diff --git a/.env.example b/.env.example index 5dc3fe6a165..48e917c4860 100644 --- a/.env.example +++ b/.env.example @@ -183,6 +183,12 @@ REDIS_PREFIX=stream: # 后台任务每模型并发上限(0/负数禁用;交互 chat 不受其限制) # WEKNORA_MODEL_MAX_CONCURRENCY=32 +# 模型参数目录来源:GITHUB / CNB / LOCAL(默认 LOCAL,使用内置 models.json 离线兜底; +# 镜像地址确定后切 GITHUB/CNB,非法值告警并回落 LOCAL) +# WEKNORA_MODELS_CATALOG=LOCAL +# 远端模型列表探测超时秒数(POST /models/remote-catalog,默认 8) +# WEKNORA_REMOTE_CATALOG_TIMEOUT_SECONDS=8 + # ========== B3. 文件存储(通用)========== # 文件存储类型:local / minio / cos / tos / s3 / obs / oss / dummy。 STORAGE_TYPE=local diff --git a/.gitignore b/.gitignore index e76cca0cafc..560b71f3962 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,13 @@ miniprogram/project.private.config.json # Locally built BrowserSkill runtime and extension bundles /artifacts/browserskill/ + +# 模型管理设计文档(本地评审用,不入库/不随 PR 提交,2026-09-15 裁定) +/CONTEXT.md +/docs/adr/0001-saved-model-config-never-auto-refreshed.md +/docs/adr/0002-model-level-thinking-levels-catalog-or-user-pick.md +/docs/adr/0003-scope-model-management-plus-unified-invocation.md +/docs/adr/0004-model-types-converge-to-four-facets-vision-rides-modalities.md + +# fork 本地的手动 docker 构建流水线(不入上游,2026-09-15) +/.github/workflows/docker-build.yaml diff --git a/.golangci.yml b/.golangci.yml index 86a59075531..51a99392cbf 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -4,10 +4,18 @@ linters: - lll # 控制行宽 - govet - revive + - exhaustive # 仅检查标注 //exhaustive:enforce 的 switch(如适配器 role 映射) settings: lll: line-length: 120 tab-width: 4 + exhaustive: + explicit-exhaustive-switch: true + errcheck: + exclude-functions: + # gin.Context.Error returns the error for chaining; repo-wide the return is + # deliberately ignored (64 pre-existing bare calls). + - (*github.com/gin-gonic/gin.Context).Error exclusions: paths: # swag embeds OpenAPI as single-line JSON strings; line-length is not meaningful. diff --git a/cli/cmd/model/create.go b/cli/cmd/model/create.go index 2d1de41b1af..93a380f1828 100644 --- a/cli/cmd/model/create.go +++ b/cli/cmd/model/create.go @@ -32,7 +32,7 @@ var createSourceValues = []string{string(sdk.ModelSourceLocal), string(sdk.Model // canonicalModelType maps the server's frontend term "chat" to the KnowledgeQA // enum — that is the server's own /models/providers vocabulary (see its // frontendToModelType map). The other frontend terms (embedding / rerank / -// vllm / asr) already match the enum case-insensitively, so no alias is needed. +// asr) already match the enum case-insensitively, so no alias is needed. // Returns the input unchanged when it isn't an alias. func canonicalModelType(t string) string { if strings.EqualFold(strings.TrimSpace(t), "chat") { @@ -66,13 +66,11 @@ type CreateService interface { } // frontendModelType maps the create enum to the server's /models/providers -// "model_type" query vocabulary (KnowledgeQA→chat, VLLM→vllm; others lowercase). +// "model_type" query vocabulary (KnowledgeQA→chat; others lowercase). func frontendModelType(t string) string { switch t { case string(sdk.ModelTypeKnowledgeQA): return "chat" - case string(sdk.ModelTypeVLLM): - return "vllm" default: return strings.ToLower(t) } @@ -83,7 +81,7 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { opts := &CreateOptions{} cmd := &cobra.Command{ Use: "create ", - Short: "Register a model (embedding / rerank / chat / VLLM / ASR)", + Short: "Register a model (embedding / rerank / chat / ASR)", Long: `Register a model on the server so it can back a knowledge base's embedding / summary config (see 'weknora kb config set') or an agent (--model). @@ -103,7 +101,7 @@ Two modes: --base-url defaults to that provider's URL for the type when omitted. ---type accepts the server's term "chat" for KnowledgeQA (embedding/rerank/vllm/ +--type accepts the server's term "chat" for KnowledgeQA (embedding/rerank/ asr match the enum directly). Embedding models take --dimension. Pipe the provider key via --api-key-stdin so it never lands in argv/history. Anything else goes through repeatable --param key=value.`, @@ -178,7 +176,7 @@ else goes through repeatable --param key=value.`, cmdutil.AddFormatFlag(cmd, modelCreateFields...) cmdutil.AddDryRunFlag(cmd, &opts.DryRun) cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{ - UsedFor: "register a model (embedding/rerank/chat/VLLM/ASR) so a KB or agent can use it; capture .data.id to pass to `weknora kb config set` / `agent create --model`.", + UsedFor: "register a model (embedding/rerank/chat/ASR) so a KB or agent can use it; capture .data.id to pass to `weknora kb config set` / `agent create --model`.", RequiredFlags: []string{" (positional)", "--type", "--source (local|remote)", "--provider (when --source remote)"}, Examples: []string{ `weknora model create nomic-embed-text --type Embedding --source local --dimension 768 # Ollama (server pulls it)`, diff --git a/cli/cmd/model/list.go b/cli/cmd/model/list.go index acd95cf92d5..0d433fed83f 100644 --- a/cli/cmd/model/list.go +++ b/cli/cmd/model/list.go @@ -36,7 +36,7 @@ var modelSourceValues = cmdutil.EnumStrings(sdk.AllModelSources()) // ListOptions captures `model list` filter flag state. type ListOptions struct { // Type / Source, when set, restrict output to models of that type - // (Embedding, Rerank, KnowledgeQA, VLLM, ASR) or provider (local, openai, + // (Embedding, Rerank, KnowledgeQA, ASR) or provider (local, openai, // …), matched case-insensitively. Empty shows everything. Type string Source string @@ -58,7 +58,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { Use: "list", Short: "List models configured on the server", Long: `List the models configured on the server, sorted by type then name. Pass ---type to restrict to one model type (Embedding, Rerank, KnowledgeQA, VLLM, ASR).`, +--type to restrict to one model type (Embedding, Rerank, KnowledgeQA, ASR).`, Args: cobra.NoArgs, RunE: func(c *cobra.Command, _ []string) error { fopts, err := cmdutil.CheckFormatFlag(c) @@ -78,7 +78,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { return runList(c.Context(), opts, fopts, cli) }, } - cmd.Flags().StringVar(&opts.Type, "type", "", "Only show models of this type (Embedding, Rerank, KnowledgeQA, VLLM, ASR)") + cmd.Flags().StringVar(&opts.Type, "type", "", "Only show models of this type (Embedding, Rerank, KnowledgeQA, ASR)") cmd.Flags().StringVar(&opts.Source, "source", "", "Only show models from this provider (local, remote, openai, aliyun, …)") cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return — client-side cap; meta.has_more/total_count report the full size (1..10000)") cmdutil.AddFormatFlag(cmd, modelListFields...) diff --git a/client/model.go b/client/model.go index a6d67cc97b3..9f8ed111968 100644 --- a/client/model.go +++ b/client/model.go @@ -16,8 +16,7 @@ type ModelType string const ( ModelTypeEmbedding ModelType = "Embedding" // Embedding model ModelTypeRerank ModelType = "Rerank" // Rerank model - ModelTypeKnowledgeQA ModelType = "KnowledgeQA" // KnowledgeQA model - ModelTypeVLLM ModelType = "VLLM" // VLLM model + ModelTypeKnowledgeQA ModelType = "KnowledgeQA" // KnowledgeQA model (vision rides input modalities) ModelTypeASR ModelType = "ASR" // ASR (Automatic Speech Recognition) model ) @@ -26,7 +25,7 @@ const ( // re-typing the string set, so they can't drift from the SDK. func AllModelTypes() []ModelType { return []ModelType{ - ModelTypeEmbedding, ModelTypeRerank, ModelTypeKnowledgeQA, ModelTypeVLLM, ModelTypeASR, + ModelTypeEmbedding, ModelTypeRerank, ModelTypeKnowledgeQA, ModelTypeASR, } } @@ -215,7 +214,7 @@ type ModelProviderListResponse struct { } // ListModelProviders retrieves the list of supported model providers. -// modelType is optional and can be used to filter by type: "chat", "embedding", "rerank", "vllm". +// modelType is optional and can be used to filter by type: "chat", "embedding", "rerank", "asr". func (c *Client) ListModelProviders(ctx context.Context, modelType string) ([]ModelProvider, error) { var queryParams url.Values if modelType != "" { diff --git a/frontend/src/api/agent/index.ts b/frontend/src/api/agent/index.ts index e03c8818087..8464f4d02c6 100644 --- a/frontend/src/api/agent/index.ts +++ b/frontend/src/api/agent/index.ts @@ -47,6 +47,7 @@ export interface CustomAgentConfig { temperature?: number; max_completion_tokens?: number; // 0 = 跟随系统默认(快速问答 2048;智能推理 4096,绑沙箱可写文件时 24576)。大于 0 为自定义上限 thinking?: boolean; // 是否启用思考模式(支持扩展思考的模型) + thinking_level?: string; // 思考档位(空 = 跟随模型默认;开关为 config.thinking) citation_enabled?: boolean; // 是否在最终回答中输出知识库/网页来源引用(默认开启) // ===== Agent模式设置 ===== @@ -135,6 +136,18 @@ export interface CustomAgentConfig { // 意图提示词:非检索意图(问候、闲聊等)时覆盖主系统提示词 intent_prompts?: Record; + // ===== 上下文模板 / 检索历史 / 数据分析 / FAQ / 联网抓取 / 查询理解 ===== + // (后端 custom_agent.go 全量替换 config——按本接口从零构造会清零这些 + // 字段,2026-09-13 审查补齐镜像;context_template_id 声明见上方模型设置组) + retain_retrieval_history?: boolean; + data_analysis_enabled?: boolean; + faq_priority_enabled?: boolean; + faq_direct_answer_threshold?: number; + faq_score_boost?: number; + web_fetch_enabled?: boolean; + web_fetch_top_n?: number; + query_understand_model_id?: string; + // ===== 已废弃字段(保留兼容)===== welcome_message?: string; question_suggestions?: QuestionSuggestionConfig; diff --git a/frontend/src/api/chat/streame.ts b/frontend/src/api/chat/streame.ts index 3600adfceed..5581bc5ccfc 100644 --- a/frontend/src/api/chat/streame.ts +++ b/frontend/src/api/chat/streame.ts @@ -26,28 +26,26 @@ interface StreamOptions { } export function useStream() { - // 响应式状态 - const output = ref('') // 显示内容 + // 响应式状态(output/isLoading 曾是有写无读的死状态、buffer 是只进不读的 + // 跨流死缓冲——随流累积,2026-09-13 审查删除;消费者只取下方 return 的键) const isStreaming = ref(false) // 流状态 - const isLoading = ref(false) // 初始加载 const error = ref(null)// 错误信息 const lastStreamRequest = ref(null) let controller = new AbortController() let streamGeneration = 0 - // 流式渲染缓冲 - let buffer: string[] = [] - let renderTimer: number | null = null - // 启动流式请求 - const startStream = async (params: { session_id: any; query: any; knowledge_base_ids?: string[]; knowledge_ids?: string[]; tag_ids?: string[]; agent_enabled?: boolean; agent_id?: string; agent_source_tenant_id?: string | number; web_search_enabled?: boolean; local_browser_enabled?: boolean; summary_model_id?: string; mcp_service_ids?: string[]; skill_names?: string[]; mentioned_items?: Array<{id: string; name: string; type: string; kb_type?: string; kb_id?: string; kb_name?: string; service_id?: string; skill_name?: string}>; images?: Array<{data: string}>; attachment_uploads?: Array<{data: string; file_name: string; file_size: number}>; attachment_ids?: string[]; suggestion_attribution?: { suggestion_set_id: string; question_id: string }; method: string; url: string; embed_token?: string; embed_session_sig?: string; embed_visitor_id?: string }) => { + const startStream = async (params: { session_id: any; query: any; knowledge_base_ids?: string[]; knowledge_ids?: string[]; tag_ids?: string[]; agent_enabled?: boolean; agent_id?: string; agent_source_tenant_id?: string | number; web_search_enabled?: boolean; local_browser_enabled?: boolean; summary_model_id?: string; thinking_level?: string; thinking?: boolean; mcp_service_ids?: string[]; skill_names?: string[]; mentioned_items?: Array<{id: string; name: string; type: string; kb_type?: string; kb_id?: string; kb_name?: string; service_id?: string; skill_name?: string}>; images?: Array<{data: string}>; attachment_uploads?: Array<{data: string; file_name: string; file_size: number}>; attachment_ids?: string[]; suggestion_attribution?: { suggestion_set_id: string; question_id: string }; method: string; url: string; embed_token?: string; embed_session_sig?: string; embed_visitor_id?: string }) => { const myGeneration = ++streamGeneration + // 终止在途旧流(2026-09-13 审查):此前 generation guard 只丢 UI 渲染, + // 旧连接仍在耗网络与上游 tokens——先 abort 旧 controller 再换新; + // 旧流的 onclose/catch 需校验 generation,防止清理动作杀死新流。 + controller.abort() + controller = new AbortController() const streamAbort = controller // 重置状态 - output.value = ''; error.value = null; isStreaming.value = true; - isLoading.value = true; // 获取API配置 const apiUrl = getApiBaseUrl(); @@ -137,6 +135,13 @@ export function useStream() { if (params.images !== undefined && params.images.length > 0) { postBody.images = params.images; } + // 会话级思考覆盖(design §8.1.3):空 = 跟随智能体/模型默认,不发送 + if (params.thinking !== undefined) { + postBody.thinking = params.thinking; + } + if (params.thinking_level) { + postBody.thinking_level = params.thinking_level; + } // Include attachment_uploads if provided (documents, audio, etc.) if (params.attachment_uploads !== undefined && params.attachment_uploads.length > 0) { postBody.attachment_uploads = params.attachment_uploads; @@ -183,12 +188,19 @@ export function useStream() { if (res.status === 401) throw new StreamAuthError(res.status); if (!res.ok) throw new Error(`HTTP ${res.status}`); console.log(`[TTFB] response:headers request_id=${requestID} elapsed_ms=${(performance.now() - sentAt).toFixed(1)}`); - isLoading.value = false; }, onmessage: (ev) => { if (myGeneration !== streamGeneration) return - const parsed = JSON.parse(ev.data); + // 坏帧跳过(2026-09-13 审查):裸 JSON.parse 的一帧异常会 reject + // 整条流;SSE 语义本可跳过坏帧续传。 + let parsed: any; + try { + parsed = JSON.parse(ev.data); + } catch { + console.warn('[stream] skip non-JSON SSE frame:', String(ev.data).slice(0, 120)); + return; + } // Log first answer chunk for end-to-end TTFB measurement. // Filter by event type so non-answer events (references, tool // calls, etc.) don't count as the "first token" arrival. @@ -196,7 +208,6 @@ export function useStream() { firstAnswerLogged = true; console.log(`[TTFB] response:first_answer request_id=${requestID} elapsed_ms=${(performance.now() - sentAt).toFixed(1)}`); } - buffer.push(parsed); // 数据存入缓冲 // 执行自定义处理 if (chunkHandler) { chunkHandler(parsed); @@ -209,7 +220,8 @@ export function useStream() { }, onclose: () => { - stopStream(); + // generation 守卫:被新流 abort 的旧连接走到这里时不得清理新流 + if (myGeneration === streamGeneration) stopStream(); }, }); @@ -227,8 +239,11 @@ export function useStream() { reloginMessage: i18n.global.t('error.pleaseRelogin'), }); } catch (err) { - error.value = err instanceof Error ? err.message : String(err) - stopStream() + // generation 守卫:旧流被 abort 的报错不得写入新流的 error 状态 + if (myGeneration === streamGeneration) { + error.value = err instanceof Error ? err.message : String(err) + stopStream() + } } } @@ -245,16 +260,13 @@ export function useStream() { controller.abort(); controller = new AbortController(); // 重置控制器(如需重新发起) isStreaming.value = false; - isLoading.value = false; } // 组件卸载时自动清理 onUnmounted(stopStream) return { - output, // 显示内容 isStreaming, // 是否在流式传输中 - isLoading, // 初始连接状态 error, lastStreamRequest, onChunk, diff --git a/frontend/src/api/initialization/index.ts b/frontend/src/api/initialization/index.ts index 36ded671dae..d9d4f235838 100644 --- a/frontend/src/api/initialization/index.ts +++ b/frontend/src/api/initialization/index.ts @@ -42,8 +42,10 @@ export interface InitializationConfig { }; multimodal: { enabled: boolean; - storageType: 'cos' | 'minio'; + // 后端写入 effectiveProvider,可为任意已配置的存储 provider(如 'obs') + storageType: string; vlm?: { + modelId?: string; modelName: string; baseUrl: string; /** @deprecated Use credentials.apiKey from GET responses */ @@ -179,22 +181,6 @@ export function updateKBConfig(kbId: string, config: KBModelConfigRequest): Prom } // 根据知识库ID执行配置更新(旧版,保留兼容性) -export function initializeSystemByKB(kbId: string, config: InitializationConfig): Promise { - return new Promise((resolve, reject) => { - console.log('Starting KB config update...', kbId, config); - post(`/api/v1/initialization/initialize/${kbId}`, config) - .then((response: any) => { - console.log('KB config update completed', response); - resolve(response); - }) - .catch((error: any) => { - console.error('Failed to update KB config:', error); - reject(error.error || error); - }); - }); -} - -// 检查Ollama服务状态 export function checkOllamaStatus(): Promise<{ available: boolean; version?: string; error?: string; baseUrl?: string }> { return new Promise((resolve, reject) => { get('/api/v1/initialization/ollama/status') @@ -272,35 +258,6 @@ export function getDownloadProgress(taskId: string): Promise { }); } -// 获取所有下载任务 -export function listDownloadTasks(): Promise { - return new Promise((resolve, reject) => { - get('/api/v1/initialization/ollama/download/tasks') - .then((response: any) => { - resolve(response.data || []); - }) - .catch((error: any) => { - console.error('Failed to list download tasks:', error); - reject(error); - }); - }); -} - - -export function getCurrentConfigByKB(kbId: string): Promise { - return new Promise((resolve, reject) => { - get(`/api/v1/initialization/config/${kbId}`) - .then((response: any) => { - resolve(response.data || {}); - }) - .catch((error: any) => { - console.error('Failed to get KB config:', error); - reject(error); - }); - }); -} - -// 所有"测试连接"接口共用的通用可选参数。 // customHeaders / extraConfig / interfaceType 对应后端 ModelTestRequest 里的同名字段, // 会被透传给真正的模型装配流程,保证测试连接与生产调用走完全相同的路径。 interface BaseModelTestPayload { @@ -405,100 +362,6 @@ export function checkASRModel(modelConfig: { }); } -export function testMultimodalFunction(testData: { - image: File; - vlm_model: string; - vlm_base_url: string; - vlm_api_key?: string; - vlm_interface_type?: string; - storage_type?: 'cos' | 'minio'; - // COS optional fields (required only when storage_type === 'cos') - cos_secret_id?: string; - cos_secret_key?: string; - cos_region?: string; - cos_bucket_name?: string; - cos_app_id?: string; - cos_path_prefix?: string; - // MinIO optional fields - minio_bucket_name?: string; - minio_path_prefix?: string; - chunk_size: number; - chunk_overlap: number; - separators: string[]; -}): Promise<{ - success: boolean; - caption?: string; - ocr?: string; - processing_time?: number; - message?: string; -}> { - return new Promise((resolve, reject) => { - const formData = new FormData(); - formData.append('image', testData.image); - formData.append('vlm_model', testData.vlm_model); - formData.append('vlm_base_url', testData.vlm_base_url); - if (testData.vlm_api_key) { - formData.append('vlm_api_key', testData.vlm_api_key); - } - if (testData.vlm_interface_type) { - formData.append('vlm_interface_type', testData.vlm_interface_type); - } - if (testData.storage_type) { - formData.append('storage_type', testData.storage_type); - } - // Append COS fields only when storage_type is COS - if (testData.storage_type === 'cos') { - if (testData.cos_secret_id) formData.append('cos_secret_id', testData.cos_secret_id); - if (testData.cos_secret_key) formData.append('cos_secret_key', testData.cos_secret_key); - if (testData.cos_region) formData.append('cos_region', testData.cos_region); - if (testData.cos_bucket_name) formData.append('cos_bucket_name', testData.cos_bucket_name); - if (testData.cos_app_id) formData.append('cos_app_id', testData.cos_app_id); - if (testData.cos_path_prefix) formData.append('cos_path_prefix', testData.cos_path_prefix); - } - // MinIO fields - if (testData.minio_bucket_name) formData.append('minio_bucket_name', testData.minio_bucket_name); - if (testData.minio_path_prefix) formData.append('minio_path_prefix', testData.minio_path_prefix); - formData.append('chunk_size', testData.chunk_size.toString()); - formData.append('chunk_overlap', testData.chunk_overlap.toString()); - formData.append('separators', JSON.stringify(testData.separators)); - - // 获取鉴权Token - const token = localStorage.getItem('weknora_token'); - const headers: Record = {}; - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } - - // 跨空间访问请求头:直接附,避免 short-circuit "selectedTenantId - // === defaultTenantId 时不附" 在某些边角下让 header 静默丢失。 - // 与 utils/request.ts、api/chat/streame.ts 行为一致。 - const selectedTenantId = localStorage.getItem('weknora_selected_tenant_id'); - if (selectedTenantId) { - headers['X-Tenant-ID'] = selectedTenantId; - } - - // 使用原生fetch因为需要发送FormData - fetch('/api/v1/initialization/multimodal/test', { - method: 'POST', - headers, - body: formData - }) - .then(response => response.json()) - .then((data: any) => { - if (data.success) { - resolve(data.data || {}); - } else { - resolve({ success: false, message: data.message || t('error.initialization.testFailed') }); - } - }) - .catch((error: any) => { - console.error('Failed multimodal test:', error); - reject(error); - }); - }); -} - -// 文本内容关系提取接口 export interface TextRelationExtractionRequest { text: string; tags: string[]; @@ -579,6 +442,64 @@ export function fabriTag(request: FabriTagRequest): Promise { }); } +// 能力声明分片 — 镜像后端 internal/models/provider/capabilities.go 的 JSON 形状。 +// 前端按模型 type 只取对应分片(chat/embedding/rerank/asr),零厂商字段知识。 +export interface ProviderThinkingCaps { + supported: boolean; // 思考是否可控制 + can_disable: boolean; // 是否可关闭(强制思考模型为 false) + supported_levels?: string[]; // 平台五档词表子集(low/medium/high/xhigh/max) + default_level?: string; // 用户留空时的厂商级默认档 +} + +export interface ProviderChatCaps { + thinking: ProviderThinkingCaps; + input_modalities?: string[]; // text/image/audio + protocol: string; // openai_chat / anthropic_messages / google_genai / ollama / dashscope + parallel_tool_calls: boolean; +} + +export interface ProviderEmbeddingCaps { + can_override_dimension: boolean; + dimensions?: number[]; + max_batch_size?: number; +} + +export interface ProviderCapabilities { + common: { + streaming: boolean; + health_probe: boolean; + token_counting: boolean; + usage_reporting: string; // full / partial / none + model_listing: { supported: boolean }; + }; + chat?: ProviderChatCaps; + embedding?: ProviderEmbeddingCaps; + // 镜像后端 RerankCaps / ASRCaps(internal/models/invoke/capabilities.go) + rerank?: { max_documents?: number; min_score?: number; max_score?: number }; + asr?: { audio_formats?: string[]; languages?: string[]; streaming?: boolean }; + // 厂商需要的凭证槽位;weknoracloud 声明为空数组(凭证在空间级设置)。 + credentials?: ProviderCredentialFieldSpec[]; +} + +// 厂商凭证槽位声明(design §6.8):前端据此动态渲染凭证表单。 +// 镜像后端 internal/models/invoke/capabilities.go 的 CredentialFieldSpec。 +export interface ProviderCredentialFieldSpec { + key: string; // 固定三槽:api_key / app_id / app_secret + required: boolean; + label_key?: string; // 前端 i18n key +} + +// 厂商声明的动态配置字段(如 Azure OpenAI 的 api_version)。 +export interface ProviderExtraField { + key: string; + label: string; + type: string; // "string" | "number" | "boolean" | "select" + required: boolean; + default: string; + placeholder?: string; + options?: { label: string; value: string }[]; +} + // 模型厂商信息类型 export interface ModelProviderOption { value: string; // provider 标识符 @@ -586,6 +507,8 @@ export interface ModelProviderOption { description: string; // 描述 defaultUrls: Record; // 按模型类型区分的默认 URL modelTypes: string[]; // 支持的模型类型 + capabilities?: ProviderCapabilities; // 能力声明分片(后端 EffectiveCapabilities 下发) + extraFields?: ProviderExtraField[]; // 动态配置字段 } // 获取模型厂商列表 diff --git a/frontend/src/api/model/index.ts b/frontend/src/api/model/index.ts index 4ad93f09adb..8d3d4487bdc 100644 --- a/frontend/src/api/model/index.ts +++ b/frontend/src/api/model/index.ts @@ -12,7 +12,7 @@ export interface ModelConfig { tenant_id?: number; name: string; display_name?: string; - type: 'KnowledgeQA' | 'Embedding' | 'Rerank' | 'VLLM' | 'ASR'; + type: 'KnowledgeQA' | 'Embedding' | 'Rerank' | 'ASR'; source: 'local' | 'remote'; description?: string; parameters: { @@ -24,18 +24,29 @@ export interface ModelConfig { truncate_prompt_tokens?: number; supports_dimension_override?: boolean; }; - interface_type?: 'ollama' | 'openai'; // VLLM专用 + interface_type?: 'ollama' | 'openai'; // Ollama 专用 parameter_size?: string; // Ollama模型参数大小 (e.g., "7B", "13B", "70B") extra_config?: Record; // Provider-specific configuration // 自定义 HTTP 请求头(类似 Python OpenAI SDK 的 extra_headers), // 会在调用远程模型 API 时附加到每个请求上。Authorization、Content-Type 等保留头会被忽略。 custom_headers?: Record; - supports_vision?: boolean; // Whether the model accepts image/multimodal input - // 对话/VLM 的上下文窗口(token)。0 或不填表示使用后端默认 200000。 + supports_vision?: boolean; // Deprecated: use chat.input_modalities (migration window dual-read) + // 对话模型的上下文窗口(token)。0 或不填表示使用后端默认 200000。 context_window?: number; max_output_tokens?: number; + // Chat 分片(对话模型专用):思考档位、输入模态等新参数。 + // 后端读侧优先分片、回落顶层扁平字段(迁移期双读)。 + chat?: { + context_window?: number; + max_output_tokens?: number; + thinking_enabled?: boolean; + thinking_level?: string; + selected_levels?: string[]; + parallel_tool_calls?: boolean; + input_modalities?: string[]; + }; // 后台任务(入库/富化)对该模型的并发上限,按模型 ID 全副本共享。 - // 0 或不填表示沿用全局默认(model.max_concurrency);仅对 chat/embedding/vllm 生效。 + // 0 或不填表示沿用全局默认(model.max_concurrency);仅对 chat/embedding 生效。 max_concurrency?: number; app_id?: string; // Secret fields (api_key, app_secret) are never returned by the server in @@ -49,10 +60,10 @@ export interface ModelConfig { status?: string; // Per-field configured? metadata from the main response. For builtin // models it is returned only to system administrators. - credentials?: Record; + // 后端只构造 api_key/app_secret 两键(app_id 非密钥不进此 map) + credentials?: Partial>; created_at?: string; updated_at?: string; - deleted_at?: string | null; } // 创建模型 @@ -76,13 +87,11 @@ export function createModel(data: ModelConfig): Promise { // 获取模型列表 export function listModels(type?: string): Promise { return new Promise((resolve, reject) => { - const url = `/api/v1/models`; + // 服务端类型过滤(2026-09-14 裁定 #14):?type= 下推 repo,不再全量拉取 + const url = type ? `/api/v1/models?type=${encodeURIComponent(type)}` : `/api/v1/models`; get(url) .then((response: any) => { if (response.success && response.data) { - if (type) { - response.data = response.data.filter((item: ModelConfig) => item.type === type); - } resolve(response.data); } else { resolve([]); @@ -97,24 +106,6 @@ export function listModels(type?: string): Promise { }); } -// 获取单个模型 -export function getModel(id: string): Promise { - return new Promise((resolve, reject) => { - get(`/api/v1/models/${id}`) - .then((response: any) => { - if (response.success && response.data) { - resolve(response.data); - } else { - reject(new Error(response.message || t('error.model.getFailed'))); - } - }) - .catch((error: any) => { - console.error('Failed to get model:', error); - reject(error); - }); - }); -} - // 更新模型 export function updateModel(id: string, data: Partial): Promise { return new Promise((resolve, reject) => { @@ -211,10 +202,10 @@ export async function debugModel( // shape and the design notes in internal/handler/dto/mcp.go. // ---------------------------------------------------------------------------- -export type ModelCredentialField = 'api_key' | 'app_secret' +export type ModelCredentialField = 'api_key' | 'app_id' | 'app_secret' export interface ModelCredentialsResponse { - fields: Record + fields: Partial> } export async function putModelCredentials( @@ -279,3 +270,58 @@ export function getWeKnoraCloudStatus(): Promise { }) }) } + +// ---------------------------------------------------------------------------- +// 模型目录与远端列表(design §5.10)。取值链:接口元数据 → models.json 目录 → 留空。 +// ---------------------------------------------------------------------------- + +// 厂商列表接口返回的模型条目(OpenAI /models 形状)。 +export interface RemoteCatalogModel { + id: string; + display_name?: string; + owned_by?: string; + // 接口元数据(取值链第 1 级)——后端已摊平为扁平字段(invoke.RemoteModel, + // v1 的恒空 meta 信封已删除)。多数 OpenAI 兼容列表只有 id,其余为空。 + context_window?: number; + max_output_tokens?: number; + modalities?: string[]; + thinking_levels?: string[]; + /** @deprecated 后端 meta 信封已删除,恒 undefined,勿再读取 */ + meta?: unknown; +} + +// 后端代理探测厂商模型列表(密钥不出服务端)。失败降级 available:false,不阻断创建。 +interface CatalogEnvelope { success: boolean; data: T } + +export async function probeRemoteCatalog( + body: { provider: string; base_url?: string; api_key?: string; model_id?: string; model_type?: string }, +): Promise<{ available: boolean; models?: RemoteCatalogModel[]; reason?: string }> { + const response = await post>( + '/api/v1/models/remote-catalog', + body, + ) + return response?.data ?? { available: false } +} + +// models.json 目录条目(WeKnora 自有 schema,design §5.4)。 +export interface CatalogModelEntry { + context_window?: number; + max_output_tokens?: number; + input_modalities?: string[]; + thinking?: { + supported: boolean; + can_disable: boolean; + levels?: string[]; + default_level?: string; + }; +} + +// 内置 models.json 目录(按 provider 过滤)。目录不可用只降级预填,不报错。 +export async function fetchModelCatalog( + provider: string, +): Promise<{ available: boolean; version?: string; providers?: Record }> }> { + const response = await get }> }>>( + `/api/v1/models/catalog?provider=${encodeURIComponent(provider)}`, + ) + return response?.data ?? { available: false } +} diff --git a/frontend/src/assets/img/providers/color/model/aliyun.svg b/frontend/src/assets/img/providers/color/model/aliyun.svg new file mode 100644 index 00000000000..9acccd083c8 --- /dev/null +++ b/frontend/src/assets/img/providers/color/model/aliyun.svg @@ -0,0 +1 @@ +BaiLian \ No newline at end of file diff --git a/frontend/src/assets/img/providers/color/model/anthropic.svg b/frontend/src/assets/img/providers/color/model/anthropic.svg new file mode 100644 index 00000000000..5b81844cbdc --- /dev/null +++ b/frontend/src/assets/img/providers/color/model/anthropic.svg @@ -0,0 +1 @@ +Anthropic \ No newline at end of file diff --git a/frontend/src/assets/img/providers/color/model/azure_openai.svg b/frontend/src/assets/img/providers/color/model/azure_openai.svg new file mode 100644 index 00000000000..b3df8655871 --- /dev/null +++ b/frontend/src/assets/img/providers/color/model/azure_openai.svg @@ -0,0 +1 @@ +Azure \ No newline at end of file diff --git a/frontend/src/assets/img/providers/color/model/deepseek.svg b/frontend/src/assets/img/providers/color/model/deepseek.svg new file mode 100644 index 00000000000..3fc2302406e --- /dev/null +++ b/frontend/src/assets/img/providers/color/model/deepseek.svg @@ -0,0 +1 @@ +DeepSeek \ No newline at end of file diff --git a/frontend/src/assets/img/providers/color/model/gemini.svg b/frontend/src/assets/img/providers/color/model/gemini.svg new file mode 100644 index 00000000000..62681dfa7cf --- /dev/null +++ b/frontend/src/assets/img/providers/color/model/gemini.svg @@ -0,0 +1 @@ +Gemini \ No newline at end of file diff --git a/frontend/src/assets/img/providers/color/model/generic.svg b/frontend/src/assets/img/providers/color/model/generic.svg new file mode 100644 index 00000000000..78caf4fa20f --- /dev/null +++ b/frontend/src/assets/img/providers/color/model/generic.svg @@ -0,0 +1 @@ +OpenAI \ No newline at end of file diff --git a/frontend/src/assets/img/providers/color/model/gpustack.svg b/frontend/src/assets/img/providers/color/model/gpustack.svg new file mode 100644 index 00000000000..3499244c383 --- /dev/null +++ b/frontend/src/assets/img/providers/color/model/gpustack.svg @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/img/providers/color/model/hunyuan.svg b/frontend/src/assets/img/providers/color/model/hunyuan.svg new file mode 100644 index 00000000000..42edd6cd94c --- /dev/null +++ b/frontend/src/assets/img/providers/color/model/hunyuan.svg @@ -0,0 +1 @@ +Hunyuan \ No newline at end of file diff --git a/frontend/src/assets/img/providers/color/model/jina.svg b/frontend/src/assets/img/providers/color/model/jina.svg new file mode 100644 index 00000000000..fc996bf9055 --- /dev/null +++ b/frontend/src/assets/img/providers/color/model/jina.svg @@ -0,0 +1 @@ +Jina \ No newline at end of file diff --git a/frontend/src/assets/img/providers/color/model/litellm.svg b/frontend/src/assets/img/providers/color/model/litellm.svg new file mode 100644 index 00000000000..4c54a3e491a --- /dev/null +++ b/frontend/src/assets/img/providers/color/model/litellm.svg @@ -0,0 +1,306 @@ + + +🚅 高速列车 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/img/providers/color/model/lkeap.svg b/frontend/src/assets/img/providers/color/model/lkeap.svg new file mode 100644 index 00000000000..4e7d033e8b8 --- /dev/null +++ b/frontend/src/assets/img/providers/color/model/lkeap.svg @@ -0,0 +1 @@ +TencentCloud \ No newline at end of file diff --git a/frontend/src/assets/img/providers/color/model/longcat.svg b/frontend/src/assets/img/providers/color/model/longcat.svg new file mode 100644 index 00000000000..fde6c8a364d --- /dev/null +++ b/frontend/src/assets/img/providers/color/model/longcat.svg @@ -0,0 +1 @@ +LongCat \ No newline at end of file diff --git a/frontend/src/assets/img/providers/color/model/mimo.svg b/frontend/src/assets/img/providers/color/model/mimo.svg new file mode 100644 index 00000000000..b8ba03b8719 --- /dev/null +++ b/frontend/src/assets/img/providers/color/model/mimo.svg @@ -0,0 +1 @@ +XiaomiMiMo \ No newline at end of file diff --git a/frontend/src/assets/img/providers/color/model/minimax.svg b/frontend/src/assets/img/providers/color/model/minimax.svg new file mode 100644 index 00000000000..beb7adb86ec --- /dev/null +++ b/frontend/src/assets/img/providers/color/model/minimax.svg @@ -0,0 +1 @@ +Minimax \ No newline at end of file diff --git a/frontend/src/assets/img/providers/color/model/modelscope.svg b/frontend/src/assets/img/providers/color/model/modelscope.svg new file mode 100644 index 00000000000..afbaa171a23 --- /dev/null +++ b/frontend/src/assets/img/providers/color/model/modelscope.svg @@ -0,0 +1 @@ +ModelScope \ No newline at end of file diff --git a/frontend/src/assets/img/providers/color/model/moonshot.svg b/frontend/src/assets/img/providers/color/model/moonshot.svg new file mode 100644 index 00000000000..fb56ac106c4 --- /dev/null +++ b/frontend/src/assets/img/providers/color/model/moonshot.svg @@ -0,0 +1 @@ +MoonshotAI \ No newline at end of file diff --git a/frontend/src/assets/img/providers/color/model/novita.svg b/frontend/src/assets/img/providers/color/model/novita.svg new file mode 100644 index 00000000000..0658ce0f092 --- /dev/null +++ b/frontend/src/assets/img/providers/color/model/novita.svg @@ -0,0 +1 @@ +Novita AI \ No newline at end of file diff --git a/frontend/src/assets/img/providers/color/model/nvidia.svg b/frontend/src/assets/img/providers/color/model/nvidia.svg new file mode 100644 index 00000000000..a9683c2e00d --- /dev/null +++ b/frontend/src/assets/img/providers/color/model/nvidia.svg @@ -0,0 +1 @@ +Nvidia \ No newline at end of file diff --git a/frontend/src/assets/img/providers/color/model/ollama.svg b/frontend/src/assets/img/providers/color/model/ollama.svg new file mode 100644 index 00000000000..cc887e3dcfd --- /dev/null +++ b/frontend/src/assets/img/providers/color/model/ollama.svg @@ -0,0 +1 @@ +Ollama \ No newline at end of file diff --git a/frontend/src/assets/img/providers/color/model/openai.svg b/frontend/src/assets/img/providers/color/model/openai.svg new file mode 100644 index 00000000000..78caf4fa20f --- /dev/null +++ b/frontend/src/assets/img/providers/color/model/openai.svg @@ -0,0 +1 @@ +OpenAI \ No newline at end of file diff --git a/frontend/src/assets/img/providers/color/model/openrouter.svg b/frontend/src/assets/img/providers/color/model/openrouter.svg new file mode 100644 index 00000000000..07aca9ac9cf --- /dev/null +++ b/frontend/src/assets/img/providers/color/model/openrouter.svg @@ -0,0 +1 @@ +OpenRouter \ No newline at end of file diff --git a/frontend/src/assets/img/providers/color/model/qianfan.svg b/frontend/src/assets/img/providers/color/model/qianfan.svg new file mode 100644 index 00000000000..40565efc081 --- /dev/null +++ b/frontend/src/assets/img/providers/color/model/qianfan.svg @@ -0,0 +1 @@ +BaiduCloud \ No newline at end of file diff --git a/frontend/src/assets/img/providers/color/model/qiniu.svg b/frontend/src/assets/img/providers/color/model/qiniu.svg new file mode 100644 index 00000000000..8057b68d2bf --- /dev/null +++ b/frontend/src/assets/img/providers/color/model/qiniu.svg @@ -0,0 +1 @@ +Qiniu \ No newline at end of file diff --git a/frontend/src/assets/img/providers/color/model/requesty.svg b/frontend/src/assets/img/providers/color/model/requesty.svg new file mode 100644 index 00000000000..2ac7d3863b2 --- /dev/null +++ b/frontend/src/assets/img/providers/color/model/requesty.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/img/providers/color/model/siliconflow.svg b/frontend/src/assets/img/providers/color/model/siliconflow.svg new file mode 100644 index 00000000000..6b5f6d805d0 --- /dev/null +++ b/frontend/src/assets/img/providers/color/model/siliconflow.svg @@ -0,0 +1 @@ +SiliconCloud \ No newline at end of file diff --git a/frontend/src/assets/img/providers/color/model/volcengine.svg b/frontend/src/assets/img/providers/color/model/volcengine.svg new file mode 100644 index 00000000000..ecf6d75e0aa --- /dev/null +++ b/frontend/src/assets/img/providers/color/model/volcengine.svg @@ -0,0 +1 @@ +Volcengine \ No newline at end of file diff --git a/frontend/src/assets/img/providers/color/model/zhipu.svg b/frontend/src/assets/img/providers/color/model/zhipu.svg new file mode 100644 index 00000000000..0c6e61cea8a --- /dev/null +++ b/frontend/src/assets/img/providers/color/model/zhipu.svg @@ -0,0 +1 @@ +Zhipu \ No newline at end of file diff --git a/frontend/src/components/Input-field.focus.test.mjs b/frontend/src/components/Input-field.focus.test.mjs index 560c2a3cc40..391d8f8c5aa 100644 --- a/frontend/src/components/Input-field.focus.test.mjs +++ b/frontend/src/components/Input-field.focus.test.mjs @@ -24,6 +24,11 @@ for (const mode of ['normal', 'embedded', 'after', 'inject']) { settingsStore: {}, chatResources: { isFresh: () => true }, collectAgentNotReadyReasons: () => ({ keys: [], labels: [] }), attachmentUploadRef: { value: null }, + // The unified-thinking emit adds a 6th argument read from these refs + // (declared outside the sliced region); false sends an empty level. + sessionThinkingActive: { value: false }, + sessionThinkingTouched: { value: false }, + sessionThinking: { value: { enabled: false, level: '' } }, emit: name => effects.push([name]), clearvalue: () => effects.push(['clear']), getTextareaEl: () => textarea, diff --git a/frontend/src/components/Input-field.vue b/frontend/src/components/Input-field.vue index 32e8ff2e05c..9e7d0bfa896 100644 --- a/frontend/src/components/Input-field.vue +++ b/frontend/src/components/Input-field.vue @@ -31,6 +31,8 @@ import { useChatResourcesStore } from '@/stores/chatResources'; import { useEditorResourcesStore } from '@/stores/editorResources'; import { useI18n } from 'vue-i18n'; import AttachmentUpload, { type AttachmentFile } from './AttachmentUpload.vue'; +import ThinkingControls from './ThinkingControls.vue'; +import { listModelProviders, type ModelProviderOption } from '@/api/initialization'; import { kbSatisfiesAgentRequirements, deriveKbFilterForAgent, @@ -68,8 +70,7 @@ const { chatModels: availableModels, webSearchProviders, } = storeToRefs(chatResources); -const { t, locale } = useI18n(); - +const { t, te, locale } = useI18n(); let query = ref(""); const showKbSelector = ref(false); @@ -1092,6 +1093,111 @@ const selectedModel = computed(() => { return availableModels.value.find(model => model.id === selectedModelId.value); }); +// ---- 会话级思考覆盖(design §8.1.3):内存态不持久化。面板初始展示智能体 +// 的思考设置;用户改动(touched)后才随 QA 请求发送 thinking(布尔,缺省 +// 跟随智能体/全局默认)与 thinking_level——未触碰时不发送任何思考字段。 ---- +const sessionThinkingTouched = ref(false); +const sessionThinking = ref<{ enabled: boolean; level: string }>({ enabled: false, level: '' }); +const showThinkingPanel = ref(false); +const chatProviderOptions = ref([]); + +const loadThinkingCaps = async () => { + try { + chatProviderOptions.value = await listModelProviders('chat'); + } catch (e) { + console.error('Failed to load chat provider capabilities', e); + } +}; + +const selectedModelThinkingCaps = computed(() => { + const provider = selectedModel.value?.parameters.provider; + if (!provider) return undefined; + return chatProviderOptions.value.find(p => p.value === provider)?.capabilities?.chat?.thinking; +}); + +const selectedModelChatShard = computed(() => selectedModel.value?.parameters.chat); + +/** 未声明思考的模型整块隐藏(触发按钮 + 面板)。模型记录主开关关闭 + (chat 分片 thinking_enabled === false,模型管理页拍板)同样隐藏—— + 运行时入口已硬门(foldChatOptions),UI 随动不再发送思考字段。布尔思考 + (SupportedLevels 空,如 ollama think)同样支持——会话 API 已传输 + thinking 布尔,面板渲染纯开关(ThinkingControls single 形态对空档位不 + 渲染选择器)。 */ +const thinkingSupported = computed(() => + selectedModelThinkingCaps.value?.supported === true + && selectedModelChatShard.value?.thinking_enabled !== false); + +/** single 档位 options = (分片子集 ?? 厂商枚举) ∩ 厂商枚举(与 ThinkingControls 内规则一致)。 */ +const sessionThinkingLevelOptions = computed(() => { + const levels = selectedModelThinkingCaps.value?.supported_levels ?? []; + const shard = selectedModelChatShard.value?.selected_levels ?? []; + return shard.length ? levels.filter(l => shard.includes(l)) : levels; +}); + +/** 智能体侧的思考默认(自定义智能体 config;内置普通模式回落关闭)。 */ +const agentThinkingDefault = computed(() => ({ + enabled: selectedAgent.value?.config?.thinking === true, + level: selectedAgent.value?.config?.thinking_level || '', +})); + +/** 展示态:未触碰 = 智能体默认(档位缺省回落厂商 default_level,且必须在 + 当前可选集合内,否则回落"跟随模型默认"占位);触碰后 = 用户的选择。 */ +const displayedThinking = computed(() => { + if (sessionThinkingTouched.value) return sessionThinking.value; + const options = sessionThinkingLevelOptions.value; + const capsDefault = selectedModelThinkingCaps.value?.default_level || ''; + const level = options.includes(agentThinkingDefault.value.level) ? agentThinkingDefault.value.level + : options.includes(capsDefault) ? capsDefault : ''; + return { enabled: agentThinkingDefault.value.enabled, level }; +}); + +const sessionThinkingEffectiveEnabled = computed(() => displayedThinking.value.enabled); +const sessionThinkingActive = computed(() => sessionThinkingEffectiveEnabled.value); + +const sessionThinkingTriggerLabel = computed(() => { + if (!sessionThinkingEffectiveEnabled.value) return t('thinking.levelLabel'); + const level = displayedThinking.value.level; + if (!level) return t('thinking.levelLabel'); + const key = `model.editor.thinkingLevels.${level}`; + return `${t('thinking.levelLabel')} · ${te(key) ? t(key) : level}`; +}); + +const sessionThinkingValue = computed({ + get: () => ({ enabled: sessionThinkingEffectiveEnabled.value, level: displayedThinking.value.level }), + set: (v) => { + sessionThinkingTouched.value = true; + sessionThinking.value = { enabled: v.enabled ?? false, level: v.level || '' }; + }, +}); + +/** 清回"跟随智能体/模型默认"(未触碰态:不发任何思考字段)。 */ +const clearSessionThinking = () => { + sessionThinkingTouched.value = false; + sessionThinking.value = { enabled: false, level: '' }; +}; + +// 会话中切模型:触碰过的档位 ∉ 新模型集合 → 清回默认态 + toast; +// 面板隐藏(模型不支持思考)时残留覆盖同样会随请求发出且用户不可见,一并清。 +watch(selectedModelId, () => { + if (!sessionThinkingTouched.value) return; + const options = sessionThinkingLevelOptions.value; + if (sessionThinking.value.level + && (options.length === 0 || !options.includes(sessionThinking.value.level))) { + clearSessionThinking(); + MessagePlugin.warning(t('input.thinkingLevelResetToast')); + } +}); + +// 切智能体:面板默认展示跟随新智能体的思考设置。 +watch(selectedAgentId, () => { + clearSessionThinking(); +}); + +// 会话切换:会话级覆盖是内存态,不跨会话携带 +watch(() => props.sessionId, () => { + clearSessionThinking(); +}); + // 模型展示名:本空间列表中有则用名称;若为共享智能体且其 model_id 不在本空间列表中则显示“共享智能体配置的模型” const selectedModelDisplayName = computed(() => { if (selectedModel.value) return modelDisplayName(selectedModel.value); @@ -1843,6 +1949,7 @@ onMounted(() => { loadChatModels(), loadAgents(), loadMCPServices(), + loadThinkingCaps(), ]); window.addEventListener(CHAT_FILE_DROP_EVENT, handleChatFileDrop as EventListener); @@ -1948,7 +2055,7 @@ watch([selectedKbIds, selectedFileIds], ([kbIds, fileIds]) => { }, { deep: true }); const emit = defineEmits<{ - (e: 'send-msg', query: string, modelId: string, mentionedItems: MentionRequestItem[], imageFiles: File[], attachmentFiles: AttachmentFile[]): void; + (e: 'send-msg', query: string, modelId: string, mentionedItems: MentionRequestItem[], imageFiles: File[], attachmentFiles: AttachmentFile[], thinkingLevel?: string, thinkingEnabled?: boolean): void; (e: 'stop-generation'): void; (e: 'stop-confirmed'): void; (e: 'stop-failed'): void; @@ -2080,7 +2187,12 @@ const createSession = async (val: string, delivery: 'inject' | 'after' = 'after' const imageFiles = uploadedImages.value.map(img => img.file); const attachmentFiles = uploadedAttachments.value; - emit('send-msg', val, selectedModelId.value, mentionedItems, imageFiles, attachmentFiles); + // Detach-safety now lives in the teardown blur (blur the active textarea + // before unmount); sending must keep focus — the composer restores/keeps + // it across the DOM update (upstream focus contract). + emit('send-msg', val, selectedModelId.value, mentionedItems, imageFiles, attachmentFiles, + sessionThinkingTouched.value && thinkingSupported.value && sessionThinking.value.enabled ? sessionThinking.value.level : '', + sessionThinkingTouched.value && thinkingSupported.value ? sessionThinking.value.enabled : undefined); // Clean up image previews uploadedImages.value.forEach(img => URL.revokeObjectURL(img.preview)); @@ -2855,6 +2967,29 @@ defineExpose({ + + + +
+
+ + {{ sessionThinkingTriggerLabel }} +
+
+ +
@@ -3695,6 +3830,12 @@ const getImgSrc = (url: string) => { margin-left: auto; flex-shrink: 0; + /* 思考触发器是右侧第二个元素:两个 auto 边距会把剩余空间从中间平分, + 造成模型芯片与思考按钮之间的大空洞(2026-09-14 反馈)——改固定间距 */ + &.model-display--thinking { + margin-left: 6px; + } + &.agent-controlled { .model-selector-trigger { cursor: not-allowed; @@ -3729,6 +3870,20 @@ const getImgSrc = (url: string) => { } } +.thinking-trigger { + min-width: 0; + + &.active { + border-color: var(--td-brand-color); + color: var(--td-brand-color); + + .model-selector-name { + color: var(--td-brand-color); + } + } +} + + .model-selector-name { flex: 1; font-size: 12px; @@ -4064,3 +4219,43 @@ const getImgSrc = (url: string) => { } } + + + diff --git a/frontend/src/components/ModelDebugDrawer.vue b/frontend/src/components/ModelDebugDrawer.vue index 0b562dea1ec..35a0fac272c 100644 --- a/frontend/src/components/ModelDebugDrawer.vue +++ b/frontend/src/components/ModelDebugDrawer.vue @@ -95,14 +95,14 @@

{{ $t('modelSettings.debug.documentsHint') }}

-
+
@@ -204,7 +204,7 @@ import { copyWithToast } from '@/utils/clipboard' import SettingDrawer from '@/components/settings/SettingDrawer.vue' import { debugModel, type ModelConfig, type ModelDebugResult } from '@/api/model' import { fileSizeVerification } from '@/utils' -import { modelSupportsThinking } from '@/utils/thinkingControl' +import { listModelProviders, type ProviderCapabilities } from '@/api/initialization' import { formatContextWindow, modelHasContextWindow, @@ -251,8 +251,29 @@ let runSequence = 0 const selectedModel = computed(() => props.models.find(model => model.id === selectedModelId.value)) const filteredModels = computed(() => props.models.filter(model => model.type === selectedModelType.value)) const isChat = computed(() => selectedModel.value?.type === 'KnowledgeQA') -const supportsThinking = computed(() => selectedModel.value ? modelSupportsThinking(selectedModel.value) : false) -const needsFile = computed(() => ['VLLM', 'ASR'].includes(selectedModel.value?.type || '')) +// 思考开关按厂商能力声明判断(/models/providers),不再镜像后端厂商分支。 +// 能力清单不可用时退回显示开关:调试抽屉是自证工具,后端会如实回报 +// thinking_parameter_sent 观测。 +const providerCaps = ref>({}) +watch(() => props.visible, async (visible) => { + if (!visible || Object.keys(providerCaps.value).length > 0) return + try { + const providers = await listModelProviders() + providerCaps.value = Object.fromEntries(providers.map(p => [p.value, p.capabilities])) + } catch { + providerCaps.value = {} + } +}) +const supportsThinking = computed(() => { + const model = selectedModel.value + if (!model || model.type !== 'KnowledgeQA' || model.source !== 'remote') return false + const caps = providerCaps.value[model.parameters.provider || ''] + return caps ? (caps.chat?.thinking.supported ?? false) : true +}) +// ADR 0004: 图像理解不是类型而是对话模型的能力,调试抽屉按声明能力出表单。 +const isVisionModel = computed(() => selectedModel.value?.parameters?.supports_vision === true) +const fileAccept = computed(() => (isVisionModel.value ? 'image/*' : 'audio/*')) +const needsFile = computed(() => selectedModel.value?.type === 'ASR') const documents = computed(() => documentsText.value.split('\n').map(item => item.trim()).filter(Boolean)) const canRun = computed(() => { if (!selectedModel.value) return false @@ -267,7 +288,6 @@ const allModelTypeOptions = computed(() => { KnowledgeQA: { short: 'chat', icon: 'chat' }, Embedding: { short: 'embedding', icon: 'chart-bubble' }, Rerank: { short: 'rerank', icon: 'filter-sort' }, - VLLM: { short: 'vllm', icon: 'image' }, ASR: { short: 'asr', icon: 'sound' }, } return (Object.keys(keys) as DebugModelType[]).map(value => ({ @@ -295,19 +315,19 @@ const vendorLabel = (model: ModelConfig) => { const inputLabel = computed(() => { if (selectedModel.value?.type === 'Embedding') return t('modelSettings.debug.embeddingInput') - if (selectedModel.value?.type === 'VLLM') return t('modelSettings.debug.vlmPrompt') + if (isVisionModel.value && file.value) return t('modelSettings.debug.vlmPrompt') if (selectedModel.value?.type === 'Rerank') return t('modelSettings.debug.query') return t('modelSettings.debug.query') }) const inputPlaceholder = computed(() => { if (selectedModel.value?.type === 'Embedding') return t('modelSettings.debug.embeddingPlaceholder') - if (selectedModel.value?.type === 'VLLM') return t('modelSettings.debug.vlmPromptPlaceholder') + if (isVisionModel.value && file.value) return t('modelSettings.debug.vlmPromptPlaceholder') return t('modelSettings.debug.queryPlaceholder') }) const fileLabel = computed(() => - selectedModel.value?.type === 'VLLM' + isVisionModel.value ? t('modelSettings.debug.imageFile') : t('modelSettings.debug.audioFile'), ) diff --git a/frontend/src/components/ModelEditorDialog.vue b/frontend/src/components/ModelEditorDialog.vue index 30add26856c..32e6e744be6 100644 --- a/frontend/src/components/ModelEditorDialog.vue +++ b/frontend/src/components/ModelEditorDialog.vue @@ -22,27 +22,7 @@ - - - +

{{ $t('model.editor.sectionType') }}

@@ -64,109 +44,11 @@
-

{{ $t('model.editor.sectionSource') }}

- -
- -
- - -
- - -
- - {{ $t('model.editor.ollamaNotSupportRerank') }} -
- - -
- - {{ $t('model.editor.ollamaUnavailable') }} - - - {{ $t('model.editor.goToOllamaSettings') }} - -
-
- - -
- -
- - - -
- - {{ model.name }} - {{ formatModelSize(model.size) }} -
-
- - - -
- - {{ $t('model.editor.downloadLabel', { keyword: searchKeyword }) }} -
-
- - - -
- - - - - {{ $t('model.editor.refreshList') }} - -
-
-
- - - - -
- - + + + + +
+ + +

{{ $t('model.editor.displayNameDesc') }}

+
+ + + + - -
-

{{ $t('model.editor.sectionAdvanced') }}

+ + +
+

{{ $t('model.editor.sectionParameters') }}

@@ -353,58 +339,79 @@
- + {{ $t('model.editor.dimensionOverrideDesc') }}
+

{{ $t('model.editor.dimensionOverrideDisabledHint') }}

- -
- + +
+ + :placeholder="$t('model.editor.contextWindowPlaceholder', { value: DEFAULT_MODEL_CONTEXT_WINDOW })" + @change="markManualField('contextWindow')" />

{{ $t('model.editor.contextWindowDesc') }}

-
- -
- - {{ $t('model.editor.supportsVisionDesc') }} -
+ + +

{{ $t('model.editor.maxOutputTokensDesc') }}

- -
- - - -
- {{ opt.label }} - {{ opt.hint }} -
-
-
-

{{ $t('model.editor.thinkingControlDesc') }}

+ +
+ + + + {{ $t('model.editor.modalityText') }} + {{ $t('model.editor.modalityImage') }} + {{ $t('model.editor.modalityAudio') }} + +

{{ $t('model.editor.inputModalitiesDesc') }}

+ + + +
+ + +
+

{{ $t('model.editor.sectionAdvanced') }}

} -type EditorModelType = 'chat' | 'embedding' | 'rerank' | 'vllm' | 'asr' +type EditorModelType = 'chat' | 'embedding' | 'rerank' | 'asr' interface Props { visible: boolean modelType: EditorModelType modelData?: ModelFormData | null - saveModel: (data: ModelFormData & { modelType?: EditorModelType }) => Promise } const { t, te } = useI18n() @@ -495,6 +513,7 @@ const props = withDefaults(defineProps(), { const emit = defineEmits<{ 'update:visible': [value: boolean] + 'confirm': [data: ModelFormData & { modelType?: EditorModelType }] }>() const draftModelType = ref(props.modelType) @@ -509,7 +528,6 @@ const modelTypeChoices = computed(() => ([ { value: 'chat' as const, label: t('modelSettings.typeShort.chat'), icon: 'chat' }, { value: 'embedding' as const, label: t('modelSettings.typeShort.embedding'), icon: 'chart-bubble' }, { value: 'rerank' as const, label: t('modelSettings.typeShort.rerank'), icon: 'filter-sort' }, - { value: 'vllm' as const, label: t('modelSettings.typeShort.vllm'), icon: 'image' }, { value: 'asr' as const, label: t('modelSettings.typeShort.asr'), icon: 'sound' }, ])) @@ -517,147 +535,26 @@ const modelTypeChoices = computed(() => ([ const apiProviderOptions = ref([]) const loadingProviders = ref(false) -// 硬编码的后备 Provider 配置 (当 API 不可用时使用) -const fallbackProviderOptions = computed(() => [ - { - value: 'openai', - label: t('model.editor.providers.openai.label'), - defaultUrls: { - chat: 'https://api.openai.com/v1', - embedding: 'https://api.openai.com/v1', - rerank: 'https://api.openai.com/v1', - vllm: 'https://api.openai.com/v1', - asr: 'https://api.openai.com/v1' - }, - description: t('model.editor.providers.openai.description'), - modelTypes: ['chat', 'embedding', 'vllm', 'asr'] - }, - { - value: 'azure_openai', - label: t('model.editor.providers.azure_openai.label'), - defaultUrls: { - chat: 'https://{resource}.openai.azure.com', - embedding: 'https://{resource}.openai.azure.com', - vllm: 'https://{resource}.openai.azure.com', - asr: 'https://{resource}.openai.azure.com' - }, - description: t('model.editor.providers.azure_openai.description'), - modelTypes: ['chat', 'embedding', 'vllm', 'asr'] - }, - { - value: 'aliyun', - label: t('model.editor.providers.aliyun.label'), - defaultUrls: { - chat: 'https://dashscope.aliyuncs.com/compatible-mode/v1', - embedding: 'https://dashscope.aliyuncs.com/compatible-mode/v1', - rerank: 'https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank', - vllm: 'https://dashscope.aliyuncs.com/compatible-mode/v1' - }, - description: t('model.editor.providers.aliyun.description'), - modelTypes: ['chat', 'embedding', 'rerank', 'vllm'] - }, - { - value: 'zhipu', - label: t('model.editor.providers.zhipu.label'), - defaultUrls: { - chat: 'https://open.bigmodel.cn/api/paas/v4', - embedding: 'https://open.bigmodel.cn/api/paas/v4/embeddings', - vllm: 'https://open.bigmodel.cn/api/paas/v4' - }, - description: t('model.editor.providers.zhipu.description'), - modelTypes: ['chat', 'embedding', 'vllm'] - }, - { - value: 'openrouter', - label: t('model.editor.providers.openrouter.label'), - defaultUrls: { - chat: 'https://openrouter.ai/api/v1', - embedding: 'https://openrouter.ai/api/v1' - }, - description: t('model.editor.providers.openrouter.description'), - modelTypes: ['chat', 'embedding'] - }, - { - value: 'litellm', - label: t('model.editor.providers.litellm.label'), - defaultUrls: { - chat: 'http://your_litellm_proxy/v1', - embedding: 'http://your_litellm_proxy/v1', - vllm: 'http://your_litellm_proxy/v1' - }, - description: t('model.editor.providers.litellm.description'), - modelTypes: ['chat', 'embedding', 'vllm'] - }, - { - value: 'requesty', - label: t('model.editor.providers.requesty.label'), - defaultUrls: { - chat: 'https://router.requesty.ai/v1', - embedding: 'https://router.requesty.ai/v1' - }, - description: t('model.editor.providers.requesty.description'), - modelTypes: ['chat', 'embedding'] - }, - { - value: 'gemini', - label: t('model.editor.providers.gemini.label'), - defaultUrls: { - chat: 'https://generativelanguage.googleapis.com/v1beta/openai', - embedding: 'https://generativelanguage.googleapis.com/v1beta' - }, - description: t('model.editor.providers.gemini.description'), - modelTypes: ['chat', 'embedding'] - }, - { - value: 'siliconflow', - label: t('model.editor.providers.siliconflow.label'), - defaultUrls: { - chat: 'https://api.siliconflow.cn/v1', - embedding: 'https://api.siliconflow.cn/v1', - rerank: 'https://api.siliconflow.cn/v1' - }, - description: t('model.editor.providers.siliconflow.description'), - modelTypes: ['chat', 'embedding', 'rerank'] - }, - { - value: 'jina', - label: t('model.editor.providers.jina.label'), - defaultUrls: { - embedding: 'https://api.jina.ai/v1', - rerank: 'https://api.jina.ai/v1' - }, - description: t('model.editor.providers.jina.description'), - modelTypes: ['embedding', 'rerank'] - }, - { - value: 'nvidia', - label: t('model.editor.providers.nvidia.label'), - defaultUrls: { - chat: 'https://integrate.api.nvidia.com/v1', - embedding: 'https://integrate.api.nvidia.com/v1', - rerank: 'https://ai.api.nvidia.com/v1/retrieval/nvidia/reranking', - vllm: 'https://integrate.api.nvidia.com/v1', - }, - description: t('model.editor.providers.nvidia.description'), - modelTypes: ['chat', 'embedding', 'rerank', 'vllm'] - }, - { - value: 'novita', - label: t('model.editor.providers.novita.label'), - defaultUrls: { - chat: 'https://api.novita.ai/openai/v1', - embedding: 'https://api.novita.ai/openai/v1', - vllm: 'https://api.novita.ai/openai/v1', - }, - description: t('model.editor.providers.novita.description'), - modelTypes: ['chat', 'embedding', 'vllm'] - }, +// 兜底厂商:仅保留「自定义 (OpenAI 兼容)」。预置厂商清单以能力声明 API +// (/models/providers)为唯一来源;API 不可用时长尾厂商不再前端硬编码, +// 用户可先用 generic 手填 Base URL 接入。 +const fallbackProviderOptions = computed(() => [ { value: 'generic', label: t('model.editor.providers.generic.label'), defaultUrls: {}, description: t('model.editor.providers.generic.description'), - modelTypes: ['chat', 'embedding', 'rerank', 'vllm', 'asr'] + modelTypes: ['chat', 'embedding', 'rerank', 'asr'] + }, + { + value: 'ollama', + label: t('model.editor.providers.ollama.label'), + defaultUrls: { + chat: 'http://localhost:11434', + embedding: 'http://localhost:11434', + } as Record, + description: t('model.editor.providers.ollama.description'), + modelTypes: ['chat', 'embedding'] }, ]) @@ -666,9 +563,9 @@ const loadProviders = async () => { loadingProviders.value = true try { const providers = await listModelProviders(activeModelType.value) - if (providers.length > 0) { - apiProviderOptions.value = providers - } + // 无条件赋值(2026-09-13 审查):失败/空返回时保留旧列表会让用户给 + // embedding 挑一个 chat 专属厂商;清空后 computed 自动回落 generic 兜底。 + apiProviderOptions.value = providers } catch (error) { console.error('Failed to load providers from API, using fallback', error) } finally { @@ -703,52 +600,268 @@ const dialogVisible = computed({ if (!saving.value) emit('update:visible', val) } }) +/** 正在从 modelData 灌入表单,忽略厂商/来源控件的程序化 change 副作用 */ +const hydratingForm = ref(false) -const showThinkingControlField = computed(() => - activeModelType.value === 'chat' && formData.value.source === 'remote', +// ---- 能力声明(来源:/models/providers,前端零厂商字段知识) ---- +const activeProviderCaps = computed(() => + apiProviderOptions.value.find(p => p.value === formData.value.provider)?.capabilities ) -const resolvedThinkingControl = (): ThinkingControlValue => - defaultThinkingControl( - formData.value.provider || '', - formData.value.modelName || '', - ) +// #15:厂商 LOGO 查询(assets/img/providers/*/model/.svg) +const providerLogoMatch = (value: string) => { + try { + return providerLogo('model', value) + } catch { + return undefined + } +} -/** 用户是否手动改过思考参数格式(改过则不再自动覆盖,直到换服务商) */ -const thinkingControlManual = ref(false) -/** 正在从 modelData 灌入表单,忽略厂商/来源控件的程序化 change 副作用 */ -const hydratingForm = ref(false) +/** 当前厂商的动态扩展字段(跳过已有专属控件/专属汇入逻辑的键)。 */ +const activeExtraFields = computed(() => { + const provider = providerOptions.value.find(p => p.value === formData.value.provider) + return (provider?.extraFields ?? []).filter(f => { + if (f.key === 'region' && isLkeapRerank.value) return false // lkeapRegion 专属控件 + return true + }) +}) + +/** Chat 类型远程模型的思考能力声明(选项/开关的渲染依据)。 */ +const chatThinkingCaps = computed(() => ( + activeModelType.value === 'chat' && formData.value.source === 'remote' + ? activeProviderCaps.value?.chat?.thinking + : undefined +)) + +const showThinkingSection = computed(() => chatThinkingCaps.value?.supported === true) + +/** ThinkingControls(levels 形态)的双向桥:值落在 chat 分片对应表单字段上。 */ +const thinkingControlsValue = computed({ + get: () => ({ + enabled: formData.value.thinkingEnabled, + level: formData.value.thinkingLevel, + selectedLevels: formData.value.selectedLevels, + }), + set: (v) => { + formData.value.thinkingEnabled = v.enabled + formData.value.thinkingLevel = v.level ?? '' + formData.value.selectedLevels = v.selectedLevels ?? [] + }, +}) + +/** 组件字段名 → 预填追踪字段名(徽章来源标注清除)。 */ +const onThinkingManual = (field: 'enabled' | 'selectedLevels' | 'level') => { + markManualField(field === 'enabled' ? 'thinkingEnabled' : field === 'level' ? 'thinkingLevel' : 'selectedLevels') +} + + +const dimensionOverrideDisabled = computed(() => + activeModelType.value === 'embedding' + && activeProviderCaps.value?.embedding + && activeProviderCaps.value.embedding.can_override_dimension === false +) -const onThinkingControlManualPick = () => { - thinkingControlManual.value = true +// ---- 目录预填(design §5.3 取值链 / ADR 0001 保存即终态) ---- +type PrefillSource = 'catalog' | 'remote' +const PREFILL_FIELDS = ['contextWindow', 'maxOutputTokens', 'inputModalities', 'selectedLevels', 'thinkingLevel', 'thinkingEnabled', 'displayName'] as const +type PrefillField = (typeof PREFILL_FIELDS)[number] +const PREFILL_BADGE_KEYS: Record = { + catalog: 'model.editor.sourceCatalog', + remote: 'model.editor.sourceRemote', +} + +const prefillSource = ref>>({}) +const manualFields = ref>(new Set()) +/** 编辑已存模型打开后为 true:已存值优先,预填不覆盖;换模型 ID 视为新选择后解除(D1 推论)。 */ +const prefillBlocked = ref(false) + +const prefillBadge = (field: PrefillField) => { + const src = prefillSource.value[field] + return src ? t(PREFILL_BADGE_KEYS[src]) : '' +} + +const markManualField = (field: PrefillField) => { + manualFields.value.add(field) + delete prefillSource.value[field] +} + +function applyPrefill(meta: CatalogModelEntry, source: PrefillSource) { + if (prefillBlocked.value) return + const f = formData.value + const set = (field: PrefillField, hasValue: boolean, assign: () => void) => { + // 手改 > 已预填(接口元数据优先于目录) > 未填 + if (!hasValue || manualFields.value.has(field) || prefillSource.value[field]) return + assign() + prefillSource.value[field] = source + } + set('contextWindow', !!meta.context_window, () => { f.contextWindow = meta.context_window }) + set('maxOutputTokens', !!meta.max_output_tokens, () => { f.maxOutputTokens = meta.max_output_tokens }) + set('inputModalities', !!meta.input_modalities?.length, () => { + // 词表白名单(ADR 0004):checkbox-group 的 v-model 不剪组外值,REPLACE + // 语义会把目录声明的 video 等废弃值原样保存回来——预填即过滤,恒保 text。 + f.inputModalities = (meta.input_modalities as string[]) + .filter(m => m === 'text' || m === 'image' || m === 'audio') + if (!f.inputModalities.includes('text')) f.inputModalities.unshift('text') + f.supportsVision = f.inputModalities.includes('image') + }) + if (meta.thinking?.supported && showThinkingSection.value) { + const providerLevels = chatThinkingCaps.value?.supported_levels + const levels = (meta.thinking.levels ?? []).filter( + l => !providerLevels?.length || providerLevels.includes(l), + ) + set('selectedLevels', levels.length > 0, () => { f.selectedLevels = [...levels] }) + const defaultLevel = meta.thinking.default_level + set('thinkingLevel', !!defaultLevel && levels.includes(defaultLevel), () => { + f.thinkingLevel = defaultLevel || '' + if (f.thinkingLevel && f.thinkingEnabled === undefined) f.thinkingEnabled = true + }) + } } -const syncThinkingControlToForm = (force = false) => { - if (!showThinkingControlField.value) return - if (!force && !isEdit.value && thinkingControlManual.value) return - formData.value.thinkingControl = resolvedThinkingControl() +// ---- 远端模型列表探测(可搜索下拉数据源,POST /models/remote-catalog) ---- +const remoteModels = ref([]) +const probingRemoteModels = ref(false) +let probeSequence = 0 + +// 2026-09-12 裁定③:所有远程模型列表加载按当前编辑的模型类型过滤—— +// 五类型全放开远程下拉(探测请求携带 model_type;无过滤能力的厂商由 +// 适配器忽略,失败降级手输不变)。 +const showRemoteModelSelect = computed(() => + !isOllamaProvider.value + && formData.value.provider !== 'weknoracloud' +) + +// #反馈(2026-09-14):服务端类型过滤只有 aliyun 能力码生效,其余厂商的 +// 目录接口不带类型元数据——按模型名启发式在客户端过滤(creatable 手输 +// 仍是逃逸口)。chat 做排除式(避免误杀命名特别的对话模型)。 +const REMOTE_MODEL_TYPE_HINTS: Record = { + embedding: /embed/i, + rerank: /rerank/i, + asr: /(whisper|paraformer|sensevoice|\basr\b|transcri|speech)/i, +} +const remoteModelMatchesType = (id: string, type: string): boolean => { + const lower = id.toLowerCase() + const isEmbed = /embed/i.test(lower) + const isRerank = /rerank/i.test(lower) + const isAsrTts = /(whisper|paraformer|sensevoice|asr|transcri|speech|tts|audio)/i.test(lower) + switch (type) { + case 'embedding': + return isEmbed + case 'rerank': + return isRerank + case 'asr': + return isAsrTts + default: // chat:排除明确的非对话族 + return !isEmbed && !isRerank && !isAsrTts + } +} + +const remoteModelOptions = computed(() => + remoteModels.value + .filter(m => remoteModelMatchesType(m.id, activeModelType.value)) + .map(m => ({ value: m.id, label: m.id })) +) + +const onRemoteModelCreate = (value: string | number) => { + formData.value.modelName = String(value) } -const applyThinkingControlFromModelData = () => { - if (!props.modelData || activeModelType.value !== 'chat' || formData.value.source !== 'remote') return - thinkingControlManual.value = !!props.modelData.thinkingControl - formData.value.thinkingControl = resolveThinkingControl( - props.modelData.thinkingControl, - formData.value.provider || props.modelData.provider || '', - formData.value.modelName || props.modelData.modelName || '', - ) +const probeRemoteModels = async () => { + if (!showRemoteModelSelect.value || !formData.value.provider) return + // Base URL 未填时不发探测(后端只会报 "base URL is required",纯噪音); + // 清空下拉即可,等地址落定后由 watch 触发真正的探测。 + if (!(formData.value.baseUrl || '').trim()) { + remoteModels.value = [] + return + } + const seq = ++probeSequence + probingRemoteModels.value = true + try { + // 编辑模式 apiKey 不在表单里:传 model_id 让后端用存储凭证兜底 + const payload = isEdit.value && props.modelData?.id + ? { provider: formData.value.provider, model_id: props.modelData.id, base_url: formData.value.baseUrl || '', model_type: activeModelType.value } + : { provider: formData.value.provider, base_url: formData.value.baseUrl || '', api_key: formData.value.apiKey || '', model_type: activeModelType.value } + const result = await probeRemoteCatalog(payload) + if (seq !== probeSequence) return + remoteModels.value = result.available ? (result.models ?? []) : [] + } catch { + if (seq === probeSequence) remoteModels.value = [] + } finally { + if (seq === probeSequence) probingRemoteModels.value = false + } } -const thinkingControlOptions = computed(() => { - const keys = ['none', 'chatTemplateKwargs', 'enableThinking', 'thinkingType'] as const - const values = ['none', 'chat_template_kwargs', 'enable_thinking', 'thinking_type'] as const - return keys.map((key, i) => ({ - value: values[i], - label: t(`model.editor.thinkingControl.${key}.label`), - hint: t(`model.editor.thinkingControl.${key}.hint`), - })) +// ---- models.json 目录查询(GET /models/catalog?provider=X) ---- +const catalogCache = ref>>({}) + +const normalizeModelId = (id: string) => + id.trim().toLowerCase().replace(/-latest$/i, '').replace(/-\d{8}$/i, '') + +const lookupCatalogEntry = (provider: string, modelId: string): CatalogModelEntry | null => { + const models = catalogCache.value[provider] + if (!models) return null + const id = modelId.trim() + if (!id) return null + return models[id] ?? models[id.toLowerCase()] ?? models[normalizeModelId(id)] ?? null +} + +const ensureCatalogLoaded = async (provider: string) => { + if (!provider || catalogCache.value[provider]) return + try { + const result = await fetchModelCatalog(provider) + if (result.available && result.providers) { + catalogCache.value = { + ...catalogCache.value, + ...Object.fromEntries( + Object.entries(result.providers).map(([key, value]) => [key, value.models ?? {}]), + ), + } + } else { + catalogCache.value[provider] = {} + } + } catch { + catalogCache.value[provider] = {} + } +} + +// 后端 RemoteModel 摊平字段 → 目录条目形状(applyPrefill 的输入)。 +// 后端不发 thinking 的 supported/can_disable/default_level:有 levels 即视为 +// supported,can_disable 取宽(可被取值链第 2 级目录修正)。 +const remoteModelPrefill = (m: RemoteCatalogModel): CatalogModelEntry => ({ + context_window: m.context_window, + max_output_tokens: m.max_output_tokens, + input_modalities: m.modalities, + thinking: m.thinking_levels?.length + ? { supported: true, can_disable: true, levels: m.thinking_levels } + : undefined, }) +/** 换模型 ID = 新选择:按取值链预填(接口元数据 → 目录 → 留空)。 */ +const prefillForModelId = async (modelId: string) => { + if (!showRemoteModelSelect.value || !modelId.trim() || !formData.value.provider) return + // 新一轮预填:清掉上一轮的来源标注(手动字段保留标注清除权) + for (const field of PREFILL_FIELDS) { + if (!manualFields.value.has(field)) delete prefillSource.value[field] + } + const provider = formData.value.provider + const id = modelId.trim() + // 1) 接口元数据(后端已摊平为扁平字段,meta 信封已删除——取值链第 1 级) + const remoteHit = remoteModels.value.find(m => m.id === id) + if (remoteHit) applyPrefill(remoteModelPrefill(remoteHit), 'remote') + // 展示名 → 显示名称(2026-09-14 反馈):选模型即带出厂商的展示名; + // 手动改过 displayName 的不再覆盖。 + if (remoteHit?.display_name && !manualFields.value.has('displayName')) { + formData.value.displayName = remoteHit.display_name + prefillSource.value.displayName = 'remote' + } + // 2) models.json 目录(补齐接口元数据未覆盖的字段) + await ensureCatalogLoaded(provider) + const entry = lookupCatalogEntry(provider, id) + if (entry) applyPrefill(entry, 'catalog') +} + + + // Header icon for the SettingDrawer — uses the same TDesign icon name table // as the model card list, so the drawer's leading badge visually matches the // card the user just clicked on. @@ -757,7 +870,6 @@ const modelTypeIcon = computed(() => { chat: 'chat', embedding: 'chart-bubble', rerank: 'filter-sort', - vllm: 'image', asr: 'sound', } return map[activeModelType.value] || 'setting' @@ -798,24 +910,93 @@ const signedRerankCredentialHint = computed(() => ( : t('model.editor.lkeap.rerankCredentialHint') )) -// Credential resource binding for the shared component. +// ---- 凭证表单动态渲染(design §6.8:按所选厂商 Credentials spec,切厂商字段组跟随) ---- + +/** 三槽固定词表(后端 CredentialFieldSpec.Key):槽位 → 表单字段。 */ +const CREDENTIAL_SLOT_FIELDS: Record = { + api_key: 'apiKey', + app_id: 'appId', + app_secret: 'appSecret', +} +const CREDENTIAL_SLOT_KEYS = Object.keys(CREDENTIAL_SLOT_FIELDS) as ModelCredentialField[] + +/** 厂商声明的凭证槽位 spec;能力声明未加载(API 兜底路径)时为 undefined。 */ +const providerCredentialSpec = computed(() => activeProviderCaps.value?.credentials) + +/** + * 槽位 label:签名 rerank 专属标注 > spec.label_key > 槽位默认文案。 + * api_key 的"(可选)"后缀随 spec 的必填声明走(2026-09-13 反馈 #2): + * required=true → 「API Key」;false → 「API Key(可选)」;spec 未加载 + * (undefined)→ 中性「API Key」,不声称可选。 + */ +const credentialLabel = (key: ModelCredentialField, labelKey?: string, required?: boolean): string => { + if (isSignedRerank.value && key === 'api_key') return signedRerankAccessKeyLabel.value + if (isSignedRerank.value && key === 'app_secret') return signedRerankSecretKeyLabel.value + if (labelKey && te(labelKey)) return t(labelKey) + if (key === 'app_id') return t('model.editor.appIdLabel') + if (key === 'app_secret') return t('model.editor.appSecretLabel') + if (required === false) return t('model.editor.apiKeyOptional') + return t('model.credentials.apiKey') +} + const credentialFields = computed[]>(() => { - const fields: CredentialFieldDef[] = [ - { - key: 'api_key', - label: (isSignedRerank.value - ? signedRerankAccessKeyLabel.value - : t('model.editor.apiKeyOptional')) as string, - }, - ] - if (formData.value.provider === 'weknoracloud') { - fields.push({ key: 'app_secret', label: 'App Secret' }) - } else if (isSignedRerank.value) { - fields.push({ key: 'app_secret', label: signedRerankSecretKeyLabel.value as string }) + const spec = providerCredentialSpec.value + if (!spec) { + // 兜底:能力声明不可用时维持 v1 字段组 + const fields: CredentialFieldDef[] = [ + { key: 'api_key', label: credentialLabel('api_key') }, + ] + if (isSignedRerank.value) { + fields.push({ key: 'app_secret', label: signedRerankSecretKeyLabel.value as string }) + } + return fields } + const fields = spec + .filter(f => (CREDENTIAL_SLOT_KEYS as string[]).includes(f.key)) + .map(f => ({ + key: f.key as ModelCredentialField, + label: credentialLabel(f.key as ModelCredentialField, f.label_key, f.required), + })) return fields }) +/** spec 为权威可见性来源(weknoracloud spec=[] → 整块隐藏);spec 未加载时沿用 v1 判断。 */ +const credentialBlockVisible = computed(() => { + if (formData.value.source !== 'remote') return false + if (providerCredentialSpec.value) return credentialFields.value.length > 0 + return formData.value.provider !== 'weknoracloud' +}) + +const isCredentialRequired = (key: string): boolean => { + // 签名 rerank(lkeap/volcengine):app_secret 在 rerank 调用侧必填 + //(SecretId/SecretKey 缺一即构造失败);spec 是 provider 级声明、无类型 + // 维度——类型相关的必填在这里补齐(2026-09-14 裁定)。 + if (key === 'app_secret' && isSignedRerank.value) return true + return providerCredentialSpec.value?.find(f => f.key === key)?.required === true +} + +const credentialValue = (key: string) => + formData.value[CREDENTIAL_SLOT_FIELDS[key]] as string | undefined ?? '' + +const setCredentialValue = (key: string, value: string) => { + ;(formData.value as any)[CREDENTIAL_SLOT_FIELDS[key]] = value +} + +const credentialPlaceholder = (key: string) => { + if (key === 'api_key' && isSignedRerank.value) return signedRerankAccessKeyPlaceholder.value + if (key === 'app_secret' && isSignedRerank.value) return signedRerankSecretKeyPlaceholder.value + return t('model.editor.apiKeyPlaceholder') +} + +// Create 模式密码输入的明文预览开关(按槽位记忆,关抽屉时清空)。 +const revealedCredentialKeys = ref>(new Set()) +const toggleCredentialReveal = (key: string) => { + const next = new Set(revealedCredentialKeys.value) + if (next.has(key)) next.delete(key) + else next.add(key) + revealedCredentialKeys.value = next +} + const credentialApi = computed>(() => { const id = props.modelData?.id ?? '' return { @@ -832,37 +1013,19 @@ const credentialApi = computed>(() = // Initial credential metadata. ModelSettings.convertToLegacyFormat // preserves `credentials` from the main ListModels response so the card // renders the correct "Configured" state on dialog open. -const credentialMeta = computed(() => (props.modelData as any)?.credentials ?? { - api_key: { configured: false }, - app_secret: { configured: false }, +const credentialMeta = computed(() => { + const stored = (props.modelData as any)?.credentials ?? {} + const meta: Record = {} + for (const field of credentialFields.value) { + meta[field.key] = stored[field.key] ?? { configured: false } + } + return meta }) -// Placeholder hint for the create-mode API key input. Edit mode replaces -// this input entirely with a card. -const apiKeyPlaceholder = computed(() => t('model.editor.apiKeyPlaceholder')) - const formRef = ref() const saving = ref(false) -const saveError = ref('') - -// Settings itself listens on window for Escape. Capture it while saving so -// the parent cannot unmount this editor before the request finishes. -const handleSaveEscape = (event: KeyboardEvent) => { - if (props.visible && saving.value && (event.key === 'Escape' || event.code === 'Escape')) { - event.preventDefault() - event.stopImmediatePropagation() - } -} -watch(() => props.visible && saving.value, (locked) => { - if (locked) window.addEventListener('keydown', handleSaveEscape, true) - else window.removeEventListener('keydown', handleSaveEscape, true) -}, { flush: 'sync' }) -// Toggles the create-mode API key input between masked and plain text. Lets -// the user proofread a freshly pasted secret without losing the password -// affordance for everyday use. Reset every time the drawer closes (see -// reset block in the visible watcher) so we never leak the previous value -// across editor sessions. -const showApiKey = ref(false) +// Create 模式明文预览开关 revealedCredentialKeys 声明见凭证动态渲染区; +// 每次抽屉关闭时重置(见 visible watcher 的 reset 块),避免跨编辑会话残留。 const modelChecked = ref(false) const modelAvailable = ref(false) const checking = ref(false) @@ -948,6 +1111,7 @@ const formData = ref({ displayName: '', baseUrl: '', apiKey: '', + appId: '', dimension: undefined, supportsDimensionOverride: false, interfaceType: 'ollama', @@ -955,52 +1119,16 @@ const formData = ref({ supportsVision: false, contextWindow: undefined, maxConcurrency: undefined, - thinkingControl: defaultThinkingControl('generic', ''), + maxOutputTokens: undefined, + thinkingEnabled: undefined, + thinkingLevel: '', + selectedLevels: [], + inputModalities: ['text'], customHeaders: [], appSecret: '', lkeapRegion: 'ap-guangzhou', }) -const rules = computed(() => ({ - modelName: [ - { required: true, message: t('model.editor.validation.modelNameRequired') }, - { - validator: (val: string) => { - if (!val || !val.trim()) { - return { result: false, message: t('model.editor.validation.modelNameEmpty') } - } - if (val.trim().length > 100) { - return { result: false, message: t('model.editor.validation.modelNameMax') } - } - return { result: true } - }, - trigger: 'blur' - } - ], - baseUrl: [ - { - required: true, - message: t('model.editor.validation.baseUrlRequired'), - trigger: 'blur' - }, - { - validator: (val: string) => { - if (!val || !val.trim()) { - return { result: false, message: t('model.editor.validation.baseUrlEmpty') } - } - // 简单的 URL 格式校验 - try { - new URL(val.trim()) - return { result: true } - } catch { - return { result: false, message: t('model.editor.validation.baseUrlInvalid') } - } - }, - trigger: 'blur' - } - ] -})) - // 获取弹窗描述文字 const getModalDescription = () => { const key = `model.editor.description.${activeModelType.value}` as const @@ -1009,11 +1137,6 @@ const getModalDescription = () => { // 获取模型名称占位符 const getModelNamePlaceholder = () => { - if (activeModelType.value === 'vllm') { - return formData.value.source === 'local' - ? t('model.editor.modelNamePlaceholder.localVllm') - : t('model.editor.modelNamePlaceholder.remoteVllm') - } if (activeModelType.value === 'asr') { return t('model.editor.modelNamePlaceholder.remoteAsr') } @@ -1023,9 +1146,6 @@ const getModelNamePlaceholder = () => { } const getBaseUrlPlaceholder = () => { - if (activeModelType.value === 'vllm') { - return t('model.editor.baseUrlPlaceholderVllm') - } if (activeModelType.value === 'asr') { return t('model.editor.baseUrlPlaceholderAsr') } @@ -1091,7 +1211,6 @@ const selectModelType = async (type: EditorModelType) => { } if (type !== 'chat') { formData.value.supportsVision = false - thinkingControlManual.value = false } remoteChecked.value = false remoteAvailable.value = false @@ -1105,10 +1224,6 @@ const selectModelType = async (type: EditorModelType) => { } else { handleProviderChange(formData.value.provider || 'generic') } - if (showThinkingControlField.value && !isEdit.value) { - thinkingControlManual.value = false - syncThinkingControlToForm(true) - } } // 监听 visible 变化,初始化表单 @@ -1130,6 +1245,10 @@ watch(() => props.visible, (val) => { dimensionChecked.value = false dimensionSuccess.value = false dimensionMessage.value = '' + // 编辑路径不走 resetForm,上一会话探测出的远端模型列表会跨会话残留, + // 用户可能选中错误厂商的模型 ID(2026-09-13 审查) + remoteModels.value = [] + probingRemoteModels.value = false const currentId = props.modelData?.id ?? null draftModelType.value = props.modelType @@ -1143,11 +1262,16 @@ watch(() => props.visible, (val) => { formData.value = { ...props.modelData, apiKey: '', + appId: '', + appSecret: '', customHeaders: Array.isArray(props.modelData.customHeaders) ? props.modelData.customHeaders.map(h => ({ key: h.key, value: h.value })) : [], + inputModalities: props.modelData.inputModalities + ?? (props.modelData.supportsVision ? ['text', 'image'] : ['text']), } - applyThinkingControlFromModelData() + // 已存值优先:预填不覆盖(D1 保存即终态);换模型 ID 时解除 + prefillBlocked.value = true } else if (lastOpenedModelId.value !== null || !formData.value.id) { // 上次是编辑某个模型,或第一次新增 → 重置成空白 resetForm() @@ -1156,50 +1280,42 @@ watch(() => props.visible, (val) => { lastOpenedModelId.value = currentId - // ReRank 模型强制使用 remote 来源(Ollama 不支持 ReRank) - if (activeModelType.value === 'rerank') { - formData.value.source = 'remote' - } - // 如果当前 provider 是 WeKnoraCloud,检查凭证状态 if (formData.value.provider === 'weknoracloud') { checkWkcCredentialStatus() } - if (showThinkingControlField.value && !isEdit.value) { - thinkingControlManual.value = false - syncThinkingControlToForm(true) - } } finally { nextTick(() => { hydratingForm.value = false + // 编辑打开的灌入被 probe watch 跳过(防灌水期逐字段触发),灌完必须 + // 踢一脚首次探测,否则编辑态模型下拉要等用户改了某个字段才加载 + // (2026-09-15 真机:点开已存模型下拉为空,清空重输才出现列表)。 + // 探测函数自带编辑态凭证兜底(model_id);失败仅置空,与原行为一致。 + if (showRemoteModelSelect.value && formData.value.provider) void probeRemoteModels() }) } } }) -watch( - () => [ - activeModelType.value, props.modelData?.id, formData.value.source, - formData.value.provider, formData.value.modelName, formData.value.baseUrl, - formData.value.apiKey, formData.value.appSecret, formData.value.customHeaders, - formData.value.dimension, formData.value.supportsDimensionOverride, - formData.value.lkeapRegion, formData.value.thinkingControl, - ], - () => { - if (!applyingDetectedDimension) invalidateConnectionTest(props.visible && !hydratingForm.value) - }, - { deep: true, flush: 'sync' }, -) +// 「模型来源」控件已退役(2026-09-13 裁定):ollama 即本地、其余即远程。 +const isOllamaProvider = computed(() => formData.value.provider === 'ollama') -watch(() => props.visible, () => { - invalidateConnectionTest(false) - saveError.value = '' -}, { flush: 'sync' }) +// source 派生自 provider(含灌入期——存量 local 记录若 provider 不一致会被 +// 纠正为派生值,payload 出口再兜底一次)。 +// 输入模态自由编辑(2026-09-13 裁定 #2/#3):模型列表/目录未提供模态 +// 时不限制用户勾选;文本恒选(chat facet 公理,ADR 0004)。supportsVision +// 保持与模态数组同步(列表页图标消费它)。 +watch(() => formData.value.inputModalities, (mods) => { + formData.value.supportsVision = !!mods?.includes('image') +}) + +watch(() => formData.value.provider, (p) => { + formData.value.source = p === 'ollama' ? 'local' : 'remote' +}) // 重置表单 const resetForm = () => { - thinkingControlManual.value = false formData.value = { id: generateId(), name: '', // 保留字段但不使用,保存时用 modelName @@ -1209,6 +1325,7 @@ const resetForm = () => { displayName: '', baseUrl: '', apiKey: '', + appId: '', dimension: undefined, // 默认不填,让用户手动输入或通过检测按钮获取 supportsDimensionOverride: false, interfaceType: undefined, @@ -1216,7 +1333,11 @@ const resetForm = () => { supportsVision: false, contextWindow: undefined, maxConcurrency: undefined, - thinkingControl: defaultThinkingControl('generic', ''), + maxOutputTokens: undefined, + thinkingEnabled: undefined, + thinkingLevel: '', + selectedLevels: [], + inputModalities: ['text'], customHeaders: [], appSecret: '', lkeapRegion: 'ap-guangzhou', @@ -1229,17 +1350,32 @@ const resetForm = () => { dimensionChecked.value = false dimensionSuccess.value = false dimensionMessage.value = '' - showApiKey.value = false + revealedCredentialKeys.value = new Set() + // 预填/探测状态一并清空 + manualFields.value = new Set() + prefillSource.value = {} + prefillBlocked.value = false + remoteModels.value = [] + probingRemoteModels.value = false } - // 处理厂商选择变化 (自动填充默认 URL) const handleProviderChange = (value: string) => { const provider = providerOptions.value.find(opt => opt.value === value) if (provider && provider.defaultUrls) { - // 根据当前模型类型获取对应的默认 URL + // 根据当前模型类型获取对应的默认 URL;无默认值时清空——残留上一家 + // 厂商的地址会把 A 家的 key 发到 B 家的域(2026-09-14 裁定) const defaultUrl = provider.defaultUrls[activeModelType.value] if (defaultUrl) { formData.value.baseUrl = defaultUrl + } else if (value !== 'weknoracloud') { + formData.value.baseUrl = '' + } + // 动态扩展字段默认值预填(如 azure api_version 2024-10-21) + for (const f of provider.extraFields ?? []) { + if (f.default && !formData.value.extraConfig?.[f.key]) { + if (!formData.value.extraConfig) formData.value.extraConfig = {} + formData.value.extraConfig[f.key] = f.default + } } if (value === 'lkeap' && activeModelType.value === 'rerank' && !formData.value.modelName?.trim()) { formData.value.modelName = 'lke-reranker-base' @@ -1256,44 +1392,71 @@ const handleProviderChange = (value: string) => { if (value === 'weknoracloud') { checkWkcCredentialStatus() } - if (hydratingForm.value) return - if (activeModelType.value !== 'chat' || formData.value.source !== 'remote') return - if (!isEdit.value) { - thinkingControlManual.value = false - syncThinkingControlToForm(true) - return - } - // 编辑时仅用户主动换厂商才跟随默认 - thinkingControlManual.value = false - syncThinkingControlToForm(true) } -watch( - () => [formData.value.source, formData.value.provider, formData.value.modelName] as const, - ([source, provider, modelName], [prevSource, prevProvider, prevModelName]) => { - if (hydratingForm.value || isEdit.value) return - if (activeModelType.value !== 'chat' || source !== 'remote') return - if (source === prevSource && provider === prevProvider && modelName === prevModelName) return - - const providerChanged = provider !== prevProvider +let probeTimer: ReturnType | null = null - if (providerChanged) { - thinkingControlManual.value = false - syncThinkingControlToForm(true) - return - } - if (!thinkingControlManual.value) { - syncThinkingControlToForm(true) - return +watch( + () => [formData.value.source, formData.value.provider, formData.value.modelName, activeModelType.value, + formData.value.baseUrl, formData.value.apiKey] as const, + ([source, provider, modelName, modelType, baseUrl, apiKey], + [prevSource, prevProvider, prevModelName, prevModelType, prevBaseUrl, prevApiKey]) => { + if (hydratingForm.value) return + if (source === prevSource && provider === prevProvider && modelName === prevModelName + && modelType === prevModelType && baseUrl === prevBaseUrl && apiKey === prevApiKey) return + + // 远端模型列表探测(防抖):新建用已填 base_url+api_key,编辑用存储凭证; + // 切换模型类型同样重探(裁定③:列表按当前编辑类型过滤) + if (showRemoteModelSelect.value) { + if (probeTimer) clearTimeout(probeTimer) + probeTimer = setTimeout(() => { void probeRemoteModels() }, 500) } - const prevDefault = defaultThinkingControl(prevProvider || '', prevModelName || '') - if (formData.value.thinkingControl === prevDefault) { - syncThinkingControlToForm(true) + + // 换模型 ID = 新选择:按取值链重新预填(D1 推论);仅换来源/厂商不触发 + if (modelName !== prevModelName) { + prefillBlocked.value = false + void prefillForModelId(modelName) } }, ) -// 监听来源变化,重置校验状态(已合并到下面的 watch) +// 厂商 caps 晚到(/models/providers 异步返回常慢于探测/目录):思考区翻真 +// 时重跑一次预填——applyPrefill 的 manualFields 守卫保证不覆盖手动值 +// (2026-09-13 审查:此前预填只在 modelName 变化时执行一次,慢网下静默丢失)。 +// 注意注册位置:watch 源在注册时立即求值一次,任何(传递性)读取 formData +// 的 watch 必须放在 const formData 声明之后——放早了会在 setup 期 TDZ 崩溃 +// (2026-09-13 真机回归:"添加模型"打不开,Cannot access 'formData' before +// initialization;最初插在 prefillForModelId 之后、formData 声明之前)。 +watch(showThinkingSection, (supported) => { + if (supported && !hydratingForm.value) { + void prefillForModelId(formData.value.modelName) + } +}) + +// 连接字段编辑即失效旧结果(上游 fbad5b37 语义):displayName 等展示字段 +// 不失效;检测回填维度(applyingDetectedDimension)期间不失效,防自触发。 +watch( + () => [ + activeModelType.value, props.modelData?.id, formData.value.source, + formData.value.provider, formData.value.modelName, formData.value.baseUrl, + formData.value.apiKey, formData.value.appSecret, formData.value.customHeaders, + formData.value.dimension, formData.value.supportsDimensionOverride, + formData.value.lkeapRegion, + // (main's list also had formData.thinkingControl — that field exists only + // in main's ModelFormData; our thinking config rides + // thinkingEnabled/thinkingLevel/selectedLevels below.) + ], + () => { + if (!applyingDetectedDimension) invalidateConnectionTest(props.visible && !hydratingForm.value) + }, + { deep: true, flush: 'sync' }, +) + +watch(() => props.visible, () => { + invalidateConnectionTest(false) +}, { flush: 'sync' }) + + // 生成唯一ID const generateId = () => { @@ -1491,6 +1654,10 @@ const checkRemoteAPI = async () => { provider: formData.value.provider, ...idPayload, ...headerPayload, + // azure api_version 等动态扩展字段随测试连接透传 + ...(formData.value.extraConfig && Object.keys(formData.value.extraConfig).length > 0 + ? { extraConfig: { ...formData.value.extraConfig } } + : {}), }) break @@ -1539,19 +1706,6 @@ const checkRemoteAPI = async () => { break } - case 'vllm': - // VLLM 模型(多模态) - // VLLM 使用 checkRemoteModel 进行基础连接测试 - result = await checkRemoteModel({ - modelName: formData.value.modelName, - baseUrl: formData.value.baseUrl || '', - apiKey: formData.value.apiKey || '', - provider: formData.value.provider, - ...idPayload, - ...headerPayload, - }) - break - case 'asr': // ASR 模型(语音识别)— 使用专用的 ASR 测试接口(/v1/audio/transcriptions) result = await checkASRModel({ @@ -1590,7 +1744,6 @@ const handleConfirm = async () => { if (saving.value) return saving.value = true if (checking.value) invalidateConnectionTest() - saveError.value = '' try { // 手动校验必填字段 if (!formData.value.modelName || !formData.value.modelName.trim()) { @@ -1619,9 +1772,39 @@ const handleConfirm = async () => { } } - // 执行表单验证 - const validation = await formRef.value?.validate() - if (validation !== undefined && validation !== true) return + // 创建模式:spec 标记 required 的凭证槽位必须填写(编辑模式凭证由 + // CredentialResource 独立管理,不在此校验)。 + if (!isEdit.value && credentialBlockVisible.value) { + const missing = credentialFields.value.find( + f => isCredentialRequired(f.key) && !credentialValue(f.key).trim(), + ) + if (missing) { + MessagePlugin.warning( + t('model.editor.validation.credentialRequired', { field: missing.label }), + ) + return + } + } + + // 数字字段显式校验(2026-09-13 审查):t-input 的 :min/:max 只约束步进 + // 箭头、挡不住键入——越界值此前被父组件静默丢弃(卡片显示默认值)或 + // 原样发往后端。字段留空仍交给父组件的门控语义。 + // 运行时形态是 number | ''(清空输入)| undefined,统一按 unknown 比较 + const cw = formData.value.contextWindow as unknown + if (cw !== '' && cw != null && (Number.isNaN(Number(cw)) || Number(cw) < 1024 || Number(cw) > 10000000)) { + MessagePlugin.warning(t('model.editor.validation.contextWindowRange')) + return + } + const mo = formData.value.maxOutputTokens as unknown + if (mo !== '' && mo != null && (Number.isNaN(Number(mo)) || Number(mo) < 1)) { + MessagePlugin.warning(t('model.editor.validation.maxOutputTokensRange')) + return + } + const mc = formData.value.maxConcurrency as unknown + if (mc !== '' && mc != null && (Number.isNaN(Number(mc)) || Number(mc) < 0)) { + MessagePlugin.warning(t('model.editor.validation.maxConcurrencyRange')) + return + } // Credential removal in edit mode is handled inline by the // CredentialResource card (it confirms + DELETEs to /credentials), so @@ -1632,18 +1815,17 @@ const handleConfirm = async () => { formData.value.id = generateId() } - await props.saveModel({ + emit('confirm', { ...formData.value, + source: isOllamaProvider.value ? 'local' : 'remote', ...(isEdit.value ? {} : { modelType: activeModelType.value }), }) - emit('update:visible', false) - invalidateConnectionTest(false) - // 保存成功后重置草稿,下次打开新增模型时是空白 - resetForm() - lastOpenedModelId.value = null + // D1(2026-09-14 裁定):保存失败不关抽屉——关闭与重置改由父组件在 + // 保存成功后调用 resetAfterSave()(此前 emit 后无条件关闭,校验/API + // 失败时用户输入全丢)。 // 移除此处的成功提示,由父组件统一处理 - } catch (error: any) { - saveError.value = error?.message || t('modelSettings.toasts.saveFailed') + } catch (error) { + console.error('表单验证失败:', error) } finally { saving.value = false } @@ -1651,6 +1833,7 @@ const handleConfirm = async () => { // 监听模型选择变化(处理下载逻辑和自动维度检测提示) watch(() => formData.value.modelName, async (newValue, oldValue) => { + if (hydratingForm.value) return // 编辑打开的灌入不触发下载/维度提示副作用 if (!newValue) return // 处理下载逻辑 @@ -1706,8 +1889,12 @@ const startDownload = async (modelName: string) => { // 刷新模型列表 await loadOllamaModels() - // 自动选中新下载的模型 - formData.value.modelName = modelName + // 自动选中新下载的模型——仅当抽屉仍打开且还在 local 新建流程: + // ESC/遮罩关闭不清理下载轮询,完成后若用户已改开编辑表单,这里 + // 的无条件回填会把别人家表单的 modelName 写坏(2026-09-13 审查)。 + if (props.visible && formData.value.source === 'local' && !isEdit.value) { + formData.value.modelName = modelName + } // 重置状态 downloadProgress.value = 0 @@ -1738,7 +1925,6 @@ const startDownload = async (modelName: string) => { // 组件卸载时清理定时器 onUnmounted(() => { - window.removeEventListener('keydown', handleSaveEscape, true) invalidateConnectionTest(false) if (downloadInterval) { clearInterval(downloadInterval) @@ -1767,19 +1953,11 @@ watch(() => formData.value.source, () => { downloadProgress.value = 0 currentDownloadModel.value = '' - if ( - !hydratingForm.value - && !isEdit.value - && formData.value.source === 'remote' - && activeModelType.value === 'chat' - ) { - thinkingControlManual.value = false - syncThinkingControlToForm(true) - } }) // 监听模型名称变化,清理维度检测状态 watch(() => formData.value.modelName, () => { + if (hydratingForm.value) return // 编辑打开的灌入不是用户输入 dimensionChecked.value = false dimensionSuccess.value = false dimensionMessage.value = '' @@ -1792,6 +1970,17 @@ const handleCancel = () => { lastOpenedModelId.value = null dialogVisible.value = false } + +/** + * D1 契约:父组件在保存成功(createModel/updateModel resolve)后调用。 + * 关抽屉 + 清草稿;失败路径不调用 → 抽屉保持打开、输入保留。 + */ +const resetAfterSave = () => { + dialogVisible.value = false + resetForm() + lastOpenedModelId.value = null +} +defineExpose({ resetAfterSave }) diff --git a/frontend/src/components/credentials/CredentialResource.vue b/frontend/src/components/credentials/CredentialResource.vue index aaf082f740a..9cf47e038ff 100644 --- a/frontend/src/components/credentials/CredentialResource.vue +++ b/frontend/src/components/credentials/CredentialResource.vue @@ -151,8 +151,9 @@ export interface CredentialFieldDef { export interface CredentialResourceApi { // PUT /credentials — body keyed by field name, value is the new secret. - // Returns the updated per-field configured map. - save: (patch: Partial>) => Promise> + // Returns the updated per-field configured map (Partial: the backend only + // constructs the secret slots it manages, e.g. api_key/app_secret). + save: (patch: Partial>) => Promise>> // DELETE /credentials/:field remove: (field: K) => Promise } diff --git a/frontend/src/components/modelEditorFlow.test.ts b/frontend/src/components/modelEditorFlow.test.ts index d87f52a16da..9887ae8df13 100644 --- a/frontend/src/components/modelEditorFlow.test.ts +++ b/frontend/src/components/modelEditorFlow.test.ts @@ -50,7 +50,7 @@ async function fixture(options: { } const exports: any = {} runInNewContext(compiled, { - exports, URL, setInterval, clearInterval, + exports, URL, setInterval, clearInterval, setTimeout, clearTimeout, window: { addEventListener() {}, removeEventListener() {} }, console: { error() {}, debug() {}, log() {} }, require(name: string) { @@ -77,11 +77,28 @@ async function fixture(options: { parentNode: () => null, nextSibling: () => null, }) let vm: any + let parentSave: Promise = Promise.resolve() + let lastSaveError: Error | null = null const app = renderer.createApp({ setup: () => () => h(exports.default, { ...props, ref: (value: any) => { vm = value }, - saveModel: async (payload: any) => { saves.push(payload); await options.save?.(payload) }, + // Our contract: the editor emits `confirm`; the PARENT persists, then + // closes the drawer and clears the draft on success only (D1 ruling). + // ModelSettings' failure path rethrows and leaves the drawer untouched. + 'onConfirm': (payload: any) => { + saves.push(payload) + parentSave = (async () => { + try { + await options.save?.(payload) + props.visible = false + vm?.resetAfterSave?.() + } catch (error) { + lastSaveError = error as Error + throw error + } + })() + }, 'onUpdate:visible': (value: boolean) => { visibility.push(value); props.visible = value }, }), }) @@ -92,10 +109,10 @@ async function fixture(options: { Object.assign(vm.formData, { modelName: 'draft-model', baseUrl: 'https://example.com/v1', apiKey: options.edit ? '' : 'draft-key' }) vm.formRef = { validate: async () => true } await nextTick() - return { vm, props, requests, saves, toasts, visibility, close: () => app.unmount() } + return { vm, props, requests, saves, toasts, visibility, parentSave: () => parentSave, lastSaveError: () => lastSaveError, close: () => app.unmount() } } -for (const type of ['chat', 'embedding', 'rerank', 'vllm', 'asr']) { +for (const type of ['chat', 'embedding', 'rerank', 'asr']) { test(`${type}: connection tests use unsaved form values without saving`, async () => { const f = await fixture({ type }) try { @@ -234,42 +251,31 @@ for (const source of ['remote', 'local']) { }) } -test('save waits for success, blocks duplicate saves and cancellation, then closes and clears the draft', async () => { - const response = deferred() - const f = await fixture({ save: () => response.promise }) +test('confirm emits the payload once; the parent closes the drawer and clears the draft on success', async () => { + const f = await fixture({ save: async () => {} }) try { - const pending = f.vm.handleConfirm() - await nextTick() - assert.equal(f.vm.saving, true) - assert.equal(f.props.visible, true) - assert.equal(f.vm.formData.modelName, 'draft-model') await f.vm.handleConfirm() - f.vm.handleCancel() - f.vm.dialogVisible = false assert.equal(f.saves.length, 1) - assert.equal(f.visibility.length, 0) - let stopped = false - f.vm.handleSaveEscape({ key: 'Escape', preventDefault() {}, stopImmediatePropagation() { stopped = true } }) - assert.equal(stopped, true, 'Escape must not bubble to the outer Settings modal while saving') - response.resolve() - await pending - assert.equal(f.props.visible, false) - assert.equal(f.vm.formData.modelName, '') + await f.parentSave() + assert.equal(f.props.visible, false, 'only the parent closes the drawer (D1)') + assert.equal(f.vm.formData.modelName, '', 'the parent clears the draft after success') assert.equal(f.vm.saving, false) } finally { f.close() } }) -test('failed saves retain draft and drawer, show the error and allow a successful retry', async () => { +test('failed saves retain draft and drawer and allow a successful retry', async () => { let calls = 0 const f = await fixture({ save: async () => { if (++calls === 1) throw new Error('save rejected') } }) try { await f.vm.handleConfirm() - assert.equal(f.props.visible, true) + await assert.rejects(f.parentSave(), /save rejected/) + assert.ok(f.lastSaveError(), 'the parent surfaces the error to its own toast layer') + assert.equal(f.props.visible, true, 'D1: a failed save keeps the drawer open') assert.equal(f.vm.formData.modelName, 'draft-model') assert.equal(f.vm.formData.apiKey, 'draft-key') - assert.equal(f.vm.saveError, 'save rejected') assert.equal(f.vm.saving, false) await f.vm.handleConfirm() + await f.parentSave() assert.equal(f.props.visible, false) } finally { f.close() } }) @@ -277,11 +283,14 @@ test('failed saves retain draft and drawer, show the error and allow a successfu test('failed form validation prevents persistence and leaves the drawer open', async () => { const f = await fixture() try { - f.vm.formRef = { validate: async () => ({ modelName: [{ result: false }] }) } + // Our dialog validates explicitly (no formRef.validate): an empty model + // name must warn, persist nothing, and keep the drawer open. + f.vm.formData.modelName = ' ' await f.vm.handleConfirm() assert.equal(f.saves.length, 0) assert.equal(f.props.visible, true) assert.equal(f.vm.saving, false) + assert.ok(f.toasts.some((toast: string) => toast.includes('modelNameRequired')), String(f.toasts)) } finally { f.close() } }) @@ -290,6 +299,7 @@ test('failed connection test does not prevent saving', async () => { try { await f.vm.checkRemoteAPI() await f.vm.handleConfirm() + await f.parentSave() assert.equal(f.saves.length, 1) assert.equal(f.props.visible, false) } finally { f.close() } diff --git a/frontend/src/components/modelEditorSourceState.ts b/frontend/src/components/modelEditorSourceState.ts index 11afbadf4cd..ae87ab87f6a 100644 --- a/frontend/src/components/modelEditorSourceState.ts +++ b/frontend/src/components/modelEditorSourceState.ts @@ -1,6 +1,6 @@ export type ModelEditorSource = 'local' | 'remote' -export type ModelEditorType = 'chat' | 'embedding' | 'rerank' | 'vllm' | 'asr' +export type ModelEditorType = 'chat' | 'embedding' | 'rerank' | 'asr' export function shouldShowOllamaUnavailableTip( source: ModelEditorSource, diff --git a/frontend/src/components/modelSelectorFilter.test.ts b/frontend/src/components/modelSelectorFilter.test.ts index 359f71d5949..50e7e0521a8 100644 --- a/frontend/src/components/modelSelectorFilter.test.ts +++ b/frontend/src/components/modelSelectorFilter.test.ts @@ -21,19 +21,18 @@ function model(overrides: Partial & Pick { - const ids = filterModelsByType(fixtures, 'VLLM').map((m) => m.id) - assert.deepEqual(ids, ['vllm-1', 'chat-vision']) +test('vision selector includes vision-capable chat models only (ADR 0004)', () => { + const ids = filterModelsByType(fixtures, 'vision').map((m) => m.id) + assert.deepEqual(ids, ['chat-vision']) }) -test('VLLM selector keeps the standard model-name presentation without type suffixes', () => { +test('vision selector keeps the standard model-name presentation without type suffixes', () => { assert.match(selectorSource, /:label="modelDisplayName\(model\)"/) assert.doesNotMatch(selectorSource, /modelOptionTypeLabel|modelOptionLabel|vlmOptionType/) }) @@ -43,7 +42,7 @@ test('VLM selection, knowledge-base and agent saves, and deletion preserve the s assert.match(selectorSource, /emit\('update:selectedModelId', value \|\| ''\)/) assert.match(kbEditorSource, /handleMultimodalVLLMChange[\s\S]*vllmModelId = modelId/) assert.match(kbEditorSource, /vlm_config = \{[\s\S]*model_id:[\s\S]*vllmModelId/) - assert.match(agentEditorSource, /model-type="VLLM"[\s\S]*formData\.config\.vlm_model_id[\s\S]*formData\.config\.vlm_model_id = val/) + assert.match(agentEditorSource, /model-type="vision"[\s\S]*formData\.config\.vlm_model_id[\s\S]*formData\.config\.vlm_model_id = val/) assert.match(agentEditorSource, /const payload = \{ \.\.\.formData\.value, config: serializeAgentPrompts/) assert.match(agentEditorSource, /updateAgent\(formData\.value\.id, payload\)/) assert.match(modelSettingsSource, /@confirm="deleteModel\(model\._modelType, model\.id\)"/) @@ -51,13 +50,13 @@ test('VLM selection, knowledge-base and agent saves, and deletion preserve the s assert.match(modelSettingsSource, /await deleteModelAPI\(modelId\)/) }) -test('VLLM selector excludes non-vision chat and other types even with supports_vision', () => { - const ids = filterModelsByType(fixtures, 'VLLM') +test('vision selector excludes non-vision chat and other types even with supports_vision', () => { + const ids = filterModelsByType(fixtures, 'vision') assert.ok(!ids.some((m) => m.id === 'chat-text')) assert.ok(!ids.some((m) => m.id === 'embed-vision')) }) -test('non-VLLM selectors still filter strictly by type', () => { +test('non-vision selectors still filter strictly by type', () => { const ids = filterModelsByType(fixtures, 'KnowledgeQA').map((m) => m.id) assert.deepEqual(ids, ['chat-vision', 'chat-text']) }) diff --git a/frontend/src/components/modelSelectorFilter.ts b/frontend/src/components/modelSelectorFilter.ts index c7f642743b7..0338473c7cb 100644 --- a/frontend/src/components/modelSelectorFilter.ts +++ b/frontend/src/components/modelSelectorFilter.ts @@ -1,16 +1,18 @@ import type { ModelConfig } from '@/api/model' -export type ModelSelectorType = ModelConfig['type'] +/** + * 'vision' 是一个伪类型:图像理解槽位(KB 图片描述、智能体看图)选择的不是 + * 一种模型类型,而是声明了图片输入的对话模型(ADR 0004)。 + */ +export type ModelSelectorType = ModelConfig['type'] | 'vision' export function filterModelsByType( allModels: ModelConfig[], modelType: ModelSelectorType, ): ModelConfig[] { - if (modelType === 'VLLM') { + if (modelType === 'vision') { return allModels.filter( - (m) => - m.type === 'VLLM' || - (m.type === 'KnowledgeQA' && m.parameters?.supports_vision === true), + (m) => m.type === 'KnowledgeQA' && m.parameters?.supports_vision === true, ) } diff --git a/frontend/src/composables/useChatStreamHandler.ts b/frontend/src/composables/useChatStreamHandler.ts index de8549f337c..90abc1d3653 100644 --- a/frontend/src/composables/useChatStreamHandler.ts +++ b/frontend/src/composables/useChatStreamHandler.ts @@ -1002,6 +1002,12 @@ export function useChatStreamHandler(options: UseChatStreamHandlerOptions) { loading.value = false isReplying.value = false message.is_completed = true + // E9(2026-09-14 裁定):complete 事件携带本轮 usage——agent 与 + // 非 agent 模式统一存到助手消息上供展示 + const completeUsage = (dataPayload as any)?.usage + if (completeUsage && typeof completeUsage === 'object') { + message.usage = completeUsage + } onReplyComplete?.(String(message.content || '')) onTurnComplete?.(message) fullContent.value = '' diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index 1dc7bfeaa61..c01d1f6106f 100755 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -2623,6 +2623,7 @@ export default { kbLockedByAgent: 'Knowledge base configuration is locked by the current agent', kbDisabledByAgent: 'Knowledge base is disabled by the current agent', modelLockedByAgent: 'Model selection is locked by the current agent', + thinkingLevelResetToast: 'Model switched. The selected thinking level is not supported by the new model and has been reset to follow the default.', imageUploadDisabledByAgent: 'Image upload is not enabled for this agent', goToAgentSettings: 'Go to agent settings' }, @@ -2738,7 +2739,7 @@ export default { name: 'Name', nameDesc: 'For admin lists and channel identification', namePlaceholder: 'e.g. Website support', - nameDefaultHint: 'Defaults to "{agent} · Web Embed" when an agent is selected. Leave blank on save to use the default name.', + nameDefaultHint: 'Defaults to the agent name · Web Embed; editable, and used as the name when saved empty.', defaultChannelName: 'Web Embed', defaultChannelNameWithAgent: '{agent} · Web Embed', welcomeMessage: 'Welcome message', @@ -3596,6 +3597,8 @@ export default { questionMinimapAttachmentPlaceholder: '(Attachment)', referenceChunkCount: '{count} chunk(s)', fallbackHint: 'No relevant content found in knowledge base. Above is a direct response from the model.', + usageHint: 'Token usage for this turn (input → output · total · cache hit)', + usageCached: 'cached {n}', requestInfoTitle: 'Request info', requestInfoRequestId: 'Request ID', requestInfoMessageId: 'Message ID', @@ -4458,7 +4461,15 @@ export default { testFailed: 'Test failed' } }, + thinking: { + levelLabel: 'Thinking level', + levelPlaceholder: 'Follow model default', + reset: 'Reset', + }, model: { + credentials: { + apiKey: 'API Key', + }, modelName: 'Model Name', defaultTag: 'Default', addModelInSettings: 'Go to global settings to add models', @@ -4472,6 +4483,7 @@ export default { typeLabel: 'Model Type', sectionSource: 'Source', sectionProvider: 'Provider Settings', + sectionParameters: 'Model Parameters', sectionAdvanced: 'Advanced Options', sourceLabel: 'Model Source', sourceLocal: 'Ollama', @@ -4480,15 +4492,12 @@ export default { chat: 'Configure large language models for conversations', embedding: 'Configure embedding models for text vectorization', rerank: 'Configure models for result re-ranking', - vllm: 'Configure vision-language models for multimodal understanding', asr: 'Configure speech-to-text models for audio transcription', default: 'Configure model information' }, modelNamePlaceholder: { local: 'e.g. llama2:latest', remote: 'e.g. gpt-4, claude-3-opus', - localVllm: 'e.g. llava:latest', - remoteVllm: 'e.g. gpt-4-vision-preview', remoteAsr: 'e.g. whisper-1' }, baseUrlLabel: 'Base URL', @@ -4496,7 +4505,6 @@ export default { displayNamePlaceholder: 'e.g. Support QA model', displayNameDesc: 'Used only in the UI. Runtime calls still use the model name above.', baseUrlPlaceholder: 'e.g. https://api.openai.com/v1', - baseUrlPlaceholderVllm: 'e.g. http://localhost:11434/v1', baseUrlPlaceholderAsr: 'e.g. https://api.openai.com/v1', apiKeyOptional: 'API Key (optional)', apiKeyPlaceholder: 'Enter API Key', @@ -4536,6 +4544,11 @@ export default { dimensionOverrideDesc: 'Enable only if the provider documentation says this model accepts a dimensions parameter.', supportsVisionLabel: 'Supports Vision / Multimodal', supportsVisionDesc: 'Whether the model accepts image and multimodal input', + inputModalitiesLabel: 'Input modalities', + inputModalitiesDesc: 'Check the input modalities this model supports; text is the baseline capability and is checked by default.', + modalityText: 'Text', + modalityImage: 'Image', + modalityAudio: 'Audio', contextWindowLabel: 'Context Window', contextWindowPlaceholder: 'Default {value}', contextWindowDesc: 'How many tokens this model can take in one request. Agent history compaction uses this limit. Leave empty for the default 200000 (200K). Use the provider’s real window — a larger guess means compaction never fires and the provider rejects the request.', @@ -4544,25 +4557,29 @@ export default { maxConcurrencyLabel: 'Background concurrency limit', maxConcurrencyPlaceholder: '0 = use global default', maxConcurrencyDesc: 'Caps concurrent background (ingestion/enrichment) calls to this model, shared per model across all replicas. 0 or empty falls back to the global default; interactive chat is never affected.', - thinkingControlLabel: 'Thinking mode request format', - thinkingControlDesc: 'Controls how the agent’s “Thinking mode” on/off switch is written to the API. We pre-select based on vendor/model when possible; change it to match your API docs. With “Do not send”, the agent Thinking mode switch has no effect.', - thinkingControl: { - none: { - label: 'Do not send thinking fields', - hint: 'Agent “Thinking mode” switch has no effect; thinking parameters are not sent in requests' - }, - chatTemplateKwargs: { - label: 'chat_template_kwargs', - hint: 'Custom OpenAI-compatible gateways, NVIDIA NIM, vLLM / local Qwen' - }, - enableThinking: { - label: 'enable_thinking', - hint: 'Alibaba DashScope: qwen3, qwen-plus, qwen-max, qwen-turbo' - }, - thinkingType: { - label: 'thinking.type', - hint: 'Volcengine Ark; Tencent LKEAP (DeepSeek V3, etc.; default for LKEAP; use “Do not send” for R1)' - } + thinkingToggleLabel: 'Thinking switch', + thinkingToggleDesc: 'When off, requests carry no thinking parameters; level selection is unaffected.', + selectedLevelsLabel: 'Supported thinking levels', + selectedLevelsPlaceholder: 'Select the levels this model supports', + selectedLevelsDesc: 'Pick the levels from the vendor vocabulary this model actually supports; leave empty for unspecified.', + thinkingLevelLabel: 'Default thinking level', + thinkingLevelPlaceholder: 'Empty = provider decides', + thinkingLevelDesc: 'Level used when the user/agent does not specify one; empty lets the model provider decide (vendor default or no level parameter).', + thinkingLevelsUnsupportedHint: 'This model does not support thinking strength control', + appIdLabel: 'App ID', + appSecretLabel: 'App Secret', + maxOutputTokensLabel: 'Max output tokens', + maxOutputTokensPlaceholder: 'Empty = unlimited', + maxOutputTokensDesc: 'How many tokens this model may generate in one response. Leave empty for no limit.', + sourceCatalog: 'Catalog', + visionDisabledHint: 'The current vendor does not declare image input support', + dimensionOverrideDisabledHint: 'The current vendor does not declare custom output dimension support', + thinkingLevels: { + low: 'Low', + medium: 'Medium', + high: 'High', + xhigh: 'Extra High', + max: 'Max' }, dimensionHint: 'Model selected. Click "Detect Dimension" to fetch the vector dimension automatically.', loadModelListFailed: 'Failed to load model list', @@ -4590,7 +4607,11 @@ export default { modelNameMax: 'Model name cannot exceed 100 characters', baseUrlRequired: 'Please enter the Base URL', baseUrlEmpty: 'Base URL cannot be empty', - baseUrlInvalid: 'Invalid Base URL, please enter a valid URL' + baseUrlInvalid: 'Invalid Base URL, please enter a valid URL', + contextWindowRange: 'Context window must be between 1024 and 10000000', + maxOutputTokensRange: 'Max output tokens must be a positive integer', + maxConcurrencyRange: 'Max concurrency must be a non-negative integer', + credentialRequired: 'Please fill in {field}' }, providerLabel: 'Provider', providerPlaceholder: 'Select model provider', @@ -4629,7 +4650,11 @@ export default { }, generic: { label: 'Custom (OpenAI-compatible)', - description: 'Generic API endpoint' + description: 'Generic API endpoint', + ollama: { + label: 'Ollama', + description: 'Local Ollama server (http://localhost:11434)', + }, }, siliconflow: { label: 'SiliconFlow', @@ -4954,11 +4979,6 @@ export default { desc: 'Configure models for result re-ranking', empty: 'No re-rank models' }, - vllm: { - title: 'VLLM Vision Models', - desc: 'Configure vision-language models for multimodal understanding', - empty: 'No VLLM models' - }, asr: { title: 'ASR Speech Models', desc: 'Configure speech-to-text models for audio transcription (e.g. OpenAI Whisper)', @@ -5925,6 +5945,7 @@ export default { }, agentEditor: { builtinHint: 'This is a built-in agent. Name and description cannot be modified, but configuration parameters can be adjusted.', + thinkingLevelResetToast: 'Model switched. The selected thinking level is not supported by the new model and has been reset to follow the default.', navGroups: { basic: 'Basics', knowledge: 'Knowledge & Retrieval', diff --git a/frontend/src/i18n/locales/ja-JP.ts b/frontend/src/i18n/locales/ja-JP.ts index bdc1078504e..627e95cc89a 100644 --- a/frontend/src/i18n/locales/ja-JP.ts +++ b/frontend/src/i18n/locales/ja-JP.ts @@ -2623,6 +2623,7 @@ export default { kbLockedByAgent: '現在のエージェントによりナレッジベース設定がロックされています', kbDisabledByAgent: '現在のエージェントによりナレッジベースが無効になっています', modelLockedByAgent: '現在のエージェントによりモデル選択がロックされています', + thinkingLevelResetToast: 'モデルを切り替えたため、選択していた思考レベルは新しいモデルではサポートされず、デフォルトにリセットされました。', imageUploadDisabledByAgent: 'このエージェントでは画像アップロードが有効になっていません', goToAgentSettings: 'エージェント設定に移動' }, @@ -2738,7 +2739,7 @@ export default { name: '名前', nameDesc: '管理画面の一覧やチャネルの識別に使用します', namePlaceholder: '例: Webサイトサポート', - nameDefaultHint: 'エージェントを選択すると「{agent} · Web埋め込み」がデフォルトになります。保存時に空欄のままにするとデフォルトの名前が使用されます。', + nameDefaultHint: 'デフォルトは「エージェント名 · Web埋め込み」です。編集でき、保存時に空欄の場合はデフォルト名が使用されます。', defaultChannelName: 'Web埋め込み', defaultChannelNameWithAgent: '{agent} · Web埋め込み', welcomeMessage: 'ウェルカムメッセージ', @@ -3596,6 +3597,8 @@ export default { questionMinimapAttachmentPlaceholder: '(添付ファイル)', referenceChunkCount: '{count}件のチャンク', fallbackHint: 'ナレッジベースから関連する内容が見つかりませんでした。上記はモデルの直接回答です。', + usageHint: 'このターンのトークン使用量(入力 → 出力 · 合計 · キャッシュヒット)', + usageCached: 'キャッシュ {n}', requestInfoTitle: 'リクエスト情報', requestInfoRequestId: 'Request ID', requestInfoMessageId: 'メッセージID', @@ -4458,7 +4461,15 @@ export default { testFailed: 'テストに失敗しました' } }, + thinking: { + levelLabel: '思考レベル', + levelPlaceholder: 'モデルのデフォルトに従う', + reset: 'リセット', + }, model: { + credentials: { + apiKey: 'API Key', + }, modelName: 'モデル名', defaultTag: 'デフォルト', addModelInSettings: 'モデルを追加するにはシステム設定を開いてください', @@ -4472,6 +4483,7 @@ export default { typeLabel: 'モデルタイプ', sectionSource: 'ソース', sectionProvider: 'プロバイダ設定', + sectionParameters: 'モデルパラメータ設定', sectionAdvanced: '詳細オプション', sourceLabel: 'モデルのソース', sourceLocal: 'Ollama', @@ -4480,15 +4492,12 @@ export default { chat: '会話用の大規模言語モデルを設定します', embedding: 'テキストのベクトル化に使う埋め込みモデルを設定します', rerank: '検索結果のリランクに使うモデルを設定します', - vllm: 'マルチモーダル理解に使う視覚言語モデルを設定します', asr: '音声の文字起こしに使う音声認識モデルを設定します', default: 'モデル情報を設定します' }, modelNamePlaceholder: { local: '例: llama2:latest', remote: '例: gpt-4, claude-3-opus', - localVllm: '例: llava:latest', - remoteVllm: '例: gpt-4-vision-preview', remoteAsr: '例: whisper-1' }, baseUrlLabel: 'ベースURL', @@ -4496,7 +4505,6 @@ export default { displayNamePlaceholder: '例: サポートQA用モデル', displayNameDesc: 'UIでの表示にのみ使用されます。実行時の呼び出しには上のモデル名が使われます。', baseUrlPlaceholder: '例: https://api.openai.com/v1', - baseUrlPlaceholderVllm: '例: http://localhost:11434/v1', baseUrlPlaceholderAsr: '例: https://api.openai.com/v1', apiKeyOptional: 'APIキー(任意)', apiKeyPlaceholder: 'APIキーを入力してください', @@ -4536,6 +4544,11 @@ export default { dimensionOverrideDesc: 'プロバイダのドキュメントでこのモデルがdimensionsパラメータに対応していると記載されている場合にのみ有効にしてください。デフォルトでは検出された実際の次元数のみを使用します。', supportsVisionLabel: '視覚・マルチモーダルに対応', supportsVisionDesc: 'モデルが画像やマルチモーダル入力を受け付けるかどうか', + inputModalitiesLabel: '入力モダリティ', + inputModalitiesDesc: 'このモデルが対応する入力モダリティを選択してください。テキストは全モデルの基本能力のためデフォルトで選択されています。', + modalityText: 'テキスト', + modalityImage: '画像', + modalityAudio: '音声', contextWindowLabel: 'コンテキストウィンドウ', contextWindowPlaceholder: 'デフォルト値{value}', contextWindowDesc: '1回のリクエストでこのモデルが受け付けられるトークン数です。エージェントの履歴圧縮はこの上限を基準にします。空欄の場合はデフォルト値の200000(200K)が使われます。プロバイダの実際のウィンドウサイズを指定してください。大きすぎる値を指定すると圧縮が働かず、プロバイダにリクエストを拒否されます。', @@ -4544,25 +4557,29 @@ export default { maxConcurrencyLabel: 'バックグラウンドの並列実行上限', maxConcurrencyPlaceholder: '0 = グローバルのデフォルト値を使用', maxConcurrencyDesc: 'このモデルへのバックグラウンド(取り込み・エンリッチメント)呼び出しの並列数を制限します。モデルごとにすべてのレプリカで共有されます。0または空欄の場合はグローバルのデフォルト値が使われます。対話型のチャットには影響しません。', - thinkingControlLabel: '思考モードのリクエスト形式', - thinkingControlDesc: 'エージェントの「思考モード」のオン/オフをAPIにどう送信するかを設定します。可能な場合はベンダやモデルに応じて自動選択されます。お使いのAPIドキュメントに合わせて変更してください。「送信しない」を選ぶと、エージェントの思考モードの切り替えは効果がありません。', - thinkingControl: { - none: { - label: '思考関連のフィールドを送信しない', - hint: 'エージェントの「思考モード」の切り替えは効果がなく、リクエストに思考パラメータは送信されません' - }, - chatTemplateKwargs: { - label: 'chat_template_kwargs', - hint: 'OpenAI互換のカスタムゲートウェイ、NVIDIA NIM、vLLM / ローカルのQwen' - }, - enableThinking: { - label: 'enable_thinking', - hint: 'Alibaba DashScope: qwen3、qwen-plus、qwen-max、qwen-turbo' - }, - thinkingType: { - label: 'thinking.type', - hint: 'Volcengine Ark、Tencent LKEAP(DeepSeek V3など。LKEAPのデフォルト値。R1では「送信しない」を使用)' - } + thinkingToggleLabel: '思考スイッチ', + thinkingToggleDesc: 'オフにするとリクエストに思考パラメータを含めません。レベル選択には影響しません。', + selectedLevelsLabel: '対応する思考レベル', + selectedLevelsPlaceholder: 'このモデルが対応するレベルを選択', + selectedLevelsDesc: 'ベンダのレベル語彙から、このモデルが実際に対応するレベルを選択してください。空欄は未指定です。', + thinkingLevelLabel: 'デフォルト思考レベル', + thinkingLevelPlaceholder: '空欄 = モデルプロバイダーに任せる', + thinkingLevelDesc: 'ユーザー/エージェントがレベルを指定しない場合に使われるデフォルトです。空欄ならモデルプロバイダーが決定します(ベンダのデフォルトまたはレベルパラメータなし)。', + thinkingLevelsUnsupportedHint: 'このモデルは思考強度の調整に対応していません', + appIdLabel: 'App ID', + appSecretLabel: 'App Secret', + maxOutputTokensLabel: '最大出力トークン数', + maxOutputTokensPlaceholder: '空欄 = 無制限', + maxOutputTokensDesc: 'このモデルが 1 回の応答で生成できる最大トークン数です。空欄なら制限しません。', + sourceCatalog: 'カタログ', + visionDisabledHint: '現在のベンダは画像入力サポートを宣言していません', + dimensionOverrideDisabledHint: '現在のベンダはカスタム出力次元数のサポートを宣言していません', + thinkingLevels: { + low: '低', + medium: '中', + high: '高', + xhigh: '超高', + max: '最大' }, dimensionHint: 'モデルを選択しました。「次元数を検出」をクリックするとベクトル次元数を自動取得できます。', loadModelListFailed: 'モデル一覧の読み込みに失敗しました', @@ -4590,7 +4607,11 @@ export default { modelNameMax: 'モデル名は100文字以内で入力してください', baseUrlRequired: 'ベースURLを入力してください', baseUrlEmpty: 'ベースURLは空にできません', - baseUrlInvalid: 'ベースURLが無効です。有効なURLを入力してください' + baseUrlInvalid: 'ベースURLが無効です。有効なURLを入力してください', + contextWindowRange: 'コンテキストウィンドウは1024〜10000000の間で指定してください', + maxOutputTokensRange: '最大出力トークンは正の整数で指定してください', + maxConcurrencyRange: 'バックグラウンド同時実行上限は0以上の整数で指定してください', + credentialRequired: '{field}を入力してください' }, providerLabel: 'プロバイダ', providerPlaceholder: 'モデルプロバイダを選択', @@ -4629,7 +4650,11 @@ export default { }, generic: { label: 'カスタム(OpenAI互換)', - description: '汎用のAPIエンドポイント' + description: '汎用のAPIエンドポイント', + ollama: { + label: 'Ollama', + description: 'ローカル Ollama サーバー(http://localhost:11434)', + }, }, siliconflow: { label: 'SiliconFlow', @@ -4954,11 +4979,6 @@ export default { desc: '結果のリランクに使用するモデルを設定します', empty: 'リランクモデルがありません' }, - vllm: { - title: 'VLLM視覚モデル', - desc: 'マルチモーダル理解に使用する視覚言語モデルを設定します', - empty: 'VLLMモデルがありません' - }, asr: { title: 'ASR音声モデル', desc: '音声の文字起こしに使用する音声認識モデルを設定します(例: OpenAI Whisper)', @@ -5925,6 +5945,7 @@ export default { }, agentEditor: { builtinHint: 'これは組み込みエージェントです。名前と説明は変更できませんが、設定パラメータは調整できます。', + thinkingLevelResetToast: 'モデルを切り替えたため、選択していた思考レベルは新しいモデルではサポートされず、デフォルトにリセットされました。', navGroups: { basic: '基本', knowledge: 'ナレッジと検索', diff --git a/frontend/src/i18n/locales/ko-KR.ts b/frontend/src/i18n/locales/ko-KR.ts index 9b2541154de..c1c22bb1b6d 100755 --- a/frontend/src/i18n/locales/ko-KR.ts +++ b/frontend/src/i18n/locales/ko-KR.ts @@ -991,6 +991,7 @@ export default { }, agentEditor: { builtinHint: '내장 에이전트입니다. 이름과 설명은 수정할 수 없지만, 설정 매개변수는 조정할 수 있습니다.', + thinkingLevelResetToast: '모델이 변경되어 선택한 사고 레벨이 새 모델에서 지원되지 않으므로 기본값 따르기로 초기화되었습니다.', fileTypes: { label: '지원 파일 유형', desc: '선택 가능한 파일 유형을 제한합니다. 비워두면 모든 유형을 지원합니다.', @@ -2329,11 +2330,6 @@ export default { desc: '음성 인식 및 오디오 전사를 위한 음성 인식 모델 설정 (예: OpenAI Whisper)', empty: 'ASR 음성 모델 없음' }, - vllm: { - title: 'VLLM 비전 모델', - desc: '시각 이해 및 멀티모달용 비전 언어 모델 설정', - empty: 'VLLM 비전 모델 없음' - }, rerank: { title: 'ReRank 모델', desc: '결과 재정렬용 모델 설정', @@ -2583,7 +2579,15 @@ export default { languageDescription: '인터페이스 표시 언어 선택', languageSaved: '언어 설정이 저장되었습니다' }, + thinking: { + levelLabel: '사고 레벨', + levelPlaceholder: '모델 기본값 따르기', + reset: '초기화', + }, model: { + credentials: { + apiKey: 'API Key', + }, modelName: '모델 이름', defaultTag: '기본값', addModelInSettings: '전역 설정에서 모델 추가하기', @@ -2598,6 +2602,7 @@ export default { typeLabel: '모델 유형', sectionSource: '모델 소스', sectionProvider: '연결 설정', + sectionParameters: '모델 파라미터 설정', sectionAdvanced: '고급 옵션', sourceLabel: '모델 소스', sourceLocal: 'Ollama', @@ -2607,7 +2612,6 @@ export default { displayNamePlaceholder: '예: 고객지원 QA 모델', displayNameDesc: 'UI 표시용으로만 사용되며 실제 호출은 위의 모델 이름을 사용합니다.', baseUrlPlaceholder: '예: https://api.openai.com/v1', - baseUrlPlaceholderVllm: '예: http://localhost:11434/v1', baseUrlPlaceholderAsr: '예: https://api.openai.com/v1', apiKeyOptional: 'API 키 (선택)', apiKeyPlaceholder: 'API 키 입력', @@ -2630,6 +2634,11 @@ export default { dimensionOverrideDesc: '제공자 문서에서 이 모델이 dimensions 매개변수를 지원한다고 확인한 경우에만 켜세요.', supportsVisionLabel: '비전/멀티모달 지원', supportsVisionDesc: '모델의 이미지 등 멀티모달 입력 지원 여부', + inputModalitiesLabel: '입력 모달리티', + inputModalitiesDesc: '이 모델이 지원하는 입력 모달리티를 선택하세요. 텍스트는 모든 모델의 기본 능력이므로 기본 선택되어 있습니다.', + modalityText: '텍스트', + modalityImage: '이미지', + modalityAudio: '오디오', contextWindowLabel: '컨텍스트 창', contextWindowPlaceholder: '기본값 {value}', contextWindowDesc: '모델이 한 요청에 수용할 수 있는 토큰 수입니다. 에이전트 대화 압축이 이 한도를 사용합니다. 비워 두면 기본값 200000(200K)을 사용합니다. 공급자 문서의 실제 값을 입력하세요. 더 크게 설정하면 압축이 발생하지 않고 요청이 거부될 수 있습니다.', @@ -2638,8 +2647,23 @@ export default { maxConcurrencyLabel: '백그라운드 동시 실행 상한', maxConcurrencyPlaceholder: '0이면 전역 기본값 사용', maxConcurrencyDesc: '문서 인덱싱/보강 등 백그라운드 작업이 이 모델을 호출하는 동시 실행 수를 제한합니다(모델별로 모든 복제본이 공유). 0 또는 비워 두면 전역 기본값을 사용하며, 대화형 채팅에는 영향을 주지 않습니다.', - thinkingControlLabel: '사고 모드 매개변수 형식', - thinkingControlDesc: '에이전트 「사고 모드」 켜기/끄기 시 API에 어떻게 기록할지 결정합니다. 벤더/모델에 따라 미리 선택되며, 실제 API와 다르면 문서에 맞게 수정하세요. 「전송 안 함」을 선택하면 에이전트 「사고 모드」 스위치가 효과가 없습니다.', + thinkingToggleLabel: '사고 스위치', + thinkingToggleDesc: '끄면 요청에 사고 파라미터를 담지 않습니다. 레벨 선택에는 영향이 없습니다.', + selectedLevelsLabel: '지원되는 사고 레벨', + selectedLevelsPlaceholder: '이 모델이 지원하는 레벨 선택', + selectedLevelsDesc: '벤더 레벨 어휘에서 이 모델이 실제로 지원하는 레벨을 선택하세요. 비워 두면 미지정입니다.', + thinkingLevelLabel: '기본 사고 레벨', + thinkingLevelPlaceholder: '비움 = 모델 공급자가 결정', + thinkingLevelDesc: '사용자/에이전트가 레벨을 지정하지 않았을 때 쓰이는 기본 레벨입니다. 비워 두면 모델 공급자가 결정합니다(벤더 기본값 사용 또는 레벨 파라미터 미전송).', + thinkingLevelsUnsupportedHint: '이 모델은 사고 강도 조절을 지원하지 않습니다', + appIdLabel: 'App ID', + appSecretLabel: 'App Secret', + maxOutputTokensLabel: '최대 출력 토큰', + maxOutputTokensPlaceholder: '비워 두면 제한 없음', + maxOutputTokensDesc: '이 모델이 한 번의 응답에서 생성할 수 있는 최대 토큰 수입니다. 비워 두면 제한하지 않습니다.', + sourceCatalog: '카탈로그', + visionDisabledHint: '현재 벤더는 이미지 입력을 지원한다고 선언하지 않았습니다', + dimensionOverrideDisabledHint: '현재 벤더는 사용자 지정 출력 차원을 지원한다고 선언하지 않았습니다', dimensionHint: '모델이 선택되었습니다. "차원 감지" 버튼을 클릭하여 벡터 차원을 자동으로 가져옵니다', loadModelListFailed: '모델 목록 로드 실패', listRefreshed: '목록이 새로고침되었습니다', @@ -2733,7 +2757,11 @@ export default { }, generic: { label: '사용자 정의 (OpenAI 호환)', - description: 'Generic API endpoint' + description: 'Generic API endpoint', + ollama: { + label: 'Ollama', + description: '로컬 Ollama 서버 (http://localhost:11434)', + }, }, requesty: { label: 'Requesty', @@ -2774,25 +2802,18 @@ export default { modelNameMax: '모델 이름은 100자를 초과할 수 없습니다', baseUrlRequired: 'Base URL을 입력해주세요', baseUrlEmpty: 'Base URL은 비워둘 수 없습니다', - baseUrlInvalid: 'Base URL 형식이 올바르지 않습니다. 유효한 URL을 입력해주세요' + baseUrlInvalid: 'Base URL 형식이 올바르지 않습니다. 유효한 URL을 입력해주세요', + contextWindowRange: '컨텍스트 윈도우는 1024~10000000 사이여야 합니다', + maxOutputTokensRange: '최대 출력 토큰은 양의 정수여야 합니다', + maxConcurrencyRange: '백그라운드 동시 실행 상한은 0 이상의 정수여야 합니다', + credentialRequired: '{field}을(를) 입력해주세요' }, - thinkingControl: { - thinkingType: { - label: 'thinking.type', - hint: 'Volcengine Ark; Tencent LKEAP (DeepSeek V3 등, LKEAP 기본값; R1은 「전송 안 함」)' - }, - enableThinking: { - label: 'enable_thinking', - hint: 'Alibaba DashScope: qwen3, qwen-plus, qwen-max, qwen-turbo' - }, - chatTemplateKwargs: { - label: 'chat_template_kwargs', - hint: '사용자 정의 OpenAI 호환, NVIDIA NIM, vLLM / 로컬 Qwen 배포' - }, - none: { - label: '사고 매개변수 전송 안 함', - hint: '에이전트 「사고 모드」 스위치가 효과 없음, 요청에 사고 관련 매개변수를 보내지 않음' - } + thinkingLevels: { + low: '낮음', + medium: '중간', + high: '높음', + xhigh: '매우 높음', + max: '최대' }, volcengine: { accessKeyLabel: 'Access Key ID', @@ -2814,15 +2835,12 @@ export default { modelNamePlaceholder: { local: '예: llama2:latest', remote: '예: gpt-4, claude-3-opus', - localVllm: '예: llava:latest', - remoteVllm: '예: gpt-4-vision-preview', remoteAsr: '예: whisper-1' }, description: { chat: '대화용 대규모 언어 모델 설정', embedding: '텍스트 벡터화용 임베딩 모델 설정', rerank: '결과 재정렬용 모델 설정', - vllm: '시각 이해 및 멀티모달용 비전 언어 모델 설정', asr: '음성 인식 및 오디오 전사를 위한 음성 인식 모델 설정', default: '모델 정보 설정' } @@ -3656,6 +3674,8 @@ export default { questionMinimapAttachmentPlaceholder: '(첨부)', referenceChunkCount: '{count}개 청크', fallbackHint: '지식 베이스에서 관련 내용을 찾지 못했습니다. 위는 모델의 직접 응답입니다.', + usageHint: '이 턴의 토큰 사용량 (입력 → 출력 · 합계 · 캐시 히트)', + usageCached: '캐시 {n}', requestInfoTitle: 'Request info', requestInfoRequestId: 'Request ID', requestInfoMessageId: 'Message ID', @@ -4697,6 +4717,7 @@ export default { kbLockedByAgent: '현재 에이전트가 지식베이스 구성을 잠갔습니다.', kbDisabledByAgent: '현재 에이전트가 지식베이스 기능을 비활성화했습니다.', modelLockedByAgent: '현재 에이전트는 모델 구성을 잠갔습니다.', + thinkingLevelResetToast: '모델이 변경되어 선택한 사고 레벨이 새 모델에서 지원되지 않으므로 기본값 따르기로 초기화되었습니다.', imageUploadDisabledByAgent: '현재 에이전트에서 이미지 업로드가 활성화되지 않았습니다', goToAgentSettings: '에이전트 설정으로 이동', messages: { diff --git a/frontend/src/i18n/locales/ru-RU.ts b/frontend/src/i18n/locales/ru-RU.ts index 68dd6ed6fcf..a4897cb9537 100755 --- a/frontend/src/i18n/locales/ru-RU.ts +++ b/frontend/src/i18n/locales/ru-RU.ts @@ -991,6 +991,7 @@ export default { }, agentEditor: { builtinHint: 'Это встроенный агент. Имя и описание нельзя изменить, но можно настроить параметры конфигурации.', + thinkingLevelResetToast: 'Модель переключена. Выбранный уровень размышления не поддерживается новой моделью и сброшен на значение по умолчанию.', fileTypes: { label: 'Поддерживаемые типы файлов', desc: 'Ограничение выбираемых типов файлов. Пустое поле — все типы поддерживаются.', @@ -2329,11 +2330,6 @@ export default { desc: 'Модели распознавания речи для транскрибации аудио (например, OpenAI Whisper)', empty: 'Нет ASR моделей' }, - vllm: { - title: 'VLLM модели зрения', - desc: 'Визуально-языковые модели для мультимодального понимания', - empty: 'Нет VLLM моделей' - }, rerank: { title: 'Модели ReRank', desc: 'Модели для повторной ранжировки результатов', @@ -2583,7 +2579,15 @@ export default { languageDescription: 'Выберите язык отображения интерфейса', languageSaved: 'Настройки языка сохранены' }, + thinking: { + levelLabel: 'Уровень размышления', + levelPlaceholder: 'По умолчанию для модели', + reset: 'Сбросить', + }, model: { + credentials: { + apiKey: 'API Key', + }, modelName: 'Название модели', defaultTag: 'По умолчанию', addModelInSettings: 'Перейти в общие настройки для добавления моделей', @@ -2598,6 +2602,7 @@ export default { typeLabel: 'Тип модели', sectionSource: 'Источник', sectionProvider: 'Настройки провайдера', + sectionParameters: 'Параметры модели', sectionAdvanced: 'Дополнительные параметры', sourceLabel: 'Источник модели', sourceLocal: 'Ollama', @@ -2607,7 +2612,6 @@ export default { displayNamePlaceholder: 'например: модель поддержки', displayNameDesc: 'Используется только в интерфейсе. Для вызовов по-прежнему используется имя модели выше.', baseUrlPlaceholder: 'например: https://api.openai.com/v1', - baseUrlPlaceholderVllm: 'например: http://localhost:11434/v1', baseUrlPlaceholderAsr: 'например: https://api.openai.com/v1', apiKeyOptional: 'API Key (опционально)', apiKeyPlaceholder: 'Введите API Key', @@ -2630,6 +2634,11 @@ export default { dimensionOverrideDesc: 'Включайте только если документация провайдера подтверждает поддержку параметра dimensions.', supportsVisionLabel: 'Поддержка визуального / мультимодального ввода', supportsVisionDesc: 'Поддерживает ли модель изображения и другой мультимодальный ввод', + inputModalitiesLabel: 'Входные модальности', + inputModalitiesDesc: 'Отметьте входные модальности, которые поддерживает модель; текст — базовая способность всех моделей и отмечен по умолчанию.', + modalityText: 'Текст', + modalityImage: 'Изображение', + modalityAudio: 'Аудио', contextWindowLabel: 'Контекстное окно', contextWindowPlaceholder: 'По умолчанию {value}', contextWindowDesc: 'Сколько токенов модель принимает за один запрос. Сжатие истории агента использует этот лимит. Пустое значение — по умолчанию 200000 (200K). Укажите реальное окно провайдера: завышенное значение не запускает сжатие, и провайдер отклоняет запрос.', @@ -2638,8 +2647,23 @@ export default { maxConcurrencyLabel: 'Лимит фоновой параллельности', maxConcurrencyPlaceholder: '0 — использовать глобальное значение', maxConcurrencyDesc: 'Ограничивает число одновременных фоновых вызовов (индексация/обогащение) к этой модели, общее для модели по всем репликам. 0 или пусто — используется глобальное значение по умолчанию; интерактивный чат не затрагивается.', - thinkingControlLabel: 'Формат параметров режима размышления', - thinkingControlDesc: 'Определяет, как переключатель «Режим размышления» агента записывается в API. При возможности выбирается по поставщику/модели; при несоответствии измените по документации API. При выборе «Не отправлять» переключатель «Режим размышления» агента не действует.', + thinkingToggleLabel: 'Переключатель размышления', + thinkingToggleDesc: 'Когда выключено, запрос не содержит параметров размышления; на выбор уровней не влияет.', + selectedLevelsLabel: 'Поддерживаемые уровни размышления', + selectedLevelsPlaceholder: 'Выберите уровни, которые поддерживает модель', + selectedLevelsDesc: 'Отметьте уровни из словаря вендора, которые модель реально поддерживает; пусто = не задано.', + thinkingLevelLabel: 'Уровень размышления по умолчанию', + thinkingLevelPlaceholder: 'Пусто = решает провайдер модели', + thinkingLevelDesc: 'Уровень по умолчанию, когда пользователь/агент его не указал; пусто — решает провайдер модели (вендор по умолчанию или без параметра уровня).', + thinkingLevelsUnsupportedHint: 'Эта модель не поддерживает регулировку силы размышления', + appIdLabel: 'App ID', + appSecretLabel: 'App Secret', + maxOutputTokensLabel: 'Максимум выходных токенов', + maxOutputTokensPlaceholder: 'Пусто = без ограничения', + maxOutputTokensDesc: 'Сколько токенов модель может сгенерировать за один ответ. Пусто — без ограничения.', + sourceCatalog: 'Каталог', + visionDisabledHint: 'Текущий вендор не заявляет поддержку входных изображений', + dimensionOverrideDisabledHint: 'Текущий вендор не заявляет поддержку пользовательской размерности вывода', dimensionHint: 'Модель выбрана. Нажмите «Определить размерность», чтобы автоматически получить значение.', loadModelListFailed: 'Не удалось загрузить список моделей', listRefreshed: 'Список обновлён', @@ -2733,7 +2757,11 @@ export default { }, generic: { label: 'Пользовательский (OpenAI-совместимый)', - description: 'Generic API endpoint' + description: 'Generic API endpoint', + ollama: { + label: 'Ollama', + description: 'Локальный сервер Ollama (http://localhost:11434)', + }, }, requesty: { label: 'Requesty', @@ -2774,25 +2802,18 @@ export default { modelNameMax: 'Название модели не может превышать 100 символов', baseUrlRequired: 'Введите Base URL', baseUrlEmpty: 'Base URL не может быть пустым', - baseUrlInvalid: 'Недопустимый Base URL, введите корректный адрес' + baseUrlInvalid: 'Недопустимый Base URL, введите корректный адрес', + contextWindowRange: 'Окно контекста должно быть от 1024 до 10000000', + maxOutputTokensRange: 'Макс. токены вывода должны быть положительным целым числом', + maxConcurrencyRange: 'Лимит фоновой конкурентности должен быть неотрицательным целым числом', + credentialRequired: 'Заполните поле «{field}»' }, - thinkingControl: { - thinkingType: { - label: 'thinking.type', - hint: 'Volcengine Ark; Tencent LKEAP (DeepSeek V3 и др.; по умолчанию для LKEAP; для R1 — «Не отправлять»)' - }, - enableThinking: { - label: 'enable_thinking', - hint: 'Alibaba DashScope: qwen3, qwen-plus, qwen-max, qwen-turbo' - }, - chatTemplateKwargs: { - label: 'chat_template_kwargs', - hint: 'Пользовательские OpenAI-совместимые шлюзы, NVIDIA NIM, vLLM / локальный Qwen' - }, - none: { - label: 'Не отправлять параметры размышления', - hint: 'Переключатель «Режим размышления» агента не действует; параметры размышления не отправляются в запросе' - } + thinkingLevels: { + low: 'Низкий', + medium: 'Средний', + high: 'Высокий', + xhigh: 'Очень высокий', + max: 'Максимум' }, volcengine: { accessKeyLabel: 'Access Key ID', @@ -2814,15 +2835,12 @@ export default { modelNamePlaceholder: { local: 'например: llama2:latest', remote: 'например: gpt-4, claude-3-opus', - localVllm: 'например: llava:latest', - remoteVllm: 'например: gpt-4-vision-preview', remoteAsr: 'например: whisper-1' }, description: { chat: 'Настройте языковую модель для диалогов', embedding: 'Настройте модель встраивания для текстовой векторизации', rerank: 'Настройте модель для повторного ранжирования результатов', - vllm: 'Настройте визуально-языковую модель для мультимодального понимания', asr: 'Настройте модель распознавания речи для транскрибации аудио', default: 'Настройте информацию о модели' } @@ -3656,6 +3674,8 @@ export default { questionMinimapAttachmentPlaceholder: '(Вложение)', referenceChunkCount: '{count} фрагмент(ов)', fallbackHint: 'В базе знаний не найдено релевантного содержимого. Выше представлен прямой ответ модели.', + usageHint: 'Использование токенов за этот ход (вход → выход · всего · попадание в кэш)', + usageCached: 'кэш {n}', requestInfoTitle: 'Request info', requestInfoRequestId: 'Request ID', requestInfoMessageId: 'Message ID', @@ -4473,7 +4493,7 @@ export default { name: 'Название', nameDesc: 'Для админ-списков и идентификации канала', namePlaceholder: 'напр. Поддержка на сайте', - nameDefaultHint: 'По умолчанию «{agent} · Встраивание на сайт». Можно изменить; при пустом поле при сохранении используется имя по умолчанию.', + nameDefaultHint: 'По умолчанию «имя агента · Встраивание на сайт». Можно изменить; при пустом поле при сохранении используется имя по умолчанию.', defaultChannelName: 'Встраивание на сайт', defaultChannelNameWithAgent: '{agent} · Встраивание на сайт', welcomeMessage: 'Приветствие', @@ -4677,7 +4697,7 @@ export default { customAgentMissingSummaryModel: 'Модель беседы', customAgentMissingRerankModel: 'Модель переранжирования', goToAgentEditor: 'Go to configure →', - agentNotReadyDetail: 'Agent не готов. Пожалуйста, настройте следующее: {reasons}', + agentNotReadyDetail: 'Агент «{agentName}» не готов. Пожалуйста, настройте следующее: {reasons}', sharedAgentNotReadyDetail: 'Общий agent «{agentName}» не готов (отсутствует: {reasons}). Обратитесь к администратору организации, которая поделилась agent.', knowledgeBase: 'База знаний', knowledgeBaseWithCount: 'База знаний ({count})', @@ -4697,6 +4717,7 @@ export default { kbLockedByAgent: 'Knowledge base configuration is locked by the current agent', kbDisabledByAgent: 'Knowledge base is disabled by the current agent', modelLockedByAgent: 'Model selection is locked by the current agent', + thinkingLevelResetToast: 'Модель переключена. Выбранный уровень размышления не поддерживается новой моделью и сброшен на значение по умолчанию.', imageUploadDisabledByAgent: 'Image upload is not enabled for this agent', goToAgentSettings: 'Go to agent settings', messages: { diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 725d2aa58d0..6623725b817 100755 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -993,6 +993,7 @@ export default { }, agentEditor: { builtinHint: '这是内置智能体,名称和描述不可修改,但可以调整配置参数', + thinkingLevelResetToast: '已切换模型,思考档位不在新模型支持范围内,已重置为跟随默认', fileTypes: { label: '支持的文件类型', desc: '限制可选择的文件类型,留空表示支持所有类型', @@ -2331,11 +2332,6 @@ export default { desc: '配置用于语音识别和音频转录的语音转文本模型(如 OpenAI Whisper)', empty: '暂无 ASR 语音模型' }, - vllm: { - title: 'VLLM 视觉模型', - desc: '配置用于视觉理解和多模态的视觉语言模型', - empty: '暂无 VLLM 视觉模型' - }, rerank: { title: 'ReRank 模型', desc: '配置用于结果重排序的模型', @@ -2585,7 +2581,15 @@ export default { languageDescription: '选择界面显示语言', languageSaved: '语言设置已保存' }, + thinking: { + levelLabel: '思考档位', + levelPlaceholder: '跟随模型默认', + reset: '重置', + }, model: { + credentials: { + apiKey: 'API Key', + }, modelName: '模型名称', defaultTag: '默认', addModelInSettings: '前往全局设置添加模型', @@ -2600,6 +2604,7 @@ export default { typeLabel: '模型类型', sectionSource: '模型来源', sectionProvider: '接入配置', + sectionParameters: '模型参数设置', sectionAdvanced: '高级选项', sourceLabel: '模型来源', sourceLocal: 'Ollama', @@ -2609,7 +2614,6 @@ export default { displayNamePlaceholder: '例如:客服问答模型', displayNameDesc: '仅用于界面展示,实际调用仍使用上面的模型名称。', baseUrlPlaceholder: '例如:https://api.openai.com/v1', - baseUrlPlaceholderVllm: '例如:http://localhost:11434/v1', baseUrlPlaceholderAsr: '例如:https://api.openai.com/v1', apiKeyOptional: 'API Key(可选)', apiKeyPlaceholder: '输入 API Key', @@ -2632,6 +2636,11 @@ export default { dimensionOverrideDesc: '仅在确认该模型支持 dimensions 参数时开启;默认只使用检测到的实际维度。', supportsVisionLabel: '支持视觉/多模态', supportsVisionDesc: '模型是否支持图片等多模态输入', + inputModalitiesLabel: '输入模态', + inputModalitiesDesc: '勾选该模型支持的输入模态;文本为所有模型的基础能力,默认勾选。', + modalityText: '文本', + modalityImage: '图片', + modalityAudio: '音频', contextWindowLabel: '上下文窗口', contextWindowPlaceholder: '默认 {value}', contextWindowDesc: '该模型一次请求能容纳的 token 数。智能体压缩对话历史会按此上限工作。留空则使用默认 200000(200K)。请按厂商文档填写真实值,填大会导致压缩不触发、上游直接拒绝请求。', @@ -2640,8 +2649,21 @@ export default { maxConcurrencyLabel: '后台并发上限', maxConcurrencyPlaceholder: '0 表示使用全局默认', maxConcurrencyDesc: '限制文档入库/富化等后台任务对该模型的并发调用数(按模型全副本共享)。0 或留空表示沿用全局默认;不影响交互式对话。', - thinkingControlLabel: '思考模式参数格式', - thinkingControlDesc: '决定智能体「思考模式」开/关时如何写入 API。已尝试按厂商/模型预选,若与实际情况不符请按 API 文档手动修改;选「不写入」时,智能体「思考模式」开关不生效。', + thinkingToggleLabel: '思考开关', + thinkingToggleDesc: '关闭后请求不携带思考参数;档位选择不受影响。', + selectedLevelsLabel: '支持的思考档位', + selectedLevelsPlaceholder: '选择该模型支持的档位', + selectedLevelsDesc: '从厂商档位词表中勾选该模型实际支持的档位;留空表示未指定。', + thinkingLevelLabel: '默认思考档位', + thinkingLevelPlaceholder: '留空 = 服务商自决', + thinkingLevelDesc: '用户/智能体未指定档位时使用的默认档;留空由模型服务商决定(用厂商默认或不发档位参数)。', + thinkingLevelsUnsupportedHint: '该模型不支持思考强度调节', + maxOutputTokensLabel: '最大输出 token', + maxOutputTokensPlaceholder: '留空 = 不限制', + maxOutputTokensDesc: '该模型单次回复最多生成的 token 数。留空则不做限制。', + sourceCatalog: '目录', + visionDisabledHint: '当前厂商未声明支持图像输入', + dimensionOverrideDisabledHint: '当前厂商未声明支持自定义输出维度', dimensionHint: '模型已选择,点击"检测维度"按钮自动获取向量维度', loadModelListFailed: '加载模型列表失败', listRefreshed: '列表已刷新', @@ -2657,6 +2679,8 @@ export default { connectionConfigError: '连接失败,请检查配置', downloadStarted: '开始下载 {name}', downloadCompleted: '{name} 下载完成', + appIdLabel: 'App ID', + appSecretLabel: 'App Secret', downloadFailed: '{name} 下载失败', downloadStartFailed: '启动下载失败', ollamaUnavailable: 'Ollama服务不可用,无法选择本地模型', @@ -2735,7 +2759,11 @@ export default { }, generic: { label: '自定义 (OpenAI兼容接口)', - description: 'Generic API endpoint (OpenAI-compatible)' + description: 'Generic API endpoint (OpenAI-compatible)', + ollama: { + label: 'Ollama', + description: '本地 Ollama 服务(http://localhost:11434)', + }, }, requesty: { label: 'Requesty', @@ -2776,25 +2804,18 @@ export default { modelNameMax: '模型名称不能超过100个字符', baseUrlRequired: '请输入 Base URL', baseUrlEmpty: 'Base URL 不能为空', - baseUrlInvalid: 'Base URL 格式不正确,请输入有效的 URL' + baseUrlInvalid: 'Base URL 格式不正确,请输入有效的 URL', + contextWindowRange: '上下文窗口需在 1024 ~ 10000000 之间', + maxOutputTokensRange: '最大输出 Token 需为正整数', + maxConcurrencyRange: '后台并发上限需为非负整数', + credentialRequired: '请填写{field}' }, - thinkingControl: { - thinkingType: { - label: 'thinking.type', - hint: '火山引擎 Ark;腾讯云 LKEAP(DeepSeek V3 等,选 LKEAP 时默认此项;R1 请改「不写入」)' - }, - enableThinking: { - label: 'enable_thinking', - hint: '阿里云 DashScope:qwen3、qwen-plus、qwen-max、qwen-turbo' - }, - chatTemplateKwargs: { - label: 'chat_template_kwargs', - hint: '自定义 OpenAI 兼容、NVIDIA NIM、vLLM / 本地 Qwen 部署' - }, - none: { - label: '不写入思考参数', - hint: '智能体「思考模式」开关不生效,不会在请求中写入思考相关参数' - } + thinkingLevels: { + low: '低', + medium: '中', + high: '高', + xhigh: '超高', + max: '最大' }, volcengine: { accessKeyLabel: 'Access Key ID', @@ -2816,15 +2837,12 @@ export default { modelNamePlaceholder: { local: '例如:llama2:latest', remote: '例如:gpt-4, claude-3-opus', - localVllm: '例如:llava:latest', - remoteVllm: '例如:gpt-4-vision-preview', remoteAsr: '例如:whisper-1' }, description: { chat: '配置用于对话的大语言模型', embedding: '配置用于文本向量化的嵌入模型', rerank: '配置用于结果重排序的模型', - vllm: '配置用于视觉理解和多模态的视觉语言模型', asr: '配置用于语音识别和音频转录的语音转文本模型', default: '配置模型信息' } @@ -3658,6 +3676,8 @@ export default { questionMinimapAttachmentPlaceholder: '(附件)', referenceChunkCount: '{count}个片段', fallbackHint: '未从知识库中检索到相关内容,以上为模型直接回答', + usageHint: '本轮模型调用的 token 用量(输入 → 输出 · 总计 · 缓存命中)', + usageCached: '缓存 {n}', requestInfoTitle: '请求信息', requestInfoRequestId: 'Request ID', requestInfoMessageId: '消息 ID', @@ -4699,6 +4719,7 @@ export default { kbLockedByAgent: '当前智能体已锁定知识库配置', kbDisabledByAgent: '当前智能体已禁用知识库功能', modelLockedByAgent: '当前智能体已锁定模型配置', + thinkingLevelResetToast: '已切换模型,思考档位不在新模型支持范围内,已重置为跟随默认', imageUploadDisabledByAgent: '当前智能体未启用图片上传', goToAgentSettings: '去设置智能体', messages: { diff --git a/frontend/src/stores/settings.ts b/frontend/src/stores/settings.ts index 684f7702cc8..a54ca6ffa8f 100644 --- a/frontend/src/stores/settings.ts +++ b/frontend/src/stores/settings.ts @@ -52,7 +52,7 @@ interface ModelItem { baseUrl?: string; // 远程API URL apiKey?: string; // 远程API Key dimension?: number; // Embedding专用:向量维度 - interfaceType?: 'ollama' | 'openai'; // VLLM专用:接口类型 + interfaceType?: 'ollama' | 'openai'; // Ollama 本地模型接口类型 isDefault?: boolean; // 是否为默认模型 } @@ -61,7 +61,6 @@ interface ModelConfig { chatModels: ModelItem[]; embeddingModels: ModelItem[]; rerankModels: ModelItem[]; - vllmModels: ModelItem[]; // VLLM视觉模型 } // Ollama 配置接口 @@ -91,8 +90,7 @@ const defaultSettings: Settings = { modelConfig: { chatModels: [], embeddingModels: [], - rerankModels: [], - vllmModels: [] + rerankModels: [] }, ollamaConfig: { baseUrl: "http://localhost:11434", @@ -232,7 +230,7 @@ export const useSettingsStore = defineStore("settings", { }, // 添加模型 - addModel(type: 'chat' | 'embedding' | 'rerank' | 'vllm', model: ModelItem) { + addModel(type: 'chat' | 'embedding' | 'rerank', model: ModelItem) { const key = `${type}Models` as keyof ModelConfig; const models = [...this.settings.modelConfig[key]] as ModelItem[]; // 如果设为默认,取消其他模型的默认状态 @@ -249,7 +247,7 @@ export const useSettingsStore = defineStore("settings", { }, // 更新模型 - updateModel(type: 'chat' | 'embedding' | 'rerank' | 'vllm', modelId: string, updates: Partial) { + updateModel(type: 'chat' | 'embedding' | 'rerank', modelId: string, updates: Partial) { const key = `${type}Models` as keyof ModelConfig; const models = [...this.settings.modelConfig[key]] as ModelItem[]; const index = models.findIndex(m => m.id === modelId); @@ -265,7 +263,7 @@ export const useSettingsStore = defineStore("settings", { }, // 删除模型 - deleteModel(type: 'chat' | 'embedding' | 'rerank' | 'vllm', modelId: string) { + deleteModel(type: 'chat' | 'embedding' | 'rerank', modelId: string) { const key = `${type}Models` as keyof ModelConfig; let models = [...this.settings.modelConfig[key]] as ModelItem[]; const deletedModel = models.find(m => m.id === modelId); @@ -279,7 +277,7 @@ export const useSettingsStore = defineStore("settings", { }, // 设置默认模型 - setDefaultModel(type: 'chat' | 'embedding' | 'rerank' | 'vllm', modelId: string) { + setDefaultModel(type: 'chat' | 'embedding' | 'rerank', modelId: string) { const key = `${type}Models` as keyof ModelConfig; const models = [...this.settings.modelConfig[key]] as ModelItem[]; models.forEach(m => m.isDefault = (m.id === modelId)); diff --git a/frontend/src/utils/contextWindow.test.ts b/frontend/src/utils/contextWindow.test.ts index 5fda946a7ab..c1faf032639 100644 --- a/frontend/src/utils/contextWindow.test.ts +++ b/frontend/src/utils/contextWindow.test.ts @@ -30,12 +30,13 @@ test('formatContextWindow uses the default when unset', () => { assert.equal(formatContextWindow(128000), '128K') }) -test('modelHasContextWindow is chat and VLM only', () => { +test('modelHasContextWindow is chat only (vision rides the chat type, ADR 0004)', () => { assert.equal(modelHasContextWindow('KnowledgeQA'), true) - assert.equal(modelHasContextWindow('VLLM'), true) assert.equal(modelHasContextWindow('chat'), true) assert.equal(modelHasContextWindow('Embedding'), false) assert.equal(modelHasContextWindow('Rerank'), false) + assert.equal(modelHasContextWindow('ASR'), false) + assert.equal(modelHasContextWindow('VLLM'), false) assert.equal(isDefaultContextWindow(0), true) assert.equal(isDefaultContextWindow(200000), false) }) diff --git a/frontend/src/utils/contextWindow.ts b/frontend/src/utils/contextWindow.ts index ea518e538e7..650f9668111 100644 --- a/frontend/src/utils/contextWindow.ts +++ b/frontend/src/utils/contextWindow.ts @@ -2,7 +2,7 @@ export const DEFAULT_MODEL_CONTEXT_WINDOW = 200000 export function modelHasContextWindow(type?: string): boolean { - return type === 'KnowledgeQA' || type === 'VLLM' || type === 'chat' || type === 'vllm' + return type === 'KnowledgeQA' || type === 'chat' } /** Tokens the backend will actually use: the model's value, or 200K. */ diff --git a/frontend/src/utils/thinkingControl.test.ts b/frontend/src/utils/thinkingControl.test.ts deleted file mode 100644 index 1840e34e657..00000000000 --- a/frontend/src/utils/thinkingControl.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import assert from 'node:assert/strict' -import test from 'node:test' - -import { defaultThinkingControl } from './thinkingControl.ts' - -// Cases mirror internal/models/chat/provider_test.go TestResolveProvider thinking defaults. -test('defaultThinkingControl matches backend provider adapters', () => { - const cases: Array<[string, string, ReturnType]> = [ - ['generic', 'anything', 'chat_template_kwargs'], - ['nvidia', 'anything', 'chat_template_kwargs'], - ['litellm', 'anything', 'chat_template_kwargs'], - ['volcengine', 'doubao', 'thinking_type'], - ['aliyun', 'qwen3-32b', 'enable_thinking'], - ['aliyun', 'qwen-plus', 'enable_thinking'], - ['aliyun', 'gpt-4', 'none'], - ['lkeap', '', 'thinking_type'], - ['lkeap', 'deepseek-v3.1', 'thinking_type'], - ['lkeap', 'deepseek-r1', 'none'], - ['openai', 'gpt-4o', 'none'], - ['openai', 'gpt-5', 'none'], - ['azure_openai', 'gpt-4', 'none'], - ['deepseek', 'deepseek-chat', 'none'], - ['zhipu', 'glm-4', 'none'], - ['gemini', 'gemini-2.0', 'none'], - ['siliconflow', 'qwen3-8b', 'none'], - ['hunyuan', 'hunyuan-turbo', 'none'], - ['moonshot', 'moonshot-v1-8k', 'none'], - ['weknoracloud', 'anything', 'none'], - ] - for (const [provider, model, want] of cases) { - assert.equal( - defaultThinkingControl(provider, model), - want, - `${provider}/${model}`, - ) - } -}) diff --git a/frontend/src/utils/thinkingControl.ts b/frontend/src/utils/thinkingControl.ts deleted file mode 100644 index 9ec34cbb742..00000000000 --- a/frontend/src/utils/thinkingControl.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** Mirrors backend internal/models/provider.IsQwenThinkingModel */ -export function isQwenThinkingModel(modelName: string): boolean { - const lower = modelName.trim().toLowerCase() - return ( - lower.startsWith('qwen3') - || lower.startsWith('qwen-plus') - || lower.startsWith('qwen-max') - || lower.startsWith('qwen-turbo') - ) -} - -/** Mirrors backend internal/models/provider.IsLKEAPDeepSeekR1Model */ -export function isLkeapDeepSeekR1Model(modelName: string): boolean { - return modelName.toLowerCase().includes('deepseek-r1') -} - -export type ThinkingControlValue = - | 'none' - | 'chat_template_kwargs' - | 'enable_thinking' - | 'thinking_type' - -const THINKING_CONTROL_VALUES: ThinkingControlValue[] = [ - 'none', - 'chat_template_kwargs', - 'enable_thinking', - 'thinking_type', -] - -/** - * Default thinking_control for provider+model. - * Must stay aligned with chat.resolveProvider(...).Thinking() in provider.go. - */ -export function defaultThinkingControl( - provider: string, - modelName = '', -): ThinkingControlValue { - const p = provider.trim().toLowerCase() - const model = modelName.trim() - - switch (p) { - case 'aliyun': - return isQwenThinkingModel(model) ? 'enable_thinking' : 'none' - case 'lkeap': - // R1 系列后端不发 thinking 参数;其余(含未填模型名)按 LKEAP 的 thinking.type 格式预选 - if (model && isLkeapDeepSeekR1Model(model)) return 'none' - return 'thinking_type' - case 'generic': - case 'nvidia': - case 'litellm': - return 'chat_template_kwargs' - case 'volcengine': - return 'thinking_type' - default: - // openai, azure_openai, anthropic, zhipu, deepseek, gemini, siliconflow, - // hunyuan, moonshot, openrouter, weknoracloud, … → baseProvider / noThinking - return 'none' - } -} - -/** Resolve stored extra_config or fall back to the provider default. */ -export function resolveThinkingControl( - saved: string | undefined, - provider: string, - modelName = '', -): ThinkingControlValue { - const v = saved?.trim().toLowerCase() - if (THINKING_CONTROL_VALUES.includes(v as ThinkingControlValue)) { - return v as ThinkingControlValue - } - return defaultThinkingControl(provider, modelName) -} - -/** Whether the debug drawer should expose Think on/off for this saved model. */ -export function modelSupportsThinking(model: { - type: string - source: string - name: string - parameters: { - provider?: string - extra_config?: { thinking_control?: string } - } -}): boolean { - if (model.type !== 'KnowledgeQA' || model.source !== 'remote') return false - return resolveThinkingControl( - model.parameters.extra_config?.thinking_control, - model.parameters.provider || '', - model.name || '', - ) !== 'none' -} diff --git a/frontend/src/utils/weknoraCloudModels.ts b/frontend/src/utils/weknoraCloudModels.ts index 97fd2ae4822..ffea7d225d5 100644 --- a/frontend/src/utils/weknoraCloudModels.ts +++ b/frontend/src/utils/weknoraCloudModels.ts @@ -9,7 +9,8 @@ const BACKEND_TYPE_BY_KIND: Record = { chat: 'KnowledgeQA', embedding: 'Embedding', rerank: 'Rerank', - vllm: 'VLLM', + // ADR 0004:视觉不再是类型——wkc 的 vlm 条目落库为声明了图片输入的对话模型 + vllm: 'KnowledgeQA', } export const WKC_MODEL_NAME_BY_KIND: Record = { @@ -31,7 +32,13 @@ export function existingWkcKinds(models: ModelConfig[]): Set { for (const model of models) { if (!isWeKnoraCloudModel(model)) continue for (const kind of WKC_MODEL_KINDS) { - if (model.type === BACKEND_TYPE_BY_KIND[kind]) { + // chat 与 vllm 两种 kind 落库后同为 KnowledgeQA,type 无法区分—— + // 以 wkc 固定的模型名(WKC_MODEL_NAME_BY_KIND)补齐判定位。 + if ( + model.type === BACKEND_TYPE_BY_KIND[kind] + && (model.name === WKC_MODEL_NAME_BY_KIND[kind] + || model.display_name === WKC_MODEL_NAME_BY_KIND[kind]) + ) { found.add(kind) } } diff --git a/frontend/src/views/agent/AgentEditorModal.vue b/frontend/src/views/agent/AgentEditorModal.vue index 7e93b463a7a..5c571b41537 100644 --- a/frontend/src/views/agent/AgentEditorModal.vue +++ b/frontend/src/views/agent/AgentEditorModal.vue @@ -637,14 +637,16 @@
- -
-
- -

{{ $t('agentEditor.desc.thinking') }}

-
-
- + +
+
+
@@ -765,10 +767,10 @@

{{ $t('agentEditor.imageUpload.vlmModelDesc') }}

-
@@ -1868,6 +1870,8 @@ import AgentAvatar from '@/components/AgentAvatar.vue'; import PromptTemplateSelector from '@/components/PromptTemplateSelector.vue'; import ModelSelector from '@/components/ModelSelector.vue'; import SandboxSkillsPanel from '@/components/SandboxSkillsPanel.vue'; +import ThinkingControls from '@/components/ThinkingControls.vue'; +import { listModelProviders, type ModelProviderOption } from '@/api/initialization'; import SettingDrawer from '@/components/settings/SettingDrawer.vue'; import KBParserSettings, { type ParserEngineRule } from '@/views/knowledge/settings/KBParserSettings.vue'; import AgentShareSettings from '@/components/AgentShareSettings.vue'; @@ -2731,6 +2735,7 @@ const defaultFormData = { temperature: 0.7, max_completion_tokens: 0, thinking: false, // 默认禁用思考模式 + thinking_level: '', // 思考档位;空 = 跟随模型默认 citation_enabled: true, // 默认输出知识库/网页来源引用 // Agent模式设置 max_iterations: 10, @@ -3351,10 +3356,67 @@ const onAgentTypeChange = (val: AgentType) => { } }; -// 思考模式计算属性(直接绑定 boolean) -const thinkingEnabled = computed({ - get: () => formData.value.config.thinking === true, - set: (val: boolean) => { formData.value.config.thinking = val; } +// ---- 思考控件(ThinkingControls single 形态,design §8.1.2) ---- + +// 厂商能力声明缓存(/models/providers?type=chat);加载失败时控件整体置灰兜底。 +const chatProviderOptions = ref([]); +const loadChatProviderCaps = async () => { + try { + chatProviderOptions.value = await listModelProviders('chat'); + } catch (e) { + console.error('Failed to load chat provider capabilities', e); + } +}; +onMounted(loadChatProviderCaps); + +// 所选对话模型与其思考能力声明 / chat 分片 +const selectedAgentModel = computed(() => + allModels.value.find(m => m.id === formData.value.config.model_id)); +const agentModelChatShard = computed(() => selectedAgentModel.value?.parameters.chat); +const agentThinkingCaps = computed(() => { + const provider = selectedAgentModel.value?.parameters.provider; + if (!provider) return undefined; + return chatProviderOptions.value.find(p => p.value === provider)?.capabilities?.chat?.thinking; +}); + +/** 模型记录主开关(模型管理页)关闭 → 思考不可用:caps 置 undefined 让 + ThinkingControls 整块不渲染,宿主行随 agentThinkingAvailable 一并隐藏 + (已存值保留,运行时入口亦有硬门兜底),与会话面板"消失不见"的语义 + 同步(模型级开关 = 硬门)。 */ +const agentThinkingAvailable = computed(() => + agentThinkingCaps.value?.supported === true + && agentModelChatShard.value?.thinking_enabled !== false); + +// single 档位 options = (分片子集 ?? 厂商枚举) ∩ 厂商枚举(与组件内规则一致) +const agentThinkingLevelOptions = computed(() => { + const levels = agentThinkingCaps.value?.supported_levels ?? []; + const shard = agentModelChatShard.value?.selected_levels ?? []; + return shard.length ? levels.filter(l => shard.includes(l)) : levels; +}); + +// 开关 → config.thinking(bool 三态语义不变);档位 → config.thinking_level(空 = 跟随模型默认) +const thinkingValue = computed({ + get: () => ({ + enabled: formData.value.config.thinking === true, + level: formData.value.config.thinking_level || '', + }), + set: (v) => { + formData.value.config.thinking = v.enabled ?? false; + formData.value.config.thinking_level = v.level || ''; + }, +}); + +// 模型切换边界:已存档位 ∉ 新模型集合 → 自动清空为"跟随模型默认" + toast +// (不留隐藏非法状态;即使漏清,wire 层回落链也兜底)。不支持思考的模型 +// 已存值保留不丢(模型换回来仍生效)。 +watch(() => formData.value.config.model_id, () => { + const level = formData.value.config.thinking_level; + if (!level) return; + const options = agentThinkingLevelOptions.value; + if (options.length > 0 && !options.includes(level)) { + formData.value.config.thinking_level = ''; + MessagePlugin.warning(t('agentEditor.thinkingLevelResetToast')); + } }); // 是否为内置智能体 diff --git a/frontend/src/views/chat/components/botmsg.vue b/frontend/src/views/chat/components/botmsg.vue index 0c6b8458abd..bbb6a2087f9 100644 --- a/frontend/src/views/chat/components/botmsg.vue +++ b/frontend/src/views/chat/components/botmsg.vue @@ -75,6 +75,11 @@
+ + + + {{ usageSummary }} +
{ } }; -const sendMsg = async (value, modelId = '', mentionedItems = [], imageFiles = [], attachmentFiles = []) => { +const sendMsg = async (value, modelId = '', mentionedItems = [], imageFiles = [], attachmentFiles = [], thinkingLevel = '', thinkingEnabled) => { stopStream(); prepareForNewOutgoingMessage(); activitySessionId.value = String(session_id.value); @@ -1279,6 +1279,8 @@ const sendMsg = async (value, modelId = '', mentionedItems = [], imageFiles = [] web_search_enabled: webSearchEnabled, local_browser_enabled: !props.embeddedMode && agentEnabled && useSettingsStoreInstance.isLocalBrowserEnabled && !useBrowserConnectionStore().knownOffline, summary_model_id: modelId, + thinking_level: thinkingLevel || undefined, + thinking: typeof thinkingEnabled === 'boolean' ? thinkingEnabled : undefined, mcp_service_ids: requestMcpServiceIds, skill_names: requestSkillNames, tag_ids: tagIds, diff --git a/frontend/src/views/knowledge/KnowledgeBaseEditorModal.vue b/frontend/src/views/knowledge/KnowledgeBaseEditorModal.vue index 0d7ff0334fb..d2fb022ff6c 100644 --- a/frontend/src/views/knowledge/KnowledgeBaseEditorModal.vue +++ b/frontend/src/views/knowledge/KnowledgeBaseEditorModal.vue @@ -307,7 +307,7 @@
{ } const handleAddVLLMModel = () => { - uiStore.openSettings('models', 'vllm') + uiStore.openSettings('models', 'chat') } const handleAddASRModel = () => { diff --git a/frontend/src/views/settings/ModelSettings.vue b/frontend/src/views/settings/ModelSettings.vue index 5171c86456d..c9e7670b8df 100644 --- a/frontend/src/views/settings/ModelSettings.vue +++ b/frontend/src/views/settings/ModelSettings.vue @@ -41,7 +41,6 @@ - @@ -61,7 +60,13 @@ @click="onModelCardClick($event, model._modelType, model)" @keydown.enter="onModelCardClick($event, model._modelType, model)">
- + + + +
@@ -107,7 +112,7 @@ · {{ $t('model.editor.dimensionLabel') }} {{ model.dimension }} -