ompchamber 1.1.0 → 1.2.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/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.57b39ffa7f.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 +181 -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 +83 -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.57b39ffa7f.js.LICENSE.txt} +0 -0
|
@@ -16,36 +16,14 @@ import type { Dispatch, SetStateAction } from 'preact/compat';
|
|
|
16
16
|
import type { ChatMessageData, ExtensionUiDialogRequest, IncomingExtensionUiRequest, OmpAgentCallbacks } from '@/shared/types';
|
|
17
17
|
import { triggerChatCompletionSound } from '@/client/hooks/ui/notification-sound';
|
|
18
18
|
import { isNoticeRow } from '@/shared/lib/chat/notice-row';
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
bindStreamingCoalescer,
|
|
21
|
+
disposeStreamingCoalescer,
|
|
22
|
+
flushStreamingUpdates,
|
|
23
|
+
queueStreamingUpdate,
|
|
24
|
+
} from '@/shared/lib/chat/timeline/stream-coalescer';
|
|
20
25
|
import { PHASE_VERBS } from '@/shared/lib/chat/timeline/tool-phrases';
|
|
21
26
|
|
|
22
|
-
// omp emits one message_update per model chunk, each carrying that message's
|
|
23
|
-
// FULL accumulated content, so a burst only needs the newest payload per
|
|
24
|
-
// message id. This batch commits at most once per animation frame; the terminal
|
|
25
|
-
// frame flushes synchronously first so the last chunk is never dropped.
|
|
26
|
-
// `apply`/`afterFlush` delegate through module vars because the callbacks
|
|
27
|
-
// factory re-runs on every render.
|
|
28
|
-
let applyMessageUpdater: Dispatch<SetStateAction<ChatMessageData[]>> = () => {};
|
|
29
|
-
let scrollAfterFlush: () => void = () => {};
|
|
30
|
-
|
|
31
|
-
const messageBatch = createRafBatch<ChatMessageData[]>(
|
|
32
|
-
updater => applyMessageUpdater(updater),
|
|
33
|
-
() => scrollAfterFlush(),
|
|
34
|
-
);
|
|
35
|
-
|
|
36
|
-
/** Apply any coalesced update, then invalidate its scheduled frame. */
|
|
37
|
-
export function disposeStreamingCoalescer(): void {
|
|
38
|
-
messageBatch.flush();
|
|
39
|
-
messageBatch.cancel();
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Drop coalesced updates without applying them — a session switch discards
|
|
43
|
-
* the previous session's queued `message_update` frames so they cannot leak
|
|
44
|
-
* into the freshly cleared timeline of the next session. */
|
|
45
|
-
export function cancelStreamingCoalescer(): void {
|
|
46
|
-
messageBatch.cancel();
|
|
47
|
-
}
|
|
48
|
-
|
|
49
27
|
export interface OmpAgentCallbacksDeps {
|
|
50
28
|
setGenerating: (v: boolean) => void;
|
|
51
29
|
setGeneratingVerb: (v: string) => void;
|
|
@@ -91,8 +69,7 @@ export function createOmpAgentCallbacks(deps: OmpAgentCallbacksDeps): OmpAgentCa
|
|
|
91
69
|
withdrawExtensionDialog,
|
|
92
70
|
} = deps;
|
|
93
71
|
|
|
94
|
-
|
|
95
|
-
scrollAfterFlush = () => scrollToBottom('smooth');
|
|
72
|
+
bindStreamingCoalescer(setLocalMessages, () => scrollToBottom('smooth'));
|
|
96
73
|
|
|
97
74
|
return {
|
|
98
75
|
onAgentStart: () => {
|
|
@@ -152,7 +129,7 @@ export function createOmpAgentCallbacks(deps: OmpAgentCallbacksDeps): OmpAgentCa
|
|
|
152
129
|
if (msg.role !== 'user' && !isNoticeRow(msg)) optimisticUserIdRef.current = null;
|
|
153
130
|
// Queue for the next frame, collapsing same-message bursts to the newest
|
|
154
131
|
// full-content frame; scroll runs once per rendered frame.
|
|
155
|
-
|
|
132
|
+
queueStreamingUpdate(prev => {
|
|
156
133
|
const placeholderId = aiPlaceholderIdRef.current;
|
|
157
134
|
// Notice rows (e.g. background job done, system alerts) are appended in
|
|
158
135
|
// arrival order — the timeline renders state as-is, so omp's own write
|
|
@@ -237,7 +214,7 @@ export function createOmpAgentCallbacks(deps: OmpAgentCallbacksDeps): OmpAgentCa
|
|
|
237
214
|
}
|
|
238
215
|
// Apply any coalesced update before the terminal frame so the last chunk
|
|
239
216
|
// is never dropped and the end always runs after it.
|
|
240
|
-
|
|
217
|
+
flushStreamingUpdates();
|
|
241
218
|
if (msg.role !== 'user') optimisticUserIdRef.current = null;
|
|
242
219
|
setLocalMessages(prev => {
|
|
243
220
|
const placeholderId = aiPlaceholderIdRef.current;
|
|
@@ -345,6 +322,15 @@ export function createOmpAgentCallbacks(deps: OmpAgentCallbacksDeps): OmpAgentCa
|
|
|
345
322
|
return [...prev, row];
|
|
346
323
|
});
|
|
347
324
|
},
|
|
325
|
+
// omp renamed the session — the auto-title generation the chamber asks for
|
|
326
|
+
// after a settled run, a `/rename`, or an RPC set_session_name. The slot
|
|
327
|
+
// write is in place, so the title is already on disk: refresh the metadata
|
|
328
|
+
// the navbar/context panel read, and signal the sidebar (whose scan cache
|
|
329
|
+
// is keyed on file mtime, which a fixed-width in-place write cannot move).
|
|
330
|
+
onSessionTitleChanged: () => {
|
|
331
|
+
const sid = adoptedSessionIdRef.current ?? sessionIdRef.current;
|
|
332
|
+
if (sid) refreshSessionMeta(sid);
|
|
333
|
+
},
|
|
348
334
|
onNotice: (_level, message) => {
|
|
349
335
|
console.info('OMP notice:', message);
|
|
350
336
|
},
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Frame coalescer for the live agent stream.
|
|
8
|
+
*
|
|
9
|
+
* omp emits one `message_update` per model chunk, each carrying that message's
|
|
10
|
+
* FULL accumulated content, so a burst only needs the newest payload per
|
|
11
|
+
* message id. This batches at most one commit per animation frame; the terminal
|
|
12
|
+
* frame flushes synchronously first so the last chunk is never dropped.
|
|
13
|
+
*
|
|
14
|
+
* The sink is a module-level singleton bound once per callbacks factory run
|
|
15
|
+
* (`bindStreamingCoalescer`), because that factory re-runs on every render
|
|
16
|
+
* while the batch's scheduled frame must survive across renders. Split out of
|
|
17
|
+
* omp-callbacks.ts so that file stays under the repo's per-file size ceiling.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { Dispatch, SetStateAction } from 'preact/compat';
|
|
21
|
+
import type { ChatMessageData } from '@/shared/types';
|
|
22
|
+
import { createRafBatch } from '@/shared/lib/chat/timeline/stream-raf';
|
|
23
|
+
|
|
24
|
+
let applyMessageUpdater: Dispatch<SetStateAction<ChatMessageData[]>> = () => {};
|
|
25
|
+
let scrollAfterFlush: () => void = () => {};
|
|
26
|
+
|
|
27
|
+
const messageBatch = createRafBatch<ChatMessageData[]>(
|
|
28
|
+
updater => applyMessageUpdater(updater),
|
|
29
|
+
() => scrollAfterFlush(),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
/** Point the batch at the current render's state setter and scroll hook. */
|
|
33
|
+
export function bindStreamingCoalescer(
|
|
34
|
+
apply: Dispatch<SetStateAction<ChatMessageData[]>>,
|
|
35
|
+
afterFlush: () => void,
|
|
36
|
+
): void {
|
|
37
|
+
applyMessageUpdater = apply;
|
|
38
|
+
scrollAfterFlush = afterFlush;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Queue a coalesced update for the next animation frame, keyed per message. */
|
|
42
|
+
export function queueStreamingUpdate(
|
|
43
|
+
updater: (prev: ChatMessageData[]) => ChatMessageData[],
|
|
44
|
+
key: string,
|
|
45
|
+
): void {
|
|
46
|
+
messageBatch.queue(updater, key);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Apply any queued update synchronously (terminal frames call this first so
|
|
50
|
+
* the last chunk is never dropped). */
|
|
51
|
+
export function flushStreamingUpdates(): void {
|
|
52
|
+
messageBatch.flush();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Apply any coalesced update, then invalidate its scheduled frame. */
|
|
56
|
+
export function disposeStreamingCoalescer(): void {
|
|
57
|
+
messageBatch.flush();
|
|
58
|
+
messageBatch.cancel();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Drop coalesced updates without applying them — a session switch discards
|
|
62
|
+
* the previous session's queued `message_update` frames so they cannot leak
|
|
63
|
+
* into the freshly cleared timeline of the next session. */
|
|
64
|
+
export function cancelStreamingCoalescer(): void {
|
|
65
|
+
messageBatch.cancel();
|
|
66
|
+
}
|
|
@@ -8,8 +8,15 @@
|
|
|
8
8
|
* rewind endpoint: identifying raw composer input and deciding whether a
|
|
9
9
|
* JSONL-derived user turn and a stored DB-copy turn are the same request
|
|
10
10
|
* (omp rewrites delivered prompts, so equality is not enough).
|
|
11
|
+
*
|
|
12
|
+
* The jump rail also reads from here: it lists user turns of the FULL session
|
|
13
|
+
* (`/api/chat/:id/turns`) and from the mounted window, and both build the same
|
|
14
|
+
* tooltip preview, so the truncation length is shared rather than duplicated.
|
|
11
15
|
*/
|
|
12
16
|
|
|
17
|
+
/** Longest prompt excerpt a rail tooltip renders. */
|
|
18
|
+
export const TURN_PREVIEW_CHARS = 220;
|
|
19
|
+
|
|
13
20
|
/** Whether a stored content string is raw composer input (slash command or
|
|
14
21
|
* @mention) rather than the prompt omp actually delivered. */
|
|
15
22
|
export function isRawComposerInput(content: string): boolean {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* File-kind classification shared by the server read routes and every client
|
|
8
|
+
* viewer (desktop editor, mobile editor, chat tool panels).
|
|
9
|
+
*
|
|
10
|
+
* A raster image is not text: decoding its bytes as UTF-8 yields replacement
|
|
11
|
+
* characters, so the editor used to paint a PNG as mojibake. The extension is
|
|
12
|
+
* the only signal available before reading the file, and it is the same signal
|
|
13
|
+
* the Files panel already uses to pick an icon — keeping it in one module means
|
|
14
|
+
* the reader, the icon, and the viewer can never disagree about which files are
|
|
15
|
+
* pictures.
|
|
16
|
+
*
|
|
17
|
+
* SVG is deliberately absent: it is editable XML text and stays on the
|
|
18
|
+
* syntax-highlighted code path.
|
|
19
|
+
*/
|
|
20
|
+
export const IMAGE_MIME_BY_EXT: Record<string, string> = {
|
|
21
|
+
png: 'image/png',
|
|
22
|
+
apng: 'image/apng',
|
|
23
|
+
jpg: 'image/jpeg',
|
|
24
|
+
jpeg: 'image/jpeg',
|
|
25
|
+
jfif: 'image/jpeg',
|
|
26
|
+
gif: 'image/gif',
|
|
27
|
+
webp: 'image/webp',
|
|
28
|
+
avif: 'image/avif',
|
|
29
|
+
bmp: 'image/bmp',
|
|
30
|
+
ico: 'image/x-icon',
|
|
31
|
+
tif: 'image/tiff',
|
|
32
|
+
tiff: 'image/tiff',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Lower-cased extension of a path or name, query/fragment/line suffix removed. */
|
|
36
|
+
export function getFileExtension(filePath: string | undefined | null): string {
|
|
37
|
+
if (!filePath) return '';
|
|
38
|
+
const clean = filePath.split('?')[0].split('#')[0].split(/[/\\]/).pop() ?? '';
|
|
39
|
+
const dot = clean.lastIndexOf('.');
|
|
40
|
+
return dot > 0 ? clean.slice(dot + 1).toLowerCase() : '';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* MIME type for a raster image path, or null when the extension is not one.
|
|
45
|
+
* Null is the "treat as text" answer, so every caller branches on this single
|
|
46
|
+
* check instead of re-deriving the extension list.
|
|
47
|
+
*/
|
|
48
|
+
export function getImageMimeType(filePath: string | undefined | null): string | null {
|
|
49
|
+
return IMAGE_MIME_BY_EXT[getFileExtension(filePath)] ?? null;
|
|
50
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Path
|
|
2
|
+
* Path helpers shared by the Files panel and every surface that opens a file.
|
|
3
3
|
*
|
|
4
4
|
* `/api/fs/dir` emits every entry as a path relative to the listing's base
|
|
5
5
|
* directory — the workspace root, or the selected nested repo — and reports
|
|
@@ -7,7 +7,25 @@
|
|
|
7
7
|
* two halves; a relative path on its own is not resolvable outside the panel
|
|
8
8
|
* (the client-side `rootPath` may be `~`-relative, rejected by the server, or
|
|
9
9
|
* absent entirely in mock mode).
|
|
10
|
+
*
|
|
11
|
+
* `buildFsRawUrl` is the byte-stream counterpart of that same scoping: it is
|
|
12
|
+
* what an image tab points its `<img>` at, so the file it names is the file
|
|
13
|
+
* the tab names, nested repo included.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* URL of one file's raw bytes through `/api/fs/raw`, carrying the same scope
|
|
18
|
+
* (`root`, nested `repo`) as `/api/fs/read` so an image opened from a nested
|
|
19
|
+
* repo resolves to the file the tab names. `repo: '.'` is omitted: the server
|
|
20
|
+
* treats an absent repo as the scoped root.
|
|
10
21
|
*/
|
|
22
|
+
export function buildFsRawUrl(file: { path: string; root?: string; repo?: string }): string {
|
|
23
|
+
const params = new URLSearchParams({ path: file.path });
|
|
24
|
+
if (file.root) params.set('root', file.root);
|
|
25
|
+
if (file.repo && file.repo !== '.') params.set('repo', file.repo);
|
|
26
|
+
return `/api/fs/raw?${params.toString()}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
11
29
|
export function toAbsolutePath(baseDir: string | null | undefined, relPath: string): string {
|
|
12
30
|
// `/`- and `./`-prefixed raws are stripped; a dot-file name (`.env`) is not a prefix.
|
|
13
31
|
const rel = relPath.replace(/^(?:\.?[/\\])+/, '').replace(/[/\\]+$/, '');
|
|
@@ -20,16 +20,19 @@
|
|
|
20
20
|
* they are no-ops everywhere else.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import { beforeAll, describe, expect, test } from 'bun:test';
|
|
23
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
24
24
|
import { Window } from 'happy-dom';
|
|
25
25
|
|
|
26
26
|
let hydrateMathBlocks: typeof import('@/shared/lib/markdown/katex').hydrateMathBlocks;
|
|
27
27
|
let renderMarkdown: typeof import('@/shared/lib/markdown/marked').renderMarkdown;
|
|
28
28
|
let sanitizeHtml: typeof import('@/shared/lib/markdown/sanitize').sanitizeHtml;
|
|
29
29
|
|
|
30
|
+
const displaced: Record<string, unknown> = {};
|
|
31
|
+
const installed: Record<string, unknown> = {};
|
|
32
|
+
|
|
30
33
|
beforeAll(async () => {
|
|
31
34
|
const win = new Window({ url: 'http://localhost' });
|
|
32
|
-
Object.
|
|
35
|
+
for (const [key, value] of Object.entries({
|
|
33
36
|
window: win,
|
|
34
37
|
document: win.document,
|
|
35
38
|
MutationObserver: win.MutationObserver,
|
|
@@ -37,7 +40,11 @@ beforeAll(async () => {
|
|
|
37
40
|
Element: win.Element,
|
|
38
41
|
HTMLElement: win.HTMLElement,
|
|
39
42
|
NodeFilter: win.NodeFilter,
|
|
40
|
-
})
|
|
43
|
+
})) {
|
|
44
|
+
displaced[key] = (globalThis as Record<string, unknown>)[key];
|
|
45
|
+
installed[key] = value;
|
|
46
|
+
(globalThis as Record<string, unknown>)[key] = value;
|
|
47
|
+
}
|
|
41
48
|
// Dynamic import is required here, not stylistic: `sanitize.ts` binds
|
|
42
49
|
// DOMPurify at module scope, which captures the DOM globals present at
|
|
43
50
|
// evaluation time. A static import would run before the happy-dom globals
|
|
@@ -47,6 +54,16 @@ beforeAll(async () => {
|
|
|
47
54
|
({ sanitizeHtml } = await import('@/shared/lib/markdown/sanitize'));
|
|
48
55
|
});
|
|
49
56
|
|
|
57
|
+
// Every test file shares one process, so the globals go back the way they were:
|
|
58
|
+
// the fold in `agent-events.ts` branches on `typeof window`, and a leaked DOM
|
|
59
|
+
// window would take its browser path with a `CustomEvent` from another realm.
|
|
60
|
+
afterAll(() => {
|
|
61
|
+
for (const key of Object.keys(installed)) {
|
|
62
|
+
if (displaced[key] === undefined) delete (globalThis as Record<string, unknown>)[key];
|
|
63
|
+
else (globalThis as Record<string, unknown>)[key] = displaced[key];
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
50
67
|
/** Render markdown and mount it exactly as `MarkdownRenderer` does. */
|
|
51
68
|
function mount(content: string): HTMLElement {
|
|
52
69
|
const host = document.createElement('div');
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Recovery of inlined text attachments from a delivered prompt.
|
|
8
|
+
*
|
|
9
|
+
* The composer does not send a text file as data — it appends the file to the
|
|
10
|
+
* prompt as `Attached file: <name>` plus a fenced block, and that composed
|
|
11
|
+
* prompt is the only record omp keeps. `stripInlinedTextAttachments` removes
|
|
12
|
+
* those blocks so the timeline shows the user's own words, which also discards
|
|
13
|
+
* the names: a reloaded session then showed an image chip and nothing for the
|
|
14
|
+
* `.md`/`.py`/`.json` that rode beside it.
|
|
15
|
+
*
|
|
16
|
+
* These tests pin the round trip, including the adversarial cases the format
|
|
17
|
+
* allows: content that itself contains a run of backticks (the fence grows to
|
|
18
|
+
* outrun it) and content that literally reads `Attached file:`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, expect, test } from 'bun:test';
|
|
22
|
+
import {
|
|
23
|
+
extractInlinedTextAttachments,
|
|
24
|
+
stripInlinedTextAttachments,
|
|
25
|
+
} from '@/shared/lib/omp/session/parse-message-blocks';
|
|
26
|
+
|
|
27
|
+
describe('extractInlinedTextAttachments', () => {
|
|
28
|
+
test('recovers a file the composer appended to a prompt', () => {
|
|
29
|
+
const prompt = 'explain this\n\nAttached file: script.py\n```python\nx=1\n```';
|
|
30
|
+
expect(extractInlinedTextAttachments(prompt)).toEqual([{ name: 'script.py', content: 'x=1' }]);
|
|
31
|
+
// The visible text is the user's own words; the block is not shown twice.
|
|
32
|
+
expect(stripInlinedTextAttachments(prompt)).toBe('explain this');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('recovers an attach-only prompt, whose visible text is empty', () => {
|
|
36
|
+
const prompt = 'Attached file: attachment-test.md\n```markdown\n# Title\n\nbody\n\n```';
|
|
37
|
+
// Trailing newline before the closing fence belongs to the content: the
|
|
38
|
+
// composer writes `<content>\n<fence>`.
|
|
39
|
+
expect(extractInlinedTextAttachments(prompt)).toEqual([
|
|
40
|
+
{ name: 'attachment-test.md', content: '# Title\n\nbody\n' },
|
|
41
|
+
]);
|
|
42
|
+
expect(stripInlinedTextAttachments(prompt)).toBe('');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('recovers every file when several were attached', () => {
|
|
46
|
+
const prompt = 'q\n\nAttached file: a.md\n```markdown\nA\n```\n\nAttached file: b.py\n```python\nB\n```';
|
|
47
|
+
expect(extractInlinedTextAttachments(prompt)).toEqual([
|
|
48
|
+
{ name: 'a.md', content: 'A' },
|
|
49
|
+
{ name: 'b.py', content: 'B' },
|
|
50
|
+
]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('leaves a prompt with no attachments alone', () => {
|
|
54
|
+
expect(extractInlinedTextAttachments('just a normal message')).toEqual([]);
|
|
55
|
+
expect(stripInlinedTextAttachments('just a normal message')).toBe('just a normal message');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('does not mistake prose that merely mentions the header', () => {
|
|
59
|
+
// No fence follows, so this is not a block — it is the user talking.
|
|
60
|
+
expect(extractInlinedTextAttachments('I saw an Attached file: note in the docs')).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('keeps content that contains a backtick run', () => {
|
|
64
|
+
// `fenceForContent` grows the fence past the longest run in the content, so
|
|
65
|
+
// the inner ``` must survive as content and the outer ```` is the close.
|
|
66
|
+
const prompt = 'q\n\nAttached file: nested.md\n````markdown\nbefore\n```\ncode\n```\nafter\n````';
|
|
67
|
+
expect(extractInlinedTextAttachments(prompt)).toEqual([
|
|
68
|
+
{ name: 'nested.md', content: 'before\n```\ncode\n```\nafter' },
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('keeps content that literally contains the header', () => {
|
|
73
|
+
const prompt = 'q\n\nAttached file: tricky.md\n````markdown\nAttached file: fake.txt\n```\ninner\n```\n````';
|
|
74
|
+
expect(extractInlinedTextAttachments(prompt)).toEqual([
|
|
75
|
+
{ name: 'tricky.md', content: 'Attached file: fake.txt\n```\ninner\n```' },
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('an unterminated block yields nothing rather than a partial read', () => {
|
|
80
|
+
expect(extractInlinedTextAttachments('q\n\nAttached file: open.md\n```markdown\nno close')).toEqual([]);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import type { ChatMessageData, ToolCallData } from '@/shared/types';
|
|
16
|
-
import { extractText, extractUserImageAttachments, parseMessageBlocks, stripInlinedTextAttachments, toToolCallData } from '@/shared/lib/omp/session/parse-message-blocks';
|
|
16
|
+
import { extractInlinedTextAttachments, extractText, extractUserImageAttachments, parseMessageBlocks, stripInlinedTextAttachments, toToolCallData } from '@/shared/lib/omp/session/parse-message-blocks';
|
|
17
17
|
import { reminderPartIndex } from '@/shared/lib/chat/notice-row';
|
|
18
18
|
import { deriveTurnError } from '@/shared/lib/omp/session/turn-error';
|
|
19
19
|
import { formatClock } from '@/shared/lib/format/time';
|
|
@@ -95,8 +95,22 @@ export function toChatMessage(raw: Record<string, unknown>, streaming = true): C
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
if (role === 'user') {
|
|
98
|
-
const
|
|
99
|
-
const
|
|
98
|
+
const rawText = parsed.textParts.join('\n');
|
|
99
|
+
const text = stripInlinedTextAttachments(rawText);
|
|
100
|
+
// Parity with the JSONL path: the composer's inlined `Attached file:` blocks
|
|
101
|
+
// are the only record of a text attachment, so they are read back into
|
|
102
|
+
// chips here too. Without this a text file vanished from the timeline as
|
|
103
|
+
// soon as the turn was re-rendered from the wire.
|
|
104
|
+
const attachments = [
|
|
105
|
+
...extractUserImageAttachments(raw.content),
|
|
106
|
+
...extractInlinedTextAttachments(rawText).map((file, index) => ({
|
|
107
|
+
id: `text-${index + 1}`,
|
|
108
|
+
name: file.name,
|
|
109
|
+
type: 'text/plain',
|
|
110
|
+
preview: '',
|
|
111
|
+
content: file.content,
|
|
112
|
+
})),
|
|
113
|
+
];
|
|
100
114
|
return {
|
|
101
115
|
id,
|
|
102
116
|
role,
|
|
@@ -105,7 +119,13 @@ export function toChatMessage(raw: Record<string, unknown>, streaming = true): C
|
|
|
105
119
|
startedAt,
|
|
106
120
|
content: text,
|
|
107
121
|
attribution,
|
|
108
|
-
attachments: attachments.map((a) => ({
|
|
122
|
+
attachments: attachments.map((a) => ({
|
|
123
|
+
id: a.id,
|
|
124
|
+
name: a.name,
|
|
125
|
+
preview: a.preview,
|
|
126
|
+
type: a.type,
|
|
127
|
+
...('content' in a ? { content: a.content } : {}),
|
|
128
|
+
})),
|
|
109
129
|
};
|
|
110
130
|
}
|
|
111
131
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { ChatMessageData } from '@/shared/types/chat';
|
|
7
|
-
import { extractText, extractUserImageAttachments, parseAssistantContent, resultOutput, roleFor, stripInlinedTextAttachments, type OmpMessageEntry } from '@/shared/lib/omp/session/messages-parse';
|
|
7
|
+
import { extractInlinedTextAttachments, extractText, extractUserImageAttachments, parseAssistantContent, resultOutput, roleFor, stripInlinedTextAttachments, type OmpMessageEntry } from '@/shared/lib/omp/session/messages-parse';
|
|
8
8
|
import { isRecord } from '@/shared/lib/util/guards';
|
|
9
9
|
import { reminderPartIndex } from '@/shared/lib/chat/notice-row';
|
|
10
10
|
import { deriveTurnError } from '@/shared/lib/omp/session/turn-error';
|
|
@@ -32,8 +32,21 @@ export function toChatMessage(entry: OmpMessageEntry): ChatMessageData | null {
|
|
|
32
32
|
};
|
|
33
33
|
|
|
34
34
|
if (role === 'user') {
|
|
35
|
-
const
|
|
36
|
-
const
|
|
35
|
+
const rawText = extractText(content);
|
|
36
|
+
const text = stripInlinedTextAttachments(rawText);
|
|
37
|
+
// omp keeps no attachment metadata for a text file: the composer inlined it
|
|
38
|
+
// into the prompt and that composed prompt is the only record. Recover the
|
|
39
|
+
// names from those blocks so a reloaded session still shows their chips —
|
|
40
|
+
// images survive on their own (they ride as a content block), text files
|
|
41
|
+
// did not, and disappeared from the timeline entirely.
|
|
42
|
+
const textAttachments = extractInlinedTextAttachments(rawText);
|
|
43
|
+
const attachments = [...extractUserImageAttachments(content), ...textAttachments.map((file, index) => ({
|
|
44
|
+
id: `text-${index + 1}`,
|
|
45
|
+
name: file.name,
|
|
46
|
+
type: 'text/plain',
|
|
47
|
+
preview: '',
|
|
48
|
+
content: file.content,
|
|
49
|
+
}))];
|
|
37
50
|
if (!text.trim() && attachments.length === 0) return null;
|
|
38
51
|
return { ...base, content: text, attachments };
|
|
39
52
|
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import type { ToolCallData } from '@/shared/types/chat';
|
|
15
15
|
import type { OmpMessageEntry } from '@/shared/types/omp/session';
|
|
16
|
-
import { extractText as coreExtractText, extractUserImageAttachments, parseMessageBlocks, stripInlinedTextAttachments, toToolCallData } from '@/shared/lib/omp/session/parse-message-blocks';
|
|
16
|
+
import { extractInlinedTextAttachments, extractText as coreExtractText, extractUserImageAttachments, parseMessageBlocks, stripInlinedTextAttachments, toToolCallData } from '@/shared/lib/omp/session/parse-message-blocks';
|
|
17
17
|
import { isRecord } from '@/shared/lib/util/guards';
|
|
18
18
|
|
|
19
19
|
export { stripInlinedTextAttachments };
|
|
@@ -66,4 +66,4 @@ export function parseAssistantContent(content: unknown): ParsedAssistantTurn {
|
|
|
66
66
|
};
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
export { extractUserImageAttachments };
|
|
69
|
+
export { extractUserImageAttachments, extractInlinedTextAttachments };
|
|
@@ -182,14 +182,37 @@ export function toToolCallData(
|
|
|
182
182
|
};
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
/**
|
|
186
|
+
* A user-attachment entry extracted from an omp content block. `blobRef` is set
|
|
187
|
+
* when omp externalized the bytes instead of inlining them (see
|
|
188
|
+
* `@/server/lib/omp/session/blobs.server`); the server resolves it before the
|
|
189
|
+
* timeline sees the attachment, so `preview` here is only a placeholder.
|
|
190
|
+
*/
|
|
191
|
+
export interface ExtractedImageAttachment {
|
|
192
|
+
id: string;
|
|
193
|
+
name: string;
|
|
194
|
+
preview: string;
|
|
195
|
+
type: string;
|
|
196
|
+
/** `blob:sha256:<hash>` when the bytes were externalized, else undefined. */
|
|
197
|
+
blobRef?: string;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** True for the `blob:sha256:<hash>` references omp writes in place of image data. */
|
|
201
|
+
export function isBlobImageRef(data: string): boolean {
|
|
202
|
+
return data.startsWith('blob:sha256:');
|
|
203
|
+
}
|
|
204
|
+
|
|
185
205
|
/** Extract image blocks from an omp user content array ({type:'image',
|
|
186
|
-
* data: base64
|
|
187
|
-
* timeline keeps showing them after a reload from the session
|
|
206
|
+
* data: base64 | blob:sha256:<hash>, mimeType}) into ChatMessageData attachment
|
|
207
|
+
* entries so the timeline keeps showing them after a reload from the session
|
|
208
|
+
* JSONL. A blob ref carries no preview: building one from the ref string
|
|
209
|
+
* produced an undecodable `data:` URL, which is what the server-side resolver
|
|
210
|
+
* now replaces with the real bytes. */
|
|
188
211
|
export function extractUserImageAttachments(
|
|
189
212
|
content: unknown,
|
|
190
|
-
):
|
|
213
|
+
): ExtractedImageAttachment[] {
|
|
191
214
|
if (!Array.isArray(content)) return [];
|
|
192
|
-
const attachments:
|
|
215
|
+
const attachments: ExtractedImageAttachment[] = [];
|
|
193
216
|
let index = 0;
|
|
194
217
|
for (const block of content) {
|
|
195
218
|
if (!isRecord(block) || block.type !== 'image') continue;
|
|
@@ -198,11 +221,13 @@ export function extractUserImageAttachments(
|
|
|
198
221
|
if (!data) continue;
|
|
199
222
|
index += 1;
|
|
200
223
|
const ext = (mimeType.split('/')[1] || 'png').replace(/[^a-z0-9]/gi, '') || 'png';
|
|
224
|
+
const externalized = isBlobImageRef(data);
|
|
201
225
|
attachments.push({
|
|
202
226
|
id: `att-${index}`,
|
|
203
227
|
name: `attachment-${index}.${ext}`,
|
|
204
|
-
preview: `data:${mimeType};base64,${data}`,
|
|
228
|
+
preview: externalized ? '' : `data:${mimeType};base64,${data}`,
|
|
205
229
|
type: mimeType,
|
|
230
|
+
...(externalized ? { blobRef: data } : {}),
|
|
206
231
|
});
|
|
207
232
|
}
|
|
208
233
|
return attachments;
|
|
@@ -221,3 +246,58 @@ export function stripInlinedTextAttachments(text: string): string {
|
|
|
221
246
|
if (!/^[^\n]+\n```[a-z]*\n/.test(rest)) return text;
|
|
222
247
|
return before.trim();
|
|
223
248
|
}
|
|
249
|
+
|
|
250
|
+
/** One text file recovered from an inlined `Attached file:` block. */
|
|
251
|
+
export interface InlinedTextAttachment {
|
|
252
|
+
name: string;
|
|
253
|
+
content: string;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Recover the text files the composer inlined into a delivered prompt.
|
|
258
|
+
*
|
|
259
|
+
* omp stores no attachment metadata for a text file: the composer appends each
|
|
260
|
+
* one to the prompt as `Attached file: <name>` plus a fenced block, and that
|
|
261
|
+
* composed prompt IS the session record. `stripInlinedTextAttachments` removes
|
|
262
|
+
* those blocks for display, which also throws the names away — so a reloaded
|
|
263
|
+
* session showed no chip for a dropped `.md`/`.py`/`.json` while its image
|
|
264
|
+
* siblings survived (images ride as their own content block).
|
|
265
|
+
*
|
|
266
|
+
* This walks the same format back out, so the timeline can render the chips and
|
|
267
|
+
* re-open the files. The fence length is whatever `fenceForContent` chose (at
|
|
268
|
+
* least 3 backticks, longer when the content itself contains a run), and a
|
|
269
|
+
* block's content may contain anything — including the literal string
|
|
270
|
+
* "Attached file:" — so the closing fence is matched by exact length rather
|
|
271
|
+
* than by looking for the next header.
|
|
272
|
+
*/
|
|
273
|
+
export function extractInlinedTextAttachments(text: string): InlinedTextAttachment[] {
|
|
274
|
+
const attachments: InlinedTextAttachment[] = [];
|
|
275
|
+
const header = /(?:^|\n{2})Attached file: ([^\n]+)\n(`{3,})[a-zA-Z0-9_-]*\n/g;
|
|
276
|
+
let match: RegExpExecArray | null;
|
|
277
|
+
while ((match = header.exec(text)) !== null) {
|
|
278
|
+
const name = match[1].trim();
|
|
279
|
+
const fence = match[2];
|
|
280
|
+
const contentStart = match.index + match[0].length;
|
|
281
|
+
// The closing fence sits on its own line and repeats the opening run
|
|
282
|
+
// exactly; a longer run is content, not the close.
|
|
283
|
+
const closer = `\n${fence}`;
|
|
284
|
+
let cursor = contentStart;
|
|
285
|
+
let end = -1;
|
|
286
|
+
while (cursor <= text.length) {
|
|
287
|
+
const at = text.indexOf(closer, cursor);
|
|
288
|
+
if (at === -1) break;
|
|
289
|
+
const after = text.slice(at + closer.length);
|
|
290
|
+
// Accept the close only at a block boundary: end of string, or a blank
|
|
291
|
+
// line followed by the next header / nothing.
|
|
292
|
+
if (after === '' || after.startsWith('\n') || /^\n{2}Attached file: /.test(after)) {
|
|
293
|
+
end = at;
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
cursor = at + closer.length;
|
|
297
|
+
}
|
|
298
|
+
if (end === -1) continue;
|
|
299
|
+
attachments.push({ name, content: text.slice(contentStart, end) });
|
|
300
|
+
header.lastIndex = end + closer.length;
|
|
301
|
+
}
|
|
302
|
+
return attachments;
|
|
303
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* BTW (side question) domain types.
|
|
8
|
+
*
|
|
9
|
+
* A BTW topic is an independent question plus its explicit follow-ups, run
|
|
10
|
+
* against the parent session's context but never appended to its transcript —
|
|
11
|
+
* the same contract omp's own `/btw` implements in the TUI. The server is the
|
|
12
|
+
* source of truth for a session's topics; the panel renders the state it is
|
|
13
|
+
* sent and streams answer text through `btw_delta` frames.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Lifecycle of one question/answer pair inside a topic. */
|
|
17
|
+
export type BtwTurnStatus = 'running' | 'complete' | 'cancelled' | 'failed' | 'interrupted';
|
|
18
|
+
|
|
19
|
+
/** Model a topic's side conversation runs on — the parent session's model,
|
|
20
|
+
* captured when the topic was created (omp's `/btw` uses the active model). */
|
|
21
|
+
export interface BtwModel {
|
|
22
|
+
provider: string;
|
|
23
|
+
id: string;
|
|
24
|
+
/** Display name reported by the running side session (`DeepSeek-V4.1-Flash`). */
|
|
25
|
+
name?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One question and its answer inside a topic. */
|
|
29
|
+
export interface BtwTurn {
|
|
30
|
+
index: number;
|
|
31
|
+
question: string;
|
|
32
|
+
answer: string;
|
|
33
|
+
status: BtwTurnStatus;
|
|
34
|
+
createdAt: number;
|
|
35
|
+
updatedAt: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A side conversation scoped to one chamber session. */
|
|
39
|
+
export interface BtwTopic {
|
|
40
|
+
id: string;
|
|
41
|
+
sessionId: string;
|
|
42
|
+
/** The first question, used as the topic's label in history. */
|
|
43
|
+
title: string;
|
|
44
|
+
createdAt: number;
|
|
45
|
+
updatedAt: number;
|
|
46
|
+
model?: BtwModel;
|
|
47
|
+
/** Set once the topic was promoted into the chat as a session of its own. */
|
|
48
|
+
promotedSessionId?: string;
|
|
49
|
+
turns: BtwTurn[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A session's whole BTW view. */
|
|
53
|
+
export interface BtwState {
|
|
54
|
+
topics: BtwTopic[];
|
|
55
|
+
/** Topic with a question in flight; at most one per session, because a
|
|
56
|
+
* session runs one side conversation at a time. */
|
|
57
|
+
runningTopicId: string | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Server → client frames on `/api/btw/:sessionId/*` (WS and SSE alike). */
|
|
61
|
+
export type BtwFrame =
|
|
62
|
+
| { type: 'connected'; sessionId: string }
|
|
63
|
+
| { type: 'btw_state'; state: BtwState }
|
|
64
|
+
| { type: 'btw_delta'; topicId: string; turnIndex: number; text: string }
|
|
65
|
+
| { type: 'btw_error'; topicId: string | null; message: string };
|