Repository navigation
Expand file tree
/
Copy pathmessage.ts
More file actions
290 lines (265 loc) · 11.4 KB
/
Copy pathmessage.ts
File metadata and controls
290 lines (265 loc) · 11.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
// ============================================
// Message API — OpenCode v2 原生
//
// ## v1 → v2 关键差异
//
// - 读取消息:v1 `session.messages()` → v2 `message.list()`
// 返回 `{ data, cursor }`,元素是**自带内容的完整消息**
// (不再有独立的 parts 数组)。
// - 发送消息:v1 `session.prompt({ parts: [...] })` 且另有 promptAsync;
// v2 `session.prompt({ sessionID, text, files?, agents?, skills? })`
// —— **入参是 text + 附件数组**,不是 parts 联合;
// 且 prompt 本身就是入队语义(等价于 v1 的 promptAsync),
// 响应是入队项(SessionInboxUser),不是助手回复。
// 助手输出通过事件流到达。
// ============================================
import { getSDKClient } from './sdk'
import type { JsonValue } from '@opencode/client/promise'
import { resolveSessionTarget } from '../utils/sessionKey'
import type { SessionMessage, UserMessage } from './types'
import type { Attachment, RevertedMessage, SendMessageParams } from './types'
// ============================================
// 消息查询
// ============================================
/**
* 获取会话消息列表。
*
* v2 的 `message.list` 默认 **新的在前**(倒序分页,cursor 往历史深处翻),
* 而本应用的 messageStore / 渲染层一律按时间正序(旧→新)组织。
* 官方做法相同:`order: "desc"` 拉回来后 `toReversed()`
*(packages/client/src/solid/data.ts:1625-1630)。
* 因此这里统一在出口反转,所有调用点拿到的都是正序。
*/
/**
* 单条消息回读(官方 data.ts:677-683 同款)。
*
* model.selected 事件先合成一行 model-switched 乐观消息,随后用
* message.get 把服务端的 durable 行拉回替换(补全字段/对账)。
*/
export async function getSessionMessage(
sessionId: string,
messageId: string,
serverId?: string,
): Promise<SessionMessage | undefined> {
const target = resolveSessionTarget(sessionId, serverId)
const sdk = getSDKClient(target.serverId)
const result = await sdk.session.message.get({ sessionID: target.sessionId, messageID: messageId })
return result
}
export async function getSessionMessages(
sessionId: string,
limit?: number,
_directory?: string,
serverId?: string,
): Promise<SessionMessage[]> {
const target = resolveSessionTarget(sessionId, serverId)
const sdk = getSDKClient(target.serverId)
const result = await sdk.message.list({ sessionID: target.sessionId, limit, order: 'desc' })
return [...result.data].reverse()
}
/**
* 按游标翻一页历史(官方 message.loadMore 的数据通道,data.ts:1684-1709)。
*
* v2 的 `message.list` 响应带 `cursor.next`:传回即可拿到更老的一页,
* 不需要像「递增 limit 从头重拉」那样重复传输已加载的消息。
* 返回的 messages 同样是正序(旧→新);nextCursor 为 null 表示没有更早的历史。
*/
export async function getSessionMessagesPage(
sessionId: string,
options?: { limit?: number; cursor?: string },
serverId?: string,
): Promise<{ messages: SessionMessage[]; nextCursor: string | null }> {
const target = resolveSessionTarget(sessionId, serverId)
const sdk = getSDKClient(target.serverId)
const result = await sdk.message.list({
sessionID: target.sessionId,
limit: options?.limit,
// 游标自带 order/direction(服务端 cursor JSON 内含)——同传 order
// 会吃 InvalidCursorError(400),官方 data.ts loadMore 同款:
// 首页 limit+order,翻页只带 limit+cursor
...(options?.cursor ? { cursor: options.cursor } : { order: 'desc' as const }),
})
return {
messages: [...result.data].reverse(),
nextCursor: result.cursor.next ?? null,
}
}
// ============================================
// 用户消息内容提取
// ============================================
/**
* 从 v2 用户消息中提取纯文本与附件。
*
* v2 的用户消息自带 `text` 与 `files` / `agents` / `skills` 数组,
* 不再需要遍历 parts。
*
* 注意入参与出参的文件形状不同:
* - prompt **入参** 的 files 是 `{ uri, name?, description? }`
* - 消息**记录**里的 files 是 `PromptFileAttachment`
* (`{ data, mime, source, name?, description? }`,没有 uri)
* 这里处理的是消息记录,因此路径要从 `source` 取。
*/
export function extractUserMessageContent(message: UserMessage): RevertedMessage {
const attachments: Attachment[] = []
for (const file of message.files ?? []) {
const source = file.source
const uri = source?.type === 'uri' ? source.uri : ''
const displayName = file.name || uri || file.mime || 'attachment'
attachments.push({
id: crypto.randomUUID(),
type: 'file',
displayName,
// inline 附件没有可回填的路径,只在有 uri 时带上
url: uri,
relativePath: uri || displayName,
})
}
for (const agent of message.agents ?? []) {
attachments.push({
id: crypto.randomUUID(),
type: 'agent',
displayName: agent.name,
agentName: agent.name,
})
}
return { text: message.text ?? '', attachments }
}
// ============================================
// 发送消息
// ============================================
/**
* 附件 → prompt 的 files/agents/skills 部件(buildPromptInput 的组装段,
* 队列编辑确认(editedPromptInput)与正常发送共用)。
*/
export function buildPromptParts(attachments: Attachment[]): {
files: Array<{ uri: string; name?: string; mention?: { start: number; end: number; text: string } }>
agents: Array<{ name: string; mention?: { start: number; end: number; text: string } }>
skills: Array<{ id: string; mention?: { start: number; end: number; text: string } }>
} {
const files: Array<{ uri: string; name?: string; mention?: { start: number; end: number; text: string } }> = []
const agents: Array<{ name: string; mention?: { start: number; end: number; text: string } }> = []
const skills: Array<{ id: string; mention?: { start: number; end: number; text: string } }> = []
const mention = (attachment: Attachment) =>
attachment.textRange
? { start: attachment.textRange.start, end: attachment.textRange.end, text: attachment.textRange.value }
: undefined
for (const attachment of attachments) {
if (attachment.type === 'agent') {
agents.push({
name: attachment.agentName || attachment.displayName,
...(mention(attachment) ? { mention: mention(attachment) } : {}),
})
continue
}
if (attachment.type === 'skill') {
// skill 提及(官方 request.ts:68-72):目前输入端尚未提供 skill 附件入口,
// 载荷先对齐官方形状,入口落地后即通
const id = attachment.skillId || attachment.displayName
if (id) skills.push({ id, ...(mention(attachment) ? { mention: mention(attachment) } : {}) })
continue
}
const uri = attachment.url || ''
if (!uri) {
console.warn('Skipping attachment with empty URL:', attachment)
continue
}
files.push({
uri,
name: attachment.displayName,
...(mention(attachment) ? { mention: mention(attachment) } : {}),
})
}
return { files, agents, skills }
}
/**
* 构建 v2 的 prompt 入参(官方 composer/request.ts + submit.ts:395-411 同款)。
*
* v2 的形状是 `{ text, files?, agents?, skills?, metadata }`:
* - 文本走 `text`;`metadata.displayText` 记录用户原始输入(服务端与
* 转写展示都优先它,官方 message-timeline.tsx:295-303)
* - 文件附件走 `files[].uri`;agent 提及走 `agents[].name`;两者都带
* `mention` 区间(@提及在文本中的对齐信息,官方 request.ts:71-86)
* - `metadata.agent/model` 记录发送时的选择快照——queue 条目投递时
* 服务端按它生效( steer 才在发送前 switch,见 sendMessage)
*/
function buildPromptInput(
params: SendMessageParams,
sessionID: string,
): {
sessionID: string
id?: string
text: string
files?: Array<{ uri: string; name?: string; mention?: { start: number; end: number; text: string } }>
agents?: Array<{ name: string; mention?: { start: number; end: number; text: string } }>
skills?: Array<{ id: string; mention?: { start: number; end: number; text: string } }>
metadata?: Record<string, JsonValue>
} {
const { files, agents, skills } = buildPromptParts(params.attachments)
return {
sessionID,
// 客户端铸造的 id:服务端原样采用,乐观行与 durable 行同 id 对账
...(params.id ? { id: params.id } : {}),
text: params.text,
// 投递方式:busy 时 'queue' 排队(服务端排空后自动投递)或 'steer'
// 插队注入当前回合;idle 时不传走服务端默认(steer)
//(官方 packages/core/src/session/prompt.ts:50 默认 steer)
...(params.delivery ? { delivery: params.delivery } : {}),
...(files.length > 0 ? { files } : {}),
...(agents.length > 0 ? { agents } : {}),
...(skills.length > 0 ? { skills } : {}),
// 选择快照 + 原始输入(官方 submit.ts:401-410 同款)
metadata: {
displayText: params.text,
...(params.agent ? { agent: params.agent } : {}),
...(params.model
? {
model: {
providerID: params.model.providerID,
modelID: params.model.modelID,
...(params.variant ? { variant: params.variant } : {}),
},
}
: {}),
},
}
}
/**
* 发送消息(入队)。
*
* v2 的 prompt 是入队语义:立即返回入队项,助手输出经事件流到达。
* 因此 v1 的 sendMessage / sendMessageAsync 在 v2 合并为同一个调用。
*
* **模型 / agent 必须在发送前单独切换**:v2 的 `SessionPromptInput` 里
* 根本没有 model / agent / variant 字段(与 v1 的 per-prompt model 不同),
* 它们只能通过 `session.switchModel` / `session.switchAgent` 生效。
* 之前这里把 params 里的 model/agent 直接丢掉 → 会话中途切换模型/agent
* 完全不起作用(新建会话不受影响,因为 create 已带上这两个字段)。
*/
export async function sendMessage(params: SendMessageParams, serverId?: string): Promise<void> {
const target = resolveSessionTarget(params.sessionId, serverId)
const sdk = getSDKClient(target.serverId)
// 模型/agent 的应用时机(官方 submit.ts:384-410):只有 steer(含 idle 默认)
// 才在发送前切换——那会重配**当前正在运行的回合**。queue 条目不该碰当前
// 回合,其选择记入 metadata、投递时才生效。
const appliesImmediately = params.delivery !== 'queue'
if (params.model && appliesImmediately) {
await sdk.session.switchModel({
sessionID: target.sessionId,
// 注意字段名不同:prompt 侧叫 modelID,switch 侧叫 id
model: {
id: params.model.modelID,
providerID: params.model.providerID,
...(params.variant ? { variant: params.variant } : {}),
},
})
}
if (params.agent && appliesImmediately) {
await sdk.session.switchAgent({ sessionID: target.sessionId, agent: params.agent })
}
await sdk.session.prompt(buildPromptInput(params, target.sessionId))
}
/**
* 异步发送消息 —— v2 中与 sendMessage 等价(prompt 本身即入队)。
* 保留导出以兼容旧调用点。
*/
export const sendMessageAsync = sendMessage