From 312d1a6ed146659d5c2771995197d7da0b4990d0 Mon Sep 17 00:00:00 2001
From: Ivoripuion <1049971327@qq.com>
Date: Tue, 13 Jan 2026 23:47:56 +0800
Subject: [PATCH 1/3] add AI Assistant

---
 apps/reader/locales/en-US.ts                  |  41 ++
 apps/reader/locales/ja-JP.ts                  |  42 ++
 apps/reader/locales/zh-CN.ts                  |  37 ++
 apps/reader/src/components/AIAssistant.tsx    | 445 ++++++++++++++++++
 apps/reader/src/components/Layout.tsx         |   9 +
 apps/reader/src/components/Reader.tsx         |   2 +
 .../src/components/TextSelectionMenu.tsx      |  20 +
 apps/reader/src/components/TranslatePopup.tsx | 171 +++++++
 .../reader/src/components/viewlets/AIView.tsx | 100 ++++
 apps/reader/src/hooks/index.ts                |   1 +
 apps/reader/src/hooks/useAIConfig.ts          |   8 +
 apps/reader/src/hooks/useAction.ts            |   1 +
 apps/reader/src/state.ts                      |  24 +
 apps/reader/src/utils/summarize.ts            |  69 +++
 apps/reader/src/utils/translate.ts            |  69 +++
 apps/reader/src/utils/translatePage.ts        | 188 ++++++++
 16 files changed, 1227 insertions(+)
 create mode 100644 apps/reader/src/components/AIAssistant.tsx
 create mode 100644 apps/reader/src/components/TranslatePopup.tsx
 create mode 100644 apps/reader/src/components/viewlets/AIView.tsx
 create mode 100644 apps/reader/src/hooks/useAIConfig.ts
 create mode 100644 apps/reader/src/utils/summarize.ts
 create mode 100644 apps/reader/src/utils/translate.ts
 create mode 100644 apps/reader/src/utils/translatePage.ts

