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
|
@@ -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
|
+
|
|
@@ -16,36 +16,14 @@ import type { Dispatch, SetStateAction } from 'preact/compat';
|
|
|
16
16
|
import type { ChatMessageData, ExtensionUiDialogRequest, IncomingExtensionUiRequest, OmpAgentCallbacks } from '@/shared/types';
|
|
17
17
|
import { triggerChatCompletionSound } from '@/client/hooks/ui/notification-sound';
|
|
18
18
|
import { isNoticeRow } from '@/shared/lib/chat/notice-row';
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
bindStreamingCoalescer,
|
|
21
|
+
disposeStreamingCoalescer,
|
|
22
|
+
flushStreamingUpdates,
|
|
23
|
+
queueStreamingUpdate,
|
|
24
|
+
} from '@/shared/lib/chat/timeline/stream-coalescer';
|
|
20
25
|
import { PHASE_VERBS } from '@/shared/lib/chat/timeline/tool-phrases';
|
|
21
26
|
|
|
22
|
-
// omp emits one message_update per model chunk, each carrying that message's
|
|
23
|
-
// FULL accumulated content, so a burst only needs the newest payload per
|
|
24
|
-
// message id. This batch commits at most once per animation frame; the terminal
|
|
25
|
-
// frame flushes synchronously first so the last chunk is never dropped.
|
|
26
|
-
// `apply`/`afterFlush` delegate through module vars because the callbacks
|
|
27
|
-
// factory re-runs on every render.
|
|
28
|
-
let applyMessageUpdater: Dispatch<SetStateAction<ChatMessageData[]>> = () => {};
|
|
29
|
-
let scrollAfterFlush: () => void = () => {};
|
|
30
|
-
|
|
31
|
-
const messageBatch = createRafBatch<ChatMessageData[]>(
|
|
32
|
-
updater => applyMessageUpdater(updater),
|
|
33
|
-
() => scrollAfterFlush(),
|
|
34
|
-
);
|
|
35
|
-
|
|
36
|
-
/** Apply any coalesced update, then invalidate its scheduled frame. */
|
|
37
|
-
export function disposeStreamingCoalescer(): void {
|
|
38
|
-
messageBatch.flush();
|
|
39
|
-
messageBatch.cancel();
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Drop coalesced updates without applying them — a session switch discards
|
|
43
|
-
* the previous session's queued `message_update` frames so they cannot leak
|
|
44
|
-
* into the freshly cleared timeline of the next session. */
|
|
45
|
-
export function cancelStreamingCoalescer(): void {
|
|
46
|
-
messageBatch.cancel();
|
|
47
|
-
}
|
|
48
|
-
|
|
49
27
|
export interface OmpAgentCallbacksDeps {
|
|
50
28
|
setGenerating: (v: boolean) => void;
|
|
51
29
|
setGeneratingVerb: (v: string) => void;
|
|
@@ -91,8 +69,7 @@ export function createOmpAgentCallbacks(deps: OmpAgentCallbacksDeps): OmpAgentCa
|
|
|
91
69
|
withdrawExtensionDialog,
|
|
92
70
|
} = deps;
|
|
93
71
|
|
|
94
|
-
|
|
95
|
-
scrollAfterFlush = () => scrollToBottom('smooth');
|
|
72
|
+
bindStreamingCoalescer(setLocalMessages, () => scrollToBottom('smooth'));
|
|
96
73
|
|
|
97
74
|
return {
|
|
98
75
|
onAgentStart: () => {
|
|
@@ -152,7 +129,7 @@ export function createOmpAgentCallbacks(deps: OmpAgentCallbacksDeps): OmpAgentCa
|
|
|
152
129
|
if (msg.role !== 'user' && !isNoticeRow(msg)) optimisticUserIdRef.current = null;
|
|
153
130
|
// Queue for the next frame, collapsing same-message bursts to the newest
|
|
154
131
|
// full-content frame; scroll runs once per rendered frame.
|
|
155
|
-
|
|
132
|
+
queueStreamingUpdate(prev => {
|
|
156
133
|
const placeholderId = aiPlaceholderIdRef.current;
|
|
157
134
|
// Notice rows (e.g. background job done, system alerts) are appended in
|
|
158
135
|
// arrival order — the timeline renders state as-is, so omp's own write
|
|
@@ -237,7 +214,7 @@ export function createOmpAgentCallbacks(deps: OmpAgentCallbacksDeps): OmpAgentCa
|
|
|
237
214
|
}
|
|
238
215
|
// Apply any coalesced update before the terminal frame so the last chunk
|
|
239
216
|
// is never dropped and the end always runs after it.
|
|
240
|
-
|
|
217
|
+
flushStreamingUpdates();
|
|
241
218
|
if (msg.role !== 'user') optimisticUserIdRef.current = null;
|
|
242
219
|
setLocalMessages(prev => {
|
|
243
220
|
const placeholderId = aiPlaceholderIdRef.current;
|
|
@@ -345,6 +322,15 @@ export function createOmpAgentCallbacks(deps: OmpAgentCallbacksDeps): OmpAgentCa
|
|
|
345
322
|
return [...prev, row];
|
|
346
323
|
});
|
|
347
324
|
},
|
|
325
|
+
// omp renamed the session — the auto-title generation the chamber asks for
|
|
326
|
+
// after a settled run, a `/rename`, or an RPC set_session_name. The slot
|
|
327
|
+
// write is in place, so the title is already on disk: refresh the metadata
|
|
328
|
+
// the navbar/context panel read, and signal the sidebar (whose scan cache
|
|
329
|
+
// is keyed on file mtime, which a fixed-width in-place write cannot move).
|
|
330
|
+
onSessionTitleChanged: () => {
|
|
331
|
+
const sid = adoptedSessionIdRef.current ?? sessionIdRef.current;
|
|
332
|
+
if (sid) refreshSessionMeta(sid);
|
|
333
|
+
},
|
|
348
334
|
onNotice: (_level, message) => {
|
|
349
335
|
console.info('OMP notice:', message);
|
|
350
336
|
},
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Frame coalescer for the live agent stream.
|
|
8
|
+
*
|
|
9
|
+
* omp emits one `message_update` per model chunk, each carrying that message's
|
|
10
|
+
* FULL accumulated content, so a burst only needs the newest payload per
|
|
11
|
+
* message id. This batches at most one commit per animation frame; the terminal
|
|
12
|
+
* frame flushes synchronously first so the last chunk is never dropped.
|
|
13
|
+
*
|
|
14
|
+
* The sink is a module-level singleton bound once per callbacks factory run
|
|
15
|
+
* (`bindStreamingCoalescer`), because that factory re-runs on every render
|
|
16
|
+
* while the batch's scheduled frame must survive across renders. Split out of
|
|
17
|
+
* omp-callbacks.ts so that file stays under the repo's per-file size ceiling.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { Dispatch, SetStateAction } from 'preact/compat';
|
|
21
|
+
import type { ChatMessageData } from '@/shared/types';
|
|
22
|
+
import { createRafBatch } from '@/shared/lib/chat/timeline/stream-raf';
|
|
23
|
+
|
|
24
|
+
let applyMessageUpdater: Dispatch<SetStateAction<ChatMessageData[]>> = () => {};
|
|
25
|
+
let scrollAfterFlush: () => void = () => {};
|
|
26
|
+
|
|
27
|
+
const messageBatch = createRafBatch<ChatMessageData[]>(
|
|
28
|
+
updater => applyMessageUpdater(updater),
|
|
29
|
+
() => scrollAfterFlush(),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
/** Point the batch at the current render's state setter and scroll hook. */
|
|
33
|
+
export function bindStreamingCoalescer(
|
|
34
|
+
apply: Dispatch<SetStateAction<ChatMessageData[]>>,
|
|
35
|
+
afterFlush: () => void,
|
|
36
|
+
): void {
|
|
37
|
+
applyMessageUpdater = apply;
|
|
38
|
+
scrollAfterFlush = afterFlush;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Queue a coalesced update for the next animation frame, keyed per message. */
|
|
42
|
+
export function queueStreamingUpdate(
|
|
43
|
+
updater: (prev: ChatMessageData[]) => ChatMessageData[],
|
|
44
|
+
key: string,
|
|
45
|
+
): void {
|
|
46
|
+
messageBatch.queue(updater, key);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Apply any queued update synchronously (terminal frames call this first so
|
|
50
|
+
* the last chunk is never dropped). */
|
|
51
|
+
export function flushStreamingUpdates(): void {
|
|
52
|
+
messageBatch.flush();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Apply any coalesced update, then invalidate its scheduled frame. */
|
|
56
|
+
export function disposeStreamingCoalescer(): void {
|
|
57
|
+
messageBatch.flush();
|
|
58
|
+
messageBatch.cancel();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Drop coalesced updates without applying them — a session switch discards
|
|
62
|
+
* the previous session's queued `message_update` frames so they cannot leak
|
|
63
|
+
* into the freshly cleared timeline of the next session. */
|
|
64
|
+
export function cancelStreamingCoalescer(): void {
|
|
65
|
+
messageBatch.cancel();
|
|
66
|
+
}
|
|
@@ -8,8 +8,15 @@
|
|
|
8
8
|
* rewind endpoint: identifying raw composer input and deciding whether a
|
|
9
9
|
* JSONL-derived user turn and a stored DB-copy turn are the same request
|
|
10
10
|
* (omp rewrites delivered prompts, so equality is not enough).
|
|
11
|
+
*
|
|
12
|
+
* The jump rail also reads from here: it lists user turns of the FULL session
|
|
13
|
+
* (`/api/chat/:id/turns`) and from the mounted window, and both build the same
|
|
14
|
+
* tooltip preview, so the truncation length is shared rather than duplicated.
|
|
11
15
|
*/
|
|
12
16
|
|
|
17
|
+
/** Longest prompt excerpt a rail tooltip renders. */
|
|
18
|
+
export const TURN_PREVIEW_CHARS = 220;
|
|
19
|
+
|
|
13
20
|
/** Whether a stored content string is raw composer input (slash command or
|
|
14
21
|
* @mention) rather than the prompt omp actually delivered. */
|
|
15
22
|
export function isRawComposerInput(content: string): boolean {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* File-kind classification shared by the server read routes and every client
|
|
8
|
+
* viewer (desktop editor, mobile editor, chat tool panels).
|
|
9
|
+
*
|
|
10
|
+
* A raster image is not text: decoding its bytes as UTF-8 yields replacement
|
|
11
|
+
* characters, so the editor used to paint a PNG as mojibake. The extension is
|
|
12
|
+
* the only signal available before reading the file, and it is the same signal
|
|
13
|
+
* the Files panel already uses to pick an icon — keeping it in one module means
|
|
14
|
+
* the reader, the icon, and the viewer can never disagree about which files are
|
|
15
|
+
* pictures.
|
|
16
|
+
*
|
|
17
|
+
* SVG is deliberately absent: it is editable XML text and stays on the
|
|
18
|
+
* syntax-highlighted code path.
|
|
19
|
+
*/
|
|
20
|
+
export const IMAGE_MIME_BY_EXT: Record<string, string> = {
|
|
21
|
+
png: 'image/png',
|
|
22
|
+
apng: 'image/apng',
|
|
23
|
+
jpg: 'image/jpeg',
|
|
24
|
+
jpeg: 'image/jpeg',
|
|
25
|
+
jfif: 'image/jpeg',
|
|
26
|
+
gif: 'image/gif',
|
|
27
|
+
webp: 'image/webp',
|
|
28
|
+
avif: 'image/avif',
|
|
29
|
+
bmp: 'image/bmp',
|
|
30
|
+
ico: 'image/x-icon',
|
|
31
|
+
tif: 'image/tiff',
|
|
32
|
+
tiff: 'image/tiff',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Lower-cased extension of a path or name, query/fragment/line suffix removed. */
|
|
36
|
+
export function getFileExtension(filePath: string | undefined | null): string {
|
|
37
|
+
if (!filePath) return '';
|
|
38
|
+
const clean = filePath.split('?')[0].split('#')[0].split(/[/\\]/).pop() ?? '';
|
|
39
|
+
const dot = clean.lastIndexOf('.');
|
|
40
|
+
return dot > 0 ? clean.slice(dot + 1).toLowerCase() : '';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* MIME type for a raster image path, or null when the extension is not one.
|
|
45
|
+
* Null is the "treat as text" answer, so every caller branches on this single
|
|
46
|
+
* check instead of re-deriving the extension list.
|
|
47
|
+
*/
|
|
48
|
+
export function getImageMimeType(filePath: string | undefined | null): string | null {
|
|
49
|
+
return IMAGE_MIME_BY_EXT[getFileExtension(filePath)] ?? null;
|
|
50
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Path
|
|
2
|
+
* Path helpers shared by the Files panel and every surface that opens a file.
|
|
3
3
|
*
|
|
4
4
|
* `/api/fs/dir` emits every entry as a path relative to the listing's base
|
|
5
5
|
* directory — the workspace root, or the selected nested repo — and reports
|
|
@@ -7,7 +7,25 @@
|
|
|
7
7
|
* two halves; a relative path on its own is not resolvable outside the panel
|
|
8
8
|
* (the client-side `rootPath` may be `~`-relative, rejected by the server, or
|
|
9
9
|
* absent entirely in mock mode).
|
|
10
|
+
*
|
|
11
|
+
* `buildFsRawUrl` is the byte-stream counterpart of that same scoping: it is
|
|
12
|
+
* what an image tab points its `<img>` at, so the file it names is the file
|
|
13
|
+
* the tab names, nested repo included.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* URL of one file's raw bytes through `/api/fs/raw`, carrying the same scope
|
|
18
|
+
* (`root`, nested `repo`) as `/api/fs/read` so an image opened from a nested
|
|
19
|
+
* repo resolves to the file the tab names. `repo: '.'` is omitted: the server
|
|
20
|
+
* treats an absent repo as the scoped root.
|
|
10
21
|
*/
|
|
22
|
+
export function buildFsRawUrl(file: { path: string; root?: string; repo?: string }): string {
|
|
23
|
+
const params = new URLSearchParams({ path: file.path });
|
|
24
|
+
if (file.root) params.set('root', file.root);
|
|
25
|
+
if (file.repo && file.repo !== '.') params.set('repo', file.repo);
|
|
26
|
+
return `/api/fs/raw?${params.toString()}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
11
29
|
export function toAbsolutePath(baseDir: string | null | undefined, relPath: string): string {
|
|
12
30
|
// `/`- and `./`-prefixed raws are stripped; a dot-file name (`.env`) is not a prefix.
|
|
13
31
|
const rel = relPath.replace(/^(?:\.?[/\\])+/, '').replace(/[/\\]+$/, '');
|
|
@@ -20,16 +20,19 @@
|
|
|
20
20
|
* they are no-ops everywhere else.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import { beforeAll, describe, expect, test } from 'bun:test';
|
|
23
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
24
24
|
import { Window } from 'happy-dom';
|
|
25
25
|
|
|
26
26
|
let hydrateMathBlocks: typeof import('@/shared/lib/markdown/katex').hydrateMathBlocks;
|
|
27
27
|
let renderMarkdown: typeof import('@/shared/lib/markdown/marked').renderMarkdown;
|
|
28
28
|
let sanitizeHtml: typeof import('@/shared/lib/markdown/sanitize').sanitizeHtml;
|
|
29
29
|
|
|
30
|
+
const displaced: Record<string, unknown> = {};
|
|
31
|
+
const installed: Record<string, unknown> = {};
|
|
32
|
+
|
|
30
33
|
beforeAll(async () => {
|
|
31
34
|
const win = new Window({ url: 'http://localhost' });
|
|
32
|
-
Object.
|
|
35
|
+
for (const [key, value] of Object.entries({
|
|
33
36
|
window: win,
|
|
34
37
|
document: win.document,
|
|
35
38
|
MutationObserver: win.MutationObserver,
|
|
@@ -37,7 +40,11 @@ beforeAll(async () => {
|
|
|
37
40
|
Element: win.Element,
|
|
38
41
|
HTMLElement: win.HTMLElement,
|
|
39
42
|
NodeFilter: win.NodeFilter,
|
|
40
|
-
})
|
|
43
|
+
})) {
|
|
44
|
+
displaced[key] = (globalThis as Record<string, unknown>)[key];
|
|
45
|
+
installed[key] = value;
|
|
46
|
+
(globalThis as Record<string, unknown>)[key] = value;
|
|
47
|
+
}
|
|
41
48
|
// Dynamic import is required here, not stylistic: `sanitize.ts` binds
|
|
42
49
|
// DOMPurify at module scope, which captures the DOM globals present at
|
|
43
50
|
// evaluation time. A static import would run before the happy-dom globals
|
|
@@ -47,6 +54,16 @@ beforeAll(async () => {
|
|
|
47
54
|
({ sanitizeHtml } = await import('@/shared/lib/markdown/sanitize'));
|
|
48
55
|
});
|
|
49
56
|
|
|
57
|
+
// Every test file shares one process, so the globals go back the way they were:
|
|
58
|
+
// the fold in `agent-events.ts` branches on `typeof window`, and a leaked DOM
|
|
59
|
+
// window would take its browser path with a `CustomEvent` from another realm.
|
|
60
|
+
afterAll(() => {
|
|
61
|
+
for (const key of Object.keys(installed)) {
|
|
62
|
+
if (displaced[key] === undefined) delete (globalThis as Record<string, unknown>)[key];
|
|
63
|
+
else (globalThis as Record<string, unknown>)[key] = displaced[key];
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
50
67
|
/** Render markdown and mount it exactly as `MarkdownRenderer` does. */
|
|
51
68
|
function mount(content: string): HTMLElement {
|
|
52
69
|
const host = document.createElement('div');
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The preference rules are shared verbatim by the server (which persists the
|
|
8
|
+
* arrays) and the picker (which applies them optimistically), so these lock the
|
|
9
|
+
* behaviour both sides read back: composite keys, most-recent-first order, and
|
|
10
|
+
* the five-entry cap.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, expect, test } from 'bun:test';
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
EMPTY_MODEL_PREFERENCES,
|
|
17
|
+
RECENT_MODELS_LIMIT,
|
|
18
|
+
applyModelPreferences,
|
|
19
|
+
filterKnownKeys,
|
|
20
|
+
readModelPreferences,
|
|
21
|
+
recordRecentKey,
|
|
22
|
+
toggleFavoriteKey,
|
|
23
|
+
} from '@/shared/lib/models/preferences';
|
|
24
|
+
import type { AIModelOption } from '@/shared/types';
|
|
25
|
+
|
|
26
|
+
describe('toggleFavoriteKey', () => {
|
|
27
|
+
test('adds an unknown key at the end and removes a known one', () => {
|
|
28
|
+
expect(toggleFavoriteKey(['a:1'], 'b:2')).toEqual(['a:1', 'b:2']);
|
|
29
|
+
expect(toggleFavoriteKey(['a:1', 'b:2'], 'a:1')).toEqual(['b:2']);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('keys on provider + id, so the same id under another provider is a separate favorite', () => {
|
|
33
|
+
// The registry serves `deepseek-v4-pro` from both `deepseek` and `kenari`.
|
|
34
|
+
// An id-only list would unstar both rows when either is clicked.
|
|
35
|
+
const favorites = toggleFavoriteKey([], 'deepseek:deepseek-v4-pro');
|
|
36
|
+
expect(toggleFavoriteKey(favorites, 'kenari:deepseek-v4-pro')).toEqual([
|
|
37
|
+
'deepseek:deepseek-v4-pro',
|
|
38
|
+
'kenari:deepseek-v4-pro',
|
|
39
|
+
]);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('recordRecentKey', () => {
|
|
44
|
+
test('moves a re-picked model to the front instead of duplicating it', () => {
|
|
45
|
+
expect(recordRecentKey(['a:1', 'b:2', 'c:3'], 'b:2')).toEqual(['b:2', 'a:1', 'c:3']);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('caps the list at five, evicting the oldest entry', () => {
|
|
49
|
+
const full = ['a:1', 'b:2', 'c:3', 'd:4', 'e:5'];
|
|
50
|
+
const next = recordRecentKey(full, 'f:6');
|
|
51
|
+
|
|
52
|
+
expect(next).toHaveLength(RECENT_MODELS_LIMIT);
|
|
53
|
+
expect(next[0]).toBe('f:6');
|
|
54
|
+
expect(next).not.toContain('e:5');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('re-picking inside a full list keeps the other four', () => {
|
|
58
|
+
const full = ['a:1', 'b:2', 'c:3', 'd:4', 'e:5'];
|
|
59
|
+
expect(recordRecentKey(full, 'c:3')).toEqual(['c:3', 'a:1', 'b:2', 'd:4', 'e:5']);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('readModelPreferences', () => {
|
|
64
|
+
test('reads a well-formed pair', () => {
|
|
65
|
+
expect(readModelPreferences({ favorites: ['a:1'], recentKeys: ['b:2'] }))
|
|
66
|
+
.toEqual({ favorites: ['a:1'], recentKeys: ['b:2'] });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('drops non-string entries and caps an over-long recent list', () => {
|
|
70
|
+
const raw = {
|
|
71
|
+
favorites: ['a:1', 7, null, ''],
|
|
72
|
+
recentKeys: ['a:1', 'b:2', 'c:3', 'd:4', 'e:5', 'f:6', 'g:7'],
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
expect(readModelPreferences(raw)).toEqual({
|
|
76
|
+
favorites: ['a:1'],
|
|
77
|
+
recentKeys: ['a:1', 'b:2', 'c:3', 'd:4', 'e:5'],
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('an absent or malformed row yields empty rails', () => {
|
|
82
|
+
expect(readModelPreferences(null)).toEqual(EMPTY_MODEL_PREFERENCES);
|
|
83
|
+
expect(readModelPreferences('nonsense')).toEqual(EMPTY_MODEL_PREFERENCES);
|
|
84
|
+
expect(readModelPreferences({ favorites: 'not-a-list' })).toEqual(EMPTY_MODEL_PREFERENCES);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('filterKnownKeys', () => {
|
|
89
|
+
test('drops keys the registry no longer serves', () => {
|
|
90
|
+
// A model removed from models.yml, or every model of a disconnected
|
|
91
|
+
// provider, must not hold a slot in the five-entry rail.
|
|
92
|
+
const known = new Set(['a:1', 'c:3']);
|
|
93
|
+
expect(filterKnownKeys(['a:1', 'gone:9', 'c:3'], known)).toEqual(['a:1', 'c:3']);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('pruning before a write frees the slot instead of merely hiding it', () => {
|
|
97
|
+
// The rail reads at most five entries. A stale key left in the stored array
|
|
98
|
+
// occupies one of them across every reload, so the user sees four models
|
|
99
|
+
// and the fifth pick appears to do nothing.
|
|
100
|
+
const stored = ['gone:9', 'a:1', 'b:2', 'c:3', 'd:4'];
|
|
101
|
+
const pruned = recordRecentKey(filterKnownKeys(stored, new Set(['a:1', 'b:2', 'c:3', 'd:4', 'e:5'])), 'e:5');
|
|
102
|
+
|
|
103
|
+
expect(pruned).toEqual(['e:5', 'a:1', 'b:2', 'c:3', 'd:4']);
|
|
104
|
+
expect(pruned).toHaveLength(RECENT_MODELS_LIMIT);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('applyModelPreferences', () => {
|
|
109
|
+
const rows: AIModelOption[] = [
|
|
110
|
+
{ id: '1', name: 'One', provider: 'a' },
|
|
111
|
+
{ id: '2', name: 'Two', provider: 'a', isFavorite: true },
|
|
112
|
+
{ id: '3', name: 'Three', provider: 'b' },
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
test('lights only the rows named by the stored key list', () => {
|
|
116
|
+
const applied = applyModelPreferences(rows, { favorites: ['b:3'], recentKeys: [] });
|
|
117
|
+
|
|
118
|
+
// Truthiness is the contract the star reads: a registry row has no
|
|
119
|
+
// `isFavorite` field at all, so `false` and `undefined` both mean "unlit".
|
|
120
|
+
expect(applied.map((m) => Boolean(m.isFavorite))).toEqual([false, false, true]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('leaves already-correct rows identical so only the changed row re-renders', () => {
|
|
124
|
+
const applied = applyModelPreferences(rows, { favorites: [], recentKeys: [] });
|
|
125
|
+
|
|
126
|
+
expect(applied[0]).toBe(rows[0]);
|
|
127
|
+
expect(applied[2]).toBe(rows[2]);
|
|
128
|
+
expect(applied[1]).not.toBe(rows[1]);
|
|
129
|
+
expect(applied[1].isFavorite).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Favorite + recent model preferences, keyed by the composite `provider:id`
|
|
8
|
+
* identity (`modelKey`). A model id alone is not unique — the omp registry
|
|
9
|
+
* serves the same id from several providers — so an id-keyed favorite list
|
|
10
|
+
* lights up every provider's row and records the wrong "recent".
|
|
11
|
+
*
|
|
12
|
+
* Pure list operations only: the server persists the arrays (one
|
|
13
|
+
* `app_settings` row) and the picker applies them optimistically, so both
|
|
14
|
+
* sides MUST agree on the ordering and the cap. Keeping the rules here is what
|
|
15
|
+
* makes them agree — a second copy in the route or the hook would drift.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { AIModelOption, ModelPreferences } from '@/shared/types';
|
|
19
|
+
import { modelKey } from '@/shared/lib/models/identity';
|
|
20
|
+
|
|
21
|
+
/** How many last-used models the RECENT rail holds. */
|
|
22
|
+
export const RECENT_MODELS_LIMIT = 5;
|
|
23
|
+
|
|
24
|
+
/** Used before the first write, and when a payload carries no preferences. */
|
|
25
|
+
export const EMPTY_MODEL_PREFERENCES: ModelPreferences = { favorites: [], recentKeys: [] };
|
|
26
|
+
|
|
27
|
+
/** Add or remove one key; the relative order of the survivors is untouched. */
|
|
28
|
+
export function toggleFavoriteKey(favorites: readonly string[], key: string): string[] {
|
|
29
|
+
return favorites.includes(key)
|
|
30
|
+
? favorites.filter((entry) => entry !== key)
|
|
31
|
+
: [...favorites, key];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Move a key to the front of the recents, de-duplicated and capped. Re-picking
|
|
36
|
+
* a model must not leave a second copy behind — the rail would then show the
|
|
37
|
+
* same model twice and the cap would evict a genuinely older entry.
|
|
38
|
+
*/
|
|
39
|
+
export function recordRecentKey(
|
|
40
|
+
recentKeys: readonly string[],
|
|
41
|
+
key: string,
|
|
42
|
+
limit: number = RECENT_MODELS_LIMIT,
|
|
43
|
+
): string[] {
|
|
44
|
+
return [key, ...recentKeys.filter((entry) => entry !== key)].slice(0, Math.max(1, limit));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Drop keys the live registry no longer offers — a model removed from
|
|
49
|
+
* models.yml, or every model of a provider the user disconnected. Without this
|
|
50
|
+
* a stale key holds one of the five recent slots forever, so a rail that looks
|
|
51
|
+
* empty is hiding real entries behind keys nothing can render.
|
|
52
|
+
*/
|
|
53
|
+
export function filterKnownKeys(keys: readonly string[], known: ReadonlySet<string>): string[] {
|
|
54
|
+
return keys.filter((key) => known.has(key));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Parse a persisted preferences row without trusting the JSON shape. */
|
|
58
|
+
export function readModelPreferences(value: unknown): ModelPreferences {
|
|
59
|
+
const source = typeof value === 'object' && value !== null ? value as Record<string, unknown> : {};
|
|
60
|
+
const strings = (input: unknown): string[] =>
|
|
61
|
+
Array.isArray(input) ? input.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0) : [];
|
|
62
|
+
return {
|
|
63
|
+
favorites: strings(source.favorites),
|
|
64
|
+
recentKeys: strings(source.recentKeys).slice(0, RECENT_MODELS_LIMIT),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Stamp the favorite flag onto a catalog list. The registry carries no
|
|
70
|
+
* favorites of its own — it is a read-only view of omp's config — so the picker
|
|
71
|
+
* learns which rows are starred from the preference store, never from the
|
|
72
|
+
* catalog's own `isFavorite` (that is MOCK seed data).
|
|
73
|
+
*
|
|
74
|
+
* The comparison is by truthiness, not identity: a registry row has no
|
|
75
|
+
* `isFavorite` field at all, so an `undefined !== false` test would rebuild
|
|
76
|
+
* every row on every load and re-render the whole panel. Rows whose star state
|
|
77
|
+
* already reads correctly are returned untouched.
|
|
78
|
+
*/
|
|
79
|
+
export function applyModelPreferences(
|
|
80
|
+
models: AIModelOption[],
|
|
81
|
+
preferences: ModelPreferences,
|
|
82
|
+
): AIModelOption[] {
|
|
83
|
+
const favorites = new Set(preferences.favorites);
|
|
84
|
+
return models.map((model) => {
|
|
85
|
+
const isFavorite = favorites.has(modelKey(model));
|
|
86
|
+
return Boolean(model.isFavorite) === isFavorite ? model : { ...model, isFavorite };
|
|
87
|
+
});
|
|
88
|
+
}
|