improved a lot of things
This commit is contained in:
@@ -10,6 +10,8 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.56.0",
|
||||
"@originmain/design-language": "workspace:*",
|
||||
"@originmain/diff-engine": "workspace:*",
|
||||
"zod": "^3.0.0"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
|
||||
// ── Model constants ───────────────────────────────────────────────────────────
|
||||
|
||||
export const MODEL = 'claude-opus-4-7' as const;
|
||||
|
||||
// ── Singleton client ──────────────────────────────────────────────────────────
|
||||
// The client is created once and shared. API key is injected from the server
|
||||
// environment — never exposed to the client bundle.
|
||||
|
||||
let _client: Anthropic | null = null;
|
||||
|
||||
export function getClient(): Anthropic {
|
||||
if (!_client) {
|
||||
const apiKey = process.env.ANTHROPIC_API_KEY;
|
||||
if (!apiKey) throw new Error('ANTHROPIC_API_KEY is not set');
|
||||
_client = new Anthropic({ apiKey });
|
||||
}
|
||||
return _client;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { AIGateway } from '../gateway.js';
|
||||
import { buildSystemPrompt } from '../prompts/system.js';
|
||||
|
||||
export interface AgentQAInput {
|
||||
/** 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 as JSON */
|
||||
artboardContextJson: string;
|
||||
/** Active DLF */
|
||||
dlfJson?: string;
|
||||
}
|
||||
|
||||
export interface AgentQAOutput {
|
||||
answer: string;
|
||||
/** Optional visual reference description (e.g. "see padding token in section 3") */
|
||||
visualReference?: string;
|
||||
}
|
||||
|
||||
export async function answerAgentQuestion(
|
||||
gateway: AIGateway,
|
||||
input: AgentQAInput
|
||||
): Promise<AgentQAOutput> {
|
||||
const system = buildSystemPrompt({
|
||||
role: 'a design agent answering questions from a coding agent implementing a design diff',
|
||||
...(input.dlfJson !== undefined ? { dlfJson: input.dlfJson } : {}),
|
||||
});
|
||||
|
||||
const response = await gateway.complete({
|
||||
system,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: `<artboard_context>\n${input.artboardContextJson}\n</artboard_context>\n\n<diff_id>${input.diffId}</diff_id>\n\n<question>\n${input.question}\n</question>\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,
|
||||
temperature: 0.7,
|
||||
});
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(response.text);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Design agent returned unparseable JSON: ${String(err)}. ` +
|
||||
`Raw response (first 300 chars): ${response.text.slice(0, 300)}`
|
||||
);
|
||||
}
|
||||
|
||||
const output = parsed as AgentQAOutput;
|
||||
if (!output.answer || typeof output.answer !== 'string') {
|
||||
throw new Error(
|
||||
`Design agent response missing required "answer" field. ` +
|
||||
`Raw response (first 300 chars): ${response.text.slice(0, 300)}`
|
||||
);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { AIGateway } from '../gateway.js';
|
||||
import { buildSystemPrompt } from '../prompts/system.js';
|
||||
|
||||
export interface ArtboardQueryInput {
|
||||
/** Natural language query from the user */
|
||||
query: string;
|
||||
/** Array of artboard metadata objects as JSON */
|
||||
artboardsJson: string;
|
||||
}
|
||||
|
||||
export interface ArtboardQueryResult {
|
||||
artboardId: string;
|
||||
relevanceScore: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ArtboardQueryOutput {
|
||||
results: ArtboardQueryResult[];
|
||||
reasoning: string;
|
||||
}
|
||||
|
||||
export async function queryCrossArtboard(
|
||||
gateway: AIGateway,
|
||||
input: ArtboardQueryInput
|
||||
): Promise<ArtboardQueryOutput> {
|
||||
const system = buildSystemPrompt({ role: 'a search agent filtering artboards by design intent' });
|
||||
|
||||
const response = await gateway.complete({
|
||||
system,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: `<artboards>\n${input.artboardsJson}\n</artboards>\n\n<query>\n${input.query}\n</query>\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,
|
||||
temperature: 0.3,
|
||||
});
|
||||
|
||||
// Returning empty results on parse failure is indistinguishable from "no match".
|
||||
// Throw so the UI can show an error rather than a misleading empty state.
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(response.text);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Artboard query returned unparseable JSON: ${String(err)}. ` +
|
||||
`Raw response (first 300 chars): ${response.text.slice(0, 300)}`
|
||||
);
|
||||
}
|
||||
|
||||
return parsed as ArtboardQueryOutput;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type Anthropic from '@anthropic-ai/sdk';
|
||||
import { AIGateway } from '../gateway.js';
|
||||
import { buildSystemPrompt } from '../prompts/system.js';
|
||||
import type { GatewayResponse } from '../gateway.js';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CompletionZoneInput {
|
||||
/** JSON of the surrounding component tree */
|
||||
componentTreeJson: string;
|
||||
/** User intent / what the zone should be filled with */
|
||||
intent: string;
|
||||
/** Active Design Language File as JSON string */
|
||||
dlfJson?: string;
|
||||
/** Before screenshot as base64 data URL (optional) */
|
||||
screenshotBase64?: string;
|
||||
}
|
||||
|
||||
export interface CompletionZoneOutput {
|
||||
/** Proposed component tree changes as structured JSON */
|
||||
proposedTree: unknown;
|
||||
/** Natural language description of the proposed change */
|
||||
description: string;
|
||||
raw: GatewayResponse;
|
||||
}
|
||||
|
||||
// ── Feature ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fillCompletionZone(
|
||||
gateway: AIGateway,
|
||||
input: CompletionZoneInput,
|
||||
maxRetries = 3
|
||||
): Promise<CompletionZoneOutput> {
|
||||
const system = buildSystemPrompt({
|
||||
role: 'a Completion Zone design agent',
|
||||
...(input.dlfJson !== undefined ? { dlfJson: input.dlfJson } : {}),
|
||||
});
|
||||
|
||||
const userContent: Anthropic.Messages.ContentBlockParam[] = [
|
||||
{
|
||||
type: 'text',
|
||||
text: `<component_tree>\n${input.componentTreeJson}\n</component_tree>\n\n<intent>\n${input.intent}\n</intent>\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
|
||||
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,
|
||||
temperature: 0.3,
|
||||
});
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(response.text);
|
||||
return { proposedTree: parsed.proposedTree as unknown, description: String(parsed.description ?? ''), raw: response };
|
||||
} 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)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new Error('Completion zone fill failed after retries');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { AIGateway } from '../gateway.js';
|
||||
import { buildSystemPrompt } from '../prompts/system.js';
|
||||
|
||||
export interface DiffSummaryInput {
|
||||
/** Serialized component-level changes (JSON) */
|
||||
changesJson: string;
|
||||
/** Component name */
|
||||
componentName: string;
|
||||
}
|
||||
|
||||
export interface DiffSummaryOutput {
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export async function generateDiffSummary(
|
||||
gateway: AIGateway,
|
||||
input: DiffSummaryInput
|
||||
): Promise<DiffSummaryOutput> {
|
||||
const system = buildSystemPrompt({ role: 'a technical writer summarizing UI component changes' });
|
||||
|
||||
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<changes>\n${input.changesJson}\n</changes>\n\nRespond with only the summary sentence.`,
|
||||
},
|
||||
],
|
||||
maxTokens: 128,
|
||||
temperature: 0.3,
|
||||
});
|
||||
|
||||
return { summary: response.text.trim() };
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { AIGateway } from '../gateway.js';
|
||||
import { buildSystemPrompt } from '../prompts/system.js';
|
||||
|
||||
export interface DriftReportInput {
|
||||
/** Screenshot of the live app as base64 data URL */
|
||||
screenshotBase64: string;
|
||||
/** Active Design Language File as JSON string */
|
||||
dlfJson: string;
|
||||
}
|
||||
|
||||
export interface DriftViolation {
|
||||
component: string;
|
||||
property: string;
|
||||
currentValue: string;
|
||||
expectedValue: string;
|
||||
severity: 'critical' | 'warning';
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface DriftReportOutput {
|
||||
violations: DriftViolation[];
|
||||
summary: string;
|
||||
violationCount: number;
|
||||
}
|
||||
|
||||
export async function generateDriftReport(
|
||||
gateway: AIGateway,
|
||||
input: DriftReportInput
|
||||
): Promise<DriftReportOutput> {
|
||||
const system = buildSystemPrompt({
|
||||
role: 'a design system compliance auditor',
|
||||
dlfJson: input.dlfJson,
|
||||
});
|
||||
|
||||
const response = await gateway.complete({
|
||||
system,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'image/png',
|
||||
data: input.screenshotBase64.replace(/^data:image\/\w+;base64,/, ''),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Compare this screenshot against the design language file provided in your system context. Identify all design system drift violations.\n\nReturn a JSON object:\n{\n "violations": [{"component":"...", "property":"...", "currentValue":"...", "expectedValue":"...", "severity":"critical|warning", "description":"..."}],\n "summary": "...",\n "violationCount": N\n}\n\nRespond ONLY with valid JSON.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
maxTokens: 4096,
|
||||
temperature: 0.3,
|
||||
});
|
||||
|
||||
// Returning empty violations on parse failure would be a false-negative in a
|
||||
// compliance feature. Throw so the caller can surface the error clearly.
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(response.text);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Drift report returned unparseable JSON: ${String(err)}. ` +
|
||||
`Raw response (first 300 chars): ${response.text.slice(0, 300)}`
|
||||
);
|
||||
}
|
||||
|
||||
return parsed as DriftReportOutput;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
import { getClient, MODEL } from './client.js';
|
||||
|
||||
// ── Gateway config ────────────────────────────────────────────────────────────
|
||||
|
||||
interface GatewayConfig {
|
||||
/** Max requests per minute (default: 60) */
|
||||
rpmLimit?: number;
|
||||
/** Max retries on transient errors (default: 3) */
|
||||
maxRetries?: number;
|
||||
}
|
||||
|
||||
// ── Cost tracking ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface RequestCost {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheWriteTokens: number;
|
||||
/** Estimated cost in USD cents */
|
||||
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)
|
||||
|
||||
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 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
|
||||
);
|
||||
|
||||
return { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, estimatedCentsCost };
|
||||
}
|
||||
|
||||
// ── Rate limiter ──────────────────────────────────────────────────────────────
|
||||
|
||||
class RateLimiter {
|
||||
private readonly rpm: number;
|
||||
private timestamps: number[] = [];
|
||||
|
||||
constructor(rpm: number) { this.rpm = rpm; }
|
||||
|
||||
async acquire(): Promise<void> {
|
||||
const now = Date.now();
|
||||
this.timestamps = this.timestamps.filter(t => now - t < 60_000);
|
||||
if (this.timestamps.length >= this.rpm) {
|
||||
const oldest = this.timestamps[0];
|
||||
if (oldest !== undefined) {
|
||||
const wait = 60_000 - (now - oldest);
|
||||
if (wait > 0) await sleep(wait);
|
||||
}
|
||||
}
|
||||
this.timestamps.push(Date.now());
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// ── Gateway ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GatewayRequest {
|
||||
messages: Anthropic.Messages.MessageParam[];
|
||||
system?: Anthropic.Messages.TextBlockParam[];
|
||||
maxTokens?: number;
|
||||
// NOTE: adaptive thinking (`thinking: { type: 'adaptive' }`) requires SDK >=0.58.
|
||||
// Upgrade @anthropic-ai/sdk and uncomment when available.
|
||||
/** Temperature: 0.3 for deterministic, 0.7 for generative */
|
||||
temperature?: number;
|
||||
}
|
||||
|
||||
export interface GatewayResponse {
|
||||
content: Anthropic.Messages.ContentBlock[];
|
||||
text: string;
|
||||
cost: RequestCost;
|
||||
}
|
||||
|
||||
export class AIGateway {
|
||||
private readonly client: Anthropic;
|
||||
private readonly rateLimiter: RateLimiter;
|
||||
private readonly maxRetries: number;
|
||||
private totalCost = 0; // cumulative cents
|
||||
|
||||
constructor(config: GatewayConfig = {}) {
|
||||
this.client = getClient();
|
||||
this.rateLimiter = new RateLimiter(config.rpmLimit ?? 60);
|
||||
this.maxRetries = config.maxRetries ?? 3;
|
||||
}
|
||||
|
||||
async complete(req: GatewayRequest): Promise<GatewayResponse> {
|
||||
await this.rateLimiter.acquire();
|
||||
|
||||
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,
|
||||
...(req.temperature !== undefined ? { temperature: req.temperature } : {}),
|
||||
...(req.system !== undefined ? { system: req.system } : {}),
|
||||
messages: req.messages,
|
||||
});
|
||||
|
||||
const text = response.content
|
||||
.filter((b): b is Anthropic.Messages.TextBlock => b.type === 'text')
|
||||
.map(b => b.text)
|
||||
.join('');
|
||||
|
||||
const cost = computeCost(response.usage);
|
||||
this.totalCost += cost.estimatedCentsCost;
|
||||
|
||||
return { content: response.content, text, cost };
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new Error('AI gateway request failed');
|
||||
}
|
||||
|
||||
/** Cumulative estimated cost in cents since this gateway was instantiated. */
|
||||
getCumulativeCost(): number {
|
||||
return this.totalCost;
|
||||
}
|
||||
}
|
||||
@@ -1 +1,18 @@
|
||||
export {};
|
||||
export { getClient, MODEL } from './client.js';
|
||||
export { AIGateway } from './gateway.js';
|
||||
export type { GatewayRequest, GatewayResponse, RequestCost } from './gateway.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 { queryCrossArtboard } from './features/artboard-query.js';
|
||||
export type { ArtboardQueryInput, ArtboardQueryOutput, ArtboardQueryResult } from './features/artboard-query.js';
|
||||
|
||||
export { generateDriftReport } from './features/drift-report.js';
|
||||
export type { DriftReportInput, DriftReportOutput, DriftViolation } from './features/drift-report.js';
|
||||
|
||||
export { answerAgentQuestion } from './features/agent-qa.js';
|
||||
export type { AgentQAInput, AgentQAOutput } from './features/agent-qa.js';
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type Anthropic from '@anthropic-ai/sdk';
|
||||
|
||||
// ── System prompt builder ─────────────────────────────────────────────────────
|
||||
// The DLF is placed as the FIRST cache breakpoint — it's stable across all
|
||||
// requests in a workspace session, so cache hit rate is very high.
|
||||
// Variable content (artboard context, user question) comes AFTER the breakpoints.
|
||||
|
||||
export function buildSystemPrompt(opts: {
|
||||
role: string;
|
||||
dlfJson?: string;
|
||||
}): Anthropic.Messages.TextBlockParam[] {
|
||||
const blocks: Anthropic.Messages.TextBlockParam[] = [];
|
||||
|
||||
// First block: stable role description — cached
|
||||
blocks.push({
|
||||
type: 'text',
|
||||
text: `You are ${opts.role}. You work within Originmain, an AI-native design engineering platform. Your outputs are used directly by designers and engineers to implement product changes. Be precise, structured, and always respect the design system constraints provided.`,
|
||||
cache_control: { type: 'ephemeral' },
|
||||
});
|
||||
|
||||
// Second block: DLF if provided — cached (stable per workspace)
|
||||
if (opts.dlfJson) {
|
||||
blocks.push({
|
||||
type: 'text',
|
||||
text: `<design_language_file>\n${opts.dlfJson}\n</design_language_file>`,
|
||||
cache_control: { type: 'ephemeral' },
|
||||
});
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
Reference in New Issue
Block a user