Files
DevFlow/src/components/ai/ChatInput.vue
绝尘 ad1821bc14 修复: 审批不超时+重启恢复 + 其他小改
- APPROVAL_TIMEOUT_MS=Infinity(已决策:审批不做超时)
- V33 迁移: ai_conversations 加 pending_approvals 列
- save_conversation 持久化 pending_approvals
- ai_conversation_switch 从 DB 恢复 pending_approvals
- switchConversation 加 try/catch(对话不存在→建新)
- @项目 关联添加取消按钮(×)
- TopBar 底部无标题时图标右对齐
- TaskDetail.vue 删除重复 case
2026-06-28 13:09:55 +08:00

1184 lines
43 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>
<!-- L2 状态机停止按钮三态(批2 1c):conv_state 派生 generating/stopping/error 三态,
idle( conv_state 未收到回退旧 streaming=false)显发送按钮双轨过渡不强制全替旧 bool -->
<button
v-if="stopBtnState === 'stopping'"
class="ai-send-btn ai-send-btn--stop ai-send-btn--stopping"
:title="$t('aiChat.stopping')"
disabled
>
<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-if="stopBtnState === 'retry'"
class="ai-send-btn ai-send-btn--stop ai-send-btn--retry"
:title="$t('aiChat.stopFailedRetry')"
@click="store.stopChat"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
</button>
<button
v-else-if="stopBtnState === 'stop'"
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>
<!-- .4 Phase 4: @项目展开摘要(输入区 enrichment 预览) -->
<div v-if="hasEnrichment" class="ai-enrichment-bar">
<button
class="ai-enrichment-toggle"
:class="{ 'ai-enrichment-toggle--expanded': enrichmentExpanded }"
@click="enrichmentExpanded = !enrichmentExpanded"
:title="enrichmentExpanded ? $t('aiChat.enrichmentCollapse') : $t('aiChat.enrichmentExpand')"
:aria-label="enrichmentExpanded ? $t('aiChat.enrichmentCollapse') : $t('aiChat.enrichmentExpand')"
>
<svg class="ai-enrichment-chevron" width="10" height="10" viewBox="0 0 10 10" fill="currentColor">
<path d="M2 3l3 4 3-4" />
</svg>
<span class="ai-enrichment-badge">{{ enrichmentBadgeText }}</span>
</button>
<button class="ai-enrichment-dismiss" @click="dismissEnrichment" title="取消关联">×</button>
<div v-if="enrichmentExpanded" class="ai-enrichment-panel">
<div class="ai-enrichment-label">{{ $t('aiChat.enrichmentLabel') }}</div>
<pre class="ai-enrichment-body"><code>{{ enrichmentDetailText }}</code></pre>
</div>
</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 { getConvState } from '../../composables/ai/useAiEvents'
import type { AiMessage, ContentPart, SkillInfo, ProjectRecord, TaskRecord, IdeaRecord, MentionSpan } 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)
// L2 状态机停止按钮三态(批2 1c):从 conv_state 派生,旧 streaming/generating bool 双轨过渡兜底。
//
// 三态映射(对齐后端 ConvState 语义):
// - 'stopping':ConvState=stopping,停止中 → 显"停止中" disabled(防重复点);
// - 'stop': ConvState=generating(含审批挂起/压缩派生等活跃态)→ 显停止按钮可点;
// - 'retry': ConvState=error,停止失败可重试 → 显重试按钮(再发 stop 信号);
// - 'idle': 空闲(或 conv_state 未收到回退旧 bool=false)→ 显发送按钮。
//
// 兜底:conv_state 未追踪过(getConvState 返回 null,CONV_STATE_ENABLED=off 或老后端)时,
// 回退旧 store.state.streaming 判断(generating→显停止,否则 idle)。不强制全替旧 bool,防回归。
// 注:旧 streaming 是全局单值(非 per-conv),仅当前会话生成时为 true;F-09 多会话并发下 conv_state
// 更精确,但回退路径用 streaming 对单会话场景语义等价。
const stopBtnState = computed<'idle' | 'stop' | 'stopping' | 'retry'>(() => {
const convId = store.state.activeConversationId
const cs = getConvState(convId)
if (cs === 'stopping') return 'stopping'
if (cs === 'error') return 'retry'
if (cs === 'generating' || cs === 'compressed') return 'stop'
// conv_state 未收到(idle 或 null)→ 回退旧 streaming bool
return store.state.streaming ? 'stop' : 'idle'
})
// ── 技能 `/` 联想 ──
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) => {
// Input Augmentation: skill chip + @ 标记共存(两者正交 user intent)——删原 pendingSkill
// 非空时强制关 mention 的分支。skill chip 是独立 UI 态(已选技能),@ 是输入态(光标触发),
// 用户可同时持有已选技能并在文本里 @ 引用实体。
// / 技能联想(仅当输入以 / 开头且光标在首字符区)
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 = ''
// selectSkill 清空 inputText,pendingMentionSpans 指向的区间随之失效,一并清空防孤儿 span
pendingMentionSpans.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' | 'idea'
/** 扁平后唯一 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)
// Input Augmentation: pending mention 区间元数据(selectMention 记录,handleSend 透传后清空)。
// 每个 span 描述用户气泡内一段 [类型: 名] 文本在 inputText 中的 {start,length} + kind/refId/label,
// 后端据 mentionSpans resolve 投影成 Augmentation 注入;MessageList 据此精确切区间渲染 chip。
const pendingMentionSpans = ref<MentionSpan[]>([])
// ── ⑥.4 Phase 4: @项目展开摘要(输入区 enrichment 预览) ──
// 当用户 @[项目:xxx] 后,从 projectsStore 反查项目+关联任务/灵感,
// 在输入区下方显式 badge + 可展开的上下文参考面板。
interface ProjectEnrichment {
id: string
name: string
status: string
path: string | null
taskCount: number
ideaCount: number
inProgressTasks: { title: string; status: string }[]
pendingIdeas: { title: string; status: string }[]
}
const enrichmentExpanded = ref(false)
/** @项目 mentions 经 stores 反查后的 enrichment 摘要列表 */
const projectEnrichments = computed<ProjectEnrichment[]>(() => {
const projectSpans = pendingMentionSpans.value.filter(sp => sp.kind === 'project')
if (projectSpans.length === 0) return []
const projects = (projectsStore.projects || []) as ProjectRecord[]
const tasks = (projectsStore.tasks || []) as TaskRecord[]
const ideas = (projectsStore.ideas || []) as IdeaRecord[]
const results: ProjectEnrichment[] = []
for (const span of projectSpans) {
const project = projects.find(p => p.id === span.refId)
if (!project) continue
const relTasks = tasks.filter(t => t.project_id === project.id)
// ideas linking to this project via promoted_to (project name matching)
const relIdeas = ideas.filter(i => i.promoted_to === project.name || i.promoted_to === project.id)
const running = relTasks.filter(t => t.status === 'in_progress').slice(0, 20)
const pending = relIdeas.filter(i => i.status === 'pending_review').slice(0, 20)
results.push({
id: project.id,
name: project.name,
status: project.status as string,
path: project.path,
taskCount: relTasks.length,
ideaCount: relIdeas.length,
inProgressTasks: running.map(t => ({ title: t.title, status: t.status as string })),
pendingIdeas: pending.map(i => ({ title: i.title, status: i.status as string })),
})
}
return results
})
/** 是否有 enrichment 可展示 */
const hasEnrichment = computed(() => projectEnrichments.value.length > 0)
/** 概要 badge 文本(汇总所有 @项目 entrichment) */
const enrichmentBadgeText = computed(() => {
const list = projectEnrichments.value
if (!list.length) return ''
const totalTasks = list.reduce((s, p) => s + p.taskCount, 0)
const totalIdeas = list.reduce((s, p) => s + p.ideaCount, 0)
const names = list.map(p => p.name).join('、')
return t('aiChat.enrichmentBadge', { project: names, tasks: totalTasks, ideas: totalIdeas })
})
/** 展开后的 enrichment 正文(mimic 后端 build_augmentation_segment 格式) */
const enrichmentDetailText = computed(() => {
const list = projectEnrichments.value
if (!list.length) return ''
const lines: string[] = []
for (const p of list) {
lines.push(`【项目】${p.name}(状态: ${p.status}`)
if (p.path) lines.push(`目录: ${p.path}`)
if (p.inProgressTasks.length > 0) {
lines.push(`进行中任务(${p.taskCount}):`)
for (const t of p.inProgressTasks) {
lines.push(` - ${t.title} (${t.status})`)
}
}
if (p.pendingIdeas.length > 0) {
lines.push(`待评估灵感(${p.ideaCount}):`)
for (const i of p.pendingIdeas) {
lines.push(` - ${i.title} (${i.status})`)
}
}
lines.push('')
}
return lines.join('\n')
})
/** 取消 @项目 关联:清除 mentionSpan + 移除输入框中的 [@项目: xxx] 标记 */
function dismissEnrichment() {
const projectSpans = pendingMentionSpans.value.filter(sp => sp.kind === 'project')
if (projectSpans.length === 0) return
// 从 inputText 中移除所有 [@项目: xxx] 标记
let text = inputText.value
for (const span of projectSpans) {
const label = text.slice(span.start, span.start + span.length)
// 替换标记 + 前后可能的空格为单个空格
const pattern = new RegExp(`\\s*${escapeRegex(label)}\s*`)
text = text.replace(pattern, ' ').trim()
}
inputText.value = text
// 清除 project 类型的 mentionSpan
pendingMentionSpans.value = pendingMentionSpans.value.filter(sp => sp.kind !== 'project')
enrichmentExpanded.value = false
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
const mentionGroupLabel = computed(() => ({
project: t('aiChat.mentionGroupProject'),
task: t('aiChat.mentionGroupTask'),
idea: t('aiChat.mentionGroupIdea'),
}))
/** 当前 @ 查询文本(@ 之后的串,不含 @) */
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'),
}))
const ideas = (projectsStore.ideas || []) as IdeaRecord[]
const ideaItems: MentionItem[] = ideas
.filter(it => match(it.title) || match(it.description || ''))
.slice(0, 20)
.map(it => ({
type: 'idea' as const,
uid: 'i-' + it.id,
id: it.id,
name: it.title,
desc: it.description || '',
typeLabel: t('aiChat.mentionGroupIdea'),
}))
return [...projectItems, ...taskItems, ...ideaItems]
})
/** 联想候选总数(未过滤,用于区分"无匹配" vs "空") */
const mentionTotal = computed(() =>
((projectsStore.projects || []) as ProjectRecord[]).length
+ ((projectsStore.tasks || []) as TaskRecord[]).length
+ ((projectsStore.ideas || []) as IdeaRecord[]).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') },
{ key: 'idea', label: mentionGroupLabel.value.idea, items: mentionItems.value.filter(i => i.type === 'idea') },
])
/** 扁平下标(组内 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}]`
: item.type === 'task'
? `[${t('aiChat.mentionGroupTask')}: ${item.name}]`
: `[${t('aiChat.mentionGroupIdea')}: ${item.name}]`
inputText.value = before + label + ' ' + after
// Input Augmentation: 记 pending mention 区间(start=before.length,label 区间长度=label.length)。
// before 已包含此前所有插入的 [类型: 名] 标记,故 before.length 恰为本 label 在最终文本中的起点。
// refId 用实体 id(后端 resolve 用),label 即 chip 展示文本(== 后段校验用 == 文本)。
pendingMentionSpans.value.push({
start: before.length,
length: label.length,
kind: item.type,
refId: item.id,
label,
})
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()
// Input Augmentation: 快照 pending mention 区间(同 parts 快照,失败时回填)。
// 关键:span.start 是相对原始 inputText(可能含前导空格)的偏移;text = inputText.trim()
// 已剥前导空白。若不修正偏移,trim 掉的前导空白会让所有 chip start 错位 → 渲染降级纯文本。
// 此处按剥掉的前导空白长度整体左移 start(尾部 trim 不影响已记录的 chip start;chip 不在尾部空白)。
const rawLeadingWs = inputText.value.length - inputText.value.replace(/^\s+/, '').length
const snapshotSpans = pendingMentionSpans.value.slice().map(sp => ({
...sp,
start: Math.max(0, sp.start - rawLeadingWs),
}))
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 = []
// Input Augmentation: 清空 mention 区间(与 inputText 清空同处,保证下条消息从空白态起步)
pendingMentionSpans.value = []
skillOpen.value = false
mentionOpen.value = false
mentionStart.value = -1
if (inputEl.value) inputEl.value.style.height = 'auto'
// 同步清空输入框后立即发送sendMessage 内部同步 push 用户消息,
// 清空与 push 合并到同一渲染周期 flush消除"输入框已空但消息未显示"的间隙
try {
// Input Augmentation: 透传 mentionSpans(L0 直接 doSend 注入 + 本地 user 消息挂 mentionSpans 渲染 chip;
// L1 入队续发当前不挂 spans,见 useAiSend.sendMessage 设计取舍注释)
await store.sendMessage(text, skill?.name, false, parts, snapshotSpans.length > 0 ? snapshotSpans : undefined)
} catch (e) {
console.error('[AI] 发送失败:', e)
inputText.value = text
pendingSkill.value = skill
// 发送失败:回填图片供重试(快照原样还原)
if (parts) {
pendingImages.value = snapshotImgs
}
// 发送失败:回填 mention 区间供重试(快照原样还原,区间仍与回填文本对齐)
if (snapshotSpans.length) {
pendingMentionSpans.value = snapshotSpans
}
// 用户可见提示(原仅 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 = []
pendingMentionSpans.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>
<!-- ChatInput scoped 样式(第二批抽离遗漏修复: AiChat.vue <style scoped> 迁移,
零视觉变更 AiChat scoped 不渗透子组件根,输入框/技能联想/发送按钮元素失配 本组件自持
CSS 变量(--df-*)由根级 global.css 提供,共享同一份.ai-input-area 容器留父级 AiChat scoped) -->
<style scoped>
.ai-input-wrap {
position: relative;
display: flex;
/* F-260614-05 Phase 2b: flex-wrap 让全宽的图片预览行强制 textarea+按钮换行到下一行 */
flex-wrap: wrap;
align-items: center;
gap: 6px;
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg);
padding: 6px 8px;
transition: border-color 0.15s;
}
/* F-260614-05 Phase 2b: 待发送图片预览行(粘贴/拖拽加入,发送前可移除) */
.ai-img-preview-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
width: 100%; /* 全宽强制后续 textarea+按钮换行 */
padding-bottom: 4px;
border-bottom: 0.5px solid var(--df-border);
}
.ai-img-preview {
position: relative;
width: 56px;
height: 56px;
border-radius: var(--df-radius);
overflow: hidden;
border: 0.5px solid var(--df-border);
background: var(--df-bg);
}
.ai-img-preview-thumb {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.ai-img-preview-x {
position: absolute;
top: 2px;
right: 2px;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 50%;
background: color-mix(in srgb, var(--df-bg) 80%, transparent);
color: var(--df-text-dim);
font-size: 14px;
line-height: 1;
cursor: pointer;
padding: 0;
transition: background 0.15s, color 0.15s;
}
.ai-img-preview-x:hover {
background: var(--df-danger);
color: #fff;
}
.ai-input-wrap:focus-within {
border-color: color-mix(in srgb, var(--df-accent) 40%, transparent);
}
/* ── 技能 chip / 联想浮层 ── */
.ai-skill-chip {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
padding: 3px 8px;
background: color-mix(in srgb, var(--df-accent) 16%, transparent);
border: 0.5px solid color-mix(in srgb, var(--df-accent) 40%, transparent);
border-radius: var(--df-radius);
font-size: 12px;
}
.ai-skill-chip-name { color: var(--df-accent); font-weight: 600; flex-shrink: 0; }
.ai-skill-chip-desc { color: var(--df-text-dim); flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* 参数格式提示(技能自带 argument_hint 数据,不进 i18n) */
.ai-skill-chip-main { display: flex; align-items: center; gap: 6px; flex: 1; min-width: 0; flex-wrap: wrap; }
.ai-skill-chip-hint {
color: var(--df-text-dim);
font-family: var(--df-font-mono, ui-monospace, monospace);
font-size: 11px;
background: color-mix(in srgb, var(--df-text-dim) 14%, transparent);
padding: 1px 5px;
border-radius: 3px;
}
.ai-skill-chip-x { background: none; border: none; color: var(--df-text-dim); cursor: pointer; font-size: 14px; line-height: 1; padding: 0 2px; flex-shrink: 0; }
.ai-skill-chip-x:hover { color: var(--df-text); }
.ai-skill-popover {
position: absolute;
bottom: calc(100% + 4px);
left: 0;
right: 0;
max-height: 240px;
overflow-y: auto;
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
z-index: 20;
}
.ai-skill-item {
display: flex;
flex-direction: column;
gap: 2px;
padding: 6px 10px;
cursor: pointer;
font-size: 12px;
}
.ai-skill-item-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
.ai-skill-item--active,
.ai-skill-item:hover {
background: color-mix(in srgb, var(--df-accent) 14%, transparent);
}
.ai-skill-item-name { color: var(--df-accent); font-weight: 600; min-width: 90px; }
.ai-skill-item-desc { color: var(--df-text-dim); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ai-skill-item-src { color: var(--df-text-dim); opacity: 0.6; font-size: 10px; text-transform: uppercase; }
/* 浮层内参数格式提示(等宽 dim 小字,仅在 skill.argument_hint 有值时显) */
.ai-skill-item-hint {
color: var(--df-text-dim);
font-family: var(--df-font-mono, ui-monospace, monospace);
font-size: 11px;
opacity: 0.85;
word-break: break-all;
}
.ai-skill-empty { padding: 8px 12px; color: var(--df-text-dim); opacity: 0.7; font-size: 12px; }
/* ── UX-10 §1.4: @ 实体引用浮层(复用 .ai-skill-popover 容器,仅新增组标题/类型标签) ── */
.ai-mention-popover { /* 同 .ai-skill-popover 定位,这里仅为语义占位,样式继承 */ }
.ai-mention-group {
padding: 4px 10px 2px;
font-size: 10px;
font-weight: 600;
color: var(--df-text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
user-select: none;
}
.ai-mention-item-name { color: var(--df-text); font-weight: 500; flex-shrink: 0; }
.ai-mention-item-desc {
color: var(--df-text-dim);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 类型标签小色块(项目/任务区分) */
.ai-mention-item-type {
flex-shrink: 0;
padding: 1px 5px;
border-radius: var(--df-radius-sm);
font-size: 9px;
font-weight: 600;
letter-spacing: 0.3px;
background: var(--df-bg-hover);
color: var(--df-text-dim);
}
.ai-mention-item-type--project {
background: color-mix(in srgb, var(--df-accent) 16%, transparent);
color: var(--df-accent);
}
.ai-mention-item-type--task {
background: color-mix(in srgb, var(--df-success) 16%, transparent);
color: var(--df-success);
}
.ai-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--df-text);
font-size: 13px;
font-family: var(--df-font-sans);
line-height: 1.5;
resize: vertical;
min-height: 20px;
height: 20px;
max-height: 200px;
overflow-y: auto;
}
.ai-input::placeholder {
color: var(--df-text-dim);
}
.ai-send-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: var(--df-radius);
background: var(--df-bg-raised);
color: var(--df-text-dim);
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
}
.ai-send-btn--active {
background: var(--df-accent);
color: #fff;
}
.ai-send-btn--active:hover {
background: var(--df-accent-hover);
}
.ai-send-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.ai-send-btn--stop {
background: #e5484d;
color: #fff;
}
.ai-send-btn--stop:hover {
background: #d63a3f;
}
/* L2 停止中态:disabled 灰显(防重复点),复用 :disabled 通用规则(opacity 0.4) */
.ai-send-btn--stopping {
cursor: not-allowed;
}
/* L2 停止失败可重试态:橙色边框提示(区别于红色停止),hover 加深 */
.ai-send-btn--retry {
background: #e5484d;
color: #fff;
}
.ai-send-btn--retry:hover {
background: #d63a3f;
}
.ai-input-hint {
font-size: 10px;
color: var(--df-text-dim);
text-align: right;
margin-top: 4px;
opacity: 0.6;
}
/* ── ⑥.4 Phase 4: @项目展开摘要(输入区 enrichment 预览) ── */
.ai-enrichment-bar {
display: flex;
align-items: center;
margin-top: 6px;
border: 0.5px solid color-mix(in srgb, var(--df-accent) 30%, transparent);
border-radius: var(--df-radius);
overflow: hidden;
}
.ai-enrichment-toggle {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 4px 8px;
border: none;
background: color-mix(in srgb, var(--df-accent) 10%, transparent);
color: var(--df-text);
cursor: pointer;
font-size: 11px;
text-align: left;
transition: background 0.15s;
}
.ai-enrichment-toggle:hover {
background: color-mix(in srgb, var(--df-accent) 18%, transparent);
}
.ai-enrichment-toggle--expanded {
border-bottom: 0.5px solid color-mix(in srgb, var(--df-accent) 20%, transparent);
}
.ai-enrichment-chevron {
flex-shrink: 0;
color: var(--df-text-dim);
transition: transform 0.15s;
}
.ai-enrichment-toggle--expanded .ai-enrichment-chevron {
transform: rotate(180deg);
}
.ai-enrichment-dismiss {
flex-shrink: 0;
border: none;
background: transparent;
color: var(--df-text-dim);
cursor: pointer;
font-size: 14px;
padding: 0 4px;
line-height: 1;
transition: color 0.15s;
}
.ai-enrichment-dismiss:hover {
color: var(--df-danger);
}
.ai-enrichment-badge {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--df-accent);
font-weight: 500;
}
.ai-enrichment-panel {
padding: 6px 8px;
background: var(--df-bg);
max-height: 200px;
overflow-y: auto;
}
.ai-enrichment-label {
font-size: 10px;
color: var(--df-text-dim);
margin-bottom: 4px;
font-weight: 600;
}
.ai-enrichment-body {
margin: 0;
font-size: 11px;
line-height: 1.6;
color: var(--df-text);
white-space: pre-wrap;
word-break: break-all;
font-family: var(--df-font-mono, ui-monospace, monospace);
}
</style>