feat: 新增语音转文字(ASR)功能

- 后端:新增 /api/audio/transcribe 接口,调用 Ollama whisper 进行语音识别
- 前端:新增 AudioTranscribePage.vue 页面,支持 MP3/WAV/M4A/OGG/FLAC 等格式
- 注册路由、工具卡片、智能助手欢迎语更新

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
eaiadmin
2026-09-14 00:53:36 +08:00
co-authored by Claude Code
parent 752c7837ef
commit 0455f064ac
299 changed files with 1588 additions and 432 deletions
@@ -0,0 +1,113 @@
package config
import (
"os"
"path/filepath"
"strconv"
)
// Config 全局配置,从环境变量读取(带默认值)。
// system_config 表中的值在运行时覆盖对应字段(见 service)。
type Config struct {
Port string
DBPath string // SQLite 文件路径
JWTSecret string
JWTExpireMin int
LLMBaseURL string
LLMAPIKey string
LLMModel string
EmbedModel string
KnowledgeServiceURL string
KnowledgeIndexDir string
KBDataDir string
KnowledgeSourceDir string
FileMaxDoc int64
FileMaxVideo int64
ChunkThreshold int64
LibreOfficeBin string
PdftotextBin string
KingdeeBaseURL string
KingdeeAccountID string
KingdeeUsername string
KingdeePassword string
KingdeeLCID int
KingdeeTimeoutSec int
}
// Load 读取环境变量构造配置。缺失的用默认值(部署时通过 .env / systemd 注入)。
func Load() *Config {
baseDir := backendBaseDir()
return &Config{
Port: getenv("PORT", "8080"),
DBPath: getenv("DB_PATH", filepath.Join(baseDir, "data", "eai_agentplatform.db")),
JWTSecret: getenv("JWT_SECRET", "change-this-to-a-strong-secret-in-production"),
JWTExpireMin: getenvInt("JWT_EXPIRE_MINUTES", 480),
LLMBaseURL: getenv("LLM_BASE_URL", "http://127.0.0.1:11434/v1"),
LLMAPIKey: getenv("LLM_API_KEY", "ollama"),
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")),
FileMaxDoc: getenvInt64("FILE_MAX_SIZE_DOC", 209715200),
FileMaxVideo: getenvInt64("FILE_MAX_SIZE_VIDEO", 2147483648),
ChunkThreshold: getenvInt64("CHUNK_THRESHOLD", 104857600),
LibreOfficeBin: getenv("LIBREOFFICE_BIN", "libreoffice"),
PdftotextBin: getenv("PDFTOTEXT_BIN", "pdftotext"),
KingdeeBaseURL: getenv("KINGDEE_BASE_URL", ""),
KingdeeAccountID: getenv("KINGDEE_ACCOUNT_ID", ""),
KingdeeUsername: getenv("KINGDEE_USERNAME", ""),
KingdeePassword: getenv("KINGDEE_PASSWORD", ""),
KingdeeLCID: getenvInt("KINGDEE_LCID", 2052),
KingdeeTimeoutSec: getenvInt("KINGDEE_TIMEOUT_SEC", 15),
}
}
func backendBaseDir() string {
if exe, err := os.Executable(); err == nil && exe != "" {
return filepath.Clean(filepath.Join(filepath.Dir(exe), ".."))
}
if wd, err := os.Getwd(); err == nil && wd != "" {
return wd
}
return "."
}
func getenv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func getenvInt(key string, def int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return def
}
func getenvInt64(key string, def int64) int64 {
if v := os.Getenv(key); v != "" {
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
return n
}
}
return def
}
@@ -0,0 +1,552 @@
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
)
// ──────────────────────────────────────────────
// 核心类型
// ──────────────────────────────────────────────
// 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"`
}
// 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
}
// 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"`
// 兼容旧版平铺 routes(若有则回退)
Routes map[string]RouteInfo `json:"routes,omitempty"`
}
// AISecrets AI 密钥文件结构(从 pj034 直接复制的格式)
type AISecrets struct {
VECTORENGINE_API_KEY string `json:"VECTORENGINE_API_KEY,omitempty"`
OPENROUTER_API_KEY string `json:"OPENROUTER_API_KEY,omitempty"`
VOLCES_API_KEY string `json:"VOLCES_API_KEY,omitempty"`
LMUAI_API_KEY string `json:"LMUAI_API_KEY,omitempty"`
ALIYUN_API_KEY string `json:"ALIYUN_API_KEY,omitempty"`
DEEPSEEK_API_KEY string `json:"DEEPSEEK_API_KEY,omitempty"`
REMOVE_BG_API_KEY string `json:"REMOVE_BG_API_KEY,omitempty"`
ANTHROPIC_API_KEY string `json:"ANTHROPIC_API_KEY,omitempty"`
OPENAI_API_KEY string `json:"OPENAI_API_KEY,omitempty"`
BRAVE_SEARCH_API_KEY string `json:"BRAVE_SEARCH_API_KEY,omitempty"`
}
// PlatformConfig 平台静态配置
type PlatformConfig struct {
Name string `json:"name"`
ShortName string `json:"short_name"`
Version string `json:"version"`
BuildTime string `json:"build_time"`
Company string `json:"company"`
Description string `json:"description"`
}
// ──────────────────────────────────────────────
// PROVIDER_SECRET_KEY 映射(参考 pj034 ai_config.py)
// provider 名称 → secrets JSON 中的字段名
// ──────────────────────────────────────────────
var ProviderSecretKey = map[string]string{
"vectorengine": "VECTORENGINE_API_KEY",
"openrouter": "OPENROUTER_API_KEY",
"volces": "VOLCES_API_KEY",
"lmuai": "LMUAI_API_KEY",
"aliyun": "ALIYUN_API_KEY",
"pic_copilot": "ALIYUN_API_KEY",
"deepseek": "DEEPSEEK_API_KEY",
"remove_bg": "REMOVE_BG_API_KEY",
"anthropic": "ANTHROPIC_API_KEY",
"openai": "OPENAI_API_KEY",
}
// 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",
"vectorengine": "https://api.vectorengine.ai/v1",
"volces": "https://ark.cn-beijing.volces.com/api/v3",
"lmuai": "https://api.lmuai.com/v1",
}
// ──────────────────────────────────────────────
// 文件路径解析
// ──────────────────────────────────────────────
var (
mu sync.RWMutex
cachedAICfg *AIConfig
cachedSecrets *AISecrets
cachedPlatform *PlatformConfig
)
// configDir 寻找 config/ 目录
func configDir() string {
// 优先使用可执行文件所在后端目录下的 config/
exe, _ := os.Executable()
if exe != "" {
exeDir := filepath.Dir(exe)
candidates := []string{
filepath.Join(exeDir, "config"),
filepath.Join(exeDir, "..", "config"),
}
for _, d := range candidates {
if fi, err := os.Stat(d); err == nil && fi.IsDir() {
return filepath.Clean(d)
}
}
}
// 回退 CWD 下的 config/
if fi, err := os.Stat("config"); err == nil && fi.IsDir() {
return filepath.Clean("config")
}
// 再回退 deploy/
if fi, err := os.Stat("deploy"); err == nil && fi.IsDir() {
return filepath.Clean("deploy")
}
return ""
}
func resolveConfigPath(name string) string {
if d := configDir(); d != "" {
return filepath.Join(d, name)
}
return name
}
func loadJSON(path string, dst any) error {
p := resolveConfigPath(path)
data, err := os.ReadFile(p)
if err != nil {
return fmt.Errorf("读取 %s 失败: %w", p, err)
}
if err := json.Unmarshal(data, dst); err != nil {
return fmt.Errorf("解析 %s 失败: %w", p, err)
}
return nil
}
// ──────────────────────────────────────────────
// 公开加载函数
// ──────────────────────────────────────────────
func LoadAIConfig(forceReload ...bool) (*AIConfig, error) {
fr := len(forceReload) > 0 && forceReload[0]
if !fr {
mu.RLock()
if cachedAICfg != nil {
ptr := cachedAICfg
mu.RUnlock()
return ptr, nil
}
mu.RUnlock()
}
mu.Lock()
defer mu.Unlock()
if !fr && cachedAICfg != nil {
return cachedAICfg, nil
}
var cfg AIConfig
if err := loadJSON("ai_config.json", &cfg); err != nil {
return nil, err
}
cachedAICfg = &cfg
return &cfg, nil
}
func LoadAISecrets(forceReload ...bool) (*AISecrets, error) {
fr := len(forceReload) > 0 && forceReload[0]
if !fr {
mu.RLock()
if cachedSecrets != nil {
ptr := cachedSecrets
mu.RUnlock()
return ptr, nil
}
mu.RUnlock()
}
mu.Lock()
defer mu.Unlock()
if !fr && cachedSecrets != nil {
return cachedSecrets, nil
}
var s AISecrets
if err := loadJSON("ai_secrets.json", &s); err != nil {
// 文件不存在时不报错,返回空结构
return &AISecrets{}, nil
}
cachedSecrets = &s
return &s, nil
}
func LoadPlatformConfig(forceReload ...bool) (*PlatformConfig, error) {
fr := len(forceReload) > 0 && forceReload[0]
if !fr {
mu.RLock()
if cachedPlatform != nil {
ptr := cachedPlatform
mu.RUnlock()
return ptr, nil
}
mu.RUnlock()
}
mu.Lock()
defer mu.Unlock()
if !fr && cachedPlatform != nil {
return cachedPlatform, nil
}
var p PlatformConfig
if err := loadJSON("platform.json", &p); err != nil {
return nil, err
}
cachedPlatform = &p
return &p, nil
}
// ──────────────────────────────────────────────
// GetRoute — 核心路由解析(参考 pj034 get_route())
// 输入 agent 名或 route_id,返回完整 RouteConfig(已注入 base_url / api_key)
// 查找路径:agent_routes → 分类 routes(chats/embeds/images) → 兼容旧版 routes → default
// ──────────────────────────────────────────────
func GetRoute(agentOrRouteID string) (*RouteConfig, error) {
aiCfg, err := LoadAIConfig()
if err != nil {
return nil, fmt.Errorf("AI 配置加载失败: %w", err)
}
// 1) 先当 agent 名查
routeID := agentOrRouteID
if r, ok := aiCfg.AgentRoutes[agentOrRouteID]; ok {
routeID = r
}
// 2) 在分类路由中查找
info, category, found := findRoute(aiCfg, routeID)
if !found {
// 3) 回退默认
if aiCfg.DefaultRoute != "" {
routeID = aiCfg.DefaultRoute
info, category, found = findRoute(aiCfg, routeID)
}
if !found {
return nil, fmt.Errorf("路由 %q 未在 ai_config.json 中定义,且无有效默认路由", agentOrRouteID)
}
}
// 4) 从 secrets 注入 base_url / api_key
baseURL := ""
apiKey := ""
secrets, _ := LoadAISecrets()
// 先查 provider 对应的 secrets key
if secretField, ok := ProviderSecretKey[info.Provider]; ok && secrets != nil {
// 通过反射取 struct 字段值
apiKey = getSecretByField(secrets, secretField)
}
// base_url: 先从 ProviderDefaultBaseURL 取
if url, ok := ProviderDefaultBaseURL[info.Provider]; ok {
baseURL = url
}
// 组装
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,
}
if rc.MaxTokens <= 0 {
rc.MaxTokens = 2048
}
if rc.Temperature <= 0 {
rc.Temperature = 0.7
}
return rc, nil
}
// findRoute 在 chat_routes / embed_routes / image_routes / 兼容 routes 中查找
func findRoute(cfg *AIConfig, routeID string) (RouteInfo, string, bool) {
if cfg.ChatRoutes != nil {
if info, ok := cfg.ChatRoutes[routeID]; ok {
return info, "chat", true
}
}
if cfg.EmbedRoutes != nil {
if info, ok := cfg.EmbedRoutes[routeID]; ok {
return info, "embed", true
}
}
if cfg.ImageRoutes != nil {
if info, ok := cfg.ImageRoutes[routeID]; ok {
return info, "image", true
}
}
// 兼容旧版平铺 routes
if cfg.Routes != nil {
if info, ok := cfg.Routes[routeID]; ok {
return info, info.Category, true
}
}
return RouteInfo{}, "", false
}
// getSecretByField 从 AISecrets 按字段名取值
func getSecretByField(s *AISecrets, field string) string {
switch field {
case "VECTORENGINE_API_KEY":
return s.VECTORENGINE_API_KEY
case "OPENROUTER_API_KEY":
return s.OPENROUTER_API_KEY
case "VOLCES_API_KEY":
return s.VOLCES_API_KEY
case "LMUAI_API_KEY":
return s.LMUAI_API_KEY
case "ALIYUN_API_KEY":
return s.ALIYUN_API_KEY
case "DEEPSEEK_API_KEY":
return s.DEEPSEEK_API_KEY
case "REMOVE_BG_API_KEY":
return s.REMOVE_BG_API_KEY
case "ANTHROPIC_API_KEY":
return s.ANTHROPIC_API_KEY
case "OPENAI_API_KEY":
return s.OPENAI_API_KEY
case "BRAVE_SEARCH_API_KEY":
return s.BRAVE_SEARCH_API_KEY
default:
return ""
}
}
// ──────────────────────────────────────────────
// 辅助函数
// ──────────────────────────────────────────────
// GetFallbackRoutes 获取回退路由链
func GetFallbackRoutes(primaryRouteID string) ([]*RouteConfig, error) {
aiCfg, err := LoadAIConfig()
if err != nil {
return nil, err
}
fallbackIDs := aiCfg.FallbackRoutes[primaryRouteID]
if len(fallbackIDs) == 0 {
return nil, nil
}
var result []*RouteConfig
for _, fid := range fallbackIDs {
r, err := GetRoute(fid)
if err != nil {
continue
}
result = append(result, r)
}
return result, nil
}
// GetAllRoutes 返回所有定义的路由(跨分类)
func GetAllRoutes() ([]*RouteConfig, error) {
aiCfg, err := LoadAIConfig()
if err != nil {
return nil, err
}
var result []*RouteConfig
seen := make(map[string]bool)
addRoutes := func(routes map[string]RouteInfo, category string) {
for rid := range routes {
if seen[rid] {
continue
}
seen[rid] = true
r, err := GetRoute(rid)
if err != nil {
continue
}
r.Category = category
result = append(result, r)
}
}
addRoutes(aiCfg.ChatRoutes, "chat")
addRoutes(aiCfg.EmbedRoutes, "embed")
addRoutes(aiCfg.ImageRoutes, "image")
// 兼容旧版
if aiCfg.Routes != nil {
for rid, info := range aiCfg.Routes {
if seen[rid] {
continue
}
seen[rid] = true
cat := info.Category
if cat == "" {
cat = "general"
}
r, err := GetRoute(rid)
if err != nil {
continue
}
r.Category = cat
result = append(result, r)
}
}
return result, nil
}
// GetRoutesByCategory 按分类返回路由
func GetRoutesByCategory(category string) ([]*RouteConfig, error) {
aiCfg, err := LoadAIConfig()
if err != nil {
return nil, err
}
var routeMap map[string]RouteInfo
switch category {
case "chat":
routeMap = aiCfg.ChatRoutes
case "embed":
routeMap = aiCfg.EmbedRoutes
case "image":
routeMap = aiCfg.ImageRoutes
default:
return nil, fmt.Errorf("未知路由分类: %s", category)
}
var result []*RouteConfig
for rid := range routeMap {
r, err := GetRoute(rid)
if err != nil {
continue
}
result = append(result, r)
}
return result, nil
}
// GetProviderAPIKey 直接获取指定 provider 的 API key
func GetProviderAPIKey(provider string) string {
secrets, _ := LoadAISecrets()
if secrets == nil {
return ""
}
if field, ok := ProviderSecretKey[provider]; ok {
return getSecretByField(secrets, field)
}
return ""
}
// GetProviderBaseURL 获取指定 provider 的默认 base_url
func GetProviderBaseURL(provider string) string {
if url, ok := ProviderDefaultBaseURL[provider]; ok {
return url
}
return ""
}
// ──────────────────────────────────────────────
// AI 配置管理(管理员:读/写/热重载/密钥状态)
// ──────────────────────────────────────────────
// GetAIConfigRaw 读取 ai_config.json 原始内容(供前端 JSON 编辑器)
func GetAIConfigRaw() ([]byte, error) {
return os.ReadFile(resolveConfigPath("ai_config.json"))
}
// SaveAIConfig 校验并原子写回 ai_config.json,随后清缓存热生效
func SaveAIConfig(raw []byte) error {
// 先校验合法性(能反序列化为 AIConfig 且有 version)
var cfg AIConfig
if err := json.Unmarshal(raw, &cfg); err != nil {
return fmt.Errorf("ai_config.json 不是合法 JSON: %w", err)
}
if cfg.Version == "" {
return fmt.Errorf("ai_config.json 缺少 version 字段")
}
path := resolveConfigPath("ai_config.json")
tmp := path + ".tmp"
if err := os.WriteFile(tmp, raw, 0o644); err != nil {
return fmt.Errorf("写入临时文件失败: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
return fmt.Errorf("替换配置文件失败: %w", err)
}
mu.Lock()
cachedAICfg = nil
mu.Unlock()
return nil
}
// ResetCache 清空全部配置缓存(热重载,无需重启)
func ResetCache() {
mu.Lock()
cachedAICfg = nil
cachedSecrets = nil
cachedPlatform = nil
mu.Unlock()
}
// SecretsStatus 返回各 provider 密钥是否已配置(不回显明文)
func SecretsStatus() map[string]bool {
secrets, _ := LoadAISecrets()
status := make(map[string]bool)
if secrets == nil {
return status
}
for provider, field := range ProviderSecretKey {
status[provider] = strings.TrimSpace(getSecretByField(secrets, field)) != ""
}
return status
}