@bibobonking/opensol 3.0.3 → 3.1.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 +60 -8
- package/CHANGELOG.md +21 -0
- package/README.md +2 -2
- package/desktop/README.md +39 -0
- package/desktop/loading/index.html +36 -0
- package/desktop/src-tauri/Cargo.lock +4301 -0
- package/desktop/src-tauri/Cargo.toml +23 -0
- package/desktop/src-tauri/binaries/.gitkeep +0 -0
- package/desktop/src-tauri/build.rs +3 -0
- package/desktop/src-tauri/capabilities/default.json +7 -0
- package/desktop/src-tauri/icons/128x128.png +0 -0
- package/desktop/src-tauri/icons/128x128@2x.png +0 -0
- package/desktop/src-tauri/icons/32x32.png +0 -0
- package/desktop/src-tauri/icons/64x64.png +0 -0
- package/desktop/src-tauri/icons/Square107x107Logo.png +0 -0
- package/desktop/src-tauri/icons/Square142x142Logo.png +0 -0
- package/desktop/src-tauri/icons/Square150x150Logo.png +0 -0
- package/desktop/src-tauri/icons/Square284x284Logo.png +0 -0
- package/desktop/src-tauri/icons/Square30x30Logo.png +0 -0
- package/desktop/src-tauri/icons/Square310x310Logo.png +0 -0
- package/desktop/src-tauri/icons/Square44x44Logo.png +0 -0
- package/desktop/src-tauri/icons/Square71x71Logo.png +0 -0
- package/desktop/src-tauri/icons/Square89x89Logo.png +0 -0
- package/desktop/src-tauri/icons/StoreLogo.png +0 -0
- package/desktop/src-tauri/icons/icon.icns +0 -0
- package/desktop/src-tauri/icons/icon.ico +0 -0
- package/desktop/src-tauri/icons/icon.png +0 -0
- package/desktop/src-tauri/src/lib.rs +210 -0
- package/desktop/src-tauri/src/main.rs +6 -0
- package/desktop/src-tauri/tauri.conf.json +48 -0
- package/dist/assets/index-CeN54A2N.css +1 -0
- package/dist/assets/index-fhIO9a7_.js +318 -0
- package/dist/index.html +4 -4
- package/index.html +2 -2
- package/package.json +2 -1
- package/server/README.md +2 -2
- package/server/daemon.mjs +138 -2
- package/server/lib/mcp-oauth.mjs +219 -0
- package/server/lib/mcp-oauth.test.mjs +175 -0
- package/server/lib/mcp.mjs +899 -0
- package/server/lib/mcp.test.mjs +46 -0
- package/server/lib/store.mjs +22 -0
- package/server/lib/tools.mjs +59 -1
- package/src/App.tsx +2 -0
- package/src/agent/tools.ts +6 -5
- package/src/components/CommandPalette.tsx +14 -1
- package/src/components/Composer.tsx +22 -38
- package/src/components/DiffView.tsx +98 -0
- package/src/components/Logo.tsx +1 -1
- package/src/components/McpPanel.tsx +687 -0
- package/src/components/MessageItem.tsx +62 -13
- package/src/components/ModelDeck.tsx +661 -0
- package/src/components/SessionsTab.tsx +206 -0
- package/src/components/SettingsModal.tsx +146 -71
- package/src/components/Sidebar.tsx +79 -24
- package/src/components/Splash.tsx +1 -1
- package/src/components/ToolCallCard.tsx +29 -7
- package/src/components/WorkspacePanel.tsx +16 -1
- package/src/core/kernel.ts +2 -0
- package/src/core/mcp-tools.ts +63 -0
- package/src/core/plugins/tools/fs.ts +24 -3
- package/src/core/services/agent-loop.ts +3 -1
- package/src/core/services/approval.ts +2 -1
- package/src/core/services/titles.ts +135 -0
- package/src/core/services/tools.ts +11 -8
- package/src/core/types.ts +4 -0
- package/src/data/providers.ts +1 -1
- package/src/index.css +97 -1
- package/src/lib/daemon.ts +38 -0
- package/src/lib/diff.ts +62 -0
- package/src/lib/mcp-config.test.mjs +47 -0
- package/src/lib/mcp-config.ts +203 -0
- package/src/lib/permissions.ts +6 -6
- package/src/store/chats.ts +9 -1
- package/src/store/mcp.ts +94 -0
- package/src/store/settings.ts +10 -7
- package/src/store/ui.ts +17 -0
- package/src/store/workspace.ts +26 -0
- package/src/types.ts +63 -2
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/assets/index-BmN7pBji.css +0 -1
- package/dist/assets/index-DgNDjkOz.js +0 -301
- package/src/components/ModelPicker.tsx +0 -487
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
Plus,
|
|
13
13
|
Rocket,
|
|
14
14
|
Search,
|
|
15
|
+
Plug,
|
|
15
16
|
Settings,
|
|
16
17
|
Sparkles,
|
|
17
18
|
Trash2,
|
|
@@ -22,6 +23,7 @@ import { hasCredentials, useSettings } from '../store/settings'
|
|
|
22
23
|
import { useRuntime, useUi } from '../store/ui'
|
|
23
24
|
import { useWorkspace } from '../store/workspace'
|
|
24
25
|
import { stopRun } from '../agent'
|
|
26
|
+
import { kernel } from '../core/kernel'
|
|
25
27
|
import { getProviderName } from '../data/providers'
|
|
26
28
|
import { cn, downloadText, formatRelativeTime, groupChatsByDate } from '../lib/utils'
|
|
27
29
|
import { LogoLockup } from './Logo'
|
|
@@ -46,6 +48,7 @@ export function Sidebar() {
|
|
|
46
48
|
const settings = useSettings()
|
|
47
49
|
const workspace = useWorkspace((state) => state.info)
|
|
48
50
|
const addWorkspace = useWorkspace((state) => state.addWorkspace)
|
|
51
|
+
const removeWorkspace = useWorkspace((state) => state.removeWorkspace)
|
|
49
52
|
const sidebarCollapsed = useUi((state) => state.sidebarCollapsed)
|
|
50
53
|
const toggleSidebar = useUi((state) => state.toggleSidebar)
|
|
51
54
|
const mobileOpen = useUi((state) => state.mobileSidebarOpen)
|
|
@@ -53,10 +56,12 @@ export function Sidebar() {
|
|
|
53
56
|
const openSettings = useUi((state) => state.openSettings)
|
|
54
57
|
const setWorkspaceOpen = useUi((state) => state.setWorkspaceOpen)
|
|
55
58
|
const setDeployOpen = useUi((state) => state.setDeployOpen)
|
|
59
|
+
const setMcpOpen = useUi((state) => state.setMcpOpen)
|
|
56
60
|
const askConfirm = useUi((state) => state.askConfirm)
|
|
57
61
|
const notify = useUi((state) => state.notify)
|
|
58
62
|
const focusComposer = useUi((state) => state.focusComposer)
|
|
59
63
|
const streaming = useRuntime((state) => state.streaming)
|
|
64
|
+
const titling = useRuntime((state) => state.titling)
|
|
60
65
|
|
|
61
66
|
const [query, setQuery] = useState('')
|
|
62
67
|
const [menu, setMenu] = useState<ChatMenu | null>(null)
|
|
@@ -108,6 +113,28 @@ export function Sidebar() {
|
|
|
108
113
|
}
|
|
109
114
|
}
|
|
110
115
|
|
|
116
|
+
const deleteWorkspace = async (path: string, name: string) => {
|
|
117
|
+
const confirmed = await askConfirm({
|
|
118
|
+
title: 'Remove this workspace?',
|
|
119
|
+
body: `“${name}” is removed from OpenSol's list. The folder and its files stay on your disk; conversations started there are kept.`,
|
|
120
|
+
confirmLabel: 'Remove workspace',
|
|
121
|
+
tone: 'danger',
|
|
122
|
+
})
|
|
123
|
+
if (!confirmed) return
|
|
124
|
+
try {
|
|
125
|
+
await removeWorkspace(path)
|
|
126
|
+
notify(`Removed ${name} from your workspaces.`, 'success')
|
|
127
|
+
} catch (error) {
|
|
128
|
+
notify(error instanceof Error ? error.message : 'Could not remove that workspace.', 'error')
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const aiRename = async (id: string) => {
|
|
133
|
+
setMenu(null)
|
|
134
|
+
const title = await kernel.titles?.generate(id, { force: true })
|
|
135
|
+
if (!title) notify('The model could not suggest a title right now.', 'error')
|
|
136
|
+
}
|
|
137
|
+
|
|
111
138
|
const beginRename = (id: string, title: string) => {
|
|
112
139
|
setEditingId(id)
|
|
113
140
|
setDraftTitle(title)
|
|
@@ -225,6 +252,17 @@ export function Sidebar() {
|
|
|
225
252
|
<Package size={15} strokeWidth={1.7} className="shrink-0 text-faint" />
|
|
226
253
|
Plugins
|
|
227
254
|
</button>
|
|
255
|
+
<button
|
|
256
|
+
type="button"
|
|
257
|
+
onClick={() => {
|
|
258
|
+
setMobileOpen(false)
|
|
259
|
+
setMcpOpen(true)
|
|
260
|
+
}}
|
|
261
|
+
className="nav-item"
|
|
262
|
+
>
|
|
263
|
+
<Plug size={15} strokeWidth={1.7} className="shrink-0 text-faint" />
|
|
264
|
+
MCP
|
|
265
|
+
</button>
|
|
228
266
|
<button
|
|
229
267
|
type="button"
|
|
230
268
|
onClick={() => {
|
|
@@ -286,28 +324,38 @@ export function Sidebar() {
|
|
|
286
324
|
{workspaces.map((entry) => {
|
|
287
325
|
const active = entry.path === workspace.root
|
|
288
326
|
return (
|
|
289
|
-
<
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
327
|
+
<div key={entry.path} className="group/ws relative">
|
|
328
|
+
<button
|
|
329
|
+
type="button"
|
|
330
|
+
onClick={() => void switchWorkspace(entry.path)}
|
|
331
|
+
title={entry.path}
|
|
332
|
+
className={cn(
|
|
333
|
+
'flex h-8 w-full items-center gap-2.5 rounded-lg px-2.5 pr-8 text-left text-[13px] transition-all duration-150 active:scale-[0.985]',
|
|
334
|
+
active ? 'bg-raised text-ink' : 'text-muted hover:bg-raised/80 hover:text-ink',
|
|
335
|
+
)}
|
|
336
|
+
>
|
|
337
|
+
<Folder size={15} strokeWidth={1.6} className={cn('shrink-0 transition-transform duration-200 group-hover/ws:-rotate-6 group-hover/ws:scale-110', active ? 'text-accent' : 'text-faint')} />
|
|
338
|
+
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
|
|
339
|
+
{switching === entry.path ? (
|
|
340
|
+
<LoaderCircle size={11} className="shrink-0 animate-spin text-faint" />
|
|
341
|
+
) : active ? (
|
|
342
|
+
<Check size={11} className="shrink-0 text-accent" />
|
|
343
|
+
) : (
|
|
344
|
+
<span className="shrink-0 text-[10px] tabular-nums text-faint transition-opacity group-hover/ws:opacity-0">
|
|
345
|
+
{formatRelativeTime(entry.lastOpened)}
|
|
346
|
+
</span>
|
|
347
|
+
)}
|
|
348
|
+
</button>
|
|
349
|
+
<button
|
|
350
|
+
type="button"
|
|
351
|
+
onClick={() => void deleteWorkspace(entry.path, entry.name)}
|
|
352
|
+
className="absolute right-1 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-faint opacity-0 transition-all hover:bg-danger/10 hover:text-danger focus:opacity-100 group-hover/ws:opacity-100"
|
|
353
|
+
title="Remove workspace from the list"
|
|
354
|
+
aria-label={`Remove workspace ${entry.name}`}
|
|
355
|
+
>
|
|
356
|
+
<Trash2 size={12} />
|
|
357
|
+
</button>
|
|
358
|
+
</div>
|
|
311
359
|
)
|
|
312
360
|
})}
|
|
313
361
|
</div>
|
|
@@ -360,7 +408,7 @@ export function Sidebar() {
|
|
|
360
408
|
title={`${session.title} · ${formatRelativeTime(session.updatedAt)}`}
|
|
361
409
|
className={cn('sidebar-item pr-9', activeId === session.id && 'sidebar-item-active')}
|
|
362
410
|
>
|
|
363
|
-
<span className=
|
|
411
|
+
<span className={cn('min-w-0 flex-1 truncate', titling[session.id] && 'text-shimmer')}>{session.title}</span>
|
|
364
412
|
{streaming[session.id] && (
|
|
365
413
|
<span
|
|
366
414
|
className="flex shrink-0 items-center gap-1 text-[10.5px] font-medium text-accent group-hover/item:hidden"
|
|
@@ -381,7 +429,7 @@ export function Sidebar() {
|
|
|
381
429
|
}
|
|
382
430
|
const rect = event.currentTarget.getBoundingClientRect()
|
|
383
431
|
const width = 176
|
|
384
|
-
const height =
|
|
432
|
+
const height = 166
|
|
385
433
|
const x = Math.max(8, Math.min(rect.right - width, window.innerWidth - width - 8))
|
|
386
434
|
const y =
|
|
387
435
|
rect.bottom + height + 8 > window.innerHeight
|
|
@@ -470,6 +518,13 @@ export function Sidebar() {
|
|
|
470
518
|
>
|
|
471
519
|
<Pencil size={12} /> Rename
|
|
472
520
|
</button>
|
|
521
|
+
<button
|
|
522
|
+
type="button"
|
|
523
|
+
onClick={() => void aiRename(session.id)}
|
|
524
|
+
className="flex h-7 w-full items-center gap-2 rounded-md px-2 text-left text-[11.5px] text-muted hover:bg-raised hover:text-ink"
|
|
525
|
+
>
|
|
526
|
+
<Sparkles size={12} /> Name with AI
|
|
527
|
+
</button>
|
|
473
528
|
<button
|
|
474
529
|
type="button"
|
|
475
530
|
onClick={() => exportChat(session.id)}
|
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react'
|
|
2
2
|
import { ArrowRight } from 'lucide-react'
|
|
3
|
-
import type { ToolMessage, ToolName } from '../types'
|
|
3
|
+
import type { FileDiff, ToolMessage, ToolName } from '../types'
|
|
4
4
|
import { toolLabel, toolSummary } from '../agent/tools'
|
|
5
5
|
import { resolveApproval } from '../agent/approval'
|
|
6
6
|
import { useUi } from '../store/ui'
|
|
7
7
|
import { cn } from '../lib/utils'
|
|
8
|
+
import { diffLines, diffStats } from '../lib/diff'
|
|
9
|
+
import { DiffBadge, DiffView } from './DiffView'
|
|
10
|
+
|
|
11
|
+
const FILE_TOOLS = new Set(['write_file', 'edit_file', 'rewrite_file'])
|
|
12
|
+
|
|
13
|
+
/** The diff the daemon reported, or one worked out from the call itself for older chats. */
|
|
14
|
+
export function diffForTool(message: ToolMessage): FileDiff | undefined {
|
|
15
|
+
if (message.diff) return message.diff
|
|
16
|
+
if (message.status !== 'done' || message.call.name !== 'edit_file') return undefined
|
|
17
|
+
const { oldText, newText } = message.call.args
|
|
18
|
+
if (typeof oldText !== 'string' || typeof newText !== 'string') return undefined
|
|
19
|
+
return diffStats(diffLines(oldText, newText))
|
|
20
|
+
}
|
|
8
21
|
|
|
9
22
|
function statusMeta(status: ToolMessage['status']) {
|
|
10
23
|
switch (status) {
|
|
@@ -55,6 +68,8 @@ export function ToolCallCard({ message, compact = false }: { message: ToolMessag
|
|
|
55
68
|
const status = statusMeta(message.status)
|
|
56
69
|
const summary = toolSummary(message.call.name, message.call.args)
|
|
57
70
|
const args = message.call.args
|
|
71
|
+
const diff = diffForTool(message)
|
|
72
|
+
const isFileTool = FILE_TOOLS.has(message.call.name)
|
|
58
73
|
const command = typeof args.command === 'string' ? args.command : ''
|
|
59
74
|
const content =
|
|
60
75
|
typeof args.content === 'string'
|
|
@@ -87,7 +102,7 @@ export function ToolCallCard({ message, compact = false }: { message: ToolMessag
|
|
|
87
102
|
: status.label.toLowerCase()
|
|
88
103
|
|
|
89
104
|
return (
|
|
90
|
-
<div className="text-[12.5px]">
|
|
105
|
+
<div className="animate-slideleft text-[12.5px]">
|
|
91
106
|
<button
|
|
92
107
|
type="button"
|
|
93
108
|
onClick={() => setOpen((value) => !value)}
|
|
@@ -104,10 +119,14 @@ export function ToolCallCard({ message, compact = false }: { message: ToolMessag
|
|
|
104
119
|
/>
|
|
105
120
|
<span className="shrink-0 font-medium text-ink">{toolLabel(message.call.name)}</span>
|
|
106
121
|
<span className="min-w-0 flex-1 truncate font-mono text-[11.5px] text-muted">{summary || '—'}</span>
|
|
107
|
-
{
|
|
108
|
-
<
|
|
109
|
-
|
|
110
|
-
|
|
122
|
+
{isFileTool && diff && message.status === 'done' ? (
|
|
123
|
+
<DiffBadge added={diff.added} removed={diff.removed} line={diff.line} column={diff.column} className="shrink-0" />
|
|
124
|
+
) : (
|
|
125
|
+
trailing && (
|
|
126
|
+
<span className={cn('shrink-0 text-[11px]', message.status === 'running' ? 'text-shimmer' : message.status === 'done' ? 'text-faint' : status.className)}>
|
|
127
|
+
{trailing}
|
|
128
|
+
</span>
|
|
129
|
+
)
|
|
111
130
|
)}
|
|
112
131
|
</button>
|
|
113
132
|
|
|
@@ -140,7 +159,10 @@ export function ToolCallCard({ message, compact = false }: { message: ToolMessag
|
|
|
140
159
|
$ {command}
|
|
141
160
|
</pre>
|
|
142
161
|
)}
|
|
143
|
-
{
|
|
162
|
+
{message.call.name === 'edit_file' && typeof args.oldText === 'string' && typeof args.newText === 'string' && (
|
|
163
|
+
<DiffView before={args.oldText} after={args.newText} startLine={diff?.line ?? 1} />
|
|
164
|
+
)}
|
|
165
|
+
{content && message.call.name !== 'edit_file' && (
|
|
144
166
|
<pre className="thin-scroll max-h-48 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed text-muted">
|
|
145
167
|
{content.length > 4000 ? `${content.slice(0, 4000)}\n… ${content.length - 4000} more characters` : content}
|
|
146
168
|
</pre>
|
|
@@ -26,6 +26,8 @@ import { cn, formatBytes } from '../lib/utils'
|
|
|
26
26
|
import { useUi } from '../store/ui'
|
|
27
27
|
import { useWorkspace } from '../store/workspace'
|
|
28
28
|
import { PreviewPane } from './PreviewPane'
|
|
29
|
+
import { SessionsTab } from './SessionsTab'
|
|
30
|
+
import { useChats } from '../store/chats'
|
|
29
31
|
import { Spinner } from './ui'
|
|
30
32
|
|
|
31
33
|
interface TreeNode {
|
|
@@ -230,7 +232,8 @@ export function WorkspacePanel() {
|
|
|
230
232
|
|
|
231
233
|
const [query, setQuery] = useState('')
|
|
232
234
|
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
|
|
233
|
-
const
|
|
235
|
+
const sessionCount = useChats((state) => state.sessions.length)
|
|
236
|
+
const [tab, setTab] = useState<'files' | 'preview' | 'git' | 'sessions'>('files')
|
|
234
237
|
const [choosing, setChoosing] = useState(!info.available)
|
|
235
238
|
const [editor, setEditor] = useState<{ mode: 'create-file' | 'create-dir' | 'rename'; value: string } | null>(
|
|
236
239
|
null,
|
|
@@ -457,6 +460,16 @@ export function WorkspacePanel() {
|
|
|
457
460
|
>
|
|
458
461
|
Preview
|
|
459
462
|
</button>
|
|
463
|
+
<button
|
|
464
|
+
type="button"
|
|
465
|
+
onClick={() => setTab('sessions')}
|
|
466
|
+
className={cn(
|
|
467
|
+
'h-7 rounded-md px-2.5 text-[11px] font-medium',
|
|
468
|
+
tab === 'sessions' ? 'bg-raised text-ink' : 'text-muted hover:text-ink',
|
|
469
|
+
)}
|
|
470
|
+
>
|
|
471
|
+
Sessions <span className="text-faint">· {sessionCount}</span>
|
|
472
|
+
</button>
|
|
460
473
|
<button
|
|
461
474
|
type="button"
|
|
462
475
|
onClick={() => setTab('git')}
|
|
@@ -639,6 +652,8 @@ export function WorkspacePanel() {
|
|
|
639
652
|
</>
|
|
640
653
|
) : tab === 'preview' ? (
|
|
641
654
|
<PreviewPane />
|
|
655
|
+
) : tab === 'sessions' ? (
|
|
656
|
+
<SessionsTab />
|
|
642
657
|
) : (
|
|
643
658
|
<div className="thin-scroll min-h-0 flex-1 overflow-y-auto">
|
|
644
659
|
<div className="flex items-center gap-2 border-b border-line px-3 py-3">
|
package/src/core/kernel.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { CompactionService } from './services/compaction'
|
|
|
9
9
|
import { SubagentService } from './services/subagents'
|
|
10
10
|
import { UiService } from './services/ui'
|
|
11
11
|
import { AgentLoopService } from './services/agent-loop'
|
|
12
|
+
import { TitleService } from './services/titles'
|
|
12
13
|
|
|
13
14
|
import * as toolFs from './plugins/tools/fs'
|
|
14
15
|
import * as toolShell from './plugins/tools/shell'
|
|
@@ -30,6 +31,7 @@ export function createAppKernel(): Context {
|
|
|
30
31
|
ctx.plugin(SubagentService)
|
|
31
32
|
ctx.plugin(UiService)
|
|
32
33
|
ctx.plugin(AgentLoopService)
|
|
34
|
+
ctx.plugin(TitleService)
|
|
33
35
|
|
|
34
36
|
// 2. Mount First-Party Tool Plugins
|
|
35
37
|
ctx.plugin(toolFs)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { AppSettings, ToolName, ToolSetting } from '../types'
|
|
2
|
+
import { mcpCall } from '../lib/daemon'
|
|
3
|
+
import { isMcpToolName, useMcp } from '../store/mcp'
|
|
4
|
+
import type { ToolDefinition, ToolSchema } from './types'
|
|
5
|
+
|
|
6
|
+
const MAX_OUTPUT = 28_000
|
|
7
|
+
|
|
8
|
+
function toParameters(schema: Record<string, unknown>): ToolSchema['parameters'] {
|
|
9
|
+
const properties =
|
|
10
|
+
schema.properties && typeof schema.properties === 'object'
|
|
11
|
+
? (schema.properties as Record<string, unknown>)
|
|
12
|
+
: {}
|
|
13
|
+
const required = Array.isArray(schema.required)
|
|
14
|
+
? schema.required.filter((item): item is string => typeof item === 'string')
|
|
15
|
+
: undefined
|
|
16
|
+
return { type: 'object', properties, ...(required?.length ? { required } : {}) }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A connected, enabled MCP tool as a regular tool definition; null when it is not callable now. */
|
|
20
|
+
export function mcpDefinition(name: ToolName): ToolDefinition | undefined {
|
|
21
|
+
if (!isMcpToolName(name)) return undefined
|
|
22
|
+
const hit = useMcp.getState().lookup(name)
|
|
23
|
+
if (!hit) return undefined
|
|
24
|
+
const { server, tool } = hit
|
|
25
|
+
if (!server.enabled || server.status !== 'connected' || server.disabledTools.includes(tool.name)) return undefined
|
|
26
|
+
return {
|
|
27
|
+
name,
|
|
28
|
+
description: `[MCP server “${server.name}”] ${tool.description || tool.name}`.slice(0, 1200),
|
|
29
|
+
parameters: toParameters(tool.inputSchema),
|
|
30
|
+
allowedInMode: (mode) => mode === 'build' || tool.readOnly,
|
|
31
|
+
async execute(args, exec) {
|
|
32
|
+
const { result } = await mcpCall(server.id, tool.name, args, exec.signal)
|
|
33
|
+
const text = result.text.trim() || '(the tool returned no output)'
|
|
34
|
+
return {
|
|
35
|
+
output: text.length > MAX_OUTPUT ? `${text.slice(0, MAX_OUTPUT)}\n… truncated (${text.length - MAX_OUTPUT} more characters)` : text,
|
|
36
|
+
isError: result.isError,
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function mcpDefinitions(): ToolDefinition[] {
|
|
43
|
+
const out: ToolDefinition[] = []
|
|
44
|
+
for (const server of useMcp.getState().servers) {
|
|
45
|
+
for (const tool of server.tools) {
|
|
46
|
+
const definition = mcpDefinition(tool.fn)
|
|
47
|
+
if (definition) out.push(definition)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return out
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Enabled/approval for any tool: built-ins come from Settings → Tools, MCP tools from their server. */
|
|
54
|
+
export function resolveToolSetting(settings: AppSettings, name: ToolName): ToolSetting | undefined {
|
|
55
|
+
if (!isMcpToolName(name)) return settings.tools[name]
|
|
56
|
+
const hit = useMcp.getState().lookup(name)
|
|
57
|
+
if (!hit) return undefined
|
|
58
|
+
const { server, tool } = hit
|
|
59
|
+
return {
|
|
60
|
+
enabled: server.enabled && server.status === 'connected' && !server.disabledTools.includes(tool.name),
|
|
61
|
+
approval: server.approval,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Context } from 'cordis'
|
|
2
2
|
import { executeDaemonTool } from '../../../lib/daemon'
|
|
3
3
|
import { useWorkspace } from '../../../store/workspace'
|
|
4
|
+
import type { FileDiff } from '../../../types'
|
|
4
5
|
import type { ToolResult } from '../../types'
|
|
5
6
|
|
|
6
7
|
const MAX_TOOL_OUTPUT = 28_000
|
|
@@ -54,6 +55,20 @@ function formatList(result: Record<string, unknown>): string {
|
|
|
54
55
|
return `${textValue(result.root) ? `Root: ${textValue(result.root)}\n` : ''}${lines.join('\n')}${suffix}`
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
function diffOf(result: Record<string, unknown>): FileDiff | undefined {
|
|
59
|
+
const raw = record(result.diff)
|
|
60
|
+
const added = numberValue(raw.added)
|
|
61
|
+
const removed = numberValue(raw.removed)
|
|
62
|
+
if (added === undefined || removed === undefined) return undefined
|
|
63
|
+
return { added, removed, line: numberValue(raw.line), column: numberValue(raw.column) }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function diffLabel(diff?: FileDiff) {
|
|
67
|
+
if (!diff) return ''
|
|
68
|
+
const where = diff.line ? ` at L${diff.line}${diff.column ? `:${diff.column}` : ''}` : ''
|
|
69
|
+
return ` · +${diff.added} −${diff.removed}${where}`
|
|
70
|
+
}
|
|
71
|
+
|
|
57
72
|
async function refreshWorkspace() {
|
|
58
73
|
await useWorkspace.getState().refresh().catch(() => undefined)
|
|
59
74
|
}
|
|
@@ -143,9 +158,11 @@ export function apply(ctx: Context) {
|
|
|
143
158
|
const result = record((await executeDaemonTool('write_file', { path, content }, exec.mode, exec.signal, exec.turnId)).result)
|
|
144
159
|
await refreshWorkspace()
|
|
145
160
|
const verb = result.created === true ? 'Created' : 'Updated'
|
|
161
|
+
const diff = diffOf(result)
|
|
146
162
|
return {
|
|
147
|
-
output: `${verb} ${textValue(result.path) || path} · ${content.split('\n').length} lines, ${numberValue(result.size) ?? content.length} bytes.`,
|
|
163
|
+
output: `${verb} ${textValue(result.path) || path} · ${content.split('\n').length} lines, ${numberValue(result.size) ?? content.length} bytes${diffLabel(diff)}.`,
|
|
148
164
|
isError: false,
|
|
165
|
+
diff,
|
|
149
166
|
}
|
|
150
167
|
},
|
|
151
168
|
}),
|
|
@@ -168,9 +185,11 @@ export function apply(ctx: Context) {
|
|
|
168
185
|
const content = requireString(args, 'content')
|
|
169
186
|
const result = record((await executeDaemonTool('rewrite_file', { path, content }, exec.mode, exec.signal, exec.turnId)).result)
|
|
170
187
|
await refreshWorkspace()
|
|
188
|
+
const diff = diffOf(result)
|
|
171
189
|
return {
|
|
172
|
-
output: `Rewrote ${textValue(result.path) || path} · ${content.split('\n').length} lines, ${numberValue(result.size) ?? content.length} bytes.`,
|
|
190
|
+
output: `Rewrote ${textValue(result.path) || path} · ${content.split('\n').length} lines, ${numberValue(result.size) ?? content.length} bytes${diffLabel(diff)}.`,
|
|
173
191
|
isError: false,
|
|
192
|
+
diff,
|
|
174
193
|
}
|
|
175
194
|
},
|
|
176
195
|
}),
|
|
@@ -207,9 +226,11 @@ export function apply(ctx: Context) {
|
|
|
207
226
|
).result,
|
|
208
227
|
)
|
|
209
228
|
await refreshWorkspace()
|
|
229
|
+
const diff = diffOf(result)
|
|
210
230
|
return {
|
|
211
|
-
output: `Edited ${textValue(result.path) || path} · ${numberValue(result.replacements) ?? 1} replacement${numberValue(result.replacements) === 1 ? '' : 's'}.`,
|
|
231
|
+
output: `Edited ${textValue(result.path) || path} · ${numberValue(result.replacements) ?? 1} replacement${numberValue(result.replacements) === 1 ? '' : 's'}${diffLabel(diff)}.`,
|
|
212
232
|
isError: false,
|
|
233
|
+
diff,
|
|
213
234
|
}
|
|
214
235
|
},
|
|
215
236
|
}),
|
|
@@ -8,6 +8,7 @@ import { getProviderName } from '../../data/providers'
|
|
|
8
8
|
import { describeRequestError } from '../../lib/sse'
|
|
9
9
|
import type { AdapterResult } from '../../agent/adapters/shared'
|
|
10
10
|
import { createTurnQueue, lastUserMessageId } from '../../agent/queue'
|
|
11
|
+
import { resolveToolSetting } from '../mcp-tools'
|
|
11
12
|
import type { StepContext, StepResult, TurnResult } from '../types'
|
|
12
13
|
|
|
13
14
|
const MAX_ROUNDS = 16
|
|
@@ -276,7 +277,7 @@ export class AgentLoopService extends Service {
|
|
|
276
277
|
|
|
277
278
|
for (const call of result.toolCalls) {
|
|
278
279
|
if (controller.signal.aborted) break
|
|
279
|
-
const toolSetting = settings
|
|
280
|
+
const toolSetting = resolveToolSetting(settings, call.name)
|
|
280
281
|
if (!toolSetting?.enabled) {
|
|
281
282
|
const message: ToolMessage = {
|
|
282
283
|
id: createId('tool'),
|
|
@@ -332,6 +333,7 @@ export class AgentLoopService extends Service {
|
|
|
332
333
|
status: output.isError ? 'error' : 'done',
|
|
333
334
|
result: output.output,
|
|
334
335
|
durationMs: Date.now() - toolStarted,
|
|
336
|
+
diff: output.diff,
|
|
335
337
|
}
|
|
336
338
|
this.ctx.sessions.updateMessage(sessionId, toolMessageId, finalToolMessage)
|
|
337
339
|
toolMessages.push(finalToolMessage)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Service, type Context } from 'cordis'
|
|
2
2
|
import type { ToolExecutionContext, ToolPreExecuteResult } from '../types'
|
|
3
|
+
import { resolveToolSetting } from '../mcp-tools'
|
|
3
4
|
|
|
4
5
|
interface PendingApproval {
|
|
5
6
|
toolCallId: string
|
|
@@ -18,7 +19,7 @@ export class ApprovalService extends Service {
|
|
|
18
19
|
|
|
19
20
|
// Attach to tools/pre-execute waterfall gate
|
|
20
21
|
this.ctx.on('tools/pre-execute', async (exec: ToolExecutionContext, next: () => Promise<ToolPreExecuteResult>) => {
|
|
21
|
-
const toolSetting = exec.settings
|
|
22
|
+
const toolSetting = resolveToolSetting(exec.settings, exec.name)
|
|
22
23
|
if (toolSetting?.approval === 'ask' && exec.callId) {
|
|
23
24
|
const approved = await this.waitForApproval(exec.callId, exec.signal)
|
|
24
25
|
if (!approved) {
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { Service, type Context } from 'cordis'
|
|
2
|
+
import type { AppSettings, BuiltinToolName, Message } from '../../types'
|
|
3
|
+
import { activeModelId, useSettings } from '../../store/settings'
|
|
4
|
+
import { useChats } from '../../store/chats'
|
|
5
|
+
import { useRuntime } from '../../store/ui'
|
|
6
|
+
import { findModel } from '../../store/models'
|
|
7
|
+
import { effortOptions } from '../../lib/reasoning'
|
|
8
|
+
import { createId } from '../../store/chats'
|
|
9
|
+
import type { TurnResult } from '../types'
|
|
10
|
+
|
|
11
|
+
const TITLE_SYSTEM =
|
|
12
|
+
'You name chat sessions for a coding assistant. Reply with the title only: 2 to 6 words, in the same language as the user, no quotes, no markdown, no trailing punctuation.'
|
|
13
|
+
|
|
14
|
+
function clip(value: string, max: number) {
|
|
15
|
+
const clean = value.replace(/\s+/g, ' ').trim()
|
|
16
|
+
return clean.length > max ? `${clean.slice(0, max)}…` : clean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function cleanTitle(raw: string): string {
|
|
20
|
+
const first = raw
|
|
21
|
+
.replace(/<think>[\s\S]*?<\/think>/gi, '')
|
|
22
|
+
.split('\n')
|
|
23
|
+
.map((line) => line.trim())
|
|
24
|
+
.find(Boolean)
|
|
25
|
+
if (!first) return ''
|
|
26
|
+
const title = first
|
|
27
|
+
.replace(/^(title|tiêu đề)\s*[::-]\s*/i, '')
|
|
28
|
+
.replace(/^[#>*\-\s"'“”‘’`]+|[\s"'“”‘’`.。!]+$/g, '')
|
|
29
|
+
.trim()
|
|
30
|
+
return title.length > 64 ? `${title.slice(0, 63).trim()}…` : title
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function excerpt(messages: Message[]) {
|
|
34
|
+
const user = messages.find((message) => message.role === 'user')
|
|
35
|
+
const assistant = [...messages].reverse().find((message) => message.role === 'assistant' && message.content.trim())
|
|
36
|
+
return {
|
|
37
|
+
user: user && user.role === 'user' ? clip(user.content, 700) : '',
|
|
38
|
+
assistant: assistant && assistant.role === 'assistant' ? clip(assistant.content, 700) : '',
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Lets the model name a session after its first answer, or on demand from the sidebar. */
|
|
43
|
+
export class TitleService extends Service {
|
|
44
|
+
static provide = 'titles'
|
|
45
|
+
static inject = ['llm', 'sessions']
|
|
46
|
+
|
|
47
|
+
private inflight = new Set<string>()
|
|
48
|
+
|
|
49
|
+
constructor(ctx: Context) {
|
|
50
|
+
super(ctx, 'titles')
|
|
51
|
+
this.ctx.on('agent/turn-finish', (turn: TurnResult) => {
|
|
52
|
+
if (turn.error || turn.stopped) return
|
|
53
|
+
const settings = useSettings.getState()
|
|
54
|
+
if (!settings.aiSessionTitles) return
|
|
55
|
+
const session = this.ctx.sessions.current(turn.sessionId)
|
|
56
|
+
// Only a title that is still the first-message excerpt may be replaced; names the person typed stay.
|
|
57
|
+
if (!session || (session.titleSource ?? 'auto') !== 'auto') return
|
|
58
|
+
void this.generate(turn.sessionId)
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async generate(sessionId: string, { force = false } = {}): Promise<string | null> {
|
|
63
|
+
if (this.inflight.has(sessionId)) return null
|
|
64
|
+
const session = this.ctx.sessions.current(sessionId)
|
|
65
|
+
if (!session) return null
|
|
66
|
+
if (!force && (session.titleSource ?? 'auto') !== 'auto') return null
|
|
67
|
+
const { user, assistant } = excerpt(session.messages)
|
|
68
|
+
if (!user) return null
|
|
69
|
+
|
|
70
|
+
const settings = useSettings.getState()
|
|
71
|
+
const model = findModel(settings.provider, activeModelId(settings))
|
|
72
|
+
// No tools, no web, the lowest reasoning effort the model offers: a title is not worth a thinking budget.
|
|
73
|
+
const tools = Object.fromEntries(
|
|
74
|
+
Object.entries(settings.tools).map(([name, value]) => [name, { ...value, enabled: false }]),
|
|
75
|
+
) as Record<BuiltinToolName, AppSettings['tools'][BuiltinToolName]>
|
|
76
|
+
const efforts = effortOptions(model)
|
|
77
|
+
const titleSettings: AppSettings = {
|
|
78
|
+
...settings,
|
|
79
|
+
tools,
|
|
80
|
+
webSearchEnabled: false,
|
|
81
|
+
temperature: 0.3,
|
|
82
|
+
maxTokens: 512,
|
|
83
|
+
effort: efforts[0] ?? settings.effort,
|
|
84
|
+
}
|
|
85
|
+
const history: Message[] = [
|
|
86
|
+
{
|
|
87
|
+
id: createId('title'),
|
|
88
|
+
role: 'user',
|
|
89
|
+
createdAt: Date.now(),
|
|
90
|
+
content: `Name this conversation.\n\nUser: ${user}\n\nAssistant: ${assistant || '(no answer yet)'}`,
|
|
91
|
+
},
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
this.inflight.add(sessionId)
|
|
95
|
+
useRuntime.getState().setTitling(sessionId, true)
|
|
96
|
+
try {
|
|
97
|
+
const controller = new AbortController()
|
|
98
|
+
const timer = window.setTimeout(() => controller.abort(), 25_000)
|
|
99
|
+
let streamed = ''
|
|
100
|
+
const result = await this.ctx.llm
|
|
101
|
+
.stream({
|
|
102
|
+
settings: titleSettings,
|
|
103
|
+
model,
|
|
104
|
+
history,
|
|
105
|
+
system: TITLE_SYSTEM,
|
|
106
|
+
mode: 'plan',
|
|
107
|
+
signal: controller.signal,
|
|
108
|
+
handlers: {
|
|
109
|
+
onText: (delta) => {
|
|
110
|
+
streamed += delta
|
|
111
|
+
},
|
|
112
|
+
onReasoning: () => undefined,
|
|
113
|
+
onToolCalls: () => undefined,
|
|
114
|
+
onUsage: () => undefined,
|
|
115
|
+
},
|
|
116
|
+
})
|
|
117
|
+
.finally(() => window.clearTimeout(timer))
|
|
118
|
+
const title = cleanTitle(result.content || streamed)
|
|
119
|
+
const latest = this.ctx.sessions.current(sessionId)
|
|
120
|
+
if (!title || !latest) return null
|
|
121
|
+
if (!force && (latest.titleSource ?? 'auto') !== 'auto') return null
|
|
122
|
+
this.ctx.sessions.updateSession(sessionId, { title, titleSource: 'ai' })
|
|
123
|
+
return title
|
|
124
|
+
} catch {
|
|
125
|
+
return null
|
|
126
|
+
} finally {
|
|
127
|
+
this.inflight.delete(sessionId)
|
|
128
|
+
useRuntime.getState().setTitling(sessionId, false)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function sessionTitleSource(sessionId: string) {
|
|
134
|
+
return useChats.getState().sessions.find((session) => session.id === sessionId)?.titleSource
|
|
135
|
+
}
|