v3: AI SDK pivot — pi removed, Next.js 16 + AI SDK 7 + AI Elements teacher app
The teaching agent no longer runs on pi. Stack: - ToolLoopAgent (ai@7) + OpenRouter (OPENROUTER_API_KEY), model via LH_MODEL, default anthropic/claude-sonnet-4.5 with reasoning tokens - quiz/ask_user_question = client-side tools (no server execute): the call lands as a typed tool part in input-available state; the card grades via src/core/question-core.ts; addToolOutput sends the verdict; the teacher's reaction continues the same assistant message - resume-mid-quiz is free: the pending tool part persists in the transcript - persistence: one JSON per chat (data/chats/), full UIMessage transcripts, saved onFinish (also on abort) - UI: AI Elements (owned copy-paste components, pruned to the five in use) + button-based QuizCard/AnsweredCard (ask answers never graded) - pi fully removed: TUI, extensions, Fastify+WS server, bridge, custom protocol all deleted (recoverable from git history) - tests: question-core units carry over; chat-flow integration drives handleChatRequest with MockLanguageModelV4 (48 tests, zero tokens); render smoke tests guard the cards (renderToStaticMarkup) Carried over unchanged: teacher system prompt, question-core, schema/ ledger/derive/extract pipeline, data model.
This commit is contained in:
+3
-1
@@ -1,5 +1,7 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
data/
|
data/
|
||||||
|
.next/
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
web/dist/
|
*.tsbuildinfo
|
||||||
|
.env.local
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { handleChatRequest } from "@/src/server/chat-handler";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
return handleChatRequest(req);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { getChat } from "@/src/store/chats";
|
||||||
|
|
||||||
|
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const chat = getChat(id);
|
||||||
|
if (!chat) return Response.json({ error: "Not found" }, { status: 404 });
|
||||||
|
return Response.json(chat);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { createChat, listChats, titleFromPrompt } from "@/src/store/chats";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return Response.json({ chats: listChats() });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const body = (await req.json().catch(() => ({}))) as { title?: unknown };
|
||||||
|
const title = typeof body.title === "string" ? body.title : "";
|
||||||
|
const chat = createChat(titleFromPrompt(title || "Untitled session"));
|
||||||
|
return Response.json({ id: chat.id, title: chat.title });
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { Chat } from "@/components/chat";
|
||||||
|
import { getChat } from "@/src/store/chats";
|
||||||
|
import type { TeacherUIMessage } from "@/src/agent/ui-message";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function ChatPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
searchParams: Promise<{ prompt?: string }>;
|
||||||
|
}) {
|
||||||
|
const [{ id }, { prompt }] = await Promise.all([params, searchParams]);
|
||||||
|
const chat = getChat(id);
|
||||||
|
if (!chat) notFound();
|
||||||
|
return (
|
||||||
|
<Chat
|
||||||
|
chatId={chat.id}
|
||||||
|
title={chat.title}
|
||||||
|
initialMessages={chat.messages as TeacherUIMessage[]}
|
||||||
|
initialPrompt={prompt}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Design tokens — the single place to restyle the app.
|
||||||
|
* (The owner has a specific visual design planned; it lands here and in
|
||||||
|
* component edits, not in structural changes.)
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.922 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--color-background);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Learning Harness",
|
||||||
|
description: "A teacher agent with a memory — sessions, quizzes, and a knowledge graph of what you know.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en" className="dark">
|
||||||
|
<body className="min-h-dvh antialiased">{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { listChats } from "@/src/store/chats";
|
||||||
|
import { Home } from "@/components/home";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
return <Home chats={listChats()} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { UIMessage } from "ai";
|
||||||
|
import { ArrowDownIcon, DownloadIcon } from "lucide-react";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
|
||||||
|
|
||||||
|
export type ConversationProps = ComponentProps<typeof StickToBottom>;
|
||||||
|
|
||||||
|
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
||||||
|
<StickToBottom
|
||||||
|
className={cn("relative flex-1 overflow-y-hidden", className)}
|
||||||
|
initial="smooth"
|
||||||
|
resize="smooth"
|
||||||
|
role="log"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ConversationContentProps = ComponentProps<
|
||||||
|
typeof StickToBottom.Content
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const ConversationContent = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ConversationContentProps) => (
|
||||||
|
<StickToBottom.Content
|
||||||
|
className={cn("flex flex-col gap-8 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ConversationEmptyState = ({
|
||||||
|
className,
|
||||||
|
title = "No messages yet",
|
||||||
|
description = "Start a conversation to see messages here",
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ConversationEmptyStateProps) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? (
|
||||||
|
<>
|
||||||
|
{icon && <div className="text-muted-foreground">{icon}</div>}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-medium text-sm">{title}</h3>
|
||||||
|
{description && (
|
||||||
|
<p className="text-muted-foreground text-sm">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
|
||||||
|
|
||||||
|
export const ConversationScrollButton = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ConversationScrollButtonProps) => {
|
||||||
|
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
||||||
|
|
||||||
|
const handleScrollToBottom = useCallback(() => {
|
||||||
|
scrollToBottom();
|
||||||
|
}, [scrollToBottom]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
!isAtBottom && (
|
||||||
|
<Button
|
||||||
|
className={cn(
|
||||||
|
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full dark:bg-background dark:hover:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={handleScrollToBottom}
|
||||||
|
size="icon"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ArrowDownIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMessageText = (message: UIMessage): string =>
|
||||||
|
message.parts
|
||||||
|
.filter((part) => part.type === "text")
|
||||||
|
.map((part) => part.text)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
export type ConversationDownloadProps = Omit<
|
||||||
|
ComponentProps<typeof Button>,
|
||||||
|
"onClick"
|
||||||
|
> & {
|
||||||
|
messages: UIMessage[];
|
||||||
|
filename?: string;
|
||||||
|
formatMessage?: (message: UIMessage, index: number) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultFormatMessage = (message: UIMessage): string => {
|
||||||
|
const roleLabel =
|
||||||
|
message.role.charAt(0).toUpperCase() + message.role.slice(1);
|
||||||
|
return `**${roleLabel}:** ${getMessageText(message)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const messagesToMarkdown = (
|
||||||
|
messages: UIMessage[],
|
||||||
|
formatMessage: (
|
||||||
|
message: UIMessage,
|
||||||
|
index: number
|
||||||
|
) => string = defaultFormatMessage
|
||||||
|
): string => messages.map((msg, i) => formatMessage(msg, i)).join("\n\n");
|
||||||
|
|
||||||
|
export const ConversationDownload = ({
|
||||||
|
messages,
|
||||||
|
filename = "conversation.md",
|
||||||
|
formatMessage = defaultFormatMessage,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ConversationDownloadProps) => {
|
||||||
|
const handleDownload = useCallback(() => {
|
||||||
|
const markdown = messagesToMarkdown(messages, formatMessage);
|
||||||
|
const blob = new Blob([markdown], { type: "text/markdown" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.append(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, [messages, filename, formatMessage]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className={cn(
|
||||||
|
"absolute top-4 right-4 rounded-full dark:bg-background dark:hover:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={handleDownload}
|
||||||
|
size="icon"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? <DownloadIcon className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
ButtonGroup,
|
||||||
|
ButtonGroupText,
|
||||||
|
} from "@/components/ui/button-group";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { cjk } from "@streamdown/cjk";
|
||||||
|
import { code } from "@streamdown/code";
|
||||||
|
import { math } from "@streamdown/math";
|
||||||
|
import { mermaid } from "@streamdown/mermaid";
|
||||||
|
import type { UIMessage } from "ai";
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||||
|
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { Streamdown, type PluginConfig } from "streamdown";
|
||||||
|
|
||||||
|
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
|
from: UIMessage["role"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Message = ({ className, from, ...props }: MessageProps) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"group flex w-full max-w-[95%] flex-col gap-2",
|
||||||
|
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
|
export const MessageContent = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MessageContentProps) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"is-user:dark flex w-fit min-w-0 max-w-full flex-col gap-2 overflow-hidden text-sm",
|
||||||
|
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
|
||||||
|
"group-[.is-assistant]:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export type MessageActionsProps = ComponentProps<"div">;
|
||||||
|
|
||||||
|
export const MessageActions = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: MessageActionsProps) => (
|
||||||
|
<div className={cn("flex items-center gap-1", className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export type MessageActionProps = ComponentProps<typeof Button> & {
|
||||||
|
tooltip?: string;
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MessageAction = ({
|
||||||
|
tooltip,
|
||||||
|
children,
|
||||||
|
label,
|
||||||
|
variant = "ghost",
|
||||||
|
size = "icon-sm",
|
||||||
|
...props
|
||||||
|
}: MessageActionProps) => {
|
||||||
|
const button = (
|
||||||
|
<Button size={size} type="button" variant={variant} {...props}>
|
||||||
|
{children}
|
||||||
|
<span className="sr-only">{label || tooltip}</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (tooltip) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{tooltip}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return button;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface MessageBranchContextType {
|
||||||
|
currentBranch: number;
|
||||||
|
totalBranches: number;
|
||||||
|
goToPrevious: () => void;
|
||||||
|
goToNext: () => void;
|
||||||
|
branches: ReactElement[];
|
||||||
|
setBranches: (branches: ReactElement[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MessageBranchContext = createContext<MessageBranchContextType | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
const useMessageBranch = () => {
|
||||||
|
const context = useContext(MessageBranchContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
"MessageBranch components must be used within MessageBranch"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
|
defaultBranch?: number;
|
||||||
|
onBranchChange?: (branchIndex: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MessageBranch = ({
|
||||||
|
defaultBranch = 0,
|
||||||
|
onBranchChange,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MessageBranchProps) => {
|
||||||
|
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
|
||||||
|
const [branches, setBranches] = useState<ReactElement[]>([]);
|
||||||
|
|
||||||
|
const handleBranchChange = useCallback(
|
||||||
|
(newBranch: number) => {
|
||||||
|
setCurrentBranch(newBranch);
|
||||||
|
onBranchChange?.(newBranch);
|
||||||
|
},
|
||||||
|
[onBranchChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const goToPrevious = useCallback(() => {
|
||||||
|
const newBranch =
|
||||||
|
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
|
||||||
|
handleBranchChange(newBranch);
|
||||||
|
}, [currentBranch, branches.length, handleBranchChange]);
|
||||||
|
|
||||||
|
const goToNext = useCallback(() => {
|
||||||
|
const newBranch =
|
||||||
|
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
|
||||||
|
handleBranchChange(newBranch);
|
||||||
|
}, [currentBranch, branches.length, handleBranchChange]);
|
||||||
|
|
||||||
|
const contextValue = useMemo<MessageBranchContextType>(
|
||||||
|
() => ({
|
||||||
|
branches,
|
||||||
|
currentBranch,
|
||||||
|
goToNext,
|
||||||
|
goToPrevious,
|
||||||
|
setBranches,
|
||||||
|
totalBranches: branches.length,
|
||||||
|
}),
|
||||||
|
[branches, currentBranch, goToNext, goToPrevious]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MessageBranchContext.Provider value={contextValue}>
|
||||||
|
<div
|
||||||
|
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</MessageBranchContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
|
export const MessageBranchContent = ({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: MessageBranchContentProps) => {
|
||||||
|
const { currentBranch, setBranches, branches } = useMessageBranch();
|
||||||
|
const childrenArray = useMemo(
|
||||||
|
() => (Array.isArray(children) ? children : [children]),
|
||||||
|
[children]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use useEffect to update branches when they change
|
||||||
|
useEffect(() => {
|
||||||
|
if (branches.length !== childrenArray.length) {
|
||||||
|
setBranches(childrenArray);
|
||||||
|
}
|
||||||
|
}, [childrenArray, branches, setBranches]);
|
||||||
|
|
||||||
|
return childrenArray.map((branch, index) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid gap-2 overflow-hidden [&>div]:pb-0",
|
||||||
|
index === currentBranch ? "block" : "hidden"
|
||||||
|
)}
|
||||||
|
key={branch.key}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{branch}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessageBranchSelectorProps = ComponentProps<typeof ButtonGroup>;
|
||||||
|
|
||||||
|
export const MessageBranchSelector = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MessageBranchSelectorProps) => {
|
||||||
|
const { totalBranches } = useMessageBranch();
|
||||||
|
|
||||||
|
// Don't render if there's only one branch
|
||||||
|
if (totalBranches <= 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ButtonGroup
|
||||||
|
className={cn(
|
||||||
|
"[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
orientation="horizontal"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
|
||||||
|
|
||||||
|
export const MessageBranchPrevious = ({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: MessageBranchPreviousProps) => {
|
||||||
|
const { goToPrevious, totalBranches } = useMessageBranch();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
aria-label="Previous branch"
|
||||||
|
disabled={totalBranches <= 1}
|
||||||
|
onClick={goToPrevious}
|
||||||
|
size="icon-sm"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? <ChevronLeftIcon size={14} />}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessageBranchNextProps = ComponentProps<typeof Button>;
|
||||||
|
|
||||||
|
export const MessageBranchNext = ({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: MessageBranchNextProps) => {
|
||||||
|
const { goToNext, totalBranches } = useMessageBranch();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
aria-label="Next branch"
|
||||||
|
disabled={totalBranches <= 1}
|
||||||
|
onClick={goToNext}
|
||||||
|
size="icon-sm"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? <ChevronRightIcon size={14} />}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
|
||||||
|
|
||||||
|
export const MessageBranchPage = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MessageBranchPageProps) => {
|
||||||
|
const { currentBranch, totalBranches } = useMessageBranch();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ButtonGroupText
|
||||||
|
className={cn(
|
||||||
|
"border-none bg-transparent text-muted-foreground shadow-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{currentBranch + 1} of {totalBranches}
|
||||||
|
</ButtonGroupText>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
|
||||||
|
|
||||||
|
// Type-only version skew: @streamdown/code bundles shiki@3 types while
|
||||||
|
// streamdown@2.5 expects shiki@4 types. Both work at runtime (each ships its
|
||||||
|
// own highlighter); the cast papers over the .d.ts mismatch.
|
||||||
|
const streamdownPlugins = { cjk, code, math, mermaid } as unknown as PluginConfig;
|
||||||
|
|
||||||
|
export const MessageResponse = memo(
|
||||||
|
({ className, ...props }: MessageResponseProps) => (
|
||||||
|
<Streamdown
|
||||||
|
className={cn(
|
||||||
|
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
plugins={streamdownPlugins}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
(prevProps, nextProps) =>
|
||||||
|
prevProps.children === nextProps.children &&
|
||||||
|
nextProps.isAnimating === prevProps.isAnimating
|
||||||
|
);
|
||||||
|
|
||||||
|
MessageResponse.displayName = "MessageResponse";
|
||||||
|
|
||||||
|
export type MessageToolbarProps = ComponentProps<"div">;
|
||||||
|
|
||||||
|
export const MessageToolbar = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: MessageToolbarProps) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mt-4 flex w-full items-center justify-between gap-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,227 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { cjk } from "@streamdown/cjk";
|
||||||
|
import { code } from "@streamdown/code";
|
||||||
|
import { math } from "@streamdown/math";
|
||||||
|
import { mermaid } from "@streamdown/mermaid";
|
||||||
|
import { BrainIcon, ChevronDownIcon } from "lucide-react";
|
||||||
|
import type { ComponentProps, ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { Streamdown, type PluginConfig } from "streamdown";
|
||||||
|
|
||||||
|
import { Shimmer } from "./shimmer";
|
||||||
|
|
||||||
|
interface ReasoningContextValue {
|
||||||
|
isStreaming: boolean;
|
||||||
|
isOpen: boolean;
|
||||||
|
setIsOpen: (open: boolean) => void;
|
||||||
|
duration: number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
|
||||||
|
|
||||||
|
export const useReasoning = () => {
|
||||||
|
const context = useContext(ReasoningContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("Reasoning components must be used within Reasoning");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
|
||||||
|
isStreaming?: boolean;
|
||||||
|
open?: boolean;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
duration?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AUTO_CLOSE_DELAY = 1000;
|
||||||
|
const MS_IN_S = 1000;
|
||||||
|
|
||||||
|
export const Reasoning = memo(
|
||||||
|
({
|
||||||
|
className,
|
||||||
|
isStreaming = false,
|
||||||
|
open,
|
||||||
|
defaultOpen,
|
||||||
|
onOpenChange,
|
||||||
|
duration: durationProp,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ReasoningProps) => {
|
||||||
|
const resolvedDefaultOpen = defaultOpen ?? isStreaming;
|
||||||
|
// Track if defaultOpen was explicitly set to false (to prevent auto-open)
|
||||||
|
const isExplicitlyClosed = defaultOpen === false;
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useControllableState<boolean>({
|
||||||
|
defaultProp: resolvedDefaultOpen,
|
||||||
|
onChange: onOpenChange,
|
||||||
|
prop: open,
|
||||||
|
});
|
||||||
|
const [duration, setDuration] = useControllableState<number | undefined>({
|
||||||
|
defaultProp: undefined,
|
||||||
|
prop: durationProp,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasEverStreamedRef = useRef(isStreaming);
|
||||||
|
const [hasAutoClosed, setHasAutoClosed] = useState(false);
|
||||||
|
const startTimeRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// Track when streaming starts and compute duration
|
||||||
|
useEffect(() => {
|
||||||
|
if (isStreaming) {
|
||||||
|
hasEverStreamedRef.current = true;
|
||||||
|
if (startTimeRef.current === null) {
|
||||||
|
startTimeRef.current = Date.now();
|
||||||
|
}
|
||||||
|
} else if (startTimeRef.current !== null) {
|
||||||
|
setDuration(Math.ceil((Date.now() - startTimeRef.current) / MS_IN_S));
|
||||||
|
startTimeRef.current = null;
|
||||||
|
}
|
||||||
|
}, [isStreaming, setDuration]);
|
||||||
|
|
||||||
|
// Auto-open when streaming starts (unless explicitly closed)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isStreaming && !isOpen && !isExplicitlyClosed) {
|
||||||
|
setIsOpen(true);
|
||||||
|
}
|
||||||
|
}, [isStreaming, isOpen, setIsOpen, isExplicitlyClosed]);
|
||||||
|
|
||||||
|
// Auto-close when streaming ends (once only, and only if it ever streamed)
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
hasEverStreamedRef.current &&
|
||||||
|
!isStreaming &&
|
||||||
|
isOpen &&
|
||||||
|
!hasAutoClosed
|
||||||
|
) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setHasAutoClosed(true);
|
||||||
|
}, AUTO_CLOSE_DELAY);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [isStreaming, isOpen, setIsOpen, hasAutoClosed]);
|
||||||
|
|
||||||
|
const handleOpenChange = useCallback(
|
||||||
|
(newOpen: boolean) => {
|
||||||
|
setIsOpen(newOpen);
|
||||||
|
},
|
||||||
|
[setIsOpen]
|
||||||
|
);
|
||||||
|
|
||||||
|
const contextValue = useMemo(
|
||||||
|
() => ({ duration, isOpen, isStreaming, setIsOpen }),
|
||||||
|
[duration, isOpen, isStreaming, setIsOpen]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReasoningContext.Provider value={contextValue}>
|
||||||
|
<Collapsible
|
||||||
|
className={cn("not-prose mb-4", className)}
|
||||||
|
onOpenChange={handleOpenChange}
|
||||||
|
open={isOpen}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Collapsible>
|
||||||
|
</ReasoningContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ReasoningTriggerProps = ComponentProps<
|
||||||
|
typeof CollapsibleTrigger
|
||||||
|
> & {
|
||||||
|
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
|
||||||
|
if (isStreaming || duration === 0) {
|
||||||
|
return <Shimmer duration={1}>Thinking...</Shimmer>;
|
||||||
|
}
|
||||||
|
if (duration === undefined) {
|
||||||
|
return <p>Thought for a few seconds</p>;
|
||||||
|
}
|
||||||
|
return <p>Thought for {duration} seconds</p>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ReasoningTrigger = memo(
|
||||||
|
({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
getThinkingMessage = defaultGetThinkingMessage,
|
||||||
|
...props
|
||||||
|
}: ReasoningTriggerProps) => {
|
||||||
|
const { isStreaming, isOpen, duration } = useReasoning();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollapsibleTrigger
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? (
|
||||||
|
<>
|
||||||
|
<BrainIcon className="size-4" />
|
||||||
|
{getThinkingMessage(isStreaming, duration)}
|
||||||
|
<ChevronDownIcon
|
||||||
|
className={cn(
|
||||||
|
"size-4 transition-transform",
|
||||||
|
isOpen ? "rotate-180" : "rotate-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ReasoningContentProps = ComponentProps<
|
||||||
|
typeof CollapsibleContent
|
||||||
|
> & {
|
||||||
|
children: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// See message.tsx — same streamdown plugin type skew.
|
||||||
|
const streamdownPlugins = { cjk, code, math, mermaid } as unknown as PluginConfig;
|
||||||
|
|
||||||
|
export const ReasoningContent = memo(
|
||||||
|
({ className, children, ...props }: ReasoningContentProps) => (
|
||||||
|
<CollapsibleContent
|
||||||
|
className={cn(
|
||||||
|
"mt-4 text-sm",
|
||||||
|
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Streamdown plugins={streamdownPlugins}>{children}</Streamdown>
|
||||||
|
</CollapsibleContent>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
Reasoning.displayName = "Reasoning";
|
||||||
|
ReasoningTrigger.displayName = "ReasoningTrigger";
|
||||||
|
ReasoningContent.displayName = "ReasoningContent";
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { MotionProps } from "motion/react";
|
||||||
|
import { motion } from "motion/react";
|
||||||
|
import type { CSSProperties, ElementType, JSX } from "react";
|
||||||
|
import { memo, useMemo } from "react";
|
||||||
|
|
||||||
|
type MotionHTMLProps = MotionProps & Record<string, unknown>;
|
||||||
|
|
||||||
|
// Cache motion components at module level to avoid creating during render
|
||||||
|
const motionComponentCache = new Map<
|
||||||
|
keyof JSX.IntrinsicElements,
|
||||||
|
React.ComponentType<MotionHTMLProps>
|
||||||
|
>();
|
||||||
|
|
||||||
|
const getMotionComponent = (element: keyof JSX.IntrinsicElements) => {
|
||||||
|
let component = motionComponentCache.get(element);
|
||||||
|
if (!component) {
|
||||||
|
component = motion.create(element);
|
||||||
|
motionComponentCache.set(element, component);
|
||||||
|
}
|
||||||
|
return component;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface TextShimmerProps {
|
||||||
|
children: string;
|
||||||
|
as?: ElementType;
|
||||||
|
className?: string;
|
||||||
|
duration?: number;
|
||||||
|
spread?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ShimmerComponent = ({
|
||||||
|
children,
|
||||||
|
as: Component = "p",
|
||||||
|
className,
|
||||||
|
duration = 2,
|
||||||
|
spread = 2,
|
||||||
|
}: TextShimmerProps) => {
|
||||||
|
const MotionComponent = getMotionComponent(
|
||||||
|
Component as keyof JSX.IntrinsicElements
|
||||||
|
);
|
||||||
|
|
||||||
|
const dynamicSpread = useMemo(
|
||||||
|
() => (children?.length ?? 0) * spread,
|
||||||
|
[children, spread]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MotionComponent
|
||||||
|
animate={{ backgroundPosition: "0% center" }}
|
||||||
|
className={cn(
|
||||||
|
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
|
||||||
|
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
initial={{ backgroundPosition: "100% center" }}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--spread": `${dynamicSpread}px`,
|
||||||
|
backgroundImage:
|
||||||
|
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
|
transition={{
|
||||||
|
duration,
|
||||||
|
ease: "linear",
|
||||||
|
repeat: Number.POSITIVE_INFINITY,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</MotionComponent>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Shimmer = memo(ShimmerComponent);
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { QuestionAnswerData } from "@/src/core/question-core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static rendering of an answered question (tool part in `output-available`).
|
||||||
|
*
|
||||||
|
* Quiz answers are GRADED: ✓/✗ marks, the correct answers, and the
|
||||||
|
* explanation. Ask answers are NOT graded — a plain record of what was said.
|
||||||
|
* (The v1 bug this pins: ask answers must never get quiz grading UI.)
|
||||||
|
*/
|
||||||
|
export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
|
||||||
|
if (answer.dismissed) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
|
||||||
|
<span className="font-medium">{answer.questionText}</span> — skipped
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isQuiz = answer.kind === "quiz";
|
||||||
|
const selectedIdx = new Set(answer.answers.map((a) => a.index));
|
||||||
|
const correctIdx = new Set(answer.correctIndices ?? []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="answered-card">
|
||||||
|
<div className="mb-1 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
{isQuiz ? "Quiz" : "Question"}
|
||||||
|
{isQuiz &&
|
||||||
|
(answer.dontKnow ? (
|
||||||
|
<span className="rounded bg-muted px-1.5 py-0.5">didn't know</span>
|
||||||
|
) : answer.correct ? (
|
||||||
|
<span className="rounded bg-green-500/15 px-1.5 py-0.5 text-green-500">correct</span>
|
||||||
|
) : (
|
||||||
|
<span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">incorrect</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mb-3 font-medium">{answer.questionText}</div>
|
||||||
|
|
||||||
|
{answer.options && answer.options.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-1.5 text-sm">
|
||||||
|
{answer.options.map((opt) => {
|
||||||
|
const sel = selectedIdx.has(opt.index);
|
||||||
|
const key = isQuiz && correctIdx.has(opt.index);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={opt.index}
|
||||||
|
className={cn(
|
||||||
|
"flex items-start gap-2 rounded-lg border px-3 py-1.5",
|
||||||
|
sel && key && "border-green-500/60 bg-green-500/10",
|
||||||
|
sel && !key && isQuiz && "border-red-500/60 bg-red-500/10",
|
||||||
|
sel && !isQuiz && "border-primary bg-accent",
|
||||||
|
!sel && key && "border-green-500/40",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isQuiz && (
|
||||||
|
<span className="w-4 shrink-0">
|
||||||
|
{sel && key && "✓"}
|
||||||
|
{sel && !key && "✗"}
|
||||||
|
{!sel && key && "✓"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={cn(!sel && "text-muted-foreground")}>{opt.label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{answer.other && <div className="mt-2 text-sm">“{answer.other}”</div>}
|
||||||
|
{answer.text && <div className="mt-2 whitespace-pre-wrap text-sm">“{answer.text}”</div>}
|
||||||
|
{answer.dontKnow && isQuiz && <div className="mt-2 text-sm text-muted-foreground">Answered “I don't know”.</div>}
|
||||||
|
|
||||||
|
{isQuiz && answer.explanation && (
|
||||||
|
<div className="mt-3 rounded-lg bg-muted px-3 py-2 text-sm text-muted-foreground">
|
||||||
|
<span className="font-medium text-foreground">Why: </span>
|
||||||
|
{answer.explanation}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{answer.note && <div className="mt-2 text-xs text-muted-foreground">Note: {answer.note}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useChat } from "@ai-sdk/react";
|
||||||
|
import { DefaultChatTransport, isToolUIPart, lastAssistantMessageIsCompleteWithToolCalls } from "ai";
|
||||||
|
import {
|
||||||
|
Conversation,
|
||||||
|
ConversationContent,
|
||||||
|
ConversationEmptyState,
|
||||||
|
ConversationScrollButton,
|
||||||
|
} from "@/components/ai-elements/conversation";
|
||||||
|
import { Message, MessageContent, MessageResponse } from "@/components/ai-elements/message";
|
||||||
|
import { Reasoning, ReasoningContent, ReasoningTrigger } from "@/components/ai-elements/reasoning";
|
||||||
|
import {
|
||||||
|
PromptInput,
|
||||||
|
PromptInputBody,
|
||||||
|
PromptInputFooter,
|
||||||
|
PromptInputSubmit,
|
||||||
|
PromptInputTextarea,
|
||||||
|
PromptInputTools,
|
||||||
|
type PromptInputMessage,
|
||||||
|
} from "@/components/ai-elements/prompt-input";
|
||||||
|
import { Shimmer } from "@/components/ai-elements/shimmer";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { QuizCard } from "@/components/quiz-card";
|
||||||
|
import { AnsweredCard } from "@/components/answered-card";
|
||||||
|
import type { TeacherUIMessage } from "@/src/agent/ui-message";
|
||||||
|
import type { QuestionAnswerData } from "@/src/core/question-core";
|
||||||
|
import { findPendingToolCall } from "@/src/core/pending";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The teaching session. All chat state lives in useChat; the server persists
|
||||||
|
* the canonical transcript after every turn. A pending quiz/ask is just an
|
||||||
|
* unanswered tool part — so reload/resume mid-question works for free.
|
||||||
|
*/
|
||||||
|
export function Chat({
|
||||||
|
chatId,
|
||||||
|
title,
|
||||||
|
initialMessages,
|
||||||
|
initialPrompt,
|
||||||
|
}: {
|
||||||
|
chatId: string;
|
||||||
|
title: string;
|
||||||
|
initialMessages: TeacherUIMessage[];
|
||||||
|
initialPrompt?: string;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { messages, sendMessage, addToolOutput, status, error, stop } =
|
||||||
|
useChat<TeacherUIMessage>({
|
||||||
|
id: chatId,
|
||||||
|
messages: initialMessages,
|
||||||
|
transport: new DefaultChatTransport({ api: "/api/chat", body: { chatId } }),
|
||||||
|
// After the learner answers (addToolOutput), automatically send the
|
||||||
|
// continuation request so the teacher reacts to the answer.
|
||||||
|
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
|
||||||
|
});
|
||||||
|
|
||||||
|
// New chat with a topic: send the first message exactly once.
|
||||||
|
const sentInitial = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sentInitial.current && initialPrompt && initialMessages.length === 0) {
|
||||||
|
sentInitial.current = true;
|
||||||
|
void sendMessage({ text: initialPrompt });
|
||||||
|
}
|
||||||
|
}, [initialPrompt, initialMessages.length, sendMessage]);
|
||||||
|
|
||||||
|
const pending = findPendingToolCall(messages);
|
||||||
|
const busy = status === "submitted" || status === "streaming";
|
||||||
|
|
||||||
|
const submitAnswer = (toolCallId: string, tool: "quiz" | "ask_user_question", output: QuestionAnswerData) => {
|
||||||
|
void addToolOutput({ tool, toolCallId, output });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onComposerSubmit = (msg: PromptInputMessage) => {
|
||||||
|
if (!msg.text.trim() || busy || pending) return;
|
||||||
|
void sendMessage({ text: msg.text });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-dvh flex-col">
|
||||||
|
<header className="flex items-center gap-3 border-b px-4 py-3">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => router.push("/")}>
|
||||||
|
← Sessions
|
||||||
|
</Button>
|
||||||
|
<div className="min-w-0 flex-1 truncate font-medium">{title}</div>
|
||||||
|
{busy && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => stop()}>
|
||||||
|
Stop
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Conversation className="flex-1">
|
||||||
|
<ConversationContent className="mx-auto w-full max-w-3xl gap-4 px-4 py-6">
|
||||||
|
{messages.length === 0 && !busy && (
|
||||||
|
<ConversationEmptyState
|
||||||
|
title="New session"
|
||||||
|
description="The teacher will open by probing what you already know."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{messages.map((m, mi) => (
|
||||||
|
<Message key={m.id} from={m.role}>
|
||||||
|
<MessageContent>
|
||||||
|
{m.role === "user" ? (
|
||||||
|
<div className="whitespace-pre-wrap">{m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")}</div>
|
||||||
|
) : (
|
||||||
|
m.parts.map((part, pi) => {
|
||||||
|
const isLastPart = mi === messages.length - 1 && pi === m.parts.length - 1;
|
||||||
|
switch (part.type) {
|
||||||
|
case "text":
|
||||||
|
return <MessageResponse key={pi}>{part.text}</MessageResponse>;
|
||||||
|
case "reasoning":
|
||||||
|
return (
|
||||||
|
<Reasoning key={pi} isStreaming={status === "streaming" && isLastPart} className="mb-2">
|
||||||
|
<ReasoningTrigger />
|
||||||
|
<ReasoningContent>{part.text}</ReasoningContent>
|
||||||
|
</Reasoning>
|
||||||
|
);
|
||||||
|
case "step-start":
|
||||||
|
return pi > 0 ? <Separator key={pi} className="my-3" /> : null;
|
||||||
|
case "tool-quiz":
|
||||||
|
case "tool-ask_user_question": {
|
||||||
|
const tool = part.type === "tool-quiz" ? ("quiz" as const) : ("ask_user_question" as const);
|
||||||
|
if (part.state === "output-available") {
|
||||||
|
return <AnsweredCard key={pi} answer={part.output as QuestionAnswerData} />;
|
||||||
|
}
|
||||||
|
if (part.state === "input-available") {
|
||||||
|
return (
|
||||||
|
<QuizCard
|
||||||
|
key={pi}
|
||||||
|
toolName={tool}
|
||||||
|
toolCallId={part.toolCallId}
|
||||||
|
input={part.input}
|
||||||
|
onSubmit={(output) => submitAnswer(part.toolCallId, tool, output)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// input-streaming: the question is still being generated
|
||||||
|
return (
|
||||||
|
<div key={pi} className="rounded-xl border bg-card p-4">
|
||||||
|
<Shimmer className="text-sm">Preparing a question…</Shimmer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</MessageContent>
|
||||||
|
</Message>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{status === "submitted" && <Shimmer className="text-sm">Thinking…</Shimmer>}
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm">
|
||||||
|
Something went wrong: {error.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ConversationContent>
|
||||||
|
<ConversationScrollButton />
|
||||||
|
</Conversation>
|
||||||
|
|
||||||
|
<div className="border-t px-4 py-3">
|
||||||
|
<div className="mx-auto w-full max-w-3xl">
|
||||||
|
{pending && (
|
||||||
|
<div className="mb-2 text-center text-xs text-muted-foreground">
|
||||||
|
Answer the question above to continue.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<PromptInput onSubmit={onComposerSubmit}>
|
||||||
|
<PromptInputBody>
|
||||||
|
<PromptInputTextarea
|
||||||
|
placeholder={pending ? "Answer the question above first…" : "Ask, answer, or steer the lesson…"}
|
||||||
|
disabled={!!pending}
|
||||||
|
/>
|
||||||
|
</PromptInputBody>
|
||||||
|
<PromptInputFooter>
|
||||||
|
<PromptInputTools />
|
||||||
|
<PromptInputSubmit status={status} disabled={busy || !!pending} />
|
||||||
|
</PromptInputFooter>
|
||||||
|
</PromptInput>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import type { ChatSummary } from "@/src/store/chats";
|
||||||
|
|
||||||
|
export function Home({ chats }: { chats: ChatSummary[] }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [topic, setTopic] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const start = async () => {
|
||||||
|
const t = topic.trim();
|
||||||
|
if (!t || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/chats", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ title: t }),
|
||||||
|
});
|
||||||
|
const { id } = (await res.json()) as { id: string };
|
||||||
|
router.push(`/chat/${id}?prompt=${encodeURIComponent(t)}`);
|
||||||
|
} catch {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto flex min-h-dvh w-full max-w-2xl flex-col px-4 py-10">
|
||||||
|
<h1 className="mb-1 text-2xl font-semibold">Learning Harness</h1>
|
||||||
|
<p className="mb-8 text-sm text-muted-foreground">
|
||||||
|
A teacher with a memory. Start a session on anything you want to understand.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="mb-10 flex gap-2"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void start();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={topic}
|
||||||
|
onChange={(e) => setTopic(e.target.value)}
|
||||||
|
placeholder="What do you want to learn?"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={!topic.trim() || busy}>
|
||||||
|
{busy ? "Starting…" : "Start"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h2 className="mb-3 text-sm font-medium uppercase tracking-wide text-muted-foreground">Sessions</h2>
|
||||||
|
{chats.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No sessions yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{chats.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push(`/chat/${c.id}`)}
|
||||||
|
className="rounded-lg border px-4 py-3 text-left transition-colors hover:bg-accent"
|
||||||
|
>
|
||||||
|
<div className="truncate font-medium">{c.title}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{c.messageCount} messages · {new Date(c.updatedAt).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { AskToolInput, QuizToolInput } from "@/src/agent/tools";
|
||||||
|
import {
|
||||||
|
buildAnswer,
|
||||||
|
coerceCorrectAnswer,
|
||||||
|
normalizeOptions,
|
||||||
|
resolveCorrect,
|
||||||
|
shuffleOptions,
|
||||||
|
type AnswerRequest,
|
||||||
|
type QuestionAnswerData,
|
||||||
|
type QuestionData,
|
||||||
|
type QuestionMode,
|
||||||
|
} from "@/src/core/question-core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pending interaction card — renders when a quiz/ask tool part is in
|
||||||
|
* `input-available` state. Owns selection state (single source of truth —
|
||||||
|
* buttons, not native inputs), grades via question-core, and submits the
|
||||||
|
* structured answer record as the tool output.
|
||||||
|
*
|
||||||
|
* Quiz: graded (single/multi-select + "I don't know").
|
||||||
|
* Ask: not graded (single/multi-select + "Other" free text, or text mode).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface QuizCardProps {
|
||||||
|
toolName: "quiz" | "ask_user_question";
|
||||||
|
toolCallId: string;
|
||||||
|
input: QuizToolInput | AskToolInput;
|
||||||
|
onSubmit: (output: QuestionAnswerData) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuizCard({ toolName, toolCallId, input, onSubmit }: QuizCardProps) {
|
||||||
|
const kind = toolName === "quiz" ? ("quiz" as const) : ("ask" as const);
|
||||||
|
|
||||||
|
// Build the canonical QuestionData once per question. Quiz options are
|
||||||
|
// shuffled so position never leaks correctness; the display order is
|
||||||
|
// snapshotted into the answer record at submit time.
|
||||||
|
const qd = useMemo<QuestionData>(() => {
|
||||||
|
const quizInput = kind === "quiz" ? (input as QuizToolInput) : null;
|
||||||
|
const askInput = kind === "ask" ? (input as AskToolInput) : null;
|
||||||
|
const multi = input.multiSelect === true;
|
||||||
|
const mode: QuestionMode =
|
||||||
|
kind === "quiz"
|
||||||
|
? multi
|
||||||
|
? "multi-select"
|
||||||
|
: "single-select"
|
||||||
|
: (askInput?.mode ?? (askInput?.options?.length ? (multi ? "multi-select" : "single-select") : "text"));
|
||||||
|
const options = normalizeOptions((quizInput?.options ?? askInput?.options ?? []) as { label: string; value?: string }[]);
|
||||||
|
const ordered = kind === "quiz" && options.length > 0 ? shuffleOptions(options) : options;
|
||||||
|
const q: QuestionData = {
|
||||||
|
id: toolCallId,
|
||||||
|
kind,
|
||||||
|
question: input.question,
|
||||||
|
context: input.details,
|
||||||
|
mode,
|
||||||
|
options: ordered,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
if (quizInput) {
|
||||||
|
const resolved = resolveCorrect(coerceCorrectAnswer(quizInput.correctAnswer), ordered);
|
||||||
|
if (resolved.error) console.error("[quiz] correctAnswer did not resolve:", resolved.error);
|
||||||
|
q.correctIndices = resolved.indices;
|
||||||
|
q.explanation = quizInput.explanation;
|
||||||
|
}
|
||||||
|
return q;
|
||||||
|
}, [toolCallId, kind, input]);
|
||||||
|
|
||||||
|
const [selected, setSelected] = useState<Set<number>>(new Set()); // 0-based indices
|
||||||
|
const [dontKnow, setDontKnow] = useState(false);
|
||||||
|
const [showOther, setShowOther] = useState(false);
|
||||||
|
const [other, setOther] = useState("");
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
|
||||||
|
const toggle = (idx: number) => {
|
||||||
|
setDontKnow(false);
|
||||||
|
setShowOther(false);
|
||||||
|
setSelected((prev) => {
|
||||||
|
if (qd.mode === "multi-select") {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(idx)) next.delete(idx);
|
||||||
|
else next.add(idx);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
return new Set([idx]);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickDontKnow = () => {
|
||||||
|
setSelected(new Set());
|
||||||
|
setShowOther(false);
|
||||||
|
setDontKnow((v) => !v);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickOther = () => {
|
||||||
|
setSelected(new Set());
|
||||||
|
setDontKnow(false);
|
||||||
|
setShowOther(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const canSubmit =
|
||||||
|
qd.mode === "text"
|
||||||
|
? text.trim().length > 0
|
||||||
|
: dontKnow || (showOther && other.trim().length > 0) || selected.size > 0;
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
if (!canSubmit) return;
|
||||||
|
const req: AnswerRequest = {
|
||||||
|
dontKnow,
|
||||||
|
values: [...selected].map((i) => qd.options[i]!.value),
|
||||||
|
other: showOther ? other : undefined,
|
||||||
|
text: qd.mode === "text" ? text : undefined,
|
||||||
|
note: note.trim() || undefined,
|
||||||
|
};
|
||||||
|
onSubmit(buildAnswer(qd, req));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="quiz-card">
|
||||||
|
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
{kind === "quiz" ? "Quiz" : "Question"}
|
||||||
|
{qd.mode === "multi-select" && " · select all that apply"}
|
||||||
|
</div>
|
||||||
|
<div className="mb-3 font-medium">{qd.question}</div>
|
||||||
|
{qd.context && <div className="mb-3 text-sm text-muted-foreground">{qd.context}</div>}
|
||||||
|
|
||||||
|
{qd.mode !== "text" && (
|
||||||
|
<div className="flex flex-col gap-2" role={qd.mode === "multi-select" ? "group" : "radiogroup"}>
|
||||||
|
{qd.options.map((opt, i) => {
|
||||||
|
const isSel = selected.has(i);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
role={qd.mode === "multi-select" ? "checkbox" : "radio"}
|
||||||
|
aria-checked={isSel}
|
||||||
|
onClick={() => toggle(i)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-start gap-3 rounded-lg border px-3 py-2 text-left text-sm transition-colors",
|
||||||
|
isSel ? "border-primary bg-accent" : "hover:bg-accent/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"mt-0.5 flex size-4 shrink-0 items-center justify-center border",
|
||||||
|
qd.mode === "multi-select" ? "rounded-sm" : "rounded-full",
|
||||||
|
isSel ? "border-primary bg-primary text-primary-foreground" : "border-muted-foreground/40",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isSel && <span className={cn(qd.mode === "multi-select" ? "text-[10px]" : "size-1.5 rounded-full bg-current")}>{qd.mode === "multi-select" ? "✓" : ""}</span>}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{opt.label}
|
||||||
|
{opt.description && <span className="block text-xs text-muted-foreground">{opt.description}</span>}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{kind === "quiz" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={pickDontKnow}
|
||||||
|
aria-pressed={dontKnow}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border border-dashed px-3 py-2 text-left text-sm text-muted-foreground transition-colors",
|
||||||
|
dontKnow && "border-primary text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
I don't know
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{kind === "ask" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={pickOther}
|
||||||
|
aria-pressed={showOther}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border border-dashed px-3 py-2 text-left text-sm text-muted-foreground transition-colors",
|
||||||
|
showOther && "border-primary text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Other…
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(qd.mode === "text" || showOther) && (
|
||||||
|
<textarea
|
||||||
|
className="mt-2 w-full rounded-lg border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
rows={qd.mode === "text" ? 4 : 2}
|
||||||
|
placeholder={qd.mode === "text" ? "Type your answer…" : "Type your own answer…"}
|
||||||
|
value={qd.mode === "text" ? text : other}
|
||||||
|
onChange={(e) => (qd.mode === "text" ? setText(e.target.value) : setOther(e.target.value))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="mt-2 w-full rounded-lg border bg-background px-3 py-1.5 text-sm outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
placeholder="Add a note (optional) — anything you're unsure about"
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => setNote(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-3 flex justify-end">
|
||||||
|
<Button size="sm" onClick={submit} disabled={!canSubmit}>
|
||||||
|
Submit answer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
|
||||||
|
const buttonGroupVariants = cva(
|
||||||
|
"flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
orientation: {
|
||||||
|
horizontal:
|
||||||
|
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
|
||||||
|
vertical:
|
||||||
|
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
orientation: "horizontal",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function ButtonGroup({
|
||||||
|
className,
|
||||||
|
orientation,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="button-group"
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ButtonGroupText({
|
||||||
|
className,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot.Root : "div"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 rounded-md border bg-muted px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ButtonGroupSeparator({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Separator>) {
|
||||||
|
return (
|
||||||
|
<Separator
|
||||||
|
data-slot="button-group-separator"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"relative m-0! self-stretch bg-input data-[orientation=vertical]:h-auto",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ButtonGroup,
|
||||||
|
ButtonGroupSeparator,
|
||||||
|
ButtonGroupText,
|
||||||
|
buttonGroupVariants,
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
||||||
|
outline:
|
||||||
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm": "size-8",
|
||||||
|
"icon-lg": "size-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot.Root : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
function Collapsible({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||||
|
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.CollapsibleTrigger
|
||||||
|
data-slot="collapsible-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleContent({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.CollapsibleContent
|
||||||
|
data-slot="collapsible-content"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Command as CommandPrimitive } from "cmdk"
|
||||||
|
import { SearchIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
|
||||||
|
function Command({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive
|
||||||
|
data-slot="command"
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandDialog({
|
||||||
|
title = "Command Palette",
|
||||||
|
description = "Search for a command to run...",
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Dialog> & {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
className?: string
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogHeader className="sr-only">
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogContent
|
||||||
|
className={cn("overflow-hidden p-0", className)}
|
||||||
|
showCloseButton={showCloseButton}
|
||||||
|
>
|
||||||
|
<Command className="**:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||||
|
{children}
|
||||||
|
</Command>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="command-input-wrapper"
|
||||||
|
className="flex h-9 items-center gap-2 border-b px-3"
|
||||||
|
>
|
||||||
|
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
data-slot="command-input"
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
data-slot="command-list"
|
||||||
|
className={cn(
|
||||||
|
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandEmpty({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Empty
|
||||||
|
data-slot="command-empty"
|
||||||
|
className="py-6 text-center text-sm"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
data-slot="command-group"
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Separator
|
||||||
|
data-slot="command-separator"
|
||||||
|
className={cn("-mx-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
data-slot="command-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="command-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
|
CommandSeparator,
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal data-slot="dialog-portal">
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close asChild>
|
||||||
|
<Button variant="outline">Close</Button>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function DropdownMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Trigger
|
||||||
|
data-slot="dropdown-menu-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: "default" | "destructive"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||||
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto size-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { HoverCard as HoverCardPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function HoverCard({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||||
|
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function HoverCardTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HoverCardContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||||
|
<HoverCardPrimitive.Content
|
||||||
|
data-slot="hover-card-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</HoverCardPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
|
||||||
|
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-group"
|
||||||
|
role="group"
|
||||||
|
className={cn(
|
||||||
|
"group/input-group relative flex w-full items-center rounded-md border border-input shadow-xs transition-[color,box-shadow] outline-none dark:bg-input/30",
|
||||||
|
"h-9 min-w-0 has-[>textarea]:h-auto",
|
||||||
|
|
||||||
|
// Variants based on alignment.
|
||||||
|
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
|
||||||
|
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
|
||||||
|
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
|
||||||
|
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
|
||||||
|
|
||||||
|
// Focus state.
|
||||||
|
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50",
|
||||||
|
|
||||||
|
// Error state.
|
||||||
|
"has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
|
||||||
|
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputGroupAddonVariants = cva(
|
||||||
|
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
align: {
|
||||||
|
"inline-start":
|
||||||
|
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
|
||||||
|
"inline-end":
|
||||||
|
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
|
||||||
|
"block-start":
|
||||||
|
"order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-3",
|
||||||
|
"block-end":
|
||||||
|
"order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5 [.border-t]:pt-3",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
align: "inline-start",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function InputGroupAddon({
|
||||||
|
className,
|
||||||
|
align = "inline-start",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="input-group-addon"
|
||||||
|
data-align={align}
|
||||||
|
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||||
|
onClick={(e) => {
|
||||||
|
if ((e.target as HTMLElement).closest("button")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputGroupButtonVariants = cva(
|
||||||
|
"flex items-center gap-2 text-sm shadow-none",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
|
||||||
|
sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5",
|
||||||
|
"icon-xs":
|
||||||
|
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
|
||||||
|
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: "xs",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function InputGroupButton({
|
||||||
|
className,
|
||||||
|
type = "button",
|
||||||
|
variant = "ghost",
|
||||||
|
size = "xs",
|
||||||
|
...props
|
||||||
|
}: Omit<React.ComponentProps<typeof Button>, "size"> &
|
||||||
|
VariantProps<typeof inputGroupButtonVariants>) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type={type}
|
||||||
|
data-size={size}
|
||||||
|
variant={variant}
|
||||||
|
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
data-slot="input-group-control"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupTextarea({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
data-slot="input-group-control"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupButton,
|
||||||
|
InputGroupText,
|
||||||
|
InputGroupInput,
|
||||||
|
InputGroupTextarea,
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||||
|
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
import { Select as SelectPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = "item-aligned",
|
||||||
|
align = "center",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
className={cn(
|
||||||
|
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
align={align}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
data-slot="select-item-indicator"
|
||||||
|
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||||
|
>
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
decorative = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
data-slot="separator"
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Loader2Icon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||||
|
return (
|
||||||
|
<Loader2Icon
|
||||||
|
role="status"
|
||||||
|
aria-label="Loading"
|
||||||
|
className={cn("size-4 animate-spin", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Spinner }
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function TooltipProvider({
|
||||||
|
delayDuration = 0,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Provider
|
||||||
|
data-slot="tooltip-provider"
|
||||||
|
delayDuration={delayDuration}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||||
|
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 0,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
|
||||||
|
</TooltipPrimitive.Content>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
@@ -6,11 +6,12 @@ export const ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|||||||
|
|
||||||
export const PATHS = {
|
export const PATHS = {
|
||||||
root: ROOT,
|
root: ROOT,
|
||||||
extensions: path.join(ROOT, "extensions"),
|
/** Teacher system prompt — the agent's instructions (src/agent/). */
|
||||||
teacherPrompt: path.join(ROOT, "teacher", "system-prompt.md"),
|
teacherPrompt: path.join(ROOT, "src", "agent", "system-prompt.md"),
|
||||||
data: path.join(ROOT, "data"),
|
data: path.join(ROOT, "data"),
|
||||||
/** Hermetic pi agentDir: settings, auth, and NOTHING from ~/.pi/agent except a symlinked auth.json. */
|
/** Chat sessions (AI SDK UIMessage transcripts, one JSON file per chat). */
|
||||||
agentDir: path.join(ROOT, "data", "agent"),
|
chats: path.join(ROOT, "data", "chats"),
|
||||||
|
/** Old pi session JSONLs — retroactive bootstrap input only. */
|
||||||
sessions: path.join(ROOT, "data", "sessions"),
|
sessions: path.join(ROOT, "data", "sessions"),
|
||||||
graph: path.join(ROOT, "data", "graph"),
|
graph: path.join(ROOT, "data", "graph"),
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -1,669 +0,0 @@
|
|||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
import {
|
|
||||||
Editor,
|
|
||||||
type EditorTheme,
|
|
||||||
Key,
|
|
||||||
Text,
|
|
||||||
matchesKey,
|
|
||||||
truncateToWidth,
|
|
||||||
wrapTextWithAnsi,
|
|
||||||
} from "@mariozechner/pi-tui";
|
|
||||||
import { Type } from "@sinclair/typebox";
|
|
||||||
|
|
||||||
interface AskOption {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DisplayOption extends AskOption {
|
|
||||||
id: string;
|
|
||||||
index?: number;
|
|
||||||
isOther?: boolean;
|
|
||||||
isSubmit?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TextAnswer {
|
|
||||||
type: "text";
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OptionAnswer {
|
|
||||||
type: "option";
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
index: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OtherAnswer {
|
|
||||||
type: "other";
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type AskAnswer = TextAnswer | OptionAnswer | OtherAnswer;
|
|
||||||
type AskUserQuestionStatus = "answered" | "cancelled" | "unavailable";
|
|
||||||
type AskUserQuestionMode = "text" | "single-select" | "multi-select";
|
|
||||||
|
|
||||||
interface AskUserQuestionResultDetails {
|
|
||||||
status: AskUserQuestionStatus;
|
|
||||||
question: string;
|
|
||||||
context?: string;
|
|
||||||
mode: AskUserQuestionMode;
|
|
||||||
answers: AskAnswer[];
|
|
||||||
message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const OptionSchema = Type.Object({
|
|
||||||
label: Type.String({
|
|
||||||
description:
|
|
||||||
'Display label for the option. If you recommend an option, place it first and append "(Recommended)" to the label.',
|
|
||||||
}),
|
|
||||||
value: Type.Optional(
|
|
||||||
Type.String({
|
|
||||||
description: "Optional machine-readable value returned for the option. Defaults to the label.",
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
description: Type.Optional(Type.String({ description: "Optional extra detail shown below the option." })),
|
|
||||||
});
|
|
||||||
|
|
||||||
const AskUserQuestionParams = Type.Object({
|
|
||||||
question: Type.String({
|
|
||||||
description: "The single question to ask the user. Ask exactly one question per tool call.",
|
|
||||||
}),
|
|
||||||
details: Type.Optional(
|
|
||||||
Type.String({
|
|
||||||
description: "Optional extra context or instructions shown under the question.",
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
options: Type.Optional(
|
|
||||||
Type.Array(OptionSchema, {
|
|
||||||
description:
|
|
||||||
"Optional multiple-choice options. Omit or pass an empty array for free-form text input. Users will always be able to choose Other and type a custom answer when options are provided.",
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
multiSelect: Type.Optional(
|
|
||||||
Type.Boolean({
|
|
||||||
description: "Set to true to allow multiple answers to be selected for a question.",
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
function normalizeOptions(options: Array<{ label: string; value?: string; description?: string }> | undefined): AskOption[] {
|
|
||||||
return (options || [])
|
|
||||||
.map((option) => ({
|
|
||||||
label: option.label.trim(),
|
|
||||||
value: option.value?.trim() || option.label.trim(),
|
|
||||||
description: option.description?.trim() || undefined,
|
|
||||||
}))
|
|
||||||
.filter((option) => option.label.length > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOtherLabel(options: AskOption[]): string {
|
|
||||||
return options.some((option) => option.label.toLowerCase() === "other") ? "Other (custom)" : "Other";
|
|
||||||
}
|
|
||||||
|
|
||||||
function createEditorTheme(theme: any): EditorTheme {
|
|
||||||
return {
|
|
||||||
borderColor: (s) => theme.fg("accent", s),
|
|
||||||
selectList: {
|
|
||||||
selectedPrefix: (t) => theme.fg("accent", t),
|
|
||||||
selectedText: (t) => theme.fg("accent", t),
|
|
||||||
description: (t) => theme.fg("muted", t),
|
|
||||||
scrollInfo: (t) => theme.fg("dim", t),
|
|
||||||
noMatch: (t) => theme.fg("warning", t),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function addWrapped(lines: string[], text: string, width: number, indent = ""): void {
|
|
||||||
const contentWidth = Math.max(1, width - indent.length);
|
|
||||||
for (const line of wrapTextWithAnsi(text, contentWidth)) {
|
|
||||||
lines.push(truncateToWidth(`${indent}${line}`, width));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatAnswerForModel(answer: AskAnswer): string {
|
|
||||||
switch (answer.type) {
|
|
||||||
case "text":
|
|
||||||
return answer.label;
|
|
||||||
case "other":
|
|
||||||
return `Other: ${answer.label}`;
|
|
||||||
case "option":
|
|
||||||
return `${answer.index}. ${answer.label}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function answerSortRank(answer: AskAnswer): number {
|
|
||||||
switch (answer.type) {
|
|
||||||
case "option":
|
|
||||||
return answer.index;
|
|
||||||
case "other":
|
|
||||||
return Number.MAX_SAFE_INTEGER - 1;
|
|
||||||
case "text":
|
|
||||||
return Number.MAX_SAFE_INTEGER;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortAnswers(answers: AskAnswer[]): AskAnswer[] {
|
|
||||||
return [...answers].sort((a, b) => answerSortRank(a) - answerSortRank(b));
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildStructuredResult(
|
|
||||||
status: AskUserQuestionStatus,
|
|
||||||
question: string,
|
|
||||||
mode: AskUserQuestionMode,
|
|
||||||
answers: AskAnswer[],
|
|
||||||
context?: string,
|
|
||||||
message?: string,
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
status,
|
|
||||||
question,
|
|
||||||
context,
|
|
||||||
mode,
|
|
||||||
answers,
|
|
||||||
message,
|
|
||||||
} as AskUserQuestionResultDetails;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelledResult(question: string, mode: AskUserQuestionMode, context?: string) {
|
|
||||||
const message = "User cancelled the question";
|
|
||||||
return {
|
|
||||||
content: [{ type: "text" as const, text: message }],
|
|
||||||
details: buildStructuredResult("cancelled", question, mode, [], context, message),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function unavailableResult(question: string, mode: AskUserQuestionMode, message: string, context?: string) {
|
|
||||||
return {
|
|
||||||
content: [{ type: "text" as const, text: message }],
|
|
||||||
details: buildStructuredResult("unavailable", question, mode, [], context, message),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildResult(question: string, context: string | undefined, mode: AskUserQuestionMode, answers: AskAnswer[]) {
|
|
||||||
let text: string;
|
|
||||||
if (mode === "text") {
|
|
||||||
const answer = answers[0];
|
|
||||||
text = answer.label.trim().length > 0 ? `User answered: ${answer.label}` : "User submitted an empty response";
|
|
||||||
} else if (mode === "single-select") {
|
|
||||||
text = `User selected: ${formatAnswerForModel(answers[0])}`;
|
|
||||||
} else {
|
|
||||||
text = `User selected:\n${answers.map((answer) => `- ${formatAnswerForModel(answer)}`).join("\n")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [{ type: "text" as const, text }],
|
|
||||||
details: buildStructuredResult("answered", question, mode, answers, context),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function askSingleChoice(
|
|
||||||
ctx: any,
|
|
||||||
question: string,
|
|
||||||
context: string | undefined,
|
|
||||||
options: AskOption[],
|
|
||||||
): Promise<AskAnswer | null> {
|
|
||||||
const otherLabel = getOtherLabel(options);
|
|
||||||
const allOptions: DisplayOption[] = [
|
|
||||||
...options.map((option, index) => ({ ...option, id: `option:${index}`, index: index + 1 })),
|
|
||||||
{ id: "other", label: otherLabel, value: "__other__", isOther: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
return ctx.ui.custom<AskAnswer | null>((tui: any, theme: any, _kb: any, done: (result: AskAnswer | null) => void) => {
|
|
||||||
let optionIndex = 0;
|
|
||||||
let editMode = false;
|
|
||||||
let cachedLines: string[] | undefined;
|
|
||||||
let cachedWidth = -1;
|
|
||||||
const editor = new Editor(tui, createEditorTheme(theme));
|
|
||||||
|
|
||||||
editor.onSubmit = (value) => {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (!trimmed) return;
|
|
||||||
done({ type: "other", label: trimmed, value: trimmed });
|
|
||||||
};
|
|
||||||
|
|
||||||
function refresh() {
|
|
||||||
cachedLines = undefined;
|
|
||||||
tui.requestRender();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleInput(data: string) {
|
|
||||||
if (editMode) {
|
|
||||||
if (matchesKey(data, Key.escape)) {
|
|
||||||
editMode = false;
|
|
||||||
editor.setText("");
|
|
||||||
refresh();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
editor.handleInput(data);
|
|
||||||
refresh();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matchesKey(data, Key.up)) {
|
|
||||||
optionIndex = Math.max(0, optionIndex - 1);
|
|
||||||
refresh();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (matchesKey(data, Key.down)) {
|
|
||||||
optionIndex = Math.min(allOptions.length - 1, optionIndex + 1);
|
|
||||||
refresh();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (matchesKey(data, Key.enter)) {
|
|
||||||
const selected = allOptions[optionIndex];
|
|
||||||
if (selected.isOther) {
|
|
||||||
editMode = true;
|
|
||||||
editor.setText("");
|
|
||||||
refresh();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
done({
|
|
||||||
type: "option",
|
|
||||||
label: selected.label,
|
|
||||||
value: selected.value,
|
|
||||||
index: selected.index!,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (matchesKey(data, Key.escape)) {
|
|
||||||
done(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function render(width: number): string[] {
|
|
||||||
// The cache MUST be keyed on width: pi-tui calls requestRender() but NOT
|
|
||||||
// invalidate() on terminal resize, so render() can be re-entered with a
|
|
||||||
// new width. Returning stale wider lines trips the TUI width guard and
|
|
||||||
// crashes the process.
|
|
||||||
if (cachedLines && cachedWidth === width) return cachedLines;
|
|
||||||
|
|
||||||
const lines: string[] = [];
|
|
||||||
const add = (text: string) => lines.push(truncateToWidth(text, width));
|
|
||||||
|
|
||||||
add(theme.fg("accent", "─".repeat(width)));
|
|
||||||
addWrapped(lines, theme.fg("text", ` ${question}`), width);
|
|
||||||
if (context) {
|
|
||||||
lines.push("");
|
|
||||||
addWrapped(lines, theme.fg("muted", ` ${context}`), width);
|
|
||||||
}
|
|
||||||
lines.push("");
|
|
||||||
|
|
||||||
for (let i = 0; i < allOptions.length; i++) {
|
|
||||||
const option = allOptions[i];
|
|
||||||
const selected = i === optionIndex;
|
|
||||||
const prefix = selected ? theme.fg("accent", "> ") : " ";
|
|
||||||
const label = option.isOther ? option.label : `${option.index}. ${option.label}`;
|
|
||||||
const styled = selected ? theme.fg("accent", label) : theme.fg("text", label);
|
|
||||||
add(`${prefix}${styled}`);
|
|
||||||
if (option.description) {
|
|
||||||
addWrapped(lines, theme.fg("muted", option.description), width, " ");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (editMode) {
|
|
||||||
lines.push("");
|
|
||||||
add(theme.fg("muted", " Write your custom answer:"));
|
|
||||||
for (const line of editor.render(Math.max(1, width - 2))) {
|
|
||||||
add(` ${line}`);
|
|
||||||
}
|
|
||||||
lines.push("");
|
|
||||||
add(theme.fg("dim", " Enter to submit • Esc to go back"));
|
|
||||||
} else {
|
|
||||||
lines.push("");
|
|
||||||
add(theme.fg("dim", " ↑↓ navigate • Enter select • Esc cancel"));
|
|
||||||
}
|
|
||||||
|
|
||||||
add(theme.fg("accent", "─".repeat(width)));
|
|
||||||
cachedLines = lines;
|
|
||||||
cachedWidth = width;
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
render,
|
|
||||||
invalidate: () => {
|
|
||||||
cachedLines = undefined;
|
|
||||||
},
|
|
||||||
handleInput,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function askMultiChoice(
|
|
||||||
ctx: any,
|
|
||||||
question: string,
|
|
||||||
context: string | undefined,
|
|
||||||
options: AskOption[],
|
|
||||||
): Promise<AskAnswer[] | null> {
|
|
||||||
const otherLabel = getOtherLabel(options);
|
|
||||||
const choiceItems: DisplayOption[] = options.map((option, index) => ({
|
|
||||||
...option,
|
|
||||||
id: `option:${index}`,
|
|
||||||
index: index + 1,
|
|
||||||
}));
|
|
||||||
const submitItem: DisplayOption = { id: "submit", label: "Submit", value: "__submit__", isSubmit: true };
|
|
||||||
const allItems: DisplayOption[] = [
|
|
||||||
...choiceItems,
|
|
||||||
{ id: "other", label: otherLabel, value: "__other__", isOther: true },
|
|
||||||
submitItem,
|
|
||||||
];
|
|
||||||
|
|
||||||
return ctx.ui.custom<AskAnswer[] | null>((tui: any, theme: any, _kb: any, done: (result: AskAnswer[] | null) => void) => {
|
|
||||||
let optionIndex = 0;
|
|
||||||
let editMode = false;
|
|
||||||
let cachedLines: string[] | undefined;
|
|
||||||
let cachedWidth = -1;
|
|
||||||
const selected = new Map<string, AskAnswer>();
|
|
||||||
const editor = new Editor(tui, createEditorTheme(theme));
|
|
||||||
|
|
||||||
editor.onSubmit = (value) => {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (!trimmed) return;
|
|
||||||
selected.set("other", { type: "other", label: trimmed, value: trimmed });
|
|
||||||
editMode = false;
|
|
||||||
refresh();
|
|
||||||
};
|
|
||||||
|
|
||||||
function refresh() {
|
|
||||||
cachedLines = undefined;
|
|
||||||
tui.requestRender();
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleOption(item: DisplayOption) {
|
|
||||||
if (selected.has(item.id)) {
|
|
||||||
selected.delete(item.id);
|
|
||||||
} else {
|
|
||||||
selected.set(item.id, {
|
|
||||||
type: "option",
|
|
||||||
label: item.label,
|
|
||||||
value: item.value,
|
|
||||||
index: item.index!,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleInput(data: string) {
|
|
||||||
if (editMode) {
|
|
||||||
if (matchesKey(data, Key.escape)) {
|
|
||||||
editMode = false;
|
|
||||||
editor.setText(selected.get("other")?.label || "");
|
|
||||||
refresh();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
editor.handleInput(data);
|
|
||||||
refresh();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matchesKey(data, Key.up)) {
|
|
||||||
optionIndex = Math.max(0, optionIndex - 1);
|
|
||||||
refresh();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (matchesKey(data, Key.down)) {
|
|
||||||
optionIndex = Math.min(allItems.length - 1, optionIndex + 1);
|
|
||||||
refresh();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const current = allItems[optionIndex];
|
|
||||||
if (matchesKey(data, Key.space)) {
|
|
||||||
if (current.isSubmit) return;
|
|
||||||
if (current.isOther) {
|
|
||||||
if (selected.has("other")) {
|
|
||||||
selected.delete("other");
|
|
||||||
refresh();
|
|
||||||
} else {
|
|
||||||
editMode = true;
|
|
||||||
editor.setText("");
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
toggleOption(current);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matchesKey(data, Key.enter)) {
|
|
||||||
if (current.isSubmit) {
|
|
||||||
if (selected.size > 0) {
|
|
||||||
done(sortAnswers(Array.from(selected.values())));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (current.isOther) {
|
|
||||||
editMode = true;
|
|
||||||
editor.setText(selected.get("other")?.label || "");
|
|
||||||
refresh();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
toggleOption(current);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matchesKey(data, Key.escape)) {
|
|
||||||
done(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function render(width: number): string[] {
|
|
||||||
// The cache MUST be keyed on width: pi-tui calls requestRender() but NOT
|
|
||||||
// invalidate() on terminal resize, so render() can be re-entered with a
|
|
||||||
// new width. Returning stale wider lines trips the TUI width guard and
|
|
||||||
// crashes the process.
|
|
||||||
if (cachedLines && cachedWidth === width) return cachedLines;
|
|
||||||
|
|
||||||
const lines: string[] = [];
|
|
||||||
const add = (text: string) => lines.push(truncateToWidth(text, width));
|
|
||||||
|
|
||||||
add(theme.fg("accent", "─".repeat(width)));
|
|
||||||
addWrapped(lines, theme.fg("text", ` ${question}`), width);
|
|
||||||
if (context) {
|
|
||||||
lines.push("");
|
|
||||||
addWrapped(lines, theme.fg("muted", ` ${context}`), width);
|
|
||||||
}
|
|
||||||
lines.push("");
|
|
||||||
|
|
||||||
for (let i = 0; i < allItems.length; i++) {
|
|
||||||
const item = allItems[i];
|
|
||||||
const isFocused = i === optionIndex;
|
|
||||||
const prefix = isFocused ? theme.fg("accent", "> ") : " ";
|
|
||||||
|
|
||||||
if (item.isSubmit) {
|
|
||||||
const label = selected.size > 0 ? `✓ ${item.label} (${selected.size} selected)` : `○ ${item.label}`;
|
|
||||||
const styled = isFocused
|
|
||||||
? theme.fg("accent", label)
|
|
||||||
: theme.fg(selected.size > 0 ? "success" : "dim", label);
|
|
||||||
add(`${prefix}${styled}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.isOther) {
|
|
||||||
const other = selected.get("other");
|
|
||||||
const marker = other ? "[x]" : "[ ]";
|
|
||||||
const suffix = other ? ` — ${other.label}` : "";
|
|
||||||
const styled = isFocused
|
|
||||||
? theme.fg("accent", `${marker} ${item.label}${suffix}`)
|
|
||||||
: theme.fg(other ? "success" : "text", `${marker} ${item.label}${suffix}`);
|
|
||||||
add(`${prefix}${styled}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const checked = selected.has(item.id);
|
|
||||||
const marker = checked ? "[x]" : "[ ]";
|
|
||||||
const label = `${marker} ${item.index}. ${item.label}`;
|
|
||||||
const styled = isFocused
|
|
||||||
? theme.fg("accent", label)
|
|
||||||
: theme.fg(checked ? "success" : "text", label);
|
|
||||||
add(`${prefix}${styled}`);
|
|
||||||
if (item.description) {
|
|
||||||
addWrapped(lines, theme.fg("muted", item.description), width, " ");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (editMode) {
|
|
||||||
lines.push("");
|
|
||||||
add(theme.fg("muted", " Write your custom answer:"));
|
|
||||||
for (const line of editor.render(Math.max(1, width - 2))) {
|
|
||||||
add(` ${line}`);
|
|
||||||
}
|
|
||||||
lines.push("");
|
|
||||||
add(theme.fg("dim", " Enter to save • Esc to go back"));
|
|
||||||
} else {
|
|
||||||
lines.push("");
|
|
||||||
if (selected.size === 0) {
|
|
||||||
add(theme.fg("warning", " Select at least one answer before submitting."));
|
|
||||||
}
|
|
||||||
add(theme.fg("dim", " ↑↓ navigate • Space toggle • Enter edit/submit • Esc cancel"));
|
|
||||||
}
|
|
||||||
|
|
||||||
add(theme.fg("accent", "─".repeat(width)));
|
|
||||||
cachedLines = lines;
|
|
||||||
cachedWidth = width;
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
render,
|
|
||||||
invalidate: () => {
|
|
||||||
cachedLines = undefined;
|
|
||||||
},
|
|
||||||
handleInput,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shared UI mutex. ctx.ui.custom()/editor can only handle one active call at
|
|
||||||
// a time, so ALL pop-up-style tools (ask_user_question, quiz, ...) must
|
|
||||||
// serialize against each other, not just against themselves. We stash one
|
|
||||||
// mutex on globalThis so separate extension files can share it without
|
|
||||||
// importing each other.
|
|
||||||
const SHARED_UI_LOCK_KEY = "__piSharedUiLock";
|
|
||||||
function getSharedUiLock() {
|
|
||||||
const g = globalThis as any;
|
|
||||||
if (!g[SHARED_UI_LOCK_KEY]) {
|
|
||||||
let chain: Promise<void> = Promise.resolve();
|
|
||||||
g[SHARED_UI_LOCK_KEY] = {
|
|
||||||
withLock<T>(fn: () => T | Promise<T>): Promise<T> {
|
|
||||||
const prev = chain;
|
|
||||||
let release: () => void;
|
|
||||||
chain = new Promise<void>((r) => { release = r; });
|
|
||||||
return prev.then(fn).finally(() => release!());
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return g[SHARED_UI_LOCK_KEY] as { withLock<T>(fn: () => T | Promise<T>): Promise<T> };
|
|
||||||
}
|
|
||||||
const sharedUiLock = getSharedUiLock();
|
|
||||||
|
|
||||||
function withUILock<T>(fn: () => Promise<T>): Promise<T> {
|
|
||||||
return sharedUiLock.withLock(fn);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function askUserQuestion(pi: ExtensionAPI) {
|
|
||||||
pi.registerTool({
|
|
||||||
name: "ask_user_question",
|
|
||||||
label: "ask_user_question",
|
|
||||||
description:
|
|
||||||
"Ask the user a single question and pause execution until they answer. Use this when requirements are ambiguous, user preferences are needed, a decision would materially affect implementation, or you need confirmation before proceeding. Ask exactly one question per tool call, and prefer multiple separate tool calls over bundling unrelated questions together.",
|
|
||||||
promptSnippet:
|
|
||||||
"Use this tool to ask exactly one clarifying question, missing-requirement question, preference question, or decision question before continuing.",
|
|
||||||
promptGuidelines: [
|
|
||||||
"Ask exactly one question per tool call.",
|
|
||||||
"If you need answers to multiple questions, make multiple separate ask_user_question tool calls instead of combining them into one prompt.",
|
|
||||||
'Users will always be able to select "Other" to provide custom text input when options are provided.',
|
|
||||||
"Use multiSelect: true only when you need multiple answers to the same question.",
|
|
||||||
'If you recommend a specific option, make it the first option in the list and add "(Recommended)" at the end of the label.',
|
|
||||||
"Prefer this tool over guessing when requirements, preferences, or implementation choices are unclear.",
|
|
||||||
"Use this tool when multiple valid implementation paths exist and the preferred path depends on user choice.",
|
|
||||||
],
|
|
||||||
parameters: AskUserQuestionParams,
|
|
||||||
|
|
||||||
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
||||||
const options = normalizeOptions(params.options);
|
|
||||||
const context = params.details?.trim() || undefined;
|
|
||||||
const mode: AskUserQuestionMode = options.length === 0 ? "text" : params.multiSelect ? "multi-select" : "single-select";
|
|
||||||
|
|
||||||
if (signal?.aborted) {
|
|
||||||
return cancelledResult(params.question, mode, context);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ctx.hasUI) {
|
|
||||||
return unavailableResult(params.question, mode, "ask_user_question requires interactive mode UI", context);
|
|
||||||
}
|
|
||||||
|
|
||||||
return withUILock(async () => {
|
|
||||||
if (mode === "text") {
|
|
||||||
const editorTitle = context ? `${params.question}\n\n${context}` : params.question;
|
|
||||||
const answer = await ctx.ui.editor(editorTitle);
|
|
||||||
if (answer === undefined) {
|
|
||||||
return cancelledResult(params.question, mode, context);
|
|
||||||
}
|
|
||||||
return buildResult(params.question, context, mode, [
|
|
||||||
{ type: "text", label: answer.trim(), value: answer.trim() },
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mode === "single-select") {
|
|
||||||
const answer = await askSingleChoice(ctx, params.question, context, options);
|
|
||||||
if (!answer) {
|
|
||||||
return cancelledResult(params.question, mode, context);
|
|
||||||
}
|
|
||||||
return buildResult(params.question, context, mode, [answer]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const answers = await askMultiChoice(ctx, params.question, context, options);
|
|
||||||
if (!answers) {
|
|
||||||
return cancelledResult(params.question, mode, context);
|
|
||||||
}
|
|
||||||
return buildResult(params.question, context, mode, answers);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
renderCall(args, theme) {
|
|
||||||
const options = normalizeOptions(args.options as Array<{ label: string; value?: string; description?: string }> | undefined);
|
|
||||||
let text = theme.fg("toolTitle", theme.bold("ask_user_question ")) + theme.fg("muted", args.question);
|
|
||||||
if (args.multiSelect) {
|
|
||||||
text += theme.fg("dim", " [multi-select]");
|
|
||||||
}
|
|
||||||
if (options.length > 0) {
|
|
||||||
const labels = [...options.map((option) => option.label), getOtherLabel(options)].join(", ");
|
|
||||||
text += `\n${theme.fg("dim", ` Options: ${labels}`)}`;
|
|
||||||
}
|
|
||||||
return new Text(text, 0, 0);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderResult(result, _options, theme) {
|
|
||||||
const details = result.details as AskUserQuestionResultDetails | undefined;
|
|
||||||
if (!details) {
|
|
||||||
const first = result.content[0];
|
|
||||||
return new Text(first?.type === "text" ? first.text : "", 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (details.status === "cancelled") {
|
|
||||||
return new Text(theme.fg("warning", details.message || "Cancelled"), 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (details.status === "unavailable") {
|
|
||||||
return new Text(theme.fg("warning", details.message || "ask_user_question unavailable"), 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = details.answers.map((answer) => {
|
|
||||||
switch (answer.type) {
|
|
||||||
case "text":
|
|
||||||
return `${theme.fg("success", "✓ ")}${theme.fg("accent", answer.label || "(empty response)")}`;
|
|
||||||
case "other":
|
|
||||||
return `${theme.fg("success", "✓ ")}${theme.fg("muted", "Other: ")}${theme.fg("accent", answer.label)}`;
|
|
||||||
case "option":
|
|
||||||
return `${theme.fg("success", "✓ ")}${theme.fg("accent", `${answer.index}. ${answer.label}`)}`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return new Text(lines.join("\n"), 0, 0);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
/**
|
|
||||||
* ask-web — the ask_user_question tool in WEB DISPATCH mode.
|
|
||||||
*
|
|
||||||
* Same dispatch shape as quiz-web: the tool validates and persists the
|
|
||||||
* question (lh_question custom entry), returns immediately, and the learner's
|
|
||||||
* answer arrives as the next user message once the server calls the bridge.
|
|
||||||
* Not graded — this is the preference/decision sibling of quiz.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
import { Text } from "@mariozechner/pi-tui";
|
|
||||||
import { Type } from "@sinclair/typebox";
|
|
||||||
import {
|
|
||||||
QUESTION_ANSWERED_ENTRY,
|
|
||||||
QUESTION_ENTRY,
|
|
||||||
type QuestionData,
|
|
||||||
normalizeOptions,
|
|
||||||
} from "./core/question-core.ts";
|
|
||||||
import { getBridge } from "./core/bridge.ts";
|
|
||||||
|
|
||||||
const OptionSchema = Type.Object({
|
|
||||||
label: Type.String({
|
|
||||||
description:
|
|
||||||
'Display label for the option. If you recommend an option, place it first and append "(Recommended)" to the label.',
|
|
||||||
}),
|
|
||||||
value: Type.Optional(
|
|
||||||
Type.String({ description: "Optional machine-readable value returned for the option. Defaults to the label." }),
|
|
||||||
),
|
|
||||||
description: Type.Optional(Type.String({ description: "Optional extra detail shown below the option." })),
|
|
||||||
});
|
|
||||||
|
|
||||||
const AskUserQuestionParams = Type.Object({
|
|
||||||
question: Type.String({
|
|
||||||
description: "The single question to ask the user. Ask exactly one question per tool call.",
|
|
||||||
}),
|
|
||||||
details: Type.Optional(
|
|
||||||
Type.String({ description: "Optional extra context or instructions shown under the question." }),
|
|
||||||
),
|
|
||||||
options: Type.Optional(
|
|
||||||
Type.Array(OptionSchema, {
|
|
||||||
description:
|
|
||||||
"Optional multiple-choice options. Omit or pass an empty array for free-form text input. Users will always be able to choose Other and type a custom answer when options are provided.",
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
multiSelect: Type.Optional(
|
|
||||||
Type.Boolean({ description: "Set to true to allow multiple answers to be selected for a question." }),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const PRESENTED_MESSAGE =
|
|
||||||
"Question displayed to the learner. End your turn now and wait: their answer will arrive as the next user message. Do not call any other tool before receiving it.";
|
|
||||||
|
|
||||||
function unavailableResult(question: string, mode: string, message: string, context?: string) {
|
|
||||||
return {
|
|
||||||
content: [{ type: "text" as const, text: message }],
|
|
||||||
details: { status: "unavailable", question, context, mode, message },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function askWeb(pi: ExtensionAPI) {
|
|
||||||
const bridge = getBridge();
|
|
||||||
|
|
||||||
let currentCtx: { sessionManager: { getEntries(): Array<{ type: string; customType?: string; data?: unknown }> } } | null = null;
|
|
||||||
|
|
||||||
bridge.registerBackend({
|
|
||||||
kind: "ask",
|
|
||||||
appendAnswered: (data) => pi.appendEntry(QUESTION_ANSWERED_ENTRY, data),
|
|
||||||
sendUserMessage: (text) => pi.sendUserMessage(text, { deliverAs: "followUp" }),
|
|
||||||
getEntries: () => currentCtx?.sessionManager.getEntries() ?? [],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Re-arm pending questions on session (re)load — exit/resume mid-question.
|
|
||||||
pi.on("session_start", (_event, ctx) => {
|
|
||||||
currentCtx = ctx;
|
|
||||||
bridge.clear("ask");
|
|
||||||
bridge.rearm("ask");
|
|
||||||
});
|
|
||||||
|
|
||||||
pi.registerTool({
|
|
||||||
name: "ask_user_question",
|
|
||||||
label: "ask_user_question",
|
|
||||||
description:
|
|
||||||
"Ask the user a single question and pause execution until they answer. Use this when requirements are ambiguous, user preferences are needed, a decision would materially affect implementation, or you need confirmation before proceeding. Ask exactly one question per tool call, and prefer multiple separate tool calls over bundling unrelated questions together.",
|
|
||||||
promptSnippet:
|
|
||||||
"Use this tool to ask exactly one clarifying question, missing-requirement question, preference question, or decision question before continuing.",
|
|
||||||
promptGuidelines: [
|
|
||||||
"Ask exactly one question per tool call.",
|
|
||||||
"If you need answers to multiple questions, make multiple separate ask_user_question tool calls instead of combining them into one prompt.",
|
|
||||||
'Users will always be able to select "Other" to provide custom text input when options are provided.',
|
|
||||||
"Use multiSelect: true only when you need multiple answers to the same question.",
|
|
||||||
'If you recommend a specific option, make it the first option in the list and add "(Recommended)" at the end of the label.',
|
|
||||||
"Prefer this tool over guessing when requirements, preferences, or implementation choices are unclear.",
|
|
||||||
"Use this tool when multiple valid implementation paths exist and the preferred path depends on user choice.",
|
|
||||||
"After calling ask_user_question, END YOUR TURN. The answer arrives as the next user message; do not call any other tool while waiting.",
|
|
||||||
],
|
|
||||||
parameters: AskUserQuestionParams,
|
|
||||||
|
|
||||||
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
||||||
const context = params.details?.trim() || undefined;
|
|
||||||
let options;
|
|
||||||
try {
|
|
||||||
options = normalizeOptions(params.options);
|
|
||||||
} catch (e) {
|
|
||||||
return unavailableResult(params.question, "single-select", `ask_user_question ${(e as Error).message}`, context);
|
|
||||||
}
|
|
||||||
const mode = options.length === 0 ? "text" : params.multiSelect ? "multi-select" : "single-select";
|
|
||||||
|
|
||||||
const data: QuestionData = {
|
|
||||||
id: `q_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`,
|
|
||||||
kind: "ask",
|
|
||||||
question: params.question,
|
|
||||||
context,
|
|
||||||
mode,
|
|
||||||
options,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
pi.appendEntry(QUESTION_ENTRY, data);
|
|
||||||
bridge.publish(data);
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [{ type: "text" as const, text: PRESENTED_MESSAGE }],
|
|
||||||
details: {
|
|
||||||
status: "presented",
|
|
||||||
question: params.question,
|
|
||||||
context,
|
|
||||||
mode,
|
|
||||||
options: options.map((o, i) => ({ index: i + 1, label: o.label })),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
renderCall(args, theme) {
|
|
||||||
let text = theme.fg("toolTitle", theme.bold("ask_user_question ")) + theme.fg("muted", args.question);
|
|
||||||
if (args.multiSelect) text += theme.fg("dim", " [multi-select]");
|
|
||||||
return new Text(text, 0, 0);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderResult(result, _options, theme) {
|
|
||||||
const details = result.details as { status?: string; message?: string } | undefined;
|
|
||||||
if (details?.status === "unavailable") {
|
|
||||||
return new Text(theme.fg("warning", details.message || "ask_user_question unavailable"), 0, 0);
|
|
||||||
}
|
|
||||||
return new Text(theme.fg("muted", "Question displayed to the learner — awaiting their answer."), 0, 0);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
/**
|
|
||||||
* bridge — in-process communication between the web server and the question
|
|
||||||
* extensions. Both run in the same Node process (the server embeds pi via the
|
|
||||||
* SDK), so a globalThis registry is the simplest robust channel.
|
|
||||||
*
|
|
||||||
* The server only ever needs ONE operation: `answer()` / `dismiss()` — which
|
|
||||||
* must run inside an extension (it needs the ExtensionAPI: appendEntry +
|
|
||||||
* sendUserMessage). Everything else (new questions, answered questions) the
|
|
||||||
* server observes directly from the session event stream (`entry_appended`),
|
|
||||||
* which is the single source of truth.
|
|
||||||
*
|
|
||||||
* The registry is rebuilt on every `session_start` (resume re-arm) by scanning
|
|
||||||
* the session entries for lh_question / lh_question_answered.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
type AnswerRequest,
|
|
||||||
type QuestionData,
|
|
||||||
QUESTION_ANSWERED_ENTRY,
|
|
||||||
QUESTION_ENTRY,
|
|
||||||
type QuestionKind,
|
|
||||||
buildAnswer,
|
|
||||||
formatAnswerMessage,
|
|
||||||
} from "./question-core.ts";
|
|
||||||
|
|
||||||
interface Answerer {
|
|
||||||
(data: QuestionData, req: AnswerRequest): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Backend {
|
|
||||||
kind: QuestionKind;
|
|
||||||
/** Append a custom entry to the session (not sent to the LLM). */
|
|
||||||
appendAnswered(data: unknown): void;
|
|
||||||
/** Send a user message into the session (triggers an agent turn). */
|
|
||||||
sendUserMessage(text: string): void;
|
|
||||||
/** All entries of the current session branch. */
|
|
||||||
getEntries(): Array<{ type: string; customType?: string; data?: unknown }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AnswerOutcome {
|
|
||||||
ok: boolean;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
class QuestionBridge {
|
|
||||||
/** kind → backend (registered by each web extension at load) */
|
|
||||||
private backends = new Map<QuestionKind, Backend>();
|
|
||||||
/** questionId → data, insertion-ordered (oldest first) */
|
|
||||||
private pending = new Map<string, QuestionData>();
|
|
||||||
|
|
||||||
registerBackend(backend: Backend): void {
|
|
||||||
this.backends.set(backend.kind, backend);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Called by a web extension's tool execute(): record + persist the question. */
|
|
||||||
publish(data: QuestionData): void {
|
|
||||||
this.pending.set(data.id, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Clear this kind's pending questions (called on session_start before re-arm). */
|
|
||||||
clear(kind: QuestionKind): void {
|
|
||||||
for (const [id, qd] of this.pending) {
|
|
||||||
if (qd.kind === kind) this.pending.delete(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Re-arm from session entries (called on session_start after clear). */
|
|
||||||
rearm(kind: QuestionKind): void {
|
|
||||||
const backend = this.backends.get(kind);
|
|
||||||
if (!backend) return;
|
|
||||||
const answered = new Set<string>();
|
|
||||||
for (const e of backend.getEntries()) {
|
|
||||||
if (e.type === "custom" && e.customType === QUESTION_ANSWERED_ENTRY) {
|
|
||||||
const d = e.data as { questionId?: string } | undefined;
|
|
||||||
if (d?.questionId) answered.add(d.questionId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const e of backend.getEntries()) {
|
|
||||||
if (e.type === "custom" && e.customType === QUESTION_ENTRY) {
|
|
||||||
const d = e.data as QuestionData | undefined;
|
|
||||||
if (d?.kind === kind && !answered.has(d.id)) this.pending.set(d.id, d);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
answer(questionId: string, req: AnswerRequest): AnswerOutcome {
|
|
||||||
const qd = this.pending.get(questionId);
|
|
||||||
if (!qd) return { ok: false, error: "unknown or already-answered question" };
|
|
||||||
const backend = this.backends.get(qd.kind);
|
|
||||||
if (!backend) return { ok: false, error: "no backend registered" };
|
|
||||||
this.pending.delete(questionId);
|
|
||||||
const answerData = buildAnswer(qd, req);
|
|
||||||
backend.appendAnswered(answerData);
|
|
||||||
backend.sendUserMessage(formatAnswerMessage(answerData));
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
dismiss(questionId: string): AnswerOutcome {
|
|
||||||
const qd = this.pending.get(questionId);
|
|
||||||
if (!qd) return { ok: false, error: "unknown or already-answered question" };
|
|
||||||
const backend = this.backends.get(qd.kind);
|
|
||||||
if (!backend) return { ok: false, error: "no backend registered" };
|
|
||||||
this.pending.delete(questionId);
|
|
||||||
const answerData = buildAnswer(qd, { dontKnow: false });
|
|
||||||
answerData.dismissed = true;
|
|
||||||
backend.appendAnswered(answerData);
|
|
||||||
backend.sendUserMessage(formatAnswerMessage(answerData));
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const BRIDGE_KEY = "__learningHarnessQuestionBridge";
|
|
||||||
|
|
||||||
export function getBridge(): QuestionBridge {
|
|
||||||
const g = globalThis as Record<string, unknown>;
|
|
||||||
if (!g[BRIDGE_KEY]) g[BRIDGE_KEY] = new QuestionBridge();
|
|
||||||
return g[BRIDGE_KEY] as QuestionBridge;
|
|
||||||
}
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
/**
|
|
||||||
* quiz-web — the quiz tool in WEB DISPATCH mode.
|
|
||||||
*
|
|
||||||
* Unlike the terminal quiz (which blocks on an interactive component), this
|
|
||||||
* variant returns immediately after persisting the question:
|
|
||||||
*
|
|
||||||
* 1. execute() validates + shuffles + resolves correct indices, appends an
|
|
||||||
* `lh_question` custom entry (NOT in LLM context — the correct answer and
|
|
||||||
* explanation are safe there), publishes to the bridge, and returns.
|
|
||||||
* 2. The tool result tells the agent to end its turn — the learner's graded
|
|
||||||
* answer arrives as the next user message.
|
|
||||||
* 3. The server calls bridge.answer() when the learner answers; the
|
|
||||||
* extension grades, appends `lh_question_answered`, and sends the graded
|
|
||||||
* answer into the session as a user message.
|
|
||||||
*
|
|
||||||
* This shape makes exit/resume mid-quiz free: the pending state lives in the
|
|
||||||
* session file itself, and is re-armed on session_start.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
import { Text } from "@mariozechner/pi-tui";
|
|
||||||
import { Type } from "@sinclair/typebox";
|
|
||||||
import {
|
|
||||||
QUESTION_ANSWERED_ENTRY,
|
|
||||||
QUESTION_ENTRY,
|
|
||||||
type QuestionData,
|
|
||||||
normalizeOptions,
|
|
||||||
resolveCorrect,
|
|
||||||
shuffleOptions,
|
|
||||||
} from "./core/question-core.ts";
|
|
||||||
import { getBridge } from "./core/bridge.ts";
|
|
||||||
|
|
||||||
const OptionSchema = Type.Object({
|
|
||||||
label: Type.String({ description: "Display label for the answer option." }),
|
|
||||||
value: Type.Optional(
|
|
||||||
Type.String({ description: "Optional machine-readable value returned for the option. Defaults to the label." }),
|
|
||||||
),
|
|
||||||
description: Type.Optional(Type.String({ description: "Optional extra detail shown below the option." })),
|
|
||||||
});
|
|
||||||
|
|
||||||
const QuizParams = Type.Object({
|
|
||||||
question: Type.String({
|
|
||||||
description: "The single quiz question to ask. Ask exactly one question per tool call.",
|
|
||||||
}),
|
|
||||||
details: Type.Optional(
|
|
||||||
Type.String({ description: "Optional extra context or instructions shown under the question." }),
|
|
||||||
),
|
|
||||||
options: Type.Array(OptionSchema, {
|
|
||||||
description:
|
|
||||||
"The answer options (2 or more). Options only — there is no free-text mode. Give each option a stable `value`; you reference the correct one by that value in correctAnswer.",
|
|
||||||
minItems: 2,
|
|
||||||
}),
|
|
||||||
multiSelect: Type.Optional(
|
|
||||||
Type.Boolean({ description: "Set to true when more than one option is correct and the user must select all of them." }),
|
|
||||||
),
|
|
||||||
correctAnswer: Type.Union([Type.String(), Type.Array(Type.String())], {
|
|
||||||
description:
|
|
||||||
'REQUIRED. The correct answer as the option value(s) — the `value` field of the option you intend. Single-select: a single string (e.g. "mercury"). Multi-select: an array of strings (e.g. ["belize", "niue"]); the user is only correct if their selection matches this set exactly. Always pass the value, not a position number — this is self-checking and prevents miscounting.',
|
|
||||||
}),
|
|
||||||
explanation: Type.String({
|
|
||||||
description:
|
|
||||||
"REQUIRED. Explanation revealed AFTER the user answers (shown whether they got it right or wrong). Use it to reinforce why the correct answer is correct.",
|
|
||||||
}),
|
|
||||||
shuffle: Type.Optional(
|
|
||||||
Type.Boolean({
|
|
||||||
description:
|
|
||||||
"Defaults to true: options are randomly reordered before display so the correct answer isn't always in the same position. Set to false only when option order is meaningful (e.g. ordered numeric values, or an 'All/None of the above' option that must stay last).",
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const PRESENTED_MESSAGE =
|
|
||||||
"Quiz displayed to the learner. End your turn now and wait: their graded answer will arrive as the next user message. Do not call any other tool before receiving it.";
|
|
||||||
|
|
||||||
function unavailableResult(question: string, mode: string, message: string, context?: string) {
|
|
||||||
return {
|
|
||||||
content: [{ type: "text" as const, text: message }],
|
|
||||||
details: { status: "unavailable", question, context, mode, message },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function quizWeb(pi: ExtensionAPI) {
|
|
||||||
const bridge = getBridge();
|
|
||||||
|
|
||||||
// The web server drives answering through the bridge. This extension is
|
|
||||||
// the only place with ExtensionAPI access (appendEntry + sendUserMessage).
|
|
||||||
// ctx (with sessionManager) is captured from event handlers.
|
|
||||||
let currentCtx: { sessionManager: { getEntries(): Array<{ type: string; customType?: string; data?: unknown }> } } | null = null;
|
|
||||||
|
|
||||||
bridge.registerBackend({
|
|
||||||
kind: "quiz",
|
|
||||||
appendAnswered: (data) => pi.appendEntry(QUESTION_ANSWERED_ENTRY, data),
|
|
||||||
sendUserMessage: (text) => pi.sendUserMessage(text, { deliverAs: "followUp" }),
|
|
||||||
getEntries: () => currentCtx?.sessionManager.getEntries() ?? [],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Re-arm pending questions on session (re)load — exit/resume mid-quiz.
|
|
||||||
pi.on("session_start", (_event, ctx) => {
|
|
||||||
currentCtx = ctx;
|
|
||||||
bridge.clear("quiz");
|
|
||||||
bridge.rearm("quiz");
|
|
||||||
});
|
|
||||||
|
|
||||||
pi.registerTool({
|
|
||||||
name: "quiz",
|
|
||||||
label: "quiz",
|
|
||||||
description:
|
|
||||||
"Ask the user a GRADED question with a known correct answer, then instantly grade and give feedback. Unlike ask_user_question (which collects preferences/decisions with no right answer), quiz always has a correct answer supplied by you, marks the user's selection right/wrong (✓/✗), reveals the correct answer, and can show an explanation. Use it to (1) assess what the learner already understands before teaching, and (2) run tight practice/retrieval loops after explaining, or probe understanding whenever you're unsure they've got it. Options-only: single-select or multi-select, plus an automatic 'I don't know' choice so the user can signal a genuine gap instead of guessing. An always-present optional note field lets the user attach a free-text note to ANY answer; it reaches you only when non-empty. No free-text answers — for non-graded questions use ask_user_question instead.",
|
|
||||||
promptSnippet:
|
|
||||||
"Use the quiz tool to test the user with a graded multiple-choice or multi-select question (required correct answer + required explanation). For non-graded questions, use ask_user_question.",
|
|
||||||
promptGuidelines: [
|
|
||||||
"quiz is GRADED; ask_user_question is not. If the question has a correct answer, use quiz. If you just need a preference, decision, or open-ended input, use ask_user_question.",
|
|
||||||
'correctAnswer is REQUIRED and is the option value, not a position number. Single-select: one string (e.g. "mercury"). Multi-select: an array of strings (e.g. ["belice", "niue"]).',
|
|
||||||
"Always pass the option's `value` string as correctAnswer — it is self-checking and prevents miscounting positions. A value that matches no option is a hard error.",
|
|
||||||
"explanation is REQUIRED — always say why the correct answer is correct.",
|
|
||||||
"Multi-select is graded as an exact-set match: the user is correct only if they select every correct option and no incorrect ones.",
|
|
||||||
"There is no free-text mode. An 'I don't know' choice is ALWAYS added automatically — provide ONLY the real, gradable options (at least two). Never add your own uncertainty/opt-out option like 'I don't know', 'I'm not sure', or 'Not sure'; that is handled for you and a manual one would be redundant or gradable-as-wrong.",
|
|
||||||
"If a result comes back as dontKnow, the user honestly did not know and did NOT guess — treat it as a genuine knowledge gap to teach into, not as a wrong answer.",
|
|
||||||
"Any answer (right, wrong, or 'I don't know') may carry an optional free-text `note` the user typed in the always-present note field. When present it reflects what they were thinking or unsure about — read it and let it steer your follow-up. It is omitted entirely when empty.",
|
|
||||||
"Treat each wrong answer (distractor) as a diagnostic probe, not just filler: make it a specific, believable mistake the user might actually hold — a common misconception, or an adjacent/easily-confused concept — so that WHICH wrong answer they pick reveals WHICH nuance of their understanding is off. You learn far more from a targeted wrong choice than from a binary right/wrong, and the choice tells you exactly which gap to teach into next (and what the explanation should address).",
|
|
||||||
"Guardrail: every distractor must be unambiguously wrong on the intended reading — tempting, but a real error, not a defensible alternative. Don't drift into trick questions.",
|
|
||||||
"Anti-guessing hygiene: don't let the correct answer stand out by form (longest, most precise, most hedged, or the only one in the right format). Keep options similar in length, specificity, and phrasing so it can't be picked from shape alone.",
|
|
||||||
"Set multiSelect: true only when more than one option is correct.",
|
|
||||||
"Options are shuffled before display by default, so don't worry about which position you list the correct answer in. Set shuffle: false only when option order is meaningful (ordered values, or an 'All/None of the above' option that must stay last).",
|
|
||||||
"To probe nuance, ask several quick quiz questions and adapt each one based on the previous answers, rather than writing one giant question.",
|
|
||||||
"Don't leak the answer through formatting: keep option phrasing/length even and don't hint which is correct.",
|
|
||||||
"After calling quiz, END YOUR TURN. The graded answer arrives as the next user message; do not call any other tool while waiting.",
|
|
||||||
],
|
|
||||||
parameters: QuizParams,
|
|
||||||
|
|
||||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
||||||
const context = params.details?.trim() || undefined;
|
|
||||||
const explanation = params.explanation.trim();
|
|
||||||
const mode = params.multiSelect ? "multi-select" : "single-select";
|
|
||||||
|
|
||||||
let options;
|
|
||||||
try {
|
|
||||||
options = normalizeOptions(params.options);
|
|
||||||
} catch (e) {
|
|
||||||
return unavailableResult(params.question, mode, `quiz ${(e as Error).message}`, context);
|
|
||||||
}
|
|
||||||
if (options.length < 2) {
|
|
||||||
return unavailableResult(params.question, mode, "quiz requires at least two options", context);
|
|
||||||
}
|
|
||||||
if (params.shuffle !== false) {
|
|
||||||
options = shuffleOptions(options);
|
|
||||||
}
|
|
||||||
const { indices: correctIndices, error } = resolveCorrect(
|
|
||||||
params.correctAnswer as string | string[],
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
if (error) {
|
|
||||||
return unavailableResult(params.question, mode, `quiz ${error}`, context);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: QuestionData = {
|
|
||||||
id: `q_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`,
|
|
||||||
kind: "quiz",
|
|
||||||
question: params.question,
|
|
||||||
context,
|
|
||||||
mode,
|
|
||||||
options,
|
|
||||||
correctIndices,
|
|
||||||
explanation,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Persist the full question (answer included — custom entries are NOT
|
|
||||||
// sent to the LLM) and register it as pending, then return immediately.
|
|
||||||
pi.appendEntry(QUESTION_ENTRY, data);
|
|
||||||
bridge.publish(data);
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [{ type: "text" as const, text: PRESENTED_MESSAGE }],
|
|
||||||
details: {
|
|
||||||
status: "presented",
|
|
||||||
question: params.question,
|
|
||||||
context,
|
|
||||||
mode,
|
|
||||||
options: options.map((o, i) => ({ index: i + 1, label: o.label })),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
renderCall(args, theme) {
|
|
||||||
// Never render correctAnswer/explanation — it would leak the answer.
|
|
||||||
let text = theme.fg("toolTitle", theme.bold("quiz ")) + theme.fg("muted", args.question);
|
|
||||||
if (args.multiSelect) text += theme.fg("dim", " [multi-select]");
|
|
||||||
return new Text(text, 0, 0);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderResult(result, _options, theme) {
|
|
||||||
const details = result.details as { status?: string; message?: string } | undefined;
|
|
||||||
if (details?.status === "unavailable") {
|
|
||||||
return new Text(theme.fg("warning", details.message || "quiz unavailable"), 0, 0);
|
|
||||||
}
|
|
||||||
return new Text(theme.fg("muted", "Quiz displayed to the learner — awaiting their answer."), 0, 0);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,981 +0,0 @@
|
|||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
import {
|
|
||||||
Editor,
|
|
||||||
type EditorTheme,
|
|
||||||
Key,
|
|
||||||
Text,
|
|
||||||
matchesKey,
|
|
||||||
truncateToWidth,
|
|
||||||
wrapTextWithAnsi,
|
|
||||||
} from "@mariozechner/pi-tui";
|
|
||||||
import { Type } from "@sinclair/typebox";
|
|
||||||
import {
|
|
||||||
type OptionAnswer,
|
|
||||||
type QuizOption,
|
|
||||||
isCorrect,
|
|
||||||
normalizeOptions,
|
|
||||||
resolveCorrect,
|
|
||||||
shuffleOptions,
|
|
||||||
} from "./core/question-core.ts";
|
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
|
||||||
// quiz — a GRADED sibling of ask_user_question (TERMINAL mode: blocks on an
|
|
||||||
// interactive pi-tui component until the learner answers).
|
|
||||||
//
|
|
||||||
// Where ask_user_question collects a preference/decision with no notion of
|
|
||||||
// right or wrong, `quiz` poses a question that HAS a correct answer, grades the
|
|
||||||
// user's selection instantly, and shows tight feedback (✓/✗ + the correct
|
|
||||||
// answer + an optional explanation) to both the user and the agent.
|
|
||||||
//
|
|
||||||
// It is intentionally options-only: single-select or multi-select. There is no
|
|
||||||
// free-text mode and no "Other" option, because a free-text answer can't be
|
|
||||||
// graded against a correct index.
|
|
||||||
//
|
|
||||||
// Pure logic (normalize/shuffle/resolve/grade) lives in ./core/question-core.ts,
|
|
||||||
// shared with the web dispatch variant (quiz-web.ts).
|
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface DisplayOption extends QuizOption {
|
|
||||||
id: string;
|
|
||||||
index: number;
|
|
||||||
isSubmit?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The always-present "I don't know" choice. It is NOT a real option: it never
|
|
||||||
// participates in shuffling, has no correct-answer value, and produces a
|
|
||||||
// distinct signal (dontKnow) rather than a right/wrong grade — so an honest
|
|
||||||
// "I don't know" is never confused with a lucky or unlucky guess.
|
|
||||||
const DONT_KNOW_VALUE = "__dont_know__";
|
|
||||||
const DONT_KNOW_LABEL = "I don't know";
|
|
||||||
const DONT_KNOW_INDEX = 0; // real options are 1-based; submit uses -1
|
|
||||||
|
|
||||||
// Unified response from either ask* component. answers holds the real
|
|
||||||
// selections (empty when dontKnow); note is the optional free-text the user
|
|
||||||
// typed in the always-present note field (kept only when non-empty).
|
|
||||||
interface QuizResponse {
|
|
||||||
dontKnow: boolean;
|
|
||||||
note?: string;
|
|
||||||
answers: OptionAnswer[];
|
|
||||||
}
|
|
||||||
|
|
||||||
type QuizStatus = "answered" | "cancelled" | "unavailable";
|
|
||||||
type QuizMode = "single-select" | "multi-select";
|
|
||||||
|
|
||||||
const OptionSchema = Type.Object({
|
|
||||||
label: Type.String({ description: "Display label for the answer option." }),
|
|
||||||
value: Type.Optional(
|
|
||||||
Type.String({ description: "Optional machine-readable value returned for the option. Defaults to the label." }),
|
|
||||||
),
|
|
||||||
description: Type.Optional(Type.String({ description: "Optional extra detail shown below the option." })),
|
|
||||||
});
|
|
||||||
|
|
||||||
const QuizParams = Type.Object({
|
|
||||||
question: Type.String({
|
|
||||||
description: "The single quiz question to ask. Ask exactly one question per tool call.",
|
|
||||||
}),
|
|
||||||
details: Type.Optional(
|
|
||||||
Type.String({ description: "Optional extra context or instructions shown under the question." }),
|
|
||||||
),
|
|
||||||
options: Type.Array(OptionSchema, {
|
|
||||||
description:
|
|
||||||
"The answer options (2 or more). Options only — there is no free-text mode. Give each option a stable `value`; you reference the correct one by that value in correctAnswer.",
|
|
||||||
minItems: 2,
|
|
||||||
}),
|
|
||||||
multiSelect: Type.Optional(
|
|
||||||
Type.Boolean({ description: "Set to true when more than one option is correct and the user must select all of them." }),
|
|
||||||
),
|
|
||||||
correctAnswer: Type.Union([Type.String(), Type.Array(Type.String())], {
|
|
||||||
description:
|
|
||||||
'REQUIRED. The correct answer as the option value(s) — the `value` field of the option you intend. Single-select: a single string (e.g. "mercury"). Multi-select: an array of strings (e.g. ["belize", "niue"]); the user is only correct if their selection matches this set exactly. Always pass the value, not a position number — this is self-checking and prevents miscounting.',
|
|
||||||
}),
|
|
||||||
explanation: Type.String({
|
|
||||||
description:
|
|
||||||
"REQUIRED. Explanation revealed AFTER the user answers (shown whether they got it right or wrong). Use it to reinforce why the correct answer is correct.",
|
|
||||||
}),
|
|
||||||
shuffle: Type.Optional(
|
|
||||||
Type.Boolean({
|
|
||||||
description:
|
|
||||||
"Defaults to true: options are randomly reordered before display so the correct answer isn't always in the same position. Set to false only when option order is meaningful (e.g. ordered numeric values, or an 'All/None of the above' option that must stay last).",
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
function createEditorTheme(theme: any): EditorTheme {
|
|
||||||
return {
|
|
||||||
borderColor: (s) => theme.fg("accent", s),
|
|
||||||
selectList: {
|
|
||||||
selectedPrefix: (t) => theme.fg("accent", t),
|
|
||||||
selectedText: (t) => theme.fg("accent", t),
|
|
||||||
description: (t) => theme.fg("muted", t),
|
|
||||||
scrollInfo: (t) => theme.fg("dim", t),
|
|
||||||
noMatch: (t) => theme.fg("warning", t),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function addWrapped(lines: string[], text: string, width: number, indent = ""): void {
|
|
||||||
const contentWidth = Math.max(1, width - indent.length);
|
|
||||||
for (const line of wrapTextWithAnsi(text, contentWidth)) {
|
|
||||||
lines.push(truncateToWidth(`${indent}${line}`, width));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildStructuredResult(
|
|
||||||
status: QuizStatus,
|
|
||||||
question: string,
|
|
||||||
mode: QuizMode,
|
|
||||||
answers: OptionAnswer[],
|
|
||||||
correctIndices: number[],
|
|
||||||
correct: boolean | undefined,
|
|
||||||
explanation: string | undefined,
|
|
||||||
context?: string,
|
|
||||||
message?: string,
|
|
||||||
options?: DisplayedOption[],
|
|
||||||
dontKnow?: boolean,
|
|
||||||
note?: string,
|
|
||||||
): QuizResultDetails {
|
|
||||||
return { status, question, context, mode, answers, correctIndices, options, correct, dontKnow, note, explanation, message };
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelledResult(question: string, mode: QuizMode, correctIndices: number[], context?: string) {
|
|
||||||
const message = "User cancelled the quiz";
|
|
||||||
return {
|
|
||||||
content: [{ type: "text" as const, text: message }],
|
|
||||||
details: buildStructuredResult("cancelled", question, mode, [], correctIndices, undefined, undefined, context, message),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function unavailableResult(question: string, mode: QuizMode, message: string, correctIndices: number[], context?: string) {
|
|
||||||
return {
|
|
||||||
content: [{ type: "text" as const, text: message }],
|
|
||||||
details: buildStructuredResult("unavailable", question, mode, [], correctIndices, undefined, undefined, context, message),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatOptionRef(options: QuizOption[], index: number): string {
|
|
||||||
const opt = options.find((o, i) => i + 1 === index);
|
|
||||||
return `${index}. ${opt ? opt.label : "(unknown)"}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DisplayedOption {
|
|
||||||
index: number; // 1-based, in the final (possibly shuffled) display order
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface QuizResultDetails {
|
|
||||||
status: QuizStatus;
|
|
||||||
question: string;
|
|
||||||
context?: string;
|
|
||||||
mode: QuizMode;
|
|
||||||
answers: OptionAnswer[];
|
|
||||||
correctIndices: number[];
|
|
||||||
options?: DisplayedOption[]; // full option list in display order, for the transcript
|
|
||||||
correct?: boolean;
|
|
||||||
dontKnow?: boolean; // user selected "I don't know" instead of guessing
|
|
||||||
note?: string; // optional free-text from the always-present note field (any answer)
|
|
||||||
explanation?: string;
|
|
||||||
message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildResult(
|
|
||||||
question: string,
|
|
||||||
context: string | undefined,
|
|
||||||
mode: QuizMode,
|
|
||||||
options: QuizOption[],
|
|
||||||
response: QuizResponse,
|
|
||||||
correctIndices: number[],
|
|
||||||
explanation: string | undefined,
|
|
||||||
) {
|
|
||||||
const { dontKnow, note, answers } = response;
|
|
||||||
const selectedIndices = answers.map((a) => a.index);
|
|
||||||
// "I don't know" is never counted as correct — it's a distinct outcome.
|
|
||||||
const correct = dontKnow ? false : isCorrect(selectedIndices, correctIndices);
|
|
||||||
const correctStr = correctIndices.map((i) => formatOptionRef(options, i)).join(", ");
|
|
||||||
const displayedOptions: DisplayedOption[] = options.map((o, i) => ({ index: i + 1, label: o.label }));
|
|
||||||
|
|
||||||
let text: string;
|
|
||||||
if (dontKnow) {
|
|
||||||
// Make the signal explicit for the agent: the user did NOT guess, so this
|
|
||||||
// is a genuine knowledge gap, not a wrong answer to correct against.
|
|
||||||
text = `User selected "I don't know" — they did not attempt an answer (a genuine knowledge gap, not a wrong guess).`;
|
|
||||||
text += `\nCorrect: ${correctStr}`;
|
|
||||||
if (note) text += `\nUser's note: ${note}`;
|
|
||||||
} else {
|
|
||||||
const verdict = correct ? "correctly" : "incorrectly";
|
|
||||||
const selectedStr = answers.map((a) => `${a.index}. ${a.label}`).join(", ");
|
|
||||||
text = `User answered ${verdict}.\nSelected: ${selectedStr}\nCorrect: ${correctStr}`;
|
|
||||||
if (note) text += `\nUser's note: ${note}`;
|
|
||||||
}
|
|
||||||
if (explanation) text += `\nExplanation: ${explanation}`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [{ type: "text" as const, text }],
|
|
||||||
details: buildStructuredResult(
|
|
||||||
"answered",
|
|
||||||
question,
|
|
||||||
mode,
|
|
||||||
answers,
|
|
||||||
correctIndices,
|
|
||||||
correct,
|
|
||||||
explanation,
|
|
||||||
context,
|
|
||||||
undefined,
|
|
||||||
displayedOptions,
|
|
||||||
dontKnow,
|
|
||||||
note,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shared feedback block, rendered after the user submits.
|
|
||||||
function renderFeedback(
|
|
||||||
lines: string[],
|
|
||||||
theme: any,
|
|
||||||
width: number,
|
|
||||||
options: QuizOption[],
|
|
||||||
selectedIndices: number[],
|
|
||||||
correctIndices: number[],
|
|
||||||
explanation: string | undefined,
|
|
||||||
dontKnow = false,
|
|
||||||
note?: string,
|
|
||||||
): void {
|
|
||||||
const add = (text: string) => lines.push(truncateToWidth(text, width));
|
|
||||||
const correct = !dontKnow && isCorrect(selectedIndices, correctIndices);
|
|
||||||
const selectedSet = new Set(selectedIndices);
|
|
||||||
const correctSet = new Set(correctIndices);
|
|
||||||
|
|
||||||
lines.push("");
|
|
||||||
for (let i = 0; i < options.length; i++) {
|
|
||||||
const index = i + 1;
|
|
||||||
const opt = options[i];
|
|
||||||
const isSelected = selectedSet.has(index);
|
|
||||||
const isKey = correctSet.has(index);
|
|
||||||
let marker: string;
|
|
||||||
let color: string;
|
|
||||||
if (dontKnow) {
|
|
||||||
// No guess was made — only reveal the correct answer(s); never show ✗.
|
|
||||||
marker = isKey ? "✓" : " ";
|
|
||||||
color = isKey ? "success" : "dim";
|
|
||||||
} else if (isSelected && isKey) {
|
|
||||||
marker = "✓";
|
|
||||||
color = "success";
|
|
||||||
} else if (isSelected && !isKey) {
|
|
||||||
marker = "✗";
|
|
||||||
color = "error";
|
|
||||||
} else if (!isSelected && isKey) {
|
|
||||||
// correct answer the user missed
|
|
||||||
marker = "✓";
|
|
||||||
color = "success";
|
|
||||||
} else {
|
|
||||||
marker = " ";
|
|
||||||
color = "dim";
|
|
||||||
}
|
|
||||||
add(theme.fg(color, ` ${marker} ${index}. ${opt.label}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push("");
|
|
||||||
if (dontKnow) {
|
|
||||||
add(theme.fg("warning", " · You said: I don't know"));
|
|
||||||
const correctStr = correctIndices.map((i) => formatOptionRef(options, i)).join(", ");
|
|
||||||
addWrapped(lines, theme.fg("muted", `Correct answer: ${correctStr}`), width, " ");
|
|
||||||
} else if (correct) {
|
|
||||||
add(theme.fg("success", " ✓ Correct!"));
|
|
||||||
} else {
|
|
||||||
add(theme.fg("error", " ✗ Incorrect."));
|
|
||||||
const correctStr = correctIndices.map((i) => formatOptionRef(options, i)).join(", ");
|
|
||||||
addWrapped(lines, theme.fg("muted", `Correct answer: ${correctStr}`), width, " ");
|
|
||||||
}
|
|
||||||
if (note) {
|
|
||||||
addWrapped(lines, theme.fg("muted", `Your note: ${note}`), width, " ");
|
|
||||||
}
|
|
||||||
if (explanation) {
|
|
||||||
lines.push("");
|
|
||||||
addWrapped(lines, theme.fg("text", explanation), width, " ");
|
|
||||||
}
|
|
||||||
lines.push("");
|
|
||||||
add(theme.fg("dim", " Enter/Esc to continue"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Top border + question + optional context. Shared by both components.
|
|
||||||
function pushHeader(lines: string[], theme: any, width: number, question: string, context: string | undefined): void {
|
|
||||||
lines.push(truncateToWidth(theme.fg("accent", "─".repeat(width)), width));
|
|
||||||
addWrapped(lines, theme.fg("text", question), width, " ");
|
|
||||||
if (context) {
|
|
||||||
lines.push("");
|
|
||||||
addWrapped(lines, theme.fg("muted", context), width, " ");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The "I don't know" row in the selection list — visually separated and dimmed
|
|
||||||
// so it reads as distinct from the real, gradable options.
|
|
||||||
function pushDontKnowRow(lines: string[], theme: any, width: number, focused: boolean): void {
|
|
||||||
lines.push("");
|
|
||||||
const prefix = focused ? theme.fg("accent", "> ") : " ";
|
|
||||||
const styled = focused ? theme.fg("accent", DONT_KNOW_LABEL) : theme.fg("dim", DONT_KNOW_LABEL);
|
|
||||||
lines.push(truncateToWidth(`${prefix}${styled}`, width));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persistent, always-present note field rendered under the options during the
|
|
||||||
// select phase. Applies to ANY answer (including "I don't know") and is only
|
|
||||||
// surfaced to the agent when non-empty.
|
|
||||||
function pushNoteField(lines: string[], theme: any, width: number, editor: Editor, focused: boolean): void {
|
|
||||||
lines.push("");
|
|
||||||
const label = focused ? theme.fg("accent", "Note (optional):") : theme.fg("muted", "Note (optional):");
|
|
||||||
addWrapped(lines, label, width, " ");
|
|
||||||
for (const line of editor.render(width)) lines.push(line);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the note Editor. `disableSubmit` is set because Enter must NOT submit
|
|
||||||
// here: the editor's submit path clears the buffer, which would wipe the note.
|
|
||||||
// Instead the host intercepts Enter to return focus to the options while
|
|
||||||
// keeping the text. Ctrl+J still inserts a newline (pi convention), so
|
|
||||||
// multi-line notes work.
|
|
||||||
function makeNoteEditor(tui: any, theme: any): Editor {
|
|
||||||
const editor = new Editor(tui, createEditorTheme(theme));
|
|
||||||
editor.focused = false;
|
|
||||||
editor.disableSubmit = true;
|
|
||||||
return editor;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function askSingleChoice(
|
|
||||||
ctx: any,
|
|
||||||
question: string,
|
|
||||||
context: string | undefined,
|
|
||||||
options: QuizOption[],
|
|
||||||
correctIndices: number[],
|
|
||||||
explanation: string | undefined,
|
|
||||||
): Promise<QuizResponse | null> {
|
|
||||||
const allOptions: DisplayOption[] = options.map((option, index) => ({
|
|
||||||
...option,
|
|
||||||
id: `option:${index}`,
|
|
||||||
index: index + 1,
|
|
||||||
}));
|
|
||||||
const dontKnowNav = allOptions.length; // nav index of the "I don't know" row
|
|
||||||
|
|
||||||
return ctx.ui.custom<QuizResponse | null>(
|
|
||||||
(tui: any, theme: any, _kb: any, done: (result: QuizResponse | null) => void) => {
|
|
||||||
let optionIndex = 0;
|
|
||||||
let phase: "select" | "feedback" = "select";
|
|
||||||
let focus: "options" | "note" = "options";
|
|
||||||
let chosen: OptionAnswer | null = null;
|
|
||||||
let dontKnow = false;
|
|
||||||
const editor = makeNoteEditor(tui, theme);
|
|
||||||
let cachedLines: string[] | undefined;
|
|
||||||
let cachedWidth = -1;
|
|
||||||
|
|
||||||
function refresh() {
|
|
||||||
cachedLines = undefined;
|
|
||||||
tui.requestRender();
|
|
||||||
}
|
|
||||||
|
|
||||||
function noteText(): string | undefined {
|
|
||||||
const t = editor.getText().trim();
|
|
||||||
return t.length ? t : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toOptions() {
|
|
||||||
focus = "options";
|
|
||||||
editor.focused = false;
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
function response(): QuizResponse {
|
|
||||||
const note = noteText();
|
|
||||||
return dontKnow
|
|
||||||
? { dontKnow: true, note, answers: [] }
|
|
||||||
: { dontKnow: false, note, answers: chosen ? [chosen] : [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleInput(data: string) {
|
|
||||||
if (phase === "feedback") {
|
|
||||||
if (matchesKey(data, Key.enter) || matchesKey(data, Key.escape)) {
|
|
||||||
done(response());
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tab toggles focus between the options list and the note field.
|
|
||||||
if (matchesKey(data, Key.tab)) {
|
|
||||||
focus = focus === "options" ? "note" : "options";
|
|
||||||
editor.focused = focus === "note";
|
|
||||||
refresh();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (focus === "note") {
|
|
||||||
// Enter and Esc both return to the options and keep the note text.
|
|
||||||
// (Enter must be intercepted here: the editor's own submit clears
|
|
||||||
// the buffer. Ctrl+J still reaches the editor as a newline.)
|
|
||||||
if (matchesKey(data, Key.enter) || matchesKey(data, Key.escape)) {
|
|
||||||
toOptions();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
editor.handleInput(data);
|
|
||||||
tui.requestRender();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// focus === "options"
|
|
||||||
if (matchesKey(data, Key.up)) {
|
|
||||||
optionIndex = Math.max(0, optionIndex - 1);
|
|
||||||
refresh();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (matchesKey(data, Key.down)) {
|
|
||||||
optionIndex = Math.min(dontKnowNav, optionIndex + 1);
|
|
||||||
refresh();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (matchesKey(data, Key.enter)) {
|
|
||||||
if (optionIndex === dontKnowNav) {
|
|
||||||
dontKnow = true;
|
|
||||||
chosen = null;
|
|
||||||
} else {
|
|
||||||
const selected = allOptions[optionIndex];
|
|
||||||
chosen = { label: selected.label, value: selected.value, index: selected.index };
|
|
||||||
dontKnow = false;
|
|
||||||
}
|
|
||||||
phase = "feedback";
|
|
||||||
refresh();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (matchesKey(data, Key.escape)) {
|
|
||||||
done(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function render(width: number): string[] {
|
|
||||||
// The cache MUST be keyed on width: pi-tui calls requestRender() but NOT
|
|
||||||
// invalidate() on terminal resize, so render() can be re-entered with a
|
|
||||||
// new width. Returning stale wider lines trips the TUI width guard and
|
|
||||||
// crashes the process.
|
|
||||||
if (cachedLines && cachedWidth === width) return cachedLines;
|
|
||||||
|
|
||||||
const lines: string[] = [];
|
|
||||||
const add = (text: string) => lines.push(truncateToWidth(text, width));
|
|
||||||
pushHeader(lines, theme, width, question, context);
|
|
||||||
|
|
||||||
if (phase === "feedback") {
|
|
||||||
renderFeedback(
|
|
||||||
lines,
|
|
||||||
theme,
|
|
||||||
width,
|
|
||||||
options,
|
|
||||||
chosen ? [chosen.index] : [],
|
|
||||||
correctIndices,
|
|
||||||
explanation,
|
|
||||||
dontKnow,
|
|
||||||
noteText(),
|
|
||||||
);
|
|
||||||
add(theme.fg("accent", "─".repeat(width)));
|
|
||||||
cachedLines = lines;
|
|
||||||
cachedWidth = width;
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push("");
|
|
||||||
for (let i = 0; i < allOptions.length; i++) {
|
|
||||||
const option = allOptions[i];
|
|
||||||
const selected = focus === "options" && i === optionIndex;
|
|
||||||
const prefix = selected ? theme.fg("accent", "> ") : " ";
|
|
||||||
const label = `${option.index}. ${option.label}`;
|
|
||||||
const styled = selected ? theme.fg("accent", label) : theme.fg("text", label);
|
|
||||||
add(`${prefix}${styled}`);
|
|
||||||
if (option.description) {
|
|
||||||
addWrapped(lines, theme.fg("muted", option.description), width, " ");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pushDontKnowRow(lines, theme, width, focus === "options" && optionIndex === dontKnowNav);
|
|
||||||
|
|
||||||
pushNoteField(lines, theme, width, editor, focus === "note");
|
|
||||||
|
|
||||||
lines.push("");
|
|
||||||
if (focus === "note") {
|
|
||||||
add(theme.fg("dim", " Type note • Ctrl+J newline • Enter back to options • Tab options • Esc back"));
|
|
||||||
} else {
|
|
||||||
add(theme.fg("dim", " ↑↓ navigate • Enter answer • Tab note • Esc cancel"));
|
|
||||||
}
|
|
||||||
add(theme.fg("accent", "─".repeat(width)));
|
|
||||||
// Not cached when the note is focused: the editor renders a live cursor.
|
|
||||||
if (focus !== "note") {
|
|
||||||
cachedLines = lines;
|
|
||||||
cachedWidth = width;
|
|
||||||
}
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
render,
|
|
||||||
invalidate: () => {
|
|
||||||
cachedLines = undefined;
|
|
||||||
editor.invalidate();
|
|
||||||
},
|
|
||||||
handleInput,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function askMultiChoice(
|
|
||||||
ctx: any,
|
|
||||||
question: string,
|
|
||||||
context: string | undefined,
|
|
||||||
options: QuizOption[],
|
|
||||||
correctIndices: number[],
|
|
||||||
explanation: string | undefined,
|
|
||||||
): Promise<QuizResponse | null> {
|
|
||||||
const DONT_KNOW_ID = "dont-know";
|
|
||||||
const choiceItems: DisplayOption[] = options.map((option, index) => ({
|
|
||||||
...option,
|
|
||||||
id: `option:${index}`,
|
|
||||||
index: index + 1,
|
|
||||||
}));
|
|
||||||
const dontKnowItem: DisplayOption = {
|
|
||||||
id: DONT_KNOW_ID,
|
|
||||||
label: DONT_KNOW_LABEL,
|
|
||||||
value: DONT_KNOW_VALUE,
|
|
||||||
index: DONT_KNOW_INDEX,
|
|
||||||
};
|
|
||||||
const submitItem: DisplayOption = { id: "submit", label: "Submit", value: "__submit__", index: -1, isSubmit: true };
|
|
||||||
const allItems: DisplayOption[] = [...choiceItems, dontKnowItem, submitItem];
|
|
||||||
|
|
||||||
return ctx.ui.custom<QuizResponse | null>(
|
|
||||||
(tui: any, theme: any, _kb: any, done: (result: QuizResponse | null) => void) => {
|
|
||||||
let optionIndex = 0;
|
|
||||||
let phase: "select" | "feedback" = "select";
|
|
||||||
let focus: "options" | "note" = "options";
|
|
||||||
const editor = makeNoteEditor(tui, theme);
|
|
||||||
let cachedLines: string[] | undefined;
|
|
||||||
let cachedWidth = -1;
|
|
||||||
const selected = new Map<string, OptionAnswer>();
|
|
||||||
|
|
||||||
function refresh() {
|
|
||||||
cachedLines = undefined;
|
|
||||||
tui.requestRender();
|
|
||||||
}
|
|
||||||
|
|
||||||
function noteText(): string | undefined {
|
|
||||||
const t = editor.getText().trim();
|
|
||||||
return t.length ? t : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toOptions() {
|
|
||||||
focus = "options";
|
|
||||||
editor.focused = false;
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
const choseDontKnow = () => selected.has(DONT_KNOW_ID);
|
|
||||||
const realAnswers = () =>
|
|
||||||
sortAnswers(Array.from(selected.values()).filter((a) => a.index !== DONT_KNOW_INDEX));
|
|
||||||
|
|
||||||
function response(): QuizResponse {
|
|
||||||
const note = noteText();
|
|
||||||
return choseDontKnow()
|
|
||||||
? { dontKnow: true, note, answers: [] }
|
|
||||||
: { dontKnow: false, note, answers: realAnswers() };
|
|
||||||
}
|
|
||||||
|
|
||||||
// "I don't know" is exclusive: choosing it clears real selections, and
|
|
||||||
// choosing any real option clears "I don't know".
|
|
||||||
function toggleOption(item: DisplayOption) {
|
|
||||||
if (item.id === DONT_KNOW_ID) {
|
|
||||||
if (selected.has(DONT_KNOW_ID)) {
|
|
||||||
selected.delete(DONT_KNOW_ID);
|
|
||||||
} else {
|
|
||||||
selected.clear();
|
|
||||||
selected.set(DONT_KNOW_ID, { label: item.label, value: item.value, index: item.index });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
selected.delete(DONT_KNOW_ID);
|
|
||||||
if (selected.has(item.id)) {
|
|
||||||
selected.delete(item.id);
|
|
||||||
} else {
|
|
||||||
selected.set(item.id, { label: item.label, value: item.value, index: item.index });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
function submit() {
|
|
||||||
if (selected.size === 0) return;
|
|
||||||
phase = "feedback";
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleInput(data: string) {
|
|
||||||
if (phase === "feedback") {
|
|
||||||
if (matchesKey(data, Key.enter) || matchesKey(data, Key.escape)) {
|
|
||||||
done(response());
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tab toggles focus between the options list and the note field.
|
|
||||||
if (matchesKey(data, Key.tab)) {
|
|
||||||
focus = focus === "options" ? "note" : "options";
|
|
||||||
editor.focused = focus === "note";
|
|
||||||
refresh();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (focus === "note") {
|
|
||||||
// Enter and Esc both return to the options and keep the note text.
|
|
||||||
// (Enter must be intercepted here: the editor's own submit clears
|
|
||||||
// the buffer. Ctrl+J still reaches the editor as a newline.)
|
|
||||||
if (matchesKey(data, Key.enter) || matchesKey(data, Key.escape)) {
|
|
||||||
toOptions();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
editor.handleInput(data);
|
|
||||||
tui.requestRender();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// focus === "options"
|
|
||||||
if (matchesKey(data, Key.up)) {
|
|
||||||
optionIndex = Math.max(0, optionIndex - 1);
|
|
||||||
refresh();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (matchesKey(data, Key.down)) {
|
|
||||||
optionIndex = Math.min(allItems.length - 1, optionIndex + 1);
|
|
||||||
refresh();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const current = allItems[optionIndex];
|
|
||||||
if (matchesKey(data, Key.space)) {
|
|
||||||
if (current.isSubmit) return;
|
|
||||||
toggleOption(current);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matchesKey(data, Key.enter)) {
|
|
||||||
if (current.isSubmit) {
|
|
||||||
submit();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
toggleOption(current);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matchesKey(data, Key.escape)) {
|
|
||||||
done(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function render(width: number): string[] {
|
|
||||||
// The cache MUST be keyed on width: pi-tui calls requestRender() but NOT
|
|
||||||
// invalidate() on terminal resize, so render() can be re-entered with a
|
|
||||||
// new width. Returning stale wider lines trips the TUI width guard and
|
|
||||||
// crashes the process.
|
|
||||||
if (cachedLines && cachedWidth === width) return cachedLines;
|
|
||||||
|
|
||||||
const lines: string[] = [];
|
|
||||||
const add = (text: string) => lines.push(truncateToWidth(text, width));
|
|
||||||
pushHeader(lines, theme, width, question, context);
|
|
||||||
|
|
||||||
if (phase === "feedback") {
|
|
||||||
renderFeedback(
|
|
||||||
lines,
|
|
||||||
theme,
|
|
||||||
width,
|
|
||||||
options,
|
|
||||||
realAnswers().map((a) => a.index),
|
|
||||||
correctIndices,
|
|
||||||
explanation,
|
|
||||||
choseDontKnow(),
|
|
||||||
noteText(),
|
|
||||||
);
|
|
||||||
add(theme.fg("accent", "─".repeat(width)));
|
|
||||||
cachedLines = lines;
|
|
||||||
cachedWidth = width;
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push("");
|
|
||||||
for (let i = 0; i < allItems.length; i++) {
|
|
||||||
const item = allItems[i];
|
|
||||||
const isFocused = focus === "options" && i === optionIndex;
|
|
||||||
const prefix = isFocused ? theme.fg("accent", "> ") : " ";
|
|
||||||
|
|
||||||
if (item.isSubmit) {
|
|
||||||
const label = selected.size > 0 ? `✓ ${item.label} (${selected.size} selected)` : `○ ${item.label}`;
|
|
||||||
const styled = isFocused
|
|
||||||
? theme.fg("accent", label)
|
|
||||||
: theme.fg(selected.size > 0 ? "success" : "dim", label);
|
|
||||||
add(`${prefix}${styled}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.id === DONT_KNOW_ID) {
|
|
||||||
lines.push(""); // visual separation from the real options
|
|
||||||
const checked = selected.has(item.id);
|
|
||||||
const label = `${checked ? "[x]" : "[ ]"} ${item.label}`;
|
|
||||||
const styled = isFocused ? theme.fg("accent", label) : theme.fg(checked ? "warning" : "dim", label);
|
|
||||||
add(`${prefix}${styled}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const checked = selected.has(item.id);
|
|
||||||
const marker = checked ? "[x]" : "[ ]";
|
|
||||||
const label = `${marker} ${item.index}. ${item.label}`;
|
|
||||||
const styled = isFocused ? theme.fg("accent", label) : theme.fg(checked ? "success" : "text", label);
|
|
||||||
add(`${prefix}${styled}`);
|
|
||||||
if (item.description) {
|
|
||||||
addWrapped(lines, theme.fg("muted", item.description), width, " ");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pushNoteField(lines, theme, width, editor, focus === "note");
|
|
||||||
|
|
||||||
lines.push("");
|
|
||||||
if (selected.size === 0) {
|
|
||||||
add(theme.fg("warning", " Select at least one answer before submitting."));
|
|
||||||
}
|
|
||||||
if (focus === "note") {
|
|
||||||
add(theme.fg("dim", " Type note • Ctrl+J newline • Enter back to options • Tab options • Esc back"));
|
|
||||||
} else {
|
|
||||||
add(theme.fg("dim", " ↑↓ navigate • Space toggle • Enter submit • Tab note • Esc cancel"));
|
|
||||||
}
|
|
||||||
add(theme.fg("accent", "─".repeat(width)));
|
|
||||||
// Not cached when the note is focused: the editor renders a live cursor.
|
|
||||||
if (focus !== "note") {
|
|
||||||
cachedLines = lines;
|
|
||||||
cachedWidth = width;
|
|
||||||
}
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
render,
|
|
||||||
invalidate: () => {
|
|
||||||
cachedLines = undefined;
|
|
||||||
editor.invalidate();
|
|
||||||
},
|
|
||||||
handleInput,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortAnswers(answers: OptionAnswer[]): OptionAnswer[] {
|
|
||||||
return [...answers].sort((a, b) => a.index - b.index);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shared UI mutex. ctx.ui.custom()/editor can only handle one active call at
|
|
||||||
// a time, so ALL pop-up-style tools (quiz, ask_user_question, ...) must
|
|
||||||
// serialize against each other, not just against themselves. We stash one
|
|
||||||
// mutex on globalThis so separate extension files can share it without
|
|
||||||
// importing each other.
|
|
||||||
const SHARED_UI_LOCK_KEY = "__piSharedUiLock";
|
|
||||||
function getSharedUiLock() {
|
|
||||||
const g = globalThis as any;
|
|
||||||
if (!g[SHARED_UI_LOCK_KEY]) {
|
|
||||||
let chain: Promise<void> = Promise.resolve();
|
|
||||||
g[SHARED_UI_LOCK_KEY] = {
|
|
||||||
withLock<T>(fn: () => T | Promise<T>): Promise<T> {
|
|
||||||
const prev = chain;
|
|
||||||
let release: () => void;
|
|
||||||
chain = new Promise<void>((r) => { release = r; });
|
|
||||||
return prev.then(fn).finally(() => release!());
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return g[SHARED_UI_LOCK_KEY] as { withLock<T>(fn: () => T | Promise<T>): Promise<T> };
|
|
||||||
}
|
|
||||||
const sharedUiLock = getSharedUiLock();
|
|
||||||
|
|
||||||
function withUILock<T>(fn: () => Promise<T>): Promise<T> {
|
|
||||||
return sharedUiLock.withLock(fn);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function quiz(pi: ExtensionAPI) {
|
|
||||||
pi.registerTool({
|
|
||||||
name: "quiz",
|
|
||||||
label: "quiz",
|
|
||||||
description:
|
|
||||||
"Ask the user a GRADED question with a known correct answer, then instantly grade and give feedback. Unlike ask_user_question (which collects preferences/decisions with no right answer), quiz always has a correct answer supplied by you, marks the user's selection right/wrong (✓/✗), reveals the correct answer, and can show an explanation. Use it to (1) assess what the learner already understands before teaching, and (2) run tight practice/retrieval loops after explaining, or probe understanding whenever you're unsure they've got it. Options-only: single-select or multi-select, plus an automatic 'I don't know' choice so the user can signal a genuine gap instead of guessing. An always-present optional note field (Tab to focus it) lets the user attach a free-text note to ANY answer; it reaches you only when non-empty. No free-text answers — for non-graded questions use ask_user_question instead.",
|
|
||||||
promptSnippet:
|
|
||||||
"Use the quiz tool to test the user with a graded multiple-choice or multi-select question (required correct answer + required explanation). For non-graded questions, use ask_user_question.",
|
|
||||||
promptGuidelines: [
|
|
||||||
"quiz is GRADED; ask_user_question is not. If the question has a correct answer, use quiz. If you just need a preference, decision, or open-ended input, use ask_user_question.",
|
|
||||||
'correctAnswer is REQUIRED and is the option value, not a position number. Single-select: one string (e.g. "mercury"). Multi-select: an array of strings (e.g. ["belize", "niue"]).',
|
|
||||||
"Always pass the option's `value` string as correctAnswer — it is self-checking and prevents miscounting positions. A value that matches no option is a hard error.",
|
|
||||||
"explanation is REQUIRED — always say why the correct answer is correct.",
|
|
||||||
"Multi-select is graded as an exact-set match: the user is correct only if they select every correct option and no incorrect ones.",
|
|
||||||
"There is no free-text mode. An 'I don't know' choice is ALWAYS added automatically — provide ONLY the real, gradable options (at least two). Never add your own uncertainty/opt-out option like 'I don't know', 'I'm not sure', or 'Not sure'; that is handled for you and a manual one would be redundant or gradable-as-wrong.",
|
|
||||||
"If a result comes back as dontKnow, the user honestly did not know and did NOT guess — treat it as a genuine knowledge gap to teach into, not as a wrong answer.",
|
|
||||||
"Any answer (right, wrong, or 'I don't know') may carry an optional free-text `note` the user typed in the always-present note field. When present it reflects what they were thinking or unsure about — read it and let it steer your follow-up. It is omitted entirely when empty.",
|
|
||||||
"Treat each wrong answer (distractor) as a diagnostic probe, not just filler: make it a specific, believable mistake the user might actually hold — a common misconception, or an adjacent/easily-confused concept — so that WHICH wrong answer they pick reveals WHICH nuance of their understanding is off. You learn far more from a targeted wrong choice than from a binary right/wrong, and the choice tells you exactly which gap to teach into next (and what the explanation should address).",
|
|
||||||
"Guardrail: every distractor must be unambiguously wrong on the intended reading — tempting, but a real error, not a defensible alternative. Don't drift into trick questions.",
|
|
||||||
"Anti-guessing hygiene: don't let the correct answer stand out by form (longest, most precise, most hedged, or the only one in the right format). Keep options similar in length, specificity, and phrasing so it can't be picked from shape alone.",
|
|
||||||
"Set multiSelect: true only when more than one option is correct.",
|
|
||||||
"Options are shuffled before display by default, so don't worry about which position you list the correct answer in. Set shuffle: false only when option order is meaningful (ordered values, or an 'All/None of the above' option that must stay last).",
|
|
||||||
"To probe nuance, ask several quick quiz questions and adapt each one based on the previous answers, rather than writing one giant question.",
|
|
||||||
"Don't leak the answer through formatting: keep option phrasing/length even and don't hint which is correct.",
|
|
||||||
],
|
|
||||||
parameters: QuizParams,
|
|
||||||
|
|
||||||
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
||||||
const context = params.details?.trim() || undefined;
|
|
||||||
const explanation = params.explanation.trim();
|
|
||||||
const mode: QuizMode = params.multiSelect ? "multi-select" : "single-select";
|
|
||||||
|
|
||||||
let options: QuizOption[];
|
|
||||||
try {
|
|
||||||
options = normalizeOptions(params.options);
|
|
||||||
} catch (e) {
|
|
||||||
return unavailableResult(params.question, mode, `quiz ${(e as Error).message}`, [], context);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shuffle for display (default on) BEFORE resolving correct indices, so
|
|
||||||
// grading matches the order the user sees.
|
|
||||||
if (params.shuffle !== false) {
|
|
||||||
options = shuffleOptions(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit the true (post-shuffle) display order immediately, before the UI
|
|
||||||
// blocks on the user's answer. Listeners such as md-log rely on this to
|
|
||||||
// show the question in the SAME order the user actually sees it, instead
|
|
||||||
// of the pre-shuffle order the agent originally wrote in its tool call.
|
|
||||||
// Deliberately omits correctIndices/explanation — this fires before the
|
|
||||||
// user has answered and must not leak the answer.
|
|
||||||
onUpdate?.({
|
|
||||||
content: [{ type: "text", text: "Awaiting user response..." }],
|
|
||||||
details: { options: options.map((o, i) => ({ index: i + 1, label: o.label })) },
|
|
||||||
});
|
|
||||||
|
|
||||||
const { indices: correctIndices, error: correctError } = resolveCorrect(
|
|
||||||
params.correctAnswer as string | string[],
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (signal?.aborted) {
|
|
||||||
return cancelledResult(params.question, mode, correctIndices, context);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.length < 2) {
|
|
||||||
return unavailableResult(
|
|
||||||
params.question,
|
|
||||||
mode,
|
|
||||||
"quiz requires at least two options",
|
|
||||||
correctIndices,
|
|
||||||
context,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (correctError) {
|
|
||||||
return unavailableResult(params.question, mode, `quiz ${correctError}`, correctIndices, context);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ctx.hasUI) {
|
|
||||||
return unavailableResult(params.question, mode, "quiz requires interactive mode UI", correctIndices, context);
|
|
||||||
}
|
|
||||||
|
|
||||||
return withUILock(async () => {
|
|
||||||
const response =
|
|
||||||
mode === "single-select"
|
|
||||||
? await askSingleChoice(ctx, params.question, context, options, correctIndices, explanation)
|
|
||||||
: await askMultiChoice(ctx, params.question, context, options, correctIndices, explanation);
|
|
||||||
if (!response) {
|
|
||||||
return cancelledResult(params.question, mode, correctIndices, context);
|
|
||||||
}
|
|
||||||
return buildResult(params.question, context, mode, options, response, correctIndices, explanation);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
renderCall(args, theme) {
|
|
||||||
// NOTE: never render correctAnswer or explanation here — it would leak
|
|
||||||
// the answer into the transcript before the user responds. We also do NOT
|
|
||||||
// enumerate the options here: they are shuffled at execute time, so any
|
|
||||||
// order shown during streaming would be stale/misleading. The full option
|
|
||||||
// list is rendered — in its true display order — by renderResult after the
|
|
||||||
// user answers.
|
|
||||||
const options = normalizeOptions(
|
|
||||||
args.options as Array<{ label: string; value?: string; description?: string }> | undefined,
|
|
||||||
);
|
|
||||||
let text = theme.fg("toolTitle", theme.bold("quiz ")) + theme.fg("muted", args.question);
|
|
||||||
if (args.multiSelect) {
|
|
||||||
text += theme.fg("dim", " [multi-select]");
|
|
||||||
}
|
|
||||||
if (options.length > 0) {
|
|
||||||
const noun = options.length === 1 ? "option" : "options";
|
|
||||||
text += theme.fg("dim", ` (${options.length} ${noun})`);
|
|
||||||
}
|
|
||||||
return new Text(text, 0, 0);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderResult(result, _options, theme) {
|
|
||||||
const details = result.details as QuizResultDetails | undefined;
|
|
||||||
if (!details) {
|
|
||||||
const first = result.content[0];
|
|
||||||
return new Text(first?.type === "text" ? first.text : "", 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (details.status === "cancelled") {
|
|
||||||
return new Text(theme.fg("warning", details.message || "Cancelled"), 0, 0);
|
|
||||||
}
|
|
||||||
if (details.status === "unavailable") {
|
|
||||||
return new Text(theme.fg("warning", details.message || "quiz unavailable"), 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const correctSet = new Set(details.correctIndices);
|
|
||||||
const selectedSet = new Set(details.answers.map((a) => a.index));
|
|
||||||
const lines: string[] = [];
|
|
||||||
|
|
||||||
// Full option list in the true (shuffled) display order, with ✓/✗ marks.
|
|
||||||
// Falls back to just the selected answers for older results that predate
|
|
||||||
// details.options.
|
|
||||||
const displayed =
|
|
||||||
details.options && details.options.length > 0
|
|
||||||
? details.options
|
|
||||||
: details.answers.map((a) => ({ index: a.index, label: a.label }));
|
|
||||||
|
|
||||||
for (const opt of displayed) {
|
|
||||||
const isSelected = selectedSet.has(opt.index);
|
|
||||||
const isKey = correctSet.has(opt.index);
|
|
||||||
let mark: string;
|
|
||||||
let body: string;
|
|
||||||
if (details.dontKnow) {
|
|
||||||
// No guess — only reveal the correct answer(s); never show ✗.
|
|
||||||
mark = isKey ? theme.fg("success", "✓ ") : " ";
|
|
||||||
body = isKey ? theme.fg("success", `${opt.index}. ${opt.label}`) : theme.fg("dim", `${opt.index}. ${opt.label}`);
|
|
||||||
} else if (isSelected && isKey) {
|
|
||||||
mark = theme.fg("success", "✓ ");
|
|
||||||
body = theme.fg("accent", `${opt.index}. ${opt.label}`);
|
|
||||||
} else if (isSelected && !isKey) {
|
|
||||||
mark = theme.fg("error", "✗ ");
|
|
||||||
body = theme.fg("error", `${opt.index}. ${opt.label}`);
|
|
||||||
} else if (!isSelected && isKey) {
|
|
||||||
mark = theme.fg("success", "✓ ");
|
|
||||||
body = theme.fg("success", `${opt.index}. ${opt.label}`);
|
|
||||||
} else {
|
|
||||||
mark = " ";
|
|
||||||
body = theme.fg("dim", `${opt.index}. ${opt.label}`);
|
|
||||||
}
|
|
||||||
lines.push(`${mark}${body}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push("");
|
|
||||||
const verdict = details.dontKnow
|
|
||||||
? theme.fg("warning", "I don't know")
|
|
||||||
: details.correct
|
|
||||||
? theme.fg("success", "Correct!")
|
|
||||||
: theme.fg("error", "Incorrect");
|
|
||||||
lines.push(verdict);
|
|
||||||
|
|
||||||
if (details.note) {
|
|
||||||
lines.push(theme.fg("muted", `Note: ${details.note}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (details.explanation) {
|
|
||||||
lines.push(theme.fg("muted", details.explanation));
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Text(lines.join("\n"), 0, 0);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
import "./.next/types/routes.d.ts";
|
||||||
|
import "./.next/types/root-params.d.ts";
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
// Framework-free core lives in src/ (plain TS, imported by routes and CLI alike).
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+4513
-4681
File diff suppressed because it is too large
Load Diff
+42
-31
@@ -1,47 +1,58 @@
|
|||||||
{
|
{
|
||||||
"name": "learning-harness",
|
"name": "learning-harness",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "A standalone learning harness built on the pi agent SDK: teacher agent, session artifacts, and a derived knowledge graph of what the learner knows.",
|
"description": "A standalone learning harness: a teacher agent (AI SDK) with quiz/ask interactions, session artifacts, and a derived knowledge graph of what the learner knows.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"teach": "tsx src/cli.ts teach",
|
"dev": "next dev",
|
||||||
"doctor": "tsx src/cli.ts doctor",
|
"build": "next build",
|
||||||
"doctor:web": "tsx src/cli.ts doctor --web",
|
"start": "next start",
|
||||||
"test": "tsx --test tests/*.test.ts",
|
|
||||||
"test:unit": "tsx --test tests/question-core.test.ts tests/bridge.test.ts",
|
|
||||||
"e2e:live": "tsx scripts/e2e.ts",
|
|
||||||
"serve": "tsx src/server/index.ts",
|
|
||||||
"web": "vite",
|
|
||||||
"dev": "concurrently -n server,web -c blue,green \"npm:serve\" \"npm:web\"",
|
|
||||||
"build": "vite build",
|
|
||||||
"start": "npm run build && npm run serve",
|
|
||||||
"ingest": "tsx src/cli.ts ingest",
|
"ingest": "tsx src/cli.ts ingest",
|
||||||
"graph": "tsx src/cli.ts graph",
|
"graph": "tsx src/cli.ts graph",
|
||||||
"what-next": "tsx src/cli.ts what-next",
|
"what-next": "tsx src/cli.ts what-next",
|
||||||
"typecheck": "tsc --noEmit && tsc --noEmit -p web/tsconfig.json"
|
"test": "tsx --test tests/*.test.ts tests/*.test.tsx",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/pi-coding-agent": "0.84.2",
|
"@ai-sdk/react": "^4.0.79",
|
||||||
"@fastify/static": "^8.0.0",
|
"@openrouter/ai-sdk-provider": "^3.0.0",
|
||||||
"@fastify/websocket": "^11.0.0",
|
"@radix-ui/react-use-controllable-state": "^1.2.6",
|
||||||
"fastify": "^5.0.0",
|
"@rive-app/react-webgl2": "^4.32.1",
|
||||||
"katex": "^0.16.11",
|
"@streamdown/cjk": "^1.0.3",
|
||||||
"mermaid": "^11.4.0",
|
"@streamdown/code": "^1.1.1",
|
||||||
"react": "^18.3.1",
|
"@streamdown/math": "^1.0.2",
|
||||||
"react-dom": "^18.3.1",
|
"@streamdown/mermaid": "^1.0.2",
|
||||||
"react-markdown": "^9.0.1",
|
"@xyflow/react": "^12.11.3",
|
||||||
"rehype-katex": "^7.0.1",
|
"ai": "^7.0.76",
|
||||||
"remark-math": "^6.0.0"
|
"ansi-to-react": "^6.2.6",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"lucide-react": "^1.33.0",
|
||||||
|
"media-chrome": "^4.19.2",
|
||||||
|
"motion": "^13.1.1",
|
||||||
|
"nanoid": "^6.0.1",
|
||||||
|
"next": "^16.3.2",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"radix-ui": "^1.6.7",
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8",
|
||||||
|
"react-jsx-parser": "^2.4.1",
|
||||||
|
"streamdown": "^2.5.0",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"tokenlens": "^1.3.1",
|
||||||
|
"use-stick-to-bottom": "^1.1.6",
|
||||||
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/react": "^18.3.5",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^19.2.4",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"tailwindcss": "^4.3.3",
|
||||||
"concurrently": "^9.0.0",
|
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.6.0",
|
"typescript": "^5.6.0"
|
||||||
"vite": "^6.0.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
-163
@@ -1,163 +0,0 @@
|
|||||||
/**
|
|
||||||
* e2e test — drives the web server like a browser would:
|
|
||||||
* 1. creates a session (topic prompt)
|
|
||||||
* 2. waits for the teacher to present a quiz (lh_question → pending)
|
|
||||||
* 3. answers it over WS
|
|
||||||
* 4. verifies the graded answer arrives as a user message and the agent
|
|
||||||
* continues (settles again)
|
|
||||||
* 5. exits and RESUMES the session mid-flow to prove resume works
|
|
||||||
*
|
|
||||||
* Run: npx tsx scripts/e2e.ts (server must be running)
|
|
||||||
*/
|
|
||||||
|
|
||||||
const BASE = "http://127.0.0.1:8787";
|
|
||||||
|
|
||||||
function connectWs(): Promise<WebSocket> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const ws = new WebSocket("ws://127.0.0.1:8787/ws");
|
|
||||||
ws.onopen = () => resolve(ws);
|
|
||||||
ws.onerror = (e) => reject(e);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeCollector(ws: WebSocket) {
|
|
||||||
const seen: any[] = [];
|
|
||||||
ws.addEventListener("message", (ev) => {
|
|
||||||
try {
|
|
||||||
seen.push(JSON.parse(String(ev.data)));
|
|
||||||
} catch {}
|
|
||||||
});
|
|
||||||
return seen;
|
|
||||||
}
|
|
||||||
|
|
||||||
function waitEvent(
|
|
||||||
seen: any[],
|
|
||||||
ws: WebSocket,
|
|
||||||
pred: (msg: any) => boolean,
|
|
||||||
timeoutMs = 120_000,
|
|
||||||
): Promise<any> {
|
|
||||||
const already = seen.find(pred);
|
|
||||||
if (already) return Promise.resolve(already);
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const timer = setTimeout(() => reject(new Error("timeout waiting for event")), timeoutMs);
|
|
||||||
const handler = (ev: MessageEvent) => {
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(String(ev.data));
|
|
||||||
if (pred(msg)) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
ws.removeEventListener("message", handler);
|
|
||||||
resolve(msg);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore non-JSON
|
|
||||||
}
|
|
||||||
};
|
|
||||||
ws.addEventListener("message", handler);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log("── creating session");
|
|
||||||
const res = await fetch(`${BASE}/api/sessions`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
body: JSON.stringify({ topic: "Teach me why the sky is blue." }),
|
|
||||||
});
|
|
||||||
const { id } = (await res.json()) as { id: string };
|
|
||||||
console.log(" session:", id);
|
|
||||||
|
|
||||||
console.log("── connecting WS");
|
|
||||||
const ws = await connectWs();
|
|
||||||
const seen = makeCollector(ws);
|
|
||||||
|
|
||||||
// Wait for a pending question (the teacher probes with ask_user_question or quiz).
|
|
||||||
// Fallback: if the first turn settles without a question, ask for one.
|
|
||||||
console.log("── waiting for a pending question…");
|
|
||||||
let pending: any;
|
|
||||||
try {
|
|
||||||
pending = await waitEvent(seen, ws, (m) => m.type === "pending" && m.question !== null, 120_000);
|
|
||||||
} catch {
|
|
||||||
console.log(" no question yet — prompting for one");
|
|
||||||
ws.send(JSON.stringify({ type: "prompt", text: "Quiz me on something first before teaching." }));
|
|
||||||
pending = await waitEvent(seen, ws, (m) => m.type === "pending" && m.question !== null, 180_000);
|
|
||||||
}
|
|
||||||
const q = pending.question;
|
|
||||||
console.log(` ${q.kind} [${q.mode}]: ${q.question}`);
|
|
||||||
console.log(` options: ${q.options?.map((o: any) => o.label).join(" | ") ?? "(text)"}`);
|
|
||||||
|
|
||||||
// Verify the correct answer is NOT in the payload.
|
|
||||||
const raw = JSON.stringify(q);
|
|
||||||
if (raw.includes("correct") || raw.includes("explanation")) {
|
|
||||||
throw new Error("LEAK: pending question payload contains correct/explanation!");
|
|
||||||
}
|
|
||||||
console.log(" ✓ no answer leak in client payload");
|
|
||||||
|
|
||||||
// Answer: pick the first option (or text mode).
|
|
||||||
if (q.mode === "text") {
|
|
||||||
ws.send(JSON.stringify({ type: "answer", questionId: q.id, text: "not sure — just teach me" }));
|
|
||||||
} else {
|
|
||||||
ws.send(JSON.stringify({ type: "answer", questionId: q.id, values: [q.options[0].value] }));
|
|
||||||
}
|
|
||||||
console.log("── answered; waiting for the graded answer to enter the session…");
|
|
||||||
|
|
||||||
// Register BOTH waits before proceeding (avoids missing fast events).
|
|
||||||
const settledAgain = waitEvent(seen, ws, (m) => m.type === "streaming" && m.value === false, 180_000);
|
|
||||||
const answeredEntry = await waitEvent(seen, ws, (m) => m.type === "entry" && m.entry?.type === "questionAnswered");
|
|
||||||
const ad = answeredEntry.entry.data;
|
|
||||||
console.log(` answered: correct=${ad.correct} dontKnow=${ad.dontKnow} dismissed=${ad.dismissed}`);
|
|
||||||
|
|
||||||
// The agent turn that follows the answer must stream and settle.
|
|
||||||
console.log("── waiting for the agent to continue and settle…");
|
|
||||||
await settledAgain;
|
|
||||||
console.log(" ✓ agent continued after the answer and settled");
|
|
||||||
|
|
||||||
// Check transcript state: user message with [Quiz answer] prefix must exist.
|
|
||||||
const userAnswer = seen.find(
|
|
||||||
(m) => m.type === "entry" && m.entry?.type === "user" && m.entry?.text?.startsWith("["),
|
|
||||||
);
|
|
||||||
console.log(userAnswer ? ` ✓ answer user message: ${userAnswer.entry.text.split("\n")[0]}` : " (no answer user message seen — check server logs)");
|
|
||||||
|
|
||||||
// Prompt while nothing pending should work.
|
|
||||||
const settlePrompt = waitEvent(seen, ws, (m) => m.type === "streaming" && m.value === false, 180_000);
|
|
||||||
ws.send(JSON.stringify({ type: "prompt", text: "thanks, that's enough for now" }));
|
|
||||||
await settlePrompt;
|
|
||||||
console.log(" ✓ follow-up prompt worked");
|
|
||||||
|
|
||||||
// Dismiss any second question the teacher may have asked, so the resume
|
|
||||||
// test starts from a clean (no pending) state.
|
|
||||||
const pendingNow = seen.find((m) => m.type === "pending" && m.question !== null && !seen.some((o) => o.type === "entry" && o.entry?.type === "questionAnswered" && o.entry.data.questionId === m.question.id));
|
|
||||||
if (pendingNow) {
|
|
||||||
ws.send(JSON.stringify({ type: "dismiss", questionId: pendingNow.question.id }));
|
|
||||||
await waitEvent(seen, ws, (m) => m.type === "pending" && m.question === null, 60_000);
|
|
||||||
console.log(" ✓ extra question dismissed");
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.close();
|
|
||||||
|
|
||||||
// ── resume test: list sessions, resume the one we just made, verify backfill
|
|
||||||
console.log("── resume test");
|
|
||||||
const list = (await (await fetch(`${BASE}/api/sessions`)).json()) as { sessions: any[] };
|
|
||||||
const mine = list.sessions.find((s) => s.id === id);
|
|
||||||
console.log(` listed: "${mine?.name}" (${mine?.messageCount} msgs)`);
|
|
||||||
const rres = await fetch(`${BASE}/api/sessions/${encodeURIComponent(mine!.file)}/resume`, { method: "POST" });
|
|
||||||
if (!rres.ok) throw new Error(`resume failed: ${rres.status}`);
|
|
||||||
const ws2 = await connectWs();
|
|
||||||
const seen2 = makeCollector(ws2);
|
|
||||||
const backfill = await waitEvent(seen2, ws2, (m) => m.type === "backfill");
|
|
||||||
const kinds = backfill.entries.reduce((acc: Record<string, number>, e: any) => {
|
|
||||||
acc[e.type] = (acc[e.type] ?? 0) + 1;
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
console.log(" backfill entries:", JSON.stringify(kinds));
|
|
||||||
const pending2 = await waitEvent(seen2, ws2, (m) => m.type === "pending");
|
|
||||||
console.log(` pending after resume: ${pending2.question === null ? "none (correct)" : "UNEXPECTED: " + pending2.question.id}`);
|
|
||||||
ws2.close();
|
|
||||||
|
|
||||||
console.log("\n✓ E2E PASSED");
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((err) => {
|
|
||||||
console.error("\n✗ E2E FAILED:", err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import * as fs from "node:fs";
|
||||||
|
import { ToolLoopAgent, stepCountIs, type LanguageModel } from "ai";
|
||||||
|
import { openrouter } from "@openrouter/ai-sdk-provider";
|
||||||
|
import { PATHS } from "../../config.ts";
|
||||||
|
import { teacherTools } from "./tools.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The teacher agent. Everything pi used to provide (agent loop, tool calling,
|
||||||
|
* streaming, multi-step) is these few lines now.
|
||||||
|
*
|
||||||
|
* The model is injectable so tests can pass a MockLanguageModelV4 and drive
|
||||||
|
* deterministic scripted lessons (see tests/chat-flow.test.ts).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DEFAULT_MODEL = process.env.LH_MODEL ?? "anthropic/claude-sonnet-4.5";
|
||||||
|
|
||||||
|
const instructions = fs.readFileSync(PATHS.teacherPrompt, "utf-8");
|
||||||
|
|
||||||
|
export function createTeacherAgent(model?: LanguageModel) {
|
||||||
|
return new ToolLoopAgent({
|
||||||
|
model:
|
||||||
|
model ??
|
||||||
|
openrouter(DEFAULT_MODEL, {
|
||||||
|
// Ask OpenRouter for reasoning tokens when the model supports them;
|
||||||
|
// they stream as reasoning parts into the UI's collapsible block.
|
||||||
|
reasoning: { enabled: true, effort: "medium" },
|
||||||
|
}),
|
||||||
|
instructions,
|
||||||
|
tools: teacherTools,
|
||||||
|
// Both tools are client-side (no execute) — a tool call ends the server
|
||||||
|
// turn automatically. stepCountIs is just the safety net.
|
||||||
|
stopWhen: stepCountIs(20),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TeacherAgent = ReturnType<typeof createTeacherAgent>;
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { tool } from "ai";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The teacher's interaction tools — CLIENT-SIDE tools (no `execute`).
|
||||||
|
*
|
||||||
|
* The model's tool call is not executed on the server; it streams to the
|
||||||
|
* browser as a typed tool part in `input-available` state. The learner
|
||||||
|
* answers in the rendered card; the client grades (quiz) via
|
||||||
|
* src/core/question-core.ts and sends the verdict back with addToolOutput.
|
||||||
|
*
|
||||||
|
* Parameter names mirror src/agent/system-prompt.md exactly — the prompt
|
||||||
|
* documents these fields to the model. Keep them in sync.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const optionSchema = z.object({
|
||||||
|
label: z.string().describe("Display label for the answer option"),
|
||||||
|
value: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Optional machine-readable value returned for the option. Defaults to the label."),
|
||||||
|
description: z.string().optional().describe("Optional extra detail shown below the option"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Raw schemas stay exported: `tool()` wraps them into its internal Schema
|
||||||
|
// type (erasing z.infer), so clients infer input types from these instead.
|
||||||
|
export const quizInputSchema = z.object({
|
||||||
|
question: z.string().describe("The single quiz question to ask"),
|
||||||
|
options: z
|
||||||
|
.array(optionSchema)
|
||||||
|
.min(2)
|
||||||
|
.describe("The answer options (2 or more). Options only — no free-text mode. Never add your own 'I don't know' option."),
|
||||||
|
correctAnswer: z
|
||||||
|
.union([z.string(), z.array(z.string())])
|
||||||
|
.describe(
|
||||||
|
"The correct answer as the option value(s) — the `value` field of the option you intend. " +
|
||||||
|
"Single-select: one string. Multi-select: an array; the learner is only correct if their selection matches this set exactly.",
|
||||||
|
),
|
||||||
|
explanation: z
|
||||||
|
.string()
|
||||||
|
.describe("Explanation revealed AFTER the learner answers. Always say why the correct answer is correct."),
|
||||||
|
multiSelect: z.boolean().optional().describe("Set true only when more than one option is correct"),
|
||||||
|
details: z.string().optional().describe("Optional extra context or instructions shown under the question"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const askInputSchema = z.object({
|
||||||
|
question: z.string().describe("The single question to ask"),
|
||||||
|
mode: z
|
||||||
|
.enum(["single-select", "multi-select", "text"])
|
||||||
|
.optional()
|
||||||
|
.describe("Answer mode. Omit for single-select when options are given, or text when they are not."),
|
||||||
|
options: z
|
||||||
|
.array(optionSchema)
|
||||||
|
.min(2)
|
||||||
|
.optional()
|
||||||
|
.describe("Optional multiple-choice options. Omit for free-form text input. The learner can always choose 'Other' and type a custom answer."),
|
||||||
|
multiSelect: z.boolean().optional().describe("Set true to allow multiple answers to be selected"),
|
||||||
|
details: z.string().optional().describe("Optional extra context or instructions shown under the question"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const quizTool = tool({
|
||||||
|
description:
|
||||||
|
"Ask the learner a GRADED question with a known correct answer, then instantly grade and give feedback. " +
|
||||||
|
"Use it to (1) assess what the learner already understands before teaching, and (2) run tight practice/retrieval loops after explaining. " +
|
||||||
|
"Options-only: single-select or multi-select, plus an automatic 'I don't know' choice so the learner can signal a genuine gap instead of guessing. " +
|
||||||
|
"No free-text answers — for non-graded questions use ask_user_question instead. " +
|
||||||
|
"Ask exactly one question per tool call; after calling, end your turn and wait for the graded result.",
|
||||||
|
inputSchema: quizInputSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const askUserQuestionTool = tool({
|
||||||
|
description:
|
||||||
|
"Ask the learner a single question and pause until they answer. NOT graded — there is no correct answer. " +
|
||||||
|
"Use when requirements are ambiguous, preferences are needed, a decision would materially affect the lesson, " +
|
||||||
|
"or you need an open-ended response. Ask exactly one question per tool call; after calling, end your turn and wait.",
|
||||||
|
inputSchema: askInputSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const teacherTools = {
|
||||||
|
quiz: quizTool,
|
||||||
|
ask_user_question: askUserQuestionTool,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QuizToolInput = z.infer<typeof quizInputSchema>;
|
||||||
|
export type AskToolInput = z.infer<typeof askInputSchema>;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { UIMessage } from "ai";
|
||||||
|
import type { AskToolInput, QuizToolInput } from "./tools.ts";
|
||||||
|
import type { QuestionAnswerData } from "../core/question-core.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The typed UI message for the teacher chat — gives the frontend typed tool
|
||||||
|
* parts ("tool-quiz" / "tool-ask_user_question" with typed input/output)
|
||||||
|
* without importing the server-side agent into the client bundle.
|
||||||
|
*
|
||||||
|
* Declared explicitly (not via InferUITools) because a client-side tool has
|
||||||
|
* no execute function, so its OUTPUT type lives only here: the structured
|
||||||
|
* answer record the client builds and sends via addToolOutput.
|
||||||
|
*/
|
||||||
|
export type TeacherUITools = {
|
||||||
|
quiz: { input: QuizToolInput; output: QuestionAnswerData };
|
||||||
|
ask_user_question: { input: AskToolInput; output: QuestionAnswerData };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TeacherUIMessage = UIMessage<unknown, never, TeacherUITools>;
|
||||||
+3
-15
@@ -2,18 +2,15 @@
|
|||||||
/**
|
/**
|
||||||
* learning-harness CLI
|
* learning-harness CLI
|
||||||
*
|
*
|
||||||
* teach [message] launch an interactive teaching session (full pi TUI)
|
|
||||||
* doctor [--web] non-interactive smoke test of the teacher wiring
|
|
||||||
* (--web checks the web-dispatch extension set)
|
|
||||||
* serve start the web server (teacher agent + REST + WebSocket)
|
|
||||||
* ingest <file> normalize a session JSONL and print the canonical document
|
* ingest <file> normalize a session JSONL and print the canonical document
|
||||||
* (Stage 1 only — extractor/merger not yet implemented)
|
* (Stage 1 only — extractor/merger not yet implemented)
|
||||||
* graph print graph/ledger stats
|
* graph print graph/ledger stats
|
||||||
* what-next (not yet implemented)
|
* what-next (not yet implemented)
|
||||||
|
*
|
||||||
|
* The web app (Next.js + AI SDK): npm run dev
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import { doctor, runTeachingSession } from "./teacher/create-session.ts";
|
|
||||||
import { normalizeSession } from "./extract/normalize.ts";
|
import { normalizeSession } from "./extract/normalize.ts";
|
||||||
import { ledgers } from "./ledger.ts";
|
import { ledgers } from "./ledger.ts";
|
||||||
import { deriveAll } from "./derive.ts";
|
import { deriveAll } from "./derive.ts";
|
||||||
@@ -23,15 +20,6 @@ async function main() {
|
|||||||
const [command, ...rest] = process.argv.slice(2);
|
const [command, ...rest] = process.argv.slice(2);
|
||||||
|
|
||||||
switch (command) {
|
switch (command) {
|
||||||
case "teach": {
|
|
||||||
await runTeachingSession(rest.join(" ") || undefined);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "doctor": {
|
|
||||||
const mode = rest.includes("--web") ? "web" : "tui";
|
|
||||||
await doctor(mode);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "ingest": {
|
case "ingest": {
|
||||||
const file = rest[0];
|
const file = rest[0];
|
||||||
if (!file) {
|
if (!file) {
|
||||||
@@ -69,7 +57,7 @@ async function main() {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
console.log("Commands: teach [message] | doctor | ingest <file> | graph | what-next");
|
console.log("Commands: ingest <file> | graph | what-next");
|
||||||
process.exit(command ? 1 : 0);
|
process.exit(command ? 1 : 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { isToolUIPart, type UIMessage } from "ai";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pending-question gate: an unanswered interaction tool part (quiz/ask)
|
||||||
|
* blocks the composer — one question at a time, mirroring the teacher's
|
||||||
|
* prompt contract. Derived client state; the server enforces nothing.
|
||||||
|
*/
|
||||||
|
export interface PendingToolCall {
|
||||||
|
toolCallId: string;
|
||||||
|
tool: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findPendingToolCall(messages: UIMessage[]): PendingToolCall | null {
|
||||||
|
for (let m = messages.length - 1; m >= 0; m--) {
|
||||||
|
const msg = messages[m]!;
|
||||||
|
for (let p = msg.parts.length - 1; p >= 0; p--) {
|
||||||
|
const part = msg.parts[p]!;
|
||||||
|
if (isToolUIPart(part) && part.state === "input-available") {
|
||||||
|
return { toolCallId: part.toolCallId, tool: part.type.slice("tool-".length) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,21 +1,23 @@
|
|||||||
/**
|
/**
|
||||||
* question-core — pure logic shared by the terminal (blocking) and web
|
* question-core — pure logic for quiz/ask questions. No framework imports,
|
||||||
* (dispatch) quiz/ask extensions. No pi imports, no UI: everything here is
|
* no UI: everything here is deterministic and unit-testable.
|
||||||
* deterministic and unit-testable.
|
|
||||||
*
|
*
|
||||||
* The WEB dispatch shape:
|
* Used by:
|
||||||
* 1. Tool execute() validates + shuffles + resolves the correct answer,
|
* - the TUI quiz extension (extensions/quiz.ts) — blocking terminal UI
|
||||||
* appends an `lh_question` custom entry (NOT in LLM context — safe to
|
* - the web app (AI SDK client-side tools) — the browser validates,
|
||||||
* store correctAnswer/explanation there) and returns immediately.
|
* shuffles, grades with these functions, then sends the verdict back
|
||||||
* 2. The agent ends its turn. The server pushes the question to the client.
|
* as the tool output via addToolOutput
|
||||||
* 3. The learner answers; the server calls back into the extension bridge.
|
|
||||||
* 4. The extension grades, appends `lh_question_answered` (gold-standard
|
|
||||||
* structured data for the extractor), and sends the graded answer into
|
|
||||||
* the session as a user message so the agent can continue.
|
|
||||||
*
|
*
|
||||||
* Because nothing blocks, a session can be exited and resumed mid-question:
|
* The web shape (AI SDK):
|
||||||
* the pending state lives in the session file itself (lh_question entries
|
* 1. The teacher model calls the `quiz` tool (question + options +
|
||||||
* with no matching lh_question_answered).
|
* correctAnswer + explanation). No server execute → the call lands in
|
||||||
|
* the UI as a typed tool part in `input-available` state.
|
||||||
|
* 2. The learner answers in the card; the client grades with this module.
|
||||||
|
* 3. addToolOutput sends the verdict (selection + correctness + note —
|
||||||
|
* never the explanation, the model authored that) back to the model.
|
||||||
|
*
|
||||||
|
* Because the pending question IS a persisted message part, a session can be
|
||||||
|
* exited and resumed mid-question for free.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ─── shared types ───────────────────────────────────────────────────────────
|
// ─── shared types ───────────────────────────────────────────────────────────
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { createAgentUIStreamResponse, type UIMessage } from "ai";
|
||||||
|
import { createTeacherAgent, type TeacherAgent } from "../agent/teacher-agent.ts";
|
||||||
|
import { getChat, saveMessages } from "../store/chats.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One chat turn: run the teacher agent over the client's message list and
|
||||||
|
* stream the UI message stream back. Framework-free (plain Request/Response)
|
||||||
|
* so tests can drive it directly with a scripted mock model — the Next route
|
||||||
|
* handler in app/api/chat is a one-line wrapper.
|
||||||
|
*
|
||||||
|
* The client owns the message list between turns (useChat); the server
|
||||||
|
* persists the canonical state (client messages + agent response) onFinish.
|
||||||
|
*/
|
||||||
|
export async function handleChatRequest(req: Request, agent?: TeacherAgent): Promise<Response> {
|
||||||
|
let body: { chatId?: unknown; messages?: unknown };
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const chatId = typeof body.chatId === "string" ? body.chatId : "";
|
||||||
|
if (!chatId || !getChat(chatId)) {
|
||||||
|
return Response.json({ error: "Unknown chat" }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (!Array.isArray(body.messages)) {
|
||||||
|
return Response.json({ error: "messages must be an array" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return createAgentUIStreamResponse({
|
||||||
|
agent: agent ?? createTeacherAgent(),
|
||||||
|
uiMessages: body.messages as UIMessage[],
|
||||||
|
onFinish: ({ messages, isAborted }) => {
|
||||||
|
// Persist even when aborted: the partial transcript (including any
|
||||||
|
// pending question part) is what makes resume work.
|
||||||
|
try {
|
||||||
|
saveMessages(chatId, messages as UIMessage[]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[chat ${chatId}] persist failed:`, err, { isAborted });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,484 +0,0 @@
|
|||||||
/**
|
|
||||||
* web server — Fastify + WebSocket server embedding the teacher agent via the
|
|
||||||
* pi SDK (web dispatch mode: quiz/ask return immediately, answers arrive as
|
|
||||||
* user messages — nothing blocks, so sessions can be exited/resumed mid-question).
|
|
||||||
*
|
|
||||||
* Delivery model (the part that must never double-send or drop):
|
|
||||||
* - ONE append-only entry log per active session (`entryLog`), plus a cursor
|
|
||||||
* (`syncedCount`) into the session manager's entries.
|
|
||||||
* - Every entry enters the log exactly once — either at activation (seeded
|
|
||||||
* from history, no broadcast) or live via syncEntries (broadcast to all
|
|
||||||
* connected sockets).
|
|
||||||
* - A socket connecting at ANY time receives the whole log as backfill.
|
|
||||||
* There is no per-socket marking and no window in which an entry can be
|
|
||||||
* both backfilled and broadcast — the duplication race from v1 is
|
|
||||||
* structurally impossible now.
|
|
||||||
*
|
|
||||||
* - Streaming: thinking and text deltas are forwarded as they happen;
|
|
||||||
* ephemeral (a socket that connects mid-turn gets `streaming: true` in its
|
|
||||||
* snapshot and the entries when they land).
|
|
||||||
*
|
|
||||||
* v1 scope: one active session at a time; extraction pipeline NOT wired yet.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import Fastify from "fastify";
|
|
||||||
import fastifyWebsocket from "@fastify/websocket";
|
|
||||||
import fastifyStatic from "@fastify/static";
|
|
||||||
import {
|
|
||||||
type AgentSessionEvent,
|
|
||||||
type AgentSessionRuntimeDiagnostic,
|
|
||||||
createAgentSessionRuntime,
|
|
||||||
SessionManager,
|
|
||||||
} from "@earendil-works/pi-coding-agent";
|
|
||||||
import { PATHS } from "../../config.ts";
|
|
||||||
import { buildTeacherRuntimeFactory } from "../teacher/create-session.ts";
|
|
||||||
import {
|
|
||||||
QUESTION_ANSWERED_ENTRY,
|
|
||||||
QUESTION_ENTRY,
|
|
||||||
findPendingQuestion,
|
|
||||||
type QuestionAnswerData,
|
|
||||||
type QuestionData,
|
|
||||||
toClientQuestion,
|
|
||||||
} from "../../extensions/core/question-core.ts";
|
|
||||||
import { getBridge } from "../../extensions/core/bridge.ts";
|
|
||||||
import type { ClientEntry, ServerMessage, SessionSummary } from "../shared/protocol.ts";
|
|
||||||
|
|
||||||
/** What the server needs from a teacher runtime (real or scripted). */
|
|
||||||
export interface RuntimeLike {
|
|
||||||
session: {
|
|
||||||
subscribe(listener: (evt: AgentSessionEvent) => void): () => void;
|
|
||||||
dispose(): void;
|
|
||||||
isStreaming: boolean;
|
|
||||||
prompt(text: string): Promise<void>;
|
|
||||||
abort(): unknown;
|
|
||||||
setSessionName(name: string): unknown;
|
|
||||||
sessionManager: SessionManager;
|
|
||||||
};
|
|
||||||
diagnostics: readonly AgentSessionRuntimeDiagnostic[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RuntimeFactory = (options: {
|
|
||||||
cwd: string;
|
|
||||||
agentDir: string;
|
|
||||||
sessionManager: SessionManager;
|
|
||||||
}) => Promise<RuntimeLike>;
|
|
||||||
|
|
||||||
export interface ServerOptions {
|
|
||||||
/** TCP port; 0 = pick a free port (tests). Default: LH_PORT or 8787. */
|
|
||||||
port?: number;
|
|
||||||
/** Teacher runtime factory — inject a scripted one in tests. */
|
|
||||||
runtimeFactory?: RuntimeFactory;
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultRuntimeFactory: RuntimeFactory = (options) =>
|
|
||||||
createAgentSessionRuntime(buildTeacherRuntimeFactory("web"), options) as unknown as Promise<RuntimeLike>;
|
|
||||||
|
|
||||||
// ─── entry serialization ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function extractText(content: unknown[]): string {
|
|
||||||
return content
|
|
||||||
.filter((c): c is { type: "text"; text: string } => (c as { type?: string }).type === "text")
|
|
||||||
.map((c) => c.text)
|
|
||||||
.join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractThinking(content: unknown[]): string {
|
|
||||||
return content
|
|
||||||
.filter(
|
|
||||||
(c): c is { type: "thinking"; thinking: string } => (c as { type?: string }).type === "thinking",
|
|
||||||
)
|
|
||||||
.map((c) => c.thinking)
|
|
||||||
.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializeEntry(entry: {
|
|
||||||
type: string;
|
|
||||||
id: string;
|
|
||||||
customType?: string;
|
|
||||||
data?: unknown;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
message?: any;
|
|
||||||
}): ClientEntry | null {
|
|
||||||
if (entry.type === "message" && entry.message) {
|
|
||||||
const m = entry.message;
|
|
||||||
if (m.role === "user") {
|
|
||||||
return { type: "user", id: entry.id, text: extractText(m.content ?? []) };
|
|
||||||
}
|
|
||||||
if (m.role === "assistant") {
|
|
||||||
const toolCalls = ((m.content ?? []) as unknown[])
|
|
||||||
.filter(
|
|
||||||
(c): c is { type: "toolCall"; name: string; arguments?: unknown } =>
|
|
||||||
(c as { type?: string }).type === "toolCall",
|
|
||||||
)
|
|
||||||
.map((c) => ({ name: c.name, args: (c.arguments ?? {}) as Record<string, unknown> }));
|
|
||||||
const thinking = extractThinking(m.content ?? []);
|
|
||||||
return {
|
|
||||||
type: "assistant",
|
|
||||||
id: entry.id,
|
|
||||||
text: extractText(m.content ?? []),
|
|
||||||
thinking: thinking || undefined,
|
|
||||||
toolCalls,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (m.role === "toolResult") {
|
|
||||||
return { type: "toolResult", id: entry.id, toolName: m.toolName ?? "", text: extractText(m.content ?? []) };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (entry.type === "custom" && entry.customType === QUESTION_ENTRY) {
|
|
||||||
const data = entry.data as QuestionData | undefined;
|
|
||||||
if (!data) return null;
|
|
||||||
return { type: "question", id: entry.id, data: toClientQuestion(data) };
|
|
||||||
}
|
|
||||||
if (entry.type === "custom" && entry.customType === QUESTION_ANSWERED_ENTRY) {
|
|
||||||
const data = entry.data as QuestionAnswerData | undefined;
|
|
||||||
if (!data) return null;
|
|
||||||
return { type: "questionAnswered", id: entry.id, data };
|
|
||||||
}
|
|
||||||
return { type: "meta", id: entry.id, kind: entry.type };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── session listing (reads data/sessions/*.jsonl) ──────────────────────────
|
|
||||||
|
|
||||||
function listSessions(): SessionSummary[] {
|
|
||||||
const dir = PATHS.sessions;
|
|
||||||
const files = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => f.endsWith(".jsonl")) : [];
|
|
||||||
const summaries: SessionSummary[] = [];
|
|
||||||
for (const file of files) {
|
|
||||||
try {
|
|
||||||
const lines = fs.readFileSync(path.join(dir, file), "utf8").split("\n").filter(Boolean);
|
|
||||||
if (lines.length === 0) continue;
|
|
||||||
const header = JSON.parse(lines[0] ?? "") as { id?: string; timestamp?: string };
|
|
||||||
const id = header.id;
|
|
||||||
if (!id) continue;
|
|
||||||
let name: string | undefined;
|
|
||||||
let preview: string | undefined;
|
|
||||||
let updatedAt = header.timestamp ?? "";
|
|
||||||
let messageCount = 0;
|
|
||||||
for (const line of lines.slice(1)) {
|
|
||||||
let entry: { type?: string; message?: { role?: string; content?: unknown[] }; timestamp?: string; name?: string };
|
|
||||||
try {
|
|
||||||
entry = JSON.parse(line);
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (entry.timestamp) updatedAt = entry.timestamp;
|
|
||||||
if (entry.type === "session_info" && entry.name) name = entry.name;
|
|
||||||
if (entry.type === "message" && entry.message?.role === "user") {
|
|
||||||
messageCount++;
|
|
||||||
if (!preview) preview = extractText(entry.message.content ?? []).slice(0, 120);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
summaries.push({
|
|
||||||
id,
|
|
||||||
file,
|
|
||||||
name,
|
|
||||||
createdAt: header.timestamp ?? "",
|
|
||||||
updatedAt,
|
|
||||||
messageCount,
|
|
||||||
preview,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// unreadable session file — skip
|
|
||||||
}
|
|
||||||
}
|
|
||||||
summaries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
||||||
return summaries;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── server ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function startServer(options: ServerOptions = {}): Promise<{
|
|
||||||
port: number;
|
|
||||||
close: () => Promise<void>;
|
|
||||||
}> {
|
|
||||||
const runtimeFactory = options.runtimeFactory ?? defaultRuntimeFactory;
|
|
||||||
|
|
||||||
// Active session state. `entryLog` is the single source of truth for what
|
|
||||||
// clients have been able to see; `syncedCount` is how many session entries
|
|
||||||
// it reflects. Both reset together on activation.
|
|
||||||
interface ActiveSession {
|
|
||||||
runtime: RuntimeLike;
|
|
||||||
sessionId: string;
|
|
||||||
file: string;
|
|
||||||
}
|
|
||||||
let active: ActiveSession | null = null;
|
|
||||||
let unsubscribeEvents: (() => void) | null = null;
|
|
||||||
let entryLog: ClientEntry[] = [];
|
|
||||||
let syncedCount = 0;
|
|
||||||
|
|
||||||
const sockets = new Set<WebSocket>();
|
|
||||||
|
|
||||||
function broadcast(msg: ServerMessage): void {
|
|
||||||
const raw = JSON.stringify(msg);
|
|
||||||
for (const ws of sockets) {
|
|
||||||
if (ws.readyState === ws.OPEN) ws.send(raw);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function broadcastPending(): void {
|
|
||||||
if (!active) return;
|
|
||||||
const pending = findPendingQuestion(active.runtime.session.sessionManager.getEntries());
|
|
||||||
broadcast({ type: "pending", question: pending });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Append every not-yet-logged session entry to the log (broadcasting each). */
|
|
||||||
function syncEntries(): void {
|
|
||||||
if (!active) return;
|
|
||||||
const entries = active.runtime.session.sessionManager.getEntries();
|
|
||||||
for (; syncedCount < entries.length; syncedCount++) {
|
|
||||||
const entry = entries[syncedCount]!;
|
|
||||||
const client = serializeEntry(entry as never);
|
|
||||||
if (client) {
|
|
||||||
entryLog.push(client);
|
|
||||||
broadcast({ type: "entry", entry: client });
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
entry.type === "custom" &&
|
|
||||||
((entry as { customType?: string }).customType === QUESTION_ENTRY ||
|
|
||||||
(entry as { customType?: string }).customType === QUESTION_ANSWERED_ENTRY)
|
|
||||||
) {
|
|
||||||
broadcastPending();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSessionEvent(evt: AgentSessionEvent): void {
|
|
||||||
if (!active) return;
|
|
||||||
switch (evt.type) {
|
|
||||||
case "message_update": {
|
|
||||||
const e = evt.assistantMessageEvent;
|
|
||||||
if (e?.type === "text_delta") {
|
|
||||||
broadcast({ type: "delta", kind: "text", delta: e.delta });
|
|
||||||
} else if (e?.type === "thinking_delta") {
|
|
||||||
broadcast({ type: "delta", kind: "thinking", delta: e.delta });
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "agent_start":
|
|
||||||
broadcast({ type: "streaming", value: true });
|
|
||||||
break;
|
|
||||||
case "agent_settled":
|
|
||||||
broadcast({ type: "streaming", value: false });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
syncEntries();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function activate(sessionManager: SessionManager): Promise<ActiveSession> {
|
|
||||||
if (unsubscribeEvents) {
|
|
||||||
unsubscribeEvents();
|
|
||||||
unsubscribeEvents = null;
|
|
||||||
}
|
|
||||||
if (active) {
|
|
||||||
active.runtime.session.dispose();
|
|
||||||
active = null;
|
|
||||||
}
|
|
||||||
const runtime = await runtimeFactory({
|
|
||||||
cwd: PATHS.root,
|
|
||||||
agentDir: PATHS.agentDir,
|
|
||||||
sessionManager,
|
|
||||||
});
|
|
||||||
for (const d of runtime.diagnostics) {
|
|
||||||
if (d.type === "error") console.error(`[teacher] ${d.message}`);
|
|
||||||
}
|
|
||||||
unsubscribeEvents = runtime.session.subscribe(handleSessionEvent);
|
|
||||||
const file = path.basename(sessionManager.getSessionFile() ?? "");
|
|
||||||
active = { runtime, sessionId: sessionManager.getSessionId(), file };
|
|
||||||
|
|
||||||
// Seed the log from history WITHOUT broadcasting: sockets that connect
|
|
||||||
// later get it via their connect-backfill; sockets already connected get
|
|
||||||
// it via the hello+backfill below. Either way, exactly once.
|
|
||||||
entryLog = sessionManager
|
|
||||||
.getEntries()
|
|
||||||
.map((e) => serializeEntry(e as never))
|
|
||||||
.filter((e): e is ClientEntry => e !== null);
|
|
||||||
syncedCount = sessionManager.getEntries().length;
|
|
||||||
|
|
||||||
broadcast({ type: "hello", sessionId: active.sessionId });
|
|
||||||
broadcast({ type: "backfill", entries: entryLog });
|
|
||||||
broadcastPending();
|
|
||||||
broadcast({ type: "streaming", value: runtime.session.isStreaming });
|
|
||||||
return active;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createSession(topic: string): Promise<ActiveSession> {
|
|
||||||
const sessionManager = SessionManager.create(PATHS.root, PATHS.sessions);
|
|
||||||
const result = await activate(sessionManager);
|
|
||||||
// Name the session after the topic (shown in the session list).
|
|
||||||
const name = topic.trim().slice(0, 60) || "New session";
|
|
||||||
result.runtime.session.setSessionName(name);
|
|
||||||
// Fire the first prompt (don't await — clients follow along via WS).
|
|
||||||
void result.runtime.session.prompt(topic).catch((err) => {
|
|
||||||
console.error("[teacher] prompt failed:", err);
|
|
||||||
broadcast({ type: "error", message: String(err) });
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resumeSession(file: string): Promise<ActiveSession> {
|
|
||||||
const sessionManager = SessionManager.open(path.join(PATHS.sessions, file), PATHS.sessions, PATHS.root);
|
|
||||||
return activate(sessionManager);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── HTTP ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const app = Fastify({ logger: false });
|
|
||||||
await app.register(fastifyWebsocket);
|
|
||||||
|
|
||||||
// Serve the built frontend (web/dist) when it exists.
|
|
||||||
const distDir = path.join(PATHS.root, "web", "dist");
|
|
||||||
if (fs.existsSync(distDir)) {
|
|
||||||
await app.register(fastifyStatic, { root: distDir });
|
|
||||||
app.setNotFoundHandler((req, reply) => {
|
|
||||||
if (req.url.startsWith("/api") || req.url.startsWith("/ws")) {
|
|
||||||
reply.code(404).send({ error: "not found" });
|
|
||||||
} else {
|
|
||||||
reply.sendFile("index.html");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
app.get("/api/sessions", async () => ({
|
|
||||||
sessions: listSessions(),
|
|
||||||
activeId: active?.sessionId ?? null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
app.post<{ Body: { topic?: string } }>("/api/sessions", async (req, reply) => {
|
|
||||||
const topic = (req.body?.topic ?? "").trim();
|
|
||||||
if (!topic) return reply.code(400).send({ error: "topic is required" });
|
|
||||||
const result = await createSession(topic);
|
|
||||||
return { id: result.sessionId, file: result.file };
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post<{ Params: { file: string } }>("/api/sessions/:file/resume", async (req, reply) => {
|
|
||||||
const file = req.params.file;
|
|
||||||
const full = path.join(PATHS.sessions, file);
|
|
||||||
if (!file.endsWith(".jsonl") || !fs.existsSync(full)) {
|
|
||||||
return reply.code(404).send({ error: "session not found" });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const result = await resumeSession(file);
|
|
||||||
return { id: result.sessionId, file: result.file };
|
|
||||||
} catch (err) {
|
|
||||||
return reply.code(500).send({ error: String(err) });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── WebSocket ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
app.get("/ws", { websocket: true }, (socket) => {
|
|
||||||
sockets.add(socket);
|
|
||||||
socket.on("close", () => sockets.delete(socket));
|
|
||||||
|
|
||||||
// Initial snapshot: everything the session has produced so far.
|
|
||||||
socket.send(JSON.stringify({ type: "hello", sessionId: active?.sessionId ?? null } satisfies ServerMessage));
|
|
||||||
socket.send(JSON.stringify({ type: "backfill", entries: entryLog } satisfies ServerMessage));
|
|
||||||
if (active) {
|
|
||||||
socket.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: "pending",
|
|
||||||
question: findPendingQuestion(active.runtime.session.sessionManager.getEntries()),
|
|
||||||
} satisfies ServerMessage),
|
|
||||||
);
|
|
||||||
socket.send(
|
|
||||||
JSON.stringify({ type: "streaming", value: active.runtime.session.isStreaming } satisfies ServerMessage),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.on("message", (raw: Buffer) => {
|
|
||||||
let msg: { type?: string } & Record<string, unknown>;
|
|
||||||
try {
|
|
||||||
msg = JSON.parse(raw.toString());
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!active) {
|
|
||||||
socket.send(JSON.stringify({ type: "error", message: "no active session" } satisfies ServerMessage));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const session = active.runtime.session;
|
|
||||||
switch (msg.type) {
|
|
||||||
case "prompt": {
|
|
||||||
const text = String(msg.text ?? "").trim();
|
|
||||||
if (!text) return;
|
|
||||||
// One question at a time: don't accept prompts while a question
|
|
||||||
// is waiting to be answered.
|
|
||||||
if (findPendingQuestion(session.sessionManager.getEntries())) {
|
|
||||||
socket.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: "error",
|
|
||||||
message: "Answer the pending question first (or dismiss it).",
|
|
||||||
} satisfies ServerMessage),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void session.prompt(text).catch((err) => {
|
|
||||||
console.error("[teacher] prompt failed:", err);
|
|
||||||
broadcast({ type: "error", message: String(err) });
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "answer": {
|
|
||||||
const outcome = getBridge().answer(String(msg.questionId ?? ""), {
|
|
||||||
dontKnow: Boolean(msg.dontKnow),
|
|
||||||
values: Array.isArray(msg.values) ? msg.values.map(String) : undefined,
|
|
||||||
other: msg.other ? String(msg.other) : undefined,
|
|
||||||
text: msg.text ? String(msg.text) : undefined,
|
|
||||||
note: msg.note ? String(msg.note) : undefined,
|
|
||||||
});
|
|
||||||
if (!outcome.ok) {
|
|
||||||
socket.send(
|
|
||||||
JSON.stringify({ type: "error", message: outcome.error ?? "answer failed" } satisfies ServerMessage),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "dismiss": {
|
|
||||||
const outcome = getBridge().dismiss(String(msg.questionId ?? ""));
|
|
||||||
if (!outcome.ok) {
|
|
||||||
socket.send(
|
|
||||||
JSON.stringify({ type: "error", message: outcome.error ?? "dismiss failed" } satisfies ServerMessage),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "abort": {
|
|
||||||
void session.abort();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const port = options.port ?? Number(process.env.LH_PORT ?? 8787);
|
|
||||||
await app.listen({ port, host: "127.0.0.1" });
|
|
||||||
const actualPort = (app.server.address() as { port: number }).port;
|
|
||||||
|
|
||||||
return {
|
|
||||||
port: actualPort,
|
|
||||||
close: async () => {
|
|
||||||
if (unsubscribeEvents) unsubscribeEvents();
|
|
||||||
if (active) active.runtime.session.dispose();
|
|
||||||
for (const ws of sockets) ws.close();
|
|
||||||
sockets.clear();
|
|
||||||
await app.close();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── entrypoint ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
|
||||||
const server = await startServer();
|
|
||||||
console.log(`learning-harness web server: http://127.0.0.1:${server.port}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.argv[1] && process.argv[1].endsWith("server/index.ts")) {
|
|
||||||
main().catch((err) => {
|
|
||||||
console.error(err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
/**
|
|
||||||
* protocol — shared types between the web server and the browser client.
|
|
||||||
* One source of truth for the WS protocol and REST DTOs.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ClientQuestion, QuestionAnswerData } from "../../extensions/core/question-core.ts";
|
|
||||||
|
|
||||||
export type { ClientQuestion, QuestionAnswerData } from "../../extensions/core/question-core.ts";
|
|
||||||
|
|
||||||
// ─── entries (session → client) ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface ClientToolCall {
|
|
||||||
name: string;
|
|
||||||
args: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ClientEntry =
|
|
||||||
| { type: "user"; id: string; text: string }
|
|
||||||
| { type: "assistant"; id: string; text: string; thinking?: string; toolCalls: ClientToolCall[] }
|
|
||||||
| { type: "toolResult"; id: string; toolName: string; text: string }
|
|
||||||
| { type: "question"; id: string; data: ClientQuestion }
|
|
||||||
| { type: "questionAnswered"; id: string; data: QuestionAnswerData }
|
|
||||||
| { type: "meta"; id: string; kind: string };
|
|
||||||
|
|
||||||
// ─── REST DTOs ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface SessionSummary {
|
|
||||||
id: string;
|
|
||||||
file: string;
|
|
||||||
name?: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
messageCount: number;
|
|
||||||
preview?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NewSessionResponse {
|
|
||||||
id: string;
|
|
||||||
file: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── WS protocol ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export type ServerMessage =
|
|
||||||
| { type: "hello"; sessionId: string | null }
|
|
||||||
| { type: "backfill"; entries: ClientEntry[] }
|
|
||||||
| { type: "entry"; entry: ClientEntry }
|
|
||||||
| { type: "pending"; question: ClientQuestion | null }
|
|
||||||
| { type: "delta"; kind: "thinking" | "text"; delta: string }
|
|
||||||
| { type: "streaming"; value: boolean }
|
|
||||||
| { type: "error"; message: string };
|
|
||||||
|
|
||||||
export type ClientMessage =
|
|
||||||
| { type: "prompt"; text: string }
|
|
||||||
| {
|
|
||||||
type: "answer";
|
|
||||||
questionId: string;
|
|
||||||
dontKnow?: boolean;
|
|
||||||
values?: string[];
|
|
||||||
other?: string;
|
|
||||||
text?: string;
|
|
||||||
note?: string;
|
|
||||||
}
|
|
||||||
| { type: "dismiss"; questionId: string }
|
|
||||||
| { type: "abort" };
|
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import type { UIMessage } from "ai";
|
||||||
|
import { PATHS } from "../../config.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chat store — one JSON file per chat in data/chats/.
|
||||||
|
*
|
||||||
|
* The stored messages are full AI SDK UIMessages (parts included). This is
|
||||||
|
* the canonical session record: a pending quiz is a tool part in
|
||||||
|
* `input-available` state, which is exactly what makes resume-mid-question
|
||||||
|
* free. The extraction pipeline (src/extract) will read these later; old pi
|
||||||
|
* sessions (data/sessions) remain bootstrap input only.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ChatRecord {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
messages: UIMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatSummary {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
messageCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chatFile(id: string): string {
|
||||||
|
if (!/^[A-Za-z0-9_-]+$/.test(id)) throw new Error(`Invalid chat id: ${id}`);
|
||||||
|
return path.join(PATHS.chats, `${id}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createChat(title: string): ChatRecord {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const record: ChatRecord = {
|
||||||
|
id: `chat_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`,
|
||||||
|
title: title.trim() || "Untitled session",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
messages: [],
|
||||||
|
};
|
||||||
|
fs.mkdirSync(PATHS.chats, { recursive: true });
|
||||||
|
fs.writeFileSync(chatFile(record.id), JSON.stringify(record, null, "\t"), "utf-8");
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChat(id: string): ChatRecord | null {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(chatFile(id), "utf-8")) as ChatRecord;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listChats(): ChatSummary[] {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(PATHS.chats, { recursive: true });
|
||||||
|
return fs
|
||||||
|
.readdirSync(PATHS.chats)
|
||||||
|
.filter((f) => f.endsWith(".json"))
|
||||||
|
.map((f) => getChat(f.slice(0, -5)))
|
||||||
|
.filter((r): r is ChatRecord => r !== null)
|
||||||
|
.map(({ messages, ...summary }) => ({ ...summary, messageCount: messages.length }))
|
||||||
|
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist the full message list (client state + new response) after a turn. */
|
||||||
|
export function saveMessages(id: string, messages: UIMessage[]): void {
|
||||||
|
const record = getChat(id);
|
||||||
|
if (!record) throw new Error(`Chat not found: ${id}`);
|
||||||
|
record.messages = messages;
|
||||||
|
record.updatedAt = new Date().toISOString();
|
||||||
|
fs.writeFileSync(chatFile(id), JSON.stringify(record, null, "\t"), "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Derive a short title from the first user message. */
|
||||||
|
export function titleFromPrompt(prompt: string): string {
|
||||||
|
const oneLine = prompt.replace(/\s+/g, " ").trim();
|
||||||
|
return oneLine.length > 72 ? `${oneLine.slice(0, 72)}…` : oneLine;
|
||||||
|
}
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
/**
|
|
||||||
* Teacher session — the teaching runtime of the learning harness.
|
|
||||||
*
|
|
||||||
* pi (@earendil-works/pi-coding-agent) is embedded as a library dependency.
|
|
||||||
* This is NOT the user's global pi: hermetic isolation is enforced by
|
|
||||||
* - agentDir → data/agent/ (settings/auth/skills/extensions; nothing from ~/.pi/agent
|
|
||||||
* loads except auth.json, which is a symlink — credentials only)
|
|
||||||
* - sessions → data/sessions/ (harness-owned session JSONLs)
|
|
||||||
* - systemPrompt → teacher/system-prompt.md (fully replaces pi's coding-agent prompt)
|
|
||||||
* - tools → noTools: "builtin" (read/bash/edit/write off; only extension tools remain)
|
|
||||||
* - extensions→ explicitly: quiz.ts + ask-user-question.ts via additionalExtensionPaths
|
|
||||||
*/
|
|
||||||
|
|
||||||
import path from "node:path";
|
|
||||||
import {
|
|
||||||
createAgentSessionFromServices,
|
|
||||||
createAgentSessionRuntime,
|
|
||||||
createAgentSessionServices,
|
|
||||||
InteractiveMode,
|
|
||||||
initTheme,
|
|
||||||
SessionManager,
|
|
||||||
type CreateAgentSessionRuntimeFactory,
|
|
||||||
} from "@earendil-works/pi-coding-agent";
|
|
||||||
import { PATHS } from "../../config.ts";
|
|
||||||
|
|
||||||
export type TeacherMode = "tui" | "web";
|
|
||||||
|
|
||||||
/** Test-only injection: replace the model + model runtime with a scripted fake. */
|
|
||||||
export interface TeacherRuntimeOverrides {
|
|
||||||
model?: unknown;
|
|
||||||
modelRuntime?: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildTeacherRuntimeFactory(
|
|
||||||
mode: TeacherMode = "tui",
|
|
||||||
overrides?: TeacherRuntimeOverrides,
|
|
||||||
): CreateAgentSessionRuntimeFactory {
|
|
||||||
// Terminal mode: quiz/ask block on interactive pi-tui components.
|
|
||||||
// Web mode: quiz/ask use the dispatch shape (return immediately; the graded
|
|
||||||
// answer arrives as the next user message — see extensions/quiz-web.ts).
|
|
||||||
const extensionFiles =
|
|
||||||
mode === "web"
|
|
||||||
? [path.join(PATHS.extensions, "quiz-web.ts"), path.join(PATHS.extensions, "ask-web.ts")]
|
|
||||||
: [path.join(PATHS.extensions, "quiz.ts"), path.join(PATHS.extensions, "ask-user-question.ts")];
|
|
||||||
return async ({ cwd, sessionManager, sessionStartEvent }) => {
|
|
||||||
const services = await createAgentSessionServices({
|
|
||||||
cwd,
|
|
||||||
agentDir: PATHS.agentDir,
|
|
||||||
resourceLoaderOptions: {
|
|
||||||
// Only the extensions we explicitly list. No discovery of ~/.pi/agent
|
|
||||||
// or .pi/extensions beyond what agentDir/cwd contain (they contain none).
|
|
||||||
additionalExtensionPaths: extensionFiles,
|
|
||||||
// The teacher system prompt — replaces pi's coding prompt entirely.
|
|
||||||
systemPrompt: PATHS.teacherPrompt,
|
|
||||||
// Hermetic: no skills, no prompt templates, no AGENTS.md context files.
|
|
||||||
// Themes stay enabled for a good TUI.
|
|
||||||
noSkills: true,
|
|
||||||
noPromptTemplates: true,
|
|
||||||
noContextFiles: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
// Test injection: swap the real model runtime for a scripted fake BEFORE
|
|
||||||
// the session is constructed from services. Production never passes this.
|
|
||||||
if (overrides?.modelRuntime) {
|
|
||||||
(services as { modelRuntime: unknown }).modelRuntime = overrides.modelRuntime;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...(await createAgentSessionFromServices({
|
|
||||||
services,
|
|
||||||
sessionManager,
|
|
||||||
sessionStartEvent,
|
|
||||||
// No read/bash/edit/write. quiz + ask_user_question (extension tools)
|
|
||||||
// remain enabled.
|
|
||||||
noTools: "builtin",
|
|
||||||
model: overrides?.model as never,
|
|
||||||
})),
|
|
||||||
services,
|
|
||||||
diagnostics: services.diagnostics,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Launch the interactive teaching session (full pi TUI). */
|
|
||||||
export async function runTeachingSession(initialMessage?: string): Promise<void> {
|
|
||||||
const createRuntime = buildTeacherRuntimeFactory();
|
|
||||||
const sessionManager = SessionManager.create(PATHS.root, PATHS.sessions);
|
|
||||||
|
|
||||||
const runtime = await createAgentSessionRuntime(createRuntime, {
|
|
||||||
cwd: PATHS.root,
|
|
||||||
agentDir: PATHS.agentDir,
|
|
||||||
sessionManager,
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const diagnostic of runtime.diagnostics) {
|
|
||||||
console.error(`[${diagnostic.type}] ${diagnostic.message}`);
|
|
||||||
}
|
|
||||||
if (runtime.diagnostics.some((d) => d.type === "error")) {
|
|
||||||
console.error("Fatal diagnostics — aborting.");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
initTheme("dark", true);
|
|
||||||
|
|
||||||
const mode = new InteractiveMode(runtime, {
|
|
||||||
migratedProviders: [],
|
|
||||||
modelFallbackMessage: undefined,
|
|
||||||
initialMessage,
|
|
||||||
initialImages: [],
|
|
||||||
initialMessages: [],
|
|
||||||
});
|
|
||||||
await mode.run();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Non-interactive smoke test: verify the whole wiring boots correctly. */
|
|
||||||
export async function doctor(mode: TeacherMode = "tui"): Promise<void> {
|
|
||||||
const sessionManager = SessionManager.inMemory(PATHS.root);
|
|
||||||
const runtime = await buildTeacherRuntimeFactory(mode)({
|
|
||||||
cwd: PATHS.root,
|
|
||||||
agentDir: PATHS.agentDir,
|
|
||||||
sessionManager,
|
|
||||||
sessionStartEvent: { type: "session_start", reason: "startup" },
|
|
||||||
});
|
|
||||||
const { session, services } = runtime;
|
|
||||||
|
|
||||||
for (const diagnostic of runtime.diagnostics) {
|
|
||||||
console.log(`[${diagnostic.type}] ${diagnostic.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const extensions = services.resourceLoader.getExtensions();
|
|
||||||
console.log("\nExtensions loaded:");
|
|
||||||
for (const ext of extensions.extensions) {
|
|
||||||
const tools = Array.from(ext.tools.keys());
|
|
||||||
console.log(` - ${path.basename(ext.path)} tools: ${tools.join(", ") || "(none)"}`);
|
|
||||||
}
|
|
||||||
if (extensions.errors.length > 0) {
|
|
||||||
console.log("Extension errors:");
|
|
||||||
for (const { path: p, error } of extensions.errors) console.log(` ! ${p}: ${error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = session.agent.state;
|
|
||||||
console.log(`\nTools visible to the agent: ${state.tools.map((t) => t.name).join(", ")}`);
|
|
||||||
console.log(`Model: ${state.model ? `${state.model.provider}/${state.model.id}` : "(none — restore from settings failed)"}`);
|
|
||||||
console.log(`Thinking level: ${state.thinkingLevel}`);
|
|
||||||
|
|
||||||
const prompt = state.systemPrompt;
|
|
||||||
console.log(`\nSystem prompt: ${prompt.length} chars`);
|
|
||||||
console.log("--- first 300 chars ---");
|
|
||||||
console.log(prompt.slice(0, 300));
|
|
||||||
console.log("--- last 200 chars ---");
|
|
||||||
console.log(prompt.slice(-200));
|
|
||||||
|
|
||||||
const expectedTools = ["quiz", "ask_user_question"];
|
|
||||||
const toolNames = state.tools.map((t) => t.name);
|
|
||||||
const missing = expectedTools.filter((t) => !toolNames.includes(t));
|
|
||||||
const leaked = toolNames.filter((t) => ["read", "bash", "edit", "write", "grep", "find", "ls"].includes(t));
|
|
||||||
console.log(`\n${missing.length === 0 ? "✓" : "✗"} expected tools present${missing.length ? ` — MISSING: ${missing.join(", ")}` : ""}`);
|
|
||||||
console.log(`${leaked.length === 0 ? "✓" : "✗"} no coding tools leaked${leaked.length ? ` — LEAKED: ${leaked.join(", ")}` : ""}`);
|
|
||||||
console.log(`${prompt.includes("You are the teacher agent of a learning harness") ? "✓" : "✗"} teacher system prompt active`);
|
|
||||||
console.log(`${prompt.includes("quiz") && prompt.includes("ask_user_question") ? "✓" : "✗"} tool guidelines folded into system prompt`);
|
|
||||||
|
|
||||||
session.dispose();
|
|
||||||
}
|
|
||||||
@@ -1,228 +0,0 @@
|
|||||||
/**
|
|
||||||
* scripted-model — a deterministic fake LLM for integration tests.
|
|
||||||
*
|
|
||||||
* Replaces the real model runtime inside the teacher session: the FULL pi
|
|
||||||
* agent loop runs (extensions, tool execution, session persistence, event
|
|
||||||
* stream), but every assistant response comes from a fixed script. No network,
|
|
||||||
* no tokens, no variance — the same script always produces the same session.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// AgentMessage comes from a transitive dep that is not directly importable;
|
|
||||||
// a structural local type is enough for the helpers below.
|
|
||||||
export interface AnyMessage {
|
|
||||||
role?: string;
|
|
||||||
content?: Array<{ type?: string; text?: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── types ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface ScriptedToolCall {
|
|
||||||
name: string;
|
|
||||||
arguments: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One scripted assistant turn: reasoning, tool calls, and/or final text. */
|
|
||||||
export interface ScriptedTurn {
|
|
||||||
text?: string;
|
|
||||||
thinking?: string;
|
|
||||||
toolCalls?: ScriptedToolCall[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FakeAssistantMessage {
|
|
||||||
role: "assistant";
|
|
||||||
content: Array<Record<string, unknown>>;
|
|
||||||
api: string;
|
|
||||||
provider: string;
|
|
||||||
model: string;
|
|
||||||
usage: Record<string, unknown>;
|
|
||||||
stopReason: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── fake event stream (what the agent loop consumes) ──────────────────────
|
|
||||||
|
|
||||||
class FakeEventStream {
|
|
||||||
private queue: Array<Record<string, unknown>> = [];
|
|
||||||
private done = false;
|
|
||||||
private resolvers: Array<(r: IteratorResult<Record<string, unknown>>) => void> = [];
|
|
||||||
private finalMessage: FakeAssistantMessage | null = null;
|
|
||||||
private resultResolve!: (m: FakeAssistantMessage) => void;
|
|
||||||
readonly resultPromise: Promise<FakeAssistantMessage>;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.resultPromise = new Promise((resolve) => {
|
|
||||||
this.resultResolve = resolve;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
push(event: Record<string, unknown>): void {
|
|
||||||
if (this.done) return;
|
|
||||||
const r = this.resolvers.shift();
|
|
||||||
if (r) r({ value: event, done: false });
|
|
||||||
else this.queue.push(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
end(message: FakeAssistantMessage): void {
|
|
||||||
this.done = true;
|
|
||||||
this.finalMessage = message;
|
|
||||||
this.resultResolve(message);
|
|
||||||
while (this.resolvers.length > 0) {
|
|
||||||
this.resolvers.shift()!({ value: undefined as never, done: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async result(): Promise<FakeAssistantMessage> {
|
|
||||||
return this.resultPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Symbol.asyncIterator](): AsyncIterator<Record<string, unknown>> {
|
|
||||||
return {
|
|
||||||
next: (): Promise<IteratorResult<Record<string, unknown>>> => {
|
|
||||||
const queued = this.queue.shift();
|
|
||||||
if (queued !== undefined) return Promise.resolve({ value: queued, done: false });
|
|
||||||
if (this.done) return Promise.resolve({ value: undefined as never, done: true });
|
|
||||||
return new Promise((resolve) => this.resolvers.push(resolve));
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── fake model + runtime ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function baseMessage(turnIndex: number): FakeAssistantMessage {
|
|
||||||
return {
|
|
||||||
role: "assistant",
|
|
||||||
content: [],
|
|
||||||
api: "openai-completions",
|
|
||||||
provider: "scripted",
|
|
||||||
model: `scripted-turn-${turnIndex}`,
|
|
||||||
usage: { input: 100, output: 10, cacheRead: 0, cacheWrite: 0, totalTokens: 110, cost: { input: 0, output: 0, total: 0 } },
|
|
||||||
stopReason: "stop",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScriptedRuntime {
|
|
||||||
/** The model object to pass as `model` when creating the session. */
|
|
||||||
model: Record<string, unknown>;
|
|
||||||
/** The ModelRuntime replacement to inject into services. */
|
|
||||||
modelRuntime: {
|
|
||||||
getModel: () => unknown;
|
|
||||||
hasConfiguredAuth: () => boolean;
|
|
||||||
streamSimple: (model: unknown, context: unknown, options?: unknown) => FakeEventStream;
|
|
||||||
};
|
|
||||||
/** All contexts the "model" was called with (for assertions). */
|
|
||||||
contextsSeen: Array<{ systemPrompt?: string; messages: unknown[] }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createScriptedRuntime(script: ScriptedTurn[]): ScriptedRuntime {
|
|
||||||
let turnIndex = 0;
|
|
||||||
const contextsSeen: Array<{ systemPrompt?: string; messages: unknown[] }> = [];
|
|
||||||
|
|
||||||
const model = {
|
|
||||||
id: "scripted-model",
|
|
||||||
name: "Scripted Test Model",
|
|
||||||
api: "openai-completions",
|
|
||||||
provider: "scripted",
|
|
||||||
baseUrl: "http://localhost:0",
|
|
||||||
reasoning: false,
|
|
||||||
input: ["text"] as const,
|
|
||||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
||||||
contextWindow: 200_000,
|
|
||||||
maxTokens: 8_192,
|
|
||||||
};
|
|
||||||
|
|
||||||
function nextStream(): FakeEventStream {
|
|
||||||
const stream = new FakeEventStream();
|
|
||||||
const turn = script[turnIndex] ?? { text: "(script exhausted)" };
|
|
||||||
turnIndex++;
|
|
||||||
|
|
||||||
const msg = baseMessage(turnIndex);
|
|
||||||
const content: Array<Record<string, unknown>> = [];
|
|
||||||
|
|
||||||
// "start"
|
|
||||||
stream.push({ type: "start", partial: { ...msg, content: [] } });
|
|
||||||
|
|
||||||
// thinking (reasoning) — split into two deltas so accumulation is tested
|
|
||||||
if (turn.thinking !== undefined && turn.thinking.length > 0) {
|
|
||||||
content.push({ type: "thinking", thinking: turn.thinking });
|
|
||||||
const idx = content.length - 1;
|
|
||||||
stream.push({ type: "thinking_start", contentIndex: idx, partial: { ...msg, content: [...content] } });
|
|
||||||
const mid = Math.ceil(turn.thinking.length / 2);
|
|
||||||
stream.push({ type: "thinking_delta", contentIndex: idx, delta: turn.thinking.slice(0, mid), partial: { ...msg, content: [...content] } });
|
|
||||||
stream.push({ type: "thinking_delta", contentIndex: idx, delta: turn.thinking.slice(mid), partial: { ...msg, content: [...content] } });
|
|
||||||
stream.push({ type: "thinking_end", contentIndex: idx, content: turn.thinking, partial: { ...msg, content: [...content] } });
|
|
||||||
}
|
|
||||||
|
|
||||||
// tool calls
|
|
||||||
for (const tc of turn.toolCalls ?? []) {
|
|
||||||
const toolCall = {
|
|
||||||
type: "toolCall",
|
|
||||||
id: `call_${turnIndex}_${content.length}`,
|
|
||||||
name: tc.name,
|
|
||||||
arguments: tc.arguments,
|
|
||||||
};
|
|
||||||
content.push(toolCall);
|
|
||||||
stream.push({
|
|
||||||
type: "toolcall_end",
|
|
||||||
contentIndex: content.length - 1,
|
|
||||||
toolCall,
|
|
||||||
partial: { ...msg, content: [...content] },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// text
|
|
||||||
if (turn.text !== undefined && turn.text.length > 0) {
|
|
||||||
content.push({ type: "text", text: turn.text });
|
|
||||||
stream.push({
|
|
||||||
type: "text_start",
|
|
||||||
contentIndex: content.length - 1,
|
|
||||||
partial: { ...msg, content: [...content] },
|
|
||||||
});
|
|
||||||
stream.push({
|
|
||||||
type: "text_delta",
|
|
||||||
contentIndex: content.length - 1,
|
|
||||||
delta: turn.text,
|
|
||||||
partial: { ...msg, content: [...content] },
|
|
||||||
});
|
|
||||||
stream.push({
|
|
||||||
type: "text_end",
|
|
||||||
contentIndex: content.length - 1,
|
|
||||||
content: turn.text,
|
|
||||||
partial: { ...msg, content: [...content] },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// "done" + result
|
|
||||||
const final = { ...msg, content, stopReason: turn.toolCalls?.length ? "toolUse" : "stop" };
|
|
||||||
stream.push({
|
|
||||||
type: "done",
|
|
||||||
reason: turn.toolCalls?.length ? "toolUse" : "stop",
|
|
||||||
message: final,
|
|
||||||
});
|
|
||||||
stream.end(final);
|
|
||||||
return stream;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
model,
|
|
||||||
contextsSeen,
|
|
||||||
modelRuntime: {
|
|
||||||
getModel: () => model,
|
|
||||||
hasConfiguredAuth: () => true,
|
|
||||||
streamSimple: (_model: unknown, context: unknown) => {
|
|
||||||
const ctx = context as { systemPrompt?: string; messages: unknown[] };
|
|
||||||
contextsSeen.push(ctx);
|
|
||||||
return nextStream();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── helpers for assertions on session messages ─────────────────────────────
|
|
||||||
|
|
||||||
export function messageText(m: AnyMessage): string {
|
|
||||||
const content = (m as { content?: Array<{ type?: string; text?: string }> }).content ?? [];
|
|
||||||
return content
|
|
||||||
.filter((c) => c.type === "text" && typeof c.text === "string")
|
|
||||||
.map((c) => c.text)
|
|
||||||
.join("");
|
|
||||||
}
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unit tests — the question bridge (state machine, fake backends only).
|
|
||||||
*
|
|
||||||
* The bridge is globalThis-singleton state shared with other tests in the same
|
|
||||||
* process, so every test creates its own bridge instance via the class-free
|
|
||||||
* export path... (it doesn't export the class — we exercise the singleton and
|
|
||||||
* reset it between tests with clear()).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { beforeEach, describe, it } from "node:test";
|
|
||||||
import {
|
|
||||||
type AnswerRequest,
|
|
||||||
type QuestionData,
|
|
||||||
QUESTION_ANSWERED_ENTRY,
|
|
||||||
QUESTION_ENTRY,
|
|
||||||
} from "../extensions/core/question-core.ts";
|
|
||||||
import { getBridge } from "../extensions/core/bridge.ts";
|
|
||||||
|
|
||||||
interface Recorded {
|
|
||||||
answered: unknown[];
|
|
||||||
messages: string[];
|
|
||||||
entries: Array<{ type: string; customType?: string; data?: unknown }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fakeBackend(kind: "quiz" | "ask"): { recorded: Recorded } {
|
|
||||||
const recorded: Recorded = { answered: [], messages: [], entries: [] };
|
|
||||||
getBridge().registerBackend({
|
|
||||||
kind,
|
|
||||||
appendAnswered: (data) => recorded.answered.push(data),
|
|
||||||
sendUserMessage: (text) => recorded.messages.push(text),
|
|
||||||
getEntries: () => recorded.entries,
|
|
||||||
});
|
|
||||||
return { recorded };
|
|
||||||
}
|
|
||||||
|
|
||||||
function quizData(id: string): QuestionData {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
kind: "quiz",
|
|
||||||
question: `Question ${id}?`,
|
|
||||||
mode: "single-select",
|
|
||||||
options: [
|
|
||||||
{ label: "Right", value: "right" },
|
|
||||||
{ label: "Wrong", value: "wrong" },
|
|
||||||
],
|
|
||||||
correctIndices: [1],
|
|
||||||
explanation: "because",
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("question bridge", () => {
|
|
||||||
let quiz: { recorded: Recorded };
|
|
||||||
let ask: { recorded: Recorded };
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
const bridge = getBridge();
|
|
||||||
bridge.clear("quiz");
|
|
||||||
bridge.clear("ask");
|
|
||||||
quiz = fakeBackend("quiz");
|
|
||||||
ask = fakeBackend("ask");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("publish makes a question answerable; answer grades and sends a user message", () => {
|
|
||||||
const bridge = getBridge();
|
|
||||||
const qd = quizData("q1");
|
|
||||||
bridge.publish(qd);
|
|
||||||
|
|
||||||
const outcome = bridge.answer("q1", { values: ["right"] });
|
|
||||||
assert.equal(outcome.ok, true);
|
|
||||||
|
|
||||||
assert.equal(quiz.recorded.answered.length, 1);
|
|
||||||
const ad = quiz.recorded.answered[0] as { correct: boolean; questionId: string };
|
|
||||||
assert.equal(ad.questionId, "q1");
|
|
||||||
assert.equal(ad.correct, true);
|
|
||||||
|
|
||||||
assert.equal(quiz.recorded.messages.length, 1);
|
|
||||||
assert.ok(quiz.recorded.messages[0].startsWith("[Quiz answer]"));
|
|
||||||
// ask backend untouched
|
|
||||||
assert.equal(ask.recorded.messages.length, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("answering a question twice fails the second time", () => {
|
|
||||||
const bridge = getBridge();
|
|
||||||
bridge.publish(quizData("q2"));
|
|
||||||
assert.equal(bridge.answer("q2", { values: ["wrong"] }).ok, true);
|
|
||||||
const second = bridge.answer("q2", { values: ["right"] });
|
|
||||||
assert.equal(second.ok, false);
|
|
||||||
assert.match(second.error ?? "", /already-answered/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("answering an unknown question fails", () => {
|
|
||||||
const outcome = getBridge().answer("nope", { values: [] });
|
|
||||||
assert.equal(outcome.ok, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("dismiss records a dismissed answer and notifies the agent", () => {
|
|
||||||
const bridge = getBridge();
|
|
||||||
bridge.publish(quizData("q3"));
|
|
||||||
assert.equal(bridge.dismiss("q3").ok, true);
|
|
||||||
|
|
||||||
const ad = quiz.recorded.answered[0] as { dismissed: boolean };
|
|
||||||
assert.equal(ad.dismissed, true);
|
|
||||||
assert.match(quiz.recorded.messages[0], /dismissed/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("dontKnow answers are never correct", () => {
|
|
||||||
const bridge = getBridge();
|
|
||||||
bridge.publish(quizData("q4"));
|
|
||||||
bridge.answer("q4", { dontKnow: true });
|
|
||||||
const ad = quiz.recorded.answered[0] as { dontKnow: boolean; correct: boolean };
|
|
||||||
assert.equal(ad.dontKnow, true);
|
|
||||||
assert.equal(ad.correct, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rearm picks up unanswered questions from session entries", () => {
|
|
||||||
const bridge = getBridge();
|
|
||||||
// Simulate a resumed session whose entries contain an unanswered question.
|
|
||||||
quiz.recorded.entries = [
|
|
||||||
{ type: "custom", customType: QUESTION_ENTRY, data: quizData("q5") },
|
|
||||||
{
|
|
||||||
type: "custom",
|
|
||||||
customType: QUESTION_ANSWERED_ENTRY,
|
|
||||||
data: { questionId: "other", answers: [] },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
bridge.rearm("quiz");
|
|
||||||
|
|
||||||
// Now answerable through the bridge.
|
|
||||||
assert.equal(bridge.answer("q5", { values: ["right"] }).ok, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rearm ignores already-answered questions", () => {
|
|
||||||
const bridge = getBridge();
|
|
||||||
quiz.recorded.entries = [
|
|
||||||
{ type: "custom", customType: QUESTION_ENTRY, data: quizData("q6") },
|
|
||||||
{
|
|
||||||
type: "custom",
|
|
||||||
customType: QUESTION_ANSWERED_ENTRY,
|
|
||||||
data: { questionId: "q6", answers: [], dontKnow: true },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
bridge.rearm("quiz");
|
|
||||||
const outcome = bridge.answer("q6", { values: ["right"] });
|
|
||||||
assert.equal(outcome.ok, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clear removes only its kind's pending questions", () => {
|
|
||||||
const bridge = getBridge();
|
|
||||||
bridge.publish(quizData("q7"));
|
|
||||||
const askQd: QuestionData = { ...quizData("q8"), kind: "ask", correctIndices: undefined };
|
|
||||||
bridge.publish(askQd);
|
|
||||||
|
|
||||||
bridge.clear("quiz");
|
|
||||||
assert.equal(bridge.answer("q7", { values: ["right"] }).ok, false);
|
|
||||||
assert.equal(bridge.answer("q8", { other: "x" }).ok, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ask answers route to the ask backend ungraded", () => {
|
|
||||||
const bridge = getBridge();
|
|
||||||
const askQd: QuestionData = { ...quizData("q9"), kind: "ask", correctIndices: undefined };
|
|
||||||
bridge.publish(askQd);
|
|
||||||
|
|
||||||
bridge.answer("q9", { other: "custom answer" });
|
|
||||||
const ad = ask.recorded.answered[0] as { other?: string; correct?: boolean };
|
|
||||||
assert.equal(ad.other, "custom answer");
|
|
||||||
assert.equal(ad.correct, undefined);
|
|
||||||
assert.ok(ask.recorded.messages[0].startsWith("[Answer to your question]"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
/**
|
||||||
|
* Scripted integration test — the full teacher loop with zero tokens:
|
||||||
|
*
|
||||||
|
* learner opens chat → mock model calls `quiz` → server streams + persists
|
||||||
|
* → client grades and sends the answer back as the tool output
|
||||||
|
* → mock model reacts → transcript persisted.
|
||||||
|
*
|
||||||
|
* Drives handleChatRequest directly (plain Request/Response — no Next server
|
||||||
|
* needed) with a MockLanguageModelV4. This is the AI SDK equivalent of the
|
||||||
|
* old pi dispatch contract test: the quiz bridge is gone, the tool part IS
|
||||||
|
* the pending state, and the persisted transcript is the resume record.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { after, describe, it } from "node:test";
|
||||||
|
import { MockLanguageModelV4, simulateReadableStream } from "ai/test";
|
||||||
|
import type { LanguageModelV4StreamPart } from "@ai-sdk/provider";
|
||||||
|
import { createTeacherAgent } from "../src/agent/teacher-agent.ts";
|
||||||
|
import { handleChatRequest } from "../src/server/chat-handler.ts";
|
||||||
|
import { createChat, getChat, saveMessages } from "../src/store/chats.ts";
|
||||||
|
import { buildAnswer, coerceCorrectAnswer, normalizeOptions, resolveCorrect, type QuestionData } from "../src/core/question-core.ts";
|
||||||
|
import { findPendingToolCall } from "../src/core/pending.ts";
|
||||||
|
import { PATHS } from "../config.ts";
|
||||||
|
|
||||||
|
const QUIZ_INPUT = {
|
||||||
|
question: "Which planet is closest to the sun?",
|
||||||
|
options: [
|
||||||
|
{ label: "Venus" },
|
||||||
|
{ label: "Mercury" },
|
||||||
|
{ label: "Mars" },
|
||||||
|
],
|
||||||
|
correctAnswer: "Mercury",
|
||||||
|
explanation: "Mercury is the innermost planet.",
|
||||||
|
};
|
||||||
|
|
||||||
|
function usage() {
|
||||||
|
return {
|
||||||
|
inputTokens: { total: 10, noCache: 10, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
outputTokens: { total: 5, text: 5, reasoning: 0 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Turn 1: a bit of reasoning, then the quiz tool call. Turn 2: plain text. */
|
||||||
|
function scriptedModel(calls: string[][]) {
|
||||||
|
let turn = 0;
|
||||||
|
return new MockLanguageModelV4({
|
||||||
|
doStream: async (options) => {
|
||||||
|
const promptTexts = options.prompt.map((m) => JSON.stringify(m.content));
|
||||||
|
calls.push(promptTexts);
|
||||||
|
turn++;
|
||||||
|
const chunks: LanguageModelV4StreamPart[] =
|
||||||
|
turn === 1
|
||||||
|
? [
|
||||||
|
{ type: "stream-start", warnings: [] },
|
||||||
|
{ type: "reasoning-start", id: "r1" },
|
||||||
|
{ type: "reasoning-delta", id: "r1", delta: "Probe the basics first." },
|
||||||
|
{ type: "reasoning-end", id: "r1" },
|
||||||
|
{
|
||||||
|
type: "tool-call",
|
||||||
|
toolCallId: "call-1",
|
||||||
|
toolName: "quiz",
|
||||||
|
input: JSON.stringify(QUIZ_INPUT),
|
||||||
|
},
|
||||||
|
{ type: "finish", finishReason: { unified: "tool-calls", raw: "tool_calls" }, usage: usage() },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ type: "stream-start", warnings: [] },
|
||||||
|
{ type: "text-start", id: "t1" },
|
||||||
|
{ type: "text-delta", id: "t1", delta: "Right — Mercury it is." },
|
||||||
|
{ type: "text-end", id: "t1" },
|
||||||
|
{ type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage: usage() },
|
||||||
|
];
|
||||||
|
return { stream: simulateReadableStream({ chunks, initialDelayInMs: 0, chunkDelayInMs: 0 }) };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drain(res: Response): Promise<string> {
|
||||||
|
if (res.status !== 200) {
|
||||||
|
assert.fail(`expected 200, got ${res.status}: ${await res.text()}`);
|
||||||
|
}
|
||||||
|
return res.text(); // consumes the SSE stream to completion (fires onFinish)
|
||||||
|
}
|
||||||
|
|
||||||
|
function post(chatId: string, messages: unknown[]): Request {
|
||||||
|
return new Request("http://test/api/chat", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ chatId, messages }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdChats: string[] = [];
|
||||||
|
after(() => {
|
||||||
|
for (const id of createdChats) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(path.join(PATHS.chats, `${id}.json`));
|
||||||
|
} catch {
|
||||||
|
/* already gone */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("teacher chat flow (scripted model)", () => {
|
||||||
|
it("quiz tool call persists as a pending part; the answer continues the lesson", async () => {
|
||||||
|
const calls: string[][] = [];
|
||||||
|
const agent = createTeacherAgent(scriptedModel(calls));
|
||||||
|
const chat = createChat("Test: solar system");
|
||||||
|
createdChats.push(chat.id);
|
||||||
|
|
||||||
|
// ── Turn 1: learner opens with a topic; teacher probes with a quiz ──
|
||||||
|
const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "teach me the solar system" }] };
|
||||||
|
await drain(await handleChatRequest(post(chat.id, [firstUser]), agent));
|
||||||
|
|
||||||
|
const saved1 = getChat(chat.id)!;
|
||||||
|
assert.equal(saved1.messages.length, 2, "user message + assistant response persisted");
|
||||||
|
const assistantMsg = saved1.messages[1]!;
|
||||||
|
assert.equal(assistantMsg.role, "assistant");
|
||||||
|
|
||||||
|
const reasoningPart = assistantMsg.parts.find((p) => p.type === "reasoning");
|
||||||
|
assert.ok(reasoningPart && reasoningPart.type === "reasoning", "reasoning part streamed + persisted");
|
||||||
|
assert.match(reasoningPart.text, /Probe the basics/);
|
||||||
|
|
||||||
|
const quizPart = assistantMsg.parts.find((p) => p.type === "tool-quiz");
|
||||||
|
assert.ok(quizPart && quizPart.type === "tool-quiz", "quiz tool part persisted");
|
||||||
|
assert.equal(quizPart.state, "input-available", "unanswered quiz = pending state (resume record)");
|
||||||
|
assert.deepEqual(quizPart.input, QUIZ_INPUT);
|
||||||
|
|
||||||
|
// The pending-question gate sees it.
|
||||||
|
const pending = findPendingToolCall(saved1.messages);
|
||||||
|
assert.deepEqual(pending, { toolCallId: "call-1", tool: "quiz" });
|
||||||
|
|
||||||
|
// ── The learner answers: client grades, output goes back on the part ──
|
||||||
|
const options = normalizeOptions(QUIZ_INPUT.options);
|
||||||
|
const qd: QuestionData = {
|
||||||
|
id: "call-1",
|
||||||
|
kind: "quiz",
|
||||||
|
question: QUIZ_INPUT.question,
|
||||||
|
mode: "single-select",
|
||||||
|
options,
|
||||||
|
correctIndices: resolveCorrect(coerceCorrectAnswer(QUIZ_INPUT.correctAnswer), options).indices,
|
||||||
|
explanation: QUIZ_INPUT.explanation,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
const answer = buildAnswer(qd, { values: ["Mercury"] });
|
||||||
|
assert.equal(answer.correct, true);
|
||||||
|
|
||||||
|
const messages2 = saved1.messages.map((m) =>
|
||||||
|
m.id === assistantMsg.id
|
||||||
|
? {
|
||||||
|
...m,
|
||||||
|
parts: m.parts.map((p) =>
|
||||||
|
p.type === "tool-quiz" ? { ...p, state: "output-available" as const, output: answer } : p,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: m,
|
||||||
|
);
|
||||||
|
await drain(await handleChatRequest(post(chat.id, messages2), agent));
|
||||||
|
|
||||||
|
// ── The teacher reacts — as a CONTINUATION of the same assistant message
|
||||||
|
// (the last client message is the assistant message carrying the tool
|
||||||
|
// output, so the new text appends to that message's parts). ──
|
||||||
|
const saved2 = getChat(chat.id)!;
|
||||||
|
assert.equal(saved2.messages.length, 2, "the reaction continues the same assistant message");
|
||||||
|
const finalParts = saved2.messages[1]!.parts;
|
||||||
|
const textPart = finalParts.find((p) => p.type === "text");
|
||||||
|
assert.ok(textPart && textPart.type === "text", "reaction text appended after the tool part");
|
||||||
|
assert.match(textPart.text, /Mercury it is/);
|
||||||
|
// The answered quiz part is still there, now with its output.
|
||||||
|
const answeredQuiz = finalParts.find((p) => p.type === "tool-quiz");
|
||||||
|
assert.ok(answeredQuiz && answeredQuiz.type === "tool-quiz" && answeredQuiz.state === "output-available");
|
||||||
|
|
||||||
|
// The model actually received the graded answer as a tool result.
|
||||||
|
assert.equal(calls.length, 2, "model saw exactly two prompts");
|
||||||
|
const toolResultSent = calls[1]!.some((c) => c.includes("tool-result") || c.includes("Mercury"));
|
||||||
|
assert.ok(toolResultSent, "continuation prompt carries the tool result to the model");
|
||||||
|
|
||||||
|
// No pending question remains.
|
||||||
|
assert.equal(findPendingToolCall(saved2.messages), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unknown chats", async () => {
|
||||||
|
const res = await handleChatRequest(post("chat_nonexistent", []));
|
||||||
|
assert.equal(res.status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists even when the stream is aborted (resume keeps partial state)", async () => {
|
||||||
|
const calls: string[][] = [];
|
||||||
|
const agent = createTeacherAgent(scriptedModel(calls));
|
||||||
|
const chat = createChat("Test: abort");
|
||||||
|
createdChats.push(chat.id);
|
||||||
|
|
||||||
|
const req = post(chat.id, [{ id: "u1", role: "user", parts: [{ type: "text", text: "quiz me" }] }]);
|
||||||
|
const res = await handleChatRequest(req, agent);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
// Read one chunk then cancel — simulates the browser closing mid-stream.
|
||||||
|
const reader = res.body!.getReader();
|
||||||
|
await reader.read();
|
||||||
|
await reader.cancel();
|
||||||
|
// Give onFinish a tick to fire.
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
const saved = getChat(chat.id)!;
|
||||||
|
assert.ok(saved.messages.length >= 1, "partial transcript persisted");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("chat store", () => {
|
||||||
|
it("round-trips and lists chats", async () => {
|
||||||
|
const chat = createChat("Store test");
|
||||||
|
createdChats.push(chat.id);
|
||||||
|
saveMessages(chat.id, [{ id: "m1", role: "user", parts: [{ type: "text", text: "hi" }] }]);
|
||||||
|
const loaded = getChat(chat.id)!;
|
||||||
|
assert.equal(loaded.messages.length, 1);
|
||||||
|
assert.equal(loaded.title, "Store test");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
/**
|
|
||||||
* Integration test — the full teaching flow with a SCRIPTED model.
|
|
||||||
*
|
|
||||||
* Real pi agent loop, real web extensions (quiz-web/ask-web), real bridge,
|
|
||||||
* real session persistence — the only fake is the LLM. Verifies the dispatch
|
|
||||||
* contract end to end:
|
|
||||||
*
|
|
||||||
* prompt → scripted turn calls quiz → extension persists lh_question and
|
|
||||||
* returns immediately → agent ends turn → bridge.answer() grades and sends
|
|
||||||
* the [Quiz answer] user message → scripted final turn → settle.
|
|
||||||
*
|
|
||||||
* Then reloads the session file into a NEW runtime (resume) and verifies the
|
|
||||||
* pending-question re-arm from persisted entries.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { after, before, describe, it } from "node:test";
|
|
||||||
import { createAgentSessionRuntime, SessionManager } from "@earendil-works/pi-coding-agent";
|
|
||||||
import { PATHS } from "../config.ts";
|
|
||||||
import { buildTeacherRuntimeFactory } from "../src/teacher/create-session.ts";
|
|
||||||
import {
|
|
||||||
QUESTION_ANSWERED_ENTRY,
|
|
||||||
QUESTION_ENTRY,
|
|
||||||
findPendingQuestion,
|
|
||||||
toClientQuestion,
|
|
||||||
type QuestionAnswerData,
|
|
||||||
type QuestionData,
|
|
||||||
} from "../extensions/core/question-core.ts";
|
|
||||||
import { getBridge } from "../extensions/core/bridge.ts";
|
|
||||||
import { createScriptedRuntime } from "../src/testing/scripted-model.ts";
|
|
||||||
|
|
||||||
const QUIZ_ARGS = {
|
|
||||||
question: "Which is closest to the Sun?",
|
|
||||||
options: [
|
|
||||||
{ label: "Mercury", value: "mercury" },
|
|
||||||
{ label: "Venus", value: "venus" },
|
|
||||||
],
|
|
||||||
correctAnswer: "mercury",
|
|
||||||
explanation: "Mercury is the innermost planet.",
|
|
||||||
};
|
|
||||||
|
|
||||||
let tmpDir: string;
|
|
||||||
let sessionDir: string;
|
|
||||||
|
|
||||||
before(() => {
|
|
||||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lh-int-"));
|
|
||||||
sessionDir = path.join(tmpDir, "sessions");
|
|
||||||
fs.mkdirSync(sessionDir, { recursive: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
after(() => {
|
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
function entriesOf(sm: SessionManager) {
|
|
||||||
return sm.getEntries() as Array<{ type: string; customType?: string; data?: unknown; id: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function customEntries(sm: SessionManager, customType: string) {
|
|
||||||
return entriesOf(sm).filter((e) => e.type === "custom" && e.customType === customType);
|
|
||||||
}
|
|
||||||
|
|
||||||
function userMessages(sm: SessionManager) {
|
|
||||||
return entriesOf(sm)
|
|
||||||
.filter((e) => e.type === "message" && (e as { message: { role: string } }).message.role === "user")
|
|
||||||
.map((e) => (e as unknown as { message: { content: Array<{ type: string; text?: string }> } }).message);
|
|
||||||
}
|
|
||||||
|
|
||||||
function waitForIdle(session: { waitForIdle(): Promise<void> }, timeoutMs = 10_000): Promise<void> {
|
|
||||||
return Promise.race([
|
|
||||||
session.waitForIdle(),
|
|
||||||
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("timeout waiting for idle")), timeoutMs)),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Poll until pred() is true (bridge sendUserMessage is fire-and-forget async,
|
|
||||||
* so waitForIdle alone can win a race against the incoming prompt). */
|
|
||||||
function waitFor(pred: () => boolean, what: string, timeoutMs = 10_000): Promise<void> {
|
|
||||||
const start = Date.now();
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const tick = () => {
|
|
||||||
if (pred()) return resolve();
|
|
||||||
if (Date.now() - start > timeoutMs) return reject(new Error(`timeout waiting for ${what}`));
|
|
||||||
setTimeout(tick, 25);
|
|
||||||
};
|
|
||||||
tick();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("scripted teaching flow (web dispatch mode)", () => {
|
|
||||||
it("runs the quiz dispatch cycle and persists gold-standard data", async () => {
|
|
||||||
const scripted = createScriptedRuntime([
|
|
||||||
// Turn 1: call quiz, end turn (toolUse stop reason).
|
|
||||||
{ toolCalls: [{ name: "quiz", arguments: QUIZ_ARGS }] },
|
|
||||||
// Turn 2 (after the tool result, model declines to continue): final text.
|
|
||||||
{ text: "Take a shot." },
|
|
||||||
// Turn 3 (after the [Quiz answer] user message): wrap up.
|
|
||||||
{ text: "Right — Mercury it is. Lesson over." },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const sessionManager = SessionManager.create(PATHS.root, sessionDir);
|
|
||||||
const runtime = await createAgentSessionRuntime(buildTeacherRuntimeFactory("web", {
|
|
||||||
model: scripted.model,
|
|
||||||
modelRuntime: scripted.modelRuntime,
|
|
||||||
}), {
|
|
||||||
cwd: PATHS.root,
|
|
||||||
agentDir: PATHS.agentDir,
|
|
||||||
sessionManager,
|
|
||||||
});
|
|
||||||
assert.ok(!runtime.diagnostics.some((d) => d.type === "error"), "no fatal diagnostics");
|
|
||||||
|
|
||||||
// ── Turn 1: prompt → quiz presented ────────────────────────────────
|
|
||||||
const promptDone = runtime.session.prompt("Teach me about the solar system.");
|
|
||||||
await waitForIdle(runtime.session);
|
|
||||||
await promptDone;
|
|
||||||
|
|
||||||
const questions = customEntries(sessionManager, QUESTION_ENTRY) as Array<{ data?: QuestionData }>;
|
|
||||||
assert.equal(questions.length, 1, "exactly one lh_question entry");
|
|
||||||
const qd = questions[0]!.data!;
|
|
||||||
assert.equal(qd.kind, "quiz");
|
|
||||||
assert.equal(qd.mode, "single-select");
|
|
||||||
assert.deepEqual(qd.correctIndices, [qd.options.findIndex((o) => o.value === "mercury") + 1], "correct index resolved post-shuffle");
|
|
||||||
assert.equal(qd.explanation, QUIZ_ARGS.explanation);
|
|
||||||
|
|
||||||
// Pending question visible without leaking the answer.
|
|
||||||
const pending = findPendingQuestion(entriesOf(sessionManager));
|
|
||||||
assert.ok(pending, "pending question found");
|
|
||||||
const clientPayload = JSON.stringify(toClientQuestion(qd));
|
|
||||||
assert.ok(!clientPayload.includes("correctIndices"));
|
|
||||||
assert.ok(!clientPayload.includes("Mercury is the innermost"));
|
|
||||||
|
|
||||||
// ── Answer: graded + injected as user message ──────────────────────
|
|
||||||
const outcome = getBridge().answer(qd.id, { values: ["mercury"] });
|
|
||||||
assert.equal(outcome.ok, true);
|
|
||||||
await waitFor(() => userMessages(sessionManager).length === 2, "quiz answer user message");
|
|
||||||
await waitForIdle(runtime.session);
|
|
||||||
|
|
||||||
const answers = customEntries(sessionManager, QUESTION_ANSWERED_ENTRY) as Array<{
|
|
||||||
data?: QuestionAnswerData;
|
|
||||||
}>;
|
|
||||||
assert.equal(answers.length, 1, "exactly one lh_question_answered entry");
|
|
||||||
const ad = answers[0]!.data!;
|
|
||||||
assert.equal(ad.correct, true);
|
|
||||||
assert.equal(ad.questionId, qd.id);
|
|
||||||
assert.equal(ad.answers.length, 1);
|
|
||||||
assert.equal(ad.answers[0]!.value, "mercury");
|
|
||||||
|
|
||||||
const users = userMessages(sessionManager);
|
|
||||||
assert.equal(users.length, 2, "topic prompt + quiz answer");
|
|
||||||
const answerMsg = users[1]!.content.find((c) => c.type === "text")!.text!;
|
|
||||||
assert.ok(answerMsg.startsWith("[Quiz answer]"), "answer arrives as user message");
|
|
||||||
assert.match(answerMsg, /Result: CORRECT/);
|
|
||||||
assert.match(answerMsg, /Selected: \d\. Mercury/);
|
|
||||||
|
|
||||||
// Pending is cleared after answering.
|
|
||||||
assert.equal(findPendingQuestion(entriesOf(sessionManager)), null);
|
|
||||||
|
|
||||||
// The scripted model saw the answer in its context (turn 3).
|
|
||||||
assert.ok(scripted.contextsSeen.length >= 3);
|
|
||||||
const lastContext = scripted.contextsSeen[scripted.contextsSeen.length - 1]!;
|
|
||||||
const lastMessages = JSON.stringify(lastContext.messages);
|
|
||||||
assert.ok(lastMessages.includes("[Quiz answer]"), "answer message reached the LLM context");
|
|
||||||
// …but the tool RESULT must never reveal the answer (the correct answer
|
|
||||||
// and explanation live only in the lh_question custom entry, which is
|
|
||||||
// excluded from LLM context). The toolCall arguments legitimately contain
|
|
||||||
// them — the model wrote those itself.
|
|
||||||
for (const ctx of scripted.contextsSeen) {
|
|
||||||
const messages = ctx.messages as Array<{ role?: string; content?: Array<{ type?: string; text?: string; name?: string }> }>;
|
|
||||||
for (const m of messages) {
|
|
||||||
if (m.role !== "toolResult") continue;
|
|
||||||
for (const c of m.content ?? []) {
|
|
||||||
if (c.type === "text" && c.text) {
|
|
||||||
assert.ok(!c.text.includes("Mercury is the innermost"), "no explanation leak via tool result");
|
|
||||||
assert.ok(!/correct/i.test(c.text), "no correct-answer leak via tool result");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
runtime.session.dispose();
|
|
||||||
|
|
||||||
// ── Resume: pending re-arm from the session file ───────────────────
|
|
||||||
const sessionFile = sessionManager.getSessionFile()!;
|
|
||||||
const sm2 = SessionManager.open(sessionFile, sessionDir, PATHS.root);
|
|
||||||
const scripted2 = createScriptedRuntime([{ text: "resumed" }]);
|
|
||||||
const runtime2 = await createAgentSessionRuntime(buildTeacherRuntimeFactory("web", {
|
|
||||||
model: scripted2.model,
|
|
||||||
modelRuntime: scripted2.modelRuntime,
|
|
||||||
}), {
|
|
||||||
cwd: PATHS.root,
|
|
||||||
agentDir: PATHS.agentDir,
|
|
||||||
sessionManager: sm2,
|
|
||||||
});
|
|
||||||
// session_start fired during construction → extensions re-armed.
|
|
||||||
assert.equal(findPendingQuestion(entriesOf(sm2)), null, "answered question is NOT re-armed");
|
|
||||||
assert.equal(customEntries(sm2, QUESTION_ENTRY).length, 1, "question entry survived on disk");
|
|
||||||
runtime2.session.dispose();
|
|
||||||
|
|
||||||
// ── Resume with an UNANSWERED question pending ─────────────────────
|
|
||||||
// New session, fresh script: quiz presented but never answered.
|
|
||||||
const scripted3 = createScriptedRuntime([
|
|
||||||
{ toolCalls: [{ name: "quiz", arguments: QUIZ_ARGS }] },
|
|
||||||
{ text: "Waiting for your answer." },
|
|
||||||
]);
|
|
||||||
const sm3 = SessionManager.create(PATHS.root, sessionDir);
|
|
||||||
const runtime3 = await createAgentSessionRuntime(buildTeacherRuntimeFactory("web", {
|
|
||||||
model: scripted3.model,
|
|
||||||
modelRuntime: scripted3.modelRuntime,
|
|
||||||
}), {
|
|
||||||
cwd: PATHS.root,
|
|
||||||
agentDir: PATHS.agentDir,
|
|
||||||
sessionManager: sm3,
|
|
||||||
});
|
|
||||||
const p3 = runtime3.session.prompt("Teach me something else.");
|
|
||||||
await waitForIdle(runtime3.session);
|
|
||||||
await p3;
|
|
||||||
runtime3.session.dispose();
|
|
||||||
|
|
||||||
const file3 = sm3.getSessionFile()!;
|
|
||||||
const sm4 = SessionManager.open(file3, sessionDir, PATHS.root);
|
|
||||||
const scripted4 = createScriptedRuntime([{ text: "ok" }]);
|
|
||||||
const runtime4 = await createAgentSessionRuntime(buildTeacherRuntimeFactory("web", {
|
|
||||||
model: scripted4.model,
|
|
||||||
modelRuntime: scripted4.modelRuntime,
|
|
||||||
}), {
|
|
||||||
cwd: PATHS.root,
|
|
||||||
agentDir: PATHS.agentDir,
|
|
||||||
sessionManager: sm4,
|
|
||||||
});
|
|
||||||
const reArmed = findPendingQuestion(entriesOf(sm4));
|
|
||||||
assert.ok(reArmed, "unanswered question re-arms on resume");
|
|
||||||
assert.equal(reArmed!.kind, "quiz");
|
|
||||||
// And it is answerable through the bridge (the re-armed id).
|
|
||||||
const answerOutcome = getBridge().answer(reArmed!.id, { dontKnow: true, note: "forgot" });
|
|
||||||
assert.equal(answerOutcome.ok, true);
|
|
||||||
const answeredOnDisk = customEntries(sm4, QUESTION_ANSWERED_ENTRY) as Array<{
|
|
||||||
data?: QuestionAnswerData;
|
|
||||||
}>;
|
|
||||||
assert.equal(answeredOnDisk.length, 1);
|
|
||||||
assert.equal(answeredOnDisk[0]!.data!.dontKnow, true);
|
|
||||||
runtime4.session.dispose();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Unit tests — question-core (pure logic, no pi, no server, no LLM).
|
* Unit tests — question-core (pure logic, no server, no LLM).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
shuffleOptions,
|
shuffleOptions,
|
||||||
type QuestionData,
|
type QuestionData,
|
||||||
toClientQuestion,
|
toClientQuestion,
|
||||||
} from "../extensions/core/question-core.ts";
|
} from "../src/core/question-core.ts";
|
||||||
|
|
||||||
// ─── normalizeOptions ───────────────────────────────────────────────────────
|
// ─── normalizeOptions ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* Render smoke tests — the lesson from the v2 bugs: protocol tests don't see
|
||||||
|
* rendering. These render the interaction cards to static HTML and assert on
|
||||||
|
* the output. No DOM needed (react-dom/server).
|
||||||
|
*
|
||||||
|
* tsx note: tsx ignores tsconfig's `jsx` field and uses the CLASSIC JSX
|
||||||
|
* transform (React.createElement), so `React` must be a resolvable global.
|
||||||
|
* The shim below provides it; Vite/Next use the automatic runtime and don't
|
||||||
|
* care.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
(globalThis as Record<string, unknown>).React = React;
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { QuizCard } from "../components/quiz-card.tsx";
|
||||||
|
import { AnsweredCard } from "../components/answered-card.tsx";
|
||||||
|
import { buildAnswer, coerceCorrectAnswer, normalizeOptions, resolveCorrect, type QuestionData } from "../src/core/question-core.ts";
|
||||||
|
|
||||||
|
const quizInput = {
|
||||||
|
question: "Which planet is closest to the sun?",
|
||||||
|
options: [{ label: "Venus" }, { label: "Mercury" }, { label: "Mars" }],
|
||||||
|
correctAnswer: "Mercury",
|
||||||
|
explanation: "Mercury is the innermost planet.",
|
||||||
|
};
|
||||||
|
|
||||||
|
function quizQD(): QuestionData {
|
||||||
|
const options = normalizeOptions(quizInput.options);
|
||||||
|
return {
|
||||||
|
id: "q1",
|
||||||
|
kind: "quiz",
|
||||||
|
question: quizInput.question,
|
||||||
|
mode: "single-select",
|
||||||
|
options,
|
||||||
|
correctIndices: resolveCorrect(coerceCorrectAnswer(quizInput.correctAnswer), options).indices,
|
||||||
|
explanation: quizInput.explanation,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("render smoke tests", () => {
|
||||||
|
it("QuizCard renders question, options, and the automatic 'I don't know'", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<QuizCard toolName="quiz" toolCallId="q1" input={quizInput} onSubmit={() => {}} />,
|
||||||
|
);
|
||||||
|
assert.match(html, /Which planet is closest to the sun\?/);
|
||||||
|
assert.match(html, /Mercury/);
|
||||||
|
assert.match(html, /I don't know|I don't know/);
|
||||||
|
// The v1 desync bug: options are buttons with ARIA roles, not native inputs.
|
||||||
|
assert.ok(!html.includes('type="radio"'), "no native radio inputs");
|
||||||
|
assert.ok(html.includes('role="radio"'), "button-based radio role present");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("QuizCard never leaks the explanation or the correct answer before submit", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<QuizCard toolName="quiz" toolCallId="q1" input={quizInput} onSubmit={() => {}} />,
|
||||||
|
);
|
||||||
|
assert.ok(!html.includes("innermost"), "explanation must not render pre-answer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ask card renders Other + note and no grading affordance", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<QuizCard
|
||||||
|
toolName="ask_user_question"
|
||||||
|
toolCallId="a1"
|
||||||
|
input={{ question: "How confident are you?", options: [{ label: "Very" }, { label: "Somewhat" }] }}
|
||||||
|
onSubmit={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
assert.match(html, /Other/);
|
||||||
|
assert.ok(!html.includes("I don't know"), "ask has no 'I don't know'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AnsweredCard grades quiz answers (✓/✗ + explanation)", () => {
|
||||||
|
const answer = buildAnswer(quizQD(), { values: ["Venus"] });
|
||||||
|
const html = renderToStaticMarkup(<AnsweredCard answer={answer} />);
|
||||||
|
assert.match(html, /incorrect/);
|
||||||
|
assert.match(html, /innermost/, "explanation renders post-answer");
|
||||||
|
assert.ok(html.includes("✓") && html.includes("✗"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AnsweredCard does NOT grade ask answers", () => {
|
||||||
|
const qd: QuestionData = {
|
||||||
|
id: "a1",
|
||||||
|
kind: "ask",
|
||||||
|
question: "How confident are you?",
|
||||||
|
mode: "single-select",
|
||||||
|
options: normalizeOptions([{ label: "Very" }, { label: "Somewhat" }]),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
const answer = buildAnswer(qd, { values: ["Somewhat"] });
|
||||||
|
const html = renderToStaticMarkup(<AnsweredCard answer={answer} />);
|
||||||
|
assert.ok(!html.includes("incorrect") && !html.includes("✗"), "ask answers never get quiz grading UI");
|
||||||
|
assert.match(html, /Somewhat/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AnsweredCard renders dismissal as skipped", () => {
|
||||||
|
const answer = { ...buildAnswer(quizQD(), { dontKnow: true }), dismissed: true };
|
||||||
|
const html = renderToStaticMarkup(<AnsweredCard answer={answer} />);
|
||||||
|
assert.match(html, /skipped/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
/**
|
|
||||||
* Render smoke tests — the layer every earlier bug slipped through.
|
|
||||||
*
|
|
||||||
* Components are rendered to static HTML (no browser, no DOM) and we assert
|
|
||||||
* the expected content is actually IN the output. These would have caught:
|
|
||||||
* - Markdown never receiving its text (assistant messages rendered empty)
|
|
||||||
* - ask answers rendered as graded quiz answers (red ✗)
|
|
||||||
* - quiz options built from native radio inputs (the desync bug)
|
|
||||||
*
|
|
||||||
* Mermaid: SSR skips effects, so diagrams don't render here — that's fine,
|
|
||||||
* we only assert the surrounding markup doesn't crash.
|
|
||||||
*
|
|
||||||
* Note: components carry `import React from "react"` because tsx (the test
|
|
||||||
* runner) uses the classic JSX transform, which needs React in scope. Vite
|
|
||||||
* uses the automatic runtime, where the import is simply unused.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { describe, it } from "node:test";
|
|
||||||
import { createElement as h } from "react";
|
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
|
||||||
import { Markdown } from "../web/src/components/Markdown.tsx";
|
|
||||||
import { QuizCard } from "../web/src/components/QuizCard.tsx";
|
|
||||||
import { AnsweredCard } from "../web/src/components/AnsweredCard.tsx";
|
|
||||||
import { EntryBlock } from "../web/src/views/SessionView.tsx";
|
|
||||||
import type { ClientQuestion, QuestionAnswerData } from "../src/shared/protocol.ts";
|
|
||||||
|
|
||||||
describe("render smoke tests", () => {
|
|
||||||
it("Markdown renders the actual text content", () => {
|
|
||||||
const html = renderToStaticMarkup(h(Markdown, { text: "Hello **bold** world" }));
|
|
||||||
assert.match(html, /Hello/);
|
|
||||||
assert.match(html, /<strong>bold<\/strong>/);
|
|
||||||
assert.match(html, /world/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Markdown renders LaTeX via KaTeX", () => {
|
|
||||||
const html = renderToStaticMarkup(h(Markdown, { text: "Energy is $E = mc^2$ here." }));
|
|
||||||
assert.match(html, /here\./);
|
|
||||||
assert.match(html, /katex/i, "KaTeX markup present");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Markdown survives a mermaid block without crashing", () => {
|
|
||||||
const html = renderToStaticMarkup(h(Markdown, { text: "Before\n\n```mermaid\ngraph TD\nA-->B\n```\n\nAfter" }));
|
|
||||||
assert.match(html, /Before/);
|
|
||||||
assert.match(html, /After/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("QuizCard renders options as buttons (no native radio/checkbox inputs)", () => {
|
|
||||||
const q: ClientQuestion = {
|
|
||||||
id: "q1",
|
|
||||||
kind: "quiz",
|
|
||||||
question: "Which planet?",
|
|
||||||
mode: "single-select",
|
|
||||||
options: [
|
|
||||||
{ label: "Mercury", value: "mercury" },
|
|
||||||
{ label: "Venus", value: "venus" },
|
|
||||||
],
|
|
||||||
multiSelect: false,
|
|
||||||
};
|
|
||||||
const html = renderToStaticMarkup(h(QuizCard, { question: q, send: () => {} }));
|
|
||||||
assert.match(html, /Mercury/);
|
|
||||||
assert.match(html, /Venus/);
|
|
||||||
assert.match(html, /I don't know/);
|
|
||||||
assert.ok(!/<input[^>]*type="radio"/.test(html), "no native radio inputs (v1 desync bug)");
|
|
||||||
assert.ok(!/<input[^>]*type="checkbox"/.test(html), "no native checkbox inputs");
|
|
||||||
assert.match(html, /<button[^>]*role="radio"/, "options are buttons with radio role");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("QuizCard multi-select uses checkbox role on buttons", () => {
|
|
||||||
const q: ClientQuestion = {
|
|
||||||
id: "q2",
|
|
||||||
kind: "quiz",
|
|
||||||
question: "Pick all.",
|
|
||||||
mode: "multi-select",
|
|
||||||
options: [
|
|
||||||
{ label: "A", value: "a" },
|
|
||||||
{ label: "B", value: "b" },
|
|
||||||
],
|
|
||||||
multiSelect: true,
|
|
||||||
};
|
|
||||||
const html = renderToStaticMarkup(h(QuizCard, { question: q, send: () => {} }));
|
|
||||||
assert.match(html, /<button[^>]*role="checkbox"/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("AnsweredCard: quiz answers are graded", () => {
|
|
||||||
const data: QuestionAnswerData = {
|
|
||||||
questionId: "q1",
|
|
||||||
kind: "quiz",
|
|
||||||
questionText: "Which planet?",
|
|
||||||
mode: "single-select",
|
|
||||||
options: [
|
|
||||||
{ index: 1, label: "Mercury" },
|
|
||||||
{ index: 2, label: "Venus" },
|
|
||||||
],
|
|
||||||
answers: [{ index: 1, label: "Mercury", value: "mercury" }],
|
|
||||||
correctIndices: [1],
|
|
||||||
correct: true,
|
|
||||||
explanation: "Because.",
|
|
||||||
};
|
|
||||||
const html = renderToStaticMarkup(h(AnsweredCard, { data }));
|
|
||||||
assert.match(html, /Correct/);
|
|
||||||
assert.match(html, /✓/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("AnsweredCard: ask answers are NOT graded (no ✗, no verdict)", () => {
|
|
||||||
const data: QuestionAnswerData = {
|
|
||||||
questionId: "q1",
|
|
||||||
kind: "ask",
|
|
||||||
questionText: "What do you prefer?",
|
|
||||||
mode: "single-select",
|
|
||||||
options: [
|
|
||||||
{ index: 1, label: "Option A" },
|
|
||||||
{ index: 2, label: "Option B" },
|
|
||||||
],
|
|
||||||
answers: [{ index: 2, label: "Option B", value: "b" }],
|
|
||||||
};
|
|
||||||
const html = renderToStaticMarkup(h(AnsweredCard, { data }));
|
|
||||||
assert.match(html, /Option B/);
|
|
||||||
assert.ok(!html.includes("✗"), "ask answers never show ✗ (v1 grading bug)");
|
|
||||||
assert.ok(!/Incorrect/.test(html), "no grading verdict for ask");
|
|
||||||
assert.ok(!/Correct<\/div>/.test(html));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("AnsweredCard: dismissed questions render as skipped", () => {
|
|
||||||
const data: QuestionAnswerData = {
|
|
||||||
questionId: "q1",
|
|
||||||
kind: "quiz",
|
|
||||||
questionText: "Q?",
|
|
||||||
mode: "single-select",
|
|
||||||
options: [],
|
|
||||||
answers: [],
|
|
||||||
dismissed: true,
|
|
||||||
};
|
|
||||||
const html = renderToStaticMarkup(h(AnsweredCard, { data }));
|
|
||||||
assert.match(html, /Skipped/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("EntryBlock: toolResult entries (LLM plumbing) are never rendered", () => {
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
h(EntryBlock, {
|
|
||||||
entry: {
|
|
||||||
type: "toolResult",
|
|
||||||
id: "e1",
|
|
||||||
toolName: "quiz",
|
|
||||||
text: "Quiz displayed to the learner. End your turn now and wait…",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
assert.ok(!html.includes("End your turn"), "dispatch instructions must not leak into the transcript");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("EntryBlock: quiz-answer user messages are not duplicated in the transcript", () => {
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
h(EntryBlock, { entry: { type: "user", id: "e2", text: "[Quiz answer] Selected: Mercury" } }),
|
|
||||||
);
|
|
||||||
assert.ok(!html.includes("Mercury"), "already rendered as an answer card");
|
|
||||||
const normal = renderToStaticMarkup(h(EntryBlock, { entry: { type: "user", id: "e3", text: "Teach me physics" } }));
|
|
||||||
assert.match(normal, /Teach me physics/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,330 +0,0 @@
|
|||||||
/**
|
|
||||||
* Integration test — the web server with a SCRIPTED model runtime.
|
|
||||||
*
|
|
||||||
* Real Fastify server, real WebSocket protocol, real web extensions and
|
|
||||||
* bridge, real session files — the only fake is the LLM. This is the test
|
|
||||||
* layer that catches exactly the class of bugs reported from the browser:
|
|
||||||
*
|
|
||||||
* - exactly-once entry delivery (no duplicated user messages, ever —
|
|
||||||
* including sockets that connect after the turn already happened)
|
|
||||||
* - thinking + text deltas streamed live
|
|
||||||
* - quiz answer flow over WS
|
|
||||||
* - multiple sockets seeing the same history
|
|
||||||
*
|
|
||||||
* Deterministic: zero tokens, zero variance, runs in milliseconds.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
import { after, before, describe, it } from "node:test";
|
|
||||||
import { createAgentSessionRuntime } from "@earendil-works/pi-coding-agent";
|
|
||||||
import { PATHS } from "../config.ts";
|
|
||||||
import { buildTeacherRuntimeFactory } from "../src/teacher/create-session.ts";
|
|
||||||
import { createScriptedRuntime } from "../src/testing/scripted-model.ts";
|
|
||||||
import { startServer } from "../src/server/index.ts";
|
|
||||||
import type { ServerMessage } from "../src/shared/protocol.ts";
|
|
||||||
|
|
||||||
const QUIZ_ARGS = {
|
|
||||||
question: "Which is closest to the Sun?",
|
|
||||||
options: [
|
|
||||||
{ label: "Mercury", value: "mercury" },
|
|
||||||
{ label: "Venus", value: "venus" },
|
|
||||||
],
|
|
||||||
correctAnswer: "mercury",
|
|
||||||
explanation: "Mercury is the innermost planet.",
|
|
||||||
};
|
|
||||||
|
|
||||||
let port: number;
|
|
||||||
let closeServer: () => Promise<void>;
|
|
||||||
|
|
||||||
/** The script for the NEXT session the server activates (tests swap it). */
|
|
||||||
let currentScript: import("../src/testing/scripted-model.ts").ScriptedTurn[] = [
|
|
||||||
// Turn 1: think, then call quiz.
|
|
||||||
{
|
|
||||||
thinking: "I should probe the learner's prior knowledge first.",
|
|
||||||
toolCalls: [{ name: "quiz", arguments: QUIZ_ARGS }],
|
|
||||||
},
|
|
||||||
// Turn 2 (after the tool result): end turn.
|
|
||||||
{ text: "Take a shot." },
|
|
||||||
// Turn 3 (after the [Quiz answer] user message): wrap up.
|
|
||||||
{ text: "Right — Mercury it is. Lesson over." },
|
|
||||||
];
|
|
||||||
|
|
||||||
before(async () => {
|
|
||||||
const runtimeFactory = async (options: {
|
|
||||||
cwd: string;
|
|
||||||
agentDir: string;
|
|
||||||
sessionManager: import("@earendil-works/pi-coding-agent").SessionManager;
|
|
||||||
}) => {
|
|
||||||
const scripted = createScriptedRuntime(currentScript);
|
|
||||||
return createAgentSessionRuntime(
|
|
||||||
buildTeacherRuntimeFactory("web", { model: scripted.model, modelRuntime: scripted.modelRuntime }),
|
|
||||||
options,
|
|
||||||
) as never;
|
|
||||||
};
|
|
||||||
const server = await startServer({ port: 0, runtimeFactory });
|
|
||||||
port = server.port;
|
|
||||||
closeServer = server.close;
|
|
||||||
});
|
|
||||||
|
|
||||||
after(async () => {
|
|
||||||
await closeServer();
|
|
||||||
// The tests created real session files — remove them so the learner's
|
|
||||||
// data/sessions stays clean.
|
|
||||||
for (const file of createdFiles) {
|
|
||||||
try {
|
|
||||||
await fs.rm(path.join(PATHS.sessions, file), { force: true });
|
|
||||||
} catch {
|
|
||||||
// best effort
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Session files this test created (cleaned up in after()). */
|
|
||||||
const createdFiles: string[] = [];
|
|
||||||
|
|
||||||
async function createTestSession(topic: string): Promise<{ id: string; file: string }> {
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/api/sessions`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
body: JSON.stringify({ topic }),
|
|
||||||
});
|
|
||||||
assert.ok(res.ok, `create failed: ${res.status}`);
|
|
||||||
const created = (await res.json()) as { id: string; file: string };
|
|
||||||
assert.ok(created.file.endsWith(".jsonl"), "create returns the session file");
|
|
||||||
createdFiles.push(created.file);
|
|
||||||
return created;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── WS test helper ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface Collected {
|
|
||||||
ws: WebSocket;
|
|
||||||
msgs: ServerMessage[];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openSocket(): Promise<Collected> {
|
|
||||||
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`);
|
|
||||||
const msgs: ServerMessage[] = [];
|
|
||||||
const opened = new Promise<void>((resolve, reject) => {
|
|
||||||
ws.onopen = () => resolve();
|
|
||||||
ws.onerror = () => reject(new Error("ws connect failed"));
|
|
||||||
});
|
|
||||||
ws.onmessage = (ev) => {
|
|
||||||
msgs.push(JSON.parse(String(ev.data)) as ServerMessage);
|
|
||||||
};
|
|
||||||
await opened;
|
|
||||||
return { ws, msgs };
|
|
||||||
}
|
|
||||||
|
|
||||||
function waitMsg(
|
|
||||||
c: Collected,
|
|
||||||
pred: (m: ServerMessage) => boolean,
|
|
||||||
what: string,
|
|
||||||
timeoutMs = 5000,
|
|
||||||
): Promise<ServerMessage> {
|
|
||||||
const found = c.msgs.find(pred);
|
|
||||||
if (found) return Promise.resolve(found);
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
const m = c.msgs.find(pred);
|
|
||||||
if (m) {
|
|
||||||
clearInterval(interval);
|
|
||||||
clearTimeout(timer);
|
|
||||||
resolve(m);
|
|
||||||
}
|
|
||||||
}, 15);
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
clearInterval(interval);
|
|
||||||
reject(new Error(`timeout waiting for ${what}`));
|
|
||||||
}, timeoutMs);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function waitSilence(c: Collected, settleMs = 300): Promise<void> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
let last = c.msgs.length;
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
if (c.msgs.length === last) {
|
|
||||||
clearInterval(interval);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
last = c.msgs.length;
|
|
||||||
}, settleMs / 3);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every entry id a socket has seen (backfill + live), in order. */
|
|
||||||
function seenEntryIds(msgs: ServerMessage[]): string[] {
|
|
||||||
const ids: string[] = [];
|
|
||||||
for (const m of msgs) {
|
|
||||||
if (m.type === "backfill") ids.push(...m.entries.map((e) => e.id));
|
|
||||||
if (m.type === "entry") ids.push(m.entry.id);
|
|
||||||
}
|
|
||||||
return ids;
|
|
||||||
}
|
|
||||||
|
|
||||||
function seenUserTexts(msgs: ServerMessage[]): string[] {
|
|
||||||
const texts: string[] = [];
|
|
||||||
for (const m of msgs) {
|
|
||||||
if (m.type === "backfill") texts.push(...m.entries.filter((e) => e.type === "user").map((e) => e.text));
|
|
||||||
if (m.type === "entry" && m.entry.type === "user") texts.push(m.entry.text);
|
|
||||||
}
|
|
||||||
return texts;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── the test ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("web server protocol (scripted model)", () => {
|
|
||||||
it("delivers every entry exactly once, streams thinking, and handles the answer flow", async () => {
|
|
||||||
const TOPIC = "Teach me the solar system.";
|
|
||||||
|
|
||||||
// Connect BEFORE creating the session so we see every live event.
|
|
||||||
const c1 = await openSocket();
|
|
||||||
|
|
||||||
// ── create the session ────────────────────────────────────────────
|
|
||||||
const created = await createTestSession(TOPIC);
|
|
||||||
assert.ok(created.file.endsWith(".jsonl"));
|
|
||||||
|
|
||||||
// ── the first turn: thinking delta, quiz presented, agent settles ──
|
|
||||||
const thinkingDelta = await waitMsg(
|
|
||||||
c1,
|
|
||||||
(m) => m.type === "delta" && m.kind === "thinking",
|
|
||||||
"thinking delta",
|
|
||||||
);
|
|
||||||
assert.equal((thinkingDelta as { delta: string }).delta.length > 0, true);
|
|
||||||
|
|
||||||
const pendingMsg = await waitMsg(
|
|
||||||
c1,
|
|
||||||
(m) => m.type === "pending" && m.question !== null,
|
|
||||||
"pending question",
|
|
||||||
);
|
|
||||||
const question = (pendingMsg as { question: { id: string; question: string; kind: string } }).question;
|
|
||||||
assert.equal(question.kind, "quiz");
|
|
||||||
// No leak in the client payload.
|
|
||||||
const pendingJson = JSON.stringify(pendingMsg);
|
|
||||||
assert.ok(!pendingJson.includes("Mercury is the innermost"), "no explanation leak to client");
|
|
||||||
|
|
||||||
await waitMsg(c1, (m) => m.type === "streaming" && m.value === false, "settle after quiz turn");
|
|
||||||
|
|
||||||
// The topic user message must have arrived EXACTLY once.
|
|
||||||
await waitSilence(c1);
|
|
||||||
const topicCount = seenUserTexts(c1.msgs).filter((t) => t === TOPIC).length;
|
|
||||||
assert.equal(topicCount, 1, "topic message delivered exactly once (the v1 duplication bug)");
|
|
||||||
const ids1 = seenEntryIds(c1.msgs);
|
|
||||||
assert.equal(ids1.length, new Set(ids1).size, "no duplicate entry ids on socket 1");
|
|
||||||
|
|
||||||
// ── answer the quiz ───────────────────────────────────────────────
|
|
||||||
c1.ws.send(JSON.stringify({ type: "answer", questionId: question.id, values: ["mercury"] }));
|
|
||||||
|
|
||||||
await waitMsg(
|
|
||||||
c1,
|
|
||||||
(m) => m.type === "entry" && m.entry.type === "questionAnswered",
|
|
||||||
"questionAnswered entry",
|
|
||||||
);
|
|
||||||
const answerUserMsg = await waitMsg(
|
|
||||||
c1,
|
|
||||||
(m) => m.type === "entry" && m.entry.type === "user" && m.entry.text.startsWith("[Quiz answer]"),
|
|
||||||
"[Quiz answer] user entry",
|
|
||||||
);
|
|
||||||
assert.match((answerUserMsg as { entry: { text: string } }).entry.text, /Result: CORRECT/);
|
|
||||||
|
|
||||||
// Pending cleared after answering.
|
|
||||||
await waitMsg(c1, (m) => m.type === "pending" && m.question === null, "pending cleared");
|
|
||||||
// Agent continues after the answer and settles.
|
|
||||||
await waitMsg(c1, (m) => m.type === "streaming" && m.value === false, "settle after answer");
|
|
||||||
await waitSilence(c1);
|
|
||||||
|
|
||||||
const ids1b = seenEntryIds(c1.msgs);
|
|
||||||
assert.equal(ids1b.length, new Set(ids1b).size, "still no duplicate entry ids after answering");
|
|
||||||
|
|
||||||
// ── a SECOND socket connects late: same history, exactly once ─────
|
|
||||||
const c2 = await openSocket();
|
|
||||||
await waitMsg(c2, (m) => m.type === "backfill", "socket 2 backfill");
|
|
||||||
await waitSilence(c2);
|
|
||||||
|
|
||||||
const ids2 = seenEntryIds(c2.msgs);
|
|
||||||
assert.equal(ids2.length, new Set(ids2).size, "no duplicate entry ids on socket 2");
|
|
||||||
assert.deepEqual([...ids2].sort(), [...ids1b].sort(), "late socket sees the same history");
|
|
||||||
|
|
||||||
// Thinking survives in the persisted assistant entry.
|
|
||||||
const assistantEntry = c2.msgs
|
|
||||||
.flatMap((m) => (m.type === "backfill" ? m.entries : []))
|
|
||||||
.find((e) => e.type === "assistant" && (e as { thinking?: string }).thinking);
|
|
||||||
assert.ok(assistantEntry, "persisted assistant entry carries thinking");
|
|
||||||
assert.match((assistantEntry as { thinking: string }).thinking, /probe the learner/);
|
|
||||||
|
|
||||||
// ── follow-up prompt: both sockets get it exactly once ────────────
|
|
||||||
c1.ws.send(JSON.stringify({ type: "prompt", text: "thanks, that's enough for now" }));
|
|
||||||
await waitMsg(
|
|
||||||
c1,
|
|
||||||
(m) => m.type === "entry" && m.entry.type === "user" && m.entry.text.includes("enough for now"),
|
|
||||||
"follow-up user entry on socket 1",
|
|
||||||
);
|
|
||||||
await waitMsg(
|
|
||||||
c2,
|
|
||||||
(m) => m.type === "entry" && m.entry.type === "user" && m.entry.text.includes("enough for now"),
|
|
||||||
"follow-up user entry on socket 2",
|
|
||||||
);
|
|
||||||
await waitMsg(c1, (m) => m.type === "streaming" && m.value === false, "settle after follow-up");
|
|
||||||
await waitSilence(c1);
|
|
||||||
await waitSilence(c2);
|
|
||||||
|
|
||||||
for (const [name, c] of [
|
|
||||||
["socket1", c1],
|
|
||||||
["socket2", c2],
|
|
||||||
] as const) {
|
|
||||||
const ids = seenEntryIds(c.msgs);
|
|
||||||
assert.equal(ids.length, new Set(ids).size, `no duplicate entry ids on ${name}`);
|
|
||||||
const texts = seenUserTexts(c.msgs);
|
|
||||||
assert.equal(texts.filter((t) => t === TOPIC).length, 1, `${name}: topic once`);
|
|
||||||
assert.equal(texts.filter((t) => t.includes("enough for now")).length, 1, `${name}: follow-up once`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── the pending-question guard ────────────────────────────────────
|
|
||||||
// (No pending question now, so a prompt must NOT be rejected:)
|
|
||||||
// verified implicitly above — the follow-up prompt went through.
|
|
||||||
|
|
||||||
c1.ws.close();
|
|
||||||
c2.ws.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects prompts while a question is pending", async () => {
|
|
||||||
currentScript = [
|
|
||||||
{ toolCalls: [{ name: "quiz", arguments: QUIZ_ARGS }] },
|
|
||||||
{ text: "Take a shot." },
|
|
||||||
];
|
|
||||||
await createTestSession("Teach me something with a quiz.");
|
|
||||||
const c = await openSocket();
|
|
||||||
const pendingMsg = await waitMsg(
|
|
||||||
c,
|
|
||||||
(m) => m.type === "pending" && m.question !== null,
|
|
||||||
"pending question",
|
|
||||||
);
|
|
||||||
const question = (pendingMsg as { question: { id: string } }).question;
|
|
||||||
c.ws.send(JSON.stringify({ type: "prompt", text: "hello?" }));
|
|
||||||
const err = await waitMsg(c, (m) => m.type === "error", "guard rejection");
|
|
||||||
assert.match((err as { message: string }).message, /Answer the pending question/);
|
|
||||||
|
|
||||||
// Clean up: dismiss so subsequent state is clean.
|
|
||||||
c.ws.send(JSON.stringify({ type: "dismiss", questionId: question.id }));
|
|
||||||
await waitMsg(
|
|
||||||
c,
|
|
||||||
(m) => m.type === "entry" && m.entry.type === "questionAnswered",
|
|
||||||
"dismissal recorded",
|
|
||||||
);
|
|
||||||
c.ws.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lists sessions including the created ones", async () => {
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/api/sessions`);
|
|
||||||
assert.ok(res.ok);
|
|
||||||
const data = (await res.json()) as { sessions: Array<{ file: string; name?: string }> };
|
|
||||||
assert.ok(data.sessions.length >= 2, "at least the two sessions from this test");
|
|
||||||
assert.ok(
|
|
||||||
data.sessions.some((s) => s.name === "Teach me the solar system."),
|
|
||||||
"session named after its topic",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+45
-15
@@ -1,17 +1,47 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "NodeNext",
|
"lib": [
|
||||||
"moduleResolution": "NodeNext",
|
"ES2023",
|
||||||
"lib": ["ES2022"],
|
"dom",
|
||||||
"types": ["node"],
|
"dom.iterable"
|
||||||
"strict": true,
|
],
|
||||||
"noUncheckedIndexedAccess": true,
|
"module": "esnext",
|
||||||
"verbatimModuleSyntax": true,
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"jsx": "react-jsx",
|
||||||
"jsx": "react-jsx",
|
"strict": true,
|
||||||
"noEmit": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"skipLibCheck": true
|
"verbatimModuleSyntax": true,
|
||||||
},
|
"allowImportingTsExtensions": true,
|
||||||
"include": ["config.ts", "src/**/*.ts", "extensions/core/**/*.ts"]
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"incremental": true,
|
||||||
|
"types": [
|
||||||
|
"node"
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"allowJs": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import { defineConfig } from "vite";
|
|
||||||
import react from "@vitejs/plugin-react";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
root: "web",
|
|
||||||
plugins: [react()],
|
|
||||||
build: {
|
|
||||||
outDir: "dist",
|
|
||||||
emptyOutDir: true,
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
port: 5173,
|
|
||||||
proxy: {
|
|
||||||
"/api": "http://127.0.0.1:8787",
|
|
||||||
"/ws": {
|
|
||||||
target: "ws://127.0.0.1:8787",
|
|
||||||
ws: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>learning harness</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
-113
@@ -1,113 +0,0 @@
|
|||||||
/**
|
|
||||||
* App — URL-routed shell.
|
|
||||||
*
|
|
||||||
* / home (session list, graph placeholder, new-session input)
|
|
||||||
* /s/<file> teaching session (own URL → deep links, refresh, back button)
|
|
||||||
*
|
|
||||||
* History API navigation (no router dependency). The server only ever has ONE
|
|
||||||
* active session, so we track which file we last activated this SPA lifetime:
|
|
||||||
* a session view mounts "already active" only for that file; anything else
|
|
||||||
* resumes via POST before connecting.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import type { NewSessionResponse, SessionSummary } from "../../src/shared/protocol.ts";
|
|
||||||
import { Home } from "./views/Home.tsx";
|
|
||||||
import { SessionView } from "./views/SessionView.tsx";
|
|
||||||
|
|
||||||
type Route = { view: "home" } | { view: "session"; file: string };
|
|
||||||
|
|
||||||
function parseRoute(): Route {
|
|
||||||
const m = location.pathname.match(/^\/s\/([^/]+)\/?$/);
|
|
||||||
if (m && m[1]) return { view: "session", file: decodeURIComponent(m[1]) };
|
|
||||||
return { view: "home" };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function App() {
|
|
||||||
const [route, setRoute] = useState<Route>(parseRoute);
|
|
||||||
const [sessions, setSessions] = useState<SessionSummary[]>([]);
|
|
||||||
const [homeError, setHomeError] = useState<string | null>(null);
|
|
||||||
/** Session file last activated on the server during this SPA lifetime. */
|
|
||||||
const activeFileRef = useRef<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onPop = () => setRoute(parseRoute());
|
|
||||||
window.addEventListener("popstate", onPop);
|
|
||||||
return () => window.removeEventListener("popstate", onPop);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const navigate = useCallback((to: string) => {
|
|
||||||
history.pushState(null, "", to);
|
|
||||||
setRoute(parseRoute());
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const refreshSessions = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/sessions");
|
|
||||||
const data = (await res.json()) as { sessions: SessionSummary[] };
|
|
||||||
setSessions(data.sessions ?? []);
|
|
||||||
} catch {
|
|
||||||
// server down — keep whatever we have
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (route.view === "home") void refreshSessions();
|
|
||||||
}, [route, refreshSessions]);
|
|
||||||
|
|
||||||
const goHome = useCallback(() => {
|
|
||||||
navigate("/");
|
|
||||||
}, [navigate]);
|
|
||||||
|
|
||||||
const startSession = useCallback(
|
|
||||||
async (topic: string) => {
|
|
||||||
setHomeError(null);
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/sessions", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
body: JSON.stringify({ topic }),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`failed to create session (${res.status})`);
|
|
||||||
const data = (await res.json()) as NewSessionResponse;
|
|
||||||
activeFileRef.current = data.file;
|
|
||||||
navigate(`/s/${encodeURIComponent(data.file)}`);
|
|
||||||
} catch (err) {
|
|
||||||
setHomeError(String(err instanceof Error ? err.message : err));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[navigate],
|
|
||||||
);
|
|
||||||
|
|
||||||
const openSession = useCallback(
|
|
||||||
(file: string) => {
|
|
||||||
// SessionView resumes the file itself if it isn't the active one.
|
|
||||||
navigate(`/s/${encodeURIComponent(file)}`);
|
|
||||||
},
|
|
||||||
[navigate],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (route.view === "home") {
|
|
||||||
return (
|
|
||||||
<Home
|
|
||||||
sessions={sessions}
|
|
||||||
onStart={startSession}
|
|
||||||
onOpen={openSession}
|
|
||||||
onRefresh={refreshSessions}
|
|
||||||
error={homeError}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SessionView
|
|
||||||
key={route.file}
|
|
||||||
file={route.file}
|
|
||||||
active={activeFileRef.current === route.file}
|
|
||||||
onActivated={(file) => {
|
|
||||||
activeFileRef.current = file;
|
|
||||||
}}
|
|
||||||
onHome={goHome}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
-786
@@ -1,786 +0,0 @@
|
|||||||
/* learning harness — minimal v1 dark theme */
|
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--bg: #14161a;
|
|
||||||
--bg-panel: #1b1e24;
|
|
||||||
--bg-raised: #22262e;
|
|
||||||
--border: #2e333d;
|
|
||||||
--text: #d8dce4;
|
|
||||||
--text-muted: #8b93a3;
|
|
||||||
--accent: #7aa2f7;
|
|
||||||
--success: #9ece6a;
|
|
||||||
--error: #f7768e;
|
|
||||||
--warning: #e0af68;
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
|
||||||
body,
|
|
||||||
#root {
|
|
||||||
height: 100%;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
font: inherit;
|
|
||||||
color: inherit;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 6px 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
button:disabled {
|
|
||||||
opacity: 0.45;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
button.primary {
|
|
||||||
background: var(--accent);
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: #10131a;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
button.ghost {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
button.icon-btn {
|
|
||||||
padding: 2px 8px;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
button.icon-btn:hover {
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="text"],
|
|
||||||
textarea {
|
|
||||||
font: inherit;
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 9px 12px;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
input[type="text"]:focus,
|
|
||||||
textarea:focus {
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
input[type="text"]:disabled,
|
|
||||||
textarea:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── home ─────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.home {
|
|
||||||
display: flex;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
width: 260px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
background: var(--bg-panel);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 14px 14px 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-list {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 0 8px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-item {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 9px 10px;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
.session-item:hover {
|
|
||||||
background: var(--bg-raised);
|
|
||||||
}
|
|
||||||
.session-name {
|
|
||||||
color: var(--text);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.session-meta {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
color: var(--text-muted);
|
|
||||||
padding: 12px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-main {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 24px;
|
|
||||||
gap: 20px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.graph-placeholder {
|
|
||||||
flex: 1;
|
|
||||||
border: 1px dashed var(--border);
|
|
||||||
border-radius: 10px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: var(--bg-panel);
|
|
||||||
}
|
|
||||||
|
|
||||||
.graph-placeholder-inner {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.graph-placeholder-title {
|
|
||||||
font-size: 17px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.graph-placeholder-sub {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
opacity: 0.7;
|
|
||||||
margin-top: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-session {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-session input {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── session view ─────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.session {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 10px 18px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
background: var(--bg-panel);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-head-status {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
.dot.on {
|
|
||||||
background: var(--accent);
|
|
||||||
animation: pulse 1.2s infinite;
|
|
||||||
}
|
|
||||||
.dot.ok {
|
|
||||||
background: var(--success);
|
|
||||||
}
|
|
||||||
.dot.q {
|
|
||||||
background: var(--warning);
|
|
||||||
}
|
|
||||||
.dot.off {
|
|
||||||
background: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
opacity: 0.35;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.abort-btn {
|
|
||||||
border-color: var(--error);
|
|
||||||
color: var(--error);
|
|
||||||
background: transparent;
|
|
||||||
padding: 4px 12px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.transcript {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 20px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.msg {
|
|
||||||
padding: 4px 24px;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.msg.user {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.msg.user .msg-bubble {
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 12px 12px 4px 12px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
max-width: 72%;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.msg.assistant {
|
|
||||||
max-width: 820px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 3px 12px;
|
|
||||||
font-size: 12.5px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
margin-right: 8px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
.tool-name {
|
|
||||||
color: var(--accent);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.tool-arg {
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
max-width: 420px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.composer {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 14px 18px;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
background: var(--bg-panel);
|
|
||||||
}
|
|
||||||
|
|
||||||
.composer input {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-banner {
|
|
||||||
margin: 10px 24px;
|
|
||||||
padding: 9px 14px;
|
|
||||||
border: 1px solid var(--error);
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--error);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── markdown ─────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.markdown p {
|
|
||||||
margin: 0 0 12px;
|
|
||||||
}
|
|
||||||
.markdown h1,
|
|
||||||
.markdown h2,
|
|
||||||
.markdown h3 {
|
|
||||||
margin: 20px 0 10px;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
.markdown h1 {
|
|
||||||
font-size: 22px;
|
|
||||||
}
|
|
||||||
.markdown h2 {
|
|
||||||
font-size: 19px;
|
|
||||||
}
|
|
||||||
.markdown h3 {
|
|
||||||
font-size: 16.5px;
|
|
||||||
}
|
|
||||||
.markdown ul,
|
|
||||||
.markdown ol {
|
|
||||||
margin: 0 0 12px;
|
|
||||||
padding-left: 26px;
|
|
||||||
}
|
|
||||||
.markdown li {
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
.markdown code {
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 1px 5px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
|
||||||
}
|
|
||||||
.markdown pre {
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
.markdown pre code {
|
|
||||||
background: none;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.markdown blockquote {
|
|
||||||
border-left: 3px solid var(--border);
|
|
||||||
margin: 0 0 12px;
|
|
||||||
padding: 2px 14px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
.markdown table {
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
.markdown th,
|
|
||||||
.markdown td {
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
padding: 6px 12px;
|
|
||||||
}
|
|
||||||
.markdown .katex-display {
|
|
||||||
overflow-x: auto;
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mermaid {
|
|
||||||
background: var(--bg-panel);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 12px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
.mermaid svg {
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── quiz cards ───────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.quiz-card {
|
|
||||||
max-width: 680px;
|
|
||||||
margin: 10px 24px 18px;
|
|
||||||
background: var(--bg-panel);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 16px 18px;
|
|
||||||
}
|
|
||||||
.quiz-card.quiz {
|
|
||||||
border-color: #3b4252;
|
|
||||||
}
|
|
||||||
.quiz-card.answered {
|
|
||||||
opacity: 0.92;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiz-kind {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--accent);
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
.quiz-card.ask .quiz-kind {
|
|
||||||
color: var(--warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiz-question {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiz-context {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 13.5px;
|
|
||||||
margin-top: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiz-options {
|
|
||||||
margin-top: 12px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiz-option {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
}
|
|
||||||
.quiz-option:hover {
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
.quiz-option.selected {
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
.quiz-option.static {
|
|
||||||
cursor: default;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
.quiz-option.static.correct {
|
|
||||||
border-color: var(--success);
|
|
||||||
}
|
|
||||||
.quiz-option.static.wrong {
|
|
||||||
border-color: var(--error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiz-option input {
|
|
||||||
margin-top: 3px;
|
|
||||||
accent-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiz-option-label {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiz-option-desc {
|
|
||||||
display: block;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 12.5px;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiz-mark {
|
|
||||||
width: 18px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
text-align: center;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.quiz-option.static.correct .quiz-mark {
|
|
||||||
color: var(--success);
|
|
||||||
}
|
|
||||||
.quiz-option.static.wrong .quiz-mark {
|
|
||||||
color: var(--error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiz-option.dont-know {
|
|
||||||
border-style: dashed;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
.quiz-option.dont-know.selected {
|
|
||||||
color: var(--warning);
|
|
||||||
border-color: var(--warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiz-other,
|
|
||||||
.quiz-text {
|
|
||||||
margin-top: 8px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiz-note {
|
|
||||||
margin-top: 10px;
|
|
||||||
width: 100%;
|
|
||||||
resize: vertical;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiz-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiz-verdict {
|
|
||||||
margin-top: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.quiz-verdict.correct {
|
|
||||||
color: var(--success);
|
|
||||||
}
|
|
||||||
.quiz-verdict.wrong {
|
|
||||||
color: var(--error);
|
|
||||||
}
|
|
||||||
.quiz-verdict.dont-know {
|
|
||||||
color: var(--warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiz-note-display {
|
|
||||||
margin-top: 8px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 13.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiz-explanation {
|
|
||||||
margin-top: 10px;
|
|
||||||
padding-top: 10px;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiz-text-answer {
|
|
||||||
margin-top: 10px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── v2: transcript wrap + stick-to-bottom ─────────────────────────── */
|
|
||||||
|
|
||||||
.transcript-wrap {
|
|
||||||
flex: 1;
|
|
||||||
position: relative;
|
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.transcript-wrap .transcript {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.jump-latest {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 16px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 999px;
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 12.5px;
|
|
||||||
padding: 6px 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
|
|
||||||
z-index: 5;
|
|
||||||
}
|
|
||||||
.jump-latest:hover {
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── v2: message status (optimistic echo) ──────────────────────────── */
|
|
||||||
|
|
||||||
.msg.user.sending .msg-bubble {
|
|
||||||
opacity: 0.55;
|
|
||||||
}
|
|
||||||
.msg.user.failed .msg-bubble {
|
|
||||||
border-color: var(--error);
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
.msg-status {
|
|
||||||
font-size: 11.5px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
align-self: flex-end;
|
|
||||||
margin: 4px 6px 0 0;
|
|
||||||
}
|
|
||||||
.msg-status.error {
|
|
||||||
color: var(--error);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── v2: thinking blocks ───────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.thinking {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
.thinking-toggle {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 12.5px;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 2px 0;
|
|
||||||
display: inline-flex;
|
|
||||||
gap: 6px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.thinking-toggle:hover {
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
.thinking-caret {
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
.thinking-body {
|
|
||||||
border-left: 2px solid var(--border);
|
|
||||||
padding: 4px 0 4px 12px;
|
|
||||||
margin-top: 6px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.55;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
max-height: 320px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
.thinking.streaming .thinking-body {
|
|
||||||
animation: thinking-pulse 1.6s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
.thinking-pulse {
|
|
||||||
animation: thinking-pulse 1.6s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
@keyframes thinking-pulse {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
opacity: 0.45;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── v2: live streaming text ───────────────────────────────────────── */
|
|
||||||
|
|
||||||
.live-text {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.stream-cursor {
|
|
||||||
display: inline-block;
|
|
||||||
width: 8px;
|
|
||||||
height: 15px;
|
|
||||||
background: var(--accent);
|
|
||||||
margin-left: 2px;
|
|
||||||
animation: cursor-blink 1s step-end infinite;
|
|
||||||
vertical-align: text-bottom;
|
|
||||||
}
|
|
||||||
@keyframes cursor-blink {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── v2: button-based quiz options ─────────────────────────────────── */
|
|
||||||
|
|
||||||
.quiz-option {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 9px 12px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
font: inherit;
|
|
||||||
color: inherit;
|
|
||||||
transition: border-color 0.12s ease;
|
|
||||||
}
|
|
||||||
.quiz-option:hover {
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
.quiz-option.selected {
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiz-marker {
|
|
||||||
flex: none;
|
|
||||||
width: 15px;
|
|
||||||
height: 15px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 1.5px solid var(--border);
|
|
||||||
margin-top: 3px;
|
|
||||||
position: relative;
|
|
||||||
transition: border-color 0.12s ease;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
.quiz-option.multi .quiz-marker {
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
.quiz-option.selected .quiz-marker {
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
.quiz-option.selected .quiz-marker::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
inset: 3px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--accent);
|
|
||||||
}
|
|
||||||
.quiz-option.multi.selected .quiz-marker::after {
|
|
||||||
border-radius: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiz-option.dont-know {
|
|
||||||
border-style: dashed;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
.quiz-option.dont-know.selected {
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* answered cards: option rows are static divs — keep old marker sizing */
|
|
||||||
.quiz-option.static .quiz-marker {
|
|
||||||
width: 15px;
|
|
||||||
height: 15px;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── v2: boot screen / reconnecting ────────────────────────────────── */
|
|
||||||
|
|
||||||
.boot-screen {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
.boot-error {
|
|
||||||
color: var(--error);
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import type { QuestionAnswerData } from "../../../src/shared/protocol.ts";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* AnsweredCard — static rendering of a question that has been answered.
|
|
||||||
*
|
|
||||||
* QUIZ answers are graded (correct/incorrect marks, explanation).
|
|
||||||
* ASK answers are NOT graded — selections are highlighted, no ✓/✗, no verdict
|
|
||||||
* (the v1 bug: ask answers were run through quiz grading and showed as wrong).
|
|
||||||
*/
|
|
||||||
|
|
||||||
export function AnsweredCard({ data }: { data: QuestionAnswerData }) {
|
|
||||||
if (data.dismissed) {
|
|
||||||
return (
|
|
||||||
<div className="quiz-card answered dismissed">
|
|
||||||
<div className="quiz-kind">{data.kind === "quiz" ? "Quiz" : "Question"}</div>
|
|
||||||
<div className="quiz-question">{data.questionText}</div>
|
|
||||||
<div className="quiz-verdict dont-know">Skipped</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isQuiz = data.kind === "quiz";
|
|
||||||
const selectedSet = new Set(data.answers.map((a) => a.index));
|
|
||||||
const correctSet = new Set(isQuiz ? (data.correctIndices ?? []) : []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`quiz-card answered ${data.kind}`}>
|
|
||||||
<div className="quiz-kind">{isQuiz ? "Quiz" : "Question"}</div>
|
|
||||||
<div className="quiz-question">{data.questionText}</div>
|
|
||||||
|
|
||||||
{data.options && data.options.length > 0 && (
|
|
||||||
<div className="quiz-options">
|
|
||||||
{data.options.map((opt) => {
|
|
||||||
const isSelected = selectedSet.has(opt.index);
|
|
||||||
const isKey = isQuiz && correctSet.has(opt.index);
|
|
||||||
let cls = "";
|
|
||||||
let mark = "";
|
|
||||||
if (isQuiz) {
|
|
||||||
if (data.dontKnow) {
|
|
||||||
cls = isKey ? "correct" : "";
|
|
||||||
mark = isKey ? "✓" : "";
|
|
||||||
} else if (isSelected && isKey) {
|
|
||||||
cls = "correct selected";
|
|
||||||
mark = "✓";
|
|
||||||
} else if (isSelected) {
|
|
||||||
cls = "wrong";
|
|
||||||
mark = "✗";
|
|
||||||
} else if (isKey) {
|
|
||||||
cls = "correct";
|
|
||||||
mark = "✓";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// ask: no grading — just show what was selected
|
|
||||||
cls = isSelected ? "selected" : "";
|
|
||||||
mark = isSelected ? "●" : "";
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div key={opt.index} className={`quiz-option static ${cls}`}>
|
|
||||||
<span className="quiz-mark">{mark}</span>
|
|
||||||
<span className="quiz-option-label">
|
|
||||||
{opt.index}. {opt.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{data.other && (
|
|
||||||
<div className={`quiz-option static ${isQuiz ? "" : "selected"}`}>
|
|
||||||
<span className="quiz-mark">{isQuiz ? "✓" : "●"}</span>
|
|
||||||
<span className="quiz-option-label">Other: {data.other}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{data.text && <div className="quiz-text-answer">{data.text}</div>}
|
|
||||||
|
|
||||||
{isQuiz && (
|
|
||||||
<div className={`quiz-verdict ${data.dontKnow ? "dont-know" : data.correct ? "correct" : "wrong"}`}>
|
|
||||||
{data.dontKnow ? "I don't know" : data.correct ? "Correct" : "Incorrect"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{data.note && <div className="quiz-note-display">Note: {data.note}</div>}
|
|
||||||
{isQuiz && data.explanation && (
|
|
||||||
<div className="quiz-explanation">{data.explanation}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GraphPlaceholder — the home view's main panel. Deliberately isolated: the
|
|
||||||
* real knowledge graph (territory map, domain clusters, frontier) is a
|
|
||||||
* separate research/planning phase and will replace ONLY this component.
|
|
||||||
*/
|
|
||||||
export function GraphPlaceholder() {
|
|
||||||
return (
|
|
||||||
<div className="graph-placeholder">
|
|
||||||
<div className="graph-placeholder-inner">
|
|
||||||
<div className="graph-placeholder-title">Knowledge graph</div>
|
|
||||||
<div className="graph-placeholder-sub">
|
|
||||||
Coming in phase 2 — the territory map, repairs, and unlock views.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import React, { memo, useEffect, useRef, useState } from "react";
|
|
||||||
import ReactMarkdown from "react-markdown";
|
|
||||||
import remarkMath from "remark-math";
|
|
||||||
import rehypeKatex from "rehype-katex";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Markdown — renders assistant prose with LaTeX (KaTeX) and mermaid diagrams
|
|
||||||
* (the teacher's Phase-2 dependency maps are mermaid code blocks).
|
|
||||||
*/
|
|
||||||
|
|
||||||
let mermaidPromise: Promise<typeof import("mermaid")> | null = null;
|
|
||||||
function getMermaid() {
|
|
||||||
if (!mermaidPromise) {
|
|
||||||
mermaidPromise = import("mermaid").then((m) => {
|
|
||||||
m.default.initialize({ startOnLoad: false, theme: "dark" });
|
|
||||||
return m;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return mermaidPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
function MermaidBlock({ code }: { code: string }) {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const mermaid = await getMermaid();
|
|
||||||
const { svg } = await mermaid.default.render(`mmd-${Math.random().toString(36).slice(2)}`, code);
|
|
||||||
if (!cancelled && ref.current) ref.current.innerHTML = svg;
|
|
||||||
} catch (e) {
|
|
||||||
if (!cancelled) setError(String(e));
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [code]);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<pre className="mermaid-error">
|
|
||||||
<code>{code}</code>
|
|
||||||
</pre>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <div className="mermaid" ref={ref} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Markdown = memo(function Markdown({ text }: { text: string }) {
|
|
||||||
return (
|
|
||||||
<div className="markdown">
|
|
||||||
<ReactMarkdown
|
|
||||||
remarkPlugins={[remarkMath]}
|
|
||||||
rehypePlugins={[rehypeKatex]}
|
|
||||||
components={{
|
|
||||||
code({ className, children, ...props }) {
|
|
||||||
const lang = /language-(\w+)/.exec(className ?? "")?.[1];
|
|
||||||
const raw = String(children).replace(/\n$/, "");
|
|
||||||
if (lang === "mermaid") return <MermaidBlock code={raw} />;
|
|
||||||
return (
|
|
||||||
<code className={className} {...props}>
|
|
||||||
{children}
|
|
||||||
</code>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
pre({ children }) {
|
|
||||||
const child = children as { props?: { className?: string } };
|
|
||||||
if (child?.props?.className?.includes("language-mermaid")) return <>{children}</>;
|
|
||||||
return <pre>{children}</pre>;
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{text}
|
|
||||||
</ReactMarkdown>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
/**
|
|
||||||
* Message components — one entry of the transcript.
|
|
||||||
*
|
|
||||||
* Assistant messages render as: thinking (collapsible) → tool chips → text.
|
|
||||||
* Thinking is streamed live in the session view (expanded while streaming,
|
|
||||||
* collapses when the message lands) and persisted in the entry, so it
|
|
||||||
* survives reloads and resumes.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import type { ClientToolCall } from "../../../src/shared/protocol.ts";
|
|
||||||
import { Markdown } from "./Markdown.tsx";
|
|
||||||
|
|
||||||
// ─── thinking ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function ThinkingBlock({ text, streaming }: { text: string; streaming?: boolean }) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
// Auto-collapse when the stream finishes (expanded-while-live pattern).
|
|
||||||
useEffect(() => {
|
|
||||||
if (streaming) setOpen(true);
|
|
||||||
else setOpen(false);
|
|
||||||
}, [streaming]);
|
|
||||||
|
|
||||||
if (!text) return null;
|
|
||||||
return (
|
|
||||||
<div className={`thinking ${streaming ? "streaming" : ""}`}>
|
|
||||||
<button type="button" className="thinking-toggle" onClick={() => setOpen((v) => !v)}>
|
|
||||||
{streaming ? <span className="thinking-pulse">Thinking…</span> : <span>Thought process</span>}
|
|
||||||
{!streaming && <span className="thinking-caret">{open ? "▾" : "▸"}</span>}
|
|
||||||
</button>
|
|
||||||
{open && <div className="thinking-body">{text}</div>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── tool calls ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const TOOL_LABELS: Record<string, string> = {
|
|
||||||
quiz: "asked a quiz",
|
|
||||||
ask_user_question: "asked a question",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ToolChips({ toolCalls }: { toolCalls: ClientToolCall[] }) {
|
|
||||||
if (toolCalls.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<div className="tool-chips">
|
|
||||||
{toolCalls.map((tc, i) => (
|
|
||||||
<div key={i} className="tool-chip">
|
|
||||||
<span className="tool-name">{TOOL_LABELS[tc.name] ?? tc.name}</span>
|
|
||||||
{typeof tc.args.question === "string" && tc.args.question && (
|
|
||||||
<span className="tool-arg"> {tc.args.question}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── user ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function UserMessage({ text, status }: { text: string; status?: "sending" | "failed" }) {
|
|
||||||
return (
|
|
||||||
<div className={`msg user ${status === "failed" ? "failed" : status === "sending" ? "sending" : ""}`}>
|
|
||||||
<div className="msg-bubble">{text}</div>
|
|
||||||
{status === "failed" && <div className="msg-status error">failed to send</div>}
|
|
||||||
{status === "sending" && <div className="msg-status">sending…</div>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── assistant ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function AssistantMessage({
|
|
||||||
text,
|
|
||||||
thinking,
|
|
||||||
toolCalls,
|
|
||||||
}: {
|
|
||||||
text: string;
|
|
||||||
thinking?: string;
|
|
||||||
toolCalls: ClientToolCall[];
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="msg assistant">
|
|
||||||
<ThinkingBlock text={thinking ?? ""} />
|
|
||||||
<ToolChips toolCalls={toolCalls} />
|
|
||||||
{text && <Markdown text={text} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
/**
|
|
||||||
* QuizCard — the interactive answer UI for a pending quiz/ask question.
|
|
||||||
*
|
|
||||||
* Options are BUTTONS with a custom marker: React state is the single source
|
|
||||||
* of truth for selection (no native radio/checkbox inputs fighting it — that
|
|
||||||
* desync was the v1 selection bug). One click anywhere on the row selects.
|
|
||||||
*
|
|
||||||
* Semantics mirror the terminal quiz:
|
|
||||||
* - single-select: exactly one of {options, Other (ask), I-don't-know (quiz)}
|
|
||||||
* - multi-select: toggle options; I-don't-know/Other are exclusive toggles
|
|
||||||
* - text mode (ask): free-form answer
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useMemo, useState } from "react";
|
|
||||||
import type { ClientQuestion } from "../../../src/shared/protocol.ts";
|
|
||||||
|
|
||||||
interface QuizCardProps {
|
|
||||||
question: ClientQuestion;
|
|
||||||
send: (msg: unknown) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function QuizCard({ question, send }: QuizCardProps) {
|
|
||||||
const multi = question.mode === "multi-select";
|
|
||||||
const isQuiz = question.kind === "quiz";
|
|
||||||
const isText = question.mode === "text";
|
|
||||||
|
|
||||||
// Selection state — the ONLY source of truth.
|
|
||||||
const [selected, setSelected] = useState<string[]>([]);
|
|
||||||
const [dontKnow, setDontKnow] = useState(false);
|
|
||||||
const [otherSelected, setOtherSelected] = useState(false);
|
|
||||||
const [other, setOther] = useState("");
|
|
||||||
const [text, setText] = useState("");
|
|
||||||
const [note, setNote] = useState("");
|
|
||||||
|
|
||||||
const pickOption = (value: string) => {
|
|
||||||
setDontKnow(false);
|
|
||||||
if (multi) {
|
|
||||||
setSelected((prev) => (prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value]));
|
|
||||||
} else {
|
|
||||||
setOtherSelected(false);
|
|
||||||
setSelected([value]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const pickOther = () => {
|
|
||||||
if (multi) {
|
|
||||||
setOtherSelected((v) => !v);
|
|
||||||
} else {
|
|
||||||
setDontKnow(false);
|
|
||||||
setSelected([]);
|
|
||||||
setOtherSelected(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const pickDontKnow = () => {
|
|
||||||
if (dontKnow) {
|
|
||||||
setDontKnow(false);
|
|
||||||
} else {
|
|
||||||
setDontKnow(true);
|
|
||||||
setSelected([]);
|
|
||||||
setOtherSelected(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const canSubmit = useMemo(() => {
|
|
||||||
if (dontKnow) return true;
|
|
||||||
if (isText) return text.trim().length > 0;
|
|
||||||
if (otherSelected && other.trim()) return true;
|
|
||||||
return selected.length > 0;
|
|
||||||
}, [dontKnow, isText, text, otherSelected, other, selected]);
|
|
||||||
|
|
||||||
const submit = () => {
|
|
||||||
if (!canSubmit) return;
|
|
||||||
send({
|
|
||||||
type: "answer",
|
|
||||||
questionId: question.id,
|
|
||||||
dontKnow: dontKnow || undefined,
|
|
||||||
values: selected.length > 0 ? selected : undefined,
|
|
||||||
other: otherSelected && other.trim() ? other.trim() : undefined,
|
|
||||||
text: isText ? text.trim() : undefined,
|
|
||||||
note: note.trim() || undefined,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const dismiss = () => {
|
|
||||||
send({ type: "dismiss", questionId: question.id });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`quiz-card ${isQuiz ? "quiz" : "ask"}`}>
|
|
||||||
<div className="quiz-kind">{isQuiz ? "Quiz" : "Question"}</div>
|
|
||||||
<div className="quiz-question">{question.question}</div>
|
|
||||||
{question.context && <div className="quiz-context">{question.context}</div>}
|
|
||||||
|
|
||||||
{isText ? (
|
|
||||||
<textarea
|
|
||||||
className="quiz-text"
|
|
||||||
placeholder="Type your answer…"
|
|
||||||
value={text}
|
|
||||||
rows={3}
|
|
||||||
onChange={(e) => setText(e.target.value)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="quiz-options" role="group" aria-label="options">
|
|
||||||
{question.options.map((opt) => {
|
|
||||||
const isSelected = selected.includes(opt.value);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={opt.value}
|
|
||||||
type="button"
|
|
||||||
className={`quiz-option ${multi ? "multi" : "single"} ${isSelected ? "selected" : ""}`}
|
|
||||||
role={multi ? "checkbox" : "radio"}
|
|
||||||
aria-checked={isSelected}
|
|
||||||
onClick={() => pickOption(opt.value)}
|
|
||||||
>
|
|
||||||
<span className="quiz-marker" aria-hidden="true" />
|
|
||||||
<span className="quiz-option-label">
|
|
||||||
{opt.label}
|
|
||||||
{opt.description && <span className="quiz-option-desc">{opt.description}</span>}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{!isQuiz && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`quiz-option ${multi ? "multi" : "single"} ${otherSelected ? "selected" : ""}`}
|
|
||||||
role={multi ? "checkbox" : "radio"}
|
|
||||||
aria-checked={otherSelected}
|
|
||||||
onClick={pickOther}
|
|
||||||
>
|
|
||||||
<span className="quiz-marker" aria-hidden="true" />
|
|
||||||
<span className="quiz-option-label">Other</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{otherSelected && (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="quiz-other"
|
|
||||||
placeholder="Your custom answer…"
|
|
||||||
value={other}
|
|
||||||
autoFocus
|
|
||||||
onChange={(e) => setOther(e.target.value)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isQuiz && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`quiz-option dont-know ${dontKnow ? "selected" : ""}`}
|
|
||||||
role="checkbox"
|
|
||||||
aria-checked={dontKnow}
|
|
||||||
onClick={pickDontKnow}
|
|
||||||
>
|
|
||||||
<span className="quiz-marker" aria-hidden="true" />
|
|
||||||
<span className="quiz-option-label">I don't know</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<textarea
|
|
||||||
className="quiz-note"
|
|
||||||
placeholder="Note (optional) — what you were thinking or unsure about"
|
|
||||||
value={note}
|
|
||||||
rows={2}
|
|
||||||
onChange={(e) => setNote(e.target.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="quiz-actions">
|
|
||||||
<button className="primary" onClick={submit} disabled={!canSubmit}>
|
|
||||||
{dontKnow ? "I don't know" : "Submit"}
|
|
||||||
</button>
|
|
||||||
<button className="ghost" onClick={dismiss}>
|
|
||||||
Skip
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
/**
|
|
||||||
* Transcript — the scrollable message list with stick-to-bottom behavior:
|
|
||||||
* auto-scrolls while the learner is at the bottom; if they scroll up to read,
|
|
||||||
* auto-scrolling pauses and a "jump to latest" pill appears.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useEffect, useRef, useState, type ReactNode } from "react";
|
|
||||||
|
|
||||||
export function Transcript({ children, scrollToken }: { children: ReactNode; scrollToken: string | number }) {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const [pinned, setPinned] = useState(true);
|
|
||||||
|
|
||||||
const onScroll = () => {
|
|
||||||
const el = ref.current;
|
|
||||||
if (!el) return;
|
|
||||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
|
||||||
setPinned(atBottom);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = ref.current;
|
|
||||||
if (el && pinned) el.scrollTop = el.scrollHeight;
|
|
||||||
}, [scrollToken, pinned]);
|
|
||||||
|
|
||||||
const jumpToLatest = () => {
|
|
||||||
const el = ref.current;
|
|
||||||
if (el) el.scrollTop = el.scrollHeight;
|
|
||||||
setPinned(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="transcript-wrap">
|
|
||||||
<div className="transcript" ref={ref} onScroll={onScroll}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
{!pinned && (
|
|
||||||
<button type="button" className="jump-latest" onClick={jumpToLatest}>
|
|
||||||
↓ Jump to latest
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import React, { StrictMode } from "react";
|
|
||||||
import { createRoot } from "react-dom/client";
|
|
||||||
import { App } from "./App.tsx";
|
|
||||||
import "katex/dist/katex.min.css";
|
|
||||||
import "./app.css";
|
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
|
||||||
<StrictMode>
|
|
||||||
<App />
|
|
||||||
</StrictMode>,
|
|
||||||
);
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
/**
|
|
||||||
* useSession — the interaction core of the session view.
|
|
||||||
*
|
|
||||||
* Owns the WebSocket lifecycle and the client-side message state machine,
|
|
||||||
* following the standard chat-UI patterns:
|
|
||||||
*
|
|
||||||
* - optimistic echo: a sent prompt renders instantly with status "sending";
|
|
||||||
* when the server's real entry arrives it REPLACES the optimistic copy
|
|
||||||
* (matched by text), flipping to a normal message. If a reconnect's
|
|
||||||
* backfill proves the message never landed, it flips to "failed".
|
|
||||||
* - reconnect: on drop, retry with exponential backoff; the server's
|
|
||||||
* hello+backfill on reconnect fully replaces local state (no dupes —
|
|
||||||
* the server guarantees exactly-once per socket).
|
|
||||||
* - streaming: thinking and text deltas accumulate in separate live
|
|
||||||
* buffers; when the assistant entry lands, the buffers reset.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import type { ClientEntry, ClientQuestion, ServerMessage } from "../../src/shared/protocol.ts";
|
|
||||||
|
|
||||||
export type MessageStatus = "sending" | "failed";
|
|
||||||
|
|
||||||
export type DisplayEntry =
|
|
||||||
| { type: "user"; id: string; text: string; status?: MessageStatus }
|
|
||||||
| Exclude<ClientEntry, { type: "user" }>;
|
|
||||||
|
|
||||||
export interface SessionState {
|
|
||||||
status: "connecting" | "open" | "reconnecting";
|
|
||||||
entries: DisplayEntry[];
|
|
||||||
liveThinking: string;
|
|
||||||
liveText: string;
|
|
||||||
streaming: boolean;
|
|
||||||
pending: ClientQuestion | null;
|
|
||||||
error: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const INITIAL: SessionState = {
|
|
||||||
status: "connecting",
|
|
||||||
entries: [],
|
|
||||||
liveThinking: "",
|
|
||||||
liveText: "",
|
|
||||||
streaming: false,
|
|
||||||
pending: null,
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
function localId(): string {
|
|
||||||
return `local-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSession(): {
|
|
||||||
state: SessionState;
|
|
||||||
sendPrompt: (text: string) => void;
|
|
||||||
sendAnswer: (payload: Record<string, unknown>) => void;
|
|
||||||
sendDismiss: (questionId: string) => void;
|
|
||||||
sendAbort: () => void;
|
|
||||||
} {
|
|
||||||
const [state, setState] = useState<SessionState>(INITIAL);
|
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
|
||||||
const retryRef = useRef(0);
|
|
||||||
const aliveRef = useRef(true);
|
|
||||||
|
|
||||||
const connect = useCallback(() => {
|
|
||||||
if (!aliveRef.current) return;
|
|
||||||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
|
||||||
const ws = new WebSocket(`${proto}://${location.host}/ws`);
|
|
||||||
wsRef.current = ws;
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
retryRef.current = 0;
|
|
||||||
setState((s) => ({ ...s, status: "open", error: null }));
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onmessage = (ev) => {
|
|
||||||
const msg = JSON.parse(ev.data as string) as ServerMessage;
|
|
||||||
setState((s) => {
|
|
||||||
switch (msg.type) {
|
|
||||||
case "hello":
|
|
||||||
// Session switch or fresh connect: full resync follows.
|
|
||||||
return { ...s, entries: [], liveThinking: "", liveText: "", pending: null, streaming: false };
|
|
||||||
case "backfill":
|
|
||||||
return { ...s, entries: reconcileBackfill(s.entries, msg.entries) };
|
|
||||||
case "entry":
|
|
||||||
return applyEntry(s, msg.entry);
|
|
||||||
case "pending":
|
|
||||||
return { ...s, pending: msg.question };
|
|
||||||
case "delta":
|
|
||||||
return msg.kind === "thinking"
|
|
||||||
? { ...s, liveThinking: s.liveThinking + msg.delta }
|
|
||||||
: { ...s, liveText: s.liveText + msg.delta };
|
|
||||||
case "streaming":
|
|
||||||
return { ...s, streaming: msg.value };
|
|
||||||
case "error":
|
|
||||||
return { ...s, error: msg.message };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = () => {
|
|
||||||
if (!aliveRef.current) return;
|
|
||||||
setState((s) => ({ ...s, status: "reconnecting", streaming: false }));
|
|
||||||
const delay = Math.min(1000 * 2 ** retryRef.current, 8000);
|
|
||||||
retryRef.current++;
|
|
||||||
setTimeout(connect, delay);
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = () => {
|
|
||||||
// onclose follows and schedules the retry
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
aliveRef.current = true;
|
|
||||||
connect();
|
|
||||||
return () => {
|
|
||||||
aliveRef.current = false;
|
|
||||||
wsRef.current?.close();
|
|
||||||
wsRef.current = null;
|
|
||||||
};
|
|
||||||
}, [connect]);
|
|
||||||
|
|
||||||
const sendPrompt = useCallback((text: string) => {
|
|
||||||
const trimmed = text.trim();
|
|
||||||
if (!trimmed) return;
|
|
||||||
const optimistic: DisplayEntry = { type: "user", id: localId(), text: trimmed, status: "sending" };
|
|
||||||
if (wsRef.current?.readyState !== WebSocket.OPEN) {
|
|
||||||
setState((s) => ({ ...s, entries: [...s.entries, { ...optimistic, status: "failed" }] }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
wsRef.current.send(JSON.stringify({ type: "prompt", text: trimmed }));
|
|
||||||
setState((s) => ({ ...s, entries: [...s.entries, optimistic], error: null }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const sendRaw = useCallback((msg: unknown) => {
|
|
||||||
wsRef.current?.send(JSON.stringify(msg));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const sendAnswer = useCallback(
|
|
||||||
(payload: Record<string, unknown>) => {
|
|
||||||
sendRaw({ type: "answer", ...payload });
|
|
||||||
},
|
|
||||||
[sendRaw],
|
|
||||||
);
|
|
||||||
|
|
||||||
const sendDismiss = useCallback(
|
|
||||||
(questionId: string) => {
|
|
||||||
sendRaw({ type: "dismiss", questionId });
|
|
||||||
},
|
|
||||||
[sendRaw],
|
|
||||||
);
|
|
||||||
|
|
||||||
const sendAbort = useCallback(() => {
|
|
||||||
sendRaw({ type: "abort" });
|
|
||||||
}, [sendRaw]);
|
|
||||||
|
|
||||||
return { state, sendPrompt, sendAnswer, sendDismiss, sendAbort };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── state transitions ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Replace an optimistic (status: sending) copy of the same text; else append. */
|
|
||||||
function applyEntry(s: SessionState, entry: ClientEntry): SessionState {
|
|
||||||
if (entry.type === "user") {
|
|
||||||
const idx = s.entries.findIndex(
|
|
||||||
(e) => e.type === "user" && e.status === "sending" && e.text === entry.text,
|
|
||||||
);
|
|
||||||
if (idx >= 0) {
|
|
||||||
const entries = [...s.entries];
|
|
||||||
entries[idx] = entry;
|
|
||||||
return { ...s, entries };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (entry.type === "assistant") {
|
|
||||||
// The finished message supersedes the live streaming buffers.
|
|
||||||
return { ...s, entries: [...s.entries, entry], liveThinking: "", liveText: "" };
|
|
||||||
}
|
|
||||||
return { ...s, entries: [...s.entries, entry] };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** After a reconnect backfill: adopt the server's truth; optimistic messages
|
|
||||||
* the server never received become "failed". */
|
|
||||||
function reconcileBackfill(local: DisplayEntry[], backfill: ClientEntry[]): DisplayEntry[] {
|
|
||||||
const sentTexts = new Set(backfill.filter((e) => e.type === "user").map((e) => e.text));
|
|
||||||
const orphans = local
|
|
||||||
.filter(
|
|
||||||
(e): e is Extract<DisplayEntry, { type: "user" }> =>
|
|
||||||
e.type === "user" && e.status === "sending" && !sentTexts.has(e.text),
|
|
||||||
)
|
|
||||||
.map((e) => ({ ...e, status: "failed" as const }));
|
|
||||||
return [...backfill, ...orphans];
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import type { SessionSummary } from "../../../src/shared/protocol.ts";
|
|
||||||
import { GraphPlaceholder } from "../components/GraphPlaceholder.tsx";
|
|
||||||
|
|
||||||
interface HomeProps {
|
|
||||||
sessions: SessionSummary[];
|
|
||||||
onStart: (topic: string) => Promise<void>;
|
|
||||||
onOpen: (file: string) => void;
|
|
||||||
onRefresh: () => void;
|
|
||||||
error: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
|
||||||
if (!iso) return "";
|
|
||||||
try {
|
|
||||||
return new Date(iso).toLocaleString(undefined, {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return iso;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Home({ sessions, onStart, onOpen, onRefresh, error }: HomeProps) {
|
|
||||||
const [topic, setTopic] = useState("");
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
const t = topic.trim();
|
|
||||||
if (!t || busy) return;
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
await onStart(t);
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
setTopic("");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="home">
|
|
||||||
<aside className="sidebar">
|
|
||||||
<div className="sidebar-head">
|
|
||||||
<span>Sessions</span>
|
|
||||||
<button className="icon-btn" title="Refresh" onClick={onRefresh}>
|
|
||||||
↻
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="session-list">
|
|
||||||
{sessions.length === 0 && <div className="empty">No sessions yet</div>}
|
|
||||||
{sessions.map((s) => (
|
|
||||||
<button key={s.id} className="session-item" onClick={() => onOpen(s.file)}>
|
|
||||||
<div className="session-name">{s.name || s.preview || "Untitled"}</div>
|
|
||||||
<div className="session-meta">
|
|
||||||
{formatDate(s.updatedAt)} · {s.messageCount} msg{s.messageCount === 1 ? "" : "s"}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
<main className="home-main">
|
|
||||||
<GraphPlaceholder />
|
|
||||||
<div className="new-session">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="What do you want to learn?"
|
|
||||||
value={topic}
|
|
||||||
disabled={busy}
|
|
||||||
onChange={(e) => setTopic(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") void submit();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button onClick={() => void submit()} disabled={busy || !topic.trim()}>
|
|
||||||
{busy ? "Starting…" : "Start"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{error && <div className="error-banner">{error}</div>}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
/**
|
|
||||||
* SessionView — the teaching session at /s/<file>.
|
|
||||||
*
|
|
||||||
* Two phases:
|
|
||||||
* 1. boot — if this file isn't the server's active session, POST resume
|
|
||||||
* first ("Opening session…"); fresh page loads and cross-session
|
|
||||||
* navigation land here.
|
|
||||||
* 2. live — useSession owns the WS + state machine; this component renders
|
|
||||||
* the transcript (entries + live streaming block + pending quiz card)
|
|
||||||
* and the composer.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import type { DisplayEntry, SessionState } from "../useSession.ts";
|
|
||||||
import { useSession } from "../useSession.ts";
|
|
||||||
import { Markdown } from "../components/Markdown.tsx";
|
|
||||||
import { AssistantMessage, ThinkingBlock, ToolChips, UserMessage } from "../components/Message.tsx";
|
|
||||||
import { QuizCard } from "../components/QuizCard.tsx";
|
|
||||||
import { AnsweredCard } from "../components/AnsweredCard.tsx";
|
|
||||||
import { Transcript } from "../components/Transcript.tsx";
|
|
||||||
|
|
||||||
interface SessionViewProps {
|
|
||||||
file: string;
|
|
||||||
active: boolean;
|
|
||||||
onActivated: (file: string) => void;
|
|
||||||
onHome: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** User messages already rendered as answer cards — don't duplicate them. */
|
|
||||||
const HIDDEN_USER_PREFIXES = ["[Quiz answer]", "[Answer to your question]"];
|
|
||||||
|
|
||||||
export function EntryBlock({ entry }: { entry: DisplayEntry }) {
|
|
||||||
switch (entry.type) {
|
|
||||||
case "user":
|
|
||||||
if (HIDDEN_USER_PREFIXES.some((p) => entry.text.startsWith(p))) return null;
|
|
||||||
return <UserMessage text={entry.text} status={entry.status} />;
|
|
||||||
case "assistant":
|
|
||||||
return <AssistantMessage text={entry.text} thinking={entry.thinking} toolCalls={entry.toolCalls} />;
|
|
||||||
case "toolResult":
|
|
||||||
// Internal plumbing for the LLM ("Quiz displayed to the learner,
|
|
||||||
// end your turn…") — never learner-visible.
|
|
||||||
return null;
|
|
||||||
case "question":
|
|
||||||
// Rendered by the pending-question flow; when answered, the
|
|
||||||
// questionAnswered entry renders instead.
|
|
||||||
return null;
|
|
||||||
case "questionAnswered":
|
|
||||||
return <AnsweredCard data={entry.data} />;
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SessionView({ file, active, onActivated, onHome }: SessionViewProps) {
|
|
||||||
// ── boot: make sure the server has this session active ─────────────────
|
|
||||||
const [boot, setBoot] = useState<"resuming" | "ready" | "error">(active ? "ready" : "resuming");
|
|
||||||
const [bootError, setBootError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (active) {
|
|
||||||
setBoot("ready");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let cancelled = false;
|
|
||||||
resumeOnce(file)
|
|
||||||
.then(() => {
|
|
||||||
if (cancelled) return;
|
|
||||||
onActivated(file);
|
|
||||||
setBoot("ready");
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setBootError(String(err instanceof Error ? err.message : err));
|
|
||||||
setBoot("error");
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [file, active]);
|
|
||||||
|
|
||||||
if (boot !== "ready") {
|
|
||||||
return (
|
|
||||||
<div className="session">
|
|
||||||
<header className="session-head">
|
|
||||||
<button className="icon-btn" onClick={onHome} title="Back">
|
|
||||||
←
|
|
||||||
</button>
|
|
||||||
<div className="session-head-status">
|
|
||||||
{boot === "error" ? (
|
|
||||||
<span className="boot-error">{bootError}</span>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span className="dot on" /> opening session…
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<div className="boot-screen">{boot === "error" ? bootError : "Opening session…"}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <SessionLive onHome={onHome} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── live phase ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const resumePromises = new Map<string, Promise<void>>();
|
|
||||||
|
|
||||||
function resumeOnce(file: string): Promise<void> {
|
|
||||||
let p = resumePromises.get(file);
|
|
||||||
if (!p) {
|
|
||||||
p = fetch(`/api/sessions/${encodeURIComponent(file)}/resume`, { method: "POST" })
|
|
||||||
.then((res) => {
|
|
||||||
if (!res.ok) throw new Error(`could not open session (${res.status})`);
|
|
||||||
})
|
|
||||||
// don't cache failures — a remount should be able to retry
|
|
||||||
.catch((err) => {
|
|
||||||
resumePromises.delete(file);
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
resumePromises.set(file, p);
|
|
||||||
}
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SessionLive({ onHome }: { onHome: () => void }) {
|
|
||||||
const { state, sendPrompt, sendAnswer, sendDismiss, sendAbort } = useSession();
|
|
||||||
const [input, setInput] = useState("");
|
|
||||||
|
|
||||||
const submit = () => {
|
|
||||||
const text = input.trim();
|
|
||||||
if (!text || state.streaming || state.pending !== null || state.status !== "open") return;
|
|
||||||
sendPrompt(text);
|
|
||||||
setInput("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const inputDisabled =
|
|
||||||
state.streaming || state.pending !== null || state.status !== "open";
|
|
||||||
|
|
||||||
const placeholder =
|
|
||||||
state.status !== "open"
|
|
||||||
? "Reconnecting…"
|
|
||||||
: state.pending !== null
|
|
||||||
? "Answer the question above first…"
|
|
||||||
: state.streaming
|
|
||||||
? "Teaching…"
|
|
||||||
: "Reply to the teacher…";
|
|
||||||
|
|
||||||
const statusLabel =
|
|
||||||
state.status === "connecting"
|
|
||||||
? "connecting…"
|
|
||||||
: state.status === "reconnecting"
|
|
||||||
? "reconnecting…"
|
|
||||||
: state.streaming
|
|
||||||
? "teaching…"
|
|
||||||
: state.pending !== null
|
|
||||||
? "waiting for your answer"
|
|
||||||
: "ready";
|
|
||||||
|
|
||||||
const scrollToken =
|
|
||||||
state.entries.length + state.liveText.length + state.liveThinking.length + (state.pending?.id ?? "");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="session">
|
|
||||||
<header className="session-head">
|
|
||||||
<button className="icon-btn" onClick={onHome} title="Back">
|
|
||||||
←
|
|
||||||
</button>
|
|
||||||
<div className="session-head-status">
|
|
||||||
<span className={`dot ${state.status === "open" ? (state.streaming ? "on" : "ok") : "off"}`} />{" "}
|
|
||||||
{statusLabel}
|
|
||||||
</div>
|
|
||||||
{state.streaming && (
|
|
||||||
<button className="abort-btn" onClick={sendAbort}>
|
|
||||||
Stop
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<Transcript scrollToken={scrollToken}>
|
|
||||||
{state.entries.map((entry) => (
|
|
||||||
<EntryBlock key={entry.id} entry={entry} />
|
|
||||||
))}
|
|
||||||
{(state.liveThinking || state.liveText) && (
|
|
||||||
<div className="msg assistant live">
|
|
||||||
<ThinkingBlock text={state.liveThinking} streaming={state.streaming} />
|
|
||||||
{state.liveText && (
|
|
||||||
<div className="live-text">
|
|
||||||
<Markdown text={state.liveText} />
|
|
||||||
{state.streaming && <span className="stream-cursor" />}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{state.pending && (
|
|
||||||
<QuizCard
|
|
||||||
question={state.pending}
|
|
||||||
send={(msg) => {
|
|
||||||
if (msg && typeof msg === "object" && (msg as { type?: string }).type === "answer") {
|
|
||||||
sendAnswer(msg as Record<string, unknown>);
|
|
||||||
} else if (msg && typeof msg === "object" && (msg as { type?: string }).type === "dismiss") {
|
|
||||||
sendDismiss(String((msg as { questionId?: string }).questionId ?? ""));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{state.error && <div className="error-banner">{state.error}</div>}
|
|
||||||
</Transcript>
|
|
||||||
|
|
||||||
<div className="composer">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder={placeholder}
|
|
||||||
value={input}
|
|
||||||
disabled={inputDisabled}
|
|
||||||
onChange={(e) => setInput(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") submit();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{state.streaming ? (
|
|
||||||
<button className="abort" onClick={sendAbort}>
|
|
||||||
Stop
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button onClick={submit} disabled={inputDisabled || !input.trim()}>
|
|
||||||
Send
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "Bundler",
|
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"strict": true,
|
|
||||||
"noUncheckedIndexedAccess": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"types": ["vite/client"]
|
|
||||||
},
|
|
||||||
"include": ["src/**/*.ts", "src/**/*.tsx", "../src/shared/protocol.ts"]
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user