@bibobonking/opensol 3.0.2 → 3.1.0
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 +18 -0
- package/README.md +15 -12
- 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-DdI1_iPD.css +1 -0
- package/dist/assets/index-egG5Bgfo.js +312 -0
- package/dist/index.html +2 -2
- package/package.json +2 -1
- package/server/daemon.mjs +64 -2
- package/server/lib/mcp.mjs +532 -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/agent/tools.ts +6 -5
- package/src/components/Composer.tsx +22 -38
- package/src/components/DiffView.tsx +98 -0
- package/src/components/McpTab.tsx +337 -0
- package/src/components/MessageItem.tsx +62 -13
- package/src/components/ModelDeck.tsx +652 -0
- package/src/components/SessionsTab.tsx +206 -0
- package/src/components/SettingsModal.tsx +106 -69
- package/src/components/Sidebar.tsx +78 -24
- 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/index.css +97 -1
- package/src/lib/daemon.ts +29 -0
- package/src/lib/diff.ts +62 -0
- package/src/lib/permissions.ts +6 -6
- package/src/store/chats.ts +9 -1
- package/src/store/mcp.ts +67 -0
- package/src/store/settings.ts +9 -6
- package/src/store/ui.ts +13 -0
- package/src/store/workspace.ts +26 -0
- package/src/types.ts +51 -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
|
@@ -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
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Service, type Context } from 'cordis'
|
|
2
2
|
import type { AgentMode, AppSettings, ToolName } from '../../types'
|
|
3
|
+
import { mcpDefinition, mcpDefinitions, resolveToolSetting } from '../mcp-tools'
|
|
3
4
|
import type {
|
|
4
5
|
ToolDefinition,
|
|
5
6
|
ToolExecutionContext,
|
|
@@ -39,11 +40,11 @@ export class ToolService extends Service {
|
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
get(name: ToolName): ToolDefinition | undefined {
|
|
42
|
-
return this.definitions.get(name)
|
|
43
|
+
return this.definitions.get(name) ?? mcpDefinition(name)
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
list(): ToolDefinition[] {
|
|
46
|
-
return [...this.definitions.values()]
|
|
47
|
+
return [...this.definitions.values(), ...mcpDefinitions()]
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
schemas(
|
|
@@ -52,9 +53,11 @@ export class ToolService extends Service {
|
|
|
52
53
|
{ subagent = false }: { subagent?: boolean } = {},
|
|
53
54
|
): ToolSchema[] {
|
|
54
55
|
const list: ToolSchema[] = []
|
|
55
|
-
|
|
56
|
+
// Subagents stay on the built-in read-only tools; MCP tools are not offered to them.
|
|
57
|
+
const all = subagent ? [...this.definitions.values()] : this.list()
|
|
58
|
+
for (const def of all) {
|
|
56
59
|
if (def.name === 'subagent' && subagent) continue
|
|
57
|
-
if (!settings
|
|
60
|
+
if (!resolveToolSetting(settings, def.name)?.enabled) continue
|
|
58
61
|
const allowed = def.allowedInMode ? def.allowedInMode(mode) : toolAllowedInMode(def.name, mode)
|
|
59
62
|
if (!allowed) continue
|
|
60
63
|
if ((def.name === 'web_search' || def.name === 'read_url') && !settings.webSearchEnabled) continue
|
|
@@ -83,10 +86,10 @@ export class ToolService extends Service {
|
|
|
83
86
|
'tools/pre-execute',
|
|
84
87
|
exec,
|
|
85
88
|
async () => {
|
|
86
|
-
if (!settings
|
|
87
|
-
return { decision: 'deny', reason: 'This tool is disabled
|
|
89
|
+
if (!resolveToolSetting(settings, name)?.enabled) {
|
|
90
|
+
return { decision: 'deny', reason: 'This tool is disabled or its MCP server is not connected (Settings → Tools / MCP).' }
|
|
88
91
|
}
|
|
89
|
-
const def = this.
|
|
92
|
+
const def = this.get(name)
|
|
90
93
|
const allowed = def?.allowedInMode ? def.allowedInMode(mode) : toolAllowedInMode(name, mode)
|
|
91
94
|
if (!allowed) {
|
|
92
95
|
return {
|
|
@@ -113,7 +116,7 @@ export class ToolService extends Service {
|
|
|
113
116
|
|
|
114
117
|
// 2. tools/execute waterfall
|
|
115
118
|
const result: ToolResult = await this.ctx.events.waterfall('tools/execute', exec, async () => {
|
|
116
|
-
const def = this.
|
|
119
|
+
const def = this.get(name)
|
|
117
120
|
if (!def) {
|
|
118
121
|
return { output: `Tool "${name}" is not registered.`, isError: true }
|
|
119
122
|
}
|
package/src/core/types.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
AgentMode,
|
|
3
3
|
AppSettings,
|
|
4
4
|
AssistantMessage,
|
|
5
|
+
FileDiff,
|
|
5
6
|
Message,
|
|
6
7
|
ModelInfo,
|
|
7
8
|
ToolCall,
|
|
@@ -32,6 +33,8 @@ export interface ToolSchema {
|
|
|
32
33
|
export interface ToolResult {
|
|
33
34
|
output: string
|
|
34
35
|
isError: boolean
|
|
36
|
+
/** Set by file tools so the UI can show +added / −removed lines. */
|
|
37
|
+
diff?: FileDiff
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
export interface ToolExecutionContext {
|
|
@@ -120,6 +123,7 @@ declare module 'cordis' {
|
|
|
120
123
|
subagents: import('./services/subagents').SubagentService
|
|
121
124
|
agentLoop: import('./services/agent-loop').AgentLoopService
|
|
122
125
|
ui: import('./services/ui').UiService
|
|
126
|
+
titles: import('./services/titles').TitleService
|
|
123
127
|
}
|
|
124
128
|
|
|
125
129
|
interface Events {
|
package/src/index.css
CHANGED
|
@@ -222,7 +222,15 @@
|
|
|
222
222
|
}
|
|
223
223
|
|
|
224
224
|
.settings-row {
|
|
225
|
-
@apply flex flex-col gap-2 border-b border-
|
|
225
|
+
@apply -mx-3 flex flex-col gap-2 rounded-2xl border-b border-transparent px-3 py-4 transition-colors duration-200 hover:bg-raised/50 sm:flex-row sm:items-center sm:justify-between sm:gap-6;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.settings-card {
|
|
229
|
+
@apply relative rounded-2xl border border-line bg-panel p-4 shadow-pill transition-all duration-300 sm:p-5;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.settings-card:hover {
|
|
233
|
+
border-color: rgb(var(--accent) / 0.25);
|
|
226
234
|
}
|
|
227
235
|
|
|
228
236
|
.settings-label {
|
|
@@ -774,3 +782,91 @@
|
|
|
774
782
|
33% { transform: translateX(0); }
|
|
775
783
|
66% { transform: translateX(1px); }
|
|
776
784
|
}
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
/* ───────── v3.1 motion & animation ───────── */
|
|
788
|
+
|
|
789
|
+
.settings-enter > * {
|
|
790
|
+
animation: rise 0.55s cubic-bezier(0.16, 1, 0.3, 1) both;
|
|
791
|
+
}
|
|
792
|
+
.settings-enter > *:nth-child(2) { animation-delay: 60ms; }
|
|
793
|
+
.settings-enter > *:nth-child(3) { animation-delay: 120ms; }
|
|
794
|
+
.settings-enter > *:nth-child(4) { animation-delay: 180ms; }
|
|
795
|
+
.settings-enter > *:nth-child(n + 5) { animation-delay: 240ms; }
|
|
796
|
+
|
|
797
|
+
.settings-title-in {
|
|
798
|
+
animation: blurin 0.55s cubic-bezier(0.16, 1, 0.3, 1) both;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
.settings-shell {
|
|
802
|
+
animation: shell-in 0.42s cubic-bezier(0.16, 1, 0.3, 1) backwards;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
.settings-glow {
|
|
806
|
+
background:
|
|
807
|
+
radial-gradient(120% 100% at 0% 0%, rgb(var(--accent) / 0.16), transparent 65%),
|
|
808
|
+
radial-gradient(80% 80% at 100% 0%, rgb(var(--accent) / 0.06), transparent 70%);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
@keyframes shell-in {
|
|
812
|
+
from { opacity: 0; transform: translateY(18px) scale(0.975); filter: blur(6px); }
|
|
813
|
+
to { opacity: 1; transform: none; filter: blur(0); }
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/* Model deck */
|
|
817
|
+
.deck-scrim { animation: fade 0.25s ease-out both; }
|
|
818
|
+
.deck-panel { animation: deck-in 0.5s cubic-bezier(0.16, 1, 0.3, 1) backwards; }
|
|
819
|
+
.deck-row { animation: deck-row 0.4s cubic-bezier(0.16, 1, 0.3, 1) backwards; }
|
|
820
|
+
.deck-pop { animation: scalein 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both; }
|
|
821
|
+
.deck-aurora {
|
|
822
|
+
background:
|
|
823
|
+
radial-gradient(70% 120% at 12% 0%, rgb(var(--accent) / 0.18), transparent 70%),
|
|
824
|
+
radial-gradient(50% 100% at 95% 0%, rgb(var(--accent) / 0.07), transparent 70%);
|
|
825
|
+
}
|
|
826
|
+
.deck-throttle {
|
|
827
|
+
background-image: radial-gradient(rgb(var(--line)) 1px, transparent 1px);
|
|
828
|
+
background-size: 14px 14px;
|
|
829
|
+
background-color: rgb(var(--canvas) / 0.7);
|
|
830
|
+
}
|
|
831
|
+
.throttle-knob { box-shadow: 0 0 0 4px rgb(var(--accent) / 0.16), 0 6px 18px -6px rgb(var(--accent) / 0.55); }
|
|
832
|
+
.throttle-knob::after {
|
|
833
|
+
content: '';
|
|
834
|
+
position: absolute;
|
|
835
|
+
inset: -6px;
|
|
836
|
+
border-radius: 9999px;
|
|
837
|
+
border: 1px solid rgb(var(--accent) / 0.5);
|
|
838
|
+
animation: ping 2.4s cubic-bezier(0, 0, 0.2, 1) infinite;
|
|
839
|
+
}
|
|
840
|
+
.deck-trigger:active { transform: scale(0.96); }
|
|
841
|
+
|
|
842
|
+
@keyframes deck-in {
|
|
843
|
+
from { opacity: 0; transform: translateY(24px) scale(0.96); filter: blur(8px); }
|
|
844
|
+
to { opacity: 1; transform: none; filter: blur(0); }
|
|
845
|
+
}
|
|
846
|
+
@keyframes deck-row {
|
|
847
|
+
from { opacity: 0; transform: translateX(-10px); }
|
|
848
|
+
to { opacity: 1; transform: none; }
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/* Diff rows slide in as a tool card opens. */
|
|
852
|
+
.diff-row { animation: deck-row 0.35s cubic-bezier(0.16, 1, 0.3, 1) backwards; }
|
|
853
|
+
.diff-badge { animation: scalein 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both; }
|
|
854
|
+
|
|
855
|
+
/* Messages and tool rows arrive with a soft rise. */
|
|
856
|
+
.msg-enter { animation: rise 0.5s cubic-bezier(0.16, 1, 0.3, 1) both; }
|
|
857
|
+
|
|
858
|
+
/* Buttons feel physical. */
|
|
859
|
+
button:not(:disabled) { -webkit-tap-highlight-color: transparent; }
|
|
860
|
+
.chip, .nav-item, .sidebar-item { will-change: transform; }
|
|
861
|
+
|
|
862
|
+
/* Streaming caret and send-button pulse while the agent works. */
|
|
863
|
+
.session-card:hover { transform: translateX(2px); }
|
|
864
|
+
|
|
865
|
+
@media (prefers-reduced-motion: reduce) {
|
|
866
|
+
*, *::before, *::after {
|
|
867
|
+
animation-duration: 0.001ms !important;
|
|
868
|
+
animation-iteration-count: 1 !important;
|
|
869
|
+
transition-duration: 0.001ms !important;
|
|
870
|
+
scroll-behavior: auto !important;
|
|
871
|
+
}
|
|
872
|
+
}
|