@janux/agent 0.2.0 → 0.2.1
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/package.json +23 -4
- package/src/agent.ts +2 -0
- package/src/llm-endpoint.test.ts +68 -0
- package/src/llm-endpoint.ts +85 -0
- package/src/local/copilot.test.ts +113 -0
- package/src/local/copilot.ts +114 -0
- package/src/local/index.ts +27 -0
- package/src/local/llm.ts +84 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@janux/agent",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Janux embedded agent runtime: zero-config model resolution, provider routing and the ui/api tool loop.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -14,11 +14,30 @@
|
|
|
14
14
|
"type": "module",
|
|
15
15
|
"main": "./src/index.ts",
|
|
16
16
|
"exports": {
|
|
17
|
-
".": "./src/index.ts"
|
|
17
|
+
".": "./src/index.ts",
|
|
18
|
+
"./local": "./src/local/index.ts"
|
|
18
19
|
},
|
|
19
20
|
"dependencies": {
|
|
20
|
-
"janux": "0.1
|
|
21
|
-
"@janux/server": "0.1
|
|
21
|
+
"janux": "0.2.1",
|
|
22
|
+
"@janux/server": "0.2.1",
|
|
23
|
+
"@aralroca/gui-agent": "^0.5.0",
|
|
24
|
+
"ai": "^6.0.0"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@browser-ai/transformers-js": ">=2.2.0",
|
|
28
|
+
"@huggingface/transformers": ">=3.7.0"
|
|
29
|
+
},
|
|
30
|
+
"peerDependenciesMeta": {
|
|
31
|
+
"@browser-ai/transformers-js": {
|
|
32
|
+
"optional": true
|
|
33
|
+
},
|
|
34
|
+
"@huggingface/transformers": {
|
|
35
|
+
"optional": true
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@browser-ai/transformers-js": "^2.2.2",
|
|
40
|
+
"@happy-dom/global-registrator": "^15.11.0"
|
|
22
41
|
},
|
|
23
42
|
"homepage": "https://github.com/aralroca/Janux#readme",
|
|
24
43
|
"bugs": "https://github.com/aralroca/Janux/issues",
|
package/src/agent.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AgentDeps, AgentMount } from '@janux/server';
|
|
2
|
+
import { createLlmHandler } from './llm-endpoint';
|
|
2
3
|
import { resolveModel, setupCard, type ModelEnv } from './model';
|
|
3
4
|
import { callProvider, type AgentTool, type ChatMessage, type FetchLike, type ToolCall } from './providers';
|
|
4
5
|
|
|
@@ -83,6 +84,7 @@ export function defineAgent(config: AgentConfig = {}, overrides: AgentOverrides
|
|
|
83
84
|
const maxTurns = config.maxTurns ?? 6;
|
|
84
85
|
|
|
85
86
|
return {
|
|
87
|
+
handleLlm: createLlmHandler(config.model, env, fetchImpl),
|
|
86
88
|
async handle(req: Request, deps: AgentDeps): Promise<Response> {
|
|
87
89
|
const model = resolveModel(config.model, env);
|
|
88
90
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { defineAgent } from './agent';
|
|
3
|
+
|
|
4
|
+
const ENV = { ANTHROPIC_API_KEY: 'test-key' };
|
|
5
|
+
|
|
6
|
+
function anthropicReply(blocks: unknown[]): Response {
|
|
7
|
+
return new Response(JSON.stringify({ content: blocks }), {
|
|
8
|
+
status: 200,
|
|
9
|
+
headers: { 'content-type': 'application/json' },
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function llmRequest(body: unknown): Request {
|
|
14
|
+
return new Request('http://localhost/_janux/llm', {
|
|
15
|
+
method: 'POST',
|
|
16
|
+
headers: { 'content-type': 'application/json' },
|
|
17
|
+
body: JSON.stringify(body),
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('/_janux/llm handler', () => {
|
|
22
|
+
it('answers with a setup card (503) when no model is configured', async () => {
|
|
23
|
+
const mount = defineAgent({}, { env: {} });
|
|
24
|
+
const response = await mount.handleLlm!(llmRequest({ messages: [], tools: [] }));
|
|
25
|
+
|
|
26
|
+
expect(response.status).toBe(503);
|
|
27
|
+
expect(((await response.json()) as any).type).toBe('setup');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('proxies one turn: system extracted, tools mapped, arguments round-tripped', async () => {
|
|
31
|
+
let sent: any;
|
|
32
|
+
const fetchImpl = async (_url: string, init: RequestInit) => {
|
|
33
|
+
sent = JSON.parse(init.body as string);
|
|
34
|
+
|
|
35
|
+
return anthropicReply([
|
|
36
|
+
{ type: 'text', text: 'Searching…' },
|
|
37
|
+
{ type: 'tool_use', id: 'call_1', name: 'search_docs', input: { query: 'islands' } },
|
|
38
|
+
]);
|
|
39
|
+
};
|
|
40
|
+
const mount = defineAgent({}, { env: ENV, fetchImpl });
|
|
41
|
+
const response = await mount.handleLlm!(
|
|
42
|
+
llmRequest({
|
|
43
|
+
messages: [
|
|
44
|
+
{ role: 'system', content: 'You answer from the docs.' },
|
|
45
|
+
{ role: 'user', content: 'What are islands?' },
|
|
46
|
+
{ role: 'assistant', content: '', toolCalls: [{ id: 'c0', name: 'search_docs', arguments: { query: 'x' } }] },
|
|
47
|
+
{ role: 'tool', content: '{"matches":[]}', toolCallId: 'c0' },
|
|
48
|
+
],
|
|
49
|
+
tools: [{ name: 'search_docs', description: 'Search the docs', inputSchema: { type: 'object' } }],
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
const body = (await response.json()) as any;
|
|
53
|
+
|
|
54
|
+
expect(sent.system).toBe('You answer from the docs.');
|
|
55
|
+
expect(sent.tools).toEqual([{ name: 'search_docs', description: 'Search the docs', input_schema: { type: 'object' } }]);
|
|
56
|
+
expect(sent.messages[0]).toEqual({ role: 'user', content: 'What are islands?' });
|
|
57
|
+
expect(body.text).toBe('Searching…');
|
|
58
|
+
expect(body.toolCalls).toEqual([{ id: 'call_1', name: 'search_docs', arguments: { query: 'islands' } }]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('tolerates an empty body', async () => {
|
|
62
|
+
const fetchImpl = async () => anthropicReply([{ type: 'text', text: 'hi' }]);
|
|
63
|
+
const mount = defineAgent({}, { env: ENV, fetchImpl });
|
|
64
|
+
const response = await mount.handleLlm!(new Request('http://localhost/_janux/llm', { method: 'POST' }));
|
|
65
|
+
|
|
66
|
+
expect(((await response.json()) as any).text).toBe('hi');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { resolveModel, setupCard, type ModelEnv } from './model';
|
|
2
|
+
import { callProvider, type AgentTool, type ChatMessage, type FetchLike, type ToolCall } from './providers';
|
|
3
|
+
|
|
4
|
+
/** One turn of the gui-agent remote-Llm protocol: `{ messages, tools }` in, `{ text, toolCalls }` out. */
|
|
5
|
+
interface WireMessage {
|
|
6
|
+
role: 'system' | 'user' | 'assistant' | 'tool';
|
|
7
|
+
content: string;
|
|
8
|
+
toolCalls?: WireToolCall[];
|
|
9
|
+
toolCallId?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface WireToolCall {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
arguments: Record<string, unknown>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface WireTool {
|
|
19
|
+
name: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
inputSchema?: Record<string, unknown>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface WireBody {
|
|
25
|
+
messages?: WireMessage[];
|
|
26
|
+
tools?: WireTool[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function json(body: unknown, status = 200): Response {
|
|
30
|
+
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function systemOf(messages: WireMessage[]): string {
|
|
34
|
+
return messages
|
|
35
|
+
.filter((message) => message.role === 'system')
|
|
36
|
+
.map((message) => message.content)
|
|
37
|
+
.join('\n\n');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function toChatMessages(messages: WireMessage[]): ChatMessage[] {
|
|
41
|
+
return messages
|
|
42
|
+
.filter((message) => message.role !== 'system')
|
|
43
|
+
.map((message) => ({
|
|
44
|
+
role: message.role as ChatMessage['role'],
|
|
45
|
+
content: message.content,
|
|
46
|
+
toolCallId: message.toolCallId,
|
|
47
|
+
toolCalls: message.toolCalls?.map(({ id, name, arguments: input }) => ({ id, name, input })),
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function toAgentTools(tools: WireTool[]): AgentTool[] {
|
|
52
|
+
return tools.map(({ name, description, inputSchema }) => ({ name, description, input: inputSchema }));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function toWireCalls(toolCalls: ToolCall[]): WireToolCall[] {
|
|
56
|
+
return toolCalls.map(({ id, name, input }) => ({
|
|
57
|
+
id,
|
|
58
|
+
name,
|
|
59
|
+
arguments: (input ?? {}) as Record<string, unknown>,
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The `/_janux/llm` mount: a stateless single-turn proxy for browser-side
|
|
65
|
+
* agent loops (`serverLlm()` from `@janux/agent/local`). The loop and the
|
|
66
|
+
* tools stay in the page; only the model call crosses the wire.
|
|
67
|
+
*/
|
|
68
|
+
export function createLlmHandler(model: string | undefined, env: ModelEnv, fetchImpl: FetchLike) {
|
|
69
|
+
return async (req: Request): Promise<Response> => {
|
|
70
|
+
const resolved = resolveModel(model, env);
|
|
71
|
+
|
|
72
|
+
if (!resolved) return json(setupCard(), 503);
|
|
73
|
+
const body = (await req.json().catch(() => ({}))) as WireBody;
|
|
74
|
+
const messages = body.messages ?? [];
|
|
75
|
+
const reply = await callProvider(
|
|
76
|
+
resolved,
|
|
77
|
+
systemOf(messages),
|
|
78
|
+
toChatMessages(messages),
|
|
79
|
+
toAgentTools(body.tools ?? []),
|
|
80
|
+
fetchImpl,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
return json({ text: reply.text, toolCalls: toWireCalls(reply.toolCalls) });
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { GlobalRegistrator } from '@happy-dom/global-registrator';
|
|
2
|
+
import { afterAll, afterEach, beforeAll, describe, expect, it, mock } from 'bun:test';
|
|
3
|
+
import { registry } from '@aralroca/gui-agent';
|
|
4
|
+
import type { LlmRequest, LlmResponse } from '@aralroca/gui-agent';
|
|
5
|
+
import { createCopilot } from './copilot';
|
|
6
|
+
|
|
7
|
+
beforeAll(() => GlobalRegistrator.register({ url: 'https://app.test/' }));
|
|
8
|
+
afterAll(() => GlobalRegistrator.unregister());
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
registry.clear();
|
|
11
|
+
delete (window as any).janux;
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const MANIFEST_TOOLS = [
|
|
15
|
+
{ name: 'cart.addItem', description: 'Add an item', guard: 'confirm', input: { type: 'object' } },
|
|
16
|
+
{ name: 'api.docs.searchDocs', description: 'Search docs', guard: 'auto', input: { type: 'object' } },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
function installBridge(): ReturnType<typeof mock> {
|
|
20
|
+
const call = mock(async () => ({ ok: true }));
|
|
21
|
+
|
|
22
|
+
(window as any).janux = { manifest: () => ({ tools: MANIFEST_TOOLS }), call };
|
|
23
|
+
|
|
24
|
+
return call;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** An Llm that requests `calls` on its first turn and answers "done" on the next. */
|
|
28
|
+
function scriptedLlm(calls: LlmResponse['toolCalls']): { llm: (request: LlmRequest) => Promise<LlmResponse>; seen: LlmRequest[] } {
|
|
29
|
+
const seen: LlmRequest[] = [];
|
|
30
|
+
const llm = async (request: LlmRequest): Promise<LlmResponse> => {
|
|
31
|
+
seen.push(request);
|
|
32
|
+
|
|
33
|
+
return seen.length === 1 ? { toolCalls: calls } : { text: 'done' };
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
return { llm, seen };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('createCopilot', () => {
|
|
40
|
+
it('exposes sanitized manifest tools to the model and routes calls through the bridge', async () => {
|
|
41
|
+
const call = installBridge();
|
|
42
|
+
const { llm, seen } = scriptedLlm([{ id: '1', name: 'cart_addItem', arguments: { sku: 'a' } }]);
|
|
43
|
+
const copilot = createCopilot({ llm });
|
|
44
|
+
const result = await copilot.ask('add item a');
|
|
45
|
+
|
|
46
|
+
expect(seen[0]!.tools.map((tool) => tool.name)).toEqual(['cart_addItem', 'api_docs_searchDocs', 'navigate']);
|
|
47
|
+
expect(seen[0]!.tools[0]!.description).toContain('a human must approve');
|
|
48
|
+
expect(call).toHaveBeenCalledWith('cart.addItem', { sku: 'a' });
|
|
49
|
+
expect(result.text).toBe('done');
|
|
50
|
+
expect(result.messages.at(-2)?.role).toBe('tool');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('routes api.* tools through /_janux/api instead of the bridge', async () => {
|
|
54
|
+
const call = installBridge();
|
|
55
|
+
const originalFetch = globalThis.fetch;
|
|
56
|
+
const fetchMock = mock(async (url: string) => Response.json({ matches: [] }));
|
|
57
|
+
|
|
58
|
+
globalThis.fetch = fetchMock as any;
|
|
59
|
+
const { llm } = scriptedLlm([{ id: '1', name: 'api_docs_searchDocs', arguments: { query: 'x' } }]);
|
|
60
|
+
|
|
61
|
+
await createCopilot({ llm }).ask('search x');
|
|
62
|
+
globalThis.fetch = originalFetch;
|
|
63
|
+
|
|
64
|
+
expect(call).not.toHaveBeenCalled();
|
|
65
|
+
expect(fetchMock.mock.calls[0]?.[0]).toBe('/_janux/api/docs.searchDocs');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('skips manifest bridging when manifestTools is false, but keeps navigate', async () => {
|
|
69
|
+
installBridge();
|
|
70
|
+
const { llm, seen } = scriptedLlm([]);
|
|
71
|
+
|
|
72
|
+
await createCopilot({ llm, manifestTools: false }).ask('hi');
|
|
73
|
+
|
|
74
|
+
expect(seen[0]!.tools.map((tool) => tool.name)).toEqual(['navigate']);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('navigate drives the page through the framework tool', async () => {
|
|
78
|
+
installBridge();
|
|
79
|
+
document.body.innerHTML = '<a href="/docs/guide/cli-and-deployment">CLI</a>';
|
|
80
|
+
const assign = mock((path: string) => path);
|
|
81
|
+
|
|
82
|
+
(location as any).assign = assign;
|
|
83
|
+
const { llm } = scriptedLlm([{ id: '1', name: 'navigate', arguments: { path: '/docs/guide/cli-and-deployment' } }]);
|
|
84
|
+
|
|
85
|
+
await createCopilot({ llm, manifestTools: false }).ask('go to cli');
|
|
86
|
+
await new Promise((resolve) => setTimeout(resolve, 420));
|
|
87
|
+
|
|
88
|
+
expect(assign).toHaveBeenCalledWith('/docs/guide/cli-and-deployment');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('never overwrites a same-named tool the app registered itself', async () => {
|
|
92
|
+
installBridge();
|
|
93
|
+
const appExecute = mock(async () => 'app wins');
|
|
94
|
+
|
|
95
|
+
registry.register({ name: 'cart_addItem', description: 'App-owned', execute: appExecute });
|
|
96
|
+
const { llm } = scriptedLlm([{ id: '1', name: 'cart_addItem', arguments: {} }]);
|
|
97
|
+
|
|
98
|
+
await createCopilot({ llm }).ask('add');
|
|
99
|
+
|
|
100
|
+
expect(appExecute).toHaveBeenCalled();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('dispose unregisters the bridged tools', async () => {
|
|
104
|
+
installBridge();
|
|
105
|
+
const { llm } = scriptedLlm([]);
|
|
106
|
+
const copilot = createCopilot({ llm });
|
|
107
|
+
|
|
108
|
+
await copilot.ask('hi');
|
|
109
|
+
expect(registry.has('cart_addItem')).toBe(true);
|
|
110
|
+
copilot.dispose();
|
|
111
|
+
expect(registry.has('cart_addItem')).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { GuiAgent, registry } from '@aralroca/gui-agent';
|
|
2
|
+
import type { AgentStep, Confirm, Llm, RunResult, ToolDefinition } from '@aralroca/gui-agent';
|
|
3
|
+
import { createNavigateTool } from 'janux/client';
|
|
4
|
+
|
|
5
|
+
export interface CopilotOptions {
|
|
6
|
+
/** The model driving the loop: `localLlm()`, `serverLlm()`, or any custom {@link Llm}. */
|
|
7
|
+
llm: Llm;
|
|
8
|
+
/** Extra system prompt appended to gui-agent's built-in instructions. */
|
|
9
|
+
instructions?: string;
|
|
10
|
+
/** Maximum tool-calling rounds per question. Default 8. */
|
|
11
|
+
maxSteps?: number;
|
|
12
|
+
/** Confirmation gate for non-read-only tool calls. */
|
|
13
|
+
confirm?: Confirm;
|
|
14
|
+
/** Observe each step of the loop. */
|
|
15
|
+
onStep?: (step: AgentStep) => void;
|
|
16
|
+
/** Expose the Janux manifest tools (intents + api()) to the model. Default true. */
|
|
17
|
+
manifestTools?: boolean;
|
|
18
|
+
/** Synthesize generic click/fill/read tools from the live DOM. Default false. */
|
|
19
|
+
domFallback?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface Copilot {
|
|
23
|
+
/** Run the agent loop toward `question`; resolves with the final answer and transcript. */
|
|
24
|
+
ask(question: string, signal?: AbortSignal): Promise<RunResult>;
|
|
25
|
+
/** Unregister the manifest tools this copilot bridged into gui-agent. */
|
|
26
|
+
dispose(): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Models are steadier with `snake_case` names; mirrors installWebMCP's sanitization. */
|
|
30
|
+
const wireName = (name: string): string => name.replace(/[^\w-]/g, '_');
|
|
31
|
+
|
|
32
|
+
async function callServerTool(name: string, input: unknown): Promise<unknown> {
|
|
33
|
+
const response = await fetch(`/_janux/api/${name.slice('api.'.length)}`, {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: { 'content-type': 'application/json', 'x-janux-origin': 'agent' },
|
|
36
|
+
body: JSON.stringify(input ?? {}),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
return response.json();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function toDefinition(tool: any, bridge: any): ToolDefinition {
|
|
43
|
+
const approval = tool.guard === 'confirm' ? ' Returns a proposal a human must approve.' : '';
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
name: wireName(tool.name),
|
|
47
|
+
description: `${tool.description ?? `Janux tool ${tool.name}`}${approval}`,
|
|
48
|
+
inputSchema: tool.input ?? { type: 'object', properties: {} },
|
|
49
|
+
execute: (input) =>
|
|
50
|
+
tool.name.startsWith('api.') ? callServerTool(tool.name, input) : bridge.call(tool.name, input),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Re-registers every manifest tool (replace-mode: SPA navigations change the surface). */
|
|
55
|
+
function syncManifestTools(registered: Set<string>): void {
|
|
56
|
+
const bridge = (window as any).janux;
|
|
57
|
+
|
|
58
|
+
if (!bridge) return;
|
|
59
|
+
bridge.manifest().tools.forEach((tool: any) => {
|
|
60
|
+
const name = wireName(tool.name);
|
|
61
|
+
|
|
62
|
+
// A same-named tool registered by the app itself (defineTool) wins.
|
|
63
|
+
if (registry.has(name) && !registered.has(name)) return;
|
|
64
|
+
registry.register(toDefinition(tool, bridge), { replace: true, skipModelContext: true });
|
|
65
|
+
registered.add(name);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Mirrors the framework's built-in navigate tool into the gui-agent registry. */
|
|
70
|
+
function syncNavigateTool(registered: Set<string>): void {
|
|
71
|
+
const tool = createNavigateTool();
|
|
72
|
+
|
|
73
|
+
if (registry.has(tool.name) && !registered.has(tool.name)) return;
|
|
74
|
+
registry.register(
|
|
75
|
+
{
|
|
76
|
+
name: tool.name,
|
|
77
|
+
description: tool.description ?? tool.name,
|
|
78
|
+
inputSchema: tool.inputSchema,
|
|
79
|
+
execute: (input) => tool.execute(input),
|
|
80
|
+
},
|
|
81
|
+
{ replace: true, skipModelContext: true },
|
|
82
|
+
);
|
|
83
|
+
registered.add(tool.name);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A browser-side copilot for a Janux app: the gui-agent loop over the app's
|
|
88
|
+
* own tools (manifest intents + api() endpoints, plus anything registered with
|
|
89
|
+
* `defineTool`), driven by a local or server {@link Llm}.
|
|
90
|
+
*/
|
|
91
|
+
export function createCopilot(options: CopilotOptions): Copilot {
|
|
92
|
+
const registered = new Set<string>();
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
ask(question, signal) {
|
|
96
|
+
if (options.manifestTools !== false) syncManifestTools(registered);
|
|
97
|
+
syncNavigateTool(registered);
|
|
98
|
+
const agent = new GuiAgent({
|
|
99
|
+
llm: options.llm,
|
|
100
|
+
systemPrompt: options.instructions,
|
|
101
|
+
maxSteps: options.maxSteps ?? 8,
|
|
102
|
+
domFallback: options.domFallback ?? false,
|
|
103
|
+
confirm: options.confirm,
|
|
104
|
+
onStep: options.onStep,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return agent.run(question, signal);
|
|
108
|
+
},
|
|
109
|
+
dispose() {
|
|
110
|
+
registered.forEach((name) => registry.unregister(name));
|
|
111
|
+
registered.clear();
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@janux/agent/local` — the browser-side copilot runtime.
|
|
3
|
+
*
|
|
4
|
+
* The agent loop runs in the page (via `@aralroca/gui-agent`) against the
|
|
5
|
+
* app's own tools; the model is pluggable: `localLlm()` runs an open-source
|
|
6
|
+
* model on the visitor's machine (WebGPU), `serverLlm()` keeps it server-side.
|
|
7
|
+
*/
|
|
8
|
+
export { createCopilot, type Copilot, type CopilotOptions } from './copilot';
|
|
9
|
+
export {
|
|
10
|
+
DEFAULT_LOCAL_MODEL,
|
|
11
|
+
localLlm,
|
|
12
|
+
serverLlm,
|
|
13
|
+
supportsLocalLlm,
|
|
14
|
+
type LocalLlm,
|
|
15
|
+
type LocalLlmOptions,
|
|
16
|
+
type ServerLlmOptions,
|
|
17
|
+
} from './llm';
|
|
18
|
+
export { defineTool, registry } from '@aralroca/gui-agent';
|
|
19
|
+
export type {
|
|
20
|
+
AgentStep,
|
|
21
|
+
Confirm,
|
|
22
|
+
Llm,
|
|
23
|
+
LlmRequest,
|
|
24
|
+
LlmResponse,
|
|
25
|
+
RunResult,
|
|
26
|
+
ToolDefinition,
|
|
27
|
+
} from '@aralroca/gui-agent';
|
package/src/local/llm.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { createAiSdkLlm, createRemoteLlm } from '@aralroca/gui-agent/ai-sdk';
|
|
2
|
+
import type { Llm, LlmRequest, LlmResponse } from '@aralroca/gui-agent';
|
|
3
|
+
|
|
4
|
+
/** Apache-2.0, ~500 MB in q4f16 and the strongest tool-caller of its size class. */
|
|
5
|
+
export const DEFAULT_LOCAL_MODEL = 'onnx-community/Qwen3-0.6B-ONNX';
|
|
6
|
+
|
|
7
|
+
export interface LocalLlmOptions {
|
|
8
|
+
/** Hugging Face model id (ONNX). Defaults to {@link DEFAULT_LOCAL_MODEL}. */
|
|
9
|
+
modelId?: string;
|
|
10
|
+
device?: 'webgpu' | 'wasm';
|
|
11
|
+
dtype?: string;
|
|
12
|
+
/** Run inference off the main thread (a worker module using `TransformersJSWorkerHandler`). */
|
|
13
|
+
worker?: Worker;
|
|
14
|
+
/** Extra settings forwarded to `generateText` (temperature, maxOutputTokens…). */
|
|
15
|
+
settings?: Record<string, unknown>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface LocalLlm extends Llm {
|
|
19
|
+
/** Download the model (or reuse the browser cache) ahead of the first request. */
|
|
20
|
+
load(options?: { onProgress?: (fraction: number) => void }): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Whether this browser can run the local model (WebGPU available). */
|
|
24
|
+
export function supportsLocalLlm(): boolean {
|
|
25
|
+
return typeof navigator !== 'undefined' && 'gpu' in navigator;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function importProvider(): Promise<any> {
|
|
29
|
+
try {
|
|
30
|
+
return await import('@browser-ai/transformers-js');
|
|
31
|
+
} catch (error) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
'janux: localLlm() requires "@browser-ai/transformers-js" and its peers ' +
|
|
34
|
+
`("ai", "@huggingface/transformers") to be installed. (${String(error)})`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function createSession(options: LocalLlmOptions, onProgress?: (fraction: number) => void): Promise<Llm> {
|
|
40
|
+
const { transformersJS } = await importProvider();
|
|
41
|
+
const model = transformersJS(options.modelId ?? DEFAULT_LOCAL_MODEL, {
|
|
42
|
+
device: options.device ?? 'webgpu',
|
|
43
|
+
dtype: options.dtype ?? 'q4f16',
|
|
44
|
+
...(options.worker ? { worker: options.worker } : {}),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
await model.createSessionWithProgress((fraction: number) => onProgress?.(fraction));
|
|
48
|
+
|
|
49
|
+
return createAiSdkLlm({ model, settings: options.settings });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* An {@link Llm} backed by an open-source model running in the visitor's
|
|
54
|
+
* browser (Transformers.js over WebGPU). The model downloads lazily — call
|
|
55
|
+
* `load()` first to control when (and show progress).
|
|
56
|
+
*/
|
|
57
|
+
export function localLlm(options: LocalLlmOptions = {}): LocalLlm {
|
|
58
|
+
let session: Promise<Llm> | undefined;
|
|
59
|
+
|
|
60
|
+
const ensure = (onProgress?: (fraction: number) => void): Promise<Llm> =>
|
|
61
|
+
(session ??= createSession(options, onProgress));
|
|
62
|
+
const llm = (async (request: LlmRequest): Promise<LlmResponse> => (await ensure())(request)) as LocalLlm;
|
|
63
|
+
|
|
64
|
+
llm.load = async ({ onProgress } = {}) => {
|
|
65
|
+
await ensure(onProgress);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return llm;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ServerLlmOptions {
|
|
72
|
+
/** Defaults to the built-in `/_janux/llm` mount. */
|
|
73
|
+
endpoint?: string;
|
|
74
|
+
headers?: Record<string, string>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* An {@link Llm} backed by the app's server (`/_janux/llm`), where the model
|
|
79
|
+
* and API keys resolve exactly like `defineAgent()`. Same browser-side loop,
|
|
80
|
+
* different brain.
|
|
81
|
+
*/
|
|
82
|
+
export function serverLlm(options: ServerLlmOptions = {}): Llm {
|
|
83
|
+
return createRemoteLlm({ api: options.endpoint ?? '/_janux/llm', headers: options.headers });
|
|
84
|
+
}
|