1150 lines
34 KiB
Go
1150 lines
34 KiB
Go
package controllers
|
||
|
||
import (
|
||
"bufio"
|
||
"bytes"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"server/models"
|
||
"server/pkg/identity"
|
||
|
||
"github.com/beego/beego/v2/client/orm"
|
||
beego "github.com/beego/beego/v2/server/web"
|
||
)
|
||
|
||
// PlatformAiController 平台�?AI 管理(platform「AI管理」页�?
|
||
// 服务商配置存 yz_platform_ai_provider;OCR 配置暂存 yz_platform_site_setting 标签�?
|
||
type PlatformAiController struct {
|
||
beego.Controller
|
||
}
|
||
|
||
func (c *PlatformAiController) jsonErr(httpStatus, bizCode int, msg string) {
|
||
c.Ctx.Output.SetStatus(httpStatus)
|
||
c.Data["json"] = map[string]any{"code": bizCode, "msg": msg}
|
||
_ = c.ServeJSON()
|
||
}
|
||
|
||
func maskKey(key string) string {
|
||
key = strings.TrimSpace(key)
|
||
if len(key) <= 8 {
|
||
if key == "" {
|
||
return ""
|
||
}
|
||
return "***"
|
||
}
|
||
return key[:4] + "***" + key[len(key)-4:]
|
||
}
|
||
|
||
func fillProvider(m *models.PlatformAiProvider) {
|
||
if m.Models != "" {
|
||
// 兼容两种存储格式:[{"id","name"}] 对象数组 �?["model-id"] 字符串数�?
|
||
var raw []json.RawMessage
|
||
if err := json.Unmarshal([]byte(m.Models), &raw); err == nil {
|
||
for _, r := range raw {
|
||
var item models.AiModelItem
|
||
if err := json.Unmarshal(r, &item); err == nil && item.ID != "" {
|
||
m.ModelsList = append(m.ModelsList, item)
|
||
continue
|
||
}
|
||
var s string
|
||
if err := json.Unmarshal(r, &s); err == nil && s != "" {
|
||
m.ModelsList = append(m.ModelsList, models.AiModelItem{ID: s, Name: s})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
m.ApiKeyMasked = maskKey(m.ApiKey)
|
||
m.ApiKey = ""
|
||
}
|
||
|
||
// List GET /platform/ai/provider/list?provider_type=
|
||
func (c *PlatformAiController) List() {
|
||
if _, err := requirePlatform(&c.Controller); err != nil {
|
||
code := identity.Status(err)
|
||
c.jsonErr(code, code, err.Error())
|
||
return
|
||
}
|
||
ptype := strings.TrimSpace(c.GetString("provider_type"))
|
||
qs := models.Orm.QueryTable(new(models.PlatformAiProvider)).
|
||
Filter("delete_time__isnull", true)
|
||
if ptype != "" {
|
||
qs = qs.Filter("provider_type", ptype)
|
||
}
|
||
var rows []models.PlatformAiProvider
|
||
_, _ = qs.OrderBy("-is_default", "-id").All(&rows)
|
||
for i := range rows {
|
||
fillProvider(&rows[i])
|
||
}
|
||
c.Data["json"] = map[string]any{"code": 200, "msg": "success", "data": rows}
|
||
_ = c.ServeJSON()
|
||
}
|
||
|
||
type platformAiProviderPayload struct {
|
||
ID uint64 `json:"id"`
|
||
ProviderType string `json:"provider_type"`
|
||
ApiType string `json:"api_type"`
|
||
Name string `json:"name"`
|
||
ApiBase string `json:"api_base"`
|
||
ApiKey string `json:"api_key"`
|
||
Models []models.AiModelItem `json:"models"`
|
||
DefaultModel string `json:"default_model"`
|
||
IsDefault int8 `json:"is_default"`
|
||
Status int8 `json:"status"`
|
||
Remark string `json:"remark"`
|
||
}
|
||
|
||
func readProviderPayload(c *PlatformAiController) (*platformAiProviderPayload, bool) {
|
||
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
||
if err != nil {
|
||
c.jsonErr(400, 400, "参数错误")
|
||
return nil, false
|
||
}
|
||
var p platformAiProviderPayload
|
||
if err := json.Unmarshal(raw, &p); err != nil {
|
||
c.jsonErr(400, 400, "参数错误")
|
||
return nil, false
|
||
}
|
||
p.ProviderType = strings.TrimSpace(p.ProviderType)
|
||
p.ApiType = strings.TrimSpace(p.ApiType)
|
||
if p.ApiType == "" {
|
||
p.ApiType = "chat_completions"
|
||
}
|
||
switch p.ApiType {
|
||
case "chat_completions", "responses", "anthropic":
|
||
default:
|
||
c.jsonErr(400, 400, "不支持的协议类型")
|
||
return nil, false
|
||
}
|
||
p.Name = strings.TrimSpace(p.Name)
|
||
p.ApiBase = strings.TrimRight(strings.TrimSpace(p.ApiBase), "/")
|
||
if p.ProviderType == "" || p.Name == "" || p.ApiBase == "" {
|
||
c.jsonErr(400, 400, "服务商类型、名称、API基址不能为空")
|
||
return nil, false
|
||
}
|
||
return &p, true
|
||
}
|
||
|
||
func (p *platformAiProviderPayload) toModel() *models.PlatformAiProvider {
|
||
modelsJSON, _ := json.Marshal(p.Models)
|
||
return &models.PlatformAiProvider{
|
||
ID: p.ID, ProviderType: p.ProviderType, ApiType: p.ApiType, Name: p.Name, ApiBase: p.ApiBase,
|
||
ApiKey: p.ApiKey, Models: string(modelsJSON), DefaultModel: strings.TrimSpace(p.DefaultModel),
|
||
IsDefault: p.IsDefault, Status: p.Status, Remark: strings.TrimSpace(p.Remark),
|
||
}
|
||
}
|
||
|
||
// Create POST /platform/ai/provider
|
||
func (c *PlatformAiController) Create() {
|
||
if _, err := requirePlatform(&c.Controller); err != nil {
|
||
code := identity.Status(err)
|
||
c.jsonErr(code, code, err.Error())
|
||
return
|
||
}
|
||
p, ok := readProviderPayload(c)
|
||
if !ok {
|
||
return
|
||
}
|
||
m := p.toModel()
|
||
if m.Status != 0 {
|
||
m.Status = 1
|
||
}
|
||
if p.IsDefault == 1 {
|
||
_, _ = models.Orm.QueryTable(new(models.PlatformAiProvider)).
|
||
Filter("delete_time__isnull", true).Update(map[string]any{"is_default": 0})
|
||
}
|
||
id, err := models.Orm.Insert(m)
|
||
if err != nil {
|
||
c.jsonErr(500, 500, "保存失败: "+err.Error())
|
||
return
|
||
}
|
||
c.Data["json"] = map[string]any{"code": 200, "msg": "保存成功", "data": map[string]any{"id": id}}
|
||
_ = c.ServeJSON()
|
||
}
|
||
|
||
// Update PUT /platform/ai/provider/:id
|
||
func (c *PlatformAiController) Update() {
|
||
if _, err := requirePlatform(&c.Controller); err != nil {
|
||
code := identity.Status(err)
|
||
c.jsonErr(code, code, err.Error())
|
||
return
|
||
}
|
||
id, _ := c.GetInt64(":id", 0)
|
||
if id <= 0 {
|
||
c.jsonErr(400, 400, "无效ID")
|
||
return
|
||
}
|
||
p, ok := readProviderPayload(c)
|
||
if !ok {
|
||
return
|
||
}
|
||
m := p.toModel()
|
||
// api_key 传空表示不修�?
|
||
update := map[string]any{
|
||
"provider_type": m.ProviderType, "api_type": m.ApiType, "name": m.Name, "api_base": m.ApiBase,
|
||
"models": m.Models, "default_model": m.DefaultModel,
|
||
"is_default": m.IsDefault, "status": m.Status, "remark": m.Remark,
|
||
}
|
||
if strings.TrimSpace(p.ApiKey) != "" {
|
||
update["api_key"] = p.ApiKey
|
||
}
|
||
if p.IsDefault == 1 {
|
||
_, _ = models.Orm.QueryTable(new(models.PlatformAiProvider)).
|
||
Filter("delete_time__isnull", true).Filter("id__ne", id).
|
||
Update(map[string]any{"is_default": 0})
|
||
}
|
||
n, err := models.Orm.QueryTable(new(models.PlatformAiProvider)).
|
||
Filter("id", id).Filter("delete_time__isnull", true).Update(update)
|
||
if err != nil {
|
||
c.jsonErr(500, 500, "保存失败: "+err.Error())
|
||
return
|
||
}
|
||
if n == 0 {
|
||
c.jsonErr(404, 404, "配置不存在")
|
||
return
|
||
}
|
||
c.Data["json"] = map[string]any{"code": 200, "msg": "保存成功"}
|
||
_ = c.ServeJSON()
|
||
}
|
||
|
||
// Delete DELETE /platform/ai/provider/:id
|
||
func (c *PlatformAiController) Delete() {
|
||
if _, err := requirePlatform(&c.Controller); err != nil {
|
||
code := identity.Status(err)
|
||
c.jsonErr(code, code, err.Error())
|
||
return
|
||
}
|
||
id, _ := c.GetInt64(":id", 0)
|
||
n, err := models.Orm.QueryTable(new(models.PlatformAiProvider)).
|
||
Filter("id", id).Filter("delete_time__isnull", true).
|
||
Update(map[string]any{"delete_time": time.Now()})
|
||
if err != nil {
|
||
c.jsonErr(500, 500, "删除失败: "+err.Error())
|
||
return
|
||
}
|
||
if n == 0 {
|
||
c.jsonErr(404, 404, "配置不存在")
|
||
return
|
||
}
|
||
c.Data["json"] = map[string]any{"code": 200, "msg": "删除成功"}
|
||
_ = c.ServeJSON()
|
||
}
|
||
|
||
// loadProvider 取一条启用中的服务商配置(优先指�?id,否则平台默认)
|
||
func loadPlatformProvider(id uint64) (*models.PlatformAiProvider, error) {
|
||
var m models.PlatformAiProvider
|
||
qs := models.Orm.QueryTable(new(models.PlatformAiProvider)).
|
||
Filter("delete_time__isnull", true).Filter("status", 1)
|
||
if id > 0 {
|
||
err := qs.Filter("id", id).One(&m)
|
||
return &m, err
|
||
}
|
||
err := qs.Filter("is_default", 1).One(&m)
|
||
if err != nil {
|
||
err = models.Orm.QueryTable(new(models.PlatformAiProvider)).
|
||
Filter("delete_time__isnull", true).Filter("status", 1).
|
||
OrderBy("-id").One(&m)
|
||
}
|
||
return &m, err
|
||
}
|
||
|
||
// chatCompletion 按服务商协议类型分发调用,返回助手回复文�?
|
||
func chatCompletion(provider *models.PlatformAiProvider, model string, messages []map[string]string) (string, error) {
|
||
switch provider.ApiType {
|
||
case "responses":
|
||
return chatResponses(provider, model, messages)
|
||
case "anthropic":
|
||
return chatAnthropic(provider, model, messages)
|
||
default:
|
||
return chatCompletions(provider, model, messages)
|
||
}
|
||
}
|
||
|
||
func doAIRequest(req *http.Request, apiKey string) ([]byte, int, error) {
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||
client := &http.Client{Timeout: 90 * time.Second}
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
defer resp.Body.Close()
|
||
body, _ := io.ReadAll(resp.Body)
|
||
return body, resp.StatusCode, nil
|
||
}
|
||
|
||
// chatCompletions OpenAI Chat Completions:POST {api_base}/chat/completions
|
||
func chatCompletions(provider *models.PlatformAiProvider, model string, messages []map[string]string) (string, error) {
|
||
payload := map[string]any{"model": model, "messages": messages, "stream": false}
|
||
body, _ := json.Marshal(payload)
|
||
req, err := http.NewRequest("POST", provider.ApiBase+"/chat/completions", bytes.NewReader(body))
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
respBody, status, err := doAIRequest(req, provider.ApiKey)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if status != 200 {
|
||
return "", errUpstream(string(respBody))
|
||
}
|
||
var out struct {
|
||
Choices []struct {
|
||
Message struct {
|
||
Content string `json:"content"`
|
||
} `json:"message"`
|
||
} `json:"choices"`
|
||
Error *struct {
|
||
Message string `json:"message"`
|
||
} `json:"error"`
|
||
}
|
||
if err := json.Unmarshal(respBody, &out); err != nil {
|
||
return "", err
|
||
}
|
||
if out.Error != nil && out.Error.Message != "" {
|
||
return "", errUpstream(out.Error.Message)
|
||
}
|
||
if len(out.Choices) == 0 {
|
||
return "", errUpstream("上游返回为空")
|
||
}
|
||
return out.Choices[0].Message.Content, nil
|
||
}
|
||
|
||
// chatResponses OpenAI Responses:POST {api_base}/responses
|
||
// input 直接复用 messages 数组(role/content 结构兼容�?
|
||
func chatResponses(provider *models.PlatformAiProvider, model string, messages []map[string]string) (string, error) {
|
||
payload := map[string]any{"model": model, "input": messages}
|
||
body, _ := json.Marshal(payload)
|
||
req, err := http.NewRequest("POST", provider.ApiBase+"/responses", bytes.NewReader(body))
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
respBody, status, err := doAIRequest(req, provider.ApiKey)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if status != 200 {
|
||
return "", errUpstream(string(respBody))
|
||
}
|
||
var out struct {
|
||
OutputText string `json:"output_text"`
|
||
Output []struct {
|
||
Type string `json:"type"`
|
||
Content []struct {
|
||
Type string `json:"type"`
|
||
Text string `json:"text"`
|
||
} `json:"content"`
|
||
} `json:"output"`
|
||
Error *struct {
|
||
Message string `json:"message"`
|
||
} `json:"error"`
|
||
}
|
||
if err := json.Unmarshal(respBody, &out); err != nil {
|
||
return "", err
|
||
}
|
||
if out.Error != nil && out.Error.Message != "" {
|
||
return "", errUpstream(out.Error.Message)
|
||
}
|
||
if out.OutputText != "" {
|
||
return out.OutputText, nil
|
||
}
|
||
for _, item := range out.Output {
|
||
for _, c := range item.Content {
|
||
if c.Text != "" {
|
||
return c.Text, nil
|
||
}
|
||
}
|
||
}
|
||
return "", errUpstream("上游返回为空")
|
||
}
|
||
|
||
// chatAnthropic Anthropic Messages:POST {api_base}/v1/messages
|
||
// system 消息提取到顶�?system 字段;x-api-key + anthropic-version 鉴权
|
||
func chatAnthropic(provider *models.PlatformAiProvider, model string, messages []map[string]string) (string, error) {
|
||
var systemParts []string
|
||
var msgs []map[string]string
|
||
for _, m := range messages {
|
||
role := m["role"]
|
||
if role == "system" {
|
||
systemParts = append(systemParts, m["content"])
|
||
continue
|
||
}
|
||
if role == "assistant" {
|
||
role = "assistant"
|
||
} else {
|
||
role = "user"
|
||
}
|
||
msgs = append(msgs, map[string]string{"role": role, "content": m["content"]})
|
||
}
|
||
payload := map[string]any{
|
||
"model": model,
|
||
"max_tokens": 4096,
|
||
"messages": msgs,
|
||
}
|
||
if len(systemParts) > 0 {
|
||
payload["system"] = strings.Join(systemParts, "\n")
|
||
}
|
||
body, _ := json.Marshal(payload)
|
||
|
||
base := strings.TrimRight(provider.ApiBase, "/")
|
||
if !strings.HasSuffix(base, "/v1") {
|
||
base += "/v1"
|
||
}
|
||
req, err := http.NewRequest("POST", base+"/messages", bytes.NewReader(body))
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
req.Header.Set("x-api-key", provider.ApiKey)
|
||
req.Header.Set("anthropic-version", "2023-06-01")
|
||
req.Header.Set("Content-Type", "application/json")
|
||
|
||
client := &http.Client{Timeout: 90 * time.Second}
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
defer resp.Body.Close()
|
||
respBody, _ := io.ReadAll(resp.Body)
|
||
if resp.StatusCode != 200 {
|
||
return "", errUpstream(string(respBody))
|
||
}
|
||
var out struct {
|
||
Content []struct {
|
||
Type string `json:"type"`
|
||
Text string `json:"text"`
|
||
} `json:"content"`
|
||
Error *struct {
|
||
Message string `json:"message"`
|
||
} `json:"error"`
|
||
}
|
||
if err := json.Unmarshal(respBody, &out); err != nil {
|
||
return "", err
|
||
}
|
||
if out.Error != nil && out.Error.Message != "" {
|
||
return "", errUpstream(out.Error.Message)
|
||
}
|
||
for _, c := range out.Content {
|
||
if c.Text != "" {
|
||
return c.Text, nil
|
||
}
|
||
}
|
||
return "", errUpstream("上游返回为空")
|
||
}
|
||
|
||
type upstreamError string
|
||
|
||
func (e upstreamError) Error() string { return "AI服务调用失败: " + string(e) }
|
||
func errUpstream(msg string) error { return upstreamError(msg) }
|
||
|
||
// Test POST /platform/ai/provider/test { id } �?{ provider_id, model }
|
||
// 用一�?ping 验证连通�?
|
||
func (c *PlatformAiController) Test() {
|
||
if _, err := requirePlatform(&c.Controller); err != nil {
|
||
code := identity.Status(err)
|
||
c.jsonErr(code, code, err.Error())
|
||
return
|
||
}
|
||
var req struct {
|
||
ID uint64 `json:"id"`
|
||
Model string `json:"model"`
|
||
}
|
||
raw, _ := io.ReadAll(c.Ctx.Request.Body)
|
||
_ = json.Unmarshal(raw, &req)
|
||
|
||
provider, err := loadPlatformProvider(req.ID)
|
||
if err != nil {
|
||
c.jsonErr(400, 400, "服务商配置不存在或未启用")
|
||
return
|
||
}
|
||
model := strings.TrimSpace(req.Model)
|
||
if model == "" {
|
||
model = provider.DefaultModel
|
||
}
|
||
if model == "" {
|
||
var list []models.AiModelItem
|
||
_ = json.Unmarshal([]byte(provider.Models), &list)
|
||
if len(list) > 0 {
|
||
model = list[0].ID
|
||
}
|
||
}
|
||
if model == "" {
|
||
c.jsonErr(400, 400, "未配置可用模型")
|
||
return
|
||
}
|
||
start := time.Now()
|
||
content, err := chatCompletion(provider, model, []map[string]string{
|
||
{"role": "user", "content": "请只回复两个字:正常"},
|
||
})
|
||
if err != nil {
|
||
c.jsonErr(502, 502, err.Error())
|
||
return
|
||
}
|
||
c.Data["json"] = map[string]any{
|
||
"code": 200, "msg": "连接成功",
|
||
"data": map[string]any{
|
||
"latency_ms": int(time.Since(start).Milliseconds()),
|
||
"reply": content,
|
||
"model": model,
|
||
},
|
||
}
|
||
_ = c.ServeJSON()
|
||
}
|
||
|
||
// Chat POST /platform/ai/chat { provider_id, model, messages:[{role,content}] }
|
||
// Playground 对话代理:平台管理员�?AI管理页测试各服务�?
|
||
func (c *PlatformAiController) Chat() {
|
||
if _, err := requirePlatform(&c.Controller); err != nil {
|
||
code := identity.Status(err)
|
||
c.jsonErr(code, code, err.Error())
|
||
return
|
||
}
|
||
var req struct {
|
||
ProviderID uint64 `json:"provider_id"`
|
||
Model string `json:"model"`
|
||
Messages []map[string]string `json:"messages"`
|
||
}
|
||
raw, _ := io.ReadAll(c.Ctx.Request.Body)
|
||
if err := json.Unmarshal(raw, &req); err != nil || len(req.Messages) == 0 {
|
||
c.jsonErr(400, 400, "参数错误")
|
||
return
|
||
}
|
||
provider, err := loadPlatformProvider(req.ProviderID)
|
||
if err != nil {
|
||
c.jsonErr(400, 400, "服务商配置不存在或未启用")
|
||
return
|
||
}
|
||
model := strings.TrimSpace(req.Model)
|
||
if model == "" {
|
||
model = provider.DefaultModel
|
||
}
|
||
if model == "" {
|
||
c.jsonErr(400, 400, "未选择模型")
|
||
return
|
||
}
|
||
// 只保�?role/content,避免把多余字段透传给上�?
|
||
clean := make([]map[string]string, 0, len(req.Messages))
|
||
for _, m := range req.Messages {
|
||
role := strings.TrimSpace(m["role"])
|
||
if role != "user" && role != "assistant" && role != "system" {
|
||
continue
|
||
}
|
||
clean = append(clean, map[string]string{"role": role, "content": m["content"]})
|
||
}
|
||
if len(clean) == 0 {
|
||
c.jsonErr(400, 400, "消息为空")
|
||
return
|
||
}
|
||
content, err := chatCompletion(provider, model, clean)
|
||
if err != nil {
|
||
c.jsonErr(502, 502, err.Error())
|
||
return
|
||
}
|
||
c.Data["json"] = map[string]any{
|
||
"code": 200, "msg": "success",
|
||
"data": map[string]any{"content": content, "model": model},
|
||
}
|
||
_ = c.ServeJSON()
|
||
}
|
||
|
||
// OCR 配置标签(存 yz_platform_site_setting,OCR 识别实现后续接入�?
|
||
var ocrConfigTags = []string{"ocr_enabled", "ocr_provider_id", "ocr_model", "ocr_prompt"}
|
||
|
||
// OcrConfig GET /platform/ai/ocrConfig
|
||
func (c *PlatformAiController) OcrConfig() {
|
||
if _, err := requirePlatform(&c.Controller); err != nil {
|
||
code := identity.Status(err)
|
||
c.jsonErr(code, code, err.Error())
|
||
return
|
||
}
|
||
all := models.GetPlatformSiteTagMap()
|
||
data := make(map[string]string, len(ocrConfigTags))
|
||
for _, tag := range ocrConfigTags {
|
||
data[tag] = all[tag]
|
||
}
|
||
c.Data["json"] = map[string]any{"code": 200, "msg": "success", "data": data}
|
||
_ = c.ServeJSON()
|
||
}
|
||
|
||
// SaveOcrConfig POST /platform/ai/ocrConfig { ocr_enabled, ocr_provider_id, ocr_model, ocr_prompt }
|
||
func (c *PlatformAiController) SaveOcrConfig() {
|
||
if _, err := requirePlatform(&c.Controller); err != nil {
|
||
code := identity.Status(err)
|
||
c.jsonErr(code, code, err.Error())
|
||
return
|
||
}
|
||
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
||
if err != nil {
|
||
c.jsonErr(400, 400, "参数错误")
|
||
return
|
||
}
|
||
var payload map[string]string
|
||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||
c.jsonErr(400, 400, "参数错误")
|
||
return
|
||
}
|
||
values := make(map[string]string, len(ocrConfigTags))
|
||
for _, tag := range ocrConfigTags {
|
||
if v, ok := payload[tag]; ok {
|
||
values[tag] = strings.TrimSpace(v)
|
||
}
|
||
}
|
||
if len(values) == 0 {
|
||
c.jsonErr(400, 400, "参数错误")
|
||
return
|
||
}
|
||
if err := models.SavePlatformSiteSettings(values); err != nil {
|
||
c.jsonErr(500, 500, "保存失败: "+err.Error())
|
||
return
|
||
}
|
||
c.Data["json"] = map[string]any{"code": 200, "msg": "保存成功"}
|
||
_ = c.ServeJSON()
|
||
}
|
||
|
||
// RecognizeOcr POST /platform/ai/ocr/recognize —�?OCR 识别占位接口
|
||
// 具体实现(视觉模�?/ 专用 OCR 服务)后续接入;当前返回配置状态供前端联调�?
|
||
func (c *PlatformAiController) RecognizeOcr() {
|
||
if _, err := requirePlatform(&c.Controller); err != nil {
|
||
code := identity.Status(err)
|
||
c.jsonErr(code, code, err.Error())
|
||
return
|
||
}
|
||
all := models.GetPlatformSiteTagMap()
|
||
enabled := strings.TrimSpace(all["ocr_enabled"]) == "1"
|
||
c.Data["json"] = map[string]any{
|
||
"code": 200,
|
||
"msg": "success",
|
||
"data": map[string]any{
|
||
"enabled": enabled,
|
||
"provider_id": all["ocr_provider_id"],
|
||
"model": all["ocr_model"],
|
||
"recognized": false,
|
||
"message": "OCR 识别服务待接入,配置已保存",
|
||
"placeholder": true,
|
||
},
|
||
}
|
||
_ = c.ServeJSON()
|
||
}
|
||
|
||
// ==================== 流式对话(Playground 打字机效果)====================
|
||
|
||
// 归一�?SSE 事件:{"t":"delta"|"think"|"done"|"error","c":"..."}
|
||
func writeStreamEvent(w http.ResponseWriter, flusher http.Flusher, ev map[string]any) {
|
||
b, _ := json.Marshal(ev)
|
||
fmt.Fprintf(w, "data: %s\n\n", b)
|
||
if flusher != nil {
|
||
flusher.Flush()
|
||
}
|
||
}
|
||
|
||
// streamHTTPRequest 构造带 stream:true 的上游请求,返回 *http.Request 与鉴权头已设置的 client 请求
|
||
// messages 的 content 支持字符串或多模态内容块数组(附件直传大模型,不落库)
|
||
func buildStreamRequest(provider *models.PlatformAiProvider, model string, messages []map[string]any) (*http.Request, error) {
|
||
var url string
|
||
var payload map[string]any
|
||
|
||
switch provider.ApiType {
|
||
case "responses":
|
||
url = provider.ApiBase + "/responses"
|
||
payload = map[string]any{"model": model, "input": messages, "stream": true}
|
||
case "anthropic":
|
||
base := strings.TrimRight(provider.ApiBase, "/")
|
||
if !strings.HasSuffix(base, "/v1") {
|
||
base += "/v1"
|
||
}
|
||
url = base + "/messages"
|
||
var systemParts []string
|
||
var msgs []map[string]any
|
||
for _, m := range messages {
|
||
role, _ := m["role"].(string)
|
||
if role == "system" {
|
||
if s, ok := m["content"].(string); ok {
|
||
systemParts = append(systemParts, s)
|
||
}
|
||
continue
|
||
}
|
||
if role != "assistant" {
|
||
role = "user"
|
||
}
|
||
msgs = append(msgs, map[string]any{"role": role, "content": anthropicContent(m["content"])})
|
||
}
|
||
payload = map[string]any{"model": model, "max_tokens": 4096, "messages": msgs, "stream": true}
|
||
if len(systemParts) > 0 {
|
||
payload["system"] = strings.Join(systemParts, "\n")
|
||
}
|
||
default:
|
||
url = provider.ApiBase + "/chat/completions"
|
||
payload = map[string]any{"model": model, "messages": messages, "stream": true}
|
||
}
|
||
|
||
body, _ := json.Marshal(payload)
|
||
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
if provider.ApiType == "anthropic" {
|
||
req.Header.Set("x-api-key", provider.ApiKey)
|
||
req.Header.Set("anthropic-version", "2023-06-01")
|
||
} else {
|
||
req.Header.Set("Authorization", "Bearer "+provider.ApiKey)
|
||
}
|
||
return req, nil
|
||
}
|
||
|
||
// normalizeStreamData 把上�?SSE data 负载转成归一化事件列�?
|
||
// 返回 (events, isDone, isError)
|
||
// anthropicContent 把 OpenAI 风格的 message content 转成 Anthropic content blocks
|
||
// 字符串 → [{type:text}];数组 → text 块 + base64 图片块(data URL 拆分)
|
||
func anthropicContent(content any) []map[string]any {
|
||
if s, ok := content.(string); ok {
|
||
return []map[string]any{{"type": "text", "text": s}}
|
||
}
|
||
arr, ok := content.([]any)
|
||
if !ok {
|
||
return []map[string]any{{"type": "text", "text": ""}}
|
||
}
|
||
var blocks []map[string]any
|
||
for _, item := range arr {
|
||
m, ok := item.(map[string]any)
|
||
if !ok {
|
||
continue
|
||
}
|
||
switch m["type"] {
|
||
case "text":
|
||
if t, ok := m["text"].(string); ok {
|
||
blocks = append(blocks, map[string]any{"type": "text", "text": t})
|
||
}
|
||
case "image_url":
|
||
if iu, ok := m["image_url"].(map[string]any); ok {
|
||
if u, ok := iu["url"].(string); ok {
|
||
if media, data, ok := parseDataURL(u); ok {
|
||
blocks = append(blocks, map[string]any{
|
||
"type": "image",
|
||
"source": map[string]any{
|
||
"type": "base64",
|
||
"media_type": media,
|
||
"data": data,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if len(blocks) == 0 {
|
||
blocks = append(blocks, map[string]any{"type": "text", "text": ""})
|
||
}
|
||
return blocks
|
||
}
|
||
|
||
// parseDataURL 解析 data:[mediatype];base64,xxxx
|
||
func parseDataURL(u string) (media, data string, ok bool) {
|
||
if !strings.HasPrefix(u, "data:") {
|
||
return "", "", false
|
||
}
|
||
rest := strings.TrimPrefix(u, "data:")
|
||
idx := strings.Index(rest, ";base64,")
|
||
if idx < 0 {
|
||
return "", "", false
|
||
}
|
||
return rest[:idx], rest[idx+len(";base64,"):], true
|
||
}
|
||
|
||
func normalizeStreamData(apiType string, data string) ([]map[string]any, bool) {
|
||
data = strings.TrimSpace(data)
|
||
if data == "" || data == "[DONE]" {
|
||
return nil, data == "[DONE]"
|
||
}
|
||
var raw map[string]any
|
||
if err := json.Unmarshal([]byte(data), &raw); err != nil {
|
||
return nil, false
|
||
}
|
||
|
||
switch apiType {
|
||
case "responses":
|
||
typ, _ := raw["type"].(string)
|
||
switch typ {
|
||
case "response.output_text.delta":
|
||
if d, ok := raw["delta"].(string); ok && d != "" {
|
||
return []map[string]any{{"t": "delta", "c": d}}, false
|
||
}
|
||
case "response.reasoning_summary_text.delta", "response.reasoning_text.delta":
|
||
if d, ok := raw["delta"].(string); ok && d != "" {
|
||
return []map[string]any{{"t": "think", "c": d}}, false
|
||
}
|
||
case "response.completed":
|
||
return nil, true
|
||
case "response.failed", "error":
|
||
msg := "上游返回错误"
|
||
if e, ok := raw["error"].(map[string]any); ok {
|
||
if m, ok := e["message"].(string); ok {
|
||
msg = m
|
||
}
|
||
}
|
||
if r, ok := raw["response"].(map[string]any); ok {
|
||
if e, ok := r["error"].(map[string]any); ok {
|
||
if m, ok := e["message"].(string); ok {
|
||
msg = m
|
||
}
|
||
}
|
||
}
|
||
return []map[string]any{{"t": "error", "c": msg}}, true
|
||
}
|
||
case "anthropic":
|
||
typ, _ := raw["type"].(string)
|
||
switch typ {
|
||
case "content_block_delta":
|
||
if d, ok := raw["delta"].(map[string]any); ok {
|
||
dt, _ := d["type"].(string)
|
||
if dt == "text_delta" {
|
||
if t, ok := d["text"].(string); ok && t != "" {
|
||
return []map[string]any{{"t": "delta", "c": t}}, false
|
||
}
|
||
}
|
||
if dt == "thinking_delta" {
|
||
if t, ok := d["thinking"].(string); ok && t != "" {
|
||
return []map[string]any{{"t": "think", "c": t}}, false
|
||
}
|
||
}
|
||
}
|
||
case "message_stop":
|
||
return nil, true
|
||
case "error":
|
||
msg := "上游返回错误"
|
||
if e, ok := raw["error"].(map[string]any); ok {
|
||
if m, ok := e["message"].(string); ok {
|
||
msg = m
|
||
}
|
||
}
|
||
return []map[string]any{{"t": "error", "c": msg}}, true
|
||
}
|
||
default: // chat_completions
|
||
choices, _ := raw["choices"].([]any)
|
||
if len(choices) > 0 {
|
||
if ch, ok := choices[0].(map[string]any); ok {
|
||
if delta, ok := ch["delta"].(map[string]any); ok {
|
||
var evs []map[string]any
|
||
if t, ok := delta["reasoning_content"].(string); ok && t != "" {
|
||
evs = append(evs, map[string]any{"t": "think", "c": t})
|
||
}
|
||
if t, ok := delta["content"].(string); ok && t != "" {
|
||
evs = append(evs, map[string]any{"t": "delta", "c": t})
|
||
}
|
||
return evs, false
|
||
}
|
||
}
|
||
}
|
||
if e, ok := raw["error"].(map[string]any); ok {
|
||
if m, ok := e["message"].(string); ok {
|
||
return []map[string]any{{"t": "error", "c": m}}, true
|
||
}
|
||
}
|
||
}
|
||
return nil, false
|
||
}
|
||
|
||
// ChatStream POST /platform/ai/chat/stream
|
||
// SSE 代理:把三种上游协议的流式响应归一化为 {"t":"delta|think|done|error","c":"..."} 转发给前�?
|
||
func (c *PlatformAiController) ChatStream() {
|
||
defer c.StopRun() // 阻止 Beego 在 handler 返回后继续模板渲染
|
||
if _, err := requirePlatform(&c.Controller); err != nil {
|
||
code := identity.Status(err)
|
||
c.jsonErr(code, code, err.Error())
|
||
return
|
||
}
|
||
var req struct {
|
||
ProviderID uint64 `json:"provider_id"`
|
||
Model string `json:"model"`
|
||
Messages []map[string]any `json:"messages"`
|
||
SessionID uint64 `json:"session_id"`
|
||
}
|
||
raw, _ := io.ReadAll(c.Ctx.Request.Body)
|
||
if err := json.Unmarshal(raw, &req); err != nil || len(req.Messages) == 0 {
|
||
c.jsonErr(400, 400, "参数错误")
|
||
return
|
||
}
|
||
// 落库用户消息(最后一条)
|
||
if req.SessionID > 0 {
|
||
persistUserMessage(req.SessionID, req.Messages[len(req.Messages)-1]["content"])
|
||
}
|
||
provider, err := loadPlatformProvider(req.ProviderID)
|
||
if err != nil {
|
||
c.jsonErr(400, 400, "服务商配置不存在或未启用")
|
||
return
|
||
}
|
||
model := strings.TrimSpace(req.Model)
|
||
if model == "" {
|
||
model = provider.DefaultModel
|
||
}
|
||
if model == "" {
|
||
c.jsonErr(400, 400, "未选择模型")
|
||
return
|
||
}
|
||
clean := make([]map[string]any, 0, len(req.Messages))
|
||
for _, m := range req.Messages {
|
||
role, _ := m["role"].(string)
|
||
role = strings.TrimSpace(role)
|
||
if role != "user" && role != "assistant" && role != "system" {
|
||
continue
|
||
}
|
||
clean = append(clean, map[string]any{"role": role, "content": m["content"]})
|
||
}
|
||
if len(clean) == 0 {
|
||
c.jsonErr(400, 400, "消息为空")
|
||
return
|
||
}
|
||
|
||
upReq, err := buildStreamRequest(provider, model, clean)
|
||
if err != nil {
|
||
c.jsonErr(500, 500, "构建请求失败")
|
||
return
|
||
}
|
||
client := &http.Client{Timeout: 300 * time.Second}
|
||
resp, err := client.Do(upReq)
|
||
if err != nil {
|
||
c.jsonErr(502, 502, "AI服务调用失败: "+err.Error())
|
||
return
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
// 切换为 SSE 响应(Beego ResponseWriter 是包装类型,取底层 http.ResponseWriter)
|
||
rw := c.Ctx.ResponseWriter.ResponseWriter
|
||
rw.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
|
||
rw.Header().Set("Cache-Control", "no-cache")
|
||
rw.Header().Set("Connection", "keep-alive")
|
||
rw.Header().Set("X-Accel-Buffering", "no")
|
||
rw.WriteHeader(200)
|
||
flusher, _ := rw.(http.Flusher)
|
||
|
||
if resp.StatusCode != 200 {
|
||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||
writeStreamEvent(rw, flusher, map[string]any{"t": "error", "c": "AI服务调用失败: " + string(body)})
|
||
writeStreamEvent(rw, flusher, map[string]any{"t": "done"})
|
||
return
|
||
}
|
||
|
||
scanner := bufio.NewScanner(resp.Body)
|
||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||
var accContent, accThink strings.Builder
|
||
for scanner.Scan() {
|
||
line := scanner.Text()
|
||
if !strings.HasPrefix(line, "data:") {
|
||
continue
|
||
}
|
||
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||
events, isDone := normalizeStreamData(provider.ApiType, payload)
|
||
for _, ev := range events {
|
||
writeStreamEvent(rw, flusher, ev)
|
||
if ev["t"] == "delta" {
|
||
if s, ok := ev["c"].(string); ok {
|
||
accContent.WriteString(s)
|
||
}
|
||
} else if ev["t"] == "think" {
|
||
if s, ok := ev["c"].(string); ok {
|
||
accThink.WriteString(s)
|
||
}
|
||
}
|
||
}
|
||
if isDone {
|
||
break
|
||
}
|
||
}
|
||
writeStreamEvent(rw, flusher, map[string]any{"t": "done"})
|
||
persistStreamResult(req.SessionID, accContent.String(), accThink.String())
|
||
}
|
||
|
||
// persistStreamResult 流式结束后落库:assistant 消息 + 会话更新时间/标题
|
||
func persistStreamResult(sessionID uint64, content, think string) {
|
||
if sessionID == 0 {
|
||
return
|
||
}
|
||
var last models.PlatformAiChatMessage
|
||
_ = models.Orm.QueryTable(new(models.PlatformAiChatMessage)).
|
||
Filter("session_id", sessionID).OrderBy("-seq").Limit(1).One(&last, "seq")
|
||
_, _ = models.Orm.Insert(&models.PlatformAiChatMessage{
|
||
SessionID: sessionID,
|
||
Role: "assistant",
|
||
Content: content,
|
||
Think: think,
|
||
Seq: last.Seq + 1,
|
||
CreateTime: time.Now(),
|
||
})
|
||
_, _ = models.Orm.QueryTable(new(models.PlatformAiChatSession)).
|
||
Filter("id", sessionID).
|
||
Update(orm.Params{"update_time": time.Now()})
|
||
}
|
||
|
||
// persistUserMessage 流式开始前落库 user 消息(content 存原始 JSON,含附件)
|
||
func persistUserMessage(sessionID uint64, content any) {
|
||
if sessionID == 0 {
|
||
return
|
||
}
|
||
b, _ := json.Marshal(content)
|
||
var last models.PlatformAiChatMessage
|
||
_ = models.Orm.QueryTable(new(models.PlatformAiChatMessage)).
|
||
Filter("session_id", sessionID).OrderBy("-seq").Limit(1).One(&last, "seq")
|
||
_, _ = models.Orm.Insert(&models.PlatformAiChatMessage{
|
||
SessionID: sessionID,
|
||
Role: "user",
|
||
Content: string(b),
|
||
Seq: last.Seq + 1,
|
||
CreateTime: time.Now(),
|
||
})
|
||
// 首条消息时回填会话标题
|
||
var cnt int64
|
||
_, _ = models.Orm.QueryTable(new(models.PlatformAiChatMessage)).
|
||
Filter("session_id", sessionID).Count()
|
||
if cnt == 1 {
|
||
if s := userContentText(content); s != "" {
|
||
if r := []rune(s); len(r) > 30 {
|
||
s = string(r[:30]) + "..."
|
||
}
|
||
_, _ = models.Orm.QueryTable(new(models.PlatformAiChatSession)).
|
||
Filter("id", sessionID).Update(orm.Params{"title": s})
|
||
}
|
||
}
|
||
_, _ = models.Orm.QueryTable(new(models.PlatformAiChatSession)).
|
||
Filter("id", sessionID).
|
||
Update(orm.Params{"update_time": time.Now()})
|
||
}
|
||
|
||
// userContentText 从 user content(字符串或多模态数组)提取纯文本用于标题
|
||
func userContentText(content any) string {
|
||
switch v := content.(type) {
|
||
case string:
|
||
return v
|
||
case []any:
|
||
var sb strings.Builder
|
||
for _, item := range v {
|
||
if m, ok := item.(map[string]any); ok && m["type"] == "text" {
|
||
if t, ok := m["text"].(string); ok {
|
||
sb.WriteString(t)
|
||
}
|
||
}
|
||
}
|
||
return sb.String()
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// ==================== 会话管理 ====================
|
||
|
||
// ListChatSessions GET /platform/ai/chat/sessions
|
||
func (c *PlatformAiController) ListChatSessions() {
|
||
if _, err := requirePlatform(&c.Controller); err != nil {
|
||
c.jsonErr(403, 403, "无权限")
|
||
return
|
||
}
|
||
var list []models.PlatformAiChatSession
|
||
_, err := models.Orm.QueryTable(new(models.PlatformAiChatSession)).
|
||
Filter("delete_time__isnull", true).
|
||
OrderBy("-is_top", "-update_time").Limit(100).All(&list)
|
||
if err != nil {
|
||
c.jsonErr(500, 500, "查询失败")
|
||
return
|
||
}
|
||
c.Data["json"] = map[string]any{"code": 200, "msg": "success", "data": list}
|
||
_ = c.ServeJSON()
|
||
}
|
||
|
||
// CreateChatSession POST /platform/ai/chat/session {provider_id, model}
|
||
func (c *PlatformAiController) CreateChatSession() {
|
||
if _, err := requirePlatform(&c.Controller); err != nil {
|
||
c.jsonErr(403, 403, "无权限")
|
||
return
|
||
}
|
||
var req struct {
|
||
ProviderID uint64 `json:"provider_id"`
|
||
Model string `json:"model"`
|
||
}
|
||
_ = json.Unmarshal(c.Ctx.Input.RequestBody, &req)
|
||
s := models.PlatformAiChatSession{
|
||
ProviderID: req.ProviderID,
|
||
Model: req.Model,
|
||
CreateTime: time.Now(),
|
||
UpdateTime: time.Now(),
|
||
}
|
||
id, err := models.Orm.Insert(&s)
|
||
if err != nil {
|
||
c.jsonErr(500, 500, "创建失败")
|
||
return
|
||
}
|
||
s.ID = uint64(id)
|
||
c.Data["json"] = map[string]any{"code": 200, "msg": "success", "data": s}
|
||
_ = c.ServeJSON()
|
||
}
|
||
|
||
// GetChatSessionMessages GET /platform/ai/chat/session/:id/messages
|
||
func (c *PlatformAiController) GetChatSessionMessages() {
|
||
if _, err := requirePlatform(&c.Controller); err != nil {
|
||
c.jsonErr(403, 403, "无权限")
|
||
return
|
||
}
|
||
id, _ := c.GetInt64(":id")
|
||
var msgs []models.PlatformAiChatMessage
|
||
_, _ = models.Orm.QueryTable(new(models.PlatformAiChatMessage)).
|
||
Filter("session_id", id).OrderBy("seq").All(&msgs)
|
||
c.Data["json"] = map[string]any{"code": 200, "msg": "success", "data": msgs}
|
||
_ = c.ServeJSON()
|
||
}
|
||
|
||
// UpdateChatSession PUT /platform/ai/chat/session/:id {title?, is_top?}
|
||
func (c *PlatformAiController) UpdateChatSession() {
|
||
if _, err := requirePlatform(&c.Controller); err != nil {
|
||
c.jsonErr(403, 403, "无权限")
|
||
return
|
||
}
|
||
id, _ := c.GetInt64(":id")
|
||
var req struct {
|
||
Title *string `json:"title"`
|
||
IsTop *int8 `json:"is_top"`
|
||
}
|
||
_ = json.Unmarshal(c.Ctx.Input.RequestBody, &req)
|
||
params := orm.Params{}
|
||
if req.Title != nil {
|
||
t := strings.TrimSpace(*req.Title)
|
||
if t == "" {
|
||
t = "新对话"
|
||
}
|
||
if r := []rune(t); len(r) > 100 {
|
||
t = string(r[:100])
|
||
}
|
||
params["title"] = t
|
||
}
|
||
if req.IsTop != nil {
|
||
params["is_top"] = *req.IsTop
|
||
}
|
||
if len(params) == 0 {
|
||
c.jsonErr(400, 400, "无更新内容")
|
||
return
|
||
}
|
||
_, err := models.Orm.QueryTable(new(models.PlatformAiChatSession)).
|
||
Filter("id", id).Update(params)
|
||
if err != nil {
|
||
c.jsonErr(500, 500, "更新失败")
|
||
return
|
||
}
|
||
c.Data["json"] = map[string]any{"code": 200, "msg": "success"}
|
||
_ = c.ServeJSON()
|
||
}
|
||
|
||
// DeleteChatSession DELETE /platform/ai/chat/session/:id
|
||
func (c *PlatformAiController) DeleteChatSession() {
|
||
if _, err := requirePlatform(&c.Controller); err != nil {
|
||
c.jsonErr(403, 403, "无权限")
|
||
return
|
||
}
|
||
id, _ := c.GetInt64(":id")
|
||
now := time.Now()
|
||
_, _ = models.Orm.QueryTable(new(models.PlatformAiChatSession)).
|
||
Filter("id", id).Update(orm.Params{"delete_time": now})
|
||
_, _ = models.Orm.QueryTable(new(models.PlatformAiChatMessage)).
|
||
Filter("session_id", id).Delete()
|
||
c.Data["json"] = map[string]any{"code": 200, "msg": "success"}
|
||
_ = c.ServeJSON()
|
||
}
|