virlen-remote 0.4.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +362 -137
- package/README.md +389 -201
- package/dist/index.d.ts +12 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -1
- package/dist/protocol/agents.d.ts +14 -0
- package/dist/protocol/agents.d.ts.map +1 -0
- package/dist/protocol/agents.js +14 -0
- package/dist/protocol/agents.js.map +1 -0
- package/dist/protocol/api.d.ts +422 -1
- package/dist/protocol/api.d.ts.map +1 -1
- package/dist/protocol/api.js.map +1 -1
- package/dist/protocol/compress.d.ts +55 -0
- package/dist/protocol/compress.d.ts.map +1 -0
- package/dist/protocol/compress.js +60 -0
- package/dist/protocol/compress.js.map +1 -0
- package/dist/protocol/files.d.ts +261 -0
- package/dist/protocol/files.d.ts.map +1 -0
- package/dist/protocol/files.js +506 -0
- package/dist/protocol/files.js.map +1 -0
- package/dist/protocol/host.d.ts +41 -1
- package/dist/protocol/host.d.ts.map +1 -1
- package/dist/protocol/host.js +10 -1
- package/dist/protocol/host.js.map +1 -1
- package/dist/protocol/message-actions.d.ts +58 -0
- package/dist/protocol/message-actions.d.ts.map +1 -0
- package/dist/protocol/message-actions.js +38 -0
- package/dist/protocol/message-actions.js.map +1 -0
- package/dist/protocol/message-files.d.ts +79 -0
- package/dist/protocol/message-files.d.ts.map +1 -0
- package/dist/protocol/message-files.js +118 -0
- package/dist/protocol/message-files.js.map +1 -0
- package/dist/protocol/tool-args.d.ts +95 -0
- package/dist/protocol/tool-args.d.ts.map +1 -0
- package/dist/protocol/tool-args.js +439 -0
- package/dist/protocol/tool-args.js.map +1 -0
- package/dist/testing/mock-host.d.ts +44 -1
- package/dist/testing/mock-host.d.ts.map +1 -1
- package/dist/testing/mock-host.js +487 -8
- package/dist/testing/mock-host.js.map +1 -1
- package/dist/transport/rtc.d.ts +17 -0
- package/dist/transport/rtc.d.ts.map +1 -1
- package/dist/transport/rtc.js +49 -12
- package/dist/transport/rtc.js.map +1 -1
- package/dist/transport/signaling.d.ts +12 -1
- package/dist/transport/signaling.d.ts.map +1 -1
- package/dist/transport/signaling.js +17 -3
- package/dist/transport/signaling.js.map +1 -1
- package/package.json +69 -69
- package/src/index.ts +270 -179
- package/src/protocol/agents.ts +13 -0
- package/src/protocol/api.ts +960 -553
- package/src/protocol/compress.ts +65 -0
- package/src/protocol/files.ts +544 -0
- package/src/protocol/host.ts +205 -150
- package/src/protocol/message-actions.ts +60 -0
- package/src/protocol/message-files.ts +161 -0
- package/src/protocol/pairing.ts +254 -254
- package/src/protocol/tool-args.ts +478 -0
- package/src/testing/mock-host.ts +1285 -691
- package/src/transport/link-kind.ts +222 -222
- package/src/transport/rtc.ts +381 -344
- package/src/transport/signaling.ts +331 -317
- package/src/transport/transfer-tier.ts +64 -64
package/src/protocol/host.ts
CHANGED
|
@@ -1,150 +1,205 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Host 侧通用胶水(电脑端「接口层」的与传输无关的部分)。
|
|
3
|
-
*
|
|
4
|
-
* 设计:**把「方法分发」与「数据来源」解耦**,用 `HostDataSource` 端口隔离。
|
|
5
|
-
* 于是同一份 bridge 代码有两个数据源实现:
|
|
6
|
-
* - 真实:`virlen-app/src/bridge/`,接 `sessionStore` / `chat-service`(含 ACL / 审计)
|
|
7
|
-
* - 演示/测试:mock 数据源(`virlen-remote/testing`)
|
|
8
|
-
* 这样 M2 的浏览器 harness(mock)与 M3 的真实桌面端(真实源)复用同一分发逻辑,
|
|
9
|
-
* 且 mock 让「不依赖 WebRTC 的端到端联调」成为可能。
|
|
10
|
-
*
|
|
11
|
-
* 事件用 `HostEmit` 回填:host 侧(store-bridge / mock)通过它把 `HostEvents` 推给手机。
|
|
12
|
-
*/
|
|
13
|
-
import type { Endpoint } from './endpoint'
|
|
14
|
-
import type {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
*
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}
|
|
150
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Host 侧通用胶水(电脑端「接口层」的与传输无关的部分)。
|
|
3
|
+
*
|
|
4
|
+
* 设计:**把「方法分发」与「数据来源」解耦**,用 `HostDataSource` 端口隔离。
|
|
5
|
+
* 于是同一份 bridge 代码有两个数据源实现:
|
|
6
|
+
* - 真实:`virlen-app/src/bridge/`,接 `sessionStore` / `chat-service`(含 ACL / 审计)
|
|
7
|
+
* - 演示/测试:mock 数据源(`virlen-remote/testing`)
|
|
8
|
+
* 这样 M2 的浏览器 harness(mock)与 M3 的真实桌面端(真实源)复用同一分发逻辑,
|
|
9
|
+
* 且 mock 让「不依赖 WebRTC 的端到端联调」成为可能。
|
|
10
|
+
*
|
|
11
|
+
* 事件用 `HostEmit` 回填:host 侧(store-bridge / mock)通过它把 `HostEvents` 推给手机。
|
|
12
|
+
*/
|
|
13
|
+
import type { Endpoint } from './endpoint'
|
|
14
|
+
import type {
|
|
15
|
+
AgentOptionDTO,
|
|
16
|
+
AnswerParams,
|
|
17
|
+
AnswerResult,
|
|
18
|
+
CompressParams,
|
|
19
|
+
ContextInfoDTO,
|
|
20
|
+
ContextParams,
|
|
21
|
+
CreateSessionParams,
|
|
22
|
+
DeleteMessageParams,
|
|
23
|
+
DeleteSessionParams,
|
|
24
|
+
FileListParams,
|
|
25
|
+
FileListResult,
|
|
26
|
+
FileReadParams,
|
|
27
|
+
FileReadResult,
|
|
28
|
+
FileWriteAbortParams,
|
|
29
|
+
FileWriteBeginParams,
|
|
30
|
+
FileWriteBeginResult,
|
|
31
|
+
FileWriteChunkParams,
|
|
32
|
+
FileWriteChunkResult,
|
|
33
|
+
FileWriteFinishParams,
|
|
34
|
+
FileWriteFinishResult,
|
|
35
|
+
HostEvents,
|
|
36
|
+
InteractionDTO,
|
|
37
|
+
MessageDTO,
|
|
38
|
+
ModelProviderDTO,
|
|
39
|
+
MsgPageDTO,
|
|
40
|
+
MsgPageParams,
|
|
41
|
+
PinSessionParams,
|
|
42
|
+
RenameSessionParams,
|
|
43
|
+
SendParams,
|
|
44
|
+
SessionSummaryDTO,
|
|
45
|
+
SetModelParams,
|
|
46
|
+
WorkspaceOptionDTO,
|
|
47
|
+
} from './api'
|
|
48
|
+
import type { ClientInfo, HelloParams, HelloResult } from './hello'
|
|
49
|
+
|
|
50
|
+
/** 电脑端接口层的数据来源(返回已投影好的 DTO,投影逻辑在实现侧)。 */
|
|
51
|
+
export interface HostDataSource {
|
|
52
|
+
listSessions(): SessionSummaryDTO[] | Promise<SessionSummaryDTO[]>
|
|
53
|
+
getMessages(params: MsgPageParams): MsgPageDTO | Promise<MsgPageDTO>
|
|
54
|
+
getMessage(params: { sessionId: string; messageId: string }): MessageDTO | Promise<MessageDTO>
|
|
55
|
+
send(params: SendParams): { messageId: string } | Promise<{ messageId: string }>
|
|
56
|
+
cancel(params: { sessionId: string }): { ok: true } | Promise<{ ok: true }>
|
|
57
|
+
/** 从暂停的 run 快照恢复执行(M5,与手机端「暂存」配对)。 */
|
|
58
|
+
resume(params: { sessionId: string }): { ok: true } | Promise<{ ok: true }>
|
|
59
|
+
subscribe(params: { sessionId: string; fromRowid?: number }): { ok: true } | Promise<{ ok: true }>
|
|
60
|
+
answer(params: AnswerParams): AnswerResult | Promise<AnswerResult>
|
|
61
|
+
/** 当前待应答交互(拉取式;手机每次链路就绪后调一次,补齐错过的 `requested` 事件)。 */
|
|
62
|
+
listInteractions(): InteractionDTO[] | Promise<InteractionDTO[]>
|
|
63
|
+
// ── M4 写操作(§16.1)──
|
|
64
|
+
createSession(params: CreateSessionParams): { sessionId: string } | Promise<{ sessionId: string }>
|
|
65
|
+
renameSession(params: RenameSessionParams): { ok: true } | Promise<{ ok: true }>
|
|
66
|
+
setPinned(params: PinSessionParams): { ok: true } | Promise<{ ok: true }>
|
|
67
|
+
/** ⚠️ 不可逆;实现侧必须校验 `confirm === true`(不得依赖手机 UI)。 */
|
|
68
|
+
deleteSession(params: DeleteSessionParams): { ok: true } | Promise<{ ok: true }>
|
|
69
|
+
/**
|
|
70
|
+
* 删除单条消息及其之后的全部消息(截断)。
|
|
71
|
+
*
|
|
72
|
+
* ⚠️ 实现侧必须自己把三道闸都做上(不得依赖手机 UI):
|
|
73
|
+
* 1. `confirm === true`(缺则 `E_CONFIRM_REQUIRED`);
|
|
74
|
+
* 2. 会话**正在回复**时拒(`E_BUSY`)—— 删断正在跑的 run 会留下悬空工具调用;
|
|
75
|
+
* 3. 目标是 `role:'tool'` 的消息时拒(`E_BAD_REQUEST`)—— 工具结果与发起它的
|
|
76
|
+
* assistant 消息是一体两面,单独删掉只会留下悬空调用。
|
|
77
|
+
*
|
|
78
|
+
* 结果不在应答里回传:实现侧负责推 `host.event.session.messages.reset`,
|
|
79
|
+
* 客户端重拉窗口(与压缩同一条一致性策略)。
|
|
80
|
+
*/
|
|
81
|
+
deleteMessage(params: DeleteMessageParams): { ok: true } | Promise<{ ok: true }>
|
|
82
|
+
/** 可选:覆盖默认 hello 应答。 */
|
|
83
|
+
hello?(params: HelloParams): HelloResult | Promise<HelloResult>
|
|
84
|
+
// ── §22:模型 / 工作目录 / 上下文 ──
|
|
85
|
+
/** 已启用的模型服务与模型(白名单:不含 apiKey / baseUrl)。 */
|
|
86
|
+
listModels(): ModelProviderDTO[] | Promise<ModelProviderDTO[]>
|
|
87
|
+
/** 切换会话模型;实现侧必须校验「服务已启用且模型存在」。 */
|
|
88
|
+
setModel(params: SetModelParams): { ok: true } | Promise<{ ok: true }>
|
|
89
|
+
/** 新建会话可选的 Agent 候选集(**只用于新建**,已有会话不可改)。 */
|
|
90
|
+
listAgents(): AgentOptionDTO[] | Promise<AgentOptionDTO[]>
|
|
91
|
+
/** 新建会话可选的工作目录候选集(**只用于新建**,已有会话不可改)。 */
|
|
92
|
+
listWorkspaces(): WorkspaceOptionDTO[] | Promise<WorkspaceOptionDTO[]>
|
|
93
|
+
/** 上下文占用快照(口径与桌面 token 环一致)。 */
|
|
94
|
+
getContext(params: ContextParams): ContextInfoDTO | Promise<ContextInfoDTO>
|
|
95
|
+
/**
|
|
96
|
+
* 压缩上下文(fire-and-forget)。
|
|
97
|
+
*
|
|
98
|
+
* ⚠️ 实现侧必须校验 `confirm === true`(不得依赖手机 UI),并自行保证「正在回复 / 正在压缩」不被并发触发。
|
|
99
|
+
*/
|
|
100
|
+
compress(params: CompressParams): { ok: true } | Promise<{ ok: true }>
|
|
101
|
+
// ── §37:工作目录文件 ──
|
|
102
|
+
/**
|
|
103
|
+
* 列目录(实现侧必须自己过安全校验与 ACL)。
|
|
104
|
+
*
|
|
105
|
+
* ⚠️ 实现侧还要自己判「这条链路能不能传文件」(非中继):那是**链路事实**,
|
|
106
|
+
* 不在本文档层能看到的范围里(见 `TransferTier` / `LinkKind`)。
|
|
107
|
+
*/
|
|
108
|
+
listFiles(params: FileListParams): FileListResult | Promise<FileListResult>
|
|
109
|
+
/** 读一个分块(预览与下载同一条路)。 */
|
|
110
|
+
readFile(params: FileReadParams): FileReadResult | Promise<FileReadResult>
|
|
111
|
+
/** 上传:开始(校验 + 定名 + 建临时文件)。 */
|
|
112
|
+
beginFileWrite(params: FileWriteBeginParams): FileWriteBeginResult | Promise<FileWriteBeginResult>
|
|
113
|
+
/** 上传:写一块(偏移必须与已接收字节数一致)。 */
|
|
114
|
+
writeFileChunk(params: FileWriteChunkParams): FileWriteChunkResult | Promise<FileWriteChunkResult>
|
|
115
|
+
/** 上传:收尾(临时文件 → 目标名,原子落盘)。 */
|
|
116
|
+
finishFileWrite(params: FileWriteFinishParams): FileWriteFinishResult | Promise<FileWriteFinishResult>
|
|
117
|
+
/** 上传:放弃(删临时文件)。 */
|
|
118
|
+
abortFileWrite(params: FileWriteAbortParams): { ok: true } | Promise<{ ok: true }>
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export type HostEmit = <E extends keyof HostEvents & string>(topic: E, payload: HostEvents[E]) => void
|
|
122
|
+
|
|
123
|
+
export interface HostRegistration {
|
|
124
|
+
/** 把一条 `HostEvents` 推给对端(手机)。 */
|
|
125
|
+
emit: HostEmit
|
|
126
|
+
/** 注销全部 handler 与订阅。 */
|
|
127
|
+
dispose(): void
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface RegisterHostOptions {
|
|
131
|
+
hostInfo?: ClientInfo
|
|
132
|
+
/** 本机声明的能力集(会与手机 hello 的能力取交集,见 hello.ts)。 */
|
|
133
|
+
capabilities?: string[]
|
|
134
|
+
paired?: boolean
|
|
135
|
+
deviceName?: string
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const DEFAULT_CAPABILITIES = ['session.list', 'session.send', 'interaction.answer', 'stream.delta']
|
|
139
|
+
|
|
140
|
+
export function registerHostHandlers(
|
|
141
|
+
endpoint: Endpoint,
|
|
142
|
+
source: HostDataSource,
|
|
143
|
+
options: RegisterHostOptions = {},
|
|
144
|
+
): HostRegistration {
|
|
145
|
+
const disposers: Array<() => void> = []
|
|
146
|
+
|
|
147
|
+
const emit: HostEmit = (topic, payload) => {
|
|
148
|
+
endpoint.emit(topic, payload)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const on = (method: string, handler: (params: unknown) => unknown | Promise<unknown>) => {
|
|
152
|
+
disposers.push(endpoint.handle(method, handler))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
on('host.hello', async (params) => {
|
|
156
|
+
if (source.hello) return source.hello(params as HelloParams)
|
|
157
|
+
const hello = params as Partial<HelloParams>
|
|
158
|
+
return {
|
|
159
|
+
protocolVersion: hello.protocolVersion ?? 1,
|
|
160
|
+
host: options.hostInfo ?? { platform: 'web-harness', appVersion: '0.0.0' },
|
|
161
|
+
capabilities: options.capabilities ?? DEFAULT_CAPABILITIES,
|
|
162
|
+
paired: options.paired ?? true,
|
|
163
|
+
deviceName: options.deviceName ?? 'Virlen 电脑(演示)',
|
|
164
|
+
} satisfies HelloResult
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
on('host.session.list', async () => ({ sessions: await source.listSessions() }))
|
|
168
|
+
on('host.session.messages', async (params) => source.getMessages(params as MsgPageParams))
|
|
169
|
+
on('host.session.message.get', async (params) => {
|
|
170
|
+
const p = params as { sessionId: string; messageId: string }
|
|
171
|
+
return { message: await source.getMessage(p) }
|
|
172
|
+
})
|
|
173
|
+
on('host.session.send', async (params) => source.send(params as SendParams))
|
|
174
|
+
on('host.session.cancel', async (params) => source.cancel(params as { sessionId: string }))
|
|
175
|
+
on('host.session.resume', async (params) => source.resume(params as { sessionId: string }))
|
|
176
|
+
on('host.session.subscribe', async (params) => source.subscribe(params as { sessionId: string; fromRowid?: number }))
|
|
177
|
+
on('host.session.create', async (params) => source.createSession(params as CreateSessionParams))
|
|
178
|
+
on('host.session.rename', async (params) => source.renameSession(params as RenameSessionParams))
|
|
179
|
+
on('host.session.pin', async (params) => source.setPinned(params as PinSessionParams))
|
|
180
|
+
on('host.session.delete', async (params) => source.deleteSession(params as DeleteSessionParams))
|
|
181
|
+
on('host.session.message.delete', async (params) => source.deleteMessage(params as DeleteMessageParams))
|
|
182
|
+
on('host.interaction.answer', async (params) => source.answer(params as AnswerParams))
|
|
183
|
+
on('host.interaction.list', async () => ({ interactions: await source.listInteractions() }))
|
|
184
|
+
// ── §22:模型 / Agent / 工作目录 / 上下文 ──
|
|
185
|
+
on('host.model.list', async () => ({ providers: await source.listModels() }))
|
|
186
|
+
on('host.session.setModel', async (params) => source.setModel(params as SetModelParams))
|
|
187
|
+
on('host.agent.list', async () => ({ agents: await source.listAgents() }))
|
|
188
|
+
on('host.workspace.list', async () => ({ workspaces: await source.listWorkspaces() }))
|
|
189
|
+
on('host.session.context', async (params) => source.getContext(params as ContextParams))
|
|
190
|
+
on('host.session.compress', async (params) => source.compress(params as CompressParams))
|
|
191
|
+
// ── §37:工作目录文件 ──
|
|
192
|
+
on('host.file.list', async (params) => source.listFiles(params as FileListParams))
|
|
193
|
+
on('host.file.read', async (params) => source.readFile(params as FileReadParams))
|
|
194
|
+
on('host.file.write.begin', async (params) => source.beginFileWrite(params as FileWriteBeginParams))
|
|
195
|
+
on('host.file.write.chunk', async (params) => source.writeFileChunk(params as FileWriteChunkParams))
|
|
196
|
+
on('host.file.write.finish', async (params) => source.finishFileWrite(params as FileWriteFinishParams))
|
|
197
|
+
on('host.file.write.abort', async (params) => source.abortFileWrite(params as FileWriteAbortParams))
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
emit,
|
|
201
|
+
dispose() {
|
|
202
|
+
for (const dispose of disposers) dispose()
|
|
203
|
+
},
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 消息级操作(引用 / 删除)—— **两端共用的契约**:能力名 + 引用引用体。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么这两个东西要放在共享包里
|
|
5
|
+
*
|
|
6
|
+
* 与 §33 的传输档位同一条理由:它们都是**两端各表一半**的约定。
|
|
7
|
+
* - **引用**:手机端发 `SendParams.quotes`,电脑端把它组装成 `{type:'quote'}` 内容块;
|
|
8
|
+
* 手机端还要把 `MessageDTO.quotes` 渲染成引用条。字段名、角色取值域、快照语义
|
|
9
|
+
* 写两份必然漂移(一边 `quote`、一边 `quotes`,或一边 `assistant`、一边 `ai`)。
|
|
10
|
+
* - **删除**:手机端长按菜单里的「删除」项**只在电脑端声明能力时才出现** ——
|
|
11
|
+
* 能力名是这里的常量,不是两边各写一遍的字面量(§33 的 `typeof` 绑定同一条教训)。
|
|
12
|
+
*
|
|
13
|
+
* ## 能力的两类语义(与 §33 的 `MESSAGE_DETAIL_CAPABILITY` 一致)
|
|
14
|
+
*
|
|
15
|
+
* 本包里导出的能力名有两种角色,**消费方必须分清**:
|
|
16
|
+
* - **功能标记**(`MESSAGE_QUOTE_CAPABILITY` 与 §33 的 `MESSAGE_DETAIL_CAPABILITY`):
|
|
17
|
+
* 只用来决定「界面给不给这个入口」,**不会被 ACL 的 `assert()` 拦**——引用本身就是
|
|
18
|
+
* `session.send` 的一个参数,不是新的权限;
|
|
19
|
+
* - **权限**(`MESSAGE_DELETE_CAPABILITY`):删消息是**破坏性操作**,电脑端必须独立
|
|
20
|
+
* `assert` 它,缺失即 `E_DENIED`——手机端不显示按钮**不是**防线(§7-⑪ 的教训)。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 一条被引用消息的**快照引用**。
|
|
25
|
+
*
|
|
26
|
+
* ⚠️ `text` 是**快照**,不是外键:被引用的原消息可能随后被删除、被上下文压缩替换、
|
|
27
|
+
* 或滚出手机端的已加载窗口(§20.2 分页)—— 只存 `messageId` 的话,发给模型的引用内容
|
|
28
|
+
* 会在那些情形下凭空缺失,而用户界面上一无所知。这与桌面输入框的 `QuoteAttachment`
|
|
29
|
+
* **同一条口径**(`ui/pages/chat/components/input/hooks.ts`)。
|
|
30
|
+
*
|
|
31
|
+
* 角色取值域只有 `user` / `assistant`:**只有「有正文的两方发言」可被引用**。
|
|
32
|
+
* `tool`(工具输出)与 `system`(压缩摘要)不是对话发言,引用它们没有语义
|
|
33
|
+
* (桌面端的右键菜单同样不给它们「引用」项)。
|
|
34
|
+
*/
|
|
35
|
+
export interface MessageQuote {
|
|
36
|
+
/** 被引用消息的 id(同时用作 UI 列表 key / 去重键)。 */
|
|
37
|
+
messageId: string
|
|
38
|
+
/** 被引用消息的发送方。 */
|
|
39
|
+
role: 'user' | 'assistant'
|
|
40
|
+
/** 被引用消息的正文快照(原消息被删 / 被压缩后,引文仍然完整)。 */
|
|
41
|
+
text: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 手机端声明它 = **长按气泡的菜单里提供「引用」**,并能把引用条渲染出来。
|
|
46
|
+
* 电脑端列出它 = **本机认识 `SendParams.quotes` 与 `MessageDTO.quotes`**。
|
|
47
|
+
*
|
|
48
|
+
* 为什么必须有这道闸(不是可选的礼貌):**已部署的旧电脑端不认 `quotes` 参数** ——
|
|
49
|
+
* 它是普通字段,RPC 会正常成功,引文被静默丢掉。用户侧的表现是「我明明引用了,
|
|
50
|
+
* AI 却当没看见」,且没有任何报错可查。宁可对旧电脑端不显示这个入口。
|
|
51
|
+
*/
|
|
52
|
+
export const MESSAGE_QUOTE_CAPABILITY = 'message.quote'
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 删除单条消息(及其后全部)的能力名 —— 电脑端 ACL 必须声明它,手机端才显示「删除」项。
|
|
56
|
+
*
|
|
57
|
+
* 与上面那个不同,**这是权限而不是功能标记**:电脑端在 handler 里独立 `assert`,
|
|
58
|
+
* 未授权即 `E_DENIED`。手机端隐藏入口只是 UI 收敛,绝不构成防线(§7-⑪)。
|
|
59
|
+
*/
|
|
60
|
+
export const MESSAGE_DELETE_CAPABILITY = 'message.delete'
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 消息里的**文件引用** —— **两端共用的契约**:能力名 + 引用体 + 校验口径。
|
|
3
|
+
*
|
|
4
|
+
* ## 这是什么(与桌面输入框的「文件附件」同一条口径)
|
|
5
|
+
*
|
|
6
|
+
* 手机端在文件面板里挑一个电脑上的文件,把它的**路径 + 展示元数据**挂到要发的那条消息上;
|
|
7
|
+
* 电脑端把它组装成 `{type:'file'}` 内容块交给引擎。**不搬运文件内容** ——
|
|
8
|
+
* 真正的读取交给 AI 用 `read_file` 工具按需完成(桌面端 `FileAttachment` 的文件头
|
|
9
|
+
* 写着同一条:「只存路径,不拷贝文件内容」)。
|
|
10
|
+
*
|
|
11
|
+
* ## 为什么必须放在共享包里
|
|
12
|
+
*
|
|
13
|
+
* 与 §36 的引用(`message-actions.ts`)同一条理由:它是**两端各表一半**的约定 ——
|
|
14
|
+
* 手机端发 `SendParams.files` 并把 `MessageDTO.files` 渲染成 chip,电脑端把前者组装成内容块、
|
|
15
|
+
* 把后者投影出来。字段名(`path` / `isDir` / `size`)与「路径分隔符统一成 `/`」的归一化
|
|
16
|
+
* 各写一份必然漂移,而漂移的症状是**静默的**:元数据对不上时 chip 显示成另一个东西,
|
|
17
|
+
* 没有任何报错可查。
|
|
18
|
+
*
|
|
19
|
+
* ## 校验为什么也在这里(而不是电脑侧自己写一份)
|
|
20
|
+
*
|
|
21
|
+
* `sanitizeFileRefs` 是**两端唯一的校验口径**:真电脑侧(`virlen-app/src/bridge/host-source.ts`)
|
|
22
|
+
* 与演示宿主(`testing/mock-host.ts`)都调它。若各写一份,mock 与真机会在「什么样的 `files`
|
|
23
|
+
* 算非法」上分叉 —— 而 mock 是单测的唯一参照物,分叉等于测试对着假行为发绿灯。
|
|
24
|
+
*
|
|
25
|
+
* ## 能力位的语义(与 §36 的 `MESSAGE_QUOTE_CAPABILITY` 一致)
|
|
26
|
+
*
|
|
27
|
+
* `MESSAGE_FILE_CAPABILITY` 是**功能标记**,不是权限:文件引用本身就是 `session.send` 的一个
|
|
28
|
+
* 参数(越权防线仍是 ACL 里的 `session.send`)。写进本表只为让手机端知道
|
|
29
|
+
* 「本机电脑端认识 `files`」—— 旧电脑端会把未知字段**静默丢掉**,用户侧的表现是
|
|
30
|
+
* 「我明明附了文件,AI 却当没看见」,且没有任何报错可查。宁可对旧电脑端不显示这个入口。
|
|
31
|
+
*/
|
|
32
|
+
import { FILE_NAME_MAX_LEN } from './files'
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 手机端声明它 = **文件面板能给「引用」入口**,并能渲染 `MessageDTO.files` 的 chip。
|
|
36
|
+
* 电脑端列出它 = **本机认识 `SendParams.files` 与 `MessageDTO.files`**。
|
|
37
|
+
*/
|
|
38
|
+
export const MESSAGE_FILE_CAPABILITY = 'message.file'
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 一条消息最多能带几个文件引用。
|
|
42
|
+
*
|
|
43
|
+
* 上限的意义有两层:① 超过之后模型也只是「看得到一堆路径」,边际收益为零;
|
|
44
|
+
* ② 它是**载荷上界**(每个引用都在消息里,且会随消息一起持久化 / 导出 / 重放)。
|
|
45
|
+
* 超限**拒整条**而不是截断:截断是静默的 —— 手机端的 chip 还在,用户会以为都带上了。
|
|
46
|
+
*/
|
|
47
|
+
export const MESSAGE_FILE_MAX = 20
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 单个引用路径的长度上限(1024 字符)。
|
|
51
|
+
*
|
|
52
|
+
* 与 `MESSAGE_FILE_MAX` 同一条纪律:路径是**手机端给的字符串**,没有上界就等于允许
|
|
53
|
+
* 一个(有 bug 或被改写的)客户端把任意长的内容塞进消息里。正常路径远低于这个数
|
|
54
|
+
* (Windows 的 `MAX_PATH` 是 260)。
|
|
55
|
+
*/
|
|
56
|
+
export const MESSAGE_FILE_PATH_MAX = 1024
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 一条**文件引用**(手机端 → 电脑端 → 引擎内容块)。
|
|
60
|
+
*
|
|
61
|
+
* ⚠️ 与 `MessageQuote` 不同,这里**不是快照**:存的是路径,AI 读的那一刻磁盘上是什么就是什么。
|
|
62
|
+
* 这正是「不搬运内容」的代价,也是它的好处(引用的永远是**当前**内容,不会拿一份过期副本
|
|
63
|
+
* 去回答)。桌面端的文件附件同此语义。
|
|
64
|
+
*/
|
|
65
|
+
export interface MessageFileRef {
|
|
66
|
+
/** 文件绝对路径(分隔符按契约统一为 `/`,见 `sanitizeFileRefs`)。 */
|
|
67
|
+
path: string
|
|
68
|
+
/** 文件名(含扩展名)—— chip 上显示的就是它。 */
|
|
69
|
+
name: string
|
|
70
|
+
/** 是否为目录(目录也能被引用:模型用 `list_files` 去看里面有什么)。 */
|
|
71
|
+
isDir?: boolean
|
|
72
|
+
/** 字节数(目录没有这一项)。只用于展示,**不作为任何校验依据**(它不是文件的事实来源)。 */
|
|
73
|
+
size?: number
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 校验结果。
|
|
78
|
+
*
|
|
79
|
+
* ⚠️ **刻意不写成判别联合**(`| { ok: true; files } | { ok: false; reason }`):
|
|
80
|
+
* 布尔字面量的判别联合靠 `strictNullChecks` 才能收窄,而消费方 `virlen-app` 的 tsconfig 是
|
|
81
|
+
* `strictNullChecks: false` —— 写成判别联合的话,电脑侧那句 `if (!r.ok) { …r.reason… }`
|
|
82
|
+
* 根本收窄不了(直接 `TS2339: Property 'reason' does not exist`)。契约放在共享包里,
|
|
83
|
+
* 就不能只在一边成立。
|
|
84
|
+
*
|
|
85
|
+
* 不变式:`ok === (reason === '')`(通过时 `reason` 是空串,`files` 是被归一化的引用;
|
|
86
|
+
* 被拒时 `files` 恒为空数组)。
|
|
87
|
+
*/
|
|
88
|
+
export interface FileRefSanitizeResult {
|
|
89
|
+
/** 是否通过校验。 */
|
|
90
|
+
ok: boolean
|
|
91
|
+
/** 归一化后的引用;被拒时恒为空数组(调用方那时应该直接抛错)。 */
|
|
92
|
+
files: MessageFileRef[]
|
|
93
|
+
/** 被拒原因(可直接显示给用户);通过时为空串。 */
|
|
94
|
+
reason: string
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 校验并归一化手机传来的文件引用(**电脑侧与演示宿主共用这一份**)。
|
|
99
|
+
*
|
|
100
|
+
* 规则:
|
|
101
|
+
* - 缺省 / `null` → 空数组(不带文件引用是完全正常的一条消息);
|
|
102
|
+
* - 不是数组 / 条目不是对象 / `path` 不是非空字符串 → **整条拒**(返回 `ok:false`);
|
|
103
|
+
* - 路径超过 `MESSAGE_FILE_PATH_MAX`、条目数超过 `MESSAGE_FILE_MAX` → 整条拒;
|
|
104
|
+
* - `name` 缺失或为空 → 用路径末段兜底(电脑侧从内容块投影回来时也会用到这条);
|
|
105
|
+
* - `isDir` / `size` 形状不对 → **丢掉该字段**(它们是展示元数据,说谎也只是 chip 上少一行字),
|
|
106
|
+
* 但路径非法必须拒 —— 那是这条引用**唯一有语义的部分**。
|
|
107
|
+
* - 路径分隔符 `\` → `/`(与全项目的路径口径一致,两侧拼串才不会出现 `a\/b`);
|
|
108
|
+
* - 同一路径只保留第一次出现的(手机端连点两下不该变成两条引用)。
|
|
109
|
+
*
|
|
110
|
+
* ## 为什么非法就整条拒(而不是「丢掉那一条、其余照发」)
|
|
111
|
+
*
|
|
112
|
+
* §36 的教训:**假绿灯比报错难查得多**。丢掉一条引用时,手机端那边的 chip 还在输入框里、
|
|
113
|
+
* 发完消息里也有它 —— 用户会以为带上了,而 AI 从未看到。拒掉整条之后,用户看到的是
|
|
114
|
+
* 一条明确的失败提示,芯片还在,可以改完再发。
|
|
115
|
+
*/
|
|
116
|
+
export function sanitizeFileRefs(input: unknown): FileRefSanitizeResult {
|
|
117
|
+
/** 被拒(`files` 恒为空数组 —— 失败就是**整条**失败,没有「部分成功」这个态) */
|
|
118
|
+
const reject = (reason: string): FileRefSanitizeResult => ({ ok: false, files: [], reason })
|
|
119
|
+
|
|
120
|
+
if (input == null) return { ok: true, files: [], reason: '' }
|
|
121
|
+
if (!Array.isArray(input)) return reject('文件引用的形状不对')
|
|
122
|
+
if (input.length > MESSAGE_FILE_MAX) {
|
|
123
|
+
return reject(`一次最多引用 ${MESSAGE_FILE_MAX} 个文件`)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const files: MessageFileRef[] = []
|
|
127
|
+
const seen = new Set<string>()
|
|
128
|
+
for (const raw of input) {
|
|
129
|
+
if (typeof raw !== 'object' || raw === null) {
|
|
130
|
+
return reject('文件引用的形状不对')
|
|
131
|
+
}
|
|
132
|
+
const item = raw as Partial<MessageFileRef>
|
|
133
|
+
if (typeof item.path !== 'string' || !item.path.trim()) {
|
|
134
|
+
return reject('文件引用缺少路径')
|
|
135
|
+
}
|
|
136
|
+
// 归一化分隔符:`E:\a\b` → `E:/a/b`(与 `normalizeWorkspace` / 文件面板同一口径)
|
|
137
|
+
const path = item.path.replace(/\\/g, '/')
|
|
138
|
+
if (path.length > MESSAGE_FILE_PATH_MAX) {
|
|
139
|
+
return reject('文件路径过长')
|
|
140
|
+
}
|
|
141
|
+
if (seen.has(path)) continue
|
|
142
|
+
seen.add(path)
|
|
143
|
+
|
|
144
|
+
const name = typeof item.name === 'string' ? item.name.trim() : ''
|
|
145
|
+
files.push({
|
|
146
|
+
path,
|
|
147
|
+
name: (name || baseName(path)).slice(0, FILE_NAME_MAX_LEN),
|
|
148
|
+
...(typeof item.isDir === 'boolean' ? { isDir: item.isDir } : {}),
|
|
149
|
+
...(typeof item.size === 'number' && Number.isFinite(item.size) && item.size >= 0
|
|
150
|
+
? { size: item.size }
|
|
151
|
+
: {}),
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
return { ok: true, files, reason: '' }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 路径末段(`name` 缺失时的兜底;本地实现以免把 `protocol/files` 的整套路径工具拖进来)。 */
|
|
158
|
+
function baseName(path: string): string {
|
|
159
|
+
const parts = path.replace(/\/+$/, '').split('/')
|
|
160
|
+
return parts[parts.length - 1] || path
|
|
161
|
+
}
|