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:
@@ -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] + "..."
|
||||
}
|
||||
Reference in New Issue
Block a user