From 2d208784b41e2db18b307a3b5ce9e515cb7698ec Mon Sep 17 00:00:00 2001 From: Peisong Xiao Date: Sun, 18 Jan 2026 14:10:41 -0500 Subject: [PATCH] reworked advanced mode and token parser to support Ollama --- sitecompanion/background.js | 79 +++++++++++++++++++++++++++---------- sitecompanion/popup.js | 35 +++++++++++----- sitecompanion/settings.js | 76 +++++++++++------------------------ 3 files changed, 109 insertions(+), 81 deletions(-) diff --git a/sitecompanion/background.js b/sitecompanion/background.js index ebfb14a..8e46552 100644 --- a/sitecompanion/background.js +++ b/sitecompanion/background.js @@ -10,8 +10,6 @@ const DEFAULT_SETTINGS = { activeEnvConfigId: "", profiles: [], apiBaseUrl: "https://api.openai.com/v1", - apiKeyHeader: "Authorization", - apiKeyPrefix: "Bearer ", model: "gpt-5.2", systemPrompt: "", tasks: DEFAULT_TASKS, @@ -25,6 +23,8 @@ const DEFAULT_SETTINGS = { const OUTPUT_STORAGE_KEY = "lastOutput"; const AUTO_RUN_KEY = "autoRunDefaultTask"; const SHORTCUT_RUN_KEY = "runShortcutId"; +const DEFAULT_API_KEY_HEADER = "Authorization"; +const DEFAULT_API_KEY_PREFIX = "Bearer "; let activeAbortController = null; let keepalivePort = null; const streamState = { @@ -112,8 +112,6 @@ chrome.runtime.onInstalled.addListener(async () => { id, name: "Default", apiBaseUrl: stored.apiBaseUrl || DEFAULT_SETTINGS.apiBaseUrl, - apiKeyHeader: stored.apiKeyHeader || DEFAULT_SETTINGS.apiKeyHeader, - apiKeyPrefix: stored.apiKeyPrefix || DEFAULT_SETTINGS.apiKeyPrefix, model: stored.model || DEFAULT_SETTINGS.model, apiKeyId: fallbackKeyId, apiUrl: "", @@ -409,6 +407,12 @@ async function handleAnalysisRequest(port, payload, signal) { } = payload || {}; const isAdvanced = apiMode === "advanced"; + const resolvedApiKeyHeader = isAdvanced + ? "" + : apiKeyHeader || DEFAULT_API_KEY_HEADER; + const resolvedApiKeyPrefix = isAdvanced + ? "" + : apiKeyPrefix ?? DEFAULT_API_KEY_PREFIX; if (isAdvanced) { if (!apiUrl) { safePost(port, { type: "ERROR", message: "Missing API URL." }); @@ -420,7 +424,7 @@ async function handleAnalysisRequest(port, payload, signal) { return; } - if (apiKeyHeader && !apiKey) { + if (resolvedApiKeyHeader && !apiKey) { safePost(port, { type: "ERROR", message: "Missing API key." }); return; } @@ -456,8 +460,8 @@ async function handleAnalysisRequest(port, payload, signal) { apiKey, apiUrl, requestTemplate, - apiKeyHeader, - apiKeyPrefix, + apiKeyHeader: resolvedApiKeyHeader, + apiKeyPrefix: resolvedApiKeyPrefix, apiBaseUrl, model, systemPrompt: systemPrompt || "", @@ -472,8 +476,8 @@ async function handleAnalysisRequest(port, payload, signal) { await streamChatCompletion({ apiKey, apiBaseUrl, - apiKeyHeader, - apiKeyPrefix, + apiKeyHeader: resolvedApiKeyHeader, + apiKeyPrefix: resolvedApiKeyPrefix, model, systemPrompt: systemPrompt || "", userMessage, @@ -536,16 +540,42 @@ function buildTemplateBody(template, replacements) { try { return JSON.parse(filled); } catch { - throw new Error("Invalid request template JSON."); + throw new Error("Invalid request template JSON." + filled); } } +function extractStreamDelta(parsed) { + if (!parsed) return ""; + const openAiDelta = parsed?.choices?.[0]?.delta?.content; + if (openAiDelta) return openAiDelta; + const openAiMessage = parsed?.choices?.[0]?.message?.content; + if (openAiMessage) return openAiMessage; + const ollamaMessage = parsed?.message?.content; + if (ollamaMessage) return ollamaMessage; + if (typeof parsed?.response === "string") return parsed.response; + if (typeof parsed?.content === "string") return parsed.content; + return ""; +} + +function parseStreamLine(line) { + const trimmed = line.trim(); + if (!trimmed) return null; + if (trimmed.startsWith("event:") || trimmed.startsWith("id:")) { + return null; + } + if (trimmed.startsWith("data:")) { + const data = trimmed.slice(5).trim(); + return data || null; + } + return trimmed; +} + async function readSseStream(response, onDelta) { const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; - // OpenAI-compatible SSE stream; parse incremental deltas from data lines. + // OpenAI-compatible SSE or newline-delimited JSON streaming. while (true) { const { value, done } = await reader.read(); if (done) break; @@ -555,24 +585,33 @@ async function readSseStream(response, onDelta) { buffer = lines.pop() || ""; for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed.startsWith("data:")) continue; - - const data = trimmed.slice(5).trim(); - if (!data) continue; - if (data === "[DONE]") return; + const payload = parseStreamLine(line); + if (!payload) continue; + if (payload === "[DONE]") return; let parsed; try { - parsed = JSON.parse(data); + parsed = JSON.parse(payload); } catch { continue; } - const delta = parsed?.choices?.[0]?.delta?.content; + const delta = extractStreamDelta(parsed); if (delta) onDelta(delta); + if (parsed?.done === true) return; } } + + const tail = parseStreamLine(buffer); + if (!tail) return; + if (tail === "[DONE]") return; + try { + const parsed = JSON.parse(tail); + const delta = extractStreamDelta(parsed); + if (delta) onDelta(delta); + } catch { + // Ignore trailing parse failures. + } } async function streamChatCompletion({ @@ -636,8 +675,8 @@ async function streamCustomCompletion({ onDelta }) { const replacements = { - PROMPT_GOES_HERE: userMessage, SYSTEM_PROMPT_GOES_HERE: systemPrompt, + PROMPT_GOES_HERE: userMessage, API_KEY_GOES_HERE: apiKey, MODEL_GOES_HERE: model || "", API_BASE_URL_GOES_HERE: apiBaseUrl || "" diff --git a/sitecompanion/popup.js b/sitecompanion/popup.js index a16f358..422c327 100644 --- a/sitecompanion/popup.js +++ b/sitecompanion/popup.js @@ -186,13 +186,16 @@ function normalizeConfigList(list) { : []; } +const DEFAULT_API_KEY_HEADER = "Authorization"; +const DEFAULT_API_KEY_PREFIX = "Bearer "; + const TEMPLATE_PLACEHOLDERS = [ - "PROMPT_GOES_HERE", "SYSTEM_PROMPT_GOES_HERE", + "PROMPT_GOES_HERE", "API_KEY_GOES_HERE", "MODEL_GOES_HERE", "API_BASE_URL_GOES_HERE" -]; +].sort((a, b) => b.length - a.length); function buildTemplateValidationSource(template) { let output = template || ""; @@ -203,10 +206,28 @@ function buildTemplateValidationSource(template) { return output; } +function normalizeTemplateInput(template) { + return (template || "") + .replace(/\uFEFF/g, "") + .replace(/[\u200B-\u200D\u2060]/g, "") + .replace(/[\u2028\u2029]/g, "\n") + .replace(/[\u0000-\u001F]/g, (char) => + char === "\n" || char === "\r" || char === "\t" ? char : " " + ) + .replace(/[\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000]/g, " "); +} + function isValidTemplateJson(template) { if (!template) return false; + const normalized = normalizeTemplateInput(template); try { - JSON.parse(buildTemplateValidationSource(template)); + JSON.parse(normalized); + return true; + } catch { + // Fall through to placeholder-neutralized parsing. + } + try { + JSON.parse(buildTemplateValidationSource(normalized)); return true; } catch { return false; @@ -1017,8 +1038,6 @@ async function handleAnalyze() { apiConfigs = [], activeApiConfigId = "", apiBaseUrl, - apiKeyHeader, - apiKeyPrefix, model, systemPrompt, resume @@ -1028,8 +1047,6 @@ async function handleAnalyze() { "apiConfigs", "activeApiConfigId", "apiBaseUrl", - "apiKeyHeader", - "apiKeyPrefix", "model", "systemPrompt", "resume" @@ -1071,8 +1088,8 @@ async function handleAnalyze() { const resolvedApiUrl = activeConfig?.apiUrl || ""; const resolvedTemplate = activeConfig?.requestTemplate || ""; const resolvedApiBaseUrl = activeConfig?.apiBaseUrl || apiBaseUrl || ""; - const resolvedApiKeyHeader = activeConfig?.apiKeyHeader ?? apiKeyHeader ?? ""; - const resolvedApiKeyPrefix = activeConfig?.apiKeyPrefix ?? apiKeyPrefix ?? ""; + const resolvedApiKeyHeader = isAdvanced ? "" : DEFAULT_API_KEY_HEADER; + const resolvedApiKeyPrefix = isAdvanced ? "" : DEFAULT_API_KEY_PREFIX; const resolvedModel = activeConfig?.model || model || ""; const resolvedKeys = normalizeConfigList(apiKeys).filter( diff --git a/sitecompanion/settings.js b/sitecompanion/settings.js index a472091..95778d9 100644 --- a/sitecompanion/settings.js +++ b/sitecompanion/settings.js @@ -26,9 +26,7 @@ const toc = document.querySelector(".toc"); const tocResizer = document.getElementById("tocResizer"); const OPENAI_DEFAULTS = { - apiBaseUrl: "https://api.openai.com/v1", - apiKeyHeader: "Authorization", - apiKeyPrefix: "Bearer " + apiBaseUrl: "https://api.openai.com/v1" }; const DEFAULT_MODEL = "gpt-5.2"; const DEFAULT_SYSTEM_PROMPT = ""; @@ -476,12 +474,12 @@ function normalizeConfigList(list) { } const TEMPLATE_PLACEHOLDERS = [ - "PROMPT_GOES_HERE", "SYSTEM_PROMPT_GOES_HERE", + "PROMPT_GOES_HERE", "API_KEY_GOES_HERE", "MODEL_GOES_HERE", "API_BASE_URL_GOES_HERE" -]; +].sort((a, b) => b.length - a.length); function buildTemplateValidationSource(template) { let output = template || ""; @@ -492,10 +490,28 @@ function buildTemplateValidationSource(template) { return output; } +function normalizeTemplateInput(template) { + return (template || "") + .replace(/\uFEFF/g, "") + .replace(/[\u200B-\u200D\u2060]/g, "") + .replace(/[\u2028\u2029]/g, "\n") + .replace(/[\u0000-\u001F]/g, (char) => + char === "\n" || char === "\r" || char === "\t" ? char : " " + ) + .replace(/[\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000]/g, " "); +} + function isValidTemplateJson(template) { if (!template) return false; + const normalized = normalizeTemplateInput(template); try { - JSON.parse(buildTemplateValidationSource(template)); + JSON.parse(normalized); + return true; + } catch { + // Fall through to placeholder-neutralized parsing. + } + try { + JSON.parse(buildTemplateValidationSource(normalized)); return true; } catch { return false; @@ -549,8 +565,6 @@ function readApiConfigFromCard(card) { const nameInput = card.querySelector(".api-config-name"); const keySelect = card.querySelector(".api-config-key-select"); const baseInput = card.querySelector(".api-config-base"); - const headerInput = card.querySelector(".api-config-header"); - const prefixInput = card.querySelector(".api-config-prefix"); const modelInput = card.querySelector(".api-config-model"); const urlInput = card.querySelector(".api-config-url"); const templateInput = card.querySelector(".api-config-template"); @@ -562,8 +576,6 @@ function readApiConfigFromCard(card) { name: (nameInput?.value || "Default").trim(), apiKeyId: keySelect?.value || "", apiBaseUrl: (baseInput?.value || "").trim(), - apiKeyHeader: (headerInput?.value || "").trim(), - apiKeyPrefix: prefixInput?.value || "", model: (modelInput?.value || "").trim(), apiUrl: (urlInput?.value || "").trim(), requestTemplate: (templateInput?.value || "").trim(), @@ -623,30 +635,6 @@ function buildApiConfigCard(config) { baseField.appendChild(baseLabel); baseField.appendChild(baseInput); - const headerField = document.createElement("div"); - headerField.className = "field advanced-only"; - const headerLabel = document.createElement("label"); - headerLabel.textContent = "API Key Header"; - const headerInput = document.createElement("input"); - headerInput.type = "text"; - headerInput.placeholder = OPENAI_DEFAULTS.apiKeyHeader; - headerInput.value = config.apiKeyHeader || ""; - headerInput.className = "api-config-header"; - headerField.appendChild(headerLabel); - headerField.appendChild(headerInput); - - const prefixField = document.createElement("div"); - prefixField.className = "field advanced-only"; - const prefixLabel = document.createElement("label"); - prefixLabel.textContent = "API Key Prefix"; - const prefixInput = document.createElement("input"); - prefixInput.type = "text"; - prefixInput.placeholder = OPENAI_DEFAULTS.apiKeyPrefix; - prefixInput.value = config.apiKeyPrefix || ""; - prefixInput.className = "api-config-prefix"; - prefixField.appendChild(prefixLabel); - prefixField.appendChild(prefixInput); - const modelField = document.createElement("div"); modelField.className = "field basic-only"; const modelLabel = document.createElement("label"); @@ -674,7 +662,7 @@ function buildApiConfigCard(config) { const templateField = document.createElement("div"); templateField.className = "field advanced-only"; const templateLabel = document.createElement("label"); - templateLabel.textContent = "Request JSON template"; + templateLabel.textContent = "Request JSON body"; const templateInput = document.createElement("textarea"); templateInput.rows = 8; templateInput.placeholder = [ @@ -747,8 +735,6 @@ function buildApiConfigCard(config) { id: newApiConfigId(), name, apiBaseUrl: OPENAI_DEFAULTS.apiBaseUrl, - apiKeyHeader: OPENAI_DEFAULTS.apiKeyHeader, - apiKeyPrefix: OPENAI_DEFAULTS.apiKeyPrefix, model: DEFAULT_MODEL, apiUrl: "", requestTemplate: "", @@ -787,8 +773,6 @@ function buildApiConfigCard(config) { resetBtn.textContent = "Reset to OpenAI"; resetBtn.addEventListener("click", () => { baseInput.value = OPENAI_DEFAULTS.apiBaseUrl; - headerInput.value = OPENAI_DEFAULTS.apiKeyHeader; - prefixInput.value = OPENAI_DEFAULTS.apiKeyPrefix; modelInput.value = DEFAULT_MODEL; urlInput.value = ""; templateInput.value = ""; @@ -808,8 +792,6 @@ function buildApiConfigCard(config) { const updateSelect = () => updateEnvApiOptions(); nameInput.addEventListener("input", updateSelect); baseInput.addEventListener("input", updateSelect); - headerInput.addEventListener("input", updateSelect); - prefixInput.addEventListener("input", updateSelect); modelInput.addEventListener("input", updateSelect); urlInput.addEventListener("input", updateSelect); templateInput.addEventListener("input", updateSelect); @@ -830,8 +812,6 @@ function buildApiConfigCard(config) { card.appendChild(nameField); card.appendChild(keyField); card.appendChild(baseField); - card.appendChild(headerField); - card.appendChild(prefixField); card.appendChild(modelField); card.appendChild(urlField); card.appendChild(templateField); @@ -3479,7 +3459,7 @@ function updateSidebarErrors() { } } - if (!defaultApiConfig.advanced && defaultApiConfig?.apiKeyHeader) { + if (!defaultApiConfig.advanced) { const key = enabledApiKeys.find( (entry) => entry.id === defaultApiConfig?.apiKeyId ); @@ -3534,8 +3514,6 @@ async function loadSettings() { activeEnvConfigId = "", profiles = [], apiBaseUrl = "", - apiKeyHeader = "", - apiKeyPrefix = "", model = "", systemPrompt = "", resume = "", @@ -3559,8 +3537,6 @@ async function loadSettings() { "activeEnvConfigId", "profiles", "apiBaseUrl", - "apiKeyHeader", - "apiKeyPrefix", "model", "systemPrompt", "resume", @@ -3724,8 +3700,6 @@ async function loadSettings() { id: newApiConfigId(), name: "Default", apiBaseUrl: apiBaseUrl || OPENAI_DEFAULTS.apiBaseUrl, - apiKeyHeader: apiKeyHeader || OPENAI_DEFAULTS.apiKeyHeader, - apiKeyPrefix: apiKeyPrefix || OPENAI_DEFAULTS.apiKeyPrefix, model: model || DEFAULT_MODEL, apiKeyId: resolvedActiveId || resolvedKeys[0]?.id || "", apiUrl: "", @@ -4046,8 +4020,6 @@ addApiConfigBtn.addEventListener("click", () => { id: newApiConfigId(), name, apiBaseUrl: OPENAI_DEFAULTS.apiBaseUrl, - apiKeyHeader: OPENAI_DEFAULTS.apiKeyHeader, - apiKeyPrefix: OPENAI_DEFAULTS.apiKeyPrefix, model: DEFAULT_MODEL, apiUrl: "", requestTemplate: "",