diff --git a/docs/content-model-providers.md b/docs/content-model-providers.md new file mode 100644 index 0000000..2cbdc08 --- /dev/null +++ b/docs/content-model-providers.md @@ -0,0 +1,31 @@ +# 聊天总结模型厂商接入 + +聊天总结任务不绑定某个模型。管理员在“大模型管理”中选择厂商、填写对应 API Key 与模型 ID、完成连接测试并激活后,Worker 会在下一条任务执行时读取当前激活配置。 + +## 已适配厂商 + +| 页面分类 | `provider` | 默认 Base URL | 需要填写 | +|---|---|---|---| +| 火山方舟 / 豆包 | `ark` | `https://ark.cn-beijing.volces.com/api/v3` | API Key、模型 ID 或 Endpoint ID | +| 阿里云百炼 / 千问 | `dashscope` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | 同地域 API Key、模型 ID | +| DeepSeek | `deepseek` | `https://api.deepseek.com` | API Key、模型 ID | +| 月之暗面 / Kimi | `moonshot` | `https://api.moonshot.cn/v1` | API Key、模型 ID | +| 智谱 AI / GLM | `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | API Key、模型 ID | +| 百度千帆 / 文心 | `qianfan` | `https://qianfan.baidubce.com/v2` | Bearer Token、模型 ID | +| 腾讯混元 | `hunyuan` | `https://api.hunyuan.cloud.tencent.com/v1` | API Key、模型 ID | +| MiniMax | `minimax` | `https://api.minimaxi.com/v1` | API Key、模型 ID | +| 百川智能 | `baichuan` | `https://api.baichuan-ai.com/v1` | API Key、模型 ID | +| 硅基流动 | `siliconflow` | `https://api.siliconflow.cn/v1` | API Key、模型 ID | +| 本地 Ollama | `ollama` | `http://ollama:11434/v1` | 本地模型 ID;密钥使用占位值 | +| 自定义兼容服务 | `openai_compatible` | 管理员填写 | HTTPS Base URL、API Key、模型 ID | + +所有云服务使用 Bearer Token 鉴权和 `/chat/completions` 请求。适配器只对官方已明确支持或项目已验证的厂商发送 `response_format`;其他厂商通过严格提示、JSON 提取和响应校验取得结构化结果,避免某些兼容接口因不认识 JSON Mode 参数而拒绝请求。带 `` 思考包装的返回也会在解析前清理。 + +## 使用流程 + +1. 选择厂商,页面会预填官方 Base URL 和常用模型名。 +2. 按厂商控制台填写 API Key 与准确模型 ID。使用地域或业务空间专属域名时,可修改 Base URL。 +3. 保存后执行“测试连接”。测试内容是固定的无长者数据文本。 +4. 测试通过后设为当前模型。下一条聊天总结任务立即使用,不需要重启 Worker。 + +API Key 通过 AES-256-GCM 加密保存,读取接口仅返回末四位提示。连接测试、激活与移除均写入审计日志。 diff --git a/internal/contentmodel/service.go b/internal/contentmodel/service.go index f3a5222..bc9afb7 100644 --- a/internal/contentmodel/service.go +++ b/internal/contentmodel/service.go @@ -362,7 +362,7 @@ func normalizeInput(input *CreateInput) { func validateInput(input CreateInput) error { parsed, err := url.Parse(input.BaseURL) - validProvider := input.Provider == "ark" || input.Provider == "openai_compatible" + validProvider := extraction.SupportsCompatibleProvider(input.Provider) validURL := err == nil && parsed.Host != "" && (parsed.Scheme == "https" || (parsed.Scheme == "http" && isApprovedLocalModelHost(parsed.Hostname()))) if !validUUID(input.TenantID) || len([]rune(input.DisplayName)) < 1 || len([]rune(input.DisplayName)) > 120 || !validProvider || !validURL || input.ModelID == "" || len(input.APIKey) < 8 || input.Temperature < 0 || input.Temperature > 2 || input.MaxTokens < 256 || input.MaxTokens > 8192 { return ErrInvalid diff --git a/internal/contentmodel/service_test.go b/internal/contentmodel/service_test.go index 503c295..ba5e048 100644 --- a/internal/contentmodel/service_test.go +++ b/internal/contentmodel/service_test.go @@ -2,6 +2,33 @@ package contentmodel import "testing" +func TestValidateInputAcceptsSupportedDomesticProviders(t *testing.T) { + providers := []string{"ark", "dashscope", "deepseek", "moonshot", "zhipu", "qianfan", "hunyuan", "minimax", "baichuan", "siliconflow"} + for _, provider := range providers { + t.Run(provider, func(t *testing.T) { + input := CreateInput{ + TenantID: "10000000-0000-0000-0000-000000000001", DisplayName: provider, + Provider: provider, BaseURL: "https://model.example.com/v1", ModelID: "summary-model", + APIKey: "test-api-key", Temperature: 0.2, MaxTokens: 1200, + } + if err := validateInput(input); err != nil { + t.Fatalf("supported provider was rejected: %v", err) + } + }) + } +} + +func TestValidateInputRejectsUnknownProvider(t *testing.T) { + input := CreateInput{ + TenantID: "10000000-0000-0000-0000-000000000001", DisplayName: "unknown", + Provider: "unknown", BaseURL: "https://model.example.com/v1", ModelID: "summary-model", + APIKey: "test-api-key", Temperature: 0.2, MaxTokens: 1200, + } + if err := validateInput(input); err == nil { + t.Fatal("unknown provider was accepted") + } +} + func TestValidateInputAllowsComposeOllamaEndpoint(t *testing.T) { input := CreateInput{ TenantID: "10000000-0000-0000-0000-000000000001", DisplayName: "本地 Qwen", diff --git a/internal/extraction/generator.go b/internal/extraction/generator.go index 5cdeffc..ec16259 100644 --- a/internal/extraction/generator.go +++ b/internal/extraction/generator.go @@ -132,10 +132,10 @@ func NewCompatibleGenerator(provider, baseURL, apiKey, model string, temperature model = strings.TrimSpace(model) parsed, err := url.Parse(baseURL) if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { - return nil, errors.New("Ark base URL must be an absolute HTTP(S) URL") + return nil, errors.New("content model base URL must be an absolute HTTP(S) URL") } - if provider != "ark" && provider != "openai_compatible" { - return nil, errors.New("content model provider must be ark or openai_compatible") + if !SupportsCompatibleProvider(provider) { + return nil, errors.New("unsupported content model provider") } if apiKey == "" || model == "" || timeout <= 0 || temperature < 0 || temperature > 2 || maxTokens < 256 || maxTokens > 8192 { return nil, errors.New("content model credentials and valid generation parameters are required") @@ -158,9 +158,14 @@ func (g *ArkGenerator) Generate(ctx context.Context, input GenerationInput) (Gen {"role": "system", "content": contentValuePrompt}, {"role": "user", "content": content}, }, - "temperature": g.temperature, - "max_tokens": g.maxTokens, - "response_format": map[string]string{"type": "json_object"}, + "temperature": g.temperature, + "max_tokens": g.maxTokens, + } + // JSON mode is sent only where the provider documents it or this project + // has exercised it. Other compatible providers still receive an explicit + // JSON-only prompt, followed by strict response extraction and validation. + if providerSupportsJSONMode(g.provider) { + requestBody["response_format"] = map[string]string{"type": "json_object"} } encoded, err := json.Marshal(requestBody) if err != nil { @@ -168,22 +173,22 @@ func (g *ArkGenerator) Generate(ctx context.Context, input GenerationInput) (Gen } req, err := http.NewRequestWithContext(ctx, http.MethodPost, g.endpoint, bytes.NewReader(encoded)) if err != nil { - return GenerationResult{}, fmt.Errorf("build Ark content request: %w", err) + return GenerationResult{}, fmt.Errorf("build content model request: %w", err) } req.Header.Set("Authorization", "Bearer "+g.apiKey) req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", "voice-platform-content-extractor/1.0") resp, err := g.client.Do(req) if err != nil { - return GenerationResult{}, outbox.Retryable(fmt.Errorf("call Ark content model: %w", err)) + return GenerationResult{}, outbox.Retryable(fmt.Errorf("call content model: %w", err)) } defer resp.Body.Close() responseBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if readErr != nil { - return GenerationResult{}, outbox.Retryable(fmt.Errorf("read Ark content response: %w", readErr)) + return GenerationResult{}, outbox.Retryable(fmt.Errorf("read content model response: %w", readErr)) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { - requestErr := fmt.Errorf("Ark content model returned HTTP %d", resp.StatusCode) + requestErr := fmt.Errorf("content model returned HTTP %d", resp.StatusCode) if resp.StatusCode == http.StatusRequestTimeout || resp.StatusCode == http.StatusTooEarly || resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 { return GenerationResult{}, outbox.Retryable(requestErr) } @@ -197,15 +202,15 @@ func (g *ArkGenerator) Generate(ctx context.Context, input GenerationInput) (Gen } `json:"choices"` } if err = json.Unmarshal(responseBody, &completion); err != nil || len(completion.Choices) == 0 { - return GenerationResult{}, outbox.Retryable(errors.New("Ark content response has no usable choice")) + return GenerationResult{}, outbox.Retryable(errors.New("content model response has no usable choice")) } - jsonContent := strings.TrimSpace(completion.Choices[0].Message.Content) + jsonContent := extractJSONObject(completion.Choices[0].Message.Content) jsonContent = strings.TrimPrefix(jsonContent, "```json") jsonContent = strings.TrimPrefix(jsonContent, "```") jsonContent = strings.TrimSuffix(jsonContent, "```") var result GenerationResult if err = json.Unmarshal([]byte(strings.TrimSpace(jsonContent)), &result); err != nil { - return GenerationResult{}, outbox.Retryable(fmt.Errorf("decode Ark structured content: %w", err)) + return GenerationResult{}, outbox.Retryable(fmt.Errorf("decode structured content: %w", err)) } result.Summary = strings.TrimSpace(result.Summary) result.DecisionReason = strings.TrimSpace(result.DecisionReason) @@ -216,7 +221,7 @@ func (g *ArkGenerator) Generate(ctx context.Context, input GenerationInput) (Gen result.Keywords = normalizedKeywords(result.Keywords) normalizeDecisionContent(&result) if !validGeneratedContent(result) { - return GenerationResult{}, outbox.Retryable(errors.New("Ark structured content failed validation")) + return GenerationResult{}, outbox.Retryable(errors.New("structured content failed validation")) } result.ModelRef = g.provider + ":" + g.model result.PromptVersion = "content-value-" + g.provider + "-json-v3" @@ -224,6 +229,42 @@ func (g *ArkGenerator) Generate(ctx context.Context, input GenerationInput) (Gen return result, nil } +// SupportsCompatibleProvider is the single backend allowlist used both when a +// model configuration is saved and when a worker resolves that configuration. +func SupportsCompatibleProvider(provider string) bool { + switch provider { + case "ark", "dashscope", "deepseek", "moonshot", "zhipu", "qianfan", "hunyuan", "minimax", "baichuan", "siliconflow", "ollama", "openai_compatible": + return true + default: + return false + } +} + +func providerSupportsJSONMode(provider string) bool { + switch provider { + case "ark", "dashscope", "deepseek", "siliconflow", "ollama", "openai_compatible": + return true + default: + return false + } +} + +func extractJSONObject(content string) string { + content = strings.TrimSpace(content) + if end := strings.LastIndex(content, ""); end >= 0 { + content = strings.TrimSpace(content[end+len(""):]) + } + content = strings.TrimPrefix(content, "```json") + content = strings.TrimPrefix(content, "```") + content = strings.TrimSuffix(content, "```") + content = strings.TrimSpace(content) + start, end := strings.Index(content, "{"), strings.LastIndex(content, "}") + if start >= 0 && end >= start { + return content[start : end+1] + } + return content +} + func normalizeDecisionContent(result *GenerationResult) { if !result.Meaningful { result.StoryWorthy = false diff --git a/internal/extraction/generator_test.go b/internal/extraction/generator_test.go index 913a5ef..3b75118 100644 --- a/internal/extraction/generator_test.go +++ b/internal/extraction/generator_test.go @@ -78,6 +78,46 @@ func TestCompatibleGeneratorClearsContradictoryStoryFields(t *testing.T) { } } +func TestCompatibleGeneratorHandlesThinkingWrapper(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": "先判断内容价值\n```json\n{\"meaningful\":false,\"story_worthy\":false,\"decision_reason\":\"只有问候\",\"summary\":\"\",\"keywords\":[],\"open_topic\":\"\",\"story_title\":\"\",\"story_content\":\"\",\"fact_content\":\"\",\"confidence\":0.95}\n```"}}}}) + })) + defer server.Close() + generator, err := NewCompatibleGenerator("minimax", server.URL+"/v1", "local-key", "MiniMax-test", 0.1, 512, time.Second) + if err != nil { + t.Fatal(err) + } + result, err := generator.Generate(context.Background(), GenerationInput{Content: "您好。"}) + if err != nil || result.Meaningful || result.ModelRef != "minimax:MiniMax-test" { + t.Fatalf("result=%+v err=%v", result, err) + } +} + +func TestCompatibleGeneratorOmitsUndocumentedJSONMode(t *testing.T) { + for _, provider := range []string{"moonshot", "zhipu", "qianfan", "hunyuan", "minimax", "baichuan"} { + t.Run(provider, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if _, exists := body["response_format"]; exists { + t.Fatalf("%s request unexpectedly enabled JSON mode", provider) + } + _ = json.NewEncoder(w).Encode(map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": `{"meaningful":false,"story_worthy":false,"decision_reason":"测试内容","summary":"","keywords":[],"open_topic":"","story_title":"","story_content":"","fact_content":"","confidence":0.9}`}}}}) + })) + defer server.Close() + generator, err := NewCompatibleGenerator(provider, server.URL, "test-key", "test-model", 0.2, 512, time.Second) + if err != nil { + t.Fatal(err) + } + if _, err := generator.Generate(context.Background(), GenerationInput{Content: "模型连接测试"}); err != nil { + t.Fatal(err) + } + }) + } +} + func TestArkGeneratorRetriesTemporaryFailure(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "busy", http.StatusTooManyRequests) diff --git a/migrations/000045_content_model_providers.down.sql b/migrations/000045_content_model_providers.down.sql new file mode 100644 index 0000000..54ce481 --- /dev/null +++ b/migrations/000045_content_model_providers.down.sql @@ -0,0 +1,14 @@ +BEGIN; + +UPDATE content_model_config +SET provider = 'openai_compatible' +WHERE provider NOT IN ('ark', 'openai_compatible'); + +ALTER TABLE content_model_config + DROP CONSTRAINT content_model_config_provider_check; + +ALTER TABLE content_model_config + ADD CONSTRAINT content_model_config_provider_check + CHECK (provider IN ('ark', 'openai_compatible')); + +COMMIT; diff --git a/migrations/000045_content_model_providers.up.sql b/migrations/000045_content_model_providers.up.sql new file mode 100644 index 0000000..6352595 --- /dev/null +++ b/migrations/000045_content_model_providers.up.sql @@ -0,0 +1,30 @@ +BEGIN; + +ALTER TABLE content_model_config + DROP CONSTRAINT content_model_config_provider_check; + +ALTER TABLE content_model_config + ADD CONSTRAINT content_model_config_provider_check CHECK (provider IN ( + 'ark', + 'dashscope', + 'deepseek', + 'moonshot', + 'zhipu', + 'qianfan', + 'hunyuan', + 'minimax', + 'baichuan', + 'siliconflow', + 'ollama', + 'openai_compatible' + )); + +-- Classify local Ollama records created before provider-specific options were +-- available. This changes only the label/transport capability profile; the +-- encrypted credential, activation state and model identity stay untouched. +UPDATE content_model_config +SET provider = 'ollama' +WHERE provider = 'openai_compatible' + AND base_url ~* '^http://(ollama|localhost|127\.0\.0\.1)(:[0-9]+)?(/|$)'; + +COMMIT; diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index de87e01..9823441 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -5668,7 +5668,7 @@ components: id: { type: string, format: uuid } tenant_id: { type: string, format: uuid } display_name: { type: string, minLength: 1, maxLength: 120 } - provider: { type: string, enum: [ark, openai_compatible] } + provider: { $ref: '#/components/schemas/ContentModelProvider' } base_url: { type: string, format: uri } model_id: { type: string, minLength: 1, maxLength: 200 } credential_hint: { type: string, description: 仅包含掩码和密钥末四位。 } @@ -5688,12 +5688,16 @@ components: required: [display_name, provider, base_url, model_id, api_key, temperature, max_tokens] properties: display_name: { type: string, minLength: 1, maxLength: 120 } - provider: { type: string, enum: [ark, openai_compatible] } + provider: { $ref: '#/components/schemas/ContentModelProvider' } base_url: { type: string, format: uri, maxLength: 500 } model_id: { type: string, minLength: 1, maxLength: 200 } api_key: { type: string, minLength: 8, maxLength: 1000, writeOnly: true } temperature: { type: number, minimum: 0, maximum: 2 } max_tokens: { type: integer, minimum: 256, maximum: 8192 } + ContentModelProvider: + type: string + description: 模型厂商分类;所有类型均通过 OpenAI Chat Completions 兼容协议调用。 + enum: [ark, dashscope, deepseek, moonshot, zhipu, qianfan, hunyuan, minimax, baichuan, siliconflow, ollama, openai_compatible] StoryStatus: type: string enum: [CANDIDATE, IN_REVIEW, CONFIRMED, REJECTED, SUPERSEDED] diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 726102b..16e1cc8 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -34,13 +34,14 @@ export type ConversationDetail = components["schemas"]["ConversationDetail"]; export type ConversationUsage = components["schemas"]["ConversationUsage"]; export type ConversationTurn = components["schemas"]["ConversationTurn"]; export type ConversationContentSummary = components["schemas"]["ConversationContentSummary"]; +export type ContentModelProvider = "ark" | "dashscope" | "deepseek" | "moonshot" | "zhipu" | "qianfan" | "hunyuan" | "minimax" | "baichuan" | "siliconflow" | "ollama" | "openai_compatible"; export type ContentModel = { - id: string; tenant_id: string; display_name: string; provider: "ark" | "openai_compatible"; + id: string; tenant_id: string; display_name: string; provider: ContentModelProvider; base_url: string; model_id: string; credential_hint: string; temperature: number; max_tokens: number; status: "inactive" | "active" | "revoked"; test_status: "untested" | "passed" | "failed"; last_tested_at?: string; last_error_code?: string; activated_at?: string; version: number; created_at: string; updated_at: string; }; -export type CreateContentModelRequest = { display_name: string; provider: "ark" | "openai_compatible"; base_url: string; model_id: string; api_key: string; temperature: number; max_tokens: number }; +export type CreateContentModelRequest = { display_name: string; provider: ContentModelProvider; base_url: string; model_id: string; api_key: string; temperature: number; max_tokens: number }; export type ConversationMedia = components["schemas"]["ConversationMedia"]; export type MediaSegment = components["schemas"]["MediaSegment"]; export type RecordingAccessGrant = components["schemas"]["RecordingAccessGrant"]; diff --git a/web/src/api/schema.d.ts b/web/src/api/schema.d.ts index 61a1cae..bc2d0bd 100644 --- a/web/src/api/schema.d.ts +++ b/web/src/api/schema.d.ts @@ -5347,8 +5347,7 @@ export interface components { /** Format: uuid */ tenant_id: string; display_name: string; - /** @enum {string} */ - provider: "ark" | "openai_compatible"; + provider: components["schemas"]["ContentModelProvider"]; /** Format: uri */ base_url: string; model_id: string; @@ -5374,8 +5373,7 @@ export interface components { }; CreateContentModelRequest: { display_name: string; - /** @enum {string} */ - provider: "ark" | "openai_compatible"; + provider: components["schemas"]["ContentModelProvider"]; /** Format: uri */ base_url: string; model_id: string; @@ -5383,6 +5381,11 @@ export interface components { temperature: number; max_tokens: number; }; + /** + * @description 模型厂商分类;所有类型均通过 OpenAI Chat Completions 兼容协议调用。 + * @enum {string} + */ + ContentModelProvider: "ark" | "dashscope" | "deepseek" | "moonshot" | "zhipu" | "qianfan" | "hunyuan" | "minimax" | "baichuan" | "siliconflow" | "ollama" | "openai_compatible"; /** @enum {string} */ StoryStatus: "CANDIDATE" | "IN_REVIEW" | "CONFIRMED" | "REJECTED" | "SUPERSEDED"; StorySource: { diff --git a/web/src/contentModelProviders.ts b/web/src/contentModelProviders.ts new file mode 100644 index 0000000..2ce0c72 --- /dev/null +++ b/web/src/contentModelProviders.ts @@ -0,0 +1,37 @@ +import type { ContentModelProvider } from "./api/client"; + +export type ContentModelProviderPreset = { + id: ContentModelProvider; + name: string; + shortName: string; + category: "domestic" | "local" | "custom"; + baseURL: string; + modelID: string; + modelPlaceholder: string; + keyLabel: string; + description: string; + docsURL?: string; +}; + +export const contentModelProviderPresets: ContentModelProviderPreset[] = [ + { id: "ark", name: "火山方舟 / 豆包", shortName: "豆包", category: "domestic", baseURL: "https://ark.cn-beijing.volces.com/api/v3", modelID: "", modelPlaceholder: "模型 ID 或推理 Endpoint ID", keyLabel: "方舟 API Key", description: "豆包及方舟模型,支持模型 ID 或专属推理接入点。", docsURL: "https://www.volcengine.com/docs/82379/1795150" }, + { id: "dashscope", name: "阿里云百炼 / 千问", shortName: "千问", category: "domestic", baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", modelID: "qwen-plus", modelPlaceholder: "例如 qwen-plus", keyLabel: "百炼 API Key", description: "使用北京地域 OpenAI 兼容入口,Key 与地域需一致。", docsURL: "https://help.aliyun.com/zh/model-studio/compatibility-of-openai-with-dashscope" }, + { id: "deepseek", name: "DeepSeek", shortName: "DeepSeek", category: "domestic", baseURL: "https://api.deepseek.com", modelID: "deepseek-v4-flash", modelPlaceholder: "例如 deepseek-v4-flash", keyLabel: "DeepSeek API Key", description: "DeepSeek 官方 OpenAI 兼容接口。", docsURL: "https://api-docs.deepseek.com/zh-cn/" }, + { id: "moonshot", name: "月之暗面 / Kimi", shortName: "Kimi", category: "domestic", baseURL: "https://api.moonshot.cn/v1", modelID: "kimi-k2.5", modelPlaceholder: "例如 kimi-k2.5", keyLabel: "Moonshot API Key", description: "Kimi 官方开放平台兼容接口。", docsURL: "https://platform.moonshot.cn/docs" }, + { id: "zhipu", name: "智谱 AI / GLM", shortName: "智谱 GLM", category: "domestic", baseURL: "https://open.bigmodel.cn/api/paas/v4", modelID: "glm-5", modelPlaceholder: "例如 glm-5", keyLabel: "智谱 API Key", description: "智谱开放平台 OpenAI Chat Completions 接口。", docsURL: "https://docs.bigmodel.cn/cn/guide/develop/openai/introduction" }, + { id: "qianfan", name: "百度千帆 / 文心", shortName: "百度千帆", category: "domestic", baseURL: "https://qianfan.baidubce.com/v2", modelID: "", modelPlaceholder: "填写千帆控制台中的 model 参数", keyLabel: "千帆 Bearer Token", description: "千帆 V2 OpenAI 兼容入口,使用 Bearer Token。", docsURL: "https://cloud.baidu.com/doc/qianfan/s/rmh4stn9m" }, + { id: "hunyuan", name: "腾讯混元", shortName: "腾讯混元", category: "domestic", baseURL: "https://api.hunyuan.cloud.tencent.com/v1", modelID: "hunyuan-turbos-latest", modelPlaceholder: "例如 hunyuan-turbos-latest", keyLabel: "混元 API Key", description: "腾讯混元 OpenAI 兼容入口。", docsURL: "https://cloud.tencent.com/document/product/1729/111007" }, + { id: "minimax", name: "MiniMax", shortName: "MiniMax", category: "domestic", baseURL: "https://api.minimaxi.com/v1", modelID: "MiniMax-M2.7", modelPlaceholder: "例如 MiniMax-M2.7", keyLabel: "MiniMax API Key", description: "MiniMax 文本对话兼容接口,已适配思考内容包装。", docsURL: "https://platform.minimaxi.com/docs/api-reference/text-chat-openai" }, + { id: "baichuan", name: "百川智能", shortName: "百川", category: "domestic", baseURL: "https://api.baichuan-ai.com/v1", modelID: "Baichuan2-Turbo", modelPlaceholder: "例如 Baichuan2-Turbo", keyLabel: "百川 API Key", description: "百川官方 Chat Completions 接口。", docsURL: "https://platform.baichuan-ai.com/docs/api" }, + { id: "siliconflow", name: "硅基流动", shortName: "硅基流动", category: "domestic", baseURL: "https://api.siliconflow.cn/v1", modelID: "", modelPlaceholder: "填写硅基流动模型 ID", keyLabel: "SiliconFlow API Key", description: "一个 Key 接入多种国内开源模型。", docsURL: "https://docs.siliconflow.cn/cn/api-reference/chat-completions/chat-completions" }, + { id: "ollama", name: "本地 Ollama", shortName: "本地 Ollama", category: "local", baseURL: "http://ollama:11434/v1", modelID: "qwen2.5:7b", modelPlaceholder: "例如 qwen2.5:7b", keyLabel: "本地占位密钥", description: "当前 Docker 网络内的本地模型,不向外部发送聊天内容。" }, + { id: "openai_compatible", name: "自定义兼容服务", shortName: "自定义服务", category: "custom", baseURL: "https://", modelID: "", modelPlaceholder: "填写服务提供方给出的模型 ID", keyLabel: "API Key", description: "用于其他支持 OpenAI Chat Completions 的 HTTPS 服务。" }, +]; + +export function contentModelProviderPreset(provider: ContentModelProvider) { + return contentModelProviderPresets.find((item) => item.id === provider) ?? contentModelProviderPresets[contentModelProviderPresets.length - 1]; +} + +export function contentModelProviderLabel(provider: ContentModelProvider) { + return contentModelProviderPreset(provider).name; +} diff --git a/web/src/pages/SaaSWorkspace.tsx b/web/src/pages/SaaSWorkspace.tsx index ce9c799..cee76d6 100644 --- a/web/src/pages/SaaSWorkspace.tsx +++ b/web/src/pages/SaaSWorkspace.tsx @@ -41,6 +41,7 @@ import { type ConversationTurn, type ConversationMedia, type ContentModel, + type ContentModelProvider, type CreateContentModelRequest, type CreateSubjectFactRequest, type CreateSubjectRequest, @@ -73,6 +74,7 @@ import { type UpdateSubjectRequest, type Story } from "../api/client"; +import { contentModelProviderLabel, contentModelProviderPreset, contentModelProviderPresets } from "../contentModelProviders"; import { FamilyUsageOverview } from "../components/UsageOverview"; export type CustomerSection = "home" | "members" | "subjects" | "devices" | "conversations" | "stories" | "sharing" | "privacy" | "billing" | "messages" | "models"; @@ -2103,7 +2105,7 @@ function ConversationPanel({ subjects, selectedID, onSelect, conversations, insi :

