重构: df-miniapp移入apps/(monorepo分层 crates=Rust库/apps=前端应用)
- df-miniapp/ -> apps/df-miniapp/(uni-app 工程归集前端应用层,与 crates/ Rust 库分层) - 删冗余 apps/df-miniapp/.gitignore(根已覆盖 node_modules/dist/.DS_Store 等) - 根 .gitignore 加 unpackage/(uni-app 编译产物,唯一根未覆盖项) - vue-tsc 零错(移动不改代码,类型不变)
This commit is contained in:
21
apps/df-miniapp/index.html
Normal file
21
apps/df-miniapp/index.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,minimum-scale=1.0,user-scalable=no" />
|
||||
<title>DevFlow Mini</title>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.documentElement.style.fontSize = window.innerWidth / 20 + 'px'
|
||||
})
|
||||
</script>
|
||||
<link rel="stylesheet" href="<%= BASE_URL %>static/index.<%= VUE_APP_INDEX_CSS_HASH %>.css" />
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<strong>本应用需 JavaScript 启用。</strong>
|
||||
</noscript>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
54
apps/df-miniapp/manifest.json
Normal file
54
apps/df-miniapp/manifest.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "DevFlow",
|
||||
"appid": "PLACEHOLDER",
|
||||
"description": "DevFlow 远程 AI Chat — 跨端操作桌面端开发助手",
|
||||
"versionName": "0.1.0",
|
||||
"versionCode": 100,
|
||||
"transformPx": false,
|
||||
"app-plus": {
|
||||
"usingComponents": true,
|
||||
"splashscreen": {
|
||||
"alwaysShowBeforeRender": true,
|
||||
"waiting": true,
|
||||
"autoclose": true,
|
||||
"delay": 0
|
||||
},
|
||||
"modules": {},
|
||||
"distribute": {
|
||||
"android": {
|
||||
"permissions": [
|
||||
"<uses-permission android:name=\"android.permission.INTERNET\"/>"
|
||||
]
|
||||
},
|
||||
"ios": {},
|
||||
"sdkConfigs": {}
|
||||
}
|
||||
},
|
||||
"quickapp": {},
|
||||
"mp-weixin": {
|
||||
"appid": "PLACEHOLDER",
|
||||
"setting": {
|
||||
"urlCheck": false,
|
||||
"es6": true,
|
||||
"minified": true,
|
||||
"postcss": true
|
||||
},
|
||||
"usingComponents": true,
|
||||
"permission": {},
|
||||
"requiredBackgroundModes": [],
|
||||
"requiredPrivateInfos": [],
|
||||
"lazyCodeLoading": "requiredComponents"
|
||||
},
|
||||
"h5": {
|
||||
"title": "DevFlow Mini",
|
||||
"router": {
|
||||
"mode": "hash",
|
||||
"base": "./"
|
||||
},
|
||||
"devServer": {
|
||||
"port": 8081,
|
||||
"https": false
|
||||
}
|
||||
},
|
||||
"vueVersion": "3"
|
||||
}
|
||||
11975
apps/df-miniapp/package-lock.json
generated
Normal file
11975
apps/df-miniapp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
apps/df-miniapp/package.json
Normal file
34
apps/df-miniapp/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "df-miniapp",
|
||||
"version": "0.1.0",
|
||||
"description": "DevFlow 微信小程序(uni-app Vue3)—— 远程 AI Chat 操作桌面端",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev:h5": "uni",
|
||||
"dev:mp-weixin": "uni -p mp-weixin",
|
||||
"build:h5": "uni build",
|
||||
"build:mp-weixin": "uni build -p mp-weixin",
|
||||
"type-check": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dcloudio/uni-app": "vue3",
|
||||
"@dcloudio/uni-components": "vue3",
|
||||
"@dcloudio/uni-mp-weixin": "vue3",
|
||||
"@dcloudio/uni-h5": "vue3",
|
||||
"vue": "^3.4.0",
|
||||
"vue-i18n": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dcloudio/types": "^3.4.0",
|
||||
"@dcloudio/uni-automator": "vue3",
|
||||
"@dcloudio/uni-cli-shared": "vue3",
|
||||
"@dcloudio/vite-plugin-uni": "vue3",
|
||||
"@types/node": "^20.0.0",
|
||||
"@vue/runtime-core": "^3.4.0",
|
||||
"@vue/tsconfig": "^0.5.0",
|
||||
"sass": "^1.70.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^7.3.0",
|
||||
"vue-tsc": "^2.0.0"
|
||||
}
|
||||
}
|
||||
41
apps/df-miniapp/pages.json
Normal file
41
apps/df-miniapp/pages.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/chat/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "AI 对话",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/conversations/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "会话列表",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "white",
|
||||
"navigationBarTitleText": "DevFlow",
|
||||
"navigationBarBackgroundColor": "#1a1a1a",
|
||||
"backgroundColor": "#0f0f0f",
|
||||
"backgroundTextStyle": "light"
|
||||
},
|
||||
"tabBar": {
|
||||
"color": "#999999",
|
||||
"selectedColor": "#4a9eff",
|
||||
"backgroundColor": "#1a1a1a",
|
||||
"borderStyle": "black",
|
||||
"list": [
|
||||
{
|
||||
"pagePath": "pages/chat/index",
|
||||
"text": "对话"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/conversations/index",
|
||||
"text": "会话"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
32
apps/df-miniapp/src/App.vue
Normal file
32
apps/df-miniapp/src/App.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
|
||||
import { useAiChat } from '@/composables/useAiChat'
|
||||
|
||||
// 应用启动:连接 relay。useAiChat 单例管理 WS 生命周期 + 状态。
|
||||
const aiChat = useAiChat()
|
||||
|
||||
onLaunch(() => {
|
||||
console.log('[DevFlow Mini] 应用启动')
|
||||
aiChat.connect()
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
// 切回前台:若 WS 已断,触发重连
|
||||
aiChat.resumeIfDisconnected()
|
||||
})
|
||||
|
||||
onHide(() => {
|
||||
console.log('[DevFlow Mini] 应用进入后台')
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- uni-app 规范:App.vue 不渲染具体内容,页面由 pages.json 路由自动注入。
|
||||
全局样式放 App.vue 的 style(无 scoped),各页面继承。 -->
|
||||
<style lang="scss">
|
||||
/* 全局基础样式(暗色主题,各页面 page 标签继承) */
|
||||
page {
|
||||
background-color: $uni-bg-color;
|
||||
color: $uni-text-color;
|
||||
font-size: $uni-font-size-base;
|
||||
}
|
||||
</style>
|
||||
279
apps/df-miniapp/src/api/ws.ts
Normal file
279
apps/df-miniapp/src/api/ws.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* df-relay WS 客户端(uni-app 跨端适配)
|
||||
*
|
||||
* 协议(对齐 crates/df-relay/src/relay.rs handle_connection):
|
||||
* 1. 建立 WS 连接到 `/ws/miniapp`
|
||||
* 2. 首帧发 Hello{kind:'miniapp', device_id, token} 身份宣告(relay.rs:197-208,10s 超时)
|
||||
* 3. 校验通过后进入收发循环;失败 relay 发 Error 帧 + Close
|
||||
*
|
||||
* 跨端适配:
|
||||
* - 微信小程序:uni.connectSocket(微信原生 API,非浏览器 WebSocket)
|
||||
* - H5:uni.connectSocket 封装浏览器 WebSocket
|
||||
* - 统一经 uni API 封装,业务层不直接 new WebSocket
|
||||
*
|
||||
* 机制:
|
||||
* - 重连:指数退避(baseDelay * 2^n,封顶 maxDelay),握手失败/网络断/服务关闭触发
|
||||
* - 心跳:固定间隔发 ControlMessage{control_kind:'ping'},防空闲断开 + 在线状态
|
||||
* - JSON 透传:入站解析为 BroadcastMessage,payload 是 AiChatEvent JSON
|
||||
* 出站直接发 Command JSON(relay 自动包 payload,见 relay.rs:332-365)
|
||||
*/
|
||||
|
||||
import { getConfig } from '@/config'
|
||||
import type { BroadcastMessage, Hello, MiniCommand, ControlMessage } from '@/types/relay'
|
||||
|
||||
/** WS 连接状态 */
|
||||
export type WsStatus = 'disconnected' | 'connecting' | 'connected' | 'handshaking' | 'reconnecting'
|
||||
|
||||
/** 入站事件回调(Event/Control 消息分派) */
|
||||
export interface WsHandlers {
|
||||
/** 收到 Event 消息(payload 已解为 AiChatEvent JSON,业务层按 type discriminator 处理) */
|
||||
onEvent: (msg: BroadcastMessage) => void
|
||||
/** 收到 Control 消息(心跳响应 / 在线状态等) */
|
||||
onControl: (msg: ControlMessage) => void
|
||||
/** 状态变更(连接 / 断开 / 重连中) */
|
||||
onStatus: (status: WsStatus, detail?: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* WS 客户端单例(miniapp 全局一个连接)
|
||||
*
|
||||
* 设计:单 socket + 单 handlers 回调集合(composable 订阅,业务层不直接持 socket)。
|
||||
* 握手/心跳/重连机制内部封装,业务层仅关心 send(Command) + onEvent。
|
||||
*/
|
||||
class WsClient {
|
||||
private socket: UniApp.SocketTask | null = null
|
||||
private handlers: WsHandlers | null = null
|
||||
private status: WsStatus = 'disconnected'
|
||||
/** 握手是否已完成(收到首个广播或握手成功信号) */
|
||||
private handshaked = false
|
||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private reconnectAttempts = 0
|
||||
/** 主动关闭标志(用户调用 disconnect 时置 true,不再触发重连) */
|
||||
private manualClose = false
|
||||
|
||||
/** 订阅(单订阅,composable 注册一次) */
|
||||
subscribe(h: WsHandlers): void {
|
||||
this.handlers = h
|
||||
}
|
||||
|
||||
/** 读取当前状态 */
|
||||
getStatus(): WsStatus {
|
||||
return this.status
|
||||
}
|
||||
|
||||
/** 主动连接 */
|
||||
connect(): void {
|
||||
if (this.status === 'connected' || this.status === 'connecting' || this.status === 'handshaking') {
|
||||
return
|
||||
}
|
||||
this.manualClose = false
|
||||
this.openSocket()
|
||||
}
|
||||
|
||||
/** 主动断开(用户手动,不重连) */
|
||||
disconnect(): void {
|
||||
this.manualClose = true
|
||||
this.cleanup()
|
||||
this.setStatus('disconnected', 'manual close')
|
||||
}
|
||||
|
||||
/** 若已断开则触发重连(onShow 时调用,小程序后台回前台) */
|
||||
resumeIfDisconnected(): void {
|
||||
if (this.status === 'disconnected' && !this.manualClose) {
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 Command(miniapp → device)
|
||||
*
|
||||
* 不包装 BroadcastMessage —— relay.rs:332-365 入站文本帧直接当 payload 包成
|
||||
* BroadcastMessage(device_id/kind/source/from 由 relay 填),故客户端仅发业务 JSON。
|
||||
*/
|
||||
send(cmd: MiniCommand): boolean {
|
||||
if (!this.socket || !this.handshaked) {
|
||||
console.warn('[WsClient] 未连接或握手未完成,丢弃命令', cmd.cmd)
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const text = JSON.stringify(cmd)
|
||||
this.socket.send({ data: text })
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('[WsClient] send 失败', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 内部实现 ──────────────────────────────────────────
|
||||
|
||||
private openSocket(): void {
|
||||
const cfg = getConfig()
|
||||
this.setStatus('connecting')
|
||||
console.log('[WsClient] 连接', cfg.relayHost)
|
||||
|
||||
try {
|
||||
this.socket = uni.connectSocket({
|
||||
url: cfg.relayHost,
|
||||
complete: () => {
|
||||
// complete 回调在 uni-app 仅表示"任务创建完成",非连接完成(onOpen 才是)
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('[WsClient] connectSocket 异常', e)
|
||||
this.scheduleReconnect()
|
||||
return
|
||||
}
|
||||
|
||||
this.socket.onOpen(() => this.handleOpen())
|
||||
this.socket.onMessage((res) => this.handleMessage(res))
|
||||
this.socket.onClose((res) => this.handleClose(res))
|
||||
this.socket.onError((res) => this.handleError(res))
|
||||
}
|
||||
|
||||
/** WS 已建立连接 → 立即发 Hello 握手帧 */
|
||||
private handleOpen(): void {
|
||||
this.setStatus('handshaking')
|
||||
const cfg = getConfig()
|
||||
const hello: Hello = {
|
||||
kind: 'miniapp',
|
||||
device_id: cfg.deviceId,
|
||||
token: cfg.token,
|
||||
}
|
||||
try {
|
||||
this.socket?.send({ data: JSON.stringify(hello) })
|
||||
} catch (e) {
|
||||
console.error('[WsClient] 发送 Hello 失败', e)
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/** 入站消息分派:握手阶段首帧=控制(握手成功/失败),握手后=BroadcastMessage */
|
||||
private handleMessage(res: { data: string | ArrayBuffer }): void {
|
||||
const text = typeof res.data === 'string' ? res.data : ''
|
||||
if (!text) return
|
||||
|
||||
// 握手阶段可能收到 relay 发的错误控制帧(relay.rs:200-238)
|
||||
if (!this.handshaked) {
|
||||
this.handleHandshakeResponse(text)
|
||||
return
|
||||
}
|
||||
|
||||
// 解析 BroadcastMessage(device 事件经 relay 包成完整 BroadcastMessage 下发)
|
||||
let msg: BroadcastMessage
|
||||
try {
|
||||
msg = JSON.parse(text) as BroadcastMessage
|
||||
} catch (e) {
|
||||
console.warn('[WsClient] 入站消息 JSON 解析失败', e, text.slice(0, 200))
|
||||
return
|
||||
}
|
||||
|
||||
// 按 kind 分派:Event → 业务层;Control → 心跳响应等
|
||||
if (msg.kind === 'event') {
|
||||
this.handlers?.onEvent(msg)
|
||||
} else if (msg.kind === 'control') {
|
||||
this.handlers?.onControl(msg.payload as ControlMessage)
|
||||
}
|
||||
// command 是 device→miniapp 反向(不应在此端收到,忽略)
|
||||
}
|
||||
|
||||
/** 处理握手响应(relay 握手通过后无显式 ack,首条业务消息即视为握手成功;
|
||||
* 失败时 relay 发 {"kind":"control","error":"..."} + 关连接) */
|
||||
private handleHandshakeResponse(text: string): void {
|
||||
// relay 握手失败帧(relay.rs:200-238):{"kind":"control","error":"handshake_failed|kind_mismatch|auth_failed"}
|
||||
if (text.includes('"error"')) {
|
||||
console.error('[WsClient] 握手失败', text)
|
||||
this.setStatus('disconnected', `handshake failed: ${text}`)
|
||||
// 握手失败不重连(token 错则重连也错),等用户修配置后手动 connect
|
||||
this.cleanup()
|
||||
return
|
||||
}
|
||||
// 首条非错误消息即视为握手通过(实际握手后 relay 会路由 device 的 Event 过来)
|
||||
this.handshaked = true
|
||||
this.reconnectAttempts = 0
|
||||
this.setStatus('connected')
|
||||
this.startHeartbeat()
|
||||
// 该首消息也是业务消息,转 handleMessage 处理(此时 handshaked=true,走 BroadcastMessage 解析路径)
|
||||
this.handleMessage({ data: text })
|
||||
}
|
||||
|
||||
private handleClose(res: { code: number; reason: string }): void {
|
||||
console.log('[WsClient] 连接关闭', res.code, res.reason)
|
||||
this.cleanup()
|
||||
if (!this.manualClose) {
|
||||
this.scheduleReconnect()
|
||||
} else {
|
||||
this.setStatus('disconnected', 'manual close')
|
||||
}
|
||||
}
|
||||
|
||||
private handleError(res: { errMsg: string }): void {
|
||||
console.error('[WsClient] socket 错误', res.errMsg)
|
||||
// 不直接 scheduleReconnect:onClose 会跟着触发,在 onClose 统一处理重连
|
||||
}
|
||||
|
||||
/** 启动心跳(固定间隔发 ControlMessage{control_kind:'heartbeat'}) */
|
||||
private startHeartbeat(): void {
|
||||
this.stopHeartbeat()
|
||||
const cfg = getConfig()
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
const ping: ControlMessage = { control_kind: 'ping' }
|
||||
// 直接发 ControlMessage JSON(relay 透传,device 可回 pong)
|
||||
try {
|
||||
this.socket?.send({ data: JSON.stringify(ping) })
|
||||
} catch (e) {
|
||||
console.warn('[WsClient] 心跳发送失败', e)
|
||||
}
|
||||
// 注:cfg.heartbeatInterval 读但不显式用(已通过 setInterval 传入),保 cfg 引用
|
||||
void cfg
|
||||
}, cfg.heartbeatInterval)
|
||||
}
|
||||
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer)
|
||||
this.heartbeatTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 指数退避重连(baseDelay * 2^attempts,封顶 maxDelay) */
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectTimer) return
|
||||
const cfg = getConfig()
|
||||
const attempt = this.reconnectAttempts++
|
||||
const delay = Math.min(cfg.reconnectBaseDelay * Math.pow(2, attempt), cfg.reconnectMaxDelay)
|
||||
this.setStatus('reconnecting', `attempt ${attempt + 1}, delay ${delay}ms`)
|
||||
console.log(`[WsClient] ${delay}ms 后重连(attempt ${attempt + 1})`)
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
this.openSocket()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
/** 清理 socket + 定时器(连接关闭时调) */
|
||||
private cleanup(): void {
|
||||
this.stopHeartbeat()
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
if (this.socket) {
|
||||
try {
|
||||
this.socket.close({})
|
||||
} catch {
|
||||
// 已关闭的 socket close 抛错忽略
|
||||
}
|
||||
this.socket = null
|
||||
}
|
||||
this.handshaked = false
|
||||
}
|
||||
|
||||
private setStatus(status: WsStatus, detail?: string): void {
|
||||
this.status = status
|
||||
this.handlers?.onStatus(status, detail)
|
||||
}
|
||||
}
|
||||
|
||||
/** 全局单例(miniapp 进程内一个连接) */
|
||||
export const wsClient = new WsClient()
|
||||
490
apps/df-miniapp/src/composables/useAiChat.ts
Normal file
490
apps/df-miniapp/src/composables/useAiChat.ts
Normal file
@@ -0,0 +1,490 @@
|
||||
/**
|
||||
* AI Chat composable(WS 版,miniapp 专用)
|
||||
*
|
||||
* 与桌面端 useAiSend/useAiEvents 的差异:
|
||||
* - 桌面用 Tauri invoke(发命令)/ listen(收事件),miniapp 用 WS 收发(relay 透传)
|
||||
* - 桌面有 useAiApproval/useAiStream/useAiConversations 等细粒度拆分,
|
||||
* miniapp MVP 合一(状态量小,后续可拆)
|
||||
*
|
||||
* 参考(桌面端逻辑结构,WS 替代 Tauri):
|
||||
* - src/composables/ai/useAiEvents.ts:699 handleEvent(事件分派逻辑)
|
||||
* - src/composables/ai/useAiSend.ts(sendMessage 流程)
|
||||
*
|
||||
* 状态:
|
||||
* - messages: 消息气泡列表(本地渲染,非真相源 —— 真相源在桌面端 session.messages)
|
||||
* - currentText: 流式累积文本(对齐桌面 state.currentText,AiTextDelta 增量拼)
|
||||
* - generating: 生成中(对齐桌面 state.generatingConvs,单 conv 简化为 bool)
|
||||
* - conversations: 会话列表(命令 device 同步,本地缓存)
|
||||
* - activeConversationId: 当前会话(对齐桌面 state.activeConversationId)
|
||||
* - wsStatus: WS 连接状态
|
||||
* - tokenUsage: 最近回复 token 用量(可选展示)
|
||||
*
|
||||
* 方法:
|
||||
* - send(text): 发消息(send_message Command → relay → device invoke ai_chat_send)
|
||||
* - stop(): 停止生成(stop Command → device invoke ai_chat_stop)
|
||||
* - regenerate(): 重发最后一条 user 消息(重新 send_message)
|
||||
* - switchConversation(id): 切换会话(switch Command → device 切 active)
|
||||
*/
|
||||
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { wsClient } from '@/api/ws'
|
||||
import type { WsStatus } from '@/api/ws'
|
||||
import type {
|
||||
AiChatEvent,
|
||||
ChatMessage,
|
||||
Conversation,
|
||||
AiToolCallInfo,
|
||||
TokenUsage,
|
||||
} from '@/types/events'
|
||||
import type { BroadcastMessage, MiniCommand, ControlMessage } from '@/types/relay'
|
||||
|
||||
/** 简易唯一 id 生成(miniapp 无 crypto.randomUUID 兼容性顾虑,时间戳+随机即可) */
|
||||
function genId(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 10000)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局单例状态(miniapp 进程内一份,多页面共享)
|
||||
*
|
||||
* 与桌面端差异:桌面 state 在 src/stores/ai.ts(pinia-like 单例),miniapp 用模块级
|
||||
* reactive(uni-app 无 pinia 依赖,模块单例即可)。
|
||||
*/
|
||||
const messages = reactive<ChatMessage[]>([])
|
||||
const currentText = ref<string>('')
|
||||
const generating = ref<boolean>(false)
|
||||
const agentRound = ref<number>(0)
|
||||
const conversations = reactive<Conversation[]>([])
|
||||
const activeConversationId = ref<string | null>(null)
|
||||
const wsStatus = ref<WsStatus>('disconnected')
|
||||
const wsStatusDetail = ref<string>('')
|
||||
const tokenUsage = ref<TokenUsage | null>(null)
|
||||
const pendingApprovals = reactive<AiToolCallInfo[]>([])
|
||||
|
||||
/** 已注册事件监听标志(防多次订阅 wsClient) */
|
||||
let subscribed = false
|
||||
|
||||
/** 从工具调用 id 在消息列表中查找(对齐桌面 findToolCall,useAiEvents.ts:408) */
|
||||
function findToolCall(id: string): AiToolCallInfo | null {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]
|
||||
if (m.toolCalls) {
|
||||
for (const tc of m.toolCalls) {
|
||||
if (tc.id === id) return tc
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 流式文本回填到最后一条 assistant 气泡(对齐桌面 flushCurrentText,useAiEvents.ts:186) */
|
||||
function flushCurrentText(): void {
|
||||
if (!currentText.value) return
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]
|
||||
if (m.role !== 'assistant') break
|
||||
if (!m.isError) {
|
||||
m.content = currentText.value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AiChatEvent 处理(对齐桌面 useAiEvents.ts handleEvent,按域简化合并)
|
||||
*
|
||||
* miniapp MVP 简化:
|
||||
* - 仅处理核心渲染相关事件(TextDelta/AgentRound/Completed/Error/Tool/Approval)
|
||||
* - 看门狗/超时/审批计时器等桌面机制未移植(后续批)
|
||||
* - 路由判断:无 conversation_id 或等于 active 时处理,其他忽略
|
||||
*/
|
||||
export function handleEvent(event: AiChatEvent): void {
|
||||
const convId = event.conversation_id ?? null
|
||||
|
||||
// 首次收到事件同步当前对话 id(对齐桌面 useAiEvents.ts:705)
|
||||
if (convId && !activeConversationId.value) {
|
||||
activeConversationId.value = convId
|
||||
}
|
||||
|
||||
// 路由:非当前会话事件忽略(miniapp MVP 单会话视图,不追踪多 conv 生成态)
|
||||
const isCurrent = !convId || convId === activeConversationId.value
|
||||
if (!isCurrent) return
|
||||
|
||||
switch (event.type) {
|
||||
case 'AiTextDelta':
|
||||
currentText.value += event.delta
|
||||
break
|
||||
|
||||
case 'AiAgentRound': {
|
||||
// 新一轮:flush 当前文本到上一条 assistant,新建空 assistant 占位
|
||||
flushCurrentText()
|
||||
currentText.value = ''
|
||||
messages.push({
|
||||
id: genId('ai'),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
if (event.round > 0) agentRound.value = event.round
|
||||
break
|
||||
}
|
||||
|
||||
case 'AiHeartbeat':
|
||||
// 静默期报活,miniapp 仅维持连接(无 watchdog 需重置)
|
||||
break
|
||||
|
||||
case 'AiStreamRetry': {
|
||||
// 重试提示:复用错误气泡或新建
|
||||
const last = messages[messages.length - 1]
|
||||
const text = `重试中(${event.attempt}/${event.max_attempts})...`
|
||||
if (last && last.isError) {
|
||||
last.content = text
|
||||
} else {
|
||||
messages.push({
|
||||
id: genId('retry'),
|
||||
role: 'assistant',
|
||||
content: text,
|
||||
isError: true,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'AiMaxRoundsReached':
|
||||
// 达 max 暂停:miniapp 展示提示气泡(不实现继续/停止按钮,用户回桌面处理)
|
||||
messages.push({
|
||||
id: genId('maxrounds'),
|
||||
role: 'system',
|
||||
content: '⚠ 已达最大轮次,请在桌面端处理(继续/停止)',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
break
|
||||
|
||||
case 'AiToolCallStarted': {
|
||||
const info: AiToolCallInfo = {
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
args: event.args,
|
||||
status: 'running',
|
||||
}
|
||||
if (!findToolCall(event.id)) {
|
||||
const last = messages[messages.length - 1]
|
||||
if (last && last.role === 'assistant') {
|
||||
last.toolCalls = last.toolCalls || []
|
||||
last.toolCalls.push(info)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'AiToolCallCompleted': {
|
||||
const tc = findToolCall(event.id)
|
||||
if (tc) {
|
||||
tc.status = 'completed'
|
||||
tc.result = event.result
|
||||
}
|
||||
// 移除对应挂起审批(若有)
|
||||
const idx = pendingApprovals.findIndex((p) => p.id === event.id)
|
||||
if (idx >= 0) pendingApprovals.splice(idx, 1)
|
||||
break
|
||||
}
|
||||
|
||||
case 'AiToolAutoApproved':
|
||||
// 会话级信任自动放行,miniapp 仅 console(不做 toast UI)
|
||||
console.log(`[auto-approved] ${event.tool} @ ${event.dir}`)
|
||||
break
|
||||
|
||||
case 'AiApprovalRequired': {
|
||||
const info: AiToolCallInfo = {
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
args: event.args,
|
||||
status: 'pending_approval',
|
||||
kind: 'risk',
|
||||
reason: event.reason,
|
||||
diff: event.diff ?? undefined,
|
||||
}
|
||||
pendingApprovals.push(info)
|
||||
const tc = findToolCall(event.id)
|
||||
if (tc) {
|
||||
tc.status = 'pending_approval'
|
||||
tc.reason = event.reason
|
||||
if (event.diff) tc.diff = event.diff
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'AiApprovalResult': {
|
||||
if (!event.approved) {
|
||||
const tc = findToolCall(event.id)
|
||||
if (tc) tc.status = 'rejected'
|
||||
}
|
||||
const idx = pendingApprovals.findIndex((p) => p.id === event.id)
|
||||
if (idx >= 0) pendingApprovals.splice(idx, 1)
|
||||
break
|
||||
}
|
||||
|
||||
case 'AiDirAuthRequired':
|
||||
// 路径授权:miniapp 仅提示(不实现授权 UI,用户回桌面处理)
|
||||
messages.push({
|
||||
id: genId('dirauth'),
|
||||
role: 'system',
|
||||
content: `⚠ 路径授权请求:${event.tool} → ${event.dir}(请在桌面端处理)`,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
break
|
||||
|
||||
case 'AiCompleted': {
|
||||
flushCurrentText()
|
||||
currentText.value = ''
|
||||
generating.value = false
|
||||
agentRound.value = 0
|
||||
// token 用量(可选展示)
|
||||
tokenUsage.value = {
|
||||
prompt: event.prompt_tokens,
|
||||
completion: event.completion_tokens,
|
||||
total: event.total_tokens,
|
||||
}
|
||||
// 不完整标记(网络中断保文)
|
||||
if (event.incomplete) {
|
||||
messages.push({
|
||||
id: genId('incomplete'),
|
||||
role: 'assistant',
|
||||
content: '⚠ 响应因网络中断不完整',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'AiError': {
|
||||
flushCurrentText()
|
||||
currentText.value = ''
|
||||
generating.value = false
|
||||
agentRound.value = 0
|
||||
pendingApprovals.splice(0, pendingApprovals.length)
|
||||
messages.push({
|
||||
id: genId('err'),
|
||||
role: 'assistant',
|
||||
content: friendlyError(event.error),
|
||||
isError: true,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'AiHelpRequired':
|
||||
// 断路器熔断求助:miniapp 展示求助卡(用户在桌面选 option,miniapp 不渲染选项按钮)
|
||||
flushCurrentText()
|
||||
currentText.value = ''
|
||||
generating.value = false
|
||||
messages.push({
|
||||
id: genId('help'),
|
||||
role: 'system',
|
||||
content: `🆘 AI 求助:${event.reason}\n已试:${event.context}\n建议:${event.options.join(' / ')}(请在桌面端选择)`,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
break
|
||||
|
||||
case 'AiContextCleared':
|
||||
case 'AiCompressing':
|
||||
// 上下文分段/压缩中,miniapp 无需特殊处理(状态在桌面端)
|
||||
break
|
||||
|
||||
case 'AiCompressed':
|
||||
// 压缩完成:在消息首位插入摘要提示
|
||||
messages.unshift({
|
||||
id: genId('compressed'),
|
||||
role: 'system',
|
||||
content: `📋 上下文已压缩(摘要:${event.summary.slice(0, 100)}...)`,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
break
|
||||
|
||||
case 'AiConvStateChanged':
|
||||
// 统一状态机:generating/stopping → generating=true;idle/error → false
|
||||
if (event.conv_state === 'generating' || event.conv_state === 'stopping') {
|
||||
generating.value = true
|
||||
} else if (event.conv_state === 'idle' || event.conv_state === 'error') {
|
||||
generating.value = false
|
||||
}
|
||||
// compressed 是派生态,保持当前 generating(压缩完回原态)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/** 错误转用户友好提示(对齐桌面 friendlyError,useAiEvents.ts:177) */
|
||||
export function friendlyError(raw: string): string {
|
||||
if (/404|not\s*found/i.test(raw)) return '未找到(可能对话已删除)'
|
||||
if (/401|403|unauthorized|api[_\s-]?key/i.test(raw)) return '鉴权失败,请检查 API Key 配置'
|
||||
if (/timeout|超时/i.test(raw)) return '请求超时'
|
||||
if (/network|connection|ECONN|网络|连接/i.test(raw)) return '网络错误,请检查连接'
|
||||
return raw
|
||||
}
|
||||
|
||||
/** 注册 wsClient 订阅(幂等,App.vue onLaunch 调用一次) */
|
||||
function subscribeWs(): void {
|
||||
if (subscribed) return
|
||||
subscribed = true
|
||||
wsClient.subscribe({
|
||||
onEvent: (msg: BroadcastMessage) => {
|
||||
// payload 是 AiChatEvent JSON,透传给 handleEvent
|
||||
try {
|
||||
const event = msg.payload as AiChatEvent
|
||||
if (event && typeof event === 'object' && 'type' in event) {
|
||||
handleEvent(event)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[useAiChat] 事件 payload 解析失败', e)
|
||||
}
|
||||
},
|
||||
onControl: (msg: ControlMessage) => {
|
||||
// 心跳响应等,miniapp 仅 console(不维持在线状态 UI)
|
||||
console.log('[useAiChat] control', msg)
|
||||
},
|
||||
onStatus: (status: WsStatus, detail?: string) => {
|
||||
wsStatus.value = status
|
||||
wsStatusDetail.value = detail ?? ''
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 发 send_message Command(relay → device invoke ai_chat_send) */
|
||||
function send(text: string): void {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return
|
||||
if (generating.value) {
|
||||
console.warn('[useAiChat] 生成中,忽略新发送')
|
||||
return
|
||||
}
|
||||
|
||||
// 本地先 push user 气泡(乐观渲染,对齐桌面 sendMessage)
|
||||
messages.push({
|
||||
id: genId('user'),
|
||||
role: 'user',
|
||||
content: trimmed,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
// 新建 assistant 占位气泡(等待流式增量填充)
|
||||
messages.push({
|
||||
id: genId('ai'),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
currentText.value = ''
|
||||
generating.value = true
|
||||
|
||||
const cmd: MiniCommand = {
|
||||
cmd: 'send_message',
|
||||
args: {
|
||||
message: trimmed,
|
||||
conversation_id: activeConversationId.value,
|
||||
},
|
||||
}
|
||||
if (!wsClient.send(cmd)) {
|
||||
// 发送失败:在 assistant 占位气泡显错误
|
||||
flushCurrentText()
|
||||
messages.push({
|
||||
id: genId('err'),
|
||||
role: 'assistant',
|
||||
content: '未连接桌面端,请检查网络/配置',
|
||||
isError: true,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 停止生成(relay → device invoke ai_chat_stop) */
|
||||
function stop(): void {
|
||||
if (!generating.value) return
|
||||
const cmd: MiniCommand = {
|
||||
cmd: 'stop',
|
||||
args: {
|
||||
conversation_id: activeConversationId.value,
|
||||
},
|
||||
}
|
||||
wsClient.send(cmd)
|
||||
}
|
||||
|
||||
/** 重发最后一条 user 消息(重新 send_message) */
|
||||
function regenerate(): void {
|
||||
// 找最后一条 user 消息
|
||||
let lastUserText = ''
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === 'user') {
|
||||
lastUserText = messages[i].content
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!lastUserText) return
|
||||
// 移除末尾 assistant 占位/错误气泡(若仍是空的)
|
||||
const last = messages[messages.length - 1]
|
||||
if (last && last.role === 'assistant' && !last.content) {
|
||||
messages.pop()
|
||||
}
|
||||
send(lastUserText)
|
||||
}
|
||||
|
||||
/** 切换会话(switch Command → device 切 active_conversation_id) */
|
||||
function switchConversation(convId: string): void {
|
||||
activeConversationId.value = convId
|
||||
// 清空当前视图(新会话消息从 device 同步或留空)
|
||||
messages.splice(0, messages.length)
|
||||
currentText.value = ''
|
||||
generating.value = false
|
||||
|
||||
const cmd: MiniCommand = {
|
||||
cmd: 'switch_conversation',
|
||||
args: { conversation_id: convId },
|
||||
}
|
||||
wsClient.send(cmd)
|
||||
}
|
||||
|
||||
/** 新建会话(本地清空 + 切到 null,下次 send 时 device 创建新 conv) */
|
||||
function newConversation(): void {
|
||||
activeConversationId.value = null
|
||||
messages.splice(0, messages.length)
|
||||
currentText.value = ''
|
||||
generating.value = false
|
||||
pendingApprovals.splice(0, pendingApprovals.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* useAiChat 单例 hook
|
||||
*
|
||||
* 多次调用返回同一状态(模块级 reactive 单例)。
|
||||
*/
|
||||
export function useAiChat() {
|
||||
subscribeWs()
|
||||
|
||||
const isWsConnected = computed(() => wsStatus.value === 'connected')
|
||||
|
||||
return {
|
||||
// 状态
|
||||
messages,
|
||||
currentText,
|
||||
generating,
|
||||
agentRound,
|
||||
conversations,
|
||||
activeConversationId,
|
||||
wsStatus,
|
||||
wsStatusDetail,
|
||||
isWsConnected,
|
||||
tokenUsage,
|
||||
pendingApprovals,
|
||||
// 方法
|
||||
connect: () => wsClient.connect(),
|
||||
disconnect: () => wsClient.disconnect(),
|
||||
resumeIfDisconnected: () => wsClient.resumeIfDisconnected(),
|
||||
send,
|
||||
stop,
|
||||
regenerate,
|
||||
switchConversation,
|
||||
newConversation,
|
||||
handleEvent,
|
||||
friendlyError,
|
||||
}
|
||||
}
|
||||
50
apps/df-miniapp/src/config.ts
Normal file
50
apps/df-miniapp/src/config.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* miniapp 运行时配置(host/device_id/token 占位,由用户在设置页或 build env 填)
|
||||
*
|
||||
* 这些值在真实部署前是占位:
|
||||
* - RELAY_HOST:ws/wss 地址,需替换为实际部署的 df-relay 地址
|
||||
* - DEVICE_ID:用户首次配置桌面端时生成的设备 ID
|
||||
* - TOKEN:对齐 df-relay 的 DF_RELAY_TOKEN(生产建议每 device 独立 token)
|
||||
*/
|
||||
|
||||
export interface MiniappConfig {
|
||||
/** relay WS 地址(含协议),如 'ws://192.168.1.100:8080/ws/miniapp' 或 'wss://relay.example.com/ws/miniapp' */
|
||||
relayHost: string
|
||||
/** 配对绑定的设备 ID */
|
||||
deviceId: string
|
||||
/** 配对 token(对齐 df-relay DF_RELAY_TOKEN) */
|
||||
token: string
|
||||
/** 心跳间隔(ms) */
|
||||
heartbeatInterval: number
|
||||
/** 重连初始间隔(ms,指数退避) */
|
||||
reconnectBaseDelay: number
|
||||
/** 重连最大间隔(ms) */
|
||||
reconnectMaxDelay: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认配置(占位值,需替换)
|
||||
*
|
||||
* 真实部署:用户在设置页填 relayHost/deviceId/token,或通过 build-time env 注入。
|
||||
* MVP 阶段留占位让类型自洽,实际连接会失败(token 校验不通过 → relay 关连接,
|
||||
* 这符合预期,提示用户配置)。
|
||||
*/
|
||||
export const defaultConfig: MiniappConfig = {
|
||||
relayHost: 'ws://localhost:8080/ws/miniapp',
|
||||
deviceId: 'PLACEHOLDER_DEVICE_ID',
|
||||
token: 'devflow-relay-default-token',
|
||||
heartbeatInterval: 30000,
|
||||
reconnectBaseDelay: 1000,
|
||||
reconnectMaxDelay: 30000,
|
||||
}
|
||||
|
||||
/** 单例配置(miniapp 内存配置,后续接 storage 持久化) */
|
||||
let _config: MiniappConfig = { ...defaultConfig }
|
||||
|
||||
export function getConfig(): MiniappConfig {
|
||||
return _config
|
||||
}
|
||||
|
||||
export function setConfig(patch: Partial<MiniappConfig>): void {
|
||||
_config = { ..._config, ...patch }
|
||||
}
|
||||
13
apps/df-miniapp/src/main.ts
Normal file
13
apps/df-miniapp/src/main.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createSSRApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
/**
|
||||
* uni-app 入口:createSSRApp 规范(uni-app Vue3 必需,非 createApp)。
|
||||
* 桌面端 main.ts 用 createApp(Tauri 浏览器环境),小程序用 createSSRApp 对齐 uni 跨端。
|
||||
*/
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App)
|
||||
return {
|
||||
app,
|
||||
}
|
||||
}
|
||||
358
apps/df-miniapp/src/pages/chat/index.vue
Normal file
358
apps/df-miniapp/src/pages/chat/index.vue
Normal file
@@ -0,0 +1,358 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, watch } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { useAiChat } from '@/composables/useAiChat'
|
||||
|
||||
const ai = useAiChat()
|
||||
const inputText = ref('')
|
||||
const scrollIntoView = ref('')
|
||||
const scrollTimer = ref<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
/** 滚动到底部(消息列表更新时) */
|
||||
function scrollToBottom(): void {
|
||||
if (scrollTimer.value) clearTimeout(scrollTimer.value)
|
||||
scrollTimer.value = setTimeout(() => {
|
||||
// 用 id 标记滚动锚点
|
||||
scrollIntoView.value = ''
|
||||
nextTick(() => {
|
||||
scrollIntoView.value = 'msg-bottom-anchor'
|
||||
})
|
||||
}, 50)
|
||||
}
|
||||
|
||||
/** 发送消息 */
|
||||
function handleSend(): void {
|
||||
const text = inputText.value
|
||||
if (!text.trim() || ai.generating.value) return
|
||||
ai.send(text)
|
||||
inputText.value = ''
|
||||
}
|
||||
|
||||
/** 停止生成 */
|
||||
function handleStop(): void {
|
||||
ai.stop()
|
||||
}
|
||||
|
||||
/** 切换到会话列表页 */
|
||||
function goConversations(): void {
|
||||
uni.switchTab({ url: '/pages/conversations/index' })
|
||||
}
|
||||
|
||||
// 消息列表变化时滚到底
|
||||
watch(
|
||||
() => ai.messages.length,
|
||||
() => scrollToBottom()
|
||||
)
|
||||
watch(
|
||||
() => ai.currentText.value,
|
||||
() => scrollToBottom()
|
||||
)
|
||||
|
||||
onShow(() => {
|
||||
// 页面显示时若已连接,滚到底部展示最新消息
|
||||
scrollToBottom()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="chat-page">
|
||||
<!-- 顶部状态栏 -->
|
||||
<view class="status-bar">
|
||||
<view class="status-dot" :class="ai.isWsConnected.value ? 'online' : 'offline'" />
|
||||
<text class="status-text">
|
||||
{{ ai.isWsConnected.value ? '已连接桌面端' : ai.wsStatus.value }}
|
||||
</text>
|
||||
<view class="status-spacer" />
|
||||
<view class="header-btn" @click="goConversations">
|
||||
<text>会话</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<scroll-view
|
||||
class="msg-list"
|
||||
scroll-y
|
||||
:scroll-into-view="scrollIntoView"
|
||||
:scroll-with-animation="true"
|
||||
>
|
||||
<view v-if="ai.messages.length === 0" class="empty-hint">
|
||||
<text>暂无消息,发送一条试试</text>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-for="msg in ai.messages"
|
||||
:key="msg.id"
|
||||
class="msg-row"
|
||||
:class="msg.role"
|
||||
>
|
||||
<!-- user 气泡 -->
|
||||
<view v-if="msg.role === 'user'" class="bubble user-bubble">
|
||||
<text>{{ msg.content }}</text>
|
||||
</view>
|
||||
|
||||
<!-- assistant / system 气泡 -->
|
||||
<view v-else class="bubble" :class="msg.isError ? 'error-bubble' : msg.role === 'system' ? 'system-bubble' : 'assistant-bubble'">
|
||||
<text>{{ msg.content }}</text>
|
||||
<!-- 工具调用列表(简化展示 name + status) -->
|
||||
<view v-if="msg.toolCalls && msg.toolCalls.length" class="tool-calls">
|
||||
<view v-for="tc in msg.toolCalls" :key="tc.id" class="tool-call">
|
||||
<text class="tool-name">{{ tc.name }}</text>
|
||||
<text class="tool-status" :class="tc.status">{{ tc.status }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 流式生成中的当前文本(末尾渲染,非独立气泡) -->
|
||||
<view v-if="ai.generating.value && ai.currentText.value" class="msg-row assistant">
|
||||
<view class="bubble assistant-bubble streaming">
|
||||
<text>{{ ai.currentText.value }}</text>
|
||||
<text class="cursor">▎</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 空占位 assistant(刚发,等首个 delta) -->
|
||||
<view
|
||||
v-if="ai.generating.value && !ai.currentText.value && ai.messages.length > 0 && ai.messages[ai.messages.length - 1].role === 'assistant' && !ai.messages[ai.messages.length - 1].content"
|
||||
class="msg-row assistant"
|
||||
>
|
||||
<view class="bubble assistant-bubble">
|
||||
<text class="thinking">思考中...</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 滚动锚点 -->
|
||||
<view id="msg-bottom-anchor" class="bottom-anchor" />
|
||||
</scroll-view>
|
||||
|
||||
<!-- 输入栏 -->
|
||||
<view class="input-bar">
|
||||
<view class="input-wrap">
|
||||
<input
|
||||
v-model="inputText"
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder="输入消息..."
|
||||
:disabled="ai.generating.value"
|
||||
confirm-type="send"
|
||||
@confirm="handleSend"
|
||||
/>
|
||||
</view>
|
||||
<view v-if="!ai.generating.value" class="send-btn" @click="handleSend">
|
||||
<text>发送</text>
|
||||
</view>
|
||||
<view v-else class="stop-btn" @click="handleStop">
|
||||
<text>停止</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.chat-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background-color: $uni-bg-color;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16rpx 24rpx;
|
||||
background-color: $uni-bg-color-dark;
|
||||
border-bottom: 1rpx solid $uni-border-color;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
border-radius: 50%;
|
||||
margin-right: 12rpx;
|
||||
&.online {
|
||||
background-color: $uni-color-success;
|
||||
}
|
||||
&.offline {
|
||||
background-color: $uni-text-color-disable;
|
||||
}
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: $uni-font-size-sm;
|
||||
color: $uni-text-color-grey;
|
||||
}
|
||||
|
||||
.status-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-btn {
|
||||
padding: 8rpx 24rpx;
|
||||
background-color: $uni-bg-color-light;
|
||||
border-radius: 8rpx;
|
||||
text {
|
||||
font-size: $uni-font-size-sm;
|
||||
color: $uni-color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.msg-list {
|
||||
flex: 1;
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
text-align: center;
|
||||
padding: 200rpx 0;
|
||||
text {
|
||||
color: $uni-text-color-disable;
|
||||
font-size: $uni-font-size-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.msg-row {
|
||||
display: flex;
|
||||
margin-bottom: 24rpx;
|
||||
&.user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
&.assistant,
|
||||
&.system {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.bubble {
|
||||
max-width: 80%;
|
||||
padding: 20rpx 24rpx;
|
||||
border-radius: 16rpx;
|
||||
text {
|
||||
font-size: $uni-font-size-base;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.user-bubble {
|
||||
background-color: $uni-bubble-user-bg;
|
||||
text {
|
||||
color: $uni-bubble-user-text;
|
||||
}
|
||||
}
|
||||
|
||||
.assistant-bubble {
|
||||
background-color: $uni-bubble-assistant-bg;
|
||||
text {
|
||||
color: $uni-bubble-assistant-text;
|
||||
}
|
||||
&.streaming {
|
||||
border: 1rpx solid $uni-color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.system-bubble {
|
||||
background-color: transparent;
|
||||
border: 1rpx dashed $uni-border-color;
|
||||
text {
|
||||
color: $uni-text-color-grey;
|
||||
font-size: $uni-font-size-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.error-bubble {
|
||||
background-color: $uni-bubble-error-bg;
|
||||
text {
|
||||
color: $uni-bubble-error-text;
|
||||
}
|
||||
}
|
||||
|
||||
.cursor {
|
||||
color: $uni-color-primary;
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
|
||||
.thinking {
|
||||
color: $uni-text-color-grey;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
.tool-calls {
|
||||
margin-top: 12rpx;
|
||||
padding-top: 12rpx;
|
||||
border-top: 1rpx solid $uni-border-color;
|
||||
}
|
||||
|
||||
.tool-call {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 4rpx;
|
||||
}
|
||||
|
||||
.tool-name {
|
||||
font-size: $uni-font-size-sm;
|
||||
color: $uni-color-primary;
|
||||
margin-right: 12rpx;
|
||||
}
|
||||
|
||||
.tool-status {
|
||||
font-size: $uni-font-size-sm;
|
||||
color: $uni-text-color-grey;
|
||||
&.completed { color: $uni-color-success; }
|
||||
&.failed, &.rejected { color: $uni-color-error; }
|
||||
&.pending_approval { color: $uni-color-warning; }
|
||||
}
|
||||
|
||||
.bottom-anchor {
|
||||
height: 1rpx;
|
||||
}
|
||||
|
||||
.input-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16rpx 24rpx;
|
||||
background-color: $uni-bg-color-dark;
|
||||
border-top: 1rpx solid $uni-border-color;
|
||||
}
|
||||
|
||||
.input-wrap {
|
||||
flex: 1;
|
||||
padding: 0 24rpx;
|
||||
background-color: $uni-bg-color-light;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
height: 72rpx;
|
||||
color: $uni-text-color;
|
||||
font-size: $uni-font-size-base;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
padding: 16rpx 32rpx;
|
||||
background-color: $uni-color-primary;
|
||||
border-radius: 8rpx;
|
||||
margin-left: 16rpx;
|
||||
text {
|
||||
color: #ffffff;
|
||||
font-size: $uni-font-size-base;
|
||||
}
|
||||
}
|
||||
|
||||
.stop-btn {
|
||||
padding: 16rpx 32rpx;
|
||||
background-color: $uni-color-error;
|
||||
border-radius: 8rpx;
|
||||
margin-left: 16rpx;
|
||||
text {
|
||||
color: #ffffff;
|
||||
font-size: $uni-font-size-base;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
159
apps/df-miniapp/src/pages/conversations/index.vue
Normal file
159
apps/df-miniapp/src/pages/conversations/index.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<script setup lang="ts">
|
||||
import { onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { useAiChat } from '@/composables/useAiChat'
|
||||
|
||||
const ai = useAiChat()
|
||||
|
||||
/** 点击会话项切换 */
|
||||
function handleSelect(convId: string): void {
|
||||
ai.switchConversation(convId)
|
||||
// 切换后跳回对话页
|
||||
uni.switchTab({ url: '/pages/chat/index' })
|
||||
}
|
||||
|
||||
/** 新建会话 */
|
||||
function handleNew(): void {
|
||||
ai.newConversation()
|
||||
uni.switchTab({ url: '/pages/chat/index' })
|
||||
}
|
||||
|
||||
/** 下拉刷新(后续接 device 同步会话列表的 Command) */
|
||||
onPullDownRefresh(() => {
|
||||
// MVP:会话列表来自本地缓存,刷新空动作占位
|
||||
// 后续批:发 list_conversations Command → device 返回列表 → 更新 ai.conversations
|
||||
setTimeout(() => {
|
||||
uni.stopPullDownRefresh()
|
||||
}, 500)
|
||||
})
|
||||
|
||||
/** 格式化时间戳为可读字符串 */
|
||||
function formatTime(ts?: number): string {
|
||||
if (!ts) return ''
|
||||
const d = new Date(ts)
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(d.getDate()).padStart(2, '0')
|
||||
const hh = String(d.getHours()).padStart(2, '0')
|
||||
const mi = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${mm}-${dd} ${hh}:${mi}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="conv-page">
|
||||
<!-- 顶部新建按钮 -->
|
||||
<view class="new-bar" @click="handleNew">
|
||||
<text class="new-icon">+</text>
|
||||
<text class="new-text">新建会话</text>
|
||||
</view>
|
||||
|
||||
<!-- 会话列表 -->
|
||||
<scroll-view class="conv-list" scroll-y>
|
||||
<view v-if="ai.conversations.length === 0" class="empty-hint">
|
||||
<text>暂无历史会话</text>
|
||||
<text class="empty-sub">在桌面端创建后,此处同步</text>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-for="conv in ai.conversations"
|
||||
:key="conv.id"
|
||||
class="conv-item"
|
||||
:class="{ active: conv.id === ai.activeConversationId.value }"
|
||||
@click="handleSelect(conv.id)"
|
||||
>
|
||||
<view class="conv-main">
|
||||
<text class="conv-title">{{ conv.title || '(未命名会话)' }}</text>
|
||||
<text v-if="conv.lastMessage" class="conv-last">{{ conv.lastMessage }}</text>
|
||||
</view>
|
||||
<text v-if="conv.updatedAt" class="conv-time">{{ formatTime(conv.updatedAt) }}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.conv-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background-color: $uni-bg-color;
|
||||
}
|
||||
|
||||
.new-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 24rpx;
|
||||
background-color: $uni-bg-color-dark;
|
||||
border-bottom: 1rpx solid $uni-border-color;
|
||||
}
|
||||
|
||||
.new-icon {
|
||||
font-size: 40rpx;
|
||||
color: $uni-color-primary;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.new-text {
|
||||
font-size: $uni-font-size-base;
|
||||
color: $uni-color-primary;
|
||||
}
|
||||
|
||||
.conv-list {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 200rpx 24rpx;
|
||||
text {
|
||||
color: $uni-text-color-disable;
|
||||
font-size: $uni-font-size-base;
|
||||
}
|
||||
.empty-sub {
|
||||
margin-top: 12rpx;
|
||||
font-size: $uni-font-size-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.conv-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 24rpx;
|
||||
border-bottom: 1rpx solid $uni-border-color;
|
||||
&.active {
|
||||
background-color: $uni-bg-color-light;
|
||||
border-left: 6rpx solid $uni-color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.conv-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.conv-title {
|
||||
font-size: $uni-font-size-base;
|
||||
color: $uni-text-color;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.conv-last {
|
||||
margin-top: 8rpx;
|
||||
font-size: $uni-font-size-sm;
|
||||
color: $uni-text-color-grey;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.conv-time {
|
||||
font-size: $uni-font-size-sm;
|
||||
color: $uni-text-color-disable;
|
||||
margin-left: 16rpx;
|
||||
}
|
||||
</style>
|
||||
7
apps/df-miniapp/src/shims-uni.d.ts
vendored
Normal file
7
apps/df-miniapp/src/shims-uni.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/// <reference types="@dcloudio/types" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
|
||||
export default component
|
||||
}
|
||||
125
apps/df-miniapp/src/types/events.ts
Normal file
125
apps/df-miniapp/src/types/events.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* AiChatEvent 类型镜像(对齐 DevFlow 后端 Rust 定义)
|
||||
*
|
||||
* 类型证据:
|
||||
* - Rust 定义:src-tauri/src/commands/ai/mod.rs:105-216 `pub enum AiChatEvent`
|
||||
* (18 个变体,#[serde(tag = "type")] → TS 用 type discriminator 镜像)
|
||||
* - Rust 字段 snake_case:Rust 默认 snake_case 字段,TS 直接对齐(不加 camelCase 转换)
|
||||
* - 桌面 TS:src/api/types.ts(未在本批核验,但变体名/字段从 Rust 源码逐字对齐)
|
||||
*
|
||||
* 注意:TS 联合类型 discriminator 名为 `type`,与 Rust #[serde(tag = "type")] 一致。
|
||||
* 变体名 PascalCase(Rust enum 变体名原样,serde 默认 PascalCase 序列化)。
|
||||
*/
|
||||
|
||||
/** 错误源分类(对齐 Rust ErrorType,mod.rs:89-102,snake_case 序列化) */
|
||||
export type ErrorType = 'auth' | 'network' | 'timeout' | 'provider_config' | 'unknown'
|
||||
|
||||
/** 会话生命周期状态(对齐 Rust ConvState,L2 统一状态机,snake_case 序列化) */
|
||||
export type ConvState = 'idle' | 'generating' | 'stopping' | 'error' | 'compressed'
|
||||
|
||||
/**
|
||||
* AI 聊天事件联合类型(18 个变体,严格对齐 Rust mod.rs:107-216)
|
||||
*
|
||||
* 字段全部对齐 Rust serde(rename_all 默认 snake_case 字段名,tag = "type")。
|
||||
* 变体名 PascalCase 来自 Rust enum 变体名(serde 默认 PascalCase 序列化 enum variant)。
|
||||
*/
|
||||
export type AiChatEvent =
|
||||
| { type: 'AiTextDelta'; delta: string; conversation_id?: string | null }
|
||||
| { type: 'AiToolCallStarted'; id: string; name: string; args: unknown; conversation_id?: string | null }
|
||||
| { type: 'AiToolCallCompleted'; id: string; result: unknown; conversation_id?: string | null }
|
||||
| { type: 'AiToolAutoApproved'; id: string; tool: string; dir: string; conversation_id?: string | null }
|
||||
| {
|
||||
type: 'AiApprovalRequired'
|
||||
id: string
|
||||
name: string
|
||||
args: unknown
|
||||
reason: string
|
||||
diff?: string | null
|
||||
conversation_id?: string | null
|
||||
}
|
||||
| { type: 'AiApprovalResult'; id: string; approved: boolean; conversation_id?: string | null }
|
||||
| {
|
||||
type: 'AiCompleted'
|
||||
total_tokens: number
|
||||
prompt_tokens: number
|
||||
completion_tokens: number
|
||||
incomplete?: boolean | null
|
||||
conversation_id?: string | null
|
||||
}
|
||||
| { type: 'AiError'; error: string; error_type?: ErrorType | null; conversation_id?: string | null }
|
||||
| { type: 'AiAgentRound'; round: number; conversation_id?: string | null }
|
||||
| { type: 'AiHeartbeat'; conversation_id?: string | null }
|
||||
| { type: 'AiMaxRoundsReached'; conversation_id?: string | null }
|
||||
| { type: 'AiStreamRetry'; attempt: number; max_attempts: number; conversation_id?: string | null }
|
||||
| {
|
||||
type: 'AiDirAuthRequired'
|
||||
id: string
|
||||
tool: string
|
||||
path: string
|
||||
dir: string
|
||||
conversation_id?: string | null
|
||||
}
|
||||
| { type: 'AiContextCleared'; conversation_id?: string | null }
|
||||
| { type: 'AiCompressing'; conversation_id?: string | null }
|
||||
| { type: 'AiCompressed'; conversation_id?: string | null; summary: string }
|
||||
| {
|
||||
type: 'AiHelpRequired'
|
||||
reason: string
|
||||
context: string
|
||||
options: string[]
|
||||
conversation_id?: string | null
|
||||
}
|
||||
| { type: 'AiConvStateChanged'; conv_state: ConvState; conversation_id?: string | null }
|
||||
|
||||
/** AiChatEvent 的 type 字面量集合(用于 narrowing / 分派) */
|
||||
export type AiChatEventType = AiChatEvent['type']
|
||||
|
||||
/** 会话信息(对齐桌面端 list_conversations 返回结构,字段名 camelCase 兼容桌面 IPC 约定) */
|
||||
export interface Conversation {
|
||||
id: string
|
||||
title: string
|
||||
/** 最近一条消息摘要(可选) */
|
||||
lastMessage?: string
|
||||
/** 最近更新时间戳(ms) */
|
||||
updatedAt?: number
|
||||
/** 创建时间(ms) */
|
||||
createdAt?: number
|
||||
}
|
||||
|
||||
/** 本地渲染消息气泡(对齐桌面端 AiMessage 结构,简化为 miniapp 必需字段) */
|
||||
export interface ChatMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
content: string
|
||||
timestamp: number
|
||||
isError?: boolean
|
||||
/** 工具调用列表(对齐 AiToolCallInfo) */
|
||||
toolCalls?: AiToolCallInfo[]
|
||||
/** 不完整标记(AiCompleted{incomplete:true} 对应系统提示气泡) */
|
||||
incomplete?: boolean
|
||||
}
|
||||
|
||||
/** 工具调用信息(对齐桌面 useAiEvents.ts:399 的 AiToolCallInfo) */
|
||||
export interface AiToolCallInfo {
|
||||
id: string
|
||||
name: string
|
||||
args: unknown
|
||||
result?: unknown
|
||||
status: 'running' | 'completed' | 'failed' | 'pending_approval' | 'rejected'
|
||||
reason?: string
|
||||
diff?: string
|
||||
/** 审批类型(risk 普通 / path 路径授权,对齐后端 ApprovalKind) */
|
||||
kind?: 'risk' | 'path'
|
||||
dir?: string
|
||||
path?: string
|
||||
}
|
||||
|
||||
/** 挂起审批项(复用 AiToolCallInfo 结构,miniapp 不实现审批 UI 仅展示) */
|
||||
export type PendingApproval = AiToolCallInfo
|
||||
|
||||
/** Token 用量(对齐桌面端 state.lastTokenUsage) */
|
||||
export interface TokenUsage {
|
||||
prompt: number
|
||||
completion: number
|
||||
total: number
|
||||
}
|
||||
110
apps/df-miniapp/src/types/relay.ts
Normal file
110
apps/df-miniapp/src/types/relay.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* df-relay 协议类型镜像(对齐 Rust crates/df-relay/src/)
|
||||
*
|
||||
* 类型证据:
|
||||
* - ClientKind / MessageKind:crates/df-relay/src/broadcast.rs:21-41
|
||||
* (#[serde(rename_all = "snake_case")] → 'device'/'miniapp', 'event'/'command'/'control')
|
||||
* - BroadcastMessage:broadcast.rs:48-62(device_id/kind/source/from/payload/ts)
|
||||
* - Hello 握手帧:crates/df-relay/src/relay.rs:44-53(kind/device_id/token)
|
||||
* kind 用 ClientKindWire(relay.rs:56-61),序列化为 'device'/'miniapp'
|
||||
* - 路由端点:relay.rs:177-183 `/ws/miniapp`(小程序专用端点)
|
||||
*
|
||||
* 关键协议点(relay.rs:332-365 handle_inbound_text):
|
||||
* relay 不解析 payload —— miniapp 入站文本帧直接当 payload 包成 BroadcastMessage 路由给 device。
|
||||
* 故 miniapp 发送时,直接序列化 Command JSON(relay 自动包 payload),不需客户端构造 BroadcastMessage。
|
||||
* 接收时,device 事件经 relay 包成完整 BroadcastMessage 序列化下发,miniapp 需先解 BroadcastMessage
|
||||
* 再从 payload 取 AiChatEvent。
|
||||
*/
|
||||
|
||||
/** 客户端类型(对齐 Rust ClientKind,snake_case 序列化,broadcast.rs:21-28) */
|
||||
export type ClientKind = 'device' | 'miniapp'
|
||||
|
||||
/** 消息语义分类(对齐 Rust MessageKind,snake_case 序列化,broadcast.rs:35-41) */
|
||||
export type MessageKind = 'event' | 'command' | 'control'
|
||||
|
||||
/** 在线状态(对齐 Rust PresenceState,snake_case 序列化,broadcast.rs:119-126) */
|
||||
export type PresenceState = 'online' | 'offline'
|
||||
|
||||
/** ConnId 是 Rust u64(对齐 conn.rs ConnId struct),TS 用 number */
|
||||
export type ConnId = number
|
||||
|
||||
/**
|
||||
* 中继路由消息骨架(对齐 Rust BroadcastMessage,broadcast.rs:48-62)
|
||||
*
|
||||
* miniapp 接收时 payload 为 AiChatEvent JSON(relay 透传不解析)。
|
||||
*/
|
||||
export interface BroadcastMessage {
|
||||
/** 配对绑定的设备 ID */
|
||||
device_id: string
|
||||
/** 消息语义 */
|
||||
kind: MessageKind
|
||||
/** 消息来源连接 ID(relay 分配,u64) */
|
||||
source: ConnId
|
||||
/** 来源客户端类型 */
|
||||
from: ClientKind
|
||||
/** 载荷(原始 JSON,relay 透传不解析 —— Event 时为 AiChatEvent) */
|
||||
payload: unknown
|
||||
/** 时间戳(ms) */
|
||||
ts: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户端首消息:身份宣告(对齐 Rust Hello,relay.rs:44-53)
|
||||
*
|
||||
* 小程序连 `/ws/miniapp` 后首帧必须发此结构,否则 relay 关连接(relay.rs:197-208)。
|
||||
* kind 恒为 'miniapp'(与端点匹配,relay.rs:212-225 校验,不匹配则关连接)。
|
||||
*/
|
||||
export interface Hello {
|
||||
/** 客户端类型,小程序恒 'miniapp'(对齐 ClientKindWire) */
|
||||
kind: 'miniapp' | 'device'
|
||||
/** 配对绑定的设备 ID(用户首次配置时生成) */
|
||||
device_id: string
|
||||
/** 配对 token(env DF_RELAY_TOKEN,fallback "devflow-relay-default-token") */
|
||||
token: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 控制面消息(对齐 Rust ControlMessage,broadcast.rs:132-145,tag = "control_kind")
|
||||
*
|
||||
* miniapp 主要发 Heartbeat 心跳 + Ping;Pair 在首次绑定时用(本批未实现绑定流程)。
|
||||
*/
|
||||
export type ControlMessage =
|
||||
| { control_kind: 'pair'; device_id: string; token: string }
|
||||
| { control_kind: 'heartbeat'; device_id: string }
|
||||
| { control_kind: 'presence'; device_id: string; state: PresenceState }
|
||||
| { control_kind: 'ping' }
|
||||
| { control_kind: 'pong' }
|
||||
|
||||
/**
|
||||
* 小程序 → 桌面端 Command 协议(miniapp 发起操作)
|
||||
*
|
||||
* 类型为对齐 Tauri command 名,relay 透传 device_id 路由到桌面端,桌面端 invoke 对应 command。
|
||||
* 字段:
|
||||
* - cmd:命令名(对齐 src-tauri command 函数名)
|
||||
* - args:命令参数(对齐各 command 的参数)
|
||||
*
|
||||
* MVP 三条(send_message / stop / switch_conversation),其余审批/重试等留后续批。
|
||||
*/
|
||||
export interface MiniCommand {
|
||||
/** Tauri command 名 */
|
||||
cmd: string
|
||||
/** 命令参数(原始对象,对齐各 command 参数签名) */
|
||||
args: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** send_message 命令参数(对齐 src-tauri ai_chat_send command) */
|
||||
export interface SendMessageArgs {
|
||||
message: string
|
||||
conversation_id?: string | null
|
||||
model_override?: string | null
|
||||
}
|
||||
|
||||
/** switch_conversation 命令参数 */
|
||||
export interface SwitchConversationArgs {
|
||||
conversation_id: string
|
||||
}
|
||||
|
||||
/** stop 命令参数(对齐 ai_chat_stop) */
|
||||
export interface StopArgs {
|
||||
conversation_id?: string | null
|
||||
}
|
||||
25
apps/df-miniapp/tsconfig.json
Normal file
25
apps/df-miniapp/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"jsx": "preserve",
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"types": ["@dcloudio/types", "@types/node"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"exclude": ["node_modules", "dist", "unpackage"]
|
||||
}
|
||||
33
apps/df-miniapp/uni.scss
Normal file
33
apps/df-miniapp/uni.scss
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 全局样式变量(uni-app 规范:uni.scss 由 uni-app 自动引入到各页面/组件)
|
||||
*
|
||||
* 颜色基调对齐桌面端暗色主题(便于跨端视觉一致)。
|
||||
*/
|
||||
|
||||
/* 主色调 */
|
||||
$uni-color-primary: #4a9eff;
|
||||
$uni-color-success: #4cd964;
|
||||
$uni-color-warning: #f0ad4e;
|
||||
$uni-color-error: #dd524d;
|
||||
|
||||
/* 中性色(暗色背景适配) */
|
||||
$uni-bg-color: #0f0f0f;
|
||||
$uni-bg-color-dark: #1a1a1a;
|
||||
$uni-bg-color-light: #2a2a2a;
|
||||
$uni-text-color: #e0e0e0;
|
||||
$uni-text-color-grey: #999999;
|
||||
$uni-text-color-disable: #666666;
|
||||
$uni-border-color: #333333;
|
||||
|
||||
/* 字号 */
|
||||
$uni-font-size-sm: 24rpx;
|
||||
$uni-font-size-base: 28rpx;
|
||||
$uni-font-size-lg: 32rpx;
|
||||
|
||||
/* 气泡角色配色 */
|
||||
$uni-bubble-user-bg: #4a9eff;
|
||||
$uni-bubble-user-text: #ffffff;
|
||||
$uni-bubble-assistant-bg: #2a2a2a;
|
||||
$uni-bubble-assistant-text: #e0e0e0;
|
||||
$uni-bubble-error-bg: #3a1a1a;
|
||||
$uni-bubble-error-text: #ff6b6b;
|
||||
Reference in New Issue
Block a user