akanjs 3.0.0-alpha.26 → 3.0.0-alpha.28
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/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
- package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
- package/package.json +1 -1
- package/server/systemPageDocument.tsx +1 -1
- package/types/server/systemPageDocument.d.ts +1 -1
- package/types/ui/Agent/Markdown.d.ts +11 -0
- package/types/ui/Agent/markdownBlocks.d.ts +34 -0
- package/types/ui/Agent/markdownSpans.d.ts +2 -0
- package/types/ui/Agent/markdownTable.d.ts +16 -0
- package/types/ui/Model/AdminPanel.d.ts +4 -1
- package/ui/Agent/Approval.tsx +1 -1
- package/ui/Agent/Bubble.tsx +2 -1
- package/ui/Agent/Chat.tsx +47 -6
- package/ui/Agent/Context.tsx +1 -1
- package/ui/Agent/Dock.tsx +1 -1
- package/ui/Agent/Markdown.tsx +125 -0
- package/ui/Agent/Tool.tsx +1 -1
- package/ui/Agent/Transcript.tsx +1 -1
- package/ui/Agent/markdownBlocks.ts +135 -0
- package/ui/Agent/markdownSpans.tsx +49 -0
- package/ui/Agent/markdownTable.ts +54 -0
- package/ui/Model/AdminPanel.tsx +15 -3
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -39,7 +39,7 @@ export const SYSTEM_PAGE_STATUS_COPY = {
|
|
|
39
39
|
} as const;
|
|
40
40
|
|
|
41
41
|
export const SYSTEM_PAGE_STYLE = `
|
|
42
|
-
:root { color-scheme: dark; --akan-primary: #ff493b; --akan-secondary: #2b2e33; --akan-accent: #d1a23b; --akan-foreground: #ffffff; --akan-background: #1a1a1a; --akan-
|
|
42
|
+
:root { color-scheme: dark; --akan-primary: #ff493b; --akan-secondary: #2b2e33; --akan-accent: #d1a23b; --akan-foreground: #ffffff; --akan-background: #1a1a1a; --akan-error: #f02020; }
|
|
43
43
|
body { margin: 0; min-height: 100vh; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: var(--akan-background); color: var(--akan-foreground); }
|
|
44
44
|
a { color: inherit; }
|
|
45
45
|
.akan-system-page { min-height: 100vh; display: grid; place-items: center; padding: 32px 18px; box-sizing: border-box; background: radial-gradient(circle at top left, rgba(255, 73, 59, 0.16), transparent 34%), linear-gradient(135deg, var(--akan-background), #111); }
|
|
@@ -32,7 +32,7 @@ export declare const SYSTEM_PAGE_STATUS_COPY: {
|
|
|
32
32
|
readonly actionLabel: "Back to safety";
|
|
33
33
|
};
|
|
34
34
|
};
|
|
35
|
-
export declare const SYSTEM_PAGE_STYLE = "\n:root { color-scheme: dark; --akan-primary: #ff493b; --akan-secondary: #2b2e33; --akan-accent: #d1a23b; --akan-foreground: #ffffff; --akan-background: #1a1a1a; --akan-
|
|
35
|
+
export declare const SYSTEM_PAGE_STYLE = "\n:root { color-scheme: dark; --akan-primary: #ff493b; --akan-secondary: #2b2e33; --akan-accent: #d1a23b; --akan-foreground: #ffffff; --akan-background: #1a1a1a; --akan-error: #f02020; }\nbody { margin: 0; min-height: 100vh; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif; background: var(--akan-background); color: var(--akan-foreground); }\na { color: inherit; }\n.akan-system-page { min-height: 100vh; display: grid; place-items: center; padding: 32px 18px; box-sizing: border-box; background: radial-gradient(circle at top left, rgba(255, 73, 59, 0.16), transparent 34%), linear-gradient(135deg, var(--akan-background), #111); }\n.akan-system-card { width: min(720px, 100%); border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 32px; background: linear-gradient(145deg, rgba(42, 42, 42, 0.96), rgba(43, 46, 51, 0.78)); box-shadow: 0 24px 80px rgba(0, 0, 0, 0.36); padding: clamp(28px, 6vw, 56px); }\n.akan-system-status { margin: 0 0 18px; font-weight: 800; font-size: clamp(4rem, 18vw, 8rem); line-height: 0.85; letter-spacing: -0.08em; color: var(--akan-primary); }\n.akan-system-eyebrow { margin: 0 0 10px; color: var(--akan-accent); font-size: 0.78rem; font-weight: 800; letter-spacing: 0.18em; text-transform: uppercase; }\n.akan-system-title { margin: 0; max-width: 12ch; font-size: clamp(2rem, 7vw, 4rem); line-height: 0.95; letter-spacing: -0.055em; color: var(--akan-foreground); }\n.akan-system-description { margin: 22px 0 0; max-width: 56ch; color: rgba(255, 255, 255, 0.76); font-size: 1.05rem; line-height: 1.75; }\n.akan-system-path { margin: 22px 0 0; overflow-wrap: anywhere; border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 16px; background: rgba(26, 26, 26, 0.72); padding: 12px 14px; color: rgba(255, 255, 255, 0.72); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 0.88rem; }\n.akan-system-actions { margin-top: 30px; display: flex; flex-wrap: wrap; gap: 12px; }\n.akan-system-action { display: inline-flex; min-height: 44px; align-items: center; justify-content: center; border: 1px solid var(--akan-primary); border-radius: 999px; background: var(--akan-primary); box-shadow: 0 12px 34px rgba(255, 73, 59, 0.26); color: var(--akan-foreground); padding: 0 18px; font-weight: 800; text-decoration: none; }\n.akan-system-secondary { border: 1px solid rgba(255, 255, 255, 0.12); background: var(--akan-secondary); color: var(--akan-foreground); }\n.akan-system-details { margin-top: 28px; max-height: 260px; overflow: auto; border-radius: 18px; background: rgba(26, 26, 26, 0.82); border: 1px solid rgba(240, 32, 32, 0.22); padding: 16px; color: rgba(255, 255, 255, 0.78); font-size: 0.82rem; line-height: 1.55; white-space: pre-wrap; }\n";
|
|
36
36
|
export declare function createSystemPageDocument(options: SystemPageOptions): ReactNode;
|
|
37
37
|
export declare function getSystemPageErrorDetails(error: unknown): string;
|
|
38
38
|
export declare function getSystemPageHomeHref({ pathname, i18n, basePaths, headerBasePath, }: SystemPageHomeHrefOptions): string;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
interface MarkdownProps {
|
|
2
|
+
className?: string;
|
|
3
|
+
children: string;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* The chat's own markdown renderer: React elements, never `dangerouslySetInnerHTML`, and no parser dependency.
|
|
7
|
+
* `Bun.markdown` is the obvious candidate and cannot serve this — it is a runtime API the bundler passes through
|
|
8
|
+
* verbatim, so it is undefined in the browser, and assistant text arrives here as SSE deltas the client accrues.
|
|
9
|
+
*/
|
|
10
|
+
export default function Markdown({ className, children }: MarkdownProps): import("react/jsx-runtime").JSX.Element;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type TableBlock } from "./markdownTable.d.ts";
|
|
2
|
+
export interface MarkdownItem {
|
|
3
|
+
depth: number;
|
|
4
|
+
ordered: boolean;
|
|
5
|
+
num: number;
|
|
6
|
+
text: string;
|
|
7
|
+
}
|
|
8
|
+
export type MarkdownBlock = {
|
|
9
|
+
kind: "code";
|
|
10
|
+
text: string;
|
|
11
|
+
} | {
|
|
12
|
+
kind: "heading";
|
|
13
|
+
level: number;
|
|
14
|
+
text: string;
|
|
15
|
+
} | {
|
|
16
|
+
kind: "list";
|
|
17
|
+
items: MarkdownItem[];
|
|
18
|
+
} | {
|
|
19
|
+
kind: "quote";
|
|
20
|
+
text: string;
|
|
21
|
+
} | {
|
|
22
|
+
kind: "rule";
|
|
23
|
+
} | {
|
|
24
|
+
kind: "para";
|
|
25
|
+
text: string;
|
|
26
|
+
} | TableBlock;
|
|
27
|
+
/**
|
|
28
|
+
* Block scanner for assistant chat text — the CommonMark subset a model actually emits over this wire.
|
|
29
|
+
*/
|
|
30
|
+
export declare class MarkdownBlocks {
|
|
31
|
+
#private;
|
|
32
|
+
static of(source: string): MarkdownBlock[];
|
|
33
|
+
constructor(source: string);
|
|
34
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type Align = "left" | "center" | "right";
|
|
2
|
+
export interface TableBlock {
|
|
3
|
+
kind: "table";
|
|
4
|
+
aligns: (Align | null)[];
|
|
5
|
+
head: string[];
|
|
6
|
+
rows: string[][];
|
|
7
|
+
}
|
|
8
|
+
/** GFM table scan, kept out of the block scanner because the delimiter row makes it the one shape that has to
|
|
9
|
+
* read the line after the one it starts on. */
|
|
10
|
+
export declare class MarkdownTable {
|
|
11
|
+
#private;
|
|
12
|
+
static at(lines: string[], at: number): {
|
|
13
|
+
block: TableBlock;
|
|
14
|
+
next: number;
|
|
15
|
+
} | null;
|
|
16
|
+
}
|
|
@@ -18,6 +18,9 @@ interface AdminPanelProps<T extends string, State, Input, Full extends {
|
|
|
18
18
|
[key: string]: any;
|
|
19
19
|
};
|
|
20
20
|
};
|
|
21
|
+
template?: any;
|
|
22
|
+
unit?: any;
|
|
23
|
+
view?: any;
|
|
21
24
|
/** Summary column to query descriptor. Only a column this map names becomes a link to its filtered listing. */
|
|
22
25
|
queryMap?: {
|
|
23
26
|
[key: string]: unknown;
|
|
@@ -31,5 +34,5 @@ export default function AdminPanel<RefName extends string, State, Input, Full ex
|
|
|
31
34
|
id: string;
|
|
32
35
|
}, Light extends {
|
|
33
36
|
id: string;
|
|
34
|
-
}>({ slice, components, queryMap, summaryColumns, insightColumns, renderInsight, renderDashboard, ...props }: AdminPanelProps<RefName, State, Input, Full, Light>): import("react/jsx-runtime").JSX.Element;
|
|
37
|
+
}>({ slice, components, queryMap, template, unit, view, summaryColumns, insightColumns, renderInsight, renderDashboard, ...props }: AdminPanelProps<RefName, State, Input, Full, Light>): import("react/jsx-runtime").JSX.Element;
|
|
35
38
|
export {};
|
package/ui/Agent/Approval.tsx
CHANGED
|
@@ -13,7 +13,7 @@ export default function Approval({ className, approval }: ApprovalProps) {
|
|
|
13
13
|
return (
|
|
14
14
|
<div className={cn("flex flex-col gap-2 border-warning/30 border-t bg-warning/10 px-4 py-3", className)}>
|
|
15
15
|
<p className="text-sm">{approval.message}</p>
|
|
16
|
-
<pre className="max-h-24 overflow-auto rounded-field bg-
|
|
16
|
+
<pre className="max-h-24 overflow-auto rounded-field bg-background/60 p-2 font-mono text-[10px]">
|
|
17
17
|
{JSON.stringify(approval.args, null, 2)}
|
|
18
18
|
</pre>
|
|
19
19
|
<div className="flex justify-end gap-2">
|
package/ui/Agent/Bubble.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { cn } from "akanjs/client";
|
|
3
3
|
import type { ChatMessage, ToolCallResult } from "../../vendor/use-agentic";
|
|
4
|
+
import Markdown from "./Markdown";
|
|
4
5
|
|
|
5
6
|
interface BubbleProps {
|
|
6
7
|
className?: string;
|
|
@@ -63,7 +64,7 @@ export default function Bubble({ className, message, results }: BubbleProps) {
|
|
|
63
64
|
const isDrafting = !message.text && !message.toolCalls?.length && !message.error;
|
|
64
65
|
return (
|
|
65
66
|
<div className={cn("flex max-w-[85%] flex-col gap-1 self-start", className)}>
|
|
66
|
-
{message.text ? <
|
|
67
|
+
{message.text ? <Markdown className="text-sm">{message.text}</Markdown> : null}
|
|
67
68
|
{(message.toolCalls ?? []).map((call) => (
|
|
68
69
|
<Row key={call.id} args={call.args} name={call.name} result={results?.get(call.id)} />
|
|
69
70
|
))}
|
package/ui/Agent/Chat.tsx
CHANGED
|
@@ -3,7 +3,7 @@ import { cn, fetch, usePage } from "akanjs/client";
|
|
|
3
3
|
import type { PromptResult } from "akanjs/signal";
|
|
4
4
|
import { AgentContext, type AgentPrompt, AgentPrompts, ensureStoreSurface } from "akanjs/store";
|
|
5
5
|
import { useContext, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
6
|
-
import { AiOutlineClear, AiOutlineClose
|
|
6
|
+
import { AiOutlineClear, AiOutlineClose } from "react-icons/ai";
|
|
7
7
|
import { type AgentRunner, AgentSession, SessionContext } from "../../vendor/use-agentic";
|
|
8
8
|
import { Button } from "../Button";
|
|
9
9
|
import { inputRecipe } from "../recipe";
|
|
@@ -26,6 +26,8 @@ export interface ChatProps {
|
|
|
26
26
|
persist?: PersistOption;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
const isApplePlatform = () => /Mac|iPhone|iPad|iPod/i.test(navigator.platform);
|
|
30
|
+
|
|
29
31
|
/**
|
|
30
32
|
* The user-facing half of the in-page agent: one floating chat wired to the same surface the dock inspects.
|
|
31
33
|
* The conversation loop runs in this browser session — every tool call executes here, gated by the approval
|
|
@@ -63,10 +65,31 @@ export const DefaultChat = ({
|
|
|
63
65
|
);
|
|
64
66
|
const [open, setOpen] = useState(defaultOpen);
|
|
65
67
|
const [draft, setDraft] = useState("");
|
|
68
|
+
const [hotkey, setHotkey] = useState<{ label: string; keys: string } | null>(null);
|
|
66
69
|
const listRef = useRef<HTMLDivElement>(null);
|
|
70
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
67
71
|
useEffect(() => {
|
|
68
72
|
listRef.current?.scrollTo({ top: listRef.current.scrollHeight });
|
|
69
73
|
}, [version, open]);
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
const apple = isApplePlatform();
|
|
76
|
+
setHotkey(apple ? { label: "⌘ L", keys: "Meta+L" } : { label: "Ctrl+L", keys: "Control+L" });
|
|
77
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
78
|
+
if (event.repeat || event.key.toLowerCase() !== "l" || event.shiftKey || event.altKey) return;
|
|
79
|
+
const chord = apple ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey;
|
|
80
|
+
if (!chord) return;
|
|
81
|
+
event.preventDefault();
|
|
82
|
+
setOpen(true);
|
|
83
|
+
inputRef.current?.focus();
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
window.addEventListener("keydown", onKeyDown, true);
|
|
87
|
+
return () => window.removeEventListener("keydown", onKeyDown, true);
|
|
88
|
+
}, []);
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (!open) return;
|
|
91
|
+
inputRef.current?.focus();
|
|
92
|
+
}, [open]);
|
|
70
93
|
const runPrompt = async (prompt: AgentPrompt, args: string[]) => {
|
|
71
94
|
const usage = `/${prompt.name} ${prompt.args.map((arg) => `<${arg.name}>`).join(" ")}`.trim();
|
|
72
95
|
if (args.length < prompt.args.filter((arg) => arg.required).length) {
|
|
@@ -121,16 +144,33 @@ export const DefaultChat = ({
|
|
|
121
144
|
if (!open)
|
|
122
145
|
return (
|
|
123
146
|
<button
|
|
147
|
+
aria-keyshortcuts={hotkey?.keys}
|
|
124
148
|
aria-label={l("base.agent")}
|
|
125
149
|
data-agent-ui=""
|
|
126
150
|
className={cn(
|
|
127
|
-
"fixed right-4 bottom-4 z-50 flex size-12 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform hover:scale-105",
|
|
151
|
+
"group/agent fixed right-4 bottom-4 z-50 flex size-12 items-center justify-center rounded-full border border-primary/20 bg-primary/90 text-primary-foreground shadow-lg shadow-primary/30 transition-transform hover:scale-105",
|
|
128
152
|
className,
|
|
129
153
|
)}
|
|
130
154
|
onClick={() => setOpen(true)}
|
|
131
155
|
type="button"
|
|
132
156
|
>
|
|
133
|
-
|
|
157
|
+
{hotkey ? (
|
|
158
|
+
<kbd className="pointer-events-none absolute right-full mr-3 hidden rounded-lg border border-border bg-background px-2 py-0.5 font-mono text-foreground/50 text-xs opacity-0 shadow-sm group-hover/agent:opacity-100 group-focus-visible/agent:opacity-100 md:block">
|
|
159
|
+
{hotkey.label}
|
|
160
|
+
</kbd>
|
|
161
|
+
) : null}
|
|
162
|
+
<svg className="size-7" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
|
163
|
+
<path
|
|
164
|
+
fill="currentColor"
|
|
165
|
+
d="M40 18 C43.6 43.6 54.4 54.4 80 58 C54.4 61.6 43.6 72.4 40 98
|
|
166
|
+
C36.4 72.4 25.6 61.6 0 58 C25.6 54.4 36.4 43.6 40 18 Z"
|
|
167
|
+
/>
|
|
168
|
+
<path
|
|
169
|
+
fill="currentColor"
|
|
170
|
+
d="M80 2 C81.8 14.8 87.2 20.2 100 22 C87.2 23.8 81.8 29.2 80 42
|
|
171
|
+
C78.2 29.2 72.8 23.8 60 22 C72.8 20.2 78.2 14.8 80 2 Z"
|
|
172
|
+
/>
|
|
173
|
+
</svg>
|
|
134
174
|
</button>
|
|
135
175
|
);
|
|
136
176
|
return (
|
|
@@ -142,7 +182,7 @@ export const DefaultChat = ({
|
|
|
142
182
|
className,
|
|
143
183
|
)}
|
|
144
184
|
>
|
|
145
|
-
<header className="flex items-center gap-2 border-
|
|
185
|
+
<header className="flex items-center gap-2 border-foreground/5 border-b px-4 py-3">
|
|
146
186
|
<span className="font-semibold text-sm">{title ?? l("base.agent")}</span>
|
|
147
187
|
{session.isRunning ? <span className="size-2 animate-pulse rounded-full bg-primary" /> : null}
|
|
148
188
|
<span className="ml-auto flex items-center gap-2">
|
|
@@ -175,7 +215,7 @@ export const DefaultChat = ({
|
|
|
175
215
|
</div>
|
|
176
216
|
{session.pendingApproval ? <Approval approval={session.pendingApproval} /> : null}
|
|
177
217
|
{menu.length ? (
|
|
178
|
-
<div className="flex max-h-40 flex-col overflow-y-auto border-
|
|
218
|
+
<div className="flex max-h-40 flex-col overflow-y-auto border-foreground/5 border-t py-1">
|
|
179
219
|
{menu.map((prompt) => (
|
|
180
220
|
<button
|
|
181
221
|
className="flex items-baseline gap-2 px-4 py-1.5 text-left hover:bg-muted"
|
|
@@ -196,10 +236,11 @@ export const DefaultChat = ({
|
|
|
196
236
|
))}
|
|
197
237
|
</div>
|
|
198
238
|
) : null}
|
|
199
|
-
<div className="flex items-center gap-2 border-
|
|
239
|
+
<div className="flex items-center gap-2 border-foreground/5 border-t p-3">
|
|
200
240
|
<input
|
|
201
241
|
className={inputRecipe({ size: "sm" }, "flex-1")}
|
|
202
242
|
onChange={(event) => setDraft(event.target.value)}
|
|
243
|
+
ref={inputRef}
|
|
203
244
|
onKeyDown={(event) => {
|
|
204
245
|
if (event.key !== "Enter" || event.nativeEvent.isComposing) return;
|
|
205
246
|
event.preventDefault();
|
package/ui/Agent/Context.tsx
CHANGED
|
@@ -29,7 +29,7 @@ export default function Context({ className }: ContextProps) {
|
|
|
29
29
|
Assemble
|
|
30
30
|
</button>
|
|
31
31
|
{shown ? (
|
|
32
|
-
<pre className="mt-2 max-h-60 overflow-auto rounded-field bg-
|
|
32
|
+
<pre className="mt-2 max-h-60 overflow-auto rounded-field bg-background/60 p-2 text-[10px] leading-tight">
|
|
33
33
|
{shown}
|
|
34
34
|
</pre>
|
|
35
35
|
) : null}
|
package/ui/Agent/Dock.tsx
CHANGED
|
@@ -41,7 +41,7 @@ export const Dock = ({ className, bridge, surface, open = false }: DockProps) =>
|
|
|
41
41
|
<aside
|
|
42
42
|
data-agent-ui=""
|
|
43
43
|
className={cn(
|
|
44
|
-
"fixed right-4 bottom-4 z-50 flex max-h-[70vh] w-80 flex-col gap-2 overflow-y-auto rounded-box border border-
|
|
44
|
+
"fixed right-4 bottom-4 z-50 flex max-h-[70vh] w-80 flex-col gap-2 overflow-y-auto rounded-box border border-foreground/10 bg-background/95 p-3 shadow-lg",
|
|
45
45
|
className,
|
|
46
46
|
)}
|
|
47
47
|
>
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { cn } from "akanjs/client";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { type MarkdownBlock, MarkdownBlocks, type MarkdownItem } from "./markdownBlocks";
|
|
5
|
+
import { spans } from "./markdownSpans";
|
|
6
|
+
import type { Align, TableBlock } from "./markdownTable";
|
|
7
|
+
|
|
8
|
+
const alignClass: { [key in Align]: string } = { left: "text-left", center: "text-center", right: "text-right" };
|
|
9
|
+
|
|
10
|
+
interface ListProps {
|
|
11
|
+
className?: string;
|
|
12
|
+
items: MarkdownItem[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Folds the scanner's flat depth-scored items back into real nested lists, so a nested run carries its own
|
|
16
|
+
* marker and leaves the outer numbering alone — a hidden `li` still advances an `ol`'s counter. */
|
|
17
|
+
const List = ({ className, items }: ListProps) => {
|
|
18
|
+
const rows: ReactNode[] = [];
|
|
19
|
+
for (let at = 0; at < items.length; ) {
|
|
20
|
+
const item = items[at];
|
|
21
|
+
const nested: MarkdownItem[] = [];
|
|
22
|
+
for (at += 1; at < items.length && items[at].depth > item.depth; at += 1) nested.push(items[at]);
|
|
23
|
+
rows.push(
|
|
24
|
+
<li key={at}>
|
|
25
|
+
{spans(item.text)}
|
|
26
|
+
{nested.length ? <List className="pl-4" items={nested} /> : null}
|
|
27
|
+
</li>,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
const [first] = items;
|
|
31
|
+
return first.ordered ? (
|
|
32
|
+
<ol className={cn("list-inside list-decimal", className)} start={first.num}>
|
|
33
|
+
{rows}
|
|
34
|
+
</ol>
|
|
35
|
+
) : (
|
|
36
|
+
<ul className={cn("list-inside list-disc", className)}>{rows}</ul>
|
|
37
|
+
);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
interface TableProps {
|
|
41
|
+
block: TableBlock;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const Table = ({ block }: TableProps) => {
|
|
45
|
+
const cellClass = block.head.map((_, idx) => {
|
|
46
|
+
const align = block.aligns[idx];
|
|
47
|
+
return align ? alignClass[align] : "";
|
|
48
|
+
});
|
|
49
|
+
return (
|
|
50
|
+
|
|
51
|
+
<div className="overflow-x-auto">
|
|
52
|
+
<table className="min-w-full border-collapse">
|
|
53
|
+
<thead>
|
|
54
|
+
<tr className="bg-muted/40">
|
|
55
|
+
{block.head.map((cell, idx) => (
|
|
56
|
+
<th className={cn("border-base-content/10 border-b px-2 py-1 text-left", cellClass[idx])} key={idx}>
|
|
57
|
+
{spans(cell)}
|
|
58
|
+
</th>
|
|
59
|
+
))}
|
|
60
|
+
</tr>
|
|
61
|
+
</thead>
|
|
62
|
+
<tbody>
|
|
63
|
+
{block.rows.map((row, rowIdx) => (
|
|
64
|
+
<tr key={rowIdx}>
|
|
65
|
+
{row.map((cell, idx) => (
|
|
66
|
+
<td className={cn("border-base-content/5 border-b px-2 py-1 text-left", cellClass[idx])} key={idx}>
|
|
67
|
+
{spans(cell)}
|
|
68
|
+
</td>
|
|
69
|
+
))}
|
|
70
|
+
</tr>
|
|
71
|
+
))}
|
|
72
|
+
</tbody>
|
|
73
|
+
</table>
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
interface BlockProps {
|
|
79
|
+
block: MarkdownBlock;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const Block = ({ block }: BlockProps) => {
|
|
83
|
+
switch (block.kind) {
|
|
84
|
+
case "code":
|
|
85
|
+
return (
|
|
86
|
+
<pre className="overflow-x-auto rounded-field bg-muted p-2 font-mono text-[11px] leading-tight">
|
|
87
|
+
{block.text}
|
|
88
|
+
</pre>
|
|
89
|
+
);
|
|
90
|
+
case "heading":
|
|
91
|
+
return <p className={cn("font-semibold", block.level <= 2 && "text-base")}>{spans(block.text)}</p>;
|
|
92
|
+
case "list":
|
|
93
|
+
return <List items={block.items} />;
|
|
94
|
+
case "table":
|
|
95
|
+
return <Table block={block} />;
|
|
96
|
+
case "quote":
|
|
97
|
+
return (
|
|
98
|
+
<blockquote className="border-foreground/20 border-l-2 pl-2 text-foreground/70">{spans(block.text)}</blockquote>
|
|
99
|
+
);
|
|
100
|
+
case "rule":
|
|
101
|
+
return <hr className="border-foreground/10" />;
|
|
102
|
+
default:
|
|
103
|
+
return <p>{spans(block.text)}</p>;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
interface MarkdownProps {
|
|
108
|
+
className?: string;
|
|
109
|
+
children: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The chat's own markdown renderer: React elements, never `dangerouslySetInnerHTML`, and no parser dependency.
|
|
114
|
+
* `Bun.markdown` is the obvious candidate and cannot serve this — it is a runtime API the bundler passes through
|
|
115
|
+
* verbatim, so it is undefined in the browser, and assistant text arrives here as SSE deltas the client accrues.
|
|
116
|
+
*/
|
|
117
|
+
export default function Markdown({ className, children }: MarkdownProps) {
|
|
118
|
+
return (
|
|
119
|
+
<div className={cn("flex flex-col gap-2 break-words", className)}>
|
|
120
|
+
{MarkdownBlocks.of(children).map((block, idx) => (
|
|
121
|
+
<Block block={block} key={idx} />
|
|
122
|
+
))}
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|
package/ui/Agent/Tool.tsx
CHANGED
|
@@ -37,7 +37,7 @@ export default function Tool({ className, surface, tool, onRun }: ToolProps) {
|
|
|
37
37
|
onRun();
|
|
38
38
|
};
|
|
39
39
|
return (
|
|
40
|
-
<details className={cn("rounded-field bg-
|
|
40
|
+
<details className={cn("rounded-field bg-background/60 px-2 py-1", className)}>
|
|
41
41
|
<summary className="flex cursor-pointer list-none items-center gap-2 [&::-webkit-details-marker]:hidden">
|
|
42
42
|
<span
|
|
43
43
|
className={cn(
|
package/ui/Agent/Transcript.tsx
CHANGED
|
@@ -17,7 +17,7 @@ export default function Transcript({ className, calls }: TranscriptProps) {
|
|
|
17
17
|
return (
|
|
18
18
|
<div className={cn("flex flex-col gap-1", className)}>
|
|
19
19
|
{calls.map((call, idx) => (
|
|
20
|
-
<div className="flex flex-col rounded-field bg-
|
|
20
|
+
<div className="flex flex-col rounded-field bg-background/60 px-2 py-1" key={idx}>
|
|
21
21
|
<div className="flex items-baseline gap-2">
|
|
22
22
|
<span className="truncate font-mono text-xs">{call.name}</span>
|
|
23
23
|
<span className="ml-auto shrink-0 text-[10px] text-foreground/40">
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { MarkdownTable, type TableBlock } from "./markdownTable";
|
|
2
|
+
|
|
3
|
+
export interface MarkdownItem {
|
|
4
|
+
depth: number;
|
|
5
|
+
ordered: boolean;
|
|
6
|
+
num: number;
|
|
7
|
+
text: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type MarkdownBlock =
|
|
11
|
+
| { kind: "code"; text: string }
|
|
12
|
+
| { kind: "heading"; level: number; text: string }
|
|
13
|
+
| { kind: "list"; items: MarkdownItem[] }
|
|
14
|
+
| { kind: "quote"; text: string }
|
|
15
|
+
| { kind: "rule" }
|
|
16
|
+
| { kind: "para"; text: string }
|
|
17
|
+
| TableBlock;
|
|
18
|
+
|
|
19
|
+
const fence = /^ {0,3}(```|~~~)/;
|
|
20
|
+
const heading = /^ {0,3}(#{1,6})\s+(.*)$/;
|
|
21
|
+
const rule = /^ {0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/;
|
|
22
|
+
const quote = /^ {0,3}> ?(.*)$/;
|
|
23
|
+
const bullet = /^(\s*)[-*+]\s+(.*)$/;
|
|
24
|
+
const ordered = /^(\s*)(\d{1,9})[.)]\s+(.*)$/;
|
|
25
|
+
const lazyLine = /^\s+\S/;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Block scanner for assistant chat text — the CommonMark subset a model actually emits over this wire.
|
|
29
|
+
*/
|
|
30
|
+
export class MarkdownBlocks {
|
|
31
|
+
static of(source: string): MarkdownBlock[] {
|
|
32
|
+
return new MarkdownBlocks(source).#run();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
readonly #lines: string[];
|
|
36
|
+
readonly #blocks: MarkdownBlock[] = [];
|
|
37
|
+
#at = 0;
|
|
38
|
+
|
|
39
|
+
constructor(source: string) {
|
|
40
|
+
this.#lines = source.replace(/\r\n?/g, "\n").split("\n");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
#run() {
|
|
44
|
+
while (this.#at < this.#lines.length) {
|
|
45
|
+
const line = this.#lines[this.#at];
|
|
46
|
+
const head = heading.exec(line);
|
|
47
|
+
const fenced = fence.exec(line);
|
|
48
|
+
const table = MarkdownTable.at(this.#lines, this.#at);
|
|
49
|
+
if (!line.trim()) this.#at += 1;
|
|
50
|
+
else if (fenced) this.#code(fenced[1]);
|
|
51
|
+
else if (rule.test(line)) this.#take({ kind: "rule" });
|
|
52
|
+
else if (head) this.#take({ kind: "heading", level: head[1].length, text: head[2] });
|
|
53
|
+
else if (quote.test(line)) this.#quote();
|
|
54
|
+
else if (ordered.test(line)) this.#list(true);
|
|
55
|
+
else if (bullet.test(line)) this.#list(false);
|
|
56
|
+
else if (table) {
|
|
57
|
+
this.#blocks.push(table.block);
|
|
58
|
+
this.#at = table.next;
|
|
59
|
+
} else this.#para();
|
|
60
|
+
}
|
|
61
|
+
return this.#blocks;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
#take(block: MarkdownBlock) {
|
|
65
|
+
this.#blocks.push(block);
|
|
66
|
+
this.#at += 1;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#code(marker: string) {
|
|
70
|
+
this.#at += 1;
|
|
71
|
+
const body: string[] = [];
|
|
72
|
+
while (this.#at < this.#lines.length) {
|
|
73
|
+
const line = this.#lines[this.#at];
|
|
74
|
+
this.#at += 1;
|
|
75
|
+
if (line.trimStart().startsWith(marker)) break;
|
|
76
|
+
body.push(line);
|
|
77
|
+
}
|
|
78
|
+
this.#blocks.push({ kind: "code", text: body.join("\n") });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#quote() {
|
|
82
|
+
const lines: string[] = [];
|
|
83
|
+
while (this.#at < this.#lines.length) {
|
|
84
|
+
const match = quote.exec(this.#lines[this.#at]);
|
|
85
|
+
if (!match) break;
|
|
86
|
+
lines.push(match[1]);
|
|
87
|
+
this.#at += 1;
|
|
88
|
+
}
|
|
89
|
+
this.#blocks.push({ kind: "quote", text: lines.join(" ").trim() });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
#list(isOrdered: boolean) {
|
|
93
|
+
const items: MarkdownItem[] = [];
|
|
94
|
+
while (this.#at < this.#lines.length) {
|
|
95
|
+
const line = this.#lines[this.#at];
|
|
96
|
+
const item = MarkdownBlocks.#item(line, isOrdered);
|
|
97
|
+
const open = items.at(-1);
|
|
98
|
+
if (item) items.push(item);
|
|
99
|
+
else if (open && lazyLine.test(line)) open.text += ` ${line.trim()}`;
|
|
100
|
+
else break;
|
|
101
|
+
this.#at += 1;
|
|
102
|
+
}
|
|
103
|
+
this.#blocks.push({ kind: "list", items });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
static #item(line: string, isOrdered: boolean): MarkdownItem | null {
|
|
107
|
+
const numbered = ordered.exec(line);
|
|
108
|
+
const bulleted = bullet.exec(line);
|
|
109
|
+
const other = isOrdered ? bulleted : numbered;
|
|
110
|
+
const match = (isOrdered ? numbered : bulleted) ?? (other?.[1].length ? other : null);
|
|
111
|
+
if (!match) return null;
|
|
112
|
+
const carriesNum = match === numbered;
|
|
113
|
+
return {
|
|
114
|
+
depth: Math.floor(match[1].length / 2),
|
|
115
|
+
ordered: carriesNum,
|
|
116
|
+
num: carriesNum ? Number(match[2]) : 0,
|
|
117
|
+
text: carriesNum ? match[3] : match[2],
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
#para() {
|
|
122
|
+
const lines: string[] = [];
|
|
123
|
+
while (this.#at < this.#lines.length) {
|
|
124
|
+
const line = this.#lines[this.#at];
|
|
125
|
+
if (!line.trim() || this.#opensBlock(line) || MarkdownTable.at(this.#lines, this.#at)) break;
|
|
126
|
+
lines.push(line.trim());
|
|
127
|
+
this.#at += 1;
|
|
128
|
+
}
|
|
129
|
+
this.#blocks.push({ kind: "para", text: lines.join(" ") });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
#opensBlock(line: string) {
|
|
133
|
+
return [fence, heading, rule, quote, bullet, ordered].some((pattern) => pattern.test(line));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
const inline =
|
|
5
|
+
/(!?)\[([^\]]*)\]\(((?:[^\s()]|\([^\s()]*\))+)\)|`([^`]+)`|\*\*([\s\S]+?)\*\*|\*([^*\n]+?)\*|~~([\s\S]+?)~~/g;
|
|
6
|
+
|
|
7
|
+
const isSafeHref = (href: string) => !/^[a-z][a-z0-9+.-]*:/i.test(href) || /^(?:https?|mailto|tel):/i.test(href);
|
|
8
|
+
|
|
9
|
+
export const spans = (text: string): ReactNode[] => {
|
|
10
|
+
const nodes: ReactNode[] = [];
|
|
11
|
+
let cut = 0;
|
|
12
|
+
for (const match of text.matchAll(inline)) {
|
|
13
|
+
const at = match.index;
|
|
14
|
+
const [, image, label, href, code, strong, em, del] = match;
|
|
15
|
+
if (at > cut) nodes.push(text.slice(cut, at));
|
|
16
|
+
cut = at + match[0].length;
|
|
17
|
+
if (href)
|
|
18
|
+
nodes.push(
|
|
19
|
+
image || !isSafeHref(href) ? (
|
|
20
|
+
label
|
|
21
|
+
) : (
|
|
22
|
+
<a className="underline" href={href} key={at} rel="noreferrer" target="_blank">
|
|
23
|
+
{spans(label)}
|
|
24
|
+
</a>
|
|
25
|
+
),
|
|
26
|
+
);
|
|
27
|
+
else if (code)
|
|
28
|
+
nodes.push(
|
|
29
|
+
<code className="rounded-field bg-muted px-1 font-mono text-[11px]" key={at}>
|
|
30
|
+
{code}
|
|
31
|
+
</code>,
|
|
32
|
+
);
|
|
33
|
+
else if (strong)
|
|
34
|
+
nodes.push(
|
|
35
|
+
<strong className="font-semibold" key={at}>
|
|
36
|
+
{spans(strong)}
|
|
37
|
+
</strong>,
|
|
38
|
+
);
|
|
39
|
+
else if (em) nodes.push(<em key={at}>{spans(em)}</em>);
|
|
40
|
+
else if (del)
|
|
41
|
+
nodes.push(
|
|
42
|
+
<del className="opacity-60" key={at}>
|
|
43
|
+
{spans(del)}
|
|
44
|
+
</del>,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
if (cut < text.length) nodes.push(text.slice(cut));
|
|
48
|
+
return nodes;
|
|
49
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export type Align = "left" | "center" | "right";
|
|
2
|
+
|
|
3
|
+
export interface TableBlock {
|
|
4
|
+
kind: "table";
|
|
5
|
+
aligns: (Align | null)[];
|
|
6
|
+
head: string[];
|
|
7
|
+
rows: string[][];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const cellSplit = /(?<!\\)\|/;
|
|
11
|
+
const delimiter = /^:?-+:?$/;
|
|
12
|
+
|
|
13
|
+
/** GFM table scan, kept out of the block scanner because the delimiter row makes it the one shape that has to
|
|
14
|
+
* read the line after the one it starts on. */
|
|
15
|
+
export class MarkdownTable {
|
|
16
|
+
static at(lines: string[], at: number): { block: TableBlock; next: number } | null {
|
|
17
|
+
const header = lines[at];
|
|
18
|
+
const aligns = MarkdownTable.#aligns(lines[at + 1]);
|
|
19
|
+
if (!header?.includes("|") || !aligns) return null;
|
|
20
|
+
const head = MarkdownTable.#cells(header);
|
|
21
|
+
const rows: string[][] = [];
|
|
22
|
+
let next = at + 2;
|
|
23
|
+
for (; next < lines.length; next += 1) {
|
|
24
|
+
const line = lines[next];
|
|
25
|
+
if (!line.trim() || !line.includes("|")) break;
|
|
26
|
+
const cells = MarkdownTable.#cells(line);
|
|
27
|
+
|
|
28
|
+
rows.push(head.map((_, idx) => cells[idx] ?? ""));
|
|
29
|
+
}
|
|
30
|
+
return { block: { kind: "table", aligns, head, rows }, next };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
static #cells(line: string): string[] {
|
|
34
|
+
return line
|
|
35
|
+
.trim()
|
|
36
|
+
.replace(/^\|/, "")
|
|
37
|
+
.replace(/(?<!\\)\|$/, "")
|
|
38
|
+
.split(cellSplit)
|
|
39
|
+
.map((cell) => cell.trim().replace(/\\\|/g, "|"));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The delimiter row is what separates a table from a paragraph that merely contains a pipe, and from the
|
|
43
|
+
* `---` of a rule — which is why the pipe is required of it rather than of the header. */
|
|
44
|
+
static #aligns(line: string | undefined): (Align | null)[] | null {
|
|
45
|
+
if (!line?.includes("|")) return null;
|
|
46
|
+
const cells = MarkdownTable.#cells(line);
|
|
47
|
+
if (!cells.every((cell) => delimiter.test(cell))) return null;
|
|
48
|
+
return cells.map((cell) => {
|
|
49
|
+
const closed = cell.endsWith(":");
|
|
50
|
+
if (cell.startsWith(":")) return closed ? "center" : "left";
|
|
51
|
+
return closed ? "right" : null;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
package/ui/Model/AdminPanel.tsx
CHANGED
|
@@ -14,6 +14,9 @@ interface AdminPanelProps<T extends string, State, Input, Full extends { id: str
|
|
|
14
14
|
Unit: { [key: string]: any };
|
|
15
15
|
View: { [key: string]: any };
|
|
16
16
|
};
|
|
17
|
+
template?: any;
|
|
18
|
+
unit?: any;
|
|
19
|
+
view?: any;
|
|
17
20
|
/** Summary column to query descriptor. Only a column this map names becomes a link to its filtered listing. */
|
|
18
21
|
queryMap?: { [key: string]: unknown };
|
|
19
22
|
/** Keys of the app's `summary` state shown as dashboard tiles above the list. */
|
|
@@ -32,6 +35,9 @@ export default function AdminPanel<
|
|
|
32
35
|
slice,
|
|
33
36
|
components,
|
|
34
37
|
queryMap,
|
|
38
|
+
template,
|
|
39
|
+
unit,
|
|
40
|
+
view,
|
|
35
41
|
summaryColumns,
|
|
36
42
|
insightColumns = [],
|
|
37
43
|
renderInsight = ({ insight }) => (
|
|
@@ -52,9 +58,15 @@ export default function AdminPanel<
|
|
|
52
58
|
...props
|
|
53
59
|
}: AdminPanelProps<RefName, State, Input, Full, Light>) {
|
|
54
60
|
const { sliceName } = slice;
|
|
55
|
-
const Unit = components.Unit.General
|
|
56
|
-
|
|
57
|
-
|
|
61
|
+
const Unit = (unit ?? components.Unit.General ?? components.Unit.Card ?? Object.values(components.Unit).at(0)) as
|
|
62
|
+
| ((props: ModelProps<any, any>) => ReactNode)
|
|
63
|
+
| undefined;
|
|
64
|
+
const Template = (template ?? components.Template.General ?? Object.values(components.Template).at(0)) as
|
|
65
|
+
| ((props: { [key in RefName]: Full }) => ReactNode)
|
|
66
|
+
| undefined;
|
|
67
|
+
const View = (view ?? components.View.General ?? Object.values(components.View).at(0)) as
|
|
68
|
+
| ComponentType<{ [key in RefName]: Full }>
|
|
69
|
+
| undefined;
|
|
58
70
|
return (
|
|
59
71
|
<Data.ListContainer
|
|
60
72
|
slice={slice}
|