@powerhousedao/reactor-browser 6.2.2-dev.9 → 6.2.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 +341 -0
- package/dist/attachment-service-KFFLBEuD.d.ts +1062 -0
- package/dist/attachment-service-KFFLBEuD.d.ts.map +1 -0
- package/dist/{client-u13cr-Vg.js → client-B8ezRmBB.js} +30 -3
- package/dist/client-B8ezRmBB.js.map +1 -0
- package/dist/client-BIHN_UyR.d.ts +14 -0
- package/dist/client-BIHN_UyR.d.ts.map +1 -0
- package/dist/{client-_Gh2Uf0E.js → client-D66jVnot.js} +79 -13
- package/dist/client-D66jVnot.js.map +1 -0
- package/dist/{document-by-id-dLYFX-xz.js → document-by-id-BSZqTN66.js} +81 -51
- package/dist/document-by-id-BSZqTN66.js.map +1 -0
- package/dist/document-model-modules-DfQBNGc-.js +419 -0
- package/dist/document-model-modules-DfQBNGc-.js.map +1 -0
- package/dist/document-operations-CBExT76y.js +2544 -0
- package/dist/document-operations-CBExT76y.js.map +1 -0
- package/dist/global-1rvI8VdA.d.ts +400 -0
- package/dist/global-1rvI8VdA.d.ts.map +1 -0
- package/dist/index-BE3wBHLP.d.ts +349 -0
- package/dist/index-BE3wBHLP.d.ts.map +1 -0
- package/dist/index-BIVDXw3K.d.ts +144 -0
- package/dist/index-BIVDXw3K.d.ts.map +1 -0
- package/dist/index.d.ts +385 -718
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +957 -1712
- package/dist/index.js.map +1 -1
- package/dist/{inspector-proxy-CvQDDxmT.d.ts → inspector-proxy-6h5VKAwt.d.ts} +4 -2
- package/dist/inspector-proxy-6h5VKAwt.d.ts.map +1 -0
- package/dist/{make-ph-event-functions-BHoLPif5.js → make-ph-event-functions-DBq3iWYn.js} +1 -1
- package/dist/{make-ph-event-functions-BHoLPif5.js.map → make-ph-event-functions-DBq3iWYn.js.map} +1 -1
- package/dist/{index-DS4W07Y8.d.ts → pglite-CntadC_p-9NDG14dS.d.ts} +2 -142
- package/dist/pglite-CntadC_p-9NDG14dS.d.ts.map +1 -0
- package/dist/{relational-Dj06-YkI.js → relational-D4sxGPz7.js} +2 -2
- package/dist/{relational-Dj06-YkI.js.map → relational-D4sxGPz7.js.map} +1 -1
- package/dist/renown-BHxwP7xU.js +97 -0
- package/dist/renown-BHxwP7xU.js.map +1 -0
- package/dist/{renown-D-1c_Bvx.js → renown-BIEv6bSI.js} +523 -57
- package/dist/renown-BIEv6bSI.js.map +1 -0
- package/dist/src/ai/index.d.ts +371 -0
- package/dist/src/ai/index.d.ts.map +1 -0
- package/dist/src/ai/index.js +1067 -0
- package/dist/src/ai/index.js.map +1 -0
- package/dist/src/analytics/index.d.ts +1 -1
- package/dist/src/analytics/index.js +2 -1
- package/dist/src/analytics/index.js.map +1 -1
- package/dist/src/graphql/client.d.ts +3 -2
- package/dist/src/graphql/client.js +1 -1
- package/dist/src/graphql-client/entry.d.ts +4 -0
- package/dist/src/graphql-client/entry.js +4 -0
- package/dist/src/relational/index.d.ts +1 -1
- package/dist/src/relational/index.js +1 -1
- package/dist/src/renown/index.d.ts +2 -2
- package/dist/src/renown/index.js +3 -2
- package/dist/src/rpc/index.d.ts +6 -3
- package/dist/src/rpc/index.d.ts.map +1 -1
- package/dist/src/rpc/index.js +41 -10
- package/dist/src/rpc/index.js.map +1 -1
- package/dist/store-BJYcr_FL.js +37 -0
- package/dist/store-BJYcr_FL.js.map +1 -0
- package/dist/switchboard-D4-_jwk-.js +271 -0
- package/dist/switchboard-D4-_jwk-.js.map +1 -0
- package/dist/{types-CPV0Cnsy.d.ts → types-6vDzbRRS.d.ts} +1 -1
- package/dist/{types-CPV0Cnsy.d.ts.map → types-6vDzbRRS.d.ts.map} +1 -1
- package/dist/{client-09xv0Dq6.d.ts → types-Dcv1ZLoW.d.ts} +87 -19
- package/dist/types-Dcv1ZLoW.d.ts.map +1 -0
- package/package.json +27 -10
- package/dist/client-09xv0Dq6.d.ts.map +0 -1
- package/dist/client-_Gh2Uf0E.js.map +0 -1
- package/dist/client-u13cr-Vg.js.map +0 -1
- package/dist/document-by-id-dLYFX-xz.js.map +0 -1
- package/dist/index-D3bV5rcl.d.ts +0 -216
- package/dist/index-D3bV5rcl.d.ts.map +0 -1
- package/dist/index-DS4W07Y8.d.ts.map +0 -1
- package/dist/inspector-proxy-CvQDDxmT.d.ts.map +0 -1
- package/dist/renown-D-1c_Bvx.js.map +0 -1
|
@@ -0,0 +1,1067 @@
|
|
|
1
|
+
import { S as useSelectedDriveSafe, s as useSelectedNode } from "../../document-model-modules-DfQBNGc-.js";
|
|
2
|
+
import { a as getBrowserSyncManager, i as createSwitchboardSchemaTool, l as useSelectedDocumentId, n as SWITCHBOARD_INTROSPECTION_QUERY, o as resolveDriveSwitchboard, r as SWITCHBOARD_SCHEMA_TOOL_NAME, s as summarizeIntrospectionSchema, t as GQL_CHANNEL_SUFFIX } from "../../switchboard-D4-_jwk-.js";
|
|
3
|
+
import { isFileNode, isFolderNode } from "@powerhousedao/shared/document-drive";
|
|
4
|
+
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
5
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { isStepCount, streamText, tool } from "ai";
|
|
8
|
+
import { toResponseMessages } from "ai/internal";
|
|
9
|
+
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
|
10
|
+
import { ArrowUp, Check, Maximize2, MessageCircle, Minimize2, Square, Trash2, X } from "lucide-react";
|
|
11
|
+
import ReactMarkdown from "react-markdown";
|
|
12
|
+
import remarkGfm from "remark-gfm";
|
|
13
|
+
//#region src/ai/types.ts
|
|
14
|
+
/** Tool names whose execution mutates the reactor and requires approval. */
|
|
15
|
+
const WRITE_TOOLS = new Set([
|
|
16
|
+
"createDocument",
|
|
17
|
+
"addActions",
|
|
18
|
+
"deleteDocument",
|
|
19
|
+
"addDrive",
|
|
20
|
+
"deleteDrive",
|
|
21
|
+
"addRemoteDrive"
|
|
22
|
+
]);
|
|
23
|
+
/**
|
|
24
|
+
* Whether executing the tool mutates state and requires user approval:
|
|
25
|
+
* the built-in write tools, or any tool flagged destructive in its
|
|
26
|
+
* annotations (covers package-provided tools outside the built-in set).
|
|
27
|
+
*/
|
|
28
|
+
function isWriteTool(name, annotations) {
|
|
29
|
+
return WRITE_TOOLS.has(name) || annotations?.destructiveHint === true;
|
|
30
|
+
}
|
|
31
|
+
//#endregion
|
|
32
|
+
//#region src/ai/agent.ts
|
|
33
|
+
/** Hard cap on model round-trips per user message (each tool step counts). */
|
|
34
|
+
const MAX_AGENT_STEPS = 10;
|
|
35
|
+
/**
|
|
36
|
+
* Hard cap on the characters of one tool result fed back to the model.
|
|
37
|
+
*
|
|
38
|
+
* Large results (whole document states, catalog listings, schema
|
|
39
|
+
* summaries) accumulate across steps because every step resends the whole
|
|
40
|
+
* conversation; without a budget a few broad queries exhaust the model's
|
|
41
|
+
* context and stall the turn. Over-budget results are truncated with a
|
|
42
|
+
* marker so the model re-queries more narrowly.
|
|
43
|
+
*/
|
|
44
|
+
const MAX_TOOL_RESULT_CHARS = 12e3;
|
|
45
|
+
const TRUNCATION_NOTE = "\n...[truncated: this tool result exceeded the context budget; re-query with a more specific filter or fewer items]";
|
|
46
|
+
function boundedToolResult(value) {
|
|
47
|
+
const text = resultText(value);
|
|
48
|
+
if (text.length <= 12e3) return value;
|
|
49
|
+
return text.slice(0, MAX_TOOL_RESULT_CHARS) + TRUNCATION_NOTE;
|
|
50
|
+
}
|
|
51
|
+
function resultText(value) {
|
|
52
|
+
if (typeof value === "string") return value;
|
|
53
|
+
const json = JSON.stringify(value);
|
|
54
|
+
return typeof json === "string" ? json : "undefined";
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Number of the most recent turns whose tool results are kept in full in
|
|
58
|
+
* the committed history. Tool results are re-derivable (the store is the
|
|
59
|
+
* source of truth), so results older than that window are replaced with
|
|
60
|
+
* one-line stubs: this is what keeps a long conversation from exhausting
|
|
61
|
+
* the model's context.
|
|
62
|
+
*/
|
|
63
|
+
const FULL_RESULT_TURNS = 2;
|
|
64
|
+
/** Tool results smaller than this are cheap to keep and are never stubbed. */
|
|
65
|
+
const STUB_MIN_CHARS = 500;
|
|
66
|
+
function toolResultChars(output) {
|
|
67
|
+
const part = output;
|
|
68
|
+
if (part.type === "text" && typeof part.value === "string") return part.value.length;
|
|
69
|
+
const target = part.value !== void 0 ? part.value : output;
|
|
70
|
+
const json = JSON.stringify(target);
|
|
71
|
+
return typeof json === "string" ? json.length : 0;
|
|
72
|
+
}
|
|
73
|
+
function argDigest(input) {
|
|
74
|
+
if (input === void 0) return "";
|
|
75
|
+
let text;
|
|
76
|
+
try {
|
|
77
|
+
text = JSON.stringify(input);
|
|
78
|
+
} catch {
|
|
79
|
+
text = "";
|
|
80
|
+
}
|
|
81
|
+
if (text.length <= 80) return text;
|
|
82
|
+
return `${text.slice(0, 77)}…`;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Replaces tool results older than the last {@link FULL_RESULT_TURNS}
|
|
86
|
+
* turns with one-line stubs naming the tool and its arguments. User and
|
|
87
|
+
* assistant messages are never touched, and tool calls keep their input,
|
|
88
|
+
* so the model can re-query any stubbed result with the same arguments.
|
|
89
|
+
* Idempotent: stubs are below {@link STUB_MIN_CHARS} and pass through.
|
|
90
|
+
*/
|
|
91
|
+
function stubStaleToolResults(history) {
|
|
92
|
+
let users = 0;
|
|
93
|
+
let windowStart = 0;
|
|
94
|
+
for (let i = history.length - 1; i >= 0; i--) if (history[i].role === "user") {
|
|
95
|
+
users += 1;
|
|
96
|
+
if (users === 2) {
|
|
97
|
+
windowStart = i;
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (users < 2) return history;
|
|
102
|
+
const callInputs = /* @__PURE__ */ new Map();
|
|
103
|
+
for (const m of history) if (m.role === "assistant" && Array.isArray(m.content)) {
|
|
104
|
+
for (const part of m.content) if (part.type === "tool-call") callInputs.set(part.toolCallId, part.input);
|
|
105
|
+
}
|
|
106
|
+
return history.map((m, i) => {
|
|
107
|
+
if (i >= windowStart || m.role !== "tool") return m;
|
|
108
|
+
const content = m.content.map((part) => {
|
|
109
|
+
if (part.type !== "tool-result") return part;
|
|
110
|
+
const chars = toolResultChars(part.output);
|
|
111
|
+
if (chars < 500) return part;
|
|
112
|
+
const stub = {
|
|
113
|
+
type: "text",
|
|
114
|
+
value: `[result omitted: ${part.toolName} ${argDigest(callInputs.get(part.toolCallId))} — ${chars} chars; re-query the tool to refetch]`
|
|
115
|
+
};
|
|
116
|
+
return {
|
|
117
|
+
...part,
|
|
118
|
+
output: stub
|
|
119
|
+
};
|
|
120
|
+
});
|
|
121
|
+
if (!content.some((part, index) => part !== m.content[index])) return m;
|
|
122
|
+
return {
|
|
123
|
+
...m,
|
|
124
|
+
content
|
|
125
|
+
};
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Creates the chat language model for a user-supplied OpenAI-compatible
|
|
130
|
+
* endpoint. Requests go directly from the browser to the endpoint; the API
|
|
131
|
+
* key is never sent to any Powerhouse server.
|
|
132
|
+
*/
|
|
133
|
+
/**
|
|
134
|
+
* Resolves the configured base URL. Relative paths (e.g. `/v1`) resolve
|
|
135
|
+
* against the page origin, so single-origin deployments can serve the
|
|
136
|
+
* endpoint behind the same reverse proxy without CORS.
|
|
137
|
+
*/
|
|
138
|
+
function resolveBaseUrl(raw) {
|
|
139
|
+
const base = raw.trim().replace(/\/+$/, "");
|
|
140
|
+
if (/^https?:\/\//i.test(base)) return base;
|
|
141
|
+
if (typeof window === "undefined") return base;
|
|
142
|
+
return `${window.location.origin}${base.startsWith("/") ? "" : "/"}${base}`;
|
|
143
|
+
}
|
|
144
|
+
function createReactorChatModel(settings) {
|
|
145
|
+
return createOpenAICompatible({
|
|
146
|
+
name: "reactor-chat",
|
|
147
|
+
baseURL: resolveBaseUrl(settings.baseUrl),
|
|
148
|
+
apiKey: settings.apiKey
|
|
149
|
+
}).chatModel(settings.model.trim());
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Unwraps the MCP `CallToolResult` envelope produced by the reactor tool
|
|
153
|
+
* core into a plain value for the AI SDK. Throws for error results so the
|
|
154
|
+
* SDK surfaces them as tool errors in the next model step.
|
|
155
|
+
*/
|
|
156
|
+
function unwrapToolResult(raw) {
|
|
157
|
+
if (raw && typeof raw === "object") {
|
|
158
|
+
const envelope = raw;
|
|
159
|
+
if (typeof envelope.isError === "boolean" || Array.isArray(envelope.content)) {
|
|
160
|
+
if (envelope.isError) {
|
|
161
|
+
const text = envelope.content?.find((c) => c.type === "text")?.text ?? "Unknown tool error";
|
|
162
|
+
throw new Error(text.replace(/^Error:\s*/, ""));
|
|
163
|
+
}
|
|
164
|
+
if (envelope.structuredContent !== void 0) return envelope.structuredContent;
|
|
165
|
+
const textPart = envelope.content?.find((c) => c.type === "text");
|
|
166
|
+
if (textPart?.text !== void 0) try {
|
|
167
|
+
return JSON.parse(textPart.text);
|
|
168
|
+
} catch {
|
|
169
|
+
return textPart.text;
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return raw;
|
|
175
|
+
}
|
|
176
|
+
/** Builds the system prompt, grounding the agent in the current selection. */
|
|
177
|
+
function buildSystemPrompt(context) {
|
|
178
|
+
const lines = [
|
|
179
|
+
"You are an assistant embedded in the Powerhouse Connect drive explorer.",
|
|
180
|
+
"You operate on a local-first document store (the reactor) exclusively through the provided tools.",
|
|
181
|
+
"Documents are instances of typed document models; drives are collections that group documents and folders.",
|
|
182
|
+
"Prefer read-only tools to discover state (document models, drives, documents, relationships) before making changes.",
|
|
183
|
+
"Never invent document ids, drive ids, folder ids or document model types — discover them with the read tools first.",
|
|
184
|
+
"Never accept secret values (passwords, tokens, API keys) in chat. When a connection or configuration requires a secret, tell the user to enter it in the relevant editor (e.g. the connection editor) and point them to the document. Never ask the user to paste a secret into the chat.",
|
|
185
|
+
"When the user refers to 'this', 'here' or 'it' without naming a target, they mean the current selection below; prefer it for create/modify targets.",
|
|
186
|
+
"Tool results may be truncated when they are large: if you see a truncation marker, narrow the query (more specific filter, fewer items) instead of retrying the same call.",
|
|
187
|
+
"Older tool results in this conversation may be replaced by a short '[result omitted: ...]' stub as the context grows; that is normal housekeeping, not an error. If you still need the data, call the same tool again with the same arguments."
|
|
188
|
+
];
|
|
189
|
+
const selection = [];
|
|
190
|
+
if (context.driveName) selection.push(`The current drive is "${context.driveName}"` + (context.driveId ? ` (id: ${context.driveId})` : "") + ".");
|
|
191
|
+
if (context.nodeKind === "folder" && context.nodeName) selection.push(`The current folder is "${context.nodeName}".`);
|
|
192
|
+
if (context.documentType) selection.push(`The current document is "${context.documentName ?? "unnamed"}" of type "${context.documentType}"` + (context.documentId ? ` (id: ${context.documentId})` : "") + ".");
|
|
193
|
+
if (context.switchboardUrl) selection.push(`The switchboard for this drive is at ${context.switchboardUrl}; its GraphQL endpoint is ${context.switchboardGraphqlUrl ?? ""}. Use the getSwitchboardSchema tool to list the queries and mutations it exposes.`);
|
|
194
|
+
else if (context.driveId) selection.push("This drive is not synced to a switchboard, so no switchboard endpoints are available for it.");
|
|
195
|
+
if (selection.length > 0) lines.push("Current selection:", ...selection.map((s) => `- ${s}`));
|
|
196
|
+
else lines.push("Nothing is currently selected. When the target of an action is ambiguous, ask the user which drive, folder or document it should apply to.");
|
|
197
|
+
return lines.join("\n");
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Runs one tool-loop conversation turn against an OpenAI-compatible model.
|
|
201
|
+
*
|
|
202
|
+
* The agent keeps the model message history across turns. Write tools are
|
|
203
|
+
* gated by the AI SDK's tool approval: when auto-approval is off, the
|
|
204
|
+
* pending user decision is bridged through {@link approve}/{@link reject}.
|
|
205
|
+
*/
|
|
206
|
+
var ReactorChatAgent = class {
|
|
207
|
+
history;
|
|
208
|
+
approvals = /* @__PURE__ */ new Map();
|
|
209
|
+
constructor(options) {
|
|
210
|
+
this.options = options;
|
|
211
|
+
this.history = options.history ?? [];
|
|
212
|
+
}
|
|
213
|
+
/** The model history after the last completed turn, for the next agent. */
|
|
214
|
+
getHistory() {
|
|
215
|
+
return this.history;
|
|
216
|
+
}
|
|
217
|
+
/** Clears the conversation history (a fresh chat). */
|
|
218
|
+
reset() {
|
|
219
|
+
this.history = [];
|
|
220
|
+
this.cancelPendingApprovals();
|
|
221
|
+
}
|
|
222
|
+
/** Approves a pending write tool call, resuming the agent loop. */
|
|
223
|
+
approve(toolCallId) {
|
|
224
|
+
this.resolveApproval(toolCallId, true);
|
|
225
|
+
}
|
|
226
|
+
/** Rejects a pending write tool call; the rejection is fed to the model. */
|
|
227
|
+
reject(toolCallId) {
|
|
228
|
+
this.resolveApproval(toolCallId, false);
|
|
229
|
+
}
|
|
230
|
+
resolveApproval(toolCallId, approved) {
|
|
231
|
+
const resolve = this.approvals.get(toolCallId);
|
|
232
|
+
if (resolve) {
|
|
233
|
+
this.approvals.delete(toolCallId);
|
|
234
|
+
resolve(approved);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
cancelPendingApprovals() {
|
|
238
|
+
for (const resolve of this.approvals.values()) resolve(false);
|
|
239
|
+
this.approvals.clear();
|
|
240
|
+
}
|
|
241
|
+
/** Runs one user turn: appends the message, streams the model, commits history. */
|
|
242
|
+
async send(text) {
|
|
243
|
+
const { onEvent, signal, settings } = this.options;
|
|
244
|
+
const userMessage = {
|
|
245
|
+
role: "user",
|
|
246
|
+
content: text
|
|
247
|
+
};
|
|
248
|
+
const messages = [...this.history, userMessage];
|
|
249
|
+
const tools = {};
|
|
250
|
+
for (const descriptor of this.options.tools) tools[descriptor.name] = tool({
|
|
251
|
+
description: descriptor.description,
|
|
252
|
+
inputSchema: z.object(descriptor.inputSchema),
|
|
253
|
+
execute: async (args) => boundedToolResult(unwrapToolResult(await descriptor.callback(args)))
|
|
254
|
+
});
|
|
255
|
+
const model = this.options.model ?? createReactorChatModel(settings);
|
|
256
|
+
const turnMessages = [];
|
|
257
|
+
let lastInputTokens;
|
|
258
|
+
const result = streamText({
|
|
259
|
+
model,
|
|
260
|
+
system: buildSystemPrompt(this.options.context),
|
|
261
|
+
messages,
|
|
262
|
+
tools,
|
|
263
|
+
stopWhen: isStepCount(10),
|
|
264
|
+
abortSignal: signal,
|
|
265
|
+
onStepEnd: async (step) => {
|
|
266
|
+
turnMessages.push(...await toResponseMessages({
|
|
267
|
+
content: step.content,
|
|
268
|
+
tools
|
|
269
|
+
}));
|
|
270
|
+
if (typeof step.usage.inputTokens === "number") lastInputTokens = step.usage.inputTokens;
|
|
271
|
+
},
|
|
272
|
+
toolApproval: async ({ toolCall }) => {
|
|
273
|
+
const descriptor = this.options.tools.find((t) => t.name === toolCall.toolName);
|
|
274
|
+
if (settings.autoApproveWrites || !isWriteTool(toolCall.toolName, descriptor?.annotations)) return "not-applicable";
|
|
275
|
+
const toolCallId = toolCall.toolCallId;
|
|
276
|
+
onEvent({
|
|
277
|
+
type: "approval-request",
|
|
278
|
+
toolCallId,
|
|
279
|
+
name: toolCall.toolName,
|
|
280
|
+
args: toolCall.input
|
|
281
|
+
});
|
|
282
|
+
const approved = await new Promise((resolve) => {
|
|
283
|
+
this.approvals.set(toolCallId, resolve);
|
|
284
|
+
});
|
|
285
|
+
onEvent({
|
|
286
|
+
type: "approval-resolved",
|
|
287
|
+
toolCallId,
|
|
288
|
+
approved
|
|
289
|
+
});
|
|
290
|
+
return approved ? "approved" : {
|
|
291
|
+
type: "denied",
|
|
292
|
+
reason: "The user denied this action. Do not retry it; acknowledge the denial and ask how to proceed."
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
for await (const part of result.fullStream) this.handleStreamPart(part, onEvent);
|
|
297
|
+
if (lastInputTokens !== void 0) onEvent({
|
|
298
|
+
type: "usage",
|
|
299
|
+
inputTokens: lastInputTokens
|
|
300
|
+
});
|
|
301
|
+
this.history = stubStaleToolResults([
|
|
302
|
+
...this.history,
|
|
303
|
+
userMessage,
|
|
304
|
+
...this.dropUnresolvedToolCalls(turnMessages)
|
|
305
|
+
]);
|
|
306
|
+
onEvent({ type: "finish" });
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Removes tool calls that were never executed (step cap or abort):
|
|
310
|
+
* OpenAI-compatible APIs reject a tool call without its tool result.
|
|
311
|
+
*/
|
|
312
|
+
dropUnresolvedToolCalls(messages) {
|
|
313
|
+
const resolved = /* @__PURE__ */ new Set();
|
|
314
|
+
for (const m of messages) if (m.role === "tool") {
|
|
315
|
+
for (const part of m.content) if (part.type === "tool-result") resolved.add(part.toolCallId);
|
|
316
|
+
}
|
|
317
|
+
const out = [...messages];
|
|
318
|
+
if (out.length === 0) return out;
|
|
319
|
+
const last = out[out.length - 1];
|
|
320
|
+
if (last.role !== "assistant" || typeof last.content === "string") return out;
|
|
321
|
+
const content = last.content.filter((part) => part.type !== "tool-call" || resolved.has(part.toolCallId));
|
|
322
|
+
if (content.length === 0) out.pop();
|
|
323
|
+
else out[out.length - 1] = {
|
|
324
|
+
...last,
|
|
325
|
+
content
|
|
326
|
+
};
|
|
327
|
+
return out;
|
|
328
|
+
}
|
|
329
|
+
handleStreamPart(part, onEvent) {
|
|
330
|
+
const p = part;
|
|
331
|
+
switch (p.type) {
|
|
332
|
+
case "text-delta": {
|
|
333
|
+
const { text } = p;
|
|
334
|
+
onEvent({
|
|
335
|
+
type: "text-delta",
|
|
336
|
+
delta: text
|
|
337
|
+
});
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
case "tool-call": {
|
|
341
|
+
const { toolCallId, toolName, input } = p;
|
|
342
|
+
onEvent({
|
|
343
|
+
type: "tool-start",
|
|
344
|
+
toolCallId,
|
|
345
|
+
name: toolName,
|
|
346
|
+
args: input
|
|
347
|
+
});
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
case "tool-result": {
|
|
351
|
+
const { toolCallId, toolName, output } = p;
|
|
352
|
+
onEvent({
|
|
353
|
+
type: "tool-result",
|
|
354
|
+
toolCallId,
|
|
355
|
+
name: toolName,
|
|
356
|
+
state: "done",
|
|
357
|
+
result: output
|
|
358
|
+
});
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
case "tool-error": {
|
|
362
|
+
const { toolCallId, toolName, error } = p;
|
|
363
|
+
onEvent({
|
|
364
|
+
type: "tool-result",
|
|
365
|
+
toolCallId,
|
|
366
|
+
name: toolName,
|
|
367
|
+
state: "error",
|
|
368
|
+
error: error instanceof Error ? error.message : String(error)
|
|
369
|
+
});
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
case "tool-approval-response": {
|
|
373
|
+
const { approved, toolCall } = p;
|
|
374
|
+
if (!approved) onEvent({
|
|
375
|
+
type: "tool-result",
|
|
376
|
+
toolCallId: toolCall.toolCallId,
|
|
377
|
+
name: toolCall.toolName,
|
|
378
|
+
state: "rejected"
|
|
379
|
+
});
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
case "tool-output-denied": {
|
|
383
|
+
const { toolCallId, toolName } = p;
|
|
384
|
+
onEvent({
|
|
385
|
+
type: "tool-result",
|
|
386
|
+
toolCallId,
|
|
387
|
+
name: toolName,
|
|
388
|
+
state: "rejected"
|
|
389
|
+
});
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
case "abort": break;
|
|
393
|
+
case "error": {
|
|
394
|
+
const { error } = p;
|
|
395
|
+
onEvent({
|
|
396
|
+
type: "error",
|
|
397
|
+
error: error instanceof Error ? error.message : String(error)
|
|
398
|
+
});
|
|
399
|
+
break;
|
|
400
|
+
}
|
|
401
|
+
default: break;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
//#endregion
|
|
406
|
+
//#region src/ai/components/approval-card.tsx
|
|
407
|
+
function formatArgs$1(args) {
|
|
408
|
+
let json;
|
|
409
|
+
try {
|
|
410
|
+
json = JSON.stringify(args, null, 2);
|
|
411
|
+
} catch {
|
|
412
|
+
return String(args);
|
|
413
|
+
}
|
|
414
|
+
return json.length > 400 ? `${json.slice(0, 400)}\n…` : json;
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* In-chat approval card for a write tool call. Approving or rejecting
|
|
418
|
+
* resolves the pending approval inside the agent loop.
|
|
419
|
+
*/
|
|
420
|
+
function ApprovalCard({ approval, onApprove, onReject }) {
|
|
421
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
422
|
+
className: "rounded-lg border border-border bg-muted/40 p-3",
|
|
423
|
+
children: [
|
|
424
|
+
/* @__PURE__ */ jsxs("p", {
|
|
425
|
+
className: "mb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground",
|
|
426
|
+
children: ["Approval required — ", approval.name]
|
|
427
|
+
}),
|
|
428
|
+
/* @__PURE__ */ jsx("pre", {
|
|
429
|
+
className: "mb-3 max-h-40 overflow-auto rounded bg-background p-2 text-xs text-foreground",
|
|
430
|
+
children: formatArgs$1(approval.args)
|
|
431
|
+
}),
|
|
432
|
+
/* @__PURE__ */ jsxs("div", {
|
|
433
|
+
className: "flex gap-2",
|
|
434
|
+
children: [/* @__PURE__ */ jsxs("button", {
|
|
435
|
+
type: "button",
|
|
436
|
+
onClick: () => onApprove(approval.toolCallId),
|
|
437
|
+
className: "flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90",
|
|
438
|
+
children: [/* @__PURE__ */ jsx(Check, { size: 14 }), "Approve"]
|
|
439
|
+
}), /* @__PURE__ */ jsxs("button", {
|
|
440
|
+
type: "button",
|
|
441
|
+
onClick: () => onReject(approval.toolCallId),
|
|
442
|
+
className: "flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm font-medium text-foreground hover:bg-muted",
|
|
443
|
+
children: [/* @__PURE__ */ jsx(X, { size: 14 }), "Reject"]
|
|
444
|
+
})]
|
|
445
|
+
})
|
|
446
|
+
]
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
//#endregion
|
|
450
|
+
//#region src/ai/components/markdown.tsx
|
|
451
|
+
/**
|
|
452
|
+
* Markdown renderer for assistant messages.
|
|
453
|
+
*
|
|
454
|
+
* Uses react-markdown, which builds React elements from the markdown AST and
|
|
455
|
+
* does not execute raw HTML — safe for model-generated content. remark-gfm
|
|
456
|
+
* adds the table/strikethrough/task-list syntax the agent emits. Element
|
|
457
|
+
* styling is scoped to the chat window's text-sm scale via Tailwind
|
|
458
|
+
* utilities, so no extra CSS is needed.
|
|
459
|
+
*/
|
|
460
|
+
function Markdown({ text }) {
|
|
461
|
+
return /* @__PURE__ */ jsx(ReactMarkdown, {
|
|
462
|
+
remarkPlugins: [remarkGfm],
|
|
463
|
+
components: {
|
|
464
|
+
p: ({ children }) => /* @__PURE__ */ jsx("p", {
|
|
465
|
+
className: "my-1.5 leading-relaxed first:mt-0 last:mb-0",
|
|
466
|
+
children
|
|
467
|
+
}),
|
|
468
|
+
h1: ({ children }) => /* @__PURE__ */ jsx("h1", {
|
|
469
|
+
className: "mb-1 mt-3 font-semibold first:mt-0",
|
|
470
|
+
children
|
|
471
|
+
}),
|
|
472
|
+
h2: ({ children }) => /* @__PURE__ */ jsx("h2", {
|
|
473
|
+
className: "mb-1 mt-3 font-semibold first:mt-0",
|
|
474
|
+
children
|
|
475
|
+
}),
|
|
476
|
+
h3: ({ children }) => /* @__PURE__ */ jsx("h3", {
|
|
477
|
+
className: "mb-1 mt-2.5 font-semibold first:mt-0",
|
|
478
|
+
children
|
|
479
|
+
}),
|
|
480
|
+
ul: ({ children }) => /* @__PURE__ */ jsx("ul", {
|
|
481
|
+
className: "my-1.5 list-disc space-y-0.5 pl-5",
|
|
482
|
+
children
|
|
483
|
+
}),
|
|
484
|
+
ol: ({ children }) => /* @__PURE__ */ jsx("ol", {
|
|
485
|
+
className: "my-1.5 list-decimal space-y-0.5 pl-5",
|
|
486
|
+
children
|
|
487
|
+
}),
|
|
488
|
+
li: ({ children }) => /* @__PURE__ */ jsx("li", {
|
|
489
|
+
className: "leading-relaxed",
|
|
490
|
+
children
|
|
491
|
+
}),
|
|
492
|
+
blockquote: ({ children }) => /* @__PURE__ */ jsx("blockquote", {
|
|
493
|
+
className: "my-1.5 border-l-2 border-border pl-2.5 text-muted-foreground",
|
|
494
|
+
children
|
|
495
|
+
}),
|
|
496
|
+
hr: () => /* @__PURE__ */ jsx("hr", { className: "my-2 border-border" }),
|
|
497
|
+
a: ({ children, href }) => /* @__PURE__ */ jsx("a", {
|
|
498
|
+
href,
|
|
499
|
+
target: "_blank",
|
|
500
|
+
rel: "noreferrer noopener",
|
|
501
|
+
className: "text-primary underline",
|
|
502
|
+
children
|
|
503
|
+
}),
|
|
504
|
+
table: ({ children }) => /* @__PURE__ */ jsx("div", {
|
|
505
|
+
className: "my-1.5 overflow-x-auto",
|
|
506
|
+
children: /* @__PURE__ */ jsx("table", {
|
|
507
|
+
className: "w-full border-collapse text-xs",
|
|
508
|
+
children
|
|
509
|
+
})
|
|
510
|
+
}),
|
|
511
|
+
thead: ({ children }) => /* @__PURE__ */ jsx("thead", { children }),
|
|
512
|
+
th: ({ children }) => /* @__PURE__ */ jsx("th", {
|
|
513
|
+
className: "border border-border bg-muted px-2 py-1 text-left font-semibold",
|
|
514
|
+
children
|
|
515
|
+
}),
|
|
516
|
+
td: ({ children }) => /* @__PURE__ */ jsx("td", {
|
|
517
|
+
className: "border border-border px-2 py-1 align-top",
|
|
518
|
+
children
|
|
519
|
+
}),
|
|
520
|
+
pre: ({ children }) => /* @__PURE__ */ jsx("pre", {
|
|
521
|
+
className: "my-1.5 overflow-x-auto rounded-md bg-muted p-2.5 text-xs leading-relaxed",
|
|
522
|
+
children
|
|
523
|
+
}),
|
|
524
|
+
code: ({ children, className }) => {
|
|
525
|
+
if ((className ?? "").startsWith("language-")) return /* @__PURE__ */ jsx("code", {
|
|
526
|
+
className,
|
|
527
|
+
children
|
|
528
|
+
});
|
|
529
|
+
return /* @__PURE__ */ jsx("code", {
|
|
530
|
+
className: "rounded bg-muted px-1 py-0.5 text-[0.8125rem]",
|
|
531
|
+
children
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
},
|
|
535
|
+
children: text
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
//#endregion
|
|
539
|
+
//#region src/ai/components/chat-window.tsx
|
|
540
|
+
function formatArgs(args) {
|
|
541
|
+
let json;
|
|
542
|
+
try {
|
|
543
|
+
json = JSON.stringify(args);
|
|
544
|
+
} catch {
|
|
545
|
+
return String(args);
|
|
546
|
+
}
|
|
547
|
+
return json.length > 200 ? `${json.slice(0, 200)}…` : json;
|
|
548
|
+
}
|
|
549
|
+
function ToolCard({ part }) {
|
|
550
|
+
const [expanded, setExpanded] = useState(false);
|
|
551
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
552
|
+
className: "rounded-lg border border-border bg-background p-2",
|
|
553
|
+
children: [/* @__PURE__ */ jsxs("button", {
|
|
554
|
+
type: "button",
|
|
555
|
+
onClick: () => setExpanded((e) => !e),
|
|
556
|
+
className: "flex w-full items-center gap-2 text-left text-xs text-foreground",
|
|
557
|
+
children: [
|
|
558
|
+
part.state === "done" && /* @__PURE__ */ jsx(Check, {
|
|
559
|
+
size: 13,
|
|
560
|
+
className: "shrink-0 text-info"
|
|
561
|
+
}),
|
|
562
|
+
part.state === "error" && /* @__PURE__ */ jsx(X, {
|
|
563
|
+
size: 13,
|
|
564
|
+
className: "shrink-0 text-destructive"
|
|
565
|
+
}),
|
|
566
|
+
part.state === "rejected" && /* @__PURE__ */ jsx(X, {
|
|
567
|
+
size: 13,
|
|
568
|
+
className: "shrink-0 text-muted-foreground"
|
|
569
|
+
}),
|
|
570
|
+
part.state === "executing" && /* @__PURE__ */ jsx("span", {
|
|
571
|
+
className: "shrink-0 text-muted-foreground",
|
|
572
|
+
children: "⋯"
|
|
573
|
+
}),
|
|
574
|
+
/* @__PURE__ */ jsxs("span", {
|
|
575
|
+
className: "font-mono text-muted-foreground",
|
|
576
|
+
children: [
|
|
577
|
+
part.name,
|
|
578
|
+
"(",
|
|
579
|
+
formatArgs(part.args),
|
|
580
|
+
")"
|
|
581
|
+
]
|
|
582
|
+
})
|
|
583
|
+
]
|
|
584
|
+
}), expanded && /* @__PURE__ */ jsx("pre", {
|
|
585
|
+
className: "mt-2 max-h-40 overflow-auto rounded bg-muted p-2 text-xs text-foreground",
|
|
586
|
+
children: JSON.stringify({
|
|
587
|
+
args: part.args,
|
|
588
|
+
result: part.result,
|
|
589
|
+
error: part.error
|
|
590
|
+
}, null, 2)
|
|
591
|
+
})]
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
function MessageView({ message, onApprove, onReject }) {
|
|
595
|
+
if (message.role === "user") return /* @__PURE__ */ jsx("div", {
|
|
596
|
+
className: "flex justify-end",
|
|
597
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
598
|
+
className: "max-w-[85%] whitespace-pre-wrap rounded-lg bg-muted px-3 py-2 text-sm text-foreground",
|
|
599
|
+
children: message.parts.filter((p) => p.type === "text").map((p) => p.text).join("")
|
|
600
|
+
})
|
|
601
|
+
});
|
|
602
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
603
|
+
className: "space-y-2",
|
|
604
|
+
children: [message.parts.map((part, index) => part.type === "text" ? /* @__PURE__ */ jsx("div", {
|
|
605
|
+
className: "text-sm text-foreground",
|
|
606
|
+
children: /* @__PURE__ */ jsx(Markdown, { text: part.text })
|
|
607
|
+
}, index) : /* @__PURE__ */ jsxs("div", {
|
|
608
|
+
className: "space-y-2",
|
|
609
|
+
children: [/* @__PURE__ */ jsx(ToolCard, { part }), part.state === "awaiting-approval" && /* @__PURE__ */ jsx(ApprovalCard, {
|
|
610
|
+
approval: {
|
|
611
|
+
toolCallId: part.toolCallId,
|
|
612
|
+
name: part.name,
|
|
613
|
+
args: part.args
|
|
614
|
+
},
|
|
615
|
+
onApprove,
|
|
616
|
+
onReject
|
|
617
|
+
})]
|
|
618
|
+
}, part.toolCallId)), message.parts.length === 0 && /* @__PURE__ */ jsx("div", {
|
|
619
|
+
className: "text-sm text-muted-foreground",
|
|
620
|
+
children: "Thinking…"
|
|
621
|
+
})]
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
/** The chat window panel, anchored above the FAB in the bottom-right. */
|
|
625
|
+
function ChatWindow({ chat, onClose }) {
|
|
626
|
+
const [fullscreen, setFullscreen] = useState(false);
|
|
627
|
+
const [draft, setDraft] = useState("");
|
|
628
|
+
const listRef = useRef(null);
|
|
629
|
+
useEffect(() => {
|
|
630
|
+
const el = listRef.current;
|
|
631
|
+
if (el) el.scrollTo({ top: el.scrollHeight });
|
|
632
|
+
}, [chat.messages]);
|
|
633
|
+
useEffect(() => {
|
|
634
|
+
if (!fullscreen) return;
|
|
635
|
+
const onKey = (event) => {
|
|
636
|
+
if (event.key === "Escape") setFullscreen(false);
|
|
637
|
+
};
|
|
638
|
+
window.addEventListener("keydown", onKey);
|
|
639
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
640
|
+
}, [fullscreen]);
|
|
641
|
+
const submit = () => {
|
|
642
|
+
const text = draft;
|
|
643
|
+
setDraft("");
|
|
644
|
+
chat.send(text);
|
|
645
|
+
};
|
|
646
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
647
|
+
className: fullscreen ? "fixed inset-0 z-50 flex flex-col overflow-hidden bg-background" : "fixed bottom-24 right-6 z-40 flex max-h-[min(28rem,calc(100vh-8rem))] w-[min(24rem,calc(100vw-3rem))] flex-col overflow-hidden rounded-xl border border-border bg-background shadow-2xl",
|
|
648
|
+
children: [
|
|
649
|
+
/* @__PURE__ */ jsxs("div", {
|
|
650
|
+
className: "flex items-center justify-between border-b border-border px-4 py-2.5",
|
|
651
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
652
|
+
className: "text-sm font-semibold text-foreground",
|
|
653
|
+
children: "AI Assistant"
|
|
654
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
655
|
+
className: "flex items-center gap-1 text-muted-foreground",
|
|
656
|
+
children: [
|
|
657
|
+
/* @__PURE__ */ jsx("button", {
|
|
658
|
+
type: "button",
|
|
659
|
+
"aria-label": fullscreen ? "Exit fullscreen" : "Fullscreen",
|
|
660
|
+
title: fullscreen ? "Exit fullscreen (Esc)" : "Fullscreen",
|
|
661
|
+
onClick: () => setFullscreen((f) => !f),
|
|
662
|
+
className: "rounded p-1 hover:bg-muted hover:text-foreground",
|
|
663
|
+
children: fullscreen ? /* @__PURE__ */ jsx(Minimize2, { size: 15 }) : /* @__PURE__ */ jsx(Maximize2, { size: 15 })
|
|
664
|
+
}),
|
|
665
|
+
/* @__PURE__ */ jsx("button", {
|
|
666
|
+
type: "button",
|
|
667
|
+
"aria-label": "Clear conversation",
|
|
668
|
+
title: "New chat",
|
|
669
|
+
onClick: chat.clear,
|
|
670
|
+
className: "rounded p-1 hover:bg-muted hover:text-foreground",
|
|
671
|
+
children: /* @__PURE__ */ jsx(Trash2, { size: 15 })
|
|
672
|
+
}),
|
|
673
|
+
/* @__PURE__ */ jsx("button", {
|
|
674
|
+
type: "button",
|
|
675
|
+
"aria-label": "Close AI chat",
|
|
676
|
+
title: "Close",
|
|
677
|
+
onClick: onClose,
|
|
678
|
+
className: "rounded p-1 hover:bg-muted hover:text-foreground",
|
|
679
|
+
children: /* @__PURE__ */ jsx(X, { size: 15 })
|
|
680
|
+
})
|
|
681
|
+
]
|
|
682
|
+
})]
|
|
683
|
+
}),
|
|
684
|
+
/* @__PURE__ */ jsxs("div", {
|
|
685
|
+
ref: listRef,
|
|
686
|
+
className: "flex-1 space-y-3 overflow-y-auto px-4 py-3",
|
|
687
|
+
children: [
|
|
688
|
+
chat.messages.length === 0 && /* @__PURE__ */ jsx("div", {
|
|
689
|
+
className: "pt-8 text-center text-sm text-muted-foreground",
|
|
690
|
+
children: chat.configured ? /* @__PURE__ */ jsx("p", { children: "Ask the assistant to create or change documents, manage drives, or inspect read models." }) : /* @__PURE__ */ jsx("p", { children: "Add your OpenAI-compatible endpoint under Settings → AI Assistant, then start a conversation." })
|
|
691
|
+
}),
|
|
692
|
+
chat.messages.map((message) => /* @__PURE__ */ jsx(MessageView, {
|
|
693
|
+
message,
|
|
694
|
+
onApprove: chat.approve,
|
|
695
|
+
onReject: chat.reject
|
|
696
|
+
}, message.id)),
|
|
697
|
+
chat.error && /* @__PURE__ */ jsx("div", {
|
|
698
|
+
className: "rounded-lg border border-destructive/40 bg-destructive/10 p-2 text-xs text-destructive",
|
|
699
|
+
children: chat.error
|
|
700
|
+
})
|
|
701
|
+
]
|
|
702
|
+
}),
|
|
703
|
+
/* @__PURE__ */ jsx("div", {
|
|
704
|
+
className: "border-t border-border p-3",
|
|
705
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
706
|
+
className: "flex items-end gap-2",
|
|
707
|
+
children: [/* @__PURE__ */ jsx("textarea", {
|
|
708
|
+
rows: 2,
|
|
709
|
+
value: draft,
|
|
710
|
+
onChange: (e) => setDraft(e.target.value),
|
|
711
|
+
onKeyDown: (e) => {
|
|
712
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
713
|
+
e.preventDefault();
|
|
714
|
+
submit();
|
|
715
|
+
}
|
|
716
|
+
},
|
|
717
|
+
placeholder: chat.configured ? "Ask the assistant… (Enter to send)" : "Configure the endpoint in settings first",
|
|
718
|
+
className: "max-h-32 min-h-[2.5rem] flex-1 resize-none rounded-md border border-border bg-background p-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none",
|
|
719
|
+
disabled: chat.isStreaming
|
|
720
|
+
}), chat.isStreaming ? /* @__PURE__ */ jsx("button", {
|
|
721
|
+
type: "button",
|
|
722
|
+
"aria-label": "Stop generating",
|
|
723
|
+
title: "Stop",
|
|
724
|
+
onClick: chat.stop,
|
|
725
|
+
className: "flex size-9 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90",
|
|
726
|
+
children: /* @__PURE__ */ jsx(Square, { size: 15 })
|
|
727
|
+
}) : /* @__PURE__ */ jsx("button", {
|
|
728
|
+
type: "button",
|
|
729
|
+
"aria-label": "Send message",
|
|
730
|
+
title: "Send",
|
|
731
|
+
onClick: submit,
|
|
732
|
+
disabled: !draft.trim() || !chat.configured,
|
|
733
|
+
className: "flex size-9 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:disabled-effect",
|
|
734
|
+
children: /* @__PURE__ */ jsx(ArrowUp, { size: 15 })
|
|
735
|
+
})]
|
|
736
|
+
})
|
|
737
|
+
})
|
|
738
|
+
]
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
//#endregion
|
|
742
|
+
//#region src/ai/settings-store.ts
|
|
743
|
+
const STORAGE_KEY = "ph-ai-chat-settings";
|
|
744
|
+
const DEFAULT_AI_SETTINGS = {
|
|
745
|
+
enabled: false,
|
|
746
|
+
baseUrl: "",
|
|
747
|
+
apiKey: "",
|
|
748
|
+
model: "",
|
|
749
|
+
autoApproveWrites: false
|
|
750
|
+
};
|
|
751
|
+
let snapshot = load();
|
|
752
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
753
|
+
function load() {
|
|
754
|
+
if (typeof localStorage === "undefined") return { ...DEFAULT_AI_SETTINGS };
|
|
755
|
+
try {
|
|
756
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
757
|
+
if (!raw) return { ...DEFAULT_AI_SETTINGS };
|
|
758
|
+
const parsed = JSON.parse(raw);
|
|
759
|
+
return {
|
|
760
|
+
enabled: typeof parsed.enabled === "boolean" ? parsed.enabled : false,
|
|
761
|
+
baseUrl: typeof parsed.baseUrl === "string" ? parsed.baseUrl : "",
|
|
762
|
+
apiKey: typeof parsed.apiKey === "string" ? parsed.apiKey : "",
|
|
763
|
+
model: typeof parsed.model === "string" ? parsed.model : "",
|
|
764
|
+
autoApproveWrites: typeof parsed.autoApproveWrites === "boolean" ? parsed.autoApproveWrites : false
|
|
765
|
+
};
|
|
766
|
+
} catch {
|
|
767
|
+
return { ...DEFAULT_AI_SETTINGS };
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
function persist(settings) {
|
|
771
|
+
try {
|
|
772
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
|
773
|
+
} catch {}
|
|
774
|
+
}
|
|
775
|
+
function emit() {
|
|
776
|
+
for (const listener of listeners) listener();
|
|
777
|
+
}
|
|
778
|
+
/** Returns the current settings (stable snapshot for useSyncExternalStore). */
|
|
779
|
+
function getAiSettings() {
|
|
780
|
+
return snapshot;
|
|
781
|
+
}
|
|
782
|
+
/** Merges a partial update, persists, and notifies subscribers. */
|
|
783
|
+
function updateAiSettings(patch) {
|
|
784
|
+
snapshot = {
|
|
785
|
+
...snapshot,
|
|
786
|
+
...patch
|
|
787
|
+
};
|
|
788
|
+
persist(snapshot);
|
|
789
|
+
emit();
|
|
790
|
+
return snapshot;
|
|
791
|
+
}
|
|
792
|
+
/** Clears all stored settings. */
|
|
793
|
+
function clearAiSettings() {
|
|
794
|
+
snapshot = { ...DEFAULT_AI_SETTINGS };
|
|
795
|
+
try {
|
|
796
|
+
localStorage.removeItem(STORAGE_KEY);
|
|
797
|
+
} catch {}
|
|
798
|
+
emit();
|
|
799
|
+
}
|
|
800
|
+
/** Subscribes to settings changes. Returns the unsubscribe function. */
|
|
801
|
+
function subscribeAiSettings(listener) {
|
|
802
|
+
listeners.add(listener);
|
|
803
|
+
return () => {
|
|
804
|
+
listeners.delete(listener);
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
/** True when the endpoint, key and model are all configured. */
|
|
808
|
+
function isAiConfigured(settings) {
|
|
809
|
+
return settings.baseUrl.trim().length > 0 && settings.apiKey.trim().length > 0 && settings.model.trim().length > 0;
|
|
810
|
+
}
|
|
811
|
+
//#endregion
|
|
812
|
+
//#region src/ai/context.ts
|
|
813
|
+
/**
|
|
814
|
+
* Snapshot of the current selection in the drive explorer, used to make the
|
|
815
|
+
* chat context-aware: "create a budget here" targets the selected drive,
|
|
816
|
+
* "summarize this document" targets the selected document.
|
|
817
|
+
*/
|
|
818
|
+
function useChatContext() {
|
|
819
|
+
const [drive] = useSelectedDriveSafe();
|
|
820
|
+
const node = useSelectedNode();
|
|
821
|
+
const documentId = useSelectedDocumentId();
|
|
822
|
+
const context = {
|
|
823
|
+
driveId: drive?.header.id,
|
|
824
|
+
driveName: drive?.header.name
|
|
825
|
+
};
|
|
826
|
+
if (node) {
|
|
827
|
+
context.nodeId = node.id;
|
|
828
|
+
context.nodeName = node.name;
|
|
829
|
+
if (isFolderNode(node)) context.nodeKind = "folder";
|
|
830
|
+
else if (isFileNode(node)) context.nodeKind = "file";
|
|
831
|
+
}
|
|
832
|
+
if (documentId && node && isFileNode(node)) {
|
|
833
|
+
context.documentName = node.name;
|
|
834
|
+
context.documentType = node.documentType;
|
|
835
|
+
context.documentId = documentId;
|
|
836
|
+
}
|
|
837
|
+
const sb = resolveDriveSwitchboard(drive?.header.id);
|
|
838
|
+
if (sb) {
|
|
839
|
+
context.switchboardUrl = sb.switchboardUrl;
|
|
840
|
+
context.switchboardGraphqlUrl = sb.graphqlUrl;
|
|
841
|
+
}
|
|
842
|
+
return context;
|
|
843
|
+
}
|
|
844
|
+
//#endregion
|
|
845
|
+
//#region src/ai/use-reactor-chat.ts
|
|
846
|
+
function updateAssistantPart(messages, assistantId, update) {
|
|
847
|
+
if (!assistantId) return messages;
|
|
848
|
+
return messages.map((message) => message.id === assistantId ? {
|
|
849
|
+
...message,
|
|
850
|
+
parts: update(message.parts)
|
|
851
|
+
} : message);
|
|
852
|
+
}
|
|
853
|
+
function toErrorMessage(error) {
|
|
854
|
+
return error instanceof Error ? error.message : String(error);
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Drives one in-browser chat conversation against the reactor tools.
|
|
858
|
+
*
|
|
859
|
+
* `toolsProvider` lazily resolves the tool descriptors (e.g. from
|
|
860
|
+
* `createReactorMcpProvider` bound to `window.ph.reactorClient`) when the
|
|
861
|
+
* user sends a message, so the reactor does not need to be ready at render
|
|
862
|
+
* time.
|
|
863
|
+
*/
|
|
864
|
+
function useReactorChat(toolsProvider) {
|
|
865
|
+
const [messages, setMessages] = useState([]);
|
|
866
|
+
const [isStreaming, setIsStreaming] = useState(false);
|
|
867
|
+
const [pendingApprovals, setPendingApprovals] = useState([]);
|
|
868
|
+
const [error, setError] = useState(null);
|
|
869
|
+
const agentRef = useRef(null);
|
|
870
|
+
const abortRef = useRef(null);
|
|
871
|
+
const streamingRef = useRef(false);
|
|
872
|
+
const pendingRef = useRef(/* @__PURE__ */ new Set());
|
|
873
|
+
const assistantIdRef = useRef(null);
|
|
874
|
+
const historyRef = useRef([]);
|
|
875
|
+
const settings = useSyncExternalStore(subscribeAiSettings, getAiSettings);
|
|
876
|
+
const context = useChatContext();
|
|
877
|
+
const handleEvent = useCallback((event) => {
|
|
878
|
+
const assistantId = assistantIdRef.current;
|
|
879
|
+
switch (event.type) {
|
|
880
|
+
case "text-delta":
|
|
881
|
+
setMessages((prev) => updateAssistantPart(prev, assistantId, (parts) => {
|
|
882
|
+
const last = parts[parts.length - 1];
|
|
883
|
+
if (last?.type === "text") return [...parts.slice(0, -1), {
|
|
884
|
+
...last,
|
|
885
|
+
text: last.text + event.delta
|
|
886
|
+
}];
|
|
887
|
+
return [...parts, {
|
|
888
|
+
type: "text",
|
|
889
|
+
text: event.delta
|
|
890
|
+
}];
|
|
891
|
+
}));
|
|
892
|
+
break;
|
|
893
|
+
case "tool-start":
|
|
894
|
+
setMessages((prev) => updateAssistantPart(prev, assistantId, (parts) => [...parts, {
|
|
895
|
+
type: "tool",
|
|
896
|
+
toolCallId: event.toolCallId,
|
|
897
|
+
name: event.name,
|
|
898
|
+
args: event.args,
|
|
899
|
+
state: pendingRef.current.has(event.toolCallId) ? "awaiting-approval" : "executing"
|
|
900
|
+
}]));
|
|
901
|
+
break;
|
|
902
|
+
case "approval-request":
|
|
903
|
+
pendingRef.current.add(event.toolCallId);
|
|
904
|
+
setMessages((prev) => updateAssistantPart(prev, assistantId, (parts) => parts.map((part) => part.type === "tool" && part.toolCallId === event.toolCallId ? {
|
|
905
|
+
...part,
|
|
906
|
+
state: "awaiting-approval"
|
|
907
|
+
} : part)));
|
|
908
|
+
setPendingApprovals((prev) => [...prev, {
|
|
909
|
+
toolCallId: event.toolCallId,
|
|
910
|
+
name: event.name,
|
|
911
|
+
args: event.args
|
|
912
|
+
}]);
|
|
913
|
+
break;
|
|
914
|
+
case "approval-resolved":
|
|
915
|
+
pendingRef.current.delete(event.toolCallId);
|
|
916
|
+
setPendingApprovals((prev) => prev.filter((a) => a.toolCallId !== event.toolCallId));
|
|
917
|
+
if (!event.approved) setMessages((prev) => updateAssistantPart(prev, assistantId, (parts) => parts.map((part) => part.type === "tool" && part.toolCallId === event.toolCallId ? {
|
|
918
|
+
...part,
|
|
919
|
+
state: "rejected"
|
|
920
|
+
} : part)));
|
|
921
|
+
break;
|
|
922
|
+
case "tool-result":
|
|
923
|
+
setMessages((prev) => updateAssistantPart(prev, assistantId, (parts) => parts.map((part) => part.type === "tool" && part.toolCallId === event.toolCallId ? {
|
|
924
|
+
...part,
|
|
925
|
+
state: event.state,
|
|
926
|
+
result: event.result,
|
|
927
|
+
error: event.error
|
|
928
|
+
} : part)));
|
|
929
|
+
break;
|
|
930
|
+
case "error":
|
|
931
|
+
setError(event.error);
|
|
932
|
+
break;
|
|
933
|
+
case "finish": break;
|
|
934
|
+
}
|
|
935
|
+
}, []);
|
|
936
|
+
const send = useCallback((text) => {
|
|
937
|
+
const trimmed = text.trim();
|
|
938
|
+
if (!trimmed || streamingRef.current) return;
|
|
939
|
+
setError(null);
|
|
940
|
+
const currentSettings = getAiSettings();
|
|
941
|
+
if (!isAiConfigured(currentSettings)) {
|
|
942
|
+
setError("Configure the AI endpoint, API key and model in settings first.");
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
(async () => {
|
|
946
|
+
let tools;
|
|
947
|
+
try {
|
|
948
|
+
if (!toolsProvider) throw new Error("No tool provider configured");
|
|
949
|
+
tools = await toolsProvider();
|
|
950
|
+
} catch (toolError) {
|
|
951
|
+
setError(`Could not initialise the reactor tools: ${toErrorMessage(toolError)}`);
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
if (tools.length === 0) {
|
|
955
|
+
setError("The reactor exposed no tools.");
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
const controller = new AbortController();
|
|
959
|
+
abortRef.current = controller;
|
|
960
|
+
const agent = new ReactorChatAgent({
|
|
961
|
+
settings: currentSettings,
|
|
962
|
+
tools,
|
|
963
|
+
context,
|
|
964
|
+
onEvent: handleEvent,
|
|
965
|
+
signal: controller.signal,
|
|
966
|
+
history: historyRef.current
|
|
967
|
+
});
|
|
968
|
+
agentRef.current = agent;
|
|
969
|
+
const assistantId = crypto.randomUUID();
|
|
970
|
+
assistantIdRef.current = assistantId;
|
|
971
|
+
setMessages((prev) => [
|
|
972
|
+
...prev,
|
|
973
|
+
{
|
|
974
|
+
id: crypto.randomUUID(),
|
|
975
|
+
role: "user",
|
|
976
|
+
parts: [{
|
|
977
|
+
type: "text",
|
|
978
|
+
text: trimmed
|
|
979
|
+
}]
|
|
980
|
+
},
|
|
981
|
+
{
|
|
982
|
+
id: assistantId,
|
|
983
|
+
role: "assistant",
|
|
984
|
+
parts: []
|
|
985
|
+
}
|
|
986
|
+
]);
|
|
987
|
+
streamingRef.current = true;
|
|
988
|
+
setIsStreaming(true);
|
|
989
|
+
try {
|
|
990
|
+
await agent.send(trimmed);
|
|
991
|
+
} catch (sendError) {
|
|
992
|
+
setError(toErrorMessage(sendError));
|
|
993
|
+
} finally {
|
|
994
|
+
streamingRef.current = false;
|
|
995
|
+
setIsStreaming(false);
|
|
996
|
+
abortRef.current = null;
|
|
997
|
+
historyRef.current = agent.getHistory();
|
|
998
|
+
}
|
|
999
|
+
})();
|
|
1000
|
+
}, [
|
|
1001
|
+
toolsProvider,
|
|
1002
|
+
context,
|
|
1003
|
+
handleEvent
|
|
1004
|
+
]);
|
|
1005
|
+
const stop = useCallback(() => {
|
|
1006
|
+
abortRef.current?.abort();
|
|
1007
|
+
}, []);
|
|
1008
|
+
const approve = useCallback((toolCallId) => {
|
|
1009
|
+
agentRef.current?.approve(toolCallId);
|
|
1010
|
+
}, []);
|
|
1011
|
+
const reject = useCallback((toolCallId) => {
|
|
1012
|
+
agentRef.current?.reject(toolCallId);
|
|
1013
|
+
}, []);
|
|
1014
|
+
const clear = useCallback(() => {
|
|
1015
|
+
abortRef.current?.abort();
|
|
1016
|
+
agentRef.current?.reset();
|
|
1017
|
+
agentRef.current = null;
|
|
1018
|
+
historyRef.current = [];
|
|
1019
|
+
pendingRef.current.clear();
|
|
1020
|
+
setPendingApprovals([]);
|
|
1021
|
+
setError(null);
|
|
1022
|
+
assistantIdRef.current = null;
|
|
1023
|
+
}, []);
|
|
1024
|
+
return {
|
|
1025
|
+
messages,
|
|
1026
|
+
isStreaming,
|
|
1027
|
+
pendingApprovals,
|
|
1028
|
+
error,
|
|
1029
|
+
settings,
|
|
1030
|
+
configured: isAiConfigured(settings),
|
|
1031
|
+
send,
|
|
1032
|
+
stop,
|
|
1033
|
+
approve,
|
|
1034
|
+
reject,
|
|
1035
|
+
clear
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
//#endregion
|
|
1039
|
+
//#region src/ai/components/reactor-chat-fab.tsx
|
|
1040
|
+
/**
|
|
1041
|
+
* Bottom-right floating action button that opens the reactor AI chat window.
|
|
1042
|
+
*
|
|
1043
|
+
* `getTools` lazily resolves the reactor tool descriptors (from
|
|
1044
|
+
* `createReactorMcpProvider` bound to the browser reactor client) when the
|
|
1045
|
+
* user sends a message.
|
|
1046
|
+
*/
|
|
1047
|
+
function ReactorChatFab({ getTools }) {
|
|
1048
|
+
const settings = useSyncExternalStore(subscribeAiSettings, getAiSettings);
|
|
1049
|
+
const [open, setOpen] = useState(false);
|
|
1050
|
+
const chat = useReactorChat(getTools);
|
|
1051
|
+
if (!settings.enabled) return null;
|
|
1052
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("button", {
|
|
1053
|
+
type: "button",
|
|
1054
|
+
"aria-label": open ? "Close AI chat" : "Open AI chat",
|
|
1055
|
+
title: open ? "Close AI chat" : "AI chat",
|
|
1056
|
+
onClick: () => setOpen((o) => !o),
|
|
1057
|
+
className: "fixed bottom-6 right-6 z-40 flex size-12 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform hover:scale-105 disabled:disabled-effect",
|
|
1058
|
+
children: open ? /* @__PURE__ */ jsx(X, { size: 20 }) : /* @__PURE__ */ jsx(MessageCircle, { size: 20 })
|
|
1059
|
+
}), open && /* @__PURE__ */ jsx(ChatWindow, {
|
|
1060
|
+
chat,
|
|
1061
|
+
onClose: () => setOpen(false)
|
|
1062
|
+
})] });
|
|
1063
|
+
}
|
|
1064
|
+
//#endregion
|
|
1065
|
+
export { ApprovalCard, ChatWindow, DEFAULT_AI_SETTINGS, FULL_RESULT_TURNS, GQL_CHANNEL_SUFFIX, MAX_AGENT_STEPS, MAX_TOOL_RESULT_CHARS, ReactorChatAgent, ReactorChatFab, STUB_MIN_CHARS, SWITCHBOARD_INTROSPECTION_QUERY, SWITCHBOARD_SCHEMA_TOOL_NAME, WRITE_TOOLS, buildSystemPrompt, clearAiSettings, createReactorChatModel, createSwitchboardSchemaTool, getAiSettings, getBrowserSyncManager, isAiConfigured, isWriteTool, resolveDriveSwitchboard, stubStaleToolResults, subscribeAiSettings, summarizeIntrospectionSchema, unwrapToolResult, updateAiSettings, useChatContext, useReactorChat };
|
|
1066
|
+
|
|
1067
|
+
//# sourceMappingURL=index.js.map
|