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
|
@@ -3,190 +3,28 @@ import type { ActionFunctionArgs, LoaderFunctionArgs } from '@/server/lib/remix-
|
|
|
3
3
|
import { getDb } from '@/server/db.server';
|
|
4
4
|
import { INITIAL_MODELS_CATALOG } from '@/client/data/models/catalog';
|
|
5
5
|
import { isMockMode } from '@/server/mock.server';
|
|
6
|
-
import type { AIModelOption,
|
|
6
|
+
import type { AIModelOption, ModelsData, ProviderItem } from '@/shared/types';
|
|
7
7
|
import { readSettingsJson, writeSettingsJson } from '@/server/lib/db/settings-store';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
|
|
8
|
+
import { modelKey } from '@/shared/lib/models/identity';
|
|
9
|
+
import {
|
|
10
|
+
EMPTY_MODELS,
|
|
11
|
+
SAFE_MODEL_LOAD_FAILURE_MESSAGE,
|
|
12
|
+
invalidateModelsCache,
|
|
13
|
+
loadModelsWithCache,
|
|
14
|
+
readStoredProvidersForModels,
|
|
15
|
+
} from '@/server/lib/models/registry.server';
|
|
16
|
+
import {
|
|
17
|
+
readModelPreferencesFor,
|
|
18
|
+
recordRecentPreference,
|
|
19
|
+
toggleFavoritePreference,
|
|
20
|
+
} from '@/server/lib/models/preferences.server';
|
|
11
21
|
|
|
12
22
|
const MODELS_CATALOG_KEY = 'omp_models_catalog';
|
|
13
23
|
const SELECTED_MODEL_KEY = 'omp_selected_model';
|
|
14
|
-
const PROVIDERS_CONFIG_KEY = 'omp_providers_config';
|
|
15
24
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const modelNameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
|
|
20
|
-
|
|
21
|
-
function compareModelEntries(a: { id: string; name: string; provider: string }, b: { id: string; name: string; provider: string }): number {
|
|
22
|
-
return modelNameCollator.compare(a.name || a.id, b.name || b.id)
|
|
23
|
-
|| modelNameCollator.compare(a.provider, b.provider)
|
|
24
|
-
|| modelNameCollator.compare(a.id, b.id);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
// "off" is always a valid selector; the concrete efforts come from the model's
|
|
28
|
-
// baked thinking metadata (omp: getSupportedEfforts = reasoning ? efforts : []).
|
|
29
|
-
function thinkingLevelsFor(model: OmpModel): string[] {
|
|
30
|
-
if (!model.reasoning) return ['off'];
|
|
31
|
-
return ['off', ...(model.thinking?.efforts ?? [])];
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function supportsFastMode(model: OmpModel): boolean {
|
|
35
|
-
return model.provider === 'anthropic' || model.provider === 'openai' || model.provider === 'google';
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Disabled providers recorded in the chamber's own provider overlay. Used only
|
|
40
|
-
* by the MOCK path of `/api/models`, which has no config.yml `disabledProviders`
|
|
41
|
-
* to read — real mode gets the same answer natively.
|
|
42
|
-
*/
|
|
43
|
-
async function readStoredProvidersForModels(): Promise<ProviderItem[]> {
|
|
44
|
-
try {
|
|
45
|
-
const db = await getDb();
|
|
46
|
-
const parsed = await readSettingsJson<unknown>(db, PROVIDERS_CONFIG_KEY, null);
|
|
47
|
-
if (!Array.isArray(parsed)) return [];
|
|
48
|
-
return parsed.filter((provider): provider is ProviderItem => (
|
|
49
|
-
typeof provider === 'object' && provider !== null
|
|
50
|
-
&& typeof (provider as ProviderItem).slug === 'string'
|
|
51
|
-
&& (provider as ProviderItem).disabled === true
|
|
52
|
-
));
|
|
53
|
-
} catch {
|
|
54
|
-
return [];
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function hiddenModelKey(provider: string, modelId: string): string {
|
|
59
|
-
return `${provider.trim().toLowerCase()}:${modelId}`;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
async function readHiddenModelKeys(): Promise<Set<string>> {
|
|
63
|
-
try {
|
|
64
|
-
const db = await getDb();
|
|
65
|
-
const parsed = await readSettingsJson<unknown>(db, PROVIDERS_CONFIG_KEY, null);
|
|
66
|
-
if (!Array.isArray(parsed)) return new Set();
|
|
67
|
-
const hidden = new Set<string>();
|
|
68
|
-
for (const provider of parsed) {
|
|
69
|
-
if (typeof provider?.slug !== 'string' || !Array.isArray(provider.models)) continue;
|
|
70
|
-
for (const model of provider.models) {
|
|
71
|
-
if (typeof model?.id === 'string' && model.isVisible === false) {
|
|
72
|
-
hidden.add(hiddenModelKey(provider.slug, model.id));
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
return hidden;
|
|
77
|
-
} catch {
|
|
78
|
-
return new Set();
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* A model is offered only when its provider is neither disabled in omp's
|
|
84
|
-
* config.yml (`disabledProviders`) nor carrying a per-model hide in the
|
|
85
|
-
* chamber overlay. Disabling the provider is the coarse switch — Settings →
|
|
86
|
-
* Providers flips it so the whole provider leaves the chat picker.
|
|
87
|
-
*/
|
|
88
|
-
function filterSelectableModels(
|
|
89
|
-
available: OmpModel[],
|
|
90
|
-
disabledProviders: Set<string>,
|
|
91
|
-
hiddenModelKeys: Set<string>,
|
|
92
|
-
): OmpModel[] {
|
|
93
|
-
return available.filter((model) => (
|
|
94
|
-
!disabledProviders.has(model.provider)
|
|
95
|
-
&& !hiddenModelKeys.has(hiddenModelKey(model.provider, model.id))
|
|
96
|
-
));
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
async function loadModels(): Promise<ModelsData> {
|
|
100
|
-
const availableResponse = await runUtilityCommand<{ models?: unknown }>(
|
|
101
|
-
{ type: 'get_available_models' },
|
|
102
|
-
120_000,
|
|
103
|
-
);
|
|
104
|
-
const available = Array.isArray(availableResponse.models)
|
|
105
|
-
? availableResponse.models
|
|
106
|
-
.filter((model): model is OmpModel => (
|
|
107
|
-
typeof model === 'object' && model !== null
|
|
108
|
-
&& typeof (model as OmpModel).id === 'string'
|
|
109
|
-
&& typeof (model as OmpModel).provider === 'string'
|
|
110
|
-
))
|
|
111
|
-
.map((model) => ({
|
|
112
|
-
...model,
|
|
113
|
-
name: typeof model.name === 'string' && model.name.trim().length > 0 ? model.name : model.id,
|
|
114
|
-
}))
|
|
115
|
-
: [];
|
|
116
|
-
|
|
117
|
-
const disabledProviders = await readDisabledProviders();
|
|
118
|
-
const hiddenModelKeys = await readHiddenModelKeys();
|
|
119
|
-
const nameMap: Record<string, string> = {};
|
|
120
|
-
const thinkingLevels: Record<string, string[]> = {};
|
|
121
|
-
const modelList: ModelEntry[] = filterSelectableModels(available, disabledProviders, hiddenModelKeys)
|
|
122
|
-
.map((m) => ({
|
|
123
|
-
id: m.id,
|
|
124
|
-
name: m.name,
|
|
125
|
-
provider: m.provider,
|
|
126
|
-
thinkingLevels: thinkingLevelsFor(m),
|
|
127
|
-
supportsFastMode: supportsFastMode(m),
|
|
128
|
-
contextWindow: m.contextWindow ?? undefined,
|
|
129
|
-
maxTokens: m.maxTokens ?? undefined,
|
|
130
|
-
cost: m.cost,
|
|
131
|
-
}))
|
|
132
|
-
.sort(compareModelEntries);
|
|
133
|
-
|
|
134
|
-
const loginResponse = await runUtilityCommand<{ providers?: unknown }>(
|
|
135
|
-
{ type: 'get_login_providers' },
|
|
136
|
-
30_000,
|
|
137
|
-
);
|
|
138
|
-
const loginProviders = Array.isArray(loginResponse.providers)
|
|
139
|
-
? loginResponse.providers.filter((provider): provider is { id: string; name: string; authenticated: boolean } => (
|
|
140
|
-
typeof provider === 'object' && provider !== null
|
|
141
|
-
&& typeof (provider as { id?: unknown }).id === 'string'
|
|
142
|
-
&& typeof (provider as { name?: unknown }).name === 'string'
|
|
143
|
-
&& typeof (provider as { authenticated?: unknown }).authenticated === 'boolean'
|
|
144
|
-
))
|
|
145
|
-
: [];
|
|
146
|
-
const connectedProviders = loginProviders
|
|
147
|
-
.filter((provider) => provider.authenticated)
|
|
148
|
-
.map((provider) => ({ id: provider.id, name: provider.name, disabled: disabledProviders.has(provider.id) }));
|
|
149
|
-
for (const m of available) {
|
|
150
|
-
const key = `${m.provider}:${m.id}`;
|
|
151
|
-
nameMap[key] = m.name;
|
|
152
|
-
thinkingLevels[key] = thinkingLevelsFor(m);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
let defaultModel: { provider: string; modelId: string } | null = null;
|
|
156
|
-
try {
|
|
157
|
-
const state = await runUtilityCommand<{ model?: { provider?: string; id?: string } }>(
|
|
158
|
-
{ type: 'get_state' },
|
|
159
|
-
30_000,
|
|
160
|
-
);
|
|
161
|
-
const provider = state.model?.provider;
|
|
162
|
-
const modelId = state.model?.id;
|
|
163
|
-
if (provider && modelId && available.some((m) => m.provider === provider && m.id === modelId)) {
|
|
164
|
-
defaultModel = { provider, modelId };
|
|
165
|
-
}
|
|
166
|
-
} catch {
|
|
167
|
-
// Default model is cosmetic — the models list is still useful without it.
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
return { models: nameMap, modelList, defaultModel, thinkingLevels, connectedProviders };
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
const EMPTY_MODELS: ModelsData = {
|
|
174
|
-
models: {},
|
|
175
|
-
modelList: [],
|
|
176
|
-
defaultModel: null,
|
|
177
|
-
thinkingLevels: {},
|
|
178
|
-
};
|
|
179
|
-
|
|
180
|
-
async function loadModelsWithCache(): Promise<ModelsData> {
|
|
181
|
-
const cached = globalThis.__ompChamberModelsCache;
|
|
182
|
-
if (cached && cached.expiresAt > Date.now()) return cached.data;
|
|
183
|
-
const data = await loadModels();
|
|
184
|
-
globalThis.__ompChamberModelsCache = { data, expiresAt: Date.now() + MODELS_CACHE_TTL_MS };
|
|
185
|
-
return data;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function invalidateModelsCache(): void {
|
|
189
|
-
invalidateModelsCaches();
|
|
25
|
+
/** Error text for a 500 envelope without leaking a non-Error throw as `undefined`. */
|
|
26
|
+
function errorMessage(error: unknown): string {
|
|
27
|
+
return error instanceof Error ? error.message : String(error);
|
|
190
28
|
}
|
|
191
29
|
|
|
192
30
|
/** Parse a persisted { provider, modelId|id } reference without trusting the JSON shape. */
|
|
@@ -200,7 +38,7 @@ function readSelectedModelRef(value: unknown): { provider: string; modelId: stri
|
|
|
200
38
|
}
|
|
201
39
|
|
|
202
40
|
/** The user's persisted pick resolved against the live registry; null when stale or unparseable. */
|
|
203
|
-
async function loadPersistedModelOption(modelList:
|
|
41
|
+
async function loadPersistedModelOption(modelList: ModelsData['modelList']): Promise<AIModelOption | null> {
|
|
204
42
|
try {
|
|
205
43
|
const db = await getDb();
|
|
206
44
|
const selected = readSelectedModelRef(await readSettingsJson<unknown>(db, SELECTED_MODEL_KEY, null));
|
|
@@ -220,37 +58,44 @@ async function loadPersistedModelOption(modelList: ModelEntry[]): Promise<AIMode
|
|
|
220
58
|
}
|
|
221
59
|
}
|
|
222
60
|
|
|
223
|
-
|
|
224
|
-
|
|
61
|
+
/**
|
|
62
|
+
* MOCK mode keeps the SQLite-backed demo catalog, but the favorite/recent
|
|
63
|
+
* rails come from the same preference store as real mode — the catalog's own
|
|
64
|
+
* `isFavorite`/`isRecent` flags are only its seed.
|
|
65
|
+
*/
|
|
66
|
+
async function loadMockModels() {
|
|
67
|
+
const db = await getDb();
|
|
68
|
+
const catalog = await readSettingsJson<unknown>(db, MODELS_CATALOG_KEY, null);
|
|
69
|
+
const stored: AIModelOption[] = Array.isArray(catalog) && catalog.length > 0 ? catalog : INITIAL_MODELS_CATALOG;
|
|
70
|
+
// Demo mode has no config.yml to read, so the disable flag lives on the
|
|
71
|
+
// stored provider overlay. Filtering here keeps the model picker honest
|
|
72
|
+
// in MOCK=true too — the settings page must be able to prove the switch.
|
|
73
|
+
const disabledSlugs = new Set(
|
|
74
|
+
(await readStoredProvidersForModels()).map((provider: ProviderItem) => provider.slug.trim().toLowerCase()),
|
|
75
|
+
);
|
|
76
|
+
const models = stored.filter((model) => !disabledSlugs.has(model.provider.trim().toLowerCase()));
|
|
77
|
+
const persisted = await readSettingsJson<AIModelOption | null>(db, SELECTED_MODEL_KEY, null);
|
|
78
|
+
// Every provider disabled ⇒ empty list and no selection; the composer must
|
|
79
|
+
// see that honestly instead of holding on to a hidden provider's model.
|
|
80
|
+
let selectedModel: AIModelOption | null = models[5] || models[0] || null;
|
|
81
|
+
if (persisted && persisted.id && persisted.provider) {
|
|
82
|
+
const match = models.find(m => m.id === persisted.id && m.provider === persisted.provider);
|
|
83
|
+
if (match) selectedModel = match;
|
|
84
|
+
}
|
|
85
|
+
return { models, selectedModel, modelPreferences: await readModelPreferencesFor(models), isMock: true };
|
|
86
|
+
}
|
|
225
87
|
|
|
88
|
+
export async function loader({ request }: LoaderFunctionArgs) {
|
|
226
89
|
// MOCK mode keeps the SQLite-backed catalog (chamber demo behavior).
|
|
227
|
-
if (
|
|
90
|
+
if (isMockMode()) {
|
|
228
91
|
try {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const stored: AIModelOption[] = Array.isArray(catalog) && catalog.length > 0 ? catalog : INITIAL_MODELS_CATALOG;
|
|
232
|
-
// Demo mode has no config.yml to read, so the disable flag lives on the
|
|
233
|
-
// stored provider overlay. Filtering here keeps the model picker honest
|
|
234
|
-
// in MOCK=true too — the settings page must be able to prove the switch.
|
|
235
|
-
const disabledSlugs = new Set(
|
|
236
|
-
(await readStoredProvidersForModels()).map((provider) => provider.slug.trim().toLowerCase()),
|
|
237
|
-
);
|
|
238
|
-
const models = stored.filter((model) => !disabledSlugs.has(model.provider.trim().toLowerCase()));
|
|
239
|
-
const persisted = await readSettingsJson<AIModelOption | null>(db, SELECTED_MODEL_KEY, null);
|
|
240
|
-
// Every provider disabled ⇒ empty list and no selection; the composer must
|
|
241
|
-
// see that honestly instead of holding on to a hidden provider's model.
|
|
242
|
-
let selectedModel: AIModelOption | null = models[5] || models[0] || null;
|
|
243
|
-
if (persisted && persisted.id && persisted.provider) {
|
|
244
|
-
const match = models.find(m => m.id === persisted.id && m.provider === persisted.provider);
|
|
245
|
-
if (match) selectedModel = match;
|
|
246
|
-
}
|
|
247
|
-
return json({ models, selectedModel, isMock: mock });
|
|
248
|
-
} catch (error: any) {
|
|
92
|
+
return json(await loadMockModels());
|
|
93
|
+
} catch (error: unknown) {
|
|
249
94
|
return json({
|
|
250
|
-
error: error
|
|
95
|
+
error: errorMessage(error),
|
|
251
96
|
models: INITIAL_MODELS_CATALOG,
|
|
252
97
|
selectedModel: INITIAL_MODELS_CATALOG[5] || INITIAL_MODELS_CATALOG[0] || null,
|
|
253
|
-
isMock:
|
|
98
|
+
isMock: true,
|
|
254
99
|
}, { status: 500 });
|
|
255
100
|
}
|
|
256
101
|
}
|
|
@@ -261,8 +106,13 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|
|
261
106
|
void request;
|
|
262
107
|
try {
|
|
263
108
|
const data = await loadModelsWithCache();
|
|
264
|
-
// The selection
|
|
265
|
-
|
|
109
|
+
// The selection and the preferences change independently of the 60s
|
|
110
|
+
// registry cache, so both are read fresh on every request.
|
|
111
|
+
return json({
|
|
112
|
+
...data,
|
|
113
|
+
selectedModel: await loadPersistedModelOption(data.modelList),
|
|
114
|
+
modelPreferences: await readModelPreferencesFor(data.modelList),
|
|
115
|
+
});
|
|
266
116
|
} catch {
|
|
267
117
|
return json({ ...EMPTY_MODELS, modelError: SAFE_MODEL_LOAD_FAILURE_MESSAGE });
|
|
268
118
|
}
|
|
@@ -272,23 +122,42 @@ export async function action({ request }: ActionFunctionArgs) {
|
|
|
272
122
|
try {
|
|
273
123
|
const db = await getDb();
|
|
274
124
|
const body = await request.json();
|
|
125
|
+
const mock = isMockMode();
|
|
275
126
|
|
|
276
127
|
const catalog = await readSettingsJson<unknown>(db, MODELS_CATALOG_KEY, null);
|
|
277
128
|
// The shipped demo catalog is MOCK-only: a real install starts from an empty
|
|
278
129
|
// list, so a demo model can never be persisted into its stored catalog.
|
|
279
130
|
let models: AIModelOption[] = Array.isArray(catalog)
|
|
280
131
|
? catalog
|
|
281
|
-
: (
|
|
132
|
+
: (mock ? INITIAL_MODELS_CATALOG : []);
|
|
282
133
|
|
|
283
134
|
const { actionType, modelId, provider, thinkingLevel, model, models: newModels } = body;
|
|
284
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Composite keys the registry currently serves. Preference writes prune
|
|
138
|
+
* against it so a model that left models.yml — or whose provider was
|
|
139
|
+
* disconnected — cannot hold one of the five recent slots. Real mode reads
|
|
140
|
+
* the same 60s-cached registry the loader serves; MOCK uses its demo list.
|
|
141
|
+
*/
|
|
142
|
+
const knownModelKeys = async (): Promise<Set<string>> => {
|
|
143
|
+
const live = mock ? models : (await loadModelsWithCache()).modelList;
|
|
144
|
+
return new Set(live.map((entry) => modelKey(entry)));
|
|
145
|
+
};
|
|
146
|
+
|
|
285
147
|
// Model-scoped mutations must match provider + id: the registry serves the
|
|
286
148
|
// same model id from several providers, so an id-only match rewrites the
|
|
287
149
|
// first provider's entry instead of the one the user acted on.
|
|
288
150
|
if (actionType === 'toggleFavorite' && modelId && provider) {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
151
|
+
// Favorites live in the preference store, NOT in the model catalog: the
|
|
152
|
+
// catalog is a MOCK-only demo list that real mode never reads back, so a
|
|
153
|
+
// star written there vanished on the next render.
|
|
154
|
+
const preferences = await toggleFavoritePreference(modelKey({ provider, id: modelId }), await knownModelKeys());
|
|
155
|
+
// Keep the demo catalog's flag in step so MOCK mode's own views agree.
|
|
156
|
+
if (mock) {
|
|
157
|
+
models = models.map(m => m.id === modelId && m.provider === provider ? { ...m, isFavorite: !m.isFavorite } : m);
|
|
158
|
+
await writeSettingsJson(db, MODELS_CATALOG_KEY, models);
|
|
159
|
+
}
|
|
160
|
+
return json({ success: true, models, modelPreferences: preferences });
|
|
292
161
|
}
|
|
293
162
|
|
|
294
163
|
if (actionType === 'setThinking' && modelId && provider && thinkingLevel) {
|
|
@@ -308,8 +177,12 @@ export async function action({ request }: ActionFunctionArgs) {
|
|
|
308
177
|
const parsedModel = readSelectedModelRef(model);
|
|
309
178
|
if (parsedModel) {
|
|
310
179
|
invalidateModelsCache();
|
|
311
|
-
//
|
|
312
|
-
//
|
|
180
|
+
// "The user picked this model" is one event: recording the recent here
|
|
181
|
+
// keeps a second request from ever disagreeing with the selection.
|
|
182
|
+
await recordRecentPreference(
|
|
183
|
+
modelKey({ provider: parsedModel.provider, id: parsedModel.modelId }),
|
|
184
|
+
await knownModelKeys(),
|
|
185
|
+
);
|
|
313
186
|
}
|
|
314
187
|
return json({ success: true, selectedModel: model });
|
|
315
188
|
}
|
|
@@ -326,7 +199,7 @@ export async function action({ request }: ActionFunctionArgs) {
|
|
|
326
199
|
}
|
|
327
200
|
|
|
328
201
|
return json({ error: 'Invalid action or parameters' }, { status: 400 });
|
|
329
|
-
} catch (error:
|
|
330
|
-
return json({ error: error
|
|
202
|
+
} catch (error: unknown) {
|
|
203
|
+
return json({ error: errorMessage(error) }, { status: 500 });
|
|
331
204
|
}
|
|
332
205
|
}
|
|
@@ -5,6 +5,7 @@ import { getDb } from '@/server/db.server';
|
|
|
5
5
|
import { DEFAULT_COMMANDS_LIST } from '@/client/data/settings/command';
|
|
6
6
|
import { isMockMode } from '@/server/mock.server';
|
|
7
7
|
import type { CommandItem } from '@/shared/types';
|
|
8
|
+
import { isRecord } from '@/shared/lib/util/guards';
|
|
8
9
|
import { createSettingsListStore } from '@/server/lib/db/settings-store';
|
|
9
10
|
import { runUtilityCommand } from '@/server/lib/omp/rpc/utility';
|
|
10
11
|
|
|
@@ -16,27 +17,67 @@ const commandsStore = createSettingsListStore<CommandItem>({
|
|
|
16
17
|
plural: 'commands',
|
|
17
18
|
});
|
|
18
19
|
|
|
19
|
-
/**
|
|
20
|
+
/** Command sources the chamber surfaces in the composer (mcp_prompt is not user-typable). */
|
|
21
|
+
const AGENT_COMMAND_SOURCES: Record<string, true> = {
|
|
22
|
+
builtin: true,
|
|
23
|
+
skill: true,
|
|
24
|
+
custom: true,
|
|
25
|
+
extension: true,
|
|
26
|
+
file: true,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Live slash commands from the agent's `get_available_commands`, mapped into
|
|
31
|
+
* the chamber's CommandItem. Aliases, subcommands and the argument hint ride
|
|
32
|
+
* along: they are what the composer's `/` popup needs to behave like omp's own
|
|
33
|
+
* editor (`/models` completing to the alias, `/fast ` offering on|off|status).
|
|
34
|
+
* Anything malformed is dropped rather than trusted.
|
|
35
|
+
*/
|
|
36
|
+
function toLiveCommandItem(value: unknown): CommandItem | null {
|
|
37
|
+
if (!isRecord(value)) return null;
|
|
38
|
+
const name = typeof value.name === 'string' ? value.name : '';
|
|
39
|
+
if (!name) return null;
|
|
40
|
+
if (typeof value.source !== 'string' || !AGENT_COMMAND_SOURCES[value.source]) return null;
|
|
41
|
+
|
|
42
|
+
const aliases = Array.isArray(value.aliases)
|
|
43
|
+
? value.aliases.filter((alias): alias is string => typeof alias === 'string' && alias.length > 0)
|
|
44
|
+
: [];
|
|
45
|
+
|
|
46
|
+
const hint = isRecord(value.input) && typeof value.input.hint === 'string' ? value.input.hint : undefined;
|
|
47
|
+
|
|
48
|
+
const subcommands = Array.isArray(value.subcommands)
|
|
49
|
+
? value.subcommands.flatMap((sub) => {
|
|
50
|
+
if (!isRecord(sub) || typeof sub.name !== 'string' || !sub.name) return [];
|
|
51
|
+
return [{
|
|
52
|
+
name: sub.name,
|
|
53
|
+
...(typeof sub.description === 'string' ? { description: sub.description } : {}),
|
|
54
|
+
...(typeof sub.usage === 'string' ? { usage: sub.usage } : {}),
|
|
55
|
+
}];
|
|
56
|
+
})
|
|
57
|
+
: [];
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
id: `omp-live-${name}`,
|
|
61
|
+
name,
|
|
62
|
+
description: typeof value.description === 'string' ? value.description : '',
|
|
63
|
+
scope: 'user',
|
|
64
|
+
template: `/${name}`,
|
|
65
|
+
isBuiltIn: true,
|
|
66
|
+
...(aliases.length ? { aliases } : {}),
|
|
67
|
+
...(subcommands.length ? { subcommands } : {}),
|
|
68
|
+
...(hint ? { inputHint: hint } : {}),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Read live agent commands (builtin/skill/custom/extension/file sources) via RPC. */
|
|
20
73
|
async function loadAgentCommands(): Promise<CommandItem[]> {
|
|
21
74
|
try {
|
|
22
75
|
const data = await runUtilityCommand<{ commands?: unknown }>({ type: 'get_available_commands' }, 30_000);
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const command = c as { name?: string; description?: string; source?: string };
|
|
29
|
-
const name = typeof command.name === 'string' ? command.name : '';
|
|
30
|
-
return {
|
|
31
|
-
id: `omp-live-${name}`,
|
|
32
|
-
name,
|
|
33
|
-
description: typeof command.description === 'string' ? command.description : '',
|
|
34
|
-
scope: 'user' as const,
|
|
35
|
-
template: `/${name}`,
|
|
36
|
-
isBuiltIn: true,
|
|
37
|
-
};
|
|
38
|
-
})
|
|
39
|
-
.filter((c) => c.name.length > 0);
|
|
76
|
+
if (!Array.isArray(data.commands)) return [];
|
|
77
|
+
return data.commands.flatMap((command) => {
|
|
78
|
+
const item = toLiveCommandItem(command);
|
|
79
|
+
return item ? [item] : [];
|
|
80
|
+
});
|
|
40
81
|
} catch {
|
|
41
82
|
return [];
|
|
42
83
|
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Attachment metadata across the two representations the composer handles.
|
|
8
|
+
*
|
|
9
|
+
* A LIVE attachment carries `file: File`; a REPLAYED one — out of the queue row
|
|
10
|
+
* or back out of committed history — does not, because `JSON.stringify(new
|
|
11
|
+
* File(…))` is `{}`. Every accessor and every assembly path must therefore work
|
|
12
|
+
* from the persisted display fields, which is what used to break: a queued or
|
|
13
|
+
* retried send read `a.file.name` and threw, and the image payload filter
|
|
14
|
+
* matched on a `data:` preview the composer never produces.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, expect, test } from 'bun:test';
|
|
18
|
+
import type { Attachment } from '@/shared/types';
|
|
19
|
+
import {
|
|
20
|
+
applyTextAttachmentBudget,
|
|
21
|
+
attachmentImage,
|
|
22
|
+
attachmentName,
|
|
23
|
+
attachmentSize,
|
|
24
|
+
attachmentType,
|
|
25
|
+
composeMessageWithTextAttachments,
|
|
26
|
+
isImageAttachment,
|
|
27
|
+
isTextAttachment,
|
|
28
|
+
looksLikeTextFile,
|
|
29
|
+
prepareQueuedAttachments,
|
|
30
|
+
readTextAttachments,
|
|
31
|
+
} from '@/shared/lib/chat/attachments';
|
|
32
|
+
|
|
33
|
+
function liveFile(name: string, body: string, type = ''): File {
|
|
34
|
+
return new File([body], name, { type });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function liveAttachment(overrides: Partial<Attachment> & { id: string }): Attachment {
|
|
38
|
+
return { preview: '', ...overrides };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** What the queue row and committed history actually hold. */
|
|
42
|
+
function replayed(attachments: Attachment[]): Attachment[] {
|
|
43
|
+
return JSON.parse(JSON.stringify(attachments)) as Attachment[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('attachment metadata accessors', () => {
|
|
47
|
+
test('read a live file, and fall back to the persisted fields', () => {
|
|
48
|
+
const live = liveAttachment({ id: 'a', name: 'notes.md', type: 'text/markdown', size: 99, file: liveFile('notes.md', '# hi', 'text/markdown') });
|
|
49
|
+
expect(attachmentName(live)).toBe('notes.md');
|
|
50
|
+
expect(attachmentType(live)).toBe('text/markdown');
|
|
51
|
+
expect(attachmentSize(live)).toBe(4); // the live handle is authoritative
|
|
52
|
+
|
|
53
|
+
const replay = replayed([live])[0];
|
|
54
|
+
// `JSON.stringify` renders a File as an empty object — not as undefined —
|
|
55
|
+
// so the replay path must treat a truthiness check as unreliable and read
|
|
56
|
+
// the display fields first.
|
|
57
|
+
expect(replay.file as unknown).toEqual({});
|
|
58
|
+
expect(attachmentName(replay)).toBe('notes.md');
|
|
59
|
+
expect(attachmentType(replay)).toBe('text/markdown');
|
|
60
|
+
expect(attachmentSize(replay)).toBe(99);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('never throw on an attachment with neither a file nor a name', () => {
|
|
64
|
+
const bare = { id: 'x', preview: '' } as Attachment;
|
|
65
|
+
expect(attachmentName(bare)).toBe('attachment');
|
|
66
|
+
expect(attachmentType(bare)).toBe('');
|
|
67
|
+
expect(attachmentSize(bare)).toBe(0);
|
|
68
|
+
expect(isTextAttachment(bare)).toBe(false);
|
|
69
|
+
expect(attachmentImage(bare)).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('text vs image classification', () => {
|
|
74
|
+
test('code files with no browser MIME type still inline', () => {
|
|
75
|
+
// `File.type` is empty for most source files, so the extension decides.
|
|
76
|
+
for (const name of ['script.py', 'app.tsx', 'query.sql', 'Dockerfile.json', 'data.yaml']) {
|
|
77
|
+
expect(isTextAttachment({ name, type: '', file: liveFile(name, 'x') })).toBe(true);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('a browser-reported text MIME type is enough on its own', () => {
|
|
82
|
+
expect(isTextAttachment({ name: 'LICENSE', type: 'text/plain', file: liveFile('LICENSE', 'x', 'text/plain') })).toBe(true);
|
|
83
|
+
expect(isTextAttachment({ name: 'blob.bin', type: 'application/json', file: liveFile('blob.bin', '{}', 'application/json') })).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('binaries and images never consume the inline budget', () => {
|
|
87
|
+
expect(isTextAttachment({ name: 'app.dmg', type: 'application/octet-stream', file: liveFile('app.dmg', 'x', 'application/octet-stream') })).toBe(false);
|
|
88
|
+
// An image whose name happens to look textual is still an image.
|
|
89
|
+
expect(isTextAttachment({ name: 'trap.md', type: 'image/png', file: liveFile('trap.md', 'x', 'image/png') })).toBe(false);
|
|
90
|
+
expect(isImageAttachment({ name: 'trap.md', type: 'image/png', file: liveFile('trap.md', 'x', 'image/png') })).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('image payloads', () => {
|
|
95
|
+
test('carry the omp ImageContent type and the persisted MIME type', () => {
|
|
96
|
+
const payload = attachmentImage({
|
|
97
|
+
type: 'image/png',
|
|
98
|
+
dataBase64: 'iVBORw0KGgo=',
|
|
99
|
+
});
|
|
100
|
+
expect(payload).toEqual({ type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('a replay with a blob preview still yields its payload', () => {
|
|
104
|
+
// The old server-side filter matched `preview.startsWith('data:image/')`
|
|
105
|
+
// and so found nothing: the composer's preview is a blob URL.
|
|
106
|
+
const [replay] = replayed([
|
|
107
|
+
liveAttachment({ id: 'i', name: 's.png', type: 'image/png', preview: 'blob:http://x/1', dataBase64: 'AAAA', file: liveFile('s.png', 'x', 'image/png') }),
|
|
108
|
+
]);
|
|
109
|
+
expect(attachmentImage(replay)?.data).toBe('AAAA');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('an unread image yields nothing rather than a blank payload', () => {
|
|
113
|
+
expect(attachmentImage({ type: 'image/png', dataBase64: '' })).toBeNull();
|
|
114
|
+
expect(attachmentImage({ type: 'image/png' })).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('text contents', () => {
|
|
119
|
+
test('are read from a live file, and from `content` on a replay', async () => {
|
|
120
|
+
const live = [liveAttachment({ id: 't', name: 'notes.md', type: 'text/markdown', file: liveFile('notes.md', '# hi', 'text/markdown') })];
|
|
121
|
+
expect((await readTextAttachments(live)).map((f) => f.content)).toEqual(['# hi']);
|
|
122
|
+
|
|
123
|
+
const replay = replayed(await prepareQueuedAttachments(live));
|
|
124
|
+
expect(replay[0].file).toBeUndefined();
|
|
125
|
+
expect(replay[0].content).toBe('# hi');
|
|
126
|
+
expect((await readTextAttachments(replay)).map((f) => f.content)).toEqual(['# hi']);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('a replay without a persisted copy is flagged, not silently dropped', async () => {
|
|
130
|
+
// It used to be filtered out, which sent a prompt naming a file whose
|
|
131
|
+
// contents never arrived — with nothing in the UI to say so.
|
|
132
|
+
const files = await readTextAttachments([
|
|
133
|
+
{ id: 't', name: 'notes.md', type: 'text/markdown', preview: '' },
|
|
134
|
+
]);
|
|
135
|
+
expect(files.map((f) => ({ name: f.name, missing: f.missing, content: f.content }))).toEqual([
|
|
136
|
+
{ name: 'notes.md', missing: true, content: '' },
|
|
137
|
+
]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('carry their attachment id so the caller can map them back', async () => {
|
|
141
|
+
const files = await readTextAttachments([
|
|
142
|
+
{ id: 'keep', name: 'a.md', type: 'text/markdown', preview: '', content: 'a' },
|
|
143
|
+
{ id: 'drop', name: 'b.bin', type: 'application/octet-stream', preview: '', content: 'b' },
|
|
144
|
+
]);
|
|
145
|
+
expect(files.map((f) => f.id)).toEqual(['keep']);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('are fenced with a language that matches the file', () => {
|
|
149
|
+
const py = [{ name: 'script.py', mimeType: 'text/x-python', content: 'x=1', size: 3 }];
|
|
150
|
+
expect(composeMessageWithTextAttachments('go', py)).toContain('```python');
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('inline budget', () => {
|
|
155
|
+
test('counts replayed attachments, so repeated drops stop at the cap', () => {
|
|
156
|
+
const existing = Array.from({ length: 10 }, (_, i) => ({ name: `f${i}.md`, type: 'text/markdown', size: 10, content: 'x' }));
|
|
157
|
+
const { accepted, skipped } = applyTextAttachmentBudget([liveFile('extra.md', 'x', 'text/markdown')], existing);
|
|
158
|
+
expect(accepted).toEqual([]);
|
|
159
|
+
expect(skipped.map((f) => f.name)).toEqual(['extra.md']);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('lets an image through when the text budget is spent', () => {
|
|
163
|
+
const existing = Array.from({ length: 10 }, (_, i) => ({ name: `f${i}.md`, type: 'text/markdown', size: 10, content: 'x' }));
|
|
164
|
+
const { accepted, skipped } = applyTextAttachmentBudget([liveFile('shot.png', 'x', 'image/png')], existing);
|
|
165
|
+
expect(accepted.map((f) => f.name)).toEqual(['shot.png']);
|
|
166
|
+
expect(skipped).toEqual([]);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe('content sniffing for unclassified files', () => {
|
|
171
|
+
test('a file whose name and MIME say nothing is recognised as text', async () => {
|
|
172
|
+
// A promised-file drag from another app arrives like this: real bytes, a
|
|
173
|
+
// generic name, no usable type. Deciding from metadata alone left it
|
|
174
|
+
// attached but never inlined.
|
|
175
|
+
expect(await looksLikeTextFile(liveFile('document', 'IMPORTANT\ncode: ABC'))).toBe(true);
|
|
176
|
+
expect(await looksLikeTextFile(liveFile('file', '# Title\n\nBody'))).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('binary content is refused', async () => {
|
|
180
|
+
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
181
|
+
expect(await looksLikeTextFile(new File([png], 'download'))).toBe(false);
|
|
182
|
+
// A NUL byte is decisive: no text encoding this app handles contains one.
|
|
183
|
+
expect(await looksLikeTextFile(new File([new Uint8Array([65, 66, 0, 67])], 'blob'))).toBe(false);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('an empty file is not treated as text', async () => {
|
|
187
|
+
expect(await looksLikeTextFile(liveFile('empty', ''))).toBe(false);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('a sniffed attachment keeps its content through the read path', async () => {
|
|
191
|
+
// The sniffer's verdict is recorded on the attachment at attach time, so the
|
|
192
|
+
// send path must honour it even though the name still says nothing — and a
|
|
193
|
+
// bare `content` string must NOT be enough to smuggle a binary through.
|
|
194
|
+
const sniffed: Attachment = {
|
|
195
|
+
id: 'x',
|
|
196
|
+
name: 'document',
|
|
197
|
+
type: '',
|
|
198
|
+
size: 20,
|
|
199
|
+
preview: '',
|
|
200
|
+
content: 'IMPORTANT\ncode: ABC',
|
|
201
|
+
sniffedText: true,
|
|
202
|
+
};
|
|
203
|
+
const read = await readTextAttachments([sniffed]);
|
|
204
|
+
expect(read.map((f) => ({ name: f.name, content: f.content }))).toEqual([
|
|
205
|
+
{ name: 'document', content: 'IMPORTANT\ncode: ABC' },
|
|
206
|
+
]);
|
|
207
|
+
});
|
|
208
|
+
});
|