@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
package/src/App.tsx ADDED
@@ -0,0 +1,152 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { Sidebar } from './components/Sidebar'
3
+ import { ChatView } from './components/ChatView'
4
+ import { WorkspacePanel } from './components/WorkspacePanel'
5
+ import { SettingsModal } from './components/SettingsModal'
6
+ import { DeployModal } from './components/DeployModal'
7
+ import { CommandPalette } from './components/CommandPalette'
8
+ import { Splash } from './components/Splash'
9
+ import { ConfirmDialog, ToastHost } from './components/ui'
10
+ import { useSettings } from './store/settings'
11
+ import { useModelSync } from './store/models'
12
+ import { useUi } from './store/ui'
13
+ import { useBoot } from './lib/boot'
14
+ import { resolveTheme, themeIsDark } from './lib/themes'
15
+ import { listInstalledPlugins } from './lib/daemon'
16
+ import { syncPlugins } from './plugins'
17
+
18
+ const MIN_SPLASH_MS = 1600
19
+
20
+ export default function App() {
21
+ const phase = useBoot((state) => state.phase)
22
+ const error = useBoot((state) => state.error)
23
+ const boot = useBoot((state) => state.boot)
24
+ const theme = useSettings((state) => state.theme)
25
+ const chatFontSize = useSettings((state) => state.chatFontSize)
26
+ const mobileSidebarOpen = useUi((state) => state.mobileSidebarOpen)
27
+ const setMobileSidebarOpen = useUi((state) => state.setMobileSidebarOpen)
28
+ const setPaletteOpen = useUi((state) => state.setPaletteOpen)
29
+
30
+ useModelSync()
31
+
32
+ // Keep the splash up for at least MIN_SPLASH_MS so the intro animation can play,
33
+ // then let it fade out over the app instead of cutting.
34
+ const [splash, setSplash] = useState<'show' | 'leaving' | 'gone'>('show')
35
+ useEffect(() => {
36
+ if (phase !== 'ready' || splash !== 'show') return
37
+ const started = useBoot.getState().startedAt || Date.now()
38
+ const wait = Math.max(0, MIN_SPLASH_MS - (Date.now() - started))
39
+ const t1 = window.setTimeout(() => setSplash('leaving'), wait)
40
+ return () => window.clearTimeout(t1)
41
+ }, [phase, splash])
42
+ useEffect(() => {
43
+ if (splash !== 'leaving') return
44
+ const t2 = window.setTimeout(() => setSplash('gone'), 520)
45
+ return () => window.clearTimeout(t2)
46
+ }, [splash])
47
+
48
+ useEffect(() => {
49
+ void boot()
50
+ }, [boot])
51
+
52
+ // Safe + enabled plugins are loaded once the app knows the daemon answered.
53
+ useEffect(() => {
54
+ if (phase !== 'ready') return
55
+ void listInstalledPlugins()
56
+ .then((result) => syncPlugins(result.plugins))
57
+ .catch(() => undefined)
58
+ }, [phase])
59
+
60
+ useEffect(() => {
61
+ const root = document.documentElement
62
+ const media = window.matchMedia('(prefers-color-scheme: dark)')
63
+ const apply = () => {
64
+ const resolved = resolveTheme(theme, media.matches)
65
+ root.dataset.theme = resolved
66
+ root.classList.toggle('dark', themeIsDark(theme, media.matches))
67
+ document
68
+ .querySelector('meta[name="theme-color"]')
69
+ ?.setAttribute('content', themeIsDark(theme, media.matches) ? '#141413' : '#ffffff')
70
+ try {
71
+ localStorage.setItem('opensol.theme', theme)
72
+ } catch {
73
+ // Storage can be unavailable in private mode; the theme still applies.
74
+ }
75
+ }
76
+ apply()
77
+ if (theme !== 'system') return
78
+ media.addEventListener('change', apply)
79
+ return () => media.removeEventListener('change', apply)
80
+ }, [theme])
81
+
82
+ useEffect(() => {
83
+ document.documentElement.style.setProperty('--chat-font-size', `${chatFontSize}px`)
84
+ }, [chatFontSize])
85
+
86
+ useEffect(() => {
87
+ const onKeyDown = (event: KeyboardEvent) => {
88
+ const mod = event.metaKey || event.ctrlKey
89
+ const key = event.key.toLowerCase()
90
+ if (mod && key === 'k') {
91
+ event.preventDefault()
92
+ setPaletteOpen(!useUi.getState().paletteOpen)
93
+ return
94
+ }
95
+ if (mod && !event.shiftKey && !event.altKey) {
96
+ if (key === 'b') {
97
+ event.preventDefault()
98
+ useUi.getState().toggleSidebar()
99
+ return
100
+ }
101
+ if (key === ',') {
102
+ event.preventDefault()
103
+ useUi.getState().openSettings('general')
104
+ return
105
+ }
106
+ if (key === '.') {
107
+ event.preventDefault()
108
+ const ui = useUi.getState()
109
+ ui.setWorkspaceOpen(!ui.workspaceOpen)
110
+ return
111
+ }
112
+ }
113
+ if (event.key === 'Escape' && useUi.getState().mobileSidebarOpen) {
114
+ setMobileSidebarOpen(false)
115
+ }
116
+ }
117
+ window.addEventListener('keydown', onKeyDown)
118
+ return () => window.removeEventListener('keydown', onKeyDown)
119
+ }, [setMobileSidebarOpen, setPaletteOpen])
120
+
121
+ const ready = phase === 'ready'
122
+
123
+ return (
124
+ <div className="flex h-dvh min-h-0 overflow-hidden bg-canvas text-ink">
125
+ {ready && (
126
+ <>
127
+ <Sidebar />
128
+ {mobileSidebarOpen && (
129
+ <button
130
+ type="button"
131
+ aria-label="Close sidebar"
132
+ onClick={() => setMobileSidebarOpen(false)}
133
+ className="modal-scrim fixed inset-0 z-40 cursor-default bg-black/30 backdrop-blur-[2px] lg:hidden"
134
+ />
135
+ )}
136
+ <div className="relative flex min-h-0 min-w-0 flex-1 bg-panel lg:my-1.5 lg:mr-1.5 lg:overflow-hidden lg:rounded-xl lg:border lg:border-line lg:shadow-pill">
137
+ <ChatView />
138
+ <WorkspacePanel />
139
+ </div>
140
+ <SettingsModal />
141
+ <DeployModal />
142
+ <CommandPalette />
143
+ <ConfirmDialog />
144
+ </>
145
+ )}
146
+ <ToastHost />
147
+ {splash !== 'gone' && (
148
+ <Splash phase={phase} error={error} leaving={splash === 'leaving'} onRetry={() => void boot(true)} />
149
+ )}
150
+ </div>
151
+ )
152
+ }
@@ -0,0 +1,233 @@
1
+ import type { Message, ToolCall } from '../../types'
2
+ import { requestLLM } from '../../lib/daemon'
3
+ import { budgetForEffort, effortValues, hasBudget, normalizeEffort } from '../../lib/reasoning'
4
+ import { readSSE } from '../../lib/sse'
5
+ import { getToolSchemas } from '../tools'
6
+ import {
7
+ assistantHistory,
8
+ imageAttachments,
9
+ parseToolArgs,
10
+ toolResultText,
11
+ userText,
12
+ type AdapterRequest,
13
+ type AdapterResult,
14
+ } from './shared'
15
+
16
+ type AnthropicBlock = Record<string, unknown>
17
+ type AnthropicMessage = { role: 'user' | 'assistant'; content: AnthropicBlock[] }
18
+
19
+ function buildMessages(history: Message[]): AnthropicMessage[] {
20
+ const messages: AnthropicMessage[] = []
21
+ const push = (role: 'user' | 'assistant', blocks: AnthropicBlock[]) => {
22
+ if (blocks.length === 0) return
23
+ const last = messages[messages.length - 1]
24
+ if (last?.role === role) last.content.push(...blocks)
25
+ else messages.push({ role, content: blocks })
26
+ }
27
+
28
+ for (const message of history) {
29
+ if (message.role === 'user') {
30
+ const blocks: AnthropicBlock[] = []
31
+ for (const image of imageAttachments(message)) {
32
+ const match = /^data:(image\/[a-z0-9.+-]+);base64,(.+)$/i.exec(image.dataUrl ?? '')
33
+ if (!match) continue
34
+ blocks.push({
35
+ type: 'image',
36
+ source: { type: 'base64', media_type: match[1], data: match[2] },
37
+ })
38
+ }
39
+ const text = userText(message)
40
+ if (text.trim()) blocks.push({ type: 'text', text })
41
+ if (blocks.length === 0) blocks.push({ type: 'text', text: '(empty message)' })
42
+ push('user', blocks)
43
+ continue
44
+ }
45
+
46
+ if (message.role === 'assistant') {
47
+ if (!assistantHistory(message)) continue
48
+ const blocks: AnthropicBlock[] = []
49
+ for (const thinking of message.thinkingBlocks ?? []) {
50
+ if (thinking.signature) {
51
+ blocks.push({ type: 'thinking', thinking: thinking.text, signature: thinking.signature })
52
+ }
53
+ }
54
+ if (message.content.trim()) blocks.push({ type: 'text', text: message.content })
55
+ for (const call of message.toolCalls) {
56
+ blocks.push({ type: 'tool_use', id: call.id, name: call.name, input: call.args })
57
+ }
58
+ push('assistant', blocks)
59
+ continue
60
+ }
61
+
62
+ push('user', [
63
+ {
64
+ type: 'tool_result',
65
+ tool_use_id: message.call.id,
66
+ content: toolResultText(message.result, message.status),
67
+ is_error: message.status === 'error' || message.status === 'denied',
68
+ },
69
+ ])
70
+ }
71
+
72
+ return messages
73
+ }
74
+
75
+ export async function streamAnthropic(request: AdapterRequest): Promise<AdapterResult> {
76
+ const { settings, model, history, system, mode, signal, handlers } = request
77
+ const modelId = settings.model.trim()
78
+ if (!modelId) throw new Error('Choose a model before sending a message.')
79
+
80
+ const efforts = effortValues(model)
81
+ const selectedEffort = normalizeEffort(settings.effort, model)
82
+ const thinkingRequested = selectedEffort !== 'none' && (efforts.length > 0 || hasBudget(model))
83
+
84
+ let maxTokens = settings.maxTokens
85
+ let budget = 0
86
+ if (thinkingRequested && efforts.length === 0 && hasBudget(model)) {
87
+ budget = budgetForEffort(selectedEffort, model)
88
+ maxTokens = Math.max(settings.maxTokens, budget + 1024)
89
+ if (model?.maxOutput) maxTokens = Math.min(maxTokens, model.maxOutput)
90
+ budget = Math.min(budget, maxTokens - 1024)
91
+ if (budget < 1024) budget = 0
92
+ }
93
+
94
+ const tools = getToolSchemas(settings, mode).map((tool) => ({
95
+ name: tool.name,
96
+ description: tool.description,
97
+ input_schema: tool.parameters,
98
+ }))
99
+
100
+ const body: Record<string, unknown> = {
101
+ model: modelId,
102
+ max_tokens: maxTokens,
103
+ system,
104
+ messages: buildMessages(history),
105
+ stream: true,
106
+ }
107
+ if (tools.length > 0) body.tools = tools
108
+ if (thinkingRequested && efforts.length > 0) {
109
+ // Anthropic output_config.effort only accepts: low, medium, high, xhigh, max
110
+ // Map unsupported values (none, minimal) to the closest valid value.
111
+ const anthropicEffort: string = selectedEffort === 'minimal' ? 'low' : selectedEffort
112
+ body.output_config = { effort: anthropicEffort }
113
+ } else if (budget > 0) {
114
+ body.thinking = { type: 'enabled', budget_tokens: budget }
115
+ } else if (!thinkingRequested) {
116
+ const supportsTemperature = model ? model.temperature : true
117
+ if (supportsTemperature) body.temperature = settings.temperature
118
+ }
119
+
120
+ const response = await requestLLM(settings.provider, 'anthropic', modelId, body, signal)
121
+
122
+ let content = ''
123
+ let reasoning = ''
124
+ let usage: AdapterResult['usage'] = {}
125
+ const blocks = new Map<
126
+ number,
127
+ {
128
+ type: string
129
+ text: string
130
+ signature: string
131
+ id: string
132
+ name: string
133
+ json: string
134
+ input: unknown
135
+ }
136
+ >()
137
+
138
+ for await (const data of readSSE(response)) {
139
+ let payload: Record<string, unknown>
140
+ try {
141
+ payload = JSON.parse(data) as Record<string, unknown>
142
+ } catch {
143
+ continue
144
+ }
145
+ const type = payload.type
146
+
147
+ if (type === 'error') {
148
+ const error = payload.error as { message?: string } | string | undefined
149
+ throw new Error(typeof error === 'string' ? error : (error?.message ?? 'Anthropic returned an error.'))
150
+ }
151
+
152
+ if (type === 'message_start') {
153
+ const message = payload.message as { usage?: { input_tokens?: number } } | undefined
154
+ usage = { ...usage, input: message?.usage?.input_tokens }
155
+ continue
156
+ }
157
+
158
+ if (type === 'content_block_start') {
159
+ const index = Number(payload.index ?? blocks.size)
160
+ const block = payload.content_block as Record<string, unknown>
161
+ blocks.set(index, {
162
+ type: String(block.type ?? ''),
163
+ text: typeof block.text === 'string' ? block.text : typeof block.thinking === 'string' ? block.thinking : '',
164
+ signature: typeof block.signature === 'string' ? block.signature : '',
165
+ id: typeof block.id === 'string' ? block.id : '',
166
+ name: typeof block.name === 'string' ? block.name : '',
167
+ json: '',
168
+ input: block.input,
169
+ })
170
+ continue
171
+ }
172
+
173
+ if (type === 'content_block_delta') {
174
+ const index = Number(payload.index ?? 0)
175
+ const entry = blocks.get(index)
176
+ const delta = payload.delta as Record<string, unknown> | undefined
177
+ if (!entry || !delta) continue
178
+ if (delta.type === 'text_delta' && typeof delta.text === 'string') {
179
+ entry.text += delta.text
180
+ content += delta.text
181
+ handlers.onText(delta.text)
182
+ } else if (delta.type === 'thinking_delta' && typeof delta.thinking === 'string') {
183
+ entry.text += delta.thinking
184
+ reasoning += delta.thinking
185
+ handlers.onReasoning(delta.thinking)
186
+ } else if (delta.type === 'signature_delta' && typeof delta.signature === 'string') {
187
+ entry.signature += delta.signature
188
+ } else if (delta.type === 'input_json_delta' && typeof delta.partial_json === 'string') {
189
+ entry.json += delta.partial_json
190
+ }
191
+ continue
192
+ }
193
+
194
+ if (type === 'message_delta') {
195
+ const deltaUsage = payload.usage as { output_tokens?: number } | undefined
196
+ usage = { ...usage, output: deltaUsage?.output_tokens ?? usage.output }
197
+ handlers.onUsage(usage)
198
+ continue
199
+ }
200
+
201
+ if (type === 'message_stop') break
202
+ }
203
+
204
+ const availableTools = new Set<string>(tools.map((tool) => tool.name))
205
+ const toolCalls: ToolCall[] = []
206
+ const thinkingBlocks: Array<{ text: string; signature?: string }> = []
207
+ for (const [, block] of [...blocks.entries()].sort((a, b) => a[0] - b[0])) {
208
+ if (block.type === 'tool_use' && block.name) {
209
+ if (!availableTools.has(block.name)) {
210
+ throw new Error(`The model requested a tool that is not enabled: ${block.name}`)
211
+ }
212
+ const args = block.json
213
+ ? parseToolArgs(block.json, block.name)
214
+ : block.input && typeof block.input === 'object'
215
+ ? (block.input as Record<string, unknown>)
216
+ : {}
217
+ toolCalls.push({
218
+ id: block.id || `call_${Date.now().toString(36)}_${toolCalls.length}`,
219
+ name: block.name as ToolCall['name'],
220
+ args,
221
+ })
222
+ }
223
+ if (block.type === 'thinking' && block.text) {
224
+ thinkingBlocks.push({
225
+ text: block.text,
226
+ signature: block.signature || undefined,
227
+ })
228
+ }
229
+ }
230
+ if (toolCalls.length > 0) handlers.onToolCalls(toolCalls)
231
+
232
+ return { content, reasoning, toolCalls, thinkingBlocks, usage }
233
+ }
@@ -0,0 +1,225 @@
1
+ import type { Message, ToolCall } from '../../types'
2
+ import { requestLLM } from '../../lib/daemon'
3
+ import { budgetForEffort, effortValues, hasBudget, normalizeEffort, hasToggle } from '../../lib/reasoning'
4
+ import { readSSE } from '../../lib/sse'
5
+ import { getToolSchemas } from '../tools'
6
+ import {
7
+ assistantHistory,
8
+ imageAttachments,
9
+ toolResultText,
10
+ userText,
11
+ type AdapterRequest,
12
+ type AdapterResult,
13
+ } from './shared'
14
+
15
+ interface GeminiContent {
16
+ role: 'user' | 'model'
17
+ parts: Array<Record<string, unknown>>
18
+ }
19
+
20
+ function buildContents(history: Message[]): GeminiContent[] {
21
+ const contents: GeminiContent[] = []
22
+ const push = (role: 'user' | 'model', parts: Array<Record<string, unknown>>) => {
23
+ if (parts.length === 0) return
24
+ const last = contents[contents.length - 1]
25
+ if (last?.role === role) last.parts.push(...parts)
26
+ else contents.push({ role, parts })
27
+ }
28
+
29
+ for (const message of history) {
30
+ if (message.role === 'user') {
31
+ const parts: Array<Record<string, unknown>> = []
32
+ for (const image of imageAttachments(message)) {
33
+ const match = /^data:([^;]+);base64,(.+)$/i.exec(image.dataUrl ?? '')
34
+ if (!match) continue
35
+ parts.push({ inlineData: { mimeType: match[1], data: match[2] } })
36
+ }
37
+ const text = userText(message)
38
+ if (text.trim()) parts.push({ text })
39
+ if (parts.length === 0) parts.push({ text: '(empty message)' })
40
+ push('user', parts)
41
+ continue
42
+ }
43
+
44
+ if (message.role === 'assistant') {
45
+ if (!assistantHistory(message)) continue
46
+ const parts: Array<Record<string, unknown>> = []
47
+ if (message.content.trim()) parts.push({ text: message.content })
48
+ for (const call of message.toolCalls) {
49
+ parts.push({
50
+ functionCall: { name: call.name, args: call.args },
51
+ ...(call.thoughtSignature ? { thoughtSignature: call.thoughtSignature } : {}),
52
+ })
53
+ }
54
+ push('model', parts)
55
+ continue
56
+ }
57
+
58
+ push('user', [
59
+ {
60
+ functionResponse: {
61
+ name: message.call.name,
62
+ response: {
63
+ result: toolResultText(message.result, message.status),
64
+ ...(message.status === 'error' || message.status === 'denied' ? { isError: true } : {}),
65
+ },
66
+ },
67
+ },
68
+ ])
69
+ }
70
+
71
+ return contents
72
+ }
73
+
74
+ function cleanSchema(value: unknown): unknown {
75
+ if (Array.isArray(value)) return value.map(cleanSchema)
76
+ if (!value || typeof value !== 'object') return value
77
+ const result: Record<string, unknown> = {}
78
+ for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
79
+ if (key === 'additionalProperties' || key === '$schema') continue
80
+ result[key] = cleanSchema(entry)
81
+ }
82
+ return result
83
+ }
84
+
85
+ export async function streamGemini(request: AdapterRequest): Promise<AdapterResult> {
86
+ const { settings, model, history, system, mode, signal, handlers } = request
87
+ const modelId = settings.model.trim()
88
+ if (!modelId) throw new Error('Choose a model before sending a message.')
89
+
90
+ const efforts = effortValues(model)
91
+ const selectedEffort = normalizeEffort(settings.effort, model)
92
+ const budget = hasBudget(model) ? budgetForEffort(selectedEffort, model) : 0
93
+ const thinkingRequested = selectedEffort !== 'none' && (efforts.length > 0 || budget > 0)
94
+ let maxOutputTokens = settings.maxTokens
95
+ if (thinkingRequested && budget > 0) {
96
+ maxOutputTokens = Math.max(maxOutputTokens, budget + 2048)
97
+ if (model?.maxOutput) maxOutputTokens = Math.min(maxOutputTokens, model.maxOutput)
98
+ }
99
+
100
+ const tools = getToolSchemas(settings, mode)
101
+
102
+ const generationConfig: Record<string, unknown> = {
103
+ maxOutputTokens,
104
+ }
105
+ if (model ? model.temperature : true) generationConfig.temperature = settings.temperature
106
+ if (thinkingRequested && efforts.length > 0) {
107
+ // Gemini API requires UPPERCASE thinking level values: MINIMAL, LOW, MEDIUM, HIGH
108
+ generationConfig.thinkingConfig = { thinkingLevel: selectedEffort.toUpperCase(), includeThoughts: true }
109
+ } else if (thinkingRequested && budget > 0) {
110
+ generationConfig.thinkingConfig = {
111
+ thinkingBudget: Math.min(budget, Math.max(1024, maxOutputTokens - 1024)),
112
+ includeThoughts: true,
113
+ }
114
+ } else if (thinkingRequested && hasToggle(model)) {
115
+ generationConfig.thinkingConfig = { includeThoughts: true }
116
+ }
117
+
118
+ const body: Record<string, unknown> = {
119
+ systemInstruction: { parts: [{ text: system }] },
120
+ contents: buildContents(history),
121
+ generationConfig,
122
+ }
123
+ if (tools.length > 0) {
124
+ body.tools = [
125
+ {
126
+ functionDeclarations: tools.map((tool) => ({
127
+ name: tool.name,
128
+ description: tool.description,
129
+ parameters: cleanSchema(tool.parameters),
130
+ })),
131
+ },
132
+ ]
133
+ body.toolConfig = { functionCallingConfig: { mode: 'AUTO' } }
134
+ }
135
+
136
+ const response = await requestLLM(settings.provider, 'gemini', modelId, body, signal)
137
+
138
+ let content = ''
139
+ let reasoning = ''
140
+ let usage: AdapterResult['usage'] = {}
141
+ let finishReason = ''
142
+ const calls: Array<{ name: string; args: Record<string, unknown>; signature?: string }> = []
143
+ const availableTools = new Set<string>(tools.map((tool) => tool.name))
144
+ let pendingSignature: string | undefined
145
+
146
+ for await (const data of readSSE(response)) {
147
+ let payload: Record<string, unknown>
148
+ try {
149
+ payload = JSON.parse(data) as Record<string, unknown>
150
+ } catch {
151
+ continue
152
+ }
153
+
154
+ const error = payload.error as { message?: string } | undefined
155
+ if (error?.message) throw new Error(error.message)
156
+
157
+ const feedback = payload.promptFeedback as { blockReason?: string } | undefined
158
+ if (feedback?.blockReason) {
159
+ throw new Error(`Gemini blocked this request (${feedback.blockReason}).`)
160
+ }
161
+
162
+ const usageMetadata = payload.usageMetadata as
163
+ | { promptTokenCount?: number; candidatesTokenCount?: number; thoughtsTokenCount?: number }
164
+ | undefined
165
+ if (usageMetadata) {
166
+ usage = {
167
+ input: usageMetadata.promptTokenCount,
168
+ output:
169
+ (usageMetadata.candidatesTokenCount ?? 0) + (usageMetadata.thoughtsTokenCount ?? 0) ||
170
+ usageMetadata.candidatesTokenCount,
171
+ }
172
+ handlers.onUsage(usage)
173
+ }
174
+
175
+ const candidates = payload.candidates as
176
+ | Array<{
177
+ content?: { parts?: Array<Record<string, unknown>> }
178
+ finishReason?: string
179
+ }>
180
+ | undefined
181
+ const candidate = candidates?.[0]
182
+ if (candidate?.finishReason) finishReason = candidate.finishReason
183
+ for (const part of candidate?.content?.parts ?? []) {
184
+ const partSignature = typeof part.thoughtSignature === 'string' ? part.thoughtSignature : undefined
185
+ if (partSignature) pendingSignature = partSignature
186
+ if (typeof part.text === 'string' && part.text) {
187
+ if (part.thought === true) {
188
+ reasoning += part.text
189
+ handlers.onReasoning(part.text)
190
+ } else {
191
+ content += part.text
192
+ handlers.onText(part.text)
193
+ }
194
+ }
195
+ const functionCall = part.functionCall as
196
+ | { name?: string; args?: Record<string, unknown> }
197
+ | undefined
198
+ if (functionCall?.name) {
199
+ if (!availableTools.has(functionCall.name as ToolCall['name'])) {
200
+ throw new Error(`Gemini requested a tool that is not enabled: ${functionCall.name}`)
201
+ }
202
+ calls.push({
203
+ name: functionCall.name,
204
+ args: functionCall.args ?? {},
205
+ signature: partSignature ?? pendingSignature,
206
+ })
207
+ pendingSignature = undefined
208
+ }
209
+ }
210
+ }
211
+
212
+ if (finishReason === 'SAFETY' || finishReason === 'RECITATION') {
213
+ throw new Error(`Gemini stopped the response (${finishReason.toLowerCase()}).`)
214
+ }
215
+
216
+ const toolCalls: ToolCall[] = calls.map((call, index) => ({
217
+ id: `gemini_${Date.now().toString(36)}_${index}`,
218
+ name: call.name as ToolCall['name'],
219
+ args: call.args,
220
+ thoughtSignature: call.signature,
221
+ }))
222
+ if (toolCalls.length > 0) handlers.onToolCalls(toolCalls)
223
+
224
+ return { content, reasoning, toolCalls, usage }
225
+ }