ompchamber 1.2.1 → 2.0.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/README.md +131 -147
- package/dist/client/index.html +4 -2
- package/dist/client/static/css/index.5f630421b7.css +2 -0
- package/dist/client/static/js/77.0d81c74cf0.js +139 -0
- package/dist/client/static/js/async/1k.973e8e21fc.js +2 -0
- package/dist/client/static/js/async/{3y.3186ecdd63.js → 3y.3530b824ad.js} +6 -6
- package/dist/client/static/js/async/{q.b453193538.js → 4m.928a1dde85.js} +10 -10
- package/dist/client/static/js/async/66.5dfa29f957.js +93 -0
- package/dist/client/static/js/async/7g.a7814350c3.js +1 -0
- package/dist/client/static/js/async/ar.9303e5f2e8.js +2 -0
- package/dist/client/static/js/async/{cx.9c8268d542.js → cx.3cb0242a3a.js} +1 -1
- package/dist/client/static/js/async/e.93214d622b.js +79 -0
- package/dist/client/static/js/async/g.74ff36419f.js +2 -0
- package/dist/client/static/js/async/{m.2e1ea3db9e.js.LICENSE.txt → g.74ff36419f.js.LICENSE.txt} +5 -0
- package/dist/client/static/js/async/h1.393f066d09.js +1 -0
- package/dist/client/static/js/async/jl.874d352278.js +1 -0
- package/dist/client/static/js/async/{k.d16cd09ae7.js → k.3210286401.js} +1 -1
- package/dist/client/static/js/async/{m.2e1ea3db9e.js → m.eb2e6752e2.js} +3 -3
- package/dist/client/static/js/async/m.eb2e6752e2.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/mt.0352e976d8.js +1 -0
- package/dist/client/static/js/async/{oc.eb22093f05.js → oc.b73042e8bd.js} +1 -1
- package/dist/client/static/js/async/{og.53f068804c.js → og.8eb577893a.js} +1 -1
- package/dist/client/static/js/async/px.fc6877924b.js +1 -0
- package/dist/client/static/js/async/q.33d6779f98.js +21 -0
- package/dist/client/static/js/async/{q5.88b50ffa95.js → q5.97dc1481b6.js} +1 -1
- package/dist/client/static/js/async/s.0f395cdda5.js +94 -0
- package/dist/client/static/js/async/{tr.2a0d2b7600.js → tr.e878658140.js} +1 -1
- package/dist/client/static/js/async/vt.42416b707d.js +1 -0
- package/dist/client/static/js/async/x1.da8687159b.js +90 -0
- package/dist/client/static/js/async/xe.da8dcc2015.js +2 -0
- package/dist/client/static/js/async/xe.da8dcc2015.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/{xn.6360379f71.js → xn.6510c0abe2.js} +2 -2
- package/dist/client/static/js/async/yg.35bebd6d0c.js +149 -0
- package/dist/client/static/js/async/yv.d99d829136.js +1 -0
- package/dist/client/static/js/index.c05535dbde.js +38 -0
- package/package.json +3 -3
- package/src/client/components/common/ComposerTextarea.tsx +9 -1
- package/src/client/components/common/MarkdownRenderer.tsx +50 -12
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +9 -3
- package/src/client/components/settings/Modal.tsx +11 -2
- package/src/client/components/settings/categories/AppearanceSettings.tsx +7 -30
- package/src/client/components/settings/categories/ProviderSettings.tsx +2 -0
- package/src/client/components/settings/categories/appearance-settings/ThemeCard.tsx +61 -0
- package/src/client/components/settings/categories/appearance-settings/ThemeGrid.tsx +80 -0
- package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +1 -1
- package/src/client/components/settings/categories/provider-settings/AddProviderModal.tsx +43 -6
- package/src/client/components/settings/categories/provider-settings/ModelConfigModal.tsx +54 -54
- package/src/client/components/workspace/SearchPanel.tsx +16 -3
- package/src/client/components/workspace/btw-panel/AskCard.tsx +11 -6
- package/src/client/components/workspace/btw-panel/Messages.tsx +105 -51
- package/src/client/components/workspace/btw-panel/index.tsx +124 -45
- package/src/client/components/workspace/chat-timeline/AiMessageFooter.tsx +30 -14
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +10 -1
- package/src/client/components/workspace/chat-timeline/GeneratingIndicator.tsx +8 -2
- package/src/client/components/workspace/chat-timeline/MessageItem.tsx +27 -16
- package/src/client/components/workspace/chat-timeline/MessageList.tsx +11 -0
- package/src/client/components/workspace/chat-timeline/RunFooter.tsx +8 -0
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +12 -1
- package/src/client/components/workspace/chat-timeline/index.tsx +7 -2
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +42 -15
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +13 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/index.tsx +13 -4
- package/src/client/components/workspace/editor/index.tsx +11 -2
- package/src/client/components/workspace/file-explorer/GitRepoDropdown.tsx +15 -41
- package/src/client/components/workspace/file-explorer/index.tsx +86 -16
- package/src/client/components/workspace/git-panel/index.tsx +60 -78
- package/src/client/components/workspace/terminal-panel/Header.tsx +16 -1
- package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +5 -5
- package/src/client/components/workspace/terminal-panel/index.tsx +6 -2
- package/src/client/components/workspace/terminal-panel/mount.ts +2 -2
- package/src/client/data/theme/terminal.ts +50 -52
- package/src/client/hooks/chat/btw/index.ts +139 -18
- package/src/client/hooks/chat/btw/intercept.ts +8 -8
- package/src/client/hooks/chat/btw/mode.ts +111 -16
- package/src/client/hooks/chat/composer/index.ts +10 -4
- package/src/client/hooks/chat/timeline/access-mode.ts +41 -0
- package/src/client/hooks/chat/timeline/ask-frames.test.ts +87 -0
- package/src/client/hooks/chat/timeline/ask-frames.ts +20 -1
- package/src/client/hooks/chat/timeline/auto-scroll.ts +13 -4
- package/src/client/hooks/chat/timeline/index.ts +8 -20
- package/src/client/hooks/chat/timeline/queue.ts +66 -9
- package/src/client/hooks/chat/timeline/scroll.test.ts +260 -0
- package/src/client/hooks/chat/timeline/scroll.ts +93 -20
- package/src/client/hooks/chat/timeline/session-load.ts +7 -1
- package/src/client/hooks/chat/timeline/session-pagination.test.ts +167 -0
- package/src/client/hooks/chat/timeline/session-pagination.ts +37 -11
- package/src/client/hooks/settings/providers.ts +34 -3
- package/src/client/hooks/ui/open-file.ts +7 -1
- package/src/client/hooks/ui/theme.ts +82 -57
- package/src/client/hooks/workspace/file-tabs.ts +5 -1
- package/src/client/hooks/workspace/repo-scope/index.ts +119 -0
- package/src/client/hooks/workspace/repo-scope/store.test.ts +161 -0
- package/src/client/hooks/workspace/repo-scope/store.ts +201 -0
- package/src/client/hooks/workspace/search-stream.ts +24 -5
- package/src/client/main.tsx +6 -0
- package/src/client/tailwind.css +19 -56
- package/src/server/lib/btw/child.server.ts +41 -0
- package/src/server/lib/btw/lifecycle.server.ts +254 -0
- package/src/server/lib/btw/prompt.ts +13 -5
- package/src/server/lib/btw/registry.server.ts +13 -4
- package/src/server/lib/btw/runtime.server.ts +149 -156
- package/src/server/lib/btw/schema.server.ts +27 -0
- package/src/server/lib/btw/service.server.ts +106 -5
- package/src/server/lib/btw/store.server.ts +57 -6
- package/src/server/lib/db/schema.ts +4 -0
- package/src/server/lib/db/settings-store.test.ts +65 -0
- package/src/server/lib/db/settings-store.ts +25 -0
- package/src/server/lib/fs/atomic-write.ts +42 -4
- package/src/server/lib/fs/config-lock.ts +74 -0
- package/src/server/lib/fs/git-ignore.ts +55 -5
- package/src/server/lib/fs/ripgrep.ts +119 -0
- package/src/server/lib/lifecycle/identity.ts +10 -64
- package/src/server/lib/lifecycle/proc/darwin.ts +238 -0
- package/src/server/lib/lifecycle/proc/index.ts +38 -0
- package/src/server/lib/lifecycle/proc/linux.ts +80 -0
- package/src/server/lib/lifecycle/proc/ps.ts +86 -0
- package/src/server/lib/lifecycle/proc/types.ts +33 -0
- package/src/server/lib/lifecycle/proc/win32.ts +53 -0
- package/src/server/lib/models/provider-registry.server.ts +2 -2
- package/src/server/lib/models/registry.server.ts +1 -1
- package/src/server/lib/omp/config/behavior.ts +17 -17
- package/src/server/lib/omp/config/disabled-providers.ts +84 -35
- package/src/server/lib/omp/config/document.ts +128 -0
- package/src/server/lib/omp/config/extensions.ts +11 -10
- package/src/server/lib/omp/config/mcp.ts +4 -57
- package/src/server/lib/omp/config/model-overrides.ts +123 -0
- package/src/server/lib/omp/config/models-config.ts +92 -0
- package/src/server/lib/omp/config/models-validation.ts +159 -0
- package/src/server/lib/omp/config/path-scoped.ts +74 -0
- package/src/server/lib/omp/config/providers.ts +241 -211
- package/src/server/lib/omp/config/roles.ts +6 -14
- package/src/server/lib/omp/core/auth-credentials.ts +1 -1
- package/src/server/lib/omp/rpc/frame-fold.test.ts +124 -0
- package/src/server/lib/omp/rpc/frame-fold.ts +43 -24
- package/src/server/lib/omp/rpc/lifecycle.test.ts +41 -0
- package/src/server/lib/omp/rpc/manager.ts +21 -21
- package/src/server/lib/omp/rpc/pending-ui-dialogs.ts +11 -7
- package/src/server/lib/omp/rpc/session-commands.ts +17 -29
- package/src/server/lib/omp/rpc/session-registry.ts +5 -66
- package/src/server/lib/omp/rpc/web-state.ts +0 -6
- package/src/server/lib/omp/session/auto-title.server.test.ts +109 -11
- package/src/server/lib/omp/session/auto-title.server.ts +95 -20
- package/src/server/lib/omp/session/sidebar-data.server.ts +11 -6
- package/src/server/lib/queue/delivery-dispatch.server.test.ts +162 -0
- package/src/server/lib/queue/delivery-harness.ts +88 -0
- package/src/server/lib/queue/delivery.server.test.ts +78 -149
- package/src/server/lib/queue/delivery.server.ts +105 -13
- package/src/server/lib/queue/store.server.ts +30 -5
- package/src/server/lib/terminal/foreground.test.ts +164 -0
- package/src/server/lib/terminal/foreground.ts +34 -6
- package/src/server/lib/terminal/history.ts +8 -7
- package/src/server/plugins/compress.ts +33 -5
- package/src/server/plugins/ssr.ts +14 -7
- package/src/server/routes/btw/topics.ts +89 -2
- package/src/server/routes/fs/git.ts +5 -6
- package/src/server/routes/fs/search.ts +17 -26
- package/src/server/routes/sessions/queue.ts +17 -4
- package/src/server/routes/settings/index.ts +2 -0
- package/src/server/routes/settings/model-override.ts +69 -0
- package/src/server/routes/settings/provider-models.ts +35 -12
- package/src/server/routes/settings/providers.ts +31 -1
- package/src/server/routes/settings/root.ts +8 -3
- package/src/shared/lib/chat/btw/client.ts +34 -0
- package/src/shared/lib/chat/timeline/tool-phrases.ts +7 -0
- package/src/shared/lib/code/shiki-themes.ts +9 -5
- package/src/shared/lib/fs/file-tree.ts +17 -12
- package/src/shared/lib/markdown/autolink.test.ts +32 -0
- package/src/shared/lib/markdown/autolink.ts +50 -0
- package/src/shared/lib/markdown/document-urls.test.ts +68 -0
- package/src/shared/lib/markdown/document-urls.ts +118 -0
- package/src/shared/lib/markdown/marked.ts +27 -15
- package/src/shared/lib/markdown/mermaid.ts +2 -2
- package/src/shared/lib/models/catalog.ts +1 -1
- package/src/shared/lib/omp/session/schema-migrations.ts +30 -0
- package/src/shared/lib/omp/session/stream-state.server.test.ts +54 -0
- package/src/shared/lib/omp/session/stream-state.server.ts +70 -21
- package/src/shared/lib/settings/client.ts +10 -4
- package/src/shared/lib/settings/diff.test.ts +31 -0
- package/src/shared/lib/settings/diff.ts +37 -0
- package/src/shared/lib/theme/catalog.test.ts +168 -0
- package/src/shared/lib/theme/catalog.ts +98 -0
- package/src/shared/lib/theme/color.ts +88 -0
- package/src/shared/lib/theme/css.ts +70 -0
- package/src/shared/lib/theme/palettes/chamber.ts +61 -0
- package/src/shared/lib/theme/palettes/dark.ts +313 -0
- package/src/shared/lib/theme/palettes/light.ts +313 -0
- package/src/shared/types/btw.ts +83 -4
- package/src/shared/types/index.ts +1 -0
- package/src/shared/types/settings/provider.ts +8 -1
- package/src/shared/types/settings/state.ts +10 -4
- package/src/shared/types/theme.ts +48 -0
- package/dist/client/static/css/index.b938f4a693.css +0 -2
- package/dist/client/static/js/77.8022a800b5.js +0 -139
- package/dist/client/static/js/async/1k.a411fe5b43.js +0 -2
- package/dist/client/static/js/async/66.86544ad741.js +0 -93
- package/dist/client/static/js/async/7g.db82aca7ba.js +0 -1
- package/dist/client/static/js/async/ar.ad5c8822a8.js +0 -2
- package/dist/client/static/js/async/e.6443e739a2.js +0 -79
- package/dist/client/static/js/async/g.ee41bfed7f.js +0 -2
- package/dist/client/static/js/async/h1.d7091cef78.js +0 -1
- package/dist/client/static/js/async/jl.4911fa3383.js +0 -1
- package/dist/client/static/js/async/mt.853be2b5aa.js +0 -1
- package/dist/client/static/js/async/ot.84c71b308f.js +0 -2
- package/dist/client/static/js/async/px.783b6f91f2.js +0 -1
- package/dist/client/static/js/async/q8.7f1b48e9cd.js +0 -21
- package/dist/client/static/js/async/q8.7f1b48e9cd.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/s.aad2ecebc6.js +0 -94
- package/dist/client/static/js/async/vt.20406a5604.js +0 -1
- package/dist/client/static/js/async/x1.fe8af17016.js +0 -90
- package/dist/client/static/js/async/xn.6360379f71.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/yg.5a94e1fc8a.js +0 -149
- package/dist/client/static/js/async/yv.2fa22f009a.js +0 -1
- package/dist/client/static/js/index.5fe7a2cb64.js +0 -34
- package/src/client/components/workspace/btw-panel/Composer.tsx +0 -183
- package/src/client/components/workspace/btw-panel/attachments.ts +0 -80
- /package/dist/client/static/js/{77.8022a800b5.js.LICENSE.txt → 77.0d81c74cf0.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{g.ee41bfed7f.js.LICENSE.txt → 1k.973e8e21fc.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{3y.3186ecdd63.js.LICENSE.txt → 3y.3530b824ad.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{ot.84c71b308f.js.LICENSE.txt → 4m.928a1dde85.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{q.b453193538.js.LICENSE.txt → ar.9303e5f2e8.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{1k.a411fe5b43.js.LICENSE.txt → q.33d6779f98.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{ar.ad5c8822a8.js.LICENSE.txt → xn.6510c0abe2.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{index.5fe7a2cb64.js.LICENSE.txt → index.c05535dbde.js.LICENSE.txt} +0 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { json } from '@/server/lib/remix-compat';
|
|
2
|
+
import type { ActionFunctionArgs } from '@/server/lib/remix-compat';
|
|
3
|
+
import { methodNotAllowed } from '@/server/lib/route-adapter';
|
|
4
|
+
import { isMockMode } from '@/server/mock.server';
|
|
5
|
+
import { writeModelOverride, type ReasoningEffort } from '@/server/lib/omp/config/model-overrides';
|
|
6
|
+
import { invalidateModelsCaches } from '@/shared/lib/models/server-cache';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* PUT /api/settings/model-override — the per-model knobs omp actually honours.
|
|
10
|
+
*
|
|
11
|
+
* omp resolves `maxTokens` and the reasoning `thinking` block per model from
|
|
12
|
+
* models.yml `modelOverrides`, and has no per-model temperature/topP at all
|
|
13
|
+
* (those exist only as global `config.yml` settings, editable in Settings →
|
|
14
|
+
* OMP Engine). Writing the chamber's model dialog anywhere else made its
|
|
15
|
+
* controls inert, so this route is the one that reaches the agent.
|
|
16
|
+
*
|
|
17
|
+
* Body: { provider, modelId, maxTokens?: number|null, reasoningEffort?: string|null }
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const EFFORTS: readonly ReasoningEffort[] = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
|
|
21
|
+
|
|
22
|
+
export async function action({ request, params }: ActionFunctionArgs) {
|
|
23
|
+
if (request.method !== 'PUT' && request.method !== 'POST') {
|
|
24
|
+
return methodNotAllowed({ request, params });
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const body = await request.json() as {
|
|
28
|
+
provider?: unknown;
|
|
29
|
+
modelId?: unknown;
|
|
30
|
+
maxTokens?: unknown;
|
|
31
|
+
reasoningEffort?: unknown;
|
|
32
|
+
};
|
|
33
|
+
const provider = typeof body.provider === 'string' ? body.provider.trim() : '';
|
|
34
|
+
const modelId = typeof body.modelId === 'string' ? body.modelId.trim() : '';
|
|
35
|
+
if (!provider || !modelId) {
|
|
36
|
+
return json({ error: 'provider and modelId are required' }, { status: 400 });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// `null` means "clear the override"; `undefined` means "leave it alone".
|
|
40
|
+
let maxTokens: number | null | undefined;
|
|
41
|
+
if (body.maxTokens === null) maxTokens = null;
|
|
42
|
+
else if (typeof body.maxTokens === 'number' && Number.isFinite(body.maxTokens) && body.maxTokens > 0) {
|
|
43
|
+
maxTokens = Math.round(body.maxTokens);
|
|
44
|
+
} else if (body.maxTokens !== undefined) {
|
|
45
|
+
return json({ error: 'maxTokens must be a positive number or null' }, { status: 400 });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let reasoningEffort: ReasoningEffort | null | undefined;
|
|
49
|
+
if (body.reasoningEffort === null) reasoningEffort = null;
|
|
50
|
+
else if (typeof body.reasoningEffort === 'string' && (EFFORTS as readonly string[]).includes(body.reasoningEffort)) {
|
|
51
|
+
reasoningEffort = body.reasoningEffort as ReasoningEffort;
|
|
52
|
+
} else if (body.reasoningEffort !== undefined) {
|
|
53
|
+
return json({ error: `reasoningEffort must be one of ${EFFORTS.join(', ')} or null` }, { status: 400 });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (maxTokens === undefined && reasoningEffort === undefined) {
|
|
57
|
+
return json({ error: 'nothing to write' }, { status: 400 });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Mock mode has no models.yml to edit; the overlay already carries the value.
|
|
61
|
+
if (isMockMode()) return json({ success: true, written: false, isMock: true });
|
|
62
|
+
|
|
63
|
+
const result = await writeModelOverride({ provider, modelId, maxTokens, reasoningEffort });
|
|
64
|
+
if (result.written) invalidateModelsCaches();
|
|
65
|
+
return json({ success: result.written, ...result });
|
|
66
|
+
} catch (error) {
|
|
67
|
+
return json({ error: error instanceof Error ? error.message : String(error) }, { status: 400 });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -124,10 +124,26 @@ async function fetchRemoteModels(baseUrl: string, apiKey?: string): Promise<Fetc
|
|
|
124
124
|
};
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
* omp's `api` for a custom provider, inferred from its endpoint. omp accepts
|
|
129
|
+
* eleven `api` values and there is no way to ask a gateway which one it speaks,
|
|
130
|
+
* so the chamber only claims the two dialects it can recognise by URL; every
|
|
131
|
+
* other endpoint gets the OpenAI-compatible default, which is what the
|
|
132
|
+
* model-listing probe already assumes. A gateway that speaks something else
|
|
133
|
+
* (Azure, Bedrock, Vertex, …) needs its `api` set by hand in models.yml.
|
|
134
|
+
*/
|
|
135
|
+
function inferProviderApi(baseUrl: string): 'openai-completions' | 'anthropic-messages' | 'google-generative-ai' {
|
|
136
|
+
if (/anthropic\./i.test(baseUrl)) return 'anthropic-messages';
|
|
137
|
+
if (/generativelanguage\.googleapis\.com/i.test(baseUrl)) return 'google-generative-ai';
|
|
138
|
+
return 'openai-completions';
|
|
139
|
+
}
|
|
140
|
+
|
|
127
141
|
/**
|
|
128
142
|
* omp's models.yml schema requires all four cost fields when cost is present,
|
|
129
143
|
* so a price is only seeded when both sides are known; missing ones default
|
|
130
144
|
* to 0. Context/output labels ("128K ctx · 16K out") parse back to tokens.
|
|
145
|
+
* Cache prices come from the catalog and are kept when known — dropping them
|
|
146
|
+
* makes every cached turn look free in omp's usage accounting.
|
|
131
147
|
*/
|
|
132
148
|
function toOmpSeed(model: ProviderModel): {
|
|
133
149
|
id: string;
|
|
@@ -148,7 +164,14 @@ function toOmpSeed(model: ProviderModel): {
|
|
|
148
164
|
};
|
|
149
165
|
const contextWindow = parseTokens(model.contextWindow, 'ctx');
|
|
150
166
|
const outTokens = parseTokens(model.contextWindow, 'out');
|
|
151
|
-
|
|
167
|
+
// omp rejects the whole file on a non-finite number, so a price has to be a
|
|
168
|
+
// real finite value before it may be written at all.
|
|
169
|
+
const price = (value: unknown): number | undefined => (
|
|
170
|
+
typeof value === 'number' && Number.isFinite(value) ? value : undefined
|
|
171
|
+
);
|
|
172
|
+
const priceInput = price(model.priceInput);
|
|
173
|
+
const priceOutput = price(model.priceOutput);
|
|
174
|
+
const hasCost = priceInput !== undefined && priceOutput !== undefined;
|
|
152
175
|
return {
|
|
153
176
|
id: model.id,
|
|
154
177
|
...(model.name && model.name !== model.id ? { name: model.name } : {}),
|
|
@@ -158,10 +181,10 @@ function toOmpSeed(model: ProviderModel): {
|
|
|
158
181
|
...(outTokens && outTokens > 0 ? { maxTokens: Math.round(outTokens) } : {}),
|
|
159
182
|
...(hasCost ? {
|
|
160
183
|
cost: {
|
|
161
|
-
input:
|
|
162
|
-
output:
|
|
163
|
-
cacheRead: 0,
|
|
164
|
-
cacheWrite: 0,
|
|
184
|
+
input: priceInput,
|
|
185
|
+
output: priceOutput,
|
|
186
|
+
cacheRead: price(model.priceCacheRead) ?? 0,
|
|
187
|
+
cacheWrite: price(model.priceCacheWrite) ?? 0,
|
|
165
188
|
},
|
|
166
189
|
} : {}),
|
|
167
190
|
};
|
|
@@ -170,8 +193,8 @@ function toOmpSeed(model: ProviderModel): {
|
|
|
170
193
|
/**
|
|
171
194
|
* Fill in metadata the listing endpoint did not provide from the models.dev
|
|
172
195
|
* catalog: vision/reasoning/tool flags, context + output window, and
|
|
173
|
-
* per-1M-token pricing. Only empty fields are filled —
|
|
174
|
-
* reported itself is kept.
|
|
196
|
+
* per-1M-token pricing (cache rates included). Only empty fields are filled —
|
|
197
|
+
* anything the provider reported itself is kept.
|
|
175
198
|
*/
|
|
176
199
|
async function enrichFromCatalog(
|
|
177
200
|
models: ProviderModel[],
|
|
@@ -201,6 +224,10 @@ async function enrichFromCatalog(
|
|
|
201
224
|
hasTools: model.hasTools || info.tool_call === true,
|
|
202
225
|
priceInput: model.priceInput ?? (typeof info.cost?.input === 'number' ? info.cost.input : undefined),
|
|
203
226
|
priceOutput: model.priceOutput ?? (typeof info.cost?.output === 'number' ? info.cost.output : undefined),
|
|
227
|
+
priceCacheRead: model.priceCacheRead
|
|
228
|
+
?? (typeof info.cost?.cache_read === 'number' ? info.cost.cache_read : undefined),
|
|
229
|
+
priceCacheWrite: model.priceCacheWrite
|
|
230
|
+
?? (typeof info.cost?.cache_write === 'number' ? info.cost.cache_write : undefined),
|
|
204
231
|
};
|
|
205
232
|
});
|
|
206
233
|
}
|
|
@@ -244,11 +271,7 @@ export async function action({ request }: ActionFunctionArgs) {
|
|
|
244
271
|
const upsert = await upsertOmpProviderModels(providerSlug, {
|
|
245
272
|
baseUrl,
|
|
246
273
|
apiKey,
|
|
247
|
-
api:
|
|
248
|
-
? 'anthropic-messages'
|
|
249
|
-
: /generativelanguage\.googleapis\.com/i.test(baseUrl)
|
|
250
|
-
? 'google-generative-ai'
|
|
251
|
-
: 'openai-completions',
|
|
274
|
+
api: inferProviderApi(baseUrl),
|
|
252
275
|
models: enriched.map(toOmpSeed),
|
|
253
276
|
});
|
|
254
277
|
if (upsert.written) invalidateModelsCaches();
|
|
@@ -7,6 +7,7 @@ import { isMockMode } from '@/server/mock.server';
|
|
|
7
7
|
import type { ProviderItem } from '@/shared/types';
|
|
8
8
|
import { createSettingsListStore } from '@/server/lib/db/settings-store';
|
|
9
9
|
import { disableNativeProvider, enableNativeProvider } from '@/server/lib/omp/config/disabled-providers';
|
|
10
|
+
import { removeOmpProvider } from '@/server/lib/omp/config/providers';
|
|
10
11
|
import { PROVIDERS_SETTINGS_KEY as SETTINGS_KEY, deduplicateProviderItems, mergeProviders } from '@/server/lib/models/provider-registry.server';
|
|
11
12
|
import { invalidateModelsCaches } from '@/shared/lib/models/server-cache';
|
|
12
13
|
|
|
@@ -111,8 +112,37 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
|
|
111
112
|
const id = url.searchParams.get('id');
|
|
112
113
|
if (!id) return json({ error: 'id is required' }, { status: 400 });
|
|
113
114
|
|
|
115
|
+
const stored = await readStoredProviders();
|
|
116
|
+
const target = stored.find((provider) => provider.id === id);
|
|
114
117
|
const list = await providersStore.remove(db, id, { absent: DEFAULT_PROVIDERS_LIST, empty: DEFAULT_PROVIDERS_LIST });
|
|
115
|
-
|
|
118
|
+
|
|
119
|
+
// Removing the overlay row alone left the provider registered in
|
|
120
|
+
// models.yml, so omp kept serving it and the next load merged it straight
|
|
121
|
+
// back into this list — a delete that visibly did nothing. Unregister it
|
|
122
|
+
// from the agent's own registry too.
|
|
123
|
+
let removedFromOmp = false;
|
|
124
|
+
if (target?.slug && !isMockMode()) {
|
|
125
|
+
try {
|
|
126
|
+
removedFromOmp = (await removeOmpProvider(target.slug)).removed;
|
|
127
|
+
// A provider omp still holds in disabledProviders would linger as a
|
|
128
|
+
// "disconnected" ghost entry after its models are gone.
|
|
129
|
+
await enableNativeProvider(target.slug);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
// The overlay row is already gone; report the native failure instead
|
|
132
|
+
// of failing the whole request, so the UI can tell the user the entry
|
|
133
|
+
// is still in models.yml.
|
|
134
|
+
const merged = await mergeProviders(list).catch(() => null);
|
|
135
|
+
return json({
|
|
136
|
+
success: true,
|
|
137
|
+
providers: merged?.providers ?? list,
|
|
138
|
+
removedFromOmp: false,
|
|
139
|
+
warning: error instanceof Error ? error.message : 'models.yml cleanup failed',
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
invalidateModelsCaches();
|
|
144
|
+
const merged = await mergeProviders(list);
|
|
145
|
+
return json({ success: true, providers: merged.providers, removedFromOmp });
|
|
116
146
|
}
|
|
117
147
|
|
|
118
148
|
if (request.method === 'POST' || request.method === 'PUT') {
|
|
@@ -3,7 +3,7 @@ import type { ActionFunctionArgs, LoaderFunctionArgs } from '@/server/lib/remix-
|
|
|
3
3
|
import { methodNotAllowed } from '@/server/lib/route-adapter';
|
|
4
4
|
import { getDb } from '@/server/db.server';
|
|
5
5
|
import { isMockMode } from '@/server/mock.server';
|
|
6
|
-
import { readAllSettingsJson, writeSettingsJson, writeSettingsRaw } from '@/server/lib/db/settings-store';
|
|
6
|
+
import { isPlainObject, mergeSettingsJson, readAllSettingsJson, writeSettingsJson, writeSettingsRaw } from '@/server/lib/db/settings-store';
|
|
7
7
|
|
|
8
8
|
export async function loader({ request: _request }: LoaderFunctionArgs) {
|
|
9
9
|
try {
|
|
@@ -24,9 +24,14 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
|
|
24
24
|
const data = await request.json();
|
|
25
25
|
const db = await getDb();
|
|
26
26
|
|
|
27
|
-
// Data should be an object of key-value pairs
|
|
27
|
+
// Data should be an object of key-value pairs. Object values merge into
|
|
28
|
+
// whatever the key already holds, so a writer that sends one key's patch —
|
|
29
|
+
// the settings modal now sends only what changed — cannot discard the keys
|
|
30
|
+
// another writer stored in between. Arrays and scalars still replace.
|
|
28
31
|
for (const [key, value] of Object.entries(data)) {
|
|
29
|
-
if (
|
|
32
|
+
if (isPlainObject(value)) {
|
|
33
|
+
await mergeSettingsJson(db, key, value);
|
|
34
|
+
} else if (typeof value === 'object') {
|
|
30
35
|
await writeSettingsJson(db, key, value);
|
|
31
36
|
} else {
|
|
32
37
|
await writeSettingsRaw(db, key, String(value));
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import type { AgentImage, BtwFrame, BtwState, StreamTransport } from '@/shared/types';
|
|
17
|
+
import type { AttachedTextFileData } from '@/shared/lib/chat/attachments';
|
|
18
|
+
import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
|
|
17
19
|
import { connectEvents } from '@/shared/lib/chat/omp/sse';
|
|
18
20
|
import { connectSocket } from '@/shared/lib/chat/omp/socket';
|
|
19
21
|
import { btwEventsUrl, btwSocketUrl } from '@/shared/lib/chat/omp/transport';
|
|
@@ -70,6 +72,13 @@ export interface AskBtwInput {
|
|
|
70
72
|
topicId?: string;
|
|
71
73
|
question: string;
|
|
72
74
|
images?: AgentImage[];
|
|
75
|
+
/** Text files inlined for this question (read at attach time by the composer). */
|
|
76
|
+
textFiles?: AttachedTextFileData[];
|
|
77
|
+
/** Composer picks for a FIRST question — a topic that does not exist yet has
|
|
78
|
+
* no row to hold them, so they ride the ask and reach the spawn. */
|
|
79
|
+
model?: { provider: string; id: string };
|
|
80
|
+
thinkingLevel?: string;
|
|
81
|
+
approvalMode?: ApprovalMode;
|
|
73
82
|
}
|
|
74
83
|
|
|
75
84
|
export function askBtwQuestion(sessionId: string, input: AskBtwInput): Promise<BtwState> {
|
|
@@ -78,6 +87,10 @@ export function askBtwQuestion(sessionId: string, input: AskBtwInput): Promise<B
|
|
|
78
87
|
question: input.question,
|
|
79
88
|
...(input.topicId ? { topicId: input.topicId } : {}),
|
|
80
89
|
...(input.images?.length ? { images: input.images } : {}),
|
|
90
|
+
...(input.textFiles?.length ? { textFiles: input.textFiles } : {}),
|
|
91
|
+
...(input.model ? { provider: input.model.provider, modelId: input.model.id } : {}),
|
|
92
|
+
...(input.thinkingLevel ? { thinkingLevel: input.thinkingLevel } : {}),
|
|
93
|
+
...(input.approvalMode ? { accessMode: input.approvalMode } : {}),
|
|
81
94
|
});
|
|
82
95
|
}
|
|
83
96
|
|
|
@@ -85,6 +98,27 @@ export function abortBtwQuestion(sessionId: string, topicId: string): Promise<Bt
|
|
|
85
98
|
return requestBtw<BtwState>(sessionId, 'POST', { action: 'abort', topicId });
|
|
86
99
|
}
|
|
87
100
|
|
|
101
|
+
export function setBtwModel(sessionId: string, topicId: string, provider: string, modelId: string): Promise<BtwState> {
|
|
102
|
+
return requestBtw<BtwState>(sessionId, 'POST', { action: 'set_model', topicId, provider, modelId });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function setBtwThinkingLevel(sessionId: string, topicId: string, level: string): Promise<BtwState> {
|
|
106
|
+
return requestBtw<BtwState>(sessionId, 'POST', { action: 'set_thinking_level', topicId, level });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function setBtwAccessMode(sessionId: string, topicId: string, accessMode: ApprovalMode): Promise<BtwState> {
|
|
110
|
+
return requestBtw<BtwState>(sessionId, 'POST', { action: 'set_access_mode', topicId, accessMode });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function respondBtwDialog(
|
|
114
|
+
sessionId: string,
|
|
115
|
+
topicId: string,
|
|
116
|
+
id: string,
|
|
117
|
+
response: { value: string } | { confirmed: boolean } | { cancelled: true },
|
|
118
|
+
): Promise<BtwState> {
|
|
119
|
+
return requestBtw<BtwState>(sessionId, 'POST', { action: 'dialog_response', topicId, id, response });
|
|
120
|
+
}
|
|
121
|
+
|
|
88
122
|
export function promoteBtwTopic(sessionId: string, topicId: string): Promise<{ sessionId: string }> {
|
|
89
123
|
return requestBtw<{ sessionId: string }>(sessionId, 'POST', { action: 'promote', topicId });
|
|
90
124
|
}
|
|
@@ -90,10 +90,17 @@ export const DEVICE_VERBS: Record<string, string> = {
|
|
|
90
90
|
* Phrase shown while the model streams reasoning or prose rather than running a
|
|
91
91
|
* tool. A tool call always names itself from its own name (see
|
|
92
92
|
* `describeAssistantPhase`), so there is no in-between "preparing" state.
|
|
93
|
+
*
|
|
94
|
+
* `sideQuestion` is the BTW panel's FALLBACK phase, used only before the side
|
|
95
|
+
* child has reported anything: once it does, the panel shows the child's own
|
|
96
|
+
* per-phase verb (`Thinking`, `Writing response`, or the running tool's name).
|
|
97
|
+
* It is named for the feature rather than reusing `thinking`, which would claim
|
|
98
|
+
* a reasoning phase that has not been observed yet.
|
|
93
99
|
*/
|
|
94
100
|
export const PHASE_VERBS = {
|
|
95
101
|
thinking: 'Thinking',
|
|
96
102
|
writing: 'Writing response',
|
|
103
|
+
sideQuestion: 'Answering side question',
|
|
97
104
|
} as const;
|
|
98
105
|
|
|
99
106
|
export function asString(value: unknown): string | undefined {
|
|
@@ -3,12 +3,16 @@
|
|
|
3
3
|
* SPDX-License-Identifier: Apache-2.0
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* The two Shiki palettes every token carries.
|
|
8
|
+
*
|
|
9
|
+
* Shiki emits both palettes on every token span as `--shiki-light` /
|
|
10
|
+
* `--shiki-dark` custom properties with no inline color, so switching themes
|
|
11
|
+
* recolors code with zero JS and no re-highlight. `tailwind.css` selects which
|
|
12
|
+
* one paints, keyed off `<html data-theme-variant>` rather than a list of theme
|
|
13
|
+
* ids — that list is what a new theme used to fall through.
|
|
14
|
+
*/
|
|
6
15
|
export const SHIKI_LIGHT = 'one-light';
|
|
7
16
|
export const SHIKI_DARK = 'one-dark-pro';
|
|
8
17
|
|
|
9
18
|
export const SHIKI_THEMES = { light: SHIKI_LIGHT, dark: SHIKI_DARK } as const;
|
|
10
|
-
|
|
11
|
-
/** Collapse any app theme id to the syntax theme key it uses. */
|
|
12
|
-
export function resolveSyntaxThemeKey(theme?: string): 'light' | 'dark' {
|
|
13
|
-
return theme === 'noir' || theme === 'one-dark-pro-soft' ? 'dark' : 'light';
|
|
14
|
-
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
import type { FsNode } from '@/shared/types';
|
|
2
|
+
|
|
3
|
+
/** Replace the children of the node at `path`, rebuilding only that branch. */
|
|
4
|
+
export function setChildrenAt(nodes: FsNode[], path: string, children: FsNode[]): FsNode[] {
|
|
2
5
|
return nodes.map(node => {
|
|
3
|
-
if (node.path === path) return { ...node, children
|
|
6
|
+
if (node.path === path) return { ...node, children };
|
|
4
7
|
if (Array.isArray(node.children)) {
|
|
5
8
|
const nested = setChildrenAt(node.children, path, children);
|
|
6
9
|
if (nested !== node.children) return { ...node, children: nested };
|
|
@@ -9,18 +12,20 @@ export function setChildrenAt(nodes: any[], path: string, children: any[]): any[
|
|
|
9
12
|
});
|
|
10
13
|
}
|
|
11
14
|
|
|
12
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Re-attach the children already loaded for a folder, so a re-listing of the
|
|
17
|
+
* same tree does not collapse what the user had open.
|
|
18
|
+
*
|
|
19
|
+
* Which folders are open is the caller's `expandedPaths` set, keyed by paths
|
|
20
|
+
* RELATIVE to the listed root — which is why the caller must drop both that set
|
|
21
|
+
* and the cache when the listed root changes: `src/` under one workspace would
|
|
22
|
+
* otherwise rehydrate into `src/` under the next.
|
|
23
|
+
*/
|
|
24
|
+
export function rehydrateTree(nodes: FsNode[], cache: Record<string, FsNode[]>): FsNode[] {
|
|
13
25
|
return nodes.map(node => {
|
|
14
26
|
if (node.type !== 'folder') return node;
|
|
15
27
|
const cached = cache[node.path];
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
return {
|
|
19
|
-
...node,
|
|
20
|
-
children: rehydrateTree(cached, cache, expanded),
|
|
21
|
-
is_expanded: wasExpanded ? 1 : 0,
|
|
22
|
-
};
|
|
23
|
-
}
|
|
24
|
-
return node;
|
|
28
|
+
if (!cached) return node;
|
|
29
|
+
return { ...node, children: rehydrateTree(cached, cache) };
|
|
25
30
|
});
|
|
26
31
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import { renderMarkdown } from '@/shared/lib/markdown/marked';
|
|
8
|
+
|
|
9
|
+
describe('autolink vs code span', () => {
|
|
10
|
+
test('a backticked email stays a code span, not a mailto link', () => {
|
|
11
|
+
const html = renderMarkdown('addresses (`NNN+login@users.noreply.github.com`) and more');
|
|
12
|
+
expect(html).toContain('<code class="inline-code">NNN+login@users.noreply.github.com</code>');
|
|
13
|
+
expect(html).not.toContain('mailto:');
|
|
14
|
+
expect(html).not.toContain('`');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('a backticked URL stays a code span', () => {
|
|
18
|
+
const html = renderMarkdown('see `https://bun.sh/docs` for more');
|
|
19
|
+
expect(html).toContain('<code class="inline-code">https://bun.sh/docs</code>');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('ordinary autolinks are untouched', () => {
|
|
23
|
+
expect(renderMarkdown('see https://bun.sh/docs for more')).toContain('<a href="https://bun.sh/docs">');
|
|
24
|
+
expect(renderMarkdown('write to a@b.com now')).toContain('<a href="mailto:a@b.com">');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('a URL next to a code span keeps both', () => {
|
|
28
|
+
const html = renderMarkdown('https://bun.sh then `npm i`');
|
|
29
|
+
expect(html).toContain('<a href="https://bun.sh">');
|
|
30
|
+
expect(html).toContain('<code class="inline-code">npm i</code>');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* linkify-it autolinking that yields to code spans.
|
|
8
|
+
*
|
|
9
|
+
* linkify-it accepts a backtick as an email local-part character (RFC 5321's
|
|
10
|
+
* `!#$%&'*+/=?^_`{|}~-` set), so `` `NNN+login@users.noreply.github.com` ``
|
|
11
|
+
* matches as ONE autolink whose raw text spans both backticks. The extension
|
|
12
|
+
* runs before marked's own inline rules, so that match is consumed and the code
|
|
13
|
+
* span never forms: the backticks end up as link text and the href carries
|
|
14
|
+
* `%60` escapes (`mailto:%60NNN+…%60`) — a broken link AND a code span that
|
|
15
|
+
* shows its delimiters.
|
|
16
|
+
*
|
|
17
|
+
* A code span wins over an autolink in CommonMark, and the inline lexer reaches
|
|
18
|
+
* the OPENING backtick first — so refusing every match whose raw contains a
|
|
19
|
+
* backtick is enough to hand that position back to the `codespan` rule, which
|
|
20
|
+
* then swallows the whole span. Autolinks without a backtick in them are
|
|
21
|
+
* untouched, which is every ordinary URL and email.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type {
|
|
25
|
+
MarkedExtension,
|
|
26
|
+
RendererExtension,
|
|
27
|
+
Token,
|
|
28
|
+
TokenizerAndRendererExtension,
|
|
29
|
+
TokenizerThis,
|
|
30
|
+
} from 'marked';
|
|
31
|
+
import markedLinkifyIt from 'marked-linkify-it';
|
|
32
|
+
|
|
33
|
+
/** Refuse an autolink whose raw text carries a backtick (see the file header). */
|
|
34
|
+
function guardAutolink(
|
|
35
|
+
extension: TokenizerAndRendererExtension | RendererExtension,
|
|
36
|
+
): TokenizerAndRendererExtension | RendererExtension {
|
|
37
|
+
if (!('tokenizer' in extension) || !extension.tokenizer) return extension;
|
|
38
|
+
const tokenize = extension.tokenizer;
|
|
39
|
+
return {
|
|
40
|
+
...extension,
|
|
41
|
+
tokenizer(this: TokenizerThis, source: string, tokens: Token[]) {
|
|
42
|
+
const token = tokenize.call(this, source, tokens);
|
|
43
|
+
return token && !token.raw.includes('`') ? token : undefined;
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function codeSafeAutolink(): MarkedExtension {
|
|
49
|
+
return { extensions: (markedLinkifyIt().extensions ?? []).map(guardAutolink) };
|
|
50
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import { resolveDocumentReference, rewriteDocumentReferences } from '@/shared/lib/markdown/document-urls';
|
|
8
|
+
|
|
9
|
+
const doc = (path: string, root?: string, repo?: string) => ({ path, root, repo });
|
|
10
|
+
|
|
11
|
+
describe('resolveDocumentReference', () => {
|
|
12
|
+
test('resolves beside the document, not beside the app', () => {
|
|
13
|
+
expect(resolveDocumentReference('docs/hero.png', doc('README.md'))?.path).toBe('docs/hero.png');
|
|
14
|
+
expect(resolveDocumentReference('img/a.png', doc('docs/guide.md'))?.path).toBe('docs/img/a.png');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('walks out of the document folder', () => {
|
|
18
|
+
expect(resolveDocumentReference('../README.md', doc('docs/guide.md'))?.path).toBe('README.md');
|
|
19
|
+
expect(resolveDocumentReference('./a.png', doc('docs/guide.md'))?.path).toBe('docs/a.png');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('a leading slash is the scope root, not the document folder', () => {
|
|
23
|
+
expect(resolveDocumentReference('/docs/a.png', doc('manual/v2/guide.md'))?.path).toBe('docs/a.png');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('cannot climb above the scope root', () => {
|
|
27
|
+
expect(resolveDocumentReference('../../../../etc/passwd', doc('README.md'))?.path).toBe('etc/passwd');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('keeps the scope in the raw URL', () => {
|
|
31
|
+
const resolved = resolveDocumentReference('a.png', doc('docs/guide.md', '/ws', 'projects/x'));
|
|
32
|
+
expect(resolved?.url).toBe('/api/fs/raw?path=docs%2Fa.png&root=%2Fws&repo=projects%2Fx');
|
|
33
|
+
// `repo: '.'` means the root itself, so the param is omitted.
|
|
34
|
+
expect(resolveDocumentReference('a.png', doc('docs/guide.md', '/ws', '.'))?.url)
|
|
35
|
+
.toBe('/api/fs/raw?path=docs%2Fa.png&root=%2Fws');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('a query or fragment belongs to the reference, not the path', () => {
|
|
39
|
+
expect(resolveDocumentReference('img/a.png?v=2#frag', doc('docs/guide.md'))?.path).toBe('docs/img/a.png');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('decodes percent escapes', () => {
|
|
43
|
+
expect(resolveDocumentReference('my%20image.png', doc('README.md'))?.path).toBe('my image.png');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('leaves anything that is not a relative file reference alone', () => {
|
|
47
|
+
for (const reference of ['https://x.test/a.png', 'mailto:a@b.c', 'data:image/png;base64,AA', '//cdn.test/a.png', '#section', ' ', '']) {
|
|
48
|
+
expect(resolveDocumentReference(reference, doc('README.md'))).toBeNull();
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('rewriteDocumentReferences', () => {
|
|
54
|
+
test('points images at the raw route and links at the file they name', () => {
|
|
55
|
+
expect(rewriteDocumentReferences('<img src="docs/a.png"><a href="AGENTS.md">x</a>', doc('README.md')))
|
|
56
|
+
.toBe('<img src="/api/fs/raw?path=docs%2Fa.png"><a href="/api/fs/raw?path=AGENTS.md" data-file-path="AGENTS.md">x</a>');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('escapes the query separator so the attribute survives HTML parsing', () => {
|
|
60
|
+
expect(rewriteDocumentReferences('<img src="a.png">', doc('docs/g.md', '/ws')))
|
|
61
|
+
.toBe('<img src="/api/fs/raw?path=docs%2Fa.png&root=%2Fws">');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('leaves external references and escaped code untouched', () => {
|
|
65
|
+
const html = '<a href="https://x.test">x</a><code class="language-html"><img src="a.png"></code>';
|
|
66
|
+
expect(rewriteDocumentReferences(html, doc('README.md'))).toBe(html);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolve the relative references of a markdown *document* (an opened file's
|
|
8
|
+
* preview) to the workspace files they actually name.
|
|
9
|
+
*
|
|
10
|
+
* A previewed file is not a chat message: `` means the image
|
|
11
|
+
* on disk beside the document, and `[Architecture](AGENTS.md)` means that file.
|
|
12
|
+
* Neither resolves on its own — the preview is an app route, so a relative URL
|
|
13
|
+
* is requested from the server's own origin (`/docs/hero.png` → 404, and a
|
|
14
|
+
* click would navigate the console away from itself). Each one is rewritten to
|
|
15
|
+
* the workspace file it points at: images through `/api/fs/raw` (real bytes,
|
|
16
|
+
* real content-type) and links through the same URL plus the resolved path in
|
|
17
|
+
* `data-file-path`, which the panel turns back into an editor tab.
|
|
18
|
+
*
|
|
19
|
+
* Only plain relative references are touched. Anything with a scheme
|
|
20
|
+
* (`https:`, `mailto:`, `data:`), a protocol-relative `//host`, or a bare
|
|
21
|
+
* `#anchor` is the document's own business and is left exactly as written.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { buildFsRawUrl } from '@/shared/lib/fs/paths';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Where the document lives. `path` is relative to the picked scope exactly the
|
|
28
|
+
* way `/api/fs/raw` and the file panel read it: relative to `repo` when one is
|
|
29
|
+
* picked, otherwise to `root`.
|
|
30
|
+
*/
|
|
31
|
+
export interface DocumentScope {
|
|
32
|
+
path: string;
|
|
33
|
+
root?: string;
|
|
34
|
+
repo?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ResolvedDocumentReference {
|
|
38
|
+
/** Scope-relative path of the referenced file — what an editor tab opens. */
|
|
39
|
+
path: string;
|
|
40
|
+
/** Raw-bytes URL of the same file, for an `<img src>` or a middle-click. */
|
|
41
|
+
url: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A scheme (`https:`), protocol-relative host, fragment, or empty ref. */
|
|
45
|
+
const ABSOLUTE_REFERENCE_RE = /^(?:[a-z][a-z0-9+.-]*:|\/\/|#|$)/i;
|
|
46
|
+
|
|
47
|
+
/** Collapse `.`/`..` segments; ascending above the scope root is dropped. */
|
|
48
|
+
function normalizeSegments(segments: string[]): string {
|
|
49
|
+
const out: string[] = [];
|
|
50
|
+
for (const segment of segments) {
|
|
51
|
+
if (!segment || segment === '.') continue;
|
|
52
|
+
if (segment === '..') {
|
|
53
|
+
out.pop();
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
out.push(segment);
|
|
57
|
+
}
|
|
58
|
+
return out.join('/');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** `%20`-style escapes are how a markdown link writes a space. */
|
|
62
|
+
function decodeReference(value: string): string {
|
|
63
|
+
try {
|
|
64
|
+
return decodeURIComponent(value);
|
|
65
|
+
} catch {
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Resolve one reference written inside `scope`'s document, or null when it is
|
|
72
|
+
* not a relative file reference (leave those untouched).
|
|
73
|
+
*/
|
|
74
|
+
export function resolveDocumentReference(
|
|
75
|
+
reference: string,
|
|
76
|
+
scope: DocumentScope,
|
|
77
|
+
): ResolvedDocumentReference | null {
|
|
78
|
+
const raw = reference.trim();
|
|
79
|
+
if (ABSOLUTE_REFERENCE_RE.test(raw)) return null;
|
|
80
|
+
|
|
81
|
+
// `?v=1` and `#section` address the preview, not the file on disk.
|
|
82
|
+
const withoutQuery = raw.split(/[?#]/)[0];
|
|
83
|
+
const decoded = decodeReference(withoutQuery);
|
|
84
|
+
if (!decoded) return null;
|
|
85
|
+
|
|
86
|
+
// A leading slash is root-relative (GitHub's repo-root rule), and the scope
|
|
87
|
+
// frame already IS that root, so it resolves without the document's folder.
|
|
88
|
+
const rootRelative = /^[/\\]/.test(decoded);
|
|
89
|
+
const documentDir = scope.path.replace(/\\/g, '/').split('/').slice(0, -1);
|
|
90
|
+
const segments = decoded.split(/[\\/]+/);
|
|
91
|
+
const path = normalizeSegments(rootRelative ? segments : [...documentDir, ...segments]);
|
|
92
|
+
if (!path) return null;
|
|
93
|
+
|
|
94
|
+
return { path, url: buildFsRawUrl({ path, root: scope.root, repo: scope.repo }) };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Attribute values are injected as HTML, so `&` and `"` must be entities. */
|
|
98
|
+
function escapeAttribute(value: string): string {
|
|
99
|
+
return value.replace(/&/g, '&').replace(/"/g, '"');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Rewrite the relative `src`/`href` attributes of rendered document HTML.
|
|
104
|
+
* Fenced code is already escaped by the renderer, so a literal `src="` inside
|
|
105
|
+
* a code block cannot be mistaken for an attribute.
|
|
106
|
+
*/
|
|
107
|
+
export function rewriteDocumentReferences(html: string, scope: DocumentScope): string {
|
|
108
|
+
return html.replace(/(\s(?:src|href))="([^"]*)"/g, (match, attribute: string, value: string) => {
|
|
109
|
+
const resolved = resolveDocumentReference(value, scope);
|
|
110
|
+
if (!resolved) return match;
|
|
111
|
+
const url = ` ${attribute.trim()}="${escapeAttribute(resolved.url)}"`;
|
|
112
|
+
// The resolved path rides along on links only: it is what a click opens as
|
|
113
|
+
// a tab, and a mid-click still has the raw URL to fall back to.
|
|
114
|
+
return attribute.trim() === 'href'
|
|
115
|
+
? `${url} data-file-path="${escapeAttribute(resolved.path)}"`
|
|
116
|
+
: url;
|
|
117
|
+
});
|
|
118
|
+
}
|