diff --git a/backend/src/api/ai.js b/backend/src/api/ai.js new file mode 100644 index 0000000..9b5078c --- /dev/null +++ b/backend/src/api/ai.js @@ -0,0 +1,142 @@ +import request from '@/utils/request' + +// AI接入配置 +export function getAiProviderList() { + return request({ + url: '/backend/ai/provider/list', + method: 'get' + }) +} + +export function createAiProvider(data) { + return request({ + url: '/backend/ai/provider', + method: 'post', + data + }) +} + +export function updateAiProvider(id, data) { + return request({ + url: `/backend/ai/provider/${id}`, + method: 'put', + data + }) +} + +export function deleteAiProvider(id) { + return request({ + url: `/backend/ai/provider/${id}`, + method: 'delete' + }) +} + +export function testAiProvider(data) { + return request({ + url: '/backend/ai/provider/test', + method: 'post', + data + }) +} + +// 聊天会话 +export function getChatSessionList() { + return request({ + url: '/backend/ai/chat/session/list', + method: 'get' + }) +} + +export function createChatSession(data) { + return request({ + url: '/backend/ai/chat/session', + method: 'post', + data + }) +} + +export function updateChatSession(id, data) { + return request({ + url: `/backend/ai/chat/session/${id}`, + method: 'put', + data + }) +} + +export function deleteChatSession(id) { + return request({ + url: `/backend/ai/chat/session/${id}`, + method: 'delete' + }) +} + +// 聊天消息 +export function getChatMessageList(sessionId) { + return request({ + url: '/backend/ai/chat/message/list', + method: 'get', + params: { session_id: sessionId } + }) +} + +export function sendChatMessage(data) { + return request({ + url: '/backend/ai/chat/send', + method: 'post', + data + }) +} + +export function deleteChatMessage(id) { + return request({ + url: `/backend/ai/chat/message/${id}`, + method: 'delete' + }) +} + +// 角色预设 +export function getAiPresetList() { + return request({ + url: '/backend/ai/preset/list', + method: 'get' + }) +} + +export function createAiPreset(data) { + return request({ + url: '/backend/ai/preset', + method: 'post', + data + }) +} + +export function updateAiPreset(id, data) { + return request({ + url: `/backend/ai/preset/${id}`, + method: 'put', + data + }) +} + +export function deleteAiPreset(id) { + return request({ + url: `/backend/ai/preset/${id}`, + method: 'delete' + }) +} + +export function setAiPresetDefault(id) { + return request({ + url: `/backend/ai/preset/${id}/default`, + method: 'put' + }) +} + +// 智能生成公司信息 +export function smartGenerateCompany(data) { + return request({ + url: '/backend/ai/smart-generate/company', + method: 'post', + data + }) +} diff --git a/backend/src/router/index.js b/backend/src/router/index.js index 83376f3..7d26fb4 100644 --- a/backend/src/router/index.js +++ b/backend/src/router/index.js @@ -75,6 +75,12 @@ const staticMainChildren = [ component: () => import("@/views/user/notifyConfig.vue"), meta: { requiresAuth: true, title: "通知设置" } }, + { + path: "/apps/ai", + name: "aiSquare", + component: () => import("@/views/apps/ai/index.vue"), + meta: { requiresAuth: true, title: "AI广场" } + }, // 兼容拼写错误的路径重定向 { path: "/apps/erp/dashborad", diff --git a/backend/src/views/apps/ai/aiplayground/index.vue b/backend/src/views/apps/ai/aiplayground/index.vue new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/views/apps/ai/components/chatSidebar.vue b/backend/src/views/apps/ai/components/chatSidebar.vue new file mode 100644 index 0000000..503ab98 --- /dev/null +++ b/backend/src/views/apps/ai/components/chatSidebar.vue @@ -0,0 +1,268 @@ + + + + + diff --git a/backend/src/views/apps/ai/components/chatWindow.vue b/backend/src/views/apps/ai/components/chatWindow.vue new file mode 100644 index 0000000..cc717ae --- /dev/null +++ b/backend/src/views/apps/ai/components/chatWindow.vue @@ -0,0 +1,746 @@ + + + + + diff --git a/backend/src/views/apps/ai/components/presetDialog.vue b/backend/src/views/apps/ai/components/presetDialog.vue new file mode 100644 index 0000000..4713a37 --- /dev/null +++ b/backend/src/views/apps/ai/components/presetDialog.vue @@ -0,0 +1,217 @@ + + + + + diff --git a/backend/src/views/apps/ai/components/providerDialog.vue b/backend/src/views/apps/ai/components/providerDialog.vue new file mode 100644 index 0000000..c8cb690 --- /dev/null +++ b/backend/src/views/apps/ai/components/providerDialog.vue @@ -0,0 +1,519 @@ + + + + + diff --git a/backend/src/views/apps/ai/index.vue b/backend/src/views/apps/ai/index.vue new file mode 100644 index 0000000..fe76784 --- /dev/null +++ b/backend/src/views/apps/ai/index.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/backend/src/views/apps/ai/settings/index.vue b/backend/src/views/apps/ai/settings/index.vue new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/views/apps/erp/accountSets/components/edit.vue b/backend/src/views/apps/erp/accountSets/components/edit.vue deleted file mode 100644 index 224b0ec..0000000 --- a/backend/src/views/apps/erp/accountSets/components/edit.vue +++ /dev/null @@ -1,184 +0,0 @@ - - - diff --git a/backend/src/views/apps/erp/accountSets/index.vue b/backend/src/views/apps/erp/accountSets/index.vue deleted file mode 100644 index 7cf6efa..0000000 --- a/backend/src/views/apps/erp/accountSets/index.vue +++ /dev/null @@ -1,253 +0,0 @@ - - - - - diff --git a/backend/src/views/apps/erp/components/smartAddCompany.vue b/backend/src/views/apps/erp/components/smartAddCompany.vue new file mode 100644 index 0000000..bbd214c --- /dev/null +++ b/backend/src/views/apps/erp/components/smartAddCompany.vue @@ -0,0 +1,351 @@ + + + + + diff --git a/backend/src/views/apps/erp/customer/components/edit.vue b/backend/src/views/apps/erp/customer/components/edit.vue index c051d1a..64d637d 100644 --- a/backend/src/views/apps/erp/customer/components/edit.vue +++ b/backend/src/views/apps/erp/customer/components/edit.vue @@ -30,22 +30,34 @@ - + - + - + + - + @@ -58,16 +70,6 @@ - - - - - - - - - - @@ -95,12 +97,19 @@ - + - + @@ -114,13 +123,31 @@ - + + + + + + + + + + + - + - + - + @@ -166,22 +202,34 @@ - + - + - + - + @@ -189,12 +237,18 @@ - + - + @@ -221,16 +275,27 @@
新增客户 + 智能添加
@@ -27,7 +28,8 @@ - + +
@@ -192,21 +194,29 @@ :company-id="currentCompany?.id" :company-name="currentCompany?.customer_name" /> + + - - diff --git a/backend/src/views/apps/erp/customers/index.vue b/backend/src/views/apps/erp/customers/index.vue deleted file mode 100644 index 34e42d3..0000000 --- a/backend/src/views/apps/erp/customers/index.vue +++ /dev/null @@ -1,473 +0,0 @@ - - - - - diff --git a/backend/src/views/apps/erp/supplier/index.vue b/backend/src/views/apps/erp/supplier/index.vue index c2631c8..9726b37 100644 --- a/backend/src/views/apps/erp/supplier/index.vue +++ b/backend/src/views/apps/erp/supplier/index.vue @@ -7,6 +7,7 @@
新增供应商 + 智能添加
@@ -192,21 +193,29 @@ :company-id="currentCompany?.id" :company-name="currentCompany?.supplier_name" /> + + - - diff --git a/backend/src/views/apps/erp/suppliers/index.vue b/backend/src/views/apps/erp/suppliers/index.vue deleted file mode 100644 index c2631c8..0000000 --- a/backend/src/views/apps/erp/suppliers/index.vue +++ /dev/null @@ -1,473 +0,0 @@ - - - - - diff --git a/go/controllers/backend_ai_chat.go b/go/controllers/backend_ai_chat.go new file mode 100644 index 0000000..2822c46 --- /dev/null +++ b/go/controllers/backend_ai_chat.go @@ -0,0 +1,794 @@ +package controllers + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "server/models" + "server/pkg/jwtutil" + + beego "github.com/beego/beego/v2/server/web" +) + +// BackendAiChatController AI聊天消息控制器 +type BackendAiChatController struct { + beego.Controller +} + +func (c *BackendAiChatController) chatClaims() (*jwtutil.Claims, error) { + auth := c.Ctx.Request.Header.Get("Authorization") + if auth == "" { + return nil, fmt.Errorf("未登录") + } + parts := strings.SplitN(auth, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + return nil, fmt.Errorf("认证信息格式错误") + } + claims, err := jwtutil.ParseToken(parts[1]) + if err != nil { + return nil, fmt.Errorf("无效的token") + } + if claims.UserType != "backend" { + return nil, fmt.Errorf("无权访问") + } + return claims, nil +} + +func (c *BackendAiChatController) chatJsonErr(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +func (c *BackendAiChatController) chatOk(data interface{}) { + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data} + _ = c.ServeJSON() +} + +type chatSendPayload struct { + SessionID uint64 `json:"session_id"` + Content string `json:"content"` + ProviderID uint64 `json:"provider_id"` + Model string `json:"model"` +} + +type openaiMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type openaiRequest struct { + Model string `json:"model"` + Messages []openaiMessage `json:"messages"` +} + +type openaiResponse struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + Error *struct { + Message string `json:"message"` + } `json:"error"` +} + +type anthropicRequest struct { + Model string `json:"model"` + MaxTokens int `json:"max_tokens"` + System string `json:"system,omitempty"` + Messages []openaiMessage `json:"messages"` +} + +type anthropicResponse struct { + Content []struct { + Text string `json:"text"` + } `json:"content"` + Error *struct { + Message string `json:"message"` + } `json:"error"` +} + +// MessageList GET /backend/ai/chat/message/list?session_id=xxx +func (c *BackendAiChatController) MessageList() { + claims, err := c.chatClaims() + if err != nil { + c.chatJsonErr(401, 401, err.Error()) + return + } + + sessionIDStr := strings.TrimSpace(c.GetString("session_id")) + if sessionIDStr == "" { + c.chatJsonErr(400, 400, "缺少会话ID") + return + } + sessionID, err := strconv.ParseUint(sessionIDStr, 10, 64) + if err != nil { + c.chatJsonErr(400, 400, "会话ID格式错误") + return + } + + // 验证会话归属 + session := models.BackendAiChatSession{ID: sessionID} + if err := models.Orm.Read(&session); err != nil { + c.chatJsonErr(404, 404, "会话不存在") + return + } + if session.TenantID != fmt.Sprintf("%d", claims.TenantId) || session.UserID != uint64(claims.UserID) { + c.chatJsonErr(403, 403, "无权访问") + return + } + + var list []models.BackendAiChatMessage + _, err = models.Orm.QueryTable(new(models.BackendAiChatMessage)). + Filter("session_id", sessionID). + OrderBy("id"). + All(&list) + if err != nil { + c.chatJsonErr(500, 500, "查询失败: "+err.Error()) + return + } + + c.chatOk(map[string]interface{}{"list": list, "title": session.Title}) +} + +// DeleteMessage DELETE /backend/ai/chat/message/:id +func (c *BackendAiChatController) DeleteMessage() { + claims, err := c.chatClaims() + if err != nil { + c.chatJsonErr(401, 401, err.Error()) + return + } + + idStr := c.Ctx.Input.Param(":id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + c.chatJsonErr(400, 400, "消息ID格式错误") + return + } + + msg := models.BackendAiChatMessage{ID: id} + if err := models.Orm.Read(&msg); err != nil { + c.chatJsonErr(404, 404, "消息不存在") + return + } + + // 验证会话归属 + session := models.BackendAiChatSession{ID: msg.SessionID} + if err := models.Orm.Read(&session); err != nil { + c.chatJsonErr(404, 404, "会话不存在") + return + } + if session.TenantID != fmt.Sprintf("%d", claims.TenantId) || session.UserID != uint64(claims.UserID) { + c.chatJsonErr(403, 403, "无权操作") + return + } + + if _, err := models.Orm.Delete(&msg); err != nil { + c.chatJsonErr(500, 500, "删除失败: "+err.Error()) + return + } + + c.chatOk(nil) +} + +// Send POST /backend/ai/chat/send +func (c *BackendAiChatController) Send() { + claims, err := c.chatClaims() + if err != nil { + c.chatJsonErr(401, 401, err.Error()) + return + } + + body, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.chatJsonErr(400, 400, "读取请求体失败") + return + } + var p chatSendPayload + if err := json.Unmarshal(body, &p); err != nil { + c.chatJsonErr(400, 400, "参数格式错误") + return + } + + if strings.TrimSpace(p.Content) == "" { + c.chatJsonErr(400, 400, "消息内容不能为空") + return + } + + // 查找或创建会话 + var session models.BackendAiChatSession + if p.SessionID > 0 { + session = models.BackendAiChatSession{ID: p.SessionID} + if err := models.Orm.Read(&session); err != nil { + c.chatJsonErr(404, 404, "会话不存在") + return + } + if session.TenantID != fmt.Sprintf("%d", claims.TenantId) || session.UserID != uint64(claims.UserID) { + c.chatJsonErr(403, 403, "无权操作") + return + } + } else { + // 新建会话,标题取消息前20字 + title := p.Content + if len([]rune(title)) > 20 { + title = string([]rune(title)[:20]) + "..." + } + session = models.BackendAiChatSession{ + TenantID: fmt.Sprintf("%d", claims.TenantId), + UserID: uint64(claims.UserID), + ProviderID: p.ProviderID, + Title: title, + CreateTime: time.Now(), + UpdateTime: time.Now(), + } + id, err := models.Orm.Insert(&session) + if err != nil { + c.chatJsonErr(500, 500, "创建会话失败: "+err.Error()) + return + } + session.ID = uint64(id) + } + + // 查找AI接入配置 + providerID := p.ProviderID + if providerID == 0 { + providerID = session.ProviderID + } + + var provider models.BackendAiProvider + if providerID > 0 { + provider = models.BackendAiProvider{ID: providerID} + if err := models.Orm.Read(&provider); err != nil { + c.chatJsonErr(400, 400, "指定的AI接入配置不存在,请先在设置中配置") + return + } + if provider.TenantID != fmt.Sprintf("%d", claims.TenantId) || provider.UserID != uint64(claims.UserID) { + c.chatJsonErr(403, 403, "无权使用该配置") + return + } + if provider.Status != 1 { + c.chatJsonErr(400, 400, "该AI接入配置已被禁用") + return + } + } else { + // 找第一个启用的配置 + var providers []models.BackendAiProvider + _, err := models.Orm.QueryTable(new(models.BackendAiProvider)). + Filter("tenant_id", fmt.Sprintf("%d", claims.TenantId)). + Filter("user_id", uint64(claims.UserID)). + Filter("status", 1). + Filter("delete_time__isnull", true). + OrderBy("-id"). + Limit(1). + All(&providers) + if err != nil || len(providers) == 0 { + c.chatJsonErr(400, 400, "尚未配置AI接入,请先在设置中配置OpenAI或Anthropic接入") + return + } + provider = providers[0] + } + + // 加载历史消息(最近20条) + var history []models.BackendAiChatMessage + _, _ = models.Orm.QueryTable(new(models.BackendAiChatMessage)). + Filter("session_id", session.ID). + OrderBy("-id"). + Limit(20). + All(&history) + // 反转顺序 + for i, j := 0, len(history)-1; i < j; i, j = i+1, j-1 { + history[i], history[j] = history[j], history[i] + } + + // 构建消息列表 + messages := make([]openaiMessage, 0, len(history)+1) + for _, m := range history { + messages = append(messages, openaiMessage{Role: m.Role, Content: m.Content}) + } + messages = append(messages, openaiMessage{Role: "user", Content: p.Content}) + + // 保存用户消息 + userMsg := models.BackendAiChatMessage{ + TenantID: fmt.Sprintf("%d", claims.TenantId), + SessionID: session.ID, + Role: "user", + Content: p.Content, + CreateTime: time.Now(), + } + _, _ = models.Orm.Insert(&userMsg) + + // 确定使用的模型:用户指定 > provider第一个模型 + useModel := strings.TrimSpace(p.Model) + if useModel == "" { + providerModels := parseProviderModels(provider.Models) + if len(providerModels) > 0 { + useModel = providerModels[0] + } + } + if useModel == "" { + c.chatJsonErr(400, 400, "未指定模型且该接入配置无可用模型") + return + } + + // 查询用户默认角色预设 + var systemPrompt string + var presets []models.BackendAiChatPreset + _, _ = models.Orm.QueryTable(new(models.BackendAiChatPreset)). + Filter("tenant_id", fmt.Sprintf("%d", claims.TenantId)). + Filter("user_id", uint64(claims.UserID)). + Filter("is_default", 1). + Filter("delete_time__isnull", true). + Limit(1). + All(&presets) + if len(presets) > 0 { + systemPrompt = presets[0].Content + } + + // 调用AI + reply, err := callAI(provider, useModel, systemPrompt, messages) + if err != nil { + c.chatJsonErr(500, 500, "AI调用失败: "+err.Error()) + return + } + + // 保存AI回复 + assistantMsg := models.BackendAiChatMessage{ + TenantID: fmt.Sprintf("%d", claims.TenantId), + SessionID: session.ID, + Role: "assistant", + Content: reply, + CreateTime: time.Now(), + } + _, _ = models.Orm.Insert(&assistantMsg) + + // 更新会话时间 + session.UpdateTime = time.Now() + _, _ = models.Orm.Update(&session, "update_time") + + c.chatOk(map[string]interface{}{ + "session_id": session.ID, + "reply": reply, + "message_id": assistantMsg.ID, + }) +} + +func callAI(provider models.BackendAiProvider, model string, systemPrompt string, messages []openaiMessage) (string, error) { + client := &http.Client{Timeout: 120 * time.Second} + + if provider.ProviderType == "openai" { + // OpenAI 兼容格式 + reqMessages := messages + if systemPrompt != "" { + reqMessages = append([]openaiMessage{{Role: "system", Content: systemPrompt}}, messages...) + } + reqBody := openaiRequest{ + Model: model, + Messages: reqMessages, + } + jsonData, _ := json.Marshal(reqBody) + + // 处理URL:如果已经包含/chat/completions,直接使用;否则拼接 + url := strings.TrimRight(provider.ApiBase, "/") + if !strings.HasSuffix(url, "/chat/completions") { + url = url + "/chat/completions" + } + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+provider.ApiKey) + + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + var result openaiResponse + if err := json.Unmarshal(body, &result); err != nil { + return "", fmt.Errorf("解析响应失败: %s", string(body)) + } + if result.Error != nil { + return "", fmt.Errorf(result.Error.Message) + } + if len(result.Choices) == 0 { + return "", fmt.Errorf("AI未返回内容") + } + return result.Choices[0].Message.Content, nil + + } else { + // Anthropic 格式 + reqBody := anthropicRequest{ + Model: model, + MaxTokens: 4096, + System: systemPrompt, + Messages: messages, + } + jsonData, _ := json.Marshal(reqBody) + + // 处理URL:如果已经包含/v1/messages,直接使用;否则拼接 + url := strings.TrimRight(provider.ApiBase, "/") + if !strings.HasSuffix(url, "/v1/messages") { + url = url + "/v1/messages" + } + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", provider.ApiKey) + req.Header.Set("anthropic-version", "2023-06-01") + + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + var result anthropicResponse + if err := json.Unmarshal(body, &result); err != nil { + return "", fmt.Errorf("解析响应失败: %s", string(body)) + } + if result.Error != nil { + return "", fmt.Errorf(result.Error.Message) + } + if len(result.Content) == 0 { + return "", fmt.Errorf("AI未返回内容") + } + return result.Content[0].Text, nil + } +} + +// SendStream POST /backend/ai/chat/send-stream +// 流式响应(SSE),逐块返回AI回复 +func (c *BackendAiChatController) SendStream() { + claims, err := c.chatClaims() + if err != nil { + c.chatJsonErr(401, 401, err.Error()) + return + } + + body, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.chatJsonErr(400, 400, "读取请求体失败") + return + } + var p chatSendPayload + if err := json.Unmarshal(body, &p); err != nil { + c.chatJsonErr(400, 400, "参数格式错误") + return + } + + if strings.TrimSpace(p.Content) == "" { + c.chatJsonErr(400, 400, "消息内容不能为空") + return + } + + // 查找或创建会话 + var session models.BackendAiChatSession + if p.SessionID > 0 { + session = models.BackendAiChatSession{ID: p.SessionID} + if err := models.Orm.Read(&session); err != nil { + c.chatJsonErr(404, 404, "会话不存在") + return + } + if session.TenantID != fmt.Sprintf("%d", claims.TenantId) || session.UserID != uint64(claims.UserID) { + c.chatJsonErr(403, 403, "无权操作") + return + } + } else { + title := p.Content + if len([]rune(title)) > 20 { + title = string([]rune(title)[:20]) + "..." + } + session = models.BackendAiChatSession{ + TenantID: fmt.Sprintf("%d", claims.TenantId), + UserID: uint64(claims.UserID), + ProviderID: p.ProviderID, + Title: title, + CreateTime: time.Now(), + UpdateTime: time.Now(), + } + id, err := models.Orm.Insert(&session) + if err != nil { + c.chatJsonErr(500, 500, "创建会话失败: "+err.Error()) + return + } + session.ID = uint64(id) + } + + // 查找AI接入配置 + providerID := p.ProviderID + if providerID == 0 { + providerID = session.ProviderID + } + + var provider models.BackendAiProvider + if providerID > 0 { + provider = models.BackendAiProvider{ID: providerID} + if err := models.Orm.Read(&provider); err != nil { + c.chatJsonErr(400, 400, "指定的AI接入配置不存在") + return + } + if provider.TenantID != fmt.Sprintf("%d", claims.TenantId) || provider.UserID != uint64(claims.UserID) { + c.chatJsonErr(403, 403, "无权使用该配置") + return + } + if provider.Status != 1 { + c.chatJsonErr(400, 400, "该AI接入配置已被禁用") + return + } + } else { + var providers []models.BackendAiProvider + _, err := models.Orm.QueryTable(new(models.BackendAiProvider)). + Filter("tenant_id", fmt.Sprintf("%d", claims.TenantId)). + Filter("user_id", uint64(claims.UserID)). + Filter("status", 1). + Filter("delete_time__isnull", true). + OrderBy("-id"). + Limit(1). + All(&providers) + if err != nil || len(providers) == 0 { + c.chatJsonErr(400, 400, "尚未配置AI接入,请先在设置中配置") + return + } + provider = providers[0] + } + + // 确定模型 + useModel := strings.TrimSpace(p.Model) + if useModel == "" { + providerModels := parseProviderModels(provider.Models) + if len(providerModels) > 0 { + useModel = providerModels[0] + } + } + if useModel == "" { + c.chatJsonErr(400, 400, "未指定模型且该接入配置无可用模型") + return + } + + // 查询默认预设 + var systemPrompt string + var presets []models.BackendAiChatPreset + _, _ = models.Orm.QueryTable(new(models.BackendAiChatPreset)). + Filter("tenant_id", fmt.Sprintf("%d", claims.TenantId)). + Filter("user_id", uint64(claims.UserID)). + Filter("is_default", 1). + Filter("delete_time__isnull", true). + Limit(1). + All(&presets) + if len(presets) > 0 { + systemPrompt = presets[0].Content + } + + // 加载历史消息 + var history []models.BackendAiChatMessage + _, _ = models.Orm.QueryTable(new(models.BackendAiChatMessage)). + Filter("session_id", session.ID). + OrderBy("-id"). + Limit(20). + All(&history) + for i, j := 0, len(history)-1; i < j; i, j = i+1, j-1 { + history[i], history[j] = history[j], history[i] + } + + messages := make([]openaiMessage, 0, len(history)+1) + for _, m := range history { + messages = append(messages, openaiMessage{Role: m.Role, Content: m.Content}) + } + messages = append(messages, openaiMessage{Role: "user", Content: p.Content}) + + // 保存用户消息 + userMsg := models.BackendAiChatMessage{ + TenantID: fmt.Sprintf("%d", claims.TenantId), + SessionID: session.ID, + Role: "user", + Content: p.Content, + CreateTime: time.Now(), + } + _, _ = models.Orm.Insert(&userMsg) + + // 设置SSE响应头 - 直接操作底层ResponseWriter,绕过beego包装层 + rw := c.Ctx.ResponseWriter.ResponseWriter + rw.Header().Set("Content-Type", "text/event-stream") + rw.Header().Set("Cache-Control", "no-cache") + rw.Header().Set("Connection", "keep-alive") + rw.Header().Set("X-Accel-Buffering", "no") + rw.WriteHeader(200) + + var flusher http.Flusher + if f, ok := rw.(http.Flusher); ok { + flusher = f + } + + writeSSE := func(event string, data string) { + rw.Write([]byte(fmt.Sprintf("event: %s\ndata: %s\n\n", event, data))) + if flusher != nil { + flusher.Flush() + } + } + + // 发送session_id事件 + writeSSE("session", fmt.Sprintf("{\"session_id\":%d}", session.ID)) + + // 流式调用AI + fullReply := "" + streamErr := callAIStream(provider, useModel, systemPrompt, messages, func(chunk string) { + fullReply += chunk + chunkJSON, _ := json.Marshal(map[string]string{"content": chunk}) + writeSSE("content", string(chunkJSON)) + }) + + if streamErr != nil { + errJSON, _ := json.Marshal(map[string]string{"error": streamErr.Error()}) + writeSSE("error", string(errJSON)) + return + } + + // 保存AI回复 + assistantMsg := models.BackendAiChatMessage{ + TenantID: fmt.Sprintf("%d", claims.TenantId), + SessionID: session.ID, + Role: "assistant", + Content: fullReply, + CreateTime: time.Now(), + } + _, _ = models.Orm.Insert(&assistantMsg) + + // 更新会话时间 + session.UpdateTime = time.Now() + _, _ = models.Orm.Update(&session, "update_time") + + // 发送done事件 + doneJSON, _ := json.Marshal(map[string]interface{}{ + "session_id": session.ID, + "message_id": assistantMsg.ID, + }) + writeSSE("done", string(doneJSON)) +} + +// callAIStream 流式调用AI,onChunk回调每收到一个内容块就调用 +func callAIStream(provider models.BackendAiProvider, model string, systemPrompt string, messages []openaiMessage, onChunk func(string)) error { + client := &http.Client{Timeout: 120 * time.Second} + + if provider.ProviderType == "openai" { + // OpenAI 兼容格式 - 流式 + reqMessages := messages + if systemPrompt != "" { + reqMessages = append([]openaiMessage{{Role: "system", Content: systemPrompt}}, messages...) + } + reqBody := map[string]interface{}{ + "model": model, + "messages": reqMessages, + "stream": true, + } + jsonData, _ := json.Marshal(reqBody) + + // 处理URL:如果已经包含/chat/completions,直接使用;否则拼接 + url := strings.TrimRight(provider.ApiBase, "/") + if !strings.HasSuffix(url, "/chat/completions") { + url = url + "/chat/completions" + } + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+provider.ApiKey) + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API返回错误状态 %d: %s", resp.StatusCode, string(body)) + } + + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 1024*1024), 1024*1024) + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "data: ") { + continue + } + data := strings.TrimPrefix(line, "data: ") + if data == "[DONE]" { + break + } + var chunk struct { + Choices []struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + } `json:"choices"` + } + if err := json.Unmarshal([]byte(data), &chunk); err != nil { + continue + } + if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" { + onChunk(chunk.Choices[0].Delta.Content) + } + } + return scanner.Err() + + } else { + // Anthropic 格式 - 流式 + reqBody := map[string]interface{}{ + "model": model, + "max_tokens": 4096, + "messages": messages, + "stream": true, + } + if systemPrompt != "" { + reqBody["system"] = systemPrompt + } + jsonData, _ := json.Marshal(reqBody) + + // 处理URL:如果已经包含/v1/messages,直接使用;否则拼接 + url := strings.TrimRight(provider.ApiBase, "/") + if !strings.HasSuffix(url, "/v1/messages") { + url = url + "/v1/messages" + } + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", provider.ApiKey) + req.Header.Set("anthropic-version", "2023-06-01") + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API返回错误状态 %d: %s", resp.StatusCode, string(body)) + } + + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 1024*1024), 1024*1024) + currentEvent := "" + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "event: ") { + currentEvent = strings.TrimPrefix(line, "event: ") + continue + } + if strings.HasPrefix(line, "data: ") { + data := strings.TrimPrefix(line, "data: ") + if currentEvent == "content_block_delta" { + var chunk struct { + Delta struct { + Text string `json:"text"` + } `json:"delta"` + } + if err := json.Unmarshal([]byte(data), &chunk); err == nil && chunk.Delta.Text != "" { + onChunk(chunk.Delta.Text) + } + } + if currentEvent == "message_stop" { + break + } + } + } + return scanner.Err() + } +} diff --git a/go/controllers/backend_ai_chat_preset.go b/go/controllers/backend_ai_chat_preset.go new file mode 100644 index 0000000..b2b5f8f --- /dev/null +++ b/go/controllers/backend_ai_chat_preset.go @@ -0,0 +1,288 @@ +package controllers + +import ( + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + "time" + + "server/models" + "server/pkg/jwtutil" + + beego "github.com/beego/beego/v2/server/web" +) + +// BackendAiChatPresetController AI聊天角色预设控制器 +type BackendAiChatPresetController struct { + beego.Controller +} + +func (c *BackendAiChatPresetController) presetClaims() (*jwtutil.Claims, error) { + auth := c.Ctx.Request.Header.Get("Authorization") + if auth == "" { + return nil, fmt.Errorf("未登录") + } + parts := strings.SplitN(auth, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + return nil, fmt.Errorf("认证信息格式错误") + } + claims, err := jwtutil.ParseToken(parts[1]) + if err != nil { + return nil, fmt.Errorf("无效的token") + } + if claims.UserType != "backend" { + return nil, fmt.Errorf("无权访问") + } + return claims, nil +} + +func (c *BackendAiChatPresetController) presetJsonErr(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +func (c *BackendAiChatPresetController) presetOk(data interface{}) { + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data} + _ = c.ServeJSON() +} + +type aiPresetPayload struct { + Name string `json:"name"` + Content string `json:"content"` + IsDefault int8 `json:"is_default"` +} + +// List GET /backend/ai/preset/list +func (c *BackendAiChatPresetController) List() { + claims, err := c.presetClaims() + if err != nil { + c.presetJsonErr(401, 401, err.Error()) + return + } + + var list []models.BackendAiChatPreset + _, err = models.Orm.QueryTable(new(models.BackendAiChatPreset)). + Filter("tenant_id", fmt.Sprintf("%d", claims.TenantId)). + Filter("user_id", uint64(claims.UserID)). + Filter("delete_time__isnull", true). + OrderBy("-is_default", "-id"). + All(&list) + if err != nil { + c.presetJsonErr(500, 500, "查询失败: "+err.Error()) + return + } + + c.presetOk(map[string]interface{}{"list": list}) +} + +// Create POST /backend/ai/preset +func (c *BackendAiChatPresetController) Create() { + claims, err := c.presetClaims() + if err != nil { + c.presetJsonErr(401, 401, err.Error()) + return + } + + body, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.presetJsonErr(400, 400, "读取请求体失败") + return + } + var p aiPresetPayload + if err := json.Unmarshal(body, &p); err != nil { + c.presetJsonErr(400, 400, "参数格式错误") + return + } + + if strings.TrimSpace(p.Name) == "" { + c.presetJsonErr(400, 400, "预设名称不能为空") + return + } + if strings.TrimSpace(p.Content) == "" { + c.presetJsonErr(400, 400, "预设内容不能为空") + return + } + + // 如果设为默认,先取消其他默认 + if p.IsDefault == 1 { + var defaults []models.BackendAiChatPreset + _, _ = models.Orm.QueryTable(new(models.BackendAiChatPreset)). + Filter("tenant_id", fmt.Sprintf("%d", claims.TenantId)). + Filter("user_id", uint64(claims.UserID)). + Filter("is_default", 1). + All(&defaults) + for _, d := range defaults { + d.IsDefault = 0 + d.UpdateTime = time.Now() + _, _ = models.Orm.Update(&d, "is_default", "update_time") + } + } + + preset := models.BackendAiChatPreset{ + TenantID: fmt.Sprintf("%d", claims.TenantId), + UserID: uint64(claims.UserID), + Name: strings.TrimSpace(p.Name), + Content: strings.TrimSpace(p.Content), + IsDefault: p.IsDefault, + CreateTime: time.Now(), + UpdateTime: time.Now(), + } + + id, err := models.Orm.Insert(&preset) + if err != nil { + c.presetJsonErr(500, 500, "创建失败: "+err.Error()) + return + } + + c.presetOk(map[string]interface{}{"id": id}) +} + +// Update PUT /backend/ai/preset/:id +func (c *BackendAiChatPresetController) Update() { + claims, err := c.presetClaims() + if err != nil { + c.presetJsonErr(401, 401, err.Error()) + return + } + + idStr := c.Ctx.Input.Param(":id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + c.presetJsonErr(400, 400, "ID格式错误") + return + } + + preset := models.BackendAiChatPreset{ID: id} + if err := models.Orm.Read(&preset); err != nil { + c.presetJsonErr(404, 404, "预设不存在") + return + } + if preset.TenantID != fmt.Sprintf("%d", claims.TenantId) || preset.UserID != uint64(claims.UserID) { + c.presetJsonErr(403, 403, "无权操作") + return + } + + body, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.presetJsonErr(400, 400, "读取请求体失败") + return + } + var p aiPresetPayload + if err := json.Unmarshal(body, &p); err != nil { + c.presetJsonErr(400, 400, "参数格式错误") + return + } + + // 如果设为默认,先取消其他默认 + if p.IsDefault == 1 && preset.IsDefault != 1 { + var defaults []models.BackendAiChatPreset + _, _ = models.Orm.QueryTable(new(models.BackendAiChatPreset)). + Filter("tenant_id", fmt.Sprintf("%d", claims.TenantId)). + Filter("user_id", uint64(claims.UserID)). + Filter("is_default", 1). + All(&defaults) + for _, d := range defaults { + d.IsDefault = 0 + d.UpdateTime = time.Now() + _, _ = models.Orm.Update(&d, "is_default", "update_time") + } + } + + preset.Name = strings.TrimSpace(p.Name) + preset.Content = strings.TrimSpace(p.Content) + preset.IsDefault = p.IsDefault + preset.UpdateTime = time.Now() + + _, err = models.Orm.Update(&preset) + if err != nil { + c.presetJsonErr(500, 500, "更新失败: "+err.Error()) + return + } + + c.presetOk(nil) +} + +// SetDefault PUT /backend/ai/preset/:id/default +func (c *BackendAiChatPresetController) SetDefault() { + claims, err := c.presetClaims() + if err != nil { + c.presetJsonErr(401, 401, err.Error()) + return + } + + idStr := c.Ctx.Input.Param(":id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + c.presetJsonErr(400, 400, "ID格式错误") + return + } + + preset := models.BackendAiChatPreset{ID: id} + if err := models.Orm.Read(&preset); err != nil { + c.presetJsonErr(404, 404, "预设不存在") + return + } + if preset.TenantID != fmt.Sprintf("%d", claims.TenantId) || preset.UserID != uint64(claims.UserID) { + c.presetJsonErr(403, 403, "无权操作") + return + } + + // 取消其他默认 + var defaults []models.BackendAiChatPreset + _, _ = models.Orm.QueryTable(new(models.BackendAiChatPreset)). + Filter("tenant_id", fmt.Sprintf("%d", claims.TenantId)). + Filter("user_id", uint64(claims.UserID)). + Filter("is_default", 1). + All(&defaults) + for _, d := range defaults { + d.IsDefault = 0 + d.UpdateTime = time.Now() + _, _ = models.Orm.Update(&d, "is_default", "update_time") + } + + // 设为默认 + preset.IsDefault = 1 + preset.UpdateTime = time.Now() + _, _ = models.Orm.Update(&preset, "is_default", "update_time") + + c.presetOk(nil) +} + +// Delete DELETE /backend/ai/preset/:id +func (c *BackendAiChatPresetController) Delete() { + claims, err := c.presetClaims() + if err != nil { + c.presetJsonErr(401, 401, err.Error()) + return + } + + idStr := c.Ctx.Input.Param(":id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + c.presetJsonErr(400, 400, "ID格式错误") + return + } + + preset := models.BackendAiChatPreset{ID: id} + if err := models.Orm.Read(&preset); err != nil { + c.presetJsonErr(404, 404, "预设不存在") + return + } + if preset.TenantID != fmt.Sprintf("%d", claims.TenantId) || preset.UserID != uint64(claims.UserID) { + c.presetJsonErr(403, 403, "无权操作") + return + } + + now := time.Now() + preset.DeleteTime = &now + _, err = models.Orm.Update(&preset, "delete_time") + if err != nil { + c.presetJsonErr(500, 500, "删除失败: "+err.Error()) + return + } + + c.presetOk(nil) +} diff --git a/go/controllers/backend_ai_chat_session.go b/go/controllers/backend_ai_chat_session.go new file mode 100644 index 0000000..7fb9281 --- /dev/null +++ b/go/controllers/backend_ai_chat_session.go @@ -0,0 +1,209 @@ +package controllers + +import ( + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + "time" + + "server/models" + "server/pkg/jwtutil" + + beego "github.com/beego/beego/v2/server/web" +) + +// BackendAiChatSessionController AI聊天会话控制器 +type BackendAiChatSessionController struct { + beego.Controller +} + +func (c *BackendAiChatSessionController) sessionClaims() (*jwtutil.Claims, error) { + auth := c.Ctx.Request.Header.Get("Authorization") + if auth == "" { + return nil, fmt.Errorf("未登录") + } + parts := strings.SplitN(auth, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + return nil, fmt.Errorf("认证信息格式错误") + } + claims, err := jwtutil.ParseToken(parts[1]) + if err != nil { + return nil, fmt.Errorf("无效的token") + } + if claims.UserType != "backend" { + return nil, fmt.Errorf("无权访问") + } + return claims, nil +} + +func (c *BackendAiChatSessionController) sessionJsonErr(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +func (c *BackendAiChatSessionController) sessionOk(data interface{}) { + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data} + _ = c.ServeJSON() +} + +type chatSessionPayload struct { + Title string `json:"title"` + ProviderID uint64 `json:"provider_id"` +} + +// List GET /backend/ai/chat/session/list +func (c *BackendAiChatSessionController) List() { + claims, err := c.sessionClaims() + if err != nil { + c.sessionJsonErr(401, 401, err.Error()) + return + } + + var list []models.BackendAiChatSession + _, err = models.Orm.QueryTable(new(models.BackendAiChatSession)). + Filter("tenant_id", fmt.Sprintf("%d", claims.TenantId)). + Filter("user_id", uint64(claims.UserID)). + Filter("delete_time__isnull", true). + OrderBy("-update_time"). + All(&list) + if err != nil { + c.sessionJsonErr(500, 500, "查询失败: "+err.Error()) + return + } + + c.sessionOk(map[string]interface{}{"list": list}) +} + +// Create POST /backend/ai/chat/session +func (c *BackendAiChatSessionController) Create() { + claims, err := c.sessionClaims() + if err != nil { + c.sessionJsonErr(401, 401, err.Error()) + return + } + + body, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.sessionJsonErr(400, 400, "读取请求体失败") + return + } + var p chatSessionPayload + if err := json.Unmarshal(body, &p); err != nil { + c.sessionJsonErr(400, 400, "参数格式错误") + return + } + + title := strings.TrimSpace(p.Title) + if title == "" { + title = "新对话" + } + + session := models.BackendAiChatSession{ + TenantID: fmt.Sprintf("%d", claims.TenantId), + UserID: uint64(claims.UserID), + ProviderID: p.ProviderID, + Title: title, + CreateTime: time.Now(), + UpdateTime: time.Now(), + } + + id, err := models.Orm.Insert(&session) + if err != nil { + c.sessionJsonErr(500, 500, "创建失败: "+err.Error()) + return + } + + c.sessionOk(map[string]interface{}{"id": id, "title": title}) +} + +// Update PUT /backend/ai/chat/session/:id(重命名) +func (c *BackendAiChatSessionController) Update() { + claims, err := c.sessionClaims() + if err != nil { + c.sessionJsonErr(401, 401, err.Error()) + return + } + + idStr := c.Ctx.Input.Param(":id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + c.sessionJsonErr(400, 400, "ID格式错误") + return + } + + session := models.BackendAiChatSession{ID: id} + if err := models.Orm.Read(&session); err != nil { + c.sessionJsonErr(404, 404, "会话不存在") + return + } + if session.TenantID != fmt.Sprintf("%d", claims.TenantId) || session.UserID != uint64(claims.UserID) { + c.sessionJsonErr(403, 403, "无权操作") + return + } + + body, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.sessionJsonErr(400, 400, "读取请求体失败") + return + } + var p chatSessionPayload + if err := json.Unmarshal(body, &p); err != nil { + c.sessionJsonErr(400, 400, "参数格式错误") + return + } + + if strings.TrimSpace(p.Title) != "" { + session.Title = strings.TrimSpace(p.Title) + } + if p.ProviderID > 0 { + session.ProviderID = p.ProviderID + } + session.UpdateTime = time.Now() + + _, err = models.Orm.Update(&session) + if err != nil { + c.sessionJsonErr(500, 500, "更新失败: "+err.Error()) + return + } + + c.sessionOk(nil) +} + +// Delete DELETE /backend/ai/chat/session/:id +func (c *BackendAiChatSessionController) Delete() { + claims, err := c.sessionClaims() + if err != nil { + c.sessionJsonErr(401, 401, err.Error()) + return + } + + idStr := c.Ctx.Input.Param(":id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + c.sessionJsonErr(400, 400, "ID格式错误") + return + } + + session := models.BackendAiChatSession{ID: id} + if err := models.Orm.Read(&session); err != nil { + c.sessionJsonErr(404, 404, "会话不存在") + return + } + if session.TenantID != fmt.Sprintf("%d", claims.TenantId) || session.UserID != uint64(claims.UserID) { + c.sessionJsonErr(403, 403, "无权操作") + return + } + + now := time.Now() + session.DeleteTime = &now + _, err = models.Orm.Update(&session, "delete_time") + if err != nil { + c.sessionJsonErr(500, 500, "删除失败: "+err.Error()) + return + } + + c.sessionOk(nil) +} diff --git a/go/controllers/backend_ai_provider.go b/go/controllers/backend_ai_provider.go new file mode 100644 index 0000000..1bf9d8e --- /dev/null +++ b/go/controllers/backend_ai_provider.go @@ -0,0 +1,360 @@ +package controllers + +import ( + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + "sync" + "time" + + "server/models" + "server/pkg/jwtutil" + + beego "github.com/beego/beego/v2/server/web" +) + +// BackendAiProviderController AI接入配置控制器 +type BackendAiProviderController struct { + beego.Controller +} + +func (c *BackendAiProviderController) aiClaims() (*jwtutil.Claims, error) { + auth := c.Ctx.Request.Header.Get("Authorization") + if auth == "" { + return nil, fmt.Errorf("未登录") + } + parts := strings.SplitN(auth, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + return nil, fmt.Errorf("认证信息格式错误") + } + claims, err := jwtutil.ParseToken(parts[1]) + if err != nil { + return nil, fmt.Errorf("无效的token") + } + if claims.UserType != "backend" { + return nil, fmt.Errorf("无权访问") + } + return claims, nil +} + +func (c *BackendAiProviderController) aiJsonErr(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +func (c *BackendAiProviderController) aiOk(data interface{}) { + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data} + _ = c.ServeJSON() +} + +type aiProviderPayload struct { + ProviderType string `json:"provider_type"` + Name string `json:"name"` + ApiBase string `json:"api_base"` + ApiKey string `json:"api_key"` + Models []string `json:"models"` + IsDefault int8 `json:"is_default"` + Status int8 `json:"status"` + Remark string `json:"remark"` +} + +// clearTenantDefault 取消租户内所有默认配置 +func clearTenantDefault(tenantID string) { + var defaults []models.BackendAiProvider + _, _ = models.Orm.QueryTable(new(models.BackendAiProvider)). + Filter("tenant_id", tenantID). + Filter("is_default", 1). + All(&defaults) + for _, d := range defaults { + d.IsDefault = 0 + d.UpdateTime = time.Now() + _, _ = models.Orm.Update(&d, "is_default", "update_time") + } +} + +// parseModels 将模型JSON字符串解析为数组 +func parseProviderModels(m string) []string { + if m == "" { + return []string{} + } + var list []string + if err := json.Unmarshal([]byte(m), &list); err != nil { + // 兼容旧格式:逗号分隔的单个模型 + return []string{m} + } + return list +} + +// List GET /backend/ai/provider/list +func (c *BackendAiProviderController) List() { + claims, err := c.aiClaims() + if err != nil { + c.aiJsonErr(401, 401, err.Error()) + return + } + + var list []models.BackendAiProvider + _, err = models.Orm.QueryTable(new(models.BackendAiProvider)). + Filter("tenant_id", fmt.Sprintf("%d", claims.TenantId)). + Filter("user_id", uint64(claims.UserID)). + Filter("delete_time__isnull", true). + OrderBy("-id"). + All(&list) + if err != nil { + c.aiJsonErr(500, 500, "查询失败: "+err.Error()) + return + } + + // 脱敏api_key + 解析模型列表 + for i := range list { + if list[i].ApiKey != "" && len(list[i].ApiKey) > 8 { + list[i].ApiKey = list[i].ApiKey[:4] + "****" + list[i].ApiKey[len(list[i].ApiKey)-4:] + } + list[i].ModelsList = parseProviderModels(list[i].Models) + } + + c.aiOk(map[string]interface{}{"list": list}) +} + +// Create POST /backend/ai/provider +func (c *BackendAiProviderController) Create() { + claims, err := c.aiClaims() + if err != nil { + c.aiJsonErr(401, 401, err.Error()) + return + } + + body, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.aiJsonErr(400, 400, "读取请求体失败") + return + } + var p aiProviderPayload + if err := json.Unmarshal(body, &p); err != nil { + c.aiJsonErr(400, 400, "参数格式错误") + return + } + + if strings.TrimSpace(p.Name) == "" { + c.aiJsonErr(400, 400, "配置名称不能为空") + return + } + if p.ProviderType != "openai" && p.ProviderType != "anthropic" { + c.aiJsonErr(400, 400, "接入类型必须是 openai 或 anthropic") + return + } + if strings.TrimSpace(p.ApiBase) == "" || strings.TrimSpace(p.ApiKey) == "" { + c.aiJsonErr(400, 400, "接口地址、API Key不能为空") + return + } + if len(p.Models) == 0 { + c.aiJsonErr(400, 400, "至少配置一个模型") + return + } + + // 模型数组序列化为JSON + modelsJSON, _ := json.Marshal(p.Models) + + // 如果设为默认,先取消租户内其他默认 + if p.IsDefault == 1 { + clearTenantDefault(fmt.Sprintf("%d", claims.TenantId)) + } + + provider := models.BackendAiProvider{ + TenantID: fmt.Sprintf("%d", claims.TenantId), + UserID: uint64(claims.UserID), + ProviderType: p.ProviderType, + Name: strings.TrimSpace(p.Name), + ApiBase: strings.TrimSpace(p.ApiBase), + ApiKey: strings.TrimSpace(p.ApiKey), + Models: string(modelsJSON), + IsDefault: p.IsDefault, + Status: p.Status, + Remark: strings.TrimSpace(p.Remark), + CreateTime: time.Now(), + UpdateTime: time.Now(), + } + + id, err := models.Orm.Insert(&provider) + if err != nil { + c.aiJsonErr(500, 500, "创建失败: "+err.Error()) + return + } + + c.aiOk(map[string]interface{}{"id": id}) +} + +// Update PUT /backend/ai/provider/:id +func (c *BackendAiProviderController) Update() { + claims, err := c.aiClaims() + if err != nil { + c.aiJsonErr(401, 401, err.Error()) + return + } + + idStr := c.Ctx.Input.Param(":id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + c.aiJsonErr(400, 400, "ID格式错误") + return + } + + provider := models.BackendAiProvider{ID: id} + if err := models.Orm.Read(&provider); err != nil { + c.aiJsonErr(404, 404, "配置不存在") + return + } + if provider.TenantID != fmt.Sprintf("%d", claims.TenantId) { + c.aiJsonErr(403, 403, "无权操作") + return + } + + body, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.aiJsonErr(400, 400, "读取请求体失败") + return + } + var p aiProviderPayload + if err := json.Unmarshal(body, &p); err != nil { + c.aiJsonErr(400, 400, "参数格式错误") + return + } + + // 如果设为默认,先取消租户内其他默认 + if p.IsDefault == 1 && provider.IsDefault != 1 { + clearTenantDefault(fmt.Sprintf("%d", claims.TenantId)) + } + + provider.Name = strings.TrimSpace(p.Name) + provider.ProviderType = p.ProviderType + provider.ApiBase = strings.TrimSpace(p.ApiBase) + // 只有传入了非脱敏的key才更新 + if p.ApiKey != "" && !strings.Contains(p.ApiKey, "****") { + provider.ApiKey = strings.TrimSpace(p.ApiKey) + } + // 更新模型列表 + if len(p.Models) > 0 { + modelsJSON, _ := json.Marshal(p.Models) + provider.Models = string(modelsJSON) + } + provider.IsDefault = p.IsDefault + provider.Status = p.Status + provider.Remark = strings.TrimSpace(p.Remark) + provider.UpdateTime = time.Now() + + _, err = models.Orm.Update(&provider) + if err != nil { + c.aiJsonErr(500, 500, "更新失败: "+err.Error()) + return + } + + c.aiOk(nil) +} + +// Delete DELETE /backend/ai/provider/:id +func (c *BackendAiProviderController) Delete() { + claims, err := c.aiClaims() + if err != nil { + c.aiJsonErr(401, 401, err.Error()) + return + } + + idStr := c.Ctx.Input.Param(":id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + c.aiJsonErr(400, 400, "ID格式错误") + return + } + + provider := models.BackendAiProvider{ID: id} + if err := models.Orm.Read(&provider); err != nil { + c.aiJsonErr(404, 404, "配置不存在") + return + } + if provider.TenantID != fmt.Sprintf("%d", claims.TenantId) { + c.aiJsonErr(403, 403, "无权操作") + return + } + + now := time.Now() + provider.DeleteTime = &now + _, err = models.Orm.Update(&provider, "delete_time") + if err != nil { + c.aiJsonErr(500, 500, "删除失败: "+err.Error()) + return + } + + c.aiOk(nil) +} + +type aiTestPayload struct { + ProviderType string `json:"provider_type"` + ApiBase string `json:"api_base"` + ApiKey string `json:"api_key"` + Models []string `json:"models"` +} + +type modelTestResult struct { + Model string `json:"model"` + Success bool `json:"success"` + Error string `json:"error"` +} + +// Test POST /backend/ai/provider/test +// 批量测试模型连通性 +func (c *BackendAiProviderController) Test() { + if _, err := c.aiClaims(); err != nil { + c.aiJsonErr(401, 401, err.Error()) + return + } + + body, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.aiJsonErr(400, 400, "读取请求体失败") + return + } + var p aiTestPayload + if err := json.Unmarshal(body, &p); err != nil { + c.aiJsonErr(400, 400, "参数格式错误") + return + } + + if strings.TrimSpace(p.ApiBase) == "" || strings.TrimSpace(p.ApiKey) == "" { + c.aiJsonErr(400, 400, "接口地址和API Key不能为空") + return + } + if len(p.Models) == 0 { + c.aiOk(map[string]interface{}{"results": []modelTestResult{}}) + return + } + + // 构造临时provider + provider := models.BackendAiProvider{ + ProviderType: p.ProviderType, + ApiBase: strings.TrimSpace(p.ApiBase), + ApiKey: strings.TrimSpace(p.ApiKey), + } + testMessages := []openaiMessage{{Role: "user", Content: "hi"}} + + // 并发测试 + results := make([]modelTestResult, len(p.Models)) + var wg sync.WaitGroup + for i, model := range p.Models { + wg.Add(1) + go func(idx int, m string) { + defer wg.Done() + _, err := callAI(provider, m, "", testMessages) + if err != nil { + results[idx] = modelTestResult{Model: m, Success: false, Error: err.Error()} + } else { + results[idx] = modelTestResult{Model: m, Success: true, Error: ""} + } + }(i, model) + } + wg.Wait() + + c.aiOk(map[string]interface{}{"results": results}) +} diff --git a/go/controllers/backend_ai_smart_generate.go b/go/controllers/backend_ai_smart_generate.go new file mode 100644 index 0000000..d6f84ea --- /dev/null +++ b/go/controllers/backend_ai_smart_generate.go @@ -0,0 +1,184 @@ +package controllers + +import ( + "encoding/json" + "fmt" + "io" + "strings" + "time" + + "server/models" + "server/pkg/jwtutil" + + beego "github.com/beego/beego/v2/server/web" +) + +// BackendAiSmartGenerateController AI智能生成控制器 +type BackendAiSmartGenerateController struct { + beego.Controller +} + +func (c *BackendAiSmartGenerateController) sgClaims() (*jwtutil.Claims, error) { + auth := c.Ctx.Request.Header.Get("Authorization") + if auth == "" { + return nil, fmt.Errorf("未登录") + } + parts := strings.SplitN(auth, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + return nil, fmt.Errorf("认证信息格式错误") + } + claims, err := jwtutil.ParseToken(parts[1]) + if err != nil { + return nil, fmt.Errorf("无效的token") + } + if claims.UserType != "backend" { + return nil, fmt.Errorf("无权访问") + } + return claims, nil +} + +func (c *BackendAiSmartGenerateController) sgJsonErr(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +func (c *BackendAiSmartGenerateController) sgOk(data interface{}) { + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data} + _ = c.ServeJSON() +} + +type smartGeneratePayload struct { + CompanyName string `json:"company_name"` + Type string `json:"type"` // supplier / customer +} + +// GenerateCompany POST /backend/ai/smart-generate/company +// 根据公司名称智能生成详细信息 +func (c *BackendAiSmartGenerateController) GenerateCompany() { + claims, err := c.sgClaims() + if err != nil { + c.sgJsonErr(401, 401, err.Error()) + return + } + + body, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.sgJsonErr(400, 400, "读取请求体失败") + return + } + var p smartGeneratePayload + if err := json.Unmarshal(body, &p); err != nil { + c.sgJsonErr(400, 400, "参数格式错误") + return + } + + if strings.TrimSpace(p.CompanyName) == "" { + c.sgJsonErr(400, 400, "公司名称不能为空") + return + } + if p.Type != "supplier" && p.Type != "customer" { + c.sgJsonErr(400, 400, "类型必须是 supplier 或 customer") + return + } + + // 查询租户默认AI配置 + var providers []models.BackendAiProvider + _, err = models.Orm.QueryTable(new(models.BackendAiProvider)). + Filter("tenant_id", fmt.Sprintf("%d", claims.TenantId)). + Filter("is_default", 1). + Filter("status", 1). + Filter("delete_time__isnull", true). + Limit(1). + All(&providers) + if err != nil || len(providers) == 0 { + c.sgJsonErr(400, 400, "当前企业没有配置智能体无法使用本功能,请联系管理员") + return + } + provider := providers[0] + + // 确定使用的模型(第一个) + providerModels := parseProviderModels(provider.Models) + if len(providerModels) == 0 { + c.sgJsonErr(400, 400, "默认接入配置没有可用模型") + return + } + useModel := providerModels[0] + + // 构造prompt + typeLabel := "供应商" + nameField := "supplier_name" + if p.Type == "customer" { + typeLabel = "客户" + nameField = "customer_name" + } + + prompt := fmt.Sprintf(`你是一个企业信息助手。请根据%s名称"%s",生成该公司的详细信息,以JSON格式返回,包含以下字段: +- %s: 公司全称 +- contact_person: 联系人 +- contact_phone: 联系电话 +- contact_email: 邮箱 +- address: 公司地址 +- industry: 所属行业 +- registered_capital: 注册资本(如"100万元") +- paid_capital: 实缴资本(如"50万元") +- establish_date: 成立日期(YYYY-MM-DD格式,不确定则留空) +- administrative_division: 行政区划(如"江苏省连云港市海州区") +- enterprise_type: 企业类型(如"有限责任公司") +- taxpayer_qualification: 纳税人资质("一般纳税人"或"小规模纳税人") +- business_scope: 经营范围 +- invoice_title: 发票抬头(公司全称) +- tax_number: 统一社会信用代码/税号(18位,不确定则留空) +- bank_name: 开户银行 +- bank_account: 银行账号 +- registered_address: 注册地址 +- registered_phone: 注册电话 +- remark: 备注 + +请只返回JSON对象,不要返回其他任何文字、解释或markdown代码块标记。如果某些信息不确定,请留空字符串。`, typeLabel, p.CompanyName, nameField) + + messages := []openaiMessage{ + {Role: "user", Content: prompt}, + } + + // 调用AI(非流式,超时120s) + reply, err := callAI(provider, useModel, "", messages) + if err != nil { + c.sgJsonErr(500, 500, "AI生成失败: "+err.Error()) + return + } + + // 解析AI返回的JSON + reply = strings.TrimSpace(reply) + // 去掉可能的markdown代码块标记 + reply = strings.TrimPrefix(reply, "```json") + reply = strings.TrimPrefix(reply, "```") + reply = strings.TrimSuffix(reply, "```") + reply = strings.TrimSpace(reply) + + var result map[string]interface{} + if err := json.Unmarshal([]byte(reply), &result); err != nil { + // 解析失败,返回原始文本让用户自己处理 + c.sgOk(map[string]interface{}{ + "raw": reply, + "parsed": false, + "name": p.CompanyName, + "type": p.Type, + "generate_time": time.Now().Format("2006-01-02 15:04:05"), + }) + return + } + + // 确保公司名称字段存在 + if _, ok := result[nameField]; !ok || result[nameField] == "" { + result[nameField] = p.CompanyName + } + + c.sgOk(map[string]interface{}{ + "parsed": true, + "data": result, + "name": p.CompanyName, + "type": p.Type, + "generate_time": time.Now().Format("2006-01-02 15:04:05"), + }) +} diff --git a/go/models/backend_ai_chat_message.go b/go/models/backend_ai_chat_message.go new file mode 100644 index 0000000..f2e26ba --- /dev/null +++ b/go/models/backend_ai_chat_message.go @@ -0,0 +1,17 @@ +package models + +import "time" + +// BackendAiChatMessage AI聊天消息表 +type BackendAiChatMessage struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"` + SessionID uint64 `orm:"column(session_id)" json:"session_id"` + Role string `orm:"column(role);size(20)" json:"role"` // user / assistant + Content string `orm:"column(content);type(text)" json:"content"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` +} + +func (m *BackendAiChatMessage) TableName() string { + return "yz_backend_ai_chat_message" +} diff --git a/go/models/backend_ai_chat_preset.go b/go/models/backend_ai_chat_preset.go new file mode 100644 index 0000000..9a1ea4c --- /dev/null +++ b/go/models/backend_ai_chat_preset.go @@ -0,0 +1,20 @@ +package models + +import "time" + +// BackendAiChatPreset AI聊天角色预设表(用户级) +type BackendAiChatPreset struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"` + UserID uint64 `orm:"column(user_id);default(0)" json:"user_id"` + Name string `orm:"column(name);size(100)" json:"name"` + Content string `orm:"column(content);type(text)" json:"content"` + IsDefault int8 `orm:"column(is_default);default(0)" json:"is_default"` // 0-否 1-是 + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *BackendAiChatPreset) TableName() string { + return "yz_backend_ai_chat_preset" +} diff --git a/go/models/backend_ai_chat_session.go b/go/models/backend_ai_chat_session.go new file mode 100644 index 0000000..481eeff --- /dev/null +++ b/go/models/backend_ai_chat_session.go @@ -0,0 +1,19 @@ +package models + +import "time" + +// BackendAiChatSession AI聊天会话表 +type BackendAiChatSession struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"` + UserID uint64 `orm:"column(user_id);default(0)" json:"user_id"` + ProviderID uint64 `orm:"column(provider_id);default(0)" json:"provider_id"` + Title string `orm:"column(title);size(200)" json:"title"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *BackendAiChatSession) TableName() string { + return "yz_backend_ai_chat_session" +} diff --git a/go/models/backend_ai_provider.go b/go/models/backend_ai_provider.go new file mode 100644 index 0000000..b1b0faa --- /dev/null +++ b/go/models/backend_ai_provider.go @@ -0,0 +1,26 @@ +package models + +import "time" + +// BackendAiProvider AI接入配置表(租户隔离) +type BackendAiProvider struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"` + UserID uint64 `orm:"column(user_id);default(0)" json:"user_id"` + ProviderType string `orm:"column(provider_type);size(20)" json:"provider_type"` // openai / anthropic + Name string `orm:"column(name);size(100)" json:"name"` + ApiBase string `orm:"column(api_base);size(255)" json:"api_base"` + ApiKey string `orm:"column(api_key);size(255)" json:"api_key"` + Models string `orm:"column(models);type(text)" json:"-"` // JSON数组存储,如 ["gpt-4o","gpt-3.5-turbo"] + ModelsList []string `orm:"-" json:"models"` // 返回给前端的模型列表 + IsDefault int8 `orm:"column(is_default);default(0)" json:"is_default"` // 0-否 1-是(租户默认模型,用于智能添加等企业级功能) + Status int8 `orm:"column(status);default(1)" json:"status"` // 0-禁用 1-启用 + Remark string `orm:"column(remark);size(255)" json:"remark"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *BackendAiProvider) TableName() string { + return "yz_backend_ai_provider" +} diff --git a/go/models/init.go b/go/models/init.go index 5b3273d..1cb8004 100644 --- a/go/models/init.go +++ b/go/models/init.go @@ -77,6 +77,10 @@ func Init(_ string) { new(ErpAccountSet), new(ErpNormalSetting), new(ErpCompanyContact), + new(BackendAiProvider), + new(BackendAiChatSession), + new(BackendAiChatMessage), + new(BackendAiChatPreset), new(CmsArticleCategory), new(CmsArticle), diff --git a/go/routers/backend/backend.go b/go/routers/backend/backend.go index b733135..672b3ca 100644 --- a/go/routers/backend/backend.go +++ b/go/routers/backend/backend.go @@ -338,4 +338,30 @@ func registerOrganizationRoutes(module string) { beego.Router("/backend/erp/company-contact/list", &controllers.BackendErpCompanyContactController{}, "get:List") beego.Router("/backend/erp/company-contact", &controllers.BackendErpCompanyContactController{}, "post:Create") beego.Router("/backend/erp/company-contact/:id", &controllers.BackendErpCompanyContactController{}, "put:Update;delete:Delete") + + // AI接入配置 + beego.Router("/backend/ai/provider/list", &controllers.BackendAiProviderController{}, "get:List") + beego.Router("/backend/ai/provider", &controllers.BackendAiProviderController{}, "post:Create") + beego.Router("/backend/ai/provider/test", &controllers.BackendAiProviderController{}, "post:Test") + beego.Router("/backend/ai/provider/:id", &controllers.BackendAiProviderController{}, "put:Update;delete:Delete") + + // AI聊天会话 + beego.Router("/backend/ai/chat/session/list", &controllers.BackendAiChatSessionController{}, "get:List") + beego.Router("/backend/ai/chat/session", &controllers.BackendAiChatSessionController{}, "post:Create") + beego.Router("/backend/ai/chat/session/:id", &controllers.BackendAiChatSessionController{}, "put:Update;delete:Delete") + + // AI聊天消息 + beego.Router("/backend/ai/chat/message/list", &controllers.BackendAiChatController{}, "get:MessageList") + beego.Router("/backend/ai/chat/message/:id", &controllers.BackendAiChatController{}, "delete:DeleteMessage") + beego.Router("/backend/ai/chat/send", &controllers.BackendAiChatController{}, "post:Send") + beego.Router("/backend/ai/chat/send-stream", &controllers.BackendAiChatController{}, "post:SendStream") + + // AI智能生成 + beego.Router("/backend/ai/smart-generate/company", &controllers.BackendAiSmartGenerateController{}, "post:GenerateCompany") + + // AI聊天角色预设 + beego.Router("/backend/ai/preset/list", &controllers.BackendAiChatPresetController{}, "get:List") + beego.Router("/backend/ai/preset", &controllers.BackendAiChatPresetController{}, "post:Create") + beego.Router("/backend/ai/preset/:id", &controllers.BackendAiChatPresetController{}, "put:Update;delete:Delete") + beego.Router("/backend/ai/preset/:id/default", &controllers.BackendAiChatPresetController{}, "put:SetDefault") }