选择一段聊天

这里会展示大模型生成的摘要、关键词、故事线索和后续话题。

} - + {detailOpen ?
{ if (event.currentTarget === event.target) setDetailOpen(false); }}>

通话详情

查看文字内容、整理结果、语音授权与状态历史

{detailLoading ? : detailError && selectedConversationID ? void onOpen(selectedConversationID)} /> : detail ? <>

CONVERSATION DETAIL

{conversationStatusLabel(detail.status)}

{formatDate(detail.created_at)} · {detail.duration_ms ? `${Math.round(detail.duration_ms / 1000)}秒` : "时长待确认"}

@@ -2128,13 +2130,44 @@ function ContentModelPanel({ models, loading, error, canManage, actionBusy, onCr const [open, setOpen] = useState(false); const [draft, setDraft] = useState({ display_name: "豆包聊天总结", provider: "ark", base_url: "https://ark.cn-beijing.volces.com/api/v3", model_id: "", api_key: "", temperature: 0.2, max_tokens: 1280 }); const active = models.find((item) => item.status === "active"); - async function submit(event: FormEvent) { event.preventDefault(); if (await onCreate(draft)) { setOpen(false); setDraft((item) => ({ ...item, display_name: "", model_id: "", api_key: "" })); } } + const selectedProvider = contentModelProviderPreset(draft.provider); + const providerGroups = [ + { category: "domestic" as const, title: "国内主流云模型", detail: "已预填各厂商官方兼容接口" }, + { category: "local" as const, title: "本地模型", detail: "聊天内容不离开本机" }, + { category: "custom" as const, title: "其他服务", detail: "接入任意 OpenAI 兼容 HTTPS 接口" }, + ]; + function selectProvider(provider: ContentModelProvider) { + const preset = contentModelProviderPreset(provider); + setDraft((item) => ({ + ...item, + provider, + display_name: `${preset.shortName}聊天总结`, + base_url: preset.baseURL, + model_id: preset.modelID, + api_key: provider === "ollama" ? "ollama-local-key" : "", + })); + } + async function submit(event: FormEvent) { + event.preventDefault(); + if (await onCreate(draft)) { + setOpen(false); + setDraft((item) => ({ ...item, api_key: "" })); + } + } return

当前总结引擎

{active?.display_name || "系统默认模型"}

{active ? `${active.model_id} · 下一条总结任务立即生效` : "配置并激活模型后,聊天摘要、关键词和故事候选将由它生成"}
{canManage ? : null}
{error ?
{error}
: null} - {loading ? :
{models.length ? models.map((item) =>

{item.display_name}

{item.provider === "ark" ? "火山方舟 / 豆包" : "OpenAI 兼容服务"}

{item.status === "active" ? "正在使用" : "备用"}
模型 ID
{item.model_id}
接口地址
{item.base_url}
访问密钥
{item.credential_hint}
生成参数
温度 {item.temperature} · 最多 {item.max_tokens} tokens
{item.test_status === "passed" ? "连通性正常" : item.test_status === "failed" ? `测试失败 · ${item.last_error_code || "请检查配置"}` : "尚未测试"}{canManage ?
{item.status !== "active" ? : null}{item.status !== "active" ? : null}
: null}
) :

还没有自定义总结模型

添加火山方舟或其他 OpenAI 兼容模型,测试通过后即可激活。

{canManage ? : null}
}
} + {loading ? :
{models.length ? models.map((item) =>

{item.display_name}

{contentModelProviderLabel(item.provider)}

{item.status === "active" ? "正在使用" : "备用"}
模型 ID
{item.model_id}
接口地址
{item.base_url}
访问密钥
{item.credential_hint}
生成参数
温度 {item.temperature} · 最多 {item.max_tokens} tokens
{item.test_status === "passed" ? "连通性正常" : item.test_status === "failed" ? `测试失败 · ${item.last_error_code || "请检查配置"}` : "尚未测试"}{canManage ?
{item.status !== "active" ? : null}{item.status !== "active" ? : null}
: null}
) :

还没有自定义总结模型

选择一个厂商,只需填写密钥并确认模型 ID,测试通过后即可激活。

{canManage ? : null}
}
}
密钥如何保存

API Key 使用独立主密钥经 AES-256-GCM 加密后存入数据库,页面和接口都不会再次回显明文;每次激活、测试和移除都会写入审计记录。

- {open ?
{ if (event.currentTarget === event.target) setOpen(false); }}>
void submit(event)}>

添加总结大模型

该模型用于生成聊天摘要、关键词、故事和下次话题。

: null} + {open ?
{ if (event.currentTarget === event.target) setOpen(false); }}>
void submit(event)}>

