@addozhang/dsh-discord 0.3.0 → 0.4.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/README.md +3 -2
- package/README.zh.md +3 -2
- package/lib/compose.d.ts +20 -0
- package/lib/compose.js +8 -3
- package/lib/dsh/api-proxy-face.d.ts +10 -0
- package/lib/dsh/api-proxy-face.js +6 -1
- package/lib/features/image-collection.d.ts +11 -0
- package/lib/features/image-collection.js +22 -8
- package/lib/features/image-download.d.ts +6 -0
- package/lib/features/image-download.js +21 -0
- package/lib/features/prompt-submission.d.ts +9 -0
- package/lib/features/prompt-submission.js +7 -1
- package/lib/features/session-mainline.d.ts +42 -1
- package/lib/features/session-mainline.js +29 -3
- package/lib/gateway/inbound.d.ts +14 -0
- package/lib/gateway/inbound.js +41 -0
- package/lib/i18n.d.ts +1 -0
- package/lib/i18n.js +2 -0
- package/lib/index.js +40 -3
- package/package.json +1 -1
- package/lib/features/image-submission.d.ts +0 -67
- package/lib/features/image-submission.js +0 -57
package/README.md
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
[](https://github.com/addozhang/dsh-discord/actions/workflows/ci.yml)
|
|
7
7
|
[](./LICENSE)
|
|
8
8
|
[](./package.json)
|
|
9
|
+
[](https://dshfind.com/en/plugins/addozhang/dsh-discord?ref=badge)
|
|
9
10
|
|
|
10
11
|
A Discord-first adapter for [DeepSeek Harness](https://github.com/deepseek-ai): run DSH sessions from a Discord guild — mention the bot to open a task thread, steer and stop turns, answer approvals and questions inline, and watch the answer stream in.
|
|
11
12
|
|
|
@@ -17,7 +18,7 @@ This is a function/namespace plugin (`inject: ['apiProxy', 'credentials', 'setti
|
|
|
17
18
|
|
|
18
19
|
## Features
|
|
19
20
|
|
|
20
|
-
- **Mention-driven sessions** — an authorized `@bot <task>` in a bound channel anchors a thread (the author's message becomes the first post), creates the DSH session, and submits the prompt at most once. Follow-ups inside the thread queue without a mention.
|
|
21
|
+
- **Mention-driven sessions** — an authorized `@bot <task>` in a bound channel anchors a thread (the author's message becomes the first post), creates the DSH session, and submits the prompt at most once. Follow-ups inside the thread queue without a mention. Attached images ride along: they are downloaded from the Discord CDN within strict size/host bounds and submitted as image parts for multimodal models.
|
|
21
22
|
- **Stream rendering** — typing indicators, a single edited head message, per-tool activity rows, fenced long-answer splitting, one-time finalize; the activity message is deleted when the turn ends.
|
|
22
23
|
- **Approvals & questions** — DSH ask frames become Discord buttons, select menus, and a free-text modal; ownership is enforced (the asker — or the thread owner on later turns — clicks), expiry sweeps fail closed, and settled controls grey out in place.
|
|
23
24
|
|
|
@@ -132,7 +133,7 @@ The settings card exposes the three high-frequency fields (guild allowlist, auto
|
|
|
132
133
|
|
|
133
134
|
```sh
|
|
134
135
|
pnpm install --ignore-scripts
|
|
135
|
-
pnpm test #
|
|
136
|
+
pnpm test # 683 tests incl. gateway/REST twin E2E
|
|
136
137
|
pnpm typecheck
|
|
137
138
|
pnpm lint
|
|
138
139
|
pnpm build # lib + client bundle
|
package/README.zh.md
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
[](https://github.com/addozhang/dsh-discord/actions/workflows/ci.yml)
|
|
7
7
|
[](./LICENSE)
|
|
8
8
|
[](./package.json)
|
|
9
|
+
[](https://dshfind.com/en/plugins/addozhang/dsh-discord?ref=badge)
|
|
9
10
|
|
|
10
11
|
[DeepSeek Harness](https://github.com/deepseek-ai) 的 Discord 优先适配器:在 Discord 服务器中运行 DSH 会话——@机器人即可开启任务线程、插话与停止运行、在线审批与回答问题,并实时观看回答流式输出。
|
|
11
12
|
|
|
@@ -17,7 +18,7 @@
|
|
|
17
18
|
|
|
18
19
|
## 功能
|
|
19
20
|
|
|
20
|
-
- **@提及驱动会话** — 在已绑定的频道中,被授权的 `@机器人 <任务>` 会锚定一个线程(你的消息成为首帖)、创建 DSH 会话,并且至多提交一次。线程内的后续消息无需 @
|
|
21
|
+
- **@提及驱动会话** — 在已绑定的频道中,被授权的 `@机器人 <任务>` 会锚定一个线程(你的消息成为首帖)、创建 DSH 会话,并且至多提交一次。线程内的后续消息无需 @ 即可排队。消息附带的图片会一并送达:在严格的大小与主机白名单约束下从 Discord CDN 下载,并作为 image parts 提交给多模态模型。
|
|
21
22
|
- **流式渲染** — typing 指示、单条头消息编辑、逐工具活动行、代码围栏感知的长文分段、一次性收尾;Turn 结束时活动消息会被删除。
|
|
22
23
|
- **审批与提问** — DSH ask 帧渲染为 Discord 按钮、下拉菜单与自由文本弹窗;所有权强制校验(提问者——或后续 Turn 的线程属主——才能点击),超时清扫 fail-closed,结算后的控件原地置灰。
|
|
23
24
|
|
|
@@ -133,7 +134,7 @@ dsh-discord:
|
|
|
133
134
|
|
|
134
135
|
```sh
|
|
135
136
|
pnpm install --ignore-scripts
|
|
136
|
-
pnpm test #
|
|
137
|
+
pnpm test # 683 tests incl. gateway/REST twin E2E
|
|
137
138
|
pnpm typecheck
|
|
138
139
|
pnpm lint
|
|
139
140
|
pnpm build # lib + client bundle
|
package/lib/compose.d.ts
CHANGED
|
@@ -31,6 +31,13 @@ export interface SessionMainlinePort {
|
|
|
31
31
|
authorId: string;
|
|
32
32
|
workspaceId: string;
|
|
33
33
|
prompt: string;
|
|
34
|
+
/** Declared image attachments carried by the message (16.50). */
|
|
35
|
+
images: ReadonlyArray<{
|
|
36
|
+
url: string;
|
|
37
|
+
filename: string;
|
|
38
|
+
declaredSize: number;
|
|
39
|
+
contentType: string;
|
|
40
|
+
}>;
|
|
34
41
|
}): Promise<{
|
|
35
42
|
outcome: 'admitted';
|
|
36
43
|
threadId: string;
|
|
@@ -47,6 +54,9 @@ export interface SessionMainlinePort {
|
|
|
47
54
|
outcome: 'prompt-rejected';
|
|
48
55
|
} | {
|
|
49
56
|
outcome: 'prompt-unknown';
|
|
57
|
+
} | {
|
|
58
|
+
outcome: 'image-failed';
|
|
59
|
+
reason: string;
|
|
50
60
|
}>;
|
|
51
61
|
continueInThread(request: {
|
|
52
62
|
applicationId: string;
|
|
@@ -55,6 +65,13 @@ export interface SessionMainlinePort {
|
|
|
55
65
|
sessionId: string;
|
|
56
66
|
messageId: string;
|
|
57
67
|
prompt: string;
|
|
68
|
+
/** Declared image attachments carried by the message (16.50). */
|
|
69
|
+
images: ReadonlyArray<{
|
|
70
|
+
url: string;
|
|
71
|
+
filename: string;
|
|
72
|
+
declaredSize: number;
|
|
73
|
+
contentType: string;
|
|
74
|
+
}>;
|
|
58
75
|
}): Promise<{
|
|
59
76
|
outcome: 'queued';
|
|
60
77
|
} | {
|
|
@@ -65,6 +82,9 @@ export interface SessionMainlinePort {
|
|
|
65
82
|
outcome: 'rejected';
|
|
66
83
|
} | {
|
|
67
84
|
outcome: 'unknown';
|
|
85
|
+
} | {
|
|
86
|
+
outcome: 'image-failed';
|
|
87
|
+
reason: string;
|
|
68
88
|
}>;
|
|
69
89
|
}
|
|
70
90
|
export interface BindingsProbe {
|
package/lib/compose.js
CHANGED
|
@@ -18,10 +18,13 @@ export const GATEWAY_URL = 'wss://gateway.discord.gg/?v=10&encoding=json';
|
|
|
18
18
|
export function routeEvent(deps, event, decision) {
|
|
19
19
|
if (event.kind === 'message') {
|
|
20
20
|
const prompt = event.content.trim();
|
|
21
|
+
const images = event.images;
|
|
22
|
+
// Text and images are independent task carriers: either alone admits.
|
|
23
|
+
const hasTask = prompt !== '' || images.length > 0;
|
|
21
24
|
// Adapter-owned thread: ordinary continuation, no mention required.
|
|
22
25
|
const sessionId = deps.bindings.sessionForThread(event.guildId, event.channelId);
|
|
23
26
|
if (sessionId !== undefined) {
|
|
24
|
-
if (
|
|
27
|
+
if (!hasTask)
|
|
25
28
|
return;
|
|
26
29
|
const request = {
|
|
27
30
|
applicationId: deps.applicationId(),
|
|
@@ -30,10 +33,11 @@ export function routeEvent(deps, event, decision) {
|
|
|
30
33
|
sessionId,
|
|
31
34
|
messageId: event.messageId,
|
|
32
35
|
prompt,
|
|
36
|
+
images,
|
|
33
37
|
};
|
|
34
38
|
void deps.mainline.continueInThread(request)
|
|
35
39
|
.then((result) => {
|
|
36
|
-
if (result.outcome === 'rejected' || result.outcome === 'unknown') {
|
|
40
|
+
if (result.outcome === 'rejected' || result.outcome === 'unknown' || result.outcome === 'image-failed') {
|
|
37
41
|
deps.logger?.warn('discord_continuation_not_queued', { ...result, messageId: event.messageId });
|
|
38
42
|
}
|
|
39
43
|
})
|
|
@@ -63,7 +67,7 @@ export function routeEvent(deps, event, decision) {
|
|
|
63
67
|
}
|
|
64
68
|
return;
|
|
65
69
|
}
|
|
66
|
-
if (
|
|
70
|
+
if (!hasTask)
|
|
67
71
|
return;
|
|
68
72
|
void deps.mainline.admitMention({
|
|
69
73
|
applicationId: deps.applicationId(),
|
|
@@ -73,6 +77,7 @@ export function routeEvent(deps, event, decision) {
|
|
|
73
77
|
authorId: event.authorId,
|
|
74
78
|
workspaceId,
|
|
75
79
|
prompt,
|
|
80
|
+
images,
|
|
76
81
|
})
|
|
77
82
|
.then((result) => {
|
|
78
83
|
if (result.outcome !== 'admitted') {
|
|
@@ -34,6 +34,10 @@ export interface DshApiProxyFace {
|
|
|
34
34
|
content: Array<{
|
|
35
35
|
type: 'text';
|
|
36
36
|
text: string;
|
|
37
|
+
} | {
|
|
38
|
+
type: 'image';
|
|
39
|
+
mediaType: string;
|
|
40
|
+
data: string;
|
|
37
41
|
}>;
|
|
38
42
|
}>): Promise<RpcResponseShape<{
|
|
39
43
|
accepted: true;
|
|
@@ -215,10 +219,16 @@ export type PromptOutcome = {
|
|
|
215
219
|
* the turn may or may not have been admitted, so callers must not resubmit.
|
|
216
220
|
* `options.rpcId` pins the adapter-owned stable request id, which the Host
|
|
217
221
|
* records on the durable `user/message` (`source.rpcId`) for reconciliation.
|
|
222
|
+
* Images (16.50) encode as ordered `image` parts after the text part — the
|
|
223
|
+
* rc.2 `session.prompt` content is parts-shaped at this seam.
|
|
218
224
|
*/
|
|
219
225
|
export declare function promptSession(dsh: DshApiProxyFace, request: {
|
|
220
226
|
sessionId: string;
|
|
221
227
|
prompt: string;
|
|
228
|
+
images?: ReadonlyArray<{
|
|
229
|
+
mediaType: string;
|
|
230
|
+
base64: string;
|
|
231
|
+
}>;
|
|
222
232
|
}, options?: ApiProxyFaceOptions & {
|
|
223
233
|
rpcId?: string;
|
|
224
234
|
}): Promise<PromptOutcome>;
|
|
@@ -174,6 +174,8 @@ export async function readWorkspaceDetail(dsh, reference, options = {}) {
|
|
|
174
174
|
* the turn may or may not have been admitted, so callers must not resubmit.
|
|
175
175
|
* `options.rpcId` pins the adapter-owned stable request id, which the Host
|
|
176
176
|
* records on the durable `user/message` (`source.rpcId`) for reconciliation.
|
|
177
|
+
* Images (16.50) encode as ordered `image` parts after the text part — the
|
|
178
|
+
* rc.2 `session.prompt` content is parts-shaped at this seam.
|
|
177
179
|
*/
|
|
178
180
|
export async function promptSession(dsh, request, options = {}) {
|
|
179
181
|
return submitPromptTurn(dsh, { ...request, mode: 'queue' }, options);
|
|
@@ -193,7 +195,10 @@ async function submitPromptTurn(dsh, request, options) {
|
|
|
193
195
|
response = await withRpcTimeout(dsh.sessions.prompt(mintRequest({
|
|
194
196
|
sessionId: request.sessionId,
|
|
195
197
|
mode: request.mode,
|
|
196
|
-
content: [
|
|
198
|
+
content: [
|
|
199
|
+
{ type: 'text', text: request.prompt },
|
|
200
|
+
...(request.images ?? []).map(image => ({ type: 'image', mediaType: image.mediaType, data: image.base64 })),
|
|
201
|
+
],
|
|
197
202
|
}, options.rpcId)), timeoutMs);
|
|
198
203
|
}
|
|
199
204
|
catch (cause) {
|
|
@@ -37,10 +37,21 @@ export interface ImageAttachment {
|
|
|
37
37
|
declaredSize: number;
|
|
38
38
|
contentType: string;
|
|
39
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* The production download port (16.50): every fetch rides the safe boundary
|
|
42
|
+
* — allowlisted Discord CDN hosts over HTTPS, one revalidated redirect hop,
|
|
43
|
+
* supported media types only.
|
|
44
|
+
*/
|
|
45
|
+
export declare function createSafeImageDownloadPort(): ImageDownloadPort;
|
|
40
46
|
export type ImageCollectionResult = {
|
|
41
47
|
outcome: 'collected';
|
|
42
48
|
images: number;
|
|
43
49
|
totalBytes: number;
|
|
50
|
+
/** The downloaded images in attachment order (16.50): callers encode them for submission. */
|
|
51
|
+
downloaded: ReadonlyArray<{
|
|
52
|
+
mediaType: string;
|
|
53
|
+
body: Uint8Array;
|
|
54
|
+
}>;
|
|
44
55
|
} | {
|
|
45
56
|
outcome: 'too-large';
|
|
46
57
|
reason: 'declared' | 'actual' | 'aggregate';
|
|
@@ -8,10 +8,22 @@
|
|
|
8
8
|
* bounded in practice by Discord CDN attachment limits and the aggregate
|
|
9
9
|
* cap, not by streaming. Refusals are plain values.
|
|
10
10
|
*/
|
|
11
|
+
import { createHttpFetchPort, fetchSafeImage } from './image-download.js';
|
|
11
12
|
/** Per-image cap (design: bounded by DSH-advertised limits; adapter-capped). */
|
|
12
13
|
export const MAX_IMAGE_BYTES = 8 * 1_024 * 1_024;
|
|
13
14
|
/** Aggregate cap across all images of one message. */
|
|
14
15
|
export const MAX_AGGREGATE_IMAGE_BYTES = 24 * 1_024 * 1_024;
|
|
16
|
+
/**
|
|
17
|
+
* The production download port (16.50): every fetch rides the safe boundary
|
|
18
|
+
* — allowlisted Discord CDN hosts over HTTPS, one revalidated redirect hop,
|
|
19
|
+
* supported media types only.
|
|
20
|
+
*/
|
|
21
|
+
export function createSafeImageDownloadPort() {
|
|
22
|
+
const http = createHttpFetchPort();
|
|
23
|
+
return {
|
|
24
|
+
download: request => fetchSafeImage(http, { url: request.url }),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
15
27
|
/**
|
|
16
28
|
* Wrap the safe download boundary with byte-count enforcement: the body size
|
|
17
29
|
* is checked against the remaining aggregate budget before it is accepted,
|
|
@@ -35,6 +47,7 @@ export function collectImages(port, request) {
|
|
|
35
47
|
let remainingAggregate = MAX_AGGREGATE_IMAGE_BYTES;
|
|
36
48
|
let images = 0;
|
|
37
49
|
let totalBytes = 0;
|
|
50
|
+
const downloaded = [];
|
|
38
51
|
const inner = port;
|
|
39
52
|
async function run() {
|
|
40
53
|
for (const attachment of request.attachments) {
|
|
@@ -50,30 +63,31 @@ export function collectImages(port, request) {
|
|
|
50
63
|
const timeout = new Promise((resolve) => {
|
|
51
64
|
timer = setTimeout(() => { resolve('timeout'); }, request.timeoutMs);
|
|
52
65
|
});
|
|
53
|
-
const
|
|
66
|
+
const downloadedImage = await Promise.race([
|
|
54
67
|
bounded.download({ url: attachment.url }).then((result) => result),
|
|
55
68
|
timeout,
|
|
56
69
|
]).finally(() => {
|
|
57
70
|
if (timer !== undefined)
|
|
58
71
|
clearTimeout(timer);
|
|
59
72
|
});
|
|
60
|
-
if (
|
|
73
|
+
if (downloadedImage === 'timeout')
|
|
61
74
|
return { outcome: 'timeout' };
|
|
62
|
-
if (
|
|
63
|
-
if (
|
|
75
|
+
if (downloadedImage.outcome === 'downloaded') {
|
|
76
|
+
if (downloadedImage.body.byteLength > MAX_IMAGE_BYTES) {
|
|
64
77
|
return { outcome: 'too-large', reason: 'actual' };
|
|
65
78
|
}
|
|
66
79
|
images += 1;
|
|
67
|
-
totalBytes +=
|
|
68
|
-
remainingAggregate -=
|
|
80
|
+
totalBytes += downloadedImage.body.byteLength;
|
|
81
|
+
remainingAggregate -= downloadedImage.body.byteLength;
|
|
82
|
+
downloaded.push({ mediaType: downloadedImage.mediaType, body: downloadedImage.body });
|
|
69
83
|
continue;
|
|
70
84
|
}
|
|
71
|
-
if (
|
|
85
|
+
if (downloadedImage.outcome === 'http-error' && downloadedImage.status === 413) {
|
|
72
86
|
return { outcome: 'too-large', reason: 'actual' };
|
|
73
87
|
}
|
|
74
88
|
return { outcome: 'download-failed' };
|
|
75
89
|
}
|
|
76
|
-
return { outcome: 'collected', images, totalBytes };
|
|
90
|
+
return { outcome: 'collected', images, totalBytes, downloaded };
|
|
77
91
|
}
|
|
78
92
|
return run();
|
|
79
93
|
}
|
|
@@ -46,3 +46,9 @@ export type SafeImageResult = {
|
|
|
46
46
|
export declare function fetchSafeImage(port: HttpFetchPort, request: {
|
|
47
47
|
url: string;
|
|
48
48
|
}): Promise<SafeImageResult>;
|
|
49
|
+
/**
|
|
50
|
+
* The production fetch boundary over the platform `fetch` (16.50). Redirects
|
|
51
|
+
* are NOT followed here — `fetchSafeImage` revalidates every hop itself —
|
|
52
|
+
* and the body is only read for a terminal 200 response.
|
|
53
|
+
*/
|
|
54
|
+
export declare function createHttpFetchPort(): HttpFetchPort;
|
|
@@ -75,3 +75,24 @@ function isParseable(rawUrl) {
|
|
|
75
75
|
export function fetchSafeImage(port, request) {
|
|
76
76
|
return fetchOnce(port, MAX_REDIRECTS, request);
|
|
77
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* The production fetch boundary over the platform `fetch` (16.50). Redirects
|
|
80
|
+
* are NOT followed here — `fetchSafeImage` revalidates every hop itself —
|
|
81
|
+
* and the body is only read for a terminal 200 response.
|
|
82
|
+
*/
|
|
83
|
+
export function createHttpFetchPort() {
|
|
84
|
+
return {
|
|
85
|
+
async fetch(url) {
|
|
86
|
+
const response = await fetch(url, { redirect: 'manual' });
|
|
87
|
+
const location = response.headers.get('location');
|
|
88
|
+
const contentType = response.headers.get('content-type');
|
|
89
|
+
const body = response.status === 200 ? new Uint8Array(await response.arrayBuffer()) : undefined;
|
|
90
|
+
return {
|
|
91
|
+
status: response.status,
|
|
92
|
+
contentType: contentType ?? undefined,
|
|
93
|
+
location: location ?? undefined,
|
|
94
|
+
body,
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -15,6 +15,8 @@ export interface DshPromptPort {
|
|
|
15
15
|
sessionId: string;
|
|
16
16
|
prompt: string;
|
|
17
17
|
mode: 'queue';
|
|
18
|
+
/** Collected images (16.50); omitted for text-only prompts so legacy shapes are untouched. */
|
|
19
|
+
images?: ReadonlyArray<PromptRequestImage>;
|
|
18
20
|
}): Promise<{
|
|
19
21
|
outcome: 'accepted';
|
|
20
22
|
} | {
|
|
@@ -24,6 +26,11 @@ export interface DshPromptPort {
|
|
|
24
26
|
outcome: 'unknown';
|
|
25
27
|
}>;
|
|
26
28
|
}
|
|
29
|
+
/** One collected image, ready for the prompt parts encoding. */
|
|
30
|
+
export interface PromptRequestImage {
|
|
31
|
+
mediaType: string;
|
|
32
|
+
base64: string;
|
|
33
|
+
}
|
|
27
34
|
export interface PromptSubmissionDeps {
|
|
28
35
|
prompts: DshPromptPort;
|
|
29
36
|
intents: IntentStore;
|
|
@@ -44,6 +51,8 @@ export interface PromptRequest {
|
|
|
44
51
|
requestId: string;
|
|
45
52
|
sessionId: string;
|
|
46
53
|
prompt: string;
|
|
54
|
+
/** Collected images (16.50); omitted for text-only prompts. */
|
|
55
|
+
images?: ReadonlyArray<PromptRequestImage>;
|
|
47
56
|
}
|
|
48
57
|
export declare function createPromptSubmissionFlow(deps: PromptSubmissionDeps): {
|
|
49
58
|
submitOnce(request: PromptRequest): Promise<PromptSubmissionResult>;
|
|
@@ -10,7 +10,12 @@
|
|
|
10
10
|
import { hashPayload } from '../state/intents.js';
|
|
11
11
|
export function createPromptSubmissionFlow(deps) {
|
|
12
12
|
async function submit(requestId, request) {
|
|
13
|
-
const
|
|
13
|
+
const images = request.images ?? [];
|
|
14
|
+
const contentHash = await hashPayload({
|
|
15
|
+
sessionId: request.sessionId,
|
|
16
|
+
prompt: request.prompt,
|
|
17
|
+
...(images.length > 0 ? { images } : {}),
|
|
18
|
+
});
|
|
14
19
|
const claim = await deps.intents.claim({
|
|
15
20
|
messageId: requestId,
|
|
16
21
|
contentHash,
|
|
@@ -26,6 +31,7 @@ export function createPromptSubmissionFlow(deps) {
|
|
|
26
31
|
sessionId: request.sessionId,
|
|
27
32
|
prompt: request.prompt,
|
|
28
33
|
mode: 'queue',
|
|
34
|
+
...(images.length > 0 ? { images } : {}),
|
|
29
35
|
});
|
|
30
36
|
if (submitted.outcome === 'accepted') {
|
|
31
37
|
await deps.intents.resolve(requestId, 'succeeded', deps.nowMs());
|
|
@@ -9,13 +9,44 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import type { createThreadCreationFlow } from './thread-creation.js';
|
|
11
11
|
import type { createSessionCreationFlow } from './session-creation.js';
|
|
12
|
-
import type { createPromptSubmissionFlow } from './prompt-submission.js';
|
|
12
|
+
import type { createPromptSubmissionFlow, PromptRequestImage } from './prompt-submission.js';
|
|
13
13
|
import type { TurnTracker } from './turn-ownership.js';
|
|
14
|
+
/** Declared image metadata as carried on the normalized wire message. */
|
|
15
|
+
export interface MainlineWireImage {
|
|
16
|
+
url: string;
|
|
17
|
+
filename: string;
|
|
18
|
+
declaredSize: number;
|
|
19
|
+
contentType: string;
|
|
20
|
+
}
|
|
21
|
+
/** One collected image, ready for submission. */
|
|
22
|
+
export type MainlineImage = PromptRequestImage;
|
|
23
|
+
/**
|
|
24
|
+
* The bounded collection boundary (design.md §12, 16.50): declared wire
|
|
25
|
+
* metadata goes in, base64-encoded images come out. The composition root
|
|
26
|
+
* implements it over the safe-download boundary — this layer never touches
|
|
27
|
+
* the network itself.
|
|
28
|
+
*/
|
|
29
|
+
export interface MainlineImageCollector {
|
|
30
|
+
collect(request: {
|
|
31
|
+
attachments: ReadonlyArray<{
|
|
32
|
+
url: string;
|
|
33
|
+
declaredSize: number;
|
|
34
|
+
contentType: string;
|
|
35
|
+
}>;
|
|
36
|
+
}): Promise<{
|
|
37
|
+
outcome: 'collected';
|
|
38
|
+
images: ReadonlyArray<MainlineImage>;
|
|
39
|
+
} | {
|
|
40
|
+
outcome: 'failed';
|
|
41
|
+
reason: string;
|
|
42
|
+
}>;
|
|
43
|
+
}
|
|
14
44
|
export interface SessionMainlineDeps {
|
|
15
45
|
threads: ReturnType<typeof createThreadCreationFlow>;
|
|
16
46
|
sessions: ReturnType<typeof createSessionCreationFlow>;
|
|
17
47
|
prompts: ReturnType<typeof createPromptSubmissionFlow>;
|
|
18
48
|
turns: TurnTracker;
|
|
49
|
+
images: MainlineImageCollector;
|
|
19
50
|
}
|
|
20
51
|
export type MentionMainlineResult = {
|
|
21
52
|
outcome: 'admitted';
|
|
@@ -33,6 +64,9 @@ export type MentionMainlineResult = {
|
|
|
33
64
|
outcome: 'prompt-rejected';
|
|
34
65
|
} | {
|
|
35
66
|
outcome: 'prompt-unknown';
|
|
67
|
+
} | {
|
|
68
|
+
outcome: 'image-failed';
|
|
69
|
+
reason: string;
|
|
36
70
|
};
|
|
37
71
|
export interface MentionMainlineRequest {
|
|
38
72
|
applicationId: string;
|
|
@@ -43,6 +77,8 @@ export interface MentionMainlineRequest {
|
|
|
43
77
|
authorId: string;
|
|
44
78
|
workspaceId: string;
|
|
45
79
|
prompt: string;
|
|
80
|
+
/** Declared image attachments (16.50); omitted for text-only mentions. */
|
|
81
|
+
images?: ReadonlyArray<MainlineWireImage>;
|
|
46
82
|
}
|
|
47
83
|
export type ContinuationMainlineResult = {
|
|
48
84
|
outcome: 'queued';
|
|
@@ -54,6 +90,9 @@ export type ContinuationMainlineResult = {
|
|
|
54
90
|
outcome: 'rejected';
|
|
55
91
|
} | {
|
|
56
92
|
outcome: 'unknown';
|
|
93
|
+
} | {
|
|
94
|
+
outcome: 'image-failed';
|
|
95
|
+
reason: string;
|
|
57
96
|
};
|
|
58
97
|
export interface ContinuationMainlineRequest {
|
|
59
98
|
applicationId: string;
|
|
@@ -62,6 +101,8 @@ export interface ContinuationMainlineRequest {
|
|
|
62
101
|
sessionId: string;
|
|
63
102
|
messageId: string;
|
|
64
103
|
prompt: string;
|
|
104
|
+
/** Declared image attachments (16.50); omitted for text-only messages. */
|
|
105
|
+
images?: ReadonlyArray<MainlineWireImage>;
|
|
65
106
|
}
|
|
66
107
|
/**
|
|
67
108
|
* The stable, adapter-owned request id for a submitted prompt: derived from
|
|
@@ -30,17 +30,38 @@ function mapSubmission(result) {
|
|
|
30
30
|
return { outcome: 'conflict' };
|
|
31
31
|
return result.outcome === 'rejected' ? { outcome: 'rejected' } : { outcome: 'unknown' };
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Collect the request's declared images through the bounded boundary. A
|
|
35
|
+
* text-only request never touches the collector; a failed collection is a
|
|
36
|
+
* first-class outcome so no degraded text-only submission is sent silently.
|
|
37
|
+
*/
|
|
38
|
+
async function collectRequestImages(deps, images) {
|
|
39
|
+
if (images.length === 0)
|
|
40
|
+
return { outcome: 'collected', images: [] };
|
|
41
|
+
const collected = await deps.images.collect({
|
|
42
|
+
attachments: images.map(({ url, declaredSize, contentType }) => ({ url, declaredSize, contentType })),
|
|
43
|
+
});
|
|
44
|
+
if (collected.outcome !== 'collected')
|
|
45
|
+
return { outcome: 'image-failed', reason: collected.reason };
|
|
46
|
+
return collected;
|
|
47
|
+
}
|
|
33
48
|
export function createSessionMainline(deps) {
|
|
34
49
|
return {
|
|
35
50
|
async admitMention(request) {
|
|
51
|
+
const declaredImages = request.images ?? [];
|
|
52
|
+
const images = await collectRequestImages(deps, declaredImages);
|
|
53
|
+
if (images.outcome !== 'collected')
|
|
54
|
+
return images;
|
|
36
55
|
// The source message id is the durable intent: one thread per message,
|
|
37
|
-
// deterministic recovery on redelivery (design.md §4, §10).
|
|
56
|
+
// deterministic recovery on redelivery (design.md §4, §10). An
|
|
57
|
+
// image-only mention names the thread after its first image's filename.
|
|
58
|
+
const title = request.prompt !== '' ? request.prompt : (declaredImages[0]?.filename ?? '');
|
|
38
59
|
const thread = await deps.threads.ensureThread({
|
|
39
60
|
sourceMessageId: request.messageId,
|
|
40
|
-
contentHash: await hashPayload({ prompt: request.prompt }),
|
|
61
|
+
contentHash: await hashPayload({ prompt: request.prompt, images: declaredImages }),
|
|
41
62
|
guildId: request.guildId,
|
|
42
63
|
parentChannelId: request.channelId,
|
|
43
|
-
threadName: safeTitle(
|
|
64
|
+
threadName: safeTitle(title),
|
|
44
65
|
creatorUserId: request.authorId,
|
|
45
66
|
});
|
|
46
67
|
if (thread.outcome === 'conflict')
|
|
@@ -66,6 +87,7 @@ export function createSessionMainline(deps) {
|
|
|
66
87
|
requestId,
|
|
67
88
|
sessionId,
|
|
68
89
|
prompt: request.prompt,
|
|
90
|
+
...(images.images.length > 0 ? { images: images.images } : {}),
|
|
69
91
|
});
|
|
70
92
|
if (submitted.outcome === 'accepted') {
|
|
71
93
|
ownTurn(deps, { sessionId, requestId, threadId });
|
|
@@ -81,11 +103,15 @@ export function createSessionMainline(deps) {
|
|
|
81
103
|
return submitted.outcome === 'rejected' ? { outcome: 'prompt-rejected' } : { outcome: 'prompt-unknown' };
|
|
82
104
|
},
|
|
83
105
|
async continueInThread(request) {
|
|
106
|
+
const images = await collectRequestImages(deps, request.images ?? []);
|
|
107
|
+
if (images.outcome !== 'collected')
|
|
108
|
+
return images;
|
|
84
109
|
const requestId = requestIdFor(request.messageId);
|
|
85
110
|
const submitted = await deps.prompts.submitOnce({
|
|
86
111
|
requestId,
|
|
87
112
|
sessionId: request.sessionId,
|
|
88
113
|
prompt: request.prompt,
|
|
114
|
+
...(images.images.length > 0 ? { images: images.images } : {}),
|
|
89
115
|
});
|
|
90
116
|
if (submitted.outcome === 'accepted') {
|
|
91
117
|
ownTurn(deps, { sessionId: request.sessionId, requestId, threadId: request.threadId });
|
package/lib/gateway/inbound.d.ts
CHANGED
|
@@ -40,6 +40,20 @@ export interface NormalizedMessage {
|
|
|
40
40
|
mentionedBot: boolean;
|
|
41
41
|
/** The snowflake of the message this one replies to, when present and valid. */
|
|
42
42
|
repliedToId: DiscordSnowflake | undefined;
|
|
43
|
+
/**
|
|
44
|
+
* DECLARED image-attachment metadata (16.50): supported types only,
|
|
45
|
+
* malformed entries skipped, capped at Discord's per-message limit. No
|
|
46
|
+
* bytes cross this boundary — download happens later, behind the
|
|
47
|
+
* allowlisted fetch boundary.
|
|
48
|
+
*/
|
|
49
|
+
images: DiscordImageAttachment[];
|
|
50
|
+
}
|
|
51
|
+
/** One supported image attachment, as declared by the wire. */
|
|
52
|
+
export interface DiscordImageAttachment {
|
|
53
|
+
url: string;
|
|
54
|
+
filename: string;
|
|
55
|
+
declaredSize: number;
|
|
56
|
+
contentType: string;
|
|
43
57
|
}
|
|
44
58
|
/** A validated guild interaction with its actor identity. */
|
|
45
59
|
export interface NormalizedInteraction {
|
package/lib/gateway/inbound.js
CHANGED
|
@@ -40,6 +40,46 @@ function extractRoleIds(payload) {
|
|
|
40
40
|
function asSnowflake(value) {
|
|
41
41
|
return isDiscordSnowflake(value) ? value : undefined;
|
|
42
42
|
}
|
|
43
|
+
/** Image content types the adapter carries (design.md §12). */
|
|
44
|
+
const SUPPORTED_IMAGE_TYPES = new Set([
|
|
45
|
+
'image/jpeg',
|
|
46
|
+
'image/png',
|
|
47
|
+
'image/webp',
|
|
48
|
+
'image/gif',
|
|
49
|
+
]);
|
|
50
|
+
/** Discord's per-message attachment cap; a defensive bound on untrusted input. */
|
|
51
|
+
const MAX_MESSAGE_IMAGES = 10;
|
|
52
|
+
/**
|
|
53
|
+
* Narrow untrusted `attachments` onto declared image metadata. Entries that
|
|
54
|
+
* are not records, lack any required field, or declare an unsupported media
|
|
55
|
+
* type are skipped — a malformed attachment never rejects the message.
|
|
56
|
+
*/
|
|
57
|
+
function extractImages(payload) {
|
|
58
|
+
const raw = payload['attachments'];
|
|
59
|
+
if (!Array.isArray(raw))
|
|
60
|
+
return [];
|
|
61
|
+
const images = [];
|
|
62
|
+
for (const entry of raw) {
|
|
63
|
+
if (images.length >= MAX_MESSAGE_IMAGES)
|
|
64
|
+
break;
|
|
65
|
+
if (!isRecord(entry))
|
|
66
|
+
continue;
|
|
67
|
+
const url = entry['url'];
|
|
68
|
+
const filename = entry['filename'];
|
|
69
|
+
const size = entry['size'];
|
|
70
|
+
const contentType = entry['content_type'];
|
|
71
|
+
if (typeof url !== 'string' || url === '')
|
|
72
|
+
continue;
|
|
73
|
+
if (typeof filename !== 'string' || filename === '')
|
|
74
|
+
continue;
|
|
75
|
+
if (typeof size !== 'number' || !Number.isFinite(size) || size < 0)
|
|
76
|
+
continue;
|
|
77
|
+
if (typeof contentType !== 'string' || !SUPPORTED_IMAGE_TYPES.has(contentType))
|
|
78
|
+
continue;
|
|
79
|
+
images.push({ url, filename, declaredSize: size, contentType });
|
|
80
|
+
}
|
|
81
|
+
return images;
|
|
82
|
+
}
|
|
43
83
|
function parseMessage(payload, selfUserId) {
|
|
44
84
|
// Webhook-authored messages are app-driven surfaces, not member input:
|
|
45
85
|
// their author object need not carry `bot: true`, and anyone with
|
|
@@ -93,6 +133,7 @@ function parseMessage(payload, selfUserId) {
|
|
|
93
133
|
content: stripped.text,
|
|
94
134
|
mentionedBot: stripped.mentioned,
|
|
95
135
|
repliedToId,
|
|
136
|
+
images: extractImages(payload),
|
|
96
137
|
},
|
|
97
138
|
};
|
|
98
139
|
}
|
package/lib/i18n.d.ts
CHANGED
package/lib/i18n.js
CHANGED
|
@@ -84,6 +84,7 @@ const zh = {
|
|
|
84
84
|
mainlineSessionRejected: '⚠️ DSH 拒绝了会话创建(工作区可能已失效);请稍后重试或重新 /project bind。',
|
|
85
85
|
mainlineSessionUnknown: '⚠️ 会话创建结果未知;请到 DSH Web 确认后再重试,不会自动重复创建。',
|
|
86
86
|
mainlinePromptRejected: '⚠️ DSH 拒绝了任务提交。',
|
|
87
|
+
mainlineImageFailed: '⚠️ 附件图片获取失败,任务未提交;请重试或改用文字描述。',
|
|
87
88
|
mainlinePromptUnknown: '⚠️ 任务提交结果未知;为避免重复执行不会自动重发,请确认后重试。',
|
|
88
89
|
continuationRejected: '⚠️ 消息提交被 DSH 拒绝。',
|
|
89
90
|
continuationUnknown: '⚠️ 消息提交结果未知;不会自动重发,请确认后重试。',
|
|
@@ -207,6 +208,7 @@ const en = {
|
|
|
207
208
|
mainlineSessionRejected: '⚠️ DSH rejected the session creation (the workspace may be stale); try again later or re-run /project bind.',
|
|
208
209
|
mainlineSessionUnknown: '⚠️ Session creation outcome unknown; confirm on the DSH web console before retrying. It will not be resubmitted automatically.',
|
|
209
210
|
mainlinePromptRejected: '⚠️ DSH rejected the task submission.',
|
|
211
|
+
mainlineImageFailed: '⚠️ Could not fetch the attached image(s); the task was not submitted. Retry or describe it in text.',
|
|
210
212
|
mainlinePromptUnknown: '⚠️ Task submission outcome unknown; it will not be resent automatically to avoid double execution. Confirm and retry.',
|
|
211
213
|
continuationRejected: '⚠️ The message submission was rejected by DSH.',
|
|
212
214
|
continuationUnknown: '⚠️ Message submission outcome unknown; it will not be resent automatically. Confirm and retry.',
|
package/lib/index.js
CHANGED
|
@@ -31,6 +31,7 @@ import { createSessionCreationFlow } from './features/session-creation.js';
|
|
|
31
31
|
import { createResumeCandidatesPort } from './features/session-resume.js';
|
|
32
32
|
import { createPromptSubmissionFlow } from './features/prompt-submission.js';
|
|
33
33
|
import { createSessionMainline, requestIdFor } from './features/session-mainline.js';
|
|
34
|
+
import { collectImages, createSafeImageDownloadPort } from './features/image-collection.js';
|
|
34
35
|
import { startLiveRender } from './stream/live.js';
|
|
35
36
|
import { planWorkspaceChannel, workspaceChannelName } from './features/workspace-channel.js';
|
|
36
37
|
/** Stable Cordis plugin name for diagnostics. */
|
|
@@ -286,7 +287,37 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
|
|
|
286
287
|
createSession: request => createSessionViaProxy(apiProxy, request, { log: rpcLog }),
|
|
287
288
|
};
|
|
288
289
|
const dshPromptPort = {
|
|
289
|
-
submit: request => promptSession(apiProxy, {
|
|
290
|
+
submit: request => promptSession(apiProxy, {
|
|
291
|
+
sessionId: request.sessionId,
|
|
292
|
+
prompt: request.prompt,
|
|
293
|
+
...(request.images === undefined ? {} : { images: request.images }),
|
|
294
|
+
}, { log: rpcLog, rpcId: request.requestId }),
|
|
295
|
+
};
|
|
296
|
+
/**
|
|
297
|
+
* The bounded image collection boundary (16.50): downloads ride the
|
|
298
|
+
* safe-download port (allowlisted CDN hosts, capped sizes, timeout) and
|
|
299
|
+
* come back base64-encoded for the prompt parts.
|
|
300
|
+
*/
|
|
301
|
+
const IMAGE_DOWNLOAD_TIMEOUT_MS = 15_000;
|
|
302
|
+
const imageCollector = {
|
|
303
|
+
collect: async ({ attachments }) => {
|
|
304
|
+
const result = await collectImages(createSafeImageDownloadPort(), {
|
|
305
|
+
nowMs: Date.now(),
|
|
306
|
+
timeoutMs: IMAGE_DOWNLOAD_TIMEOUT_MS,
|
|
307
|
+
attachments,
|
|
308
|
+
});
|
|
309
|
+
if (result.outcome !== 'collected') {
|
|
310
|
+
const reason = result.outcome === 'too-large' ? `too-large:${result.reason}` : result.outcome;
|
|
311
|
+
return { outcome: 'failed', reason };
|
|
312
|
+
}
|
|
313
|
+
return {
|
|
314
|
+
outcome: 'collected',
|
|
315
|
+
images: result.downloaded.map(image => ({
|
|
316
|
+
mediaType: image.mediaType,
|
|
317
|
+
base64: Buffer.from(image.body).toString('base64'),
|
|
318
|
+
})),
|
|
319
|
+
};
|
|
320
|
+
},
|
|
290
321
|
};
|
|
291
322
|
const composedMainline = createSessionMainline({
|
|
292
323
|
threads: createThreadCreationFlow({
|
|
@@ -305,6 +336,7 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
|
|
|
305
336
|
nowMs: () => Date.now(),
|
|
306
337
|
}),
|
|
307
338
|
turns: turnTracker,
|
|
339
|
+
images: imageCollector,
|
|
308
340
|
});
|
|
309
341
|
/** Mainline outcome → copy key; the string resolves live per language. */
|
|
310
342
|
const MAINLINE_FAILURE_KEYS = {
|
|
@@ -314,6 +346,7 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
|
|
|
314
346
|
'session-unknown': 'mainlineSessionUnknown',
|
|
315
347
|
'prompt-rejected': 'mainlinePromptRejected',
|
|
316
348
|
'prompt-unknown': 'mainlinePromptUnknown',
|
|
349
|
+
'image-failed': 'mainlineImageFailed',
|
|
317
350
|
};
|
|
318
351
|
const mainline = {
|
|
319
352
|
admitMention: async (request) => {
|
|
@@ -334,9 +367,13 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
|
|
|
334
367
|
},
|
|
335
368
|
continueInThread: async (request) => {
|
|
336
369
|
const result = await composedMainline.continueInThread(request);
|
|
337
|
-
if (result.outcome === 'rejected' || result.outcome === 'unknown') {
|
|
370
|
+
if (result.outcome === 'rejected' || result.outcome === 'unknown' || result.outcome === 'image-failed') {
|
|
338
371
|
rpcLog('discord_continuation_not_queued', { messageId: request.messageId, outcome: result.outcome });
|
|
339
|
-
const notice = result.outcome === 'rejected'
|
|
372
|
+
const notice = result.outcome === 'rejected'
|
|
373
|
+
? copy.continuationRejected
|
|
374
|
+
: result.outcome === 'image-failed'
|
|
375
|
+
? copy.mainlineImageFailed
|
|
376
|
+
: copy.continuationUnknown;
|
|
340
377
|
void withRest(rest => rest.request('POST', `/channels/${request.threadId}/messages`, { content: notice, flags: DISCORD_SUPPRESS_NOTIFICATIONS_FLAG, allowed_mentions: ALLOWED_MENTIONS_NONE }))
|
|
341
378
|
.catch((cause) => { rpcLog('discord_mainline_notice_failed', String(cause)); });
|
|
342
379
|
}
|
package/package.json
CHANGED
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Image submission through `session.prompt` (design.md §12, task 12.3).
|
|
3
|
-
* Mixed text/images encode into ordered prompt parts; the model's modality
|
|
4
|
-
* is checked BEFORE any DSH call; and the request id claims an intent so a
|
|
5
|
-
* duplicate Discord delivery never resubmits. Host outcomes map to values
|
|
6
|
-
* with the intent state kept honest (failed on rejection, unknown preserved
|
|
7
|
-
* for reconciliation).
|
|
8
|
-
*/
|
|
9
|
-
import { type IntentStore } from '../state/intents.js';
|
|
10
|
-
export type PromptPart = {
|
|
11
|
-
type: 'text';
|
|
12
|
-
text: string;
|
|
13
|
-
} | {
|
|
14
|
-
type: 'image';
|
|
15
|
-
mediaType: string;
|
|
16
|
-
data: string;
|
|
17
|
-
};
|
|
18
|
-
export interface DshImagePromptPort {
|
|
19
|
-
submit(request: {
|
|
20
|
-
requestId: string;
|
|
21
|
-
sessionId: string;
|
|
22
|
-
parts: readonly PromptPart[];
|
|
23
|
-
}): Promise<{
|
|
24
|
-
outcome: 'accepted';
|
|
25
|
-
} | {
|
|
26
|
-
outcome: 'rejected';
|
|
27
|
-
} | {
|
|
28
|
-
outcome: 'unknown';
|
|
29
|
-
}>;
|
|
30
|
-
}
|
|
31
|
-
export interface ImageSubmissionDeps {
|
|
32
|
-
prompts: DshImagePromptPort;
|
|
33
|
-
intents: IntentStore;
|
|
34
|
-
/** Whether the session's current model accepts image parts. */
|
|
35
|
-
modality: () => boolean;
|
|
36
|
-
nowMs: () => number;
|
|
37
|
-
}
|
|
38
|
-
export type ImageSubmissionResult = {
|
|
39
|
-
outcome: 'accepted';
|
|
40
|
-
} | {
|
|
41
|
-
outcome: 'rejected';
|
|
42
|
-
} | {
|
|
43
|
-
outcome: 'unknown';
|
|
44
|
-
} | {
|
|
45
|
-
outcome: 'already-submitted';
|
|
46
|
-
} | {
|
|
47
|
-
outcome: 'conflict';
|
|
48
|
-
} | {
|
|
49
|
-
outcome: 'refused';
|
|
50
|
-
reason: 'unsupported-modality';
|
|
51
|
-
};
|
|
52
|
-
/** Encode text plus images into ordered prompt parts. */
|
|
53
|
-
export declare function encodePromptParts(text: string, images: ReadonlyArray<{
|
|
54
|
-
mediaType: string;
|
|
55
|
-
base64: string;
|
|
56
|
-
}>): PromptPart[];
|
|
57
|
-
export declare function createImageSubmissionFlow(deps: ImageSubmissionDeps): {
|
|
58
|
-
submit(request: {
|
|
59
|
-
requestId: string;
|
|
60
|
-
sessionId: string;
|
|
61
|
-
text: string;
|
|
62
|
-
images: ReadonlyArray<{
|
|
63
|
-
mediaType: string;
|
|
64
|
-
base64: string;
|
|
65
|
-
}>;
|
|
66
|
-
}): Promise<ImageSubmissionResult>;
|
|
67
|
-
};
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Image submission through `session.prompt` (design.md §12, task 12.3).
|
|
3
|
-
* Mixed text/images encode into ordered prompt parts; the model's modality
|
|
4
|
-
* is checked BEFORE any DSH call; and the request id claims an intent so a
|
|
5
|
-
* duplicate Discord delivery never resubmits. Host outcomes map to values
|
|
6
|
-
* with the intent state kept honest (failed on rejection, unknown preserved
|
|
7
|
-
* for reconciliation).
|
|
8
|
-
*/
|
|
9
|
-
import { hashPayload } from '../state/intents.js';
|
|
10
|
-
/** Encode text plus images into ordered prompt parts. */
|
|
11
|
-
export function encodePromptParts(text, images) {
|
|
12
|
-
const parts = [];
|
|
13
|
-
const trimmed = text.trim();
|
|
14
|
-
if (trimmed !== '')
|
|
15
|
-
parts.push({ type: 'text', text: trimmed });
|
|
16
|
-
for (const image of images) {
|
|
17
|
-
parts.push({ type: 'image', mediaType: image.mediaType, data: image.base64 });
|
|
18
|
-
}
|
|
19
|
-
return parts;
|
|
20
|
-
}
|
|
21
|
-
export function createImageSubmissionFlow(deps) {
|
|
22
|
-
return {
|
|
23
|
-
async submit(request) {
|
|
24
|
-
const parts = encodePromptParts(request.text, request.images);
|
|
25
|
-
// Modality gate: refuse BEFORE the intent claim so an explicit user
|
|
26
|
-
// retry with a text-only message is a clean new attempt.
|
|
27
|
-
if (request.images.length > 0 && !deps.modality()) {
|
|
28
|
-
return { outcome: 'refused', reason: 'unsupported-modality' };
|
|
29
|
-
}
|
|
30
|
-
const contentHash = await hashPayload({ sessionId: request.sessionId, parts });
|
|
31
|
-
const claim = await deps.intents.claim({
|
|
32
|
-
messageId: request.requestId,
|
|
33
|
-
contentHash,
|
|
34
|
-
claimedAtMs: deps.nowMs(),
|
|
35
|
-
});
|
|
36
|
-
if (claim.outcome === 'conflict')
|
|
37
|
-
return { outcome: 'conflict' };
|
|
38
|
-
if (claim.outcome === 'duplicate')
|
|
39
|
-
return { outcome: 'already-submitted' };
|
|
40
|
-
const submitted = await deps.prompts.submit({
|
|
41
|
-
requestId: request.requestId,
|
|
42
|
-
sessionId: request.sessionId,
|
|
43
|
-
parts,
|
|
44
|
-
});
|
|
45
|
-
if (submitted.outcome === 'accepted') {
|
|
46
|
-
await deps.intents.resolve(request.requestId, 'succeeded', deps.nowMs());
|
|
47
|
-
return { outcome: 'accepted' };
|
|
48
|
-
}
|
|
49
|
-
if (submitted.outcome === 'rejected') {
|
|
50
|
-
await deps.intents.resolve(request.requestId, 'failed', deps.nowMs());
|
|
51
|
-
return { outcome: 'rejected' };
|
|
52
|
-
}
|
|
53
|
-
await deps.intents.resolve(request.requestId, 'unknown', deps.nowMs());
|
|
54
|
-
return { outcome: 'unknown' };
|
|
55
|
-
},
|
|
56
|
-
};
|
|
57
|
-
}
|