diff --git a/llm/llm-preset-utils.js b/llm/llm-preset-utils.js index 22ab264..af6de7e 100644 --- a/llm/llm-preset-utils.js +++ b/llm/llm-preset-utils.js @@ -2,6 +2,206 @@ function normalizeLlmConfigValue(value) { return String(value || "").trim(); } + const OPENAI_COMPATIBLE_PROVIDER_LABELS = { + openai: "OpenAI", + openrouter: "OpenRouter", + deepseek: "DeepSeek", + xai: "xAI", + mistral: "Mistral", + moonshot: "Moonshot", + zai: "Z.AI", + groq: "Groq", + siliconflow: "SiliconFlow", + aimlapi: "AI/ML API", + fireworks: "Fireworks", + nanogpt: "NanoGPT", + chutes: "Chutes", + electronhub: "ElectronHub", + "volcengine-ark": "火山方舟 Ark", + "custom-openai-compatible": "自定义 OpenAI 兼容渠道", + }; + + function tryParseLlmUrl(value) { + const normalized = normalizeLlmConfigValue(value); + if (!normalized) return null; + + try { + return new URL(normalized); + } catch { + return null; + } + } + + function normalizeParsedUrlString(parsedUrl) { + if (!parsedUrl) return ""; + const cloned = new URL(parsedUrl.toString()); + cloned.search = ""; + cloned.hash = ""; + return String(cloned.toString()).replace(/\/+$/, ""); + } + + function stripOpenAiCompatibleEndpointSuffix(value) { + return String(value || "") + .replace(/\/+((chat|text)\/completions|completions|embeddings|models)$/i, "") + .replace(/\/+$/, ""); + } + + function stripAnthropicEndpointSuffix(value) { + return String(value || "") + .replace(/\/+messages$/i, "") + .replace(/\/+$/, ""); + } + + function stripGoogleAiStudioEndpointSuffix(value) { + return String(value || "") + .replace( + /\/+v\d+(?:beta)?\/models(?:\/[^/:?#]+:(?:streamGenerateContent|generateContent))?$/i, + "", + ) + .replace(/\/+$/, ""); + } + + function resolveKnownOpenAiCompatibleProviderId(parsedUrl) { + const hostname = String(parsedUrl?.hostname || "").trim().toLowerCase(); + const pathname = String(parsedUrl?.pathname || "").trim().toLowerCase(); + + if (!hostname) { + return "custom-openai-compatible"; + } + + if (hostname.includes("openai.com")) return "openai"; + if (hostname.includes("openrouter.ai")) return "openrouter"; + if (hostname.includes("deepseek.com")) return "deepseek"; + if (hostname === "x.ai" || hostname === "api.x.ai" || hostname.endsWith(".x.ai")) { + return "xai"; + } + if (hostname.includes("mistral.ai")) return "mistral"; + if (hostname.includes("moonshot.ai")) return "moonshot"; + if (hostname === "api.z.ai" || hostname.endsWith(".z.ai")) return "zai"; + if (hostname.includes("groq.com")) return "groq"; + if (hostname.includes("siliconflow.com")) return "siliconflow"; + if (hostname.includes("aimlapi.com")) return "aimlapi"; + if (hostname.includes("fireworks.ai")) return "fireworks"; + if (hostname.includes("nano-gpt.com")) return "nanogpt"; + if (hostname.includes("chutes.ai")) return "chutes"; + if (hostname.includes("electronhub.ai")) return "electronhub"; + if ( + hostname.includes("volces.com") || + hostname.startsWith("ark.") || + pathname.includes("/api/coding/v3") + ) { + return "volcengine-ark"; + } + + return "custom-openai-compatible"; + } + + function createResolvedDedicatedProviderConfig(overrides = {}) { + return { + inputUrl: "", + apiUrl: "", + providerId: "", + providerLabel: "", + transportId: "", + transportLabel: "", + hostSource: "", + hostSourceConst: "", + routeMode: "", + supportsModelFetch: false, + statusStrategies: [], + isKnownProvider: false, + isOpenAiCompatible: false, + ...overrides, + }; + } + + export function resolveDedicatedLlmProviderConfig(value = "") { + const normalizedInput = normalizeLlmConfigValue(value); + if (!normalizedInput) { + return createResolvedDedicatedProviderConfig(); + } + + const parsedUrl = tryParseLlmUrl(normalizedInput); + if (!parsedUrl) { + return createResolvedDedicatedProviderConfig({ + inputUrl: normalizedInput, + apiUrl: normalizedInput.replace(/\/+$/, ""), + providerId: "custom-openai-compatible", + providerLabel: OPENAI_COMPATIBLE_PROVIDER_LABELS["custom-openai-compatible"], + transportId: "dedicated-openai-compatible", + transportLabel: "专用 OpenAI 兼容接口", + hostSource: "custom", + hostSourceConst: "CUSTOM", + routeMode: "custom", + supportsModelFetch: true, + statusStrategies: ["custom", "openai-reverse-proxy"], + isKnownProvider: false, + isOpenAiCompatible: true, + }); + } + + const normalizedUrl = normalizeParsedUrlString(parsedUrl); + const hostname = String(parsedUrl.hostname || "").trim().toLowerCase(); + + if (hostname.includes("anthropic.com")) { + const apiUrl = stripAnthropicEndpointSuffix(normalizedUrl) || normalizedUrl; + return createResolvedDedicatedProviderConfig({ + inputUrl: normalizedInput, + apiUrl, + providerId: "anthropic-claude", + providerLabel: "Anthropic Claude", + transportId: "dedicated-anthropic-claude", + transportLabel: "Anthropic Claude 接口", + hostSource: "claude", + hostSourceConst: "CLAUDE", + routeMode: "reverse-proxy", + supportsModelFetch: false, + statusStrategies: [], + isKnownProvider: true, + isOpenAiCompatible: false, + }); + } + + if (hostname.includes("generativelanguage.googleapis.com")) { + const apiUrl = stripGoogleAiStudioEndpointSuffix(normalizedUrl) || normalizedUrl; + return createResolvedDedicatedProviderConfig({ + inputUrl: normalizedInput, + apiUrl, + providerId: "google-ai-studio", + providerLabel: "Google AI Studio / Gemini", + transportId: "dedicated-google-ai-studio", + transportLabel: "Google AI Studio / Gemini 接口", + hostSource: "makersuite", + hostSourceConst: "MAKERSUITE", + routeMode: "reverse-proxy", + supportsModelFetch: true, + statusStrategies: ["makersuite-reverse-proxy"], + isKnownProvider: true, + isOpenAiCompatible: false, + }); + } + + const providerId = resolveKnownOpenAiCompatibleProviderId(parsedUrl); + const apiUrl = stripOpenAiCompatibleEndpointSuffix(normalizedUrl) || normalizedUrl; + return createResolvedDedicatedProviderConfig({ + inputUrl: normalizedInput, + apiUrl, + providerId, + providerLabel: + OPENAI_COMPATIBLE_PROVIDER_LABELS[providerId] || + OPENAI_COMPATIBLE_PROVIDER_LABELS["custom-openai-compatible"], + transportId: "dedicated-openai-compatible", + transportLabel: "专用 OpenAI 兼容接口", + hostSource: "custom", + hostSourceConst: "CUSTOM", + routeMode: "custom", + supportsModelFetch: true, + statusStrategies: ["custom", "openai-reverse-proxy"], + isKnownProvider: providerId !== "custom-openai-compatible", + isOpenAiCompatible: true, + }); + } + export function createLlmConfigSnapshot(source = {}) { return { llmApiUrl: normalizeLlmConfigValue(source?.llmApiUrl), diff --git a/llm/llm.js b/llm/llm.js index 3ea73ba..3ff5f75 100644 --- a/llm/llm.js +++ b/llm/llm.js @@ -6,7 +6,10 @@ import { extension_settings } from "../../../../extensions.js"; import { chat_completion_sources, sendOpenAIRequest } from "../../../../openai.js"; import { debugLog, debugWarn } from "../runtime/debug-logging.js"; import { resolveTaskGenerationOptions } from "../runtime/generation-options.js"; -import { resolveLlmConfigSelection } from "./llm-preset-utils.js"; +import { + resolveDedicatedLlmProviderConfig, + resolveLlmConfigSelection, +} from "./llm-preset-utils.js"; import { getActiveTaskProfile } from "../prompting/prompt-profiles.js"; import { resolveConfiguredTimeoutMs } from "../runtime/request-timeout.js"; import { applyTaskRegex } from "../prompting/task-regex.js"; @@ -206,11 +209,27 @@ function getMemoryLLMConfig(taskType = "") { ? activeProfile.generation.llm_preset : ""; const selection = resolveLlmConfigSelection(settings, selectedPresetName); + const resolvedProvider = resolveDedicatedLlmProviderConfig( + selection.config?.llmApiUrl, + ); return { - apiUrl: normalizeOpenAICompatibleBaseUrl(selection.config?.llmApiUrl), + inputApiUrl: resolvedProvider.inputUrl || "", + apiUrl: resolvedProvider.apiUrl || "", apiKey: String(selection.config?.llmApiKey || "").trim(), model: String(selection.config?.llmModel || "").trim(), timeoutMs: getConfiguredTimeoutMs(settings), + llmProvider: resolvedProvider.providerId || "", + llmProviderLabel: resolvedProvider.providerLabel || "", + llmTransport: resolvedProvider.transportId || "", + llmTransportLabel: resolvedProvider.transportLabel || "", + llmRouteMode: resolvedProvider.routeMode || "", + llmHostSource: resolvedProvider.hostSource || "", + llmHostSourceConst: resolvedProvider.hostSourceConst || "", + llmSupportsModelFetch: resolvedProvider.supportsModelFetch === true, + llmStatusStrategies: Array.isArray(resolvedProvider.statusStrategies) + ? [...resolvedProvider.statusStrategies] + : [], + llmChannel: resolvedProvider, llmConfigSource: selection.source || "global", llmConfigSourceLabel: formatLlmConfigSourceLabel(selection.source), llmPresetName: selection.presetName || "", @@ -418,6 +437,7 @@ function buildEffectiveLlmRoute( hasDedicatedConfig, privateRequestSource, taskType = "", + config = null, ) { const dedicated = Boolean(hasDedicatedConfig); return { @@ -425,8 +445,18 @@ function buildEffectiveLlmRoute( requestSource: String(privateRequestSource || "").trim(), llm: dedicated ? "dedicated-memory-llm" : "sillytavern-current-model", transport: dedicated - ? "dedicated-openai-compatible" + ? String(config?.llmTransport || "dedicated-openai-compatible") : "sillytavern-current-model", + transportLabel: dedicated + ? String( + config?.llmTransportLabel || config?.llmProviderLabel || "专用记忆模型", + ) + : "酒馆当前模型", + provider: dedicated ? String(config?.llmProvider || "") : "", + providerLabel: dedicated ? String(config?.llmProviderLabel || "") : "", + routeMode: dedicated ? String(config?.llmRouteMode || "") : "", + inputApiUrl: dedicated ? String(config?.inputApiUrl || "") : "", + apiUrl: dedicated ? String(config?.apiUrl || "") : "", }; } @@ -665,10 +695,8 @@ function buildResponseErrorMessage(response, responseText = "") { } function normalizeOpenAICompatibleBaseUrl(value) { - return String(value || "") - .trim() - .replace(/\/+(chat\/completions|embeddings)$/i, "") - .replace(/\/+$/, ""); + const resolved = resolveDedicatedLlmProviderConfig(value); + return resolved.apiUrl || String(value || "").trim().replace(/\/+$/, ""); } function hasDedicatedLLMConfig(config = getMemoryLLMConfig()) { @@ -739,28 +767,87 @@ function buildDedicatedAuthHeaderString(apiKey = "") { return normalized ? `Authorization: Bearer ${normalized}` : ""; } -function buildDedicatedStatusRequestVariants(config = getMemoryLLMConfig()) { - const customVariant = { +function resolveChatCompletionSourceValue(sourceConst = "", fallback = "") { + const normalizedConst = String(sourceConst || "").trim(); + if ( + normalizedConst && + chat_completion_sources && + typeof chat_completion_sources === "object" && + chat_completion_sources[normalizedConst] + ) { + return String(chat_completion_sources[normalizedConst]).trim(); + } + return String(fallback || "").trim(); +} + +function buildDedicatedCustomStatusVariant(config = getMemoryLLMConfig()) { + return { mode: "custom", body: { - chat_completion_source: chat_completion_sources.CUSTOM, + chat_completion_source: resolveChatCompletionSourceValue("CUSTOM", "custom"), custom_url: config.apiUrl, custom_include_headers: buildDedicatedAuthHeaderString(config.apiKey), reverse_proxy: config.apiUrl, proxy_password: "", }, }; +} - const legacyOpenAiVariant = { - mode: "openai-reverse-proxy", +function buildDedicatedReverseProxyStatusVariant( + mode, + sourceConst, + fallbackSource, + config = getMemoryLLMConfig(), +) { + return { + mode, body: { - chat_completion_source: chat_completion_sources.OPENAI, + chat_completion_source: resolveChatCompletionSourceValue( + sourceConst, + fallbackSource, + ), reverse_proxy: config.apiUrl, proxy_password: config.apiKey || "", }, }; +} - return [customVariant, legacyOpenAiVariant]; +function buildDedicatedStatusRequestVariants(config = getMemoryLLMConfig()) { + const strategies = Array.isArray(config.llmStatusStrategies) + ? config.llmStatusStrategies + : ["custom", "openai-reverse-proxy"]; + const variants = []; + const seenModes = new Set(); + + for (const strategy of strategies) { + let variant = null; + if (strategy === "custom") { + variant = buildDedicatedCustomStatusVariant(config); + } else if (strategy === "openai-reverse-proxy") { + variant = buildDedicatedReverseProxyStatusVariant( + "openai-reverse-proxy", + "OPENAI", + "openai", + config, + ); + } else if (strategy === "makersuite-reverse-proxy") { + variant = buildDedicatedReverseProxyStatusVariant( + "makersuite-reverse-proxy", + "MAKERSUITE", + "makersuite", + config, + ); + } + + if (!variant?.mode || seenModes.has(variant.mode)) { + continue; + } + + seenModes.add(variant.mode); + variants.push(variant); + } + + return variants; } async function requestDedicatedStatusModels( @@ -1444,6 +1531,64 @@ async function executeDedicatedRequest( } } +function shouldForceDedicatedNonStream(config = getMemoryLLMConfig()) { + return ( + String(config.llmRouteMode || "").trim() === "reverse-proxy" && + ["claude", "makersuite"].includes( + String(config.llmHostSource || "").trim().toLowerCase(), + ) + ); +} + +function buildDedicatedRequestBody( + config, + transportMessages, + filteredGeneration, + resolvedCompletionTokens, + { jsonMode = false } = {}, +) { + const routeMode = String(config?.llmRouteMode || "custom").trim() || "custom"; + const body = { + model: config.model, + messages: transportMessages, + temperature: filteredGeneration.temperature ?? 1, + max_tokens: resolvedCompletionTokens, + stream: filteredGeneration.stream ?? false, + frequency_penalty: filteredGeneration.frequency_penalty ?? 0, + presence_penalty: filteredGeneration.presence_penalty ?? 0, + top_p: filteredGeneration.top_p ?? 1, + }; + + if (routeMode === "reverse-proxy") { + body.chat_completion_source = resolveChatCompletionSourceValue( + config.llmHostSourceConst, + config.llmHostSource || "custom", + ); + body.reverse_proxy = config.apiUrl; + body.proxy_password = config.apiKey || ""; + if (jsonMode) { + body.json_schema = createGenericJsonSchema(); + } + } else { + body.chat_completion_source = resolveChatCompletionSourceValue("CUSTOM", "custom"); + body.custom_url = config.apiUrl; + body.custom_include_headers = config.apiKey + ? buildYamlObject({ + Authorization: `Bearer ${config.apiKey}`, + }) + : ""; + if (jsonMode && _jsonModeSupported) { + body.custom_include_body = buildYamlObject({ + response_format: { + type: "json_object", + }, + }); + } + } + + return body; +} + async function callDedicatedOpenAICompatible( messages, { @@ -1487,8 +1632,15 @@ async function callDedicatedOpenAICompatible( }; const taskKey = taskType || privateRequestSource; const initialFilteredGeneration = generationResolved.filtered || {}; + const filteredGeneration = { + ...initialFilteredGeneration, + }; + const forceNonStream = hasDedicatedConfig && shouldForceDedicatedNonStream(config); + if (forceNonStream && filteredGeneration.stream === true) { + filteredGeneration.stream = false; + } const streamRequested = - hasDedicatedConfig && initialFilteredGeneration.stream === true; + hasDedicatedConfig && filteredGeneration.stream === true; const streamState = createStreamDebugState({ requested: streamRequested, }); @@ -1499,10 +1651,17 @@ async function callDedicatedOpenAICompatible( jsonMode, dedicatedConfig: hasDedicatedConfig, route: hasDedicatedConfig - ? "dedicated-openai-compatible" + ? config.llmTransport || "dedicated-openai-compatible" : "sillytavern-current-model", + routeLabel: hasDedicatedConfig ? config.llmTransportLabel || "" : "酒馆当前模型", model: hasDedicatedConfig ? config.model : "sillytavern-current-model", + inputApiUrl: hasDedicatedConfig ? config.inputApiUrl || "" : "", apiUrl: hasDedicatedConfig ? config.apiUrl : "", + llmProvider: config.llmProvider || "", + llmProviderLabel: config.llmProviderLabel || "", + llmTransport: config.llmTransport || "", + llmTransportLabel: config.llmTransportLabel || "", + llmRouteMode: config.llmRouteMode || "", llmConfigSource: config.llmConfigSource || "global", llmConfigSourceLabel: config.llmConfigSourceLabel || "", llmPresetName: config.llmPresetName || "", @@ -1511,13 +1670,15 @@ async function callDedicatedOpenAICompatible( messages, transportMessages, generation: generationResolved.generation || {}, - filteredGeneration: generationResolved.filtered || {}, + filteredGeneration, removedGeneration: generationResolved.removed || [], capabilityMode: generationResolved.capabilityMode || "", + streamForceDisabled: forceNonStream, effectiveRoute: buildEffectiveLlmRoute( hasDedicatedConfig, privateRequestSource, taskType, + config, ), maxCompletionTokens, ...buildStreamDebugSnapshot(streamState), @@ -1546,30 +1707,19 @@ async function callDedicatedOpenAICompatible( : jsonMode ? DEFAULT_JSON_COMPLETION_TOKENS : DEFAULT_TEXT_COMPLETION_TOKENS; - const filteredGeneration = generationResolved.filtered || {}; const resolvedCompletionTokens = Number.isFinite( filteredGeneration.max_completion_tokens, ) ? filteredGeneration.max_completion_tokens : completionTokens; - const body = { - chat_completion_source: chat_completion_sources.CUSTOM, - custom_url: config.apiUrl, - custom_include_headers: config.apiKey - ? buildYamlObject({ - Authorization: `Bearer ${config.apiKey}`, - }) - : "", - model: config.model, - messages: transportMessages, - temperature: filteredGeneration.temperature ?? 1, - max_tokens: resolvedCompletionTokens, - stream: filteredGeneration.stream ?? false, - frequency_penalty: filteredGeneration.frequency_penalty ?? 0, - presence_penalty: filteredGeneration.presence_penalty ?? 0, - top_p: filteredGeneration.top_p ?? 1, - }; + const body = buildDedicatedRequestBody( + config, + transportMessages, + filteredGeneration, + resolvedCompletionTokens, + { jsonMode }, + ); const optionalGenerationFields = [ "top_p", @@ -1596,12 +1746,8 @@ async function callDedicatedOpenAICompatible( body[field] = filteredGeneration[field]; } - if (jsonMode && _jsonModeSupported) { - body.custom_include_body = buildYamlObject({ - response_format: { - type: "json_object", - }, - }); + if (Object.prototype.hasOwnProperty.call(filteredGeneration, "request_thoughts")) { + body.include_reasoning = Boolean(filteredGeneration.request_thoughts); } recordTaskLlmRequest(taskKey, { @@ -1609,9 +1755,16 @@ async function callDedicatedOpenAICompatible( taskType: String(taskType || "").trim(), jsonMode, dedicatedConfig: true, - route: "dedicated-openai-compatible", + route: config.llmTransport || "dedicated-openai-compatible", + routeLabel: config.llmTransportLabel || "", model: config.model, + inputApiUrl: config.inputApiUrl || "", apiUrl: config.apiUrl, + llmProvider: config.llmProvider || "", + llmProviderLabel: config.llmProviderLabel || "", + llmTransport: config.llmTransport || "", + llmTransportLabel: config.llmTransportLabel || "", + llmRouteMode: config.llmRouteMode || "", llmConfigSource: config.llmConfigSource || "global", llmConfigSourceLabel: config.llmConfigSourceLabel || "", llmPresetName: config.llmPresetName || "", @@ -1624,10 +1777,12 @@ async function callDedicatedOpenAICompatible( removedGeneration: generationResolved.removed || [], capabilityMode: generationResolved.capabilityMode || "", resolvedCompletionTokens, + streamForceDisabled: forceNonStream, effectiveRoute: buildEffectiveLlmRoute( true, privateRequestSource, taskType, + config, ), requestBody: body, ...buildStreamDebugSnapshot(streamState), @@ -1983,7 +2138,7 @@ export async function callLLM(systemPrompt, userPrompt, options = {}) { export async function testLLMConnection() { const config = getMemoryLLMConfig(); const mode = hasDedicatedLLMConfig(config) - ? `dedicated:${config.model}` + ? `dedicated:${config.llmProviderLabel || config.llmTransportLabel || config.model}:${config.model}` : "sillytavern-current-model"; try { @@ -2013,7 +2168,22 @@ export async function fetchMemoryLLMModels() { }; } + if (config.llmSupportsModelFetch !== true) { + return { + success: false, + models: [], + error: `${config.llmProviderLabel || "当前渠道"} 暂不支持自动拉取模型,请手动填写模型名`, + }; + } + const variants = buildDedicatedStatusRequestVariants(config); + if (!variants.length) { + return { + success: false, + models: [], + error: `${config.llmProviderLabel || "当前渠道"} 暂无可用的模型探测策略,请手动填写模型名`, + }; + } const errors = []; try { diff --git a/manifest.json b/manifest.json index b960b46..41dcfde 100644 --- a/manifest.json +++ b/manifest.json @@ -6,6 +6,6 @@ "js": "index.js", "css": "style.css", "author": "Youzini", - "version": "4.6.2", + "version": "4.6.6", "homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology" } diff --git a/style.css b/style.css index 77b999e..52b1d6e 100644 --- a/style.css +++ b/style.css @@ -3977,6 +3977,11 @@ overflow: auto; } +#bme-mobile-graph-pane { + overflow: hidden; + overscroll-behavior: contain; +} + .bme-mobile-graph-pane.active { display: flex; } diff --git a/tests/graph-renderer-utils.mjs b/tests/graph-renderer-utils.mjs new file mode 100644 index 0000000..26396a6 --- /dev/null +++ b/tests/graph-renderer-utils.mjs @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; + +import { + isUsableGraphCanvasSize, + remapPositionBetweenRects, +} from "../ui/graph-renderer-utils.js"; + +assert.equal(isUsableGraphCanvasSize(0, 0), false); +assert.equal(isUsableGraphCanvasSize(47, 120), false); +assert.equal(isUsableGraphCanvasSize(120, 47), false); +assert.equal(isUsableGraphCanvasSize(48, 48), true); +assert.equal(isUsableGraphCanvasSize(320, 180), true); + +assert.deepEqual( + remapPositionBetweenRects(60, 35, { x: 10, y: 10, w: 100, h: 50 }, { x: 20, y: 20, w: 200, h: 100 }), + { x: 120, y: 70 }, +); + +assert.deepEqual( + remapPositionBetweenRects(-50, 300, { x: 10, y: 10, w: 100, h: 50 }, { x: 20, y: 20, w: 200, h: 100 }), + { x: 20, y: 120 }, +); + +assert.deepEqual( + remapPositionBetweenRects(42, 84, null, { x: 20, y: 20, w: 200, h: 100 }), + { x: 42, y: 84 }, +); + +console.log("graph-renderer-utils tests passed"); diff --git a/tests/llm-model-fetch.mjs b/tests/llm-model-fetch.mjs index fc39656..ca9ecf7 100644 --- a/tests/llm-model-fetch.mjs +++ b/tests/llm-model-fetch.mjs @@ -110,6 +110,23 @@ async function withModelFetchSettings(run) { } } +async function withModelFetchSettingsOverrides(overrides, run) { + const previousSettings = JSON.parse( + JSON.stringify(extensionsApi.extension_settings.st_bme || {}), + ); + extensionsApi.extension_settings.st_bme = { + ...previousSettings, + ...buildModelFetchSettings(), + ...(overrides || {}), + }; + + try { + await run(); + } finally { + extensionsApi.extension_settings.st_bme = previousSettings; + } +} + async function testFetchMemoryModelsUsesCustomStatusFirst() { const originalFetch = globalThis.fetch; const seenBodies = []; @@ -238,8 +255,70 @@ async function testFetchMemoryModelsParsesNestedPayload() { } } +async function testFetchMemoryModelsUsesGoogleStatusRoute() { + const originalFetch = globalThis.fetch; + const seenBodies = []; + + globalThis.fetch = async (_url, options = {}) => { + seenBodies.push(JSON.parse(String(options.body || "{}"))); + return new Response( + JSON.stringify({ + data: [{ id: "gemini-2.5-pro" }], + }), + { + status: 200, + headers: { + "Content-Type": "application/json", + }, + }, + ); + }; + + try { + await withModelFetchSettingsOverrides( + { + llmApiUrl: + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent", + llmApiKey: "gemini-secret", + }, + async () => { + const result = await llm.fetchMemoryLLMModels(); + assert.equal(result.success, true); + assert.deepEqual(result.models, [ + { id: "gemini-2.5-pro", label: "gemini-2.5-pro" }, + ]); + assert.equal(seenBodies.length, 1); + assert.equal(seenBodies[0].chat_completion_source, "makersuite"); + assert.equal(seenBodies[0].reverse_proxy, "https://generativelanguage.googleapis.com"); + assert.equal(seenBodies[0].proxy_password, "gemini-secret"); + }, + ); + } finally { + globalThis.fetch = originalFetch; + } +} + +async function testFetchMemoryModelsReturnsHelpfulMessageForAnthropic() { + await withModelFetchSettingsOverrides( + { + llmApiUrl: "https://api.anthropic.com/v1/messages", + llmApiKey: "anthropic-secret", + llmModel: "claude-sonnet-4-5", + }, + async () => { + const result = await llm.fetchMemoryLLMModels(); + assert.equal(result.success, false); + assert.equal(result.models.length, 0); + assert.match(result.error, /Anthropic Claude/); + assert.match(result.error, /手动填写模型名/); + }, + ); +} + await testFetchMemoryModelsUsesCustomStatusFirst(); await testFetchMemoryModelsFallsBackToLegacyStatus(); await testFetchMemoryModelsParsesNestedPayload(); +await testFetchMemoryModelsUsesGoogleStatusRoute(); +await testFetchMemoryModelsReturnsHelpfulMessageForAnthropic(); console.log("llm-model-fetch tests passed"); diff --git a/tests/llm-preset-utils.mjs b/tests/llm-preset-utils.mjs index 8ad92ea..13fb3c8 100644 --- a/tests/llm-preset-utils.mjs +++ b/tests/llm-preset-utils.mjs @@ -5,6 +5,7 @@ import { isSameLlmConfigSnapshot, isUsableLlmConfigSnapshot, normalizeLlmPresetMap, + resolveDedicatedLlmProviderConfig, resolveLlmConfigSelection, resolveActiveLlmPresetName, sanitizeLlmPresetSettings, @@ -226,4 +227,31 @@ assert.deepEqual(invalidTaskPresetSelection.config, { llmModel: "model-global", }); +const arkProvider = resolveDedicatedLlmProviderConfig( + "https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions", +); +assert.equal(arkProvider.providerId, "volcengine-ark"); +assert.equal(arkProvider.transportId, "dedicated-openai-compatible"); +assert.equal(arkProvider.routeMode, "custom"); +assert.equal(arkProvider.apiUrl, "https://ark.cn-beijing.volces.com/api/coding/v3"); +assert.equal(arkProvider.supportsModelFetch, true); + +const anthropicProvider = resolveDedicatedLlmProviderConfig( + "https://api.anthropic.com/v1/messages", +); +assert.equal(anthropicProvider.providerId, "anthropic-claude"); +assert.equal(anthropicProvider.transportId, "dedicated-anthropic-claude"); +assert.equal(anthropicProvider.routeMode, "reverse-proxy"); +assert.equal(anthropicProvider.apiUrl, "https://api.anthropic.com/v1"); +assert.equal(anthropicProvider.supportsModelFetch, false); + +const geminiProvider = resolveDedicatedLlmProviderConfig( + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent", +); +assert.equal(geminiProvider.providerId, "google-ai-studio"); +assert.equal(geminiProvider.transportId, "dedicated-google-ai-studio"); +assert.equal(geminiProvider.routeMode, "reverse-proxy"); +assert.equal(geminiProvider.apiUrl, "https://generativelanguage.googleapis.com"); +assert.equal(geminiProvider.supportsModelFetch, true); + console.log("llm-preset-utils tests passed"); diff --git a/tests/llm-streaming.mjs b/tests/llm-streaming.mjs index f46add4..001e822 100644 --- a/tests/llm-streaming.mjs +++ b/tests/llm-streaming.mjs @@ -83,7 +83,7 @@ if (originalSendOpenAIRequest === undefined) { globalThis.__llmStreamingSendOpenAIRequest = originalSendOpenAIRequest; } -function buildStreamingSettings(generation = {}) { +function buildStreamingSettings(generation = {}, overrides = {}) { const taskProfiles = createDefaultTaskProfiles(); taskProfiles.extract.profiles[0].generation = { ...taskProfiles.extract.profiles[0].generation, @@ -96,6 +96,7 @@ function buildStreamingSettings(generation = {}) { timeoutMs: 1234, taskProfilesVersion: 3, taskProfiles, + ...(overrides || {}), }; } @@ -125,13 +126,13 @@ function getSnapshot(taskKey = "extract") { return globalThis.__stBmeRuntimeDebugState?.taskLlmRequests?.[taskKey] || null; } -async function withStreamingSettings(generation, run) { +async function withStreamingSettings(generation, run, overrides = {}) { const previousSettings = JSON.parse( JSON.stringify(extensionsApi.extension_settings.st_bme || {}), ); extensionsApi.extension_settings.st_bme = { ...previousSettings, - ...buildStreamingSettings(generation), + ...buildStreamingSettings(generation, overrides), }; delete globalThis.__stBmeRuntimeDebugState; @@ -415,9 +416,72 @@ async function testJsonRetryKeepsProfileCompletionTokens() { } } +async function testAnthropicRouteUsesReverseProxyAndDisablesStreaming() { + const originalFetch = globalThis.fetch; + let requestBody = null; + + globalThis.fetch = async (_url, options = {}) => { + requestBody = JSON.parse(String(options.body || "{}")); + return new Response( + JSON.stringify({ + choices: [ + { + message: { + content: '{"ok":true}', + }, + finish_reason: "stop", + }, + ], + }), + { + status: 200, + headers: { + "Content-Type": "application/json", + }, + }, + ); + }; + + try { + await withStreamingSettings( + { stream: true }, + async () => { + const result = await llm.callLLMForJSON({ + systemPrompt: "system", + userPrompt: "user", + maxRetries: 0, + taskType: "extract", + requestSource: "test:anthropic-route", + }); + + assert.deepEqual(result, { ok: true }); + assert.equal(requestBody?.chat_completion_source, "claude"); + assert.equal(requestBody?.reverse_proxy, "https://api.anthropic.com/v1"); + assert.equal(requestBody?.proxy_password, "sk-stream-secret"); + assert.equal(requestBody?.stream, false); + assert.ok(requestBody?.json_schema); + + const snapshot = getSnapshot("extract"); + assert.ok(snapshot); + assert.equal(snapshot.route, "dedicated-anthropic-claude"); + assert.equal(snapshot.llmProviderLabel, "Anthropic Claude"); + assert.equal(snapshot.streamRequested, false); + assert.equal(snapshot.streamForceDisabled, true); + }, + { + llmApiUrl: "https://api.anthropic.com/v1/messages", + llmModel: "claude-sonnet-4-5", + }, + ); + } finally { + globalThis.fetch = originalFetch; + } +} + await testDedicatedStreamingSuccess(); await testDedicatedStreamingFallsBackToNonStream(); await testDedicatedStreamingAbortDoesNotLeaveActiveState(); await testJsonRetryKeepsProfileCompletionTokens(); +await testAnthropicRouteUsesReverseProxyAndDisablesStreaming(); console.log("llm-streaming tests passed"); diff --git a/tests/panel-graph-refresh.mjs b/tests/panel-graph-refresh.mjs new file mode 100644 index 0000000..368afb7 --- /dev/null +++ b/tests/panel-graph-refresh.mjs @@ -0,0 +1,186 @@ +import assert from "node:assert/strict"; + +import { + buildVisibleGraphRefreshToken, + resolveVisibleGraphWorkspaceMode, +} from "../ui/panel-graph-refresh-utils.js"; + +assert.equal( + resolveVisibleGraphWorkspaceMode({ + overlayActive: false, + isMobile: false, + currentTabId: "dashboard", + currentGraphView: "graph", + }), + "hidden", +); + +assert.equal( + resolveVisibleGraphWorkspaceMode({ + overlayActive: true, + isMobile: false, + currentTabId: "config", + currentGraphView: "graph", + }), + "hidden", +); + +assert.equal( + resolveVisibleGraphWorkspaceMode({ + overlayActive: true, + isMobile: false, + currentTabId: "dashboard", + currentGraphView: "graph", + }), + "desktop:graph", +); + +assert.equal( + resolveVisibleGraphWorkspaceMode({ + overlayActive: true, + isMobile: false, + currentTabId: "memory", + currentGraphView: "cognition", + }), + "desktop:cognition", +); + +assert.equal( + resolveVisibleGraphWorkspaceMode({ + overlayActive: true, + isMobile: false, + currentTabId: "actions", + currentGraphView: "summary", + }), + "desktop:summary", +); + +assert.equal( + resolveVisibleGraphWorkspaceMode({ + overlayActive: true, + isMobile: true, + currentTabId: "dashboard", + currentMobileGraphView: "graph", + }), + "hidden", +); + +assert.equal( + resolveVisibleGraphWorkspaceMode({ + overlayActive: true, + isMobile: true, + currentTabId: "graph", + currentMobileGraphView: "graph", + }), + "mobile:graph", +); + +assert.equal( + resolveVisibleGraphWorkspaceMode({ + overlayActive: true, + isMobile: true, + currentTabId: "graph", + currentMobileGraphView: "cognition", + }), + "mobile:cognition", +); + +assert.equal( + resolveVisibleGraphWorkspaceMode({ + overlayActive: true, + isMobile: true, + currentTabId: "graph", + currentMobileGraphView: "summary", + }), + "mobile:summary", +); + +assert.equal( + buildVisibleGraphRefreshToken({ + visibleMode: "hidden", + chatId: "chat-main", + loadState: "loaded", + revision: 12, + nodeCount: 40, + edgeCount: 55, + lastProcessedSeq: 9, + }), + "hidden", +); + +const baseToken = buildVisibleGraphRefreshToken({ + visibleMode: "desktop:graph", + chatId: "chat-main", + loadState: "loaded", + revision: 12, + nodeCount: 40, + edgeCount: 55, + lastProcessedSeq: 9, +}); + +assert.equal( + baseToken, + buildVisibleGraphRefreshToken({ + visibleMode: "desktop:graph", + chatId: "chat-main", + loadState: "loaded", + revision: 12, + nodeCount: 40, + edgeCount: 55, + lastProcessedSeq: 9, + }), +); + +assert.notEqual( + baseToken, + buildVisibleGraphRefreshToken({ + visibleMode: "desktop:graph", + chatId: "chat-main", + loadState: "loaded", + revision: 13, + nodeCount: 40, + edgeCount: 55, + lastProcessedSeq: 9, + }), +); + +assert.notEqual( + baseToken, + buildVisibleGraphRefreshToken({ + visibleMode: "desktop:cognition", + chatId: "chat-main", + loadState: "loaded", + revision: 12, + nodeCount: 40, + edgeCount: 55, + lastProcessedSeq: 9, + }), +); + +assert.notEqual( + baseToken, + buildVisibleGraphRefreshToken({ + visibleMode: "desktop:graph", + chatId: "chat-side", + loadState: "loaded", + revision: 12, + nodeCount: 40, + edgeCount: 55, + lastProcessedSeq: 9, + }), +); + +assert.notEqual( + baseToken, + buildVisibleGraphRefreshToken({ + visibleMode: "desktop:graph", + chatId: "chat-main", + loadState: "loaded", + revision: 12, + nodeCount: 41, + edgeCount: 55, + lastProcessedSeq: 9, + }), +); + +console.log("panel-graph-refresh tests passed"); diff --git a/tests/trivial-user-input.mjs b/tests/trivial-user-input.mjs index 658099e..2a2eb05 100644 --- a/tests/trivial-user-input.mjs +++ b/tests/trivial-user-input.mjs @@ -284,6 +284,36 @@ async function testSkipFlagTtlExpires() { } } +async function testPromptViewerSyntheticGenerationSkipsRecall() { + const harness = await createGenerationRecallHarness(); + const fakeDialog = { + textContent: "Prompt Viewer", + querySelector(selector) { + if (selector === ".fa-rotate-right.animate-spin") { + return {}; + } + return null; + }, + }; + harness.document.querySelectorAll = (selector) => + selector === '[role="dialog"]' ? [fakeDialog] : []; + harness.__sendTextareaValue = "hello world"; + + const startResult = harness.result.onGenerationStarted("normal", {}, false); + assert.equal(startResult, null); + assert.equal( + harness.result.getCurrentGenerationTrivialSkip()?.reason, + "tavern-helper-prompt-viewer", + ); + + const beforeCombine = await harness.result.onBeforeCombinePrompts(); + assert.deepEqual(beforeCombine, { + skipped: true, + reason: "tavern-helper-prompt-viewer", + }); + assert.equal(harness.runRecallCalls.length, 0); +} + await Promise.resolve(); testIsTrivialUserInputTable(); await testSlashCommandSkipsRecallAndExtraction(); @@ -297,5 +327,6 @@ await testNonTrivialGenerationClearsResidualTrivialSkip(); await testNonTargetMessageIdDoesNotConsumeFlag(); await testNullMessageIdFallsBackToLastAssistantIndex(); await testSkipFlagTtlExpires(); +await testPromptViewerSyntheticGenerationSkipsRecall(); console.log("trivial-user-input tests passed"); diff --git a/ui/graph-renderer-utils.js b/ui/graph-renderer-utils.js new file mode 100644 index 0000000..7efd0d8 --- /dev/null +++ b/ui/graph-renderer-utils.js @@ -0,0 +1,46 @@ +function clampUnit(value) { + if (!Number.isFinite(Number(value))) return 0; + return Math.min(1, Math.max(0, Number(value))); +} + +export function isUsableGraphCanvasSize(width = 0, height = 0, minDimension = 48) { + const normalizedWidth = Number(width); + const normalizedHeight = Number(height); + const threshold = Number.isFinite(Number(minDimension)) + ? Math.max(1, Number(minDimension)) + : 48; + return ( + Number.isFinite(normalizedWidth) && + Number.isFinite(normalizedHeight) && + normalizedWidth >= threshold && + normalizedHeight >= threshold + ); +} + +export function remapPositionBetweenRects(x = 0, y = 0, prevRect = null, nextRect = null) { + const pointX = Number.isFinite(Number(x)) ? Number(x) : 0; + const pointY = Number.isFinite(Number(y)) ? Number(y) : 0; + if (!prevRect || !nextRect) { + return { + x: pointX, + y: pointY, + }; + } + + const prevX = Number.isFinite(Number(prevRect.x)) ? Number(prevRect.x) : 0; + const prevY = Number.isFinite(Number(prevRect.y)) ? Number(prevRect.y) : 0; + const prevW = Math.max(1, Number.isFinite(Number(prevRect.w)) ? Number(prevRect.w) : 0); + const prevH = Math.max(1, Number.isFinite(Number(prevRect.h)) ? Number(prevRect.h) : 0); + const nextX = Number.isFinite(Number(nextRect.x)) ? Number(nextRect.x) : 0; + const nextY = Number.isFinite(Number(nextRect.y)) ? Number(nextRect.y) : 0; + const nextW = Math.max(1, Number.isFinite(Number(nextRect.w)) ? Number(nextRect.w) : 0); + const nextH = Math.max(1, Number.isFinite(Number(nextRect.h)) ? Number(nextRect.h) : 0); + + const relX = clampUnit((pointX - prevX) / prevW); + const relY = clampUnit((pointY - prevY) / prevH); + + return { + x: nextX + relX * nextW, + y: nextY + relY * nextH, + }; +} diff --git a/ui/graph-renderer.js b/ui/graph-renderer.js index 08360fb..05e0d1e 100644 --- a/ui/graph-renderer.js +++ b/ui/graph-renderer.js @@ -2,6 +2,10 @@ // 零依赖:客观层 / 角色 POV / 用户 POV 分区内 Vogel 初值 + 一次性力导向稳定,无帧循环抖动 import { getNodeColors } from './themes.js'; +import { + isUsableGraphCanvasSize, + remapPositionBetweenRects, +} from './graph-renderer-utils.js'; import { getGraphNodeLabel, getNodeDisplayName } from '../graph/node-labels.js'; import { normalizeMemoryScope } from '../graph/memory-scope.js'; import { @@ -40,6 +44,8 @@ const DEFAULT_LAYOUT_CONFIG = { neuralMinGap: 12, }; +const MIN_USABLE_CANVAS_DIMENSION = 48; + /** 兼容旧版 forceConfig(召回卡片等) */ function layoutKeysFromForceConfig(fc) { if (!fc || typeof fc !== 'object') return {}; @@ -192,6 +198,10 @@ export class GraphRenderer { this._regionPanels = []; this._lastGraph = null; + this._lastLayoutHints = {}; + this._lastCanvasCssWidth = 0; + this._lastCanvasCssHeight = 0; + this._lastDevicePixelRatio = window.devicePixelRatio || 1; // View transform this.scale = 1; @@ -234,6 +244,9 @@ export class GraphRenderer { const prevSelectedId = this.selectedNode?.id || null; this.nodeMap.clear(); this._lastGraph = graph; + this._lastLayoutHints = layoutHints && typeof layoutHints === 'object' + ? { ...layoutHints } + : {}; if (layoutHints && Object.prototype.hasOwnProperty.call(layoutHints, 'userPovAliases')) { this._userPovAliasSet = buildUserPovAliasNormalizedSet( layoutHints.userPovAliases, @@ -438,6 +451,40 @@ export class GraphRenderer { } } + _rebuildLayoutForCurrentViewport(W, H) { + const previousRectsByRegion = new Map(); + for (const node of this.nodes) { + if (!node?.regionKey || previousRectsByRegion.has(node.regionKey) || !node.regionRect) { + continue; + } + previousRectsByRegion.set(node.regionKey, { + x: node.regionRect.x, + y: node.regionRect.y, + w: node.regionRect.w, + h: node.regionRect.h, + }); + } + + const parts = partitionNodesByScope(this.nodes, this._userPovAliasSet); + this._regionPanels = this._computeRegionPanels(W, H, parts); + + for (const node of this.nodes) { + const nextRect = node.regionRect; + const previousRect = previousRectsByRegion.get(node.regionKey) || nextRect; + const nextPosition = remapPositionBetweenRects( + node.x, + node.y, + previousRect, + nextRect, + ); + node.x = nextPosition.x; + node.y = nextPosition.y; + node.vx = 0; + node.vy = 0; + this._clampNodeToRegion(node); + } + } + /** * 椭圆 Vogel 螺旋初值:有机疏密,Deterministic,无网格感 */ @@ -634,9 +681,10 @@ export class GraphRenderer { const W = this.canvas.width / dpr; const H = this.canvas.height / dpr; + ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); ctx.save(); - ctx.scale(dpr, dpr); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.translate(this.offsetX, this.offsetY); ctx.scale(this.scale, this.scale); @@ -703,6 +751,14 @@ export class GraphRenderer { ctx.restore(); } + _scheduleRender() { + if (this.animId) return; + this.animId = requestAnimationFrame(() => { + this.animId = null; + this._render(); + }); + } + _drawGrid(W, H) { const sp = this.config.gridSpacing; if (!sp || sp <= 0) return; @@ -813,7 +869,7 @@ export class GraphRenderer { this.offsetY += dy; this._touchSession.lastX = t.clientX; this._touchSession.lastY = t.clientY; - this._render(); + this._scheduleRender(); }, { passive: false }); c.addEventListener('touchend', (e) => { if (!this._touchSession) return; @@ -891,17 +947,17 @@ export class GraphRenderer { this.dragNode.x = x; this.dragNode.y = y; this._clampNodeToRegion(this.dragNode); - this._render(); + this._scheduleRender(); } else if (this.isPanning) { this.offsetX += e.clientX - this.lastMouse.x; this.offsetY += e.clientY - this.lastMouse.y; - this._render(); + this._scheduleRender(); } else { const node = this._findNodeAt(x, y); if (node !== this.hoveredNode) { this.hoveredNode = node; this.canvas.style.cursor = node ? 'pointer' : 'grab'; - this._render(); + this._scheduleRender(); } } this.lastMouse = { x: e.clientX, y: e.clientY }; @@ -981,15 +1037,34 @@ export class GraphRenderer { const dpr = window.devicePixelRatio || 1; const parent = this.canvas.parentElement; if (!parent) return; - const w = parent.clientWidth; - const h = parent.clientHeight; + const w = Math.round(parent.clientWidth || 0); + const h = Math.round(parent.clientHeight || 0); + if (!isUsableGraphCanvasSize(w, h, MIN_USABLE_CANVAS_DIMENSION)) { + return; + } + + if ( + w === this._lastCanvasCssWidth + && h === this._lastCanvasCssHeight + && dpr === this._lastDevicePixelRatio + ) { + return; + } + + this._lastCanvasCssWidth = w; + this._lastCanvasCssHeight = h; + this._lastDevicePixelRatio = dpr; + this.canvas.width = w * dpr; this.canvas.height = h * dpr; this.canvas.style.width = w + 'px'; this.canvas.style.height = h + 'px'; - if (this._lastGraph) { - this.loadGraph(this._lastGraph); + if (this.nodes.length > 0 && this._regionPanels.length > 0) { + this._rebuildLayoutForCurrentViewport(w, h); + this._render(); + } else if (this._lastGraph) { + this.loadGraph(this._lastGraph, this._lastLayoutHints); } else { this._render(); } diff --git a/ui/panel-graph-refresh-utils.js b/ui/panel-graph-refresh-utils.js new file mode 100644 index 0000000..f86da7a --- /dev/null +++ b/ui/panel-graph-refresh-utils.js @@ -0,0 +1,59 @@ +export function resolveVisibleGraphWorkspaceMode({ + overlayActive = false, + isMobile = false, + currentTabId = "dashboard", + currentGraphView = "graph", + currentMobileGraphView = "graph", +} = {}) { + if (!overlayActive) return "hidden"; + if (isMobile) { + if (currentTabId !== "graph") return "hidden"; + const mobileView = String(currentMobileGraphView || "graph").trim() || "graph"; + return mobileView === "cognition" + ? "mobile:cognition" + : mobileView === "summary" + ? "mobile:summary" + : "mobile:graph"; + } + if (currentTabId === "config") return "hidden"; + const desktopView = String(currentGraphView || "graph").trim() || "graph"; + return desktopView === "cognition" + ? "desktop:cognition" + : desktopView === "summary" + ? "desktop:summary" + : "desktop:graph"; +} + +export function buildVisibleGraphRefreshToken({ + visibleMode = "hidden", + chatId = "", + loadState = "", + revision = 0, + nodeCount = -1, + edgeCount = -1, + lastProcessedSeq = -1, +} = {}) { + const normalizedMode = String(visibleMode || "hidden").trim() || "hidden"; + if (normalizedMode === "hidden") return "hidden"; + const normalizedRevision = Number.isFinite(Number(revision)) + ? Math.trunc(Number(revision)) + : 0; + const normalizedNodeCount = Number.isFinite(Number(nodeCount)) + ? Math.trunc(Number(nodeCount)) + : -1; + const normalizedEdgeCount = Number.isFinite(Number(edgeCount)) + ? Math.trunc(Number(edgeCount)) + : -1; + const normalizedLastProcessedSeq = Number.isFinite(Number(lastProcessedSeq)) + ? Math.trunc(Number(lastProcessedSeq)) + : -1; + return [ + normalizedMode, + String(chatId || "").trim(), + String(loadState || "").trim() || "unknown", + normalizedRevision, + normalizedNodeCount, + normalizedEdgeCount, + normalizedLastProcessedSeq, + ].join("|"); +} diff --git a/ui/panel.html b/ui/panel.html index 3e228f0..a0a9e7a 100644 --- a/ui/panel.html +++ b/ui/panel.html @@ -782,8 +782,7 @@
记忆 LLM
- 留空时复用当前聊天模型;填写后走现有代理转发到 OpenAI - 兼容接口。 + 留空时复用当前聊天模型;填写后会自动识别常见渠道,未识别时按自定义 OpenAI 兼容接口处理。
@@ -836,6 +835,9 @@ placeholder="https://api.openai.com/v1" /> +
+ 留空时复用当前聊天模型。支持自动识别 OpenAI 兼容渠道、Anthropic Claude、Google AI Studio / Gemini;填写完整 endpoint 时会自动规整为可复用的 base URL。 +
{ + _flushScheduledVisibleGraphRefresh(); + }, delay); + + return { + scheduled: true, + reason: "throttled", + token: nextToken, + delay, + }; +} + /** * 初始化面板(由 index.js 调用一次) */ @@ -938,7 +1128,6 @@ export function openPanel() { panelEl?.querySelector(".bme-tab-btn.active")?.dataset.tab || currentTabId; _switchTab(activeTabId); _refreshRuntimeStatus(); - _refreshGraph(); _buildLegend(); } @@ -948,6 +1137,8 @@ export function openPanel() { export function closePanel() { if (!overlayEl) return; overlayEl.classList.remove("active"); + _clearScheduledVisibleGraphRefresh(); + lastVisibleGraphRefreshToken = ""; } /** @@ -989,7 +1180,7 @@ export function refreshLiveState() { _refreshMessageTraceWorkspace(); } - _refreshGraph(); + _scheduleVisibleGraphWorkspaceRefresh(); } // ==================== Tab 切换 ==================== @@ -1004,6 +1195,7 @@ function _bindTabs() { } function _switchTab(tabId) { + const previousVisibleGraphMode = _getVisibleGraphWorkspaceMode(); let next = tabId || "dashboard"; // 「图谱」仅移动端底部 Tab 可用;桌面端图谱在右侧主工作区,侧栏不设该 Tab if (!_isMobile() && next === "graph") { @@ -1035,11 +1227,17 @@ function _switchTab(tabId) { _refreshConfigTab(); break; case "graph": - _refreshMobileGraphTab(); break; default: break; } + + const nextVisibleGraphMode = _getVisibleGraphWorkspaceMode(); + if (nextVisibleGraphMode !== previousVisibleGraphMode) { + _scheduleVisibleGraphWorkspaceRefresh({ force: true }); + } else { + _scheduleVisibleGraphWorkspaceRefresh(); + } } function _getPlannerApi() { @@ -1118,8 +1316,7 @@ function _switchGraphView(view) { if (cogWorkspace) cogWorkspace.style.display = isCognition ? "" : "none"; if (summaryWorkspace) summaryWorkspace.style.display = isSummary ? "" : "none"; - if (isCognition) _refreshCognitionWorkspace(); - if (isSummary) _refreshSummaryWorkspace(); + _refreshGraph({ force: true }); } // ==================== 移动端图谱 Tab ==================== @@ -1144,14 +1341,7 @@ function _switchMobileGraphSubView(view) { } function _refreshMobileGraphTab() { - if (currentMobileGraphView === "graph") { - _refreshGraph(); - _buildMobileLegend(); - } else if (currentMobileGraphView === "cognition") { - _refreshMobileCognitionFull(); - } else if (currentMobileGraphView === "summary") { - _refreshMobileSummaryFull(); - } + _refreshGraph({ force: true }); } function _buildMobileLegend() { @@ -2947,20 +3137,8 @@ function _hostUserPovAliasHintsForGraph() { return getHostUserAliasHints(); } -function _refreshGraph() { - const graph = _getGraph?.(); - if (!graph) return; - const hints = { userPovAliases: _hostUserPovAliasHintsForGraph() }; - graphRenderer?.loadGraph(graph, hints); - mobileGraphRenderer?.loadGraph(graph, hints); - if (currentGraphView === "cognition") { - _refreshCognitionWorkspace(); - } else if (currentGraphView === "summary") { - _refreshSummaryWorkspace(); - } - if (currentTabId === "graph") { - _refreshMobileGraphTab(); - } +function _refreshGraph(options = {}) { + return _refreshVisibleGraphWorkspace({ force: options.force !== false }); } function _buildLegend() { @@ -4544,6 +4722,7 @@ function _refreshConfigTab() { _setInputValue("bme-setting-llm-url", settings.llmApiUrl || ""); _setInputValue("bme-setting-llm-key", settings.llmApiKey || ""); _setInputValue("bme-setting-llm-model", settings.llmModel || ""); + _refreshMemoryLlmProviderHelp(settings.llmApiUrl || ""); _populateLlmPresetSelect(settings.llmPresets || {}, resolvedActiveLlmPreset); _syncLlmPresetControls(resolvedActiveLlmPreset); _setInputValue("bme-setting-timeout-ms", settings.timeoutMs ?? 300000); @@ -5073,6 +5252,7 @@ function _bindConfigControls() { _setInputValue("bme-setting-llm-url", preset.llmApiUrl); _setInputValue("bme-setting-llm-key", preset.llmApiKey); _setInputValue("bme-setting-llm-model", preset.llmModel); + _refreshMemoryLlmProviderHelp(preset.llmApiUrl); _clearFetchedLlmModels(); _syncLlmPresetControls(selectedName); }); @@ -5167,6 +5347,7 @@ function _bindConfigControls() { bindText("bme-setting-llm-url", (value) => { _patchSettings({ llmApiUrl: value.trim() }); + _refreshMemoryLlmProviderHelp(value); _markLlmPresetDirty({ clearFetchedModels: true }); }); bindText("bme-setting-llm-key", (value) => { @@ -6176,6 +6357,8 @@ function _getMonitorRouteLabel(value = "") { if (!normalized) return ""; const labels = { "dedicated-openai-compatible": "专用 OpenAI 兼容接口", + "dedicated-anthropic-claude": "Anthropic Claude 接口", + "dedicated-google-ai-studio": "Google AI Studio / Gemini 接口", "sillytavern-current-model": "酒馆当前模型", "dedicated-memory-llm": "专用记忆模型", global: "跟随当前 API", @@ -6227,7 +6410,8 @@ function _getMonitorEjsStatusLabel(status = "") { function _formatMonitorRouteInfo(entry = {}) { const parts = [ - _getMonitorRouteLabel(entry?.route), + _getMonitorRouteLabel(entry?.routeLabel || entry?.route), + String(entry?.llmProviderLabel || "").trim(), _getMonitorRouteLabel(entry?.llmConfigSourceLabel), String(entry?.model || "").trim() ? `模型:${String(entry.model).trim()}` : "", ].filter(Boolean); @@ -7914,7 +8098,11 @@ function _renderTaskDebugLlmCard(taskType, llmRequest) {
请求路径 - ${_escHtml(llmRequest.route || "—")} + ${_escHtml(llmRequest.routeLabel || _getMonitorRouteLabel(llmRequest.route || "") || llmRequest.route || "—")} +
+
+ 识别渠道 + ${_escHtml(llmRequest.llmProviderLabel || llmRequest.llmProvider || "—")}
模型 @@ -9538,6 +9726,7 @@ function _setText(id, text) { function _getGraphPersistenceSnapshot() { return _getGraphPersistenceState?.() || { + revision: 0, loadState: "no-chat", reason: "", writesBlocked: true,