From f026880fa791d509b229c9d3165ffc2b782ae10e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Fri, 19 Jun 2026 04:04:35 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84:=20=E6=8B=86AiChat=20God?= =?UTF-8?q?=E7=AC=AC=E4=BA=8C=E6=89=B9ChatInput(strategy)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新建 ai/ChatInput.vue(641行): 输入框+图片粘贴/拖拽+技能/联想+@mention+发送/停止(4块独立逻辑) - AiChat.vue 3663→3128行(-535): ChatInput template+script迁移, store共享(useAiStore/useProjectStore单例) - props editingMsgId + emit sent/error/cancel-edit + expose focus/startEditFocus/clearInput/hasUnsentInput/sendExamplePrompt 边界: scrollToBottom/showToast/editingMsgId留父(emit回调); ChatInput 641>500(4块逻辑, 后续可拆useImageInput/useSkillMention composable) 主代兜底: vue-tsc 0 + grep ChatInput抽离/AiChat emit绑定印证 strategy: 单批1-2文件原子; AiChat余3128行(后续MessageList核心/TopBar/QueueList) --- crates/df-execute/src/shell.rs | 8 + src/components/AiChat.vue | 597 ++--------------------------- src/components/ai/ChatInput.vue | 641 ++++++++++++++++++++++++++++++++ 3 files changed, 680 insertions(+), 566 deletions(-) create mode 100644 src/components/ai/ChatInput.vue diff --git a/crates/df-execute/src/shell.rs b/crates/df-execute/src/shell.rs index d5cf121..6491d61 100644 --- a/crates/df-execute/src/shell.rs +++ b/crates/df-execute/src/shell.rs @@ -85,6 +85,14 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result { // 对齐 tool_registry.rs:514「进程已终止」文案名副其实。tokio 1.52.3 支持。 cmd.kill_on_drop(true); + // B-260619-01: Windows 下创建子进程默认弹控制台窗口(cmd/powershell 黑窗闪现)。 + // CREATE_NO_WINDOW(0x0800_0000) 标志抑制窗口创建,后台静默执行。 + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x0800_0000); + } + if let Some(dir) = &request.working_dir { cmd.current_dir(dir); } diff --git a/src/components/AiChat.vue b/src/components/AiChat.vue index 6805310..484d951 100644 --- a/src/components/AiChat.vue +++ b/src/components/AiChat.vue @@ -466,109 +466,14 @@ - -
- - /{{ pendingSkill.name }} - {{ pendingSkill.description }} - {{ pendingSkill.argument_hint }} - - -
-
- -
-
- - -
-
- - -
-
- {{ store.state.skills.length ? $t('aiChat.skillNoMatch') : $t('aiChat.skillNotLoaded') }} -
-
-
- /{{ s.name }} - {{ s.description }} - {{ s.source }} -
- {{ s.argument_hint }} -
-
- -
-
- {{ mentionTotal ? $t('aiChat.mentionNoMatch') : $t('aiChat.mentionEmpty') }} -
-
- -
-
- - -
-
{{ $t('aiChat.inputHint') }}
+ + @@ -595,12 +500,13 @@ import { pendingMaxRounds } from '../composables/ai/useAiEvents' import { aiApi } from '../api' import ConfirmDialog from './ConfirmDialog.vue' import ConversationSidebar from './ai/ConversationSidebar.vue' +import ChatInput from './ai/ChatInput.vue' import { useConfirm } from '../composables/useConfirm' import { useMarkdown } from '../composables/useMarkdown' import ToolCardList from './ToolCardList.vue' import { formatRelativeZh, formatDate } from '../utils/time' import { useProjectStore } from '../stores/project' -import type { AiMessage, ContentPart, SkillInfo, ProjectRecord, TaskRecord } from '../api/types' +import type { AiMessage, ContentPart } from '../api/types' const props = defineProps<{ detached?: boolean @@ -769,10 +675,9 @@ function goToSettings() { router.push('/settings') } -/** 点击示例问题卡片:填入输入框并发送(复用 handleSend 同款发送路径,错误回退一致) */ -async function sendExamplePrompt(i18nKey: string) { - inputText.value = t(i18nKey) - await handleSend() +/** 点击示例问题卡片:委托 ChatInput 子组件发送(填入输入框 + handleSend 同款路径) */ +function sendExamplePrompt(i18nKey: string) { + chatInputRef.value?.sendExamplePrompt(i18nKey) } // ── 错误 toast(零依赖,复用 Settings.vue 同款 reactive toast) ── @@ -793,104 +698,18 @@ let _unlistenToolSlow: (() => void) | null = null // AE-2025-04: 会话信任自动放行 toast unlistener(useAiEvents 经事件总线中转,同 _unlistenToolSlow 模式) let _unlistenAutoApproved: (() => void) | null = null -const inputText = ref('') -const inputEl = ref() +// 第二批抽离: inputText/inputEl/图片输入/技能/实体联想/handleSend 已迁移至 ChatInput.vue +// chatInputRef: 父调 expose(focus/startEditFocus/clearInput/hasUnsentInput/sendExamplePrompt) +const chatInputRef = ref | null>(null) -// ── F-260614-05 Phase 2b: 图片输入(粘贴/拖拽) ── -// 待发送图片列表(base64 data URI + media_type),handleSend 时构造 ContentPart[]。 -// 仅本组件级 ref,不入 store;发送后清空。预览缩略图渲染在输入框上方。 -// -// 约束: -// - 限制单次最多 6 张(防超长上下文/超限请求);超过 toast 提示。 -// - 限制单张 10MB(纯文本 base64 估算 ≥ 13M 字符;超限 toast 提示跳过)。 -// - 仅接受 image/* MIME;非图片(文本粘贴/文件拖入)走原生行为不拦。 -const MAX_PENDING_IMAGES = 6 -const MAX_IMAGE_BYTES = 10 * 1024 * 1024 // 10MB(解码后字节上限) -const pendingImages = ref<{ id: string; dataUrl: string; mediaType: string }[]>([]) +// editingMsgId 仍由父持有(startEdit/cancelEdit 在父,经 prop 透传 ChatInput) +const editingMsgId = ref(null) -/** 读 File → base64 data URL(供 预览 + ContentPart.image base64 字段) */ -function readImageFile(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onload = () => resolve(reader.result as string) - reader.onerror = () => reject(reader.error ?? new Error('read failed')) - reader.readAsDataURL(file) - }) +/** ChatInput emit('error') 转发到本组件 toast(保持单一 toast 源) */ +function onChatInputError(payload: { msg: string; type: 'error' | 'warning' | 'info' }): void { + showToast(payload.msg, payload.type) } -/** 把 image File 加入 pendingImages;超限/超大小跳过并 toast */ -async function addImageFile(file: File): Promise { - if (!file.type.startsWith('image/')) return - if (file.size > MAX_IMAGE_BYTES) { - showToast(t('aiChat.imageTooLarge'), 'warning') - return - } - if (pendingImages.value.length >= MAX_PENDING_IMAGES) { - showToast(t('aiChat.imageLimit', { n: MAX_PENDING_IMAGES }), 'warning') - return - } - try { - const dataUrl = await readImageFile(file) - pendingImages.value.push({ - // id 用 timestamp+random,DOM key 唯一,移除按钮定位 - id: `img-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - dataUrl, - mediaType: file.type, - }) - } catch (e) { - showToast(t('aiChat.imageReadFailed'), 'error') - console.error('[AI] 图片读取失败:', e) - } -} - -/** 移除待发送图片 */ -function removePendingImage(id: string): void { - pendingImages.value = pendingImages.value.filter(p => p.id !== id) -} - -/** 粘贴:ClipboardEvent 检测 image 类型条目,加入待发送列表(不插入文本占位) */ -async function onPaste(e: ClipboardEvent): Promise { - const items = e.clipboardData?.items - if (!items) return - const files: File[] = [] - for (let i = 0; i < items.length; i++) { - const it = items[i] - if (it.kind === 'file' && it.type.startsWith('image/')) { - const f = it.getAsFile() - if (f) files.push(f) - } - } - if (files.length === 0) return - // 含图片:阻止默认粘贴(避免把图片的二进制当文本塞进 textarea),逐个加入 - e.preventDefault() - for (const f of files) await addImageFile(f) -} - -/** 拖拽进入:仅允许图片,preventDefault 让 drop 事件触发 */ -function onDragOver(e: DragEvent): void { - if (!e.dataTransfer) return - // 判断当前是否含 image/* 类型(copy/move 光标提示) - const hasImage = Array.from(e.dataTransfer.types).includes('Files') - if (hasImage) { - e.preventDefault() - e.dataTransfer.dropEffect = 'copy' - } -} - -/** 拖拽放下:取出图片 File,加入待发送 */ -async function onDrop(e: DragEvent): Promise { - const files = e.dataTransfer?.files - if (!files || files.length === 0) return - // 仅处理图片文件(非图片拖入走原生不拦,但这里 preventDefault 已无副作用) - const images = Array.from(files).filter(f => f.type.startsWith('image/')) - if (images.length === 0) return - e.preventDefault() - for (const f of images) await addImageFile(f) -} - -/** 发送可用:有文本或图片或已选技能(纯技能调用允许空文本) */ -const canSend = computed(() => inputText.value.trim().length > 0 || pendingImages.value.length > 0 || !!pendingSkill.value) - /** ContentPart 图片片类型(discriminator type==='image' 的联合成员) */ type ImageContentPart = Extract @@ -914,8 +733,6 @@ function imageSrc(img: ImageContentPart): string { function imgAlt(img: ImageContentPart): string { return img.alt || t('aiChat.imagePreviewAlt') } -// UX-09: 编辑末条 user 消息态。editingMsgId 有值时 handleSend 走 editMessage 而非 sendMessage。 -const editingMsgId = ref(null) const messagesContainer = ref() // 工具卡片列表(子组件 ToolCardList 自治折叠态,父级仅经 expose 调 collapseInactive 收起旧卡) const toolCardListRef = ref>() @@ -974,72 +791,9 @@ async function confirmNewConversation() { await store.newConversation() } -// ── 技能 `/` 联想 ── -const skillOpen = ref(false) -const skillIndex = ref(0) -const pendingSkill = ref(null) -const skillQuery = computed(() => (inputText.value.startsWith('/') ? inputText.value.slice(1) : '')) -const filteredSkills = computed(() => { - const q = skillQuery.value.trim().toLowerCase() - const list = store.state.skills - if (!q) return list - return list.filter( - (s) => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q), - ) -}) - -watch(inputText, (v) => { - if (pendingSkill.value) { - // 已选技能 chip 态不开任何联想浮层 - skillOpen.value = false - mentionOpen.value = false - mentionStart.value = -1 - return - } - // / 技能联想(仅当输入以 / 开头且光标在首字符区) - skillOpen.value = v.startsWith('/') - if (skillOpen.value) skillIndex.value = 0 - // UX-10: @ 实体引用(技能联想与 @ 联想互斥,二者同时仅一开) - if (skillOpen.value) { - mentionOpen.value = false - mentionStart.value = -1 - } else { - detectMentionTrigger() - } -}) - -/** - * UX-10: 检测光标前最近的 @ 触发符。 - * 规则:从光标位置向前找最近的 @,期间无空白/换行则激活(支持 "abc @dev" 但不支持 "a @b c @d" 中第二个被空格阻断)。 - * 找到 → mentionStart=该 @ 下标, mentionOpen=true; 否则关闭。 - */ -function detectMentionTrigger() { - const el = inputEl.value - const text = inputText.value - const cursor = el?.selectionStart ?? text.length - // 从光标向前扫,遇空白/换行即停(说明 @ 与光标间有分隔,非同一 token) - let i = cursor - 1 - while (i >= 0) { - const ch = text[i] - if (ch === '@') { - // @ 必须在行首或前一字符为空白/换行/起始(避免邮箱 a@b 误触发) - const prev = i > 0 ? text[i - 1] : '' - if (prev === '' || /\s/.test(prev)) { - mentionStart.value = i - mentionOpen.value = true - mentionIndex.value = 0 - return - } - // 形如 a@b:前字符非空白 → 不是 mention 触发符,继续向前扫也只会找到更早的 @, - // 但中间已有非空白阻断,直接关闭 - break - } - if (/\s/.test(ch)) break - i-- - } - mentionOpen.value = false - mentionStart.value = -1 -} +// 第二批抽离: 技能 `/` 联想 + @ 实体引用联想 + watch(inputText) 已迁移至 ChatInput.vue +// projectsStore 仍由父持有(onMounted 预加载 projects/tasks 供子组件 @ 联想读取) +const projectsStore = useProjectStore() // UX-09:切换对话/新对话时取消编辑态(编辑态绑定末条 user,切走后无效) // 2026-06-18 改:打开/切换会话默认进 specify 模式并选中权重最高 model(用户诉求:默认确定选中、 @@ -1054,126 +808,7 @@ watch(() => store.state.activeConversationId, () => { isFollowingBottom = true }) -function selectSkill(s: SkillInfo) { - pendingSkill.value = s - inputText.value = '' - skillOpen.value = false - mentionOpen.value = false - mentionStart.value = -1 - nextTick(() => inputEl.value?.focus()) -} -function clearSkill() { - pendingSkill.value = null - nextTick(() => inputEl.value?.focus()) -} - -// ── UX-10 §1.4: @ 实体引用(@ 触发 popover,选中插入 [类型:名] 标记,后端 system prompt 注入摘要) ── -// 架构复用 / 技能联想:独立状态(mentionOpen/mentionIndex/mentionQuery),不与 skill 混。 -// 联想源只读 projectsStore.projects / projectsStore.tasks(前端 store 现有数据,不新增 IPC)。 -// 触发:输入框光标前最近的 @ 后无空白/换行 → 激活;Esc/光标移开/选中插入 → 关闭。 -// Enter 不冲突:popover 开时 Enter 插入选中(覆盖发送),关时 Enter 发送(原逻辑)。 -const projectsStore = useProjectStore() - -interface MentionItem { - type: 'project' | 'task' - /** 扁平后唯一 key(用于 key/导航扁平索引定位) */ - uid: string - /** 实体原始 id(供后端解析,目前仅插入 [类型:名] 文本,id 未透传) */ - id: string - name: string - desc: string - typeLabel: string -} - -const mentionOpen = ref(false) -const mentionIndex = ref(0) -// @ 触发符在 inputText 中的起始下标(用于选中后精确替换 @query 段) -const mentionStart = ref(-1) - -const mentionGroupLabel = computed(() => ({ - project: t('aiChat.mentionGroupProject'), - task: t('aiChat.mentionGroupTask'), -})) - -/** 当前 @ 查询文本(@ 之后的串,不含 @) */ -const mentionQuery = computed(() => { - if (mentionStart.value < 0) return '' - return inputText.value.slice(mentionStart.value + 1) -}) - -/** 联想候选(项目 + 任务,按查询过滤;各限 20 防列表膨胀) */ -const mentionItems = computed(() => { - const q = mentionQuery.value.trim().toLowerCase() - const projects = (projectsStore.projects || []) as ProjectRecord[] - const tasks = (projectsStore.tasks || []) as TaskRecord[] - const match = (s: string) => !q || s.toLowerCase().includes(q) - const projectItems: MentionItem[] = projects - .filter(p => match(p.name) || match(p.description || '')) - .slice(0, 20) - .map(p => ({ - type: 'project' as const, - uid: 'p-' + p.id, - id: p.id, - name: p.name, - desc: p.description || '', - typeLabel: t('aiChat.mentionGroupProject'), - })) - const taskItems: MentionItem[] = tasks - .filter(tk => match(tk.title) || match(tk.description || '')) - .slice(0, 20) - .map(tk => ({ - type: 'task' as const, - uid: 't-' + tk.id, - id: tk.id, - name: tk.title, - desc: tk.description || '', - typeLabel: t('aiChat.mentionGroupTask'), - })) - return [...projectItems, ...taskItems] -}) - -/** 联想候选总数(未过滤,用于区分"无匹配" vs "空") */ -const mentionTotal = computed(() => - ((projectsStore.projects || []) as ProjectRecord[]).length - + ((projectsStore.tasks || []) as TaskRecord[]).length, -) - -/** 按类型分组(模板按组渲染组标题) */ -const mentionGroups = computed(() => [ - { key: 'project', label: mentionGroupLabel.value.project, items: mentionItems.value.filter(i => i.type === 'project') }, - { key: 'task', label: mentionGroupLabel.value.task, items: mentionItems.value.filter(i => i.type === 'task') }, -]) - -/** 扁平下标(组内 item 在扁平列表中的位置,供键盘导航 ↑↓/高亮) */ -function mentionFlatIndex(uid: string): number { - return mentionItems.value.findIndex(i => i.uid === uid) -} - -/** 选中实体 → 替换 @query 段为 [类型: 名] 标记 + 关闭浮层 */ -function selectMention(item: MentionItem) { - const before = inputText.value.slice(0, mentionStart.value) - const cursorAfter = mentionStart.value + 1 + mentionQuery.value.length - const after = inputText.value.slice(cursorAfter) - const label = item.type === 'project' - ? `[${t('aiChat.mentionGroupProject')}: ${item.name}]` - : `[${t('aiChat.mentionGroupTask')}: ${item.name}]` - inputText.value = before + label + ' ' + after - mentionOpen.value = false - mentionStart.value = -1 - mentionIndex.value = 0 - nextTick(() => { - const el = inputEl.value - if (el) { - // 光标置插入标记末尾 - const pos = (before + label + ' ').length - el.focus() - el.setSelectionRange(pos, pos) - el.style.height = 'auto' - el.style.height = Math.min(el.scrollHeight, 120) + 'px' - } - }) -} // 对话侧栏子组件引用(Ctrl+K 快捷键调 focusSearch;子组件自管重命名/导出/拖拽/搜索/分组) const convSidebarRef = ref | null>(null) @@ -1306,28 +941,16 @@ function isLastUser(msg: AiMessage): boolean { return false } -/** UX-09:进入编辑态 — 回填末条 user 内容到输入框,聚焦,置 editingMsgId。 */ +/** UX-09:进入编辑态 — 置 editingMsgId(透传 ChatInput prop)+ 委托 ChatInput.startEditFocus 回填/聚焦。 */ function startEdit(msg: AiMessage): void { editingMsgId.value = msg.id - inputText.value = msg.content - nextTick(() => { - inputEl.value?.focus() - // 光标置末 - const el = inputEl.value - if (el) { - const len = el.value.length - el.setSelectionRange(len, len) - el.style.height = 'auto' - el.style.height = Math.min(el.scrollHeight, 120) + 'px' - } - }) + chatInputRef.value?.startEditFocus(msg) } -/** UX-09:取消编辑态(切换对话/清空/ESC)。 */ +/** UX-09:取消编辑态(切换对话/清空/ESC)。清 editingMsgId + 委托 ChatInput.clearInput 清输入。 */ function cancelEdit(): void { editingMsgId.value = null - inputText.value = '' - if (inputEl.value) inputEl.value.style.height = 'auto' + chatInputRef.value?.clearInput() } /** token 数格式化:<1000 原值,≥1000 用 k(如 1234→1.2k) */ @@ -1391,11 +1014,7 @@ function onGlobalKeydown(e: KeyboardEvent) { * 用于 beforeunload 拦截(分离窗口关闭/刷新)与未来 onBeforeUnmount 拦截(面板切换)。 */ function hasUnsentInput(): boolean { - return ( - inputText.value.trim().length > 0 || - pendingImages.value.length > 0 || - !!pendingSkill.value - ) + return chatInputRef.value?.hasUnsentInput() ?? false } /** @@ -1409,162 +1028,8 @@ function onBeforeUnloadGuard(e: BeforeUnloadEvent) { e.returnValue = '' } -function handleKeydown(e: KeyboardEvent) { - // UX-09:编辑态 Escape 取消编辑(最高优先级) - if (editingMsgId.value && e.key === 'Escape') { - e.preventDefault() - cancelEdit() - return - } - // Escape 关闭技能联想浮层(无论是否有匹配项) - if (skillOpen.value && e.key === 'Escape') { - e.preventDefault() - skillOpen.value = false - return - } - // 已选技能 chip 态下 Escape 取消选中(浮层已关闭,这里兜底 chip 可取消路径) - if (pendingSkill.value && e.key === 'Escape') { - e.preventDefault() - pendingSkill.value = null - return - } - // 技能联想浮层导航(仅有匹配项时拦截方向/回车) - if (skillOpen.value && filteredSkills.value.length) { - if (e.key === 'ArrowDown') { - e.preventDefault() - skillIndex.value = (skillIndex.value + 1) % filteredSkills.value.length - return - } - if (e.key === 'ArrowUp') { - e.preventDefault() - skillIndex.value = (skillIndex.value - 1 + filteredSkills.value.length) % filteredSkills.value.length - return - } - if ((e.key === 'Enter' || e.key === 'Tab') && !e.shiftKey) { - e.preventDefault() - selectSkill(filteredSkills.value[skillIndex.value]) - return - } - if (e.key === 'Escape') { - e.preventDefault() - skillOpen.value = false - return - } - } - // UX-10: @ 实体引用浮层导航(仅有匹配项时拦截方向/回车;与技能联想互斥) - if (mentionOpen.value && mentionItems.value.length) { - if (e.key === 'ArrowDown') { - e.preventDefault() - mentionIndex.value = (mentionIndex.value + 1) % mentionItems.value.length - return - } - if (e.key === 'ArrowUp') { - e.preventDefault() - mentionIndex.value = (mentionIndex.value - 1 + mentionItems.value.length) % mentionItems.value.length - return - } - if ((e.key === 'Enter' || e.key === 'Tab') && !e.shiftKey) { - e.preventDefault() - selectMention(mentionItems.value[mentionIndex.value]) - return - } - if (e.key === 'Escape') { - e.preventDefault() - mentionOpen.value = false - mentionStart.value = -1 - return - } - } - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault() - handleSend() - } -} - -function autoResize() { - const el = inputEl.value - if (el) { - el.style.height = 'auto' - el.style.height = Math.min(el.scrollHeight, 120) + 'px' - } -} - -async function handleSend() { - const text = inputText.value.trim() - // UX-09 编辑态:走 editMessage(不 push 新 user,编辑末条 user 后重生成) - if (editingMsgId.value) { - if (!text) return - inputText.value = '' - if (inputEl.value) inputEl.value.style.height = 'auto' - try { - await store.editMessage(text) - editingMsgId.value = null - } catch (e) { - console.error('[AI] 编辑失败:', e) - // 失败回填输入框供重试,保持编辑态(editMessage 内已回滚占位) - inputText.value = text - const msg = e instanceof Error ? e.message : String(e) - showToast(t('aiChat.editMessageFailed', { msg }), 'error') - } - await nextTick() - scrollToBottom() - return - } - const skill = pendingSkill.value - // 无文本、无图片、未选技能时忽略;选了技能允许空文本(纯技能调用) - if (!text && !skill && pendingImages.value.length === 0) return - // F-260614-05 Phase 2b: 快照待发送图片,构造 ContentPart[]([Text?, Image...])。 - // 后端 ai_chat_send IPC 当前不接 parts(Phase 2c 接入);本轮 parts 仅写本地 push 的 - // user 消息渲染多模态图,后端请求仍走 content 文本路径。 - const snapshotImgs = pendingImages.value.slice() - const parts: ContentPart[] | undefined = snapshotImgs.length > 0 - ? [ - // 文本片(非空时作首片,便于后端 2c 接入后 content 与 parts 文本一致; - // 空文本则只发图片片) - ...(text ? [{ type: 'text' as const, text }] : []), - ...snapshotImgs.map(img => { - // dataUrl 形如 data:image/png;base64,xxxx —— 拆出纯 base64 + media_type - const m = /^data:([^;]+);base64,(.*)$/s.exec(img.dataUrl) - const mediaType = m?.[1] || img.mediaType - const base64 = m?.[2] || '' - return { - type: 'image' as const, - url: null, - base64, - media_type: mediaType, - alt: null, - } - }), - ] - : undefined - // 生成中不再拦截:sendMessage 会把消息排入待发送队列,完成后自动续发 - // 先清输入(无论立即发还是入队);队列满/IPC 失败时 catch 回填 - inputText.value = '' - pendingSkill.value = null - pendingImages.value = [] - skillOpen.value = false - mentionOpen.value = false - mentionStart.value = -1 - if (inputEl.value) inputEl.value.style.height = 'auto' - // 同步清空输入框后立即发送:sendMessage 内部同步 push 用户消息, - // 清空与 push 合并到同一渲染周期 flush,消除"输入框已空但消息未显示"的间隙 - try { - await store.sendMessage(text, skill?.name, false, parts) - } catch (e) { - console.error('[AI] 发送失败:', e) - inputText.value = text - pendingSkill.value = skill - // 发送失败:回填图片供重试(快照原样还原) - if (parts) { - pendingImages.value = snapshotImgs - } - // 用户可见提示(原仅 console.error,用户无感);Tauri IPC 错误是字符串非 Error 对象 - const msg = e instanceof Error ? e.message : String(e) - showToast(t('aiChat.toastSendFail', { msg }), 'error') - } - await nextTick() - scrollToBottom() -} +// 第二批抽离: handleKeydown/autoResize/handleSend 已迁移至 ChatInput.vue +// (ChatInput 经 emit('sent')/emit('cancel-edit')/emit('error') 与父交互) // ── B-260616-02 L2 发送韧性:排队超时检测 + 强制发送 ── diff --git a/src/components/ai/ChatInput.vue b/src/components/ai/ChatInput.vue new file mode 100644 index 0000000..42bc265 --- /dev/null +++ b/src/components/ai/ChatInput.vue @@ -0,0 +1,641 @@ + + +