diff --git a/apps/detail-photocraft/apps/photocraft/src/jiansun_projects.rs b/apps/detail-photocraft/apps/photocraft/src/jiansun_projects.rs index 22ef3822..5922a023 100644 --- a/apps/detail-photocraft/apps/photocraft/src/jiansun_projects.rs +++ b/apps/detail-photocraft/apps/photocraft/src/jiansun_projects.rs @@ -18,7 +18,53 @@ fn local_path(base: &Path, id: &str) -> R { Ok(path) } +/// Native generation has a second barrier after a successful GET: its result may +/// still be staged rather than applied to the user's canonical canvas. +pub(super) fn creation_barriers(dir: &Path) -> R> { + if !dir.join("creation.json").exists() { return Ok(vec![]); } + let creation = super::creation::load(dir)?; + let generation = &creation["generation"]; + if generation.is_null() { return Ok(vec![]); } + if !generation.is_object() { return Err("生成恢复记录无效,请保留原项目核查".into()); } + let mut barriers = Vec::new(); + let pending = &generation["pending"]; + if !pending.is_null() { + if !pending.is_object() { return Err("原图片待处理记录无效,请保留原项目核查".into()); } + let id = match pending.get("taskId").filter(|id| !id.is_null()) { + Some(id) => id.as_str().filter(|id| Uuid::parse_str(id).is_ok()).ok_or("原图片任务身份无效,请保留原项目核查")?, + // Legacy/pre-dispatch journals have no task ID yet. This marker does + // not invent an upstream identity and remains stable in cloud drafts. + None => "creation-pending", + }; + barriers.push(json!({"id":id,"role":"assistant","text":"页面生成待处理,原任务与结果已保留","pending":true,"unknown":false})); + } + let recovery = &generation["recovery"]; + if !recovery.is_null() { + if !recovery.is_object() { return Err("原图片恢复记录无效,请保留原项目核查".into()); } + let id = recovery["taskId"].as_str().filter(|id| Uuid::parse_str(id).is_ok()).ok_or("原图片恢复身份无效,请保留原项目核查")?; + match recovery["applied"].as_bool() { + Some(true) => {}, // Older successful native recoveries may retain phase=complete. + Some(false) => { + let task = read_json(&dir.join("image-jobs").join(id).join("task.json"))?; + if task["id"] != id { return Err("原图片恢复任务不一致,请保留原项目核查".into()); } + let phase = task["phase"].as_str().filter(|phase| matches!(*phase, "prepared" | "dispatched" | "running" | "unknown" | "complete" | "applied" | "failed" | "rejected")).ok_or("原图片恢复状态无效,请保留原项目核查")?; + // The existing explicit image-applied acknowledgement also closes + // a conflict staged in creation.json; never mistake another task's ACK. + if phase != "applied" { + let unknown = matches!(phase, "dispatched" | "running" | "unknown"); + barriers.push(json!({"id":id,"role":"assistant","text":if unknown {"原请求待核查,未重发"} else {"原图片已保留,尚待应用到画布"},"pending":true,"unknown":unknown})); + } + }, + None => return Err("原图片应用状态无效,请保留原项目核查".into()), + } + } + Ok(barriers) +} + pub(super) fn idle(dir: &Path) -> R<()> { + if !creation_barriers(dir)?.is_empty() { + return Err("项目有待处理的生成任务或待应用结果,请先处理原任务;不能完结或删除".into()); + } if let (Some(account), Some(id)) = (dir.parent().and_then(Path::parent), dir.file_name()) && !super::pi::unresolved(&account.join("pi-validation").join(id).join("requests"))?.is_empty() { return Err("项目有Pi请求待核查,不能删除或绕过原请求".into()); @@ -242,6 +288,70 @@ impl Backend { #[cfg(test)] mod tests { use super::*; + fn creation_barrier_fixture() -> (PathBuf, PathBuf, Value, Value) { + let account = std::env::temp_dir().join(format!("creation-lifecycle-{}", Uuid::new_v4())); + let dir = account.join("projects").join(Uuid::new_v4().to_string()); + let task = json!({"id":Uuid::new_v4().to_string(),"phase":"complete"}); + write_json(&dir.join("context.json"), &json!({"revision":7,"status":"draft"})).unwrap(); + write_json(&dir.join("image-task.json"), &task).unwrap(); + write_json(&dir.join("image-jobs").join(task["id"].as_str().unwrap()).join("task.json"), &task).unwrap(); + fs::write(dir.join("canvas.pcraft"), b"unchanged-canonical-canvas").unwrap(); + let c = json!({"version":1,"revision":3,"generation":{"pending":null,"recovery":null}}); + (account, dir, c, task) + } + #[test] + fn generation_pending_and_unapplied_conflicts_block_the_shared_lifecycle_guard() { + let (account, dir, original, task) = creation_barrier_fixture(); + for conflict in [false, true] { + let mut c = original.clone(); + if conflict { c["generation"]["recovery"] = json!({"taskId":task["id"],"applied":false,"state":"conflict"}); } + else { c["generation"]["pending"] = json!({"taskId":task["id"],"sequence":1}); } + write_json(&dir.join("creation.json"), &c).unwrap(); + assert!(idle(&dir).unwrap_err().contains("待应用")); + assert!(trash_local(&account, dir.file_name().unwrap().to_str().unwrap()).unwrap_err().contains("待应用")); + assert_eq!(fs::read(dir.join("canvas.pcraft")).unwrap(), b"unchanged-canonical-canvas"); + assert!(!account.join("trash").exists()); + } + fs::remove_dir_all(account).unwrap(); + } + #[test] + fn applied_and_ordinary_historical_complete_results_do_not_block_lifecycle() { + let (account, dir, mut c, mut task) = creation_barrier_fixture(); + write_json(&dir.join("creation.json"), &c).unwrap(); + assert!(idle(&dir).is_ok(), "an ordinary historical complete task is not an unapplied recovery"); + c["generation"]["recovery"] = json!({"taskId":task["id"],"applied":true}); + write_json(&dir.join("creation.json"), &c).unwrap(); + assert!(idle(&dir).is_ok(), "old applied creation receipts may still have archive phase complete"); + c["generation"]["recovery"]["applied"] = json!(false); + task["phase"] = json!("applied"); + write_json(&dir.join("creation.json"), &c).unwrap(); + write_json(&dir.join("image-jobs").join(task["id"].as_str().unwrap()).join("task.json"), &task).unwrap(); + assert!(idle(&dir).is_ok(), "the original task's explicit manual ACK releases a staged conflict"); + trash_local(&account, dir.file_name().unwrap().to_str().unwrap()).unwrap(); + assert!(!dir.exists()); + assert_eq!(fs::read(account.join("trash").join(dir.file_name().unwrap()).join("canvas.pcraft")).unwrap(), b"unchanged-canonical-canvas"); + fs::remove_dir_all(account).unwrap(); + } + #[test] + fn corrupt_creation_barriers_default_deny_without_touching_the_project() { + let (account, dir, original, task) = creation_barrier_fixture(); + for malformed in [json!({"version":2}), json!({"version":1,"generation":[]}), json!({"version":1,"generation":{"pending":[]}}), json!({"version":1,"generation":{"pending":{"taskId":"../other"}}}), json!({"version":1,"generation":{"recovery":[]}}), json!({"version":1,"generation":{"recovery":{"taskId":task["id"]}}}), json!({"version":1,"generation":{"recovery":{"taskId":"../other","applied":false}}})] { + write_json(&dir.join("creation.json"), &malformed).unwrap(); + assert!(idle(&dir).is_err()); + } + let mut c = original; + c["generation"]["recovery"] = json!({"taskId":task["id"],"applied":false}); + write_json(&dir.join("creation.json"), &c).unwrap(); + let file = dir.join("image-jobs").join(task["id"].as_str().unwrap()).join("task.json"); + write_json(&file, &json!({"id":Uuid::new_v4().to_string(),"phase":"applied"})).unwrap(); + assert!(idle(&dir).is_err(), "another task's ACK cannot clear this barrier"); + write_json(&file, &json!({"id":task["id"],"phase":"invalid"})).unwrap(); + assert!(idle(&dir).is_err()); + fs::remove_file(file).unwrap(); + assert!(idle(&dir).is_err()); + assert_eq!(fs::read(dir.join("canvas.pcraft")).unwrap(), b"unchanged-canonical-canvas"); + fs::remove_dir_all(account).unwrap(); + } fn fixture() -> Backend { let mut b = Backend::new(std::env::temp_dir().join(format!("jiansun-projects-{}", Uuid::new_v4()))).unwrap(); b.auth = json!({"account":{"id":"local-fixture"}}); diff --git a/apps/detail-photocraft/apps/photocraft/src/jiansun_sync.rs b/apps/detail-photocraft/apps/photocraft/src/jiansun_sync.rs index ff675b02..7739458f 100644 --- a/apps/detail-photocraft/apps/photocraft/src/jiansun_sync.rs +++ b/apps/detail-photocraft/apps/photocraft/src/jiansun_sync.rs @@ -233,6 +233,14 @@ fn recovery_summary(dir: &Path, requests: &Path) -> R> { } } } + for barrier in super::projects::creation_barriers(dir)? { + if let Some(existing) = entries.iter_mut().find(|entry| entry["id"] == barrier["id"]) { + existing["pending"] = json!(true); + if barrier["unknown"] == true { existing["unknown"] = json!(true); } + } else { + entries.push(barrier); + } + } if requests.exists() { for task in super::pi::unresolved(requests)? { entries.push(json!({"id":task["id"],"role":"assistant","text":"原请求待核查,未重发","unknown":true,"pending":false})); @@ -654,6 +662,44 @@ impl Backend { #[cfg(test)] mod tests { use super::*; + #[test] + fn draft_recovery_summary_preserves_unapplied_identity_without_claiming_unknown() { + let dir = std::env::temp_dir().join(format!("draft-unapplied-summary-{}", Uuid::new_v4())); + let id = Uuid::new_v4().to_string(); + let task = json!({"id":id,"phase":"complete"}); + write_json(&dir.join("image-task.json"), &task).unwrap(); + let archive = dir.join("image-jobs").join(&id).join("task.json"); + write_json(&archive, &task).unwrap(); + let requests = dir.join("requests"); + let mut c = json!({"version":1,"generation":{"pending":null,"recovery":{"taskId":id,"applied":false,"state":"conflict"}}}); + write_json(&dir.join("creation.json"), &c).unwrap(); + let summary = recovery_summary(&dir, &requests).unwrap(); + assert_eq!(summary.len(), 1); + assert_eq!(summary[0]["id"], id); + assert_eq!(summary[0]["pending"], true); + assert_eq!(summary[0]["unknown"], false); + assert!(summary[0]["text"].as_str().unwrap().contains("尚待应用")); + let mut unknown = task.clone(); unknown["phase"] = json!("unknown"); + write_json(&archive, &unknown).unwrap(); write_json(&dir.join("image-task.json"), &unknown).unwrap(); + let summary = recovery_summary(&dir, &requests).unwrap(); + assert_eq!(summary.len(), 1); + assert_eq!(summary[0]["id"], id); + assert_eq!(summary[0]["unknown"], true, "a genuine unknown task must never become a known result barrier"); + assert_eq!(summary[0]["pending"], true); + write_json(&archive, &json!({"id":id,"phase":"applied"})).unwrap(); + write_json(&dir.join("image-task.json"), &json!({"id":id,"phase":"applied"})).unwrap(); + assert!(recovery_summary(&dir, &requests).unwrap().is_empty()); + c["generation"]["recovery"] = Value::Null; + c["generation"]["pending"] = json!({"sequence":1,"previousTaskId":Uuid::new_v4().to_string()}); + write_json(&dir.join("creation.json"), &c).unwrap(); + let summary = recovery_summary(&dir, &requests).unwrap(); + assert_eq!(summary[0]["id"], "creation-pending"); + assert_eq!(summary[0]["pending"], true); assert_eq!(summary[0]["unknown"], false); + c["generation"]["pending"]["taskId"] = json!("../invalid"); + write_json(&dir.join("creation.json"), &c).unwrap(); + assert!(recovery_summary(&dir, &requests).is_err()); + fs::remove_dir_all(dir).unwrap(); + } fn fixture(origin: &str) -> Backend { let mut b = Backend::new(std::env::temp_dir().join(format!("native-sync-{}", Uuid::new_v4()))).unwrap(); b.origin = origin.into(); diff --git a/components/detail-generator/DetailProjectBrowser.test.tsx b/components/detail-generator/DetailProjectBrowser.test.tsx index d297be6f..aabb7fa2 100644 --- a/components/detail-generator/DetailProjectBrowser.test.tsx +++ b/components/detail-generator/DetailProjectBrowser.test.tsx @@ -57,3 +57,43 @@ test('native project cards and read-only pages use the uploaded first-screen pre expect(screen.queryByText('只读:true')).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: /生成|编辑|保存/ })).not.toBeInTheDocument() }) + +test.each([['sku', 'SKU'], ['spu', 'SPU'], ['barcode', '条码']])('native %s drafts show saved product details before any AI analysis', async (identifierType, label) => { + const brief = { name: '顾家长桌', brandName: '顾家', identifierType, identifier: 'GBGJ-CYJ0056', colorName: '胡桃木色', source: 'library', referenceImageCount: 3, dimensions: { length: '1200', width: '600', height: '750', unit: 'mm' }, note: '' } + ;(global.fetch as jest.Mock).mockImplementation((url: string) => url === '/api/detail-projects' ? reply({ items: [item], nextCursor: null }) : reply({ source: 'desktop', project: { id: 'one', document: { version: 3, screens: [], brief, facts: { items: [] }, sellingPoints: { batches: [] } } }, assets: [] })) + render() + fireEvent.click(await screen.findByRole('button', { name: '查看 顾家电脑桌' })) + const summary = await screen.findByText('产品资料与卖点') + expect(summary).toHaveClass('min-h-11') + expect(summary.parentElement).not.toHaveAttribute('open') + fireEvent.click(summary) + const info = within(summary.parentElement!) + for (const text of ['顾家长桌', '顾家', label, 'GBGJ-CYJ0056', '胡桃木色', '长 1200 × 宽 600 × 高 750 毫米', '素材库', '3 张']) expect(info.getByText(text)).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /新建|生成|编辑|保存/ })).not.toBeInTheDocument() + expect((global.fetch as jest.Mock).mock.calls.every(([, init]) => !init?.method)).toBe(true) +}) + +test('complete native projects retain fact and selling-point text alongside partial saved dimensions', async () => { + ;(global.fetch as jest.Mock).mockImplementation((url: string) => url === '/api/detail-projects' ? reply({ items: [item], nextCursor: null }) : reply({ source: 'desktop', project: { id: 'one', document: { version: 3, screens: [], brief: { name: '电脑桌', source: 'upload', dimensions: { length: '120', width: '', height: '', unit: 'cm' } }, facts: { items: [{ text: '四条支腿' }] }, sellingPoints: { batches: [{ items: ['四腿支撑结构'] }] } } }, assets: [] })) + render() + fireEvent.click(await screen.findByRole('button', { name: '查看 顾家电脑桌' })) + expect(await screen.findByText('长 120 厘米')).toBeInTheDocument() + for (const text of ['上传产品资料', '四条支腿', '四腿支撑结构']) expect(screen.getByText(text)).toBeInTheDocument() + for (const label of ['品牌', '颜色', 'SKU', 'SPU', '条码', '参考图片']) expect(screen.queryByText(label)).not.toBeInTheDocument() +}) + +test('legacy Web product fields and free-text dimensions remain readable without a native brief', async () => { + const document = { screens: [], inputSource: 'new', brand: { name: '朴美' }, product: { name: '新款边桌', identifierType: 'barcode', identifier: '6901234567890', facts: '圆形台面', specifications: { color: '橡木色', dimensions: '宽120cm,深度待定' }, assetIds: ['product-one', 'product-two'], referenceAssetIds: ['scene'] }, analysis: { sellingPoints: ['简洁圆形台面'] } } + ;(global.fetch as jest.Mock).mockImplementation((url: string) => url === '/api/detail-projects' ? reply({ items: [item], nextCursor: null }) : reply({ source: 'web', project: { id: 'one', document }, assets: [] })) + render() + fireEvent.click(await screen.findByRole('button', { name: '查看 顾家电脑桌' })) + for (const text of ['新款边桌', '朴美', '条码', '6901234567890', '橡木色', '宽120cm,深度待定', '上传产品资料', '3 张', '圆形台面', '简洁圆形台面']) expect(await screen.findByText(text)).toBeInTheDocument() +}) + +test('empty product details omit the disclosure and never invent source, dimensions or image counts', async () => { + ;(global.fetch as jest.Mock).mockImplementation((url: string) => url === '/api/detail-projects' ? reply({ items: [item], nextCursor: null }) : reply({ source: 'desktop', project: { id: 'one', document: { version: 3, screens: [], brief: { name: ' ', source: 'unknown', referenceImageCount: -1, dimensions: { length: '', width: '', height: '', unit: 'mm' } } } }, assets: [] })) + render() + fireEvent.click(await screen.findByRole('button', { name: '查看 顾家电脑桌' })) + expect(await screen.findByText('草稿尚未生成页面,已保存资料可在客户端继续处理。')).toBeInTheDocument() + expect(screen.queryByText('产品资料与卖点')).not.toBeInTheDocument() +}) diff --git a/components/detail-generator/DetailProjectBrowser.tsx b/components/detail-generator/DetailProjectBrowser.tsx index a42719e2..70a2e9da 100644 --- a/components/detail-generator/DetailProjectBrowser.tsx +++ b/components/detail-generator/DetailProjectBrowser.tsx @@ -17,6 +17,29 @@ const control = 'inline-flex min-h-11 min-w-11 items-center justify-center gap-2 const noop = () => {} const previewUrl = (project: Pick, source: string) => endpoint(project) + '/assets/' + encodeURIComponent(source) +function productDetails(document: Record) { + const brief = document.brief || {}, product = document.product || {}, specifications = product.specifications || {} + const text = (value: unknown) => typeof value === 'string' ? value.trim() : '' + const size = brief.dimensions || specifications.size + const unit = ({ mm: '毫米', cm: '厘米', m: '米' } as Record)[size?.unit] || '' + const dimensions = size && ['length', 'width', 'height'].map((key, index) => { + const value = text(size[key]) + return value ? `${['长', '宽', '高'][index]} ${value}` : '' + }).filter(Boolean).join(' × ') + const identifierType = brief.identifierType || product.identifierType + const source = brief.source || document.inputSource + const count = Number.isInteger(brief.referenceImageCount) ? brief.referenceImageCount : Array.isArray(product.assetIds) ? product.assetIds.length + (Array.isArray(product.referenceAssetIds) ? product.referenceAssetIds.length : 0) : undefined + return [ + ['产品名称', text(brief.name) || text(product.name)], + ['品牌', text(brief.brandName) || text(document.brand?.name)], + [({ sku: 'SKU', spu: 'SPU', barcode: '条码' } as Record)[identifierType] || '产品编号', text(brief.identifier) || text(product.identifier)], + ['颜色', text(brief.colorName) || text(specifications.color)], + ['尺寸', dimensions ? `${dimensions}${unit ? ` ${unit}` : ''}` : text(specifications.dimensions)], + ['资料来源', ({ library: '素材库', upload: '上传产品资料', new: '上传产品资料' } as Record)[source]], + ['参考图片', typeof count === 'number' && count > 0 ? `${count} 张` : ''], + ].filter((entry): entry is [string, string] => !!entry[1]) +} + function NativePreview({ detail, screen }: { detail: Detail; screen: Record }) { const [failed, setFailed] = useState(false), [loading, setLoading] = useState(true) const source = screen.previewSource @@ -105,6 +128,7 @@ export default function DetailProjectBrowser() { const shown = items.filter(p => (p.name || '').toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())) const screens = detail?.project.document.screens || [] const screen = screens.find((s: any) => s.id === screenId) || screens[0] + const productInfo = useMemo(() => detail ? productDetails(detail.project.document) : [], [detail]) const notes = useMemo(() => { const d = detail?.project.document if (!d) return [] @@ -136,7 +160,10 @@ export default function DetailProjectBrowser() {

