fireworks-ai 0.2.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.
Potentially problematic release.
This version of fireworks-ai might be problematic. Click here for more details.
- package/LICENSE +21 -0
- package/README.md +320 -0
- package/dist/react/AgentProvider.d.ts +15 -0
- package/dist/react/AgentProvider.js +30 -0
- package/dist/react/ChatInput.d.ts +6 -0
- package/dist/react/ChatInput.js +21 -0
- package/dist/react/CollapsibleCard.d.ts +9 -0
- package/dist/react/CollapsibleCard.js +8 -0
- package/dist/react/MessageList.d.ts +3 -0
- package/dist/react/MessageList.js +29 -0
- package/dist/react/StatusDot.d.ts +5 -0
- package/dist/react/StatusDot.js +12 -0
- package/dist/react/TextMessage.d.ts +5 -0
- package/dist/react/TextMessage.js +13 -0
- package/dist/react/ThinkingIndicator.d.ts +3 -0
- package/dist/react/ThinkingIndicator.js +5 -0
- package/dist/react/ToolCallCard.d.ts +5 -0
- package/dist/react/ToolCallCard.js +33 -0
- package/dist/react/cn.d.ts +2 -0
- package/dist/react/cn.js +5 -0
- package/dist/react/index.d.ts +13 -0
- package/dist/react/index.js +12 -0
- package/dist/react/registry.d.ts +4 -0
- package/dist/react/registry.js +10 -0
- package/dist/react/registry.test.d.ts +1 -0
- package/dist/react/registry.test.js +26 -0
- package/dist/react/store.d.ts +28 -0
- package/dist/react/store.js +109 -0
- package/dist/react/store.test.d.ts +1 -0
- package/dist/react/store.test.js +113 -0
- package/dist/react/use-agent.d.ts +11 -0
- package/dist/react/use-agent.js +96 -0
- package/dist/server/index.d.ts +5 -0
- package/dist/server/index.js +4 -0
- package/dist/server/push-channel.d.ts +8 -0
- package/dist/server/push-channel.js +40 -0
- package/dist/server/push-channel.test.d.ts +1 -0
- package/dist/server/push-channel.test.js +57 -0
- package/dist/server/router.d.ts +8 -0
- package/dist/server/router.js +52 -0
- package/dist/server/session.d.ts +32 -0
- package/dist/server/session.js +73 -0
- package/dist/server/translator.d.ts +14 -0
- package/dist/server/translator.js +151 -0
- package/dist/server/translator.test.d.ts +1 -0
- package/dist/server/translator.test.js +156 -0
- package/dist/types.d.ts +39 -0
- package/dist/types.js +1 -0
- package/package.json +69 -0
- package/src/theme.css +133 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { getWidget, registerWidget, stripMcpPrefix } from "./registry.js";
|
|
3
|
+
describe("Widget Registry", () => {
|
|
4
|
+
it("registerWidget + getWidget roundtrip", () => {
|
|
5
|
+
const component = () => null;
|
|
6
|
+
registerWidget({ toolName: "test_tool", label: "Test", component });
|
|
7
|
+
const reg = getWidget("test_tool");
|
|
8
|
+
expect(reg).toBeDefined();
|
|
9
|
+
expect(reg?.label).toBe("Test");
|
|
10
|
+
expect(reg?.component).toBe(component);
|
|
11
|
+
});
|
|
12
|
+
it("getWidget returns undefined for unregistered name", () => {
|
|
13
|
+
expect(getWidget("nonexistent_widget")).toBeUndefined();
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
describe("stripMcpPrefix", () => {
|
|
17
|
+
it("strips mcp__server__tool prefix", () => {
|
|
18
|
+
expect(stripMcpPrefix("mcp__myserver__read_file")).toBe("read_file");
|
|
19
|
+
});
|
|
20
|
+
it("leaves names without prefix unchanged", () => {
|
|
21
|
+
expect(stripMcpPrefix("search")).toBe("search");
|
|
22
|
+
});
|
|
23
|
+
it("handles multiple underscores in tool name", () => {
|
|
24
|
+
expect(stripMcpPrefix("mcp__srv__my_long_tool_name")).toBe("my_long_tool_name");
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type StoreApi } from "zustand/vanilla";
|
|
2
|
+
import type { ChatMessage } from "../types.js";
|
|
3
|
+
interface ChatStoreState {
|
|
4
|
+
sessionId: string | null;
|
|
5
|
+
messages: ChatMessage[];
|
|
6
|
+
isStreaming: boolean;
|
|
7
|
+
isThinking: boolean;
|
|
8
|
+
streamingText: string;
|
|
9
|
+
}
|
|
10
|
+
interface ChatStoreActions {
|
|
11
|
+
setSessionId: (id: string) => void;
|
|
12
|
+
addUserMessage: (text: string) => void;
|
|
13
|
+
appendStreamingText: (text: string) => void;
|
|
14
|
+
flushStreamingText: () => void;
|
|
15
|
+
addSystemMessage: (text: string) => void;
|
|
16
|
+
startToolCall: (toolUseId: string, name: string) => void;
|
|
17
|
+
appendToolInput: (toolUseId: string, partialJson: string) => void;
|
|
18
|
+
finalizeToolCall: (toolUseId: string, name: string, input: Record<string, unknown>) => void;
|
|
19
|
+
completeToolCall: (toolUseId: string, result: string) => void;
|
|
20
|
+
errorToolCall: (toolUseId: string, error: string) => void;
|
|
21
|
+
setStreaming: (v: boolean) => void;
|
|
22
|
+
setThinking: (v: boolean) => void;
|
|
23
|
+
reset: () => void;
|
|
24
|
+
}
|
|
25
|
+
export type ChatStoreShape = ChatStoreState & ChatStoreActions;
|
|
26
|
+
export type ChatStore = StoreApi<ChatStoreShape>;
|
|
27
|
+
export declare function createChatStore(): ChatStore;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { immer } from "zustand/middleware/immer";
|
|
2
|
+
import { createStore } from "zustand/vanilla";
|
|
3
|
+
let nextId = 0;
|
|
4
|
+
function findToolCall(messages, toolUseId) {
|
|
5
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
6
|
+
const tc = messages[i].toolCalls;
|
|
7
|
+
if (tc?.length) {
|
|
8
|
+
const match = tc.find((t) => t.id === toolUseId);
|
|
9
|
+
if (match)
|
|
10
|
+
return match;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
export function createChatStore() {
|
|
16
|
+
return createStore()(immer((set) => ({
|
|
17
|
+
sessionId: null,
|
|
18
|
+
messages: [],
|
|
19
|
+
isStreaming: false,
|
|
20
|
+
isThinking: false,
|
|
21
|
+
streamingText: "",
|
|
22
|
+
setSessionId: (id) => set((s) => {
|
|
23
|
+
s.sessionId = id;
|
|
24
|
+
}),
|
|
25
|
+
addUserMessage: (text) => set((s) => {
|
|
26
|
+
s.messages.push({
|
|
27
|
+
id: `msg-${++nextId}`,
|
|
28
|
+
role: "user",
|
|
29
|
+
content: text,
|
|
30
|
+
});
|
|
31
|
+
}),
|
|
32
|
+
addSystemMessage: (text) => set((s) => {
|
|
33
|
+
s.messages.push({
|
|
34
|
+
id: `msg-${++nextId}`,
|
|
35
|
+
role: "system",
|
|
36
|
+
content: text,
|
|
37
|
+
});
|
|
38
|
+
}),
|
|
39
|
+
appendStreamingText: (text) => set((s) => {
|
|
40
|
+
s.streamingText += text;
|
|
41
|
+
}),
|
|
42
|
+
flushStreamingText: () => set((s) => {
|
|
43
|
+
if (s.streamingText) {
|
|
44
|
+
const last = s.messages[s.messages.length - 1];
|
|
45
|
+
if (last?.role === "assistant" && !last.toolCalls?.length) {
|
|
46
|
+
last.content += s.streamingText;
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
s.messages.push({
|
|
50
|
+
id: `msg-${++nextId}`,
|
|
51
|
+
role: "assistant",
|
|
52
|
+
content: s.streamingText,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
s.streamingText = "";
|
|
56
|
+
}
|
|
57
|
+
}),
|
|
58
|
+
startToolCall: (toolUseId, name) => set((s) => {
|
|
59
|
+
s.messages.push({
|
|
60
|
+
id: `msg-${++nextId}`,
|
|
61
|
+
role: "assistant",
|
|
62
|
+
content: "",
|
|
63
|
+
toolCalls: [{ id: toolUseId, name, input: {}, status: "pending" }],
|
|
64
|
+
});
|
|
65
|
+
}),
|
|
66
|
+
appendToolInput: (toolUseId, partialJson) => set((s) => {
|
|
67
|
+
const tc = findToolCall(s.messages, toolUseId);
|
|
68
|
+
if (tc) {
|
|
69
|
+
tc.partialInput = (tc.partialInput ?? "") + partialJson;
|
|
70
|
+
tc.status = "streaming_input";
|
|
71
|
+
}
|
|
72
|
+
}),
|
|
73
|
+
finalizeToolCall: (toolUseId, name, input) => set((s) => {
|
|
74
|
+
const tc = findToolCall(s.messages, toolUseId);
|
|
75
|
+
if (tc) {
|
|
76
|
+
tc.name = name;
|
|
77
|
+
tc.input = input;
|
|
78
|
+
tc.status = "running";
|
|
79
|
+
}
|
|
80
|
+
}),
|
|
81
|
+
completeToolCall: (toolUseId, result) => set((s) => {
|
|
82
|
+
const tc = findToolCall(s.messages, toolUseId);
|
|
83
|
+
if (tc) {
|
|
84
|
+
tc.result = result;
|
|
85
|
+
tc.status = "complete";
|
|
86
|
+
}
|
|
87
|
+
}),
|
|
88
|
+
errorToolCall: (toolUseId, error) => set((s) => {
|
|
89
|
+
const tc = findToolCall(s.messages, toolUseId);
|
|
90
|
+
if (tc) {
|
|
91
|
+
tc.error = error;
|
|
92
|
+
tc.status = "error";
|
|
93
|
+
}
|
|
94
|
+
}),
|
|
95
|
+
setStreaming: (v) => set((s) => {
|
|
96
|
+
s.isStreaming = v;
|
|
97
|
+
}),
|
|
98
|
+
setThinking: (v) => set((s) => {
|
|
99
|
+
s.isThinking = v;
|
|
100
|
+
}),
|
|
101
|
+
reset: () => set((s) => {
|
|
102
|
+
s.sessionId = null;
|
|
103
|
+
s.messages = [];
|
|
104
|
+
s.isStreaming = false;
|
|
105
|
+
s.isThinking = false;
|
|
106
|
+
s.streamingText = "";
|
|
107
|
+
}),
|
|
108
|
+
})));
|
|
109
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createChatStore } from "./store.js";
|
|
3
|
+
function firstToolCall(store) {
|
|
4
|
+
const tc = store.getState().messages[0].toolCalls?.[0];
|
|
5
|
+
expect(tc).toBeDefined();
|
|
6
|
+
return tc;
|
|
7
|
+
}
|
|
8
|
+
describe("ChatStore", () => {
|
|
9
|
+
it("addUserMessage appends a user message", () => {
|
|
10
|
+
const store = createChatStore();
|
|
11
|
+
store.getState().addUserMessage("hello");
|
|
12
|
+
const { messages } = store.getState();
|
|
13
|
+
expect(messages).toHaveLength(1);
|
|
14
|
+
expect(messages[0].role).toBe("user");
|
|
15
|
+
expect(messages[0].content).toBe("hello");
|
|
16
|
+
expect(messages[0].id).toMatch(/^msg-/);
|
|
17
|
+
});
|
|
18
|
+
it("appendStreamingText + flushStreamingText creates assistant message", () => {
|
|
19
|
+
const store = createChatStore();
|
|
20
|
+
store.getState().appendStreamingText("hel");
|
|
21
|
+
store.getState().appendStreamingText("lo");
|
|
22
|
+
expect(store.getState().streamingText).toBe("hello");
|
|
23
|
+
store.getState().flushStreamingText();
|
|
24
|
+
const { messages, streamingText } = store.getState();
|
|
25
|
+
expect(streamingText).toBe("");
|
|
26
|
+
expect(messages).toHaveLength(1);
|
|
27
|
+
expect(messages[0].role).toBe("assistant");
|
|
28
|
+
expect(messages[0].content).toBe("hello");
|
|
29
|
+
});
|
|
30
|
+
it("flushStreamingText appends to existing assistant message without tool calls", () => {
|
|
31
|
+
const store = createChatStore();
|
|
32
|
+
store.getState().appendStreamingText("first ");
|
|
33
|
+
store.getState().flushStreamingText();
|
|
34
|
+
store.getState().appendStreamingText("second");
|
|
35
|
+
store.getState().flushStreamingText();
|
|
36
|
+
const { messages } = store.getState();
|
|
37
|
+
expect(messages).toHaveLength(1);
|
|
38
|
+
expect(messages[0].content).toBe("first second");
|
|
39
|
+
});
|
|
40
|
+
it("flushStreamingText is a no-op when streamingText is empty", () => {
|
|
41
|
+
const store = createChatStore();
|
|
42
|
+
store.getState().flushStreamingText();
|
|
43
|
+
expect(store.getState().messages).toHaveLength(0);
|
|
44
|
+
});
|
|
45
|
+
it("startToolCall creates assistant message with pending tool call", () => {
|
|
46
|
+
const store = createChatStore();
|
|
47
|
+
store.getState().startToolCall("tc-1", "search");
|
|
48
|
+
const { messages } = store.getState();
|
|
49
|
+
expect(messages).toHaveLength(1);
|
|
50
|
+
expect(messages[0].role).toBe("assistant");
|
|
51
|
+
expect(messages[0].toolCalls).toHaveLength(1);
|
|
52
|
+
expect(messages[0].toolCalls?.[0]).toMatchObject({
|
|
53
|
+
id: "tc-1",
|
|
54
|
+
name: "search",
|
|
55
|
+
status: "pending",
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
it("appendToolInput accumulates partialInput and sets streaming_input status", () => {
|
|
59
|
+
const store = createChatStore();
|
|
60
|
+
store.getState().startToolCall("tc-1", "search");
|
|
61
|
+
store.getState().appendToolInput("tc-1", '{"q":');
|
|
62
|
+
store.getState().appendToolInput("tc-1", '"test"}');
|
|
63
|
+
const tc = firstToolCall(store);
|
|
64
|
+
expect(tc.partialInput).toBe('{"q":"test"}');
|
|
65
|
+
expect(tc.status).toBe("streaming_input");
|
|
66
|
+
});
|
|
67
|
+
it("finalizeToolCall sets input and running status", () => {
|
|
68
|
+
const store = createChatStore();
|
|
69
|
+
store.getState().startToolCall("tc-1", "search");
|
|
70
|
+
store.getState().finalizeToolCall("tc-1", "search", { q: "test" });
|
|
71
|
+
const tc = firstToolCall(store);
|
|
72
|
+
expect(tc.input).toEqual({ q: "test" });
|
|
73
|
+
expect(tc.status).toBe("running");
|
|
74
|
+
});
|
|
75
|
+
it("completeToolCall sets result and complete status", () => {
|
|
76
|
+
const store = createChatStore();
|
|
77
|
+
store.getState().startToolCall("tc-1", "search");
|
|
78
|
+
store.getState().completeToolCall("tc-1", '{"results":[]}');
|
|
79
|
+
const tc = firstToolCall(store);
|
|
80
|
+
expect(tc.result).toBe('{"results":[]}');
|
|
81
|
+
expect(tc.status).toBe("complete");
|
|
82
|
+
});
|
|
83
|
+
it("errorToolCall sets error and error status", () => {
|
|
84
|
+
const store = createChatStore();
|
|
85
|
+
store.getState().startToolCall("tc-1", "search");
|
|
86
|
+
store.getState().errorToolCall("tc-1", "timeout");
|
|
87
|
+
const tc = firstToolCall(store);
|
|
88
|
+
expect(tc.error).toBe("timeout");
|
|
89
|
+
expect(tc.status).toBe("error");
|
|
90
|
+
});
|
|
91
|
+
it("reset clears all state", () => {
|
|
92
|
+
const store = createChatStore();
|
|
93
|
+
store.getState().setSessionId("sess-1");
|
|
94
|
+
store.getState().addUserMessage("hi");
|
|
95
|
+
store.getState().setStreaming(true);
|
|
96
|
+
store.getState().setThinking(true);
|
|
97
|
+
store.getState().reset();
|
|
98
|
+
const s = store.getState();
|
|
99
|
+
expect(s.sessionId).toBeNull();
|
|
100
|
+
expect(s.messages).toHaveLength(0);
|
|
101
|
+
expect(s.isStreaming).toBe(false);
|
|
102
|
+
expect(s.isThinking).toBe(false);
|
|
103
|
+
expect(s.streamingText).toBe("");
|
|
104
|
+
});
|
|
105
|
+
it("addSystemMessage appends a system message", () => {
|
|
106
|
+
const store = createChatStore();
|
|
107
|
+
store.getState().addSystemMessage("Session ended");
|
|
108
|
+
const { messages } = store.getState();
|
|
109
|
+
expect(messages).toHaveLength(1);
|
|
110
|
+
expect(messages[0].role).toBe("system");
|
|
111
|
+
expect(messages[0].content).toBe("Session ended");
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { CustomEvent } from "../types.js";
|
|
2
|
+
import type { ChatStore } from "./store.js";
|
|
3
|
+
export interface UseAgentConfig {
|
|
4
|
+
endpoint?: string;
|
|
5
|
+
onCustomEvent?: (event: CustomEvent) => void;
|
|
6
|
+
}
|
|
7
|
+
export interface UseAgentReturn {
|
|
8
|
+
sessionId: string | null;
|
|
9
|
+
sendMessage: (text: string) => Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
export declare function useAgent(store: ChatStore, config?: UseAgentConfig): UseAgentReturn;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
|
|
2
|
+
export function useAgent(store, config) {
|
|
3
|
+
const endpoint = config?.endpoint ?? "/api";
|
|
4
|
+
const onCustomEvent = config?.onCustomEvent;
|
|
5
|
+
const eventSourceRef = useRef(null);
|
|
6
|
+
const sessionId = useSyncExternalStore(store.subscribe, () => store.getState().sessionId);
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
let cancelled = false;
|
|
9
|
+
async function init() {
|
|
10
|
+
const res = await fetch(`${endpoint}/sessions`, { method: "POST" });
|
|
11
|
+
const data = await res.json();
|
|
12
|
+
if (!cancelled)
|
|
13
|
+
store.getState().setSessionId(data.sessionId);
|
|
14
|
+
}
|
|
15
|
+
init();
|
|
16
|
+
return () => {
|
|
17
|
+
cancelled = true;
|
|
18
|
+
};
|
|
19
|
+
}, [endpoint, store]);
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (!sessionId)
|
|
22
|
+
return;
|
|
23
|
+
const es = new EventSource(`${endpoint}/sessions/${sessionId}/events`);
|
|
24
|
+
eventSourceRef.current = es;
|
|
25
|
+
es.addEventListener("message_start", () => {
|
|
26
|
+
store.getState().setThinking(true);
|
|
27
|
+
});
|
|
28
|
+
es.addEventListener("text_delta", (e) => {
|
|
29
|
+
store.getState().setThinking(false);
|
|
30
|
+
const { text } = JSON.parse(e.data);
|
|
31
|
+
store.getState().appendStreamingText(text);
|
|
32
|
+
});
|
|
33
|
+
es.addEventListener("tool_start", (e) => {
|
|
34
|
+
store.getState().setThinking(false);
|
|
35
|
+
store.getState().flushStreamingText();
|
|
36
|
+
const { id, name } = JSON.parse(e.data);
|
|
37
|
+
store.getState().startToolCall(id, name);
|
|
38
|
+
});
|
|
39
|
+
es.addEventListener("tool_input_delta", (e) => {
|
|
40
|
+
const { id, partialJson } = JSON.parse(e.data);
|
|
41
|
+
store.getState().appendToolInput(id, partialJson);
|
|
42
|
+
});
|
|
43
|
+
es.addEventListener("tool_call", (e) => {
|
|
44
|
+
store.getState().flushStreamingText();
|
|
45
|
+
const { id, name, input } = JSON.parse(e.data);
|
|
46
|
+
store.getState().finalizeToolCall(id, name, input);
|
|
47
|
+
});
|
|
48
|
+
es.addEventListener("tool_result", (e) => {
|
|
49
|
+
const { toolUseId, result } = JSON.parse(e.data);
|
|
50
|
+
store.getState().completeToolCall(toolUseId, result);
|
|
51
|
+
if (store.getState().isStreaming) {
|
|
52
|
+
store.getState().setThinking(true);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
es.addEventListener("session_error", (e) => {
|
|
56
|
+
store.getState().flushStreamingText();
|
|
57
|
+
store.getState().setThinking(false);
|
|
58
|
+
const { subtype } = JSON.parse(e.data);
|
|
59
|
+
store.getState().addSystemMessage(`Session ended: ${subtype}`);
|
|
60
|
+
store.getState().setStreaming(false);
|
|
61
|
+
});
|
|
62
|
+
es.addEventListener("turn_complete", () => {
|
|
63
|
+
store.getState().flushStreamingText();
|
|
64
|
+
store.getState().setThinking(false);
|
|
65
|
+
store.getState().setStreaming(false);
|
|
66
|
+
});
|
|
67
|
+
es.addEventListener("error", () => {
|
|
68
|
+
if (es.readyState === EventSource.CLOSED) {
|
|
69
|
+
store.getState().flushStreamingText();
|
|
70
|
+
store.getState().setStreaming(false);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
if (onCustomEvent) {
|
|
74
|
+
es.addEventListener("custom", (e) => {
|
|
75
|
+
onCustomEvent(JSON.parse(e.data));
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return () => {
|
|
79
|
+
es.close();
|
|
80
|
+
eventSourceRef.current = null;
|
|
81
|
+
};
|
|
82
|
+
}, [sessionId, endpoint, store, onCustomEvent]);
|
|
83
|
+
const sendMessage = useCallback(async (text) => {
|
|
84
|
+
if (!sessionId)
|
|
85
|
+
return;
|
|
86
|
+
store.getState().addUserMessage(text);
|
|
87
|
+
store.getState().setStreaming(true);
|
|
88
|
+
store.getState().setThinking(true);
|
|
89
|
+
await fetch(`${endpoint}/sessions/${sessionId}/messages`, {
|
|
90
|
+
method: "POST",
|
|
91
|
+
headers: { "Content-Type": "application/json" },
|
|
92
|
+
body: JSON.stringify({ text }),
|
|
93
|
+
});
|
|
94
|
+
}, [sessionId, endpoint, store]);
|
|
95
|
+
return { sessionId, sendMessage };
|
|
96
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type { CustomEvent, SSEEvent } from "../types.js";
|
|
2
|
+
export { PushChannel } from "./push-channel.js";
|
|
3
|
+
export { createAgentRouter } from "./router.js";
|
|
4
|
+
export { type Session, type SessionInit, SessionManager } from "./session.js";
|
|
5
|
+
export { MessageTranslator, sseEncode, streamSession, type TranslatorConfig, } from "./translator.js";
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export class PushChannel {
|
|
2
|
+
queue = [];
|
|
3
|
+
resolve = null;
|
|
4
|
+
done = false;
|
|
5
|
+
push(value) {
|
|
6
|
+
if (this.done)
|
|
7
|
+
return;
|
|
8
|
+
if (this.resolve) {
|
|
9
|
+
const r = this.resolve;
|
|
10
|
+
this.resolve = null;
|
|
11
|
+
r({ value, done: false });
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
this.queue.push(value);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
close() {
|
|
18
|
+
this.done = true;
|
|
19
|
+
if (this.resolve) {
|
|
20
|
+
const r = this.resolve;
|
|
21
|
+
this.resolve = null;
|
|
22
|
+
r({ value: undefined, done: true });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
[Symbol.asyncIterator]() {
|
|
26
|
+
return {
|
|
27
|
+
next: () => {
|
|
28
|
+
if (this.queue.length > 0) {
|
|
29
|
+
return Promise.resolve({ value: this.queue.shift(), done: false });
|
|
30
|
+
}
|
|
31
|
+
if (this.done) {
|
|
32
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
33
|
+
}
|
|
34
|
+
return new Promise((resolve) => {
|
|
35
|
+
this.resolve = resolve;
|
|
36
|
+
});
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { PushChannel } from "./push-channel.js";
|
|
3
|
+
describe("PushChannel", () => {
|
|
4
|
+
it("queues values pushed before consuming", async () => {
|
|
5
|
+
const ch = new PushChannel();
|
|
6
|
+
ch.push(1);
|
|
7
|
+
ch.push(2);
|
|
8
|
+
ch.push(3);
|
|
9
|
+
ch.close();
|
|
10
|
+
const values = [];
|
|
11
|
+
for await (const v of ch) {
|
|
12
|
+
values.push(v);
|
|
13
|
+
}
|
|
14
|
+
expect(values).toEqual([1, 2, 3]);
|
|
15
|
+
});
|
|
16
|
+
it("resolves immediately when push is called while awaiting", async () => {
|
|
17
|
+
const ch = new PushChannel();
|
|
18
|
+
const promise = (async () => {
|
|
19
|
+
const iter = ch[Symbol.asyncIterator]();
|
|
20
|
+
const result = await iter.next();
|
|
21
|
+
return result.value;
|
|
22
|
+
})();
|
|
23
|
+
// Allow the iterator to set up its pending promise
|
|
24
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
25
|
+
ch.push("hello");
|
|
26
|
+
expect(await promise).toBe("hello");
|
|
27
|
+
ch.close();
|
|
28
|
+
});
|
|
29
|
+
it("ends iteration on close", async () => {
|
|
30
|
+
const ch = new PushChannel();
|
|
31
|
+
ch.close();
|
|
32
|
+
const iter = ch[Symbol.asyncIterator]();
|
|
33
|
+
const result = await iter.next();
|
|
34
|
+
expect(result.done).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
it("ignores pushes after close", async () => {
|
|
37
|
+
const ch = new PushChannel();
|
|
38
|
+
ch.push(1);
|
|
39
|
+
ch.close();
|
|
40
|
+
ch.push(2);
|
|
41
|
+
const values = [];
|
|
42
|
+
for await (const v of ch) {
|
|
43
|
+
values.push(v);
|
|
44
|
+
}
|
|
45
|
+
expect(values).toEqual([1]);
|
|
46
|
+
});
|
|
47
|
+
it("yields all queued values then ends on close", async () => {
|
|
48
|
+
const ch = new PushChannel();
|
|
49
|
+
ch.push("a");
|
|
50
|
+
ch.push("b");
|
|
51
|
+
const iter = ch[Symbol.asyncIterator]();
|
|
52
|
+
expect((await iter.next()).value).toBe("a");
|
|
53
|
+
expect((await iter.next()).value).toBe("b");
|
|
54
|
+
ch.close();
|
|
55
|
+
expect((await iter.next()).done).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { SessionManager } from "./session.js";
|
|
3
|
+
import { type MessageTranslator } from "./translator.js";
|
|
4
|
+
export declare function createAgentRouter<TCtx>(config: {
|
|
5
|
+
sessions: SessionManager<TCtx>;
|
|
6
|
+
translator: MessageTranslator<TCtx>;
|
|
7
|
+
basePath?: string;
|
|
8
|
+
}): Hono;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { cors } from "hono/cors";
|
|
3
|
+
import { sseEncode, streamSession } from "./translator.js";
|
|
4
|
+
export function createAgentRouter(config) {
|
|
5
|
+
const { sessions, translator, basePath = "/api" } = config;
|
|
6
|
+
const app = new Hono();
|
|
7
|
+
app.use(`${basePath}/*`, cors({ origin: "*" }));
|
|
8
|
+
app.post(`${basePath}/sessions`, (c) => {
|
|
9
|
+
const session = sessions.create();
|
|
10
|
+
return c.json({ sessionId: session.id });
|
|
11
|
+
});
|
|
12
|
+
app.post(`${basePath}/sessions/:id/messages`, async (c) => {
|
|
13
|
+
const session = sessions.get(c.req.param("id"));
|
|
14
|
+
if (!session)
|
|
15
|
+
return c.json({ error: "Session not found" }, 404);
|
|
16
|
+
const body = await c.req.json();
|
|
17
|
+
if (!body.text?.trim())
|
|
18
|
+
return c.json({ error: "Message text required" }, 400);
|
|
19
|
+
session.pushMessage(body.text.trim());
|
|
20
|
+
return c.json({ ok: true });
|
|
21
|
+
});
|
|
22
|
+
app.get(`${basePath}/sessions/:id/events`, (c) => {
|
|
23
|
+
const session = sessions.get(c.req.param("id"));
|
|
24
|
+
if (!session)
|
|
25
|
+
return c.json({ error: "Session not found" }, 404);
|
|
26
|
+
const stream = new ReadableStream({
|
|
27
|
+
async start(controller) {
|
|
28
|
+
const encoder = new TextEncoder();
|
|
29
|
+
try {
|
|
30
|
+
for await (const evt of streamSession(session, translator)) {
|
|
31
|
+
controller.enqueue(encoder.encode(sseEncode(evt)));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
const message = err instanceof Error ? err.message : "Unknown error";
|
|
36
|
+
controller.enqueue(encoder.encode(sseEncode({ event: "error", data: JSON.stringify({ message }) })));
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
controller.close();
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
return new Response(stream, {
|
|
44
|
+
headers: {
|
|
45
|
+
"Content-Type": "text/event-stream",
|
|
46
|
+
"Cache-Control": "no-cache",
|
|
47
|
+
Connection: "keep-alive",
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
return app;
|
|
52
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
+
type SDKMessage = ReturnType<typeof query> extends AsyncGenerator<infer T> ? T : never;
|
|
3
|
+
export interface SessionInit<TCtx> {
|
|
4
|
+
context: TCtx;
|
|
5
|
+
model: string;
|
|
6
|
+
systemPrompt: string;
|
|
7
|
+
mcpServers?: Record<string, unknown>;
|
|
8
|
+
tools?: unknown[];
|
|
9
|
+
allowedTools?: string[];
|
|
10
|
+
maxTurns?: number;
|
|
11
|
+
permissionMode?: "default" | "plan" | "bypassPermissions";
|
|
12
|
+
}
|
|
13
|
+
export interface Session<TCtx> {
|
|
14
|
+
id: string;
|
|
15
|
+
context: TCtx;
|
|
16
|
+
pushMessage(text: string): void;
|
|
17
|
+
messageIterator: AsyncIterable<SDKMessage>;
|
|
18
|
+
abort(): void;
|
|
19
|
+
createdAt: number;
|
|
20
|
+
lastActivityAt: number;
|
|
21
|
+
}
|
|
22
|
+
export declare class SessionManager<TCtx> {
|
|
23
|
+
private sessions;
|
|
24
|
+
private factory;
|
|
25
|
+
private idleTimeoutMs;
|
|
26
|
+
constructor(factory: () => SessionInit<TCtx>, idleTimeoutMs?: number);
|
|
27
|
+
create(): Session<TCtx>;
|
|
28
|
+
get(id: string): Session<TCtx> | undefined;
|
|
29
|
+
delete(id: string): void;
|
|
30
|
+
cleanup(): void;
|
|
31
|
+
}
|
|
32
|
+
export {};
|