diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ead319e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Build + run: npm run build diff --git a/docs/local-ai.md b/docs/local-ai.md new file mode 100644 index 0000000..596d8fb --- /dev/null +++ b/docs/local-ai.md @@ -0,0 +1,123 @@ +# Chrome Built-in AI / Local AI + +Textura 的 AI 执行策略是 **Local-first AI + Cloud fallback**。 + +## 执行方式 + +| 模式 | 行为 | 云端上传边界 | +| --- | --- | --- | +| 智能选择(推荐) | 对应本地 API 已就绪、任务与语言支持时走本地;否则使用已配置 Cloud Provider | **可能上传**。任何 fallback 都会在 AI Assistant 中显示实际执行 Provider | +| Chrome 本地 AI | 只允许浏览器内置 AI;不可用时直接报错 | **不会上传**到 Cloud Provider | +| 仅云端 | 保持传统 Provider 行为 | 会直接发送给用户配置的 Provider | + +> Smart 模式不会自动下载模型。`downloadable` / `downloading` 都视为“尚未准备”,用户必须在偏好设置中显式点击准备按钮。 + +## 当前任务矩阵 + +| 任务 | 英文 Local | 中文 | Local API | +| --- | --- | --- | --- | +| 润色 | ✅ | Cloud fallback | Prompt API | +| 纠错 | ✅ | Cloud fallback | Prompt API | +| 扩写 | ✅ | Cloud fallback | Prompt API | +| 摘要 | ✅ | Cloud fallback | Summarizer API | +| 排版 | 暂不支持 | Cloud | — | + +当前生产语言边界跟随 Chrome Built-in AI 官方能力。Textura 目前只对英语开放本地业务路由;即使浏览器支持更多语言,新增业务语言也应先经过回归测试再开放。 + +## 模型准备 + +偏好设置 → AI 配置 → Chrome 本地 AI 会分别显示: + +- Prompt API:英文润色 / 纠错 / 扩写。 +- Summarizer API:英文摘要。 + +可能状态: + +- `available`:可以立即本地执行。 +- `downloadable`:需要用户主动准备/下载。 +- `downloading`:浏览器正在下载;Textura 不会自动把业务请求等待在下载链路上。 +- `unavailable`:浏览器、设备、系统资源或 API 不满足条件。 + +下载进度通过 `downloadprogress` 展示。准备动作必须来自用户直接点击。 + +## 隐私语义 + +### Smart + +Smart 是便利模式,不等价于“纯本地”。当以下情况发生时可以使用 Cloud fallback: + +- 当前任务未开放本地能力。 +- 当前文本语言不支持本地能力。 +- 对应 Built-in API 不存在或不可用。 +- 模型尚未准备完成。 +- 本地运行发生非用户主动取消的运行时错误。 + +AI Assistant 会显示“本次实际执行”,例如 `Chrome 本地 AI` 或 `DeepSeek(fallback)`。 + +### Explicit Local + +显式选择 `Chrome 本地 AI` 是严格隐私边界: + +- 不静默 fallback Cloud。 +- 不支持的语言直接报错。 +- 模型未准备直接提示去设置页准备。 +- 用户点击停止后不会转而调用 Cloud。 + +## 兼容性矩阵 + +下面是预期行为。发布前需要在真实环境中逐项验证。 + +| 环境 | Prompt API | Summarizer API | Textura 预期 | +| --- | --- | --- | --- | +| Chrome Desktop(满足设备条件) | capability 检测 | capability 检测 | available 时可本地执行 | +| Chrome Desktop(模型未下载) | downloadable/downloading | downloadable/downloading | Smart 走 Cloud;设置页允许显式准备 | +| Edge / Chromium 但无对应 API | unavailable | unavailable | 不报 JS 错误,Smart 走 Cloud | +| Safari | unavailable | unavailable | 不报 JS 错误,Smart 走 Cloud | +| Firefox | unavailable | unavailable | 不报 JS 错误,Smart 走 Cloud | +| Chrome Android / iOS | unavailable | unavailable | 不尝试本地执行 | +| Tauri WebView | 取决于 WebView | 取决于 WebView | capability 为准,不按 UA 猜测 | + +## 回归清单 + +### Capability / 下载 + +1. 非支持浏览器打开设置页,无未捕获异常。 +2. Prompt / Summarizer 状态分别可见。 +3. `downloadable` 不会因为点击“开始生成”自动下载。 +4. 点击准备按钮后能显示下载进度。 +5. 下载完成后重新检测为 `available`。 + +### 路由 + +1. Smart + English polish + Prompt available → Local。 +2. Smart + English summarize + Summarizer available → Local。 +3. Smart + Chinese → Cloud fallback。 +4. Smart + model downloadable → Cloud fallback。 +5. Explicit Local + Chinese → error,Cloud 不应收到请求。 +6. Explicit Local + format → UI 禁用 / 不调用 Cloud。 +7. Explicit Cloud → 始终使用原 Provider。 + +### 流式与取消 + +1. Prompt API 流式文本持续更新。 +2. Summarizer API 流式摘要持续更新。 +3. 本地生成过程中点击“停止生成”能终止读取。 +4. 用户主动停止后不得触发 Cloud fallback。 + +### 兼容旧配置 + +1. 原 DeepSeek / OpenAI / Anthropic / Qwen / Doubao / Zhipu / Ollama / Custom 配置不丢失。 +2. 切换 Local/Smart 不修改原 API Key 与模型。 +3. 再切回 Cloud 后原配置继续可用。 + +## CI + +Pull Request 会执行: + +```bash +npm ci +npm run lint +npm run build +``` + +CI 用于阻止 TypeScript / ESLint / Next.js 构建回归;Chrome Built-in AI 的真实 availability、下载和本地推理仍需要真实 Chrome 设备做人工回归。 diff --git a/src/components/editor/AiAssistDialog.tsx b/src/components/editor/AiAssistDialog.tsx index 5955bf4..136d580 100644 --- a/src/components/editor/AiAssistDialog.tsx +++ b/src/components/editor/AiAssistDialog.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { useEditorStore } from '@/store/useEditorStore'; +import { useLocalAiStore } from '@/store/localAiStore'; import { Button } from '@/components/ui/button'; import { Textarea } from '@/components/ui/textarea'; import { @@ -11,12 +12,13 @@ import { DialogFooter, DialogHeader, DialogTitle, -} from "@/components/ui/dialog"; +} from '@/components/ui/dialog'; import { Label } from '@/components/ui/label'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Copy, ExternalLink, ArrowRight, Bot, Sparkles, Zap, Loader2, Settings } from 'lucide-react'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Copy, ExternalLink, ArrowRight, Bot, Sparkles, Zap, Loader2, Settings, Square } from 'lucide-react'; import { toast } from 'sonner'; -import { callAiFormatting, type AiTaskMode } from '@/lib/aiService'; +import type { AiTaskMode } from '@/lib/aiService'; +import { executeAiTask, type AiExecutionMeta } from '@/lib/ai/local'; import { openExternalLink } from '@/lib/link'; interface AiAssistDialogProps { @@ -40,33 +42,52 @@ const TASK_MODES: { id: AiTaskMode; label: string }[] = [ { id: 'fix', label: '纠错' }, ]; +const LOCAL_PROMPT_TASKS = new Set(['polish', 'summarize', 'expand', 'fix']); + +function hasCloudConfig(provider: string, apiKey: string, customApiUrl?: string) { + if (provider === 'none') return false; + if (provider === 'ollama') return true; + if (provider === 'custom' && !customApiUrl?.trim()) return false; + return Boolean(apiKey.trim()); +} + +function executionModeLabel(mode: 'smart' | 'chrome-built-in' | 'cloud') { + if (mode === 'smart') return '智能选择'; + if (mode === 'chrome-built-in') return 'Chrome 本地 AI'; + return '仅云端'; +} + export function AiAssistDialog({ open, onOpenChange, selectedText }: AiAssistDialogProps) { const { markdown, setMarkdown, aiProvider, setAiProvider, aiApiConfig, setSettingsOpen } = useEditorStore(); + const executionMode = useLocalAiStore((s) => s.executionMode); const [resultText, setResultText] = React.useState(''); const [isLoading, setIsLoading] = React.useState(false); const [mode, setMode] = React.useState<'api' | 'manual'>('api'); const [taskMode, setTaskMode] = React.useState('format'); + const [lastExecution, setLastExecution] = React.useState(null); + const abortControllerRef = React.useRef(null); - // Determine the content to process: selected text or full document const effectiveContent = selectedText && selectedText.trim() ? selectedText : markdown; const isProcessingSelection = Boolean(selectedText && selectedText.trim()); - - // 当对话框打开时,检查API是否配置 + const cloudConfigured = hasCloudConfig(aiApiConfig.provider, aiApiConfig.apiKey, aiApiConfig.customApiUrl); + const canUseApi = executionMode !== 'cloud' || cloudConfigured; + React.useEffect(() => { - if (open) { - if (aiApiConfig.provider === 'none' || !aiApiConfig.apiKey) { - setMode('manual'); - } else { - setMode('api'); - } + if (!open) return; + setMode(canUseApi ? 'api' : 'manual'); + setLastExecution(null); + }, [open, canUseApi]); + + React.useEffect(() => { + if (executionMode === 'chrome-built-in' && !LOCAL_PROMPT_TASKS.has(taskMode)) { + setTaskMode('polish'); } - }, [open, aiApiConfig]); + }, [executionMode, taskMode]); - const taskModeLabel = TASK_MODES.find(m => m.id === taskMode)?.label || '排版'; - const prompt = `请对以下内容进行「${taskModeLabel}」处理。 + React.useEffect(() => () => abortControllerRef.current?.abort(), []); -【原文内容】: -${effectiveContent}`; + const taskModeLabel = TASK_MODES.find((item) => item.id === taskMode)?.label || '排版'; + const prompt = `请对以下内容进行「${taskModeLabel}」处理。\n\n【原文内容】:\n${effectiveContent}`; const handleCopyPrompt = async () => { try { @@ -87,49 +108,72 @@ ${effectiveContent}`; document.execCommand('copy'); document.body.removeChild(textarea); toast.success('Prompt 已复制到剪贴板'); - } catch (e) { - console.error('Copy failed:', e); + } catch (error) { + console.error('Copy failed:', error); toast.error('复制失败,请手动复制'); } } }; const handleOpenAi = () => { - const provider = AI_PROVIDERS.find(p => p.id === aiProvider); + const provider = AI_PROVIDERS.find((item) => item.id === aiProvider); if (provider) { openExternalLink(provider.url); toast.info(`已打开 ${provider.name},请粘贴 Prompt`); } }; - // API模式调用AI const handleApiCall = async () => { if (!effectiveContent.trim()) { toast.error(isProcessingSelection ? '请先选中一些文字' : '请先输入一些内容'); return; } + const controller = new AbortController(); + abortControllerRef.current = controller; setIsLoading(true); setResultText(''); + setLastExecution(null); try { - const result = await callAiFormatting(aiApiConfig, effectiveContent, (chunk) => { - setResultText(chunk); - }, taskMode); + const result = await executeAiTask({ + config: aiApiConfig, + executionMode, + content: effectiveContent, + taskMode, + signal: controller.signal, + onChunk: (chunk) => setResultText(chunk), + }); + + setLastExecution(result.execution); if (result.success && result.content) { setResultText(result.content); - toast.success(`AI ${taskModeLabel}完成!`); + if (result.execution.provider === 'chrome-built-in') { + toast.success(`Chrome 本地 AI ${taskModeLabel}完成`); + } else if (result.execution.fallback) { + toast.success(`已通过 ${result.execution.cloudProvider || '云端 AI'} fallback 完成${taskModeLabel}`); + } else { + toast.success(`AI ${taskModeLabel}完成!`); + } + } else if (result.execution.reason === 'local-aborted') { + toast.info('已停止本地 AI 生成'); } else { toast.error(result.error || '调用失败'); } - } catch { - toast.error('调用失败,请检查 API 配置'); + } catch (error) { + console.error('[AI Assist] execution failed:', error); + toast.error('调用失败,请检查 AI 配置'); } finally { + abortControllerRef.current = null; setIsLoading(false); } }; + const handleStop = () => { + abortControllerRef.current?.abort(); + }; + const handleApply = () => { if (!resultText.trim()) { toast.error('请先生成或粘贴内容'); @@ -137,13 +181,11 @@ ${effectiveContent}`; } if (isProcessingSelection && selectedText) { - // Replace only the selected portion in the full markdown const idx = markdown.indexOf(selectedText); if (idx !== -1) { const newMarkdown = markdown.substring(0, idx) + resultText + markdown.substring(idx + selectedText.length); setMarkdown(newMarkdown); } else { - // Fallback: if exact match not found, replace the whole document setMarkdown(resultText); } } else { @@ -155,13 +197,6 @@ ${effectiveContent}`; setResultText(''); }; - const hasApiConfig = aiApiConfig.provider !== 'none' && ( - aiApiConfig.provider === 'ollama' || aiApiConfig.apiKey.trim() !== '' - ); - - // 检查是否可以进行API调用 - const canUseApi = aiApiConfig.provider !== 'none' && (aiApiConfig.provider === 'ollama' || aiApiConfig.apiKey.trim() !== ''); - return ( @@ -171,9 +206,9 @@ ${effectiveContent}`; AI 辅助 - {hasApiConfig - ? '选择任务模式和操作方式,AI 将帮你处理内容。' - : '请在设置中配置 AI API 以使用一键处理功能,或使用手动模式。'} + {canUseApi + ? '选择任务模式和执行方式,Textura 会按本地优先策略处理内容。' + : '当前为仅云端模式,请先配置 AI API,或使用手动模式。'} {isProcessingSelection && (
@@ -188,14 +223,13 @@ ${effectiveContent}`;
)} - +
- {/* Mode Tabs */} - setMode(v as 'api' | 'manual')} className="w-full"> + setMode(value as 'api' | 'manual')} className="w-full"> - API 一键处理 + 一键处理 @@ -203,41 +237,64 @@ ${effectiveContent}`; - {/* API Mode */} {canUseApi ? ( <> - {/* Task Mode Selector */}
- {TASK_MODES.map((tm) => ( - - ))} + {TASK_MODES.map((item) => { + const disabled = executionMode === 'chrome-built-in' && !LOCAL_PROMPT_TASKS.has(item.id); + return ( + + ); + })}
- {/* Current Config Info */} -
+
- 当前配置: - {aiApiConfig.provider} + 执行方式: + {executionModeLabel(executionMode)}
-
- 模型: - {aiApiConfig.model} +
+ 云端 Provider: + {aiApiConfig.provider}
+ {aiApiConfig.provider !== 'none' && ( +
+ 云端模型: + {aiApiConfig.model || '-'} +
+ )} + {lastExecution && ( +
+ 本次实际执行: + + {lastExecution.provider === 'chrome-built-in' + ? 'Chrome 本地 AI' + : `${lastExecution.cloudProvider || 'Cloud'}${lastExecution.fallback ? '(fallback)' : ''}`} + +
+ )}
- {/* Preview of content */} + {executionMode === 'smart' && ( +
+ 智能选择可能在本地任务不支持、模型未准备或运行失败时使用已配置的云端 Provider;实际执行方式会显示在上方。 +
+ )} +
- {/* Result */}