@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.
- 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/server/README.md +75 -0
- package/server/daemon.mjs +1082 -0
- package/server/lib/checkpoints.mjs +124 -0
- package/server/lib/checkpoints.test.mjs +110 -0
- package/server/lib/deploy.mjs +436 -0
- package/server/lib/http.mjs +237 -0
- package/server/lib/plugins.mjs +321 -0
- package/server/lib/plugins.test.mjs +140 -0
- package/server/lib/providers.mjs +594 -0
- package/server/lib/store.mjs +491 -0
- package/server/lib/tools.mjs +990 -0
- package/server/opensol.mjs +202 -0
- package/server/package.json +42 -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
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
|
+
}
|