994 lines
31 KiB
Vue
994 lines
31 KiB
Vue
<script setup lang="ts">
|
||
import { computed, ref, watch } from 'vue'
|
||
import { marked } from 'marked'
|
||
import { styleMarkdown } from '@/utils/mdRenderer'
|
||
import { useAiChat } from '@/composables/useAiChat'
|
||
import type { MentionSpan } from '@/types/relay'
|
||
import type { ProjectRecord, TaskRecord, IdeaRecord, AiToolCallInfo } from '@/types/events'
|
||
|
||
// 组件内联(绕过微信工具 3.15.2 组件解析缓存 bug):独立组件恒报 Component not found,
|
||
// inline 进页面后 chat/index.json usingComponents 空,工具无需解析 components/,问题消失。
|
||
// MdView/MentionInput 源保留备用(工具修复后可拆回)。
|
||
/** md→html 渲染 + 缓存(避免 v-for 每次 render 重算 marked.parse 同步阻塞) */
|
||
const mdCache = new Map<string, string>()
|
||
function renderMd(src: string): string {
|
||
if (!src) return ''
|
||
const cached = mdCache.get(src)
|
||
if (cached !== undefined) return cached
|
||
let html: string
|
||
try {
|
||
html = marked.parse(src, { breaks: true, async: false }) as string
|
||
html = styleMarkdown(html) // P1-G:代码块/链接/表格 inline style(rich-text 不认 class)
|
||
} catch {
|
||
html = src
|
||
}
|
||
mdCache.set(src, html)
|
||
return html
|
||
}
|
||
|
||
const ai = useAiChat()
|
||
const inputText = ref('')
|
||
|
||
// —— 联想弹层状态(技能 / 与 @ 实体复用同一浮层结构,detectTrigger 决定 trigger) ——
|
||
// 弹层可见 + 当前触发类型。trigger='skill' = `/` 开头技能联想;trigger='entity' = 末尾 `@` 实体联想。
|
||
const mentionVisible = ref(false)
|
||
const mentionTrigger = ref<'skill' | 'entity'>('skill')
|
||
|
||
// —— pendingSkill(`/<skill>` 选中后 chip 展示,handleSend 时透传 ai.send skill 参数) ——
|
||
// 选中技能后清 inputText 并置 chip,后续用户输入技能参数;发送时 skill.name 透传后端注入 SKILL.md。
|
||
// 类型与 useAiChat.skills 元素一致(对齐 relay.ts SkillInfo)。
|
||
type SkillInfoLike = { name: string; description: string; argument_hint?: string }
|
||
const pendingSkill = ref<SkillInfoLike | null>(null)
|
||
|
||
// —— pendingMentionSpans(@项目/@任务/@灵感 选中后累积,handleSend 时透传 ai.send spans 参数) ——
|
||
// 每条对应 inputText 中一段 `[类型:名]` 文本区间(start/length 为字符偏移)。
|
||
// 用户可连续 @ 多个实体,发送时整体透传,后端 resolve 投影成 Augmentation 注入。
|
||
const pendingMentionSpans = ref<MentionSpan[]>([])
|
||
|
||
// —— P1-12 自动滚底 + P1-7 生成中关联想 + P1-13 渲染上限 ——
|
||
/** scroll-into-view 锚点(同值不重滚,故 toggle ''→id 触发) */
|
||
const scrollAnchor = ref('')
|
||
function scrollToBottom(): void {
|
||
scrollAnchor.value = ''
|
||
// setTimeout(0) 等新消息 DOM 渲染完(mp-weixin nextTick 兼容)
|
||
setTimeout(() => { scrollAnchor.value = 'list-bottom-anchor' }, 0)
|
||
}
|
||
let scrollTimer: ReturnType<typeof setTimeout> | null = null
|
||
/** 流式增量高频,throttle 防滚底抖动 */
|
||
function throttledScrollToBottom(): void {
|
||
if (scrollTimer) return
|
||
scrollTimer = setTimeout(() => {
|
||
scrollTimer = null
|
||
scrollToBottom()
|
||
}, 120)
|
||
}
|
||
// 新消息 push / 切会话历史回流 → 立即滚底;流式增量 → throttled 滚底
|
||
watch(() => ai.messages.length, () => scrollToBottom())
|
||
watch(() => ai.currentText.value, throttledScrollToBottom)
|
||
// P1-7:生成中关闭联想弹层,防 input disabled 时仍点插入(input disabled 不阻止弹层点击)
|
||
watch(() => ai.generating.value, (g) => { if (g) mentionVisible.value = false })
|
||
/**
|
||
* P1-13 渲染上限兜底:仅渲染最近 N 条,防长会话(100+)rich-text 全量渲染卡顿/内存膨胀。
|
||
* TODO 真虚拟化(滚动入视口才 marked.parse / 窗口化),当前截断旧消息简化兜底。
|
||
*/
|
||
const MAX_RENDER_MSGS = 200
|
||
const visibleMessages = computed(() => ai.messages.slice(-MAX_RENDER_MSGS))
|
||
|
||
/**
|
||
* 输入变化时检测联想触发条件。
|
||
*
|
||
* 两类触发:
|
||
* - 技能(`skill`):inputText 以 `/` 开头,且尚未选 pendingSkill(已选则不再触发,
|
||
* 因 chip 已在输入栏,用户输入的是技能参数)→ listSkills() 拉技能 + 显技能浮层。
|
||
* - 实体(`entity`):末尾连续非空白段恰好为单字符 `@`(避免匹配 url/邮箱)→
|
||
* listEntities() 拉实体 + 显实体浮层。
|
||
*
|
||
* 二者优先级:技能(行首 /)优先于实体(末尾 @),因 `/` 开头整行属技能模式。
|
||
* 已选 pendingSkill 时屏蔽 `/` 重复触发(同上,用户在输入参数)。
|
||
*/
|
||
function detectTrigger(): void {
|
||
const text = inputText.value
|
||
if (!text) {
|
||
mentionVisible.value = false
|
||
return
|
||
}
|
||
// 技能触发:行首 / 且未选技能。空格后(/file xxx)不再是行首 /,自动关闭浮层。
|
||
if (text.startsWith('/') && !pendingSkill.value) {
|
||
mentionTrigger.value = 'skill'
|
||
mentionVisible.value = true
|
||
ai.listSkills()
|
||
return
|
||
}
|
||
// 实体触发:末尾连续非空白段恰好单字符 @
|
||
const match = text.match(/([^\s]+)\s*$/)
|
||
const last = match ? match[1] : ''
|
||
if (last === '@') {
|
||
mentionTrigger.value = 'entity'
|
||
mentionVisible.value = true
|
||
ai.listEntities()
|
||
return
|
||
}
|
||
mentionVisible.value = false
|
||
}
|
||
|
||
/** 监听输入:mp-weixin textarea 的 @input 在 v-model 后触发,可直接检测(P3-A input→textarea) */
|
||
function onInput(): void {
|
||
detectTrigger()
|
||
}
|
||
|
||
/**
|
||
* 选中技能(技能浮层):置 pendingSkill chip + 清 inputText。
|
||
*
|
||
* 清 inputText 因技能模式语义是「选技能后输参数」,选完即重置输入框等用户输参数文本。
|
||
* 技能参数提示(argument_hint,如 "<url>")随 chip 展示,引导用户输入。
|
||
* 不透传 skill 到 inputText(避免 `/技能名` 文本污染参数输入),skill 仅经 handleSend 透传。
|
||
*/
|
||
function onSkillSelect(skill: SkillInfoLike): void {
|
||
pendingSkill.value = skill
|
||
inputText.value = ''
|
||
mentionVisible.value = false
|
||
}
|
||
|
||
/**
|
||
* 选中实体(实体浮层):替换末尾 `@` 为 `[类型:名]` 文本 + 累积 MentionSpan。
|
||
*
|
||
* span.start/length:实体插入文本(inputText 中 `[类型:名]` 段)的字符偏移,
|
||
* 后端据 start/length 在原文中切出区间注入对应实体上下文。
|
||
* kind/refId/label 对齐后端 MentionSpanDto:project/task/idea + id + 展示名。
|
||
* 连续 @ 多个实体时累积(每次 push 一条),发送时整体透传。
|
||
*/
|
||
function onEntitySelect(
|
||
entity: ProjectRecord | TaskRecord | IdeaRecord,
|
||
kind: 'project' | 'task' | 'idea',
|
||
): void {
|
||
const text = inputText.value
|
||
// 去末尾触发字符 `@`(detectTrigger 已保证末段是 @,这里兜底 replace 防御)
|
||
const stripped = text.replace(/@\s*$/, '')
|
||
// 类型标签中文映射(对齐桌面端 chip 展示习惯:项目/任务/灵感)
|
||
const kindLabel = kind === 'project' ? '项目' : kind === 'task' ? '任务' : '灵感'
|
||
// 名:project 用 name,task/idea 用 title
|
||
const name = (entity as ProjectRecord).name || (entity as TaskRecord | IdeaRecord).title
|
||
const insertText = `[${kindLabel}:${name}]`
|
||
const start = stripped.length
|
||
inputText.value = stripped + insertText
|
||
// 累积 mention span(字符偏移,后端透传不解释单位)
|
||
pendingMentionSpans.value.push({
|
||
start,
|
||
length: insertText.length,
|
||
kind,
|
||
refId: entity.id,
|
||
label: name,
|
||
})
|
||
mentionVisible.value = false
|
||
}
|
||
|
||
/** 清除已选技能 chip(handleSend 后或用户点 × 清除) */
|
||
function clearPendingSkill(): void {
|
||
pendingSkill.value = null
|
||
}
|
||
|
||
/** 关闭弹层(点遮罩) */
|
||
function onMentionClose(): void {
|
||
mentionVisible.value = false
|
||
}
|
||
|
||
/**
|
||
* 发送:对齐 useAiChat.send(text, skill?, spans?) 新签名。
|
||
*
|
||
* - skill:已选 pendingSkill 时透传 name(后端注入 SKILL.md);否则 undefined(普通对话)。
|
||
* - spans:有 pendingMentionSpans 时透传(后端 resolve 实体上下文);否则 undefined。
|
||
* - 发送后清 inputText + pendingSkill + pendingMentionSpans,复位待下一次输入。
|
||
*
|
||
* 保留原有守卫:空文本忽略 / generating 中忽略 / 关闭联想浮层。
|
||
*/
|
||
function handleSend(): void {
|
||
console.log('[v2:send] enter text=', JSON.stringify(inputText.value), 'gen=', ai.generating.value)
|
||
const text = inputText.value.trim()
|
||
if (!text) {
|
||
console.log('[v2:send] 空文本,忽略')
|
||
return
|
||
}
|
||
if (ai.generating.value) {
|
||
// 审批挂起/流式生成中后端拒新 send:提示用户(去审批或等待),避免点了没反应困惑
|
||
uni.showToast({
|
||
title: ai.pendingApprovals.length > 0 ? '有待审批项,请先处理' : '生成中,请稍候',
|
||
icon: 'none',
|
||
})
|
||
return
|
||
}
|
||
mentionVisible.value = false
|
||
const skill = pendingSkill.value ? pendingSkill.value.name : undefined
|
||
const spans = pendingMentionSpans.value.length > 0 ? pendingMentionSpans.value : undefined
|
||
ai.send(text, skill, spans)
|
||
inputText.value = ''
|
||
pendingSkill.value = null
|
||
pendingMentionSpans.value = []
|
||
}
|
||
|
||
// —— 工具卡片 + 审批 UI(对齐桌面端 ToolCard,适配 mp-weixin 深色风格) ——
|
||
// 后端推 AiToolCallStarted/AiApprovalRequired/AiDirAuthRequired → useAiChat 收进
|
||
// 消息 m.toolCalls(状态机)+ pendingApprovals。消息内联渲染工具状态 + 待审批按钮,
|
||
// miniapp 成轻量审批终端(单一渲染源,对齐桌面 ToolCard 嵌消息气泡)。
|
||
|
||
// 高危工具白名单(approve 前二次确认,对齐桌面 ToolCard.vue HIGH_RISK_TOOLS)
|
||
const HIGH_RISK_TOOLS = new Set<string>([
|
||
'delete_task',
|
||
'delete_project',
|
||
'restore_project',
|
||
'purge_project',
|
||
'delete_file',
|
||
'run_command',
|
||
'http_request',
|
||
])
|
||
|
||
// 工具状态 → 中文文案 + 颜色 class(渲染状态点/标签)
|
||
const STATUS_META: Record<string, { label: string; cls: string }> = {
|
||
running: { label: '执行中', cls: 'st-running' },
|
||
completed: { label: '已完成', cls: 'st-completed' },
|
||
failed: { label: '失败', cls: 'st-failed' },
|
||
pending_approval: { label: '待审批', cls: 'st-pending' },
|
||
rejected: { label: '已拒绝', cls: 'st-rejected' },
|
||
}
|
||
|
||
/** 截断长字符串(参数值/结果展示防撑爆气泡) */
|
||
function truncate(s: string, max = 120): string {
|
||
return s.length > max ? s.slice(0, max) + '…' : s
|
||
}
|
||
|
||
/** 格式化工具参数为 [{key,val}] 行数组(args 任意结构,最多 4 项防长) */
|
||
function formatArgs(args: unknown): { key: string; val: string }[] {
|
||
if (!args || typeof args !== 'object') return []
|
||
const obj = args as Record<string, unknown>
|
||
return Object.entries(obj)
|
||
.filter(([, v]) => v !== undefined && v !== null && v !== '')
|
||
.slice(0, 4)
|
||
.map(([k, v]) => ({
|
||
key: k,
|
||
val: truncate(typeof v === 'string' ? v : JSON.stringify(v)),
|
||
}))
|
||
}
|
||
|
||
/** 格式化执行结果摘要(completed/failed 展示,result 可能任意结构) */
|
||
function formatResult(result: unknown): string {
|
||
if (result === undefined || result === null) return ''
|
||
if (typeof result === 'string') return truncate(result)
|
||
try {
|
||
return truncate(JSON.stringify(result))
|
||
} catch {
|
||
return ''
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 识别后端审批占位 message(对齐 crates/df-ai/context_helpers.rs:498 PENDING_MARKER_PREFIX)。
|
||
*
|
||
* 后端工具挂起审批时往消息流写占位 assistant message,content 形如
|
||
* 「需要用户审批,等待确认__PENDING__:call_xxx」。桌面端识别占位转工具审批卡片;
|
||
* miniapp 不识别会原样渲染成气泡(暴露 __PENDING__ 技术标识)。此处识别后隐藏文本,
|
||
* 审批 UI 由消息内联 m.toolCalls 工具卡片单独渲染(单一渲染源,不重复)。
|
||
*/
|
||
const PENDING_MARKER = '__PENDING__:'
|
||
const PENDING_PLACEHOLDER_LEGACY = '需要用户审批,等待确认'
|
||
function isPendingPlaceholder(content: string | undefined | null): boolean {
|
||
if (!content) return false
|
||
if (content.includes(PENDING_MARKER)) return true
|
||
return content === PENDING_PLACEHOLDER_LEGACY
|
||
}
|
||
|
||
/**
|
||
* 识别纯 JSON 工具结果 message(content 是工具执行结果 JSON,如 list_tasks 的
|
||
* {"has_more":false,"items":[],...})。弱化为灰小字折叠,不占主气泡,不刺眼。
|
||
*/
|
||
function isToolResultJson(content: string | undefined | null): boolean {
|
||
if (!content) return false
|
||
const t = content.trim()
|
||
if (!(t.startsWith('{') || t.startsWith('['))) return false
|
||
try {
|
||
JSON.parse(t)
|
||
return true
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
/** 审批通过(risk 类):高危工具先 uni.showModal 二次确认 */
|
||
function onApprove(tc: AiToolCallInfo): void {
|
||
if (HIGH_RISK_TOOLS.has(tc.name)) {
|
||
uni.showModal({
|
||
title: '高危操作确认',
|
||
content: `「${tc.name}」属高危操作,确认批准执行?`,
|
||
confirmColor: '#e85a4f',
|
||
success: (r) => {
|
||
if (r.confirm) ai.approve(tc.id, true)
|
||
},
|
||
})
|
||
return
|
||
}
|
||
ai.approve(tc.id, true)
|
||
}
|
||
|
||
/** 审批拒绝(risk 类) */
|
||
function onReject(tc: AiToolCallInfo): void {
|
||
ai.approve(tc.id, false)
|
||
}
|
||
|
||
/** 路径授权(path 类:once=本次 / always=永久 / deny=拒绝) */
|
||
function onAuthorize(tc: AiToolCallInfo, decision: 'once' | 'always' | 'deny'): void {
|
||
ai.authorizeDir(tc.id, decision)
|
||
}
|
||
|
||
/** 连接状态中文文案(F21:裸枚举字符串 disconnected/connecting 不可读) */
|
||
const STATUS_TEXT: Record<string, string> = {
|
||
connecting: '连接中…',
|
||
handshaking: '握手中…',
|
||
reconnecting: '重连中…·点此立即重连',
|
||
disconnected: '已断开·点此重连',
|
||
}
|
||
const statusText = computed(() => {
|
||
if (ai.isWsConnected.value) {
|
||
return ai.deviceOnline.value ? '已连接桌面端' : '已连接中继'
|
||
}
|
||
return STATUS_TEXT[ai.wsStatus.value] || ai.wsStatus.value
|
||
})
|
||
const canManualReconnect = computed(
|
||
() => ai.wsStatus.value === 'disconnected' || ai.wsStatus.value === 'reconnecting',
|
||
)
|
||
function onStatusTap(): void {
|
||
if (canManualReconnect.value) ai.resumeIfDisconnected()
|
||
}
|
||
|
||
/** 停止生成(wires F9 终态兜底;生成中 send 禁用无停止入口,用户无法中断) */
|
||
function onStop(): void {
|
||
ai.stop()
|
||
}
|
||
|
||
/** 继续循环(P1-E:MaxRounds 挂起后再跑一轮) */
|
||
function onContinueLoop(): void {
|
||
ai.continueLoop()
|
||
}
|
||
|
||
/** 停止循环(P1-E:MaxRounds 挂起后彻底终止多轮 agent) */
|
||
function onStopLoop(): void {
|
||
ai.stopLoop()
|
||
}
|
||
|
||
/** 重发最后一条 user 消息(P1-F:regenerate 入口) */
|
||
function onRegenerate(): void {
|
||
ai.regenerate()
|
||
}
|
||
|
||
/** 长按消息复制全文(P1-F:assistant 复制 markdown 源,user 复制原文) */
|
||
function onCopyMessage(m: { content?: string }): void {
|
||
if (!m.content) return
|
||
uni.setClipboardData({
|
||
data: m.content,
|
||
success: () => uni.showToast({ title: '已复制', icon: 'none' }),
|
||
})
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<view class="page">
|
||
<!-- 状态栏 + 待审批徽标(F21:断开/重连中可点状态栏手动重连) -->
|
||
<view class="top">
|
||
<text class="st" :class="{ 'st-tap': canManualReconnect }" @tap="onStatusTap">{{ statusText }}</text>
|
||
<view class="top-right">
|
||
<text v-if="ai.tokenUsage.value" class="token-usage">🪙 {{ ai.tokenUsage.value.total }}</text>
|
||
<view v-if="ai.pendingApprovals.length > 0" class="pending-badge">
|
||
<text>⏳ {{ ai.pendingApprovals.length }} 待审批</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 达最大轮次面板(P1-E:继续/停止循环,对齐桌面 MaxRounds) -->
|
||
<view v-if="ai.maxRoundsActive.value" class="maxrounds-panel">
|
||
<text class="maxrounds-text">⚠ 已达最大轮次</text>
|
||
<view class="maxrounds-actions">
|
||
<button class="approval-btn approve" size="mini" @tap="onContinueLoop">继续一轮</button>
|
||
<button class="approval-btn reject" size="mini" @tap="onStopLoop">停止</button>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 待审批面板(从 pendingApprovals 渲染,与 messages 解耦:req3 重连 load_messages 替换 messages 不影响审批卡) -->
|
||
<view v-if="ai.pendingApprovals.length > 0" class="pending-panel">
|
||
<view class="pending-panel-head">
|
||
<text>⏳ {{ ai.pendingApprovals.length }} 项待审批</text>
|
||
</view>
|
||
<view v-for="p in ai.pendingApprovals" :key="p.id" class="tool-card">
|
||
<view class="tool-head">
|
||
<text class="tool-name">🔧 {{ p.name }}</text>
|
||
<text class="tool-status st-pending">待审批</text>
|
||
</view>
|
||
<view v-if="formatArgs(p.args).length > 0" class="tool-args">
|
||
<view v-for="a in formatArgs(p.args)" :key="a.key" class="tool-arg">
|
||
<text class="tool-arg-key">{{ a.key }}:</text>
|
||
<text class="tool-arg-val">{{ a.val }}</text>
|
||
</view>
|
||
</view>
|
||
<view v-if="p.kind === 'path' && p.dir" class="tool-dir">
|
||
<text>📁 {{ p.dir }}</text>
|
||
</view>
|
||
<view v-if="p.reason" class="tool-reason">
|
||
<text>⚠ {{ p.reason }}</text>
|
||
</view>
|
||
<!-- risk 类:批准/拒绝 -->
|
||
<view v-if="p.kind !== 'path'" class="approval-actions">
|
||
<button class="approval-btn approve" size="mini" @tap="onApprove(p)">批准</button>
|
||
<button class="approval-btn reject" size="mini" @tap="onReject(p)">拒绝</button>
|
||
</view>
|
||
<!-- path 类:本次/永久/拒绝 -->
|
||
<view v-else class="approval-actions">
|
||
<button class="approval-btn approve" size="mini" @tap="onAuthorize(p, 'once')">本次</button>
|
||
<button class="approval-btn always" size="mini" @tap="onAuthorize(p, 'always')">永久</button>
|
||
<button class="approval-btn reject" size="mini" @tap="onAuthorize(p, 'deny')">拒绝</button>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 消息列表(scroll-view 标准滚动,纯 text 渲染) -->
|
||
<scroll-view class="list" scroll-y :scroll-into-view="scrollAnchor" :scroll-with-animation="true">
|
||
<view v-if="ai.messages.length === 0" class="empty">
|
||
<text>暂无消息</text>
|
||
</view>
|
||
<view v-for="m in visibleMessages" :key="m.id" class="msg" :class="m.role" @longpress="onCopyMessage(m)">
|
||
<text v-if="m.role === 'user'" user-select>{{ m.content }}</text>
|
||
<template v-else>
|
||
<!-- 后端审批占位 message(「需要…__PENDING__:call_xxx」)隐藏文本,审批走工具卡片 -->
|
||
<rich-text
|
||
v-if="m.content && !isPendingPlaceholder(m.content) && !isToolResultJson(m.content)"
|
||
:nodes="renderMd(m.content)"
|
||
selectable
|
||
/>
|
||
<!-- 纯 JSON 工具结果弱化(灰小字折叠,不占主气泡) -->
|
||
<view v-else-if="m.content && isToolResultJson(m.content)" class="msg-toolresult">
|
||
<text>📋 {{ m.content }}</text>
|
||
</view>
|
||
<!-- 占位 message(isPendingPlaceholder)不渲染文本,仅下方工具卡片承载审批 UI -->
|
||
<!-- 工具调用卡片(对齐桌面 ToolCard,assistant 消息内联渲染状态 + 审批按钮) -->
|
||
<view v-for="tc in m.toolCalls || []" :key="tc.id" class="tool-card">
|
||
<view class="tool-head">
|
||
<text class="tool-name">🔧 {{ tc.name }}</text>
|
||
<text class="tool-status" :class="(STATUS_META[tc.status] || {}).cls">
|
||
{{ (STATUS_META[tc.status] || {}).label || tc.status }}
|
||
</text>
|
||
</view>
|
||
<!-- 参数 -->
|
||
<view v-if="formatArgs(tc.args).length > 0" class="tool-args">
|
||
<view v-for="a in formatArgs(tc.args)" :key="a.key" class="tool-arg">
|
||
<text class="tool-arg-key">{{ a.key }}:</text>
|
||
<text class="tool-arg-val">{{ a.val }}</text>
|
||
</view>
|
||
</view>
|
||
<!-- path 授权待授权目录 -->
|
||
<view v-if="tc.kind === 'path' && tc.status === 'pending_approval' && tc.dir" class="tool-dir">
|
||
<text>📁 {{ tc.dir }}</text>
|
||
</view>
|
||
<!-- 风险说明 -->
|
||
<view v-if="tc.reason" class="tool-reason">
|
||
<text>⚠ {{ tc.reason }}</text>
|
||
</view>
|
||
<!-- 审批按钮已移至顶部 pendingApprovals 面板(与 messages 解耦,req3 重连恢复) -->
|
||
<!-- 执行结果摘要(completed/failed) -->
|
||
<view
|
||
v-if="(tc.status === 'completed' || tc.status === 'failed') && formatResult(tc.result)"
|
||
class="tool-result"
|
||
>
|
||
<text>{{ formatResult(tc.result) }}</text>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
</view>
|
||
<!-- 流式生成中的当前文本 -->
|
||
<view v-if="ai.generating.value && ai.currentText.value" class="msg assistant">
|
||
<text user-select>{{ ai.currentText.value }}</text>
|
||
</view>
|
||
<!-- P1-12 滚底锚点(scroll-into-view 目标) -->
|
||
<view id="list-bottom-anchor"></view>
|
||
</scroll-view>
|
||
|
||
<!-- 联想弹层(技能 / 与 @ 实体复用同一浮层结构,detectTrigger 决定 trigger) -->
|
||
<view v-if="mentionVisible && !ai.generating.value" class="mention-mask" @tap="onMentionClose">
|
||
<view class="mention-pop" @tap.stop>
|
||
<view class="mention-head">
|
||
<text class="mention-title">{{ mentionTrigger === 'skill' ? '技能' : '提及' }}</text>
|
||
<text class="mention-hint">点击选中</text>
|
||
</view>
|
||
<scroll-view class="mention-list" scroll-y>
|
||
<!-- 技能列表(`/` 触发,name/description/argument_hint 展示) -->
|
||
<template v-if="mentionTrigger === 'skill'">
|
||
<view v-if="ai.skills.length === 0" class="mention-empty">
|
||
<text>加载中或无技能...</text>
|
||
</view>
|
||
<view
|
||
v-for="(s, idx) in ai.skills"
|
||
:key="'sk-' + idx"
|
||
class="mention-item"
|
||
@tap="onSkillSelect(s)"
|
||
>
|
||
<view class="mention-item-main">
|
||
<text class="mention-item-label">/{{ s.name }}</text>
|
||
<text class="mention-item-source">{{ s.source }}</text>
|
||
</view>
|
||
<text class="mention-item-desc">{{ s.description }}</text>
|
||
<text v-if="s.argument_hint" class="mention-item-insert">参数:{{ s.argument_hint }}</text>
|
||
</view>
|
||
</template>
|
||
<!-- 实体列表(`@` 触发,按 项目/任务/灵感 分组) -->
|
||
<template v-else>
|
||
<view v-if="ai.entities.projects.length === 0 && ai.entities.tasks.length === 0 && ai.entities.ideas.length === 0" class="mention-empty">
|
||
<text>加载中或无数据...</text>
|
||
</view>
|
||
<view v-if="ai.entities.projects.length > 0" class="mention-group-title">
|
||
<text>项目</text>
|
||
</view>
|
||
<view
|
||
v-for="(p, idx) in ai.entities.projects"
|
||
:key="'p-' + idx"
|
||
class="mention-item"
|
||
@tap="onEntitySelect(p, 'project')"
|
||
>
|
||
<text class="mention-item-label">{{ p.name }}</text>
|
||
<text class="mention-item-desc">{{ p.description }}</text>
|
||
</view>
|
||
<view v-if="ai.entities.tasks.length > 0" class="mention-group-title">
|
||
<text>任务</text>
|
||
</view>
|
||
<view
|
||
v-for="(t, idx) in ai.entities.tasks"
|
||
:key="'t-' + idx"
|
||
class="mention-item"
|
||
@tap="onEntitySelect(t, 'task')"
|
||
>
|
||
<text class="mention-item-label">{{ t.title }}</text>
|
||
<text class="mention-item-desc">{{ t.description }}</text>
|
||
</view>
|
||
<view v-if="ai.entities.ideas.length > 0" class="mention-group-title">
|
||
<text>灵感</text>
|
||
</view>
|
||
<view
|
||
v-for="(i, idx) in ai.entities.ideas"
|
||
:key="'i-' + idx"
|
||
class="mention-item"
|
||
@tap="onEntitySelect(i, 'idea')"
|
||
>
|
||
<text class="mention-item-label">{{ i.title }}</text>
|
||
<text class="mention-item-desc">{{ i.description }}</text>
|
||
</view>
|
||
</template>
|
||
</scroll-view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 输入 + 发送(原生 button @tap) -->
|
||
<view class="bar">
|
||
<!-- pendingSkill chip(选中技能后展示名+参数提示,× 清除) -->
|
||
<view v-if="pendingSkill" class="skill-chip">
|
||
<text class="skill-chip-name">/{{ pendingSkill.name }}</text>
|
||
<text v-if="pendingSkill.argument_hint" class="skill-chip-hint">{{ pendingSkill.argument_hint }}</text>
|
||
<text class="skill-chip-close" @tap="clearPendingSkill">×</text>
|
||
</view>
|
||
<textarea
|
||
v-model="inputText"
|
||
class="ipt"
|
||
:placeholder="ai.generating.value ? '生成中,完成后发送…' : pendingSkill ? '输入技能参数' : '输入消息(输入 / 或 @ 触发联想)'"
|
||
:auto-height="true"
|
||
:adjust-position="true"
|
||
confirm-type="send"
|
||
@confirm="handleSend"
|
||
@input="onInput"
|
||
/>
|
||
<!-- 重发最后一条 user(P1-F,非生成中且有消息时显) -->
|
||
<button
|
||
v-if="!ai.generating.value && ai.messages.length > 0"
|
||
class="regen-btn"
|
||
@tap="onRegenerate"
|
||
>
|
||
重发
|
||
</button>
|
||
<!-- 生成中显停止按钮(wires F9),否则发送 -->
|
||
<button v-if="ai.generating.value" class="send-btn stop-btn" @tap="onStop">停止</button>
|
||
<button v-else class="send-btn" @tap="handleSend">发送</button>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.page {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100vh;
|
||
background-color: #0f0f0f;
|
||
}
|
||
.top {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 8px 12px;
|
||
background-color: #1a1a1a;
|
||
}
|
||
.st {
|
||
color: #999999;
|
||
font-size: 12px;
|
||
}
|
||
.st-tap {
|
||
color: #4a9eff;
|
||
}
|
||
.top-right {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
}
|
||
.token-usage {
|
||
color: #777777;
|
||
font-size: 11px;
|
||
margin-right: 8px;
|
||
}
|
||
.list {
|
||
flex: 1;
|
||
min-height: 0;
|
||
padding: 12px;
|
||
}
|
||
.empty {
|
||
text-align: center;
|
||
padding: 80px 0;
|
||
}
|
||
.empty text {
|
||
color: #666666;
|
||
font-size: 13px;
|
||
}
|
||
.msg {
|
||
margin-bottom: 12px;
|
||
padding: 10px 14px;
|
||
border-radius: 10px;
|
||
background-color: #2a2a2a;
|
||
}
|
||
.msg text {
|
||
color: #e0e0e0;
|
||
font-size: 14px;
|
||
line-height: 1.5;
|
||
}
|
||
.msg.user {
|
||
background-color: #4a9eff;
|
||
}
|
||
.msg.user text {
|
||
color: #ffffff;
|
||
}
|
||
.msg.system text {
|
||
color: #999999;
|
||
font-size: 12px;
|
||
}
|
||
.bar {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: flex-end;
|
||
padding: 8px 12px;
|
||
background-color: #1a1a1a;
|
||
}
|
||
.ipt {
|
||
flex: 1;
|
||
background-color: #2a2a2a;
|
||
color: #e0e0e0;
|
||
padding: 8px 12px;
|
||
min-height: 36px;
|
||
max-height: 120px;
|
||
box-sizing: border-box;
|
||
overflow-y: auto;
|
||
border-radius: 6px;
|
||
font-size: 14px;
|
||
line-height: 20px;
|
||
}
|
||
.send-btn {
|
||
margin-left: 8px;
|
||
background-color: #4a9eff;
|
||
color: #ffffff;
|
||
font-size: 14px;
|
||
}
|
||
.send-btn.stop-btn {
|
||
background-color: #5a2a2a;
|
||
color: #e85a4f;
|
||
}
|
||
.regen-btn {
|
||
margin-left: 8px;
|
||
background-color: #2a2a2a;
|
||
color: #c0c0c0;
|
||
font-size: 13px;
|
||
}
|
||
/* @/ 联想弹层(内联自 MentionInput,绕过工具组件解析 bug) */
|
||
.mention-mask {
|
||
position: fixed;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
top: 0;
|
||
background-color: rgba(0, 0, 0, 0.3);
|
||
z-index: 999;
|
||
}
|
||
.mention-pop {
|
||
position: absolute;
|
||
left: 12px;
|
||
right: 12px;
|
||
bottom: 56px;
|
||
max-height: 260px;
|
||
background-color: #1f1f1f;
|
||
border: 1px solid #333333;
|
||
border-radius: 10px;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.mention-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 8px 12px;
|
||
background-color: #262626;
|
||
border-bottom: 1px solid #2e2e2e;
|
||
}
|
||
.mention-title {
|
||
color: #e0e0e0;
|
||
font-size: 13px;
|
||
font-weight: bold;
|
||
}
|
||
.mention-hint {
|
||
color: #777777;
|
||
font-size: 11px;
|
||
}
|
||
.mention-list {
|
||
max-height: 220px;
|
||
}
|
||
.mention-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 10px 12px;
|
||
border-bottom: 1px solid #2a2a2a;
|
||
}
|
||
.mention-item-main {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
.mention-item-label {
|
||
color: #e0e0e0;
|
||
font-size: 14px;
|
||
}
|
||
.mention-item-source {
|
||
color: #777777;
|
||
font-size: 10px;
|
||
margin-left: 8px;
|
||
}
|
||
.mention-item-desc {
|
||
color: #999999;
|
||
font-size: 12px;
|
||
margin-top: 2px;
|
||
}
|
||
.mention-item-insert {
|
||
color: #4a9eff;
|
||
font-size: 12px;
|
||
margin-top: 2px;
|
||
}
|
||
.mention-empty {
|
||
padding: 24px 12px;
|
||
text-align: center;
|
||
}
|
||
.mention-empty text {
|
||
color: #666666;
|
||
font-size: 12px;
|
||
}
|
||
.mention-group-title {
|
||
padding: 6px 12px;
|
||
background-color: #232323;
|
||
border-bottom: 1px solid #2a2a2a;
|
||
}
|
||
.mention-group-title text {
|
||
color: #888888;
|
||
font-size: 11px;
|
||
font-weight: bold;
|
||
}
|
||
/* pendingSkill chip(选中技能后展示名+参数提示+× 清除) */
|
||
.skill-chip {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
padding: 0 8px;
|
||
height: 28px;
|
||
margin-right: 6px;
|
||
background-color: #1f3a5f;
|
||
border: 1px solid #4a9eff;
|
||
border-radius: 14px;
|
||
}
|
||
.skill-chip-name {
|
||
color: #4a9eff;
|
||
font-size: 12px;
|
||
}
|
||
.skill-chip-hint {
|
||
color: #7799bb;
|
||
font-size: 10px;
|
||
margin-left: 6px;
|
||
}
|
||
.skill-chip-close {
|
||
color: #4a9eff;
|
||
font-size: 16px;
|
||
margin-left: 6px;
|
||
padding: 0 2px;
|
||
}
|
||
/* 工具卡片 + 审批 UI(对齐桌面 ToolCard,深色风格) */
|
||
.pending-badge {
|
||
flex-shrink: 0;
|
||
margin: 0 8px;
|
||
padding: 2px 8px;
|
||
background-color: #3a2f1f;
|
||
border: 1px solid #f0c75e;
|
||
border-radius: 10px;
|
||
}
|
||
.pending-badge text {
|
||
color: #f0c75e;
|
||
font-size: 11px;
|
||
}
|
||
/* 待审批面板(从 pendingApprovals 渲染,与 messages 解耦避重连竞态) */
|
||
.pending-panel {
|
||
flex-shrink: 0;
|
||
max-height: 40vh;
|
||
overflow-y: auto;
|
||
padding: 4px 12px 8px;
|
||
background-color: #1a1a1a;
|
||
border-bottom: 1px solid #2a2a2a;
|
||
}
|
||
.pending-panel-head {
|
||
padding: 4px 0;
|
||
}
|
||
.pending-panel-head text {
|
||
color: #f0c75e;
|
||
font-size: 12px;
|
||
font-weight: bold;
|
||
}
|
||
/* 达最大轮次面板(P1-E:继续/停止循环) */
|
||
.maxrounds-panel {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 8px 12px;
|
||
background-color: #2a2218;
|
||
border-bottom: 1px solid #3a2f1f;
|
||
}
|
||
.maxrounds-text {
|
||
color: #f0c75e;
|
||
font-size: 13px;
|
||
}
|
||
.maxrounds-actions {
|
||
display: flex;
|
||
flex-direction: row;
|
||
}
|
||
.tool-card {
|
||
margin-top: 8px;
|
||
padding: 8px 10px;
|
||
background-color: #1f1f1f;
|
||
border: 1px solid #333333;
|
||
border-radius: 8px;
|
||
}
|
||
.tool-head {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
.tool-name {
|
||
color: #e0e0e0;
|
||
font-size: 13px;
|
||
font-weight: bold;
|
||
}
|
||
.tool-status {
|
||
padding: 1px 6px;
|
||
border-radius: 8px;
|
||
font-size: 10px;
|
||
}
|
||
.tool-status.st-running {
|
||
background-color: #2a3a5a;
|
||
color: #6bb6ff;
|
||
}
|
||
.tool-status.st-completed {
|
||
background-color: #1f3a2a;
|
||
color: #6bd99a;
|
||
}
|
||
.tool-status.st-failed {
|
||
background-color: #3a1f1f;
|
||
color: #e85a4f;
|
||
}
|
||
.tool-status.st-pending {
|
||
background-color: #3a2f1f;
|
||
color: #f0c75e;
|
||
}
|
||
.tool-status.st-rejected {
|
||
background-color: #2a2a2a;
|
||
color: #999999;
|
||
}
|
||
.tool-args {
|
||
margin-top: 6px;
|
||
}
|
||
.tool-arg {
|
||
display: flex;
|
||
flex-direction: row;
|
||
margin-top: 2px;
|
||
}
|
||
.tool-arg-key {
|
||
color: #7799bb;
|
||
font-size: 12px;
|
||
margin-right: 4px;
|
||
}
|
||
.tool-arg-val {
|
||
color: #c0c0c0;
|
||
font-size: 12px;
|
||
flex: 1;
|
||
}
|
||
.tool-dir {
|
||
margin-top: 4px;
|
||
}
|
||
.tool-dir text {
|
||
color: #6bd99a;
|
||
font-size: 12px;
|
||
}
|
||
.tool-reason {
|
||
margin-top: 6px;
|
||
padding: 4px 6px;
|
||
background-color: #2a2218;
|
||
border-radius: 4px;
|
||
}
|
||
.tool-reason text {
|
||
color: #f0c75e;
|
||
font-size: 12px;
|
||
}
|
||
.approval-actions {
|
||
display: flex;
|
||
flex-direction: row;
|
||
margin-top: 8px;
|
||
}
|
||
.approval-btn {
|
||
margin-right: 6px;
|
||
padding: 0 12px;
|
||
height: 30px;
|
||
line-height: 30px;
|
||
font-size: 12px;
|
||
border-radius: 6px;
|
||
}
|
||
.approval-btn.approve {
|
||
background-color: #2a5a3a;
|
||
color: #6bd99a;
|
||
}
|
||
.approval-btn.always {
|
||
background-color: #2a3a5a;
|
||
color: #6bb6ff;
|
||
}
|
||
.approval-btn.reject {
|
||
background-color: #5a2a2a;
|
||
color: #e85a4f;
|
||
margin-right: 0;
|
||
}
|
||
.tool-result {
|
||
margin-top: 6px;
|
||
padding: 4px 6px;
|
||
background-color: #181818;
|
||
border-radius: 4px;
|
||
}
|
||
.tool-result text {
|
||
color: #999999;
|
||
font-size: 11px;
|
||
}
|
||
/* 纯 JSON 工具结果 message 弱化(灰小字折叠,避免刺眼占用主气泡) */
|
||
.msg-toolresult {
|
||
margin-top: 4px;
|
||
padding: 6px 8px;
|
||
background-color: #181818;
|
||
border-radius: 6px;
|
||
max-height: 80px;
|
||
overflow: hidden;
|
||
}
|
||
.msg-toolresult text {
|
||
color: #777777;
|
||
font-size: 11px;
|
||
}
|
||
</style>
|