Skip to content
Closed
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
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ jobs:
run: bun install --frozen-lockfile

- name: Run release checks
run: bun run release:check
run: |
sudo apt-get update
sudo apt-get install -y latexmk texlive-latex-extra texlive-fonts-recommended texlive-science
bun run release:check

- name: Upload E2E diagnostics on failure
if: failure()
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ jobs:
fi

- name: Run release checks
run: bun run release:check
run: |
sudo apt-get update
sudo apt-get install -y latexmk texlive-latex-extra texlive-fonts-recommended texlive-science
bun run release:check

- name: Upload E2E diagnostics on failure
if: failure()
Expand Down
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# FastWrite

需要 [Bun](https://bun.sh/) 1.3+ 和 Git。
需要 [Bun](https://bun.sh/) 1.3+、Git、tar,以及运行服务器的机器上可执行的 LaTeX 工具链。推荐安装 TeX Live 或 MiKTeX,并将 `latexmk`、`pdflatex`、`bibtex` 加入 PATH。浏览器不再包含 WASM 编译器。

## 开发运行

Expand All @@ -11,7 +11,9 @@ bun run dev

打开 <http://localhost:3002>(Vite 前端代理 API)。

浏览器 WASM 编译器会在编译时自动识别缺少的 TeX 包,由 FastWrite Server 从兼容的 TeX Live/CTAN 源按需下载;下载结果会在服务端和浏览器中缓存,不需要预先同步 `local-packages`。
PDF 由服务器本地 LaTeX 编译器生成。Ubuntu/Debian 可先安装 `sudo apt-get install latexmk texlive-latex-extra texlive-fonts-recommended texlive-science`;Windows 可安装 MiKTeX 和 Perl(latexmk 需要),macOS 可安装 MacTeX。缺少模板依赖时,按编译日志补装对应 TeX 包。无 LaTeX 时仍可编辑和导出源码,但不能生成 PDF 或执行要求当前版本编译成功的定向复审。

Review 默认附带当前成功编译版本的分页文本(最多 20 页、合计 200,000 字符);源码审稿会明确区分。分页文本不包含图像视觉或版式信息。

选定 venue 的 LaTeX 模板首次成功获取后会缓存到 `FASTWRITE_DATA_DIR/templates/`(未配置时为默认数据目录);后续初始化直接使用本地缓存,不会重复下载。

Expand Down Expand Up @@ -45,7 +47,11 @@ bun run package:app

`.github/workflows/ci.yml` 会在 push 和 pull request 时自动运行类型检查、单元测试、构建和浏览器 E2E。推送与根 `package.json` 版本一致的标签(例如 `v0.1.0`)会触发 `.github/workflows/release.yml`,在完整测试通过后创建 GitHub Release,并发布 Linux x64、Windows x64、macOS Intel 和 macOS Apple Silicon 安装包。macOS 包当前未进行 Apple Developer 签名或 notarization,首次运行可能出现 Gatekeeper 提示。

在 `.env` 设置 `FASTWRITE_HARNESS=codex` 或 `claude`,并配置 `FASTWRITE_HARNESS_API_KEY`、可选的 `FASTWRITE_HARNESS_BASE_URL`、`FASTWRITE_HARNESS_MODEL`、`FASTWRITE_HARNESS_WIRE_API` 和 `FASTWRITE_HARNESS_TIMEOUT_MS`,即可为全部 AI 工作流配置统一 Harness。Project Settings 也可运行时配置这些 Harness 参数;API key 只保存在内存中且不会回传。Agent 规划、普通 AI 操作以及 `/draft`、`/continue`、`/revise` 的完整文件生成默认超时均为 300 秒,可用 `FASTWRITE_HARNESS_TIMEOUT_MS` 覆盖(最大 600 秒)。导入私有 GitHub Repository 时设置 `FASTWRITE_GITHUB_TOKEN`。
在 `.env` 设置 `FASTWRITE_HARNESS_API_KEY`、`FASTWRITE_HARNESS_BASE_URL`、`FASTWRITE_HARNESS_MODEL` 和 `FASTWRITE_HARNESS_WIRE_API=chat`(也支持 `responses`),可直接使用兼容 API,例如 Qwen 的兼容接口。Project Settings 的运行时参数优先于环境配置,API key 只保存在内存中且不会回传;重启后恢复环境配置。配置状态表示已选择连接方式,不代表模型调用已验证成功。

未提供 API key 时,使用 `FASTWRITE_HARNESS=codex` 或 `claude` 对应的已安装且独立认证的 CLI。CLI 与直接 API 是两条明确的执行路径。Agent 规划和文件生成默认超时 300 秒,可用 `FASTWRITE_HARNESS_TIMEOUT_MS` 覆盖(最大 600 秒)。导入私有 GitHub Repository 时设置 `FASTWRITE_GITHUB_TOKEN`。

Qwen3 系列的 Chat Completions 调用使用 `enable_thinking=false`,避免思考过程耗尽交互式写作期限;其他模型不发送该供应商参数。

验证 `.env` 中的真实 LLM 配置:

Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/agent/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ function responseShape(payloads: unknown[], contentType: string, byteLength: num
return [`content-type=${contentType || "missing"}`, `bytes=${byteLength}`, eventTypes.length ? `events=${eventTypes.join(",")}` : "events=none", `parsed=${payloads.length}`, `parse-failures=${parseFailures}`, `done=${sawDone ? "yes" : "no"}`, statuses.length ? `status=${statuses.join(",")}` : "status=missing", outputTypes.length ? `output=${outputTypes.join(",")}` : "output=none"].join("; ");
}

async function compatibleResponseText(response: Response): Promise<{ content: string; shape: string }> {
export async function compatibleResponseText(response: Response): Promise<{ content: string; shape: string }> {
const body = await response.text();
const contentType = response.headers.get("content-type") ?? "";
const looksLikeSSE = contentType.toLowerCase().includes("text/event-stream") || /(?:^|\n)\s*(?:event|data):/.test(body);
Expand Down
5 changes: 3 additions & 2 deletions apps/server/src/agent/review-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { isAgentCancellation, runAgentOperation } from "./agent-operation";
import { writingGuardMany } from "../writing/writing-guard";
import { deriveArgumentGraph } from "../claims/argument-graph";
import { buildAdversarialMemo } from "../claims/adversarial-memo";
import { validateOperationOutput } from "./structured-provider";

function now() { return new Date().toISOString(); }
function textPaths(nodes: WorkspaceTreeNode[]): string[] { return nodes.flatMap((node) => node.type === "directory" ? textPaths(node.children) : node.kind === "text" ? [node.path] : []); }
Expand Down Expand Up @@ -75,8 +76,8 @@ export class ReviewService {
const result = await runAgentOperation<ReviewAgentOutput>(
async (signal) => {
const input = { documents: documents.map(({ path, content }) => ({ path, content })), outline: flattenOutline(outline).map(({ path, title, line }) => ({ path, title, line })), skill: project.skill, skillInstructions: `${workflowInstructions}\n\n${skill.instructions}`, venueInstructions: skill.venueInstructions, ...(boundedPageText.length ? { pdfPageText: boundedPageText } : {}) };
if (!this.agent!.reviewPass) { providerPasses.domain = { status: "completed" }; providerPasses.venue = { status: "completed" }; return this.agent!.review!(input, signal); }
const results = await Promise.allSettled([this.agent!.reviewPass({ ...input, pass: "domain" }, signal), this.agent!.reviewPass({ ...input, pass: "venue" }, signal)]);
if (!this.agent!.reviewPass) { const output = validateOperationOutput("review", await this.agent!.review!(input, signal)); providerPasses.domain = { status: "completed" }; providerPasses.venue = { status: "completed" }; return output; }
const results = await Promise.allSettled((["domain", "venue"] as const).map(async (pass) => validateOperationOutput("reviewPass", await this.agent!.reviewPass!({ ...input, pass }, signal)) as ReviewAgentOutput));
providerPasses.domain = results[0]?.status === "fulfilled" ? { status: "completed" } : { status: "failed", error: results[0]?.reason instanceof Error ? results[0].reason.message : "Domain pass failed" };
providerPasses.venue = results[1]?.status === "fulfilled" ? { status: "completed" } : { status: "failed", error: results[1]?.reason instanceof Error ? results[1].reason.message : "Venue pass failed" };
const successful = results.filter((item): item is PromiseFulfilledResult<ReviewAgentOutput> => item.status === "fulfilled").map((item) => item.value);
Expand Down
58 changes: 58 additions & 0 deletions apps/server/src/agent/structured-provider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { afterEach, expect, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createApplication } from "../app";
import { operationSchemas, operationPrompt, validateOperationOutput } from "./structured-provider";

const cleanups: Array<() => Promise<unknown>> = [];
afterEach(async () => { for (const cleanup of cleanups.splice(0)) await cleanup(); });
const validReview = { overallAssessment: "Bounded review", recommendation: "borderline", strengths: [], weaknesses: [], nextSteps: [], issues: [] };

test("runtime API settings drive real HTTP requests and malformed review never persists a clean report", async () => {
const calls: any[] = [];
let malformed = true;
const upstream = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(request) {
const body = await request.json() as any;
calls.push({ path: new URL(request.url).pathname, authorization: request.headers.get("authorization"), body });
const prompt = body.messages[0].content as string;
const value = prompt.includes("operation 'complete'") ? { suggestion: "Actual HTTP fixture completion." } : malformed ? { result: {} } : validReview;
return Response.json({ choices: [{ message: { content: JSON.stringify(value) } }] });
} });
cleanups.push(async () => upstream.stop(true));
const directory = await mkdtemp(join(tmpdir(), "fastwrite-api-chain-"));
cleanups.push(() => rm(directory, { recursive: true, force: true }));
const app = await createApplication(directory);
const request = (path: string, method = "GET", body?: unknown) => app(new Request(`http://fastwrite.test${path}`, { method, ...(body === undefined ? {} : { headers: { "content-type": "application/json" }, body: JSON.stringify(body) }) }));
const configured = await request("/api/harness-settings", "PUT", { apiKey: "fixture-key", baseURL: `http://127.0.0.1:${upstream.port}/v1`, model: "qwen3.8-27b", wireAPI: "chat" });
expect(await configured.json()).toMatchObject({ configured: true, transport: "api", source: "runtime", model: "qwen3.8-27b" });
const project = await (await request("/api/projects", "POST", { name: "API wiring" })).json() as any;
const root = `/api/projects/${project.id}`;
const completion = await request(root + "/completions", "POST", { path: "main.tex", cursor: 0, fileVersion: 1, kind: "auto" });
expect(await completion.json()).toMatchObject({ suggestion: "Actual HTTP fixture completion." });
expect(calls[0]).toMatchObject({ path: "/v1/chat/completions", authorization: "Bearer fixture-key", body: { model: "qwen3.8-27b", enable_thinking: false } });
const failed = await request(root + "/reviews", "POST", { sourceOnly: true });
expect(failed.status).toBe(502);
expect(await (await request(root + "/reviews")).json()).toEqual([]);
malformed = false;
const retried = await request(root + "/reviews", "POST", { sourceOnly: true });
expect(retried.status).toBe(201);
expect(await retried.json()).toMatchObject({ report: { recommendation: "borderline", inputType: "source" } });
const stale = await request(root + "/reviews", "POST", { pageText: ["Old PDF"], projectVersion: project.version - 1 });
expect(stale.status).toBe(409);
expect(await stale.json()).toMatchObject({ error: { code: "review_pdf_stale" } });
await request(root + "/compile-results", "POST", { projectVersion: project.version, status: "success", summary: "Fixture compilation record" });
const pdfReview = await request(root + "/reviews", "POST", { sourceOnly: false, pageText: ["PDF_PAGE_SENTINEL"], projectVersion: project.version });
expect(pdfReview.status).toBe(201);
expect(await pdfReview.json()).toMatchObject({ report: { inputType: "pdf-preview" } });
expect(calls.at(-1).body.messages[0].content).toContain('"pdfPageText":["PDF_PAGE_SENTINEL"]');
});

test("all writing operations have explicit schemas and reject generic result wrappers", () => {
for (const method of Object.keys(operationSchemas)) {
expect(() => validateOperationOutput(method, { result: {} })).toThrow();
expect(operationPrompt(method, { outline: [], mainDocument: "main.tex" })).not.toContain('{"result":object}');
}
expect(() => validateOperationOutput("review", { ...validReview, recommendation: "undefined" })).toThrow();
expect(() => validateOperationOutput("rereviewIssues", { assessments: [{ issueId: "i", resolved: "yes", assessment: "ok" }], regressions: [] })).toThrow();
});
Loading
Loading