@swarmclawai/swarmclaw 0.4.0 → 0.5.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/README.md +21 -4
- package/bin/server-cmd.js +28 -19
- package/next.config.ts +13 -0
- package/package.json +3 -1
- package/src/app/api/agents/[id]/route.ts +39 -22
- package/src/app/api/agents/[id]/thread/route.ts +2 -2
- package/src/app/api/agents/route.ts +3 -2
- package/src/app/api/agents/trash/route.ts +44 -0
- package/src/app/api/clawhub/install/route.ts +2 -2
- package/src/app/api/connectors/[id]/route.ts +17 -7
- package/src/app/api/connectors/[id]/webhook/route.ts +103 -0
- package/src/app/api/connectors/route.ts +6 -3
- package/src/app/api/credentials/[id]/route.ts +2 -1
- package/src/app/api/credentials/route.ts +2 -2
- package/src/app/api/documents/route.ts +2 -2
- package/src/app/api/files/serve/route.ts +8 -0
- 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 +2 -2
- 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/agent-files/route.ts +57 -0
- package/src/app/api/openclaw/approvals/route.ts +46 -0
- package/src/app/api/openclaw/config-sync/route.ts +33 -0
- package/src/app/api/openclaw/cron/route.ts +52 -0
- package/src/app/api/openclaw/directory/route.ts +27 -0
- package/src/app/api/openclaw/discover/route.ts +62 -0
- package/src/app/api/openclaw/dotenv-keys/route.ts +18 -0
- package/src/app/api/openclaw/exec-config/route.ts +41 -0
- package/src/app/api/openclaw/gateway/route.ts +72 -0
- package/src/app/api/openclaw/history/route.ts +109 -0
- package/src/app/api/openclaw/media/route.ts +53 -0
- package/src/app/api/openclaw/models/route.ts +12 -0
- package/src/app/api/openclaw/permissions/route.ts +39 -0
- package/src/app/api/openclaw/sandbox-env/route.ts +69 -0
- package/src/app/api/openclaw/skills/install/route.ts +32 -0
- package/src/app/api/openclaw/skills/remove/route.ts +24 -0
- package/src/app/api/openclaw/skills/route.ts +82 -0
- package/src/app/api/openclaw/sync/route.ts +31 -0
- package/src/app/api/orchestrator/run/route.ts +2 -2
- 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 -15
- package/src/app/api/providers/route.ts +2 -2
- package/src/app/api/schedules/[id]/route.ts +16 -18
- package/src/app/api/schedules/[id]/run/route.ts +4 -3
- package/src/app/api/schedules/route.ts +2 -2
- package/src/app/api/secrets/[id]/route.ts +16 -17
- package/src/app/api/secrets/route.ts +2 -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]/edit-resend/route.ts +22 -0
- package/src/app/api/sessions/[id]/fork/route.ts +44 -0
- package/src/app/api/sessions/[id]/messages/route.ts +20 -2
- package/src/app/api/sessions/[id]/retry/route.ts +2 -1
- package/src/app/api/sessions/[id]/route.ts +14 -4
- package/src/app/api/sessions/route.ts +8 -4
- 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 +2 -2
- package/src/app/api/tasks/[id]/approve/route.ts +2 -1
- package/src/app/api/tasks/[id]/route.ts +6 -5
- package/src/app/api/tasks/route.ts +2 -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/webhooks/[id]/route.ts +29 -31
- package/src/app/api/webhooks/route.ts +2 -2
- package/src/app/globals.css +14 -0
- package/src/app/layout.tsx +5 -20
- package/src/app/page.tsx +3 -24
- package/src/cli/index.js +60 -0
- package/src/cli/index.ts +1 -1
- package/src/cli/spec.js +42 -0
- package/src/components/agents/agent-avatar.tsx +45 -0
- package/src/components/agents/agent-card.tsx +19 -5
- package/src/components/agents/agent-chat-list.tsx +31 -24
- package/src/components/agents/agent-files-editor.tsx +185 -0
- package/src/components/agents/agent-list.tsx +84 -3
- package/src/components/agents/agent-sheet.tsx +147 -14
- package/src/components/agents/cron-job-form.tsx +137 -0
- package/src/components/agents/exec-config-panel.tsx +147 -0
- package/src/components/agents/inspector-panel.tsx +310 -0
- package/src/components/agents/openclaw-skills-panel.tsx +230 -0
- package/src/components/agents/permission-preset-selector.tsx +79 -0
- package/src/components/agents/personality-builder.tsx +111 -0
- package/src/components/agents/sandbox-env-panel.tsx +72 -0
- package/src/components/agents/skill-install-dialog.tsx +102 -0
- package/src/components/agents/trash-list.tsx +109 -0
- package/src/components/chat/chat-area.tsx +41 -6
- package/src/components/chat/chat-header.tsx +305 -29
- package/src/components/chat/chat-preview-panel.tsx +113 -0
- package/src/components/chat/exec-approval-card.tsx +89 -0
- package/src/components/chat/message-bubble.tsx +218 -36
- package/src/components/chat/message-list.tsx +135 -31
- package/src/components/chat/streaming-bubble.tsx +59 -10
- package/src/components/chat/suggestions-bar.tsx +74 -0
- package/src/components/chat/thinking-indicator.tsx +20 -6
- package/src/components/chat/tool-call-bubble.tsx +98 -19
- package/src/components/chat/tool-request-banner.tsx +20 -2
- package/src/components/chat/trace-block.tsx +103 -0
- package/src/components/chat/voice-overlay.tsx +80 -0
- package/src/components/connectors/connector-list.tsx +6 -2
- package/src/components/connectors/connector-sheet.tsx +31 -7
- package/src/components/layout/app-layout.tsx +47 -25
- package/src/components/projects/project-list.tsx +123 -0
- package/src/components/projects/project-sheet.tsx +135 -0
- package/src/components/schedules/schedule-list.tsx +3 -1
- package/src/components/sessions/new-session-sheet.tsx +6 -6
- package/src/components/sessions/session-card.tsx +1 -1
- package/src/components/sessions/session-list.tsx +7 -7
- package/src/components/settings/gateway-connection-panel.tsx +278 -0
- package/src/components/shared/avatar.tsx +13 -2
- package/src/components/shared/connector-platform-icon.tsx +4 -0
- package/src/components/shared/settings/section-heartbeat.tsx +1 -1
- package/src/components/shared/settings/section-orchestrator.tsx +1 -2
- package/src/components/shared/settings/section-web-search.tsx +56 -0
- package/src/components/shared/settings/settings-page.tsx +74 -0
- package/src/components/skills/skill-list.tsx +2 -1
- package/src/components/tasks/task-board.tsx +1 -1
- package/src/components/tasks/task-list.tsx +5 -2
- package/src/components/tasks/task-sheet.tsx +12 -12
- package/src/hooks/use-continuous-speech.ts +181 -0
- package/src/hooks/use-openclaw-gateway.ts +63 -0
- package/src/hooks/use-view-router.ts +52 -0
- package/src/hooks/use-voice-conversation.ts +80 -0
- package/src/lib/id.ts +6 -0
- package/src/lib/notification-sounds.ts +58 -0
- package/src/lib/personality-parser.ts +97 -0
- package/src/lib/projects.ts +13 -0
- package/src/lib/provider-sets.ts +5 -0
- package/src/lib/providers/anthropic.ts +14 -1
- package/src/lib/providers/index.ts +6 -0
- package/src/lib/providers/ollama.ts +9 -1
- package/src/lib/providers/openai.ts +9 -1
- package/src/lib/providers/openclaw.ts +28 -2
- package/src/lib/runtime-loop.ts +2 -2
- 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 +82 -6
- package/src/lib/server/collection-helpers.ts +54 -0
- package/src/lib/server/connectors/bluebubbles.test.ts +217 -0
- package/src/lib/server/connectors/bluebubbles.ts +360 -0
- package/src/lib/server/connectors/connector-routing.test.ts +1 -1
- package/src/lib/server/connectors/googlechat.ts +51 -8
- package/src/lib/server/connectors/manager.ts +424 -13
- package/src/lib/server/connectors/media.ts +2 -2
- package/src/lib/server/connectors/openclaw.ts +65 -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/daemon-state.ts +11 -0
- package/src/lib/server/execution-log.ts +3 -3
- package/src/lib/server/heartbeat-service.ts +1 -1
- package/src/lib/server/knowledge-db.test.ts +2 -33
- package/src/lib/server/main-agent-loop.ts +8 -9
- package/src/lib/server/main-session.ts +21 -0
- package/src/lib/server/memory-db.ts +6 -6
- package/src/lib/server/openclaw-approvals.ts +105 -0
- package/src/lib/server/openclaw-config-sync.ts +107 -0
- package/src/lib/server/openclaw-exec-config.ts +52 -0
- package/src/lib/server/openclaw-gateway.ts +291 -0
- package/src/lib/server/openclaw-history-merge.ts +36 -0
- package/src/lib/server/openclaw-models.ts +56 -0
- package/src/lib/server/openclaw-permission-presets.ts +64 -0
- package/src/lib/server/openclaw-sync.ts +497 -0
- package/src/lib/server/orchestrator-lg.ts +30 -9
- package/src/lib/server/orchestrator.ts +4 -4
- package/src/lib/server/process-manager.ts +2 -2
- package/src/lib/server/queue.ts +24 -11
- 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 +2 -2
- package/src/lib/server/session-tools/connector.ts +53 -6
- package/src/lib/server/session-tools/crud.ts +3 -3
- package/src/lib/server/session-tools/delegate.ts +22 -6
- package/src/lib/server/session-tools/file.ts +192 -19
- package/src/lib/server/session-tools/index.ts +4 -2
- 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 +33 -0
- package/src/lib/server/session-tools/search-providers.ts +277 -0
- package/src/lib/server/session-tools/session-info.ts +2 -2
- package/src/lib/server/session-tools/session-tools-wiring.test.ts +2 -2
- package/src/lib/server/session-tools/shell.ts +1 -1
- package/src/lib/server/session-tools/web.ts +53 -72
- package/src/lib/server/storage.ts +74 -11
- package/src/lib/server/stream-agent-chat.ts +53 -4
- package/src/lib/server/suggestions.ts +20 -0
- package/src/lib/server/task-result.test.ts +44 -0
- package/src/lib/server/task-result.ts +14 -0
- package/src/lib/server/ws-hub.ts +14 -0
- package/src/lib/tool-definitions.ts +5 -3
- package/src/lib/tts-stream.ts +130 -0
- package/src/lib/view-routes.ts +28 -0
- package/src/proxy.ts +3 -0
- package/src/stores/use-app-store.ts +80 -1
- package/src/stores/use-approval-store.ts +78 -0
- package/src/stores/use-chat-store.ts +162 -6
- package/src/types/index.ts +154 -3
- package/tsconfig.json +13 -4
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useRef, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
export type ContinuousSpeechState = 'idle' | 'listening' | 'cooldown' | 'waitingForResponse'
|
|
6
|
+
|
|
7
|
+
interface SpeechRecognitionResult {
|
|
8
|
+
isFinal: boolean
|
|
9
|
+
[index: number]: { transcript: string }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface SpeechRecognitionResultList {
|
|
13
|
+
readonly length: number
|
|
14
|
+
[index: number]: SpeechRecognitionResult
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface SpeechRecognitionEvent {
|
|
18
|
+
resultIndex: number
|
|
19
|
+
results: SpeechRecognitionResultList
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface SpeechRecognitionErrorEvent {
|
|
23
|
+
error: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface SpeechRecognitionInstance {
|
|
27
|
+
continuous: boolean
|
|
28
|
+
interimResults: boolean
|
|
29
|
+
maxAlternatives: number
|
|
30
|
+
lang: string
|
|
31
|
+
onresult: ((e: SpeechRecognitionEvent) => void) | null
|
|
32
|
+
onerror: ((e: SpeechRecognitionErrorEvent) => void) | null
|
|
33
|
+
onend: (() => void) | null
|
|
34
|
+
start(): void
|
|
35
|
+
stop(): void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface WindowWithSpeechRecognition {
|
|
39
|
+
SpeechRecognition?: new () => SpeechRecognitionInstance
|
|
40
|
+
webkitSpeechRecognition?: new () => SpeechRecognitionInstance
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface UseContinuousSpeechOptions {
|
|
44
|
+
lang?: string
|
|
45
|
+
silenceDelayMs?: number
|
|
46
|
+
onUtterance: (transcript: string) => void
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function useContinuousSpeech(options: UseContinuousSpeechOptions) {
|
|
50
|
+
const { lang, silenceDelayMs = 800, onUtterance } = options
|
|
51
|
+
const [state, setState] = useState<ContinuousSpeechState>('idle')
|
|
52
|
+
const [transcript, setTranscript] = useState('')
|
|
53
|
+
const [interimText, setInterimText] = useState('')
|
|
54
|
+
|
|
55
|
+
const recogRef = useRef<SpeechRecognitionInstance | null>(null)
|
|
56
|
+
const silenceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
57
|
+
const activeRef = useRef(false)
|
|
58
|
+
const accumulatedRef = useRef('')
|
|
59
|
+
|
|
60
|
+
const clearSilenceTimer = () => {
|
|
61
|
+
if (silenceTimerRef.current) {
|
|
62
|
+
clearTimeout(silenceTimerRef.current)
|
|
63
|
+
silenceTimerRef.current = null
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const startRecognition = useCallback(() => {
|
|
68
|
+
const w = window as unknown as WindowWithSpeechRecognition
|
|
69
|
+
const SR = w.SpeechRecognition || w.webkitSpeechRecognition
|
|
70
|
+
if (!SR) return
|
|
71
|
+
|
|
72
|
+
if (recogRef.current) {
|
|
73
|
+
try { recogRef.current.stop() } catch { /* noop */ }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const recog = new SR()
|
|
77
|
+
recog.continuous = true
|
|
78
|
+
recog.interimResults = true
|
|
79
|
+
recog.maxAlternatives = 1
|
|
80
|
+
recog.lang = lang || navigator.language || 'en-US'
|
|
81
|
+
|
|
82
|
+
recog.onresult = (e: SpeechRecognitionEvent) => {
|
|
83
|
+
clearSilenceTimer()
|
|
84
|
+
let interim = ''
|
|
85
|
+
let final = ''
|
|
86
|
+
|
|
87
|
+
for (let i = e.resultIndex; i < e.results.length; i++) {
|
|
88
|
+
const result = e.results[i]
|
|
89
|
+
if (result.isFinal) {
|
|
90
|
+
final += result[0].transcript
|
|
91
|
+
} else {
|
|
92
|
+
interim += result[0].transcript
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (final) {
|
|
97
|
+
accumulatedRef.current += (accumulatedRef.current ? ' ' : '') + final.trim()
|
|
98
|
+
setTranscript(accumulatedRef.current)
|
|
99
|
+
setInterimText('')
|
|
100
|
+
|
|
101
|
+
// Start silence timer — after delay, send the utterance
|
|
102
|
+
silenceTimerRef.current = setTimeout(() => {
|
|
103
|
+
if (!activeRef.current) return
|
|
104
|
+
const text = accumulatedRef.current.trim()
|
|
105
|
+
if (text) {
|
|
106
|
+
setState('waitingForResponse')
|
|
107
|
+
onUtterance(text)
|
|
108
|
+
accumulatedRef.current = ''
|
|
109
|
+
setTranscript('')
|
|
110
|
+
}
|
|
111
|
+
}, silenceDelayMs)
|
|
112
|
+
} else {
|
|
113
|
+
setInterimText(interim)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
recog.onerror = (e: SpeechRecognitionErrorEvent) => {
|
|
118
|
+
// 'no-speech' is normal during silence; 'aborted' when stopping intentionally
|
|
119
|
+
if (e.error === 'no-speech' || e.error === 'aborted') return
|
|
120
|
+
console.warn('[continuous-speech] error:', e.error)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
recog.onend = () => {
|
|
124
|
+
// Auto-restart if still active (browser may stop recognition periodically)
|
|
125
|
+
if (activeRef.current && state !== 'waitingForResponse') {
|
|
126
|
+
try { recog.start() } catch { /* noop */ }
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
recogRef.current = recog
|
|
131
|
+
try {
|
|
132
|
+
recog.start()
|
|
133
|
+
setState('listening')
|
|
134
|
+
} catch {
|
|
135
|
+
setState('idle')
|
|
136
|
+
}
|
|
137
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
138
|
+
}, [lang, silenceDelayMs, onUtterance])
|
|
139
|
+
|
|
140
|
+
const start = useCallback(() => {
|
|
141
|
+
activeRef.current = true
|
|
142
|
+
accumulatedRef.current = ''
|
|
143
|
+
setTranscript('')
|
|
144
|
+
setInterimText('')
|
|
145
|
+
startRecognition()
|
|
146
|
+
}, [startRecognition])
|
|
147
|
+
|
|
148
|
+
const stop = useCallback(() => {
|
|
149
|
+
activeRef.current = false
|
|
150
|
+
clearSilenceTimer()
|
|
151
|
+
if (recogRef.current) {
|
|
152
|
+
try { recogRef.current.stop() } catch { /* noop */ }
|
|
153
|
+
recogRef.current = null
|
|
154
|
+
}
|
|
155
|
+
setState('idle')
|
|
156
|
+
setTranscript('')
|
|
157
|
+
setInterimText('')
|
|
158
|
+
accumulatedRef.current = ''
|
|
159
|
+
}, [])
|
|
160
|
+
|
|
161
|
+
const pause = useCallback(() => {
|
|
162
|
+
clearSilenceTimer()
|
|
163
|
+
if (recogRef.current) {
|
|
164
|
+
try { recogRef.current.stop() } catch { /* noop */ }
|
|
165
|
+
}
|
|
166
|
+
}, [])
|
|
167
|
+
|
|
168
|
+
const resume = useCallback(() => {
|
|
169
|
+
if (!activeRef.current) return
|
|
170
|
+
accumulatedRef.current = ''
|
|
171
|
+
setTranscript('')
|
|
172
|
+
setInterimText('')
|
|
173
|
+
setState('listening')
|
|
174
|
+
startRecognition()
|
|
175
|
+
}, [startRecognition])
|
|
176
|
+
|
|
177
|
+
const supported = typeof window !== 'undefined' &&
|
|
178
|
+
!!((window as unknown as WindowWithSpeechRecognition).SpeechRecognition || (window as unknown as WindowWithSpeechRecognition).webkitSpeechRecognition)
|
|
179
|
+
|
|
180
|
+
return { state, transcript, interimText, start, stop, pause, resume, supported }
|
|
181
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
4
|
+
import { api } from '@/lib/api-client'
|
|
5
|
+
import { useWs } from './use-ws'
|
|
6
|
+
|
|
7
|
+
/** Call an OpenClaw gateway RPC method via the proxy route. */
|
|
8
|
+
export function useOpenClawRpc<T = unknown>(method: string | null, params?: unknown) {
|
|
9
|
+
const [data, setData] = useState<T | null>(null)
|
|
10
|
+
const [loading, setLoading] = useState(false)
|
|
11
|
+
const [error, setError] = useState<string | null>(null)
|
|
12
|
+
const paramsRef = useRef(params)
|
|
13
|
+
paramsRef.current = params
|
|
14
|
+
|
|
15
|
+
const fetch = useCallback(async () => {
|
|
16
|
+
if (!method) return
|
|
17
|
+
setLoading(true)
|
|
18
|
+
setError(null)
|
|
19
|
+
try {
|
|
20
|
+
const res = await api<{ ok: boolean; result: T; error?: string }>('POST', '/openclaw/gateway', {
|
|
21
|
+
method,
|
|
22
|
+
params: paramsRef.current,
|
|
23
|
+
})
|
|
24
|
+
if (res.error) {
|
|
25
|
+
setError(res.error)
|
|
26
|
+
} else {
|
|
27
|
+
setData(res.result)
|
|
28
|
+
}
|
|
29
|
+
} catch (err: unknown) {
|
|
30
|
+
setError(err instanceof Error ? err.message : String(err))
|
|
31
|
+
} finally {
|
|
32
|
+
setLoading(false)
|
|
33
|
+
}
|
|
34
|
+
}, [method])
|
|
35
|
+
|
|
36
|
+
useEffect(() => { fetch() }, [fetch])
|
|
37
|
+
|
|
38
|
+
return { data, loading, error, refetch: fetch }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Subscribe to an OpenClaw event topic via the WS hub. */
|
|
42
|
+
export function useOpenClawEvent(topic: string, handler: () => void) {
|
|
43
|
+
useWs(`openclaw:${topic}`, handler)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Check gateway connection status. */
|
|
47
|
+
export function useOpenClawConnected() {
|
|
48
|
+
const [connected, setConnected] = useState(false)
|
|
49
|
+
|
|
50
|
+
const check = useCallback(async () => {
|
|
51
|
+
try {
|
|
52
|
+
const res = await api<{ connected: boolean }>('GET', '/openclaw/gateway')
|
|
53
|
+
setConnected(res.connected)
|
|
54
|
+
} catch {
|
|
55
|
+
setConnected(false)
|
|
56
|
+
}
|
|
57
|
+
}, [])
|
|
58
|
+
|
|
59
|
+
useEffect(() => { check() }, [check])
|
|
60
|
+
useWs('openclaw:agents', check)
|
|
61
|
+
|
|
62
|
+
return connected
|
|
63
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from 'react'
|
|
4
|
+
import { useAppStore } from '@/stores/use-app-store'
|
|
5
|
+
import { VIEW_TO_PATH, PATH_TO_VIEW, DEFAULT_VIEW } from '@/lib/view-routes'
|
|
6
|
+
|
|
7
|
+
export function useViewRouter() {
|
|
8
|
+
const fromPopstate = useRef(false)
|
|
9
|
+
|
|
10
|
+
// Mount: read pathname → set active view
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
const view = PATH_TO_VIEW[window.location.pathname]
|
|
13
|
+
if (view) {
|
|
14
|
+
useAppStore.getState().setActiveView(view)
|
|
15
|
+
} else {
|
|
16
|
+
useAppStore.getState().setActiveView(DEFAULT_VIEW)
|
|
17
|
+
window.history.replaceState(null, '', VIEW_TO_PATH[DEFAULT_VIEW])
|
|
18
|
+
}
|
|
19
|
+
}, [])
|
|
20
|
+
|
|
21
|
+
// State→URL: push new path when activeView changes
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
let prev = useAppStore.getState().activeView
|
|
24
|
+
const unsub = useAppStore.subscribe((state) => {
|
|
25
|
+
const next = state.activeView
|
|
26
|
+
if (next === prev) return
|
|
27
|
+
prev = next
|
|
28
|
+
if (fromPopstate.current) {
|
|
29
|
+
fromPopstate.current = false
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
const targetPath = VIEW_TO_PATH[next]
|
|
33
|
+
if (targetPath && window.location.pathname !== targetPath) {
|
|
34
|
+
window.history.pushState(null, '', targetPath)
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
return unsub
|
|
38
|
+
}, [])
|
|
39
|
+
|
|
40
|
+
// Popstate: browser back/forward → update view
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
const onPopstate = () => {
|
|
43
|
+
const view = PATH_TO_VIEW[window.location.pathname]
|
|
44
|
+
if (view) {
|
|
45
|
+
fromPopstate.current = true
|
|
46
|
+
useAppStore.getState().setActiveView(view)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
window.addEventListener('popstate', onPopstate)
|
|
50
|
+
return () => window.removeEventListener('popstate', onPopstate)
|
|
51
|
+
}, [])
|
|
52
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useRef, useState } from 'react'
|
|
4
|
+
import { useContinuousSpeech } from './use-continuous-speech'
|
|
5
|
+
import { SentenceAccumulator, AudioChunkQueue, fetchStreamTts } from '@/lib/tts-stream'
|
|
6
|
+
import { useChatStore } from '@/stores/use-chat-store'
|
|
7
|
+
|
|
8
|
+
export type VoiceConversationState = 'idle' | 'listening' | 'processing' | 'speaking'
|
|
9
|
+
|
|
10
|
+
export function useVoiceConversation() {
|
|
11
|
+
const [active, setActive] = useState(false)
|
|
12
|
+
const [voiceState, setVoiceState] = useState<VoiceConversationState>('idle')
|
|
13
|
+
const accumulatorRef = useRef<SentenceAccumulator | null>(null)
|
|
14
|
+
const queueRef = useRef<AudioChunkQueue | null>(null)
|
|
15
|
+
const sendMessage = useChatStore((s) => s.sendMessage)
|
|
16
|
+
|
|
17
|
+
const speech = useContinuousSpeech({
|
|
18
|
+
onUtterance: useCallback((text: string) => {
|
|
19
|
+
setVoiceState('processing')
|
|
20
|
+
// Send the transcribed text as a chat message
|
|
21
|
+
sendMessage(text)
|
|
22
|
+
}, [sendMessage]),
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
// Called by the chat store's onStreamEvent callback
|
|
26
|
+
const handleStreamEvent = useCallback((event: { t: string; text?: string }) => {
|
|
27
|
+
if (!active) return
|
|
28
|
+
|
|
29
|
+
if (event.t === 'd' && event.text) {
|
|
30
|
+
setVoiceState('speaking')
|
|
31
|
+
if (!accumulatorRef.current) {
|
|
32
|
+
const queue = new AudioChunkQueue()
|
|
33
|
+
queueRef.current = queue
|
|
34
|
+
queue.onComplete = () => {
|
|
35
|
+
// Resume listening after TTS playback finishes
|
|
36
|
+
setVoiceState('listening')
|
|
37
|
+
speech.resume()
|
|
38
|
+
}
|
|
39
|
+
accumulatorRef.current = new SentenceAccumulator((sentence) => {
|
|
40
|
+
queue.enqueue(fetchStreamTts(sentence))
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
accumulatorRef.current.push(event.text)
|
|
44
|
+
} else if (event.t === 'done') {
|
|
45
|
+
// Flush remaining text to TTS
|
|
46
|
+
if (accumulatorRef.current) {
|
|
47
|
+
accumulatorRef.current.flush()
|
|
48
|
+
accumulatorRef.current = null
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}, [active, speech])
|
|
52
|
+
|
|
53
|
+
const start = useCallback(() => {
|
|
54
|
+
setActive(true)
|
|
55
|
+
setVoiceState('listening')
|
|
56
|
+
// Register the stream event handler on the chat store
|
|
57
|
+
useChatStore.setState({ onStreamEvent: handleStreamEvent, voiceConversationActive: true })
|
|
58
|
+
speech.start()
|
|
59
|
+
}, [speech, handleStreamEvent])
|
|
60
|
+
|
|
61
|
+
const stop = useCallback(() => {
|
|
62
|
+
setActive(false)
|
|
63
|
+
setVoiceState('idle')
|
|
64
|
+
speech.stop()
|
|
65
|
+
queueRef.current?.stop()
|
|
66
|
+
queueRef.current = null
|
|
67
|
+
accumulatorRef.current = null
|
|
68
|
+
useChatStore.setState({ onStreamEvent: null, voiceConversationActive: false })
|
|
69
|
+
}, [speech])
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
active,
|
|
73
|
+
state: voiceState,
|
|
74
|
+
interimText: speech.interimText,
|
|
75
|
+
transcript: speech.transcript,
|
|
76
|
+
supported: speech.supported,
|
|
77
|
+
start,
|
|
78
|
+
stop,
|
|
79
|
+
}
|
|
80
|
+
}
|
package/src/lib/id.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
let ctx: AudioContext | null = null
|
|
2
|
+
|
|
3
|
+
function ensureCtx(): AudioContext | null {
|
|
4
|
+
if (!ctx) {
|
|
5
|
+
try { ctx = new AudioContext() } catch { return null }
|
|
6
|
+
}
|
|
7
|
+
if (ctx.state === 'suspended') ctx.resume()
|
|
8
|
+
return ctx
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function tone(freq: number, duration: number, type: OscillatorType = 'sine', delay = 0) {
|
|
12
|
+
const c = ensureCtx()
|
|
13
|
+
if (!c) return
|
|
14
|
+
const osc = c.createOscillator()
|
|
15
|
+
const gain = c.createGain()
|
|
16
|
+
osc.type = type
|
|
17
|
+
osc.frequency.value = freq
|
|
18
|
+
gain.gain.setValueAtTime(0.12, c.currentTime + delay)
|
|
19
|
+
gain.gain.exponentialRampToValueAtTime(0.001, c.currentTime + delay + duration / 1000)
|
|
20
|
+
osc.connect(gain)
|
|
21
|
+
gain.connect(c.destination)
|
|
22
|
+
osc.start(c.currentTime + delay)
|
|
23
|
+
osc.stop(c.currentTime + delay + duration / 1000)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Two ascending tones: C5 → E5 */
|
|
27
|
+
export function playStreamStart() {
|
|
28
|
+
tone(523, 80, 'sine', 0)
|
|
29
|
+
tone(659, 80, 'sine', 0.09)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Two descending tones: E5 → C5 */
|
|
33
|
+
export function playStreamEnd() {
|
|
34
|
+
tone(659, 80, 'sine', 0)
|
|
35
|
+
tone(523, 80, 'sine', 0.09)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Single ding: A5 */
|
|
39
|
+
export function playToolComplete() {
|
|
40
|
+
tone(880, 120, 'triangle')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Low buzz: A3 */
|
|
44
|
+
export function playError() {
|
|
45
|
+
tone(220, 200, 'square')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const LS_KEY = 'sc_sound_notifications'
|
|
49
|
+
|
|
50
|
+
export function getSoundEnabled(): boolean {
|
|
51
|
+
if (typeof window === 'undefined') return false
|
|
52
|
+
return localStorage.getItem(LS_KEY) === '1'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function setSoundEnabled(v: boolean) {
|
|
56
|
+
if (typeof window === 'undefined') return
|
|
57
|
+
localStorage.setItem(LS_KEY, v ? '1' : '0')
|
|
58
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { PersonalityDraft } from '@/types'
|
|
2
|
+
|
|
3
|
+
// --- IDENTITY.md ---
|
|
4
|
+
|
|
5
|
+
export function parseIdentityMd(content: string): PersonalityDraft['identity'] {
|
|
6
|
+
const result: PersonalityDraft['identity'] = {}
|
|
7
|
+
const lines = content.split('\n')
|
|
8
|
+
for (const line of lines) {
|
|
9
|
+
const match = line.match(/^\s*-\s*(.+?):\s*(.+)$/)
|
|
10
|
+
if (!match) continue
|
|
11
|
+
const key = match[1].trim().toLowerCase()
|
|
12
|
+
const value = match[2].trim()
|
|
13
|
+
if (key === 'name') result.name = value
|
|
14
|
+
else if (key === 'creature' || key === 'species' || key === 'type') result.creature = value
|
|
15
|
+
else if (key === 'vibe' || key === 'personality') result.vibe = value
|
|
16
|
+
else if (key === 'emoji' || key === 'icon') result.emoji = value
|
|
17
|
+
}
|
|
18
|
+
return result
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function serializeIdentityMd(draft: PersonalityDraft['identity']): string {
|
|
22
|
+
const lines: string[] = ['# Identity', '']
|
|
23
|
+
if (draft.name) lines.push(`- Name: ${draft.name}`)
|
|
24
|
+
if (draft.creature) lines.push(`- Creature: ${draft.creature}`)
|
|
25
|
+
if (draft.vibe) lines.push(`- Vibe: ${draft.vibe}`)
|
|
26
|
+
if (draft.emoji) lines.push(`- Emoji: ${draft.emoji}`)
|
|
27
|
+
return lines.join('\n') + '\n'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// --- USER.md ---
|
|
31
|
+
|
|
32
|
+
export function parseUserMd(content: string): PersonalityDraft['user'] {
|
|
33
|
+
const result: PersonalityDraft['user'] = {}
|
|
34
|
+
const contextIdx = content.indexOf('## Context')
|
|
35
|
+
const headerPart = contextIdx >= 0 ? content.slice(0, contextIdx) : content
|
|
36
|
+
const contextPart = contextIdx >= 0 ? content.slice(contextIdx + '## Context'.length).trim() : ''
|
|
37
|
+
|
|
38
|
+
const lines = headerPart.split('\n')
|
|
39
|
+
for (const line of lines) {
|
|
40
|
+
const match = line.match(/^\s*-\s*(.+?):\s*(.+)$/)
|
|
41
|
+
if (!match) continue
|
|
42
|
+
const key = match[1].trim().toLowerCase()
|
|
43
|
+
const value = match[2].trim()
|
|
44
|
+
if (key === 'name') result.name = value
|
|
45
|
+
else if (key === 'call them' || key === 'nickname') result.callThem = value
|
|
46
|
+
else if (key === 'pronouns') result.pronouns = value
|
|
47
|
+
else if (key === 'timezone') result.timezone = value
|
|
48
|
+
else if (key === 'notes') result.notes = value
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (contextPart) result.context = contextPart
|
|
52
|
+
|
|
53
|
+
return result
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function serializeUserMd(draft: PersonalityDraft['user']): string {
|
|
57
|
+
const lines: string[] = ['# User', '']
|
|
58
|
+
if (draft.name) lines.push(`- Name: ${draft.name}`)
|
|
59
|
+
if (draft.callThem) lines.push(`- Call them: ${draft.callThem}`)
|
|
60
|
+
if (draft.pronouns) lines.push(`- Pronouns: ${draft.pronouns}`)
|
|
61
|
+
if (draft.timezone) lines.push(`- Timezone: ${draft.timezone}`)
|
|
62
|
+
if (draft.notes) lines.push(`- Notes: ${draft.notes}`)
|
|
63
|
+
if (draft.context) {
|
|
64
|
+
lines.push('', '## Context', '', draft.context)
|
|
65
|
+
}
|
|
66
|
+
return lines.join('\n') + '\n'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// --- SOUL.md ---
|
|
70
|
+
|
|
71
|
+
export function parseSoulMd(content: string): PersonalityDraft['soul'] {
|
|
72
|
+
const result: PersonalityDraft['soul'] = {}
|
|
73
|
+
const sections = content.split(/^##\s+/m)
|
|
74
|
+
|
|
75
|
+
for (const section of sections) {
|
|
76
|
+
const nlIdx = section.indexOf('\n')
|
|
77
|
+
if (nlIdx < 0) continue
|
|
78
|
+
const heading = section.slice(0, nlIdx).trim().toLowerCase()
|
|
79
|
+
const body = section.slice(nlIdx + 1).trim()
|
|
80
|
+
|
|
81
|
+
if (heading.startsWith('core truth')) result.coreTruths = body
|
|
82
|
+
else if (heading.startsWith('boundar')) result.boundaries = body
|
|
83
|
+
else if (heading.startsWith('vibe')) result.vibe = body
|
|
84
|
+
else if (heading.startsWith('continuity')) result.continuity = body
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return result
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function serializeSoulMd(draft: PersonalityDraft['soul']): string {
|
|
91
|
+
const lines: string[] = ['# Soul', '']
|
|
92
|
+
if (draft.coreTruths) lines.push('## Core Truths', '', draft.coreTruths, '')
|
|
93
|
+
if (draft.boundaries) lines.push('## Boundaries', '', draft.boundaries, '')
|
|
94
|
+
if (draft.vibe) lines.push('## Vibe', '', draft.vibe, '')
|
|
95
|
+
if (draft.continuity) lines.push('## Continuity', '', draft.continuity, '')
|
|
96
|
+
return lines.join('\n')
|
|
97
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { api } from './api-client'
|
|
2
|
+
import type { Project } from '../types'
|
|
3
|
+
|
|
4
|
+
export const fetchProjects = () => api<Record<string, Project>>('GET', '/projects')
|
|
5
|
+
|
|
6
|
+
export const createProject = (data: Omit<Project, 'id' | 'createdAt' | 'updatedAt'>) =>
|
|
7
|
+
api<Project>('POST', '/projects', data)
|
|
8
|
+
|
|
9
|
+
export const updateProject = (id: string, data: Partial<Project>) =>
|
|
10
|
+
api<Project>('PUT', `/projects/${id}`, data)
|
|
11
|
+
|
|
12
|
+
export const deleteProject = (id: string) =>
|
|
13
|
+
api<string>('DELETE', `/projects/${id}`)
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** CLI providers that use their own tool execution — incompatible with LangGraph orchestration. */
|
|
2
|
+
export const NON_LANGGRAPH_PROVIDER_IDS = new Set(['claude-cli', 'codex-cli', 'opencode-cli'])
|
|
3
|
+
|
|
4
|
+
/** Providers with native tool/capability support (CLI providers + OpenClaw). */
|
|
5
|
+
export const NATIVE_CAPABILITY_PROVIDER_IDS = new Set(['claude-cli', 'codex-cli', 'opencode-cli', 'openclaw'])
|
|
@@ -23,10 +23,12 @@ function fileToContentBlocks(filePath: string): any[] {
|
|
|
23
23
|
return [{ type: 'text', text: `[Attached file: ${filePath.split('/').pop()}]` }]
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
export function streamAnthropicChat({ session, message, imagePath, apiKey, systemPrompt, write, active, loadHistory }: StreamChatOptions): Promise<string> {
|
|
26
|
+
export function streamAnthropicChat({ session, message, imagePath, apiKey, systemPrompt, write, active, loadHistory, onUsage }: StreamChatOptions): Promise<string> {
|
|
27
27
|
return new Promise((resolve) => {
|
|
28
28
|
const messages = buildMessages(session, message, imagePath, loadHistory)
|
|
29
29
|
const model = session.model || 'claude-sonnet-4-6'
|
|
30
|
+
let usageInput = 0
|
|
31
|
+
let usageOutput = 0
|
|
30
32
|
|
|
31
33
|
const body: Record<string, unknown> = {
|
|
32
34
|
model,
|
|
@@ -86,11 +88,22 @@ export function streamAnthropicChat({ session, message, imagePath, apiKey, syste
|
|
|
86
88
|
fullResponse += parsed.delta.text
|
|
87
89
|
write(`data: ${JSON.stringify({ t: 'd', text: parsed.delta.text })}\n\n`)
|
|
88
90
|
}
|
|
91
|
+
// message_start carries input token count
|
|
92
|
+
if (parsed.type === 'message_start' && parsed.message?.usage) {
|
|
93
|
+
usageInput = parsed.message.usage.input_tokens || 0
|
|
94
|
+
}
|
|
95
|
+
// message_delta carries output token count
|
|
96
|
+
if (parsed.type === 'message_delta' && parsed.usage) {
|
|
97
|
+
usageOutput = parsed.usage.output_tokens || 0
|
|
98
|
+
}
|
|
89
99
|
} catch {}
|
|
90
100
|
}
|
|
91
101
|
})
|
|
92
102
|
|
|
93
103
|
apiRes.on('end', () => {
|
|
104
|
+
if (onUsage && (usageInput > 0 || usageOutput > 0)) {
|
|
105
|
+
onUsage({ inputTokens: usageInput, outputTokens: usageOutput })
|
|
106
|
+
}
|
|
94
107
|
active.delete(session.id)
|
|
95
108
|
resolve(fullResponse)
|
|
96
109
|
})
|
|
@@ -13,6 +13,11 @@ export interface ProviderHandler {
|
|
|
13
13
|
streamChat: (opts: StreamChatOptions) => Promise<string>
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
export interface StreamChatUsage {
|
|
17
|
+
inputTokens: number
|
|
18
|
+
outputTokens: number
|
|
19
|
+
}
|
|
20
|
+
|
|
16
21
|
export interface StreamChatOptions {
|
|
17
22
|
session: any
|
|
18
23
|
message: string
|
|
@@ -22,6 +27,7 @@ export interface StreamChatOptions {
|
|
|
22
27
|
write: (data: string) => void
|
|
23
28
|
active: Map<string, any>
|
|
24
29
|
loadHistory: (sessionId: string) => any[]
|
|
30
|
+
onUsage?: (usage: StreamChatUsage) => void
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
interface BuiltinProviderConfig extends ProviderInfo {
|
|
@@ -6,7 +6,7 @@ import type { StreamChatOptions } from './index'
|
|
|
6
6
|
const IMAGE_EXTS = /\.(png|jpg|jpeg|gif|webp|bmp)$/i
|
|
7
7
|
const TEXT_EXTS = /\.(txt|md|csv|json|xml|html|js|ts|tsx|jsx|py|go|rs|java|c|cpp|h|yml|yaml|toml|env|log|sh|sql|css|scss)$/i
|
|
8
8
|
|
|
9
|
-
export function streamOllamaChat({ session, message, imagePath, apiKey, write, active, loadHistory }: StreamChatOptions): Promise<string> {
|
|
9
|
+
export function streamOllamaChat({ session, message, imagePath, apiKey, write, active, loadHistory, onUsage }: StreamChatOptions): Promise<string> {
|
|
10
10
|
return new Promise((resolve) => {
|
|
11
11
|
const messages = buildMessages(session, message, imagePath, loadHistory)
|
|
12
12
|
const model = session.model || 'llama3'
|
|
@@ -69,6 +69,14 @@ export function streamOllamaChat({ session, message, imagePath, apiKey, write, a
|
|
|
69
69
|
fullResponse += content
|
|
70
70
|
write(`data: ${JSON.stringify({ t: 'd', text: content })}\n\n`)
|
|
71
71
|
}
|
|
72
|
+
// Final chunk (done: true) carries token counts
|
|
73
|
+
if (parsed.done && onUsage) {
|
|
74
|
+
const input = parsed.prompt_eval_count || 0
|
|
75
|
+
const output = parsed.eval_count || 0
|
|
76
|
+
if (input > 0 || output > 0) {
|
|
77
|
+
onUsage({ inputTokens: input, outputTokens: output })
|
|
78
|
+
}
|
|
79
|
+
}
|
|
72
80
|
} catch {}
|
|
73
81
|
}
|
|
74
82
|
})
|
|
@@ -43,7 +43,7 @@ async function fileToContentParts(filePath: string): Promise<any[]> {
|
|
|
43
43
|
return [{ type: 'text', text: `[Attached file: ${name}]` }]
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
export function streamOpenAiChat({ session, message, imagePath, apiKey, systemPrompt, write, active, loadHistory }: StreamChatOptions): Promise<string> {
|
|
46
|
+
export function streamOpenAiChat({ session, message, imagePath, apiKey, systemPrompt, write, active, loadHistory, onUsage }: StreamChatOptions): Promise<string> {
|
|
47
47
|
return new Promise(async (resolve) => {
|
|
48
48
|
const messages = await buildMessages(session, message, imagePath, systemPrompt, loadHistory)
|
|
49
49
|
const model = session.model || 'gpt-4o'
|
|
@@ -52,6 +52,7 @@ export function streamOpenAiChat({ session, message, imagePath, apiKey, systemPr
|
|
|
52
52
|
model,
|
|
53
53
|
messages,
|
|
54
54
|
stream: true,
|
|
55
|
+
stream_options: { include_usage: true },
|
|
55
56
|
})
|
|
56
57
|
|
|
57
58
|
let fullResponse = ''
|
|
@@ -136,6 +137,13 @@ export function streamOpenAiChat({ session, message, imagePath, apiKey, systemPr
|
|
|
136
137
|
fullResponse += delta
|
|
137
138
|
write(`data: ${JSON.stringify({ t: 'd', text: delta })}\n\n`)
|
|
138
139
|
}
|
|
140
|
+
// Extract usage from the final chunk (stream_options: include_usage)
|
|
141
|
+
if (parsed.usage && onUsage) {
|
|
142
|
+
onUsage({
|
|
143
|
+
inputTokens: parsed.usage.prompt_tokens || 0,
|
|
144
|
+
outputTokens: parsed.usage.completion_tokens || 0,
|
|
145
|
+
})
|
|
146
|
+
}
|
|
139
147
|
} catch {}
|
|
140
148
|
}
|
|
141
149
|
}
|