Skip to main content

07-appkit-chat-history

Add persistent chat history to an AppKit application using the Lakebase plugin and the Serving plugin (both AppKit-native, no Vercel AI SDK). Covers database schema, session auth middleware, a server-side proxy route that streams from `AppKit.serving("agent").asUser(req).stream()` while persisting assistant messages and capturing MLflow `trace_id`, the history API routes, a history sidebar, and ephemeral-mode fallback. Use when asked to persist conversations, add chat history, save messages, or build a conversation sidebar. Triggers on "chat history", "save conversations", "persistent chat", "conversation sidebar", "message storage", "save messages", "ephemeral chat".

Aller à l'installation

Informations de source

Dépôt
databricks-solutions/vibe-coding-workshop-template
Dernière activité de la source
25 juin 2026 à 06:16
Langue détectée de SKILL.md
anglais
Étoiles
6
Forks
7

Options d'installation

Le prompt qui vérifie d'abord la source est sélectionné par défaut. Vous pouvez passer à une commande directe ou télécharger une copie locale.

Vérifiez les fichiers source

Lisez SKILL.md et les fichiers associés affichés par SkillsMP avant de décider de l'installer.

Explorateur de fichiers
3 fichiers

Affichage de SKILL.md

SKILL.md
Instructions source · Aperçu en lecture seule
name
07-appkit-chat-history
description
Add persistent chat history to an AppKit application using the Lakebase plugin and the Serving plugin (both AppKit-native, no Vercel AI SDK). Covers database schema, session auth middleware, a server-side proxy route that streams from `AppKit.serving("agent").asUser(req).stream()` while persisting assistant messages and capturing MLflow `trace_id`, the history API routes, a history sidebar, and ephemeral-mode fallback. Use when asked to persist conversations, add chat history, save messages, or build a conversation sidebar. Triggers on "chat history", "save conversations", "persistent chat", "conversation sidebar", "message storage", "save messages", "ephemeral chat".
license
Apache-2.0
compatibility
Requires 05-appkit-lakebase-wiring and 06-appkit-serving-wiring complete, Node.js v22+, Databricks CLI >= 0.295.0
allowed-tools
Bash(databricks:*) Bash(npm:*) Bash(curl:*) Bash(node:*) Read
clients
["ide_cli","genie_code"]
bundle_resource
apps
deploy_verb
apps_deploy
deploy_note
Chat-history persistence is source editing (server.ts DDL/routes, sidebar, hooks) — client-agnostic. The `chat` schema/table DDL runs **server-side on app startup** under the SP (RULE_10 Deploy-First) on both clients — never run it locally. IDE: `npm run build`/`npm test` gates, `databricks bundle validate --profile $PROFILE`, then `databricks apps deploy --profile $PROFILE`. Genie Code: local `npm` gates are an IDE convenience (server-side build on deploy); run `bundle validate` via `runDatabricksCli` (omit `--profile`; `--target dev` if targetless blocked); deploy per `03-appkit-deploy`. `apps validate` is hard-blocked and (per Step 9) unsafe for Lakebase apps — skip on both clients. Verify via browser + logs.
coverage
full
metadata
{"author":"prashanth subrahmanyam","version":"1.2.0","domain":"apps","role":"chat-history","standalone":false,"last_verified":"2026-06-02","volatility":"medium","upstream_sources":[{"name":"databricks-agent-skills/databricks-lakebase","repo":"databricks/databricks-agent-skills","paths":"[Truncated]","relationship":"extended","last_synced":"2026-04-27","sync_commit":"manifest-v2-2026-04-22"},{"name":"databricks-agent-skills/databricks-apps","repo":"databricks/databricks-agent-skills","paths":"[Truncated]","relationship":"extended","last_synced":"2026-04-27","sync_commit":"manifest-v2-2026-04-22"}]}
# Add Persistent Chat History to an AppKit Application Persist chat conversations in Lakebase so users can revisit past conversations, using AppKit-native primitives only: `AppKit.lakebase.query()` for persistence, `AppKit.serving("agent").asUser(req).stream()` for the agent stream, and `server.extend()` with `autoStart: false` for the proxy route. **No Vercel AI SDK, no `@databricks/ai-sdk-provider`, no `streamText`.** This skill builds on top of the agent wiring already established in [06-appkit-serving-wiring](../06-appkit-serving-wiring/SKILL.md). ## When to Use - Adding persistent conversation storage to an AppKit chat interface built with `useServingStream` - Building a sidebar with conversation history - Supporting ephemeral mode (graceful degradation when Lakebase is unavailable) - Capturing `trace_id` server-side for later feedback logging (see [08-appkit-feedback](../08-appkit-feedback/SKILL.md)) **Not for agent streaming fundamentals.** Use [06-appkit-serving-wiring](../06-appkit-serving-wiring/SKILL.md) to wire `useServingStream` and the base `/api/serving/:alias/stream` route first. **Not for plugin registration.** Use [04-appkit-plugin-add](../04-appkit-plugin-add/SKILL.md) + [05-appkit-lakebase-wiring](../05-appkit-lakebase-wiring/SKILL.md) to register the Lakebase plugin and declare bundle resources first. --- ## Prerequisites Verify all of these before proceeding: 1. Lakebase plugin registered in `server/server.ts` with bundle resources deployed (see [05-appkit-lakebase-wiring](../05-appkit-lakebase-wiring/SKILL.md)) 2. Serving plugin registered in `server/server.ts` with a working agent endpoint (see [06-appkit-serving-wiring](../06-appkit-serving-wiring/SKILL.md)) 3. `useServingStream` renders a basic chat UI that works end-to-end 4. App deployed at least once so the Service Principal exists and can create schemas 5. `AppKit.server` is constructed with `server({ autoStart: false })` and `AppKit.server.start()` is called after `AppKit.server.extend(...)` **Upstream docs (always check for latest):** ```bash npx @databricks/appkit docs "lakebase" npx @databricks/appkit docs "serving" ``` ### Working in Genie Code (client routing) Everything in this skill is **source editing + server-side code** (DDL, proxy route, sidebar, hooks) — written the same way on both clients. The DDL runs server-side on startup (Step 1) and is already client-agnostic. Only local gates and the deployed-app checks differ: | IDE/CLI (as written) | Genie Code substitution | |----------------------|--------------------------| | `npm run build` / `npm test` gates (Steps 1–8) | **IDE-only** convenience — no local Node toolchain. Skip; the platform builds **server-side** on deploy, and the retry tests run in CI or post-clone where npm exists. Errors surface in `databricks apps logs <name>` | | `npm run dev` | not available — verify on the deployed app | | `npx @databricks/appkit docs …` | npx absent (P9) — WebFetch https://databricks.github.io/appkit/docs/plugins/ | | `databricks bundle validate --profile $PROFILE` (Step 9) | run via `runDatabricksCli` (omit `--profile`; `--target dev` if a targetless validate is guardrail-blocked) — this is the canonical gate on both clients | | `databricks apps validate` (Step 9) | hard-blocked **and** unsafe for Lakebase apps (boots locally) — skip on both clients, rely on `bundle validate` + server-side build logs | | local `curl http://localhost:8000/api/…` gates (Steps 3, 7) | no local dev server — exercise the routes on the **deployed** app via browser, or the OAuth-session `requests.Session()` test in `03-appkit-deploy` | | `databricks apps deploy …` | see the `03-appkit-deploy` deploy-routing contract (`runDatabricksCli`, else SDK `w.apps.deploy(... SNAPSHOT)`) | Paths are relative to `apps_lakebase/$APP_NAME` — inside your git-cloned workshop project (`artifact_root`) on Genie Code, never the read-only `.assistant/skills` copy and never `/tmp`. See `skills/genie-code-environment` for the full manifest. --- ## Architecture ``` Browser (useServingStream or direct fetch) │ ▼ AppKit Server — server.extend() proxy route /api/chat │ ├─► AppKit.lakebase.query() (persist chat record, user message) │ ├─► AppKit.serving("agent").asUser(req).stream({ messages }) │ │ │ └─ for await (chunk of stream): │ 1. Forward chunk to client as SSE │ 2. Accumulate assistant text via dual-format extractor │ 3. Scan chunk for trace_id │ └─► AppKit.lakebase.query() (INSERT assistant message + traceId on stream close) ``` This pattern extends the `server.extend()` streaming proxy already documented in [06-appkit-serving-wiring/SKILL.md](../06-appkit-serving-wiring/SKILL.md) Step 6b. The only additions are the Lakebase inserts and the text/traceId accumulators. --- ## Step 1: Create the Chat Schema Add idempotent DDL after `createApp()` in `server/server.ts`. The `chat` schema is deliberately isolated from application data schemas so it doesn't collide with other tables. > **RULE_10 — this DDL is intentionally in-app, not a bundle resource or a `psql` script.** `initChatSchema()` runs **server-side on every app startup** via `AppKit.lakebase.query()`, so the app's Service Principal owns the schema, tables, and indexes (the same Deploy-First Pattern as `05-appkit-lakebase-wiring` Step 1d). This is **client-agnostic** — it executes identically whether the app was deployed from an IDE or Genie Code, and there is **no client-side `psql`/DDL step**. Do not move it into `databricks.yml` or a setup script: idempotent `CREATE … IF NOT EXISTS` on startup is the correct, SP-owning pattern. ```typescript async function initChatSchema() { try { await AppKit.lakebase.query(`CREATE SCHEMA IF NOT EXISTS chat`); await AppKit.lakebase.query(` CREATE TABLE IF NOT EXISTS chat."User" ( id TEXT PRIMARY KEY NOT NULL, email VARCHAR(64) NOT NULL ) `); await AppKit.lakebase.query(` CREATE TABLE IF NOT EXISTS chat."Chat" ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), "createdAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, title TEXT NOT NULL DEFAULT 'New chat', "userId" TEXT NOT NULL, visibility VARCHAR(10) NOT NULL DEFAULT 'private', "lastContext" JSONB ) `); await AppKit.lakebase.query(` CREATE TABLE IF NOT EXISTS chat."Message" ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), "chatId" UUID NOT NULL REFERENCES chat."Chat"(id) ON DELETE CASCADE, role VARCHAR(20) NOT NULL, parts JSON NOT NULL DEFAULT '[]', attachments JSON NOT NULL DEFAULT '[]', "createdAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "traceId" TEXT ) `); await AppKit.lakebase.query(` CREATE TABLE IF NOT EXISTS chat."Vote" ( "chatId" UUID NOT NULL REFERENCES chat."Chat"(id) ON DELETE CASCADE, "messageId" UUID NOT NULL REFERENCES chat."Message"(id) ON DELETE CASCADE, "isUpvoted" BOOLEAN NOT NULL, PRIMARY KEY ("chatId", "messageId") ) `); await AppKit.lakebase.query(` CREATE INDEX IF NOT EXISTS idx_chat_user ON chat."Chat" ("userId", "createdAt" DESC) `); await AppKit.lakebase.query(` CREATE INDEX IF NOT EXISTS idx_message_chat ON chat."Message" ("chatId", "createdAt" ASC) `); console.log("[Chat] Schema initialized"); } catch (err) { console.warn("[Chat] Schema init failed (may not have DB access):", err); } } await initChatSchema(); ``` See [references/chat-schema.md](references/chat-schema.md) for the full ER diagram, per-column rationale, and optional Drizzle ORM migration path. **Gate:** Schema init runs on startup. A subsequent `npm run build` passes, and deploying the app produces server logs containing `[Chat] Schema initialized`. --- ## Step 2: Add Auth Middleware Add Express middleware to parse the OBO user identity once per request and attach it to `req.session`. This avoids calling a helper function inline in every route handler. > **Header contract.** Databricks Apps sets four canonical user headers on every request: > > - `x-forwarded-email` — end user's email (the value AppKit and downstream agents use as the canonical user attribute) > - `x-forwarded-preferred-username` — preferred username (often equals the email) > - `x-forwarded-user` — stable workspace user id > - `x-forwarded-access-token` — downscoped OBO token (only present when `user_api_scopes` is declared) > > `x-forwarded-user-info` is **not** a canonical Databricks Apps header and must not be used. Earlier internal patterns assumed a JSON-blob header existed; it does not. Read the canonical headers above directly. ```typescript interface AppSession { userId: string; email: string; name?: string; authenticated: boolean; } declare global { namespace Express { interface Request { session?: AppSession; } } } function readCanonicalUserHeaders(req: import("express").Request) { const email = req.headers["x-forwarded-email"]; const preferred = req.headers["x-forwarded-preferred-username"]; const user = req.headers["x-forwarded-user"]; const pick = (h: string | string[] | undefined): string | undefined => typeof h === "string" && h.length > 0 ? h : undefined; return { email: pick(email), preferredUsername: pick(preferred), userId: pick(user), }; } function authMiddleware( req: import("express").Request, _res: import("express").Response, next: import("express").NextFunction, ) { const canonical = readCanonicalUserHeaders(req); if (canonical.email || canonical.userId) { // userId persists in Lakebase as TEXT — prefer the email so it lines up // with MLflow AssessmentSource.source_id. Fall back to x-forwarded-user. const userId = canonical.email ?? canonical.userId!; const email = canonical.email ?? canonical.preferredUsername ?? "unknown@local"; req.session = { userId, email, name: canonical.preferredUsername, authenticated: true, }; } else { req.session = { userId: process.env.DEV_USER_EMAIL ?? "local-dev-user", email: process.env.DEV_USER_EMAIL ?? "dev@local", authenticated: false, }; } next(); } function requireAuth( req: import("express").Request, res: import("express").Response, next: import("express").NextFunction, ) { if (!req.session) { return res.status(401).json({ error: "Authentication required" }); } next(); } function requireChatAccess( req: import("express").Request, res: import("express").Response, next: import("express").NextFunction, ) { const chatId = req.params.chatId; if (!chatId) return next(); AppKit.lakebase .query(`SELECT "userId", visibility FROM chat."Chat" WHERE id = $1`, [chatId]) .then((result) => { if (result.rows.length === 0) return res.status(404).json({ error: "Chat not found" }); const chat = result.rows[0]; if (chat.userId !== req.session?.userId && chat.visibility !== "public") { return res.status(403).json({ error: "Access denied" }); } next(); }) .catch(() => next()); } ``` Apply the middleware to all chat routes: ```typescript AppKit.server.extend((app) => { app.use("/api/chat", authMiddleware, requireAuth); app.use("/api/history", authMiddleware, requireAuth); app.use("/api/messages", authMiddleware, requireAuth); app.use("/api/feedback", authMiddleware, requireAuth); app.use("/api/session", authMiddleware); }); ``` Route handlers then read `req.session.userId` instead of calling a helper. See [references/session-auth.md](references/session-auth.md) for the full middleware reference, OBO header format, and access control rules. **Gate:** `npm run build` passes with the middleware imports. Hitting any protected route without the canonical `x-forwarded-email` / `x-forwarded-user` headers (i.e. running outside the Databricks Apps platform) returns the local-dev fallback identity. --- ## Step 3: Add History API Routes These routes power the sidebar and message loading. All use `AppKit.lakebase.query()` — no ORM. ```typescript AppKit.server.extend((app) => { async function ensureUser(req: import("express").Request) { const userId = req.session!.userId; const email = req.session!.email; await AppKit.lakebase.query( `INSERT INTO chat."User" (id, email) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING`, [userId, email], ); return userId; } app.get("/api/history", async (req, res) => { try { const userId = await ensureUser(req); const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); const startingAfter = req.query.starting_after as string | undefined; const endingBefore = req.query.ending_before as string | undefined; let sql: string; let params: unknown[]; if (startingAfter) { sql = `SELECT id, title, "createdAt", visibility FROM chat."Chat" WHERE "userId" = $1 AND "createdAt" < (SELECT "createdAt" FROM chat."Chat" WHERE id = $2) ORDER BY "createdAt" DESC LIMIT $3`; params = [userId, startingAfter, limit + 1]; } else if (endingBefore) { sql = `SELECT * FROM ( SELECT id, title, "createdAt", visibility FROM chat."Chat" WHERE "userId" = $1 AND "createdAt" > (SELECT "createdAt" FROM chat."Chat" WHERE id = $2)
Voir sur GitHub
Ce SKILL.md est tres volumineux, SkillsMP affiche donc ici seulement la premiere section. Voir sur GitHub