From 8c40d94fd950aed32ca94a4087829b0aee2ed68e Mon Sep 17 00:00:00 2001 From: Dear <2586844575@qq.com> Date: Thu, 9 Jul 2026 16:13:36 +0800 Subject: [PATCH] Add AI assistant for community posts --- manifest.json | 14 + src/background/services/llmService.js | 171 +++++++ .../services/sidebarMessageRouter.js | 34 ++ .../services/supportCommunityService.js | 469 ++++++++++++++++++ src/content/support/communityAssistant.css | 417 ++++++++++++++++ src/content/support/communityAssistant.js | 454 +++++++++++++++++ src/ui/sidebar/modules/settingsPanel.js | 54 +- src/ui/sidebar/sidebar.html | 18 + 8 files changed, 1630 insertions(+), 1 deletion(-) create mode 100644 src/background/services/llmService.js create mode 100644 src/content/support/communityAssistant.css create mode 100644 src/content/support/communityAssistant.js diff --git a/manifest.json b/manifest.json index 63ee233..f6b2daf 100644 --- a/manifest.json +++ b/manifest.json @@ -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": [ @@ -106,6 +118,8 @@ } ], "host_permissions": [ + "http://localhost/*", + "http://127.0.0.1/*", "https://*/*", "https://api.worldquantbrain.com/*" ], diff --git a/src/background/services/llmService.js b/src/background/services/llmService.js new file mode 100644 index 0000000..4f74924 --- /dev/null +++ b/src/background/services/llmService.js @@ -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 }); +} + +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 }); +} + +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; +} + +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, + }; +} diff --git a/src/background/services/sidebarMessageRouter.js b/src/background/services/sidebarMessageRouter.js index c783068..92f28d2 100644 --- a/src/background/services/sidebarMessageRouter.js +++ b/src/background/services/sidebarMessageRouter.js @@ -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; }); diff --git a/src/background/services/supportCommunityService.js b/src/background/services/supportCommunityService.js index a137a5d..9be240b 100644 --- a/src/background/services/supportCommunityService.js +++ b/src/background/services/supportCommunityService.js @@ -1,5 +1,7 @@ import { getLocalValue, removeLocalValue, setLocalValue } from './storageService.js'; +import { getLlmConfig, runLlmText, saveLlmConfig } from './llmService.js'; + const API_BASE = 'https://api.worldquantbrain.com'; const SUPPORT_BASE = 'https://support.worldquantbrain.com'; const LIKED_IDS_KEY = 'WQP_LikedIds'; @@ -15,6 +17,7 @@ const POST_DETAIL_CONCURRENCY = 4; const RECENT_POST_CONCURRENCY = 3; const SECTION_CONCURRENCY = 2; const ARTICLE_CONCURRENCY = 3; +const AI_SUMMARY_PROMPT_VERSION = 'markdown-summary-v1'; let csrfToken = null; let supportReadyPromise = null; @@ -1008,6 +1011,460 @@ async function fetchPostComments(postRef, ctx = {}) { }; } +function decodeBasicHtmlEntities(text) { + return String(text || '') + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/"/gi, '"') + .replace(/'/gi, "'") + .replace(/'/gi, "'") + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code))) + .replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCharCode(parseInt(code, 16))); +} + +function htmlToText(html) { + return decodeBasicHtmlEntities(String(html || '') + .replace(//gi, ' ') + .replace(//gi, ' ') + .replace(//gi, '\n') + .replace(/<\/p>/gi, '\n') + .replace(/<\/li>/gi, '\n') + .replace(/<[^>]+>/g, ' ') + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .replace(/[ \t]{2,}/g, ' ') + .trim()); +} + +function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function plainTextToHtml(value) { + const paragraphs = String(value || '') + .split(/\n{2,}/) + .map((part) => part.trim()) + .filter(Boolean); + if (!paragraphs.length) return ''; + return paragraphs.map((paragraph) => `

