bazilion 0.0.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/.understand-anything/.understandignore +25 -0
- package/.understand-anything/fingerprints.json +14267 -0
- package/.understand-anything/knowledge-graph.json +18128 -0
- package/.understand-anything/meta.json +6 -0
- package/CLAUDE.md +164 -0
- package/LICENSE +21 -0
- package/README.md +195 -0
- package/apps/cli/package.json +21 -0
- package/apps/cli/src/auth-file.ts +18 -0
- package/apps/cli/src/client.ts +37 -0
- package/apps/cli/src/columnize.ts +32 -0
- package/apps/cli/src/commands/agent.ts +574 -0
- package/apps/cli/src/commands/auth.ts +110 -0
- package/apps/cli/src/commands/backup.ts +135 -0
- package/apps/cli/src/commands/completion.ts +155 -0
- package/apps/cli/src/commands/config.ts +95 -0
- package/apps/cli/src/commands/doctor.ts +131 -0
- package/apps/cli/src/commands/group.ts +132 -0
- package/apps/cli/src/commands/inbox.ts +82 -0
- package/apps/cli/src/commands/login.ts +73 -0
- package/apps/cli/src/commands/memory.ts +106 -0
- package/apps/cli/src/commands/profile.ts +259 -0
- package/apps/cli/src/commands/provider.ts +170 -0
- package/apps/cli/src/commands/send.ts +24 -0
- package/apps/cli/src/commands/serve.ts +89 -0
- package/apps/cli/src/commands/skill.ts +120 -0
- package/apps/cli/src/commands/token.ts +148 -0
- package/apps/cli/src/commands/trigger.ts +129 -0
- package/apps/cli/src/commands/uninstall.ts +156 -0
- package/apps/cli/src/index.ts +196 -0
- package/apps/cli/src/paths.ts +12 -0
- package/apps/cli/test/agent.test.ts +213 -0
- package/apps/cli/test/backup.test.ts +95 -0
- package/apps/cli/test/chat.test.ts +539 -0
- package/apps/cli/test/columnize.test.ts +29 -0
- package/apps/cli/test/completion.test.ts +45 -0
- package/apps/cli/test/config-page.test.ts +151 -0
- package/apps/cli/test/group.test.ts +74 -0
- package/apps/cli/test/helpers.ts +70 -0
- package/apps/cli/test/inbox-autodeliver.test.ts +143 -0
- package/apps/cli/test/inbox.test.ts +147 -0
- package/apps/cli/test/memory.test.ts +69 -0
- package/apps/cli/test/profile.test.ts +110 -0
- package/apps/cli/test/send.test.ts +30 -0
- package/apps/cli/test/server-fixture.ts +212 -0
- package/apps/cli/test/session-head.test.ts +45 -0
- package/apps/cli/test/skill.test.ts +128 -0
- package/apps/cli/test/token.test.ts +109 -0
- package/apps/cli/test/trigger.test.ts +245 -0
- package/apps/cli/tsconfig.json +4 -0
- package/apps/daemon/package.json +31 -0
- package/apps/daemon/src/app.ts +41 -0
- package/apps/daemon/src/core/agent/archive.ts +8 -0
- package/apps/daemon/src/core/agent/delete.ts +30 -0
- package/apps/daemon/src/core/agent/resolve.ts +31 -0
- package/apps/daemon/src/core/agent/spawn.ts +95 -0
- package/apps/daemon/src/core/agent/unarchive.ts +11 -0
- package/apps/daemon/src/core/availableModels.ts +58 -0
- package/apps/daemon/src/core/db/client.ts +113 -0
- package/apps/daemon/src/core/db/migrate.ts +41 -0
- package/apps/daemon/src/core/db/migrations/0001_init.sql +179 -0
- package/apps/daemon/src/core/group/delete.ts +21 -0
- package/apps/daemon/src/core/group/register.ts +68 -0
- package/apps/daemon/src/core/index.ts +71 -0
- package/apps/daemon/src/core/paths.ts +56 -0
- package/apps/daemon/src/core/profile/create.ts +78 -0
- package/apps/daemon/src/core/profile/delete.ts +26 -0
- package/apps/daemon/src/core/profile/identity.ts +70 -0
- package/apps/daemon/src/core/profile/load.ts +45 -0
- package/apps/daemon/src/core/profile/seed.ts +74 -0
- package/apps/daemon/src/core/profile/templates.ts +60 -0
- package/apps/daemon/src/core/profile/update.ts +57 -0
- package/apps/daemon/src/core/profile/validate.ts +9 -0
- package/apps/daemon/src/core/repos/agents.ts +202 -0
- package/apps/daemon/src/core/repos/config.ts +78 -0
- package/apps/daemon/src/core/repos/groups.ts +55 -0
- package/apps/daemon/src/core/repos/messages.ts +127 -0
- package/apps/daemon/src/core/repos/profiles.ts +83 -0
- package/apps/daemon/src/core/repos/providerModels.ts +58 -0
- package/apps/daemon/src/core/repos/providerState.ts +37 -0
- package/apps/daemon/src/core/repos/secrets.ts +145 -0
- package/apps/daemon/src/core/repos/skillMeta.ts +49 -0
- package/apps/daemon/src/core/repos/triggers.ts +101 -0
- package/apps/daemon/src/core/repos/webTokens.ts +87 -0
- package/apps/daemon/src/core/secrets.ts +65 -0
- package/apps/daemon/src/core/services.ts +264 -0
- package/apps/daemon/src/core/skills/discover.ts +28 -0
- package/apps/daemon/src/core/skills/import.ts +136 -0
- package/apps/daemon/src/core/skills/parse.ts +52 -0
- package/apps/daemon/src/core/skills/resolve.ts +50 -0
- package/apps/daemon/src/index.ts +45 -0
- package/apps/daemon/src/lib/agent-cancel.ts +48 -0
- package/apps/daemon/src/lib/agent-id.ts +13 -0
- package/apps/daemon/src/lib/agent-turn.ts +56 -0
- package/apps/daemon/src/lib/api-key.ts +56 -0
- package/apps/daemon/src/lib/auth.ts +41 -0
- package/apps/daemon/src/lib/cron.ts +93 -0
- package/apps/daemon/src/lib/ctx.ts +80 -0
- package/apps/daemon/src/lib/messaging-host.ts +34 -0
- package/apps/daemon/src/lib/middleware-auth.ts +52 -0
- package/apps/daemon/src/lib/scheduler.ts +294 -0
- package/apps/daemon/src/routes/agents.ts +772 -0
- package/apps/daemon/src/routes/auth-login.ts +193 -0
- package/apps/daemon/src/routes/config.ts +267 -0
- package/apps/daemon/src/routes/groups.ts +133 -0
- package/apps/daemon/src/routes/messages.ts +29 -0
- package/apps/daemon/src/routes/misc.ts +239 -0
- package/apps/daemon/src/routes/profiles.ts +197 -0
- package/apps/daemon/src/routes/skills.ts +123 -0
- package/apps/daemon/src/routes/triggers.ts +29 -0
- package/apps/daemon/src/runtime/auth/openai-codex.ts +121 -0
- package/apps/daemon/src/runtime/auto-reply/heartbeat.ts +31 -0
- package/apps/daemon/src/runtime/index.ts +77 -0
- package/apps/daemon/src/runtime/memory/files.ts +103 -0
- package/apps/daemon/src/runtime/memory/qmd.ts +152 -0
- package/apps/daemon/src/runtime/memory/types.ts +16 -0
- package/apps/daemon/src/runtime/pi/events.ts +173 -0
- package/apps/daemon/src/runtime/pi/session.ts +536 -0
- package/apps/daemon/src/runtime/pi/tools.ts +85 -0
- package/apps/daemon/src/runtime/providers/catalog.ts +145 -0
- package/apps/daemon/src/runtime/providers/pi-adapter.ts +272 -0
- package/apps/daemon/src/runtime/providers/registry.ts +374 -0
- package/apps/daemon/src/runtime/providers/retry.ts +176 -0
- package/apps/daemon/src/runtime/providers/types.ts +33 -0
- package/apps/daemon/src/runtime/session/prompt.ts +83 -0
- package/apps/daemon/src/runtime/tools/bootstrap.ts +22 -0
- package/apps/daemon/src/runtime/tools/home.ts +114 -0
- package/apps/daemon/src/runtime/tools/memory.ts +81 -0
- package/apps/daemon/src/runtime/tools/messaging.ts +127 -0
- package/apps/daemon/src/runtime/tools/registry.ts +29 -0
- package/apps/daemon/src/runtime/tools/types.ts +13 -0
- package/apps/daemon/src/runtime/tools/web-extract.ts +110 -0
- package/apps/daemon/src/runtime/tools/web-ssrf.ts +245 -0
- package/apps/daemon/src/runtime/tools/web.ts +273 -0
- package/apps/daemon/src/runtime/worker/entry.ts +221 -0
- package/apps/daemon/src/runtime/worker/ipc-protocol.ts +75 -0
- package/apps/daemon/src/runtime/worker/spawn.ts +249 -0
- package/apps/daemon/test/core/agents.test.ts +319 -0
- package/apps/daemon/test/core/available-models.test.ts +63 -0
- package/apps/daemon/test/core/config.test.ts +99 -0
- package/apps/daemon/test/core/groups.test.ts +63 -0
- package/apps/daemon/test/core/helpers.ts +50 -0
- package/apps/daemon/test/core/identity.test.ts +85 -0
- package/apps/daemon/test/core/migrations.test.ts +83 -0
- package/apps/daemon/test/core/profiles.test.ts +182 -0
- package/apps/daemon/test/core/provider-models.test.ts +57 -0
- package/apps/daemon/test/core/provider-state.test.ts +36 -0
- package/apps/daemon/test/core/skill-meta.test.ts +45 -0
- package/apps/daemon/test/core/skills.test.ts +271 -0
- package/apps/daemon/test/core/triggers.test.ts +182 -0
- package/apps/daemon/test/core/web-tokens.test.ts +79 -0
- package/apps/daemon/test/cron.test.ts +90 -0
- package/apps/daemon/test/runtime/heartbeat.test.ts +34 -0
- package/apps/daemon/test/runtime/memory-qmd.test.ts +97 -0
- package/apps/daemon/test/runtime/memory.test.ts +78 -0
- package/apps/daemon/test/runtime/messaging.test.ts +213 -0
- package/apps/daemon/test/runtime/mock-server.ts +65 -0
- package/apps/daemon/test/runtime/openai-codex-auth.test.ts +116 -0
- package/apps/daemon/test/runtime/providers.test.ts +306 -0
- package/apps/daemon/test/runtime/retry.test.ts +191 -0
- package/apps/daemon/test/runtime/session-head.test.ts +90 -0
- package/apps/daemon/test/runtime/tools-home.test.ts +102 -0
- package/apps/daemon/test/runtime/tools-web.test.ts +206 -0
- package/apps/daemon/tsconfig.json +4 -0
- package/apps/mobile/README.md +60 -0
- package/apps/mobile/app/_layout.tsx +58 -0
- package/apps/mobile/app/agents/[id]/chat.tsx +486 -0
- package/apps/mobile/app/agents/[id]/index.tsx +166 -0
- package/apps/mobile/app/agents/index.tsx +212 -0
- package/apps/mobile/app/index.tsx +21 -0
- package/apps/mobile/app/pair.tsx +226 -0
- package/apps/mobile/app/settings.tsx +419 -0
- package/apps/mobile/app.json +49 -0
- package/apps/mobile/assets/adaptive-icon.png +0 -0
- package/apps/mobile/assets/favicon.png +0 -0
- package/apps/mobile/assets/icon.png +0 -0
- package/apps/mobile/assets/splash-icon.png +0 -0
- package/apps/mobile/babel.config.js +6 -0
- package/apps/mobile/metro.config.js +28 -0
- package/apps/mobile/package.json +44 -0
- package/apps/mobile/src/auth.ts +66 -0
- package/apps/mobile/src/pair-url.ts +48 -0
- package/apps/mobile/src/theme-context.tsx +88 -0
- package/apps/mobile/src/theme.ts +135 -0
- package/apps/mobile/test/pair-url.test.ts +46 -0
- package/apps/mobile/tsconfig.json +23 -0
- package/apps/web/components.json +25 -0
- package/apps/web/package.json +39 -0
- package/apps/web/public/baziu.svg +8 -0
- package/apps/web/src/components/AgentTabs.tsx +45 -0
- package/apps/web/src/components/BaziuLogo.tsx +21 -0
- package/apps/web/src/components/ChatPane.tsx +1033 -0
- package/apps/web/src/components/ConfigTabs.tsx +29 -0
- package/apps/web/src/components/CopyButton.tsx +68 -0
- package/apps/web/src/components/CreateGroupDialog.tsx +127 -0
- package/apps/web/src/components/FieldRow.tsx +94 -0
- package/apps/web/src/components/Footer.tsx +10 -0
- package/apps/web/src/components/PawIcon.tsx +15 -0
- package/apps/web/src/components/Sidebar.tsx +287 -0
- package/apps/web/src/components/SpawnDialog.tsx +129 -0
- package/apps/web/src/components/ThemeToggle.tsx +75 -0
- package/apps/web/src/components/TopNav.tsx +34 -0
- package/apps/web/src/components/ui/button.tsx +67 -0
- package/apps/web/src/components/ui/card.tsx +103 -0
- package/apps/web/src/components/ui/checkbox.tsx +31 -0
- package/apps/web/src/components/ui/dialog.tsx +168 -0
- package/apps/web/src/components/ui/input.tsx +19 -0
- package/apps/web/src/components/ui/label.tsx +22 -0
- package/apps/web/src/components/ui/radio-group.tsx +44 -0
- package/apps/web/src/components/ui/select.tsx +192 -0
- package/apps/web/src/components/ui/separator.tsx +26 -0
- package/apps/web/src/components/ui/table.tsx +116 -0
- package/apps/web/src/components/ui/tabs.tsx +88 -0
- package/apps/web/src/components/ui/textarea.tsx +18 -0
- package/apps/web/src/lib/auth.ts +50 -0
- package/apps/web/src/lib/daemon-client.ts +34 -0
- package/apps/web/src/lib/md.ts +45 -0
- package/apps/web/src/lib/utils.ts +6 -0
- package/apps/web/src/lib/wire-constants.ts +27 -0
- package/apps/web/src/routeTree.gen.ts +408 -0
- package/apps/web/src/router.tsx +20 -0
- package/apps/web/src/routes/__root.tsx +123 -0
- package/apps/web/src/routes/agents/$id/inbox.tsx +207 -0
- package/apps/web/src/routes/agents/$id/index.tsx +527 -0
- package/apps/web/src/routes/agents/$id/triggers.tsx +239 -0
- package/apps/web/src/routes/agents/index.tsx +265 -0
- package/apps/web/src/routes/api/$.ts +88 -0
- package/apps/web/src/routes/config/index.tsx +315 -0
- package/apps/web/src/routes/config/services.tsx +49 -0
- package/apps/web/src/routes/config/tokens.tsx +192 -0
- package/apps/web/src/routes/groups/$id/index.tsx +153 -0
- package/apps/web/src/routes/groups/$id/memory.tsx +321 -0
- package/apps/web/src/routes/groups/index.tsx +191 -0
- package/apps/web/src/routes/index.tsx +133 -0
- package/apps/web/src/routes/login.tsx +63 -0
- package/apps/web/src/routes/profiles/$id.tsx +549 -0
- package/apps/web/src/routes/profiles/index.tsx +458 -0
- package/apps/web/src/routes/skills/index.tsx +297 -0
- package/apps/web/src/routes/welcome.tsx +61 -0
- package/apps/web/src/styles.css +449 -0
- package/apps/web/tsconfig.json +25 -0
- package/apps/web/vite.config.ts +25 -0
- package/biome.json +23 -0
- package/docs/agent-engine.md +219 -0
- package/docs/architecture.md +627 -0
- package/docs/backlog/README.md +42 -0
- package/docs/backlog/draft/BAZ-001-a2a-federation-spike.md +125 -0
- package/docs/openclaw-reference.md +210 -0
- package/package.json +38 -0
- package/packages/api-types/package.json +11 -0
- package/packages/api-types/src/entities.ts +146 -0
- package/packages/api-types/src/events.ts +55 -0
- package/packages/api-types/src/index.ts +488 -0
- package/packages/api-types/src/memory.ts +15 -0
- package/packages/client/package.json +13 -0
- package/packages/client/src/index.ts +117 -0
- package/pnpm-workspace.yaml +3 -0
- package/tsconfig.base.json +23 -0
- package/tsconfig.json +11 -0
- package/vitest.config.ts +22 -0
|
@@ -0,0 +1,1033 @@
|
|
|
1
|
+
// Chat panel: renders the running transcript, sends messages, consumes the
|
|
2
|
+
// daemon's NDJSON ChatFrame stream. Initial messages come from the route
|
|
3
|
+
// loader; new ones land via fetch + ReadableStream consume.
|
|
4
|
+
|
|
5
|
+
import type { ChatFrame, ProviderMessage, SessionHeadResponse } from '@bazilion/api-types'
|
|
6
|
+
import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
7
|
+
import { renderMd } from '../lib/md'
|
|
8
|
+
|
|
9
|
+
const INBOX_WAKE_PREFIX = '[[bazilion:inbox-wake]]\n'
|
|
10
|
+
const COMPACTION_REPLAY_PREFIX = '[conversation summary]'
|
|
11
|
+
const AUTOSCROLL_THRESHOLD_PX = 40
|
|
12
|
+
const SESSION_POLL_MS = 10_000
|
|
13
|
+
const TOOL_GROUP_MAX_HEIGHT_PX = 320
|
|
14
|
+
const MAX_INPUT_HEIGHT = 200
|
|
15
|
+
|
|
16
|
+
type ToolItem = {
|
|
17
|
+
kind: 'call' | 'result' | 'error'
|
|
18
|
+
id: string
|
|
19
|
+
name: string
|
|
20
|
+
body: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type RenderEntry =
|
|
24
|
+
| { type: 'user'; content: string }
|
|
25
|
+
| { type: 'assistant'; content: string }
|
|
26
|
+
| { type: 'tool'; items: ToolItem[] }
|
|
27
|
+
| { type: 'system'; content: string }
|
|
28
|
+
| { type: 'error'; content: string }
|
|
29
|
+
|
|
30
|
+
const SLASH_HELP =
|
|
31
|
+
'slash commands:\n' +
|
|
32
|
+
' /context — context breakdown (system prompt, tools, skills, history)\n' +
|
|
33
|
+
' /compact [N] — summarize the head; keep the last N messages verbatim (default 10)\n' +
|
|
34
|
+
' /reset — reset chat history for this agent\n' +
|
|
35
|
+
' /help — show this list'
|
|
36
|
+
|
|
37
|
+
interface ChatContextResponse {
|
|
38
|
+
agentId: string
|
|
39
|
+
model: string
|
|
40
|
+
systemPrompt: { chars: number; tokens: number; files: { name: string; chars: number }[] }
|
|
41
|
+
tools: {
|
|
42
|
+
count: number
|
|
43
|
+
listChars: number
|
|
44
|
+
schemaChars: number
|
|
45
|
+
entries: { name: string; schemaChars: number; paramCount: number | null }[]
|
|
46
|
+
}
|
|
47
|
+
skills: { count: number; entries: { name: string; blockChars: number }[] }
|
|
48
|
+
history: {
|
|
49
|
+
messageEntries: number
|
|
50
|
+
compactionEntries: number
|
|
51
|
+
chars: number
|
|
52
|
+
bytes: number
|
|
53
|
+
tokensEstimate: number
|
|
54
|
+
}
|
|
55
|
+
totals: { chars: number; tokens: number }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface ChatCompactResponse {
|
|
59
|
+
before: number
|
|
60
|
+
after: number
|
|
61
|
+
summarized: number
|
|
62
|
+
keptTail: number
|
|
63
|
+
tokensBefore: number
|
|
64
|
+
tokensAfter: number
|
|
65
|
+
summary: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function prettyArgs(raw: string): string {
|
|
69
|
+
if (!raw) return ''
|
|
70
|
+
try {
|
|
71
|
+
return JSON.stringify(JSON.parse(raw), null, 2)
|
|
72
|
+
} catch {
|
|
73
|
+
return raw
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function formatBytes(n: number): string {
|
|
78
|
+
if (n < 1024) return `${n} B`
|
|
79
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`
|
|
80
|
+
return `${(n / (1024 * 1024)).toFixed(2)} MiB`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Project canonical ProviderMessage[] into render entries: assistant content +
|
|
84
|
+
// any tool calls, then tool-role messages collapse into the same group.
|
|
85
|
+
function projectMessages(msgs: ProviderMessage[]): RenderEntry[] {
|
|
86
|
+
const entries: RenderEntry[] = []
|
|
87
|
+
let openTool: { type: 'tool'; items: ToolItem[] } | null = null
|
|
88
|
+
for (const m of msgs) {
|
|
89
|
+
if (m.role === 'user') {
|
|
90
|
+
openTool = null
|
|
91
|
+
entries.push({ type: 'user', content: m.content })
|
|
92
|
+
} else if (m.role === 'assistant') {
|
|
93
|
+
if (m.content) {
|
|
94
|
+
openTool = null
|
|
95
|
+
entries.push({ type: 'assistant', content: m.content })
|
|
96
|
+
}
|
|
97
|
+
if (m.toolCalls && m.toolCalls.length > 0) {
|
|
98
|
+
if (!openTool) {
|
|
99
|
+
openTool = { type: 'tool', items: [] }
|
|
100
|
+
entries.push(openTool)
|
|
101
|
+
}
|
|
102
|
+
for (const tc of m.toolCalls) {
|
|
103
|
+
openTool.items.push({ kind: 'call', id: tc.id, name: tc.name, body: tc.arguments })
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
} else if (m.role === 'tool') {
|
|
107
|
+
if (!openTool) {
|
|
108
|
+
openTool = { type: 'tool', items: [] }
|
|
109
|
+
entries.push(openTool)
|
|
110
|
+
}
|
|
111
|
+
const isError = m.content.startsWith('Error:') || m.content.startsWith('error:')
|
|
112
|
+
openTool.items.push({
|
|
113
|
+
kind: isError ? 'error' : 'result',
|
|
114
|
+
id: m.toolCallId ?? '',
|
|
115
|
+
name: m.toolName ?? '',
|
|
116
|
+
body: m.content,
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return entries
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface ChatPaneProps {
|
|
124
|
+
agentId: string
|
|
125
|
+
agentName: string
|
|
126
|
+
initialMessages: ProviderMessage[]
|
|
127
|
+
/** SSR snapshot of the session-file head; the stale-banner poll compares against it. */
|
|
128
|
+
initialSessionHead?: SessionHeadResponse
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function ChatPane({
|
|
132
|
+
agentId,
|
|
133
|
+
agentName,
|
|
134
|
+
initialMessages,
|
|
135
|
+
initialSessionHead,
|
|
136
|
+
}: ChatPaneProps) {
|
|
137
|
+
const [serverMessages, setServerMessages] = useState<ProviderMessage[]>(initialMessages)
|
|
138
|
+
const [liveEntries, setLiveEntries] = useState<RenderEntry[]>([])
|
|
139
|
+
const [systemBubbles, setSystemBubbles] = useState<
|
|
140
|
+
Array<{ id: number; content: string; afterIdx: number }>
|
|
141
|
+
>([])
|
|
142
|
+
const [thinking, setThinking] = useState(false)
|
|
143
|
+
const [streaming, setStreaming] = useState(false)
|
|
144
|
+
const [editIdx, setEditIdx] = useState<number | null>(null)
|
|
145
|
+
const [input, setInput] = useState('')
|
|
146
|
+
const [staleBanner, setStaleBanner] = useState(false)
|
|
147
|
+
|
|
148
|
+
const messagesRef = useRef<HTMLDivElement | null>(null)
|
|
149
|
+
const inputRef = useRef<HTMLTextAreaElement | null>(null)
|
|
150
|
+
const currentAbortRef = useRef<AbortController | null>(null)
|
|
151
|
+
// Slash-command output bubbles are anchored to the count of visible
|
|
152
|
+
// (non-system) entries at push time, so they stay in place when later
|
|
153
|
+
// messages arrive instead of being pinned to the bottom of the transcript.
|
|
154
|
+
const systemIdRef = useRef(0)
|
|
155
|
+
const visibleEntryCountRef = useRef(0)
|
|
156
|
+
const knownHeadRef = useRef<SessionHeadResponse>(
|
|
157
|
+
initialSessionHead ?? { file: null, size: 0 },
|
|
158
|
+
)
|
|
159
|
+
// Keep current streaming/state references stable across closures (the poll
|
|
160
|
+
// loop and visibilitychange handler both read them).
|
|
161
|
+
const streamingRef = useRef(false)
|
|
162
|
+
streamingRef.current = streaming
|
|
163
|
+
|
|
164
|
+
// Re-seed when the agent prop changes (the loader returns new initialMessages
|
|
165
|
+
// for a different agent on the home page).
|
|
166
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: explicit reset on agent switch
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
setServerMessages(initialMessages)
|
|
169
|
+
setLiveEntries([])
|
|
170
|
+
setSystemBubbles([])
|
|
171
|
+
setEditIdx(null)
|
|
172
|
+
setInput('')
|
|
173
|
+
setThinking(false)
|
|
174
|
+
setStreaming(false)
|
|
175
|
+
setStaleBanner(false)
|
|
176
|
+
knownHeadRef.current = initialSessionHead ?? { file: null, size: 0 }
|
|
177
|
+
}, [agentId])
|
|
178
|
+
|
|
179
|
+
// --- smart autoscroll ---
|
|
180
|
+
const wasNearBottomRef = useRef(true)
|
|
181
|
+
useLayoutEffect(() => {
|
|
182
|
+
const el = messagesRef.current
|
|
183
|
+
if (!el) return
|
|
184
|
+
if (wasNearBottomRef.current) {
|
|
185
|
+
el.scrollTop = el.scrollHeight
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
function captureScroll() {
|
|
189
|
+
const el = messagesRef.current
|
|
190
|
+
if (!el) {
|
|
191
|
+
wasNearBottomRef.current = true
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
wasNearBottomRef.current =
|
|
195
|
+
el.scrollHeight - el.scrollTop - el.clientHeight < AUTOSCROLL_THRESHOLD_PX
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// --- textarea auto-resize ---
|
|
199
|
+
useLayoutEffect(() => {
|
|
200
|
+
const el = inputRef.current
|
|
201
|
+
if (!el) return
|
|
202
|
+
el.style.height = 'auto'
|
|
203
|
+
el.style.height = `${Math.min(el.scrollHeight, MAX_INPUT_HEIGHT)}px`
|
|
204
|
+
}, [input])
|
|
205
|
+
|
|
206
|
+
// --- pending-send marker (visibilitychange → reload if midflight) ---
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
const key = `bz_pending_${agentId}`
|
|
209
|
+
function onVis() {
|
|
210
|
+
if (document.visibilityState === 'visible' && sessionStorage.getItem(key)) {
|
|
211
|
+
sessionStorage.removeItem(key)
|
|
212
|
+
window.location.reload()
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
document.addEventListener('visibilitychange', onVis)
|
|
216
|
+
return () => document.removeEventListener('visibilitychange', onVis)
|
|
217
|
+
}, [agentId])
|
|
218
|
+
|
|
219
|
+
// --- stale-banner poll loop ---
|
|
220
|
+
useEffect(() => {
|
|
221
|
+
if (!initialSessionHead) return
|
|
222
|
+
let stopped = false
|
|
223
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
224
|
+
async function tick() {
|
|
225
|
+
if (stopped) return
|
|
226
|
+
if (!document.hidden && !streamingRef.current && !staleBanner) {
|
|
227
|
+
try {
|
|
228
|
+
const res = await fetch(
|
|
229
|
+
`/api/agents/${encodeURIComponent(agentId)}/sessions/head`,
|
|
230
|
+
)
|
|
231
|
+
if (res.ok) {
|
|
232
|
+
const body = (await res.json()) as SessionHeadResponse
|
|
233
|
+
if (typeof body.size === 'number') {
|
|
234
|
+
const known = knownHeadRef.current
|
|
235
|
+
if (body.file !== known.file || body.size !== known.size) {
|
|
236
|
+
setStaleBanner(true)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
} catch {
|
|
241
|
+
// transient — try again next tick
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
timer = setTimeout(tick, SESSION_POLL_MS)
|
|
245
|
+
}
|
|
246
|
+
timer = setTimeout(tick, SESSION_POLL_MS)
|
|
247
|
+
return () => {
|
|
248
|
+
stopped = true
|
|
249
|
+
if (timer) clearTimeout(timer)
|
|
250
|
+
}
|
|
251
|
+
}, [agentId, initialSessionHead, staleBanner])
|
|
252
|
+
|
|
253
|
+
async function refreshKnownHead() {
|
|
254
|
+
try {
|
|
255
|
+
const res = await fetch(`/api/agents/${encodeURIComponent(agentId)}/sessions/head`)
|
|
256
|
+
if (!res.ok) return
|
|
257
|
+
const body = (await res.json()) as SessionHeadResponse
|
|
258
|
+
if (typeof body.size === 'number') {
|
|
259
|
+
knownHeadRef.current = { file: body.file ?? null, size: body.size }
|
|
260
|
+
}
|
|
261
|
+
} catch {
|
|
262
|
+
// swallow
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// --- system bubble helper (slash command output) ---
|
|
267
|
+
function pushSystem(text: string) {
|
|
268
|
+
captureScroll()
|
|
269
|
+
setSystemBubbles((prev) => [
|
|
270
|
+
...prev,
|
|
271
|
+
{ id: systemIdRef.current++, content: text, afterIdx: visibleEntryCountRef.current },
|
|
272
|
+
])
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// --- slash commands ---
|
|
276
|
+
async function runContextCommand() {
|
|
277
|
+
try {
|
|
278
|
+
const res = await fetch(`/api/agents/${encodeURIComponent(agentId)}/chat/context`)
|
|
279
|
+
if (!res.ok) {
|
|
280
|
+
pushSystem(`/context failed: ${res.statusText}`)
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
const ctx = (await res.json()) as ChatContextResponse
|
|
284
|
+
const fmt = (chars: number, tokens: number) =>
|
|
285
|
+
`${chars.toLocaleString()} chars (~${tokens.toLocaleString()} tok)`
|
|
286
|
+
const lines: string[] = []
|
|
287
|
+
lines.push(`context for ${ctx.agentId}`)
|
|
288
|
+
lines.push(`model: ${ctx.model}`)
|
|
289
|
+
lines.push('')
|
|
290
|
+
lines.push(`system prompt: ${fmt(ctx.systemPrompt.chars, ctx.systemPrompt.tokens)}`)
|
|
291
|
+
for (const f of ctx.systemPrompt.files) {
|
|
292
|
+
lines.push(` - ${f.name}: ${f.chars.toLocaleString()} chars`)
|
|
293
|
+
}
|
|
294
|
+
lines.push('')
|
|
295
|
+
lines.push(
|
|
296
|
+
`tools: ${ctx.tools.count} (${ctx.tools.schemaChars.toLocaleString()} chars of schema)`,
|
|
297
|
+
)
|
|
298
|
+
for (const t of ctx.tools.entries.slice(0, 5)) {
|
|
299
|
+
lines.push(` - ${t.name}: ${t.schemaChars.toLocaleString()} chars`)
|
|
300
|
+
}
|
|
301
|
+
if (ctx.skills.count > 0) {
|
|
302
|
+
lines.push('')
|
|
303
|
+
lines.push(`skills: ${ctx.skills.count}`)
|
|
304
|
+
for (const s of ctx.skills.entries.slice(0, 10)) {
|
|
305
|
+
lines.push(` - ${s.name}: ${s.blockChars.toLocaleString()} chars`)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
lines.push('')
|
|
309
|
+
lines.push(
|
|
310
|
+
`history: ${ctx.history.messageEntries} messages / ${ctx.history.compactionEntries} compactions`,
|
|
311
|
+
)
|
|
312
|
+
lines.push(` ${fmt(ctx.history.chars, ctx.history.tokensEstimate)}`)
|
|
313
|
+
lines.push(` bytes on disk: ${formatBytes(ctx.history.bytes)}`)
|
|
314
|
+
lines.push('')
|
|
315
|
+
lines.push(`TOTAL: ${fmt(ctx.totals.chars, ctx.totals.tokens)}`)
|
|
316
|
+
pushSystem(lines.join('\n'))
|
|
317
|
+
} catch (err) {
|
|
318
|
+
pushSystem(`/context failed: ${(err as Error).message}`)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function runResetCommand() {
|
|
323
|
+
if (serverMessages.length === 0) {
|
|
324
|
+
pushSystem('/reset: history already empty')
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
if (!confirm('reset chat history for this agent?')) return
|
|
328
|
+
exitEditMode()
|
|
329
|
+
try {
|
|
330
|
+
const res = await fetch(`/api/agents/${encodeURIComponent(agentId)}/chat/reset`, {
|
|
331
|
+
method: 'POST',
|
|
332
|
+
})
|
|
333
|
+
if (!res.ok) {
|
|
334
|
+
let msg = res.statusText
|
|
335
|
+
try {
|
|
336
|
+
msg = ((await res.json()) as { error?: string }).error || msg
|
|
337
|
+
} catch {}
|
|
338
|
+
pushSystem(`/reset failed: ${msg}`)
|
|
339
|
+
return
|
|
340
|
+
}
|
|
341
|
+
setServerMessages([])
|
|
342
|
+
setLiveEntries([])
|
|
343
|
+
// Drop prior slash-command bubbles whose anchors point into the wiped
|
|
344
|
+
// history — they'd otherwise render at the trailing end with stale context.
|
|
345
|
+
setSystemBubbles([])
|
|
346
|
+
pushSystem('/reset: history wiped')
|
|
347
|
+
} catch (err) {
|
|
348
|
+
pushSystem(`/reset failed: ${(err as Error).message}`)
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function runCompactCommand(rest: string) {
|
|
353
|
+
if (serverMessages.length < 2) {
|
|
354
|
+
pushSystem('/compact: need ≥2 messages to compact')
|
|
355
|
+
return
|
|
356
|
+
}
|
|
357
|
+
let keepTail: number | undefined
|
|
358
|
+
const argText = rest.trim()
|
|
359
|
+
if (argText) {
|
|
360
|
+
const n = Number(argText)
|
|
361
|
+
if (Number.isFinite(n) && n >= 0) keepTail = Math.floor(n)
|
|
362
|
+
}
|
|
363
|
+
exitEditMode()
|
|
364
|
+
setStreaming(true)
|
|
365
|
+
setThinking(true)
|
|
366
|
+
try {
|
|
367
|
+
const res = await fetch(`/api/agents/${encodeURIComponent(agentId)}/chat/compact`, {
|
|
368
|
+
method: 'POST',
|
|
369
|
+
headers: { 'content-type': 'application/json' },
|
|
370
|
+
body: JSON.stringify(keepTail !== undefined ? { keepTail } : {}),
|
|
371
|
+
})
|
|
372
|
+
if (!res.ok) {
|
|
373
|
+
let msg = res.statusText
|
|
374
|
+
try {
|
|
375
|
+
msg = ((await res.json()) as { error?: string }).error || msg
|
|
376
|
+
} catch {}
|
|
377
|
+
pushSystem(`/compact failed: ${msg}`)
|
|
378
|
+
return
|
|
379
|
+
}
|
|
380
|
+
const body = (await res.json()) as ChatCompactResponse
|
|
381
|
+
pushSystem(
|
|
382
|
+
`/compact: ${body.before} → ${body.after} entries (${body.summarized} summarized, ${body.keptTail} kept verbatim; ~${body.tokensBefore.toLocaleString()} → ~${body.tokensAfter.toLocaleString()} tok). reloading…`,
|
|
383
|
+
)
|
|
384
|
+
setTimeout(() => window.location.reload(), 400)
|
|
385
|
+
} catch (err) {
|
|
386
|
+
pushSystem(`/compact failed: ${(err as Error).message}`)
|
|
387
|
+
} finally {
|
|
388
|
+
setThinking(false)
|
|
389
|
+
setStreaming(false)
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function maybeHandleSlashCommand(text: string): Promise<boolean> {
|
|
394
|
+
const trimmed = text.trim()
|
|
395
|
+
if (!trimmed.startsWith('/')) return false
|
|
396
|
+
const parts = trimmed.split(/\s+/)
|
|
397
|
+
const cmd = (parts[0] ?? '').toLowerCase()
|
|
398
|
+
const rest = parts.slice(1).join(' ')
|
|
399
|
+
switch (cmd) {
|
|
400
|
+
case '/help':
|
|
401
|
+
pushSystem(SLASH_HELP)
|
|
402
|
+
return true
|
|
403
|
+
case '/context':
|
|
404
|
+
await runContextCommand()
|
|
405
|
+
return true
|
|
406
|
+
case '/reset':
|
|
407
|
+
await runResetCommand()
|
|
408
|
+
return true
|
|
409
|
+
case '/compact':
|
|
410
|
+
await runCompactCommand(rest)
|
|
411
|
+
return true
|
|
412
|
+
default:
|
|
413
|
+
return false
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// --- edit-last-message ---
|
|
418
|
+
function findLastUserIdx(): number {
|
|
419
|
+
for (let i = serverMessages.length - 1; i >= 0; i--) {
|
|
420
|
+
if (serverMessages[i]?.role === 'user') return i
|
|
421
|
+
}
|
|
422
|
+
return -1
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function enterEditMode() {
|
|
426
|
+
if (liveEntries.length > 0 || streaming) return
|
|
427
|
+
const idx = findLastUserIdx()
|
|
428
|
+
if (idx === -1) return
|
|
429
|
+
const userMsg = serverMessages[idx]
|
|
430
|
+
if (!userMsg) return
|
|
431
|
+
setEditIdx(idx)
|
|
432
|
+
setInput(userMsg.content)
|
|
433
|
+
inputRef.current?.focus()
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function exitEditMode() {
|
|
437
|
+
setEditIdx(null)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// --- send ---
|
|
441
|
+
const send = useCallback(
|
|
442
|
+
async (text: string) => {
|
|
443
|
+
if (!text.trim() || streaming) return
|
|
444
|
+
setInput('')
|
|
445
|
+
|
|
446
|
+
// Slash commands shortcut.
|
|
447
|
+
if (await maybeHandleSlashCommand(text)) return
|
|
448
|
+
|
|
449
|
+
// Edit-mode truncate.
|
|
450
|
+
let truncatedServerMessages: ProviderMessage[] | null = null
|
|
451
|
+
if (editIdx !== null) {
|
|
452
|
+
const keep = editIdx
|
|
453
|
+
try {
|
|
454
|
+
const res = await fetch(
|
|
455
|
+
`/api/agents/${encodeURIComponent(agentId)}/chat/truncate`,
|
|
456
|
+
{
|
|
457
|
+
method: 'POST',
|
|
458
|
+
headers: { 'content-type': 'application/json' },
|
|
459
|
+
body: JSON.stringify({ keepCount: keep }),
|
|
460
|
+
},
|
|
461
|
+
)
|
|
462
|
+
if (!res.ok) {
|
|
463
|
+
let err = res.statusText
|
|
464
|
+
try {
|
|
465
|
+
err = ((await res.json()) as { error?: string }).error || err
|
|
466
|
+
} catch {}
|
|
467
|
+
setInput(text)
|
|
468
|
+
alert(`failed to replace last turn: ${err}`)
|
|
469
|
+
return
|
|
470
|
+
}
|
|
471
|
+
truncatedServerMessages = serverMessages.slice(0, keep)
|
|
472
|
+
setServerMessages(truncatedServerMessages)
|
|
473
|
+
setEditIdx(null)
|
|
474
|
+
} catch (err) {
|
|
475
|
+
setInput(text)
|
|
476
|
+
alert(`failed to replace last turn: ${(err as Error).message}`)
|
|
477
|
+
return
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
captureScroll()
|
|
482
|
+
setLiveEntries([{ type: 'user', content: text }])
|
|
483
|
+
sessionStorage.setItem(`bz_pending_${agentId}`, '1')
|
|
484
|
+
const abort = new AbortController()
|
|
485
|
+
currentAbortRef.current = abort
|
|
486
|
+
setStreaming(true)
|
|
487
|
+
setThinking(true)
|
|
488
|
+
|
|
489
|
+
try {
|
|
490
|
+
const res = await fetch(`/api/agents/${encodeURIComponent(agentId)}/chat`, {
|
|
491
|
+
method: 'POST',
|
|
492
|
+
headers: { 'content-type': 'application/json' },
|
|
493
|
+
body: JSON.stringify({ message: text }),
|
|
494
|
+
signal: abort.signal,
|
|
495
|
+
})
|
|
496
|
+
if (!res.ok || !res.body) {
|
|
497
|
+
let err = res.statusText
|
|
498
|
+
try {
|
|
499
|
+
err = ((await res.json()) as { error?: string }).error || err
|
|
500
|
+
} catch {}
|
|
501
|
+
setLiveEntries((prev) => [...prev, { type: 'error', content: `[error] ${err}` }])
|
|
502
|
+
return
|
|
503
|
+
}
|
|
504
|
+
const reader = res.body.getReader()
|
|
505
|
+
const decoder = new TextDecoder()
|
|
506
|
+
let buffer = ''
|
|
507
|
+
while (true) {
|
|
508
|
+
const { done, value } = await reader.read()
|
|
509
|
+
if (done) break
|
|
510
|
+
buffer += decoder.decode(value, { stream: true })
|
|
511
|
+
const lines = buffer.split('\n')
|
|
512
|
+
buffer = lines.pop() ?? ''
|
|
513
|
+
for (const line of lines) {
|
|
514
|
+
if (!line.trim()) continue
|
|
515
|
+
let frame: ChatFrame
|
|
516
|
+
try {
|
|
517
|
+
frame = JSON.parse(line) as ChatFrame
|
|
518
|
+
} catch {
|
|
519
|
+
continue
|
|
520
|
+
}
|
|
521
|
+
handleFrame(frame)
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
if (buffer.trim()) {
|
|
525
|
+
try {
|
|
526
|
+
handleFrame(JSON.parse(buffer) as ChatFrame)
|
|
527
|
+
} catch {}
|
|
528
|
+
}
|
|
529
|
+
await refreshKnownHead()
|
|
530
|
+
sessionStorage.removeItem(`bz_pending_${agentId}`)
|
|
531
|
+
} catch (err) {
|
|
532
|
+
const name = (err as Error).name
|
|
533
|
+
if (name !== 'AbortError') {
|
|
534
|
+
setLiveEntries((prev) => [
|
|
535
|
+
...prev,
|
|
536
|
+
{ type: 'error', content: `[network error] ${(err as Error).message}` },
|
|
537
|
+
])
|
|
538
|
+
}
|
|
539
|
+
sessionStorage.removeItem(`bz_pending_${agentId}`)
|
|
540
|
+
} finally {
|
|
541
|
+
setThinking(false)
|
|
542
|
+
setStreaming(false)
|
|
543
|
+
currentAbortRef.current = null
|
|
544
|
+
}
|
|
545
|
+
},
|
|
546
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: stable refs intentional
|
|
547
|
+
[agentId, editIdx, serverMessages, streaming],
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
function handleFrame(frame: ChatFrame) {
|
|
551
|
+
if (frame.kind === 'fatal') {
|
|
552
|
+
setLiveEntries((prev) => [
|
|
553
|
+
...prev,
|
|
554
|
+
{ type: 'error', content: `[fatal] ${frame.error}` },
|
|
555
|
+
])
|
|
556
|
+
return
|
|
557
|
+
}
|
|
558
|
+
if (frame.kind === 'done') {
|
|
559
|
+
setServerMessages(frame.messages)
|
|
560
|
+
setLiveEntries([])
|
|
561
|
+
return
|
|
562
|
+
}
|
|
563
|
+
if (frame.kind !== 'event') return
|
|
564
|
+
const ev = frame.event
|
|
565
|
+
captureScroll()
|
|
566
|
+
if (ev.type === 'user_message') return
|
|
567
|
+
if (ev.type === 'assistant_delta') {
|
|
568
|
+
setThinking(false)
|
|
569
|
+
setLiveEntries((prev) => {
|
|
570
|
+
const next = [...prev]
|
|
571
|
+
const last = next[next.length - 1]
|
|
572
|
+
if (last?.type === 'assistant') {
|
|
573
|
+
next[next.length - 1] = { type: 'assistant', content: last.content + ev.delta }
|
|
574
|
+
} else {
|
|
575
|
+
next.push({ type: 'assistant', content: ev.delta })
|
|
576
|
+
}
|
|
577
|
+
return next
|
|
578
|
+
})
|
|
579
|
+
return
|
|
580
|
+
}
|
|
581
|
+
if (ev.type === 'assistant_message') {
|
|
582
|
+
setThinking(false)
|
|
583
|
+
setLiveEntries((prev) => {
|
|
584
|
+
const next = [...prev]
|
|
585
|
+
const last = next[next.length - 1]
|
|
586
|
+
if (last?.type === 'assistant') {
|
|
587
|
+
next[next.length - 1] = { type: 'assistant', content: ev.text }
|
|
588
|
+
} else {
|
|
589
|
+
next.push({ type: 'assistant', content: ev.text })
|
|
590
|
+
}
|
|
591
|
+
return next
|
|
592
|
+
})
|
|
593
|
+
return
|
|
594
|
+
}
|
|
595
|
+
if (ev.type === 'tool_call' || ev.type === 'tool_result' || ev.type === 'tool_error') {
|
|
596
|
+
setThinking(ev.type !== 'tool_call')
|
|
597
|
+
const item: ToolItem =
|
|
598
|
+
ev.type === 'tool_call'
|
|
599
|
+
? { kind: 'call', id: ev.id, name: ev.name, body: ev.arguments }
|
|
600
|
+
: ev.type === 'tool_result'
|
|
601
|
+
? { kind: 'result', id: ev.id, name: ev.name, body: ev.result }
|
|
602
|
+
: { kind: 'error', id: ev.id, name: ev.name, body: ev.error }
|
|
603
|
+
setLiveEntries((prev) => {
|
|
604
|
+
const next = [...prev]
|
|
605
|
+
const last = next[next.length - 1]
|
|
606
|
+
if (last?.type === 'tool') {
|
|
607
|
+
next[next.length - 1] = { type: 'tool', items: [...last.items, item] }
|
|
608
|
+
} else {
|
|
609
|
+
next.push({ type: 'tool', items: [item] })
|
|
610
|
+
}
|
|
611
|
+
return next
|
|
612
|
+
})
|
|
613
|
+
return
|
|
614
|
+
}
|
|
615
|
+
if (ev.type === 'error') {
|
|
616
|
+
setLiveEntries((prev) => [...prev, { type: 'error', content: `[error] ${ev.error}` }])
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
async function cancel() {
|
|
621
|
+
try {
|
|
622
|
+
const res = await fetch(`/api/agents/${encodeURIComponent(agentId)}/cancel`, {
|
|
623
|
+
method: 'POST',
|
|
624
|
+
})
|
|
625
|
+
if (res.ok || res.status === 204) return
|
|
626
|
+
} catch {
|
|
627
|
+
// fall through to local fetch abort
|
|
628
|
+
}
|
|
629
|
+
currentAbortRef.current?.abort()
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
|
633
|
+
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
|
|
634
|
+
e.preventDefault()
|
|
635
|
+
void send(input)
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// --- render projection ---
|
|
640
|
+
const baseEntries = projectMessages(serverMessages)
|
|
641
|
+
// Update the anchor reference so the next pushSystem() captures the current
|
|
642
|
+
// count. Writing to a ref during render is supported by React.
|
|
643
|
+
visibleEntryCountRef.current = baseEntries.length + liveEntries.length
|
|
644
|
+
const lastUserIdx = (() => {
|
|
645
|
+
if (liveEntries.length > 0 || streaming) return -1
|
|
646
|
+
for (let i = baseEntries.length - 1; i >= 0; i--) {
|
|
647
|
+
if (baseEntries[i]?.type === 'user') return i
|
|
648
|
+
}
|
|
649
|
+
return -1
|
|
650
|
+
})()
|
|
651
|
+
// willDropFromIdx: when in edit mode, every entry from the last user msg
|
|
652
|
+
// onward will be dropped on submit. Compute the entry index of the
|
|
653
|
+
// serverMessages[editIdx] user message.
|
|
654
|
+
const willDropFromIdx = (() => {
|
|
655
|
+
if (editIdx === null) return -1
|
|
656
|
+
let userCount = 0
|
|
657
|
+
for (let i = 0; i < baseEntries.length; i++) {
|
|
658
|
+
if (baseEntries[i]?.type === 'user') {
|
|
659
|
+
if (userCount === serverMessages.slice(0, editIdx + 1).filter((m) => m.role === 'user').length - 1) {
|
|
660
|
+
return i
|
|
661
|
+
}
|
|
662
|
+
userCount++
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
// Fallback: last user
|
|
666
|
+
return lastUserIdx
|
|
667
|
+
})()
|
|
668
|
+
|
|
669
|
+
return (
|
|
670
|
+
<div className="flex h-full flex-col overflow-hidden rounded-[16px] border border-frost bg-snow shadow-baziu-sm">
|
|
671
|
+
<div className="flex items-baseline justify-between border-b border-frost px-4 py-2.5">
|
|
672
|
+
<h1 className="font-display text-[1.2rem] text-charcoal">{agentName}</h1>
|
|
673
|
+
<a
|
|
674
|
+
href={`/agents/${agentId}`}
|
|
675
|
+
className="text-xs text-mocha-light hover:text-sapphire"
|
|
676
|
+
>
|
|
677
|
+
settings →
|
|
678
|
+
</a>
|
|
679
|
+
</div>
|
|
680
|
+
|
|
681
|
+
{staleBanner && (
|
|
682
|
+
<div className="mx-5 mt-2 flex items-center gap-2 rounded-md border border-sapphire bg-frost px-4 py-2 text-[0.86em] text-mocha">
|
|
683
|
+
<span
|
|
684
|
+
className="h-2 w-2 flex-none rounded-full bg-sapphire"
|
|
685
|
+
aria-hidden="true"
|
|
686
|
+
/>
|
|
687
|
+
<span className="flex-1">new activity from another source — reload to see it</span>
|
|
688
|
+
<button
|
|
689
|
+
type="button"
|
|
690
|
+
onClick={() => window.location.reload()}
|
|
691
|
+
className="rounded-sm bg-sapphire px-3 py-1 text-[0.92em] text-snow hover:opacity-90"
|
|
692
|
+
>
|
|
693
|
+
reload
|
|
694
|
+
</button>
|
|
695
|
+
<button
|
|
696
|
+
type="button"
|
|
697
|
+
onClick={() => setStaleBanner(false)}
|
|
698
|
+
className="px-1 text-mocha-light hover:text-mocha"
|
|
699
|
+
aria-label="dismiss"
|
|
700
|
+
>
|
|
701
|
+
×
|
|
702
|
+
</button>
|
|
703
|
+
</div>
|
|
704
|
+
)}
|
|
705
|
+
|
|
706
|
+
<div
|
|
707
|
+
ref={messagesRef}
|
|
708
|
+
onScroll={captureScroll}
|
|
709
|
+
className={`min-h-[240px] flex-1 overflow-y-auto px-5 py-5 ${editIdx !== null ? 'is-editing' : ''}`}
|
|
710
|
+
>
|
|
711
|
+
{baseEntries.length === 0 && liveEntries.length === 0 && systemBubbles.length === 0 && (
|
|
712
|
+
<p className="py-12 text-center italic text-fawn">start a conversation…</p>
|
|
713
|
+
)}
|
|
714
|
+
{(() => {
|
|
715
|
+
const out: ReactNode[] = []
|
|
716
|
+
let sysIdx = 0
|
|
717
|
+
const flushSysUpTo = (idx: number) => {
|
|
718
|
+
while (
|
|
719
|
+
sysIdx < systemBubbles.length &&
|
|
720
|
+
(systemBubbles[sysIdx] as { afterIdx: number }).afterIdx <= idx
|
|
721
|
+
) {
|
|
722
|
+
const sb = systemBubbles[sysIdx] as {
|
|
723
|
+
id: number
|
|
724
|
+
content: string
|
|
725
|
+
afterIdx: number
|
|
726
|
+
}
|
|
727
|
+
out.push(
|
|
728
|
+
<Bubble
|
|
729
|
+
key={`y-${sb.id}`}
|
|
730
|
+
entry={{ type: 'system', content: sb.content }}
|
|
731
|
+
/>,
|
|
732
|
+
)
|
|
733
|
+
sysIdx++
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
flushSysUpTo(0)
|
|
737
|
+
for (let i = 0; i < baseEntries.length; i++) {
|
|
738
|
+
const entry = baseEntries[i] as RenderEntry
|
|
739
|
+
out.push(
|
|
740
|
+
<Bubble
|
|
741
|
+
key={`s-${i}`}
|
|
742
|
+
entry={entry}
|
|
743
|
+
isLastUser={i === lastUserIdx && editIdx === null}
|
|
744
|
+
isWillDrop={willDropFromIdx !== -1 && i >= willDropFromIdx}
|
|
745
|
+
onEdit={enterEditMode}
|
|
746
|
+
/>,
|
|
747
|
+
)
|
|
748
|
+
flushSysUpTo(i + 1)
|
|
749
|
+
}
|
|
750
|
+
for (let i = 0; i < liveEntries.length; i++) {
|
|
751
|
+
const entry = liveEntries[i] as RenderEntry
|
|
752
|
+
out.push(<Bubble key={`l-${i}`} entry={entry} />)
|
|
753
|
+
flushSysUpTo(baseEntries.length + i + 1)
|
|
754
|
+
}
|
|
755
|
+
// Anchors past the current real-entry count (e.g. after edit-mode
|
|
756
|
+
// submit drops the tail) render at the end — better than vanishing.
|
|
757
|
+
while (sysIdx < systemBubbles.length) {
|
|
758
|
+
const sb = systemBubbles[sysIdx] as {
|
|
759
|
+
id: number
|
|
760
|
+
content: string
|
|
761
|
+
afterIdx: number
|
|
762
|
+
}
|
|
763
|
+
out.push(
|
|
764
|
+
<Bubble
|
|
765
|
+
key={`y-${sb.id}`}
|
|
766
|
+
entry={{ type: 'system', content: sb.content }}
|
|
767
|
+
/>,
|
|
768
|
+
)
|
|
769
|
+
sysIdx++
|
|
770
|
+
}
|
|
771
|
+
return out
|
|
772
|
+
})()}
|
|
773
|
+
{thinking && (
|
|
774
|
+
<div className="flex items-center gap-2 px-1 py-1 text-[0.85em] text-mocha-light">
|
|
775
|
+
<Dot />
|
|
776
|
+
<Dot delay="0.15s" />
|
|
777
|
+
<Dot delay="0.3s" />
|
|
778
|
+
<span>agent is thinking…</span>
|
|
779
|
+
</div>
|
|
780
|
+
)}
|
|
781
|
+
</div>
|
|
782
|
+
|
|
783
|
+
{editIdx !== null && (
|
|
784
|
+
<div className="flex items-center gap-2 border-t border-frost bg-sapphire-glow px-5 py-2 text-[0.85em] text-sapphire-deep">
|
|
785
|
+
<span>editing last message — submit to replace, or</span>
|
|
786
|
+
<button
|
|
787
|
+
type="button"
|
|
788
|
+
onClick={exitEditMode}
|
|
789
|
+
className="ml-auto rounded-sm border border-sapphire-light bg-transparent px-2 py-0.5 text-[0.92em] text-sapphire-deep hover:bg-snow"
|
|
790
|
+
>
|
|
791
|
+
cancel
|
|
792
|
+
</button>
|
|
793
|
+
</div>
|
|
794
|
+
)}
|
|
795
|
+
|
|
796
|
+
<form
|
|
797
|
+
className="flex items-end gap-2 border-t border-frost bg-ivory px-5 py-3"
|
|
798
|
+
onSubmit={(e) => {
|
|
799
|
+
e.preventDefault()
|
|
800
|
+
void send(input)
|
|
801
|
+
}}
|
|
802
|
+
>
|
|
803
|
+
<textarea
|
|
804
|
+
ref={inputRef}
|
|
805
|
+
rows={1}
|
|
806
|
+
value={input}
|
|
807
|
+
onChange={(e) => setInput(e.target.value)}
|
|
808
|
+
onKeyDown={onKeyDown}
|
|
809
|
+
disabled={streaming}
|
|
810
|
+
placeholder="say something… (Shift+Enter for newline; try /context, /compact, /reset)"
|
|
811
|
+
autoComplete="off"
|
|
812
|
+
className="max-h-[200px] min-h-[2.4rem] flex-1 resize-none overflow-y-auto rounded-md border-[1.5px] border-frost bg-snow px-3 py-2 text-[0.93em] leading-[1.45] text-chocolate outline-none transition-colors focus:border-sapphire focus:shadow-[0_0_0_3px_var(--color-sapphire-glow)]"
|
|
813
|
+
/>
|
|
814
|
+
<button
|
|
815
|
+
type="submit"
|
|
816
|
+
disabled={streaming || !input.trim()}
|
|
817
|
+
className="rounded-md bg-sapphire px-4 py-2 text-[0.92em] font-semibold text-snow transition-colors hover:bg-sapphire-deep disabled:cursor-not-allowed disabled:opacity-50"
|
|
818
|
+
>
|
|
819
|
+
send
|
|
820
|
+
</button>
|
|
821
|
+
{streaming && (
|
|
822
|
+
<button
|
|
823
|
+
type="button"
|
|
824
|
+
onClick={cancel}
|
|
825
|
+
className="rounded-md border-[1.5px] border-frost bg-transparent px-3 py-1.5 text-[0.92em] font-medium text-mocha hover:border-sapphire-light hover:bg-sapphire-glow hover:text-sapphire"
|
|
826
|
+
>
|
|
827
|
+
cancel
|
|
828
|
+
</button>
|
|
829
|
+
)}
|
|
830
|
+
</form>
|
|
831
|
+
</div>
|
|
832
|
+
)
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
interface BubbleProps {
|
|
836
|
+
entry: RenderEntry
|
|
837
|
+
isLastUser?: boolean
|
|
838
|
+
isWillDrop?: boolean
|
|
839
|
+
onEdit?: () => void
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function Bubble({ entry, isLastUser, isWillDrop, onEdit }: BubbleProps) {
|
|
843
|
+
const dropCls = isWillDrop ? 'opacity-40 [&_.bubble-content]:line-through' : ''
|
|
844
|
+
if (entry.type === 'user') {
|
|
845
|
+
if (entry.content.startsWith(INBOX_WAKE_PREFIX)) {
|
|
846
|
+
const body = entry.content.slice(INBOX_WAKE_PREFIX.length)
|
|
847
|
+
return (
|
|
848
|
+
<div className={`my-4 flex flex-col items-end ${dropCls}`}>
|
|
849
|
+
<span className="mb-1 text-[0.72em] font-semibold uppercase tracking-wider text-mocha opacity-90">
|
|
850
|
+
inbox
|
|
851
|
+
</span>
|
|
852
|
+
<div className="bubble-content max-w-[85%] whitespace-pre-wrap break-words rounded-[14px_14px_4px_14px] border border-fawn border-l-[3px] border-l-mocha-light bg-ivory px-3 py-2 font-mono text-[0.88em] leading-[1.5] text-mocha">
|
|
853
|
+
{body}
|
|
854
|
+
</div>
|
|
855
|
+
</div>
|
|
856
|
+
)
|
|
857
|
+
}
|
|
858
|
+
return (
|
|
859
|
+
<div className={`group relative my-4 flex flex-col items-end ${dropCls}`}>
|
|
860
|
+
<span className="sr-only">you</span>
|
|
861
|
+
<div className="bubble-content max-w-[85%] whitespace-pre-wrap break-words rounded-[14px_14px_4px_14px] border border-sapphire-light bg-sapphire-glow px-3 py-2 text-chocolate">
|
|
862
|
+
{entry.content}
|
|
863
|
+
</div>
|
|
864
|
+
{isLastUser && onEdit && (
|
|
865
|
+
<button
|
|
866
|
+
type="button"
|
|
867
|
+
onClick={onEdit}
|
|
868
|
+
title="edit and resend — replaces the last turn"
|
|
869
|
+
className="absolute left-1 top-1 rounded-sm border border-fawn bg-ivory px-2 py-0.5 text-[0.78em] font-medium text-mocha opacity-65 transition hover:border-sapphire hover:bg-sapphire-glow hover:text-sapphire hover:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100"
|
|
870
|
+
>
|
|
871
|
+
edit
|
|
872
|
+
</button>
|
|
873
|
+
)}
|
|
874
|
+
</div>
|
|
875
|
+
)
|
|
876
|
+
}
|
|
877
|
+
if (entry.type === 'assistant') {
|
|
878
|
+
if (entry.content.startsWith(COMPACTION_REPLAY_PREFIX)) {
|
|
879
|
+
const summary = entry.content.slice(COMPACTION_REPLAY_PREFIX.length).trim()
|
|
880
|
+
return (
|
|
881
|
+
<div className={`my-3 border-y border-dashed border-frost py-2 text-[0.85em] italic text-mocha-light ${dropCls}`}>
|
|
882
|
+
<details>
|
|
883
|
+
<summary className="cursor-pointer select-none text-center [&::-webkit-details-marker]:hidden">
|
|
884
|
+
— conversation summary (click to expand) —
|
|
885
|
+
</summary>
|
|
886
|
+
<div
|
|
887
|
+
className="md-content mt-2 rounded-sm bg-frost/40 p-2 not-italic"
|
|
888
|
+
// biome-ignore lint/security/noDangerouslySetInnerHtml: marked + DOMPurify
|
|
889
|
+
dangerouslySetInnerHTML={{ __html: renderMd(summary) }}
|
|
890
|
+
/>
|
|
891
|
+
</details>
|
|
892
|
+
</div>
|
|
893
|
+
)
|
|
894
|
+
}
|
|
895
|
+
const isErr =
|
|
896
|
+
entry.content.startsWith('[error]') ||
|
|
897
|
+
entry.content.startsWith('[fatal]') ||
|
|
898
|
+
entry.content.startsWith('[network error]')
|
|
899
|
+
if (isErr) {
|
|
900
|
+
return (
|
|
901
|
+
<div className={`my-4 flex flex-col items-start ${dropCls}`}>
|
|
902
|
+
<span className="mb-1 text-[0.72em] font-semibold uppercase tracking-wider text-[#9B3D3D]">
|
|
903
|
+
error
|
|
904
|
+
</span>
|
|
905
|
+
<div
|
|
906
|
+
className="bubble-content rounded-r-sm border-l-[3px] border-l-[#9B3D3D] bg-[rgba(196,135,138,0.06)] py-1 pl-3 pr-2 leading-[1.55]"
|
|
907
|
+
// biome-ignore lint/security/noDangerouslySetInnerHtml: marked + DOMPurify
|
|
908
|
+
dangerouslySetInnerHTML={{ __html: renderMd(entry.content) }}
|
|
909
|
+
/>
|
|
910
|
+
</div>
|
|
911
|
+
)
|
|
912
|
+
}
|
|
913
|
+
return (
|
|
914
|
+
<div className={`my-4 flex flex-col items-start ${dropCls}`}>
|
|
915
|
+
<span className="sr-only">agent</span>
|
|
916
|
+
<div
|
|
917
|
+
className="md-content bubble-content w-full leading-[1.55] text-chocolate"
|
|
918
|
+
// biome-ignore lint/security/noDangerouslySetInnerHtml: marked + DOMPurify
|
|
919
|
+
dangerouslySetInnerHTML={{ __html: renderMd(entry.content) }}
|
|
920
|
+
/>
|
|
921
|
+
</div>
|
|
922
|
+
)
|
|
923
|
+
}
|
|
924
|
+
if (entry.type === 'tool') {
|
|
925
|
+
return <ToolGroup items={entry.items} dropCls={dropCls} />
|
|
926
|
+
}
|
|
927
|
+
if (entry.type === 'system') {
|
|
928
|
+
return (
|
|
929
|
+
<div className={`my-3 rounded-r-sm border-l-[3px] border-sapphire bg-sapphire-glow px-3 py-1 font-mono text-[0.88em] text-sapphire-deep ${dropCls}`}>
|
|
930
|
+
<span className="mr-2 font-semibold uppercase tracking-wider opacity-80">system</span>
|
|
931
|
+
<span className="whitespace-pre-wrap">{entry.content}</span>
|
|
932
|
+
</div>
|
|
933
|
+
)
|
|
934
|
+
}
|
|
935
|
+
if (entry.type === 'error') {
|
|
936
|
+
return (
|
|
937
|
+
<div className={`my-3 rounded-r-sm border-l-[3px] border-[#9B3D3D] bg-[rgba(196,135,138,0.08)] px-3 py-1 text-[0.92em] text-[#9B3D3D] ${dropCls}`}>
|
|
938
|
+
{entry.content}
|
|
939
|
+
</div>
|
|
940
|
+
)
|
|
941
|
+
}
|
|
942
|
+
return null
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function ToolGroup({ items, dropCls }: { items: ToolItem[]; dropCls: string }) {
|
|
946
|
+
const [expanded, setExpanded] = useState(false)
|
|
947
|
+
const contentRef = useRef<HTMLDivElement | null>(null)
|
|
948
|
+
const [overflows, setOverflows] = useState(false)
|
|
949
|
+
useLayoutEffect(() => {
|
|
950
|
+
const el = contentRef.current
|
|
951
|
+
if (!el) return
|
|
952
|
+
setOverflows(el.scrollHeight > TOOL_GROUP_MAX_HEIGHT_PX + 4)
|
|
953
|
+
}, [items.length])
|
|
954
|
+
|
|
955
|
+
return (
|
|
956
|
+
<div
|
|
957
|
+
className={`my-1.5 rounded-r-sm border-l-[3px] border-fawn bg-ivory px-3 py-2 font-mono text-[0.82em] leading-[1.5] text-mocha-light ${dropCls}`}
|
|
958
|
+
>
|
|
959
|
+
<div
|
|
960
|
+
ref={contentRef}
|
|
961
|
+
className="relative overflow-hidden"
|
|
962
|
+
style={{ maxHeight: expanded ? 'none' : `${TOOL_GROUP_MAX_HEIGHT_PX}px` }}
|
|
963
|
+
>
|
|
964
|
+
{items.map((it, i) => (
|
|
965
|
+
<ToolLine key={i} item={it} />
|
|
966
|
+
))}
|
|
967
|
+
{overflows && !expanded && (
|
|
968
|
+
<div
|
|
969
|
+
className="pointer-events-none absolute inset-x-0 bottom-0 h-10"
|
|
970
|
+
style={{
|
|
971
|
+
background:
|
|
972
|
+
'linear-gradient(to bottom, rgba(247,240,229,0), var(--color-ivory))',
|
|
973
|
+
}}
|
|
974
|
+
/>
|
|
975
|
+
)}
|
|
976
|
+
</div>
|
|
977
|
+
{overflows && (
|
|
978
|
+
<button
|
|
979
|
+
type="button"
|
|
980
|
+
onClick={() => setExpanded((v) => !v)}
|
|
981
|
+
className="mt-2 rounded-sm border border-fawn bg-transparent px-2 py-0.5 text-[0.9em] text-mocha hover:border-sapphire hover:text-sapphire-deep"
|
|
982
|
+
>
|
|
983
|
+
{expanded ? 'show less ↑' : 'show more ↓'}
|
|
984
|
+
</button>
|
|
985
|
+
)}
|
|
986
|
+
</div>
|
|
987
|
+
)
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function ToolLine({ item }: { item: ToolItem }) {
|
|
991
|
+
if (item.kind === 'call') {
|
|
992
|
+
const args = prettyArgs(item.body)
|
|
993
|
+
const multiLine = args.includes('\n')
|
|
994
|
+
return (
|
|
995
|
+
<div className="whitespace-pre-wrap break-words py-0.5">
|
|
996
|
+
<span className="mr-1 opacity-45">→</span>
|
|
997
|
+
<span className="font-medium text-mocha">{item.name}</span>
|
|
998
|
+
{multiLine ? (
|
|
999
|
+
<pre className="ml-4 mt-0.5 rounded-sm bg-[rgba(42,31,22,0.04)] px-2 py-1 font-mono text-[0.95em] leading-[1.4] text-mocha">
|
|
1000
|
+
{args}
|
|
1001
|
+
</pre>
|
|
1002
|
+
) : args ? (
|
|
1003
|
+
<span> ({args})</span>
|
|
1004
|
+
) : null}
|
|
1005
|
+
</div>
|
|
1006
|
+
)
|
|
1007
|
+
}
|
|
1008
|
+
if (item.kind === 'error') {
|
|
1009
|
+
return (
|
|
1010
|
+
<div className="whitespace-pre-wrap break-words py-0.5 text-rose-baziu">
|
|
1011
|
+
<span className="mr-1 opacity-45">←</span>
|
|
1012
|
+
{item.body}
|
|
1013
|
+
</div>
|
|
1014
|
+
)
|
|
1015
|
+
}
|
|
1016
|
+
return (
|
|
1017
|
+
<div className="whitespace-pre-wrap break-words py-0.5">
|
|
1018
|
+
<span className="mr-1 opacity-45">←</span>
|
|
1019
|
+
{item.body}
|
|
1020
|
+
</div>
|
|
1021
|
+
)
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function Dot({ delay = '0s' }: { delay?: string }) {
|
|
1025
|
+
return (
|
|
1026
|
+
<span
|
|
1027
|
+
className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-mocha-light/60"
|
|
1028
|
+
style={{ animationDelay: delay }}
|
|
1029
|
+
aria-hidden="true"
|
|
1030
|
+
/>
|
|
1031
|
+
)
|
|
1032
|
+
}
|
|
1033
|
+
|