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

1555 lines
53 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 { uploadImage } from '@/utils/fileUpload'
import { useAiChat } from '@/composables/useAiChat'
import type { MentionSpan, MiniContentPart } from '@/types/relay'
import type { ProjectRecord, TaskRecord, IdeaRecord, AiToolCallInfo, ChatMessage, TokenUsage } from '@/types/events'
// 组件内联(绕过微信工具 3.15.2 组件解析缓存 bug):独立组件恒报 Component not found,
// inline 进页面后 chat/index.json usingComponents 空,工具无需解析 components/,问题消失。
// MdView/MentionInput 源保留(src/components/ 下,工具 bug 修复后可拆回)。
/**
* md→html 渲染 + 缓存(避免 v-for 每次 render 重算 marked.parse 同步阻塞)。
* mdCache 上限 300(P0-2 走查):tabBar 页常驻,无上限会随长会话/切会话 HTML 累积
* (旧会话 trimmed 后 HTML 仍留内存),加 FIFO 淘汰防内存泄漏。
*/
const mdCache = new Map<string, string>()
const MD_CACHE_LIMIT = 300
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
}
if (mdCache.size >= MD_CACHE_LIMIT) {
mdCache.delete(mdCache.keys().next().value!)
}
mdCache.set(src, html)
return html
}
// ────────────────────────────────────────────────────────────────
// 流式 Markdown 块级 memo 渲染(对齐桌面 useStreamRenderer.ts)。
//
// 核心设计(单缓存,存"最终 styled html"):
// - splitStreamBlocks:marked.lexer 围栏感知切块(code token 整段,非 code 按 \n\n 切段)
// - 单一缓存 Map<blockText, styledHtml>:已完成块命中即返回(parse+style 都缓存了),
// 末块每次重算 → 每 delta 成本 O(末块),而非 O(全文)
// - 未闭合代码围栏末块降级转义(流式写入中不渲染半截代码)
//
// 与桌面端差异:rich-text 只能整体替换 nodes(非 v-html 分块),故"分块 DOM 稳定"价值
// 不存在,这里缓存的是**渲染结果**而非中间态,减少重复 parse+style 即可。
// ────────────────────────────────────────────────────────────────
/** 单一缓存:块文本 → 最终 styled html。已完成块命中,末块重算。上限 300,超限删最旧 */
const streamBlockCache = new Map<string, string>()
const STREAM_BLOCK_CACHE_LIMIT = 300
/** HTML 转义 + 换行转 <br>(miniapp 无现成实现;未闭合围栏降级用) */
function escapeFallback(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/\n/g, '<br>')
}
/** 切块:marked.lexer() 围栏感知(code token 整段一块,非 code 按 \n\n 切段) */
function splitStreamBlocks(text: string): string[] {
if (!text) return []
const blocks: string[] = []
const tokens = marked.lexer(text)
for (const tok of tokens) {
if (!tok || typeof (tok as { raw?: string }).raw !== 'string') continue
const raw = (tok as { raw: string }).raw
if ((tok as { type: string }).type === 'code') {
if (raw.trim()) blocks.push(raw)
continue
}
for (const b of raw.split(/\n{2,}/)) if (b.trim()) blocks.push(b)
}
return blocks.length ? blocks : [text]
}
/** 末块未闭合代码围栏检测:行首 ≤3 空格 + 3+ 反引号/波浪计数,奇数=未闭合 */
function isUnclosedCodeFence(block: string): boolean {
let fenceCount = 0
for (const line of block.split('\n')) {
if (/^[ ]{0,3}(`{3,}|~{3,})/.exec(line)) fenceCount++
}
return fenceCount % 2 === 1
}
/** 单块 → styled html(parse + styleMarkdown),未闭合围栏降级转义 */
function parseStreamBlock(block: string): string {
if (isUnclosedCodeFence(block)) return escapeFallback(block)
try {
return styleMarkdown(marked.parse(block, { breaks: true, async: false }) as string)
} catch {
return escapeFallback(block)
}
}
/**
* 流式渲染入口:切块 → 单缓存命中/写入 → 拼接。
* 已完成块命中缓存(parse+style 都缓存了),末块重算,每 delta O(末块)。
*/
function renderStreamingMd(src: string): string {
if (!src) return ''
const blocks = splitStreamBlocks(src)
const n = blocks.length
const parts: string[] = []
for (let i = 0; i < n; i++) {
const b = blocks[i]
if (i === n - 1) {
// 末块:重算(内容在变),不缓存
parts.push(parseStreamBlock(b))
continue
}
// 已完成块:缓存命中直接取,未命中算并写
const cached = streamBlockCache.get(b)
if (cached !== undefined) {
parts.push(cached)
} else {
const styled = parseStreamBlock(b)
if (streamBlockCache.size >= STREAM_BLOCK_CACHE_LIMIT) {
streamBlockCache.delete(streamBlockCache.keys().next().value!)
}
streamBlockCache.set(b, styled)
parts.push(styled)
}
}
return parts.join('')
}
const ai = useAiChat()
const inputText = ref('')
// Ai.messages 是 ref,用计数器 key 强制模板刷新(mp-weixin 数组 computed 追踪不可靠)
const messagesKey = ref(0)
const messages = computed(() => {
messagesKey.value // 依赖计数器
return ai.messages.value
})
// 每次 messages 长度变化,计数器递增
watch(() => ai.messages.value.length, () => { messagesKey.value++ }, { immediate: true })
// —— 计算属性:解包 useAiChat 的 ref 供模板使用 ——
const pendingApprovals = computed(() => ai.pendingApprovals.value)
const skills = computed(() => ai.skills.value)
const entities = computed(() => ai.entities.value)
const tokenUsage = computed(() => ai.tokenUsage.value)
const maxRoundsActive = computed(() => ai.maxRoundsActive.value)
const generating = computed(() => ai.generating.value)
const currentText = computed(() => ai.currentText.value)
const deviceOnline = computed(() => ai.deviceOnline.value)
const wsStatus = computed(() => ai.wsStatus.value)
const isWsConnected = computed(() => ai.isWsConnected.value)
const loading = computed(() => ai.loading.value)
/**
* 节流 ref composable(优雅化):源变化 → 节流窗口合并,输出节流后值。
* 首帧立即(低延迟),窗口内合并,窗口末取最新。返回 { throttled, cancel, flush }。
*
* 用途:流式 currentText 高频 delta → 节流渲染源,降低 rich-text 重建频率。
* 根因(用户报"桌面端已答完,小程序端还一个字一个字蹦"):JS 单线程,每 delta 全量
* lexer+style+rich-text 重建打爆主线程 → onMessage 接收排队(假"传输慢")。
* 节流合并后 rich-text 重建降频,主线程释放,接收跟上 → 流式顺滑。
*/
function useThrottledRef<T>(source: () => T, ms: number) {
const throttled = ref<T>()
let timer: ReturnType<typeof setTimeout> | null = null
watch(source, (v) => {
if (timer) return
throttled.value = v // 首帧立即
timer = setTimeout(() => {
timer = null
throttled.value = source() // 窗口末取最新
}, ms)
})
return {
throttled,
cancel: () => {
if (timer) { clearTimeout(timer); timer = null }
throttled.value = undefined
},
flush: () => {
if (timer) { clearTimeout(timer); timer = null }
throttled.value = source()
},
}
}
// 流式渲染节流:currentText → 200ms 节流合并(rich-text 重建降频,主线程释放)
const { throttled: streamRenderText, cancel: cancelStreamThrottle } = useThrottledRef(
() => currentText.value, 200,
)
// 生成结束:清节流(完成态走 renderMd 完整渲染,无需流式块缓存)
watch(() => generating.value, (g) => { if (!g) cancelStreamThrottle() })
// 每会话独立模型:可选模型列表 + 当前选中 + 默认(顶栏模型选择器)
const models = computed(() => ai.models.value)
const activeModel = computed(() => ai.activeModel.value)
/** 当前模型展示名(label 优先,缺省 model_id;未选显默认模型) */
const currentModelLabel = computed(() => {
const id = activeModel.value
if (!id) return '默认模型'
const m = models.value.find((m) => m.model_id === id)
return m?.label || id
})
/** 点击模型选择器:弹 ActionSheet 选模型。选中置 activeModel,send 时透传 model_override */
function onModelSelect(): void {
if (models.value.length === 0) {
// 未拉到模型列表:重拉一次(可能 device 未在线/未响应)
ai.listModels()
uni.showToast({ title: '暂无模型列表,重试中…', icon: 'none' })
return
}
const itemList = models.value.map((m) => m.label || m.model_id)
uni.showActionSheet({
itemList,
success: (r) => {
const picked = models.value[r.tapIndex]
if (picked) ai.setActiveModel(picked.model_id)
},
})
}
// —— 联想弹层状态(技能 / 与 @ 实体复用同一浮层结构,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[]>([])
// —— 图片输入(2026-08-05):选图后上传 file.1216.top 拿 URL / 失败回退 base64,发送时透传 ai.send parts 参数 ——
// 存临时文件路径(缩略图预览) + ContentPart(发送),对齐后端 df-ai-core ContentPart serde 内部标签 tag=type。
interface PendingImage {
tempPath: string
part: MiniContentPart
}
const pendingImages = ref<PendingImage[]>([])
/** 临时文件扩展名 → MIME 映射(fileType 是类别 'image' 非 MIME,须按扩展名推断) */
function toMime(file: { tempFilePath?: string; fileType?: string }): string {
const ft = file.fileType
if (ft && /^image\//i.test(ft)) return ft // 真 MIME 优先
const p = file.tempFilePath || ''
if (/\.png$/i.test(p)) return 'image/png'
if (/\.(jpe?g)$/i.test(p)) return 'image/jpeg'
if (/\.webp$/i.test(p)) return 'image/webp'
if (/\.gif$/i.test(p)) return 'image/gif'
return 'image/jpeg'
}
/** 选图:uni.chooseMedia(compressed)→ 上传 file.1216.top 拿 URL;失败回退 base64。 */
function onPickImage(): void {
uni.chooseMedia({
count: 1,
mediaType: ['image'],
sizeType: ['compressed'],
success: (res) => {
const file = res.tempFiles && res.tempFiles[0]
if (!file) return
const mt = toMime(file)
// 先尝试上传 file.1216.top → URL 引用(公开可达,provider 直传)
uploadImage(file.tempFilePath)
.then(({ url }) => {
pendingImages.value.push({ tempPath: file.tempFilePath, part: { type: 'image', url, media_type: mt } })
})
.catch(() => {
// 上传失败回退 base64(Phase 0 修复后 base64 可真正到达 provider,不阻断)
uni.getFileSystemManager().readFile({
filePath: file.tempFilePath,
encoding: 'base64',
success: (r) => {
const b64 = r.data as string
if (!b64) return
pendingImages.value.push({ tempPath: file.tempFilePath, part: { type: 'image', base64: b64, media_type: mt } })
},
fail: () => uni.showToast({ title: '图片读取失败', icon: 'none' }),
})
uni.showToast({ title: '上传失败,已改用本地直传', icon: 'none' })
})
},
})
}
/** 移除已选图片(预览 × 删除)。 */
function removeImage(idx: number): void {
pendingImages.value.splice(idx, 1)
}
// —— 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(() => messages.value.length, () => scrollToBottom())
watch(() => currentText.value, throttledScrollToBottom)
// P1-7:生成中关闭联想弹层,防 input disabled 时仍点插入(input disabled 不阻止弹层点击)
watch(() => 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(() => messages.value.slice(-MAX_RENDER_MSGS))
/**
* 纯空 assistant 占位气泡过滤(对齐桌面 MessageList.shouldRenderMsg:87-96)。
*
* send()/AiAgentRound 会先推空 assistant 占位(content=''),流式文本在独立气泡渲染。
* 工具轮(本轮 LLM 无文本,只有工具调用)结束后,这些空气泡残留 → 深灰空白长条刷屏。
* 过滤规则(与桌面一致):
* - 有 toolCalls → 渲染(工具卡可见)
* - 有 content/isError → 渲染
* - 纯空中间条 → 不渲染
* 注:miniapp 无桌面「末条+流式占位」概念(流式文本走独立 currentText 气泡),故不需该分支。
*/
function shouldRenderMsg(m: ChatMessage): boolean {
if (
m.role === 'assistant' &&
!m.content?.trim() &&
!m.isError &&
!(m.toolCalls && m.toolCalls.length)
) {
return false
}
return true
}
/**
* 输入变化时检测联想触发条件。
*
* 两类触发:
* - 技能(`skill`):inputText 以 `/` 开头,且尚未选 pendingSkill(已选则不再触发,
* 因 chip 已在输入栏,用户输入的是技能参数)→ listSkills() 拉技能 + 显技能浮层。
* - 实体(`entity`):末尾连续非空白段恰好为单字符 `@`(避免匹配 url/邮箱)→
* listEntities() 拉实体 + 显实体浮层。
*
* 二者优先级:技能(行首 /)优先于实体(末尾 @),因 `/` 开头整行属技能模式。
* 已选 pendingSkill 时屏蔽 `/` 重复触发(同上,用户在输入参数)。
*/
/**
* 输入变化时检测联想触发条件。
*
* P2-12 防抖:每按键 detectTrigger 会重复发 listSkills/listEntities,WS 命令+数组
* splice 换刷每键一次浪费。用 lastMentionFetch 记录最近一次触发的技能/实体拉取,
* 同一触发类型不重复发(浮层数据已拉到,复用即可)。
*/
let lastMentionFetch: '' | 'skill' | 'entity' = ''
function detectTrigger(): void {
const text = inputText.value
if (!text) {
mentionVisible.value = false
lastMentionFetch = ''
return
}
// 技能触发:行首 / 且未选技能。空格后(/file xxx)不再是行首 /,自动关闭浮层。
if (text.startsWith('/') && !pendingSkill.value) {
mentionTrigger.value = 'skill'
mentionVisible.value = true
if (lastMentionFetch !== 'skill') { lastMentionFetch = 'skill'; ai.listSkills() }
return
}
// 实体触发:末尾连续非空白段恰好单字符 @
const match = text.match(/([^\s]+)\s*$/)
const last = match ? match[1] : ''
if (last === '@') {
mentionTrigger.value = 'entity'
mentionVisible.value = true
if (lastMentionFetch !== 'entity') { lastMentionFetch = 'entity'; ai.listEntities() }
return
}
mentionVisible.value = false
if (lastMentionFetch !== '') lastMentionFetch = ''
}
/** 监听输入: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=', generating.value, 'imgs=', pendingImages.value.length)
const text = inputText.value.trim()
const hasImage = pendingImages.value.length > 0
if (!text && !hasImage) {
console.log('[v2:send] 空文本且无图片,忽略')
return
}
if (generating.value) {
// 审批挂起/流式生成中后端拒新 send:提示用户(去审批或等待),避免点了没反应困惑
uni.showToast({
title: pendingApprovals.value.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
// 图片输入:有已选图则构造 parts(ContentPart Image base64),无则 undefined 走纯文本零回归
const parts = hasImage ? pendingImages.value.map((p) => p.part) : undefined
ai.send(text, skill, spans, undefined, parts)
inputText.value = ''
pendingSkill.value = null
pendingMentionSpans.value = []
pendingImages.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 ''
}
}
/**
* 解析统一 diff 文本为行数组(P0-2:工具卡 diff 渲染,对齐桌面 parseDiffLines)。
*
* 写文件审批的 tc.diff 是行级 diff,按行前缀分类着色:
* - `+` 新增(绿) / `-` 删除(红) / `@@` 块头(蓝) / 其余上下文(灰)
* 原生 view 渲染(非 rich-text),滚动/颜色均可用。
*/
type DiffLine = { type: 'add' | 'del' | 'hdr' | 'ctx'; text: string }
function parseDiffLines(diff: string): DiffLine[] {
return diff.split('\n').map((line) => {
if (line.startsWith('+')) return { type: 'add', text: line }
if (line.startsWith('-')) return { type: 'del', text: line }
if (line.startsWith('@@')) return { type: 'hdr', text: line }
return { type: 'ctx', text: line }
})
}
/**
* 识别后端审批占位 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 (isWsConnected.value) {
return deviceOnline.value ? '已连接桌面端' : '已连接中继'
}
return STATUS_TEXT[wsStatus.value] || wsStatus.value
})
const canManualReconnect = computed(
() => wsStatus.value === 'disconnected' || 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' }),
})
}
/**
* 输入 token 计算(对齐桌面 tokenInOf,MessageList.vue:116-120)。
* cache_miss 存在直接用;否则(provider 不报 cache_miss,如 GLM)回退 prompt-cache_hit
* (全价真实输入),两者都无才 fallback prompt。防顶栏 in 为空。
*/
function tokenInOf(t: TokenUsage): number {
if (t.cache_miss !== undefined && t.cache_miss > 0) return t.cache_miss
if (t.cache_hit !== undefined && t.prompt !== undefined) {
return Math.max(0, t.prompt - t.cache_hit)
}
return t.prompt
}
</script>
<template>
<view class="page">
<!-- 状态栏 + 待审批徽标(F21:断开/重连中可点状态栏手动重连) -->
<view class="top">
<text class="st" :class="{ 'st-tap': canManualReconnect }" @tap="onStatusTap">{{ statusText }}</text>
<view class="top-right">
<!-- 每会话独立模型:模型选择器(点击弹 ActionSheet 选模型) -->
<text class="model-select" @tap="onModelSelect">🧠 {{ currentModelLabel }}</text>
<text v-if="tokenUsage" class="token-usage">
🪙 {{ tokenUsage.total }}
<text v-if="tokenUsage.cache_miss !== undefined || tokenUsage.cache_hit !== undefined || tokenUsage.reasoning !== undefined" class="token-breakdown">
<text class="token-in">in:{{ tokenInOf(tokenUsage) }}</text>
<text v-if="tokenUsage.cache_hit !== undefined" class="token-cache">cache:{{ tokenUsage.cache_hit }}</text>
<text v-if="tokenUsage.reasoning !== undefined" class="token-think">思考:{{ tokenUsage.reasoning }}</text>
<text class="token-out">out:{{ tokenUsage.completion }}</text>
</text>
</text>
<view v-if="pendingApprovals.length > 0" class="pending-badge">
<text> {{ pendingApprovals.length }} 待审批</text>
</view>
</view>
</view>
<!-- 达最大轮次面板(P1-E:继续/停止循环,对齐桌面 MaxRounds) -->
<view v-if="maxRoundsActive" 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="pendingApprovals.length > 0" class="pending-panel">
<view class="pending-panel-head">
<text> {{ pendingApprovals.length }} 项待审批</text>
</view>
<view v-for="p in 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="loading" class="loading-skeleton">
<view class="sk-item"><view class="sk-line sk-w80"></view></view>
<view class="sk-item"><view class="sk-line sk-w60"></view><view class="sk-line sk-w90"></view></view>
<view class="sk-item"><view class="sk-line sk-w70"></view><view class="sk-line sk-w50"></view></view>
</view>
<view v-else-if="messages.length === 0" class="empty">
<text>暂无消息</text>
</view>
<template v-for="m in visibleMessages" :key="m.id">
<view v-if="shouldRenderMsg(m)" class="msg" :class="[m.role, m.isError ? 'msg-error' : '']" @longpress="onCopyMessage(m)">
<view v-if="m.role === 'user'">
<!-- 图片预览(消息带图时) -->
<image v-for="(img, i) in m.images || []" :key="i" :src="img" mode="widthFix" class="msg-user-img" />
<!-- 文本(有文本才显,图片+文本共存;纯图片则只有图,不空白) -->
<text v-if="m.content" user-select>{{ m.content }}</text>
</view>
<template v-else>
<!-- P0-3:错误气泡纯文本渲染(不走 markdown 二次解析,错误串含 `**`/`#` 时不会被解析成格式) -->
<text v-if="m.isError" user-select>{{ m.content }}</text>
<!-- 后端审批占位 message(需要__PENDING__:call_xxx)隐藏文本,审批走工具卡片 -->
<rich-text
v-else-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>
<!-- P0-2:写文件审批 diff 行级渲染(红删/绿增/蓝块头,对齐桌面行级 diff 预览)
pending_approval 时给审批提供变更内容,completed 时展示落盘结果 -->
<scroll-view v-if="tc.diff" class="tool-diff" scroll-y>
<view v-for="(dl, i) in parseDiffLines(tc.diff)" :key="i" class="tool-diff-line" :class="dl.type">
<text>{{ dl.text }}</text>
</view>
</scroll-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>
<!-- P0-3:错误气泡操作行(重试 = 重发最后一条 user,对齐桌面错误气泡重试入口) -->
<view v-if="m.isError" class="msg-actions">
<button class="msg-retry-btn" size="mini" @tap="onRegenerate">重试</button>
</view>
</template>
</view>
</template>
<!-- 流式生成中的当前文本(rich-text 流式 markdown 渲染,块级 memo 防全文重 parse) -->
<view v-if="generating && streamRenderText" class="msg assistant">
<rich-text :nodes="renderStreamingMd(streamRenderText)" selectable />
</view>
<!-- P1-12 滚底锚点(scroll-into-view 目标) -->
<view id="list-bottom-anchor"></view>
</scroll-view>
<!-- 联想弹层(技能 / @ 实体复用同一浮层结构,detectTrigger 决定 trigger) -->
<view v-if="mentionVisible && !generating" 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="skills.length === 0" class="mention-empty">
<text>加载中或无技能...</text>
</view>
<view
v-for="(s, idx) in 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="entities.projects.length === 0 && entities.tasks.length === 0 && entities.ideas.length === 0" class="mention-empty">
<text>加载中或无数据...</text>
</view>
<view v-if="entities.projects.length > 0" class="mention-group-title">
<text>项目</text>
</view>
<view
v-for="(p, idx) in 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="entities.tasks.length > 0" class="mention-group-title">
<text>任务</text>
</view>
<view
v-for="(t, idx) in 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="entities.ideas.length > 0" class="mention-group-title">
<text>灵感</text>
</view>
<view
v-for="(i, idx) in 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">
<!-- 已选图片预览(选图后显示,缩略图 + ×删除) -->
<view v-if="pendingImages.length > 0" class="img-preview-row">
<view v-for="(img, idx) in pendingImages" :key="idx" class="img-preview-item">
<image :src="img.tempPath" mode="aspectFill" class="img-preview-thumb" />
<text class="img-preview-close" @tap="removeImage(idx)">×</text>
</view>
</view>
<view class="bar-row">
<!-- 选图按钮(左侧 +,mp-weixin button 默认 block 需样式收敛) -->
<button class="img-pick-btn" @tap="onPickImage"></button>
<!-- 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="generating ? '生成中,完成后发送…' : pendingSkill ? '输入技能参数' : '输入消息(输入 / 或 @ 触发联想)'"
:adjust-position="true"
:disable-default-padding="true"
:cursor-spacing="8"
confirm-type="send"
@confirm="handleSend"
@input="onInput"
/>
<!-- 生成中显停止按钮(wires F9),否则发送无常驻"重发"按钮(对齐桌面:regenerate
错误气泡"重试"入口,onRegenerate 已由 P0-3 错误气泡重试按钮复用) -->
<button v-if="generating" class="send-btn stop-btn" @tap="onStop">停止</button>
<button v-else class="send-btn" @tap="handleSend">发送</button>
</view>
</view>
</view>
</template>
<style scoped>
.page {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #0f0f0f;
overflow-x: hidden; /* 防内容(长URL/表格)撑开页面级水平滚动条 */
}
.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;
}
/* 每会话独立模型:顶栏模型选择器 chip(点击弹 ActionSheet) */
.model-select {
color: #6bb6ff;
font-size: 11px;
margin-right: 8px;
padding: 2px 8px;
background-color: #1f3a5f;
border: 1px solid #4a9eff;
border-radius: 10px;
}
.token-breakdown {
display: inline;
margin-left: 4px;
}
.token-in {
color: #777777;
font-size: 10px;
margin-left: 4px;
}
.token-cache {
color: #6bd99a;
font-size: 10px;
margin-left: 4px;
}
.token-think {
color: #ccaa88;
font-size: 10px;
margin-left: 4px;
}
.token-out {
color: #6bb6ff;
font-size: 10px;
margin-left: 4px;
}
.list {
flex: 1;
min-height: 0;
padding: 12px;
overflow-x: hidden; /* 消息区只纵向滚,横向内容裁剪防水平滚动条 */
}
.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;
overflow: hidden; /* 气泡内超宽内容(长URL/表格)裁剪,防撑开消息区横向滚动 */
word-break: break-all; /* 长单词/URL 强制断行(rich-text 内容不继承外层文本换行) */
}
.msg text {
color: #e0e0e0;
font-size: 14px;
line-height: 1.5;
word-break: break-all; /* 直接 text 子元素(user/error)长 URL 断行,防撑开 */
}
.msg.user {
background-color: #4a9eff;
}
.msg.user text {
color: #ffffff;
}
/* 用户消息图片预览(2026-08-06):120px 宽 + 圆角,与气泡风格一致;mode=widthFix 高自适应 */
.msg-user-img {
display: block;
width: 120px;
border-radius: 6px;
margin-bottom: 6px;
}
.msg.system text {
color: #999999;
font-size: 12px;
}
/* P0-3:错误气泡(红底红边,与正常回复区分;纯文本渲染不走 markdown) */
.msg.msg-error {
background-color: #3a1f1f;
border: 1px solid #e85a4f;
}
.msg.msg-error > text {
color: #ff8a80;
}
/* 错误气泡操作行:重试按钮 */
.msg-actions {
margin-top: 8px;
}
.msg-retry-btn {
display: inline-block;
margin: 0;
padding: 0 20rpx;
height: 52rpx;
line-height: 52rpx;
font-size: 24rpx;
color: #e85a4f;
background-color: #2a2a2a;
border: 1px solid #e85a4f;
border-radius: 8rpx;
}
.bar {
flex-shrink: 0;
display: flex;
flex-direction: column; /* 预览行 + 输入行(2026-08-05 图片输入:已选图预览在上,输入行在下) */
padding: 8px 12px;
background-color: #1a1a1a;
}
.bar-row {
display: flex;
align-items: flex-end; /* 输入行:选图按钮/技能chip/输入框/发送按钮底部对齐 */
}
/* 图片输入预览(2026-08-05) */
.img-preview-row {
display: flex;
flex-direction: row;
padding-bottom: 8px;
}
.img-preview-item {
position: relative;
margin-right: 8px;
}
.img-preview-thumb {
width: 64px;
height: 64px;
border-radius: 6px;
background-color: #2a2a2a;
}
.img-preview-close {
position: absolute;
top: -6px;
right: -6px;
width: 18px;
height: 18px;
line-height: 18px;
text-align: center;
font-size: 12px;
color: #ffffff;
background-color: rgba(0, 0, 0, 0.6);
border-radius: 50%;
}
.img-pick-btn {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
margin: 0 6px 0 0;
padding: 0;
font-size: 20px;
color: #e0e0e0;
background-color: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 6px;
box-sizing: border-box;
line-height: 1;
}
/* mp-weixin button 默认 ::after 边框去掉,防 img-pick-btn 双框 */
.img-pick-btn::after {
border: none;
}
.ipt {
flex: 1;
background-color: #2a2a2a;
color: #e0e0e0;
padding: 8px 12px;
min-height: 36px;
height: 36px; /* 去掉 auto-height 后固定高度(聚焦不再重算变高);超长内容 max-height 内滚动 */
max-height: 120px;
/* BUG-260805-03:mp-weixin textarea 默认边框(1px)撑高,与按钮 36px 高度不一致。
补 border:none 消边框差;box-sizing 已在(见下),min-height 含 padding 与按钮语义对齐。 */
border: none;
box-sizing: border-box; /* min-height 含 padding,与 send-btn(36px border-box) 语义对齐 */
overflow-y: auto;
border-radius: 6px;
font-size: 14px;
line-height: 20px;
}
.send-btn {
display: inline-block; /* mp-weixin button 默认 block,flex 里会占满/拉伸 */
flex-shrink: 0; /* 防输入框 flex 挤压按钮 */
margin: 0 0 0 8px;
height: 36px; /* 对齐输入框 min-height(36px),视觉齐平 */
line-height: 34px; /* height-2px 边框余量,文字垂直居中 */
padding: 0 16px;
background-color: #4a9eff;
color: #ffffff;
font-size: 14px;
border-radius: 6px; /* 与输入框圆角一致 */
border: none;
box-sizing: border-box; /* padding 不撑高,保 36px */
}
/* mp-weixin button 默认 ::after 边框(1px 淡边框)去掉,防 send-btn 双框 */
.send-btn::after {
border: none;
}
.send-btn.stop-btn {
background-color: #5a2a2a;
color: #e85a4f;
border: 1px solid #e85a4f; /* 停止按钮红框,与普通发送区分 */
}
/* @/ 联想弹层(内联自 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;
word-break: break-all; /* 工具参数值(路径/长字符串)断行防撑开 */
}
.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;
}
/* P0-2:写文件审批 diff(行级红删绿增蓝块头,scroll-view 限高可滚) */
.tool-diff {
margin-top: 6px;
max-height: 200px;
background-color: #121212;
border-radius: 4px;
box-sizing: border-box;
}
.tool-diff-line {
padding: 0 6px;
line-height: 18px;
}
.tool-diff-line text {
font-family: monospace;
font-size: 11px;
word-break: break-all;
}
.tool-diff-line.add {
background-color: rgba(107, 217, 154, 0.08);
}
.tool-diff-line.add text {
color: #6bd99a;
}
.tool-diff-line.del {
background-color: rgba(232, 90, 79, 0.08);
}
.tool-diff-line.del text {
color: #e85a4f;
}
.tool-diff-line.hdr text {
color: #6bb6ff;
}
.tool-diff-line.ctx text {
color: #999999;
}
/* 纯 JSON 工具结果 message 弱化(灰小字折叠,避免刺眼占用主气泡) */
.msg-toolresult {
margin-top: 4px;
padding: 6px 8px;
background-color: #181818;
border-radius: 6px;
max-height: 80px;
overflow: hidden;
word-break: break-all; /* 长 JSON 强制断行,防撑开气泡(真机水平滚动条根因之一) */
}
.msg-toolresult text {
color: #b0b0b0;
font-size: 11px;
}
/* 骨架屏(切会话/冷启动加载占位,闪烁动画模拟内容) */
.loading-skeleton {
padding: 12px 0;
}
.sk-line {
display: block;
height: 12px;
margin: 6px 0;
background: linear-gradient(90deg, #2a2a2a 25%, #333333 50%, #2a2a2a 75%);
background-size: 200% 100%;
animation: sk-shimmer 1.5s infinite;
border-radius: 6px;
}
.sk-w80 { width: 80% }
.sk-w90 { width: 90% }
.sk-w60 { width: 60% }
.sk-w70 { width: 70% }
.sk-w50 { width: 50% }
.sk-item {
margin-bottom: 16px;
padding: 10px 14px;
border-radius: 10px;
background-color: #2a2a2a;
}
@keyframes sk-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>