feat: add domestic model provider presets
This commit is contained in:
@@ -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 加密保存,读取接口仅返回末四位提示。连接测试、激活与移除均写入审计日志。
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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"];
|
||||||
|
|||||||
Vendored
+7
-4
@@ -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: {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user