diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 56c36de8..6bc56e51 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -90,10 +90,10 @@ - [x] **FILE-05** — File upload from chat input via drag-and-drop or button; file is stored on disk and its metadata is written to libSQL - [x] **FILE-06** — Inline file preview in chat: images render inline, PDFs show a first-page preview, code files show a syntax-highlighted preview - [x] **FILE-07** — One-click file download from chat for any attached or generated file -- [ ] **FILE-08** — Agent-generated files (code output, specs, presentations) stored in `files/projects//generated/`, linked to the originating task and conversation in libSQL +- [x] **FILE-08** — Agent-generated files (code output, specs, presentations) stored in `files/projects//generated/`, linked to the originating task and conversation in libSQL - [x] **FILE-09** — Git integration: `files/` is a git repository; every file operation (upload, generate, replace, delete) creates a commit with a descriptive message - [x] **FILE-10** — Version history: user can view the git log for any file and see its change history -- [ ] **FILE-11** — Placeholder asset tracking: Nexus auto-maintains a `PLACEHOLDERS.md` manifest in each project directory; when a placeholder is replaced by a final asset, the manifest and DB are updated with the replacement chain +- [x] **FILE-11** — Placeholder asset tracking: Nexus auto-maintains a `PLACEHOLDERS.md` manifest in each project directory; when a placeholder is replaced by a final asset, the manifest and DB are updated with the replacement chain - [x] **FILE-12** — File scope promotion: a chat-scoped file can be promoted to a project scope; a project file can be referenced in any chat conversation - [x] **FILE-13** — Cross-device file access: files are served via the Nexus server API so a file uploaded on one device is accessible on any other device on the network @@ -175,9 +175,9 @@ The following are explicitly deferred: | FILE-05 | Phase 25 | Complete | | FILE-06 | Phase 25 | Complete | | FILE-07 | Phase 25 | Complete | -| FILE-08 | Phase 25 | Pending | +| FILE-08 | Phase 25 | Complete | | FILE-09 | Phase 25 | Complete | | FILE-10 | Phase 25 | Complete | -| FILE-11 | Phase 25 | Pending | +| FILE-11 | Phase 25 | Complete | | FILE-12 | Phase 25 | Complete | | FILE-13 | Phase 25 | Complete | diff --git a/server/src/routes/chat-files.ts b/server/src/routes/chat-files.ts index 7e72814d..d7d26daa 100644 --- a/server/src/routes/chat-files.ts +++ b/server/src/routes/chat-files.ts @@ -8,6 +8,8 @@ import { chatFileService, deriveCategory } from "../services/chat-files.js"; import { chatService } from "../services/chat.js"; import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js"; import { assertBoard, assertCompanyAccess } from "./authz.js"; +import { placeholderService } from "../services/placeholder-service.js"; +import { resolveDefaultStorageDir } from "../home-paths.js"; const fileUpload = multer({ storage: multer.memoryStorage(), @@ -31,6 +33,8 @@ export function chatFileRoutes(db: Db, storage: StorageService) { const router = Router(); const fileSvc = chatFileService(db); const convSvc = chatService(db); + const phSvc = placeholderService(); + const storageDir = resolveDefaultStorageDir(); // POST /conversations/:id/files — Upload a file to a conversation router.post("/conversations/:id/files", async (req, res) => { @@ -96,6 +100,16 @@ export function chatFileRoutes(db: Db, storage: StorageService) { projectId: parsedMeta.data.projectId ?? null, }); + // Track placeholder if agent-generated and project-scoped + if (parsedMeta.data.source === "agent_generated" && chatFile.projectId) { + const projectDir = path.join(storageDir, "..", "..", "projects", chatFile.projectId); + phSvc.addEntry(projectDir, { + fileId: chatFile.id, + filename: chatFile.originalFilename, + description: "Generated by agent", + }).catch(() => {}); + } + res.status(201).json({ file: chatFile, contentPath: `/api/files/${chatFile.id}/content`, @@ -205,6 +219,37 @@ export function chatFileRoutes(db: Db, storage: StorageService) { res.json(updated); }); + // POST /files/:fileId/replace — Replace a placeholder with a final asset + router.post("/files/:fileId/replace", async (req, res) => { + assertBoard(req); + const fileId = req.params.fileId as string; + const oldFile = await fileSvc.getById(fileId); + if (!oldFile) { res.status(404).json({ error: "File not found" }); return; } + assertCompanyAccess(req, oldFile.companyId); + + const { newFileId } = req.body ?? {}; + if (!newFileId || typeof newFileId !== "string") { + res.status(400).json({ error: "newFileId is required" }); return; + } + const newFile = await fileSvc.getById(newFileId); + if (!newFile) { res.status(404).json({ error: "Replacement file not found" }); return; } + + // Update placeholder manifest if project-scoped + if (oldFile.projectId) { + const projectDir = path.join(storageDir, "..", "..", "projects", oldFile.projectId); + await phSvc.replaceEntry(projectDir, fileId, newFileId); + } + + // Create reference linking replacement to original context + await fileSvc.createReference({ + fileId: newFileId, + conversationId: oldFile.conversationId ?? "", + messageId: oldFile.messageId ?? undefined, + }); + + res.json({ replaced: fileId, replacedBy: newFileId }); + }); + // PATCH /files/:fileId — Attach file to a message (set messageId) router.patch("/files/:fileId", async (req, res) => { assertBoard(req);