feat(25-07): add placeholder and agent-generated file routes

- Import placeholderService and resolveDefaultStorageDir in chat-files routes
- Track agent_generated project-scoped uploads in PLACEHOLDERS.md manifest
- Add POST /files/:fileId/replace endpoint for placeholder replacement
- Replace endpoint updates manifest and creates cross-reference chain
- Mark FILE-08 and FILE-11 Complete in REQUIREMENTS.md
This commit is contained in:
Nexus Dev 2026-04-02 00:12:12 +00:00
parent 859ba1707b
commit 7ab49d9824
2 changed files with 49 additions and 4 deletions

View file

@ -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/<slug>/generated/`, linked to the originating task and conversation in libSQL
- [x] **FILE-08** — Agent-generated files (code output, specs, presentations) stored in `files/projects/<slug>/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 |

View file

@ -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);