Files
DevFlow/apps/df-miniapp/src/pages/chat/index.vue
T

994 lines
31 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.
<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>