diff --git a/app/src/api.js b/app/src/api.js index 3d75ab5..1f1a253 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -31,6 +31,18 @@ export const api = { createTask: (pid, body) => http('POST', `/api/projects/${pid}/tasks`, body), patchProject: (pid, body) => http('PATCH', `/api/projects/${pid}`, body), createProject: (body) => http('POST', '/api/projects', body), + // 附件上传走 multipart(不能用 JSON helper,content-type 由浏览器带 boundary) + uploadAttachments: async (taskId, files) => { + const fd = new FormData(); + for (const f of files) fd.append('files', f, f.name); + const res = await fetch(`/api/tasks/${taskId}/attachments`, { method: 'POST', body: fd }); + if (!res.ok) { + let msg = res.statusText; + try { msg = (await res.json()).error || msg; } catch { /* 非 JSON */ } + throw new Error(`上传附件失败 → ${res.status} ${msg}`); + } + return res.json(); + }, }; // WS 单向订阅:连上后服务端推送 events 表记录。断线自动重连(指数退避,封顶 10s)。 diff --git a/app/src/app.jsx b/app/src/app.jsx index eaeb96c..d5f997c 100644 --- a/app/src/app.jsx +++ b/app/src/app.jsx @@ -208,8 +208,13 @@ export function App() { catch (e) { toast('warn', '同步失败:' + e.message); } }; const createTask = async (payload) => { + const { files, ...body } = payload; try { - await api.createTask(currentId, payload); + const task = await api.createTask(currentId, body); + if (files && files.length) { + try { await api.uploadAttachments(task.id, files); toast('ok', `附件已上传 · ${files.length} 个`); } + catch (e) { toast('warn', e.message); } + } toast('ok', t.toastCreated); loadProject(currentId); loadGlobal(); } catch (e) { toast('warn', '创建失败:' + e.message); } diff --git a/design/ui_kits/console/TaskTree.jsx b/design/ui_kits/console/TaskTree.jsx index 2110d87..e438b75 100644 --- a/design/ui_kits/console/TaskTree.jsx +++ b/design/ui_kits/console/TaskTree.jsx @@ -205,10 +205,11 @@ function NewTaskPanel({ tasks, onCancel, onCreate, t }) { const [complexity, setComplexity] = React.useState('auto'); const [priority, setPriority] = React.useState('1'); const [parentId, setParentId] = React.useState(''); + const [files, setFiles] = React.useState([]); const submit = (e) => { e.preventDefault(); if (!title.trim()) return; - onCreate({ title: title.trim(), complexity, priority: Number(priority), parentId: parentId || null }); + onCreate({ title: title.trim(), complexity, priority: Number(priority), parentId: parentId || null, files }); }; // 扁平化任务树供父任务下拉(含子任务) const flat = []; @@ -232,6 +233,18 @@ function NewTaskPanel({ tasks, onCancel, onCreate, t }) { setFiles([...e.target.files])} + style={{ fontFamily: 'var(--mono)', fontSize: 12, color: 'var(--ink)' }} /> + + {files.length ? ( +
+ {files.map((f) => f.name).join(' · ')} +
+ ) : null} +
diff --git a/package-lock.json b/package-lock.json index 1aeb33f..808a3b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.175", + "@fastify/multipart": "^8.3.1", "@modelcontextprotocol/sdk": "^1.29.0", "better-sqlite3": "^11.3.0", "fastify": "^4.28.1", @@ -643,6 +644,28 @@ "fast-uri": "^2.0.0" } }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "license": "MIT" + }, + "node_modules/@fastify/deepmerge": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-2.0.2.tgz", + "integrity": "sha512-3wuLdX5iiiYeZWP6bQrjqhrcvBIf0NHbQH1Ur1WbHvoiuTYUEItgygea3zs8aHpiitn0lOB8gX20u1qO+FDm7Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/@fastify/error": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/@fastify/error/-/error-3.4.1.tgz", @@ -667,6 +690,36 @@ "fast-deep-equal": "^3.1.3" } }, + "node_modules/@fastify/multipart": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/@fastify/multipart/-/multipart-8.3.1.tgz", + "integrity": "sha512-pncbnG28S6MIskFSVRtzTKE9dK+GrKAJl0NbaQ/CG8ded80okWFsYKzSlP9haaLNQhNRDOoHqmGQNvgbiPVpWQ==", + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@fastify/deepmerge": "^2.0.0", + "@fastify/error": "^4.0.0", + "fastify-plugin": "^4.0.0", + "secure-json-parse": "^2.4.0", + "stream-wormhole": "^1.1.0" + } + }, + "node_modules/@fastify/multipart/node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/@hono/node-server": { "version": "1.19.14", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", @@ -1461,6 +1514,12 @@ "toad-cache": "^3.3.0" } }, + "node_modules/fastify-plugin": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-4.5.1.tgz", + "integrity": "sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==", + "license": "MIT" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -2558,6 +2617,15 @@ "node": ">= 0.8" } }, + "node_modules/stream-wormhole": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stream-wormhole/-/stream-wormhole-1.1.0.tgz", + "integrity": "sha512-gHFfL3px0Kctd6Po0M8TzEvt3De/xu6cnRrjlfYNhwbhLPLwigI2t1nc6jrzNuaYg5C4YF78PPFuQPzRiqn9ew==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", diff --git a/package.json b/package.json index e4414c5..e2e1b83 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.175", + "@fastify/multipart": "^8.3.1", "@modelcontextprotocol/sdk": "^1.29.0", "better-sqlite3": "^11.3.0", "fastify": "^4.28.1", diff --git a/src/api/server.ts b/src/api/server.ts index 261a385..16be41d 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -13,9 +13,18 @@ import { git, removeWorktree } from '../executor/worktree.js'; import { cleanupTaskRunArtifacts } from '../executor/cleanup.js'; import { resolveLogo, LOGO_MIME } from './logo.js'; import { readTranscript, TranscriptError } from '../executor/transcript.js'; -import { createReadStream } from 'node:fs'; +import { createReadStream, createWriteStream, mkdirSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join, basename } from 'node:path'; +import { pipeline } from 'node:stream/promises'; +import multipart from '@fastify/multipart'; import { createUsageFetcher, type UsageInfo } from '../daemon/usage.js'; +/** 数据根目录(与 protocol/worktree 同约定): */ +function dataDir(): string { + return process.env.MAESTRO_DATA_DIR ?? join(homedir(), '.maestro'); +} + /** Project 出参:附加 hasTodoJson(/todo/todo.json 是否存在,每次序列化时算) */ function projectOut(p: Project): Project & { hasTodoJson: boolean } { return { ...p, hasTodoJson: hasTodoJson(p.repoPath) }; @@ -39,6 +48,9 @@ export function buildServer(opts: ApiOptions): FastifyInstance { const classify = opts.classify ?? ((title, description, cwd) => classifyComplexity(title, description, { cwd })); const app = Fastify({ logger: opts.logger ?? false }); + // 附件上传:multipart/form-data(单文件 ≤25MB,单次 ≤10 个) + app.register(multipart, { limits: { fileSize: 25 * 1024 * 1024, files: 10 } }); + // Store 错误 → 400(业务校验),其余 → 500 app.setErrorHandler((err, _req, reply) => { if (err instanceof StoreError) return reply.code(400).send({ error: err.message }); @@ -218,6 +230,23 @@ export function buildServer(opts: ApiOptions): FastifyInstance { return t; }); + // 附件上传(multipart files[]):落盘 /tasks//attachments/,元数据写 tasks.attachments + app.post('/api/tasks/:id/attachments', async (req, reply) => { + const { id } = req.params as { id: string }; + if (!store.getTask(id)) return reply.code(404).send({ error: `任务不存在: ${id}` }); + const dir = join(dataDir(), 'tasks', id, 'attachments'); + mkdirSync(dir, { recursive: true }); + const saved: Array<{ name: string; type: string; path: string }> = []; + for await (const part of req.files()) { + const safe = basename(part.filename).replace(/[^\w.\-]/g, '_') || `file-${Date.now()}`; + await pipeline(part.file, createWriteStream(join(dir, safe))); + if (part.file.truncated) return reply.code(400).send({ error: `文件 ${part.filename} 超过 25MB 上限` }); + saved.push({ name: part.filename, type: part.mimetype, path: `tasks/${id}/attachments/${safe}` }); + } + if (saved.length === 0) return reply.code(400).send({ error: '未收到文件' }); + return { attachments: store.addAttachments(id, saved) }; + }); + // 部分更新任务(title/priority/complexity;complexity 重置逻辑在 Store.patchTask) app.patch('/api/tasks/:id', (req) => { const { id } = req.params as { id: string }; diff --git a/src/model/types.ts b/src/model/types.ts index 1873598..4ff077c 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -83,10 +83,18 @@ export interface Task { retryBaseline: number; // 上次手动重投时已有的失败 run 数(重置重试计数用) nextEligibleAt: string | null; // 持久化退避:早于此时间不被领取(重试退避,重启不丢);null=即刻可领 lastRunError?: string | null; // needs_attention 时:最近一次失败 run 的错误信息(供审核区展示) + attachments?: Attachment[]; // 随任务提交的图片/文件(存 /tasks//attachments/) createdAt: string; updatedAt: string; } +/** 任务附件:图片/文件随任务提交,落盘于 /tasks//attachments/ */ +export interface Attachment { + name: string; // 原始文件名 + type: string; // MIME 类型(如 image/png) + path: string; // 相对 data 根的存储路径(tasks//attachments/) +} + export type RunKind = 'planner' | 'executor' | 'reviewer' | 'security'; export type RunStatus = 'started' | 'succeeded' | 'failed' | 'cancelled'; diff --git a/src/store/db.ts b/src/store/db.ts index 4534ceb..abbb341 100644 --- a/src/store/db.ts +++ b/src/store/db.ts @@ -36,6 +36,7 @@ export function openDb(file: string): Database.Database { ensureColumn(db, 'runs', 'cost_usd', 'cost_usd REAL'); // 该 run 折算成本 USD(按模型×token) ensureColumn(db, 'projects', 'budget_usd', 'budget_usd REAL'); // 项目当期预算上限 USD(null=不限) ensureColumn(db, 'projects', 'budget_period', "budget_period TEXT NOT NULL DEFAULT 'month'"); // 预算周期 day|month + ensureColumn(db, 'tasks', 'attachments', 'attachments TEXT'); // 附件 JSON [{name,type,path}](图片/文件随任务提交) const schema = readFileSync(join(HERE, 'schema.sql'), 'utf8'); db.exec(schema); return db; diff --git a/src/store/mappers.ts b/src/store/mappers.ts index ff5ab79..144cb9a 100644 --- a/src/store/mappers.ts +++ b/src/store/mappers.ts @@ -21,6 +21,7 @@ export interface TaskRow { next_eligible_at: string | null; created_at: string; updated_at: string; source_ref: string | null; + attachments: string | null; last_run_error?: string | null; // pendingApprovals 扩展字段(subquery) } export interface ApprovalRow { @@ -81,6 +82,7 @@ export function rowToTask(r: TaskRow, approvals: ApprovalRecord[] = []): Task { assignee: r.assignee as Task['assignee'], retryBaseline: r.retry_baseline ?? 0, nextEligibleAt: r.next_eligible_at ?? null, lastRunError: r.last_run_error ?? null, + attachments: r.attachments ? (JSON.parse(r.attachments) as Task['attachments']) : undefined, createdAt: r.created_at, updatedAt: r.updated_at, }; } diff --git a/src/store/schema.sql b/src/store/schema.sql index da4c3e6..33e30a7 100644 --- a/src/store/schema.sql +++ b/src/store/schema.sql @@ -44,7 +44,8 @@ CREATE TABLE IF NOT EXISTS tasks ( next_eligible_at TEXT, -- 持久化退避:早于此时间不被领取(null=即刻可领) created_at TEXT NOT NULL, updated_at TEXT NOT NULL, - source_ref TEXT -- 旧 todo 来源标识(todo:17 / todo:17/1A),项目内唯一 + source_ref TEXT, -- 旧 todo 来源标识(todo:17 / todo:17/1A),项目内唯一 + attachments TEXT -- 附件 JSON [{name,type,path}](图片/文件随任务提交) ); CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id, status); CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id); diff --git a/src/store/store.ts b/src/store/store.ts index 38a7e57..042057f 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -4,7 +4,7 @@ import { rowToProject, rowToTask, rowToApproval, rowToRun, rowToEvent, type ProjectRow, type TaskRow, type ApprovalRow, type RunRow, type EventRow, } from './mappers.js'; -import type { Project, Task, ApprovalRecord, Run, Event, TaskResult, Autonomy, EventType } from '../model/types.js'; +import type { Project, Task, ApprovalRecord, Run, Event, TaskResult, Autonomy, EventType, Attachment } from '../model/types.js'; import { DEFAULT_MAX_DEPTH, HARD_MAX_DEPTH } from '../model/types.js'; import type { Complexity } from '../model/complexity.js'; import { rankByScore } from '../model/scoring.js'; @@ -277,7 +277,7 @@ export class Store { title: input.title, complexity: input.complexity, status, priority: input.priority ?? 1, deps: JSON.stringify(input.deps ?? []), plan: null, spec: null, operations: null, result: null, assignee: null, retry_baseline: 0, next_eligible_at: null, - created_at: now(), updated_at: now(), source_ref: null, + created_at: now(), updated_at: now(), source_ref: null, attachments: null, }; this.db.prepare( `INSERT INTO tasks (id,project_id,parent_id,depth,title,complexity,status,priority,deps,plan,spec,operations,result,assignee,retry_baseline,created_at,updated_at,source_ref) @@ -287,6 +287,17 @@ export class Store { return rowToTask(row); } + /** 追加任务附件(文件已落盘,这里只记元数据)。返回更新后的全部附件。 */ + addAttachments(taskId: string, items: Attachment[]): Attachment[] { + const t = this.getTaskRow(taskId); + if (!t) throw new StoreError(`任务不存在: ${taskId}`); + const cur: Attachment[] = t.attachments ? JSON.parse(t.attachments) as Attachment[] : []; + const next = [...cur, ...items]; + this.db.prepare(`UPDATE tasks SET attachments = ?, updated_at = ? WHERE id = ?`).run(JSON.stringify(next), now(), taskId); + this.emit(t.project_id, taskId, 'task.updated', { field: 'attachments', count: next.length }); + return next; + } + private getTaskRow(taskId: string): TaskRow | undefined { return this.db.prepare(`SELECT * FROM tasks WHERE id = ?`).get(taskId) as TaskRow | undefined; }