diff --git a/src/app/service/agent/service_worker/agent.ts b/src/app/service/agent/service_worker/agent.ts index 37661d45d..f04e00602 100644 --- a/src/app/service/agent/service_worker/agent.ts +++ b/src/app/service/agent/service_worker/agent.ts @@ -44,6 +44,7 @@ import { AgentOPFSService } from "./opfs_service"; import { executeSkillScript } from "@App/app/service/offscreen/client"; import { createTabTools } from "@App/app/service/agent/core/tools/tab_tools"; import { ChatService } from "./chat_service"; +import { PromptOptimizerService } from "./prompt_optimizer_service"; // 保留对外 API(测试文件直接从 "./agent" import 这三个函数) export { isRetryableError, withRetry, classifyErrorCode } from "./retry_utils"; @@ -78,6 +79,7 @@ export class AgentService { private toolLoopOrchestrator!: ToolLoopOrchestrator; // 主聊天入口及会话 CRUD 委托给 ChatService private chatService!: ChatService; + private promptOptimizerService: PromptOptimizerService; constructor( private group: Group, @@ -88,6 +90,7 @@ export class AgentService { this.modelService = new AgentModelService(group); this.opfsService = new AgentOPFSService(sender); this.llmClient = new LLMClient(agentChatRepo); + this.promptOptimizerService = new PromptOptimizerService(this.modelService, this.llmClient); this.compactService = new CompactService( this.modelService, { @@ -155,6 +158,12 @@ export class AgentService { this.group.on("attachToConversation", this.handleAttachToConversation.bind(this)); // 获取正在运行的会话 ID 列表 this.group.on("getRunningConversationIds", () => this.getRunningConversationIds()); + this.group.on("optimizePrompt", (params: { requestId: string; prompt: string; modelId?: string }) => + this.promptOptimizerService.optimizePrompt(params) + ); + this.group.on("cancelPromptOptimization", (requestId: string) => + this.promptOptimizerService.cancelOptimization(requestId) + ); // Skill 管理(供 Options UI 调用) this.group.on( "installSkill", diff --git a/src/app/service/agent/service_worker/prompt_optimizer_service.test.ts b/src/app/service/agent/service_worker/prompt_optimizer_service.test.ts new file mode 100644 index 000000000..0ab7d7d94 --- /dev/null +++ b/src/app/service/agent/service_worker/prompt_optimizer_service.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from "vitest"; +import type { AgentModelConfig } from "../core/types"; +import { PROMPT_OPTIMIZER_SYSTEM_PROMPT, PromptOptimizerService } from "./prompt_optimizer_service"; + +describe("提示词优化服务", () => { + it("应使用所选模型并要求保留原意和输入语言", async () => { + const model: AgentModelConfig = { + id: "model-1", + name: "Model 1", + provider: "openai", + apiBaseUrl: "https://example.com", + apiKey: "test-key", + model: "model-1", + }; + const getModel = vi.fn().mockResolvedValue(model); + const callLLM = vi.fn().mockResolvedValue({ content: " 优化后的提示词 " }); + const service = new PromptOptimizerService({ getModel }, { callLLM }); + + await expect( + service.optimizePrompt({ requestId: "request-1", prompt: " 帮我分析数据 ", modelId: "model-1" }) + ).resolves.toBe("优化后的提示词"); + + expect(getModel).toHaveBeenCalledWith("model-1"); + expect(PROMPT_OPTIMIZER_SYSTEM_PROMPT).toContain("same language"); + expect(PROMPT_OPTIMIZER_SYSTEM_PROMPT).toContain("Preserve the original intent"); + expect(callLLM).toHaveBeenCalledWith( + model, + { + messages: [ + { role: "system", content: PROMPT_OPTIMIZER_SYSTEM_PROMPT }, + { role: "user", content: "帮我分析数据" }, + ], + cache: false, + }, + expect.any(Function), + expect.any(AbortSignal) + ); + }); + + it("空输入或空响应应报错", async () => { + const getModel = vi.fn().mockResolvedValue({ id: "model-1" }); + const callLLM = vi.fn().mockResolvedValue({ content: " " }); + const service = new PromptOptimizerService({ getModel }, { callLLM }); + + await expect(service.optimizePrompt({ requestId: "empty", prompt: " ", modelId: "model-1" })).rejects.toThrow( + "Prompt cannot be empty" + ); + await expect( + service.optimizePrompt({ requestId: "empty-response", prompt: "draft", modelId: "model-1" }) + ).rejects.toThrow("empty response"); + }); + + it("取消请求时应中止进行中的模型调用", async () => { + let signal: AbortSignal | undefined; + const callLLM = vi.fn((_model, _params, _sendEvent, callSignal: AbortSignal) => { + signal = callSignal; + return new Promise((_resolve, reject) => { + callSignal.addEventListener("abort", () => reject(callSignal.reason), { once: true }); + }); + }); + const service = new PromptOptimizerService({ getModel: vi.fn().mockResolvedValue({ id: "model-1" }) }, { callLLM }); + + const optimizing = service.optimizePrompt({ requestId: "request-to-cancel", prompt: "draft" }); + await vi.waitFor(() => expect(signal).toBeDefined()); + expect(service.cancelOptimization("request-to-cancel")).toBe(true); + + await expect(optimizing).rejects.toBeDefined(); + expect(signal?.aborted).toBe(true); + }); +}); diff --git a/src/app/service/agent/service_worker/prompt_optimizer_service.ts b/src/app/service/agent/service_worker/prompt_optimizer_service.ts new file mode 100644 index 000000000..fba43bde0 --- /dev/null +++ b/src/app/service/agent/service_worker/prompt_optimizer_service.ts @@ -0,0 +1,71 @@ +import type { AgentModelConfig, ChatRequest, ChatStreamEvent } from "../core/types"; +import type { LLMCallResult } from "./llm_client"; + +export const PROMPT_OPTIMIZER_SYSTEM_PROMPT = + "You are a prompt engineering expert. Rewrite the user's raw input into a clear, structured, and actionable prompt that an AI agent can execute. Preserve the original intent, requirements, constraints, and factual details. Always respond in the same language as the user's input. Output only the optimized prompt text, with no explanation, preamble, or markdown fence."; + +interface PromptOptimizerModelSource { + getModel(modelId?: string): Promise; +} + +interface PromptOptimizerLLM { + callLLM( + model: AgentModelConfig, + params: { messages: ChatRequest["messages"]; cache?: boolean }, + sendEvent: (event: ChatStreamEvent) => void, + signal: AbortSignal + ): Promise; +} + +export class PromptOptimizerService { + private readonly activeRequests = new Map(); + + constructor( + private readonly modelSource: PromptOptimizerModelSource, + private readonly llm: PromptOptimizerLLM + ) {} + + async optimizePrompt(params: { requestId: string; prompt: string; modelId?: string }): Promise { + const prompt = params.prompt.trim(); + if (!prompt) throw new Error("Prompt cannot be empty"); + + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(new DOMException("Prompt optimization timed out", "TimeoutError")), + 60_000 + ); + this.activeRequests.set(params.requestId, controller); + + try { + const model = await this.modelSource.getModel(params.modelId); + const result = await this.llm.callLLM( + model, + { + messages: [ + { role: "system", content: PROMPT_OPTIMIZER_SYSTEM_PROMPT }, + { role: "user", content: prompt }, + ], + cache: false, + }, + () => {}, + controller.signal + ); + const optimized = result.content.trim(); + if (!optimized) throw new Error("Prompt optimizer returned an empty response"); + return optimized; + } finally { + clearTimeout(timeout); + if (this.activeRequests.get(params.requestId) === controller) { + this.activeRequests.delete(params.requestId); + } + } + } + + cancelOptimization(requestId: string): boolean { + const controller = this.activeRequests.get(requestId); + if (!controller) return false; + controller.abort(new DOMException("Prompt optimization cancelled", "AbortError")); + this.activeRequests.delete(requestId); + return true; + } +} diff --git a/src/app/service/service_worker/client.ts b/src/app/service/service_worker/client.ts index ac046020d..5bf90d25c 100644 --- a/src/app/service/service_worker/client.ts +++ b/src/app/service/service_worker/client.ts @@ -488,6 +488,14 @@ export class AgentClient extends Client { return this.do("setSummaryModelId", id); } + optimizePrompt(params: { requestId: string; prompt: string; modelId?: string }): Promise { + return this.doThrow("optimizePrompt", params); + } + + cancelPromptOptimization(requestId: string): Promise { + return this.do("cancelPromptOptimization", requestId).then(() => undefined); + } + // 搜索配置 getSearchConfig(): Promise { return this.doThrow("getSearchConfig"); diff --git a/src/locales/de-DE/agent.json b/src/locales/de-DE/agent.json index 40d0543ee..cf4d88c5d 100644 --- a/src/locales/de-DE/agent.json +++ b/src/locales/de-DE/agent.json @@ -107,6 +107,10 @@ "chat_cancel_edit": "Abbrechen", "chat_message_queued": "In Warteschlange", "chat_cancel_message": "Senden abbrechen", + "chat_prompt_optimize": "Prompt optimieren", + "chat_prompt_optimizing": "Prompt wird optimiert…", + "chat_prompt_optimized": "Prompt optimiert", + "chat_prompt_optimize_failed": "Prompt konnte nicht optimiert werden: {{error}}", "permission_title": "Das Skript fordert die Verwendung des Agent-Gesprächs an", "permission_describe": "Dieses Skript fordert Zugang zur Agent-Gesprächsfunktion an, was API-Token verbraucht. Erlauben Sie nur vertrauenswürdigen Skripten.", "permission_content": "Agent-Gespräch", diff --git a/src/locales/en-US/agent.json b/src/locales/en-US/agent.json index d4615aab2..81a5268da 100644 --- a/src/locales/en-US/agent.json +++ b/src/locales/en-US/agent.json @@ -107,6 +107,10 @@ "chat_cancel_edit": "Cancel", "chat_message_queued": "Queued", "chat_cancel_message": "Cancel send", + "chat_prompt_optimize": "Optimize prompt", + "chat_prompt_optimizing": "Optimizing prompt…", + "chat_prompt_optimized": "Prompt optimized", + "chat_prompt_optimize_failed": "Could not optimize prompt: {{error}}", "permission_title": "The script is requesting to use Agent conversation", "permission_describe": "This script requests Agent conversation access, which will consume API tokens. Only grant access to trusted scripts.", "permission_content": "Agent Conversation", diff --git a/src/locales/ja-JP/agent.json b/src/locales/ja-JP/agent.json index c8b5c0ba8..e049c5d07 100644 --- a/src/locales/ja-JP/agent.json +++ b/src/locales/ja-JP/agent.json @@ -107,6 +107,10 @@ "chat_cancel_edit": "キャンセル", "chat_message_queued": "キュー中", "chat_cancel_message": "送信をキャンセル", + "chat_prompt_optimize": "プロンプトを最適化", + "chat_prompt_optimizing": "プロンプトを最適化中…", + "chat_prompt_optimized": "プロンプトを最適化しました", + "chat_prompt_optimize_failed": "プロンプトを最適化できませんでした: {{error}}", "permission_title": "スクリプトが Agent 会話の使用をリクエストしています", "permission_describe": "このスクリプトは Agent 会話機能の使用をリクエストしており、API トークンを消費します。信頼できるスクリプトにのみ許可してください。", "permission_content": "Agent 会話", diff --git a/src/locales/ko-KR/agent.json b/src/locales/ko-KR/agent.json index bd896e633..a05e24058 100644 --- a/src/locales/ko-KR/agent.json +++ b/src/locales/ko-KR/agent.json @@ -107,6 +107,10 @@ "chat_cancel_edit": "취소", "chat_message_queued": "대기 중", "chat_cancel_message": "전송 취소", + "chat_prompt_optimize": "프롬프트 최적화", + "chat_prompt_optimizing": "프롬프트 최적화 중…", + "chat_prompt_optimized": "프롬프트가 최적화되었습니다", + "chat_prompt_optimize_failed": "프롬프트를 최적화하지 못했습니다: {{error}}", "permission_title": "스크립트가 에이전트 대화 사용을 요청합니다", "permission_describe": "이 스크립트는 에이전트 대화 기능 사용을 요청하며, 이는 API 토큰을 소비합니다. 신뢰할 수 있는 스크립트에만 허용하세요.", "permission_content": "에이전트 대화", diff --git a/src/locales/pt-BR/agent.json b/src/locales/pt-BR/agent.json index d778352c6..bbfa5ad3e 100644 --- a/src/locales/pt-BR/agent.json +++ b/src/locales/pt-BR/agent.json @@ -107,6 +107,10 @@ "chat_cancel_edit": "Cancelar", "chat_message_queued": "Na fila", "chat_cancel_message": "Cancelar envio", + "chat_prompt_optimize": "Otimizar prompt", + "chat_prompt_optimizing": "Otimizando prompt…", + "chat_prompt_optimized": "Prompt otimizado", + "chat_prompt_optimize_failed": "Não foi possível otimizar o prompt: {{error}}", "permission_title": "O script está solicitando o uso da conversa do Agente", "permission_describe": "Este script solicita acesso à conversa do Agente, o que consumirá tokens da API. Conceda acesso apenas a scripts confiáveis.", "permission_content": "Conversa do Agente", diff --git a/src/locales/ru-RU/agent.json b/src/locales/ru-RU/agent.json index c2b7c7fa2..e172a53e9 100644 --- a/src/locales/ru-RU/agent.json +++ b/src/locales/ru-RU/agent.json @@ -107,6 +107,10 @@ "chat_cancel_edit": "Отмена", "chat_message_queued": "В очереди", "chat_cancel_message": "Отменить отправку", + "chat_prompt_optimize": "Оптимизировать промпт", + "chat_prompt_optimizing": "Оптимизация промпта…", + "chat_prompt_optimized": "Промпт оптимизирован", + "chat_prompt_optimize_failed": "Не удалось оптимизировать промпт: {{error}}", "permission_title": "Скрипт запрашивает использование Agent разговора", "permission_describe": "Этот скрипт запрашивает доступ к функции Agent разговора, что будет потреблять API токены. Разрешайте только доверенным скриптам.", "permission_content": "Agent разговор", diff --git a/src/locales/tr-TR/agent.json b/src/locales/tr-TR/agent.json index 3b1d4d14f..51993dcf1 100644 --- a/src/locales/tr-TR/agent.json +++ b/src/locales/tr-TR/agent.json @@ -107,6 +107,10 @@ "chat_cancel_edit": "İptal", "chat_message_queued": "Sıraya alındı", "chat_cancel_message": "Göndermeyi iptal et", + "chat_prompt_optimize": "İstemi optimize et", + "chat_prompt_optimizing": "İstem optimize ediliyor…", + "chat_prompt_optimized": "İstem optimize edildi", + "chat_prompt_optimize_failed": "İstem optimize edilemedi: {{error}}", "permission_title": "Betik, Ajan konuşmasını kullanmak istiyor", "permission_describe": "Bu betik, API tokenlerini tüketecek olan Ajan konuşma erişimi istiyor. Yalnızca güvenilir betiklere erişim izni verin.", "permission_content": "Ajan Konuşması", diff --git a/src/locales/vi-VN/agent.json b/src/locales/vi-VN/agent.json index 74115f117..5434fc95b 100644 --- a/src/locales/vi-VN/agent.json +++ b/src/locales/vi-VN/agent.json @@ -107,6 +107,10 @@ "chat_cancel_edit": "Hủy", "chat_message_queued": "Đang chờ", "chat_cancel_message": "Hủy gửi", + "chat_prompt_optimize": "Tối ưu hóa câu lệnh", + "chat_prompt_optimizing": "Đang tối ưu hóa câu lệnh…", + "chat_prompt_optimized": "Đã tối ưu hóa câu lệnh", + "chat_prompt_optimize_failed": "Không thể tối ưu hóa câu lệnh: {{error}}", "permission_title": "Script yêu cầu sử dụng cuộc trò chuyện Agent", "permission_describe": "Script này yêu cầu quyền truy cập chức năng cuộc trò chuyện Agent, sẽ tiêu thụ API token. Chỉ cấp quyền cho các script đáng tin cậy.", "permission_content": "Cuộc trò chuyện Agent", diff --git a/src/locales/zh-CN/agent.json b/src/locales/zh-CN/agent.json index 8743c1612..c949ecb2f 100644 --- a/src/locales/zh-CN/agent.json +++ b/src/locales/zh-CN/agent.json @@ -107,6 +107,10 @@ "chat_cancel_edit": "取消", "chat_message_queued": "排队中", "chat_cancel_message": "取消发送", + "chat_prompt_optimize": "优化提示词", + "chat_prompt_optimizing": "正在优化提示词…", + "chat_prompt_optimized": "提示词已优化", + "chat_prompt_optimize_failed": "无法优化提示词:{{error}}", "permission_title": "脚本请求使用 Agent 对话", "permission_describe": "此脚本请求使用 Agent 对话功能,将消耗 API Token。请仅对可信脚本授权。", "permission_content": "Agent 对话", diff --git a/src/locales/zh-TW/agent.json b/src/locales/zh-TW/agent.json index 678c7d573..ddecebf6c 100644 --- a/src/locales/zh-TW/agent.json +++ b/src/locales/zh-TW/agent.json @@ -107,6 +107,10 @@ "chat_cancel_edit": "取消", "chat_message_queued": "排隊中", "chat_cancel_message": "取消傳送", + "chat_prompt_optimize": "最佳化提示詞", + "chat_prompt_optimizing": "正在最佳化提示詞…", + "chat_prompt_optimized": "提示詞已最佳化", + "chat_prompt_optimize_failed": "無法最佳化提示詞:{{error}}", "permission_title": "腳本請求使用 Agent 對話", "permission_describe": "此腳本請求使用 Agent 對話功能,將消耗 API Token。請僅對可信腳本授權。", "permission_content": "Agent 對話", diff --git a/src/pages/options/routes/Agent/Chat/ChatArea.tsx b/src/pages/options/routes/Agent/Chat/ChatArea.tsx index 67f63acbd..2cb238367 100644 --- a/src/pages/options/routes/Agent/Chat/ChatArea.tsx +++ b/src/pages/options/routes/Agent/Chat/ChatArea.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { notify } from "@App/pages/components/ui/toast"; +import { agentClient } from "@App/pages/store/features/script"; import { Bot } from "lucide-react"; import type { AgentModelConfig, @@ -766,6 +767,8 @@ export default function ChatArea({ backgroundEnabled={backgroundEnabled} onBackgroundEnabledChange={onBackgroundEnabledChange} hasPendingMessage={pendingMessageId !== null} + onOptimizePrompt={(prompt, modelId, requestId) => agentClient.optimizePrompt({ requestId, prompt, modelId })} + onCancelOptimizePrompt={(requestId) => agentClient.cancelPromptOptimization(requestId)} /> {noModel && (
diff --git a/src/pages/options/routes/Agent/Chat/ChatInput.test.tsx b/src/pages/options/routes/Agent/Chat/ChatInput.test.tsx index 68e1be72f..8901c4669 100644 --- a/src/pages/options/routes/Agent/Chat/ChatInput.test.tsx +++ b/src/pages/options/routes/Agent/Chat/ChatInput.test.tsx @@ -1,12 +1,16 @@ import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; -import { render, cleanup, screen, fireEvent } from "@testing-library/react"; +import { StrictMode } from "react"; +import { render, cleanup, screen, fireEvent, waitFor } from "@testing-library/react"; import { t } from "@App/locales/locales"; import { initTestLanguage } from "@Tests/initTestLanguage"; import type { AgentModelConfig, SkillSummary } from "@App/app/service/agent/core/types"; import ChatInput from "./ChatInput"; beforeAll(() => initTestLanguage("zh-CN")); -afterEach(() => cleanup()); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); const model = (id: string): AgentModelConfig => ({ id, @@ -91,4 +95,96 @@ describe("聊天输入框 ChatInput", () => { fireEvent.mouseDown(screen.getByTestId("slash-item-search")); expect(ta.value).toBe("/search "); }); + + it("优化提示词时禁用输入并用模型响应替换内容", async () => { + let resolveOptimize!: (value: string) => void; + const onOptimizePrompt = vi.fn().mockReturnValue( + new Promise((resolve) => { + resolveOptimize = resolve; + }) + ); + setup({ onOptimizePrompt }); + const textarea = screen.getByTestId("chat-textarea") as HTMLTextAreaElement; + fireEvent.change(textarea, { target: { value: " 原始提示词 " } }); + fireEvent.click(screen.getByTestId("chat-optimize-prompt")); + + expect(onOptimizePrompt).toHaveBeenCalledWith("原始提示词", "gpt-4o", expect.any(String)); + expect(textarea).toBeDisabled(); + resolveOptimize("优化后的提示词"); + + await waitFor(() => expect(textarea.value).toBe("优化后的提示词")); + expect(textarea).not.toBeDisabled(); + expect(textarea).toHaveFocus(); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); + + it("提示词优化进行中应向辅助技术公告状态", () => { + setup({ onOptimizePrompt: vi.fn().mockReturnValue(new Promise(() => {})) }); + const textarea = screen.getByTestId("chat-textarea"); + fireEvent.change(textarea, { target: { value: "原始提示词" } }); + fireEvent.click(screen.getByTestId("chat-optimize-prompt")); + + expect(screen.getByRole("status")).toHaveTextContent(t("agent:chat_prompt_optimizing")); + }); + + it("组件卸载时应取消尚未完成的提示词优化", () => { + const onCancelOptimizePrompt = vi.fn(); + const { unmount } = render( + {}))} + onCancelOptimizePrompt={onCancelOptimizePrompt} + /> + ); + fireEvent.change(screen.getByTestId("chat-textarea"), { target: { value: "原始提示词" } }); + fireEvent.click(screen.getByTestId("chat-optimize-prompt")); + + unmount(); + + expect(onCancelOptimizePrompt).toHaveBeenCalledWith(expect.any(String)); + }); + + it("StrictMode 下优化完成后仍应写回并恢复输入框", async () => { + render( + + + + ); + const textarea = screen.getByTestId("chat-textarea") as HTMLTextAreaElement; + fireEvent.change(textarea, { target: { value: "原始提示词" } }); + fireEvent.click(screen.getByTestId("chat-optimize-prompt")); + + await waitFor(() => expect(textarea.value).toBe("优化后的提示词")); + expect(textarea).not.toBeDisabled(); + expect(textarea).toHaveFocus(); + }); + + it("空输入时禁用提示词优化按钮", () => { + setup({ onOptimizePrompt: vi.fn() }); + expect(screen.getByTestId("chat-optimize-prompt")).toBeDisabled(); + }); + + it("提示词优化失败时保留原输入并恢复控件", async () => { + setup({ onOptimizePrompt: vi.fn().mockRejectedValue(new Error("API unavailable")) }); + const textarea = screen.getByTestId("chat-textarea") as HTMLTextAreaElement; + const optimizeButton = screen.getByTestId("chat-optimize-prompt"); + fireEvent.change(textarea, { target: { value: "原始提示词" } }); + fireEvent.click(optimizeButton); + + await waitFor(() => expect(optimizeButton).not.toBeDisabled()); + expect(textarea.value).toBe("原始提示词"); + }); }); diff --git a/src/pages/options/routes/Agent/Chat/ChatInput.tsx b/src/pages/options/routes/Agent/Chat/ChatInput.tsx index c136faa3e..9a02d4a91 100644 --- a/src/pages/options/routes/Agent/Chat/ChatInput.tsx +++ b/src/pages/options/routes/Agent/Chat/ChatInput.tsx @@ -8,8 +8,10 @@ import { Eye, File as FileIcon, Image as ImageIcon, + Loader2, Paperclip, PlayCircle, + Sparkles, Square, Wrench, X, @@ -208,6 +210,8 @@ export default function ChatInput({ backgroundEnabled, onBackgroundEnabledChange, hasPendingMessage, + onOptimizePrompt, + onCancelOptimizePrompt, }: { models: AgentModelConfig[]; selectedModelId: string; @@ -224,14 +228,41 @@ export default function ChatInput({ backgroundEnabled?: boolean; onBackgroundEnabledChange?: (enabled: boolean) => void; hasPendingMessage?: boolean; + onOptimizePrompt?: (prompt: string, modelId: string, requestId: string) => Promise; + onCancelOptimizePrompt?: (requestId: string) => Promise | void; }) { const { t } = useTranslation(); const [input, setInput] = useState(""); const [attachments, setAttachments] = useState([]); const [isDragging, setIsDragging] = useState(false); const [slashActiveIndex, setSlashActiveIndex] = useState(0); + const [isOptimizing, setIsOptimizing] = useState(false); const textareaRef = useRef(null); const fileInputRef = useRef(null); + const activeOptimizationIdRef = useRef(null); + const cancelOptimizePromptRef = useRef(onCancelOptimizePrompt); + const shouldRestoreFocusRef = useRef(false); + const mountedRef = useRef(true); + + useEffect(() => { + cancelOptimizePromptRef.current = onCancelOptimizePrompt; + }, [onCancelOptimizePrompt]); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + const requestId = activeOptimizationIdRef.current; + if (requestId) void cancelOptimizePromptRef.current?.(requestId); + }; + }, []); + + useEffect(() => { + if (!isOptimizing && shouldRestoreFocusRef.current) { + shouldRestoreFocusRef.current = false; + textareaRef.current?.focus(); + } + }, [isOptimizing]); // 斜杠命令过滤 const slashQuery = useMemo(() => { @@ -385,7 +416,33 @@ export default function ChatInput({ e.target.value = ""; }; - const canSend = !!(input.trim() || attachments.length > 0) && !disabled && !hasPendingMessage; + const handleOptimizePrompt = async () => { + const prompt = input.trim(); + if (!prompt || !onOptimizePrompt || isOptimizing || disabled) return; + + const requestId = crypto.randomUUID(); + activeOptimizationIdRef.current = requestId; + setIsOptimizing(true); + try { + const optimized = await onOptimizePrompt(prompt, selectedModelId, requestId); + if (!mountedRef.current) return; + setInput(optimized); + shouldRestoreFocusRef.current = true; + notify.success(t("agent:chat_prompt_optimized")); + } catch (error) { + if (!mountedRef.current) return; + notify.error( + t("agent:chat_prompt_optimize_failed", { + error: error instanceof Error ? error.message : String(error), + }) + ); + } finally { + if (activeOptimizationIdRef.current === requestId) activeOptimizationIdRef.current = null; + if (mountedRef.current) setIsOptimizing(false); + } + }; + + const canSend = !!(input.trim() || attachments.length > 0) && !disabled && !hasPendingMessage && !isOptimizing; const iconBtn = "size-7 max-md:size-11 rounded flex items-center justify-center bg-transparent border-none cursor-pointer text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"; @@ -462,7 +519,7 @@ export default function ChatInput({ onKeyDown={handleKeyDown} onPaste={handlePaste} placeholder={t("agent:chat_input_placeholder")} - disabled={disabled} + disabled={disabled || isOptimizing} rows={1} className="w-full resize-none border-none outline-none bg-transparent text-sm text-foreground min-h-[24px] max-h-[200px] placeholder:text-muted-foreground" /> @@ -486,6 +543,28 @@ export default function ChatInput({ > + {onOptimizePrompt && ( + + )} + {isOptimizing && ( + + {t("agent:chat_prompt_optimizing")} + + )} {onEnableToolsChange && (