diff --git a/apps/reader/locales/en-US.ts b/apps/reader/locales/en-US.ts
index 31ed17b8..2a77be6a 100644
--- a/apps/reader/locales/en-US.ts
+++ b/apps/reader/locales/en-US.ts
@@ -43,6 +43,44 @@ export default {
   'theme.source_color': 'Source Color',
   'theme.background_color': 'Background Color',
 
+  'ai.title': 'AI Configuration',
+  'ai.api_key': 'API Key',
+  'ai.api_key.placeholder': 'Enter OpenAI API Key',
+  'ai.api_key.show': 'Show',
+  'ai.api_key.hide': 'Hide',
+  'ai.api_url': 'API URL',
+  'ai.api_url.placeholder': 'https://api.openai.com/v1',
+  'ai.model_name': 'Model Name',
+  'ai.model_name.placeholder': 'gpt-3.5-turbo',
+  'ai.translate_prompt': 'Translate Prompt',
+  'ai.translate_prompt.placeholder':
+    'Please translate the following text to Chinese, maintaining the original format and style:',
+  'ai.summarize_prompt': 'Summarize Prompt',
+  'ai.summarize_prompt.placeholder':
+    'Please summarize the main content of the following text, be concise and clear:',
+
+  'ai_assistant.summarize': 'AI Summarize',
+  'ai_assistant.summarize.title': 'Page Summary',
+  'ai_assistant.summarize.loading': 'Summarizing...',
+  'ai_assistant.summarize.error.no_content':
+    'Unable to get current page content',
+  'ai_assistant.summarize.error.no_text': 'Current page has no text content',
+  'ai_assistant.summarize.error.failed': 'Summarization failed',
+  'ai_assistant.translate.page': 'Translate Page',
+  'ai_assistant.translate.page.title': 'Translating Page',
+  'ai_assistant.translate.loading': 'Translating...',
+  'ai_assistant.translate.progress': 'Progress',
+  'ai_assistant.translate.success': 'Translation complete',
+  'ai_assistant.translate.success.detail':
+    '{success} succeeded, {failed} failed',
+  'ai_assistant.translate.removed': 'Translations removed',
+  'ai_assistant.translate.error.no_content':
+    'Unable to get current page content',
+  'ai_assistant.translate.error.no_paragraphs':
+    'Current page has no translatable paragraphs',
+  'ai_assistant.translate.error.failed': 'Translation failed',
+  'ai_assistant.close': 'Close',
+
   'settings.title': 'Settings',
   'settings.language': 'Language',
   'settings.color_scheme': 'Color Scheme',
@@ -59,6 +97,9 @@ export default {
 
   'menu.copy': 'Copy',
   'menu.search_in_book': 'Search in book',
+  'menu.translate': 'Translate',
+  'menu.translate.loading': 'Translating...',
+  'menu.close': 'Close',
   'menu.annotate': 'Annotate',
   'menu.define': 'Define',
   'menu.undefine': 'Undefine',
diff --git a/apps/reader/locales/ja-JP.ts b/apps/reader/locales/ja-JP.ts
index 473dfd12..daf30527 100644
--- a/apps/reader/locales/ja-JP.ts
+++ b/apps/reader/locales/ja-JP.ts
@@ -43,6 +43,45 @@ export default {
   'theme.source_color': 'ソースカラー',
   'theme.background_color': '背景色',
 
+  'ai.title': 'AI 設定',
+  'ai.api_key': 'API キー',
+  'ai.api_key.placeholder': 'OpenAI API キーを入力',
+  'ai.api_key.show': '表示',
+  'ai.api_key.hide': '非表示',
+  'ai.api_url': 'API URL',
+  'ai.api_url.placeholder': 'https://api.openai.com/v1',
+  'ai.model_name': 'モデル名',
+  'ai.model_name.placeholder': 'gpt-3.5-turbo',
+  'ai.translate_prompt': '翻訳プロンプト',
+  'ai.translate_prompt.placeholder':
+    '以下のテキストを中国語に翻訳してください。元の形式とスタイルを維持してください：',
+  'ai.summarize_prompt': '要約プロンプト',
+  'ai.summarize_prompt.placeholder':
+    '以下のテキストの主要内容を要約してください。簡潔で明確に：',
+
+  'ai_assistant.summarize': 'AI 要約',
+  'ai_assistant.summarize.title': 'ページ要約',
+  'ai_assistant.summarize.loading': '要約中...',
+  'ai_assistant.summarize.error.no_content':
+    '現在のページコンテンツを取得できません',
+  'ai_assistant.summarize.error.no_text':
+    '現在のページにテキストコンテンツがありません',
+  'ai_assistant.summarize.error.failed': '要約に失敗しました',
+  'ai_assistant.translate.page': 'ページ翻訳',
+  'ai_assistant.translate.page.title': 'ページ翻訳中',
+  'ai_assistant.translate.loading': '翻訳中...',
+  'ai_assistant.translate.progress': '進捗',
+  'ai_assistant.translate.success': '翻訳完了',
+  'ai_assistant.translate.success.detail':
+    '成功 {success} 件、失敗 {failed} 件',
+  'ai_assistant.translate.removed': '翻訳を削除しました',
+  'ai_assistant.translate.error.no_content':
+    '現在のページコンテンツを取得できません',
+  'ai_assistant.translate.error.no_paragraphs':
+    '現在のページに翻訳可能な段落がありません',
+  'ai_assistant.translate.error.failed': '翻訳に失敗しました',
+  'ai_assistant.close': '閉じる',
+
   'settings.title': '設定',
   'settings.language': '言語',
   'settings.color_scheme': '配色',
@@ -59,6 +98,9 @@ export default {
 
   'menu.copy': 'コピー',
   'menu.search_in_book': '本を検索',
+  'menu.translate': '翻訳',
+  'menu.translate.loading': '翻訳中...',
+  'menu.close': '閉じる',
   'menu.annotate': '注釈',
   'menu.define': '定義',
   'menu.undefine': '未定義',
diff --git a/apps/reader/locales/zh-CN.ts b/apps/reader/locales/zh-CN.ts
index d11df16f..d29c5579 100644
--- a/apps/reader/locales/zh-CN.ts
+++ b/apps/reader/locales/zh-CN.ts
@@ -43,6 +43,40 @@ export default {
   'theme.source_color': '源色',
   'theme.background_color': '背景色',
 
+  'ai.title': 'AI 配置',
+  'ai.api_key': 'API 密钥',
+  'ai.api_key.placeholder': '请输入 OpenAI API Key',
+  'ai.api_key.show': '显示',
+  'ai.api_key.hide': '隐藏',
+  'ai.api_url': 'API 地址',
+  'ai.api_url.placeholder': 'https://api.openai.com/v1',
+  'ai.model_name': '模型名称',
+  'ai.model_name.placeholder': 'gpt-3.5-turbo',
+  'ai.translate_prompt': '翻译提示词',
+  'ai.translate_prompt.placeholder':
+    '请将以下文本翻译成中文，保持原文的格式和风格：',
+  'ai.summarize_prompt': '总结提示词',
+  'ai.summarize_prompt.placeholder': '请总结以下文本的主要内容，要求简洁明了：',
+
+  'ai_assistant.summarize': 'AI 总结',
+  'ai_assistant.summarize.title': '页面总结',
+  'ai_assistant.summarize.loading': '总结中...',
+  'ai_assistant.summarize.error.no_content': '无法获取当前页面内容',
+  'ai_assistant.summarize.error.no_text': '当前页面没有文本内容',
+  'ai_assistant.summarize.error.failed': '总结失败',
+  'ai_assistant.translate.page': '翻译页面',
+  'ai_assistant.translate.page.title': '翻译页面',
+  'ai_assistant.translate.loading': '翻译中...',
+  'ai_assistant.translate.progress': '翻译进度',
+  'ai_assistant.translate.success': '翻译完成',
+  'ai_assistant.translate.success.detail':
+    '成功 {success} 个，失败 {failed} 个',
+  'ai_assistant.translate.removed': '已移除翻译',
+  'ai_assistant.translate.error.no_content': '无法获取当前页面内容',
+  'ai_assistant.translate.error.no_paragraphs': '当前页面没有可翻译的段落',
+  'ai_assistant.translate.error.failed': '翻译失败',
+  'ai_assistant.close': '关闭',
+
   'settings.title': '设置',
   'settings.language': '语言',
   'settings.color_scheme': '颜色模式',
@@ -59,6 +93,9 @@ export default {
 
   'menu.copy': '复制',
   'menu.search_in_book': '书内搜索',
+  'menu.translate': '翻译',
+  'menu.translate.loading': '翻译中...',
+  'menu.close': '关闭',
   'menu.annotate': '标注',
   'menu.define': '定义',
   'menu.undefine': '取消定义',
diff --git a/apps/reader/src/components/AIAssistant.tsx b/apps/reader/src/components/AIAssistant.tsx
new file mode 100644
index 00000000..9c020c20
--- /dev/null
+++ b/apps/reader/src/components/AIAssistant.tsx
@@ -0,0 +1,445 @@
+import { Overlay } from '@literal-ui/core'
+import clsx from 'clsx'
+import { useEffect, useRef, useState } from 'react'
+import { MdClose, MdSmartToy, MdSummarize, MdTranslate } from 'react-icons/md'
+
+import { useAIConfig, useTranslation } from '../hooks'
+import { BookTab } from '../models'
+import { scale } from '../platform'
+
+import { IconButton } from './Button'
+
+interface AIAssistantProps {
+  tab: BookTab
+}
+
+export const AIAssistant: React.FC<AIAssistantProps> = ({ tab }) => {
+  const [position, setPosition] = useState({ x: 20, y: 100 })
+  const [isDragging, setIsDragging] = useState(false)
+  const [showMenu, setShowMenu] = useState(false)
+  const [showSummary, setShowSummary] = useState(false)
+  const [summaryText, setSummaryText] = useState<string>('')
+  const [loading, setLoading] = useState(false)
+  const [error, setError] = useState<string>('')
+  const [translating, setTranslating] = useState(false)
+  const [translateProgress, setTranslateProgress] = useState(0)
+  const [translateStatus, setTranslateStatus] = useState<string>('')
+  const buttonRef = useRef<HTMLDivElement>(null)
+  const menuRef = useRef<HTMLDivElement>(null)
+  const dragStartPos = useRef({ x: 0, y: 0 })
+  const dragStartMousePos = useRef({ x: 0, y: 0 })
+  const hasDragged = useRef(false)
+  const [config] = useAIConfig()
+  const t = useTranslation('ai_assistant')
+
+  // Load saved position from localStorage
+  useEffect(() => {
+    const saved = localStorage.getItem('aiAssistantPosition')
+    if (saved) {
+      try {
+        const { x, y } = JSON.parse(saved)
+        setPosition({ x, y })
+      } catch (e) {
+        // Ignore parse errors
+      }
+    }
+  }, [])
+
+  // Save position to localStorage
+  useEffect(() => {
+    if (!isDragging) {
+      localStorage.setItem('aiAssistantPosition', JSON.stringify(position))
+    }
+  }, [position, isDragging])
+
+  const handleMouseDown = (e: React.MouseEvent) => {
+    if (e.button !== 0) return // Only handle left mouse button
+    e.preventDefault()
+    e.stopPropagation()
+
+    const container = tab.container
+    if (!container) return
+
+    // Reset drag flag
+    hasDragged.current = false
+
+    // Store initial button position relative to container
+    dragStartPos.current = { ...position }
+
+    // Store initial mouse position relative to viewport
+    dragStartMousePos.current = {
+      x: e.clientX,
+      y: e.clientY,
+    }
+
+    setIsDragging(true)
+  }
+
+  useEffect(() => {
+    if (!isDragging) return
+
+    const handleMouseMove = (e: MouseEvent) => {
+      e.preventDefault()
+      e.stopPropagation()
+
+      const container = tab.container
+      if (!container) {
+        setIsDragging(false)
+        return
+      }
+
+      const containerRect = container.getBoundingClientRect()
+      const buttonRect = buttonRef.current?.getBoundingClientRect()
+      if (!buttonRect) return
+
+      // Calculate mouse movement delta
+      const deltaX = e.clientX - dragStartMousePos.current.x
+      const deltaY = e.clientY - dragStartMousePos.current.y
+
+      // Check if moved enough to be considered a drag (threshold: 5px)
+      const dragDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
+      if (dragDistance > 5) {
+        hasDragged.current = true
+      }
+
+      // Calculate new position based on initial position + delta
+      const newX = dragStartPos.current.x + deltaX
+      const newY = dragStartPos.current.y + deltaY
+
+      // Constrain to container bounds
+      const maxX = containerRect.width - buttonRect.width
+      const maxY = containerRect.height - buttonRect.height
+
+      setPosition({
+        x: Math.max(0, Math.min(newX, maxX)),
+        y: Math.max(0, Math.min(newY, maxY)),
+      })
+    }
+
+    const handleMouseUp = (e: MouseEvent) => {
+      e.preventDefault()
+      e.stopPropagation()
+      setIsDragging(false)
+    }
+
+    const handleMouseLeave = () => {
+      // If mouse leaves the window, stop dragging
+      setIsDragging(false)
+    }
+
+    // Use capture phase to ensure we catch events early
+    // Use passive: false for mousemove to allow preventDefault
+    document.addEventListener('mousemove', handleMouseMove, {
+      passive: false,
+      capture: true,
+    })
+    document.addEventListener('mouseup', handleMouseUp, {
+      passive: true,
+      capture: true,
+    })
+    // Listen for mouse leave to handle mouse leaving window
+    document.addEventListener('mouseleave', handleMouseLeave, {
+      passive: true,
+    })
+
+    return () => {
+      document.removeEventListener('mousemove', handleMouseMove, {
+        capture: true,
+      })
+      document.removeEventListener('mouseup', handleMouseUp, { capture: true })
+      document.removeEventListener('mouseleave', handleMouseLeave)
+    }
+  }, [isDragging, tab.container])
+
+  const handleSummarize = async () => {
+    setShowMenu(false)
+    setShowSummary(true)
+    setLoading(true)
+    setError('')
+
+    try {
+      // Get current page content
+      const contents = tab.rendition?.getContents()
+      if (!contents || contents.length === 0) {
+        setError(t('summarize.error.no_content'))
+        setLoading(false)
+        return
+      }
+
+      const view = contents[0]
+      const doc = view.document
+      const text = doc.body.textContent?.trim() || ''
+
+      if (!text) {
+        setError(t('summarize.error.no_text'))
+        setLoading(false)
+        return
+      }
+
+      const { summarizeText } = await import('../utils/summarize')
+      const result = await summarizeText(text, config)
+
+      if (result.error) {
+        setError(result.error)
+      } else {
+        setSummaryText(result.text)
+      }
+    } catch (err) {
+      setError(err instanceof Error ? err.message : t('summarize.error.failed'))
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  const handleTranslatePage = async () => {
+    setShowMenu(false)
+    setTranslating(true)
+    setTranslateProgress(0)
+    setTranslateStatus('')
+    setError('')
+
+    try {
+      // Get current page content
+      const contents = tab.rendition?.getContents()
+      if (!contents || contents.length === 0) {
+        setError(t('translate.error.no_content'))
+        setTranslating(false)
+        return
+      }
+
+      const view = contents[0]
+      const doc = view.document
+
+      // Check if already translated
+      const hasTranslations = doc.querySelector('.ai-translation')
+      if (hasTranslations) {
+        // Remove existing translations
+        const { removeTranslations } = await import('../utils/translatePage')
+        removeTranslations(doc)
+        // Trigger reflow
+        tab.rendition?.resize()
+        setTranslating(false)
+        setTranslateStatus(t('translate.removed'))
+        setTimeout(() => setTranslateStatus(''), 2000)
+        return
+      }
+
+      // Extract paragraphs
+      const { extractParagraphs, translatePage } = await import(
+        '../utils/translatePage'
+      )
+      const paragraphs = extractParagraphs(doc)
+
+      if (paragraphs.length === 0) {
+        setError(t('translate.error.no_paragraphs'))
+        setTranslating(false)
+        return
+      }
+
+      setTranslateStatus(`${t('translate.progress')}: 0/${paragraphs.length}`)
+
+      // Translate paragraphs
+      const result = await translatePage(paragraphs, config, (progress) => {
+        setTranslateProgress(progress)
+        const current = Math.round(progress * paragraphs.length)
+        setTranslateStatus(
+          `${t('translate.progress')}: ${current}/${paragraphs.length}`,
+        )
+      })
+
+      // Trigger reflow to adjust page layout
+      tab.rendition?.resize()
+
+      if (result.success > 0) {
+        const successText = t('translate.success.detail')
+          .replace('{success}', result.success.toString())
+          .replace('{failed}', result.failed.toString())
+        setTranslateStatus(`${t('translate.success')}: ${successText}`)
+      } else {
+        setError(t('translate.error.failed'))
+      }
+    } catch (err) {
+      setError(err instanceof Error ? err.message : t('translate.error.failed'))
+    } finally {
+      setTranslating(false)
+      setTimeout(() => {
+        setTranslateStatus('')
+        setTranslateProgress(0)
+      }, 3000)
+    }
+  }
+
+  const container = tab.container
+  if (!container) return null
+
+  // Calculate menu position to avoid going off-screen
+  const menuWidth = 200
+  const menuHeight = 60
+  const buttonSize = 48
+  const containerRect = container.getBoundingClientRect()
+  const buttonRight = position.x + buttonSize
+  const spaceRight = containerRect.width - buttonRight
+
+  // Position menu to the right if there's space, otherwise to the left
+  const menuLeft =
+    spaceRight >= menuWidth + 10
+      ? buttonRight + 10
+      : Math.max(0, position.x - menuWidth - 10)
+  const menuTop = position.y
+
+  return (
+    <>
+      <div
+        ref={buttonRef}
+        className={clsx(
+          'bg-surface text-on-surface-variant shadow-2 absolute z-50 flex h-12 w-12 cursor-move select-none items-center justify-center rounded-full transition-shadow',
+          isDragging ? 'shadow-4' : 'hover:shadow-3',
+        )}
+        style={{
+          left: `${position.x}px`,
+          top: `${position.y}px`,
+          userSelect: 'none',
+          WebkitUserSelect: 'none',
+          touchAction: 'none',
+        }}
+        onMouseDown={handleMouseDown}
+        onClick={(e) => {
+          // Prevent click if we just dragged or are currently dragging
+          if (hasDragged.current || isDragging) {
+            e.preventDefault()
+            e.stopPropagation()
+            return
+          }
+          // Only show menu if we didn't drag (moved less than threshold)
+          e.stopPropagation()
+          setShowMenu(true)
+        }}
+      >
+        <MdSmartToy size={scale(24, 28)} />
+      </div>
+
+      {showMenu && (
+        <>
+          <Overlay
+            className="!z-[49] !bg-transparent"
+            onMouseDown={() => setShowMenu(false)}
+          />
+          <div
+            ref={menuRef}
+            className="bg-surface text-on-surface-variant shadow-2 absolute z-50 min-w-[200px] rounded p-2"
+            style={{
+              left: `${menuLeft}px`,
+              top: `${Math.max(
+                0,
+                Math.min(menuTop, containerRect.height - menuHeight),
+              )}px`,
+            }}
+            onMouseDown={(e) => e.stopPropagation()}
+          >
+            <div className="space-y-1">
+              <button
+                className="hover:bg-surface-variant flex w-full items-center gap-2 rounded px-3 py-2 text-left"
+                onClick={handleSummarize}
+              >
+                <MdSummarize size={scale(20, 24)} />
+                <span className="typescale-body-medium">{t('summarize')}</span>
+              </button>
+              <button
+                className="hover:bg-surface-variant flex w-full items-center gap-2 rounded px-3 py-2 text-left"
+                onClick={handleTranslatePage}
+                disabled={translating}
+              >
+                <MdTranslate size={scale(20, 24)} />
+                <span className="typescale-body-medium">
+                  {t('translate.page')}
+                </span>
+              </button>
+            </div>
+          </div>
+        </>
+      )}
+
+      {showSummary && (
+        <>
+          <Overlay
+            className="!z-[51] !bg-transparent"
+            onMouseDown={() => setShowSummary(false)}
+          />
+          <div
+            className="bg-surface text-on-surface-variant shadow-2 fixed left-1/2 top-1/2 z-[51] max-h-[80vh] w-full max-w-2xl -translate-x-1/2 -translate-y-1/2 rounded p-4"
+            onMouseDown={(e) => e.stopPropagation()}
+          >
+            <div className="mb-3 flex items-center justify-between">
+              <div className="flex items-center gap-2">
+                <MdSummarize size={scale(20, 24)} />
+                <span className="typescale-title-medium text-on-surface">
+                  {t('summarize.title')}
+                </span>
+              </div>
+              <IconButton
+                title={t('close')}
+                Icon={MdClose}
+                size={scale(20, 24)}
+                onClick={() => setShowSummary(false)}
+              />
+            </div>
+            <div className="typescale-body-medium text-on-surface max-h-[60vh] overflow-y-auto">
+              {loading && (
+                <div className="text-on-surface-variant">
+                  {t('summarize.loading')}
+                </div>
+              )}
+              {error && (
+                <div className="text-error text-on-surface-variant">
+                  {error}
+                </div>
+              )}
+              {!loading && !error && summaryText && (
+                <div className="whitespace-pre-wrap">{summaryText}</div>
+              )}
+            </div>
+          </div>
+        </>
+      )}
+
+      {translating && (
+        <>
+          <Overlay className="!z-[51] !bg-black/20" />
+          <div
+            className="bg-surface text-on-surface-variant shadow-2 fixed left-1/2 top-1/2 z-[51] min-w-[300px] -translate-x-1/2 -translate-y-1/2 rounded p-4"
+            onMouseDown={(e) => e.stopPropagation()}
+          >
+            <div className="mb-3 flex items-center gap-2">
+              <MdTranslate size={scale(20, 24)} />
+              <span className="typescale-title-medium text-on-surface">
+                {t('translate.page.title')}
+              </span>
+            </div>
+            <div className="mb-2">
+              <div className="typescale-body-small text-on-surface-variant mb-1">
+                {translateStatus || t('translate.loading')}
+              </div>
+              <div className="bg-surface-variant h-2 w-full overflow-hidden rounded">
+                <div
+                  className="bg-primary h-full transition-all duration-300"
+                  style={{ width: `${translateProgress * 100}%` }}
+                />
+              </div>
+            </div>
+          </div>
+        </>
+      )}
+
+      {translateStatus && !translating && (
+        <div
+          className="bg-surface text-on-surface-variant shadow-2 fixed left-1/2 top-20 z-[51] min-w-[200px] -translate-x-1/2 rounded px-4 py-2"
+          onMouseDown={(e) => e.stopPropagation()}
+        >
+          <div className="typescale-body-small text-on-surface">
+            {translateStatus}
+          </div>
+        </div>
+      )}
+    </>
+  )
+}
diff --git a/apps/reader/src/components/Layout.tsx b/apps/reader/src/components/Layout.tsx
index 84227536..89e301bd 100644
--- a/apps/reader/src/components/Layout.tsx
+++ b/apps/reader/src/components/Layout.tsx
@@ -10,6 +10,7 @@ import {
   MdToc,
   MdTimeline,
   MdOutlineLightMode,
+  MdSmartToy,
 } from 'react-icons/md'
 import { RiFontSize, RiHome6Line, RiSettings5Line } from 'react-icons/ri'
 import { useRecoilState } from 'recoil'
@@ -30,6 +31,7 @@ import { activeClass } from '../styles'
 
 import { SplitView, useSplitViewItem } from './base'
 import { Settings } from './pages'
+import { AIView } from './viewlets/AIView'
 import { AnnotationView } from './viewlets/AnnotationView'
 import { ImageView } from './viewlets/ImageView'
 import { SearchView } from './viewlets/SearchView'
@@ -124,6 +126,13 @@ const viewActions: IViewAction[] = [
     View: ThemeView,
     env: Env.Desktop | Env.Mobile,
   },
+  {
+    name: 'ai',
+    title: 'ai',
+    Icon: MdSmartToy,
+    View: AIView,
+    env: Env.Desktop | Env.Mobile,
+  },
 ]
 
 const ActivityBar: React.FC = () => {
diff --git a/apps/reader/src/components/Reader.tsx b/apps/reader/src/components/Reader.tsx
index 9f34ad71..4c20527d 100644
--- a/apps/reader/src/components/Reader.tsx
+++ b/apps/reader/src/components/Reader.tsx
@@ -33,6 +33,7 @@ import { BookTab, reader, useReaderSnapshot } from '../models'
 import { isTouchScreen } from '../platform'
 import { updateCustomStyle } from '../styles'
 
+import { AIAssistant } from './AIAssistant'
 import {
   getClickedAnnotation,
   setClickedAnnotation,
@@ -413,6 +414,7 @@ function BookPane({ tab, onMouseDown }: BookPaneProps) {
         />
         <TextSelectionMenu tab={tab} />
         <Annotations tab={tab} />
+        <AIAssistant tab={tab} />
       </div>
       <ReaderPaneFooter tab={tab} />
     </div>
diff --git a/apps/reader/src/components/TextSelectionMenu.tsx b/apps/reader/src/components/TextSelectionMenu.tsx
index afad66e8..225e525e 100644
--- a/apps/reader/src/components/TextSelectionMenu.tsx
+++ b/apps/reader/src/components/TextSelectionMenu.tsx
@@ -8,6 +8,7 @@ import {
   MdOutlineEdit,
   MdOutlineIndeterminateCheckBox,
   MdSearch,
+  MdTranslate,
 } from 'react-icons/md'
 import { useSnapshot } from 'valtio'
 
@@ -27,6 +28,7 @@ import { copy, keys, last } from '../utils'
 
 import { Button, IconButton } from './Button'
 import { TextField } from './Form'
+import { TranslatePopup } from './TranslatePopup'
 import { layout, LayoutAnchorMode, LayoutAnchorPosition } from './base'
 
 interface TextSelectionMenuProps {
@@ -134,6 +136,7 @@ const TextSelectionMenuRenderer: React.FC<TextSelectionMenuRendererProps> = ({
   const cfi = tab.rangeToCfi(range)
   const annotation = tab.book.annotations.find((a) => a.cfi === cfi)
   const [annotate, setAnnotate] = useState(!!annotation)
+  const [showTranslate, setShowTranslate] = useState(false)
 
   const position = forward
     ? LayoutAnchorPosition.Before
@@ -222,6 +225,14 @@ const TextSelectionMenuRenderer: React.FC<TextSelectionMenuRendererProps> = ({
                 tab.setKeyword(text)
               }}
             />
+            <IconButton
+              title={t('translate')}
+              Icon={MdTranslate}
+              size={ICON_SIZE}
+              onClick={() => {
+                setShowTranslate(true)
+              }}
+            />
             <IconButton
               title={t('annotate')}
               Icon={MdOutlineEdit}
@@ -319,6 +330,15 @@ const TextSelectionMenuRenderer: React.FC<TextSelectionMenuRendererProps> = ({
           </div>
         )}
       </div>
+      {showTranslate && (
+        <TranslatePopup
+          text={text}
+          anchorRect={anchorRect}
+          containerRect={containerRect}
+          viewRect={viewRect}
+          onClose={() => setShowTranslate(false)}
+        />
+      )}
     </FocusLock>
   )
 }
diff --git a/apps/reader/src/components/TranslatePopup.tsx b/apps/reader/src/components/TranslatePopup.tsx
new file mode 100644
index 00000000..d85273b0
--- /dev/null
+++ b/apps/reader/src/components/TranslatePopup.tsx
@@ -0,0 +1,171 @@
+import { Overlay } from '@literal-ui/core'
+import clsx from 'clsx'
+import { useEffect, useRef, useState } from 'react'
+import { MdClose, MdTranslate } from 'react-icons/md'
+
+import { useAIConfig, useTranslation } from '../hooks'
+import { scale } from '../platform'
+
+import { IconButton } from './Button'
+
+interface TranslatePopupProps {
+  text: string
+  anchorRect: DOMRect
+  containerRect: DOMRect
+  viewRect: DOMRect
+  onClose: () => void
+}
+
+export const TranslatePopup: React.FC<TranslatePopupProps> = ({
+  text,
+  anchorRect,
+  containerRect,
+  viewRect,
+  onClose,
+}) => {
+  const [config] = useAIConfig()
+  const t = useTranslation('menu')
+  const [translatedText, setTranslatedText] = useState<string>('')
+  const [loading, setLoading] = useState(true)
+  const [error, setError] = useState<string>('')
+  const popupRef = useRef<HTMLDivElement>(null)
+  const [width, setWidth] = useState(300) // Default width
+  const [height, setHeight] = useState(100) // Default height
+
+  useEffect(() => {
+    if (!config.apiKey || !config.apiUrl || !config.modelName) {
+      setError('AI configuration is incomplete')
+      setLoading(false)
+      return
+    }
+
+    const translate = async () => {
+      try {
+        const { translateText } = await import('../utils/translate')
+        const result = await translateText(text, config)
+        if (result.error) {
+          setError(result.error)
+        } else {
+          setTranslatedText(result.text)
+        }
+      } catch (err) {
+        setError(err instanceof Error ? err.message : 'Translation failed')
+      } finally {
+        setLoading(false)
+      }
+    }
+
+    translate()
+  }, [text, config])
+
+  useEffect(() => {
+    if (popupRef.current) {
+      const rect = popupRef.current.getBoundingClientRect()
+      setWidth(rect.width)
+      setHeight(rect.height)
+    }
+  }, [translatedText, loading, error])
+
+  // Calculate position relative to container
+  const anchorLeft = anchorRect.left + viewRect.left - containerRect.left
+  const anchorTop = anchorRect.top + viewRect.top - containerRect.top
+  const anchorRight = anchorLeft + anchorRect.width
+  const anchorBottom = anchorTop + anchorRect.height
+
+  const viewportWidth = containerRect.width
+  const viewportHeight = containerRect.height
+  const margin = 8
+
+  // Calculate available space in each direction
+  const spaceRight = viewportWidth - anchorRight
+  const spaceLeft = anchorLeft
+  const spaceBottom = viewportHeight - anchorBottom
+  const spaceTop = anchorTop
+
+  // Determine horizontal position
+  // If there's not enough space on the right, position to the left
+  let left: number
+  if (spaceRight >= width + margin) {
+    // Enough space on the right, position to the right
+    left = anchorRight + margin
+  } else if (spaceLeft >= width + margin) {
+    // Not enough space on right, but enough on left, position to the left
+    left = anchorLeft - width - margin
+  } else {
+    // Not enough space on either side, center it or align to edges
+    if (spaceLeft > spaceRight) {
+      left = Math.max(0, anchorLeft - width)
+    } else {
+      left = Math.min(viewportWidth - width, anchorRight)
+    }
+  }
+
+  // Determine vertical position
+  // If there's not enough space below, position above
+  let top: number
+  if (spaceBottom >= height + margin) {
+    // Enough space below, position below
+    top = anchorBottom + margin
+  } else if (spaceTop >= height + margin) {
+    // Not enough space below, but enough above, position above
+    top = anchorTop - height - margin
+  } else {
+    // Not enough space on either side, center it or align to edges
+    if (spaceTop > spaceBottom) {
+      top = Math.max(0, anchorTop - height)
+    } else {
+      top = Math.min(viewportHeight - height, anchorBottom)
+    }
+  }
+
+  // Ensure the popup stays within viewport bounds
+  left = Math.max(0, Math.min(left, viewportWidth - width))
+  top = Math.max(0, Math.min(top, viewportHeight - height))
+
+  return (
+    <>
+      <Overlay className="!z-[60] !bg-transparent" onMouseDown={onClose} />
+      <div
+        ref={popupRef}
+        className={clsx(
+          'bg-surface text-on-surface-variant shadow-2 absolute z-[60] max-w-sm rounded p-3',
+        )}
+        style={{
+          left: `${left}px`,
+          top: `${top}px`,
+          minWidth: '200px',
+          maxWidth: '400px',
+        }}
+        onMouseDown={(e) => e.stopPropagation()}
+      >
+        <div className="mb-2 flex items-center justify-between">
+          <div className="flex items-center gap-2">
+            <MdTranslate size={scale(18, 20)} />
+            <span className="typescale-label-medium text-on-surface-variant">
+              {t('translate')}
+            </span>
+          </div>
+          <IconButton
+            title={t('close')}
+            Icon={MdClose}
+            size={scale(18, 20)}
+            onClick={onClose}
+          />
+        </div>
+        <div className="typescale-body-medium text-on-surface">
+          {loading && (
+            <div className="text-on-surface-variant">
+              {t('translate.loading')}
+            </div>
+          )}
+          {error && (
+            <div className="text-error text-on-surface-variant">{error}</div>
+          )}
+          {!loading && !error && translatedText && (
+            <div className="whitespace-pre-wrap">{translatedText}</div>
+          )}
+        </div>
+      </div>
+    </>
+  )
+}
diff --git a/apps/reader/src/components/viewlets/AIView.tsx b/apps/reader/src/components/viewlets/AIView.tsx
new file mode 100644
index 00000000..9c515a06
--- /dev/null
+++ b/apps/reader/src/components/viewlets/AIView.tsx
@@ -0,0 +1,100 @@
+import { useState } from 'react'
+import { MdVisibility, MdVisibilityOff } from 'react-icons/md'
+
+import { useTranslation, useAIConfig } from '@flow/reader/hooks'
+
+import { TextField } from '../Form'
+import { PaneViewProps, PaneView, Pane } from '../base'
+
+export const AIView: React.FC<PaneViewProps> = (props) => {
+  const [config, setConfig] = useAIConfig()
+  const [showApiKey, setShowApiKey] = useState(false)
+  const t = useTranslation('ai')
+
+  return (
+    <PaneView {...props}>
+      <Pane headline={t('title')} className="space-y-3 px-5 pt-2 pb-4">
+        <div>
+          <TextField
+            name={t('api_key')}
+            type={showApiKey ? 'text' : 'password'}
+            value={config.apiKey || ''}
+            onChange={(e) => {
+              setConfig({
+                ...config,
+                apiKey: e.target.value,
+              })
+            }}
+            placeholder={t('api_key.placeholder')}
+            actions={[
+              {
+                title: showApiKey ? t('api_key.hide') : t('api_key.show'),
+                Icon: showApiKey ? MdVisibilityOff : MdVisibility,
+                onClick: () => {
+                  setShowApiKey(!showApiKey)
+                },
+              },
+            ]}
+          />
+        </div>
+        <div>
+          <TextField
+            name={t('api_url')}
+            type="url"
+            value={config.apiUrl || ''}
+            onChange={(e) => {
+              setConfig({
+                ...config,
+                apiUrl: e.target.value,
+              })
+            }}
+            placeholder={t('api_url.placeholder')}
+          />
+        </div>
+        <div>
+          <TextField
+            name={t('model_name')}
+            value={config.modelName || ''}
+            onChange={(e) => {
+              setConfig({
+                ...config,
+                modelName: e.target.value,
+              })
+            }}
+            placeholder={t('model_name.placeholder')}
+          />
+        </div>
+        <div>
+          <TextField
+            name={t('translate_prompt')}
+            as="textarea"
+            value={config.translatePrompt || ''}
+            onChange={(e) => {
+              setConfig({
+                ...config,
+                translatePrompt: e.target.value,
+              })
+            }}
+            placeholder={t('translate_prompt.placeholder')}
+            className="min-h-[100px]"
+          />
+        </div>
+        <div>
+          <TextField
+            name={t('summarize_prompt')}
+            as="textarea"
+            value={config.summarizePrompt || ''}
+            onChange={(e) => {
+              setConfig({
+                ...config,
+                summarizePrompt: e.target.value,
+              })
+            }}
+            placeholder={t('summarize_prompt.placeholder')}
+            className="min-h-[100px]"
+          />
+        </div>
+      </Pane>
+    </PaneView>
+  )
+}
diff --git a/apps/reader/src/hooks/index.ts b/apps/reader/src/hooks/index.ts
index a55e52ca..a5bf5feb 100644
--- a/apps/reader/src/hooks/index.ts
+++ b/apps/reader/src/hooks/index.ts
@@ -1,6 +1,7 @@
 export * from './remote'
 export * from './theme'
 export * from './useAction'
+export * from './useAIConfig'
 export * from './useAsync'
 export * from './useDisablePinchZooming'
 export * from './useEnv'
diff --git a/apps/reader/src/hooks/useAIConfig.ts b/apps/reader/src/hooks/useAIConfig.ts
new file mode 100644
index 00000000..1de09e4b
--- /dev/null
+++ b/apps/reader/src/hooks/useAIConfig.ts
@@ -0,0 +1,8 @@
+import { useRecoilState } from 'recoil'
+
+import { aiConfigState, AIConfig } from '../state'
+
+export function useAIConfig(): [AIConfig, (config: AIConfig) => void] {
+  const [config, setConfig] = useRecoilState(aiConfigState)
+  return [config, setConfig]
+}
diff --git a/apps/reader/src/hooks/useAction.ts b/apps/reader/src/hooks/useAction.ts
index e55d6ead..9cf6b611 100644
--- a/apps/reader/src/hooks/useAction.ts
+++ b/apps/reader/src/hooks/useAction.ts
@@ -8,6 +8,7 @@ export type Action =
   | 'image'
   | 'timeline'
   | 'theme'
+  | 'ai'
 export const actionState = atom<Action | undefined>({
   key: 'action',
   default: undefined,
diff --git a/apps/reader/src/state.ts b/apps/reader/src/state.ts
index 9f40b9ce..4e6e8f95 100644
--- a/apps/reader/src/state.ts
+++ b/apps/reader/src/state.ts
@@ -32,6 +32,30 @@ export interface Settings extends TypographyConfiguration {
   enableTextSelectionMenu?: boolean
 }
 
+export interface AIConfig {
+  apiKey?: string
+  apiUrl?: string
+  modelName?: string
+  translatePrompt?: string
+  summarizePrompt?: string
+}
+
+export const defaultAIConfig: AIConfig = {
+  apiKey: '',
+  apiUrl: 'https://api.openai.com/v1',
+  modelName: 'gpt-3.5-turbo',
+  translatePrompt: '请将以下文本翻译成中文，保持原文的格式和风格：',
+  summarizePrompt: '请总结以下文本的主要内容，要求简洁明了：',
+}
+
+const aiConfigState = atom<AIConfig>({
+  key: 'aiConfig',
+  default: defaultAIConfig,
+  effects: [localStorageEffect('aiConfig', defaultAIConfig)],
+})
+
+export { aiConfigState }
+
 export interface TypographyConfiguration {
   fontSize?: string
   fontWeight?: number
diff --git a/apps/reader/src/utils/summarize.ts b/apps/reader/src/utils/summarize.ts
new file mode 100644
index 00000000..2509e74d
--- /dev/null
+++ b/apps/reader/src/utils/summarize.ts
@@ -0,0 +1,69 @@
+import { AIConfig } from '../state'
+
+export interface SummarizeResult {
+  text: string
+  error?: string
+}
+
+export async function summarizeText(
+  text: string,
+  config: AIConfig,
+): Promise<SummarizeResult> {
+  if (!config.apiKey || !config.apiUrl || !config.modelName) {
+    return {
+      text: '',
+      error: 'AI configuration is incomplete',
+    }
+  }
+
+  const prompt =
+    config.summarizePrompt || '请总结以下文本的主要内容，要求简洁明了：'
+  const fullPrompt = `${prompt}\n\n${text}`
+
+  try {
+    const response = await fetch(`${config.apiUrl}/chat/completions`, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        Authorization: `Bearer ${config.apiKey}`,
+      },
+      body: JSON.stringify({
+        model: config.modelName,
+        messages: [
+          {
+            role: 'user',
+            content: fullPrompt,
+          },
+        ],
+        temperature: 0.3,
+      }),
+    })
+
+    if (!response.ok) {
+      const errorData = await response.json().catch(() => ({}))
+      return {
+        text: '',
+        error: errorData.error?.message || `API error: ${response.statusText}`,
+      }
+    }
+
+    const data = await response.json()
+    const summarizedText = data.choices?.[0]?.message?.content?.trim() || ''
+
+    if (!summarizedText) {
+      return {
+        text: '',
+        error: 'No summary result',
+      }
+    }
+
+    return {
+      text: summarizedText,
+    }
+  } catch (error) {
+    return {
+      text: '',
+      error: error instanceof Error ? error.message : 'Unknown error',
+    }
+  }
+}
diff --git a/apps/reader/src/utils/translate.ts b/apps/reader/src/utils/translate.ts
new file mode 100644
index 00000000..da1a45c3
--- /dev/null
+++ b/apps/reader/src/utils/translate.ts
@@ -0,0 +1,69 @@
+import { AIConfig } from '../state'
+
+export interface TranslateResult {
+  text: string
+  error?: string
+}
+
+export async function translateText(
+  text: string,
+  config: AIConfig,
+): Promise<TranslateResult> {
+  if (!config.apiKey || !config.apiUrl || !config.modelName) {
+    return {
+      text: '',
+      error: 'AI configuration is incomplete',
+    }
+  }
+
+  const prompt =
+    config.translatePrompt || '请将以下文本翻译成中文，保持原文的格式和风格：'
+  const fullPrompt = `${prompt}\n\n${text}`
+
+  try {
+    const response = await fetch(`${config.apiUrl}/chat/completions`, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        Authorization: `Bearer ${config.apiKey}`,
+      },
+      body: JSON.stringify({
+        model: config.modelName,
+        messages: [
+          {
+            role: 'user',
+            content: fullPrompt,
+          },
+        ],
+        temperature: 0.3,
+      }),
+    })
+
+    if (!response.ok) {
+      const errorData = await response.json().catch(() => ({}))
+      return {
+        text: '',
+        error: errorData.error?.message || `API error: ${response.statusText}`,
+      }
+    }
+
+    const data = await response.json()
+    const translatedText = data.choices?.[0]?.message?.content?.trim() || ''
+
+    if (!translatedText) {
+      return {
+        text: '',
+        error: 'No translation result',
+      }
+    }
+
+    return {
+      text: translatedText,
+    }
+  } catch (error) {
+    return {
+      text: '',
+      error: error instanceof Error ? error.message : 'Unknown error',
+    }
+  }
+}
diff --git a/apps/reader/src/utils/translatePage.ts b/apps/reader/src/utils/translatePage.ts
new file mode 100644
index 00000000..ce7d2d6f
--- /dev/null
+++ b/apps/reader/src/utils/translatePage.ts
@@ -0,0 +1,188 @@
+import { AIConfig } from '../state'
+
+import { translateText } from './translate'
+
+export interface Paragraph {
+  element: HTMLElement
+  text: string
+  translatedText?: string
+}
+
+/**
+ * Extract paragraphs from the document
+ */
+export function extractParagraphs(doc: Document): Paragraph[] {
+  const paragraphs: Paragraph[] = []
+  const body = doc.body
+
+  // Get all text-containing elements (p, div, li, etc.)
+  const walker = doc.createTreeWalker(body, NodeFilter.SHOW_ELEMENT, {
+    acceptNode: (node: Node) => {
+      const el = node as HTMLElement
+      // Skip if already processed (has translation)
+      if (el.dataset.translated === 'true') {
+        return NodeFilter.FILTER_REJECT
+      }
+      // Only process block-level elements with text
+      const tagName = el.tagName.toLowerCase()
+      if (
+        [
+          'p',
+          'div',
+          'li',
+          'h1',
+          'h2',
+          'h3',
+          'h4',
+          'h5',
+          'h6',
+          'blockquote',
+        ].includes(tagName) &&
+        el.textContent?.trim()
+      ) {
+        return NodeFilter.FILTER_ACCEPT
+      }
+      return NodeFilter.FILTER_SKIP
+    },
+  })
+
+  let node: Node | null
+  while ((node = walker.nextNode())) {
+    const el = node as HTMLElement
+    const text = el.textContent?.trim()
+    if (text && text.length > 0) {
+      paragraphs.push({
+        element: el,
+        text,
+      })
+    }
+  }
+
+  return paragraphs
+}
+
+/**
+ * Translate paragraphs and insert translations into the document
+ */
+export async function translatePage(
+  paragraphs: Paragraph[],
+  config: AIConfig,
+  onProgress?: (progress: number) => void,
+): Promise<{ success: number; failed: number }> {
+  let success = 0
+  let failed = 0
+
+  for (let i = 0; i < paragraphs.length; i++) {
+    const paragraph = paragraphs[i]
+
+    try {
+      // Translate the paragraph
+      const result = await translateText(paragraph.text, config)
+
+      if (result.error || !result.text) {
+        failed++
+        continue
+      }
+
+      paragraph.translatedText = result.text
+
+      // Insert translation after the original paragraph
+      insertTranslation(paragraph.element, result.text)
+
+      success++
+
+      // Report progress
+      if (onProgress) {
+        onProgress((i + 1) / paragraphs.length)
+      }
+
+      // Small delay to avoid overwhelming the API
+      await new Promise((resolve) => setTimeout(resolve, 100))
+    } catch (error) {
+      failed++
+      console.error('Translation error:', error)
+    }
+  }
+
+  return { success, failed }
+}
+
+/**
+ * Insert translation element after the original paragraph
+ */
+function insertTranslation(
+  originalElement: HTMLElement,
+  translatedText: string,
+) {
+  // Mark original element as translated
+  originalElement.dataset.translated = 'true'
+
+  // Create translation container
+  const translationContainer =
+    originalElement.ownerDocument.createElement('div')
+  translationContainer.className = 'ai-translation'
+  translationContainer.setAttribute('data-ai-translation', 'true')
+
+  // Use CSS variables for better theme support
+  translationContainer.style.cssText = `
+    margin-top: 0.5em;
+    margin-bottom: 1em;
+    padding: 0.75em;
+    padding-left: 1em;
+    background-color: rgba(128, 128, 128, 0.08);
+    border-left: 3px solid rgba(128, 128, 128, 0.2);
+    font-style: italic;
+    opacity: 0.85;
+    transition: opacity 0.2s;
+  `
+
+  // Add hover effect
+  translationContainer.addEventListener('mouseenter', () => {
+    translationContainer.style.opacity = '1'
+  })
+  translationContainer.addEventListener('mouseleave', () => {
+    translationContainer.style.opacity = '0.85'
+  })
+
+  // Create translation text element
+  const translationText = originalElement.ownerDocument.createElement('p')
+  translationText.textContent = translatedText
+  translationText.style.cssText = `
+    margin: 0;
+    line-height: 1.6;
+    word-wrap: break-word;
+    overflow-wrap: break-word;
+  `
+
+  translationContainer.appendChild(translationText)
+
+  // Insert after the original element
+  if (originalElement.nextSibling) {
+    originalElement.parentNode?.insertBefore(
+      translationContainer,
+      originalElement.nextSibling,
+    )
+  } else {
+    originalElement.parentNode?.appendChild(translationContainer)
+  }
+
+  // Trigger a small delay to ensure DOM is updated before reflow
+  setTimeout(() => {
+    // Force reflow to ensure layout is updated
+    void translationContainer.offsetHeight
+  }, 0)
+}
+
+/**
+ * Remove all translations from the page
+ */
+export function removeTranslations(doc: Document) {
+  const translations = doc.querySelectorAll('.ai-translation')
+  translations.forEach((el) => el.remove())
+
+  // Remove translated markers
+  const translatedElements = doc.querySelectorAll('[data-translated="true"]')
+  translatedElements.forEach((el) => {
+    delete (el as HTMLElement).dataset.translated
+  })
+}

From b56d08021c5f5f49db3ea05fc47606be954e3dbd Mon Sep 17 00:00:00 2001
From: Ivoripuion <1049971327@qq.com>
Date: Wed, 14 Jan 2026 01:02:58 +0800
Subject: [PATCH 2/3] add copy button

---
 apps/reader/src/components/TranslatePopup.tsx | 35 +++++++++++++++----
 1 file changed, 28 insertions(+), 7 deletions(-)

diff --git a/apps/reader/src/components/TranslatePopup.tsx b/apps/reader/src/components/TranslatePopup.tsx
index d85273b0..f3135f97 100644
--- a/apps/reader/src/components/TranslatePopup.tsx
+++ b/apps/reader/src/components/TranslatePopup.tsx
@@ -1,7 +1,7 @@
 import { Overlay } from '@literal-ui/core'
 import clsx from 'clsx'
 import { useEffect, useRef, useState } from 'react'
-import { MdClose, MdTranslate } from 'react-icons/md'
+import { MdClose, MdTranslate, MdContentCopy } from 'react-icons/md'
 
 import { useAIConfig, useTranslation } from '../hooks'
 import { scale } from '../platform'
@@ -66,6 +66,16 @@ export const TranslatePopup: React.FC<TranslatePopupProps> = ({
     }
   }, [translatedText, loading, error])
 
+  // copy button
+  const handleCopy = async () => {
+    if (!translatedText) return
+    try {
+      await navigator.clipboard.writeText(translatedText)
+    } catch (err) {
+      console.error('Failed to copy text:', err)
+    }
+  }
+
   // Calculate position relative to container
   const anchorLeft = anchorRect.left + viewRect.left - containerRect.left
   const anchorTop = anchorRect.top + viewRect.top - containerRect.top
@@ -145,12 +155,23 @@ export const TranslatePopup: React.FC<TranslatePopupProps> = ({
               {t('translate')}
             </span>
           </div>
-          <IconButton
-            title={t('close')}
-            Icon={MdClose}
-            size={scale(18, 20)}
-            onClick={onClose}
-          />
+          <div className="flex gap-1">
+            {/* 复制按钮：仅在有有效翻译结果时显示 */}
+            {!loading && !error && translatedText && (
+              <IconButton
+                title={t('copy')}
+                Icon={MdContentCopy}
+                size={scale(18, 20)}
+                onClick={handleCopy}
+              />
+            )}
+            <IconButton
+              title={t('close')}
+              Icon={MdClose}
+              size={scale(18, 20)}
+              onClick={onClose}
+            />
+          </div>
         </div>
         <div className="typescale-body-medium text-on-surface">
           {loading && (

From b8b7ae93342c389c8313c01abc775ebb32d33561 Mon Sep 17 00:00:00 2001
From: Ivoripuion <1049971327@qq.com>
Date: Thu, 15 Jan 2026 00:38:35 +0800
Subject: [PATCH 3/3] add copied

---
 apps/reader/src/components/TranslatePopup.tsx | 12 ++++++++----
 1 file changed, 8 insertions(+), 4 deletions(-)

diff --git a/apps/reader/src/components/TranslatePopup.tsx b/apps/reader/src/components/TranslatePopup.tsx
index f3135f97..9ff012fb 100644
--- a/apps/reader/src/components/TranslatePopup.tsx
+++ b/apps/reader/src/components/TranslatePopup.tsx
@@ -1,7 +1,7 @@
 import { Overlay } from '@literal-ui/core'
 import clsx from 'clsx'
 import { useEffect, useRef, useState } from 'react'
-import { MdClose, MdTranslate, MdContentCopy } from 'react-icons/md'
+import { MdClose, MdTranslate, MdContentCopy, MdCheck } from 'react-icons/md'
 
 import { useAIConfig, useTranslation } from '../hooks'
 import { scale } from '../platform'
@@ -31,6 +31,7 @@ export const TranslatePopup: React.FC<TranslatePopupProps> = ({
   const popupRef = useRef<HTMLDivElement>(null)
   const [width, setWidth] = useState(300) // Default width
   const [height, setHeight] = useState(100) // Default height
+  const [copied, setCopied] = useState(false)
 
   useEffect(() => {
     if (!config.apiKey || !config.apiUrl || !config.modelName) {
@@ -71,6 +72,8 @@ export const TranslatePopup: React.FC<TranslatePopupProps> = ({
     if (!translatedText) return
     try {
       await navigator.clipboard.writeText(translatedText)
+      setCopied(true)
+      setTimeout(() => setCopied(false), 1500)
     } catch (err) {
       console.error('Failed to copy text:', err)
     }
@@ -156,13 +159,14 @@ export const TranslatePopup: React.FC<TranslatePopupProps> = ({
             </span>
           </div>
           <div className="flex gap-1">
-            {/* 复制按钮：仅在有有效翻译结果时显示 */}
+            {/* 复制按钮：成功后短暂显示 ✔️ */}
             {!loading && !error && translatedText && (
               <IconButton
-                title={t('copy')}
-                Icon={MdContentCopy}
+                title={copied ? t('copied') : t('copy')}
+                Icon={copied ? MdCheck : MdContentCopy}
                 size={scale(18, 20)}
                 onClick={handleCopy}
+                className={copied ? 'text-success' : ''}
               />
             )}
             <IconButton