${escapeHtml(paragraph).replace(/\n/g, '
')}

`).join(''); +} + +function inlineMarkdownToHtml(value) { + return escapeHtml(value) + .replace(/`([^`]+)`/g, '$1') + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/\*([^*]+)\*/g, '$1'); +} + +function markdownToHtml(value) { + const lines = String(value || '').replace(/\r\n/g, '\n').split('\n'); + const blocks = []; + let paragraph = []; + let listItems = []; + let listOrdered = false; + + const flushParagraph = () => { + if (!paragraph.length) return; + blocks.push(`

${paragraph.map(inlineMarkdownToHtml).join('
')}

`); + paragraph = []; + }; + const flushList = () => { + if (!listItems.length) return; + const tag = listOrdered ? 'ol' : 'ul'; + blocks.push(`<${tag}>${listItems.map((item) => `
  • ${inlineMarkdownToHtml(item)}
  • `).join('')}`); + listItems = []; + }; + + lines.forEach((rawLine) => { + const line = rawLine.trim(); + if (!line) { + flushParagraph(); + flushList(); + return; + } + const heading = line.match(/^(#{1,4})\s+(.+)$/); + if (heading) { + flushParagraph(); + flushList(); + const level = Math.min(4, heading[1].length + 1); + blocks.push(`${inlineMarkdownToHtml(heading[2])}`); + return; + } + const numbered = line.match(/^\d+[.)]\s+(.+)$/); + const bulleted = line.match(/^[-*+]\s+(.+)$/); + if (numbered || bulleted) { + flushParagraph(); + const isOrdered = Boolean(numbered); + if (listItems.length && listOrdered !== isOrdered) flushList(); + listOrdered = isOrdered; + listItems.push(numbered?.[1] || bulleted?.[1] || line); + return; + } + flushList(); + paragraph.push(line); + }); + + flushParagraph(); + flushList(); + return blocks.join('') || plainTextToHtml(value); +} + +function hashText(value) { + const text = String(value || ''); + let hash = 2166136261; + for (let index = 0; index < text.length; index += 1) { + hash ^= text.charCodeAt(index); + hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24); + } + return (hash >>> 0).toString(16).padStart(8, '0'); +} + +function buildCommunityAiContextHash(context) { + return hashText([ + context.post.id, + context.post.title || '', + context.post.text || '', + context.source.totalCommentCount, + ...context.comments.map((comment) => [ + comment.id, + comment.text || '', + ].join(':')), + ].join('\n')); +} + +async function fetchCommunityPostDetail(postRef, ctx = {}) { + const postId = parseApiId(postRef, 'posts', 'postId'); + const data = await fetchZendeskJson(`/api/v2/community/posts/${encodeURIComponent(postId)}.json`, {}, ctx); + return normalizeCommunityPost(data?.post || { id: postId }); +} + +async function buildCommunityAiContext(payload = {}, ctx = {}) { + const postRef = payload.postUrl || payload.postId; + if (!postRef) throw new Error('postUrl or postId is required'); + await ensureSupportReady(payload, ctx); + + const post = await fetchCommunityPostDetail(postRef, ctx); + const detail = await fetchPostComments(post, ctx); + const mergedPost = { + ...post, + postContent: post.postContent || detail.postContent || '', + }; + const comments = Object.values(detail.comments || {}) + .sort((a, b) => { + const voteDiff = Number(b.voteNum || 0) - Number(a.voteNum || 0); + if (voteDiff !== 0) return voteDiff; + return String(a.commentTimeDatetime || '').localeCompare(String(b.commentTimeDatetime || '')); + }) + .map((comment) => ({ + id: comment.id, + author: comment.author, + createdAt: comment.createdAt || comment.commentTimeDatetime, + voteNum: comment.voteNum, + text: htmlToText(comment.commentContent), + })); + + const source = { + postId: mergedPost.id, + postUrl: mergedPost.url || communityPostUrl(mergedPost.id), + title: mergedPost.title, + commentCount: comments.length, + totalCommentCount: commentsCount(detail.comments), + fetchedAt: new Date().toISOString(), + }; + return { + source, + post: { + id: mergedPost.id, + title: mergedPost.title, + author: mergedPost.author, + createdAt: mergedPost.createdAt || mergedPost.datetime, + updatedAt: mergedPost.updatedAt, + voteNum: mergedPost.voteNum, + text: htmlToText(mergedPost.postContent), + }, + comments, + }; +} + +function buildAiContextText(context) { + const commentsText = context.comments.map((comment, index) => [ + `Comment ${index + 1} id=${comment.id} author=${comment.author || '-'} votes=${comment.voteNum || 0} created=${comment.createdAt || '-'}`, + comment.text || '', + ].join('\n')).join('\n\n'); + return [ + `Post id: ${context.post.id}`, + `Title: ${context.post.title}`, + `Author: ${context.post.author || '-'}`, + `Created: ${context.post.createdAt || '-'}`, + '', + 'Post body:', + context.post.text || '(empty)', + '', + `Comments included: ${context.comments.length}/${context.source.totalCommentCount}`, + commentsText || '(no comments)', + ].join('\n'); +} + +async function saveCommunityAiPatch(postId, patch) { + const state = await getCommunityState(); + const aiByPost = state.aiByPost && typeof state.aiByPost === 'object' ? state.aiByPost : {}; + aiByPost[postId] = { + ...(aiByPost[postId] || {}), + ...patch, + updatedAt: new Date().toISOString(), + }; + await saveCommunityStatePatch({ aiByPost }); +} + +export async function getCachedCommunityAiSummary(payload = {}) { + const postRef = payload.postUrl || payload.postId; + if (!postRef) throw new Error('postUrl or postId is required'); + const postId = parseApiId(postRef, 'posts', 'postId'); + const state = await getCommunityState(); + const cachedSummary = state.aiByPost?.[postId]?.summary; + if (!cachedSummary) return null; + if (!cachedSummary.summaryMarkdown || cachedSummary.cache?.promptVersion !== AI_SUMMARY_PROMPT_VERSION) return null; + return { + ...cachedSummary, + cached: true, + cacheUnchecked: true, + }; +} + +export async function summarizeCommunityPostWithAi(payload = {}, ctx = {}) { + progress(ctx, 'Fetching post and comments for AI summary...'); + const context = await buildCommunityAiContext(payload, ctx); + const contextHash = buildCommunityAiContextHash(context); + const llmConfig = await getLlmConfig(); + const state = await getCommunityState(); + const cachedSummary = state.aiByPost?.[context.source.postId]?.summary; + if (payload.forceRefresh !== true + && cachedSummary?.cache?.contextHash === contextHash + && cachedSummary?.cache?.model === llmConfig.model + && cachedSummary?.cache?.baseUrl === llmConfig.baseUrl + && cachedSummary?.cache?.promptVersion === AI_SUMMARY_PROMPT_VERSION + && cachedSummary?.summaryMarkdown) { + progress(ctx, `Using cached AI summary for ${context.source.postId}.`); + return { + ...cachedSummary, + cached: true, + }; + } + + progress(ctx, `Summarizing ${context.comments.length}/${context.source.totalCommentCount} comments with AI...`); + + const { text, usage, model } = await runLlmText({ + taskName: 'community summary', + systemPrompt: [ + '你是一名专业的技术社区讨论总结师,擅长从帖子正文和长评论串中提炼结构化结论。', + '', + '你的任务是帮助读者快速理解:', + '1. 楼主在问什么;', + '2. 评论区提供了哪些有效信息;', + '3. 哪些内容已经形成共识或存在分歧;', + '4. 哪些问题仍缺少信息或尚未解决;', + '5. 后续回复可以从哪些已有信息切入。', + '', + '严格规则:', + '- 只能使用用户提供的帖子正文和评论内容。', + '- 不得引入外部事实、平台规则、个人经验、模型常识或上下文中未出现的指标。', + '- 不得编造事实、排名、alpha 表现、提交记录、官方政策、用户身份或操作结果。', + '- 必须区分事实、观点、建议和未验证内容。', + '- 没有上下文支撑的内容,不得写成结论,只能放入“仍未解决的问题”或“风险提醒”。', + '- 如果原文或评论中本身存在猜测,可以说明“评论中有人猜测/提到”,但不得将其当作事实。', + '', + '输出要求:', + '- 直接返回 Markdown 正文。', + '- 不添加“以下是总结”等前言。', + '- 使用简体中文。', + '- 表达要具体、克制、可扫描,避免空泛套话和重复内容。', + ].join('\n'), + + userPrompt: [ + '请基于下面提供的帖子正文和评论串,输出一份专业讨论总结。', + '', + '请使用以下 Markdown 结构:', + '', + '## 核心问题', + '- 概括楼主提出的问题。', + '- 补充楼主给出的背景、条件、约束或已尝试内容。', + '- 不要扩展原文没有提到的背景。', + '', + '## 评论区要点', + '- 总结评论区已经提供的有效信息。', + '- 区分共识、分歧、补充条件、操作建议和经验反馈。', + '- 如果某条评论只是猜测或未被确认,需要明确其不确定性。', + '', + '## 仍未解决的问题', + '- 列出当前上下文仍缺少的信息。', + '- 列出评论中没有确认、没有结论或需要楼主进一步补充的点。', + '- 不要把推测写成已解决。', + '', + '## 可回复角度', + '- 只基于已有帖子和评论,给出后续回复可以切入的方向。', + '- 可以包括:补充信息、澄清条件、回应评论分歧、追问关键变量、整理已有结论。', + '- 不要生成脱离上下文的新建议。', + '', + '## 风险提醒', + '- 标出上下文不足导致的潜在误读。', + '- 标出未验证猜测、指标解释风险、政策/官方口径风险。', + '- 如果没有明显风险,写“暂无明显风险,但仍应避免超出原文推断。”', + '', + '整体要求:', + '- 分段分条,重点明确。', + '- 每条尽量具体,不写泛泛而谈的空话。', + '- 不要重复同一信息。', + '- 不要输出原文复述式长摘要。', + '- 不要把没有证据的猜测写成事实。', + '', + buildAiContextText(context), + ].join('\n'), + }); + + const data = { + source: context.source, + summaryMarkdown: text, + usage, + model, + cached: false, + cache: { + contextHash, + baseUrl: llmConfig.baseUrl, + model: llmConfig.model, + promptVersion: AI_SUMMARY_PROMPT_VERSION, + savedAt: new Date().toISOString(), + }, + }; + await saveCommunityAiPatch(context.source.postId, { summary: data }); + return data; +} + +export async function draftCommunityPostCommentWithAi(payload = {}, ctx = {}) { + progress(ctx, 'Fetching post and comments for AI comment draft...'); + const context = await buildCommunityAiContext(payload, ctx); + const customInstruction = String(payload.customInstruction || '').trim(); + + progress(ctx, 'Drafting AI comment...'); + const { text, usage, model } = await runLlmText({ + taskName: 'community comment draft', + systemPrompt: [ + '你是一名专业的技术社区回复顾问,擅长根据帖子正文和评论上下文,起草克制、清晰、有帮助的中文社区评论。', + '', + '你的目标是参与讨论、补充信息、提出澄清问题或整理已有观点,而不是替官方下结论。', + '', + '信息边界:', + '- 只能使用用户提供的帖子正文、评论内容和用户额外指令。', + '- 用户额外指令只代表写作偏好、语气要求或回复方向,不自动构成事实依据。', + '- 不得引入外部事实、平台规则、官方政策、个人经历、模型常识或上下文中不存在的数据。', + '- 不得声称自己代表官方、管理员、平台、规则解释者或有内部信息。', + '- 不得编造个人 alpha 数据、排名、提交数量、Sharpe、Fitness、VF、回测结果、审核状态或任何未出现的指标。', + '', + '证据规则:', + '- 帖子或评论中明确出现的信息,可以作为回复依据。', + '- 评论区中的观点、经验或猜测,只能按“评论中提到/有人建议/可以先确认”的方式表达,不得写成事实。', + '- 上下文证据不足时,优先提出澄清问题、检查方向或谨慎补充,不要强行给结论。', + '- 不要使用“研究表明”“通常来说”“官方应该”“一定是”等缺乏上下文支撑的表达。', + '- 避免使用“可能”“应该会”“大概”“我觉得”等无依据表达;需要表达不确定性时,用“从目前信息看”“建议先确认”“还需要补充”这类克制表述。', + '', + '回复风格:', + '- 回复要像真实社区评论,简洁、自然、有针对性。', + '- 第一段必须承接楼主的具体问题,不要泛泛开场。', + '- 后续内容如有依据,给出 1-3 条具体细节、检查方向、补充问题或可执行建议。', + '- 多个要点使用编号列表或短横线列表;每条只表达一个重点。', + '- 避免空泛附和、过度寒暄、泛泛夸赞、免责声明、套话和填充内容。', + '- 不要写成长篇总结,不要复述整段帖子。', + '', + '输出要求:', + '- 直接返回可发布的 Markdown 评论正文。', + '- 不附加 reason、riskFlags、解释文字、标题或前言。', + '- 使用简体中文。', + ].join('\n'), + + userPrompt: [ + '请基于下面的帖子正文和评论串,起草一条适合直接发布的社区评论。', + '', + '写作要求:', + '- 先承接楼主的具体问题。', + '- 再基于已有上下文给出具体回复。', + '- 如果依据不足,只提出澄清问题、检查方向或谨慎补充。', + '- 如果包含多个细节,请使用 1. 2. 3. 或 - 列表。', + '- 控制篇幅,避免写成总结报告。', + '', + customInstruction + ? `用户额外指令:${customInstruction}\n注意:该指令只影响回复角度、语气或篇幅,不得作为事实依据。` + : '', + '', + buildAiContextText(context), + ].join('\n'), + }); + + const commentMarkdown = String(text || '').trim(); + const data = { + source: context.source, + draft: { + commentMarkdown, + commentText: commentMarkdown, + commentHtml: markdownToHtml(commentMarkdown), + }, + usage, + model, + }; + await saveCommunityAiPatch(context.source.postId, { draft: data }); + return data; +} + +export async function createCommunityPostComment(payload = {}, ctx = {}) { + const postRef = payload.postUrl || payload.postId; + if (!postRef) throw new Error('postUrl or postId is required'); + const postId = parseApiId(postRef, 'posts', 'postId'); + const body = String(payload.commentHtml || markdownToHtml(payload.commentText || '')).trim(); + if (!body) throw new Error('comment body is required'); + + await ensureSupportReady(payload, ctx); + const token = await getCsrfToken(ctx); + progress(ctx, `Posting comment to ${postId}...`); + const response = await fetch(`${SUPPORT_BASE}/api/v2/community/posts/${encodeURIComponent(postId)}/comments.json`, withCredentials({ + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-CSRF-Token': token, + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ + comment: { + body, + }, + }), + })); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + const detail = data?.error || data?.description || data?.message || response.statusText; + throw new Error(`Comment post failed (${response.status}): ${detail}`); + } + + const comment = normalizePostComment(data?.comment || data, postId); + await saveCommunityAiPatch(postId, { + lastPostedComment: { + comment, + body, + postedAt: new Date().toISOString(), + }, + }); + return { + postId, + comment, + response: data, + }; +} + async function getLikedIds() { const ids = await getLocalValue(LIKED_IDS_KEY); return Array.isArray(ids) ? ids : []; @@ -1834,6 +2291,18 @@ export async function runCommunityAction(action, payload = {}, ctx = {}) { return crawlCategoryFullAll(payload, ctx); case 'CLEAR_LIKED_IDS': return clearLikedIds(); + case 'LLM_CONFIG_GET': + return getLlmConfig(); + case 'LLM_CONFIG_SAVE': + return saveLlmConfig(payload.config); + case 'AI_SUMMARIZE_POST': + return summarizeCommunityPostWithAi(payload, ctx); + case 'AI_GET_CACHED_SUMMARY': + return getCachedCommunityAiSummary(payload); + case 'AI_DRAFT_COMMENT': + return draftCommunityPostCommentWithAi(payload, ctx); + case 'AI_POST_COMMENT': + return createCommunityPostComment(payload, ctx); default: throw new Error(`Unsupported community action: ${action}`); } diff --git a/src/content/support/communityAssistant.css b/src/content/support/communityAssistant.css new file mode 100644 index 0000000..6b5ed34 --- /dev/null +++ b/src/content/support/communityAssistant.css @@ -0,0 +1,417 @@ +#wqp-community-ai-prompt { + position: fixed; + right: 18px; + top: 18px; + z-index: 2147483600; + display: flex; + flex-direction: column; + box-sizing: border-box; + width: min(440px, calc(100vw - 36px)); + min-width: 300px; + min-height: 132px; + max-width: calc(100vw - 16px); + max-height: calc(100vh - 36px); + overflow: hidden; + overscroll-behavior: contain; + resize: both; + border: 1px solid rgba(15, 23, 42, 0.14); + border-radius: 8px; + background: #ffffff; + box-shadow: 0 14px 34px rgba(15, 23, 42, 0.2); + color: #111827; + font: 13px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +#wqp-community-ai-prompt.is-collapsed { + min-height: 0; + height: auto !important; + resize: none; +} + +#wqp-community-ai-prompt.is-dragging { + user-select: none; +} + +#wqp-community-ai-prompt * { + box-sizing: border-box; +} + +#wqp-community-ai-prompt .wqp-ai-card-head { + flex: 0 0 auto; + display: grid; + gap: 6px; + padding: 13px 14px 11px; + border-bottom: 1px solid #e5e7eb; + background: #f8fafc; + cursor: move; + user-select: none; +} + +#wqp-community-ai-prompt .wqp-ai-card-content { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + display: grid; + gap: 12px; + padding: 14px; +} + +#wqp-community-ai-prompt.is-collapsed .wqp-ai-card-head { + border-bottom: 0; +} + +#wqp-community-ai-prompt.is-collapsed .wqp-ai-card-content, +#wqp-community-ai-prompt.is-collapsed .wqp-ai-prompt-body { + display: none; +} + +#wqp-community-ai-prompt .wqp-ai-card-title-row, +#wqp-community-ai-prompt .wqp-ai-summary-head, +#wqp-community-ai-prompt .wqp-ai-meta { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +#wqp-community-ai-prompt .wqp-ai-card-title-row, +#wqp-community-ai-prompt .wqp-ai-summary-head { + justify-content: space-between; +} + +#wqp-community-ai-prompt .wqp-ai-window-actions { + display: flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; +} + +#wqp-community-ai-prompt .wqp-ai-prompt-title { + font-weight: 700; + line-height: 1.25; +} + +#wqp-community-ai-prompt .wqp-ai-prompt-body, +#wqp-community-ai-prompt .wqp-ai-source, +#wqp-community-ai-prompt .wqp-ai-muted { + color: #6b7280; +} + +#wqp-community-ai-prompt .wqp-ai-source { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + font-weight: 600; + color: #374151; +} + +#wqp-community-ai-prompt .wqp-ai-intro { + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #f9fafb; + color: #4b5563; + padding: 10px; +} + +#wqp-community-ai-prompt .wqp-ai-badge { + flex: 0 0 auto; + border: 1px solid #dbeafe; + border-radius: 999px; + background: #eff6ff; + color: #1d4ed8; + font-size: 11px; + font-weight: 700; + line-height: 1; + padding: 4px 7px; +} + +#wqp-community-ai-prompt .wqp-ai-badge.is-cached { + border-color: #bbf7d0; + background: #f0fdf4; + color: #15803d; +} + +#wqp-community-ai-prompt .wqp-ai-badge.is-fresh { + border-color: #fed7aa; + background: #fff7ed; + color: #c2410c; +} + +#wqp-community-ai-prompt .wqp-ai-meta { + flex-wrap: wrap; + color: #6b7280; + font-size: 12px; +} + +#wqp-community-ai-prompt .wqp-ai-meta span { + border-radius: 999px; + background: #f3f4f6; + padding: 3px 7px; +} + +#wqp-community-ai-prompt .wqp-ai-prompt-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +#wqp-community-ai-prompt button { + min-height: 32px; + border: 1px solid #d1d5db; + border-radius: 6px; + background: #ffffff; + color: #111827; + cursor: pointer; + font: inherit; + padding: 6px 10px; +} + +#wqp-community-ai-prompt .wqp-ai-window-button { + min-height: 24px; + border-radius: 999px; + font-size: 11px; + line-height: 1; + padding: 4px 7px; +} + +#wqp-community-ai-prompt button.wqp-ai-primary { + border-color: #2563eb; + background: #2563eb; + color: #ffffff; +} + +#wqp-community-ai-prompt button:hover { + filter: brightness(0.97); +} + +#wqp-community-ai-prompt h3, +#wqp-community-ai-prompt p { + margin: 0; +} + +#wqp-community-ai-prompt h3 { + font-size: 13px; + line-height: 1.3; + margin-bottom: 8px; +} + +#wqp-community-ai-prompt ul { + margin: 0; + padding-left: 18px; +} + +#wqp-community-ai-prompt li + li { + margin-top: 5px; +} + +#wqp-community-ai-prompt .wqp-ai-grid { + display: grid; + gap: 8px; +} + +#wqp-community-ai-prompt .wqp-ai-section, +#wqp-community-ai-prompt .wqp-ai-note, +#wqp-community-ai-prompt .wqp-ai-markdown, +#wqp-community-ai-prompt .wqp-ai-draft-preview, +#wqp-community-ai-prompt .wqp-ai-warning, +#wqp-community-ai-prompt .wqp-ai-error, +#wqp-community-ai-prompt .wqp-ai-success { + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #f9fafb; + padding: 10px; +} + +#wqp-community-ai-prompt details.wqp-ai-section { + padding: 0; + overflow: hidden; +} + +#wqp-community-ai-prompt details.wqp-ai-section summary { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 34px; + cursor: pointer; + font-weight: 700; + list-style: none; + padding: 9px 10px; +} + +#wqp-community-ai-prompt details.wqp-ai-section summary::-webkit-details-marker { + display: none; +} + +#wqp-community-ai-prompt details.wqp-ai-section summary::after { + content: "+"; + color: #6b7280; + font-size: 14px; + line-height: 1; +} + +#wqp-community-ai-prompt details.wqp-ai-section[open] summary { + border-bottom: 1px solid #e5e7eb; +} + +#wqp-community-ai-prompt details.wqp-ai-section[open] summary::after { + content: "-"; +} + +#wqp-community-ai-prompt details.wqp-ai-section ul, +#wqp-community-ai-prompt details.wqp-ai-section .wqp-ai-muted { + padding: 9px 12px 10px 26px; +} + +#wqp-community-ai-prompt details.wqp-ai-section .wqp-ai-muted { + display: block; + padding-left: 12px; +} + +#wqp-community-ai-prompt .wqp-ai-draft-preview { + display: grid; + gap: 8px; + background: #ffffff; +} + +#wqp-community-ai-prompt .wqp-ai-markdown, +#wqp-community-ai-prompt .wqp-ai-draft-preview { + color: #111827; +} + +#wqp-community-ai-prompt .wqp-ai-preview-title { + color: #374151; + font-weight: 700; +} + +#wqp-community-ai-prompt .wqp-ai-markdown h2, +#wqp-community-ai-prompt .wqp-ai-draft-preview h2, +#wqp-community-ai-prompt .wqp-ai-markdown h3, +#wqp-community-ai-prompt .wqp-ai-draft-preview h3, +#wqp-community-ai-prompt .wqp-ai-markdown h4, +#wqp-community-ai-prompt .wqp-ai-draft-preview h4 { + margin: 0; + color: #111827; + font-size: 14px; + line-height: 1.35; +} + +#wqp-community-ai-prompt .wqp-ai-markdown h2:not(:first-child), +#wqp-community-ai-prompt .wqp-ai-draft-preview h2:not(:first-child), +#wqp-community-ai-prompt .wqp-ai-markdown h3:not(:first-child), +#wqp-community-ai-prompt .wqp-ai-draft-preview h3:not(:first-child), +#wqp-community-ai-prompt .wqp-ai-markdown h4:not(:first-child), +#wqp-community-ai-prompt .wqp-ai-draft-preview h4:not(:first-child) { + margin-top: 10px; +} + +#wqp-community-ai-prompt .wqp-ai-markdown p, +#wqp-community-ai-prompt .wqp-ai-draft-preview p { + margin: 0; + color: #111827; +} + +#wqp-community-ai-prompt .wqp-ai-markdown p + p, +#wqp-community-ai-prompt .wqp-ai-markdown p + ul, +#wqp-community-ai-prompt .wqp-ai-markdown p + ol, +#wqp-community-ai-prompt .wqp-ai-markdown ul + p, +#wqp-community-ai-prompt .wqp-ai-markdown ol + p { + margin-top: 8px; +} + +#wqp-community-ai-prompt .wqp-ai-markdown ul, +#wqp-community-ai-prompt .wqp-ai-markdown ol, +#wqp-community-ai-prompt .wqp-ai-draft-preview ul, +#wqp-community-ai-prompt .wqp-ai-draft-preview ol { + margin: 0; + padding-left: 22px; +} + +#wqp-community-ai-prompt .wqp-ai-markdown li + li, +#wqp-community-ai-prompt .wqp-ai-draft-preview li + li, +#wqp-community-ai-prompt .wqp-ai-draft-preview p + p { + margin-top: 6px; +} + +#wqp-community-ai-prompt .wqp-ai-markdown code, +#wqp-community-ai-prompt .wqp-ai-draft-preview code { + border-radius: 4px; + background: #f3f4f6; + color: #111827; + font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + padding: 1px 4px; +} + +#wqp-community-ai-prompt .wqp-ai-field { + display: grid; + gap: 6px; + color: #374151; + font-weight: 600; +} + +#wqp-community-ai-prompt .wqp-ai-comment-box { + border-top: 1px solid #e5e7eb; + padding-top: 12px; +} + +#wqp-community-ai-prompt textarea { + width: 100%; + min-height: 72px; + resize: vertical; + border: 1px solid #d1d5db; + border-radius: 8px; + padding: 9px; + color: #111827; + font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-weight: 400; +} + +#wqp-community-ai-prompt .wqp-ai-loading { + padding: 18px 8px; + text-align: center; + color: #4b5563; +} + +#wqp-community-ai-prompt .wqp-ai-prompt-status { + color: #6b7280; + word-break: break-word; +} + +#wqp-community-ai-prompt .wqp-ai-prompt-status.error, +#wqp-community-ai-prompt .wqp-ai-error { + color: #991b1b; +} + +#wqp-community-ai-prompt .wqp-ai-prompt-status.success, +#wqp-community-ai-prompt .wqp-ai-success { + color: #166534; +} + +#wqp-community-ai-prompt .wqp-ai-prompt-status.loading { + color: #4b5563; +} + +#wqp-community-ai-prompt .wqp-ai-error { + border-color: #fecaca; + background: #fef2f2; +} + +#wqp-community-ai-prompt .wqp-ai-warning { + border-color: #fde68a; + background: #fffbeb; +} + +#wqp-community-ai-prompt .wqp-ai-success { + border-color: #bbf7d0; + background: #f0fdf4; +} + +@media (max-width: 720px) { + #wqp-community-ai-prompt { + right: 10px; + left: 10px; + top: 10px; + width: auto; + max-height: calc(100vh - 20px); + } +} diff --git a/src/content/support/communityAssistant.js b/src/content/support/communityAssistant.js new file mode 100644 index 0000000..5f5ed8b --- /dev/null +++ b/src/content/support/communityAssistant.js @@ -0,0 +1,454 @@ +(function () { + 'use strict'; + + if (window.__WQP_COMMUNITY_AI_ASSISTANT__) return; + window.__WQP_COMMUNITY_AI_ASSISTANT__ = true; + + const POST_URL_PATTERN = /^https:\/\/support\.worldquantbrain\.com\/hc\/[^/]+\/community\/posts\/\d+/; + if (!POST_URL_PATTERN.test(location.href)) return; + + const MIN_CARD_WIDTH = 300; + const MIN_CARD_HEIGHT = 132; + + let latestSummary = null; + let latestDraft = null; + let latestInstruction = ''; + let card = null; + + function sendMessage(type, payload = {}) { + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage({ type, ...payload }, (response) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + return; + } + if (!response?.ok) { + reject(new Error(response?.error || `Request failed: ${type}`)); + return; + } + resolve(response.data); + }); + }); + } + + function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function inlineMarkdownHtml(value) { + return escapeHtml(value) + .replace(/`([^`]+)`/g, '$1') + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/\*([^*]+)\*/g, '$1'); + } + + function markdownToHtml(value) { + const lines = String(value || '').replace(/\r\n/g, '\n').split('\n'); + const blocks = []; + let paragraph = []; + let listItems = []; + let listOrdered = false; + + const flushParagraph = () => { + if (!paragraph.length) return; + blocks.push(`

    ${paragraph.map(inlineMarkdownHtml).join('
    ')}

    `); + paragraph = []; + }; + const flushList = () => { + if (!listItems.length) return; + const tag = listOrdered ? 'ol' : 'ul'; + blocks.push(`<${tag}>${listItems.map((item) => `
  • ${inlineMarkdownHtml(item)}
  • `).join('')}`); + listItems = []; + }; + + lines.forEach((rawLine) => { + const line = rawLine.trim(); + if (!line) { + flushParagraph(); + flushList(); + return; + } + const heading = line.match(/^(#{1,4})\s+(.+)$/); + if (heading) { + flushParagraph(); + flushList(); + const level = Math.min(4, heading[1].length + 1); + blocks.push(`${inlineMarkdownHtml(heading[2])}`); + return; + } + const numbered = line.match(/^\d+[.)]\s+(.+)$/); + const bulleted = line.match(/^[-*+]\s+(.+)$/); + if (numbered || bulleted) { + flushParagraph(); + const isOrdered = Boolean(numbered); + if (listItems.length && listOrdered !== isOrdered) flushList(); + listOrdered = isOrdered; + listItems.push(numbered?.[1] || bulleted?.[1] || line); + return; + } + flushList(); + paragraph.push(line); + }); + + flushParagraph(); + flushList(); + return blocks.join('') || '

    No content.

    '; + } + + function formatDateTime(value) { + if (!value) return ''; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return String(value); + return date.toLocaleString(); + } + + function clamp(value, min, max) { + return Math.min(Math.max(value, min), max); + } + + function constrainCardToViewport() { + if (!card) return; + const rect = card.getBoundingClientRect(); + const maxLeft = Math.max(8, window.innerWidth - Math.min(rect.width, window.innerWidth - 16) - 8); + const maxTop = Math.max(8, window.innerHeight - Math.min(rect.height, window.innerHeight - 16) - 8); + card.style.left = `${clamp(rect.left, 8, maxLeft)}px`; + card.style.top = `${clamp(rect.top, 8, maxTop)}px`; + card.style.right = 'auto'; + } + + function setCollapsed(collapsed) { + if (!card) return; + card.classList.toggle('is-collapsed', collapsed); + const toggle = card.querySelector('[data-action="toggle-collapse"]'); + if (toggle) { + toggle.textContent = collapsed ? 'Expand' : 'Collapse'; + toggle.setAttribute('aria-label', collapsed ? 'Expand AI card' : 'Collapse AI card'); + } + if (!collapsed && !card.style.height) { + card.style.height = ''; + } + } + + function bindCardWindowInteractions() { + if (!card || card.dataset.boundWindow === 'true') return; + card.dataset.boundWindow = 'true'; + let dragState = null; + + card.addEventListener('pointerdown', (event) => { + const handle = event.target?.closest?.('.wqp-ai-card-head'); + if (!handle || event.target.closest('button, input, textarea, select, a, summary')) return; + const rect = card.getBoundingClientRect(); + dragState = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + }; + card.setPointerCapture?.(event.pointerId); + card.classList.add('is-dragging'); + event.preventDefault(); + }); + + card.addEventListener('pointermove', (event) => { + if (!dragState || event.pointerId !== dragState.pointerId) return; + const nextLeft = dragState.left + event.clientX - dragState.startX; + const nextTop = dragState.top + event.clientY - dragState.startY; + const maxLeft = Math.max(8, window.innerWidth - dragState.width - 8); + const maxTop = Math.max(8, window.innerHeight - dragState.height - 8); + card.style.left = `${clamp(nextLeft, 8, maxLeft)}px`; + card.style.top = `${clamp(nextTop, 8, maxTop)}px`; + card.style.right = 'auto'; + }); + + card.addEventListener('pointerup', (event) => { + if (!dragState || event.pointerId !== dragState.pointerId) return; + dragState = null; + card.releasePointerCapture?.(event.pointerId); + card.classList.remove('is-dragging'); + }); + + card.addEventListener('pointercancel', () => { + dragState = null; + card.classList.remove('is-dragging'); + }); + + window.addEventListener('resize', () => { + constrainCardToViewport(); + }); + } + + function ensureCard() { + if (card) return card; + if (!document.body) return null; + + card = document.createElement('div'); + card.id = 'wqp-community-ai-prompt'; + card.innerHTML = ` +
    +
    +
    AI forum assistant
    +
    + Enabled + +
    +
    +
    Summarize this post and its comments, then draft a reply when needed.
    +
    +
    +
    No AI call is made until you request a summary.
    +
    + +
    +
    +
    + `; + card.addEventListener('click', handleCardClick); + card.addEventListener('input', handleCardInput); + document.body.appendChild(card); + bindCardWindowInteractions(); + return card; + } + + function setCardContent(html) { + ensureCard(); + const content = card?.querySelector('.wqp-ai-card-content'); + if (content) content.innerHTML = html; + } + + function setCardStatus(text, mode = '') { + ensureCard(); + const status = card?.querySelector('.wqp-ai-prompt-status'); + if (!status) return; + status.textContent = text || ''; + status.className = `wqp-ai-prompt-status${mode ? ` ${mode}` : ''}`; + } + + function setCardLoading(text) { + setCardContent(` +
    ${escapeHtml(text)}
    +
    ${escapeHtml(location.href)}
    + `); + } + + function setCardError(error) { + setCardContent(` +
    + Action failed +

    ${escapeHtml(error.message || String(error))}

    +
    +
    + +
    + `); + } + + async function showCardIfEnabled() { + try { + const config = await sendMessage('WQP_LLM_CONFIG_GET'); + if (config?.enabled === true) { + ensureCard(); + await loadCachedSummary(); + } + } catch (error) { + console.warn('[WQP AI] Unable to read AI settings:', error); + } + } + + async function loadCachedSummary() { + try { + const data = await sendMessage('WQP_COMMUNITY_AI_GET_CACHED_SUMMARY', { postUrl: location.href }); + if (data?.summaryMarkdown) { + renderSummary(data); + } + } catch (error) { + console.warn('[WQP AI] Unable to load cached summary:', error); + } + } + + function renderSummary(data) { + latestSummary = data; + latestDraft = null; + const source = data.source || {}; + const commentCount = `${source.commentCount || 0}/${source.totalCommentCount || 0}`; + const cacheTime = formatDateTime(data.cache?.savedAt || source.fetchedAt); + const statusText = data.cached ? 'Loaded cached summary.' : 'Summary generated and saved.'; + const markdown = data.summaryMarkdown || ''; + setCardContent(` +
    +
    ${escapeHtml(source.title || 'Support post')}
    + ${data.cached ? 'Cached' : 'Generated'} +
    +
    + Comments ${escapeHtml(commentCount)} + ${cacheTime ? `${escapeHtml(cacheTime)}` : ''} +
    +
    + ${markdownToHtml(markdown)} +
    + +
    + + +
    +
    ${escapeHtml(statusText)}
    + `); + } + + function renderDraft(data) { + latestDraft = data; + const draft = data.draft || {}; + const markdown = draft.commentMarkdown || draft.commentText || ''; + setCardContent(` +
    +
    ${escapeHtml(latestSummary?.source?.title || 'Support post')}
    + Draft +
    +
    +
    Markdown preview
    + ${markdownToHtml(markdown)} +
    + +
    + + + + +
    +
    Comment draft ready.
    + `); + } + + async function runSummary(forceRefresh = false) { + try { + setCardLoading(forceRefresh ? 'Refreshing summary with AI...' : 'Checking saved summary...'); + const data = await sendMessage('WQP_COMMUNITY_AI_SUMMARIZE_POST', { + postUrl: location.href, + forceRefresh, + }); + renderSummary(data); + } catch (error) { + setCardError(error); + } + } + + async function runDraft() { + try { + const instructionInput = document.getElementById('wqp-ai-comment-instruction'); + const instruction = instructionInput ? instructionInput.value : latestInstruction; + latestInstruction = instruction; + setCardStatus('Generating comment draft...', 'loading'); + const data = await sendMessage('WQP_COMMUNITY_AI_DRAFT_COMMENT', { + postUrl: latestSummary?.source?.postUrl || location.href, + customInstruction: instruction, + }); + renderDraft(data); + } catch (error) { + setCardStatus(error.message || String(error), 'error'); + } + } + + function setNativeInputValue(element, value) { + const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(element), 'value')?.set; + if (setter) setter.call(element, value); + else element.value = value; + element.dispatchEvent(new Event('input', { bubbles: true })); + element.dispatchEvent(new Event('change', { bubbles: true })); + } + + function insertDraftIntoPage() { + const text = document.getElementById('wqp-ai-comment-draft')?.value?.trim(); + if (!text) { + setCardStatus('No comment draft to insert.', 'error'); + return false; + } + const editor = document.querySelector('textarea[name="body"], textarea#comment_body, .comment-form textarea, [contenteditable="true"], .ck-editor__editable, trix-editor'); + if (!editor) { + setCardStatus('Comment editor was not found on this page.', 'error'); + return false; + } + editor.scrollIntoView({ behavior: 'smooth', block: 'center' }); + editor.focus(); + if (editor.tagName === 'TEXTAREA' || editor.tagName === 'INPUT') { + setNativeInputValue(editor, text); + } else if (editor.tagName === 'TRIX-EDITOR' && editor.editor) { + editor.editor.loadHTML(markdownToHtml(text)); + } else { + editor.innerHTML = markdownToHtml(text); + editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: text })); + } + setCardStatus('Draft inserted into the page editor.', 'success'); + return true; + } + + async function postDraft() { + const text = document.getElementById('wqp-ai-comment-draft')?.value?.trim(); + if (!text) { + setCardStatus('No comment draft to post.', 'error'); + return; + } + if (!confirm(`Post this AI comment to the current Support thread?\n\n${text}`)) return; + try { + setCardLoading('Posting comment...'); + const data = await sendMessage('WQP_COMMUNITY_AI_POST_COMMENT', { + postUrl: latestDraft?.source?.postUrl || location.href, + commentText: text, + }); + setCardContent(` +
    + Comment posted. +

    ${escapeHtml(data.comment?.url || '')}

    +
    +
    + +
    + `); + } catch (error) { + setCardError(error); + } + } + + function handleCardClick(event) { + const action = event.target?.dataset?.action; + if (!action) return; + if (action === 'toggle-collapse') { + setCollapsed(!card?.classList.contains('is-collapsed')); + return; + } + if (action === 'summarize') runSummary(false); + if (action === 'refresh-summary') runSummary(true); + if (action === 'show-summary' && latestSummary) renderSummary(latestSummary); + if (action === 'draft') runDraft(); + if (action === 'insert') insertDraftIntoPage(); + if (action === 'post') postDraft(); + } + + function handleCardInput(event) { + if (event.target?.id !== 'wqp-ai-comment-draft') return; + const preview = document.getElementById('wqp-ai-comment-preview'); + if (!preview) return; + preview.innerHTML = ` +
    Markdown preview
    + ${markdownToHtml(event.target.value)} + `; + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', showCardIfEnabled, { once: true }); + } else { + showCardIfEnabled(); + } +})(); diff --git a/src/ui/sidebar/modules/settingsPanel.js b/src/ui/sidebar/modules/settingsPanel.js index ad216bc..347aa6c 100644 --- a/src/ui/sidebar/modules/settingsPanel.js +++ b/src/ui/sidebar/modules/settingsPanel.js @@ -7,9 +7,15 @@ const ids = { geniusCombineTag: 'geniusCombineTag', geniusAlphaCount: 'geniusAlphaCount', apiMonitorEnabled: 'apiMonitorEnabled', + llmEnabled: 'llmEnabled', + llmBaseUrl: 'llmBaseUrl', + llmModel: 'llmModel', + llmApiKey: 'llmApiKey', save: 'saveSettingsBtn', }; +let hasSavedLlmApiKey = false; + function readSettingsFromForm() { return { dataAnalysisEnabled: document.getElementById(ids.dataAnalysis).checked, @@ -26,6 +32,43 @@ function writeSettingsToForm(settings) { document.getElementById(ids.apiMonitorEnabled).checked = settings.apiMonitorEnabled === true; } +function readLlmConfigFromForm() { + const rawApiKey = document.getElementById(ids.llmApiKey).value; + const apiKey = rawApiKey === '********' ? '' : rawApiKey; + return { + enabled: document.getElementById(ids.llmEnabled).checked, + baseUrl: document.getElementById(ids.llmBaseUrl).value.trim(), + model: document.getElementById(ids.llmModel).value.trim(), + apiKey, + keepExistingApiKey: (!apiKey || rawApiKey === '********') && hasSavedLlmApiKey, + }; +} + +function writeLlmConfigToForm(config = {}) { + document.getElementById(ids.llmEnabled).checked = config.enabled === true; + document.getElementById(ids.llmBaseUrl).value = config.baseUrl || ''; + document.getElementById(ids.llmModel).value = config.model || ''; + const apiKeyInput = document.getElementById(ids.llmApiKey); + hasSavedLlmApiKey = config.hasApiKey === true; + apiKeyInput.value = hasSavedLlmApiKey ? '********' : ''; + apiKeyInput.placeholder = hasSavedLlmApiKey ? 'Saved API key' : 'Required unless your endpoint does not need it'; +} + +function bindLlmApiKeyPlaceholder() { + const apiKeyInput = document.getElementById(ids.llmApiKey); + if (!apiKeyInput) return; + apiKeyInput.addEventListener('focus', () => { + if (apiKeyInput.value === '********') { + apiKeyInput.value = ''; + } + }); + apiKeyInput.addEventListener('blur', () => { + if (!apiKeyInput.value && hasSavedLlmApiKey) { + apiKeyInput.value = '********'; + } + }); +} + function setDataMeta(text) { const el = document.getElementById('dataMeta'); if (el) el.textContent = text || ''; @@ -75,11 +118,14 @@ export async function initSettingsPanel() { const saveBtn = document.getElementById(ids.save); const importDataZipBtn = document.getElementById('importDataZipBtn'); const importDataZipFile = document.getElementById('importDataZipFile'); + bindLlmApiKeyPlaceholder(); setStatus('加载设置...'); try { const settings = await sendMessage('WQP_SETTINGS_GET'); writeSettingsToForm(settings || {}); + const llmConfig = await sendMessage('WQP_LLM_CONFIG_GET'); + writeLlmConfigToForm(llmConfig || {}); setStatus(''); } catch (error) { setStatus(`设置加载失败:${error.message}`, 'error'); @@ -115,9 +161,15 @@ export async function initSettingsPanel() { event.preventDefault(); saveBtn.disabled = true; const settings = readSettingsFromForm(); + const llmConfig = readLlmConfigFromForm(); try { await sendMessage('WQP_SETTINGS_SAVE', { settings }); - setStatus('设置已保存。', 'success'); + if (llmConfig.enabled) { + setStatus('Testing AI model connection...'); + } + const savedLlmConfig = await sendMessage('WQP_LLM_CONFIG_SAVE', { config: llmConfig }); + writeLlmConfigToForm(savedLlmConfig || {}); + setStatus(llmConfig.enabled ? 'Settings saved. AI model connection test passed.' : 'Settings saved. AI is disabled.', 'success'); } catch (error) { setStatus(`保存失败:${error.message}`, 'error'); } finally { diff --git a/src/ui/sidebar/sidebar.html b/src/ui/sidebar/sidebar.html index b7d3556..cc3819b 100644 --- a/src/ui/sidebar/sidebar.html +++ b/src/ui/sidebar/sidebar.html @@ -49,6 +49,24 @@

    WorldQuant Scope

    +
    AI Forum Assistant
    + + + + +
    Data 文件