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:
eaiadmin
2026-09-17 21:32:35 +08:00
co-authored by Claude Code
parent 8b136d4a10
commit 16d63de4e1
180 changed files with 22283 additions and 13850 deletions
@@ -1,214 +0,0 @@
package main
import (
"database/sql"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"strings"
"time"
_ "modernc.org/sqlite"
)
type taskRow struct {
ID int64 `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
UpdatedAt string `json:"updated_at"`
}
type articleRow struct {
TaskID int64 `json:"task_id"`
Keyword string `json:"keyword"`
SelectedTopic string `json:"selected_topic"`
TopicHeat string `json:"topic_heat"`
OutlineStyle string `json:"outline_style"`
OutlineWords int `json:"outline_words"`
ContentTargetWords int `json:"content_target_words"`
HotspotsJSON string `json:"hotspots_json"`
TopicCandidatesJSON string `json:"topic_candidates_json"`
}
type runRow struct {
ID int64 `json:"id"`
TaskID int64 `json:"task_id"`
ActionKey string `json:"action_key"`
ActionTitle string `json:"action_title"`
OutputJSON string `json:"output_json"`
LogsJSON string `json:"logs_json"`
StartedAt string `json:"started_at"`
}
func main() {
taskID := flag.Int64("task", 0, "official account task id")
resetStuck := flag.Bool("reset-stuck", false, "reset stuck running state for the given task")
flag.Parse()
dbPath := `d:\traecode\pj0235-eai_agentplatform\eai_ap_app\backend-go\data\eai_agentplatform.db`
db, err := sql.Open("sqlite", dbPath)
if err != nil {
log.Fatal(err)
}
defer db.Close()
if *resetStuck {
if *taskID <= 0 {
log.Fatal("please pass -task when using -reset-stuck")
}
if err := resetOfficialAccountTaskState(db, *taskID); err != nil {
log.Fatal(err)
}
fmt.Printf("reset stuck running state for task %d\n", *taskID)
return
}
tasks := make([]taskRow, 0, 5)
taskRows, err := db.Query(`
select id, title, status, updated_at
from worker_task
where specialist_key = 'wechat-official-account'
order by id desc
limit 5
`)
if err != nil {
log.Fatal(err)
}
for taskRows.Next() {
var row taskRow
if err := taskRows.Scan(&row.ID, &row.Title, &row.Status, &row.UpdatedAt); err != nil {
log.Fatal(err)
}
tasks = append(tasks, row)
}
_ = taskRows.Close()
var article articleRow
_ = db.QueryRow(`
select task_id, keyword, selected_topic, topic_heat, outline_style, outline_words, content_target_words, hotspots_json, topic_candidates_json
from official_account_article
order by id desc
limit 1
`).Scan(
&article.TaskID,
&article.Keyword,
&article.SelectedTopic,
&article.TopicHeat,
&article.OutlineStyle,
&article.OutlineWords,
&article.ContentTargetWords,
&article.HotspotsJSON,
&article.TopicCandidatesJSON,
)
runs := make([]runRow, 0, 5)
runRows, err := db.Query(`
select id, task_id, action_key, action_title, output_json, logs_json, started_at
from worker_run
where task_id = ?
order by id desc
limit 5
`, article.TaskID)
if err != nil {
log.Fatal(err)
}
for runRows.Next() {
var row runRow
if err := runRows.Scan(&row.ID, &row.TaskID, &row.ActionKey, &row.ActionTitle, &row.OutputJSON, &row.LogsJSON, &row.StartedAt); err != nil {
log.Fatal(err)
}
runs = append(runs, row)
}
_ = runRows.Close()
var hotspotCount int
_ = db.QueryRow(`select count(1) from official_account_hotspot`).Scan(&hotspotCount)
result := map[string]any{
"tasks": tasks,
"latest_article": article,
"latest_runs": runs,
"hotspot_count": hotspotCount,
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(result); err != nil {
fmt.Println(err)
}
}
func resetOfficialAccountTaskState(db *sql.DB, taskID int64) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer func() {
if err != nil {
_ = tx.Rollback()
}
}()
var contextJSON string
if err = tx.QueryRow(`select context_json from worker_task where id = ?`, taskID).Scan(&contextJSON); err != nil {
return err
}
type stepState struct {
Status string `json:"status"`
Progress int `json:"progress"`
ProgressText string `json:"progress_text"`
StartedAt string `json:"started_at"`
FinishedAt string `json:"finished_at"`
Logs []string `json:"logs"`
Output map[string]interface{} `json:"output"`
}
type workflowState struct {
CurrentStep string `json:"current_step"`
Steps map[string]*stepState `json:"steps"`
}
var workflow workflowState
if strings.TrimSpace(contextJSON) != "" {
if err = json.Unmarshal([]byte(contextJSON), &workflow); err != nil {
return err
}
}
if workflow.Steps == nil {
workflow.Steps = map[string]*stepState{}
}
for _, key := range []string{"topic_recommendation", "title_generation", "outline_generation", "content_creation"} {
step := workflow.Steps[key]
if step == nil || step.Status != "running" {
continue
}
step.Status = "error"
step.ProgressText = "上次执行已中断,请重新执行"
step.FinishedAt = time.Now().Format(time.RFC3339)
step.Logs = append(step.Logs, time.Now().Format("15:04:05")+" 执行已中断,已自动停止")
}
workflow.CurrentStep = "topic_recommendation"
newContext, err := json.Marshal(workflow)
if err != nil {
return err
}
if _, err = tx.Exec(`
update worker_task
set status = ?, current_result = ?, context_json = ?, updated_at = CURRENT_TIMESTAMP
where id = ?
`, "待处理", "", string(newContext), taskID); err != nil {
return err
}
if _, err = tx.Exec(`
update worker_run
set status = ?, output_json = ?, logs_json = ?, finished_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
where task_id = ? and status = ?
`, "error", `{"summary":"上次执行已中断,请重新执行","error":"interrupted"}`, `["执行已中断,已自动停止"]`, taskID, "running"); err != nil {
return err
}
return tx.Commit()
}
@@ -36,6 +36,8 @@ func main() {
log.Fatalf("种子数据初始化失败: %v", err)
}
config.StartAIRouteHealthLoop(30 * time.Minute)
// 定期备份:SQLite 是单文件且无外部副本,坏一份就是全丢。
// 失败只在日志里报,绝不因此拦下服务。
if cfg.BackupEnabled {
@@ -1 +1 @@
{"agent_routes": {"embed_gen": "embed_route_openrouter_text_v3", "image_gen": "image_route_openrouter_gpt_image_2", "path_coach": "chat_route_openrouter_deepseek_v4_flash", "title_gen": "chat_route_openrouter_deepseek_v4_flash"}, "chat_routes": {"chat_route_ollama_qwen": {"description": "本地 Ollama Qwen2.5:7b", "endpoint": "/chat/completions", "max_tokens": 2048, "model": "qwen2.5:7b", "provider": "ollama", "temperature": 0.7}, "chat_route_ollama_qwen_fast": {"description": "本地快速对话 (Ollama Qwen2.5:7b 轻量)", "endpoint": "/chat/completions", "max_tokens": 512, "model": "qwen2.5:7b", "provider": "ollama", "temperature": 0.3}, "chat_route_openrouter_deepseek_v3": {"description": "DeepSeek V3.2 (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 4096, "model": "deepseek/deepseek-v3.2", "provider": "openrouter", "temperature": 0.6}, "chat_route_openrouter_deepseek_v4_flash": {"description": "DeepSeek V4 Flash (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 4096, "model": "deepseek/deepseek-v4-flash", "provider": "openrouter", "temperature": 0.7}, "chat_route_openrouter_gpt_5": {"description": "GPT 5.4 (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 8192, "model": "openai/gpt-5.4", "provider": "openrouter", "temperature": 0.7}, "chat_route_openrouter_grok_4": {"description": "Grok 4.20 (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 4096, "model": "x-ai/grok-4.20", "provider": "openrouter", "temperature": 0.6}}, "default_embed_route": "embed_route_openrouter_text_v3", "default_route": "chat_route_openrouter_deepseek_v4_flash", "description": "eai_agentplatform AI 路由配置 — 按场景分类定义多条路由,支持 provider/模型切换", "embed_routes": {"embed_route_ollama_bge_m3": {"description": "本地 Ollama bge-m3", "endpoint": "/embeddings", "model": "bge-m3", "provider": "ollama"}, "embed_route_openrouter_text_v3": {"description": "OpenAI text-embedding-3-small (OpenRouter)", "endpoint": "/embeddings", "model": "openai/text-embedding-3-small", "provider": "openrouter"}}, "fallback_routes": {"chat_route_openrouter_deepseek_v3": ["chat_route_openrouter_grok_4", "chat_route_ollama_qwen"], "chat_route_openrouter_deepseek_v4_flash": ["chat_route_openrouter_deepseek_v3", "chat_route_openrouter_grok_4", "chat_route_ollama_qwen"], "chat_route_openrouter_grok_4": ["chat_route_ollama_qwen"], "embed_route_ollama_bge_m3": ["embed_route_openrouter_text_v3"], "embed_route_openrouter_text_v3": ["embed_route_ollama_bge_m3"]}, "image_routes": {"image_route_ollama_llava": {"description": "本地 Ollama LLaVA", "endpoint": "/chat/completions", "max_tokens": 2048, "model": "llava", "provider": "ollama", "temperature": 0.7}, "image_route_openrouter_flux": {"description": "FLUX 1.1 Pro (OpenRouter)", "endpoint": "/images/generations", "max_tokens": 1024, "model": "black-forest-labs/flux-1.1-pro", "provider": "openrouter", "temperature": 1}, "image_route_openrouter_gpt_image_2": {"description": "GPT 5.4 Image 2 (OpenRouter)", "endpoint": "/images/generations", "max_tokens": 4096, "model": "openai/gpt-5.4-image-2", "provider": "openrouter", "temperature": 1}}, "version": "2.1.0"}
{"agent_routes": {"embed_gen": "embed_route_openrouter_text_v3", "image_gen": "image_route_openrouter_gpt_image_2", "path_coach": "chat_route_openrouter_deepseek_v4_flash", "title_gen": "chat_route_openrouter_deepseek_v4_flash"}, "chat_routes": {"chat_route_ollama_qwen": {"description": "本地 Ollama Qwen2.5:7b", "endpoint": "/chat/completions", "max_tokens": 2048, "model": "qwen2.5:7b", "provider": "ollama", "short_model_name": "Qwen", "short_route_name": "本地", "temperature": 0.7}, "chat_route_ollama_qwen_fast": {"description": "本地快速对话 (Ollama Qwen2.5:7b 轻量)", "endpoint": "/chat/completions", "max_tokens": 512, "model": "qwen2.5:7b", "provider": "ollama", "short_model_name": "Qwen", "short_route_name": "快聊", "temperature": 0.3}, "chat_route_openrouter_deepseek_v3": {"description": "DeepSeek V3.2 (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 4096, "model": "deepseek/deepseek-v3.2", "provider": "openrouter", "short_model_name": "DSV3", "short_route_name": "均衡", "temperature": 0.6}, "chat_route_openrouter_deepseek_v4_flash": {"description": "DeepSeek V4 Flash (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 4096, "model": "deepseek/deepseek-v4-flash", "provider": "openrouter", "short_model_name": "DS4F", "short_route_name": "默认", "temperature": 0.7}, "chat_route_openrouter_gpt_5": {"description": "GPT 5.4 (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 8192, "model": "openai/gpt-5.4", "provider": "openrouter", "short_model_name": "GPT5", "short_route_name": "全能", "temperature": 0.7}, "chat_route_openrouter_grok_4": {"description": "Grok 4.20 (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 4096, "model": "x-ai/grok-4.20", "provider": "openrouter", "short_model_name": "Grok4", "short_route_name": "推理", "temperature": 0.6}}, "default_embed_route": "embed_route_openrouter_text_v3", "default_route": "chat_route_openrouter_deepseek_v4_flash", "description": "eai_agentplatform AI 路由配置 — 按场景分类定义多条路由,支持 provider/模型切换", "embed_routes": {"embed_route_ollama_bge_m3": {"description": "本地 Ollama bge-m3", "endpoint": "/embeddings", "model": "bge-m3", "provider": "ollama", "short_model_name": "BGE", "short_route_name": "本地"}, "embed_route_openrouter_text_v3": {"description": "OpenAI text-embedding-3-small (OpenRouter)", "endpoint": "/embeddings", "model": "openai/text-embedding-3-small", "provider": "openrouter", "short_model_name": "Emb3", "short_route_name": "默认"}}, "fallback_routes": {"chat_route_openrouter_deepseek_v3": ["chat_route_openrouter_grok_4", "chat_route_ollama_qwen"], "chat_route_openrouter_deepseek_v4_flash": ["chat_route_openrouter_deepseek_v3", "chat_route_openrouter_grok_4", "chat_route_ollama_qwen"], "chat_route_openrouter_grok_4": ["chat_route_ollama_qwen"], "embed_route_ollama_bge_m3": ["embed_route_openrouter_text_v3"], "embed_route_openrouter_text_v3": ["embed_route_ollama_bge_m3"]}, "image_routes": {"image_route_ollama_llava": {"description": "本地 Ollama LLaVA", "endpoint": "/chat/completions", "max_tokens": 2048, "model": "llava", "provider": "ollama", "short_model_name": "LLaVA", "short_route_name": "本地", "temperature": 0.7}, "image_route_openrouter_flux": {"description": "FLUX 1.1 Pro (OpenRouter)", "endpoint": "/images/generations", "max_tokens": 1024, "model": "black-forest-labs/flux-1.1-pro", "provider": "openrouter", "short_model_name": "FLUX", "short_route_name": "绘图", "temperature": 1}, "image_route_openrouter_gpt_image_2": {"description": "GPT 5.4 Image 2 (OpenRouter)", "endpoint": "/images/generations", "max_tokens": 4096, "model": "openai/gpt-5.4-image-2", "provider": "openrouter", "short_model_name": "G图2", "short_route_name": "默认", "temperature": 1}}, "version": "2.1.0"}
@@ -1,16 +1,6 @@
package ai
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"eai_agentplatform/backend/internal/config"
)
// 知识检索相关的数据传输类型(由 internal/api 使用)。
type KnowledgeClassifyResult struct {
Intent string `json:"intent"`
@@ -41,109 +31,3 @@ type KnowledgeIndexItem struct {
KnowledgeSpaceKey string `json:"knowledge_space_key"`
KnowledgeSpaceName string `json:"knowledge_space_name"`
}
func KnowledgeServiceEnabled(cfg *config.Config) bool {
return cfg != nil && strings.TrimSpace(cfg.KnowledgeServiceURL) != ""
}
func KnowledgeClassify(cfg *config.Config, query string) (*KnowledgeClassifyResult, error) {
var resp struct {
Data KnowledgeClassifyResult `json:"data"`
}
if err := postKnowledgeService(cfg, "/classify", map[string]any{
"query": query,
}, &resp); err != nil {
return nil, err
}
return &resp.Data, nil
}
func KnowledgeSearch(cfg *config.Config, query, spaceKey string, topK int) ([]KnowledgeSearchItem, error) {
var resp struct {
Data struct {
Items []KnowledgeSearchItem `json:"items"`
} `json:"data"`
}
if err := postKnowledgeService(cfg, "/search", map[string]any{
"query": query,
"space_key": spaceKey,
"top_k": topK,
}, &resp); err != nil {
return nil, err
}
return resp.Data.Items, nil
}
func KnowledgeRebuildIndex(cfg *config.Config, items []KnowledgeIndexItem) error {
var resp map[string]any
return postKnowledgeService(cfg, "/index/rebuild", map[string]any{
"index_dir": cfg.KnowledgeIndexDir,
"items": items,
}, &resp)
}
func postKnowledgeService(cfg *config.Config, path string, payload any, out any) error {
if !KnowledgeServiceEnabled(cfg) {
return fmt.Errorf("knowledge service disabled")
}
b, err := json.Marshal(payload)
if err != nil {
return err
}
url := strings.TrimRight(cfg.KnowledgeServiceURL, "/") + path
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(b))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
// #region debug-point D:knowledge-service-request
if payload, err := json.Marshal(map[string]any{
"sessionId": "knowledge-chat-401",
"runId": "pre-fix",
"hypothesisId": "D",
"location": "backend-go/internal/ai/knowledge_service_client.go:postKnowledgeService:request",
"msg": "[DEBUG] knowledge service request",
"data": map[string]any{
"url": url,
"path": path,
},
"ts": time.Now().UnixMilli(),
}); err == nil {
go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload)))
}
// #endregion
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 300 {
// #region debug-point D:knowledge-service-non200
if payload, err := json.Marshal(map[string]any{
"sessionId": "knowledge-chat-401",
"runId": "pre-fix",
"hypothesisId": "D",
"location": "backend-go/internal/ai/knowledge_service_client.go:postKnowledgeService:non200",
"msg": "[DEBUG] knowledge service non-200",
"data": map[string]any{
"url": url,
"status": resp.StatusCode,
"bodyPreview": truncate(string(body), 240),
},
"ts": time.Now().UnixMilli(),
}); err == nil {
go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload)))
}
// #endregion
return fmt.Errorf("knowledge service %d: %s", resp.StatusCode, truncate(string(body), 200))
}
if out == nil || len(body) == 0 {
return nil
}
if err := json.Unmarshal(body, out); err != nil {
return fmt.Errorf("knowledge service parse failed: %w", err)
}
return nil
}
@@ -16,8 +16,8 @@ func Retrieve(cfg *config.Config, query string, topK int) []string {
topK = 5
}
var vectorResults []string
if route, err := config.GetRoute("embed_gen"); err == nil {
if v, err := vectorRetrieve(route, query, topK); err == nil {
if aiRoute, err := config.GetRoute("embed_gen"); err == nil {
if v, err := vectorRetrieve(aiRoute, query, topK); err == nil {
vectorResults = v
}
}
@@ -40,13 +40,13 @@ func Retrieve(cfg *config.Config, query string, topK int) []string {
}
// vectorRetrieve 向量检索:query + 所有 chunk 一次批量 embedding,brute-force 余弦 topK
func vectorRetrieve(route *config.RouteConfig, query string, topK int) ([]string, error) {
func vectorRetrieve(aiRoute *config.RouteConfig, query string, topK int) ([]string, error) {
var chunks []model.KnowledgeChunk
store.DB.Order("id ASC").Find(&chunks)
if len(chunks) == 0 {
return nil, nil
}
client := NewClient(route)
client := NewClient(aiRoute)
inputs := make([]string, 0, len(chunks)+1)
inputs = append(inputs, query)
for _, ch := range chunks {
@@ -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 ""
}
@@ -20,9 +20,6 @@ type Config struct {
LLMModel string
EmbedModel string
KnowledgeServiceURL string
KnowledgeIndexDir string
KBDataDir string
KnowledgeSourceDir string
@@ -63,9 +60,6 @@ func Load() *Config {
LLMModel: getenv("LLM_MODEL", "qwen2.5:7b"),
EmbedModel: getenv("EMBED_MODEL", "bge-m3"),
KnowledgeServiceURL: getenv("KNOWLEDGE_SERVICE_URL", "http://127.0.0.1:10233"),
KnowledgeIndexDir: getenv("KNOWLEDGE_INDEX_DIR", filepath.Join(baseDir, "data", "faiss")),
KBDataDir: getenv("KB_DATA_DIR", filepath.Join(baseDir, "data", "kb_data")),
KnowledgeSourceDir: getenv("KNOWLEDGE_SOURCE_DIR", filepath.Join(baseDir, "knowledge_source")),
@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
)
@@ -15,46 +16,50 @@ import (
// RouteInfo 单条路由定义(JSON 文件中的原始数据)
type RouteInfo struct {
Provider string `json:"provider"`
Model string `json:"model"`
Endpoint string `json:"endpoint"`
MaxTokens int `json:"max_tokens"`
Temperature float64 `json:"temperature"`
Category string `json:"category,omitempty"`
Description string `json:"description,omitempty"`
Provider string `json:"provider"`
Model string `json:"model"`
Endpoint string `json:"endpoint"`
MaxTokens int `json:"max_tokens"`
Temperature float64 `json:"temperature"`
Category string `json:"category,omitempty"`
Description string `json:"description,omitempty"`
ShortRouteName string `json:"short_route_name,omitempty"`
ShortModelName string `json:"short_model_name,omitempty"`
}
// RouteConfig 运行时完整路由配置(合并 secrets 后)
type RouteConfig struct {
RouteID string
Provider string
Model string
BaseURL string // 完整 base_url(已从 secrets 注入)
Endpoint string // 相对路径(如 /chat/completions)
FullURL string // BaseURL + Endpoint
APIKey string
MaxTokens int
Temperature float64
Category string // chat / embed / image
Description string
RouteID string
Provider string
Model string
BaseURL string // 完整 base_url(已从 secrets 注入)
Endpoint string // 相对路径(如 /chat/completions)
FullURL string // BaseURL + Endpoint
APIKey string
MaxTokens int
Temperature float64
Category string // chat / embed / image
Description string
ShortRouteName string
ShortModelName string
}
// AIConfig 顶层结构(支持分类路由)
type AIConfig struct {
Version string `json:"version"`
Description string `json:"description"`
DefaultRoute string `json:"default_route"`
DefaultEmbedRoute string `json:"default_embed_route"`
AgentRoutes map[string]string `json:"agent_routes"`
ChatRoutes map[string]RouteInfo `json:"chat_routes"`
EmbedRoutes map[string]RouteInfo `json:"embed_routes"`
ImageRoutes map[string]RouteInfo `json:"image_routes"`
FallbackRoutes map[string][]string `json:"fallback_routes"`
Version string `json:"version"`
Description string `json:"description"`
DefaultRoute string `json:"default_route"`
DefaultEmbedRoute string `json:"default_embed_route"`
AgentRoutes map[string]string `json:"agent_routes"`
ChatRoutes map[string]RouteInfo `json:"chat_routes"`
EmbedRoutes map[string]RouteInfo `json:"embed_routes"`
ImageRoutes map[string]RouteInfo `json:"image_routes"`
FallbackRoutes map[string][]string `json:"fallback_routes"`
// 兼容旧版平铺 routes(若有则回退)
Routes map[string]RouteInfo `json:"routes,omitempty"`
Routes map[string]RouteInfo `json:"routes,omitempty"`
}
// AISecrets AI 密钥文件结构(从 pj034 直接复制的格式)
// AISecrets AI 密钥文件结构
type AISecrets struct {
VECTORENGINE_API_KEY string `json:"VECTORENGINE_API_KEY,omitempty"`
OPENROUTER_API_KEY string `json:"OPENROUTER_API_KEY,omitempty"`
@@ -79,7 +84,7 @@ type PlatformConfig struct {
}
// ──────────────────────────────────────────────
// PROVIDER_SECRET_KEY 映射(参考 pj034 ai_config.py)
// PROVIDER_SECRET_KEY 映射
// provider 名称 → secrets JSON 中的字段名
// ──────────────────────────────────────────────
@@ -98,12 +103,12 @@ var ProviderSecretKey = map[string]string{
// ProviderDefaultBaseURL provider 默认 base_url(当 secrets 未提供时)
var ProviderDefaultBaseURL = map[string]string{
"ollama": "http://127.0.0.1:11434/v1",
"openrouter": "https://openrouter.ai/api/v1",
"openai": "https://api.openai.com/v1",
"ollama": "http://127.0.0.1:11434/v1",
"openrouter": "https://openrouter.ai/api/v1",
"openai": "https://api.openai.com/v1",
"vectorengine": "https://api.vectorengine.ai/v1",
"volces": "https://ark.cn-beijing.volces.com/api/v3",
"lmuai": "https://api.lmuai.com/v1",
"volces": "https://ark.cn-beijing.volces.com/api/v3",
"lmuai": "https://api.lmuai.com/v1",
}
// ──────────────────────────────────────────────
@@ -244,8 +249,8 @@ func LoadPlatformConfig(forceReload ...bool) (*PlatformConfig, error) {
}
// ──────────────────────────────────────────────
// GetRoute — 核心路由解析(参考 pj034 get_route())
// 输入 agent 名或 route_id,返回完整 RouteConfig(已注入 base_url / api_key)
// GetRoute — 核心路由解析
// 输入 agent 名或 ai_route_id,返回完整 RouteConfig(已注入 base_url / api_key)
// 查找路径:agent_routes → 分类 routes(chats/embeds/images) → 兼容旧版 routes → default
// ──────────────────────────────────────────────
@@ -261,6 +266,13 @@ func GetRoute(agentOrRouteID string) (*RouteConfig, error) {
routeID = r
}
if routeID == AutoChatRouteID {
return resolveAutoRoute("chat")
}
if routeID == AutoEmbedRouteID {
return resolveAutoRoute("embed")
}
// 2) 在分类路由中查找
info, category, found := findRoute(aiCfg, routeID)
if !found {
@@ -294,17 +306,19 @@ func GetRoute(agentOrRouteID string) (*RouteConfig, error) {
fullURL := strings.TrimRight(baseURL, "/") + info.Endpoint
rc := &RouteConfig{
RouteID: routeID,
Provider: info.Provider,
Model: info.Model,
BaseURL: baseURL,
Endpoint: info.Endpoint,
FullURL: fullURL,
APIKey: apiKey,
MaxTokens: info.MaxTokens,
Temperature: info.Temperature,
Category: category,
Description: info.Description,
RouteID: routeID,
Provider: info.Provider,
Model: info.Model,
BaseURL: baseURL,
Endpoint: info.Endpoint,
FullURL: fullURL,
APIKey: apiKey,
MaxTokens: info.MaxTokens,
Temperature: info.Temperature,
Category: category,
Description: info.Description,
ShortRouteName: info.ShortRouteName,
ShortModelName: info.ShortModelName,
}
if rc.MaxTokens <= 0 {
rc.MaxTokens = 2048
@@ -463,8 +477,14 @@ func GetRoutesByCategory(category string) ([]*RouteConfig, error) {
return nil, fmt.Errorf("未知路由分类: %s", category)
}
var result []*RouteConfig
keys := make([]string, 0, len(routeMap))
for rid := range routeMap {
keys = append(keys, rid)
}
sort.Strings(keys)
var result []*RouteConfig
for _, rid := range keys {
r, err := GetRoute(rid)
if err != nil {
continue
@@ -0,0 +1,330 @@
package config
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strings"
"sync"
"time"
)
const (
AutoChatRouteID = "chat_route_auto"
AutoEmbedRouteID = "embed_route_auto"
defaultAIRouteProbeInterval = 30 * time.Minute
defaultAIRouteProbeTimeout = 20 * time.Second
)
type RouteHealth struct {
AIRouteID string `json:"ai_route_id"`
Category string `json:"category"`
Healthy bool `json:"healthy"`
Checked bool `json:"checked"`
LatencyMs int64 `json:"latency_ms,omitempty"`
LastCheckedAt time.Time `json:"last_checked_at,omitempty"`
LastError string `json:"last_error,omitempty"`
}
var (
routeHealthMu sync.RWMutex
routeHealthMap = map[string]RouteHealth{}
routeHealthOnce sync.Once
)
func StartAIRouteHealthLoop(interval time.Duration) {
if interval <= 0 {
interval = defaultAIRouteProbeInterval
}
routeHealthOnce.Do(func() {
go func() {
RefreshAIRouteHealthNow()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
RefreshAIRouteHealthNow()
}
}()
})
}
func RefreshAIRouteHealthNow() {
refreshAIRouteHealthForCategory("chat")
refreshAIRouteHealthForCategory("embed")
}
func GetRouteHealth(routeID string) (RouteHealth, bool) {
routeHealthMu.RLock()
defer routeHealthMu.RUnlock()
status, ok := routeHealthMap[routeID]
return status, ok
}
func resolveAutoRoute(category string) (*RouteConfig, error) {
routes, err := GetRoutesByCategory(category)
if err != nil {
return nil, err
}
if len(routes) == 0 {
return nil, fmt.Errorf("%s 路由未配置", category)
}
if shouldRefreshRouteHealth(routes) {
refreshAIRouteHealth(routes)
}
defaultRouteID := getDefaultRouteIDForCategory(category)
best := pickBestHealthyRoute(routes, defaultRouteID)
if best != nil {
return best, nil
}
if defaultRouteID != "" {
if route, err := GetRoute(defaultRouteID); err == nil && route != nil {
return route, nil
}
}
return routes[0], nil
}
func refreshAIRouteHealthForCategory(category string) {
routes, err := GetRoutesByCategory(category)
if err != nil || len(routes) == 0 {
return
}
refreshAIRouteHealth(routes)
}
func refreshAIRouteHealth(routes []*RouteConfig) {
for _, route := range routes {
if route == nil {
continue
}
ReportRouteHealth(probeRoute(route))
}
}
func shouldRefreshRouteHealth(routes []*RouteConfig) bool {
if len(routes) == 0 {
return false
}
cutoff := time.Now().Add(-(defaultAIRouteProbeInterval + 5*time.Minute))
routeHealthMu.RLock()
defer routeHealthMu.RUnlock()
for _, route := range routes {
if route == nil {
continue
}
status, ok := routeHealthMap[route.RouteID]
if !ok || !status.Checked || status.LastCheckedAt.Before(cutoff) {
return true
}
}
return false
}
func pickBestHealthyRoute(routes []*RouteConfig, defaultRouteID string) *RouteConfig {
type candidate struct {
route *RouteConfig
health RouteHealth
isDefault bool
}
candidates := make([]candidate, 0, len(routes))
routeHealthMu.RLock()
for _, route := range routes {
if route == nil {
continue
}
status, ok := routeHealthMap[route.RouteID]
if !ok || !status.Checked || !status.Healthy {
continue
}
candidates = append(candidates, candidate{
route: route,
health: status,
isDefault: route.RouteID == defaultRouteID,
})
}
routeHealthMu.RUnlock()
if len(candidates) == 0 {
return nil
}
sort.Slice(candidates, func(i, j int) bool {
if candidates[i].isDefault != candidates[j].isDefault {
return candidates[i].isDefault
}
if candidates[i].health.LatencyMs != candidates[j].health.LatencyMs {
return candidates[i].health.LatencyMs < candidates[j].health.LatencyMs
}
return candidates[i].route.RouteID < candidates[j].route.RouteID
})
return candidates[0].route
}
func getDefaultRouteIDForCategory(category string) string {
aiCfg, err := LoadAIConfig()
if err != nil {
return ""
}
switch category {
case "chat":
if aiCfg.DefaultRoute != AutoChatRouteID {
return aiCfg.DefaultRoute
}
case "embed":
if aiCfg.DefaultEmbedRoute != AutoEmbedRouteID {
return aiCfg.DefaultEmbedRoute
}
}
return ""
}
func probeRoute(route *RouteConfig) RouteHealth {
if route == nil {
return RouteHealth{
Checked: true,
LastCheckedAt: time.Now(),
LastError: "路由不存在",
}
}
status := RouteHealth{
AIRouteID: route.RouteID,
Category: route.Category,
Checked: true,
LastCheckedAt: time.Now(),
}
start := time.Now()
if strings.TrimSpace(route.BaseURL) == "" {
status.LastError = "base_url 未配置"
return status
}
if requiresRouteAPIKey(route) && strings.TrimSpace(route.APIKey) == "" {
status.LastError = "API Key 未配置"
return status
}
client := &http.Client{Timeout: defaultAIRouteProbeTimeout}
var err error
switch route.Category {
case "embed":
err = probeEmbedRoute(client, route)
default:
err = probeChatRoute(client, route)
}
status.LatencyMs = time.Since(start).Milliseconds()
if err != nil {
status.LastError = err.Error()
return status
}
status.Healthy = true
return status
}
func probeChatRoute(client *http.Client, route *RouteConfig) error {
body := map[string]any{
"model": route.Model,
"messages": []map[string]string{{"role": "user", "content": "ping"}},
"stream": false,
"temperature": 0,
"max_tokens": 4,
}
var resp struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := doRouteProbeRequest(client, route, body, &resp); err != nil {
return err
}
if len(resp.Choices) == 0 {
return fmt.Errorf("返回空 choices")
}
return nil
}
func probeEmbedRoute(client *http.Client, route *RouteConfig) error {
body := map[string]any{
"model": route.Model,
"input": "ping",
}
var resp struct {
Data []struct {
Embedding []float64 `json:"embedding"`
} `json:"data"`
}
if err := doRouteProbeRequest(client, route, body, &resp); err != nil {
return err
}
if len(resp.Data) == 0 || len(resp.Data[0].Embedding) == 0 {
return fmt.Errorf("返回空 embedding")
}
return nil
}
func doRouteProbeRequest(client *http.Client, route *RouteConfig, body any, out any) error {
raw, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, route.FullURL, bytes.NewReader(raw))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if strings.TrimSpace(route.APIKey) != "" {
req.Header.Set("Authorization", "Bearer "+route.APIKey)
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("服务不可达: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("读取响应失败: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("返回 %d: %s", resp.StatusCode, truncateProbeText(string(data), 160))
}
if err := json.Unmarshal(data, out); err != nil {
return fmt.Errorf("响应解析失败: %w", err)
}
return nil
}
func requiresRouteAPIKey(route *RouteConfig) bool {
if route == nil {
return false
}
baseURL := strings.ToLower(strings.TrimSpace(route.BaseURL))
if strings.Contains(baseURL, "openrouter.ai") || strings.Contains(baseURL, "openai.com") {
return true
}
provider := strings.ToLower(strings.TrimSpace(route.Provider))
return provider == "openrouter" || provider == "openai"
}
func ReportRouteHealth(status RouteHealth) {
if strings.TrimSpace(status.AIRouteID) == "" {
return
}
routeHealthMu.Lock()
routeHealthMap[status.AIRouteID] = status
routeHealthMu.Unlock()
}
func truncateProbeText(text string, limit int) string {
text = strings.TrimSpace(text)
if limit <= 0 || len(text) <= limit {
return text
}
return text[:limit] + "..."
}
@@ -1,11 +1,7 @@
package middleware
import (
"bytes"
"encoding/json"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -53,10 +49,7 @@ func Auth(cfg *config.Config) gin.HandlerFunc {
}
c.Set("current_user", &user)
// #region debug-point C:auth-success
go func(path, method, username, role string, userID uint) { _, _ = http.Post("http://127.0.0.1:7777/event", "application/json", bytes.NewBuffer(mustJSON(map[string]any{"sessionId": "admin-auth-misjudge", "runId": "pre-fix", "hypothesisId": "C", "location": "internal/middleware/auth.go:Auth", "msg": "[DEBUG] auth accepted request", "data": map[string]any{"path": path, "method": method, "username": username, "role": role, "user_id": userID, "token_present": true}, "ts": time.Now().UnixMilli()}))) }(c.Request.URL.Path, c.Request.Method, user.Username, user.Role, user.ID)
// #endregion
c.Next()
c.Next()
}
}
@@ -65,9 +58,6 @@ func RequireAdmin() gin.HandlerFunc {
return func(c *gin.Context) {
u := CurrentUser(c)
if u == nil || u.Role != "admin" {
// #region debug-point D:require-admin-reject
go func(path, method string, u *model.User) { payload := map[string]any{"sessionId": "admin-auth-misjudge", "runId": "pre-fix", "hypothesisId": "D", "location": "internal/middleware/auth.go:RequireAdmin", "msg": "[DEBUG] require admin rejected request", "data": map[string]any{"path": path, "method": method, "user_nil": u == nil}, "ts": time.Now().UnixMilli()}; if u != nil { payload["data"] = map[string]any{"path": path, "method": method, "user_nil": false, "username": u.Username, "role": u.Role, "user_id": u.ID} }; _, _ = http.Post("http://127.0.0.1:7777/event", "application/json", bytes.NewBuffer(mustJSON(payload))) }(c.Request.URL.Path, c.Request.Method, u)
// #endregion
web.Fail(c, web.NewForbiddenError("需要管理员权限"))
c.Abort()
return
@@ -93,11 +83,3 @@ func extractBearer(c *gin.Context) string {
}
return ""
}
func mustJSON(v any) []byte {
b, err := json.Marshal(v)
if err != nil {
return []byte(`{"sessionId":"admin-auth-misjudge","runId":"pre-fix","hypothesisId":"D","location":"internal/middleware/auth.go:mustJSON","msg":"[DEBUG] marshal failed","ts":0}`)
}
return b
}
@@ -0,0 +1,26 @@
package model
import "time"
// ActionDefinition 内部原子执行动作定义。
type ActionDefinition struct {
ID uint `gorm:"primaryKey" json:"id"`
Key string `gorm:"size:64;uniqueIndex;not null" json:"key"`
Label string `gorm:"size:128;not null" json:"label"`
Description string `gorm:"type:text" json:"description"`
ActionType string `gorm:"size:32;not null;default:execution;index" json:"action_type"`
ConnectorRef string `gorm:"size:64;default:'';index" json:"connector_ref"`
InputSchemaJSON string `gorm:"type:text" json:"input_schema_json"`
OutputSchemaJSON string `gorm:"type:text" json:"output_schema_json"`
RiskLevel string `gorm:"size:16;not null;default:low;index" json:"risk_level"`
ApprovalMode string `gorm:"size:32;not null;default:not_required" json:"approval_mode"`
AuditLevel string `gorm:"size:32;not null;default:standard" json:"audit_level"`
ExposedToUser bool `gorm:"not null;default:false;index" json:"exposed_to_user"`
OntologyBindingJSON string `gorm:"type:text" json:"ontology_binding_json"`
State string `gorm:"size:16;not null;default:active;index" json:"state"`
SortOrder int `gorm:"not null;default:0;index" json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (ActionDefinition) TableName() string { return "action_definition" }
@@ -0,0 +1,26 @@
package model
import "time"
// Project 项目:任务的容器。
//
// 任务记「做的是哪件事」,项目记「这些事是为谁、按什么规矩做的」——
// 指令是给 AI 的长期上下文,预置的专员/技能/连接器是这个项目默认带上的能力。
// 归属跟 WorkerTask.Owner 同构(全名优先,回退登录名),列表只列自己的。
type Project struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:40;not null" json:"name"`
Instruction string `gorm:"type:text" json:"instruction"`
TemplateKey string `gorm:"size:64;default:''" json:"template_key"`
Owner string `gorm:"size:64;not null;index" json:"owner"`
Pinned bool `gorm:"not null;default:false" json:"pinned"`
// 建项目时选中的能力,存 JSON 数组字符串。key 各自指向 specialist.key /
// skill.key / connector.key —— 只存 key,展示时再去取名字。
SpecialistKeys string `gorm:"type:text" json:"specialist_keys"`
SkillKeys string `gorm:"column:skill_keys;type:text" json:"skill_keys"`
ConnectorKeys string `gorm:"type:text" json:"connector_keys"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (Project) TableName() string { return "project" }
@@ -0,0 +1,32 @@
package model
import "time"
// SkillDefinition 对外暴露的任务级能力定义。
type SkillDefinition struct {
ID uint `gorm:"primaryKey" json:"id"`
Key string `gorm:"size:64;uniqueIndex;not null" json:"key"`
Label string `gorm:"size:128;not null" json:"label"`
DisplayCode string `gorm:"column:display_code;size:16;default:'';index" json:"display_code"`
EAILogicCode string `gorm:"column:eailogic_code;size:32;default:'';index" json:"eailogic_code"`
Description string `gorm:"type:text" json:"description"`
RoleKind string `gorm:"size:32;not null;default:skill;index" json:"role_kind"` // assistant / specialist / skill
Source string `gorm:"size:32;not null;default:eai;index" json:"source"` // eai / custom
ObjectEntryRoute string `gorm:"column:object_entry_route;size:128;default:''" json:"object_entry_route"`
LegacyObjectEntryRoute string `gorm:"column:legacy_object_entry_route;size:128;default:''" json:"legacy_object_entry_route"`
ExposedToUser bool `gorm:"not null;default:true;index" json:"exposed_to_user"`
StarterPromptsJSON string `gorm:"type:text" json:"starter_prompts_json"`
PromptTemplate string `gorm:"type:text" json:"prompt_template"`
InputSchemaJSON string `gorm:"type:text" json:"input_schema_json"`
OutputSchemaJSON string `gorm:"type:text" json:"output_schema_json"`
ArtifactSchemaJSON string `gorm:"type:text" json:"artifact_schema_json"`
ActionRefsJSON string `gorm:"type:text" json:"action_refs_json"`
PolicyRefsJSON string `gorm:"type:text" json:"policy_refs_json"`
OntologyBindingJSON string `gorm:"type:text" json:"ontology_binding_json"`
State string `gorm:"size:16;not null;default:active;index" json:"state"`
SortOrder int `gorm:"not null;default:0;index" json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (SkillDefinition) TableName() string { return "skill_definition" }
@@ -0,0 +1,16 @@
package model
import "time"
// UserAppCenter 保存用户自己的应用中心状态,用于同步我的应用、收藏和最近使用。
type UserAppCenter struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;uniqueIndex" json:"user_id"`
FavoriteKeys string `gorm:"type:text;not null;default:''" json:"favorite_keys"`
RecentKeys string `gorm:"type:text;not null;default:''" json:"recent_keys"`
CustomApps string `gorm:"type:text;not null;default:''" json:"custom_apps"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (UserAppCenter) TableName() string { return "user_app_center" }
@@ -17,6 +17,9 @@ type WorkerTask struct {
DueAt *time.Time `json:"due_at"`
CreatedBy *uint `gorm:"index" json:"created_by"`
LastTriggeredAt *time.Time `json:"last_triggered_at"`
// ProjectID 这条任务属于哪个项目。可空 —— 不挂项目的任务跟以前一样,
// 删项目时把它置空(任务本身留着),所以不能是级联删除。
ProjectID *uint `gorm:"index" json:"project_id"`
// Pinned 置顶:只影响「我的任务」列表的排序,不改变任务本身的状态。
Pinned bool `gorm:"not null;default:false" json:"pinned"`
CreatedAt time.Time `json:"created_at"`
@@ -1,6 +1,7 @@
package store
import (
"fmt"
"os"
"path/filepath"
@@ -15,7 +16,6 @@ import (
var DB *gorm.DB
// Init 打开 SQLite(pure-Go 驱动,CGO_ENABLED=0 可静态编译)并 AutoMigrate 全部表。
// 通过 GORM 方言抽象,未来切换 MySQL 仅需替换驱动与 DSN。
func Init(dbPath string) (*gorm.DB, error) {
if dir := filepath.Dir(dbPath); dir != "." && dir != "" {
if err := os.MkdirAll(dir, 0o755); err != nil {
@@ -55,15 +55,149 @@ func Init(dbPath string) (*gorm.DB, error) {
&model.Notification{},
&model.StudyNote{},
&model.Specialist{},
&model.SkillDefinition{},
&model.ActionDefinition{},
&model.Project{},
&model.WorkerTask{},
&model.WorkerArtifact{},
&model.WorkerRun{},
&model.UserAppCenter{},
&model.OfficialAccountArticle{},
&model.OfficialAccountHotspot{},
); err != nil {
return nil, err
}
if err := migrateObjectEntryRouteColumns(db); err != nil {
return nil, err
}
DB = db
return db, nil
}
func migrateObjectEntryRouteColumns(db *gorm.DB) error {
if db.Migrator().HasTable(&model.Specialist{}) {
if db.Migrator().HasColumn(&model.Specialist{}, "object_entry_route") {
specialistLegacyExpr := coalesceRouteExpr(
db.Migrator().HasColumn(&model.Specialist{}, "entry_route"),
"entry_route",
db.Migrator().HasColumn(&model.Specialist{}, "route"),
"route",
)
if specialistLegacyExpr != "" {
if err := db.Exec(fmt.Sprintf(`
UPDATE specialist
SET object_entry_route = %s
WHERE COALESCE(object_entry_route, '') = ''
AND COALESCE(%s, '') <> ''
`, specialistLegacyExpr, specialistLegacyExpr)).Error; err != nil {
return err
}
}
}
if db.Migrator().HasColumn(&model.Specialist{}, "entry_route") {
if err := dropColumn(db, "specialist", "entry_route"); err != nil {
return err
}
}
if db.Migrator().HasColumn(&model.Specialist{}, "route") {
if err := dropColumn(db, "specialist", "route"); err != nil {
return err
}
}
}
if db.Migrator().HasTable(&model.SkillDefinition{}) {
if db.Migrator().HasColumn(&model.SkillDefinition{}, "object_entry_route") {
skillLegacyExpr := coalesceRouteExpr(
db.Migrator().HasColumn(&model.SkillDefinition{}, "entry_route"),
"entry_route",
db.Migrator().HasColumn(&model.SkillDefinition{}, "route"),
"route",
)
if skillLegacyExpr != "" {
if err := db.Exec(fmt.Sprintf(`
UPDATE skill_definition
SET object_entry_route = %s
WHERE COALESCE(object_entry_route, '') = ''
AND COALESCE(%s, '') <> ''
`, skillLegacyExpr, skillLegacyExpr)).Error; err != nil {
return err
}
}
}
if db.Migrator().HasColumn(&model.SkillDefinition{}, "legacy_object_entry_route") {
skillLegacyObjectExpr := coalesceRouteExpr(
db.Migrator().HasColumn(&model.SkillDefinition{}, "legacy_entry_route"),
"legacy_entry_route",
db.Migrator().HasColumn(&model.SkillDefinition{}, "legacy_route"),
"legacy_route",
)
if skillLegacyObjectExpr != "" {
if err := db.Exec(fmt.Sprintf(`
UPDATE skill_definition
SET legacy_object_entry_route = %s
WHERE COALESCE(legacy_object_entry_route, '') = ''
AND COALESCE(%s, '') <> ''
`, skillLegacyObjectExpr, skillLegacyObjectExpr)).Error; err != nil {
return err
}
}
}
if db.Migrator().HasColumn(&model.SkillDefinition{}, "entry_route") {
if err := dropColumn(db, "skill_definition", "entry_route"); err != nil {
return err
}
}
if db.Migrator().HasColumn(&model.SkillDefinition{}, "route") {
if err := dropColumn(db, "skill_definition", "route"); err != nil {
return err
}
}
if db.Migrator().HasColumn(&model.SkillDefinition{}, "legacy_entry_route") {
if err := dropColumn(db, "skill_definition", "legacy_entry_route"); err != nil {
return err
}
}
if db.Migrator().HasColumn(&model.SkillDefinition{}, "legacy_route") {
if err := dropColumn(db, "skill_definition", "legacy_route"); err != nil {
return err
}
}
}
return nil
}
func coalesceRouteExpr(primaryExists bool, primaryColumn string, fallbackExists bool, fallbackColumn string) string {
parts := make([]string, 0, 2)
if primaryExists {
parts = append(parts, fmt.Sprintf("NULLIF(%s, '')", primaryColumn))
}
if fallbackExists {
parts = append(parts, fallbackColumn)
}
if len(parts) == 0 {
return ""
}
if len(parts) == 1 {
return parts[0]
}
return fmt.Sprintf("COALESCE(%s)", joinCSV(parts))
}
func joinCSV(parts []string) string {
result := ""
for i, part := range parts {
if i > 0 {
result += ", "
}
result += part
}
return result
}
func dropColumn(db *gorm.DB, tableName, columnName string) error {
return db.Exec(fmt.Sprintf(`ALTER TABLE "%s" DROP COLUMN "%s"`, tableName, columnName)).Error
}
@@ -0,0 +1,123 @@
package store
import (
"path/filepath"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"eai_agentplatform/backend/internal/model"
)
func openMigrationTestDB(t *testing.T) *gorm.DB {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "migration.db")
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
return db
}
func TestMigrateObjectEntryRouteColumnsDropsLegacyColumns(t *testing.T) {
db := openMigrationTestDB(t)
if err := db.AutoMigrate(&model.Specialist{}, &model.SkillDefinition{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
if err := db.Exec(`ALTER TABLE specialist ADD COLUMN entry_route TEXT DEFAULT ''`).Error; err != nil {
t.Fatalf("add specialist.entry_route: %v", err)
}
if err := db.Exec(`ALTER TABLE specialist ADD COLUMN route TEXT DEFAULT ''`).Error; err != nil {
t.Fatalf("add specialist.route: %v", err)
}
if err := db.Exec(`ALTER TABLE skill_definition ADD COLUMN entry_route TEXT DEFAULT ''`).Error; err != nil {
t.Fatalf("add skill_definition.entry_route: %v", err)
}
if err := db.Exec(`ALTER TABLE skill_definition ADD COLUMN route TEXT DEFAULT ''`).Error; err != nil {
t.Fatalf("add skill_definition.route: %v", err)
}
if err := db.Exec(`ALTER TABLE skill_definition ADD COLUMN legacy_entry_route TEXT DEFAULT ''`).Error; err != nil {
t.Fatalf("add skill_definition.legacy_entry_route: %v", err)
}
if err := db.Exec(`ALTER TABLE skill_definition ADD COLUMN legacy_route TEXT DEFAULT ''`).Error; err != nil {
t.Fatalf("add skill_definition.legacy_route: %v", err)
}
specialist := model.Specialist{
Key: "spec-demo",
Label: "专员演示",
Tier: "generic",
WorkerType: "dw",
ObjectEntryRoute: "",
State: "active",
MarketTag: "installed",
}
if err := db.Create(&specialist).Error; err != nil {
t.Fatalf("create specialist: %v", err)
}
if err := db.Exec(`UPDATE specialist SET entry_route = ?, route = ? WHERE id = ?`, "/legacy-specialist", "/legacy-specialist-route", specialist.ID).Error; err != nil {
t.Fatalf("seed specialist legacy routes: %v", err)
}
skill := model.SkillDefinition{
Key: "skill-demo",
Label: "技能演示",
RoleKind: "skill",
Source: "eai",
ObjectEntryRoute: "",
LegacyObjectEntryRoute: "",
State: "active",
}
if err := db.Create(&skill).Error; err != nil {
t.Fatalf("create skill: %v", err)
}
if err := db.Exec(`UPDATE skill_definition SET entry_route = ?, route = ?, legacy_entry_route = ?, legacy_route = ? WHERE id = ?`,
"/legacy-skill", "/legacy-skill-route", "/legacy-old-skill", "/legacy-old-skill-route", skill.ID,
).Error; err != nil {
t.Fatalf("seed skill legacy routes: %v", err)
}
if err := migrateObjectEntryRouteColumns(db); err != nil {
t.Fatalf("migrate object entry route columns: %v", err)
}
var migratedSpecialist model.Specialist
if err := db.First(&migratedSpecialist, specialist.ID).Error; err != nil {
t.Fatalf("reload specialist: %v", err)
}
if migratedSpecialist.ObjectEntryRoute != "/legacy-specialist" {
t.Fatalf("specialist object_entry_route = %q, want %q", migratedSpecialist.ObjectEntryRoute, "/legacy-specialist")
}
var migratedSkill model.SkillDefinition
if err := db.First(&migratedSkill, skill.ID).Error; err != nil {
t.Fatalf("reload skill: %v", err)
}
if migratedSkill.ObjectEntryRoute != "/legacy-skill" {
t.Fatalf("skill object_entry_route = %q, want %q", migratedSkill.ObjectEntryRoute, "/legacy-skill")
}
if migratedSkill.LegacyObjectEntryRoute != "/legacy-old-skill" {
t.Fatalf("skill legacy_object_entry_route = %q, want %q", migratedSkill.LegacyObjectEntryRoute, "/legacy-old-skill")
}
if db.Migrator().HasColumn(&model.Specialist{}, "entry_route") {
t.Fatalf("specialist.entry_route should be dropped")
}
if db.Migrator().HasColumn(&model.Specialist{}, "route") {
t.Fatalf("specialist.route should be dropped")
}
if db.Migrator().HasColumn(&model.SkillDefinition{}, "entry_route") {
t.Fatalf("skill_definition.entry_route should be dropped")
}
if db.Migrator().HasColumn(&model.SkillDefinition{}, "route") {
t.Fatalf("skill_definition.route should be dropped")
}
if db.Migrator().HasColumn(&model.SkillDefinition{}, "legacy_entry_route") {
t.Fatalf("skill_definition.legacy_entry_route should be dropped")
}
if db.Migrator().HasColumn(&model.SkillDefinition{}, "legacy_route") {
t.Fatalf("skill_definition.legacy_route should be dropped")
}
}
@@ -12,6 +12,7 @@ func EnsureSpecialistStructuredRecords(item *model.Specialist) {
if item == nil {
return
}
EnsureSpecialistRoleCard(item)
if strings.TrimSpace(item.InputsRecordsJSON) == "" {
item.InputsRecordsJSON = mustJSON(buildInputsRecords(item))
}
@@ -26,6 +27,25 @@ func EnsureSpecialistStructuredRecords(item *model.Specialist) {
}
}
func EnsureSpecialistRoleCard(item *model.Specialist) {
if item == nil || strings.TrimSpace(item.RoleCardJSON) != "" {
return
}
item.RoleCardJSON = mustJSON(map[string]any{
"name": item.Label,
"tagline": firstNonEmpty(item.Summary, item.WorkStatus, item.Label),
"greeting": "我是" + item.Label + ",已经准备好接手这条任务并继续推进。",
"relationship_to_user": "你的协作搭档",
"tone": "专业、直接、可执行",
"opening_prompt": "告诉我当前事项、目标和约束,我先帮你起步。",
"starter_prompts": defaultStarterPrompts(item),
"boundaries": []string{
"高风险动作会明确标记待确认",
"需要人工审批的步骤不会默认自动执行",
},
})
}
func ValidateStructuredRecordsJSON(value string) bool {
value = strings.TrimSpace(value)
if value == "" {
@@ -35,6 +55,24 @@ func ValidateStructuredRecordsJSON(value string) bool {
return json.Unmarshal([]byte(value), &arr) == nil
}
func ValidateJSONObjectJSON(value string) bool {
value = strings.TrimSpace(value)
if value == "" {
return true
}
var obj map[string]any
return json.Unmarshal([]byte(value), &obj) == nil
}
func ValidateJSONStringArray(value string) bool {
value = strings.TrimSpace(value)
if value == "" {
return true
}
var arr []string
return json.Unmarshal([]byte(value), &arr) == nil
}
func buildInputsRecords(item *model.Specialist) []map[string]any {
records := make([]map[string]any, 0)
for _, value := range splitText(item.ConnectorScope) {
@@ -177,6 +215,23 @@ func buildResultRecords(item *model.Specialist) []map[string]any {
return results
}
func defaultStarterPrompts(item *model.Specialist) []string {
prompts := make([]string, 0, 3)
for _, skill := range splitText(item.BaseSkills) {
prompts = append(prompts, "帮我处理:"+skill)
if len(prompts) == 3 {
return prompts
}
}
if item.Summary != "" {
prompts = append(prompts, "继续推进这条事项")
}
if len(prompts) == 0 {
prompts = []string{"先帮我梳理当前事项", "告诉我下一步怎么做", "先看有哪些待确认点"}
}
return prompts
}
func mustJSON(v any) string {
data, err := json.Marshal(v)
if err != nil {
@@ -35,7 +35,7 @@
2. **三个固定 section**(按 `## ` 二级标题分块):
- `## 结构化产品数据` → 摄入 `product` 表
- `## AI 检索知识` → 摄入 `knowledge_chunk` 表(自然语言段落,供 MySQL FULLTEXT + LLM)
- `## AI 检索知识` → 摄入 `knowledge_chunk` 表(自然语言段落,供 Go 原生向量/关键词检索 + LLM)
- `## 考试题目` → 摄入 `question` 表
## 3. 题目答案契约(统一 list 形式)
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
import json
import os
import subprocess
import sys
os.environ.setdefault("ORT_DISABLE_GPU", "1")
try:
from paddleocr import PaddleOCR
except Exception:
PaddleOCR = None
try:
from rapidocr_onnxruntime import RapidOCR
except Exception:
RapidOCR = None
def flatten_result(raw_result):
lines = []
text_parts = []
for page in raw_result or []:
if not page:
continue
for item in page:
if not item or len(item) < 2:
continue
points = item[0]
text_info = item[1] or []
text = str(text_info[0] or "").strip() if len(text_info) > 0 else ""
score = float(text_info[1] or 0) if len(text_info) > 1 else 0
if not text:
continue
lines.append({
"text": text,
"score": score,
"points": points,
})
text_parts.append(text)
return {
"text": "\n".join(text_parts).strip(),
"lines": lines,
}
def run_paddle_ocr(image_path):
if PaddleOCR is None:
return None
ocr = PaddleOCR(
use_angle_cls=True,
lang="ch",
show_log=False,
use_gpu=False,
enable_mkldnn=False,
cpu_threads=1,
)
result = ocr.ocr(image_path, cls=True)
payload = flatten_result(result)
payload["engine"] = "paddleocr"
return payload
def run_rapid_ocr(image_path):
if RapidOCR is None:
return None
engine = RapidOCR()
result, _ = engine(image_path)
payload = {"text": "", "lines": []}
for item in result or []:
if not item or len(item) < 3:
continue
points, text, score = item[0], str(item[1] or "").strip(), float(item[2] or 0)
if not text:
continue
payload["lines"].append({
"text": text,
"score": score,
"points": points,
})
payload["text"] += f"{text}\n"
payload["text"] = payload["text"].strip()
payload["engine"] = "rapidocr"
return payload
def try_paddle_ocr_subprocess(image_path):
cmd = [sys.executable, __file__, "--engine", "paddle-only", image_path]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
check=False,
env=os.environ.copy(),
)
except Exception:
return None
if result.returncode != 0:
return None
try:
payload = json.loads((result.stdout or "").strip())
except Exception:
return None
if not isinstance(payload, dict) or payload.get("error"):
return None
payload["engine"] = "paddleocr"
return payload
def main():
args = sys.argv[1:]
engine_mode = "auto"
if len(args) >= 2 and args[0] == "--engine":
engine_mode = args[1].strip().lower() or "auto"
args = args[2:]
if len(args) < 1:
print(json.dumps({"error": "missing_image_path"}, ensure_ascii=False))
sys.exit(1)
image_path = args[0]
if engine_mode == "paddle-only":
payload = run_paddle_ocr(image_path)
elif engine_mode == "rapid-only":
payload = run_rapid_ocr(image_path)
else:
payload = try_paddle_ocr_subprocess(image_path) or run_rapid_ocr(image_path)
if payload is None:
print(json.dumps({"error": "ocr_engine_not_installed"}, ensure_ascii=False))
sys.exit(1)
print(json.dumps(payload, ensure_ascii=False))
if __name__ == "__main__":
main()