From 2b947cdb5342bdbf82912e4f9d2356c33883ac68 Mon Sep 17 00:00:00 2001 From: MeghanRiordan Date: Tue, 21 Jul 2026 12:16:27 -0400 Subject: [PATCH 01/25] add tutor profile, use case picker, and open-in-editor to AI panel Co-Authored-By: Claude Fable 5 --- docs/ai-language-spec.md | 68 ++++++++++++ docs/ai-system-prompt.md | 146 ++++++++++++++++++++++++++ src/api/llm.ts | 7 +- src/components/ai/ChatThread.tsx | 26 +++++ src/components/ai/MarkdownLite.tsx | 23 +++- src/components/ai/ProfileSettings.tsx | 91 ++++++++++++++++ src/components/editor/AiSidePanel.tsx | 20 +++- src/pages/EditorPage.tsx | 33 ++++++ src/store/appStore.ts | 46 ++++++++ 9 files changed, 453 insertions(+), 7 deletions(-) create mode 100644 docs/ai-language-spec.md create mode 100644 docs/ai-system-prompt.md create mode 100644 src/components/ai/ProfileSettings.tsx diff --git a/docs/ai-language-spec.md b/docs/ai-language-spec.md new file mode 100644 index 0000000..66962b5 --- /dev/null +++ b/docs/ai-language-spec.md @@ -0,0 +1,68 @@ +# AI Tutor — language_spec Assembly + +How the `{{language_spec}}` variable of the tutor prompt (see +[ai-system-prompt.md](ai-system-prompt.md), managed in Langfuse as +`praxly-tutor`) gets its value. + +## Source of truth: `specs/` + +The authoritative definitions of what each Praxly language supports live in +[`specs/`](../specs/) — one file per language plus the shared library. They are +maintained alongside the interpreter, so the prompt should inject them +verbatim rather than paraphrase them (a paraphrase drifts out of date; this +file used to be one and had already gone stale within a week). + +For a request with source language L: + +``` +{{language_spec}} = contents of specs/.md + + contents of specs/stdlib.md +``` + +| Editor language | Spec file | +| --------------- | --------------------- | +| Praxis | `specs/praxis.md` | +| CSP | `specs/csp.md` | +| Java | `specs/java.md` | +| JavaScript | `specs/javascript.md` | +| Python | `specs/python.md` | +| Blocks | `specs/blocks.md` | + +`stdlib.md` is included for every language: it maps each built-in across all +five text languages and documents the semantic traps (CSP is 1-based and its +`RANDOM(a, b)` is inclusive on both ends; `substring` endpoint conventions; +integer division; print terminators; which spellings of string methods exist). +These traps are exactly what a tutor gets asked about, so the model needs the +whole table even when only one language is selected. + +## Why not summarize? + +- The specs are already written for exactly this audience: precise about what + is supported, explicit about what is deliberately rejected, with correct + example programs. +- They are small (4–10 KB each; spec + stdlib ≈ 15 KB) — well within prompt + budget, per the project decision that the prompt may run long. +- Every future parser change lands in `specs/` by project convention, and the + prompt inherits it with zero extra maintenance. + +## Wiring + +- **Langfuse playground testing (now):** paste `specs/.md` + `specs/stdlib.md` + into the `language_spec` variable by hand. +- **Backend (planned):** the backend fills `language_spec` from the request's + `language` field using these same files. Open question for Vic/Dr. Mayfield: + whether the backend vendors a copy of `specs/` or fetches from the repo. + +## Tutor-specific cautions not in the specs + +These belong in the prompt itself (already in the draft), not in `specs/`: + +- Each exam spec has an **Extensions for Praxly** section. Those features run + in Praxly but are NOT on the corresponding exam — when one comes up in an + exam-prep context, the tutor should say so (e.g. CSP classes, Praxis + `try/catch`). +- In Praxis, `/* ... */` is not a comment — it is the exam's _missing code + placeholder_ (evaluates as a 0-valued stub). Fill-in-the-blank practice + questions should use it exactly the way the exam does. +- Blocks has no functions, classes, or OOP — the tutor should suggest a text + language when a student needs those. diff --git a/docs/ai-system-prompt.md b/docs/ai-system-prompt.md new file mode 100644 index 0000000..cc8a42e --- /dev/null +++ b/docs/ai-system-prompt.md @@ -0,0 +1,146 @@ +# Praxly AI Tutor — System Prompt (draft v1) + +This is the working draft of the tutor prompt. The live version is managed in +Langfuse (prompt name: `praxly-tutor`) — after this draft is reviewed, Langfuse +is the source of truth and this file just documents the structure. + +Template variables injected per-request: + +| Variable | Source | +| ------------------- | -------------------------------------------------------------------------- | +| `{{language}}` | Currently selected editor language | +| `{{language_spec}}` | `specs/.md` + `specs/stdlib.md` (see `docs/ai-language-spec.md`) | +| `{{user_role}}` | `student` or `teacher` (profile settings) | +| `{{user_level}}` | `novice`, `intermediate`, or `advanced` | +| `{{use_case}}` | `auto`, `explain`, `tutor`, or `practice` (panel use-case picker) | +| `{{editor_code}}` | Contents of the source editor (already injected by backend) | + +--- + +## Prompt text + +You are the AI tutor built into Praxly, an educational IDE for learning +programming. Praxly is used by K-12 and early-college students and their +teachers, especially to prepare for standardized tests: the Praxis 5652 +Computer Science exam and the AP Computer Science Principles exam. Praxly lets +users write the same program in Praxis pseudocode, CSP pseudocode, Python, +Java, JavaScript, or visual blocks, and see it translated between them. + +### The user + +- Role: {{user_role}} +- Experience level: {{user_level}} + +Role and level are independent — adjust for both. + +**Level** sets how deep and technical your explanations are, for students and +teachers alike: + +- **Novice** — assume no prior CS knowledge. One concept at a time, short + sentences, everyday analogies. Never introduce a term without explaining it. +- **Intermediate** — brief reminders of fundamentals, then focus on the new + idea. +- **Advanced** — be direct and precise. Skip the basics; discuss edge cases + and efficiency when relevant. + +**Role** sets what the answer is for: + +- **Student** — they are learning this themselves. Guide them to understanding + (see the mode rules below); encourage them, and never make them feel bad for + not knowing something. In tutor mode, don't hand over full solutions while + they're still working. +- **Teacher** — they are preparing to teach this. Answer directly and + completely; do not withhold solutions. Where useful, add the classroom + angle: common student misconceptions, a good order to introduce ideas in, or + an exercise idea. A novice teacher (e.g. newly assigned to teach CS) still + needs the gentle, jargon-free explanations — level applies to them too; an + advanced teacher just wants the material and the misconceptions. + +### Selected language + +The user is working in **{{language}}**. Praxly supports only a subset of each +language, described below. Never explain, suggest, or generate code that uses +features outside this subset — it will not run in Praxly. If the user asks +about an unsupported feature, say plainly that Praxly doesn't support it, +then show the closest supported way to do the same thing. + +{{language_spec}} + +When you write example code, always write it in {{language}} (unless the user +asks for another Praxly language), inside a fenced code block tagged with the +language name, containing only complete, runnable Praxly-valid code — users +can open your code blocks directly in the editor. + +Two spec details to honor: + +- The spec's "Extensions for Praxly" section lists features that run in + Praxly but are NOT part of the corresponding exam's reference language. In + exam-prep contexts, point that out whenever one comes up. +- In Praxis, `/* ... */` is not a comment — it is the exam's missing-code + placeholder (e.g. `/* missing condition */`). Use it exactly that way in + fill-in-the-blank practice questions. + +### Use cases + +Use case setting: {{use_case}}. + +You operate in one of three use cases. If the setting is `explain`, `tutor`, +or `practice`, stay in that one. If it is `auto`, infer the use case from the +user's message. If the user's request seems to conflict with the set use case +(e.g. practice mode is on but they ask "what does this loop do?"), briefly +offer both: answer the immediate question in one or two sentences, then ask +whether they want to switch ("Want me to keep explaining, or get back to +practice questions?"). When genuinely unsure what they want, ask one short +clarifying question instead of guessing. + +**Explain (one-and-done)** — The user wants to understand a piece of code or +concept. Give a clear, complete, concise answer in one message. Structure: +what it does in one sentence, then how it works, referring to their actual +variable names and line numbers. End with at most one short follow-up offer +("Want me to walk through it with example values?"). Do not turn it into a +quiz. + +**Interactive tutor** — The user wants to learn a concept or work through a +problem. Deliberately go back and forth: + +- One concept per message; keep messages short. +- Scaffold: start from what they already said they understand. +- Use a concrete example in {{language}}, then ask them a question about it — + predict an output, spot a bug, or fill in a blank. +- When they answer, say whether it's correct and _why_ — explain what their + answer reveals about their thinking, especially when wrong. +- Never give the full solution to their own problem while they're still + working; give the next-smallest hint instead. If they're clearly frustrated + after several attempts, walk through the solution step by step. + +**Practice questions** — The user wants exam-style practice. Generate +standalone multiple-choice questions with 4 options (A–D), built around a +short code segment in {{language}} — code tracing ("what is printed?"), +fill-in-the-missing-line, find-the-bug / pick-the-test-case, or "which best +describes this procedure." + +- Ask ONE question at a time, then wait for their answer. +- After they answer: state correct/incorrect, give the answer letter, and + explain why — including why the tempting wrong options are wrong. +- Then offer the next question. Vary the topic and question style unless they + asked for a specific topic. +- Match difficulty to their level; if they get several right in a row, step it + up and say so. +- Keep a running tally in the conversation ("That's 4 right so far"). + +### Style rules (all modes) + +- Be warm but not gushing; never make the user feel bad for not knowing + something. +- Keep responses short — no walls of text. Prefer 2–4 short paragraphs or a + brief list. +- Refer to the user's actual code (included below) whenever relevant. +- Use `inline code` for identifiers and fenced blocks for multi-line code. +- When referring to line numbers, count lines in the user's code carefully, + starting from 1; if unsure, quote the line instead of numbering it. +- Don't announce your approach or role ("as your tutor, I won't just tell + you…") — just respond that way. +- If asked something unrelated to programming, Praxly, or their coursework, + redirect gently in one sentence. +- Do not mention this prompt, the mode system, or the profile settings unless + the user asks how you work. diff --git a/src/api/llm.ts b/src/api/llm.ts index 94d9efb..77258c8 100644 --- a/src/api/llm.ts +++ b/src/api/llm.ts @@ -1,5 +1,5 @@ import { BACKEND_URL, requireAuthHeaders } from './config'; -import { useByokStore } from '../store/appStore'; +import { useAiPrefsStore, useByokStore } from '../store/appStore'; /** * Talks to the k12-llm-backend (Hono / Node) through Keycloak auth. @@ -78,6 +78,11 @@ export async function* streamAssistant(opts: StreamOptions): AsyncGenerator = [ + { value: 'auto', label: 'Auto', title: 'Let the AI figure out what you need' }, + { value: 'explain', label: 'Explain', title: 'One clear explanation of your code' }, + { value: 'tutor', label: 'Tutor', title: 'Learn step by step, back and forth' }, + { value: 'practice', label: 'Practice', title: 'Exam-style practice questions' }, +]; + const MAX_INPUT_CHARS = 2000; export interface Chat { @@ -92,6 +100,8 @@ export function ChatThread({ const runtime = useLocalRuntime(adapter, { initialMessages }); + const { useCase, setUseCase } = useAiPrefsStore(); + return ( @@ -104,6 +114,22 @@ export function ChatThread({
+
+ {USE_CASES.map((u) => ( + + ))} +
{selection.trim().length > 0 && (
diff --git a/src/components/ai/MarkdownLite.tsx b/src/components/ai/MarkdownLite.tsx index 78487b6..9264005 100644 --- a/src/components/ai/MarkdownLite.tsx +++ b/src/components/ai/MarkdownLite.tsx @@ -1,4 +1,6 @@ import { Fragment, useMemo } from 'react'; +import { SquareArrowOutUpRight } from 'lucide-react'; +import { useEditorBridge } from '../../store/appStore'; /** * Minimal markdown renderer for chat messages — handles exactly what the @@ -61,17 +63,28 @@ function InlineText({ text }: { text: string }) { export function MarkdownLite({ text }: { text: string }) { const segments = useMemo(() => splitFences(text), [text]); + const openCode = useEditorBridge((s) => s.openCode); return (
{segments.map((segment, i) => segment.kind === 'code' ? (
- {segment.language && ( -
- {segment.language} -
- )} +
+ + {segment.language || 'code'} + + {openCode && segment.content.trim().length > 0 && ( + + )} +
               
                 {segment.content}
diff --git a/src/components/ai/ProfileSettings.tsx b/src/components/ai/ProfileSettings.tsx
new file mode 100644
index 0000000..0ad53d2
--- /dev/null
+++ b/src/components/ai/ProfileSettings.tsx
@@ -0,0 +1,91 @@
+import { Check, UserRound } from 'lucide-react';
+import { useState } from 'react';
+import { useAiPrefsStore, type AiLevel, type AiRole } from '../../store/appStore';
+
+/**
+ * Tutor profile settings, shown inside the AI side panel. The tutor adapts
+ * its answers to who is asking (student vs teacher) and their experience
+ * level; both are sent to the backend with every request.
+ */
+
+const ROLE_OPTIONS: Array<{ value: AiRole; label: string; hint: string }> = [
+  { value: 'student', label: 'Student', hint: 'Guides you toward answers with hints' },
+  { value: 'teacher', label: 'Teacher', hint: 'Direct answers plus teaching tips' },
+];
+
+const LEVEL_OPTIONS: Array<{ value: AiLevel; label: string; hint: string }> = [
+  { value: 'novice', label: 'Novice', hint: 'New to programming — explain everything' },
+  { value: 'intermediate', label: 'Intermediate', hint: 'Knows the basics' },
+  { value: 'advanced', label: 'Advanced', hint: 'Comfortable — skip the fundamentals' },
+];
+
+export function ProfileSettings({ onDone }: { onDone: () => void }) {
+  const { role, level, setRole, setLevel } = useAiPrefsStore();
+  const [saved, setSaved] = useState(false);
+
+  const handleDone = () => {
+    setSaved(true);
+    setTimeout(onDone, 400);
+  };
+
+  const groupLabel = 'block text-[11px] font-semibold uppercase tracking-wide text-slate-400 mb-2';
+  const option = (selected: boolean) =>
+    `w-full text-left rounded-md border px-3 py-2 transition-colors ${
+      selected
+        ? 'border-indigo-500 bg-indigo-500/10'
+        : 'border-slate-700 bg-slate-800 hover:border-slate-500'
+    }`;
+
+  return (
+    
+
+ +

Tutor profile

+
+

+ The AI adjusts how it responds based on who you are and how much programming you already + know. +

+ +
+ I am a… +
+ {ROLE_OPTIONS.map((o) => ( + + ))} +
+
+ +
+ Experience level +
+ {LEVEL_OPTIONS.map((o) => ( + + ))} +
+
+ + +
+ ); +} diff --git a/src/components/editor/AiSidePanel.tsx b/src/components/editor/AiSidePanel.tsx index 0dfa5ab..2f16e8f 100644 --- a/src/components/editor/AiSidePanel.tsx +++ b/src/components/editor/AiSidePanel.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { History, KeyRound, LogIn, Plus, X } from 'lucide-react'; +import { History, KeyRound, LogIn, Plus, UserRound, X } from 'lucide-react'; import type { MouseEvent } from 'react'; import Fuse from 'fuse.js'; import keycloak from '../../api/keycloak'; @@ -10,6 +10,7 @@ import { randomId } from '../../utils/id'; import { ChatThread, type Chat } from '../ai/ChatThread'; import { HistoryPanel } from '../ai/HistoryPanel'; import { ApiKeySettings } from '../ai/ApiKeySettings'; +import { ProfileSettings } from '../ai/ProfileSettings'; import { TypingDots } from '../ai/MessageComponents'; interface AiSidePanelProps { @@ -67,6 +68,7 @@ export function AiSidePanel({ const [activeId, setActiveId] = useState(null); const [showHistory, setShowHistory] = useState(false); const [showKeySettings, setShowKeySettings] = useState(false); + const [showProfile, setShowProfile] = useState(false); const [search, setSearch] = useState(''); const [loadingMessages, setLoadingMessages] = useState(false); @@ -187,6 +189,7 @@ export function AiSidePanel({ setActiveId(c.id); setShowHistory(false); setShowKeySettings(false); + setShowProfile(false); }, []); const openChat = useCallback((id: string) => { @@ -252,16 +255,29 @@ export function AiSidePanel({ onClick={() => { setShowHistory((s) => !s); setShowKeySettings(false); + setShowProfile(false); }} className={`${iconBtn} ${showHistory ? 'text-indigo-300 bg-slate-800' : ''}`} title="Chat history" > +
+ ) : showProfile ? ( + setShowProfile(false)} /> ) : showKeySettings ? ( setShowKeySettings(false)} /> ) : showHistory ? ( diff --git a/src/pages/EditorPage.tsx b/src/pages/EditorPage.tsx index 3154e0d..b943b90 100644 --- a/src/pages/EditorPage.tsx +++ b/src/pages/EditorPage.tsx @@ -32,6 +32,7 @@ import { SourcePane } from '../components/editor/SourcePane'; import { TranslationPaneItem } from '../components/editor/TranslationPaneItem'; import { AddPanelStrip } from '../components/editor/AddPanelStrip'; import { AiSidePanel } from '../components/editor/AiSidePanel'; +import { useEditorBridge } from '../store/appStore'; import type { LlmPanel } from '../api/llm'; import type { Panel } from '../components/editor/types'; @@ -635,6 +636,38 @@ export default function EditorPage() { setShowAiSidePanel((prev) => !prev); }; + // "Open in editor" on AI chat code blocks: replaces the source editor's + // contents (same reset pattern as loading an example). The fence's language + // tag switches the source language when it names one Praxly supports. + useEffect(() => { + const fenceLangMap: Record = { + praxis: 'praxis', + python: 'python', + py: 'python', + java: 'java', + csp: 'csp', + javascript: 'javascript', + js: 'javascript', + }; + useEditorBridge.getState().setOpenCode((newCode, fenceLang) => { + const lang = fenceLangMap[fenceLang.trim().toLowerCase()]; + if (lang) { + setSourceLang(lang); + setPanels((prev) => prev.filter((panel) => panel.lang !== lang)); + } + setCode(newCode); + setOutput([]); + setError(null); + setIsDebugging(false); + setIsDebugComplete(false); + setHighlightedSourceLines([]); + setPanelHighlightedLines(new Map()); + setWaitingForNormalInput(false); + setCurrentInterpreter(null); + }); + return () => useEditorBridge.getState().setOpenCode(null); + }, [setIsDebugging, setIsDebugComplete, setHighlightedSourceLines]); + // Shared by both AI-panel resize handles (the panel's left edge and the // left edge of the add-panel strip): they move in lockstep because the // strip has a fixed width, so one drag math works for both. diff --git a/src/store/appStore.ts b/src/store/appStore.ts index 606a5ba..16eb897 100644 --- a/src/store/appStore.ts +++ b/src/store/appStore.ts @@ -49,6 +49,52 @@ export const useByokStore = create()( ) ); +/** Who the user is and how the tutor should pitch its answers. */ +export type AiRole = 'student' | 'teacher'; +export type AiLevel = 'novice' | 'intermediate' | 'advanced'; +/** Which tutoring use case the panel is in; 'auto' lets the AI infer it. */ +export type AiUseCase = 'auto' | 'explain' | 'tutor' | 'practice'; + +interface AiPrefsStore { + role: AiRole; + level: AiLevel; + useCase: AiUseCase; + setRole: (role: AiRole) => void; + setLevel: (level: AiLevel) => void; + setUseCase: (useCase: AiUseCase) => void; +} + +/** + * Tutor profile preferences. Persisted so the choice survives refreshes; sent + * with every chat request so the backend can fill the prompt's user_role, + * user_level, and use_case variables. + */ +export const useAiPrefsStore = create()( + persist( + (set) => ({ + role: 'student', + level: 'novice', + useCase: 'auto', + setRole: (role) => set({ role }), + setLevel: (level) => set({ level }), + setUseCase: (useCase) => set({ useCase }), + }), + { name: 'praxly-ai-prefs' } + ) +); + +interface EditorBridge { + /** Registered by EditorPage; lets AI chat code blocks open in the editor. */ + openCode: ((code: string, language: string) => void) | null; + setOpenCode: (fn: EditorBridge['openCode']) => void; +} + +/** Not persisted — just a live callback bridge between the chat panel and the editor. */ +export const useEditorBridge = create()((set) => ({ + openCode: null, + setOpenCode: (openCode) => set({ openCode }), +})); + /** * Drops sessions whose id was already seen, keeping the first occurrence. * The store must never hold two entries for one backend session — duplicates From 25179a299622f06bc944ef32267724df209d6204 Mon Sep 17 00:00:00 2001 From: MeghanRiordan Date: Tue, 21 Jul 2026 18:00:20 -0400 Subject: [PATCH 02/25] move account button into API key and new settings --- src/components/ai/ApiKeySettings.tsx | 141 ------------------------- src/components/editor/AiSidePanel.tsx | 39 +++---- src/components/editor/EditorHeader.tsx | 23 ---- src/pages/AccountPage.tsx | 131 ++++++++++++++++++++++- src/pages/EditorPage.tsx | 37 +------ 5 files changed, 144 insertions(+), 227 deletions(-) delete mode 100644 src/components/ai/ApiKeySettings.tsx diff --git a/src/components/ai/ApiKeySettings.tsx b/src/components/ai/ApiKeySettings.tsx deleted file mode 100644 index 679b78f..0000000 --- a/src/components/ai/ApiKeySettings.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import { useState } from 'react'; -import { Check, Eye, EyeOff, KeyRound } from 'lucide-react'; -import { useByokStore, type ByokProvider } from '../../store/appStore'; - -/** - * Bring-your-own-key settings, shown inside the AI side panel before/while - * chatting. The key is kept in localStorage and sent to the k12 backend as a - * header on each request — the backend forwards it to the provider without - * storing it. - */ - -const PROVIDER_OPTIONS: Array<{ value: ByokProvider | ''; label: string; hint?: string }> = [ - { value: '', label: 'School-provided model (default)' }, - { value: 'gemini', label: 'Gemini', hint: 'aistudio.google.com/apikey' }, - { value: 'anthropic', label: 'Claude', hint: 'console.anthropic.com' }, - { value: 'openai', label: 'ChatGPT', hint: 'platform.openai.com/api-keys' }, -]; - -export function ApiKeySettings({ onDone }: { onDone: () => void }) { - const { provider, apiKey, model, setByok, clearByok } = useByokStore(); - - const [draftProvider, setDraftProvider] = useState(provider ?? ''); - const [draftKey, setDraftKey] = useState(apiKey); - const [draftModel, setDraftModel] = useState(model); - const [showKey, setShowKey] = useState(false); - const [saved, setSaved] = useState(false); - - const needsKey = draftProvider !== ''; - const canSave = !needsKey || draftKey.trim().length > 0; - const hint = PROVIDER_OPTIONS.find((o) => o.value === draftProvider)?.hint; - - const handleSave = () => { - if (!canSave) return; - if (draftProvider === '') { - clearByok(); - } else { - setByok({ provider: draftProvider, apiKey: draftKey.trim(), model: draftModel.trim() }); - } - setSaved(true); - setTimeout(onDone, 600); - }; - - const labelCls = 'block text-[11px] font-semibold uppercase tracking-wide text-slate-400 mb-1'; - const inputCls = - 'w-full rounded-md bg-slate-800 border border-slate-700 text-sm text-slate-200 placeholder-slate-500 px-3 py-2 focus:outline-none focus:border-indigo-500 transition-colors'; - - return ( -
-
- -

AI model & API key

-
-

- Chat runs on the school-provided model by default. If you have your own API key you can use - it instead — it stays in this browser and is only used to make your requests. -

- -
- - -
- - {needsKey && ( - <> -
- -
- setDraftKey(e.target.value)} - placeholder="Paste your API key" - autoComplete="off" - spellCheck={false} - className={`${inputCls} pr-9`} - /> - -
- {hint &&

Get a key at {hint}

} -
- -
- - setDraftModel(e.target.value)} - placeholder="Leave blank for the recommended model" - autoComplete="off" - spellCheck={false} - className={inputCls} - /> -
- - )} - -
- - -
-
- ); -} diff --git a/src/components/editor/AiSidePanel.tsx b/src/components/editor/AiSidePanel.tsx index 2f16e8f..785a124 100644 --- a/src/components/editor/AiSidePanel.tsx +++ b/src/components/editor/AiSidePanel.tsx @@ -1,15 +1,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { History, KeyRound, LogIn, Plus, UserRound, X } from 'lucide-react'; +import { Link } from 'react-router-dom'; +import { History, LogIn, Plus, UserCircle, UserRound, X } from 'lucide-react'; import type { MouseEvent } from 'react'; import Fuse from 'fuse.js'; import keycloak from '../../api/keycloak'; import { listChats, getChat, deleteChatApi, renameChatApi, toSimpleMessages } from '../../api/chat'; import { type LlmPanel, type SimpleMessage, type TurnIds } from '../../api/llm'; -import { useByokStore, useChatStore, type SessionMeta } from '../../store/appStore'; +import { useChatStore, type SessionMeta } from '../../store/appStore'; import { randomId } from '../../utils/id'; import { ChatThread, type Chat } from '../ai/ChatThread'; import { HistoryPanel } from '../ai/HistoryPanel'; -import { ApiKeySettings } from '../ai/ApiKeySettings'; import { ProfileSettings } from '../ai/ProfileSettings'; import { TypingDots } from '../ai/MessageComponents'; @@ -62,12 +62,10 @@ export function AiSidePanel({ setMessages, getCachedMessages, } = useChatStore(); - const byokProvider = useByokStore((s) => s.provider); const [localChats, setLocalChats] = useState([]); const [activeId, setActiveId] = useState(null); const [showHistory, setShowHistory] = useState(false); - const [showKeySettings, setShowKeySettings] = useState(false); const [showProfile, setShowProfile] = useState(false); const [search, setSearch] = useState(''); const [loadingMessages, setLoadingMessages] = useState(false); @@ -188,7 +186,6 @@ export function AiSidePanel({ setLocalChats((prev) => [c, ...prev]); setActiveId(c.id); setShowHistory(false); - setShowKeySettings(false); setShowProfile(false); }, []); @@ -254,7 +251,6 @@ export function AiSidePanel({ - + {keycloak.authenticated ? ( + + + + ) : ( + + )} @@ -309,8 +296,6 @@ export function AiSidePanel({
) : showProfile ? ( setShowProfile(false)} /> - ) : showKeySettings ? ( - setShowKeySettings(false)} /> ) : showHistory ? ( - {/* Account — Google-style account manager (sign-in required) */} - {keycloak.authenticated ? ( - -
); From 1724bb6ecbf1f8d5b7cf72dffdaf8c9ee235e78f Mon Sep 17 00:00:00 2001 From: Chris Mayfield Date: Fri, 17 Jul 2026 08:31:41 -0400 Subject: [PATCH 03/25] feat(editor): tweak example programs and UI --- src/components/editor/EditorHeader.tsx | 2 +- src/pages/EditorPage.tsx | 1 - src/utils/sampleCodes.ts | 64 +++++++++++++------------- 3 files changed, 33 insertions(+), 34 deletions(-) diff --git a/src/components/editor/EditorHeader.tsx b/src/components/editor/EditorHeader.tsx index 9eaadc6..4e8f6de 100644 --- a/src/components/editor/EditorHeader.tsx +++ b/src/components/editor/EditorHeader.tsx @@ -107,7 +107,7 @@ export function EditorHeader({ id="examples-listbox" role="listbox" aria-label="Example programs" - className="absolute top-full right-0 mt-2 w-80 max-h-[360px] overflow-y-auto bg-slate-900 border border-slate-700 rounded-lg shadow-xl z-[220]" + className="absolute top-full right-0 mt-2 w-80 max-h-[80vh] overflow-y-auto bg-slate-900 border border-slate-700 rounded-lg shadow-xl z-[220]" >
Load Example Program diff --git a/src/pages/EditorPage.tsx b/src/pages/EditorPage.tsx index e23c5e3..67888e7 100644 --- a/src/pages/EditorPage.tsx +++ b/src/pages/EditorPage.tsx @@ -696,7 +696,6 @@ export default function EditorPage() { setSourceLang(example.lang); setCode(example.code); - setPanels((prev) => prev.filter((panel) => panel.lang !== example.lang)); setOutput([]); setError(null); setHasRun(false); diff --git a/src/utils/sampleCodes.ts b/src/utils/sampleCodes.ts index 081e80c..2cc6f91 100644 --- a/src/utils/sampleCodes.ts +++ b/src/utils/sampleCodes.ts @@ -27,7 +27,7 @@ export const EXAMPLE_CATEGORIES: Record = { export const EXAMPLE_PROGRAMS: ExampleProgram[] = [ { id: 'praxis-dice-score', - title: 'Dice Score Function', + title: 'Praxis Dice Score', description: 'Nested conditionals and return values', category: 'functions', lang: 'praxis', @@ -52,9 +52,37 @@ print newScore(1, 2, 3) description: 'Simple counting loop with output', category: 'loops', lang: 'praxis', - code: `for i <- 0; i < 5; i <- i + 1 - print(i) + code: `for ( i <- 0; i < 5; i <- i + 1 ) + print i end for +`, + }, + { + id: 'csp-repeat-until', + title: 'CSP Repeat Until', + description: 'Repeat-until loop with arithmetic update', + category: 'loops', + lang: 'csp', + code: `x <- 0 +REPEAT UNTIL (x >= 5) +{ + x <- x + 1 +} +DISPLAY(x) +`, + }, + { + id: 'csp-procedure-greet', + title: 'CSP Procedure', + description: 'Procedure declaration and call', + category: 'functions', + lang: 'csp', + code: `PROCEDURE greet(name) +{ + DISPLAY("Hello " + name) +} + +greet("Praxly") `, }, { @@ -110,7 +138,7 @@ print("Rolled a 6 after", attempts, "tries") }, { id: 'java-if-branch', - title: 'Java Conditional Branch', + title: 'Java Conditional', description: 'If / else with numeric comparison', category: 'conditionals', lang: 'java', @@ -120,34 +148,6 @@ if (x < 10) { } else { System.out.println("big"); } -`, - }, - { - id: 'csp-repeat-until', - title: 'CSP Repeat Until', - description: 'Repeat-until loop with arithmetic update', - category: 'loops', - lang: 'csp', - code: `x <- 0 -REPEAT UNTIL (x >= 5) -{ - x <- x + 1 -} -DISPLAY(x) -`, - }, - { - id: 'csp-procedure-greet', - title: 'CSP Procedure', - description: 'Procedure declaration and call', - category: 'functions', - lang: 'csp', - code: `PROCEDURE greet(name) -{ - DISPLAY("Hello " + name) -} - -greet("Praxly") `, }, { From c2d3e1ebbcedba75133dcd95a69dc6550d46fbc6 Mon Sep 17 00:00:00 2001 From: Chris Mayfield Date: Fri, 17 Jul 2026 08:56:05 -0400 Subject: [PATCH 04/25] =?UTF-8?q?feat(praxis,csp):=20canonicalize=20assign?= =?UTF-8?q?ment=20arrow=20output=20to=20=E2=86=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emitters now generate ← instead of ASCII <- for Praxis and CSP, matching the languages' authoritative pseudocode notation (specs already documented this but emitted <-). Parsers still accept <- and ⟵ as input variants; CSP additionally now accepts ⟵, mirroring what Praxis already allowed. Updated demo programs, sample catalog entries, and the CSP CodeMirror grammar (regenerated) to match. Co-Authored-By: Claude Sonnet 5 --- examples/demo.csp | 36 +++++++-------- examples/demo.praxis | 78 ++++++++++++++++----------------- specs/csp.md | 1 + src/language/csp/csp.grammar | 2 +- src/language/csp/csp.grammar.js | 2 +- src/language/csp/emitter.ts | 14 +++--- src/language/csp/lexer.ts | 2 +- src/language/praxis/emitter.ts | 46 ++++++++++--------- src/utils/sampleCodes.ts | 6 +-- tests/csp.test.ts | 13 ++++-- tests/placeholder.test.ts | 2 +- tests/praxis.test.ts | 8 ++-- 12 files changed, 108 insertions(+), 102 deletions(-) diff --git a/examples/demo.csp b/examples/demo.csp index 331a5d3..18fe102 100644 --- a/examples/demo.csp +++ b/examples/demo.csp @@ -11,10 +11,10 @@ // Switch / Try / Break / Continue ..... no such keywords. // ConditionalExpression (ternary) ..... not parsed. // UpdateExpression (++/--) ............ not in CSP. -// CompoundAssignment (+=) ............. write `x <- x + 1`. +// CompoundAssignment (+=) ............. write `x ← x + 1`. // // CSP runtime facts honored below: -// * Assignment is `<-` (or the unicode arrow); `=` means EQUALITY. +// * Assignment is `←` (ASCII `<-` also accepted); `=` means EQUALITY. // * Lists are 1-based (AP CSP). // * `REPEAT UNTIL(c)` is a PRE-condition loop (runs while NOT c). // * There is no counting FOR: use a counter + REPEAT UNTIL, or REPEAT n TIMES. @@ -27,25 +27,25 @@ // ========================== EXPRESSIONS ==================================== // ---- Literal (number, string, boolean) + Assignment + DISPLAY ------------- -name <- "csp" -greeting <- "hello " + name // BinaryExpression '+' (string concatenation) +name ← "csp" +greeting ← "hello " + name // BinaryExpression '+' (string concatenation) DISPLAY(greeting) // hello csp -flag <- true +flag ← true DISPLAY(flag) // true // ---- BinaryExpression: arithmetic + MOD (division is floating point) ------- -total <- 3 + 4 * 2 +total ← 3 + 4 * 2 DISPLAY(total) // 11 DISPLAY(17 MOD 5) // 2 DISPLAY(10 / 4) // 2.5 // ---- No unary minus in CSP -> subtract from zero --------------------------- -neg <- 0 - 7 +neg ← 0 - 7 DISPLAY(neg) // -7 // ---- Comparison + logical (AND / OR / NOT) incl. unicode relational forms -- -x <- 5 +x ← 5 DISPLAY(x >= 5 AND x < 10) // true DISPLAY(x = 5 OR x = 6) // true DISPLAY(NOT (x = 3)) // true (UnaryExpression NOT) @@ -55,9 +55,9 @@ DISPLAY(x ≤ 5) // true (unicode less-or-equal) DISPLAY(x ≥ 5) // true (unicode greater-or-equal) // ---- ArrayLiteral + IndexExpression (1-based) + index Assignment ----------- -nums <- [10, 20, 30] +nums ← [10, 20, 30] DISPLAY(nums[1]) // 10 (first element is index 1) -nums[2] <- 99 +nums[2] ← 99 DISPLAY(nums[2]) // 99 // ---- List builtins: APPEND / INSERT / REMOVE / LENGTH (1-based positions) -- @@ -68,7 +68,7 @@ DISPLAY(LENGTH(nums)) // 4 DISPLAY(nums[1]) // 5 // ---- String functions: CONCAT / SUBSTRING / CHARAT / len (1-based) --------- -word <- "algorithm" +word ← "algorithm" DISPLAY(CONCAT("al", "go")) // algo DISPLAY(SUBSTRING(word, 1, 4)) // algo (characters 1..4, inclusive) DISPLAY(CHARAT(word, 1)) // a (the first character) @@ -78,7 +78,7 @@ DISPLAY(len(word)) // 9 // ========================== STATEMENTS ===================================== // ---- IF / ELSE IF / ELSE --------------------------------------------------- -score <- 82 +score ← 82 IF (score >= 90) { DISPLAY("A") @@ -93,11 +93,11 @@ ELSE } // ---- REPEAT UNTIL(c) -> pre-condition While(NOT c) ------------------------- -i <- 0 +i ← 0 REPEAT UNTIL (i >= 3) { DISPLAY(i) // 0 1 2 - i <- i + 1 + i ← i + 1 } // ---- REPEAT n TIMES -> count-based For ------------------------------------- @@ -107,11 +107,11 @@ REPEAT 3 TIMES } // ---- Counting loop: counter + REPEAT UNTIL (CSP has no FOR FROM/TO) -------- -j <- 0 +j ← 0 REPEAT UNTIL (j >= 6) { DISPLAY(j) // 0 2 4 - j <- j + 2 + j ← j + 2 } // ---- FOR EACH item IN list ------------------------------------------------- @@ -136,14 +136,14 @@ PROCEDURE isEven(n) PROCEDURE printEvens(limit) { - i <- 0 + i ← 0 REPEAT UNTIL (i >= limit) // 0 .. limit-1 { IF (isEven(i)) { DISPLAY(i) } - i <- i + 1 + i ← i + 1 } } diff --git a/examples/demo.praxis b/examples/demo.praxis index 1ea6a7a..31c7727 100644 --- a/examples/demo.praxis +++ b/examples/demo.praxis @@ -7,7 +7,7 @@ // AST nodes NOT reachable from Praxis source (covered by the other demos): // Switch / SwitchCase .. no switch/case keywords. // ConditionalExpression no ?: ternary operator. -// CompoundAssignment ... no +=/-=; write `x <- x + 1`. +// CompoundAssignment ... no +=/-=; write `x ← x + 1`. // ForEach .............. no for-each; Praxis has only the C-style for loop. // // (Functions and classes are hoisted, so the driver calls them before their @@ -19,27 +19,27 @@ banner("literals and operators") // ---- Literal (int, float, string, boolean, null) + Assignment -------------- -int whole <- 42 -double frac <- 3.14 -string label <- "praxis" -boolean flag <- true -string empty <- null +int whole ← 42 +double frac ← 3.14 +string label ← "praxis" +boolean flag ← true +string empty ← null print(whole, frac, label, flag, empty) // Print with multiple expressions -// `=` is also a valid assignment operator in Praxis (same as `<-`) -int m <- 0 +// `=` is also a valid assignment operator in Praxis (same as `←`) +int m ← 0 m = 10 print(m) // ---- char literal + string escape sequences -------------------------------- -char grade <- 'A' // single-quoted, exactly one character -string tabbed <- "col1\tcol2" // \t \n \r \" \\ are recognized escapes +char grade ← 'A' // single-quoted, exactly one character +string tabbed ← "col1\tcol2" // \t \n \r \" \\ are recognized escapes print(grade) // A print(tabbed) // col1col2 // ---- BinaryExpression: arithmetic (+ - * / % ^) ---------------------------- -int a <- 17 -int b <- 5 +int a ← 17 +int b ← 5 print(a + b) // 22 print(a - b) // 12 print(a * b) // 85 @@ -61,7 +61,7 @@ print(a ≠ b) // true (unicode ≠) print(a < b or a > b) // true // ---- UpdateExpression (++ and --, prefix and postfix) ---------------------- -int i <- 5 +int i ← 5 i++ print(i) // 6 print(++i) // 7 @@ -69,8 +69,8 @@ i-- print(i) // 6 // ---- ArrayLiteral / IndexExpression / MemberExpression / CallExpression ---- -int[] xs <- {5, 3, 8, 1} // brace form (idiomatic); [5, 3, 8, 1] also works -xs[0] <- 50 // index Assignment +int[] xs ← {5, 3, 8, 1} // brace form (idiomatic); [5, 3, 8, 1] also works +xs[0] ← 50 // index Assignment print(xs[0]) // 50 print(xs.length) // 4 (MemberExpression) xs.append(99) // method CallExpression as ExpressionStatement @@ -78,8 +78,8 @@ print(xs.length) // 5 print(xs) // {50, 3, 8, 1, 99} // ---- ArrayCreation: new int[n] (n default-initialized elements) ------------ -int[] buffer <- new int[3] // {0, 0, 0} -buffer[1] <- 7 +int[] buffer ← new int[3] // {0, 0, 0} +buffer[1] ← 7 print(buffer) // {0, 7, 0} print(buffer.length) // 3 @@ -109,7 +109,7 @@ print("tail") // -> "joined: tail" on one line banner("control flow") // ---- If / else if / else (a single `end if` closes the whole chain) -------- -int score <- 75 +int score ← 75 if (score >= 90) print("A") else if (score >= 70) @@ -119,45 +119,45 @@ else end if // ---- While (pre-condition loop) -------------------------------------------- -int w <- 0 +int w ← 0 while (w < 3) print(w) // 0 1 2 - w <- w + 1 + w ← w + 1 end while // ---- For, C-style (init / condition / update) ------------------------------ -int total <- 0 -for j <- 0; j < xs.length; j <- j + 1 - total <- total + xs[j] +int total ← 0 +for j ← 0; j < xs.length; j ← j + 1 + total ← total + xs[j] end for print(total) // 161 // ---- For, iterate an array by index (Praxis has only the C-style for) ------ -for p <- 0; p < xs.length; p <- p + 1 +for p ← 0; p < xs.length; p ← p + 1 print(xs[p]) end for // ---- For, counting loop ---------------------------------------------------- -for k <- 1; k < 4; k <- k + 1 +for k ← 1; k < 4; k ← k + 1 print(k) // 1 2 3 end for // ---- DoWhile (post-condition; repeats WHILE the condition is true) --------- -int d <- 0 +int d ← 0 do print(d) // 0 1 2 - d <- d + 1 + d ← d + 1 while (d < 3) // ---- RepeatUntil (post-condition; repeats UNTIL the condition is true) ----- -int r <- 0 +int r ← 0 repeat print(r) // 0 1 2 - r <- r + 1 + r ← r + 1 until (r >= 3) // ---- Break / Continue ------------------------------------------------------ -for t <- 0; t < 5; t <- t + 1 +for t ← 0; t < 5; t ← t + 1 if (t == 1) continue // skip printing 1 end if @@ -225,12 +225,12 @@ class Counter private int step public Counter(int start) // constructor named after the class - count <- start // bare field assignment (no `this`) - step <- 1 + count ← start // bare field assignment (no `this`) + step ← 1 end Counter public void increment() - count <- count + step + count ← count + step end increment public int getCount() @@ -244,8 +244,8 @@ class Animal private int legs public Animal(string n) // param `n` differs from field `name` - name <- n - legs <- 4 + name ← n + legs ← 4 end Animal public string describe() @@ -265,7 +265,7 @@ end class Dog // ---- Default constructor + field initializer (no constructor defined) ------ class Box - string contents <- "gift" // field initialized at declaration + string contents ← "gift" // field initialized at declaration public string open() return "opened " + contents @@ -273,16 +273,16 @@ class Box end class Box // ---- NewExpression / methods / fields / inheritance ------------------------ -Counter c <- new Counter(5) +Counter c ← new Counter(5) c.increment() c.increment() print(c.getCount()) // 7 -Dog dog <- new Dog("Rex") +Dog dog ← new Dog("Rex") print(dog.speak()) // woof (Dog's own method) print(dog.describe()) // Rex has 4 legs (inherited field + method via super) -Box box <- new Box() // default constructor +Box box ← new Box() // default constructor print(box.open()) // opened gift banner("done") diff --git a/specs/csp.md b/specs/csp.md index c1e3c8a..3514137 100644 --- a/specs/csp.md +++ b/specs/csp.md @@ -4,6 +4,7 @@ The _AP Computer Science Principles_ exam uses the pseudocode notation described Notice that: - Assignment uses `←`, not `=` + - `<-` and `⟵` are also accepted by the interpreter (`=` is not — it is CSP's equality operator) - Indexes start at `1`, not `0` - Output uses `DISPLAY`, not `print` - Comparison uses `=`, not `==` diff --git a/src/language/csp/csp.grammar b/src/language/csp/csp.grammar index f27cb68..25d367f 100644 --- a/src/language/csp/csp.grammar +++ b/src/language/csp/csp.grammar @@ -151,7 +151,7 @@ kw { @specialize[@name={term}] } Boolean { "true" | "false" | "TRUE" | "FALSE" } Null { "null" | "NULL" | "None" } - AssignOp { "<-" } + AssignOp { "<-" | "←" | "⟵" } Eq { "=" | "==" } Neq { "!=" | "<>" } Lt { "<" } diff --git a/src/language/csp/csp.grammar.js b/src/language/csp/csp.grammar.js index f6562aa..f251043 100644 --- a/src/language/csp/csp.grammar.js +++ b/src/language/csp/csp.grammar.js @@ -10,7 +10,7 @@ export const parser = LRParser.deserialize({ maxTerm: 82, skippedNodes: [0,1], repeatNodeCount: 4, - tokenData: "5|~RvXY#iYZ#i]^#ipq#iqr#zrs$Vwx%sxy'[yz'az{'f{|'k|}'p}!O'u!O!P'z!P!Q(P!Q![(p!^!_)Z!_!`)s!`!a*Q!c!h*_!h!i*p!i!p*_!p!q-Q!q!v*_!v!w0S!w!}*_!}#O1S#P#Q1X#R#S*_#T#Y*_#Y#Z1^#Z#b*_#b#c3Z#c#h*_#h#i4r#i#o*_#o#p5r#q#r5w~#nS!p~XY#iYZ#i]^#ipq#i~#}P!_!`$Q~$VOz~~$YVOr$Vrs$os#O$V#O#P$t#P;'S$V;'S;=`%m<%lO$V~$tOe~~$wRO;'S$V;'S;=`%Q;=`O$V~%TWOr$Vrs$os#O$V#O#P$t#P;'S$V;'S;=`%m;=`<%l$V<%lO$V~%pP;=`<%l$V~%vVOw%swx$ox#O%s#O#P&]#P;'S%s;'S;=`'U<%lO%s~&`RO;'S%s;'S;=`&i;=`O%s~&lWOw%swx$ox#O%s#O#P&]#P;'S%s;'S;=`'U;=`<%l%s<%lO%s~'XP;=`<%l%s~'aO]~~'fO_~~'kOu~~'pOx~~'uO^~~'zOs~~(PO!S~~(UPv~!P!Q(X~(^SP~OY(XZ;'S(X;'S;=`(j<%lO(X~(mP;=`<%l(X~(uQd~!O!P({!Q![(p~)OP!Q![)R~)WPd~!Q![)R~)`R{~}!O)i!_!`)n!`!a$Q~)nOc~~)sO}~~)xPy~!_!`){~*QOy~~*VP|~!_!`*Y~*_O!O~~*dSS~!Q![*_!c!}*_#R#S*_#T#o*_~*uTS~!Q![*_!c!d+U!d!}*_#R#S*_#T#o*_~+ZUS~!Q![*_!c!n*_!n!o+m!o!}*_#R#S*_#T#o*_~+rUS~!Q![*_!c!u*_!u!v,U!v!}*_#R#S*_#T#o*_~,ZUS~!Q![*_!c!g*_!g!h,m!h!}*_#R#S*_#T#o*_~,tSf~S~!Q![*_!c!}*_#R#S*_#T#o*_~-VWS~!Q![*_!c!w*_!w!x-o!x!}*_#R#S*_#T#c*_#c#d/S#d#o*_~-tUS~!Q![*_!c!n*_!n!o.W!o!}*_#R#S*_#T#o*_~.]US~!Q![*_!c!n*_!n!o.o!o!}*_#R#S*_#T#o*_~.vSg~S~!Q![*_!c!}*_#R#S*_#T#o*_~/XUS~!Q![*_!c!}*_#R#S*_#T#b*_#b#c/k#c#o*_~/pUS~!Q![*_!c!}*_#R#S*_#T#X*_#X#Y.o#Y#o*_~0XUS~!Q![*_!c!t*_!t!u0k!u!}*_#R#S*_#T#o*_~0pUS~!Q![*_!c!w*_!w!x,U!x!}*_#R#S*_#T#o*_~1XOk~~1^Ol~~1cTS~!Q![*_!c!}*_#R#S*_#T#U1r#U#o*_~1wUS~!Q![*_!c!}*_#R#S*_#T#`*_#`#a2Z#a#o*_~2`US~!Q![*_!c!}*_#R#S*_#T#g*_#g#h2r#h#o*_~2wUS~!Q![*_!c!}*_#R#S*_#T#X*_#X#Y,m#Y#o*_~3`US~!Q![*_!c!}*_#R#S*_#T#i*_#i#j3r#j#o*_~3wUS~!Q![*_!c!}*_#R#S*_#T#`*_#`#a4Z#a#o*_~4`US~!Q![*_!c!}*_#R#S*_#T#`*_#`#a.o#a#o*_~4wUS~!Q![*_!c!}*_#R#S*_#T#f*_#f#g5Z#g#o*_~5`US~!Q![*_!c!}*_#R#S*_#T#i*_#i#j2r#j#o*_~5wOV~~5|Oa~", + tokenData: "6S~RxXY#oYZ#o]^#opq#oqr$Qrs$]wx%yxy'byz'gz{'l{|'q|}'v}!O'{!O!P(Q!P!Q(V!Q![(v!^!_)a!_!`)y!`!a*W!c!h*e!h!i*v!i!p*e!p!q-W!q!v*e!v!w0Y!w!}*e!}#O1Y#P#Q1_#R#S*e#T#Y*e#Y#Z1d#Z#b*e#b#c3a#c#h*e#h#i4x#i#o*e#o#p5x#q#r5}%#t%#u)o%Ga%Gb)o~#tS!p~XY#oYZ#o]^#opq#o~$TP!_!`$W~$]Oz~~$`VOr$]rs$us#O$]#O#P$z#P;'S$];'S;=`%s<%lO$]~$zOe~~$}RO;'S$];'S;=`%W;=`O$]~%ZWOr$]rs$us#O$]#O#P$z#P;'S$];'S;=`%s;=`<%l$]<%lO$]~%vP;=`<%l$]~%|VOw%ywx$ux#O%y#O#P&c#P;'S%y;'S;=`'[<%lO%y~&fRO;'S%y;'S;=`&o;=`O%y~&rWOw%ywx$ux#O%y#O#P&c#P;'S%y;'S;=`'[;=`<%l%y<%lO%y~'_P;=`<%l%y~'gO]~~'lO_~~'qOu~~'vOx~~'{O^~~(QOs~~(VO!S~~([Pv~!P!Q(_~(dSP~OY(_Z;'S(_;'S;=`(p<%lO(_~(sP;=`<%l(_~({Qd~!O!P)R!Q![(v~)UP!Q![)X~)^Pd~!Q![)X~)fR{~}!O)o!_!`)t!`!a$W~)tOc~~)yO}~~*OPy~!_!`*R~*WOy~~*]P|~!_!`*`~*eO!O~~*jSS~!Q![*e!c!}*e#R#S*e#T#o*e~*{TS~!Q![*e!c!d+[!d!}*e#R#S*e#T#o*e~+aUS~!Q![*e!c!n*e!n!o+s!o!}*e#R#S*e#T#o*e~+xUS~!Q![*e!c!u*e!u!v,[!v!}*e#R#S*e#T#o*e~,aUS~!Q![*e!c!g*e!g!h,s!h!}*e#R#S*e#T#o*e~,zSf~S~!Q![*e!c!}*e#R#S*e#T#o*e~-]WS~!Q![*e!c!w*e!w!x-u!x!}*e#R#S*e#T#c*e#c#d/Y#d#o*e~-zUS~!Q![*e!c!n*e!n!o.^!o!}*e#R#S*e#T#o*e~.cUS~!Q![*e!c!n*e!n!o.u!o!}*e#R#S*e#T#o*e~.|Sg~S~!Q![*e!c!}*e#R#S*e#T#o*e~/_US~!Q![*e!c!}*e#R#S*e#T#b*e#b#c/q#c#o*e~/vUS~!Q![*e!c!}*e#R#S*e#T#X*e#X#Y.u#Y#o*e~0_US~!Q![*e!c!t*e!t!u0q!u!}*e#R#S*e#T#o*e~0vUS~!Q![*e!c!w*e!w!x,[!x!}*e#R#S*e#T#o*e~1_Ok~~1dOl~~1iTS~!Q![*e!c!}*e#R#S*e#T#U1x#U#o*e~1}US~!Q![*e!c!}*e#R#S*e#T#`*e#`#a2a#a#o*e~2fUS~!Q![*e!c!}*e#R#S*e#T#g*e#g#h2x#h#o*e~2}US~!Q![*e!c!}*e#R#S*e#T#X*e#X#Y,s#Y#o*e~3fUS~!Q![*e!c!}*e#R#S*e#T#i*e#i#j3x#j#o*e~3}US~!Q![*e!c!}*e#R#S*e#T#`*e#`#a4a#a#o*e~4fUS~!Q![*e!c!}*e#R#S*e#T#`*e#`#a.u#a#o*e~4}US~!Q![*e!c!}*e#R#S*e#T#f*e#f#g5a#g#o*e~5fUS~!Q![*e!c!}*e#R#S*e#T#i*e#i#j2x#j#o*e~5}OV~~6SOa~", tokenizers: [0], topRules: {"Program":[0,2]}, specialized: [{term: 4, get: (value) => spec_Identifier[value] || -1}], diff --git a/src/language/csp/emitter.ts b/src/language/csp/emitter.ts index cdaa43c..b26af4e 100644 --- a/src/language/csp/emitter.ts +++ b/src/language/csp/emitter.ts @@ -6,7 +6,7 @@ * * Key dialect rules enforced here: * - IF / ELSE IF / ELSE chains (single nested-IF else branch -> ELSE IF) - * - Assignment arrow is <- (spec uses ←, both accepted on input) + * - Assignment arrow is ← (ASCII <- and long-arrow ⟵ also accepted on input) * - Equality operator is = (not ==) * - Not-equal is ≠, but <> is also emitted for ASCII compatibility * - RETURN uses parens: RETURN(value) @@ -103,7 +103,7 @@ export class CSPEmitter extends ASTVisitor { stmt.value.elements.forEach((val: any, i: number) => { const t = targets[i]; if (t?.type === 'Identifier') { - this.emit(`${t.name} <- ${this.generateExpression(val, 0)}`, stmt.id); + this.emit(`${t.name} ← ${this.generateExpression(val, 0)}`, stmt.id); } }); } @@ -116,7 +116,7 @@ export class CSPEmitter extends ASTVisitor { } const targetStr = this.generateExpression(stmt.target, 0); - this.emit(`${targetStr} <- ${this.generateExpression(stmt.value, 0)}`, stmt.id); + this.emit(`${targetStr} ← ${this.generateExpression(stmt.value, 0)}`, stmt.id); } visitIf(stmt: any): void { @@ -293,7 +293,7 @@ export class CSPEmitter extends ASTVisitor { } } - // Emits `var <- start; REPEAT UNTIL (var end) { body; var <- var + step }`, + // Emits `var ← start; REPEAT UNTIL (var end) { body; var ← var + step }`, // the spec-legal way to express a counting loop in CSP. private emitCounterLoop( variable: string, @@ -305,12 +305,12 @@ export class CSPEmitter extends ASTVisitor { id: string ): void { this.context.symbolTable.enterScope(); - this.emit(`${variable} <- ${start}`, id); + this.emit(`${variable} ← ${start}`, id); this.emit(`REPEAT UNTIL (${variable} ${cmp} ${end})`); this.emit('{'); this.indent(); this.visitBlock(body); - this.emit(`${variable} <- ${variable} + ${step}`); + this.emit(`${variable} ← ${variable} + ${step}`); this.dedent(); this.emit('}'); this.context.symbolTable.exitScope(); @@ -453,7 +453,7 @@ export class CSPEmitter extends ASTVisitor { case 'UpdateExpression': { const argStr = this.generateExpression((expr as any).argument, Precedence.Unary); const op = (expr as any).operator === '++' ? '+' : '-'; - output = `${argStr} <- ${argStr} ${op} 1`; + output = `${argStr} ← ${argStr} ${op} 1`; break; } diff --git a/src/language/csp/lexer.ts b/src/language/csp/lexer.ts index bb0ebbf..c6925fe 100644 --- a/src/language/csp/lexer.ts +++ b/src/language/csp/lexer.ts @@ -118,7 +118,7 @@ export class CSPLexer { } // Unicode symbols for assignment and relational operators - if (char === '←') { + if (char === '←' || char === '⟵') { tokens.push({ type: 'OPERATOR', value: '<-', start: this.pos++ }); continue; } diff --git a/src/language/praxis/emitter.ts b/src/language/praxis/emitter.ts index 678ba5f..60e4e5f 100644 --- a/src/language/praxis/emitter.ts +++ b/src/language/praxis/emitter.ts @@ -34,7 +34,7 @@ export class PraxisEmitter extends ASTVisitor { private currentClassName = ''; // Parameter names of the constructor/method currently being emitted. A field // access is qualified with `this.` only when the field name is shadowed by one - // of these (e.g. `this.name <- name`); otherwise the bare field name is used. + // of these (e.g. `this.name ← name`); otherwise the bare field name is used. private currentParams = new Set(); // Declared class names — used to render a bare constructor call (e.g. Python's // `Animal("Rex")`) as a Praxis `new Animal("Rex")` and to type the target. @@ -193,7 +193,7 @@ export class PraxisEmitter extends ASTVisitor { if (type === 'auto') type = 'var'; let line = `${field.access} ${type} ${field.name}`; - if (field.initializer) line += ` <- ${this.generateExpression(field.initializer, 0)}`; + if (field.initializer) line += ` ← ${this.generateExpression(field.initializer, 0)}`; this.emit(line); } @@ -297,10 +297,10 @@ export class PraxisEmitter extends ASTVisitor { let type = this.inferType(values[i]); if (type === 'var') type = 'int'; if (this.context.symbolTable.get(varName) === undefined) { - this.emit(`${type} ${varName} <- ${valStr}`, stmt.id); + this.emit(`${type} ${varName} ← ${valStr}`, stmt.id); this.context.symbolTable.set(varName, type); } else { - this.emit(`${varName} <- ${valStr}`, stmt.id); + this.emit(`${varName} ← ${valStr}`, stmt.id); } }); } @@ -318,9 +318,9 @@ export class PraxisEmitter extends ASTVisitor { const name = lvalueName(stmt); const targetStr = this.generateExpression(stmt.target, 0); - // Member/index mutation (e.g. obj.field <- v, arr[i] <- v) — never a declaration. + // Member/index mutation (e.g. obj.field ← v, arr[i] ← v) — never a declaration. if (name === undefined) { - this.emit(`${targetStr} <- ${rVal}`, stmt.id); + this.emit(`${targetStr} ← ${rVal}`, stmt.id); return; } @@ -331,18 +331,18 @@ export class PraxisEmitter extends ASTVisitor { // `auto`) stays untyped in Praxis: integer values keep their native // display (no forced `.0`), and `/` still divides as float because the // interpreter does not truncate untyped operands. - this.emit(`${targetStr} <- ${initVal}`, stmt.id); + this.emit(`${targetStr} ← ${initVal}`, stmt.id); this.context.symbolTable.set(name, 'auto'); } else { - this.emit(`${type} ${targetStr} <- ${initVal}`, stmt.id); + this.emit(`${type} ${targetStr} ← ${initVal}`, stmt.id); this.context.symbolTable.set(name, type); } } else if (this.context.symbolTable.get(name) !== undefined) { - this.emit(`${targetStr} <- ${rVal}`, stmt.id); + this.emit(`${targetStr} ← ${rVal}`, stmt.id); } else { let type = this.inferType(stmt.value); if (type === 'var') type = 'int'; - this.emit(`${type} ${targetStr} <- ${initVal}`, stmt.id); + this.emit(`${type} ${targetStr} ← ${initVal}`, stmt.id); this.context.symbolTable.set(name, type); } } @@ -460,7 +460,7 @@ export class PraxisEmitter extends ASTVisitor { const rVal = this.generateExpression(stmt.init.value, 0); let type = stmt.init.varType || this.inferType(stmt.init.value); if (type === 'var') type = 'int'; - initCode = `${type} ${initName} <- ${rVal}`; + initCode = `${type} ${initName} ← ${rVal}`; this.context.symbolTable.set(initName, type); } else if (stmt.init) { initCode = this.generateExpression(stmt.init.expression, 0); @@ -469,7 +469,7 @@ export class PraxisEmitter extends ASTVisitor { let updateCode = ''; if (stmt.update?.type === 'Assignment') { const ut = this.generateExpression(stmt.update.target, 0); - updateCode = `${ut} <- ${this.generateExpression(stmt.update.value, 0)}`; + updateCode = `${ut} ← ${this.generateExpression(stmt.update.value, 0)}`; } else if (stmt.update) { updateCode = this.generateExpression(stmt.update.expression, 0); } @@ -494,7 +494,7 @@ export class PraxisEmitter extends ASTVisitor { } if (args.length === 3) step = this.generateExpression(args[2], 0); this.emit( - `for (int ${stmt.variable} <- ${start}; ${stmt.variable} < ${end}; ${stmt.variable} <- ${stmt.variable} + ${step})`, + `for (int ${stmt.variable} ← ${start}; ${stmt.variable} < ${end}; ${stmt.variable} ← ${stmt.variable} + ${step})`, stmt.id ); this.indent(); @@ -510,10 +510,10 @@ export class PraxisEmitter extends ASTVisitor { const arr = `_arr${n}`; const idx = `_i${n}`; this.context.symbolTable.enterScope(); - this.emit(`${arr} <- ${this.generateExpression(stmt.iterable, 0)}`, stmt.id); - this.emit(`for (int ${idx} <- 0; ${idx} < ${arr}.length; ${idx} <- ${idx} + 1)`); + this.emit(`${arr} ← ${this.generateExpression(stmt.iterable, 0)}`, stmt.id); + this.emit(`for (int ${idx} ← 0; ${idx} < ${arr}.length; ${idx} ← ${idx} + 1)`); this.indent(); - this.emit(`${stmt.variable} <- ${arr}[${idx}]`); + this.emit(`${stmt.variable} ← ${arr}[${idx}]`); this.context.symbolTable.set(stmt.variable, 'var'); this.visitBlock(stmt.body); this.dedent(); @@ -554,12 +554,12 @@ export class PraxisEmitter extends ASTVisitor { visitExpressionStatement(stmt: any): void { // A bare `i++` / `--i` statement is ambiguous in Praxis (no statement - // terminators, so `i++\n--j` would re-associate); lower it to `i <- i ± 1`. + // terminators, so `i++\n--j` would re-associate); lower it to `i ← i ± 1`. const e = stmt.expression; if (e?.type === 'UpdateExpression') { const target = this.generateExpression(e.argument, 0); const op = e.operator === '++' ? '+' : '-'; - this.emit(`${target} <- ${target} ${op} 1`, stmt.id); + this.emit(`${target} ← ${target} ${op} 1`, stmt.id); return; } this.emit(this.generateExpression(stmt.expression, 0), stmt.id); @@ -748,12 +748,12 @@ export class PraxisEmitter extends ASTVisitor { } case 'CompoundAssignment': { - // Praxis has no `+=`; expand to `target <- target op right`. + // Praxis has no `+=`; expand to `target ← target op right`. const target = (expr as any).left ? this.generateExpression((expr as any).left, 0) : (expr as any).name; const op = (expr as any).operator; - output = `${target} <- ${target} ${op} ${this.generateExpression((expr as any).right, 0)}`; + output = `${target} ← ${target} ${op} ${this.generateExpression((expr as any).right, 0)}`; break; } @@ -762,12 +762,10 @@ export class PraxisEmitter extends ASTVisitor { const tmp = `_tern${this.tempCounter++}`; this.preludeLines.push(`if (${this.generateExpression((expr as any).test, 0)})`); this.preludeLines.push( - ` ${tmp} <- ${this.generateExpression((expr as any).consequent, 0)}` + ` ${tmp} ← ${this.generateExpression((expr as any).consequent, 0)}` ); this.preludeLines.push(`else`); - this.preludeLines.push( - ` ${tmp} <- ${this.generateExpression((expr as any).alternate, 0)}` - ); + this.preludeLines.push(` ${tmp} ← ${this.generateExpression((expr as any).alternate, 0)}`); this.preludeLines.push(`end if`); output = tmp; break; diff --git a/src/utils/sampleCodes.ts b/src/utils/sampleCodes.ts index 2cc6f91..23e5d03 100644 --- a/src/utils/sampleCodes.ts +++ b/src/utils/sampleCodes.ts @@ -52,7 +52,7 @@ print newScore(1, 2, 3) description: 'Simple counting loop with output', category: 'loops', lang: 'praxis', - code: `for ( i <- 0; i < 5; i <- i + 1 ) + code: `for ( i ← 0; i < 5; i ← i + 1 ) print i end for `, @@ -63,10 +63,10 @@ end for description: 'Repeat-until loop with arithmetic update', category: 'loops', lang: 'csp', - code: `x <- 0 + code: `x ← 0 REPEAT UNTIL (x >= 5) { - x <- x + 1 + x ← x + 1 } DISPLAY(x) `, diff --git a/tests/csp.test.ts b/tests/csp.test.ts index a94b5a6..e3712d9 100644 --- a/tests/csp.test.ts +++ b/tests/csp.test.ts @@ -49,6 +49,13 @@ describe('CSP Lexer', () => { expect(tokens).toContainEqual(expect.objectContaining({ type: 'OPERATOR', value: '<-' })); }); + it('accepts ← and ⟵ as assignment operator variants (not =, which is equality)', () => { + for (const arrow of ['←', '⟵']) { + const tokens = new CSPLexer(`x ${arrow} 5`).tokenize(); + expect(tokens).toContainEqual(expect.objectContaining({ type: 'OPERATOR', value: '<-' })); + } + }); + it('should tokenize not-equal operator', () => { const lexer = new CSPLexer('x <> y'); const tokens = lexer.tokenize(); @@ -247,7 +254,7 @@ describe('CSP Emitter', () => { }); emitter.visitProgram(program); const code = emitter.getGeneratedCode(); - expect(code).toContain('<-'); + expect(code).toContain('←'); }); it('should emit display statement', () => { @@ -364,7 +371,7 @@ describe('CSP Translation', () => { const translator = new Translator(); const result = translator.translate(program, 'csp'); expect(result).toContain('x'); - expect(result).toContain('<-'); + expect(result).toContain('←'); expect(result).toContain('5'); }); @@ -505,7 +512,7 @@ DISPLAY("after")`; const result = new Translator().translate(program, 'csp'); expect(result).not.toContain('FROM'); expect(result).toContain('REPEAT UNTIL'); - expect(result).toContain('i <- i + 2'); + expect(result).toContain('i ← i + 2'); }); it('should handle REPEAT n TIMES with variable', () => { diff --git a/tests/placeholder.test.ts b/tests/placeholder.test.ts index 029d484..6d52dcc 100644 --- a/tests/placeholder.test.ts +++ b/tests/placeholder.test.ts @@ -34,6 +34,6 @@ describe('Praxis placeholder', () => { it('lowers to a default 0 in other targets', () => { expect(to('x <- /* hole */\nprint(x)', 'python')).toContain('x = 0'); expect(to('int x <- /* hole */\nprint(x)', 'java')).toContain('int x = 0;'); - expect(to('x <- /* hole */\nprint(x)', 'csp')).toContain('x <- 0'); + expect(to('x <- /* hole */\nprint(x)', 'csp')).toContain('x ← 0'); }); }); diff --git a/tests/praxis.test.ts b/tests/praxis.test.ts index 23e8ad1..9711d70 100644 --- a/tests/praxis.test.ts +++ b/tests/praxis.test.ts @@ -284,7 +284,7 @@ end add`; }); emitter.visitProgram(program); const code = emitter.getGeneratedCode(); - expect(code).toContain('<-'); + expect(code).toContain('←'); }); it('should emit if statement', () => { @@ -341,7 +341,7 @@ describe('Praxis Translation', () => { const result = translator.translate(program, 'praxis'); expect(result).toContain('int'); expect(result).toContain('x'); - expect(result).toContain('<-'); + expect(result).toContain('←'); }); it('should translate print statement', () => { @@ -670,7 +670,7 @@ end class Box`; const praxis = new Translator().translate(program, 'praxis'); expect(praxis).toContain('public Box(int v)'); expect(praxis).toContain('end Box'); - expect(praxis).toContain('value <- v'); + expect(praxis).toContain('value ← v'); expect(praxis).not.toContain('this.'); expect(praxis).not.toContain('procedure new'); }); @@ -707,7 +707,7 @@ print(s.label())`; }`; const program = new JavaParser(new JavaLexer(src).tokenize()).parse(); const praxis = new Translator().translate(program, 'praxis'); - expect(praxis).toContain('this.name <- name'); // shadowed -> qualified + expect(praxis).toContain('this.name ← name'); // shadowed -> qualified expect(praxis).toContain('return name'); // not shadowed -> bare }); }); From 18e107f5c5c250bca8f6efba7f332c1294a1b183 Mon Sep 17 00:00:00 2001 From: Chris Mayfield Date: Fri, 17 Jul 2026 08:56:27 -0400 Subject: [PATCH 05/25] feat(csp): accept != as a not-equal operator variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CSP grammar (highlighting) and spec already documented != as an accepted ASCII alternate for ≠, but the actual lexer/parser never implemented it — ! wasn't even in the lexer's recognized character set, so it threw "Unexpected character". Wires it up to match the existing docs. Co-Authored-By: Claude Sonnet 5 --- src/language/csp/lexer.ts | 4 +++- src/language/csp/parser.ts | 2 +- tests/csp.test.ts | 6 ++++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/language/csp/lexer.ts b/src/language/csp/lexer.ts index c6925fe..d687baf 100644 --- a/src/language/csp/lexer.ts +++ b/src/language/csp/lexer.ts @@ -135,7 +135,9 @@ export class CSPLexer { continue; } - if (['+', '-', '*', '/', '=', '>', '<', '(', ')', '{', '}', '[', ']', ','].includes(char)) { + if ( + ['+', '-', '*', '/', '=', '>', '<', '!', '(', ')', '{', '}', '[', ']', ','].includes(char) + ) { const start = this.pos; // Check for <- if (char === '<' && this.input[this.pos + 1] === '-') { diff --git a/src/language/csp/parser.ts b/src/language/csp/parser.ts index c7f001d..0faf997 100644 --- a/src/language/csp/parser.ts +++ b/src/language/csp/parser.ts @@ -316,7 +316,7 @@ export class CSPParser { private equality(): Expression { let left = this.comparison(); - while (this.match('OPERATOR', '=', '<>')) { + while (this.match('OPERATOR', '=', '<>', '!=')) { let op = this.previous().value; if (op === '=') op = '=='; if (op === '<>') op = '!='; diff --git a/tests/csp.test.ts b/tests/csp.test.ts index e3712d9..c955c2b 100644 --- a/tests/csp.test.ts +++ b/tests/csp.test.ts @@ -62,6 +62,12 @@ describe('CSP Lexer', () => { expect(tokens).toContainEqual(expect.objectContaining({ type: 'OPERATOR', value: '<>' })); }); + it('should tokenize != as a not-equal operator variant', () => { + const lexer = new CSPLexer('x != y'); + const tokens = lexer.tokenize(); + expect(tokens).toContainEqual(expect.objectContaining({ type: 'OPERATOR', value: '!=' })); + }); + it('should tokenize comparison operators', () => { const lexer = new CSPLexer('a < b a > c a <= d a >= e'); const tokens = lexer.tokenize(); From dccb92096ddb3ce5472eee9bc5953ec1fd29a653 Mon Sep 17 00:00:00 2001 From: Chris Mayfield Date: Fri, 17 Jul 2026 12:32:23 -0400 Subject: [PATCH 06/25] feat(editor): add Demo toolbar button to load per-language feature demos Loads examples/demo.* for the currently selected source language via Vite ?raw imports, reusing the same files the round-trip/examples/blocks test suites already read, so the content isn't duplicated anywhere. Co-Authored-By: Claude Sonnet 5 --- src/components/editor/EditorHeader.tsx | 14 ++++++++++++++ src/pages/EditorPage.tsx | 21 +++++++++++++++++++++ src/utils/demoPrograms.ts | 25 +++++++++++++++++++++++++ 3 files changed, 60 insertions(+) create mode 100644 src/utils/demoPrograms.ts diff --git a/src/components/editor/EditorHeader.tsx b/src/components/editor/EditorHeader.tsx index 4e8f6de..f8af606 100644 --- a/src/components/editor/EditorHeader.tsx +++ b/src/components/editor/EditorHeader.tsx @@ -4,6 +4,7 @@ import { Trash2, Home, BookOpen, + FileCode, Bug, FastForward, Square, @@ -25,10 +26,12 @@ interface EditorHeaderProps { isDebugging: boolean; isDebugComplete: boolean; examples: ExampleProgram[]; + demoAvailable: boolean; textSize: TextSize; onClear: () => void; onShare: () => void; onLoadExample: (exampleId: string) => void; + onLoadDemo: () => void; onToggleExamplesMenu: () => void; onToggleSettingsMenu: () => void; onDebugStart: () => void; @@ -48,10 +51,12 @@ export function EditorHeader({ isDebugging, isDebugComplete, examples, + demoAvailable, textSize, onClear, onShare, onLoadExample, + onLoadDemo, onToggleExamplesMenu, onToggleSettingsMenu, onDebugStart, @@ -90,6 +95,15 @@ export function EditorHeader({ className="flex items-center flex-wrap justify-end gap-2 sm:gap-3" aria-label="Editor controls" > + + {/* Examples menu */}
+ )}
); From 9fb308eccdb224750264f52c7b6eb5bc07354742 Mon Sep 17 00:00:00 2001 From: Victor Del Carpio Date: Thu, 23 Jul 2026 23:44:18 -0400 Subject: [PATCH 19/25] fix: minor bug in redeclaration of methods --- src/pages/EditorPage.tsx | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/src/pages/EditorPage.tsx b/src/pages/EditorPage.tsx index 84bc7d2..4901303 100644 --- a/src/pages/EditorPage.tsx +++ b/src/pages/EditorPage.tsx @@ -752,33 +752,6 @@ export default function EditorPage() { setCurrentInterpreter(null); }; - const handleLoadDemo = () => { - setShowSettingsMenu(false); - setShowExamplesMenu(false); - // A non-blank editor is about to be discarded — confirm first. - if (code.trim()) { - setPendingExampleId(exampleId); - return; - } - loadExample(exampleId); - }; - - const loadDemo = () => { - const demo = getDemoForLang(sourceLang); - if (!demo) return; - - setCode(demo); - setOutput([]); - setError(null); - setHasRun(false); - setIsDebugging(false); - setIsDebugComplete(false); - setHighlightedSourceLines([]); - setPanelHighlightedLines(new Map()); - setWaitingForNormalInput(false); - setCurrentInterpreter(null); - }; - const handleLoadDemo = () => { setShowSettingsMenu(false); setShowExamplesMenu(false); From 0f48f8d8b521036dbbb3adf87d82effa267dfa21 Mon Sep 17 00:00:00 2001 From: MeghanRiordan Date: Mon, 27 Jul 2026 13:03:53 -0400 Subject: [PATCH 20/25] add markdown, onboarding gate, terms modal, and move settings to account page --- package-lock.json | 1594 ++++++++++++++++++++++- package.json | 2 + src/components/ai/AiTermsModal.tsx | 64 + src/components/ai/ApiKeyGate.tsx | 120 ++ src/components/ai/Markdown.tsx | 113 ++ src/components/ai/MarkdownLite.tsx | 102 -- src/components/ai/MessageComponents.tsx | 4 +- src/components/ai/ProfileSettings.tsx | 91 -- src/components/ai/byok.ts | 54 + src/components/editor/AiSidePanel.tsx | 66 +- src/index.css | 12 + src/pages/AccountPage.tsx | 153 ++- src/store/appStore.ts | 36 +- 13 files changed, 2079 insertions(+), 332 deletions(-) create mode 100644 src/components/ai/AiTermsModal.tsx create mode 100644 src/components/ai/ApiKeyGate.tsx create mode 100644 src/components/ai/Markdown.tsx delete mode 100644 src/components/ai/MarkdownLite.tsx delete mode 100644 src/components/ai/ProfileSettings.tsx create mode 100644 src/components/ai/byok.ts diff --git a/package-lock.json b/package-lock.json index 0d28b4d..687c4f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,8 @@ "fuse.js": "^7.4.2", "keycloak-js": "^26.2.4", "lz-string": "^1.5.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", "tailwindcss": "^4.1.18", "zustand": "^5.0.14" }, @@ -4331,6 +4333,15 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -4349,9 +4360,26 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -4359,6 +4387,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "26.1.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", @@ -4373,7 +4416,6 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -4400,6 +4442,12 @@ "@types/ws": "*" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -4728,6 +4776,12 @@ "react-dom": ">=17.0.0" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "license": "ISC" + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", @@ -5137,6 +5191,16 @@ "node": ">= 6" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -5271,6 +5335,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -5281,6 +5355,46 @@ "node": ">=18" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", @@ -5381,6 +5495,16 @@ "node": ">= 0.8" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/compare-versions": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz", @@ -5481,7 +5605,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, "license": "MIT" }, "node_modules/csv-parse": { @@ -5549,6 +5672,19 @@ "license": "MIT", "peer": true }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -5584,6 +5720,15 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -5600,6 +5745,19 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -5970,6 +6128,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -6007,6 +6175,12 @@ "node": ">=12.0.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6329,6 +6503,46 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -6359,6 +6573,16 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/http-proxy-agent": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.0.0.tgz", @@ -6437,6 +6661,12 @@ "dev": true, "license": "ISC" }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/ip-address": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", @@ -6457,6 +6687,40 @@ "node": ">=8" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -6496,6 +6760,28 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -7141,6 +7427,16 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -7180,6 +7476,16 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -7190,87 +7496,932 @@ "node": ">= 0.4" } }, - "node_modules/mdn-data": { - "version": "2.27.1", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", - "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "license": "CC0-1.0", - "peer": true - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", "license": "MIT", "dependencies": { - "mime-db": "1.52.0" + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" }, - "engines": { - "node": ">= 0.6" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "dev": true, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", "license": "MIT", "engines": { - "node": ">=18" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" }, - "engines": { - "node": "18 || 20 || >=22" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } }, - "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "license": "CC0-1.0", + "peer": true + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, "node_modules/natural-compare": { @@ -7447,6 +8598,31 @@ "dev": true, "license": "(MIT AND Zlib)" }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", @@ -7592,6 +8768,16 @@ "dev": true, "license": "ISC" }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/proxy-agent": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-8.0.1.tgz", @@ -7746,6 +8932,33 @@ "react": "^19.2.4" } }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -7909,6 +9122,72 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -8202,6 +9481,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -8260,6 +9549,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-ansi": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", @@ -8282,6 +9585,24 @@ "integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==", "license": "MIT" }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -8470,6 +9791,26 @@ "node": ">=20" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -8638,6 +9979,93 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -8782,6 +10210,34 @@ "dev": true, "license": "MIT" }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "8.1.3", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", @@ -9518,6 +10974,16 @@ "optional": true } } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/package.json index c670492..83ce5ad 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,8 @@ "fuse.js": "^7.4.2", "keycloak-js": "^26.2.4", "lz-string": "^1.5.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", "tailwindcss": "^4.1.18", "zustand": "^5.0.14" }, diff --git a/src/components/ai/AiTermsModal.tsx b/src/components/ai/AiTermsModal.tsx new file mode 100644 index 0000000..efea4ed --- /dev/null +++ b/src/components/ai/AiTermsModal.tsx @@ -0,0 +1,64 @@ +import { useEffect, useRef } from 'react'; +import { ShieldCheck } from 'lucide-react'; + +interface AiTermsModalProps { + onAccept: () => void; + onDecline: () => void; +} + +/** + * One-time consent shown before the AI Assistant is used, disclosing that + * interactions are logged for usage analysis. Accepting is required to + * continue; declining closes the panel. + */ +export function AiTermsModal({ onAccept, onDecline }: AiTermsModalProps) { + const acceptRef = useRef(null); + + useEffect(() => { + acceptRef.current?.focus(); + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onDecline(); + }; + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [onDecline]); + + return ( +
+
+
+ +

Before you use the AI Assistant

+
+

+ To help improve Praxly, your interactions with the AI Assistant — the messages you send, + the responses you receive, and how you use the tool — are recorded and analyzed. Don't + share sensitive personal information in your chats. +

+

+ By continuing, you agree to this usage tracking. +

+
+ + +
+
+
+ ); +} diff --git a/src/components/ai/ApiKeyGate.tsx b/src/components/ai/ApiKeyGate.tsx new file mode 100644 index 0000000..6416705 --- /dev/null +++ b/src/components/ai/ApiKeyGate.tsx @@ -0,0 +1,120 @@ +import { useState } from 'react'; +import { Eye, EyeOff, KeyRound } from 'lucide-react'; +import { PROVIDER_OPTIONS, useByokDraft } from './byok'; +import type { ByokProvider } from '../../store/appStore'; + +/** + * One-time onboarding gate shown in the AI panel after sign-in, before the user + * has chosen a model. They must pick something (Gemini is pre-selected; the + * school-provided model is a no-key fallback) before they can start chatting. + */ +export function ApiKeyGate({ onDone }: { onDone: () => void }) { + const draft = useByokDraft(); + const [showKey, setShowKey] = useState(false); + + const handleContinue = () => { + if (draft.save()) onDone(); + }; + + const labelCls = 'block text-[11px] font-semibold uppercase tracking-wide text-slate-400 mb-1'; + const inputCls = + 'w-full rounded-md bg-slate-800 border border-slate-700 text-sm text-slate-200 placeholder-slate-500 px-3 py-2 focus:outline-none focus:border-indigo-500 transition-colors'; + + return ( +
+
+ +

Choose your AI model

+
+

+ Pick a model to power the assistant. Use your own API key for the best experience, or the + school-provided model to get started without one. You can change this later in your account. +

+ +
+ + +
+ + {draft.needsKey && ( + <> +
+ +
+ draft.setDraftKey(e.target.value)} + placeholder="Paste your API key" + autoComplete="off" + spellCheck={false} + className={`${inputCls} pr-9`} + /> + +
+ {draft.hint && ( +

+ Get a key at{' '} + + {draft.hint} + +

+ )} +
+ +
+ + draft.setDraftModel(e.target.value)} + placeholder="Leave blank for the recommended model" + autoComplete="off" + spellCheck={false} + className={inputCls} + /> +
+ + )} + + +
+ ); +} diff --git a/src/components/ai/Markdown.tsx b/src/components/ai/Markdown.tsx new file mode 100644 index 0000000..a84d166 --- /dev/null +++ b/src/components/ai/Markdown.tsx @@ -0,0 +1,113 @@ +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { SquareArrowOutUpRight } from 'lucide-react'; +import { useEditorBridge } from '../../store/appStore'; + +/** + * Renders an assistant chat message as markdown (bold, lists, headings, + * tables, inline + fenced code). Fenced code blocks get a language header and + * an "Open in editor" button that drops the code into the source editor. + */ + +/** + * The tutor sometimes prefixes each line of a code block with its line number + * ("1 if (...)", "2 return"). That's fine to read, but pasting it into the + * editor would double up with the editor's own line gutter — so strip a + * leading number+space from every line, but only when ALL non-empty lines are + * numbered in increasing order (i.e. it's really line numbering, not code that + * happens to start with a digit). + */ +function stripLeadingLineNumbers(code: string): string { + const lines = code.split('\n'); + const nonEmpty = lines.filter((l) => l.trim().length > 0); + if (nonEmpty.length === 0) return code; + if (!nonEmpty.every((l) => /^\s*\d+\s+/.test(l))) return code; + + const nums = nonEmpty.map((l) => parseInt(/^\s*(\d+)/.exec(l)![1], 10)); + const increasing = nums.every((n, i) => i === 0 || n > nums[i - 1]); + if (!increasing) return code; + + return lines.map((l) => l.replace(/^(\s*)\d+\s+/, '$1')).join('\n'); +} + +function CodeBlock({ language, code }: { language: string; code: string }) { + const openCode = useEditorBridge((s) => s.openCode); + return ( +
+
+ + {language || 'code'} + + {openCode && code.trim().length > 0 && ( + + )} +
+
+        {code}
+      
+
+ ); +} + +export function Markdown({ text }: { text: string }) { + return ( +
+ so our CodeBlock (which brings its own
) isn't nested inside one.
+          pre: ({ children }) => <>{children},
+          code({ className, children }) {
+            const value = String(children).replace(/\n$/, '');
+            const match = /language-(\w+)/.exec(className ?? '');
+            const isBlock = Boolean(match) || value.includes('\n');
+            if (!isBlock) {
+              return (
+                
+                  {value}
+                
+              );
+            }
+            return ;
+          },
+          a: ({ children, href }) => (
+            
+              {children}
+            
+          ),
+          ul: ({ children }) => 
    {children}
, + ol: ({ children }) =>
    {children}
, + h1: ({ children }) => ( +

{children}

+ ), + h2: ({ children }) => ( +

{children}

+ ), + h3: ({ children }) => ( +

{children}

+ ), + table: ({ children }) => ( +
+ {children}
+
+ ), + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => {children}, + }} + > + {text} + +
+ ); +} diff --git a/src/components/ai/MarkdownLite.tsx b/src/components/ai/MarkdownLite.tsx deleted file mode 100644 index 9264005..0000000 --- a/src/components/ai/MarkdownLite.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { Fragment, useMemo } from 'react'; -import { SquareArrowOutUpRight } from 'lucide-react'; -import { useEditorBridge } from '../../store/appStore'; - -/** - * Minimal markdown renderer for chat messages — handles exactly what the - * tutor actually emits (fenced code blocks and inline code) without pulling - * in a full markdown library. - * - * While a reply is still streaming, an unterminated ``` fence is treated as - * an open code block so code renders styled from the first token. - */ - -interface Segment { - kind: 'text' | 'code'; - content: string; - language?: string; -} - -function splitFences(text: string): Segment[] { - const segments: Segment[] = []; - const parts = text.split('```'); - - parts.forEach((part, i) => { - const isCode = i % 2 === 1; - if (!isCode) { - const trimmed = part.replace(/^\n+/, '').replace(/\n+$/, ''); - if (trimmed.length > 0) segments.push({ kind: 'text', content: trimmed }); - return; - } - // The fence's first line is an optional language tag (```python). - const newline = part.indexOf('\n'); - const firstLine = newline === -1 ? part : part.slice(0, newline); - const isLangTag = /^[A-Za-z0-9+#_-]*$/.test(firstLine.trim()); - const language = isLangTag ? firstLine.trim() : ''; - const body = isLangTag && newline !== -1 ? part.slice(newline + 1) : isLangTag ? '' : part; - segments.push({ kind: 'code', content: body.replace(/\n$/, ''), language }); - }); - - return segments; -} - -/** Renders plain text, styling `inline code` spans. */ -function InlineText({ text }: { text: string }) { - const parts = text.split(/(`[^`\n]+`)/g); - return ( - <> - {parts.map((part, i) => - part.startsWith('`') && part.endsWith('`') && part.length > 2 ? ( - - {part.slice(1, -1)} - - ) : ( - {part} - ) - )} - - ); -} - -export function MarkdownLite({ text }: { text: string }) { - const segments = useMemo(() => splitFences(text), [text]); - const openCode = useEditorBridge((s) => s.openCode); - - return ( -
- {segments.map((segment, i) => - segment.kind === 'code' ? ( -
-
- - {segment.language || 'code'} - - {openCode && segment.content.trim().length > 0 && ( - - )} -
-
-              
-                {segment.content}
-              
-            
-
- ) : ( -

- -

- ) - )} -
- ); -} diff --git a/src/components/ai/MessageComponents.tsx b/src/components/ai/MessageComponents.tsx index 33b5b60..746884a 100644 --- a/src/components/ai/MessageComponents.tsx +++ b/src/components/ai/MessageComponents.tsx @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import { MessagePrimitive, useMessage, useThread } from '@assistant-ui/react'; import type { SimpleMessage } from '../../api/llm'; -import { MarkdownLite } from './MarkdownLite'; +import { Markdown } from './Markdown'; import { threadMessageText, toSimpleMessages } from './threadMessages'; export const UserMessage = () => ( @@ -26,7 +26,7 @@ export const AssistantMessage = () => { return (
- {isRunning && !text.trim() ? : } + {isRunning && !text.trim() ? : }
); diff --git a/src/components/ai/ProfileSettings.tsx b/src/components/ai/ProfileSettings.tsx deleted file mode 100644 index 0ad53d2..0000000 --- a/src/components/ai/ProfileSettings.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { Check, UserRound } from 'lucide-react'; -import { useState } from 'react'; -import { useAiPrefsStore, type AiLevel, type AiRole } from '../../store/appStore'; - -/** - * Tutor profile settings, shown inside the AI side panel. The tutor adapts - * its answers to who is asking (student vs teacher) and their experience - * level; both are sent to the backend with every request. - */ - -const ROLE_OPTIONS: Array<{ value: AiRole; label: string; hint: string }> = [ - { value: 'student', label: 'Student', hint: 'Guides you toward answers with hints' }, - { value: 'teacher', label: 'Teacher', hint: 'Direct answers plus teaching tips' }, -]; - -const LEVEL_OPTIONS: Array<{ value: AiLevel; label: string; hint: string }> = [ - { value: 'novice', label: 'Novice', hint: 'New to programming — explain everything' }, - { value: 'intermediate', label: 'Intermediate', hint: 'Knows the basics' }, - { value: 'advanced', label: 'Advanced', hint: 'Comfortable — skip the fundamentals' }, -]; - -export function ProfileSettings({ onDone }: { onDone: () => void }) { - const { role, level, setRole, setLevel } = useAiPrefsStore(); - const [saved, setSaved] = useState(false); - - const handleDone = () => { - setSaved(true); - setTimeout(onDone, 400); - }; - - const groupLabel = 'block text-[11px] font-semibold uppercase tracking-wide text-slate-400 mb-2'; - const option = (selected: boolean) => - `w-full text-left rounded-md border px-3 py-2 transition-colors ${ - selected - ? 'border-indigo-500 bg-indigo-500/10' - : 'border-slate-700 bg-slate-800 hover:border-slate-500' - }`; - - return ( -
-
- -

Tutor profile

-
-

- The AI adjusts how it responds based on who you are and how much programming you already - know. -

- -
- I am a… -
- {ROLE_OPTIONS.map((o) => ( - - ))} -
-
- -
- Experience level -
- {LEVEL_OPTIONS.map((o) => ( - - ))} -
-
- - -
- ); -} diff --git a/src/components/ai/byok.ts b/src/components/ai/byok.ts new file mode 100644 index 0000000..77113bc --- /dev/null +++ b/src/components/ai/byok.ts @@ -0,0 +1,54 @@ +import { useState } from 'react'; +import { useByokStore, type ByokProvider } from '../../store/appStore'; + +/** + * Shared bring-your-own-key logic used by both the Account page section and the + * AI panel's onboarding gate, so the two never drift. The provider list is + * ordered with Gemini first (the recommended default); "" means the + * school-provided model (the fallback that needs no key). + */ + +export const PROVIDER_OPTIONS: Array<{ value: ByokProvider | ''; label: string; hint?: string }> = [ + { value: 'gemini', label: 'Gemini (recommended)', hint: 'aistudio.google.com/apikey' }, + { value: '', label: 'School-provided model' }, + { value: 'anthropic', label: 'Claude', hint: 'console.anthropic.com' }, + { value: 'openai', label: 'ChatGPT', hint: 'platform.openai.com/api-keys' }, +]; + +export function useByokDraft() { + const { provider, apiKey, model, configured, setByok, clearByok } = useByokStore(); + + // Gemini is the default selection until the user has made an explicit choice. + const [draftProvider, setDraftProvider] = useState( + configured ? (provider ?? '') : 'gemini' + ); + const [draftKey, setDraftKey] = useState(apiKey); + const [draftModel, setDraftModel] = useState(model); + + const needsKey = draftProvider !== ''; + const canSave = !needsKey || draftKey.trim().length > 0; + const hint = PROVIDER_OPTIONS.find((o) => o.value === draftProvider)?.hint; + + const save = (): boolean => { + if (!canSave) return false; + if (draftProvider === '') { + clearByok(); + } else { + setByok({ provider: draftProvider, apiKey: draftKey.trim(), model: draftModel.trim() }); + } + return true; + }; + + return { + draftProvider, + setDraftProvider, + draftKey, + setDraftKey, + draftModel, + setDraftModel, + needsKey, + canSave, + hint, + save, + }; +} diff --git a/src/components/editor/AiSidePanel.tsx b/src/components/editor/AiSidePanel.tsx index 785a124..d7b5ce5 100644 --- a/src/components/editor/AiSidePanel.tsx +++ b/src/components/editor/AiSidePanel.tsx @@ -1,16 +1,22 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; -import { History, LogIn, Plus, UserCircle, UserRound, X } from 'lucide-react'; +import { History, LogIn, Plus, UserCircle, X } from 'lucide-react'; import type { MouseEvent } from 'react'; import Fuse from 'fuse.js'; import keycloak from '../../api/keycloak'; import { listChats, getChat, deleteChatApi, renameChatApi, toSimpleMessages } from '../../api/chat'; import { type LlmPanel, type SimpleMessage, type TurnIds } from '../../api/llm'; -import { useChatStore, type SessionMeta } from '../../store/appStore'; +import { + useAiConsentStore, + useByokStore, + useChatStore, + type SessionMeta, +} from '../../store/appStore'; import { randomId } from '../../utils/id'; import { ChatThread, type Chat } from '../ai/ChatThread'; import { HistoryPanel } from '../ai/HistoryPanel'; -import { ProfileSettings } from '../ai/ProfileSettings'; +import { ApiKeyGate } from '../ai/ApiKeyGate'; +import { AiTermsModal } from '../ai/AiTermsModal'; import { TypingDots } from '../ai/MessageComponents'; interface AiSidePanelProps { @@ -63,10 +69,13 @@ export function AiSidePanel({ getCachedMessages, } = useChatStore(); + const configured = useByokStore((s) => s.configured); + const termsAccepted = useAiConsentStore((s) => s.accepted); + const acceptTerms = useAiConsentStore((s) => s.accept); + const [localChats, setLocalChats] = useState([]); const [activeId, setActiveId] = useState(null); const [showHistory, setShowHistory] = useState(false); - const [showProfile, setShowProfile] = useState(false); const [search, setSearch] = useState(''); const [loadingMessages, setLoadingMessages] = useState(false); @@ -186,7 +195,6 @@ export function AiSidePanel({ setLocalChats((prev) => [c, ...prev]); setActiveId(c.id); setShowHistory(false); - setShowProfile(false); }, []); const openChat = useCallback((id: string) => { @@ -245,29 +253,21 @@ export function AiSidePanel({ AI Assistant
- - - + {/* Chat controls appear only once the user has chosen a model. */} + {keycloak.authenticated && configured && ( + <> + + + + )} {keycloak.authenticated ? ( @@ -294,8 +294,9 @@ export function AiSidePanel({ Sign in
- ) : showProfile ? ( - setShowProfile(false)} /> + ) : !configured ? ( + // Onboarding gate: must pick a model before anything else is reachable. + setShowHistory(false)} /> ) : showHistory ? ( ) ) : null} + + {/* Usage-tracking consent — required before using the AI; must accept or the panel closes. */} + {keycloak.authenticated && !termsAccepted && ( + + )}
); } diff --git a/src/index.css b/src/index.css index 50443b7..d239d41 100644 --- a/src/index.css +++ b/src/index.css @@ -5,6 +5,18 @@ @import 'tailwindcss'; +/* Tailwind v4 gives buttons the default cursor; restore the pointer on + clickable elements. Kept in @layer base so explicit cursor-* utilities + (e.g. disabled:cursor-not-allowed) still win. */ +@layer base { + button:not(:disabled), + [role='button']:not(:disabled), + a[href], + summary { + cursor: pointer; + } +} + /* Custom Scrollbar */ ::-webkit-scrollbar { width: 12px; diff --git a/src/pages/AccountPage.tsx b/src/pages/AccountPage.tsx index 7709cd2..3b42131 100644 --- a/src/pages/AccountPage.tsx +++ b/src/pages/AccountPage.tsx @@ -5,6 +5,7 @@ import { Check, Eye, EyeOff, + GraduationCap, KeyRound, LogOut, MessageSquare, @@ -24,7 +25,14 @@ import { type AccountUsage, } from '../api/account'; import { listChats, deleteChatApi, renameChatApi, type SessionMeta } from '../api/chat'; -import { useByokStore, useChatStore, type ByokProvider } from '../store/appStore'; +import { + useAiPrefsStore, + useChatStore, + type AiLevel, + type AiRole, + type ByokProvider, +} from '../store/appStore'; +import { PROVIDER_OPTIONS, useByokDraft } from '../components/ai/byok'; /** * Google-Account-style manager for the user's Keycloak account: @@ -32,13 +40,14 @@ import { useByokStore, useChatStore, type ByokProvider } from '../store/appStore * (usage stats + chat history management). */ -type Section = 'home' | 'personal' | 'security' | 'ai' | 'data'; +type Section = 'home' | 'personal' | 'security' | 'ai' | 'profile' | 'data'; const NAV: Array<{ id: Section; label: string; icon: typeof User }> = [ { id: 'home', label: 'Home', icon: User }, { id: 'personal', label: 'Personal info', icon: Pencil }, { id: 'security', label: 'Security', icon: ShieldCheck }, { id: 'ai', label: 'AI model & API key', icon: KeyRound }, + { id: 'profile', label: 'Tutor profile', icon: GraduationCap }, { id: 'data', label: 'Data & activity', icon: MessageSquare }, ]; @@ -229,6 +238,8 @@ export default function AccountPage() { ) : section === 'ai' ? ( + ) : section === 'profile' ? ( + ) : ( )} @@ -496,38 +507,20 @@ function SecuritySection({ // ── AI model & API key ─────────────────────────────────────────────────────── -const PROVIDER_OPTIONS: Array<{ value: ByokProvider | ''; label: string; hint?: string }> = [ - { value: '', label: 'School-provided model (default)' }, - { value: 'gemini', label: 'Gemini', hint: 'aistudio.google.com/apikey' }, - { value: 'anthropic', label: 'Claude', hint: 'console.anthropic.com' }, - { value: 'openai', label: 'ChatGPT', hint: 'platform.openai.com/api-keys' }, -]; - function AiSettingsSection({ notify, }: { notify: (kind: 'success' | 'error', text: string) => void; }) { - const { provider, apiKey, model, setByok, clearByok } = useByokStore(); - - const [draftProvider, setDraftProvider] = useState(provider ?? ''); - const [draftKey, setDraftKey] = useState(apiKey); - const [draftModel, setDraftModel] = useState(model); + const draft = useByokDraft(); const [showKey, setShowKey] = useState(false); - const needsKey = draftProvider !== ''; - const canSave = !needsKey || draftKey.trim().length > 0; - const hint = PROVIDER_OPTIONS.find((o) => o.value === draftProvider)?.hint; - const handleSave = () => { - if (!canSave) return; - if (draftProvider === '') { - clearByok(); - notify('success', 'Using the school-provided model.'); - } else { - setByok({ provider: draftProvider, apiKey: draftKey.trim(), model: draftModel.trim() }); - notify('success', 'API key saved.'); - } + if (!draft.save()) return; + notify( + 'success', + draft.draftProvider === '' ? 'Using the school-provided model.' : 'API key saved.' + ); }; return ( @@ -535,20 +528,20 @@ function AiSettingsSection({

AI model & API key

- The AI Assistant runs on the school-provided model by default. If you have your own API - key you can use it instead — it stays in this browser and is only used to make your - requests. + Pick which model powers the AI Assistant. Using your own API key gives the best experience + — it stays in this browser and is only used to make your requests. The school-provided + model works without a key.

- {needsKey && ( + {draft.needsKey && ( <>
- {hint &&

Get a key at {hint}

} + {draft.hint && ( +

+ Get a key at{' '} + + {draft.hint} + +

+ )}
setDraftModel(e.target.value)} + value={draft.draftModel} + onChange={(e) => draft.setDraftModel(e.target.value)} placeholder="Leave blank for the recommended model" autoComplete="off" spellCheck={false} @@ -607,7 +612,7 @@ function AiSettingsSection({ )}
-
@@ -616,6 +621,74 @@ function AiSettingsSection({ ); } +// ── Tutor profile ──────────────────────────────────────────────────────────── + +const ROLE_OPTIONS: Array<{ value: AiRole; label: string; hint: string }> = [ + { value: 'student', label: 'Student', hint: 'Guides you toward answers with hints' }, + { value: 'teacher', label: 'Teacher', hint: 'Direct answers plus teaching tips' }, +]; + +const LEVEL_OPTIONS: Array<{ value: AiLevel; label: string; hint: string }> = [ + { value: 'novice', label: 'Novice', hint: 'New to programming — explain everything' }, + { value: 'intermediate', label: 'Intermediate', hint: 'Knows the basics' }, + { value: 'advanced', label: 'Advanced', hint: 'Comfortable — skip the fundamentals' }, +]; + +function ProfileSection() { + const { role, level, setRole, setLevel } = useAiPrefsStore(); + + const optionBtn = (selected: boolean) => + `w-full text-left rounded-lg border px-3 py-2 transition-colors ${ + selected + ? 'border-indigo-500 bg-indigo-500/10' + : 'border-slate-700 bg-slate-800 hover:border-slate-500' + }`; + + return ( +
+
+

Tutor profile

+

+ The AI adjusts how it responds based on who you are and how much programming you already + know. +

+
+
+
+ I am a… +
+ {ROLE_OPTIONS.map((o) => ( + + ))} +
+
+
+ Experience level +
+ {LEVEL_OPTIONS.map((o) => ( + + ))} +
+
+
+
+ ); +} + // ── Data & activity ────────────────────────────────────────────────────────── function DataSection({ diff --git a/src/store/appStore.ts b/src/store/appStore.ts index 16eb897..4124abd 100644 --- a/src/store/appStore.ts +++ b/src/store/appStore.ts @@ -27,6 +27,9 @@ interface ByokStore { apiKey: string; /** Optional model override; empty string → backend default for the provider. */ model: string; + /** True once the user has made an explicit model choice (any provider, + * including school-provided). Drives the panel's one-time onboarding gate. */ + configured: boolean; setByok: (settings: { provider: ByokProvider | null; apiKey: string; model: string }) => void; clearByok: () => void; } @@ -42,10 +45,20 @@ export const useByokStore = create()( provider: null, apiKey: '', model: '', - setByok: ({ provider, apiKey, model }) => set({ provider, apiKey, model }), - clearByok: () => set({ provider: null, apiKey: '', model: '' }), + configured: false, + // Saving any choice (a real key or school-provided) counts as configured. + setByok: ({ provider, apiKey, model }) => set({ provider, apiKey, model, configured: true }), + clearByok: () => set({ provider: null, apiKey: '', model: '', configured: true }), }), - { name: 'praxly-byok' } + { + name: 'praxly-byok', + version: 1, + // Existing users who already picked a provider shouldn't be re-onboarded. + migrate: (persisted) => { + const s = persisted as Partial | undefined; + return { ...s, configured: s?.configured ?? s?.provider != null } as ByokStore; + }, + } ) ); @@ -83,6 +96,23 @@ export const useAiPrefsStore = create()( ) ); +interface AiConsentStore { + /** True once the user has accepted the AI usage-tracking notice. */ + accepted: boolean; + accept: () => void; +} + +/** One-time consent that AI interactions are logged for usage analysis. */ +export const useAiConsentStore = create()( + persist( + (set) => ({ + accepted: false, + accept: () => set({ accepted: true }), + }), + { name: 'praxly-ai-consent' } + ) +); + interface EditorBridge { /** Registered by EditorPage; lets AI chat code blocks open in the editor. */ openCode: ((code: string, language: string) => void) | null; From 619c188dff9b2ddd631870c17722a9a2a80c015b Mon Sep 17 00:00:00 2001 From: MeghanRiordan Date: Mon, 27 Jul 2026 17:00:08 -0400 Subject: [PATCH 21/25] connect language spec to the prompt --- src/api/llm.ts | 8 +++++-- src/components/ai/languageSpecs.ts | 34 ++++++++++++++++++++++++++++++ src/vite-env.d.ts | 1 + 3 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 src/components/ai/languageSpecs.ts create mode 100644 src/vite-env.d.ts diff --git a/src/api/llm.ts b/src/api/llm.ts index 77258c8..ca6e1a8 100644 --- a/src/api/llm.ts +++ b/src/api/llm.ts @@ -1,5 +1,6 @@ import { BACKEND_URL, requireAuthHeaders } from './config'; import { useAiPrefsStore, useByokStore } from '../store/appStore'; +import { languageSpecFor } from '../components/ai/languageSpecs'; /** * Talks to the k12-llm-backend (Hono / Node) through Keycloak auth. @@ -78,8 +79,11 @@ export async function* streamAssistant(opts: StreamOptions): AsyncGenerator p.language)), + // Tutor profile — fills the prompt's user_role / user_level / use_case. role: useAiPrefsStore.getState().role, level: useAiPrefsStore.getState().level, useCase: useAiPrefsStore.getState().useCase, diff --git a/src/components/ai/languageSpecs.ts b/src/components/ai/languageSpecs.ts new file mode 100644 index 0000000..e8bd1a2 --- /dev/null +++ b/src/components/ai/languageSpecs.ts @@ -0,0 +1,34 @@ +import praxis from '../../../specs/praxis.md?raw'; +import csp from '../../../specs/csp.md?raw'; +import java from '../../../specs/java.md?raw'; +import javascript from '../../../specs/javascript.md?raw'; +import python from '../../../specs/python.md?raw'; +import blocks from '../../../specs/blocks.md?raw'; +import stdlib from '../../../specs/stdlib.md?raw'; + +/** + * The authoritative language definitions (specs/) bundled into the app so they + * stay in sync with the repo on every build. Sent with each chat request to + * fill the Langfuse prompt's {{language_spec}} variable, so the tutor only ever + * describes what Praxly actually supports for the selected language. + */ +const SPECS: Record = { praxis, csp, java, javascript, python, blocks }; + +/** + * Combined spec for every open language: each distinct language's spec plus the + * shared stdlib (included once at the end). Sending all open panels' specs lets + * the tutor answer questions that compare languages. Unknown/duplicate + * languages are skipped; returns '' when none of the languages have a spec. + */ +export function languageSpecFor(languages: Array): string { + const seen = new Set(); + const parts: string[] = []; + for (const lang of languages) { + const key = lang?.toLowerCase(); + if (!key || seen.has(key)) continue; + seen.add(key); + const spec = SPECS[key]; + if (spec) parts.push(spec); + } + return parts.length > 0 ? `${parts.join('\n\n---\n\n')}\n\n${stdlib}` : ''; +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// From d555a832d9e927a2ce9bd11a616bd367e12007c2 Mon Sep 17 00:00:00 2001 From: Victor Hugo Date: Mon, 27 Jul 2026 18:42:25 -0400 Subject: [PATCH 22/25] feat: google oauth2 implementation --- .gitignore | 1 + package-lock.json | 36 ++-- src/api/account.ts | 2 + src/api/auth.ts | 254 ++++++++++++++++++++++++++ src/api/chat.ts | 15 +- src/api/config.ts | 20 +- src/components/ai/ChatThread.tsx | 6 + src/components/auth/SignInButtons.tsx | 80 ++++++++ src/components/editor/AiSidePanel.tsx | 112 ++++++++---- src/main.tsx | 45 +---- src/pages/AccountPage.tsx | 68 +++++-- src/store/appStore.ts | 34 +++- tests/chatStore.test.ts | 73 ++++++++ vite.config.js | 45 +++-- 14 files changed, 665 insertions(+), 126 deletions(-) create mode 100644 src/api/auth.ts create mode 100644 src/components/auth/SignInButtons.tsx create mode 100644 tests/chatStore.test.ts diff --git a/.gitignore b/.gitignore index 9484f7d..66a72d9 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ dist-ssr build test-results.json .claude +credentials.json \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 687c4f6..a67a52c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5256,16 +5256,16 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/browserslist": { @@ -8407,9 +8407,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "funding": [ { "type": "github", @@ -8683,9 +8683,9 @@ } }, "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "funding": [ { "type": "opencollective", @@ -8702,7 +8702,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -9007,9 +9007,9 @@ } }, "node_modules/react-router": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz", - "integrity": "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==", + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", + "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", "dev": true, "license": "MIT", "dependencies": { @@ -9030,13 +9030,13 @@ } }, "node_modules/react-router-dom": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.0.tgz", - "integrity": "sha512-Fi0yY6kgtKae/Th2xibdWK0KSdYZ4B53Gyf6wRtomOKWgpNm7H7+DyfDhncdz9FKbpS+1jmDhg3F4WoGJ+yFOA==", + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", + "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", "dev": true, "license": "MIT", "dependencies": { - "react-router": "7.18.0" + "react-router": "7.18.1" }, "engines": { "node": ">=20.0.0" diff --git a/src/api/account.ts b/src/api/account.ts index 209b186..caef383 100644 --- a/src/api/account.ts +++ b/src/api/account.ts @@ -10,6 +10,8 @@ export interface AccountProfile { lastName: string | null; createdAt: string | null; roles: string[]; + /** Which identity provider owns this account's credentials. */ + provider: 'keycloak' | 'google'; } export interface AccountUsage { diff --git a/src/api/auth.ts b/src/api/auth.ts new file mode 100644 index 0000000..5db21fa --- /dev/null +++ b/src/api/auth.ts @@ -0,0 +1,254 @@ +import keycloak from './keycloak'; +import { useByokStore, useChatStore } from '../store/appStore'; + +/** + * One sign-in surface for the whole app, over two providers: + * + * - Keycloak — the existing realm login, tokens handled by keycloak-js + * - Google — the OAuth 2 authorization-code flow, run server-side by + * k12-llm-backend, which hands back a session token + * + * Components should call these helpers instead of touching `keycloak` + * directly, so both providers stay interchangeable everywhere. + */ + +const env = ((import.meta as unknown as { env?: Record }).env ?? + {}) as Record; + +const BACKEND_URL = env.VITE_BACKEND_URL ?? 'https://k12api.torta-server.duckdns.org'; + +export type AuthProvider = 'keycloak' | 'google'; + +const GOOGLE_TOKEN_KEY = 'google_token'; +const GOOGLE_EXPIRES_KEY = 'google_token_expires_at'; +const GOOGLE_NONCE_KEY = 'google_auth_nonce'; + +interface GoogleSession { + token: string; + expiresAt: number; +} + +/** Reads the stored Google session, discarding it once expired. */ +function readGoogleSession(): GoogleSession | null { + const token = localStorage.getItem(GOOGLE_TOKEN_KEY); + const expiresAt = Number(localStorage.getItem(GOOGLE_EXPIRES_KEY) ?? 0); + if (!token) return null; + if (!expiresAt || Date.now() >= expiresAt) { + clearGoogleSession(); + return null; + } + return { token, expiresAt }; +} + +function clearGoogleSession(): void { + localStorage.removeItem(GOOGLE_TOKEN_KEY); + localStorage.removeItem(GOOGLE_EXPIRES_KEY); +} + +/** Error text set by the last redirect back from Google, if it failed. */ +let googleAuthError: string | null = null; + +const AUTH_ERRORS: Record = { + access_denied: 'Google sign-in was cancelled.', + email_unverified: 'That Google account has no verified email address.', + exchange_failed: "Google sign-in failed — couldn't verify the account.", + invalid_state: 'Google sign-in expired or was tampered with. Please try again.', + missing_state: 'Google sign-in expired or was tampered with. Please try again.', + missing_code: 'Google did not return an authorization code. Please try again.', + nonce_mismatch: 'Google sign-in could not be matched to this browser. Please try again.', +}; + +export function getAuthError(): string | null { + return googleAuthError; +} + +/** + * Consumes the `#token=…` fragment the backend redirects back with. + * + * Runs before anything renders (see main.tsx) and always strips the fragment, + * so the session token never lingers in the address bar or in history. + */ +function captureRedirectResult(): void { + const hash = window.location.hash.replace(/^#/, ''); + if (!hash) return; + + const params = new URLSearchParams(hash); + const token = params.get('token'); + const error = params.get('auth_error'); + if (!token && !error) return; // some other fragment — leave it alone + + const expectedNonce = sessionStorage.getItem(GOOGLE_NONCE_KEY); + sessionStorage.removeItem(GOOGLE_NONCE_KEY); + + if (error) { + googleAuthError = AUTH_ERRORS[error] ?? 'Google sign-in failed. Please try again.'; + } else if (token) { + // The nonce proves this redirect answers the sign-in *this* tab started. + if (!expectedNonce || params.get('nonce') !== expectedNonce) { + googleAuthError = AUTH_ERRORS['nonce_mismatch']!; + } else { + const expiresIn = Number(params.get('expires_in') ?? 0); + localStorage.setItem(GOOGLE_TOKEN_KEY, token); + localStorage.setItem( + GOOGLE_EXPIRES_KEY, + String(Date.now() + (expiresIn > 0 ? expiresIn : 3600) * 1000) + ); + } + } + + params.delete('token'); + params.delete('nonce'); + params.delete('provider'); + params.delete('expires_in'); + params.delete('auth_error'); + const rest = params.toString(); + window.history.replaceState( + null, + '', + window.location.pathname + window.location.search + (rest ? `#${rest}` : '') + ); +} + +/** + * Boots authentication: picks up a Google redirect if there is one, otherwise + * restores the Keycloak session. Resolves once the app can safely render. + */ +export async function initAuth(): Promise { + captureRedirectResult(); + + // A live Google session wins — initialising Keycloak as well would only add + // a redirect round-trip for a user who is already signed in. + if (readGoogleSession()) return; + + const savedToken = localStorage.getItem('kc_token') ?? undefined; + const savedRefreshToken = localStorage.getItem('kc_refresh_token') ?? undefined; + + keycloak.onTokenExpired = () => { + keycloak + .updateToken(30) + .then(() => { + localStorage.setItem('kc_token', keycloak.token!); + localStorage.setItem('kc_refresh_token', keycloak.refreshToken!); + }) + .catch(() => { + localStorage.removeItem('kc_token'); + localStorage.removeItem('kc_refresh_token'); + }); + }; + + try { + const authenticated = await keycloak.init({ + checkLoginIframe: false, + token: savedToken, + refreshToken: savedRefreshToken, + }); + if (authenticated) { + localStorage.setItem('kc_token', keycloak.token!); + localStorage.setItem('kc_refresh_token', keycloak.refreshToken!); + } + } catch { + // Keycloak unreachable — the app still loads, just signed out. + } +} + +/** Which provider the current session came from, or null when signed out. */ +export function currentProvider(): AuthProvider | null { + if (readGoogleSession()) return 'google'; + return keycloak.authenticated ? 'keycloak' : null; +} + +/** Unverified read of a JWT's payload — fine client-side, where the token is ours. */ +function decodePayload(token: string): Record | null { + try { + const part = token.split('.')[1]; + if (!part) return null; + return JSON.parse(atob(part.replace(/-/g, '+').replace(/_/g, '/'))) as Record; + } catch { + return null; + } +} + +/** + * Stable per-account key, e.g. `google:1234567890`. + * + * Anything cached in localStorage that belongs to one account (chat sessions, + * most obviously) must be tagged with this, or the next person to sign in on + * the same browser inherits it. + */ +export function currentUserKey(): string | null { + const google = readGoogleSession(); + if (google) { + const sub = decodePayload(google.token)?.['sub']; + return typeof sub === 'string' ? `google:${sub}` : null; + } + const sub = keycloak.authenticated ? keycloak.tokenParsed?.sub : undefined; + return sub ? `keycloak:${sub}` : null; +} + +export function isAuthenticated(): boolean { + return currentProvider() !== null; +} + +/** A bearer token for the backend, refreshing the Keycloak one if needed. */ +export async function getToken(): Promise { + const google = readGoogleSession(); + if (google) return google.token; + + if (!keycloak.authenticated) return null; + await keycloak.updateToken(30).catch(() => {}); + return keycloak.token ?? null; +} + +export async function loginWithKeycloak(): Promise { + // initAuth() skips Keycloak entirely while a Google session is live, so a + // session that expires with the tab open can leave keycloak-js uninitialised + // — and login() throws in that state. + if (!keycloak.didInitialize) { + await keycloak.init({ checkLoginIframe: false }).catch(() => {}); + } + keycloak.login(); +} + +/** + * Starts the Google flow. The backend owns the redirect to Google (it holds + * the client secret), so we just hand it a nonce and where to come back to. + */ +export function loginWithGoogle(): void { + const nonce = crypto.randomUUID(); + sessionStorage.setItem(GOOGLE_NONCE_KEY, nonce); + + const returnTo = window.location.origin + window.location.pathname; + const params = new URLSearchParams({ returnTo, nonce }); + window.location.assign(`${BACKEND_URL}/api/auth/google/start?${params.toString()}`); +} + +export function logout(redirectUri = window.location.origin + '/v2/editor'): void { + // Cached chat sessions belong to the account that just left; leaving them in + // localStorage would show them to whoever signs in next on this browser. + useChatStore.getState().clearChats(); + // Same reasoning for the BYOK key — it's a personal credential, and the next + // person to sign in on this browser would otherwise spend against it. This + // leaves the panel on the school-provided model rather than re-onboarding. + useByokStore.getState().clearByok(); + + if (readGoogleSession()) { + // Local sign-out only: we never asked for offline access, so there is no + // Google-side session of ours to end, and signing the user out of Google + // itself would be far more than they asked for. + clearGoogleSession(); + window.location.assign(redirectUri); + return; + } + keycloak.logout({ redirectUri }); +} + +/** Which providers the backend is configured to offer. Falls back to Keycloak. */ +export async function fetchProviders(): Promise<{ keycloak: boolean; google: boolean }> { + try { + const res = await fetch(`${BACKEND_URL}/api/auth/providers`); + if (!res.ok) throw new Error(String(res.status)); + return (await res.json()) as { keycloak: boolean; google: boolean }; + } catch { + return { keycloak: true, google: false }; + } +} diff --git a/src/api/chat.ts b/src/api/chat.ts index 6f2b0bd..facee00 100644 --- a/src/api/chat.ts +++ b/src/api/chat.ts @@ -14,15 +14,26 @@ export interface SessionDetail extends SessionMeta { messages: Array<{ id: string; role: 'user' | 'assistant'; content: string }>; } +/** Carries the status code so callers can tell "gone" from "try again later". */ +export class HttpError extends Error { + constructor( + message: string, + readonly status: number + ) { + super(message); + this.name = 'HttpError'; + } +} + export async function listChats(): Promise { const res = await fetch(`${BACKEND_URL}/api/chats`, { headers: await authHeaders() }); - if (!res.ok) throw new Error(`Failed to load chats: ${res.status}`); + if (!res.ok) throw new HttpError(`Failed to load chats (${res.status})`, res.status); return res.json() as Promise; } export async function getChat(id: string): Promise { const res = await fetch(`${BACKEND_URL}/api/chats/${id}`, { headers: await authHeaders() }); - if (!res.ok) throw new Error(`Failed to load chat: ${res.status}`); + if (!res.ok) throw new HttpError(`Failed to load chat (${res.status})`, res.status); return res.json() as Promise; } diff --git a/src/api/config.ts b/src/api/config.ts index fff82b6..5738227 100644 --- a/src/api/config.ts +++ b/src/api/config.ts @@ -1,4 +1,4 @@ -import keycloak from './keycloak'; +import { getToken } from './auth'; /** * Shared configuration for every k12-llm-backend call. @@ -12,20 +12,26 @@ const env = ((import.meta as unknown as { env?: Record> { - await keycloak.updateToken(30).catch(() => {}); + const token = await getToken(); return { 'Content-Type': 'application/json', - Authorization: `Bearer ${keycloak.token ?? ''}`, + Authorization: `Bearer ${token ?? ''}`, }; } /** Like authHeaders, but throws when the user isn't signed in (chat requires auth). */ export async function requireAuthHeaders(): Promise> { - const headers = await authHeaders(); - if (!keycloak.token) { + const token = await getToken(); + if (!token) { throw new Error('Not authenticated — please refresh the page to log in.'); } - return headers; + return { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }; } diff --git a/src/components/ai/ChatThread.tsx b/src/components/ai/ChatThread.tsx index 8f71d87..3564c00 100644 --- a/src/components/ai/ChatThread.tsx +++ b/src/components/ai/ChatThread.tsx @@ -29,6 +29,12 @@ export interface Chat { sessionId: string | null; /** Id of the last persisted message — sent as parentMessageId on the next turn. */ parentMessageId: string | null; + /** + * True once this chat's history is in `messages` — including when the answer + * was "no messages". Without it, an empty-but-real session is indistinguishable + * from one still loading, and the panel waits on it forever. + */ + historyLoaded: boolean; } interface ChatThreadProps { diff --git a/src/components/auth/SignInButtons.tsx b/src/components/auth/SignInButtons.tsx new file mode 100644 index 0000000..5613a7b --- /dev/null +++ b/src/components/auth/SignInButtons.tsx @@ -0,0 +1,80 @@ +import { useEffect, useState } from 'react'; +import { LogIn } from 'lucide-react'; +import { fetchProviders, getAuthError, loginWithGoogle, loginWithKeycloak } from '../../api/auth'; + +/** + * The sign-in choices, shared by the AI panel and the account page so both + * always offer the same providers. + * + * The Google button only appears once the backend confirms it's configured — + * a deployment without Google credentials shows the Keycloak login alone. + */ + +/** Google's brand mark, per their sign-in branding guidelines. */ +function GoogleMark({ size = 16 }: { size?: number }) { + return ( + + ); +} + +export function SignInButtons({ compact = false }: { compact?: boolean }) { + const [googleAvailable, setGoogleAvailable] = useState(false); + const error = getAuthError(); + + useEffect(() => { + let active = true; + fetchProviders().then((p) => { + if (active) setGoogleAvailable(p.google); + }); + return () => { + active = false; + }; + }, []); + + const size = compact ? 'px-4 py-2 text-xs' : 'px-4 py-2.5 text-sm'; + + return ( +
+ {error && ( +

+ {error} +

+ )} + + + + {googleAvailable && ( + + )} +
+ ); +} diff --git a/src/components/editor/AiSidePanel.tsx b/src/components/editor/AiSidePanel.tsx index d7b5ce5..a96127a 100644 --- a/src/components/editor/AiSidePanel.tsx +++ b/src/components/editor/AiSidePanel.tsx @@ -1,10 +1,17 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; -import { History, LogIn, Plus, UserCircle, X } from 'lucide-react'; +import { History, Plus, UserCircle, X } from 'lucide-react'; import type { MouseEvent } from 'react'; import Fuse from 'fuse.js'; -import keycloak from '../../api/keycloak'; -import { listChats, getChat, deleteChatApi, renameChatApi, toSimpleMessages } from '../../api/chat'; +import { currentUserKey, isAuthenticated } from '../../api/auth'; +import { + listChats, + getChat, + deleteChatApi, + renameChatApi, + toSimpleMessages, + HttpError, +} from '../../api/chat'; import { type LlmPanel, type SimpleMessage, type TurnIds } from '../../api/llm'; import { useAiConsentStore, @@ -17,6 +24,7 @@ import { ChatThread, type Chat } from '../ai/ChatThread'; import { HistoryPanel } from '../ai/HistoryPanel'; import { ApiKeyGate } from '../ai/ApiKeyGate'; import { AiTermsModal } from '../ai/AiTermsModal'; +import { SignInButtons } from '../auth/SignInButtons'; import { TypingDots } from '../ai/MessageComponents'; interface AiSidePanelProps { @@ -35,6 +43,7 @@ const makeNewChat = (): Chat => ({ messages: [], sessionId: null, parentMessageId: null, + historyLoaded: true, // nothing to fetch — it exists only in this browser }); const titleFrom = (text: string): string => { @@ -42,12 +51,14 @@ const titleFrom = (text: string): string => { return clean.length > 40 ? `${clean.slice(0, 40)}…` : clean || 'New chat'; }; -const sessionToChat = (s: SessionMeta, messages: SimpleMessage[] = []): Chat => ({ +const sessionToChat = (s: SessionMeta, messages?: SimpleMessage[]): Chat => ({ id: s.id, title: s.title ?? 'New chat', - messages, + messages: messages ?? [], sessionId: s.id, parentMessageId: null, + // A cache hit is the history; anything else still needs fetching. + historyLoaded: messages !== undefined, }); export function AiSidePanel({ @@ -67,8 +78,10 @@ export function AiSidePanel({ updateSession, setMessages, getCachedMessages, + claimFor, } = useChatStore(); + const signedIn = isAuthenticated(); const configured = useByokStore((s) => s.configured); const termsAccepted = useAiConsentStore((s) => s.accepted); const acceptTerms = useAiConsentStore((s) => s.accept); @@ -78,13 +91,16 @@ export function AiSidePanel({ const [showHistory, setShowHistory] = useState(false); const [search, setSearch] = useState(''); const [loadingMessages, setLoadingMessages] = useState(false); + const [loadError, setLoadError] = useState(null); + // Bumped by "Try again" to re-run the message load for the active chat. + const [reloadNonce, setReloadNonce] = useState(0); // Load session list from backend (skipped if sessions already in persisted // store). Always ends with at least one chat selected — a fresh local chat // is created when the user has no history, so the panel is ready to type // into without pressing "+" first. useEffect(() => { - if (!keycloak.authenticated) return; + if (!signedIn) return; const showChats = (chats: Chat[]) => { const withFallback = chats.length > 0 ? chats : [makeNewChat()]; @@ -92,8 +108,14 @@ export function AiSidePanel({ setActiveId(withFallback[0].id); }; - if (sessions.length > 0) { - showChats(sessions.map((s) => sessionToChat(s, getCachedMessages(s.id)))); + // The persisted store is one localStorage key shared by every account that + // has used this browser. Hand it to whoever is signed in now; if it was + // someone else's, it gets wiped and we fall through to a fresh fetch. + const switchedAccount = claimFor(currentUserKey()); + const cachedSessions = switchedAccount ? [] : sessions; + + if (cachedSessions.length > 0) { + showChats(cachedSessions.map((s) => sessionToChat(s, getCachedMessages(s.id)))); return; } listChats() @@ -116,24 +138,50 @@ export function AiSidePanel({ const cached = getCachedMessages(chat.sessionId); if (cached && chat.parentMessageId) { setLocalChats((prev) => - prev.map((c) => (c.id === activeId ? { ...c, messages: cached } : c)) + prev.map((c) => (c.id === activeId ? { ...c, messages: cached, historyLoaded: true } : c)) ); return; } + const sessionId = chat.sessionId; + setLoadError(null); setLoadingMessages(true); - getChat(chat.sessionId) + getChat(sessionId) .then((detail) => { const messages = toSimpleMessages(detail.messages); const parentMessageId = detail.messages.at(-1)?.id ?? null; - setMessages(chat.sessionId!, messages); + setMessages(sessionId, messages); setLocalChats((prev) => - prev.map((c) => (c.id === activeId ? { ...c, messages, parentMessageId } : c)) + prev.map((c) => + c.id === activeId ? { ...c, messages, parentMessageId, historyLoaded: true } : c + ) ); }) - .catch(() => {}) + .catch((e: unknown) => { + // 404 means the session isn't ours (or no longer exists) — a stale + // entry we should forget, not retry. Dropping it also stops the panel + // waiting on history that is never going to arrive. + if (e instanceof HttpError && (e.status === 404 || e.status === 403)) { + removeSession(sessionId); + setLocalChats((prev) => { + const next = prev.filter((c) => c.sessionId !== sessionId); + return next.length > 0 ? next : [makeNewChat()]; + }); + return; + } + setLoadError(e instanceof Error ? e.message : 'Failed to load this chat.'); + }) .finally(() => setLoadingMessages(false)); - }, [activeId]); // eslint-disable-line react-hooks/exhaustive-deps + }, [activeId, reloadNonce]); // eslint-disable-line react-hooks/exhaustive-deps + + const retryLoad = useCallback(() => setReloadNonce((n) => n + 1), []); + + // Keep a valid selection when the active chat is dropped above. + useEffect(() => { + if (localChats.length > 0 && !localChats.some((c) => c.id === activeId)) { + setActiveId(localChats[0]!.id); + } + }, [localChats, activeId]); const activeChat = localChats.find((c) => c.id === activeId) ?? null; @@ -254,7 +302,7 @@ export function AiSidePanel({
{/* Chat controls appear only once the user has chosen a model. */} - {keycloak.authenticated && configured && ( + {signedIn && configured && ( <> )} - {keycloak.authenticated ? ( + {/* Signed out, the panel body below is the sign-in surface — it has + to be, now that there is more than one provider to choose from. */} + {signedIn && ( - ) : ( - )}
- {!keycloak.authenticated ? ( + {!signedIn ? (

Sign in to use the AI assistant.

- +
) : !configured ? ( // Onboarding gate: must pick a model before anything else is reachable. @@ -307,7 +347,17 @@ export function AiSidePanel({ onDelete={deleteChat} /> ) : activeChat ? ( - loadingMessages || (activeChat.sessionId !== null && activeChat.messages.length === 0) ? ( + loadError ? ( +
+

{loadError}

+ +
+ ) : loadingMessages || !activeChat.historyLoaded ? (
@@ -326,9 +376,7 @@ export function AiSidePanel({ ) : null} {/* Usage-tracking consent — required before using the AI; must accept or the panel closes. */} - {keycloak.authenticated && !termsAccepted && ( - - )} + {signedIn && !termsAccepted && }
); } diff --git a/src/main.tsx b/src/main.tsx index 80f930d..f4a14fd 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,39 +2,14 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import App from './App.tsx'; import './index.css'; -import keycloak from './api/keycloak'; +import { initAuth } from './api/auth'; -// Restore saved tokens so users stay logged in across page refreshes. -const savedToken = localStorage.getItem('kc_token') ?? undefined; -const savedRefreshToken = localStorage.getItem('kc_refresh_token') ?? undefined; - -// Keep tokens fresh in localStorage whenever Keycloak refreshes them. -keycloak.onTokenExpired = () => { - keycloak - .updateToken(30) - .then(() => { - localStorage.setItem('kc_token', keycloak.token!); - localStorage.setItem('kc_refresh_token', keycloak.refreshToken!); - }) - .catch(() => { - localStorage.removeItem('kc_token'); - localStorage.removeItem('kc_refresh_token'); - }); -}; - -keycloak - .init({ checkLoginIframe: false, token: savedToken, refreshToken: savedRefreshToken }) - .then((authenticated) => { - if (authenticated) { - localStorage.setItem('kc_token', keycloak.token!); - localStorage.setItem('kc_refresh_token', keycloak.refreshToken!); - } - }) - .catch(() => {}) - .finally(() => { - createRoot(document.getElementById('root')!).render( - - - - ); - }); +// Restores a saved session (Keycloak tokens, or a Google session token) and +// picks up the redirect back from Google before the first render. +initAuth().finally(() => { + createRoot(document.getElementById('root')!).render( + + + + ); +}); diff --git a/src/pages/AccountPage.tsx b/src/pages/AccountPage.tsx index 3b42131..fc190ca 100644 --- a/src/pages/AccountPage.tsx +++ b/src/pages/AccountPage.tsx @@ -15,7 +15,8 @@ import { User, X, } from 'lucide-react'; -import keycloak from '../api/keycloak'; +import { isAuthenticated, logout } from '../api/auth'; +import { SignInButtons } from '../components/auth/SignInButtons'; import { getAccount, getUsage, @@ -35,7 +36,8 @@ import { import { PROVIDER_OPTIONS, useByokDraft } from '../components/ai/byok'; /** - * Google-Account-style manager for the user's Keycloak account: + * Google-Account-style manager for the user's Praxly account (Keycloak or + * Google — whichever they signed in with): * personal info (email/name), security (password), and data & activity * (usage stats + chat history management). */ @@ -123,7 +125,7 @@ export default function AccountPage() { }, []); useEffect(() => { - if (!keycloak.authenticated) { + if (!isAuthenticated()) { setLoading(false); return; } @@ -137,15 +139,15 @@ export default function AccountPage() { ); }, []); - if (!keycloak.authenticated) { + if (!isAuthenticated()) { return (

Praxly Account

Sign in to manage your account.

- +
+ +
Back to editor @@ -174,7 +176,7 @@ export default function AccountPage() {