heyiam 0.3.3 → 0.3.6
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/export.js +19 -5
- package/dist/mount.js +9 -7
- package/dist/public/assets/{index-qyd5sXR0.js → index-7mUuxgqY.js} +3 -3
- package/dist/public/index.html +1 -1
- package/dist/render/build-render-data.js +1 -0
- package/dist/render/liquid.js +12 -2
- package/dist/render/templates/aurora/project.liquid +1 -1
- package/dist/render/templates/bauhaus/project.liquid +1 -1
- package/dist/render/templates/blueprint/project.liquid +1 -1
- package/dist/render/templates/canvas/project.liquid +1 -1
- package/dist/render/templates/carbon/project.liquid +1 -1
- package/dist/render/templates/chalk/project.liquid +1 -1
- package/dist/render/templates/circuit/project.liquid +1 -1
- package/dist/render/templates/cosmos/project.liquid +1 -1
- package/dist/render/templates/daylight/project.liquid +1 -1
- package/dist/render/templates/editorial/project.liquid +1 -1
- package/dist/render/templates/ember/project.liquid +1 -1
- package/dist/render/templates/glacier/project.liquid +1 -1
- package/dist/render/templates/grid/project.liquid +1 -1
- package/dist/render/templates/kinetic/portfolio.liquid +1 -1
- package/dist/render/templates/kinetic/project.liquid +1 -1
- package/dist/render/templates/kinetic/styles.css +15 -9
- package/dist/render/templates/meridian/project.liquid +1 -1
- package/dist/render/templates/minimal/project.liquid +1 -1
- package/dist/render/templates/mono/project.liquid +1 -1
- package/dist/render/templates/neon/project.liquid +1 -1
- package/dist/render/templates/noir/project.liquid +1 -1
- package/dist/render/templates/obsidian/project.liquid +1 -1
- package/dist/render/templates/paper/project.liquid +1 -1
- package/dist/render/templates/parallax/project.liquid +1 -1
- package/dist/render/templates/parchment/project.liquid +1 -1
- package/dist/render/templates/project.liquid +1 -1
- package/dist/render/templates/radar/project.liquid +1 -1
- package/dist/render/templates/showcase/project.liquid +1 -1
- package/dist/render/templates/signal/project.liquid +1 -1
- package/dist/render/templates/strata/project.liquid +1 -1
- package/dist/render/templates/terminal/project.liquid +1 -1
- package/dist/render/templates/verdant/project.liquid +1 -1
- package/dist/render/templates/zen/project.liquid +1 -1
- package/dist/routes/portfolio-render-data.js +2 -2
- package/dist/routes/preview.js +18 -2
- package/dist/routes/project-session-upload.js +224 -0
- package/dist/routes/publish.js +270 -260
- package/dist/settings.js +14 -1
- package/package.json +1 -1
package/dist/routes/publish.js
CHANGED
|
@@ -5,21 +5,51 @@ import path from 'node:path';
|
|
|
5
5
|
import { randomUUID } from 'node:crypto';
|
|
6
6
|
import { getAuthToken } from '../auth.js';
|
|
7
7
|
import { API_URL, PUBLIC_URL, warnIfNonDefaultApiUrl } from '../config.js';
|
|
8
|
-
import { loadEnhancedData,
|
|
8
|
+
import { loadEnhancedData, saveUploadedState, getDefaultTemplate, getPortfolioProfile, hashPortfolioProfile, updatePortfolioPublishTarget, getPortfolioPublishState, listUploadedProjects, DEFAULT_PORTFOLIO_TARGET, } from '../settings.js';
|
|
9
9
|
import { generatePortfolioHtmlFragment, generateProjectHtmlFragment, generatePortfolioSite, createZipBuffer } from '../export.js';
|
|
10
10
|
import { buildPortfolioRenderData } from './portfolio-render-data.js';
|
|
11
11
|
import { buildProjectDetail } from './context.js';
|
|
12
12
|
import { captureScreenshot } from '../screenshot.js';
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import { buildAgentSummary } from './context.js';
|
|
13
|
+
import { renderProjectHtml } from '../render/index.js';
|
|
14
|
+
import { buildProjectRenderData } from '../render/build-render-data.js';
|
|
15
|
+
import { uploadSelectedSessions } from './project-session-upload.js';
|
|
17
16
|
import { invalidatePortfolioPreviewCache } from './preview.js';
|
|
18
17
|
import { startSSE } from './sse.js';
|
|
19
18
|
import { displayNameFromDir } from '../sync.js';
|
|
20
19
|
import { toSlug } from '../format-utils.js';
|
|
21
|
-
import { getProjectUuid
|
|
20
|
+
import { getProjectUuid } from '../db.js';
|
|
22
21
|
const IMAGE_KEY_PREFIX = 'images/';
|
|
22
|
+
/**
|
|
23
|
+
* Upload the user's base64-encoded profile photo to S3 via the Phoenix
|
|
24
|
+
* presign endpoints, then save the resulting key on the user record.
|
|
25
|
+
* Returns the public `/_img/:uuid` URL suitable for og:image / <img src>,
|
|
26
|
+
* or `null` if the upload failed (publish still proceeds without a photo).
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* POSTs the user's base64 profile photo to Phoenix, which resizes it into
|
|
30
|
+
* a full (max 1200px) and small (max 600px) variant, uploads both to R2,
|
|
31
|
+
* and persists the two keys on the user record. Returns the full-size
|
|
32
|
+
* URL (for inline <img src>) or `null` on any failure.
|
|
33
|
+
*/
|
|
34
|
+
async function uploadProfilePhoto(photoBase64, auth) {
|
|
35
|
+
try {
|
|
36
|
+
const res = await fetch(`${API_URL}/api/portfolio/profile-photo`, {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: {
|
|
39
|
+
'Content-Type': 'application/json',
|
|
40
|
+
Authorization: `Bearer ${auth.token}`,
|
|
41
|
+
},
|
|
42
|
+
body: JSON.stringify({ photo: photoBase64 }),
|
|
43
|
+
});
|
|
44
|
+
if (!res.ok)
|
|
45
|
+
return null;
|
|
46
|
+
const { full_url } = await res.json();
|
|
47
|
+
return full_url ?? null;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
23
53
|
export function createPublishRouter(ctx) {
|
|
24
54
|
const router = Router();
|
|
25
55
|
// Render project preview HTML
|
|
@@ -210,220 +240,17 @@ export function createPublishRouter(ctx) {
|
|
|
210
240
|
const proj = projects.find((p) => p.dirName === project);
|
|
211
241
|
let uploadedCount = 0;
|
|
212
242
|
const failedSessions = [];
|
|
213
|
-
|
|
243
|
+
let uploadedSessionCards = [];
|
|
214
244
|
if (proj) {
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const sessionSlug = toSlug(enhanced?.title ?? session.title ?? sessionId, 80);
|
|
225
|
-
const includeTranscript = isTranscriptIncluded(sessionId);
|
|
226
|
-
const agentSummary = await buildAgentSummary(meta.children ?? [], (c) => ctx.getSessionStats(c, proj.name), { deduplicate: true });
|
|
227
|
-
const devTake = (enhanced?.developerTake ?? session.developerTake ?? '').slice(0, 2000);
|
|
228
|
-
const sessionNarrative = enhanced?.narrative ?? '';
|
|
229
|
-
const sessionTitle = enhanced?.title ?? session.title;
|
|
230
|
-
const sessionSkills = enhanced?.skills ?? session.skills ?? [];
|
|
231
|
-
const sessionSourceTool = session.source ?? meta.source ?? 'claude';
|
|
232
|
-
const sessionRecordedAt = session.date ? new Date(session.date).toISOString() : new Date().toISOString();
|
|
233
|
-
const renderOpts = {
|
|
234
|
-
sessionId,
|
|
235
|
-
session,
|
|
236
|
-
enhanced,
|
|
237
|
-
username: auth.username,
|
|
238
|
-
projectSlug: projectData.slug,
|
|
239
|
-
sessionSlug,
|
|
240
|
-
sourceTool: sessionSourceTool,
|
|
241
|
-
agentSummary,
|
|
242
|
-
template: selectedTemplate,
|
|
243
|
-
};
|
|
244
|
-
let sessionRenderedHtml = null;
|
|
245
|
-
try {
|
|
246
|
-
const sessionRenderData = buildSessionRenderData(renderOpts);
|
|
247
|
-
sessionRenderedHtml = renderSessionHtml(sessionRenderData, selectedTemplate);
|
|
248
|
-
}
|
|
249
|
-
catch (renderErr) {
|
|
250
|
-
console.error(`[upload] Session render failed for ${sessionId}:`, renderErr.message);
|
|
251
|
-
}
|
|
252
|
-
uploadedSessionCards.push(buildSessionCard(renderOpts));
|
|
253
|
-
const childLoc = agentSummary?.agents?.reduce((s, a) => s + (a.loc_changed ?? 0), 0) ?? 0;
|
|
254
|
-
const totalLocChanged = (session.linesOfCode ?? 0) + childLoc;
|
|
255
|
-
const totalFilesChanged = getFileCountWithChildren(ctx.db, sessionId) || session.filesChanged?.length || 0;
|
|
256
|
-
const sessionPayload = {
|
|
257
|
-
session: {
|
|
258
|
-
title: sessionTitle,
|
|
259
|
-
dev_take: devTake,
|
|
260
|
-
context: enhanced?.context ?? '',
|
|
261
|
-
duration_minutes: session.durationMinutes ?? 0,
|
|
262
|
-
turns: session.turns ?? 0,
|
|
263
|
-
files_changed: totalFilesChanged,
|
|
264
|
-
loc_changed: totalLocChanged,
|
|
265
|
-
recorded_at: sessionRecordedAt,
|
|
266
|
-
end_time: session.endTime ? new Date(session.endTime).toISOString() : null,
|
|
267
|
-
cwd: session.cwd ?? null,
|
|
268
|
-
wall_clock_minutes: session.wallClockMinutes ?? null,
|
|
269
|
-
template: selectedTemplate,
|
|
270
|
-
language: null,
|
|
271
|
-
tools: session.toolBreakdown?.map((t) => t.tool) ?? [],
|
|
272
|
-
skills: sessionSkills,
|
|
273
|
-
narrative: sessionNarrative,
|
|
274
|
-
project_name: proj.name,
|
|
275
|
-
project_id: projectData.project_id,
|
|
276
|
-
slug: sessionSlug,
|
|
277
|
-
status: 'unlisted',
|
|
278
|
-
source_tool: sessionSourceTool,
|
|
279
|
-
agent_summary: agentSummary,
|
|
280
|
-
rendered_html: sessionRenderedHtml,
|
|
281
|
-
},
|
|
282
|
-
};
|
|
283
|
-
// Transcript-derived fields. When the user has toggled the
|
|
284
|
-
// transcript OFF for this session we omit them from the
|
|
285
|
-
// uploaded JSON entirely — see the spread below. The
|
|
286
|
-
// session's main payload (dev take, skills, Q&A, etc.) is
|
|
287
|
-
// unaffected.
|
|
288
|
-
const turnTimeline = (session.turnTimeline ?? []).map((t) => ({
|
|
289
|
-
timestamp: t.timestamp,
|
|
290
|
-
type: t.type,
|
|
291
|
-
content: (t.content ?? '').slice(0, 200),
|
|
292
|
-
tools: t.tools ?? [],
|
|
293
|
-
}));
|
|
294
|
-
const transcriptExcerpt = (session.rawLog ?? []).slice(0, 10).map((line, i) => {
|
|
295
|
-
const role = line.startsWith('> ') ? 'dev' : 'ai';
|
|
296
|
-
const text = role === 'dev' ? line.slice(2) : line;
|
|
297
|
-
return { role, id: `Turn ${i + 1}`, text, timestamp: null };
|
|
298
|
-
});
|
|
299
|
-
const sessionData = {
|
|
300
|
-
version: 1,
|
|
301
|
-
id: sessionId,
|
|
302
|
-
title: sessionTitle,
|
|
303
|
-
dev_take: devTake,
|
|
304
|
-
context: enhanced?.context ?? '',
|
|
305
|
-
duration_minutes: session.durationMinutes ?? 0,
|
|
306
|
-
turns: session.turns ?? 0,
|
|
307
|
-
files_changed: (session.filesChanged ?? []).slice(0, 20).map((f) => (typeof f === 'string' ? { path: f, additions: 0, deletions: 0 } : f)),
|
|
308
|
-
loc_changed: totalLocChanged,
|
|
309
|
-
date: sessionRecordedAt,
|
|
310
|
-
end_time: (() => {
|
|
311
|
-
if (!session.endTime || !session.date)
|
|
312
|
-
return null;
|
|
313
|
-
const wallMs = new Date(session.endTime).getTime() - new Date(session.date).getTime();
|
|
314
|
-
const activeMs = (session.durationMinutes ?? 0) * 60_000;
|
|
315
|
-
return wallMs <= activeMs * 3 ? new Date(session.endTime).toISOString() : null;
|
|
316
|
-
})(),
|
|
317
|
-
cwd: session.cwd ?? null,
|
|
318
|
-
wall_clock_minutes: session.wallClockMinutes ?? null,
|
|
319
|
-
template: selectedTemplate,
|
|
320
|
-
skills: sessionSkills,
|
|
321
|
-
tools: session.toolBreakdown?.map((t) => t.tool) ?? [],
|
|
322
|
-
source: sessionSourceTool,
|
|
323
|
-
slug: sessionSlug,
|
|
324
|
-
project_name: proj.name,
|
|
325
|
-
narrative: sessionNarrative,
|
|
326
|
-
status: 'unlisted',
|
|
327
|
-
raw_log: [],
|
|
328
|
-
execution_path: (enhanced?.executionSteps ?? session.executionPath ?? []).map((s, i) => ({
|
|
329
|
-
label: s.title ?? `Step ${i + 1}`,
|
|
330
|
-
description: s.description ?? s.body ?? '',
|
|
331
|
-
})),
|
|
332
|
-
qa_pairs: enhanced?.qaPairs ?? session.qaPairs ?? [],
|
|
333
|
-
highlights: [],
|
|
334
|
-
tool_breakdown: (session.toolBreakdown ?? []).map((t) => ({ tool: t.tool, count: t.count })),
|
|
335
|
-
top_files: (session.filesChanged ?? []).slice(0, 20).map((f) => (typeof f === 'string' ? { path: f, additions: 0, deletions: 0 } : f)),
|
|
336
|
-
...(includeTranscript ? { turn_timeline: turnTimeline } : {}),
|
|
337
|
-
...(includeTranscript ? { transcript_excerpt: transcriptExcerpt } : {}),
|
|
338
|
-
agent_summary: agentSummary,
|
|
339
|
-
children: agentSummary?.agents?.map((a) => ({
|
|
340
|
-
sessionId: a.role,
|
|
341
|
-
role: a.role,
|
|
342
|
-
durationMinutes: a.duration_minutes,
|
|
343
|
-
linesOfCode: a.loc_changed,
|
|
344
|
-
})) ?? [],
|
|
345
|
-
};
|
|
346
|
-
const sessionCwd = session.cwd ?? undefined;
|
|
347
|
-
const redactedPayload = redactSession(sessionPayload, 'high', sessionCwd);
|
|
348
|
-
const redactedData = redactSession(sessionData, 'high', sessionCwd);
|
|
349
|
-
const payloadFindings = scanTextSync(JSON.stringify(sessionPayload));
|
|
350
|
-
if (payloadFindings.length > 0) {
|
|
351
|
-
const summary = formatFindings(payloadFindings);
|
|
352
|
-
send({ type: 'redaction', sessionId, message: summary });
|
|
353
|
-
}
|
|
354
|
-
const sessionRes = await fetch(`${API_URL}/api/sessions`, {
|
|
355
|
-
method: 'POST',
|
|
356
|
-
headers: {
|
|
357
|
-
'Content-Type': 'application/json',
|
|
358
|
-
Authorization: `Bearer ${auth.token}`,
|
|
359
|
-
},
|
|
360
|
-
body: JSON.stringify(redactedPayload),
|
|
361
|
-
});
|
|
362
|
-
if (sessionRes.ok) {
|
|
363
|
-
uploadedCount++;
|
|
364
|
-
try {
|
|
365
|
-
const sesData = await sessionRes.json();
|
|
366
|
-
if (sesData.upload_urls && includeTranscript) {
|
|
367
|
-
// Transcript-bearing S3 uploads. Skipped entirely when
|
|
368
|
-
// the user has toggled the transcript OFF for this
|
|
369
|
-
// session — the server never sees raw_log, turn
|
|
370
|
-
// timeline, or the bundled session-data JSON in that
|
|
371
|
-
// case.
|
|
372
|
-
const { raw: rawUrl, log: logUrl } = sesData.upload_urls;
|
|
373
|
-
if (rawUrl && meta.path && !meta.path.startsWith('cursor://')) {
|
|
374
|
-
try {
|
|
375
|
-
const rawText = readFileSync(meta.path, 'utf-8');
|
|
376
|
-
let redactedRaw = redactText(rawText);
|
|
377
|
-
redactedRaw = stripHomePathsInText(redactedRaw, sessionCwd);
|
|
378
|
-
await fetch(rawUrl, { method: 'PUT', body: Buffer.from(redactedRaw, 'utf-8'), headers: { 'Content-Type': 'application/octet-stream' } });
|
|
379
|
-
}
|
|
380
|
-
catch { /* S3 upload is best-effort */ }
|
|
381
|
-
}
|
|
382
|
-
if (logUrl && session.rawLog && session.rawLog.length > 0) {
|
|
383
|
-
try {
|
|
384
|
-
const redactedLog = session.rawLog.map((line) => {
|
|
385
|
-
let cleaned = redactText(line);
|
|
386
|
-
cleaned = stripHomePathsInText(cleaned, sessionCwd);
|
|
387
|
-
return cleaned;
|
|
388
|
-
});
|
|
389
|
-
await fetch(logUrl, { method: 'PUT', body: JSON.stringify(redactedLog), headers: { 'Content-Type': 'application/json' } });
|
|
390
|
-
}
|
|
391
|
-
catch { /* S3 upload is best-effort */ }
|
|
392
|
-
}
|
|
393
|
-
if (sesData.upload_urls.session) {
|
|
394
|
-
try {
|
|
395
|
-
await fetch(sesData.upload_urls.session, {
|
|
396
|
-
method: 'PUT',
|
|
397
|
-
body: JSON.stringify(redactedData),
|
|
398
|
-
headers: { 'Content-Type': 'application/json' },
|
|
399
|
-
});
|
|
400
|
-
}
|
|
401
|
-
catch { /* S3 upload is best-effort */ }
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
catch { /* Response already consumed or no upload_urls -- not fatal */ }
|
|
406
|
-
if (enhanced) {
|
|
407
|
-
saveEnhancedData(sessionId, { ...enhanced, uploaded: true });
|
|
408
|
-
}
|
|
409
|
-
send({ type: 'session', sessionId, status: 'uploaded' });
|
|
410
|
-
}
|
|
411
|
-
else {
|
|
412
|
-
const sesErrBody = await sessionRes.json().catch(() => null);
|
|
413
|
-
const rawSesErr = sesErrBody && typeof sesErrBody === 'object' ? sesErrBody.error : null;
|
|
414
|
-
const errMsg = typeof rawSesErr === 'string' ? rawSesErr
|
|
415
|
-
: (rawSesErr && typeof rawSesErr === 'object' && 'message' in rawSesErr) ? rawSesErr.message
|
|
416
|
-
: `HTTP ${sessionRes.status}`;
|
|
417
|
-
failedSessions.push({ sessionId, error: errMsg });
|
|
418
|
-
send({ type: 'session', sessionId, status: 'failed', error: errMsg });
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
catch (err) {
|
|
422
|
-
const errMsg = err.message;
|
|
423
|
-
failedSessions.push({ sessionId, error: errMsg });
|
|
424
|
-
send({ type: 'session', sessionId, status: 'failed', error: errMsg });
|
|
425
|
-
}
|
|
426
|
-
}
|
|
245
|
+
const sessionResult = await uploadSelectedSessions(ctx, auth, {
|
|
246
|
+
proj,
|
|
247
|
+
projectData,
|
|
248
|
+
selectedSessionIds,
|
|
249
|
+
send,
|
|
250
|
+
});
|
|
251
|
+
uploadedCount = sessionResult.uploadedCount;
|
|
252
|
+
failedSessions.push(...sessionResult.failedSessions);
|
|
253
|
+
uploadedSessionCards = sessionResult.uploadedSessionCards;
|
|
427
254
|
}
|
|
428
255
|
// Step 3: Render project HTML using the same path as HTML export
|
|
429
256
|
if (uploadedSessionCards.length > 0) {
|
|
@@ -555,61 +382,245 @@ export function createPublishRouter(ctx) {
|
|
|
555
382
|
});
|
|
556
383
|
return;
|
|
557
384
|
}
|
|
385
|
+
const send = startSSE(res);
|
|
558
386
|
try {
|
|
387
|
+
send({ type: 'progress', message: 'Preparing portfolio…' });
|
|
559
388
|
const profile = getPortfolioProfile();
|
|
560
389
|
const templateName = getDefaultTemplate() || 'editorial';
|
|
561
|
-
|
|
390
|
+
// If the user has a profile photo, upload it to Phoenix first so the
|
|
391
|
+
// rendered HTML can reference the hosted URL instead of inlining 1.5MB
|
|
392
|
+
// of base64. Phoenix resizes into a full + small variant and stores
|
|
393
|
+
// both in R2. Removals are handled by the upload endpoint below based
|
|
394
|
+
// on the `has_photo` signal — no explicit DELETE needed.
|
|
395
|
+
let photoUrlOverride;
|
|
396
|
+
if (profile.photoBase64) {
|
|
397
|
+
send({ type: 'progress', message: 'Uploading profile photo…' });
|
|
398
|
+
const uploadedUrl = await uploadProfilePhoto(profile.photoBase64, auth);
|
|
399
|
+
if (uploadedUrl)
|
|
400
|
+
photoUrlOverride = uploadedUrl;
|
|
401
|
+
}
|
|
402
|
+
const { renderData, filteredProjects } = await buildPortfolioRenderData(ctx, auth, { photoUrlOverride });
|
|
403
|
+
send({ type: 'progress', message: 'Rendering portfolio HTML…' });
|
|
562
404
|
const renderedHtml = generatePortfolioHtmlFragment(renderData, templateName);
|
|
563
405
|
// Upload individual project pages for every project included in the
|
|
564
406
|
// portfolio. This ensures project detail pages exist on heyi.am even
|
|
565
407
|
// if the user never published them individually.
|
|
408
|
+
const MAX_SLUG_RETRIES = 10;
|
|
409
|
+
const slugMap = new Map();
|
|
410
|
+
send({ type: 'progress', message: `Publishing ${filteredProjects.length} project${filteredProjects.length === 1 ? '' : 's'}…` });
|
|
411
|
+
let projectIndex = 0;
|
|
566
412
|
for (const rawProj of filteredProjects) {
|
|
413
|
+
projectIndex++;
|
|
567
414
|
try {
|
|
415
|
+
const allProjectsList = await ctx.getProjects();
|
|
416
|
+
const projInfo = allProjectsList.find((p) => p.dirName === rawProj.dirName);
|
|
417
|
+
if (!projInfo) {
|
|
418
|
+
console.warn(`[portfolio-upload] project not found: ${rawProj.dirName}`);
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
568
421
|
const detail = buildProjectDetail(ctx.db, rawProj);
|
|
569
|
-
const
|
|
570
|
-
const cache =
|
|
571
|
-
|
|
422
|
+
const enhance = detail.enhanceCache;
|
|
423
|
+
const cache = enhance ?? {
|
|
424
|
+
fingerprint: 'portfolio-upload',
|
|
425
|
+
enhancedAt: new Date().toISOString(),
|
|
426
|
+
selectedSessionIds: detail.sessions.map((s) => s.id),
|
|
427
|
+
result: { narrative: '', arc: [], skills: [], timeline: [], questions: [] },
|
|
428
|
+
};
|
|
429
|
+
const selectedSessionIds = enhance !== null && enhance.selectedSessionIds !== undefined
|
|
430
|
+
? enhance.selectedSessionIds
|
|
431
|
+
: detail.sessions.map((s) => s.id);
|
|
432
|
+
const projRecord = detail.project;
|
|
572
433
|
const title = cache.title
|
|
573
|
-
||
|
|
574
|
-
const
|
|
434
|
+
|| projRecord.name || displayNameFromDir(rawProj.dirName);
|
|
435
|
+
const baseSlug = toSlug(title);
|
|
575
436
|
const clientProjectId = getProjectUuid(ctx.db, rawProj.dirName);
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
437
|
+
send({ type: 'project', project: title, index: projectIndex, total: filteredProjects.length, status: 'creating' });
|
|
438
|
+
const projectHtmlPreview = generateProjectHtmlFragment(rawProj.dirName, cache, detail.sessions, auth.username, {
|
|
439
|
+
totalFilesChanged: projRecord.totalFiles,
|
|
440
|
+
totalAgentDurationMinutes: projRecord.totalAgentDuration,
|
|
441
|
+
totalInputTokens: projRecord.totalInputTokens,
|
|
442
|
+
totalOutputTokens: projRecord.totalOutputTokens,
|
|
581
443
|
});
|
|
582
|
-
|
|
583
|
-
|
|
444
|
+
const projectBodyBase = {
|
|
445
|
+
client_project_id: clientProjectId,
|
|
446
|
+
title,
|
|
447
|
+
narrative: cache.result?.narrative ?? '',
|
|
448
|
+
repo_url: cache.repoUrl || null,
|
|
449
|
+
project_url: cache.projectUrl || null,
|
|
450
|
+
timeline: cache.result?.timeline ?? [],
|
|
451
|
+
skills: cache.result?.skills ?? [],
|
|
452
|
+
total_sessions: projRecord.sessionCount,
|
|
453
|
+
total_loc: projRecord.totalLoc,
|
|
454
|
+
total_duration_minutes: projRecord.totalDuration,
|
|
455
|
+
total_agent_duration_minutes: projRecord.totalAgentDuration || null,
|
|
456
|
+
total_files_changed: projRecord.totalFiles,
|
|
457
|
+
total_input_tokens: projRecord.totalInputTokens,
|
|
458
|
+
total_output_tokens: projRecord.totalOutputTokens,
|
|
459
|
+
skipped_sessions: [],
|
|
460
|
+
};
|
|
461
|
+
let slug = baseSlug;
|
|
462
|
+
let projectRes = null;
|
|
463
|
+
if (selectedSessionIds.length === 0) {
|
|
464
|
+
for (let attempt = 0; attempt <= MAX_SLUG_RETRIES; attempt++) {
|
|
465
|
+
projectRes = await fetch(`${API_URL}/api/projects`, {
|
|
466
|
+
method: 'POST',
|
|
467
|
+
headers: {
|
|
468
|
+
'Content-Type': 'application/json',
|
|
469
|
+
Authorization: `Bearer ${auth.token}`,
|
|
470
|
+
},
|
|
471
|
+
body: JSON.stringify({
|
|
472
|
+
project: {
|
|
473
|
+
...projectBodyBase,
|
|
474
|
+
slug,
|
|
475
|
+
rendered_html: projectHtmlPreview,
|
|
476
|
+
},
|
|
477
|
+
}),
|
|
478
|
+
});
|
|
479
|
+
if (projectRes.status === 409) {
|
|
480
|
+
slug = `${baseSlug}-${attempt + 1}`;
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
break;
|
|
484
|
+
}
|
|
485
|
+
if (projectRes?.ok) {
|
|
486
|
+
const data = await projectRes.json().catch(() => null);
|
|
487
|
+
if (data?.slug && data.slug !== baseSlug) {
|
|
488
|
+
slugMap.set(baseSlug, data.slug);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
else {
|
|
492
|
+
const errText = await projectRes?.text().catch(() => '');
|
|
493
|
+
console.warn(`[portfolio-upload] project ${rawProj.dirName} create failed:`, projectRes?.status, errText);
|
|
494
|
+
}
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
for (let attempt = 0; attempt <= MAX_SLUG_RETRIES; attempt++) {
|
|
498
|
+
projectRes = await fetch(`${API_URL}/api/projects`, {
|
|
499
|
+
method: 'POST',
|
|
500
|
+
headers: {
|
|
501
|
+
'Content-Type': 'application/json',
|
|
502
|
+
Authorization: `Bearer ${auth.token}`,
|
|
503
|
+
},
|
|
504
|
+
body: JSON.stringify({
|
|
505
|
+
project: {
|
|
506
|
+
...projectBodyBase,
|
|
507
|
+
slug,
|
|
508
|
+
},
|
|
509
|
+
}),
|
|
510
|
+
});
|
|
511
|
+
if (projectRes.status === 409) {
|
|
512
|
+
slug = `${baseSlug}-${attempt + 1}`;
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
if (!projectRes || !projectRes.ok) {
|
|
518
|
+
const errText = await projectRes?.text().catch(() => '') ?? '';
|
|
519
|
+
console.warn(`[portfolio-upload] project ${rawProj.dirName} create failed:`, projectRes?.status, errText);
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
const projectData = await projectRes.json();
|
|
523
|
+
if (projectData.slug !== baseSlug) {
|
|
524
|
+
slugMap.set(baseSlug, projectData.slug);
|
|
525
|
+
}
|
|
526
|
+
send({ type: 'project', project: title, index: projectIndex, total: filteredProjects.length, status: 'created' });
|
|
527
|
+
send({ type: 'progress', message: `Uploading ${selectedSessionIds.length} session${selectedSessionIds.length === 1 ? '' : 's'} for ${title}…` });
|
|
528
|
+
const { uploadedSessionCards } = await uploadSelectedSessions(ctx, auth, {
|
|
529
|
+
proj: projInfo,
|
|
530
|
+
projectData,
|
|
531
|
+
selectedSessionIds,
|
|
532
|
+
sessionStatus: 'listed',
|
|
533
|
+
send: (evt) => send({ ...evt, project: title }),
|
|
534
|
+
});
|
|
535
|
+
// Ensure all existing sessions for this project are listed
|
|
536
|
+
await fetch(`${API_URL}/api/sessions/bulk-status`, {
|
|
537
|
+
method: 'PATCH',
|
|
584
538
|
headers: {
|
|
585
539
|
'Content-Type': 'application/json',
|
|
586
540
|
Authorization: `Bearer ${auth.token}`,
|
|
587
541
|
},
|
|
588
|
-
body: JSON.stringify({
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
542
|
+
body: JSON.stringify({ project_id: projectData.project_id, status: 'listed' }),
|
|
543
|
+
}).catch((e) => console.warn(`[portfolio-upload] bulk-status failed for ${title}:`, e.message));
|
|
544
|
+
if (uploadedSessionCards.length === 0) {
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
try {
|
|
548
|
+
const detailAfter = buildProjectDetail(ctx.db, rawProj);
|
|
549
|
+
const cacheAfter = detailAfter.enhanceCache ?? cache;
|
|
550
|
+
const totalFiles = detailAfter.project.totalFiles;
|
|
551
|
+
const projectHtmlFinal = generateProjectHtmlFragment(rawProj.dirName, cacheAfter, detailAfter.sessions, auth.username, {
|
|
552
|
+
totalFilesChanged: totalFiles,
|
|
553
|
+
totalInputTokens: detailAfter.project.totalInputTokens,
|
|
554
|
+
totalOutputTokens: detailAfter.project.totalOutputTokens,
|
|
555
|
+
});
|
|
556
|
+
const renderRes = await fetch(`${API_URL}/api/projects`, {
|
|
557
|
+
method: 'POST',
|
|
558
|
+
headers: {
|
|
559
|
+
'Content-Type': 'application/json',
|
|
560
|
+
Authorization: `Bearer ${auth.token}`,
|
|
602
561
|
},
|
|
603
|
-
|
|
562
|
+
body: JSON.stringify({
|
|
563
|
+
project: {
|
|
564
|
+
...projectBodyBase,
|
|
565
|
+
slug: projectData.slug,
|
|
566
|
+
rendered_html: projectHtmlFinal,
|
|
567
|
+
},
|
|
568
|
+
}),
|
|
569
|
+
});
|
|
570
|
+
if (!renderRes.ok) {
|
|
571
|
+
console.warn(`[portfolio-upload] project ${rawProj.dirName} rendered_html update failed:`, renderRes.status);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
catch (renderErr) {
|
|
575
|
+
console.warn(`[portfolio-upload] project render update ${rawProj.dirName}:`, renderErr.message);
|
|
576
|
+
}
|
|
577
|
+
const uploadedSessionIds = selectedSessionIds.filter((sid) => {
|
|
578
|
+
const enhanced = loadEnhancedData(sid);
|
|
579
|
+
return enhanced?.uploaded;
|
|
580
|
+
});
|
|
581
|
+
saveUploadedState(rawProj.dirName, {
|
|
582
|
+
slug: projectData.slug,
|
|
583
|
+
projectId: projectData.project_id,
|
|
584
|
+
uploadedSessions: uploadedSessionIds,
|
|
604
585
|
});
|
|
605
586
|
}
|
|
606
587
|
catch (projErr) {
|
|
607
588
|
console.warn(`[portfolio-upload] skipping project ${rawProj.dirName}:`, projErr.message);
|
|
608
589
|
}
|
|
609
590
|
}
|
|
610
|
-
//
|
|
611
|
-
//
|
|
612
|
-
|
|
591
|
+
// Demote sessions on projects that were previously published but are
|
|
592
|
+
// no longer included in the portfolio.
|
|
593
|
+
const includedDirNames = new Set(filteredProjects.map((p) => p.dirName));
|
|
594
|
+
const previouslyUploaded = listUploadedProjects();
|
|
595
|
+
for (const { dirName, state } of previouslyUploaded) {
|
|
596
|
+
if (includedDirNames.has(dirName))
|
|
597
|
+
continue;
|
|
598
|
+
try {
|
|
599
|
+
send({ type: 'progress', message: `Demoting sessions for removed project "${dirName}"…` });
|
|
600
|
+
await fetch(`${API_URL}/api/sessions/bulk-status`, {
|
|
601
|
+
method: 'PATCH',
|
|
602
|
+
headers: {
|
|
603
|
+
'Content-Type': 'application/json',
|
|
604
|
+
Authorization: `Bearer ${auth.token}`,
|
|
605
|
+
},
|
|
606
|
+
body: JSON.stringify({ project_id: state.projectId, status: 'unlisted' }),
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
catch (demoteErr) {
|
|
610
|
+
console.warn(`[portfolio-upload] failed to demote sessions for ${dirName}:`, demoteErr.message);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
// Rewrite project links in the portfolio HTML to match the actual
|
|
614
|
+
// Phoenix-assigned slugs (which may differ due to conflict retries).
|
|
615
|
+
let finalHtml = renderedHtml;
|
|
616
|
+
for (const [originalSlug, actualSlug] of slugMap) {
|
|
617
|
+
const pattern = `/${auth.username}/${originalSlug}"`;
|
|
618
|
+
const replacement = `/${auth.username}/${actualSlug}"`;
|
|
619
|
+
while (finalHtml.includes(pattern)) {
|
|
620
|
+
finalHtml = finalHtml.replace(pattern, replacement);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
send({ type: 'progress', message: 'Publishing landing page…' });
|
|
613
624
|
const phoenixRes = await fetch(`${API_URL}/api/portfolio/upload`, {
|
|
614
625
|
method: 'POST',
|
|
615
626
|
headers: {
|
|
@@ -617,8 +628,9 @@ export function createPublishRouter(ctx) {
|
|
|
617
628
|
Authorization: `Bearer ${auth.token}`,
|
|
618
629
|
},
|
|
619
630
|
body: JSON.stringify({
|
|
620
|
-
html:
|
|
631
|
+
html: finalHtml,
|
|
621
632
|
profile,
|
|
633
|
+
has_photo: Boolean(profile.photoBase64),
|
|
622
634
|
}),
|
|
623
635
|
});
|
|
624
636
|
if (!phoenixRes.ok) {
|
|
@@ -635,9 +647,8 @@ export function createPublishRouter(ctx) {
|
|
|
635
647
|
lastError: errMsg,
|
|
636
648
|
lastErrorAt: new Date().toISOString(),
|
|
637
649
|
});
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
});
|
|
650
|
+
send({ type: 'error', code: 'PORTFOLIO_UPLOAD_FAILED', message: errMsg });
|
|
651
|
+
res.end();
|
|
641
652
|
return;
|
|
642
653
|
}
|
|
643
654
|
const okBody = await phoenixRes.json().catch(() => ({}));
|
|
@@ -653,15 +664,15 @@ export function createPublishRouter(ctx) {
|
|
|
653
664
|
lastError: undefined,
|
|
654
665
|
lastErrorAt: undefined,
|
|
655
666
|
});
|
|
656
|
-
// Published version of the portfolio just changed — drop any cached
|
|
657
|
-
// /preview/portfolio HTML so the next preview reflects reality.
|
|
658
667
|
invalidatePortfolioPreviewCache();
|
|
659
|
-
|
|
668
|
+
send({
|
|
669
|
+
type: 'done',
|
|
660
670
|
ok: true,
|
|
661
671
|
url: publishedUrl ?? `${PUBLIC_URL}/${auth.username}`,
|
|
662
672
|
publishedAt,
|
|
663
673
|
hash,
|
|
664
674
|
});
|
|
675
|
+
res.end();
|
|
665
676
|
}
|
|
666
677
|
catch (err) {
|
|
667
678
|
const errMsg = err.message;
|
|
@@ -673,9 +684,8 @@ export function createPublishRouter(ctx) {
|
|
|
673
684
|
});
|
|
674
685
|
}
|
|
675
686
|
catch { /* don't mask the original error */ }
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
});
|
|
687
|
+
send({ type: 'error', code: 'PORTFOLIO_UPLOAD_FAILED', message: errMsg });
|
|
688
|
+
res.end();
|
|
679
689
|
}
|
|
680
690
|
});
|
|
681
691
|
// Export the portfolio as a downloadable .zip file.
|
package/dist/settings.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, readdirSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { createHash } from 'node:crypto';
|
|
@@ -235,6 +235,19 @@ export function clearUploadedState(projectDirName, configDir) {
|
|
|
235
235
|
if (existsSync(path))
|
|
236
236
|
unlinkSync(path);
|
|
237
237
|
}
|
|
238
|
+
export function listUploadedProjects(configDir) {
|
|
239
|
+
const dir = uploadedDir(configDir);
|
|
240
|
+
if (!existsSync(dir))
|
|
241
|
+
return [];
|
|
242
|
+
return readdirSync(dir)
|
|
243
|
+
.filter((f) => f.endsWith('.json'))
|
|
244
|
+
.map((f) => {
|
|
245
|
+
const dirName = f.replace(/\.json$/, '');
|
|
246
|
+
const state = getUploadedState(dirName, configDir);
|
|
247
|
+
return state ? { dirName, state } : null;
|
|
248
|
+
})
|
|
249
|
+
.filter((x) => x !== null);
|
|
250
|
+
}
|
|
238
251
|
const PORTFOLIO_PUBLISH_FILE = 'portfolio-publish.json';
|
|
239
252
|
const DEFAULT_PORTFOLIO_TARGET = 'heyi.am';
|
|
240
253
|
function portfolioPublishPath(configDir = getDataDir()) {
|