studiograph 1.3.48-next.77 → 1.3.48-next.78
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/prompts/entity-types-section.d.ts +8 -0
- package/dist/agent/prompts/entity-types-section.js +27 -2
- package/dist/agent/prompts/entity-types-section.js.map +1 -1
- package/dist/agent/tools/folder-tools.js +18 -7
- package/dist/agent/tools/folder-tools.js.map +1 -1
- package/dist/agent/tools/graph-tools.js +65 -9
- package/dist/agent/tools/graph-tools.js.map +1 -1
- package/dist/cli/commands/index.js +1 -1
- package/dist/cli/commands/index.js.map +1 -1
- package/dist/core/entity-types-catalog.js +2 -0
- package/dist/core/entity-types-catalog.js.map +1 -1
- package/dist/core/graph.d.ts +106 -2
- package/dist/core/graph.js +281 -10
- package/dist/core/graph.js.map +1 -1
- package/dist/core/validation.d.ts +85 -4
- package/dist/core/validation.js +34 -3
- package/dist/core/validation.js.map +1 -1
- package/dist/core/workspace-manager.js +3 -1
- package/dist/core/workspace-manager.js.map +1 -1
- package/dist/mcp/tools.js +6 -1
- package/dist/mcp/tools.js.map +1 -1
- package/dist/server/routes/asset-api.js +29 -7
- package/dist/server/routes/asset-api.js.map +1 -1
- package/dist/server/routes/graph-api.js +256 -45
- package/dist/server/routes/graph-api.js.map +1 -1
- package/dist/services/asset-upload-service.d.ts +53 -0
- package/dist/services/asset-upload-service.js +199 -0
- package/dist/services/asset-upload-service.js.map +1 -0
- package/dist/services/assets/base.d.ts +15 -0
- package/dist/services/assets/base.js +63 -0
- package/dist/services/assets/base.js.map +1 -1
- package/dist/services/assets/index.d.ts +17 -0
- package/dist/services/assets/index.js +17 -0
- package/dist/services/assets/index.js.map +1 -1
- package/dist/services/import-service.d.ts +20 -1
- package/dist/services/import-service.js +190 -35
- package/dist/services/import-service.js.map +1 -1
- package/dist/services/vector-service.d.ts +20 -2
- package/dist/services/vector-service.js +46 -5
- package/dist/services/vector-service.js.map +1 -1
- package/dist/web/_app/immutable/assets/0.C-U08O83.css +2 -0
- package/dist/web/_app/immutable/assets/2.D4UyD6ef.css +1 -0
- package/dist/web/_app/immutable/assets/{4.DMWmOCtr.css → 4.CMuW9ER9.css} +1 -1
- package/dist/web/_app/immutable/chunks/{B2MhjR4K.js → B2dQ_HtA.js} +1 -1
- package/dist/web/_app/immutable/chunks/{CbChB7Sk.js → BVG-59G_.js} +1 -1
- package/dist/web/_app/immutable/chunks/{CcKbicMh.js → BYMboxS1.js} +1 -1
- package/dist/web/_app/immutable/chunks/BcnRmtrP2.js +1 -0
- package/dist/web/_app/immutable/chunks/Bi49oCkW.js +1 -0
- package/dist/web/_app/immutable/chunks/{Bdr7RoI1.js → BkG8IWXL.js} +1 -1
- package/dist/web/_app/immutable/chunks/BljXMCRN2.js +1 -0
- package/dist/web/_app/immutable/chunks/C1eadUOT.js +1 -0
- package/dist/web/_app/immutable/chunks/{DgmiIA_n.js → C7ZXf1Ei.js} +1 -1
- package/dist/web/_app/immutable/chunks/{he436Z7O.js → C9fPdp9e.js} +1 -1
- package/dist/web/_app/immutable/chunks/{DYw6aCAA2.js → CABEPnKi2.js} +1 -1
- package/dist/web/_app/immutable/chunks/{C4o1fi_w.js → CFFhRHgg.js} +1 -1
- package/dist/web/_app/immutable/chunks/{DB38-mVP.js → CMv-UMQD.js} +1 -1
- package/dist/web/_app/immutable/chunks/{C-MyzyGb.js → CO4LsWNt.js} +1 -1
- package/dist/web/_app/immutable/chunks/{CQPEqD942.js → CXGYcrGq2.js} +1 -1
- package/dist/web/_app/immutable/chunks/ClxmxcVF.js +1 -0
- package/dist/web/_app/immutable/chunks/Co7pw0iy.js +1 -0
- package/dist/web/_app/immutable/chunks/Cu_13EXg.js +8 -0
- package/dist/web/_app/immutable/chunks/{DxmEO50C.js → Cy1yuSCN.js} +1 -1
- package/dist/web/_app/immutable/chunks/{_VOdw24P.js → CzT0T6FJ.js} +1 -1
- package/dist/web/_app/immutable/chunks/{Bl_v2-kF.js → D1AVtJaJ.js} +1 -1
- package/dist/web/_app/immutable/chunks/{CCXj03fW.js → D6ObqpSX.js} +1 -1
- package/dist/web/_app/immutable/chunks/DBMIhZt72.js +1 -0
- package/dist/web/_app/immutable/chunks/{CUVp7kiS.js → DFE7ldAP.js} +1 -1
- package/dist/web/_app/immutable/chunks/{DAQ0s979.js → DKDDh_Qn.js} +1 -1
- package/dist/web/_app/immutable/chunks/DNneJjJN.js +1 -0
- package/dist/web/_app/immutable/chunks/{r4OgPVQH.js → DOastlFY.js} +1 -1
- package/dist/web/_app/immutable/chunks/DPHJu-Bj.js +5 -0
- package/dist/web/_app/immutable/chunks/{D00taIEV.js → DQKT4stP.js} +1 -1
- package/dist/web/_app/immutable/chunks/DQe4a6BS.js +1 -0
- package/dist/web/_app/immutable/chunks/{Ce2P7b-X.js → DQpUZZbo.js} +1 -1
- package/dist/web/_app/immutable/chunks/DVxZLm0W.js +2 -0
- package/dist/web/_app/immutable/chunks/{BWPBe1t_.js → DYbq0sax.js} +1 -1
- package/dist/web/_app/immutable/chunks/{CjDvXCxV.js → Ddr2JLfY.js} +1 -1
- package/dist/web/_app/immutable/chunks/DeQkDfnZ.js +1 -0
- package/dist/web/_app/immutable/chunks/Df-4W2cE2.js +2 -0
- package/dist/web/_app/immutable/chunks/{e9oCENPh.js → Dfu55H0O.js} +1 -1
- package/dist/web/_app/immutable/chunks/{7tAv3Zey.js → DhERF52D.js} +1 -1
- package/dist/web/_app/immutable/chunks/{C2UI_KQ1.js → Dt_-Caf8.js} +1 -1
- package/dist/web/_app/immutable/chunks/DxS997Bi.js +1 -0
- package/dist/web/_app/immutable/chunks/{ChPgixWE.js → P-vv4BQi2.js} +1 -1
- package/dist/web/_app/immutable/chunks/{Fgrpvl6l.js → UqWZFiDw.js} +1 -1
- package/dist/web/_app/immutable/chunks/{DPXpm86N.js → ZhCPwTv_.js} +1 -1
- package/dist/web/_app/immutable/chunks/anxNIaXZ.js +1 -0
- package/dist/web/_app/immutable/chunks/{DuaBraCb.js → dTznEU-N.js} +4 -4
- package/dist/web/_app/immutable/chunks/{wY5DYt0W.js → iqOW8ttz.js} +1 -1
- package/dist/web/_app/immutable/chunks/{DQ86iS7p.js → j7kuJOP8.js} +1 -1
- package/dist/web/_app/immutable/chunks/{PySYLKbq.js → mbVGz96e.js} +1 -1
- package/dist/web/_app/immutable/chunks/rmFsC7j32.js +1 -0
- package/dist/web/_app/immutable/chunks/{CLBsX7ax.js → sdhQg9Jq.js} +1 -1
- package/dist/web/_app/immutable/entry/{app.DN1UaKFY.js → app.BUM2MbTk.js} +2 -2
- package/dist/web/_app/immutable/entry/start.BObvxb93.js +1 -0
- package/dist/web/_app/immutable/nodes/{0.B4kzM-PX.js → 0.BMXqKYhl.js} +2 -2
- package/dist/web/_app/immutable/nodes/{1.BFSzcxgp.js → 1.QaGjZyIl.js} +1 -1
- package/dist/web/_app/immutable/nodes/10.F1nNX_z9.js +1 -0
- package/dist/web/_app/immutable/nodes/{11.DL-1uGFV.js → 11.BfZsU6Vh.js} +1 -1
- package/dist/web/_app/immutable/nodes/{12.BaxGfUVt.js → 12.C5hRguPI.js} +1 -1
- package/dist/web/_app/immutable/nodes/{13.DnuNEtfe.js → 13.BqOI1wyC.js} +1 -1
- package/dist/web/_app/immutable/nodes/2.CCWtRh8O.js +130 -0
- package/dist/web/_app/immutable/nodes/3.DDPCGvvJ.js +2 -0
- package/dist/web/_app/immutable/nodes/4.BhR6-Pss.js +11 -0
- package/dist/web/_app/immutable/nodes/5.DkAirE7a.js +1 -0
- package/dist/web/_app/immutable/nodes/{6.CX3g0XIN.js → 6.CF014BWC.js} +1 -1
- package/dist/web/_app/immutable/nodes/{7.Crv4XWO2.js → 7.B6NRypBk.js} +1 -1
- package/dist/web/_app/immutable/nodes/{8.2aJyTu75.js → 8.B4moM7KQ.js} +1 -1
- package/dist/web/_app/immutable/nodes/{9.lbjYEmw7.js → 9.DVcGpCuy.js} +1 -1
- package/dist/web/_app/version.json +1 -1
- package/dist/web/index.html +16 -16
- package/package.json +1 -1
- package/dist/web/_app/immutable/assets/0.DNW-Nd5m.css +0 -2
- package/dist/web/_app/immutable/assets/2.R677OFd1.css +0 -1
- package/dist/web/_app/immutable/chunks/-jdP5JqO.js +0 -5
- package/dist/web/_app/immutable/chunks/9lbhMmvd.js +0 -1
- package/dist/web/_app/immutable/chunks/B-z4a3eR.js +0 -1
- package/dist/web/_app/immutable/chunks/BDCOho_G.js +0 -1
- package/dist/web/_app/immutable/chunks/BDgHxF8Y.js +0 -8
- package/dist/web/_app/immutable/chunks/BynMq22o.js +0 -1
- package/dist/web/_app/immutable/chunks/C7o6G8Ak2.js +0 -1
- package/dist/web/_app/immutable/chunks/CDDIrInV2.js +0 -2
- package/dist/web/_app/immutable/chunks/CHRfvdkq2.js +0 -1
- package/dist/web/_app/immutable/chunks/CIwAM5f0.js +0 -2
- package/dist/web/_app/immutable/chunks/CSWuNPeq.js +0 -1
- package/dist/web/_app/immutable/chunks/CTFOXiYo.js +0 -1
- package/dist/web/_app/immutable/chunks/CXYTfe_P2.js +0 -1
- package/dist/web/_app/immutable/chunks/CbCiZ2mS2.js +0 -1
- package/dist/web/_app/immutable/chunks/Cjw6nw-K.js +0 -1
- package/dist/web/_app/immutable/chunks/DDzRa0WU.js +0 -1
- package/dist/web/_app/immutable/chunks/DhikmMsg2.js +0 -1
- package/dist/web/_app/immutable/entry/start.B318VXx4.js +0 -1
- package/dist/web/_app/immutable/nodes/10.Bj0EQP8T.js +0 -1
- package/dist/web/_app/immutable/nodes/2.Gew8dws6.js +0 -130
- package/dist/web/_app/immutable/nodes/3.CWR6Ua2L.js +0 -2
- package/dist/web/_app/immutable/nodes/4.ArX8Iovt.js +0 -11
- package/dist/web/_app/immutable/nodes/5.Cg8S9qNq.js +0 -1
- /package/dist/web/_app/immutable/chunks/{D5VdX2NC.js → CQwUl_3l.js} +0 -0
- /package/dist/web/_app/immutable/chunks/{EdMwt2az.js → CvZmtMIf.js} +0 -0
- /package/dist/web/_app/immutable/chunks/{CUyyAHze.js → Dn6yJ9ZG.js} +0 -0
- /package/dist/web/_app/immutable/chunks/{DXeZwJCC2.js → rsjIo1y12.js} +0 -0
|
@@ -203,11 +203,19 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
203
203
|
const memberCount = authService.getFolderMembers(r.name).length;
|
|
204
204
|
const callerRole = user ? authService.getFolderRole(user.id, r.name) : null;
|
|
205
205
|
let entityTypes = [];
|
|
206
|
+
// `folders` carries the per-folder display_name override (when one
|
|
207
|
+
// exists in the folder's .studiograph-folder.json sidecar); the client
|
|
208
|
+
// falls back to toDisplayName(slug) when display_name is absent.
|
|
206
209
|
let folders = [];
|
|
207
210
|
try {
|
|
208
211
|
const graph = workspaceManager.getGraph(r.name);
|
|
209
212
|
entityTypes = graph.listEntityTypes();
|
|
210
|
-
folders = graph.listFolders().map(f =>
|
|
213
|
+
folders = graph.listFolders().map(f => {
|
|
214
|
+
const entry = { path: f.path };
|
|
215
|
+
if (f.display_name)
|
|
216
|
+
entry.display_name = f.display_name;
|
|
217
|
+
return entry;
|
|
218
|
+
});
|
|
211
219
|
}
|
|
212
220
|
catch { /* repo not mounted */ }
|
|
213
221
|
return {
|
|
@@ -902,6 +910,48 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
902
910
|
return reply.status(400).send({ error: err.message });
|
|
903
911
|
}
|
|
904
912
|
});
|
|
913
|
+
// POST /api/repos/:repo/entities/bulk-delete — delete many entities in
|
|
914
|
+
// one git transaction + one WS broadcast. Replaces N parallel
|
|
915
|
+
// DELETE /entities/:type/:id calls, which on a 500-entry bulk delete
|
|
916
|
+
// produced 500 git commits + 500 entity_change broadcasts and froze
|
|
917
|
+
// the UI for minutes.
|
|
918
|
+
fastify.post('/api/repos/:repo/entities/bulk-delete', async (req, reply) => {
|
|
919
|
+
const { repo } = req.params;
|
|
920
|
+
if (!hasRepoAccess(req, repo, workspaceManager, authService)) {
|
|
921
|
+
return reply.status(404).send({ error: `Folder “${repo}” not found` });
|
|
922
|
+
}
|
|
923
|
+
const body = req.body;
|
|
924
|
+
if (!body?.entities || !Array.isArray(body.entities)) {
|
|
925
|
+
return reply.status(400).send({ error: 'Expected { entities: [{ entityType, entityId }] }' });
|
|
926
|
+
}
|
|
927
|
+
let graph;
|
|
928
|
+
try {
|
|
929
|
+
graph = workspaceManager.getGraph(repo);
|
|
930
|
+
}
|
|
931
|
+
catch (err) {
|
|
932
|
+
return reply.status(404).send({ error: err.message });
|
|
933
|
+
}
|
|
934
|
+
try {
|
|
935
|
+
const result = await graph.bulkDelete(body.entities.map(e => ({ entityType: e.entityType, entityId: e.entityId })), {
|
|
936
|
+
user: reqGitUser(req),
|
|
937
|
+
commitMessage: `Bulk delete ${body.entities.length} ${body.entities.length === 1 ? 'entry' : 'entries'} from web UI`,
|
|
938
|
+
});
|
|
939
|
+
if (result.deleted.length > 0) {
|
|
940
|
+
wsHub?.broadcast({
|
|
941
|
+
type: 'repo_sync', repo,
|
|
942
|
+
actor: req.user?.displayName ?? 'API',
|
|
943
|
+
source: 'api', timestamp: new Date().toISOString(),
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
return reply.send({
|
|
947
|
+
deleted: result.deleted.length,
|
|
948
|
+
errors: result.errors,
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
catch (err) {
|
|
952
|
+
return reply.status(400).send({ error: err.message });
|
|
953
|
+
}
|
|
954
|
+
});
|
|
905
955
|
// POST /api/repos/:repo/entities — create a new entity
|
|
906
956
|
fastify.post('/api/repos/:repo/entities', async (req, reply) => {
|
|
907
957
|
const { repo } = req.params;
|
|
@@ -982,9 +1032,11 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
982
1032
|
}
|
|
983
1033
|
});
|
|
984
1034
|
// POST /api/repos/:repo/folders — create a folder (with .gitkeep). Auto-creates parents.
|
|
1035
|
+
// Optional display_name is written to the folder's .studiograph-folder.json
|
|
1036
|
+
// sidecar when set; otherwise the client falls back to toDisplayName(slug).
|
|
985
1037
|
fastify.post('/api/repos/:repo/folders', async (req, reply) => {
|
|
986
1038
|
const { repo } = req.params;
|
|
987
|
-
const { path } = req.body ?? {};
|
|
1039
|
+
const { path, display_name } = req.body ?? {};
|
|
988
1040
|
if (!path || typeof path !== 'string') {
|
|
989
1041
|
return reply.status(400).send({ error: 'path is required' });
|
|
990
1042
|
}
|
|
@@ -999,17 +1051,25 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
999
1051
|
return reply.status(404).send({ error: err.message });
|
|
1000
1052
|
}
|
|
1001
1053
|
try {
|
|
1002
|
-
await graph.createFolder(path, undefined, reqGitUser(req)
|
|
1054
|
+
await graph.createFolder(path, undefined, reqGitUser(req), {
|
|
1055
|
+
displayName: display_name,
|
|
1056
|
+
});
|
|
1003
1057
|
return reply.status(201).send({ success: true, path });
|
|
1004
1058
|
}
|
|
1005
1059
|
catch (err) {
|
|
1006
1060
|
return reply.status(400).send({ error: err.message });
|
|
1007
1061
|
}
|
|
1008
1062
|
});
|
|
1009
|
-
// PATCH /api/repos/:repo/folders — rename / move a folder within the repo
|
|
1063
|
+
// PATCH /api/repos/:repo/folders — rename / move a folder within the repo,
|
|
1064
|
+
// optionally updating its display_name in the same commit. Three shapes:
|
|
1065
|
+
// { from, to } — slug-only rename / move
|
|
1066
|
+
// { from, to, display_name } — slug change + display update
|
|
1067
|
+
// { from, to: from, display_name } — display-only update (no FS move)
|
|
1068
|
+
// `display_name: ""` (or null) clears the override and reverts to the slug
|
|
1069
|
+
// fallback.
|
|
1010
1070
|
fastify.patch('/api/repos/:repo/folders', async (req, reply) => {
|
|
1011
1071
|
const { repo } = req.params;
|
|
1012
|
-
const { from, to } = req.body ?? {};
|
|
1072
|
+
const { from, to, display_name } = req.body ?? {};
|
|
1013
1073
|
if (!from || !to) {
|
|
1014
1074
|
return reply.status(400).send({ error: 'from and to are required' });
|
|
1015
1075
|
}
|
|
@@ -1026,7 +1086,9 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
1026
1086
|
try {
|
|
1027
1087
|
if (sessionManager)
|
|
1028
1088
|
await sessionManager.flush(repo);
|
|
1029
|
-
await graph.renameFolder(from, to, undefined, reqGitUser(req)
|
|
1089
|
+
await graph.renameFolder(from, to, undefined, reqGitUser(req), {
|
|
1090
|
+
displayName: display_name === undefined ? undefined : display_name,
|
|
1091
|
+
});
|
|
1030
1092
|
return reply.send({ success: true, from, to });
|
|
1031
1093
|
}
|
|
1032
1094
|
catch (err) {
|
|
@@ -1094,11 +1156,15 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
1094
1156
|
// POST /api/repos/:repo/import — import files into a folder.
|
|
1095
1157
|
//
|
|
1096
1158
|
// Routing by extension:
|
|
1097
|
-
// .md / .txt / .csv
|
|
1098
|
-
// preserves the source faithfully. Markdown frontmatter
|
|
1099
|
-
// entity_id) is honored when present; otherwise
|
|
1100
|
-
//
|
|
1101
|
-
//
|
|
1159
|
+
// .md / .txt / .csv → direct write via importFile() — no LLM
|
|
1160
|
+
// round-trip, preserves the source faithfully. Markdown frontmatter
|
|
1161
|
+
// (entity_type, entity_id) is honored when present; otherwise
|
|
1162
|
+
// sensible defaults.
|
|
1163
|
+
// .pdf + opaque binaries → asset wrapper via uploadAsset() —
|
|
1164
|
+
// creates an `asset` entity, stores the binary in R2 or the local
|
|
1165
|
+
// volume, writes a PDF text sidecar for vector indexing.
|
|
1166
|
+
// .docx → text extraction + agent capture
|
|
1167
|
+
// pipeline (until the mammoth → markdown workstream lands).
|
|
1102
1168
|
fastify.post('/api/repos/:repo/import', async (req, reply) => {
|
|
1103
1169
|
const { repo } = req.params;
|
|
1104
1170
|
if (!hasRepoAccess(req, repo, workspaceManager, authService)) {
|
|
@@ -1108,10 +1174,18 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
1108
1174
|
if (!body?.files || !Array.isArray(body.files)) {
|
|
1109
1175
|
return reply.status(400).send({ error: 'Expected { files: [{ filename, content }] }' });
|
|
1110
1176
|
}
|
|
1177
|
+
// Reject paths with traversal segments; importFile slugifies the rest.
|
|
1178
|
+
// Empty / undefined = repo root (existing behavior).
|
|
1179
|
+
const targetFolderPath = (body.targetFolderPath ?? '').trim();
|
|
1180
|
+
if (targetFolderPath.split('/').some(seg => seg === '..' || seg === '.')) {
|
|
1181
|
+
return reply.status(400).send({ error: 'Invalid targetFolderPath: contains traversal segments' });
|
|
1182
|
+
}
|
|
1111
1183
|
const captureRepo = body.agentDecides ? undefined : repo;
|
|
1112
1184
|
const gitUser = reqGitUser(req);
|
|
1113
1185
|
try {
|
|
1114
1186
|
const { extractText, isSupportedFile, importFile } = await import('../../services/import-service.js');
|
|
1187
|
+
const { isAssetUploadExtension } = await import('../../services/assets/base.js');
|
|
1188
|
+
const { uploadAsset } = await import('../../services/asset-upload-service.js');
|
|
1115
1189
|
const { extname } = await import('path');
|
|
1116
1190
|
const results = [];
|
|
1117
1191
|
// Direct-write extensions never need an LLM. Verbatim preservation —
|
|
@@ -1128,19 +1202,73 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
1128
1202
|
}
|
|
1129
1203
|
const result = await importFile(graph, file.filename, file.content, {
|
|
1130
1204
|
user: gitUser,
|
|
1131
|
-
//
|
|
1132
|
-
//
|
|
1133
|
-
|
|
1205
|
+
// Caller-chosen destination subfolder; importFile combines it
|
|
1206
|
+
// with each file's own relative directory.
|
|
1207
|
+
folderPath: targetFolderPath || undefined,
|
|
1208
|
+
// Defer commits — we batch them into one transaction at the
|
|
1209
|
+
// end of the request so a 500-file import is one git commit
|
|
1210
|
+
// instead of 500. Per-file commits dominated wall time
|
|
1211
|
+
// (~50-200ms each) before this; batching collapses that to
|
|
1212
|
+
// one commit for the whole batch.
|
|
1213
|
+
skipCommit: true,
|
|
1134
1214
|
});
|
|
1135
|
-
if (result.status === 'created') {
|
|
1215
|
+
if (result.status === 'created' && result.path) {
|
|
1136
1216
|
return {
|
|
1137
1217
|
filename: file.filename,
|
|
1138
1218
|
status: 'created',
|
|
1139
1219
|
entities: [{ entityId: result.entityId, entityType: result.entityType }],
|
|
1220
|
+
entry: {
|
|
1221
|
+
operation: 'create',
|
|
1222
|
+
entityType: result.entityType,
|
|
1223
|
+
entityId: result.entityId,
|
|
1224
|
+
filePath: result.path,
|
|
1225
|
+
},
|
|
1226
|
+
sidecarPaths: result.sidecarPaths,
|
|
1140
1227
|
};
|
|
1141
1228
|
}
|
|
1142
1229
|
return { filename: file.filename, status: result.status, error: result.error };
|
|
1143
1230
|
}
|
|
1231
|
+
async function processAsset(file) {
|
|
1232
|
+
let graph;
|
|
1233
|
+
try {
|
|
1234
|
+
graph = workspaceManager.getGraph(repo);
|
|
1235
|
+
}
|
|
1236
|
+
catch (err) {
|
|
1237
|
+
return { filename: file.filename, status: 'error', error: err.message ?? 'Folder not found' };
|
|
1238
|
+
}
|
|
1239
|
+
try {
|
|
1240
|
+
const buffer = Buffer.from(file.content, 'base64');
|
|
1241
|
+
const result = await uploadAsset({
|
|
1242
|
+
graph,
|
|
1243
|
+
filename: file.filename,
|
|
1244
|
+
buffer,
|
|
1245
|
+
folderPath: targetFolderPath || undefined,
|
|
1246
|
+
user: gitUser,
|
|
1247
|
+
workspacePath: graph.getWorkspaceRoot(),
|
|
1248
|
+
// Defer commits — the dispatcher's batched commitPending
|
|
1249
|
+
// call at the bottom of the route covers every entry in
|
|
1250
|
+
// the same transaction.
|
|
1251
|
+
skipCommit: true,
|
|
1252
|
+
});
|
|
1253
|
+
return {
|
|
1254
|
+
filename: file.filename,
|
|
1255
|
+
status: 'created',
|
|
1256
|
+
entities: [{ entityId: result.entityId, entityType: result.entityType }],
|
|
1257
|
+
entry: {
|
|
1258
|
+
operation: 'create',
|
|
1259
|
+
entityType: 'asset',
|
|
1260
|
+
entityId: result.entityId,
|
|
1261
|
+
filePath: result.filePath,
|
|
1262
|
+
},
|
|
1263
|
+
// PDF text sidecars: include in extraPaths so they land in
|
|
1264
|
+
// the same commit as the entity files.
|
|
1265
|
+
sidecarPaths: result.sidecarPath ? [result.sidecarPath] : undefined,
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1268
|
+
catch (err) {
|
|
1269
|
+
return { filename: file.filename, status: 'error', error: err.message ?? 'Asset upload failed' };
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1144
1272
|
async function processViaAgent(file) {
|
|
1145
1273
|
try {
|
|
1146
1274
|
const text = await extractText(file.filename, file.content);
|
|
@@ -1171,13 +1299,21 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
1171
1299
|
}
|
|
1172
1300
|
}
|
|
1173
1301
|
async function processFile(file) {
|
|
1174
|
-
if (!file.filename
|
|
1175
|
-
return { filename: file.filename, status: 'error', error:
|
|
1302
|
+
if (!file.filename) {
|
|
1303
|
+
return { filename: file.filename, status: 'error', error: 'Missing filename' };
|
|
1176
1304
|
}
|
|
1177
1305
|
const ext = extname(file.filename).toLowerCase();
|
|
1306
|
+
// Direct-write text formats first — never collide with asset
|
|
1307
|
+
// detection. Then asset extensions (images, PDF, design files,
|
|
1308
|
+
// archives, …). Then the agent-extraction fallback for .docx
|
|
1309
|
+
// until the mammoth workstream lands. Anything else: reject.
|
|
1178
1310
|
if (DIRECT_WRITE_EXTS.has(ext))
|
|
1179
1311
|
return processDirect(file);
|
|
1180
|
-
|
|
1312
|
+
if (isAssetUploadExtension(file.filename))
|
|
1313
|
+
return processAsset(file);
|
|
1314
|
+
if (isSupportedFile(file.filename))
|
|
1315
|
+
return processViaAgent(file);
|
|
1316
|
+
return { filename: file.filename, status: 'error', error: `Unsupported file: ${file.filename}` };
|
|
1181
1317
|
}
|
|
1182
1318
|
// Process files in batches of CONCURRENCY
|
|
1183
1319
|
for (let i = 0; i < body.files.length; i += CONCURRENCY) {
|
|
@@ -1185,8 +1321,45 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
1185
1321
|
const batchResults = await Promise.all(batch.map(processFile));
|
|
1186
1322
|
results.push(...batchResults);
|
|
1187
1323
|
}
|
|
1324
|
+
// Direct-write path deferred git commits — flush them all in one
|
|
1325
|
+
// transaction now. Collapses N per-file commits into one batch
|
|
1326
|
+
// commit (~50ms vs N×50ms). Agent-path files committed inline via
|
|
1327
|
+
// /api/capture so they're not in this set.
|
|
1328
|
+
const pendingEntries = results
|
|
1329
|
+
.map(r => r.entry)
|
|
1330
|
+
.filter((e) => Boolean(e));
|
|
1331
|
+
// Collect every sidecar path produced by the import — these stage in
|
|
1332
|
+
// the same commit as the entity files, so the folder display name
|
|
1333
|
+
// and the entries it labels land atomically. De-dup because two
|
|
1334
|
+
// imported files inside the same Schema_OS/widgets/ would otherwise
|
|
1335
|
+
// produce duplicate sidecar entries.
|
|
1336
|
+
const sidecarPaths = Array.from(new Set(results.flatMap(r => r.sidecarPaths ?? [])));
|
|
1337
|
+
if (pendingEntries.length > 0) {
|
|
1338
|
+
try {
|
|
1339
|
+
const graph = workspaceManager.getGraph(repo);
|
|
1340
|
+
await graph.commitPending(pendingEntries, {
|
|
1341
|
+
commitMessage: `Import ${pendingEntries.length} ${pendingEntries.length === 1 ? 'entry' : 'entries'} from web UI`,
|
|
1342
|
+
user: gitUser,
|
|
1343
|
+
extraPaths: sidecarPaths.length > 0 ? sidecarPaths : undefined,
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1346
|
+
catch (err) {
|
|
1347
|
+
console.warn(`[import] batch commit failed for ${repo}: ${err instanceof Error ? err.message : err}`);
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1188
1350
|
const created = results.filter(r => r.status === 'created').length;
|
|
1189
1351
|
const entityCount = results.reduce((sum, r) => sum + (r.entities?.length ?? 0), 0);
|
|
1352
|
+
// Single repo_sync broadcast for the whole import. The realtime
|
|
1353
|
+
// store's repo_sync handler refreshes the repo's entity list once
|
|
1354
|
+
// — vs. one entity_change per file, which would do N re-renders
|
|
1355
|
+
// on every connected client and freeze the UI for large imports.
|
|
1356
|
+
if (created > 0) {
|
|
1357
|
+
wsHub?.broadcast({
|
|
1358
|
+
type: 'repo_sync', repo,
|
|
1359
|
+
actor: req.user?.displayName ?? 'API',
|
|
1360
|
+
source: 'api', timestamp: new Date().toISOString(),
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1190
1363
|
return {
|
|
1191
1364
|
success: true,
|
|
1192
1365
|
imported: created,
|
|
@@ -1245,41 +1418,79 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
1245
1418
|
});
|
|
1246
1419
|
}
|
|
1247
1420
|
});
|
|
1248
|
-
// POST /api/repos/:repo/
|
|
1249
|
-
|
|
1421
|
+
// POST /api/repos/:repo/assets — generic asset upload.
|
|
1422
|
+
//
|
|
1423
|
+
// Multipart `file` field. Creates a wrapper `asset` entity whose
|
|
1424
|
+
// frontmatter points at the stored binary (R2 or local volume) via
|
|
1425
|
+
// `asset_url`. PDFs additionally get a text sidecar at
|
|
1426
|
+
// `<workspace>/.studiograph/assets-text/asset/<id>.txt` so the vector
|
|
1427
|
+
// indexer can RAG them without bloating the markdown body. See
|
|
1428
|
+
// docs/asset-architecture.md.
|
|
1429
|
+
fastify.post('/api/repos/:repo/assets', async (req, reply) => {
|
|
1250
1430
|
const { repo } = req.params;
|
|
1251
1431
|
if (!hasRepoAccess(req, repo, workspaceManager, authService)) {
|
|
1252
|
-
return reply.status(
|
|
1432
|
+
return reply.status(404).send({ error: `Folder "${repo}" not found` });
|
|
1253
1433
|
}
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1434
|
+
let graph;
|
|
1435
|
+
try {
|
|
1436
|
+
graph = workspaceManager.getGraph(repo);
|
|
1257
1437
|
}
|
|
1438
|
+
catch (err) {
|
|
1439
|
+
return reply.status(404).send({ error: err.message });
|
|
1440
|
+
}
|
|
1441
|
+
// Per-route fileSize override — the global multipart limit (10MB) is
|
|
1442
|
+
// tuned for inline image embeds; full asset uploads accept up to the
|
|
1443
|
+
// 100MB cap enforced inside AssetService.upload().
|
|
1444
|
+
let file;
|
|
1258
1445
|
try {
|
|
1259
|
-
|
|
1260
|
-
const graph = workspaceManager.getGraph(repo);
|
|
1261
|
-
const user = reqGitUser(req);
|
|
1262
|
-
const result = await importImages(graph, body.files, user);
|
|
1263
|
-
// Single git commit for all created entities
|
|
1264
|
-
if (result.created > 0) {
|
|
1265
|
-
const git = graph.getGitService();
|
|
1266
|
-
if (git) {
|
|
1267
|
-
const commitUser = user ?? { id: 'import', name: 'Import', email: 'import@studiograph' };
|
|
1268
|
-
git.commitAll(`Import ${result.created} image${result.created !== 1 ? 's' : ''} as references`, commitUser);
|
|
1269
|
-
}
|
|
1270
|
-
}
|
|
1271
|
-
return {
|
|
1272
|
-
success: true,
|
|
1273
|
-
created: result.created,
|
|
1274
|
-
skipped: result.skipped,
|
|
1275
|
-
errors: result.errors,
|
|
1276
|
-
};
|
|
1446
|
+
file = await req.file({ limits: { fileSize: 100 * 1024 * 1024 } });
|
|
1277
1447
|
}
|
|
1278
1448
|
catch (err) {
|
|
1279
|
-
return reply.status(400).send({
|
|
1280
|
-
|
|
1281
|
-
|
|
1449
|
+
return reply.status(400).send({ error: err?.message ?? 'Invalid multipart upload' });
|
|
1450
|
+
}
|
|
1451
|
+
if (!file)
|
|
1452
|
+
return reply.status(400).send({ error: 'No file uploaded' });
|
|
1453
|
+
const buffer = await file.toBuffer();
|
|
1454
|
+
const folderPath = (req.query.folder ?? '').trim();
|
|
1455
|
+
if (folderPath.split('/').some(seg => seg === '..' || seg === '.')) {
|
|
1456
|
+
return reply.status(400).send({ error: 'Invalid folder: contains traversal segments' });
|
|
1457
|
+
}
|
|
1458
|
+
try {
|
|
1459
|
+
const { uploadAsset } = await import('../../services/asset-upload-service.js');
|
|
1460
|
+
const result = await uploadAsset({
|
|
1461
|
+
graph,
|
|
1462
|
+
filename: file.filename,
|
|
1463
|
+
buffer,
|
|
1464
|
+
folderPath: folderPath || undefined,
|
|
1465
|
+
name: req.query.name?.trim() || undefined,
|
|
1466
|
+
user: reqGitUser(req),
|
|
1467
|
+
workspacePath: graph.getWorkspaceRoot(),
|
|
1282
1468
|
});
|
|
1469
|
+
wsHub?.broadcast({
|
|
1470
|
+
type: 'repo_sync',
|
|
1471
|
+
repo,
|
|
1472
|
+
actor: req.user?.displayName ?? 'API',
|
|
1473
|
+
source: 'api',
|
|
1474
|
+
timestamp: new Date().toISOString(),
|
|
1475
|
+
});
|
|
1476
|
+
return reply.send({
|
|
1477
|
+
success: true,
|
|
1478
|
+
entity: {
|
|
1479
|
+
entityId: result.entityId,
|
|
1480
|
+
entityType: result.entityType,
|
|
1481
|
+
asset_url: result.asset_url,
|
|
1482
|
+
asset_kind: result.asset_kind,
|
|
1483
|
+
asset_size: result.asset_size,
|
|
1484
|
+
pages: result.pages,
|
|
1485
|
+
},
|
|
1486
|
+
});
|
|
1487
|
+
}
|
|
1488
|
+
catch (err) {
|
|
1489
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1490
|
+
const status = msg.includes('not configured') ? 501
|
|
1491
|
+
: msg.includes('Unsupported asset extension') ? 415
|
|
1492
|
+
: 400;
|
|
1493
|
+
return reply.status(status).send({ success: false, error: msg });
|
|
1283
1494
|
}
|
|
1284
1495
|
});
|
|
1285
1496
|
}
|