broapp 0.1.0 → 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.
- package/README.md +13 -4
- package/package.json +10 -2
- package/src/ai/host/adapter.ts +106 -0
- package/src/ai/host/create-ai.ts +200 -0
- package/src/ai/host/fake.ts +223 -0
- package/src/ai/host/from-contract.ts +85 -0
- package/src/ai/host/index.ts +32 -0
- package/src/ai/host/registry.ts +221 -0
- package/src/ai/host/run-types.ts +14 -0
- package/src/ai/host/run.ts +320 -0
- package/src/ai/host/secrets.ts +118 -0
- package/src/ai/host/settings.ts +83 -0
- package/src/ai/host/tool.ts +89 -0
- package/src/ai/react/AiChat.tsx +194 -0
- package/src/ai/react/AiSettings.tsx +222 -0
- package/src/ai/react/ai.css +152 -0
- package/src/ai/react/index.tsx +35 -0
- package/src/ai/react/provider.tsx +97 -0
- package/src/ai/react/use-ai-chat.ts +309 -0
- package/src/ai/react/use-ai-models.ts +70 -0
- package/src/ai/react/use-ai-settings.ts +105 -0
- package/src/ai/shared/contract.ts +138 -0
- package/src/ai/shared/index.ts +16 -0
- package/src/ai/shared/types.check.ts +43 -0
- package/src/ai/shared/types.ts +87 -0
- package/src/host/app.ts +94 -30
- package/src/host/index.ts +5 -0
- package/src/react/hooks.tsx +37 -3
- package/src/react/index.ts +1 -0
- package/src/shared/contract.ts +56 -2
- package/src/shared/errors.ts +10 -0
- package/src/shared/index.ts +8 -2
- package/src/shared/schema.ts +125 -28
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the API key lives.
|
|
3
|
+
*
|
|
4
|
+
* `<dataDir>/ai/secrets.json` is a plain file, owned by the user, with mode
|
|
5
|
+
* 0600 — the same posture as `~/.aws/credentials` and `~/.npmrc`. It is not
|
|
6
|
+
* encrypted. What it protects against is another *user* on the machine and a
|
|
7
|
+
* backup that copies world-readable files. What it does not protect against is
|
|
8
|
+
* another process running as the same user: that process can read the file,
|
|
9
|
+
* and no scheme that runs unattended on the same account can prevent it.
|
|
10
|
+
*
|
|
11
|
+
* A user who does not want the key on disk can turn `remember` off, and the
|
|
12
|
+
* key is kept in memory for the life of the process instead.
|
|
13
|
+
*/
|
|
14
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
/** A place to keep secrets by name. */
|
|
18
|
+
export interface SecretStore {
|
|
19
|
+
get(name: string): Promise<string | null>;
|
|
20
|
+
set(name: string, value: string): Promise<void>;
|
|
21
|
+
delete(name: string): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The secret name the layer uses for a provider's key. */
|
|
25
|
+
export function apiKeySecretName(providerId: string): string {
|
|
26
|
+
return `provider:${providerId}:apiKey`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A store that forgets everything when the process exits. */
|
|
30
|
+
export function createMemorySecretStore(): SecretStore {
|
|
31
|
+
const values = new Map<string, string>();
|
|
32
|
+
return {
|
|
33
|
+
get: (name) => Promise.resolve(values.get(name) ?? null),
|
|
34
|
+
set: (name, value) => {
|
|
35
|
+
values.set(name, value);
|
|
36
|
+
return Promise.resolve();
|
|
37
|
+
},
|
|
38
|
+
delete: (name) => {
|
|
39
|
+
values.delete(name);
|
|
40
|
+
return Promise.resolve();
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface StoredSecrets {
|
|
46
|
+
version: 1;
|
|
47
|
+
secrets: Record<string, string>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A store backed by `<dataDir>/ai/secrets.json`. */
|
|
51
|
+
export function createFileSecretStore(dataDir: string): SecretStore {
|
|
52
|
+
const directory = join(dataDir, 'ai');
|
|
53
|
+
const file = join(directory, 'secrets.json');
|
|
54
|
+
const temporary = `${file}.tmp`;
|
|
55
|
+
let warned = false;
|
|
56
|
+
|
|
57
|
+
function read(): StoredSecrets {
|
|
58
|
+
let text: string;
|
|
59
|
+
try {
|
|
60
|
+
text = readFileSync(file, 'utf8');
|
|
61
|
+
} catch {
|
|
62
|
+
return { version: 1, secrets: {} };
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const parsed = JSON.parse(text) as unknown;
|
|
66
|
+
if (typeof parsed !== 'object' || parsed === null) throw new Error('not an object');
|
|
67
|
+
const secrets = (parsed as { secrets?: unknown }).secrets;
|
|
68
|
+
if (typeof secrets !== 'object' || secrets === null) throw new Error('no secrets');
|
|
69
|
+
const out: Record<string, string> = {};
|
|
70
|
+
for (const [name, value] of Object.entries(secrets as Record<string, unknown>)) {
|
|
71
|
+
if (typeof value === 'string') out[name] = value;
|
|
72
|
+
}
|
|
73
|
+
return { version: 1, secrets: out };
|
|
74
|
+
} catch {
|
|
75
|
+
// Warned once: a corrupt file would otherwise print on every read, and
|
|
76
|
+
// the layer reads settings often.
|
|
77
|
+
if (!warned) {
|
|
78
|
+
warned = true;
|
|
79
|
+
console.warn(`[broapp] ignoring unreadable AI secrets at ${file}`);
|
|
80
|
+
}
|
|
81
|
+
return { version: 1, secrets: {} };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function write(next: StoredSecrets): void {
|
|
86
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
87
|
+
writeFileSync(temporary, `${JSON.stringify(next, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
88
|
+
renameSync(temporary, file);
|
|
89
|
+
try {
|
|
90
|
+
chmodSync(file, 0o600);
|
|
91
|
+
} catch {
|
|
92
|
+
// Windows has no POSIX mode bits. The call is made anyway so that every
|
|
93
|
+
// platform that does have them gets them, and the one that does not is
|
|
94
|
+
// not a special case in the caller.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
get: (name) => Promise.resolve(read().secrets[name] ?? null),
|
|
100
|
+
set: (name, value) => {
|
|
101
|
+
const current = read();
|
|
102
|
+
write({ version: 1, secrets: { ...current.secrets, [name]: value } });
|
|
103
|
+
return Promise.resolve();
|
|
104
|
+
},
|
|
105
|
+
delete: (name) => {
|
|
106
|
+
const current = read();
|
|
107
|
+
if (!(name in current.secrets)) return Promise.resolve();
|
|
108
|
+
const { [name]: _removed, ...rest } = current.secrets;
|
|
109
|
+
if (Object.keys(rest).length === 0) {
|
|
110
|
+
// An empty file is worse than none: it still says a key was here.
|
|
111
|
+
rmSync(file, { force: true });
|
|
112
|
+
return Promise.resolve();
|
|
113
|
+
}
|
|
114
|
+
write({ version: 1, secrets: rest });
|
|
115
|
+
return Promise.resolve();
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the AI layer's non-secret settings live.
|
|
3
|
+
*
|
|
4
|
+
* `<dataDir>/ai/settings.json` holds which provider and model the user chose
|
|
5
|
+
* and where to reach them. It never holds the API key — that is
|
|
6
|
+
* `secrets.ts` — and a test asserts the string does not appear in the file,
|
|
7
|
+
* because "we do not write it there" is the kind of promise that quietly
|
|
8
|
+
* stops being true.
|
|
9
|
+
*/
|
|
10
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
/** The settings file's shape. `version` exists so a later format can migrate. */
|
|
14
|
+
export interface StoredSettings {
|
|
15
|
+
version: 1;
|
|
16
|
+
provider: string | null;
|
|
17
|
+
modelId: string | null;
|
|
18
|
+
baseUrl: string | null;
|
|
19
|
+
/** False means the key is held in memory only and forgotten on exit. */
|
|
20
|
+
remember: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Reads and writes {@link StoredSettings}. */
|
|
24
|
+
export interface SettingsStore {
|
|
25
|
+
read(): StoredSettings;
|
|
26
|
+
write(next: StoredSettings): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What a fresh installation has. No provider, so the layer is off. */
|
|
30
|
+
export function defaultSettings(): StoredSettings {
|
|
31
|
+
return { version: 1, provider: null, modelId: null, baseUrl: null, remember: true };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function coerce(value: unknown): StoredSettings | null {
|
|
35
|
+
if (typeof value !== 'object' || value === null) return null;
|
|
36
|
+
const raw = value as Record<string, unknown>;
|
|
37
|
+
const text = (key: string): string | null => (typeof raw[key] === 'string' ? (raw[key] as string) : null);
|
|
38
|
+
return {
|
|
39
|
+
version: 1,
|
|
40
|
+
provider: text('provider'),
|
|
41
|
+
modelId: text('modelId'),
|
|
42
|
+
baseUrl: text('baseUrl'),
|
|
43
|
+
remember: raw['remember'] !== false,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Open the settings store for one data directory. */
|
|
48
|
+
export function createSettingsStore(dataDir: string): SettingsStore {
|
|
49
|
+
const directory = join(dataDir, 'ai');
|
|
50
|
+
const file = join(directory, 'settings.json');
|
|
51
|
+
const temporary = `${file}.tmp`;
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
read() {
|
|
55
|
+
let text: string;
|
|
56
|
+
try {
|
|
57
|
+
text = readFileSync(file, 'utf8');
|
|
58
|
+
} catch {
|
|
59
|
+
// No file yet is the ordinary case on a first run, not a failure.
|
|
60
|
+
return defaultSettings();
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const parsed = coerce(JSON.parse(text) as unknown);
|
|
64
|
+
if (parsed === null) throw new Error('not an object');
|
|
65
|
+
return parsed;
|
|
66
|
+
} catch {
|
|
67
|
+
// A file the user or another tool mangled should not stop the
|
|
68
|
+
// application from starting, and should not be deleted either — they
|
|
69
|
+
// may want to repair it.
|
|
70
|
+
console.warn(`[broapp] ignoring unreadable AI settings at ${file}`);
|
|
71
|
+
return defaultSettings();
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
write(next) {
|
|
76
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
77
|
+
// Written to a sibling and renamed, so a crash mid-write leaves the
|
|
78
|
+
// previous settings intact rather than a truncated file.
|
|
79
|
+
writeFileSync(temporary, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
|
|
80
|
+
renameSync(temporary, file);
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the AI layer can offer a model, and how a confirmation is answered.
|
|
3
|
+
*
|
|
4
|
+
* Kept apart from `create-ai.ts` so that `from-contract.ts` and `run.ts` can
|
|
5
|
+
* share these without either importing the other's module graph.
|
|
6
|
+
*/
|
|
7
|
+
import type { JsonSchema } from '../../shared/schema.ts';
|
|
8
|
+
import type { ToolPermission } from '../shared/types.ts';
|
|
9
|
+
|
|
10
|
+
/** One thing a model may do. */
|
|
11
|
+
export interface AiTool {
|
|
12
|
+
readonly description: string;
|
|
13
|
+
/** JSON Schema for the input. Use `schema.toJsonSchema()` or write it by hand. */
|
|
14
|
+
readonly inputSchema: JsonSchema;
|
|
15
|
+
/** `read` runs immediately; `confirm` asks the user first. */
|
|
16
|
+
readonly permission: ToolPermission;
|
|
17
|
+
execute(input: unknown, signal: AbortSignal): Promise<unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A record the model may be shown, named but not loaded. */
|
|
21
|
+
export interface ContextRef {
|
|
22
|
+
readonly ref: string;
|
|
23
|
+
readonly title: string;
|
|
24
|
+
readonly snippet?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A record the model is shown in full. */
|
|
28
|
+
export interface ContextDocument {
|
|
29
|
+
readonly ref: string;
|
|
30
|
+
readonly title: string;
|
|
31
|
+
readonly content: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Where the model's knowledge of the application's data comes from. */
|
|
35
|
+
export interface AiContextProviders {
|
|
36
|
+
/** Records relevant to a query. Return refs and short snippets, not full content. */
|
|
37
|
+
search?(query: { text: string; limit: number }, signal: AbortSignal): Promise<ContextRef[]>;
|
|
38
|
+
/** Full content for named refs. Unknown refs are skipped, not errors. */
|
|
39
|
+
resolve?(refs: readonly string[], signal: AbortSignal): Promise<ContextDocument[]>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The table a waiting tool call and `ai.chatConfirm` meet in.
|
|
44
|
+
*
|
|
45
|
+
* One per `Ai`, because a confirmation belongs to a run, and a run belongs to
|
|
46
|
+
* a stream that may be one of several open at once.
|
|
47
|
+
*/
|
|
48
|
+
export interface Confirmations {
|
|
49
|
+
wait(runId: string, callId: string, timeoutMs: number, signal: AbortSignal): Promise<boolean>;
|
|
50
|
+
/** Called by `ai.chatConfirm`. Returns false when nobody is waiting. */
|
|
51
|
+
answer(runId: string, callId: string, approve: boolean): boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Build the confirmation table. */
|
|
55
|
+
export function createConfirmations(): Confirmations {
|
|
56
|
+
const waiting = new Map<string, (approved: boolean) => void>();
|
|
57
|
+
const key = (runId: string, callId: string): string => `${runId} ${callId}`;
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
wait(runId, callId, timeoutMs, signal) {
|
|
61
|
+
const id = key(runId, callId);
|
|
62
|
+
return new Promise<boolean>((resolve) => {
|
|
63
|
+
let settled = false;
|
|
64
|
+
const finish = (approved: boolean): void => {
|
|
65
|
+
if (settled) return;
|
|
66
|
+
settled = true;
|
|
67
|
+
waiting.delete(id);
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
signal.removeEventListener('abort', onAbort);
|
|
70
|
+
resolve(approved);
|
|
71
|
+
};
|
|
72
|
+
// A question nobody answers is a denial, not a hung stream: the user
|
|
73
|
+
// may have closed the tab, and the tool must not run unattended.
|
|
74
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
75
|
+
const onAbort = (): void => finish(false);
|
|
76
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
77
|
+
if (signal.aborted) finish(false);
|
|
78
|
+
else waiting.set(id, finish);
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
answer(runId, callId, approve) {
|
|
83
|
+
const resolve = waiting.get(key(runId, callId));
|
|
84
|
+
if (resolve === undefined) return false;
|
|
85
|
+
resolve(approve);
|
|
86
|
+
return true;
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The chat panel.
|
|
3
|
+
*
|
|
4
|
+
* The assistant's text is rendered as text. No markdown library, no
|
|
5
|
+
* `dangerouslySetInnerHTML`: the content is written by a model that has just
|
|
6
|
+
* been shown documents from the user's own machine, and a renderer that turns
|
|
7
|
+
* some of that into markup is a way for a document to reach the page. A
|
|
8
|
+
* `<pre>` with `white-space: pre-wrap` keeps the line breaks, which is most of
|
|
9
|
+
* what markdown would have been used for anyway.
|
|
10
|
+
*/
|
|
11
|
+
import * as React from 'react';
|
|
12
|
+
|
|
13
|
+
import { useAiChat, type AiChatOptions, type ToolCallState } from './use-ai-chat.ts';
|
|
14
|
+
import { useAiSettings } from './use-ai-settings.ts';
|
|
15
|
+
|
|
16
|
+
/** Props for {@link AiChat}. */
|
|
17
|
+
export interface AiChatProps {
|
|
18
|
+
/** Records the user is looking at, sent with every message. */
|
|
19
|
+
readonly refs?: readonly string[];
|
|
20
|
+
readonly placeholder?: string;
|
|
21
|
+
readonly emptyText?: string;
|
|
22
|
+
/** Called when a tool call settles, so the application can refetch. */
|
|
23
|
+
readonly onToolResult?: AiChatOptions['onToolResult'];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function ToolCall({
|
|
27
|
+
call,
|
|
28
|
+
onConfirm,
|
|
29
|
+
}: {
|
|
30
|
+
call: ToolCallState;
|
|
31
|
+
onConfirm: (callId: string, approve: boolean) => void;
|
|
32
|
+
}): React.ReactElement {
|
|
33
|
+
return (
|
|
34
|
+
<div className="ai-chat__tool">
|
|
35
|
+
<details>
|
|
36
|
+
<summary>
|
|
37
|
+
{call.status === 'denied' ? 'Declined' : 'Used'} {call.tool}
|
|
38
|
+
</summary>
|
|
39
|
+
<pre className="ai-chat__json">{JSON.stringify(call.input, null, 2)}</pre>
|
|
40
|
+
{call.output === undefined ? null : (
|
|
41
|
+
<pre className="ai-chat__json">{JSON.stringify(call.output, null, 2)}</pre>
|
|
42
|
+
)}
|
|
43
|
+
</details>
|
|
44
|
+
{call.status !== 'awaiting-confirmation' ? null : (
|
|
45
|
+
<div className="ai-chat__confirm" role="group" aria-label={`Allow ${call.tool}?`}>
|
|
46
|
+
<span>Allow this?</span>
|
|
47
|
+
<button
|
|
48
|
+
className="button button--primary"
|
|
49
|
+
type="button"
|
|
50
|
+
onClick={() => onConfirm(call.callId, true)}
|
|
51
|
+
>
|
|
52
|
+
Allow
|
|
53
|
+
</button>
|
|
54
|
+
<button className="button" type="button" onClick={() => onConfirm(call.callId, false)}>
|
|
55
|
+
Decline
|
|
56
|
+
</button>
|
|
57
|
+
</div>
|
|
58
|
+
)}
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function AiChat({
|
|
64
|
+
refs,
|
|
65
|
+
placeholder,
|
|
66
|
+
emptyText,
|
|
67
|
+
onToolResult,
|
|
68
|
+
}: AiChatProps): React.ReactElement {
|
|
69
|
+
const { settings } = useAiSettings();
|
|
70
|
+
const chat = useAiChat({
|
|
71
|
+
...(refs === undefined ? {} : { refs }),
|
|
72
|
+
...(onToolResult === undefined ? {} : { onToolResult }),
|
|
73
|
+
});
|
|
74
|
+
const [draft, setDraft] = React.useState('');
|
|
75
|
+
const input = React.useRef<HTMLTextAreaElement | null>(null);
|
|
76
|
+
const busy = chat.status === 'streaming' || chat.status === 'awaiting-confirmation';
|
|
77
|
+
|
|
78
|
+
// Back to the box when the turn ends, so a conversation can be carried on
|
|
79
|
+
// without reaching for the mouse.
|
|
80
|
+
React.useEffect(() => {
|
|
81
|
+
if (chat.status === 'idle') input.current?.focus();
|
|
82
|
+
}, [chat.status]);
|
|
83
|
+
|
|
84
|
+
if (settings === null || settings.configured !== true) {
|
|
85
|
+
return (
|
|
86
|
+
<section className="card ai-chat" aria-labelledby="ai-chat-title">
|
|
87
|
+
<h2 className="card__title" id="ai-chat-title">
|
|
88
|
+
Assistant
|
|
89
|
+
</h2>
|
|
90
|
+
<p className="form__hint">
|
|
91
|
+
{/* Until the first settings read returns there is nothing to say yet,
|
|
92
|
+
and saying "not set up" would be a guess that is wrong as often as
|
|
93
|
+
it is right. */}
|
|
94
|
+
{settings === null
|
|
95
|
+
? 'Checking the AI settings…'
|
|
96
|
+
: 'AI is not set up. Open Settings to choose a provider.'}
|
|
97
|
+
</p>
|
|
98
|
+
</section>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const submit = (): void => {
|
|
103
|
+
const text = draft;
|
|
104
|
+
setDraft('');
|
|
105
|
+
void chat.send(text);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<section className="card ai-chat" aria-labelledby="ai-chat-title">
|
|
110
|
+
<h2 className="card__title" id="ai-chat-title">
|
|
111
|
+
Assistant
|
|
112
|
+
</h2>
|
|
113
|
+
|
|
114
|
+
<div className="ai-chat__messages" role="log" aria-live="polite">
|
|
115
|
+
{chat.messages.length === 0 ? (
|
|
116
|
+
<p className="form__hint">{emptyText ?? 'Ask a question about what you are looking at.'}</p>
|
|
117
|
+
) : null}
|
|
118
|
+
{chat.messages.map((message) =>
|
|
119
|
+
message.role === 'user' ? (
|
|
120
|
+
<div className="ai-chat__message ai-chat__message--user" key={message.id}>
|
|
121
|
+
<pre className="ai-chat__text">{message.content}</pre>
|
|
122
|
+
</div>
|
|
123
|
+
) : (
|
|
124
|
+
<div className="ai-chat__message ai-chat__message--assistant" key={message.id}>
|
|
125
|
+
{message.toolCalls.map((call) => (
|
|
126
|
+
<ToolCall
|
|
127
|
+
call={call}
|
|
128
|
+
key={call.callId}
|
|
129
|
+
onConfirm={(callId, approve) => void chat.confirm(callId, approve)}
|
|
130
|
+
/>
|
|
131
|
+
))}
|
|
132
|
+
<pre className="ai-chat__text">{message.content}</pre>
|
|
133
|
+
{message.pending ? <span className="ai-chat__typing">…</span> : null}
|
|
134
|
+
</div>
|
|
135
|
+
),
|
|
136
|
+
)}
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
{chat.error === null ? null : (
|
|
140
|
+
<p className="message message--error" role="alert">
|
|
141
|
+
{chat.error}
|
|
142
|
+
</p>
|
|
143
|
+
)}
|
|
144
|
+
{chat.usage === null ? null : (
|
|
145
|
+
<p className="form__hint ai-chat__usage">
|
|
146
|
+
{chat.usage.inputTokens} tokens in, {chat.usage.outputTokens} out
|
|
147
|
+
</p>
|
|
148
|
+
)}
|
|
149
|
+
|
|
150
|
+
<div className="ai-chat__composer">
|
|
151
|
+
<label className="form__label" htmlFor="ai-chat-input">
|
|
152
|
+
Message
|
|
153
|
+
</label>
|
|
154
|
+
<textarea
|
|
155
|
+
className="input ai-chat__input"
|
|
156
|
+
id="ai-chat-input"
|
|
157
|
+
ref={input}
|
|
158
|
+
rows={3}
|
|
159
|
+
placeholder={placeholder ?? 'Ask about these notes'}
|
|
160
|
+
value={draft}
|
|
161
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
162
|
+
onKeyDown={(event) => {
|
|
163
|
+
// Enter sends; Shift+Enter is a newline. Anything with a modifier
|
|
164
|
+
// is left alone, so the platform shortcuts still work.
|
|
165
|
+
if (event.key !== 'Enter' || event.shiftKey || event.metaKey || event.ctrlKey) return;
|
|
166
|
+
event.preventDefault();
|
|
167
|
+
if (!busy) submit();
|
|
168
|
+
}}
|
|
169
|
+
/>
|
|
170
|
+
<div className="ai-chat__actions">
|
|
171
|
+
<button
|
|
172
|
+
className="button button--primary"
|
|
173
|
+
type="button"
|
|
174
|
+
disabled={busy || draft.trim() === ''}
|
|
175
|
+
onClick={submit}
|
|
176
|
+
>
|
|
177
|
+
Send
|
|
178
|
+
</button>
|
|
179
|
+
<button className="button" type="button" disabled={!busy} onClick={chat.cancel}>
|
|
180
|
+
Stop
|
|
181
|
+
</button>
|
|
182
|
+
<button
|
|
183
|
+
className="button"
|
|
184
|
+
type="button"
|
|
185
|
+
disabled={chat.messages.length === 0}
|
|
186
|
+
onClick={chat.clear}
|
|
187
|
+
>
|
|
188
|
+
Clear
|
|
189
|
+
</button>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
</section>
|
|
193
|
+
);
|
|
194
|
+
}
|