diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 55ba25a..2c42fb2 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -151,7 +151,19 @@ "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\('version:', d.get\\('version'\\)\\)\")", "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\('type:', d.get\\('type'\\)\\); print\\('error:', d.get\\('error',{}\\).get\\('message','none'\\)[:200]\\)\")", "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); text=next\\(\\(b['text'] for b in d.get\\('content',[]\\) if b['type']=='text'\\),''\\); print\\('STATUS:', d.get\\('type','?'\\)\\); print\\('ERROR:', d.get\\('error',{}\\).get\\('message','none'\\)[:300]\\); print\\('TEXT:', text[:300]\\)\")", - "Bash(perl -pi -e 's/color: '\\\\''rgba\\\\\\(255,255,255,0\\\\.22\\\\\\)'\\\\''/color: T.dim/g;' -e 's/color: '\\\\''rgba\\\\\\(255,255,255,0\\\\.18\\\\\\)'\\\\''/color: T.dim/g;' -e 's/color: '\\\\''rgba\\\\\\(255,255,255,0\\\\.28\\\\\\)'\\\\''/color: T.key/g;' -e 's/color: '\\\\''rgba\\\\\\(255,255,255,0\\\\.32\\\\\\)'\\\\''/color: T.key/g;' -e 's/color: '\\\\''rgba\\\\\\(255,255,255,0\\\\.45\\\\\\)'\\\\''/color: T.fgMuted/g;' -e 's/color: '\\\\''rgba\\\\\\(255,255,255,0\\\\.5\\\\\\)'\\\\''/color: T.fgMuted/g;' -e 's/color: '\\\\''rgba\\\\\\(255,255,255,0\\\\.62\\\\\\)'\\\\''/color: T.fgMuted/g;' -e 's/color: '\\\\''rgba\\\\\\(255,255,255,0\\\\.85\\\\\\)'\\\\''/color: T.fg/g;' -e 's/color: '\\\\''rgba\\\\\\(255,255,255,0\\\\.88\\\\\\)'\\\\''/color: T.fg/g;' /Users/USER/Desktop/originmain/packages/app/src/components/inspector/Inspector.tsx)" + "Bash(perl -pi -e 's/color: '\\\\''rgba\\\\\\(255,255,255,0\\\\.22\\\\\\)'\\\\''/color: T.dim/g;' -e 's/color: '\\\\''rgba\\\\\\(255,255,255,0\\\\.18\\\\\\)'\\\\''/color: T.dim/g;' -e 's/color: '\\\\''rgba\\\\\\(255,255,255,0\\\\.28\\\\\\)'\\\\''/color: T.key/g;' -e 's/color: '\\\\''rgba\\\\\\(255,255,255,0\\\\.32\\\\\\)'\\\\''/color: T.key/g;' -e 's/color: '\\\\''rgba\\\\\\(255,255,255,0\\\\.45\\\\\\)'\\\\''/color: T.fgMuted/g;' -e 's/color: '\\\\''rgba\\\\\\(255,255,255,0\\\\.5\\\\\\)'\\\\''/color: T.fgMuted/g;' -e 's/color: '\\\\''rgba\\\\\\(255,255,255,0\\\\.62\\\\\\)'\\\\''/color: T.fgMuted/g;' -e 's/color: '\\\\''rgba\\\\\\(255,255,255,0\\\\.85\\\\\\)'\\\\''/color: T.fg/g;' -e 's/color: '\\\\''rgba\\\\\\(255,255,255,0\\\\.88\\\\\\)'\\\\''/color: T.fg/g;' /Users/USER/Desktop/originmain/packages/app/src/components/inspector/Inspector.tsx)", + "Bash(pnpm --filter @originmain/renderer exec tsc --noEmit)", + "Bash(pnpm --filter \"@originmain/origin-graph\" exec tsc --noEmit)", + "Bash(pnpm --filter \"@originmain/ai-layer\" exec tsc --noEmit)", + "Bash(pnpm --filter \"@originmain/diff-engine\" exec tsc --noEmit)", + "Bash(pnpm --filter \"@originmain/renderer\" exec tsc --noEmit)", + "Bash(pnpm --filter \"@originmain/app\" exec tsc --noEmit)", + "Bash(pnpm --filter \"@originmain/origin-graph\" run test)", + "Bash(pnpm update *)", + "Bash(pnpm --filter \"@originmain/diff-engine\" run test)", + "Bash(pnpm --filter @originmain/app typecheck)", + "Bash(pnpm --filter @originmain/ai-layer typecheck)", + "Bash(pnpm --filter @originmain/design-language typecheck)" ] } } diff --git a/package.json b/package.json index 7660e67..8d54cf5 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,10 @@ "devDependencies": { "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", - "@vitest/coverage-v8": "^2.0.0", + "@vitest/coverage-v8": "^2.1.9", "eslint": "^9.0.0", "typescript": "^5.5.0", - "vitest": "^2.0.0" + "vitest": "^2.1.9" }, "engines": { "node": ">=22", diff --git a/packages/ai-layer/src/client.ts b/packages/ai-layer/src/client.ts index c4e464a..50a2c57 100644 --- a/packages/ai-layer/src/client.ts +++ b/packages/ai-layer/src/client.ts @@ -2,9 +2,10 @@ import Anthropic from '@anthropic-ai/sdk'; // ── Model constants ─────────────────────────────────────────────────────────── -// claude-opus-4-7 uses adaptive thinking (thinking.type = 'adaptive'). -// It does NOT accept temperature, top_p, or top_k — those are omitted in gateway.ts. -export const MODEL = 'claude-opus-4-7' as const; +// Spec Layer 6: "Claude Sonnet 4 API is the only AI provider." +// claude-sonnet-4-6 supports per-call temperature (0.1 / 0.2 / 0.3 per prompt) +// which the spec requires for diff-summary, completion-zone, and agent-query. +export const MODEL = 'claude-sonnet-4-6' as const; // ── Singleton client ────────────────────────────────────────────────────────── // The client is created once and shared. API key is injected from the server diff --git a/packages/ai-layer/src/features/agent-qa.ts b/packages/ai-layer/src/features/agent-qa.ts index fade923..9f9bcaa 100644 --- a/packages/ai-layer/src/features/agent-qa.ts +++ b/packages/ai-layer/src/features/agent-qa.ts @@ -1,5 +1,5 @@ import type { AIGateway } from '../gateway.js'; -import { buildSystemPrompt } from '../prompts/system.js'; +import { buildAgentQueryMessages } from '../prompts/agent-query.prompt.js'; export interface AgentQAInput { /** Question from the coding agent (e.g. Cursor or Claude Code) */ @@ -10,6 +10,10 @@ export interface AgentQAInput { artboardContextJson: string; /** Active DLF */ dlfJson?: string; + /** Before-state screenshot as base64 data URL (spec Layer 6.3-R3) */ + beforeScreenshotBase64?: string; + /** After-state screenshot as base64 data URL (spec Layer 6.3-R3) */ + afterScreenshotBase64?: string; } export interface AgentQAOutput { @@ -22,20 +26,14 @@ export async function answerAgentQuestion( gateway: AIGateway, input: AgentQAInput ): Promise { - const system = buildSystemPrompt({ - role: 'a design agent answering questions from a coding agent implementing a design diff', - ...(input.dlfJson !== undefined ? { dlfJson: input.dlfJson } : {}), - }); + const { system, userContent, maxTokens } = buildAgentQueryMessages(input); const response = await gateway.complete({ system, - messages: [ - { - role: 'user', - content: `\n${input.artboardContextJson}\n\n\n${input.diffId}\n\n\n${input.question}\n\n\nAnswer the coding agent's question concisely and precisely. If the answer references a specific design token, component, or visual region, include a "visualReference" field. Return JSON:\n{\n "answer": "...",\n "visualReference": "..." (optional)\n}\n\nRespond ONLY with valid JSON.`, - }, - ], - maxTokens: 1024, + // userContent is ContentBlockParam[] — may include image blocks for screenshots + messages: [{ role: 'user', content: userContent }], + maxTokens, + temperature: 0.1, // spec Layer 6.3: 0.1 for agent-query (factual, authoritative) }); let parsed: unknown; diff --git a/packages/ai-layer/src/features/artboard-query.ts b/packages/ai-layer/src/features/artboard-query.ts index 636ba28..474e8e2 100644 --- a/packages/ai-layer/src/features/artboard-query.ts +++ b/packages/ai-layer/src/features/artboard-query.ts @@ -1,5 +1,5 @@ import type { AIGateway } from '../gateway.js'; -import { buildSystemPrompt } from '../prompts/system.js'; +import { buildArtboardQueryMessages } from '../prompts/artboard-query.prompt.js'; export interface ArtboardQueryInput { /** Natural language query from the user */ @@ -23,17 +23,13 @@ export async function queryCrossArtboard( gateway: AIGateway, input: ArtboardQueryInput ): Promise { - const system = buildSystemPrompt({ role: 'a search agent filtering artboards by design intent' }); + const { system, userContent, maxTokens } = buildArtboardQueryMessages(input); const response = await gateway.complete({ system, - messages: [ - { - role: 'user', - content: `\n${input.artboardsJson}\n\n\n\n${input.query}\n\n\nReturn a JSON object:\n{\n "results": [{"artboardId": "...", "relevanceScore": 0-1, "reason": "..."}],\n "reasoning": "brief explanation"\n}\n\nOnly include artboards with relevanceScore > 0.3. Respond ONLY with valid JSON.`, - }, - ], - maxTokens: 1024, + messages: [{ role: 'user', content: userContent }], + maxTokens, + temperature: 0.1, // spec Layer 10.2: 0.1 for workspace queries (factual) }); // Returning empty results on parse failure is indistinguishable from "no match". diff --git a/packages/ai-layer/src/features/completion-zone.ts b/packages/ai-layer/src/features/completion-zone.ts index 32c4fe3..a63ca88 100644 --- a/packages/ai-layer/src/features/completion-zone.ts +++ b/packages/ai-layer/src/features/completion-zone.ts @@ -1,8 +1,17 @@ -import type Anthropic from '@anthropic-ai/sdk'; +import { z } from 'zod'; import { AIGateway } from '../gateway.js'; -import { buildSystemPrompt } from '../prompts/system.js'; +import { buildCompletionZoneMessages } from '../prompts/completion-zone.prompt.js'; import type { GatewayResponse } from '../gateway.js'; +// ── Output schema (spec Layer 6.3-R2: validated with Zod before accepting) ──── +// The model MUST return exactly these two fields. We validate the shape before +// returning to callers so a malformed AI response never reaches the canvas store. + +const CompletionZoneResultSchema = z.object({ + proposedTree: z.unknown(), // arbitrary component tree — shape validated downstream + description: z.string().min(1), // non-empty natural-language summary +}); + // ── Types ───────────────────────────────────────────────────────────────────── export interface CompletionZoneInput { @@ -14,10 +23,12 @@ export interface CompletionZoneInput { dlfJson?: string; /** Before screenshot as base64 data URL (optional) */ screenshotBase64?: string; + /** Workspace ID for per-workspace cost attribution (spec Layer 6) */ + workspaceId?: string; } export interface CompletionZoneOutput { - /** Proposed component tree changes as structured JSON */ + /** Proposed component tree changes as structured JSON — Zod-validated shape */ proposedTree: unknown; /** Natural language description of the proposed change */ description: string; @@ -31,45 +42,50 @@ export async function fillCompletionZone( input: CompletionZoneInput, maxRetries = 3 ): Promise { - const system = buildSystemPrompt({ - role: 'a Completion Zone design agent', - ...(input.dlfJson !== undefined ? { dlfJson: input.dlfJson } : {}), - }); + const { system, userContent, maxTokens } = buildCompletionZoneMessages(input); - const userContent: Anthropic.Messages.ContentBlockParam[] = [ - { - type: 'text', - text: `\n${input.componentTreeJson}\n\n\n\n${input.intent}\n\n\nReturn a JSON object with two fields:\n- "proposedTree": the updated component tree matching the intent\n- "description": a one-sentence description of the change\n\nRespond ONLY with valid JSON.`, - }, - ]; - - if (input.screenshotBase64) { - userContent.unshift({ - type: 'image', - source: { type: 'base64', media_type: 'image/png', data: input.screenshotBase64.replace(/^data:image\/\w+;base64,/, '') }, - } as Anthropic.Messages.ImageBlockParam); - } - - // Retry up to maxRetries times on invalid JSON output + // Retry up to maxRetries times on invalid JSON or Zod-invalid output let lastError: Error | null = null; for (let attempt = 0; attempt < maxRetries; attempt++) { const response = await gateway.complete({ system, - messages: [{ role: 'user', content: userContent }], - maxTokens: 4096, + messages: [{ role: 'user', content: userContent }], + maxTokens, + temperature: 0.3, // spec Layer 6.3: 0.3 for completion-zone (creative latitude) + ...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}), }); + // Strip markdown fences the model occasionally adds despite instructions + const raw = response.text.replace(/^```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '').trim(); + + let parsed: unknown; try { - const parsed = JSON.parse(response.text.replace(/^```(?:json)?\s*/i, "").replace(/\s*```\s*$/, "").trim()); - return { proposedTree: parsed.proposedTree as unknown, description: String(parsed.description ?? ''), raw: response }; + parsed = JSON.parse(raw); } catch (parseErr) { lastError = new Error( `AI returned invalid JSON on attempt ${attempt + 1}: ${String(parseErr)}. ` + `Raw response (first 200 chars): ${response.text.slice(0, 200)}` ); + continue; } + + // Validate shape with Zod before accepting (spec Layer 6.3-R2) + const validated = CompletionZoneResultSchema.safeParse(parsed); + if (!validated.success) { + lastError = new Error( + `AI response failed schema validation on attempt ${attempt + 1}: ` + + validated.error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ') + + `. Raw response (first 200 chars): ${response.text.slice(0, 200)}` + ); + continue; + } + + return { + proposedTree: validated.data.proposedTree, + description: validated.data.description, + raw: response, + }; } throw lastError ?? new Error('Completion zone fill failed after retries'); } - diff --git a/packages/ai-layer/src/features/diff-summary.ts b/packages/ai-layer/src/features/diff-summary.ts index 32e3fa9..9e4b56e 100644 --- a/packages/ai-layer/src/features/diff-summary.ts +++ b/packages/ai-layer/src/features/diff-summary.ts @@ -1,11 +1,13 @@ import type { AIGateway } from '../gateway.js'; -import { buildSystemPrompt } from '../prompts/system.js'; +import { buildDiffSummaryMessages, buildAggregateSummaryMessages } from '../prompts/diff-summary.prompt.js'; export interface DiffSummaryInput { /** Serialized component-level changes (JSON) */ changesJson: string; /** Component name */ componentName: string; + /** Active DLF as JSON string — passed through for rule-violation annotation */ + dlfJson?: string; } export interface DiffSummaryOutput { @@ -16,17 +18,54 @@ export async function generateDiffSummary( gateway: AIGateway, input: DiffSummaryInput ): Promise { - const system = buildSystemPrompt({ role: 'a technical writer summarizing UI component changes' }); + const { system, userContent, maxTokens } = buildDiffSummaryMessages(input); const response = await gateway.complete({ system, - messages: [ - { - role: 'user', - content: `Summarize the following component changes for "${input.componentName}" in one concise sentence (max 20 words) suitable for a developer reviewing a pull request. Focus on what changed and its purpose.\n\n\n${input.changesJson}\n\n\nRespond with only the summary sentence.`, - }, - ], - maxTokens: 128, + messages: [{ role: 'user', content: userContent }], + maxTokens, + temperature: 0.2, // spec Layer 6.3: 0.2 for diff-summary (consistent output) + }); + + return { summary: response.text.trim() }; +} + +// ── Aggregate summary ───────────────────────────────────────────────────────── + +export interface AggregateSummaryInput { + /** All changes in the diff as serialized JSON */ + changesJson: string; + /** Human-readable artboard name, e.g. "Homepage Hero" */ + artboardName: string; + /** Active DLF as JSON string — used to flag rule violations in the summary */ + dlfJson?: string; +} + +export interface AggregateSummaryOutput { + summary: string; +} + +/** + * Generates a 2-3 sentence summary of ALL changes across a single diff + * (artboard-level granularity, suitable for the diff card header and PR body). + * Uses buildAggregateSummaryMessages — the session-level counterpart of + * buildDiffSummaryMessages (component-level). + */ +export async function generateAggregateSummary( + gateway: AIGateway, + input: AggregateSummaryInput, +): Promise { + const { system, userContent, maxTokens } = buildAggregateSummaryMessages({ + changesJson: input.changesJson, + artboardName: input.artboardName, + ...(input.dlfJson !== undefined ? { dlfJson: input.dlfJson } : {}), + }); + + const response = await gateway.complete({ + system, + messages: [{ role: 'user', content: userContent }], + maxTokens, + temperature: 0.2, // same as per-component summary — deterministic aggregate }); return { summary: response.text.trim() }; diff --git a/packages/ai-layer/src/gateway.ts b/packages/ai-layer/src/gateway.ts index 1d1368f..0a0dd2d 100644 --- a/packages/ai-layer/src/gateway.ts +++ b/packages/ai-layer/src/gateway.ts @@ -1,5 +1,13 @@ import Anthropic from '@anthropic-ai/sdk'; import { getClient, MODEL } from './client.js'; +import { generateDiffSummary, generateAggregateSummary } from './features/diff-summary.js'; +import { fillCompletionZone } from './features/completion-zone.js'; +import { queryCrossArtboard } from './features/artboard-query.js'; +import { answerAgentQuestion } from './features/agent-qa.js'; +import type { DiffSummaryInput, DiffSummaryOutput, AggregateSummaryInput, AggregateSummaryOutput } from './features/diff-summary.js'; +import type { CompletionZoneInput, CompletionZoneOutput } from './features/completion-zone.js'; +import type { ArtboardQueryInput, ArtboardQueryOutput } from './features/artboard-query.js'; +import type { AgentQAInput, AgentQAOutput } from './features/agent-qa.js'; // ── Gateway config ──────────────────────────────────────────────────────────── @@ -21,21 +29,22 @@ export interface RequestCost { estimatedCentsCost: number; } -const OPUS_4_INPUT_COST_PER_M = 500; // $5.00 / 1M -const OPUS_4_OUTPUT_COST_PER_M = 2500; // $25.00 / 1M -const OPUS_4_CACHE_READ_PER_M = 50; // $0.50 / 1M (estimated) +// Spec Layer 6: claude-sonnet-4-6 pricing (used for cost attribution logging) +const SONNET_4_INPUT_COST_PER_M = 300; // $3.00 / 1M input tokens +const SONNET_4_OUTPUT_COST_PER_M = 1500; // $15.00 / 1M output tokens +const SONNET_4_CACHE_READ_PER_M = 30; // $0.30 / 1M cache-read tokens function computeCost(usage: { input_tokens: number; output_tokens: number; cache_read_input_tokens?: number | null; cache_creation_input_tokens?: number | null }): RequestCost { - const inputTokens = usage.input_tokens; - const outputTokens = usage.output_tokens; - const cacheReadTokens = usage.cache_read_input_tokens ?? 0; + const inputTokens = usage.input_tokens; + const outputTokens = usage.output_tokens; + const cacheReadTokens = usage.cache_read_input_tokens ?? 0; const cacheWriteTokens = usage.cache_creation_input_tokens ?? 0; const billableInput = inputTokens - cacheReadTokens; const estimatedCentsCost = Math.round( - (billableInput / 1_000_000) * OPUS_4_INPUT_COST_PER_M + - (outputTokens / 1_000_000) * OPUS_4_OUTPUT_COST_PER_M + - (cacheReadTokens / 1_000_000) * OPUS_4_CACHE_READ_PER_M + (billableInput / 1_000_000) * SONNET_4_INPUT_COST_PER_M + + (outputTokens / 1_000_000) * SONNET_4_OUTPUT_COST_PER_M + + (cacheReadTokens / 1_000_000) * SONNET_4_CACHE_READ_PER_M ); return { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, estimatedCentsCost }; @@ -70,11 +79,22 @@ function sleep(ms: number): Promise { // ── Gateway ─────────────────────────────────────────────────────────────────── export interface GatewayRequest { - messages: Anthropic.Messages.MessageParam[]; - system?: Anthropic.Messages.TextBlockParam[]; - maxTokens?: number; - // NOTE: temperature is intentionally omitted — claude-opus-4-7 with - // adaptive thinking rejects temperature, top_p, and top_k with a 400 error. + messages: Anthropic.Messages.MessageParam[]; + system?: Anthropic.Messages.TextBlockParam[]; + maxTokens?: number; + /** + * Sampling temperature — spec Layer 6.3 per-prompt values: + * diff-summary: 0.2 (consistent, low-creativity summaries) + * completion-zone: 0.3 (slight creative latitude for UI generation) + * agent-query: 0.1 (factual/authoritative answers) + * Defaults to 0.2 if omitted. Range [0, 1]. + */ + temperature?: number; + /** + * Workspace ID for per-workspace cost attribution (spec Layer 6.2-R2). + * Logged on every request so cost can be aggregated per workspace. + */ + workspaceId?: string; } export interface GatewayResponse { @@ -98,15 +118,18 @@ export class AIGateway { async complete(req: GatewayRequest): Promise { await this.rateLimiter.acquire(); + const requestId = `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`; + const startMs = Date.now(); + let lastError: Error | null = null; for (let attempt = 0; attempt < this.maxRetries; attempt++) { try { const response = await this.client.messages.create({ - model: MODEL, - max_tokens: req.maxTokens ?? 4096, - thinking: { type: 'adaptive' }, + model: MODEL, + max_tokens: req.maxTokens ?? 4096, + temperature: req.temperature ?? 0.2, ...(req.system !== undefined ? { system: req.system } : {}), - messages: req.messages, + messages: req.messages, }); const text = response.content @@ -114,20 +137,55 @@ export class AIGateway { .map(b => b.text) .join(''); - const cost = computeCost(response.usage); + const cost = computeCost(response.usage); + const latencyMs = Date.now() - startMs; this.totalCost += cost.estimatedCentsCost; + // ── Structured request log (spec Layer 6.2-R4) ────────────────────── + // Every AI call is logged with: requestId, model, token counts, cost + // estimate, latency, and workspace ID for per-workspace attribution. + console.log(JSON.stringify({ + level: 'info', + event: 'ai_request', + requestId, + model: MODEL, + workspaceId: req.workspaceId ?? null, + inputTokens: cost.inputTokens, + outputTokens: cost.outputTokens, + cacheReadTokens: cost.cacheReadTokens, + cacheWriteTokens: cost.cacheWriteTokens, + estimatedCents: cost.estimatedCentsCost, + latencyMs, + attempt, + })); + return { content: response.content, text, cost }; } catch (err) { lastError = err instanceof Error ? err : new Error(String(err)); + const latencyMs = Date.now() - startMs; + + // Log every failed attempt for observability + console.error(JSON.stringify({ + level: 'error', + event: 'ai_request_error', + requestId, + model: MODEL, + workspaceId: req.workspaceId ?? null, + attempt, + latencyMs, + error: lastError.message, + })); + // Retry on 429, 529, 5xx, and network errors (no HTTP status). // Break immediately on client errors (4xx that aren't rate-limits). if (err instanceof Anthropic.APIError) { const { status } = err; if (status !== 429 && status !== 529 && status < 500) break; } - // Non-APIError (network timeout, DNS failure, etc.) → always retry - await sleep(2 ** attempt * 1000); + // Non-APIError (network timeout, DNS failure, etc.) → always retry. + // Cap at 30s so a long retry sequence doesn't wedge the server forever. + const delayMs = Math.min(2 ** attempt * 1000, 30_000); + await sleep(delayMs); } } @@ -138,4 +196,28 @@ export class AIGateway { getCumulativeCost(): number { return this.totalCost; } + + // ── Spec Layer 5 convenience methods ───────────────────────────────────────── + // Thin wrappers that delegate to the standalone feature functions, making the + // gateway usable as a single dependency injection point across the app layer. + + generateDiffSummary(input: DiffSummaryInput): Promise { + return generateDiffSummary(this, input); + } + + generateAggregateSummary(input: AggregateSummaryInput): Promise { + return generateAggregateSummary(this, input); + } + + fillCompletionZone(input: CompletionZoneInput): Promise { + return fillCompletionZone(this, input); + } + + queryArtboards(input: ArtboardQueryInput): Promise { + return queryCrossArtboard(this, input); + } + + answerAgentQuery(input: AgentQAInput): Promise { + return answerAgentQuestion(this, input); + } } diff --git a/packages/ai-layer/src/index.ts b/packages/ai-layer/src/index.ts index 715b700..5b7edea 100644 --- a/packages/ai-layer/src/index.ts +++ b/packages/ai-layer/src/index.ts @@ -2,11 +2,24 @@ export { getClient, MODEL } from './client.js'; export { AIGateway } from './gateway.js'; export type { GatewayRequest, GatewayResponse, RequestCost } from './gateway.js'; +// ── Versioned prompt builders ───────────────────────────────────────────────── +export { DIFF_SUMMARY_PROMPT_VERSION, buildDiffSummaryMessages, buildAggregateSummaryMessages } from './prompts/diff-summary.prompt.js'; +export type { DiffSummaryPromptInput, AggregateSummaryPromptInput } from './prompts/diff-summary.prompt.js'; + +export { COMPLETION_ZONE_PROMPT_VERSION, buildCompletionZoneMessages } from './prompts/completion-zone.prompt.js'; +export type { CompletionZonePromptInput } from './prompts/completion-zone.prompt.js'; + +export { ARTBOARD_QUERY_PROMPT_VERSION, buildArtboardQueryMessages } from './prompts/artboard-query.prompt.js'; +export type { ArtboardQueryPromptInput } from './prompts/artboard-query.prompt.js'; + +export { AGENT_QUERY_PROMPT_VERSION, buildAgentQueryMessages } from './prompts/agent-query.prompt.js'; +export type { AgentQueryPromptInput } from './prompts/agent-query.prompt.js'; + export { fillCompletionZone } from './features/completion-zone.js'; export type { CompletionZoneInput, CompletionZoneOutput } from './features/completion-zone.js'; -export { generateDiffSummary } from './features/diff-summary.js'; -export type { DiffSummaryInput, DiffSummaryOutput } from './features/diff-summary.js'; +export { generateDiffSummary, generateAggregateSummary } from './features/diff-summary.js'; +export type { DiffSummaryInput, DiffSummaryOutput, AggregateSummaryInput, AggregateSummaryOutput } from './features/diff-summary.js'; export { queryCrossArtboard } from './features/artboard-query.js'; export type { ArtboardQueryInput, ArtboardQueryOutput, ArtboardQueryResult } from './features/artboard-query.js'; diff --git a/packages/ai-layer/src/prompts/agent-query.prompt.ts b/packages/ai-layer/src/prompts/agent-query.prompt.ts new file mode 100644 index 0000000..138081a --- /dev/null +++ b/packages/ai-layer/src/prompts/agent-query.prompt.ts @@ -0,0 +1,99 @@ +// ── Agent Query Prompt ──────────────────────────────────────────────────────── +// Versioned prompt for answering coding-agent questions about design diffs. +// +// Version: 1.0 +// Model: claude-sonnet-4-6 (spec Layer 6: "Claude Sonnet 4 API is the only AI provider") +// Tokens: max 1024 — JSON with answer + optional visual reference +// +// Structure: +// System: stable role (cached) + optional DLF constraint block (cached) +// User: [before screenshot] + [after screenshot] + artboard context + +// diff ID + agent question (spec Layer 6.3-R3: screenshots required) + +import type Anthropic from '@anthropic-ai/sdk'; +import { buildSystemPrompt } from './system.js'; + +export const AGENT_QUERY_PROMPT_VERSION = '1.0'; + +export interface AgentQueryPromptInput { + /** Question from the coding agent (e.g. Cursor or Claude Code) */ + question: string; + /** The diff ID this question is about */ + diffId: string; + /** Full artboard context (IntentDiff + artboard metadata) as JSON */ + artboardContextJson: string; + /** Active DLF as JSON string (optional — cached in system when present) */ + dlfJson?: string; + /** + * Before-state screenshot as base64 data URL (spec Layer 6.3-R3). + * Gives the model visual ground truth of what the artboard looked like + * before the diff was applied. + */ + beforeScreenshotBase64?: string; + /** + * After-state screenshot as base64 data URL (spec Layer 6.3-R3). + * Gives the model the proposed post-diff visual state. + */ + afterScreenshotBase64?: string; +} + +function stripDataUrl(dataUrl: string): string { + return dataUrl.replace(/^data:image\/\w+;base64,/, ''); +} + +/** + * Builds the system + user content blocks for a coding-agent design question. + * When screenshots are provided they precede the text context so the model can + * visually ground its answer before reading the structured JSON. + * Max 1024 tokens — the answer must be precise and actionable. + */ +export function buildAgentQueryMessages(input: AgentQueryPromptInput): { + system: Anthropic.Messages.TextBlockParam[]; + userContent: Anthropic.Messages.ContentBlockParam[]; + maxTokens: number; +} { + const system = buildSystemPrompt({ + role: 'a design agent answering questions from a coding agent implementing a design diff', + ...(input.dlfJson !== undefined ? { dlfJson: input.dlfJson } : {}), + }); + + const userContent: Anthropic.Messages.ContentBlockParam[] = []; + + // Screenshots first — visual context before text, per multimodal best practice. + if (input.beforeScreenshotBase64) { + userContent.push({ + type: 'text', + text: 'Before state (artboard before this diff was applied):', + }); + userContent.push({ + type: 'image', + source: { type: 'base64', media_type: 'image/png', data: stripDataUrl(input.beforeScreenshotBase64) }, + } as Anthropic.Messages.ImageBlockParam); + } + + if (input.afterScreenshotBase64) { + userContent.push({ + type: 'text', + text: 'After state (proposed artboard after this diff is applied):', + }); + userContent.push({ + type: 'image', + source: { type: 'base64', media_type: 'image/png', data: stripDataUrl(input.afterScreenshotBase64) }, + } as Anthropic.Messages.ImageBlockParam); + } + + userContent.push({ + type: 'text', + text: + `\n${input.artboardContextJson}\n\n\n` + + `${input.diffId}\n\n` + + `\n${input.question}\n\n\n` + + `Answer the coding agent's question concisely and precisely. ` + + `If the answer references a specific design token, component, or visual region, ` + + `include a "visualReference" field. Return JSON:\n` + + `{\n "answer": "...",\n "visualReference": "..." (optional)\n}\n\n` + + `Respond ONLY with valid JSON.`, + }); + + return { system, userContent, maxTokens: 1024 }; +} diff --git a/packages/ai-layer/src/prompts/artboard-query.prompt.ts b/packages/ai-layer/src/prompts/artboard-query.prompt.ts new file mode 100644 index 0000000..b874b01 --- /dev/null +++ b/packages/ai-layer/src/prompts/artboard-query.prompt.ts @@ -0,0 +1,46 @@ +// ── Artboard Query Prompt ───────────────────────────────────────────────────── +// Versioned prompt for cross-artboard design intent search. +// +// Version: 1.0 +// Model: claude-sonnet-4-6 (spec Layer 6: "Claude Sonnet 4 API is the only AI provider") +// Tokens: max 1024 — output is a JSON results array ranked by relevance +// +// Structure: +// System: stable role (cached) — no DLF needed for cross-artboard search +// User: artboard metadata array + query string (not cached — varies per call) + +import type Anthropic from '@anthropic-ai/sdk'; +import { buildSystemPrompt } from './system.js'; + +export const ARTBOARD_QUERY_PROMPT_VERSION = '1.0'; + +export interface ArtboardQueryPromptInput { + /** Natural language query from the user */ + query: string; + /** Array of artboard metadata objects serialized as JSON */ + artboardsJson: string; +} + +/** + * Builds the system + user message pair for a cross-artboard relevance search. + * Only artboards with relevanceScore > 0.3 should be returned by the model. + */ +export function buildArtboardQueryMessages(input: ArtboardQueryPromptInput): { + system: Anthropic.Messages.TextBlockParam[]; + userContent: string; + maxTokens: number; +} { + const system = buildSystemPrompt({ role: 'a search agent filtering artboards by design intent' }); + + const userContent = + `\n${input.artboardsJson}\n\n\n` + + `\n${input.query}\n\n\n` + + `Return a JSON object:\n` + + `{\n` + + ` "results": [{"artboardId": "...", "relevanceScore": 0-1, "reason": "..."}],\n` + + ` "reasoning": "brief explanation"\n` + + `}\n\n` + + `Only include artboards with relevanceScore > 0.3. Respond ONLY with valid JSON.`; + + return { system, userContent, maxTokens: 1024 }; +} diff --git a/packages/ai-layer/src/prompts/completion-zone.prompt.ts b/packages/ai-layer/src/prompts/completion-zone.prompt.ts new file mode 100644 index 0000000..e3dec79 --- /dev/null +++ b/packages/ai-layer/src/prompts/completion-zone.prompt.ts @@ -0,0 +1,71 @@ +// ── Completion Zone Prompt ──────────────────────────────────────────────────── +// Versioned prompt for filling AI Completion Zones. +// +// Version: 1.0 +// Model: claude-sonnet-4-6 (spec Layer 6: "Claude Sonnet 4 API is the only AI provider") +// Tokens: max 4096 — output is a structured JSON tree +// +// Structure: +// System: stable role (cached) + DLF as a hard constraint block (cached) +// User: component tree context + optional screenshot + zone intent string +// +// The DLF is placed first (before the component tree) so it is cached across +// all completion zone calls in the same workspace session. The model must treat +// the DLF as a hard constraint: any proposed component must exist in the DLF's +// component list, and all props must pass the DLF's allowedProps rules. + +import type Anthropic from '@anthropic-ai/sdk'; +import { buildSystemPrompt } from './system.js'; + +export const COMPLETION_ZONE_PROMPT_VERSION = '1.0'; + +export interface CompletionZonePromptInput { + componentTreeJson: string; + intent: string; + dlfJson?: string; + screenshotBase64?: string; +} + +/** + * Builds the full message array for a completion zone generation request. + * Returns a multi-part user message that includes an optional image block. + */ +export function buildCompletionZoneMessages(input: CompletionZonePromptInput): { + system: Anthropic.Messages.TextBlockParam[]; + userContent: Anthropic.Messages.ContentBlockParam[]; + maxTokens: number; +} { + const system = buildSystemPrompt({ + role: 'a Completion Zone design agent that generates UI component trees to fill empty design regions', + ...(input.dlfJson !== undefined ? { dlfJson: input.dlfJson } : {}), + }); + + const textBlock: Anthropic.Messages.TextBlockParam = { + type: 'text', + text: + `\n${input.componentTreeJson}\n\n\n` + + `\n${input.intent}\n\n\n` + + (input.dlfJson + ? `The DLF in the system prompt is a HARD CONSTRAINT. Only use components and prop values defined there.\n\n` + : '') + + `Return a JSON object with exactly two fields:\n` + + `- "proposedTree": the updated component tree matching the intent (same shape as the input tree)\n` + + `- "description": one sentence describing what was generated and why\n\n` + + `Respond ONLY with valid JSON. No markdown, no prose outside the JSON.`, + }; + + const userContent: Anthropic.Messages.ContentBlockParam[] = []; + + // Screenshot goes first if provided (visual context before textual context). + if (input.screenshotBase64) { + const base64Data = input.screenshotBase64.replace(/^data:image\/\w+;base64,/, ''); + userContent.push({ + type: 'image', + source: { type: 'base64', media_type: 'image/png', data: base64Data }, + } as Anthropic.Messages.ImageBlockParam); + } + + userContent.push(textBlock); + + return { system, userContent, maxTokens: 4096 }; +} diff --git a/packages/ai-layer/src/prompts/diff-summary.prompt.ts b/packages/ai-layer/src/prompts/diff-summary.prompt.ts new file mode 100644 index 0000000..3819b6d --- /dev/null +++ b/packages/ai-layer/src/prompts/diff-summary.prompt.ts @@ -0,0 +1,72 @@ +// ── Diff Summary Prompt ─────────────────────────────────────────────────────── +// Versioned prompt for generating human-readable summaries of ComponentChange +// records and aggregate IntentDiff records. +// +// Version: 1.0 +// Model: claude-sonnet-4-6 (spec Layer 6: "Claude Sonnet 4 API is the only AI provider") +// Tokens: max 300 per change summary, 500 for aggregate summary +// +// Structure: +// System: stable role (cached) + optional DLF constraint block (cached) +// User: component changes + output instruction (not cached — varies per call) + +import type Anthropic from '@anthropic-ai/sdk'; +import { buildSystemPrompt } from './system.js'; + +export const DIFF_SUMMARY_PROMPT_VERSION = '1.0'; + +export interface DiffSummaryPromptInput { + changesJson: string; + componentName: string; + dlfJson?: string; +} + +export interface AggregateSummaryPromptInput { + changesJson: string; + artboardName: string; + dlfJson?: string; +} + +/** + * Builds the system + user message pair for a single-component change summary. + * Max 300 tokens — one tight sentence for PR review readability. + */ +export function buildDiffSummaryMessages( + input: DiffSummaryPromptInput, +): { system: Anthropic.Messages.TextBlockParam[]; userContent: string; maxTokens: number } { + const system = buildSystemPrompt({ + role: 'a technical writer summarizing UI component changes for pull request reviewers', + ...(input.dlfJson !== undefined ? { dlfJson: input.dlfJson } : {}), + }); + + const userContent = + `Summarize the following changes to "${input.componentName}" in one concise sentence ` + + `(max 20 words). Focus on the user-visible impact, not technical prop names. ` + + `If the change violates a design system rule, note it.\n\n` + + `\n${input.changesJson}\n\n\n` + + `Respond with ONLY the summary sentence.`; + + return { system, userContent, maxTokens: 300 }; +} + +/** + * Builds the system + user message pair for an aggregate IntentDiff summary. + * Max 500 tokens — a short paragraph describing all changes together. + */ +export function buildAggregateSummaryMessages( + input: AggregateSummaryPromptInput, +): { system: Anthropic.Messages.TextBlockParam[]; userContent: string; maxTokens: number } { + const system = buildSystemPrompt({ + role: 'a technical writer summarizing a design session for engineering handoff', + ...(input.dlfJson !== undefined ? { dlfJson: input.dlfJson } : {}), + }); + + const userContent = + `Write a 2-3 sentence summary of the following design changes made to the "${input.artboardName}" artboard. ` + + `Describe the combined intent — what changed and why — in terms a developer reading a PR can understand. ` + + `Do not list individual prop changes; synthesize them.\n\n` + + `\n${input.changesJson}\n\n\n` + + `Respond with ONLY the summary paragraph.`; + + return { system, userContent, maxTokens: 500 }; +} diff --git a/packages/app/package.json b/packages/app/package.json index 6bba200..19f18be 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -27,9 +27,13 @@ "@pierre/trees": "1.0.0-beta.3", "@supabase/supabase-js": "^2.0.0", "@tanstack/react-query": "^5.62.0", + "@trpc/client": "^11.17.0", + "@trpc/react-query": "^11.17.0", + "@trpc/server": "^11.17.0", "next": "^15.1.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "zod": "^3.25.76", "zustand": "^5.0.0" }, "devDependencies": { diff --git a/packages/app/src/app/api/trpc/[trpc]/route.ts b/packages/app/src/app/api/trpc/[trpc]/route.ts new file mode 100644 index 0000000..90b9f03 --- /dev/null +++ b/packages/app/src/app/api/trpc/[trpc]/route.ts @@ -0,0 +1,21 @@ +// ── tRPC HTTP handler (Next.js App Router) ──────────────────────────────────── +// Mounts the tRPC appRouter at /api/trpc/* using the fetch adapter. +// Both GET (queries) and POST (mutations) are needed. + +import { fetchRequestHandler } from '@trpc/server/adapters/fetch'; +import { appRouter } from '@/server/routers/index'; +import { createTRPCContext } from '@/server/trpc'; +import type { NextRequest } from 'next/server'; + +const handler = (req: NextRequest) => + fetchRequestHandler({ + endpoint: '/api/trpc', + req, + router: appRouter, + createContext: createTRPCContext, + onError: ({ path, error }) => { + console.error(`tRPC error on /${path}:`, error.message); + }, + }); + +export { handler as GET, handler as POST }; diff --git a/packages/app/src/app/providers.tsx b/packages/app/src/app/providers.tsx index d596ae5..a1e699f 100644 --- a/packages/app/src/app/providers.tsx +++ b/packages/app/src/app/providers.tsx @@ -3,7 +3,9 @@ import { useState, useEffect, type ReactNode } from 'react'; import { FluentProvider } from '@fluentui/react-components'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { httpBatchLink } from '@trpc/client'; import { originmainLightTheme, originmainDarkTheme } from '@originmain/ui'; +import { trpc } from '@/lib/trpc'; import { useTheme } from '@/store/theme'; import { TourOverlay } from '@/components/walkthrough/TourOverlay'; @@ -23,6 +25,16 @@ export function Providers({ children }: { children: ReactNode }) { }), ); + // tRPC client — shares the QueryClient so tRPC queries/mutations go into the + // same cache as all other TanStack Query calls in the app. + const [trpcClient] = useState(() => + trpc.createClient({ + links: [ + httpBatchLink({ url: '/api/trpc' }), + ], + }), + ); + const mode = useTheme((s) => s.mode); const theme = mode === 'dark' ? originmainDarkTheme : originmainLightTheme; @@ -33,11 +45,13 @@ export function Providers({ children }: { children: ReactNode }) { }, [mode]); return ( - - - {children} - - - + + + + {children} + + + + ); } diff --git a/packages/app/src/components/canvas/Canvas.tsx b/packages/app/src/components/canvas/Canvas.tsx index 6039bc8..8809f91 100644 --- a/packages/app/src/components/canvas/Canvas.tsx +++ b/packages/app/src/components/canvas/Canvas.tsx @@ -7,6 +7,7 @@ import { useCanvas } from '@/store/canvas'; import { useArtboards, createArtboardMutation } from '@/hooks/useArtboards'; import { useCanvasTheme } from '@/store/canvasTheme'; import { Artboard } from './Artboard'; +import { CompletionZone } from './CompletionZone'; export function Canvas() { const T = useCanvasTheme(); @@ -77,8 +78,13 @@ export function Canvas() { // Zone tool: drag to draw a completion zone const zoneStart = useRef<{ x: number; y: number } | null>(null); - const [zonePreview, setZonePreview] = useState<{ x: number; y: number; w: number; h: number } | null>(null); - const [zoneDone, setZoneDone] = useState<{ x: number; y: number; w: number; h: number } | null>(null); + const [zonePreview, setZonePreview] = useState<{ x: number; y: number; w: number; h: number } | null>(null); + const [zoneDone, setZoneDone] = useState<{ x: number; y: number; w: number; h: number } | null>(null); + // Completion preview: AI result overlaid on the artboard at zone coords (spec Layer 6) + const [completionPreview, setCompletionPreview] = useState<{ + result: string; + bounds: { x: number; y: number; w: number; h: number }; + } | null>(null); // Wheel: pan or pinch-zoom useEffect(() => { @@ -274,15 +280,93 @@ export function Canvas() { )} + + {/* AI completion preview — rendered in artboard space at zone bounds. + Spec Layer 6: "A preview overlay showing the AI-generated completion + on the artboard." Positioned inside the transform layer so it tracks + pan/zoom automatically with no coordinate conversion needed. */} + {completionPreview && ( +
+ {/* "AI" badge */} +
+ + ⚡ AI Preview + + {/* Dismiss button */} + +
+

+ {completionPreview.result} +

+
+ )} - {/* Zone prompt overlay — shown after a zone drag completes */} + {/* Completion zone popup — shown after a zone drag completes. + Lives in screen space (outside the transform layer) so the input + isn't scaled by zoom. */} {zoneDone && ( - setZoneDone(null)} + onClose={() => { setZoneDone(null); }} + onResult={(result, bounds) => setCompletionPreview({ result, bounds })} /> )} @@ -443,145 +527,4 @@ function UrlOnboardingOverlay({ ); } -/* ── Zone prompt overlay ──────────────────────────────────── */ -function ZonePromptOverlay({ - bounds, artboardId, panX, panY, zoom, onClose, -}: { - bounds: { x: number; y: number; w: number; h: number }; - artboardId: string | null; - panX: number; panY: number; zoom: number; - onClose: () => void; -}) { - const [prompt, setPrompt] = useState(''); - const [status, setStatus] = useState<'idle' | 'loading' | 'done' | 'error'>('idle'); - const [result, setResult] = useState(''); - - // Convert canvas → screen coordinates (relative to canvas container) - const screenX = bounds.x * zoom + panX; - const screenY = (bounds.y + bounds.h) * zoom + panY + 10; // 10px below zone - - const submit = useCallback(async () => { - if (!prompt.trim() || !artboardId) return; - setStatus('loading'); - try { - const res = await fetch('/api/ai/completion-zone', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - artboard_id: artboardId, - bounds: { x: bounds.x, y: bounds.y, width: bounds.w, height: bounds.h }, - prompt: prompt.trim(), - }), - }); - if (!res.ok) throw new Error(`${res.status}`); - const data = await res.json() as { completion?: string; result?: string }; - setResult(data.completion ?? data.result ?? 'Done'); - setStatus('done'); - } catch (e) { - console.error('[ZonePrompt]', e); - setStatus('error'); - } - }, [prompt, artboardId, bounds]); - - return ( -
e.stopPropagation()} - > - {/* Header */} -
- - ⚡ Completion zone · {bounds.w}×{bounds.h} - - -
- - {status === 'done' ? ( -
- {result} -
- ) : ( - <> -