@@ -2,6 +2,7 @@ import type { WebFeature } from "@stc/contracts";
|
||||
import { learnerWeb } from "@stc/feature-learner/web";
|
||||
import { importWeb } from "../../../packages/feature-import/src/web";
|
||||
import { studioWeb } from "../../../packages/feature-studio/src/web";
|
||||
import { aiWeb } from "../../../packages/feature-ai/src/web";
|
||||
|
||||
// Phase 1 packages append one import line each and push into this list.
|
||||
export const webFeatures: WebFeature[] = [learnerWeb, importWeb, studioWeb];
|
||||
export const webFeatures: WebFeature[] = [learnerWeb, importWeb, studioWeb, aiWeb];
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# Contract requests from WP-D (AI)
|
||||
|
||||
Phase 1 worked around each of these inside `packages/feature-ai`. Phase 2 decides.
|
||||
|
||||
## 1. Feature factories in `startServer` (apps/server)
|
||||
|
||||
`aiFeature(ctx, opts?)` needs the `ServerContext` (db, courses, jobs, auth, config), but `startServer(config, features)` builds the
|
||||
context itself and takes ready-made `Feature` objects. So `apps/server/src/main.ts` is not wired yet (the web side is:
|
||||
one line in `apps/web/src/features.ts`). Proposal: `startServer(config, factories: ((ctx: ServerContext) => Feature)[])`,
|
||||
then append `(ctx) => aiFeature(ctx, { draftSink: createDraftWriter(ctx) })`. Jobs must be registered after the
|
||||
factories run (the job handler `ai.generate` lives on the returned feature).
|
||||
Local workaround: `packages/feature-ai/scripts/dev.ts` builds the context and composes the feature for the dev server on port 3464.
|
||||
|
||||
## 2. Studio `DraftWriter` as the generator's sink (no adapter needed)
|
||||
|
||||
The studio exports `createDraftWriter(ctx, { identity? })` whose `writeDraftFiles(courseId, authorId, files, message)`
|
||||
returns `{ branch, commit }` and commits only to `draft/<authorId>`. The AI package's `DraftSink` has the same method
|
||||
and returns `DraftWriteResult = { location?, branch?, commit? }`, so the studio writer is assignable as is:
|
||||
|
||||
```ts
|
||||
aiFeature(ctx, { draftSink: createDraftWriter(ctx, { identity: (id) => /* user name + email */ null }) })
|
||||
```
|
||||
|
||||
`generateDraft` then reports `location: "draft/<author> @ <short commit>"` plus `branch` and `commit`; the generate page
|
||||
says "Committed to your draft branch". Test: `test/generator.test.ts` ("a studio DraftWriter is a DraftSink as is").
|
||||
Open point: the studio writer throws 404 for courses it does not manage (overlay courses such as Pure Snake). Phase 2
|
||||
can wrap it to fall back to `createFsDraftSink(dataDir)` (drafts under `<dataDir>/drafts/<courseId>/<run>/`), or make
|
||||
generation for unmanaged courses an explicit error. Until wired, the filesystem sink is the default.
|
||||
|
||||
## 3. Tool-call fields on `ChatMessage`
|
||||
|
||||
`ChatMessage` has `toolCallId` and `name` but no structured slot for an assistant turn with several tool calls or with
|
||||
thinking blocks. The package encodes one tool call per assistant message (`content` = JSON input) and the Anthropic
|
||||
provider keeps each response's raw content blocks (thinking with signature, text, tool_use) in memory by tool_use id
|
||||
and replays them unchanged. Proposal: `ChatMessage.toolCalls?: { id, name, input }[]` and an opaque
|
||||
`providerData?: unknown` the provider can round-trip.
|
||||
|
||||
## 4. Per-course mentor toggle in `ai_settings`
|
||||
|
||||
The frozen key is `(scope, owner_id)` with one `mentor_off` column, so a per-course toggle cannot be a row. The flag
|
||||
lives in the user row's `config_json.mentorOff[courseId]`; `mentor_off` stays 0 (reserved for "off everywhere").
|
||||
Proposal: add `course_id TEXT NOT NULL DEFAULT ''` to the key, or a small `ai_mentor_prefs(user_id, course_id, off)` table.
|
||||
|
||||
## 5. Citations on `ai_messages`
|
||||
|
||||
`ai_messages` has no column for an assistant message's sources. They are stored as an extra row with role `sources`
|
||||
(JSON) right after the assistant row and folded back when reading. Proposal: `ai_messages.meta_json TEXT`.
|
||||
|
||||
## 6. Derived text cache layout (shared with WP-B)
|
||||
|
||||
Retrieval reads the importer's layout:
|
||||
- `<dataDir>/cache/decks/<courseId>/<deckId>/text.json` = `[{ page, text }]` (page images alongside);
|
||||
- `<dataDir>/cache/transcripts/<courseId>/<lessonId>.json` = `{ lessonId, text, segments: [{ start, end, text }] }`
|
||||
(skipped when the lesson has a `transcript` file in the course, which the job writes too);
|
||||
- plus plain `.txt/.md/.vtt/.srt` under `<dataDir>/cache/<courseId>/**` (older layout, kept for hand-made caches).
|
||||
Proposal: export these paths from `@stc/core` (e.g. `cachePaths(dataDir)`) so both packages share one definition.
|
||||
|
||||
## 7. Learner lesson URL
|
||||
|
||||
Citations carry `lessonId`; the assistant links them to `/c/<courseId>/<lessonId>`. Every such link goes through
|
||||
`lessonUrl(courseId, lessonId)` / `courseUrl(courseId)` in `packages/feature-ai/src/web/urls.ts` (also exported from
|
||||
`@stc/feature-ai/web`), so a different WP-A route is a one-line change. Proposal: move that helper into
|
||||
`@stc/contracts/web` so WP-A owns the route shape.
|
||||
|
||||
## 8. A global `author` role in core's `canView`
|
||||
|
||||
Generation and the studio file listing now require an authoring right (owner, the `author` role or a `course_authors`
|
||||
row) and core's `canView(user, course, acl, isAuthor)`. `canView` only knows per-course authors (`isAuthor`), so a user
|
||||
with the global `author` role cannot reach a `restricted` course without an ACL grant or a `course_authors` row.
|
||||
Spec section 6 allows author rights "on all courses or per course", and the studio branch (`feature-studio`
|
||||
`server/feature.ts:82-85`) currently grants the global role on every course. Proposal: core exports one
|
||||
`canAuthor(user, course, acl, isAuthor)` (or `canView` treats `roles.includes("author")` like `isAuthor`), and both
|
||||
the studio and the AI package call it. Until then the AI package follows core's `canView` (the stricter rule).
|
||||
|
||||
## 9. `STC_AI_ALLOWED_BASE_URLS` in `AppConfig`
|
||||
|
||||
Learners' own keys (BYOK) go only to `https://api.anthropic.com`, `https://api.openai.com/v1` or an origin on the
|
||||
allowlist (SSRF guard). The allowlist is `STC_AI_ALLOWED_BASE_URLS` (comma-separated exact origins) plus the admin
|
||||
setting `allowedBaseUrls` in the server AI config. `AppConfig` is frozen, so the package reads the variable from
|
||||
`process.env` (injectable through `aiFeature(ctx, { env })`). Proposal: add `aiAllowedBaseUrls: string[]` to
|
||||
`AppConfig` and `loadConfig`.
|
||||
@@ -0,0 +1,178 @@
|
||||
# WP-D AI Implementation Plan
|
||||
|
||||
> **For agentic workers:** executed inline by the WP-D agent with TDD (failing test first), one commit per task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Opt-in AI for STCourses: a course-aware learner assistant (retrieval, course tools, MCP tools, mentor mode) and a studio generator that writes house-style drafts. AI is off until an admin configures a server provider or a user saves their own key (BYOK).
|
||||
|
||||
**Architecture:**
|
||||
- `packages/feature-ai` exports `aiFeature(ctx, opts?): Feature`, `aiWeb: WebFeature` and `migrationsDirs`.
|
||||
- Providers implement the frozen `AiProvider` contract with plain `fetch` (no SDK): Anthropic Messages API (streaming SSE, tool use, default model `claude-opus-5-5`) and OpenAI-compatible chat completions (covers Ollama).
|
||||
- Settings live in the existing `ai_settings` and `user_api_keys` tables (no new tables). Secrets are AES-GCM encrypted with a key derived by HKDF-SHA-256 from `ctx.config.secret`; they never leave the server.
|
||||
- Retrieval is an in-process BM25 index per loaded course (course Markdown, transcripts, code, deck/transcript text under `<dataDir>/cache/<courseId>/`), plus the requesting user's notes at query time. Solution files are never indexed.
|
||||
- The assistant runs a small tool loop over `search_course`, `read_lesson`, `list_notes` and admin-configured MCP servers (stdio and streamable HTTP), and streams NDJSON events to the browser.
|
||||
- The generator builds house-style prompts (Pure Snake module shape, Systemic Design beats), parses `=== FILE: … ===` blocks from the model output, and hands them to a `DraftSink`. The default sink writes to `<dataDir>/drafts/<courseId>/<runId>/`; Phase 2 swaps in the studio's `createDraftWriter(ctx)`.
|
||||
|
||||
**Tech Stack:** Bun 1.3, TypeScript strict, WebCrypto, `Bun.SQL`, `Bun.spawn`, React 19 + React Router 6, `@stc/ui` primitives.
|
||||
|
||||
**Spec:** sections 9.2, 7, 2 (anti-goals). Brief: `.superpowers/sdd/2026-10-30-stcourses/phase-1-brief.md` (WP-D).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
Inherited from `docs/superpowers/plans/2026-10-30-stcourses.md` (Bun only, plain CSS with tokens, both databases, AGPL, never write into the reference course repos, never commit licensed content, keyboard rules, commit trailer). Package-specific:
|
||||
|
||||
- **AI is off by default.** No provider configured and no BYOK key means every AI route answers `enabled: false` / 409, and the UI says how to turn it on.
|
||||
- **Keys never go to the client.** API responses carry `hasApiKey: boolean`, never ciphertext or plaintext.
|
||||
- **No real network in tests.** Providers take an injectable `fetch`; MCP tests use a local fake server (stdio child process and `Bun.serve` on port 0).
|
||||
- **Mentor mode is on by default** and its rules go into the system prompt; when off, the UI shows "Mentor mode off: I'll give full answers".
|
||||
- **Generated material is never published.** The generator only writes through a `DraftSink`.
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. **Secrets:** BYOK and server keys are encrypted at rest (AES-GCM, random IV, HKDF key from `STC_SECRET`), a wrong secret fails to decrypt, and no route returns key material. Tests: Tasks 1, 2, 9.
|
||||
2. **Mentor anti-goal:** mentor rules are in the system prompt unless the learner switched them off for that course; solution files (`browser.solution`, `solutions/`, `reference/`, `expansion/solution/`) are never retrieved or readable by tools. Tests: Tasks 5, 7.
|
||||
3. **Generation never publishes:** drafts land only under the sink location, paths are sanitised (no `..`, no absolute paths). Test: Task 8.
|
||||
4. **Access control:** assistant routes require that the user can view the course (`canView`); generator routes require owner, the `author` role or a `course_authors` row; admin routes require owner. Test: Task 9.
|
||||
5. **Thinking blocks:** the Anthropic provider replays an assistant turn's raw content blocks (thinking, text, tool_use) unchanged inside one tool loop. Test: Task 3.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Package scaffold and secret box
|
||||
|
||||
**Files:** `packages/feature-ai/{package.json,src/index.ts,src/crypto.ts}`, test `packages/feature-ai/test/crypto.test.ts`
|
||||
|
||||
**Interfaces (Produces):**
|
||||
- `createSecretBox(secret: string): { encrypt(plain: string): Promise<string>; decrypt(token: string): Promise<string> }`. HKDF-SHA-256 (salt `"stcourses/byok"`, info `"aes-gcm-256/v1"`) → AES-GCM-256; token `v1.<b64 iv>.<b64 ciphertext>`.
|
||||
- `migrationsDirs: string[]` (empty: the package adds no tables).
|
||||
|
||||
- [x] Tests: round trip; two encryptions differ (random IV); a box with another secret rejects; a tampered token rejects.
|
||||
- [x] Implement; commit `feat(ai): package scaffold and AES-GCM secret box`.
|
||||
|
||||
### Task 2: Settings repository
|
||||
|
||||
**Files:** `src/settings.ts`, test `test/settings.test.ts` (uses `forEachDb` from `packages/db/test/helpers.ts`)
|
||||
|
||||
**Interfaces:**
|
||||
- `ProviderKind = "anthropic" | "openai"`; `ProviderConfig = { kind, model?, baseUrl?, effort? }`.
|
||||
- `McpServerConfig = { id, name, transport: "stdio" | "http", command?, args?, env?, url?, headers?, courseIds?: string[] }` (no `courseIds` = global).
|
||||
- `aiSettingsRepo(db, box)`:
|
||||
- `getServer(): Promise<{ provider: (ProviderConfig & { hasApiKey }) | null; mcpServers }>`, `saveServer({ provider: (ProviderConfig & { apiKey?: string; clearApiKey?: boolean }) | null, mcpServers })`, `serverApiKey(): Promise<string | null>`.
|
||||
- `getUser(userId): Promise<{ byok: (ProviderConfig & { hasApiKey }) | null; mentorOff: Record<courseId, boolean> }>`, `saveUserKey(userId, cfg & { apiKey })`, `removeUserKey(userId)`, `userApiKey(userId, kind)`, `setMentorOff(userId, courseId, off)`, `isMentorOff(userId, courseId)`.
|
||||
- Storage: `ai_settings` row `('server','')` holds `{ provider, apiKeyCipher, mcpServers }`; row `('user', userId)` holds `{ byok, mentorOff }`. The per-course flag lives in `config_json` because the frozen primary key is `(scope, owner_id)`; the `mentor_off` column mirrors "off for every course" (always 0 for now). Contract request filed. Keys go in `user_api_keys` encrypted. Upserts are select-then-insert-or-update in a transaction.
|
||||
|
||||
- [x] Tests (both DBs): empty server config is `null` provider; saving a key stores ciphertext only (raw row does not contain the key) and `serverApiKey()` returns it; omitting `apiKey` on save keeps the old key; BYOK save/remove; mentor toggle per course is independent.
|
||||
- [x] Implement; commit `feat(ai): settings repository for server config, BYOK keys and mentor toggles`.
|
||||
|
||||
### Task 3: SSE parsing and the Anthropic provider
|
||||
|
||||
**Files:** `src/providers/sse.ts`, `src/providers/anthropic.ts`, test `test/anthropic.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- `readSse(body: ReadableStream<Uint8Array>): AsyncIterable<{ event: string; data: string }>`.
|
||||
- `createAnthropicProvider({ apiKey, model = "claude-opus-5-5", baseUrl = "https://api.anthropic.com", effort?, maxTokens = 64000, fetch? }): AiProvider`.
|
||||
- POST `/v1/messages` with `stream: true`, headers `x-api-key`, `anthropic-version: 2023-06-01`. No `thinking`, `temperature` or forced `tool_choice` (Opus 5.5 rejects them). On the first-party endpoint, `fallbacks: "default"` with beta header `server-side-fallback-2026-07-01`.
|
||||
- Message mapping: `system` messages join into `system`; an assistant `ChatMessage` with `toolCallId` and `name` is a tool call (content = JSON input); `tool` messages become `tool_result` blocks; consecutive same-role blocks merge.
|
||||
- The provider remembers each response's full content blocks (thinking with signature, text, tool_use) by tool_use id and replays them unchanged for the matching assistant tool-call messages.
|
||||
- Streams `{ text }` per `text_delta`, `{ toolCall }` per finished `tool_use` block; HTTP errors and `error` events throw with the API message; `stop_reason: "refusal"` yields a plain sentence.
|
||||
|
||||
- [x] Tests with a fake `fetch` that returns canned SSE: text streams in order; a tool_use with split `input_json_delta` yields one parsed toolCall; request body has model, system, tools as `input_schema`, no `thinking`/`temperature`; a second call after a tool round replays the thinking block with its signature unchanged before `tool_result`; non-2xx throws with the error message.
|
||||
- [x] Implement; commit `feat(ai): Anthropic provider over fetch with streaming tool use`.
|
||||
|
||||
### Task 4: OpenAI-compatible provider
|
||||
|
||||
**Files:** `src/providers/openai.ts`, `src/providers/index.ts`, test `test/openai.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- `createOpenAiProvider({ baseUrl, apiKey?, model, fetch? }): AiProvider`. POST `<baseUrl>/chat/completions`, `stream: true`, tools as `{ type: "function", function: { name, description, parameters } }`. Assistant tool-call messages map to `tool_calls`; `tool` messages to `{ role: "tool", tool_call_id }`. Accumulates `delta.tool_calls` by index and emits on finish or `[DONE]`.
|
||||
- `createProvider(cfg: ProviderConfig, apiKey: string | null, fetchImpl?): AiProvider`.
|
||||
|
||||
- [x] Tests: text deltas; fragmented tool-call arguments; no `Authorization` header without a key (Ollama); request mapping of a tool round.
|
||||
- [x] Implement; commit `feat(ai): OpenAI-compatible provider (covers Ollama)`.
|
||||
|
||||
### Task 5: Retrieval
|
||||
|
||||
**Files:** `src/retrieval/{bm25.ts,course-index.ts}`, test `test/retrieval.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- `Bm25<T>` with `add(text, item)`, `search(query, k): { item, score }[]`.
|
||||
- `Chunk = { path, kind: "lesson" | "transcript" | "markdown" | "code" | "deck" | "note", text, heading?, lessonId?, lessonTitle? }`.
|
||||
- `buildCourseIndex(course: LoadedCourse, dataDir: string): Promise<CourseIndex>`; `CourseIndex.search(query, k, extra?: Chunk[]): Chunk[]`; `isSolutionPath(course, rel): boolean`.
|
||||
- `getCourseIndex(course, dataDir)` caches per `LoadedCourse` object (a repository reload yields new objects) with a 5-minute TTL.
|
||||
- Sources: every `.md` under the course root (depth-limited, skipping `.git`, `node_modules`), manifest code dirs and common code extensions (size cap 200 KB per file, 2 000 files per course), `.md/.txt/.vtt/.srt/.json` text under `<dataDir>/cache/<courseId>/`. Markdown splits at headings; long chunks split at ~1 500 chars. Paths are mapped to lessons through `file`, `transcript` and `hints.file` (anchor heading when it matches).
|
||||
- Never indexed: `browser.solution` files and any path segment `solutions`, `solution`, `reference` under `expansion/`, or `reference/` in Systemic-Design style modules (see `isSolutionPath`).
|
||||
|
||||
- [x] Tests on the `sample` fixture: "sum of its arguments" hits `lessons/04-exercise.md` with lessonId `m2-text` or `m2-ex-add`; `exercises/add/solution.js` never appears; a deck text file in a temp `cache/sample/decks/d1/page-1.txt` is found with kind `deck`; note chunks passed as `extra` rank.
|
||||
- [x] Implement; commit `feat(ai): BM25 course retrieval over lessons, transcripts, code, deck text and notes`.
|
||||
|
||||
### Task 6: MCP client
|
||||
|
||||
**Files:** `src/mcp/client.ts`, test `test/mcp.test.ts`, fixture `test/fixtures/fake-mcp.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- `connectMcp(cfg: McpServerConfig, opts?: { fetch?, timeoutMs? }): Promise<McpClient>`; `McpClient = { tools(): Promise<{ name, description, inputSchema }[]>; call(name, args): Promise<string>; close(): Promise<void> }`.
|
||||
- JSON-RPC 2.0: `initialize` (protocolVersion `2025-06-18`), `notifications/initialized`, `tools/list`, `tools/call`. stdio = newline-delimited JSON over `Bun.spawn`; streamable HTTP = POST with `Accept: application/json, text/event-stream`, JSON or SSE replies, `Mcp-Session-Id` echoed.
|
||||
- `mcpServersFor(courseId, servers)` selects global servers plus ones listing the course.
|
||||
|
||||
- [x] Tests: the fake server over stdio lists `echo` and `tools/call` returns its text; the same over HTTP (fake via `Bun.serve` on port 0, replying with SSE for `tools/call`) with the session id echoed; a server that exits gives a rejected promise, not a hang.
|
||||
- [x] Implement; commit `feat(ai): MCP client over stdio and streamable HTTP`.
|
||||
|
||||
### Task 7: Assistant core
|
||||
|
||||
**Files:** `src/assistant/{prompt.ts,tools.ts,loop.ts}`, test `test/assistant.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- `buildSystemPrompt({ courseTitle, mentorText: string | null, mentorOn, sources: Chunk[], lessonTitle? }): string`. With mentor on and text present: a `## Course mentor rules` section with the file's text. With mentor off: a line saying full answers are allowed. Sources are numbered `[n] path (lesson title)` with their text; the prompt asks for citations by path.
|
||||
- `courseTools({ ctx, course, userId, index })`: `ToolSpec[]` plus `run(name, input)` for `search_course`, `read_lesson` (refuses solution files) and `list_notes`.
|
||||
- `runAssistant({ provider, messages, tools, signal, maxSteps = 6 }): AsyncIterable<AssistantEvent>` where `AssistantEvent = { type: "text", text } | { type: "tool", name } | { type: "error", message } | { type: "done", text }`. Tool calls become assistant messages with `toolCallId`/`name` and `tool` result messages; unknown tools and tool errors are fed back as error text.
|
||||
- Citations: `citationsFor(chunks): { path, lessonId?, lessonTitle?, kind }[]` deduplicated.
|
||||
|
||||
- [x] Tests: mentor on puts the sample `MENTOR.md` text in the system prompt; mentor off omits it and states full answers; a fake provider that calls `search_course` then answers gets the tool result and the loop ends with text; `read_lesson` on the exercise returns markdown; solution paths are refused.
|
||||
- [x] Implement; commit `feat(ai): assistant prompt, course tools and tool loop`.
|
||||
|
||||
### Task 8: Generator and draft sink
|
||||
|
||||
**Files:** `src/generator/{templates.ts,parse.ts,generate.ts,draft-sink.ts}`, test `test/generator.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- `DraftSink = { writeDraftFiles(courseId, authorId, files: { path, content }[], message): Promise<{ location: string; paths: string[] }> }`; `createFsDraftSink(dataDir)` writes to `<dataDir>/drafts/<courseId>/<runId>/` plus `DRAFT.json` (author, message, time). Same signature as the studio's `createDraftWriter(ctx).writeDraftFiles`.
|
||||
- `GenerateRequest = { courseId, kind: "document" | "exercise" | "questions", style: "pure-snake" | "systemic-design", topic, sources: ({ kind: "lesson", lessonId } | { kind: "file", path } | { kind: "text", label, text })[], instructions? }`.
|
||||
- `buildGeneratorPrompt(req, material, course)`: house-style system prompt (module shape: why, objectives, concepts, puzzle with "Think about it first" fold, everyday-programming bridge, exercises, hints as nudge / stronger hint / near-solution folds, check your understanding, in production, further reading; or Observe → Predict (+Discussion fold) → Spec → Build (Hint 1 — nudge / Hint 2 — concept / Hint 3 — pseudo-code) → Name it → Expansion Drop (+what this drop measures) → Further reading). Exercises: `README.md`, `starter.js`, `tests.js` (`export const tests` per the `ExerciseTest` contract), `solution.js`, shared fixture JSON across tracks when the course has tracks. Questions: check-your-understanding list with at least one predict and one "what would break if".
|
||||
- `parseFiles(text): { files, warnings }` reads `=== FILE: <path> ===` … `=== END FILE ===`; rejects absolute and `..` paths.
|
||||
- `checkHouseStyle(req, files): string[]` warnings for missing beats/tiers.
|
||||
- `generateDraft({ provider, req, course, ctx, sink, authorId }): Promise<{ location, paths, warnings }>`; every Markdown file starts with a draft marker comment.
|
||||
|
||||
- [x] Tests: prompt for each style contains its beats; parse handles two files and rejects `../x`; fake provider output written by the fs sink lands under `<dataDir>/drafts/sample/…` and nothing changes in the course folder; missing tiers give warnings.
|
||||
- [x] Implement; commit `feat(ai): house-style generator writing drafts through a DraftSink`.
|
||||
|
||||
### Task 9: Feature routes and jobs
|
||||
|
||||
**Files:** `src/feature.ts`, test `test/feature.test.ts`
|
||||
|
||||
**Interfaces:** `aiFeature(ctx, opts?: { fetch?; draftSink?; providerFactory?(cfg, key): AiProvider }): Feature` with:
|
||||
- `GET /api/ai/status?courseId=` → `{ enabled, source: "byok" | "server" | null, mentor?: { available, on } }`.
|
||||
- `GET|PUT /api/ai/admin/config` (owner), `POST /api/ai/admin/mcp/test` (owner; lists a server's tools).
|
||||
- `GET /api/ai/account`, `PUT /api/ai/account/key`, `DELETE /api/ai/account/key` (signed in).
|
||||
- `PUT /api/ai/courses/:courseId/mentor` `{ off }`.
|
||||
- `POST /api/ai/courses/:courseId/chat` `{ message, conversationId? }` → `application/x-ndjson` stream: `{type:"meta", conversationId, mentorOn, citations}`, `text`, `tool`, `done`, `error`. Stores user and assistant turns in `ai_conversations`/`ai_messages`.
|
||||
- `GET /api/ai/courses/:courseId/conversations`, `GET|DELETE /api/ai/conversations/:id`.
|
||||
- `GET /api/ai/studio/sources?courseId=`, `POST /api/ai/studio/generate` (enqueues job `ai.generate`), `GET /api/ai/studio/jobs/:id`.
|
||||
- Job `ai.generate` runs `generateDraft` with the requesting user's provider.
|
||||
|
||||
- [x] Tests through `createApp` with a fake `AuthService`: status disabled by default; admin config hides the key; non-owner gets 403; BYOK enables the assistant for that user only; chat with a fake provider streams text and `meta.citations` include `lessons/04-exercise.md`; system prompt holds mentor rules, and not after `PUT mentor {off:true}`; anonymous on a restricted course gets 404; generate as a learner gets 403; the job handler writes drafts under `<dataDir>/drafts/sample`.
|
||||
- [x] Implement; commit `feat(ai): HTTP routes, chat streaming and generation job`.
|
||||
|
||||
### Task 10: Web UI
|
||||
|
||||
**Files:** `src/web/{index.ts,api.ts,AssistantPage.tsx,AdminAiPage.tsx,AccountAiPage.tsx,GeneratePage.tsx,ai.css}`, `apps/web/src/features.ts` (one line), test `test/web.test.tsx`
|
||||
|
||||
- `aiWeb: WebFeature` routes `/c/:courseId/assistant`, `/admin/ai` (nav admin "AI"), `/account/ai`, `/studio/generate`.
|
||||
- Assistant: course name, mentor segmented toggle, the plain banner "Mentor mode off: I'll give full answers" when off, conversation rows, sources as rows with paths, composer with a primary Send pill. Disabled state explains how to enable AI.
|
||||
- Admin: provider kind, model, base URL, write-only key, MCP server rows. Account: BYOK form and remove. Generate: course, kind, style, sources checklist, topic, instructions, job progress, list of draft files with location.
|
||||
- `readNdjson(stream)` client helper.
|
||||
|
||||
- [x] Tests: `readNdjson` splits across chunk boundaries; `renderToString` of the banner component shows the exact sentence when off and nothing when on.
|
||||
- [x] Implement, check in headless Chrome via `packages/feature-ai/scripts/dev.ts` on port 3464; commit `feat(ai): assistant panel, AI settings and studio generator UI`.
|
||||
|
||||
### Task 11: Contract requests and report
|
||||
|
||||
- [x] `docs/contract-requests/ai.md`: feature factories in `startServer`; tool-call fields on `ChatMessage`; per-course `ai_settings` key; studio `createDraftWriter` wiring; cache layout for deck text and transcripts; learner lesson URL.
|
||||
- [x] Run `bun test`, `bun run typecheck`, the Postgres run; write `wp-ai-report.md`.
|
||||
@@ -3,5 +3,9 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "AGPL-3.0-only"
|
||||
"license": "AGPL-3.0-only",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./web": "./src/web/index.ts"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// Local dev server for the AI package until Phase 2 wires feature factories into apps/server.
|
||||
// STC_PORT=3464 STC_DATA_DIR=/tmp/stc-ai-data STC_COURSE_DIRS=./fixtures/courses:./overlays bun packages/feature-ai/scripts/dev.ts
|
||||
// STC_AI_DEV_USER=owner|learner|none picks the signed-in user (dev only). STC_AI_DEV_FAKE=1 uses a canned provider.
|
||||
import type { AiProvider, AuthService, ServerContext } from "@stc/contracts";
|
||||
import { createApp, createJobQueue, loadConfig } from "@stc/core";
|
||||
import { createCourseRepository } from "@stc/course-format";
|
||||
import { connect, migrate } from "@stc/db";
|
||||
import indexHtml from "../../../apps/web/index.html";
|
||||
import { aiFeature } from "../src/feature";
|
||||
|
||||
const config = loadConfig();
|
||||
const who = process.env.STC_AI_DEV_USER ?? "owner";
|
||||
const auth: AuthService = {
|
||||
userFromRequest: async () =>
|
||||
who === "none" ? null : { id: `dev-${who}`, email: `${who}@localhost`, name: who, roles: who === "owner" ? ["owner"] : ["learner"], groups: [] },
|
||||
};
|
||||
|
||||
const fake: AiProvider = {
|
||||
id: "fake",
|
||||
async *chat(messages) {
|
||||
const sys = messages[0]?.content ?? "";
|
||||
if (sys.includes("=== FILE:")) {
|
||||
yield { text: "=== FILE: questions/adding-many-numbers.md ===\n# Check your understanding: adding many numbers\n\n1. Predict: what does `sum([])` return?\n2. What would break if `sum` used `a - b`?\n=== END FILE ===\n" };
|
||||
return;
|
||||
}
|
||||
const mentor = sys.includes("## Course mentor rules");
|
||||
const answer = mentor
|
||||
? "Before I explain: what does the failing test give as input, and what did you expect `add` to return?\n\nThe lesson's nudge asks **which operator combines two numbers**. Try it, run the tests, and tell me what happens. [lessons/04-exercise.md · Adding numbers]"
|
||||
: "Here is the whole answer.\n\n```js\nexport const add = (a, b) => a + b\n```\n\nIt returns the sum of its arguments. [lessons/04-exercise.md · Adding numbers]";
|
||||
for (const part of answer.match(/.{1,12}/gs) ?? []) { await Bun.sleep(5); yield { text: part }; }
|
||||
},
|
||||
};
|
||||
|
||||
const db = await connect(config.databaseUrl);
|
||||
await migrate(db);
|
||||
const courses = await createCourseRepository(config.courseDirs);
|
||||
const ctx = { config, db, courses, auth } as ServerContext;
|
||||
const feature = aiFeature(ctx, process.env.STC_AI_DEV_FAKE ? { providerFactory: () => fake } : {});
|
||||
const jobs = createJobQueue(db, feature.jobs ?? {});
|
||||
ctx.jobs = jobs;
|
||||
const app = createApp(ctx, [feature]);
|
||||
jobs.start();
|
||||
const server = Bun.serve({
|
||||
port: config.port,
|
||||
routes: { ...app.routes, "/api/*": (req) => app.fetch(req), "/*": indexHtml },
|
||||
fetch: (req) => app.fetch(req),
|
||||
});
|
||||
console.log(`STCourses (AI dev) on ${server.url} as ${who}`);
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { AiProvider, ChatMessage } from "@stc/contracts";
|
||||
import type { ToolSet } from "./tools";
|
||||
|
||||
export type AssistantEvent =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "tool"; name: string }
|
||||
| { type: "error"; message: string }
|
||||
| { type: "done"; text: string };
|
||||
|
||||
/**
|
||||
* Runs the model with tools until it answers without calling a tool, or until maxSteps model calls.
|
||||
* Mutates `messages` by appending the tool round trips, so callers can inspect them.
|
||||
*/
|
||||
export async function* runAssistant(o: {
|
||||
provider: AiProvider;
|
||||
messages: ChatMessage[];
|
||||
tools: ToolSet;
|
||||
signal?: AbortSignal;
|
||||
maxSteps?: number;
|
||||
}): AsyncIterable<AssistantEvent> {
|
||||
const maxSteps = o.maxSteps ?? 6;
|
||||
let answer = "";
|
||||
for (let step = 0; step < maxSteps; step++) {
|
||||
let text = "";
|
||||
const calls: { id: string; name: string; input: unknown }[] = [];
|
||||
try {
|
||||
for await (const ev of o.provider.chat(o.messages, { tools: o.tools.specs, signal: o.signal })) {
|
||||
if (ev.text) { text += ev.text; yield { type: "text", text: ev.text }; }
|
||||
if (ev.toolCall) calls.push(ev.toolCall);
|
||||
}
|
||||
} catch (e) {
|
||||
yield { type: "error", message: (e as Error).message };
|
||||
return;
|
||||
}
|
||||
answer += text;
|
||||
if (!calls.length) {
|
||||
yield { type: "done", text: answer };
|
||||
return;
|
||||
}
|
||||
if (text) o.messages.push({ role: "assistant", content: text });
|
||||
for (const c of calls) o.messages.push({ role: "assistant", content: JSON.stringify(c.input ?? {}), toolCallId: c.id, name: c.name });
|
||||
for (const c of calls) {
|
||||
yield { type: "tool", name: c.name };
|
||||
let result: string;
|
||||
try { result = await o.tools.run(c.name, c.input); } catch (e) { result = `Tool error: ${(e as Error).message}`; }
|
||||
o.messages.push({ role: "tool", content: result, toolCallId: c.id, name: c.name });
|
||||
}
|
||||
}
|
||||
const note = "\n\n(I stopped after several lookups. Ask again to continue.)";
|
||||
yield { type: "text", text: note };
|
||||
yield { type: "done", text: answer + note };
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { CourseRepository, LoadedCourse } from "@stc/course-format";
|
||||
import type { Chunk } from "../retrieval/course-index";
|
||||
|
||||
export interface Citation { path: string; kind: Chunk["kind"]; lessonId?: string; lessonTitle?: string }
|
||||
|
||||
const MAX_MENTOR = 40_000;
|
||||
|
||||
/** The manifest's `mentor` file (for example Pure Snake's MENTOR.md), or null when the course has none. */
|
||||
export async function readMentorText(repo: CourseRepository, course: LoadedCourse): Promise<string | null> {
|
||||
const rel = course.manifest?.mentor;
|
||||
if (!rel) return null;
|
||||
const abs = repo.resolvePath(course.id, rel);
|
||||
if (!abs) return null;
|
||||
try { return (await Bun.file(abs).text()).slice(0, MAX_MENTOR); } catch { return null; }
|
||||
}
|
||||
|
||||
const label = (c: Chunk) => [c.path, c.lessonTitle && `${c.lessonTitle} (${c.lessonId})`, c.heading].filter(Boolean).join(" · ");
|
||||
|
||||
export function buildSystemPrompt(o: {
|
||||
course: LoadedCourse;
|
||||
mentorText: string | null;
|
||||
mentorOn: boolean;
|
||||
sources: Chunk[];
|
||||
lesson?: { id: string; title: string } | null;
|
||||
extraTools?: string[];
|
||||
}): string {
|
||||
const title = o.course.manifest?.title ?? o.course.id;
|
||||
const parts: string[] = [];
|
||||
parts.push(
|
||||
`You are the course assistant for "${title}" on STCourses, a self-hosted course platform. You help one learner with this course: you answer questions, explain ideas, and quiz them when they ask.`,
|
||||
[
|
||||
"Ground your answers in the course material. Use the retrieved sources below and your tools (search_course, read_lesson, read_file, list_notes, my_progress) to look things up rather than guessing.",
|
||||
"When you rely on a source, cite its file path and lesson in square brackets, for example [lessons/04-exercise.md · Adding numbers]. If the course does not cover something, say so plainly.",
|
||||
"Reference solutions are for authors only and are not available to you. Do not try to reconstruct them.",
|
||||
"Keep answers short and plain. Use Markdown for code.",
|
||||
].join("\n"),
|
||||
);
|
||||
if (o.extraTools?.length) parts.push(`The administrator also connected these external tools: ${o.extraTools.join(", ")}. Use them only when the course material is not enough.`);
|
||||
if (o.lesson) parts.push(`The learner is currently on the lesson "${o.lesson.title}" (id ${o.lesson.id}).`);
|
||||
if (o.mentorText && o.mentorOn) {
|
||||
parts.push(
|
||||
`## Course mentor rules\n\nThis course ships the following rules for an AI tutor. Follow them for the whole conversation; they take priority over being maximally helpful. Where they mention files, tools or a workspace you cannot access, apply their spirit through this chat.\n\n<mentor_rules>\n${o.mentorText.trim()}\n</mentor_rules>`,
|
||||
);
|
||||
} else if (o.mentorText && !o.mentorOn) {
|
||||
parts.push("## Mentor mode is off\n\nThe learner switched off this course's mentor rules for themselves. Give full, direct answers, including complete worked solutions when they ask for them.");
|
||||
}
|
||||
if (o.sources.length) {
|
||||
parts.push(
|
||||
`## Retrieved sources\n\nThese excerpts matched the learner's question. They may be incomplete; use the tools to read more.\n\n${o.sources
|
||||
.map((c, i) => `[${i + 1}] ${label(c)}\n${c.text}`)
|
||||
.join("\n\n---\n\n")}`,
|
||||
);
|
||||
}
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
/** Course files and lessons behind an answer, deduplicated. Notes are the learner's own and are not cited as files. */
|
||||
export function citationsFor(chunks: Chunk[]): Citation[] {
|
||||
const seen = new Set<string>();
|
||||
const out: Citation[] = [];
|
||||
for (const c of chunks) {
|
||||
if (c.kind === "note") continue;
|
||||
const key = `${c.path}\u0000${c.lessonId ?? ""}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const cit: Citation = { path: c.path, kind: c.kind };
|
||||
if (c.lessonId) cit.lessonId = c.lessonId;
|
||||
if (c.lessonTitle) cit.lessonTitle = c.lessonTitle;
|
||||
out.push(cit);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { stat } from "node:fs/promises";
|
||||
import type { ToolSpec } from "@stc/contracts";
|
||||
import type { CourseRepository, LoadedCourse } from "@stc/course-format";
|
||||
import type { McpServerConfig } from "../settings";
|
||||
import { connectMcp, mcpToolName, type McpClient } from "../mcp/client";
|
||||
import { canonicalCoursePath, isGuardedFile, noteChunks, type Chunk, type CourseIndex } from "../retrieval/course-index";
|
||||
|
||||
export interface ToolSet {
|
||||
specs: ToolSpec[];
|
||||
/** Runs a tool and returns text for the model. Never throws for expected failures. */
|
||||
run(name: string, input: unknown): Promise<string>;
|
||||
/** Course chunks the tools surfaced (for citations). */
|
||||
sources: Chunk[];
|
||||
close?(): Promise<void>;
|
||||
}
|
||||
|
||||
type ProgressLike = { state: "not-started" | "in-progress" | "done"; positionSeconds: number | null };
|
||||
type NoteLike = { id: string; lessonId: string; body: string; anchorKind: string; anchorValue: string | null };
|
||||
const MAX_READ = 60_000;
|
||||
const str = (v: unknown) => (typeof v === "string" ? v : "");
|
||||
const obj = (v: unknown): Record<string, unknown> => (v && typeof v === "object" ? (v as Record<string, unknown>) : {});
|
||||
|
||||
export function courseTools(o: {
|
||||
repo: CourseRepository;
|
||||
course: LoadedCourse;
|
||||
index: CourseIndex;
|
||||
listNotes: (lessonId?: string) => Promise<NoteLike[]>;
|
||||
/** The learner's progress in this course, keyed by lesson id (shape of `progressRepo(db).get`). */
|
||||
getProgress?: () => Promise<Record<string, ProgressLike>>;
|
||||
}): ToolSet {
|
||||
const sources: Chunk[] = [];
|
||||
const { course } = o;
|
||||
const lessonChunk = (lessonId: string): Chunk | null => {
|
||||
const e = course.lessonIndex.get(lessonId);
|
||||
if (!e) return null;
|
||||
const path = e.lesson.type === "video" ? (e.lesson.transcript ?? e.lesson.file ?? "") : (e.lesson.file ?? "");
|
||||
return { path, kind: e.lesson.type === "video" ? "transcript" : "lesson", text: "", lessonId, lessonTitle: e.lesson.title };
|
||||
};
|
||||
const specs: ToolSpec[] = [
|
||||
{
|
||||
name: "search_course",
|
||||
description: "Search this course's lessons, transcripts, slide text and code files. Returns the best matching excerpts with their file paths and lessons.",
|
||||
inputSchema: { type: "object", properties: { query: { type: "string", description: "Words to search for" } }, required: ["query"], additionalProperties: false },
|
||||
},
|
||||
{
|
||||
name: "read_lesson",
|
||||
description: "Read the full Markdown of one lesson (or a video lesson's transcript) by lesson id.",
|
||||
inputSchema: { type: "object", properties: { lessonId: { type: "string" } }, required: ["lessonId"], additionalProperties: false },
|
||||
},
|
||||
{
|
||||
name: "read_file",
|
||||
description: "Read a text or code file from the course by its course-relative path, for example a companion code file.",
|
||||
inputSchema: { type: "object", properties: { path: { type: "string" } }, required: ["path"], additionalProperties: false },
|
||||
},
|
||||
{
|
||||
name: "list_notes",
|
||||
description: "List the learner's own notes in this course, optionally for one lesson.",
|
||||
inputSchema: { type: "object", properties: { lessonId: { type: "string" } }, additionalProperties: false },
|
||||
},
|
||||
...(o.getProgress
|
||||
? [{
|
||||
name: "my_progress",
|
||||
description: "The learner's progress in this course: which lessons are done, in progress (with the video position) or not started.",
|
||||
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||
}]
|
||||
: []),
|
||||
];
|
||||
const lessonList = () =>
|
||||
(course.manifest?.modules ?? []).map((m) => `${m.title}: ${m.lessons.map((l) => `${l.id} (${l.title})`).join(", ")}`).join("\n");
|
||||
|
||||
return {
|
||||
specs,
|
||||
sources,
|
||||
async run(name, input) {
|
||||
const a = obj(input);
|
||||
switch (name) {
|
||||
case "search_course": {
|
||||
const q = str(a.query);
|
||||
if (!q.trim()) return "Give a non-empty query.";
|
||||
const hits = o.index.search(q, 6);
|
||||
sources.push(...hits);
|
||||
if (!hits.length) return "No matches in this course.";
|
||||
return hits.map((c, i) => `[${i + 1}] ${[c.path, c.lessonTitle && `${c.lessonTitle} (${c.lessonId})`, c.heading].filter(Boolean).join(" · ")}\n${c.text}`).join("\n\n---\n\n");
|
||||
}
|
||||
case "read_lesson": {
|
||||
const id = str(a.lessonId);
|
||||
const ref = lessonChunk(id);
|
||||
if (!ref) return `No lesson with id "${id}". Lessons:\n${lessonList()}`;
|
||||
const canon = ref.path ? canonicalCoursePath(o.repo, course, ref.path) : null;
|
||||
if (canon && isGuardedFile(o.repo, course, canon)) return "That file is for authors only.";
|
||||
const md = await o.repo.readLessonMarkdown(course.id, id);
|
||||
if (md === null) return `Lesson "${id}" has no readable text (it may be a video without a transcript).`;
|
||||
sources.push(ref);
|
||||
return md.slice(0, MAX_READ);
|
||||
}
|
||||
case "read_file": {
|
||||
// Canonicalise first (decode, normalise, follow symlinks), then guard the real path.
|
||||
const canon = canonicalCoursePath(o.repo, course, str(a.path));
|
||||
if (!canon) return `No such file in this course: ${str(a.path)}`;
|
||||
if (isGuardedFile(o.repo, course, canon)) return "That file is for authors only and is not available.";
|
||||
const info = await stat(canon.abs).catch(() => null);
|
||||
if (!info?.isFile()) return `No such file in this course: ${canon.rel}`;
|
||||
if (info.size > 2_000_000) return "That file is too large to read here.";
|
||||
const text = await Bun.file(canon.abs).text();
|
||||
if (text.includes("\u0000")) return "That is a binary file.";
|
||||
sources.push({ path: canon.rel, kind: /\.(md|markdown)$/i.test(canon.rel) ? "markdown" : "code", text: "" });
|
||||
return text.slice(0, MAX_READ);
|
||||
}
|
||||
case "list_notes": {
|
||||
const lessonId = str(a.lessonId) || undefined;
|
||||
const notes = await o.listNotes(lessonId);
|
||||
if (!notes.length) return "The learner has no notes here yet.";
|
||||
return noteChunks(course, notes).map((c) => `- ${[c.lessonTitle ?? c.lessonId, c.heading].filter(Boolean).join(", ")}: ${c.text}`).join("\n");
|
||||
}
|
||||
case "my_progress": {
|
||||
if (!o.getProgress) return `Unknown tool "${name}".`;
|
||||
const p = await o.getProgress();
|
||||
const lessons = (course.manifest?.modules ?? []).flatMap((m) => m.lessons);
|
||||
const done = lessons.filter((l) => p[l.id]?.state === "done").length;
|
||||
const clock = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
|
||||
const line = (l: { id: string; title: string }) => {
|
||||
const e = p[l.id];
|
||||
const state = e?.state === "done" ? "done" : e?.state === "in-progress" ? `in progress${e.positionSeconds ? `, at ${clock(e.positionSeconds)}` : ""}` : "not started";
|
||||
return `- ${l.title} (${l.id}): ${state}`;
|
||||
};
|
||||
return [`${done} of ${lessons.length} lessons done.`, ...(course.manifest?.modules ?? []).flatMap((m) => [`${m.title}:`, ...m.lessons.map(line)])].join("\n");
|
||||
}
|
||||
default:
|
||||
return `Unknown tool "${name}".`;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Tools from admin-configured MCP servers. Servers that fail to connect are skipped and reported. */
|
||||
export async function mcpTools(servers: McpServerConfig[], opts: { fetch?: typeof fetch; timeoutMs?: number } = {}): Promise<ToolSet & { warnings: string[]; names: string[] }> {
|
||||
const clients: McpClient[] = [];
|
||||
const routes = new Map<string, { client: McpClient; tool: string }>();
|
||||
const specs: ToolSpec[] = [];
|
||||
const warnings: string[] = [];
|
||||
await Promise.all(
|
||||
servers.map(async (s) => {
|
||||
try {
|
||||
const client = await connectMcp(s, opts);
|
||||
clients.push(client);
|
||||
for (const t of await client.tools()) {
|
||||
const name = mcpToolName(s.id, t.name);
|
||||
routes.set(name, { client, tool: t.name });
|
||||
specs.push({ name, description: `[${s.name}] ${t.description}`.slice(0, 1024), inputSchema: t.inputSchema });
|
||||
}
|
||||
} catch (e) {
|
||||
warnings.push(`MCP server "${s.name}" is unavailable: ${(e as Error).message}`);
|
||||
}
|
||||
}),
|
||||
);
|
||||
return {
|
||||
specs,
|
||||
sources: [],
|
||||
warnings,
|
||||
names: specs.map((s) => s.name),
|
||||
async run(name, input) {
|
||||
const r = routes.get(name);
|
||||
if (!r) return `Unknown tool "${name}".`;
|
||||
try { return (await r.client.call(r.tool, input)).slice(0, MAX_READ); } catch (e) { return `Tool error: ${(e as Error).message}`; }
|
||||
},
|
||||
async close() { await Promise.all(clients.map((c) => c.close().catch(() => {}))); },
|
||||
};
|
||||
}
|
||||
|
||||
export function combineTools(sets: ToolSet[]): ToolSet {
|
||||
const owner = new Map<string, ToolSet>();
|
||||
for (const s of sets) for (const spec of s.specs) if (!owner.has(spec.name)) owner.set(spec.name, s);
|
||||
return {
|
||||
specs: [...owner.keys()].map((n) => owner.get(n)!.specs.find((s) => s.name === n)!),
|
||||
get sources() { return sets.flatMap((s) => s.sources); },
|
||||
async run(name, input) {
|
||||
const s = owner.get(name);
|
||||
return s ? s.run(name, input) : `Unknown tool "${name}".`;
|
||||
},
|
||||
async close() { await Promise.all(sets.map((s) => s.close?.())); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Secret box for API keys at rest: AES-GCM-256 with a key derived by HKDF-SHA-256 from STC_SECRET.
|
||||
* Token format: `v1.<base64 iv>.<base64 ciphertext+tag>`.
|
||||
*/
|
||||
export interface SecretBox {
|
||||
encrypt(plain: string): Promise<string>;
|
||||
decrypt(token: string): Promise<string>;
|
||||
}
|
||||
|
||||
const enc = new TextEncoder();
|
||||
const dec = new TextDecoder();
|
||||
const b64 = (u: Uint8Array) => Buffer.from(u).toString("base64");
|
||||
const unb64 = (s: string) => new Uint8Array(Buffer.from(s, "base64"));
|
||||
|
||||
export function createSecretBox(secret: string): SecretBox {
|
||||
let keyP: Promise<CryptoKey> | null = null;
|
||||
const key = () =>
|
||||
(keyP ??= (async () => {
|
||||
const ikm = await crypto.subtle.importKey("raw", enc.encode(secret), "HKDF", false, ["deriveKey"]);
|
||||
return crypto.subtle.deriveKey(
|
||||
{ name: "HKDF", hash: "SHA-256", salt: enc.encode("stcourses/byok"), info: enc.encode("aes-gcm-256/v1") },
|
||||
ikm,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt", "decrypt"],
|
||||
);
|
||||
})());
|
||||
return {
|
||||
async encrypt(plain) {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, await key(), enc.encode(plain)));
|
||||
return `v1.${b64(iv)}.${b64(ct)}`;
|
||||
},
|
||||
async decrypt(token) {
|
||||
const [v, iv, ct] = token.split(".");
|
||||
if (v !== "v1" || !iv || !ct) throw new Error("Unrecognised secret format");
|
||||
const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv: unb64(iv) }, await key(), unb64(ct));
|
||||
return dec.decode(plain);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { AiProvider } from "@stc/contracts";
|
||||
import type { ProviderKind } from "./settings";
|
||||
|
||||
/**
|
||||
* Where a user's own key (BYOK) may be sent. Learners must not be able to point the server at arbitrary hosts
|
||||
* (SSRF), so BYOK uses the official endpoint for its kind, or an origin the admin allowed explicitly through
|
||||
* STC_AI_ALLOWED_BASE_URLS (comma-separated origins) or the server AI config's `allowedBaseUrls`.
|
||||
* The admin's own server-scope provider may use any base URL (Ollama and the like).
|
||||
*/
|
||||
export const PRESET_BASE_URLS: Record<ProviderKind, string> = {
|
||||
anthropic: "https://api.anthropic.com",
|
||||
openai: "https://api.openai.com/v1",
|
||||
};
|
||||
|
||||
const trimSlash = (s: string) => s.replace(/\/+$/, "");
|
||||
|
||||
/** The origin of an http(s) URL, or null. "Exact origins" means scheme, host and port must all match. */
|
||||
export function originOf(u: string): string | null {
|
||||
try {
|
||||
const url = new URL(u.trim());
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
||||
if (url.username || url.password) return null;
|
||||
return url.origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses an allowlist (array or comma-separated string) into origins; entries that are not plain origins are dropped. */
|
||||
export function parseAllowedOrigins(v: unknown): string[] {
|
||||
const items = Array.isArray(v) ? v.map(String) : typeof v === "string" ? v.split(",") : [];
|
||||
const out = new Set<string>();
|
||||
for (const raw of items) {
|
||||
const s = trimSlash(raw.trim());
|
||||
if (!s) continue;
|
||||
const o = originOf(s);
|
||||
if (o && o === s) out.add(o);
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
|
||||
/**
|
||||
* The base URL a BYOK config may use, or null when it is not allowed.
|
||||
* - empty: the official endpoint for the kind;
|
||||
* - the official endpoint for the kind (with or without a trailing slash);
|
||||
* - any URL whose origin is on the allowlist.
|
||||
*/
|
||||
export function byokBaseUrl(kind: ProviderKind, baseUrl: string | undefined, allowed: string[]): string | null {
|
||||
const preset = PRESET_BASE_URLS[kind];
|
||||
if (!baseUrl || !baseUrl.trim()) return preset;
|
||||
const clean = trimSlash(baseUrl.trim());
|
||||
if (clean === preset) return preset;
|
||||
const origin = originOf(clean);
|
||||
if (!origin) return null;
|
||||
const url = new URL(clean);
|
||||
if (url.search || url.hash) return null;
|
||||
return allowed.includes(origin) ? clean : null;
|
||||
}
|
||||
|
||||
/** fetch for user-scope calls: never follows redirects (an allowed host must not bounce the request elsewhere). */
|
||||
export function noRedirectFetch(base: typeof fetch): typeof fetch {
|
||||
const f = ((input: RequestInfo | URL, init?: RequestInit) => base(input, { ...init, redirect: "manual" })) as typeof fetch;
|
||||
return f;
|
||||
}
|
||||
|
||||
export const USER_PROVIDER_ERROR = "The AI provider returned an error. Check your key, model and endpoint under Account, AI.";
|
||||
|
||||
/** The message shown for a failed user-scope provider call: the HTTP status, if any, and a fixed sentence. */
|
||||
export function publicProviderError(message: string): string {
|
||||
const status = /(?:^|\s)([1-5]\d{2}):/.exec(message)?.[1];
|
||||
return status ? `${status}: ${USER_PROVIDER_ERROR}` : USER_PROVIDER_ERROR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a provider created with a user's key: any error is logged server-side with its full text and rethrown
|
||||
* as the status plus a fixed message, so upstream response bodies never reach the browser.
|
||||
*/
|
||||
export function sanitizeProviderErrors(p: AiProvider, log: (msg: string) => void): AiProvider {
|
||||
return {
|
||||
id: p.id,
|
||||
async *chat(messages, opts) {
|
||||
try {
|
||||
yield* p.chat(messages, opts);
|
||||
} catch (e) {
|
||||
const raw = (e as Error)?.message ?? String(e);
|
||||
if ((e as Error)?.name === "AbortError") throw e;
|
||||
log(raw);
|
||||
throw new Error(publicProviderError(raw));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { extname, join, relative, sep } from "node:path";
|
||||
import type { AiProvider, AuthUser, ChatMessage, Feature, JobContext, RequestContext, ServerContext } from "@stc/contracts";
|
||||
import { canView } from "@stc/core";
|
||||
import type { LoadedCourse } from "@stc/course-format";
|
||||
import { aclRepo, notesRepo, progressRepo } from "@stc/db";
|
||||
import { buildSystemPrompt, citationsFor, readMentorText } from "./assistant/prompt";
|
||||
import { combineTools, courseTools, mcpTools, type ToolSet } from "./assistant/tools";
|
||||
import { runAssistant } from "./assistant/loop";
|
||||
import { createSecretBox } from "./crypto";
|
||||
import { createFsDraftSink, type DraftSink } from "./generator/draft-sink";
|
||||
import { generateDraft, type GenerateRequest, type GenerateSource } from "./generator/generate";
|
||||
import { connectMcp, mcpServersFor } from "./mcp/client";
|
||||
import { createProvider } from "./providers";
|
||||
import { PRESET_BASE_URLS, byokBaseUrl, noRedirectFetch, parseAllowedOrigins, publicProviderError, sanitizeProviderErrors } from "./endpoints";
|
||||
import { getCourseIndex, noteChunks } from "./retrieval/course-index";
|
||||
import { aiSettingsRepo, type McpServerConfig, type ProviderConfig } from "./settings";
|
||||
|
||||
export interface AiFeatureOptions {
|
||||
/** Used by providers and HTTP MCP servers. Tests pass a fake; production uses global fetch. */
|
||||
fetch?: typeof fetch;
|
||||
/** Where generated drafts go. Phase 2 passes the studio's createDraftWriter(ctx); default writes under <dataDir>/drafts. */
|
||||
draftSink?: DraftSink;
|
||||
/** Overrides provider construction (tests use a fake AiProvider). */
|
||||
providerFactory?: (cfg: ProviderConfig, apiKey: string | null) => AiProvider;
|
||||
mcpTimeoutMs?: number;
|
||||
/** Environment to read STC_AI_ALLOWED_BASE_URLS from. Default: process.env. */
|
||||
env?: Record<string, string | undefined>;
|
||||
/** Server-side log for provider errors that are not shown to users. Default: console.error. */
|
||||
log?: (message: string) => void;
|
||||
}
|
||||
|
||||
const json = (body: unknown, status = 200) => Response.json(body, { status });
|
||||
const err = (status: number, message: string) => json({ error: message }, status);
|
||||
const KINDS = new Set(["anthropic", "openai"]);
|
||||
const EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]);
|
||||
const HISTORY_TURNS = 20;
|
||||
const MAX_MESSAGE = 8_000;
|
||||
|
||||
async function body(req: Request): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
const b = await req.json();
|
||||
return b && typeof b === "object" && !Array.isArray(b) ? (b as Record<string, unknown>) : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
const str = (v: unknown): string | undefined => (typeof v === "string" && v.trim() ? v.trim() : undefined);
|
||||
|
||||
function parseProvider(v: unknown, scope: "server" | "user"): (ProviderConfig & { apiKey?: string; clearApiKey?: boolean }) | null | "invalid" {
|
||||
if (v === null || v === undefined) return null;
|
||||
if (typeof v !== "object") return "invalid";
|
||||
const p = v as Record<string, unknown>;
|
||||
if (!KINDS.has(p.kind as string)) return "invalid";
|
||||
if (p.effort !== undefined && p.effort !== "" && !EFFORTS.has(p.effort as string)) return "invalid";
|
||||
const out: ProviderConfig & { apiKey?: string; clearApiKey?: boolean } = { kind: p.kind as ProviderConfig["kind"] };
|
||||
if (str(p.model)) out.model = str(p.model);
|
||||
if (str(p.baseUrl)) {
|
||||
try { new URL(str(p.baseUrl)!); } catch { return "invalid"; }
|
||||
out.baseUrl = str(p.baseUrl);
|
||||
}
|
||||
if (str(p.effort)) out.effort = str(p.effort) as ProviderConfig["effort"];
|
||||
if (str(p.apiKey)) out.apiKey = str(p.apiKey);
|
||||
if (p.clearApiKey === true) out.clearApiKey = true;
|
||||
// Server scope: any endpoint, but OpenAI-compatible needs one. User scope: an empty base URL means the official endpoint.
|
||||
if (out.kind === "openai" && (!out.model || (scope === "server" && !out.baseUrl))) return "invalid";
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseMcp(v: unknown): McpServerConfig[] | "invalid" {
|
||||
if (v === undefined || v === null) return [];
|
||||
if (!Array.isArray(v)) return "invalid";
|
||||
const out: McpServerConfig[] = [];
|
||||
const ids = new Set<string>();
|
||||
for (const raw of v) {
|
||||
if (!raw || typeof raw !== "object") return "invalid";
|
||||
const s = raw as Record<string, unknown>;
|
||||
const id = str(s.id);
|
||||
if (!id || !/^[a-zA-Z0-9_-]{1,40}$/.test(id) || ids.has(id)) return "invalid";
|
||||
ids.add(id);
|
||||
const transport = s.transport === "stdio" || s.transport === "http" ? s.transport : null;
|
||||
if (!transport) return "invalid";
|
||||
const cfg: McpServerConfig = { id, name: str(s.name) ?? id, transport };
|
||||
if (transport === "stdio") {
|
||||
if (!str(s.command)) return "invalid";
|
||||
cfg.command = str(s.command);
|
||||
if (Array.isArray(s.args)) cfg.args = s.args.map(String);
|
||||
if (s.env && typeof s.env === "object") cfg.env = Object.fromEntries(Object.entries(s.env).map(([k, val]) => [k, String(val)]));
|
||||
} else {
|
||||
const url = str(s.url);
|
||||
if (!url) return "invalid";
|
||||
try { new URL(url); } catch { return "invalid"; }
|
||||
cfg.url = url;
|
||||
if (s.headers && typeof s.headers === "object") cfg.headers = Object.fromEntries(Object.entries(s.headers).map(([k, val]) => [k, String(val)]));
|
||||
}
|
||||
if (Array.isArray(s.courseIds)) cfg.courseIds = s.courseIds.map(String).filter(Boolean);
|
||||
out.push(cfg);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseGenerate(b: Record<string, unknown>): GenerateRequest | null {
|
||||
const courseId = str(b.courseId);
|
||||
const topic = str(b.topic);
|
||||
if (!courseId || !topic) return null;
|
||||
if (!["document", "exercise", "questions"].includes(b.kind as string)) return null;
|
||||
if (!["pure-snake", "systemic-design"].includes(b.style as string)) return null;
|
||||
if (!Array.isArray(b.sources)) return null;
|
||||
const sources: GenerateSource[] = [];
|
||||
for (const s of b.sources) {
|
||||
if (!s || typeof s !== "object") return null;
|
||||
const x = s as Record<string, unknown>;
|
||||
if (x.kind === "lesson" && str(x.lessonId)) sources.push({ kind: "lesson", lessonId: str(x.lessonId)! });
|
||||
else if (x.kind === "file" && str(x.path)) sources.push({ kind: "file", path: str(x.path)! });
|
||||
else if (x.kind === "cache" && str(x.path)) sources.push({ kind: "cache", path: str(x.path)! });
|
||||
else if (x.kind === "text" && typeof x.text === "string" && x.text.trim()) sources.push({ kind: "text", label: str(x.label) ?? "Pasted text", text: x.text });
|
||||
else return null;
|
||||
}
|
||||
return { courseId, topic: topic.slice(0, 200), kind: b.kind as GenerateRequest["kind"], style: b.style as GenerateRequest["style"], sources, instructions: typeof b.instructions === "string" ? b.instructions.slice(0, 8_000) : undefined };
|
||||
}
|
||||
|
||||
const LISTABLE = new Set([".md", ".markdown", ".txt", ".js", ".mjs", ".ts", ".tsx", ".jsx", ".py", ".rs", ".scala", ".fs", ".go", ".java", ".sh", ".json", ".yaml", ".yml", ".sql", ".html", ".css", ".toml", ".vtt", ".srt"]);
|
||||
async function listFiles(root: string, limit = 800, keep: (rel: string) => boolean = () => true): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
const walk = async (dir: string, depth: number) => {
|
||||
if (depth > 8 || out.length >= limit) return;
|
||||
let entries;
|
||||
try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (const e of entries) {
|
||||
if (out.length >= limit) return;
|
||||
if (e.name.startsWith(".") || e.name === "node_modules" || e.name === "target" || e.name === "dist") continue;
|
||||
const abs = join(dir, e.name);
|
||||
if (e.isDirectory()) await walk(abs, depth + 1);
|
||||
else if (e.isFile() && LISTABLE.has(extname(e.name).toLowerCase())) {
|
||||
const rel = relative(root, abs).split(sep).join("/");
|
||||
if (keep(rel)) out.push(rel);
|
||||
}
|
||||
}
|
||||
};
|
||||
await walk(root, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function aiFeature(ctx: ServerContext, opts: AiFeatureOptions = {}): Feature {
|
||||
const settings = aiSettingsRepo(ctx.db, createSecretBox(ctx.config.secret));
|
||||
const acl = aclRepo(ctx.db);
|
||||
const notes = notesRepo(ctx.db);
|
||||
const progress = progressRepo(ctx.db);
|
||||
const sink = opts.draftSink ?? createFsDraftSink(ctx.config.dataDir);
|
||||
const log = opts.log ?? ((m: string) => console.error(`[ai] ${m}`));
|
||||
const env = opts.env ?? process.env;
|
||||
const allowedOrigins = async () => [...new Set([...parseAllowedOrigins(env.STC_AI_ALLOWED_BASE_URLS ?? ""), ...(await settings.getServer()).allowedBaseUrls])];
|
||||
/**
|
||||
* Providers for a user's own key never follow redirects, and their errors reach the user only as the HTTP
|
||||
* status plus a fixed sentence (the full text is logged here). Server-scope providers are the admin's own.
|
||||
*/
|
||||
const makeProvider = (r: { source: "byok" | "server"; cfg: ProviderConfig; key: string | null }, userId: string): AiProvider => {
|
||||
if (r.source === "server") return opts.providerFactory ? opts.providerFactory(r.cfg, r.key) : createProvider(r.cfg, r.key, opts.fetch);
|
||||
const p = opts.providerFactory ? opts.providerFactory(r.cfg, r.key) : createProvider(r.cfg, r.key, noRedirectFetch(opts.fetch ?? fetch));
|
||||
return sanitizeProviderErrors(p, (m) => log(`provider error for user ${userId} (own key): ${m}`));
|
||||
};
|
||||
|
||||
/** BYOK first ("used only for their own requests"), then the server provider. */
|
||||
const resolveProvider = async (userId: string | null): Promise<{ source: "byok" | "server"; cfg: ProviderConfig; key: string | null } | null> => {
|
||||
if (userId) {
|
||||
const u = await settings.getUser(userId);
|
||||
if (u.byok) {
|
||||
// Re-checked on every use: an origin the admin removed from the allowlist stops working at once.
|
||||
const base = byokBaseUrl(u.byok.kind, u.byok.baseUrl, await allowedOrigins());
|
||||
const key = base ? await settings.userApiKey(userId).catch(() => null) : null;
|
||||
if (key && base) return { source: "byok", cfg: { ...u.byok, baseUrl: base }, key };
|
||||
}
|
||||
}
|
||||
const s = await settings.getServer();
|
||||
if (!s.provider) return null;
|
||||
if (s.provider.kind === "anthropic" && !s.provider.hasApiKey) return null;
|
||||
const key = s.provider.hasApiKey ? await settings.serverApiKey().catch(() => null) : null;
|
||||
if (s.provider.kind === "anthropic" && !key) return null;
|
||||
return { source: "server", cfg: s.provider, key };
|
||||
};
|
||||
|
||||
const viewable = async (user: AuthUser | null, courseId: string): Promise<LoadedCourse | null> => {
|
||||
const c = ctx.courses.get(courseId);
|
||||
if (!c || c.status !== "ok") return null;
|
||||
const ok = canView(user, c, await acl.listForCourse(c.id), user ? await acl.isAuthor(c.id, user.id) : false);
|
||||
return ok ? c : null;
|
||||
};
|
||||
/**
|
||||
* Generation and the studio file listing: the user must hold an authoring right (owner, the `author` role, or a
|
||||
* `course_authors` row) AND see the course under core's `canView`, the same visibility rule as everywhere else.
|
||||
* A global `author` therefore cannot reach a restricted course without an ACL grant or a course_authors row
|
||||
* (see contract request 8).
|
||||
*/
|
||||
const canAuthor = async (user: AuthUser, course: LoadedCourse) => {
|
||||
const isAuthorRow = await acl.isAuthor(course.id, user.id);
|
||||
const mayAuthor = user.roles.includes("owner") || user.roles.includes("author") || isAuthorRow;
|
||||
return mayAuthor && canView(user, course, await acl.listForCourse(course.id), isAuthorRow);
|
||||
};
|
||||
|
||||
const conversationOf = async (id: string, userId: string) => {
|
||||
const rows = await ctx.db.sql`SELECT id, user_id, course_id, title, created_at FROM ai_conversations WHERE id = ${id} AND user_id = ${userId}`;
|
||||
return rows[0] as { id: string; user_id: string; course_id: string; title: string | null; created_at: string } | undefined;
|
||||
};
|
||||
// Roles: "user", "assistant", and "sources" (JSON citations for the assistant message before it).
|
||||
const addMessage = async (conversationId: string, role: "user" | "assistant" | "sources", content: string) => {
|
||||
await ctx.db.sql`INSERT INTO ai_messages (id, conversation_id, role, content, created_at) VALUES (${Bun.randomUUIDv7()}, ${conversationId}, ${role}, ${content}, ${new Date().toISOString()})`;
|
||||
};
|
||||
const messagesOf = async (conversationId: string) =>
|
||||
(await ctx.db.sql`SELECT id, role, content, created_at FROM ai_messages WHERE conversation_id = ${conversationId}`)
|
||||
.map((r: { id: string; role: string; content: string; created_at: string }) => ({ id: r.id, role: r.role, content: r.content, createdAt: r.created_at }))
|
||||
.sort((a: { id: string }, b: { id: string }) => (a.id < b.id ? -1 : 1)) as { id: string; role: "user" | "assistant" | "sources"; content: string; createdAt: string }[];
|
||||
/** Chat turns with each assistant message's stored citations attached. */
|
||||
const turnsOf = async (conversationId: string) => {
|
||||
const out: { id: string; role: "user" | "assistant"; content: string; createdAt: string; citations?: unknown[] }[] = [];
|
||||
for (const m of await messagesOf(conversationId)) {
|
||||
if (m.role === "sources") {
|
||||
const last = out[out.length - 1];
|
||||
if (last?.role === "assistant") { try { last.citations = JSON.parse(m.content); } catch {} }
|
||||
} else out.push({ ...m, role: m.role });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const signedIn = (rc: RequestContext) => (rc.user ? null : err(401, "Sign in to use the assistant."));
|
||||
const ownerOnly = (rc: RequestContext) => (!rc.user ? err(401, "Sign in first.") : rc.user.roles.includes("owner") ? null : err(403, "Only the owner can change AI settings."));
|
||||
|
||||
const chat = async (req: Request, rc: RequestContext): Promise<Response> => {
|
||||
const deny = signedIn(rc);
|
||||
if (deny) return deny;
|
||||
const user = rc.user!;
|
||||
const course = await viewable(user, rc.params.courseId ?? "");
|
||||
if (!course) return err(404, "Course not found.");
|
||||
const b = await body(req);
|
||||
const message = typeof b?.message === "string" ? b.message.trim() : "";
|
||||
if (!message) return err(400, "Write a message.");
|
||||
if (message.length > MAX_MESSAGE) return err(400, "That message is too long.");
|
||||
const resolved = await resolveProvider(user.id);
|
||||
if (!resolved) return err(409, "AI is not set up. An admin can configure a server provider, or you can add your own key under Account, AI.");
|
||||
let provider: AiProvider;
|
||||
try { provider = makeProvider(resolved, user.id); } catch (e) { return err(409, (e as Error).message); }
|
||||
|
||||
let conversationId = str(b?.conversationId);
|
||||
if (conversationId) {
|
||||
const conv = await conversationOf(conversationId, user.id);
|
||||
if (!conv || conv.course_id !== course.id) return err(404, "Conversation not found.");
|
||||
} else {
|
||||
conversationId = Bun.randomUUIDv7();
|
||||
await ctx.db.sql`INSERT INTO ai_conversations (id, user_id, course_id, title, created_at) VALUES (${conversationId}, ${user.id}, ${course.id}, ${message.slice(0, 80)}, ${new Date().toISOString()})`;
|
||||
}
|
||||
const history = (await turnsOf(conversationId)).slice(-HISTORY_TURNS);
|
||||
await addMessage(conversationId, "user", message);
|
||||
|
||||
const lessonId = str(b?.lessonId);
|
||||
const lesson = lessonId ? course.lessonIndex.get(lessonId)?.lesson : undefined;
|
||||
const index = await getCourseIndex(course, ctx.config.dataDir);
|
||||
const userNotes = await notes.list(user.id, course.id);
|
||||
const sources = index.search(lesson ? `${message} ${lesson.title}` : message, 6, noteChunks(course, userNotes));
|
||||
const mentorText = await readMentorText(ctx.courses, course);
|
||||
const mentorOn = !(await settings.isMentorOff(user.id, course.id));
|
||||
const convId = conversationId;
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
const enc = new TextEncoder();
|
||||
const send = (ev: unknown) => { try { controller.enqueue(enc.encode(JSON.stringify(ev) + "\n")); } catch {} };
|
||||
send({ type: "meta", conversationId: convId, mentorOn, mentorAvailable: !!mentorText, source: resolved.source, citations: citationsFor(sources) });
|
||||
let tools: ToolSet | null = null;
|
||||
try {
|
||||
const course_ = courseTools({ repo: ctx.courses, course, index, listNotes: (lid) => notes.list(user.id, course.id, lid), getProgress: () => progress.get(user.id, course.id) });
|
||||
const servers = mcpServersFor(course.id, (await settings.getServer()).mcpServers);
|
||||
const mcp = servers.length ? await mcpTools(servers, { fetch: opts.fetch, timeoutMs: opts.mcpTimeoutMs }) : null;
|
||||
for (const w of mcp?.warnings ?? []) send({ type: "notice", message: w });
|
||||
tools = combineTools(mcp ? [course_, mcp] : [course_]);
|
||||
const messages: ChatMessage[] = [
|
||||
{ role: "system", content: buildSystemPrompt({ course, mentorText, mentorOn, sources, lesson: lesson ? { id: lesson.id, title: lesson.title } : null, extraTools: mcp?.names }) },
|
||||
...history.map((m) => ({ role: m.role, content: m.content }) as ChatMessage),
|
||||
{ role: "user", content: message },
|
||||
];
|
||||
let answer = "";
|
||||
let cited: unknown[] = [];
|
||||
for await (const ev of runAssistant({ provider, messages, tools, signal: req.signal })) {
|
||||
if (ev.type === "done") {
|
||||
answer = ev.text;
|
||||
cited = citationsFor([...sources, ...tools.sources]);
|
||||
send({ type: "done", text: ev.text, citations: cited });
|
||||
} else send(ev);
|
||||
}
|
||||
if (answer) {
|
||||
await addMessage(convId, "assistant", answer);
|
||||
await addMessage(convId, "sources", JSON.stringify(cited));
|
||||
}
|
||||
} catch (e) {
|
||||
const m = (e as Error).message;
|
||||
if (resolved.source === "byok") log(`chat error for user ${user.id} (own key): ${m}`);
|
||||
send({ type: "error", message: resolved.source === "byok" ? publicProviderError(m) : m });
|
||||
} finally {
|
||||
await tools?.close?.().catch(() => {});
|
||||
try { controller.close(); } catch {}
|
||||
}
|
||||
},
|
||||
});
|
||||
return new Response(stream, { headers: { "content-type": "application/x-ndjson; charset=utf-8", "cache-control": "no-store" } });
|
||||
};
|
||||
|
||||
const generateJob = async (payload: unknown, jc: JobContext) => {
|
||||
const p = payload as { req: GenerateRequest; userId: string };
|
||||
const resolved = await resolveProvider(p.userId);
|
||||
if (!resolved) throw new Error("AI is not set up for this user.");
|
||||
const provider = makeProvider(resolved, p.userId);
|
||||
return generateDraft({ provider, repo: ctx.courses, req: p.req, sink, authorId: p.userId, dataDir: ctx.config.dataDir, signal: jc.signal, onProgress: (f, m) => jc.reportProgress(f, m) });
|
||||
};
|
||||
|
||||
return {
|
||||
name: "ai",
|
||||
jobs: { "ai.generate": generateJob },
|
||||
routes: {
|
||||
"/api/ai/status": {
|
||||
GET: async (_req, rc) => {
|
||||
const resolved = rc.user ? await resolveProvider(rc.user.id) : null;
|
||||
const out: Record<string, unknown> = { enabled: !!resolved, source: resolved?.source ?? null, signedIn: !!rc.user };
|
||||
const courseId = rc.url.searchParams.get("courseId");
|
||||
if (courseId) {
|
||||
const course = await viewable(rc.user, courseId);
|
||||
if (course) {
|
||||
out.course = { id: course.id, title: course.manifest?.title ?? course.id };
|
||||
out.mentor = { available: !!course.manifest?.mentor, on: rc.user ? !(await settings.isMentorOff(rc.user.id, course.id)) : true };
|
||||
}
|
||||
}
|
||||
return json(out);
|
||||
},
|
||||
},
|
||||
"/api/ai/admin/config": {
|
||||
GET: async (_req, rc) => ownerOnly(rc) ?? json(await settings.getServer()),
|
||||
PUT: async (req, rc) => {
|
||||
const deny = ownerOnly(rc);
|
||||
if (deny) return deny;
|
||||
const b = await body(req);
|
||||
if (!b) return err(400, "Invalid JSON.");
|
||||
const provider = parseProvider(b.provider, "server");
|
||||
const mcpServers = parseMcp(b.mcpServers);
|
||||
if (provider === "invalid") return err(400, "Invalid provider. OpenAI-compatible endpoints need a base URL and a model.");
|
||||
if (mcpServers === "invalid") return err(400, "Invalid MCP server list.");
|
||||
let allowedBaseUrls: string[] | undefined;
|
||||
if (b.allowedBaseUrls !== undefined) {
|
||||
const list = Array.isArray(b.allowedBaseUrls) ? b.allowedBaseUrls.map(String).map((x) => x.trim()).filter(Boolean) : null;
|
||||
if (!list) return err(400, "allowedBaseUrls must be a list of origins.");
|
||||
allowedBaseUrls = parseAllowedOrigins(list);
|
||||
if (allowedBaseUrls.length !== new Set(list.map((x) => x.replace(/\/+$/, ""))).size) return err(400, "Each allowed base URL must be an origin such as http://ollama.internal:11434.");
|
||||
}
|
||||
await settings.saveServer({ provider, mcpServers, allowedBaseUrls });
|
||||
return json(await settings.getServer());
|
||||
},
|
||||
},
|
||||
"/api/ai/admin/mcp/test": {
|
||||
POST: async (req, rc) => {
|
||||
const deny = ownerOnly(rc);
|
||||
if (deny) return deny;
|
||||
const b = await body(req);
|
||||
const parsed = parseMcp(b ? [b] : null);
|
||||
if (parsed === "invalid" || !parsed.length) return err(400, "Invalid MCP server.");
|
||||
try {
|
||||
const c = await connectMcp(parsed[0]!, { fetch: opts.fetch, timeoutMs: opts.mcpTimeoutMs ?? 15_000 });
|
||||
try { return json({ ok: true, tools: (await c.tools()).map((t) => ({ name: t.name, description: t.description })) }); } finally { await c.close(); }
|
||||
} catch (e) {
|
||||
return json({ ok: false, error: (e as Error).message });
|
||||
}
|
||||
},
|
||||
},
|
||||
"/api/ai/account": {
|
||||
GET: async (_req, rc) => {
|
||||
if (!rc.user) return err(401, "Sign in first.");
|
||||
const u = await settings.getUser(rc.user.id);
|
||||
const s = await settings.getServer();
|
||||
return json({ byok: u.byok, serverProvider: s.provider ? { kind: s.provider.kind, model: s.provider.model ?? null } : null });
|
||||
},
|
||||
},
|
||||
"/api/ai/account/key": {
|
||||
PUT: async (req, rc) => {
|
||||
if (!rc.user) return err(401, "Sign in first.");
|
||||
const b = await body(req);
|
||||
const p = b ? parseProvider(b, "user") : "invalid";
|
||||
if (!p || p === "invalid" || !p.apiKey) return err(400, "Choose a provider and paste your key. OpenAI-compatible endpoints also need a model.");
|
||||
const base = byokBaseUrl(p.kind, p.baseUrl, await allowedOrigins());
|
||||
if (!base) return err(400, "That base URL is not allowed for your own key. Use the official endpoint, or ask an admin to allow it.");
|
||||
await settings.saveUserKey(rc.user.id, { ...p, baseUrl: base === PRESET_BASE_URLS[p.kind] ? undefined : base, apiKey: p.apiKey });
|
||||
return json((await settings.getUser(rc.user.id)).byok);
|
||||
},
|
||||
DELETE: async (_req, rc) => {
|
||||
if (!rc.user) return err(401, "Sign in first.");
|
||||
await settings.removeUserKey(rc.user.id);
|
||||
return json({ ok: true });
|
||||
},
|
||||
},
|
||||
"/api/ai/courses/:courseId/mentor": {
|
||||
PUT: async (req, rc) => {
|
||||
const deny = signedIn(rc);
|
||||
if (deny) return deny;
|
||||
const course = await viewable(rc.user, rc.params.courseId ?? "");
|
||||
if (!course) return err(404, "Course not found.");
|
||||
const b = await body(req);
|
||||
if (typeof b?.off !== "boolean") return err(400, "Send { off: true | false }.");
|
||||
await settings.setMentorOff(rc.user!.id, course.id, b.off);
|
||||
return json({ mentor: { available: !!course.manifest?.mentor, on: !b.off } });
|
||||
},
|
||||
},
|
||||
"/api/ai/courses/:courseId/chat": { POST: chat },
|
||||
"/api/ai/courses/:courseId/conversations": {
|
||||
GET: async (_req, rc) => {
|
||||
const deny = signedIn(rc);
|
||||
if (deny) return deny;
|
||||
const rows = await ctx.db.sql`SELECT id, title, created_at FROM ai_conversations WHERE user_id = ${rc.user!.id} AND course_id = ${rc.params.courseId ?? ""}`;
|
||||
return json(rows.map((r: { id: string; title: string | null; created_at: string }) => ({ id: r.id, title: r.title, createdAt: r.created_at })).sort((a: { createdAt: string }, b: { createdAt: string }) => (a.createdAt < b.createdAt ? 1 : -1)));
|
||||
},
|
||||
},
|
||||
"/api/ai/conversations/:id": {
|
||||
GET: async (_req, rc) => {
|
||||
const deny = signedIn(rc);
|
||||
if (deny) return deny;
|
||||
const conv = await conversationOf(rc.params.id ?? "", rc.user!.id);
|
||||
if (!conv) return err(404, "Conversation not found.");
|
||||
return json({ id: conv.id, courseId: conv.course_id, title: conv.title, messages: await turnsOf(conv.id) });
|
||||
},
|
||||
DELETE: async (_req, rc) => {
|
||||
const deny = signedIn(rc);
|
||||
if (deny) return deny;
|
||||
const conv = await conversationOf(rc.params.id ?? "", rc.user!.id);
|
||||
if (!conv) return err(404, "Conversation not found.");
|
||||
await ctx.db.sql.begin(async (tx) => {
|
||||
await tx`DELETE FROM ai_messages WHERE conversation_id = ${conv.id}`;
|
||||
await tx`DELETE FROM ai_conversations WHERE id = ${conv.id}`;
|
||||
});
|
||||
return json({ ok: true });
|
||||
},
|
||||
},
|
||||
"/api/ai/studio/courses": {
|
||||
GET: async (_req, rc) => {
|
||||
if (!rc.user) return err(401, "Sign in first.");
|
||||
const out = [];
|
||||
for (const c of ctx.courses.list()) {
|
||||
if (c.status === "ok" && (await canAuthor(rc.user, c))) out.push({ id: c.id, title: c.manifest?.title ?? c.id, tracks: c.manifest?.tracks ?? [] });
|
||||
}
|
||||
return json(out);
|
||||
},
|
||||
},
|
||||
"/api/ai/studio/sources": {
|
||||
GET: async (_req, rc) => {
|
||||
if (!rc.user) return err(401, "Sign in first.");
|
||||
const courseId = rc.url.searchParams.get("courseId") ?? "";
|
||||
const course = ctx.courses.get(courseId);
|
||||
if (!course || course.status !== "ok") return err(404, "Course not found.");
|
||||
if (!(await canAuthor(rc.user, course))) return err(403, "Only authors can generate drafts.");
|
||||
const lessons = (course.manifest?.modules ?? []).flatMap((m) => m.lessons.map((l) => ({ id: l.id, title: l.title, type: l.type, module: m.title, hasText: l.type !== "video" || !!l.transcript })));
|
||||
const files = await listFiles(course.root, 800); // authors may use any course file, solutions included
|
||||
const cache = await listFiles(join(ctx.config.dataDir, "cache", course.id), 400);
|
||||
return json({ lessons, files, cache });
|
||||
},
|
||||
},
|
||||
"/api/ai/studio/generate": {
|
||||
POST: async (req, rc) => {
|
||||
if (!rc.user) return err(401, "Sign in first.");
|
||||
const b = await body(req);
|
||||
const g = b ? parseGenerate(b) : null;
|
||||
if (!g) return err(400, "Choose a course, a kind (document, exercise or questions), a style and a topic.");
|
||||
const course = ctx.courses.get(g.courseId);
|
||||
if (!course || course.status !== "ok") return err(404, "Course not found.");
|
||||
if (!(await canAuthor(rc.user, course))) return err(403, "Only authors can generate drafts.");
|
||||
if (!(await resolveProvider(rc.user.id))) return err(409, "AI is not set up.");
|
||||
const jobId = await ctx.jobs.enqueue("ai.generate", { req: g, userId: rc.user.id });
|
||||
return json({ jobId }, 202);
|
||||
},
|
||||
},
|
||||
"/api/ai/studio/jobs/:id": {
|
||||
GET: async (_req, rc) => {
|
||||
if (!rc.user) return err(401, "Sign in first.");
|
||||
const job = await ctx.jobs.get(rc.params.id ?? "");
|
||||
const owner = (job?.payload as { userId?: string } | null)?.userId;
|
||||
if (!job || job.kind !== "ai.generate" || (owner !== rc.user.id && !rc.user.roles.includes("owner"))) return err(404, "Job not found.");
|
||||
return json({ id: job.id, status: job.status, progress: job.progress, message: job.message, result: job.result, error: job.error });
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { dirname, join, resolve, sep } from "node:path";
|
||||
|
||||
export interface DraftFile { path: string; content: string }
|
||||
|
||||
/**
|
||||
* What a sink reports back. The studio's writer returns `{ branch, commit }` (a commit on `draft/<authorId>`);
|
||||
* the filesystem fallback returns `{ location }` (a folder under `<dataDir>/drafts`).
|
||||
*/
|
||||
export interface DraftWriteResult { location?: string; branch?: string; commit?: string }
|
||||
|
||||
/**
|
||||
* Where generated files go. Same method as the studio's `DraftWriter` (`createDraftWriter(ctx, { identity? })` in
|
||||
* @stc/feature-studio), so that writer is a `DraftSink` as is, with no adapter: Phase 2 passes it in as
|
||||
* `aiFeature(ctx, { draftSink: createDraftWriter(ctx) })`. Drafts are never published by the AI package.
|
||||
*/
|
||||
export interface DraftSink {
|
||||
writeDraftFiles(courseId: string, authorId: string, files: DraftFile[], message: string): Promise<DraftWriteResult>;
|
||||
}
|
||||
|
||||
/** Human-readable place of a draft: the folder, or `draft/<author> @ <short commit>`. */
|
||||
export function draftLocation(r: DraftWriteResult): string {
|
||||
if (r.location) return r.location;
|
||||
if (r.branch) return r.commit ? `${r.branch} @ ${r.commit.slice(0, 7)}` : r.branch;
|
||||
return "draft";
|
||||
}
|
||||
|
||||
export function safeRelPath(p: string): string | null {
|
||||
const clean = p.trim().replace(/\\/g, "/").replace(/^\.\//, "");
|
||||
if (!clean || clean.startsWith("/") || /^[a-zA-Z]:/.test(clean)) return null;
|
||||
const parts = clean.split("/");
|
||||
if (parts.some((s) => s === ".." || s === "" || s === "." || s === ".git")) return null;
|
||||
return clean;
|
||||
}
|
||||
|
||||
/** Filesystem fallback: `<dataDir>/drafts/<courseId>/<runId>/…` plus DRAFT.json. Outside every course folder. */
|
||||
export function createFsDraftSink(dataDir: string): DraftSink {
|
||||
return {
|
||||
async writeDraftFiles(courseId, authorId, files, message) {
|
||||
if (!safeRelPath(courseId) || courseId.includes("/")) throw new Error("Invalid course id");
|
||||
const runId = `${new Date().toISOString().replace(/[:.]/g, "-")}-${Bun.randomUUIDv7().slice(-8)}`;
|
||||
const location = resolve(dataDir, "drafts", courseId, runId);
|
||||
const paths: string[] = [];
|
||||
for (const f of files) {
|
||||
const rel = safeRelPath(f.path);
|
||||
if (!rel) throw new Error(`Unsafe draft path: ${f.path}`);
|
||||
const abs = resolve(location, rel);
|
||||
if (!abs.startsWith(location + sep)) throw new Error(`Unsafe draft path: ${f.path}`);
|
||||
await mkdir(dirname(abs), { recursive: true });
|
||||
await Bun.write(abs, f.content);
|
||||
paths.push(rel);
|
||||
}
|
||||
await Bun.write(
|
||||
join(location, "DRAFT.json"),
|
||||
JSON.stringify({ courseId, authorId, message, createdAt: new Date().toISOString(), published: false, files: paths }, null, 2),
|
||||
);
|
||||
return { location };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { AiProvider } from "@stc/contracts";
|
||||
import { resolveInside, type CourseRepository, type LoadedCourse } from "@stc/course-format";
|
||||
import { draftLocation, safeRelPath, type DraftFile, type DraftSink } from "./draft-sink";
|
||||
import { OUTPUT_FORMAT, REQUIRED, VOICE, styleTemplate, type Kind, type Style } from "./templates";
|
||||
|
||||
export type GenerateSource =
|
||||
| { kind: "lesson"; lessonId: string }
|
||||
| { kind: "file"; path: string }
|
||||
| { kind: "cache"; path: string }
|
||||
| { kind: "text"; label: string; text: string };
|
||||
|
||||
export interface GenerateRequest {
|
||||
courseId: string;
|
||||
kind: Kind;
|
||||
style: Style;
|
||||
topic: string;
|
||||
sources: GenerateSource[];
|
||||
instructions?: string;
|
||||
}
|
||||
|
||||
/** `location` is a folder (filesystem sink) or `draft/<author> @ <commit>` (studio writer). */
|
||||
export interface GenerateResult { location: string; branch?: string; commit?: string; paths: string[]; warnings: string[] }
|
||||
|
||||
const MAX_MATERIAL = 400_000;
|
||||
|
||||
export function slugify(s: string): string {
|
||||
return s.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "draft";
|
||||
}
|
||||
|
||||
export function targetPaths(req: GenerateRequest): string[] {
|
||||
const slug = slugify(req.topic);
|
||||
if (req.kind === "exercise") return [`exercises/${slug}/README.md`, `exercises/${slug}/starter.js`, `exercises/${slug}/tests.js`, `exercises/${slug}/solution.js`, `exercises/${slug}/fixtures/${slug}.json`, `exercises/${slug}/manifest-snippet.yaml`];
|
||||
if (req.kind === "questions") return [`questions/${slug}.md`];
|
||||
return req.style === "systemic-design" ? [`course/${slug}/lesson.md`, `course/${slug}/scenarios.md`] : [`course/${slug}.md`];
|
||||
}
|
||||
|
||||
/** Reads the chosen inputs. Authors may use any course file, including solutions; cache text comes from <dataDir>/cache/<courseId>/. */
|
||||
export async function gatherMaterial(repo: CourseRepository, course: LoadedCourse, sources: GenerateSource[], dataDir: string): Promise<{ text: string; warnings: string[] }> {
|
||||
const parts: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
let total = 0;
|
||||
const push = (label: string, body: string) => {
|
||||
if (total >= MAX_MATERIAL) { warnings.push(`Left out "${label}": the material is already ${MAX_MATERIAL} characters.`); return; }
|
||||
let text = body;
|
||||
if (total + text.length > MAX_MATERIAL) {
|
||||
text = text.slice(0, MAX_MATERIAL - total);
|
||||
warnings.push(`"${label}" was shortened to fit the material limit.`);
|
||||
}
|
||||
total += text.length;
|
||||
parts.push(`<source label="${label.replace(/"/g, "'")}">\n${text}\n</source>`);
|
||||
};
|
||||
const readText = async (abs: string | null, label: string) => {
|
||||
const info = abs ? await stat(abs).catch(() => null) : null;
|
||||
if (!abs || !info?.isFile()) { warnings.push(`Source not found: ${label}`); return; }
|
||||
const text = await Bun.file(abs).text();
|
||||
if (text.includes("\u0000")) { warnings.push(`Skipped binary file: ${label}`); return; }
|
||||
push(label, text);
|
||||
};
|
||||
for (const s of sources) {
|
||||
if (s.kind === "text") push(s.label || "Pasted text", s.text);
|
||||
else if (s.kind === "lesson") {
|
||||
const e = course.lessonIndex.get(s.lessonId);
|
||||
const md = e ? await repo.readLessonMarkdown(course.id, s.lessonId) : null;
|
||||
if (!e || md === null) warnings.push(`Lesson has no readable text: ${s.lessonId}`);
|
||||
else push(`${e.lesson.type === "video" ? e.lesson.transcript : e.lesson.file} (lesson ${s.lessonId}: ${e.lesson.title})`, md);
|
||||
} else if (s.kind === "file") await readText(repo.resolvePath(course.id, s.path), s.path);
|
||||
else await readText(resolveInside(join(dataDir, "cache", course.id), s.path), `cache/${s.path}`);
|
||||
}
|
||||
return { text: parts.join("\n\n"), warnings };
|
||||
}
|
||||
|
||||
function outline(course: LoadedCourse): string {
|
||||
return (course.manifest?.modules ?? []).map((m, i) => `${i + 1}. ${m.title} (${m.lessons.length} lessons)`).join("\n");
|
||||
}
|
||||
|
||||
export function buildGeneratorPrompt(req: GenerateRequest, material: string, course: LoadedCourse): { system: string; user: string } {
|
||||
const what = req.kind === "document" ? "an explanatory lesson document" : req.kind === "exercise" ? "a small exercise with tests and hint tiers" : "check-your-understanding questions";
|
||||
const system = [
|
||||
`You draft course material for STCourses authors. You write ${what} for the course "${course.manifest?.title ?? course.id}", in the house style below, from the source material the author selected.`,
|
||||
VOICE,
|
||||
styleTemplate(req.kind, req.style),
|
||||
OUTPUT_FORMAT,
|
||||
].join("\n\n");
|
||||
const tracks = course.manifest?.tracks?.map((t) => `${t.label} (${t.id})`).join(", ") || "one track (JavaScript)";
|
||||
const user = [
|
||||
`Course: ${course.manifest?.title ?? course.id}`,
|
||||
`Language tracks: ${tracks}`,
|
||||
`Existing modules:\n${outline(course) || "(none yet)"}`,
|
||||
`Topic: ${req.topic}`,
|
||||
req.instructions?.trim() ? `Author's instructions:\n${req.instructions.trim()}` : "",
|
||||
`Write these files:\n${targetPaths(req).map((p) => `- ${p}`).join("\n")}`,
|
||||
`Source material (transcripts, text, slide text and code chosen by the author):\n\n${material || "(none: work from the topic and instructions, and mark facts TODO(author): check)"}`,
|
||||
].filter(Boolean).join("\n\n");
|
||||
return { system, user };
|
||||
}
|
||||
|
||||
/** Reads `=== FILE: path ===` … `=== END FILE ===` blocks. Unsafe paths are dropped with a warning. */
|
||||
export function parseFiles(text: string): { files: DraftFile[]; warnings: string[] } {
|
||||
const files: DraftFile[] = [];
|
||||
const warnings: string[] = [];
|
||||
const lines = text.split(/\r?\n/);
|
||||
let cur: { path: string; lines: string[] } | null = null;
|
||||
const close = () => {
|
||||
if (!cur) return;
|
||||
const rel = safeRelPath(cur.path);
|
||||
if (!rel) warnings.push(`Dropped a file with an unsafe path: ${cur.path}`);
|
||||
else files.push({ path: rel, content: cur.lines.join("\n").replace(/\n*$/, "\n") });
|
||||
cur = null;
|
||||
};
|
||||
for (const line of lines) {
|
||||
const open = /^===\s*FILE:\s*(.+?)\s*===\s*$/.exec(line);
|
||||
if (open) { close(); cur = { path: open[1]!.replace(/^`|`$/g, ""), lines: [] }; continue; }
|
||||
if (/^===\s*END FILE\s*===\s*$/.test(line)) { close(); continue; }
|
||||
cur?.lines.push(line);
|
||||
}
|
||||
if (cur) { warnings.push(`The last file (${(cur as { path: string }).path}) was not closed; it may be cut off.`); close(); }
|
||||
if (!files.length) warnings.push("The model returned no files.");
|
||||
return { files, warnings };
|
||||
}
|
||||
|
||||
export function checkHouseStyle(req: GenerateRequest, files: DraftFile[]): string[] {
|
||||
const warnings: string[] = [];
|
||||
const md = files.filter((f) => /\.(md|markdown)$/i.test(f.path)).map((f) => f.content).join("\n");
|
||||
for (const s of REQUIRED[req.kind][req.style]) if (!md.includes(s)) warnings.push(`House style: missing "${s}".`);
|
||||
if (req.kind === "exercise") {
|
||||
for (const name of ["starter.js", "tests.js", "solution.js"]) if (!files.some((f) => f.path.endsWith(`/${name}`))) warnings.push(`Exercise: missing ${name}.`);
|
||||
const tests = files.find((f) => f.path.endsWith("/tests.js"));
|
||||
if (tests && !/export\s+const\s+tests\b/.test(tests.content)) warnings.push("Exercise: tests.js does not export `tests`.");
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
const marker = (req: GenerateRequest) =>
|
||||
`<!-- Draft generated by STCourses AI on ${new Date().toISOString().slice(0, 10)} (${req.kind}, ${req.style} style) from author-selected sources. An author must review it before publishing. -->\n\n`;
|
||||
|
||||
/** Runs the model once and writes the files it returns through the DraftSink. Never publishes. */
|
||||
export async function generateDraft(o: {
|
||||
provider: AiProvider;
|
||||
repo: CourseRepository;
|
||||
req: GenerateRequest;
|
||||
sink: DraftSink;
|
||||
authorId: string;
|
||||
dataDir: string;
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (fraction: number, message: string) => Promise<void> | void;
|
||||
}): Promise<GenerateResult> {
|
||||
const course = o.repo.get(o.req.courseId);
|
||||
if (!course || course.status !== "ok") throw new Error(`Course not available: ${o.req.courseId}`);
|
||||
await o.onProgress?.(0.05, "Reading sources");
|
||||
const material = await gatherMaterial(o.repo, course, o.req.sources, o.dataDir);
|
||||
const prompt = buildGeneratorPrompt(o.req, material.text, course);
|
||||
await o.onProgress?.(0.15, "Drafting");
|
||||
let text = "";
|
||||
let chars = 0;
|
||||
for await (const ev of o.provider.chat([{ role: "system", content: prompt.system }, { role: "user", content: prompt.user }], { signal: o.signal })) {
|
||||
if (ev.text) {
|
||||
text += ev.text;
|
||||
chars += ev.text.length;
|
||||
if (chars > 4000) { chars = 0; await o.onProgress?.(Math.min(0.85, 0.15 + text.length / 60_000), "Drafting"); }
|
||||
}
|
||||
}
|
||||
const parsed = parseFiles(text);
|
||||
if (!parsed.files.length) throw new Error(parsed.warnings.join(" ") || "The model returned no files.");
|
||||
const files = parsed.files.map((f) => (/\.(md|markdown)$/i.test(f.path) ? { ...f, content: marker(o.req) + f.content } : f));
|
||||
await o.onProgress?.(0.9, "Writing drafts");
|
||||
const written = await o.sink.writeDraftFiles(course.id, o.authorId, files, `AI draft: ${o.req.kind} "${o.req.topic}" (${o.req.style} style)`);
|
||||
return {
|
||||
location: draftLocation(written),
|
||||
...(written.branch ? { branch: written.branch } : {}),
|
||||
...(written.commit ? { commit: written.commit } : {}),
|
||||
paths: files.map((f) => f.path),
|
||||
warnings: [...material.warnings, ...parsed.warnings, ...checkHouseStyle(o.req, parsed.files)],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
// House-style prompt templates for the studio generator, distilled from the two reference courses:
|
||||
// - Pure Snake (functional-programming-course): module shape of course/03-pure-game-rules.md and MENTOR.md;
|
||||
// - Systemic Design (systemic-design-course): the beats of course/01-handles/lesson.md and mentor/SKILL.md.
|
||||
|
||||
export type Style = "pure-snake" | "systemic-design";
|
||||
export type Kind = "document" | "exercise" | "questions";
|
||||
|
||||
export const OUTPUT_FORMAT = `## Output format
|
||||
|
||||
Reply with one or more files and nothing that matters outside them. Write each file exactly like this:
|
||||
|
||||
=== FILE: <course-relative path> ===
|
||||
<the whole file content>
|
||||
=== END FILE ===
|
||||
|
||||
Use only relative paths inside the course (no leading slash, no ".."). Everything you write is a draft for an author to review; it is never published automatically.`;
|
||||
|
||||
export const VOICE = `## Voice and rules (both house styles)
|
||||
|
||||
- Write for a learner working alone at home with general programming experience. Plain, direct sentences; second person ("you"). No hype, no emoji, no headings in all caps.
|
||||
- Teach by making the learner think first: a concrete situation or puzzle before the explanation, and the explanation folded so they can try first.
|
||||
- Every fact about a tool, library or real system must come from the source material you are given. If the material does not say it, leave it out or mark it "TODO(author): check".
|
||||
- Code in fenced blocks with a language tag. Keep examples small. Exercise solutions never appear in learner-facing text; near-solution hints are skeletons with \`___\` holes.
|
||||
- When the course has several language tracks, give each track's signature and use one shared fixture file for test cases, so every track tests the same cases with the same names.`;
|
||||
|
||||
export const PURE_SNAKE_DOCUMENT = `## House style: Pure Snake module
|
||||
|
||||
Follow this module shape exactly (headings verbatim, in this order):
|
||||
|
||||
# Module N — <Title>
|
||||
|
||||
> **Time:** ~<estimate> · **You'll edit:** <files per track>
|
||||
> **Tests:** <test command per track>
|
||||
> **Prerequisites:** <earlier modules>
|
||||
|
||||
## Why this module
|
||||
Two or three paragraphs: how this is usually done (stateful, the familiar way), what goes wrong, and what this module does instead. End by announcing the puzzles.
|
||||
|
||||
## Learning objectives
|
||||
"After this module you can:" then 4-6 bullets, each starting with a bold verb (**implement**, **explain**, **reason**, **predict**, **read**).
|
||||
|
||||
## Concepts
|
||||
### <one H3 per concept>
|
||||
Explain with a tiny example on a *different* record than the exercise (say so: "(illustration on a different record)"), shown in each track.
|
||||
Include at least one bridge:
|
||||
> **From everyday programming:** <how the learner already meets this idea: loops, setters, callbacks, SQL, Git, unit tests, mocks>
|
||||
|
||||
### Puzzle 1: <a question the learner can answer on paper>
|
||||
A concrete small case (a 5 x 5 board, a list of three items). Then **Before reading on, decide:** <the question>.
|
||||
|
||||
<details><summary>Think about it first, then open the explanation</summary>
|
||||
|
||||
The reasoning, the answer, and the rule it reveals. Name the fixture cases that check it.
|
||||
|
||||
</details>
|
||||
|
||||
(One or two puzzles. Each one targets a subtlety implementations commonly get wrong.)
|
||||
|
||||
## Exercises
|
||||
### Exercise N.1 — \`functionName\`
|
||||
The signature per track and a one-line example in a comment. "Fixture: \`fixtures/mNN-<function>.json\` (<case names>)."
|
||||
(2-4 exercises, each small and testable.)
|
||||
|
||||
### Run
|
||||
The test command per track.
|
||||
|
||||
## Hints
|
||||
For EVERY exercise, three folds in this order, labelled exactly:
|
||||
|
||||
<details><summary>Exercise N.1 (functionName) — nudge</summary>
|
||||
|
||||
One question or pointer back to an earlier idea.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Exercise N.1 (functionName) — stronger hint</summary>
|
||||
|
||||
The approach in words, no code.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Exercise N.1 (functionName) — near-solution (try the others first)</summary>
|
||||
|
||||
A skeleton per track with \`___\` holes. Never the full answer.
|
||||
|
||||
</details>
|
||||
|
||||
## Check your understanding
|
||||
6-8 numbered questions. Include: at least one "Predict:" question with concrete input, at least one "What would break if…", one "Explain to a friend, with a drawing…", and one design question ("Sketch a different model that…").
|
||||
|
||||
## In production
|
||||
2-3 bullets linking the idea to real systems, each with a source from the material or marked TODO(author).
|
||||
|
||||
## Further reading
|
||||
3-5 bullets: "[Title](url) — why it is worth reading." Only links present in the material; otherwise "TODO(author): add reading".`;
|
||||
|
||||
export const SYSTEMIC_DESIGN_DOCUMENT = `## House style: Systemic Design module
|
||||
|
||||
Every module follows the same beats, as H2 headings verbatim and in this order: Observe, Predict, Spec, Build, Name it, Expansion Drop, Further reading.
|
||||
|
||||
# Module N: <Title>
|
||||
One paragraph: what the engine learns in this module and why later modules stand on it.
|
||||
|
||||
## Observe
|
||||
A concrete situation from a real game or system, with the exact rule text quoted and its source cited in a link. Distinguish what the source says from the course's own reading ("That example is the course's reading of the quoted rule"). End with the tension the module resolves.
|
||||
|
||||
## Predict
|
||||
"Write your answers down before you open the discussion, for example in \`notes/NN.md\` in your workspace."
|
||||
2-3 numbered questions that make the learner commit to a design choice.
|
||||
|
||||
<details><summary>Discussion</summary>
|
||||
|
||||
Answer each question by number. Compare the tempting options and say what goes wrong with each, then name the answer.
|
||||
|
||||
</details>
|
||||
|
||||
## Spec
|
||||
The contract: harness functions with signatures in each track, data formats, and how tests are named. Point to \`scenarios.md\`: each scenario heading is a test name, identical across tracks, with Setup / Action / Assertions bullets.
|
||||
|
||||
## Build
|
||||
Where code goes (generic machinery in the engine, named content in content files; the engine must not name specific items) and what not to build yet.
|
||||
|
||||
<details><summary>Hint 1 — nudge</summary>
|
||||
|
||||
A question about who owns the data or when something happens.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Hint 2 — concept</summary>
|
||||
|
||||
The design in bolded terms (**Id**, **Entity record**, …), still no code.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Hint 3 — pseudo-code</summary>
|
||||
|
||||
\`\`\`text
|
||||
language-neutral pseudo-code, never one track's real code
|
||||
\`\`\`
|
||||
|
||||
</details>
|
||||
|
||||
## Name it
|
||||
Name the pattern, then show where it appears in real engines or systems with links from the material, and the one idea to keep.
|
||||
|
||||
## Expansion Drop
|
||||
New content arrives that the design must absorb without touching the engine: numbered steps (commit, copy the expansion test, implement in harness/content only, run the expansion check). State the test name and what it checks.
|
||||
|
||||
<details><summary>Discussion: what this drop measures</summary>
|
||||
|
||||
What a good design makes trivial here, and what a coupled design would have had to change.
|
||||
|
||||
</details>
|
||||
|
||||
## Further reading
|
||||
Bullets with links from the material, each with one line on why.
|
||||
|
||||
Also write \`scenarios.md\` next to the lesson: "# Module N scenarios: <Title>", one sentence on where the tests live, then one "### test_NN_<name>" heading per scenario with Setup / Action / Assertions bullets.`;
|
||||
|
||||
export const EXERCISE = (style: Style) => `## House style: exercise
|
||||
|
||||
Write one small exercise that runs in the STCourses browser runner, plus its hint tiers.
|
||||
|
||||
Files (all under the exercise folder you are given):
|
||||
- \`README.md\`: the task in the course's voice: one paragraph of context, the signature, two worked examples (input → output), and what "done" means. Then the hints as three folds per exercise, labelled ${style === "systemic-design" ? '"Hint 1 — nudge", "Hint 2 — concept", "Hint 3 — pseudo-code"' : '"Exercise N.M (name) — nudge", "… — stronger hint", "… — near-solution (try the others first)"'}. The near-solution tier is a skeleton with \`___\` holes (stronger hint: the approach in words; nudge: one question). The tiers in plain words are always nudge, stronger hint, near-solution.
|
||||
- \`starter.js\`: an ES module exporting the function(s) with the right signature and a body that returns a wrong but type-correct value (or throws "TODO"). A doc comment states the contract.
|
||||
- \`tests.js\`: an ES module \`export const tests = [ { name: "…", run(mod, assert) { assert.equal(mod.fn(…), …) } }, … ]\`. Use only \`assert.equal\`, \`assert.deepEqual\` and \`assert.ok\`. 5-8 tests including edge cases; test names read like fixture case names (\`sum-of-empty-list-is-zero\`). No imports: the runner loads this one module.
|
||||
- \`solution.js\`: the reference solution (authors only; learners never see it).
|
||||
- \`fixtures/<slug>.json\`: the same test cases as data (\`[{ "name", "input", "expected" }]\`), shared across tracks so other language tracks test identical cases.
|
||||
- \`manifest-snippet.yaml\`: the lesson entry an author would paste into the manifest (type: exercise, browser: { language: js, starter, tests, solution }, hints: { file, match }).`;
|
||||
|
||||
export const QUESTIONS = `## House style: knowledge check
|
||||
|
||||
Write a "## Check your understanding" section as a numbered list of 6-8 questions about the material, in the reference courses' style:
|
||||
- at least one "Predict:" question with concrete input the learner works out on paper;
|
||||
- at least one "What would break if…" question;
|
||||
- one "Explain to a friend, with a drawing…" question;
|
||||
- one design question ("Sketch a different model that…");
|
||||
- the rest test the main idea, not trivia. No multiple choice.
|
||||
|
||||
After the list, add one fold per question for authors:
|
||||
|
||||
<details><summary>Answer notes for question N (authors)</summary>
|
||||
|
||||
What a good answer contains and the misconception it probes.
|
||||
|
||||
</details>`;
|
||||
|
||||
export function styleTemplate(kind: Kind, style: Style): string {
|
||||
if (kind === "exercise") return EXERCISE(style);
|
||||
if (kind === "questions") return QUESTIONS;
|
||||
return style === "systemic-design" ? SYSTEMIC_DESIGN_DOCUMENT : PURE_SNAKE_DOCUMENT;
|
||||
}
|
||||
|
||||
/** Headings and labels the house-style check looks for. */
|
||||
export const REQUIRED: Record<Kind, Record<Style, string[]>> = {
|
||||
document: {
|
||||
"pure-snake": ["## Why this module", "## Learning objectives", "## Concepts", "Think about it first", "## Exercises", "## Hints", "— nudge", "— stronger hint", "— near-solution", "## Check your understanding", "## Further reading"],
|
||||
"systemic-design": ["## Observe", "## Predict", "## Spec", "## Build", "Hint 1 — nudge", "Hint 2 — concept", "Hint 3 — pseudo-code", "## Name it", "## Expansion Drop", "## Further reading"],
|
||||
},
|
||||
exercise: {
|
||||
"pure-snake": ["nudge", "stronger hint", "near-solution"],
|
||||
"systemic-design": ["Hint 1 — nudge", "Hint 2 — concept", "Hint 3 — pseudo-code"],
|
||||
},
|
||||
questions: {
|
||||
"pure-snake": ["## Check your understanding"],
|
||||
"systemic-design": ["## Check your understanding"],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
export { createSecretBox, type SecretBox } from "./crypto";
|
||||
|
||||
/** Migration folders for this package. Empty: the AI package uses the ai_* tables from 0001_init.sql. */
|
||||
export const migrationsDirs: string[] = [];
|
||||
export * from "./settings";
|
||||
export * from "./providers";
|
||||
export * from "./retrieval/course-index";
|
||||
export { Bm25, tokenize } from "./retrieval/bm25";
|
||||
export * from "./mcp/client";
|
||||
export * from "./assistant/prompt";
|
||||
export * from "./assistant/tools";
|
||||
export * from "./assistant/loop";
|
||||
export * from "./generator/draft-sink";
|
||||
export * from "./generator/generate";
|
||||
export * from "./generator/templates";
|
||||
export { aiFeature, type AiFeatureOptions } from "./feature";
|
||||
export { aiWeb } from "./web";
|
||||
@@ -0,0 +1,198 @@
|
||||
import type { McpServerConfig } from "../settings";
|
||||
import { readSse } from "../providers/sse";
|
||||
|
||||
export interface McpTool { name: string; description: string; inputSchema: Record<string, unknown> }
|
||||
export interface McpClient {
|
||||
tools(): Promise<McpTool[]>;
|
||||
call(name: string, args: unknown): Promise<string>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
const PROTOCOL_VERSION = "2025-06-18";
|
||||
const CLIENT_INFO = { name: "stcourses", version: "1.0.0" };
|
||||
|
||||
type RpcReply = { id?: number | string; result?: any; error?: { code: number; message: string } };
|
||||
interface Transport { request(method: string, params: unknown): Promise<any>; notify(method: string, params?: unknown): Promise<void>; close(): Promise<void> }
|
||||
|
||||
const withTimeout = <T>(p: Promise<T>, ms: number, what: string): Promise<T> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const t = setTimeout(() => reject(new Error(`MCP ${what} timed out after ${ms} ms`)), ms);
|
||||
p.then((v) => { clearTimeout(t); resolve(v); }, (e) => { clearTimeout(t); reject(e); });
|
||||
});
|
||||
|
||||
function unwrap(r: RpcReply): any {
|
||||
if (r.error) throw new Error(`MCP error ${r.error.code}: ${r.error.message}`);
|
||||
return r.result;
|
||||
}
|
||||
|
||||
/** Inherited by stdio MCP servers. Everything else (STC_SECRET, database URLs, API keys) stays with the server. */
|
||||
const INHERITED_ENV = ["PATH", "HOME", "LANG"] as const;
|
||||
|
||||
/** The environment for a stdio MCP server: PATH, HOME and LANG from the server, plus exactly the admin-configured variables. */
|
||||
export function mcpChildEnv(cfg: McpServerConfig, parent: Record<string, string | undefined> = process.env): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const k of INHERITED_ENV) if (parent[k] !== undefined) env[k] = parent[k]!;
|
||||
return { ...env, ...(cfg.env ?? {}) };
|
||||
}
|
||||
|
||||
function stdioTransport(cfg: McpServerConfig, timeoutMs: number): Transport {
|
||||
if (!cfg.command) throw new Error(`MCP server "${cfg.name}" has no command`);
|
||||
const proc = Bun.spawn([cfg.command, ...(cfg.args ?? [])], {
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "ignore",
|
||||
env: mcpChildEnv(cfg),
|
||||
});
|
||||
let nextId = 1;
|
||||
const pending = new Map<number, { resolve(v: RpcReply): void; reject(e: Error): void }>();
|
||||
let closed = false;
|
||||
const failAll = (e: Error) => { for (const p of pending.values()) p.reject(e); pending.clear(); };
|
||||
|
||||
(async () => {
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
try {
|
||||
for await (const chunk of proc.stdout as unknown as AsyncIterable<Uint8Array>) {
|
||||
buf += decoder.decode(chunk, { stream: true });
|
||||
let nl: number;
|
||||
while ((nl = buf.indexOf("\n")) !== -1) {
|
||||
const line = buf.slice(0, nl).trim();
|
||||
buf = buf.slice(nl + 1);
|
||||
if (!line) continue;
|
||||
let msg: RpcReply;
|
||||
try { msg = JSON.parse(line); } catch { continue; }
|
||||
if (typeof msg.id === "number" && pending.has(msg.id)) {
|
||||
pending.get(msg.id)!.resolve(msg);
|
||||
pending.delete(msg.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
closed = true;
|
||||
failAll(new Error(`MCP server "${cfg.name}" exited`));
|
||||
})();
|
||||
proc.exited.then((code) => { closed = true; failAll(new Error(`MCP server "${cfg.name}" exited with code ${code}`)); });
|
||||
|
||||
const send = (obj: unknown) => {
|
||||
if (closed) throw new Error(`MCP server "${cfg.name}" is not running`);
|
||||
proc.stdin.write(JSON.stringify(obj) + "\n");
|
||||
proc.stdin.flush();
|
||||
};
|
||||
return {
|
||||
async request(method, params) {
|
||||
const id = nextId++;
|
||||
const reply = new Promise<RpcReply>((resolve, reject) => pending.set(id, { resolve, reject }));
|
||||
send({ jsonrpc: "2.0", id, method, params });
|
||||
return unwrap(await withTimeout(reply, timeoutMs, method));
|
||||
},
|
||||
async notify(method, params) { send({ jsonrpc: "2.0", method, ...(params ? { params } : {}) }); },
|
||||
async close() {
|
||||
closed = true;
|
||||
try { proc.stdin.end(); } catch {}
|
||||
proc.kill();
|
||||
await withTimeout(proc.exited, 2000, "shutdown").catch(() => proc.kill(9));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function httpTransport(cfg: McpServerConfig, timeoutMs: number, doFetch: typeof fetch): Transport {
|
||||
if (!cfg.url) throw new Error(`MCP server "${cfg.name}" has no URL`);
|
||||
const url = cfg.url;
|
||||
let session: string | null = null;
|
||||
let nextId = 1;
|
||||
const post = async (body: unknown, signal: AbortSignal) => {
|
||||
const headers: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
accept: "application/json, text/event-stream",
|
||||
"mcp-protocol-version": PROTOCOL_VERSION,
|
||||
...(cfg.headers ?? {}),
|
||||
};
|
||||
if (session) headers["mcp-session-id"] = session;
|
||||
const res = await doFetch(url, { method: "POST", headers, body: JSON.stringify(body), signal });
|
||||
const sid = res.headers.get("mcp-session-id");
|
||||
if (sid) session = sid;
|
||||
return res;
|
||||
};
|
||||
return {
|
||||
async request(method, params) {
|
||||
const id = nextId++;
|
||||
const ctl = new AbortController();
|
||||
const t = setTimeout(() => ctl.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await post({ jsonrpc: "2.0", id, method, params }, ctl.signal);
|
||||
if (!res.ok) throw new Error(`MCP HTTP ${res.status} from "${cfg.name}"`);
|
||||
const type = res.headers.get("content-type") ?? "";
|
||||
if (type.includes("text/event-stream") && res.body) {
|
||||
for await (const { data } of readSse(res.body)) {
|
||||
let msg: RpcReply;
|
||||
try { msg = JSON.parse(data); } catch { continue; }
|
||||
if (msg.id === id) { ctl.abort(); return unwrap(msg); }
|
||||
}
|
||||
throw new Error(`MCP stream from "${cfg.name}" ended without a reply`);
|
||||
}
|
||||
const msg = await res.json();
|
||||
const reply = Array.isArray(msg) ? msg.find((m: RpcReply) => m.id === id) : msg;
|
||||
return unwrap(reply ?? { error: { code: -1, message: "no reply" } });
|
||||
} catch (e) {
|
||||
if (ctl.signal.aborted && (e as Error).name === "AbortError") throw new Error(`MCP ${method} timed out after ${timeoutMs} ms`);
|
||||
throw e;
|
||||
} finally { clearTimeout(t); }
|
||||
},
|
||||
async notify(method, params) {
|
||||
const ctl = new AbortController();
|
||||
const t = setTimeout(() => ctl.abort(), timeoutMs);
|
||||
try { await post({ jsonrpc: "2.0", method, ...(params ? { params } : {}) }, ctl.signal); } finally { clearTimeout(t); }
|
||||
},
|
||||
async close() {
|
||||
if (!session) return;
|
||||
const headers: Record<string, string> = { "mcp-session-id": session, ...(cfg.headers ?? {}) };
|
||||
await doFetch(url, { method: "DELETE", headers }).catch(() => {});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Connects and initializes an MCP server (stdio or streamable HTTP). */
|
||||
export async function connectMcp(cfg: McpServerConfig, opts: { fetch?: typeof fetch; timeoutMs?: number } = {}): Promise<McpClient> {
|
||||
const timeoutMs = opts.timeoutMs ?? 20_000;
|
||||
const t = cfg.transport === "stdio" ? stdioTransport(cfg, timeoutMs) : httpTransport(cfg, timeoutMs, opts.fetch ?? fetch);
|
||||
try {
|
||||
await t.request("initialize", { protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: CLIENT_INFO });
|
||||
await t.notify("notifications/initialized");
|
||||
} catch (e) {
|
||||
await t.close().catch(() => {});
|
||||
throw e;
|
||||
}
|
||||
return {
|
||||
async tools() {
|
||||
const out: McpTool[] = [];
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const r = await t.request("tools/list", cursor ? { cursor } : {});
|
||||
for (const tool of r?.tools ?? []) out.push({ name: tool.name, description: tool.description ?? "", inputSchema: tool.inputSchema ?? { type: "object" } });
|
||||
cursor = r?.nextCursor;
|
||||
} while (cursor);
|
||||
return out;
|
||||
},
|
||||
async call(name, args) {
|
||||
const r = await t.request("tools/call", { name, arguments: args ?? {} });
|
||||
const text = (r?.content ?? [])
|
||||
.map((c: any) => (c.type === "text" ? c.text : c.type === "resource" ? (c.resource?.text ?? `[resource ${c.resource?.uri}]`) : `[${c.type} content]`))
|
||||
.join("\n");
|
||||
const body = text || (r?.structuredContent ? JSON.stringify(r.structuredContent) : "");
|
||||
if (r?.isError) throw new Error(body || "tool failed");
|
||||
return body;
|
||||
},
|
||||
close: () => t.close(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Global servers (no courseIds) plus servers that list this course. */
|
||||
export function mcpServersFor(courseId: string, servers: McpServerConfig[]): McpServerConfig[] {
|
||||
return servers.filter((s) => !s.courseIds?.length || s.courseIds.includes(courseId));
|
||||
}
|
||||
|
||||
/** Provider-safe tool name (^[a-zA-Z0-9_-]{1,64}$) for an MCP tool. */
|
||||
export function mcpToolName(serverId: string, tool: string): string {
|
||||
const clean = (s: string) => s.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||
return `mcp_${clean(serverId).slice(0, 20)}__${clean(tool)}`.slice(0, 64);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { AiProvider, ChatMessage, ToolSpec } from "@stc/contracts";
|
||||
import type { Effort } from "../settings";
|
||||
import { errorMessage, readSse } from "./sse";
|
||||
|
||||
export const DEFAULT_ANTHROPIC_MODEL = "claude-opus-5-5";
|
||||
const FIRST_PARTY = "https://api.anthropic.com";
|
||||
|
||||
export interface AnthropicOptions {
|
||||
apiKey: string;
|
||||
model?: string;
|
||||
baseUrl?: string;
|
||||
effort?: Effort;
|
||||
maxTokens?: number;
|
||||
fetch?: typeof fetch;
|
||||
}
|
||||
|
||||
type Block = Record<string, unknown> & { type: string };
|
||||
type ApiMessage = { role: "user" | "assistant"; content: Block[] };
|
||||
|
||||
/**
|
||||
* Anthropic Messages API over plain fetch with streaming and tool use.
|
||||
* - Thinking is left to the model default (adaptive on current models); no temperature or forced tool_choice.
|
||||
* - The frozen ChatMessage contract has no slot for thinking blocks, so the provider remembers every
|
||||
* response's raw content blocks by tool_use id and replays them unchanged when the caller sends back
|
||||
* the matching assistant tool-call message (required for thinking + tool use).
|
||||
* - On the first-party endpoint, server-side refusal fallbacks are on ("default" routing).
|
||||
*/
|
||||
export function createAnthropicProvider(o: AnthropicOptions): AiProvider {
|
||||
const doFetch = o.fetch ?? fetch;
|
||||
const base = (o.baseUrl || FIRST_PARTY).replace(/\/+$/, "");
|
||||
const firstParty = base === FIRST_PARTY;
|
||||
const rawByToolId = new Map<string, Block[]>();
|
||||
|
||||
const toApi = (messages: ChatMessage[]): { system: string; messages: ApiMessage[] } => {
|
||||
const system = messages.filter((m) => m.role === "system").map((m) => m.content).join("\n\n");
|
||||
const out: ApiMessage[] = [];
|
||||
const push = (role: "user" | "assistant", block: Block) => {
|
||||
const last = out[out.length - 1];
|
||||
if (last && last.role === role) last.content.push(block);
|
||||
else out.push({ role, content: [block] });
|
||||
};
|
||||
for (const m of messages) {
|
||||
if (m.role === "system") continue;
|
||||
if (m.role === "tool") push("user", { type: "tool_result", tool_use_id: m.toolCallId ?? "", content: m.content });
|
||||
else if (m.role === "assistant" && m.toolCallId) {
|
||||
let input: unknown = {};
|
||||
try { input = JSON.parse(m.content || "{}"); } catch { input = { value: m.content }; }
|
||||
push("assistant", { type: "tool_use", id: m.toolCallId, name: m.name ?? "", input });
|
||||
} else if (m.content) push(m.role === "assistant" ? "assistant" : "user", { type: "text", text: m.content });
|
||||
}
|
||||
// Replace assistant turns we produced with their exact original blocks (thinking signatures intact).
|
||||
for (const msg of out) {
|
||||
if (msg.role !== "assistant") continue;
|
||||
const id = msg.content.find((b) => b.type === "tool_use")?.id as string | undefined;
|
||||
const raw = id ? rawByToolId.get(id) : undefined;
|
||||
if (raw) msg.content = structuredClone(raw);
|
||||
}
|
||||
return { system, messages: out };
|
||||
};
|
||||
|
||||
return {
|
||||
id: "anthropic",
|
||||
async *chat(messages: ChatMessage[], opts: { tools?: ToolSpec[]; signal?: AbortSignal }) {
|
||||
const { system, messages: apiMessages } = toApi(messages);
|
||||
const body: Record<string, unknown> = {
|
||||
model: o.model || DEFAULT_ANTHROPIC_MODEL,
|
||||
max_tokens: o.maxTokens ?? 64000,
|
||||
stream: true,
|
||||
messages: apiMessages,
|
||||
};
|
||||
if (system) body.system = system;
|
||||
if (opts.tools?.length) body.tools = opts.tools.map((t) => ({ name: t.name, description: t.description, input_schema: t.inputSchema }));
|
||||
if (o.effort) body.output_config = { effort: o.effort };
|
||||
const headers: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
"x-api-key": o.apiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
};
|
||||
if (firstParty) {
|
||||
body.fallbacks = "default";
|
||||
headers["anthropic-beta"] = "server-side-fallback-2026-07-01";
|
||||
}
|
||||
const res = await doFetch(`${base}/v1/messages`, { method: "POST", headers, body: JSON.stringify(body), signal: opts.signal });
|
||||
if (!res.ok || !res.body) throw new Error(`Anthropic API error ${await errorMessage(res)}`);
|
||||
|
||||
const blocks: Block[] = [];
|
||||
const partialJson = new Map<number, string>();
|
||||
const toolIds: string[] = [];
|
||||
let stopReason: string | null = null;
|
||||
for await (const { data } of readSse(res.body)) {
|
||||
let ev: any;
|
||||
try { ev = JSON.parse(data); } catch { continue; }
|
||||
switch (ev.type) {
|
||||
case "content_block_start": {
|
||||
const b = { ...ev.content_block } as Block;
|
||||
blocks[ev.index] = b;
|
||||
if (b.type === "tool_use") partialJson.set(ev.index, "");
|
||||
break;
|
||||
}
|
||||
case "content_block_delta": {
|
||||
const b = blocks[ev.index];
|
||||
const d = ev.delta ?? {};
|
||||
if (!b) break;
|
||||
if (d.type === "text_delta") {
|
||||
b.text = String(b.text ?? "") + d.text;
|
||||
yield { text: d.text as string };
|
||||
} else if (d.type === "input_json_delta") partialJson.set(ev.index, (partialJson.get(ev.index) ?? "") + d.partial_json);
|
||||
else if (d.type === "thinking_delta") b.thinking = String(b.thinking ?? "") + d.thinking;
|
||||
else if (d.type === "signature_delta") b.signature = String(b.signature ?? "") + d.signature;
|
||||
else if (d.type === "citations_delta") b.citations = [...((b.citations as unknown[]) ?? []), d.citation];
|
||||
break;
|
||||
}
|
||||
case "content_block_stop": {
|
||||
const b = blocks[ev.index];
|
||||
if (b?.type === "tool_use") {
|
||||
const json = partialJson.get(ev.index) ?? "";
|
||||
let input: unknown = {};
|
||||
try { input = json ? JSON.parse(json) : (b.input ?? {}); } catch { input = { _invalidJson: json }; }
|
||||
b.input = input;
|
||||
toolIds.push(b.id as string);
|
||||
yield { toolCall: { id: b.id as string, name: b.name as string, input } };
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "message_delta":
|
||||
if (ev.delta?.stop_reason) stopReason = ev.delta.stop_reason;
|
||||
break;
|
||||
case "error":
|
||||
throw new Error(`Anthropic API error: ${ev.error?.message ?? "stream error"}`);
|
||||
}
|
||||
}
|
||||
const content = blocks.filter(Boolean);
|
||||
for (const id of toolIds) rawByToolId.set(id, content);
|
||||
if (stopReason === "refusal" && !content.some((b) => b.type === "text" && b.text)) {
|
||||
yield { text: "The model declined to answer this request." };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { AiProvider } from "@stc/contracts";
|
||||
import type { ProviderConfig } from "../settings";
|
||||
import { createAnthropicProvider } from "./anthropic";
|
||||
import { createOpenAiProvider } from "./openai";
|
||||
|
||||
export { createAnthropicProvider, DEFAULT_ANTHROPIC_MODEL } from "./anthropic";
|
||||
export { createOpenAiProvider } from "./openai";
|
||||
|
||||
export type ProviderFactory = (cfg: ProviderConfig, apiKey: string | null) => AiProvider;
|
||||
|
||||
export function createProvider(cfg: ProviderConfig, apiKey: string | null, fetchImpl?: typeof fetch): AiProvider {
|
||||
if (cfg.kind === "anthropic") {
|
||||
if (!apiKey) throw new Error("The Anthropic provider needs an API key");
|
||||
return createAnthropicProvider({ apiKey, model: cfg.model, baseUrl: cfg.baseUrl, effort: cfg.effort, fetch: fetchImpl });
|
||||
}
|
||||
if (!cfg.baseUrl || !cfg.model) throw new Error("An OpenAI-compatible provider needs a base URL and a model");
|
||||
return createOpenAiProvider({ baseUrl: cfg.baseUrl, model: cfg.model, apiKey, fetch: fetchImpl });
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { AiProvider, ChatMessage, ToolSpec } from "@stc/contracts";
|
||||
import { errorMessage, readSse } from "./sse";
|
||||
|
||||
export interface OpenAiOptions { baseUrl: string; model: string; apiKey?: string | null; fetch?: typeof fetch }
|
||||
|
||||
type OaMessage =
|
||||
| { role: "system" | "user"; content: string }
|
||||
| { role: "assistant"; content: string | null; tool_calls?: { id: string; type: "function"; function: { name: string; arguments: string } }[] }
|
||||
| { role: "tool"; tool_call_id: string; content: string };
|
||||
|
||||
function toOpenAi(messages: ChatMessage[]): OaMessage[] {
|
||||
const out: OaMessage[] = [];
|
||||
for (const m of messages) {
|
||||
if (m.role === "assistant" && m.toolCallId) {
|
||||
const call = { id: m.toolCallId, type: "function" as const, function: { name: m.name ?? "", arguments: m.content || "{}" } };
|
||||
const last = out[out.length - 1];
|
||||
if (last && last.role === "assistant") (last.tool_calls ??= []).push(call);
|
||||
else out.push({ role: "assistant", content: null, tool_calls: [call] });
|
||||
} else if (m.role === "tool") out.push({ role: "tool", tool_call_id: m.toolCallId ?? "", content: m.content });
|
||||
else out.push({ role: m.role, content: m.content } as OaMessage);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** OpenAI-compatible chat completions (OpenAI, Ollama, vLLM, LM Studio, OpenRouter …) with streaming tool calls. */
|
||||
export function createOpenAiProvider(o: OpenAiOptions): AiProvider {
|
||||
const doFetch = o.fetch ?? fetch;
|
||||
const base = o.baseUrl.replace(/\/+$/, "");
|
||||
return {
|
||||
id: "openai",
|
||||
async *chat(messages, opts: { tools?: ToolSpec[]; signal?: AbortSignal }) {
|
||||
const body: Record<string, unknown> = { model: o.model, stream: true, messages: toOpenAi(messages) };
|
||||
if (opts.tools?.length) body.tools = opts.tools.map((t) => ({ type: "function", function: { name: t.name, description: t.description, parameters: t.inputSchema } }));
|
||||
const headers: Record<string, string> = { "content-type": "application/json" };
|
||||
if (o.apiKey) headers.authorization = `Bearer ${o.apiKey}`;
|
||||
const res = await doFetch(`${base}/chat/completions`, { method: "POST", headers, body: JSON.stringify(body), signal: opts.signal });
|
||||
if (!res.ok || !res.body) throw new Error(`Chat endpoint error ${await errorMessage(res)}`);
|
||||
const pending = new Map<number, { id: string; name: string; args: string }>();
|
||||
const flush = function* () {
|
||||
for (const [, c] of [...pending].sort((a, b) => a[0] - b[0])) {
|
||||
let input: unknown = {};
|
||||
try { input = c.args ? JSON.parse(c.args) : {}; } catch { input = { _invalidJson: c.args }; }
|
||||
yield { toolCall: { id: c.id, name: c.name, input } };
|
||||
}
|
||||
pending.clear();
|
||||
};
|
||||
for await (const { data } of readSse(res.body)) {
|
||||
if (data.trim() === "[DONE]") break;
|
||||
let ev: any;
|
||||
try { ev = JSON.parse(data); } catch { continue; }
|
||||
if (ev.error) throw new Error(`Chat endpoint error: ${ev.error.message ?? JSON.stringify(ev.error)}`);
|
||||
const choice = ev.choices?.[0];
|
||||
if (!choice) continue;
|
||||
const d = choice.delta ?? {};
|
||||
if (typeof d.content === "string" && d.content) yield { text: d.content };
|
||||
for (const tc of d.tool_calls ?? []) {
|
||||
const idx = tc.index ?? 0;
|
||||
const cur = pending.get(idx) ?? { id: "", name: "", args: "" };
|
||||
if (tc.id) cur.id = tc.id;
|
||||
if (tc.function?.name) cur.name += tc.function.name;
|
||||
if (tc.function?.arguments) cur.args += tc.function.arguments;
|
||||
pending.set(idx, cur);
|
||||
}
|
||||
if (choice.finish_reason) yield* flush();
|
||||
}
|
||||
yield* flush();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/** Minimal Server-Sent Events reader: yields one {event, data} per blank-line-terminated record. */
|
||||
export async function* readSse(body: ReadableStream<Uint8Array>): AsyncIterable<{ event: string; data: string }> {
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
let event = "message";
|
||||
let data: string[] = [];
|
||||
const flushLine = function* (line: string): Generator<{ event: string; data: string }> {
|
||||
if (line === "") {
|
||||
if (data.length) yield { event, data: data.join("\n") };
|
||||
event = "message";
|
||||
data = [];
|
||||
return;
|
||||
}
|
||||
if (line.startsWith(":")) return;
|
||||
const i = line.indexOf(":");
|
||||
const field = i === -1 ? line : line.slice(0, i);
|
||||
let value = i === -1 ? "" : line.slice(i + 1);
|
||||
if (value.startsWith(" ")) value = value.slice(1);
|
||||
if (field === "event") event = value;
|
||||
else if (field === "data") data.push(value);
|
||||
};
|
||||
for await (const chunk of body as unknown as AsyncIterable<Uint8Array>) {
|
||||
buf += decoder.decode(chunk, { stream: true });
|
||||
let nl: number;
|
||||
while ((nl = buf.search(/\r\n|\n|\r/)) !== -1) {
|
||||
const line = buf.slice(0, nl);
|
||||
buf = buf.slice(nl + (buf.startsWith("\r\n", nl) ? 2 : 1));
|
||||
yield* flushLine(line);
|
||||
}
|
||||
}
|
||||
buf += decoder.decode();
|
||||
if (buf) yield* flushLine(buf);
|
||||
yield* flushLine("");
|
||||
}
|
||||
|
||||
export async function errorMessage(res: Response): Promise<string> {
|
||||
const text = await res.text().catch(() => "");
|
||||
try {
|
||||
const j = JSON.parse(text);
|
||||
const m = j?.error?.message ?? j?.message ?? j?.error;
|
||||
if (typeof m === "string") return `${res.status}: ${m}`;
|
||||
} catch {}
|
||||
return `${res.status}: ${text.slice(0, 300) || res.statusText}`;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
const STOP = new Set(
|
||||
"a an and are as at be but by for from has have i if in into is it its of on or so that the their then there these this to was were what when which who why will with you your".split(" "),
|
||||
);
|
||||
|
||||
/** Light plural folding so "apples" matches "apple". */
|
||||
const stem = (w: string): string =>
|
||||
w.length > 4 && w.endsWith("ies") ? `${w.slice(0, -3)}y` : w.length > 3 && w.endsWith("s") && !w.endsWith("ss") ? w.slice(0, -1) : w;
|
||||
|
||||
/** Lowercased word tokens without stopwords; camelCase and snake_case identifiers also add their parts. */
|
||||
export function tokenize(text: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const raw of text.match(/[\p{L}\p{N}_]+/gu) ?? []) {
|
||||
const lower = raw.toLowerCase();
|
||||
if (STOP.has(lower) || lower.length > 64) continue;
|
||||
out.push(stem(lower));
|
||||
const parts = raw.split(/_|(?<=[\p{Ll}\p{N}])(?=\p{Lu})/u).filter(Boolean);
|
||||
if (parts.length > 1) for (const p of parts) if (p.length > 1) out.push(stem(p.toLowerCase()));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type Doc<T> = { item: T; tf: Map<string, number>; len: number };
|
||||
|
||||
const termFreq = (tokens: string[]) => {
|
||||
const tf = new Map<string, number>();
|
||||
for (const t of tokens) tf.set(t, (tf.get(t) ?? 0) + 1);
|
||||
return tf;
|
||||
};
|
||||
|
||||
/** Okapi BM25 (k1 = 1.2, b = 0.75) over an in-memory document list. */
|
||||
export class Bm25<T> {
|
||||
private docs: Doc<T>[] = [];
|
||||
private df = new Map<string, number>();
|
||||
private totalLen = 0;
|
||||
|
||||
add(text: string, item: T): void {
|
||||
const tokens = tokenize(text);
|
||||
const tf = termFreq(tokens);
|
||||
for (const t of tf.keys()) this.df.set(t, (this.df.get(t) ?? 0) + 1);
|
||||
this.docs.push({ item, tf, len: tokens.length });
|
||||
this.totalLen += tokens.length;
|
||||
}
|
||||
|
||||
get size(): number { return this.docs.length; }
|
||||
|
||||
private idf(term: string): number {
|
||||
const n = this.docs.length;
|
||||
const df = this.df.get(term) ?? 0;
|
||||
return Math.log(1 + (n - df + 0.5) / (df + 0.5));
|
||||
}
|
||||
|
||||
private score(q: string[], tf: Map<string, number>, len: number): number {
|
||||
const avg = this.docs.length ? this.totalLen / this.docs.length : Math.max(1, len);
|
||||
let s = 0;
|
||||
for (const term of q) {
|
||||
const f = tf.get(term);
|
||||
if (!f) continue;
|
||||
s += this.idf(term) * ((f * 2.2) / (f + 1.2 * (0.25 + (0.75 * len) / avg)));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Top-k matches. `extra` documents are scored with this index's statistics without being added to it. */
|
||||
search(query: string, k: number, extra: { text: string; item: T }[] = []): { item: T; score: number }[] {
|
||||
const q = [...new Set(tokenize(query))];
|
||||
if (!q.length) return [];
|
||||
const results: { item: T; score: number }[] = [];
|
||||
for (const d of this.docs) {
|
||||
const s = this.score(q, d.tf, d.len);
|
||||
if (s > 0) results.push({ item: d.item, score: s });
|
||||
}
|
||||
for (const e of extra) {
|
||||
const tokens = tokenize(e.text);
|
||||
const s = this.score(q, termFreq(tokens), tokens.length);
|
||||
if (s > 0) results.push({ item: e.item, score: s });
|
||||
}
|
||||
return results.sort((a, b) => b.score - a.score).slice(0, k);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import { realpathSync } from "node:fs";
|
||||
import { readdir, stat } from "node:fs/promises";
|
||||
import { basename, extname, join, posix as posixPath, relative, sep } from "node:path";
|
||||
import { slugify, type CourseRepository, type LoadedCourse } from "@stc/course-format";
|
||||
import { Bm25 } from "./bm25";
|
||||
|
||||
export type ChunkKind = "lesson" | "transcript" | "markdown" | "code" | "deck" | "note";
|
||||
export interface Chunk {
|
||||
path: string; // course-relative path (for cache text: the deck or lesson it belongs to)
|
||||
kind: ChunkKind;
|
||||
text: string;
|
||||
heading?: string;
|
||||
lessonId?: string;
|
||||
lessonTitle?: string;
|
||||
}
|
||||
export interface CourseIndex { size: number; search(query: string, k: number, extra?: Chunk[]): Chunk[] }
|
||||
|
||||
const SKIP_DIRS = new Set([".git", "node_modules", "dist", "build", "target", ".astro", ".next", "coverage", "bin", "obj", ".venv", "__pycache__"]);
|
||||
const MD = new Set([".md", ".markdown"]);
|
||||
const CODE = new Set([".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".py", ".rs", ".scala", ".sc", ".fs", ".fsx", ".go", ".java", ".kt", ".rb", ".php", ".c", ".h", ".cpp", ".cs", ".sh", ".bash", ".ps1", ".sql", ".yaml", ".yml", ".toml", ".json", ".tf", ".html", ".css"]);
|
||||
const CODE_NAMES = new Set(["Dockerfile", "Makefile", "docker-compose.yml", "Containerfile"]);
|
||||
const CACHE_TEXT = new Set([".txt", ".md", ".vtt", ".srt"]);
|
||||
// Lock files, the manifest, and instructions written for AI agents (the mentor file goes into the system
|
||||
// prompt only while mentor mode is on, so it must not leak back in through retrieval).
|
||||
const SKIP_FILES = new Set(["package-lock.json", "bun.lock", "yarn.lock", "pnpm-lock.yaml", "Cargo.lock", "stcourse.yaml", "AGENTS.md", "CLAUDE.md", "GEMINI.md"]);
|
||||
const MAX_FILE = 200_000;
|
||||
const MAX_FILES = 2_000;
|
||||
const MAX_CHUNK = 1_500;
|
||||
const SOLUTION_SEGMENTS = new Set(["solution", "solutions", "reference"]);
|
||||
|
||||
const posix = (p: string) => p.split(sep).join("/");
|
||||
|
||||
/**
|
||||
* Files only authors may see: manifest `browser.solution` files and anything under a `solution`, `solutions`
|
||||
* or `reference` folder (Pure Snake `js/solutions/`, Systemic Design `reference/` and `expansion/solution/`).
|
||||
* The assistant never retrieves or reads them. `rel` is normalised first; for paths that come from a user or a
|
||||
* model, use `canonicalCoursePath` + `isGuardedFile`, which also follow symlinks.
|
||||
*/
|
||||
export function isSolutionPath(course: LoadedCourse, rel: string): boolean {
|
||||
const p = normalizeRel(rel);
|
||||
if (p === null) return true; // not a course-relative path at all: refuse
|
||||
if (p.split("/").slice(0, -1).some((s) => SOLUTION_SEGMENTS.has(s.toLowerCase()))) return true;
|
||||
if (/(^|\/)solution\.[a-z]+$/i.test(p)) return true;
|
||||
for (const m of course.manifest?.modules ?? []) for (const l of m.lessons) if (l.browser?.solution && normalizeRel(l.browser.solution) === p) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Posix-normalised course-relative path, or null when it is absolute or climbs out with `..`. */
|
||||
export function normalizeRel(raw: string): string | null {
|
||||
if (raw.includes("\u0000")) return null;
|
||||
const p = posixPath.normalize(raw.replace(/\\/g, "/")).replace(/^(\.\/)+/, "");
|
||||
if (!p || p === "." || p.startsWith("/") || /^[a-zA-Z]:/.test(p)) return null;
|
||||
if (p.split("/").includes("..")) return null;
|
||||
return p.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/** Undoes URL encoding (up to three layers). A malformed escape leaves the string as it is. */
|
||||
function decodeLayers(raw: string): string {
|
||||
let s = raw;
|
||||
for (let i = 0; i < 3 && /%[0-9a-fA-F]{2}/.test(s); i++) {
|
||||
try { s = decodeURIComponent(s); } catch { break; }
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical form of a path a user or the model asked for: URL-decoded, backslashes as slashes, posix-normalised,
|
||||
* leading `./` and `/` stripped, `..` rejected, then resolved through `CourseRepository.resolvePath` (real path,
|
||||
* symlinks followed, must stay inside the course root). `rel` is the real path relative to the real root.
|
||||
* Null when the path is invalid, escapes the course or does not exist.
|
||||
*/
|
||||
export function canonicalCoursePath(repo: CourseRepository, course: LoadedCourse, raw: string): { rel: string; abs: string } | null {
|
||||
const norm = normalizeRel(decodeLayers(raw.trim()).replace(/\\/g, "/").replace(/^\/+/, ""));
|
||||
if (!norm) return null;
|
||||
const abs = repo.resolvePath(course.id, norm);
|
||||
if (!abs) return null;
|
||||
let realRoot: string;
|
||||
try { realRoot = realpathSync(course.root); } catch { return null; }
|
||||
const rel = relative(realRoot, abs).split(sep).join("/");
|
||||
if (!rel || rel.startsWith("..")) return null;
|
||||
return { rel, abs };
|
||||
}
|
||||
|
||||
/**
|
||||
* The guard for tool reads, on canonical paths only: solution files (by the folder and name rules on the real
|
||||
* relative path, and by comparing real paths with every manifest `browser.solution`) and hidden files or folders.
|
||||
*/
|
||||
export function isGuardedFile(repo: CourseRepository, course: LoadedCourse, canonical: { rel: string; abs: string }): boolean {
|
||||
if (canonical.rel.split("/").some((s) => s.startsWith("."))) return true;
|
||||
if (isSolutionPath(course, canonical.rel)) return true;
|
||||
for (const m of course.manifest?.modules ?? []) {
|
||||
for (const l of m.lessons) {
|
||||
if (!l.browser?.solution) continue;
|
||||
const sol = repo.resolvePath(course.id, l.browser.solution);
|
||||
if (sol && sol === canonical.abs) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function walk(root: string, onFile: (abs: string, rel: string) => Promise<boolean>, depth = 0, base = root): Promise<boolean> {
|
||||
if (depth > 8) return true;
|
||||
let entries;
|
||||
try { entries = await readdir(root, { withFileTypes: true }); } catch { return true; }
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (const e of entries) {
|
||||
if (e.name.startsWith(".") && e.name !== ".") continue;
|
||||
const abs = join(root, e.name);
|
||||
if (e.isDirectory()) {
|
||||
if (SKIP_DIRS.has(e.name)) continue;
|
||||
if (!(await walk(abs, onFile, depth + 1, base))) return false;
|
||||
} else if (e.isFile()) {
|
||||
if (!(await onFile(abs, posix(relative(base, abs))))) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Splits Markdown at headings (outside code fences), then long sections at paragraph breaks. */
|
||||
export function splitMarkdown(text: string): { heading?: string; text: string }[] {
|
||||
const sections: { heading?: string; lines: string[] }[] = [{ lines: [] }];
|
||||
let fence = false;
|
||||
for (const line of text.split("\n")) {
|
||||
if (/^\s*(```|~~~)/.test(line)) fence = !fence;
|
||||
const h = !fence && /^(#{1,6})\s+(.*)$/.exec(line);
|
||||
if (h) sections.push({ heading: h[2]!.replace(/#+\s*$/, "").trim(), lines: [line] });
|
||||
else sections[sections.length - 1]!.lines.push(line);
|
||||
}
|
||||
const out: { heading?: string; text: string }[] = [];
|
||||
for (const s of sections) {
|
||||
const body = s.lines.join("\n").trim();
|
||||
if (!body) continue;
|
||||
out.push(...splitLong(body).map((t) => ({ heading: s.heading, text: t })));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function splitLong(text: string): string[] {
|
||||
if (text.length <= MAX_CHUNK) return [text];
|
||||
const out: string[] = [];
|
||||
let cur = "";
|
||||
for (const para of text.split(/\n{2,}/)) {
|
||||
if (cur && cur.length + para.length > MAX_CHUNK) { out.push(cur); cur = ""; }
|
||||
if (para.length > MAX_CHUNK) {
|
||||
for (let i = 0; i < para.length; i += MAX_CHUNK) out.push(para.slice(i, i + MAX_CHUNK));
|
||||
} else cur = cur ? `${cur}\n\n${para}` : para;
|
||||
}
|
||||
if (cur) out.push(cur);
|
||||
return out;
|
||||
}
|
||||
|
||||
type LessonRef = { lessonId: string; lessonTitle: string; anchor?: string; transcript: boolean };
|
||||
|
||||
function lessonRefs(course: LoadedCourse): Map<string, LessonRef[]> {
|
||||
const refs = new Map<string, LessonRef[]>();
|
||||
const add = (p: string | undefined, r: LessonRef) => {
|
||||
if (!p) return;
|
||||
const key = posix(p).replace(/^\.\//, "");
|
||||
refs.set(key, [...(refs.get(key) ?? []), r]);
|
||||
};
|
||||
for (const m of course.manifest?.modules ?? []) {
|
||||
for (const l of m.lessons) {
|
||||
const base = { lessonId: l.id, lessonTitle: l.title };
|
||||
if (l.type !== "video") add(l.file, { ...base, anchor: l.anchor, transcript: false });
|
||||
add(l.transcript, { ...base, transcript: true });
|
||||
if (l.hints?.file && l.hints.file !== l.file) add(l.hints.file, { ...base, transcript: false });
|
||||
for (const t of Object.values(l.tracks ?? {})) add(t.file, { ...base, transcript: false });
|
||||
}
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
function pickLesson(refs: LessonRef[] | undefined, heading?: string): LessonRef | undefined {
|
||||
if (!refs?.length) return undefined;
|
||||
if (heading) {
|
||||
const slug = slugify(heading);
|
||||
const hit = refs.find((r) => r.anchor && r.anchor === slug);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return refs.find((r) => !r.anchor) ?? refs[0];
|
||||
}
|
||||
|
||||
function lessonTitleOf(course: LoadedCourse, lessonId: string | undefined): string | undefined {
|
||||
return lessonId ? course.lessonIndex.get(lessonId)?.lesson.title : undefined;
|
||||
}
|
||||
|
||||
async function readJson<T>(path: string): Promise<T | null> {
|
||||
try {
|
||||
const f = Bun.file(path);
|
||||
if (!(await f.exists()) || f.size > MAX_FILE * 25) return null;
|
||||
return (await f.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const clock = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
|
||||
|
||||
export async function buildCourseIndex(course: LoadedCourse, dataDir: string): Promise<CourseIndex> {
|
||||
const bm = new Bm25<Chunk>();
|
||||
const seen = new Set<string>();
|
||||
const refs = lessonRefs(course);
|
||||
const mentor = course.manifest?.mentor ? posix(course.manifest.mentor).replace(/^\.\//, "") : null;
|
||||
const codeDirs = (course.manifest?.modules ?? []).flatMap((m) => (m.code ? [posix(m.code.dir).replace(/\/$/, "") + "/"] : []));
|
||||
const add = (c: Chunk) => {
|
||||
const key = `${c.kind}\u0000${c.text}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
bm.add([c.heading ?? "", c.lessonTitle ?? "", c.path, c.text].join("\n"), c);
|
||||
};
|
||||
|
||||
let files = 0;
|
||||
if (course.status === "ok") {
|
||||
await walk(course.root, async (abs, rel) => {
|
||||
if (files >= MAX_FILES) return false;
|
||||
const ext = extname(rel).toLowerCase();
|
||||
const name = basename(rel);
|
||||
const isMd = MD.has(ext);
|
||||
const isCode = !isMd && (CODE.has(ext) || CODE_NAMES.has(name) || codeDirs.some((d) => rel.startsWith(d)));
|
||||
if ((!isMd && !isCode) || SKIP_FILES.has(name) || rel === mentor || isSolutionPath(course, rel)) return true;
|
||||
let size = 0;
|
||||
try { size = (await stat(abs)).size; } catch { return true; }
|
||||
if (size > MAX_FILE || size === 0) return true;
|
||||
const text = await Bun.file(abs).text();
|
||||
if (isCode && text.includes("\u0000")) return true;
|
||||
files++;
|
||||
const r = refs.get(rel);
|
||||
if (isMd) {
|
||||
for (const part of splitMarkdown(text)) {
|
||||
const ref = pickLesson(r, part.heading);
|
||||
add({
|
||||
path: rel,
|
||||
kind: ref ? (ref.transcript ? "transcript" : "lesson") : "markdown",
|
||||
text: part.text,
|
||||
heading: part.heading,
|
||||
lessonId: ref?.lessonId,
|
||||
lessonTitle: ref?.lessonTitle,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const lines = text.split("\n");
|
||||
for (let i = 0; i < lines.length; i += 60) {
|
||||
add({ path: rel, kind: "code", text: lines.slice(i, i + 60).join("\n"), heading: lines.length > 60 ? `lines ${i + 1}-${Math.min(lines.length, i + 60)}` : undefined });
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const decks = new Map((course.manifest?.decks ?? []).map((d) => [d.id, d]));
|
||||
|
||||
// The importer's deck cache: <dataDir>/cache/decks/<courseId>/<deckId>/text.json = [{ page, text }].
|
||||
for (const deck of decks.values()) {
|
||||
const pages = await readJson<{ page?: number; text?: string }[]>(join(dataDir, "cache", "decks", course.id, deck.id, "text.json"));
|
||||
if (!Array.isArray(pages)) continue;
|
||||
const lessonId = course.lessonIndex.has(deck.attachTo) ? deck.attachTo : undefined;
|
||||
for (const p of pages) {
|
||||
const text = typeof p?.text === "string" ? p.text.trim() : "";
|
||||
if (!text) continue;
|
||||
for (const part of splitLong(text)) {
|
||||
add({ path: posix(deck.file), kind: "deck", text: part, heading: p.page ? `Slide ${p.page}` : deck.title, lessonId, lessonTitle: lessonTitleOf(course, lessonId) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The importer's transcription cache: <dataDir>/cache/transcripts/<courseId>/<lessonId>.json =
|
||||
// { lessonId, text, segments: [{ start, end, text }] }. Skipped when the lesson has a transcript file in the
|
||||
// course, which the transcription job writes alongside and which is indexed above.
|
||||
const trDir = join(dataDir, "cache", "transcripts", course.id);
|
||||
for (const name of await readdir(trDir).catch(() => [] as string[])) {
|
||||
if (extname(name) !== ".json") continue;
|
||||
const lessonId = name.slice(0, -5);
|
||||
const entry = course.lessonIndex.get(lessonId);
|
||||
if (!entry || entry.lesson.transcript) continue;
|
||||
const tr = await readJson<{ text?: string; segments?: { start?: number; text?: string }[] }>(join(trDir, name));
|
||||
if (!tr) continue;
|
||||
const segs = Array.isArray(tr.segments) ? tr.segments.filter((s) => typeof s?.text === "string" && s.text.trim()) : [];
|
||||
const blocks: { at: number; text: string }[] = [];
|
||||
if (segs.length) {
|
||||
for (const s of segs) {
|
||||
const last = blocks[blocks.length - 1];
|
||||
if (last && last.text.length + s.text!.length < MAX_CHUNK) last.text += ` ${s.text!.trim()}`;
|
||||
else blocks.push({ at: Number(s.start) || 0, text: s.text!.trim() });
|
||||
}
|
||||
} else if (typeof tr.text === "string" && tr.text.trim()) {
|
||||
blocks.push(...splitLong(tr.text.trim()).map((t) => ({ at: -1, text: t })));
|
||||
}
|
||||
for (const b of blocks) {
|
||||
add({ path: entry.lesson.file ? posix(entry.lesson.file) : `transcripts/${lessonId}`, kind: "transcript", text: b.text, heading: b.at >= 0 ? `at ${clock(b.at)}` : undefined, lessonId, lessonTitle: entry.lesson.title });
|
||||
}
|
||||
}
|
||||
|
||||
// Older layout: plain text under <dataDir>/cache/<courseId>/**.
|
||||
const cacheRoot = join(dataDir, "cache", course.id);
|
||||
await walk(cacheRoot, async (abs, rel) => {
|
||||
if (!CACHE_TEXT.has(extname(rel).toLowerCase())) return true;
|
||||
const size = (await stat(abs).catch(() => null))?.size ?? 0;
|
||||
if (!size || size > MAX_FILE * 5) return true;
|
||||
const text = await Bun.file(abs).text();
|
||||
const segs = rel.split("/");
|
||||
const deck = segs.map((s) => decks.get(s)).find(Boolean);
|
||||
const lessonSeg = segs.map((s) => s.replace(/\.[^.]+$/, "")).find((s) => course.lessonIndex.has(s));
|
||||
const page = /(?:page|slide)[-_ ]?(\d+)/i.exec(basename(rel))?.[1];
|
||||
if (deck) {
|
||||
const lessonId = course.lessonIndex.has(deck.attachTo) ? deck.attachTo : undefined;
|
||||
for (const part of splitLong(text.trim())) {
|
||||
add({ path: posix(deck.file), kind: "deck", text: part, heading: page ? `Slide ${Number(page)}` : deck.title, lessonId, lessonTitle: lessonTitleOf(course, lessonId) });
|
||||
}
|
||||
} else {
|
||||
const kind: ChunkKind = /transcript|\.vtt$|\.srt$/i.test(rel) || lessonSeg ? "transcript" : "deck";
|
||||
for (const part of splitLong(text.trim())) {
|
||||
add({ path: `cache/${rel}`, kind, text: part, lessonId: lessonSeg, lessonTitle: lessonTitleOf(course, lessonSeg) });
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return {
|
||||
get size() { return bm.size; },
|
||||
search(query, k, extra = []) {
|
||||
return bm.search(query, k, extra.map((c) => ({ text: [c.heading ?? "", c.lessonTitle ?? "", c.text].join("\n"), item: c }))).map((r) => r.item);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The user's notes as chunks, scored at query time and never cached. */
|
||||
export function noteChunks(course: LoadedCourse, notes: { id: string; lessonId: string; body: string; anchorKind: string; anchorValue: string | null }[]): Chunk[] {
|
||||
return notes.map((n) => ({
|
||||
path: "notes",
|
||||
kind: "note" as const,
|
||||
text: n.body,
|
||||
heading: n.anchorKind === "video" && n.anchorValue ? `at ${n.anchorValue}s` : n.anchorKind === "slide" && n.anchorValue ? `slide ${n.anchorValue}` : undefined,
|
||||
lessonId: n.lessonId,
|
||||
lessonTitle: lessonTitleOf(course, n.lessonId),
|
||||
}));
|
||||
}
|
||||
|
||||
const TTL_MS = 5 * 60_000;
|
||||
const cache = new WeakMap<LoadedCourse, { at: number; index: Promise<CourseIndex> }>();
|
||||
|
||||
/** Cached per LoadedCourse object: a repository reload yields new objects, and entries expire after 5 minutes. */
|
||||
export function getCourseIndex(course: LoadedCourse, dataDir: string): Promise<CourseIndex> {
|
||||
const hit = cache.get(course);
|
||||
if (hit && Date.now() - hit.at < TTL_MS) return hit.index;
|
||||
const index = buildCourseIndex(course, dataDir);
|
||||
cache.set(course, { at: Date.now(), index });
|
||||
index.catch(() => cache.delete(course));
|
||||
return index;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { Db } from "@stc/db";
|
||||
import type { SecretBox } from "./crypto";
|
||||
|
||||
export type ProviderKind = "anthropic" | "openai";
|
||||
export type Effort = "low" | "medium" | "high" | "xhigh" | "max";
|
||||
export interface ProviderConfig { kind: ProviderKind; model?: string; baseUrl?: string; effort?: Effort }
|
||||
export type PublicProviderConfig = ProviderConfig & { hasApiKey: boolean };
|
||||
|
||||
export interface McpServerConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
transport: "stdio" | "http";
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
/** Courses this server is offered in. Absent or empty: every course (global). */
|
||||
courseIds?: string[];
|
||||
}
|
||||
|
||||
/** `allowedBaseUrls`: origins learners' own keys (BYOK) may be sent to, besides the official endpoints. */
|
||||
export interface ServerSettings { provider: PublicProviderConfig | null; mcpServers: McpServerConfig[]; allowedBaseUrls: string[] }
|
||||
export interface ServerSettingsInput {
|
||||
provider: (ProviderConfig & { apiKey?: string; clearApiKey?: boolean }) | null;
|
||||
mcpServers: McpServerConfig[];
|
||||
/** Omitted: keep the saved list. */
|
||||
allowedBaseUrls?: string[];
|
||||
}
|
||||
export interface UserSettings { byok: PublicProviderConfig | null; mentorOff: Record<string, boolean> }
|
||||
|
||||
type ServerRow = { provider: ProviderConfig | null; apiKeyCipher: string | null; mcpServers: McpServerConfig[]; allowedBaseUrls?: string[] };
|
||||
type UserRow = { byok: ProviderConfig | null; mentorOff: Record<string, boolean> };
|
||||
|
||||
const pickProvider = (p: ProviderConfig): ProviderConfig => {
|
||||
const out: ProviderConfig = { kind: p.kind };
|
||||
if (p.model) out.model = p.model;
|
||||
if (p.baseUrl) out.baseUrl = p.baseUrl;
|
||||
if (p.effort) out.effort = p.effort;
|
||||
return out;
|
||||
};
|
||||
|
||||
const parse = <T>(s: string | undefined, fallback: T): T => {
|
||||
if (!s) return fallback;
|
||||
try { return { ...fallback, ...JSON.parse(s) }; } catch { return fallback; }
|
||||
};
|
||||
|
||||
/**
|
||||
* AI settings in the ai_settings and user_api_keys tables of 0001_init.sql.
|
||||
* - ('server', '') holds the admin provider (key encrypted) and MCP servers.
|
||||
* - ('user', <userId>) holds the BYOK provider choice and per-course mentor toggles. The frozen primary key
|
||||
* is (scope, owner_id), so the per-course flag lives in config_json; the mentor_off column means
|
||||
* "off for every course" and stays 0 (see docs/contract-requests/ai.md).
|
||||
* Keys never leave this module except through serverApiKey/userApiKey, which only server code calls.
|
||||
*/
|
||||
export function aiSettingsRepo(db: Db, box: SecretBox) {
|
||||
const read = async (scope: "server" | "user", owner: string): Promise<string | undefined> => {
|
||||
const rows = await db.sql`SELECT config_json FROM ai_settings WHERE scope = ${scope} AND owner_id = ${owner}`;
|
||||
return rows[0]?.config_json;
|
||||
};
|
||||
const write = async (scope: "server" | "user", owner: string, value: unknown) => {
|
||||
const json = JSON.stringify(value);
|
||||
const now = new Date().toISOString();
|
||||
await db.sql.begin(async (tx) => {
|
||||
const exists = await tx`SELECT 1 AS x FROM ai_settings WHERE scope = ${scope} AND owner_id = ${owner}`;
|
||||
if (exists.length) await tx`UPDATE ai_settings SET config_json = ${json}, updated_at = ${now} WHERE scope = ${scope} AND owner_id = ${owner}`;
|
||||
else await tx`INSERT INTO ai_settings (scope, owner_id, mentor_off, config_json, updated_at) VALUES (${scope}, ${owner}, 0, ${json}, ${now})`;
|
||||
});
|
||||
};
|
||||
const serverRow = async (): Promise<ServerRow> => parse(await read("server", ""), { provider: null, apiKeyCipher: null, mcpServers: [] });
|
||||
const userRow = async (userId: string): Promise<UserRow> => parse(await read("user", userId), { byok: null, mentorOff: {} });
|
||||
const keyRow = async (userId: string) =>
|
||||
(await db.sql`SELECT provider, ciphertext FROM user_api_keys WHERE user_id = ${userId}`)[0] as { provider: string; ciphertext: string } | undefined;
|
||||
|
||||
return {
|
||||
async getServer(): Promise<ServerSettings> {
|
||||
const r = await serverRow();
|
||||
return { provider: r.provider ? { ...r.provider, hasApiKey: !!r.apiKeyCipher } : null, mcpServers: r.mcpServers ?? [], allowedBaseUrls: r.allowedBaseUrls ?? [] };
|
||||
},
|
||||
async saveServer(input: ServerSettingsInput): Promise<void> {
|
||||
const prev = await serverRow();
|
||||
let apiKeyCipher: string | null = null;
|
||||
if (input.provider) {
|
||||
if (input.provider.apiKey) apiKeyCipher = await box.encrypt(input.provider.apiKey);
|
||||
else if (!input.provider.clearApiKey) apiKeyCipher = prev.apiKeyCipher;
|
||||
}
|
||||
await write("server", "", {
|
||||
provider: input.provider ? pickProvider(input.provider) : null,
|
||||
apiKeyCipher,
|
||||
mcpServers: input.mcpServers ?? [],
|
||||
allowedBaseUrls: input.allowedBaseUrls ?? prev.allowedBaseUrls ?? [],
|
||||
});
|
||||
},
|
||||
async serverApiKey(): Promise<string | null> {
|
||||
const r = await serverRow();
|
||||
return r.provider && r.apiKeyCipher ? box.decrypt(r.apiKeyCipher) : null;
|
||||
},
|
||||
async getUser(userId: string): Promise<UserSettings> {
|
||||
const r = await userRow(userId);
|
||||
const k = await keyRow(userId);
|
||||
return { byok: r.byok && k ? { ...r.byok, hasApiKey: true } : null, mentorOff: r.mentorOff ?? {} };
|
||||
},
|
||||
async saveUserKey(userId: string, cfg: ProviderConfig & { apiKey: string }): Promise<void> {
|
||||
const ciphertext = await box.encrypt(cfg.apiKey);
|
||||
const now = new Date().toISOString();
|
||||
await db.sql.begin(async (tx) => {
|
||||
await tx`DELETE FROM user_api_keys WHERE user_id = ${userId}`;
|
||||
await tx`INSERT INTO user_api_keys (user_id, provider, ciphertext, created_at) VALUES (${userId}, ${cfg.kind}, ${ciphertext}, ${now})`;
|
||||
});
|
||||
const r = await userRow(userId);
|
||||
await write("user", userId, { ...r, byok: pickProvider(cfg) });
|
||||
},
|
||||
async removeUserKey(userId: string): Promise<void> {
|
||||
await db.sql`DELETE FROM user_api_keys WHERE user_id = ${userId}`;
|
||||
const r = await userRow(userId);
|
||||
await write("user", userId, { ...r, byok: null });
|
||||
},
|
||||
async userApiKey(userId: string): Promise<string | null> {
|
||||
const k = await keyRow(userId);
|
||||
return k ? box.decrypt(k.ciphertext) : null;
|
||||
},
|
||||
async setMentorOff(userId: string, courseId: string, off: boolean): Promise<void> {
|
||||
const r = await userRow(userId);
|
||||
const mentorOff = { ...r.mentorOff };
|
||||
if (off) mentorOff[courseId] = true;
|
||||
else delete mentorOff[courseId];
|
||||
await write("user", userId, { ...r, mentorOff });
|
||||
},
|
||||
async isMentorOff(userId: string, courseId: string): Promise<boolean> {
|
||||
return (await userRow(userId)).mentorOff?.[courseId] === true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type AiSettingsRepo = ReturnType<typeof aiSettingsRepo>;
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Pill } from "@stc/ui";
|
||||
import { api, ApiError } from "./api";
|
||||
import { Field, PageHead } from "./components";
|
||||
|
||||
interface Account {
|
||||
byok: { kind: "anthropic" | "openai"; model?: string; baseUrl?: string; hasApiKey: boolean } | null;
|
||||
serverProvider: { kind: string; model: string | null } | null;
|
||||
}
|
||||
|
||||
export default function AccountAiPage() {
|
||||
const [acct, setAcct] = useState<Account | null>(null);
|
||||
const [denied, setDenied] = useState<string | null>(null);
|
||||
const [kind, setKind] = useState<"anthropic" | "openai">("anthropic");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [model, setModel] = useState("");
|
||||
const [baseUrl, setBaseUrl] = useState("");
|
||||
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
|
||||
const load = () => api<Account>("/api/ai/account").then((a) => {
|
||||
setAcct(a);
|
||||
if (a.byok) { setKind(a.byok.kind); setModel(a.byok.model ?? ""); setBaseUrl(a.byok.baseUrl ?? ""); }
|
||||
}, (e: ApiError) => setDenied(e.status === 401 ? "Sign in to manage your own AI key." : e.message));
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const save = async () => {
|
||||
setMsg(null);
|
||||
try {
|
||||
await api("/api/ai/account/key", { method: "PUT", body: { kind, apiKey, model, baseUrl } });
|
||||
setApiKey("");
|
||||
await load();
|
||||
setMsg({ ok: true, text: "Saved. Your questions now use your own key." });
|
||||
} catch (e) { setMsg({ ok: false, text: (e as Error).message }); }
|
||||
};
|
||||
const remove = async () => {
|
||||
await api("/api/ai/account/key", { method: "DELETE" });
|
||||
setApiKey("");
|
||||
await load();
|
||||
setMsg({ ok: true, text: "Your key is removed." });
|
||||
};
|
||||
|
||||
if (denied) return <div className="ai-page"><PageHead chap="Account" title="Your AI key" /><p className="ai-note">{denied}</p></div>;
|
||||
if (!acct) return <div className="ai-page"><PageHead chap="Account" title="Your AI key" /></div>;
|
||||
|
||||
return (
|
||||
<div className="ai-page">
|
||||
<PageHead chap="Account" title="Your AI key">
|
||||
<p>
|
||||
Bring your own key to use the course assistant and the studio generator with your own account. It is used only for your requests, stored encrypted, and never shown again.{" "}
|
||||
{acct.serverProvider ? "This server also has a shared provider; your key takes priority while it is saved." : "This server has no shared provider, so AI is off for you until you add a key."}
|
||||
</p>
|
||||
</PageHead>
|
||||
|
||||
{acct.byok ? <p className="ai-banner" role="status">A {acct.byok.kind === "anthropic" ? "Anthropic" : "OpenAI-compatible"} key is saved{acct.byok.model ? ` (model ${acct.byok.model})` : ""}.</p> : null}
|
||||
|
||||
<form onSubmit={(e) => { e.preventDefault(); save(); }}>
|
||||
<div className="ai-grid">
|
||||
<Field label="Provider">
|
||||
<select className="ai-select" value={kind} onChange={(e) => setKind(e.target.value as "anthropic" | "openai")}>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="openai">OpenAI-compatible</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Model" hint={kind === "anthropic" ? "Leave empty for claude-opus-5-5." : undefined}>
|
||||
<input className="ai-input" value={model} onChange={(e) => setModel(e.target.value)} placeholder={kind === "anthropic" ? "claude-opus-5-5" : "model name"} />
|
||||
</Field>
|
||||
</div>
|
||||
{kind === "openai" ? (
|
||||
<Field label="Base URL (optional)" hint="Leave empty for api.openai.com. Other endpoints work only if an admin allowed them.">
|
||||
<input className="ai-input ai-mono" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder="https://api.openai.com/v1" />
|
||||
</Field>
|
||||
) : null}
|
||||
<Field label="API key" hint={acct.byok ? "Paste a new key to replace the saved one." : undefined}>
|
||||
<input className="ai-input ai-mono" type="password" autoComplete="off" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="paste key" />
|
||||
</Field>
|
||||
<div className="ai-footer">
|
||||
<Pill variant="primary" type="submit" disabled={!apiKey.trim()}>Save key</Pill>
|
||||
{acct.byok ? <Pill onClick={remove}>Remove my key</Pill> : null}
|
||||
{msg ? <span className={msg.ok ? "ai-hint ai-ok" : "ai-error"} role="status" style={{ margin: 0 }}>{msg.text}</span> : null}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Pill } from "@stc/ui";
|
||||
import { api, ApiError } from "./api";
|
||||
import { Field, PageHead } from "./components";
|
||||
|
||||
type Kind = "" | "anthropic" | "openai";
|
||||
interface ProviderOut { kind: "anthropic" | "openai"; model?: string; baseUrl?: string; effort?: string; hasApiKey: boolean }
|
||||
interface McpServer { id: string; name: string; transport: "stdio" | "http"; command?: string; args?: string[]; env?: Record<string, string>; url?: string; headers?: Record<string, string>; courseIds?: string[] }
|
||||
interface Config { provider: ProviderOut | null; mcpServers: McpServer[]; allowedBaseUrls: string[] }
|
||||
|
||||
type ServerDraft = { id: string; name: string; transport: "stdio" | "http"; command: string; args: string; env: string; url: string; headers?: Record<string, string>; courses: string; test?: string };
|
||||
const toDraft = (s: McpServer): ServerDraft => ({
|
||||
id: s.id, name: s.name, transport: s.transport, command: s.command ?? "", args: (s.args ?? []).join(" "),
|
||||
env: Object.entries(s.env ?? {}).map(([k, v]) => `${k}=${v}`).join("\n"), url: s.url ?? "", headers: s.headers, courses: (s.courseIds ?? []).join(", "),
|
||||
});
|
||||
const parseEnv = (text: string): Record<string, string> =>
|
||||
Object.fromEntries(text.split("\n").map((l) => l.trim()).filter((l) => l.includes("=")).map((l) => [l.slice(0, l.indexOf("=")).trim(), l.slice(l.indexOf("=") + 1)]));
|
||||
const fromDraft = (d: ServerDraft): McpServer => ({
|
||||
id: d.id, name: d.name || d.id, transport: d.transport,
|
||||
...(d.transport === "stdio" ? { command: d.command, args: d.args.trim() ? d.args.trim().split(/\s+/) : [], env: parseEnv(d.env) } : { url: d.url, ...(d.headers ? { headers: d.headers } : {}) }),
|
||||
courseIds: d.courses.split(",").map((s) => s.trim()).filter(Boolean),
|
||||
});
|
||||
|
||||
export default function AdminAiPage() {
|
||||
const [loaded, setLoaded] = useState<Config | null>(null);
|
||||
const [denied, setDenied] = useState<string | null>(null);
|
||||
const [kind, setKind] = useState<Kind>("");
|
||||
const [model, setModel] = useState("");
|
||||
const [baseUrl, setBaseUrl] = useState("");
|
||||
const [effort, setEffort] = useState("");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [clearKey, setClearKey] = useState(false);
|
||||
const [servers, setServers] = useState<ServerDraft[]>([]);
|
||||
const [allowed, setAllowed] = useState("");
|
||||
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
|
||||
const apply = (c: Config) => {
|
||||
setLoaded(c);
|
||||
setKind(c.provider?.kind ?? "");
|
||||
setModel(c.provider?.model ?? "");
|
||||
setBaseUrl(c.provider?.baseUrl ?? "");
|
||||
setEffort(c.provider?.effort ?? "");
|
||||
setApiKey("");
|
||||
setClearKey(false);
|
||||
setServers(c.mcpServers.map(toDraft));
|
||||
setAllowed((c.allowedBaseUrls ?? []).join("\n"));
|
||||
};
|
||||
useEffect(() => {
|
||||
api<Config>("/api/ai/admin/config").then(apply, (e: ApiError) => setDenied(e.status === 401 ? "Sign in as the owner to change AI settings." : e.message));
|
||||
}, []);
|
||||
|
||||
const save = async () => {
|
||||
setMsg(null);
|
||||
try {
|
||||
const provider = kind ? { kind, model, baseUrl, effort, apiKey: apiKey || undefined, clearApiKey: clearKey } : null;
|
||||
apply(await api<Config>("/api/ai/admin/config", { method: "PUT", body: { provider, mcpServers: servers.map(fromDraft), allowedBaseUrls: allowed.split(/[\s,]+/).filter(Boolean) } }));
|
||||
setMsg({ ok: true, text: kind ? "Saved. The assistant is available to learners." : "Saved. AI is off for everyone without their own key." });
|
||||
} catch (e) {
|
||||
setMsg({ ok: false, text: (e as Error).message });
|
||||
}
|
||||
};
|
||||
|
||||
const setServer = (i: number, patch: Partial<ServerDraft>) => setServers((s) => s.map((x, j) => (j === i ? { ...x, ...patch } : x)));
|
||||
const testServer = async (i: number) => {
|
||||
setServer(i, { test: "Connecting…" });
|
||||
try {
|
||||
const r = await api<{ ok: boolean; tools?: { name: string }[]; error?: string }>("/api/ai/admin/mcp/test", { method: "POST", body: fromDraft(servers[i]!) });
|
||||
setServer(i, { test: r.ok ? `Connected. Tools: ${r.tools!.map((t) => t.name).join(", ") || "none"}` : `Failed: ${r.error}` });
|
||||
} catch (e) {
|
||||
setServer(i, { test: `Failed: ${(e as Error).message}` });
|
||||
}
|
||||
};
|
||||
|
||||
if (denied) return <div className="ai-page"><PageHead title="AI settings" /><p className="ai-note">{denied}</p></div>;
|
||||
if (!loaded) return <div className="ai-page"><PageHead title="AI settings" /></div>;
|
||||
|
||||
return (
|
||||
<div className="ai-page">
|
||||
<PageHead title="AI settings">
|
||||
<p>AI is off until you choose a provider here. Learners and authors can also add their own key, which is used only for their own requests. Keys are encrypted on the server and never shown again.</p>
|
||||
</PageHead>
|
||||
|
||||
<form onSubmit={(e) => { e.preventDefault(); save(); }}>
|
||||
<section className="ai-section">
|
||||
<h2>Server provider</h2>
|
||||
<span className="ai-hint">Used for everyone who has not added their own key.</span>
|
||||
<div className="ai-grid">
|
||||
<Field label="Provider">
|
||||
<select className="ai-select" value={kind} onChange={(e) => setKind(e.target.value as Kind)}>
|
||||
<option value="">None (AI off)</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="openai">OpenAI-compatible (OpenAI, Ollama, vLLM …)</option>
|
||||
</select>
|
||||
</Field>
|
||||
{kind ? (
|
||||
<Field label="Model" hint={kind === "anthropic" ? "Leave empty for claude-opus-5-5." : "For example llama3.1 on Ollama."}>
|
||||
<input className="ai-input" value={model} onChange={(e) => setModel(e.target.value)} placeholder={kind === "anthropic" ? "claude-opus-5-5" : "model name"} />
|
||||
</Field>
|
||||
) : null}
|
||||
</div>
|
||||
{kind ? (
|
||||
<>
|
||||
<Field label={kind === "anthropic" ? "Base URL (optional)" : "Base URL"} hint={kind === "openai" ? "The address that ends before /chat/completions, for example http://ollama:11434/v1." : "Leave empty for api.anthropic.com."}>
|
||||
<input className="ai-input ai-mono" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder={kind === "openai" ? "http://localhost:11434/v1" : "https://api.anthropic.com"} />
|
||||
</Field>
|
||||
<div className="ai-grid">
|
||||
<Field label="API key" hint={loaded.provider?.hasApiKey ? "A key is saved. Leave empty to keep it." : kind === "openai" ? "Optional for a local Ollama." : undefined}>
|
||||
<input className="ai-input ai-mono" type="password" autoComplete="off" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder={loaded.provider?.hasApiKey ? "••••••••" : "paste key"} />
|
||||
</Field>
|
||||
{kind === "anthropic" ? (
|
||||
<Field label="Effort" hint="How hard the model thinks. Empty uses the model's default.">
|
||||
<select className="ai-select" value={effort} onChange={(e) => setEffort(e.target.value)}>
|
||||
<option value="">Default</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
</Field>
|
||||
) : null}
|
||||
</div>
|
||||
{loaded.provider?.hasApiKey ? (
|
||||
<label className="ai-check"><input type="checkbox" checked={clearKey} onChange={(e) => setClearKey(e.target.checked)} /> Remove the saved key</label>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="ai-section">
|
||||
<h2>Endpoints for learners' own keys</h2>
|
||||
<span className="ai-hint">Own keys go to api.anthropic.com or api.openai.com only. To let people use another endpoint, such as a shared Ollama, list its origin here (one per line, for example http://ollama.internal:11434). STC_AI_ALLOWED_BASE_URLS adds more.</span>
|
||||
<Field label="Allowed origins">
|
||||
<textarea className="ai-input ai-mono" rows={2} value={allowed} onChange={(e) => setAllowed(e.target.value)} placeholder="http://ollama.internal:11434" />
|
||||
</Field>
|
||||
</section>
|
||||
|
||||
<section className="ai-section">
|
||||
<h2>MCP servers</h2>
|
||||
<span className="ai-hint">Extra tools for the assistant. Leave "Courses" empty to offer a server in every course.</span>
|
||||
{servers.map((s, i) => (
|
||||
<div className="ai-server" key={i}>
|
||||
<div className="ai-grid">
|
||||
<Field label="Id"><input className="ai-input ai-mono" value={s.id} onChange={(e) => setServer(i, { id: e.target.value })} /></Field>
|
||||
<Field label="Name"><input className="ai-input" value={s.name} onChange={(e) => setServer(i, { name: e.target.value })} /></Field>
|
||||
<Field label="Transport">
|
||||
<select className="ai-select" value={s.transport} onChange={(e) => setServer(i, { transport: e.target.value as "stdio" | "http" })}>
|
||||
<option value="http">Streamable HTTP</option>
|
||||
<option value="stdio">Local command (stdio)</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
{s.transport === "http" ? (
|
||||
<Field label="URL"><input className="ai-input ai-mono" value={s.url} onChange={(e) => setServer(i, { url: e.target.value })} placeholder="https://mcp.example.com/mcp" /></Field>
|
||||
) : (
|
||||
<div className="ai-grid">
|
||||
<Field label="Command"><input className="ai-input ai-mono" value={s.command} onChange={(e) => setServer(i, { command: e.target.value })} placeholder="bunx" /></Field>
|
||||
<Field label="Arguments"><input className="ai-input ai-mono" value={s.args} onChange={(e) => setServer(i, { args: e.target.value })} placeholder="some-mcp-server --flag" /></Field>
|
||||
</div>
|
||||
)}
|
||||
{s.transport === "stdio" ? (
|
||||
<Field label="Environment" hint="One NAME=value per line. The command gets only PATH, HOME, LANG and these; server secrets are never passed on.">
|
||||
<textarea className="ai-input ai-mono" rows={2} value={s.env} onChange={(e) => setServer(i, { env: e.target.value })} placeholder="API_TOKEN=…" />
|
||||
</Field>
|
||||
) : null}
|
||||
<Field label="Courses" hint="Course ids, separated by commas."><input className="ai-input ai-mono" value={s.courses} onChange={(e) => setServer(i, { courses: e.target.value })} /></Field>
|
||||
<div className="ai-footer" style={{ margin: "0 0 10px" }}>
|
||||
<Pill onClick={() => testServer(i)}>Test connection</Pill>
|
||||
<Pill variant="ghost" onClick={() => setServers((x) => x.filter((_, j) => j !== i))}>Remove</Pill>
|
||||
{s.test ? <span className="ai-hint">{s.test}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="ai-footer">
|
||||
<Pill onClick={() => setServers((x) => [...x, { id: `server-${x.length + 1}`, name: "", transport: "http", command: "", args: "", env: "", url: "", courses: "" }])}>Add MCP server</Pill>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="ai-footer">
|
||||
<Pill variant="primary" type="submit">Save AI settings</Pill>
|
||||
{msg ? <span className={msg.ok ? "ai-hint ai-ok" : "ai-error"} role="status" style={{ margin: 0 }}>{msg.text}</span> : null}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { Pill, SegmentedToggle } from "@stc/ui";
|
||||
import { api, readNdjson, type ChatEvent, type Citation, type Status } from "./api";
|
||||
import { courseUrl, lessonUrl } from "./urls";
|
||||
import { Markdown, MentorBanner, PageHead } from "./components";
|
||||
|
||||
type Turn = { role: "user" | "assistant"; text: string; citations?: Citation[]; tools?: string[]; error?: string; notices?: string[] };
|
||||
type ConvSummary = { id: string; title: string | null; createdAt: string };
|
||||
|
||||
const TOOL_LABEL: Record<string, string> = { search_course: "searched the course", read_lesson: "read a lesson", read_file: "read a file", list_notes: "read your notes" };
|
||||
const toolLabel = (n: string) => TOOL_LABEL[n] ?? `used ${n.replace(/^mcp_[^_]+__/, "")}`;
|
||||
|
||||
function Sources({ courseId, citations }: { courseId: string; citations: Citation[] }) {
|
||||
// One row per file; a file shared by several lessons links to the first one.
|
||||
const rows = [...new Map(citations.map((c) => [c.path, c] as const)).values()];
|
||||
if (!rows.length) return null;
|
||||
return (
|
||||
<div className="ai-sources">
|
||||
<span>Sources</span>
|
||||
<ul className="ai-rows">
|
||||
{rows.map((c) => (
|
||||
<li key={c.path}>
|
||||
<span className="ai-path">{c.path}</span>
|
||||
{c.lessonId ? <Link className="ai-meta" to={lessonUrl(courseId, c.lessonId)}>{c.lessonTitle ?? c.lessonId}</Link> : <span className="ai-meta">{c.kind}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AssistantPage() {
|
||||
const { courseId = "" } = useParams();
|
||||
const [status, setStatus] = useState<Status | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [turns, setTurns] = useState<Turn[]>([]);
|
||||
const [conversationId, setConversationId] = useState<string | null>(null);
|
||||
const [history, setHistory] = useState<ConvSummary[]>([]);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const lessonId = new URLSearchParams(typeof location === "undefined" ? "" : location.search).get("lesson") ?? undefined;
|
||||
|
||||
const loadHistory = useCallback(async () => {
|
||||
try { setHistory(await api<ConvSummary[]>(`/api/ai/courses/${encodeURIComponent(courseId)}/conversations`)); } catch { setHistory([]); }
|
||||
}, [courseId]);
|
||||
|
||||
useEffect(() => {
|
||||
api<Status>(`/api/ai/status?courseId=${encodeURIComponent(courseId)}`).then(setStatus, (e) => setLoadError(e.message));
|
||||
loadHistory();
|
||||
const linked = new URLSearchParams(location.search).get("c");
|
||||
if (linked) openConversation(linked).catch(() => {});
|
||||
return () => abortRef.current?.abort();
|
||||
}, [courseId, loadHistory]);
|
||||
|
||||
const mentor = status?.mentor ?? { available: false, on: true };
|
||||
|
||||
const setMentor = async (on: boolean) => {
|
||||
if (!status) return;
|
||||
setStatus({ ...status, mentor: { ...mentor, on } });
|
||||
try {
|
||||
const r = await api<{ mentor: Status["mentor"] }>(`/api/ai/courses/${encodeURIComponent(courseId)}/mentor`, { method: "PUT", body: { off: !on } });
|
||||
setStatus((s) => (s ? { ...s, mentor: r.mentor } : s));
|
||||
} catch (e) {
|
||||
setStatus({ ...status, mentor });
|
||||
setLoadError((e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
async function openConversation(id: string) {
|
||||
const c = await api<{ id: string; messages: { role: "user" | "assistant"; content: string; citations?: Citation[] }[] }>(`/api/ai/conversations/${id}`);
|
||||
setConversationId(c.id);
|
||||
setTurns(c.messages.map((m) => ({ role: m.role, text: m.content, citations: m.citations })));
|
||||
}
|
||||
const deleteConversation = async (id: string) => {
|
||||
await api(`/api/ai/conversations/${id}`, { method: "DELETE" });
|
||||
if (id === conversationId) { setConversationId(null); setTurns([]); }
|
||||
loadHistory();
|
||||
};
|
||||
|
||||
const send = async () => {
|
||||
const message = draft.trim();
|
||||
if (!message || busy) return;
|
||||
setDraft("");
|
||||
setBusy(true);
|
||||
const update = (fn: (t: Turn) => Turn) => setTurns((ts) => [...ts.slice(0, -1), fn(ts[ts.length - 1]!)]);
|
||||
setTurns((ts) => [...ts, { role: "user", text: message }, { role: "assistant", text: "", tools: [] }]);
|
||||
const ctl = new AbortController();
|
||||
abortRef.current = ctl;
|
||||
try {
|
||||
const res = await fetch(`/api/ai/courses/${encodeURIComponent(courseId)}/chat`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ message, conversationId, lessonId }),
|
||||
signal: ctl.signal,
|
||||
});
|
||||
if (!res.ok || !res.body) {
|
||||
let msg = res.statusText;
|
||||
try { msg = (await res.json()).error ?? msg; } catch {}
|
||||
update((t) => ({ ...t, error: msg }));
|
||||
return;
|
||||
}
|
||||
for await (const ev of readNdjson<ChatEvent>(res.body)) {
|
||||
if (ev.type === "meta") { setConversationId(ev.conversationId); update((t) => ({ ...t, citations: ev.citations })); }
|
||||
else if (ev.type === "text") update((t) => ({ ...t, text: t.text + ev.text }));
|
||||
else if (ev.type === "tool") update((t) => ({ ...t, tools: [...(t.tools ?? []), ev.name] }));
|
||||
else if (ev.type === "notice") update((t) => ({ ...t, notices: [...(t.notices ?? []), ev.message] }));
|
||||
else if (ev.type === "error") update((t) => ({ ...t, error: ev.message }));
|
||||
else if (ev.type === "done") update((t) => ({ ...t, text: ev.text || t.text, citations: ev.citations }));
|
||||
}
|
||||
loadHistory();
|
||||
} catch (e) {
|
||||
if ((e as Error).name !== "AbortError") update((t) => ({ ...t, error: (e as Error).message }));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
abortRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const newConversation = () => { abortRef.current?.abort(); setConversationId(null); setTurns([]); };
|
||||
|
||||
const courseTitle = status?.course?.title ?? courseId;
|
||||
const chap = <Link to={courseUrl(courseId)}>{courseTitle}</Link>;
|
||||
|
||||
if (loadError && !status) return <div className="ai-page"><PageHead chap={chap} title="Course assistant" /><p className="ai-error">{loadError}</p></div>;
|
||||
if (!status) return <div className="ai-page"><PageHead chap={chap} title="Course assistant" /></div>;
|
||||
if (!status.course) return <div className="ai-page"><PageHead title="Course assistant" /><p className="ai-note">This course was not found.</p></div>;
|
||||
if (!status.signedIn) {
|
||||
return (
|
||||
<div className="ai-page">
|
||||
<PageHead chap={chap} title="Course assistant" />
|
||||
<p className="ai-note">Sign in to ask the assistant about this course.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!status.enabled) {
|
||||
return (
|
||||
<div className="ai-page">
|
||||
<PageHead chap={chap} title="Course assistant" />
|
||||
<p className="ai-note">
|
||||
The assistant is off. AI stays off until an admin sets up a server provider, or you add your own API key in <Link to="/account/ai">Account, AI</Link>. Your key is used only for your own questions.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ai-page">
|
||||
<PageHead chap={chap} title="Course assistant">
|
||||
<p>Answers come from this course's lessons, transcripts, slides, code and your notes, with the sources listed.{status.source === "byok" ? " Using your own key." : ""}</p>
|
||||
</PageHead>
|
||||
|
||||
{mentor.available ? (
|
||||
<div className="ai-bar">
|
||||
<SegmentedToggle label="Mentor mode" value={mentor.on ? "on" : "off"} onChange={(v) => setMentor(v === "on")} options={[{ value: "on", label: "Mentor on" }, { value: "off", label: "Mentor off" }]} />
|
||||
<span className="ai-hint">{mentor.on ? "Follows the course's tutoring rules: questions and hints first, no full solutions." : "The course's tutoring rules are off for you in this course."}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<MentorBanner on={mentor.on} available={mentor.available} />
|
||||
{loadError ? <p className="ai-error">{loadError}</p> : null}
|
||||
|
||||
{turns.length ? (
|
||||
<ol className="ai-thread" aria-live="polite">
|
||||
{turns.map((t, i) => (
|
||||
<li key={i} className={`ai-turn ai-turn--${t.role}`}>
|
||||
<span className="ai-who">{t.role === "user" ? "You" : "Assistant"}</span>
|
||||
{t.text ? <Markdown text={t.text} /> : t.role === "assistant" && busy && i === turns.length - 1 && !t.error ? <p className="ai-activity">Thinking…</p> : null}
|
||||
{t.tools?.length ? <p className="ai-activity">{[...new Set(t.tools.map(toolLabel))].join(", ").replace(/^./, (c) => c.toUpperCase())}.</p> : null}
|
||||
{t.notices?.map((n) => <p key={n} className="ai-activity">{n}</p>)}
|
||||
{t.error ? <p className="ai-error">{t.error}</p> : null}
|
||||
{t.role === "assistant" && t.citations ? <Sources courseId={courseId} citations={t.citations} /> : null}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
<p className="ai-note">Ask about anything in the course: an idea you are unsure of, a failing test, or "quiz me on this module".</p>
|
||||
)}
|
||||
|
||||
<form className="ai-composer" onSubmit={(e) => { e.preventDefault(); send(); }}>
|
||||
<label className="sr" htmlFor="ai-message">Your question</label>
|
||||
<textarea id="ai-message" className="ai-textarea" value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="Ask a question about this course" rows={4} />
|
||||
<div className="ai-actions">
|
||||
{turns.length ? <Pill variant="ghost" onClick={newConversation}>New conversation</Pill> : null}
|
||||
{busy ? <Pill onClick={() => abortRef.current?.abort()}>Stop</Pill> : null}
|
||||
<Pill variant="primary" type="submit" disabled={busy || !draft.trim()}>Send</Pill>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{history.length ? (
|
||||
<section className="ai-history">
|
||||
<h2>Earlier conversations</h2>
|
||||
<ul className="ai-rows">
|
||||
{history.map((h) => (
|
||||
<li key={h.id}>
|
||||
<button type="button" className="ai-linkbtn" aria-current={h.id === conversationId} onClick={() => openConversation(h.id)}>{h.title || "Untitled"}</button>
|
||||
<Pill variant="ghost" onClick={() => deleteConversation(h.id)} aria-label={`Delete conversation ${h.title ?? ""}`}>Delete</Pill>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Pill, ProgressBar, SegmentedToggle } from "@stc/ui";
|
||||
import { api, ApiError, type Status } from "./api";
|
||||
import { Field, PageHead } from "./components";
|
||||
|
||||
interface CourseOpt { id: string; title: string }
|
||||
interface Sources { lessons: { id: string; title: string; type: string; module: string; hasText: boolean }[]; files: string[]; cache: string[] }
|
||||
interface Job { id: string; status: "queued" | "running" | "done" | "failed"; progress: number; message: string | null; error: string | null; result: { location: string; branch?: string; commit?: string; paths: string[]; warnings: string[] } | null }
|
||||
|
||||
const KINDS = [
|
||||
{ value: "document", label: "Document" },
|
||||
{ value: "exercise", label: "Exercise" },
|
||||
{ value: "questions", label: "Questions" },
|
||||
];
|
||||
const STYLES = [
|
||||
{ value: "pure-snake", label: "Module shape" },
|
||||
{ value: "systemic-design", label: "Observe to Expansion Drop" },
|
||||
];
|
||||
const STYLE_HINT: Record<string, string> = {
|
||||
"pure-snake": "Pure Snake's shape: why, objectives, concepts, a puzzle before the explanation, exercises with nudge, stronger hint and near-solution, and check questions.",
|
||||
"systemic-design": "Systemic Design's beats: Observe, Predict, Spec, Build, Name it, Expansion Drop, Further reading.",
|
||||
};
|
||||
|
||||
export default function GeneratePage() {
|
||||
const [courses, setCourses] = useState<CourseOpt[] | null>(null);
|
||||
const [denied, setDenied] = useState<string | null>(null);
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [courseId, setCourseId] = useState("");
|
||||
const [src, setSrc] = useState<Sources | null>(null);
|
||||
const [kind, setKind] = useState("document");
|
||||
const [style, setStyle] = useState("pure-snake");
|
||||
const [topic, setTopic] = useState("");
|
||||
const [instructions, setInstructions] = useState("");
|
||||
const [lessons, setLessons] = useState<Set<string>>(new Set());
|
||||
const [files, setFiles] = useState<Set<string>>(new Set());
|
||||
const [cache, setCache] = useState<Set<string>>(new Set());
|
||||
const [pasted, setPasted] = useState("");
|
||||
const [filter, setFilter] = useState("");
|
||||
const [job, setJob] = useState<Job | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api<CourseOpt[]>("/api/ai/studio/courses").then((c) => {
|
||||
setCourses(c);
|
||||
if (c[0]) setCourseId(c[0].id);
|
||||
}, (e: ApiError) => setDenied(e.status === 401 ? "Sign in as an author to generate drafts." : e.message));
|
||||
api<Status>("/api/ai/status").then((s) => setEnabled(s.enabled), () => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!courseId) return;
|
||||
setSrc(null);
|
||||
setLessons(new Set()); setFiles(new Set()); setCache(new Set());
|
||||
api<Sources>(`/api/ai/studio/sources?courseId=${encodeURIComponent(courseId)}`).then(setSrc, (e) => setError(e.message));
|
||||
}, [courseId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!job || job.status === "done" || job.status === "failed") return;
|
||||
const t = setTimeout(() => api<Job>(`/api/ai/studio/jobs/${job.id}`).then(setJob, (e) => setError(e.message)), 1000);
|
||||
return () => clearTimeout(t);
|
||||
}, [job]);
|
||||
|
||||
const toggle = (set: Set<string>, setter: (s: Set<string>) => void, v: string) => {
|
||||
const n = new Set(set);
|
||||
if (n.has(v)) n.delete(v); else n.add(v);
|
||||
setter(n);
|
||||
};
|
||||
const byModule = useMemo(() => {
|
||||
const m = new Map<string, Sources["lessons"]>();
|
||||
for (const l of src?.lessons ?? []) m.set(l.module, [...(m.get(l.module) ?? []), l]);
|
||||
return [...m];
|
||||
}, [src]);
|
||||
const shownFiles = (src?.files ?? []).filter((f) => !filter || f.toLowerCase().includes(filter.toLowerCase())).slice(0, 200);
|
||||
const sourceCount = lessons.size + files.size + cache.size + (pasted.trim() ? 1 : 0);
|
||||
|
||||
const generate = async () => {
|
||||
setError(null);
|
||||
setJob(null);
|
||||
const sources = [
|
||||
...[...lessons].map((lessonId) => ({ kind: "lesson", lessonId })),
|
||||
...[...files].map((path) => ({ kind: "file", path })),
|
||||
...[...cache].map((path) => ({ kind: "cache", path })),
|
||||
...(pasted.trim() ? [{ kind: "text", label: "Pasted text", text: pasted }] : []),
|
||||
];
|
||||
try {
|
||||
const { jobId } = await api<{ jobId: string }>("/api/ai/studio/generate", { method: "POST", body: { courseId, kind, style, topic, instructions, sources } });
|
||||
setJob({ id: jobId, status: "queued", progress: 0, message: "Queued", error: null, result: null });
|
||||
} catch (e) { setError((e as Error).message); }
|
||||
};
|
||||
|
||||
if (denied) return <div className="ai-page"><PageHead chap="Studio" title="Generate a draft" /><p className="ai-note">{denied}</p></div>;
|
||||
if (!courses) return <div className="ai-page"><PageHead chap="Studio" title="Generate a draft" /></div>;
|
||||
if (!courses.length) return <div className="ai-page"><PageHead chap="Studio" title="Generate a draft" /><p className="ai-note">You are not an author of any course yet.</p></div>;
|
||||
|
||||
const running = job && (job.status === "queued" || job.status === "running");
|
||||
return (
|
||||
<div className="ai-page">
|
||||
<PageHead chap="Studio" title="Generate a draft">
|
||||
<p>Drafts a document, an exercise or knowledge-check questions from material you choose. The result is written as draft files for you to review and edit. Nothing is published.</p>
|
||||
</PageHead>
|
||||
{!enabled ? <p className="ai-banner">AI is not set up. Ask the owner to configure a provider, or add your own key in Account, AI.</p> : null}
|
||||
|
||||
<form onSubmit={(e) => { e.preventDefault(); generate(); }}>
|
||||
<div className="ai-grid">
|
||||
<Field label="Course">
|
||||
<select className="ai-select" value={courseId} onChange={(e) => setCourseId(e.target.value)}>
|
||||
{courses.map((c) => <option key={c.id} value={c.id}>{c.title}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Topic">
|
||||
<input className="ai-input" value={topic} onChange={(e) => setTopic(e.target.value)} placeholder="For example: Folding time" required />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="ai-bar">
|
||||
<SegmentedToggle label="What to draft" value={kind} onChange={setKind} options={KINDS} />
|
||||
<SegmentedToggle label="House style" value={style} onChange={setStyle} options={STYLES} />
|
||||
</div>
|
||||
<p className="ai-hint" style={{ margin: "-6px 0 18px" }}>{STYLE_HINT[style]}</p>
|
||||
|
||||
<section className="ai-section">
|
||||
<h2>Material</h2>
|
||||
<span className="ai-hint">Lessons and transcripts, code and text files, slide text from imports, or text you paste. {sourceCount ? `${sourceCount} selected.` : ""}</span>
|
||||
{!src ? <p className="ai-hint">Loading…</p> : (
|
||||
<>
|
||||
<div className="ai-checklist" role="group" aria-label="Lessons">
|
||||
{byModule.map(([mod, ls]) => (
|
||||
<div key={mod}>
|
||||
<div className="ai-group">{mod}</div>
|
||||
{ls.map((l) => (
|
||||
<label className="ai-check" key={l.id}>
|
||||
<input type="checkbox" disabled={!l.hasText} checked={lessons.has(l.id)} onChange={() => toggle(lessons, setLessons, l.id)} />
|
||||
<span>{l.title} <small>{l.type}{l.hasText ? "" : ", no transcript"}</small></span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{src.files.length ? (
|
||||
<>
|
||||
<Field label="Files">
|
||||
<input className="ai-input" value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="Filter files" />
|
||||
</Field>
|
||||
<div className="ai-checklist" role="group" aria-label="Files">
|
||||
{shownFiles.map((f) => (
|
||||
<label className="ai-check" key={f}><input type="checkbox" checked={files.has(f)} onChange={() => toggle(files, setFiles, f)} /><span className="ai-mono">{f}</span></label>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{src.cache.length ? (
|
||||
<div className="ai-checklist" role="group" aria-label="Slide text and transcripts">
|
||||
<div className="ai-group">Slide text and transcripts</div>
|
||||
{src.cache.map((f) => (
|
||||
<label className="ai-check" key={f}><input type="checkbox" checked={cache.has(f)} onChange={() => toggle(cache, setCache, f)} /><span className="ai-mono">{f}</span></label>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<Field label="Pasted text" hint="A transcript, notes or anything else to draw on.">
|
||||
<textarea className="ai-textarea" value={pasted} onChange={(e) => setPasted(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Instructions" hint="Optional. For example: aim at Module 5, two exercises, JavaScript only.">
|
||||
<textarea className="ai-textarea" value={instructions} onChange={(e) => setInstructions(e.target.value)} rows={3} />
|
||||
</Field>
|
||||
</section>
|
||||
|
||||
<div className="ai-footer">
|
||||
<Pill variant="primary" type="submit" disabled={!enabled || !topic.trim() || !!running}>Generate draft</Pill>
|
||||
{error ? <span className="ai-error" style={{ margin: 0 }}>{error}</span> : null}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{job ? (
|
||||
<section className="ai-section" aria-live="polite">
|
||||
<h2>{job.status === "done" ? "Draft written" : job.status === "failed" ? "Generation failed" : "Generating"}</h2>
|
||||
{running ? <div className="ai-progress"><ProgressBar value={job.progress} label="Generation progress" /><span>{job.message ?? "Queued"}</span></div> : null}
|
||||
{job.status === "failed" ? <p className="ai-error">{job.error}</p> : null}
|
||||
{job.result ? (
|
||||
<>
|
||||
<span className="ai-hint">{job.result.branch ? "Committed to your draft branch " : "Saved as a draft at "}<span className="ai-mono">{job.result.location}</span>. Review it in the studio before publishing.</span>
|
||||
<ul className="ai-rows">{job.result.paths.map((p) => <li key={p}><span className="ai-path">{p}</span><span className="ai-meta">draft</span></li>)}</ul>
|
||||
{job.result.warnings.length ? (
|
||||
<div className="ai-section">
|
||||
<h2>Check these</h2>
|
||||
<ul className="ai-rows">{job.result.warnings.map((w) => <li key={w}><span>{w}</span><span /></li>)}</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/* AI feature: assistant panel, settings and generator. Tokens only (packages/ui/src/tokens.css). */
|
||||
.ai-page { max-width: 760px; margin: 0 auto 64px; font-size: 14px; }
|
||||
.ai-head { margin: 0 0 22px; padding-bottom: 20px; border-bottom: 1px solid var(--line); }
|
||||
.ai-chap { font-size: 13px; font-weight: 500; color: var(--muted); margin: 0 0 10px; }
|
||||
.ai-chap a { text-decoration: none; }
|
||||
.ai-chap a:hover { color: var(--head); }
|
||||
.ai-title { font: 600 28px/1.15 var(--sans); color: var(--head); margin: 0; letter-spacing: -.01em; }
|
||||
.ai-lede { margin: 10px 0 0; color: var(--muted); font-size: 14px; line-height: 1.55; }
|
||||
.ai-lede p { margin: 0; }
|
||||
|
||||
.ai-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; margin: 0 0 18px; }
|
||||
.ai-bar .ai-hint { flex: 1; min-width: 200px; }
|
||||
.ai-banner { margin: 0 0 18px; padding: 10px 14px; border: 1px solid var(--line2); border-radius: 10px; background: var(--side); color: var(--head); font-size: 13.5px; }
|
||||
.ai-note { margin: 0 0 18px; color: var(--muted); font-size: 13.5px; line-height: 1.55; }
|
||||
.ai-note a { color: var(--text); }
|
||||
.ai-error { margin: 0 0 14px; color: var(--bad); font-size: 13.5px; }
|
||||
.ai-ok { color: var(--ok); }
|
||||
|
||||
/* conversation */
|
||||
.ai-thread { list-style: none; margin: 0 0 18px; padding: 0; border-top: 1px solid var(--line); }
|
||||
.ai-turn { padding: 16px 2px; border-bottom: 1px solid var(--line); }
|
||||
.ai-who { display: block; font-size: 12px; color: var(--faint); margin: 0 0 6px; }
|
||||
.ai-turn--user .ai-md { color: var(--head); }
|
||||
.ai-md { font-size: 15px; line-height: 1.6; color: var(--text); overflow-wrap: anywhere; }
|
||||
.ai-md p, .ai-md ul, .ai-md ol { margin: 0 0 .8em; }
|
||||
.ai-md > :last-child { margin-bottom: 0; }
|
||||
.ai-md li { margin-bottom: .25em; }
|
||||
.ai-md .ai-md-h { font-weight: 600; color: var(--head); }
|
||||
.ai-md :not(pre) > code { font-size: .85em; padding: .08em .32em; background: color-mix(in srgb, var(--raised) 75%, transparent); border-radius: 4px; }
|
||||
.ai-md pre.code, pre.code { font-size: 13px; line-height: 1.6; background: var(--side); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; margin: 0 0 .8em; overflow-x: auto; white-space: pre; }
|
||||
.ai-activity { margin: 8px 0 0; font-size: 12.5px; color: var(--faint); }
|
||||
.ai-sources { margin: 12px 0 0; }
|
||||
.ai-sources > span { display: block; font-size: 12px; color: var(--faint); margin: 0 0 4px; }
|
||||
|
||||
/* rows (same rhythm as the mockup's .rows) */
|
||||
.ai-rows { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); }
|
||||
.ai-rows li { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 9px 2px; border-bottom: 1px solid var(--line); font-size: 13.5px; }
|
||||
.ai-rows .ai-path { font: 12.5px/1.4 var(--mono); color: var(--text); overflow-wrap: anywhere; }
|
||||
.ai-rows .ai-meta { font-size: 12px; color: var(--faint); white-space: nowrap; }
|
||||
.ai-rows a { text-decoration: none; }
|
||||
.ai-rows a:hover { text-decoration: underline; text-underline-offset: 3px; }
|
||||
|
||||
/* composer and forms */
|
||||
.ai-composer { display: grid; gap: 10px; }
|
||||
.ai-composer .ai-actions { display: flex; gap: 8px; justify-content: flex-end; }
|
||||
.ai-input, .ai-select, .ai-textarea { width: 100%; font-family: inherit; background: var(--side); border: 1px solid var(--line2); border-radius: 10px; padding: 9px 12px; color: var(--text); font-size: 14px; line-height: 1.5; }
|
||||
.ai-textarea { min-height: 96px; resize: vertical; }
|
||||
.ai-input:focus, .ai-select:focus, .ai-textarea:focus { outline: 0; border-color: var(--faint); }
|
||||
.ai-input:focus-visible, .ai-select:focus-visible, .ai-textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.ai-mono { font-family: var(--mono); font-size: 13px; }
|
||||
.ai-field { display: grid; gap: 5px; margin: 0 0 14px; align-content: start; }
|
||||
.ai-label { font-size: 12.5px; font-weight: 500; color: var(--muted); }
|
||||
.ai-hint { font-size: 12.5px; color: var(--faint); line-height: 1.45; }
|
||||
.ai-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0 16px; align-items: start; }
|
||||
.ai-section { margin: 30px 0 0; }
|
||||
.ai-section > h2 { font: 600 15px/1.3 var(--sans); color: var(--head); margin: 0 0 4px; }
|
||||
.ai-section > .ai-hint { display: block; margin: 0 0 12px; }
|
||||
.ai-check { display: flex; gap: 10px; align-items: baseline; padding: 6px 2px; font-size: 13.5px; color: var(--text); cursor: pointer; }
|
||||
.ai-check input { accent-color: var(--accent); }
|
||||
.ai-check small { color: var(--faint); font-size: 12px; }
|
||||
.ai-checklist { max-height: 300px; overflow-y: auto; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); padding: 4px 0; margin: 0 0 14px; }
|
||||
.ai-group { font-size: 12px; color: var(--faint); padding: 8px 2px 2px; }
|
||||
.ai-server { border-top: 1px solid var(--line); padding: 14px 0 4px; }
|
||||
.ai-server:last-of-type { border-bottom: 1px solid var(--line); }
|
||||
.ai-footer { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 22px 0 0; }
|
||||
.ai-progress { display: flex; align-items: center; gap: 12px; margin: 18px 0 0; font-size: 13px; color: var(--muted); }
|
||||
.ai-progress .stc-bar { max-width: 260px; }
|
||||
.ai-history { margin: 34px 0 0; }
|
||||
.ai-history h2 { font: 600 15px/1.3 var(--sans); color: var(--head); margin: 0 0 10px; }
|
||||
.ai-linkbtn { background: none; border: 0; padding: 0; color: var(--text); text-align: left; font-size: 13.5px; }
|
||||
.ai-linkbtn:hover { text-decoration: underline; text-underline-offset: 3px; }
|
||||
.ai-linkbtn[aria-current="true"] { color: var(--head); font-weight: 500; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.ai-title { font-size: 24px; }
|
||||
.ai-rows li { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number, message: string) { super(message); }
|
||||
}
|
||||
|
||||
export async function api<T>(path: string, init: { method?: string; body?: unknown } = {}): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method: init.method ?? "GET",
|
||||
headers: init.body === undefined ? undefined : { "content-type": "application/json" },
|
||||
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!res.ok) {
|
||||
let msg = res.statusText;
|
||||
try { msg = (await res.json()).error ?? msg; } catch {}
|
||||
throw new ApiError(res.status, msg);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/** Parses a newline-delimited JSON stream into objects. */
|
||||
export async function* readNdjson<T = unknown>(body: ReadableStream<Uint8Array>): AsyncIterable<T> {
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (value) buf += decoder.decode(value, { stream: true });
|
||||
let nl: number;
|
||||
while ((nl = buf.indexOf("\n")) !== -1) {
|
||||
const line = buf.slice(0, nl).trim();
|
||||
buf = buf.slice(nl + 1);
|
||||
if (line) yield JSON.parse(line) as T;
|
||||
}
|
||||
if (done) break;
|
||||
}
|
||||
buf += decoder.decode();
|
||||
if (buf.trim()) yield JSON.parse(buf) as T;
|
||||
}
|
||||
|
||||
export interface Citation { path: string; kind: string; lessonId?: string; lessonTitle?: string }
|
||||
export interface Status {
|
||||
enabled: boolean; source: "byok" | "server" | null; signedIn: boolean;
|
||||
course?: { id: string; title: string }; mentor?: { available: boolean; on: boolean };
|
||||
}
|
||||
export type ChatEvent =
|
||||
| { type: "meta"; conversationId: string; mentorOn: boolean; citations: Citation[] }
|
||||
| { type: "text"; text: string }
|
||||
| { type: "tool"; name: string }
|
||||
| { type: "notice"; message: string }
|
||||
| { type: "error"; message: string }
|
||||
| { type: "done"; text: string; citations: Citation[] };
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Fragment, type ReactNode } from "react";
|
||||
import "./ai.css";
|
||||
|
||||
/** Shown whenever the learner has switched the course's mentor rules off. */
|
||||
export function MentorBanner({ on, available }: { on: boolean; available: boolean }) {
|
||||
if (on || !available) return null;
|
||||
return <p className="ai-banner" role="status">Mentor mode off: I'll give full answers</p>;
|
||||
}
|
||||
|
||||
export function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="ai-field">
|
||||
<span className="ai-label">{label}</span>
|
||||
{children}
|
||||
{hint ? <span className="ai-hint">{hint}</span> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageHead({ chap, title, children }: { chap?: ReactNode; title: string; children?: ReactNode }) {
|
||||
return (
|
||||
<header className="ai-head">
|
||||
{chap ? <p className="ai-chap">{chap}</p> : null}
|
||||
<h1 className="ai-title">{title}</h1>
|
||||
{children ? <div className="ai-lede">{children}</div> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function inline(text: string, key: string): ReactNode[] {
|
||||
const out: ReactNode[] = [];
|
||||
const re = /(`[^`]+`|\*\*[^*]+\*\*)/g;
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
let i = 0;
|
||||
while ((m = re.exec(text))) {
|
||||
if (m.index > last) out.push(text.slice(last, m.index));
|
||||
const tok = m[0];
|
||||
out.push(tok.startsWith("`") ? <code key={`${key}-${i++}`}>{tok.slice(1, -1)}</code> : <strong key={`${key}-${i++}`}>{tok.slice(2, -2)}</strong>);
|
||||
last = m.index + tok.length;
|
||||
}
|
||||
if (last < text.length) out.push(text.slice(last));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A small, safe Markdown subset for assistant answers: paragraphs, lists, headings, fenced code, `code`, **bold**. */
|
||||
export function Markdown({ text }: { text: string }) {
|
||||
const blocks: ReactNode[] = [];
|
||||
const lines = text.replace(/\r\n/g, "\n").split("\n");
|
||||
let i = 0;
|
||||
let k = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]!;
|
||||
if (/^\s*```/.test(line)) {
|
||||
const code: string[] = [];
|
||||
i++;
|
||||
while (i < lines.length && !/^\s*```/.test(lines[i]!)) code.push(lines[i++]!);
|
||||
i++;
|
||||
blocks.push(<pre className="code" key={k++}>{code.join("\n")}</pre>);
|
||||
} else if (/^\s*([-*]|\d+\.)\s+/.test(line)) {
|
||||
const ordered = /^\s*\d+\./.test(line);
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && /^\s*([-*]|\d+\.)\s+/.test(lines[i]!)) items.push(lines[i++]!.replace(/^\s*([-*]|\d+\.)\s+/, ""));
|
||||
const kids = items.map((it, j) => <li key={j}>{inline(it, `${k}-${j}`)}</li>);
|
||||
blocks.push(ordered ? <ol key={k++}>{kids}</ol> : <ul key={k++}>{kids}</ul>);
|
||||
} else if (/^#{1,6}\s/.test(line)) {
|
||||
blocks.push(<p className="ai-md-h" key={k++}>{inline(line.replace(/^#+\s*/, ""), String(k))}</p>);
|
||||
i++;
|
||||
} else if (!line.trim()) i++;
|
||||
else {
|
||||
const para: string[] = [];
|
||||
while (i < lines.length && lines[i]!.trim() && !/^\s*(```|[-*]\s|\d+\.\s|#{1,6}\s)/.test(lines[i]!)) para.push(lines[i++]!);
|
||||
blocks.push(<p key={k++}>{para.map((p, j) => <Fragment key={j}>{j ? <br /> : null}{inline(p, `${k}-${j}`)}</Fragment>)}</p>);
|
||||
}
|
||||
}
|
||||
return <div className="ai-md">{blocks}</div>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { WebFeature } from "@stc/contracts";
|
||||
|
||||
export const aiWeb: WebFeature = {
|
||||
name: "ai",
|
||||
routes: [
|
||||
{ path: "/c/:courseId/assistant", element: () => import("./AssistantPage") },
|
||||
{ path: "/admin/ai", element: () => import("./AdminAiPage"), nav: { label: "AI", area: "admin" } },
|
||||
{ path: "/account/ai", element: () => import("./AccountAiPage") },
|
||||
{ path: "/studio/generate", element: () => import("./GeneratePage") },
|
||||
],
|
||||
};
|
||||
export { courseUrl, lessonUrl } from "./urls";
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Learner URLs the AI pages link to. The learner app (WP-A) owns these routes; keep every builder here so
|
||||
* Phase 2 can change the shape in one place (contract request 7).
|
||||
*/
|
||||
export function courseUrl(courseId: string): string {
|
||||
return `/c/${encodeURIComponent(courseId)}`;
|
||||
}
|
||||
|
||||
export function lessonUrl(courseId: string, lessonId: string): string {
|
||||
return `${courseUrl(courseId)}/${encodeURIComponent(lessonId)}`;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import type { ChatMessage } from "@stc/contracts";
|
||||
import { createAnthropicProvider } from "../src/providers/anthropic";
|
||||
import { collect, fakeFetch, sseBody } from "./helpers";
|
||||
|
||||
const ok = (events: [string | null, unknown][]) => () => new Response(sseBody(events), { headers: { "content-type": "text/event-stream" } });
|
||||
|
||||
const textTurn: [string, unknown][] = [
|
||||
["message_start", { type: "message_start", message: { id: "m1", content: [] } }],
|
||||
["content_block_start", { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }],
|
||||
["ping", { type: "ping" }],
|
||||
["content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello " } }],
|
||||
["content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "world" } }],
|
||||
["content_block_stop", { type: "content_block_stop", index: 0 }],
|
||||
["message_delta", { type: "message_delta", delta: { stop_reason: "end_turn" } }],
|
||||
["message_stop", { type: "message_stop" }],
|
||||
];
|
||||
|
||||
const toolTurn: [string, unknown][] = [
|
||||
["message_start", { type: "message_start", message: { id: "m2", content: [] } }],
|
||||
["content_block_start", { type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "", signature: "" } }],
|
||||
["content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "" } }],
|
||||
["content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "SIG-abc" } }],
|
||||
["content_block_stop", { type: "content_block_stop", index: 0 }],
|
||||
["content_block_start", { type: "content_block_start", index: 1, content_block: { type: "text", text: "" } }],
|
||||
["content_block_delta", { type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "Let me look." } }],
|
||||
["content_block_stop", { type: "content_block_stop", index: 1 }],
|
||||
["content_block_start", { type: "content_block_start", index: 2, content_block: { type: "tool_use", id: "toolu_1", name: "search_course", input: {} } }],
|
||||
["content_block_delta", { type: "content_block_delta", index: 2, delta: { type: "input_json_delta", partial_json: "{\"que" } }],
|
||||
["content_block_delta", { type: "content_block_delta", index: 2, delta: { type: "input_json_delta", partial_json: "ry\": \"add\"}" } }],
|
||||
["content_block_stop", { type: "content_block_stop", index: 2 }],
|
||||
["message_delta", { type: "message_delta", delta: { stop_reason: "tool_use" } }],
|
||||
["message_stop", { type: "message_stop" }],
|
||||
];
|
||||
|
||||
test("streams text and sends a well-formed request", async () => {
|
||||
const ff = fakeFetch([ok(textTurn)]);
|
||||
const p = createAnthropicProvider({ apiKey: "sk-test", fetch: ff.fetch });
|
||||
const msgs: ChatMessage[] = [{ role: "system", content: "You are kind." }, { role: "user", content: "Hi" }];
|
||||
const r = await collect(p.chat(msgs, { tools: [{ name: "search_course", description: "Search", inputSchema: { type: "object", properties: {} } }] }));
|
||||
expect(r.text).toBe("Hello world");
|
||||
const c = ff.calls[0]!;
|
||||
expect(c.url).toBe("https://api.anthropic.com/v1/messages");
|
||||
const h = new Headers(c.init.headers);
|
||||
expect(h.get("x-api-key")).toBe("sk-test");
|
||||
expect(h.get("anthropic-version")).toBe("2023-06-01");
|
||||
expect(c.body.model).toBe("claude-opus-5-5");
|
||||
expect(c.body.stream).toBe(true);
|
||||
expect(c.body.system).toBe("You are kind.");
|
||||
expect(c.body.messages).toEqual([{ role: "user", content: [{ type: "text", text: "Hi" }] }]);
|
||||
expect(c.body.tools).toEqual([{ name: "search_course", description: "Search", input_schema: { type: "object", properties: {} } }]);
|
||||
expect(c.body.thinking).toBeUndefined();
|
||||
expect(c.body.temperature).toBeUndefined();
|
||||
expect(c.body.tool_choice).toBeUndefined();
|
||||
expect(c.body.fallbacks).toBe("default");
|
||||
expect(h.get("anthropic-beta")).toBe("server-side-fallback-2026-07-01");
|
||||
});
|
||||
|
||||
test("assembles tool input and replays thinking blocks unchanged on the next call", async () => {
|
||||
const ff = fakeFetch([ok(toolTurn), ok(textTurn)]);
|
||||
const p = createAnthropicProvider({ apiKey: "k", fetch: ff.fetch });
|
||||
const msgs: ChatMessage[] = [{ role: "user", content: "How do I add?" }];
|
||||
const first = await collect(p.chat(msgs, {}));
|
||||
expect(first.text).toBe("Let me look.");
|
||||
expect(first.toolCalls).toEqual([{ id: "toolu_1", name: "search_course", input: { query: "add" } }]);
|
||||
|
||||
msgs.push({ role: "assistant", content: "Let me look." });
|
||||
msgs.push({ role: "assistant", content: JSON.stringify({ query: "add" }), toolCallId: "toolu_1", name: "search_course" });
|
||||
msgs.push({ role: "tool", content: "lessons/04-exercise.md: ...", toolCallId: "toolu_1", name: "search_course" });
|
||||
await collect(p.chat(msgs, {}));
|
||||
const sent = ff.calls[1]!.body.messages;
|
||||
expect(sent).toHaveLength(3);
|
||||
expect(sent[1]).toEqual({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "", signature: "SIG-abc" },
|
||||
{ type: "text", text: "Let me look." },
|
||||
{ type: "tool_use", id: "toolu_1", name: "search_course", input: { query: "add" } },
|
||||
],
|
||||
});
|
||||
expect(sent[2]).toEqual({ role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_1", content: "lessons/04-exercise.md: ..." }] });
|
||||
});
|
||||
|
||||
test("tool calls it did not produce are mapped from the message fields", async () => {
|
||||
const ff = fakeFetch([ok(textTurn)]);
|
||||
const p = createAnthropicProvider({ apiKey: "k", fetch: ff.fetch });
|
||||
await collect(p.chat([
|
||||
{ role: "user", content: "q" },
|
||||
{ role: "assistant", content: "{\"x\":1}", toolCallId: "t9", name: "list_notes" },
|
||||
{ role: "tool", content: "none", toolCallId: "t9" },
|
||||
], {}));
|
||||
expect(ff.calls[0]!.body.messages[1]).toEqual({ role: "assistant", content: [{ type: "tool_use", id: "t9", name: "list_notes", input: { x: 1 } }] });
|
||||
});
|
||||
|
||||
test("errors carry the API message", async () => {
|
||||
const ff = fakeFetch([() => Response.json({ type: "error", error: { type: "authentication_error", message: "invalid x-api-key" } }, { status: 401 })]);
|
||||
const p = createAnthropicProvider({ apiKey: "bad", fetch: ff.fetch });
|
||||
await expect(collect(p.chat([{ role: "user", content: "x" }], {}))).rejects.toThrow(/invalid x-api-key/);
|
||||
const ff2 = fakeFetch([ok([["error", { type: "error", error: { type: "overloaded_error", message: "Overloaded" } }]])]);
|
||||
await expect(collect(createAnthropicProvider({ apiKey: "k", fetch: ff2.fetch }).chat([{ role: "user", content: "x" }], {}))).rejects.toThrow(/Overloaded/);
|
||||
});
|
||||
|
||||
test("a refusal yields a plain sentence", async () => {
|
||||
const ff = fakeFetch([ok([["message_start", { type: "message_start", message: { content: [] } }], ["message_delta", { type: "message_delta", delta: { stop_reason: "refusal" } }], ["message_stop", { type: "message_stop" }]])]);
|
||||
const r = await collect(createAnthropicProvider({ apiKey: "k", fetch: ff.fetch }).chat([{ role: "user", content: "x" }], {}));
|
||||
expect(r.text).toContain("declined");
|
||||
});
|
||||
|
||||
test("custom base URL, model and effort", async () => {
|
||||
const ff = fakeFetch([ok(textTurn)]);
|
||||
await collect(createAnthropicProvider({ apiKey: "k", baseUrl: "http://proxy.local/", model: "claude-sonnet-5-5", effort: "high", fetch: ff.fetch }).chat([{ role: "user", content: "x" }], {}));
|
||||
expect(ff.calls[0]!.url).toBe("http://proxy.local/v1/messages");
|
||||
expect(ff.calls[0]!.body.model).toBe("claude-sonnet-5-5");
|
||||
expect(ff.calls[0]!.body.output_config).toEqual({ effort: "high" });
|
||||
expect(ff.calls[0]!.body.fallbacks).toBeUndefined();
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { beforeAll, expect, test } from "bun:test";
|
||||
import { join } from "node:path";
|
||||
import type { ChatMessage } from "@stc/contracts";
|
||||
import { createCourseRepository, type CourseRepository, type LoadedCourse } from "@stc/course-format";
|
||||
import { buildSystemPrompt, citationsFor, readMentorText } from "../src/assistant/prompt";
|
||||
import { courseTools, combineTools } from "../src/assistant/tools";
|
||||
import { runAssistant, type AssistantEvent } from "../src/assistant/loop";
|
||||
import { buildCourseIndex, type CourseIndex } from "../src/retrieval/course-index";
|
||||
import { fakeProvider } from "./helpers";
|
||||
|
||||
const FIXTURES = join(import.meta.dir, "../../../fixtures/courses");
|
||||
let repo: CourseRepository;
|
||||
let course: LoadedCourse;
|
||||
let index: CourseIndex;
|
||||
beforeAll(async () => {
|
||||
repo = await createCourseRepository([FIXTURES]);
|
||||
course = repo.get("sample")!;
|
||||
index = await buildCourseIndex(course, "/nonexistent");
|
||||
});
|
||||
|
||||
test("mentor rules are in the system prompt when mentor mode is on", async () => {
|
||||
const mentorText = await readMentorText(repo, course);
|
||||
expect(mentorText).toContain("never full solutions");
|
||||
const on = buildSystemPrompt({ course, mentorText, mentorOn: true, sources: index.search("add", 2) });
|
||||
expect(on).toContain("## Course mentor rules");
|
||||
expect(on).toContain("Give hints, never full solutions.");
|
||||
expect(on).toContain("[1] lessons/04-exercise.md");
|
||||
const off = buildSystemPrompt({ course, mentorText, mentorOn: false, sources: [] });
|
||||
expect(off).not.toContain("never full solutions");
|
||||
expect(off).toContain("Mentor mode is off");
|
||||
});
|
||||
|
||||
test("citations are deduplicated by path and lesson", () => {
|
||||
const c = citationsFor([
|
||||
{ path: "a.md", kind: "lesson", text: "x", lessonId: "l1", lessonTitle: "L1" },
|
||||
{ path: "a.md", kind: "lesson", text: "y", lessonId: "l1", lessonTitle: "L1" },
|
||||
{ path: "notes", kind: "note", text: "z", lessonId: "l1" },
|
||||
{ path: "b.js", kind: "code", text: "z" },
|
||||
]);
|
||||
expect(c).toEqual([
|
||||
{ path: "a.md", kind: "lesson", lessonId: "l1", lessonTitle: "L1" },
|
||||
{ path: "b.js", kind: "code" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("course tools search, read lessons and files, list notes, and refuse solutions", async () => {
|
||||
const t = courseTools({ repo, course, index, listNotes: async () => [{ id: "n", lessonId: "m1-intro", body: "my note", anchorKind: "none", anchorValue: null }] });
|
||||
expect(t.specs.map((s) => s.name).sort()).toEqual(["list_notes", "read_file", "read_lesson", "search_course"]);
|
||||
expect(await t.run("search_course", { query: "sum of its arguments" })).toContain("lessons/04-exercise.md");
|
||||
expect(t.sources.some((s) => s.path === "lessons/04-exercise.md")).toBe(true);
|
||||
expect(await t.run("read_lesson", { lessonId: "m2-ex-add" })).toContain("Implement `add(a, b)`");
|
||||
expect(await t.run("read_lesson", { lessonId: "nope" })).toMatch(/No lesson/);
|
||||
expect(await t.run("read_file", { path: "exercises/add/starter.js" })).toContain("add");
|
||||
expect(await t.run("read_file", { path: "exercises/add/solution.js" })).toMatch(/authors only/);
|
||||
expect(await t.run("read_file", { path: "../../../etc/passwd" })).toMatch(/No such file/);
|
||||
expect(await t.run("list_notes", {})).toContain("my note");
|
||||
});
|
||||
|
||||
test("the loop runs tools and feeds results back until the model answers", async () => {
|
||||
const t = courseTools({ repo, course, index, listNotes: async () => [] });
|
||||
const { provider, seen } = fakeProvider([
|
||||
() => ({ text: "Searching.", toolCalls: [{ id: "c1", name: "search_course", input: { query: "add arguments" } }] }),
|
||||
(msgs) => ({ text: `Found it in lessons/04-exercise.md. (${msgs.filter((m) => m.role === "tool").length} tool result)` }),
|
||||
]);
|
||||
const messages: ChatMessage[] = [{ role: "system", content: "sys" }, { role: "user", content: "How do I add?" }];
|
||||
const events: AssistantEvent[] = [];
|
||||
for await (const e of runAssistant({ provider, messages, tools: combineTools([t]) })) events.push(e);
|
||||
expect(events.filter((e) => e.type === "tool")).toEqual([{ type: "tool", name: "search_course" }]);
|
||||
const done = events.find((e) => e.type === "done") as { type: "done"; text: string };
|
||||
expect(done.text).toBe("Searching.Found it in lessons/04-exercise.md. (1 tool result)");
|
||||
const second = seen[1]!.messages;
|
||||
expect(second.at(-2)).toMatchObject({ role: "assistant", toolCallId: "c1", name: "search_course" });
|
||||
expect(second.at(-1)!.role).toBe("tool");
|
||||
expect(second.at(-1)!.content).toContain("lessons/04-exercise.md");
|
||||
expect(seen[0]!.tools!.map((x) => x.name)).toContain("search_course");
|
||||
});
|
||||
|
||||
test("unknown tools and provider errors are reported, and the step limit holds", async () => {
|
||||
const t = courseTools({ repo, course, index, listNotes: async () => [] });
|
||||
const looping = fakeProvider([() => ({ toolCalls: [{ id: crypto.randomUUID(), name: "nope", input: {} }] })]);
|
||||
const events: AssistantEvent[] = [];
|
||||
for await (const e of runAssistant({ provider: looping.provider, messages: [{ role: "user", content: "x" }], tools: combineTools([t]), maxSteps: 3 })) events.push(e);
|
||||
expect(looping.seen).toHaveLength(3);
|
||||
expect(looping.seen[1]!.messages.at(-1)!.content).toMatch(/Unknown tool/);
|
||||
expect(events.at(-1)!.type).toBe("done");
|
||||
|
||||
const broken = { id: "b", async *chat() { throw new Error("boom"); } };
|
||||
const ev2: AssistantEvent[] = [];
|
||||
for await (const e of runAssistant({ provider: broken, messages: [{ role: "user", content: "x" }], tools: combineTools([t]) })) ev2.push(e);
|
||||
expect(ev2).toEqual([{ type: "error", message: "boom" }]);
|
||||
});
|
||||
|
||||
test("my_progress reports the learner's progress per lesson", async () => {
|
||||
const t = courseTools({
|
||||
repo, course, index, listNotes: async () => [],
|
||||
getProgress: async () => ({ "m1-intro": { state: "done", positionSeconds: null, updatedAt: "2026-10-30" }, "m1-video": { state: "in-progress", positionSeconds: 75, updatedAt: "2026-10-30" } }),
|
||||
});
|
||||
expect(t.specs.map((s) => s.name)).toContain("my_progress");
|
||||
const out = await t.run("my_progress", {});
|
||||
expect(out).toContain("Introduction (m1-intro): done");
|
||||
expect(out).toContain("Watch the clip (m1-video): in progress, at 1:15");
|
||||
expect(out).toContain("Adding numbers (m2-text): not started");
|
||||
expect(out).toMatch(/1 of \d+ lessons done/);
|
||||
});
|
||||
|
||||
// The solution guard must hold for every spelling of a path: it checks the canonical real path.
|
||||
test("read_file refuses solutions however the path is written, including through symlinks", async () => {
|
||||
const { cpSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } = await import("node:fs");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const dir = mkdtempSync(join(tmpdir(), "stc-ai-guard-"));
|
||||
try {
|
||||
const root = join(dir, "courses/sample");
|
||||
cpSync(join(FIXTURES, "sample"), root, { recursive: true });
|
||||
const yaml = join(root, "stcourse.yaml");
|
||||
writeFileSync(yaml, readFileSync(yaml, "utf8").replace("solution: exercises/add/solution.js", "solution: exercises/add/answer.js"));
|
||||
writeFileSync(join(root, "exercises/add/answer.js"), "export const add = (a, b) => a + b; // ANSWER\n");
|
||||
mkdirSync(join(root, "solutions"));
|
||||
writeFileSync(join(root, "solutions/ref.js"), "// REFERENCE SOLUTION\n");
|
||||
writeFileSync(join(root, ".env"), "TOKEN=hidden\n");
|
||||
symlinkSync("exercises/add/answer.js", join(root, "peek.js"));
|
||||
symlinkSync("solutions", join(root, "material"));
|
||||
symlinkSync("../solutions/ref.js", join(root, "lessons/extra.js"));
|
||||
const r = await createCourseRepository([join(dir, "courses")]);
|
||||
const c = r.get("sample")!;
|
||||
expect(c.status).toBe("ok");
|
||||
const t = courseTools({ repo: r, course: c, index: await buildCourseIndex(c, "/nonexistent"), listNotes: async () => [] });
|
||||
const read = (path: string) => t.run("read_file", { path });
|
||||
|
||||
const refused = [
|
||||
"exercises/add/answer.js",
|
||||
"exercises/add/../add/answer.js",
|
||||
"./exercises/add/answer.js",
|
||||
"exercises//add/answer.js",
|
||||
"./exercises/./add/answer.js",
|
||||
"/exercises/add/answer.js",
|
||||
"exercises\\add\\answer.js",
|
||||
"exercises%2Fadd%2Fanswer.js",
|
||||
"exercises/add/%2E%2E/add/answer.js",
|
||||
"exercises%252Fadd%252Fanswer.js",
|
||||
"%2E/exercises/add/answer.js",
|
||||
"peek.js",
|
||||
"material/ref.js",
|
||||
"lessons/extra.js",
|
||||
"lessons/../solutions/ref.js",
|
||||
"exercises/add/solution.js",
|
||||
".env",
|
||||
"lessons/../.env",
|
||||
];
|
||||
for (const p of refused) {
|
||||
const out = await read(p);
|
||||
expect({ p, out }).toEqual({ p, out: expect.stringMatching(/authors only|not available|No such file/) });
|
||||
expect(out).not.toContain("ANSWER");
|
||||
expect(out).not.toContain("REFERENCE");
|
||||
expect(out).not.toContain("hidden");
|
||||
}
|
||||
expect(await read("exercises/add/answer.js")).toMatch(/authors only/);
|
||||
expect(await read("peek.js")).toMatch(/authors only/);
|
||||
expect(await read("../../etc/passwd")).toMatch(/No such file/);
|
||||
// Ordinary files still read, in any spelling.
|
||||
expect(await read("exercises/add/starter.js")).toContain("add");
|
||||
expect(await read("./exercises/./add/../add/starter.js")).toContain("add");
|
||||
expect(await read("exercises%2Fadd%2Fstarter.js")).toContain("add");
|
||||
expect(t.sources.at(-1)?.path).toBe("exercises/add/starter.js");
|
||||
// The index never holds them either.
|
||||
const idx = await buildCourseIndex(c, "/nonexistent");
|
||||
const hits = idx.search("ANSWER REFERENCE SOLUTION add", 50);
|
||||
expect(hits.filter((x) => /answer|ref\.js|peek|material|extra/.test(x.path) || x.text.includes("ANSWER") || x.text.includes("REFERENCE"))).toEqual([]);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { createSecretBox } from "../src/crypto";
|
||||
|
||||
test("round trips and uses a fresh IV each time", async () => {
|
||||
const box = createSecretBox("s3cret");
|
||||
const a = await box.encrypt("sk-ant-123");
|
||||
const b = await box.encrypt("sk-ant-123");
|
||||
expect(a).not.toBe(b);
|
||||
expect(a).not.toContain("sk-ant-123");
|
||||
expect(a.startsWith("v1.")).toBe(true);
|
||||
expect(await box.decrypt(a)).toBe("sk-ant-123");
|
||||
expect(await box.decrypt(b)).toBe("sk-ant-123");
|
||||
});
|
||||
|
||||
test("another secret cannot decrypt", async () => {
|
||||
const token = await createSecretBox("one").encrypt("key");
|
||||
await expect(createSecretBox("two").decrypt(token)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("a tampered token is rejected", async () => {
|
||||
const token = await createSecretBox("one").encrypt("key");
|
||||
const parts = token.split(".");
|
||||
const ct = Buffer.from(parts[2]!, "base64");
|
||||
ct[0] = ct[0]! ^ 1;
|
||||
await expect(createSecretBox("one").decrypt(`${parts[0]}.${parts[1]}.${ct.toString("base64")}`)).rejects.toThrow();
|
||||
await expect(createSecretBox("one").decrypt("garbage")).rejects.toThrow();
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
import { expect } from "bun:test";
|
||||
import { cpSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import type { AuthUser, JobRecord, ServerContext } from "@stc/contracts";
|
||||
import { createApp, createJobQueue } from "@stc/core";
|
||||
import { createCourseRepository } from "@stc/course-format";
|
||||
import type { Db } from "@stc/db";
|
||||
import { forEachDb } from "../../db/test/helpers";
|
||||
import { aiFeature, type AiFeatureOptions } from "../src/feature";
|
||||
import { fakeProvider } from "./helpers";
|
||||
|
||||
const SAMPLE = resolve(import.meta.dir, "../../../fixtures/courses/sample");
|
||||
const USERS: Record<string, AuthUser> = {
|
||||
owner: { id: "owner", email: "o@x", name: "Owner", roles: ["owner"], groups: [] },
|
||||
ann: { id: "ann", email: "a@x", name: "Ann", roles: ["learner"], groups: [] },
|
||||
bob: { id: "bob", email: "b@x", name: "Bob", roles: ["learner"], groups: [] },
|
||||
auth: { id: "auth", email: "w@x", name: "Writer", roles: ["author"], groups: [] },
|
||||
};
|
||||
|
||||
async function setup(db: Db, turns: Parameters<typeof fakeProvider>[0] = [() => ({ text: "ok" })], extra: Partial<AiFeatureOptions> = {}) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "stc-ai-feat-"));
|
||||
cpSync(SAMPLE, join(dir, "courses/sample"), { recursive: true });
|
||||
cpSync(SAMPLE, join(dir, "courses/secret"), { recursive: true });
|
||||
const m = readFileSync(join(SAMPLE, "stcourse.yaml"), "utf8");
|
||||
writeFileSync(join(dir, "courses/secret/stcourse.yaml"), m.replace("id: sample", "id: secret").replace("visibility: public", "visibility: restricted"));
|
||||
const courses = await createCourseRepository([join(dir, "courses")]);
|
||||
const fake = fakeProvider(turns);
|
||||
const keys: (string | null)[] = [];
|
||||
const ctx = {
|
||||
db, courses,
|
||||
config: { port: 0, databaseUrl: "", dataDir: join(dir, "data"), courseDirs: [], secret: "test-secret", publicUrl: "" },
|
||||
auth: { userFromRequest: async (req: Request) => USERS[req.headers.get("x-user") ?? ""] ?? null },
|
||||
} as unknown as ServerContext;
|
||||
const feature = aiFeature(ctx, { providerFactory: (_cfg, key) => { keys.push(key); return fake.provider; }, ...extra });
|
||||
const jobs = createJobQueue(db, feature.jobs!);
|
||||
ctx.jobs = jobs;
|
||||
const app = createApp(ctx, [feature]);
|
||||
const call = async (method: string, path: string, user?: string, body?: unknown) => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (user) headers["x-user"] = user;
|
||||
if (body !== undefined) headers["content-type"] = "application/json";
|
||||
return app.fetch(new Request(`http://x${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }));
|
||||
};
|
||||
return { ctx, dir, call, fake, keys, feature };
|
||||
}
|
||||
|
||||
const ndjson = async (r: Response) => (await r.text()).trim().split("\n").map((l) => JSON.parse(l));
|
||||
|
||||
forEachDb("AI is off by default and admin config is owner-only and hides keys", async (db) => {
|
||||
const { call } = await setup(db);
|
||||
expect(await (await call("GET", "/api/ai/status?courseId=sample", "ann")).json()).toMatchObject({ enabled: false, source: null });
|
||||
expect((await call("POST", "/api/ai/courses/sample/chat", "ann", { message: "hi" })).status).toBe(409);
|
||||
expect((await call("GET", "/api/ai/admin/config", "ann")).status).toBe(403);
|
||||
expect((await call("GET", "/api/ai/admin/config")).status).toBe(401);
|
||||
const put = await call("PUT", "/api/ai/admin/config", "owner", { provider: { kind: "anthropic", apiKey: "sk-ant-SERVER" }, mcpServers: [] });
|
||||
expect(put.status).toBe(200);
|
||||
const cfg = await (await call("GET", "/api/ai/admin/config", "owner")).text();
|
||||
expect(cfg).not.toContain("SERVER");
|
||||
expect(JSON.parse(cfg).provider).toEqual({ kind: "anthropic", hasApiKey: true });
|
||||
expect((await call("PUT", "/api/ai/admin/config", "owner", { provider: { kind: "nope" }, mcpServers: [] })).status).toBe(400);
|
||||
expect(await (await call("GET", "/api/ai/status?courseId=sample", "ann")).json()).toMatchObject({ enabled: true, source: "server", mentor: { available: true, on: true } });
|
||||
});
|
||||
|
||||
forEachDb("BYOK enables AI for that user only and is used before the server key", async (db) => {
|
||||
const { call, keys } = await setup(db);
|
||||
expect((await call("PUT", "/api/ai/account/key", "ann", { kind: "anthropic", apiKey: "sk-ann" })).status).toBe(200);
|
||||
const acct = await (await call("GET", "/api/ai/account", "ann")).text();
|
||||
expect(acct).not.toContain("sk-ann");
|
||||
expect(JSON.parse(acct).byok).toEqual({ kind: "anthropic", hasApiKey: true });
|
||||
expect(await (await call("GET", "/api/ai/status?courseId=sample", "ann")).json()).toMatchObject({ enabled: true, source: "byok" });
|
||||
expect(await (await call("GET", "/api/ai/status?courseId=sample", "bob")).json()).toMatchObject({ enabled: false });
|
||||
await call("POST", "/api/ai/courses/sample/chat", "ann", { message: "hello" }).then((r) => r.text());
|
||||
expect(keys).toEqual(["sk-ann"]);
|
||||
expect((await call("DELETE", "/api/ai/account/key", "ann")).status).toBe(200);
|
||||
expect(await (await call("GET", "/api/ai/status?courseId=sample", "ann")).json()).toMatchObject({ enabled: false });
|
||||
expect((await call("PUT", "/api/ai/account/key", "ann", { kind: "anthropic" })).status).toBe(400);
|
||||
});
|
||||
|
||||
forEachDb("chat cites course files, applies mentor rules, and stores the conversation", async (db) => {
|
||||
const { call, fake } = await setup(db, [
|
||||
() => ({ toolCalls: [{ id: "t1", name: "read_lesson", input: { lessonId: "m2-ex-add" } }] }),
|
||||
() => ({ text: "Which operator combines two numbers? See [lessons/04-exercise.md · Adding numbers]." }),
|
||||
]);
|
||||
await call("PUT", "/api/ai/admin/config", "owner", { provider: { kind: "anthropic", apiKey: "k" }, mcpServers: [] });
|
||||
const r = await call("POST", "/api/ai/courses/sample/chat", "ann", { message: "How do I return the sum of its arguments?" });
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.headers.get("content-type")).toContain("application/x-ndjson");
|
||||
const events = await ndjson(r);
|
||||
expect(events[0]).toMatchObject({ type: "meta", mentorOn: true });
|
||||
expect(events[0].citations.map((c: { path: string }) => c.path)).toContain("lessons/04-exercise.md");
|
||||
expect(events.some((e) => e.type === "tool" && e.name === "read_lesson")).toBe(true);
|
||||
const done = events.find((e) => e.type === "done");
|
||||
expect(done.text).toContain("Which operator");
|
||||
expect(done.citations.some((c: { path: string; lessonId?: string }) => c.path === "lessons/04-exercise.md")).toBe(true);
|
||||
const system = fake.seen[0]!.messages[0]!;
|
||||
expect(system.role).toBe("system");
|
||||
expect(system.content).toContain("Give hints, never full solutions.");
|
||||
expect(fake.seen[0]!.tools!.map((t) => t.name)).toEqual(expect.arrayContaining(["search_course", "read_lesson", "list_notes", "my_progress"]));
|
||||
|
||||
const convs = await (await call("GET", "/api/ai/courses/sample/conversations", "ann")).json();
|
||||
expect(convs).toHaveLength(1);
|
||||
const conv = await (await call("GET", `/api/ai/conversations/${convs[0].id}`, "ann")).json();
|
||||
expect(conv.messages.map((m: { role: string }) => m.role)).toEqual(["user", "assistant"]);
|
||||
expect(conv.messages[1].citations.some((c: { path: string }) => c.path === "lessons/04-exercise.md")).toBe(true);
|
||||
expect((await call("GET", `/api/ai/conversations/${convs[0].id}`, "bob")).status).toBe(404);
|
||||
|
||||
// Learner switches mentor mode off for this course: rules leave the prompt.
|
||||
expect((await call("PUT", "/api/ai/courses/sample/mentor", "ann", { off: true })).status).toBe(200);
|
||||
expect(await (await call("GET", "/api/ai/status?courseId=sample", "ann")).json()).toMatchObject({ mentor: { available: true, on: false } });
|
||||
const r2 = await ndjson(await call("POST", "/api/ai/courses/sample/chat", "ann", { message: "Show me the full solution", conversationId: convs[0].id }));
|
||||
expect(r2[0]).toMatchObject({ type: "meta", mentorOn: false, conversationId: convs[0].id });
|
||||
const sys2 = fake.seen.at(-1)!.messages[0]!.content;
|
||||
expect(sys2).not.toContain("never full solutions");
|
||||
expect(sys2).toContain("Mentor mode is off");
|
||||
// history is replayed
|
||||
expect(fake.seen.at(-1)!.messages.some((m) => m.role === "assistant" && m.content.includes("Which operator"))).toBe(true);
|
||||
// Bob still has mentor mode on
|
||||
expect(await (await call("GET", "/api/ai/status?courseId=sample", "bob")).json()).toMatchObject({ mentor: { on: true } });
|
||||
|
||||
expect((await call("DELETE", `/api/ai/conversations/${convs[0].id}`, "ann")).status).toBe(200);
|
||||
expect(await (await call("GET", "/api/ai/courses/sample/conversations", "ann")).json()).toEqual([]);
|
||||
});
|
||||
|
||||
forEachDb("course visibility and sign-in are enforced", async (db) => {
|
||||
const { call } = await setup(db);
|
||||
await call("PUT", "/api/ai/admin/config", "owner", { provider: { kind: "anthropic", apiKey: "k" }, mcpServers: [] });
|
||||
expect((await call("POST", "/api/ai/courses/secret/chat", "ann", { message: "hi" })).status).toBe(404);
|
||||
expect((await call("POST", "/api/ai/courses/sample/chat", undefined, { message: "hi" })).status).toBe(401);
|
||||
expect((await call("POST", "/api/ai/courses/nope/chat", "ann", { message: "hi" })).status).toBe(404);
|
||||
expect((await call("POST", "/api/ai/courses/secret/chat", "owner", { message: "hi" })).status).toBe(200);
|
||||
});
|
||||
|
||||
forEachDb("generation is for authors and writes drafts, not course files", async (db) => {
|
||||
const doc = "=== FILE: questions/adding.md ===\n## Check your understanding\n1. Predict: add(2, 3)?\n=== END FILE ===";
|
||||
const { call, ctx, dir } = await setup(db, [() => ({ text: doc })]);
|
||||
await call("PUT", "/api/ai/admin/config", "owner", { provider: { kind: "anthropic", apiKey: "k" }, mcpServers: [] });
|
||||
const body = { courseId: "sample", kind: "questions", style: "pure-snake", topic: "Adding", sources: [{ kind: "lesson", lessonId: "m2-text" }] };
|
||||
expect((await call("POST", "/api/ai/studio/generate", "ann", body)).status).toBe(403);
|
||||
expect((await call("GET", "/api/ai/studio/sources?courseId=sample", "ann")).status).toBe(403);
|
||||
const src = await (await call("GET", "/api/ai/studio/sources?courseId=sample", "auth")).json();
|
||||
expect(src.lessons.map((l: { id: string }) => l.id)).toContain("m2-text");
|
||||
expect(src.files).toContain("code/S01 - First steps/hello.sh");
|
||||
expect((await call("POST", "/api/ai/studio/generate", "auth", { ...body, kind: "poem" })).status).toBe(400);
|
||||
const g = await call("POST", "/api/ai/studio/generate", "auth", body);
|
||||
expect(g.status).toBe(202);
|
||||
const { jobId } = await g.json();
|
||||
// run the queued job by hand (the worker is not started in tests)
|
||||
const job = (await ctx.jobs.get(jobId)) as JobRecord;
|
||||
expect(job.kind).toBe("ai.generate");
|
||||
const feature = aiFeature(ctx, { providerFactory: () => fakeProvider([() => ({ text: doc })]).provider });
|
||||
const result = (await feature.jobs!["ai.generate"]!(job.payload, { reportProgress: async () => {}, signal: new AbortController().signal })) as { location: string; paths: string[] };
|
||||
expect(result.paths).toEqual(["questions/adding.md"]);
|
||||
expect(result.location.startsWith(join(dir, "data", "drafts", "sample"))).toBe(true);
|
||||
expect(await Bun.file(join(dir, "courses/sample/questions/adding.md")).exists()).toBe(false);
|
||||
expect((await call("GET", `/api/ai/studio/jobs/${jobId}`, "auth")).status).toBe(200);
|
||||
expect((await call("GET", `/api/ai/studio/jobs/${jobId}`, "bob")).status).toBe(404);
|
||||
});
|
||||
|
||||
// SSRF guard: a learner's own key (BYOK) may only go to the official endpoints or admin-allowed origins.
|
||||
forEachDb("BYOK base URLs are limited to official endpoints and allowed origins", async (db) => {
|
||||
const { call } = await setup(db, undefined, { env: { STC_AI_ALLOWED_BASE_URLS: "http://ollama.internal:11434, https://llm.example.org" } });
|
||||
const put = (body: unknown) => call("PUT", "/api/ai/account/key", "ann", body);
|
||||
// Internal or arbitrary hosts are refused for either kind.
|
||||
for (const baseUrl of ["http://127.0.0.1:8080/v1", "http://169.254.169.254/latest", "http://localhost:11434/v1", "https://api.anthropic.com.evil.test", "http://ollama.internal:11435/v1", "https://ollama.internal:11434/v1", "file:///etc/passwd"]) {
|
||||
expect((await put({ kind: "openai", model: "m", apiKey: "k", baseUrl })).status).toBe(400);
|
||||
}
|
||||
expect((await put({ kind: "anthropic", apiKey: "k", baseUrl: "http://127.0.0.1:9/" })).status).toBe(400);
|
||||
expect((await call("GET", "/api/ai/account", "ann")).status).toBe(200);
|
||||
expect((await (await call("GET", "/api/ai/account", "ann")).json()).byok).toBeNull();
|
||||
// Official endpoints and allowlisted origins (any path on them) are accepted.
|
||||
expect((await put({ kind: "anthropic", apiKey: "k" })).status).toBe(200);
|
||||
expect((await put({ kind: "anthropic", apiKey: "k", baseUrl: "https://api.anthropic.com/" })).status).toBe(200);
|
||||
expect((await put({ kind: "openai", model: "m", apiKey: "k" })).status).toBe(200);
|
||||
expect(await (await call("GET", "/api/ai/account", "ann")).json()).toMatchObject({ byok: { kind: "openai", model: "m", hasApiKey: true } }); // official endpoint: stored as "no base URL"
|
||||
expect((await put({ kind: "openai", model: "m", apiKey: "k", baseUrl: "http://ollama.internal:11434/v1" })).status).toBe(200);
|
||||
expect((await put({ kind: "openai", model: "m", apiKey: "k", baseUrl: "https://llm.example.org/v1/" })).status).toBe(200);
|
||||
// The admin's server-scope provider may use any endpoint (Ollama on localhost).
|
||||
expect((await call("PUT", "/api/ai/admin/config", "owner", { provider: { kind: "openai", model: "llama", baseUrl: "http://127.0.0.1:11434/v1" }, mcpServers: [] })).status).toBe(200);
|
||||
});
|
||||
|
||||
forEachDb("the admin allowlist in the server config enables an origin for BYOK, and removing it disables the key", async (db) => {
|
||||
const { call } = await setup(db);
|
||||
const put = () => call("PUT", "/api/ai/account/key", "ann", { kind: "openai", model: "m", apiKey: "k", baseUrl: "http://gpu-box:8000/v1" });
|
||||
expect((await put()).status).toBe(400);
|
||||
expect((await call("PUT", "/api/ai/admin/config", "owner", { provider: null, mcpServers: [], allowedBaseUrls: ["http://gpu-box:8000/v1"] })).status).toBe(400); // origins only
|
||||
expect((await call("PUT", "/api/ai/admin/config", "ann", { provider: null, mcpServers: [], allowedBaseUrls: ["http://gpu-box:8000"] })).status).toBe(403);
|
||||
expect((await call("PUT", "/api/ai/admin/config", "owner", { provider: null, mcpServers: [], allowedBaseUrls: ["http://gpu-box:8000"] })).status).toBe(200);
|
||||
expect((await (await call("GET", "/api/ai/admin/config", "owner")).json()).allowedBaseUrls).toEqual(["http://gpu-box:8000"]);
|
||||
expect((await put()).status).toBe(200);
|
||||
expect(await (await call("GET", "/api/ai/status?courseId=sample", "ann")).json()).toMatchObject({ enabled: true, source: "byok" });
|
||||
// Saving the config without the field keeps the list; an empty list removes it, and the saved key stops being used.
|
||||
await call("PUT", "/api/ai/admin/config", "owner", { provider: null, mcpServers: [] });
|
||||
expect((await (await call("GET", "/api/ai/admin/config", "owner")).json()).allowedBaseUrls).toEqual(["http://gpu-box:8000"]);
|
||||
await call("PUT", "/api/ai/admin/config", "owner", { provider: null, mcpServers: [], allowedBaseUrls: [] });
|
||||
expect(await (await call("GET", "/api/ai/status?courseId=sample", "ann")).json()).toMatchObject({ enabled: false });
|
||||
});
|
||||
|
||||
forEachDb("provider errors on a user's own key show the status and a fixed message, never the upstream body", async (db) => {
|
||||
const logs: string[] = [];
|
||||
const seen: { url: string; redirect?: RequestRedirect }[] = [];
|
||||
const fakeFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
seen.push({ url: String(input), redirect: init?.redirect });
|
||||
return new Response(JSON.stringify({ error: { message: "INTERNAL-SECRET-BODY from upstream" } }), { status: 502, headers: { "content-type": "application/json" } });
|
||||
}) as typeof fetch;
|
||||
const { call } = await setup(db, undefined, { providerFactory: undefined, fetch: fakeFetch, log: (m) => logs.push(m), env: { STC_AI_ALLOWED_BASE_URLS: "http://ollama.internal:11434" } });
|
||||
expect((await call("PUT", "/api/ai/account/key", "ann", { kind: "openai", model: "m", apiKey: "k", baseUrl: "http://ollama.internal:11434/v1" })).status).toBe(200);
|
||||
const text = await (await call("POST", "/api/ai/courses/sample/chat", "ann", { message: "hello" })).text();
|
||||
expect(text).not.toContain("INTERNAL-SECRET-BODY");
|
||||
const events = text.trim().split("\n").map((l) => JSON.parse(l));
|
||||
expect(events.find((e) => e.type === "error").message).toBe("502: The AI provider returned an error. Check your key, model and endpoint under Account, AI.");
|
||||
expect(seen).toEqual([{ url: "http://ollama.internal:11434/v1/chat/completions", redirect: "manual" }]);
|
||||
expect(logs.some((l) => l.includes("INTERNAL-SECRET-BODY") && l.includes("ann"))).toBe(true);
|
||||
|
||||
// The admin's server provider keeps the full message for the owner to debug.
|
||||
await call("DELETE", "/api/ai/account/key", "ann");
|
||||
await call("PUT", "/api/ai/admin/config", "owner", { provider: { kind: "openai", model: "m", baseUrl: "http://127.0.0.1:11434/v1" }, mcpServers: [] });
|
||||
const t2 = await (await call("POST", "/api/ai/courses/sample/chat", "owner", { message: "hello" })).text();
|
||||
expect(t2).toContain("502: INTERNAL-SECRET-BODY");
|
||||
});
|
||||
|
||||
forEachDb("authoring uses core canView: a global author needs a grant for a restricted course", async (db) => {
|
||||
const { call } = await setup(db);
|
||||
await call("PUT", "/api/ai/admin/config", "owner", { provider: { kind: "anthropic", apiKey: "k" }, mcpServers: [] });
|
||||
const body = { courseId: "secret", kind: "questions", style: "pure-snake", topic: "Adding", sources: [{ kind: "lesson", lessonId: "m2-text" }] };
|
||||
const courses = async (u: string) => ((await (await call("GET", "/api/ai/studio/courses", u)).json()) as { id: string }[]).map((c) => c.id).sort();
|
||||
// Public course: the author role is enough. Restricted course: not without a grant.
|
||||
expect(await courses("auth")).toEqual(["sample"]);
|
||||
expect((await call("GET", "/api/ai/studio/sources?courseId=secret", "auth")).status).toBe(403);
|
||||
expect((await call("POST", "/api/ai/studio/generate", "auth", body)).status).toBe(403);
|
||||
// The owner sees everything.
|
||||
expect(await courses("owner")).toEqual(["sample", "secret"]);
|
||||
expect((await call("POST", "/api/ai/studio/generate", "owner", body)).status).toBe(202);
|
||||
// An ACL grant (core canView) lets the global author in.
|
||||
await db.sql`INSERT INTO course_acl (course_id, principal_type, principal_id) VALUES ('secret', 'user', 'auth')`;
|
||||
expect(await courses("auth")).toEqual(["sample", "secret"]);
|
||||
expect((await call("POST", "/api/ai/studio/generate", "auth", body)).status).toBe(202);
|
||||
// A course_authors row makes a learner an author of that course only.
|
||||
expect((await call("POST", "/api/ai/studio/generate", "bob", body)).status).toBe(403);
|
||||
await db.sql`INSERT INTO course_authors (course_id, user_id) VALUES ('secret', 'bob')`;
|
||||
expect(await courses("bob")).toEqual(["secret"]);
|
||||
expect((await call("POST", "/api/ai/studio/generate", "bob", body)).status).toBe(202);
|
||||
// A grant to view alone does not make a learner an author.
|
||||
await db.sql`INSERT INTO course_acl (course_id, principal_type, principal_id) VALUES ('secret', 'user', 'ann')`;
|
||||
expect((await call("POST", "/api/ai/studio/generate", "ann", body)).status).toBe(403);
|
||||
});
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
// A fake MCP server for tests. stdio: newline-delimited JSON-RPC on stdin/stdout.
|
||||
// Exported `handle` is reused by the HTTP fake in mcp.test.ts.
|
||||
export function handle(msg: any): any | null {
|
||||
if (msg.id === undefined) return null; // notification
|
||||
switch (msg.method) {
|
||||
case "initialize":
|
||||
return { jsonrpc: "2.0", id: msg.id, result: { protocolVersion: "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "fake", version: "1" } } };
|
||||
case "tools/list":
|
||||
return { jsonrpc: "2.0", id: msg.id, result: { tools: [{ name: "echo", description: "Echo text back", inputSchema: { type: "object", properties: { text: { type: "string" } }, required: ["text"] } }] } };
|
||||
case "tools/call":
|
||||
if (msg.params?.name === "env") return { jsonrpc: "2.0", id: msg.id, result: { content: [{ type: "text", text: JSON.stringify(process.env) }] } };
|
||||
if (msg.params?.name !== "echo") return { jsonrpc: "2.0", id: msg.id, error: { code: -32602, message: `Unknown tool ${msg.params?.name}` } };
|
||||
return { jsonrpc: "2.0", id: msg.id, result: { content: [{ type: "text", text: `echo: ${msg.params.arguments?.text}` }] } };
|
||||
default:
|
||||
return { jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: "Method not found" } };
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
if (process.argv.includes("--exit-immediately")) process.exit(3);
|
||||
let buf = "";
|
||||
for await (const chunk of Bun.stdin.stream()) {
|
||||
buf += new TextDecoder().decode(chunk);
|
||||
let nl: number;
|
||||
while ((nl = buf.indexOf("\n")) !== -1) {
|
||||
const line = buf.slice(0, nl).trim();
|
||||
buf = buf.slice(nl + 1);
|
||||
if (!line) continue;
|
||||
const reply = handle(JSON.parse(line));
|
||||
if (reply) process.stdout.write(JSON.stringify(reply) + "\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { afterAll, beforeAll, expect, test } from "bun:test";
|
||||
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createCourseRepository, type CourseRepository } from "@stc/course-format";
|
||||
import { buildGeneratorPrompt, gatherMaterial, generateDraft, parseFiles, checkHouseStyle, type GenerateRequest } from "../src/generator/generate";
|
||||
import { createFsDraftSink, type DraftSink } from "../src/generator/draft-sink";
|
||||
import { fakeProvider } from "./helpers";
|
||||
|
||||
const FIXTURES = join(import.meta.dir, "../../../fixtures/courses");
|
||||
let repo: CourseRepository;
|
||||
let dataDir: string;
|
||||
beforeAll(async () => {
|
||||
repo = await createCourseRepository([FIXTURES]);
|
||||
dataDir = mkdtempSync(join(tmpdir(), "stc-ai-gen-"));
|
||||
});
|
||||
afterAll(() => rmSync(dataDir, { recursive: true, force: true }));
|
||||
|
||||
const req = (over: Partial<GenerateRequest> = {}): GenerateRequest => ({
|
||||
courseId: "sample", kind: "document", style: "pure-snake", topic: "Adding many numbers",
|
||||
sources: [{ kind: "lesson", lessonId: "m2-text" }, { kind: "text", label: "Author notes", text: "Learners confuse sum and count." }], ...over,
|
||||
});
|
||||
|
||||
test("Pure Snake style prompt carries the module shape and hint tiers", async () => {
|
||||
const course = repo.get("sample")!;
|
||||
const material = await gatherMaterial(repo, course, req().sources, dataDir);
|
||||
const p = buildGeneratorPrompt(req(), material.text, course);
|
||||
for (const s of ["## Why this module", "## Learning objectives", "## Concepts", "Think about it first, then open the explanation", "From everyday programming", "— nudge", "— stronger hint", "— near-solution", "## Check your understanding", "## In production", "## Further reading", "=== FILE:"]) {
|
||||
expect(p.system).toContain(s);
|
||||
}
|
||||
expect(p.user).toContain("Functions take values and return values.");
|
||||
expect(p.user).toContain("Learners confuse sum and count.");
|
||||
expect(p.user).toContain("course/adding-many-numbers.md");
|
||||
});
|
||||
|
||||
test("Systemic Design style prompt carries the beats", () => {
|
||||
const course = repo.get("sample")!;
|
||||
const p = buildGeneratorPrompt(req({ style: "systemic-design" }), "material", course);
|
||||
const beats = ["## Observe", "## Predict", "## Spec", "## Build", "## Name it", "## Expansion Drop", "## Further reading"];
|
||||
let last = -1;
|
||||
for (const b of beats) { const i = p.system.indexOf(b); expect(i).toBeGreaterThan(last); last = i; }
|
||||
for (const s of ["Hint 1 — nudge", "Hint 2 — concept", "Hint 3 — pseudo-code", "what this drop measures", "scenarios.md"]) expect(p.system).toContain(s);
|
||||
});
|
||||
|
||||
test("exercise and question prompts", () => {
|
||||
const course = repo.get("sample")!;
|
||||
const ex = buildGeneratorPrompt(req({ kind: "exercise" }), "m", course);
|
||||
for (const s of ["starter.js", "tests.js", "solution.js", "export const tests", "fixtures/", "nudge", "stronger hint", "near-solution"]) expect(ex.system + ex.user).toContain(s);
|
||||
const q = buildGeneratorPrompt(req({ kind: "questions" }), "m", course);
|
||||
expect(q.system).toContain("Check your understanding");
|
||||
expect(q.system).toMatch(/predict/i);
|
||||
expect(q.system).toMatch(/what would break if/i);
|
||||
});
|
||||
|
||||
test("parseFiles reads file blocks and rejects unsafe paths", () => {
|
||||
const out = parseFiles("intro\n=== FILE: course/a.md ===\n# A\n=== END FILE ===\n=== FILE: ../escape.md ===\nx\n=== END FILE ===\n=== FILE: /abs.md ===\ny\n=== END FILE ===\n=== FILE: exercises/b/starter.js ===\nexport const b = 1\n=== END FILE ===");
|
||||
expect(out.files).toEqual([{ path: "course/a.md", content: "# A\n" }, { path: "exercises/b/starter.js", content: "export const b = 1\n" }]);
|
||||
expect(out.warnings.join("\n")).toMatch(/escape\.md/);
|
||||
expect(out.warnings.join("\n")).toMatch(/abs\.md/);
|
||||
expect(parseFiles("no files here").warnings[0]).toMatch(/no files/i);
|
||||
});
|
||||
|
||||
test("house style check flags missing tiers", () => {
|
||||
const w = checkHouseStyle(req(), [{ path: "course/x.md", content: "# M\n## Why this module\n" }]);
|
||||
expect(w.some((x) => x.includes("Hints"))).toBe(true);
|
||||
expect(w.some((x) => x.includes("near-solution"))).toBe(true);
|
||||
});
|
||||
|
||||
test("generation writes drafts through the sink and never touches the course", async () => {
|
||||
const before = readdirSync(join(FIXTURES, "sample"), { recursive: true }).length;
|
||||
const doc = [
|
||||
"# Module 3 — Adding many numbers", "## Why this module", "x", "## Learning objectives", "## Concepts", "<details><summary>Think about it first, then open the explanation</summary>", "y", "</details>",
|
||||
"## Exercises", "## Hints", "<details><summary>Exercise 3.1 (sum) — nudge</summary></details>", "<details><summary>Exercise 3.1 (sum) — stronger hint</summary></details>",
|
||||
"<details><summary>Exercise 3.1 (sum) — near-solution (try the others first)</summary></details>", "## Check your understanding", "1. Predict…", "## In production", "## Further reading",
|
||||
].join("\n");
|
||||
const { provider, seen } = fakeProvider([() => ({ text: `Here you go.\n=== FILE: course/adding-many-numbers.md ===\n${doc}\n=== END FILE ===\n` })]);
|
||||
const sink = createFsDraftSink(dataDir);
|
||||
const r = await generateDraft({ provider, repo, req: req(), sink, authorId: "author-1", dataDir });
|
||||
expect(seen[0]!.tools).toBeUndefined();
|
||||
expect(r.paths).toEqual(["course/adding-many-numbers.md"]);
|
||||
expect(r.location.startsWith(join(dataDir, "drafts", "sample"))).toBe(true);
|
||||
expect(r.warnings).toEqual([]);
|
||||
const written = readFileSync(join(r.location, "course/adding-many-numbers.md"), "utf8");
|
||||
expect(written.startsWith("<!-- Draft generated by STCourses AI")).toBe(true);
|
||||
expect(written).toContain("## Why this module");
|
||||
const meta = JSON.parse(readFileSync(join(r.location, "DRAFT.json"), "utf8"));
|
||||
expect(meta).toMatchObject({ courseId: "sample", authorId: "author-1", published: false });
|
||||
expect(readdirSync(join(FIXTURES, "sample"), { recursive: true }).length).toBe(before);
|
||||
expect(existsSync(join(FIXTURES, "sample", "course"))).toBe(false);
|
||||
});
|
||||
|
||||
// The studio's DraftWriter (`createDraftWriter(ctx)` in @stc/feature-studio), reproduced structurally: it commits
|
||||
// to `draft/<authorId>` and returns { branch, commit }. It must be usable as a DraftSink without an adapter.
|
||||
interface StudioDraftWriterShape {
|
||||
writeDraftFiles(courseId: string, authorId: string, files: { path: string; content: string }[], message: string): Promise<{ branch: string; commit: string }>;
|
||||
}
|
||||
|
||||
test("a studio DraftWriter is a DraftSink as is, and the result names the draft branch", async () => {
|
||||
const calls: { courseId: string; authorId: string; paths: string[]; message: string }[] = [];
|
||||
const writer: StudioDraftWriterShape = {
|
||||
async writeDraftFiles(courseId, authorId, files, message) {
|
||||
calls.push({ courseId, authorId, paths: files.map((f) => f.path), message });
|
||||
return { branch: `draft/${authorId}`, commit: "0123456789abcdef0123456789abcdef01234567" };
|
||||
},
|
||||
};
|
||||
const sink: DraftSink = writer;
|
||||
const { provider } = fakeProvider([() => ({ text: "=== FILE: questions/adding.md ===\n## Check your understanding\n=== END FILE ===\n" })]);
|
||||
const r = await generateDraft({ provider, repo, req: req({ kind: "questions" }), sink, authorId: "author-1", dataDir });
|
||||
expect(calls).toEqual([{ courseId: "sample", authorId: "author-1", paths: ["questions/adding.md"], message: expect.stringContaining("AI draft") }]);
|
||||
expect(r).toMatchObject({ branch: "draft/author-1", commit: "0123456789abcdef0123456789abcdef01234567", paths: ["questions/adding.md"] });
|
||||
expect(r.location).toBe("draft/author-1 @ 0123456");
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { AiProvider, ChatMessage, ToolSpec } from "@stc/contracts";
|
||||
|
||||
/** SSE body from a list of [event, data] pairs, split into awkward chunks to exercise buffering. */
|
||||
export function sseBody(events: [string | null, unknown][], chunk = 7): ReadableStream<Uint8Array> {
|
||||
const text = events.map(([e, d]) => `${e ? `event: ${e}\n` : ""}data: ${typeof d === "string" ? d : JSON.stringify(d)}\n\n`).join("");
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
let i = 0;
|
||||
return new ReadableStream({
|
||||
pull(c) {
|
||||
if (i >= bytes.length) return c.close();
|
||||
c.enqueue(bytes.slice(i, i + chunk));
|
||||
i += chunk;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type Captured = { url: string; init: RequestInit; body: any };
|
||||
/** A fake fetch that records requests and answers from a queue of responses. */
|
||||
export function fakeFetch(responses: (() => Response)[]) {
|
||||
const calls: Captured[] = [];
|
||||
const f = (async (url: string | URL | Request, init?: RequestInit) => {
|
||||
calls.push({ url: String(url), init: init ?? {}, body: init?.body ? JSON.parse(String(init.body)) : null });
|
||||
const next = responses.shift();
|
||||
if (!next) throw new Error("fakeFetch: no response queued");
|
||||
return next();
|
||||
}) as unknown as typeof fetch;
|
||||
return { fetch: f, calls };
|
||||
}
|
||||
|
||||
export async function collect(it: AsyncIterable<{ text?: string; toolCall?: { id: string; name: string; input: unknown } }>) {
|
||||
let text = "";
|
||||
const toolCalls: { id: string; name: string; input: unknown }[] = [];
|
||||
for await (const ev of it) {
|
||||
if (ev.text) text += ev.text;
|
||||
if (ev.toolCall) toolCalls.push(ev.toolCall);
|
||||
}
|
||||
return { text, toolCalls };
|
||||
}
|
||||
|
||||
/** Scripted fake provider: each call to chat() plays the next turn and records the messages it got. */
|
||||
export function fakeProvider(turns: ((messages: ChatMessage[], tools?: ToolSpec[]) => { text?: string; toolCalls?: { id: string; name: string; input: unknown }[] })[]) {
|
||||
const seen: { messages: ChatMessage[]; tools?: ToolSpec[] }[] = [];
|
||||
const provider: AiProvider = {
|
||||
id: "fake",
|
||||
async *chat(messages, opts) {
|
||||
seen.push({ messages: structuredClone(messages), tools: opts.tools });
|
||||
const turn = turns[Math.min(seen.length - 1, turns.length - 1)]!(messages, opts.tools);
|
||||
if (turn.text) for (const part of turn.text.match(/.{1,8}/gs) ?? []) yield { text: part };
|
||||
for (const tc of turn.toolCalls ?? []) yield { toolCall: tc };
|
||||
},
|
||||
};
|
||||
return { provider, seen };
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { afterAll, expect, test } from "bun:test";
|
||||
import { join } from "node:path";
|
||||
import { connectMcp, mcpServersFor, mcpToolName } from "../src/mcp/client";
|
||||
import { handle } from "./fixtures/fake-mcp";
|
||||
|
||||
const FAKE = join(import.meta.dir, "fixtures/fake-mcp.ts");
|
||||
|
||||
test("stdio: lists and calls tools", async () => {
|
||||
const c = await connectMcp({ id: "fake", name: "Fake", transport: "stdio", command: process.execPath, args: [FAKE] });
|
||||
try {
|
||||
expect((await c.tools()).map((t) => t.name)).toEqual(["echo"]);
|
||||
expect(await c.call("echo", { text: "hi" })).toBe("echo: hi");
|
||||
await expect(c.call("nope", {})).rejects.toThrow(/Unknown tool/);
|
||||
} finally { await c.close(); }
|
||||
});
|
||||
|
||||
test("stdio: a server that exits rejects instead of hanging", async () => {
|
||||
await expect(connectMcp({ id: "x", name: "X", transport: "stdio", command: process.execPath, args: [FAKE, "--exit-immediately"] }, { timeoutMs: 5000 })).rejects.toThrow();
|
||||
});
|
||||
|
||||
const sessions: (string | null)[] = [];
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
if (req.method === "DELETE") return new Response(null, { status: 204 });
|
||||
const msg = await req.json();
|
||||
sessions.push(req.headers.get("mcp-session-id"));
|
||||
const reply = handle(msg);
|
||||
if (!reply) return new Response(null, { status: 202 });
|
||||
const headers: Record<string, string> = msg.method === "initialize" ? { "mcp-session-id": "sess-42" } : {};
|
||||
if (msg.method === "tools/call") {
|
||||
return new Response(`event: message\ndata: ${JSON.stringify(reply)}\n\n`, { headers: { ...headers, "content-type": "text/event-stream" } });
|
||||
}
|
||||
return Response.json(reply, { headers });
|
||||
},
|
||||
});
|
||||
afterAll(() => server.stop(true));
|
||||
|
||||
test("streamable HTTP: JSON and SSE replies, session id echoed", async () => {
|
||||
const c = await connectMcp({ id: "web", name: "Web", transport: "http", url: `http://127.0.0.1:${server.port}/mcp` });
|
||||
expect((await c.tools()).map((t) => t.name)).toEqual(["echo"]);
|
||||
expect(await c.call("echo", { text: "over http" })).toBe("echo: over http");
|
||||
await c.close();
|
||||
expect(sessions[0]).toBeNull();
|
||||
expect(sessions.slice(1).every((s) => s === "sess-42")).toBe(true);
|
||||
});
|
||||
|
||||
test("server selection and tool names", () => {
|
||||
const servers = [
|
||||
{ id: "g", name: "G", transport: "http" as const, url: "http://x" },
|
||||
{ id: "s", name: "S", transport: "http" as const, url: "http://y", courseIds: ["sample"] },
|
||||
{ id: "o", name: "O", transport: "http" as const, url: "http://z", courseIds: ["other"] },
|
||||
];
|
||||
expect(mcpServersFor("sample", servers).map((s) => s.id)).toEqual(["g", "s"]);
|
||||
expect(mcpToolName("my server", "read file!")).toBe("mcp_my_server__read_file_");
|
||||
expect(mcpToolName("a".repeat(40), "b".repeat(40)).length).toBeLessThanOrEqual(64);
|
||||
});
|
||||
|
||||
test("stdio: the child gets a minimal environment plus only its configured variables", async () => {
|
||||
const saved = { ...process.env };
|
||||
Object.assign(process.env, {
|
||||
STC_SECRET: "server-secret", STC_DATABASE_URL: "postgres://u:p@db/x", DATABASE_URL: "postgres://u:p@db/y",
|
||||
ANTHROPIC_API_KEY: "sk-ant-x", MY_SECRET: "s", AWS_SECRET_ACCESS_KEY: "aws", LANG: "C.UTF-8",
|
||||
});
|
||||
try {
|
||||
const c = await connectMcp({ id: "fake", name: "Fake", transport: "stdio", command: process.execPath, args: [FAKE], env: { MCP_TOKEN: "t", STC_EXPLICIT: "configured" } });
|
||||
try {
|
||||
const env = JSON.parse(await c.call("env", {})) as Record<string, string>;
|
||||
for (const k of ["STC_SECRET", "STC_DATABASE_URL", "DATABASE_URL", "ANTHROPIC_API_KEY", "MY_SECRET", "AWS_SECRET_ACCESS_KEY"]) expect(env[k]).toBeUndefined();
|
||||
expect(env.MCP_TOKEN).toBe("t");
|
||||
expect(env.STC_EXPLICIT).toBe("configured"); // explicitly configured by the admin
|
||||
expect(env.LANG).toBe("C.UTF-8");
|
||||
if (saved.PATH) expect(env.PATH).toBe(saved.PATH);
|
||||
if (saved.HOME) expect(env.HOME).toBe(saved.HOME);
|
||||
const unexpected = Object.keys(env).filter((k) => !["PATH", "HOME", "LANG", "MCP_TOKEN", "STC_EXPLICIT"].includes(k));
|
||||
expect(unexpected.filter((k) => /STC_|DATABASE|SECRET|_KEY$/i.test(k))).toEqual([]);
|
||||
} finally { await c.close(); }
|
||||
} finally {
|
||||
for (const k of Object.keys(process.env)) if (!(k in saved)) delete process.env[k];
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { createOpenAiProvider } from "../src/providers/openai";
|
||||
import { createProvider } from "../src/providers";
|
||||
import { collect, fakeFetch, sseBody } from "./helpers";
|
||||
|
||||
const ok = (events: [string | null, unknown][]) => () => new Response(sseBody(events));
|
||||
const chunk = (delta: unknown, finish: string | null = null) => [null, { choices: [{ index: 0, delta, finish_reason: finish }] }] as [null, unknown];
|
||||
|
||||
test("streams text deltas from an Ollama-style endpoint without a key", async () => {
|
||||
const ff = fakeFetch([ok([chunk({ role: "assistant", content: "Hel" }), chunk({ content: "lo" }), chunk({}, "stop"), [null, "[DONE]"]])]);
|
||||
const p = createOpenAiProvider({ baseUrl: "http://ollama:11434/v1/", model: "llama3.1", fetch: ff.fetch });
|
||||
const r = await collect(p.chat([{ role: "system", content: "sys" }, { role: "user", content: "hi" }], {}));
|
||||
expect(r.text).toBe("Hello");
|
||||
const c = ff.calls[0]!;
|
||||
expect(c.url).toBe("http://ollama:11434/v1/chat/completions");
|
||||
expect(new Headers(c.init.headers).get("authorization")).toBeNull();
|
||||
expect(c.body).toMatchObject({ model: "llama3.1", stream: true, messages: [{ role: "system", content: "sys" }, { role: "user", content: "hi" }] });
|
||||
});
|
||||
|
||||
test("assembles fragmented tool calls and maps a tool round back", async () => {
|
||||
const ff = fakeFetch([
|
||||
ok([
|
||||
chunk({ tool_calls: [{ index: 0, id: "call_1", type: "function", function: { name: "read_lesson", arguments: "" } }] }),
|
||||
chunk({ tool_calls: [{ index: 0, function: { arguments: "{\"lessonId\":" } }] }),
|
||||
chunk({ tool_calls: [{ index: 0, function: { arguments: "\"m1-intro\"}" } }] }),
|
||||
chunk({}, "tool_calls"),
|
||||
[null, "[DONE]"],
|
||||
]),
|
||||
ok([chunk({ content: "Done" }), [null, "[DONE]"]]),
|
||||
]);
|
||||
const p = createOpenAiProvider({ baseUrl: "https://api.example.com/v1", apiKey: "sk-x", model: "gpt-x", fetch: ff.fetch });
|
||||
const tools = [{ name: "read_lesson", description: "Read", inputSchema: { type: "object" } }];
|
||||
const r = await collect(p.chat([{ role: "user", content: "q" }], { tools }));
|
||||
expect(r.toolCalls).toEqual([{ id: "call_1", name: "read_lesson", input: { lessonId: "m1-intro" } }]);
|
||||
expect(ff.calls[0]!.body.tools).toEqual([{ type: "function", function: { name: "read_lesson", description: "Read", parameters: { type: "object" } } }]);
|
||||
expect(new Headers(ff.calls[0]!.init.headers).get("authorization")).toBe("Bearer sk-x");
|
||||
|
||||
await collect(p.chat([
|
||||
{ role: "user", content: "q" },
|
||||
{ role: "assistant", content: "Reading." },
|
||||
{ role: "assistant", content: "{\"lessonId\":\"m1-intro\"}", toolCallId: "call_1", name: "read_lesson" },
|
||||
{ role: "tool", content: "# Introduction", toolCallId: "call_1", name: "read_lesson" },
|
||||
], { tools }));
|
||||
expect(ff.calls[1]!.body.messages).toEqual([
|
||||
{ role: "user", content: "q" },
|
||||
{ role: "assistant", content: "Reading.", tool_calls: [{ id: "call_1", type: "function", function: { name: "read_lesson", arguments: "{\"lessonId\":\"m1-intro\"}" } }] },
|
||||
{ role: "tool", tool_call_id: "call_1", content: "# Introduction" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("errors carry the endpoint message", async () => {
|
||||
const ff = fakeFetch([() => Response.json({ error: { message: "model not found" } }, { status: 404 })]);
|
||||
await expect(collect(createOpenAiProvider({ baseUrl: "http://x/v1", model: "m", fetch: ff.fetch }).chat([{ role: "user", content: "x" }], {}))).rejects.toThrow(/model not found/);
|
||||
});
|
||||
|
||||
test("createProvider picks the implementation and defaults", () => {
|
||||
expect(createProvider({ kind: "anthropic" }, "k").id).toBe("anthropic");
|
||||
expect(createProvider({ kind: "openai", baseUrl: "http://x/v1", model: "m" }, null).id).toBe("openai");
|
||||
expect(() => createProvider({ kind: "anthropic" }, null)).toThrow(/key/);
|
||||
expect(() => createProvider({ kind: "openai" }, null)).toThrow(/base URL|model/);
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { afterAll, beforeAll, expect, test } from "bun:test";
|
||||
import { cpSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createCourseRepository, type LoadedCourse } from "@stc/course-format";
|
||||
import { Bm25, tokenize } from "../src/retrieval/bm25";
|
||||
import { buildCourseIndex, getCourseIndex, isSolutionPath, noteChunks } from "../src/retrieval/course-index";
|
||||
|
||||
const FIXTURES = join(import.meta.dir, "../../../fixtures/courses");
|
||||
let course: LoadedCourse;
|
||||
let dataDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const repo = await createCourseRepository([FIXTURES]);
|
||||
course = repo.get("sample")!;
|
||||
dataDir = mkdtempSync(join(tmpdir(), "stc-ai-"));
|
||||
mkdirSync(join(dataDir, "cache/sample/decks/d1"), { recursive: true });
|
||||
writeFileSync(join(dataDir, "cache/sample/decks/d1/page-3.txt"), "Container images are layered filesystems built from a Dockerfile.");
|
||||
mkdirSync(join(dataDir, "cache/sample/transcripts"), { recursive: true });
|
||||
writeFileSync(join(dataDir, "cache/sample/transcripts/m1-video.txt"), "In this clip the presenter explains the test pattern calibration.");
|
||||
});
|
||||
afterAll(() => rmSync(dataDir, { recursive: true, force: true }));
|
||||
|
||||
test("tokenize lowercases, drops stopwords and splits identifiers", () => {
|
||||
expect(tokenize("The inBounds function, and dropLast!")).toEqual(["inbound", "in", "bound", "function", "droplast", "drop", "last"]);
|
||||
expect(tokenize("Æble på dansk")).toEqual(["æble", "på", "dansk"]);
|
||||
});
|
||||
|
||||
test("BM25 ranks the more relevant document first", () => {
|
||||
const b = new Bm25<string>();
|
||||
b.add("snake moves one cell per tick", "a");
|
||||
b.add("apples spawn at random free cells", "b");
|
||||
b.add("the snake grows when it eats an apple apple", "c");
|
||||
expect(b.search("apple", 5).map((r) => r.item)).toEqual(["c", "b"]);
|
||||
expect(b.search("zebra", 5)).toEqual([]);
|
||||
});
|
||||
|
||||
test("finds lesson text and maps it to a lesson", async () => {
|
||||
const idx = await buildCourseIndex(course, dataDir);
|
||||
const hits = idx.search("sum of its arguments", 3);
|
||||
expect(hits[0]!.path).toBe("lessons/04-exercise.md");
|
||||
expect(["m2-text", "m2-ex-add"]).toContain(hits[0]!.lessonId!);
|
||||
expect(hits[0]!.lessonTitle).toBeTruthy();
|
||||
});
|
||||
|
||||
test("indexes code, transcripts and deck text from the cache", async () => {
|
||||
const idx = await buildCourseIndex(course, dataDir);
|
||||
const code = idx.search("hello.sh", 5).find((c) => c.kind === "code");
|
||||
expect(code?.path).toBe("code/S01 - First steps/hello.sh");
|
||||
const deck = idx.search("Dockerfile layered images", 3)[0]!;
|
||||
expect(deck.kind).toBe("deck");
|
||||
expect(deck.path).toBe("media/deck.pdf");
|
||||
expect(deck.heading).toBe("Slide 3");
|
||||
expect(deck.lessonId).toBe("m1-video");
|
||||
const tr = idx.search("calibration presenter", 3)[0]!;
|
||||
expect(tr.kind).toBe("transcript");
|
||||
expect(tr.lessonId).toBe("m1-video");
|
||||
});
|
||||
|
||||
test("solution files are never indexed", async () => {
|
||||
const idx = await buildCourseIndex(course, dataDir);
|
||||
for (const q of ["add", "return a + b", "export const add"]) {
|
||||
expect(idx.search(q, 50).some((c) => c.path.includes("solution"))).toBe(false);
|
||||
}
|
||||
expect(isSolutionPath(course, "exercises/add/solution.js")).toBe(true);
|
||||
expect(isSolutionPath(course, "js/solutions/rules.js")).toBe(true);
|
||||
expect(isSolutionPath(course, "course/01-handles/reference/ts/src/engine.ts")).toBe(true);
|
||||
expect(isSolutionPath(course, "course/01-handles/expansion/solution/x.rs")).toBe(true);
|
||||
expect(isSolutionPath(course, "exercises/add/starter.js")).toBe(false);
|
||||
});
|
||||
|
||||
test("the mentor file and the manifest are not retrievable", async () => {
|
||||
const idx = await buildCourseIndex(course, dataDir);
|
||||
expect(idx.search("mentor hints never full solutions", 50).some((c) => c.path === "MENTOR.md" || c.path === "stcourse.yaml")).toBe(false);
|
||||
});
|
||||
|
||||
test("notes are searched alongside the course", async () => {
|
||||
const idx = await getCourseIndex(course, dataDir);
|
||||
expect(await getCourseIndex(course, dataDir)).toBe(idx);
|
||||
const extra = noteChunks(course, [{ id: "n1", lessonId: "m1-intro", body: "Remember: zebras are striped horses", anchorKind: "none", anchorValue: null }]);
|
||||
const hits = idx.search("zebras striped", 3, extra);
|
||||
expect(hits[0]).toMatchObject({ kind: "note", lessonId: "m1-intro", lessonTitle: "Introduction" });
|
||||
});
|
||||
|
||||
// The importer's layout (feature-import): <dataDir>/cache/decks/<courseId>/<deckId>/text.json ([{ page, text }])
|
||||
// and <dataDir>/cache/transcripts/<courseId>/<lessonId>.json ({ lessonId, text, segments: [{ start, end, text }] }).
|
||||
test("reads the importer's deck text.json and transcript cache", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "stc-ai-imp-"));
|
||||
try {
|
||||
const courses = join(dir, "courses");
|
||||
cpSync(join(FIXTURES, "sample"), join(courses, "sample"), { recursive: true });
|
||||
const yaml = join(courses, "sample/stcourse.yaml");
|
||||
writeFileSync(yaml, readFileSync(yaml, "utf8").replace(", transcript: lessons/02-video.md", ""));
|
||||
const c = (await createCourseRepository([courses])).get("sample")!;
|
||||
expect(c.status).toBe("ok");
|
||||
const data = join(dir, "data");
|
||||
mkdirSync(join(data, "cache/decks/sample/d1"), { recursive: true });
|
||||
writeFileSync(join(data, "cache/decks/sample/d1/text.json"), JSON.stringify([
|
||||
{ page: 1, text: "Title slide" },
|
||||
{ page: 2, text: "Orchestration with Kubernetes pods and replica sets." },
|
||||
{ page: 3, text: "" },
|
||||
]));
|
||||
mkdirSync(join(data, "cache/transcripts/sample"), { recursive: true });
|
||||
writeFileSync(join(data, "cache/transcripts/sample/m1-video.json"), JSON.stringify({
|
||||
lessonId: "m1-video",
|
||||
text: "Welcome. Today the presenter shows a waveform monitor.",
|
||||
segments: [{ start: 0, end: 4, text: "Welcome." }, { start: 65, end: 70, text: "Today the presenter shows a waveform monitor." }],
|
||||
}));
|
||||
const idx = await buildCourseIndex(c, data);
|
||||
const deck = idx.search("Kubernetes replica sets", 3)[0]!;
|
||||
expect(deck).toMatchObject({ kind: "deck", path: "media/deck.pdf", heading: "Slide 2", lessonId: "m1-video" });
|
||||
const tr = idx.search("waveform monitor", 3)[0]!;
|
||||
expect(tr).toMatchObject({ kind: "transcript", lessonId: "m1-video", lessonTitle: "Watch the clip" });
|
||||
expect(tr.text).toContain("waveform monitor");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a cached transcript is skipped when the lesson already has a transcript file", async () => {
|
||||
const data = mkdtempSync(join(tmpdir(), "stc-ai-tr-"));
|
||||
try {
|
||||
mkdirSync(join(data, "cache/transcripts/sample"), { recursive: true });
|
||||
writeFileSync(join(data, "cache/transcripts/sample/m1-video.json"), JSON.stringify({ lessonId: "m1-video", text: "zebracorn unique words", segments: [] }));
|
||||
const idx = await buildCourseIndex(course, data);
|
||||
expect(idx.search("zebracorn", 3)).toEqual([]);
|
||||
} finally {
|
||||
rmSync(data, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { expect } from "bun:test";
|
||||
import { forEachDb } from "../../db/test/helpers";
|
||||
import { createSecretBox } from "../src/crypto";
|
||||
import { aiSettingsRepo } from "../src/settings";
|
||||
|
||||
const box = createSecretBox("test-secret");
|
||||
|
||||
forEachDb("server config is empty by default and hides the key", async (db) => {
|
||||
const r = aiSettingsRepo(db, box);
|
||||
expect(await r.getServer()).toEqual({ provider: null, mcpServers: [], allowedBaseUrls: [] });
|
||||
expect(await r.serverApiKey()).toBeNull();
|
||||
|
||||
await r.saveServer({ provider: { kind: "anthropic", model: "claude-opus-5-5", apiKey: "sk-ant-SECRET" }, mcpServers: [] });
|
||||
const got = await r.getServer();
|
||||
expect(got.provider).toEqual({ kind: "anthropic", model: "claude-opus-5-5", hasApiKey: true });
|
||||
expect(JSON.stringify(got)).not.toContain("SECRET");
|
||||
const raw = await db.sql`SELECT config_json FROM ai_settings WHERE scope = 'server'`;
|
||||
expect(raw[0].config_json).not.toContain("SECRET");
|
||||
expect(await r.serverApiKey()).toBe("sk-ant-SECRET");
|
||||
|
||||
// Saving without apiKey keeps the old key; clearApiKey removes it.
|
||||
await r.saveServer({ provider: { kind: "anthropic", model: "other" }, mcpServers: [{ id: "fs", name: "Files", transport: "stdio", command: "x" }] });
|
||||
expect(await r.serverApiKey()).toBe("sk-ant-SECRET");
|
||||
expect((await r.getServer()).mcpServers).toHaveLength(1);
|
||||
await r.saveServer({ provider: { kind: "openai", baseUrl: "http://ollama:11434/v1", model: "llama3", clearApiKey: true }, mcpServers: [] });
|
||||
expect(await r.serverApiKey()).toBeNull();
|
||||
expect((await r.getServer()).provider).toEqual({ kind: "openai", baseUrl: "http://ollama:11434/v1", model: "llama3", hasApiKey: false });
|
||||
await r.saveServer({ provider: null, mcpServers: [] });
|
||||
expect((await r.getServer()).provider).toBeNull();
|
||||
});
|
||||
|
||||
forEachDb("BYOK keys are encrypted, per user and removable", async (db) => {
|
||||
const r = aiSettingsRepo(db, box);
|
||||
expect((await r.getUser("u1")).byok).toBeNull();
|
||||
await r.saveUserKey("u1", { kind: "anthropic", apiKey: "sk-user-SECRET" });
|
||||
expect((await r.getUser("u1")).byok).toEqual({ kind: "anthropic", hasApiKey: true });
|
||||
expect((await r.getUser("u2")).byok).toBeNull();
|
||||
const raw = await db.sql`SELECT ciphertext FROM user_api_keys WHERE user_id = 'u1'`;
|
||||
expect(raw[0].ciphertext).not.toContain("SECRET");
|
||||
expect(await r.userApiKey("u1")).toBe("sk-user-SECRET");
|
||||
expect(await r.userApiKey("u2")).toBeNull();
|
||||
// replacing with another provider drops the old key
|
||||
await r.saveUserKey("u1", { kind: "openai", baseUrl: "https://api.example.com/v1", model: "m", apiKey: "k2" });
|
||||
expect(await r.userApiKey("u1")).toBe("k2");
|
||||
expect((await db.sql`SELECT COUNT(*) AS n FROM user_api_keys WHERE user_id = 'u1'`)[0].n.toString()).toBe("1");
|
||||
await r.removeUserKey("u1");
|
||||
expect((await r.getUser("u1")).byok).toBeNull();
|
||||
expect(await r.userApiKey("u1")).toBeNull();
|
||||
});
|
||||
|
||||
forEachDb("mentor mode is on by default and toggles per course", async (db) => {
|
||||
const r = aiSettingsRepo(db, box);
|
||||
expect(await r.isMentorOff("u1", "sample")).toBe(false);
|
||||
await r.setMentorOff("u1", "sample", true);
|
||||
expect(await r.isMentorOff("u1", "sample")).toBe(true);
|
||||
expect(await r.isMentorOff("u1", "pure-snake")).toBe(false);
|
||||
expect(await r.isMentorOff("u2", "sample")).toBe(false);
|
||||
await r.saveUserKey("u1", { kind: "anthropic", apiKey: "k" });
|
||||
expect(await r.isMentorOff("u1", "sample")).toBe(true);
|
||||
await r.setMentorOff("u1", "sample", false);
|
||||
expect(await r.isMentorOff("u1", "sample")).toBe(false);
|
||||
expect((await r.getUser("u1")).byok?.kind).toBe("anthropic");
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { readNdjson } from "../src/web/api";
|
||||
import { MentorBanner, Markdown } from "../src/web/components";
|
||||
import { aiWeb } from "../src/web";
|
||||
|
||||
test("readNdjson splits events across chunk boundaries", async () => {
|
||||
const text = '{"type":"meta","n":1}\n{"type":"text","text":"héllo"}\n{"type":"done"}\n';
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(c) { for (let i = 0; i < bytes.length; i += 5) c.enqueue(bytes.slice(i, i + 5)); c.close(); },
|
||||
});
|
||||
const out: unknown[] = [];
|
||||
for await (const ev of readNdjson(stream)) out.push(ev);
|
||||
expect(out).toEqual([{ type: "meta", n: 1 }, { type: "text", text: "héllo" }, { type: "done" }]);
|
||||
});
|
||||
|
||||
test("the mentor banner says so plainly when mentor mode is off", () => {
|
||||
expect(renderToString(<MentorBanner on={false} available />)).toContain("Mentor mode off: I'll give full answers");
|
||||
expect(renderToString(<MentorBanner on available />)).toBe("");
|
||||
expect(renderToString(<MentorBanner on={false} available={false} />)).toBe("");
|
||||
});
|
||||
|
||||
test("markdown renders code and text without raw HTML", () => {
|
||||
const html = renderToString(<Markdown text={"Use **this**:\n\n```js\nconst a = 1 < 2\n```\n\n- one `x`\n- <b>two</b>"} />);
|
||||
expect(html).toContain("<strong>this</strong>");
|
||||
expect(html).toContain('<pre class="code">');
|
||||
expect(html).toContain("const a = 1 < 2");
|
||||
expect(html).toContain("<code>x</code>");
|
||||
expect(html).toContain("<b>two</b>");
|
||||
});
|
||||
|
||||
test("web routes", () => {
|
||||
expect(aiWeb.routes.map((r) => r.path)).toEqual(["/c/:courseId/assistant", "/admin/ai", "/account/ai", "/studio/generate"]);
|
||||
expect(aiWeb.routes.find((r) => r.path === "/admin/ai")!.nav).toEqual({ label: "AI", area: "admin" });
|
||||
});
|
||||
|
||||
test("lesson links go through one builder", async () => {
|
||||
const { lessonUrl, courseUrl } = await import("../src/web/urls");
|
||||
expect(lessonUrl("sample", "m2-ex-add")).toBe("/c/sample/m2-ex-add");
|
||||
expect(lessonUrl("S08 - Docker", "l 1/x")).toBe("/c/S08%20-%20Docker/l%201%2Fx");
|
||||
expect(courseUrl("sample")).toBe("/c/sample");
|
||||
const src = await Bun.file(new URL("../src/web/AssistantPage.tsx", import.meta.url)).text();
|
||||
expect(src).toContain("lessonUrl(courseId, c.lessonId)");
|
||||
expect(src).not.toMatch(/`\/c\//);
|
||||
});
|
||||
Reference in New Issue
Block a user