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,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Attachment metadata across the two representations the composer handles.
|
|
8
|
+
*
|
|
9
|
+
* A LIVE attachment carries `file: File`; a REPLAYED one — out of the queue row
|
|
10
|
+
* or back out of committed history — does not, because `JSON.stringify(new
|
|
11
|
+
* File(…))` is `{}`. Every accessor and every assembly path must therefore work
|
|
12
|
+
* from the persisted display fields, which is what used to break: a queued or
|
|
13
|
+
* retried send read `a.file.name` and threw, and the image payload filter
|
|
14
|
+
* matched on a `data:` preview the composer never produces.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, expect, test } from 'bun:test';
|
|
18
|
+
import type { Attachment } from '@/shared/types';
|
|
19
|
+
import {
|
|
20
|
+
applyTextAttachmentBudget,
|
|
21
|
+
attachmentImage,
|
|
22
|
+
attachmentName,
|
|
23
|
+
attachmentSize,
|
|
24
|
+
attachmentType,
|
|
25
|
+
composeMessageWithTextAttachments,
|
|
26
|
+
isImageAttachment,
|
|
27
|
+
isTextAttachment,
|
|
28
|
+
looksLikeTextFile,
|
|
29
|
+
prepareQueuedAttachments,
|
|
30
|
+
readTextAttachments,
|
|
31
|
+
} from '@/shared/lib/chat/attachments';
|
|
32
|
+
|
|
33
|
+
function liveFile(name: string, body: string, type = ''): File {
|
|
34
|
+
return new File([body], name, { type });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function liveAttachment(overrides: Partial<Attachment> & { id: string }): Attachment {
|
|
38
|
+
return { preview: '', ...overrides };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** What the queue row and committed history actually hold. */
|
|
42
|
+
function replayed(attachments: Attachment[]): Attachment[] {
|
|
43
|
+
return JSON.parse(JSON.stringify(attachments)) as Attachment[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('attachment metadata accessors', () => {
|
|
47
|
+
test('read a live file, and fall back to the persisted fields', () => {
|
|
48
|
+
const live = liveAttachment({ id: 'a', name: 'notes.md', type: 'text/markdown', size: 99, file: liveFile('notes.md', '# hi', 'text/markdown') });
|
|
49
|
+
expect(attachmentName(live)).toBe('notes.md');
|
|
50
|
+
expect(attachmentType(live)).toBe('text/markdown');
|
|
51
|
+
expect(attachmentSize(live)).toBe(4); // the live handle is authoritative
|
|
52
|
+
|
|
53
|
+
const replay = replayed([live])[0];
|
|
54
|
+
// `JSON.stringify` renders a File as an empty object — not as undefined —
|
|
55
|
+
// so the replay path must treat a truthiness check as unreliable and read
|
|
56
|
+
// the display fields first.
|
|
57
|
+
expect(replay.file as unknown).toEqual({});
|
|
58
|
+
expect(attachmentName(replay)).toBe('notes.md');
|
|
59
|
+
expect(attachmentType(replay)).toBe('text/markdown');
|
|
60
|
+
expect(attachmentSize(replay)).toBe(99);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('never throw on an attachment with neither a file nor a name', () => {
|
|
64
|
+
const bare = { id: 'x', preview: '' } as Attachment;
|
|
65
|
+
expect(attachmentName(bare)).toBe('attachment');
|
|
66
|
+
expect(attachmentType(bare)).toBe('');
|
|
67
|
+
expect(attachmentSize(bare)).toBe(0);
|
|
68
|
+
expect(isTextAttachment(bare)).toBe(false);
|
|
69
|
+
expect(attachmentImage(bare)).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('text vs image classification', () => {
|
|
74
|
+
test('code files with no browser MIME type still inline', () => {
|
|
75
|
+
// `File.type` is empty for most source files, so the extension decides.
|
|
76
|
+
for (const name of ['script.py', 'app.tsx', 'query.sql', 'Dockerfile.json', 'data.yaml']) {
|
|
77
|
+
expect(isTextAttachment({ name, type: '', file: liveFile(name, 'x') })).toBe(true);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('a browser-reported text MIME type is enough on its own', () => {
|
|
82
|
+
expect(isTextAttachment({ name: 'LICENSE', type: 'text/plain', file: liveFile('LICENSE', 'x', 'text/plain') })).toBe(true);
|
|
83
|
+
expect(isTextAttachment({ name: 'blob.bin', type: 'application/json', file: liveFile('blob.bin', '{}', 'application/json') })).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('binaries and images never consume the inline budget', () => {
|
|
87
|
+
expect(isTextAttachment({ name: 'app.dmg', type: 'application/octet-stream', file: liveFile('app.dmg', 'x', 'application/octet-stream') })).toBe(false);
|
|
88
|
+
// An image whose name happens to look textual is still an image.
|
|
89
|
+
expect(isTextAttachment({ name: 'trap.md', type: 'image/png', file: liveFile('trap.md', 'x', 'image/png') })).toBe(false);
|
|
90
|
+
expect(isImageAttachment({ name: 'trap.md', type: 'image/png', file: liveFile('trap.md', 'x', 'image/png') })).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('image payloads', () => {
|
|
95
|
+
test('carry the omp ImageContent type and the persisted MIME type', () => {
|
|
96
|
+
const payload = attachmentImage({
|
|
97
|
+
type: 'image/png',
|
|
98
|
+
dataBase64: 'iVBORw0KGgo=',
|
|
99
|
+
});
|
|
100
|
+
expect(payload).toEqual({ type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('a replay with a blob preview still yields its payload', () => {
|
|
104
|
+
// The old server-side filter matched `preview.startsWith('data:image/')`
|
|
105
|
+
// and so found nothing: the composer's preview is a blob URL.
|
|
106
|
+
const [replay] = replayed([
|
|
107
|
+
liveAttachment({ id: 'i', name: 's.png', type: 'image/png', preview: 'blob:http://x/1', dataBase64: 'AAAA', file: liveFile('s.png', 'x', 'image/png') }),
|
|
108
|
+
]);
|
|
109
|
+
expect(attachmentImage(replay)?.data).toBe('AAAA');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('an unread image yields nothing rather than a blank payload', () => {
|
|
113
|
+
expect(attachmentImage({ type: 'image/png', dataBase64: '' })).toBeNull();
|
|
114
|
+
expect(attachmentImage({ type: 'image/png' })).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('text contents', () => {
|
|
119
|
+
test('are read from a live file, and from `content` on a replay', async () => {
|
|
120
|
+
const live = [liveAttachment({ id: 't', name: 'notes.md', type: 'text/markdown', file: liveFile('notes.md', '# hi', 'text/markdown') })];
|
|
121
|
+
expect((await readTextAttachments(live)).map((f) => f.content)).toEqual(['# hi']);
|
|
122
|
+
|
|
123
|
+
const replay = replayed(await prepareQueuedAttachments(live));
|
|
124
|
+
expect(replay[0].file).toBeUndefined();
|
|
125
|
+
expect(replay[0].content).toBe('# hi');
|
|
126
|
+
expect((await readTextAttachments(replay)).map((f) => f.content)).toEqual(['# hi']);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('a replay without a persisted copy is flagged, not silently dropped', async () => {
|
|
130
|
+
// It used to be filtered out, which sent a prompt naming a file whose
|
|
131
|
+
// contents never arrived — with nothing in the UI to say so.
|
|
132
|
+
const files = await readTextAttachments([
|
|
133
|
+
{ id: 't', name: 'notes.md', type: 'text/markdown', preview: '' },
|
|
134
|
+
]);
|
|
135
|
+
expect(files.map((f) => ({ name: f.name, missing: f.missing, content: f.content }))).toEqual([
|
|
136
|
+
{ name: 'notes.md', missing: true, content: '' },
|
|
137
|
+
]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('carry their attachment id so the caller can map them back', async () => {
|
|
141
|
+
const files = await readTextAttachments([
|
|
142
|
+
{ id: 'keep', name: 'a.md', type: 'text/markdown', preview: '', content: 'a' },
|
|
143
|
+
{ id: 'drop', name: 'b.bin', type: 'application/octet-stream', preview: '', content: 'b' },
|
|
144
|
+
]);
|
|
145
|
+
expect(files.map((f) => f.id)).toEqual(['keep']);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('are fenced with a language that matches the file', () => {
|
|
149
|
+
const py = [{ name: 'script.py', mimeType: 'text/x-python', content: 'x=1', size: 3 }];
|
|
150
|
+
expect(composeMessageWithTextAttachments('go', py)).toContain('```python');
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('inline budget', () => {
|
|
155
|
+
test('counts replayed attachments, so repeated drops stop at the cap', () => {
|
|
156
|
+
const existing = Array.from({ length: 10 }, (_, i) => ({ name: `f${i}.md`, type: 'text/markdown', size: 10, content: 'x' }));
|
|
157
|
+
const { accepted, skipped } = applyTextAttachmentBudget([liveFile('extra.md', 'x', 'text/markdown')], existing);
|
|
158
|
+
expect(accepted).toEqual([]);
|
|
159
|
+
expect(skipped.map((f) => f.name)).toEqual(['extra.md']);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('lets an image through when the text budget is spent', () => {
|
|
163
|
+
const existing = Array.from({ length: 10 }, (_, i) => ({ name: `f${i}.md`, type: 'text/markdown', size: 10, content: 'x' }));
|
|
164
|
+
const { accepted, skipped } = applyTextAttachmentBudget([liveFile('shot.png', 'x', 'image/png')], existing);
|
|
165
|
+
expect(accepted.map((f) => f.name)).toEqual(['shot.png']);
|
|
166
|
+
expect(skipped).toEqual([]);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe('content sniffing for unclassified files', () => {
|
|
171
|
+
test('a file whose name and MIME say nothing is recognised as text', async () => {
|
|
172
|
+
// A promised-file drag from another app arrives like this: real bytes, a
|
|
173
|
+
// generic name, no usable type. Deciding from metadata alone left it
|
|
174
|
+
// attached but never inlined.
|
|
175
|
+
expect(await looksLikeTextFile(liveFile('document', 'IMPORTANT\ncode: ABC'))).toBe(true);
|
|
176
|
+
expect(await looksLikeTextFile(liveFile('file', '# Title\n\nBody'))).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('binary content is refused', async () => {
|
|
180
|
+
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
181
|
+
expect(await looksLikeTextFile(new File([png], 'download'))).toBe(false);
|
|
182
|
+
// A NUL byte is decisive: no text encoding this app handles contains one.
|
|
183
|
+
expect(await looksLikeTextFile(new File([new Uint8Array([65, 66, 0, 67])], 'blob'))).toBe(false);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('an empty file is not treated as text', async () => {
|
|
187
|
+
expect(await looksLikeTextFile(liveFile('empty', ''))).toBe(false);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('a sniffed attachment keeps its content through the read path', async () => {
|
|
191
|
+
// The sniffer's verdict is recorded on the attachment at attach time, so the
|
|
192
|
+
// send path must honour it even though the name still says nothing — and a
|
|
193
|
+
// bare `content` string must NOT be enough to smuggle a binary through.
|
|
194
|
+
const sniffed: Attachment = {
|
|
195
|
+
id: 'x',
|
|
196
|
+
name: 'document',
|
|
197
|
+
type: '',
|
|
198
|
+
size: 20,
|
|
199
|
+
preview: '',
|
|
200
|
+
content: 'IMPORTANT\ncode: ABC',
|
|
201
|
+
sniffedText: true,
|
|
202
|
+
};
|
|
203
|
+
const read = await readTextAttachments([sniffed]);
|
|
204
|
+
expect(read.map((f) => ({ name: f.name, content: f.content }))).toEqual([
|
|
205
|
+
{ name: 'document', content: 'IMPORTANT\ncode: ABC' },
|
|
206
|
+
]);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -4,22 +4,32 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* Attachment metadata and prompt composition for the chat composer.
|
|
8
|
+
*
|
|
9
|
+
* Two representations of the same attachment exist and the difference is the
|
|
10
|
+
* whole reason this module exists:
|
|
11
|
+
*
|
|
12
|
+
* - A LIVE attachment (just picked/pasted/dropped) carries `file: File`, so its
|
|
13
|
+
* bytes are readable.
|
|
14
|
+
* - A REPLAYED attachment comes from the `queued_messages` row or from committed
|
|
15
|
+
* history. `File` does not survive either trip (`JSON.stringify(new File(…))`
|
|
16
|
+
* is `{}`), so only `name`/`type`/`size`/`preview`/`dataBase64`/`content`
|
|
17
|
+
* remain.
|
|
18
|
+
*
|
|
19
|
+
* Every read of an attachment's metadata therefore goes through the accessors
|
|
20
|
+
* below, which read the display fields and fall back to the live `File` — never
|
|
21
|
+
* `a.file.name`, which throws on a replayed attachment and is what used to break
|
|
22
|
+
* queued delivery and retry.
|
|
10
23
|
*/
|
|
11
24
|
|
|
25
|
+
import type { AgentImage, Attachment } from '@/shared/types';
|
|
26
|
+
import { TEXT_FILE_EXTENSIONS, isTextMimeType } from '@/shared/lib/chat/text-file';
|
|
27
|
+
|
|
28
|
+
export { looksLikeTextFile } from '@/shared/lib/chat/text-file';
|
|
29
|
+
|
|
12
30
|
export const MAX_ATTACHED_TEXT_BYTES = 256 * 1024;
|
|
13
31
|
export const MAX_ATTACHED_TEXT_FILES = 10;
|
|
14
32
|
|
|
15
|
-
const TEXT_FILE_EXTENSIONS: Record<string, true> = {
|
|
16
|
-
txt: true,
|
|
17
|
-
text: true,
|
|
18
|
-
md: true,
|
|
19
|
-
markdown: true,
|
|
20
|
-
mdx: true,
|
|
21
|
-
};
|
|
22
|
-
|
|
23
33
|
export interface AttachedTextFileData {
|
|
24
34
|
name: string;
|
|
25
35
|
mimeType: string;
|
|
@@ -27,19 +37,125 @@ export interface AttachedTextFileData {
|
|
|
27
37
|
size: number;
|
|
28
38
|
}
|
|
29
39
|
|
|
40
|
+
/** An inlined text file plus the id of the attachment it came from. */
|
|
41
|
+
export interface ReadTextAttachment extends AttachedTextFileData {
|
|
42
|
+
id: string;
|
|
43
|
+
/** True when no bytes could be read — the file is named but not carried. */
|
|
44
|
+
missing?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
30
47
|
function getFileExtension(name: string): string {
|
|
31
48
|
return name.toLowerCase().replace(/\\/g, '/').split('/').pop()?.split('.').pop() ?? '';
|
|
32
49
|
}
|
|
33
50
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
51
|
+
/** Attachment display name, live file first, persisted name second. */
|
|
52
|
+
export function attachmentName(att: Pick<Attachment, 'name' | 'file'>): string {
|
|
53
|
+
return att.name ?? att.file?.name ?? 'attachment';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Attachment MIME type, live file first, persisted type second. */
|
|
57
|
+
export function attachmentType(att: Pick<Attachment, 'type' | 'file'>): string {
|
|
58
|
+
return att.type ?? att.file?.type ?? '';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Attachment byte size, live file first, persisted size second. */
|
|
62
|
+
export function attachmentSize(att: Pick<Attachment, 'size' | 'file'>): number {
|
|
63
|
+
return att.file?.size ?? att.size ?? 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Whether this attachment's contents can be inlined into the prompt. */
|
|
67
|
+
export function isTextAttachment(att: Pick<Attachment, 'name' | 'type' | 'file'>): boolean {
|
|
68
|
+
const type = attachmentType(att);
|
|
69
|
+
if (isTextMimeType(type)) return true;
|
|
70
|
+
// An image is never inlined, whatever its extension claims.
|
|
71
|
+
if (type.startsWith('image/')) return false;
|
|
72
|
+
return TEXT_FILE_EXTENSIONS[getFileExtension(attachmentName(att))] === true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Whether this attachment is an image the model can receive. */
|
|
76
|
+
export function isImageAttachment(att: Pick<Attachment, 'name' | 'type' | 'file'>): boolean {
|
|
77
|
+
return attachmentType(att).startsWith('image/');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Base64 image payload for the omp RPC, or null when there is nothing to send. */
|
|
81
|
+
export function attachmentImage(
|
|
82
|
+
att: Pick<Attachment, 'type' | 'file' | 'dataBase64'>,
|
|
83
|
+
): AgentImage | null {
|
|
84
|
+
if (!att.dataBase64 || !isImageAttachment(att)) return null;
|
|
85
|
+
return {
|
|
86
|
+
type: 'image',
|
|
87
|
+
data: att.dataBase64,
|
|
88
|
+
// A persisted attachment carries the authoritative type; the fallback keeps
|
|
89
|
+
// a provider from rejecting a payload whose type the browser left empty.
|
|
90
|
+
mimeType: attachmentType(att) || 'image/png',
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface AttachmentBudget {
|
|
95
|
+
/** Files that fit the inline budget and may be attached. */
|
|
96
|
+
accepted: File[];
|
|
97
|
+
/** Text files refused because the budget was already spent. */
|
|
98
|
+
skipped: File[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** User-facing reason for a refusal, shared by every composer attach path. */
|
|
102
|
+
export function describeAttachmentBudget(skipped: File[]): string {
|
|
103
|
+
const limit = `${MAX_ATTACHED_TEXT_FILES} text files / ${Math.round(MAX_ATTACHED_TEXT_BYTES / 1024)} KB total`;
|
|
104
|
+
if (skipped.length === 1) return `"${skipped[0].name}" was not attached — inline limit is ${limit}.`;
|
|
105
|
+
return `${skipped.length} text files were not attached — inline limit is ${limit}.`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Gate a batch of incoming files against the text-inline budget.
|
|
110
|
+
*
|
|
111
|
+
* Only text files are gated: their contents are inlined into the prompt, so a
|
|
112
|
+
* directory drop can otherwise push megabytes into a single request. Images and
|
|
113
|
+
* other binaries travel as payloads and are capped by the caller's own limit.
|
|
114
|
+
*
|
|
115
|
+
* The budget spans the whole composer, not the batch — `existing` counts too,
|
|
116
|
+
* so three successive drops of four text files stop at ten.
|
|
117
|
+
*/
|
|
118
|
+
export function applyTextAttachmentBudget(
|
|
119
|
+
files: File[],
|
|
120
|
+
existing: { name?: string; type?: string; size?: number; file?: File }[],
|
|
121
|
+
): AttachmentBudget {
|
|
122
|
+
const accepted: File[] = [];
|
|
123
|
+
const skipped: File[] = [];
|
|
124
|
+
let textFiles = existing.filter((a) => isTextAttachment(a)).length;
|
|
125
|
+
let textBytes = existing.reduce((total, a) => (isTextAttachment(a) ? attachmentSize(a) : total), 0);
|
|
126
|
+
|
|
127
|
+
for (const file of files) {
|
|
128
|
+
if (!isTextAttachment({ name: file.name, type: file.type, file })) {
|
|
129
|
+
accepted.push(file);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (textFiles >= MAX_ATTACHED_TEXT_FILES || textBytes + file.size > MAX_ATTACHED_TEXT_BYTES) {
|
|
133
|
+
skipped.push(file);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
textFiles += 1;
|
|
137
|
+
textBytes += file.size;
|
|
138
|
+
accepted.push(file);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { accepted, skipped };
|
|
38
142
|
}
|
|
39
143
|
|
|
40
144
|
function languageForFile(name: string): string {
|
|
41
145
|
const extension = getFileExtension(name);
|
|
42
146
|
if (extension === 'md' || extension === 'markdown' || extension === 'mdx') return 'markdown';
|
|
147
|
+
if (extension === 'json' || extension === 'jsonc') return 'json';
|
|
148
|
+
if (extension === 'yaml' || extension === 'yml') return 'yaml';
|
|
149
|
+
if (extension === 'ts' || extension === 'tsx' || extension === 'mts' || extension === 'cts') return 'typescript';
|
|
150
|
+
if (extension === 'js' || extension === 'jsx' || extension === 'mjs' || extension === 'cjs') return 'javascript';
|
|
151
|
+
if (extension === 'py') return 'python';
|
|
152
|
+
if (extension === 'sh' || extension === 'bash' || extension === 'zsh') return 'bash';
|
|
153
|
+
if (extension === 'rs') return 'rust';
|
|
154
|
+
if (extension === 'go') return 'go';
|
|
155
|
+
if (extension === 'sql') return 'sql';
|
|
156
|
+
if (extension === 'html' || extension === 'htm') return 'html';
|
|
157
|
+
if (extension === 'css') return 'css';
|
|
158
|
+
if (extension === 'xml') return 'xml';
|
|
43
159
|
return 'text';
|
|
44
160
|
}
|
|
45
161
|
|
|
@@ -60,3 +176,72 @@ export function composeMessageWithTextAttachments(
|
|
|
60
176
|
});
|
|
61
177
|
return [message.trim(), ...blocks].filter(Boolean).join('\n\n');
|
|
62
178
|
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Snapshot attachments for storage in the queue row, where a `File` cannot
|
|
182
|
+
* survive. Reads text contents while the live handle still exists, so delivery
|
|
183
|
+
* can inline them, and keeps every display field the chip renderer needs.
|
|
184
|
+
*/
|
|
185
|
+
export async function prepareQueuedAttachments(attachments: Attachment[]): Promise<Attachment[]> {
|
|
186
|
+
const textFiles = await readTextAttachments(attachments);
|
|
187
|
+
const contentById = new Map(textFiles.map((file) => [file.id, file.content]));
|
|
188
|
+
return attachments.map((att) => ({
|
|
189
|
+
id: att.id,
|
|
190
|
+
name: attachmentName(att),
|
|
191
|
+
type: attachmentType(att),
|
|
192
|
+
size: attachmentSize(att),
|
|
193
|
+
// The blob URL dies with the page; the data URL survives in the row.
|
|
194
|
+
preview: att.dataBase64 ? `data:${attachmentType(att)};base64,${att.dataBase64}` : att.preview,
|
|
195
|
+
...(att.dataBase64 ? { dataBase64: att.dataBase64 } : {}),
|
|
196
|
+
...(contentById.has(att.id) ? { content: contentById.get(att.id) } : {}),
|
|
197
|
+
...(att.sniffedText ? { sniffedText: true } : {}),
|
|
198
|
+
}));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The inline-able text files of a batch, reading live `File` bytes where
|
|
203
|
+
* present and persisted `content` otherwise. Returns [] when nothing qualifies.
|
|
204
|
+
*
|
|
205
|
+
* Each entry carries the source attachment's `id`, so the caller can map a file
|
|
206
|
+
* back to the attachment it came from without re-deriving the filter.
|
|
207
|
+
*
|
|
208
|
+
* An attachment with NO readable bytes is returned with `missing: true` rather
|
|
209
|
+
* than dropped: silently omitting it sent a prompt that named a file the model
|
|
210
|
+
* never received, with nothing in the UI to say so. The caller decides whether
|
|
211
|
+
* to warn or to send anyway.
|
|
212
|
+
*/
|
|
213
|
+
export async function readTextAttachments(
|
|
214
|
+
attachments: Attachment[],
|
|
215
|
+
): Promise<ReadTextAttachment[]> {
|
|
216
|
+
// An attachment that already carries `content` was read at attach time — for
|
|
217
|
+
// an unclassified file that read only happened because the sniffer cleared it.
|
|
218
|
+
// Anything else must still classify from metadata, so a binary that happens to
|
|
219
|
+
// have a stray `content` string cannot smuggle itself into the prompt.
|
|
220
|
+
const textAttachments = attachments.filter((a) => isTextAttachment(a) || a.sniffedText === true);
|
|
221
|
+
const contents = await Promise.all(
|
|
222
|
+
textAttachments.map(async (att): Promise<{ content: string | null; missing: boolean }> => {
|
|
223
|
+
// The persisted copy wins: it was read at attach time through
|
|
224
|
+
// `FileReader`, which is the read that waits for another app's file to
|
|
225
|
+
// materialize. Re-reading the live handle here is what returned an empty
|
|
226
|
+
// body for a file whose bytes had already been captured.
|
|
227
|
+
if (typeof att.content === 'string') return { content: att.content, missing: false };
|
|
228
|
+
if (att.file) {
|
|
229
|
+
try {
|
|
230
|
+
return { content: await att.file.text(), missing: false };
|
|
231
|
+
} catch {
|
|
232
|
+
// Fall through to the persisted copy, if any.
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (typeof att.content === 'string') return { content: att.content, missing: false };
|
|
236
|
+
return { content: null, missing: true };
|
|
237
|
+
}),
|
|
238
|
+
);
|
|
239
|
+
return textAttachments.map((att, index) => ({
|
|
240
|
+
id: att.id,
|
|
241
|
+
name: attachmentName(att),
|
|
242
|
+
mimeType: attachmentType(att),
|
|
243
|
+
content: contents[index].content ?? '',
|
|
244
|
+
size: attachmentSize(att),
|
|
245
|
+
missing: contents[index].missing,
|
|
246
|
+
}));
|
|
247
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Client for a session's BTW API: the frame stream, plus the commands that
|
|
8
|
+
* create, continue, cancel and promote a side question.
|
|
9
|
+
*
|
|
10
|
+
* The stream reuses the generic JSON connectors the agent stream introduced
|
|
11
|
+
* (they are transport-only: URL in, decoded frames out), so a session's side
|
|
12
|
+
* questions follow the same transport setting — WebSocket by default, SSE
|
|
13
|
+
* where the upgrade is blocked — instead of forking a third stream flavour.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { AgentImage, BtwFrame, BtwState, StreamTransport } from '@/shared/types';
|
|
17
|
+
import { connectEvents } from '@/shared/lib/chat/omp/sse';
|
|
18
|
+
import { connectSocket } from '@/shared/lib/chat/omp/socket';
|
|
19
|
+
import { btwEventsUrl, btwSocketUrl } from '@/shared/lib/chat/omp/transport';
|
|
20
|
+
import type { StreamConnection, StreamHandlers } from '@/shared/lib/chat/omp/transport';
|
|
21
|
+
|
|
22
|
+
export type BtwStreamHandlers = StreamHandlers<BtwFrame>;
|
|
23
|
+
|
|
24
|
+
/** A rejected command, carrying the server's machine-readable reason. */
|
|
25
|
+
export class BtwRequestError extends Error {
|
|
26
|
+
readonly code?: string;
|
|
27
|
+
|
|
28
|
+
constructor(message: string, code?: string) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = 'BtwRequestError';
|
|
31
|
+
this.code = code;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function connectBtwStream(
|
|
36
|
+
sessionId: string,
|
|
37
|
+
transport: StreamTransport,
|
|
38
|
+
handlers: BtwStreamHandlers,
|
|
39
|
+
): StreamConnection {
|
|
40
|
+
return transport === 'sse' ? connectEvents(btwEventsUrl(sessionId), handlers) : connectSocket(btwSocketUrl(sessionId), handlers);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface BtwResponseBody {
|
|
44
|
+
success?: boolean;
|
|
45
|
+
data?: unknown;
|
|
46
|
+
error?: string;
|
|
47
|
+
code?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function requestBtw<T>(sessionId: string, method: 'GET' | 'POST', body?: Record<string, unknown>): Promise<T> {
|
|
51
|
+
const response = await fetch(`/api/btw/${encodeURIComponent(sessionId)}`, {
|
|
52
|
+
method,
|
|
53
|
+
...(body
|
|
54
|
+
? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }
|
|
55
|
+
: {}),
|
|
56
|
+
});
|
|
57
|
+
const payload = (await response.json().catch(() => null)) as BtwResponseBody | null;
|
|
58
|
+
if (!response.ok || !payload?.success) {
|
|
59
|
+
throw new BtwRequestError(payload?.error ?? `Side question request failed (${response.status})`, payload?.code);
|
|
60
|
+
}
|
|
61
|
+
return payload.data as T;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The session's side questions as the server sees them. */
|
|
65
|
+
export function fetchBtwState(sessionId: string): Promise<BtwState> {
|
|
66
|
+
return requestBtw<BtwState>(sessionId, 'GET');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface AskBtwInput {
|
|
70
|
+
topicId?: string;
|
|
71
|
+
question: string;
|
|
72
|
+
images?: AgentImage[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function askBtwQuestion(sessionId: string, input: AskBtwInput): Promise<BtwState> {
|
|
76
|
+
return requestBtw<BtwState>(sessionId, 'POST', {
|
|
77
|
+
action: 'ask',
|
|
78
|
+
question: input.question,
|
|
79
|
+
...(input.topicId ? { topicId: input.topicId } : {}),
|
|
80
|
+
...(input.images?.length ? { images: input.images } : {}),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function abortBtwQuestion(sessionId: string, topicId: string): Promise<BtwState> {
|
|
85
|
+
return requestBtw<BtwState>(sessionId, 'POST', { action: 'abort', topicId });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function promoteBtwTopic(sessionId: string, topicId: string): Promise<{ sessionId: string }> {
|
|
89
|
+
return requestBtw<{ sessionId: string }>(sessionId, 'POST', { action: 'promote', topicId });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function deleteBtwTopic(sessionId: string, topicId: string): Promise<BtwState> {
|
|
93
|
+
return requestBtw<BtwState>(sessionId, 'POST', { action: 'delete', topicId });
|
|
94
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*
|
|
5
|
+
* The popup's command pool has two sources with different owners: omp's
|
|
6
|
+
* `get_available_commands` and the chamber's own commands. `/btw` exists only
|
|
7
|
+
* in the second — omp's registry entry for it is TUI-only, so RPC never
|
|
8
|
+
* advertises it — and the composer intercepts the token on send. If the entry
|
|
9
|
+
* ever disappears, the popup offers every command except the one being typed,
|
|
10
|
+
* and the user is left guessing why `/btw` matches nothing but `browser` by
|
|
11
|
+
* description.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, expect, test } from 'bun:test';
|
|
15
|
+
|
|
16
|
+
import type { CommandItem } from '@/shared/types';
|
|
17
|
+
import { mergeCommandAndSkillItems } from '@/shared/lib/chat/composer/client';
|
|
18
|
+
|
|
19
|
+
function command(over: Partial<CommandItem> & { name: string }): CommandItem {
|
|
20
|
+
return {
|
|
21
|
+
id: `cmd-${over.name}`,
|
|
22
|
+
description: '',
|
|
23
|
+
scope: 'system',
|
|
24
|
+
template: '',
|
|
25
|
+
...over,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('mergeCommandAndSkillItems', () => {
|
|
30
|
+
test('offers the chamber-owned /btw with omp metadata', () => {
|
|
31
|
+
const items = mergeCommandAndSkillItems([command({ name: 'model', description: 'Show current model selection' })], []);
|
|
32
|
+
|
|
33
|
+
const btw = items.find((item) => item.name === 'btw');
|
|
34
|
+
expect(btw).toBeDefined();
|
|
35
|
+
expect(btw?.token).toBe('/btw');
|
|
36
|
+
expect(btw?.kind).toBe('command');
|
|
37
|
+
// The hint is folded into the description exactly as the popup renders it.
|
|
38
|
+
expect(btw?.description).toBe("[question] - Ask a side question, or browse this session's BTW history");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('keeps the chamber entry when omp advertises the same name', () => {
|
|
42
|
+
const items = mergeCommandAndSkillItems([command({ name: 'btw', description: 'something else entirely' })], []);
|
|
43
|
+
|
|
44
|
+
const matching = items.filter((item) => item.name === 'btw');
|
|
45
|
+
expect(matching.length).toBe(1);
|
|
46
|
+
expect(matching[0].description).toContain('Ask a side question');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AgentItem, CommandItem, ComposerPickItem, ComposerPickKind, FsFileEntry, SkillItem } from '@/shared/types';
|
|
2
|
+
import { CHAMBER_COMMANDS } from '@/shared/lib/chat/composer/trigger';
|
|
2
3
|
|
|
3
4
|
const CACHE_TTL_MS = 300_000;
|
|
4
5
|
|
|
@@ -61,6 +62,10 @@ export function toFilePickItems(files: FsFileEntry[]): ComposerPickItem[] {
|
|
|
61
62
|
* `skill:`-namespaced name a command already covers is skipped, so the two
|
|
62
63
|
* sources never double-list the same skill.
|
|
63
64
|
*
|
|
65
|
+
* The chamber's own commands (`CHAMBER_COMMANDS`) lead, so a same-named entry
|
|
66
|
+
* from omp can never advertise behavior the composer overrides on send — `/btw`
|
|
67
|
+
* is reserved by omp in the TUI for the same reason.
|
|
68
|
+
*
|
|
64
69
|
* The description carries the argument hint exactly as oh-my-pi renders it
|
|
65
70
|
* (`[on|off|status] - Toggle fast mode`) so the popup matches the CLI.
|
|
66
71
|
*/
|
|
@@ -68,7 +73,7 @@ export function mergeCommandAndSkillItems(commands: CommandItem[], skills: Skill
|
|
|
68
73
|
const result: ComposerPickItem[] = [];
|
|
69
74
|
const seen = new Set<string>();
|
|
70
75
|
|
|
71
|
-
for (const command of commands) {
|
|
76
|
+
for (const command of [...CHAMBER_COMMANDS, ...commands]) {
|
|
72
77
|
const key = command.name.toLowerCase();
|
|
73
78
|
if (seen.has(key)) continue;
|
|
74
79
|
seen.add(key);
|
|
@@ -14,8 +14,9 @@
|
|
|
14
14
|
import { describe, expect, test } from 'bun:test';
|
|
15
15
|
|
|
16
16
|
import type { ComposerPickItem } from '@/shared/types';
|
|
17
|
-
import { detectComposerTrigger, insertToken, tokenForItem } from '@/shared/lib/chat/composer/trigger';
|
|
17
|
+
import { detectComposerTrigger, insertToken, sendInsteadOfAccept, tokenForItem } from '@/shared/lib/chat/composer/trigger';
|
|
18
18
|
import { filterComposerItems } from '@/shared/lib/chat/composer/filter';
|
|
19
|
+
import { mergeCommandAndSkillItems } from '@/shared/lib/chat/composer/client';
|
|
19
20
|
|
|
20
21
|
function item(over: Partial<ComposerPickItem> & { name: string }): ComposerPickItem {
|
|
21
22
|
const isSkill = over.name.startsWith('skill:');
|
|
@@ -155,3 +156,40 @@ describe('composer slash trigger', () => {
|
|
|
155
156
|
expect(detectComposerTrigger('foo@bar', 7)).toBeNull();
|
|
156
157
|
});
|
|
157
158
|
});
|
|
159
|
+
|
|
160
|
+
describe('composer Enter on a complete command', () => {
|
|
161
|
+
/** The real popup pool: omp's commands plus the chamber's own. */
|
|
162
|
+
const CHAMBER_POOL: ComposerPickItem[] = mergeCommandAndSkillItems(
|
|
163
|
+
[{ id: 'c-model', name: 'model', description: 'Show current model selection', scope: 'system', template: '' }],
|
|
164
|
+
[],
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
/** Does Enter send here, or does the popup accept the highlighted item? */
|
|
168
|
+
const enterSends = (text: string): boolean => {
|
|
169
|
+
const trigger = detectComposerTrigger(text, text.length);
|
|
170
|
+
if (!trigger) throw new Error(`no trigger for ${text}`);
|
|
171
|
+
const [highlighted] = filterComposerItems(CHAMBER_POOL, trigger);
|
|
172
|
+
return sendInsteadOfAccept(trigger, highlighted);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
test('the chamber-owned /btw sends, so Enter reaches the side-question form', () => {
|
|
176
|
+
expect(enterSends('/btw')).toBe(true);
|
|
177
|
+
expect(enterSends('/BTW')).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('a command omp owns still accepts, however complete it looks', () => {
|
|
181
|
+
// `/model` takes no subcommands, but the chamber does not intercept it:
|
|
182
|
+
// accepting is what the CLI does, and it must stay that way.
|
|
183
|
+
expect(enterSends('/model')).toBe(false);
|
|
184
|
+
expect(enterSends('/mo')).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('a command with subcommands still opens its arguments', () => {
|
|
188
|
+
expect(enterSends('/fast')).toBe(false);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('arguments and mentions never yield Enter', () => {
|
|
192
|
+
expect(enterSends('/fast o')).toBe(false);
|
|
193
|
+
expect(enterSends('@sonic')).toBe(false);
|
|
194
|
+
});
|
|
195
|
+
});
|