- temperature 按模型适配,仅收 1 的模型不再 400 - 流式 error 帧不再被 choices 必填整帧丢弃 - 只读缓存写后失效 - git diff 中文截断撞 UTF-8 边界 panic - 非视觉模型纯图片直接拒绝 - 全局快捷键补输入焦点守卫
1216 lines
50 KiB
Vue
1216 lines
50 KiB
Vue
<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" />
|
||
<!-- B1: 当前模型不看图时贴图警示(发送仍允许,后端温和降级丢图;chip 持续可见防误判网络问题) -->
|
||
<div v-if="showImageIgnoreWarn" class="ai-img-warn-chip">{{ t('aiChat.imageIgnoredWarn') }}</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"
|
||
@beforeinput="onBeforeInputText"
|
||
@input="onInputText"
|
||
@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'
|
||
})
|
||
|
||
// ── B1: 非视觉模型贴图前置感知 ──
|
||
// 有效模型判定镜像 TopBar 口径(active provider → enabled 池 → override 命中优先,否则权重最高)。
|
||
// 池未知(未拉取/空)时保守视为支持(不误伤;后端 model_supports_vision 有 model_probe 兜底,
|
||
// 发图照旧温和降级)。发送仍允许(与现状兼容),仅以警示 chip 持续可见防「静默丢图」误判网络问题。
|
||
const currentModelSupportsVision = computed<boolean>(() => {
|
||
const p = store.state.providers.find(x => x.id === store.state.activeProvider)
|
||
|| store.state.providers.find(x => x.is_default)
|
||
|| store.state.providers[0]
|
||
if (!p?.model_configs?.length) return true
|
||
const models = p.model_configs.filter(m => m.enabled)
|
||
const chosen = (store.modelOverride.value ? models.find(m => m.model_id === store.modelOverride.value) : undefined) || models[0]
|
||
if (!chosen) return true
|
||
return (chosen.modalities ?? []).includes('vision')
|
||
})
|
||
const showImageIgnoreWarn = computed(() => pendingImages.value.length > 0 && !currentModelSupportsVision.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) => {
|
||
// 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[]>([])
|
||
|
||
// ── UX-P3: mention span 编辑平移 ──
|
||
// span.start 是 selectMention 时刻的绝对偏移,用户在 chip 文本之前增删字符后全部 span 错位
|
||
// (MessageList 渲染守卫对不上文本即降级纯文本,chip 全灭)。借 beforeinput(仅用户编辑触发,
|
||
// 程序赋值不派发事件)快照旧文本,input 时做公共前后缀差分求出编辑区间,按区间相对各 span
|
||
// 的位置处理:编辑点在 span 前→整体平移;在 span 后→不动;落在 span 文本内→丢弃该 span
|
||
// (文本已被改写区间失真,宁丢一枚 chip 也不让后端 resolve 到错位区间)。
|
||
let mentionTextBeforeEdit = ''
|
||
function onBeforeInputText(): void {
|
||
mentionTextBeforeEdit = inputEl.value?.value ?? inputText.value
|
||
}
|
||
function onInputText(): void {
|
||
autoResize()
|
||
shiftMentionSpans(mentionTextBeforeEdit, inputEl.value?.value ?? inputText.value)
|
||
}
|
||
function shiftMentionSpans(oldText: string, newText: string): void {
|
||
if (pendingMentionSpans.value.length === 0 || oldText === newText) return
|
||
// 公共前后缀夹出旧文本中被编辑的区间(粘贴/剪切的多字符增删同为一段区间,该差分通用)
|
||
const minLen = Math.min(oldText.length, newText.length)
|
||
let p = 0
|
||
while (p < minLen && oldText[p] === newText[p]) p++
|
||
let s = 0
|
||
while (s < minLen - p && oldText[oldText.length - 1 - s] === newText[newText.length - 1 - s]) s++
|
||
const editStart = p
|
||
const editEnd = oldText.length - s
|
||
const delta = newText.length - oldText.length
|
||
pendingMentionSpans.value = pendingMentionSpans.value.flatMap((sp) => {
|
||
if (editEnd <= sp.start) return [{ ...sp, start: sp.start + delta }]
|
||
if (editStart >= sp.start + sp.length) return [sp]
|
||
return []
|
||
})
|
||
}
|
||
|
||
// ── ⑥.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
|
||
// UX-P3: AI 输入框聚焦时 Ctrl/Cmd+R 会触发 WebView 刷新(浏览器直开/开发态整页刷新丢会话态),
|
||
// 此处 preventDefault 掐掉刷新默认行为。只拦输入框聚焦这一场景(本 handler 仅 textarea 触发,
|
||
// 全局其他区域不拦);不 stopPropagation,冒泡到 AiChat onGlobalKeydown 的 Ctrl+R 分支,
|
||
// 重新生成语义照常生效。
|
||
if ((e.ctrlKey || e.metaKey) && (e.key === 'r' || e.key === 'R')) {
|
||
e.preventDefault()
|
||
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
|
||
}
|
||
}
|
||
// UX-P3: 技能联想浮层开着但无匹配时,Enter 原会落到下方发送分支把 "/xxx" 原文当普通
|
||
// 消息发出。改为不发送 + toast 提示无此技能;确要发送斜杠原文可先 Esc 关浮层再 Enter。
|
||
if (skillOpen.value && e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
emit('error', { msg: t('aiChat.skillNoMatchQuery', { q: skillQuery.value.trim() }), type: 'warning' })
|
||
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) {
|
||
// UX-P3 空编辑静默:清空文本后 Enter 原来静默 return,用户无反馈卡在编辑态。
|
||
// 仍阻止空提交(editMessage 空文本语义不明),但补轻提示告知原因。
|
||
if (!text) {
|
||
emit('error', { msg: t('aiChat.editEmptyBlocked'), type: 'warning' })
|
||
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
|
||
// B2: 纯发图(无文本无技能)+ 非视觉模型 → 后端会拒绝(占位串不喂模型不浪费一轮),
|
||
// 前端先行拦截给 i18n 提示省一轮 IPC;图文混合(有文本)不受限,维持占位后缀现状
|
||
if (!text && !skill && pendingImages.value.length > 0 && !currentModelSupportsVision.value) {
|
||
emit('error', { msg: t('aiChat.imageOnlyBlocked'), type: 'warning' })
|
||
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 提取 ── */
|
||
|
||
/* B1: 非视觉模型贴图警示条(待发送图片预览行下方,warning 色轻量提示,不抢输入框注意力) */
|
||
.ai-img-warn-chip {
|
||
font-size: 10.5px;
|
||
color: var(--df-warning);
|
||
padding: 1px 4px;
|
||
}
|
||
</style>
|