diff --git a/apps/server/package.json b/apps/server/package.json index b0d63d4..e4f1cef 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -10,17 +10,18 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@fastwrite/harness-claude": "workspace:*", + "@fastwrite/harness-codex": "workspace:*", "@fastwrite/harness-core": "workspace:*", "@fastwrite/harness-protocol": "workspace:*", - "@fastwrite/harness-codex": "workspace:*", - "@fastwrite/harness-claude": "workspace:*", "@fastwrite/shared": "workspace:*", "fflate": "^0.8.2", "openai": "^7.3.0", - "pdfjs-dist": "^5.4.296" + "pdfjs-dist": "^5.4.296", + "pptxgenjs": "4.0.1" }, "devDependencies": { - "@types/bun": "latest", + "@types/bun": "1.3.14", "typescript": "^5.9.3" } } diff --git a/apps/server/src/agent/provider.ts b/apps/server/src/agent/provider.ts index 47bb08d..cc9ff3f 100644 --- a/apps/server/src/agent/provider.ts +++ b/apps/server/src/agent/provider.ts @@ -23,6 +23,7 @@ export interface ReviseAgentOutput { } export interface AgentProvider { + generateDiagram?(input: { instruction: string; svg: string; model: string; previous: unknown }, signal?: AbortSignal): Promise; fileGenerationConcurrency?(): number; revise(input: ReviseAgentInput, signal?: AbortSignal): Promise; planDraft?(input: DraftAgentInput, signal?: AbortSignal): Promise<{ outline: DraftOutlineSection[] }>; diff --git a/apps/server/src/app.test.ts b/apps/server/src/app.test.ts index 32189e4..89bf4dc 100644 --- a/apps/server/src/app.test.ts +++ b/apps/server/src/app.test.ts @@ -26,8 +26,8 @@ describe("workspace API", () => { const response = await request("/api/harnesses"); expect(response.status).toBe(200); expect(await response.json()).toEqual(expect.arrayContaining([ - expect.objectContaining({ status: expect.objectContaining({ kind: "codex", state: "ready" }), capabilities: expect.objectContaining({ streaming: true, sessions: true, skills: true, mcp: true }) }), - expect.objectContaining({ status: expect.objectContaining({ kind: "claude", state: "ready" }) }) + expect.objectContaining({ status: expect.objectContaining({ kind: "codex", state: expect.stringMatching(/^(ready|degraded|unavailable)$/) }), capabilities: expect.objectContaining({ streaming: true, sessions: true, skills: true, mcp: true }) }), + expect.objectContaining({ status: expect.objectContaining({ kind: "claude", state: expect.stringMatching(/^(ready|degraded|unavailable)$/) }) }) ])); const invalid = await request("/api/harnesses/unknown/sessions", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ cwd: "/tmp" }) }); expect(invalid.status).toBe(400); diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index afdfe4f..5940ee9 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -1,4 +1,8 @@ import { existsSync } from "node:fs"; +import { DiagramService } from "./diagrams/service"; +import { diagramProvider } from "./diagrams/provider"; +import { diagramPrompt, diagramSchema } from "./diagrams/scene"; +import { renderDrawio, renderPptx, renderSvg } from "./diagrams/render"; import { stat } from "node:fs/promises"; import { extname, join } from "node:path"; import type { @@ -58,6 +62,7 @@ import { McpToolService } from "./agent/mcp-tool-service"; import type { HarnessAdapter, HarnessEvent } from "@fastwrite/harness-core"; interface Services { + diagrams: DiagramService; database: JsonDatabase; workspaces: WorkspaceService; uploads: UploadService; @@ -153,10 +158,10 @@ function harnessProvider(runs: HarnessRunService, kind: "codex" | "claude", cwd: const adapter = runs.adapter(kind); if (!adapter) throw new ApiError(503, "harness_unavailable", `Harness '${kind}' is unavailable`); const session = await adapter.createSession({ cwd, title: `FastWrite ${method}` }); - const schema = method === "planAgentTask" ? '{"steps":[string],"affectedFiles":[string],"risks":[string],"validation":[string]}' : method === "generateAgentTask" ? '{"files":[{"path":string,"content":string,"rationale":string}]}' : method === "planDraft" ? '{"outline":[{"path":string,"title":string,"purpose":string}]}' : method === "generateDraft" ? '{"files":[{"path":string,"content":string,"rationale":string}]}' : method === "revise" ? '{"replacement":string,"rationale":string}' : method === "complete" ? '{"suggestion":string}' : '{"result":object}'; - const content = `You are a structured academic writing engine. Execute operation '${method}'. Return ONLY one valid JSON object matching this exact schema: ${schema}. Do not echo the prompt. For file generation, content must be complete compilable LaTeX prose for the requested paper, with abstract, introduction, threat model, method, evaluation plan, and limitations as applicable. Never emit TODO, FIXME, placeholder brackets, template markers, or empty sections; if evidence is missing, state a concrete evaluation plan without claiming results. Input:\n${JSON.stringify(input)}`; + const schema = method === "generateDiagram" ? JSON.stringify(diagramSchema) : method === "planAgentTask" ? '{"steps":[string],"affectedFiles":[string],"risks":[string],"validation":[string]}' : method === "generateAgentTask" ? '{"files":[{"path":string,"content":string,"rationale":string}]}' : method === "planDraft" ? '{"outline":[{"path":string,"title":string,"purpose":string}]}' : method === "generateDraft" ? '{"files":[{"path":string,"content":string,"rationale":string}]}' : method === "revise" ? '{"replacement":string,"rationale":string}' : method === "complete" ? '{"suggestion":string}' : '{"result":object}'; + const content = method === "generateDiagram" ? `${diagramPrompt}\nSchema: ${schema}\nInput: ${JSON.stringify(input)}` : `You are a structured academic writing engine. Execute operation '${method}'. Return ONLY one valid JSON object matching this exact schema: ${schema}. Do not echo the prompt. For file generation, content must be complete compilable LaTeX prose for the requested paper, with abstract, introduction, threat model, method, evaluation plan, and limitations as applicable. Never emit TODO, FIXME, placeholder brackets, template markers, or empty sections; if evidence is missing, state a concrete evaluation plan without claiming results. Input:\n${JSON.stringify(input)}`; const chunks: string[] = []; - for await (const event of runs.send({ kind, session, content, ...(model ? { model } : {}), ...(signal ? { signal } : {}) })) { if (event.type === "assistant.delta") chunks.push(event.text); if (event.type === "run.failed") throw new Error(event.error); } + for await (const event of runs.send({ kind, session, content, ...(method === "generateDiagram" ? { model: "gpt-6-astra" } : model ? { model } : {}), ...(signal ? { signal } : {}) })) { if (event.type === "assistant.delta") chunks.push(event.text); if (event.type === "run.failed") throw new Error(event.error); } const text = chunks.join("").replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "").trim(); try { return JSON.parse(text); } catch { const candidates: string[] = []; let depth = 0; let start = -1; let quoted = false; let escaped = false; @@ -216,7 +221,9 @@ export async function createApplication(dataDirectory = config.dataDirectory, op const alignment = new AlignmentService(workspaces); const agentTasks = new AgentTaskService(database, workspaces, skillRegistry, asGateway(providers.agent), memories, asGateway(providers.review), compliance); const completions = new CompletionService(workspaces, skillRegistry, asGateway(providers.completion), memories); - const services: Services = { database, workspaces, uploads, github: new GithubService(dataDirectory, workspaces), githubSync: new GithubSyncService(dataDirectory, database, workspaces), revisions, drafts, reviews, memories, agentTasks, completions, texPackages, latexCompiler, skillRegistry, compliance, latexTemplates, research, claims, alignment, harnessRegistry, mcpRegistry, harnessRuns, harnessSessions, mcpTools }; + const diagrams = new DiagramService(dataDirectory, options.agentProvider?.generateDiagram ? options.agentProvider : diagramProvider(dataDirectory)); + await diagrams.initialize(); + const services: Services = { diagrams, database, workspaces, uploads, github: new GithubService(dataDirectory, workspaces), githubSync: new GithubSyncService(dataDirectory, database, workspaces), revisions, drafts, reviews, memories, agentTasks, completions, texPackages, latexCompiler, skillRegistry, compliance, latexTemplates, research, claims, alignment, harnessRegistry, mcpRegistry, harnessRuns, harnessSessions, mcpTools }; const routes = buildRoutes(services, { status: () => { const activeConfiguration = runtimeConfiguration ?? config.agentProviders.agent; @@ -243,11 +250,24 @@ export async function createApplication(dataDirectory = config.dataDirectory, op }; } -function buildRoutes({ database, workspaces, uploads, github, githubSync, revisions, drafts, reviews, memories, agentTasks, completions, texPackages, latexCompiler, skillRegistry, compliance, latexTemplates, research, claims, alignment, harnessRegistry, mcpRegistry, harnessRuns, harnessSessions, mcpTools }: Services, agentSettings: { status: () => { configured: boolean; source: "runtime" | "environment" | "none"; baseURL?: string; model?: string; wireAPI: AgentWireApi }; configure: (input: AgentSettingsInput) => void }): Route[] { +function buildRoutes({ diagrams, database, workspaces, uploads, github, githubSync, revisions, drafts, reviews, memories, agentTasks, completions, texPackages, latexCompiler, skillRegistry, compliance, latexTemplates, research, claims, alignment, harnessRegistry, mcpRegistry, harnessRuns, harnessSessions, mcpTools }: Services, agentSettings: { status: () => { configured: boolean; source: "runtime" | "environment" | "none"; baseURL?: string; model?: string; wireAPI: AgentWireApi }; configure: (input: AgentSettingsInput) => void }): Route[] { const collaborationDocuments = new Map(); const presence = new Map>(); return [ route("GET", "/api/health", async () => json({ status: "ok" })), + route("GET", "/api/diagrams/schema", () => json(diagramSchema)), + route("GET", "/api/diagrams", async () => json((await diagrams.list()).map(({scene,...record})=>({...record,title:scene?.title})))), + route("POST", "/api/diagrams", async request => {const body=await readJson<{scene:unknown;parentId?:string}>(request);return json(await diagrams.save(body.scene,body.parentId),201)}), + route("POST", "/api/diagrams/generate", async request => json(await diagrams.generate(await readJson(request)),202)), + route("GET", "/api/diagrams/:diagramId", async (_request,params)=>json(await diagrams.get(required(params,"diagramId")))), + route("GET", "/api/diagrams/:diagramId/export/:format", async (_request,params)=>{ + const record=await diagrams.get(required(params,"diagramId"));if(!record.scene)throw new ApiError(409,"diagram_not_ready","图尚未生成完成"); + const format=required(params,"format"); + if(format==='svg')return new Response(renderSvg(record.scene),{headers:{'content-type':'image/svg+xml','content-security-policy':"default-src 'none'; style-src 'unsafe-inline'",'content-disposition':'inline; filename="diagram.svg"'}}); + if(format==='drawio')return new Response(renderDrawio(record.scene),{headers:{'content-type':'application/xml','content-disposition':'attachment; filename="diagram.drawio"'}}); + if(format==='pptx')return new Response(await renderPptx(record.scene),{headers:{'content-type':'application/vnd.openxmlformats-officedocument.presentationml.presentation','content-disposition':'attachment; filename="diagram.pptx"'}}); + throw new ApiError(400,'diagram_format_invalid','支持 SVG、draw.io 和 PPTX'); + }), route("GET", "/api/harness-settings", async () => json(agentSettings.status())), route("PUT", "/api/harness-settings", async (request) => { agentSettings.configure(await readJson(request)); return json(agentSettings.status()); }), route("GET", "/api/venues", async () => json(await skillRegistry.catalog())), diff --git a/apps/server/src/diagrams/diagrams.test.ts b/apps/server/src/diagrams/diagrams.test.ts new file mode 100644 index 0000000..7652454 --- /dev/null +++ b/apps/server/src/diagrams/diagrams.test.ts @@ -0,0 +1,11 @@ +import {describe,test,expect} from 'bun:test'; +import {unzipSync,strFromU8} from 'fflate'; +import {validateScene,safeSvgInput,type DiagramScene} from './scene'; +import {renderDrawio,renderPptx,renderSvg} from './render'; +const scene:DiagramScene={version:1,title:'Controlled research flow',width:1200,height:720,nodes:[{id:'input',label:'Research question\n数据与对照',shape:'roundRect',x:80,y:280,width:250,height:120,fill:'#EAF1E3',color:'#294D3A'},{id:'output',label:'Evidence & decision',shape:'diamond',x:760,y:250,width:280,height:180,fill:'#F5EBD8',color:'#294D3A'}],edges:[{id:'edge',from:'input',to:'output',label:'controlled test'}]}; +describe('editable diagrams',()=>{ + test('native drawio vertices and connected edges preserve escaped text',()=>{const xml=renderDrawio(scene);expect(xml).toContain('vertex="1"');expect(xml).toContain('edge="1"');expect(xml).toContain('source="input" target="output"');expect(xml).toContain('Evidence & decision');expect(xml).not.toContain('{const zip=unzipSync(await renderPptx(scene));const slide=strFromU8(zip['ppt/slides/slide1.xml']!);expect(slide).toContain('Research question');expect(slide).toContain('数据与对照');expect(slide).toContain('prst="roundRect"');expect(slide).toContain('prst="diamond"');expect(slide).toContain('prst="line"');expect((slide.match(//g)||[]).length).toBe(scene.edges.length);expect(slide).toContain('p.startsWith('ppt/media/')&&!p.endsWith('/'))).toEqual([]);expect(slide).not.toContain('');}); + test('reject invalid references, duplicate IDs and unbounded geometry',()=>{expect(()=>validateScene({...scene,edges:[{id:'bad',from:'missing',to:'output',label:''}]})).toThrow();expect(()=>validateScene({...scene,nodes:[scene.nodes[0],scene.nodes[0]]})).toThrow();expect(()=>validateScene({...scene,width:Infinity})).toThrow();expect(()=>validateScene({...scene,nodes:[{...scene.nodes[0],x:5000}]})).toThrow();}); + test('untrusted SVG cannot inject active preview content or external resources',()=>{for(const bad of ['','','',''])expect(()=>safeSvgInput(bad)).toThrow();expect(safeSvgInput('')).toContain('x'});expect(xml).not.toContain('