Files
DevFlow/src/components/ai/ChatInput.vue
T
lxy 36bc485112 优化: 界面交互完善与设置体验
- 审批卡尊重滚动意图,等长切换回底,排队超时按钮修活
- 带图编辑提示不静默丢,删除目标回写,切换文案消歧
- 会话键盘可达+搜索态重命名,筛选计数口径对齐
- 子任务提示正名,看板加载态
- 主题跟随系统,表单脏态拦截,密钥显隐留空提示
2026-10-03 16:27:48 +08:00

1131 lines
46 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">
<div class="ai-input-wrap">
<!-- 技能联想浮层须在 .ai-input-wrap(position:relative)内,
否则 .ai-skill-popover 的 absolute 定位错位跑到页面外不可见(/ 未触发根因)。 -->
<SkillMention
:skill-open="skillOpen"
:filtered-skills="filteredSkills"
:skill-index="skillIndex"
:pending-skill="pendingSkill"
:skill-query="skillQuery"
@select="selectSkill"
@clear="clearSkill"
@update:skill-index="skillIndex = $event"
/>
<!-- F-260614-05 Phase 2b: 待发送图片预览 -->
<ImageInput :pending-images="pendingImages" @remove="removePendingImage" />
<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>
<!-- UX-10 §1.4: @ 实体引用浮层(提取至 MentionPopover.vue,选中插入 [类型:名] 标记) -->
<MentionPopover
v-if="mentionOpen"
:mention-items="mentionItems"
:mention-groups="mentionGroups"
:mention-total="mentionTotal"
:mention-index="mentionIndex"
:mention-flat-index="mentionFlatIndex"
@select="selectMention"
@update:mention-index="mentionIndex = $event"
/>
<!-- 2026-08-06:📎 图片选择按钮(tauri 原生对话框选图 → 已配置上传端点则上传换 URL,否则本地 base64 引用) -->
<button
type="button"
class="ai-img-pick-btn"
:title="$t('aiChat.pickImage')"
:aria-label="$t('aiChat.pickImage')"
@click="onPickImage"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
</button>
<!-- 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: @项目展开摘要(提取至 EnrichmentPanel.vue) -->
<EnrichmentPanel
:has-enrichment="hasEnrichment"
:enrichment-badge-text="enrichmentBadgeText"
:enrichment-detail-text="enrichmentDetailText"
:enrichment-expanded="enrichmentExpanded"
@dismiss="dismissEnrichment"
@update:enrichment-expanded="enrichmentExpanded = $event"
/>
<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 { textIdle } from '../../composables/ai/useAiPendingState'
import { getConvDraft, setConvDraft, classifySendError, type SendErrorKind } from '../../composables/ai/aiShared'
import { extractImageUrlParts } from '../../composables/ai/utils'
import { uploadFile } from '../../composables/ai/fileUpload'
import { open } from '@tauri-apps/plugin-dialog'
import SkillMention from './SkillMention.vue'
import ImageInput from './ImageInput.vue'
import MentionPopover from './MentionPopover.vue'
import EnrichmentPanel from './EnrichmentPanel.vue'
import { taskApi } from '../../api/task'
import type { AiMessage, ContentPart, SkillInfo, ProjectRecord, TaskRecord, IdeaRecord, MentionSpan } from '../../api/types'
import type { MentionItem } from './MentionItem'
const props = defineProps<{
/** 编辑末条 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: 图片输入(粘贴/拖拽/选择) ──
// 待发送图片列表(url 或 base64 data URI + media_type),handleSend 时构造 ContentPart[]。
// 仅本组件级 ref,不入 store;发送后清空。预览缩略图渲染在输入框上方。
//
// 约束:
// - 限制单次最多 6 张(防超长上下文/超限请求);超过 toast 提示。
// - 限制单张 10MB(纯文本 base64 估算 ≥ 13M 字符;超限 toast 提示跳过)。
// - 仅接受 image/* MIME;非图片(文本粘贴/文件拖入)走原生行为不拦。
// - 2026-08-06:新增"📎 图片"按钮(tauri dialog 选图)→ 已配置上传端点则上传拿 URL
// (走 url 模式,provider 直传/Anthropic 预拉);未配置或上传失败回退 base64(dataUrl)。
// pendingImages 元素 url/dataUrl 二选一:url=上传服务返回的公开地址,dataUrl=base64。
const MAX_PENDING_IMAGES = 6
const MAX_IMAGE_BYTES = 10 * 1024 * 1024 // 10MB(解码后字节上限)
const pendingImages = ref<{ id: string; dataUrl?: string; url?: 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。已配置端点则上传拿 URL,否则回退 base64。 */
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
}
const id = `img-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
try {
// 已配置上传端点则上传 → URL 引用(公开可达,OpenAI 直传 / Anthropic 桌面预拉)。
// 未配置或失败回退 base64(dataUrl),不阻断图片输入。
const { url } = await uploadFile(file)
pendingImages.value.push({ id, url, mediaType: file.type })
} catch {
try {
const dataUrl = await readImageFile(file)
pendingImages.value.push({ id, dataUrl, mediaType: file.type })
} catch (e) {
emit('error', { msg: t('aiChat.imageReadFailed'), type: 'error' })
console.error('[AI] 图片读取失败:', e)
}
}
}
/** "📎 图片"按钮:tauri 原生文件对话框选图(多选,仅图片)→ 逐个 addImageFile。 */
async function onPickImage(): Promise<void> {
const selected = await open({
multiple: true,
title: t('aiChat.pickImageTitle'),
filters: [{ name: t('aiChat.imageFilter'), extensions: ['png', 'jpg', 'jpeg', 'webp', 'gif'] }],
})
if (!selected) return
// tauri dialog open 返 string[] 或 string
const files = Array.isArray(selected) ? selected : [selected]
for (const path of files) {
const f = await blobFromPath(path)
if (f) await addImageFile(f)
}
}
/** 从路径构造 File(Tauri dialog 返回路径,非 File;经 convertFileSrc 读字节构造)。
* convertFileSrc(path) → http://tauri.localhost/...(tauri 协议可访问本地文件,FilePreview 同款)。 */
async function blobFromPath(path: string): Promise<File | null> {
try {
const { convertFileSrc } = await import('@tauri-apps/api/core')
const res = await fetch(convertFileSrc(path))
if (!res.ok) return null
const blob = await res.blob()
// 扩展名 → MIME(tauri dialog filters 已限图片,兜底 image/jpeg)
const ext = path.split('.').pop()?.toLowerCase() || ''
const mime = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif' }[ext] || 'image/jpeg'
return new File([blob], path.split(/[\\/]/).pop() || 'image', { type: mime })
} catch (e) {
console.error('[AI] 读取所选文件失败:', path, e)
return null
}
}
/** 移除待发送图片 */
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)
// 按钮状态:streaming=true 且(本会话 conv_state='generating' 或最近 300ms 有文本 delta)→ 'stop'(红);
// 否则 → 'idle'(白)。textIdle 是 reactive ref,在 AiTextDelta 中每 delta 复位 false,
// 300ms 无新 delta 后 setTimeout 置 true。仅靠 textIdle 会漏掉工具执行/轮间 LLM 等待期
// (30s heartbeat 无 delta,按钮回落发送图标,生成失控不可干预),故补 conv_state 判定;
// textIdle 兜底保留,防 conv_state 尚未追踪(null)瞬间按钮闪断。
const stopBtnState = computed<'idle' | 'stop' | 'stopping' | 'retry'>(() => {
const convId = store.state.activeConversationId
const cs = getConvState(convId)
if (cs === 'stopping') return 'stopping'
// P1-3: conv_state='error' 是 AiError 收尾落的终态(useAiLifecycleEvents cleanup 先
// setStreaming(false) 再置 error),此时本会话 loop 已死——「重试停止」按钮点 stopChat 对死
// loop 无意义,却顶掉发送位,出错后鼠标用户点不了发送(Enter 不看按钮态仍可发,语义错位)。
// 仅流式仍在跑(停止失败等 loop 未断的 error)时保留 retry 语义,否则回发送按钮,
// 与 Enter/handleSend 对齐。
if (cs === 'error') return store.state.streaming ? 'retry' : 'idle'
const textActive = store.state.streaming && (cs === 'generating' || !textIdle.value)
return textActive ? '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
// @ 实体引用(技能联想与 @ 联想互斥,二者同时仅一开;视觉打架防护)
if (skillOpen.value) {
mentionOpen.value = false
mentionStart.value = -1
} else {
detectMentionTrigger()
}
})
/**
* 检测光标前最近的 @ 触发符。
* 规则:从光标位置向前找最近的 @,期间无空白/换行则激活(支持 "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
// P1-g: 用户首次进入 @ 联想即懒加载全量任务候选(幂等,缓存判重;不在 computed 副作用内调用)
void ensureTaskSuggestions()
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 混。
// 联想源:projects/ideas 读 store;tasks 联想候选读 store.tasks,但 @项目 enrichment
// 反查任务走 projectTasksCache(独立 list_tasks,隔离 Tasks 视图筛选,见 P1-g)。
// 触发:输入框光标前最近的 @ 后无空白/换行 → 激活;Esc/光标移开/选中插入 → 关闭。
// Enter 不冲突:popover 开时 Enter 插入选中(覆盖发送),关时 Enter 发送(原逻辑)。
// MentionItem 接口已抽至 ./MentionItem.ts(MentionPopover 子组件共用)。
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 + 可展开的上下文参考面板。
// P1-g: 任务联想源隔离 store.tasks(后者反映 Tasks 视图当前筛选+分页,B-29 契约)。
// @项目 enrichment 按项目反查时读 store.tasks 会被当前页筛选污染(只见到筛进来的子集)。
// 改为按 project_id 独立 invoke list_tasks({project_id, limit}) + 本地 Map 缓存,
// 切项目(projectsStore.projects 列表重载/项目删除)清空缓存防陈旧。
const MENTION_TASK_LIMIT = 50
const projectTasksCache = ref<Map<string, TaskRecord[]>>(new Map())
// P1-g: @任务联想候选源隔离 store.tasks(反映 Tasks 视图筛选+分页子集)。
// 懒加载全量未删任务(taskApi.list() 无参=全量)+ 缓存;首次 @ 触发拉取,失败空数组占位防反复重试。
const taskSuggestionCache = ref<TaskRecord[] | null>(null)
async function ensureTaskSuggestions(): Promise<void> {
if (taskSuggestionCache.value) return
try {
taskSuggestionCache.value = await taskApi.list()
} catch (e) {
taskSuggestionCache.value = []
console.error('[ChatInput] 加载任务联想失败:', e)
}
}
/** 拉取指定项目的任务并缓存(已缓存则跳过);失败静默降级为空数组(不阻塞 enrichment 渲染) */
async function ensureProjectTasks(projectId: string): Promise<void> {
if (projectTasksCache.value.has(projectId)) return
try {
const list = await taskApi.list({ project_id: projectId, limit: MENTION_TASK_LIMIT })
// 写新 Map(响应式触发:Map 重赋值让 computed 重算,mutate 旧 Map 不可靠)
const next = new Map(projectTasksCache.value)
next.set(projectId, list)
projectTasksCache.value = next
} catch (e) {
// 失败占位空数组避免反复重试,下次切项目自然清缓存重拉
const next = new Map(projectTasksCache.value)
next.set(projectId, [])
projectTasksCache.value = next
console.error('[ChatInput] 加载项目任务失败:', e)
}
}
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[]
// P1-g: 任务从 projectTasksCache 读(按 project_id 独立拉取),
// 不读 store.tasks(后者反映 Tasks 视图筛选+分页,会污染 @项目 enrichment)。
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 = (projectTasksCache.value.get(project.id) || []) as TaskRecord[]
// 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)
// 替换标记 + 前后可能的空格为单个空格
// M2: 原尾段 \s* 在模板字符串里是单反斜杠 → 解析成字面 s(零或多个 s 字母),
// 应为 \\s*(双反斜杠 → 正则 \s*),与首段 \s* 对称匹配尾部空白。
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, '\\$&')
}
// P1-g: @项目 mention 选中后懒加载该项目的任务(独立 list_tasks 通道,隔离 store.tasks 筛选)。
// 选中即触发,首次拉取后入缓存,后续同一项目不重复拉。
watch(
() => pendingMentionSpans.value.filter(sp => sp.kind === 'project').map(sp => sp.refId),
(projectIds) => {
for (const pid of projectIds) {
if (pid) void ensureProjectTasks(pid)
}
},
{ flush: 'post' },
)
// P1-g: 切项目清缓存 —— projectsStore.projects 列表重载/增删会让缓存 id 失效(项目删除后
// 缓存条目悬空)。监听 projects 引用变化即清空,下次 @项目 自然重拉最新。
watch(
() => projectsStore.projects,
() => {
if (projectTasksCache.value.size > 0) projectTasksCache.value = new Map()
},
)
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 = (taskSuggestionCache.value ?? []) 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
+ (taskSuggestionCache.value?.length ?? 0)
+ ((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) {
// P1-1 输入法组词守卫:拼音/双拼组词未上屏时 Enter=选词、Esc=取消组词,均非用户发送/取消意图,
// 直接放行给 IME。isComposing 覆盖标准浏览器;keyCode===229 兜部分环境 keydown 阶段不上报 isComposing。
if (e.isComposing || e.keyCode === 229) return
// 各 Esc 消费分支 stopPropagation:P1-2 双层防,分离窗口 AiChat 的 window 级 Esc 会关整个窗口,
// 内层已消费的 Esc 不再外抛(主防线是 AiChat 侧的 e.target 输入控件守卫,此处为辅)
// 编辑态 Escape 取消编辑(最高优先级)
if (props.editingMsgId && e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
emit('cancel-edit')
return
}
// Escape 关闭技能联想浮层(无论是否有匹配项)
if (skillOpen.value && e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
skillOpen.value = false
return
}
// 已选技能 chip 态下 Escape 取消选中(浮层已关闭,这里兜底 chip 可取消路径)
if (pendingSkill.value && e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
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()
e.stopPropagation()
skillOpen.value = false
return
}
}
// @ 实体引用浮层导航(仅有匹配项时拦截方向/回车;与技能联想互斥)
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()
e.stopPropagation()
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 => {
// url 模式(已配置上传端点时上传所得):直传 url,provider 直传 / Anthropic 预拉。
if (img.url) {
return {
type: 'image' as const,
url: img.url,
base64: null,
media_type: img.mediaType,
alt: null,
}
}
// 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
// F-260614-05 Phase 2c: 扫输入文本里的图片 URL → image 片(url 模式),与粘贴 base64 图合并。
// 治会话 01f05167:用户贴 URL 文本 → 没转 parts → 模型只收字符串 → AI fetch 失败 L1 熔断。
// text 保留原样(供 audit/纯文本模型),parts 附加 image 让 vision 模型直接看图。
// parts 已含 text 片(粘贴图场景)时直接展开追加 url 图;parts 为空且 text 非空时补 text 片对齐上面构造逻辑。
const urlParts = extractImageUrlParts(text)
const mergedParts: ContentPart[] | undefined = urlParts.length > 0
? [...(parts ?? (text ? [{ type: 'text' as const, text } as ContentPart] : [])), ...urlParts]
: parts
// 生成中不再拦截: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, mergedParts, 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 对象。
// I5:按错误内容分类映射友好文案,原始错误串留在 {msg} 细节里不丢排查线索
const msg = e instanceof Error ? e.message : String(e)
emit('error', { msg: t(SEND_ERR_KEY[classifySendError(msg)], { 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 {
// UX 走查-对话域 B3:带图消息禁止进入编辑态。editMessage IPC(ai_chat_edit)只回填/替换
// content 文本,不接受 parts——编辑重发后图片静默丢失(重载后图消失),无任何提示。
// 后端契约未扩前,入口挡住并提示,杜绝静默丢数据。父已先置 editingMsgId 再调本方法,
// 故 emit cancel-edit 让父复位编辑态,状态保持一致。
if (msg.parts?.some(p => p.type === 'image')) {
emit('cancel-edit')
emit('error', { msg: t('aiChat.editImageBlocked'), type: 'warning' })
return
}
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'
}
/** I4 队列回填:父把取出的队列项文本填回输入框重新编辑。
* 可选 skill:队列项携带技能名时由父按名找回 SkillInfo 回挂 chip;找回失败传 null 仅丢
* chip(与队列原地编辑只保 text 同一取舍)。清 mention 区间/浮层态,光标置尾并聚焦。 */
function fillDraft(text: string, skill: SkillInfo | null = null): void {
pendingSkill.value = skill
inputText.value = text
pendingMentionSpans.value = []
mentionOpen.value = false
mentionStart.value = -1
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'
}
})
}
/** 聚焦输入框 */
function focus(): void {
inputEl.value?.focus()
}
// ── V8 per-conv 草稿:切会话保存/恢复,防 A 会话未发送草稿串到 B 输入框 ──
// 草稿真相源在 aiShared.convDrafts(内存 Map,不落 localStorage)。父组件 AiChat 的
// activeConversationId watcher 注册更早先执行:编辑态切换时父先 cancelEdit 清输入,
// 本 watcher 后执行,旧会话保存到的是清空后的空串,与既有「切会话丢弃编辑缓冲」语义一致。
watch(() => store.state.activeConversationId, (nv, ov) => {
if (ov) setConvDraft(ov, inputText.value)
inputText.value = getConvDraft(nv)
// 随输入框的临时态不跨会话携带:mention 区间偏移只对旧文本有效,照搬到恢复的文本会渲染错位
// chip;技能 chip 与待发图片同理,残留到新会话会静默混入下一条消息
pendingMentionSpans.value = []
mentionOpen.value = false
mentionStart.value = -1
pendingSkill.value = null
pendingImages.value = []
skillOpen.value = false
// 恢复文本会触发输入框自身 watch 重算联想(文本以 @ 结尾时按旧光标位误拉起浮层),
// nextTick 在该 watch 之后统一收口,并按恢复文本重算输入框高度
nextTick(() => {
mentionOpen.value = false
mentionStart.value = -1
autoResize()
})
})
// ── I5 发送失败错误分类文案:类别到 i18n 键的映射(分类逻辑在 aiShared.classifySendError)。
// other 复用既有 toastSendFail 文案,行为与改造前一致 ──
const SEND_ERR_KEY: Record<SendErrorKind, string> = {
rate_limit: 'aiChat.sendErrRateLimit',
quota: 'aiChat.sendErrQuota',
network: 'aiChat.sendErrNetwork',
other: 'aiChat.toastSendFail',
}
/**
* 未发送输入保护。检测 inputText/pendingImages/pendingSkill 任一非空,
* 用于 beforeunload 拦截(分离窗口关闭/刷新)与未来 onBeforeUnmount 拦截(面板切换)。
*/
function hasUnsentInput(): boolean {
return (
inputText.value.trim().length > 0 ||
pendingImages.value.length > 0 ||
!!pendingSkill.value
)
}
defineExpose({
focus,
startEditFocus,
clearInput,
fillDraft,
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: 图片预览样式已提取到 styles/components.css 全局(ImageInput.vue 共享) */
.ai-input-wrap:focus-within {
border-color: color-mix(in srgb, var(--df-accent) 40%, transparent);
}
/* ── 技能 chip / 联想浮层样式已提取到 styles/components.css 全局(SkillMention.vue + MentionPopover.vue 共享)── */
/* ── UX-10 §1.4: @ 实体引用浮层样式已随 MentionPopover.vue 提取 ── */
.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-img-pick-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: var(--df-radius);
background: transparent;
color: var(--df-text-dim);
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
margin-right: 4px;
}
.ai-img-pick-btn:hover {
background: var(--df-bg-raised);
color: var(--df-text);
}
.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: @项目展开摘要样式已随 EnrichmentPanel.vue 提取 ── */
</style>