ompchamber 1.0.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.0d6ae50fe8.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/ComposerPicker.tsx +16 -4
- package/src/client/components/common/ComposerTextarea.tsx +1 -0
- 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/layout/RightActivityBar.tsx +1 -1
- package/src/client/components/layout/desktop-layout/ResizeHandle.tsx +2 -2
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +13 -2
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +107 -56
- package/src/client/components/layout/desktop-layout/index.tsx +25 -96
- package/src/client/components/layout/desktop-layout/resizer/index.tsx +79 -118
- package/src/client/components/layout/desktop-layout/resizer/separator-drag.ts +140 -0
- package/src/client/components/layout/session-sidebar/Header.tsx +1 -1
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +26 -12
- package/src/client/components/layout/session-sidebar/SubagentList.tsx +6 -4
- 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 +39 -20
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +3 -0
- package/src/client/components/workspace/chat-timeline/MinimapShortcuts.tsx +49 -12
- package/src/client/components/workspace/chat-timeline/SubagentView.tsx +3 -1
- 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 +67 -43
- package/src/client/components/workspace/chat-timeline/index.tsx +14 -9
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Goal.tsx +3 -3
- 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/components/workspace/model-dropdown/groups.ts +35 -2
- package/src/client/components/workspace/model-dropdown/index.tsx +48 -7
- package/src/client/components/workspace/model-dropdown/use-catalog.ts +28 -4
- 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 +35 -9
- 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 +40 -4
- package/src/client/hooks/chat/timeline/deferred-model.test.ts +219 -0
- package/src/client/hooks/chat/timeline/deferred-model.ts +92 -0
- package/src/client/hooks/chat/timeline/index.ts +29 -9
- package/src/client/hooks/chat/timeline/prompt-text.ts +6 -1
- package/src/client/hooks/chat/timeline/send.ts +45 -70
- 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/hooks/workspace/available-width.ts +33 -0
- package/src/client/hooks/workspace/panel-widths.ts +34 -37
- 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/models/preferences.server.ts +110 -0
- package/src/server/lib/models/registry.server.ts +206 -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/rpc/utility.ts +7 -1
- 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 +260 -0
- package/src/server/lib/queue/delivery.server.ts +63 -11
- 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/server/routes/models/root.ts +88 -215
- package/src/server/routes/settings/commands.ts +59 -18
- 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 +23 -9
- package/src/shared/lib/chat/composer/commands.ts +290 -0
- package/src/shared/lib/chat/composer/composer.test.ts +195 -0
- package/src/shared/lib/chat/composer/filter.ts +21 -7
- package/src/shared/lib/chat/composer/trigger.ts +154 -21
- 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/models/preferences.test.ts +131 -0
- package/src/shared/lib/models/preferences.ts +88 -0
- package/src/shared/lib/models/provider-models.ts +8 -1
- 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/lib/omp/subagent/label.test.ts +94 -0
- package/src/shared/lib/omp/subagent/label.ts +84 -0
- package/src/shared/lib/workspace/panel-widths.test.ts +113 -18
- package/src/shared/lib/workspace/panel-widths.ts +161 -30
- package/src/shared/lib/workspace/right-panels.ts +50 -11
- package/src/shared/lib/workspace/session-state/store.ts +3 -0
- package/src/shared/types/btw.ts +65 -0
- package/src/shared/types/chat.ts +77 -66
- package/src/shared/types/index.ts +2 -0
- package/src/shared/types/models.ts +101 -0
- package/src/shared/types/omp/agent.ts +14 -1
- package/src/shared/types/omp/session.ts +4 -1
- package/src/shared/types/omp/subagent.ts +4 -0
- package/src/shared/types/settings/command.ts +13 -0
- package/src/shared/types/settings/state.ts +5 -0
- package/dist/client/static/css/index.e880517fcd.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.0de15c5e17.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.0d6ae50fe8.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.0de15c5e17.js.LICENSE.txt → index.57b39ffa7f.js.LICENSE.txt} +0 -0
|
@@ -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
|
|
|
@@ -56,24 +57,37 @@ export function toFilePickItems(files: FsFileEntry[]): ComposerPickItem[] {
|
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
/**
|
|
59
|
-
* Combine
|
|
60
|
-
*
|
|
60
|
+
* Combine live command items (`/name`, including omp's own `skill:<name>`
|
|
61
|
+
* entries) with the chamber's skill list. Commands win: a skill whose plain or
|
|
62
|
+
* `skill:`-namespaced name a command already covers is skipped, so the two
|
|
63
|
+
* sources never double-list the same skill.
|
|
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
|
+
*
|
|
69
|
+
* The description carries the argument hint exactly as oh-my-pi renders it
|
|
70
|
+
* (`[on|off|status] - Toggle fast mode`) so the popup matches the CLI.
|
|
61
71
|
*/
|
|
62
72
|
export function mergeCommandAndSkillItems(commands: CommandItem[], skills: SkillItem[]): ComposerPickItem[] {
|
|
63
73
|
const result: ComposerPickItem[] = [];
|
|
64
74
|
const seen = new Set<string>();
|
|
65
75
|
|
|
66
|
-
for (const command of commands) {
|
|
76
|
+
for (const command of [...CHAMBER_COMMANDS, ...commands]) {
|
|
67
77
|
const key = command.name.toLowerCase();
|
|
68
78
|
if (seen.has(key)) continue;
|
|
69
79
|
seen.add(key);
|
|
80
|
+
const description = command.description ?? '';
|
|
70
81
|
result.push({
|
|
71
82
|
id: `command-${command.name}`,
|
|
72
83
|
name: command.name,
|
|
73
|
-
description: command.description,
|
|
74
|
-
kind: 'command',
|
|
75
|
-
source: 'command',
|
|
84
|
+
description: command.inputHint ? (description ? `${command.inputHint} - ${description}` : command.inputHint) : description,
|
|
85
|
+
kind: command.name.toLowerCase().startsWith('skill:') ? 'skill' : 'command',
|
|
86
|
+
source: command.name.toLowerCase().startsWith('skill:') ? 'skill' : 'command',
|
|
76
87
|
token: `/${command.name}`,
|
|
88
|
+
...(command.aliases?.length ? { aliases: command.aliases } : {}),
|
|
89
|
+
...(command.subcommands?.length ? { subcommands: command.subcommands } : {}),
|
|
90
|
+
...(command.inputHint ? { inputHint: command.inputHint } : {}),
|
|
77
91
|
});
|
|
78
92
|
}
|
|
79
93
|
|
|
@@ -84,11 +98,11 @@ export function mergeCommandAndSkillItems(commands: CommandItem[], skills: Skill
|
|
|
84
98
|
seen.add(prefixed);
|
|
85
99
|
result.push({
|
|
86
100
|
id: `skill-${skill.name}`,
|
|
87
|
-
name:
|
|
101
|
+
name: prefixed,
|
|
88
102
|
description: skill.description,
|
|
89
|
-
kind: '
|
|
103
|
+
kind: 'skill',
|
|
90
104
|
source: 'skill',
|
|
91
|
-
token:
|
|
105
|
+
token: `/${prefixed}`,
|
|
92
106
|
});
|
|
93
107
|
}
|
|
94
108
|
|