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:
@@ -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()
|
||||
Reference in New Issue
Block a user