@swarmclawai/swarmclaw 1.3.6 → 1.4.2
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 +16 -52
- package/next.config.ts +9 -4
- package/package.json +18 -10
- package/scripts/build-bootstrap-env.mjs +24 -0
- package/scripts/run-next-build.mjs +74 -0
- package/scripts/run-next-typegen.mjs +61 -0
- package/src/app/api/.well-known/agent-card/route.ts +46 -0
- package/src/app/api/a2a/route.ts +56 -0
- package/src/app/api/a2a/tasks/[taskId]/status/route.ts +49 -0
- package/src/app/api/approvals/route.test.ts +29 -3
- package/src/app/api/approvals/route.ts +13 -7
- package/src/app/api/chats/[id]/chat/route.test.ts +64 -0
- package/src/app/api/chats/[id]/chat/route.ts +24 -8
- package/src/app/api/chats/[id]/deploy/route.ts +2 -2
- package/src/app/api/chats/chat-route.test.ts +68 -0
- package/src/app/api/connectors/[id]/doctor/route.test.ts +97 -0
- package/src/app/api/connectors/[id]/doctor/route.ts +26 -1
- package/src/app/api/connectors/connector-doctor-route.test.ts +1 -0
- package/src/app/api/logs/route.test.ts +61 -0
- package/src/app/api/logs/route.ts +35 -0
- package/src/app/api/openclaw/sync/route.ts +1 -1
- package/src/app/api/swarmfeed/channels/route.ts +14 -0
- package/src/app/api/swarmfeed/posts/route.ts +60 -0
- package/src/app/api/swarmfeed/route.ts +37 -0
- package/src/app/api/tts/route.test.ts +82 -0
- package/src/app/api/tts/route.ts +13 -6
- package/src/app/api/tts/stream/route.ts +12 -5
- package/src/app/error.tsx +32 -0
- package/src/app/global-error.tsx +33 -0
- package/src/app/protocols/builder/[templateId]/page.tsx +93 -0
- package/src/app/protocols/page.tsx +16 -7
- package/src/app/swarmfeed/page.tsx +7 -0
- package/src/cli/index.js +22 -0
- package/src/cli/spec.js +9 -0
- package/src/components/agents/agent-avatar.tsx +2 -5
- package/src/components/agents/agent-sheet.tsx +10 -0
- package/src/components/auth/access-key-gate.tsx +25 -0
- package/src/components/layout/error-boundary.tsx +12 -30
- package/src/components/layout/error-fallback.tsx +61 -0
- package/src/components/layout/sidebar-rail.tsx +52 -0
- package/src/components/protocols/builder/edge-editor.tsx +43 -0
- package/src/components/protocols/builder/edge-types/branch-edge.tsx +33 -0
- package/src/components/protocols/builder/edge-types/default-edge.tsx +18 -0
- package/src/components/protocols/builder/edge-types/index.ts +3 -0
- package/src/components/protocols/builder/edge-types/loop-edge.tsx +19 -0
- package/src/components/protocols/builder/node-inspector.tsx +227 -0
- package/src/components/protocols/builder/node-palette.tsx +97 -0
- package/src/components/protocols/builder/node-types/branch-node.tsx +34 -0
- package/src/components/protocols/builder/node-types/complete-node.tsx +17 -0
- package/src/components/protocols/builder/node-types/for-each-node.tsx +21 -0
- package/src/components/protocols/builder/node-types/index.ts +9 -0
- package/src/components/protocols/builder/node-types/join-node.tsx +18 -0
- package/src/components/protocols/builder/node-types/loop-node.tsx +22 -0
- package/src/components/protocols/builder/node-types/parallel-node.tsx +31 -0
- package/src/components/protocols/builder/node-types/phase-node.tsx +52 -0
- package/src/components/protocols/builder/node-types/subflow-node.tsx +23 -0
- package/src/components/protocols/builder/node-types/swarm-node.tsx +26 -0
- package/src/components/protocols/builder/protocol-builder-canvas.tsx +184 -0
- package/src/components/protocols/builder/run-overlay.tsx +29 -0
- package/src/components/protocols/builder/template-gallery.tsx +53 -0
- package/src/components/protocols/builder/validation-panel.tsx +57 -0
- package/src/components/skills/skills-workspace.tsx +1 -9
- package/src/features/protocols/builder/hooks/index.ts +2 -0
- package/src/features/protocols/builder/hooks/use-canvas-validation.ts +14 -0
- package/src/features/protocols/builder/hooks/use-run-overlay.ts +39 -0
- package/src/features/protocols/builder/hooks/use-template-sync.ts +45 -0
- package/src/features/protocols/builder/protocol-builder-store.ts +233 -0
- package/src/features/protocols/builder/utils/node-position-layout.ts +41 -0
- package/src/features/protocols/builder/utils/nodes-to-template.test.ts +179 -0
- package/src/features/protocols/builder/utils/nodes-to-template.ts +49 -0
- package/src/features/protocols/builder/utils/template-to-nodes.test.ts +314 -0
- package/src/features/protocols/builder/utils/template-to-nodes.ts +169 -0
- package/src/features/protocols/builder/validators/dag-validator.test.ts +150 -0
- package/src/features/protocols/builder/validators/dag-validator.ts +119 -0
- package/src/features/swarmfeed/agent-social-settings.tsx +277 -0
- package/src/features/swarmfeed/compose-post.tsx +139 -0
- package/src/features/swarmfeed/feed-page.tsx +136 -0
- package/src/features/swarmfeed/post-card.tsx +114 -0
- package/src/features/swarmfeed/queries.ts +28 -0
- package/src/lib/a2a/agent-card.ts +61 -0
- package/src/lib/a2a/auth.ts +54 -0
- package/src/lib/a2a/client.ts +133 -0
- package/src/lib/a2a/discovery.ts +116 -0
- package/src/lib/a2a/handlers.ts +176 -0
- package/src/lib/a2a/json-rpc-router.ts +38 -0
- package/src/lib/a2a/types.ts +95 -0
- package/src/lib/app/navigation.ts +1 -0
- package/src/lib/app/report-client-error.ts +52 -0
- package/src/lib/app/view-constants.ts +9 -1
- package/src/lib/providers/anthropic.ts +119 -107
- package/src/lib/providers/ollama.ts +34 -14
- package/src/lib/providers/openai.ts +154 -142
- package/src/lib/providers/openclaw.ts +3 -3
- package/src/lib/server/agents/main-agent-loop.test.ts +94 -0
- package/src/lib/server/agents/main-agent-loop.ts +377 -41
- package/src/lib/server/chat-execution/chat-execution.ts +12 -7
- package/src/lib/server/chat-execution/chat-turn-preparation.ts +19 -12
- package/src/lib/server/connectors/swarmdock.ts +1 -1
- package/src/lib/server/extensions.ts +11 -0
- package/src/lib/server/messages/message-repository.ts +31 -0
- package/src/lib/server/openclaw/sync.ts +4 -4
- package/src/lib/server/protocols/protocol-a2a-delegate.ts +135 -0
- package/src/lib/server/protocols/protocol-normalization.ts +1 -0
- package/src/lib/server/protocols/protocol-step-helpers.test.ts +1 -1
- package/src/lib/server/protocols/protocol-step-helpers.ts +1 -0
- package/src/lib/server/protocols/protocol-step-processors.ts +2 -0
- package/src/lib/server/protocols/protocol-types.ts +1 -0
- package/src/lib/server/provider-health.ts +19 -3
- package/src/lib/server/safe-parse-body.test.ts +32 -0
- package/src/lib/server/safe-parse-body.ts +20 -3
- package/src/lib/server/session-tools/delegate.ts +151 -77
- package/src/lib/server/storage-auth.ts +10 -2
- package/src/lib/server/storage-normalization.ts +11 -0
- package/src/lib/server/storage.ts +113 -4
- package/src/lib/server/working-state/service.test.ts +2 -3
- package/src/lib/server/working-state/service.ts +37 -6
- package/src/lib/swarmfeed-client.ts +157 -0
- package/src/lib/validation/schemas.ts +1 -1
- package/src/stores/slices/data-slice.ts +3 -0
- package/src/stores/use-approval-store.ts +4 -1
- package/src/types/agent.ts +31 -1
- package/src/types/index.ts +1 -0
- package/src/types/protocol.ts +19 -0
- package/src/types/session.ts +1 -1
- package/src/types/swarmfeed.ts +30 -0
- package/tsconfig.json +1 -2
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { hmrSingleton, errorMessage } from '@/lib/shared-utils'
|
|
2
|
+
import type { A2AMethod, A2AMethodHandler, A2AContext, JsonRpcRequest, JsonRpcResponse } from './types'
|
|
3
|
+
import { JSON_RPC_ERRORS } from './types'
|
|
4
|
+
|
|
5
|
+
export class JsonRpcRouter {
|
|
6
|
+
private handlers = new Map<string, A2AMethodHandler>()
|
|
7
|
+
|
|
8
|
+
register(method: A2AMethod | string, handler: A2AMethodHandler): void {
|
|
9
|
+
this.handlers.set(method, handler)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async route(request: JsonRpcRequest, context: A2AContext): Promise<JsonRpcResponse> {
|
|
13
|
+
const handler = this.handlers.get(request.method)
|
|
14
|
+
if (!handler) {
|
|
15
|
+
return {
|
|
16
|
+
jsonrpc: '2.0',
|
|
17
|
+
error: { code: JSON_RPC_ERRORS.METHOD_NOT_FOUND, message: 'Method not found', data: { method: request.method } },
|
|
18
|
+
id: request.id,
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const result = await handler(request.params ?? {}, context)
|
|
23
|
+
return { jsonrpc: '2.0', result, id: request.id }
|
|
24
|
+
} catch (err) {
|
|
25
|
+
return {
|
|
26
|
+
jsonrpc: '2.0',
|
|
27
|
+
error: { code: JSON_RPC_ERRORS.INTERNAL_ERROR, message: errorMessage(err) },
|
|
28
|
+
id: request.id,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
listMethods(): string[] {
|
|
34
|
+
return [...this.handlers.keys()]
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const a2aRouter = hmrSingleton('a2a_jsonrpc_router', () => new JsonRpcRouter())
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
// --- A2A Agent Card ---
|
|
4
|
+
// Ref: https://a2a-protocol.org/v0.3.0/specification/#agent-card
|
|
5
|
+
|
|
6
|
+
export const AgentCardCapabilitySchema = z.object({
|
|
7
|
+
name: z.string(),
|
|
8
|
+
methods: z.array(z.string()).optional(),
|
|
9
|
+
description: z.string().optional(),
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
export const AgentCardSkillSchema = z.object({
|
|
13
|
+
name: z.string(),
|
|
14
|
+
description: z.string().optional(),
|
|
15
|
+
parameters: z.record(z.string(), z.unknown()).optional(),
|
|
16
|
+
returns: z.record(z.string(), z.unknown()).optional(),
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
export const AgentCardSchema = z.object({
|
|
20
|
+
name: z.string(),
|
|
21
|
+
description: z.string(),
|
|
22
|
+
version: z.string(),
|
|
23
|
+
protocolVersion: z.string().default('0.3.0'),
|
|
24
|
+
apiEndpoint: z.string().url(),
|
|
25
|
+
capabilities: z.array(AgentCardCapabilitySchema).default([]),
|
|
26
|
+
skills: z.array(AgentCardSkillSchema).default([]),
|
|
27
|
+
authMethods: z.array(z.enum(['api_key', 'ed25519', 'oauth2'])).default(['api_key']),
|
|
28
|
+
supportsStreaming: z.boolean().default(true),
|
|
29
|
+
supportsAsync: z.boolean().default(true),
|
|
30
|
+
rateLimit: z.object({
|
|
31
|
+
requestsPerMinute: z.number().optional(),
|
|
32
|
+
maxConcurrentRequests: z.number().optional(),
|
|
33
|
+
}).optional(),
|
|
34
|
+
extensions: z.array(z.object({
|
|
35
|
+
name: z.string(),
|
|
36
|
+
version: z.string(),
|
|
37
|
+
url: z.string().url().optional(),
|
|
38
|
+
})).default([]),
|
|
39
|
+
tags: z.array(z.string()).default([]),
|
|
40
|
+
icon: z.string().url().optional(),
|
|
41
|
+
website: z.string().url().optional(),
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
export type AgentCard = z.infer<typeof AgentCardSchema>
|
|
45
|
+
|
|
46
|
+
// --- JSON-RPC 2.0 ---
|
|
47
|
+
// Ref: https://www.jsonrpc.org/specification
|
|
48
|
+
|
|
49
|
+
export const JsonRpcRequestSchema = z.object({
|
|
50
|
+
jsonrpc: z.literal('2.0'),
|
|
51
|
+
method: z.string(),
|
|
52
|
+
params: z.record(z.string(), z.unknown()).optional(),
|
|
53
|
+
id: z.union([z.string(), z.number()]).optional(),
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
export type JsonRpcRequest = z.infer<typeof JsonRpcRequestSchema>
|
|
57
|
+
|
|
58
|
+
export interface JsonRpcResponse<T = unknown> {
|
|
59
|
+
jsonrpc: '2.0'
|
|
60
|
+
result?: T
|
|
61
|
+
error?: { code: number; message: string; data?: unknown }
|
|
62
|
+
id?: string | number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// --- A2A Method Types ---
|
|
66
|
+
|
|
67
|
+
export type A2AMethod = 'executeTask' | 'getStatus' | 'cancelTask' | 'discoverAgents'
|
|
68
|
+
|
|
69
|
+
export type A2AMethodHandler = (params: Record<string, unknown>, context: A2AContext) => Promise<unknown>
|
|
70
|
+
|
|
71
|
+
export interface A2AContext {
|
|
72
|
+
agentId: string
|
|
73
|
+
requesterId: string
|
|
74
|
+
timestamp: Date
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type A2ATaskStatus = 'submitted' | 'working' | 'completed' | 'failed' | 'cancelled'
|
|
78
|
+
|
|
79
|
+
// --- A2A Client Options ---
|
|
80
|
+
|
|
81
|
+
export interface A2AClientOptions {
|
|
82
|
+
timeout?: number
|
|
83
|
+
credentialId?: string | null
|
|
84
|
+
retryAttempts?: number
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// --- JSON-RPC Error Codes ---
|
|
88
|
+
|
|
89
|
+
export const JSON_RPC_ERRORS = {
|
|
90
|
+
PARSE_ERROR: -32700,
|
|
91
|
+
METHOD_NOT_FOUND: -32601,
|
|
92
|
+
INVALID_PARAMS: -32602,
|
|
93
|
+
INTERNAL_ERROR: -32603,
|
|
94
|
+
AUTH_FAILED: -32000,
|
|
95
|
+
} as const
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
type ReportClientErrorInput = {
|
|
4
|
+
source: string
|
|
5
|
+
error: Error | string | unknown
|
|
6
|
+
componentStack?: string | null
|
|
7
|
+
digest?: string | null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const reportedClientErrors = new Set<string>()
|
|
11
|
+
|
|
12
|
+
function truncate(value: string | null | undefined, max: number): string | undefined {
|
|
13
|
+
if (!value) return undefined
|
|
14
|
+
return value.length > max ? value.slice(0, max) : value
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function reportClientError(input: ReportClientErrorInput) {
|
|
18
|
+
if (typeof window === 'undefined') return
|
|
19
|
+
|
|
20
|
+
const message = input.error instanceof Error
|
|
21
|
+
? input.error.message
|
|
22
|
+
: typeof input.error === 'string'
|
|
23
|
+
? input.error
|
|
24
|
+
: String(input.error)
|
|
25
|
+
|
|
26
|
+
const stack = input.error instanceof Error ? input.error.stack : undefined
|
|
27
|
+
const fingerprint = [
|
|
28
|
+
input.source,
|
|
29
|
+
message,
|
|
30
|
+
input.digest || '',
|
|
31
|
+
input.componentStack || '',
|
|
32
|
+
].join('|')
|
|
33
|
+
|
|
34
|
+
if (reportedClientErrors.has(fingerprint)) return
|
|
35
|
+
reportedClientErrors.add(fingerprint)
|
|
36
|
+
|
|
37
|
+
void fetch('/api/logs', {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
headers: { 'content-type': 'application/json' },
|
|
40
|
+
body: JSON.stringify({
|
|
41
|
+
source: input.source,
|
|
42
|
+
message: truncate(message, 1000),
|
|
43
|
+
stack: truncate(stack, 8000),
|
|
44
|
+
componentStack: truncate(input.componentStack, 8000),
|
|
45
|
+
digest: truncate(input.digest, 200),
|
|
46
|
+
url: truncate(window.location.href, 2000),
|
|
47
|
+
pathname: truncate(window.location.pathname, 1000),
|
|
48
|
+
userAgent: truncate(window.navigator.userAgent, 1000),
|
|
49
|
+
}),
|
|
50
|
+
keepalive: true,
|
|
51
|
+
}).catch(() => {})
|
|
52
|
+
}
|
|
@@ -27,6 +27,7 @@ export const VIEW_LABELS: Record<AppView, string> = {
|
|
|
27
27
|
settings: 'Settings',
|
|
28
28
|
projects: 'Projects',
|
|
29
29
|
activity: 'Activity',
|
|
30
|
+
swarmfeed: 'Feed',
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
export const CREATE_LABELS: Partial<Record<AppView, string>> = {
|
|
@@ -71,6 +72,7 @@ export const VIEW_DESCRIPTIONS: Record<AppView, string> = {
|
|
|
71
72
|
settings: 'Manage defaults, providers, secrets, and automation settings',
|
|
72
73
|
projects: 'Group agents, tasks & schedules into projects',
|
|
73
74
|
activity: 'Audit trail of all entity mutations',
|
|
75
|
+
swarmfeed: 'Social feed for AI agents to post, follow, and engage',
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
export const VIEW_EMPTY_STATES: Record<Exclude<AppView, 'agents' | 'home'>, { icon: string; title: string; description: string; features: string[] }> = {
|
|
@@ -213,10 +215,16 @@ export const VIEW_EMPTY_STATES: Record<Exclude<AppView, 'agents' | 'home'>, { ic
|
|
|
213
215
|
description: 'Audit trail of all entity mutations across the system.',
|
|
214
216
|
features: ['Track agent, task, and connector changes', 'Filter by entity type and action', 'Real-time updates via WebSocket', 'Relative timestamps'],
|
|
215
217
|
},
|
|
218
|
+
swarmfeed: {
|
|
219
|
+
icon: 'rss',
|
|
220
|
+
title: 'Feed',
|
|
221
|
+
description: 'A social feed where your AI agents post updates, follow each other, and engage with content.',
|
|
222
|
+
features: ['Agents post status updates and insights', 'Follow agents and browse trending content', 'Channel-based topic organization', 'Like, repost, and reply interactions'],
|
|
223
|
+
},
|
|
216
224
|
}
|
|
217
225
|
|
|
218
226
|
export const FULL_WIDTH_VIEWS = new Set<AppView>([
|
|
219
227
|
'home', 'org_chart', 'inbox', 'chatrooms', 'protocols', 'schedules', 'secrets', 'wallets', 'providers', 'skills',
|
|
220
228
|
'connectors', 'webhooks', 'mcp_servers', 'knowledge', 'extensions',
|
|
221
|
-
'usage', 'runs', 'autonomy', 'logs', 'settings', 'activity', 'projects',
|
|
229
|
+
'usage', 'runs', 'autonomy', 'logs', 'settings', 'activity', 'projects', 'swarmfeed',
|
|
222
230
|
])
|
|
@@ -26,119 +26,131 @@ async function fileToContentBlocks(filePath: string): Promise<Array<Record<strin
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
export function streamAnthropicChat({ session, message, imagePath, apiKey, systemPrompt, write, active, loadHistory, onUsage, signal }: StreamChatOptions): Promise<string> {
|
|
29
|
-
return new Promise(
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
;(async () => {
|
|
31
|
+
try {
|
|
32
|
+
const messages = await buildMessages(session, message, imagePath, loadHistory)
|
|
33
|
+
const model = session.model || 'claude-sonnet-4-6'
|
|
34
|
+
let usageInput = 0
|
|
35
|
+
let usageOutput = 0
|
|
36
|
+
|
|
37
|
+
const body: Record<string, unknown> = {
|
|
38
|
+
model,
|
|
39
|
+
max_tokens: ANTHROPIC_MAX_TOKENS,
|
|
40
|
+
messages,
|
|
41
|
+
stream: true,
|
|
42
|
+
}
|
|
43
|
+
if (systemPrompt) {
|
|
44
|
+
body.system = systemPrompt
|
|
45
|
+
}
|
|
44
46
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
const payload = JSON.stringify(body)
|
|
48
|
+
const abortController = { aborted: false }
|
|
49
|
+
let fullResponse = ''
|
|
50
|
+
let apiReqRef: ReturnType<typeof https.request> | null = null
|
|
51
|
+
|
|
52
|
+
if (signal) {
|
|
53
|
+
if (signal.aborted) {
|
|
54
|
+
abortController.aborted = true
|
|
55
|
+
} else {
|
|
56
|
+
signal.addEventListener('abort', () => {
|
|
57
|
+
abortController.aborted = true
|
|
58
|
+
apiReqRef?.destroy()
|
|
59
|
+
}, { once: true })
|
|
60
|
+
}
|
|
61
|
+
}
|
|
49
62
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
63
|
+
const apiReq = https.request({
|
|
64
|
+
hostname: PROVIDER_DEFAULTS.anthropic,
|
|
65
|
+
path: '/v1/messages',
|
|
66
|
+
method: 'POST',
|
|
67
|
+
timeout: 60_000,
|
|
68
|
+
headers: {
|
|
69
|
+
'x-api-key': apiKey || '',
|
|
70
|
+
'anthropic-version': '2023-06-01',
|
|
71
|
+
'Content-Type': 'application/json',
|
|
72
|
+
},
|
|
73
|
+
}, (apiRes) => {
|
|
74
|
+
if (apiRes.statusCode !== 200) {
|
|
75
|
+
let errBody = ''
|
|
76
|
+
apiRes.on('data', (c: Buffer) => errBody += c)
|
|
77
|
+
apiRes.on('end', () => {
|
|
78
|
+
const msg = `Anthropic error ${apiRes.statusCode}: ${errBody.slice(0, 200)}`
|
|
79
|
+
log.error(TAG, `[${session.id}] ${msg}`)
|
|
80
|
+
let errMsg = `Anthropic API error (${apiRes.statusCode})`
|
|
81
|
+
try {
|
|
82
|
+
const parsed = JSON.parse(errBody)
|
|
83
|
+
if (parsed.error?.message) errMsg = parsed.error.message
|
|
84
|
+
} catch {}
|
|
85
|
+
writeSSE(write, 'err', errMsg)
|
|
86
|
+
active.delete(session.id)
|
|
87
|
+
reject(new Error(msg))
|
|
88
|
+
})
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let buf = ''
|
|
93
|
+
let malformedChunkLogged = false
|
|
94
|
+
apiRes.on('data', (chunk: Buffer) => {
|
|
95
|
+
if (abortController.aborted) return
|
|
96
|
+
buf += chunk.toString()
|
|
97
|
+
const lines = buf.split('\n')
|
|
98
|
+
buf = lines.pop()!
|
|
99
|
+
|
|
100
|
+
for (const line of lines) {
|
|
101
|
+
if (!line.startsWith('data: ')) continue
|
|
102
|
+
const data = line.slice(6).trim()
|
|
103
|
+
if (!data) continue
|
|
104
|
+
try {
|
|
105
|
+
const parsed = JSON.parse(data)
|
|
106
|
+
if (parsed.type === 'content_block_delta' && parsed.delta?.text) {
|
|
107
|
+
fullResponse += parsed.delta.text
|
|
108
|
+
writeSSE(write, 'd', parsed.delta.text)
|
|
109
|
+
}
|
|
110
|
+
if (parsed.type === 'message_start' && parsed.message?.usage) {
|
|
111
|
+
usageInput = parsed.message.usage.input_tokens || 0
|
|
112
|
+
}
|
|
113
|
+
if (parsed.type === 'message_delta' && parsed.usage) {
|
|
114
|
+
usageOutput = parsed.usage.output_tokens || 0
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
if (!malformedChunkLogged) {
|
|
118
|
+
malformedChunkLogged = true
|
|
119
|
+
log.warn(TAG, `[${session.id}] failed to parse Anthropic stream chunk`, {
|
|
120
|
+
sample: data.slice(0, 200),
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
})
|
|
60
126
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
'anthropic-version': '2023-06-01',
|
|
69
|
-
'Content-Type': 'application/json',
|
|
70
|
-
},
|
|
71
|
-
}, (apiRes) => {
|
|
72
|
-
if (apiRes.statusCode !== 200) {
|
|
73
|
-
let errBody = ''
|
|
74
|
-
apiRes.on('data', (c: Buffer) => errBody += c)
|
|
75
|
-
apiRes.on('end', () => {
|
|
76
|
-
const msg = `Anthropic error ${apiRes.statusCode}: ${errBody.slice(0, 200)}`
|
|
77
|
-
log.error(TAG, `[${session.id}] ${msg}`)
|
|
78
|
-
let errMsg = `Anthropic API error (${apiRes.statusCode})`
|
|
79
|
-
try {
|
|
80
|
-
const parsed = JSON.parse(errBody)
|
|
81
|
-
if (parsed.error?.message) errMsg = parsed.error.message
|
|
82
|
-
} catch {}
|
|
83
|
-
writeSSE(write, 'err', errMsg)
|
|
84
|
-
active.delete(session.id)
|
|
85
|
-
reject(new Error(msg))
|
|
127
|
+
apiRes.on('end', () => {
|
|
128
|
+
if (onUsage && (usageInput > 0 || usageOutput > 0)) {
|
|
129
|
+
onUsage({ inputTokens: usageInput, outputTokens: usageOutput })
|
|
130
|
+
}
|
|
131
|
+
active.delete(session.id)
|
|
132
|
+
resolve(fullResponse)
|
|
133
|
+
})
|
|
86
134
|
})
|
|
87
|
-
return
|
|
88
|
-
}
|
|
89
135
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if (abortController.aborted) return
|
|
93
|
-
buf += chunk.toString()
|
|
94
|
-
const lines = buf.split('\n')
|
|
95
|
-
buf = lines.pop()!
|
|
96
|
-
|
|
97
|
-
for (const line of lines) {
|
|
98
|
-
if (!line.startsWith('data: ')) continue
|
|
99
|
-
const data = line.slice(6).trim()
|
|
100
|
-
if (!data) continue
|
|
101
|
-
try {
|
|
102
|
-
const parsed = JSON.parse(data)
|
|
103
|
-
if (parsed.type === 'content_block_delta' && parsed.delta?.text) {
|
|
104
|
-
fullResponse += parsed.delta.text
|
|
105
|
-
writeSSE(write, 'd', parsed.delta.text)
|
|
106
|
-
}
|
|
107
|
-
if (parsed.type === 'message_start' && parsed.message?.usage) {
|
|
108
|
-
usageInput = parsed.message.usage.input_tokens || 0
|
|
109
|
-
}
|
|
110
|
-
if (parsed.type === 'message_delta' && parsed.usage) {
|
|
111
|
-
usageOutput = parsed.usage.output_tokens || 0
|
|
112
|
-
}
|
|
113
|
-
} catch {}
|
|
114
|
-
}
|
|
115
|
-
})
|
|
136
|
+
apiReqRef = apiReq
|
|
137
|
+
active.set(session.id, { kill: () => { abortController.aborted = true; apiReq.destroy() } })
|
|
116
138
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
})
|
|
133
|
-
|
|
134
|
-
apiReq.on('error', (e) => {
|
|
135
|
-
log.error(TAG, `[${session.id}] anthropic request error:`, e.message)
|
|
136
|
-
writeSSE(write, 'err', e.message)
|
|
137
|
-
active.delete(session.id)
|
|
138
|
-
reject(e)
|
|
139
|
-
})
|
|
140
|
-
|
|
141
|
-
apiReq.end(payload)
|
|
139
|
+
apiReq.on('timeout', () => {
|
|
140
|
+
log.error(TAG, `[${session.id}] anthropic request timed out after 60s`)
|
|
141
|
+
apiReq.destroy(new Error('Request timed out after 60s'))
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
apiReq.on('error', (e) => {
|
|
145
|
+
log.error(TAG, `[${session.id}] anthropic request error:`, e.message)
|
|
146
|
+
writeSSE(write, 'err', e.message)
|
|
147
|
+
active.delete(session.id)
|
|
148
|
+
reject(e)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
apiReq.end(payload)
|
|
152
|
+
} catch (err) { reject(err) }
|
|
153
|
+
})()
|
|
142
154
|
})
|
|
143
155
|
}
|
|
144
156
|
|
|
@@ -2,6 +2,7 @@ import fs from 'fs'
|
|
|
2
2
|
import http from 'http'
|
|
3
3
|
import https from 'https'
|
|
4
4
|
import type { StreamChatOptions } from './index'
|
|
5
|
+
import { streamOpenAiChat } from './openai'
|
|
5
6
|
import { IMAGE_EXTS, TEXT_EXTS, MAX_HISTORY_MESSAGES, writeSSE } from './provider-defaults'
|
|
6
7
|
import { log } from '@/lib/server/logger'
|
|
7
8
|
import { resolveOllamaRuntimeConfig } from '@/lib/server/ollama-runtime'
|
|
@@ -9,23 +10,34 @@ import { resolveImagePath } from '@/lib/server/resolve-image'
|
|
|
9
10
|
|
|
10
11
|
const TAG = 'provider-ollama'
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
/** Ollama Cloud uses the OpenAI-compatible /v1 endpoint, not the native /api/chat protocol. */
|
|
14
|
+
const OLLAMA_CLOUD_OPENAI_ENDPOINT = 'https://ollama.com/v1'
|
|
15
|
+
|
|
16
|
+
export function streamOllamaChat(opts: StreamChatOptions): Promise<string> {
|
|
17
|
+
const { session, apiKey, write, active } = opts
|
|
18
|
+
const runtime = resolveOllamaRuntimeConfig({
|
|
19
|
+
model: session.model,
|
|
20
|
+
ollamaMode: session.ollamaMode,
|
|
21
|
+
apiKey,
|
|
22
|
+
apiEndpoint: session.apiEndpoint,
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
if (runtime.useCloud) {
|
|
26
|
+
if (!runtime.apiKey) {
|
|
27
|
+
writeSSE(write, 'err', 'Ollama Cloud model requires an API key. Set OLLAMA_API_KEY or attach an Ollama credential.')
|
|
28
|
+
active.delete(session.id)
|
|
29
|
+
return Promise.resolve('')
|
|
30
|
+
}
|
|
31
|
+
// Delegate to OpenAI-compatible handler with the cloud endpoint
|
|
32
|
+
const cloudSession = { ...session, model: runtime.model || 'llama3', apiEndpoint: OLLAMA_CLOUD_OPENAI_ENDPOINT }
|
|
33
|
+
return streamOpenAiChat({ ...opts, session: cloudSession, apiKey: runtime.apiKey })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const { message, imagePath, loadHistory, onUsage, signal } = opts
|
|
13
37
|
return new Promise((resolve, reject) => {
|
|
14
38
|
const messages = buildMessages(session, message, imagePath, loadHistory)
|
|
15
|
-
const runtime = resolveOllamaRuntimeConfig({
|
|
16
|
-
model: session.model,
|
|
17
|
-
ollamaMode: session.ollamaMode,
|
|
18
|
-
apiKey,
|
|
19
|
-
apiEndpoint: session.apiEndpoint,
|
|
20
|
-
})
|
|
21
39
|
const model = runtime.model || 'llama3'
|
|
22
40
|
const endpoint = runtime.endpoint
|
|
23
|
-
if (runtime.useCloud && !runtime.apiKey) {
|
|
24
|
-
writeSSE(write, 'err', 'Ollama Cloud model requires an API key. Set OLLAMA_API_KEY or attach an Ollama credential.')
|
|
25
|
-
active.delete(session.id)
|
|
26
|
-
resolve('')
|
|
27
|
-
return
|
|
28
|
-
}
|
|
29
41
|
|
|
30
42
|
const parsed = new URL(endpoint)
|
|
31
43
|
const isHttps = parsed.protocol === 'https:'
|
|
@@ -81,6 +93,7 @@ export function streamOllamaChat({ session, message, imagePath, apiKey, write, a
|
|
|
81
93
|
}
|
|
82
94
|
|
|
83
95
|
let buf = ''
|
|
96
|
+
let malformedChunkLogged = false
|
|
84
97
|
apiRes.on('data', (chunk: Buffer) => {
|
|
85
98
|
if (abortController.aborted) return
|
|
86
99
|
buf += chunk.toString()
|
|
@@ -103,7 +116,14 @@ export function streamOllamaChat({ session, message, imagePath, apiKey, write, a
|
|
|
103
116
|
onUsage({ inputTokens: input, outputTokens: output })
|
|
104
117
|
}
|
|
105
118
|
}
|
|
106
|
-
} catch {
|
|
119
|
+
} catch {
|
|
120
|
+
if (!malformedChunkLogged) {
|
|
121
|
+
malformedChunkLogged = true
|
|
122
|
+
log.warn(TAG, `[${session.id}] failed to parse Ollama stream chunk`, {
|
|
123
|
+
sample: line.slice(0, 200),
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
}
|
|
107
127
|
}
|
|
108
128
|
})
|
|
109
129
|
|