chore: 工作台产品化进行中的改动
把工作区里其余在制品一并入库,主要是工作台产品化的推进:
后端:新增 capability_definition / project / my_app_center / office_skill
接口与 action_definition / skill_definition / project / user_app_center
模型,config 加路由健康上报。
前端:新增 frontend/src/skills(Office 技能与 workbuddy 复刻)、
项目管理、应用中心、能力目录页,以及配套 api / store / config;
聊天侧新增 SpecialistChip / SpecialistPanel / SkillStrip / AppChatRail
等组件。
清理:移除旧 views/tools 下的单页工具(已并入工作台)、_frozen 冻结组件、
cmd/inspect_oa_debug 调试入口,以及两份调试笔记。
其它:文档与启动脚本同步。
(这批改动与上一提交的 SY23 工作并行进行,此前已在同一工作区内交织。)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,7 @@ func PutAIConfig(c *gin.Context) {
|
||||
web.Fail(c, web.NewBadRequest(err.Error()))
|
||||
return
|
||||
}
|
||||
go config.RefreshAIRouteHealthNow()
|
||||
web.OK(c, gin.H{"saved": true})
|
||||
}
|
||||
|
||||
@@ -54,6 +55,7 @@ func ReloadAIConfig(c *gin.Context) {
|
||||
web.Fail(c, web.NewLLMNotConfigured("重载后配置校验失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
go config.RefreshAIRouteHealthNow()
|
||||
web.OK(c, gin.H{"reloaded": true})
|
||||
}
|
||||
|
||||
|
||||
@@ -36,58 +36,20 @@ func ChatMessage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Message string `json:"message"`
|
||||
Context map[string]any `json:"context"`
|
||||
History []ai.Message `json:"history"`
|
||||
Message string `json:"message"`
|
||||
Context map[string]any `json:"context"`
|
||||
History []ai.Message `json:"history"`
|
||||
AIRouteID string `json:"ai_route_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Message) == "" {
|
||||
web.Fail(c, web.NewBadRequest("message 必填"))
|
||||
return
|
||||
}
|
||||
traceID := fmt.Sprintf("chat-%d", time.Now().UnixNano())
|
||||
// #region debug-point B:chat-entry
|
||||
if payload, err := json.Marshal(gin.H{
|
||||
"sessionId": "knowledge-chat-401",
|
||||
"runId": "pre-fix",
|
||||
"hypothesisId": "B",
|
||||
"location": "backend-go/internal/api/ai_chat.go:ChatMessage:entry",
|
||||
"traceId": traceID,
|
||||
"msg": "[DEBUG] chat message received",
|
||||
"data": gin.H{
|
||||
"userId": user.ID,
|
||||
"messagePreview": []rune(strings.TrimSpace(req.Message)),
|
||||
"spaceKey": req.Context["knowledge_space_key"],
|
||||
"historySize": len(req.History),
|
||||
},
|
||||
"ts": time.Now().UnixMilli(),
|
||||
}); err == nil {
|
||||
go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload)))
|
||||
}
|
||||
// #endregion
|
||||
plan, err := buildKnowledgeAnswerPlan(req.Message, req.Context, req.History)
|
||||
if err != nil {
|
||||
web.Fail(c, web.NewBadRequest("知识问答链路执行失败"))
|
||||
return
|
||||
}
|
||||
// #region debug-point B:plan-built
|
||||
if payload, err := json.Marshal(gin.H{
|
||||
"sessionId": "knowledge-chat-401",
|
||||
"runId": "pre-fix",
|
||||
"hypothesisId": "B",
|
||||
"location": "backend-go/internal/api/ai_chat.go:ChatMessage:plan",
|
||||
"traceId": traceID,
|
||||
"msg": "[DEBUG] knowledge plan built",
|
||||
"data": gin.H{
|
||||
"intent": plan.Intent,
|
||||
"layer": plan.Layer,
|
||||
"citations": len(plan.Citations),
|
||||
"llmMessageSize": len(plan.LLMMessages),
|
||||
},
|
||||
"ts": time.Now().UnixMilli(),
|
||||
}); err == nil {
|
||||
go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload)))
|
||||
}
|
||||
// #endregion
|
||||
|
||||
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||
@@ -123,51 +85,20 @@ func ChatMessage(c *gin.Context) {
|
||||
writeEvent(gin.H{"type": "error", "message": "AI 点数不足,请联系管理员充值"})
|
||||
return
|
||||
}
|
||||
route, err := config.GetRoute("path_coach")
|
||||
aiRouteID := strings.TrimSpace(req.AIRouteID)
|
||||
if aiRouteID == "" {
|
||||
aiRouteID = "path_coach"
|
||||
}
|
||||
aiRoute, err := config.GetRoute(aiRouteID)
|
||||
if err != nil {
|
||||
writeEvent(gin.H{"type": "error", "message": "LLM 路由未配置: " + err.Error()})
|
||||
return
|
||||
}
|
||||
// #region debug-point B:llm-route
|
||||
if payload, err := json.Marshal(gin.H{
|
||||
"sessionId": "knowledge-chat-401",
|
||||
"runId": "pre-fix",
|
||||
"hypothesisId": "B",
|
||||
"location": "backend-go/internal/api/ai_chat.go:ChatMessage:llm-route",
|
||||
"traceId": traceID,
|
||||
"msg": "[DEBUG] llm route selected",
|
||||
"data": gin.H{
|
||||
"routeId": route.RouteID,
|
||||
"provider": route.Provider,
|
||||
"baseURL": route.BaseURL,
|
||||
"model": route.Model,
|
||||
},
|
||||
"ts": time.Now().UnixMilli(),
|
||||
}); err == nil {
|
||||
go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload)))
|
||||
}
|
||||
// #endregion
|
||||
start := time.Now()
|
||||
usedRoute, err := ai.GenerateStreamWithFallback(route, plan.LLMMessages, func(chunk string) {
|
||||
usedAiRoute, err := ai.GenerateStreamWithFallback(aiRoute, plan.LLMMessages, func(chunk string) {
|
||||
writeEvent(gin.H{"type": "text", "content": chunk})
|
||||
})
|
||||
if err != nil {
|
||||
// #region debug-point B:llm-error
|
||||
if payload, marshalErr := json.Marshal(gin.H{
|
||||
"sessionId": "knowledge-chat-401",
|
||||
"runId": "pre-fix",
|
||||
"hypothesisId": "B",
|
||||
"location": "backend-go/internal/api/ai_chat.go:ChatMessage:llm-error",
|
||||
"traceId": traceID,
|
||||
"msg": "[DEBUG] llm stream returned error",
|
||||
"data": gin.H{
|
||||
"error": err.Error(),
|
||||
},
|
||||
"ts": time.Now().UnixMilli(),
|
||||
}); marshalErr == nil {
|
||||
go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload)))
|
||||
}
|
||||
// #endregion
|
||||
message := err.Error()
|
||||
if strings.Contains(message, "未配置 API Key") || strings.Contains(message, "No cookie auth credentials found") {
|
||||
message = "当前知识库已进入 LLM 兜底层,但系统还没有配置可用的 LLM 服务。请管理员配置 OpenRouter API Key,或启动本地 Ollama 后再重试。"
|
||||
@@ -176,20 +107,20 @@ func ChatMessage(c *gin.Context) {
|
||||
}
|
||||
writeEvent(gin.H{"type": "error", "message": message})
|
||||
ai.LogCall(ai.LogEntry{
|
||||
UserID: user.ID, Capability: ai.CapabilityAIChat, Provider: route.Provider,
|
||||
RouteID: route.RouteID, Model: route.Model, Success: false,
|
||||
UserID: user.ID, Capability: ai.CapabilityAIChat, Provider: aiRoute.Provider,
|
||||
AIRouteID: aiRoute.RouteID, Model: aiRoute.Model, Success: false,
|
||||
ErrorMessage: err.Error(), LatencyMs: int(time.Since(start).Milliseconds()),
|
||||
})
|
||||
return
|
||||
}
|
||||
writeEvent(gin.H{"type": "done"})
|
||||
finalRoute := route
|
||||
if usedRoute != nil {
|
||||
finalRoute = usedRoute
|
||||
finalAiRoute := aiRoute
|
||||
if usedAiRoute != nil {
|
||||
finalAiRoute = usedAiRoute
|
||||
}
|
||||
ai.LogCall(ai.LogEntry{
|
||||
UserID: user.ID, Capability: ai.CapabilityAIChat, Provider: finalRoute.Provider,
|
||||
RouteID: finalRoute.RouteID, Model: finalRoute.Model, Success: true,
|
||||
UserID: user.ID, Capability: ai.CapabilityAIChat, Provider: finalAiRoute.Provider,
|
||||
AIRouteID: finalAiRoute.RouteID, Model: finalAiRoute.Model, Success: true,
|
||||
LatencyMs: int(time.Since(start).Milliseconds()),
|
||||
})
|
||||
}
|
||||
@@ -285,7 +216,7 @@ func QuickAction(c *gin.Context) {
|
||||
for _, item := range citations {
|
||||
knowledge = append(knowledge, item.Content)
|
||||
}
|
||||
route, err := config.GetRoute("title_gen")
|
||||
aiRoute, err := config.GetRoute("title_gen")
|
||||
if err != nil {
|
||||
web.OK(c, gin.H{
|
||||
"result": fallbackResult,
|
||||
@@ -297,17 +228,17 @@ func QuickAction(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
systemPrompt := buildSystemPrompt(req.Params, knowledge) + "\n\n当前任务:" + task.task
|
||||
messages := []ai.Message{
|
||||
aiMessages := []ai.Message{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: task.query},
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
result, usedRoute, err := ai.GenerateFullWithFallback(route, messages)
|
||||
result, usedAiRoute, err := ai.GenerateFullWithFallback(aiRoute, aiMessages)
|
||||
if err != nil {
|
||||
ai.LogCall(ai.LogEntry{
|
||||
UserID: user.ID, Capability: ai.CapabilityTextGen, Provider: route.Provider,
|
||||
RouteID: route.RouteID, Model: route.Model, Success: false,
|
||||
UserID: user.ID, Capability: ai.CapabilityTextGen, Provider: aiRoute.Provider,
|
||||
AIRouteID: aiRoute.RouteID, Model: aiRoute.Model, Success: false,
|
||||
ErrorMessage: err.Error(), LatencyMs: int(time.Since(start).Milliseconds()),
|
||||
})
|
||||
web.OK(c, gin.H{
|
||||
@@ -320,8 +251,8 @@ func QuickAction(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
ai.LogCall(ai.LogEntry{
|
||||
UserID: user.ID, Capability: ai.CapabilityTextGen, Provider: usedRoute.Provider,
|
||||
RouteID: usedRoute.RouteID, Model: usedRoute.Model, Success: true,
|
||||
UserID: user.ID, Capability: ai.CapabilityTextGen, Provider: usedAiRoute.Provider,
|
||||
AIRouteID: usedAiRoute.RouteID, Model: usedAiRoute.Model, Success: true,
|
||||
TokensInput: result.Usage.PromptTokens, TokensOutput: result.Usage.CompletionTokens,
|
||||
LatencyMs: int(time.Since(start).Milliseconds()),
|
||||
})
|
||||
|
||||
@@ -89,8 +89,8 @@ func extractExt(filename string) string {
|
||||
func transcribeAudio(fileData []byte, ext, language string) AudioTranscribeResult {
|
||||
// 尝试从路由配置获取 LLM baseURL,降级到 Ollama 默认
|
||||
baseURL := "http://127.0.0.1:11434"
|
||||
if r, err := config.GetRoute("title_gen"); err == nil && r != nil && r.BaseURL != "" {
|
||||
baseURL = r.BaseURL
|
||||
if aiRoute, err := config.GetRoute("title_gen"); err == nil && aiRoute != nil && aiRoute.BaseURL != "" {
|
||||
baseURL = aiRoute.BaseURL
|
||||
// 去掉 /v1 后缀,确保路径正确
|
||||
baseURL = strings.TrimSuffix(baseURL, "/v1")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
"eai_agentplatform/backend/internal/store"
|
||||
"eai_agentplatform/backend/internal/web"
|
||||
)
|
||||
|
||||
type skillDefinitionReq struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
RoleKind string `json:"role_kind"`
|
||||
Source string `json:"source"`
|
||||
ObjectEntryRoute string `json:"object_entry_route"`
|
||||
LegacyObjectEntryRoute string `json:"legacy_object_entry_route"`
|
||||
ExposedToUser bool `json:"exposed_to_user"`
|
||||
StarterPromptsJSON string `json:"starter_prompts_json"`
|
||||
PromptTemplate string `json:"prompt_template"`
|
||||
InputSchemaJSON string `json:"input_schema_json"`
|
||||
OutputSchemaJSON string `json:"output_schema_json"`
|
||||
ArtifactSchemaJSON string `json:"artifact_schema_json"`
|
||||
ActionRefsJSON string `json:"action_refs_json"`
|
||||
PolicyRefsJSON string `json:"policy_refs_json"`
|
||||
OntologyBindingJSON string `json:"ontology_binding_json"`
|
||||
State string `json:"state"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
type actionDefinitionReq struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
ActionType string `json:"action_type"`
|
||||
ConnectorRef string `json:"connector_ref"`
|
||||
InputSchemaJSON string `json:"input_schema_json"`
|
||||
OutputSchemaJSON string `json:"output_schema_json"`
|
||||
RiskLevel string `json:"risk_level"`
|
||||
ApprovalMode string `json:"approval_mode"`
|
||||
AuditLevel string `json:"audit_level"`
|
||||
ExposedToUser bool `json:"exposed_to_user"`
|
||||
OntologyBindingJSON string `json:"ontology_binding_json"`
|
||||
State string `json:"state"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
func normalizeSkillDefinitionReq(req *skillDefinitionReq) {
|
||||
req.Key = strings.TrimSpace(req.Key)
|
||||
req.Label = strings.TrimSpace(req.Label)
|
||||
req.Description = strings.TrimSpace(req.Description)
|
||||
req.RoleKind = strings.TrimSpace(req.RoleKind)
|
||||
req.Source = strings.TrimSpace(req.Source)
|
||||
req.ObjectEntryRoute = strings.TrimSpace(req.ObjectEntryRoute)
|
||||
req.LegacyObjectEntryRoute = strings.TrimSpace(req.LegacyObjectEntryRoute)
|
||||
req.StarterPromptsJSON = strings.TrimSpace(req.StarterPromptsJSON)
|
||||
req.PromptTemplate = strings.TrimSpace(req.PromptTemplate)
|
||||
req.InputSchemaJSON = strings.TrimSpace(req.InputSchemaJSON)
|
||||
req.OutputSchemaJSON = strings.TrimSpace(req.OutputSchemaJSON)
|
||||
req.ArtifactSchemaJSON = strings.TrimSpace(req.ArtifactSchemaJSON)
|
||||
req.ActionRefsJSON = strings.TrimSpace(req.ActionRefsJSON)
|
||||
req.PolicyRefsJSON = strings.TrimSpace(req.PolicyRefsJSON)
|
||||
req.OntologyBindingJSON = strings.TrimSpace(req.OntologyBindingJSON)
|
||||
req.State = strings.TrimSpace(req.State)
|
||||
}
|
||||
|
||||
func validateSkillDefinitionReq(req *skillDefinitionReq) *web.AppError {
|
||||
normalizeSkillDefinitionReq(req)
|
||||
if req.Key == "" || req.Label == "" {
|
||||
return web.NewBadRequest("key、label 为必填")
|
||||
}
|
||||
if req.RoleKind == "" {
|
||||
req.RoleKind = "skill"
|
||||
}
|
||||
switch req.RoleKind {
|
||||
case "assistant", "specialist", "skill":
|
||||
default:
|
||||
return web.NewBadRequest("role_kind 只能是 assistant、specialist 或 skill")
|
||||
}
|
||||
if req.Source == "" {
|
||||
req.Source = "eai"
|
||||
}
|
||||
if req.State == "" {
|
||||
req.State = "active"
|
||||
}
|
||||
if req.State != "active" && req.State != "inactive" {
|
||||
return web.NewBadRequest("state 只能是 active 或 inactive")
|
||||
}
|
||||
if !store.ValidateJSONStringArray(req.StarterPromptsJSON) {
|
||||
return web.NewBadRequest("starter_prompts_json 必须是字符串数组")
|
||||
}
|
||||
if !store.ValidateJSONObjectJSON(req.InputSchemaJSON) {
|
||||
return web.NewBadRequest("input_schema_json 必须是 JSON 对象")
|
||||
}
|
||||
if !store.ValidateJSONObjectJSON(req.OutputSchemaJSON) {
|
||||
return web.NewBadRequest("output_schema_json 必须是 JSON 对象")
|
||||
}
|
||||
if !store.ValidateJSONObjectJSON(req.ArtifactSchemaJSON) {
|
||||
return web.NewBadRequest("artifact_schema_json 必须是 JSON 对象")
|
||||
}
|
||||
if !store.ValidateJSONStringArray(req.ActionRefsJSON) {
|
||||
return web.NewBadRequest("action_refs_json 必须是字符串数组")
|
||||
}
|
||||
if !store.ValidateJSONStringArray(req.PolicyRefsJSON) {
|
||||
return web.NewBadRequest("policy_refs_json 必须是字符串数组")
|
||||
}
|
||||
if !store.ValidateJSONObjectJSON(req.OntologyBindingJSON) {
|
||||
return web.NewBadRequest("ontology_binding_json 必须是 JSON 对象")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeActionDefinitionReq(req *actionDefinitionReq) {
|
||||
req.Key = strings.TrimSpace(req.Key)
|
||||
req.Label = strings.TrimSpace(req.Label)
|
||||
req.Description = strings.TrimSpace(req.Description)
|
||||
req.ActionType = strings.TrimSpace(req.ActionType)
|
||||
req.ConnectorRef = strings.TrimSpace(req.ConnectorRef)
|
||||
req.InputSchemaJSON = strings.TrimSpace(req.InputSchemaJSON)
|
||||
req.OutputSchemaJSON = strings.TrimSpace(req.OutputSchemaJSON)
|
||||
req.RiskLevel = strings.TrimSpace(req.RiskLevel)
|
||||
req.ApprovalMode = strings.TrimSpace(req.ApprovalMode)
|
||||
req.AuditLevel = strings.TrimSpace(req.AuditLevel)
|
||||
req.OntologyBindingJSON = strings.TrimSpace(req.OntologyBindingJSON)
|
||||
req.State = strings.TrimSpace(req.State)
|
||||
}
|
||||
|
||||
func validateActionDefinitionReq(req *actionDefinitionReq) *web.AppError {
|
||||
normalizeActionDefinitionReq(req)
|
||||
if req.Key == "" || req.Label == "" {
|
||||
return web.NewBadRequest("key、label 为必填")
|
||||
}
|
||||
if req.ActionType == "" {
|
||||
req.ActionType = "execution"
|
||||
}
|
||||
if req.RiskLevel == "" {
|
||||
req.RiskLevel = "low"
|
||||
}
|
||||
if req.ApprovalMode == "" {
|
||||
req.ApprovalMode = "not_required"
|
||||
}
|
||||
if req.AuditLevel == "" {
|
||||
req.AuditLevel = "standard"
|
||||
}
|
||||
if req.State == "" {
|
||||
req.State = "active"
|
||||
}
|
||||
if req.State != "active" && req.State != "inactive" {
|
||||
return web.NewBadRequest("state 只能是 active 或 inactive")
|
||||
}
|
||||
if !store.ValidateJSONObjectJSON(req.InputSchemaJSON) {
|
||||
return web.NewBadRequest("input_schema_json 必须是 JSON 对象")
|
||||
}
|
||||
if !store.ValidateJSONObjectJSON(req.OutputSchemaJSON) {
|
||||
return web.NewBadRequest("output_schema_json 必须是 JSON 对象")
|
||||
}
|
||||
if !store.ValidateJSONObjectJSON(req.OntologyBindingJSON) {
|
||||
return web.NewBadRequest("ontology_binding_json 必须是 JSON 对象")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ListSkillDefinitions(c *gin.Context) {
|
||||
q := store.DB.Model(&model.SkillDefinition{})
|
||||
if c.Query("state") == "" {
|
||||
q = q.Where("state = ?", "active")
|
||||
} else {
|
||||
q = q.Where("state = ?", c.Query("state"))
|
||||
}
|
||||
if c.Query("exposed_to_user") != "" {
|
||||
q = q.Where("exposed_to_user = ?", c.Query("exposed_to_user") == "true")
|
||||
}
|
||||
var items []model.SkillDefinition
|
||||
if err := q.Order("sort_order ASC, id ASC").Find(&items).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("查询技能定义失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, items)
|
||||
}
|
||||
|
||||
func GetSkillDefinitionByKey(c *gin.Context) {
|
||||
key := strings.TrimSpace(c.Param("key"))
|
||||
if key == "" {
|
||||
web.Fail(c, web.NewBadRequest("技能 key 不能为空"))
|
||||
return
|
||||
}
|
||||
var item model.SkillDefinition
|
||||
if err := store.DB.Where("key = ?", key).First(&item).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("技能定义不存在"))
|
||||
return
|
||||
}
|
||||
web.OK(c, item)
|
||||
}
|
||||
|
||||
func CreateSkillDefinition(c *gin.Context) {
|
||||
var req skillDefinitionReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
web.Fail(c, web.NewBadRequest("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if appErr := validateSkillDefinitionReq(&req); appErr != nil {
|
||||
web.Fail(c, appErr)
|
||||
return
|
||||
}
|
||||
item := model.SkillDefinition{
|
||||
Key: req.Key,
|
||||
Label: req.Label,
|
||||
Description: req.Description,
|
||||
RoleKind: req.RoleKind,
|
||||
Source: req.Source,
|
||||
ObjectEntryRoute: req.ObjectEntryRoute,
|
||||
LegacyObjectEntryRoute: req.LegacyObjectEntryRoute,
|
||||
ExposedToUser: req.ExposedToUser,
|
||||
StarterPromptsJSON: req.StarterPromptsJSON,
|
||||
PromptTemplate: req.PromptTemplate,
|
||||
InputSchemaJSON: req.InputSchemaJSON,
|
||||
OutputSchemaJSON: req.OutputSchemaJSON,
|
||||
ArtifactSchemaJSON: req.ArtifactSchemaJSON,
|
||||
ActionRefsJSON: req.ActionRefsJSON,
|
||||
PolicyRefsJSON: req.PolicyRefsJSON,
|
||||
OntologyBindingJSON: req.OntologyBindingJSON,
|
||||
State: req.State,
|
||||
SortOrder: req.SortOrder,
|
||||
}
|
||||
if err := store.DB.Create(&item).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("创建技能定义失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, item)
|
||||
}
|
||||
|
||||
func UpdateSkillDefinition(c *gin.Context) {
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var item model.SkillDefinition
|
||||
if err := store.DB.First(&item, id).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("技能定义不存在"))
|
||||
return
|
||||
}
|
||||
var req skillDefinitionReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
web.Fail(c, web.NewBadRequest("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if appErr := validateSkillDefinitionReq(&req); appErr != nil {
|
||||
web.Fail(c, appErr)
|
||||
return
|
||||
}
|
||||
item.Key = req.Key
|
||||
item.Label = req.Label
|
||||
item.Description = req.Description
|
||||
item.RoleKind = req.RoleKind
|
||||
item.Source = req.Source
|
||||
item.ObjectEntryRoute = req.ObjectEntryRoute
|
||||
item.LegacyObjectEntryRoute = req.LegacyObjectEntryRoute
|
||||
item.ExposedToUser = req.ExposedToUser
|
||||
item.StarterPromptsJSON = req.StarterPromptsJSON
|
||||
item.PromptTemplate = req.PromptTemplate
|
||||
item.InputSchemaJSON = req.InputSchemaJSON
|
||||
item.OutputSchemaJSON = req.OutputSchemaJSON
|
||||
item.ArtifactSchemaJSON = req.ArtifactSchemaJSON
|
||||
item.ActionRefsJSON = req.ActionRefsJSON
|
||||
item.PolicyRefsJSON = req.PolicyRefsJSON
|
||||
item.OntologyBindingJSON = req.OntologyBindingJSON
|
||||
item.State = req.State
|
||||
item.SortOrder = req.SortOrder
|
||||
if err := store.DB.Save(&item).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("更新技能定义失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, item)
|
||||
}
|
||||
|
||||
func DeleteSkillDefinition(c *gin.Context) {
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var item model.SkillDefinition
|
||||
if err := store.DB.First(&item, id).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("技能定义不存在"))
|
||||
return
|
||||
}
|
||||
if err := store.DB.Delete(&item).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("删除技能定义失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, gin.H{"id": id, "deleted": true})
|
||||
}
|
||||
|
||||
func ListActionDefinitions(c *gin.Context) {
|
||||
q := store.DB.Model(&model.ActionDefinition{})
|
||||
if c.Query("state") == "" {
|
||||
q = q.Where("state = ?", "active")
|
||||
} else {
|
||||
q = q.Where("state = ?", c.Query("state"))
|
||||
}
|
||||
var items []model.ActionDefinition
|
||||
if err := q.Order("sort_order ASC, id ASC").Find(&items).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("查询 Action 定义失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, items)
|
||||
}
|
||||
|
||||
func GetActionDefinitionByKey(c *gin.Context) {
|
||||
key := strings.TrimSpace(c.Param("key"))
|
||||
if key == "" {
|
||||
web.Fail(c, web.NewBadRequest("action key 不能为空"))
|
||||
return
|
||||
}
|
||||
var item model.ActionDefinition
|
||||
if err := store.DB.Where("key = ?", key).First(&item).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("Action 定义不存在"))
|
||||
return
|
||||
}
|
||||
web.OK(c, item)
|
||||
}
|
||||
|
||||
func CreateActionDefinition(c *gin.Context) {
|
||||
var req actionDefinitionReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
web.Fail(c, web.NewBadRequest("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if appErr := validateActionDefinitionReq(&req); appErr != nil {
|
||||
web.Fail(c, appErr)
|
||||
return
|
||||
}
|
||||
item := model.ActionDefinition{
|
||||
Key: req.Key,
|
||||
Label: req.Label,
|
||||
Description: req.Description,
|
||||
ActionType: req.ActionType,
|
||||
ConnectorRef: req.ConnectorRef,
|
||||
InputSchemaJSON: req.InputSchemaJSON,
|
||||
OutputSchemaJSON: req.OutputSchemaJSON,
|
||||
RiskLevel: req.RiskLevel,
|
||||
ApprovalMode: req.ApprovalMode,
|
||||
AuditLevel: req.AuditLevel,
|
||||
ExposedToUser: req.ExposedToUser,
|
||||
OntologyBindingJSON: req.OntologyBindingJSON,
|
||||
State: req.State,
|
||||
SortOrder: req.SortOrder,
|
||||
}
|
||||
if err := store.DB.Create(&item).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("创建 Action 定义失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, item)
|
||||
}
|
||||
|
||||
func UpdateActionDefinition(c *gin.Context) {
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var item model.ActionDefinition
|
||||
if err := store.DB.First(&item, id).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("Action 定义不存在"))
|
||||
return
|
||||
}
|
||||
var req actionDefinitionReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
web.Fail(c, web.NewBadRequest("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if appErr := validateActionDefinitionReq(&req); appErr != nil {
|
||||
web.Fail(c, appErr)
|
||||
return
|
||||
}
|
||||
item.Key = req.Key
|
||||
item.Label = req.Label
|
||||
item.Description = req.Description
|
||||
item.ActionType = req.ActionType
|
||||
item.ConnectorRef = req.ConnectorRef
|
||||
item.InputSchemaJSON = req.InputSchemaJSON
|
||||
item.OutputSchemaJSON = req.OutputSchemaJSON
|
||||
item.RiskLevel = req.RiskLevel
|
||||
item.ApprovalMode = req.ApprovalMode
|
||||
item.AuditLevel = req.AuditLevel
|
||||
item.ExposedToUser = req.ExposedToUser
|
||||
item.OntologyBindingJSON = req.OntologyBindingJSON
|
||||
item.State = req.State
|
||||
item.SortOrder = req.SortOrder
|
||||
if err := store.DB.Save(&item).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("更新 Action 定义失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, item)
|
||||
}
|
||||
|
||||
func DeleteActionDefinition(c *gin.Context) {
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var item model.ActionDefinition
|
||||
if err := store.DB.First(&item, id).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("Action 定义不存在"))
|
||||
return
|
||||
}
|
||||
if err := store.DB.Delete(&item).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("删除 Action 定义失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, gin.H{"id": id, "deleted": true})
|
||||
}
|
||||
@@ -64,11 +64,11 @@ func SuggestMaterial(c *gin.Context) {
|
||||
remark := strings.TrimSpace(c.PostForm("remark"))
|
||||
|
||||
ext := extOf(header.Filename)
|
||||
if !allowedExt[ext] {
|
||||
if !isUploadableExt(ext) {
|
||||
web.Fail(c, web.NewBadRequest("不支持的文件类型"))
|
||||
return
|
||||
}
|
||||
if ext == "mp4" && header.Size > Cfg.ChunkThreshold {
|
||||
if isVideoExt(ext) && header.Size > Cfg.ChunkThreshold {
|
||||
web.Fail(c, web.NewBadRequest("视频超过 100MB 请使用分片上传"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -55,7 +55,6 @@ func ProofreadCopy(c *gin.Context) {
|
||||
web.Fail(c, web.NewBadRequest("text 必填"))
|
||||
return
|
||||
}
|
||||
req.Mode = req.Mode
|
||||
if req.Mode == "" {
|
||||
req.Mode = "basic"
|
||||
}
|
||||
|
||||
@@ -28,11 +28,11 @@ type DocumentTranslateRequest struct {
|
||||
|
||||
// DocumentTranslateResult 翻译结果
|
||||
type DocumentTranslateResult struct {
|
||||
TranslatedContent string `json:"content"`
|
||||
SourceLang string `json:"source_lang"`
|
||||
TargetLang string `json:"target_lang"`
|
||||
Format string `json:"format"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
TranslatedContent string `json:"content"`
|
||||
SourceLang string `json:"source_lang"`
|
||||
TargetLang string `json:"target_lang"`
|
||||
Format string `json:"format"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// TranslateDocument POST /api/document/translate —— 文档翻译
|
||||
@@ -47,7 +47,6 @@ func TranslateDocument(c *gin.Context) {
|
||||
web.Fail(c, web.NewBadRequest("content 必填"))
|
||||
return
|
||||
}
|
||||
req.SourceLang = req.SourceLang
|
||||
if req.TargetLang == "" {
|
||||
req.TargetLang = "zh"
|
||||
}
|
||||
@@ -122,8 +121,8 @@ func translateText(content, source, target string) string {
|
||||
4. 如果是代码、URL、特殊标识符则保留不翻译
|
||||
5. 直接输出翻译结果,不要包含解释说明`, srcName, tgtName)
|
||||
|
||||
route, _ := config.GetRoute("title_gen")
|
||||
content, err := ai.GenerateWithFallback(route, []ai.Message{
|
||||
aiRoute, _ := config.GetRoute("title_gen")
|
||||
translatedContent, err := ai.GenerateWithFallback(aiRoute, []ai.Message{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: content},
|
||||
})
|
||||
@@ -131,7 +130,7 @@ func translateText(content, source, target string) string {
|
||||
// 回退:简单逐段翻译
|
||||
return fallbackTranslate(content, source, target)
|
||||
}
|
||||
return content
|
||||
return translatedContent
|
||||
}
|
||||
|
||||
// fallbackTranslate 简易回退翻译
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
// gradeEssay 调 LLM 按评分标准对简答题作答打分,返回 0~1 得分率与一句话评语。
|
||||
// 复用 text_gen 路由(title_gen),走回退链,全程审计。失败时返回 error 由调用方决定降级。
|
||||
func gradeEssay(userID uint, stem, rubric, userAnswer string) (float64, string, error) {
|
||||
route, err := config.GetRoute("title_gen")
|
||||
aiRoute, err := config.GetRoute("title_gen")
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
@@ -21,24 +21,24 @@ func gradeEssay(userID uint, stem, rubric, userAnswer string) (float64, string,
|
||||
"只输出一个 JSON 对象,格式:{\"score\": <0到1之间的得分率数字>, \"comment\": \"<一句中文评语>\"}。" +
|
||||
"不要输出任何其他内容。"
|
||||
userPrompt := "题目:" + stem + "\n评分标准:" + rubric + "\n学员答案:" + userAnswer
|
||||
messages := []ai.Message{
|
||||
aiMessages := []ai.Message{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: userPrompt},
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
result, usedRoute, err := ai.GenerateFullWithFallback(route, messages)
|
||||
result, usedAiRoute, err := ai.GenerateFullWithFallback(aiRoute, aiMessages)
|
||||
if err != nil {
|
||||
ai.LogCall(ai.LogEntry{
|
||||
UserID: userID, Capability: ai.CapabilityEssayGrade, Provider: route.Provider,
|
||||
RouteID: route.RouteID, Model: route.Model, Success: false,
|
||||
UserID: userID, Capability: ai.CapabilityEssayGrade, Provider: aiRoute.Provider,
|
||||
AIRouteID: aiRoute.RouteID, Model: aiRoute.Model, Success: false,
|
||||
ErrorMessage: err.Error(), LatencyMs: int(time.Since(start).Milliseconds()),
|
||||
})
|
||||
return 0, "", err
|
||||
}
|
||||
ai.LogCall(ai.LogEntry{
|
||||
UserID: userID, Capability: ai.CapabilityEssayGrade, Provider: usedRoute.Provider,
|
||||
RouteID: usedRoute.RouteID, Model: usedRoute.Model, Success: true,
|
||||
UserID: userID, Capability: ai.CapabilityEssayGrade, Provider: usedAiRoute.Provider,
|
||||
AIRouteID: usedAiRoute.RouteID, Model: usedAiRoute.Model, Success: true,
|
||||
TokensInput: result.Usage.PromptTokens, TokensOutput: result.Usage.CompletionTokens,
|
||||
LatencyMs: int(time.Since(start).Milliseconds()),
|
||||
})
|
||||
|
||||
@@ -24,14 +24,11 @@ func RebuildKnowledgeIndex(c *gin.Context) {
|
||||
web.OK(c, gin.H{
|
||||
"rebuild": true,
|
||||
"chunk_count": count,
|
||||
"service_enabled": ai.KnowledgeServiceEnabled(Cfg),
|
||||
"service_enabled": false,
|
||||
})
|
||||
}
|
||||
|
||||
func triggerKnowledgeIndexRebuild() {
|
||||
if !ai.KnowledgeServiceEnabled(Cfg) {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
if count, err := rebuildKnowledgeIndexNow(); err != nil {
|
||||
log.Printf("[知识索引] 重建失败: %v", err)
|
||||
@@ -46,9 +43,6 @@ func rebuildKnowledgeIndexNow() (int, error) {
|
||||
defer knowledgeIndexRebuildLock.Unlock()
|
||||
|
||||
items := buildKnowledgeIndexItems()
|
||||
if err := ai.KnowledgeRebuildIndex(Cfg, items); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(items), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -92,42 +92,22 @@ func buildKnowledgeAnswerPlan(query string, ctx map[string]any, history []ai.Mes
|
||||
}, nil
|
||||
}
|
||||
|
||||
messages := []ai.Message{{
|
||||
aiMessages := []ai.Message{{
|
||||
Role: "system",
|
||||
Content: buildKnowledgePipelinePrompt(ctx, intent, citations),
|
||||
}}
|
||||
messages = append(messages, history...)
|
||||
messages = append(messages, ai.Message{Role: "user", Content: query})
|
||||
aiMessages = append(aiMessages, history...)
|
||||
aiMessages = append(aiMessages, ai.Message{Role: "user", Content: query})
|
||||
return &knowledgeAnswerPlan{
|
||||
Intent: intent,
|
||||
Layer: layerLLM,
|
||||
Citations: citations,
|
||||
LLMMessages: messages,
|
||||
LLMMessages: aiMessages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func classifyKnowledgeIntent(query string) (string, string) {
|
||||
if ai.KnowledgeServiceEnabled(Cfg) {
|
||||
if result, err := ai.KnowledgeClassify(Cfg, query); err == nil && result != nil {
|
||||
switch result.Intent {
|
||||
case intentSmallTalk:
|
||||
return intentSmallTalk, buildSmallTalkAnswer(query)
|
||||
case intentOutOfScope:
|
||||
if result.Score >= 0.9 {
|
||||
return intentOutOfScope, "当前知识库仅面向博昇 AI 实验室内部业务知识,不处理越域问题。"
|
||||
}
|
||||
case intentInvalid:
|
||||
if result.Score >= 0.9 {
|
||||
return intentInvalid, "请输入更明确的问题后再试。"
|
||||
}
|
||||
case intentFAQ, intentDocument:
|
||||
if result.Score >= 0.6 {
|
||||
return result.Intent, ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return heuristicKnowledgeIntent(query)
|
||||
return heuristicKnowledgeIntent(query)
|
||||
}
|
||||
|
||||
func heuristicKnowledgeIntent(query string) (string, string) {
|
||||
@@ -139,7 +119,7 @@ func heuristicKnowledgeIntent(query string) (string, string) {
|
||||
smallTalkKeywords := []string{"讲个故事", "小故事", "笑话", "闲聊", "聊天", "夸夸我", "你是谁", "今天天气", "天气", "星座"}
|
||||
for _, keyword := range smallTalkKeywords {
|
||||
if strings.Contains(lower, keyword) {
|
||||
return intentSmallTalk, buildSmallTalkAnswer(query)
|
||||
return intentSmallTalk, buildSmallTalkAnswer(query)
|
||||
}
|
||||
}
|
||||
outOfScopeKeywords := []string{"股票", "彩票", "电影", "明星", "旅游", "菜谱", "医学诊断", "法律咨询", "写诗", "翻译成英文"}
|
||||
@@ -167,40 +147,40 @@ func heuristicKnowledgeIntent(query string) (string, string) {
|
||||
}
|
||||
|
||||
func buildSmallTalkAnswer(query string) string {
|
||||
lower := strings.ToLower(strings.TrimSpace(query))
|
||||
switch {
|
||||
case strings.Contains(lower, "你是谁"):
|
||||
return "你好,我是博昇 AI 实验室的知识库助手,负责基于企业知识库回答业务制度、产品资料、培训内容和规则流程相关问题。你可以直接提问,也可以先选择左侧知识库缩小检索范围。"
|
||||
case strings.Contains(lower, "你能做什么"), strings.Contains(lower, "怎么工作"):
|
||||
return "我可以帮你回答制度规则、产品资料、FAQ、培训文档相关问题,也可以基于知识库做摘要、梳理、对比和提纲生成。默认会全局检索全部知识库,你也可以在左侧切换到某个知识库后再提问。"
|
||||
case strings.Contains(lower, "你好"), strings.Contains(lower, "hello"), strings.Contains(lower, "hi"):
|
||||
return "你好,欢迎使用博昇 AI 实验室知识库助手。你可以直接问我产品资料、审批规则、培训内容、FAQ 或制度流程相关问题,我会按知识库内容为你检索和回答。"
|
||||
default:
|
||||
return "你好,我是知识库助手。当前更适合处理业务知识、制度规则、产品资料和培训文档相关问题;如果你愿意,我可以继续帮你查规则、找资料或整理内容。"
|
||||
}
|
||||
lower := strings.ToLower(strings.TrimSpace(query))
|
||||
switch {
|
||||
case strings.Contains(lower, "你是谁"):
|
||||
return "你好,我是博昇 AI 实验室的知识库助手,负责基于企业知识库回答业务制度、产品资料、培训内容和规则流程相关问题。你可以直接提问,也可以先选择左侧知识库缩小检索范围。"
|
||||
case strings.Contains(lower, "你能做什么"), strings.Contains(lower, "怎么工作"):
|
||||
return "我可以帮你回答制度规则、产品资料、FAQ、培训文档相关问题,也可以基于知识库做摘要、梳理、对比和提纲生成。默认会全局检索全部知识库,你也可以在左侧切换到某个知识库后再提问。"
|
||||
case strings.Contains(lower, "你好"), strings.Contains(lower, "hello"), strings.Contains(lower, "hi"):
|
||||
return "你好,欢迎使用博昇 AI 实验室知识库助手。你可以直接问我产品资料、审批规则、培训内容、FAQ 或制度流程相关问题,我会按知识库内容为你检索和回答。"
|
||||
default:
|
||||
return "你好,我是知识库助手。当前更适合处理业务知识、制度规则、产品资料和培训文档相关问题;如果你愿意,我可以继续帮你查规则、找资料或整理内容。"
|
||||
}
|
||||
}
|
||||
|
||||
func extractKnowledgeSpaceKey(ctx map[string]any) string {
|
||||
if ctx == nil {
|
||||
return "all"
|
||||
return "all"
|
||||
}
|
||||
if raw, ok := ctx["knowledge_space_key"]; ok {
|
||||
key := sanitizeSpaceKey(fmt.Sprint(raw))
|
||||
if key == "" {
|
||||
return "all"
|
||||
}
|
||||
if key == "all" {
|
||||
return "all"
|
||||
}
|
||||
return ensureKnowledgeSpaceKeyOrDefault(key)
|
||||
key := sanitizeSpaceKey(fmt.Sprint(raw))
|
||||
if key == "" {
|
||||
return "all"
|
||||
}
|
||||
if key == "all" {
|
||||
return "all"
|
||||
}
|
||||
return ensureKnowledgeSpaceKeyOrDefault(key)
|
||||
}
|
||||
return "all"
|
||||
return "all"
|
||||
}
|
||||
|
||||
func matchKnowledgeFAQ(query, spaceKey string) (model.KnowledgeFAQ, bool) {
|
||||
spaceKey = sanitizeSpaceKey(spaceKey)
|
||||
spaceKey = sanitizeSpaceKey(spaceKey)
|
||||
q := store.DB.Where("status = ?", "active")
|
||||
if spaceKey != "" && spaceKey != "general" && spaceKey != "all" {
|
||||
if spaceKey != "" && spaceKey != "general" && spaceKey != "all" {
|
||||
q = q.Where("knowledge_space_key IN ?", []string{spaceKey, "general"})
|
||||
}
|
||||
var faqs []model.KnowledgeFAQ
|
||||
@@ -302,32 +282,12 @@ func retrieveKnowledgeCitations(query, spaceKey string, topK int) []knowledgeCit
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
if ai.KnowledgeServiceEnabled(Cfg) {
|
||||
if items, err := ai.KnowledgeSearch(Cfg, query, spaceKey, topK); err == nil {
|
||||
out := make([]knowledgeCitation, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, knowledgeCitation{
|
||||
ID: item.ID,
|
||||
Title: item.Title,
|
||||
Snippet: item.Snippet,
|
||||
Content: item.Content,
|
||||
ChunkIndex: item.ChunkIndex,
|
||||
SourceType: item.SourceType,
|
||||
SourceID: item.SourceID,
|
||||
KnowledgeSpaceKey: item.KnowledgeSpaceKey,
|
||||
KnowledgeSpaceName: item.KnowledgeSpaceName,
|
||||
Score: item.Score,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
candidates := loadKnowledgeCandidates(spaceKey)
|
||||
if len(candidates) == 0 {
|
||||
return []knowledgeCitation{}
|
||||
}
|
||||
if route, err := config.GetRoute("embed_gen"); err == nil {
|
||||
if items, ok := vectorRetrieveCitations(route, query, candidates, topK); ok {
|
||||
if aiRoute, err := config.GetRoute("embed_gen"); err == nil {
|
||||
if items, ok := vectorRetrieveCitations(aiRoute, query, candidates, topK); ok {
|
||||
return items
|
||||
}
|
||||
}
|
||||
@@ -367,14 +327,14 @@ func loadKnowledgeCandidates(spaceKey string) []knowledgeCandidate {
|
||||
func matchSpaceForKnowledgeQuery(selected, current string) bool {
|
||||
selected = sanitizeSpaceKey(selected)
|
||||
current = sanitizeSpaceKey(current)
|
||||
if selected == "" || selected == "general" || selected == "all" {
|
||||
if selected == "" || selected == "general" || selected == "all" {
|
||||
return true
|
||||
}
|
||||
return current == selected || current == "general"
|
||||
}
|
||||
|
||||
func vectorRetrieveCitations(route *config.RouteConfig, query string, candidates []knowledgeCandidate, topK int) ([]knowledgeCitation, bool) {
|
||||
client := ai.NewClient(route)
|
||||
func vectorRetrieveCitations(aiRoute *config.RouteConfig, query string, candidates []knowledgeCandidate, topK int) ([]knowledgeCitation, bool) {
|
||||
client := ai.NewClient(aiRoute)
|
||||
inputs := make([]string, 0, len(candidates)+1)
|
||||
inputs = append(inputs, query)
|
||||
for _, item := range candidates {
|
||||
|
||||
@@ -22,9 +22,16 @@ import (
|
||||
"eai_agentplatform/backend/internal/web"
|
||||
)
|
||||
|
||||
var allowedExt = map[string]bool{
|
||||
"ppt": true, "pptx": true, "pdf": true, "doc": true, "docx": true,
|
||||
"mp4": true, "png": true, "jpg": true, "jpeg": true,
|
||||
var blockedUploadExt = map[string]bool{
|
||||
"exe": true, "bin": true, "dll": true, "so": true, "dylib": true,
|
||||
"msi": true, "apk": true, "ipa": true, "deb": true, "rpm": true, "pkg": true, "appimage": true,
|
||||
"bat": true, "cmd": true, "com": true, "scr": true, "sys": true, "drv": true,
|
||||
"ps1": true, "psm1": true, "vbs": true, "vbe": true, "js": true, "jse": true, "wsf": true, "wsh": true,
|
||||
"reg": true, "lnk": true, "iso": true, "img": true, "dmg": true,
|
||||
}
|
||||
|
||||
var videoExt = map[string]bool{
|
||||
"mp4": true, "mov": true, "avi": true, "mkv": true, "webm": true, "m4v": true, "wmv": true, "flv": true,
|
||||
}
|
||||
|
||||
var validBindType = map[string]bool{"company": true, "product": true, "course": true, "none": true}
|
||||
@@ -44,8 +51,20 @@ func extOf(filename string) string {
|
||||
return strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), "."))
|
||||
}
|
||||
|
||||
func isUploadableExt(ext string) bool {
|
||||
normalized := strings.ToLower(strings.TrimSpace(ext))
|
||||
if normalized == "" {
|
||||
return false
|
||||
}
|
||||
return !blockedUploadExt[normalized]
|
||||
}
|
||||
|
||||
func isVideoExt(ext string) bool {
|
||||
return videoExt[strings.ToLower(strings.TrimSpace(ext))]
|
||||
}
|
||||
|
||||
func sizeLimitFor(ext string) int64 {
|
||||
if ext == "mp4" {
|
||||
if isVideoExt(ext) {
|
||||
return Cfg.FileMaxVideo
|
||||
}
|
||||
return Cfg.FileMaxDoc
|
||||
@@ -116,11 +135,11 @@ func Upload(c *gin.Context) {
|
||||
defer file.Close()
|
||||
|
||||
ext := extOf(header.Filename)
|
||||
if !allowedExt[ext] {
|
||||
if !isUploadableExt(ext) {
|
||||
web.Fail(c, web.NewBadRequest("不支持的文件类型"))
|
||||
return
|
||||
}
|
||||
if ext == "mp4" && header.Size > Cfg.ChunkThreshold {
|
||||
if isVideoExt(ext) && header.Size > Cfg.ChunkThreshold {
|
||||
web.Fail(c, web.NewBadRequest("视频超过 100MB 请使用分片上传"))
|
||||
return
|
||||
}
|
||||
@@ -213,7 +232,7 @@ func UploadInit(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
ext := extOf(req.Filename)
|
||||
if !allowedExt[ext] {
|
||||
if !isUploadableExt(ext) {
|
||||
web.Fail(c, web.NewBadRequest("不支持的文件类型"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package api
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsUploadableExt(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
ext string
|
||||
want bool
|
||||
}{
|
||||
{name: "txt allowed", ext: "txt", want: true},
|
||||
{name: "md allowed", ext: "md", want: true},
|
||||
{name: "xlsx allowed", ext: "xlsx", want: true},
|
||||
{name: "png allowed", ext: "png", want: true},
|
||||
{name: "empty blocked", ext: "", want: false},
|
||||
{name: "exe blocked", ext: "exe", want: false},
|
||||
{name: "bin blocked", ext: "bin", want: false},
|
||||
{name: "ps1 blocked", ext: "ps1", want: false},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
if got := isUploadableExt(c.ext); got != c.want {
|
||||
t.Errorf("%s: isUploadableExt(%q)=%v want %v", c.name, c.ext, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsVideoExt(t *testing.T) {
|
||||
cases := []struct {
|
||||
ext string
|
||||
want bool
|
||||
}{
|
||||
{ext: "mp4", want: true},
|
||||
{ext: "mov", want: true},
|
||||
{ext: "webm", want: true},
|
||||
{ext: "txt", want: false},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
if got := isVideoExt(c.ext); got != c.want {
|
||||
t.Errorf("isVideoExt(%q)=%v want %v", c.ext, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"eai_agentplatform/backend/internal/middleware"
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
"eai_agentplatform/backend/internal/store"
|
||||
"eai_agentplatform/backend/internal/web"
|
||||
)
|
||||
|
||||
const (
|
||||
maxFavoriteAppCount = 24
|
||||
maxRecentAppCount = 8
|
||||
maxCustomAppCount = 32
|
||||
maxCustomTagCount = 6
|
||||
)
|
||||
|
||||
type appCenterCustomApp struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Badge string `json:"badge"`
|
||||
Kind string `json:"kind"`
|
||||
Color string `json:"color"`
|
||||
Summary string `json:"summary"`
|
||||
Description string `json:"description"`
|
||||
Tags []string `json:"tags"`
|
||||
Prompts []string `json:"prompts"`
|
||||
InstallState string `json:"installState"`
|
||||
SkillKey string `json:"skillKey"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
IconText string `json:"iconText"`
|
||||
CoverTone string `json:"coverTone"`
|
||||
IsCustomApp bool `json:"isCustomApp"`
|
||||
}
|
||||
|
||||
type appCenterPayload struct {
|
||||
FavoriteKeys []string `json:"favorite_keys"`
|
||||
RecentKeys []string `json:"recent_keys"`
|
||||
CustomApps []appCenterCustomApp `json:"custom_apps"`
|
||||
}
|
||||
|
||||
// GetMyAppCenter GET /api/my/app-center
|
||||
func GetMyAppCenter(c *gin.Context) {
|
||||
user := middleware.CurrentUser(c)
|
||||
if user == nil {
|
||||
web.Fail(c, web.NewAuthError("未登录"))
|
||||
return
|
||||
}
|
||||
row, err := findUserAppCenter(user.ID)
|
||||
if err != nil {
|
||||
web.Fail(c, web.NewBadRequest("读取应用中心失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, normalizeAppCenterPayload(row))
|
||||
}
|
||||
|
||||
// UpdateMyAppCenter PUT /api/my/app-center
|
||||
func UpdateMyAppCenter(c *gin.Context) {
|
||||
user := middleware.CurrentUser(c)
|
||||
if user == nil {
|
||||
web.Fail(c, web.NewAuthError("未登录"))
|
||||
return
|
||||
}
|
||||
var req appCenterPayload
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
web.Fail(c, web.NewBadRequest("请求参数错误"))
|
||||
return
|
||||
}
|
||||
payload := normalizeAppCenterPayload(&model.UserAppCenter{
|
||||
FavoriteKeys: mustJSONAppCenter(req.FavoriteKeys),
|
||||
RecentKeys: mustJSONAppCenter(req.RecentKeys),
|
||||
CustomApps: mustJSONAppCenter(req.CustomApps),
|
||||
})
|
||||
row, err := findUserAppCenter(user.ID)
|
||||
if err != nil {
|
||||
web.Fail(c, web.NewBadRequest("读取应用中心失败"))
|
||||
return
|
||||
}
|
||||
if row == nil {
|
||||
row = &model.UserAppCenter{UserID: user.ID}
|
||||
}
|
||||
row.FavoriteKeys = mustJSONAppCenter(payload.FavoriteKeys)
|
||||
row.RecentKeys = mustJSONAppCenter(payload.RecentKeys)
|
||||
row.CustomApps = mustJSONAppCenter(payload.CustomApps)
|
||||
if err := store.DB.Save(row).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("保存应用中心失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, payload)
|
||||
}
|
||||
|
||||
func findUserAppCenter(userID uint) (*model.UserAppCenter, error) {
|
||||
var row model.UserAppCenter
|
||||
err := store.DB.Where("user_id = ?", userID).First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func normalizeAppCenterPayload(row *model.UserAppCenter) appCenterPayload {
|
||||
if row == nil {
|
||||
return appCenterPayload{
|
||||
FavoriteKeys: []string{},
|
||||
RecentKeys: []string{},
|
||||
CustomApps: []appCenterCustomApp{},
|
||||
}
|
||||
}
|
||||
var favorites []string
|
||||
var recents []string
|
||||
var customApps []appCenterCustomApp
|
||||
_ = json.Unmarshal([]byte(strings.TrimSpace(row.FavoriteKeys)), &favorites)
|
||||
_ = json.Unmarshal([]byte(strings.TrimSpace(row.RecentKeys)), &recents)
|
||||
_ = json.Unmarshal([]byte(strings.TrimSpace(row.CustomApps)), &customApps)
|
||||
return appCenterPayload{
|
||||
FavoriteKeys: normalizeStringList(favorites, maxFavoriteAppCount),
|
||||
RecentKeys: normalizeStringList(recents, maxRecentAppCount),
|
||||
CustomApps: normalizeCustomApps(customApps),
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeStringList(items []string, maxCount int) []string {
|
||||
result := make([]string, 0, len(items))
|
||||
seen := make(map[string]struct{}, len(items))
|
||||
for _, item := range items {
|
||||
normalized := strings.TrimSpace(item)
|
||||
if normalized == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[normalized]; ok {
|
||||
continue
|
||||
}
|
||||
seen[normalized] = struct{}{}
|
||||
result = append(result, normalized)
|
||||
if len(result) >= maxCount {
|
||||
break
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeCustomApps(items []appCenterCustomApp) []appCenterCustomApp {
|
||||
result := make([]appCenterCustomApp, 0, len(items))
|
||||
seen := make(map[string]struct{}, len(items))
|
||||
for index, item := range items {
|
||||
key := strings.TrimSpace(item.Key)
|
||||
if key == "" {
|
||||
key = fmt.Sprintf("custom-app-%d-%d", time.Now().UnixMilli(), index+1)
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
label := strings.TrimSpace(item.Label)
|
||||
if label == "" {
|
||||
label = "未命名应用"
|
||||
}
|
||||
summary := strings.TrimSpace(item.Summary)
|
||||
if summary == "" {
|
||||
summary = "自定义应用"
|
||||
}
|
||||
description := strings.TrimSpace(item.Description)
|
||||
if description == "" {
|
||||
description = summary
|
||||
}
|
||||
prompt := ""
|
||||
if len(item.Prompts) > 0 {
|
||||
prompt = strings.TrimSpace(item.Prompts[0])
|
||||
}
|
||||
if prompt == "" {
|
||||
prompt = summary
|
||||
}
|
||||
createdAt := strings.TrimSpace(item.CreatedAt)
|
||||
if createdAt == "" {
|
||||
createdAt = time.Now().Format(time.RFC3339)
|
||||
}
|
||||
result = append(result, appCenterCustomApp{
|
||||
Key: key,
|
||||
Label: truncateText(label, 24),
|
||||
Badge: "自定义应用",
|
||||
Kind: firstNonEmpty(strings.TrimSpace(item.Kind), "个人工作流"),
|
||||
Color: firstNonEmpty(strings.TrimSpace(item.Color), "#2563eb"),
|
||||
Summary: truncateText(summary, 80),
|
||||
Description: truncateText(description, 180),
|
||||
Tags: normalizeTagList(item.Tags),
|
||||
Prompts: []string{truncateText(prompt, 240)},
|
||||
InstallState: "installed",
|
||||
SkillKey: firstNonEmpty(strings.TrimSpace(item.SkillKey), "smart-assistant"),
|
||||
CreatedAt: createdAt,
|
||||
IconText: normalizeIconText(item.IconText, label),
|
||||
CoverTone: normalizeCoverTone(item.CoverTone, item.Color),
|
||||
IsCustomApp: true,
|
||||
})
|
||||
if len(result) >= maxCustomAppCount {
|
||||
break
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeTagList(items []string) []string {
|
||||
result := make([]string, 0, len(items))
|
||||
seen := make(map[string]struct{}, len(items))
|
||||
for _, item := range items {
|
||||
tag := truncateText(strings.TrimSpace(item), 12)
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[tag]; ok {
|
||||
continue
|
||||
}
|
||||
seen[tag] = struct{}{}
|
||||
result = append(result, tag)
|
||||
if len(result) >= maxCustomTagCount {
|
||||
break
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func truncateText(value string, limit int) string {
|
||||
text := strings.TrimSpace(value)
|
||||
runes := []rune(text)
|
||||
if len(runes) <= limit {
|
||||
return text
|
||||
}
|
||||
return string(runes[:limit])
|
||||
}
|
||||
|
||||
func normalizeIconText(iconText, label string) string {
|
||||
text := strings.TrimSpace(iconText)
|
||||
if text == "" {
|
||||
runes := []rune(strings.TrimSpace(label))
|
||||
if len(runes) == 0 {
|
||||
return "应用"
|
||||
}
|
||||
if len(runes) == 1 {
|
||||
return string(runes[:1])
|
||||
}
|
||||
return string(runes[:2])
|
||||
}
|
||||
runes := []rune(text)
|
||||
if len(runes) <= 2 {
|
||||
return string(runes)
|
||||
}
|
||||
return string(runes[:2])
|
||||
}
|
||||
|
||||
func normalizeCoverTone(coverTone, color string) string {
|
||||
text := strings.TrimSpace(coverTone)
|
||||
if text != "" {
|
||||
return text
|
||||
}
|
||||
if strings.TrimSpace(color) != "" {
|
||||
return color
|
||||
}
|
||||
return "#2563eb"
|
||||
}
|
||||
|
||||
func mustJSONAppCenter(value any) string {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return "[]"
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"eai_agentplatform/backend/internal/web"
|
||||
)
|
||||
|
||||
// 通用智能助手:seed 里 state=system 的那条内置记录。「新建任务」时还没选专员,
|
||||
// 通用助手:seed 里 state=system 的那条内置记录。「新建任务」时还没选专员,
|
||||
// 任务就先落在它名下;之后用输入框的 + 选专员/工具,任务再改挂过去。
|
||||
const generalAssistantKey = "general-assistant"
|
||||
|
||||
@@ -103,7 +103,7 @@ func DeleteMyTask(c *gin.Context) {
|
||||
web.OK(c, gin.H{"id": id})
|
||||
}
|
||||
|
||||
// CreateMyTask 建一条属于当前用户的任务。没给专员就落在通用智能助手名下。
|
||||
// CreateMyTask 建一条属于当前用户的任务。没给专员就落在通用助手名下。
|
||||
func CreateMyTask(c *gin.Context) {
|
||||
user := middleware.CurrentUser(c)
|
||||
if user == nil {
|
||||
@@ -157,8 +157,7 @@ func CreateMyTask(c *gin.Context) {
|
||||
web.OK(c, task)
|
||||
}
|
||||
|
||||
// UpdateMyTask 改自己的任务:改标题,或者把它改挂到另一个专员名下
|
||||
// (输入框的 + 里选了专员就走这里)。
|
||||
// UpdateMyTask 改自己的任务:改标题、改挂专员,或者把它加入某个项目。
|
||||
func UpdateMyTask(c *gin.Context) {
|
||||
user := middleware.CurrentUser(c)
|
||||
if user == nil {
|
||||
@@ -196,6 +195,19 @@ func UpdateMyTask(c *gin.Context) {
|
||||
if summary := strings.TrimSpace(req.Summary); summary != "" {
|
||||
task.Summary = summary
|
||||
}
|
||||
// 没传 project_id 就保持原样;前端若显式传 0,则视为把任务移出项目。
|
||||
if req.ProjectID != nil {
|
||||
if *req.ProjectID == 0 {
|
||||
task.ProjectID = nil
|
||||
} else {
|
||||
var project model.Project
|
||||
if err := projectQuery(user).Where("id = ?", *req.ProjectID).First(&project).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("项目不存在"))
|
||||
return
|
||||
}
|
||||
task.ProjectID = &project.ID
|
||||
}
|
||||
}
|
||||
if req.Pinned != nil {
|
||||
task.Pinned = *req.Pinned
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,280 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"eai_agentplatform/backend/internal/middleware"
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
"eai_agentplatform/backend/internal/store"
|
||||
"eai_agentplatform/backend/internal/web"
|
||||
)
|
||||
|
||||
// 项目名的长度上限。前端弹窗的计数器读的也是这个数 —— 两边必须一致,
|
||||
// 否则前端数到 40 说没超,后端一剪,用户看到的名字和自己打的不一样。
|
||||
const projectNameMaxLen = 40
|
||||
|
||||
type projectReq struct {
|
||||
Name string `json:"name"`
|
||||
TemplateKey string `json:"template_key"`
|
||||
// Instruction 用指针:值类型分不出「没传」和「传了空串」,指令就永远清不掉 ——
|
||||
// 详情页把指令删空再保存会被当成「没改」,用户看到的是白删一次。
|
||||
// Pinned 同理(分不出「没传」和「传了 false」,取消置顶永远取消不掉)。
|
||||
Instruction *string `json:"instruction"`
|
||||
SpecialistKeys []string `json:"specialist_keys"`
|
||||
SkillKeys []string `json:"skill_keys"`
|
||||
ConnectorKeys []string `json:"connector_keys"`
|
||||
Pinned *bool `json:"pinned"`
|
||||
}
|
||||
|
||||
// projectQuery 「我的项目」永远只列自己的,管理员也只看自己的 —— 跟
|
||||
// myTaskQuery 是同一条规矩,所以归属判定直接复用 myTaskOwners,
|
||||
// 不另写一套(两套判定迟早会漂)。
|
||||
func projectQuery(user *model.User) *gorm.DB {
|
||||
owners := myTaskOwners(user)
|
||||
query := store.DB.Model(&model.Project{})
|
||||
if len(owners) == 0 {
|
||||
return query.Where("1 = 0")
|
||||
}
|
||||
return query.Where("owner IN ?", owners)
|
||||
}
|
||||
|
||||
// encodeKeys 把 key 列表存成 JSON 字符串。空列表存空串而不是 "[]",
|
||||
// 读的时候一眼能看出「没配」和「配了但为空」的区别不大,但空串更省。
|
||||
func encodeKeys(keys []string) string {
|
||||
cleaned := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
if trimmed := strings.TrimSpace(key); trimmed != "" {
|
||||
cleaned = append(cleaned, trimmed)
|
||||
}
|
||||
}
|
||||
if len(cleaned) == 0 {
|
||||
return ""
|
||||
}
|
||||
b, err := json.Marshal(cleaned)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// ListProjects 当前用户的项目,置顶在前、最近动过的靠前。
|
||||
func ListProjects(c *gin.Context) {
|
||||
user := middleware.CurrentUser(c)
|
||||
if user == nil {
|
||||
web.Fail(c, web.NewAuthError("未登录"))
|
||||
return
|
||||
}
|
||||
|
||||
var items []model.Project
|
||||
if err := projectQuery(user).
|
||||
Order("pinned DESC, updated_at DESC, id DESC").
|
||||
Limit(50).
|
||||
Find(&items).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("查询项目失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, items)
|
||||
}
|
||||
|
||||
// CreateProject 建一个项目。空 body 也收(跟「新建任务」一样,全走默认值),
|
||||
// 但名字是必填的 —— 一个没名字的项目在列表里没法认。
|
||||
func CreateProject(c *gin.Context) {
|
||||
user := middleware.CurrentUser(c)
|
||||
if user == nil {
|
||||
web.Fail(c, web.NewAuthError("未登录"))
|
||||
return
|
||||
}
|
||||
|
||||
var req projectReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
|
||||
web.Fail(c, web.NewBadRequest("请求参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
web.Fail(c, web.NewBadRequest("项目名称不能为空"))
|
||||
return
|
||||
}
|
||||
if len([]rune(name)) > projectNameMaxLen {
|
||||
name = string([]rune(name)[:projectNameMaxLen])
|
||||
}
|
||||
|
||||
if err := validateSpecialistKeys(req.SpecialistKeys); err != nil {
|
||||
web.Fail(c, web.NewNotFoundError(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
instruction := ""
|
||||
if req.Instruction != nil {
|
||||
instruction = strings.TrimSpace(*req.Instruction)
|
||||
}
|
||||
|
||||
project := model.Project{
|
||||
Name: name,
|
||||
Instruction: instruction,
|
||||
TemplateKey: strings.TrimSpace(req.TemplateKey),
|
||||
// 归属由服务端定死,不读请求里的 owner —— 否则谁都能替别人建项目。
|
||||
Owner: myTaskOwnerName(user),
|
||||
SpecialistKeys: encodeKeys(req.SpecialistKeys),
|
||||
SkillKeys: encodeKeys(req.SkillKeys),
|
||||
ConnectorKeys: encodeKeys(req.ConnectorKeys),
|
||||
}
|
||||
if err := store.DB.Create(&project).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("创建项目失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, project)
|
||||
}
|
||||
|
||||
// UpdateProject 改名 / 改指令 / 置顶。只有显式传了的字段才覆盖:
|
||||
// 没传 name 就别把名字清空,没传 pinned 就别把它当 false。
|
||||
func UpdateProject(c *gin.Context) {
|
||||
user := middleware.CurrentUser(c)
|
||||
if user == nil {
|
||||
web.Fail(c, web.NewAuthError("未登录"))
|
||||
return
|
||||
}
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var project model.Project
|
||||
if err := projectQuery(user).Where("id = ?", id).First(&project).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("项目不存在"))
|
||||
return
|
||||
}
|
||||
|
||||
var req projectReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
web.Fail(c, web.NewBadRequest("请求参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
if name := strings.TrimSpace(req.Name); name != "" {
|
||||
if len([]rune(name)) > projectNameMaxLen {
|
||||
name = string([]rune(name)[:projectNameMaxLen])
|
||||
}
|
||||
project.Name = name
|
||||
}
|
||||
// 传了空串就是「清空指令」,得让它生效 —— 见 projectReq 里 Instruction 的注释。
|
||||
if req.Instruction != nil {
|
||||
project.Instruction = strings.TrimSpace(*req.Instruction)
|
||||
}
|
||||
if req.TemplateKey != "" {
|
||||
project.TemplateKey = strings.TrimSpace(req.TemplateKey)
|
||||
}
|
||||
if req.Pinned != nil {
|
||||
project.Pinned = *req.Pinned
|
||||
}
|
||||
// 三个能力列表:给了就用给的(空数组也算给了,表示清空),
|
||||
// 没给就保持原样。用 nil 判断,跟 Pinned 是同一套规矩。
|
||||
if req.SpecialistKeys != nil {
|
||||
if err := validateSpecialistKeys(req.SpecialistKeys); err != nil {
|
||||
web.Fail(c, web.NewNotFoundError(err.Error()))
|
||||
return
|
||||
}
|
||||
project.SpecialistKeys = encodeKeys(req.SpecialistKeys)
|
||||
}
|
||||
if req.SkillKeys != nil {
|
||||
project.SkillKeys = encodeKeys(req.SkillKeys)
|
||||
}
|
||||
if req.ConnectorKeys != nil {
|
||||
project.ConnectorKeys = encodeKeys(req.ConnectorKeys)
|
||||
}
|
||||
|
||||
if err := store.DB.Save(&project).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("更新项目失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, project)
|
||||
}
|
||||
|
||||
// DeleteProject 删项目。
|
||||
//
|
||||
// 项目里的任务**不删**,只把 project_id 置空 —— 任务是「做过的事」,
|
||||
// 删一个分组不该把它一起抹掉。前端确认框里必须把这一点说明白,
|
||||
// 否则用户会以为连任务一起没了。
|
||||
func DeleteProject(c *gin.Context) {
|
||||
user := middleware.CurrentUser(c)
|
||||
if user == nil {
|
||||
web.Fail(c, web.NewAuthError("未登录"))
|
||||
return
|
||||
}
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var project model.Project
|
||||
if err := projectQuery(user).Where("id = ?", id).First(&project).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("项目不存在"))
|
||||
return
|
||||
}
|
||||
if err := store.DB.Model(&model.WorkerTask{}).
|
||||
Where("project_id = ?", project.ID).
|
||||
Update("project_id", nil).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("解除任务归属失败"))
|
||||
return
|
||||
}
|
||||
if err := store.DB.Delete(&project).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("删除项目失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, gin.H{"id": id})
|
||||
}
|
||||
|
||||
// ListProjectTasks 项目下的任务。项目必须是自己的 —— 别人的项目直接 404,
|
||||
// 不区分「不存在」和「不是你的」。
|
||||
func ListProjectTasks(c *gin.Context) {
|
||||
user := middleware.CurrentUser(c)
|
||||
if user == nil {
|
||||
web.Fail(c, web.NewAuthError("未登录"))
|
||||
return
|
||||
}
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var project model.Project
|
||||
if err := projectQuery(user).Where("id = ?", id).First(&project).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("项目不存在"))
|
||||
return
|
||||
}
|
||||
|
||||
var items []model.WorkerTask
|
||||
if err := store.DB.Where("project_id = ?", project.ID).
|
||||
Order("updated_at DESC, id DESC").
|
||||
Limit(100).
|
||||
Find(&items).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("查询项目任务失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, items)
|
||||
}
|
||||
|
||||
// validateSpecialistKeys 专员 key 得真实存在才让存 —— 项目卡片上要显示专员名,
|
||||
// 存一个查不到的 key 进去,卡片上就会出现一行认不出来的东西。
|
||||
// 工具和连接器暂时不校验:工具是前端路由表里的常量,连接器清单以后会变,
|
||||
// 校验它们只会让老项目在清单变动后改不动。
|
||||
func validateSpecialistKeys(keys []string) error {
|
||||
for _, key := range keys {
|
||||
trimmed := strings.TrimSpace(key)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
var specialist model.Specialist
|
||||
if err := store.DB.Where("key = ?", trimmed).First(&specialist).Error; err != nil {
|
||||
return errors.New("专员不存在:" + trimmed)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -18,10 +18,10 @@ import (
|
||||
|
||||
// ReportGenRequest POST /api/report/generate 报告生成请求
|
||||
type ReportGenRequest struct {
|
||||
Topic string `json:"topic"` // 报告主题,必填
|
||||
Summary string `json:"summary"` // 报告摘要/背景
|
||||
Context map[string]any `json:"context"` // 附加上下文
|
||||
Sections []string `json:"sections"` // 期望包含的章节(可选)
|
||||
Topic string `json:"topic"` // 报告主题,必填
|
||||
Summary string `json:"summary"` // 报告摘要/背景
|
||||
Context map[string]any `json:"context"` // 附加上下文
|
||||
Sections []string `json:"sections"` // 期望包含的章节(可选)
|
||||
Knowledge string `json:"knowledge_key"` // 关联知识库 key
|
||||
}
|
||||
|
||||
@@ -33,10 +33,10 @@ type reportChapter struct {
|
||||
|
||||
// reportContent 完整报告结构
|
||||
type reportContent struct {
|
||||
Topic string `json:"topic"`
|
||||
Summary string `json:"summary"`
|
||||
Topic string `json:"topic"`
|
||||
Summary string `json:"summary"`
|
||||
Chapters []reportChapter `json:"chapters"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// ReportGenerate POST /api/report/generate —— 调用 LLM 生成报告正文
|
||||
@@ -110,7 +110,11 @@ func retrieveReportKnowledge(topic string, spaceKey string) string {
|
||||
Find(&chunks)
|
||||
parts := make([]string, 0, len(chunks))
|
||||
for _, ch := range chunks {
|
||||
parts = append(parts, fmt.Sprintf("【%s】%s", ch.Content))
|
||||
// 这里原本是 fmt.Sprintf("【%s】%s", ch.Content) —— 两个占位符只给了一个参数,
|
||||
// 实际拼出来是「【%!s(MISSING)】正文」,这段字符串是直接进 LLM prompt 的,
|
||||
// 等于每个片段前面糊一句乱码。KnowledgeChunk 没有 title 字段,
|
||||
// 没有东西可以填进【】,所以和上面按空间检索的分支对齐,直接取正文。
|
||||
parts = append(parts, ch.Content)
|
||||
}
|
||||
return strings.Join(parts, "\n\n")
|
||||
}
|
||||
@@ -140,8 +144,8 @@ func promptReportWithSections(req ReportGenRequest, knowledge string) []reportCh
|
||||
knowledge,
|
||||
)
|
||||
|
||||
route, _ := config.GetRoute("title_gen")
|
||||
content, err := ai.GenerateWithFallback(route, []ai.Message{
|
||||
aiRoute, _ := config.GetRoute("title_gen")
|
||||
aiContent, err := ai.GenerateWithFallback(aiRoute, []ai.Message{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: userPrompt},
|
||||
})
|
||||
@@ -149,7 +153,7 @@ func promptReportWithSections(req ReportGenRequest, knowledge string) []reportCh
|
||||
return fallbackReport(req.Topic)
|
||||
}
|
||||
|
||||
return parseReportMarkdown(content, req.Sections)
|
||||
return parseReportMarkdown(aiContent, req.Sections)
|
||||
}
|
||||
|
||||
// promptReportAutoChapters 让 LLM 自动规划报告章节
|
||||
@@ -176,8 +180,8 @@ func promptReportAutoChapters(req ReportGenRequest, knowledge string) []reportCh
|
||||
knowledge,
|
||||
)
|
||||
|
||||
route, _ := config.GetRoute("title_gen")
|
||||
content, err := ai.GenerateWithFallback(route, []ai.Message{
|
||||
aiRoute, _ := config.GetRoute("title_gen")
|
||||
aiContent, err := ai.GenerateWithFallback(aiRoute, []ai.Message{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: userPrompt},
|
||||
})
|
||||
@@ -185,7 +189,7 @@ func promptReportAutoChapters(req ReportGenRequest, knowledge string) []reportCh
|
||||
return fallbackReport(req.Topic)
|
||||
}
|
||||
|
||||
return parseReportJSON(content)
|
||||
return parseReportJSON(aiContent)
|
||||
}
|
||||
|
||||
// parseReportMarkdown 从 Markdown 格式解析报告为章节数组
|
||||
|
||||
@@ -43,6 +43,10 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
|
||||
r.GET("/api/specialists", middleware.Auth(cfg), ListSpecialists)
|
||||
r.GET("/api/specialists/by-key/:key", middleware.Auth(cfg), GetSpecialistByKey)
|
||||
r.GET("/api/specialists/summary", middleware.Auth(cfg), SpecialistSummary)
|
||||
r.GET("/api/skills", middleware.Auth(cfg), ListSkillDefinitions)
|
||||
r.GET("/api/skills/by-key/:key", middleware.Auth(cfg), GetSkillDefinitionByKey)
|
||||
r.GET("/api/actions", middleware.Auth(cfg), ListActionDefinitions)
|
||||
r.GET("/api/actions/by-key/:key", middleware.Auth(cfg), GetActionDefinitionByKey)
|
||||
r.GET("/api/workbench/overview", middleware.Auth(cfg), WorkbenchOverview)
|
||||
r.GET("/api/worker/tasks", middleware.Auth(cfg), ListWorkerTasks)
|
||||
r.GET("/api/worker/tasks/:id", middleware.Auth(cfg), GetWorkerTaskDetail)
|
||||
@@ -52,14 +56,20 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
|
||||
r.POST("/api/worker/my-tasks", middleware.Auth(cfg), CreateMyTask)
|
||||
r.PUT("/api/worker/my-tasks/:id", middleware.Auth(cfg), UpdateMyTask)
|
||||
r.DELETE("/api/worker/my-tasks/:id", middleware.Auth(cfg), DeleteMyTask)
|
||||
// 项目:任务的容器。和「我的任务」同一套归属规则,归属服务端定死。
|
||||
r.GET("/api/worker/projects", middleware.Auth(cfg), ListProjects)
|
||||
r.POST("/api/worker/projects", middleware.Auth(cfg), CreateProject)
|
||||
r.PUT("/api/worker/projects/:id", middleware.Auth(cfg), UpdateProject)
|
||||
r.DELETE("/api/worker/projects/:id", middleware.Auth(cfg), DeleteProject)
|
||||
r.GET("/api/worker/projects/:id/tasks", middleware.Auth(cfg), ListProjectTasks)
|
||||
r.GET("/api/worker/artifacts/:id", middleware.Auth(cfg), GetWorkerArtifactDetail)
|
||||
r.POST("/api/official-account/tasks", middleware.Auth(cfg), CreateOfficialAccountTask)
|
||||
r.GET("/api/official-account/tasks/:id/workflow", middleware.Auth(cfg), GetOfficialAccountWorkflow)
|
||||
r.PUT("/api/official-account/tasks/:id", middleware.Auth(cfg), UpdateOfficialAccountTask)
|
||||
r.POST("/api/official-account/tasks/:id/steps/:stepKey", middleware.Auth(cfg), ExecuteOfficialAccountWorkflowStep)
|
||||
r.POST("/api/official-account/tasks/:id/images/:imageKey/regenerate", middleware.Auth(cfg), RegenerateOfficialAccountImage)
|
||||
r.GET("/api/official-account/tasks/:id/export", middleware.Auth(cfg), ExportOfficialAccountDocument)
|
||||
r.GET("/api/official-account/generated-images/:filename", middleware.Auth(cfg), ServeOfficialAccountGeneratedImage)
|
||||
r.POST("/api/official-account/tasks/:id/images/:imageKey/regenerate", middleware.Auth(cfg), RegenerateOfficialAccountImage)
|
||||
r.GET("/api/official-account/tasks/:id/export", middleware.Auth(cfg), ExportOfficialAccountDocument)
|
||||
r.GET("/api/official-account/generated-images/:filename", middleware.Auth(cfg), ServeOfficialAccountGeneratedImage)
|
||||
r.GET("/api/connectors", middleware.Auth(cfg), ListConnectors)
|
||||
r.GET("/api/connectors/:key", middleware.Auth(cfg), GetConnector)
|
||||
r.POST("/api/connectors/:key/query", middleware.Auth(cfg), QueryConnector)
|
||||
@@ -84,6 +94,8 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
|
||||
// 学员:我的岗位应学清单 / 学习档案 / 积分与排行榜
|
||||
r.GET("/api/my/position", middleware.Auth(cfg), MyPosition)
|
||||
r.GET("/api/my/profile", middleware.Auth(cfg), MyProfile)
|
||||
r.GET("/api/my/app-center", middleware.Auth(cfg), GetMyAppCenter)
|
||||
r.PUT("/api/my/app-center", middleware.Auth(cfg), UpdateMyAppCenter)
|
||||
r.GET("/api/points/me", middleware.Auth(cfg), MyPoints)
|
||||
r.GET("/api/points/leaderboard", middleware.Auth(cfg), Leaderboard)
|
||||
|
||||
@@ -139,6 +151,8 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
|
||||
r.GET("/api/copy/quick", middleware.Auth(cfg), QuickProofread)
|
||||
r.POST("/api/batch/extract", middleware.Auth(cfg), ExtractBatch)
|
||||
r.POST("/api/batch/files", middleware.Auth(cfg), ExtractFromFiles)
|
||||
r.POST("/api/skills/office/execute", middleware.Auth(cfg), ExecuteOfficeSkill)
|
||||
r.POST("/api/skills/office/ocr", middleware.Auth(cfg), ExecuteOfficeSkillOCR)
|
||||
r.POST("/api/assistant/chat", middleware.Auth(cfg), Chat)
|
||||
|
||||
// 语音工具
|
||||
@@ -188,6 +202,12 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
|
||||
admin.POST("/specialists", CreateSpecialist)
|
||||
admin.PUT("/specialists/:id", UpdateSpecialist)
|
||||
admin.DELETE("/specialists/:id", DeleteSpecialist)
|
||||
admin.POST("/skills", CreateSkillDefinition)
|
||||
admin.PUT("/skills/:id", UpdateSkillDefinition)
|
||||
admin.DELETE("/skills/:id", DeleteSkillDefinition)
|
||||
admin.POST("/actions", CreateActionDefinition)
|
||||
admin.PUT("/actions/:id", UpdateActionDefinition)
|
||||
admin.DELETE("/actions/:id", DeleteActionDefinition)
|
||||
admin.POST("/worker/tasks", CreateWorkerTask)
|
||||
admin.PUT("/worker/tasks/:id", UpdateWorkerTask)
|
||||
admin.PUT("/worker/tasks/:id/status", UpdateWorkerTaskStatus)
|
||||
|
||||
@@ -107,7 +107,7 @@ func WorkbenchOverview(c *gin.Context) {
|
||||
"owner": item.Owner,
|
||||
"specialist_key": item.SpecialistKey,
|
||||
"specialist": spec.Label,
|
||||
"route": spec.Route,
|
||||
"object_entry_route": spec.ObjectEntryRoute,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ func WorkbenchOverview(c *gin.Context) {
|
||||
"title": item.Title,
|
||||
"sub": buildWorkbenchRiskText(item),
|
||||
"status": item.Status,
|
||||
"route": spec.Route,
|
||||
"object_entry_route": spec.ObjectEntryRoute,
|
||||
"specialist": spec.Label,
|
||||
})
|
||||
}
|
||||
@@ -135,7 +135,7 @@ func WorkbenchOverview(c *gin.Context) {
|
||||
"status": item.Status,
|
||||
"task_id": item.TaskID,
|
||||
"specialist_key": item.SpecialistKey,
|
||||
"route": spec.Route,
|
||||
"object_entry_route": spec.ObjectEntryRoute,
|
||||
"artifact_type": item.ArtifactType,
|
||||
"created_at": item.CreatedAt,
|
||||
})
|
||||
|
||||
@@ -28,6 +28,9 @@ type workerTaskReq struct {
|
||||
Status string `json:"status"`
|
||||
DueAt string `json:"due_at"`
|
||||
Context map[string]any `json:"context"`
|
||||
// ProjectID 把这条任务挂到某个项目下(在项目里建任务时前端会带上)。
|
||||
// 指针是为了区分「没传」和「传了 0」—— 0 不是合法 id,当成没传处理。
|
||||
ProjectID *uint `json:"project_id"`
|
||||
// Pinned 用指针:要区分「没传这个字段」和「传了 false 要取消置顶」,
|
||||
// 值类型的话取消置顶会被当成没传,永远取消不掉。
|
||||
Pinned *bool `json:"pinned"`
|
||||
@@ -90,10 +93,10 @@ func GetWorkerTaskDetail(c *gin.Context) {
|
||||
web.Fail(c, web.NewBadRequest("查询运行记录失败"))
|
||||
return
|
||||
}
|
||||
if task.SpecialistKey == officialAccountSpecialistKey {
|
||||
artifacts = compactOfficialAccountArtifactsForResponse(artifacts)
|
||||
runs = compactOfficialAccountRunsForResponse(runs)
|
||||
}
|
||||
if task.SpecialistKey == officialAccountSpecialistKey {
|
||||
artifacts = compactOfficialAccountArtifactsForResponse(artifacts)
|
||||
runs = compactOfficialAccountRunsForResponse(runs)
|
||||
}
|
||||
|
||||
web.OK(c, gin.H{
|
||||
"task": task,
|
||||
@@ -175,7 +178,7 @@ func CreateWorkerTask(c *gin.Context) {
|
||||
"worker_task_assign",
|
||||
fmt.Sprintf("你有新的待办事项:%s", task.Title),
|
||||
fmt.Sprintf("专员 %s 已为你创建事项,请尽快查看。", specialist.Label),
|
||||
specialist.Route,
|
||||
specialist.ObjectEntryRoute,
|
||||
)
|
||||
web.OK(c, task)
|
||||
}
|
||||
@@ -465,7 +468,7 @@ func ensureBootstrapWorkerTask(specialistKey string, user *model.User) error {
|
||||
"bootstrap": true,
|
||||
"specialist_key": specialist.Key,
|
||||
"specialist": specialist.Label,
|
||||
"route": specialist.Route,
|
||||
"object_entry_route": specialist.ObjectEntryRoute,
|
||||
})
|
||||
|
||||
task := model.WorkerTask{
|
||||
@@ -516,6 +519,16 @@ func buildWorkerTaskFromReq(req workerTaskReq, user *model.User) (model.WorkerTa
|
||||
}
|
||||
task.DueAt = &parsed
|
||||
}
|
||||
// 挂项目:得确认这个项目是自己的,否则等于给别人的项目里塞任务。
|
||||
// 放这里而不是各个 handler 里,是因为建任务有两条入口
|
||||
// (CreateMyTask / CreateWorkerTask),校验只写一遍才不会漏。
|
||||
if req.ProjectID != nil && *req.ProjectID != 0 {
|
||||
var project model.Project
|
||||
if err := projectQuery(user).Where("id = ?", *req.ProjectID).First(&project).Error; err != nil {
|
||||
return task, fmt.Errorf("项目不存在")
|
||||
}
|
||||
task.ProjectID = &project.ID
|
||||
}
|
||||
if user != nil {
|
||||
task.CreatedBy = &user.ID
|
||||
}
|
||||
@@ -686,27 +699,27 @@ func executeAIStep(task model.WorkerTask, specialist model.Specialist, req worke
|
||||
if user.AiPoints <= 0 {
|
||||
return "AI 点数不足,未触发智能生成。", nil, []string{"[WARN] ai execution skipped because credits are insufficient"}
|
||||
}
|
||||
route, err := config.GetRoute("title_gen")
|
||||
aiRoute, err := config.GetRoute("title_gen")
|
||||
if err != nil {
|
||||
return "LLM 路由未配置,未触发智能生成。", nil, []string{fmt.Sprintf("[WARN] ai route missing: %v", err)}
|
||||
}
|
||||
contextJSON := parseWorkerContextJSON(task.ContextJSON)
|
||||
knowledge := ai.Retrieve(Cfg, task.Title+" "+task.Summary+" "+specialist.InfoSources, 5)
|
||||
systemPrompt := buildSystemPrompt(contextJSON, knowledge) + "\n\n当前任务:" + buildWorkerAITaskPrompt(task, specialist, req)
|
||||
messages := []ai.Message{
|
||||
aiMessages := []ai.Message{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: buildWorkerUserPrompt(task, req)},
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
result, usedRoute, err := ai.GenerateFullWithFallback(route, messages)
|
||||
result, usedAiRoute, err := ai.GenerateFullWithFallback(aiRoute, aiMessages)
|
||||
if err != nil {
|
||||
ai.LogCall(ai.LogEntry{
|
||||
UserID: user.ID,
|
||||
Capability: ai.CapabilityTextGen,
|
||||
Provider: route.Provider,
|
||||
RouteID: route.RouteID,
|
||||
Model: route.Model,
|
||||
Provider: aiRoute.Provider,
|
||||
AIRouteID: aiRoute.RouteID,
|
||||
Model: aiRoute.Model,
|
||||
Success: false,
|
||||
ErrorMessage: err.Error(),
|
||||
LatencyMs: int(time.Since(start).Milliseconds()),
|
||||
@@ -716,9 +729,9 @@ func executeAIStep(task model.WorkerTask, specialist model.Specialist, req worke
|
||||
ai.LogCall(ai.LogEntry{
|
||||
UserID: user.ID,
|
||||
Capability: ai.CapabilityTextGen,
|
||||
Provider: usedRoute.Provider,
|
||||
RouteID: usedRoute.RouteID,
|
||||
Model: usedRoute.Model,
|
||||
Provider: usedAiRoute.Provider,
|
||||
AIRouteID: usedAiRoute.RouteID,
|
||||
Model: usedAiRoute.Model,
|
||||
Success: true,
|
||||
TokensInput: result.Usage.PromptTokens,
|
||||
TokensOutput: result.Usage.CompletionTokens,
|
||||
@@ -1052,7 +1065,7 @@ func buildArtifactStatusSummary(artifact model.WorkerArtifact, remark string) st
|
||||
func buildWorkerTaskLink(specialistKey string) string {
|
||||
var specialist model.Specialist
|
||||
if err := store.DB.Where("key = ?", strings.TrimSpace(specialistKey)).First(&specialist).Error; err == nil {
|
||||
return specialist.Route
|
||||
return specialist.ObjectEntryRoute
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user