@swarmclawai/swarmclaw 1.0.7 → 1.0.8
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 +6 -6
- package/package.json +1 -1
- package/src/app/api/learned-skills/route.ts +24 -0
- package/src/cli/index.js +7 -0
- package/src/components/agents/agent-chat-list.tsx +2 -0
- package/src/components/agents/agent-list.tsx +3 -1
- package/src/components/agents/agent-sheet.tsx +8 -2
- package/src/components/chat/message-list.tsx +1 -1
- package/src/components/chat/tool-events-section.test.ts +25 -0
- package/src/components/chat/tool-events-section.tsx +29 -19
- package/src/components/layout/dashboard-shell.tsx +9 -5
- package/src/components/settings/gateway-disconnect-overlay.tsx +13 -2
- package/src/components/shared/bottom-sheet.tsx +1 -1
- package/src/components/ui/dialog.tsx +1 -1
- package/src/components/ui/sheet.tsx +1 -1
- package/src/hooks/use-app-bootstrap.ts +36 -21
- package/src/lib/server/agents/agent-runtime-config.test.ts +27 -0
- package/src/lib/server/agents/agent-runtime-config.ts +12 -11
- package/src/lib/server/agents/agent-thread-session.test.ts +76 -1
- package/src/lib/server/agents/agent-thread-session.ts +20 -1
- package/src/lib/server/agents/main-agent-loop-advanced.test.ts +43 -8
- package/src/lib/server/agents/main-agent-loop.ts +21 -14
- package/src/lib/server/agents/task-session.ts +9 -2
- package/src/lib/server/build-llm.test.ts +117 -0
- package/src/lib/server/build-llm.ts +30 -9
- package/src/lib/server/chat-execution/chat-execution-session-sync.test.ts +92 -0
- package/src/lib/server/chat-execution/chat-execution.ts +10 -1
- package/src/lib/server/chat-execution/stream-agent-chat.test.ts +6 -0
- package/src/lib/server/chat-execution/stream-agent-chat.ts +8 -1
- package/src/lib/server/chatrooms/chatroom-helpers.test.ts +29 -0
- package/src/lib/server/chatrooms/chatroom-helpers.ts +11 -9
- package/src/lib/server/connectors/manager.ts +3 -0
- package/src/lib/server/provider-endpoint.ts +69 -0
- package/src/lib/server/runtime/session-run-manager.ts +12 -1
- package/src/lib/server/session-tools/context-mgmt.ts +7 -1
- package/src/lib/server/session-tools/skill-runtime.ts +3 -0
- package/src/lib/server/session-tools/skills.ts +3 -0
- package/src/lib/server/skills/learned-skills.test.ts +336 -0
- package/src/lib/server/skills/learned-skills.ts +719 -0
- package/src/lib/server/skills/runtime-skill-resolver.test.ts +70 -1
- package/src/lib/server/skills/runtime-skill-resolver.ts +71 -2
- package/src/lib/server/skills/skill-suggestions.ts +49 -5
- package/src/lib/server/storage.ts +14 -0
- package/src/stores/use-app-store.test.ts +11 -0
- package/src/stores/use-chat-store.test.ts +151 -0
- package/src/stores/use-chat-store.ts +31 -10
- package/src/types/index.ts +52 -0
package/README.md
CHANGED
|
@@ -17,13 +17,12 @@ Extension tutorial: https://swarmclaw.ai/docs/extension-tutorial
|
|
|
17
17
|
|
|
18
18
|
## Release Notes
|
|
19
19
|
|
|
20
|
-
### v1.0.
|
|
20
|
+
### v1.0.8 Highlights
|
|
21
21
|
|
|
22
|
-
- **
|
|
23
|
-
- **
|
|
24
|
-
- **
|
|
25
|
-
- **
|
|
26
|
-
- Breaking change: agent and session records now persist `tools` and `extensions` only. The legacy `plugins` field is no longer part of the runtime data model.
|
|
22
|
+
- **Learned skills and self-healing**: SwarmClaw now keeps agent-scoped learned skills and shadow revisions so repeated successes and repeated external integration failures can harden into reusable local behavior without silently mutating the shared skill library.
|
|
23
|
+
- **Direct chat stability**: chat-origin runs now stop after the visible answer instead of enqueueing hidden follow-up loops, leaked control tokens such as `NO_MESSAGE` stay out of the user transcript, and repeated internal reruns no longer replace the reply the user already saw.
|
|
24
|
+
- **OpenClaw and Ollama route hardening**: agent thread sessions now repair stale credential/endpoint resolution more aggressively, including Ollama Cloud vs local endpoint selection and OpenClaw gateway fallback behavior.
|
|
25
|
+
- **Operator UX fixes**: new agents appear in the list immediately, gateway-disconnected chat CTAs now route to the current agent's settings instead of global settings, setup-wizard flicker after access-key login is gone, and screenshot-heavy tool runs no longer render duplicate previews in chat.
|
|
27
26
|
|
|
28
27
|
## What SwarmClaw Focuses On
|
|
29
28
|
|
|
@@ -100,6 +99,7 @@ Then open `http://localhost:3456`.
|
|
|
100
99
|
- Or open **Skills** and use **Draft From Current Chat**.
|
|
101
100
|
- New agents keep **Conversation Skill Drafting** enabled by default, and you can switch it off per agent.
|
|
102
101
|
- SwarmClaw turns useful work into a **draft suggestion**, not a live self-modifying skill.
|
|
102
|
+
- Learned skills stay **user/agent scoped** by default. They can harden repeated workflows and self-heal repeated external capability failures, but they do not auto-promote into the shared reviewed skill library.
|
|
103
103
|
- Review the suggested name, rationale, summary, and transcript snippet.
|
|
104
104
|
- Approve it to save it into the normal skill library, or dismiss it.
|
|
105
105
|
- Runtime skill recommendations can use **keyword** or **embedding** ranking from **Settings → Memory & AI → Skills**.
|
package/package.json
CHANGED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
|
|
3
|
+
import { listLearnedSkills } from '@/lib/server/skills/learned-skills'
|
|
4
|
+
|
|
5
|
+
export const dynamic = 'force-dynamic'
|
|
6
|
+
|
|
7
|
+
export async function GET(req: Request) {
|
|
8
|
+
const url = new URL(req.url)
|
|
9
|
+
const agentId = url.searchParams.get('agentId') || undefined
|
|
10
|
+
const sessionId = url.searchParams.get('sessionId') || undefined
|
|
11
|
+
const lifecycle = url.searchParams.get('lifecycle') || undefined
|
|
12
|
+
|
|
13
|
+
return NextResponse.json(listLearnedSkills({
|
|
14
|
+
agentId,
|
|
15
|
+
sessionId,
|
|
16
|
+
lifecycle: lifecycle === 'candidate'
|
|
17
|
+
|| lifecycle === 'active'
|
|
18
|
+
|| lifecycle === 'shadow'
|
|
19
|
+
|| lifecycle === 'demoted'
|
|
20
|
+
|| lifecycle === 'review_ready'
|
|
21
|
+
? lifecycle
|
|
22
|
+
: undefined,
|
|
23
|
+
}))
|
|
24
|
+
}
|
package/src/cli/index.js
CHANGED
|
@@ -571,6 +571,13 @@ const COMMAND_GROUPS = [
|
|
|
571
571
|
cmd('openclaw-device', 'GET', '/setup/openclaw-device', 'Show the local OpenClaw device ID'),
|
|
572
572
|
],
|
|
573
573
|
},
|
|
574
|
+
{
|
|
575
|
+
name: 'learned-skills',
|
|
576
|
+
description: 'Inspect agent-scoped learned skills',
|
|
577
|
+
commands: [
|
|
578
|
+
cmd('list', 'GET', '/learned-skills', 'List learned skills'),
|
|
579
|
+
],
|
|
580
|
+
},
|
|
574
581
|
{
|
|
575
582
|
name: 'skills',
|
|
576
583
|
description: 'Manage reusable skills',
|
|
@@ -6,6 +6,7 @@ import { useChatStore } from '@/stores/use-chat-store'
|
|
|
6
6
|
import { useChatroomStore } from '@/stores/use-chatroom-store'
|
|
7
7
|
import { useNow } from '@/hooks/use-now'
|
|
8
8
|
import { useMountedRef } from '@/hooks/use-mounted-ref'
|
|
9
|
+
import { useWs } from '@/hooks/use-ws'
|
|
9
10
|
import { useNavigate } from '@/lib/app/navigation'
|
|
10
11
|
import { api } from '@/lib/app/api-client'
|
|
11
12
|
import { ConfirmDialog } from '@/components/shared/confirm-dialog'
|
|
@@ -86,6 +87,7 @@ export function AgentChatList({ inSidebar, onSelect }: Props) {
|
|
|
86
87
|
}, [])
|
|
87
88
|
|
|
88
89
|
useEffect(() => { loadAgents() }, [loadAgents])
|
|
90
|
+
useWs('agents', loadAgents, 30_000)
|
|
89
91
|
|
|
90
92
|
useEffect(() => {
|
|
91
93
|
return () => {
|
|
@@ -8,6 +8,7 @@ import { useApprovalStore } from '@/stores/use-approval-store'
|
|
|
8
8
|
import { Skeleton } from '@/components/shared/skeleton'
|
|
9
9
|
import { EmptyState } from '@/components/shared/empty-state'
|
|
10
10
|
import { getEnabledCapabilityIds } from '@/lib/capability-selection'
|
|
11
|
+
import { useWs } from '@/hooks/use-ws'
|
|
11
12
|
|
|
12
13
|
interface Props {
|
|
13
14
|
inSidebar?: boolean
|
|
@@ -47,7 +48,8 @@ export function AgentList({ inSidebar }: Props) {
|
|
|
47
48
|
}, [updateSettings])
|
|
48
49
|
|
|
49
50
|
const [loaded, setLoaded] = useState(Object.keys(agents).length > 0)
|
|
50
|
-
useEffect(() => { loadAgents().then(() => setLoaded(true)) }, [])
|
|
51
|
+
useEffect(() => { loadAgents().then(() => setLoaded(true)) }, [loadAgents])
|
|
52
|
+
useWs('agents', loadAgents, 30_000)
|
|
51
53
|
|
|
52
54
|
// Compute which agents are "running" (have active sessions)
|
|
53
55
|
const runningAgentIds = useMemo(() => {
|
|
@@ -42,6 +42,7 @@ const AUTO_SYNC_MODEL_PROVIDER_IDS = new Set<ProviderType>([
|
|
|
42
42
|
'fireworks',
|
|
43
43
|
'ollama',
|
|
44
44
|
])
|
|
45
|
+
const CONNECTION_TEST_TIMEOUT_MS = 40_000
|
|
45
46
|
|
|
46
47
|
type SafeAgentWallet = Omit<AgentWallet, 'encryptedPrivateKey'> & {
|
|
47
48
|
balanceAtomic?: string
|
|
@@ -156,6 +157,7 @@ export function AgentSheet() {
|
|
|
156
157
|
const setEditingId = useAppStore((s) => s.setEditingAgentId)
|
|
157
158
|
const agents = useAppStore((s) => s.agents)
|
|
158
159
|
const loadAgents = useAppStore((s) => s.loadAgents)
|
|
160
|
+
const updateAgentInStore = useAppStore((s) => s.updateAgentInStore)
|
|
159
161
|
const activeSessionId = useAppStore(selectActiveSessionId)
|
|
160
162
|
const currentSession = useAppStore((s) => {
|
|
161
163
|
const id = selectActiveSessionId(s)
|
|
@@ -697,11 +699,13 @@ export function AgentSheet() {
|
|
|
697
699
|
monthlyBudget: parsedMonthlyBudget && parsedMonthlyBudget > 0 ? parsedMonthlyBudget : null,
|
|
698
700
|
budgetAction: budgetEnabled ? budgetAction : undefined,
|
|
699
701
|
}
|
|
702
|
+
const savedAgent = editing
|
|
703
|
+
? await updateAgent(editing.id, data)
|
|
704
|
+
: await createAgent(data)
|
|
705
|
+
updateAgentInStore(savedAgent)
|
|
700
706
|
if (editing) {
|
|
701
|
-
await updateAgent(editing.id, data)
|
|
702
707
|
toast.success('Agent saved')
|
|
703
708
|
} else {
|
|
704
|
-
await createAgent(data)
|
|
705
709
|
toast.success('Agent created')
|
|
706
710
|
}
|
|
707
711
|
await loadAgents()
|
|
@@ -806,6 +810,8 @@ export function AgentSheet() {
|
|
|
806
810
|
credentialId,
|
|
807
811
|
endpoint: apiEndpoint,
|
|
808
812
|
model,
|
|
813
|
+
}, {
|
|
814
|
+
timeoutMs: CONNECTION_TEST_TIMEOUT_MS,
|
|
809
815
|
})
|
|
810
816
|
if (result.deviceId) setTestDeviceId(result.deviceId)
|
|
811
817
|
if (result.ok) {
|
|
@@ -838,7 +838,7 @@ export function MessageList({ messages, streaming, connectorFilter = null, loadi
|
|
|
838
838
|
)}
|
|
839
839
|
</div>
|
|
840
840
|
</div>
|
|
841
|
-
{showGatewayOverlay && <GatewayDisconnectOverlay />}
|
|
841
|
+
{showGatewayOverlay && <GatewayDisconnectOverlay agentId={agent?.id || null} />}
|
|
842
842
|
{showScrollToBottom && (
|
|
843
843
|
<button
|
|
844
844
|
onClick={handleScrollToBottom}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { describe, it } from 'node:test'
|
|
3
|
+
import { collectCollapsedMedia } from './tool-events-section'
|
|
4
|
+
|
|
5
|
+
describe('collectCollapsedMedia', () => {
|
|
6
|
+
const screenshotEvent = {
|
|
7
|
+
id: 'tool-1',
|
|
8
|
+
name: 'browser',
|
|
9
|
+
input: '{"action":"screenshot"}',
|
|
10
|
+
output: '',
|
|
11
|
+
status: 'done' as const,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
it('collects explicit screenshot media when enabled', () => {
|
|
15
|
+
const media = collectCollapsedMedia([screenshotEvent], { showCollapsedMedia: true })
|
|
16
|
+
|
|
17
|
+
assert.deepEqual(media?.images, ['/api/uploads/screenshot-123.png'])
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('skips collapsed media previews when disabled', () => {
|
|
21
|
+
const media = collectCollapsedMedia([screenshotEvent], { showCollapsedMedia: false })
|
|
22
|
+
|
|
23
|
+
assert.equal(media, null)
|
|
24
|
+
})
|
|
25
|
+
})
|
|
@@ -95,7 +95,33 @@ const ToolSummaryRow = memo(function ToolSummaryRow({ event, caption }: { event:
|
|
|
95
95
|
)
|
|
96
96
|
})
|
|
97
97
|
|
|
98
|
-
export
|
|
98
|
+
export function collectCollapsedMedia(toolEvents: ToolEvent[], opts?: { expanded?: boolean; showCollapsedMedia?: boolean }) {
|
|
99
|
+
if (opts?.expanded || opts?.showCollapsedMedia === false) return null
|
|
100
|
+
const seen = new Set<string>()
|
|
101
|
+
const images: string[] = []
|
|
102
|
+
const videos: string[] = []
|
|
103
|
+
const pdfs: { name: string; url: string }[] = []
|
|
104
|
+
const files: { name: string; url: string }[] = []
|
|
105
|
+
for (const ev of toolEvents) {
|
|
106
|
+
if (!ev.output) continue
|
|
107
|
+
if (!isExplicitScreenshot(ev.name, ev.input)) continue
|
|
108
|
+
const media = extractMedia(ev.output)
|
|
109
|
+
for (const url of media.images) { if (!seen.has(url)) { seen.add(url); images.push(url) } }
|
|
110
|
+
for (const url of media.videos) { if (!seen.has(url)) { seen.add(url); videos.push(url) } }
|
|
111
|
+
for (const pdf of media.pdfs) { if (!seen.has(pdf.url)) { seen.add(pdf.url); pdfs.push(pdf) } }
|
|
112
|
+
for (const file of media.files) { if (!seen.has(file.url)) { seen.add(file.url); files.push(file) } }
|
|
113
|
+
}
|
|
114
|
+
if (!images.length && !videos.length && !pdfs.length && !files.length) return null
|
|
115
|
+
return { images, videos, pdfs, files }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export const ToolEventsSection = memo(function ToolEventsSection({
|
|
119
|
+
toolEvents,
|
|
120
|
+
showCollapsedMedia = false,
|
|
121
|
+
}: {
|
|
122
|
+
toolEvents: ToolEvent[]
|
|
123
|
+
showCollapsedMedia?: boolean
|
|
124
|
+
}) {
|
|
99
125
|
const [expanded, setExpanded] = useState(false)
|
|
100
126
|
const summary = useMemo(() => {
|
|
101
127
|
let running = 0
|
|
@@ -125,24 +151,8 @@ export const ToolEventsSection = memo(function ToolEventsSection({ toolEvents }:
|
|
|
125
151
|
}, [spotlightEvent, toolEvents])
|
|
126
152
|
|
|
127
153
|
const collapsedMedia = useMemo(() => {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const images: string[] = []
|
|
131
|
-
const videos: string[] = []
|
|
132
|
-
const pdfs: { name: string; url: string }[] = []
|
|
133
|
-
const files: { name: string; url: string }[] = []
|
|
134
|
-
for (const ev of toolEvents) {
|
|
135
|
-
if (!ev.output) continue
|
|
136
|
-
if (!isExplicitScreenshot(ev.name, ev.input)) continue
|
|
137
|
-
const media = extractMedia(ev.output)
|
|
138
|
-
for (const url of media.images) { if (!seen.has(url)) { seen.add(url); images.push(url) } }
|
|
139
|
-
for (const url of media.videos) { if (!seen.has(url)) { seen.add(url); videos.push(url) } }
|
|
140
|
-
for (const pdf of media.pdfs) { if (!seen.has(pdf.url)) { seen.add(pdf.url); pdfs.push(pdf) } }
|
|
141
|
-
for (const file of media.files) { if (!seen.has(file.url)) { seen.add(file.url); files.push(file) } }
|
|
142
|
-
}
|
|
143
|
-
if (!images.length && !videos.length && !pdfs.length && !files.length) return null
|
|
144
|
-
return { images, videos, pdfs, files }
|
|
145
|
-
}, [expanded, toolEvents])
|
|
154
|
+
return collectCollapsedMedia(toolEvents, { expanded, showCollapsedMedia })
|
|
155
|
+
}, [expanded, showCollapsedMedia, toolEvents])
|
|
146
156
|
|
|
147
157
|
return (
|
|
148
158
|
<div className="max-w-[85%] md:max-w-[72%] mb-2" data-testid="tool-activity">
|
|
@@ -38,6 +38,7 @@ export function DashboardShell({ children }: { children: React.ReactNode }) {
|
|
|
38
38
|
authenticated,
|
|
39
39
|
setAuthenticated,
|
|
40
40
|
currentUser,
|
|
41
|
+
userReady,
|
|
41
42
|
setupDone,
|
|
42
43
|
agentReady
|
|
43
44
|
} = useAppBootstrap()
|
|
@@ -78,7 +79,9 @@ export function DashboardShell({ children }: { children: React.ReactNode }) {
|
|
|
78
79
|
? 'Restoring local session'
|
|
79
80
|
: !authChecked
|
|
80
81
|
? 'Checking access'
|
|
81
|
-
: authenticated &&
|
|
82
|
+
: authenticated && !userReady
|
|
83
|
+
? 'Restoring profile'
|
|
84
|
+
: authenticated && setupDone === null
|
|
82
85
|
? 'Loading setup state'
|
|
83
86
|
: authenticated && currentUser && !agentReady
|
|
84
87
|
? 'Restoring agent workspace'
|
|
@@ -109,15 +112,16 @@ export function DashboardShell({ children }: { children: React.ReactNode }) {
|
|
|
109
112
|
if (!hydrated || !authChecked) return
|
|
110
113
|
if (isAuthPage) {
|
|
111
114
|
// Reverse redirect: already authenticated with user → leave auth pages
|
|
112
|
-
if (authenticated && currentUser && setupDone !== false) {
|
|
115
|
+
if (authenticated && userReady && currentUser && setupDone !== false) {
|
|
113
116
|
router.replace('/home')
|
|
114
117
|
}
|
|
115
118
|
return
|
|
116
119
|
}
|
|
117
120
|
if (!authenticated) { router.replace('/login'); return }
|
|
118
|
-
if (!
|
|
121
|
+
if (!userReady) return
|
|
119
122
|
if (setupDone === false) { router.replace('/setup'); return }
|
|
120
|
-
|
|
123
|
+
if (!currentUser) { router.replace('/setup'); return }
|
|
124
|
+
}, [hydrated, authChecked, authenticated, currentUser, setupDone, router, isAuthPage, userReady])
|
|
121
125
|
|
|
122
126
|
// Star notification (one-time)
|
|
123
127
|
useEffect(() => {
|
|
@@ -235,7 +239,7 @@ export function DashboardShell({ children }: { children: React.ReactNode }) {
|
|
|
235
239
|
}
|
|
236
240
|
|
|
237
241
|
// Redirect happens in effect above; show loader while waiting
|
|
238
|
-
if (!authenticated || !currentUser || setupDone === null || !agentReady || setupDone === false) {
|
|
242
|
+
if (!authenticated || !userReady || !currentUser || setupDone === null || !agentReady || setupDone === false) {
|
|
239
243
|
return (
|
|
240
244
|
<FullScreenLoader
|
|
241
245
|
stage={bootStage}
|
|
@@ -49,9 +49,15 @@ export function useGatewayStatus() {
|
|
|
49
49
|
return status
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
interface GatewayDisconnectOverlayProps {
|
|
53
|
+
agentId?: string | null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function GatewayDisconnectOverlay({ agentId = null }: GatewayDisconnectOverlayProps) {
|
|
53
57
|
const navigateTo = useNavigate()
|
|
54
58
|
const setSidebarOpen = useAppStore((s) => s.setSidebarOpen)
|
|
59
|
+
const setAgentSheetOpen = useAppStore((s) => s.setAgentSheetOpen)
|
|
60
|
+
const setEditingAgentId = useAppStore((s) => s.setEditingAgentId)
|
|
55
61
|
|
|
56
62
|
return (
|
|
57
63
|
<div className="absolute inset-0 z-10 flex items-center justify-center bg-bg/60 backdrop-blur-sm">
|
|
@@ -67,13 +73,18 @@ export function GatewayDisconnectOverlay() {
|
|
|
67
73
|
</div>
|
|
68
74
|
<button
|
|
69
75
|
onClick={() => {
|
|
76
|
+
if (agentId) {
|
|
77
|
+
setEditingAgentId(agentId)
|
|
78
|
+
setAgentSheetOpen(true)
|
|
79
|
+
return
|
|
80
|
+
}
|
|
70
81
|
navigateTo('settings')
|
|
71
82
|
setSidebarOpen(true)
|
|
72
83
|
}}
|
|
73
84
|
className="px-5 py-2 rounded-[10px] border-none bg-accent-bright text-white text-[13px] font-600 cursor-pointer transition-all hover:brightness-110"
|
|
74
85
|
style={{ fontFamily: 'inherit' }}
|
|
75
86
|
>
|
|
76
|
-
|
|
87
|
+
{agentId ? 'Open Agent Settings' : 'Open Settings'}
|
|
77
88
|
</button>
|
|
78
89
|
</div>
|
|
79
90
|
</div>
|
|
@@ -40,7 +40,7 @@ export function BottomSheet({ open, onClose, children, wide, title, description
|
|
|
40
40
|
</DialogPrimitive.Description>
|
|
41
41
|
) : null}
|
|
42
42
|
<DialogPrimitive.Close
|
|
43
|
-
className="absolute right-4 top-3.5 inline-flex h-9 w-9 items-center justify-center rounded-[12px] border border-white/[0.06] bg-white/[0.03] text-text-3 transition-all hover:bg-white/[0.06] hover:text-text-2 focus:outline-none focus:ring-2 focus:ring-accent-bright/30 sm:right-5 sm:top-5"
|
|
43
|
+
className="absolute right-4 top-3.5 inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-[12px] border border-white/[0.06] bg-white/[0.03] text-text-3 transition-all hover:bg-white/[0.06] hover:text-text-2 focus:outline-none focus:ring-2 focus:ring-accent-bright/30 sm:right-5 sm:top-5"
|
|
44
44
|
>
|
|
45
45
|
<XIcon className="size-4" />
|
|
46
46
|
<span className="sr-only">Close</span>
|
|
@@ -71,7 +71,7 @@ function DialogContent({
|
|
|
71
71
|
{showCloseButton && (
|
|
72
72
|
<DialogPrimitive.Close
|
|
73
73
|
data-slot="dialog-close"
|
|
74
|
-
className="absolute top-4 right-4 inline-flex h-9 w-9 items-center justify-center rounded-[12px] border border-white/[0.06] bg-white/[0.03] text-text-3 transition-all hover:bg-white/[0.06] hover:text-text-2 focus:outline-none focus:ring-2 focus:ring-accent-bright/30 disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
|
74
|
+
className="absolute top-4 right-4 inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-[12px] border border-white/[0.06] bg-white/[0.03] text-text-3 transition-all hover:bg-white/[0.06] hover:text-text-2 focus:outline-none focus:ring-2 focus:ring-accent-bright/30 disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
|
75
75
|
>
|
|
76
76
|
<XIcon />
|
|
77
77
|
<span className="sr-only">Close</span>
|
|
@@ -76,7 +76,7 @@ function SheetContent({
|
|
|
76
76
|
>
|
|
77
77
|
{children}
|
|
78
78
|
{showCloseButton && (
|
|
79
|
-
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
|
79
|
+
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 cursor-pointer rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
|
80
80
|
<XIcon className="size-4" />
|
|
81
81
|
<span className="sr-only">Close</span>
|
|
82
82
|
</SheetPrimitive.Close>
|
|
@@ -29,6 +29,7 @@ export function useAppBootstrap() {
|
|
|
29
29
|
const [authChecked, setAuthChecked] = useState(false)
|
|
30
30
|
const [authenticated, setAuthenticated] = useState(false)
|
|
31
31
|
const [setupDone, setSetupDone] = useState<boolean | null>(null)
|
|
32
|
+
const [userReady, setUserReady] = useState(false)
|
|
32
33
|
const [agentReady, setAgentReady] = useState(false)
|
|
33
34
|
const mountedRef = useMountedRef()
|
|
34
35
|
|
|
@@ -86,21 +87,6 @@ export function useAppBootstrap() {
|
|
|
86
87
|
}
|
|
87
88
|
}, [mountedRef])
|
|
88
89
|
|
|
89
|
-
const syncUserFromServer = useCallback(async () => {
|
|
90
|
-
if (currentUser) return
|
|
91
|
-
try {
|
|
92
|
-
const settings = await api<{ userName?: string }>('GET', '/settings', undefined, {
|
|
93
|
-
timeoutMs: POST_AUTH_BOOTSTRAP_TIMEOUT_MS,
|
|
94
|
-
retries: 0,
|
|
95
|
-
})
|
|
96
|
-
if (settings.userName) {
|
|
97
|
-
setUser(settings.userName)
|
|
98
|
-
}
|
|
99
|
-
} catch (err) {
|
|
100
|
-
console.warn('Failed to sync user from server:', err)
|
|
101
|
-
}
|
|
102
|
-
}, [currentUser, setUser])
|
|
103
|
-
|
|
104
90
|
useEffect(() => {
|
|
105
91
|
hydrate()
|
|
106
92
|
}, [hydrate])
|
|
@@ -119,20 +105,48 @@ export function useAppBootstrap() {
|
|
|
119
105
|
if (hydrated) checkAuth()
|
|
120
106
|
}, [hydrated, checkAuth])
|
|
121
107
|
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (!authenticated) {
|
|
110
|
+
setUserReady(false)
|
|
111
|
+
setAgentReady(false)
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
let cancelled = false
|
|
115
|
+
;(async () => {
|
|
116
|
+
if (currentUser) {
|
|
117
|
+
if (!cancelled && mountedRef.current) setUserReady(true)
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
const settings = await api<{ userName?: string }>('GET', '/settings', undefined, {
|
|
122
|
+
timeoutMs: POST_AUTH_BOOTSTRAP_TIMEOUT_MS,
|
|
123
|
+
retries: 0,
|
|
124
|
+
})
|
|
125
|
+
if (settings.userName) {
|
|
126
|
+
setUser(settings.userName)
|
|
127
|
+
}
|
|
128
|
+
} catch (err) {
|
|
129
|
+
console.warn('Failed to sync user from server:', err)
|
|
130
|
+
} finally {
|
|
131
|
+
if (!cancelled && mountedRef.current) setUserReady(true)
|
|
132
|
+
}
|
|
133
|
+
})()
|
|
134
|
+
return () => { cancelled = true }
|
|
135
|
+
}, [authenticated, currentUser, mountedRef, setUser])
|
|
136
|
+
|
|
122
137
|
useEffect(() => {
|
|
123
138
|
if (!authenticated) return
|
|
124
139
|
connectWs()
|
|
125
|
-
syncUserFromServer()
|
|
126
140
|
loadNetworkInfo()
|
|
127
141
|
loadSettings()
|
|
128
142
|
loadSessions()
|
|
129
143
|
return () => { disconnectWs() }
|
|
130
|
-
}, [authenticated, loadNetworkInfo, loadSessions, loadSettings
|
|
144
|
+
}, [authenticated, loadNetworkInfo, loadSessions, loadSettings])
|
|
131
145
|
|
|
132
146
|
useWs('sessions', loadSessions, 15000)
|
|
133
147
|
|
|
134
148
|
useEffect(() => {
|
|
135
|
-
if (!authenticated || !currentUser) return
|
|
149
|
+
if (!authenticated || !userReady || !currentUser) return
|
|
136
150
|
let cancelled = false
|
|
137
151
|
;(async () => {
|
|
138
152
|
try {
|
|
@@ -159,10 +173,10 @@ export function useAppBootstrap() {
|
|
|
159
173
|
if (!cancelled && mountedRef.current) setAgentReady(true)
|
|
160
174
|
})()
|
|
161
175
|
return () => { cancelled = true }
|
|
162
|
-
}, [authenticated, currentUser, mountedRef])
|
|
176
|
+
}, [authenticated, currentUser, mountedRef, userReady])
|
|
163
177
|
|
|
164
178
|
useEffect(() => {
|
|
165
|
-
if (!authenticated || !
|
|
179
|
+
if (!authenticated || !userReady) return
|
|
166
180
|
let cancelled = false
|
|
167
181
|
;(async () => {
|
|
168
182
|
try {
|
|
@@ -190,7 +204,7 @@ export function useAppBootstrap() {
|
|
|
190
204
|
}
|
|
191
205
|
})()
|
|
192
206
|
return () => { cancelled = true }
|
|
193
|
-
}, [authenticated,
|
|
207
|
+
}, [authenticated, mountedRef, userReady])
|
|
194
208
|
|
|
195
209
|
return {
|
|
196
210
|
hydrated,
|
|
@@ -198,6 +212,7 @@ export function useAppBootstrap() {
|
|
|
198
212
|
authenticated,
|
|
199
213
|
setAuthenticated,
|
|
200
214
|
currentUser,
|
|
215
|
+
userReady,
|
|
201
216
|
setupDone,
|
|
202
217
|
setSetupDone,
|
|
203
218
|
agentReady
|
|
@@ -139,3 +139,30 @@ test('applyResolvedRoute copies gateway, endpoint, and fallback credentials onto
|
|
|
139
139
|
gatewayProfileId: 'gateway-1',
|
|
140
140
|
})
|
|
141
141
|
})
|
|
142
|
+
|
|
143
|
+
test('resolveAgentRouteCandidatesWithProfiles repairs a stale Ollama credential reference when only one provider credential exists', async () => {
|
|
144
|
+
const storage = await import('@/lib/server/storage')
|
|
145
|
+
const now = Date.now()
|
|
146
|
+
storage.saveCredentials({
|
|
147
|
+
'cred-ollama-cloud': {
|
|
148
|
+
id: 'cred-ollama-cloud',
|
|
149
|
+
provider: 'ollama',
|
|
150
|
+
name: 'Ollama Cloud',
|
|
151
|
+
encryptedKey: storage.encryptKey('ollama-cloud-key'),
|
|
152
|
+
createdAt: now,
|
|
153
|
+
},
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
const [route] = resolveAgentRouteCandidatesWithProfiles(makeAgent({
|
|
157
|
+
provider: 'ollama',
|
|
158
|
+
model: 'glm-5:cloud',
|
|
159
|
+
credentialId: 'stale-ollama-cred',
|
|
160
|
+
apiEndpoint: null,
|
|
161
|
+
}), [])
|
|
162
|
+
|
|
163
|
+
assert.ok(route)
|
|
164
|
+
assert.equal(route.provider, 'ollama')
|
|
165
|
+
assert.equal(route.model, 'glm-5:cloud')
|
|
166
|
+
assert.equal(route.credentialId, 'cred-ollama-cloud')
|
|
167
|
+
assert.equal(route.apiEndpoint, 'https://ollama.com')
|
|
168
|
+
})
|
|
@@ -6,7 +6,7 @@ import type {
|
|
|
6
6
|
ProviderType,
|
|
7
7
|
} from '@/types'
|
|
8
8
|
import { deriveOpenClawWsUrl, normalizeProviderEndpoint } from '@/lib/openclaw/openclaw-endpoint'
|
|
9
|
-
import {
|
|
9
|
+
import { resolveProviderApiEndpoint, resolveProviderCredentialId } from '@/lib/server/provider-endpoint'
|
|
10
10
|
import { loadGatewayProfiles } from '@/lib/server/storage'
|
|
11
11
|
import { isProviderCoolingDown } from '@/lib/server/provider-health'
|
|
12
12
|
|
|
@@ -250,12 +250,6 @@ function dedupeCredentialIds(primary: string | null | undefined, candidates: str
|
|
|
250
250
|
return result
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
-
function resolveProviderDefaultEndpoint(provider: string): string | null {
|
|
254
|
-
const info = getProvider(provider)
|
|
255
|
-
if (!info?.defaultEndpoint) return null
|
|
256
|
-
return normalizeProviderEndpoint(provider, info.defaultEndpoint) || info.defaultEndpoint.replace(/\/+$/, '')
|
|
257
|
-
}
|
|
258
|
-
|
|
259
253
|
function buildRouteFromSeed(
|
|
260
254
|
seed: RouteSeed,
|
|
261
255
|
gatewayProfiles: GatewayProfile[],
|
|
@@ -276,13 +270,20 @@ function buildRouteFromSeed(
|
|
|
276
270
|
const gatewayProfileId = gatewayProfile?.id ?? seed.gatewayProfileId ?? agentGatewayProfileId ?? null
|
|
277
271
|
|
|
278
272
|
const providerFromGateway = gatewayProfile?.provider === 'openclaw' ? 'openclaw' : provider
|
|
279
|
-
const explicitEndpoint = seed.apiEndpoint ?? gatewayProfile?.endpoint ?? null
|
|
280
|
-
const apiEndpoint = normalizeProviderEndpoint(providerFromGateway, explicitEndpoint)
|
|
281
|
-
?? resolveProviderDefaultEndpoint(providerFromGateway)
|
|
282
273
|
const model = (seed.model || '').trim() || (providerFromGateway === 'openclaw' ? DEFAULT_OPENCLAW_MODEL : '')
|
|
283
274
|
if (!providerFromGateway || !model) return null
|
|
284
275
|
|
|
285
|
-
const credentialId =
|
|
276
|
+
const credentialId = resolveProviderCredentialId({
|
|
277
|
+
provider: providerFromGateway,
|
|
278
|
+
credentialId: seed.credentialId ?? gatewayProfile?.credentialId ?? null,
|
|
279
|
+
})
|
|
280
|
+
const explicitEndpoint = seed.apiEndpoint ?? gatewayProfile?.endpoint ?? null
|
|
281
|
+
const apiEndpoint = resolveProviderApiEndpoint({
|
|
282
|
+
provider: providerFromGateway,
|
|
283
|
+
model,
|
|
284
|
+
credentialId,
|
|
285
|
+
apiEndpoint: explicitEndpoint,
|
|
286
|
+
})
|
|
286
287
|
return {
|
|
287
288
|
id: seed.id,
|
|
288
289
|
label: seed.label?.trim() || (gatewayProfile?.name || `${providerFromGateway}:${model}`),
|
|
@@ -86,7 +86,6 @@ describe('ensureAgentThreadSession', () => {
|
|
|
86
86
|
assert.equal(output.session.memoryScopeMode, 'agent')
|
|
87
87
|
assert.equal(output.session.memoryTierPreference, 'blended')
|
|
88
88
|
assert.equal(output.session.projectId, 'proj-1')
|
|
89
|
-
assert.deepEqual(output.session.plugins, ['memory', 'web_search'])
|
|
90
89
|
})
|
|
91
90
|
|
|
92
91
|
it('does not create a new shortcut chat when the agent is disabled', () => {
|
|
@@ -238,4 +237,80 @@ describe('ensureAgentThreadSession', () => {
|
|
|
238
237
|
|
|
239
238
|
assert.equal(output.connectorContext, null)
|
|
240
239
|
})
|
|
240
|
+
|
|
241
|
+
it('repairs an existing Ollama Cloud thread session away from a stale local endpoint', () => {
|
|
242
|
+
const output = runWithTempDataDir(`
|
|
243
|
+
const storageMod = await import('@/lib/server/storage')
|
|
244
|
+
const storage = storageMod.default || storageMod['module.exports'] || storageMod
|
|
245
|
+
const helperMod = await import('@/lib/server/agents/agent-thread-session')
|
|
246
|
+
const ensureAgentThreadSession = helperMod.ensureAgentThreadSession
|
|
247
|
+
|| helperMod.default?.ensureAgentThreadSession
|
|
248
|
+
|| helperMod['module.exports']?.ensureAgentThreadSession
|
|
249
|
+
|
|
250
|
+
const now = Date.now()
|
|
251
|
+
storage.saveCredentials({
|
|
252
|
+
'cred-ollama-cloud': {
|
|
253
|
+
id: 'cred-ollama-cloud',
|
|
254
|
+
provider: 'ollama',
|
|
255
|
+
name: 'Ollama Cloud',
|
|
256
|
+
encryptedKey: storage.encryptKey('ollama-cloud-key'),
|
|
257
|
+
createdAt: now,
|
|
258
|
+
},
|
|
259
|
+
})
|
|
260
|
+
storage.saveAgents({
|
|
261
|
+
hal: {
|
|
262
|
+
id: 'hal',
|
|
263
|
+
name: 'Hal2k',
|
|
264
|
+
provider: 'ollama',
|
|
265
|
+
model: 'glm-5:cloud',
|
|
266
|
+
credentialId: 'cred-ollama-cloud',
|
|
267
|
+
apiEndpoint: null,
|
|
268
|
+
fallbackCredentialIds: [],
|
|
269
|
+
heartbeatEnabled: true,
|
|
270
|
+
heartbeatIntervalSec: 600,
|
|
271
|
+
threadSessionId: 'agent-chat-hal-existing',
|
|
272
|
+
createdAt: now,
|
|
273
|
+
updatedAt: now,
|
|
274
|
+
},
|
|
275
|
+
})
|
|
276
|
+
storage.saveSessions({
|
|
277
|
+
'agent-chat-hal-existing': {
|
|
278
|
+
id: 'agent-chat-hal-existing',
|
|
279
|
+
name: 'Hal2k',
|
|
280
|
+
cwd: process.env.WORKSPACE_DIR,
|
|
281
|
+
user: 'default',
|
|
282
|
+
provider: 'ollama',
|
|
283
|
+
model: 'glm-5:cloud',
|
|
284
|
+
credentialId: 'cred-ollama-cloud',
|
|
285
|
+
apiEndpoint: 'http://localhost:11434',
|
|
286
|
+
claudeSessionId: null,
|
|
287
|
+
codexThreadId: null,
|
|
288
|
+
opencodeSessionId: null,
|
|
289
|
+
delegateResumeIds: { claudeCode: null, codex: null, opencode: null, gemini: null },
|
|
290
|
+
messages: [],
|
|
291
|
+
createdAt: now,
|
|
292
|
+
lastActiveAt: now,
|
|
293
|
+
sessionType: 'human',
|
|
294
|
+
agentId: 'hal',
|
|
295
|
+
shortcutForAgentId: 'hal',
|
|
296
|
+
},
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
const session = ensureAgentThreadSession('hal')
|
|
300
|
+
const persisted = storage.loadSessions()[session.id]
|
|
301
|
+
const healedAgent = storage.loadAgents().hal
|
|
302
|
+
|
|
303
|
+
console.log(JSON.stringify({
|
|
304
|
+
sessionId: session.id,
|
|
305
|
+
apiEndpoint: persisted.apiEndpoint || null,
|
|
306
|
+
credentialId: persisted.credentialId || null,
|
|
307
|
+
agentCredentialId: healedAgent?.credentialId || null,
|
|
308
|
+
}))
|
|
309
|
+
`)
|
|
310
|
+
|
|
311
|
+
assert.equal(output.sessionId, 'agent-chat-hal-existing')
|
|
312
|
+
assert.equal(output.credentialId, 'cred-ollama-cloud')
|
|
313
|
+
assert.equal(output.agentCredentialId, 'cred-ollama-cloud')
|
|
314
|
+
assert.equal(output.apiEndpoint, 'https://ollama.com')
|
|
315
|
+
})
|
|
241
316
|
})
|