优化: df-miniapp 跨端 AI Chat 更新(会话前基线收尾)

- ws.ts 心跳 pong 闭环 + useAiChat 看门狗治半连接挂死

- pages 新增 settings/ + chat/conversations 更新 + types events/relay + mdRenderer + tsconfig
This commit is contained in:
lxy
2026-08-05 22:14:38 +08:00
parent a91e950874
commit 71fdaac1b4
10 changed files with 1361 additions and 192 deletions
+18 -4
View File
@@ -33,6 +33,9 @@
import { getConfig } from '@/config'
import type { BroadcastMessage, Hello, MiniCommand, ControlMessage } from '@/types/relay'
/** 调试日志门控(P1 走查:生产 console.log 过微信 bridge,热路径成本)。dev=true / build=false。 */
const DEBUG = import.meta.env.DEV
/** WS 连接状态 */
export type WsStatus = 'disconnected' | 'connecting' | 'connected' | 'handshaking' | 'reconnecting'
@@ -86,6 +89,8 @@ class WsClient {
private reconnectAttempts = 0
/** 主动关闭标志(用户调用 disconnect 时置 true,不再触发重连) */
private manualClose = false
/** 可恢复标志:达上限 disconnected 时置 true,允许 connect/resumeIfDisconnected 重置重连 */
private recoverable = false
/**
* 握手完成前积压的命令队列(send 在 !handshaked 时不丢弃,入队等 hello_ack 后补发)。
* 治「握手成功到首帧竞态丢命令」:握手刚翻 true 时 relay 设备路由表可能未就绪,
@@ -112,7 +117,8 @@ class WsClient {
return
}
this.manualClose = false
// 用户主动连接复位重连计数(可与达上限后的自动退避区分,允许手动重试)
this.recoverable = false
// 用户主动连接复位重连计数(可达上限后的 disconnected 也允许手动重试)
this.reconnectAttempts = 0
// 统一经 reconnectNow(幂等 clear timer + cleanup + openSocket),杜绝 reconnecting 态双连
this.reconnectNow()
@@ -138,7 +144,9 @@ class WsClient {
*/
resumeIfDisconnected(): void {
if (this.manualClose) return
if (this.status === 'disconnected' || this.status === 'reconnecting') {
// 已达上限 disconnected(recoverable=true)也允许重置计数并重连
if (this.status === 'disconnected' || this.status === 'reconnecting' || this.recoverable) {
this.recoverable = false
// 用户触发的立即重连(onShow 抢占退避)复位计数,允许达上限后手动恢复
this.reconnectAttempts = 0
this.reconnectNow()
@@ -174,7 +182,7 @@ class WsClient {
* BroadcastMessage(device_id/kind/source/from 由 relay 填),故客户端仅发业务 JSON。
*/
send(cmd: MiniCommand): boolean {
console.log('[dbg:ws] send', cmd.cmd, 'socket=', !!this.socket, 'handshaked=', this.handshaked)
if (DEBUG) console.log('[dbg:ws] send', cmd.cmd, 'socket=', !!this.socket, 'handshaked=', this.handshaked)
if (!this.socket || !this.handshaked) {
// 握手未完成不丢弃,入队待 hello_ack 后补发(治握手成功到首帧竞态丢命令)。
// 仅在未主动断开(manualClose=false)且连接尚有恢复预期时入队,
@@ -508,8 +516,14 @@ class WsClient {
// 用户手动重连(connect/resumeIfDisconnected)复位计数后可重新开始。
if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
console.warn(`[WsClient] 已达最大重连次数 ${MAX_RECONNECT_ATTEMPTS},停止重连`)
// 不清 cleanup(保留 manualClose 不变),仅设状态 + 停退避 timer
// 让 connect()/resumeIfDisconnected() 可重置计数后恢复
this.setStatus('disconnected', '已达最大重连次数,请检查中继地址/网络后手动重连')
this.cleanup()
this.recoverable = true
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
return
}
const cfg = getConfig()
File diff suppressed because it is too large Load Diff
+10
View File
@@ -13,6 +13,12 @@
"navigationBarTitleText": "会话列表",
"enablePullDownRefresh": true
}
},
{
"path": "pages/settings/index",
"style": {
"navigationBarTitleText": "设置"
}
}
],
"globalStyle": {
@@ -35,6 +41,10 @@
{
"pagePath": "pages/conversations/index",
"text": "会话"
},
{
"pagePath": "pages/settings/index",
"text": "设置"
}
]
}
+608 -79
View File
@@ -3,14 +3,19 @@ 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'
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 源保留备用(工具修复后可拆回)。
/** md→html 渲染 + 缓存(避免 v-for 每次 render 重算 marked.parse 同步阻塞) */
// 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)
@@ -22,13 +27,202 @@ function renderMd(src: string): string {
} 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)
@@ -45,6 +239,45 @@ const pendingSkill = ref<SkillInfoLike | null>(null)
// 用户可连续 @ 多个实体,发送时整体透传,后端 resolve 投影成 Augmentation 注入。
const pendingMentionSpans = ref<MentionSpan[]>([])
// —— 图片输入(2026-08-05):选图转 base64,发送时透传 ai.send parts 参数 ——
// 存临时文件路径(缩略图预览) + ContentPart(发送),对齐后端 df-ai-core ContentPart serde externally tagged。
interface PendingImage {
tempPath: string
part: MiniContentPart
}
const pendingImages = ref<PendingImage[]>([])
/** 选图:uni.chooseMedia(compressed 压缩小图)→ base64 → 入 pendingImages。 */
function onPickImage(): void {
uni.chooseMedia({
count: 1,
mediaType: ['image'],
sizeType: ['compressed'],
success: (res) => {
const file = res.tempFiles && res.tempFiles[0]
if (!file) return
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: { Image: { base64: b64, media_type: file.fileType || 'image/jpeg' } },
})
},
fail: () => 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('')
@@ -63,16 +296,39 @@ function throttledScrollToBottom(): void {
}, 120)
}
// 新消息 push / 切会话历史回流 → 立即滚底;流式增量 → throttled 滚底
watch(() => ai.messages.length, () => scrollToBottom())
watch(() => ai.currentText.value, throttledScrollToBottom)
watch(() => messages.value.length, () => scrollToBottom())
watch(() => currentText.value, throttledScrollToBottom)
// P1-7:生成中关闭联想弹层,防 input disabled 时仍点插入(input disabled 不阻止弹层点击)
watch(() => ai.generating.value, (g) => { if (g) mentionVisible.value = false })
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(() => ai.messages.slice(-MAX_RENDER_MSGS))
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
}
/**
* 输入变化时检测联想触发条件。
@@ -86,17 +342,26 @@ const visibleMessages = computed(() => ai.messages.slice(-MAX_RENDER_MSGS))
* 二者优先级:技能(行首 /)优先于实体(末尾 @),因 `/` 开头整行属技能模式。
* 已选 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
ai.listSkills()
if (lastMentionFetch !== 'skill') { lastMentionFetch = 'skill'; ai.listSkills() }
return
}
// 实体触发:末尾连续非空白段恰好单字符 @
@@ -105,10 +370,11 @@ function detectTrigger(): void {
if (last === '@') {
mentionTrigger.value = 'entity'
mentionVisible.value = true
ai.listEntities()
if (lastMentionFetch !== 'entity') { lastMentionFetch = 'entity'; ai.listEntities() }
return
}
mentionVisible.value = false
if (lastMentionFetch !== '') lastMentionFetch = ''
}
/** 监听输入:mp-weixin textarea 的 @input 在 v-model 后触发,可直接检测(P3-A input→textarea) */
@@ -182,16 +448,17 @@ function onMentionClose(): void {
* 保留原有守卫:空文本忽略 / generating 中忽略 / 关闭联想浮层。
*/
function handleSend(): void {
console.log('[v2:send] enter text=', JSON.stringify(inputText.value), 'gen=', ai.generating.value)
console.log('[v2:send] enter text=', JSON.stringify(inputText.value), 'gen=', generating.value, 'imgs=', pendingImages.value.length)
const text = inputText.value.trim()
if (!text) {
console.log('[v2:send] 空文本,忽略')
const hasImage = pendingImages.value.length > 0
if (!text && !hasImage) {
console.log('[v2:send] 空文本且无图片,忽略')
return
}
if (ai.generating.value) {
if (generating.value) {
// 审批挂起/流式生成中后端拒新 send:提示用户(去审批或等待),避免点了没反应困惑
uni.showToast({
title: ai.pendingApprovals.length > 0 ? '有待审批项,请先处理' : '生成中,请稍候',
title: pendingApprovals.value.length > 0 ? '有待审批项,请先处理' : '生成中,请稍候',
icon: 'none',
})
return
@@ -199,10 +466,13 @@ function handleSend(): void {
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)
// 图片输入:有已选图则构造 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 深色风格) ——
@@ -259,6 +529,23 @@ function formatResult(result: unknown): string {
}
}
/**
* 解析统一 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)。
*
@@ -325,13 +612,13 @@ const STATUS_TEXT: Record<string, string> = {
disconnected: '已断开·点此重连',
}
const statusText = computed(() => {
if (ai.isWsConnected.value) {
return ai.deviceOnline.value ? '已连接桌面端' : '已连接中继'
if (isWsConnected.value) {
return deviceOnline.value ? '已连接桌面端' : '已连接中继'
}
return STATUS_TEXT[ai.wsStatus.value] || ai.wsStatus.value
return STATUS_TEXT[wsStatus.value] || wsStatus.value
})
const canManualReconnect = computed(
() => ai.wsStatus.value === 'disconnected' || ai.wsStatus.value === 'reconnecting',
() => wsStatus.value === 'disconnected' || wsStatus.value === 'reconnecting',
)
function onStatusTap(): void {
if (canManualReconnect.value) ai.resumeIfDisconnected()
@@ -365,6 +652,19 @@ function onCopyMessage(m: { content?: string }): void {
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>
@@ -373,15 +673,25 @@ function onCopyMessage(m: { content?: string }): void {
<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>
<!-- 每会话独立模型:模型选择器(点击弹 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="ai.maxRoundsActive.value" class="maxrounds-panel">
<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>
@@ -390,11 +700,11 @@ function onCopyMessage(m: { content?: string }): void {
</view>
<!-- 待审批面板( pendingApprovals 渲染, messages 解耦:req3 重连 load_messages 替换 messages 不影响审批卡) -->
<view v-if="ai.pendingApprovals.length > 0" class="pending-panel">
<view v-if="pendingApprovals.length > 0" class="pending-panel">
<view class="pending-panel-head">
<text> {{ ai.pendingApprovals.length }} 项待审批</text>
<text> {{ pendingApprovals.length }} 项待审批</text>
</view>
<view v-for="p in ai.pendingApprovals" :key="p.id" class="tool-card">
<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>
@@ -427,15 +737,24 @@ function onCopyMessage(m: { content?: string }): void {
<!-- 消息列表(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">
<!-- 加载中骨架 切会话/冷启动占位 -->
<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>
<view v-for="m in visibleMessages" :key="m.id" class="msg" :class="m.role" @longpress="onCopyMessage(m)">
<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)">
<text v-if="m.role === 'user'" user-select>{{ m.content }}</text>
<template v-else>
<!-- P0-3:错误气泡纯文本渲染(不走 markdown 二次解析,错误串含 `**`/`#` 时不会被解析成格式) -->
<text v-if="m.isError" user-select>{{ m.content }}</text>
<!-- 后端审批占位 message(需要__PENDING__:call_xxx)隐藏文本,审批走工具卡片 -->
<rich-text
v-if="m.content && !isPendingPlaceholder(m.content) && !isToolResultJson(m.content)"
v-else-if="m.content && !isPendingPlaceholder(m.content) && !isToolResultJson(m.content)"
:nodes="renderMd(m.content)"
selectable
/>
@@ -467,6 +786,13 @@ function onCopyMessage(m: { content?: string }): void {
<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
@@ -476,18 +802,23 @@ function onCopyMessage(m: { content?: string }): void {
<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>
<!-- 流式生成中的当前文本 -->
<view v-if="ai.generating.value && ai.currentText.value" class="msg assistant">
<text user-select>{{ ai.currentText.value }}</text>
</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 && !ai.generating.value" class="mention-mask" @tap="onMentionClose">
<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>
@@ -496,11 +827,11 @@ function onCopyMessage(m: { content?: string }): void {
<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">
<view v-if="skills.length === 0" class="mention-empty">
<text>加载中或无技能...</text>
</view>
<view
v-for="(s, idx) in ai.skills"
v-for="(s, idx) in skills"
:key="'sk-' + idx"
class="mention-item"
@tap="onSkillSelect(s)"
@@ -515,14 +846,14 @@ function onCopyMessage(m: { content?: string }): void {
</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">
<view v-if="entities.projects.length === 0 && entities.tasks.length === 0 && entities.ideas.length === 0" class="mention-empty">
<text>加载中或无数据...</text>
</view>
<view v-if="ai.entities.projects.length > 0" class="mention-group-title">
<view v-if="entities.projects.length > 0" class="mention-group-title">
<text>项目</text>
</view>
<view
v-for="(p, idx) in ai.entities.projects"
v-for="(p, idx) in entities.projects"
:key="'p-' + idx"
class="mention-item"
@tap="onEntitySelect(p, 'project')"
@@ -530,11 +861,11 @@ function onCopyMessage(m: { content?: string }): void {
<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">
<view v-if="entities.tasks.length > 0" class="mention-group-title">
<text>任务</text>
</view>
<view
v-for="(t, idx) in ai.entities.tasks"
v-for="(t, idx) in entities.tasks"
:key="'t-' + idx"
class="mention-item"
@tap="onEntitySelect(t, 'task')"
@@ -542,11 +873,11 @@ function onCopyMessage(m: { content?: string }): void {
<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">
<view v-if="entities.ideas.length > 0" class="mention-group-title">
<text>灵感</text>
</view>
<view
v-for="(i, idx) in ai.entities.ideas"
v-for="(i, idx) in entities.ideas"
:key="'i-' + idx"
class="mention-item"
@tap="onEntitySelect(i, 'idea')"
@@ -561,33 +892,38 @@ function onCopyMessage(m: { content?: string }): void {
<!-- 输入 + 发送(原生 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 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>
<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>
@@ -598,6 +934,7 @@ function onCopyMessage(m: { content?: string }): void {
flex-direction: column;
height: 100vh;
background-color: #0f0f0f;
overflow-x: hidden; /* 防内容(长URL/表格)撑开页面级水平滚动条 */
}
.top {
flex-shrink: 0;
@@ -624,10 +961,45 @@ function onCopyMessage(m: { content?: string }): void {
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;
@@ -642,11 +1014,14 @@ function onCopyMessage(m: { content?: string }): void {
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;
@@ -658,41 +1033,130 @@ function onCopyMessage(m: { content?: string }): void {
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;
align-items: flex-end;
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;
box-sizing: border-box;
/* 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 {
margin-left: 8px;
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;
}
.regen-btn {
margin-left: 8px;
background-color: #2a2a2a;
color: #c0c0c0;
font-size: 13px;
border: 1px solid #e85a4f; /* 停止按钮红框,与普通发送区分 */
}
/* @/ 联想弹层(内联自 MentionInput,绕过工具组件解析 bug) */
.mention-mask {
@@ -923,6 +1387,7 @@ function onCopyMessage(m: { content?: string }): void {
color: #c0c0c0;
font-size: 12px;
flex: 1;
word-break: break-all; /* 工具参数值(路径/长字符串)断行防撑开 */
}
.tool-dir {
margin-top: 4px;
@@ -977,6 +1442,41 @@ function onCopyMessage(m: { content?: string }): void {
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;
@@ -985,9 +1485,38 @@ function onCopyMessage(m: { content?: string }): void {
border-radius: 6px;
max-height: 80px;
overflow: hidden;
word-break: break-all; /* 长 JSON 强制断行,防撑开气泡(真机水平滚动条根因之一) */
}
.msg-toolresult text {
color: #777777;
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>
@@ -1,10 +1,24 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { onPullDownRefresh } from '@dcloudio/uni-app'
import { useAiChat } from '@/composables/useAiChat'
import type { Conversation } from '@/types/events'
const ai = useAiChat()
/**
* mp-weixin 模板对嵌套在对象中的 reactive/ref 响应性追踪有问题。
* 用本地 ref + watch 同步,确保模板直接拿到响应式数组。
*/
const conversations = ref<Conversation[]>([])
/**
* 监听 ai.conversations.length 变化(比 deep watch 更可靠)。
* 用 `[...ai.conversations]` 创建普通数组赋值给 ref,确保每次变化都触发模板更新。
*/
watch(() => ai.conversations.value.length, () => {
conversations.value = [...ai.conversations.value]
}, { immediate: true })
/** 点击会话项切换 */
function handleSelect(convId: string): void {
ai.switchConversation(convId)
@@ -66,13 +80,20 @@ function formatTime(ts?: number): string {
<!-- 会话列表 -->
<scroll-view class="conv-list" scroll-y>
<view v-if="ai.conversations.length === 0" class="empty-hint">
<!-- 加载中骨架屏 -->
<view v-if="conversations.length === 0 && ai.loading.value" class="conv-skeleton">
<view v-for="i in 5" :key="i" class="conv-sk-item">
<view class="conv-sk-line conv-sk-w70"></view>
<view class="conv-sk-line conv-sk-w40"></view>
</view>
</view>
<view v-else-if="conversations.length === 0" class="empty-hint">
<text>暂无历史会话</text>
<text class="empty-sub">在桌面端创建后,此处同步</text>
</view>
<view
v-for="conv in ai.conversations"
v-for="conv in conversations"
:key="conv.id"
class="conv-item"
:class="{ active: conv.id === ai.activeConversationId.value }"
@@ -85,7 +106,7 @@ function formatTime(ts?: number): string {
</view>
<text v-if="conv.updatedAt" class="conv-time">{{ formatTime(conv.updatedAt) }}</text>
</view>
<view v-if="ai.conversations.length > 0" class="list-hint">
<view v-if="conversations.length > 0" class="list-hint">
<text>长按会话可重命名</text>
</view>
</scroll-view>
@@ -186,4 +207,26 @@ function formatTime(ts?: number): string {
color: $uni-text-color-disable;
font-size: $uni-font-size-sm;
}
/* 骨架屏(下拉刷新/冷启动占位) */
.conv-skeleton {
padding: 24rpx;
}
.conv-sk-item {
margin-bottom: 32rpx;
padding: 24rpx;
}
.conv-sk-line {
height: 24rpx;
margin: 8rpx 0;
background: linear-gradient(90deg, $uni-bg-color-light 25%, #3a3a3a 50%, $uni-bg-color-light 75%);
background-size: 200% 100%;
animation: conv-sk-shimmer 1.5s infinite;
border-radius: 8rpx;
}
.conv-sk-w70 { width: 70%; }
.conv-sk-w40 { width: 40%; }
@keyframes conv-sk-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>
@@ -0,0 +1,200 @@
<script setup lang="ts">
import { computed } from 'vue'
import { getConfig } from '@/config'
import { useAiChat } from '@/composables/useAiChat'
/**
* 设置页(只读查看,不编辑)。
*
* 展示当前连接配置 + 连接状态,用户据此核对/排查问题。
* 编辑功能后续做(relayHost/deviceId/token 手填)或走二维码配对(分发阶段)。
*
* 数据源:
* - 连接配置:config.ts getConfig()(storage 持久化,含默认值合并)
* - 连接状态:useAiChat() 单例(wsStatus + deviceOnline,实时)
* - 版本号:manifest.json versionName(手动维护此处常量,对齐 0.1.0)
*/
// 连接配置(只读快照,页面 onShow 时刷新,切 tab 回来看最新值)
const cfg = computed(() => getConfig())
// 连接状态(实时,useAiChat 单例 ref 直接响应)
const ai = useAiChat()
const wsStatus = computed(() => ai.wsStatus.value)
const deviceOnline = computed(() => ai.deviceOnline.value)
const isWsConnected = computed(() => ai.isWsConnected.value)
/** 连接状态中文文案 + 颜色 class(对齐 chat 页 STATUS_TEXT 口径) */
const STATUS_META: Record<string, { label: string; cls: string }> = {
connected: { label: '已连接', cls: 'st-ok' },
connecting: { label: '连接中…', cls: 'st-wait' },
handshaking: { label: '握手中…', cls: 'st-wait' },
reconnecting: { label: '重连中…', cls: 'st-warn' },
disconnected: { label: '已断开', cls: 'st-err' },
}
const statusText = computed(() => {
if (isWsConnected.value) {
return deviceOnline.value ? '已连接桌面端' : '已连接中继'
}
return STATUS_META[wsStatus.value]?.label || wsStatus.value
})
const statusCls = computed(() => {
if (isWsConnected.value) return deviceOnline.value ? 'st-ok' : 'st-warn'
return STATUS_META[wsStatus.value]?.cls || 'st-err'
})
/** token 脱敏(只显前 4 位 + 星号,查看页不暴露全量凭据) */
function maskToken(token: string): string {
if (!token) return '(未设置)'
if (token.length <= 8) return '*'.repeat(token.length)
return `${token.slice(0, 4)}${'*'.repeat(Math.min(token.length - 4, 12))}`
}
/** 毫秒转可读(30000 → 30s) */
function fmtMs(ms: number): string {
return ms >= 1000 ? `${ms / 1000}s` : `${ms}ms`
}
/** 版本号(对齐 manifest.json versionName,改版时同步) */
const APP_VERSION = '0.1.0'
/** 手动重连(断开/重连中可点,复用 ai.resumeIfDisconnected) */
function onReconnect(): void {
if (wsStatus.value === 'disconnected' || wsStatus.value === 'reconnecting') {
ai.resumeIfDisconnected()
}
}
</script>
<template>
<view class="settings-page">
<!-- 连接状态卡片(实时,断开可点重连) -->
<view class="card">
<view class="card-head">
<text class="card-title">连接状态</text>
</view>
<view class="row">
<text class="row-label">当前状态</text>
<text class="row-value status" :class="statusCls" @tap="onReconnect">{{ statusText }}</text>
</view>
<view class="row">
<text class="row-label">中继地址</text>
<text class="row-value mono">{{ cfg.relayHost }}</text>
</view>
</view>
<!-- 连接配置(只读展示,编辑后续做) -->
<view class="card">
<view class="card-head">
<text class="card-title">连接配置</text>
<text class="card-sub">只读 · 编辑后续开放</text>
</view>
<view class="row">
<text class="row-label">设备 ID</text>
<text class="row-value mono">{{ cfg.deviceId }}</text>
</view>
<view class="row">
<text class="row-label">配对 Token</text>
<text class="row-value mono">{{ maskToken(cfg.token) }}</text>
</view>
</view>
<!-- 高级参数 -->
<view class="card">
<view class="card-head">
<text class="card-title">高级参数</text>
</view>
<view class="row">
<text class="row-label">心跳间隔</text>
<text class="row-value">{{ fmtMs(cfg.heartbeatInterval) }}</text>
</view>
<view class="row">
<text class="row-label">重连初始间隔</text>
<text class="row-value">{{ fmtMs(cfg.reconnectBaseDelay) }}</text>
</view>
<view class="row">
<text class="row-label">重连最大间隔</text>
<text class="row-value">{{ fmtMs(cfg.reconnectMaxDelay) }}</text>
</view>
</view>
<!-- 关于 -->
<view class="card">
<view class="card-head">
<text class="card-title">关于</text>
</view>
<view class="row">
<text class="row-label">版本</text>
<text class="row-value">DevFlow Mini v{{ APP_VERSION }}</text>
</view>
<view class="row">
<text class="row-label">说明</text>
<text class="row-value">远程 AI Chat · 跨端操作桌面端开发助手</text>
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
.settings-page {
min-height: 100vh;
padding: 24rpx;
box-sizing: border-box;
background-color: $uni-bg-color;
}
.card {
margin-bottom: 24rpx;
padding: 24rpx;
background-color: $uni-bg-color-light;
border-radius: 16rpx;
}
.card-head {
display: flex;
flex-direction: row;
align-items: baseline;
justify-content: space-between;
margin-bottom: 16rpx;
}
.card-title {
font-size: $uni-font-size-base;
font-weight: bold;
color: $uni-text-color;
}
.card-sub {
font-size: $uni-font-size-sm;
color: $uni-text-color-disable;
}
.row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 16rpx 0;
border-bottom: 1rpx solid $uni-border-color;
}
.row:last-child {
border-bottom: none;
}
.row-label {
font-size: $uni-font-size-sm;
color: $uni-text-color-grey;
}
.row-value {
flex: 1;
margin-left: 24rpx;
font-size: $uni-font-size-sm;
color: $uni-text-color;
text-align: right;
word-break: break-all;
}
.row-value.mono {
font-family: monospace;
}
.row-value.status {
font-weight: bold;
}
.st-ok { color: #6bd99a; }
.st-wait { color: $uni-color-primary; }
.st-warn { color: #f0c75e; }
.st-err { color: #e85a4f; }
</style>
+19
View File
@@ -93,10 +93,29 @@ export type AiChatEvent =
tasks: TaskRecord[]
ideas: IdeaRecord[]
}
// 每会话独立模型:对齐后端 list_models(device 读活跃 provider enabled 模型,跨端回推)。
// 全局事件(无 conversation_id),miniapp 顶栏模型选择器消费 models + 默认 default_model。
| {
type: 'AiModelList'
provider_id: string
default_model: string
models: ModelInfo[]
}
/** AiChatEvent 的 type 字面量集合(用于 narrowing / 分派) */
export type AiChatEventType = AiChatEvent['type']
/**
* 模型信息(每会话独立模型,对齐后端 list_models 返回)。
* provider 当前 enabled 模型列表,label 为展示名(可选,缺省用 model_id)。
*/
export interface ModelInfo {
/** 模型 id(选中后透传 model_override) */
model_id: string
/** 展示名(可选) */
label?: string
}
/** 会话信息(对齐桌面端 list_conversations 返回结构,字段名 camelCase 兼容桌面 IPC 约定) */
export interface Conversation {
id: string
+28
View File
@@ -89,6 +89,7 @@ export type ControlMessage =
* - list_conversations / load_messages(F-#95 扩展,会话列表/历史消息读取)
* - list_skills / list_entities(F-#95 联想扩展,技能/项目·任务·灵感列表,见 useAiChat.ts)
* - rename_conversation(会话重命名,2026-06-23)/ sync_pending(重连审批恢复,2026-06-23)
* - list_models(每会话独立模型,模型选择器拉取 provider enabled 模型列表,见 useAiChat.ts)
*/
export interface MiniCommand {
/** Tauri command 名 */
@@ -102,6 +103,12 @@ export interface SendMessageArgs {
message: string
conversation_id?: string | null
model_override?: string | null
/**
* 跨端新会话语义(2026-08-05):miniapp 新建会话(active=null)后首次发送置 true,
* device 端据此强制创建新 conv id(不走 active 兜底,防串旧会话)。
* 普通发送/已传 conversation_id 时不传(默认 false)。
*/
new_conversation?: boolean
/**
* 选中技能名(`/<skill>` 联想选中后透传,对齐 ai_chat_send 的 skill 参数)。
* null/undefined=无技能(普通对话);非空=后端注入对应 SKILL.md 全文到 prompt。
@@ -113,8 +120,29 @@ export interface SendMessageArgs {
* null/undefined/空=无 mention(纯文本消息)。
*/
mention_spans?: MentionSpan[] | null
/**
* 多模态片段(图片输入):miniapp 选图转 base64 构造 ContentPart Image 数组透传,
* 对齐后端 ai_chat_send 的 parts 参数(df-ai-core ContentPart,serde externally tagged)。
* null/undefined/空=纯文本消息(零回归)。
*/
parts?: MiniContentPart[] | null
}
/**
* 多模态内容片段(对齐后端 df-ai-core/src/types.rs ContentPart)。
* serde externally tagged:Image 片段 JSON 形如 `{"Image": {base64, media_type}}`。
*/
export type MiniContentPart =
| { Text: { text: string } }
| {
Image: {
url?: string | null
base64?: string | null
media_type?: string | null
alt?: string | null
}
}
/**
* 用户消息内 mention 区间的元数据
* (对齐后端 MentionSpanDto,crates/df-types/src/augmentation.rs:235)。
+22 -6
View File
@@ -33,9 +33,21 @@ function styleTag(html: string, tag: string, style: string): string {
*
* 设计取舍:pre(块代码)给深色背景;<code> 不给背景(透明),仅等宽+橙色 —— 避免块代码内层 code
* 与 pre 背景冲突的双层色块(块代码内 code 透明继承 pre 深底,内联 code 橙色等宽在消息底上也可读)。
*
* 正文颜色(P1-1 真机确认):rich-text 只认节点 inline style,不继承页面 CSS `.msg text`
* (模板的 `.msg text{color:#e0e0e0}` 仅命中 `<text>` 子元素)。marked 产出的 p/h1-6/li/td/th
* 等不带 style 时,文本在暗色气泡上呈近黑 → 暗底黑字。故给承载正文的块级元素统一注入颜色。
* strong/em/b 等行内元素继承父级(p/li)颜色,无需逐一注入。
*/
export function styleMarkdown(html: string): string {
let out = html
// 正文着色:块级文本容器统一浅色(rich-text 样式继承链从容器传子文本)
// word-break:break-all 防长 URL/长单词撑开气泡(rich-text 内文本不继承外层 .msg 的
// word-break,须 inline 注入;对齐 mdRenderer 文件头"rich-text 只认 inline style")。
const BODY_STYLE = 'color:#e0e0e0;word-break:break-all'
for (const tag of ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'blockquote', 'td']) {
out = styleTag(out, tag, BODY_STYLE)
}
// 块代码 <pre>:深色背景 + 等宽 + 圆角 + pre-wrap 换行防撑爆
out = styleTag(
out,
@@ -48,15 +60,19 @@ export function styleMarkdown(html: string): string {
'code',
'font-family:monospace;color:#e0a070;background-color:transparent;font-size:12px',
)
// 链接:蓝下划线(rich-text 不可点击,仅展示 + selectable 长按复制)
out = styleTag(out, 'a', 'color:#4a9eff;text-decoration:underline')
// 表格:border + 块级 + 横向溢出处理(rich-text 非滚动,靠 word-break 兜底;border 提升可读)
// 链接:蓝下划线(rich-text 不可点击,仅展示 + selectable 长按复制);word-break 防长 URL 撑开
out = styleTag(out, 'a', 'color:#4a9eff;text-decoration:underline;word-break:break-all')
// 表格:显式 table 布局 + 等宽字列。注意不能用 display:block(会失去表格布局语义、
// 列宽错乱),故显式声明 display:table;rich-text 非滚动容器(见文件头注释),无法建横向
// 滚动区 → 不做滚动容器,靠 max-width:100% + word-break:break-all 防超宽撑爆气泡;
// border 提升可读。
out = styleTag(
out,
'table',
'display:block;border-collapse:collapse;width:100%;font-size:12px;word-break:break-all',
'display:table;border-collapse:collapse;width:100%;max-width:100%;font-size:12px;word-break:break-all',
)
out = styleTag(out, 'th', 'border:1px solid #333;padding:4px 6px;background-color:#1a1a1a')
out = styleTag(out, 'td', 'border:1px solid #333;padding:4px 6px')
// th/td:边框 + 内边距保留,字号与 table 对齐(font-size:12px)
out = styleTag(out, 'th', 'border:1px solid #333;padding:4px 6px;font-size:12px;background-color:#1a1a1a')
out = styleTag(out, 'td', 'border:1px solid #333;padding:4px 6px;font-size:12px')
return out
}
+1 -1
View File
@@ -14,7 +14,7 @@
"skipLibCheck": true,
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM"],
"types": ["@dcloudio/types", "@types/node"],
"types": ["@dcloudio/types", "@types/node", "vite/client"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]