dsh-remote-bridge 2.2.1 → 2.2.3
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/docs/api/asyncapi.yaml +38 -3
- package/docs/api/openapi.yaml +97 -1
- package/lib/index.d.ts +194 -6
- package/lib/index.js +716 -25
- package/package.json +1 -1
package/docs/api/asyncapi.yaml
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
asyncapi: 3.0.0
|
|
2
2
|
info:
|
|
3
3
|
title: DeepSeek Harness Mobile Events
|
|
4
|
-
version: 2.2.
|
|
5
|
-
description:
|
|
4
|
+
version: 2.2.3
|
|
5
|
+
description: |
|
|
6
|
+
Live file and authorized DSH chat events. Reconnect clients must refresh
|
|
7
|
+
REST state; unbounded replay is not provided.
|
|
8
|
+
|
|
9
|
+
`chat.turn.failed` carries the terminal failure of a model turn (rate
|
|
10
|
+
limit, quota exhausted, invalid API key, signed-out stop). Without it a
|
|
11
|
+
remote client sees an ordinary `chat.turn.end` and keeps waiting on a run
|
|
12
|
+
that already failed.
|
|
6
13
|
servers:
|
|
7
14
|
local:
|
|
8
15
|
host: 127.0.0.1:3090
|
|
@@ -32,7 +39,7 @@ components:
|
|
|
32
39
|
id: { type: string }
|
|
33
40
|
type:
|
|
34
41
|
type: string
|
|
35
|
-
examples: [file.changed, file.trashed, chat.message.delta, chat.message.committed, chat.session.status, chat.approval.requested, chat.approval.resolved, connection.ready]
|
|
42
|
+
examples: [file.changed, file.trashed, chat.message.delta, chat.message.committed, chat.session.status, chat.approval.requested, chat.approval.resolved, chat.turn.failed, connection.ready]
|
|
36
43
|
time: { type: integer }
|
|
37
44
|
data:
|
|
38
45
|
oneOf:
|
|
@@ -40,6 +47,7 @@ components:
|
|
|
40
47
|
- $ref: '#/components/schemas/ChatMessageCommitted'
|
|
41
48
|
- $ref: '#/components/schemas/ChatApprovalRequested'
|
|
42
49
|
- $ref: '#/components/schemas/ChatApprovalResolved'
|
|
50
|
+
- $ref: '#/components/schemas/ChatTurnFailed'
|
|
43
51
|
- type: object
|
|
44
52
|
additionalProperties: true
|
|
45
53
|
schemas:
|
|
@@ -84,3 +92,30 @@ components:
|
|
|
84
92
|
sessionId: { type: string }
|
|
85
93
|
approvalId: { type: string }
|
|
86
94
|
outcome: { type: string, enum: [allowed-once, rejected, cancelled, unavailable] }
|
|
95
|
+
ChatTurnFailed:
|
|
96
|
+
type: object
|
|
97
|
+
description: |
|
|
98
|
+
Terminal failure of a model turn. Emitted instead of a plain
|
|
99
|
+
`chat.turn.end` when the host reports `reason.kind === 'error'` (rate
|
|
100
|
+
limit, quota exhausted, invalid key) or an abort caused by signing out
|
|
101
|
+
of the DeepSeek account. Clients must stop their running indicator and
|
|
102
|
+
show the message; otherwise a failed run keeps looking like it is in
|
|
103
|
+
flight.
|
|
104
|
+
required: [sessionId, eventType, failure]
|
|
105
|
+
properties:
|
|
106
|
+
sessionId: { type: string }
|
|
107
|
+
eventType: { type: string, const: turn/end }
|
|
108
|
+
seq: { type: integer }
|
|
109
|
+
failure:
|
|
110
|
+
type: object
|
|
111
|
+
description: |
|
|
112
|
+
`message` is the host's own wording, forwarded verbatim so clients
|
|
113
|
+
can decide localization. `code` is present only when the host
|
|
114
|
+
supplies one; an empty string is meaningful and must not be
|
|
115
|
+
treated as absent. `stopped` marks an account-sign-out stop, which
|
|
116
|
+
is not a model fault.
|
|
117
|
+
required: [message]
|
|
118
|
+
properties:
|
|
119
|
+
message: { type: string }
|
|
120
|
+
code: { type: string }
|
|
121
|
+
stopped: { type: boolean, const: true }
|
package/docs/api/openapi.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
openapi: 3.1.0
|
|
2
2
|
info:
|
|
3
3
|
title: DeepSeek Harness Mobile API
|
|
4
|
-
version: 2.2.
|
|
4
|
+
version: 2.2.3
|
|
5
5
|
description: Versioned file and chat BFF for mobile and third-party DSH clients. HTTP and HTTPS are both supported.
|
|
6
6
|
servers:
|
|
7
7
|
- url: http://127.0.0.1:3090/api/v1
|
|
@@ -582,6 +582,102 @@ paths:
|
|
|
582
582
|
responses:
|
|
583
583
|
'202': { description: Decision accepted by DSH. }
|
|
584
584
|
'409': { $ref: '#/components/responses/Error' }
|
|
585
|
+
/chat/sessions/{sessionId}/questions:
|
|
586
|
+
get:
|
|
587
|
+
operationId: listUserQuestions
|
|
588
|
+
description: |
|
|
589
|
+
Lists questions an agent is waiting on, from two sources merged together:
|
|
590
|
+
the ones this bridge is holding open (an `ask_user_question` call with no
|
|
591
|
+
`timeout`, taken over from the host's `user-questions/request` waterfall),
|
|
592
|
+
and the ones the kernel still accepts a late answer for (`continued`).
|
|
593
|
+
A question held open is reported first; the same `callId` never appears twice.
|
|
594
|
+
The `viaBridge` flag distinguishes the two. An unknown or inactive session
|
|
595
|
+
yields an empty list rather than an error.
|
|
596
|
+
parameters:
|
|
597
|
+
- name: sessionId
|
|
598
|
+
in: path
|
|
599
|
+
required: true
|
|
600
|
+
schema: { type: string }
|
|
601
|
+
responses:
|
|
602
|
+
'200':
|
|
603
|
+
description: Questions the current device may answer.
|
|
604
|
+
content:
|
|
605
|
+
application/json:
|
|
606
|
+
schema:
|
|
607
|
+
type: object
|
|
608
|
+
required: [items]
|
|
609
|
+
properties:
|
|
610
|
+
items:
|
|
611
|
+
type: array
|
|
612
|
+
items:
|
|
613
|
+
type: object
|
|
614
|
+
required: [callId, state, questions]
|
|
615
|
+
properties:
|
|
616
|
+
callId: { type: string }
|
|
617
|
+
state: { type: string, enum: [continued] }
|
|
618
|
+
viaBridge:
|
|
619
|
+
type: boolean
|
|
620
|
+
description: True when the bridge is holding this question open, so the agent is blocked on it right now.
|
|
621
|
+
questions:
|
|
622
|
+
type: array
|
|
623
|
+
items:
|
|
624
|
+
type: object
|
|
625
|
+
required: [id, question]
|
|
626
|
+
properties:
|
|
627
|
+
id: { type: string }
|
|
628
|
+
question: { type: string }
|
|
629
|
+
header: { type: string }
|
|
630
|
+
detail: { type: string }
|
|
631
|
+
multiSelect: { type: boolean }
|
|
632
|
+
options:
|
|
633
|
+
type: array
|
|
634
|
+
items:
|
|
635
|
+
type: object
|
|
636
|
+
required: [label]
|
|
637
|
+
properties:
|
|
638
|
+
label: { type: string }
|
|
639
|
+
description: { type: string }
|
|
640
|
+
'404': { $ref: '#/components/responses/Error' }
|
|
641
|
+
/chat/sessions/{sessionId}/questions/{callId}/answer:
|
|
642
|
+
post:
|
|
643
|
+
operationId: answerUserQuestion
|
|
644
|
+
description: |
|
|
645
|
+
Answers one question set. Answers for a question the bridge holds open are
|
|
646
|
+
delivered as the return value of the tool call that is waiting; answers for a
|
|
647
|
+
`continued` question are steered into the session as a new turn. The
|
|
648
|
+
`selected` array carries option labels and must match the question's own
|
|
649
|
+
options; use `custom` for free text.
|
|
650
|
+
parameters:
|
|
651
|
+
- name: sessionId
|
|
652
|
+
in: path
|
|
653
|
+
required: true
|
|
654
|
+
schema: { type: string }
|
|
655
|
+
- name: callId
|
|
656
|
+
in: path
|
|
657
|
+
required: true
|
|
658
|
+
schema: { type: string }
|
|
659
|
+
requestBody:
|
|
660
|
+
required: true
|
|
661
|
+
content:
|
|
662
|
+
application/json:
|
|
663
|
+
schema:
|
|
664
|
+
type: object
|
|
665
|
+
required: [answers]
|
|
666
|
+
properties:
|
|
667
|
+
answers:
|
|
668
|
+
type: array
|
|
669
|
+
items:
|
|
670
|
+
type: object
|
|
671
|
+
required: [id, selected]
|
|
672
|
+
properties:
|
|
673
|
+
id: { type: string }
|
|
674
|
+
selected: { type: array, items: { type: string } }
|
|
675
|
+
custom: { type: string }
|
|
676
|
+
responses:
|
|
677
|
+
'202': { description: Answer accepted. }
|
|
678
|
+
'400': { $ref: '#/components/responses/Error' }
|
|
679
|
+
'409': { $ref: '#/components/responses/Error' }
|
|
680
|
+
'503': { $ref: '#/components/responses/Error' }
|
|
585
681
|
/chat/sessions/{sessionId}/commands:
|
|
586
682
|
get:
|
|
587
683
|
operationId: listSessionCommands
|
package/lib/index.d.ts
CHANGED
|
@@ -220,16 +220,37 @@ interface DshApiProxy {
|
|
|
220
220
|
*
|
|
221
221
|
* `attachmentId` 形如 `sha256:<hex>`(**含冒号**,客户端必须 URL 编码)。
|
|
222
222
|
* 返回 `Uint8Array` 而不是 base64:图片动辄上百 KB,多一次编解码纯属浪费。
|
|
223
|
+
*
|
|
224
|
+
* **必须带 `sessionId`**:内核要靠它读会话事件、取出这张图的真实 ref,
|
|
225
|
+
* 再据此做完整性校验(详见 chat-adapter 的 readAttachment 注释)。
|
|
223
226
|
*/
|
|
224
227
|
readAttachment(request: {
|
|
225
228
|
rpcId: string;
|
|
226
229
|
payload: {
|
|
230
|
+
sessionId: string;
|
|
227
231
|
attachmentId: string;
|
|
228
232
|
};
|
|
229
233
|
}): Promise<RpcResponse<{
|
|
230
234
|
bytes: Uint8Array;
|
|
231
235
|
mediaType: string;
|
|
232
236
|
}>>;
|
|
237
|
+
/**
|
|
238
|
+
* 取回一张**交接图片**的字节(纯文本模型发图时,图片被落成工作区文件)。
|
|
239
|
+
*
|
|
240
|
+
* 与 [readAttachment] 的区别:那一个取内核附件存储里的图(走
|
|
241
|
+
* `sessionController.attachment`),这一个取插件自己写进 `.dsh-images/`
|
|
242
|
+
* 的交接件。手机端用它把图片**显示**给人看;消息里的路径文本照旧留给模型。
|
|
243
|
+
*/
|
|
244
|
+
readHandoffImage(request: {
|
|
245
|
+
rpcId: string;
|
|
246
|
+
payload: {
|
|
247
|
+
sessionId: string;
|
|
248
|
+
name: string;
|
|
249
|
+
};
|
|
250
|
+
}): Promise<RpcResponse<{
|
|
251
|
+
bytes: Uint8Array;
|
|
252
|
+
mediaType: string;
|
|
253
|
+
}>>;
|
|
233
254
|
cancel(request: {
|
|
234
255
|
rpcId: string;
|
|
235
256
|
payload: {
|
|
@@ -337,6 +358,36 @@ interface DshApiProxy {
|
|
|
337
358
|
agentPreset: string;
|
|
338
359
|
}>>;
|
|
339
360
|
};
|
|
361
|
+
/**
|
|
362
|
+
* 用户问题(内核 `ctx.userQuestions`,即 ask_user_question 工具)。
|
|
363
|
+
*
|
|
364
|
+
* 为什么单开一个命名空间而不是塞进 `sessions`:它由**另一个内核服务**提供
|
|
365
|
+
* (`@deepseek-ai/dsh-user-questions`),在一条没装它的 profile 上是**可选能力** ——
|
|
366
|
+
* 分开之后,「这个能力不在」和「这个会话有问题」在类型上就分得开。
|
|
367
|
+
*/
|
|
368
|
+
userQuestions: {
|
|
369
|
+
/**
|
|
370
|
+
* 列出还能回答的问题(内核 `continued(agent)`)。
|
|
371
|
+
* 没有 live agent、或没有该服务时返回空列表 —— 冷会话是常态,不该报错。
|
|
372
|
+
*/
|
|
373
|
+
list(request: {
|
|
374
|
+
rpcId: string;
|
|
375
|
+
payload: {
|
|
376
|
+
sessionId: string;
|
|
377
|
+
};
|
|
378
|
+
}): Promise<RpcResponse<{
|
|
379
|
+
items: UserQuestionView[];
|
|
380
|
+
}>>;
|
|
381
|
+
/** 提交答案;失败原因见 [UserQuestionAnswerOutcome]。 */
|
|
382
|
+
answer(request: {
|
|
383
|
+
rpcId: string;
|
|
384
|
+
payload: {
|
|
385
|
+
sessionId: string;
|
|
386
|
+
callId: string;
|
|
387
|
+
answer: UserQuestionAnswer;
|
|
388
|
+
};
|
|
389
|
+
}): Promise<RpcResponse<UserQuestionAnswerOutcome>>;
|
|
390
|
+
};
|
|
340
391
|
events: {
|
|
341
392
|
mux(request: {
|
|
342
393
|
rpcId: string;
|
|
@@ -468,6 +519,60 @@ interface PendingApprovalView {
|
|
|
468
519
|
risk: 'standard' | 'full-access';
|
|
469
520
|
requestedAt: number;
|
|
470
521
|
}
|
|
522
|
+
/**
|
|
523
|
+
* 一条「还能回答」的用户问题 —— 内核 `userQuestions.continued()` 的一行。
|
|
524
|
+
*
|
|
525
|
+
* 为什么类型是宽松的、而不是照抄内核的 `AskUserQuestionItem`:内核往 item 或
|
|
526
|
+
* question 上加过字段(`intent` 就是一例),手机端该自己决定要不要用,
|
|
527
|
+
* 不该等插件跟着发版。所以这里只钉住三个**契约字段**,其余原样透传。
|
|
528
|
+
*/
|
|
529
|
+
interface UserQuestionView {
|
|
530
|
+
callId: string;
|
|
531
|
+
/** 内核 `UserQuestionState`:'open' 或 'continued';本端点只返回 continued。 */
|
|
532
|
+
state: string;
|
|
533
|
+
/** 问题项**原样**透传(id / question / detail? / header? / options? / multiSelect? / intent?)。 */
|
|
534
|
+
questions: unknown[];
|
|
535
|
+
/** 内核以后加的字段照样透传,不在这里裁剪。 */
|
|
536
|
+
[key: string]: unknown;
|
|
537
|
+
}
|
|
538
|
+
/** 一条问题的答案(内核 `AskUserQuestionAnswerItem`)。 */
|
|
539
|
+
interface UserQuestionAnswerItem {
|
|
540
|
+
/** 被回答的问题 id,必须与请求里的问题 id 对上。 */
|
|
541
|
+
id: string;
|
|
542
|
+
/** 选中的选项 label;多选时可能多项。 */
|
|
543
|
+
selected: string[];
|
|
544
|
+
/** 可选的「其他」自由文本。 */
|
|
545
|
+
custom?: string;
|
|
546
|
+
}
|
|
547
|
+
/** 一次提交的完整答案批次(内核 `AskUserQuestionAnswer`):一次调用里的每个问题各占一项。 */
|
|
548
|
+
interface UserQuestionAnswer {
|
|
549
|
+
answers: UserQuestionAnswerItem[];
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* 「把答案送进内核」的结果。
|
|
553
|
+
*
|
|
554
|
+
* 刻意**不走 RpcResponse 的 error 通道**:`unwrapDsh` 只认 `not-found` /
|
|
555
|
+
* `busy` / `locked` 三种 code 去映射 HTTP 状态,其余一律 400 —— 而这里需要区分
|
|
556
|
+
* 409 / 400 / 503。做成**值**之后由 adapter 决定 HTTP 状态,
|
|
557
|
+
* 和 approvals 的 `respond()` 返回 `{ accepted: false, reason }` 是同一个套路。
|
|
558
|
+
*/
|
|
559
|
+
type UserQuestionAnswerFailure =
|
|
560
|
+
/** 内核里没有这个 callId 的悬空问题(`answer()` 返回了 false)。 */
|
|
561
|
+
'not-pending' |
|
|
562
|
+
/** 已经有一条回复在排队(内核 `REPLY_QUEUED`)。重复提交不是服务器故障。 */
|
|
563
|
+
'reply-queued' |
|
|
564
|
+
/** 答案批次没有「一问一答」地对齐(内核 `BAD_ANSWER`)。 */
|
|
565
|
+
'answer-invalid' |
|
|
566
|
+
/** 这个 agent 现在不接受人工回答(非 live root / 被别的 agent 拥有),或会话没有 live agent。 */
|
|
567
|
+
'not-answerable' |
|
|
568
|
+
/** 这套 DSH 构建里压根没有 `ctx.userQuestions` 服务。 */
|
|
569
|
+
'unavailable';
|
|
570
|
+
type UserQuestionAnswerOutcome = {
|
|
571
|
+
accepted: true;
|
|
572
|
+
} | {
|
|
573
|
+
accepted: false;
|
|
574
|
+
reason: UserQuestionAnswerFailure;
|
|
575
|
+
};
|
|
471
576
|
interface ChatWorkspaceView {
|
|
472
577
|
id: string;
|
|
473
578
|
title: string;
|
|
@@ -538,12 +643,28 @@ declare class DshChatAdapter {
|
|
|
538
643
|
/**
|
|
539
644
|
* 取回会话历史里某个图片附件的字节。
|
|
540
645
|
*
|
|
541
|
-
*
|
|
542
|
-
*
|
|
543
|
-
*
|
|
544
|
-
*
|
|
646
|
+
* **为什么会走内核的 `sessionController.attachment()`,而不自己调
|
|
647
|
+
* `ctx.attachments.readImage()`**:
|
|
648
|
+
* 1. 实测本机 profile 里 `ctx.attachments` 是 **MISSING**(拿不到注入属性)——
|
|
649
|
+
* 自己拼 ref 那条路根本走不通;
|
|
650
|
+
* 2. 内核那个方法内部一次做完三件事:校验该会话确实引用了这张图、
|
|
651
|
+
* 读出字节、用**会话事件里那条真实 ref** 做完整性校验
|
|
652
|
+
* (`mediaType` / `bytes` / `width` / `height` 四项逐项比对)。
|
|
653
|
+
* 绕开它就得把这三件事全复刻 —— 先前正是漏了第三件,于是每张图
|
|
654
|
+
* `ATTACHMENT_CORRUPT`:「字节读到了,却被校验拒之门外」。
|
|
655
|
+
*
|
|
656
|
+
* 因此 **sessionId 是必需参数**(内核要拿它读会话事件、找到那张图的真实 ref);
|
|
657
|
+
* 早先设计的「客户端把四项元数据带上来」是多余的 —— 内核手里本来就有。
|
|
658
|
+
*/
|
|
659
|
+
readAttachment(principal: Principal, sessionId: string, attachmentId: string): Promise<{
|
|
660
|
+
bytes: Uint8Array;
|
|
661
|
+
mediaType: string;
|
|
662
|
+
}>;
|
|
663
|
+
/**
|
|
664
|
+
* 取回一张**交接图片**(纯文本模型发图时落在工作区 `.dsh-images/` 的那份)。
|
|
665
|
+
* 与 [readAttachment] 同样要求会话归属;文件名在桥里还有一道白名单校验。
|
|
545
666
|
*/
|
|
546
|
-
|
|
667
|
+
readHandoffImage(principal: Principal, sessionId: string, name: string): Promise<{
|
|
547
668
|
bytes: Uint8Array;
|
|
548
669
|
mediaType: string;
|
|
549
670
|
}>;
|
|
@@ -556,6 +677,22 @@ declare class DshChatAdapter {
|
|
|
556
677
|
archiveSession(principal: Principal, sessionId: string): Promise<string[]>;
|
|
557
678
|
listPendingApprovals(principal: Principal, sessionId: string): Promise<PendingApprovalView[]>;
|
|
558
679
|
decideApproval(principal: Principal, sessionId: string, approvalId: string, outcome: 'allowed-once' | 'rejected'): Promise<PendingApprovalView>;
|
|
680
|
+
/**
|
|
681
|
+
* 列出「agent 正在等、且还能回答」的用户问题。
|
|
682
|
+
*
|
|
683
|
+
* 口径与 [listPendingApprovals] 一致:先 requireSession(会话对本设备不可见 → 404),
|
|
684
|
+
* 再问内核;字段一个都不裁 —— 客户端照着渲染,内核加字段不用改插件。
|
|
685
|
+
*/
|
|
686
|
+
listUserQuestions(principal: Principal, sessionId: string): Promise<UserQuestionView[]>;
|
|
687
|
+
/**
|
|
688
|
+
* 把一次答案提交给内核。
|
|
689
|
+
*
|
|
690
|
+
* 失败原因全部由 `api.userQuestions.answer` 以**值**给出(见 [UserQuestionAnswerOutcome]),
|
|
691
|
+
* 这里翻成 HTTP 语义:503 能力缺失 / 409 状态冲突 / 400 批次不合法。
|
|
692
|
+
* 为什么不在 bridge 里直接抛:`unwrapDsh` 只把 `not-found` / `busy` / `locked`
|
|
693
|
+
* 映成 404/409,其余一律 400 —— 表达不了这几种区别。
|
|
694
|
+
*/
|
|
695
|
+
answerUserQuestion(principal: Principal, sessionId: string, callId: string, answer: UserQuestionAnswer): Promise<void>;
|
|
559
696
|
models(principal: Principal, sessionId: string): Promise<SessionModelsView>;
|
|
560
697
|
selectModel(principal: Principal, sessionId: string, selection: ModelSelectionView): Promise<ModelSelectionView>;
|
|
561
698
|
listCommands(principal: Principal, sessionId: string): Promise<CommandDescriptorView[]>;
|
|
@@ -802,8 +939,21 @@ interface FileUploadsLike {
|
|
|
802
939
|
* 否则历史里的图片一律显示不出来。
|
|
803
940
|
*/
|
|
804
941
|
interface AttachmentStoreLike {
|
|
805
|
-
readImage(
|
|
942
|
+
readImage(
|
|
943
|
+
/**
|
|
944
|
+
* 附件引用。
|
|
945
|
+
*
|
|
946
|
+
* `attachmentId` 是唯一的**定位**字段(内核用 `ID_PATTERN.exec()` 取出 sha256
|
|
947
|
+
* 再拼 `objects/<前两位>/<sha>`);而 `mediaType` / `bytes` / `width` / `height`
|
|
948
|
+
* 参与读后**完整性校验** —— 缺任何一项都会抛 `ATTACHMENT_CORRUPT`,
|
|
949
|
+
* 哪怕字节已经正确读出来了。
|
|
950
|
+
*/
|
|
951
|
+
ref: {
|
|
806
952
|
attachmentId: string;
|
|
953
|
+
mediaType?: string;
|
|
954
|
+
bytes?: number;
|
|
955
|
+
width?: number;
|
|
956
|
+
height?: number;
|
|
807
957
|
}, signal?: AbortSignal): Promise<{
|
|
808
958
|
ref: {
|
|
809
959
|
mediaType: string;
|
|
@@ -878,6 +1028,33 @@ interface SessionControllerLike {
|
|
|
878
1028
|
}): Promise<{
|
|
879
1029
|
sessionId: string;
|
|
880
1030
|
}>;
|
|
1031
|
+
/**
|
|
1032
|
+
* 取一张**被这个会话引用过**的图片字节(内核主机端方法,名字就叫 `attachment`)。
|
|
1033
|
+
*
|
|
1034
|
+
* 这是取历史图片的**正路**,它内部一次做完三件事:校验会话确实引用了这张图、
|
|
1035
|
+
* 调 `ctx.attachments.readImage`、用**会话事件里那条真实 ref**做完整性校验
|
|
1036
|
+
*(`mediaType` / `bytes` / `width` / `height` 四项逐项比对)。
|
|
1037
|
+
*
|
|
1038
|
+
* 为什么必须走它、不能自己调 `ctx.attachments`:实测本机这条 profile 里
|
|
1039
|
+
* `ctx.attachments` 是 **MISSING**(诊断日志
|
|
1040
|
+
* `ctx.attachments MISSING(所以改走内核 sessionController.readAttachment)`),
|
|
1041
|
+
* 自己拼 ref 那条路根本走不通;而且绕开内核就得把它内部三件事全复刻一遍,
|
|
1042
|
+
* 早先正是只传了 id、缺四项元数据,导致 `ATTACHMENT_CORRUPT`。
|
|
1043
|
+
*
|
|
1044
|
+
* 注意方法名:**主机端叫 `attachment`**,客户端契约里才叫 `readAttachment`
|
|
1045
|
+
*(`dsh-api-session-controller/lib/types/client/contract/session.d.ts`)。
|
|
1046
|
+
* `data` 是 **base64 字符串**(主机端实现里 `Buffer.from(stored.data).toString("base64")`),
|
|
1047
|
+
* 不是 Uint8Array —— 调用方要自己解码。
|
|
1048
|
+
*/
|
|
1049
|
+
attachment(request: {
|
|
1050
|
+
sessionId: string;
|
|
1051
|
+
attachmentId: string;
|
|
1052
|
+
}): Promise<{
|
|
1053
|
+
attachment: {
|
|
1054
|
+
mediaType: string;
|
|
1055
|
+
};
|
|
1056
|
+
data: string;
|
|
1057
|
+
}>;
|
|
881
1058
|
selectModel(request: {
|
|
882
1059
|
sessionId: string;
|
|
883
1060
|
provider: string;
|
|
@@ -985,6 +1162,17 @@ interface LlmLike {
|
|
|
985
1162
|
error?: string;
|
|
986
1163
|
}[];
|
|
987
1164
|
discoverModels(settingsNs: string, request: unknown, signal?: AbortSignal): Promise<readonly unknown[]>;
|
|
1165
|
+
/**
|
|
1166
|
+
* 一个路由 + 模型 id 的**真实元数据**。
|
|
1167
|
+
*
|
|
1168
|
+
* 为什么必须用它、而不是自己猜模型能不能看图:内核拒绝「给纯文本模型发图片」时
|
|
1169
|
+
* 用的就是**这个方法**(`SessionController` 的 prompt 准入里
|
|
1170
|
+
* `this.ctx.llm.resolveModelInfo(...)` 再查 `inputModalities`)。入口要与内核
|
|
1171
|
+
* 得出同一个结论,只能查同一份元数据 —— 名字、前缀、家族表都是启发式,会漂。
|
|
1172
|
+
*/
|
|
1173
|
+
resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise<{
|
|
1174
|
+
inputModalities?: readonly string[];
|
|
1175
|
+
} | undefined>;
|
|
988
1176
|
}
|
|
989
1177
|
/** Host services the bridge consumes; assembled by `src/index.ts` from `ctx`. */
|
|
990
1178
|
interface Dsh02HostServices {
|
package/lib/index.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import path from "node:path";
|
|
1
|
+
import path, { join } from "node:path";
|
|
2
2
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
3
3
|
import { chmod, copyFile, lstat, mkdir, open, readFile, readdir, readlink, realpath, rename, rm, stat, symlink, unlink, writeFile } from "node:fs/promises";
|
|
4
|
-
import { createReadStream, mkdirSync } from "node:fs";
|
|
4
|
+
import { createReadStream, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { DatabaseSync } from "node:sqlite";
|
|
6
|
+
import os, { homedir, hostname, networkInterfaces } from "node:os";
|
|
6
7
|
import { pipeline } from "node:stream/promises";
|
|
7
8
|
import { isIP } from "node:net";
|
|
8
|
-
import os, { hostname, networkInterfaces } from "node:os";
|
|
9
9
|
import { createServer } from "node:http";
|
|
10
10
|
import { WebSocket, WebSocketServer } from "ws";
|
|
11
11
|
//#region src/shared/contracts.ts
|
|
@@ -424,16 +424,45 @@ var DshChatAdapter = class {
|
|
|
424
424
|
/**
|
|
425
425
|
* 取回会话历史里某个图片附件的字节。
|
|
426
426
|
*
|
|
427
|
-
*
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
427
|
+
* **为什么会走内核的 `sessionController.attachment()`,而不自己调
|
|
428
|
+
* `ctx.attachments.readImage()`**:
|
|
429
|
+
* 1. 实测本机 profile 里 `ctx.attachments` 是 **MISSING**(拿不到注入属性)——
|
|
430
|
+
* 自己拼 ref 那条路根本走不通;
|
|
431
|
+
* 2. 内核那个方法内部一次做完三件事:校验该会话确实引用了这张图、
|
|
432
|
+
* 读出字节、用**会话事件里那条真实 ref** 做完整性校验
|
|
433
|
+
* (`mediaType` / `bytes` / `width` / `height` 四项逐项比对)。
|
|
434
|
+
* 绕开它就得把这三件事全复刻 —— 先前正是漏了第三件,于是每张图
|
|
435
|
+
* `ATTACHMENT_CORRUPT`:「字节读到了,却被校验拒之门外」。
|
|
436
|
+
*
|
|
437
|
+
* 因此 **sessionId 是必需参数**(内核要拿它读会话事件、找到那张图的真实 ref);
|
|
438
|
+
* 早先设计的「客户端把四项元数据带上来」是多余的 —— 内核手里本来就有。
|
|
431
439
|
*/
|
|
432
|
-
async readAttachment(principal, attachmentId) {
|
|
440
|
+
async readAttachment(principal, sessionId, attachmentId) {
|
|
433
441
|
if (attachmentId.trim() === "") throw new ApiError(400, "BODY_INVALID", "attachmentId is required.");
|
|
442
|
+
if (sessionId.trim() === "") throw new ApiError(400, "BODY_INVALID", "sessionId is required.");
|
|
443
|
+
await this.requireSession(principal, sessionId);
|
|
434
444
|
return unwrapDsh(await this.api.sessions.readAttachment({
|
|
435
445
|
rpcId: randomUUID(),
|
|
436
|
-
payload: {
|
|
446
|
+
payload: {
|
|
447
|
+
sessionId,
|
|
448
|
+
attachmentId
|
|
449
|
+
}
|
|
450
|
+
}));
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* 取回一张**交接图片**(纯文本模型发图时落在工作区 `.dsh-images/` 的那份)。
|
|
454
|
+
* 与 [readAttachment] 同样要求会话归属;文件名在桥里还有一道白名单校验。
|
|
455
|
+
*/
|
|
456
|
+
async readHandoffImage(principal, sessionId, name) {
|
|
457
|
+
if (name.trim() === "") throw new ApiError(400, "BODY_INVALID", "name is required.");
|
|
458
|
+
if (sessionId.trim() === "") throw new ApiError(400, "BODY_INVALID", "sessionId is required.");
|
|
459
|
+
await this.requireSession(principal, sessionId);
|
|
460
|
+
return unwrapDsh(await this.api.sessions.readHandoffImage({
|
|
461
|
+
rpcId: randomUUID(),
|
|
462
|
+
payload: {
|
|
463
|
+
sessionId,
|
|
464
|
+
name
|
|
465
|
+
}
|
|
437
466
|
}));
|
|
438
467
|
}
|
|
439
468
|
async cancel(principal, sessionId) {
|
|
@@ -513,6 +542,46 @@ var DshChatAdapter = class {
|
|
|
513
542
|
this.pendingApprovals.delete(key);
|
|
514
543
|
return publicApproval(pending);
|
|
515
544
|
}
|
|
545
|
+
/**
|
|
546
|
+
* 列出「agent 正在等、且还能回答」的用户问题。
|
|
547
|
+
*
|
|
548
|
+
* 口径与 [listPendingApprovals] 一致:先 requireSession(会话对本设备不可见 → 404),
|
|
549
|
+
* 再问内核;字段一个都不裁 —— 客户端照着渲染,内核加字段不用改插件。
|
|
550
|
+
*/
|
|
551
|
+
async listUserQuestions(principal, sessionId) {
|
|
552
|
+
await this.requireSession(principal, sessionId);
|
|
553
|
+
return unwrapDsh(await this.api.userQuestions.list({
|
|
554
|
+
rpcId: randomUUID(),
|
|
555
|
+
payload: { sessionId }
|
|
556
|
+
})).items;
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* 把一次答案提交给内核。
|
|
560
|
+
*
|
|
561
|
+
* 失败原因全部由 `api.userQuestions.answer` 以**值**给出(见 [UserQuestionAnswerOutcome]),
|
|
562
|
+
* 这里翻成 HTTP 语义:503 能力缺失 / 409 状态冲突 / 400 批次不合法。
|
|
563
|
+
* 为什么不在 bridge 里直接抛:`unwrapDsh` 只把 `not-found` / `busy` / `locked`
|
|
564
|
+
* 映成 404/409,其余一律 400 —— 表达不了这几种区别。
|
|
565
|
+
*/
|
|
566
|
+
async answerUserQuestion(principal, sessionId, callId, answer) {
|
|
567
|
+
await this.requireSession(principal, sessionId);
|
|
568
|
+
const outcome = unwrapDsh(await this.api.userQuestions.answer({
|
|
569
|
+
rpcId: randomUUID(),
|
|
570
|
+
payload: {
|
|
571
|
+
sessionId,
|
|
572
|
+
callId,
|
|
573
|
+
answer
|
|
574
|
+
}
|
|
575
|
+
}));
|
|
576
|
+
if (outcome.accepted) return;
|
|
577
|
+
switch (outcome.reason) {
|
|
578
|
+
case "unavailable": throw new ApiError(503, "USER_QUESTIONS_UNAVAILABLE", "This DSH build provides no ctx.userQuestions service, so questions cannot be answered.");
|
|
579
|
+
case "not-pending": throw new ApiError(409, "QUESTION_NOT_PENDING", "This question is no longer pending.");
|
|
580
|
+
case "reply-queued": throw new ApiError(409, "QUESTION_REPLY_QUEUED", "A reply is already queued for this question.");
|
|
581
|
+
case "not-answerable": throw new ApiError(409, "QUESTION_NOT_ANSWERABLE", "This session cannot answer questions right now: its agent is not the live root.");
|
|
582
|
+
case "answer-invalid": throw new ApiError(400, "QUESTION_ANSWER_INVALID", "The answer batch must name each question of the call exactly once.");
|
|
583
|
+
}
|
|
584
|
+
}
|
|
516
585
|
async models(principal, sessionId) {
|
|
517
586
|
await this.requireSession(principal, sessionId);
|
|
518
587
|
return unwrapDsh(await this.api.sessions.models({
|
|
@@ -767,12 +836,30 @@ function normalizeDshEvent(frame, fallback, time = Date.now()) {
|
|
|
767
836
|
}
|
|
768
837
|
};
|
|
769
838
|
}
|
|
770
|
-
if (eventType === "turn/start" || eventType === "turn/end")
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
839
|
+
if (eventType === "turn/start" || eventType === "turn/end") {
|
|
840
|
+
if (eventType === "turn/end") {
|
|
841
|
+
const failure = turnFailureOf(event);
|
|
842
|
+
if (failure !== void 0) {
|
|
843
|
+
const data = {
|
|
844
|
+
...base,
|
|
845
|
+
failure
|
|
846
|
+
};
|
|
847
|
+
if (failure.stopped === true) data.stopped = true;
|
|
848
|
+
return {
|
|
849
|
+
id: `${frame.rpcId}:failed`,
|
|
850
|
+
type: "chat.turn.failed",
|
|
851
|
+
time,
|
|
852
|
+
data
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
return {
|
|
857
|
+
id: frame.rpcId,
|
|
858
|
+
type: eventType === "turn/start" ? "chat.turn.start" : "chat.turn.end",
|
|
859
|
+
time,
|
|
860
|
+
data: base
|
|
861
|
+
};
|
|
862
|
+
}
|
|
776
863
|
return {
|
|
777
864
|
id: frame.rpcId,
|
|
778
865
|
type: "chat.session.event",
|
|
@@ -808,6 +895,39 @@ function normalizeDshEvent(frame, fallback, time = Date.now()) {
|
|
|
808
895
|
data: payload
|
|
809
896
|
};
|
|
810
897
|
}
|
|
898
|
+
/**
|
|
899
|
+
* `turn/end` 里的终局失败摘要,取不到就返回 undefined(这轮是正常结束的)。
|
|
900
|
+
*
|
|
901
|
+
* 内核 `turn-error.js` 的 match 判据只认两种:`reason.kind === 'error'`,
|
|
902
|
+
* 以及 `aborted` 且取消原因是「退出 DeepSeek 登录」这个钩子。这里**同一判据**,
|
|
903
|
+
* 不多不少 ——
|
|
904
|
+
* - 不能少:漏掉任一形态,客户端就又回到「傻等」状态;
|
|
905
|
+
* - 不能多:`max-tokens` 是「回答被截断,已有输出保留」,官方单独渲染成黄色警示,
|
|
906
|
+
* 不是失败(客户端按普通 turn/end 收尾即可);普通取消(用户点了停止)更不是。
|
|
907
|
+
*
|
|
908
|
+
* `code === 'AUTH'` 时内核把 message 抹成空串(`displayFailure`),客户端那边
|
|
909
|
+
* 用本地化文案「API 密钥无效」顶上。这里保持原样转发,不替客户端猜。
|
|
910
|
+
*/
|
|
911
|
+
function turnFailureOf(event) {
|
|
912
|
+
const reason = isRecord$1(event.data) ? event.data.reason : void 0;
|
|
913
|
+
if (!isRecord$1(reason)) return void 0;
|
|
914
|
+
const kind = reason.kind;
|
|
915
|
+
if (kind === "error") {
|
|
916
|
+
const error = reason.error;
|
|
917
|
+
if (!isRecord$1(error)) return { message: String(error ?? "本轮运行失败") };
|
|
918
|
+
const code = typeof error.code === "string" ? error.code : void 0;
|
|
919
|
+
const message = typeof error.message === "string" ? error.message : JSON.stringify(error);
|
|
920
|
+
return code === void 0 ? { message } : {
|
|
921
|
+
message,
|
|
922
|
+
code
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
if (kind === "aborted" && isRecord$1(reason.reason) && reason.reason.kind === "hook" && reason.reason.reason === "deepseek-account/signed-out") return {
|
|
926
|
+
message: "Stopped because you signed out of DeepSeek.",
|
|
927
|
+
code: "ACCOUNT_SIGNED_OUT",
|
|
928
|
+
stopped: true
|
|
929
|
+
};
|
|
930
|
+
}
|
|
811
931
|
function isRecord$1(value) {
|
|
812
932
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
813
933
|
}
|
|
@@ -1187,6 +1307,72 @@ function optionalService(host, name) {
|
|
|
1187
1307
|
return null;
|
|
1188
1308
|
}
|
|
1189
1309
|
}
|
|
1310
|
+
/**
|
|
1311
|
+
* 容错取 Agent 作用域上的 `userQuestions` 服务;拿不到就返回 `null`。
|
|
1312
|
+
*
|
|
1313
|
+
* 依据(全部实测,不是推断):
|
|
1314
|
+
* - `agent.ctx` 是内核声明的字段:`@deepseek-ai/dsh-agent` 的
|
|
1315
|
+
* `lib/types/runtime-types.d.ts`(`interface Agent { readonly ctx: Context }`)。
|
|
1316
|
+
* - `ctx.get(name)` 由 cordis 混入 `ctx`:`@deepseek-ai/cordis/lib/index.js` 里
|
|
1317
|
+
* `this.mixin("reflect", ["get", "set", "provide", "accessor", "mixin"])`;
|
|
1318
|
+
* 同一文件的 `get(name, strict = true)` 在没人提供该服务时返回 `undefined`,**不抛**。
|
|
1319
|
+
* - 内核自己也这么取:`dsh-user-questions/lib/types/index.js` 里
|
|
1320
|
+
* `this.ctx.get('agents')` / `this.ctx.get('sessionProjections')`。
|
|
1321
|
+
*
|
|
1322
|
+
* 即便如此仍做三层防御(ctx 形状 → get 是不是函数 → try/catch):这是**可选能力**,
|
|
1323
|
+
* 没装它的 profile 只该少一个功能,不该让整个插件激活失败 —— attachments /
|
|
1324
|
+
* fileUploads 已经用两次线上事故教过这个道理。
|
|
1325
|
+
*/
|
|
1326
|
+
function userQuestionsOf(agent) {
|
|
1327
|
+
if (agent === null || typeof agent !== "object") return null;
|
|
1328
|
+
const ctx = agent.ctx;
|
|
1329
|
+
if (ctx === null || ctx === void 0 || typeof ctx !== "object") return null;
|
|
1330
|
+
const get = ctx.get;
|
|
1331
|
+
if (typeof get !== "function") return null;
|
|
1332
|
+
try {
|
|
1333
|
+
const service = get.call(ctx, "userQuestions");
|
|
1334
|
+
return service !== null && typeof service === "object" ? service : null;
|
|
1335
|
+
} catch {
|
|
1336
|
+
return null;
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
/**
|
|
1340
|
+
* 内核的一行 `PendingUserQuestion` → 对外 view。
|
|
1341
|
+
*
|
|
1342
|
+
* 只钉三个契约字段(callId / state / questions),其余**原样透传**:内核以后往 item
|
|
1343
|
+
* 或 question 上加字段,手机端自己决定要不要用,插件不用跟着发版。
|
|
1344
|
+
* 缺 callId 或 questions 的行直接丢掉,而不是把残缺的行发给客户端。
|
|
1345
|
+
*/
|
|
1346
|
+
function toUserQuestionView(value) {
|
|
1347
|
+
if (value === null || typeof value !== "object") return void 0;
|
|
1348
|
+
const row = value;
|
|
1349
|
+
const callId = row["callId"];
|
|
1350
|
+
const questions = row["questions"];
|
|
1351
|
+
if (typeof callId !== "string" || callId === "" || !Array.isArray(questions)) return void 0;
|
|
1352
|
+
const state = row["state"];
|
|
1353
|
+
return {
|
|
1354
|
+
...row,
|
|
1355
|
+
callId,
|
|
1356
|
+
state: typeof state === "string" ? state : "continued",
|
|
1357
|
+
questions: [...questions]
|
|
1358
|
+
};
|
|
1359
|
+
}
|
|
1360
|
+
/**
|
|
1361
|
+
* 内核异常的 `code`,取不到就 `undefined`。
|
|
1362
|
+
*
|
|
1363
|
+
* 不看 `instanceof`(跨包副本 / 跨 realm 会失手),只认 `HarnessError` 上那个
|
|
1364
|
+
* 「稳定、可编程路由」的 `code`:`@deepseek-ai/dsh-llm` 的 `lib/types/error.js` 里
|
|
1365
|
+
* `HarnessError` 就是 `this.code = code`(`name` 也被设成子类名)。
|
|
1366
|
+
*/
|
|
1367
|
+
function errorCodeOf(error) {
|
|
1368
|
+
if (error === null || typeof error !== "object") return void 0;
|
|
1369
|
+
const code = error.code;
|
|
1370
|
+
return typeof code === "string" ? code : void 0;
|
|
1371
|
+
}
|
|
1372
|
+
/** 会话没有 live agent —— `agentFor()` 抛的正是这个 code。 */
|
|
1373
|
+
function isAgentUnavailable(error) {
|
|
1374
|
+
return errorCodeOf(error) === "session/agent-unavailable";
|
|
1375
|
+
}
|
|
1190
1376
|
function ok(value) {
|
|
1191
1377
|
return { result: {
|
|
1192
1378
|
ok: true,
|
|
@@ -1443,6 +1629,175 @@ var ApprovalBridge = class {
|
|
|
1443
1629
|
return { accepted: true };
|
|
1444
1630
|
}
|
|
1445
1631
|
};
|
|
1632
|
+
/**
|
|
1633
|
+
* 同一时刻最多挂起多少条前台提问:超了就把后来的直接委托给官方 UI。
|
|
1634
|
+
* 不做无上限的挂起表 —— 失控的 agent 能问出任意多问题,每条都吊着一个 Promise。
|
|
1635
|
+
*/
|
|
1636
|
+
const MAX_PENDING_QUESTIONS = 32;
|
|
1637
|
+
/**
|
|
1638
|
+
* `user-questions/request` 的接管者 —— 形状照 [ApprovalBridge]。
|
|
1639
|
+
*
|
|
1640
|
+
* 为什么非接不可:`ask_user_question` 只有在**参数 schema 声明了 `timeout`** 时才走
|
|
1641
|
+
* 「前台限时等待 → 超时转 `continued`」那条路(`dsh-user-questions/lib/types/projection.js`
|
|
1642
|
+
* 的 `isTimedAskUserQuestionSchema` 查 `tool.parameters.properties.timeout`),
|
|
1643
|
+
* 而实测 15 个真实调用的参数键清一色只有 `questions`。也就是说绝大多数提问**不进投影**,
|
|
1644
|
+
* `GET /questions` 永远查不到它们;它们只经过这条 Cordis waterfall 事件
|
|
1645
|
+
* (`types.d.ts:136-147`)。官方 WebUI 接了这个事件 → 电脑端看得到、手机端看不到。
|
|
1646
|
+
*
|
|
1647
|
+
* 三处与 approvals 不同,都是内核契约决定的、不是选择:
|
|
1648
|
+
* 1. **先委托,再竞速 —— 两端都能答**:进入时立刻调一次 `next()`(把这条请求交给
|
|
1649
|
+
* 后面的监听者,也就是官方 WebUI 那条远端 answerer),同时自己挂一张卡给 App,
|
|
1650
|
+
* 然后 `Promise.race`:谁先给出答案就算谁的。
|
|
1651
|
+
* 这与 ApprovalBridge 不同(它是「接管 = 否决下游」,cordis `lib/index.js:307-325`
|
|
1652
|
+
* 的文档原话:a listener that does not call next() vetoes the rest of the chain)——
|
|
1653
|
+
* 审批那边先不调,问题这边先调。代价是两端各有一张卡,后答的那张会变陈旧
|
|
1654
|
+
* (客户端会收到 409「已不再等待」,语义正确)。
|
|
1655
|
+
* 2. **`this` 不能当 agent 用**:dispatcher 把它绑成 `scopeTarget(agent, agent)`
|
|
1656
|
+
* (`lib/types/index.js:327`),而 `Scoped<T>` 明确写着
|
|
1657
|
+
* 「routing-only … does not expose the subject's properties」
|
|
1658
|
+
* (`@deepseek-ai/dsh-scope/lib/types/index.d.ts:14-20`)。
|
|
1659
|
+
* 真 agent 只在 `request.agent` 里;官方 WebUI 也是另找 session
|
|
1660
|
+
* (`ctx.sessions.scopeOf(this)`),而不是读 `this.session`。
|
|
1661
|
+
* 3. 回调必须是**普通函数**:cordis 用 `callback.bind(thisArg)` 绑定作用域载体
|
|
1662
|
+
* (cordis `lib/index.js:263`)。本实现不依赖 `this`,但签名保持与内核契约一致。
|
|
1663
|
+
*/
|
|
1664
|
+
var QuestionBridge = class {
|
|
1665
|
+
host;
|
|
1666
|
+
mux;
|
|
1667
|
+
pending = /* @__PURE__ */ new Map();
|
|
1668
|
+
constructor(host, mux) {
|
|
1669
|
+
this.host = host;
|
|
1670
|
+
this.mux = mux;
|
|
1671
|
+
}
|
|
1672
|
+
/** Register the answerer once, during plugin activation. */
|
|
1673
|
+
attach() {
|
|
1674
|
+
const bridge = this;
|
|
1675
|
+
this.host.on("user-questions/request", async function(...args) {
|
|
1676
|
+
const request = args[0];
|
|
1677
|
+
const next = args[args.length - 1];
|
|
1678
|
+
const delegate = (async () => {
|
|
1679
|
+
if (typeof next !== "function") throw new Error("user-questions/request was dispatched without a next() delegate");
|
|
1680
|
+
return next();
|
|
1681
|
+
})();
|
|
1682
|
+
delegate.catch(() => {});
|
|
1683
|
+
const sessionId = sessionIdOfAgent(request?.agent);
|
|
1684
|
+
const questions = Array.isArray(request?.questions) ? [...request.questions] : void 0;
|
|
1685
|
+
const callId = (typeof request?.wait?.callId === "string" && request.wait.callId !== "" ? request.wait.callId : void 0) ?? `bridge-${randomUUID()}`;
|
|
1686
|
+
if (sessionId === void 0 || questions === void 0 || questions.length === 0) return delegate;
|
|
1687
|
+
if (request?.signal?.aborted === true) return delegate;
|
|
1688
|
+
if (bridge.pending.size >= MAX_PENDING_QUESTIONS) return delegate;
|
|
1689
|
+
const rpcId = randomUUID();
|
|
1690
|
+
const held = new Promise((resolve) => {
|
|
1691
|
+
let done = false;
|
|
1692
|
+
const settle = (value) => {
|
|
1693
|
+
if (done) return;
|
|
1694
|
+
done = true;
|
|
1695
|
+
resolve(value);
|
|
1696
|
+
};
|
|
1697
|
+
bridge.pending.set(rpcId, {
|
|
1698
|
+
sessionId,
|
|
1699
|
+
callId,
|
|
1700
|
+
questions,
|
|
1701
|
+
settle
|
|
1702
|
+
});
|
|
1703
|
+
request?.signal?.addEventListener("abort", () => {
|
|
1704
|
+
bridge.pending.delete(rpcId);
|
|
1705
|
+
settle({ kind: "dismissed" });
|
|
1706
|
+
}, { once: true });
|
|
1707
|
+
bridge.mux.push({
|
|
1708
|
+
type: "userQuestions/requested",
|
|
1709
|
+
sessionId,
|
|
1710
|
+
callId,
|
|
1711
|
+
rpcId,
|
|
1712
|
+
questions
|
|
1713
|
+
});
|
|
1714
|
+
});
|
|
1715
|
+
const first = await Promise.race([delegate.then(() => ({ kind: "delegated" }), () => new Promise(() => {})), held]);
|
|
1716
|
+
if (first.kind === "answered") {
|
|
1717
|
+
bridge.pending.delete(rpcId);
|
|
1718
|
+
bridge.mux.push({
|
|
1719
|
+
type: "userQuestions/answered",
|
|
1720
|
+
sessionId,
|
|
1721
|
+
callId,
|
|
1722
|
+
rpcId
|
|
1723
|
+
});
|
|
1724
|
+
return first.answer;
|
|
1725
|
+
}
|
|
1726
|
+
bridge.pending.delete(rpcId);
|
|
1727
|
+
bridge.mux.push({
|
|
1728
|
+
type: "userQuestions/dismissed",
|
|
1729
|
+
sessionId,
|
|
1730
|
+
callId,
|
|
1731
|
+
rpcId
|
|
1732
|
+
});
|
|
1733
|
+
return await delegate;
|
|
1734
|
+
});
|
|
1735
|
+
}
|
|
1736
|
+
/**
|
|
1737
|
+
* 回答一条**正挂在前台等待**的提问(App 通过 `POST …/answer` 打进来)。
|
|
1738
|
+
*
|
|
1739
|
+
* 与 [ApprovalBridge.respond] 同形:按 (sessionId, callId) **精确**找挂起项;
|
|
1740
|
+
* 找不到就报 `not-pending`,绝不挑一条最近的来答 —— 答错会话的代价太大。
|
|
1741
|
+
*/
|
|
1742
|
+
answer(request) {
|
|
1743
|
+
if (!isUserQuestionAnswerLike(request.answer)) return {
|
|
1744
|
+
accepted: false,
|
|
1745
|
+
reason: "bad-response"
|
|
1746
|
+
};
|
|
1747
|
+
for (const [rpcId, entry] of this.pending) {
|
|
1748
|
+
if (entry.sessionId !== request.sessionId || entry.callId !== request.callId) continue;
|
|
1749
|
+
this.pending.delete(rpcId);
|
|
1750
|
+
entry.settle({
|
|
1751
|
+
kind: "answered",
|
|
1752
|
+
answer: request.answer
|
|
1753
|
+
});
|
|
1754
|
+
return { accepted: true };
|
|
1755
|
+
}
|
|
1756
|
+
return {
|
|
1757
|
+
accepted: false,
|
|
1758
|
+
reason: "not-pending"
|
|
1759
|
+
};
|
|
1760
|
+
}
|
|
1761
|
+
/**
|
|
1762
|
+
* 某会话当前挂起中的提问(供 `GET /questions` 合并)。
|
|
1763
|
+
*
|
|
1764
|
+
* `state` 刻意报 `'continued'`:客户端(dsh-companion 的 HarnessViewModel:1314)
|
|
1765
|
+
* 只认这一个值(`sets.filter { it.state == QUESTION_STATE_CONTINUED }`),
|
|
1766
|
+
* 报 `'open'` 等于这张卡片永远不显示 —— 而它恰恰是**此刻真正阻塞着 agent** 的那条。
|
|
1767
|
+
* 「这是插件接管的」由新增的 `viaBridge` 标记表达(客户端 decode 是
|
|
1768
|
+
* `ignoreUnknownKeys = true`,加字段安全),将来客户端想换文案/单独过滤都有依据。
|
|
1769
|
+
*/
|
|
1770
|
+
pendingFor(sessionId) {
|
|
1771
|
+
const out = [];
|
|
1772
|
+
for (const entry of this.pending.values()) {
|
|
1773
|
+
if (entry.sessionId !== sessionId) continue;
|
|
1774
|
+
out.push({
|
|
1775
|
+
callId: entry.callId,
|
|
1776
|
+
state: "continued",
|
|
1777
|
+
questions: [...entry.questions],
|
|
1778
|
+
viaBridge: true
|
|
1779
|
+
});
|
|
1780
|
+
}
|
|
1781
|
+
return out;
|
|
1782
|
+
}
|
|
1783
|
+
};
|
|
1784
|
+
/**
|
|
1785
|
+
* 答案批次的最小校验(路由层已校验过一遍,这里是防御性第二道)。
|
|
1786
|
+
*
|
|
1787
|
+
* 只查「能不能安全喂给内核」:对象、`answers` 非空数组、每项 `id` 是非空字符串、
|
|
1788
|
+
* `selected` 是字符串数组。更细的「一问一答对齐」由内核自己判(BAD_ANSWER)。
|
|
1789
|
+
*/
|
|
1790
|
+
function isUserQuestionAnswerLike(value) {
|
|
1791
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
1792
|
+
const answers = value.answers;
|
|
1793
|
+
if (!Array.isArray(answers) || answers.length === 0) return false;
|
|
1794
|
+
return answers.every((item) => {
|
|
1795
|
+
if (item === null || typeof item !== "object" || Array.isArray(item)) return false;
|
|
1796
|
+
const entry = item;
|
|
1797
|
+
if (typeof entry.id !== "string" || entry.id === "") return false;
|
|
1798
|
+
return Array.isArray(entry.selected) && entry.selected.every((label) => typeof label === "string");
|
|
1799
|
+
});
|
|
1800
|
+
}
|
|
1446
1801
|
/** Best-effort projection of an Agent identity to its session id. */
|
|
1447
1802
|
function sessionIdOfAgent(agent) {
|
|
1448
1803
|
if (!agent || typeof agent !== "object") return void 0;
|
|
@@ -1457,6 +1812,183 @@ function sessionIdOf(summary) {
|
|
|
1457
1812
|
return typeof id === "string" ? id : void 0;
|
|
1458
1813
|
}
|
|
1459
1814
|
/**
|
|
1815
|
+
* 内核在 prompt 准入里会因为「模型看不到图」拒收整条消息
|
|
1816
|
+
* (`MODEL_DOES_NOT_SUPPORT_IMAGES`)。手机端拿不到桌面 WebUI 那套
|
|
1817
|
+
* 「粘贴即落成临时文件、路径进输入框」的浏览器半边,所以图片在手机上
|
|
1818
|
+
* **直接发不出去** —— 而用户明明装了识图插件。
|
|
1819
|
+
*
|
|
1820
|
+
* 这里补上手机端缺的那一步,但**刻意不引用任何插件的 API**:
|
|
1821
|
+
* 判定用内核自己的 `llm.resolveModelInfo()`(与内核拒收时查的是同一份元数据),
|
|
1822
|
+
* 动作只是「图片落成文件 + 消息里给路径」—— 任何「按路径读图」的工具都能接手。
|
|
1823
|
+
* 哪天换插件、甚至不用插件,这段代码都不需要改。
|
|
1824
|
+
*
|
|
1825
|
+
* 三条保守原则(宁可退回原样、也不要自作聪明):
|
|
1826
|
+
* 1. 元数据拿不到 / 解析不出模型 → **不动**(内核这时也不会拒,交给它);
|
|
1827
|
+
* 2. 模型声明了 image → **不动**(原生图片能显示缩略图,别抢它的活);
|
|
1828
|
+
* 3. 落盘失败 → **不动**(退回内核自己的报错,至少用户能看懂发生了什么)。
|
|
1829
|
+
*
|
|
1830
|
+
* 两个落点(按优先级):
|
|
1831
|
+
* - **会话工作目录下的 `.dsh-images/`**(首选):文件在用户的项目里,
|
|
1832
|
+
* 人和 agent 都找得到,也符合「产物放工作目录、别塞进 DSH 私有目录」;
|
|
1833
|
+
* - 解析不出工作目录时退回 DSH 目录([DEFAULT_IMAGE_HANDOFF_DIR])——
|
|
1834
|
+
* 宁可落到私有目录也不能让「发消息」本身失败。
|
|
1835
|
+
*/
|
|
1836
|
+
const HANDOFF_SUBDIR = ".dsh-images";
|
|
1837
|
+
const DEFAULT_IMAGE_HANDOFF_DIR = join(homedir(), ".dsh", "image-handoff");
|
|
1838
|
+
const IMAGE_EXTENSIONS = {
|
|
1839
|
+
"image/png": "png",
|
|
1840
|
+
"image/jpeg": "jpg",
|
|
1841
|
+
"image/webp": "webp",
|
|
1842
|
+
"image/gif": "gif",
|
|
1843
|
+
"image/heic": "heic",
|
|
1844
|
+
"image/heif": "heif",
|
|
1845
|
+
"image/bmp": "bmp",
|
|
1846
|
+
"image/avif": "avif"
|
|
1847
|
+
};
|
|
1848
|
+
/** 扩展名优先按声明的 mediaType,认不出再从魔数嗅,最后 .bin。 */
|
|
1849
|
+
function handoffExtension(mediaType, bytes) {
|
|
1850
|
+
const declared = IMAGE_EXTENSIONS[mediaType.trim().toLowerCase()];
|
|
1851
|
+
if (declared !== void 0) return declared;
|
|
1852
|
+
if (bytes.length >= 8 && bytes[0] === 137 && bytes[1] === 80) return "png";
|
|
1853
|
+
if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216) return "jpg";
|
|
1854
|
+
if (bytes.length >= 12 && bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WEBP") return "webp";
|
|
1855
|
+
if (bytes.length >= 6 && bytes.toString("ascii", 0, 3) === "GIF") return "gif";
|
|
1856
|
+
return "bin";
|
|
1857
|
+
}
|
|
1858
|
+
/**
|
|
1859
|
+
* 把一张内联图片写成文件,返回 `{ absolute, relative }`。
|
|
1860
|
+
*
|
|
1861
|
+
* 文件名用**内容哈希**:同一张图重复发不会堆副本(与内核附件存储同一思路),
|
|
1862
|
+
* 也让「同一路径 → 同一内容」对下游工具成立(可安全缓存)。
|
|
1863
|
+
* `relative` 是相对会话工作目录的短路径,给人看;`absolute` 给读图工具用。
|
|
1864
|
+
* 目录 0700、文件 0600:这些字节可能包含用户隐私。
|
|
1865
|
+
*/
|
|
1866
|
+
function writeHandoffFile(dir, workspace, mediaType, data) {
|
|
1867
|
+
const bytes = Buffer.from(data, "base64");
|
|
1868
|
+
if (bytes.length === 0) throw new Error("EMPTY_IMAGE");
|
|
1869
|
+
mkdirSync(dir, {
|
|
1870
|
+
recursive: true,
|
|
1871
|
+
mode: 448
|
|
1872
|
+
});
|
|
1873
|
+
const fileName = `${createHash("sha256").update(bytes).digest("hex")}.${handoffExtension(mediaType, bytes)}`;
|
|
1874
|
+
const absolute = join(dir, fileName);
|
|
1875
|
+
if (!existsSync(absolute)) writeFileSync(absolute, bytes, { mode: 384 });
|
|
1876
|
+
return {
|
|
1877
|
+
absolute,
|
|
1878
|
+
relative: workspace !== void 0 && absolute.startsWith(workspace + "/") ? absolute.slice(workspace.length + 1) : fileName
|
|
1879
|
+
};
|
|
1880
|
+
}
|
|
1881
|
+
/** 交接文件名只允许「内容哈希 + 图片扩展名」:挡住一切路径穿越。 */
|
|
1882
|
+
const HANDOFF_NAME = /^[0-9a-f]{64}\.(png|jpe?g|webp|gif|heic|heif|bmp|avif)$/i;
|
|
1883
|
+
const HANDOFF_MEDIA_TYPES = {
|
|
1884
|
+
png: "image/png",
|
|
1885
|
+
jpg: "image/jpeg",
|
|
1886
|
+
jpeg: "image/jpeg",
|
|
1887
|
+
webp: "image/webp",
|
|
1888
|
+
gif: "image/gif",
|
|
1889
|
+
heic: "image/heic",
|
|
1890
|
+
heif: "image/heif",
|
|
1891
|
+
bmp: "image/bmp",
|
|
1892
|
+
avif: "image/avif"
|
|
1893
|
+
};
|
|
1894
|
+
/**
|
|
1895
|
+
* 读出一张交接图片的字节(供手机端把它**显示**出来)。
|
|
1896
|
+
*
|
|
1897
|
+
* 只接受裸文件名,目录永远由「会话工作目录 + `.dsh-images`」(或兜底目录)推出,
|
|
1898
|
+
* 客户端给绝对路径也不认 —— 否则这条读文件的口子会变成任意文件读取。
|
|
1899
|
+
* 工作目录与 [writeHandoffFile] 用的是同一套解析,保证「写在哪、从哪读」。
|
|
1900
|
+
*/
|
|
1901
|
+
async function readHandoffImageFile(host, roots, fallbackDir, sessionId, name) {
|
|
1902
|
+
const fileName = name.split(/[/\\]/).pop() ?? name;
|
|
1903
|
+
const match = HANDOFF_NAME.exec(fileName);
|
|
1904
|
+
if (match === null) throw new Error("BAD_HANDOFF_NAME");
|
|
1905
|
+
const ext = (match[1] ?? "").toLowerCase();
|
|
1906
|
+
const mediaType = HANDOFF_MEDIA_TYPES[ext] ?? "application/octet-stream";
|
|
1907
|
+
const workspace = await sessionWorkspaceDir(host, roots, sessionId);
|
|
1908
|
+
const target = [...workspace !== void 0 ? [join(workspace, HANDOFF_SUBDIR, fileName)] : [], join(fallbackDir, fileName)].find((candidate) => existsSync(candidate));
|
|
1909
|
+
if (target === void 0) throw new Error("HANDOFF_IMAGE_NOT_FOUND");
|
|
1910
|
+
return {
|
|
1911
|
+
bytes: new Uint8Array(readFileSync(target)),
|
|
1912
|
+
mediaType
|
|
1913
|
+
};
|
|
1914
|
+
}
|
|
1915
|
+
/**
|
|
1916
|
+
* 解析会话当前的工作目录(绝对路径),冷会话也能用。
|
|
1917
|
+
*
|
|
1918
|
+
* 不走 live agent(冷会话没有 agent):直接用 `sessionController.list()`
|
|
1919
|
+
* 里那条会话的 `cwd`(相对授权根的 wire 路径),再与数据库里的根拼出真实路径。
|
|
1920
|
+
* 解析不出来(没有授权根 / cwd 不在任何根下)就返回 undefined —— 交给上层兜底。
|
|
1921
|
+
*/
|
|
1922
|
+
async function sessionWorkspaceDir(host, roots, sessionId) {
|
|
1923
|
+
if (roots.length === 0) return void 0;
|
|
1924
|
+
const item = (await host.sessionController.list({}, new AbortController().signal)).items?.find((entry) => entry.sessionId === sessionId);
|
|
1925
|
+
const cwd = typeof item?.cwd === "string" ? item.cwd : void 0;
|
|
1926
|
+
if (cwd === void 0) return void 0;
|
|
1927
|
+
for (const root of roots) {
|
|
1928
|
+
const relative = wireRelative(root.realPath, cwd);
|
|
1929
|
+
if (relative !== void 0) return join(root.realPath, relative);
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
/** 会话当前生效的模型(pending > lastUsed > 目录默认),取不到返回 undefined。 */
|
|
1933
|
+
async function currentModelSelection(host, sessionId) {
|
|
1934
|
+
const selection = (await host.sessionController.projections({ sessionId }, new AbortController().signal))?.values?.["modelSelection"];
|
|
1935
|
+
const catalog = await host.sessionController.modelCatalog();
|
|
1936
|
+
const current = selection?.next ?? selection?.lastUsed ?? catalog.default;
|
|
1937
|
+
const provider = current?.provider;
|
|
1938
|
+
const model = current?.model;
|
|
1939
|
+
if (typeof provider !== "string" || typeof model !== "string") return void 0;
|
|
1940
|
+
return {
|
|
1941
|
+
provider,
|
|
1942
|
+
model
|
|
1943
|
+
};
|
|
1944
|
+
}
|
|
1945
|
+
/**
|
|
1946
|
+
* 内核会不会因为「这个模型看不到图」拒收?判据与内核**逐字一致**:
|
|
1947
|
+
* `inputModalities` 已声明且不含 `image`。
|
|
1948
|
+
*
|
|
1949
|
+
* 元数据缺失(`undefined`)**不算**「确认看不到图」—— 内核在那种情况下也不拒,
|
|
1950
|
+
* 我们跟着不插手(拿不准就走原路)。
|
|
1951
|
+
*/
|
|
1952
|
+
async function modelIsConfirmedTextOnly(host, sessionId) {
|
|
1953
|
+
const selection = await currentModelSelection(host, sessionId);
|
|
1954
|
+
if (selection === void 0) return false;
|
|
1955
|
+
const modalities = (await host.llm.resolveModelInfo(selection.provider, selection.model))?.inputModalities;
|
|
1956
|
+
if (!Array.isArray(modalities)) return false;
|
|
1957
|
+
return !modalities.includes("image");
|
|
1958
|
+
}
|
|
1959
|
+
/**
|
|
1960
|
+
* 需要交接时,把每个 `image` 部件换成**两行文本**;否则原样返回。
|
|
1961
|
+
*
|
|
1962
|
+
* 同一行路径要同时伺候两类读者:
|
|
1963
|
+
* - **给人看**(第一行):图片序号、原始文件名、工作目录里的短路径;
|
|
1964
|
+
* - **给机器看**(第二行 `[Image: source: <绝对路径>]`):这是读图技能明确
|
|
1965
|
+
* 识别的占位形状之一(带图片扩展名的绝对路径同样触发),任何按路径读图的
|
|
1966
|
+
* 工具都能接手,不出现任何具体插件名。
|
|
1967
|
+
*
|
|
1968
|
+
* 任何一步抛错都退回原内容:这个功能的失败**不该**把「发消息」本身弄坏。
|
|
1969
|
+
*/
|
|
1970
|
+
async function handoffImagesForTextOnlyModel(host, roots, fallbackDir, sessionId, content) {
|
|
1971
|
+
if (!content.some((part) => part.type === "image")) return content;
|
|
1972
|
+
try {
|
|
1973
|
+
if (!await modelIsConfirmedTextOnly(host, sessionId)) return content;
|
|
1974
|
+
const workspace = await sessionWorkspaceDir(host, roots, sessionId);
|
|
1975
|
+
const dir = workspace !== void 0 ? join(workspace, HANDOFF_SUBDIR) : fallbackDir;
|
|
1976
|
+
let index = 0;
|
|
1977
|
+
return content.map((part) => {
|
|
1978
|
+
if (part.type !== "image") return part;
|
|
1979
|
+
index += 1;
|
|
1980
|
+
const { absolute, relative } = writeHandoffFile(dir, workspace, part.mediaType, part.data);
|
|
1981
|
+
const name = typeof part.name === "string" && part.name.trim() !== "" ? part.name.trim() : `图片 ${index}`;
|
|
1982
|
+
return {
|
|
1983
|
+
type: "text",
|
|
1984
|
+
text: [`🖼️ 图片 ${index}「${name}」(当前模型不支持图片输入,已保存到工作目录 ${relative};请先用读图工具打开下面那行的绝对路径,看清内容再回答)`, `[Image: source: ${absolute}]`].join("\n")
|
|
1985
|
+
};
|
|
1986
|
+
});
|
|
1987
|
+
} catch {
|
|
1988
|
+
return content;
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
/**
|
|
1460
1992
|
* Build the `apiProxy` replacement out of 0.2.x host services.
|
|
1461
1993
|
*
|
|
1462
1994
|
* Every method keeps the old `{ rpcId, payload }` request shape and the old
|
|
@@ -1464,15 +1996,20 @@ function sessionIdOf(summary) {
|
|
|
1464
1996
|
* `chat-adapter.ts` and `settings-adapter.ts` are written against.
|
|
1465
1997
|
*
|
|
1466
1998
|
* @param host - host services resolved from the plugin context.
|
|
1999
|
+
* @param host - host services resolved from the plugin context.
|
|
1467
2000
|
* @returns a `SettingsApiProxy` (i.e. `DshApiProxy` plus the settings surfaces).
|
|
1468
2001
|
*/
|
|
1469
|
-
function createDsh02ApiProxy(host) {
|
|
2002
|
+
function createDsh02ApiProxy(host, options = {}) {
|
|
1470
2003
|
const mux = new FrameHub();
|
|
1471
2004
|
const hostFrames = new FrameHub();
|
|
1472
|
-
const
|
|
2005
|
+
const imageHandoff = options.imageHandoff !== false;
|
|
2006
|
+
const handoffRoots = options.handoffRoots ?? [];
|
|
2007
|
+
const imageHandoffDir = options.imageHandoffDir ?? DEFAULT_IMAGE_HANDOFF_DIR;
|
|
1473
2008
|
const fileUploadService = optionalService(host, "fileUploads");
|
|
1474
2009
|
const approvals = new ApprovalBridge(host, mux);
|
|
1475
2010
|
approvals.attach();
|
|
2011
|
+
const questions = new QuestionBridge(host, mux);
|
|
2012
|
+
questions.attach();
|
|
1476
2013
|
const relay = new SessionEventRelay(host, mux);
|
|
1477
2014
|
host.on("api-session/added", (summary) => {
|
|
1478
2015
|
const sessionId = sessionIdOf(summary);
|
|
@@ -1517,6 +2054,21 @@ function createDsh02ApiProxy(host) {
|
|
|
1517
2054
|
if (agent === void 0 || agent === null) throw Object.assign(/* @__PURE__ */ new Error(`no live Agent for session ${sessionId}`), { code: "session/agent-unavailable" });
|
|
1518
2055
|
return agent;
|
|
1519
2056
|
}
|
|
2057
|
+
/**
|
|
2058
|
+
* `agentFor` 的宽松版:把「这个会话没有 live agent」当成**正常情况**(返回 null)。
|
|
2059
|
+
*
|
|
2060
|
+
* 为什么 list 需要它:手机端会对每个会话各拉一次问题列表,而冷会话(没在跑)没有
|
|
2061
|
+
* agent 是常态。把常态报成错误,客户端只会看到一片红。
|
|
2062
|
+
* 只放过 `session/agent-unavailable` 这一个 code,其它异常照旧往上抛。
|
|
2063
|
+
*/
|
|
2064
|
+
async function agentForQuestions(sessionId) {
|
|
2065
|
+
try {
|
|
2066
|
+
return await agentFor(sessionId);
|
|
2067
|
+
} catch (error) {
|
|
2068
|
+
if (isAgentUnavailable(error)) return null;
|
|
2069
|
+
throw error;
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
1520
2072
|
return {
|
|
1521
2073
|
respond: (message) => approvals.respond(message),
|
|
1522
2074
|
sessions: {
|
|
@@ -1555,13 +2107,15 @@ function createDsh02ApiProxy(host) {
|
|
|
1555
2107
|
prompt: (request) => call(async () => {
|
|
1556
2108
|
const requestId = randomUUID();
|
|
1557
2109
|
const receiptIds = request.payload.content.filter((part) => part.type === "file").map((part) => part.receiptId);
|
|
2110
|
+
const content = imageHandoff ? await handoffImagesForTextOnlyModel(host, handoffRoots, imageHandoffDir, request.payload.sessionId, request.payload.content) : request.payload.content;
|
|
1558
2111
|
const binding = receiptIds.length === 0 ? void 0 : await (async () => {
|
|
1559
2112
|
if (fileUploadService == null) throw new Error("FILE_UPLOADS_UNAVAILABLE: this DSH build provides no ctx.fileUploads service, so file attachments cannot be sent");
|
|
1560
2113
|
return fileUploadService.bindPrompt(await agentFor(request.payload.sessionId), receiptIds, requestId);
|
|
1561
2114
|
})();
|
|
1562
2115
|
await host.sessionController.prompt({
|
|
1563
2116
|
requestId,
|
|
1564
|
-
...request.payload
|
|
2117
|
+
...request.payload,
|
|
2118
|
+
content
|
|
1565
2119
|
}, new AbortController().signal);
|
|
1566
2120
|
binding?.commit();
|
|
1567
2121
|
return { accepted: true };
|
|
@@ -1578,13 +2132,20 @@ function createDsh02ApiProxy(host) {
|
|
|
1578
2132
|
};
|
|
1579
2133
|
}),
|
|
1580
2134
|
readAttachment: (request) => call(async () => {
|
|
1581
|
-
|
|
1582
|
-
const
|
|
2135
|
+
const { sessionId, attachmentId } = request.payload;
|
|
2136
|
+
const value = await host.sessionController.attachment({
|
|
2137
|
+
sessionId,
|
|
2138
|
+
attachmentId
|
|
2139
|
+
});
|
|
1583
2140
|
return {
|
|
1584
|
-
bytes:
|
|
1585
|
-
mediaType:
|
|
2141
|
+
bytes: new Uint8Array(Buffer.from(value.data, "base64")),
|
|
2142
|
+
mediaType: value.attachment.mediaType
|
|
1586
2143
|
};
|
|
1587
2144
|
}),
|
|
2145
|
+
readHandoffImage: (request) => call(async () => {
|
|
2146
|
+
const { sessionId, name } = request.payload;
|
|
2147
|
+
return readHandoffImageFile(host, handoffRoots, imageHandoffDir, sessionId, name);
|
|
2148
|
+
}),
|
|
1588
2149
|
cancel: (request) => call(() => host.sessionController.cancel(request.payload)),
|
|
1589
2150
|
rename: (request) => call(() => host.sessionController.rename(request.payload)),
|
|
1590
2151
|
fork: (request) => call(() => host.sessionController.fork(request.payload)),
|
|
@@ -1596,7 +2157,7 @@ function createDsh02ApiProxy(host) {
|
|
|
1596
2157
|
const catalog = await host.sessionController.modelCatalog();
|
|
1597
2158
|
const selection = (await host.sessionController.projections({ sessionId }, new AbortController().signal))?.values?.["modelSelection"];
|
|
1598
2159
|
return {
|
|
1599
|
-
current: selection?.
|
|
2160
|
+
current: selection?.next ?? selection?.lastUsed ?? catalog.default,
|
|
1600
2161
|
routable: Array.isArray(catalog.routableProviders) && catalog.routableProviders.length > 0,
|
|
1601
2162
|
groups: Array.isArray(catalog.groups) ? catalog.groups : [],
|
|
1602
2163
|
failures: Array.isArray(catalog.failures) ? catalog.failures.map((entry) => ({
|
|
@@ -1655,6 +2216,65 @@ function createDsh02ApiProxy(host) {
|
|
|
1655
2216
|
return { agentPreset: await host.agentPresets.select(agent, request.payload.agentPreset) };
|
|
1656
2217
|
})
|
|
1657
2218
|
},
|
|
2219
|
+
userQuestions: {
|
|
2220
|
+
list: (request) => call(async () => {
|
|
2221
|
+
const { sessionId } = request.payload;
|
|
2222
|
+
const bridged = questions.pendingFor(sessionId);
|
|
2223
|
+
const bridgedIds = new Set(bridged.map((item) => item.callId));
|
|
2224
|
+
const agent = await agentForQuestions(sessionId);
|
|
2225
|
+
if (agent === null) return { items: bridged };
|
|
2226
|
+
const service = userQuestionsOf(agent);
|
|
2227
|
+
if (service === null || typeof service.continued !== "function") return { items: bridged };
|
|
2228
|
+
const rows = service.continued(agent);
|
|
2229
|
+
const continued = Array.isArray(rows) ? rows.map(toUserQuestionView).filter((item) => item !== void 0) : [];
|
|
2230
|
+
return { items: [...bridged, ...continued.filter((item) => !bridgedIds.has(item.callId))] };
|
|
2231
|
+
}),
|
|
2232
|
+
answer: (request) => call(async () => {
|
|
2233
|
+
const { sessionId, callId, answer } = request.payload;
|
|
2234
|
+
const agent = await agentForQuestions(sessionId);
|
|
2235
|
+
let kernelCouldNotAnswer = false;
|
|
2236
|
+
if (agent !== null) {
|
|
2237
|
+
const service = userQuestionsOf(agent);
|
|
2238
|
+
if (service !== null && typeof service.answer === "function") {
|
|
2239
|
+
let answered;
|
|
2240
|
+
try {
|
|
2241
|
+
answered = await service.answer(agent, callId, answer);
|
|
2242
|
+
} catch (error) {
|
|
2243
|
+
const code = errorCodeOf(error);
|
|
2244
|
+
if (code === "REPLY_QUEUED") return {
|
|
2245
|
+
accepted: false,
|
|
2246
|
+
reason: "reply-queued"
|
|
2247
|
+
};
|
|
2248
|
+
if (code === "BAD_ANSWER") return {
|
|
2249
|
+
accepted: false,
|
|
2250
|
+
reason: "answer-invalid"
|
|
2251
|
+
};
|
|
2252
|
+
if (code === "CALLER_NOT_LIVE" || code === "DELEGATED_CALLER") kernelCouldNotAnswer = true;
|
|
2253
|
+
else throw error;
|
|
2254
|
+
}
|
|
2255
|
+
if (answered === true) return { accepted: true };
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
const bridged = questions.answer({
|
|
2259
|
+
sessionId,
|
|
2260
|
+
callId,
|
|
2261
|
+
answer
|
|
2262
|
+
});
|
|
2263
|
+
if (bridged.accepted) return { accepted: true };
|
|
2264
|
+
if (bridged.reason === "bad-response") return {
|
|
2265
|
+
accepted: false,
|
|
2266
|
+
reason: "answer-invalid"
|
|
2267
|
+
};
|
|
2268
|
+
if (kernelCouldNotAnswer) return {
|
|
2269
|
+
accepted: false,
|
|
2270
|
+
reason: "not-answerable"
|
|
2271
|
+
};
|
|
2272
|
+
return {
|
|
2273
|
+
accepted: false,
|
|
2274
|
+
reason: "not-pending"
|
|
2275
|
+
};
|
|
2276
|
+
})
|
|
2277
|
+
},
|
|
1658
2278
|
settings: {
|
|
1659
2279
|
describe: () => call(() => host.settingsController.describe()),
|
|
1660
2280
|
mutate: (request) => call(() => host.settingsController.mutate(request.payload.ns, request.payload.ops, request.payload.expectedRevision))
|
|
@@ -2750,7 +3370,9 @@ var ApiRouter = class {
|
|
|
2750
3370
|
if (attachmentRoute !== null && method === "GET") {
|
|
2751
3371
|
this.options.auth.requireScope(principal, "files.read");
|
|
2752
3372
|
const attachmentId = decodeURIComponent(attachmentRoute[1]);
|
|
2753
|
-
const
|
|
3373
|
+
const sessionId = url.searchParams.get("sessionId")?.trim();
|
|
3374
|
+
if (!sessionId) throw new ApiError(400, "BODY_INVALID", "sessionId query parameter is required.");
|
|
3375
|
+
const { bytes, mediaType } = await this.options.chat.readAttachment(principal, sessionId, attachmentId);
|
|
2754
3376
|
res.statusCode = 200;
|
|
2755
3377
|
res.setHeader("Content-Type", mediaType);
|
|
2756
3378
|
res.setHeader("Content-Length", String(bytes.byteLength));
|
|
@@ -2758,6 +3380,19 @@ var ApiRouter = class {
|
|
|
2758
3380
|
res.end(Buffer.from(bytes));
|
|
2759
3381
|
return;
|
|
2760
3382
|
}
|
|
3383
|
+
if (pathname === "/api/v1/image-handoff" && method === "GET") {
|
|
3384
|
+
this.options.auth.requireScope(principal, "files.read");
|
|
3385
|
+
const name = url.searchParams.get("name")?.trim();
|
|
3386
|
+
const sessionId = url.searchParams.get("sessionId")?.trim();
|
|
3387
|
+
if (!name || !sessionId) throw new ApiError(400, "BODY_INVALID", "name and sessionId query parameters are required.");
|
|
3388
|
+
const { bytes, mediaType } = await this.options.chat.readHandoffImage(principal, sessionId, name);
|
|
3389
|
+
res.statusCode = 200;
|
|
3390
|
+
res.setHeader("Content-Type", mediaType);
|
|
3391
|
+
res.setHeader("Content-Length", String(bytes.byteLength));
|
|
3392
|
+
res.setHeader("Cache-Control", "private, max-age=3600");
|
|
3393
|
+
res.end(Buffer.from(bytes));
|
|
3394
|
+
return;
|
|
3395
|
+
}
|
|
2761
3396
|
const attachmentsRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/attachments$/);
|
|
2762
3397
|
if (attachmentsRoute !== null && method === "POST") {
|
|
2763
3398
|
this.options.auth.requireScope(principal, "chat.write");
|
|
@@ -2813,6 +3448,29 @@ var ApiRouter = class {
|
|
|
2813
3448
|
sendJson(res, 202, { accepted: true });
|
|
2814
3449
|
return;
|
|
2815
3450
|
}
|
|
3451
|
+
const questionsRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/questions$/);
|
|
3452
|
+
if (method === "GET" && questionsRoute !== null) {
|
|
3453
|
+
this.options.auth.requireScope(principal, "chat.read");
|
|
3454
|
+
const sessionId = decodeURIComponent(questionsRoute[1]);
|
|
3455
|
+
sendJson(res, 200, { items: await this.options.chat.listUserQuestions(principal, sessionId) });
|
|
3456
|
+
return;
|
|
3457
|
+
}
|
|
3458
|
+
const questionAnswerRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/questions\/([^/]+)\/answer$/);
|
|
3459
|
+
if (method === "POST" && questionAnswerRoute !== null) {
|
|
3460
|
+
this.options.auth.requireScope(principal, "chat.write");
|
|
3461
|
+
const sessionId = decodeURIComponent(questionAnswerRoute[1]);
|
|
3462
|
+
const callId = decodeURIComponent(questionAnswerRoute[2]);
|
|
3463
|
+
const answer = readUserQuestionAnswer(await readJson(req, 64e3));
|
|
3464
|
+
await this.options.chat.answerUserQuestion(principal, sessionId, callId, answer);
|
|
3465
|
+
this.audit(principal, "chat.question.answer", void 0, void 0, requestId, {
|
|
3466
|
+
sessionId,
|
|
3467
|
+
callId,
|
|
3468
|
+
answers: answer.answers.length,
|
|
3469
|
+
questionIds: answer.answers.map((item) => item.id)
|
|
3470
|
+
});
|
|
3471
|
+
sendJson(res, 202, { accepted: true });
|
|
3472
|
+
return;
|
|
3473
|
+
}
|
|
2816
3474
|
const sessionModelsRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/model(?:s)?$/);
|
|
2817
3475
|
if (sessionModelsRoute !== null) {
|
|
2818
3476
|
const sessionId = decodeURIComponent(sessionModelsRoute[1]);
|
|
@@ -3168,6 +3826,33 @@ function readPromptParts(body) {
|
|
|
3168
3826
|
throw new ApiError(400, "BODY_INVALID", `content[${index}].type is not supported.`);
|
|
3169
3827
|
});
|
|
3170
3828
|
}
|
|
3829
|
+
/**
|
|
3830
|
+
* 校验一次答案提交:`{ answers: [{ id, selected[], custom? }] }`。
|
|
3831
|
+
*
|
|
3832
|
+
* 为什么要逐项校验,而不是把 body 直接透传给内核:这是**用户输入**,
|
|
3833
|
+
* 形状不对时内核给的报错更难懂;而且内核的 `answer()` 会检查「一问一答」的对齐,
|
|
3834
|
+
* 类型层面能先挡掉的就不要等到那一步。
|
|
3835
|
+
*
|
|
3836
|
+
* 入参故意收 `unknown`:`readJson` 的返回值可能是 null / 数字 / 数组,
|
|
3837
|
+
* 当对象取字段会抛 TypeError 变成 500 —— 非法输入必须是 400。
|
|
3838
|
+
*/
|
|
3839
|
+
function readUserQuestionAnswer(body) {
|
|
3840
|
+
if (body === null || typeof body !== "object" || Array.isArray(body)) throw new ApiError(400, "BODY_INVALID", "The request body must be a JSON object with an answers array.");
|
|
3841
|
+
const answers = body.answers;
|
|
3842
|
+
if (!Array.isArray(answers) || answers.length === 0) throw new ApiError(400, "BODY_INVALID", "answers must be a non-empty array.");
|
|
3843
|
+
return { answers: answers.map((raw, index) => {
|
|
3844
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) throw new ApiError(400, "BODY_INVALID", `answers[${index}] must be an object.`);
|
|
3845
|
+
const item = raw;
|
|
3846
|
+
if (typeof item.id !== "string" || item.id === "") throw new ApiError(400, "BODY_INVALID", `answers[${index}].id is required.`);
|
|
3847
|
+
if (!Array.isArray(item.selected) || item.selected.some((value) => typeof value !== "string")) throw new ApiError(400, "BODY_INVALID", `answers[${index}].selected must be an array of strings.`);
|
|
3848
|
+
if (item.custom !== void 0 && typeof item.custom !== "string") throw new ApiError(400, "BODY_INVALID", `answers[${index}].custom must be a string.`);
|
|
3849
|
+
return {
|
|
3850
|
+
id: item.id,
|
|
3851
|
+
selected: [...item.selected],
|
|
3852
|
+
...item.custom === void 0 ? {} : { custom: item.custom }
|
|
3853
|
+
};
|
|
3854
|
+
}) };
|
|
3855
|
+
}
|
|
3171
3856
|
function validateStringArray(value, name) {
|
|
3172
3857
|
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) throw new ApiError(400, "BODY_INVALID", `${name} must be an array of strings.`);
|
|
3173
3858
|
return [...new Set(value)];
|
|
@@ -3964,7 +4649,13 @@ async function apply(ctx, config = {}) {
|
|
|
3964
4649
|
const events = new WorkspaceEventBus();
|
|
3965
4650
|
const auth = new AuthService(database);
|
|
3966
4651
|
const files = new FileService(database, (event) => events.emit(event));
|
|
3967
|
-
const apiProxy = createDsh02ApiProxy(ctx
|
|
4652
|
+
const apiProxy = createDsh02ApiProxy(ctx, {
|
|
4653
|
+
handoffRoots: database.listRoots().map((root) => ({
|
|
4654
|
+
id: root.id,
|
|
4655
|
+
realPath: root.realPath
|
|
4656
|
+
})),
|
|
4657
|
+
imageHandoffDir: path.join(dataDir, "image-handoff")
|
|
4658
|
+
});
|
|
3968
4659
|
const chat = new DshChatAdapter(apiProxy, database, {
|
|
3969
4660
|
agents: ctx.agents,
|
|
3970
4661
|
commands: ctx.commands
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-bridge",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.3",
|
|
4
4
|
"description": "Versioned REST bridge for DeepSeek Harness: remote access for trusted clients (chat, files, devices) plus a Phone-settings panel inside the DSH WebUI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@11.19.0",
|