v0.6.0.0 feat(assistant): ChatGPT-style chat at #/assistant — phase 1 (#135)#139
Merged
Conversation
…ase 1 (#135) Phase 1 of issue #135: text-only chat surface at #/assistant with persisted threads and four new API endpoints. Out of scope (deferred to phase 2+): SSE streaming, twin/memory context enrichment, action-intent routing through the decision engine, tool use, mobile UI. New package @skytwin/assistant - AssistantService stateless wrapper around LlmClient - formatHistoryAsPrompt flattens ChatTurn[] to single-prompt for the LlmClient API today; phase 2 refactors LlmClient to take messages array - MAX_HISTORY_TURNS = 20 cap protects cost/latency/context-window DB: 026-assistant-threads.sql - assistant_threads + assistant_messages with ON DELETE CASCADE - (user_id, updated_at DESC) and (thread_id, created_at ASC) indexes - assistantRepository with appendMessage in a transaction so the message insert and parent updated_at bump are atomic - deriveThreadTitle pure helper (first line, ≤80 chars, ellipsis on overflow) API: 4 endpoints under /api/assistant - POST /messages, GET /threads, GET /threads/:id, DELETE /threads/:id - All under sessionAuth + requireOwnership - 409 when no AI provider configured, 502 on AllProvidersFailedError - Don't-leak-existence on 404 (same response for not-found and not-owned) - User message persisted BEFORE LLM call so it survives provider outage Web: #/assistant route with dark glass aesthetic - Reuses warm-glass dark variant (#0c0a14 ≈ spec'd #0b0d10) instead of forking a parallel theme system - Two-column layout: threads rail + composer/messages - Optimistic user bubble, animated typing dots while reply pending - Singleton click+submit+keydown delegators wired once on document, gated by hash route (CLAUDE.md "Frontend Event Handling") - All Gmail/user strings escapeHtml'd, no inline onclick - Respects prefers-reduced-motion Tests: 24 new (8 service + 7 thread title + 9 validator). Full suite green across 40 packages. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds the first phase of a new assistant feature: a persisted chat surface in the web app, a new @skytwin/assistant package for LLM-backed replies, and assistant thread/message storage plus API endpoints to create, fetch, list, and delete conversations.
Changes:
- Added assistant persistence primitives in
@skytwin/dbplus migration026-assistant-threads.sql. - Added a new
@skytwin/assistantpackage with prompt formatting and reply generation logic. - Wired the assistant into the API and web SPA with new routes, client helpers, page rendering, and styling.
Reviewed changes
Copilot reviewed 22 out of 23 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
VERSION |
Bumps release to 0.6.0.0. |
pnpm-lock.yaml |
Registers the new workspace package and API dependency. |
packages/db/src/repositories/index.ts |
Re-exports assistant repository/types. |
packages/db/src/repositories/assistant-repository.ts |
Adds thread/message repo operations and title derivation. |
packages/db/src/migrations/026-assistant-threads.sql |
Creates assistant thread/message tables and indexes. |
packages/db/src/index.ts |
Surfaces assistant DB exports from package root. |
packages/db/src/__tests__/assistant-thread-title.test.ts |
Tests thread title helper behavior. |
packages/assistant/tsconfig.json |
Adds TS config for new assistant package. |
packages/assistant/src/index.ts |
Exports assistant service API. |
packages/assistant/src/assistant-service.ts |
Implements prompt shaping and LLM reply service. |
packages/assistant/src/__tests__/assistant-service.test.ts |
Adds unit tests for assistant service/prompt formatting. |
packages/assistant/package.json |
Defines new workspace package metadata/scripts. |
CHANGELOG.md |
Documents the assistant feature release. |
apps/web/public/js/pages/assistant.js |
Implements assistant SPA page, state, and event handling. |
apps/web/public/js/app.js |
Registers #/assistant route. |
apps/web/public/js/api-client.js |
Adds assistant API client helpers. |
apps/web/public/index.html |
Loads assistant CSS and adds nav entry. |
apps/web/public/css/assistant.css |
Styles assistant layout, bubbles, and composer. |
apps/api/src/validators/assistant-message.ts |
Validates assistant message POST payloads. |
apps/api/src/routes/assistant.ts |
Adds assistant HTTP endpoints and LLM integration. |
apps/api/src/index.ts |
Mounts assistant router behind auth/ownership middleware. |
apps/api/src/__tests__/assistant-message-validator.test.ts |
Tests assistant message validation rules. |
apps/api/package.json |
Adds API dependency on @skytwin/assistant. |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const trimmed = firstLine.trim(); | ||
| if (trimmed.length === 0) return 'New conversation'; | ||
| if (trimmed.length <= 80) return trimmed; | ||
| return `${trimmed.slice(0, 77)}…`; |
Comment on lines
+23
to
+29
| CREATE TABLE IF NOT EXISTS assistant_threads ( | ||
| id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, | ||
| user_id UUID NOT NULL, | ||
| title STRING NOT NULL, | ||
| created_at TIMESTAMPTZ NOT NULL DEFAULT now(), | ||
| updated_at TIMESTAMPTZ NOT NULL DEFAULT now() | ||
| ); |
Comment on lines
+33
to
+58
| export async function renderAssistant(container, userId) { | ||
| _state.userId = userId; | ||
| ensureAssistantListener(); | ||
|
|
||
| // Initial fetch — threads and (if any) the most recent thread's messages. | ||
| let threads = []; | ||
| try { | ||
| const data = await fetchAssistantThreads(userId); | ||
| threads = Array.isArray(data?.threads) ? data.threads : []; | ||
| } catch (err) { | ||
| container.innerHTML = renderError(err); | ||
| return; | ||
| } | ||
| _state.threads = threads; | ||
|
|
||
| // Default-select the most recent thread on first render of an existing | ||
| // session. If there are no threads, leave the right pane on the empty | ||
| // state and the composer ready to start a new conversation. | ||
| if (!_state.activeThreadId && threads.length > 0) { | ||
| _state.activeThreadId = threads[0].id; | ||
| } | ||
|
|
||
| if (_state.activeThreadId) { | ||
| try { | ||
| const data = await fetchAssistantThread(_state.activeThreadId, userId); | ||
| _state.messages = Array.isArray(data?.messages) ? data.messages : []; |
Comment on lines
+293
to
+300
| try { | ||
| const data = await fetchAssistantThread(threadId, _state.userId); | ||
| _state.messages = Array.isArray(data?.messages) ? data.messages : []; | ||
| } catch { | ||
| _state.activeThreadId = null; | ||
| _state.messages = []; | ||
| } | ||
| paint(container); |
| { | ||
| id: 'error', | ||
| role: 'assistant', | ||
| content: `Couldn't reach the assistant — ${err instanceof Error ? err.message : String(err)}`, |
Comment on lines
+78
to
+175
| router.post('/messages', async (req, res, next) => { | ||
| try { | ||
| const validation = validateAssistantMessage(req.body); | ||
| if (!validation.ok) { | ||
| res.status(400).json({ | ||
| error: 'Invalid message payload', | ||
| details: validation.errors, | ||
| }); | ||
| return; | ||
| } | ||
| const { userId, content, threadId: providedThreadId } = validation; | ||
|
|
||
| const llm = await buildLlmClientForUser(userId); | ||
| if (!llm) { | ||
| res.status(409).json({ | ||
| error: 'No AI provider configured', | ||
| message: | ||
| 'Configure at least one provider in Settings → AI providers before chatting with the assistant.', | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| // Resolve the thread: existing one or new one based on the first | ||
| // user message. We persist the user message FIRST so it's durable | ||
| // even if the LLM call fails — the user shouldn't lose their input | ||
| // because of an upstream provider outage. | ||
| let threadId: string; | ||
| let isNewThread = false; | ||
| if (providedThreadId) { | ||
| const existing = await assistantRepository.getThread(userId, providedThreadId); | ||
| if (!existing) { | ||
| // Don't leak whether the thread exists vs. is owned by another | ||
| // user — same hygiene as the repository's documented contract. | ||
| res.status(404).json({ error: 'Thread not found' }); | ||
| return; | ||
| } | ||
| threadId = existing.thread.id; | ||
| } else { | ||
| const newThread = await assistantRepository.createThread(userId, content); | ||
| threadId = newThread.id; | ||
| isNewThread = true; | ||
| } | ||
|
|
||
| const userMessage = await assistantRepository.appendMessage(threadId, 'user', content); | ||
|
|
||
| // Build the prompt history from the persisted thread (gives us the | ||
| // full conversation including the user message we just appended). | ||
| const fetched = await assistantRepository.getThread(userId, threadId); | ||
| // Defensive — we just wrote the thread, but if a concurrent DELETE | ||
| // landed between INSERT and SELECT we'd have a stale view. Treat as | ||
| // 404 so the client retries with a fresh thread. | ||
| if (!fetched) { | ||
| res.status(404).json({ error: 'Thread vanished mid-request' }); | ||
| return; | ||
| } | ||
| const history: ChatTurn[] = fetched.messages.map((m) => ({ | ||
| role: m.role, | ||
| content: m.content, | ||
| })); | ||
|
|
||
| const service = new AssistantService(llm); | ||
| let reply; | ||
| try { | ||
| reply = await service.reply(history); | ||
| } catch (err) { | ||
| if (err instanceof AllProvidersFailedError) { | ||
| log.warn('All LLM providers failed for assistant request', { | ||
| userId, | ||
| threadId, | ||
| attempted: err.attempted, | ||
| }); | ||
| res.status(502).json({ | ||
| error: 'All configured AI providers failed', | ||
| message: | ||
| 'Every configured provider returned an error. Try again in a moment, or check Settings → AI providers.', | ||
| attempted: err.attempted, | ||
| }); | ||
| return; | ||
| } | ||
| throw err; | ||
| } | ||
|
|
||
| const assistantMessage = await assistantRepository.appendMessage( | ||
| threadId, | ||
| 'assistant', | ||
| reply.content, | ||
| reply.metadata, | ||
| ); | ||
|
|
||
| res.json({ | ||
| thread: { id: threadId, isNew: isNewThread }, | ||
| userMessage, | ||
| assistantMessage, | ||
| }); | ||
| } catch (err) { | ||
| next(err); | ||
| } | ||
| }); |
Comment on lines
+307
to
+333
| const previous = _state.threads.slice(); | ||
| _state.threads = _state.threads.filter((t) => t.id !== threadId); | ||
| let switched = false; | ||
| if (_state.activeThreadId === threadId) { | ||
| _state.activeThreadId = _state.threads[0]?.id ?? null; | ||
| _state.messages = []; | ||
| switched = true; | ||
| } | ||
| const container = document.getElementById('page-content'); | ||
| if (container) paint(container); | ||
|
|
||
| try { | ||
| await deleteAssistantThread(threadId, _state.userId); | ||
| // If the active thread switched to a different one, fetch its messages. | ||
| if (switched && _state.activeThreadId && container) { | ||
| try { | ||
| const data = await fetchAssistantThread(_state.activeThreadId, _state.userId); | ||
| _state.messages = Array.isArray(data?.messages) ? data.messages : []; | ||
| paint(container); | ||
| } catch { | ||
| /* swallow — leave state as is */ | ||
| } | ||
| } | ||
| } catch (err) { | ||
| // Rollback on failure so the UI doesn't lie about the server state. | ||
| _state.threads = previous; | ||
| if (container) paint(container); |
Comment on lines
+388
to
+400
| // Restore the input so the user can retry, drop the optimistic bubble, | ||
| // and surface the error in an assistant-shaped bubble. Phase 2 should | ||
| // make this a proper toast. | ||
| _state.messages = _state.messages.filter((m) => m.id !== 'optimistic'); | ||
| _state.messages = _state.messages.concat([ | ||
| { | ||
| id: 'error', | ||
| role: 'assistant', | ||
| content: `Couldn't reach the assistant — ${err instanceof Error ? err.message : String(err)}`, | ||
| createdAt: new Date().toISOString(), | ||
| }, | ||
| ]); | ||
| if (input) input.value = content; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Phase 1 of issue #135 — adds a ChatGPT-style conversational assistant at
#/assistantwith the dark glass aesthetic, persisted threads, and four new API endpoints. Out of scope for this phase (deferred to phase 2+): SSE streaming, twin/memory context enrichment, action-intent routing through the decision engine, tool use, mobile UI.What landed
@skytwin/assistant(new package)Stateless
AssistantServicewrappingLlmClient. Pure — persistence and HTTP concerns live in@skytwin/dbandapps/apiso the LLM interaction unit-tests against a stubbed client.MAX_HISTORY_TURNS = 20cap protects cost / latency / provider context-window. Older turns stay persisted (the user sees them) but aren't fed back to the model.DB (
026-assistant-threads.sql)Two tables, parent
assistant_threads (id, user_id, title, created_at, updated_at)+ childassistant_messages (id, thread_id, role, content, created_at, metadata)withON DELETE CASCADE. Two indexes for the read paths.metadata JSONBreserved for phase 2's provider/model/latency stamps.assistantRepository.appendMessageruns in a transaction so the message INSERT and the parent'supdated_atUPDATE are atomic — without it a race could leave a thread with messages but a staleupdated_at, demoting it unfairly in the list ordering.API routes
/api/assistant/messages/api/assistant/threads?userId=…/api/assistant/threads/:id?userId=…/api/assistant/threads/:id?userId=…All four mounted under
sessionAuth+requireOwnership. Don't-leak-existence semantics: a thread the user doesn't own returns the same 404 as a thread that doesn't exist.POST /messagesnotes:attempted: [...]when every provider in the chain fails.content(well under CRDB's row-size limit).Web (
#/assistant)Two-column layout: threads rail on the left (with "New" + per-thread delete), message log + composer on the right. Reuses the existing
warm-glassdark variant (#0c0a14 ≈ the spec's #0b0d10) instead of forking a parallel theme system — saves ~500 lines of CSS variables and means the assistant tracks whichever theme the user has selected.document, gated bywindow.location.hashper CLAUDE.md "Frontend Event Handling" rules.escapeHtml; no inlineonclick.prefers-reduced-motion.Safety / invariants
@skytwin/decision-engine.ExplanationRecords when the assistant routes through the decision pipeline.Test plan
pnpm build— 20 packages greenpnpm test— 40 packages, all green; 24 new tests (8 service + 7 thread title + 9 validator)pnpm lint— clean#/assistant, send a message, verify reply lands; create second thread; switch between threads; delete a thread; reload page and verify threads persistpnpm db:migrateagainst fresh local CRDBPhase 2+ follow-ups (filing as separate issues, not this PR)
POST /messages— route is structured to layer on without redesign@skytwin/decision-engine— assistant gains the ability to send mail / modify calendar / spend money behind policy gatesLlmClient.generateto take a messages array, drop theUser:/Assistant:prompt-flattening workaround)apps/mobile🤖 Generated with Claude Code