ompchamber 1.1.0 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/index.html +1 -1
- package/dist/client/static/css/index.b938f4a693.css +2 -0
- package/dist/client/static/js/77.8022a800b5.js +139 -0
- package/dist/client/static/js/async/{1k.5974df3b87.js → 1k.a411fe5b43.js} +2 -2
- package/dist/client/static/js/async/{3y.71f2ccf3b6.js → 3y.3186ecdd63.js} +5 -5
- package/dist/client/static/js/async/5b.d88304bc13.js +1 -0
- package/dist/client/static/js/async/66.86544ad741.js +93 -0
- package/dist/client/static/js/async/7g.db82aca7ba.js +1 -0
- package/dist/client/static/js/async/7l.fadbbc0583.js +1 -0
- package/dist/client/static/js/async/{dc.856fda6560.js → ar.ad5c8822a8.js} +2 -2
- package/dist/client/static/js/async/cx.9c8268d542.js +226 -0
- package/dist/client/static/js/async/e.6443e739a2.js +79 -0
- package/dist/client/static/js/async/f7.46971e9d6d.js +2 -0
- package/dist/client/static/js/async/g.ee41bfed7f.js +2 -0
- package/dist/client/static/js/async/h1.d7091cef78.js +1 -0
- package/dist/client/static/js/async/hs.e7c59d8090.js +41 -0
- package/dist/client/static/js/async/jl.4911fa3383.js +1 -0
- package/dist/client/static/js/async/{k.7466330d1a.js → k.d16cd09ae7.js} +2 -2
- package/dist/client/static/js/async/{m.c038c71b9f.js → m.2e1ea3db9e.js} +2 -2
- package/dist/client/static/js/async/m3.d2b05f9604.js +2 -0
- package/dist/client/static/js/async/mt.853be2b5aa.js +1 -0
- package/dist/client/static/js/async/{oc.9c060c9779.js → oc.eb22093f05.js} +1 -1
- package/dist/client/static/js/async/{og.634fba99ac.js → og.53f068804c.js} +1 -1
- package/dist/client/static/js/async/{5b.a5ccbc19dc.js → q.b453193538.js} +10 -10
- package/dist/client/static/js/async/q5.88b50ffa95.js +286 -0
- package/dist/client/static/js/async/q8.7f1b48e9cd.js +21 -0
- package/dist/client/static/js/async/{sy.d16be843a7.js → s.aad2ecebc6.js} +1 -1
- package/dist/client/static/js/async/{tr.e878658140.js → tr.2a0d2b7600.js} +1 -1
- package/dist/client/static/js/async/vt.20406a5604.js +1 -0
- package/dist/client/static/js/async/x1.fe8af17016.js +90 -0
- package/dist/client/static/js/async/yg.5a94e1fc8a.js +149 -0
- package/dist/client/static/js/async/yx.af2936c368.js +10 -0
- package/dist/client/static/js/index.5fe7a2cb64.js +34 -0
- package/package.json +1 -1
- package/src/client/components/common/file-icon/index.tsx +4 -1
- package/src/client/components/common/image-viewer/index.tsx +224 -0
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +57 -43
- package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +31 -24
- package/src/client/components/settings/Modal.tsx +1 -0
- package/src/client/components/settings/categories/ChatSettings.tsx +6 -2
- package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +99 -0
- package/src/client/components/workspace/btw-panel/AskCard.tsx +155 -0
- package/src/client/components/workspace/btw-panel/Composer.tsx +183 -0
- package/src/client/components/workspace/btw-panel/Messages.tsx +100 -0
- package/src/client/components/workspace/btw-panel/TopicMenu.tsx +106 -0
- package/src/client/components/workspace/btw-panel/attachments.ts +80 -0
- package/src/client/components/workspace/btw-panel/index.tsx +88 -0
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +37 -21
- package/src/client/components/workspace/chat-timeline/MinimapShortcuts.tsx +49 -12
- package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +10 -6
- package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +17 -0
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +44 -40
- package/src/client/components/workspace/chat-timeline/index.tsx +11 -9
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +25 -7
- package/src/client/components/workspace/editor/Toolbar.tsx +32 -23
- package/src/client/components/workspace/editor/index.tsx +45 -36
- package/src/client/hooks/chat/btw/index.ts +180 -0
- package/src/client/hooks/chat/btw/intercept.ts +43 -0
- package/src/client/hooks/chat/btw/mode.ts +179 -0
- package/src/client/hooks/chat/composer/attachments.ts +198 -0
- package/src/client/hooks/chat/composer/drop-entries.ts +204 -0
- package/src/client/hooks/chat/composer/drop-payload.test.ts +97 -0
- package/src/client/hooks/chat/composer/drop-payload.ts +130 -0
- package/src/client/hooks/chat/composer/drop-references.ts +106 -0
- package/src/client/hooks/chat/composer/file-drop.test.ts +194 -0
- package/src/client/hooks/chat/composer/file-drop.ts +226 -0
- package/src/client/hooks/chat/composer/file-reads.test.ts +211 -0
- package/src/client/hooks/chat/composer/file-reads.ts +238 -0
- package/src/client/hooks/chat/composer/index.ts +7 -1
- package/src/client/hooks/chat/composer/pipeline.ts +181 -0
- package/src/client/hooks/chat/omp/index.ts +3 -3
- package/src/client/hooks/chat/omp/prompt-send.ts +5 -5
- package/src/client/hooks/chat/timeline/actions.ts +16 -1
- package/src/client/hooks/chat/timeline/index.ts +16 -8
- package/src/client/hooks/chat/timeline/prompt-text.ts +6 -1
- package/src/client/hooks/chat/timeline/send.ts +29 -65
- package/src/client/hooks/chat/timeline/session-load.ts +2 -1
- package/src/client/hooks/chat/timeline/session-pagination.ts +161 -14
- package/src/client/hooks/chat/timeline/user-turns.ts +149 -0
- package/src/client/hooks/editor/file-actions.ts +49 -0
- package/src/client/hooks/editor/use-file-editor.ts +38 -27
- package/src/client/hooks/editor/use-file-transfer.ts +78 -0
- package/src/client/tailwind.css +22 -0
- package/src/server/db.server.ts +21 -287
- package/src/server/lib/btw/frames.test.ts +68 -0
- package/src/server/lib/btw/frames.ts +56 -0
- package/src/server/lib/btw/prompt.ts +28 -0
- package/src/server/lib/btw/registry.server.ts +141 -0
- package/src/server/lib/btw/runtime.server.ts +324 -0
- package/src/server/lib/btw/schema.server.ts +44 -0
- package/src/server/lib/btw/service.server.ts +171 -0
- package/src/server/lib/btw/session-copy.server.ts +213 -0
- package/src/server/lib/btw/session-copy.test.ts +166 -0
- package/src/server/lib/btw/store.server.ts +240 -0
- package/src/server/lib/db/schema.ts +110 -0
- package/src/server/lib/db/seed.ts +160 -0
- package/src/server/lib/db/workspace-sync.ts +70 -0
- package/src/server/lib/fs/root.ts +37 -0
- package/src/server/lib/omp/rpc/frame-fold.ts +207 -0
- package/src/server/lib/omp/rpc/manager.ts +27 -123
- package/src/server/lib/omp/session/auto-title.server.test.ts +137 -0
- package/src/server/lib/omp/session/auto-title.server.ts +152 -0
- package/src/server/lib/omp/session/blobs.server.test.ts +100 -0
- package/src/server/lib/omp/session/blobs.server.ts +76 -0
- package/src/server/lib/omp/session/file-scan.ts +174 -0
- package/src/server/lib/omp/session/files.test.ts +147 -0
- package/src/server/lib/omp/session/files.ts +52 -35
- package/src/server/lib/omp/session/locator.ts +2 -2
- package/src/server/lib/omp/session/merge-stored.test.ts +118 -0
- package/src/server/lib/omp/session/merge-stored.ts +171 -0
- package/src/server/lib/omp/session/messages.ts +34 -0
- package/src/server/lib/omp/session/reader.ts +2 -2
- package/src/server/lib/omp/session/subagent-presence.ts +9 -6
- package/src/server/lib/queue/delivery.server.test.ts +57 -0
- package/src/server/lib/queue/delivery.server.ts +25 -3
- package/src/server/plugins/ssr.ts +29 -2
- package/src/server/routes/btw/events.ts +70 -0
- package/src/server/routes/btw/index.ts +8 -0
- package/src/server/routes/btw/topics.ts +87 -0
- package/src/server/routes/btw/ws.ts +136 -0
- package/src/server/routes/chat/index.ts +2 -0
- package/src/server/routes/chat/session.ts +1 -115
- package/src/server/routes/chat/turns.ts +87 -0
- package/src/server/routes/fs/index.ts +4 -1
- package/src/server/routes/fs/raw.ts +85 -0
- package/src/server/routes/fs/read.ts +58 -3
- package/src/server/routes/index.ts +7 -1
- package/src/shared/lib/chat/attachments.test.ts +208 -0
- package/src/shared/lib/chat/attachments.ts +200 -15
- package/src/shared/lib/chat/btw/client.ts +94 -0
- package/src/shared/lib/chat/composer/client.test.ts +48 -0
- package/src/shared/lib/chat/composer/client.ts +6 -1
- package/src/shared/lib/chat/composer/composer.test.ts +39 -1
- package/src/shared/lib/chat/composer/trigger.ts +45 -1
- package/src/shared/lib/chat/omp/agent-events.ts +33 -80
- package/src/shared/lib/chat/omp/socket.ts +11 -6
- package/src/shared/lib/chat/omp/sse.ts +11 -6
- package/src/shared/lib/chat/omp/tool-results.ts +102 -0
- package/src/shared/lib/chat/omp/transport.ts +19 -3
- package/src/shared/lib/chat/text-file.ts +148 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -32
- package/src/shared/lib/chat/timeline/stream-coalescer.ts +66 -0
- package/src/shared/lib/chat/timeline/turns.ts +7 -0
- package/src/shared/lib/fs/file-kind.ts +50 -0
- package/src/shared/lib/fs/paths.ts +19 -1
- package/src/shared/lib/markdown/math.test.ts +20 -3
- package/src/shared/lib/omp/session/inlined-attachments.test.ts +82 -0
- package/src/shared/lib/omp/session/mapper.ts +24 -4
- package/src/shared/lib/omp/session/messages-map.ts +16 -3
- package/src/shared/lib/omp/session/messages-parse.ts +2 -2
- package/src/shared/lib/omp/session/parse-message-blocks.ts +85 -5
- package/src/shared/types/btw.ts +65 -0
- package/src/shared/types/chat.ts +39 -1
- package/src/shared/types/index.ts +1 -0
- package/src/shared/types/omp/agent.ts +14 -1
- package/src/shared/types/omp/session.ts +4 -1
- package/src/shared/types/settings/state.ts +5 -0
- package/dist/client/static/css/index.b859cc1cc5.css +0 -2
- package/dist/client/static/js/async/5bh.8b2363424f.js +0 -1
- package/dist/client/static/js/async/66.5dfa29f957.js +0 -93
- package/dist/client/static/js/async/7g.a7814350c3.js +0 -1
- package/dist/client/static/js/async/7l.7a6a377ecc.js +0 -1
- package/dist/client/static/js/async/cx.ca5feec570.js +0 -226
- package/dist/client/static/js/async/e.93214d622b.js +0 -79
- package/dist/client/static/js/async/g.a06ca3b3e9.js +0 -2
- package/dist/client/static/js/async/h1.e8d5aa8e80.js +0 -1
- package/dist/client/static/js/async/hs.6d480d0ded.js +0 -41
- package/dist/client/static/js/async/jl.874d352278.js +0 -1
- package/dist/client/static/js/async/mt.0352e976d8.js +0 -1
- package/dist/client/static/js/async/nam.b5663e919f.js +0 -10
- package/dist/client/static/js/async/q.23af2a358f.js +0 -21
- package/dist/client/static/js/async/q5.e683f7c26e.js +0 -286
- package/dist/client/static/js/async/s.222ee974f6.js +0 -2
- package/dist/client/static/js/async/uf.46ad6209fa.js +0 -2
- package/dist/client/static/js/async/vt.42416b707d.js +0 -1
- package/dist/client/static/js/async/x1.da8687159b.js +0 -90
- package/dist/client/static/js/async/yg.35bebd6d0c.js +0 -149
- package/dist/client/static/js/index.6e065017eb.js +0 -33
- package/dist/client/static/js/jp.50817896f3.js +0 -139
- /package/dist/client/static/js/{jp.50817896f3.js.LICENSE.txt → 77.8022a800b5.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{1k.5974df3b87.js.LICENSE.txt → 1k.a411fe5b43.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{3y.71f2ccf3b6.js.LICENSE.txt → 3y.3186ecdd63.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{dc.856fda6560.js.LICENSE.txt → ar.ad5c8822a8.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{m.c038c71b9f.js.LICENSE.txt → f7.46971e9d6d.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{5b.a5ccbc19dc.js.LICENSE.txt → g.ee41bfed7f.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{q.23af2a358f.js.LICENSE.txt → m.2e1ea3db9e.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{s.222ee974f6.js.LICENSE.txt → m3.d2b05f9604.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{g.a06ca3b3e9.js.LICENSE.txt → q.b453193538.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{uf.46ad6209fa.js.LICENSE.txt → q8.7f1b48e9cd.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{nam.b5663e919f.js.LICENSE.txt → yx.af2936c368.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{index.6e065017eb.js.LICENSE.txt → index.5fe7a2cb64.js.LICENSE.txt} +0 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Merge the chamber's own copy of a conversation onto the messages loaded from
|
|
8
|
+
* an omp JSONL file.
|
|
9
|
+
*
|
|
10
|
+
* The JSONL is authoritative for what the model received, but it is not a
|
|
11
|
+
* complete record of the chamber's UI: omp rewrites raw composer input
|
|
12
|
+
* (`@agent` → a task-tool delegation prompt, `/skill:a b` → `/skill:a`) and
|
|
13
|
+
* writes no user entry at all for `/command` turns. The chamber DB keeps the
|
|
14
|
+
* turn as the user typed it, which is what this restores.
|
|
15
|
+
*
|
|
16
|
+
* Attachments are UNIONED rather than picked from one side. The two sources see
|
|
17
|
+
* different halves of the same attachment:
|
|
18
|
+
*
|
|
19
|
+
* - The JSONL recovers a text file's NAME from the delivered prompt (the
|
|
20
|
+
* composer inlines the file as `Attached file: <name>` plus a fenced block)
|
|
21
|
+
* and carries an image's bytes, but has no preview and no stored content.
|
|
22
|
+
* - The chamber row has the display fields — a text file's name and content, an
|
|
23
|
+
* image's name and preview — but nothing about what omp actually received.
|
|
24
|
+
*
|
|
25
|
+
* Choosing one list over the other is what made a dropped `.md`/`.py`/`.json`
|
|
26
|
+
* vanish from a reloaded session: the JSONL had an image, so its list won, and
|
|
27
|
+
* the text file only ever existed in the chamber row.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export interface StoredAttachment {
|
|
31
|
+
name?: string;
|
|
32
|
+
preview?: string;
|
|
33
|
+
type?: string;
|
|
34
|
+
size?: number;
|
|
35
|
+
content?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface StoredMessage {
|
|
39
|
+
id?: string;
|
|
40
|
+
role: string;
|
|
41
|
+
content: string;
|
|
42
|
+
date?: string;
|
|
43
|
+
timestamp?: string;
|
|
44
|
+
attachments?: StoredAttachment[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isRawComposerInput(content: string): boolean {
|
|
48
|
+
return /^\s*\//.test(content) || /(^|\s)@[A-Za-z0-9_-]+/.test(content);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Whether a JSONL-derived user turn (a) and a stored one (b) are the same
|
|
52
|
+
* request despite omp rewriting the delivered prompt. */
|
|
53
|
+
function userTurnsRelate(a: string, b: string): boolean {
|
|
54
|
+
if (a === b || a.startsWith(b) || b.startsWith(a)) return true;
|
|
55
|
+
// `@agent` is delivered to omp as a task-tool delegation prompt.
|
|
56
|
+
if (a.startsWith('Use the task tool to delegate this request') && /(^|\s)@[A-Za-z0-9_-]+/.test(b)) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
// `/skill:<name> <args>`: the JSONL only records a synthesized `/skill:<name>`.
|
|
60
|
+
const token = a.split(' ')[0];
|
|
61
|
+
return a.startsWith('/skill:') && Boolean(token) && b.startsWith(token);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Union two attachment lists by name.
|
|
66
|
+
*
|
|
67
|
+
* The JSONL entry comes first, so the order the model received wins; a stored
|
|
68
|
+
* entry with the same name fills in the fields the JSONL could not carry.
|
|
69
|
+
*/
|
|
70
|
+
function mergeAttachmentLists(
|
|
71
|
+
fromJsonl: StoredAttachment[] | undefined,
|
|
72
|
+
fromStore: StoredAttachment[] | undefined,
|
|
73
|
+
): StoredAttachment[] {
|
|
74
|
+
const jsonl = Array.isArray(fromJsonl) ? fromJsonl : [];
|
|
75
|
+
const stored = Array.isArray(fromStore) ? fromStore : [];
|
|
76
|
+
if (jsonl.length === 0) return stored;
|
|
77
|
+
if (stored.length === 0) return jsonl;
|
|
78
|
+
const remaining = [...stored];
|
|
79
|
+
const merged = jsonl.map((attachment) => {
|
|
80
|
+
const index = remaining.findIndex((candidate) => candidate.name === attachment.name);
|
|
81
|
+
if (index === -1) return attachment;
|
|
82
|
+
const [match] = remaining.splice(index, 1);
|
|
83
|
+
return {
|
|
84
|
+
...match,
|
|
85
|
+
...attachment,
|
|
86
|
+
content: attachment.content ?? match.content,
|
|
87
|
+
preview: attachment.preview || match.preview,
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
// Anything the JSONL could not see (an attachment omp dropped before the
|
|
91
|
+
// prompt was persisted) is still shown rather than silently discarded.
|
|
92
|
+
return [...merged, ...remaining];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Merge user turns and attachments from the chamber DB copy onto the
|
|
96
|
+
* JSONL-loaded messages. */
|
|
97
|
+
export function mergeOmpAttachments(
|
|
98
|
+
messages: StoredMessage[],
|
|
99
|
+
storedMessagesJson: string | undefined,
|
|
100
|
+
): StoredMessage[] {
|
|
101
|
+
if (!storedMessagesJson) return messages;
|
|
102
|
+
let stored: StoredMessage[] = [];
|
|
103
|
+
try {
|
|
104
|
+
stored = JSON.parse(storedMessagesJson);
|
|
105
|
+
} catch {
|
|
106
|
+
return messages;
|
|
107
|
+
}
|
|
108
|
+
if (!Array.isArray(stored)) return messages;
|
|
109
|
+
const storedUsers = stored.filter((m) => m?.role === 'user' && typeof m.content === 'string');
|
|
110
|
+
|
|
111
|
+
const jsonlIndex = new Map<string, number>();
|
|
112
|
+
messages.forEach((m, index) => {
|
|
113
|
+
if (m.id) jsonlIndex.set(m.id, index);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const used = new Set<number>();
|
|
117
|
+
const merged = messages.map((m) => {
|
|
118
|
+
if (m.role !== 'user') return m;
|
|
119
|
+
const storedIndex = storedUsers.findIndex((s, i) => !used.has(i) && userTurnsRelate(m.content, s.content));
|
|
120
|
+
if (storedIndex === -1) return m;
|
|
121
|
+
used.add(storedIndex);
|
|
122
|
+
const storedMsg = storedUsers[storedIndex];
|
|
123
|
+
const content = storedMsg.content !== m.content && isRawComposerInput(storedMsg.content)
|
|
124
|
+
? storedMsg.content
|
|
125
|
+
: m.content;
|
|
126
|
+
const attachments = mergeAttachmentLists(m.attachments, storedMsg.attachments);
|
|
127
|
+
const next = { ...m, content };
|
|
128
|
+
return attachments.length ? { ...next, attachments } : next;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Unmatched raw turns are anchored before the next stored message the JSONL
|
|
132
|
+
// still carries; splice highest-anchor-first so earlier indices stay valid.
|
|
133
|
+
const insertions = new Map<number, StoredMessage[]>();
|
|
134
|
+
storedUsers.forEach((storedMsg, userIndex) => {
|
|
135
|
+
if (used.has(userIndex) || !isRawComposerInput(storedMsg.content)) return;
|
|
136
|
+
if (storedMsg.id && jsonlIndex.has(storedMsg.id)) return;
|
|
137
|
+
let anchor = merged.length;
|
|
138
|
+
for (let i = stored.indexOf(storedMsg) + 1; i < stored.length; i += 1) {
|
|
139
|
+
const id = stored[i]?.id;
|
|
140
|
+
const found = typeof id === 'string' ? jsonlIndex.get(id) : undefined;
|
|
141
|
+
if (found !== undefined) {
|
|
142
|
+
anchor = found;
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const group = insertions.get(anchor);
|
|
147
|
+
if (group) group.push(storedMsg);
|
|
148
|
+
else insertions.set(anchor, [storedMsg]);
|
|
149
|
+
});
|
|
150
|
+
for (const anchor of [...insertions.keys()].sort((a, b) => b - a)) {
|
|
151
|
+
const group = insertions.get(anchor);
|
|
152
|
+
if (group) merged.splice(anchor, 0, ...group);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const byContent = new Map<string, StoredAttachment[]>();
|
|
156
|
+
for (const m of stored) {
|
|
157
|
+
if (m?.role === 'user' && Array.isArray(m.attachments) && m.attachments.length > 0) {
|
|
158
|
+
byContent.set(m.content, m.attachments);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return merged.map((m) => {
|
|
162
|
+
if (m.role !== 'user' || m.attachments?.length) return m;
|
|
163
|
+
// The JSONL content may carry inlined text-file blocks appended to the
|
|
164
|
+
// original prompt, so match by prefix instead of exact equality.
|
|
165
|
+
const atts = byContent.get(m.content)
|
|
166
|
+
?? [...byContent.entries()].find(([storedContent]) =>
|
|
167
|
+
m.content.startsWith(storedContent) || storedContent.startsWith(m.content)
|
|
168
|
+
)?.[1];
|
|
169
|
+
return atts ? { ...m, attachments: atts } : m;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
@@ -25,6 +25,7 @@ import { normalizeThinkingLevel } from '@/shared/lib/models/thinking-levels';
|
|
|
25
25
|
import type { ChatMessageData } from '@/shared/types/chat';
|
|
26
26
|
import { collectToolOutputs, noticeFromCustomMessage, toChatMessage, type SequenceState } from '@/shared/lib/omp/session/messages-map';
|
|
27
27
|
import type { OmpMessageEntry } from '@/shared/lib/omp/session/messages-parse';
|
|
28
|
+
import { readBlobRef } from '@/server/lib/omp/session/blobs.server';
|
|
28
29
|
|
|
29
30
|
const MAX_SESSION_LOAD_BYTES = 512 * 1024 * 1024;
|
|
30
31
|
|
|
@@ -121,9 +122,42 @@ export async function loadSessionMessages(filePath: string): Promise<ChatMessage
|
|
|
121
122
|
}
|
|
122
123
|
state.messages.push(mapped);
|
|
123
124
|
}
|
|
125
|
+
await resolveImageBlobs(state.messages);
|
|
124
126
|
return state.messages;
|
|
125
127
|
}
|
|
126
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Replace `blob:sha256:` image references with the bytes they point at.
|
|
131
|
+
*
|
|
132
|
+
* omp externalizes a large attached image out of the session JSONL: the entry
|
|
133
|
+
* keeps the ref, the bytes go to the blob store. Only omp's own loader resolves
|
|
134
|
+
* them, so a reloaded chamber session used to render
|
|
135
|
+
* `data:image/png;base64,blob:sha256:…` — a data URL whose payload is the ref
|
|
136
|
+
* string, which the browser cannot decode. The ref itself is dropped once
|
|
137
|
+
* resolved; an unresolvable one leaves the attachment with no preview rather
|
|
138
|
+
* than a broken image.
|
|
139
|
+
*/
|
|
140
|
+
async function resolveImageBlobs(messages: ChatMessageData[]): Promise<void> {
|
|
141
|
+
const pending: Promise<void>[] = [];
|
|
142
|
+
for (const message of messages) {
|
|
143
|
+
for (const attachment of message.attachments ?? []) {
|
|
144
|
+
// `blobRef` rides the attachment only until the bytes are read back; it is
|
|
145
|
+
// not part of the public attachment shape, so narrow before touching it.
|
|
146
|
+
if (!('blobRef' in attachment) || typeof attachment.blobRef !== 'string') continue;
|
|
147
|
+
const ref = attachment.blobRef;
|
|
148
|
+
delete attachment.blobRef;
|
|
149
|
+
pending.push(
|
|
150
|
+
readBlobRef(ref).then((bytes) => {
|
|
151
|
+
if (bytes) {
|
|
152
|
+
attachment.preview = `data:${attachment.type ?? 'image/png'};base64,${bytes.toString('base64')}`;
|
|
153
|
+
}
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
await Promise.all(pending);
|
|
159
|
+
}
|
|
160
|
+
|
|
127
161
|
/** Derive the display title from the JSONL header/title slot (cheap read). */
|
|
128
162
|
export async function loadSessionTitle(filePath: string): Promise<string | undefined> {
|
|
129
163
|
try {
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
* - cwd → repository root → lib/omp/worktree.ts
|
|
16
16
|
* - managed projects registry → lib/omp/project-registry.ts
|
|
17
17
|
*
|
|
18
|
-
* All reads are bounded (4 KiB prefix
|
|
19
|
-
* never mutate the agent's files.
|
|
18
|
+
* All reads are bounded (a 4 KiB prefix plus a 16 KiB tail window per session
|
|
19
|
+
* file), cached by file mtime, and never mutate the agent's files.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { listAllSessionInfos, type OmpSessionInfo } from '@/server/lib/omp/session/files';
|
|
@@ -12,10 +12,13 @@
|
|
|
12
12
|
* megabytes, and doing that per session per poll dominated the endpoint's cost
|
|
13
13
|
* (the dataset scan itself is cached; this probe was not).
|
|
14
14
|
*
|
|
15
|
-
* The roster can only change when the session file or its
|
|
16
|
-
* directory changes, so that (
|
|
17
|
-
* sufficient cache version. Both cost one stat —
|
|
18
|
-
* than the parse they gate.
|
|
15
|
+
* The roster can only change when the session file gains entries or its
|
|
16
|
+
* sibling artifacts directory changes, so that (last-entry timestamp,
|
|
17
|
+
* sibling-dir mtime) pair is a sufficient cache version. Both cost one stat —
|
|
18
|
+
* orders of magnitude cheaper than the parse they gate. The timestamp is the
|
|
19
|
+
* file's last ENTRY time, not its mtime: a title-slot rewrite bumps mtime
|
|
20
|
+
* without appending anything, and a spurious version bump would re-run the
|
|
21
|
+
* parse it exists to avoid.
|
|
19
22
|
*/
|
|
20
23
|
|
|
21
24
|
import { promises as fs } from 'fs';
|
|
@@ -42,10 +45,10 @@ async function siblingDirMtimeMs(dir: string): Promise<number> {
|
|
|
42
45
|
|
|
43
46
|
export async function sessionHasSubagents(
|
|
44
47
|
sessionPath: string,
|
|
45
|
-
|
|
48
|
+
sessionLastEntryAt: string,
|
|
46
49
|
): Promise<boolean> {
|
|
47
50
|
const siblingDir = siblingDirForSession(sessionPath);
|
|
48
|
-
const version = `${
|
|
51
|
+
const version = `${sessionLastEntryAt}:${await siblingDirMtimeMs(siblingDir)}`;
|
|
49
52
|
|
|
50
53
|
const cached = cache.get(sessionPath);
|
|
51
54
|
if (cached && cached.version === version) return cached.hasSubagents;
|
|
@@ -169,6 +169,63 @@ describe('queue delivery', () => {
|
|
|
169
169
|
expect(queue.get(sessionId)).toEqual([]);
|
|
170
170
|
});
|
|
171
171
|
|
|
172
|
+
// Attachments reach the server as the persisted display fields only: the
|
|
173
|
+
// composer's `File` never survives the queue row's JSON round trip, and its
|
|
174
|
+
// image preview is a `blob:` object URL the server cannot read. These pin the
|
|
175
|
+
// fields the delivery must use instead — `dataBase64` for the bytes, `type`
|
|
176
|
+
// for the media type, `content` for an inline-able text file.
|
|
177
|
+
test('a queued image rides its persisted base64 payload', async () => {
|
|
178
|
+
const item = queueItem('with image', null);
|
|
179
|
+
item.attachments = [
|
|
180
|
+
{ id: 'i', name: 'shot.png', type: 'image/png', size: 4, preview: 'blob:http://x/1', dataBase64: 'iVBORw0KGgo=' },
|
|
181
|
+
] as QueuedMessage['attachments'];
|
|
182
|
+
queue.set(sessionId, [item]);
|
|
183
|
+
const { host, sent } = makeHost(sessionId);
|
|
184
|
+
|
|
185
|
+
scheduleQueueDelivery(host);
|
|
186
|
+
jest.advanceTimersByTime(600);
|
|
187
|
+
await flush();
|
|
188
|
+
|
|
189
|
+
expect(sent).toEqual([
|
|
190
|
+
{
|
|
191
|
+
type: 'prompt',
|
|
192
|
+
message: 'with image',
|
|
193
|
+
images: [{ type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' }],
|
|
194
|
+
},
|
|
195
|
+
]);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('a queued text file is inlined into the delivered prompt', async () => {
|
|
199
|
+
const item = queueItem('review this', null);
|
|
200
|
+
item.attachments = [
|
|
201
|
+
{ id: 't', name: 'notes.md', type: 'text/markdown', size: 5, preview: '', content: '# hi' },
|
|
202
|
+
] as QueuedMessage['attachments'];
|
|
203
|
+
queue.set(sessionId, [item]);
|
|
204
|
+
const { host, sent } = makeHost(sessionId);
|
|
205
|
+
|
|
206
|
+
scheduleQueueDelivery(host);
|
|
207
|
+
jest.advanceTimersByTime(600);
|
|
208
|
+
await flush();
|
|
209
|
+
|
|
210
|
+
const prompt = sent.find((c) => c.type === 'prompt');
|
|
211
|
+
expect(prompt?.message).toBe('review this\n\nAttached file: notes.md\n```markdown\n# hi\n```');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('an attachment without a payload is not sent as a blank image', async () => {
|
|
215
|
+
const item = queueItem('no payload', null);
|
|
216
|
+
item.attachments = [
|
|
217
|
+
{ id: 'i', name: 'broken.png', type: 'image/png', size: 4, preview: 'blob:http://x/1', dataBase64: '' },
|
|
218
|
+
] as QueuedMessage['attachments'];
|
|
219
|
+
queue.set(sessionId, [item]);
|
|
220
|
+
const { host, sent } = makeHost(sessionId);
|
|
221
|
+
|
|
222
|
+
scheduleQueueDelivery(host);
|
|
223
|
+
jest.advanceTimersByTime(600);
|
|
224
|
+
await flush();
|
|
225
|
+
|
|
226
|
+
expect(sent).toEqual([{ type: 'prompt', message: 'no payload' }]);
|
|
227
|
+
});
|
|
228
|
+
|
|
172
229
|
test('a lost timer delays the queue instead of wedging it', async () => {
|
|
173
230
|
queue.set(sessionId, [queueItem('recovered')]);
|
|
174
231
|
const { host, sent } = makeHost(sessionId);
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { claimHeadQueueItem, requeueHeadQueueItem } from '@/server/lib/queue/store.server';
|
|
24
|
+
import { composeMessageWithTextAttachments } from '@/shared/lib/chat/attachments';
|
|
24
25
|
import type { QueuedMessage } from '@/shared/types/chat';
|
|
25
26
|
|
|
26
27
|
/** Settling time between agent_end and the next prompt dispatch. Small on
|
|
@@ -71,12 +72,33 @@ async function dispatchQueuedItem(session: QueueDeliveryHost, item: QueuedMessag
|
|
|
71
72
|
await session.send({ type: 'set_thinking_level', level: item.model.thinkingLevel });
|
|
72
73
|
}
|
|
73
74
|
}
|
|
75
|
+
// Attachments reach this point as the persisted display fields — the live
|
|
76
|
+
// `File` never survived the queue's JSON round trip. The base64 payload and
|
|
77
|
+
// the MIME type are therefore read from the fields that DID persist:
|
|
78
|
+
// `dataBase64` for the bytes, `type` for the media type. (Matching on a
|
|
79
|
+
// `data:` preview used to yield zero images, because the composer's preview
|
|
80
|
+
// is a `blob:` object URL that the server cannot read.)
|
|
74
81
|
const images = item.attachments
|
|
75
|
-
.filter((a) => typeof a?.
|
|
76
|
-
.map((a) => ({
|
|
82
|
+
.filter((a) => typeof a?.dataBase64 === 'string' && a.dataBase64.length > 0)
|
|
83
|
+
.map((a) => ({
|
|
84
|
+
type: 'image' as const,
|
|
85
|
+
data: a.dataBase64 as string,
|
|
86
|
+
mimeType: typeof a.type === 'string' && a.type.startsWith('image/') ? a.type : 'image/png',
|
|
87
|
+
}));
|
|
88
|
+
// Text attachments are inlined into the prompt, exactly as the composer
|
|
89
|
+
// does at send time — the item's own `text` is the raw composer input, so
|
|
90
|
+
// without this a queued file would deliver as a bare filename mention.
|
|
91
|
+
const textFiles = item.attachments
|
|
92
|
+
.filter((a) => typeof a?.content === 'string' && a.content.length > 0)
|
|
93
|
+
.map((a) => ({
|
|
94
|
+
name: typeof a.name === 'string' && a.name ? a.name : 'attachment',
|
|
95
|
+
mimeType: typeof a.type === 'string' ? a.type : 'text/plain',
|
|
96
|
+
content: a.content as string,
|
|
97
|
+
size: typeof a.size === 'number' ? a.size : (a.content as string).length,
|
|
98
|
+
}));
|
|
77
99
|
await session.send({
|
|
78
100
|
type: 'prompt',
|
|
79
|
-
message: item.text,
|
|
101
|
+
message: composeMessageWithTextAttachments(item.text, textFiles),
|
|
80
102
|
...(images.length ? { images } : {}),
|
|
81
103
|
...(item.model ? { accessMode: item.model.accessMode } : {}),
|
|
82
104
|
});
|
|
@@ -36,7 +36,7 @@ function missingBuildResponse(): Response {
|
|
|
36
36
|
<p>For a rebuild-on-change loop while developing:</p>
|
|
37
37
|
<pre style="background:#f4f1ea;padding:1rem;border-radius:6px">bunx rsbuild build --watch</pre>
|
|
38
38
|
</body>`,
|
|
39
|
-
{ status: 503, headers:
|
|
39
|
+
{ status: 503, headers: HTML_HEADERS },
|
|
40
40
|
);
|
|
41
41
|
}
|
|
42
42
|
|
|
@@ -64,6 +64,33 @@ function themeColor(theme: string): string {
|
|
|
64
64
|
|
|
65
65
|
const MOBILE_UA = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i;
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* The HTML shell is generated per request, so its cacheability is declared
|
|
69
|
+
* rather than left to the browser's heuristics.
|
|
70
|
+
*
|
|
71
|
+
* It is not a static file: the theme and the bootstrap settings are injected on
|
|
72
|
+
* every request, and its only link to the bundle is a content hash inside the
|
|
73
|
+
* markup. A copy that outlives its build does not merely go stale — it points at
|
|
74
|
+
* an asset hash the build no longer emits, leaving the page running previous
|
|
75
|
+
* code with nothing in the response to signal it.
|
|
76
|
+
*
|
|
77
|
+
* Chrome does not currently cache it: the response carries no `Cache-Control`,
|
|
78
|
+
* `Last-Modified` or `ETag`, so there is no freshness to heuristically derive
|
|
79
|
+
* and every navigation re-fetches (measured: a second load of the shell reported
|
|
80
|
+
* the full 41 KB `transferSize`). That is the behaviour this header makes
|
|
81
|
+
* explicit — the day a validator is added for the template, heuristic caching
|
|
82
|
+
* would start and nothing else here would prevent it.
|
|
83
|
+
*
|
|
84
|
+
* `no-store` rather than `no-cache`: with no validator there is nothing to
|
|
85
|
+
* revalidate against, so a stored copy is never usable.
|
|
86
|
+
*/
|
|
87
|
+
const SHELL_CACHE = 'no-store';
|
|
88
|
+
|
|
89
|
+
const HTML_HEADERS = {
|
|
90
|
+
'content-type': 'text/html; charset=utf-8',
|
|
91
|
+
'cache-control': SHELL_CACHE,
|
|
92
|
+
} as const;
|
|
93
|
+
|
|
67
94
|
export const ssrRoutes = new Elysia({ name: 'ssr' }).get('*', async ({ request }) => {
|
|
68
95
|
const pathname = new URL(request.url).pathname;
|
|
69
96
|
|
|
@@ -95,6 +122,6 @@ export const ssrRoutes = new Elysia({ name: 'ssr' }).get('*', async ({ request }
|
|
|
95
122
|
.replace('data-theme="paper"', `data-theme="${theme}"`)
|
|
96
123
|
.replace('<!--app-head-->', `<meta name="theme-color" content="${themeColor(theme)}">`)
|
|
97
124
|
.replace('<!--app-bootstrap-->', `<script>window.__OMP_BOOTSTRAP__=${bootstrap}</script>`),
|
|
98
|
-
{ headers:
|
|
125
|
+
{ headers: HTML_HEADERS },
|
|
99
126
|
);
|
|
100
127
|
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `GET /api/btw/:sessionId/events` — SSE stream of a session's BTW frames.
|
|
8
|
+
*
|
|
9
|
+
* Unlike the agent stream this one is not observer-only: a side question's
|
|
10
|
+
* history must be readable for a session whose omp child is not running (that
|
|
11
|
+
* is the normal state of a session opened from the sidebar), so the route
|
|
12
|
+
* subscribes to the registry and never spawns anything.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createEventCoalescer, createSseStream, type EventCoalescer } from '@/server/lib/sse';
|
|
16
|
+
import { btwStateFor, subscribeBtw } from '@/server/lib/btw/registry.server';
|
|
17
|
+
import type { BtwFrame } from '@/shared/types';
|
|
18
|
+
|
|
19
|
+
/** Longest a coalesced frame may wait before it is flushed anyway. */
|
|
20
|
+
const FLUSH_DELAY_MS = 50;
|
|
21
|
+
|
|
22
|
+
export async function loader({ params, request }: { params: Record<string, string>; request: Request }) {
|
|
23
|
+
const { sessionId } = params;
|
|
24
|
+
if (!sessionId) return new Response('session id is required', { status: 400 });
|
|
25
|
+
|
|
26
|
+
let coalescer: EventCoalescer | null = null;
|
|
27
|
+
|
|
28
|
+
const stream = createSseStream({
|
|
29
|
+
heartbeatMs: 30_000,
|
|
30
|
+
signal: request.signal,
|
|
31
|
+
beforeHeartbeat: () => coalescer?.flush() ?? true,
|
|
32
|
+
onStart(handlers) {
|
|
33
|
+
let unsubscribe: (() => void) | null = null;
|
|
34
|
+
coalescer = createEventCoalescer({
|
|
35
|
+
send: (event, data) => handlers.send(event, data),
|
|
36
|
+
isBackpressured: handlers.isBackpressured,
|
|
37
|
+
flushDelayMs: FLUSH_DELAY_MS,
|
|
38
|
+
});
|
|
39
|
+
const push = (frame: BtwFrame) => coalescer?.push('', frame);
|
|
40
|
+
|
|
41
|
+
handlers.send('', { type: 'connected', sessionId });
|
|
42
|
+
if (handlers.isClosed()) return;
|
|
43
|
+
unsubscribe = subscribeBtw(sessionId, push);
|
|
44
|
+
void btwStateFor(sessionId)
|
|
45
|
+
.then((state) => push({ type: 'btw_state', state }))
|
|
46
|
+
.catch((error: unknown) => {
|
|
47
|
+
push({
|
|
48
|
+
type: 'btw_error',
|
|
49
|
+
topicId: null,
|
|
50
|
+
message: error instanceof Error ? error.message : String(error),
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return () => {
|
|
55
|
+
coalescer?.dispose();
|
|
56
|
+
coalescer = null;
|
|
57
|
+
if (unsubscribe) {
|
|
58
|
+
try {
|
|
59
|
+
unsubscribe();
|
|
60
|
+
} catch {
|
|
61
|
+
// Subscriber cleanup is best-effort.
|
|
62
|
+
}
|
|
63
|
+
unsubscribe = null;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return stream.response;
|
|
70
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { actionBindings, bindingsFor, type HandlerBinding } from '@/server/lib/route-adapter';
|
|
2
|
+
import { action, loader } from '@/server/routes/btw/topics';
|
|
3
|
+
import * as btwEvents from '@/server/routes/btw/events';
|
|
4
|
+
|
|
5
|
+
export const btwBindings: HandlerBinding[] = [
|
|
6
|
+
...actionBindings(action, '/api/btw/:sessionId', loader),
|
|
7
|
+
...bindingsFor(btwEvents, '/api/btw/:sessionId/events'),
|
|
8
|
+
];
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* BTW commands: `GET /api/btw/:sessionId` returns a session's side questions,
|
|
8
|
+
* `POST` runs one action against them.
|
|
9
|
+
*
|
|
10
|
+
* Mutating verbs stay in one handler with its own dispatch (the ported Remix
|
|
11
|
+
* shape the rest of the API uses), so an unsupported action gets a named error
|
|
12
|
+
* instead of a silent success.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from '@/server/lib/remix-compat';
|
|
16
|
+
import { toImageContents, validateAgentImages } from '@/server/lib/omp/rpc/constants';
|
|
17
|
+
import { BtwError, type BtwImages } from '@/server/lib/btw/runtime.server';
|
|
18
|
+
import { abortBtw, askBtw, getBtwState, promoteBtw, removeBtw } from '@/server/lib/btw/service.server';
|
|
19
|
+
|
|
20
|
+
/** Actions that can reject for a reason the client should show as-is. */
|
|
21
|
+
const STATUS_BY_CODE: Record<string, number> = {
|
|
22
|
+
btw_busy: 409,
|
|
23
|
+
btw_stale: 409,
|
|
24
|
+
session_not_found: 404,
|
|
25
|
+
btw_topic_not_found: 404,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function btwErrorResponse(error: unknown): Response {
|
|
29
|
+
if (error instanceof BtwError) {
|
|
30
|
+
return json({ error: error.message, code: error.code }, { status: STATUS_BY_CODE[error.code] ?? 400 });
|
|
31
|
+
}
|
|
32
|
+
return json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readImages(raw: unknown): { images?: BtwImages } | { error: string } {
|
|
36
|
+
const problem = validateAgentImages(raw);
|
|
37
|
+
if (problem) return { error: problem };
|
|
38
|
+
const images = toImageContents(raw);
|
|
39
|
+
return images ? { images } : {};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function action({ params, request }: ActionFunctionArgs) {
|
|
43
|
+
const { sessionId } = params;
|
|
44
|
+
if (!sessionId) return json({ error: 'session id is required' }, { status: 400 });
|
|
45
|
+
|
|
46
|
+
const body = (await request.json().catch(() => null)) as Record<string, unknown> | null;
|
|
47
|
+
const action = typeof body?.action === 'string' ? body.action : '';
|
|
48
|
+
const topicId = typeof body?.topicId === 'string' ? body.topicId : undefined;
|
|
49
|
+
if (!action) return json({ error: 'action is required', code: 'action_required' }, { status: 400 });
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
switch (action) {
|
|
53
|
+
case 'ask': {
|
|
54
|
+
const question = typeof body?.question === 'string' ? body.question : '';
|
|
55
|
+
const parsed = readImages(body?.images);
|
|
56
|
+
if ('error' in parsed) return json({ error: parsed.error, code: 'invalid_images' }, { status: 400 });
|
|
57
|
+
return json({ success: true, data: await askBtw(sessionId, { topicId, question, images: parsed.images }) });
|
|
58
|
+
}
|
|
59
|
+
case 'abort': {
|
|
60
|
+
if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
|
|
61
|
+
return json({ success: true, data: await abortBtw(sessionId, topicId) });
|
|
62
|
+
}
|
|
63
|
+
case 'promote': {
|
|
64
|
+
if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
|
|
65
|
+
return json({ success: true, data: await promoteBtw(sessionId, topicId) });
|
|
66
|
+
}
|
|
67
|
+
case 'delete': {
|
|
68
|
+
if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
|
|
69
|
+
return json({ success: true, data: await removeBtw(sessionId, topicId) });
|
|
70
|
+
}
|
|
71
|
+
default:
|
|
72
|
+
return json({ error: `Unknown btw action: ${action}`, code: 'unknown_action' }, { status: 400 });
|
|
73
|
+
}
|
|
74
|
+
} catch (error) {
|
|
75
|
+
return btwErrorResponse(error);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function loader({ params }: LoaderFunctionArgs) {
|
|
80
|
+
const { sessionId } = params;
|
|
81
|
+
if (!sessionId) return json({ error: 'session id is required' }, { status: 400 });
|
|
82
|
+
try {
|
|
83
|
+
return json({ success: true, data: await getBtwState(sessionId) });
|
|
84
|
+
} catch (error) {
|
|
85
|
+
return btwErrorResponse(error);
|
|
86
|
+
}
|
|
87
|
+
}
|