重构: 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:
2026-06-22 02:19:23 +08:00
parent 280baeaee0
commit a5a4fc887a
19 changed files with 3 additions and 8 deletions

View 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>

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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": "会话"
}
]
}
}

View 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>

View 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()

View 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,
}
}

View 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 }
}

View 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,
}
}

View 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>

View 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
View 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
}

View 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
}

View 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
}

View 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
View 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;