@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,194 @@
|
|
|
1
|
+
import type { InstalledPlugin } from '../lib/daemon'
|
|
2
|
+
import { getInstalledPluginArchive } from '../lib/daemon'
|
|
3
|
+
import type { PluginContribution } from './types'
|
|
4
|
+
import { registerTheme, unregisterTheme } from '../lib/themes'
|
|
5
|
+
import { createPluginApi, emptyContributions, type PluginHostCalls } from './api'
|
|
6
|
+
import { readPluginArchive } from '../../shared/plugin-package.mjs'
|
|
7
|
+
import { kernel } from '../core/kernel'
|
|
8
|
+
import type { Context } from 'cordis'
|
|
9
|
+
|
|
10
|
+
const STYLE_ID = (id: string) => `maxcode-plugin-style-${id}`
|
|
11
|
+
|
|
12
|
+
export const PLUGIN_THEME_PREFIX = 'plugin-'
|
|
13
|
+
|
|
14
|
+
function tokens(palette: Record<string, string>): Array<[string, string]> {
|
|
15
|
+
return Object.entries(palette).filter(
|
|
16
|
+
([token, value]) => /^[a-z][a-z0-9-]*$/.test(token) && /^[\d\s.]+$/.test(value),
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isDarkPalette(palette: Record<string, string>): boolean {
|
|
21
|
+
const canvas = (palette.canvas ?? '').trim().split(/\s+/).map(Number)
|
|
22
|
+
const [r, g, b] = canvas.length === 3 ? canvas : [255, 255, 255]
|
|
23
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b < 110
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Plugin theme palettes become `[data-theme='<id>']` custom properties. */
|
|
27
|
+
export function themeCss(themeId: string, palette: Record<string, string>): string {
|
|
28
|
+
const entries = tokens(palette).map(([token, value]) => `--${token}:${value.trim()}`)
|
|
29
|
+
return entries.length === 0 ? '' : `[data-theme='${themeId}']{${entries.join(';')}}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function styleElement(id: string): HTMLStyleElement | null {
|
|
33
|
+
return document.getElementById(STYLE_ID(id)) as HTMLStyleElement | null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function applyStyles(id: string, css: string) {
|
|
37
|
+
let node = styleElement(id)
|
|
38
|
+
if (!node) {
|
|
39
|
+
node = document.createElement('style')
|
|
40
|
+
node.id = STYLE_ID(id)
|
|
41
|
+
document.head.append(node)
|
|
42
|
+
}
|
|
43
|
+
node.textContent = css
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function removeStyles(id: string) {
|
|
47
|
+
styleElement(id)?.remove()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface PluginRuntime {
|
|
51
|
+
contributions: Map<string, PluginContribution>
|
|
52
|
+
themes(): Array<{ id: string; label: string }>
|
|
53
|
+
contributionsFor(id: string): PluginContribution
|
|
54
|
+
commands(): Array<{ pluginId: string; id: string; label: string; run: () => void }>
|
|
55
|
+
activate(plugin: InstalledPlugin, calls: PluginHostCalls): Promise<void>
|
|
56
|
+
deactivate(id: string): void
|
|
57
|
+
deactivateAll(): void
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface PluginGlobals {
|
|
61
|
+
__maxcodePluginApi?: unknown
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function runPluginSource(source: string, api: unknown): Promise<void> {
|
|
65
|
+
const scoped = globalThis as typeof globalThis & PluginGlobals
|
|
66
|
+
const url = URL.createObjectURL(
|
|
67
|
+
new Blob([`const maxcode = globalThis.__maxcodePluginApi;\n${source}\n`], { type: 'text/javascript' }),
|
|
68
|
+
)
|
|
69
|
+
scoped.__maxcodePluginApi = api
|
|
70
|
+
try {
|
|
71
|
+
await import(/* @vite-ignore */ url)
|
|
72
|
+
} finally {
|
|
73
|
+
delete scoped.__maxcodePluginApi
|
|
74
|
+
URL.revokeObjectURL(url)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Same-realm execution for plugins the user marked Safe.
|
|
80
|
+
*
|
|
81
|
+
* **apiVersion 1** (legacy): plugin receives a restricted `PluginRuntimeApi`.
|
|
82
|
+
*
|
|
83
|
+
* **apiVersion 2** (free): plugin receives a Cordis child `Context` — full access
|
|
84
|
+
* to all kernel services: `ctx.tools.register()`, `ctx.ui.registerCommand()`,
|
|
85
|
+
* `ctx.on('agent/request', ...)`, `ctx.sessions`, `ctx.llm`, etc.
|
|
86
|
+
* The child scope is registered as a Cordis plugin (`kernel.plugin(fn)`).
|
|
87
|
+
* On deactivate, `fiber.dispose()` auto-cleans every hook and registration.
|
|
88
|
+
* The original source files on disk are never modified.
|
|
89
|
+
*/
|
|
90
|
+
export function createPluginRuntime(): PluginRuntime {
|
|
91
|
+
// v1 legacy: manual contribution tracking
|
|
92
|
+
const contributions = new Map<string, PluginContribution>()
|
|
93
|
+
// v2 free: Cordis fiber per plugin (dispose = full cleanup)
|
|
94
|
+
const fibers = new Map<string, { dispose(): Promise<void> }>()
|
|
95
|
+
|
|
96
|
+
const deactivate = (id: string) => {
|
|
97
|
+
// v2 cleanup
|
|
98
|
+
const fiber = fibers.get(id)
|
|
99
|
+
if (fiber) {
|
|
100
|
+
fibers.delete(id)
|
|
101
|
+
void fiber.dispose()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// v1 cleanup
|
|
105
|
+
const entry = contributions.get(id)
|
|
106
|
+
if (entry) for (const theme of entry.themes.keys()) unregisterTheme(`${PLUGIN_THEME_PREFIX}${id}:${theme}`)
|
|
107
|
+
contributions.delete(id)
|
|
108
|
+
|
|
109
|
+
removeStyles(id)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
contributions,
|
|
114
|
+
contributionsFor: (id) => contributions.get(id) ?? emptyContributions(),
|
|
115
|
+
themes() {
|
|
116
|
+
const out: Array<{ id: string; label: string }> = []
|
|
117
|
+
for (const [pluginId, entry] of contributions) {
|
|
118
|
+
for (const [id, theme] of entry.themes) {
|
|
119
|
+
out.push({ id: `${PLUGIN_THEME_PREFIX}${pluginId}:${id}`, label: theme.label })
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return out
|
|
123
|
+
},
|
|
124
|
+
commands() {
|
|
125
|
+
const out: Array<{ pluginId: string; id: string; label: string; run: () => void }> = []
|
|
126
|
+
for (const [pluginId, entry] of contributions) {
|
|
127
|
+
for (const [id, command] of entry.commands) out.push({ pluginId, id, label: command.label, run: command.run })
|
|
128
|
+
}
|
|
129
|
+
return out
|
|
130
|
+
},
|
|
131
|
+
async activate(plugin, calls) {
|
|
132
|
+
deactivate(plugin.id)
|
|
133
|
+
if (!plugin.safe || !plugin.enabled) return
|
|
134
|
+
|
|
135
|
+
const { files, manifest } = readPluginArchive(await getInstalledPluginArchive(plugin.id))
|
|
136
|
+
|
|
137
|
+
if (manifest.apiVersion === 2) {
|
|
138
|
+
// Free plugin: load as a Cordis plugin function so the child ctx is
|
|
139
|
+
// scoped and fiber.dispose() auto-removes all hooks/registrations.
|
|
140
|
+
let resolve!: () => void
|
|
141
|
+
let reject!: (e: unknown) => void
|
|
142
|
+
const ready = new Promise<void>((res, rej) => { resolve = res; reject = rej })
|
|
143
|
+
|
|
144
|
+
const pluginFn = async (ctx: Context) => {
|
|
145
|
+
// Apply stylesheet (DOM side-effect — cleaned up via removeStyles in deactivate)
|
|
146
|
+
const css = files[manifest.stylesheet] ?? ''
|
|
147
|
+
if (css) applyStyles(plugin.id, css)
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
await runPluginSource(files[manifest.entry] ?? '', ctx)
|
|
151
|
+
resolve()
|
|
152
|
+
} catch (error) {
|
|
153
|
+
reject(error)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const fiber = kernel.plugin(pluginFn)
|
|
158
|
+
fibers.set(plugin.id, fiber)
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
await ready
|
|
162
|
+
} catch (error) {
|
|
163
|
+
deactivate(plugin.id)
|
|
164
|
+
throw new Error(`Plugin ${plugin.id} failed to start: ${error instanceof Error ? error.message : String(error)}`)
|
|
165
|
+
}
|
|
166
|
+
} else {
|
|
167
|
+
// Legacy v1: restricted API
|
|
168
|
+
const entry = contributions.get(plugin.id) ?? emptyContributions()
|
|
169
|
+
contributions.set(plugin.id, entry)
|
|
170
|
+
|
|
171
|
+
const api = createPluginApi(plugin, plugin.grants, calls, entry)
|
|
172
|
+
try {
|
|
173
|
+
await runPluginSource(files[manifest.entry] ?? '', Object.freeze(api))
|
|
174
|
+
const themes = [...entry.themes.entries()]
|
|
175
|
+
.map(([id, theme]) => {
|
|
176
|
+
const themeId = `${PLUGIN_THEME_PREFIX}${plugin.id}:${id}`
|
|
177
|
+
registerTheme({ id: themeId, label: theme.label, dark: isDarkPalette(theme.palette) })
|
|
178
|
+
return themeCss(themeId, theme.palette)
|
|
179
|
+
})
|
|
180
|
+
.join('\n')
|
|
181
|
+
applyStyles(plugin.id, `${files[manifest.stylesheet] ?? ''}\n${themes}`)
|
|
182
|
+
} catch (error) {
|
|
183
|
+
deactivate(plugin.id)
|
|
184
|
+
throw new Error(`Plugin ${plugin.id} failed to start: ${error instanceof Error ? error.message : String(error)}`)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
deactivate,
|
|
189
|
+
deactivateAll() {
|
|
190
|
+
const ids = [...new Set([...contributions.keys(), ...fibers.keys()])]
|
|
191
|
+
for (const id of ids) deactivate(id)
|
|
192
|
+
},
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { InstalledPlugin } from '../lib/daemon'
|
|
2
|
+
import type { PluginCapability, PluginManifest } from '../../shared/plugin-package.mjs'
|
|
3
|
+
|
|
4
|
+
export type { PluginCapability, PluginManifest }
|
|
5
|
+
|
|
6
|
+
export interface PluginGrant {
|
|
7
|
+
capabilities: PluginCapability[]
|
|
8
|
+
domains: string[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface PluginRuntimeApi {
|
|
12
|
+
/** Declared identity of the running plugin. */
|
|
13
|
+
manifest: Readonly<PluginManifest>
|
|
14
|
+
version: number
|
|
15
|
+
registerCommand(id: string, label: string, run: () => void): void
|
|
16
|
+
registerPanel(id: string, label: string, render: (host: HTMLElement) => void): void
|
|
17
|
+
registerTheme(id: string, label: string, palette: Record<string, string>): void
|
|
18
|
+
workspace: {
|
|
19
|
+
list(path?: string): Promise<{ path: string; name: string; type: string }[]>
|
|
20
|
+
read(path: string): Promise<string>
|
|
21
|
+
write(path: string, content: string): Promise<void>
|
|
22
|
+
edit(path: string, from: string, to: string): Promise<boolean>
|
|
23
|
+
}
|
|
24
|
+
network: {
|
|
25
|
+
request(url: string, init?: { method?: 'GET' | 'POST'; headers?: Record<string, string>; body?: string }): Promise<{
|
|
26
|
+
status: number
|
|
27
|
+
contentType: string
|
|
28
|
+
body: string
|
|
29
|
+
}>
|
|
30
|
+
}
|
|
31
|
+
log(message: string): void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PluginContribution {
|
|
35
|
+
commands: Map<string, { label: string; run: () => void }>
|
|
36
|
+
panels: Map<string, { label: string; render: (host: HTMLElement) => void }>
|
|
37
|
+
themes: Map<string, { label: string; palette: Record<string, string> }>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type { InstalledPlugin }
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { create } from 'zustand'
|
|
2
|
+
import type { AgentMode, ChatSession, Message, ProviderId } from '../types'
|
|
3
|
+
import { csrfToken, saveChats } from '../lib/daemon'
|
|
4
|
+
|
|
5
|
+
function createId(prefix: string) {
|
|
6
|
+
const raw =
|
|
7
|
+
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
|
8
|
+
? crypto.randomUUID()
|
|
9
|
+
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`
|
|
10
|
+
return `${prefix}_${raw}`
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export { createId }
|
|
14
|
+
|
|
15
|
+
function isSession(value: unknown): value is ChatSession {
|
|
16
|
+
if (!value || typeof value !== 'object') return false
|
|
17
|
+
const session = value as ChatSession
|
|
18
|
+
return typeof session.id === 'string' && typeof session.title === 'string' && Array.isArray(session.messages)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizeSession(session: ChatSession): ChatSession {
|
|
22
|
+
let round = 0
|
|
23
|
+
const messages = session.messages
|
|
24
|
+
.filter((message): message is Message => Boolean(message && typeof message === 'object'))
|
|
25
|
+
.map((message, index) => {
|
|
26
|
+
if (message.role === 'user') {
|
|
27
|
+
if (index > 0) round += 1
|
|
28
|
+
return message
|
|
29
|
+
}
|
|
30
|
+
const existing = typeof message.round === 'number' ? message.round : round
|
|
31
|
+
if (message.role === 'assistant') {
|
|
32
|
+
return {
|
|
33
|
+
...message,
|
|
34
|
+
content: typeof message.content === 'string' ? message.content : '',
|
|
35
|
+
reasoning: typeof message.reasoning === 'string' ? message.reasoning : '',
|
|
36
|
+
toolCalls: Array.isArray(message.toolCalls) ? message.toolCalls : [],
|
|
37
|
+
round: existing,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { ...message, round: existing }
|
|
41
|
+
})
|
|
42
|
+
return {
|
|
43
|
+
...session,
|
|
44
|
+
title: session.title.trim() || 'New conversation',
|
|
45
|
+
messages,
|
|
46
|
+
createdAt: typeof session.createdAt === 'number' ? session.createdAt : Date.now(),
|
|
47
|
+
updatedAt: typeof session.updatedAt === 'number' ? session.updatedAt : Date.now(),
|
|
48
|
+
mode: session.mode === 'plan' ? 'plan' : session.mode === 'build' ? 'build' : undefined,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface PersistedChats {
|
|
53
|
+
sessions: ChatSession[]
|
|
54
|
+
activeId: string | null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface ChatsState {
|
|
58
|
+
sessions: ChatSession[]
|
|
59
|
+
activeId: string | null
|
|
60
|
+
hydrated: boolean
|
|
61
|
+
hydrate: (payload: PersistedChats) => void
|
|
62
|
+
setActive: (id: string | null) => void
|
|
63
|
+
newChat: () => void
|
|
64
|
+
createSession: (provider: ProviderId, model: string, mode?: AgentMode) => string
|
|
65
|
+
addMessage: (sessionId: string, message: Message) => void
|
|
66
|
+
updateMessage: (
|
|
67
|
+
sessionId: string,
|
|
68
|
+
messageId: string,
|
|
69
|
+
patch: Partial<Message> | ((message: Message) => Partial<Message>),
|
|
70
|
+
) => void
|
|
71
|
+
updateSession: (sessionId: string, patch: Partial<ChatSession>) => void
|
|
72
|
+
renameSession: (sessionId: string, title: string) => void
|
|
73
|
+
deleteSession: (sessionId: string) => void
|
|
74
|
+
clearAll: () => void
|
|
75
|
+
persistNow: () => Promise<void>
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function titleFromText(text: string) {
|
|
79
|
+
const clean = text.replace(/\s+/g, ' ').trim()
|
|
80
|
+
if (!clean) return 'New conversation'
|
|
81
|
+
return clean.length > 56 ? `${clean.slice(0, 56).trim()}…` : clean
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const useChats = create<ChatsState>()((set, get) => ({
|
|
85
|
+
sessions: [],
|
|
86
|
+
activeId: null,
|
|
87
|
+
hydrated: false,
|
|
88
|
+
hydrate: (payload) => {
|
|
89
|
+
const sessions = (payload.sessions ?? [])
|
|
90
|
+
.filter(isSession)
|
|
91
|
+
.map(normalizeSession)
|
|
92
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
93
|
+
const activeId =
|
|
94
|
+
typeof payload.activeId === 'string' && sessions.some((session) => session.id === payload.activeId)
|
|
95
|
+
? payload.activeId
|
|
96
|
+
: sessions[0]?.id ?? null
|
|
97
|
+
set({ sessions, activeId, hydrated: true })
|
|
98
|
+
},
|
|
99
|
+
setActive: (id) => set({ activeId: id }),
|
|
100
|
+
newChat: () => set({ activeId: null }),
|
|
101
|
+
createSession: (provider, model, mode) => {
|
|
102
|
+
const id = createId('chat')
|
|
103
|
+
const now = Date.now()
|
|
104
|
+
const session: ChatSession = {
|
|
105
|
+
id,
|
|
106
|
+
title: 'New conversation',
|
|
107
|
+
createdAt: now,
|
|
108
|
+
updatedAt: now,
|
|
109
|
+
messages: [],
|
|
110
|
+
provider,
|
|
111
|
+
model,
|
|
112
|
+
mode,
|
|
113
|
+
}
|
|
114
|
+
set((state) => ({ sessions: [session, ...state.sessions], activeId: id }))
|
|
115
|
+
return id
|
|
116
|
+
},
|
|
117
|
+
addMessage: (sessionId, message) => {
|
|
118
|
+
set((state) => ({
|
|
119
|
+
sessions: state.sessions.map((session) => {
|
|
120
|
+
if (session.id !== sessionId) return session
|
|
121
|
+
const patch: Partial<ChatSession> = {
|
|
122
|
+
messages: [...session.messages, message],
|
|
123
|
+
updatedAt: Date.now(),
|
|
124
|
+
}
|
|
125
|
+
if (message.role === 'user' && session.messages.length === 0) {
|
|
126
|
+
patch.title = titleFromText(message.content)
|
|
127
|
+
}
|
|
128
|
+
return { ...session, ...patch }
|
|
129
|
+
}),
|
|
130
|
+
}))
|
|
131
|
+
},
|
|
132
|
+
updateMessage: (sessionId, messageId, patch) => {
|
|
133
|
+
set((state) => ({
|
|
134
|
+
sessions: state.sessions.map((session) => {
|
|
135
|
+
if (session.id !== sessionId) return session
|
|
136
|
+
let changed = false
|
|
137
|
+
const messages = session.messages.map((message) => {
|
|
138
|
+
if (message.id !== messageId) return message
|
|
139
|
+
const next = typeof patch === 'function' ? patch(message) : patch
|
|
140
|
+
if (!next || Object.keys(next).length === 0) return message
|
|
141
|
+
changed = true
|
|
142
|
+
return { ...message, ...next } as Message
|
|
143
|
+
})
|
|
144
|
+
return changed ? { ...session, messages, updatedAt: Date.now() } : session
|
|
145
|
+
}),
|
|
146
|
+
}))
|
|
147
|
+
},
|
|
148
|
+
updateSession: (sessionId, patch) => {
|
|
149
|
+
set((state) => ({
|
|
150
|
+
sessions: state.sessions.map((session) =>
|
|
151
|
+
session.id === sessionId ? { ...session, ...patch, updatedAt: Date.now() } : session,
|
|
152
|
+
),
|
|
153
|
+
}))
|
|
154
|
+
},
|
|
155
|
+
renameSession: (sessionId, title) => {
|
|
156
|
+
const clean = title.trim()
|
|
157
|
+
if (!clean) return
|
|
158
|
+
get().updateSession(sessionId, { title: clean })
|
|
159
|
+
},
|
|
160
|
+
deleteSession: (sessionId) => {
|
|
161
|
+
set((state) => {
|
|
162
|
+
const sessions = state.sessions.filter((session) => session.id !== sessionId)
|
|
163
|
+
return {
|
|
164
|
+
sessions,
|
|
165
|
+
activeId: state.activeId === sessionId ? sessions[0]?.id ?? null : state.activeId,
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
},
|
|
169
|
+
clearAll: () => set({ sessions: [], activeId: null }),
|
|
170
|
+
persistNow: async () => {
|
|
171
|
+
const { sessions, activeId } = get()
|
|
172
|
+
await saveChats({ sessions, activeId })
|
|
173
|
+
},
|
|
174
|
+
}))
|
|
175
|
+
|
|
176
|
+
let persistenceEnabled = false
|
|
177
|
+
let saveTimer: number | undefined
|
|
178
|
+
let maxTimer: number | undefined
|
|
179
|
+
|
|
180
|
+
export function enableChatPersistence() {
|
|
181
|
+
persistenceEnabled = true
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function scheduleSave() {
|
|
185
|
+
if (!persistenceEnabled || typeof window === 'undefined') return
|
|
186
|
+
if (saveTimer !== undefined) window.clearTimeout(saveTimer)
|
|
187
|
+
saveTimer = window.setTimeout(() => {
|
|
188
|
+
saveTimer = undefined
|
|
189
|
+
void persistChats()
|
|
190
|
+
}, 900)
|
|
191
|
+
if (maxTimer === undefined) {
|
|
192
|
+
maxTimer = window.setTimeout(() => {
|
|
193
|
+
maxTimer = undefined
|
|
194
|
+
void persistChats()
|
|
195
|
+
}, 6000)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function persistChats() {
|
|
200
|
+
if (!persistenceEnabled) return
|
|
201
|
+
if (saveTimer !== undefined) {
|
|
202
|
+
window.clearTimeout(saveTimer)
|
|
203
|
+
saveTimer = undefined
|
|
204
|
+
}
|
|
205
|
+
if (maxTimer !== undefined) {
|
|
206
|
+
window.clearTimeout(maxTimer)
|
|
207
|
+
maxTimer = undefined
|
|
208
|
+
}
|
|
209
|
+
const { sessions, activeId } = useChats.getState()
|
|
210
|
+
try {
|
|
211
|
+
await saveChats({ sessions, activeId })
|
|
212
|
+
} catch (error) {
|
|
213
|
+
window.dispatchEvent(
|
|
214
|
+
new CustomEvent('maxcode:notice', {
|
|
215
|
+
detail: {
|
|
216
|
+
message: error instanceof Error ? error.message : 'Could not save conversations to the local daemon.',
|
|
217
|
+
tone: 'error',
|
|
218
|
+
},
|
|
219
|
+
}),
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
useChats.subscribe(scheduleSave)
|
|
225
|
+
|
|
226
|
+
if (typeof window !== 'undefined') {
|
|
227
|
+
const unload = () => {
|
|
228
|
+
if (!persistenceEnabled) return
|
|
229
|
+
const { sessions, activeId } = useChats.getState()
|
|
230
|
+
try {
|
|
231
|
+
const body = JSON.stringify({ sessions, activeId })
|
|
232
|
+
// Browsers cap keepalive bodies at 64 KB; a bigger one would be rejected, so rely on the debounced saves.
|
|
233
|
+
if (body.length > 60_000) return
|
|
234
|
+
void fetch('/api/chats', {
|
|
235
|
+
method: 'PUT',
|
|
236
|
+
keepalive: true,
|
|
237
|
+
headers: { 'Content-Type': 'application/json', 'x-maxcode-csrf': csrfToken() },
|
|
238
|
+
body,
|
|
239
|
+
}).catch(() => undefined)
|
|
240
|
+
} catch {
|
|
241
|
+
// The daemon will keep the last saved snapshot.
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
window.addEventListener('beforeunload', unload)
|
|
245
|
+
document.addEventListener('visibilitychange', () => {
|
|
246
|
+
if (document.visibilityState === 'hidden') unload()
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function exportChats() {
|
|
251
|
+
const data = useChats.getState().sessions
|
|
252
|
+
const blob = new Blob(
|
|
253
|
+
[JSON.stringify({ app: 'OpenSol', exportedAt: new Date().toISOString(), sessions: data }, null, 2)],
|
|
254
|
+
{ type: 'application/json' },
|
|
255
|
+
)
|
|
256
|
+
const url = URL.createObjectURL(blob)
|
|
257
|
+
const anchor = document.createElement('a')
|
|
258
|
+
anchor.href = url
|
|
259
|
+
anchor.download = `maxcode-history-${new Date().toISOString().slice(0, 10)}.json`
|
|
260
|
+
anchor.click()
|
|
261
|
+
URL.revokeObjectURL(url)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function importChats(file: File): Promise<number> {
|
|
265
|
+
return file.text().then((text) => {
|
|
266
|
+
const parsed = JSON.parse(text) as { sessions?: unknown[] }
|
|
267
|
+
if (!Array.isArray(parsed.sessions)) throw new Error('This file does not contain a OpenSol history.')
|
|
268
|
+
const sessions = parsed.sessions.filter(isSession).map(normalizeSession)
|
|
269
|
+
if (sessions.length === 0) throw new Error('No valid conversations were found in this file.')
|
|
270
|
+
const existing = new Set(useChats.getState().sessions.map((session) => session.id))
|
|
271
|
+
const added = sessions.filter((session) => !existing.has(session.id))
|
|
272
|
+
useChats.setState((state) => ({
|
|
273
|
+
sessions: [...added, ...state.sessions],
|
|
274
|
+
activeId: added[0]?.id ?? state.activeId,
|
|
275
|
+
}))
|
|
276
|
+
return added.length
|
|
277
|
+
})
|
|
278
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
import { create } from 'zustand'
|
|
3
|
+
import type { ApiKind, ModelInfo, ProviderId } from '../types'
|
|
4
|
+
import { PROVIDER_MAP } from '../data/providers'
|
|
5
|
+
import { fetchModels as fetchModelsFromDaemon } from '../lib/daemon'
|
|
6
|
+
import { activeModelId, hasCredentials, useSettings } from './settings'
|
|
7
|
+
|
|
8
|
+
const NO_MODELS: ModelInfo[] = []
|
|
9
|
+
const STALE_MS = 10 * 60 * 1000
|
|
10
|
+
|
|
11
|
+
interface ModelCatalogState {
|
|
12
|
+
byProvider: Record<string, ModelInfo[]>
|
|
13
|
+
loading: Record<string, boolean>
|
|
14
|
+
errors: Record<string, string>
|
|
15
|
+
notes: Record<string, string>
|
|
16
|
+
source: Record<string, 'live' | 'catalog' | ''>
|
|
17
|
+
fetchedAt: Record<string, number>
|
|
18
|
+
fetchModels: (provider: ProviderId, force?: boolean) => Promise<ModelInfo[]>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const useModelCatalog = create<ModelCatalogState>()((set, get) => ({
|
|
22
|
+
byProvider: {},
|
|
23
|
+
loading: {},
|
|
24
|
+
errors: {},
|
|
25
|
+
notes: {},
|
|
26
|
+
source: {},
|
|
27
|
+
fetchedAt: {},
|
|
28
|
+
fetchModels: async (provider, force = false) => {
|
|
29
|
+
if (get().loading[provider]) return get().byProvider[provider] ?? NO_MODELS
|
|
30
|
+
if (!force) {
|
|
31
|
+
const existing = get().byProvider[provider] ?? NO_MODELS
|
|
32
|
+
const fetchedAt = get().fetchedAt[provider] ?? 0
|
|
33
|
+
if (existing.length > 0 && Date.now() - fetchedAt < STALE_MS) return existing
|
|
34
|
+
}
|
|
35
|
+
set((state) => ({
|
|
36
|
+
loading: { ...state.loading, [provider]: true },
|
|
37
|
+
errors: { ...state.errors, [provider]: '' },
|
|
38
|
+
}))
|
|
39
|
+
try {
|
|
40
|
+
const result = await fetchModelsFromDaemon(provider)
|
|
41
|
+
set((state) => ({
|
|
42
|
+
byProvider: { ...state.byProvider, [provider]: result.models },
|
|
43
|
+
loading: { ...state.loading, [provider]: false },
|
|
44
|
+
errors: { ...state.errors, [provider]: '' },
|
|
45
|
+
notes: { ...state.notes, [provider]: result.note || result.catalogError || '' },
|
|
46
|
+
source: { ...state.source, [provider]: result.source },
|
|
47
|
+
fetchedAt: { ...state.fetchedAt, [provider]: result.fetchedAt || Date.now() },
|
|
48
|
+
}))
|
|
49
|
+
return result.models
|
|
50
|
+
} catch (error) {
|
|
51
|
+
const message = error instanceof Error ? error.message : 'Could not load models from this provider.'
|
|
52
|
+
set((state) => ({
|
|
53
|
+
loading: { ...state.loading, [provider]: false },
|
|
54
|
+
errors: { ...state.errors, [provider]: message },
|
|
55
|
+
source: { ...state.source, [provider]: '' },
|
|
56
|
+
}))
|
|
57
|
+
return get().byProvider[provider] ?? NO_MODELS
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
}))
|
|
61
|
+
|
|
62
|
+
export function useProviderModels(provider: ProviderId): ModelInfo[] {
|
|
63
|
+
return useModelCatalog((state) => state.byProvider[provider]) ?? NO_MODELS
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function useActiveModel(): ModelInfo | null {
|
|
67
|
+
const provider = useSettings((state) => state.provider)
|
|
68
|
+
const model = useSettings((state) => state.model)
|
|
69
|
+
return useModelCatalog((state) => {
|
|
70
|
+
if (!model) return null
|
|
71
|
+
return state.byProvider[provider]?.find((entry) => entry.id === model) ?? null
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function findModel(provider: ProviderId, modelId: string): ModelInfo | null {
|
|
76
|
+
if (!modelId) return null
|
|
77
|
+
return useModelCatalog.getState().byProvider[provider]?.find((entry) => entry.id === modelId) ?? null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function useModelSync() {
|
|
81
|
+
const hydrated = useSettings((state) => state.hydrated)
|
|
82
|
+
const provider = useSettings((state) => state.provider)
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (!hydrated) return
|
|
86
|
+
const settings = useSettings.getState()
|
|
87
|
+
if (!hasCredentials(settings)) return
|
|
88
|
+
const state = useModelCatalog.getState()
|
|
89
|
+
const models = state.byProvider[provider] ?? NO_MODELS
|
|
90
|
+
const fetchedAt = state.fetchedAt[provider] ?? 0
|
|
91
|
+
if (models.length > 0 && Date.now() - fetchedAt < STALE_MS) return
|
|
92
|
+
void state.fetchModels(provider)
|
|
93
|
+
}, [hydrated, provider])
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The transport a model needs. Live model metadata wins; manual IDs fall back
|
|
98
|
+
* to the provider family, with OpenCode inferred from the model id.
|
|
99
|
+
*/
|
|
100
|
+
export function apiKindFor(provider: ProviderId, modelId: string, model?: ModelInfo | null): ApiKind {
|
|
101
|
+
if (model?.apiKind) return model.apiKind
|
|
102
|
+
const preset = PROVIDER_MAP[provider]
|
|
103
|
+
if (provider !== 'opencode') {
|
|
104
|
+
if (preset?.format === 'anthropic') return 'anthropic'
|
|
105
|
+
if (preset?.format === 'gemini') return 'gemini'
|
|
106
|
+
return 'openai-chat'
|
|
107
|
+
}
|
|
108
|
+
const id = modelId.toLowerCase()
|
|
109
|
+
if (/gemini|gemma/.test(id)) return 'gemini'
|
|
110
|
+
if (/claude|qwen.*(flash|plus)/.test(id)) return 'anthropic'
|
|
111
|
+
if (/gpt|grok|muse|codex/.test(id)) return 'openai-responses'
|
|
112
|
+
return 'openai-chat'
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function activeModelInfo(): ModelInfo | null {
|
|
116
|
+
const settings = useSettings.getState()
|
|
117
|
+
return findModel(settings.provider, activeModelId(settings))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export { NO_MODELS }
|