feat: add domestic model provider presets

This commit is contained in:
Codex Local
2026-08-30 19:55:17 +08:00
parent 522785eda8
commit dcf0fc0d7f
13 changed files with 314 additions and 28 deletions
+31
View File
@@ -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 参数而拒绝请求。带 `<think>` 思考包装的返回也会在解析前清理。
## 使用流程
1. 选择厂商,页面会预填官方 Base URL 和常用模型名。
2. 按厂商控制台填写 API Key 与准确模型 ID。使用地域或业务空间专属域名时,可修改 Base URL。
3. 保存后执行“测试连接”。测试内容是固定的无长者数据文本。
4. 测试通过后设为当前模型。下一条聊天总结任务立即使用,不需要重启 Worker。
API Key 通过 AES-256-GCM 加密保存,读取接口仅返回末四位提示。连接测试、激活与移除均写入审计日志。
+1 -1
View File
@@ -362,7 +362,7 @@ func normalizeInput(input *CreateInput) {
func validateInput(input CreateInput) error { func validateInput(input CreateInput) error {
parsed, err := url.Parse(input.BaseURL) 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()))) 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 { 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 return ErrInvalid
+27
View File
@@ -2,6 +2,33 @@ package contentmodel
import "testing" 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) { func TestValidateInputAllowsComposeOllamaEndpoint(t *testing.T) {
input := CreateInput{ input := CreateInput{
TenantID: "10000000-0000-0000-0000-000000000001", DisplayName: "本地 Qwen", TenantID: "10000000-0000-0000-0000-000000000001", DisplayName: "本地 Qwen",
+53 -12
View File
@@ -132,10 +132,10 @@ func NewCompatibleGenerator(provider, baseURL, apiKey, model string, temperature
model = strings.TrimSpace(model) model = strings.TrimSpace(model)
parsed, err := url.Parse(baseURL) parsed, err := url.Parse(baseURL)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { 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" { if !SupportsCompatibleProvider(provider) {
return nil, errors.New("content model provider must be ark or openai_compatible") return nil, errors.New("unsupported content model provider")
} }
if apiKey == "" || model == "" || timeout <= 0 || temperature < 0 || temperature > 2 || maxTokens < 256 || maxTokens > 8192 { 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") return nil, errors.New("content model credentials and valid generation parameters are required")
@@ -160,7 +160,12 @@ func (g *ArkGenerator) Generate(ctx context.Context, input GenerationInput) (Gen
}, },
"temperature": g.temperature, "temperature": g.temperature,
"max_tokens": g.maxTokens, "max_tokens": g.maxTokens,
"response_format": map[string]string{"type": "json_object"}, }
// 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) encoded, err := json.Marshal(requestBody)
if err != nil { 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)) req, err := http.NewRequestWithContext(ctx, http.MethodPost, g.endpoint, bytes.NewReader(encoded))
if err != nil { 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("Authorization", "Bearer "+g.apiKey)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "voice-platform-content-extractor/1.0") req.Header.Set("User-Agent", "voice-platform-content-extractor/1.0")
resp, err := g.client.Do(req) resp, err := g.client.Do(req)
if err != nil { 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() defer resp.Body.Close()
responseBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) responseBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if readErr != nil { 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 { 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 { if resp.StatusCode == http.StatusRequestTimeout || resp.StatusCode == http.StatusTooEarly || resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
return GenerationResult{}, outbox.Retryable(requestErr) return GenerationResult{}, outbox.Retryable(requestErr)
} }
@@ -197,15 +202,15 @@ func (g *ArkGenerator) Generate(ctx context.Context, input GenerationInput) (Gen
} `json:"choices"` } `json:"choices"`
} }
if err = json.Unmarshal(responseBody, &completion); err != nil || len(completion.Choices) == 0 { 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, "```json")
jsonContent = strings.TrimPrefix(jsonContent, "```") jsonContent = strings.TrimPrefix(jsonContent, "```")
jsonContent = strings.TrimSuffix(jsonContent, "```") jsonContent = strings.TrimSuffix(jsonContent, "```")
var result GenerationResult var result GenerationResult
if err = json.Unmarshal([]byte(strings.TrimSpace(jsonContent)), &result); err != nil { 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.Summary = strings.TrimSpace(result.Summary)
result.DecisionReason = strings.TrimSpace(result.DecisionReason) 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) result.Keywords = normalizedKeywords(result.Keywords)
normalizeDecisionContent(&result) normalizeDecisionContent(&result)
if !validGeneratedContent(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.ModelRef = g.provider + ":" + g.model
result.PromptVersion = "content-value-" + g.provider + "-json-v3" 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 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, "</think>"); end >= 0 {
content = strings.TrimSpace(content[end+len("</think>"):])
}
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) { func normalizeDecisionContent(result *GenerationResult) {
if !result.Meaningful { if !result.Meaningful {
result.StoryWorthy = false result.StoryWorthy = false
+40
View File
@@ -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": "<think>先判断内容价值</think>\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) { func TestArkGeneratorRetriesTemporaryFailure(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "busy", http.StatusTooManyRequests) http.Error(w, "busy", http.StatusTooManyRequests)
@@ -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;
@@ -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;
+6 -2
View File
@@ -5668,7 +5668,7 @@ components:
id: { type: string, format: uuid } id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid } tenant_id: { type: string, format: uuid }
display_name: { type: string, minLength: 1, maxLength: 120 } 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 } base_url: { type: string, format: uri }
model_id: { type: string, minLength: 1, maxLength: 200 } model_id: { type: string, minLength: 1, maxLength: 200 }
credential_hint: { type: string, description: 仅包含掩码和密钥末四位。 } credential_hint: { type: string, description: 仅包含掩码和密钥末四位。 }
@@ -5688,12 +5688,16 @@ components:
required: [display_name, provider, base_url, model_id, api_key, temperature, max_tokens] required: [display_name, provider, base_url, model_id, api_key, temperature, max_tokens]
properties: properties:
display_name: { type: string, minLength: 1, maxLength: 120 } 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 } base_url: { type: string, format: uri, maxLength: 500 }
model_id: { type: string, minLength: 1, maxLength: 200 } model_id: { type: string, minLength: 1, maxLength: 200 }
api_key: { type: string, minLength: 8, maxLength: 1000, writeOnly: true } api_key: { type: string, minLength: 8, maxLength: 1000, writeOnly: true }
temperature: { type: number, minimum: 0, maximum: 2 } temperature: { type: number, minimum: 0, maximum: 2 }
max_tokens: { type: integer, minimum: 256, maximum: 8192 } 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: StoryStatus:
type: string type: string
enum: [CANDIDATE, IN_REVIEW, CONFIRMED, REJECTED, SUPERSEDED] enum: [CANDIDATE, IN_REVIEW, CONFIRMED, REJECTED, SUPERSEDED]
+3 -2
View File
@@ -34,13 +34,14 @@ export type ConversationDetail = components["schemas"]["ConversationDetail"];
export type ConversationUsage = components["schemas"]["ConversationUsage"]; export type ConversationUsage = components["schemas"]["ConversationUsage"];
export type ConversationTurn = components["schemas"]["ConversationTurn"]; export type ConversationTurn = components["schemas"]["ConversationTurn"];
export type ConversationContentSummary = components["schemas"]["ConversationContentSummary"]; 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 = { 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; base_url: string; model_id: string; credential_hint: string; temperature: number; max_tokens: number;
status: "inactive" | "active" | "revoked"; test_status: "untested" | "passed" | "failed"; 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; 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 ConversationMedia = components["schemas"]["ConversationMedia"];
export type MediaSegment = components["schemas"]["MediaSegment"]; export type MediaSegment = components["schemas"]["MediaSegment"];
export type RecordingAccessGrant = components["schemas"]["RecordingAccessGrant"]; export type RecordingAccessGrant = components["schemas"]["RecordingAccessGrant"];
+7 -4
View File
@@ -5347,8 +5347,7 @@ export interface components {
/** Format: uuid */ /** Format: uuid */
tenant_id: string; tenant_id: string;
display_name: string; display_name: string;
/** @enum {string} */ provider: components["schemas"]["ContentModelProvider"];
provider: "ark" | "openai_compatible";
/** Format: uri */ /** Format: uri */
base_url: string; base_url: string;
model_id: string; model_id: string;
@@ -5374,8 +5373,7 @@ export interface components {
}; };
CreateContentModelRequest: { CreateContentModelRequest: {
display_name: string; display_name: string;
/** @enum {string} */ provider: components["schemas"]["ContentModelProvider"];
provider: "ark" | "openai_compatible";
/** Format: uri */ /** Format: uri */
base_url: string; base_url: string;
model_id: string; model_id: string;
@@ -5383,6 +5381,11 @@ export interface components {
temperature: number; temperature: number;
max_tokens: 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} */ /** @enum {string} */
StoryStatus: "CANDIDATE" | "IN_REVIEW" | "CONFIRMED" | "REJECTED" | "SUPERSEDED"; StoryStatus: "CANDIDATE" | "IN_REVIEW" | "CONFIRMED" | "REJECTED" | "SUPERSEDED";
StorySource: { StorySource: {
+37
View File
@@ -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;
}
+37 -4
View File
@@ -41,6 +41,7 @@ import {
type ConversationTurn, type ConversationTurn,
type ConversationMedia, type ConversationMedia,
type ContentModel, type ContentModel,
type ContentModelProvider,
type CreateContentModelRequest, type CreateContentModelRequest,
type CreateSubjectFactRequest, type CreateSubjectFactRequest,
type CreateSubjectRequest, type CreateSubjectRequest,
@@ -73,6 +74,7 @@ import {
type UpdateSubjectRequest, type UpdateSubjectRequest,
type Story type Story
} from "../api/client"; } from "../api/client";
import { contentModelProviderLabel, contentModelProviderPreset, contentModelProviderPresets } from "../contentModelProviders";
import { FamilyUsageOverview } from "../components/UsageOverview"; import { FamilyUsageOverview } from "../components/UsageOverview";
export type CustomerSection = "home" | "members" | "subjects" | "devices" | "conversations" | "stories" | "sharing" | "privacy" | "billing" | "messages" | "models"; 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
<footer><button type="button" className="button-secondary" onClick={() => setDetailOpen(true)}>查看完整聊天</button>{storyCandidate ? <button type="button" onClick={onOpenStories}>审核并保存为故事</button> : null}<button type="button" onClick={() => void onRegenerate()} disabled={actionBusy || !activeModel}>{actionBusy ? "正在提交…" : "重新生成总结"}</button></footer> <footer><button type="button" className="button-secondary" onClick={() => setDetailOpen(true)}>查看完整聊天</button>{storyCandidate ? <button type="button" onClick={onOpenStories}>审核并保存为故事</button> : null}<button type="button" onClick={() => void onRegenerate()} disabled={actionBusy || !activeModel}>{actionBusy ? "正在提交…" : "重新生成总结"}</button></footer>
</> : <div className="memory-summary-empty"><BookOutlined /><h3>选择一段聊天</h3><p>这里会展示大模型生成的摘要、关键词、故事线索和后续话题。</p></div>} </> : <div className="memory-summary-empty"><BookOutlined /><h3>选择一段聊天</h3><p>这里会展示大模型生成的摘要、关键词、故事线索和后续话题。</p></div>}
</section> </section>
<aside className="memory-model-card"><span className="memory-model-card__icon"><RobotOutlined /></span><p>当前总结模型</p><h3>{activeModel?.display_name || "系统默认模型"}</h3><small>{activeModel ? `${activeModel.provider === "ark" ? "火山方舟" : "OpenAI 兼容"} · ${activeModel.model_id}` : "尚未激活自定义模型,将使用部署环境默认配置"}</small><div><span className={activeModel ? "is-active" : ""} />{activeModel ? "已激活" : "默认回退"}</div>{currentSummary ? <p className="memory-model-card__history"><CheckCircleOutlined /> 最近结果由<br /><strong>{currentSummary.model_ref}</strong><br />生成版本 v{currentSummary.generation_version}</p> : null}</aside> <aside className="memory-model-card"><span className="memory-model-card__icon"><RobotOutlined /></span><p>当前总结模型</p><h3>{activeModel?.display_name || "系统默认模型"}</h3><small>{activeModel ? `${contentModelProviderLabel(activeModel.provider)} · ${activeModel.model_id}` : "尚未激活自定义模型,将使用部署环境默认配置"}</small><div><span className={activeModel ? "is-active" : ""} />{activeModel ? "已激活" : "默认回退"}</div>{currentSummary ? <p className="memory-model-card__history"><CheckCircleOutlined /> 最近结果由<br /><strong>{currentSummary.model_ref}</strong><br />生成版本 v{currentSummary.generation_version}</p> : null}</aside>
</div> </div>
{detailOpen ? <div className="workspace-dialog-backdrop" role="presentation" onMouseDown={(event) => { if (event.currentTarget === event.target) setDetailOpen(false); }}><section className="conversation-detail workspace-dialog workspace-dialog--wide" role="dialog" aria-modal="true" aria-labelledby="conversation-detail-title"><header><div><h3 id="conversation-detail-title">通话详情</h3><p>查看文字内容、整理结果、语音授权与状态历史</p></div><button type="button" aria-label="关闭" onClick={() => setDetailOpen(false)}><CloseOutlined /></button></header><div className="workspace-dialog__body">{detailLoading ? <StatePanel title="正在读取通话详情" detail="正在核验状态、最终话轮、整理结果、用量和录音访问权限…" /> : detailError && selectedConversationID ? <StatePanel title="通话详情读取失败" detail={detailError} action={() => void onOpen(selectedConversationID)} /> : detail ? <> {detailOpen ? <div className="workspace-dialog-backdrop" role="presentation" onMouseDown={(event) => { if (event.currentTarget === event.target) setDetailOpen(false); }}><section className="conversation-detail workspace-dialog workspace-dialog--wide" role="dialog" aria-modal="true" aria-labelledby="conversation-detail-title"><header><div><h3 id="conversation-detail-title">通话详情</h3><p>查看文字内容、整理结果、语音授权与状态历史</p></div><button type="button" aria-label="关闭" onClick={() => setDetailOpen(false)}><CloseOutlined /></button></header><div className="workspace-dialog__body">{detailLoading ? <StatePanel title="正在读取通话详情" detail="正在核验状态、最终话轮、整理结果、用量和录音访问权限…" /> : detailError && selectedConversationID ? <StatePanel title="通话详情读取失败" detail={detailError} action={() => void onOpen(selectedConversationID)} /> : detail ? <>
<div className="resource-detail__head"><div><p className="eyebrow">CONVERSATION DETAIL</p><h3>{conversationStatusLabel(detail.status)}</h3><p>{formatDate(detail.created_at)} · {detail.duration_ms ? `${Math.round(detail.duration_ms / 1000)}秒` : "时长待确认"}</p></div></div> <div className="resource-detail__head"><div><p className="eyebrow">CONVERSATION DETAIL</p><h3>{conversationStatusLabel(detail.status)}</h3><p>{formatDate(detail.created_at)} · {detail.duration_ms ? `${Math.round(detail.duration_ms / 1000)}秒` : "时长待确认"}</p></div></div>
@@ -2128,13 +2130,44 @@ function ContentModelPanel({ models, loading, error, canManage, actionBusy, onCr
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [draft, setDraft] = useState<CreateContentModelRequest>({ 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 [draft, setDraft] = useState<CreateContentModelRequest>({ 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"); 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 <div className="model-management-page"> return <div className="model-management-page">
<section className="model-active-hero"><div><span className="model-active-hero__icon"><RobotOutlined /></span><div><p>当前总结引擎</p><h3>{active?.display_name || "系统默认模型"}</h3><span>{active ? `${active.model_id} · 下一条总结任务立即生效` : "配置并激活模型后,聊天摘要、关键词和故事候选将由它生成"}</span></div></div>{canManage ? <button type="button" onClick={() => setOpen(true)}><PlusOutlined /> 添加大模型</button> : null}</section> <section className="model-active-hero"><div><span className="model-active-hero__icon"><RobotOutlined /></span><div><p>当前总结引擎</p><h3>{active?.display_name || "系统默认模型"}</h3><span>{active ? `${active.model_id} · 下一条总结任务立即生效` : "配置并激活模型后,聊天摘要、关键词和故事候选将由它生成"}</span></div></div>{canManage ? <button type="button" onClick={() => setOpen(true)}><PlusOutlined /> 添加大模型</button> : null}</section>
{error ? <div className="workspace__inline-error" role="alert">{error}</div> : null} {error ? <div className="workspace__inline-error" role="alert">{error}</div> : null}
{loading ? <StatePanel title="正在读取模型配置" detail="密钥不会回显,仅展示末四位提示。" /> : <section className="model-card-grid">{models.length ? models.map((item) => <article className={item.status === "active" ? "model-config-card is-active" : "model-config-card"} key={item.id}><header><span><RobotOutlined /></span><div><h3>{item.display_name}</h3><p>{item.provider === "ark" ? "火山方舟 / 豆包" : "OpenAI 兼容服务"}</p></div><b>{item.status === "active" ? "正在使用" : "备用"}</b></header><dl><div><dt>模型 ID</dt><dd>{item.model_id}</dd></div><div><dt>接口地址</dt><dd>{item.base_url}</dd></div><div><dt>访问密钥</dt><dd>{item.credential_hint}</dd></div><div><dt>生成参数</dt><dd>温度 {item.temperature} · 最多 {item.max_tokens} tokens</dd></div></dl><footer><span className={`model-test-status model-test-status--${item.test_status}`}>{item.test_status === "passed" ? "连通性正常" : item.test_status === "failed" ? `测试失败 · ${item.last_error_code || "请检查配置"}` : "尚未测试"}</span>{canManage ? <div><button type="button" className="button-secondary" disabled={actionBusy} onClick={() => void onTest(item)}>测试连接</button>{item.status !== "active" ? <button type="button" disabled={actionBusy || item.test_status !== "passed"} onClick={() => void onActivate(item)}>设为当前模型</button> : null}{item.status !== "active" ? <button type="button" className="button-danger" disabled={actionBusy} onClick={() => void onRevoke(item)}>移除</button> : null}</div> : null}</footer></article>) : <div className="model-empty"><RobotOutlined /><h3>还没有自定义总结模型</h3><p>添加火山方舟或其他 OpenAI 兼容模型,测试通过后即可激活。</p>{canManage ? <button type="button" onClick={() => setOpen(true)}>添加第一个模型</button> : null}</div>}</section>} {loading ? <StatePanel title="正在读取模型配置" detail="密钥不会回显,仅展示末四位提示。" /> : <section className="model-card-grid">{models.length ? models.map((item) => <article className={item.status === "active" ? "model-config-card is-active" : "model-config-card"} key={item.id}><header><span><RobotOutlined /></span><div><h3>{item.display_name}</h3><p>{contentModelProviderLabel(item.provider)}</p></div><b>{item.status === "active" ? "正在使用" : "备用"}</b></header><dl><div><dt>模型 ID</dt><dd>{item.model_id}</dd></div><div><dt>接口地址</dt><dd>{item.base_url}</dd></div><div><dt>访问密钥</dt><dd>{item.credential_hint}</dd></div><div><dt>生成参数</dt><dd>温度 {item.temperature} · 最多 {item.max_tokens} tokens</dd></div></dl><footer><span className={`model-test-status model-test-status--${item.test_status}`}>{item.test_status === "passed" ? "连通性正常" : item.test_status === "failed" ? `测试失败 · ${item.last_error_code || "请检查配置"}` : "尚未测试"}</span>{canManage ? <div><button type="button" className="button-secondary" disabled={actionBusy} onClick={() => void onTest(item)}>测试连接</button>{item.status !== "active" ? <button type="button" disabled={actionBusy || item.test_status !== "passed"} onClick={() => void onActivate(item)}>设为当前模型</button> : null}{item.status !== "active" ? <button type="button" className="button-danger" disabled={actionBusy} onClick={() => void onRevoke(item)}>移除</button> : null}</div> : null}</footer></article>) : <div className="model-empty"><RobotOutlined /><h3>还没有自定义总结模型</h3><p>选择一个厂商,只需填写密钥并确认模型 ID,测试通过后即可激活。</p>{canManage ? <button type="button" onClick={() => setOpen(true)}>添加第一个模型</button> : null}</div>}</section>}
<section className="model-security-note"><SafetyCertificateOutlined /><div><strong>密钥如何保存</strong><p>API Key 使用独立主密钥经 AES-256-GCM 加密后存入数据库,页面和接口都不会再次回显明文;每次激活、测试和移除都会写入审计记录。</p></div></section> <section className="model-security-note"><SafetyCertificateOutlined /><div><strong>密钥如何保存</strong><p>API Key 使用独立主密钥经 AES-256-GCM 加密后存入数据库,页面和接口都不会再次回显明文;每次激活、测试和移除都会写入审计记录。</p></div></section>
{open ? <div className="workspace-dialog-backdrop" role="presentation" onMouseDown={(event) => { if (event.currentTarget === event.target) setOpen(false); }}><form className="workspace-dialog model-create-dialog" role="dialog" aria-modal="true" onSubmit={(event) => void submit(event)}><header><div><h3>添加总结大模型</h3><p>该模型用于生成聊天摘要、关键词、故事和下次话题。</p></div><button type="button" aria-label="关闭" onClick={() => setOpen(false)}><CloseOutlined /></button></header><div className="workspace-dialog__body"><label><span>配置名称</span><input autoFocus required maxLength={120} value={draft.display_name} onChange={(event) => setDraft({ ...draft, display_name:event.target.value })} placeholder="例如:豆包聊天总结" /></label><label><span>服务类型</span><select value={draft.provider} onChange={(event) => setDraft({ ...draft, provider:event.target.value as ContentModel["provider"] })}><option value="ark">火山方舟 / 豆包</option><option value="openai_compatible">OpenAI 兼容接口</option></select></label><label><span>接口基础地址</span><input required type="url" value={draft.base_url} onChange={(event) => setDraft({ ...draft, base_url:event.target.value })} /></label><label><span>模型或 Endpoint ID</span><input required value={draft.model_id} onChange={(event) => setDraft({ ...draft, model_id:event.target.value })} placeholder="ep-xxxxxxxx" /></label><label><span>API Key</span><input required type="password" minLength={8} autoComplete="new-password" value={draft.api_key} onChange={(event) => setDraft({ ...draft, api_key:event.target.value })} placeholder="只在本次保存时使用" /></label><div className="model-parameter-row"><label><span>温度</span><input type="number" min="0" max="2" step="0.1" value={draft.temperature} onChange={(event) => setDraft({ ...draft, temperature:Number(event.target.value) })} /></label><label><span>最大输出</span><input type="number" min="256" max="8192" step="128" value={draft.max_tokens} onChange={(event) => setDraft({ ...draft, max_tokens:Number(event.target.value) })} /></label></div></div><footer><button type="button" className="button-secondary" onClick={() => setOpen(false)}>取消</button><button type="submit" disabled={actionBusy || !draft.display_name.trim() || !draft.model_id.trim() || draft.api_key.length < 8}>{actionBusy ? "正在加密保存…" : "保存配置"}</button></footer></form></div> : null} {open ? <div className="workspace-dialog-backdrop" role="presentation" onMouseDown={(event) => { if (event.currentTarget === event.target) setOpen(false); }}><form className="workspace-dialog model-create-dialog" role="dialog" aria-modal="true" aria-labelledby="model-create-title" onSubmit={(event) => void submit(event)}><header><div><h3 id="model-create-title">添加总结大模型</h3><p>先选厂商,再填写该账号对应的密钥和模型信息。</p></div><button type="button" aria-label="关闭" onClick={() => setOpen(false)}><CloseOutlined /></button></header><div className="workspace-dialog__body">
<section className="model-provider-catalog" aria-label="选择模型厂商">{providerGroups.map((group) => <div className="model-provider-group" key={group.category}><div><strong>{group.title}</strong><small>{group.detail}</small></div><div>{contentModelProviderPresets.filter((item) => item.category === group.category).map((item) => <button type="button" aria-pressed={draft.provider === item.id} className={draft.provider === item.id ? "is-selected" : ""} disabled={actionBusy} key={item.id} onClick={() => selectProvider(item.id)}><span>{item.shortName}</span><small>{item.description}</small></button>)}</div></div>)}</section>
<section className="model-provider-selection"><div><strong>{selectedProvider.name}</strong><span>{selectedProvider.description} · OpenAI Chat Completions 兼容协议</span></div>{selectedProvider.docsURL ? <a href={selectedProvider.docsURL} target="_blank" rel="noreferrer">查看官方接入文档</a> : null}</section>
<label><span>配置名称</span><input autoFocus required maxLength={120} value={draft.display_name} onChange={(event) => setDraft({ ...draft, display_name:event.target.value })} placeholder={`例如:${selectedProvider.shortName}聊天总结`} /></label>
<label><span>接口基础地址</span><input required type="url" value={draft.base_url} onChange={(event) => setDraft({ ...draft, base_url:event.target.value })} /><small>已预填官方地址;只有使用专属地域、业务空间或代理网关时才需要修改。</small></label>
<label><span>模型 ID</span><input required value={draft.model_id} onChange={(event) => setDraft({ ...draft, model_id:event.target.value })} placeholder={selectedProvider.modelPlaceholder} /><small>必须与该厂商控制台中显示的模型名称或 Endpoint ID 完全一致。</small></label>
<label><span>{selectedProvider.keyLabel}</span><input required type="password" minLength={8} autoComplete="new-password" value={draft.api_key} onChange={(event) => setDraft({ ...draft, api_key:event.target.value })} placeholder={draft.provider === "ollama" ? "本地服务使用占位值即可" : "只在本次保存时使用,保存后不再回显"} /></label>
<details className="model-advanced-settings"><summary>高级生成参数</summary><div className="model-parameter-row"><label><span>温度</span><input type="number" min="0" max="2" step="0.1" value={draft.temperature} onChange={(event) => setDraft({ ...draft, temperature:Number(event.target.value) })} /></label><label><span>最大输出</span><input type="number" min="256" max="8192" step="128" value={draft.max_tokens} onChange={(event) => setDraft({ ...draft, max_tokens:Number(event.target.value) })} /></label></div></details>
</div><footer><button type="button" className="button-secondary" onClick={() => setOpen(false)}>取消</button><button type="submit" disabled={actionBusy || !draft.display_name.trim() || !draft.base_url.trim() || !draft.model_id.trim() || draft.api_key.length < 8}>{actionBusy ? "正在加密保存…" : "保存并去测试"}</button></footer></form></div> : null}
</div>; </div>;
} }
+26 -1
View File
@@ -111,7 +111,29 @@ main {
.model-security-note > .anticon { margin-top: 3px; color: #5e8a6b; font-size: 20px; } .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-security-note p { margin: 5px 0 0; line-height: 1.65; }
.model-parameter-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } .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); } .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-summary-panel > header, .model-active-hero, .model-config-card footer { align-items: stretch; flex-direction: column; }
.memory-model-card { grid-column: auto; } .memory-model-card { grid-column: auto; }
.model-parameter-row { grid-template-columns: 1fr; } .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 { .hero {