openpencil/apps/web/server/api/mcp/document.post.ts
Kayshen-X a7d73ebb62 feat(ai): pencil-style agentic design tool-loop, multi-chat tabs, #27 panel restyle
Built-in design generation now runs as an agentic MCP tool-loop (reusing the
agent-rs BuiltInProvider), gated behind OPENPENCIL_DESIGN_AGENT_LOOP / the
Settings experimental toggle; the orchestrator stays the default.

- design-agent system prompt + in-process design toolset (parity-locked with
  the MCP surface) + flag-gated Intent::Design routing
- spawn_agents execution as sequential sub-loops + live creation-mode badges
  (per-agent glow + 'N/M designing...' header)
- new MCP tools: get_guidelines, ToolSearch, get_screenshot, get_editor_state,
  export_nodes, spawn_agents; style-guide local audit
- #27 AI panel restyle: rounded tool cards + green check-rings, gray user
  bubbles, model-pill bottom toolbar, header, empty-state pills, the
  PARALLEL AGENTS (agent_team_size) 1x-6x chip dropdown
- multi-chat tabs: ChatSessions model (Deref-to-active) + tab row UI
  (switch / close / + / Cmd+T) with each run bound to its tab

Large checkpoint commit spanning the working tree (Rust shell crates).
2026-07-02 21:21:06 +08:00

146 lines
4.8 KiB
TypeScript

import { defineEventHandler, readBody, createError, getRequestHeader, setResponseStatus } from 'h3';
import { setSyncDocument } from '../../utils/mcp-sync-state';
import { serverLog } from '../../utils/server-logger';
import type { PenDocument } from '../../../src/types/pen';
interface PostBody {
document: PenDocument;
sourceClientId?: string;
}
interface DocumentStats {
nodeCount: number;
imageCount: number;
dataUrlImageCount: number;
dataUrlChars: number;
}
function collectDocumentStats(doc: PenDocument): DocumentStats {
const stats: DocumentStats = {
nodeCount: 0,
imageCount: 0,
dataUrlImageCount: 0,
dataUrlChars: 0,
};
const visit = (nodes?: unknown): void => {
if (!Array.isArray(nodes)) return;
for (const node of nodes) {
if (!node || typeof node !== 'object') continue;
stats.nodeCount++;
const typedNode = node as {
type?: string;
src?: string;
children?: unknown;
};
if (typedNode.type === 'image') {
stats.imageCount++;
if (typeof typedNode.src === 'string' && typedNode.src.startsWith('data:')) {
stats.dataUrlImageCount++;
stats.dataUrlChars += typedNode.src.length;
}
}
visit(typedNode.children);
}
};
visit(doc.children);
if (Array.isArray(doc.pages)) {
for (const page of doc.pages) {
visit(page?.children);
}
}
return stats;
}
function formatBytes(bytes: number | null): string {
if (bytes == null || Number.isNaN(bytes)) return 'unknown';
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KiB`;
return `${(bytes / (1024 * 1024)).toFixed(2)}MiB`;
}
function isConnectionClosedError(error: unknown): boolean {
if (!error || typeof error !== 'object') return false;
const maybeError = error as { name?: string; message?: string; cause?: unknown };
const message = maybeError.message ?? '';
const causeMessage =
typeof maybeError.cause === 'object' && maybeError.cause
? String((maybeError.cause as { message?: string }).message ?? '')
: '';
return (
maybeError.name === 'AbortError' ||
/connection was closed/i.test(message) ||
/connection was closed/i.test(causeMessage) ||
/abort/i.test(message)
);
}
/** POST /api/mcp/document — Receives document update from MCP or renderer, triggers SSE broadcast. */
export default defineEventHandler(async (event) => {
const startedAt = Date.now();
const contentLengthHeader = getRequestHeader(event, 'content-length');
const bodyBytesHeader = getRequestHeader(event, 'x-openpencil-body-bytes');
const contentLength = contentLengthHeader
? Number.parseInt(contentLengthHeader, 10)
: bodyBytesHeader
? Number.parseInt(bodyBytesHeader, 10)
: null;
const sourceClientIdHeader = getRequestHeader(event, 'x-openpencil-client-id') ?? 'unknown';
let phase = 'readBody';
try {
const body = await readBody<PostBody>(event);
if (!body?.document) {
throw createError({ statusCode: 400, statusMessage: 'Missing document in request body' });
}
const doc = body.document;
if (!doc.version || (!Array.isArray(doc.children) && !Array.isArray(doc.pages))) {
throw createError({ statusCode: 400, statusMessage: 'Invalid document format' });
}
const stats = collectDocumentStats(doc);
phase = 'setSyncDocument';
const version = setSyncDocument(doc, body.sourceClientId);
const elapsedMs = Date.now() - startedAt;
serverLog.info(
`[mcp-document] ok version=${version} sourceClientId=${body.sourceClientId ?? sourceClientIdHeader} ` +
`contentLength=${formatBytes(contentLength)} nodes=${stats.nodeCount} images=${stats.imageCount} ` +
`dataUrlImages=${stats.dataUrlImageCount} dataUrlChars=${stats.dataUrlChars} elapsedMs=${elapsedMs}`,
);
return { ok: true, version };
} catch (error) {
const elapsedMs = Date.now() - startedAt;
const message = error instanceof Error ? error.message : String(error);
if (isConnectionClosedError(error)) {
serverLog.warn(
`[mcp-document] connection-closed phase=${phase} contentLength=${formatBytes(contentLength)} ` +
`sourceClientId=${sourceClientIdHeader} elapsedMs=${elapsedMs} message=${message}`,
);
// The client already closed the request while Nitro was still reading it.
// Returning a soft status keeps expected sync churn out of the 500 logs.
setResponseStatus(event, 202, 'Client closed request during MCP document sync');
return {
ok: false,
aborted: true,
phase,
};
}
serverLog.error(
`[mcp-document] failed phase=${phase} contentLength=${formatBytes(contentLength)} ` +
`sourceClientId=${sourceClientIdHeader} elapsedMs=${elapsedMs} message=${message}`,
);
throw error;
}
});