@bibobonking/opensol 0.0.0-stage → 3.0.2

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.
Files changed (127) hide show
  1. package/.github/workflows/build.yml +25 -0
  2. package/README.md +74 -2
  3. package/dist/assets/index-BmN7pBji.css +1 -0
  4. package/dist/assets/index-DgNDjkOz.js +301 -0
  5. package/dist/favicon.svg +15 -0
  6. package/dist/index.html +55 -0
  7. package/dist/preview-host.html +33 -0
  8. package/index.html +54 -0
  9. package/package.json +43 -4
  10. package/postcss.config.js +6 -0
  11. package/public/favicon.svg +15 -0
  12. package/public/preview-host.html +33 -0
  13. package/server/README.md +75 -0
  14. package/server/daemon.mjs +1082 -0
  15. package/server/lib/checkpoints.mjs +124 -0
  16. package/server/lib/checkpoints.test.mjs +110 -0
  17. package/server/lib/deploy.mjs +436 -0
  18. package/server/lib/http.mjs +237 -0
  19. package/server/lib/plugins.mjs +321 -0
  20. package/server/lib/plugins.test.mjs +140 -0
  21. package/server/lib/providers.mjs +594 -0
  22. package/server/lib/store.mjs +491 -0
  23. package/server/lib/tools.mjs +990 -0
  24. package/server/opensol.mjs +202 -0
  25. package/server/package.json +42 -0
  26. package/shared/plugin-builder.test.mjs +43 -0
  27. package/shared/plugin-package.d.mts +34 -0
  28. package/shared/plugin-package.mjs +197 -0
  29. package/shared/plugin-package.test.mjs +94 -0
  30. package/src/App.tsx +152 -0
  31. package/src/agent/adapters/anthropic.ts +233 -0
  32. package/src/agent/adapters/gemini.ts +225 -0
  33. package/src/agent/adapters/openai-responses.ts +215 -0
  34. package/src/agent/adapters/openai.ts +200 -0
  35. package/src/agent/adapters/shared.ts +96 -0
  36. package/src/agent/approval.ts +20 -0
  37. package/src/agent/compact.ts +234 -0
  38. package/src/agent/index.ts +36 -0
  39. package/src/agent/model.ts +20 -0
  40. package/src/agent/prompt.ts +107 -0
  41. package/src/agent/queue.test.mjs +111 -0
  42. package/src/agent/queue.ts +84 -0
  43. package/src/agent/subagent.ts +109 -0
  44. package/src/agent/tools.ts +541 -0
  45. package/src/components/ChatView.tsx +403 -0
  46. package/src/components/CommandPalette.tsx +416 -0
  47. package/src/components/Composer.tsx +678 -0
  48. package/src/components/ContextBar.tsx +58 -0
  49. package/src/components/DeployModal.tsx +512 -0
  50. package/src/components/ErrorBoundary.tsx +75 -0
  51. package/src/components/HomeDashboard.tsx +189 -0
  52. package/src/components/Logo.tsx +41 -0
  53. package/src/components/Markdown.tsx +232 -0
  54. package/src/components/MessageItem.tsx +655 -0
  55. package/src/components/ModelPicker.tsx +487 -0
  56. package/src/components/PixleSol.tsx +243 -0
  57. package/src/components/PluginBuilder.tsx +285 -0
  58. package/src/components/PluginManager.tsx +341 -0
  59. package/src/components/PreviewPane.tsx +166 -0
  60. package/src/components/SettingsModal.tsx +2052 -0
  61. package/src/components/Sidebar.tsx +504 -0
  62. package/src/components/Splash.tsx +104 -0
  63. package/src/components/ToolCallCard.tsx +177 -0
  64. package/src/components/TopBar.tsx +182 -0
  65. package/src/components/TrajectoryView.tsx +178 -0
  66. package/src/components/WorkspacePanel.tsx +710 -0
  67. package/src/components/ui.tsx +311 -0
  68. package/src/core/kernel.ts +45 -0
  69. package/src/core/plugins/tools/fs.ts +219 -0
  70. package/src/core/plugins/tools/git.ts +83 -0
  71. package/src/core/plugins/tools/grep.ts +98 -0
  72. package/src/core/plugins/tools/shell.ts +88 -0
  73. package/src/core/plugins/tools/subagent.ts +36 -0
  74. package/src/core/plugins/tools/web.ts +97 -0
  75. package/src/core/services/agent-loop.ts +417 -0
  76. package/src/core/services/approval.ts +66 -0
  77. package/src/core/services/compaction.ts +45 -0
  78. package/src/core/services/llm.ts +32 -0
  79. package/src/core/services/sessions.ts +47 -0
  80. package/src/core/services/subagents.ts +15 -0
  81. package/src/core/services/system-prompt.ts +177 -0
  82. package/src/core/services/tools.ts +135 -0
  83. package/src/core/services/ui.ts +109 -0
  84. package/src/core/types.ts +139 -0
  85. package/src/data/providers.ts +125 -0
  86. package/src/data/skills.ts +68 -0
  87. package/src/index.css +776 -0
  88. package/src/lib/boot.ts +94 -0
  89. package/src/lib/context.ts +75 -0
  90. package/src/lib/conversation.test.mjs +64 -0
  91. package/src/lib/conversation.ts +59 -0
  92. package/src/lib/daemon.ts +475 -0
  93. package/src/lib/hooks.ts +33 -0
  94. package/src/lib/permissions.ts +60 -0
  95. package/src/lib/pixlesol.test.mjs +51 -0
  96. package/src/lib/pixlesol.ts +55 -0
  97. package/src/lib/plugin-folder.test.mjs +145 -0
  98. package/src/lib/plugin-folder.ts +96 -0
  99. package/src/lib/reasoning.ts +87 -0
  100. package/src/lib/sse.ts +75 -0
  101. package/src/lib/themes.test.mjs +72 -0
  102. package/src/lib/themes.ts +52 -0
  103. package/src/lib/usage.test.mjs +72 -0
  104. package/src/lib/usage.ts +155 -0
  105. package/src/lib/utils.ts +73 -0
  106. package/src/main.tsx +16 -0
  107. package/src/plugins/api.test.mjs +77 -0
  108. package/src/plugins/api.ts +113 -0
  109. package/src/plugins/bridge.d.mts +1 -0
  110. package/src/plugins/bridge.mjs +26 -0
  111. package/src/plugins/bridge.test.mjs +17 -0
  112. package/src/plugins/index.ts +52 -0
  113. package/src/plugins/preview.test.mjs +18 -0
  114. package/src/plugins/preview.ts +34 -0
  115. package/src/plugins/runtime.ts +194 -0
  116. package/src/plugins/types.ts +40 -0
  117. package/src/store/chats.ts +278 -0
  118. package/src/store/models.ts +120 -0
  119. package/src/store/settings.ts +445 -0
  120. package/src/store/ui.ts +132 -0
  121. package/src/store/workspace.ts +299 -0
  122. package/src/types.ts +337 -0
  123. package/src/vite-env.d.ts +1 -0
  124. package/tailwind.config.js +83 -0
  125. package/tsconfig.json +22 -0
  126. package/tsconfig.tsbuildinfo +1 -0
  127. 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 }