// ==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 `