// ==UserScript== // @name Linux DO AI 回复草稿 // @namespace local.linuxdo.ai-reply // @version 0.1.0 // @description 在 Linux DO 回复框中基于上下文生成中文论坛口吻回复草稿;只填入草稿,不自动发送。 // @author Codex // @match https://linux.do/* // @match https://*.linux.do/* // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @grant GM_xmlhttpRequest // @connect * // @downloadURL https://update.greasyfork.icu/scripts/575851/Linux%20DO%20AI%20%E5%9B%9E%E5%A4%8D%E8%8D%89%E7%A8%BF.user.js // @updateURL https://update.greasyfork.icu/scripts/575851/Linux%20DO%20AI%20%E5%9B%9E%E5%A4%8D%E8%8D%89%E7%A8%BF.meta.js // ==/UserScript== (function () { 'use strict'; const CONFIG_KEY = 'linuxdoAiReplyConfig'; const DEFAULT_CONFIG = { endpoint: 'https://api.openai.com/v1/chat/completions', apiKey: '', model: 'gpt-4.1-mini', temperature: 0.7, maxContextPosts: 6, maxOutputTokens: '', minReplyChars: 24, maxReplyChars: 140, style: '中文论坛口吻,自然、具体、不油腻,不过度客套,像真实用户在认真参与讨论。', voiceExamples: '', customSystemPrompt: '', extraRequestJson: '', disableThinking: false, includeSelectedText: true }; const state = { lastReplyTarget: null, lastReplyTargetSource: '', lastReplyTargetAt: 0, observerStarted: false }; registerMenus(); injectStyles(); start(); function start() { trackReplyClicks(); injectAiButton(); if (!state.observerStarted) { const observer = new MutationObserver(() => injectAiButton()); observer.observe(document.documentElement, { childList: true, subtree: true }); state.observerStarted = true; } } function registerMenus() { if (typeof GM_registerMenuCommand !== 'function') return; GM_registerMenuCommand('配置 Linux DO AI 回复', openConfigDialog); GM_registerMenuCommand('清空 Linux DO AI 回复密钥', () => { const config = loadConfig(); config.apiKey = ''; saveConfig(config); showToast('已清空 API Key'); }); } function loadConfig() { const stored = safeGetValue(CONFIG_KEY, {}); const config = { ...DEFAULT_CONFIG, ...(stored || {}) }; if (stored && !stored.maxOutputTokensMode && Number(config.maxOutputTokens) === 1200) { config.maxOutputTokens = ''; } return config; } function saveConfig(config) { GM_setValue(CONFIG_KEY, { endpoint: String(config.endpoint || '').trim(), apiKey: String(config.apiKey || '').trim(), model: String(config.model || '').trim(), temperature: clampNumber(config.temperature, 0, 2, DEFAULT_CONFIG.temperature), maxContextPosts: clampInteger(config.maxContextPosts, 1, 12, DEFAULT_CONFIG.maxContextPosts), maxOutputTokens: normalizeOptionalInteger(config.maxOutputTokens, 0, 32768), maxOutputTokensMode: 'manual', minReplyChars: clampInteger(config.minReplyChars, 21, 80, DEFAULT_CONFIG.minReplyChars), maxReplyChars: clampInteger(config.maxReplyChars, 60, 400, DEFAULT_CONFIG.maxReplyChars), style: String(config.style || DEFAULT_CONFIG.style).trim(), voiceExamples: String(config.voiceExamples || '').trim(), customSystemPrompt: String(config.customSystemPrompt || '').trim(), extraRequestJson: String(config.extraRequestJson || '').trim(), disableThinking: Boolean(config.disableThinking), includeSelectedText: Boolean(config.includeSelectedText) }); } function safeGetValue(key, fallback) { try { return GM_getValue(key, fallback); } catch (error) { console.warn('[Linux DO AI Reply] 读取配置失败', error); return fallback; } } function clampNumber(value, min, max, fallback) { const number = Number(value); if (!Number.isFinite(number)) return fallback; return Math.min(max, Math.max(min, number)); } function clampInteger(value, min, max, fallback) { return Math.round(clampNumber(value, min, max, fallback)); } function normalizeOptionalInteger(value, min, max) { const text = String(value ?? '').trim(); if (!text || text === '0') return ''; const number = Number(text); if (!Number.isFinite(number)) return ''; return Math.round(Math.min(max, Math.max(min, number))); } function trackReplyClicks() { document.addEventListener( 'click', (event) => { const trigger = event.target.closest('button, a, .btn, [role="button"]'); if (!trigger) return; if (trigger.closest('#reply-control')) return; if (trigger.closest('.linuxdo-ai-reply-btn, .linuxdo-ai-context-btn')) return; const label = normalizeText( [ trigger.textContent, trigger.getAttribute('title'), trigger.getAttribute('aria-label') ].join(' ') ); const looksLikeReply = /reply|回复|回帖|respond|quote|引用/i.test(label); if (!looksLikeReply) return; const postElement = trigger.closest('.topic-post, article[data-post-id], [data-post-id]'); if (postElement) { state.lastReplyTarget = getPostData(postElement); state.lastReplyTargetSource = '点击楼层回复按钮'; } else { state.lastReplyTarget = { kind: 'topic', postNumber: 1, author: '', text: '' }; state.lastReplyTargetSource = '点击主题回复按钮'; } state.lastReplyTargetAt = Date.now(); }, true ); } function injectAiButton() { const composer = getComposer(); if (!composer) return; const target = composer.querySelector('.submit-panel') || composer.querySelector('.d-editor-button-bar') || composer.querySelector('.reply-area') || composer; if (!composer.querySelector('.linuxdo-ai-context-btn')) { const previewButton = document.createElement('button'); previewButton.type = 'button'; previewButton.className = 'btn btn-default linuxdo-ai-context-btn'; previewButton.textContent = '预览上下文'; previewButton.addEventListener('click', handlePreviewContext); target.appendChild(previewButton); } if (!composer.querySelector('.linuxdo-ai-reply-btn')) { const button = document.createElement('button'); button.type = 'button'; button.className = 'btn btn-default linuxdo-ai-reply-btn'; button.textContent = 'AI 生成回复'; button.addEventListener('click', () => handleGenerate(button)); target.appendChild(button); } } function getComposer() { const composer = document.querySelector('#reply-control'); if (!composer) return null; if (composer.classList.contains('closed') || composer.classList.contains('hidden')) return null; return composer; } async function handleGenerate(button) { const config = loadConfig(); const validation = validateConfig(config); if (validation) { openConfigDialog(validation); return; } const editor = getReplyEditor(); if (!editor) { showToast('没有找到回复输入框,请先打开回复框', 'error'); return; } const context = await collectContext(config); if (!context.title && !context.posts.length && !context.draftText) { showToast('没有读取到帖子上下文,请刷新页面后重试', 'error'); return; } setButtonBusy(button, true, '生成中...'); showToast(context.draftText ? '正在基于你的草稿润色' : '正在读取上下文并生成草稿'); try { const reply = await generateReply(config, context); setEditorValue(editor, reply); showToast(`已填入 AI 草稿,当前 ${reply.length} 个字符,请审核后手动发送`); } catch (error) { console.error('[Linux DO AI Reply] 生成失败', error); showToast(error.message || '生成失败,请检查配置', 'error'); } finally { setButtonBusy(button, false, 'AI 生成回复'); } } async function handlePreviewContext() { const config = loadConfig(); const context = await collectContext(config); if (!context.title && !context.posts.length && !context.draftText) { showToast('没有读取到帖子上下文,请刷新页面后重试', 'error'); return; } openContextPreviewDialog(config, context); } function validateConfig(config) { if (!config.endpoint) return '请先配置 API 地址'; if (!config.model) return '请先配置模型名称'; if (!config.apiKey) return '请先配置 API Key'; return ''; } async function collectContext(config) { const posts = getAllPosts(); const mainPost = getMainPost(posts) || (await fetchMainPostFromTopicJson()); const selected = config.includeSelectedText ? getSelectedPostContext() : null; const targetResult = resolveReplyTarget(posts, selected, mainPost); const target = targetResult.target; const contextPosts = pickContextPosts(posts, mainPost, target, config.maxContextPosts); return { url: location.href, title: getTopicTitle(), target, targetSource: targetResult.source, selectedText: selected ? selected.selectedText : '', draftText: getCurrentDraftText(), posts: contextPosts }; } function resolveReplyTarget(posts, selected, mainPost) { const composerTargetNumber = getComposerReplyTargetNumber(); if (composerTargetNumber) { const post = posts.find((item) => samePostNumber(item.postNumber, composerTargetNumber)); return { target: post || (samePostNumber(composerTargetNumber, 1) ? createTopicTarget(mainPost) : createPostPlaceholder(composerTargetNumber)), source: `回复框数据:#${composerTargetNumber}` }; } if (isComposerShowingTopicReply()) { return { target: createTopicTarget(mainPost), source: '回复框标题显示为主题回复' }; } const targetStillFresh = Date.now() - state.lastReplyTargetAt < 90 * 1000; if (targetStillFresh && state.lastReplyTarget) { return { target: hydrateTarget(state.lastReplyTarget, posts, mainPost), source: state.lastReplyTargetSource || '最近点击的回复按钮' }; } if (selected && selected.post) { return { target: selected.post, source: '当前选中文本所在楼层' }; } return { target: createTopicTarget(mainPost), source: '默认主楼' }; } function getMainPost(posts) { return posts.find((post) => samePostNumber(post.postNumber, 1)) || null; } function createTopicTarget(mainPost) { if (mainPost) return mainPost; return { kind: 'topic', postNumber: '1', author: '', text: '' }; } function createPostPlaceholder(postNumber) { return { kind: 'post', postNumber: String(postNumber || ''), author: '', text: '' }; } function getComposerReplyTargetNumber() { const composer = getComposer(); if (!composer) return ''; const numberElement = composer.querySelector( [ '[data-reply-to-post-number]', '[reply-to-post-number]', 'input[name="reply_to_post_number"]' ].join(',') ); const number = numberElement?.getAttribute('data-reply-to-post-number') || numberElement?.getAttribute('reply-to-post-number') || numberElement?.value || ''; return extractPostNumber(number) || normalizeText(number).match(/^\d+$/)?.[0] || ''; } function isComposerShowingTopicReply() { const topicTitle = getTopicTitle(); const composerText = getComposerActionText(); if (!topicTitle || !composerText) return false; const compactTitle = compactForCompare(topicTitle); const compactComposer = compactForCompare(composerText); if (!compactTitle || !compactComposer.includes(compactTitle)) return false; return !/(@\S+|#\d+|第\s*\d+\s*楼|replying to|回复\s*@)/i.test(composerText); } function getComposerActionText() { const composer = getComposer(); if (!composer) return ''; const selectors = [ '.composer-action-title', '.action-title', '.reply-to', '.composer-fields', '.title-input' ]; for (const selector of selectors) { const element = composer.querySelector(selector); const text = normalizeText(element && element.textContent); if (text) return text; } return ''; } function compactForCompare(text) { return normalizeText(text).replace(/\s+/g, '').toLowerCase(); } function hydrateTarget(target, posts, mainPost) { if (!target || target.kind === 'topic' || samePostNumber(target.postNumber, 1)) { return createTopicTarget(mainPost); } const samePost = posts.find((post) => samePostNumber(post.postNumber, target.postNumber)); return samePost || target || createTopicTarget(mainPost); } function pickContextPosts(posts, mainPost, target, maxPosts) { if (!posts.length && !mainPost) return []; const picked = []; const add = (post, reason) => { if (!post || !post.text) return; const key = String(post.postNumber || `${post.author}-${post.text.slice(0, 24)}`); if (picked.some((item) => item.key === key)) return; picked.push({ key, reason, post }); }; add(mainPost, '主楼'); if (target) { const targetIndex = posts.findIndex((post) => samePostNumber(post.postNumber, target.postNumber)); if (targetIndex >= 0) { add(posts[targetIndex - 2], '目标回复前文'); add(posts[targetIndex - 1], '目标回复前文'); add(posts[targetIndex], targetIndex === 0 ? '主楼' : '正在回复的楼层'); } else { add(target, '正在回复的楼层'); } } posts.slice(-maxPosts).forEach((post) => add(post, '最近回复')); return picked .slice(0, Math.max(1, maxPosts + 2)) .map((item) => ({ ...item.post, reason: item.reason })); } function samePostNumber(left, right) { if (left == null || right == null) return false; return String(left) === String(right); } function getTopicTitle() { const selectors = [ '#topic-title h1', '.title-wrapper h1', '.topic-title h1', 'h1 a.fancy-title', 'h1' ]; for (const selector of selectors) { const element = document.querySelector(selector); const text = normalizeText(element && element.textContent); if (text) return text; } return normalizeText(document.title.replace(/ - Linux DO.*$/i, '')); } function getAllPosts() { const candidates = Array.from( document.querySelectorAll('.topic-post, article[data-post-id], .post-stream [data-post-id]') ); const seen = new Set(); return candidates .filter((element) => { const key = element.getAttribute('data-post-id') || element.id || element.textContent.slice(0, 40); if (seen.has(key)) return false; seen.add(key); return true; }) .map(getPostData) .filter((post) => post.text); } async function fetchMainPostFromTopicJson() { const url = getTopicJsonUrl(); if (!url) return null; try { const response = await fetch(url, { credentials: 'same-origin', headers: { Accept: 'application/json' } }); if (!response.ok) return null; const data = await response.json(); const post = (data?.post_stream?.posts || []).find((item) => samePostNumber(item.post_number, 1)); if (!post) return null; return { kind: 'post', postNumber: '1', author: normalizeText(post.username || post.name || ''), text: limitText(htmlToText(post.cooked || post.raw || ''), 900) }; } catch (error) { console.warn('[Linux DO AI Reply] 读取主楼 JSON 失败', error); return null; } } function getTopicJsonUrl() { const canonical = document.querySelector('link[rel="canonical"]')?.href || ''; const source = canonical || location.href; try { const url = new URL(source, location.href); url.hash = ''; url.search = ''; url.pathname = url.pathname.replace(/\/+$/, ''); url.pathname = url.pathname.replace(/(\/t\/.+\/\d+)\/\d+$/i, '$1'); if (!/\/t\//i.test(url.pathname)) return ''; if (!url.pathname.endsWith('.json')) url.pathname += '.json'; return url.href; } catch (error) { return ''; } } function htmlToText(html) { const element = document.createElement('div'); element.innerHTML = String(html || ''); return extractCleanText(element); } function getPostData(element) { const postNumber = element.getAttribute('data-post-number') || element.querySelector('[data-post-number]')?.getAttribute('data-post-number') || extractPostNumber(element.id) || extractPostNumber(element.querySelector('.post-info, .post-date, a[href*="/"]')?.getAttribute('href') || ''); const authorElement = element.querySelector( '.topic-meta-data .username, .names .username, .trigger-user-card, [data-user-card], .creator .username' ); const contentElement = element.querySelector('.cooked') || element.querySelector('.topic-body .contents') || element.querySelector('.regular.contents') || element; return { kind: 'post', postNumber: postNumber || '', author: normalizeText(authorElement && authorElement.textContent), text: limitText(extractCleanText(contentElement), 900) }; } function extractPostNumber(value) { const match = String(value || '').match(/(?:post_|\/)(\d+)(?:\D*$|$)/); return match ? match[1] : ''; } function extractCleanText(element) { if (!element) return ''; const clone = element.cloneNode(true); clone .querySelectorAll( [ 'script', 'style', 'noscript', '.quote', 'aside.quote', '.post-menu-area', '.topic-map', '.embedded-posts', '.onebox', '.small-action', '.actions', '.post-controls', '.topic-avatar' ].join(',') ) .forEach((node) => node.remove()); return normalizeText(clone.textContent); } function getSelectedPostContext() { const selection = window.getSelection(); const selectedText = normalizeText(selection && selection.toString()); if (!selectedText) return null; const node = selection.anchorNode; const element = node && (node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement); const postElement = element && element.closest('.topic-post, article[data-post-id], [data-post-id]'); return { selectedText: limitText(selectedText, 500), post: postElement ? getPostData(postElement) : null }; } function getCurrentDraftText() { const editor = getReplyEditor(); return editor ? limitText(getEditorValue(editor).trim(), 500) : ''; } function getReplyEditor() { const composer = getComposer() || document; return ( composer.querySelector('textarea.d-editor-input') || composer.querySelector('textarea') || composer.querySelector('[contenteditable="true"]') ); } function getEditorValue(editor) { if (!editor) return ''; if ('value' in editor) return editor.value || ''; return editor.textContent || ''; } function setEditorValue(editor, value) { editor.focus(); if ('value' in editor) { editor.value = value; editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: value })); editor.dispatchEvent(new Event('change', { bubbles: true })); return; } editor.textContent = value; editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: value })); } async function generateReply(config, context) { const messages = [ { role: 'system', content: buildSystemPrompt(config) }, { role: 'user', content: buildUserPrompt(config, context) } ]; const payload = buildRequestPayload(config, messages); const data = await postJson(config.endpoint, payload, { Authorization: `Bearer ${config.apiKey}` }); const raw = extractAssistantText(data); if (!raw) { throw new Error(`AI 没有返回可用内容。${summarizeResponseShape(data)}`); } const reply = normalizeReply(raw, config, Boolean(context.draftText)); if (reply.length < config.minReplyChars) { throw new Error(`生成结果只有 ${reply.length} 个字符,低于设置的最小长度,请重试`); } return reply; } function buildRequestPayload(config, messages) { const payload = { model: config.model, messages, temperature: config.temperature }; if (config.maxOutputTokens) { payload.max_tokens = Number(config.maxOutputTokens); } if (config.disableThinking) { payload.enable_thinking = false; payload.chat_template_kwargs = { enable_thinking: false }; } const extra = parseExtraRequestJson(config.extraRequestJson); return { ...payload, ...extra }; } function parseExtraRequestJson(value) { const text = String(value || '').trim(); if (!text) return {}; try { const parsed = JSON.parse(text); if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') { throw new Error('额外请求参数必须是 JSON 对象'); } return parsed; } catch (error) { throw new Error(`额外请求参数 JSON 格式不正确:${error.message}`); } } function buildSystemPrompt(config) { const extra = config.customSystemPrompt ? `\n\n额外要求:\n${config.customSystemPrompt}` : ''; const voice = config.voiceExamples ? `\n\n用户个人语气样例:\n${config.voiceExamples}\n\n只学习这些样例的语气、句长、用词习惯和克制程度,不要照抄样例里的事实。` : ''; return [ '你是 Linux DO 论坛用户的回复草稿助手。', '你的任务是根据回复目标和用户草稿写一条中文论坛回复草稿,由用户审核后手动发送。', '要求:', `- 风格:${config.style}`, `- 回复长度必须不少于 ${config.minReplyChars} 个中文字符,尽量不超过 ${config.maxReplyChars} 个字符。`, '- 回复必须聚焦“回复目标”,附近楼层只用于理解讨论背景和避免重复,不要把附近楼层当作主要回复对象。', '- 不要引用、复述或模仿附近楼层的说法,除非用户草稿本身已经明确提到。', '- 如果回复框已有草稿,必须以用户草稿为准,只润色、补全和稍微扩展到合格长度,不要重写成另一个观点。', '- 必须结合回复目标,避免“感谢分享”“学习了”这类空泛灌水。', '- 如果信息不足,可以提出一个具体问题或给出一个谨慎看法。', '- 不要自称 AI,不要提到提示词、接口、规则或自动生成。', '- 不要输出 Markdown 标题、编号列表、代码块或解释说明。', '- 只输出回复正文。' ].join('\n') + voice + extra; } function buildUserPrompt(config, context) { const targetLabel = formatTargetLabel(context.target); const mode = getPromptMode(context); const targetText = context.target?.text ? `\n\n主要回复目标正文:\n${context.target.text}` : '\n\n主要回复目标正文:当前未读取到正文,请只根据主题标题和用户草稿谨慎处理。'; const selected = context.selectedText ? `\n\n用户当前选中的文本:\n${context.selectedText}` : ''; const draft = context.draftText ? `\n\n回复框已有草稿(这是用户自己写的,必须保留原意并在此基础上润色):\n${context.draftText}` : ''; const posts = context.posts .map((post, index) => { const floor = post.postNumber ? `#${post.postNumber}` : `上下文${index + 1}`; const author = post.author ? ` by ${post.author}` : ''; return `[${post.reason}] ${floor}${author}\n${post.text}`; }) .join('\n\n'); return [ `页面:${context.url}`, `主题标题:${context.title || '未读取到'}`, `任务模式:${mode}`, `回复目标:${targetLabel}`, targetText, selected, draft, '', '背景上下文(只用于了解附近讨论,不是主要回复对象,不要模仿或引用):', posts || '未读取到帖子正文', '', context.draftText ? `请把“回复框已有草稿”润色成一条适合直接填入 Linux DO 回复框的中文回复。保留用户原意,长度至少 ${config.minReplyChars} 个字符;如果草稿本身较长,不要为了最多字符限制而删掉关键信息。` : `请围绕“主要回复目标”写一条适合直接填入 Linux DO 回复框的中文回复草稿。长度至少 ${config.minReplyChars} 个字符。` ].join('\n'); } function getPromptMode(context) { return context.draftText ? '润色已有草稿' : '围绕回复目标生成新回复'; } function formatTargetLabel(target) { if (!target) return '主题整体'; if (target.kind === 'topic') { return target.text ? '主楼' : '主题整体(主楼正文当前未加载)'; } if (String(target.postNumber) === '1') { if (!target.text) return '主题整体(主楼正文当前未加载)'; return target.author ? `主楼,作者 ${target.author}` : '主楼'; } const author = target.author ? `,作者 ${target.author}` : ''; const excerpt = target.text ? `,内容摘要:${limitText(target.text, 120)}` : ''; return `第 ${target.postNumber || '?'} 楼${author}${excerpt}`; } function postJson(url, payload, extraHeaders) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url, headers: { 'Content-Type': 'application/json', ...extraHeaders }, data: JSON.stringify(payload), timeout: 60000, onload: (response) => { const text = response.responseText || ''; let json = null; try { json = text ? JSON.parse(text) : null; } catch (error) { reject(new Error(`接口返回不是 JSON:HTTP ${response.status}`)); return; } if (response.status < 200 || response.status >= 300) { const message = json?.error?.message || json?.message || text.slice(0, 160) || `HTTP ${response.status}`; reject(new Error(`接口请求失败:${message}`)); return; } resolve(json); }, onerror: () => reject(new Error('接口请求失败,请检查网络、API 地址或 @connect 权限')), ontimeout: () => reject(new Error('接口请求超时')) }); }); } function extractAssistantText(data) { const knownValues = [ data?.choices?.[0]?.message?.content, data?.choices?.[0]?.delta?.content, data?.choices?.[0]?.text, data?.output_text, data?.message?.content, data?.response, data?.candidates?.[0]?.content?.parts, data?.content, data?.output?.[0]?.content, data?.output?.[1]?.content ]; for (const value of knownValues) { const text = extractTextParts(value); if (text) return text; } const outputs = Array.isArray(data?.output) ? data.output : []; for (const output of outputs) { const text = extractTextParts(output?.content ?? output?.text); if (text) return text; } return ''; } function extractTextParts(value) { if (!value) return ''; if (typeof value === 'string') { return value.trim(); } if (Array.isArray(value)) { return value .map(extractTextParts) .filter(Boolean) .join('') .trim(); } if (typeof value === 'object') { const directText = value.text || value.content || value.output_text || value.value || value.data; if (typeof directText === 'string') return directText.trim(); if (Array.isArray(directText)) return extractTextParts(directText); if (value.type === 'output_text' && typeof value.text === 'string') { return value.text.trim(); } } return ''; } function summarizeResponseShape(data) { if (!data || typeof data !== 'object') return '接口返回为空或不是对象。'; const topKeys = Object.keys(data).slice(0, 12).join(', ') || '无'; const choice = data?.choices?.[0]; const finishReason = choice?.finish_reason || data?.candidates?.[0]?.finishReason || ''; const choiceKeys = choice && typeof choice === 'object' ? Object.keys(choice).slice(0, 10).join(', ') : ''; const messageKeys = choice?.message && typeof choice.message === 'object' ? Object.keys(choice.message).slice(0, 10).join(', ') : ''; const reasonHint = choice?.message?.reasoning_content && !choice?.message?.content ? '模型只返回了 reasoning_content,没有返回正文;建议换非推理模型,调大“请求 token 上限”,或开启“尝试禁用思考模式”。' : ''; const lengthHint = finishReason === 'length' ? 'finish_reason 为 length,说明接口输出被截断。' : ''; return [ `顶层字段:${topKeys}。`, choiceKeys ? `choice 字段:${choiceKeys}。` : '', messageKeys ? `message 字段:${messageKeys}。` : '', finishReason ? `finish_reason:${finishReason}。` : '', lengthHint, reasonHint ] .filter(Boolean) .join(' '); } function normalizeReply(raw, config, keepDraftLength) { let text = String(raw || '').trim(); text = text.replace(/^```(?:text|markdown)?\s*/i, '').replace(/\s*```$/i, ''); text = text.replace(/^["“”'「『]+|["“”'」』]+$/g, '').trim(); text = normalizeText(text); if (!keepDraftLength && text.length > config.maxReplyChars) { const softCut = text .slice(0, config.maxReplyChars) .replace(/[,。!?;、,.!?;::][^,。!?;、,.!?;::]*$/, ''); text = (softCut.length >= config.minReplyChars ? softCut : text.slice(0, config.maxReplyChars)).trim(); } return text; } function normalizeText(value) { return String(value || '') .replace(/\u00a0/g, ' ') .replace(/[ \t\r\n]+/g, ' ') .trim(); } function limitText(text, maxLength) { const normalized = normalizeText(text); if (normalized.length <= maxLength) return normalized; return `${normalized.slice(0, maxLength - 1)}…`; } function setButtonBusy(button, busy, label) { button.disabled = busy; button.textContent = label; } function openContextPreviewDialog(config, context) { const old = document.querySelector('.linuxdo-ai-reply-modal'); if (old) old.remove(); const prompt = buildUserPrompt(config, context); const targetText = formatTargetLabel(context.target); const postsHtml = context.posts .map((post, index) => { const floor = post.postNumber ? `#${post.postNumber}` : `上下文${index + 1}`; const author = post.author ? ` · ${post.author}` : ''; return `
${escapeHtml(post.reason)} · ${escapeHtml(floor)}${escapeHtml(author)}
${escapeHtml(post.text)}
`; }) .join(''); const overlay = document.createElement('div'); overlay.className = 'linuxdo-ai-reply-modal'; overlay.innerHTML = ` `; overlay.addEventListener('click', (event) => { if (event.target === overlay || event.target.closest('[data-action="close"]')) { overlay.remove(); return; } if (event.target.closest('[data-action="copy"]')) { const textarea = overlay.querySelector('[name="contextPrompt"]'); copyText(textarea.value) .then(() => showToast('Prompt 已复制')) .catch(() => showToast('复制失败,可以手动选中文本复制', 'error')); } }); document.body.appendChild(overlay); } async function copyText(text) { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); return; } const textarea = document.createElement('textarea'); textarea.value = text; textarea.style.position = 'fixed'; textarea.style.left = '-9999px'; document.body.appendChild(textarea); textarea.focus(); textarea.select(); const ok = document.execCommand('copy'); textarea.remove(); if (!ok) throw new Error('copy failed'); } function openConfigDialog(message) { const old = document.querySelector('.linuxdo-ai-reply-modal'); if (old) old.remove(); const config = loadConfig(); const overlay = document.createElement('div'); overlay.className = 'linuxdo-ai-reply-modal'; overlay.innerHTML = ` `; overlay.addEventListener('click', (event) => { if (event.target === overlay || event.target.closest('[data-action="close"]')) { overlay.remove(); return; } if (event.target.closest('[data-action="save"]')) { const formConfig = readConfigFromDialog(overlay); saveConfig(formConfig); overlay.remove(); showToast('配置已保存'); } }); document.body.appendChild(overlay); overlay.querySelector('input[name="endpoint"]').focus(); } function readConfigFromDialog(root) { const read = (name) => root.querySelector(`[name="${name}"]`)?.value || ''; return { endpoint: read('endpoint'), apiKey: read('apiKey'), model: read('model'), maxContextPosts: read('maxContextPosts'), maxOutputTokens: read('maxOutputTokens'), minReplyChars: read('minReplyChars'), maxReplyChars: read('maxReplyChars'), temperature: read('temperature'), style: read('style'), voiceExamples: read('voiceExamples'), customSystemPrompt: read('customSystemPrompt'), extraRequestJson: read('extraRequestJson'), disableThinking: Boolean(root.querySelector('[name="disableThinking"]')?.checked), includeSelectedText: Boolean(root.querySelector('[name="includeSelectedText"]')?.checked) }; } function escapeHtml(value) { return String(value ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function showToast(message, type) { const old = document.querySelector('.linuxdo-ai-reply-toast'); if (old) old.remove(); const toast = document.createElement('div'); toast.className = `linuxdo-ai-reply-toast ${type === 'error' ? 'is-error' : ''}`; toast.textContent = message; document.body.appendChild(toast); window.setTimeout(() => { toast.classList.add('is-leaving'); window.setTimeout(() => toast.remove(), 200); }, type === 'error' ? 5000 : 2600); } function injectStyles() { const style = document.createElement('style'); style.textContent = ` .linuxdo-ai-reply-btn, .linuxdo-ai-context-btn { margin-left: 8px; white-space: nowrap; } .linuxdo-ai-reply-toast { position: fixed; z-index: 999999; right: 18px; bottom: 18px; max-width: min(420px, calc(100vw - 36px)); padding: 10px 12px; border-radius: 8px; background: #1f2937; color: #fff; font-size: 14px; line-height: 1.5; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.18); transition: opacity 0.2s ease, transform 0.2s ease; } .linuxdo-ai-reply-toast.is-error { background: #b42318; } .linuxdo-ai-reply-toast.is-leaving { opacity: 0; transform: translateY(6px); } .linuxdo-ai-reply-modal { position: fixed; inset: 0; z-index: 999998; display: flex; align-items: center; justify-content: center; padding: 20px; background: rgba(17, 24, 39, 0.48); } .linuxdo-ai-reply-dialog { width: min(680px, 100%); max-height: min(760px, calc(100vh - 40px)); overflow: auto; padding: 18px; border-radius: 8px; background: var(--secondary, #fff); color: var(--primary, #111827); box-shadow: 0 22px 70px rgba(0, 0, 0, 0.28); } .linuxdo-ai-reply-dialog__header, .linuxdo-ai-reply-dialog__footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .linuxdo-ai-reply-dialog__header { margin-bottom: 14px; } .linuxdo-ai-reply-dialog__footer { justify-content: flex-end; margin-top: 16px; } .linuxdo-ai-reply-dialog h2 { margin: 0; font-size: 18px; line-height: 1.3; } .linuxdo-ai-reply-dialog label { display: block; margin: 10px 0; } .linuxdo-ai-reply-dialog label > span { display: block; margin-bottom: 6px; color: var(--primary-high, #374151); font-size: 13px; font-weight: 600; } .linuxdo-ai-reply-dialog input[type="text"], .linuxdo-ai-reply-dialog input[type="password"], .linuxdo-ai-reply-dialog input[type="number"], .linuxdo-ai-reply-dialog textarea { box-sizing: border-box; width: 100%; min-height: 38px; padding: 8px 10px; border: 1px solid var(--primary-low, #d1d5db); border-radius: 6px; background: var(--secondary, #fff); color: var(--primary, #111827); font: inherit; } .linuxdo-ai-reply-dialog textarea { min-height: 78px; resize: vertical; } .linuxdo-ai-reply-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; } .linuxdo-ai-reply-check { display: flex !important; align-items: center; gap: 8px; } .linuxdo-ai-reply-check input { margin: 0; } .linuxdo-ai-reply-check span { margin: 0 !important; font-weight: 500 !important; } .linuxdo-ai-reply-alert { margin: 0 0 12px; padding: 9px 10px; border-radius: 6px; background: #fff3cd; color: #7a4d00; font-size: 14px; } .linuxdo-ai-context-dialog { width: min(820px, 100%); } .linuxdo-ai-context-summary { display: grid; gap: 6px; margin: 0 0 12px; padding: 10px; border: 1px solid var(--primary-low, #d1d5db); border-radius: 6px; background: rgba(127, 127, 127, 0.08); font-size: 14px; line-height: 1.5; } .linuxdo-ai-context-list { display: grid; gap: 8px; max-height: 280px; overflow: auto; margin: 10px 0 12px; } .linuxdo-ai-context-post { padding: 9px 10px; border: 1px solid var(--primary-low, #d1d5db); border-radius: 6px; } .linuxdo-ai-context-post__meta { margin-bottom: 5px; color: var(--primary-high, #374151); font-size: 12px; font-weight: 700; } .linuxdo-ai-context-post__text, .linuxdo-ai-context-empty { font-size: 13px; line-height: 1.55; white-space: pre-wrap; } .linuxdo-ai-reply-icon-btn { width: 34px; height: 34px; border: 0; border-radius: 6px; background: transparent; color: inherit; font-size: 24px; line-height: 1; cursor: pointer; } .linuxdo-ai-reply-icon-btn:hover { background: rgba(127, 127, 127, 0.12); } @media (max-width: 640px) { .linuxdo-ai-reply-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .linuxdo-ai-reply-dialog { padding: 14px; } } `; document.head.appendChild(style); } })();