Greasy Fork

来自缓存

Greasy Fork is available in English.

Linux DO AI 回复草稿

在 Linux DO 回复框中基于上下文生成中文论坛口吻回复草稿;只填入草稿,不自动发送。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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      *
// ==/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 `
          <section class="linuxdo-ai-context-post">
            <div class="linuxdo-ai-context-post__meta">${escapeHtml(post.reason)} · ${escapeHtml(floor)}${escapeHtml(author)}</div>
            <div class="linuxdo-ai-context-post__text">${escapeHtml(post.text)}</div>
          </section>
        `;
      })
      .join('');

    const overlay = document.createElement('div');
    overlay.className = 'linuxdo-ai-reply-modal';
    overlay.innerHTML = `
      <div class="linuxdo-ai-reply-dialog linuxdo-ai-context-dialog" role="dialog" aria-modal="true" aria-label="预览 AI 上下文">
        <div class="linuxdo-ai-reply-dialog__header">
          <h2>预览 AI 上下文</h2>
          <button type="button" class="linuxdo-ai-reply-icon-btn" data-action="close" aria-label="关闭">×</button>
        </div>
        <div class="linuxdo-ai-context-summary">
          <div><strong>主题:</strong>${escapeHtml(context.title || '未读取到')}</div>
          <div><strong>任务模式:</strong>${escapeHtml(getPromptMode(context))}</div>
          <div><strong>回复目标:</strong>${escapeHtml(targetText)}</div>
          <div><strong>识别来源:</strong>${escapeHtml(context.targetSource || '未记录')}</div>
          <div><strong>携带楼层:</strong>${escapeHtml(String(context.posts.length))} 条</div>
          ${context.draftText ? `<div><strong>已有草稿:</strong>${escapeHtml(context.draftText)}</div>` : ''}
          ${context.selectedText ? `<div><strong>选中文本:</strong>${escapeHtml(context.selectedText)}</div>` : ''}
        </div>
        <div class="linuxdo-ai-context-list">
          ${postsHtml || '<div class="linuxdo-ai-context-empty">未读取到帖子正文</div>'}
        </div>
        <label>
          <span>实际发送给 AI 的用户提示词</span>
          <textarea name="contextPrompt" rows="10" readonly>${escapeHtml(prompt)}</textarea>
        </label>
        <div class="linuxdo-ai-reply-dialog__footer">
          <button type="button" class="btn btn-default" data-action="copy">复制 Prompt</button>
          <button type="button" class="btn btn-primary" data-action="close">关闭</button>
        </div>
      </div>
    `;

    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 = `
      <div class="linuxdo-ai-reply-dialog" role="dialog" aria-modal="true" aria-label="配置 Linux DO AI 回复">
        <div class="linuxdo-ai-reply-dialog__header">
          <h2>配置 Linux DO AI 回复</h2>
          <button type="button" class="linuxdo-ai-reply-icon-btn" data-action="close" aria-label="关闭">×</button>
        </div>
        ${message ? `<div class="linuxdo-ai-reply-alert">${escapeHtml(message)}</div>` : ''}
        <label>
          <span>API 地址</span>
          <input name="endpoint" type="text" value="${escapeHtml(config.endpoint)}" placeholder="https://api.openai.com/v1/chat/completions">
        </label>
        <label>
          <span>API Key</span>
          <input name="apiKey" type="password" value="${escapeHtml(config.apiKey)}" placeholder="sk-...">
        </label>
        <label>
          <span>模型名称</span>
          <input name="model" type="text" value="${escapeHtml(config.model)}" placeholder="gpt-4.1-mini / deepseek-chat / qwen-plus">
        </label>
        <div class="linuxdo-ai-reply-grid">
          <label>
            <span>上下文回复数</span>
            <input name="maxContextPosts" type="number" min="1" max="12" step="1" value="${escapeHtml(config.maxContextPosts)}">
          </label>
          <label>
            <span>请求 token 上限</span>
            <input name="maxOutputTokens" type="number" min="0" max="32768" step="1" value="${escapeHtml(config.maxOutputTokens)}" placeholder="留空/0=不传">
          </label>
          <label>
            <span>最少字符</span>
            <input name="minReplyChars" type="number" min="21" max="80" step="1" value="${escapeHtml(config.minReplyChars)}">
          </label>
          <label>
            <span>最多字符</span>
            <input name="maxReplyChars" type="number" min="60" max="400" step="1" value="${escapeHtml(config.maxReplyChars)}">
          </label>
          <label>
            <span>温度</span>
            <input name="temperature" type="number" min="0" max="2" step="0.1" value="${escapeHtml(config.temperature)}">
          </label>
        </div>
        <label>
          <span>回复风格</span>
          <textarea name="style" rows="3">${escapeHtml(config.style)}</textarea>
        </label>
        <label>
          <span>个人语气样例</span>
          <textarea name="voiceExamples" rows="4" placeholder="可选。放几条你自己以前写过的回复,每条一行,用来学习你的语气。">${escapeHtml(config.voiceExamples)}</textarea>
        </label>
        <label>
          <span>额外提示词</span>
          <textarea name="customSystemPrompt" rows="3" placeholder="可选,例如:更偏技术讨论,避免表情符号。">${escapeHtml(config.customSystemPrompt)}</textarea>
        </label>
        <label>
          <span>额外请求参数 JSON</span>
          <textarea name="extraRequestJson" rows="3" placeholder='可选,例如:{"enable_thinking": false}'>${escapeHtml(config.extraRequestJson)}</textarea>
        </label>
        <label class="linuxdo-ai-reply-check">
          <input name="disableThinking" type="checkbox" ${config.disableThinking ? 'checked' : ''}>
          <span>尝试禁用思考模式(适合 Qwen3 / 部分兼容接口)</span>
        </label>
        <label class="linuxdo-ai-reply-check">
          <input name="includeSelectedText" type="checkbox" ${config.includeSelectedText ? 'checked' : ''}>
          <span>把当前选中的文本作为重点上下文</span>
        </label>
        <div class="linuxdo-ai-reply-dialog__footer">
          <button type="button" class="btn btn-default" data-action="close">取消</button>
          <button type="button" class="btn btn-primary" data-action="save">保存配置</button>
        </div>
      </div>
    `;

    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, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;')
      .replace(/'/g, '&#039;');
  }

  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);
  }
})();