Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,18 @@
"js": [
"src/content/platform/common/alphaDetailsPopup.js"
]
},
{
"matches": [
"https://support.worldquantbrain.com/hc/*/community/posts/*"
],
"js": [
"src/content/support/communityAssistant.js"
],
"css": [
"src/content/support/communityAssistant.css"
],
"run_at": "document_idle"
}
],
"web_accessible_resources": [
Expand All @@ -106,6 +118,8 @@
}
],
"host_permissions": [
"http://localhost/*",
"http://127.0.0.1/*",
"https://*/*",
"https://api.worldquantbrain.com/*"
],
Expand Down
171 changes: 171 additions & 0 deletions src/background/services/llmService.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { getLocalValue, setLocalValue } from './storageService.js';

const CONFIG_KEY = 'WQP_LLM_Config';

const DEFAULT_CONFIG = {
enabled: false,
baseUrl: '',
model: '',
apiKey: '',
};

function normalizeBaseUrl(value) {
return String(value || '').trim().replace(/\/+$/, '');
}

function normalizeConfig(config = {}) {
return {
enabled: config.enabled === true,
baseUrl: normalizeBaseUrl(config.baseUrl),
model: String(config.model || '').trim(),
apiKey: String(config.apiKey || '').trim(),
};
}

function sanitizeConfig(config) {
const normalized = normalizeConfig(config);
return {
...normalized,
apiKey: '',
hasApiKey: Boolean(normalized.apiKey),
};
}

export async function getLlmConfig() {
const saved = await getLocalValue(CONFIG_KEY);
return sanitizeConfig({ ...DEFAULT_CONFIG, ...(saved || {}) });
}

export async function getLlmConfigRaw() {
const saved = await getLocalValue(CONFIG_KEY);
return normalizeConfig({ ...DEFAULT_CONFIG, ...(saved || {}) });
}

export async function saveLlmConfig(input = {}) {
const existing = await getLlmConfigRaw();
const next = normalizeConfig({
...existing,
enabled: input.enabled === true,
baseUrl: input.baseUrl,
model: input.model,
apiKey: typeof input.apiKey === 'string' && input.apiKey.length > 0
? input.apiKey
: input.keepExistingApiKey === true
? existing.apiKey
: '',
});
if (next.enabled) {
await testLlmConfig(next);
}
await setLocalValue(CONFIG_KEY, next);
return sanitizeConfig(next);
}

function extractJsonObject(text) {
const raw = String(text || '').trim();
if (!raw) throw new Error('LLM returned an empty response.');
try {
return JSON.parse(raw);
} catch (_) {
const match = raw.match(/\{[\s\S]*\}/);
if (!match) throw new Error('LLM response is not valid JSON.');
return JSON.parse(match[0]);
}
}

export async function runLlmJson({ systemPrompt, userPrompt, schemaName = 'result' }) {
const config = await getLlmConfigRaw();
if (!config.enabled) throw new Error('AI is disabled. Please enable AI in the extension side panel.');
if (!config.model) throw new Error('AI model is not configured.');
return runLlmJsonWithConfig(config, { systemPrompt, userPrompt, schemaName });
}
Comment on lines +76 to +81

export async function runLlmText({ systemPrompt, userPrompt, taskName = 'result' }) {
const config = await getLlmConfigRaw();
if (!config.enabled) throw new Error('AI is disabled. Please enable AI in the extension side panel.');
if (!config.model) throw new Error('AI model is not configured.');
return runLlmTextWithConfig(config, { systemPrompt, userPrompt, taskName });
}
Comment on lines +83 to +88

async function requestChatCompletion(config, payload) {
const headers = {
Accept: 'application/json',
'Content-Type': 'application/json',
};
if (config.apiKey) {
headers.Authorization = `Bearer ${config.apiKey}`;
}

const response = await fetch(`${config.baseUrl}/chat/completions`, {
method: 'POST',
headers,
body: JSON.stringify(payload),
});

const data = await response.json().catch(() => ({}));
if (!response.ok) {
const detail = data?.error?.message || data?.message || response.statusText;
throw new Error(`LLM request failed (${response.status}): ${detail}`);
}
return data;
}

async function testLlmConfig(config) {
if (!config.baseUrl) throw new Error('AI Base URL is required.');
if (!config.model) throw new Error('AI model is required.');
const data = await requestChatCompletion(config, {
model: config.model,
stream: false,
messages: [
{ role: 'user', content: 'Reply with exactly: ok' },
],
});
const content = data?.choices?.[0]?.message?.content;
if (typeof content !== 'string' || !content.trim()) {
throw new Error('LLM connectivity check returned an empty response.');
}
return true;
Comment on lines +123 to +127
}

async function runLlmJsonWithConfig(config, { systemPrompt, userPrompt, schemaName = 'result' }) {
const data = await requestChatCompletion(config, {
model: config.model,
stream: false,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
});

const content = data?.choices?.[0]?.message?.content;
const parsed = extractJsonObject(content);
if (!parsed || typeof parsed !== 'object') {
throw new Error(`LLM ${schemaName} response is not an object.`);
}
return {
result: parsed,
usage: data?.usage || null,
model: data?.model || config.model,
};
}

async function runLlmTextWithConfig(config, { systemPrompt, userPrompt, taskName = 'result' }) {
const data = await requestChatCompletion(config, {
model: config.model,
stream: false,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
});

const content = data?.choices?.[0]?.message?.content;
if (typeof content !== 'string' || !content.trim()) {
throw new Error(`LLM ${taskName} response is empty.`);
}
return {
text: content.trim(),
usage: data?.usage || null,
model: data?.model || config.model,
};
}
34 changes: 34 additions & 0 deletions src/background/services/sidebarMessageRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,40 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === 'WQP_PRODMEMO_DELETE') {
return respond(sendResponse, deleteProdMemoCache(msg.alphaId));
}
if (msg.type === 'WQP_LLM_CONFIG_GET') {
return respond(sendResponse, runCommunityAction('LLM_CONFIG_GET'));
}
if (msg.type === 'WQP_LLM_CONFIG_SAVE') {
return respond(sendResponse, runCommunityAction('LLM_CONFIG_SAVE', { config: msg.config }));
}
if (msg.type === 'WQP_COMMUNITY_AI_SUMMARIZE_POST') {
return respond(sendResponse, runCommunityAction('AI_SUMMARIZE_POST', {
postUrl: msg.postUrl,
postId: msg.postId,
forceRefresh: msg.forceRefresh,
}));
}
if (msg.type === 'WQP_COMMUNITY_AI_GET_CACHED_SUMMARY') {
return respond(sendResponse, runCommunityAction('AI_GET_CACHED_SUMMARY', {
postUrl: msg.postUrl,
postId: msg.postId,
}));
}
if (msg.type === 'WQP_COMMUNITY_AI_DRAFT_COMMENT') {
return respond(sendResponse, runCommunityAction('AI_DRAFT_COMMENT', {
postUrl: msg.postUrl,
postId: msg.postId,
customInstruction: msg.customInstruction,
}));
}
if (msg.type === 'WQP_COMMUNITY_AI_POST_COMMENT') {
return respond(sendResponse, runCommunityAction('AI_POST_COMMENT', {
postUrl: msg.postUrl,
postId: msg.postId,
commentText: msg.commentText,
commentHtml: msg.commentHtml,
}));
}

return false;
});
Expand Down
Loading