@swarmclawai/swarmclaw 0.3.1 → 0.4.5
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/README.md +33 -13
- package/bin/server-cmd.js +14 -7
- package/bin/swarmclaw.js +3 -1
- package/bin/update-cmd.js +120 -0
- package/next.config.ts +10 -0
- package/package.json +4 -1
- package/src/app/api/agents/[id]/route.ts +20 -18
- package/src/app/api/agents/[id]/thread/route.ts +4 -3
- package/src/app/api/agents/route.ts +8 -3
- package/src/app/api/auth/route.ts +3 -1
- package/src/app/api/claude-skills/route.ts +3 -1
- package/src/app/api/clawhub/install/route.ts +2 -2
- package/src/app/api/connectors/[id]/route.ts +14 -3
- package/src/app/api/connectors/[id]/webhook/route.ts +99 -0
- package/src/app/api/connectors/route.ts +12 -4
- package/src/app/api/credentials/[id]/route.ts +2 -1
- package/src/app/api/credentials/route.ts +5 -3
- package/src/app/api/daemon/route.ts +6 -1
- package/src/app/api/documents/route.ts +2 -2
- package/src/app/api/files/serve/route.ts +8 -0
- package/src/app/api/ip/route.ts +3 -1
- package/src/app/api/knowledge/[id]/route.ts +5 -4
- package/src/app/api/knowledge/upload/route.ts +2 -2
- package/src/app/api/mcp-servers/[id]/route.ts +11 -14
- package/src/app/api/mcp-servers/[id]/test/route.ts +2 -1
- package/src/app/api/mcp-servers/[id]/tools/route.ts +2 -1
- package/src/app/api/mcp-servers/route.ts +5 -3
- package/src/app/api/memory/[id]/route.ts +9 -8
- package/src/app/api/memory/route.ts +2 -2
- package/src/app/api/memory-images/[filename]/route.ts +2 -1
- package/src/app/api/openclaw/directory/route.ts +26 -0
- package/src/app/api/openclaw/discover/route.ts +61 -0
- package/src/app/api/openclaw/sync/route.ts +30 -0
- package/src/app/api/orchestrator/graph/route.ts +25 -0
- package/src/app/api/orchestrator/run/route.ts +2 -2
- package/src/app/api/plugins/marketplace/route.ts +3 -1
- package/src/app/api/plugins/route.ts +3 -1
- package/src/app/api/projects/[id]/route.ts +55 -0
- package/src/app/api/projects/route.ts +27 -0
- package/src/app/api/providers/[id]/models/route.ts +2 -1
- package/src/app/api/providers/[id]/route.ts +13 -12
- package/src/app/api/providers/configs/route.ts +3 -1
- package/src/app/api/providers/route.ts +7 -3
- package/src/app/api/schedules/[id]/route.ts +16 -15
- package/src/app/api/schedules/[id]/run/route.ts +4 -3
- package/src/app/api/schedules/route.ts +8 -3
- package/src/app/api/secrets/[id]/route.ts +16 -17
- package/src/app/api/secrets/route.ts +5 -3
- package/src/app/api/sessions/[id]/chat/route.ts +5 -2
- package/src/app/api/sessions/[id]/clear/route.ts +2 -1
- package/src/app/api/sessions/[id]/deploy/route.ts +2 -1
- package/src/app/api/sessions/[id]/devserver/route.ts +2 -1
- package/src/app/api/sessions/[id]/messages/route.ts +2 -1
- package/src/app/api/sessions/[id]/retry/route.ts +2 -1
- package/src/app/api/sessions/[id]/route.ts +2 -1
- package/src/app/api/sessions/route.ts +11 -4
- package/src/app/api/settings/route.ts +3 -1
- package/src/app/api/setup/doctor/route.ts +1 -0
- package/src/app/api/setup/openclaw-device/route.ts +3 -1
- package/src/app/api/skills/[id]/route.ts +23 -21
- package/src/app/api/skills/import/route.ts +2 -2
- package/src/app/api/skills/route.ts +5 -3
- package/src/app/api/tasks/[id]/approve/route.ts +74 -0
- package/src/app/api/tasks/[id]/route.ts +9 -5
- package/src/app/api/tasks/route.ts +5 -2
- package/src/app/api/tts/stream/route.ts +48 -0
- package/src/app/api/upload/route.ts +2 -2
- package/src/app/api/uploads/[filename]/route.ts +4 -1
- package/src/app/api/usage/route.ts +3 -1
- package/src/app/api/version/route.ts +3 -1
- package/src/app/api/webhooks/[id]/route.ts +31 -32
- package/src/app/api/webhooks/route.ts +5 -3
- package/src/app/icon.svg +58 -0
- package/src/app/page.tsx +11 -26
- package/src/cli/index.js +28 -9
- package/src/cli/index.ts +45 -2
- package/src/cli/spec.js +2 -8
- package/src/components/agents/agent-card.tsx +1 -1
- package/src/components/agents/agent-list.tsx +3 -1
- package/src/components/agents/agent-sheet.tsx +166 -81
- package/src/components/chat/chat-area.tsx +71 -34
- package/src/components/chat/chat-header.tsx +141 -29
- package/src/components/chat/chat-tool-toggles.tsx +12 -53
- package/src/components/chat/message-bubble.tsx +110 -42
- package/src/components/chat/tool-call-bubble.tsx +50 -6
- package/src/components/chat/tool-request-banner.tsx +1 -9
- package/src/components/chat/voice-overlay.tsx +80 -0
- package/src/components/connectors/connector-list.tsx +9 -10
- package/src/components/connectors/connector-sheet.tsx +55 -36
- package/src/components/input/chat-input.tsx +72 -56
- package/src/components/knowledge/knowledge-list.tsx +27 -31
- package/src/components/layout/app-layout.tsx +133 -90
- package/src/components/layout/daemon-indicator.tsx +3 -5
- package/src/components/logs/log-list.tsx +5 -9
- package/src/components/mcp-servers/mcp-server-list.tsx +24 -2
- package/src/components/memory/memory-detail.tsx +1 -1
- package/src/components/plugins/plugin-list.tsx +227 -27
- package/src/components/projects/project-list.tsx +122 -0
- package/src/components/projects/project-sheet.tsx +135 -0
- package/src/components/providers/provider-list.tsx +46 -13
- package/src/components/providers/provider-sheet.tsx +0 -45
- package/src/components/runs/run-list.tsx +6 -15
- package/src/components/schedules/schedule-card.tsx +54 -4
- package/src/components/schedules/schedule-list.tsx +9 -4
- package/src/components/schedules/schedule-sheet.tsx +0 -47
- package/src/components/secrets/secrets-list.tsx +20 -2
- package/src/components/sessions/new-session-sheet.tsx +14 -15
- package/src/components/sessions/session-card.tsx +1 -1
- package/src/components/sessions/session-list.tsx +7 -7
- package/src/components/shared/connector-platform-icon.tsx +26 -20
- package/src/components/shared/model-combobox.tsx +148 -0
- package/src/components/shared/settings/section-heartbeat.tsx +8 -40
- package/src/components/shared/settings/section-orchestrator.tsx +9 -11
- package/src/components/shared/settings/section-web-search.tsx +56 -0
- package/src/components/shared/settings/settings-page.tsx +73 -0
- package/src/components/skills/skill-list.tsx +262 -35
- package/src/components/skills/skill-sheet.tsx +0 -45
- package/src/components/tasks/task-board.tsx +3 -6
- package/src/components/tasks/task-card.tsx +43 -1
- package/src/components/tasks/task-list.tsx +8 -7
- package/src/components/tasks/task-sheet.tsx +0 -44
- package/src/components/usage/usage-list.tsx +12 -4
- package/src/hooks/use-continuous-speech.ts +144 -0
- package/src/hooks/use-view-router.ts +52 -0
- package/src/hooks/use-voice-conversation.ts +80 -0
- package/src/hooks/use-ws.ts +66 -0
- package/src/instrumentation.ts +2 -0
- package/src/lib/chat.ts +14 -2
- package/src/lib/id.ts +6 -0
- package/src/lib/projects.ts +13 -0
- package/src/lib/provider-sets.ts +5 -0
- package/src/lib/providers/anthropic.ts +15 -2
- package/src/lib/providers/index.ts +8 -0
- package/src/lib/providers/ollama.ts +10 -2
- package/src/lib/providers/openai.ts +42 -13
- package/src/lib/providers/openclaw.ts +11 -0
- package/src/lib/server/api-routes.test.ts +5 -6
- package/src/lib/server/build-llm.ts +17 -4
- package/src/lib/server/chat-execution.ts +57 -8
- package/src/lib/server/collection-helpers.ts +54 -0
- package/src/lib/server/connectors/bluebubbles.test.ts +208 -0
- package/src/lib/server/connectors/bluebubbles.ts +357 -0
- package/src/lib/server/connectors/connector-routing.test.ts +1 -1
- package/src/lib/server/connectors/googlechat.ts +46 -7
- package/src/lib/server/connectors/manager.ts +401 -6
- package/src/lib/server/connectors/media.ts +2 -2
- package/src/lib/server/connectors/openclaw.ts +64 -0
- package/src/lib/server/connectors/pairing.test.ts +99 -0
- package/src/lib/server/connectors/pairing.ts +256 -0
- package/src/lib/server/connectors/signal.ts +1 -0
- package/src/lib/server/connectors/teams.ts +5 -5
- package/src/lib/server/connectors/types.ts +10 -0
- package/src/lib/server/context-manager.ts +1 -1
- package/src/lib/server/daemon-state.ts +3 -0
- package/src/lib/server/data-dir.ts +1 -0
- package/src/lib/server/execution-log.ts +3 -3
- package/src/lib/server/heartbeat-service.ts +67 -3
- package/src/lib/server/knowledge-db.test.ts +2 -33
- package/src/lib/server/langgraph-checkpoint.ts +274 -0
- package/src/lib/server/main-agent-loop.ts +67 -8
- package/src/lib/server/memory-db.ts +6 -6
- package/src/lib/server/openclaw-approvals.ts +105 -0
- package/src/lib/server/openclaw-sync.ts +496 -0
- package/src/lib/server/orchestrator-lg.ts +422 -20
- package/src/lib/server/orchestrator.ts +29 -9
- package/src/lib/server/process-manager.ts +2 -2
- package/src/lib/server/queue.ts +39 -13
- package/src/lib/server/scheduler.ts +2 -2
- package/src/lib/server/session-mailbox.ts +2 -2
- package/src/lib/server/session-run-manager.ts +8 -3
- package/src/lib/server/session-tools/connector.ts +51 -4
- package/src/lib/server/session-tools/crud.ts +3 -3
- package/src/lib/server/session-tools/delegate.ts +5 -5
- package/src/lib/server/session-tools/file.ts +176 -3
- package/src/lib/server/session-tools/index.ts +4 -0
- package/src/lib/server/session-tools/memory.ts +2 -2
- package/src/lib/server/session-tools/openclaw-nodes.ts +112 -0
- package/src/lib/server/session-tools/sandbox.ts +197 -0
- package/src/lib/server/session-tools/search-providers.ts +270 -0
- package/src/lib/server/session-tools/session-info.ts +2 -2
- package/src/lib/server/session-tools/web.ts +47 -66
- package/src/lib/server/storage-mcp.test.ts +25 -2
- package/src/lib/server/storage.ts +36 -7
- package/src/lib/server/stream-agent-chat.ts +106 -22
- package/src/lib/server/task-result.test.ts +44 -0
- package/src/lib/server/task-result.ts +14 -0
- package/src/lib/server/task-validation.test.ts +23 -0
- package/src/lib/server/task-validation.ts +5 -3
- package/src/lib/server/ws-hub.ts +85 -0
- package/src/lib/tool-definitions.ts +44 -0
- package/src/lib/tts-stream.ts +130 -0
- package/src/lib/upload.ts +7 -1
- package/src/lib/view-routes.ts +28 -0
- package/src/lib/ws-client.ts +124 -0
- package/src/proxy.ts +3 -0
- package/src/stores/use-app-store.ts +28 -1
- package/src/stores/use-chat-store.ts +42 -14
- package/src/types/index.ts +34 -2
- package/src/app/api/agents/generate/route.ts +0 -42
- package/src/app/api/generate/info/route.ts +0 -12
- package/src/app/api/generate/route.ts +0 -106
- package/src/app/favicon.ico +0 -0
- package/src/components/shared/ai-gen-block.tsx +0 -77
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { WebSocketServer, WebSocket } from 'ws'
|
|
2
|
+
import type { IncomingMessage } from 'http'
|
|
3
|
+
import { validateAccessKey } from './storage'
|
|
4
|
+
|
|
5
|
+
interface WsClient {
|
|
6
|
+
ws: WebSocket
|
|
7
|
+
topics: Set<string>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface WsHub {
|
|
11
|
+
wss: WebSocketServer
|
|
12
|
+
clients: Set<WsClient>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const GK = '__swarmclaw_ws__' as const
|
|
16
|
+
|
|
17
|
+
function getHub(): WsHub | null {
|
|
18
|
+
return (globalThis as any)[GK] ?? null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function initWsServer() {
|
|
22
|
+
if (getHub()) return
|
|
23
|
+
|
|
24
|
+
const port = Number(process.env.WS_PORT) || (Number(process.env.PORT) || 3456) + 1
|
|
25
|
+
const wss = new WebSocketServer({ port, path: '/ws' })
|
|
26
|
+
const clients = new Set<WsClient>()
|
|
27
|
+
|
|
28
|
+
const hub: WsHub = { wss, clients }
|
|
29
|
+
;(globalThis as any)[GK] = hub
|
|
30
|
+
|
|
31
|
+
wss.on('connection', (ws: WebSocket, req: IncomingMessage) => {
|
|
32
|
+
// Auth: validate ?key= from upgrade URL
|
|
33
|
+
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`)
|
|
34
|
+
const key = url.searchParams.get('key') || ''
|
|
35
|
+
if (!validateAccessKey(key)) {
|
|
36
|
+
ws.close(4001, 'Unauthorized')
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const client: WsClient = { ws, topics: new Set() }
|
|
41
|
+
clients.add(client)
|
|
42
|
+
|
|
43
|
+
ws.on('message', (raw) => {
|
|
44
|
+
try {
|
|
45
|
+
const msg = JSON.parse(String(raw))
|
|
46
|
+
if (msg.type === 'subscribe' && Array.isArray(msg.topics)) {
|
|
47
|
+
for (const t of msg.topics) {
|
|
48
|
+
if (typeof t === 'string') client.topics.add(t)
|
|
49
|
+
}
|
|
50
|
+
} else if (msg.type === 'unsubscribe' && Array.isArray(msg.topics)) {
|
|
51
|
+
for (const t of msg.topics) client.topics.delete(t)
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
// ignore malformed messages
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
ws.on('close', () => {
|
|
59
|
+
clients.delete(client)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
ws.on('error', () => {
|
|
63
|
+
clients.delete(client)
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
wss.on('error', (err) => {
|
|
68
|
+
console.error('[ws-hub] WebSocket server error:', err.message)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
console.log(`[ws-hub] WebSocket server listening on port ${port}`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function notify(topic: string, action = 'update', id?: string) {
|
|
75
|
+
const hub = getHub()
|
|
76
|
+
if (!hub) return
|
|
77
|
+
|
|
78
|
+
const payload = JSON.stringify(id ? { topic, action, id } : { topic, action })
|
|
79
|
+
|
|
80
|
+
for (const client of hub.clients) {
|
|
81
|
+
if (client.topics.has(topic) && client.ws.readyState === WebSocket.OPEN) {
|
|
82
|
+
client.ws.send(payload)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export interface ToolDefinition {
|
|
2
|
+
id: string
|
|
3
|
+
label: string
|
|
4
|
+
description: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const AVAILABLE_TOOLS: ToolDefinition[] = [
|
|
8
|
+
{ id: 'shell', label: 'Shell', description: 'Execute commands in the working directory' },
|
|
9
|
+
{ id: 'files', label: 'Files', description: 'Read, write, list, move, copy, and send files' },
|
|
10
|
+
{ id: 'copy_file', label: 'Copy File', description: 'Copy files within the working directory' },
|
|
11
|
+
{ id: 'move_file', label: 'Move File', description: 'Move/rename files within the working directory' },
|
|
12
|
+
{ id: 'delete_file', label: 'Delete File', description: 'Delete files/directories (disabled by default)' },
|
|
13
|
+
{ id: 'edit_file', label: 'Edit File', description: 'Search-and-replace editing within files' },
|
|
14
|
+
{ id: 'process', label: 'Process', description: 'Monitor and control long-running shell commands' },
|
|
15
|
+
{ id: 'web_search', label: 'Web Search', description: 'Search the web via DuckDuckGo' },
|
|
16
|
+
{ id: 'web_fetch', label: 'Web Fetch', description: 'Fetch and extract text from URLs' },
|
|
17
|
+
{ id: 'claude_code', label: 'Claude Code', description: 'Delegate complex tasks to Claude Code CLI' },
|
|
18
|
+
{ id: 'codex_cli', label: 'Codex CLI', description: 'Delegate complex tasks to OpenAI Codex CLI' },
|
|
19
|
+
{ id: 'opencode_cli', label: 'OpenCode CLI', description: 'Delegate complex tasks to OpenCode CLI' },
|
|
20
|
+
{ id: 'browser', label: 'Browser', description: 'Playwright — browse, scrape, interact with web pages' },
|
|
21
|
+
{ id: 'memory', label: 'Memory', description: 'Store and retrieve long-term memories across conversations' },
|
|
22
|
+
{ id: 'sandbox', label: 'Sandbox', description: 'Run JS/TS/Python code in an isolated Deno sandbox' },
|
|
23
|
+
{ id: 'create_document', label: 'Create Document', description: 'Render markdown to PDF, HTML, or image' },
|
|
24
|
+
{ id: 'create_spreadsheet', label: 'Create Spreadsheet', description: 'Create Excel or CSV files from structured data' },
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
export const PLATFORM_TOOLS: ToolDefinition[] = [
|
|
28
|
+
{ id: 'manage_agents', label: 'Agents', description: 'Create, edit, and delete agents' },
|
|
29
|
+
{ id: 'manage_tasks', label: 'Tasks', description: 'Create, edit, and delete tasks' },
|
|
30
|
+
{ id: 'manage_schedules', label: 'Schedules', description: 'Create, edit, and delete schedules' },
|
|
31
|
+
{ id: 'manage_skills', label: 'Skills', description: 'Create, edit, and delete skills' },
|
|
32
|
+
{ id: 'manage_documents', label: 'Documents', description: 'Upload, search, and delete indexed documents' },
|
|
33
|
+
{ id: 'manage_webhooks', label: 'Webhooks', description: 'Register webhooks that trigger agent workflows' },
|
|
34
|
+
{ id: 'manage_connectors', label: 'Connectors', description: 'Create, edit, and delete connectors' },
|
|
35
|
+
{ id: 'manage_sessions', label: 'Chats', description: 'List chats, send messages, and spawn agent work' },
|
|
36
|
+
{ id: 'manage_secrets', label: 'Secrets', description: 'Store and retrieve encrypted service secrets' },
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
export const ALL_TOOLS: ToolDefinition[] = [...AVAILABLE_TOOLS, ...PLATFORM_TOOLS]
|
|
40
|
+
|
|
41
|
+
/** Flat id→label lookup for display */
|
|
42
|
+
export const TOOL_LABELS: Record<string, string> = Object.fromEntries(
|
|
43
|
+
ALL_TOOLS.map((t) => [t.id, t.label]),
|
|
44
|
+
)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming TTS utilities: sentence accumulation and ordered audio playback.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// SentenceAccumulator — buffers text deltas, emits on sentence boundaries
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
export class SentenceAccumulator {
|
|
10
|
+
private buffer = ''
|
|
11
|
+
private onSentence: (sentence: string) => void
|
|
12
|
+
|
|
13
|
+
constructor(onSentence: (sentence: string) => void) {
|
|
14
|
+
this.onSentence = onSentence
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
push(delta: string) {
|
|
18
|
+
this.buffer += delta
|
|
19
|
+
// Emit on sentence-ending punctuation followed by space or newline
|
|
20
|
+
const sentenceEnd = /([.!?])\s+/g
|
|
21
|
+
let match: RegExpExecArray | null
|
|
22
|
+
let lastIndex = 0
|
|
23
|
+
while ((match = sentenceEnd.exec(this.buffer)) !== null) {
|
|
24
|
+
const sentence = this.buffer.slice(lastIndex, match.index + 1).trim()
|
|
25
|
+
if (sentence) this.onSentence(sentence)
|
|
26
|
+
lastIndex = match.index + match[0].length
|
|
27
|
+
}
|
|
28
|
+
// Also emit on double newlines
|
|
29
|
+
const doubleNewline = this.buffer.indexOf('\n\n', lastIndex)
|
|
30
|
+
if (doubleNewline !== -1) {
|
|
31
|
+
const sentence = this.buffer.slice(lastIndex, doubleNewline).trim()
|
|
32
|
+
if (sentence) this.onSentence(sentence)
|
|
33
|
+
lastIndex = doubleNewline + 2
|
|
34
|
+
}
|
|
35
|
+
// Flush if buffer exceeds 200 chars without a break
|
|
36
|
+
if (this.buffer.length - lastIndex > 200) {
|
|
37
|
+
const sentence = this.buffer.slice(lastIndex).trim()
|
|
38
|
+
if (sentence) this.onSentence(sentence)
|
|
39
|
+
lastIndex = this.buffer.length
|
|
40
|
+
}
|
|
41
|
+
this.buffer = this.buffer.slice(lastIndex)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
flush() {
|
|
45
|
+
const remaining = this.buffer.trim()
|
|
46
|
+
if (remaining) this.onSentence(remaining)
|
|
47
|
+
this.buffer = ''
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// AudioChunkQueue — ordered sequential playback of audio chunks
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
export class AudioChunkQueue {
|
|
56
|
+
private queue: Promise<ArrayBuffer>[] = []
|
|
57
|
+
private playing = false
|
|
58
|
+
private audioCtx: AudioContext | null = null
|
|
59
|
+
private currentSource: AudioBufferSourceNode | null = null
|
|
60
|
+
private stopped = false
|
|
61
|
+
onComplete?: () => void
|
|
62
|
+
|
|
63
|
+
enqueue(fetchPromise: Promise<ArrayBuffer>) {
|
|
64
|
+
this.queue.push(fetchPromise)
|
|
65
|
+
if (!this.playing) this.playNext()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private async playNext() {
|
|
69
|
+
if (this.stopped) return
|
|
70
|
+
if (this.queue.length === 0) {
|
|
71
|
+
this.playing = false
|
|
72
|
+
this.onComplete?.()
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
this.playing = true
|
|
77
|
+
const bufferPromise = this.queue.shift()!
|
|
78
|
+
try {
|
|
79
|
+
if (!this.audioCtx) this.audioCtx = new AudioContext()
|
|
80
|
+
if (this.audioCtx.state === 'suspended') await this.audioCtx.resume()
|
|
81
|
+
|
|
82
|
+
const arrayBuffer = await bufferPromise
|
|
83
|
+
if (this.stopped) return
|
|
84
|
+
const audioBuffer = await this.audioCtx.decodeAudioData(arrayBuffer)
|
|
85
|
+
if (this.stopped) return
|
|
86
|
+
|
|
87
|
+
const source = this.audioCtx.createBufferSource()
|
|
88
|
+
source.buffer = audioBuffer
|
|
89
|
+
source.connect(this.audioCtx.destination)
|
|
90
|
+
this.currentSource = source
|
|
91
|
+
|
|
92
|
+
await new Promise<void>((resolve) => {
|
|
93
|
+
source.onended = () => {
|
|
94
|
+
this.currentSource = null
|
|
95
|
+
resolve()
|
|
96
|
+
}
|
|
97
|
+
source.start()
|
|
98
|
+
})
|
|
99
|
+
} catch {
|
|
100
|
+
// Skip failed chunks
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!this.stopped) this.playNext()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
stop() {
|
|
107
|
+
this.stopped = true
|
|
108
|
+
this.queue = []
|
|
109
|
+
if (this.currentSource) {
|
|
110
|
+
try { this.currentSource.stop() } catch { /* noop */ }
|
|
111
|
+
this.currentSource = null
|
|
112
|
+
}
|
|
113
|
+
this.playing = false
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Helper to fetch streaming TTS audio
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
export function fetchStreamTts(text: string): Promise<ArrayBuffer> {
|
|
122
|
+
return fetch('/api/tts/stream', {
|
|
123
|
+
method: 'POST',
|
|
124
|
+
headers: { 'Content-Type': 'application/json' },
|
|
125
|
+
body: JSON.stringify({ text }),
|
|
126
|
+
}).then((res) => {
|
|
127
|
+
if (!res.ok) throw new Error(`TTS error: ${res.status}`)
|
|
128
|
+
return res.arrayBuffer()
|
|
129
|
+
})
|
|
130
|
+
}
|
package/src/lib/upload.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import type { UploadResult } from '../types'
|
|
2
|
+
import { getStoredAccessKey } from './api-client'
|
|
2
3
|
|
|
3
4
|
export async function uploadImage(file: File): Promise<UploadResult> {
|
|
5
|
+
const key = getStoredAccessKey()
|
|
4
6
|
const res = await fetch('/api/upload', {
|
|
5
7
|
method: 'POST',
|
|
6
|
-
headers: {
|
|
8
|
+
headers: {
|
|
9
|
+
'X-Filename': file.name,
|
|
10
|
+
...(key ? { 'X-Access-Key': key } : {}),
|
|
11
|
+
},
|
|
7
12
|
body: file,
|
|
8
13
|
})
|
|
14
|
+
if (!res.ok) throw new Error(`Upload failed (${res.status})`)
|
|
9
15
|
return res.json()
|
|
10
16
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { AppView } from '@/types'
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_VIEW: AppView = 'agents'
|
|
4
|
+
|
|
5
|
+
export const VIEW_TO_PATH: Record<AppView, string> = {
|
|
6
|
+
agents: '/agents',
|
|
7
|
+
schedules: '/schedules',
|
|
8
|
+
memory: '/memory',
|
|
9
|
+
tasks: '/tasks',
|
|
10
|
+
secrets: '/secrets',
|
|
11
|
+
providers: '/providers',
|
|
12
|
+
skills: '/skills',
|
|
13
|
+
connectors: '/connectors',
|
|
14
|
+
webhooks: '/webhooks',
|
|
15
|
+
mcp_servers: '/mcp-servers',
|
|
16
|
+
knowledge: '/knowledge',
|
|
17
|
+
plugins: '/plugins',
|
|
18
|
+
usage: '/usage',
|
|
19
|
+
runs: '/runs',
|
|
20
|
+
logs: '/logs',
|
|
21
|
+
settings: '/settings',
|
|
22
|
+
projects: '/projects',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const entries = Object.entries(VIEW_TO_PATH) as [AppView, string][]
|
|
26
|
+
export const PATH_TO_VIEW: Record<string, AppView> = Object.fromEntries(
|
|
27
|
+
entries.map(([view, path]) => [path, view]),
|
|
28
|
+
) as Record<string, AppView>
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
type WsCallback = () => void
|
|
2
|
+
|
|
3
|
+
let ws: WebSocket | null = null
|
|
4
|
+
let accessKey = ''
|
|
5
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
6
|
+
let reconnectDelay = 1000
|
|
7
|
+
const MAX_RECONNECT_DELAY = 30_000
|
|
8
|
+
const listeners = new Map<string, Set<WsCallback>>()
|
|
9
|
+
let connected = false
|
|
10
|
+
|
|
11
|
+
function getWsUrl(key: string): string {
|
|
12
|
+
const host = typeof window !== 'undefined' ? window.location.hostname : 'localhost'
|
|
13
|
+
const port = process.env.NEXT_PUBLIC_WS_PORT || '3457'
|
|
14
|
+
const protocol = typeof window !== 'undefined' && window.location.protocol === 'https:' ? 'wss' : 'ws'
|
|
15
|
+
return `${protocol}://${host}:${port}/ws?key=${encodeURIComponent(key)}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function handleMessage(event: MessageEvent) {
|
|
19
|
+
try {
|
|
20
|
+
const msg = JSON.parse(event.data)
|
|
21
|
+
const topic = msg.topic as string
|
|
22
|
+
if (!topic) return
|
|
23
|
+
const cbs = listeners.get(topic)
|
|
24
|
+
if (cbs) {
|
|
25
|
+
for (const cb of cbs) cb()
|
|
26
|
+
}
|
|
27
|
+
} catch {
|
|
28
|
+
// ignore malformed
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function scheduleReconnect() {
|
|
33
|
+
if (reconnectTimer) return
|
|
34
|
+
reconnectTimer = setTimeout(() => {
|
|
35
|
+
reconnectTimer = null
|
|
36
|
+
if (!accessKey) return
|
|
37
|
+
connect(accessKey)
|
|
38
|
+
}, reconnectDelay)
|
|
39
|
+
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function connect(key: string) {
|
|
43
|
+
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
ws = new WebSocket(getWsUrl(key))
|
|
47
|
+
} catch {
|
|
48
|
+
scheduleReconnect()
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
ws.onopen = () => {
|
|
53
|
+
connected = true
|
|
54
|
+
reconnectDelay = 1000
|
|
55
|
+
// Subscribe to all currently registered topics
|
|
56
|
+
const topics = Array.from(listeners.keys())
|
|
57
|
+
if (topics.length > 0) {
|
|
58
|
+
ws?.send(JSON.stringify({ type: 'subscribe', topics }))
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
ws.onmessage = handleMessage
|
|
63
|
+
|
|
64
|
+
ws.onclose = () => {
|
|
65
|
+
connected = false
|
|
66
|
+
ws = null
|
|
67
|
+
if (accessKey) scheduleReconnect()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
ws.onerror = () => {
|
|
71
|
+
// onclose will fire after this
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function connectWs(key: string) {
|
|
76
|
+
accessKey = key
|
|
77
|
+
reconnectDelay = 1000
|
|
78
|
+
connect(key)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function disconnectWs() {
|
|
82
|
+
accessKey = ''
|
|
83
|
+
if (reconnectTimer) {
|
|
84
|
+
clearTimeout(reconnectTimer)
|
|
85
|
+
reconnectTimer = null
|
|
86
|
+
}
|
|
87
|
+
if (ws) {
|
|
88
|
+
ws.onclose = null
|
|
89
|
+
ws.close()
|
|
90
|
+
ws = null
|
|
91
|
+
}
|
|
92
|
+
connected = false
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function subscribeWs(topic: string, callback: WsCallback) {
|
|
96
|
+
let set = listeners.get(topic)
|
|
97
|
+
const isNew = !set
|
|
98
|
+
if (!set) {
|
|
99
|
+
set = new Set()
|
|
100
|
+
listeners.set(topic, set)
|
|
101
|
+
}
|
|
102
|
+
set.add(callback)
|
|
103
|
+
|
|
104
|
+
// Tell server about new topic subscription
|
|
105
|
+
if (isNew && ws?.readyState === WebSocket.OPEN) {
|
|
106
|
+
ws.send(JSON.stringify({ type: 'subscribe', topics: [topic] }))
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function unsubscribeWs(topic: string, callback: WsCallback) {
|
|
111
|
+
const set = listeners.get(topic)
|
|
112
|
+
if (!set) return
|
|
113
|
+
set.delete(callback)
|
|
114
|
+
if (set.size === 0) {
|
|
115
|
+
listeners.delete(topic)
|
|
116
|
+
if (ws?.readyState === WebSocket.OPEN) {
|
|
117
|
+
ws.send(JSON.stringify({ type: 'unsubscribe', topics: [topic] }))
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function isWsConnected(): boolean {
|
|
123
|
+
return connected
|
|
124
|
+
}
|
package/src/proxy.ts
CHANGED
|
@@ -9,6 +9,8 @@ export function proxy(request: NextRequest) {
|
|
|
9
9
|
const { pathname } = request.nextUrl
|
|
10
10
|
const isWebhookTrigger = request.method === 'POST'
|
|
11
11
|
&& /^\/api\/webhooks\/[^/]+\/?$/.test(pathname)
|
|
12
|
+
const isConnectorWebhook = request.method === 'POST'
|
|
13
|
+
&& /^\/api\/connectors\/[^/]+\/webhook\/?$/.test(pathname)
|
|
12
14
|
|
|
13
15
|
// Only protect API routes (not auth, uploads served as static assets, or inbound webhooks)
|
|
14
16
|
if (
|
|
@@ -16,6 +18,7 @@ export function proxy(request: NextRequest) {
|
|
|
16
18
|
|| pathname === '/api/auth'
|
|
17
19
|
|| pathname.startsWith('/api/uploads/')
|
|
18
20
|
|| isWebhookTrigger
|
|
21
|
+
|| isConnectorWebhook
|
|
19
22
|
) {
|
|
20
23
|
return NextResponse.next()
|
|
21
24
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { create } from 'zustand'
|
|
4
|
-
import type { Sessions, Session, NetworkInfo, Directory, ProviderInfo, Credentials, Agent, Schedule, AppView, BoardTask, AppSettings, OrchestratorSecret, ProviderConfig, Skill, Connector, Webhook, McpServerConfig, PluginMeta } from '../types'
|
|
4
|
+
import type { Sessions, Session, NetworkInfo, Directory, ProviderInfo, Credentials, Agent, Schedule, AppView, BoardTask, AppSettings, OrchestratorSecret, ProviderConfig, Skill, Connector, Webhook, McpServerConfig, PluginMeta, Project } from '../types'
|
|
5
5
|
import { fetchSessions, fetchDirs, fetchProviders, fetchCredentials } from '../lib/sessions'
|
|
6
6
|
import { fetchAgents } from '../lib/agents'
|
|
7
7
|
import { fetchSchedules } from '../lib/schedules'
|
|
@@ -151,6 +151,16 @@ interface AppState {
|
|
|
151
151
|
editingPluginFilename: string | null
|
|
152
152
|
setEditingPluginFilename: (filename: string | null) => void
|
|
153
153
|
|
|
154
|
+
// Projects
|
|
155
|
+
projects: Record<string, Project>
|
|
156
|
+
loadProjects: () => Promise<void>
|
|
157
|
+
projectSheetOpen: boolean
|
|
158
|
+
setProjectSheetOpen: (open: boolean) => void
|
|
159
|
+
editingProjectId: string | null
|
|
160
|
+
setEditingProjectId: (id: string | null) => void
|
|
161
|
+
activeProjectFilter: string | null
|
|
162
|
+
setActiveProjectFilter: (id: string | null) => void
|
|
163
|
+
|
|
154
164
|
}
|
|
155
165
|
|
|
156
166
|
export const useAppStore = create<AppState>((set, get) => ({
|
|
@@ -465,4 +475,21 @@ export const useAppStore = create<AppState>((set, get) => ({
|
|
|
465
475
|
editingPluginFilename: null,
|
|
466
476
|
setEditingPluginFilename: (filename) => set({ editingPluginFilename: filename }),
|
|
467
477
|
|
|
478
|
+
// Projects
|
|
479
|
+
projects: {},
|
|
480
|
+
loadProjects: async () => {
|
|
481
|
+
try {
|
|
482
|
+
const projects = await api<Record<string, Project>>('GET', '/projects')
|
|
483
|
+
set({ projects })
|
|
484
|
+
} catch {
|
|
485
|
+
// ignore
|
|
486
|
+
}
|
|
487
|
+
},
|
|
488
|
+
projectSheetOpen: false,
|
|
489
|
+
setProjectSheetOpen: (open) => set({ projectSheetOpen: open }),
|
|
490
|
+
editingProjectId: null,
|
|
491
|
+
setEditingProjectId: (id) => set({ editingProjectId: id }),
|
|
492
|
+
activeProjectFilter: null,
|
|
493
|
+
setActiveProjectFilter: (id) => set({ activeProjectFilter: id }),
|
|
494
|
+
|
|
468
495
|
}))
|
|
@@ -7,7 +7,7 @@ import { speak } from '../lib/tts'
|
|
|
7
7
|
import { getStoredAccessKey } from '../lib/api-client'
|
|
8
8
|
import { useAppStore } from './use-app-store'
|
|
9
9
|
|
|
10
|
-
interface
|
|
10
|
+
export interface PendingFile {
|
|
11
11
|
file: File
|
|
12
12
|
path: string
|
|
13
13
|
url: string
|
|
@@ -44,8 +44,15 @@ interface ChatState {
|
|
|
44
44
|
ttsEnabled: boolean
|
|
45
45
|
toggleTts: () => void
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
// Multi-file attachment support
|
|
48
|
+
pendingFiles: PendingFile[]
|
|
49
|
+
addPendingFile: (f: PendingFile) => void
|
|
50
|
+
removePendingFile: (index: number) => void
|
|
51
|
+
clearPendingFiles: () => void
|
|
52
|
+
|
|
53
|
+
// Legacy single-image compat (reads first pendingFile)
|
|
54
|
+
pendingImage: PendingFile | null
|
|
55
|
+
setPendingImage: (img: PendingFile | null) => void
|
|
49
56
|
|
|
50
57
|
devServer: DevServerStatus | null
|
|
51
58
|
setDevServer: (ds: DevServerStatus | null) => void
|
|
@@ -57,6 +64,10 @@ interface ChatState {
|
|
|
57
64
|
retryLastMessage: () => Promise<void>
|
|
58
65
|
sendHeartbeat: (sessionId: string) => Promise<void>
|
|
59
66
|
stopStreaming: () => void
|
|
67
|
+
|
|
68
|
+
// Voice conversation
|
|
69
|
+
voiceConversationActive: boolean
|
|
70
|
+
onStreamEvent: ((event: { t: string; text?: string }) => void) | null
|
|
60
71
|
}
|
|
61
72
|
|
|
62
73
|
export const useChatStore = create<ChatState>((set, get) => ({
|
|
@@ -70,21 +81,36 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|
|
70
81
|
lastUsage: null,
|
|
71
82
|
ttsEnabled: false,
|
|
72
83
|
toggleTts: () => set((s) => ({ ttsEnabled: !s.ttsEnabled })),
|
|
73
|
-
|
|
74
|
-
|
|
84
|
+
voiceConversationActive: false,
|
|
85
|
+
onStreamEvent: null,
|
|
86
|
+
|
|
87
|
+
pendingFiles: [],
|
|
88
|
+
addPendingFile: (f) => set((s) => ({ pendingFiles: [...s.pendingFiles, f] })),
|
|
89
|
+
removePendingFile: (index) => set((s) => ({ pendingFiles: s.pendingFiles.filter((_, i) => i !== index) })),
|
|
90
|
+
clearPendingFiles: () => set({ pendingFiles: [] }),
|
|
91
|
+
|
|
92
|
+
// Legacy compat: pendingImage reads/writes the first pending file
|
|
93
|
+
get pendingImage() { const files = get().pendingFiles; return files.length ? files[0] : null },
|
|
94
|
+
setPendingImage: (img) => set({ pendingFiles: img ? [img] : [] }),
|
|
95
|
+
|
|
75
96
|
devServer: null,
|
|
76
97
|
setDevServer: (ds) => set({ devServer: ds }),
|
|
77
98
|
debugOpen: false,
|
|
78
99
|
setDebugOpen: (open) => set({ debugOpen: open }),
|
|
79
100
|
|
|
80
101
|
sendMessage: async (text: string) => {
|
|
81
|
-
|
|
102
|
+
const { pendingFiles } = get()
|
|
103
|
+
if ((!text.trim() && !pendingFiles.length) || get().streaming) return
|
|
82
104
|
const sessionId = useAppStore.getState().currentSessionId
|
|
83
105
|
if (!sessionId) return
|
|
84
106
|
|
|
85
|
-
|
|
86
|
-
const imagePath =
|
|
87
|
-
const imageUrl =
|
|
107
|
+
// Primary image (backward compat)
|
|
108
|
+
const imagePath = pendingFiles[0]?.path
|
|
109
|
+
const imageUrl = pendingFiles[0]?.url
|
|
110
|
+
// All attached file paths
|
|
111
|
+
const attachedFiles = pendingFiles.length > 1
|
|
112
|
+
? pendingFiles.map((f) => f.path)
|
|
113
|
+
: undefined
|
|
88
114
|
|
|
89
115
|
const userMsg: Message = {
|
|
90
116
|
role: 'user',
|
|
@@ -92,13 +118,14 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|
|
92
118
|
time: Date.now(),
|
|
93
119
|
imagePath,
|
|
94
120
|
imageUrl,
|
|
121
|
+
attachedFiles,
|
|
95
122
|
}
|
|
96
123
|
set((s) => ({
|
|
97
124
|
streaming: true,
|
|
98
125
|
streamingSessionId: sessionId,
|
|
99
126
|
streamText: '',
|
|
100
127
|
messages: [...s.messages, userMsg],
|
|
101
|
-
|
|
128
|
+
pendingFiles: [],
|
|
102
129
|
toolEvents: [],
|
|
103
130
|
lastUsage: null,
|
|
104
131
|
}))
|
|
@@ -114,6 +141,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|
|
114
141
|
/cancelled by steer mode|stopped by user/i.test(msg || '')
|
|
115
142
|
|
|
116
143
|
await streamChat(sessionId, text, imagePath, imageUrl, (event: SSEEvent) => {
|
|
144
|
+
// Forward events to voice conversation handler if active
|
|
145
|
+
get().onStreamEvent?.(event)
|
|
117
146
|
if (event.t === 'd') {
|
|
118
147
|
fullText += event.text || ''
|
|
119
148
|
set({ streamText: fullText })
|
|
@@ -166,7 +195,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|
|
166
195
|
} else if (event.t === 'done') {
|
|
167
196
|
// done
|
|
168
197
|
}
|
|
169
|
-
})
|
|
198
|
+
}, attachedFiles)
|
|
170
199
|
|
|
171
200
|
if (fullText.trim()) {
|
|
172
201
|
const currentToolEvents = get().toolEvents
|
|
@@ -188,7 +217,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|
|
188
217
|
streamingSessionId: null,
|
|
189
218
|
streamText: '',
|
|
190
219
|
}))
|
|
191
|
-
if (get().ttsEnabled) speak(fullText)
|
|
220
|
+
if (get().ttsEnabled && !get().voiceConversationActive) speak(fullText)
|
|
192
221
|
} else {
|
|
193
222
|
set({ streaming: false, streamingSessionId: null, streamText: '' })
|
|
194
223
|
}
|
|
@@ -218,9 +247,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|
|
218
247
|
set({ messages: msgs })
|
|
219
248
|
}
|
|
220
249
|
// Re-send the last user message through the normal SSE flow
|
|
221
|
-
// Temporarily set pendingImage if there was one
|
|
222
250
|
if (imagePath) {
|
|
223
|
-
set({
|
|
251
|
+
set({ pendingFiles: [{ file: new File([], ''), path: imagePath, url: '' }] })
|
|
224
252
|
}
|
|
225
253
|
await get().sendMessage(message)
|
|
226
254
|
} catch {
|