@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,655 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Pencil,
|
|
4
|
+
AlertCircle,
|
|
5
|
+
Check,
|
|
6
|
+
Copy,
|
|
7
|
+
FileText,
|
|
8
|
+
RefreshCw,
|
|
9
|
+
Settings2,
|
|
10
|
+
Square,
|
|
11
|
+
} from 'lucide-react'
|
|
12
|
+
import type { AssistantMessage, ToolMessage, UserMessage } from '../types'
|
|
13
|
+
import type { ConversationTurn } from '../lib/conversation'
|
|
14
|
+
import { useUi } from '../store/ui'
|
|
15
|
+
import { useSettings } from '../store/settings'
|
|
16
|
+
import { useModelCatalog } from '../store/models'
|
|
17
|
+
import { regenerateLastTurn, runAgent } from '../agent'
|
|
18
|
+
import { restoreCheckpoint } from '../lib/daemon'
|
|
19
|
+
import { useWorkspace } from '../store/workspace'
|
|
20
|
+
import { createId, useChats } from '../store/chats'
|
|
21
|
+
import { toolLabel } from '../agent/tools'
|
|
22
|
+
import { formatBytes, cn } from '../lib/utils'
|
|
23
|
+
import { Markdown } from './Markdown'
|
|
24
|
+
import { ToolCallCard } from './ToolCallCard'
|
|
25
|
+
import { ArrowRight } from 'lucide-react'
|
|
26
|
+
import { LogoMark } from './Logo'
|
|
27
|
+
|
|
28
|
+
function formatTokens(value?: number) {
|
|
29
|
+
if (!value) return ''
|
|
30
|
+
return value >= 1000 ? `${(value / 1000).toFixed(1)}k` : String(value)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function UserBubble({
|
|
34
|
+
message,
|
|
35
|
+
sessionId,
|
|
36
|
+
canEdit,
|
|
37
|
+
}: {
|
|
38
|
+
message: UserMessage
|
|
39
|
+
sessionId: string
|
|
40
|
+
canEdit: boolean
|
|
41
|
+
}) {
|
|
42
|
+
const [editing, setEditing] = useState(false)
|
|
43
|
+
const [draft, setDraft] = useState(message.content)
|
|
44
|
+
|
|
45
|
+
const resend = () => {
|
|
46
|
+
const text = draft.trim()
|
|
47
|
+
if (!text) return
|
|
48
|
+
const session = useChats.getState().sessions.find((item) => item.id === sessionId)
|
|
49
|
+
const index = session?.messages.findIndex((item) => item.id === message.id) ?? -1
|
|
50
|
+
if (!session || index < 0) return
|
|
51
|
+
const id = createId('msg')
|
|
52
|
+
// Everything after the edited message belonged to the old answer, so it is dropped.
|
|
53
|
+
useChats.getState().updateSession(sessionId, { messages: session.messages.slice(0, index) })
|
|
54
|
+
useChats.getState().addMessage(sessionId, { ...message, id, content: text, createdAt: Date.now() })
|
|
55
|
+
setEditing(false)
|
|
56
|
+
runAgent(sessionId, id)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const images = (message.attachments ?? []).filter((attachment) => attachment.kind === 'image')
|
|
60
|
+
const files = (message.attachments ?? []).filter((attachment) => attachment.kind === 'text')
|
|
61
|
+
if (editing) {
|
|
62
|
+
return (
|
|
63
|
+
<div className="flex justify-end">
|
|
64
|
+
<div className="w-full max-w-[88%] rounded-2xl border border-accent/40 bg-canvas p-2.5 sm:max-w-[82%]">
|
|
65
|
+
<textarea
|
|
66
|
+
value={draft}
|
|
67
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
68
|
+
onKeyDown={(event) => {
|
|
69
|
+
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
|
|
70
|
+
event.preventDefault()
|
|
71
|
+
resend()
|
|
72
|
+
}
|
|
73
|
+
if (event.key === 'Escape') setEditing(false)
|
|
74
|
+
}}
|
|
75
|
+
autoFocus
|
|
76
|
+
rows={Math.min(10, Math.max(2, draft.split('\n').length))}
|
|
77
|
+
className="chat-text block w-full resize-none bg-transparent px-2 py-1 leading-[1.65] text-ink outline-none"
|
|
78
|
+
aria-label="Edit message"
|
|
79
|
+
/>
|
|
80
|
+
<div className="mt-1 flex items-center justify-between gap-2 px-1">
|
|
81
|
+
<p className="text-[11.5px] text-faint">Sending replaces the reply below.</p>
|
|
82
|
+
<div className="flex items-center gap-1.5">
|
|
83
|
+
<button
|
|
84
|
+
type="button"
|
|
85
|
+
onClick={() => setEditing(false)}
|
|
86
|
+
className="h-8 rounded-full px-3 text-[12.5px] text-muted transition-colors hover:text-ink"
|
|
87
|
+
>
|
|
88
|
+
Cancel
|
|
89
|
+
</button>
|
|
90
|
+
<button
|
|
91
|
+
type="button"
|
|
92
|
+
onClick={resend}
|
|
93
|
+
disabled={!draft.trim()}
|
|
94
|
+
className="h-8 rounded-full bg-accent px-3.5 text-[12.5px] font-medium text-on-accent transition hover:brightness-110 disabled:opacity-40"
|
|
95
|
+
>
|
|
96
|
+
Send
|
|
97
|
+
</button>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div className="group/user flex items-start justify-end gap-1.5">
|
|
107
|
+
{canEdit && message.content && (
|
|
108
|
+
<button
|
|
109
|
+
type="button"
|
|
110
|
+
onClick={() => {
|
|
111
|
+
setDraft(message.content)
|
|
112
|
+
setEditing(true)
|
|
113
|
+
}}
|
|
114
|
+
className="mt-2 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-faint opacity-0 transition hover:bg-raised hover:text-ink focus-visible:opacity-100 group-hover/user:opacity-100"
|
|
115
|
+
title="Edit and resend"
|
|
116
|
+
aria-label="Edit and resend message"
|
|
117
|
+
>
|
|
118
|
+
<Pencil size={14} strokeWidth={1.7} />
|
|
119
|
+
</button>
|
|
120
|
+
)}
|
|
121
|
+
<div className="max-w-[88%] sm:max-w-[82%]">
|
|
122
|
+
{images.length > 0 && (
|
|
123
|
+
<div className={cn('mb-2 grid gap-2', images.length > 1 ? 'grid-cols-2' : 'grid-cols-1')}>
|
|
124
|
+
{images.map((image) => (
|
|
125
|
+
<img
|
|
126
|
+
key={image.id}
|
|
127
|
+
src={image.dataUrl}
|
|
128
|
+
alt={image.name}
|
|
129
|
+
className="max-h-52 w-full rounded-xl border border-line object-cover"
|
|
130
|
+
/>
|
|
131
|
+
))}
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
134
|
+
<div className="rounded-2xl rounded-br-md border border-line/70 bg-canvas px-4 py-2.5">
|
|
135
|
+
{files.length > 0 && (
|
|
136
|
+
<div className="mb-2 flex flex-wrap gap-1.5">
|
|
137
|
+
{files.map((file) => (
|
|
138
|
+
<span
|
|
139
|
+
key={file.id}
|
|
140
|
+
className="inline-flex items-center gap-1.5 rounded-md border border-line bg-panel px-2 py-1 text-[10.5px] text-muted"
|
|
141
|
+
>
|
|
142
|
+
<FileText size={11} />
|
|
143
|
+
<span className="max-w-[180px] truncate">{file.name}</span>
|
|
144
|
+
<span className="text-faint">{formatBytes(file.size)}</span>
|
|
145
|
+
</span>
|
|
146
|
+
))}
|
|
147
|
+
</div>
|
|
148
|
+
)}
|
|
149
|
+
{message.content && (
|
|
150
|
+
<p className="chat-text whitespace-pre-wrap break-words leading-[1.65] text-ink">{message.content}</p>
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const FILE_EDIT_TOOLS = new Set(['write_file', 'rewrite_file', 'edit_file'])
|
|
159
|
+
|
|
160
|
+
function changedFiles(tools: ToolMessage[]): string[] {
|
|
161
|
+
const seen = new Set<string>()
|
|
162
|
+
for (const tool of tools) {
|
|
163
|
+
if (tool.status !== 'done' || !FILE_EDIT_TOOLS.has(tool.call.name)) continue
|
|
164
|
+
const path = tool.call.args.path
|
|
165
|
+
if (typeof path === 'string' && path) seen.add(path)
|
|
166
|
+
}
|
|
167
|
+
return [...seen]
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Lets the person roll back every file the agent wrote or edited during one turn. */
|
|
171
|
+
function UndoBar({ turnId, files }: { turnId: string; files: string[] }) {
|
|
172
|
+
const askConfirm = useUi((state) => state.askConfirm)
|
|
173
|
+
const notify = useUi((state) => state.notify)
|
|
174
|
+
const [state, setState] = useState<'idle' | 'working' | 'undone'>('idle')
|
|
175
|
+
const shown = files.slice(0, 2).join(', ')
|
|
176
|
+
const rest = files.length - 2
|
|
177
|
+
|
|
178
|
+
const undo = async () => {
|
|
179
|
+
const confirmed = await askConfirm({
|
|
180
|
+
title: 'Undo these file changes?',
|
|
181
|
+
body: `${files.length === 1 ? 'This file goes' : `These ${files.length} files go`} back to how ${files.length === 1 ? 'it was' : 'they were'} before this answer. Changes made through shell commands are not affected.`,
|
|
182
|
+
confirmLabel: 'Undo changes',
|
|
183
|
+
})
|
|
184
|
+
if (!confirmed) return
|
|
185
|
+
setState('working')
|
|
186
|
+
try {
|
|
187
|
+
const result = await restoreCheckpoint(turnId)
|
|
188
|
+
const parts = [
|
|
189
|
+
result.restored.length ? `restored ${result.restored.length}` : '',
|
|
190
|
+
result.removed.length ? `removed ${result.removed.length}` : '',
|
|
191
|
+
result.skipped.length ? `${result.skipped.length} too large to restore` : '',
|
|
192
|
+
].filter(Boolean)
|
|
193
|
+
notify(`Undone: ${parts.join(', ') || 'nothing to change'}.`, 'success')
|
|
194
|
+
setState('undone')
|
|
195
|
+
await useWorkspace.getState().refresh().catch(() => undefined)
|
|
196
|
+
} catch (error) {
|
|
197
|
+
setState('idle')
|
|
198
|
+
notify(error instanceof Error ? error.message : 'Could not undo those changes.', 'error')
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return (
|
|
203
|
+
<p className="flex items-baseline gap-2 text-[12.5px] text-muted">
|
|
204
|
+
<span className="min-w-0 truncate">
|
|
205
|
+
{state === 'undone' ? 'Changes undone' : `Changed ${shown}${rest > 0 ? ` and ${rest} more` : ''}`}
|
|
206
|
+
</span>
|
|
207
|
+
{state !== 'undone' && (
|
|
208
|
+
<button
|
|
209
|
+
type="button"
|
|
210
|
+
onClick={() => void undo()}
|
|
211
|
+
disabled={state === 'working'}
|
|
212
|
+
className="shrink-0 font-medium text-ink underline decoration-ink/30 underline-offset-[3px] transition-colors hover:decoration-ink disabled:opacity-50"
|
|
213
|
+
>
|
|
214
|
+
{state === 'working' ? 'Undoing…' : 'Undo'}
|
|
215
|
+
</button>
|
|
216
|
+
)}
|
|
217
|
+
</p>
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function TaskCompletedBox({
|
|
222
|
+
tools,
|
|
223
|
+
intermediateMessages = [],
|
|
224
|
+
streaming = false,
|
|
225
|
+
}: {
|
|
226
|
+
tools: ToolMessage[]
|
|
227
|
+
intermediateMessages?: AssistantMessage[]
|
|
228
|
+
streaming?: boolean
|
|
229
|
+
}) {
|
|
230
|
+
const [open, setOpen] = useState(false)
|
|
231
|
+
const autoOpened = useRef(false)
|
|
232
|
+
const autoCollapse = useSettings((state) => state.autoCollapseWork)
|
|
233
|
+
const detailed = useSettings((state) => state.workDetail === 'detailed')
|
|
234
|
+
|
|
235
|
+
const awaiting = tools.some((tool) => tool.status === 'awaiting')
|
|
236
|
+
const active = streaming || tools.some((tool) => tool.status === 'running')
|
|
237
|
+
|
|
238
|
+
// Auto-open when awaiting approval or actively running
|
|
239
|
+
useEffect(() => {
|
|
240
|
+
if (awaiting) {
|
|
241
|
+
setOpen(true)
|
|
242
|
+
} else if (active) {
|
|
243
|
+
autoOpened.current = true
|
|
244
|
+
setOpen(true)
|
|
245
|
+
} else if (autoOpened.current && autoCollapse) {
|
|
246
|
+
autoOpened.current = false
|
|
247
|
+
setOpen(false)
|
|
248
|
+
}
|
|
249
|
+
}, [active, awaiting, autoCollapse])
|
|
250
|
+
|
|
251
|
+
if (tools.length === 0 && intermediateMessages.length === 0) return null
|
|
252
|
+
|
|
253
|
+
const labels = [...new Set(tools.map((tool) => toolLabel(tool.call.name)))]
|
|
254
|
+
const failed = tools.filter((tool) => tool.status === 'error' || tool.status === 'denied').length
|
|
255
|
+
const summary = labels.length <= 3 ? labels.join(' · ') : `${labels.slice(0, 3).join(' · ')} +${labels.length - 3}`
|
|
256
|
+
|
|
257
|
+
const taskItems = [
|
|
258
|
+
...tools.map((t) => ({ kind: 'tool' as const, tool: t, time: t.createdAt })),
|
|
259
|
+
...intermediateMessages.map((m) => ({ kind: 'assistant' as const, message: m, time: m.createdAt })),
|
|
260
|
+
].sort((a, b) => a.time - b.time)
|
|
261
|
+
|
|
262
|
+
return (
|
|
263
|
+
<div className="my-1">
|
|
264
|
+
<button
|
|
265
|
+
type="button"
|
|
266
|
+
onClick={() => {
|
|
267
|
+
autoOpened.current = false
|
|
268
|
+
setOpen((value) => !value)
|
|
269
|
+
}}
|
|
270
|
+
className="group/task flex max-w-full items-baseline gap-1.5 py-0.5 text-left text-[13px]"
|
|
271
|
+
aria-expanded={open}
|
|
272
|
+
>
|
|
273
|
+
<ArrowRight
|
|
274
|
+
size={13}
|
|
275
|
+
strokeWidth={2}
|
|
276
|
+
className={cn(
|
|
277
|
+
'relative top-[1px] shrink-0 self-center text-faint transition-transform duration-200 ease-out group-hover/task:text-ink',
|
|
278
|
+
open && 'rotate-90',
|
|
279
|
+
)}
|
|
280
|
+
/>
|
|
281
|
+
<span
|
|
282
|
+
className={cn(
|
|
283
|
+
'shrink-0 font-medium transition-colors',
|
|
284
|
+
awaiting ? 'text-warning' : active ? 'text-shimmer' : 'text-ink/80 group-hover/task:text-ink',
|
|
285
|
+
)}
|
|
286
|
+
>
|
|
287
|
+
{awaiting ? 'Action required' : active ? 'Running task…' : 'Task completed'}
|
|
288
|
+
</span>
|
|
289
|
+
<span className="min-w-0 truncate text-[12px] text-faint">
|
|
290
|
+
{tools.length > 0 && `Used ${tools.length} tool${tools.length === 1 ? '' : 's'}`}
|
|
291
|
+
{summary ? ` (${summary})` : ''}
|
|
292
|
+
</span>
|
|
293
|
+
{failed > 0 && <span className="shrink-0 text-[12px] text-danger">· {failed} failed</span>}
|
|
294
|
+
</button>
|
|
295
|
+
|
|
296
|
+
{open && (
|
|
297
|
+
<div className="animate-fade space-y-0.5 pb-1 pl-[19px] pt-1">
|
|
298
|
+
{taskItems.map((item) =>
|
|
299
|
+
item.kind === 'tool' ? (
|
|
300
|
+
<ToolCallCard key={item.tool.id} message={item.tool} compact={!detailed} />
|
|
301
|
+
) : (
|
|
302
|
+
<div key={item.message.id} className="py-1">
|
|
303
|
+
{item.message.reasoning.trim() && (
|
|
304
|
+
<div className="thin-scroll max-h-40 overflow-y-auto whitespace-pre-wrap font-mono text-[11px] leading-relaxed text-faint">
|
|
305
|
+
{item.message.reasoning}
|
|
306
|
+
</div>
|
|
307
|
+
)}
|
|
308
|
+
{item.message.content.trim() && (
|
|
309
|
+
<div className="mt-1 text-[12.5px] leading-relaxed text-muted">
|
|
310
|
+
<Markdown content={item.message.content} />
|
|
311
|
+
</div>
|
|
312
|
+
)}
|
|
313
|
+
</div>
|
|
314
|
+
),
|
|
315
|
+
)}
|
|
316
|
+
</div>
|
|
317
|
+
)}
|
|
318
|
+
</div>
|
|
319
|
+
)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function UsedGroup({ tools, streaming = false }: { tools: ToolMessage[]; streaming?: boolean }) {
|
|
323
|
+
return <TaskCompletedBox tools={tools} streaming={streaming} />
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function AssistantBlock({
|
|
327
|
+
message,
|
|
328
|
+
sessionId,
|
|
329
|
+
streaming,
|
|
330
|
+
isLastAssistant,
|
|
331
|
+
showHeader,
|
|
332
|
+
turnUsage,
|
|
333
|
+
}: {
|
|
334
|
+
message: AssistantMessage
|
|
335
|
+
sessionId: string
|
|
336
|
+
streaming: boolean
|
|
337
|
+
isLastAssistant: boolean
|
|
338
|
+
/** Summed token usage of every round in the turn (shown on the final answer). */
|
|
339
|
+
turnUsage?: { input: number; output: number }
|
|
340
|
+
/** Only the first answer of a turn repeats the assistant header. */
|
|
341
|
+
showHeader: boolean
|
|
342
|
+
}) {
|
|
343
|
+
const notify = useUi((state) => state.notify)
|
|
344
|
+
const openSettings = useUi((state) => state.openSettings)
|
|
345
|
+
const showTokenEstimate = useSettings((state) => state.showTokenEstimate)
|
|
346
|
+
const providerModels = useModelCatalog((state) =>
|
|
347
|
+
message.provider ? state.byProvider[message.provider] : undefined,
|
|
348
|
+
)
|
|
349
|
+
const [copied, setCopied] = useState(false)
|
|
350
|
+
const [reasoningOpen, setReasoningOpen] = useState(streaming)
|
|
351
|
+
const hasBody = Boolean(message.content.trim() || message.reasoning.trim() || message.error)
|
|
352
|
+
const modelName =
|
|
353
|
+
providerModels?.find((model) => model.id === message.model)?.name ?? message.model ?? ''
|
|
354
|
+
const needsConfig = Boolean(message.error && /api key|base url|settings|credential|403|401/i.test(message.error))
|
|
355
|
+
const tokenBits: string[] = []
|
|
356
|
+
const usedInput = turnUsage?.input || message.usage?.input
|
|
357
|
+
const usedOutput = turnUsage?.output || message.usage?.output
|
|
358
|
+
if (usedInput) tokenBits.push(`${formatTokens(usedInput)} in`)
|
|
359
|
+
if (usedOutput) tokenBits.push(`${formatTokens(usedOutput)} out`)
|
|
360
|
+
else if (showTokenEstimate && message.content.trim()) {
|
|
361
|
+
tokenBits.push(`~${formatTokens(Math.ceil(message.content.length / 4))} out`)
|
|
362
|
+
}
|
|
363
|
+
const metaLine = [
|
|
364
|
+
modelName,
|
|
365
|
+
...tokenBits,
|
|
366
|
+
message.durationMs ? `${(message.durationMs / 1000).toFixed(1)}s` : '',
|
|
367
|
+
]
|
|
368
|
+
.filter(Boolean)
|
|
369
|
+
.join(' · ')
|
|
370
|
+
|
|
371
|
+
useEffect(() => {
|
|
372
|
+
if (streaming) setReasoningOpen(true)
|
|
373
|
+
}, [streaming])
|
|
374
|
+
|
|
375
|
+
const copy = async () => {
|
|
376
|
+
try {
|
|
377
|
+
await navigator.clipboard.writeText(message.content)
|
|
378
|
+
setCopied(true)
|
|
379
|
+
window.setTimeout(() => setCopied(false), 1600)
|
|
380
|
+
} catch {
|
|
381
|
+
notify('Clipboard access was blocked by the browser.', 'error')
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (!hasBody && !streaming) {
|
|
386
|
+
return (
|
|
387
|
+
<div className="group">
|
|
388
|
+
<div className="min-w-0">
|
|
389
|
+
{showHeader && (
|
|
390
|
+
<div className="flex items-center gap-2 text-[12px] font-semibold text-ink">
|
|
391
|
+
OpenSol
|
|
392
|
+
{modelName && <span className="font-normal text-faint">{modelName}</span>}
|
|
393
|
+
</div>
|
|
394
|
+
)}
|
|
395
|
+
<p className="mt-2 text-[13px] text-muted">The model returned an empty response.</p>
|
|
396
|
+
{isLastAssistant && (
|
|
397
|
+
<button
|
|
398
|
+
type="button"
|
|
399
|
+
onClick={() => regenerateLastTurn(sessionId)}
|
|
400
|
+
className="mt-2 inline-flex items-center gap-1.5 text-[11.5px] font-medium text-accent hover:underline"
|
|
401
|
+
>
|
|
402
|
+
<RefreshCw size={12} /> Try again
|
|
403
|
+
</button>
|
|
404
|
+
)}
|
|
405
|
+
</div>
|
|
406
|
+
</div>
|
|
407
|
+
)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return (
|
|
411
|
+
<div className="group">
|
|
412
|
+
<div className="min-w-0">
|
|
413
|
+
{(showHeader || message.stopped) && (
|
|
414
|
+
<div className="flex min-h-[22px] items-center gap-1.5">
|
|
415
|
+
{showHeader && (
|
|
416
|
+
<>
|
|
417
|
+
<LogoMark size={15} />
|
|
418
|
+
<span className="text-[12px] font-medium text-muted">OpenSol</span>
|
|
419
|
+
{modelName && <span className="truncate text-[11.5px] text-faint">{modelName}</span>}
|
|
420
|
+
</>
|
|
421
|
+
)}
|
|
422
|
+
{message.stopped && (
|
|
423
|
+
<span className="inline-flex items-center gap-1 rounded-full border border-line px-1.5 py-0.5 text-[9.5px] font-medium text-muted">
|
|
424
|
+
<Square size={7} fill="currentColor" />
|
|
425
|
+
Stopped
|
|
426
|
+
</span>
|
|
427
|
+
)}
|
|
428
|
+
</div>
|
|
429
|
+
)}
|
|
430
|
+
|
|
431
|
+
{message.reasoning.trim() && (
|
|
432
|
+
<div className="mt-1">
|
|
433
|
+
<button
|
|
434
|
+
type="button"
|
|
435
|
+
onClick={() => setReasoningOpen((value) => !value)}
|
|
436
|
+
className="group/think flex max-w-full items-baseline gap-1.5 py-0.5 text-left text-[12.5px]"
|
|
437
|
+
aria-expanded={reasoningOpen}
|
|
438
|
+
>
|
|
439
|
+
<ArrowRight
|
|
440
|
+
size={12}
|
|
441
|
+
strokeWidth={2}
|
|
442
|
+
className={cn(
|
|
443
|
+
'relative top-[1px] shrink-0 self-center text-faint transition-transform duration-200 ease-out group-hover/think:text-ink',
|
|
444
|
+
reasoningOpen && 'rotate-90',
|
|
445
|
+
)}
|
|
446
|
+
/>
|
|
447
|
+
<span className={cn('shrink-0 font-medium', streaming ? 'text-shimmer' : 'text-muted group-hover/think:text-ink')}>
|
|
448
|
+
{streaming ? 'Thinking…' : 'Thought'}
|
|
449
|
+
</span>
|
|
450
|
+
{!reasoningOpen && (
|
|
451
|
+
<span className="min-w-0 truncate text-[11.5px] text-faint">
|
|
452
|
+
{message.reasoning.replace(/\s+/g, ' ').trim().slice(0, 120)}
|
|
453
|
+
</span>
|
|
454
|
+
)}
|
|
455
|
+
</button>
|
|
456
|
+
{reasoningOpen && (
|
|
457
|
+
<div className="thin-scroll animate-fade max-h-72 overflow-y-auto whitespace-pre-wrap break-words py-1 pl-[18px] font-mono text-[11px] leading-[1.65] text-faint">
|
|
458
|
+
{message.reasoning}
|
|
459
|
+
</div>
|
|
460
|
+
)}
|
|
461
|
+
</div>
|
|
462
|
+
)}
|
|
463
|
+
|
|
464
|
+
{message.content && (
|
|
465
|
+
<div className="mt-1.5">
|
|
466
|
+
<Markdown content={message.content} streaming={streaming} />
|
|
467
|
+
</div>
|
|
468
|
+
)}
|
|
469
|
+
|
|
470
|
+
{streaming && !message.content && !message.reasoning && (
|
|
471
|
+
<div className="mt-2 flex items-center gap-2 text-[13px] text-muted">
|
|
472
|
+
<span className="flex items-center gap-1">
|
|
473
|
+
<span className="h-1.5 w-1.5 animate-breathe rounded-full bg-accent" />
|
|
474
|
+
<span className="h-1.5 w-1.5 animate-breathe rounded-full bg-accent [animation-delay:160ms]" />
|
|
475
|
+
<span className="h-1.5 w-1.5 animate-breathe rounded-full bg-accent [animation-delay:320ms]" />
|
|
476
|
+
</span>
|
|
477
|
+
Working
|
|
478
|
+
</div>
|
|
479
|
+
)}
|
|
480
|
+
|
|
481
|
+
{message.error && (
|
|
482
|
+
<div className="mt-3 rounded-xl border border-danger/25 bg-danger/[0.045] px-3.5 py-3">
|
|
483
|
+
<div className="flex items-start gap-2">
|
|
484
|
+
<AlertCircle size={15} className="mt-0.5 shrink-0 text-danger" />
|
|
485
|
+
<div className="min-w-0 flex-1">
|
|
486
|
+
<p className="text-[12px] font-medium text-ink">The request did not finish</p>
|
|
487
|
+
<p className="mt-1 whitespace-pre-wrap text-[11.5px] leading-relaxed text-muted">{message.error}</p>
|
|
488
|
+
<div className="mt-2.5 flex flex-wrap items-center gap-2">
|
|
489
|
+
{needsConfig && (
|
|
490
|
+
<button
|
|
491
|
+
type="button"
|
|
492
|
+
onClick={() => openSettings('models')}
|
|
493
|
+
className="inline-flex h-7 items-center gap-1.5 rounded-lg border border-line bg-panel px-2.5 text-[11.5px] font-medium text-ink hover:bg-raised"
|
|
494
|
+
>
|
|
495
|
+
<Settings2 size={12} />
|
|
496
|
+
Open model settings
|
|
497
|
+
</button>
|
|
498
|
+
)}
|
|
499
|
+
{isLastAssistant && (
|
|
500
|
+
<button
|
|
501
|
+
type="button"
|
|
502
|
+
onClick={() => regenerateLastTurn(sessionId)}
|
|
503
|
+
className="inline-flex h-7 items-center gap-1.5 rounded-lg border border-line bg-panel px-2.5 text-[11.5px] font-medium text-ink hover:bg-raised"
|
|
504
|
+
>
|
|
505
|
+
<RefreshCw size={12} />
|
|
506
|
+
Try again
|
|
507
|
+
</button>
|
|
508
|
+
)}
|
|
509
|
+
</div>
|
|
510
|
+
</div>
|
|
511
|
+
</div>
|
|
512
|
+
</div>
|
|
513
|
+
)}
|
|
514
|
+
|
|
515
|
+
{hasBody && !streaming && (
|
|
516
|
+
<div className="mt-2 flex min-h-[28px] flex-wrap items-center gap-0.5 text-[11.5px] text-faint">
|
|
517
|
+
{message.content && (
|
|
518
|
+
<button
|
|
519
|
+
type="button"
|
|
520
|
+
onClick={copy}
|
|
521
|
+
className="inline-flex h-7 w-7 items-center justify-center rounded-lg transition-colors hover:bg-raised hover:text-ink"
|
|
522
|
+
aria-label="Copy response"
|
|
523
|
+
title={copied ? 'Copied' : 'Copy'}
|
|
524
|
+
>
|
|
525
|
+
{copied ? <Check size={14} className="text-success" /> : <Copy size={14} strokeWidth={1.7} />}
|
|
526
|
+
</button>
|
|
527
|
+
)}
|
|
528
|
+
{isLastAssistant && (
|
|
529
|
+
<button
|
|
530
|
+
type="button"
|
|
531
|
+
onClick={() => regenerateLastTurn(sessionId)}
|
|
532
|
+
className="inline-flex h-7 w-7 items-center justify-center rounded-lg transition-colors hover:bg-raised hover:text-ink"
|
|
533
|
+
aria-label="Regenerate response"
|
|
534
|
+
title="Regenerate"
|
|
535
|
+
>
|
|
536
|
+
<RefreshCw size={14} strokeWidth={1.7} />
|
|
537
|
+
</button>
|
|
538
|
+
)}
|
|
539
|
+
<span className="ml-2 tabular-nums text-muted">{metaLine}</span>
|
|
540
|
+
</div>
|
|
541
|
+
)}
|
|
542
|
+
</div>
|
|
543
|
+
</div>
|
|
544
|
+
)
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export function TurnBlock({
|
|
548
|
+
turn,
|
|
549
|
+
sessionId,
|
|
550
|
+
streaming,
|
|
551
|
+
lastAssistantId,
|
|
552
|
+
activeTurn = false,
|
|
553
|
+
}: {
|
|
554
|
+
turn: ConversationTurn
|
|
555
|
+
sessionId: string
|
|
556
|
+
streaming: boolean
|
|
557
|
+
lastAssistantId?: string
|
|
558
|
+
/** True while this is the turn the agent is currently working on. */
|
|
559
|
+
activeTurn?: boolean
|
|
560
|
+
}) {
|
|
561
|
+
const allTools: ToolMessage[] = []
|
|
562
|
+
const assistantMessages: AssistantMessage[] = []
|
|
563
|
+
|
|
564
|
+
for (const segment of turn.segments) {
|
|
565
|
+
if (segment.kind === 'tools') {
|
|
566
|
+
allTools.push(...segment.tools)
|
|
567
|
+
} else if (segment.kind === 'assistant') {
|
|
568
|
+
assistantMessages.push(segment.message)
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const hasTools = allTools.length > 0
|
|
573
|
+
const firstAssistantId = assistantMessages[0]?.id
|
|
574
|
+
const finalAssistant = assistantMessages[assistantMessages.length - 1] ?? null
|
|
575
|
+
|
|
576
|
+
// While the agent is still working, every step is shown inline. Nothing is
|
|
577
|
+
// folded into "Task completed" until the whole turn actually finishes.
|
|
578
|
+
if (activeTurn && hasTools) {
|
|
579
|
+
return (
|
|
580
|
+
<div className="flex flex-col gap-2.5">
|
|
581
|
+
{turn.user && <UserBubble message={turn.user} sessionId={sessionId} canEdit={!streaming} />}
|
|
582
|
+
{turn.segments.map((segment, index) =>
|
|
583
|
+
segment.kind === 'tools' ? (
|
|
584
|
+
<div key={`tools-${index}`} className="space-y-0.5">
|
|
585
|
+
{segment.tools.map((tool) => (
|
|
586
|
+
<ToolCallCard key={tool.id} message={tool} />
|
|
587
|
+
))}
|
|
588
|
+
</div>
|
|
589
|
+
) : (
|
|
590
|
+
<AssistantBlock
|
|
591
|
+
key={segment.message.id}
|
|
592
|
+
message={segment.message}
|
|
593
|
+
sessionId={sessionId}
|
|
594
|
+
streaming={streaming && segment.message.id === lastAssistantId}
|
|
595
|
+
isLastAssistant={segment.message.id === lastAssistantId}
|
|
596
|
+
showHeader={segment.message.id === firstAssistantId}
|
|
597
|
+
/>
|
|
598
|
+
),
|
|
599
|
+
)}
|
|
600
|
+
</div>
|
|
601
|
+
)
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Finished turn with no tools: just the conversation.
|
|
605
|
+
if (!hasTools) {
|
|
606
|
+
let seen = 0
|
|
607
|
+
return (
|
|
608
|
+
<div className="flex flex-col gap-2.5">
|
|
609
|
+
{turn.user && <UserBubble message={turn.user} sessionId={sessionId} canEdit={!streaming} />}
|
|
610
|
+
{assistantMessages.map((message) => (
|
|
611
|
+
<AssistantBlock
|
|
612
|
+
key={message.id}
|
|
613
|
+
message={message}
|
|
614
|
+
sessionId={sessionId}
|
|
615
|
+
streaming={streaming && message.id === lastAssistantId}
|
|
616
|
+
isLastAssistant={message.id === lastAssistantId}
|
|
617
|
+
showHeader={seen++ === 0}
|
|
618
|
+
/>
|
|
619
|
+
))}
|
|
620
|
+
</div>
|
|
621
|
+
)
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Finished turn: fold the tool calls and intermediate steps into one
|
|
625
|
+
// collapsible "Task completed" block; the final answer stays outside.
|
|
626
|
+
const intermediateAssistants = assistantMessages.slice(0, -1)
|
|
627
|
+
const turnUsage = assistantMessages.reduce(
|
|
628
|
+
(sum, item) => ({ input: sum.input + (item.usage?.input ?? 0), output: sum.output + (item.usage?.output ?? 0) }),
|
|
629
|
+
{ input: 0, output: 0 },
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
return (
|
|
633
|
+
<div className="flex flex-col gap-2.5">
|
|
634
|
+
{turn.user && <UserBubble message={turn.user} sessionId={sessionId} canEdit={!streaming} />}
|
|
635
|
+
|
|
636
|
+
<TaskCompletedBox tools={allTools} intermediateMessages={intermediateAssistants} />
|
|
637
|
+
|
|
638
|
+
{finalAssistant && (
|
|
639
|
+
<AssistantBlock
|
|
640
|
+
key={finalAssistant.id}
|
|
641
|
+
message={finalAssistant}
|
|
642
|
+
sessionId={sessionId}
|
|
643
|
+
streaming={false}
|
|
644
|
+
isLastAssistant={finalAssistant.id === lastAssistantId}
|
|
645
|
+
showHeader={true}
|
|
646
|
+
turnUsage={turnUsage}
|
|
647
|
+
/>
|
|
648
|
+
)}
|
|
649
|
+
|
|
650
|
+
{turn.user && changedFiles(allTools).length > 0 && (
|
|
651
|
+
<UndoBar turnId={turn.user.id} files={changedFiles(allTools)} />
|
|
652
|
+
)}
|
|
653
|
+
</div>
|
|
654
|
+
)
|
|
655
|
+
}
|