virlen-remote 0.5.0 → 0.7.0
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 +373 -169
- package/README.md +389 -201
- package/dist/index.d.ts +10 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -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 +370 -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 +24 -1
- package/dist/protocol/host.d.ts.map +1 -1
- package/dist/protocol/host.js +9 -1
- package/dist/protocol/host.js.map +1 -1
- 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 +439 -10
- 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 -184
- package/src/protocol/agents.ts +13 -0
- package/src/protocol/api.ts +974 -615
- package/src/protocol/compress.ts +65 -0
- package/src/protocol/files.ts +544 -0
- package/src/protocol/host.ts +205 -165
- package/src/protocol/message-actions.ts +60 -60
- 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 -740
- 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,165 +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
|
-
|
|
151
|
-
on(
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
on('host.
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}
|
|
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
|
+
}
|
|
@@ -1,60 +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'
|
|
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'
|