@immediately-run/grove 0.1.1 → 0.1.3
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 +43 -115
- package/llms.txt +6 -2
- package/package.json +20 -8
- package/src/App.tsx +11 -7
- package/src/GroveApp.css +485 -85
- package/src/GroveWiki.tsx +176 -54
- package/src/components/AssetImage.tsx +1 -12
- package/src/components/Backlinks.tsx +2 -2
- package/src/components/Catalogue.test.tsx +157 -0
- package/src/components/ContentTheme.test.tsx +146 -0
- package/src/components/ContentTheme.tsx +111 -0
- package/src/components/DocList.infinite.test.tsx +177 -0
- package/src/components/DocList.tsx +82 -9
- package/src/components/Drawer.tsx +14 -2
- package/src/components/EntryHeader.tsx +24 -15
- package/src/components/EntryImage.test.tsx +210 -0
- package/src/components/EntryImage.tsx +47 -0
- package/src/components/Galleries.test.tsx +98 -0
- package/src/components/GroveAgent.test.tsx +330 -0
- package/src/components/GroveAgent.tsx +269 -149
- package/src/components/GroveNav.test.tsx +229 -0
- package/src/components/GroveNav.tsx +69 -20
- package/src/components/Icon.tsx +0 -1
- package/src/components/InlineProse.tsx +37 -0
- package/src/components/LayoutGallery.tsx +65 -0
- package/src/components/PageView.tsx +19 -4
- package/src/components/Search.test.tsx +113 -0
- package/src/components/Search.tsx +53 -18
- package/src/components/Sidebar.test.tsx +135 -0
- package/src/components/Sidebar.tsx +114 -12
- package/src/components/TableOfContents.test.tsx +27 -16
- package/src/components/ThemeAssets.test.tsx +109 -0
- package/src/components/ThemeAssets.tsx +63 -0
- package/src/components/ThemeGallery.tsx +31 -0
- package/src/components/Timeline.tsx +5 -2
- package/src/components/WikiLink.tsx +2 -2
- package/src/data/catalogue.ts +54 -0
- package/src/data/themeFonts.ts +58 -0
- package/src/data/themes.ts +44 -4
- package/src/devfs.d.ts +5 -4
- package/src/hooks/{useCorpusMetadata.ts → useBundleMetadata.ts} +6 -6
- package/src/hooks/useContentComponents.ts +1 -1
- package/src/hooks/useEditAffordance.ts +99 -0
- package/src/hooks/useOpenWikiBoot.ts +1 -1
- package/src/hooks/useOverlayFocusDismiss.test.tsx +139 -0
- package/src/hooks/useOverlayFocusDismiss.ts +118 -0
- package/src/hooks/useScrollReset.test.tsx +132 -0
- package/src/hooks/useScrollReset.ts +52 -0
- package/src/index.css +9 -2
- package/src/lib/agentPrompt.test.ts +96 -0
- package/src/lib/agentPrompt.ts +86 -0
- package/src/lib/agentTools.test.ts +115 -0
- package/src/lib/agentTools.ts +132 -0
- package/src/lib/agentTranscript.ts +50 -0
- package/src/lib/assetPath.test.ts +36 -0
- package/src/lib/assetPath.ts +42 -0
- package/src/lib/collectionCalls.test.ts +69 -0
- package/src/lib/content.test.ts +10 -2
- package/src/lib/content.ts +7 -0
- package/src/lib/contentRoot.ts +16 -1
- package/src/lib/contentStylesheet.test.ts +80 -0
- package/src/lib/contentStylesheet.ts +103 -0
- package/src/lib/corpusScan.test.ts +37 -3
- package/src/lib/corpusScan.ts +17 -2
- package/src/lib/editTarget.test.ts +108 -0
- package/src/lib/editTarget.ts +93 -0
- package/src/lib/inlineProse.parity.test.ts +52 -0
- package/src/lib/layout.ts +29 -0
- package/src/lib/openWiki.test.ts +46 -5
- package/src/lib/openWiki.ts +18 -3
- package/src/lib/pageVariants.test.tsx +87 -0
- package/src/lib/queries.test.ts +33 -1
- package/src/lib/queries.ts +33 -1
- package/src/lib/reachCard.test.ts +94 -0
- package/src/lib/reachCard.ts +112 -0
- package/src/lib/shell.ts +17 -0
- package/src/lib/starterSweep.test.tsx +160 -0
- package/src/lib/starterSweep.ts +97 -0
- package/src/lib/themeAssets.test.ts +135 -0
- package/src/lib/themeAssets.ts +143 -0
- package/src/lib/themeSelection.test.ts +52 -0
- package/src/lib/themeSelection.ts +59 -0
- package/src/mdxComponents.ts +4 -0
- package/viewer-manifest.schema.json +37 -0
- package/viewer.manifest.json +133 -6
|
@@ -1,129 +1,243 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
runAgent,
|
|
4
|
+
createChatModelClient,
|
|
5
|
+
useChatProviderState,
|
|
6
|
+
useCatalog,
|
|
7
|
+
useAllMetadata,
|
|
8
|
+
useAgentContext,
|
|
9
|
+
renderAgentContext,
|
|
10
|
+
type AgentMessage,
|
|
11
|
+
} from '@immediately-run/sdk';
|
|
12
|
+
import { useShell, EDIT_REFUSED_NOTICE } from '../lib/shell';
|
|
13
|
+
import { useHeadings, useActiveHeading } from '../hooks/useHeadings';
|
|
14
|
+
import { useOverlayFocusDismiss } from '../hooks/useOverlayFocusDismiss';
|
|
15
|
+
import { getContentRoot } from '../lib/contentRoot';
|
|
16
|
+
import { createReadEntryTool, createGroveMetadataTool, groveAgentTools, toolExecutor } from '../lib/agentTools';
|
|
17
|
+
import { buildSystemPrompt } from '../lib/agentPrompt';
|
|
18
|
+
import { computeReachRows, reachChips, showEgressDisclosure, EGRESS_DISCLOSURE } from '../lib/reachCard';
|
|
19
|
+
import { transcriptToRows, toolActivityLine, type AgentRow } from '../lib/agentTranscript';
|
|
20
|
+
import { safeSources } from '../lib/safeSources';
|
|
5
21
|
import Icon from './Icon';
|
|
6
22
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
23
|
+
// `.grove-agent` — Grove's own embedded agent (GROVE_AGENT_SPEC).
|
|
24
|
+
//
|
|
25
|
+
// The surface is a FUNCTION of the session's envelope (R-GA-1): the reach card in
|
|
26
|
+
// the expanded header is computed from the provider three-state, the `llm:chat`
|
|
27
|
+
// grant (the grant-filtered catalog), mount writability, and source trust — never
|
|
28
|
+
// hand-written copy. The loop rides the workbench's seam — SDK `runAgent` over the
|
|
29
|
+
// host `llm.chat` slot — and its two tools are the mount-chrooted `read_entry` and
|
|
30
|
+
// the index query (R-GA-2). The widget never writes (R-GA-3): every change is a
|
|
31
|
+
// hand-off to the editor / workbench. Read-only never blocks Q&A (R-GA-5). When a
|
|
32
|
+
// provider is bound the egress line is shown unconditionally (R-GA-6). Every
|
|
33
|
+
// corpus-derived byte entering the loop is fenced (R-GA-7).
|
|
34
|
+
export default function GroveAgent({
|
|
35
|
+
writable,
|
|
36
|
+
entryKey,
|
|
37
|
+
entryTitle,
|
|
38
|
+
}: {
|
|
39
|
+
writable: boolean;
|
|
40
|
+
entryKey: string;
|
|
41
|
+
entryTitle: string;
|
|
42
|
+
}) {
|
|
43
|
+
const providerState = useChatProviderState();
|
|
44
|
+
const catalog = useCatalog();
|
|
45
|
+
const index = useAllMetadata();
|
|
46
|
+
const headings = useHeadings(entryKey);
|
|
47
|
+
const activeHeading = useActiveHeading(headings);
|
|
48
|
+
const { openEditor, editRefused } = useShell();
|
|
30
49
|
const [open, setOpen] = useState(false);
|
|
31
50
|
const [detent, setDetent] = useState<'half' | 'full'>('half');
|
|
32
51
|
const [resting, setResting] = useState('');
|
|
33
52
|
const [draft, setDraft] = useState('');
|
|
34
|
-
const [
|
|
53
|
+
const [rows, setRows] = useState<AgentRow[]>([]);
|
|
35
54
|
const [streaming, setStreaming] = useState(false);
|
|
36
|
-
const [
|
|
55
|
+
const [errorToast, setErrorToast] = useState<string | null>(null);
|
|
37
56
|
const bodyRef = useRef<HTMLDivElement>(null);
|
|
38
57
|
const footRef = useRef<HTMLInputElement>(null);
|
|
58
|
+
// One AbortController per run (R3-608): the composer's stop control aborts the
|
|
59
|
+
// in-flight run through the SDK signal the loop already honours.
|
|
60
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
61
|
+
// The panel carries the dialog contract (R3-608): focus in, Tab trapped,
|
|
62
|
+
// Escape, focus return — the shared hook, stack-aware.
|
|
63
|
+
const dialogRef = useOverlayFocusDismiss(open, () => setOpen(false));
|
|
64
|
+
|
|
65
|
+
// The envelope, computed (R-GA-1). `llm:chat` appears in the grant-filtered
|
|
66
|
+
// catalog iff this app holds the consent — an ungranted fork reads a DISTINCT
|
|
67
|
+
// cause from a user without a key (G-GA-10).
|
|
68
|
+
const chatGranted = catalog.some((m) => m.name === 'llm:chat');
|
|
69
|
+
const context = useAgentContext({ entryPath: entryKey, entryTitle, heading: activeHeading || undefined });
|
|
70
|
+
const reachRows = useMemo(
|
|
71
|
+
() => computeReachRows({ providerState, chatGranted, writable, sourceShared: context.sourceShared }),
|
|
72
|
+
[providerState, chatGranted, writable, context.sourceShared],
|
|
73
|
+
);
|
|
74
|
+
const chips = useMemo(() => reachChips(reachRows), [reachRows]);
|
|
75
|
+
const canAsk = providerState.status === 'configured' && chatGranted;
|
|
76
|
+
|
|
77
|
+
// Read at CALL time (a scan may land, the reader may navigate) — refs kept fresh
|
|
78
|
+
// in an effect, never written during render (the react-hooks compiler rejects that).
|
|
79
|
+
const indexRef = useRef(index);
|
|
80
|
+
const contextRef = useRef(context);
|
|
81
|
+
const entryRef = useRef(entryKey);
|
|
82
|
+
const headingRef = useRef(activeHeading);
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
indexRef.current = index;
|
|
85
|
+
contextRef.current = context;
|
|
86
|
+
entryRef.current = entryKey;
|
|
87
|
+
headingRef.current = activeHeading;
|
|
88
|
+
}, [index, context, entryKey, activeHeading]);
|
|
39
89
|
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
const staticBanner: Banner = !writable ? 'read-only' : provider === null ? 'no-key' : null;
|
|
43
|
-
const banner: Banner = sendBanner || staticBanner;
|
|
90
|
+
// The loop's transcript, kept so follow-up questions replay the tool-use chain.
|
|
91
|
+
const transcriptRef = useRef<AgentMessage[]>([]);
|
|
44
92
|
|
|
45
93
|
useEffect(() => {
|
|
46
94
|
if (open) requestAnimationFrame(() => footRef.current?.focus());
|
|
47
95
|
}, [open]);
|
|
96
|
+
// The panel's only trigger (the resting input) UNMOUNTS when it opens, and
|
|
97
|
+
// its onFocus IS the open action — returning focus to it would reopen the
|
|
98
|
+
// panel. So the close path returns focus to the resting line's SUBMIT
|
|
99
|
+
// control (R-IX-1): a real stop in the same surface that never reopens
|
|
100
|
+
// anything, so Escape never strands the keyboard on <body>. On mount the
|
|
101
|
+
// effect stays quiet (was-open gate) or the panel would open itself.
|
|
102
|
+
const restingGoRef = useRef<HTMLButtonElement>(null);
|
|
103
|
+
const wasOpenRef = useRef(false);
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (wasOpenRef.current && !open) requestAnimationFrame(() => restingGoRef.current?.focus());
|
|
106
|
+
wasOpenRef.current = open;
|
|
107
|
+
}, [open]);
|
|
48
108
|
useEffect(() => {
|
|
49
109
|
bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight });
|
|
50
|
-
}, [
|
|
110
|
+
}, [rows, streaming]);
|
|
51
111
|
|
|
52
|
-
async function
|
|
53
|
-
const q =
|
|
112
|
+
async function ask(question: string) {
|
|
113
|
+
const q = question.trim();
|
|
54
114
|
if (!q || streaming) return;
|
|
55
115
|
setOpen(true);
|
|
56
116
|
setDraft('');
|
|
57
117
|
setResting('');
|
|
58
|
-
|
|
59
|
-
|
|
118
|
+
setErrorToast(null);
|
|
119
|
+
setRows((prev) => [...prev, { kind: 'user', text: q }]);
|
|
60
120
|
setStreaming(true);
|
|
61
|
-
setSendBanner(null);
|
|
62
121
|
|
|
122
|
+
// G-GA-8: a provider without `features.tools` degrades to context-stuffing —
|
|
123
|
+
// the deixis block, an index summary, and the current entry body ride the
|
|
124
|
+
// prompt; the request carries ZERO tools.
|
|
125
|
+
const toolsSupported = providerState.status === 'configured' && providerState.provider.features.tools === true;
|
|
126
|
+
const chroot = getContentRoot();
|
|
127
|
+
const currentKey = entryRef.current;
|
|
128
|
+
const contextBlock = renderAgentContext({
|
|
129
|
+
...contextRef.current,
|
|
130
|
+
entryPath: currentKey,
|
|
131
|
+
heading: headingRef.current || undefined,
|
|
132
|
+
});
|
|
133
|
+
let stuffedBody: string | undefined;
|
|
134
|
+
if (!toolsSupported) {
|
|
135
|
+
stuffedBody = await safeSources.read(currentKey).catch(() => undefined);
|
|
136
|
+
}
|
|
137
|
+
const system = buildSystemPrompt({
|
|
138
|
+
contextBlock,
|
|
139
|
+
toolsSupported,
|
|
140
|
+
...(toolsSupported ? {} : { entryBody: stuffedBody, entryPath: currentKey, index: indexRef.current, chroot }),
|
|
141
|
+
});
|
|
142
|
+
const entryTool = createReadEntryTool(chroot);
|
|
143
|
+
const metaTool = createGroveMetadataTool(chroot, () => indexRef.current);
|
|
144
|
+
const execute = toolExecutor([entryTool, metaTool]);
|
|
145
|
+
const tools = toolsSupported ? groveAgentTools(chroot, () => indexRef.current) : [];
|
|
146
|
+
|
|
147
|
+
let acc = '';
|
|
148
|
+
const pushAssistant = (text: string) =>
|
|
149
|
+
setRows((prev) => {
|
|
150
|
+
const next = prev.slice();
|
|
151
|
+
if (next.length && next[next.length - 1].kind === 'assistant') next[next.length - 1] = { kind: 'assistant', text };
|
|
152
|
+
else next.push({ kind: 'assistant', text });
|
|
153
|
+
return next;
|
|
154
|
+
});
|
|
155
|
+
const controller = new AbortController();
|
|
156
|
+
abortRef.current = controller;
|
|
63
157
|
try {
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
158
|
+
const final = await runAgent({
|
|
159
|
+
client: createChatModelClient(),
|
|
160
|
+
tools,
|
|
161
|
+
execute,
|
|
162
|
+
system,
|
|
163
|
+
prompt: q,
|
|
164
|
+
history: transcriptRef.current,
|
|
165
|
+
maxTurns: 8,
|
|
166
|
+
signal: controller.signal,
|
|
167
|
+
events: {
|
|
168
|
+
onAssistantDelta: (t) => {
|
|
169
|
+
acc += t;
|
|
170
|
+
pushAssistant(acc);
|
|
171
|
+
},
|
|
172
|
+
onToolUse: (name, input) => {
|
|
173
|
+
acc = '';
|
|
174
|
+
setRows((prev) => {
|
|
175
|
+
const next = prev.slice();
|
|
176
|
+
if (next.length && next[next.length - 1].kind === 'assistant' && !next[next.length - 1].text) next.pop();
|
|
177
|
+
next.push({ kind: 'activity', text: toolActivityLine(name, input) });
|
|
178
|
+
return next;
|
|
179
|
+
});
|
|
74
180
|
},
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
const rendered = transcriptToRows(final);
|
|
184
|
+
// The real loop RESOLVES an aborted run with the partial transcript (a
|
|
185
|
+
// clean stop, never a thrown error) — so the abort check lives here too,
|
|
186
|
+
// not only in the catch: a stopped run keeps what streamed and appends a
|
|
187
|
+
// named Stopped row (R3-608).
|
|
188
|
+
if (controller.signal.aborted) {
|
|
189
|
+
setRows((prev) => [...prev, { kind: 'activity', text: 'Stopped' }]);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (!rendered.some((r) => r.kind === 'assistant')) throw new Error('empty');
|
|
193
|
+
transcriptRef.current = final.slice();
|
|
194
|
+
setRows(rendered);
|
|
195
|
+
} catch (e) {
|
|
196
|
+
// A stop is not an error (R3-608): the aborted run settles with a named
|
|
197
|
+
// "Stopped" row, and the transcript keeps what streamed so far.
|
|
198
|
+
if (controller.signal.aborted) {
|
|
199
|
+
setRows((prev) => [...prev, { kind: 'activity', text: 'Stopped' }]);
|
|
200
|
+
return;
|
|
92
201
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
202
|
+
const code = (e as { code?: string })?.code || (e as Error).message;
|
|
203
|
+
setErrorToast(
|
|
204
|
+
code === 'auth-required'
|
|
205
|
+
? 'no model key connected — add one in Settings'
|
|
206
|
+
: code === 'forbidden'
|
|
207
|
+
? "this Grove wasn't granted chat — reading works as normal"
|
|
208
|
+
: 'the model or backend errored — try again in a moment',
|
|
209
|
+
);
|
|
210
|
+
setRows((prev) =>
|
|
211
|
+
prev.length && prev[prev.length - 1].kind === 'assistant' && !prev[prev.length - 1].text ? prev.slice(0, -1) : prev,
|
|
212
|
+
);
|
|
99
213
|
} finally {
|
|
100
214
|
setStreaming(false);
|
|
215
|
+
if (abortRef.current === controller) abortRef.current = null;
|
|
101
216
|
}
|
|
102
217
|
}
|
|
103
218
|
|
|
104
|
-
const restingDisabled = banner === 'read-only';
|
|
105
|
-
|
|
106
219
|
return (
|
|
107
220
|
<div className="grove-agent">
|
|
108
|
-
{/* Resting: the always-present thin input line
|
|
221
|
+
{/* Resting: the always-present thin input line. Q&A is never disabled by
|
|
222
|
+
read-only (R-GA-5) — only by the envelope genuinely lacking a provider. */}
|
|
109
223
|
{!open && (
|
|
110
224
|
<form
|
|
111
225
|
className="ga-line"
|
|
112
226
|
onSubmit={(e) => {
|
|
113
227
|
e.preventDefault();
|
|
114
|
-
if (
|
|
115
|
-
else
|
|
228
|
+
if (canAsk) void ask(resting);
|
|
229
|
+
else setOpen(true);
|
|
116
230
|
}}
|
|
117
231
|
>
|
|
118
232
|
<span className="mk" />
|
|
119
233
|
<input
|
|
120
234
|
value={resting}
|
|
121
|
-
placeholder={
|
|
235
|
+
placeholder={`Ask Grove about “${entryTitle}”…`}
|
|
122
236
|
onChange={(e) => setResting(e.target.value)}
|
|
123
237
|
onFocus={() => setOpen(true)}
|
|
124
238
|
aria-label="Ask Grove"
|
|
125
239
|
/>
|
|
126
|
-
<button className="go" type="submit" aria-label="Ask Grove">
|
|
240
|
+
<button ref={restingGoRef} className="go" type="submit" aria-label="Ask Grove">
|
|
127
241
|
<Icon name="send" />
|
|
128
242
|
</button>
|
|
129
243
|
</form>
|
|
@@ -133,16 +247,20 @@ export default function GroveAgent({ writable, entryKey, entryTitle }: { writabl
|
|
|
133
247
|
{open && (
|
|
134
248
|
<>
|
|
135
249
|
<div className="ga-scrim" onClick={() => setOpen(false)} />
|
|
136
|
-
<div
|
|
250
|
+
<div
|
|
251
|
+
className="ga-panel"
|
|
252
|
+
data-detent={detent}
|
|
253
|
+
ref={dialogRef}
|
|
254
|
+
tabIndex={-1}
|
|
255
|
+
role="dialog"
|
|
256
|
+
aria-modal="true"
|
|
257
|
+
aria-label="Ask Grove"
|
|
258
|
+
>
|
|
137
259
|
<div className="ga-panel-inner">
|
|
138
260
|
<div className="ga-head">
|
|
139
261
|
<span className="grip" onClick={() => setDetent((d) => (d === 'half' ? 'full' : 'half'))} />
|
|
140
262
|
<span className="mk" />
|
|
141
263
|
<span className="id">Grove</span>
|
|
142
|
-
<span className="scope">
|
|
143
|
-
<Icon name="shield" />
|
|
144
|
-
scoped to this wiki
|
|
145
|
-
</span>
|
|
146
264
|
<span className="grow">
|
|
147
265
|
<button className="ic" aria-label={detent === 'half' ? 'Expand' : 'Collapse'} onClick={() => setDetent((d) => (d === 'half' ? 'full' : 'half'))}>
|
|
148
266
|
<Icon name={detent === 'half' ? 'chevron-down' : 'chevron-right'} />
|
|
@@ -153,19 +271,36 @@ export default function GroveAgent({ writable, entryKey, entryTitle }: { writabl
|
|
|
153
271
|
</span>
|
|
154
272
|
</div>
|
|
155
273
|
|
|
156
|
-
{
|
|
274
|
+
{/* The reach card — the envelope, computed (R-GA-1). */}
|
|
275
|
+
<div className="ga-reach" role="list" aria-label="What the agent can do here">
|
|
276
|
+
{reachRows.map((r) => (
|
|
277
|
+
<div className={`ga-reach__row ga-reach__row--${r.state}`} role="listitem" key={r.key}>
|
|
278
|
+
<span className="ga-reach__mark" aria-hidden>
|
|
279
|
+
{r.state === 'ok' ? '✓' : r.state === 'blocked' ? '✗' : '·'}
|
|
280
|
+
</span>
|
|
281
|
+
<span className="ga-reach__label">{r.label}</span>
|
|
282
|
+
{r.cause && <span className="ga-reach__cause">{r.cause}</span>}
|
|
283
|
+
</div>
|
|
284
|
+
))}
|
|
285
|
+
</div>
|
|
286
|
+
{showEgressDisclosure(providerState) && <p className="ga-egress">{EGRESS_DISCLOSURE}</p>}
|
|
287
|
+
{errorToast && (
|
|
288
|
+
<div className="ga-toast" role="status">
|
|
289
|
+
{errorToast}
|
|
290
|
+
</div>
|
|
291
|
+
)}
|
|
157
292
|
|
|
158
293
|
<div className="ga-body" ref={bodyRef}>
|
|
159
|
-
{
|
|
294
|
+
{rows.length === 0 ? (
|
|
160
295
|
<>
|
|
161
296
|
<div className="ga-explain">
|
|
162
|
-
<b>Grove's agent</b>
|
|
163
|
-
|
|
297
|
+
<b>Grove's agent</b> answers questions about this wiki — its entries, tags, and structure — from the
|
|
298
|
+
page you're on.
|
|
164
299
|
</div>
|
|
165
|
-
{
|
|
300
|
+
{chips.length > 0 && (
|
|
166
301
|
<div className="ga-chips">
|
|
167
|
-
{
|
|
168
|
-
<button key={c} className="ga-chip" onClick={() =>
|
|
302
|
+
{chips.map((c) => (
|
|
303
|
+
<button key={c} className="ga-chip" onClick={() => void ask(c)}>
|
|
169
304
|
{c}
|
|
170
305
|
</button>
|
|
171
306
|
))}
|
|
@@ -173,20 +308,27 @@ export default function GroveAgent({ writable, entryKey, entryTitle }: { writabl
|
|
|
173
308
|
)}
|
|
174
309
|
</>
|
|
175
310
|
) : (
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
<
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
{
|
|
311
|
+
rows.map((r, i) =>
|
|
312
|
+
r.kind === 'activity' ? (
|
|
313
|
+
<div key={i} className="ga-activity">
|
|
314
|
+
<Icon name="file" />
|
|
315
|
+
{r.text}
|
|
316
|
+
</div>
|
|
317
|
+
) : (
|
|
318
|
+
<div key={i} className={`ga-msg ${r.kind}`}>
|
|
319
|
+
<span className={`ga-msg__av ${r.kind === 'assistant' ? 'grove' : 'user'}`}>
|
|
320
|
+
{r.kind === 'assistant' ? '' : 'you'}
|
|
321
|
+
</span>
|
|
322
|
+
<div className="ga-msg__b">
|
|
323
|
+
<div className="ga-msg__who">{r.kind === 'assistant' ? 'Grove' : 'You'}</div>
|
|
324
|
+
<div className="ga-msg__txt">
|
|
325
|
+
{r.text}
|
|
326
|
+
{streaming && i === rows.length - 1 && r.kind === 'assistant' ? <span className="ga-cursor" /> : null}
|
|
327
|
+
</div>
|
|
186
328
|
</div>
|
|
187
329
|
</div>
|
|
188
|
-
|
|
189
|
-
)
|
|
330
|
+
),
|
|
331
|
+
)
|
|
190
332
|
)}
|
|
191
333
|
</div>
|
|
192
334
|
|
|
@@ -195,30 +337,42 @@ export default function GroveAgent({ writable, entryKey, entryTitle }: { writabl
|
|
|
195
337
|
className="ga-foot__row"
|
|
196
338
|
onSubmit={(e) => {
|
|
197
339
|
e.preventDefault();
|
|
198
|
-
|
|
340
|
+
void ask(draft);
|
|
199
341
|
}}
|
|
200
342
|
>
|
|
201
343
|
<input
|
|
202
344
|
ref={footRef}
|
|
203
345
|
value={draft}
|
|
204
346
|
placeholder="Ask Grove…"
|
|
205
|
-
disabled={streaming ||
|
|
347
|
+
disabled={streaming || !canAsk}
|
|
206
348
|
onChange={(e) => setDraft(e.target.value)}
|
|
207
349
|
/>
|
|
208
|
-
|
|
209
|
-
<
|
|
350
|
+
{streaming ? (
|
|
351
|
+
<button
|
|
352
|
+
className="stop"
|
|
353
|
+
type="button"
|
|
354
|
+
aria-label="Stop"
|
|
355
|
+
title="Stop"
|
|
356
|
+
onClick={() => abortRef.current?.abort()}
|
|
357
|
+
>
|
|
358
|
+
<Icon name="x" />
|
|
359
|
+
</button>
|
|
360
|
+
) : null}
|
|
361
|
+
<button className="go" type="submit" disabled={streaming || !canAsk || !draft.trim()} aria-label={streaming ? 'Answering…' : 'Send'} title={streaming ? 'Answering…' : 'Send'}>
|
|
362
|
+
<Icon name="send" />
|
|
210
363
|
</button>
|
|
211
364
|
</form>
|
|
212
365
|
<div className="ga-foot__hand">
|
|
213
|
-
<span>
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
366
|
+
<span>{EGRESS_DISCLOSURE}</span>
|
|
367
|
+
{editRefused && (
|
|
368
|
+
<span className="ga-edit-refused" role="status">
|
|
369
|
+
{EDIT_REFUSED_NOTICE}
|
|
370
|
+
</span>
|
|
371
|
+
)}
|
|
372
|
+
<button type="button" onClick={() => openEditor(entryKey)}>
|
|
219
373
|
<Icon name="external" />
|
|
220
|
-
Open in the
|
|
221
|
-
</
|
|
374
|
+
Open in the editor
|
|
375
|
+
</button>
|
|
222
376
|
</div>
|
|
223
377
|
</div>
|
|
224
378
|
</div>
|
|
@@ -228,37 +382,3 @@ export default function GroveAgent({ writable, entryKey, entryTitle }: { writabl
|
|
|
228
382
|
</div>
|
|
229
383
|
);
|
|
230
384
|
}
|
|
231
|
-
|
|
232
|
-
// The no-key / forbidden / read-only / error states — calm explanations that hand
|
|
233
|
-
// to the host secret flow rather than faking a key prompt.
|
|
234
|
-
function AgentBanner({ banner }: { banner: Banner }) {
|
|
235
|
-
const copy: Record<string, { t: string; d: React.ReactNode }> = {
|
|
236
|
-
'no-key': {
|
|
237
|
-
t: 'Connect a key to use the agent.',
|
|
238
|
-
d: 'Grove rides whichever model provider you connect in the host. Add a key to start asking.',
|
|
239
|
-
},
|
|
240
|
-
forbidden: {
|
|
241
|
-
t: 'The agent isn’t available here.',
|
|
242
|
-
d: "This Grove wasn't granted the chat capability. Reading still works as normal.",
|
|
243
|
-
},
|
|
244
|
-
'read-only': {
|
|
245
|
-
t: 'You’re viewing read-only.',
|
|
246
|
-
d: 'The agent can explain this wiki, but changes need write access to this space.',
|
|
247
|
-
},
|
|
248
|
-
error: {
|
|
249
|
-
t: 'The agent hit a snag.',
|
|
250
|
-
d: 'The model or backend errored. Try again in a moment.',
|
|
251
|
-
},
|
|
252
|
-
};
|
|
253
|
-
const c = copy[banner as string];
|
|
254
|
-
if (!c) return null;
|
|
255
|
-
return (
|
|
256
|
-
<div className="ga-banner">
|
|
257
|
-
<Icon name="alert" />
|
|
258
|
-
<div>
|
|
259
|
-
<div className="ga-banner__t">{c.t}</div>
|
|
260
|
-
<div className="ga-banner__d">{c.d}</div>
|
|
261
|
-
</div>
|
|
262
|
-
</div>
|
|
263
|
-
);
|
|
264
|
-
}
|