添加总结大模型

先选厂商,再填写该账号对应的密钥和模型信息。

+
{providerGroups.map((group) =>
{group.title}{group.detail}
{contentModelProviderPresets.filter((item) => item.category === group.category).map((item) => )}
)}
+
{selectedProvider.name}{selectedProvider.description} · OpenAI Chat Completions 兼容协议
{selectedProvider.docsURL ? 查看官方接入文档 : null}
+ + + + +
高级生成参数
+
: null}
; } diff --git a/web/src/styles.css b/web/src/styles.css index 238e4e7..8bfeb06 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -111,7 +111,29 @@ main { .model-security-note > .anticon { margin-top: 3px; color: #5e8a6b; font-size: 20px; } .model-security-note p { margin: 5px 0 0; line-height: 1.65; } .model-parameter-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } -.model-create-dialog .workspace-dialog__body { display: grid; gap: 14px; } +.model-create-dialog { width: min(900px, calc(100vw - 32px)); max-height: calc(100dvh - 32px); } +.model-create-dialog .workspace-dialog__body { display: grid; gap: 16px; overflow-y: auto; } +.model-create-dialog label > small { display: block; margin-top: 5px; color: #917f78; font-size: 11px; line-height: 1.5; } +.model-provider-catalog { display: grid; gap: 13px; padding: 15px; border: 1px solid #eadfda; border-radius: 14px; background: #fbf8f5; } +.model-provider-group { display: grid; grid-template-columns: 142px minmax(0, 1fr); gap: 12px; align-items: start; } +.model-provider-group > div:first-child { padding-top: 8px; } +.model-provider-group > div:first-child strong, .model-provider-group > div:first-child small { display: block; } +.model-provider-group > div:first-child strong { color: #4f413c; font-size: 13px; } +.model-provider-group > div:first-child small { margin-top: 3px; color: #998680; font-size: 10px; line-height: 1.45; } +.model-provider-group > div:last-child { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; } +.model-provider-group button { min-height: 64px; padding: 9px 11px; border: 1px solid #e6d9d4; border-radius: 11px; color: #594a45; background: #fff; text-align: left; box-shadow: none; } +.model-provider-group button:hover { border-color: #9baeb7; background: #f7fafb; } +.model-provider-group button.is-selected { border-color: #617e8b; color: #344e5a; background: #edf4f6; box-shadow: inset 0 0 0 1px #617e8b; } +.model-provider-group button span, .model-provider-group button small { display: block; } +.model-provider-group button span { font-size: 13px; font-weight: 700; } +.model-provider-group button small { margin-top: 3px; overflow: hidden; color: #93817a; font-size: 10px; font-weight: 400; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; } +.model-provider-selection { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 12px 14px; border-radius: 12px; color: #53636a; background: #edf3f4; } +.model-provider-selection strong, .model-provider-selection span { display: block; } +.model-provider-selection span { margin-top: 2px; color: #78888e; font-size: 11px; } +.model-provider-selection a { color: #506f7c; font-size: 12px; white-space: nowrap; } +.model-advanced-settings { padding: 12px 14px; border: 1px solid #e9dfdb; border-radius: 12px; background: #fffdfb; } +.model-advanced-settings summary { color: #695752; cursor: pointer; font-size: 13px; font-weight: 650; } +.model-advanced-settings[open] summary { margin-bottom: 13px; } /* 非首页业务页统一为轻量、长者优先的内容画布。 */ .customer-page:not(.customer-page--home) > .workspace { border-color: #e9deda; border-radius: 18px; background: rgba(255, 253, 251, .96); box-shadow: 0 14px 38px rgba(78, 59, 52, .055); } @@ -131,6 +153,9 @@ main { .memory-summary-panel > header, .model-active-hero, .model-config-card footer { align-items: stretch; flex-direction: column; } .memory-model-card { grid-column: auto; } .model-parameter-row { grid-template-columns: 1fr; } + .model-provider-group { grid-template-columns: 1fr; } + .model-provider-group > div:last-child { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .model-provider-selection { align-items: flex-start; flex-direction: column; } } .hero {