From 39fe2732fb5376d491f173728b8e7342ba3d2933 Mon Sep 17 00:00:00 2001 From: moranzhi Date: Wed, 30 Sep 2026 12:00:27 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=8A=E6=80=9D=E8=80=83=E5=BC=BA=E5=BA=A6?= =?UTF-8?q?=E6=94=B6=E5=88=B0=E6=8E=A5=E5=8F=A3=E9=85=8D=E7=BD=AE=E9=87=8C?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E6=94=AF=E6=8C=81=E5=88=86=E7=BB=84=E3=80=81?= =?UTF-8?q?=E5=97=85=E6=8E=A2=E5=92=8C=E5=88=87=E6=8D=A2=E5=90=8C=E4=B8=80?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E4=B8=8B=E7=9A=84=E6=A8=A1=E5=9E=8B=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 预设不再保存思考强度。设置页会在首屏套用主题。 Co-authored-by: Cursor --- docs/preset-format.md | 5 +- src/config/api-profiles.ts | 273 +++++++-- src/config/settings.ts | 1 + src/llm/capabilities.ts | 16 +- src/llm/client.ts | 48 +- src/llm/generation-compat.ts | 138 ++++- src/llm/list-models.ts | 153 +++++ src/llm/preset-wrapper.ts | 19 +- src/preset/generation.ts | 3 - src/preset/importer.ts | 1 - src/preset/store.ts | 20 +- src/server/settings-handlers.ts | 124 +++- src/server/web-server.ts | 1 + tests/api-profile-usage.test.ts | 47 ++ tests/api-profiles-models.test.ts | 174 ++++++ tests/generation-compat.test.ts | 172 ++++++ tests/model-sniff-cache.test.ts | 45 ++ tests/preset-wrapper.test.ts | 3 +- tests/preset.test.ts | 1 - web/api-profile-usage.js | 140 +++++ web/model-sniff-cache.js | 49 ++ web/settings.css | 927 ++++++++++++++++++++++++++++-- web/settings.html | 67 ++- web/settings.js | 777 +++++++++++++++++++++++-- 24 files changed, 3047 insertions(+), 157 deletions(-) create mode 100644 src/llm/list-models.ts create mode 100644 tests/api-profile-usage.test.ts create mode 100644 tests/api-profiles-models.test.ts create mode 100644 tests/model-sniff-cache.test.ts create mode 100644 web/api-profile-usage.js create mode 100644 web/model-sniff-cache.js diff --git a/docs/preset-format.md b/docs/preset-format.md index 816eae3..64d14e5 100644 --- a/docs/preset-format.md +++ b/docs/preset-format.md @@ -133,7 +133,6 @@ type ImportedGenerationParameters = { openai_max_context?: number; openai_max_tokens?: number; stream_openai?: boolean; - reasoning_effort?: string; verbosity?: string; seed?: number; n?: number; @@ -147,7 +146,8 @@ type ImportedGenerationParameters = { temperature、top_p、top_k、min_p、frequency_penalty、presence_penalty、repetition_penalty。 请求容量与行为参数 - openai_max_context、openai_max_tokens、stream_openai、reasoning_effort、verbosity、seed、n。 + openai_max_context、openai_max_tokens、stream_openai、verbosity、seed、n。 + 思考强度不进预设,只在 API 配置里设置。 ``` 不同供应商不一定支持全部字段。导入后应先保存原始字段,再由 LLM adapter 决定哪些字段可以发送。 @@ -200,7 +200,6 @@ type GenerationParameters = { maxContextTokens?: number; maxOutputTokens?: number; stream?: boolean; - reasoningEffort?: string; verbosity?: string; seed?: number; variants?: number; diff --git a/src/config/api-profiles.ts b/src/config/api-profiles.ts index f480b0a..3507540 100644 --- a/src/config/api-profiles.ts +++ b/src/config/api-profiles.ts @@ -1,4 +1,4 @@ -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { @@ -7,6 +7,7 @@ import { type ProfileCapabilities, } from "../llm/capabilities.js"; import { sanitizeReasoningEffort } from "../llm/generation-compat.js"; +import { normalizeBaseUrl } from "../llm/list-models.js"; import type { LlmConfig } from "./env.js"; import { loadLlmConfigOptional } from "./env.js"; import { ensureUserDataDirs, getUserDataDir } from "./user-data-dir.js"; @@ -16,12 +17,21 @@ export type ApiProfile = { name: string; baseUrl: string; apiKey: string; + /** 这一条配置对应的模型 */ model: string; /** - * 该配置默认思考强度(按型号写入 reasoning_effort / thinking)。 - * 省略 / auto = 不发送;none = 关闭或映射为最轻档;带 tools 时非 GLM 强制 none,GLM-5.3 保留合法档。 - * 探测永远用 low,不受此项影响。 - */ + * 旧数据里曾把同端点的多个模型塞进一条配置。 + * 读入时会拆回独立配置,不再写回。 + */ + models?: string[]; + /** 同 baseUrl + 同 Key 哈希的收纳组 */ + groupId?: string; + /** + * 该配置的思考强度(按型号写入 reasoning_effort / thinking)。预设不带这项。 + * 省略 / auto = 不发送;none = 关闭或映射为最轻档。 + * 带 tools 时:多数型号强制 none;GLM-5.3 与 Gemini 3 保留合法档(Gemini 3.8 为 low/medium/high)。 + * 探测永远用 low,不受此项影响。 + */ reasoningEffort?: string; createdAt: string; updatedAt: string; @@ -29,9 +39,24 @@ export type ApiProfile = { capabilities?: ProfileCapabilities; }; +/** 前厅里折叠显示。分类用 Key 哈希,不比较、不展示原文。 */ +export type ApiProfileGroup = { + id: string; + name: string; + baseUrl: string; + keyHash: string; +}; + +export type ApiProfileGroupView = { + id: string; + name: string; + baseUrl: string; +}; + type ApiProfilesFile = { version: 1; profiles: ApiProfile[]; + groups?: ApiProfileGroup[]; }; const FILE_NAME = "profiles.json"; @@ -69,6 +94,65 @@ export function normalizeProfileReasoningEffort( return sanitizeReasoningEffort(trimmed); } +/** 读旧的 models 字段时用:当前模型放最前,去重。 */ +export function normalizeModelList( + model: string, + models: unknown, +): { model: string; models: string[] } { + const current = model.trim(); + const extra = Array.isArray(models) + ? models + .filter((item): item is string => typeof item === "string") + .map((item) => item.trim()) + .filter(Boolean) + : []; + const seen = new Set(); + const list: string[] = []; + const push = (id: string) => { + if (!id || seen.has(id)) return; + seen.add(id); + list.push(id); + }; + push(current); + for (const id of extra) push(id); + const active = current || list[0] || "deepseek-v4-pro"; + if (!seen.has(active)) list.unshift(active); + return { model: list[0] ?? active, models: list }; +} + +export function apiKeyHash(apiKey: string): string { + return createHash("sha256").update(apiKey.trim()).digest("hex"); +} + +function groupSlot(baseUrl: string, apiKey: string): string { + return `${normalizeBaseUrl(baseUrl)}\n${apiKeyHash(apiKey)}`; +} + +function hostLabel(baseUrl: string): string { + const trimmed = baseUrl.trim(); + try { + return new URL(trimmed).host || trimmed || "未命名组"; + } catch { + return trimmed.replace(/\/+$/, "") || "未命名组"; + } +} + +function uniqueGroupName(host: string, keyHash: string, used: Set): string { + if (!used.has(host)) return host; + const alt = `${host} · ${keyHash.slice(0, 4)}`; + if (!used.has(alt)) return alt; + let n = 2; + while (used.has(`${host} · ${n}`)) n += 1; + return `${host} · ${n}`; +} + +function withoutLegacyModels(profile: ApiProfile): ApiProfile { + if (profile.models === undefined) return profile; + const next = { ...profile }; + delete next.models; + return next; +} + function seedFromEnvIfEmpty(data: ApiProfilesFile): ApiProfilesFile { if (data.profiles.length > 0) return data; const env = loadLlmConfigOptional(); @@ -79,23 +163,129 @@ function seedFromEnvIfEmpty(data: ApiProfilesFile): ApiProfilesFile { name: "环境变量 (.env)", baseUrl: env.baseUrl, apiKey: env.apiKey, - model: env.model, + model: env.model.trim() || "deepseek-v4-pro", createdAt: now, updatedAt: now, }; return { version: 1, profiles: [profile] }; } -export function listApiProfiles(): ApiProfile[] { - const data = seedFromEnvIfEmpty(readFile()); - if (data.profiles.length !== readFile().profiles.length) { - writeFile(data); +function splitLegacyModelLists(profiles: ApiProfile[]): { + profiles: ApiProfile[]; + changed: boolean; +} { + const out: ApiProfile[] = []; + let changed = false; + const now = new Date().toISOString(); + for (const profile of profiles) { + const current = profile.model.trim(); + const extras = normalizeModelList(current, profile.models).models.filter( + (model) => model !== current, + ); + if (profile.models !== undefined) changed = true; + out.push(withoutLegacyModels({ ...profile, model: current || profile.model })); + for (const model of extras) { + changed = true; + const sibling: ApiProfile = { + id: randomUUID(), + name: model, + baseUrl: profile.baseUrl, + apiKey: profile.apiKey, + model, + createdAt: now, + updatedAt: now, + }; + if (profile.reasoningEffort) sibling.reasoningEffort = profile.reasoningEffort; + out.push(sibling); + } } - return data.profiles; + return { profiles: out, changed }; +} + +/** 同 baseUrl + 同 Key 哈希共用一个组。组名可改,分类键不变。 */ +function assignGroups(data: ApiProfilesFile): { data: ApiProfilesFile; changed: boolean } { + const groups = [...(data.groups ?? [])]; + let changed = !Array.isArray(data.groups); + const bySlot = new Map(); + for (const group of groups) { + bySlot.set(`${normalizeBaseUrl(group.baseUrl)}\n${group.keyHash}`, group); + } + const usedNames = new Set(groups.map((group) => group.name)); + const profiles = data.profiles.map((profile) => { + const baseUrl = profile.baseUrl.trim().replace(/\/+$/, "") || profile.baseUrl; + const slot = groupSlot(baseUrl, profile.apiKey); + let group = bySlot.get(slot); + if (!group) { + const keyHash = apiKeyHash(profile.apiKey); + const name = uniqueGroupName(hostLabel(baseUrl), keyHash, usedNames); + usedNames.add(name); + group = { + id: randomUUID(), + name, + baseUrl: normalizeBaseUrl(baseUrl) || baseUrl, + keyHash, + }; + groups.push(group); + bySlot.set(slot, group); + changed = true; + } + if (profile.groupId !== group.id || profile.baseUrl !== baseUrl || profile.models) { + changed = true; + return withoutLegacyModels({ ...profile, baseUrl, groupId: group.id }); + } + return profile; + }); + const used = new Set(profiles.map((profile) => profile.groupId).filter(Boolean)); + const kept = groups.filter((group) => used.has(group.id)); + if (kept.length !== groups.length) changed = true; + return { data: { version: 1, profiles, groups: kept }, changed }; +} + +function loadProfiles(): ApiProfilesFile { + const raw = readFile(); + const seeded = seedFromEnvIfEmpty(raw); + const split = splitLegacyModelLists(seeded.profiles); + const assigned = assignGroups({ + version: 1, + profiles: split.profiles, + groups: seeded.groups, + }); + if (seeded !== raw || split.changed || assigned.changed) { + writeFile(assigned.data); + } + return assigned.data; +} + +export function listApiProfiles(): ApiProfile[] { + return loadProfiles().profiles; } export function getApiProfile(id: string): ApiProfile | null { - return listApiProfiles().find((p) => p.id === id) ?? null; + return listApiProfiles().find((profile) => profile.id === id) ?? null; +} + +export function listApiGroups(): ApiProfileGroupView[] { + return (loadProfiles().groups ?? []).map((group) => ({ + id: group.id, + name: group.name, + baseUrl: group.baseUrl, + })); +} + +export function renameApiProfileGroup(id: string, name: string): ApiProfileGroupView { + const trimmed = name.trim(); + if (!trimmed) throw new Error("组名不能为空"); + const data = loadProfiles(); + const index = (data.groups ?? []).findIndex((group) => group.id === id); + if (index < 0) throw new Error("配置组不存在"); + const groups = [...(data.groups ?? [])]; + const current = groups[index]!; + if (current.name === trimmed) { + return { id: current.id, name: current.name, baseUrl: current.baseUrl }; + } + groups[index] = { ...current, name: trimmed }; + writeFile({ ...data, groups }); + return { id, name: trimmed, baseUrl: current.baseUrl }; } export function createApiProfile(input: { @@ -105,7 +295,7 @@ export function createApiProfile(input: { model: string; reasoningEffort?: string; }): ApiProfile { - const data = readFile(); + const data = loadProfiles(); const now = new Date().toISOString(); const reasoningEffort = normalizeProfileReasoningEffort(input.reasoningEffort); const profile: ApiProfile = { @@ -119,8 +309,9 @@ export function createApiProfile(input: { updatedAt: now, }; data.profiles.push(profile); - writeFile(data); - return profile; + const assigned = assignGroups(data); + writeFile(assigned.data); + return assigned.data.profiles.find((item) => item.id === profile.id) ?? profile; } export function updateApiProfile( @@ -129,43 +320,50 @@ export function updateApiProfile( Pick >, ): ApiProfile { - const data = readFile(); - const index = data.profiles.findIndex((p) => p.id === id); + const data = loadProfiles(); + const index = data.profiles.findIndex((profile) => profile.id === id); if (index < 0) throw new Error("API 配置不存在"); - const current = data.profiles[index]; - const nextBase = input.baseUrl?.trim() || current.baseUrl; + const current = data.profiles[index]!; + const nextBase = + input.baseUrl !== undefined + ? input.baseUrl.trim() || current.baseUrl + : current.baseUrl; const nextModel = input.model?.trim() || current.model; + const nextKey = + input.apiKey !== undefined && input.apiKey.trim() !== "" + ? input.apiKey.trim() + : current.apiKey; const endpointChanged = - nextBase !== current.baseUrl || nextModel !== current.model; + normalizeBaseUrl(nextBase) !== normalizeBaseUrl(current.baseUrl) || + nextModel !== current.model; const reasoningEffort = input.reasoningEffort !== undefined ? normalizeProfileReasoningEffort(input.reasoningEffort) : current.reasoningEffort; - const updated: ApiProfile = { + const draft: ApiProfile = { ...current, - name: input.name?.trim() || current.name, + name: input.name !== undefined ? input.name.trim() || current.name : current.name, baseUrl: nextBase, - apiKey: input.apiKey !== undefined && input.apiKey.trim() !== "" - ? input.apiKey.trim() - : current.apiKey, + apiKey: nextKey, model: nextModel, updatedAt: new Date().toISOString(), - // 端点或模型变了,旧探测作废 - ...(endpointChanged ? { capabilities: undefined } : {}), }; - if (reasoningEffort) updated.reasoningEffort = reasoningEffort; - else delete updated.reasoningEffort; - data.profiles[index] = updated; - writeFile(data); - return updated; + delete draft.models; + if (reasoningEffort) draft.reasoningEffort = reasoningEffort; + else delete draft.reasoningEffort; + if (endpointChanged) delete draft.capabilities; + data.profiles[index] = draft; + const assigned = assignGroups(data); + writeFile(assigned.data); + return assigned.data.profiles.find((profile) => profile.id === id) ?? draft; } export function saveProfileCapabilities( id: string, capabilities: ProfileCapabilities, ): ApiProfile { - const data = readFile(); - const index = data.profiles.findIndex((p) => p.id === id); + const data = loadProfiles(); + const index = data.profiles.findIndex((profile) => profile.id === id); if (index < 0) throw new Error("API 配置不存在"); const current = data.profiles[index]!; const updated: ApiProfile = { @@ -193,9 +391,10 @@ export async function probeAndSaveProfileCapabilities( } export function deleteApiProfile(id: string): void { - const data = readFile(); - data.profiles = data.profiles.filter((p) => p.id !== id); - writeFile(data); + const data = loadProfiles(); + data.profiles = data.profiles.filter((profile) => profile.id !== id); + const assigned = assignGroups(data); + writeFile(assigned.data); } export function profileToLlmConfig(profile: ApiProfile): LlmConfig { diff --git a/src/config/settings.ts b/src/config/settings.ts index add1c98..fa2b181 100644 --- a/src/config/settings.ts +++ b/src/config/settings.ts @@ -113,6 +113,7 @@ export function resolveActiveProfile() { baseUrl: env.baseUrl, apiKey: env.apiKey, model: env.model, + models: [env.model], createdAt: "", updatedAt: "", }; diff --git a/src/llm/capabilities.ts b/src/llm/capabilities.ts index 9e5b0e0..8ff03e9 100644 --- a/src/llm/capabilities.ts +++ b/src/llm/capabilities.ts @@ -4,9 +4,12 @@ */ import { + applyMandatoryReasoningFloor, applyRejectedGenerationField, forceReasoningEffortNoneWhenTools, + isReasoningMandatoryError, isToolsReasoningEffortConflictError, + modelRejectsDisabledReasoning, parseRejectedGenerationField, } from "./generation-compat.js"; @@ -170,7 +173,8 @@ async function postOnce( /** * 探测请求始终带 reasoning_effort=low。 - * 若供应商拒收该字段,剥掉后再试;tools 冲突则改 none 再试。 + * Gemini 3 同时写 reasoning.effort。拒收字段则剥掉再试; + * 必须开思考则抬到 low;tools 冲突则改 none 再试。 */ async function postChat( config: ProbeConfig, @@ -180,12 +184,22 @@ async function postChat( ...body, reasoning_effort: CAPABILITY_PROBE_REASONING_EFFORT, }; + if (modelRejectsDisabledReasoning(config.model)) { + withEffort.reasoning = { effort: CAPABILITY_PROBE_REASONING_EFFORT }; + } const first = await postOnce(config, withEffort); if (first.ok || (first.status !== 400 && first.status !== 422)) return first; const retryBody = { ...withEffort }; const tried = new Set(); + if (isReasoningMandatoryError(first.text, first.status)) { + if (applyMandatoryReasoningFloor(retryBody, tried)) { + return postOnce(config, retryBody); + } + return first; + } + if (isToolsReasoningEffortConflictError(first.text, first.status)) { forceReasoningEffortNoneWhenTools(retryBody, config.model); tried.add("reasoning_effort_none"); diff --git a/src/llm/client.ts b/src/llm/client.ts index 0631801..ed9037b 100644 --- a/src/llm/client.ts +++ b/src/llm/client.ts @@ -131,14 +131,50 @@ export type LlmProvider = { ): Promise; }; +const JSON_OBJECT_HINT = "请用 JSON 对象回复。"; + +function messagesMentionJson(messages: ChatMessage[]): boolean { + for (const message of messages) { + if (typeof message.content === "string" && /json/i.test(message.content)) { + return true; + } + } + return false; +} + +/** + * OpenAI `response_format: json_object` 要求 messages 里出现 json,否则 400。 + * 偏好/文风等提示不写这个词;预设夹心也可能丢掉带 JSON 的任务契约。 + */ +export function ensureJsonObjectPrompt(messages: ChatMessage[]): ChatMessage[] { + if (messagesMentionJson(messages)) return messages; + const systemIndex = messages.findIndex((message) => message.role === "system"); + if (systemIndex >= 0) { + const current = messages[systemIndex]; + if (current?.role === "system") { + const next = messages.slice(); + next[systemIndex] = { + role: "system", + content: `${current.content}\n\n${JSON_OBJECT_HINT}`, + }; + return next; + } + } + return [{ role: "system", content: JSON_OBJECT_HINT }, ...messages]; +} + export function buildRequestBody( config: LlmConfig, messages: ChatMessage[], options?: CompleteOptions & { tools?: ToolDefinition[]; stream?: boolean }, ): Record { + const requestMessages = + options?.responseFormat === "json_object" + ? ensureJsonObjectPrompt(messages) + : messages; const body: Record = { model: config.model, - messages, + messages: requestMessages, }; if (options?.tools?.length) { @@ -150,7 +186,7 @@ export function buildRequestBody( applyOpenAiGeneration(body, options?.generation ?? {}, config.model, { reasoningEffort: config.reasoningEffort, }); - // 对齐 imyai:带 tools 时按型号改写思考参数(非 GLM 强制 none) + // 带 tools 时按型号改写思考参数(Gemini 3 / GLM-5.3 保留合法档,其余强制 none) forceReasoningEffortNoneWhenTools(body, config.model); if (options?.responseFormat === "json_object") { @@ -313,10 +349,8 @@ export class OpenAiCompatibleProvider implements LlmProvider { usage?: unknown; choices?: Array<{ message?: Record }>; }; + // 空正文不抛错:游玩/Worker 会落「本轮场面未写完」等占位,仍可重 roll const parts = extractMessageParts(data.choices?.[0]?.message); - if (!parts.content && parts.toolCalls.length === 0) { - throw new Error("LLM returned empty content"); - } return { content: parts.content ?? "", reasoning: parts.reasoning, @@ -347,10 +381,8 @@ export class OpenAiCompatibleProvider implements LlmProvider { throw new Error("LLM stream response has no body"); } + // 空流不抛错:下游会落占位正文,消息正常渲染以便重 roll const parts = await consumeOpenAiToolStream(response.body, callbacks); - if (!parts.content && !parts.reasoning) { - throw new Error("LLM stream returned empty content"); - } return { content: parts.content ?? parts.reasoning ?? "", reasoning: parts.reasoning || undefined, diff --git a/src/llm/generation-compat.ts b/src/llm/generation-compat.ts index 992b251..a4fc82c 100644 --- a/src/llm/generation-compat.ts +++ b/src/llm/generation-compat.ts @@ -7,8 +7,10 @@ import type { GenerationParameters } from "../types/preset.js"; * - 推理模型去掉 temperature / top_p / penalty * - o 系列与 gpt-5 用 max_completion_tokens * - GLM:按型号映射 thinking / reasoning_effort(5.3 拒收 none;旧版拒收 effort) - * - 带 tools 时:非 GLM 强制 reasoning_effort=none(gpt-5.6-luna 等);GLM-5.3 保留合法档 - * - 仍 400 时按错误信息剥掉不支持字段再试;tools+reasoning 冲突则按型号改写再试 + * - Gemini 3(如 gemini-3.8-flash):思考不能关,none/minimal 抬到 low + * - 带 tools 时:非上述型号强制 reasoning_effort=none(gpt-5.6-luna 等);始终开启的型号保留合法档 + * - 仍 400 时按错误信息剥掉不支持字段再试;tools 冲突改 none, + * “Reasoning is mandatory … cannot be disabled” 则抬到 low 再试 */ const REASONING_EFFORT_VALUES = [ @@ -60,6 +62,7 @@ export const STRIPPABLE_GENERATION_FIELDS = new Set([ "stop", "response_format", "stream_options", + "reasoning", ]); export type ChatModelCompat = { @@ -109,6 +112,42 @@ export function classifyGlmThinking(model: string): GlmThinkingKind { return "thinking_only"; } +/** + * Gemini 3:OpenRouter 拒绝关闭思考。 + * 3.8 合法档只有 low / medium / high(minimal 也会 400)。 + */ +export function modelRejectsDisabledReasoning(model: string): boolean { + const id = modelLeaf(model); + return /^gemini-3(?:[.\-]|$)/.test(id); +} + +function isGemini38(model: string): boolean { + return /gemini-3\.8/.test(modelLeaf(model)); +} + +/** 落到 Gemini 思考档。未设置或 none 视为最低合法档 low。 */ +function mapGeminiEffort( + model: string, + effort: ReasoningEffort | undefined, +): "minimal" | "low" | "medium" | "high" { + if (!effort || effort === "none") return "low"; + if (effort === "minimal") return isGemini38(model) ? "low" : "minimal"; + if (effort === "low") return "low"; + if (effort === "medium") return "medium"; + return "high"; +} + +function writeGeminiReasoning( + body: Record, + model: string, + effort: ReasoningEffort | undefined, +): void { + const mapped = mapGeminiEffort(model, effort); + body.reasoning_effort = mapped; + body.reasoning = { effort: mapped }; + if (isThinkingDisabled(body.thinking)) delete body.thinking; +} + function mapGlm53Effort( effort: ReasoningEffort, ): "low" | "high" | "max" { @@ -136,6 +175,10 @@ function applyReasoningToBody( const glm = classifyGlmThinking(model); if (glm === "none") { + if (modelRejectsDisabledReasoning(model)) { + writeGeminiReasoning(body, model, effort); + return; + } body.reasoning_effort = effort; return; } @@ -240,7 +283,8 @@ export function applyOpenAiGeneration( /** * 带 function tools 时改写思考参数。 - * - 非 GLM(如 gpt-5.6-luna):须显式 reasoning_effort=none + * - 非 GLM、且允许关闭思考(如 gpt-5.6-luna):须显式 reasoning_effort=none + * - Gemini 3:不能 none/minimal,缺省或关闭档抬到 low * - GLM-5.3:不能 none,保留/压到合法档(默认 low) * - GLM-5.2 / 旧版:thinking disabled,去掉 effort */ @@ -262,6 +306,14 @@ export function forceReasoningEffortNoneWhenTools( body.reasoning_effort = mapGlm53Effort(current); return; } + if (modelRejectsDisabledReasoning(modelId)) { + writeGeminiReasoning( + body, + modelId, + sanitizeReasoningEffort(body.reasoning_effort), + ); + return; + } if (glm === "effort_full" || glm === "thinking_only") { body.thinking = { type: "disabled" }; delete body.reasoning_effort; @@ -302,6 +354,79 @@ export function isToolsReasoningEffortConflictError( return mentionsTools && mentionsReasoningEffort && conflict; } +/** OpenRouter 等:该端点必须开思考,显式 none / disabled 会被 400。 */ +export function isReasoningMandatoryError( + errorText: string, + status?: number, +): boolean { + if (status != null && status !== 400 && status !== 422) return false; + + let parsed: unknown; + try { + parsed = JSON.parse(errorText); + } catch { + parsed = undefined; + } + const message = `${readErrorMessage(parsed)}\n${errorText}`.toLowerCase(); + return ( + /reasoning is mandatory/.test(message) && /cannot be disabled/.test(message) + ); +} + +function isThinkingDisabled(thinking: unknown): boolean { + return ( + !!thinking && + typeof thinking === "object" && + (thinking as { type?: unknown }).type === "disabled" + ); +} + +/** + * 把关闭思考的字段抬到合法最低档 low。 + * 只改 none / minimal / disabled;已经是 low 及以上则不动,避免空转重试。 + */ +export function applyMandatoryReasoningFloor( + body: Record, + tried: Set, +): boolean { + if (tried.has("reasoning_mandatory_floor")) return false; + + let changed = false; + if (isThinkingDisabled(body.thinking)) { + body.thinking = { type: "enabled" }; + changed = true; + } + + const effort = sanitizeReasoningEffort(body.reasoning_effort); + if (effort === "none" || effort === "minimal") { + body.reasoning_effort = "low"; + body.reasoning = { effort: "low" }; + changed = true; + } + + if (body.reasoning && typeof body.reasoning === "object") { + const reasoning = { ...(body.reasoning as Record) }; + const effortValue = + typeof reasoning.effort === "string" + ? reasoning.effort.trim().toLowerCase() + : ""; + if ( + reasoning.enabled === false || + effortValue === "none" || + effortValue === "minimal" + ) { + delete reasoning.enabled; + reasoning.effort = "low"; + body.reasoning = reasoning; + changed = true; + } + } + + if (!changed) return false; + tried.add("reasoning_mandatory_floor"); + return true; +} + function readErrorMessage(raw: unknown): string { if (!raw || typeof raw !== "object") return ""; const err = (raw as { error?: unknown }).error; @@ -450,6 +575,13 @@ export async function fetchWithGenerationCompat( const text = await last.text(); const next = { ...body }; + if (isReasoningMandatoryError(text, last.status)) { + if (applyMandatoryReasoningFloor(next, tried)) { + body = next; + continue; + } + } + if (isToolsReasoningEffortConflictError(text, last.status)) { if (applyToolsReasoningEffortNone(next, tried)) { body = next; diff --git a/src/llm/list-models.ts b/src/llm/list-models.ts new file mode 100644 index 0000000..ded68b8 --- /dev/null +++ b/src/llm/list-models.ts @@ -0,0 +1,153 @@ +/** + * 拉取 OpenAI 兼容的 GET {base}/models,供同一 API 配置下快捷切换模型。 + */ + +const MAX_BODY_CHARS = 2_000_000; +const MAX_MODELS = 4000; + +export function normalizeBaseUrl(url: string): string { + return url.trim().replace(/\/+$/, "").toLowerCase(); +} + +/** 按常见兼容网关尝试 models 地址。base 已含 /v1 时只打一次。 */ +export function modelListUrls(baseUrl: string): string[] { + const base = baseUrl.trim().replace(/\/+$/, ""); + if (!base) return []; + const urls: string[] = []; + const push = (url: string) => { + if (!urls.includes(url)) urls.push(url); + }; + push(`${base}/models`); + const lower = base.toLowerCase(); + if (!/\/v1(?:beta)?(?:\/|$)/.test(lower)) { + push(`${base}/v1/models`); + push(`${base}/api/v1/models`); + } + return urls; +} + +function modelIdOf(item: unknown): string | null { + if (typeof item === "string") { + const id = item.trim(); + return id && id.length <= 200 ? id : null; + } + if (!item || typeof item !== "object") return null; + const rec = item as { id?: unknown; name?: unknown }; + const raw = typeof rec.id === "string" ? rec.id : rec.name; + if (typeof raw !== "string") return null; + const id = raw.trim(); + return id && id.length <= 200 ? id : null; +} + +/** 识别 { data: [{id}] }、{ models } 或纯数组。无法识别返回 null。 */ +export function parseModelsPayload(data: unknown): string[] | null { + let list: unknown[] | null = null; + if (Array.isArray(data)) list = data; + else if (data && typeof data === "object") { + const rec = data as { data?: unknown; models?: unknown; error?: unknown }; + if (rec.error && !Array.isArray(rec.data) && !Array.isArray(rec.models)) { + return null; + } + if (Array.isArray(rec.data)) list = rec.data; + else if (Array.isArray(rec.models)) list = rec.models; + } + if (!list) return null; + const seen = new Set(); + const ids: string[] = []; + for (const item of list) { + const id = modelIdOf(item); + if (!id || seen.has(id)) continue; + seen.add(id); + ids.push(id); + if (ids.length >= MAX_MODELS) break; + } + return ids; +} + +type FetchOutcome = + | { ok: true; models: string[] } + | { ok: false; stop: boolean; message: string }; + +async function fetchModelList(url: string, apiKey: string): Promise { + let response: Response; + try { + response = await fetch(url, { + method: "GET", + headers: { + Accept: "application/json", + Authorization: `Bearer ${apiKey}`, + }, + signal: AbortSignal.timeout(20_000), + }); + } catch (err) { + const message = err instanceof Error ? err.message : "请求失败"; + return { ok: false, stop: false, message: `${url} ${message}` }; + } + + if (response.status === 401 || response.status === 403) { + return { + ok: false, + stop: true, + message: `API Key 被拒绝(${response.status})`, + }; + } + if (!response.ok) { + return { + ok: false, + stop: false, + message: `${url} → ${response.status}`, + }; + } + + let text: string; + try { + text = await response.text(); + } catch (err) { + const message = err instanceof Error ? err.message : "读取失败"; + return { ok: false, stop: false, message: `${url} ${message}` }; + } + if (text.length > MAX_BODY_CHARS) { + return { ok: false, stop: false, message: `${url} 响应过大` }; + } + let parsed: unknown; + try { + parsed = JSON.parse(text) as unknown; + } catch { + return { ok: false, stop: false, message: `${url} 不是 JSON` }; + } + const models = parseModelsPayload(parsed); + if (!models) { + return { ok: false, stop: false, message: `${url} 没有模型列表` }; + } + return { ok: true, models }; +} + +export async function discoverRemoteModels( + baseUrl: string, + apiKey: string, +): Promise<{ models: string[]; url: string }> { + const key = apiKey.trim(); + if (!key) throw new Error("未设置 API Key"); + const urls = modelListUrls(baseUrl); + if (!urls.length) throw new Error("缺少 Base URL"); + + const errors: string[] = []; + for (let i = 0; i < urls.length; i += 1) { + const url = urls[i]!; + const result = await fetchModelList(url, key); + if (!result.ok) { + if (result.stop) throw new Error(result.message); + errors.push(result.message); + continue; + } + const last = i === urls.length - 1; + if (result.models.length === 0 && !last) { + errors.push(`${url} 返回空列表`); + continue; + } + return { models: result.models, url }; + } + throw new Error( + errors.length ? `未能嗅探模型。${errors.join(";")}` : "未能嗅探模型", + ); +} diff --git a/src/llm/preset-wrapper.ts b/src/llm/preset-wrapper.ts index 9cd85d7..b2f20e5 100644 --- a/src/llm/preset-wrapper.ts +++ b/src/llm/preset-wrapper.ts @@ -2,7 +2,7 @@ import { loadAppSettings } from "../config/settings.js"; import type { PersonaDirective } from "../persona/store.js"; import { frameMessagesWithPreset } from "../preset/play-frame.js"; import { resolveActivePreset } from "../preset/store.js"; -import type { PresetPackage } from "../types/preset.js"; +import type { GenerationParameters, PresetPackage } from "../types/preset.js"; import type { ChatMessage, CompleteOptions, @@ -23,9 +23,20 @@ function readActivePreset(): PresetPackage | null { return resolveActivePreset(loadAppSettings().activePresetId); } +/** 思考强度只跟 API 配置,不跟预设。 */ +function generationWithoutReasoningEffort( + generation: GenerationParameters | undefined, +): GenerationParameters | undefined { + if (!generation || generation.reasoningEffort === undefined) return generation; + const next = { ...generation }; + delete next.reasoningEffort; + return next; +} + /** * 统计在内、预设在外:痕迹里的 messages / generation 就是发给模型的那一份。 - * 有选用预设时:在此按 prompt_order 夹心包裹,并合并 generation。 + * 有选用预设时:在此按 prompt_order 夹心包裹,并合并采样参数。 + * 预设上的思考强度不带入,改由 API 配置决定。 */ export function wrapLlmForSession( inner: LlmProvider, @@ -58,7 +69,9 @@ export class PresetLlmProvider implements LlmProvider { messages: frameMessagesWithPreset(preset, messages, persona), options: { ...options, - generation: options?.generation ?? preset.generation, + generation: generationWithoutReasoningEffort( + options?.generation ?? preset.generation, + ), }, }; } diff --git a/src/preset/generation.ts b/src/preset/generation.ts index 0e90ff0..24acbe1 100644 --- a/src/preset/generation.ts +++ b/src/preset/generation.ts @@ -57,9 +57,6 @@ export function normalizeGeneration( if (typeof raw.stream === "boolean") out.stream = raw.stream; - const reasoningEffort = optionalString(raw.reasoningEffort); - if (reasoningEffort !== undefined) out.reasoningEffort = reasoningEffort; - const verbosity = optionalString(raw.verbosity); if (verbosity !== undefined) out.verbosity = verbosity; diff --git a/src/preset/importer.ts b/src/preset/importer.ts index 2b156f9..d466b29 100644 --- a/src/preset/importer.ts +++ b/src/preset/importer.ts @@ -77,7 +77,6 @@ function readGeneration(raw: SillyTavernPreset): GenerationParameters { maxContextTokens: raw.openai_max_context, maxOutputTokens: raw.openai_max_tokens, stream: raw.stream_openai, - reasoningEffort: raw.reasoning_effort, verbosity: raw.verbosity, seed: raw.seed !== undefined && raw.seed >= 0 ? raw.seed : undefined, variants: raw.n, diff --git a/src/preset/store.ts b/src/preset/store.ts index 4054fa8..4392d9d 100644 --- a/src/preset/store.ts +++ b/src/preset/store.ts @@ -4,6 +4,16 @@ import type { PresetPackage } from "../types/preset.js"; import { ensureUserDataDirs, getPresetsDir } from "../config/user-data-dir.js"; import { importSillyTavernPreset } from "./importer.js"; +/** 旧预设里的思考强度不再使用;读到就从文件里删掉。 */ +function withoutReasoningEffort(preset: PresetPackage, filePath: string): PresetPackage { + if (preset.generation?.reasoningEffort === undefined) return preset; + const generation = { ...preset.generation }; + delete generation.reasoningEffort; + const next = { ...preset, generation }; + writeFileSync(filePath, JSON.stringify(next, null, 2), "utf8"); + return next; +} + function presetPath(id: string): string { return path.join(getPresetsDir(), `${id}.json`); } @@ -20,8 +30,9 @@ export function listPresets(): PresetPackage[] { const presets: PresetPackage[] = []; for (const file of files) { try { - const raw = readFileSync(path.join(getPresetsDir(), file), "utf8"); - presets.push(JSON.parse(raw) as PresetPackage); + const filePath = path.join(getPresetsDir(), file); + const raw = readFileSync(filePath, "utf8"); + presets.push(withoutReasoningEffort(JSON.parse(raw) as PresetPackage, filePath)); } catch { /* skip corrupt files */ } @@ -31,8 +42,9 @@ export function listPresets(): PresetPackage[] { export function getPreset(id: string): PresetPackage | null { try { - const raw = readFileSync(presetPath(id), "utf8"); - return JSON.parse(raw) as PresetPackage; + const filePath = presetPath(id); + const raw = readFileSync(filePath, "utf8"); + return withoutReasoningEffort(JSON.parse(raw) as PresetPackage, filePath); } catch { return null; } diff --git a/src/server/settings-handlers.ts b/src/server/settings-handlers.ts index 8b95d71..6555b98 100644 --- a/src/server/settings-handlers.ts +++ b/src/server/settings-handlers.ts @@ -1,12 +1,16 @@ +import { randomUUID } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; import { createApiProfile, deleteApiProfile, getApiProfile, + listApiGroups, listApiProfiles, probeAndSaveProfileCapabilities, + renameApiProfileGroup, updateApiProfile, } from "../config/api-profiles.js"; +import { discoverRemoteModels } from "../llm/list-models.js"; import { deliveryModeLabel, resolveStructuredDeliveryMode, @@ -94,6 +98,9 @@ function personasPayload(extra: Record = {}) { }; } +/** 本次进程启动标记。重启后变化,浏览器据此丢掉嗅探缓存。 */ +const processBootId = randomUUID(); + export async function handleSettingsApi( req: IncomingMessage, res: ServerResponse, @@ -115,7 +122,13 @@ export async function handleSettingsApi( importedAt: p.importedAt, }; }); - json(res, 200, { settings, profiles, presets }); + json(res, 200, { + settings, + profiles, + groups: listApiGroups(), + presets, + bootId: processBootId, + }); return true; } @@ -164,6 +177,7 @@ export async function handleSettingsApi( ensureActiveProfileDefault(); json(res, 200, { profiles: listApiProfiles(), + groups: listApiGroups(), activeProfileId: loadAppSettings().activeProfileId, }); return true; @@ -175,6 +189,7 @@ export async function handleSettingsApi( baseUrl?: string; apiKey?: string; model?: string; + models?: string[]; reasoningEffort?: string; }; const profile = createApiProfile({ @@ -189,17 +204,104 @@ export async function handleSettingsApi( setActiveProfileId(profile.id); } const reloadedSessions = activate ? sessionManager.reloadAllLlms() : 0; - json(res, 201, { profile, activeProfileId: activate ? profile.id : null, reloadedSessions }); + json(res, 201, { + profile, + groups: listApiGroups(), + activeProfileId: activate ? profile.id : null, + reloadedSessions, + }); + return true; + } + + if (pathname === "/api/profiles/discover-models" && req.method === "POST") { + const body = JSON.parse(await readBody(req)) as { + baseUrl?: string; + apiKey?: string; + profileId?: string; + }; + let baseUrl = body.baseUrl?.trim() ?? ""; + let apiKey = body.apiKey?.trim() ?? ""; + if (body.profileId?.trim()) { + const profile = getApiProfile(body.profileId.trim()); + if (!profile) { + json(res, 404, { error: "配置不存在" }); + return true; + } + if (!baseUrl) baseUrl = profile.baseUrl; + if (!apiKey) apiKey = profile.apiKey; + } + if (!baseUrl) { + json(res, 400, { error: "缺少 Base URL" }); + return true; + } + if (!apiKey) { + json(res, 400, { error: "缺少 API Key" }); + return true; + } + try { + const found = await discoverRemoteModels(baseUrl, apiKey); + json(res, 200, { models: found.models, url: found.url }); + } catch (err) { + json(res, 400, { + error: err instanceof Error ? err.message : "嗅探失败", + }); + } + return true; + } + + const groupMatch = pathname.match(/^\/api\/profile-groups\/([^/]+)$/); + if (groupMatch && req.method === "PUT") { + const id = decodeURIComponent(groupMatch[1] ?? ""); + const body = JSON.parse(await readBody(req)) as { name?: string }; + try { + const group = renameApiProfileGroup(id, body.name ?? ""); + json(res, 200, { group, groups: listApiGroups() }); + } catch (err) { + json(res, 400, { + error: err instanceof Error ? err.message : "改组名失败", + }); + } return true; } const profileMatch = pathname.match( - /^\/api\/profiles\/([^/]+)(\/probe-capabilities)?$/, + /^\/api\/profiles\/([^/]+)(\/(?:probe-capabilities|select-model))?$/, ); if (profileMatch) { const id = decodeURIComponent(profileMatch[1]); const suffix = profileMatch[2]; + if (suffix === "/select-model" && req.method === "POST") { + const body = JSON.parse(await readBody(req)) as { + model?: string; + activate?: boolean; + }; + const model = body.model?.trim() ?? ""; + if (!model) { + json(res, 400, { error: "缺少模型" }); + return true; + } + try { + const profile = updateApiProfile(id, { model }); + if (body.activate) setActiveProfileId(profile.id); + const settings = loadAppSettings(); + const reloadedSessions = + settings.activeProfileId === profile.id + ? sessionManager.reloadAllLlms() + : 0; + json(res, 200, { + profile, + activeProfileId: loadAppSettings().activeProfileId, + reloadedSessions, + }); + } catch (err) { + json(res, 400, { + error: err instanceof Error ? err.message : "切换模型失败", + }); + } + return true; + } + if (suffix === "/probe-capabilities" && req.method === "POST") { try { const profile = await probeAndSaveProfileCapabilities(id); @@ -240,16 +342,21 @@ export async function handleSettingsApi( baseUrl?: string; apiKey?: string; model?: string; + models?: string[]; reasoningEffort?: string; }; try { const profile = updateApiProfile(id, body); const settings = loadAppSettings(); const reloadedSessions = - settings.activeProfileId === id + settings.activeProfileId === profile.id ? sessionManager.reloadAllLlms() : 0; - json(res, 200, { profile, reloadedSessions }); + json(res, 200, { + profile, + groups: listApiGroups(), + reloadedSessions, + }); } catch (err) { json(res, 404, { error: err instanceof Error ? err.message : "更新失败", @@ -426,6 +533,13 @@ export async function handleSettingsApi( } } + if (pathname === "/api/presets/deactivate" && req.method === "POST") { + setActivePresetId(null); + const reloadedSessions = sessionManager.reloadAllLlms(); + json(res, 200, { activePresetId: null, reloadedSessions }); + return true; + } + const presetMatch = pathname.match(/^\/api\/presets\/([^/]+)(\/activate)?$/); if (presetMatch) { const id = decodeURIComponent(presetMatch[1]); diff --git a/src/server/web-server.ts b/src/server/web-server.ts index 8a0d9b4..62252d5 100644 --- a/src/server/web-server.ts +++ b/src/server/web-server.ts @@ -71,6 +71,7 @@ const server = createServer(async (req, res) => { "GET /api/settings", "GET /api/profiles", "POST /api/profiles", + "POST /api/profiles/discover-models", "GET /api/presets", "GET /api/books", "GET /api/skills", diff --git a/tests/api-profile-usage.test.ts b/tests/api-profile-usage.test.ts new file mode 100644 index 0000000..b5885e2 --- /dev/null +++ b/tests/api-profile-usage.test.ts @@ -0,0 +1,47 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + filterUsedApiProfiles, + forgetApiProfileUsage, + loadApiProfileUsage, + recordApiProfileUsage, +} from "../web/api-profile-usage.js"; + +const store = new Map(); + +beforeEach(() => { + store.clear(); + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + store.set(key, value); + }, + removeItem: (key: string) => { + store.delete(key); + }, + }, + }); +}); + +describe("forgetApiProfileUsage", () => { + it("drops one used record and leaves the profile out of the shortcut list", () => { + recordApiProfileUsage("a"); + recordApiProfileUsage("b"); + + expect(forgetApiProfileUsage("a")).toBe(true); + expect(loadApiProfileUsage().a).toBeUndefined(); + expect(loadApiProfileUsage().b).toBeTruthy(); + + const visible = filterUsedApiProfiles( + [{ id: "a" }, { id: "b" }, { id: "c" }], + loadApiProfileUsage(), + "c", + ); + expect(visible.map((item) => item.id)).toEqual(["b", "c"]); + }); + + it("does nothing when there is no record", () => { + expect(forgetApiProfileUsage("missing")).toBe(false); + }); +}); diff --git a/tests/api-profiles-models.test.ts b/tests/api-profiles-models.test.ts new file mode 100644 index 0000000..3a1e393 --- /dev/null +++ b/tests/api-profiles-models.test.ts @@ -0,0 +1,174 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + apiKeyHash, + createApiProfile, + listApiGroups, + listApiProfiles, + renameApiProfileGroup, + updateApiProfile, +} from "../src/config/api-profiles.js"; +import { modelListUrls, parseModelsPayload } from "../src/llm/list-models.js"; + +describe("api profile groups", () => { + let dir = ""; + let prevData: string | undefined; + let prevKey: string | undefined; + + beforeEach(() => { + dir = mkdtempSync(path.join(tmpdir(), "wa-profiles-")); + prevData = process.env.WRITING_AGENT_DATA_DIR; + prevKey = process.env.OPENAI_API_KEY; + process.env.WRITING_AGENT_DATA_DIR = dir; + delete process.env.OPENAI_API_KEY; + }); + + afterEach(() => { + if (prevData === undefined) delete process.env.WRITING_AGENT_DATA_DIR; + else process.env.WRITING_AGENT_DATA_DIR = prevData; + if (prevKey === undefined) delete process.env.OPENAI_API_KEY; + else process.env.OPENAI_API_KEY = prevKey; + rmSync(dir, { recursive: true, force: true }); + }); + + it("keeps same-url profiles separate and folds them into one named group", () => { + const first = createApiProfile({ + name: "ds", + baseUrl: "https://openrouter.ai/api/v1/", + apiKey: "sk-same", + model: "deepseek/deepseek-v4-pro", + }); + const second = createApiProfile({ + name: "ds flash", + baseUrl: "https://OpenRouter.ai/api/v1", + apiKey: "sk-same", + model: "deepseek/deepseek-v4-flash", + }); + + expect(second.id).not.toBe(first.id); + expect(second.groupId).toBe(first.groupId); + expect(listApiProfiles()).toHaveLength(2); + const groups = listApiGroups(); + expect(groups).toHaveLength(1); + expect(groups[0]?.name).toBe("openrouter.ai"); + expect(groups[0]?.id).toBe(first.groupId); + expect(JSON.stringify(groups)).not.toContain("sk-same"); + expect(JSON.stringify(groups)).not.toContain(apiKeyHash("sk-same")); + }); + + it("uses a different group when the key differs", () => { + createApiProfile({ + name: "a", + baseUrl: "https://openrouter.ai/api/v1", + apiKey: "sk-one", + model: "alpha", + }); + createApiProfile({ + name: "b", + baseUrl: "https://openrouter.ai/api/v1", + apiKey: "sk-two", + model: "beta", + }); + const groups = listApiGroups(); + expect(groups).toHaveLength(2); + const names = groups.map((group) => group.name).sort(); + expect(names[0]).toBe("openrouter.ai"); + expect(names[1]?.startsWith("openrouter.ai · ")).toBe(true); + }); + + it("splits a legacy multi-model profile back into separate rows", () => { + const now = "2026-01-01T00:00:00.000Z"; + writeFileSync( + path.join(dir, "profiles.json"), + JSON.stringify({ + version: 1, + profiles: [ + { + id: "ds", + name: "ds", + baseUrl: "https://api.deepseek.com", + apiKey: "sk-1", + model: "deepseek-v4-pro", + models: ["deepseek-v4-pro", "deepseek-v4-flash"], + createdAt: now, + updatedAt: now, + }, + ], + }), + ); + + const profiles = listApiProfiles(); + expect(profiles.map((profile) => profile.model).sort()).toEqual([ + "deepseek-v4-flash", + "deepseek-v4-pro", + ]); + expect(new Set(profiles.map((profile) => profile.groupId)).size).toBe(1); + expect(profiles.every((profile) => profile.models === undefined)).toBe(true); + const stored = JSON.parse(readFileSync(path.join(dir, "profiles.json"), "utf8")) as { + profiles: Array<{ model: string }>; + groups: Array<{ name: string }>; + }; + expect(stored.profiles).toHaveLength(2); + expect(stored.groups[0]?.name).toBe("api.deepseek.com"); + }); + + it("renames a group without moving its profiles", () => { + const profile = createApiProfile({ + name: "ds", + baseUrl: "https://api.deepseek.com", + apiKey: "sk-1", + model: "deepseek-v4-pro", + }); + const renamed = renameApiProfileGroup(profile.groupId ?? "", "深度求索"); + expect(renamed.name).toBe("深度求索"); + expect(listApiProfiles()[0]?.groupId).toBe(profile.groupId); + expect(listApiGroups()[0]?.name).toBe("深度求索"); + }); + + it("clears capability results when the model of one profile changes", () => { + const profile = createApiProfile({ + name: "ds", + baseUrl: "https://api.deepseek.com", + apiKey: "sk-1", + model: "deepseek-v4-pro", + }); + const file = path.join(dir, "profiles.json"); + const raw = JSON.parse(readFileSync(file, "utf8")) as { + profiles: Array<{ capabilities?: { chat: string } }>; + }; + raw.profiles[0]!.capabilities = { chat: "ok" }; + writeFileSync(file, JSON.stringify(raw)); + + const switched = updateApiProfile(profile.id, { model: "deepseek-v4-flash" }); + expect(switched.id).toBe(profile.id); + expect(switched.model).toBe("deepseek-v4-flash"); + expect(switched.capabilities).toBeUndefined(); + expect(listApiProfiles()).toHaveLength(1); + }); +}); + +describe("models payload", () => { + it("parses OpenAI and plain lists", () => { + expect( + parseModelsPayload({ + data: [{ id: "a" }, { id: "a" }, { name: "b" }, { id: "" }], + }), + ).toEqual(["a", "b"]); + expect(parseModelsPayload(["z", "z", " y "])).toEqual(["z", "y"]); + expect(parseModelsPayload({ error: { message: "nope" } })).toBeNull(); + expect(parseModelsPayload({ data: [] })).toEqual([]); + }); + + it("tries versioned models urls when base has no v1", () => { + expect(modelListUrls("https://openrouter.ai")).toEqual([ + "https://openrouter.ai/models", + "https://openrouter.ai/v1/models", + "https://openrouter.ai/api/v1/models", + ]); + expect(modelListUrls("https://openrouter.ai/api/v1/")).toEqual([ + "https://openrouter.ai/api/v1/models", + ]); + }); +}); diff --git a/tests/generation-compat.test.ts b/tests/generation-compat.test.ts index 84831fd..c8c8713 100644 --- a/tests/generation-compat.test.ts +++ b/tests/generation-compat.test.ts @@ -6,7 +6,9 @@ import { classifyGlmThinking, fetchWithGenerationCompat, forceReasoningEffortNoneWhenTools, + isReasoningMandatoryError, isToolsReasoningEffortConflictError, + modelRejectsDisabledReasoning, parseRejectedGenerationField, sanitizeReasoningEffort, sanitizeVerbosity, @@ -178,6 +180,32 @@ describe("forceReasoningEffortNoneWhenTools", () => { expect(body.thinking).toEqual({ type: "disabled" }); }); + it("keeps gemini 3.8 on low when tools would otherwise disable reasoning", () => { + const body: Record = { + model: "google/gemini-3.8-flash", + tools: [{ type: "function", function: { name: "x" } }], + }; + forceReasoningEffortNoneWhenTools(body, "google/gemini-3.8-flash"); + expect(body.reasoning_effort).toBe("low"); + expect(body.reasoning).toEqual({ effort: "low" }); + }); + + it("maps gemini 3.8 max down to high and keeps medium", () => { + const maxBody: Record = { + model: "google/gemini-3.8-flash", + tools: [{ type: "function", function: { name: "x" } }], + reasoning_effort: "max", + }; + forceReasoningEffortNoneWhenTools(maxBody, "google/gemini-3.8-flash"); + expect(maxBody.reasoning_effort).toBe("high"); + expect(maxBody.reasoning).toEqual({ effort: "high" }); + + const midBody: Record = { model: "google/gemini-3.8-flash" }; + applyOpenAiGeneration(midBody, { reasoningEffort: "medium" }, "google/gemini-3.8-flash"); + expect(midBody.reasoning_effort).toBe("medium"); + expect(midBody.reasoning).toEqual({ effort: "medium" }); + }); + it("leaves body alone without tools", () => { const body: Record = { reasoning_effort: "high" }; forceReasoningEffortNoneWhenTools(body); @@ -185,6 +213,26 @@ describe("forceReasoningEffortNoneWhenTools", () => { }); }); +describe("isReasoningMandatoryError", () => { + it("detects the OpenRouter mandatory-reasoning payload", () => { + expect(modelRejectsDisabledReasoning("google/gemini-3.8-flash")).toBe(true); + expect(modelRejectsDisabledReasoning("gemini-2.5-flash")).toBe(false); + expect( + isReasoningMandatoryError( + JSON.stringify({ + error: { + message: + "Reasoning is mandatory for this endpoint and cannot be disabled.", + code: 400, + metadata: { provider_name: null }, + }, + }), + 400, + ), + ).toBe(true); + }); +}); + describe("isToolsReasoningEffortConflictError", () => { it("detects gpt-5.6-luna style conflict", () => { const text = JSON.stringify({ @@ -329,6 +377,37 @@ describe("fetchWithGenerationCompat", () => { expect(sent[0].reasoning_effort).toBeUndefined(); expect(sent[1].reasoning_effort).toBe("none"); }); + + it("retries a mandatory-reasoning 400 by lifting none to low", async () => { + const sent: Array> = []; + const response = await fetchWithGenerationCompat(async (body) => { + sent.push({ ...body }); + if (body.reasoning_effort === "none") { + return new Response( + JSON.stringify({ + error: { + message: + "Reasoning is mandatory for this endpoint and cannot be disabled.", + code: 400, + metadata: { provider_name: null }, + }, + }), + { status: 400 }, + ); + } + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }, { + model: "google/gemini-3.8-flash", + tools: [{ type: "function", function: { name: "run" } }], + reasoning_effort: "none", + messages: [], + }); + + expect(response.ok).toBe(true); + expect(sent).toHaveLength(2); + expect(sent[1].reasoning_effort).toBe("low"); + expect(sent[1].reasoning).toEqual({ effort: "low" }); + }); }); describe("buildRequestBody", () => { @@ -387,6 +466,43 @@ describe("buildRequestBody", () => { expect(body.reasoning_effort).toBe("low"); }); + it("maps gemini 3.8 none and minimal to low", () => { + const config: LlmConfig = { + baseUrl: "https://openrouter.ai/api/v1", + apiKey: "k", + model: "google/gemini-3.8-flash", + reasoningEffort: "none", + }; + const body = buildRequestBody(config, messages, {}); + expect(body.reasoning_effort).toBe("low"); + expect(body.reasoning).toEqual({ effort: "low" }); + + const minimal = buildRequestBody( + { ...config, reasoningEffort: "minimal" }, + messages, + {}, + ); + expect(minimal.reasoning_effort).toBe("low"); + }); + + it("uses low for gemini 3.8 tool calls when effort is omitted", () => { + const config: LlmConfig = { + baseUrl: "https://openrouter.ai/api/v1", + apiKey: "k", + model: "google/gemini-3.8-flash", + }; + const body = buildRequestBody(config, messages, { + tools: [ + { + type: "function", + function: { name: "run_worker", parameters: { type: "object" } }, + }, + ], + }); + expect(body.reasoning_effort).toBe("low"); + expect(body.reasoning).toEqual({ effort: "low" }); + }); + it("forces reasoning_effort none when tools are attached on non-glm", () => { const config: LlmConfig = { baseUrl: "https://example.test/v1", @@ -425,6 +541,62 @@ describe("buildRequestBody", () => { expect(body.thinking).toEqual({ type: "enabled" }); }); + it("adds the word json when json_object prompts omit it", () => { + const config: LlmConfig = { + baseUrl: "https://example.test/v1", + apiKey: "k", + model: "gpt-4o", + }; + const body = buildRequestBody( + config, + [{ role: "user", content: "只输出对象,列出候选。" }], + { responseFormat: "json_object" }, + ); + expect(body.response_format).toEqual({ type: "json_object" }); + const sent = body.messages as Array<{ role: string; content: string }>; + expect(sent[0]).toEqual({ + role: "system", + content: "请用 JSON 对象回复。", + }); + expect(sent.some((message) => /json/i.test(message.content))).toBe(true); + }); + + it("appends the json hint to an existing system message", () => { + const config: LlmConfig = { + baseUrl: "https://example.test/v1", + apiKey: "k", + model: "gpt-4o", + }; + const body = buildRequestBody( + config, + [ + { role: "system", content: "你正在提取文风包。" }, + { role: "user", content: "写得冷一点。" }, + ], + { responseFormat: "json_object" }, + ); + const sent = body.messages as Array<{ role: string; content: string }>; + expect(sent[0]?.content).toContain("你正在提取文风包。"); + expect(sent[0]?.content).toContain("请用 JSON 对象回复。"); + expect(sent[1]?.content).toBe("写得冷一点。"); + }); + + it("leaves a json_object prompt unchanged when it already says json", () => { + const config: LlmConfig = { + baseUrl: "https://example.test/v1", + apiKey: "k", + model: "gpt-4o", + }; + const messages = [ + { role: "system" as const, content: "输出一个 JSON 对象。" }, + { role: "user" as const, content: "继续。" }, + ]; + const body = buildRequestBody(config, messages, { + responseFormat: "json_object", + }); + expect(body.messages).toBe(messages); + }); + it("does not forward top_k from an imported ST preset", () => { const config: LlmConfig = { baseUrl: "https://example.test/v1", diff --git a/tests/model-sniff-cache.test.ts b/tests/model-sniff-cache.test.ts new file mode 100644 index 0000000..719dc41 --- /dev/null +++ b/tests/model-sniff-cache.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { readSniffModels, writeSniffModels } from "../web/model-sniff-cache.js"; + +const store = new Map(); + +beforeEach(() => { + store.clear(); + Object.defineProperty(globalThis, "sessionStorage", { + configurable: true, + value: { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + store.set(key, value); + }, + removeItem: (key: string) => { + store.delete(key); + }, + }, + }); +}); + +describe("model sniff cache", () => { + it("keeps models for the current boot and drops them after restart", () => { + writeSniffModels("boot-a", "https://openrouter.ai/api/v1\nkey:abc", [ + "google/gemini-3.8-flash", + "google/gemini-3.7-flash", + ]); + + expect( + readSniffModels("boot-a", "https://openrouter.ai/api/v1\nkey:abc"), + ).toEqual(["google/gemini-3.8-flash", "google/gemini-3.7-flash"]); + + expect( + readSniffModels("boot-b", "https://openrouter.ai/api/v1\nkey:abc"), + ).toEqual([]); + }); + + it("does not keep another boot's entries when writing again", () => { + writeSniffModels("boot-a", "slot", ["old-model"]); + writeSniffModels("boot-b", "slot", ["new-model"]); + + expect(readSniffModels("boot-a", "slot")).toEqual([]); + expect(readSniffModels("boot-b", "slot")).toEqual(["new-model"]); + }); +}); diff --git a/tests/preset-wrapper.test.ts b/tests/preset-wrapper.test.ts index 49e3538..1151212 100644 --- a/tests/preset-wrapper.test.ts +++ b/tests/preset-wrapper.test.ts @@ -24,7 +24,7 @@ const samplePreset: PresetPackage = { }, ], promptOrder: [{ promptId: "sys", enabled: true, orderIndex: 0 }], - generation: { temperature: 0.91, maxOutputTokens: 1234 }, + generation: { temperature: 0.91, maxOutputTokens: 1234, reasoningEffort: "max" }, unsupported: [], importedAt: "2026-01-01T00:00:00.000Z", }; @@ -72,6 +72,7 @@ describe("wrapLlmForSession", () => { temperature: 0.91, maxOutputTokens: 1234, }); + expect(received[0].generation).not.toHaveProperty("reasoningEffort"); expect(ctx.pendingContextTrace?.messages).toEqual(received[0].messages); expect(ctx.pendingContextTrace?.generation).toMatchObject({ temperature: 0.91, diff --git a/tests/preset.test.ts b/tests/preset.test.ts index a673d8d..7996625 100644 --- a/tests/preset.test.ts +++ b/tests/preset.test.ts @@ -190,7 +190,6 @@ describe("normalizeGeneration / applyPresetGeneration", () => { ).toEqual({ temperature: 0.9, maxOutputTokens: 4096, - reasoningEffort: "high", stream: true, }); }); diff --git a/web/api-profile-usage.js b/web/api-profile-usage.js new file mode 100644 index 0000000..535ec1b --- /dev/null +++ b/web/api-profile-usage.js @@ -0,0 +1,140 @@ +/** + * API 配置使用记录(浏览器 localStorage)。 + * 对话菜单与设置页共用:只把「曾经选用过」的模型放进快捷列表。 + */ + +export const API_PROFILE_USAGE_KEY = "wa-api-profile-usage"; + +/** @typedef {{ count: number, lastUsed: number }} ApiProfileUsageEntry */ +/** @typedef {Record} ApiProfileUsageMap */ + +/** @returns {ApiProfileUsageMap} */ +export function loadApiProfileUsage() { + try { + const raw = localStorage.getItem(API_PROFILE_USAGE_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? parsed : {}; + } catch { + return {}; + } +} + +/** @param {ApiProfileUsageMap} usage */ +export function saveApiProfileUsage(usage) { + try { + localStorage.setItem(API_PROFILE_USAGE_KEY, JSON.stringify(usage)); + } catch { + /* ignore quota / private mode */ + } +} + +/** 选用成功后记一次;首次出现记 count=1。 */ +export function recordApiProfileUsage(id) { + if (!id) return; + const usage = loadApiProfileUsage(); + const prev = usage[id]; + usage[id] = { + count: (prev?.count || 0) + 1, + lastUsed: Date.now(), + }; + saveApiProfileUsage(usage); +} + +/** + * 当前生效配置若还没有记录,写入一条(不刷 count)。 + * @param {string | null | undefined} id + */ +export function seedApiProfileUsage(id) { + if (!id) return; + const usage = loadApiProfileUsage(); + if (usage[id]) return; + usage[id] = { count: 1, lastUsed: Date.now() }; + saveApiProfileUsage(usage); +} + +/** 只去掉快捷列表里的曾用记录,不删除配置本身。 */ +export function forgetApiProfileUsage(id) { + if (!id) return false; + const usage = loadApiProfileUsage(); + if (!usage[id]) return false; + delete usage[id]; + saveApiProfileUsage(usage); + return true; +} + +/** @param {Set} validIds */ +export function pruneApiProfileUsage(validIds) { + const usage = loadApiProfileUsage(); + let changed = false; + for (const id of Object.keys(usage)) { + if (!validIds.has(id)) { + delete usage[id]; + changed = true; + } + } + if (changed) saveApiProfileUsage(usage); +} + +/** + * @param {string} id + * @param {ApiProfileUsageMap} usage + */ +export function apiProfileUsageRank(id, usage) { + const entry = usage[id]; + return { + lastUsed: entry?.lastUsed || 0, + count: entry?.count || 0, + }; +} + +/** @param {{ lastUsed: number, count: number }} a @param {{ lastUsed: number, count: number }} b */ +export function compareApiProfileUsage(a, b) { + if (b.lastUsed !== a.lastUsed) return b.lastUsed - a.lastUsed; + if (b.count !== a.count) return b.count - a.count; + return 0; +} + +/** + * @template T + * @param {T[]} items + * @param {(item: T) => { lastUsed: number, count: number }} rankOf + */ +export function sortByApiProfileUsage(items, rankOf) { + return items + .map((item, index) => ({ item, index, rank: rankOf(item) })) + .sort((a, b) => compareApiProfileUsage(a.rank, b.rank) || a.index - b.index) + .map((entry) => entry.item); +} + +/** + * 组内成员:只留曾用过的;当前生效始终保留。 + * @param {Array<{ id: string }>} members + * @param {ApiProfileUsageMap} usage + * @param {string | null | undefined} activeId + */ +export function filterUsedApiProfiles(members, usage, activeId) { + const used = members.filter( + (profile) => profile.id === activeId || Boolean(usage[profile.id]), + ); + return sortByApiProfileUsage(used, (profile) => + apiProfileUsageRank(profile.id, usage), + ); +} + +/** + * @param {Array<{ id: string }>} members + * @param {ApiProfileUsageMap} usage + */ +export function membersUsageRank(members, usage) { + return members.reduce( + (acc, profile) => { + const rank = apiProfileUsageRank(profile.id, usage); + return { + lastUsed: Math.max(acc.lastUsed, rank.lastUsed), + count: acc.count + rank.count, + }; + }, + { lastUsed: 0, count: 0 }, + ); +} diff --git a/web/model-sniff-cache.js b/web/model-sniff-cache.js new file mode 100644 index 0000000..137b9da --- /dev/null +++ b/web/model-sniff-cache.js @@ -0,0 +1,49 @@ +/** + * 嗅探到的型号列表。只放 sessionStorage,并绑在本次服务进程的 bootId 上。 + * 关掉页面或重启程序后 bootId 对不上,缓存作废,不会留到下一次启动。 + */ + +const STORAGE_KEY = "wa-model-sniff-v1"; + +function emptyStore(bootId) { + return { bootId, entries: {} }; +} + +function readStore(bootId) { + if (!bootId) return emptyStore(""); + try { + const parsed = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || "null"); + if (!parsed || parsed.bootId !== bootId || !parsed.entries || typeof parsed.entries !== "object") { + return emptyStore(bootId); + } + return { bootId, entries: parsed.entries }; + } catch { + return emptyStore(bootId); + } +} + +/** @param {string} bootId @param {string} cacheKey */ +export function readSniffModels(bootId, cacheKey) { + if (!bootId || !cacheKey) return []; + const raw = readStore(bootId).entries[cacheKey]; + if (!Array.isArray(raw)) return []; + return raw.filter((item) => typeof item === "string" && item.trim()); +} + +/** + * 写入会丢掉其他 bootId 的条目。 + * @param {string} bootId + * @param {string} cacheKey + * @param {string[]} models + */ +export function writeSniffModels(bootId, cacheKey, models) { + if (!bootId || !cacheKey) return false; + const store = readStore(bootId); + store.entries[cacheKey] = models; + try { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(store)); + return true; + } catch { + return false; + } +} diff --git a/web/settings.css b/web/settings.css index 2a4f97f..a1c9de4 100644 --- a/web/settings.css +++ b/web/settings.css @@ -1,26 +1,81 @@ -/* Settings — self-contained shell (main styles.css no longer provides app-shell) */ +/* Secondary pages — visual language shared with the conversation workspace. */ :root { - --st-bg: #f4f4f5; + --st-bg: #f8f8f8; --st-surface: #ffffff; - --st-border: #e4e4e7; - --st-border-strong: #d4d4d8; - --st-text: #18181b; - --st-muted: #71717a; - --st-faint: #a1a1aa; - --st-accent: #2563eb; - --st-accent-soft: #dbeafe; - --st-ok: #15803d; + --st-border: #e0e0e0; + --st-border-strong: #cfcfcf; + --st-text: #2c2c2c; + --st-muted: #767676; + --st-faint: #999999; + --st-accent: #4a9fb0; + --st-accent-soft: rgba(74, 159, 176, 0.12); + --st-ok: #16a34a; --st-danger: #dc2626; --st-rail-w: 200px; - --st-top-h: 48px; + --st-top-h: 40px; --st-radius: 8px; - font-family: "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; + --st-font-ui: "Noto Sans SC", "PingFang SC", "Microsoft YaHei UI", system-ui, sans-serif; + --st-font-read: "Noto Serif SC", "Songti SC", "STSong", Georgia, serif; + --ui-font-scale: 1; + font-family: var(--st-font-ui); font-size: 14px; color: var(--st-text); line-height: 1.45; } +html[data-color-theme="paper"] { + --st-bg: #f5ecd7; + --st-surface: #fbf8f2; + --st-border: #d9d0c1; + --st-border-strong: #c8bcaa; + --st-text: #3e3a35; + --st-muted: #746e67; + --st-faint: #8b8580; + --st-accent: #8b9a6b; + --st-accent-soft: rgba(139, 154, 107, 0.15); +} + +html[data-color-theme="dark"] { + color-scheme: dark; + --st-bg: #1e1e1e; + --st-surface: #2d2d30; + --st-border: #3e3e40; + --st-border-strong: #525254; + --st-text: #d0cec9; + --st-muted: #a09e99; + --st-faint: #757575; + --st-accent: #d4a76a; + --st-accent-soft: rgba(212, 167, 106, 0.15); + --st-ok: #4ade80; + --st-danger: #f87171; +} + +html[data-color-theme="green"] { + --st-bg: #dce5d9; + --st-surface: #f4f9f2; + --st-border: #c0cfbb; + --st-border-strong: #a8bca2; + --st-text: #2b3330; + --st-muted: #65716c; + --st-faint: #7a8580; + --st-accent: #6f8e6c; + --st-accent-soft: rgba(125, 155, 122, 0.15); +} + +html { + zoom: var(--ui-font-scale); + background: var(--st-bg); + color: var(--st-text); +} + +@supports not (zoom: 1) { + html { + zoom: normal; + font-size: calc(14px * var(--ui-font-scale, 1)); + } +} + * { box-sizing: border-box; } @@ -29,10 +84,17 @@ html, body.settings-page { height: 100%; margin: 0; + overflow: hidden; background: var(--st-bg); color: var(--st-text); } +body.settings-page { + display: flex; + flex-direction: column; + min-height: 0; +} + button, input, textarea { @@ -49,16 +111,18 @@ code { /* —— Top bar —— */ .st-top { - height: var(--st-top-h); - display: flex; + min-height: var(--st-top-h); + flex-shrink: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; - justify-content: space-between; - padding: 0 20px; - background: var(--st-surface); + gap: 8px 12px; + padding: 4px 12px; + background: color-mix(in srgb, var(--st-bg) 92%, transparent); border-bottom: 1px solid var(--st-border); - position: sticky; - top: 0; + position: relative; z-index: 20; + backdrop-filter: blur(8px); } .st-brand { @@ -75,11 +139,14 @@ code { .st-nav { display: flex; - gap: 4px; + align-items: center; + justify-content: center; + gap: 2px; + flex-wrap: wrap; } .st-nav a { - padding: 6px 12px; + padding: 5px 10px; border-radius: 6px; text-decoration: none; color: var(--st-muted); @@ -89,21 +156,213 @@ code { .st-nav a:hover { color: var(--st-text); - background: #f4f4f5; + background: var(--st-accent-soft); } .st-nav a[aria-current="page"] { - color: var(--st-accent); + color: var(--st-text); background: var(--st-accent-soft); + font-weight: 600; +} + +.st-top-actions { + display: flex; + align-items: center; + justify-content: flex-end; + min-width: 0; +} + +.st-theme-menu { + position: relative; +} + +.st-theme-summary { + width: 32px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--st-border-strong); + border-radius: 8px; + background: var(--st-surface); + color: var(--st-muted); + cursor: pointer; + list-style: none; +} + +.st-theme-summary::-webkit-details-marker { + display: none; +} + +.st-theme-summary:hover, +.st-theme-menu[open] .st-theme-summary { + color: var(--st-text); + border-color: var(--st-accent); + background: var(--st-accent-soft); +} + +.st-theme-summary:focus-visible, +.st-theme-item:focus-visible, +.st-font-scale button:focus-visible { + outline: 2px solid color-mix(in srgb, var(--st-accent) 70%, transparent); + outline-offset: 2px; +} + +.st-hamburger, +.st-hamburger::before, +.st-hamburger::after { + width: 16px; + height: 1.5px; + display: block; + border-radius: 2px; + background: currentColor; +} + +.st-hamburger { + position: relative; +} + +.st-hamburger::before, +.st-hamburger::after { + content: ""; + position: absolute; + left: 0; +} + +.st-hamburger::before { + top: -5px; +} + +.st-hamburger::after { + top: 5px; +} + +.st-theme-panel { + position: absolute; + top: calc(100% + 8px); + right: 0; + z-index: 100; + width: 258px; + padding: 10px; + border: 1px solid var(--st-border-strong); + border-radius: 12px; + background: var(--st-surface); + color: var(--st-text); + box-shadow: 0 12px 32px color-mix(in srgb, var(--st-text) 12%, transparent); +} + +.st-theme-label { + padding: 4px 10px 7px; + color: var(--st-muted); + font-size: 12px; + font-weight: 600; +} + +.st-theme-item { + width: 100%; + min-height: 54px; + display: grid; + grid-template-columns: 26px minmax(0, 1fr); + align-items: center; + gap: 10px; + padding: 7px 10px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--st-text); + text-align: left; + cursor: pointer; +} + +.st-theme-item:hover { + background: color-mix(in srgb, var(--st-surface) 58%, var(--st-bg)); +} + +.st-theme-item.is-active { + background: var(--st-accent-soft); + color: var(--st-accent); +} + +.st-theme-icon { + font-size: 18px; + line-height: 1; + text-align: center; +} + +.st-theme-copy { + display: flex; + flex-direction: column; + min-width: 0; + line-height: 1.35; +} + +.st-theme-name { + font-size: 14px; + font-weight: 650; +} + +.st-theme-desc { + color: var(--st-muted); + font-size: 12px; +} + +.st-theme-item.is-active .st-theme-desc { + color: color-mix(in srgb, var(--st-accent) 72%, var(--st-muted)); +} + +.st-theme-separator { + height: 1px; + margin: 8px 4px; + background: var(--st-border); +} + +.st-font-scale { + display: grid; + grid-template-columns: 38px minmax(0, 1fr) 38px; + align-items: center; + gap: 8px; + padding: 0 10px 2px; +} + +.st-font-scale button { + height: 38px; + border: 1px solid var(--st-border-strong); + border-radius: 8px; + background: var(--st-surface); + color: var(--st-text); + cursor: pointer; + font-size: 18px; + line-height: 1; +} + +.st-font-scale button:hover:not(:disabled) { + border-color: var(--st-accent); + background: var(--st-accent-soft); +} + +.st-font-scale button:disabled { + cursor: not-allowed; + opacity: 0.4; +} + +.st-font-value { + color: var(--st-text); + font-size: 16px; + font-variant-numeric: tabular-nums; + font-weight: 600; + text-align: center; } /* —— Shell —— */ .st-shell { display: grid; grid-template-columns: var(--st-rail-w) minmax(0, 1fr); - min-height: calc(100% - var(--st-top-h)); - max-width: 1100px; - margin: 0 auto; + flex: 1; + min-height: 0; + width: 100%; + max-width: none; + margin: 0; + overflow: hidden; } .st-rail { @@ -113,7 +372,9 @@ code { display: flex; flex-direction: column; gap: 4px; - min-height: calc(100dvh - var(--st-top-h)); + min-height: 0; + min-width: 0; + overflow: auto; } .st-rail-label { @@ -162,7 +423,8 @@ code { display: flex; flex-direction: column; min-width: 0; - min-height: calc(100dvh - var(--st-top-h)); + min-height: 0; + overflow: hidden; } .st-main-head { @@ -231,7 +493,9 @@ code { .st-body { flex: 1; + min-height: 0; padding: 16px 28px 40px; + overflow-x: hidden; overflow-y: auto; } @@ -259,7 +523,8 @@ code { } .btn-primary:hover { - background: #1d4ed8; + background: color-mix(in srgb, var(--st-accent) 84%, var(--st-text)); + border-color: color-mix(in srgb, var(--st-accent) 84%, var(--st-text)); } .btn-secondary { @@ -269,7 +534,7 @@ code { } .btn-secondary:hover { - background: #f4f4f5; + background: color-mix(in srgb, var(--st-surface) 62%, var(--st-bg)); } .btn-danger { @@ -291,8 +556,305 @@ code { .card-list { display: flex; flex-direction: column; - gap: 10px; - max-width: 640px; + gap: 14px; + width: 100%; + max-width: none; +} + +.profiles-toolbar { + width: 100%; + max-width: none; + margin-bottom: 12px; +} + +.profiles-search { + display: block; +} + +.profiles-search input { + width: 100%; + box-sizing: border-box; + padding: 8px 12px; + border: 1px solid var(--st-border-strong); + border-radius: 6px; + background: var(--st-surface); + color: var(--st-text); + font-size: 13px; +} + +.profiles-search input:focus-visible { + border-color: var(--st-accent); +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.profile-group-card { + display: flex; + flex-direction: column; + gap: 0; + padding: 0; + overflow: hidden; +} + +.profile-group-card.active { + border-color: #93c5fd; + box-shadow: inset 3px 0 0 var(--st-accent); +} + +.profile-group-card-head { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 14px 16px 12px; +} + +.profile-group-card-info { + flex: 1; + min-width: 0; +} + +.profile-group-card-title-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.profile-group-card-title-row .profile-group-name { + flex: 1 1 160px; + min-width: 0; + margin: 0; +} + +.profile-group-card-title-row .profile-group-name input { + font-size: 14px; + font-weight: 600; + padding: 4px 8px; +} + +.profile-group-meta { + margin-top: 4px; + color: var(--st-muted); + font-size: 12px; + line-height: 1.45; + word-break: break-all; +} + +.profile-group-count { + flex: 0 0 auto; + font-size: 12px; + color: var(--st-muted); + font-weight: 500; + white-space: nowrap; +} + +.profile-group-toggle { + flex: 0 0 auto; + padding: 4px 8px; + border-radius: 6px; + border: 1px solid var(--st-border-strong); + background: var(--st-bg); + color: var(--st-text); + font-size: 12px; + font-weight: 500; + cursor: pointer; +} + +.profile-group-toggle:hover { + background: var(--st-accent-soft); + color: var(--st-accent); +} + +.profile-group-toggle:focus-visible { + outline: 2px solid var(--st-accent); + outline-offset: 1px; +} + +.profile-model-rows { + display: flex; + flex-direction: column; + border-top: 1px solid var(--st-border); +} + +.profile-model-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 10px 16px; + border-top: 1px solid var(--st-border); +} + +.profile-model-row:first-child { + border-top: none; +} + +.profile-model-row:hover { + background: var(--st-bg); +} + +.profile-model-row.is-active { + background: color-mix(in srgb, var(--st-accent-soft) 55%, transparent); +} + +.profile-model-row-main { + flex: 1; + min-width: 0; +} + +.profile-model-row-title { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + font-size: 13px; + font-weight: 600; + line-height: 1.35; + word-break: break-all; +} + +.profile-model-row-meta { + margin-top: 2px; + font-size: 12px; + color: var(--st-muted); + line-height: 1.4; + word-break: break-all; +} + +.profile-model-row-actions { + display: flex; + flex-wrap: wrap; + gap: 4px; + flex: 0 0 auto; + justify-content: flex-end; + max-width: 100%; +} + +.profile-model-row-actions button:not(.btn-danger):not(.btn-activate):not(.btn-active-label) { + background: #f4f4f5; + color: var(--st-text); + border: 1px solid transparent; + border-radius: 6px; + padding: 4px 10px; + font-size: 12px; + cursor: pointer; +} + +.profile-model-row-actions button:not(.btn-danger):not(.btn-activate):not(.btn-active-label):hover { + background: var(--st-accent-soft); + color: var(--st-accent); +} + +.profile-model-row-actions .btn-activate { + background: var(--st-text) !important; + color: #fff !important; + border: none !important; + border-radius: 6px; + padding: 4px 12px; + font-size: 12px; + font-weight: 500; + cursor: pointer; +} + +.profile-model-row-actions .btn-activate:hover { + background: #000 !important; +} + +.profile-model-row-actions .btn-active-label { + background: var(--st-accent-soft); + color: var(--st-accent); + border: none; + border-radius: 6px; + padding: 4px 10px; + font-size: 12px; + cursor: default; +} + +.profile-model-row-actions .btn-danger { + padding: 4px 8px; + font-size: 12px; +} + +.profile-model-row .test-result { + padding-left: 0; + margin-top: 4px; +} + +.profile-group-empty { + margin: 0; + padding: 12px 16px 14px; + font-size: 12px; + color: var(--st-muted); + border-top: 1px solid var(--st-border); +} + +@media (max-width: 640px) { + .profile-model-row { + flex-direction: column; + align-items: stretch; + } + + .profile-model-row-actions { + justify-content: flex-start; + } +} + +.profile-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.profile-group-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.profile-group-name { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1; + font-size: 12px; + color: var(--st-muted); + font-weight: 600; +} + +.profile-group-name input { + flex: 1; + min-width: 0; + padding: 6px 8px; + border-radius: 6px; + border: 1px solid var(--st-border-strong); + background: var(--st-surface); + color: var(--st-text); + font-size: 13px; + font-weight: 600; +} + +.profile-group-name input:focus { + outline: none; + border-color: var(--st-accent); + box-shadow: 0 0 0 3px var(--st-accent-soft); +} + +.profile-group-list { + display: flex; + flex-direction: column; + gap: 8px; } .empty-hint { @@ -419,6 +981,15 @@ code { cursor: default; } +.config-card-actions .btn-active-label.is-toggle { + cursor: pointer; +} + +.config-card-actions .btn-active-label.is-toggle:hover { + background: #e4e4e7; + color: var(--st-text); +} + .test-result { font-size: 12px; padding-left: 48px; @@ -491,7 +1062,7 @@ code { overflow-x: auto; white-space: pre-wrap; color: var(--st-muted); - max-width: 640px; + max-width: none; line-height: 1.6; font-family: inherit; } @@ -826,7 +1397,8 @@ code { /* —— Storage —— */ .storage-card { - max-width: 640px; + width: 100%; + max-width: none; padding: 20px; border: 1px solid var(--st-border); border-radius: var(--st-radius); @@ -1118,11 +1690,99 @@ code { } } +/* —— Shared workspace finish —— */ +.st-brand:focus-visible, +.st-nav a:focus-visible, +.st-rail-item:focus-visible, +.btn-primary:focus-visible, +.btn-secondary:focus-visible, +.btn-danger:focus-visible, +.config-card-actions button:focus-visible, +input:focus-visible, +textarea:focus-visible, +select:focus-visible { + outline: 2px solid color-mix(in srgb, var(--st-accent) 70%, transparent); + outline-offset: 2px; +} + +.st-rail-item:hover, +.profile-model-row-actions button:not(.btn-danger):not(.btn-activate):not(.btn-active-label), +.config-card-actions button:not(.btn-danger):not(.btn-activate):not(.btn-active-label), +.active-settings-bar .active-tag, +.st-status .active-tag, +code { + background: color-mix(in srgb, var(--st-surface) 62%, var(--st-bg)); +} + +.profile-model-row:hover, +.preset-entries-inner, +.preset-entry-head:hover, +.preset-entry-body, +.preset-entry-content, +.probe-composer textarea, +.probe-extras textarea, +.probe-extras, +.probe-msg { + background: var(--st-bg); +} + +.config-card.active, +.config-card.is-active, +.profile-group-card.active { + border-color: color-mix(in srgb, var(--st-accent) 58%, var(--st-border)); + box-shadow: inset 3px 0 0 var(--st-accent); +} + +.profile-model-row-actions .btn-activate, +.config-card-actions .btn-activate { + background: var(--st-text) !important; + color: var(--st-surface) !important; +} + +.profile-model-row-actions .btn-activate:hover, +.config-card-actions .btn-activate:hover { + background: color-mix(in srgb, var(--st-text) 86%, var(--st-accent)) !important; +} + +@media (max-width: 720px) { + .st-top { + grid-template-columns: minmax(0, 1fr) auto; + align-items: stretch; + gap: 2px; + padding: 6px 10px; + } + + .st-brand { + padding: 2px 4px; + } + + .st-nav { + grid-column: 1 / -1; + grid-row: 2; + justify-content: flex-start; + flex-wrap: nowrap; + overflow-x: auto; + padding-bottom: 2px; + scrollbar-width: thin; + } + + .st-top-actions { + grid-column: 2; + grid-row: 1; + } + + .st-nav a { + flex: 0 0 auto; + } +} + .profile-dialog form { padding: 22px; display: flex; flex-direction: column; gap: 14px; + max-height: min(90vh, 760px); + overflow: auto; } .profile-dialog h3 { @@ -1161,6 +1821,200 @@ code { box-shadow: 0 0 0 3px var(--st-accent-soft); } +.profile-dialog .field-hint.is-error { + color: var(--st-danger); +} + +.model-lock { + display: flex; + flex-direction: column; + gap: 6px; +} + +.model-lock-value { + margin: 0; + padding: 9px 12px; + border-radius: 6px; + border: 1px solid var(--st-border); + background: var(--st-bg); + color: var(--st-text); + font-family: ui-monospace, "Cascadia Code", Consolas, monospace; + font-size: 13px; + word-break: break-all; +} + +.model-field { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 12px; + color: var(--st-muted); + font-weight: 500; +} + +.model-field-row { + display: flex; + gap: 8px; + align-items: stretch; +} + +.model-field-row input { + flex: 1; + min-width: 0; +} + +.model-field-row .btn-secondary { + flex: 0 0 auto; +} + +.model-field-row .btn-secondary:disabled { + opacity: 0.6; + cursor: progress; +} + +.model-pick { + display: flex; + flex-direction: column; + gap: 6px; +} + +.model-pick-list { + max-height: 220px; + overflow: auto; + border: 1px solid var(--st-border); + border-radius: 6px; + background: var(--st-bg); +} + +.model-pick-item { + display: block; + width: 100%; + text-align: left; + padding: 6px 10px; + border: none; + border-bottom: 1px solid var(--st-border); + background: transparent; + color: var(--st-text); + font-size: 12px; + font-family: ui-monospace, "Cascadia Code", Consolas, monospace; + cursor: pointer; +} + +.model-pick-item:last-child { + border-bottom: none; +} + +.model-pick-item[hidden] { + display: none; +} + +.used-model-label { + font-size: 11px; + font-weight: 500; + color: var(--st-muted); +} + +.used-model-row { + display: flex; + align-items: stretch; + border-bottom: 1px solid var(--st-border); +} + +.used-model-row:last-child { + border-bottom: none; +} + +.used-model-row .model-pick-item { + flex: 1 1 auto; + width: auto; + min-width: 0; + border-bottom: none; + word-break: break-all; +} + +.used-model-note { + display: block; + margin-top: 2px; + font-family: inherit; + font-size: 11px; + font-weight: 400; + color: var(--st-muted); +} + +.model-pick-item.is-current .used-model-note { + color: inherit; + opacity: 0.8; +} + +.used-model-forget { + flex: 0 0 auto; + margin: 0; + padding: 0 10px; + border: none; + border-left: 1px solid var(--st-border); + background: transparent; + color: var(--st-muted); + font-size: 11px; + cursor: pointer; +} + +.used-model-forget:hover { + color: var(--st-danger); + background: var(--st-bg); +} + +.used-model-forget:focus-visible { + outline: 2px solid var(--st-accent); + outline-offset: -2px; +} + +.model-pick-item:hover, +.model-pick-item.is-current { + background: var(--st-accent-soft); + color: var(--st-accent); +} + +.model-pick-item:focus-visible, +.profile-model-select:focus-visible, +.model-field-row .btn-secondary:focus-visible { + outline: 2px solid var(--st-accent); + outline-offset: -2px; +} + +.model-pick-empty { + margin: 0; + font-size: 12px; + font-weight: 400; + color: var(--st-muted); +} + +.profile-model-switch { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 8px; + font-size: 11px; + color: var(--st-muted); + font-weight: 500; +} + +.profile-model-select { + width: 100%; + max-width: 100%; + padding: 6px 8px; + border-radius: 6px; + border: 1px solid var(--st-border-strong); + background: var(--st-surface); + color: var(--st-text); + font-size: 12px; + font-family: ui-monospace, "Cascadia Code", Consolas, monospace; +} + +.profile-model-select:disabled { + opacity: 0.6; + cursor: progress; +} + .profile-dialog .field-hint { margin-top: 2px; font-size: 11px; @@ -1240,17 +2094,20 @@ code { @media (max-width: 800px) { .st-shell { grid-template-columns: 1fr; + grid-template-rows: auto minmax(0, 1fr); } .st-rail { - min-height: auto; flex-direction: row; flex-wrap: wrap; align-items: center; + align-content: flex-start; border-right: none; border-bottom: 1px solid var(--st-border); padding: 12px; gap: 6px; + max-height: 36%; + overflow: auto; } .st-rail-label, diff --git a/web/settings.html b/web/settings.html index 59244f1..dfbf2fc 100644 --- a/web/settings.html +++ b/web/settings.html @@ -4,6 +4,13 @@ 设置 · Writing Agent + + + + @@ -44,6 +51,17 @@
+
+ +
@@ -181,16 +199,33 @@ - + +
+ + 保存的是这一格里的完整型号。下面的筛选只用来查找,点一条才会把完整名字填进来。 +
+ + +
+ +
+
+ +
+
@@ -211,6 +246,26 @@ + +
+

添加型号

+

+
+ + +
+ +
+
+ +
+ 点一条会新建一行并选用。组里已有同名型号时,直接选用那一行,不改写别的型号。 +
+ +
+
+
+

新增用户角色

diff --git a/web/settings.js b/web/settings.js index 02f330b..aa80d07 100644 --- a/web/settings.js +++ b/web/settings.js @@ -1,6 +1,19 @@ +import { + apiProfileUsageRank, + filterUsedApiProfiles, + forgetApiProfileUsage, + loadApiProfileUsage, + pruneApiProfileUsage, + recordApiProfileUsage, + seedApiProfileUsage, + sortByApiProfileUsage, +} from "./api-profile-usage.js"; +import { readSniffModels, writeSniffModels } from "./model-sniff-cache.js"; + let state = { settings: { activeProfileId: null, activePresetId: null }, profiles: [], + groups: [], presets: [], personas: [], activePersonaId: null, @@ -8,6 +21,15 @@ let state = { }; let editingProfileId = null; +/** 当前编辑框里嗅探到的型号,仅用于按 Model 输入筛选 */ +let dialogKnownModels = []; +let dialogSniffBase = ""; +/** 本次服务进程的启动标记;对不上就不读嗅探缓存 */ +let sniffBootId = ""; +let sniffRestoreSeq = 0; +/** 组 id → 是否展开显示该组全部配置(默认只显示曾选用);有搜索词时按匹配显示 */ +const showAllProfilesByGroup = new Set(); +let profileSearchQuery = ""; let editingPersonaId = null; let activeSection = "api"; let lastImportedPresetId = null; @@ -17,6 +39,7 @@ const expandedPresetEntries = new Map(); const openPresetEntryId = new Map(); const profilesListEl = document.getElementById("profiles-list"); +const profileSearchEl = document.getElementById("profile-search"); const personasListEl = document.getElementById("personas-list"); const presetsListEl = document.getElementById("presets-list"); const importReportEl = document.getElementById("import-report"); @@ -137,7 +160,8 @@ function renderPanelHeader() { panelActionsEl.innerHTML = ""; if (activeSection === "api") { panelTitleEl.textContent = "API"; - panelSubtitleEl.textContent = "保存后点「选用」才会用于对话"; + panelSubtitleEl.textContent = + "一组一张卡,一行一个型号。添加型号不会改掉已有行,探测也留在原行"; const btn = document.createElement("button"); btn.type = "button"; btn.className = "btn-primary"; @@ -160,7 +184,7 @@ function renderPanelHeader() { } else if (activeSection === "preset") { panelTitleEl.textContent = "预设"; panelSubtitleEl.textContent = - "导入后可启用/编辑条目;选用后注入全部 LLM 请求"; + "导入后可启用/编辑条目;选用后注入全部 LLM 请求。再点「使用中」即不使用任何预设"; const btn = document.createElement("button"); btn.type = "button"; btn.className = "btn-primary"; @@ -173,7 +197,10 @@ function renderPanelHeader() { } } -function activateButtonHtml(active, id, action) { +function activateButtonHtml(active, id, action, { toggleOff = false } = {}) { + if (active && toggleOff) { + return ``; + } if (active) { return ``; } @@ -205,6 +232,88 @@ function capabilityBadge(caps) {
`; } +function escapeAttr(text) { + return escapeHtml(text).replace(/"/g, """); +} + +function profileMatchesQuery(profile, group, query) { + if (!query) return true; + const hay = [ + group?.name, + group?.baseUrl, + profile.name, + profile.model, + profile.baseUrl, + profile.reasoningEffort, + ] + .map((part) => String(part || "").toLowerCase()) + .join("\n"); + return hay.includes(query); +} + +function capabilityLine(caps) { + if (!caps || !caps.testedAt) { + return `能力未探测`; + } + const mode = + caps.jsonSchema === "ok" + ? "JSON Schema" + : caps.forcedTool === "ok" + ? "强制 Tool" + : caps.jsonObject === "ok" + ? "JSON Object" + : "纯文本"; + return `投递 ${escapeHtml(mode)}`; +} + +function renderModelRow(p) { + const active = p.id === state.settings.activeProfileId; + const used = Boolean(loadApiProfileUsage()[p.id]); + const row = document.createElement("div"); + row.className = `profile-model-row${active ? " is-active" : ""}`; + const metaParts = [ + p.name && p.name !== p.model ? p.name : "", + p.reasoningEffort ? `思考 ${p.reasoningEffort}` : "", + ].filter(Boolean); + const forget = !active && used + ? `` + : ""; + row.innerHTML = ` +
+
+ ${escapeHtml(p.model || p.name || "未命名")} + ${active ? '当前' : ""} +
+
+ ${metaParts.length ? `${escapeHtml(metaParts.join(" · "))} · ` : ""} + ${capabilityLine(p.capabilities)} +
+
+
+
+ ${activateButtonHtml(active, p.id, "activate-profile")} + + + ${forget} + +
`; + return row; +} + +function visibleGroupMembers(members, group, usage, activeId) { + const query = profileSearchQuery.trim().toLowerCase(); + const showAll = showAllProfilesByGroup.has(group.id) || Boolean(query); + let list = showAll + ? sortByApiProfileUsage(members, (profile) => + apiProfileUsageRank(profile.id, usage), + ) + : filterUsedApiProfiles(members, usage, activeId); + if (query) { + list = list.filter((profile) => profileMatchesQuery(profile, group, query)); + } + return { list, showAll, query }; +} + function renderProfiles() { profilesListEl.innerHTML = ""; if (!state.profiles.length) { @@ -213,35 +322,131 @@ function renderProfiles() { return; } - for (const p of state.profiles) { - const active = p.id === state.settings.activeProfileId; - const card = document.createElement("div"); - card.className = `config-card${active ? " active" : ""}`; + const usage = loadApiProfileUsage(); + const activeId = state.settings.activeProfileId; + const query = profileSearchQuery.trim().toLowerCase(); + const groups = state.groups.length + ? state.groups + : [{ id: "", name: "未分组", baseUrl: "" }]; + const seen = new Set(); + let shownGroups = 0; + + for (const group of groups) { + const members = state.profiles.filter((p) => (p.groupId || "") === group.id); + if (!members.length) continue; + for (const member of members) seen.add(member.id); + + const { list: visible, showAll } = visibleGroupMembers( + members, + group, + usage, + activeId, + ); + if (query && !visible.length) continue; + + const hasActive = members.some((p) => p.id === activeId); + const sample = members[0]; + const hidden = members.length - visible.length; + const countLabel = query + ? `匹配 ${visible.length} / ${members.length}` + : showAll + ? `${members.length} 个模型` + : hidden > 0 + ? `曾用 ${visible.length} / 共 ${members.length}` + : `${members.length} 个模型`; + const toggle = + !query && members.length > 1 + ? `` + : ""; + + const card = document.createElement("section"); + card.className = `config-card profile-group-card${hasActive ? " active" : ""}`; card.innerHTML = ` -
-
${escapeHtml(p.name.charAt(0).toUpperCase())}
-
-
- ${escapeHtml(p.name)} - ${active ? '当前' : ""} +
+
${escapeHtml( + (group.name || "G").charAt(0).toUpperCase(), + )}
+
+
+ + ${hasActive ? '当前组' : ""} + ${escapeHtml(countLabel)} + ${toggle} + ${ + group.id + ? `` + : "" + } +
+
+ ${escapeHtml(group.baseUrl || sample?.baseUrl || "")} + ${sample ? ` · Key: ${escapeHtml(maskKey(sample.apiKey))}` : ""}
-
${escapeHtml(p.model)} · ${escapeHtml(p.baseUrl)}
-
Key: ${escapeHtml(maskKey(p.apiKey))}${ - p.reasoningEffort - ? ` · 思考: ${escapeHtml(p.reasoningEffort)}` - : "" - }
- ${capabilityBadge(p.capabilities)}
-
-
- ${activateButtonHtml(active, p.id, "activate-profile")} - - - -
-
`; +
`; + + if (!visible.length) { + const empty = document.createElement("p"); + empty.className = "profile-group-empty"; + empty.textContent = query + ? "没有匹配的模型" + : "本组还没有曾选用过的型号。点「显示全部」,或「添加型号」。"; + card.appendChild(empty); + } else { + const rows = document.createElement("div"); + rows.className = "profile-model-rows"; + for (const p of visible) rows.appendChild(renderModelRow(p)); + card.appendChild(rows); + } + profilesListEl.appendChild(card); + shownGroups += 1; + } + + const loose = state.profiles.filter((p) => !seen.has(p.id)); + const looseVisible = query + ? loose.filter((p) => profileMatchesQuery(p, { name: "未分组" }, query)) + : loose; + if (looseVisible.length) { + const card = document.createElement("section"); + card.className = "config-card profile-group-card"; + card.innerHTML = ` +
+
?
+
+
+ 未分组 + ${looseVisible.length} 个模型 +
+
+
`; + const rows = document.createElement("div"); + rows.className = "profile-model-rows"; + for (const p of looseVisible) rows.appendChild(renderModelRow(p)); + card.appendChild(rows); + profilesListEl.appendChild(card); + shownGroups += 1; + } + + if (!shownGroups) { + profilesListEl.innerHTML = query + ? `

没有匹配「${escapeHtml(profileSearchQuery.trim())}」的配置。

` + : '

暂无配置,点击右上角「新增配置」。

'; } } @@ -274,7 +479,7 @@ function renderPresets() {
- ${activateButtonHtml(active, p.id, "activate-preset")} + ${activateButtonHtml(active, p.id, "activate-preset", { toggleOff: true })}
${numberFields} -