chore: 工作台产品化进行中的改动

把工作区里其余在制品一并入库,主要是工作台产品化的推进:

  后端:新增 capability_definition / project / my_app_center / office_skill
        接口与 action_definition / skill_definition / project / user_app_center
        模型,config 加路由健康上报。
  前端:新增 frontend/src/skills(Office 技能与 workbuddy 复刻)、
        项目管理、应用中心、能力目录页,以及配套 api / store / config;
        聊天侧新增 SpecialistChip / SpecialistPanel / SkillStrip / AppChatRail
        等组件。
  清理:移除旧 views/tools 下的单页工具(已并入工作台)、_frozen 冻结组件、
        cmd/inspect_oa_debug 调试入口,以及两份调试笔记。
  其它:文档与启动脚本同步。

(这批改动与上一提交的 SY23 工作并行进行,此前已在同一工作区内交织。)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
eaiadmin
2026-09-17 21:32:35 +08:00
co-authored by Claude Code
parent 8b136d4a10
commit 16d63de4e1
180 changed files with 22283 additions and 13850 deletions
@@ -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] + "..."
}