studiograph 1.3.48-next.76 → 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 +290 -38
- 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 +33 -1
- package/dist/services/import-service.js +234 -48
- 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/{BxqZoi16.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/{9wFWX30W.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/{BGQcC8WM.js → C9fPdp9e.js} +1 -1
- package/dist/web/_app/immutable/chunks/{CpjCnsRC2.js → CABEPnKi2.js} +1 -1
- package/dist/web/_app/immutable/chunks/{C4o1fi_w.js → CFFhRHgg.js} +1 -1
- package/dist/web/_app/immutable/chunks/{C3q48eWV.js → CMv-UMQD.js} +1 -1
- package/dist/web/_app/immutable/chunks/{14Ks5neZ.js → CO4LsWNt.js} +1 -1
- package/dist/web/_app/immutable/chunks/{DNod4IR72.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/{1TGSC9bG.js → D1AVtJaJ.js} +1 -1
- package/dist/web/_app/immutable/chunks/{CHdVOK6e.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/{f1DrsN7w.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/{Bzy2ehL_.js → DYbq0sax.js} +1 -1
- package/dist/web/_app/immutable/chunks/{hlnmlkiT.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/{DDQLyGcm.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/{BO35Fv1U.js → dTznEU-N.js} +4 -4
- package/dist/web/_app/immutable/chunks/{wY5DYt0W.js → iqOW8ttz.js} +1 -1
- package/dist/web/_app/immutable/chunks/{1h1Q-dyp.js → j7kuJOP8.js} +1 -1
- package/dist/web/_app/immutable/chunks/{IXuV70l3.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.DbP3pP9I.js → app.BUM2MbTk.js} +2 -2
- package/dist/web/_app/immutable/entry/start.BObvxb93.js +1 -0
- package/dist/web/_app/immutable/nodes/{0.CirGvHiz.js → 0.BMXqKYhl.js} +2 -2
- package/dist/web/_app/immutable/nodes/{1.kTjENnhN.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.DIQCUxzh.js → 11.BfZsU6Vh.js} +1 -1
- package/dist/web/_app/immutable/nodes/{12.DOlW7sto.js → 12.C5hRguPI.js} +1 -1
- package/dist/web/_app/immutable/nodes/{13.qikQg_xf.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.C1tDoWWj.js → 6.CF014BWC.js} +1 -1
- package/dist/web/_app/immutable/nodes/{7.ZR0ZhRen.js → 7.B6NRypBk.js} +1 -1
- package/dist/web/_app/immutable/nodes/{8.DUZm9iwH.js → 8.B4moM7KQ.js} +1 -1
- package/dist/web/_app/immutable/nodes/{9.BuV4JbTd.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/B-z4a3eR.js +0 -1
- package/dist/web/_app/immutable/chunks/B6o1oq9U2.js +0 -2
- package/dist/web/_app/immutable/chunks/BDCOho_G.js +0 -1
- package/dist/web/_app/immutable/chunks/Be0wk0_s2.js +0 -1
- package/dist/web/_app/immutable/chunks/C7o6G8Ak2.js +0 -1
- package/dist/web/_app/immutable/chunks/CHRfvdkq2.js +0 -1
- 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/DBATbpMA.js +0 -2
- package/dist/web/_app/immutable/chunks/DDzRa0WU.js +0 -1
- package/dist/web/_app/immutable/chunks/DTnTQDdN.js +0 -1
- package/dist/web/_app/immutable/chunks/DYG9AA8k.js +0 -8
- package/dist/web/_app/immutable/chunks/DhikmMsg2.js +0 -1
- package/dist/web/_app/immutable/chunks/Pr10v8I7.js +0 -1
- package/dist/web/_app/immutable/chunks/q69OiNAp.js +0 -1
- package/dist/web/_app/immutable/entry/start.Cl9IsfI4.js +0 -1
- package/dist/web/_app/immutable/nodes/10.5xATla_3.js +0 -1
- package/dist/web/_app/immutable/nodes/2.CiklFX4J.js +0 -130
- package/dist/web/_app/immutable/nodes/3.Byts3_Iy.js +0 -2
- package/dist/web/_app/immutable/nodes/4.BWfkHbqv.js +0 -11
- package/dist/web/_app/immutable/nodes/5.tH1sz1qb.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) {
|
|
@@ -1091,7 +1153,18 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
1091
1153
|
const committed = await sessionManager.flush(repo);
|
|
1092
1154
|
return reply.send({ committed });
|
|
1093
1155
|
});
|
|
1094
|
-
// POST /api/repos/:repo/import — import files
|
|
1156
|
+
// POST /api/repos/:repo/import — import files into a folder.
|
|
1157
|
+
//
|
|
1158
|
+
// Routing by extension:
|
|
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).
|
|
1095
1168
|
fastify.post('/api/repos/:repo/import', async (req, reply) => {
|
|
1096
1169
|
const { repo } = req.params;
|
|
1097
1170
|
if (!hasRepoAccess(req, repo, workspaceManager, authService)) {
|
|
@@ -1101,15 +1174,102 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
1101
1174
|
if (!body?.files || !Array.isArray(body.files)) {
|
|
1102
1175
|
return reply.status(400).send({ error: 'Expected { files: [{ filename, content }] }' });
|
|
1103
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
|
+
}
|
|
1104
1183
|
const captureRepo = body.agentDecides ? undefined : repo;
|
|
1184
|
+
const gitUser = reqGitUser(req);
|
|
1105
1185
|
try {
|
|
1106
|
-
const { extractText, isSupportedFile } = await import('../../services/import-service.js');
|
|
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');
|
|
1189
|
+
const { extname } = await import('path');
|
|
1107
1190
|
const results = [];
|
|
1191
|
+
// Direct-write extensions never need an LLM. Verbatim preservation —
|
|
1192
|
+
// markdown stays markdown, CSV stays CSV.
|
|
1193
|
+
const DIRECT_WRITE_EXTS = new Set(['.md', '.txt', '.csv']);
|
|
1108
1194
|
const CONCURRENCY = 3;
|
|
1109
|
-
async function
|
|
1110
|
-
|
|
1111
|
-
|
|
1195
|
+
async function processDirect(file) {
|
|
1196
|
+
let graph;
|
|
1197
|
+
try {
|
|
1198
|
+
graph = workspaceManager.getGraph(repo);
|
|
1199
|
+
}
|
|
1200
|
+
catch (err) {
|
|
1201
|
+
return { filename: file.filename, status: 'error', error: err.message ?? 'Folder not found' };
|
|
1202
|
+
}
|
|
1203
|
+
const result = await importFile(graph, file.filename, file.content, {
|
|
1204
|
+
user: gitUser,
|
|
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,
|
|
1214
|
+
});
|
|
1215
|
+
if (result.status === 'created' && result.path) {
|
|
1216
|
+
return {
|
|
1217
|
+
filename: file.filename,
|
|
1218
|
+
status: 'created',
|
|
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,
|
|
1227
|
+
};
|
|
1228
|
+
}
|
|
1229
|
+
return { filename: file.filename, status: result.status, error: result.error };
|
|
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' };
|
|
1112
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
|
+
}
|
|
1272
|
+
async function processViaAgent(file) {
|
|
1113
1273
|
try {
|
|
1114
1274
|
const text = await extractText(file.filename, file.content);
|
|
1115
1275
|
if (!text) {
|
|
@@ -1138,14 +1298,68 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
1138
1298
|
return { filename: file.filename, status: 'error', error: err.message ?? 'Processing failed' };
|
|
1139
1299
|
}
|
|
1140
1300
|
}
|
|
1301
|
+
async function processFile(file) {
|
|
1302
|
+
if (!file.filename) {
|
|
1303
|
+
return { filename: file.filename, status: 'error', error: 'Missing filename' };
|
|
1304
|
+
}
|
|
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.
|
|
1310
|
+
if (DIRECT_WRITE_EXTS.has(ext))
|
|
1311
|
+
return processDirect(file);
|
|
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}` };
|
|
1317
|
+
}
|
|
1141
1318
|
// Process files in batches of CONCURRENCY
|
|
1142
1319
|
for (let i = 0; i < body.files.length; i += CONCURRENCY) {
|
|
1143
1320
|
const batch = body.files.slice(i, i + CONCURRENCY);
|
|
1144
1321
|
const batchResults = await Promise.all(batch.map(processFile));
|
|
1145
1322
|
results.push(...batchResults);
|
|
1146
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
|
+
}
|
|
1147
1350
|
const created = results.filter(r => r.status === 'created').length;
|
|
1148
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
|
+
}
|
|
1149
1363
|
return {
|
|
1150
1364
|
success: true,
|
|
1151
1365
|
imported: created,
|
|
@@ -1204,41 +1418,79 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
1204
1418
|
});
|
|
1205
1419
|
}
|
|
1206
1420
|
});
|
|
1207
|
-
// POST /api/repos/:repo/
|
|
1208
|
-
|
|
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) => {
|
|
1209
1430
|
const { repo } = req.params;
|
|
1210
1431
|
if (!hasRepoAccess(req, repo, workspaceManager, authService)) {
|
|
1211
|
-
return reply.status(
|
|
1432
|
+
return reply.status(404).send({ error: `Folder "${repo}" not found` });
|
|
1212
1433
|
}
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1434
|
+
let graph;
|
|
1435
|
+
try {
|
|
1436
|
+
graph = workspaceManager.getGraph(repo);
|
|
1216
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;
|
|
1217
1445
|
try {
|
|
1218
|
-
|
|
1219
|
-
const graph = workspaceManager.getGraph(repo);
|
|
1220
|
-
const user = reqGitUser(req);
|
|
1221
|
-
const result = await importImages(graph, body.files, user);
|
|
1222
|
-
// Single git commit for all created entities
|
|
1223
|
-
if (result.created > 0) {
|
|
1224
|
-
const git = graph.getGitService();
|
|
1225
|
-
if (git) {
|
|
1226
|
-
const commitUser = user ?? { id: 'import', name: 'Import', email: 'import@studiograph' };
|
|
1227
|
-
git.commitAll(`Import ${result.created} image${result.created !== 1 ? 's' : ''} as references`, commitUser);
|
|
1228
|
-
}
|
|
1229
|
-
}
|
|
1230
|
-
return {
|
|
1231
|
-
success: true,
|
|
1232
|
-
created: result.created,
|
|
1233
|
-
skipped: result.skipped,
|
|
1234
|
-
errors: result.errors,
|
|
1235
|
-
};
|
|
1446
|
+
file = await req.file({ limits: { fileSize: 100 * 1024 * 1024 } });
|
|
1236
1447
|
}
|
|
1237
1448
|
catch (err) {
|
|
1238
|
-
return reply.status(400).send({
|
|
1239
|
-
|
|
1240
|
-
|
|
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(),
|
|
1241
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 });
|
|
1242
1494
|
}
|
|
1243
1495
|
});
|
|
1244
1496
|
}
|