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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ComposerPickItem, ComposerTrigger } from '@/shared/types';
|
|
1
|
+
import type { CommandItem, ComposerMatchItem, ComposerPickItem, ComposerTrigger } from '@/shared/types';
|
|
2
2
|
|
|
3
3
|
/** Chars that may legally precede an `@` agent trigger (besides string start). */
|
|
4
4
|
const AT_PRECEDING_RE = /[\s([{]/;
|
|
@@ -9,6 +9,35 @@ export const SKILL_NAMESPACE = 'skill:';
|
|
|
9
9
|
/** The collapsed namespace row's token (no trailing space on accept). */
|
|
10
10
|
export const SKILL_NAMESPACE_TOKEN = `/${SKILL_NAMESPACE}`;
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Slash commands the chamber answers itself, reserved exactly like oh-my-pi
|
|
14
|
+
* reserves its own tokens.
|
|
15
|
+
*
|
|
16
|
+
* omp's `/btw` is TUI-only — its registry entry has `handleTui` and no
|
|
17
|
+
* text-mode `handle`, and `get_available_commands` only advertises entries the
|
|
18
|
+
* RPC dispatcher can run — so the composer would offer every command except the
|
|
19
|
+
* one it intercepts on send. The entry is byte-for-byte omp's own metadata
|
|
20
|
+
* (description and inline hint), so the popup reads the same in both clients.
|
|
21
|
+
*/
|
|
22
|
+
export const CHAMBER_COMMANDS: CommandItem[] = [
|
|
23
|
+
{
|
|
24
|
+
id: 'chamber-btw',
|
|
25
|
+
name: 'btw',
|
|
26
|
+
description: "Ask a side question, or browse this session's BTW history",
|
|
27
|
+
scope: 'system',
|
|
28
|
+
template: '',
|
|
29
|
+
isBuiltIn: true,
|
|
30
|
+
inputHint: '[question]',
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
/** True when the chamber intercepts this command instead of forwarding it to
|
|
35
|
+
* omp as chat text. */
|
|
36
|
+
function isChamberOwnedCommand(name: string): boolean {
|
|
37
|
+
const lower = name.toLowerCase();
|
|
38
|
+
return CHAMBER_COMMANDS.some((command) => command.name.toLowerCase() === lower);
|
|
39
|
+
}
|
|
40
|
+
|
|
12
41
|
/**
|
|
13
42
|
* Index of a `/` that starts the draft, ignoring leading whitespace —
|
|
14
43
|
* oh-my-pi's `findLeadingSlashCommandStart`.
|
|
@@ -131,6 +160,21 @@ export function tokenForItem(item: ComposerPickItem): string {
|
|
|
131
160
|
return item.insertWithoutSpace ? item.token : `${item.token} `;
|
|
132
161
|
}
|
|
133
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Whether a popup accept should stand aside and let Enter mean "send".
|
|
165
|
+
*
|
|
166
|
+
* Only the chamber's own commands qualify: the composer intercepts those on
|
|
167
|
+
* send, so for `/btw` the Enter *is* the action and accept-on-Enter would
|
|
168
|
+
* swallow the very keystroke the user is aiming at. Every command omp owns
|
|
169
|
+
* keeps accepting, which is what opens its argument completions — including the
|
|
170
|
+
* ones that declare no subcommands and take free-form arguments.
|
|
171
|
+
*/
|
|
172
|
+
export function sendInsteadOfAccept(trigger: ComposerTrigger, item: ComposerMatchItem | undefined): boolean {
|
|
173
|
+
if (!item || trigger.kind !== 'command' || trigger.phase !== 'name') return false;
|
|
174
|
+
if (item.name.toLowerCase() !== trigger.query.toLowerCase()) return false;
|
|
175
|
+
return isChamberOwnedCommand(item.name);
|
|
176
|
+
}
|
|
177
|
+
|
|
134
178
|
/** Splice `token` over the trigger span in `value`, returning the new value and caret. */
|
|
135
179
|
export function insertToken(
|
|
136
180
|
value: string,
|
|
@@ -20,40 +20,20 @@ import { normalizeThinkingLevel } from '@/shared/lib/models/thinking-levels';
|
|
|
20
20
|
import { PHASE_VERBS } from '@/shared/lib/chat/timeline/tool-phrases';
|
|
21
21
|
import { describeAssistantPhase, describeToolActivity } from '@/shared/lib/chat/timeline/tool-verbs';
|
|
22
22
|
import { FILE_MUTATION_EVENT, isFileMutatingTool } from '@/shared/lib/chat/omp/file-mutations';
|
|
23
|
+
import {
|
|
24
|
+
pairToolOutputs,
|
|
25
|
+
putToolResult,
|
|
26
|
+
recordToolResult,
|
|
27
|
+
refreshToolMessage,
|
|
28
|
+
type ToolResultHost,
|
|
29
|
+
type ToolResultRecord,
|
|
30
|
+
} from '@/shared/lib/chat/omp/tool-results';
|
|
23
31
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
isError?: boolean;
|
|
28
|
-
details?: Record<string, any>;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** Per-entry cap on stored tool output; the tail of a build log is what matters. */
|
|
32
|
-
const MAX_TOOL_OUTPUT_CHARS = 200_000;
|
|
33
|
-
/** Max tool results retained per session; oldest entries are dropped first. */
|
|
34
|
-
const MAX_TRACKED_TOOL_RESULTS = 200;
|
|
35
|
-
|
|
36
|
-
/** Truncate tool output to the cap, keeping the tail and marking the cut. */
|
|
37
|
-
function capToolOutput(output: string): string {
|
|
38
|
-
if (output.length <= MAX_TOOL_OUTPUT_CHARS) return output;
|
|
39
|
-
return `…[truncated]\n${output.slice(-MAX_TOOL_OUTPUT_CHARS)}`;
|
|
40
|
-
}
|
|
32
|
+
// Re-exported so stream.ts and the timeline hook keep their existing import
|
|
33
|
+
// site; the implementation lives in tool-results.ts.
|
|
34
|
+
export type { ToolResultRecord } from '@/shared/lib/chat/omp/tool-results';
|
|
41
35
|
|
|
42
|
-
|
|
43
|
-
function storeToolResult(
|
|
44
|
-
map: Map<string, ToolResultRecord>,
|
|
45
|
-
callId: string,
|
|
46
|
-
record: ToolResultRecord,
|
|
47
|
-
): void {
|
|
48
|
-
map.set(callId, { ...record, output: capToolOutput(record.output) });
|
|
49
|
-
while (map.size > MAX_TRACKED_TOOL_RESULTS) {
|
|
50
|
-
const oldest = map.keys().next().value;
|
|
51
|
-
if (oldest === undefined) break;
|
|
52
|
-
map.delete(oldest);
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export interface OmpAgentFoldDeps {
|
|
36
|
+
export interface OmpAgentFoldDeps extends ToolResultHost {
|
|
57
37
|
sessionId: string;
|
|
58
38
|
setState: Dispatch<SetStateAction<OmpAgentState>>;
|
|
59
39
|
callbacksRef: RefObject<OmpAgentCallbacks>;
|
|
@@ -78,45 +58,9 @@ function setActivity(verb: string | undefined, deps: OmpAgentFoldDeps): void {
|
|
|
78
58
|
deps.callbacksRef.current?.onActivity?.(verb);
|
|
79
59
|
}
|
|
80
60
|
|
|
81
|
-
/**
|
|
82
|
-
function
|
|
83
|
-
|
|
84
|
-
toolResultsRef: RefObject<Map<string, ToolResultRecord>>,
|
|
85
|
-
): ChatMessageData {
|
|
86
|
-
if (!msg.toolCalls?.length) return msg;
|
|
87
|
-
const toolCalls = msg.toolCalls.map(tc => {
|
|
88
|
-
const res = toolResultsRef.current?.get(tc.id);
|
|
89
|
-
return res
|
|
90
|
-
? {
|
|
91
|
-
...tc,
|
|
92
|
-
output: res.output,
|
|
93
|
-
details: tc.details || res.details || undefined,
|
|
94
|
-
status: (res.isError ? 'error' : 'success') as ToolCallData['status'],
|
|
95
|
-
}
|
|
96
|
-
: tc;
|
|
97
|
-
});
|
|
98
|
-
return { ...msg, toolCalls };
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** Record a toolResult frame's output against its tool call id. */
|
|
102
|
-
function recordToolResult(
|
|
103
|
-
raw: Record<string, unknown>,
|
|
104
|
-
deps: OmpAgentFoldDeps,
|
|
105
|
-
): void {
|
|
106
|
-
const callId = typeof raw.toolCallId === 'string' ? raw.toolCallId : undefined;
|
|
107
|
-
if (!callId) return;
|
|
108
|
-
deps.toolResultsRef.current?.set(callId, {
|
|
109
|
-
output: extractTextFromContent(raw.content),
|
|
110
|
-
details: (raw.details && typeof raw.details === 'object' ? raw.details : undefined) as ToolResultRecord['details'],
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** Re-emit the last tool-carrying message when `callId` belongs to it. */
|
|
115
|
-
function refreshToolMessage(callId: string | undefined, deps: OmpAgentFoldDeps): void {
|
|
116
|
-
if (!callId) return;
|
|
117
|
-
const last = deps.lastToolMessageRef.current;
|
|
118
|
-
if (!last?.toolCalls?.some(tc => tc.id === callId)) return;
|
|
119
|
-
deps.callbacksRef.current?.onMessageUpdate?.(pairToolOutputs(last, deps.toolResultsRef));
|
|
61
|
+
/** Narrow a deps record to the tool-output host, adding the re-emit sink. */
|
|
62
|
+
function toolHost(deps: OmpAgentFoldDeps): ToolResultHost {
|
|
63
|
+
return { ...deps, onMessageUpdate: deps.callbacksRef.current?.onMessageUpdate };
|
|
120
64
|
}
|
|
121
65
|
|
|
122
66
|
/** Flush assistant turns that stopped abnormally and were never streamed as
|
|
@@ -141,7 +85,7 @@ function materializeTerminalMessages(
|
|
|
141
85
|
if (raw.stopReason !== 'aborted' && raw.stopReason !== 'error') continue;
|
|
142
86
|
const converted = toChatMessage(raw, false);
|
|
143
87
|
if (!converted) continue;
|
|
144
|
-
const paired = pairToolOutputs(converted, deps
|
|
88
|
+
const paired = pairToolOutputs(converted, deps);
|
|
145
89
|
if (paired.toolCalls?.length) deps.lastToolMessageRef.current = paired;
|
|
146
90
|
callbacks?.onMessageEnd?.(paired);
|
|
147
91
|
if (typeof raw.errorMessage === 'string') errorMessage = raw.errorMessage;
|
|
@@ -205,7 +149,7 @@ export function foldAgentEvent(data: OmpAgentEvent, deps: OmpAgentFoldDeps): voi
|
|
|
205
149
|
const completed = data.message as Record<string, unknown> | undefined;
|
|
206
150
|
if (!completed) break;
|
|
207
151
|
if (completed.role === 'toolResult') {
|
|
208
|
-
recordToolResult(completed, deps);
|
|
152
|
+
recordToolResult(completed, toolHost(deps));
|
|
209
153
|
break;
|
|
210
154
|
}
|
|
211
155
|
if (completed.role === 'custom') break;
|
|
@@ -219,7 +163,7 @@ export function foldAgentEvent(data: OmpAgentEvent, deps: OmpAgentFoldDeps): voi
|
|
|
219
163
|
if (converted.role !== 'user' && deps.currentThinkingLevelRef.current) {
|
|
220
164
|
converted.thinkingLevel = deps.currentThinkingLevelRef.current;
|
|
221
165
|
}
|
|
222
|
-
const paired = pairToolOutputs(converted, deps
|
|
166
|
+
const paired = pairToolOutputs(converted, deps);
|
|
223
167
|
if (paired.toolCalls?.length) deps.lastToolMessageRef.current = paired;
|
|
224
168
|
callbacks?.onMessageEnd?.(paired);
|
|
225
169
|
break;
|
|
@@ -254,9 +198,8 @@ export function foldAgentEvent(data: OmpAgentEvent, deps: OmpAgentFoldDeps): voi
|
|
|
254
198
|
const partial = toolResultText(data.partialResult);
|
|
255
199
|
if (!callId || !partial) break;
|
|
256
200
|
const prev = deps.toolResultsRef.current?.get(callId)?.output ?? '';
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
refreshToolMessage(callId, deps);
|
|
201
|
+
putToolResult(deps, callId, { output: prev + partial });
|
|
202
|
+
refreshToolMessage(callId, toolHost(deps));
|
|
260
203
|
break;
|
|
261
204
|
}
|
|
262
205
|
|
|
@@ -264,13 +207,12 @@ export function foldAgentEvent(data: OmpAgentEvent, deps: OmpAgentFoldDeps): voi
|
|
|
264
207
|
const callId = typeof data.toolCallId === 'string' ? data.toolCallId : undefined;
|
|
265
208
|
if (!callId) break;
|
|
266
209
|
setActivity(PHASE_VERBS.thinking, deps);
|
|
267
|
-
|
|
268
|
-
if (map) storeToolResult(map, callId, {
|
|
210
|
+
putToolResult(deps, callId, {
|
|
269
211
|
output: toolResultText(data.result),
|
|
270
212
|
isError: data.isError === true,
|
|
271
213
|
details: (data.details && typeof data.details === 'object' ? data.details : undefined) as ToolResultRecord['details'],
|
|
272
214
|
});
|
|
273
|
-
refreshToolMessage(callId, deps);
|
|
215
|
+
refreshToolMessage(callId, toolHost(deps));
|
|
274
216
|
// A file-mutating tool just finished — tell the data-bearing right panels
|
|
275
217
|
// (files / git / context) to re-read now, not on their next poll tick.
|
|
276
218
|
// Guarded: the fold also runs headless under `bun test` (no window).
|
|
@@ -328,6 +270,17 @@ export function foldAgentEvent(data: OmpAgentEvent, deps: OmpAgentFoldDeps): voi
|
|
|
328
270
|
break;
|
|
329
271
|
}
|
|
330
272
|
|
|
273
|
+
// omp renamed the session (auto-title generation, /rename, set_session_name).
|
|
274
|
+
// Nothing in the timeline changes, but the sidebar reads titles from the
|
|
275
|
+
// session file — and the rename is a 256-byte in-place slot write that the
|
|
276
|
+
// scan cache's mtime key cannot see — so this frame is the only push
|
|
277
|
+
// signal that the list is stale.
|
|
278
|
+
case 'session_info_update': {
|
|
279
|
+
const title = typeof data.title === 'string' ? data.title.trim() : '';
|
|
280
|
+
if (title) callbacks?.onSessionTitleChanged?.(title);
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
|
|
331
284
|
case 'notice': {
|
|
332
285
|
callbacks?.onNotice?.(
|
|
333
286
|
typeof data.level === 'string' ? data.level : 'info',
|
|
@@ -18,8 +18,7 @@
|
|
|
18
18
|
* route's observer-only contract.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import type {
|
|
22
|
-
import type { AgentStreamConnection, AgentStreamHandlers } from '@/shared/lib/chat/omp/transport';
|
|
21
|
+
import type { AgentStreamConnection, AgentStreamHandlers, StreamConnection, StreamHandlers } from '@/shared/lib/chat/omp/transport';
|
|
23
22
|
import { agentSocketUrl } from '@/shared/lib/chat/omp/transport';
|
|
24
23
|
|
|
25
24
|
const RECONNECT_BASE_MS = 500;
|
|
@@ -27,7 +26,8 @@ const RECONNECT_MAX_MS = 8_000;
|
|
|
27
26
|
/** Consecutive re-dials that never opened before the stream is given up on. */
|
|
28
27
|
const MAX_FAILED_DIALS = 4;
|
|
29
28
|
|
|
30
|
-
|
|
29
|
+
/** One duplex JSON socket: reconnect with capped backoff, terminal first dial. */
|
|
30
|
+
export function connectSocket<TFrame>(url: string, handlers: StreamHandlers<TFrame>): StreamConnection {
|
|
31
31
|
let socket: WebSocket | null = null;
|
|
32
32
|
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
33
33
|
let failures = 0;
|
|
@@ -35,7 +35,7 @@ export function connectAgentSocket(sessionId: string, handlers: AgentStreamHandl
|
|
|
35
35
|
let released = false;
|
|
36
36
|
|
|
37
37
|
const dial = () => {
|
|
38
|
-
const ws = new WebSocket(
|
|
38
|
+
const ws = new WebSocket(url);
|
|
39
39
|
socket = ws;
|
|
40
40
|
|
|
41
41
|
ws.onopen = () => {
|
|
@@ -47,9 +47,9 @@ export function connectAgentSocket(sessionId: string, handlers: AgentStreamHandl
|
|
|
47
47
|
|
|
48
48
|
ws.onmessage = (event) => {
|
|
49
49
|
if (released) return;
|
|
50
|
-
let data:
|
|
50
|
+
let data: TFrame;
|
|
51
51
|
try {
|
|
52
|
-
data = JSON.parse(String(event.data)) as
|
|
52
|
+
data = JSON.parse(String(event.data)) as TFrame;
|
|
53
53
|
} catch {
|
|
54
54
|
return;
|
|
55
55
|
}
|
|
@@ -93,3 +93,8 @@ export function connectAgentSocket(sessionId: string, handlers: AgentStreamHandl
|
|
|
93
93
|
},
|
|
94
94
|
};
|
|
95
95
|
}
|
|
96
|
+
|
|
97
|
+
/** WebSocket transport for a session's agent event stream. */
|
|
98
|
+
export function connectAgentSocket(sessionId: string, handlers: AgentStreamHandlers): AgentStreamConnection {
|
|
99
|
+
return connectSocket(agentSocketUrl(sessionId), handlers);
|
|
100
|
+
}
|
|
@@ -12,12 +12,12 @@
|
|
|
12
12
|
* (the server closed the stream) is reported upward.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import type {
|
|
16
|
-
import type { AgentStreamConnection, AgentStreamHandlers } from '@/shared/lib/chat/omp/transport';
|
|
15
|
+
import type { AgentStreamConnection, AgentStreamHandlers, StreamConnection, StreamHandlers } from '@/shared/lib/chat/omp/transport';
|
|
17
16
|
import { agentEventsUrl } from '@/shared/lib/chat/omp/transport';
|
|
18
17
|
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
/** One EventSource stream: the browser owns reconnection on transient drops. */
|
|
19
|
+
export function connectEvents<TFrame>(url: string, handlers: StreamHandlers<TFrame>): StreamConnection {
|
|
20
|
+
const source = new EventSource(url);
|
|
21
21
|
let released = false;
|
|
22
22
|
|
|
23
23
|
source.onopen = () => {
|
|
@@ -27,9 +27,9 @@ export function connectAgentEvents(sessionId: string, handlers: AgentStreamHandl
|
|
|
27
27
|
|
|
28
28
|
source.onmessage = (event) => {
|
|
29
29
|
if (released) return;
|
|
30
|
-
let data:
|
|
30
|
+
let data: TFrame;
|
|
31
31
|
try {
|
|
32
|
-
data = JSON.parse(event.data) as
|
|
32
|
+
data = JSON.parse(event.data) as TFrame;
|
|
33
33
|
} catch {
|
|
34
34
|
return;
|
|
35
35
|
}
|
|
@@ -51,3 +51,8 @@ export function connectAgentEvents(sessionId: string, handlers: AgentStreamHandl
|
|
|
51
51
|
},
|
|
52
52
|
};
|
|
53
53
|
}
|
|
54
|
+
|
|
55
|
+
/** SSE transport for a session's agent event stream. */
|
|
56
|
+
export function connectAgentEvents(sessionId: string, handlers: AgentStreamHandlers): AgentStreamConnection {
|
|
57
|
+
return connectEvents(agentEventsUrl(sessionId), handlers);
|
|
58
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Tool-output bookkeeping for the live agent stream.
|
|
8
|
+
*
|
|
9
|
+
* omp reports a tool call in an assistant message and its output in separate
|
|
10
|
+
* frames (`tool_execution_update` partials, then `tool_execution_end`). The
|
|
11
|
+
* timeline needs the two halves re-joined on the message that carries the call,
|
|
12
|
+
* so this module owns the call-id → output map and the re-emit that pairs them.
|
|
13
|
+
*
|
|
14
|
+
* Split out of agent-events.ts so that file stays under the repo's per-file
|
|
15
|
+
* size ceiling. The host is a narrow structural view of the fold's deps — not
|
|
16
|
+
* the deps type itself — so neither module imports the other.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { RefObject } from 'preact/compat';
|
|
20
|
+
import type { ChatMessageData, ToolCallData } from '@/shared/types';
|
|
21
|
+
import { extractTextFromContent } from '@/shared/lib/omp/session/mapper';
|
|
22
|
+
|
|
23
|
+
/** Tool output accumulated between a `toolCall` block and its result frame. */
|
|
24
|
+
export interface ToolResultRecord {
|
|
25
|
+
output: string;
|
|
26
|
+
isError?: boolean;
|
|
27
|
+
details?: Record<string, any>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Per-entry cap on stored tool output; the tail of a build log is what matters. */
|
|
31
|
+
const MAX_TOOL_OUTPUT_CHARS = 200_000;
|
|
32
|
+
/** Max tool results retained per session; oldest entries are dropped first. */
|
|
33
|
+
const MAX_TRACKED_TOOL_RESULTS = 200;
|
|
34
|
+
|
|
35
|
+
/** The slice of the fold's deps this module needs. */
|
|
36
|
+
export interface ToolResultHost {
|
|
37
|
+
toolResultsRef: RefObject<Map<string, ToolResultRecord>>;
|
|
38
|
+
lastToolMessageRef: RefObject<ChatMessageData>;
|
|
39
|
+
/** Re-emit an updated assistant message into the timeline. */
|
|
40
|
+
onMessageUpdate?: (msg: ChatMessageData) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Truncate tool output to the cap, keeping the tail and marking the cut. */
|
|
44
|
+
function capToolOutput(output: string): string {
|
|
45
|
+
if (output.length <= MAX_TOOL_OUTPUT_CHARS) return output;
|
|
46
|
+
return `…[truncated]\n${output.slice(-MAX_TOOL_OUTPUT_CHARS)}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Write a tool result into the map, enforcing both caps. */
|
|
50
|
+
function storeToolResult(
|
|
51
|
+
map: Map<string, ToolResultRecord>,
|
|
52
|
+
callId: string,
|
|
53
|
+
record: ToolResultRecord,
|
|
54
|
+
): void {
|
|
55
|
+
map.set(callId, { ...record, output: capToolOutput(record.output) });
|
|
56
|
+
while (map.size > MAX_TRACKED_TOOL_RESULTS) {
|
|
57
|
+
const oldest = map.keys().next().value;
|
|
58
|
+
if (oldest === undefined) break;
|
|
59
|
+
map.delete(oldest);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Write a result into the map, enforcing both caps. */
|
|
64
|
+
export function putToolResult(host: ToolResultHost, callId: string, record: ToolResultRecord): void {
|
|
65
|
+
const map = host.toolResultsRef.current;
|
|
66
|
+
if (map) storeToolResult(map, callId, record);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Record a toolResult frame's output against its tool call id. */
|
|
70
|
+
export function recordToolResult(raw: Record<string, unknown>, host: ToolResultHost): void {
|
|
71
|
+
const callId = typeof raw.toolCallId === 'string' ? raw.toolCallId : undefined;
|
|
72
|
+
if (!callId) return;
|
|
73
|
+
host.toolResultsRef.current?.set(callId, {
|
|
74
|
+
output: extractTextFromContent(raw.content),
|
|
75
|
+
details: (raw.details && typeof raw.details === 'object' ? raw.details : undefined) as ToolResultRecord['details'],
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Re-emit the last tool-carrying assistant message with its results paired. */
|
|
80
|
+
export function pairToolOutputs(msg: ChatMessageData, host: ToolResultHost): ChatMessageData {
|
|
81
|
+
if (!msg.toolCalls?.length) return msg;
|
|
82
|
+
const toolCalls = msg.toolCalls.map(tc => {
|
|
83
|
+
const res = host.toolResultsRef.current?.get(tc.id);
|
|
84
|
+
return res
|
|
85
|
+
? {
|
|
86
|
+
...tc,
|
|
87
|
+
output: res.output,
|
|
88
|
+
details: tc.details || res.details || undefined,
|
|
89
|
+
status: (res.isError ? 'error' : 'success') as ToolCallData['status'],
|
|
90
|
+
}
|
|
91
|
+
: tc;
|
|
92
|
+
});
|
|
93
|
+
return { ...msg, toolCalls };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Re-emit the last tool-carrying message when `callId` belongs to it. */
|
|
97
|
+
export function refreshToolMessage(callId: string | undefined, host: ToolResultHost): void {
|
|
98
|
+
if (!callId) return;
|
|
99
|
+
const last = host.lastToolMessageRef.current;
|
|
100
|
+
if (!last?.toolCalls?.some(tc => tc.id === callId)) return;
|
|
101
|
+
host.onMessageUpdate?.(pairToolOutputs(last, host));
|
|
102
|
+
}
|
|
@@ -14,20 +14,25 @@
|
|
|
14
14
|
import type { OmpAgentEvent, StreamTransport } from '@/shared/types';
|
|
15
15
|
import { readChamberSetting } from '@/shared/lib/settings/client';
|
|
16
16
|
|
|
17
|
-
export interface
|
|
17
|
+
export interface StreamHandlers<TFrame> {
|
|
18
18
|
/** Transport is attached; frames may arrive. */
|
|
19
19
|
onOpen: () => void;
|
|
20
20
|
/** One decoded event frame. */
|
|
21
|
-
onFrame: (data:
|
|
21
|
+
onFrame: (data: TFrame) => void;
|
|
22
22
|
/** Transport is gone for good (no further frames without a re-dial). */
|
|
23
23
|
onClose: () => void;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
export interface
|
|
26
|
+
export interface StreamConnection {
|
|
27
27
|
/** Permanently release the transport; no callbacks fire afterwards. */
|
|
28
28
|
close: () => void;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/** Agent-stream handlers — the socket's frames feed the timeline's fold. */
|
|
32
|
+
export type AgentStreamHandlers = StreamHandlers<OmpAgentEvent>;
|
|
33
|
+
|
|
34
|
+
export type AgentStreamConnection = StreamConnection;
|
|
35
|
+
|
|
31
36
|
export const DEFAULT_STREAM_TRANSPORT: StreamTransport = 'websocket';
|
|
32
37
|
|
|
33
38
|
/** WebSocket endpoint for a session's agent event stream. */
|
|
@@ -41,6 +46,17 @@ export function agentEventsUrl(sessionId: string): string {
|
|
|
41
46
|
return `/api/agent/${encodeURIComponent(sessionId)}/events`;
|
|
42
47
|
}
|
|
43
48
|
|
|
49
|
+
/** WebSocket endpoint for a session's BTW frame stream. */
|
|
50
|
+
export function btwSocketUrl(sessionId: string): string {
|
|
51
|
+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
52
|
+
return `${protocol}//${window.location.host}/api/btw/${encodeURIComponent(sessionId)}/ws`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** SSE endpoint for a session's BTW frame stream. */
|
|
56
|
+
export function btwEventsUrl(sessionId: string): string {
|
|
57
|
+
return `/api/btw/${encodeURIComponent(sessionId)}/events`;
|
|
58
|
+
}
|
|
59
|
+
|
|
44
60
|
/**
|
|
45
61
|
* Resolve the configured stream transport from the chamber settings snapshot.
|
|
46
62
|
*/
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Deciding whether a file's contents are text, and what language to fence them
|
|
8
|
+
* under, from the file itself rather than from its metadata.
|
|
9
|
+
*
|
|
10
|
+
* Split out of `attachments.ts` because it is the one part of classification
|
|
11
|
+
* that reads BYTES: everything else there works from `name`/`type`/`size`,
|
|
12
|
+
* which is what a replayed attachment has.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Extensions treated as inline-able text when the browser reports no useful
|
|
17
|
+
* MIME type. `File.type` is empty for many code files, so the extension is the
|
|
18
|
+
* only signal — a dropped `script.py` used to attach without its contents ever
|
|
19
|
+
* reaching the prompt.
|
|
20
|
+
*/
|
|
21
|
+
export const TEXT_FILE_EXTENSIONS: Record<string, true> = {
|
|
22
|
+
txt: true,
|
|
23
|
+
text: true,
|
|
24
|
+
md: true,
|
|
25
|
+
markdown: true,
|
|
26
|
+
mdx: true,
|
|
27
|
+
json: true,
|
|
28
|
+
jsonc: true,
|
|
29
|
+
yaml: true,
|
|
30
|
+
yml: true,
|
|
31
|
+
toml: true,
|
|
32
|
+
ini: true,
|
|
33
|
+
env: true,
|
|
34
|
+
csv: true,
|
|
35
|
+
tsv: true,
|
|
36
|
+
log: true,
|
|
37
|
+
xml: true,
|
|
38
|
+
html: true,
|
|
39
|
+
htm: true,
|
|
40
|
+
css: true,
|
|
41
|
+
scss: true,
|
|
42
|
+
less: true,
|
|
43
|
+
js: true,
|
|
44
|
+
mjs: true,
|
|
45
|
+
cjs: true,
|
|
46
|
+
jsx: true,
|
|
47
|
+
ts: true,
|
|
48
|
+
mts: true,
|
|
49
|
+
cts: true,
|
|
50
|
+
tsx: true,
|
|
51
|
+
py: true,
|
|
52
|
+
rb: true,
|
|
53
|
+
go: true,
|
|
54
|
+
rs: true,
|
|
55
|
+
java: true,
|
|
56
|
+
kt: true,
|
|
57
|
+
kts: true,
|
|
58
|
+
swift: true,
|
|
59
|
+
c: true,
|
|
60
|
+
h: true,
|
|
61
|
+
cc: true,
|
|
62
|
+
cpp: true,
|
|
63
|
+
cxx: true,
|
|
64
|
+
hpp: true,
|
|
65
|
+
hh: true,
|
|
66
|
+
cs: true,
|
|
67
|
+
php: true,
|
|
68
|
+
pl: true,
|
|
69
|
+
lua: true,
|
|
70
|
+
r: true,
|
|
71
|
+
sql: true,
|
|
72
|
+
sh: true,
|
|
73
|
+
bash: true,
|
|
74
|
+
zsh: true,
|
|
75
|
+
fish: true,
|
|
76
|
+
ps1: true,
|
|
77
|
+
bat: true,
|
|
78
|
+
cmd: true,
|
|
79
|
+
dockerfile: true,
|
|
80
|
+
makefile: true,
|
|
81
|
+
gitignore: true,
|
|
82
|
+
editorconfig: true,
|
|
83
|
+
diff: true,
|
|
84
|
+
patch: true,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** MIME types the browser reports for text-ish files it does know. */
|
|
88
|
+
export function isTextMimeType(type: string): boolean {
|
|
89
|
+
if (!type) return false;
|
|
90
|
+
if (type === 'text/plain' || type === 'text/markdown') return true;
|
|
91
|
+
return type.startsWith('text/')
|
|
92
|
+
|| type === 'application/json'
|
|
93
|
+
|| type === 'application/xml'
|
|
94
|
+
|| type === 'application/x-yaml'
|
|
95
|
+
|| type === 'application/yaml'
|
|
96
|
+
|| type === 'application/javascript'
|
|
97
|
+
|| type === 'application/x-sh'
|
|
98
|
+
|| type === 'application/sql'
|
|
99
|
+
|| type.endsWith('+json')
|
|
100
|
+
|| type.endsWith('+xml');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Bytes sampled to decide whether an unclassified file is text. */
|
|
104
|
+
const SNIFF_BYTES = 4096;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Whether an unclassified file's own bytes read as text.
|
|
108
|
+
*
|
|
109
|
+
* Some hosts hand over a file whose name and MIME say nothing useful: a
|
|
110
|
+
* promised-file drag from another app arrives as `document` with an empty type,
|
|
111
|
+
* and an opaque download as `application/octet-stream`. Deciding from the name
|
|
112
|
+
* alone left those attached but never inlined — the chip rendered, the model saw
|
|
113
|
+
* nothing.
|
|
114
|
+
*
|
|
115
|
+
* Three signals, because control-character density alone misses a short binary:
|
|
116
|
+
* a NUL byte (no text encoding this app handles contains one), an invalid UTF-8
|
|
117
|
+
* sequence (a lone continuation or lead byte), and the control-character ratio
|
|
118
|
+
* a browser applies to a Blob. `TextDecoder` in fatal mode is the cheapest
|
|
119
|
+
* correct UTF-8 validator available.
|
|
120
|
+
*/
|
|
121
|
+
export async function looksLikeTextFile(
|
|
122
|
+
file: Pick<File, 'slice'>,
|
|
123
|
+
/**
|
|
124
|
+
* Bytes already read while the drop's permission was live. A dropped file
|
|
125
|
+
* cannot be `slice()`d after that window closes, so the sniff is served from
|
|
126
|
+
* the primed read where one exists. See `file-reads.ts`.
|
|
127
|
+
*/
|
|
128
|
+
primed?: Uint8Array,
|
|
129
|
+
): Promise<boolean> {
|
|
130
|
+
try {
|
|
131
|
+
const bytes = primed ?? new Uint8Array(await file.slice(0, SNIFF_BYTES).arrayBuffer());
|
|
132
|
+
if (bytes.length === 0) return false;
|
|
133
|
+
if (bytes.includes(0)) return false;
|
|
134
|
+
try {
|
|
135
|
+
new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
136
|
+
} catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
let control = 0;
|
|
140
|
+
for (const byte of bytes) {
|
|
141
|
+
if (byte < 9 || (byte > 13 && byte < 32)) control += 1;
|
|
142
|
+
}
|
|
143
|
+
return control / bytes.length <= 0.3;
|
|
144
|
+
} catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|