@bibobonking/opensol 0.0.0-stage → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/build.yml +25 -0
- package/README.md +74 -2
- package/dist/assets/index-BmN7pBji.css +1 -0
- package/dist/assets/index-DgNDjkOz.js +301 -0
- package/dist/favicon.svg +15 -0
- package/dist/index.html +55 -0
- package/dist/preview-host.html +33 -0
- package/index.html +54 -0
- package/package.json +43 -4
- package/postcss.config.js +6 -0
- package/public/favicon.svg +15 -0
- package/public/preview-host.html +33 -0
- package/shared/plugin-builder.test.mjs +43 -0
- package/shared/plugin-package.d.mts +34 -0
- package/shared/plugin-package.mjs +197 -0
- package/shared/plugin-package.test.mjs +94 -0
- package/src/App.tsx +152 -0
- package/src/agent/adapters/anthropic.ts +233 -0
- package/src/agent/adapters/gemini.ts +225 -0
- package/src/agent/adapters/openai-responses.ts +215 -0
- package/src/agent/adapters/openai.ts +200 -0
- package/src/agent/adapters/shared.ts +96 -0
- package/src/agent/approval.ts +20 -0
- package/src/agent/compact.ts +234 -0
- package/src/agent/index.ts +36 -0
- package/src/agent/model.ts +20 -0
- package/src/agent/prompt.ts +107 -0
- package/src/agent/queue.test.mjs +111 -0
- package/src/agent/queue.ts +84 -0
- package/src/agent/subagent.ts +109 -0
- package/src/agent/tools.ts +541 -0
- package/src/components/ChatView.tsx +403 -0
- package/src/components/CommandPalette.tsx +416 -0
- package/src/components/Composer.tsx +678 -0
- package/src/components/ContextBar.tsx +58 -0
- package/src/components/DeployModal.tsx +512 -0
- package/src/components/ErrorBoundary.tsx +75 -0
- package/src/components/HomeDashboard.tsx +189 -0
- package/src/components/Logo.tsx +41 -0
- package/src/components/Markdown.tsx +232 -0
- package/src/components/MessageItem.tsx +655 -0
- package/src/components/ModelPicker.tsx +487 -0
- package/src/components/PixleSol.tsx +243 -0
- package/src/components/PluginBuilder.tsx +285 -0
- package/src/components/PluginManager.tsx +341 -0
- package/src/components/PreviewPane.tsx +166 -0
- package/src/components/SettingsModal.tsx +2052 -0
- package/src/components/Sidebar.tsx +504 -0
- package/src/components/Splash.tsx +104 -0
- package/src/components/ToolCallCard.tsx +177 -0
- package/src/components/TopBar.tsx +182 -0
- package/src/components/TrajectoryView.tsx +178 -0
- package/src/components/WorkspacePanel.tsx +710 -0
- package/src/components/ui.tsx +311 -0
- package/src/core/kernel.ts +45 -0
- package/src/core/plugins/tools/fs.ts +219 -0
- package/src/core/plugins/tools/git.ts +83 -0
- package/src/core/plugins/tools/grep.ts +98 -0
- package/src/core/plugins/tools/shell.ts +88 -0
- package/src/core/plugins/tools/subagent.ts +36 -0
- package/src/core/plugins/tools/web.ts +97 -0
- package/src/core/services/agent-loop.ts +417 -0
- package/src/core/services/approval.ts +66 -0
- package/src/core/services/compaction.ts +45 -0
- package/src/core/services/llm.ts +32 -0
- package/src/core/services/sessions.ts +47 -0
- package/src/core/services/subagents.ts +15 -0
- package/src/core/services/system-prompt.ts +177 -0
- package/src/core/services/tools.ts +135 -0
- package/src/core/services/ui.ts +109 -0
- package/src/core/types.ts +139 -0
- package/src/data/providers.ts +125 -0
- package/src/data/skills.ts +68 -0
- package/src/index.css +776 -0
- package/src/lib/boot.ts +94 -0
- package/src/lib/context.ts +75 -0
- package/src/lib/conversation.test.mjs +64 -0
- package/src/lib/conversation.ts +59 -0
- package/src/lib/daemon.ts +475 -0
- package/src/lib/hooks.ts +33 -0
- package/src/lib/permissions.ts +60 -0
- package/src/lib/pixlesol.test.mjs +51 -0
- package/src/lib/pixlesol.ts +55 -0
- package/src/lib/plugin-folder.test.mjs +145 -0
- package/src/lib/plugin-folder.ts +96 -0
- package/src/lib/reasoning.ts +87 -0
- package/src/lib/sse.ts +75 -0
- package/src/lib/themes.test.mjs +72 -0
- package/src/lib/themes.ts +52 -0
- package/src/lib/usage.test.mjs +72 -0
- package/src/lib/usage.ts +155 -0
- package/src/lib/utils.ts +73 -0
- package/src/main.tsx +16 -0
- package/src/plugins/api.test.mjs +77 -0
- package/src/plugins/api.ts +113 -0
- package/src/plugins/bridge.d.mts +1 -0
- package/src/plugins/bridge.mjs +26 -0
- package/src/plugins/bridge.test.mjs +17 -0
- package/src/plugins/index.ts +52 -0
- package/src/plugins/preview.test.mjs +18 -0
- package/src/plugins/preview.ts +34 -0
- package/src/plugins/runtime.ts +194 -0
- package/src/plugins/types.ts +40 -0
- package/src/store/chats.ts +278 -0
- package/src/store/models.ts +120 -0
- package/src/store/settings.ts +445 -0
- package/src/store/ui.ts +132 -0
- package/src/store/workspace.ts +299 -0
- package/src/types.ts +337 -0
- package/src/vite-env.d.ts +1 -0
- package/tailwind.config.js +83 -0
- package/tsconfig.json +22 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vite.config.ts +19 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import { isBinaryPath, loadPluginFolder } from './plugin-folder.ts'
|
|
4
|
+
|
|
5
|
+
const MANIFEST = JSON.stringify({
|
|
6
|
+
id: 'demo-plugin',
|
|
7
|
+
name: 'Demo plugin',
|
|
8
|
+
version: '1.0.0',
|
|
9
|
+
apiVersion: 1,
|
|
10
|
+
entry: 'main.js',
|
|
11
|
+
stylesheet: 'main.css',
|
|
12
|
+
capabilities: ['ui.commands'],
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test('a picked folder drops its own name and keeps the file layout', () => {
|
|
16
|
+
const folder = loadPluginFolder([
|
|
17
|
+
{ path: 'demo/main.js', content: 'maxcode.log("hi")' },
|
|
18
|
+
{ path: 'demo/styles/main.css', content: '.a{}' },
|
|
19
|
+
{ path: 'demo/assets/icon.bin', content: new Uint8Array([1, 2, 3]) },
|
|
20
|
+
])
|
|
21
|
+
assert.deepEqual(Object.keys(folder.files).sort(), ['assets/icon.bin', 'main.js', 'styles/main.css'])
|
|
22
|
+
assert.equal(folder.entry, 'main.js')
|
|
23
|
+
assert.equal(folder.stylesheet, 'styles/main.css')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('a loose file pick keeps its flat names and needs no stylesheet', () => {
|
|
27
|
+
const folder = loadPluginFolder([{ path: 'main.js', content: '' }])
|
|
28
|
+
assert.equal(folder.entry, 'main.js')
|
|
29
|
+
assert.equal(folder.stylesheet, 'main.css')
|
|
30
|
+
assert.equal(folder.manifest, undefined)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('the folder manifest.json seeds the form and never becomes a package file', () => {
|
|
34
|
+
const folder = loadPluginFolder([
|
|
35
|
+
{ path: 'demo/manifest.json', content: MANIFEST },
|
|
36
|
+
{ path: 'demo/main.js', content: '' },
|
|
37
|
+
])
|
|
38
|
+
assert.equal(folder.manifest?.id, 'demo-plugin')
|
|
39
|
+
assert.deepEqual(folder.manifest?.domains, [])
|
|
40
|
+
assert.equal('manifest.json' in folder.files, false)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('a folder without an entry file is rejected with a readable reason', () => {
|
|
44
|
+
assert.throws(() => loadPluginFolder([{ path: 'demo/main.css', content: '.a{}' }]), /\.js entry/)
|
|
45
|
+
assert.throws(() => loadPluginFolder([{ path: 'demo/manifest.json', content: 'not json' }]), /entry/i)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('a broken manifest.json is reported without costing the load', () => {
|
|
49
|
+
const folder = loadPluginFolder([
|
|
50
|
+
{ path: 'demo/main.js', content: 'maxcode.log("hi")' },
|
|
51
|
+
{ path: 'demo/manifest.json', content: '{oops' },
|
|
52
|
+
])
|
|
53
|
+
assert.equal(folder.entry, 'main.js')
|
|
54
|
+
assert.equal(folder.manifest, undefined)
|
|
55
|
+
assert.match(folder.manifestError, /valid JSON/)
|
|
56
|
+
const incomplete = loadPluginFolder([
|
|
57
|
+
{ path: 'demo/main.js', content: '' },
|
|
58
|
+
{ path: 'demo/manifest.json', content: '{"id":"x"}' },
|
|
59
|
+
])
|
|
60
|
+
assert.equal(incomplete.manifest, undefined)
|
|
61
|
+
assert.match(incomplete.manifestError, /plugin id/)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('the folder manifest decides the entry, so a folder of many files picks the declared one', () => {
|
|
65
|
+
const folder = loadPluginFolder([
|
|
66
|
+
{ path: 'demo/manifest.json', content: JSON.stringify({ ...JSON.parse(MANIFEST), entry: 'index.js', stylesheet: 'theme.css' }) },
|
|
67
|
+
{ path: 'demo/index.js', content: 'maxcode.log(1)' },
|
|
68
|
+
{ path: 'demo/theme.css', content: '.a{}' },
|
|
69
|
+
])
|
|
70
|
+
assert.equal(folder.entry, 'index.js')
|
|
71
|
+
assert.equal(folder.stylesheet, 'theme.css')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('a folder with a second script is refused with the reason, not a validator error', () => {
|
|
75
|
+
assert.throws(
|
|
76
|
+
() =>
|
|
77
|
+
loadPluginFolder([
|
|
78
|
+
{ path: 'demo/main.js', content: '' },
|
|
79
|
+
{ path: 'demo/helper.js', content: '' },
|
|
80
|
+
]),
|
|
81
|
+
/one script and one stylesheet.*helper\.js/s,
|
|
82
|
+
)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('a nested src/main.js is the entry, and a sibling helper is named as the extra', () => {
|
|
86
|
+
const folder = loadPluginFolder([
|
|
87
|
+
{ path: 'demo/src/main.js', content: '' },
|
|
88
|
+
{ path: 'demo/src/main.css', content: '' },
|
|
89
|
+
])
|
|
90
|
+
assert.equal(folder.entry, 'src/main.js')
|
|
91
|
+
assert.equal(folder.stylesheet, 'src/main.css')
|
|
92
|
+
|
|
93
|
+
// The extra named must be the helper — naming main.js would mean the entry was misdetected.
|
|
94
|
+
assert.throws(
|
|
95
|
+
() =>
|
|
96
|
+
loadPluginFolder([
|
|
97
|
+
{ path: 'demo/src/helper.js', content: '' },
|
|
98
|
+
{ path: 'demo/src/main.js', content: '' },
|
|
99
|
+
]),
|
|
100
|
+
/one script and one stylesheet.*src\/helper\.js/s,
|
|
101
|
+
)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('the entry pick is stable when a folder has more than one .js', () => {
|
|
105
|
+
const many = (order) => loadPluginFolder(order.map((path) => ({ path, content: '' })))
|
|
106
|
+
const error = (order) => {
|
|
107
|
+
try {
|
|
108
|
+
many(order)
|
|
109
|
+
return ''
|
|
110
|
+
} catch (caught) {
|
|
111
|
+
return caught.message
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
assert.equal(error(['demo/zed.js', 'demo/alpha.js']), error(['demo/alpha.js', 'demo/zed.js']))
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('a declared entry that is not in the folder falls back to detection', () => {
|
|
118
|
+
const folder = loadPluginFolder([
|
|
119
|
+
{ path: 'demo/manifest.json', content: JSON.stringify({ ...JSON.parse(MANIFEST), entry: 'missing.js' }) },
|
|
120
|
+
{ path: 'demo/main.js', content: '' },
|
|
121
|
+
])
|
|
122
|
+
assert.equal(folder.entry, 'main.js')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('binary assets are recognised by extension', () => {
|
|
126
|
+
assert.equal(isBinaryPath('assets/icon.png'), true)
|
|
127
|
+
assert.equal(isBinaryPath('fonts/inter.woff2'), true)
|
|
128
|
+
assert.equal(isBinaryPath('main.js'), false)
|
|
129
|
+
assert.equal(isBinaryPath('styles/main.css'), false)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('a package built from a picked folder with no domains round-trips', async () => {
|
|
133
|
+
const { buildPluginArchive, readPluginArchive } = await import('../../shared/plugin-package.mjs')
|
|
134
|
+
const folder = loadPluginFolder([
|
|
135
|
+
{ path: 'demo/manifest.json', content: MANIFEST },
|
|
136
|
+
{ path: 'demo/main.js', content: 'maxcode.log("hi")' },
|
|
137
|
+
])
|
|
138
|
+
assert.ok(folder.manifest)
|
|
139
|
+
const archive = buildPluginArchive({
|
|
140
|
+
manifest: folder.manifest,
|
|
141
|
+
files: { ...folder.files, [folder.stylesheet]: '' },
|
|
142
|
+
})
|
|
143
|
+
assert.deepEqual(readPluginArchive(archive).files['main.js'], 'maxcode.log("hi")')
|
|
144
|
+
assert.deepEqual(readPluginArchive(archive).manifest.domains, [])
|
|
145
|
+
})
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { PluginManifest } from '../../shared/plugin-package.mjs'
|
|
2
|
+
import { validatePluginManifest } from '../../shared/plugin-package.mjs'
|
|
3
|
+
|
|
4
|
+
export interface PickedFile {
|
|
5
|
+
path: string
|
|
6
|
+
content: string | Uint8Array
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface PluginFolder {
|
|
10
|
+
entry: string
|
|
11
|
+
stylesheet: string
|
|
12
|
+
files: Record<string, string | Uint8Array>
|
|
13
|
+
manifest?: PluginManifest
|
|
14
|
+
/** A `manifest.json` we would not use, so the load still succeeds and the form keeps its values. */
|
|
15
|
+
manifestError?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const BINARY = /\.(?:png|jpe?g|gif|webp|ico|woff2?|ttf|otf|eot|wasm|zip|pdf|mp3|mp4)$/i
|
|
19
|
+
|
|
20
|
+
/** Anything not read as UTF-8 text, so a picked image or font survives the round trip. */
|
|
21
|
+
export function isBinaryPath(path: string): boolean {
|
|
22
|
+
return BINARY.test(path)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A directory pick prefixes every path with the folder the user chose; loose picks do not. */
|
|
26
|
+
function stripRoot(paths: string[]): string[] {
|
|
27
|
+
const slash = paths[0]?.indexOf('/') ?? -1
|
|
28
|
+
if (slash < 0) return paths
|
|
29
|
+
const root = paths[0].slice(0, slash + 1)
|
|
30
|
+
return paths.every((path) => path.startsWith(root)) ? paths.map((path) => path.slice(root.length)) : paths
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseManifest(raw: string | Uint8Array): PluginManifest {
|
|
34
|
+
if (typeof raw !== 'string') throw new Error('manifest.json must be text.')
|
|
35
|
+
let value: unknown
|
|
36
|
+
try {
|
|
37
|
+
value = JSON.parse(raw)
|
|
38
|
+
} catch {
|
|
39
|
+
throw new Error('manifest.json is not valid JSON.')
|
|
40
|
+
}
|
|
41
|
+
return validatePluginManifest(value)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Turns a picked folder or file set into the sources a plugin package needs. The
|
|
46
|
+
* folder's own `manifest.json` only seeds the form; the package manifest is
|
|
47
|
+
* always rebuilt from the fields, so what you see is what gets packed.
|
|
48
|
+
*/
|
|
49
|
+
export function loadPluginFolder(picked: PickedFile[]): PluginFolder {
|
|
50
|
+
const paths = stripRoot(picked.map((file) => file.path))
|
|
51
|
+
const files: Record<string, string | Uint8Array> = {}
|
|
52
|
+
let raw: string | Uint8Array | undefined
|
|
53
|
+
picked.forEach((file, index) => {
|
|
54
|
+
const path = paths[index]
|
|
55
|
+
if (path === 'manifest.json') raw = file.content
|
|
56
|
+
else files[path] = file.content
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const names = Object.keys(files)
|
|
60
|
+
const usable = (path: string | undefined, extension: string) =>
|
|
61
|
+
path !== undefined && path.endsWith(extension) && names.includes(path) ? path : undefined
|
|
62
|
+
const pick = (preferred: string, extension: string) => {
|
|
63
|
+
if (names.includes(preferred)) return preferred
|
|
64
|
+
const candidates = names.filter((name) => name.endsWith(extension)).sort()
|
|
65
|
+
// `src/main.js` is the entry just as much as `main.js` is; compare leaf names.
|
|
66
|
+
return candidates.find((name) => name.slice(name.lastIndexOf('/') + 1) === preferred) ?? candidates[0]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// A folder manifest is a convenience. A broken one must not cost you the load.
|
|
70
|
+
let manifest: PluginManifest | undefined
|
|
71
|
+
let manifestError: string | undefined
|
|
72
|
+
if (raw !== undefined) {
|
|
73
|
+
try {
|
|
74
|
+
manifest = parseManifest(raw)
|
|
75
|
+
} catch (error) {
|
|
76
|
+
manifestError = error instanceof Error ? error.message : 'manifest.json could not be read.'
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// What the folder declared beats guessing by extension, and makes the pick stable.
|
|
81
|
+
const entry = usable(manifest?.entry, '.js') ?? pick('main.js', '.js')
|
|
82
|
+
if (!entry) throw new Error('A plugin needs one .js entry file. Pick a folder that has one.')
|
|
83
|
+
const stylesheet = usable(manifest?.stylesheet, '.css') ?? pick('main.css', '.css') ?? 'main.css'
|
|
84
|
+
|
|
85
|
+
// The package holds exactly one script and one stylesheet: no imports, no second module.
|
|
86
|
+
const extra = names.filter(
|
|
87
|
+
(name) => name !== entry && name !== stylesheet && /\.(?:m?js|cjs|jsx|tsx?|css)$/i.test(name),
|
|
88
|
+
)
|
|
89
|
+
if (extra.length > 0) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`A plugin package holds one script and one stylesheet. Remove or merge: ${extra.join(', ')}.`,
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { entry, stylesheet, files, manifest, manifestError }
|
|
96
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { Effort, ModelInfo, ReasoningOption } from '../types'
|
|
2
|
+
import { EFFORT_ORDER } from '../data/providers'
|
|
3
|
+
|
|
4
|
+
export function effortValues(model?: ModelInfo | null): Effort[] {
|
|
5
|
+
const option = model?.reasoningOptions.find(
|
|
6
|
+
(entry): entry is Extract<ReasoningOption, { type: 'effort' }> => entry.type === 'effort',
|
|
7
|
+
)
|
|
8
|
+
return option?.values ?? []
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function budgetOption(
|
|
12
|
+
model?: ModelInfo | null,
|
|
13
|
+
): Extract<ReasoningOption, { type: 'budget_tokens' }> | undefined {
|
|
14
|
+
return model?.reasoningOptions.find(
|
|
15
|
+
(entry): entry is Extract<ReasoningOption, { type: 'budget_tokens' }> =>
|
|
16
|
+
entry.type === 'budget_tokens',
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function hasBudget(model?: ModelInfo | null) {
|
|
21
|
+
return Boolean(budgetOption(model))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function hasToggle(model?: ModelInfo | null) {
|
|
25
|
+
return Boolean(model?.reasoningOptions.some((entry) => entry.type === 'toggle'))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Whether the effort control applies: the live model advertises effort or a budget. */
|
|
29
|
+
export function supportsReasoningControl(model?: ModelInfo | null) {
|
|
30
|
+
return effortValues(model).length > 0 || hasBudget(model)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Whether the model can think at all, even when the provider controls the switch. */
|
|
34
|
+
export function supportsReasoning(model?: ModelInfo | null) {
|
|
35
|
+
return supportsReasoningControl(model) || hasToggle(model)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function defaultEffort(model?: ModelInfo | null): Effort {
|
|
39
|
+
const values = effortValues(model)
|
|
40
|
+
if (values.length === 0) return 'medium'
|
|
41
|
+
for (const candidate of ['medium', 'high', 'low'] as Effort[]) {
|
|
42
|
+
if (values.includes(candidate)) return candidate
|
|
43
|
+
}
|
|
44
|
+
return values[0]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function normalizeEffort(effort: Effort, model?: ModelInfo | null): Effort {
|
|
48
|
+
const values = effortValues(model)
|
|
49
|
+
if (values.length === 0) return effort
|
|
50
|
+
if (values.includes(effort)) return effort
|
|
51
|
+
return defaultEffort(model)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function clampBudget(value: number, model?: ModelInfo | null) {
|
|
55
|
+
const option = budgetOption(model)
|
|
56
|
+
const min = Math.max(0, option?.min ?? 1024)
|
|
57
|
+
const max = Math.max(min, option?.max ?? 32_768)
|
|
58
|
+
return Math.max(min, Math.min(max, value))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const BUDGET_BY_EFFORT: Record<Effort, number> = {
|
|
62
|
+
none: 1024,
|
|
63
|
+
minimal: 1024,
|
|
64
|
+
low: 2048,
|
|
65
|
+
medium: 8192,
|
|
66
|
+
high: 16_384,
|
|
67
|
+
xhigh: 24_576,
|
|
68
|
+
max: 32_768,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function budgetForEffort(effort: Effort, model?: ModelInfo | null) {
|
|
72
|
+
return clampBudget(BUDGET_BY_EFFORT[effort] ?? 8192, model)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function effortOptions(model?: ModelInfo | null): Effort[] {
|
|
76
|
+
const values = effortValues(model)
|
|
77
|
+
return [...values].sort((a, b) => EFFORT_ORDER.indexOf(a) - EFFORT_ORDER.indexOf(b))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function reasoningSummary(model?: ModelInfo | null): string {
|
|
81
|
+
if (!model) return 'Choose a model to see live reasoning controls.'
|
|
82
|
+
const values = effortValues(model)
|
|
83
|
+
if (values.length > 0) return `Effort levels from this model: ${values.join(', ')}.`
|
|
84
|
+
if (hasBudget(model)) return 'This model accepts a thinking token budget, derived from the effort level.'
|
|
85
|
+
if (hasToggle(model)) return 'This model controls reasoning itself; no effort value is sent.'
|
|
86
|
+
return 'This model advertises no reasoning controls, so effort does not apply.'
|
|
87
|
+
}
|
package/src/lib/sse.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export async function* readSSE(response: Response): AsyncGenerator<string> {
|
|
2
|
+
if (!response.body) throw new Error('The provider returned an empty response body.')
|
|
3
|
+
const reader = response.body.getReader()
|
|
4
|
+
const decoder = new TextDecoder()
|
|
5
|
+
let buffer = ''
|
|
6
|
+
try {
|
|
7
|
+
while (true) {
|
|
8
|
+
const { done, value } = await reader.read()
|
|
9
|
+
if (done) break
|
|
10
|
+
buffer += decoder.decode(value, { stream: true })
|
|
11
|
+
buffer = buffer.replace(/\r\n/g, '\n')
|
|
12
|
+
let boundary = buffer.indexOf('\n\n')
|
|
13
|
+
while (boundary !== -1) {
|
|
14
|
+
const chunk = buffer.slice(0, boundary)
|
|
15
|
+
buffer = buffer.slice(boundary + 2)
|
|
16
|
+
for (const line of chunk.split('\n')) {
|
|
17
|
+
if (!line.startsWith('data:')) continue
|
|
18
|
+
const data = line.slice(5).trim()
|
|
19
|
+
if (data) yield data
|
|
20
|
+
}
|
|
21
|
+
boundary = buffer.indexOf('\n\n')
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
for (const line of buffer.split('\n')) {
|
|
25
|
+
if (line.startsWith('data:')) {
|
|
26
|
+
const data = line.slice(5).trim()
|
|
27
|
+
if (data) yield data
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
} finally {
|
|
31
|
+
reader.releaseLock()
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function parseErrorMessage(raw: string, status: number): string {
|
|
36
|
+
try {
|
|
37
|
+
const parsed = JSON.parse(raw) as {
|
|
38
|
+
error?: { message?: string; type?: string } | string
|
|
39
|
+
message?: string
|
|
40
|
+
}
|
|
41
|
+
if (typeof parsed.error === 'string') return `${parsed.error} (${status})`
|
|
42
|
+
if (parsed.error?.message) return `${parsed.error.message} (${status})`
|
|
43
|
+
if (parsed.message) return `${parsed.message} (${status})`
|
|
44
|
+
} catch {
|
|
45
|
+
// Fall through to the raw text, which is still useful.
|
|
46
|
+
}
|
|
47
|
+
const trimmed = raw.trim()
|
|
48
|
+
if (trimmed.startsWith('<')) {
|
|
49
|
+
return `The provider returned an HTML error page (${status}). Check the base URL and key.`
|
|
50
|
+
}
|
|
51
|
+
return trimmed ? `${trimmed.slice(0, 500)} (${status})` : `Request failed with status ${status}.`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function assertOk(response: Response): Promise<void> {
|
|
55
|
+
if (response.ok) return
|
|
56
|
+
const text = await response.text().catch(() => '')
|
|
57
|
+
throw new Error(parseErrorMessage(text, response.status))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function describeRequestError(error: unknown, providerName: string): string {
|
|
61
|
+
if (error instanceof DOMException && error.name === 'AbortError') return 'Stopped.'
|
|
62
|
+
if (error instanceof Error) {
|
|
63
|
+
if (/daemon is not reachable|lost the connection/i.test(error.message)) {
|
|
64
|
+
return 'The local OpenSol daemon is not reachable. Start it with “npm start”, then try again.'
|
|
65
|
+
}
|
|
66
|
+
if (/failed to fetch|networkerror|load failed/i.test(error.message)) {
|
|
67
|
+
return `The local daemon could not reach ${providerName}. Check the network and the provider base URL, then try again.`
|
|
68
|
+
}
|
|
69
|
+
if (/401|403/.test(error.message) && /key|auth|permission|refus|reject/i.test(error.message)) {
|
|
70
|
+
return `${error.message} Check the API key in Settings → Models, or choose a model this key can access.`
|
|
71
|
+
}
|
|
72
|
+
return error.message
|
|
73
|
+
}
|
|
74
|
+
return 'The request failed for an unknown reason.'
|
|
75
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { THEMES, normalizeTheme, resolveTheme, themeIsDark } from './themes.ts'
|
|
6
|
+
|
|
7
|
+
const css = readFileSync(fileURLToPath(new URL('../index.css', import.meta.url)), 'utf8')
|
|
8
|
+
|
|
9
|
+
const TOKENS = [
|
|
10
|
+
'canvas',
|
|
11
|
+
'panel',
|
|
12
|
+
'raised',
|
|
13
|
+
'line',
|
|
14
|
+
'ink',
|
|
15
|
+
'muted',
|
|
16
|
+
'faint',
|
|
17
|
+
'accent',
|
|
18
|
+
'accent-soft',
|
|
19
|
+
'on-accent',
|
|
20
|
+
'success',
|
|
21
|
+
'warning',
|
|
22
|
+
'danger',
|
|
23
|
+
'syn-keyword',
|
|
24
|
+
'syn-string',
|
|
25
|
+
'syn-number',
|
|
26
|
+
'syn-title',
|
|
27
|
+
'syn-attr',
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
function block(needle) {
|
|
31
|
+
const start = css.indexOf(needle)
|
|
32
|
+
assert.notEqual(start, -1, `missing CSS block for ${needle}`)
|
|
33
|
+
return css.slice(start, css.indexOf('}', start))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test('every named palette defines the full semantic token set', () => {
|
|
37
|
+
for (const theme of THEMES) {
|
|
38
|
+
const body = block(`[data-theme='${theme.id}']`)
|
|
39
|
+
for (const token of TOKENS) {
|
|
40
|
+
assert.match(body, new RegExp(`--${token}:`), `${theme.id} is missing --${token}`)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const light = block(':root,')
|
|
44
|
+
for (const token of TOKENS) {
|
|
45
|
+
assert.match(light, new RegExp(`--${token}:`), `light is missing --${token}`)
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('legacy, named, system and invalid themes resolve', () => {
|
|
50
|
+
assert.equal(normalizeTheme('light'), 'light')
|
|
51
|
+
assert.equal(normalizeTheme('dark'), 'dark')
|
|
52
|
+
assert.equal(normalizeTheme('system'), 'system')
|
|
53
|
+
assert.equal(normalizeTheme('night-owl'), 'night-owl')
|
|
54
|
+
assert.equal(normalizeTheme('claude-code'), 'claude-code')
|
|
55
|
+
assert.equal(normalizeTheme('flame'), 'flame')
|
|
56
|
+
assert.equal(normalizeTheme(undefined), 'system')
|
|
57
|
+
assert.equal(normalizeTheme(7), 'system')
|
|
58
|
+
assert.equal(normalizeTheme('Not A Theme'), 'system')
|
|
59
|
+
// Plugin themes are `plugin-<id>:<theme>` slugs the app has not seen at build time.
|
|
60
|
+
assert.equal(normalizeTheme('plugin-demo:sunrise'), 'plugin-demo:sunrise')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('system follows the OS preference while named themes do not', () => {
|
|
64
|
+
assert.equal(resolveTheme('system', true), 'dark')
|
|
65
|
+
assert.equal(resolveTheme('system', false), 'light')
|
|
66
|
+
assert.equal(resolveTheme('light', true), 'light')
|
|
67
|
+
assert.equal(resolveTheme('flame', false), 'flame')
|
|
68
|
+
assert.equal(themeIsDark('light', true), false)
|
|
69
|
+
assert.equal(themeIsDark('dark', false), true)
|
|
70
|
+
assert.equal(themeIsDark('night-owl', false), true)
|
|
71
|
+
assert.equal(themeIsDark('system', true), true)
|
|
72
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export type BuiltinThemeId = 'light' | 'dark' | 'system' | 'night-owl' | 'claude-code' | 'flame'
|
|
2
|
+
|
|
3
|
+
/** A built-in theme id, or a Safe plugin's `plugin-<plugin>:<theme>` id. */
|
|
4
|
+
export type ThemeId = BuiltinThemeId | (string & {})
|
|
5
|
+
|
|
6
|
+
export type ResolvedThemeId = Exclude<BuiltinThemeId, 'system'> | (string & {})
|
|
7
|
+
|
|
8
|
+
export interface ThemeMeta {
|
|
9
|
+
id: string
|
|
10
|
+
label: string
|
|
11
|
+
/** Whether the `dark` class (and `dark:` Tailwind variants) applies. */
|
|
12
|
+
dark: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const THEMES: ThemeMeta[] = [
|
|
16
|
+
{ id: 'light', label: 'Light', dark: false },
|
|
17
|
+
{ id: 'dark', label: 'Dark', dark: true },
|
|
18
|
+
{ id: 'night-owl', label: 'Night Owl', dark: true },
|
|
19
|
+
{ id: 'claude-code', label: 'Claude Code', dark: true },
|
|
20
|
+
{ id: 'flame', label: 'Flame', dark: true },
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
const SLUG = /^[a-z0-9][a-z0-9:_-]{0,79}$/
|
|
24
|
+
const registry = new Map<string, ThemeMeta>(THEMES.map((theme) => [theme.id, theme]))
|
|
25
|
+
|
|
26
|
+
/** Plugin-contributed themes register here so the app knows how to apply them. */
|
|
27
|
+
export function registerTheme(meta: ThemeMeta): void {
|
|
28
|
+
registry.set(meta.id, meta)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function unregisterTheme(id: string): void {
|
|
32
|
+
if (THEMES.some((theme) => theme.id === id)) return
|
|
33
|
+
registry.delete(id)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isThemeId(value: unknown): value is string {
|
|
37
|
+
return typeof value === 'string' && SLUG.test(value)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function resolveTheme(theme: ThemeId, prefersDark: boolean): string {
|
|
41
|
+
if (theme === 'system') return prefersDark ? 'dark' : 'light'
|
|
42
|
+
return isThemeId(theme) ? theme : 'light'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function themeIsDark(theme: ThemeId, prefersDark: boolean): boolean {
|
|
46
|
+
return registry.get(resolveTheme(theme, prefersDark))?.dark ?? false
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function normalizeTheme(value: unknown): ThemeId {
|
|
50
|
+
if (value === 'system') return 'system'
|
|
51
|
+
return isThemeId(value) ? value : 'system'
|
|
52
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import { computeUsage, dayKey, formatTokens, shortModelName } from './usage.ts'
|
|
4
|
+
|
|
5
|
+
const now = new Date(2026, 0, 15, 15, 0, 0, 0).getTime()
|
|
6
|
+
const hour = 3_600_000
|
|
7
|
+
const day = 86_400_000
|
|
8
|
+
|
|
9
|
+
function assistant(id, createdAt, model, content = '', usage) {
|
|
10
|
+
return {
|
|
11
|
+
id,
|
|
12
|
+
role: 'assistant',
|
|
13
|
+
content,
|
|
14
|
+
reasoning: '',
|
|
15
|
+
toolCalls: [],
|
|
16
|
+
model,
|
|
17
|
+
usage,
|
|
18
|
+
createdAt,
|
|
19
|
+
round: 0,
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function session(id, updatedAt, messages) {
|
|
24
|
+
return { id, title: id, createdAt: updatedAt, updatedAt, messages }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const sessions = [
|
|
28
|
+
session('recent', now, [
|
|
29
|
+
{ id: 'u1', role: 'user', content: 'hello', createdAt: now - hour },
|
|
30
|
+
assistant('a1', now - 2 * hour, 'anthropic/claude-sonnet', 'ok', { input: 100, output: 50 }),
|
|
31
|
+
assistant('a2', now - hour, 'anthropic/claude-sonnet', 'x'.repeat(40)),
|
|
32
|
+
]),
|
|
33
|
+
session('older', now - 10 * day, [
|
|
34
|
+
assistant('a3', now - 10 * day, 'openai/gpt-5', 'ok', { input: 10, output: 10 }),
|
|
35
|
+
]),
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
test('counts all sessions and messages', () => {
|
|
39
|
+
const stats = computeUsage(sessions, 'all', now)
|
|
40
|
+
assert.equal(stats.sessions, 2)
|
|
41
|
+
assert.equal(stats.messages, 4)
|
|
42
|
+
assert.equal(stats.tokens, 180)
|
|
43
|
+
assert.equal(stats.activeDays, 2)
|
|
44
|
+
assert.equal(stats.favoriteModel, 'anthropic/claude-sonnet')
|
|
45
|
+
assert.equal(stats.favoriteShare, 67)
|
|
46
|
+
assert.equal(stats.models.length, 2)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('applies the 7 day window', () => {
|
|
50
|
+
const stats = computeUsage(sessions, '7d', now)
|
|
51
|
+
assert.equal(stats.sessions, 1)
|
|
52
|
+
assert.equal(stats.messages, 3)
|
|
53
|
+
assert.equal(stats.tokens, 160)
|
|
54
|
+
assert.equal(stats.favoriteModel, 'anthropic/claude-sonnet')
|
|
55
|
+
assert.equal(stats.peakHour, '2 PM')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('builds a 26 week heatmap that includes the current day', () => {
|
|
59
|
+
const stats = computeUsage(sessions, 'all', now)
|
|
60
|
+
assert.equal(stats.heatmap.length, 26 * 7)
|
|
61
|
+
assert.equal(stats.busiestDay, 3)
|
|
62
|
+
const today = stats.heatmap.find((entry) => entry.key === dayKey(now))
|
|
63
|
+
assert.equal(today?.count, 3)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('formats token counts and model names', () => {
|
|
67
|
+
assert.equal(formatTokens(999), '999')
|
|
68
|
+
assert.equal(formatTokens(1_500), '1.5k')
|
|
69
|
+
assert.equal(formatTokens(5_400_000), '5.4M')
|
|
70
|
+
assert.equal(shortModelName('anthropic/claude-sonnet'), 'claude-sonnet')
|
|
71
|
+
assert.equal(shortModelName('gpt-5'), 'gpt-5')
|
|
72
|
+
})
|