Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
1 change: 1 addition & 0 deletions apps/server/src/agent/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface ReviseAgentOutput {
}

export interface AgentProvider {
generateDiagram?(input: { instruction: string; svg: string; model: string; previous: unknown }, signal?: AbortSignal): Promise<unknown>;
fileGenerationConcurrency?(): number;
revise(input: ReviseAgentInput, signal?: AbortSignal): Promise<ReviseAgentOutput>;
planDraft?(input: DraftAgentInput, signal?: AbortSignal): Promise<{ outline: DraftOutlineSection[] }>;
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
30 changes: 25 additions & 5 deletions apps/server/src/app.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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<string, { document: Y.Doc; version: number }>();
const presence = new Map<string, Map<string, { clientId: string; name: string; color?: string; path: string; line?: number; updatedAt: string }>>();
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<AgentSettingsInput>(request)); return json(agentSettings.status()); }),
route("GET", "/api/venues", async () => json(await skillRegistry.catalog())),
Expand Down
11 changes: 11 additions & 0 deletions apps/server/src/diagrams/diagrams.test.ts
Original file line number Diff line number Diff line change
@@ -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 &amp; decision');expect(xml).not.toContain('<image');});
test('PPTX consists of native text shapes and line geometry with no media',async()=>{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(/<p:cxnSp>/g)||[]).length).toBe(scene.edges.length);expect(slide).toContain('<a:stCxn');expect(slide).toContain('<a:endCxn');expect(Object.keys(zip).filter(p=>p.startsWith('ppt/media/')&&!p.endsWith('/'))).toEqual([]);expect(slide).not.toContain('<p:pic>');});
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 ['<svg><script>alert(1)</script></svg>','<svg onload="x"/>','<!DOCTYPE svg><svg/>','<svg><image href="https://example.org/x"/></svg>'])expect(()=>safeSvgInput(bad)).toThrow();expect(safeSvgInput('<svg><rect width="10"/></svg>')).toContain('<rect');const xml=renderSvg({...scene,title:'<script>x</script>'});expect(xml).not.toContain('<script>');expect(xml).toContain('&lt;script&gt;');});
});
28 changes: 28 additions & 0 deletions apps/server/src/diagrams/provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import OpenAI from "openai";
import type { AgentProvider } from "../agent/provider";
import { ApiError } from "../http";
import { diagramPrompt, diagramSchema } from "./scene";

/** Schema-only model access. Generated code is never evaluated or spawned. */
export function diagramProvider(root: string): Pick<AgentProvider,"generateDiagram"> {
return { async generateDiagram(input, signal) {
if (process.env.FASTWRITE_DIAGRAM_API_KEY) {
const client = new OpenAI({ apiKey:process.env.FASTWRITE_DIAGRAM_API_KEY, ...(process.env.FASTWRITE_DIAGRAM_BASE_URL?{baseURL:process.env.FASTWRITE_DIAGRAM_BASE_URL}:{}),maxRetries:0,timeout:240000 });
const response=await client.responses.create({model:"gpt-6-astra",instructions:diagramPrompt,input:JSON.stringify(input),text:{format:{type:"json_schema",name:"diagram_scene",strict:true,schema:diagramSchema}}},signal?{signal}:{});
if(response.status!=="completed"||!response.output_text)throw new ApiError(502,"diagram_model_incomplete","模型未返回完整图结构");
return JSON.parse(response.output_text);
}
const parent=join(root,"diagram-model");await mkdir(parent,{recursive:true});const cwd=await mkdtemp(join(parent,"run-"));
const schema=join(cwd,"schema.json"),output=join(cwd,"result.json");await writeFile(schema,JSON.stringify(diagramSchema));
const command=process.env.FASTWRITE_CODEX_COMMAND||"codex";
const args=["exec","--ephemeral","--ignore-user-config","--skip-git-repo-check","--sandbox","read-only","--disable","shell_tool","--disable","apply_patch_freeform","--model","gpt-6-astra","--output-schema",schema,"--output-last-message",output,"-"];
await new Promise<void>((resolve,reject)=>{
const child=execFile(command,args,{cwd,timeout:270000,maxBuffer:2*1024*1024,windowsHide:true,...(signal?{signal}:{})},error=>error?reject(new ApiError(502,"diagram_codex_failed","Codex 绘图请求失败;请检查 CLI 登录和 gpt-6-astra 可用性")):resolve());
child.stdin?.end(diagramPrompt+"\nInput data:\n"+JSON.stringify(input));
});
return JSON.parse(await readFile(output,"utf8"));
}};
}
Loading