重构: 拆AiChat God第二批ChatInput(strategy)
- 新建 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)
This commit is contained in:
@@ -85,6 +85,14 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
|
|||||||
// 对齐 tool_registry.rs:514「进程已终止」文案名副其实。tokio 1.52.3 支持。
|
// 对齐 tool_registry.rs:514「进程已终止」文案名副其实。tokio 1.52.3 支持。
|
||||||
cmd.kill_on_drop(true);
|
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 {
|
if let Some(dir) = &request.working_dir {
|
||||||
cmd.current_dir(dir);
|
cmd.current_dir(dir);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -466,109 +466,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 已选技能 chip(含参数格式提示 argument_hint) -->
|
<!-- 第二批抽离: ChatInput 子组件(输入框+附件+技能/实体联想+发送/停止) -->
|
||||||
<div v-if="pendingSkill" class="ai-skill-chip">
|
<ChatInput
|
||||||
<span class="ai-skill-chip-main">
|
ref="chatInputRef"
|
||||||
<span class="ai-skill-chip-name">/{{ pendingSkill.name }}</span>
|
:editing-msg-id="editingMsgId"
|
||||||
<span v-if="pendingSkill.description" class="ai-skill-chip-desc">{{ pendingSkill.description }}</span>
|
@sent="scrollToBottom"
|
||||||
<code v-if="pendingSkill.argument_hint" class="ai-skill-chip-hint">{{ pendingSkill.argument_hint }}</code>
|
@error="onChatInputError"
|
||||||
</span>
|
@cancel-edit="cancelEdit"
|
||||||
<button class="ai-skill-chip-x" @click="clearSkill" :title="$t('aiChat.clearSkill')" :aria-label="$t('aiChat.clearSkill')">×</button>
|
/>
|
||||||
</div>
|
|
||||||
<div class="ai-input-wrap">
|
|
||||||
<!-- F-260614-05 Phase 2b: 待发送图片预览(粘贴/拖拽加入,发送前可移除) -->
|
|
||||||
<div v-if="pendingImages.length" class="ai-img-preview-row">
|
|
||||||
<div v-for="img in pendingImages" :key="img.id" class="ai-img-preview">
|
|
||||||
<img :src="img.dataUrl" :alt="$t('aiChat.imagePreviewAlt')" class="ai-img-preview-thumb" />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="ai-img-preview-x"
|
|
||||||
:title="$t('aiChat.removeImage')"
|
|
||||||
:aria-label="$t('aiChat.removeImage')"
|
|
||||||
@click="removePendingImage(img.id)"
|
|
||||||
>×</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
ref="inputEl"
|
|
||||||
v-model="inputText"
|
|
||||||
class="ai-input"
|
|
||||||
:class="{ 'ai-input--editing': !!editingMsgId }"
|
|
||||||
:placeholder="editingMsgId
|
|
||||||
? $t('aiChat.placeholderEditing')
|
|
||||||
: (pendingSkill ? (pendingSkill.argument_hint || $t('aiChat.placeholderWithSkill')) : $t('aiChat.placeholderDefault'))"
|
|
||||||
rows="1"
|
|
||||||
@keydown="handleKeydown"
|
|
||||||
@input="autoResize"
|
|
||||||
@paste="onPaste"
|
|
||||||
@dragover="onDragOver"
|
|
||||||
@drop="onDrop"
|
|
||||||
></textarea>
|
|
||||||
<!-- 技能联想浮层 -->
|
|
||||||
<div v-if="skillOpen" class="ai-skill-popover">
|
|
||||||
<div v-if="!filteredSkills.length" class="ai-skill-empty">
|
|
||||||
{{ store.state.skills.length ? $t('aiChat.skillNoMatch') : $t('aiChat.skillNotLoaded') }}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-for="(s, i) in filteredSkills"
|
|
||||||
:key="s.path"
|
|
||||||
class="ai-skill-item"
|
|
||||||
:class="{ 'ai-skill-item--active': i === skillIndex }"
|
|
||||||
@click="selectSkill(s)"
|
|
||||||
@mouseenter="skillIndex = i"
|
|
||||||
>
|
|
||||||
<div class="ai-skill-item-row">
|
|
||||||
<span class="ai-skill-item-name">/{{ s.name }}</span>
|
|
||||||
<span class="ai-skill-item-desc">{{ s.description }}</span>
|
|
||||||
<span class="ai-skill-item-src">{{ s.source }}</span>
|
|
||||||
</div>
|
|
||||||
<code v-if="s.argument_hint" class="ai-skill-item-hint">{{ s.argument_hint }}</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- UX-10 §1.4: @ 实体引用浮层(@ 触发,选中插入 [类型:名] 标记,架构复用技能 popover) -->
|
|
||||||
<div v-if="mentionOpen" class="ai-skill-popover ai-mention-popover">
|
|
||||||
<div v-if="!mentionItems.length" class="ai-skill-empty">
|
|
||||||
{{ mentionTotal ? $t('aiChat.mentionNoMatch') : $t('aiChat.mentionEmpty') }}
|
|
||||||
</div>
|
|
||||||
<div v-else>
|
|
||||||
<template v-for="g in mentionGroups" :key="g.key">
|
|
||||||
<div v-if="g.items.length" class="ai-mention-group">{{ g.label }}</div>
|
|
||||||
<div
|
|
||||||
v-for="item in g.items"
|
|
||||||
:key="item.uid"
|
|
||||||
class="ai-skill-item"
|
|
||||||
:class="{ 'ai-skill-item--active': mentionFlatIndex(item.uid) === mentionIndex }"
|
|
||||||
@click="selectMention(item)"
|
|
||||||
@mouseenter="mentionIndex = mentionFlatIndex(item.uid)"
|
|
||||||
>
|
|
||||||
<div class="ai-skill-item-row">
|
|
||||||
<span class="ai-mention-item-type" :class="'ai-mention-item-type--' + item.type">{{ item.typeLabel }}</span>
|
|
||||||
<span class="ai-mention-item-name">{{ item.name }}</span>
|
|
||||||
<span class="ai-mention-item-desc">{{ item.desc }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
v-if="store.state.streaming"
|
|
||||||
class="ai-send-btn ai-send-btn--stop"
|
|
||||||
:title="$t('aiChat.stopGenerating')"
|
|
||||||
@click="store.stopChat"
|
|
||||||
>
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-else
|
|
||||||
class="ai-send-btn"
|
|
||||||
:class="{ 'ai-send-btn--active': canSend }"
|
|
||||||
:disabled="!canSend"
|
|
||||||
@click="handleSend"
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="ai-input-hint">{{ $t('aiChat.inputHint') }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 确认弹层(删对话等不可逆操作) -->
|
<!-- 确认弹层(删对话等不可逆操作) -->
|
||||||
@@ -595,12 +500,13 @@ import { pendingMaxRounds } from '../composables/ai/useAiEvents'
|
|||||||
import { aiApi } from '../api'
|
import { aiApi } from '../api'
|
||||||
import ConfirmDialog from './ConfirmDialog.vue'
|
import ConfirmDialog from './ConfirmDialog.vue'
|
||||||
import ConversationSidebar from './ai/ConversationSidebar.vue'
|
import ConversationSidebar from './ai/ConversationSidebar.vue'
|
||||||
|
import ChatInput from './ai/ChatInput.vue'
|
||||||
import { useConfirm } from '../composables/useConfirm'
|
import { useConfirm } from '../composables/useConfirm'
|
||||||
import { useMarkdown } from '../composables/useMarkdown'
|
import { useMarkdown } from '../composables/useMarkdown'
|
||||||
import ToolCardList from './ToolCardList.vue'
|
import ToolCardList from './ToolCardList.vue'
|
||||||
import { formatRelativeZh, formatDate } from '../utils/time'
|
import { formatRelativeZh, formatDate } from '../utils/time'
|
||||||
import { useProjectStore } from '../stores/project'
|
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<{
|
const props = defineProps<{
|
||||||
detached?: boolean
|
detached?: boolean
|
||||||
@@ -769,10 +675,9 @@ function goToSettings() {
|
|||||||
router.push('/settings')
|
router.push('/settings')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 点击示例问题卡片:填入输入框并发送(复用 handleSend 同款发送路径,错误回退一致) */
|
/** 点击示例问题卡片:委托 ChatInput 子组件发送(填入输入框 + handleSend 同款路径) */
|
||||||
async function sendExamplePrompt(i18nKey: string) {
|
function sendExamplePrompt(i18nKey: string) {
|
||||||
inputText.value = t(i18nKey)
|
chatInputRef.value?.sendExamplePrompt(i18nKey)
|
||||||
await handleSend()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 错误 toast(零依赖,复用 Settings.vue 同款 reactive toast) ──
|
// ── 错误 toast(零依赖,复用 Settings.vue 同款 reactive toast) ──
|
||||||
@@ -793,104 +698,18 @@ let _unlistenToolSlow: (() => void) | null = null
|
|||||||
// AE-2025-04: 会话信任自动放行 toast unlistener(useAiEvents 经事件总线中转,同 _unlistenToolSlow 模式)
|
// AE-2025-04: 会话信任自动放行 toast unlistener(useAiEvents 经事件总线中转,同 _unlistenToolSlow 模式)
|
||||||
let _unlistenAutoApproved: (() => void) | null = null
|
let _unlistenAutoApproved: (() => void) | null = null
|
||||||
|
|
||||||
const inputText = ref('')
|
// 第二批抽离: inputText/inputEl/图片输入/技能/实体联想/handleSend 已迁移至 ChatInput.vue
|
||||||
const inputEl = ref<HTMLTextAreaElement>()
|
// chatInputRef: 父调 expose(focus/startEditFocus/clearInput/hasUnsentInput/sendExamplePrompt)
|
||||||
|
const chatInputRef = ref<InstanceType<typeof ChatInput> | null>(null)
|
||||||
|
|
||||||
// ── F-260614-05 Phase 2b: 图片输入(粘贴/拖拽) ──
|
// editingMsgId 仍由父持有(startEdit/cancelEdit 在父,经 prop 透传 ChatInput)
|
||||||
// 待发送图片列表(base64 data URI + media_type),handleSend 时构造 ContentPart[]。
|
const editingMsgId = ref<string | null>(null)
|
||||||
// 仅本组件级 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 }[]>([])
|
|
||||||
|
|
||||||
/** 读 File → base64 data URL(供 <img> 预览 + ContentPart.image base64 字段) */
|
/** ChatInput emit('error') 转发到本组件 toast(保持单一 toast 源) */
|
||||||
function readImageFile(file: File): Promise<string> {
|
function onChatInputError(payload: { msg: string; type: 'error' | 'warning' | 'info' }): void {
|
||||||
return new Promise((resolve, reject) => {
|
showToast(payload.msg, payload.type)
|
||||||
const reader = new FileReader()
|
|
||||||
reader.onload = () => resolve(reader.result as string)
|
|
||||||
reader.onerror = () => reject(reader.error ?? new Error('read failed'))
|
|
||||||
reader.readAsDataURL(file)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 把 image File 加入 pendingImages;超限/超大小跳过并 toast */
|
|
||||||
async function addImageFile(file: File): Promise<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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' 的联合成员) */
|
/** ContentPart 图片片类型(discriminator type==='image' 的联合成员) */
|
||||||
type ImageContentPart = Extract<ContentPart, { type: 'image' }>
|
type ImageContentPart = Extract<ContentPart, { type: 'image' }>
|
||||||
|
|
||||||
@@ -914,8 +733,6 @@ function imageSrc(img: ImageContentPart): string {
|
|||||||
function imgAlt(img: ImageContentPart): string {
|
function imgAlt(img: ImageContentPart): string {
|
||||||
return img.alt || t('aiChat.imagePreviewAlt')
|
return img.alt || t('aiChat.imagePreviewAlt')
|
||||||
}
|
}
|
||||||
// UX-09: 编辑末条 user 消息态。editingMsgId 有值时 handleSend 走 editMessage 而非 sendMessage。
|
|
||||||
const editingMsgId = ref<string | null>(null)
|
|
||||||
const messagesContainer = ref<HTMLDivElement>()
|
const messagesContainer = ref<HTMLDivElement>()
|
||||||
// 工具卡片列表(子组件 ToolCardList 自治折叠态,父级仅经 expose 调 collapseInactive 收起旧卡)
|
// 工具卡片列表(子组件 ToolCardList 自治折叠态,父级仅经 expose 调 collapseInactive 收起旧卡)
|
||||||
const toolCardListRef = ref<InstanceType<typeof ToolCardList>>()
|
const toolCardListRef = ref<InstanceType<typeof ToolCardList>>()
|
||||||
@@ -974,72 +791,9 @@ async function confirmNewConversation() {
|
|||||||
await store.newConversation()
|
await store.newConversation()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 技能 `/` 联想 ──
|
// 第二批抽离: 技能 `/` 联想 + @ 实体引用联想 + watch(inputText) 已迁移至 ChatInput.vue
|
||||||
const skillOpen = ref(false)
|
// projectsStore 仍由父持有(onMounted 预加载 projects/tasks 供子组件 @ 联想读取)
|
||||||
const skillIndex = ref(0)
|
const projectsStore = useProjectStore()
|
||||||
const pendingSkill = ref<SkillInfo | null>(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
|
|
||||||
}
|
|
||||||
|
|
||||||
// UX-09:切换对话/新对话时取消编辑态(编辑态绑定末条 user,切走后无效)
|
// UX-09:切换对话/新对话时取消编辑态(编辑态绑定末条 user,切走后无效)
|
||||||
// 2026-06-18 改:打开/切换会话默认进 specify 模式并选中权重最高 model(用户诉求:默认确定选中、
|
// 2026-06-18 改:打开/切换会话默认进 specify 模式并选中权重最高 model(用户诉求:默认确定选中、
|
||||||
@@ -1054,126 +808,7 @@ watch(() => store.state.activeConversationId, () => {
|
|||||||
isFollowingBottom = true
|
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<MentionItem[]>(() => {
|
|
||||||
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;子组件自管重命名/导出/拖拽/搜索/分组)
|
// 对话侧栏子组件引用(Ctrl+K 快捷键调 focusSearch;子组件自管重命名/导出/拖拽/搜索/分组)
|
||||||
const convSidebarRef = ref<InstanceType<typeof ConversationSidebar> | null>(null)
|
const convSidebarRef = ref<InstanceType<typeof ConversationSidebar> | null>(null)
|
||||||
@@ -1306,28 +941,16 @@ function isLastUser(msg: AiMessage): boolean {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
/** UX-09:进入编辑态 — 回填末条 user 内容到输入框,聚焦,置 editingMsgId。 */
|
/** UX-09:进入编辑态 — 置 editingMsgId(透传 ChatInput prop)+ 委托 ChatInput.startEditFocus 回填/聚焦。 */
|
||||||
function startEdit(msg: AiMessage): void {
|
function startEdit(msg: AiMessage): void {
|
||||||
editingMsgId.value = msg.id
|
editingMsgId.value = msg.id
|
||||||
inputText.value = msg.content
|
chatInputRef.value?.startEditFocus(msg)
|
||||||
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'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** UX-09:取消编辑态(切换对话/清空/ESC)。 */
|
/** UX-09:取消编辑态(切换对话/清空/ESC)。清 editingMsgId + 委托 ChatInput.clearInput 清输入。 */
|
||||||
function cancelEdit(): void {
|
function cancelEdit(): void {
|
||||||
editingMsgId.value = null
|
editingMsgId.value = null
|
||||||
inputText.value = ''
|
chatInputRef.value?.clearInput()
|
||||||
if (inputEl.value) inputEl.value.style.height = 'auto'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** token 数格式化:<1000 原值,≥1000 用 k(如 1234→1.2k) */
|
/** token 数格式化:<1000 原值,≥1000 用 k(如 1234→1.2k) */
|
||||||
@@ -1391,11 +1014,7 @@ function onGlobalKeydown(e: KeyboardEvent) {
|
|||||||
* 用于 beforeunload 拦截(分离窗口关闭/刷新)与未来 onBeforeUnmount 拦截(面板切换)。
|
* 用于 beforeunload 拦截(分离窗口关闭/刷新)与未来 onBeforeUnmount 拦截(面板切换)。
|
||||||
*/
|
*/
|
||||||
function hasUnsentInput(): boolean {
|
function hasUnsentInput(): boolean {
|
||||||
return (
|
return chatInputRef.value?.hasUnsentInput() ?? false
|
||||||
inputText.value.trim().length > 0 ||
|
|
||||||
pendingImages.value.length > 0 ||
|
|
||||||
!!pendingSkill.value
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1409,162 +1028,8 @@ function onBeforeUnloadGuard(e: BeforeUnloadEvent) {
|
|||||||
e.returnValue = ''
|
e.returnValue = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
// 第二批抽离: handleKeydown/autoResize/handleSend 已迁移至 ChatInput.vue
|
||||||
// UX-09:编辑态 Escape 取消编辑(最高优先级)
|
// (ChatInput 经 emit('sent')/emit('cancel-edit')/emit('error') 与父交互)
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── B-260616-02 L2 发送韧性:排队超时检测 + 强制发送 ──
|
// ── B-260616-02 L2 发送韧性:排队超时检测 + 强制发送 ──
|
||||||
|
|
||||||
|
|||||||
641
src/components/ai/ChatInput.vue
Normal file
641
src/components/ai/ChatInput.vue
Normal file
@@ -0,0 +1,641 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ai-input-area">
|
||||||
|
<!-- 已选技能 chip(含参数格式提示 argument_hint) -->
|
||||||
|
<div v-if="pendingSkill" class="ai-skill-chip">
|
||||||
|
<span class="ai-skill-chip-main">
|
||||||
|
<span class="ai-skill-chip-name">/{{ pendingSkill.name }}</span>
|
||||||
|
<span v-if="pendingSkill.description" class="ai-skill-chip-desc">{{ pendingSkill.description }}</span>
|
||||||
|
<code v-if="pendingSkill.argument_hint" class="ai-skill-chip-hint">{{ pendingSkill.argument_hint }}</code>
|
||||||
|
</span>
|
||||||
|
<button class="ai-skill-chip-x" @click="clearSkill" :title="$t('aiChat.clearSkill')" :aria-label="$t('aiChat.clearSkill')">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="ai-input-wrap">
|
||||||
|
<!-- F-260614-05 Phase 2b: 待发送图片预览(粘贴/拖拽加入,发送前可移除) -->
|
||||||
|
<div v-if="pendingImages.length" class="ai-img-preview-row">
|
||||||
|
<div v-for="img in pendingImages" :key="img.id" class="ai-img-preview">
|
||||||
|
<img :src="img.dataUrl" :alt="$t('aiChat.imagePreviewAlt')" class="ai-img-preview-thumb" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ai-img-preview-x"
|
||||||
|
:title="$t('aiChat.removeImage')"
|
||||||
|
:aria-label="$t('aiChat.removeImage')"
|
||||||
|
@click="removePendingImage(img.id)"
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
ref="inputEl"
|
||||||
|
v-model="inputText"
|
||||||
|
class="ai-input"
|
||||||
|
:class="{ 'ai-input--editing': !!editingMsgId }"
|
||||||
|
:placeholder="editingMsgId
|
||||||
|
? $t('aiChat.placeholderEditing')
|
||||||
|
: (pendingSkill ? (pendingSkill.argument_hint || $t('aiChat.placeholderWithSkill')) : $t('aiChat.placeholderDefault'))"
|
||||||
|
rows="1"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
@input="autoResize"
|
||||||
|
@paste="onPaste"
|
||||||
|
@dragover="onDragOver"
|
||||||
|
@drop="onDrop"
|
||||||
|
></textarea>
|
||||||
|
<!-- 技能联想浮层 -->
|
||||||
|
<div v-if="skillOpen" class="ai-skill-popover">
|
||||||
|
<div v-if="!filteredSkills.length" class="ai-skill-empty">
|
||||||
|
{{ store.state.skills.length ? $t('aiChat.skillNoMatch') : $t('aiChat.skillNotLoaded') }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="(s, i) in filteredSkills"
|
||||||
|
:key="s.path"
|
||||||
|
class="ai-skill-item"
|
||||||
|
:class="{ 'ai-skill-item--active': i === skillIndex }"
|
||||||
|
@click="selectSkill(s)"
|
||||||
|
@mouseenter="skillIndex = i"
|
||||||
|
>
|
||||||
|
<div class="ai-skill-item-row">
|
||||||
|
<span class="ai-skill-item-name">/{{ s.name }}</span>
|
||||||
|
<span class="ai-skill-item-desc">{{ s.description }}</span>
|
||||||
|
<span class="ai-skill-item-src">{{ s.source }}</span>
|
||||||
|
</div>
|
||||||
|
<code v-if="s.argument_hint" class="ai-skill-item-hint">{{ s.argument_hint }}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- UX-10 §1.4: @ 实体引用浮层(@ 触发,选中插入 [类型:名] 标记,架构复用技能 popover) -->
|
||||||
|
<div v-if="mentionOpen" class="ai-skill-popover ai-mention-popover">
|
||||||
|
<div v-if="!mentionItems.length" class="ai-skill-empty">
|
||||||
|
{{ mentionTotal ? $t('aiChat.mentionNoMatch') : $t('aiChat.mentionEmpty') }}
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<template v-for="g in mentionGroups" :key="g.key">
|
||||||
|
<div v-if="g.items.length" class="ai-mention-group">{{ g.label }}</div>
|
||||||
|
<div
|
||||||
|
v-for="item in g.items"
|
||||||
|
:key="item.uid"
|
||||||
|
class="ai-skill-item"
|
||||||
|
:class="{ 'ai-skill-item--active': mentionFlatIndex(item.uid) === mentionIndex }"
|
||||||
|
@click="selectMention(item)"
|
||||||
|
@mouseenter="mentionIndex = mentionFlatIndex(item.uid)"
|
||||||
|
>
|
||||||
|
<div class="ai-skill-item-row">
|
||||||
|
<span class="ai-mention-item-type" :class="'ai-mention-item-type--' + item.type">{{ item.typeLabel }}</span>
|
||||||
|
<span class="ai-mention-item-name">{{ item.name }}</span>
|
||||||
|
<span class="ai-mention-item-desc">{{ item.desc }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="store.state.streaming"
|
||||||
|
class="ai-send-btn ai-send-btn--stop"
|
||||||
|
:title="$t('aiChat.stopGenerating')"
|
||||||
|
@click="store.stopChat"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="ai-send-btn"
|
||||||
|
:class="{ 'ai-send-btn--active': canSend }"
|
||||||
|
:disabled="!canSend"
|
||||||
|
@click="handleSend"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="ai-input-hint">{{ $t('aiChat.inputHint') }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// ChatInput: AI 输入框子组件(第二批抽离自 AiChat.vue God 组件)
|
||||||
|
// 职责:输入框 textarea + 附件(图片粘贴/拖拽)+ 技能 `/` 联想 + `@` 实体引用 + 发送/停止按钮
|
||||||
|
// 边界(交父处理):
|
||||||
|
// - editingMsgId 由父持有(prop),startEdit/cancelEdit 由父操作 prop + 调本组件 expose
|
||||||
|
// - 发送/编辑完成 emit('sent') → 父 scrollToBottom
|
||||||
|
// - 出错 emit('error', { msg, type }) → 父 showToast(单一 toast 源)
|
||||||
|
// - 示例问题卡片在父(空状态区),父调 expose sendExamplePrompt 触发本组件发送
|
||||||
|
// store 单例共享:useAiStore(sendMessage/editMessage/stopChat)、useProjectStore(@ 联想源)
|
||||||
|
import { ref, computed, nextTick, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useAiStore } from '../../stores/ai'
|
||||||
|
import { useProjectStore } from '../../stores/project'
|
||||||
|
import type { AiMessage, ContentPart, SkillInfo, ProjectRecord, TaskRecord } from '../../api/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** UX-09: 编辑末条 user 消息态;父持有,handleSend 据此走 editMessage 而非 sendMessage */
|
||||||
|
editingMsgId: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
/** 发送或编辑完成(父 scrollToBottom) */
|
||||||
|
(e: 'sent'): void
|
||||||
|
/** 出错提示(父 showToast,保持单一 toast 源) */
|
||||||
|
(e: 'error', payload: { msg: string; type: 'error' | 'warning' | 'info' }): void
|
||||||
|
/** UX-09 编辑态取消(Esc / 编辑完成清 editingMsgId) */
|
||||||
|
(e: 'cancel-edit'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const store = useAiStore()
|
||||||
|
const projectsStore = useProjectStore()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const inputText = ref('')
|
||||||
|
const inputEl = ref<HTMLTextAreaElement>()
|
||||||
|
|
||||||
|
// ── 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 }[]>([])
|
||||||
|
|
||||||
|
/** 读 File → base64 data URL(供 <img> 预览 + ContentPart.image base64 字段) */
|
||||||
|
function readImageFile(file: File): Promise<string> {
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把 image File 加入 pendingImages;超限/超大小跳过并 toast */
|
||||||
|
async function addImageFile(file: File): Promise<void> {
|
||||||
|
if (!file.type.startsWith('image/')) return
|
||||||
|
if (file.size > MAX_IMAGE_BYTES) {
|
||||||
|
emit('error', { msg: t('aiChat.imageTooLarge'), type: 'warning' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (pendingImages.value.length >= MAX_PENDING_IMAGES) {
|
||||||
|
emit('error', { msg: t('aiChat.imageLimit', { n: MAX_PENDING_IMAGES }), type: '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) {
|
||||||
|
emit('error', { msg: t('aiChat.imageReadFailed'), type: '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<void> {
|
||||||
|
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<void> {
|
||||||
|
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)
|
||||||
|
|
||||||
|
// ── 技能 `/` 联想 ──
|
||||||
|
const skillOpen = ref(false)
|
||||||
|
const skillIndex = ref(0)
|
||||||
|
const pendingSkill = ref<SkillInfo | null>(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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 发送(原逻辑)。
|
||||||
|
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<MentionItem[]>(() => {
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
// UX-09:编辑态 Escape 取消编辑(最高优先级)
|
||||||
|
if (props.editingMsgId && e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
emit('cancel-edit')
|
||||||
|
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 (props.editingMsgId) {
|
||||||
|
if (!text) return
|
||||||
|
inputText.value = ''
|
||||||
|
if (inputEl.value) inputEl.value.style.height = 'auto'
|
||||||
|
try {
|
||||||
|
await store.editMessage(text)
|
||||||
|
emit('cancel-edit')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[AI] 编辑失败:', e)
|
||||||
|
// 失败回填输入框供重试,保持编辑态(editMessage 内已回滚占位)
|
||||||
|
inputText.value = text
|
||||||
|
const msg = e instanceof Error ? e.message : String(e)
|
||||||
|
emit('error', { msg: t('aiChat.editMessageFailed', { msg }), type: 'error' })
|
||||||
|
}
|
||||||
|
await nextTick()
|
||||||
|
emit('sent')
|
||||||
|
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)
|
||||||
|
emit('error', { msg: t('aiChat.toastSendFail', { msg }), type: 'error' })
|
||||||
|
}
|
||||||
|
await nextTick()
|
||||||
|
emit('sent')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点击示例问题卡片:填入输入框并发送(复用 handleSend 同款发送路径,错误回退一致) */
|
||||||
|
async function sendExamplePrompt(i18nKey: string) {
|
||||||
|
inputText.value = t(i18nKey)
|
||||||
|
await handleSend()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 父调用 expose(父 startEdit/cancelEdit/beforeunload 调用) ──
|
||||||
|
|
||||||
|
/** UX-09 进入编辑态:回填末条 user 内容到输入框,聚焦,光标置末 */
|
||||||
|
function startEditFocus(msg: AiMessage): void {
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UX-09 取消编辑态:清空输入框 + 重置高度(父置 editingMsgId=null 后调) */
|
||||||
|
function clearInput(): void {
|
||||||
|
inputText.value = ''
|
||||||
|
pendingSkill.value = null
|
||||||
|
pendingImages.value = []
|
||||||
|
skillOpen.value = false
|
||||||
|
mentionOpen.value = false
|
||||||
|
mentionStart.value = -1
|
||||||
|
if (inputEl.value) inputEl.value.style.height = 'auto'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 聚焦输入框 */
|
||||||
|
function focus(): void {
|
||||||
|
inputEl.value?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UX-260617-16:未发送输入保护。检测 inputText/pendingImages/pendingSkill 任一非空,
|
||||||
|
* 用于 beforeunload 拦截(分离窗口关闭/刷新)与未来 onBeforeUnmount 拦截(面板切换)。
|
||||||
|
*/
|
||||||
|
function hasUnsentInput(): boolean {
|
||||||
|
return (
|
||||||
|
inputText.value.trim().length > 0 ||
|
||||||
|
pendingImages.value.length > 0 ||
|
||||||
|
!!pendingSkill.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
focus,
|
||||||
|
startEditFocus,
|
||||||
|
clearInput,
|
||||||
|
hasUnsentInput,
|
||||||
|
sendExamplePrompt,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user