{detail?.project.document.name || selected.name}

{opening &&

正在打开项目…

} {detail && <> - {!!notes.length &&
产品资料与卖点
{notes.map((note: string, i: number) =>

{note}

)}
} + {!!(productInfo.length || notes.length) &&
产品资料与卖点 + {!!productInfo.length &&
{productInfo.map(([label, value]) =>
{label}
{value}
)}
} + {!!notes.length &&
{notes.map((note: string, i: number) =>

{note}

)}
} +
} {!!screens.length &&
{screens.map((s: any, i: number) => )}
} {screen ?
{detail.project.document.version === 3 ? : }
:

草稿尚未生成页面,已保存资料可在客户端继续处理。

} } diff --git a/lib/__tests__/desktop-project-viewer.test.ts b/lib/__tests__/desktop-project-viewer.test.ts index 754cfcf7..ed0ce845 100644 --- a/lib/__tests__/desktop-project-viewer.test.ts +++ b/lib/__tests__/desktop-project-viewer.test.ts @@ -35,6 +35,26 @@ test('native projects expose only rendered previews and creation summaries, neve expect(value.assets).toEqual([{ source: 'local:preview', mime: 'image/png' }]) for (const privateValue of ['private-session','private-archive','session-backup.json','storageKey']) expect(text).not.toContain(privateValue) }) + +test('native preparation-only drafts expose safe product metadata without leaking reference image objects', async () => { + const brief = { name: '电脑桌', brandName: '顾家', identifierType: 'sku', identifier: 'GBGJ-CYJ0056', colorName: '胡桃木色', dimensions: { length: '1200', width: '600', height: '750', unit: 'mm' }, source: 'library', note: '', images: [{ source: 'local:private-product-path', purpose: 'product' }, { source: 'local:private-product-two', purpose: 'product' }, { source: 'local:private-scene', purpose: 'scene' }], privateField: 'internal-reference-detail' } + ;(getDesktopProject as jest.Mock).mockResolvedValue({ source: 'desktop', project: { id: 'p', revision: 1, status: 'draft', document: { version: 3, name: '新项目', screens: [], creation: { brief } } }, assets: [] }) + const response = await projectViewerRoute(new Request('https://sc.jiansun.vip/api/detail-projects/desktop/p'), ['desktop', 'p']) + const value = await response.json() + expect(value.project.document.brief).toEqual({ name: brief.name, brandName: brief.brandName, identifierType: brief.identifierType, identifier: brief.identifier, colorName: brief.colorName, dimensions: brief.dimensions, source: 'library', referenceImageCount: 3, note: '' }) + const text = JSON.stringify(value) + for (const privateValue of ['local:private', 'purpose', 'images', 'privateField', 'internal-reference-detail']) expect(text).not.toContain(privateValue) +}) + +test('older desktop and Web projects retain readable metadata without inventing unsupported source fields', async () => { + ;(getDesktopProject as jest.Mock).mockResolvedValue({ source: 'desktop', project: { id: 'p', revision: 1, document: { version: 2, screens: [], brief: { name: '旧桌面工程', source: 'upload', identifierType: 'barcode', identifier: '6901234567890', images: [{ source: 'local:private' }] } } }, assets: [] }) + const desktop = await projectViewerRoute(new Request('https://sc.jiansun.vip/api/detail-projects/desktop/p'), ['desktop', 'p']) + expect((await desktop.json()).project.document.brief).toEqual({ name: '旧桌面工程', source: 'upload', identifierType: 'barcode', identifier: '6901234567890', referenceImageCount: 1 }) + const product = { name: '旧网页工程', identifierType: 'spu', identifier: 'SPU-1', assetIds: ['image-one'], specifications: { color: '橡木色', dimensions: '1200×600' } } + ;(getDesktopProject as jest.Mock).mockResolvedValue({ source: 'web', project: { id: 'p', document: { screens: [], inputSource: 'library', product } }, assets: [] }) + const web = await projectViewerRoute(new Request('https://sc.jiansun.vip/api/detail-projects/web/p'), ['web', 'p']) + expect((await web.json()).project.document).toEqual({ screens: [], inputSource: 'library', product }) +}) test('browser deletion requires an allowed origin and sends the exact confirmation revision once', async () => { const url = 'https://sc.jiansun.vip/api/detail-projects/desktop/p' ;(deleteDesktopProject as jest.Mock).mockResolvedValue({ deleted: true }) diff --git a/lib/desktop-client/project-viewer.ts b/lib/desktop-client/project-viewer.ts index a392bd60..3432e0f5 100644 --- a/lib/desktop-client/project-viewer.ts +++ b/lib/desktop-client/project-viewer.ts @@ -6,6 +6,13 @@ import { desktopAssert, DesktopClientError } from './contract.mjs' import { deleteDesktopProject, desktopUser, getDesktopAsset, getDesktopProject, listDesktopProjects } from './service' const headers = { 'Cache-Control': 'private, no-store', 'X-Content-Type-Options': 'nosniff' } +const viewerBrief = (brief: any) => brief ? { + name: brief.name, brandName: brief.brandName, identifier: brief.identifier, colorName: brief.colorName, + dimensions: brief.dimensions, note: brief.note, + ...(['upload', 'library'].includes(brief.source) ? { source: brief.source } : {}), + ...(['sku', 'spu', 'barcode'].includes(brief.identifierType) ? { identifierType: brief.identifierType } : {}), + ...(Array.isArray(brief.images) ? { referenceImageCount: brief.images.length } : {}), +} : undefined /** Cookie-authenticated viewing. Desktop write tokens are never issued to this page. */ export async function projectViewerRoute(request: Request, parts: string[] = []) { try { @@ -22,9 +29,10 @@ export async function projectViewerRoute(request: Request, parts: string[] = []) // Do not expose storage keys, model bindings, provider diagnostics or task internals. const d = value.project.document const document = d.version === 3 ? { version: 3, name: d.name, screens: d.screens.map((screen: any) => ({ id: screen.id, name: screen.name, width: screen.width, height: screen.height, previewSource: screen.previewSource })), - brief: d.creation?.brief ? { name: d.creation.brief.name, brandName: d.creation.brief.brandName, identifier: d.creation.brief.identifier, colorName: d.creation.brief.colorName, dimensions: d.creation.brief.dimensions, note: d.creation.brief.note } : undefined, + brief: viewerBrief(d.creation?.brief), facts: d.creation?.facts, sellingPoints: d.creation?.sellingPoints, plan: d.creation?.plan } : - { name: d.name, screens: d.screens || [], product: d.product, brief: d.brief, brand: d.brand ? { name: d.brand.name } : undefined, + { name: d.name, screens: d.screens || [], product: d.product, brief: viewerBrief(d.brief), brand: d.brand ? { name: d.brand.name } : undefined, + ...(['library', 'new'].includes(d.inputSource) ? { inputSource: d.inputSource } : {}), analysis: d.analysis, planning: d.planning, facts: d.facts, sellingPoints: d.sellingPoints, plan: d.plan } return NextResponse.json({ source: value.source, project: { id: value.project.id, revision: value.project.revision, status: 'status' in value.project ? value.project.status : 'draft', updatedAt: value.project.updatedAt, document }, assets: value.assets.filter((a: any) => ['image/png','image/jpeg','image/webp'].includes(a.mime)).map((a: any) => ({ source: a.source, mime: a.mime })) }, { headers }) }