把思考强度收到接口配置里,并支持分组、嗅探和切换同一接口下的模型。
预设不再保存思考强度。设置页会在首屏套用主题。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
+236
-37
@@ -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<string>();
|
||||
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>): 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<string, ApiProfileGroup>();
|
||||
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, "name" | "baseUrl" | "apiKey" | "model" | "reasoningEffort">
|
||||
>,
|
||||
): 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 {
|
||||
|
||||
@@ -113,6 +113,7 @@ export function resolveActiveProfile() {
|
||||
baseUrl: env.baseUrl,
|
||||
apiKey: env.apiKey,
|
||||
model: env.model,
|
||||
models: [env.model],
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
};
|
||||
|
||||
+15
-1
@@ -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<string>();
|
||||
|
||||
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");
|
||||
|
||||
+40
-8
@@ -131,14 +131,50 @@ export type LlmProvider = {
|
||||
): Promise<CompleteWithToolsResult>;
|
||||
};
|
||||
|
||||
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<string, unknown> {
|
||||
const requestMessages =
|
||||
options?.responseFormat === "json_object"
|
||||
? ensureJsonObjectPrompt(messages)
|
||||
: messages;
|
||||
const body: Record<string, unknown> = {
|
||||
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<string, unknown> }>;
|
||||
};
|
||||
// 空正文不抛错:游玩/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,
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
tried: Set<string>,
|
||||
): 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<string, unknown>) };
|
||||
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;
|
||||
|
||||
@@ -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<string>();
|
||||
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<FetchOutcome> {
|
||||
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(";")}` : "未能嗅探模型",
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+16
-4
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
/** 本次进程启动标记。重启后变化,浏览器据此丢掉嗅探缓存。 */
|
||||
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]);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, string>();
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> = {
|
||||
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<string, unknown> = {
|
||||
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<string, unknown> = { 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<string, unknown> = { 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<Record<string, unknown>> = [];
|
||||
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",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { readSniffModels, writeSniffModels } from "../web/model-sniff-cache.js";
|
||||
|
||||
const store = new Map<string, string>();
|
||||
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -190,7 +190,6 @@ describe("normalizeGeneration / applyPresetGeneration", () => {
|
||||
).toEqual({
|
||||
temperature: 0.9,
|
||||
maxOutputTokens: 4096,
|
||||
reasoningEffort: "high",
|
||||
stream: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* API 配置使用记录(浏览器 localStorage)。
|
||||
* 对话菜单与设置页共用:只把「曾经选用过」的模型放进快捷列表。
|
||||
*/
|
||||
|
||||
export const API_PROFILE_USAGE_KEY = "wa-api-profile-usage";
|
||||
|
||||
/** @typedef {{ count: number, lastUsed: number }} ApiProfileUsageEntry */
|
||||
/** @typedef {Record<string, ApiProfileUsageEntry>} 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<string>} 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 },
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+892
-35
File diff suppressed because it is too large
Load Diff
+61
-6
@@ -4,6 +4,13 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>设置 · Writing Agent</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;600;700&family=Noto+Serif+SC:wght@500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<script src="/theme-init.js"></script>
|
||||
<link rel="stylesheet" href="/settings.css" />
|
||||
</head>
|
||||
<body class="settings-page">
|
||||
@@ -44,6 +51,17 @@
|
||||
|
||||
<div class="st-body">
|
||||
<section class="settings-section" id="section-api">
|
||||
<div class="profiles-toolbar">
|
||||
<label class="profiles-search">
|
||||
<span class="visually-hidden">搜索 API 配置</span>
|
||||
<input
|
||||
type="search"
|
||||
id="profile-search"
|
||||
placeholder="搜索组名、模型、URL…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div id="profiles-list" class="card-list"></div>
|
||||
</section>
|
||||
|
||||
@@ -181,16 +199,33 @@
|
||||
</label>
|
||||
<label>
|
||||
Base URL
|
||||
<input name="baseUrl" required placeholder="https://api.deepseek.com" />
|
||||
<input name="baseUrl" required placeholder="https://api.deepseek.com/v1" />
|
||||
</label>
|
||||
<label>
|
||||
API Key
|
||||
<input name="apiKey" type="password" placeholder="sk-…" autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
Model
|
||||
<input name="model" required placeholder="deepseek-chat" />
|
||||
</label>
|
||||
<div id="profile-model-lock" class="model-lock" hidden>
|
||||
<span class="model-field-label">型号</span>
|
||||
<p id="profile-model-lock-value" class="model-lock-value"></p>
|
||||
<span class="field-hint">型号是这一行的身份,探测跟着它走。要换型号,在组里点「添加型号」。</span>
|
||||
</div>
|
||||
<div id="profile-model-editor" class="model-field">
|
||||
<label>
|
||||
型号
|
||||
<input name="model" placeholder="deepseek-chat" autocomplete="off" />
|
||||
</label>
|
||||
<span class="field-hint">保存的是这一格里的完整型号。下面的筛选只用来查找,点一条才会把完整名字填进来。</span>
|
||||
<div class="model-field-row">
|
||||
<input id="profile-model-filter" type="search" placeholder="筛选嗅探结果,如 gemini" autocomplete="off" aria-label="筛选嗅探结果" />
|
||||
<button type="button" class="btn-secondary" id="profile-sniff">嗅探</button>
|
||||
</div>
|
||||
<p class="field-hint" id="profile-sniff-status" hidden></p>
|
||||
<div id="profile-model-pick" class="model-pick">
|
||||
<div id="profile-model-list" class="model-pick-list" role="listbox" aria-label="嗅探结果"></div>
|
||||
<p class="model-pick-empty" id="profile-model-empty" hidden></p>
|
||||
</div>
|
||||
</div>
|
||||
<label>
|
||||
思考强度
|
||||
<select name="reasoningEffort">
|
||||
@@ -201,7 +236,7 @@
|
||||
<option value="high">high · 增强</option>
|
||||
<option value="max">max · 深度</option>
|
||||
</select>
|
||||
<span class="field-hint">写入请求的思考参数。GLM-5.3:仅 low/high/max(none 会映射为 low),并附带 thinking.enabled;旧 GLM 只用 thinking、不发 reasoning_effort。探测始终用 low。带 function tools 时:非 GLM 强制 none;GLM-5.3 保留合法档。</span>
|
||||
<span class="field-hint">只在 API 配置里设置,预设不再带思考强度。GLM-5.3:仅 low/high/max(none 会映射为 low),并附带 thinking.enabled;旧 GLM 只用 thinking、不发 reasoning_effort。Gemini 3.8:仅 low/medium/high,none 与 minimal 都会映射为 low,并附带 reasoning.effort。探测始终用 low。带 function tools 时:多数型号强制 none;GLM-5.3 与 Gemini 3 保留合法档。</span>
|
||||
</label>
|
||||
<div class="dialog-actions">
|
||||
<button type="button" id="profile-cancel" class="btn-secondary">取消</button>
|
||||
@@ -211,6 +246,26 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="add-model-dialog" class="profile-dialog">
|
||||
<form id="add-model-form" method="dialog">
|
||||
<h3>添加型号</h3>
|
||||
<p class="field-hint" id="add-model-group"></p>
|
||||
<div class="model-field-row">
|
||||
<input id="add-model-filter" type="search" placeholder="筛选嗅探结果,如 gemini" autocomplete="off" aria-label="筛选嗅探结果" />
|
||||
<button type="button" class="btn-secondary" id="add-model-sniff">嗅探</button>
|
||||
</div>
|
||||
<p class="field-hint" id="add-model-status" hidden></p>
|
||||
<div id="add-model-pick" class="model-pick">
|
||||
<div id="add-model-list" class="model-pick-list" role="listbox" aria-label="嗅探结果"></div>
|
||||
<p class="model-pick-empty" id="add-model-empty" hidden></p>
|
||||
</div>
|
||||
<span class="field-hint">点一条会新建一行并选用。组里已有同名型号时,直接选用那一行,不改写别的型号。</span>
|
||||
<div class="dialog-actions">
|
||||
<button type="button" id="add-model-cancel" class="btn-secondary">关闭</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="persona-dialog" class="profile-dialog">
|
||||
<form id="persona-form" method="dialog">
|
||||
<h3 id="persona-dialog-title">新增用户角色</h3>
|
||||
|
||||
+731
-46
@@ -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 `<button type="button" class="btn-active-label is-toggle" data-action="${action}" data-id="${id}" title="再点一次即不使用预设">✓ 使用中</button>`;
|
||||
}
|
||||
if (active) {
|
||||
return `<button type="button" class="btn-active-label" disabled>✓ 使用中</button>`;
|
||||
}
|
||||
@@ -205,6 +232,88 @@ function capabilityBadge(caps) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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 `<span class="cap-unknown">能力未探测</span>`;
|
||||
}
|
||||
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
|
||||
? `<button type="button" data-action="forget-usage" data-id="${escapeAttr(p.id)}" title="移出曾用记录,配置仍保留">移出曾用</button>`
|
||||
: "";
|
||||
row.innerHTML = `
|
||||
<div class="profile-model-row-main">
|
||||
<div class="profile-model-row-title">
|
||||
<span>${escapeHtml(p.model || p.name || "未命名")}</span>
|
||||
${active ? '<span class="badge">当前</span>' : ""}
|
||||
</div>
|
||||
<div class="profile-model-row-meta">
|
||||
${metaParts.length ? `${escapeHtml(metaParts.join(" · "))} · ` : ""}
|
||||
${capabilityLine(p.capabilities)}
|
||||
</div>
|
||||
<div class="test-result" id="test-${escapeAttr(p.id)}"></div>
|
||||
</div>
|
||||
<div class="profile-model-row-actions">
|
||||
${activateButtonHtml(active, p.id, "activate-profile")}
|
||||
<button type="button" data-action="edit-profile" data-id="${escapeAttr(p.id)}">编辑</button>
|
||||
<button type="button" data-action="probe-capabilities" data-id="${escapeAttr(p.id)}">探测</button>
|
||||
${forget}
|
||||
<button type="button" class="btn-danger" data-action="delete-profile" data-id="${escapeAttr(p.id)}">删除</button>
|
||||
</div>`;
|
||||
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
|
||||
? `<button
|
||||
type="button"
|
||||
class="profile-group-toggle"
|
||||
data-action="toggle-group-all"
|
||||
data-id="${escapeAttr(group.id)}"
|
||||
>${showAll ? "只看曾用" : "显示全部"}</button>`
|
||||
: "";
|
||||
|
||||
const card = document.createElement("section");
|
||||
card.className = `config-card profile-group-card${hasActive ? " active" : ""}`;
|
||||
card.innerHTML = `
|
||||
<div class="config-card-head">
|
||||
<div class="config-card-icon api">${escapeHtml(p.name.charAt(0).toUpperCase())}</div>
|
||||
<div class="config-card-info">
|
||||
<div class="config-card-title">
|
||||
${escapeHtml(p.name)}
|
||||
${active ? '<span class="badge">当前</span>' : ""}
|
||||
<div class="profile-group-card-head">
|
||||
<div class="config-card-icon api">${escapeHtml(
|
||||
(group.name || "G").charAt(0).toUpperCase(),
|
||||
)}</div>
|
||||
<div class="profile-group-card-info">
|
||||
<div class="profile-group-card-title-row">
|
||||
<label class="profile-group-name">
|
||||
<span class="visually-hidden">组名</span>
|
||||
<input
|
||||
type="text"
|
||||
data-action="rename-group"
|
||||
data-id="${escapeAttr(group.id)}"
|
||||
value="${escapeAttr(group.name)}"
|
||||
aria-label="组名"
|
||||
${group.id ? "" : "disabled"}
|
||||
/>
|
||||
</label>
|
||||
${hasActive ? '<span class="badge">当前组</span>' : ""}
|
||||
<span class="profile-group-count">${escapeHtml(countLabel)}</span>
|
||||
${toggle}
|
||||
${
|
||||
group.id
|
||||
? `<button type="button" class="profile-group-toggle" data-action="add-model" data-id="${escapeAttr(group.id)}">添加型号</button>`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
<div class="profile-group-meta">
|
||||
${escapeHtml(group.baseUrl || sample?.baseUrl || "")}
|
||||
${sample ? ` · Key: ${escapeHtml(maskKey(sample.apiKey))}` : ""}
|
||||
</div>
|
||||
<div class="config-card-meta">${escapeHtml(p.model)} · ${escapeHtml(p.baseUrl)}</div>
|
||||
<div class="config-card-meta">Key: ${escapeHtml(maskKey(p.apiKey))}${
|
||||
p.reasoningEffort
|
||||
? ` · 思考: ${escapeHtml(p.reasoningEffort)}`
|
||||
: ""
|
||||
}</div>
|
||||
${capabilityBadge(p.capabilities)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-card-actions">
|
||||
${activateButtonHtml(active, p.id, "activate-profile")}
|
||||
<button type="button" data-action="edit-profile" data-id="${p.id}">编辑</button>
|
||||
<button type="button" data-action="probe-capabilities" data-id="${p.id}">探测</button>
|
||||
<button type="button" class="btn-danger" data-action="delete-profile" data-id="${p.id}">删除</button>
|
||||
</div>
|
||||
<div class="test-result" id="test-${p.id}"></div>`;
|
||||
</div>`;
|
||||
|
||||
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 = `
|
||||
<div class="profile-group-card-head">
|
||||
<div class="config-card-icon api">?</div>
|
||||
<div class="profile-group-card-info">
|
||||
<div class="profile-group-card-title-row">
|
||||
<strong>未分组</strong>
|
||||
<span class="profile-group-count">${looseVisible.length} 个模型</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
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
|
||||
? `<p class="empty-hint">没有匹配「${escapeHtml(profileSearchQuery.trim())}」的配置。</p>`
|
||||
: '<p class="empty-hint">暂无配置,点击右上角「新增配置」。</p>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,7 +479,7 @@ function renderPresets() {
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-card-actions">
|
||||
${activateButtonHtml(active, p.id, "activate-preset")}
|
||||
${activateButtonHtml(active, p.id, "activate-preset", { toggleOff: true })}
|
||||
<button type="button" data-action="probe-preset" data-id="${p.id}">试跑</button>
|
||||
<button type="button" data-action="toggle-preset-entries" data-id="${p.id}">
|
||||
${expanded ? "收起条目" : "编辑条目"}
|
||||
@@ -363,16 +568,6 @@ function renderGenerationForm(gen) {
|
||||
</div>
|
||||
<div class="preset-gen-grid">
|
||||
${numberFields}
|
||||
<label class="entry-field gen-field">
|
||||
reasoning effort
|
||||
<input
|
||||
type="text"
|
||||
class="entry-name-input"
|
||||
data-gen-field="reasoningEffort"
|
||||
placeholder="如 low / medium / high"
|
||||
value="${escapeHtml(generationFieldValue(g, "reasoningEffort"))}"
|
||||
/>
|
||||
</label>
|
||||
<label class="entry-field gen-field">
|
||||
verbosity
|
||||
<input
|
||||
@@ -409,7 +604,7 @@ function collectGenerationFromPanel(panel) {
|
||||
}
|
||||
const raw = input.value.trim();
|
||||
if (!raw) continue;
|
||||
if (key === "reasoningEffort" || key === "verbosity") {
|
||||
if (key === "verbosity") {
|
||||
generation[key] = raw;
|
||||
} else {
|
||||
const n = Number(raw);
|
||||
@@ -550,7 +745,11 @@ async function loadAll() {
|
||||
const data = await api("/api/settings");
|
||||
state.settings = data.settings;
|
||||
state.profiles = data.profiles;
|
||||
state.groups = data.groups || [];
|
||||
pruneApiProfileUsage(new Set(state.profiles.map((p) => p.id)));
|
||||
seedApiProfileUsage(state.settings.activeProfileId);
|
||||
state.presets = data.presets;
|
||||
sniffBootId = typeof data.bootId === "string" ? data.bootId : "";
|
||||
try {
|
||||
const personasData = await api("/api/personas");
|
||||
state.personas = personasData.personas ?? [];
|
||||
@@ -662,25 +861,252 @@ async function savePersona() {
|
||||
showToast("用户角色已保存");
|
||||
}
|
||||
|
||||
const profileModelLockEl = document.getElementById("profile-model-lock");
|
||||
const profileModelLockValueEl = document.getElementById("profile-model-lock-value");
|
||||
const profileModelEditorEl = document.getElementById("profile-model-editor");
|
||||
const profileModelFilterEl = document.getElementById("profile-model-filter");
|
||||
const addModelDialog = document.getElementById("add-model-dialog");
|
||||
const addModelGroupEl = document.getElementById("add-model-group");
|
||||
const addModelFilterEl = document.getElementById("add-model-filter");
|
||||
const addModelStatusEl = document.getElementById("add-model-status");
|
||||
const addModelListEl = document.getElementById("add-model-list");
|
||||
const addModelEmptyEl = document.getElementById("add-model-empty");
|
||||
const addModelSniffBtn = document.getElementById("add-model-sniff");
|
||||
let addModelSource = null;
|
||||
let addKnownModels = [];
|
||||
let addSniffBase = "";
|
||||
let addSniffSeq = 0;
|
||||
const profileSniffStatusEl = document.getElementById("profile-sniff-status");
|
||||
const profileModelPickEl = document.getElementById("profile-model-pick");
|
||||
const profileModelListEl = document.getElementById("profile-model-list");
|
||||
const profileModelEmptyEl = document.getElementById("profile-model-empty");
|
||||
const profileSniffBtn = document.getElementById("profile-sniff");
|
||||
const MODEL_MATCH_LIMIT = 48;
|
||||
|
||||
function setSniffStatus(el, message, isError = false) {
|
||||
if (!el) return;
|
||||
el.hidden = !message;
|
||||
el.textContent = message || "";
|
||||
el.classList.toggle("is-error", Boolean(isError));
|
||||
}
|
||||
|
||||
function uniqueModelIds(ids) {
|
||||
const list = [];
|
||||
const seen = new Set();
|
||||
for (const raw of ids) {
|
||||
const id = String(raw ?? "").trim();
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
list.push(id);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
function normalizeBaseUrlKey(url) {
|
||||
return String(url || "")
|
||||
.trim()
|
||||
.replace(/\/+$/, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/** 同一 Base URL + 同一 Key 共用一份缓存。Key 只存指纹,不存原文。 */
|
||||
async function sniffCacheKeyFor({ baseUrl, apiKey, profileId, groupId }) {
|
||||
const base = normalizeBaseUrlKey(baseUrl);
|
||||
if (!base) return "";
|
||||
const typed = String(apiKey || "").trim();
|
||||
if (typed) return `${base}\nkey:${await apiKeyFingerprint(typed)}`;
|
||||
const ident = groupId || profileId || "";
|
||||
return ident ? `${base}\ngroup:${ident}` : "";
|
||||
}
|
||||
|
||||
function createSniffContext() {
|
||||
const profile = state.profiles.find((item) => item.id === editingProfileId);
|
||||
return {
|
||||
baseUrl: profileForm.baseUrl.value,
|
||||
apiKey: profileForm.apiKey.value,
|
||||
profileId: editingProfileId || "",
|
||||
groupId: profile?.groupId || "",
|
||||
};
|
||||
}
|
||||
|
||||
function addSniffContext() {
|
||||
return {
|
||||
baseUrl: addModelSource?.baseUrl || "",
|
||||
apiKey: addModelSource?.apiKey || "",
|
||||
profileId: addModelSource?.id || "",
|
||||
groupId: addModelSource?.groupId || "",
|
||||
};
|
||||
}
|
||||
|
||||
function modelsForBase(baseUrl) {
|
||||
const base = normalizeBaseUrlKey(baseUrl);
|
||||
return uniqueModelIds(
|
||||
state.profiles
|
||||
.filter((profile) => normalizeBaseUrlKey(profile.baseUrl) === base)
|
||||
.map((profile) => profile.model),
|
||||
);
|
||||
}
|
||||
|
||||
function renderModelCatalog(listEl, emptyEl, { query, chosen, catalog, existing, sniffed = 0, idleEmpty = "先嗅探。输入关键词后,点一条填入完整型号。" }) {
|
||||
if (!listEl || !emptyEl) return;
|
||||
const text = query.trim();
|
||||
const needle = text.toLowerCase();
|
||||
if (!needle) {
|
||||
listEl.hidden = true;
|
||||
listEl.innerHTML = "";
|
||||
emptyEl.hidden = false;
|
||||
emptyEl.textContent = sniffed
|
||||
? `已有 ${sniffed} 个嗅探结果。输入关键词筛选,例如 gemini。`
|
||||
: idleEmpty;
|
||||
return;
|
||||
}
|
||||
const matched = catalog.filter((model) => model.toLowerCase().includes(needle));
|
||||
const more = Math.max(0, matched.length - MODEL_MATCH_LIMIT);
|
||||
const shown = matched.slice(0, MODEL_MATCH_LIMIT);
|
||||
listEl.hidden = shown.length === 0;
|
||||
listEl.innerHTML = shown
|
||||
.map((model) => {
|
||||
const on = model === chosen;
|
||||
const owned = existing?.has(model) ? " · 已有" : "";
|
||||
return `<button
|
||||
type="button"
|
||||
class="model-pick-item${on ? " is-current" : ""}"
|
||||
data-pick-model="${escapeAttr(model)}"
|
||||
role="option"
|
||||
aria-selected="${on ? "true" : "false"}"
|
||||
>${escapeHtml(model)}${owned}</button>`;
|
||||
})
|
||||
.join("");
|
||||
emptyEl.hidden = shown.length > 0 && more === 0;
|
||||
if (!shown.length) {
|
||||
emptyEl.textContent = catalog.length
|
||||
? `没有名称包含「${text}」的型号`
|
||||
: "还没有可筛选的型号。先嗅探,再输入关键词。";
|
||||
} else if (more) {
|
||||
emptyEl.textContent = `还有 ${more} 个,继续输入以缩小`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderCreateModelChoices() {
|
||||
const base = normalizeBaseUrlKey(profileForm.baseUrl.value);
|
||||
const catalog =
|
||||
dialogSniffBase === base
|
||||
? uniqueModelIds([...modelsForBase(base), ...dialogKnownModels])
|
||||
: modelsForBase(base);
|
||||
renderModelCatalog(profileModelListEl, profileModelEmptyEl, {
|
||||
query: profileModelFilterEl?.value || "",
|
||||
chosen: profileForm.model.value.trim(),
|
||||
catalog,
|
||||
sniffed: dialogSniffBase === base ? dialogKnownModels.length : 0,
|
||||
});
|
||||
}
|
||||
|
||||
function renderAddModelChoices() {
|
||||
const base = normalizeBaseUrlKey(addModelSource?.baseUrl || "");
|
||||
const catalog =
|
||||
addSniffBase === base
|
||||
? uniqueModelIds([...modelsForBase(base), ...addKnownModels])
|
||||
: modelsForBase(base);
|
||||
renderModelCatalog(addModelListEl, addModelEmptyEl, {
|
||||
query: addModelFilterEl?.value || "",
|
||||
chosen: "",
|
||||
catalog,
|
||||
sniffed: addSniffBase === base ? addKnownModels.length : 0,
|
||||
idleEmpty: "先嗅探。输入关键词后,点一条就会新开一行并选用。",
|
||||
existing: new Set(
|
||||
state.profiles
|
||||
.filter((profile) => (profile.groupId || "") === (addModelSource?.groupId || ""))
|
||||
.map((profile) => profile.model),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
async function restoreCreateSniffCache() {
|
||||
const seq = ++sniffRestoreSeq;
|
||||
const ctx = createSniffContext();
|
||||
const base = normalizeBaseUrlKey(ctx.baseUrl);
|
||||
const cacheKey = await sniffCacheKeyFor(ctx);
|
||||
if (seq !== sniffRestoreSeq) return;
|
||||
const models = uniqueModelIds(readSniffModels(sniffBootId, cacheKey));
|
||||
dialogSniffBase = models.length ? base : "";
|
||||
dialogKnownModels = models;
|
||||
setSniffStatus(
|
||||
profileSniffStatusEl,
|
||||
models.length
|
||||
? `沿用本次启动的嗅探结果(${models.length} 个),重启程序后会清空。`
|
||||
: "",
|
||||
);
|
||||
renderCreateModelChoices();
|
||||
}
|
||||
|
||||
async function restoreAddSniffCache() {
|
||||
const seq = ++addSniffSeq;
|
||||
const ctx = addSniffContext();
|
||||
const base = normalizeBaseUrlKey(ctx.baseUrl);
|
||||
const cacheKey = await sniffCacheKeyFor(ctx);
|
||||
if (seq !== addSniffSeq) return;
|
||||
const models = uniqueModelIds(readSniffModels(sniffBootId, cacheKey));
|
||||
addSniffBase = models.length ? base : "";
|
||||
addKnownModels = models;
|
||||
setSniffStatus(
|
||||
addModelStatusEl,
|
||||
models.length
|
||||
? `沿用本次启动的嗅探结果(${models.length} 个),重启程序后会清空。`
|
||||
: "",
|
||||
);
|
||||
renderAddModelChoices();
|
||||
}
|
||||
|
||||
function setProfileModelMode(profile) {
|
||||
const editing = Boolean(profile);
|
||||
if (profileModelLockEl) profileModelLockEl.hidden = !editing;
|
||||
if (profileModelEditorEl) profileModelEditorEl.hidden = editing;
|
||||
if (profileModelLockValueEl) {
|
||||
profileModelLockValueEl.textContent = profile?.model || "";
|
||||
}
|
||||
if (profileForm.model) profileForm.model.disabled = editing;
|
||||
}
|
||||
|
||||
function openProfileDialog(profile = null) {
|
||||
editingProfileId = profile?.id ?? null;
|
||||
profileDialogTitle.textContent = profile ? "编辑 API 配置" : "新增 API 配置";
|
||||
profileDialogTitle.textContent = profile ? "编辑这一行" : "新增 API";
|
||||
profileForm.name.value = profile?.name ?? "";
|
||||
profileForm.baseUrl.value = profile?.baseUrl ?? "https://api.deepseek.com";
|
||||
profileForm.baseUrl.value = profile?.baseUrl ?? "https://api.deepseek.com/v1";
|
||||
profileForm.apiKey.value = profile?.apiKey ?? "";
|
||||
profileForm.model.value = profile?.model ?? "deepseek-v4-pro";
|
||||
profileForm.model.value = profile ? profile.model : "";
|
||||
profileForm.reasoningEffort.value = profile?.reasoningEffort ?? "";
|
||||
if (profileModelFilterEl) profileModelFilterEl.value = "";
|
||||
dialogKnownModels = [];
|
||||
dialogSniffBase = "";
|
||||
setSniffStatus(profileSniffStatusEl, "");
|
||||
setProfileModelMode(profile);
|
||||
renderCreateModelChoices();
|
||||
profileDialog.showModal();
|
||||
if (!profile) void restoreCreateSniffCache();
|
||||
}
|
||||
|
||||
async function apiKeyFingerprint(apiKey) {
|
||||
const data = new TextEncoder().encode(apiKey.trim());
|
||||
const buf = await crypto.subtle.digest("SHA-256", data);
|
||||
return [...new Uint8Array(buf)]
|
||||
.slice(0, 8)
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function saveProfile(activate) {
|
||||
const model = profileForm.model.value.trim();
|
||||
if (!editingProfileId && !model) {
|
||||
showToast("先填写完整型号,或从筛选结果里点一条", true);
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
name: profileForm.name.value,
|
||||
baseUrl: profileForm.baseUrl.value,
|
||||
model: profileForm.model.value,
|
||||
reasoningEffort: profileForm.reasoningEffort.value,
|
||||
activate,
|
||||
};
|
||||
if (!editingProfileId) payload.model = model;
|
||||
const apiKey = profileForm.apiKey.value.trim();
|
||||
if (apiKey || !editingProfileId) {
|
||||
payload.apiKey = apiKey;
|
||||
@@ -692,10 +1118,12 @@ async function saveProfile(activate) {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const savedId = result.profile?.id || editingProfileId;
|
||||
if (activate) {
|
||||
result = await api(`/api/profiles/${editingProfileId}/activate`, {
|
||||
const activated = await api(`/api/profiles/${encodeURIComponent(savedId)}/activate`, {
|
||||
method: "POST",
|
||||
});
|
||||
result = { ...result, ...activated, profile: result.profile };
|
||||
}
|
||||
} else {
|
||||
result = await api("/api/profiles", {
|
||||
@@ -705,6 +1133,8 @@ async function saveProfile(activate) {
|
||||
}
|
||||
|
||||
profileDialog.close();
|
||||
const savedId = result.profile?.id || editingProfileId || result.activeProfileId;
|
||||
if (activate && savedId) recordApiProfileUsage(savedId);
|
||||
await loadAll();
|
||||
|
||||
if (activate) {
|
||||
@@ -717,6 +1147,7 @@ async function saveProfile(activate) {
|
||||
|
||||
async function activateProfile(id) {
|
||||
const result = await api(`/api/profiles/${id}/activate`, { method: "POST" });
|
||||
recordApiProfileUsage(id);
|
||||
await loadAll();
|
||||
const n = result.reloadedSessions ?? 0;
|
||||
showToast(`API 配置已生效${n > 0 ? `(${n} 个会话已更新)` : ""}`);
|
||||
@@ -729,6 +1160,13 @@ async function activatePreset(id) {
|
||||
showToast(`预设已生效${n > 0 ? `(${n} 个会话已更新)` : ""}`);
|
||||
}
|
||||
|
||||
async function deactivatePreset() {
|
||||
const result = await api("/api/presets/deactivate", { method: "POST" });
|
||||
await loadAll();
|
||||
const n = result.reloadedSessions ?? 0;
|
||||
showToast(`已不使用预设${n > 0 ? `(${n} 个会话已更新)` : ""}`);
|
||||
}
|
||||
|
||||
document.querySelectorAll(".st-rail-item").forEach((btn) => {
|
||||
btn.addEventListener("click", () => switchSection(btn.dataset.section));
|
||||
});
|
||||
@@ -818,6 +1256,212 @@ document.getElementById("profile-save").addEventListener("click", () => {
|
||||
saveProfile(false).catch((err) => showToast(err.message, true));
|
||||
});
|
||||
|
||||
profileForm.baseUrl.addEventListener("input", () => {
|
||||
if (editingProfileId) return;
|
||||
void restoreCreateSniffCache();
|
||||
});
|
||||
|
||||
profileForm.apiKey.addEventListener("input", () => {
|
||||
if (editingProfileId) return;
|
||||
void restoreCreateSniffCache();
|
||||
});
|
||||
|
||||
profileForm.model.addEventListener("input", () => {
|
||||
renderCreateModelChoices();
|
||||
});
|
||||
|
||||
profileModelFilterEl?.addEventListener("input", () => {
|
||||
renderCreateModelChoices();
|
||||
});
|
||||
|
||||
profileModelFilterEl?.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") e.preventDefault();
|
||||
});
|
||||
|
||||
async function sniffIntoCatalog({
|
||||
baseUrl,
|
||||
apiKey,
|
||||
profileId,
|
||||
cacheKey,
|
||||
seq,
|
||||
seqNow,
|
||||
baseStill,
|
||||
onFound,
|
||||
statusEl,
|
||||
button,
|
||||
}) {
|
||||
button.disabled = true;
|
||||
const prevLabel = button.textContent;
|
||||
button.textContent = "嗅探中…";
|
||||
setSniffStatus(statusEl, "正在请求 /models…");
|
||||
try {
|
||||
const body = { baseUrl };
|
||||
if (apiKey) body.apiKey = apiKey;
|
||||
else if (profileId) body.profileId = profileId;
|
||||
const data = await api("/api/profiles/discover-models", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const found = uniqueModelIds(Array.isArray(data.models) ? data.models : []);
|
||||
writeSniffModels(sniffBootId, cacheKey, found);
|
||||
if (seq !== seqNow()) return;
|
||||
if (!baseStill()) return;
|
||||
onFound(found);
|
||||
const host = data.url ? data.url.replace(/^https?:\/\//, "") : "/models";
|
||||
setSniffStatus(
|
||||
statusEl,
|
||||
found.length
|
||||
? `找到 ${found.length} 个模型(${host})。在筛选框输入关键词,例如 gemini。`
|
||||
: `没有返回模型(${host})`,
|
||||
!found.length,
|
||||
);
|
||||
} catch (err) {
|
||||
setSniffStatus(statusEl, err.message || "嗅探失败", true);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = prevLabel || "嗅探";
|
||||
}
|
||||
}
|
||||
|
||||
profileSniffBtn?.addEventListener("click", async () => {
|
||||
const baseUrl = profileForm.baseUrl.value.trim();
|
||||
const apiKey = profileForm.apiKey.value.trim();
|
||||
if (!baseUrl) {
|
||||
setSniffStatus(profileSniffStatusEl, "先填写 Base URL", true);
|
||||
return;
|
||||
}
|
||||
if (!apiKey && !editingProfileId) {
|
||||
setSniffStatus(profileSniffStatusEl, "先填写 API Key", true);
|
||||
return;
|
||||
}
|
||||
const seq = ++sniffRestoreSeq;
|
||||
const cacheKey = await sniffCacheKeyFor(createSniffContext());
|
||||
const baseKey = normalizeBaseUrlKey(baseUrl);
|
||||
await sniffIntoCatalog({
|
||||
baseUrl,
|
||||
apiKey,
|
||||
profileId: editingProfileId,
|
||||
cacheKey,
|
||||
seq,
|
||||
seqNow: () => sniffRestoreSeq,
|
||||
baseStill: () => normalizeBaseUrlKey(profileForm.baseUrl.value) === baseKey,
|
||||
statusEl: profileSniffStatusEl,
|
||||
button: profileSniffBtn,
|
||||
onFound(found) {
|
||||
dialogSniffBase = baseKey;
|
||||
dialogKnownModels = found;
|
||||
renderCreateModelChoices();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
profileModelListEl?.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("[data-pick-model]");
|
||||
if (!btn) return;
|
||||
profileForm.model.value = btn.getAttribute("data-pick-model") || "";
|
||||
renderCreateModelChoices();
|
||||
});
|
||||
|
||||
function openAddModelDialog(groupId) {
|
||||
const source = state.profiles.find((profile) => (profile.groupId || "") === groupId);
|
||||
if (!source) {
|
||||
showToast("这一组还没有可复制的地址和 Key", true);
|
||||
return;
|
||||
}
|
||||
const group = state.groups.find((item) => item.id === groupId);
|
||||
addModelSource = source;
|
||||
if (addModelGroupEl) {
|
||||
addModelGroupEl.textContent = `${group?.name || "这一组"} · ${source.baseUrl}`;
|
||||
}
|
||||
if (addModelFilterEl) addModelFilterEl.value = "";
|
||||
addKnownModels = [];
|
||||
addSniffBase = "";
|
||||
setSniffStatus(addModelStatusEl, "");
|
||||
renderAddModelChoices();
|
||||
addModelDialog?.showModal();
|
||||
void restoreAddSniffCache();
|
||||
}
|
||||
|
||||
async function adoptModel(model) {
|
||||
const source = addModelSource;
|
||||
if (!source || !model) return;
|
||||
const existing = state.profiles.find(
|
||||
(profile) =>
|
||||
(profile.groupId || "") === (source.groupId || "") &&
|
||||
profile.model === model,
|
||||
);
|
||||
if (existing) {
|
||||
addModelDialog?.close();
|
||||
await activateProfile(existing.id);
|
||||
return;
|
||||
}
|
||||
const result = await api("/api/profiles", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: model,
|
||||
baseUrl: source.baseUrl,
|
||||
apiKey: source.apiKey,
|
||||
model,
|
||||
reasoningEffort: source.reasoningEffort || "",
|
||||
activate: true,
|
||||
}),
|
||||
});
|
||||
addModelDialog?.close();
|
||||
const savedId = result.profile?.id || result.activeProfileId;
|
||||
if (savedId) recordApiProfileUsage(savedId);
|
||||
await loadAll();
|
||||
const n = result.reloadedSessions ?? 0;
|
||||
showToast(`已添加并选用 ${model}${n > 0 ? `,已更新 ${n} 个活跃会话` : ""}`);
|
||||
}
|
||||
|
||||
document.getElementById("add-model-cancel")?.addEventListener("click", () => {
|
||||
addModelDialog?.close();
|
||||
});
|
||||
|
||||
addModelFilterEl?.addEventListener("input", () => {
|
||||
renderAddModelChoices();
|
||||
});
|
||||
|
||||
addModelFilterEl?.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") e.preventDefault();
|
||||
});
|
||||
|
||||
document.getElementById("add-model-form")?.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
addModelSniffBtn?.addEventListener("click", async () => {
|
||||
const source = addModelSource;
|
||||
if (!source?.baseUrl) return;
|
||||
const seq = ++addSniffSeq;
|
||||
const cacheKey = await sniffCacheKeyFor(addSniffContext());
|
||||
const baseKey = normalizeBaseUrlKey(source.baseUrl);
|
||||
await sniffIntoCatalog({
|
||||
baseUrl: source.baseUrl,
|
||||
apiKey: source.apiKey,
|
||||
profileId: source.id,
|
||||
cacheKey,
|
||||
seq,
|
||||
seqNow: () => addSniffSeq,
|
||||
baseStill: () => normalizeBaseUrlKey(addModelSource?.baseUrl || "") === baseKey,
|
||||
statusEl: addModelStatusEl,
|
||||
button: addModelSniffBtn,
|
||||
onFound(found) {
|
||||
addSniffBase = baseKey;
|
||||
addKnownModels = found;
|
||||
renderAddModelChoices();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
addModelListEl?.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("[data-pick-model]");
|
||||
if (!btn) return;
|
||||
adoptModel(btn.getAttribute("data-pick-model") || "").catch((err) =>
|
||||
showToast(err.message, true),
|
||||
);
|
||||
});
|
||||
|
||||
profileForm.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
@@ -827,6 +1471,32 @@ profileForm.addEventListener("submit", async (e) => {
|
||||
}
|
||||
});
|
||||
|
||||
profilesListEl.addEventListener("change", async (e) => {
|
||||
const input = e.target.closest("[data-action='rename-group']");
|
||||
if (!input) return;
|
||||
const id = input.dataset.id;
|
||||
const name = input.value.trim();
|
||||
if (!id || !name) return;
|
||||
const current = state.groups.find((group) => group.id === id);
|
||||
if (current && current.name === name) return;
|
||||
try {
|
||||
const data = await api(`/api/profile-groups/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
state.groups = data.groups || state.groups;
|
||||
showToast(`组名已改为 ${name}`);
|
||||
} catch (err) {
|
||||
showToast(err.message, true);
|
||||
await loadAll();
|
||||
}
|
||||
});
|
||||
|
||||
profileSearchEl?.addEventListener("input", () => {
|
||||
profileSearchQuery = profileSearchEl.value || "";
|
||||
renderProfiles();
|
||||
});
|
||||
|
||||
profilesListEl.addEventListener("click", async (e) => {
|
||||
const btn = e.target.closest("[data-action]");
|
||||
if (!btn) return;
|
||||
@@ -834,6 +1504,16 @@ profilesListEl.addEventListener("click", async (e) => {
|
||||
const action = btn.dataset.action;
|
||||
|
||||
try {
|
||||
if (action === "toggle-group-all") {
|
||||
if (showAllProfilesByGroup.has(id)) showAllProfilesByGroup.delete(id);
|
||||
else showAllProfilesByGroup.add(id);
|
||||
renderProfiles();
|
||||
return;
|
||||
}
|
||||
if (action === "add-model") {
|
||||
openAddModelDialog(id);
|
||||
return;
|
||||
}
|
||||
if (action === "activate-profile") {
|
||||
await activateProfile(id);
|
||||
} else if (action === "edit-profile") {
|
||||
@@ -859,6 +1539,10 @@ profilesListEl.addEventListener("click", async (e) => {
|
||||
refreshed.className = `test-result ${chatOk ? "ok" : "fail"}`;
|
||||
}
|
||||
showToast(note, !chatOk);
|
||||
} else if (action === "forget-usage") {
|
||||
if (!forgetApiProfileUsage(id)) return;
|
||||
renderProfiles();
|
||||
showToast("已移出曾用。配置还在,可在「显示全部」里再选用。");
|
||||
} else if (action === "delete-profile") {
|
||||
if (!confirm("确定删除此 API 配置?")) return;
|
||||
await api(`/api/profiles/${id}`, { method: "DELETE" });
|
||||
@@ -894,7 +1578,8 @@ presetsListEl.addEventListener("click", async (e) => {
|
||||
|
||||
try {
|
||||
if (action === "activate-preset") {
|
||||
await activatePreset(id);
|
||||
if (state.settings.activePresetId === id) await deactivatePreset();
|
||||
else await activatePreset(id);
|
||||
} else if (action === "probe-preset") {
|
||||
openProbeDialog(id);
|
||||
} else if (action === "toggle-preset-entries") {
|
||||
|
||||
Reference in New Issue
Block a user