feat: 작업실 대규모 업그레이드 — 이력/충돌감지/도구 확장/표·게이지·진행률/출력 감시 외

안전망
- 변경 이력: 저장 때마다 바뀌기 전 상태를 프로젝트당 30개까지 보존(45초 합치기), 대시보드 "🕘 이력"에서
  되돌리기, 삭제된 프로젝트 복구(첨부파일 제외). 되돌리기 직전 상태도 이력에 남는다
- 낙관적 락: 프로젝트 updatedAt으로 다른 탭/채팅 도구가 먼저 고친 프로젝트 저장을 409로 보류하고
  충돌 배너(내 변경으로 덮어쓰기/서버 버전 불러오기) 제공. 15초 폴링으로 외부 변경 반영(편집 중엔 미룸)
- 명령 주입 수정: resolveToolImageContent가 파일명을 셸에 그대로 넘기던 execSync → execFileSync

채팅 도구(workshop_project)
- set_notes(메모 전체 교체), update_task/delete_task, rename/delete_phase, add_parts(일괄), rename/
  archive/unarchive/duplicate/delete_project(confirm 필수), set_budget, check_price(다나와),
  list_history/restore_history, view_image(첨부 사진을 모델이 직접 봄)
- 프롬프트에 개요·진행률·남은 작업·예산 현황 주입

대시보드
- 부품 표 보기·상태 필터·정렬, CSV/MD 내보내기, 목표 예산 게이지, 작업 진행률·기한(지남/임박),
  프로젝트 복제(템플릿)/보관, 부품 시세 조회, STL 썸네일(OpenSCAD, 캐시), 채팅 사진 첨부(📷)

장비 연동
- print3d_model start 시 작업실 프로젝트/작업을 등록 → K2 출력 완료/실패를 감시해 작업 자동 완료 +
  메모 기록 + 텔레그램 알림(workshop-print-watch.ts, job 없으면 폴링 안 함)

테스트: 서버/도구/감시/프런트 vm 시뮬레이션 + 라우트 통합(HTTP) + 실브라우저 수동 검증 하네스(tests/manual)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-09-24 11:53:21 +09:00
co-authored by Claude Sonnet 5
parent 8e9226e117
commit 37630be619
14 changed files with 1920 additions and 52 deletions
+1 -1
View File
@@ -1415,7 +1415,7 @@ print(json.dumps({'slides': slides, 'total': len(prs.slides)}, ensure_ascii=Fals
// browser/desktop, memory, workflow, presentation) keep their own branches.
const tool = getToolRegistry().get(name);
if (!tool) return { name, args, result: `Unknown tool: ${name}`, error: true };
const tr = await tool.execute({ ...args, _workspace: workspacePath, _workspacePath: workspacePath, ...(name === 'workshop_project' ? { _sessionId: sessionId } : {}) });
const tr = await tool.execute({ ...args, _workspace: workspacePath, _workspacePath: workspacePath, ...(name === 'workshop_project' || name === 'print3d_model' ? { _sessionId: sessionId } : {}) });
const _resultText = tr.stdout || tr.error || '';
const _hasImageMd = /!\[[^\]]*\]\(\/api\/files\/[^)]+\)/.test(_resultText);
// 작업실(ws_) 세션 크로스 프로젝트 경고 — list/python_eval 등 default 분기로 오는 모든
+29 -4
View File
@@ -326,6 +326,30 @@ async function handleChat(
partsBlock = '\n\n프로젝트에 아직 등록된 부품이 없다(빈 BOM).';
}
} catch { /* noop */ }
// 개요·진행 상황·예산 요약도 주입한다 — "지금 뭐가 남았어?/예산 얼마 남았어?"를 도구 왕복
// 없이 정확히 답하게(BOM 주입과 같은 이유). 길이는 상한을 둬서 프롬프트 비용을 묶는다.
let statusBlock = '';
try {
const bits: string[] = [];
const desc = String(proj.description || '').trim();
if (desc) bits.push(`개요: ${desc.length > 500 ? desc.slice(0, 500) + '…(이하 생략, 전체는 workshop_project get)' : desc}`);
const allTasks: { phase: string; text: string; done: boolean; due?: string }[] = [];
for (const ph of (Array.isArray(proj.phases) ? proj.phases : [])) {
for (const t of (Array.isArray(ph.tasks) ? ph.tasks : [])) allTasks.push({ phase: String(ph.name), text: String(t.text), done: !!t.done, due: t.due });
}
if (allTasks.length) {
const done = allTasks.filter(t => t.done).length;
const open = allTasks.filter(t => !t.done).slice(0, 12);
bits.push(`작업 진행 ${done}/${allTasks.length} (${Math.round(done / allTasks.length * 100)}%). 남은 작업${allTasks.length - done > open.length ? `(앞 ${open.length}개)` : ''}: ` +
open.map(t => `[${t.phase}] ${t.text}${t.due ? ` (기한 ${t.due})` : ''}`).join(' / '));
}
const budget = Number(proj.budget) || 0;
if (budget > 0) {
const total = (Array.isArray(proj.parts) ? proj.parts : []).reduce((s: number, p: any) => s + (Number(p.qty) || 0) * (Number(p.unitPrice) || 0), 0);
bits.push(`목표 예산 ${budget.toLocaleString()}원, 총 예상비용 ${total.toLocaleString()}원(${Math.round(total / budget * 100)}%)`);
}
if (bits.length) statusBlock = '\n\n프로젝트 현황(대시보드 실제 데이터):\n' + bits.join('\n');
} catch { /* noop */ }
return `[작업실(workshop) 세션 — 지금 사용자가 보고 있는 프로젝트: "${proj.name}" (id: ${proj.id}). ` +
`이 프로젝트의 파일(STL/scad/사진 등)은 워크스페이스 기준 workshop/${proj.id}/ 아래에만 있다. ` +
`파일을 찾거나 만들 때 이 프로젝트 폴더만 보고, 다른 프로젝트 폴더(workshop/<다른id>/)는 ` +
@@ -333,8 +357,9 @@ async function handleChat(
`도구 없이는 대시보드에 아무것도 반영되지 않는다 — 메모 저장/부품 추가/작업 체크 같은 ` +
`모든 저장 액션은 이번 턴에 workshop_project를 실제로 호출했을 때만 성립하므로, ` +
`호출 없이 "저장했습니다/등록했습니다"라고 보고하지 말 것.` +
`사용자 메시지에 "[첨부 사진: 경로]"가 있으면 workshop_project view_image(file_name=그 경로)로 사진을 먼저 보고 답할 것. ` +
`먼저 호출하고 그 결과로 보고할 것(2026-09-24 실측: 도구 0건인데 "메모탭에 저장했다"고 ` +
`헛보고했다가 사용자가 다시 물어 실제 호출로 이어짐).${scadHint}${partsBlock}]`;
`헛보고했다가 사용자가 다시 물어 실제 호출로 이어짐).${scadHint}${partsBlock}${statusBlock}]`;
} catch { return ''; }
})();
const callerContextWithWorkshop = workshopProjectCtx
@@ -734,7 +759,7 @@ async function handleChat(
const toolMessageContent = (multiAgentActive && (isBrowserTool || isDesktopTool))
? (isBrowserTool ? buildBrowserAck(toolName, toolResult) : buildDesktopAck(toolName, toolResult))
: toolResult.result;
const _imagingTools3 = new Set(['image_edit', 'image_info', 'image_read', 'python_eval', 'weather_map_screenshot', 'nvr_snapshot']);
const _imagingTools3 = new Set(['image_edit', 'image_info', 'image_read', 'python_eval', 'weather_map_screenshot', 'nvr_snapshot', 'workshop_project']);
const _supportsVision3 = _modelSupportsVision(_activeModelName);
if (_imagingTools3.has(toolName)) console.log(`[v2] IMG-VISION tool=${toolName} embedVision=${_supportsVision3} model=${_activeModelName || primaryProvider}`);
const _resolvedToolContent3 = _imagingTools3.has(toolName) && typeof toolMessageContent === 'string'
@@ -1272,7 +1297,7 @@ async function handleChat(
const toolMessageContent = (multiAgentActive && (isBrowserTool || isDesktopTool))
? (isBrowserTool ? buildBrowserAck(toolName, toolResult) : buildDesktopAck(toolName, toolResult))
: toolResult.result;
const _imagingTools2 = new Set(['image_edit', 'image_info', 'image_read', 'python_eval', 'weather_map_screenshot', 'nvr_snapshot']);
const _imagingTools2 = new Set(['image_edit', 'image_info', 'image_read', 'python_eval', 'weather_map_screenshot', 'nvr_snapshot', 'workshop_project']);
const _supportsVision2 = _modelSupportsVision(_activeModelName);
if (_imagingTools2.has(toolName)) console.log(`[v2] IMG-VISION tool=${toolName} embedVision=${_supportsVision2} model=${_activeModelName || primaryProvider}`);
const _resolvedContent2 = _imagingTools2.has(toolName) && typeof toolMessageContent === 'string'
@@ -3194,7 +3219,7 @@ async function handleChat(
const toolMessageContent = (multiAgentActive && (isBrowserTool || isDesktopTool))
? (isBrowserTool ? buildBrowserAck(toolName, toolResult) : buildDesktopAck(toolName, toolResult))
: toolResult.result;
const _imagingTools = new Set(['image_edit', 'image_info', 'image_read', 'python_eval', 'weather_map_screenshot', 'nvr_snapshot']);
const _imagingTools = new Set(['image_edit', 'image_info', 'image_read', 'python_eval', 'weather_map_screenshot', 'nvr_snapshot', 'workshop_project']);
const _supportsVision = _modelSupportsVision(_activeModelName);
if (_imagingTools.has(toolName)) console.log(`[v2] IMG-VISION tool=${toolName} embedVision=${_supportsVision} model=${_activeModelName || primaryProvider}`);
const _resolvedToolContent = _imagingTools.has(toolName) && typeof toolMessageContent === 'string'
+5 -3
View File
@@ -186,9 +186,11 @@ export interface WorkshopSaveRetryInput {
export const WORKSHOP_MUTATING_ACTIONS = new Set([
'create_project', 'set_active_project', 'set_description',
'add_part', 'update_part', 'delete_part',
'add_task', 'set_task_done', 'add_phase',
'add_note', 'add_link', 'delete_link',
'add_part', 'add_parts', 'update_part', 'delete_part',
'add_task', 'update_task', 'delete_task', 'set_task_done', 'add_phase', 'rename_phase', 'delete_phase',
'add_note', 'set_notes', 'add_link', 'delete_link',
'rename_project', 'set_budget', 'archive_project', 'unarchive_project', 'duplicate_project', 'delete_project',
'restore_history',
]);
export function shouldForceWorkshopSaveRetry(input: WorkshopSaveRetryInput): boolean {
+142 -2
View File
@@ -13,8 +13,14 @@
import express from 'express';
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import { getUserWorkspace } from '../../config/config';
import { loadWorkshop, saveWorkshop, upsertProject, removeProject, normalizeProjectInput } from './workshop-storage';
import {
loadWorkshop, saveWorkshop, upsertProject, removeProject, normalizeProjectInput, recordHistory, listHistory,
listDeletedProjects, restoreFromHistory, readHistory, duplicateProject, genWorkshopId,
} from './workshop-storage';
import { searchDanawa } from '../../tools/danawa';
import { renderStlPng } from '../../tools/stl-cad-core';
import { clearHistory } from '../session';
import { caseFilesDir, resolveUploadPath, sanitizePathSegment, walkCaseFiles, fileCategory } from './case-storage';
@@ -73,9 +79,18 @@ export function registerWorkshopRoutes(
if (!checked.ok) return res.status(400).json({ error: checked.error });
const workspace = getUserWorkspace(session.username);
const data = loadWorkshop(workspace);
// 충돌 감지(낙관적 락): 클라이언트가 본 updatedAt이 서버 현재 값과 다르면 그 사이 다른 곳(채팅
// 도구/다른 탭)이 이 프로젝트를 고친 것 — 조용히 덮지 않고 409로 서버 버전을 돌려준다.
// ?force=1이면 사용자가 "내 변경으로 덮어쓰기"를 명시한 경우.
const existing = data.projects.find(p => p.id === id);
const baseVersion = Number(checked.project.updatedAt) || 0;
if (existing && existing.updatedAt && baseVersion !== existing.updatedAt && req.query.force !== '1') {
return res.status(409).json({ error: 'conflict', server: existing });
}
upsertProject(data, checked.project);
saveWorkshop(workspace, data);
res.json({ success: true });
const saved = data.projects.find(p => p.id === id);
res.json({ success: true, updatedAt: saved?.updatedAt || 0 });
});
app.delete('/api/workshop/project/:id', (req, res) => {
@@ -85,6 +100,8 @@ export function registerWorkshopRoutes(
if (!/^[a-zA-Z0-9_-]+$/.test(id)) return res.status(400).json({ error: 'invalid projectId' });
const workspace = getUserWorkspace(session.username);
const data = loadWorkshop(workspace);
const doomed = data.projects.find(p => p.id === id);
if (doomed) recordHistory(workspace, doomed, true); // 삭제 후에도 이력에서 복구할 수 있게
if (!removeProject(data, id)) return res.status(404).json({ error: 'not found' });
saveWorkshop(workspace, data);
// 고아 정리: 첨부파일 폴더(workshop/<id>/)와 이 프로젝트 전용 채팅 세션(ws_<id>).
@@ -98,6 +115,129 @@ export function registerWorkshopRoutes(
res.json({ success: true, filesRemoved });
});
// ── 이력 / 복구 / 복제 ──────────────────────────────────────────────────────
app.get('/api/workshop/history/:id', (req, res) => {
const session = getSessionUser(req);
if (!session) return res.status(401).json({ error: 'Unauthorized' });
const id = String(req.params.id || '');
if (!/^[a-zA-Z0-9_-]+$/.test(id)) return res.status(400).json({ error: 'invalid projectId' });
res.json({ history: listHistory(getUserWorkspace(session.username), id) });
});
app.post('/api/workshop/history/:id/restore', (req, res) => {
const session = getSessionUser(req);
if (!session) return res.status(401).json({ error: 'Unauthorized' });
const id = String(req.params.id || '');
if (!/^[a-zA-Z0-9_-]+$/.test(id)) return res.status(400).json({ error: 'invalid projectId' });
const workspace = getUserWorkspace(session.username);
const data = loadWorkshop(workspace);
const restored = restoreFromHistory(workspace, data, id, Number(req.body?.ts));
if (!restored) return res.status(404).json({ error: 'history not found' });
saveWorkshop(workspace, data);
res.json({ success: true });
});
app.get('/api/workshop/trash', (req, res) => {
const session = getSessionUser(req);
if (!session) return res.status(401).json({ error: 'Unauthorized' });
const workspace = getUserWorkspace(session.username);
res.json({ deleted: listDeletedProjects(workspace, loadWorkshop(workspace)) });
});
// 삭제된 프로젝트의 마지막 이력으로 복구 — 첨부파일은 삭제 때 함께 지워져 돌아오지 않는다.
app.post('/api/workshop/trash/:id/restore', (req, res) => {
const session = getSessionUser(req);
if (!session) return res.status(401).json({ error: 'Unauthorized' });
const id = String(req.params.id || '');
if (!/^[a-zA-Z0-9_-]+$/.test(id)) return res.status(400).json({ error: 'invalid projectId' });
const workspace = getUserWorkspace(session.username);
const data = loadWorkshop(workspace);
if (data.projects.some(p => p.id === id)) return res.status(409).json({ error: 'already exists' });
const latest = listDeletedProjects(workspace, data).find(d => d.id === id);
const snap = latest && readHistory(workspace, id, latest.ts);
if (!snap) return res.status(404).json({ error: 'not found' });
upsertProject(data, { ...snap, id, archived: false });
saveWorkshop(workspace, data);
res.json({ success: true, name: snap.name });
});
app.post('/api/workshop/project/:id/duplicate', (req, res) => {
const session = getSessionUser(req);
if (!session) return res.status(401).json({ error: 'Unauthorized' });
const id = String(req.params.id || '');
if (!/^[a-zA-Z0-9_-]+$/.test(id)) return res.status(400).json({ error: 'invalid projectId' });
const workspace = getUserWorkspace(session.username);
const data = loadWorkshop(workspace);
const src = data.projects.find(p => p.id === id);
if (!src) return res.status(404).json({ error: 'not found' });
const copy = duplicateProject(src, genWorkshopId('proj'));
let n = 2; const base = copy.name;
while (data.projects.some(p => p.name === copy.name)) copy.name = `${base} ${n++}`;
data.projects.push(copy);
data.activeProjectId = copy.id;
saveWorkshop(workspace, data);
res.json({ success: true, id: copy.id });
});
// ── 부품 시세(다나와) ────────────────────────────────────────────────────────
// 다나와는 PC 부품 위주라 전자부품/소모품은 결과가 없을 수 있다(그땐 items가 빈 배열).
// Crawl-delay 10초는 searchDanawa 내부 큐가 지킨다.
app.post('/api/workshop/part-price', async (req, res) => {
const session = getSessionUser(req);
if (!session) return res.status(401).json({ error: 'Unauthorized' });
const q = String(req.body?.name || '').trim().slice(0, 100);
if (!q) return res.status(400).json({ error: 'name required' });
const items = (await searchDanawa(q)).filter(i => i.price && i.price > 0);
const prices = items.map(i => i.price as number).sort((a, b) => a - b);
res.json({
query: q,
items: items.slice(0, 6).map(i => ({ name: i.name, price: i.price })),
min: prices[0] ?? null,
median: prices.length ? prices[Math.floor(prices.length / 2)] : null,
count: prices.length,
});
});
// ── STL 썸네일 ───────────────────────────────────────────────────────────────
// OpenSCAD로 렌더(stl_cad 도구와 같은 코어)해 캐시한다. 렌더가 무거워 한 번에 하나씩만 돌리고,
// 실패(ASCII STL/너무 큼/렌더 오류)는 .fail 마커로 기억해 재시도 폭주를 막는다 — 클라이언트는
// 404를 받으면 기존 🔧 아이콘으로 폴백한다.
let thumbQueue: Promise<unknown> = Promise.resolve();
app.get('/api/workshop/thumb', async (req, res) => {
const session = getSessionUser(req);
if (!session) return res.status(401).json({ error: 'Unauthorized' });
const projectId = String(req.query.projectId || '');
const relPath = String(req.query.relPath || '');
const segments = relPath.split('/').filter(Boolean);
if (!/^[a-zA-Z0-9_-]+$/.test(projectId) || !segments.length || segments.some(s => s === '..' || s === '.') || !/\.stl$/i.test(relPath)) {
return res.status(400).json({ error: 'invalid' });
}
const workspace = getUserWorkspace(session.username);
const dir = caseFilesDir(session.username, APP_TYPE, projectId);
const stlPath = path.join(dir, ...segments);
if (!isPathInsideDir(dir, stlPath) || !fs.existsSync(stlPath)) return res.status(404).json({ error: 'not found' });
const st = fs.statSync(stlPath);
if (st.size > 40 * 1024 * 1024) return res.status(404).json({ error: 'too large' });
const key = crypto.createHash('sha1').update(`${projectId}/${relPath}/${st.mtimeMs}/${st.size}`).digest('hex').slice(0, 24);
const cacheDir = path.join(workspace, '.smallclaw', 'workshop-thumbs');
const png = path.join(cacheDir, `${key}.png`);
const failMark = `${png}.fail`;
const send = () => { res.set('Cache-Control', 'private, max-age=86400'); res.type('png').sendFile(png, { dotfiles: 'allow' }); };
if (fs.existsSync(png)) return send();
if (fs.existsSync(failMark)) return res.status(404).json({ error: 'render failed' });
fs.mkdirSync(cacheDir, { recursive: true });
const job = thumbQueue.then(async () => {
if (fs.existsSync(png)) return true;
const r = await renderStlPng(stlPath, 'iso', png, 320).catch(() => ({ ok: false as const, detail: 'exception' }));
if (!r.ok) { try { fs.writeFileSync(failMark, ''); } catch { /* noop */ } return false; }
return true;
});
thumbQueue = job.catch(() => undefined);
const ok = await job;
if (ok && fs.existsSync(png)) return send();
res.status(404).json({ error: 'render failed' });
});
// 활성 프로젝트 포인터만 단독 저장 — 포인터는 값 하나라 last-write-wins로 충분하다.
app.put('/api/workshop/active', (req, res) => {
const session = getSessionUser(req);
+161 -2
View File
@@ -22,12 +22,15 @@ export interface WorkshopPart {
status: '검토중' | '주문완료' | '보유';
memo: string;
links: WorkshopPartLink[];
/** 마지막 시세 조회 결과(다나와) — 단가와 비교해 변동을 보여준다. */
priceCheck?: { min: number; median: number; count: number; at: number };
}
export interface WorkshopTask {
id: string;
text: string;
done: boolean;
due?: string; // YYYY-MM-DD 기한(선택)
}
export interface WorkshopPhase {
@@ -43,6 +46,12 @@ export interface WorkshopProject {
parts: WorkshopPart[];
phases: WorkshopPhase[];
notes: string;
/** 목표 예산(원). 0/없음이면 게이지를 안 그린다. */
budget?: number;
/** 보관됨 — 프로젝트 선택 목록에서 기본 숨김(삭제와 달리 데이터·파일 유지). */
archived?: boolean;
/** 저장 때마다 서버가 갱신하는 버전(ms). 클라이언트가 이 값을 기준으로 충돌을 감지한다. */
updatedAt?: number;
}
export interface WorkshopData {
@@ -84,6 +93,7 @@ export function normalizeProjectInput(incoming: any, id: string): { ok: true; pr
}
if (incoming.notes != null && typeof incoming.notes !== 'string') return { ok: false, error: 'notes must be string' };
if (incoming.description != null && typeof incoming.description !== 'string') return { ok: false, error: 'description must be string' };
if (incoming.budget != null && !(Number.isFinite(Number(incoming.budget)) && Number(incoming.budget) >= 0)) return { ok: false, error: 'budget must be non-negative number' };
for (const ph of incoming.phases) {
if (!ph || typeof ph !== 'object' || (ph.tasks != null && !Array.isArray(ph.tasks))) return { ok: false, error: 'phases[].tasks must be array' };
}
@@ -98,10 +108,16 @@ export function normalizeProjectInput(incoming: any, id: string): { ok: true; pr
id,
notes: incoming.notes ?? '',
description: incoming.description ?? '',
budget: Math.round(Number(incoming.budget) || 0),
archived: incoming.archived === true,
phases: incoming.phases.map((ph: any) => ({
...ph,
id: safeId(ph.id, 'ph'),
tasks: (ph.tasks ?? []).map((t: any) => ({ ...t, id: safeId(t?.id, 't') })),
tasks: (ph.tasks ?? []).map((t: any) => {
const task = { ...t, id: safeId(t?.id, 't') };
if (!(typeof task.due === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(task.due))) delete task.due; // 형식이 아니면 기한 없음
return task;
}),
})),
parts: incoming.parts.map((pt: any) => ({
...pt,
@@ -220,14 +236,151 @@ export function loadWorkshop(workspaceRoot: string): WorkshopData {
return defaultWorkshopData();
}
// 프로젝트 내용 비교용(updatedAt은 제외) — 실제로 바뀐 프로젝트만 버전을 올리고 이력에 남긴다.
function contentKey(p: WorkshopProject): string {
return JSON.stringify({ ...p, updatedAt: undefined });
}
function readPrevProjects(p: string): Map<string, WorkshopProject> {
const map = new Map<string, WorkshopProject>();
try {
if (fs.existsSync(p)) {
const prev = JSON.parse(fs.readFileSync(p, 'utf-8'));
for (const pr of Array.isArray(prev?.projects) ? prev.projects : []) if (pr?.id) map.set(pr.id, pr);
}
} catch { /* 이전 파일이 없거나 손상 — 비교 없이 저장 */ }
return map;
}
export function saveWorkshop(workspaceRoot: string, data: WorkshopData): void {
const p = workshopPath(workspaceRoot);
fs.mkdirSync(path.dirname(p), { recursive: true });
// 저장 때마다: ① 바뀐 프로젝트에 새 updatedAt(충돌 감지용 버전) ② 바뀌기 전 상태를 이력에 보존.
const prev = readPrevProjects(p);
const now = Date.now();
for (const proj of data.projects) {
const before = prev.get(proj.id);
if (!before) { proj.updatedAt = now; continue; }
if (contentKey(before) === contentKey(proj)) { proj.updatedAt = before.updatedAt ?? proj.updatedAt ?? now; continue; }
recordHistory(workspaceRoot, before);
proj.updatedAt = Math.max(now, (before.updatedAt || 0) + 1);
}
const tmp = `${p}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8');
fs.renameSync(tmp, p);
}
// ── 변경 이력(2026-09-24) ────────────────────────────────────────────────────
// 채팅 도구/대시보드/다른 탭이 뒤섞여 쓰는 데이터라 LLM 실수나 저장 버그로 내용이 사라져도
// 복구할 수 있어야 한다. 프로젝트가 바뀔 때마다 "바뀌기 전" 상태를 파일로 남긴다.
// 타이핑 중 저장이 초 단위로 반복되므로 최근 스냅샷과 HISTORY_MIN_GAP_MS 안이면 건너뛴다(그
// 구간의 시작 상태는 이미 남아 있다). 프로젝트당 HISTORY_KEEP개까지만 보존한다.
export const HISTORY_MIN_GAP_MS = 45_000;
export const HISTORY_KEEP = 30;
export function historyDir(workspaceRoot: string, projectId: string): string {
return path.join(workspaceRoot, '.smallclaw', 'workshop-history', projectId);
}
function historyTimestamps(dir: string): number[] {
try {
return fs.readdirSync(dir).map(f => /^(\d+)\.json$/.exec(f)?.[1]).filter(Boolean).map(Number).sort((a, b) => b - a);
} catch { return []; }
}
export function recordHistory(workspaceRoot: string, project: WorkshopProject, force = false): void {
try {
if (!/^[A-Za-z0-9_-]+$/.test(project.id)) return;
const dir = historyDir(workspaceRoot, project.id);
const stamps = historyTimestamps(dir);
const now = Date.now();
if (!force && stamps.length && now - stamps[0] < HISTORY_MIN_GAP_MS) return;
fs.mkdirSync(dir, { recursive: true });
const ts = stamps.length && now <= stamps[0] ? stamps[0] + 1 : now;
fs.writeFileSync(path.join(dir, `${ts}.json`), JSON.stringify(project), 'utf-8');
for (const old of historyTimestamps(dir).slice(HISTORY_KEEP)) {
try { fs.unlinkSync(path.join(dir, `${old}.json`)); } catch { /* noop */ }
}
} catch { /* 이력 실패가 저장을 막으면 안 된다 */ }
}
export interface HistoryEntry { ts: number; name: string; parts: number; tasks: number; tasksDone: number; notesLen: number; descLen: number }
export function readHistory(workspaceRoot: string, projectId: string, ts: number): WorkshopProject | null {
try {
if (!/^[A-Za-z0-9_-]+$/.test(projectId) || !Number.isFinite(ts)) return null;
return JSON.parse(fs.readFileSync(path.join(historyDir(workspaceRoot, projectId), `${Math.trunc(ts)}.json`), 'utf-8'));
} catch { return null; }
}
export function listHistory(workspaceRoot: string, projectId: string): HistoryEntry[] {
if (!/^[A-Za-z0-9_-]+$/.test(projectId)) return [];
const out: HistoryEntry[] = [];
for (const ts of historyTimestamps(historyDir(workspaceRoot, projectId))) {
const pr = readHistory(workspaceRoot, projectId, ts);
if (!pr) continue;
const tasks = (pr.phases || []).flatMap(ph => ph.tasks || []);
out.push({
ts, name: pr.name, parts: (pr.parts || []).length, tasks: tasks.length,
tasksDone: tasks.filter(t => t.done).length, notesLen: String(pr.notes || '').length, descLen: String(pr.description || '').length,
});
}
return out;
}
// 이력 폴더는 남았는데 현재 프로젝트 목록에 없는 것 = 삭제된 프로젝트(복구 후보).
export function listDeletedProjects(workspaceRoot: string, data: WorkshopData): { id: string; name: string; ts: number }[] {
const root = path.join(workspaceRoot, '.smallclaw', 'workshop-history');
const alive = new Set(data.projects.map(p => p.id));
const out: { id: string; name: string; ts: number }[] = [];
try {
for (const id of fs.readdirSync(root)) {
if (alive.has(id) || !/^[A-Za-z0-9_-]+$/.test(id)) continue;
const latest = historyTimestamps(path.join(root, id))[0];
if (latest == null) continue;
const pr = readHistory(workspaceRoot, id, latest);
if (pr) out.push({ id, name: pr.name, ts: latest });
}
} catch { /* 이력 없음 */ }
return out.sort((a, b) => b.ts - a.ts);
}
// 이력 스냅샷으로 프로젝트를 되돌린다. 되돌리기 직전 상태도 이력에 남기므로 "되돌리기 취소"가 된다.
export function restoreFromHistory(workspaceRoot: string, data: WorkshopData, projectId: string, ts: number): WorkshopProject | null {
const snap = readHistory(workspaceRoot, projectId, ts);
if (!snap) return null;
const cur = data.projects.find(p => p.id === projectId);
if (cur) recordHistory(workspaceRoot, cur, true);
const restored: WorkshopProject = { ...snap, id: projectId };
upsertProject(data, restored);
return restored;
}
// 프로젝트 복제 — "템플릿 복제": 구조(부품/단계/작업/링크/메모/개요/예산)는 그대로 가져오되
// 새 id를 주고, 진행 상태(작업 완료, 부품 주문/보유, 시세)는 초기화한다.
export function duplicateProject(src: WorkshopProject, newId: string): WorkshopProject {
const copy: WorkshopProject = JSON.parse(JSON.stringify(src));
copy.id = newId;
copy.name = `${src.name} (복제)`;
copy.archived = false;
delete copy.updatedAt;
copy.parts = (copy.parts || []).map(pt => {
const { priceCheck: _pc, ...rest } = pt;
return { ...rest, id: genWorkshopId('p'), status: '검토중' as const, links: (pt.links || []).map(l => ({ ...l, id: genWorkshopId('lk') })) };
});
copy.phases = (copy.phases || []).map(ph => ({
...ph, id: genWorkshopId('ph'),
tasks: (ph.tasks || []).map(t => ({ ...t, id: genWorkshopId('t'), done: false })),
}));
return copy;
}
export function projectProgress(project: WorkshopProject): { done: number; total: number; pct: number } {
const tasks = (project.phases || []).flatMap(ph => ph.tasks || []);
const done = tasks.filter(t => t.done).length;
return { done, total: tasks.length, pct: tasks.length ? Math.round((done / tasks.length) * 100) : 0 };
}
// ── 프로젝트 개요 문서(2026-09-23) ──────────────────────────────────────────
// 대시보드 데이터를 그대로 마크다운으로 내보낸다 — 도구의 'get' 액션이 쓴다.
// 처음엔 workshop/<id>/개요/readme.md 파일로 뽑았으나 파일탭이 산만해져서(사용자
@@ -253,6 +406,12 @@ export function projectToReadme(project: WorkshopProject): string {
}
lines.push('');
lines.push(`예산: ${budgetSummary(project.parts)}`);
if (project.budget && project.budget > 0) {
const total = project.parts.reduce((sum, p) => sum + p.qty * p.unitPrice, 0);
lines.push(`목표 예산: ${fmtWon(project.budget)} (총 예상비용 ${Math.round((total / project.budget) * 100)}%${total > project.budget ? ' — 초과' : ''})`);
}
const prog = projectProgress(project);
if (prog.total) lines.push(`작업 진행: ${prog.done}/${prog.total} (${prog.pct}%)`);
lines.push('');
lines.push('## 부품');
for (const p of project.parts) {
@@ -266,7 +425,7 @@ export function projectToReadme(project: WorkshopProject): string {
for (const ph of project.phases) {
lines.push(`### ${ph.name}`);
for (const t of ph.tasks || []) {
lines.push(`- [${t.done ? 'x' : ' '}] ${t.text}`);
lines.push(`- [${t.done ? 'x' : ' '}] ${t.text}${t.due ? ` (기한 ${t.due})` : ''}`);
}
}
if (String(project.notes || '').trim()) {
+11 -4
View File
@@ -97,6 +97,7 @@ import { registerDetectiveDateRoutes } from './routes/routes-detective-dates';
import { registerLawyerCaseRoutes } from './routes/routes-lawyer-cases';
import { registerDoctorCaseRoutes } from './routes/routes-doctor-cases';
import { registerWorkshopRoutes } from './routes/routes-workshop';
import { startPrintWatch } from './workshop-print-watch';
import { registerCadRoutes } from './routes/routes-cad';
import { caseFilesDir, resolveUploadPath, registerWopiRoutes } from './routes/case-storage';
import {
@@ -396,9 +397,11 @@ function resolveImageContent(content: any, workspacePath: string): any {
// Resize large images to 512px thumbnail so the model can still see them
// without blowing up the token budget.
try {
const { execSync } = require('child_process');
// execFileSync — 셸을 거치지 않는다. 예전 execSync(`... "${filePath}"`)는 $(…)/백틱이 든
// 파일명(작업실 첨부파일 등 사용자 업로드 이름)이 셸에서 실행되는 명령 주입 경로였다.
const { execFileSync } = require('child_process');
const script = `from PIL import Image; import sys; img=Image.open(sys.argv[1]); img.thumbnail((512,512)); img.save(sys.stdout.buffer, 'JPEG', quality=75)`;
buf = execSync(`python3 -c "${script}" "${filePath}"`, { maxBuffer: 5 * 1024 * 1024 });
buf = execFileSync('python3', ['-c', script, filePath], { maxBuffer: 5 * 1024 * 1024 });
} catch {
buf = null;
}
@@ -460,9 +463,9 @@ function resolveToolImageContent(content: string, workspacePath: string, embedVi
if (stat.size <= 300_000) {
buf = fs.readFileSync(filePath);
} else {
const { execSync } = require('child_process');
const { execFileSync } = require('child_process');
const script = `from PIL import Image; import sys; img=Image.open(sys.argv[1]); img.thumbnail((600,600)); img.save(sys.stdout.buffer,'JPEG',quality=80)`;
buf = execSync(`python3 -c "${script}" "${filePath}"`, { maxBuffer: 5 * 1024 * 1024 });
buf = execFileSync('python3', ['-c', script, filePath], { maxBuffer: 5 * 1024 * 1024 });
}
if (buf && buf.length > 0) {
const ext = path.extname(filePath).toLowerCase();
@@ -4790,6 +4793,10 @@ console.log('[Server] - Error Audit: Ready');
console.log('[Server] - Context Injection Manager: Ready');
server.listen(PORT, HOST, async () => {
// 작업실 출력 감시: K2 출력 완료/실패 시 프로젝트 작업·메모 반영 + 텔레그램 알림(job 없으면 폴링 안 함).
startPrintWatch(async (text) => {
try { await telegramChannel.sendToAllowed(text); } catch { /* 텔레그램 미설정이면 메모 기록만 */ }
});
// Detect GPU hardware once — logs a single clean line, caches result for
// the lifetime of the process (used by /api/system-stats, no repeated probes).
logGpuStatus();
+131
View File
@@ -0,0 +1,131 @@
/**
* workshop-print-watch.ts
* 작업실 ↔ K2 출력 연동(2026-09-24). print3d_model(action="start")로 출력을 시작할 때 "어느 프로젝트의
* 어느 작업"인지를 job으로 등록해두고, 백그라운드에서 Moonraker 상태를 주기적으로 확인해 출력이 끝나면
* ① 해당 작업을 자동 완료 처리하고 ② 프로젝트 메모에 결과(소요시간)를 남기고 ③ 알림을 보낸다.
* 실패/취소도 메모로 남겨서 "출력 걸어놓고 잊은" 상태를 없앤다. job이 하나도 없으면 폴링 자체를 안 한다.
*/
import fs from 'fs';
import path from 'path';
import { getConfig } from '../config/config';
import { K2_HOST, K2_MOONRAKER_PORT, fmtDuration } from '../tools/k2-slicer-core';
import { loadWorkshop, saveWorkshop, matchByName } from './routes/workshop-storage';
export interface PrintJob {
workspace: string;
projectId: string;
filename: string;
/** 출력이 끝나면 완료 처리할 작업 문구(부분일치, 선택). */
taskText?: string;
startedAt: number;
}
export interface PrinterStatus { state: string; filename: string; printDurationSec: number }
export type JobVerdict = 'keep' | 'drop' | 'complete' | 'failed';
const JOB_MAX_AGE_MS = 72 * 3600 * 1000; // 3일 넘게 결과를 못 본 job은 버린다
const UNSEEN_MAX_AGE_MS = 3 * 3600 * 1000; // 시작 후 3시간 동안 그 파일이 한 번도 안 보이면 시작 실패로 본다
const base = (f: string) => String(f || '').split('/').pop() || '';
// 순수 판정 함수 — Moonraker 상태 하나와 job을 받아 다음 동작을 정한다(단위 테스트 대상).
export function judgePrintJob(job: PrintJob, st: PrinterStatus | null, now: number, seenActive: boolean): JobVerdict {
if (now - job.startedAt > JOB_MAX_AGE_MS) return 'drop';
if (!st) return 'keep'; // 프린터가 잠깐 안 보임 — 다음 주기에 다시
const same = base(st.filename) === base(job.filename);
if (same && st.state === 'complete') return 'complete';
if (same && (st.state === 'cancelled' || st.state === 'error')) return 'failed';
if (same && (st.state === 'printing' || st.state === 'paused')) return 'keep';
// 다른 파일이 출력 중이거나 대기 상태 — 우리 파일이 한 번도 안 보였고 오래됐으면 시작이 안 된 것.
if (!seenActive && now - job.startedAt > UNSEEN_MAX_AGE_MS) return 'drop';
return 'keep';
}
// ── job 저장소(전역 1개 파일) ─────────────────────────────────────────────────
function jobsPath(): string {
return path.join(getConfig().getConfigDir(), 'workshop-print-jobs.json');
}
function readJobs(): (PrintJob & { seenActive?: boolean })[] {
try { return JSON.parse(fs.readFileSync(jobsPath(), 'utf-8')); } catch { return []; }
}
function writeJobs(jobs: (PrintJob & { seenActive?: boolean })[]): void {
const p = jobsPath();
const tmp = `${p}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(jobs, null, 2), 'utf-8');
fs.renameSync(tmp, p);
}
export function registerPrintJob(job: PrintJob): void {
const jobs = readJobs().filter(j => !(j.workspace === job.workspace && base(j.filename) === base(job.filename)));
jobs.push({ ...job, filename: base(job.filename) });
writeJobs(jobs);
}
// 결과를 프로젝트에 반영한다(작업 완료 + 메모). 반영한 요약 문자열을 돌려준다.
export function applyPrintResult(job: PrintJob, verdict: 'complete' | 'failed', durationSec: number): string {
const data = loadWorkshop(job.workspace);
const project = data.projects.find(p => p.id === job.projectId);
if (!project) return '';
const when = new Date().toLocaleString('ko-KR');
const dur = durationSec > 0 ? ` (소요 ${fmtDuration(durationSec)})` : '';
let taskNote = '';
if (verdict === 'complete' && job.taskText) {
const flat = project.phases.flatMap(ph => ph.tasks);
const m = matchByName(flat, job.taskText, t => t.text);
if (m.length === 1) { flat[m[0].index].done = true; taskNote = ` — 작업 "${flat[m[0].index].text}" 완료 처리`; }
else taskNote = m.length > 1 ? ' — 작업 문구가 여러 개와 일치해 자동 완료 처리는 건너뜀' : '';
}
const line = verdict === 'complete'
? `🖨️ 출력 완료: ${job.filename}${dur}${taskNote} [${when}]`
: `⚠️ 출력 실패/취소: ${job.filename} [${when}]`;
project.notes = String(project.notes || '').trim() ? `${String(project.notes).trim()}\n${line}` : line;
saveWorkshop(job.workspace, data);
return `[${project.name}] ${line}`;
}
async function fetchPrinterStatus(): Promise<PrinterStatus | null> {
try {
const r = await fetch(`http://${K2_HOST}:${K2_MOONRAKER_PORT}/printer/objects/query?print_stats`, { signal: AbortSignal.timeout(8_000) });
if (!r.ok) return null;
const d: any = await r.json();
const ps = d?.result?.status?.print_stats || {};
return { state: String(ps.state || ''), filename: String(ps.filename || ''), printDurationSec: Math.round(ps.print_duration || 0) };
} catch { return null; }
}
export async function checkPrintJobsOnce(notify: (text: string) => Promise<void> | void): Promise<void> {
const jobs = readJobs();
if (!jobs.length) return;
const st = await fetchPrinterStatus();
const now = Date.now();
const remaining: typeof jobs = [];
let changed = false;
for (const job of jobs) {
const seen = !!job.seenActive || (!!st && base(st.filename) === base(job.filename) && (st.state === 'printing' || st.state === 'paused'));
const verdict = judgePrintJob(job, st, now, seen);
if (verdict === 'keep') {
if (seen !== !!job.seenActive) changed = true;
remaining.push({ ...job, seenActive: seen });
continue;
}
changed = true;
if (verdict === 'complete' || verdict === 'failed') {
try {
const summary = applyPrintResult(job, verdict, st?.printDurationSec || 0);
if (summary) await notify(summary);
} catch (e: any) {
console.log(`[workshop-print-watch] 결과 반영 실패(${job.filename}): ${String(e?.message || e)}`);
remaining.push(job); // 반영 실패 — 다음 주기에 재시도
}
}
}
if (changed) writeJobs(remaining);
}
let timer: NodeJS.Timeout | null = null;
export function startPrintWatch(notify: (text: string) => Promise<void> | void, intervalMs = 60_000): void {
if (timer) return;
timer = setInterval(() => { checkPrintJobsOnce(notify).catch(() => undefined); }, intervalMs);
timer.unref?.();
}
+22 -1
View File
@@ -4,6 +4,8 @@ import { ToolResult } from '../types.js';
import { getWorkspacePath } from '../config/paths.js';
import { isPathInsideDir } from './image.js';
import { loadWorkshop } from '../gateway/routes/workshop-storage.js';
import { registerPrintJob } from '../gateway/workshop-print-watch.js';
import { sessionProjectId } from './workshop-project.js';
import {
K2_HOST, K2_MOONRAKER_PORT, PRUSA_SLICER_BIN, MATERIAL_PRESETS,
buildBaseArgs, runPrusaSlicer, uploadToMoonraker, parsePrusaTime, fmtDuration,
@@ -82,6 +84,7 @@ export const print3dModelTool = {
wall_count: { type: 'number', description: '(prepare, 선택) 벽 겹수. 기본 2' },
material: { type: 'string', enum: ['PLA', 'PETG'], description: '(prepare, 선택) 소재. 기본 PLA(K2 실측 온도값 검증됨). PETG는 일반적인 추정 온도라 품질 이슈 있으면 조정 필요.' },
filename: { type: 'string', description: '(start 필수) prepare가 반환한 파일명' },
task_text: { type: 'string', description: '(start, 선택) 작업실 프로젝트의 "이 출력이 끝나면 완료 처리할 작업" 문구(일부만 일치해도 됨, 예: "베이스 플레이트 출력"). 주면 출력 완료 시 그 작업이 자동으로 체크된다.' },
confirm: { type: 'boolean', description: '(start 필수) 반드시 true — 사용자가 출력 시작을 명시적으로 승인했을 때만 true로 호출' },
},
additionalProperties: false,
@@ -106,9 +109,27 @@ export const print3dModelTool = {
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) return { success: false, error: `출력 시작 실패: HTTP ${res.status} ${await res.text().catch(() => '')}` };
// 작업실 연동: 어느 프로젝트의 출력인지 알면(작업실 채팅 세션 또는 project_name) 끝났을 때
// 작업 자동 완료+메모+알림을 해주도록 감시 job을 등록한다(workshop-print-watch.ts).
let watchNote = '';
try {
const ws = getWorkspacePath(args);
const data = loadWorkshop(ws);
const sid = sessionProjectId(args?._sessionId);
let proj = sid ? data.projects.find(p => p.id === sid) : undefined;
if (!proj && args?.project_name) {
const q = String(args.project_name).trim().toLowerCase();
const hits = data.projects.filter(p => p.name.toLowerCase().includes(q));
if (hits.length === 1) proj = hits[0];
}
if (proj) {
registerPrintJob({ workspace: ws, projectId: proj.id, filename, taskText: String(args?.task_text || '').trim() || undefined, startedAt: Date.now() });
watchNote = `\n\n📋 작업실 프로젝트 "${proj.name}"에 출력 감시를 등록했습니다 — 출력이 끝나면 ${args?.task_text ? '해당 작업을 자동 완료 처리하고 ' : ''}프로젝트 메모에 결과를 남기고 알립니다.`;
}
} catch { /* 감시 등록 실패는 출력 시작 자체에 영향 없음 */ }
return {
success: true,
stdout: `K2 콤보에서 "${filename}" 출력을 시작했습니다.\n\n⚠️ 원격으로 시작하면 K2 터치스크린에 뜨는 "필라멘트 삽입" 확인 프롬프트를 아무도 못 눌러줘서 실제 급지가 안 되고 히터가 자동으로 꺼질 수 있습니다 — 사용자에게 지금 프린터 터치스크린을 확인해서 그 프롬프트가 뜨면 직접 눌러달라고 안내하세요.`,
stdout: `K2 콤보에서 "${filename}" 출력을 시작했습니다.${watchNote}\n\n⚠️ 원격으로 시작하면 K2 터치스크린에 뜨는 "필라멘트 삽입" 확인 프롬프트를 아무도 못 눌러줘서 실제 급지가 안 되고 히터가 자동으로 꺼질 수 있습니다 — 사용자에게 지금 프린터 터치스크린을 확인해서 그 프롬프트가 뜨면 직접 눌러달라고 안내하세요.`,
};
} catch (e: any) {
return { success: false, error: `출력 시작 실패: ${String(e?.message || e)} (K2가 꺼져있거나 네트워크에 없을 수 있음)` };
+2 -2
View File
@@ -162,7 +162,7 @@ export const VIEW_ROTATIONS: Record<string, [number, number, number]> = {
right: [90, 0, 270],
};
export async function renderStlPng(stlPath: string, view: string, outPngPath: string): Promise<{ ok: true } | { ok: false; detail: string }> {
export async function renderStlPng(stlPath: string, view: string, outPngPath: string, imgSize = 900): Promise<{ ok: true } | { ok: false; detail: string }> {
let bbox: StlBBox;
try { bbox = readStlBBox(stlPath); } catch (e: any) { return { ok: false, detail: String(e?.message || e) }; }
const maxExtent = Math.max(bbox.max[0] - bbox.min[0], bbox.max[1] - bbox.min[1], bbox.max[2] - bbox.min[2]);
@@ -178,7 +178,7 @@ export async function renderStlPng(stlPath: string, view: string, outPngPath: st
const scadPath = outPngPath.replace(/\.png$/i, '.scad');
fs.writeFileSync(scadPath, `import(${JSON.stringify(stlPath)});\n`);
const scadArgs = ['-o', outPngPath, '--imgsize=900,900', '--colorscheme=Tomorrow'];
const scadArgs = ['-o', outPngPath, `--imgsize=${imgSize},${imgSize}`, '--colorscheme=Tomorrow'];
if (view === 'iso') {
scadArgs.push('--autocenter', '--viewall');
} else {
+231 -5
View File
@@ -1,10 +1,14 @@
import fs from 'fs';
import path from 'path';
import { ToolResult } from '../types.js';
import { getWorkspacePath } from '../config/paths.js';
import {
loadWorkshop, saveWorkshop, genWorkshopId,
WorkshopData, WorkshopProject, WorkshopPart, WorkshopPhase,
budgetSummary, projectToReadme, fmtWon, matchByName,
duplicateProject, projectProgress, listHistory, restoreFromHistory, recordHistory,
} from '../gateway/routes/workshop-storage.js';
import { searchDanawa } from './danawa.js';
// "작업실"(여러 메이커 프로젝트 관리 대시보드) 전용 채팅도구. 원래 로봇 프로젝트 하나만
// 다루던 robot_project를 여러 프로젝트를 오갈 수 있게 일반화(2026-09-20). 대시보드와 같은
@@ -13,6 +17,7 @@ import {
// print3d_model 같은 confirm 게이트는 필요 없음.
const STATUSES = ['검토중', '주문완료', '보유'] as const;
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
// fmtWon/budgetSummary/formatProject는 workshop-storage.ts로 옮겨졌다(2026-09-23) —
// 'get' 출력과 개요/readme.md 자동생성이 같은 projectToReadme을 쓰게 하기 위함.
@@ -75,7 +80,7 @@ function findTaskLocation(project: WorkshopProject, query: string): { ok: true;
export const workshopProjectTool = {
name: 'workshop_project',
description: '"작업실" 대시보드(여러 메이커 프로젝트를 각각 부품/작업/메모로 관리)를 조회·수정한다. 사용자가 어떤 프로젝트(로봇, 3D프린팅 등)의 부품 구매/상태 변경, 작업 완료 체크, 예산 확인, 메모 추가, 새 프로젝트 생성 등을 말하면 이 도구로 실제 대시보드(workshop-app.html)를 갱신한다. project_name을 안 주면 사용자가 대시보드에서 마지막으로 보고 있던 프로젝트를 대상으로 한다. 물리적 동작이 없는 순수 데이터 편집이라 확인 없이 바로 실행해도 된다. 프로젝트 첨부파일은 워크스페이스의 workshop/<프로젝트id>/ 아래에 있고(옛 자동생성 readme는 workshop/<프로젝트id>/개요/readme.md), "readme.md 내용을 개요로 옮겨줘" 같은 요청은 파일을 실제로 읽어본 뒤(python_eval의 open()) 내용을 set_description으로 넣는다 — 경로를 추측해 파일이 없다고 단정하지 말 것(실측: 도구를 하나도 안 쓰고 "없다"고 답해 개요 등록이 실패한 사례가 있다).',
description: '"작업실" 대시보드(여러 메이커 프로젝트를 각각 부품/작업/메모로 관리)를 조회·수정한다. 사용자가 어떤 프로젝트(로봇, 3D프린팅 등)의 부품 구매/상태 변경, 작업 완료 체크, 예산 확인, 메모 추가, 새 프로젝트 생성 등을 말하면 이 도구로 실제 대시보드(workshop-app.html)를 갱신한다. project_name을 안 주면 사용자가 대시보드에서 마지막으로 보고 있던 프로젝트를 대상으로 한다. 물리적 동작이 없는 순수 데이터 편집이라 확인 없이 바로 실행해도 된다. 메모를 "정리/다시 써줘/도표로 바꿔줘"라고 하면 add_note로 덧붙이지 말고 get으로 현재 메모를 읽은 뒤 set_notes로 전체를 교체할 것(add_note는 새 한 줄 추가 전용). 여러 부품을 한 번에 등록할 땐 add_parts(배열)를 쓸 것. 되돌리기 요청("방금 거 취소")은 list_history→restore_history. 프로젝트 첨부파일은 워크스페이스의 workshop/<프로젝트id>/ 아래에 있고(옛 자동생성 readme는 workshop/<프로젝트id>/개요/readme.md), "readme.md 내용을 개요로 옮겨줘" 같은 요청은 파일을 실제로 읽어본 뒤(python_eval의 open()) 내용을 set_description으로 넣는다 — 경로를 추측해 파일이 없다고 단정하지 말 것(실측: 도구를 하나도 안 쓰고 "없다"고 답해 개요 등록이 실패한 사례가 있다).',
schema: { action: 'string' },
jsonSchema: {
type: 'object',
@@ -83,8 +88,15 @@ export const workshopProjectTool = {
properties: {
action: {
type: 'string',
enum: ['list_projects', 'create_project', 'set_active_project', 'get', 'set_description', 'add_part', 'update_part', 'delete_part', 'add_task', 'set_task_done', 'add_phase', 'add_note', 'add_link', 'delete_link'],
description: 'list_projects: 전체 프로젝트 목록. create_project: 새 프로젝트 생성(+활성화). set_active_project: 대시보드에서 볼 프로젝트 전환. get: 프로젝트 조회(개요+예산 요약+부품+작업+메모). set_description: 프로젝트 서술형 개요 설정(텍스트는 description 파라미터로 전달). add_part/update_part/delete_part: 부품 추가/수정/삭제. add_task: 단계에 작업 추가. set_task_done: 작업 완료/미완료. add_phase: 새 단계 추가. add_note: 메모 한 줄 추가. add_link/delete_link: 부품에 참고링크(구매처/대체품/업그레이드/레포 등) 추가/삭제.',
enum: [
'list_projects', 'create_project', 'set_active_project', 'get', 'set_description',
'add_part', 'add_parts', 'update_part', 'delete_part', 'check_price',
'add_task', 'update_task', 'delete_task', 'set_task_done', 'add_phase', 'rename_phase', 'delete_phase',
'add_note', 'set_notes', 'add_link', 'delete_link',
'rename_project', 'set_budget', 'archive_project', 'unarchive_project', 'duplicate_project', 'delete_project',
'list_history', 'restore_history', 'view_image',
],
description: 'list_projects: 전체 프로젝트 목록. create_project: 새 프로젝트 생성(+활성화). set_active_project: 대시보드에서 볼 프로젝트 전환. get: 프로젝트 조회(개요+예산 요약+부품+작업+메모). set_description: 프로젝트 서술형 개요 설정(텍스트는 description 파라미터로 전달). add_part/update_part/delete_part: 부품 추가/수정/삭제. add_task: 단계에 작업 추가. set_task_done: 작업 완료/미완료. add_phase: 새 단계 추가. add_note: 메모 한 줄 추가. add_link/delete_link: 부품에 참고링크(구매처/대체품/업그레이드/레포 등) 추가/삭제. add_parts: 부품 여러 개 일괄 추가(parts 배열). check_price: 부품(part_name) 또는 검색어(query)의 다나와 시세 조회(PC부품 위주 — 전자부품/소모품은 결과가 없을 수 있음). update_task: 작업 문구 수정(new_text)/기한 설정(due, 빈 문자열이면 해제). delete_task/delete_phase/rename_phase: 작업·단계 삭제/단계 이름 변경(new_phase_name). set_notes: 메모 전체 교체(notes_text). rename_project(new_project_name)/set_budget(budget, 원 단위, 0이면 해제)/archive_project/unarchive_project/duplicate_project(구조만 복제, 진행상태 초기화)/delete_project(confirm=true 필수, 첨부파일·채팅기록까지 삭제되므로 사용자가 명시적으로 요청했을 때만). list_history: 최근 변경 이력. restore_history: 이력 시각(ts)으로 프로젝트를 되돌림. view_image: 프로젝트 첨부 사진을 모델이 직접 보게 함(file_name=파일명 또는 상대경로 일부, 사용자 메시지의 [첨부 사진: ...]이 이 파일) — 부품 식별, 배선/조립 상태 확인 등에 쓴다.',
},
project_name: { type: 'string', description: '(create_project/set_active_project 필수, 그 외 모든 액션 선택) 대상 프로젝트 이름(일부만 일치해도 됨). 생략하면 현재 활성 프로젝트를 대상으로 함.' },
description: { type: 'string', description: '(set_description 액션에서 필수 — 파라미터 이름은 description이다) 프로젝트 개요 문단 — 무엇을 만드는지/목표/구성 등을 자유 서술. 대시보드의 "개요" 탭에 표시된다.' },
@@ -101,6 +113,27 @@ export const workshopProjectTool = {
note_text: { type: 'string', description: '(add_note 필수) 메모에 추가할 한 줄' },
link_label: { type: 'string', description: '(add_link 필수) 링크 라벨(예: "쿠팡", "대체품", "GitHub 레포"), (delete_link 필수) 삭제할 링크의 라벨(일부만 일치해도 됨)' },
link_url: { type: 'string', description: '(add_link 필수) 링크 URL' },
notes_text: { type: 'string', description: '(set_notes 필수) 메모 탭 전체를 이 내용으로 교체한다(마크다운 표 가능). 빈 문자열이면 메모를 비운다.' },
new_text: { type: 'string', description: '(update_task 선택) 작업의 새 문구' },
due: { type: 'string', description: '(add_task/update_task 선택) 기한 YYYY-MM-DD. update_task에서 빈 문자열이면 기한 해제.' },
new_project_name: { type: 'string', description: '(rename_project 필수) 새 프로젝트 이름' },
budget: { type: 'number', description: '(set_budget 필수) 목표 예산(원). 0이면 해제.' },
parts: {
type: 'array',
description: '(add_parts 필수) 추가할 부품 목록',
items: {
type: 'object',
properties: {
name: { type: 'string' }, qty: { type: 'number' }, unit_price: { type: 'number' },
status: { type: 'string', enum: ['검토중', '주문완료', '보유'] }, memo: { type: 'string' },
},
required: ['name'],
},
},
query: { type: 'string', description: '(check_price 선택) part_name 대신 직접 검색어를 줄 때' },
confirm: { type: 'boolean', description: '(delete_project 필수) 반드시 true — 사용자가 삭제를 명시적으로 요청한 경우에만' },
ts: { type: 'number', description: '(restore_history 필수) list_history가 준 이력 시각(ts)' },
file_name: { type: 'string', description: '(view_image 필수) 프로젝트 첨부 사진의 파일명 또는 상대경로(일부만 일치해도 됨, 예: "사진/motor.jpg")' },
},
additionalProperties: false,
},
@@ -113,7 +146,10 @@ export const workshopProjectTool = {
const active = data.projects.find(p => p.id === data.activeProjectId);
return {
success: true,
stdout: data.projects.map(p => `- ${p.name}${p.id === active?.id ? ' (현재 활성)' : ''}`).join('\n') || '프로젝트가 없습니다.',
stdout: data.projects.map(p => {
const pr = projectProgress(p);
return `- ${p.name}${p.id === active?.id ? ' (현재 활성)' : ''}${p.archived ? ' [보관됨]' : ''}${pr.total ? ` — 작업 ${pr.done}/${pr.total}` : ''}`;
}).join('\n') || '프로젝트가 없습니다.',
};
}
@@ -216,7 +252,9 @@ export const workshopProjectTool = {
if (phase.tasks.some(t => t.text.trim().toLowerCase() === text.toLowerCase())) {
return { success: true, stdout: `[${project.name}] "${phase.name}"에 같은 작업이 이미 있어 추가하지 않음: ${text}` };
}
phase.tasks.push({ id: genWorkshopId('t'), text, done: false });
const due = String(args?.due || '').trim();
if (due && !DATE_RE.test(due)) return { success: false, error: 'due는 YYYY-MM-DD 형식이어야 합니다.' };
phase.tasks.push({ id: genWorkshopId('t'), text, done: false, ...(due ? { due } : {}) });
saveWorkshop(workspaceRoot, data);
return { success: true, stdout: `[${project.name}] "${phase.name}"에 작업 추가됨: ${text}` };
}
@@ -280,6 +318,194 @@ export const workshopProjectTool = {
return { success: true, stdout: `[${project.name}]의 링크 삭제됨: ${matches[0].label}` };
}
if (action === 'add_parts') {
const list: any[] = Array.isArray(args?.parts) ? args.parts : [];
if (!list.length) return { success: false, error: 'parts 배열이 필요합니다.' };
const added: string[] = [], skipped: string[] = [];
for (const raw of list) {
const name = String(raw?.name || '').trim();
if (!name) continue;
if (project.parts.some(p => p.name.trim().toLowerCase() === name.toLowerCase())) { skipped.push(name); continue; }
project.parts.push({
id: genWorkshopId('p'), name,
qty: Number.isFinite(Number(raw?.qty)) ? Math.max(0, Number(raw.qty)) : 1,
unitPrice: Number.isFinite(Number(raw?.unit_price)) ? Math.max(0, Number(raw.unit_price)) : 0,
status: STATUSES.includes(raw?.status) ? raw.status : '검토중',
memo: String(raw?.memo || ''), links: [],
});
added.push(name);
}
if (!added.length && !skipped.length) return { success: false, error: '유효한 부품(name)이 하나도 없습니다.' };
if (added.length) saveWorkshop(workspaceRoot, data);
return { success: true, stdout: `[${project.name}] 부품 ${added.length}개 추가됨${added.length ? `: ${added.join(', ')}` : ''}${skipped.length ? `\n이미 있어 건너뜀: ${skipped.join(', ')}` : ''}\n\n${budgetSummary(project.parts)}` };
}
if (action === 'check_price') {
let query = String(args?.query || '').trim();
let part: WorkshopPart | undefined;
if (!query) {
const found = findPartIndex(project, String(args?.part_name || ''));
if (!found.ok) return { success: false, error: found.error };
part = project.parts[found.index];
query = part.name;
}
const items = (await searchDanawa(query)).filter(i => i.price && i.price > 0);
if (!items.length) return { success: true, stdout: `"${query}" 다나와 검색 결과 없음 — 다나와는 PC 부품 위주라 전자부품/소모품은 안 나올 수 있다. web_search로 구매처 가격을 찾을 것.` };
const prices = items.map(i => i.price as number).sort((a, b) => a - b);
const min = prices[0], median = prices[Math.floor(prices.length / 2)];
if (part) {
part.priceCheck = { min, median, count: prices.length, at: Date.now() };
saveWorkshop(workspaceRoot, data);
}
const top = items.slice(0, 5).map(i => `- ${i.name}: ${fmtWon(i.price as number)}`).join('\n');
const delta = part && part.unitPrice > 0 ? `\n현재 단가 ${fmtWon(part.unitPrice)} 대비 최저가 ${min < part.unitPrice ? '↓' : min > part.unitPrice ? '↑' : '='} ${fmtWon(Math.abs(min - part.unitPrice))}` : '';
return { success: true, stdout: `"${query}" 다나와 시세 ${prices.length}건 — 최저 ${fmtWon(min)}, 중앙값 ${fmtWon(median)}${delta}\n${top}\n단가에 반영하려면 update_part(unit_price)를 쓸 것.` };
}
if (action === 'update_task') {
const found = findTaskLocation(project, String(args?.task_text || ''));
if (!found.ok) return { success: false, error: found.error };
const task = project.phases[found.phaseIndex].tasks[found.taskIndex];
if (args?.new_text != null && String(args.new_text).trim()) task.text = String(args.new_text).trim();
if (args?.due != null) {
const due = String(args.due).trim();
if (due && !DATE_RE.test(due)) return { success: false, error: 'due는 YYYY-MM-DD 형식이어야 합니다.' };
if (due) task.due = due; else delete task.due;
}
saveWorkshop(workspaceRoot, data);
return { success: true, stdout: `[${project.name}] 작업 수정됨: "${task.text}"${task.due ? ` (기한 ${task.due})` : ''}` };
}
if (action === 'delete_task') {
const found = findTaskLocation(project, String(args?.task_text || ''));
if (!found.ok) return { success: false, error: found.error };
const [removed] = project.phases[found.phaseIndex].tasks.splice(found.taskIndex, 1);
saveWorkshop(workspaceRoot, data);
return { success: true, stdout: `[${project.name}] 작업 삭제됨: ${removed.text}` };
}
if (action === 'rename_phase' || action === 'delete_phase') {
const found = findPhaseIndex(project, String(args?.phase_name || ''));
if (!found.ok) return { success: false, error: found.error };
if (action === 'delete_phase') {
const [removed] = project.phases.splice(found.index, 1);
saveWorkshop(workspaceRoot, data);
return { success: true, stdout: `[${project.name}] 단계 삭제됨: ${removed.name} (작업 ${removed.tasks.length}개 포함)` };
}
const newName = String(args?.new_phase_name || '').trim();
if (!newName) return { success: false, error: 'new_phase_name이 필요합니다.' };
const old = project.phases[found.index].name;
project.phases[found.index].name = newName;
saveWorkshop(workspaceRoot, data);
return { success: true, stdout: `[${project.name}] 단계 이름 변경: ${old} → ${newName}` };
}
if (action === 'set_notes') {
if (args?.notes_text == null) return { success: false, error: 'notes_text가 필요합니다(빈 문자열이면 메모를 비움). 먼저 get으로 현재 메모를 읽고 정리한 전체 내용을 넣을 것.' };
const text = String(args.notes_text).replace(/\s+$/, '');
project.notes = text;
saveWorkshop(workspaceRoot, data);
return { success: true, stdout: `[${project.name}] 메모 전체 교체됨(${text.length}자) — 이전 내용은 list_history/restore_history로 복구 가능` };
}
if (action === 'rename_project') {
const name = String(args?.new_project_name || '').trim();
if (!name) return { success: false, error: 'new_project_name이 필요합니다.' };
if (data.projects.some(p => p.id !== project.id && p.name.trim().toLowerCase() === name.toLowerCase())) return { success: false, error: `이미 "${name}" 프로젝트가 있습니다.` };
const old = project.name;
project.name = name;
saveWorkshop(workspaceRoot, data);
return { success: true, stdout: `프로젝트 이름 변경: ${old} → ${name}` };
}
if (action === 'set_budget') {
const b = Number(args?.budget);
if (!Number.isFinite(b) || b < 0) return { success: false, error: 'budget(원, 0 이상 숫자)이 필요합니다.' };
project.budget = Math.round(b);
saveWorkshop(workspaceRoot, data);
return { success: true, stdout: `[${project.name}] 목표 예산 ${b ? fmtWon(b) : '해제'}\n\n${budgetSummary(project.parts)}` };
}
if (action === 'archive_project' || action === 'unarchive_project') {
project.archived = action === 'archive_project';
if (project.archived && data.activeProjectId === project.id) {
const next = data.projects.find(p => p.id !== project.id && !p.archived);
if (next) data.activeProjectId = next.id;
}
saveWorkshop(workspaceRoot, data);
return { success: true, stdout: `[${project.name}] ${project.archived ? '보관됨(목록에서 숨김, 데이터 유지)' : '보관 해제됨'}` };
}
if (action === 'duplicate_project') {
const copy = duplicateProject(project, genWorkshopId('proj'));
let n = 2; const base = copy.name;
while (data.projects.some(p => p.name === copy.name)) copy.name = `${base} ${n++}`;
data.projects.push(copy);
data.activeProjectId = copy.id;
saveWorkshop(workspaceRoot, data);
return { success: true, stdout: `프로젝트 복제됨(작업 완료·부품 상태 초기화): ${copy.name} — 활성화됨` };
}
if (action === 'delete_project') {
if (args?.confirm !== true) return { success: false, error: '프로젝트 삭제는 첨부파일·채팅기록까지 지우는 되돌리기 어려운 동작이라 confirm=true가 필요합니다. 사용자가 삭제를 명시적으로 요청했는지 확인하고 다시 호출하세요.' };
if (data.projects.length <= 1) return { success: false, error: '마지막 남은 프로젝트는 삭제할 수 없습니다.' };
recordHistory(workspaceRoot, project, true); // 삭제 후에도 이력에서 복구할 수 있게
data.projects = data.projects.filter(p => p.id !== project.id);
if (data.activeProjectId === project.id) data.activeProjectId = (data.projects.find(p => !p.archived) || data.projects[0]).id;
saveWorkshop(workspaceRoot, data);
// 첨부파일 폴더도 정리(대시보드 삭제와 동일 동작)
try {
const dir = path.join(workspaceRoot, 'workshop', project.id);
if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
} catch { /* 파일 정리 실패는 삭제 자체를 막지 않음 */ }
return { success: true, stdout: `프로젝트 삭제됨: ${project.name} (구조/내용은 이력에서 복구 가능, 첨부파일은 삭제됨)` };
}
if (action === 'list_history') {
const hist = listHistory(workspaceRoot, project.id).slice(0, 10);
if (!hist.length) return { success: true, stdout: `[${project.name}] 변경 이력이 아직 없습니다.` };
return {
success: true,
stdout: `[${project.name}] 최근 변경 이력(바뀌기 "전" 상태, 최신순) — restore_history(ts)로 되돌림:\n` +
hist.map(h => `- ts=${h.ts} (${new Date(h.ts).toLocaleString('ko-KR')}) 부품 ${h.parts}개, 작업 ${h.tasksDone}/${h.tasks}, 메모 ${h.notesLen}자, 개요 ${h.descLen}자`).join('\n'),
};
}
if (action === 'restore_history') {
const ts = Number(args?.ts);
if (!Number.isFinite(ts)) return { success: false, error: 'ts가 필요합니다(list_history 참고).' };
const restored = restoreFromHistory(workspaceRoot, data, project.id, ts);
if (!restored) return { success: false, error: `ts=${ts} 이력이 없습니다. list_history로 확인하세요.` };
saveWorkshop(workspaceRoot, data);
return { success: true, stdout: `[${restored.name}] ${new Date(ts).toLocaleString('ko-KR')} 시점 상태로 되돌림(되돌리기 직전 상태도 이력에 남음)` };
}
if (action === 'view_image') {
const q = String(args?.file_name || '').trim();
if (!q) return { success: false, error: 'file_name이 필요합니다.' };
const root = path.join(workspaceRoot, 'workshop', project.id);
const found: string[] = [];
const walk = (dir: string, rel: string) => {
let entries: fs.Dirent[] = [];
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
for (const e of entries) {
const r = rel ? `${rel}/${e.name}` : e.name;
if (e.isDirectory()) walk(path.join(dir, e.name), r);
else if (/\.(png|jpe?g|webp|gif|bmp)$/i.test(e.name)) found.push(r);
}
};
walk(root, '');
if (!found.length) return { success: false, error: `[${project.name}] 프로젝트에 첨부된 사진이 없습니다(파일 탭에 업로드된 이미지).` };
const hits = matchByName(found, q, f => f).map(m => found[m.index]);
// 파일명만 준 경우("motor.jpg")도 잡도록 폴더를 뗀 이름으로 한 번 더 시도
const byBase = hits.length ? hits : matchByName(found, q, f => f.split('/').pop() || f).map(m => found[m.index]);
if (!byBase.length) return { success: false, error: `"${q}"와 일치하는 사진이 없습니다. 있는 사진: ${found.slice(0, 20).join(', ')}` };
if (byBase.length > 1) return { success: false, error: `"${q}"에 여러 사진이 일치합니다: ${byBase.slice(0, 20).join(', ')} — 더 구체적으로 지정하세요.` };
const rel = byBase[0];
const url = `/api/files/workshop/${project.id}/${rel.split('/').map(encodeURIComponent).join('/')}`;
return { success: true, stdout: `[${project.name}] 사진: ${rel}\n![${path.basename(rel)}](${url})` };
}
return { success: false, error: `알 수 없는 action: ${action}` };
},
};
+138
View File
@@ -0,0 +1,138 @@
// 작업실 UI 실브라우저 검증(수동 실행): npx tsx tests/manual/workshop-ui-check.ts
// headless Chrome(playwright-core, /usr/bin/google-chrome)로 실제 workshop-app.html + 실제 라우트를 임시
// 데이터 디렉토리에서 띄워 XSS/표 보기/필터/정렬/진행률/이력/충돌/보관/시세/CSV/사진첨부를 확인한다.
// npm test(tests/*.test.ts)에는 포함되지 않는다 — 브라우저 설치가 필요해서.
import express from 'express';
import fs from 'fs';
import path from 'path';
import os from 'os';
import http from 'http';
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wsui-'));
process.env.SMALLCLAW_DATA_DIR = tmp;
const OUT = '/tmp/claude-1000/-srv-homeclaw/20a28794-71fc-428a-b8ac-286538537d9d/scratchpad/shots';
(async () => {
const { registerWorkshopRoutes } = await import('../../src/gateway/routes/routes-workshop');
const { chromium } = require('playwright-core');
const app = express();
app.use(express.json());
app.get('/api/auth/status', (_q, r) => r.json({ authenticated: true }));
app.get('/api/chat/sessions/:id', (_q, r) => r.json({ history: [] }));
app.delete('/api/chat/sessions/:id', (_q, r) => r.json({ success: true }));
app.get('/api/settings/model', (_q, r) => r.json({ primary: 'test-model' }));
app.get('/api/k2/status', (_q, r) => r.json({ online: false }));
const isInside = (b: string, t: string) => path.resolve(t).startsWith(path.resolve(b) + path.sep);
registerWorkshopRoutes(app, () => ({ username: 'tester' }), isInside);
app.use(express.static('/srv/homeclaw/web-ui'));
const server = http.createServer(app);
await new Promise<void>(r => server.listen(0, r));
const base = `http://127.0.0.1:${(server.address() as any).port}`;
const put = (id: string, body: any) => fetch(`${base}/api/workshop/project/${id}?force=1`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, ...body }) });
await put('pa', { name: '로봇팔 <b>XSS</b>', budget: 400000,
parts: [
{ id: 'p1', name: '<img src=x onerror="window.__xss=1">모터', qty: 4, unitPrice: 45000, status: '주문완료', memo: '', links: [], priceCheck: { min: 39000, median: 42000, count: 8, at: Date.now() } },
{ id: 'p2', name: 'Raspberry Pi 5', qty: 1, unitPrice: 200000, status: '검토중', memo: '8GB', links: [] },
{ id: 'p3', name: '18650 셀', qty: 6, unitPrice: 0, status: '보유', memo: '재사용', links: [] },
],
phases: [{ id: 'ph1', name: '1단계', tasks: [{ id: 't1', text: '주문', done: true }, { id: 't2', text: '조립', done: false, due: '2020-01-01' }, { id: 't3', text: '테스트', done: false, due: '2999-01-01' }] }, { id: 'ph2', name: '2단계', tasks: [] }],
notes: '메모', description: '개요' });
await put('pb', { name: '보관될 프로젝트', parts: [], phases: [], notes: '' });
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome', args: ['--no-sandbox'] });
const page = await browser.newPage({ viewport: { width: 1400, height: 850 } });
const errors: string[] = [];
page.on('pageerror', (e: any) => errors.push('pageerror: ' + e.message));
page.on('dialog', (d: any) => d.accept());
await page.addInitScript(() => { try { sessionStorage.setItem('smallclaw_token', 't'); } catch {} });
await page.goto(`${base}/html/workshop-app.html`);
await page.waitForSelector('.rb-part-card');
const shot = async (n: string) => page.screenshot({ path: `${OUT}/${n}.png` });
const results: string[] = [];
const check = (name: string, ok: boolean, extra = '') => results.push(`${ok ? 'PASS' : 'FAIL'} ${name}${extra ? ' — ' + extra : ''}`);
await page.selectOption('#project-select', 'pa');
await page.waitForTimeout(400);
check('XSS: 부품 이름이 코드로 실행되지 않음', (await page.evaluate(() => (window as any).__xss)) === undefined);
check('XSS: 프로젝트 이름이 태그로 렌더되지 않음', (await page.locator('#project-select option:checked').innerText()).includes('<b>XSS</b>'));
check('예산 게이지 표시', await page.locator('#budget-gauge').isVisible(), await page.locator('#budget-gauge-text').innerText());
await shot('1-parts-card');
await page.click('#parts-view-seg button[data-view=table]');
check('표 보기 렌더', (await page.locator('.rb-ptable tbody tr').count()) === 3);
await page.click('#parts-chips .rb-chip:has-text("보유")');
check('상태 필터(보유)', (await page.locator('.rb-ptable tbody tr').count()) === 1);
await page.click('#parts-chips .rb-chip:has-text("전체")');
await page.selectOption('#parts-sort', 'amount');
check('금액순 정렬(첫 행=Raspberry/모터 중 최대 소계)', (await page.locator('.rb-ptable tbody tr').first().locator('.rb-pt-sub').innerText()).includes('200,000'));
await shot('2-parts-table');
await page.click('.rb-tab-btn[data-tab=tasks]');
check('작업 탭 라벨에 진행률', (await page.locator('.rb-tab-btn[data-tab=tasks]').innerText()).includes('1/3'));
check('기한 지남 표시', (await page.locator('input.rb-due.overdue').count()) === 1);
await page.locator('.rb-task-row input[type=checkbox]').nth(1).check();
await page.waitForTimeout(100);
check('체크 시 진행률 즉시 갱신', (await page.locator('.rb-tab-btn[data-tab=tasks]').innerText()).includes('2/3'));
await shot('3-tasks');
await page.waitForTimeout(1200); // 저장 debounce
await page.click('button:has-text("🕘 이력")');
await page.waitForSelector('.rb-modal-bg.open');
await page.waitForTimeout(400);
check('이력 모달 열림', (await page.locator('.rb-hist-row').count()) >= 1);
await shot('4-history');
await page.keyboard.press('Escape');
// 충돌: 다른 곳에서 서버를 먼저 수정 → 내 편집 저장 시 배너
await page.click('.rb-tab-btn[data-tab=notes]');
await fetch(`${base}/api/workshop/project/pa?force=1`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: 'pa', name: '로봇팔 <b>XSS</b>', parts: [], phases: [], notes: '다른 곳에서 수정' }) });
await page.fill('#notes-textarea', '내가 쓴 메모');
await page.waitForTimeout(1200);
check('충돌 배너 표시', await page.locator('#conflict-banner').isVisible());
await shot('5-conflict');
await page.click('#conflict-banner button:has-text("서버 버전")');
await page.waitForTimeout(300);
check('서버 버전 채택 후 배너 사라짐+내용 반영', !(await page.locator('#conflict-banner').isVisible()) && (await page.inputValue('#notes-textarea')) === '다른 곳에서 수정');
// 보관
await page.selectOption('#project-select', 'pb');
await page.waitForTimeout(300);
await page.click('#archive-btn');
await page.waitForTimeout(600);
const opts = await page.locator('#project-select option').allInnerTexts();
check('보관 시 목록에서 숨김+다른 프로젝트로 전환', !opts.some(o => o.includes('보관될')) && opts.length >= 1, opts.join(' | '));
await page.check('#show-archived');
check('보관 포함 체크 시 표시(📦)', (await page.locator('#project-select option').allInnerTexts()).some(o => o.startsWith('📦')));
// 시세 조회 모달(다나와는 네트워크라 응답을 가로채 대체) + 적용
await put('pa', { name: '로봇팔', budget: 400000, parts: [{ id: 'p2', name: 'Raspberry Pi 5', qty: 1, unitPrice: 200000, status: '검토중', memo: '', links: [] }], phases: [], notes: '' });
await page.reload();
await page.waitForSelector('.rb-part-card, .rb-ptable');
await page.selectOption('#project-select', 'pa');
await page.waitForTimeout(300);
await page.click('.rb-tab-btn[data-tab=parts]');
await page.route('**/api/workshop/part-price', (route: any) => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ query: 'Raspberry Pi 5', items: [{ name: 'RPi5 8GB', price: 150000 }, { name: 'RPi5 4GB', price: 99000 }], min: 99000, median: 150000, count: 2 }) }));
await page.click('.rb-ptable tbody tr:first-child button:has-text("💰")').catch(async () => { await page.click('#parts-view-seg button[data-view=table]'); await page.click('.rb-ptable tbody tr:first-child button:has-text("💰")'); });
await page.waitForSelector('.rb-price-item');
check('시세 모달 결과 표시', (await page.locator('.rb-price-item').count()) === 2);
await page.click('button:has-text("단가로 적용")');
await page.waitForTimeout(200);
const firstPrice = await page.locator('.rb-ptable tbody tr').first().locator('input[type=number]').nth(1).inputValue();
check('최저가가 단가로 적용됨', firstPrice === '99000', firstPrice);
// CSV 내보내기(수식 주입 방어 포함)
const [dl] = await Promise.all([page.waitForEvent('download'), page.click('button:has-text("CSV")')]);
const csv = fs.readFileSync(await dl.path(), 'utf-8');
check('CSV 다운로드(BOM+합계)', csv.startsWith('\uFEFF') && csv.includes('합계'));
// 사진 첨부 팝업 + 태그 삽입
await page.click('.rb-attach-btn');
await page.waitForSelector('#attach-pop.open');
check('사진 첨부 팝업(업로드 버튼)', (await page.locator('#attach-pop').innerText()).includes('새 사진'));
await page.evaluate(() => (window as any).attachPhoto('사진/motor.jpg'));
check('[첨부 사진: …] 태그가 입력창에 들어감', (await page.inputValue('#chat-input')).includes('[첨부 사진: 사진/motor.jpg]'));
console.log(results.join('\n'));
console.log('페이지 JS 오류:', errors.length ? errors.join('; ') : '없음');
await browser.close();
server.close();
fs.rmSync(tmp, { recursive: true, force: true });
})().catch(e => { console.error('HARNESS ERROR', e); process.exit(1); });
+329 -10
View File
@@ -149,26 +149,38 @@ describe('workshop-app.html 저장 로직', () => {
function boot(server: any) {
const puts: any[] = [];
const el: any = { textContent: '', value: '', classList: { contains: () => false }, innerHTML: '' };
let clock = 1000;
const banner: any = { style: {}, innerHTML: '' };
const el: any = { textContent: '', value: '', classList: { contains: () => false }, innerHTML: '', style: {}, disabled: false };
const ctx: any = {
// 서버와 같은 낙관적 락: 클라이언트가 본 updatedAt이 현재 값과 다르면(force 아님) 409.
fetch: async (url: string, opt: any = {}) => {
if (url === '/api/workshop/data') return { ok: true, json: async () => JSON.parse(JSON.stringify(server)) };
const m = url.match(/^\/api\/workshop\/project\/(.+)$/);
if (url === '/api/workshop/data') return { ok: true, status: 200, json: async () => JSON.parse(JSON.stringify(server)) };
const m = url.match(/^\/api\/workshop\/project\/([^?]+)(\?force=1)?$/);
if (m && opt.method === 'PUT') {
const p = JSON.parse(opt.body); puts.push(p);
const p = JSON.parse(opt.body);
const i = server.projects.findIndex((x: any) => x.id === p.id);
const cur = server.projects[i];
if (cur && cur.updatedAt && (Number(p.updatedAt) || 0) !== cur.updatedAt && !m[2]) {
return { ok: false, status: 409, json: async () => ({ error: 'conflict', server: JSON.parse(JSON.stringify(cur)) }) };
}
puts.push(p);
p.updatedAt = ++clock;
if (i >= 0) server.projects[i] = p; else server.projects.push(p);
return { ok: true, status: 200, json: async () => ({ success: true, updatedAt: p.updatedAt }) };
}
return { ok: true, json: async () => ({}) };
return { ok: true, status: 200, json: async () => ({}) };
},
document: { getElementById: () => el }, window: { addEventListener() {} }, setTimeout, clearTimeout, JSON, console,
authH: () => ({}), renderParts() {}, renderPhases() {}, updateBudget() {}, refreshChatForProject() {}, clearStlSelection() {},
loadFiles() {}, expandedParts: new Set(), stlSelection: new Set(),
document: { getElementById: (id: string) => (id === 'conflict-banner' ? banner : el), activeElement: null }, window: { addEventListener() {} },
setTimeout, clearTimeout, JSON, console, escHtml: undefined,
authH: () => ({}), renderParts() {}, renderPhases() {}, updateBudget() {}, syncBudgetInput() {}, refreshChatForProject() {}, clearStlSelection() {},
loadFiles() {}, expandedParts: new Set(), stlSelection: new Set(), alert() {},
};
vm.createContext(ctx);
vm.runInContext([escHtmlLine, ...html.slice(start, start + 1), ...html.slice(from, to),
'this.api={loadWorkshopData,softRefreshWorkshop,setNotes:(v)=>{notes=v;scheduleSave();},escAttr};'].join('\n'), ctx);
return { api: ctx.api, puts };
`this.api={loadWorkshopData,softRefreshWorkshop,saveProject,resolveConflict,setNotes:(v)=>{notes=v;scheduleSave();},setBudget:(v)=>{budget=v;scheduleSave();},
get conflict(){return conflict},get local(){return {notes,budget,parts}},escAttr};`].join('\n'), ctx);
return { api: ctx.api, puts, banner, tick: () => ++clock };
}
const mkServer = () => ({ projects: [proj('p1', 'A')], activeProjectId: 'p1' });
@@ -206,4 +218,311 @@ describe('workshop-app.html 저장 로직', () => {
assert.equal(api.escAttr('A&B "q"'), 'A&amp;B &quot;q&quot;');
assert.equal(api.escAttr(0), '0');
});
test('다른 곳이 먼저 고쳤으면 저장을 보류하고 충돌 배너를 띄운다(조용히 덮지 않음)', async () => {
const server: any = mkServer();
const { api, banner, puts } = boot(server);
await api.loadWorkshopData();
server.projects[0].notes = '채팅 도구가 쓴 내용'; server.projects[0].updatedAt = 5000; // 내가 모르는 사이 서버가 바뀜
api.setNotes('내가 쓴 내용');
await new Promise(r => setTimeout(r, 900));
assert.equal(puts.length, 0);
assert.equal(server.projects[0].notes, '채팅 도구가 쓴 내용');
assert.ok(api.conflict);
assert.equal(banner.style.display, 'flex');
assert.match(banner.innerHTML, /덮어쓰기/);
});
test('충돌 해결 — 내 변경으로 덮어쓰기(force)', async () => {
const server: any = mkServer();
const { api, puts } = boot(server);
await api.loadWorkshopData();
server.projects[0].notes = '서버쪽'; server.projects[0].updatedAt = 5000;
api.setNotes('내 것');
await new Promise(r => setTimeout(r, 900));
await api.resolveConflict('mine');
assert.equal(server.projects[0].notes, '내 것');
assert.equal(api.conflict, null);
assert.equal(puts.length, 1);
});
test('충돌 해결 — 서버 버전 채택(내 변경 버림)', async () => {
const server: any = mkServer();
const { api } = boot(server);
await api.loadWorkshopData();
server.projects[0].notes = '서버쪽'; server.projects[0].updatedAt = 5000;
api.setNotes('내 것');
await new Promise(r => setTimeout(r, 900));
await api.resolveConflict('server');
assert.equal(api.conflict, null);
assert.equal(api.local.notes, '서버쪽');
assert.equal(server.projects[0].notes, '서버쪽');
});
test('폴링 갱신: 저장 대기/충돌 중엔 서버 값을 안 끌어오고, 조용할 땐 반영한다', async () => {
const server: any = mkServer();
const { api } = boot(server);
await api.loadWorkshopData();
server.projects[0].notes = '다른 탭이 쓴 내용'; server.projects[0].updatedAt = 7000;
await api.softRefreshWorkshop({ poll: true }); // 조용함 → 반영
assert.equal(api.local.notes, '다른 탭이 쓴 내용');
api.setNotes('타이핑 중'); // 저장 대기 중
server.projects[0].notes = '또 다른 변경'; server.projects[0].updatedAt = 8000;
await api.softRefreshWorkshop({ poll: true }); // 대기 중 → 건드리지 않음
assert.equal(api.local.notes, '타이핑 중');
});
test('목표 예산 변경도 저장되고 스냅샷 기반 dirty에 잡힌다', async () => {
const server: any = mkServer();
const { api } = boot(server);
await api.loadWorkshopData();
api.setBudget(300000);
await new Promise(r => setTimeout(r, 900));
assert.equal(server.projects[0].budget, 300000);
});
});
// ══ 업그레이드(2026-09-24): 이력/버전/도구 확장/출력 감시 ═════════════════════════════
import {
recordHistory, listHistory, readHistory, restoreFromHistory, listDeletedProjects, duplicateProject, projectProgress,
projectToReadme, HISTORY_KEEP,
} from '../src/gateway/routes/workshop-storage';
import { judgePrintJob, applyPrintResult } from '../src/gateway/workshop-print-watch';
describe('변경 이력과 버전(updatedAt)', () => {
test('저장하면 바뀐 프로젝트만 updatedAt이 오르고, 안 바뀐 프로젝트는 그대로', () => {
const ws = tmpWorkspace({ activeProjectId: 'a', projects: [proj('a', 'A'), proj('b', 'B')] });
const v1 = loadWorkshop(ws);
const [aV, bV] = [v1.projects[0].updatedAt!, v1.projects[1].updatedAt!];
assert.ok(aV > 0 && bV > 0);
v1.projects[0].notes = '바뀜';
saveWorkshop(ws, v1);
const v2 = loadWorkshop(ws);
assert.ok(v2.projects[0].updatedAt! > aV);
assert.equal(v2.projects[1].updatedAt, bV);
saveWorkshop(ws, v2); // 무변경 저장
assert.equal(loadWorkshop(ws).projects[0].updatedAt, v2.projects[0].updatedAt);
});
test('바뀌기 전 상태가 이력에 남고, 45초 안의 연속 저장은 하나로 합친다', () => {
const ws = tmpWorkspace({ activeProjectId: 'a', projects: [proj('a', 'A', { notes: '원본' })] });
for (const n of ['수정1', '수정2', '수정3']) {
const d = loadWorkshop(ws); d.projects[0].notes = n; saveWorkshop(ws, d);
}
const hist = listHistory(ws, 'a');
assert.equal(hist.length, 1);
assert.equal(readHistory(ws, 'a', hist[0].ts)!.notes, '원본'); // 구간 시작 상태
});
test('이력은 프로젝트당 HISTORY_KEEP개까지만', () => {
const ws = tmpWorkspace({ activeProjectId: 'a', projects: [proj('a', 'A')] });
for (let i = 0; i < HISTORY_KEEP + 5; i++) recordHistory(ws, proj('a', 'A', { notes: String(i) }) as any, true);
assert.equal(listHistory(ws, 'a').length, HISTORY_KEEP);
});
test('이력 복원: 되돌리기 직전 상태도 이력에 남는다(되돌리기 취소 가능)', () => {
const ws = tmpWorkspace({ activeProjectId: 'a', projects: [proj('a', 'A', { notes: '소중한 메모' })] });
let d = loadWorkshop(ws); d.projects[0].notes = ''; saveWorkshop(ws, d); // 실수로 비움
const ts = listHistory(ws, 'a')[0].ts;
d = loadWorkshop(ws);
assert.ok(restoreFromHistory(ws, d, 'a', ts));
saveWorkshop(ws, d);
assert.equal(loadWorkshop(ws).projects[0].notes, '소중한 메모');
assert.ok(listHistory(ws, 'a').length >= 2); // 복원 직전(빈 메모) 상태도 보존
});
test('삭제된 프로젝트는 이력으로 복구 후보에 오른다', () => {
const ws = tmpWorkspace({ activeProjectId: 'a', projects: [proj('a', 'A'), proj('b', '지운 프로젝트', { notes: '중요' })] });
const d = loadWorkshop(ws);
recordHistory(ws, d.projects[1], true);
d.projects = d.projects.filter(p => p.id !== 'b');
saveWorkshop(ws, d);
const del = listDeletedProjects(ws, loadWorkshop(ws));
assert.equal(del.length, 1);
assert.equal(del[0].name, '지운 프로젝트');
});
});
describe('복제 · 진행률 · 개요 문서', () => {
const rich = () => proj('a', '원본', {
budget: 500000, archived: true, updatedAt: 5,
parts: [{ id: 'p1', name: '모터', qty: 2, unitPrice: 1000, status: '주문완료', memo: '', links: [{ id: 'l1', label: '쿠팡', url: 'https://x' }], priceCheck: { min: 1, median: 2, count: 3, at: 4 } }],
phases: [{ id: 'ph1', name: '준비', tasks: [{ id: 't1', text: '출력', done: true, due: '2026-10-01' }, { id: 't2', text: '조립', done: false }] }],
});
test('duplicateProject: 새 id·진행상태 초기화·구조 유지', () => {
const c = duplicateProject(rich() as any, 'proj_new');
assert.equal(c.id, 'proj_new');
assert.equal(c.name, '원본 (복제)');
assert.equal(c.archived, false);
assert.equal(c.budget, 500000);
assert.equal(c.parts[0].status, '검토중');
assert.equal(c.parts[0].priceCheck, undefined);
assert.notEqual(c.parts[0].id, 'p1');
assert.notEqual(c.parts[0].links[0].id, 'l1');
assert.equal(c.phases[0].tasks.every(t => !t.done), true);
assert.equal(c.phases[0].tasks[0].due, '2026-10-01');
});
test('projectProgress와 get 출력(진행률·목표예산·기한)', () => {
const p: any = rich();
assert.deepEqual(projectProgress(p), { done: 1, total: 2, pct: 50 });
const md = projectToReadme(p);
assert.ok(md.includes('작업 진행: 1/2 (50%)'));
assert.ok(md.includes('목표 예산: 500,000원'));
assert.ok(md.includes('(기한 2026-10-01)'));
});
test('normalizeProjectInput: budget/기한 검증', () => {
assert.equal(normalizeProjectInput({ id: 'p', name: 'A', parts: [], phases: [], budget: -1 }, 'p').ok, false);
const r = normalizeProjectInput({ id: 'p', name: 'A', parts: [], phases: [{ id: 'x', name: 's', tasks: [{ id: 't', text: 'a', done: false, due: '내일' }] }], budget: '1000' }, 'p');
assert.ok(r.ok);
if (r.ok) { assert.equal(r.project.budget, 1000); assert.equal(r.project.phases[0].tasks[0].due, undefined); }
});
});
describe('workshop_project 새 액션', () => {
const base = () => tmpWorkspace({
activeProjectId: 'a',
projects: [proj('a', '로봇', {
notes: '옛 메모 1\n옛 메모 2',
phases: [{ id: 'ph1', name: '준비', tasks: [{ id: 't1', text: '베이스 출력', done: false }, { id: 't2', text: '조립', done: false }] }, { id: 'ph2', name: '검증', tasks: [] }],
}), proj('b', '다른')],
});
const load = (ws: string) => loadWorkshop(ws);
test('set_notes는 메모 전체를 교체하고 get으로 읽은 내용을 정리할 수 있다', async () => {
const ws = base();
assert.equal((await run(ws, { action: 'set_notes', notes_text: '| a | b |\n|---|---|\n| 1 | 2 |' })).success, true);
assert.equal(load(ws).projects[0].notes, '| a | b |\n|---|---|\n| 1 | 2 |');
assert.equal((await run(ws, { action: 'set_notes' })).success, false); // 파라미터 누락은 조용히 비우지 않는다
assert.ok(load(ws).projects[0].notes.length > 0);
});
test('update_task/delete_task/rename_phase/delete_phase', async () => {
const ws = base();
assert.equal((await run(ws, { action: 'update_task', task_text: '베이스', new_text: '베이스 플레이트 출력', due: '2026-10-05' })).success, true);
let t = load(ws).projects[0].phases[0].tasks[0];
assert.equal(t.text, '베이스 플레이트 출력'); assert.equal(t.due, '2026-10-05');
assert.equal((await run(ws, { action: 'update_task', task_text: '베이스', due: '내일' })).success, false);
await run(ws, { action: 'update_task', task_text: '베이스', due: '' });
assert.equal(load(ws).projects[0].phases[0].tasks[0].due, undefined);
assert.equal((await run(ws, { action: 'delete_task', task_text: '조립' })).success, true);
assert.equal(load(ws).projects[0].phases[0].tasks.length, 1);
await run(ws, { action: 'rename_phase', phase_name: '검증', new_phase_name: '테스트' });
assert.equal(load(ws).projects[0].phases[1].name, '테스트');
await run(ws, { action: 'delete_phase', phase_name: '테스트' });
assert.equal(load(ws).projects[0].phases.length, 1);
});
test('add_parts 일괄 추가(중복은 건너뜀)', async () => {
const ws = base();
const r = await run(ws, { action: 'add_parts', parts: [{ name: '608ZZ', qty: 4, unit_price: 500 }, { name: 'M3 나사', qty: 20 }, { name: '608zz' }] });
assert.equal(r.success, true, r.error);
const parts = load(ws).projects[0].parts;
assert.equal(parts.length, 2);
assert.match(r.stdout || '', /건너뜀: 608zz/);
});
test('set_budget · rename_project · archive · duplicate', async () => {
const ws = base();
await run(ws, { action: 'set_budget', budget: 300000 });
assert.equal(load(ws).projects[0].budget, 300000);
assert.equal((await run(ws, { action: 'rename_project', new_project_name: '다른' })).success, false); // 이름 충돌
await run(ws, { action: 'rename_project', new_project_name: '로봇팔' });
assert.equal(load(ws).projects[0].name, '로봇팔');
await run(ws, { action: 'archive_project', project_name: '로봇팔' });
let d = load(ws);
assert.equal(d.projects[0].archived, true);
assert.equal(d.activeProjectId, 'b'); // 활성이던 프로젝트를 보관하면 다른 프로젝트로 전환
await run(ws, { action: 'unarchive_project', project_name: '로봇팔' });
assert.equal(load(ws).projects[0].archived, false);
await run(ws, { action: 'duplicate_project', project_name: '로봇팔' });
d = load(ws);
assert.equal(d.projects.length, 3);
assert.equal(d.projects[2].name, '로봇팔 (복제)');
});
test('delete_project는 confirm=true 필수, 마지막 프로젝트 삭제 불가, 이력에서 복구 가능', async () => {
const ws = base();
assert.equal((await run(ws, { action: 'delete_project', project_name: '다른' })).success, false);
assert.equal(load(ws).projects.length, 2);
assert.equal((await run(ws, { action: 'delete_project', project_name: '다른', confirm: true })).success, true);
assert.equal(load(ws).projects.length, 1);
assert.equal(listDeletedProjects(ws, load(ws))[0]?.name, '다른');
assert.equal((await run(ws, { action: 'delete_project', project_name: '로봇', confirm: true })).success, false);
});
test('list_history/restore_history: 메모를 실수로 교체해도 되돌릴 수 있다', async () => {
const ws = base();
await run(ws, { action: 'set_notes', notes_text: '' });
const listed = await run(ws, { action: 'list_history' });
const ts = Number(/ts=(\d+)/.exec(listed.stdout || '')?.[1]);
assert.ok(ts > 0, listed.stdout);
assert.equal((await run(ws, { action: 'restore_history', ts })).success, true);
assert.equal(load(ws).projects[0].notes, '옛 메모 1\n옛 메모 2');
assert.equal((await run(ws, { action: 'restore_history', ts: 1 })).success, false);
});
test('add_task 기한 검증, list_projects 진행률/보관 표시', async () => {
const ws = base();
assert.equal((await run(ws, { action: 'add_task', phase_name: '준비', task_text: '검수', due: '2026-11-01' })).success, true);
assert.equal((await run(ws, { action: 'add_task', phase_name: '준비', task_text: '검수2', due: '11월' })).success, false);
await run(ws, { action: 'archive_project', project_name: '다른' });
const l = await run(ws, { action: 'list_projects' });
assert.match(l.stdout || '', /로봇.*작업 0\/3/);
assert.match(l.stdout || '', /다른 \[보관됨\]/);
});
});
describe('K2 출력 감시', () => {
const job = { workspace: '/x', projectId: 'a', filename: 'print3d_1.gcode', startedAt: 1_000_000 };
const st = (state: string, filename = 'print3d_1.gcode') => ({ state, filename, printDurationSec: 600 });
test('judgePrintJob 판정표', () => {
const t = job.startedAt + 60_000;
assert.equal(judgePrintJob(job, st('complete'), t, true), 'complete');
assert.equal(judgePrintJob(job, st('complete', 'gcodes/print3d_1.gcode'), t, true), 'complete'); // 경로 접두어 무시
assert.equal(judgePrintJob(job, st('cancelled'), t, true), 'failed');
assert.equal(judgePrintJob(job, st('error'), t, true), 'failed');
assert.equal(judgePrintJob(job, st('printing'), t, true), 'keep');
assert.equal(judgePrintJob(job, st('paused'), t, true), 'keep');
assert.equal(judgePrintJob(job, null, t, true), 'keep'); // 프린터 일시 무응답
assert.equal(judgePrintJob(job, st('complete', 'other.gcode'), t, true), 'keep'); // 다른 파일의 완료는 무관
assert.equal(judgePrintJob(job, st('standby', ''), job.startedAt + 4 * 3600_000, false), 'drop'); // 시작이 안 된 job
assert.equal(judgePrintJob(job, st('standby', ''), job.startedAt + 4 * 3600_000, true), 'keep'); // 출력 도중 대기는 유지
assert.equal(judgePrintJob(job, st('printing'), job.startedAt + 73 * 3600_000, true), 'drop'); // 너무 오래됨
});
test('applyPrintResult: 작업 자동 완료 + 메모, 모호하면 완료 처리 건너뜀', () => {
const ws = tmpWorkspace({ activeProjectId: 'a', projects: [proj('a', 'A', {
phases: [{ id: 'ph', name: '출력', tasks: [{ id: 't1', text: '베이스 출력', done: false }, { id: 't2', text: '헤드 출력', done: false }] }],
})] });
const j = { workspace: ws, projectId: 'a', filename: 'x.gcode', taskText: '베이스', startedAt: 1 };
const summary = applyPrintResult(j, 'complete', 3700);
assert.match(summary, /출력 완료: x\.gcode/);
const p = loadWorkshop(ws).projects[0];
assert.equal(p.phases[0].tasks[0].done, true);
assert.equal(p.phases[0].tasks[1].done, false);
assert.match(p.notes, /🖨️ 출력 완료: x\.gcode \(소요 1시간 \d+분\)/);
applyPrintResult({ ...j, taskText: '출력' }, 'complete', 0); // "출력"은 둘 다 일치 → 건너뜀
assert.equal(loadWorkshop(ws).projects[0].phases[0].tasks[1].done, false);
applyPrintResult(j, 'failed', 0);
assert.match(loadWorkshop(ws).projects[0].notes, /⚠️ 출력 실패\/취소: x\.gcode/);
});
});
describe('workshop_project view_image (사진 첨부 채팅)', () => {
test('프로젝트 사진을 찾아 /api/files 마크다운으로 돌려준다(모델이 직접 보게 됨)', async () => {
const ws = tmpWorkspace({ activeProjectId: 'a', projects: [proj('a', 'A')] });
const dir = path.join(ws, 'workshop', 'a', '사진');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'motor.jpg'), 'x');
fs.writeFileSync(path.join(dir, 'motor2.jpg'), 'x');
fs.writeFileSync(path.join(dir, 'note.txt'), 'x');
const r = await run(ws, { action: 'view_image', file_name: '사진/motor.jpg' });
assert.equal(r.success, true, r.error);
assert.match(r.stdout || '', /!\[motor\.jpg\]\(\/api\/files\/workshop\/a\/%EC%82%AC%EC%A7%84\/motor\.jpg\)/);
// 파일명만 줘도, 정확히 일치하는 쪽이 우선(motor vs motor2)
assert.equal((await run(ws, { action: 'view_image', file_name: 'motor.jpg' })).success, true);
assert.equal((await run(ws, { action: 'view_image', file_name: 'motor' })).success, false); // 모호(motor.jpg/motor2.jpg)
assert.equal((await run(ws, { action: 'view_image', file_name: '없음.png' })).success, false);
assert.equal((await run(ws, { action: 'view_image' })).success, false);
});
});
+104
View File
@@ -0,0 +1,104 @@
// 작업실 라우트 통합 테스트 — 실제 registerWorkshopRoutes를 임시 데이터 디렉토리(SMALLCLAW_DATA_DIR)에서
// 띄워 HTTP로 검증한다(가짜 세션). 낙관적 락(409)/이력/복제/삭제 정리/% 파일명 업로드/썸네일.
import test from 'node:test';
import assert from 'node:assert/strict';
import express from 'express';
import fs from 'fs';
import path from 'path';
import os from 'os';
import http from 'http';
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wsint-'));
process.env.SMALLCLAW_DATA_DIR = tmp;
test('작업실 라우트 통합', async () => {
const { registerWorkshopRoutes } = await import('../src/gateway/routes/routes-workshop');
const app = express();
app.use(express.json());
const isInside = (b: string, t: string) => path.resolve(t).startsWith(path.resolve(b) + path.sep);
registerWorkshopRoutes(app, () => ({ username: 'tester' }), isInside);
const server = http.createServer(app);
await new Promise<void>(r => server.listen(0, r));
const base = `http://127.0.0.1:${(server.address() as any).port}`;
const j = async (m: string, u: string, body?: any) => {
const r = await fetch(base + u, { method: m, headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined });
const ct = r.headers.get('content-type') || '';
return { status: r.status, body: ct.includes('json') ? await r.json() : null, res: r };
};
const mk = (over: any = {}) => ({ id: 'pt', name: '임시', parts: [{ id: 'p1', name: '모터', qty: 2, unitPrice: 1000, status: '검토중', memo: '', links: [] }],
phases: [{ id: 'ph1', name: '준비', tasks: [{ id: 't1', text: '출력', done: false, due: '2026-10-01' }] }], notes: '', budget: 500000, ...over });
try {
// 1) 최초 저장 → updatedAt 발급
let r = await j('PUT', '/api/workshop/project/pt', mk());
assert.equal(r.status, 200); assert.ok(r.body.updatedAt > 0);
const v1 = r.body.updatedAt;
// 2) 낡은 버전(0)으로 저장 → 409 + 서버 버전
r = await j('PUT', '/api/workshop/project/pt', mk({ notes: '충돌', updatedAt: 1 }));
assert.equal(r.status, 409); assert.equal(r.body.server.id, 'pt');
// 3) 올바른 버전 → 200, 버전 상승
r = await j('PUT', '/api/workshop/project/pt', mk({ notes: '수정1', updatedAt: v1 }));
assert.equal(r.status, 200); assert.ok(r.body.updatedAt > v1);
// 4) force=1 → 낡은 버전이어도 통과
r = await j('PUT', '/api/workshop/project/pt?force=1', mk({ notes: '강제', updatedAt: 1 }));
assert.equal(r.status, 200);
// 5) 이력 생성 확인 (바뀌기 전 상태)
r = await j('GET', '/api/workshop/history/pt');
assert.ok(r.body.history.length >= 1);
// 6) 옛 전체문서 PUT은 없어졌다
r = await j('PUT', '/api/workshop/data', { projects: [] });
assert.equal(r.status, 404);
// 7) 잘못된 본문 → 400
r = await j('PUT', '/api/workshop/project/pt', mk({ notes: 5 }));
assert.equal(r.status, 400);
// 8) 되돌리기
const ts = (await j('GET', '/api/workshop/history/pt')).body.history.at(-1).ts;
r = await j('POST', '/api/workshop/history/pt/restore', { ts });
assert.equal(r.status, 200);
// 9) 복제 → 새 프로젝트, 작업 완료 초기화
r = await j('POST', '/api/workshop/project/pt/duplicate');
assert.equal(r.status, 200);
const data = (await j('GET', '/api/workshop/data')).body;
const copy = data.projects.find((p: any) => p.id === r.body.id);
assert.equal(copy.name, '임시 (복제)');
assert.equal(data.activeProjectId, copy.id);
// 10) 파일 업로드: % 파일명이 서버를 죽이지 않고 저장된다
const boundary = 'XBOUND';
const filename = '100%_infill.stl';
const fileBytes = Buffer.alloc(84); fileBytes.write('solid', 0);
const payload = Buffer.concat([
Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: application/octet-stream\r\n\r\n`),
fileBytes, Buffer.from(`\r\n--${boundary}--\r\n`),
]);
const up = await fetch(`${base}/api/workshop/upload?projectId=pt`, { method: 'POST', headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}` }, body: payload });
const upBody: any = await up.json();
assert.equal(up.status, 200, JSON.stringify(upBody)); assert.equal(upBody.name, filename);
// 11) 썸네일: ASCII/무효 STL → 404(클라이언트가 아이콘 폴백), 잘못된 경로 → 400
r = await j('GET', `/api/workshop/thumb?projectId=pt&relPath=${encodeURIComponent(filename)}`);
assert.ok([200, 404].includes(r.status)); // 무효 STL은 렌더 결과에 따라 404(클라이언트가 🔧 아이콘으로 폴백) 또는 빈 이미지
// 유효한 바이너리 STL(사면체) → OpenSCAD 렌더 PNG
const tri = (a: number[], b: number[], c: number[]) => { const buf = Buffer.alloc(50); [0, 0, 0, ...a, ...b, ...c].forEach((v, i) => buf.writeFloatLE(v, i * 4)); return buf; };
const stl = Buffer.concat([Buffer.alloc(80), Buffer.from(Uint32Array.of(4).buffer),
tri([0, 0, 0], [10, 0, 0], [0, 10, 0]), tri([0, 0, 0], [0, 0, 10], [10, 0, 0]), tri([0, 0, 0], [0, 10, 0], [0, 0, 10]), tri([10, 0, 0], [0, 0, 10], [0, 10, 0])]);
const wsDir = path.join(tmp, '.smallclaw', 'users', 'tester', 'workspace', 'workshop', 'pt');
fs.writeFileSync(path.join(wsDir, 'tetra.stl'), stl);
const th = await fetch(`${base}/api/workshop/thumb?projectId=pt&relPath=tetra.stl`);
assert.equal(th.status, 200);
assert.equal(th.headers.get('content-type'), 'image/png');
assert.ok((await th.arrayBuffer()).byteLength > 500);
r = await j('GET', '/api/workshop/thumb?projectId=pt&relPath=' + encodeURIComponent('../../etc/passwd.stl'));
assert.equal(r.status, 400);
// 12) 삭제 → 첨부파일 폴더 정리 + 이력에서 복구 후보
r = await j('DELETE', '/api/workshop/project/pt');
assert.equal(r.status, 200);
assert.equal(fs.existsSync(path.join(tmp, '.smallclaw', 'users', 'tester', 'workspace', 'workshop', 'pt')), false);
r = await j('GET', '/api/workshop/trash');
assert.ok(r.body.deleted.some((d: any) => d.id === 'pt'));
r = await j('POST', '/api/workshop/trash/pt/restore');
assert.equal(r.status, 200);
assert.ok((await j('GET', '/api/workshop/data')).body.projects.some((p: any) => p.id === 'pt'));
} finally {
await new Promise(r => server.close(r));
fs.rmSync(tmp, { recursive: true, force: true });
}
});
+614 -18
View File
@@ -24,7 +24,17 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
.rb-budget{display:flex;gap:0;border-bottom:1px solid var(--line);background:var(--panel);flex-shrink:0;flex-wrap:wrap;}
.rb-budget-item{flex:1;min-width:120px;padding:8px 14px;border-right:1px solid var(--line);}
.rb-budget-item:last-child{border-right:none;background:rgba(245,158,11,.06);}
.rb-budget-item:last-child{border-right:none;}
.rb-budget-item.total{background:rgba(245,158,11,.06);}
.rb-budget-target{display:flex;align-items:center;gap:4px;margin-top:2px;}
.rb-budget-target input{width:110px;background:var(--panel-2);border:1px solid var(--line);border-radius:5px;padding:2px 6px;font-size:13px;font-weight:700;color:var(--text);font-family:inherit;outline:none;}
.rb-budget-target input:focus{border-color:#f59e0b;}
.rb-gauge{height:5px;background:var(--line);border-radius:3px;overflow:hidden;margin-top:6px;}
.rb-gauge>i{display:block;height:100%;width:0;background:#22c55e;transition:width .3s,background .3s;}
.rb-gauge.warn>i{background:#f59e0b;}
.rb-gauge.over>i{background:#ef4444;}
.rb-gauge-text{font-size:10px;color:var(--muted);margin-top:3px;}
.rb-gauge-text.over{color:#ef4444;font-weight:700;}
.rb-budget-label{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;}
.rb-budget-val{font-size:15px;font-weight:700;margin-top:2px;}
@@ -84,6 +94,76 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
.rb-model-opt.current{color:#f59e0b;font-weight:700;}
.rb-model-dropdown-msg{padding:6px 10px;font-size:11px;color:var(--muted);}
/* ── 충돌 배너 / 모달 / 부품 표·툴바 / 진행률 (2026-09-24 업그레이드) ── */
.rb-conflict{display:none;background:rgba(239,68,68,.12);border-bottom:1px solid rgba(239,68,68,.4);padding:7px 14px;font-size:12px;color:#fca5a5;align-items:center;gap:10px;flex-wrap:wrap;flex-shrink:0;}
.rb-conflict b{color:#fecaca;}
.rb-conflict button{background:none;border:1px solid rgba(239,68,68,.6);border-radius:6px;padding:3px 10px;font-size:11px;color:#fecaca;cursor:pointer;font-family:inherit;}
.rb-conflict button:hover{background:rgba(239,68,68,.25);}
.rb-modal-bg{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:200;align-items:center;justify-content:center;padding:16px;}
.rb-modal-bg.open{display:flex;}
.rb-modal{background:var(--panel);border:1px solid var(--line);border-radius:12px;width:min(560px,100%);max-height:80dvh;display:flex;flex-direction:column;box-shadow:0 12px 40px rgba(0,0,0,.5);}
.rb-modal-hdr{display:flex;align-items:center;padding:12px 16px;border-bottom:1px solid var(--line);font-size:13px;font-weight:700;}
.rb-modal-hdr button{margin-left:auto;background:none;border:none;color:var(--muted);font-size:16px;cursor:pointer;}
.rb-modal-body{padding:12px 16px;overflow-y:auto;font-size:12px;}
.rb-modal-body h4{margin:12px 0 6px;font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;}
.rb-modal-body h4:first-child{margin-top:0;}
.rb-hist-row{display:flex;align-items:center;gap:10px;padding:7px 0;border-bottom:1px solid var(--line);}
.rb-hist-row:last-child{border-bottom:none;}
.rb-hist-row .when{font-weight:600;min-width:130px;}
.rb-hist-row .sum{flex:1;color:var(--muted);font-size:11px;}
.rb-hist-row button{background:none;border:1px solid var(--line);border-radius:6px;padding:3px 10px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;}
.rb-hist-row button:hover{border-color:#f59e0b;color:#f59e0b;}
.rb-empty{color:var(--muted);padding:10px 0;font-size:12px;}
.rb-parts-bar{display:flex;gap:8px;align-items:center;margin-bottom:10px;flex-wrap:wrap;}
.rb-chip{background:none;border:1px solid var(--line);border-radius:14px;padding:3px 11px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;}
.rb-chip:hover{border-color:#f59e0b;color:#f59e0b;}
.rb-chip.active{background:rgba(245,158,11,.15);border-color:#f59e0b;color:#f59e0b;font-weight:700;}
.rb-parts-bar select{background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:4px 8px;font-size:11px;color:var(--text);font-family:inherit;outline:none;}
.rb-seg{display:inline-flex;border:1px solid var(--line);border-radius:6px;overflow:hidden;}
.rb-seg button{background:none;border:none;padding:4px 10px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;}
.rb-seg button.active{background:rgba(245,158,11,.15);color:#f59e0b;font-weight:700;}
.rb-ptable{width:100%;border-collapse:collapse;font-size:12px;}
.rb-ptable th{text-align:left;font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;padding:6px 6px;border-bottom:1px solid var(--line);white-space:nowrap;}
.rb-ptable th.sortable{cursor:pointer;}
.rb-ptable th.sortable:hover{color:#f59e0b;}
.rb-ptable td{padding:3px 4px;border-bottom:1px solid var(--line);vertical-align:middle;}
.rb-ptable input[type=text],.rb-ptable input[type=number],.rb-ptable select{width:100%;box-sizing:border-box;background:transparent;border:1px solid transparent;border-radius:5px;padding:4px 6px;font-size:12px;color:var(--text);font-family:inherit;outline:none;}
.rb-ptable input:hover,.rb-ptable select:hover{border-color:var(--line);}
.rb-ptable input:focus,.rb-ptable select:focus{border-color:#f59e0b;background:var(--panel-2);}
.rb-ptable td.num{text-align:right;white-space:nowrap;}
.rb-ptable td.sub{text-align:right;white-space:nowrap;color:var(--muted);padding-right:8px;}
.rb-ptable tfoot td{font-weight:700;border-bottom:none;padding-top:8px;}
.rb-price-line{font-size:11px;color:var(--muted);margin-top:8px;}
.rb-price-line .down{color:#4ade80;font-weight:700;}
.rb-price-line .up{color:#f87171;font-weight:700;}
.rb-mini-btn{background:none;border:1px solid var(--line);border-radius:6px;padding:4px 10px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;}
.rb-mini-btn:hover{border-color:#f59e0b;color:#f59e0b;}
.rb-prog{height:6px;background:var(--line);border-radius:3px;overflow:hidden;}
.rb-prog>i{display:block;height:100%;background:#22c55e;transition:width .3s;}
.rb-prog-all{margin-bottom:14px;}
.rb-prog-all .rb-prog{height:8px;}
.rb-prog-label{font-size:11px;color:var(--muted);margin-bottom:5px;display:flex;justify-content:space-between;}
.rb-phase-count{font-size:10px;color:var(--muted);flex-shrink:0;}
.rb-phase-prog{width:60px;flex-shrink:0;}
.rb-task-row input.rb-due{flex:0 0 auto;width:118px;background:var(--panel-2);border:1px solid var(--line);border-radius:5px;padding:3px 5px;font-size:11px;color:var(--muted);font-family:inherit;outline:none;}
.rb-task-row input.rb-due.overdue{border-color:#ef4444;color:#f87171;}
.rb-task-row input.rb-due.soon{border-color:#f59e0b;color:#f59e0b;}
.rb-price-item{display:flex;gap:8px;padding:5px 0;border-bottom:1px solid var(--line);}
.rb-price-item span:first-child{flex:1;}
.rb-attach-wrap{position:relative;flex-shrink:0;}
.rb-attach-btn{background:none;border:1px solid var(--line);border-radius:8px;padding:6px 8px;font-size:13px;color:var(--muted);cursor:pointer;}
.rb-attach-btn:hover{border-color:#f59e0b;color:#f59e0b;}
.rb-attach-pop{display:none;position:absolute;bottom:100%;left:0;margin-bottom:6px;background:var(--panel-2);border:1px solid var(--line);border-radius:10px;box-shadow:0 4px 16px rgba(0,0,0,.4);width:250px;max-height:280px;overflow-y:auto;z-index:60;padding:8px;}
.rb-attach-pop.open{display:block;}
.rb-attach-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-top:6px;}
.rb-attach-grid img{width:100%;aspect-ratio:1/1;object-fit:cover;border-radius:6px;cursor:pointer;border:1px solid var(--line);}
.rb-attach-grid img:hover{border-color:#f59e0b;}
.rb-file-thumb img.stl-thumb{object-fit:contain;background:#2b2b2b;}
.rb-hdr-check{font-size:10px;color:var(--muted);display:flex;align-items:center;gap:3px;cursor:pointer;flex-shrink:0;}
@media (max-width:900px){
.rb-budget-target input{width:90px;}
.rb-ptable{font-size:11px;}
}
@media (max-width:900px){
.rb-layout{flex-direction:column;}
.rb-resize{display:none;}
@@ -209,16 +289,28 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
<select class="ws-project-select" id="project-select" onchange="switchProject(this.value)"></select>
<button class="hdr-btn" onclick="createProjectPrompt()">+ 새 프로젝트</button>
<button class="hdr-btn" onclick="deleteProjectPrompt()">🗑 삭제</button>
<button class="hdr-btn" onclick="duplicateProjectPrompt()" title="구조를 그대로 복제(작업 완료·부품 상태는 초기화)">⧉ 복제</button>
<button class="hdr-btn" id="archive-btn" onclick="toggleArchiveProject()" title="목록에서 숨김(데이터·파일은 유지)">📦 보관</button>
<button class="hdr-btn" onclick="openHistory()" title="변경 이력 / 되돌리기 / 삭제된 프로젝트 복구">🕘 이력</button>
<label class="rb-hdr-check" title="보관된 프로젝트도 목록에 표시"><input type="checkbox" id="show-archived" onchange="toggleShowArchived(this.checked)">보관 포함</label>
<span class="rb-hdr-sp"></span>
<span class="rb-save-indicator" id="save-indicator"></span>
<button class="hdr-btn" id="theme-btn" onclick="toggleTheme()">🌙</button>
</div>
<div class="rb-conflict" id="conflict-banner"></div>
<div class="rb-budget">
<div class="rb-budget-item"><div class="rb-budget-label">검토중</div><div class="rb-budget-val" id="sum-review">0원</div></div>
<div class="rb-budget-item"><div class="rb-budget-label">주문완료</div><div class="rb-budget-val" id="sum-ordered">0원</div></div>
<div class="rb-budget-item"><div class="rb-budget-label">보유</div><div class="rb-budget-val" id="sum-owned">0원</div></div>
<div class="rb-budget-item"><div class="rb-budget-label">총 예상비용</div><div class="rb-budget-val" id="sum-total">0원</div></div>
<div class="rb-budget-item total"><div class="rb-budget-label">총 예상비용</div><div class="rb-budget-val" id="sum-total">0원</div></div>
<div class="rb-budget-item" style="min-width:170px">
<div class="rb-budget-label">목표 예산</div>
<div class="rb-budget-target"><input type="number" min="0" step="10000" id="budget-input" placeholder="미설정" oninput="onBudgetChange()"><span style="font-size:12px">원</span></div>
<div class="rb-gauge" id="budget-gauge" style="display:none"><i id="budget-gauge-bar"></i></div>
<div class="rb-gauge-text" id="budget-gauge-text"></div>
</div>
</div>
<div class="rb-layout">
@@ -234,11 +326,28 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
<div class="rb-body">
<div class="rb-pane active" id="pane-parts">
<div class="rb-parts-bar">
<div class="rb-seg" id="parts-view-seg">
<button data-view="card" onclick="setPartsView('card')">카드</button>
<button data-view="table" onclick="setPartsView('table')">표</button>
</div>
<span id="parts-chips"></span>
<select id="parts-sort" onchange="setPartsSort(this.value)" title="정렬(화면 표시만 바뀌고 저장 순서는 유지)">
<option value="order">등록순</option>
<option value="name">이름순</option>
<option value="amount">금액 큰 순</option>
<option value="status">상태순</option>
</select>
<span class="rb-hdr-sp"></span>
<button class="rb-mini-btn" onclick="exportBom('csv')" title="엑셀에서 열 수 있는 CSV">⬇ CSV</button>
<button class="rb-mini-btn" onclick="exportBom('md')" title="마크다운 표">⬇ MD</button>
</div>
<div id="parts-container"></div>
<button class="rb-add-btn" onclick="addPart()">+ 부품 추가</button>
</div>
<div class="rb-pane" id="pane-tasks">
<div class="rb-prog-all" id="tasks-progress"></div>
<div id="phases-container"></div>
<button class="rb-phase-add" onclick="addPhase()">+ 단계 추가</button>
</div>
@@ -311,6 +420,10 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
</div>
<div class="rb-msgs" id="chat-msgs"></div>
<div class="rb-input-bar">
<div class="rb-attach-wrap">
<button class="rb-attach-btn" onclick="toggleAttachPop(event)" title="이 프로젝트의 사진을 채팅에 첨부(모델이 직접 봄)">📷</button>
<div class="rb-attach-pop" id="attach-pop"></div>
</div>
<textarea class="rb-textarea" id="chat-input" rows="1" placeholder="부품/작업/예산에 대해 물어보세요..." onkeydown="handleKey(event)" oninput="autoResize(this)"></textarea>
<button class="rb-send" id="send-btn" onclick="sendMessage()">전송</button>
</div>
@@ -318,6 +431,13 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
</div>
<div class="rb-modal-bg" id="modal-bg" onclick="if(event.target===this)closeModal()">
<div class="rb-modal">
<div class="rb-modal-hdr"><span id="modal-title"></span><button onclick="closeModal()">✕</button></div>
<div class="rb-modal-body" id="modal-body"></div>
</div>
</div>
<script>
// ── Auth ──────────────────────────────────────────────────────────────────────
const TOKEN_KEY='smallclaw_token';
@@ -351,6 +471,10 @@ let parts=[];
let phases=[];
let notes='';
let overviewDesc='';
let budget=0; // 목표 예산(원, 0=미설정)
let showArchived=false;
try{showArchived=localStorage.getItem('ws_show_archived')==='1';}catch{}
let conflict=null; // {id,server} — 다른 곳에서 먼저 수정돼 저장이 보류된 프로젝트
let saveTimer=null;
let saveDebounce=null;
// 저장 개편(2026-09-24): 전체문서 PUT 대신 바뀐 프로젝트만 개별 PUT한다 — 서버가
@@ -366,6 +490,8 @@ function normalizeProjects(list){
for(const p of list){
p.notes=typeof p.notes==='string'?p.notes:'';
p.description=typeof p.description==='string'?p.description:'';
p.budget=Number(p.budget)||0;
p.archived=p.archived===true;
if(!Array.isArray(p.parts))p.parts=[];
if(!Array.isArray(p.phases))p.phases=[];
for(const ph of p.phases)if(!Array.isArray(ph.tasks))ph.tasks=[];
@@ -400,6 +526,7 @@ function loadLocalFromProject(){
phases=proj?(proj.phases||[]):[];
notes=proj?(proj.notes||''):'';
overviewDesc=proj?(proj.description||''):'';
budget=proj?(Number(proj.budget)||0):0;
}
function syncLocalToProject(){
@@ -409,15 +536,62 @@ function syncLocalToProject(){
proj.phases=phases;
proj.notes=notes;
proj.description=overviewDesc;
proj.budget=budget;
if(savedJson.get(proj.id)!==JSON.stringify(proj))dirtyProjects.add(proj.id);
}
function renderProjectSelector(){
const sel=document.getElementById('project-select');
sel.innerHTML=workshopData.projects.map(p=>`<option value="${escAttr(p.id)}" ${p.id===workshopData.activeProjectId?'selected':''}>${escAttr(p.name)}</option>`).join('');
const list=workshopData.projects.filter(p=>showArchived||!p.archived||p.id===workshopData.activeProjectId);
sel.innerHTML=list.map(p=>`<option value="${escAttr(p.id)}" ${p.id===workshopData.activeProjectId?'selected':''}>${p.archived?'📦 ':''}${escAttr(p.name)}</option>`).join('');
const label=document.getElementById('chat-label');
const proj=currentProject();
if(label&&proj)label.textContent='🛠️ '+proj.name+' 채팅';
const ab=document.getElementById('archive-btn');
if(ab&&proj)ab.textContent=proj.archived?'📦 보관 해제':'📦 보관';
const sa=document.getElementById('show-archived');
if(sa)sa.checked=showArchived;
}
function toggleShowArchived(on){
showArchived=!!on;
try{localStorage.setItem('ws_show_archived',on?'1':'0');}catch{}
renderProjectSelector();
}
// 보관 = 삭제와 달리 데이터·파일을 그대로 두고 프로젝트 목록에서만 숨긴다.
function toggleArchiveProject(){
syncLocalToProject();
const proj=currentProject();
if(!proj)return;
const next=workshopData.projects.find(p=>p.id!==proj.id&&!p.archived);
if(!proj.archived&&!next){alert('보관하지 않은 프로젝트가 하나는 남아 있어야 합니다.');return;}
proj.archived=!proj.archived;
syncLocalToProject(); // 바뀐 archived를 dirty로 반영
if(proj.archived&&!showArchived){
scheduleSave();
switchProject(next.id);
return;
}
renderProjectSelector();
scheduleSave();
}
async function flushNow(){
if(saveDebounce){clearTimeout(saveDebounce);saveDebounce=null;}
await saveProject();
}
async function duplicateProjectPrompt(){
const proj=currentProject();
if(!proj)return;
if(!confirm(`"${proj.name}" 프로젝트를 복제할까요?\n구조(부품/단계/작업/메모/예산)는 그대로 가져오고, 작업 완료 표시와 부품 상태(주문/보유)는 초기화됩니다.`))return;
await flushNow();
try{
const r=await fetch('/api/workshop/project/'+encodeURIComponent(proj.id)+'/duplicate',{method:'POST',headers:authH()});
if(!r.ok)throw new Error('HTTP '+r.status);
}catch{alert('복제 실패');return;}
await softRefreshWorkshop(); // 서버가 복제본을 활성화했으므로 전환까지 처리된다
}
function refreshAllPanes(){
@@ -427,6 +601,7 @@ function refreshAllPanes(){
renderPhases();
document.getElementById('notes-textarea').value=notes;
document.getElementById('overview-textarea').value=overviewDesc;
syncBudgetInput();
updateBudget();
}
@@ -469,7 +644,7 @@ function deleteProjectPrompt(){
if(!confirm(`"${proj.name}" 프로젝트를 삭제할까요? 부품/작업/메모와 첨부파일, 이 프로젝트의 채팅 기록이 모두 지워지며 되돌릴 수 없습니다.`))return;
dirtyProjects.delete(proj.id);
workshopData.projects=workshopData.projects.filter(p=>p.id!==proj.id);
workshopData.activeProjectId=workshopData.projects[0].id;
workshopData.activeProjectId=(workshopData.projects.find(p=>!p.archived)||workshopData.projects[0]).id;
// 삭제는 프로젝트 단위 저장 API로 직접 반영 — 저장(debounce)이 못 건드리는
// 이미 사라진 프로젝트를 PUT하면 404 소음만 생긴다.
fetch('/api/workshop/project/'+encodeURIComponent(proj.id),{method:'DELETE',headers:authH()}).catch(()=>{});
@@ -522,6 +697,7 @@ function flushPendingSave(){
for(const id of dirtyProjects){
const p=workshopData.projects.find(x=>x.id===id);
if(!p)continue;
if(conflict)continue; // 충돌 보류 중엔 unload 저장도 서버 내용을 덮지 않는다
fetch('/api/workshop/project/'+encodeURIComponent(id),{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify(p),keepalive:true});
}
dirtyProjects.clear();
@@ -532,10 +708,26 @@ window.addEventListener('pagehide',flushPendingSave);
// 채팅 응답 후 호출: 서버 데이터를 다시 당겨오되, 내용이 안 바뀌었으면 화면을 건드리지
// 않는다(편집 중 포커스/펼침 카드 보존). 바뀌었으면 살아있는 부품에 한해 펼침 상태를
// 유지한 채 다시 그린다. loadWorkshopData(초기 로딩용, 무조건 전체 새로고침)와 다른 용도.
async function softRefreshWorkshop(){
// 사용자가 대시보드 입력칸을 편집 중인가 — 폴링 갱신이 입력칸을 다시 그려 포커스를 날리면 안 된다.
function userIsEditing(){
const a=document.activeElement;
if(!a||a===document.body||a.id==='chat-input')return false;
return /^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName)&&!!a.closest('.rb-body,.rb-budget');
}
function modalOpen(){return document.getElementById('modal-bg').classList.contains('open');}
// opts.poll: 주기적 백그라운드 갱신(다른 탭/채팅 경로의 변경 반영). 편집 중이거나 저장 대기·충돌
// 보류·모달·채팅 응답 중이면 건드리지 않고 다음 주기로 미룬다.
async function softRefreshWorkshop(opts){
const poll=!!(opts&&opts.poll);
if(poll){
if(conflict||saveDebounce||dirtyProjects.size||userIsEditing()||modalOpen())return;
if(document.getElementById('send-btn').disabled)return;
}
// 대기 중인 저장(debounce)이 있으면 먼저 flush — 안 그러면 방금 편집한 내용이 아직
// 서버에 안 간 상태에서 서버 데이터를 당겨오면 그 편집이 옛 데이터에 덮어씌워 사라진다.
if(saveDebounce){clearTimeout(saveDebounce);saveDebounce=null;await saveProject();}
if(conflict)return; // 충돌 해결 전엔 화면을 서버 값으로 바꾸지 않는다
try{
const r=await fetch('/api/workshop/data',{headers:authH()});
if(!r.ok)return;
@@ -557,6 +749,7 @@ async function softRefreshWorkshop(){
if(ta&&ta.value!==notes)ta.value=notes;
const ota=document.getElementById('overview-textarea');
if(ota&&ota.value!==overviewDesc)ota.value=overviewDesc;
syncBudgetInput();
updateBudget();
if(activeChanged){
// 채팅에서 set_active_project/create_project로 프로젝트가 바뀐 경우 —
@@ -581,12 +774,22 @@ async function saveProject(){
for(const id of ids){
const p=workshopData.projects.find(x=>x.id===id);
if(!p)continue; // 이번 턴에 삭제된 프로젝트 — deleteProjectPrompt가 서버 DELETE로 처리
if(conflict){dirtyProjects.add(id);continue;} // 충돌 해결 전엔 아무것도 서버로 보내지 않는다
const body=JSON.stringify(p);
const r=await fetch('/api/workshop/project/'+encodeURIComponent(id),{method:'PUT',headers:authH({'Content-Type':'application/json'}),body});
if(r.status===409){
// 낙관적 락: 내가 본 버전 이후 다른 곳(채팅 도구/다른 탭)이 이 프로젝트를 고쳤다 — 조용히 덮지 않는다.
const d=await r.json().catch(()=>({}));
dirtyProjects.add(id);
showConflict(id,d.server);
continue;
}
if(!r.ok)throw new Error('HTTP '+r.status);
savedJson.set(id,body);
const res=await r.json().catch(()=>({}));
if(res.updatedAt)p.updatedAt=res.updatedAt;
savedJson.set(id,JSON.stringify(p));
}
showSaveIndicator('저장됨');
if(!conflict)showSaveIndicator('저장됨');
}catch{
// 실패한 프로젝트는 dirty로 되돌려 다음 저장에서 재시도한다(안 그러면 조용히 유실).
for(const id of ids)if(savedJson.get(id)!==JSON.stringify(workshopData.projects.find(x=>x.id===id)))dirtyProjects.add(id);
@@ -594,6 +797,51 @@ async function saveProject(){
}
}
function showConflict(id,serverProj){
if(conflict)return;
conflict={id,server:serverProj};
const p=workshopData.projects.find(x=>x.id===id);
const el=document.getElementById('conflict-banner');
el.innerHTML='⚠ <b>'+escHtml(p?p.name:'프로젝트')+'</b> 이(가) 다른 곳(채팅 도구/다른 탭)에서 먼저 수정되어 저장을 보류했습니다. '
+'<button onclick="resolveConflict(\'mine\')">내 변경으로 덮어쓰기</button>'
+'<button onclick="resolveConflict(\'server\')">서버 버전 불러오기(내 변경 버림)</button>';
el.style.display='flex';
}
function clearConflict(){
conflict=null;
const el=document.getElementById('conflict-banner');
el.style.display='none';el.innerHTML='';
}
async function resolveConflict(mode){
if(!conflict)return;
const {id,server}=conflict;
const idx=workshopData.projects.findIndex(x=>x.id===id);
if(idx<0){clearConflict();return;}
if(mode==='mine'){
if(id===workshopData.activeProjectId)syncLocalToProject();
const p=workshopData.projects[idx];
try{
const r=await fetch('/api/workshop/project/'+encodeURIComponent(id)+'?force=1',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify(p)});
if(!r.ok)throw new Error();
const res=await r.json().catch(()=>({}));
if(res.updatedAt)p.updatedAt=res.updatedAt;
savedJson.set(id,JSON.stringify(p));
dirtyProjects.delete(id);
clearConflict();
showSaveIndicator('덮어씀');
}catch{alert('덮어쓰기 실패');}
return;
}
// 서버 버전 채택
const fresh=normalizeProjects([server||workshopData.projects[idx]])[0];
workshopData.projects[idx]=fresh;
savedJson.set(id,JSON.stringify(fresh));
dirtyProjects.delete(id);
clearConflict();
if(id===workshopData.activeProjectId){loadLocalFromProject();refreshAllPanes();}
else renderProjectSelector();
}
// 활성 프로젝트 포인터는 값 하나라 별도 즉시 저장(debounce 없음) — switch/create/delete에서 호출.
async function putActive(){
try{
@@ -878,9 +1126,47 @@ function togglePartExpand(id){
renderParts();
}
function renderParts(){
const container=document.getElementById('parts-container');
container.innerHTML=parts.map(p=>{
// ── 보기 상태(화면 전용, 저장 안 함) — 카드/표, 상태 필터, 정렬 ──
let partsView='card';
try{partsView=localStorage.getItem('ws_parts_view')==='table'?'table':'card';}catch{}
let partsFilter='all';
let partsSort='order';
const STATUS_ORDER={'검토중':0,'주문완료':1,'보유':2};
function setPartsView(v){partsView=v;try{localStorage.setItem('ws_parts_view',v);}catch{}renderParts();}
function setPartsFilter(f){partsFilter=f;renderParts();}
function setPartsSort(v){partsSort=v;renderParts();}
function visibleParts(){
let list=parts.filter(p=>partsFilter==='all'||p.status===partsFilter);
if(partsSort==='name')list=[...list].sort((a,b)=>String(a.name).localeCompare(String(b.name),'ko'));
else if(partsSort==='amount')list=[...list].sort((a,b)=>(b.qty*b.unitPrice)-(a.qty*a.unitPrice));
else if(partsSort==='status')list=[...list].sort((a,b)=>(STATUS_ORDER[a.status]??9)-(STATUS_ORDER[b.status]??9));
return list;
}
function renderPartsBar(){
const cnt=st=>parts.filter(p=>st==='all'||p.status===st).length;
document.getElementById('parts-chips').innerHTML=['all','검토중','주문완료','보유'].map(st=>
`<button class="rb-chip${partsFilter===st?' active':''}" onclick="setPartsFilter('${st}')">${st==='all'?'전체':st} ${cnt(st)}</button>`).join(' ');
document.querySelectorAll('#parts-view-seg button').forEach(b=>b.classList.toggle('active',b.dataset.view===partsView));
const so=document.getElementById('parts-sort');
if(so&&so.value!==partsSort)so.value=partsSort;
}
// 시세 조회 결과 한 줄(카드용) — 단가 대비 최저가 변동을 색으로 보여준다.
function priceLineHtml(p){
const pc=p.priceCheck;
if(!pc)return '';
const d=new Date(pc.at);
let delta='';
if(p.unitPrice>0){
const diff=pc.min-p.unitPrice;
delta=diff<0?` · <span class="down">▼ 단가보다 ${fmtWon(-diff)} 저렴</span>`:diff>0?` · <span class="up">▲ 단가보다 ${fmtWon(diff)} 비쌈</span>`:' · 단가와 동일';
}
return `<div class="rb-price-line">시세(다나와 ${d.getMonth()+1}/${d.getDate()}, ${pc.count}건): 최저 <b>${fmtWon(pc.min)}</b> · 중앙값 ${fmtWon(pc.median)}${delta}</div>`;
}
function renderPartCard(p){
const expanded=expandedParts.has(p.id);
return `
<div class="rb-part-card${expanded?' expanded':''}" data-id="${escAttr(p.id)}">
@@ -918,10 +1204,47 @@ function renderParts(){
`).join('')}
<button class="rb-link-add" onclick="addLink('${p.id}')">+ 링크 추가</button>
</div>
<div class="rb-part-actions"><button class="rb-del-btn" onclick="deletePart('${p.id}')">✕ 삭제</button></div>
${priceLineHtml(p)}
<div class="rb-part-actions"><button class="rb-mini-btn" onclick="checkPartPrice('${p.id}')" style="margin-right:auto">💰 시세 조회</button><button class="rb-del-btn" onclick="deletePart('${p.id}')">✕ 삭제</button></div>
</div>
</div>
`;}).join('');
`;
}
function renderPartsTable(list){
const total=list.reduce((sum,p)=>sum+p.qty*p.unitPrice,0);
const rows=list.map(p=>`
<tr data-id="${escAttr(p.id)}">
<td><input type="text" value="${escAttr(p.name)}" placeholder="(이름 없음)" oninput="updatePart('${p.id}','name',this.value)"></td>
<td style="width:64px"><input type="number" min="0" value="${escAttr(p.qty)}" oninput="updatePart('${p.id}','qty',this.value)"></td>
<td style="width:104px"><input type="number" min="0" value="${escAttr(p.unitPrice)}" oninput="updatePart('${p.id}','unitPrice',this.value)"></td>
<td class="sub rb-pt-sub">${fmtWon(p.qty*p.unitPrice)}</td>
<td style="width:92px"><select onchange="updatePart('${p.id}','status',this.value)">
${['검토중','주문완료','보유'].map(st=>`<option value="${st}" ${p.status===st?'selected':''}>${st}</option>`).join('')}
</select></td>
<td><input type="text" value="${escAttr(p.memo)}" oninput="updatePart('${p.id}','memo',this.value)"></td>
<td style="white-space:nowrap"><button class="rb-mini-btn" title="시세 조회" onclick="checkPartPrice('${p.id}')">💰</button> <button class="rb-del-btn" onclick="deletePart('${p.id}')">✕</button></td>
</tr>`).join('');
return `<table class="rb-ptable"><thead><tr>
<th class="sortable" onclick="setPartsSort('name')">이름</th><th>수량</th><th>단가</th>
<th class="sortable" onclick="setPartsSort('amount')">소계</th><th class="sortable" onclick="setPartsSort('status')">상태</th><th>메모</th><th></th>
</tr></thead><tbody>${rows}</tbody>
<tfoot><tr><td colspan="3" id="pt-foot-count">표시 중 ${list.length}개</td><td class="sub" id="pt-foot-sum">${fmtWon(total)}</td><td colspan="3"></td></tr></tfoot></table>`;
}
function updateTableFooter(){
const list=visibleParts();
const el=document.getElementById('pt-foot-sum');
if(el)el.textContent=fmtWon(list.reduce((sum,p)=>sum+p.qty*p.unitPrice,0));
}
function renderParts(){
renderPartsBar();
const container=document.getElementById('parts-container');
if(!parts.length){container.innerHTML='<div class="rb-empty">아직 등록된 부품이 없습니다. "+ 부품 추가"를 누르거나 채팅으로 등록해 보세요.</div>';return;}
const list=visibleParts();
if(!list.length){container.innerHTML='<div class="rb-empty">이 상태의 부품이 없습니다.</div>';return;}
container.innerHTML=partsView==='table'?renderPartsTable(list):list.map(renderPartCard).join('');
}
function updatePart(id,field,value){
@@ -929,6 +1252,12 @@ function updatePart(id,field,value){
if(!p)return;
if(field==='qty'||field==='unitPrice')p[field]=Math.max(0,Number(value)||0);
else p[field]=value;
const row=document.querySelector(`.rb-ptable tr[data-id="${id}"]`);
if(row){
if(field==='qty'||field==='unitPrice')row.querySelector('.rb-pt-sub').textContent=fmtWon(p.qty*p.unitPrice);
updateTableFooter();
}
if(field==='status')renderPartsBar(); // 필터 칩의 개수 갱신
const card=document.querySelector(`.rb-part-card[data-id="${id}"]`);
if(card){
if(field==='qty'||field==='unitPrice')card.querySelector('.rb-part-sub').textContent=fmtWon(p.qty*p.unitPrice);
@@ -947,7 +1276,9 @@ function addPart(){
const id=genId('p');
parts.push({id,name:'',qty:1,unitPrice:0,status:'검토중',memo:'',links:[]});
expandedParts.add(id);
partsFilter='all'; // 새 부품(검토중)이 필터에 가려지지 않게
renderParts();
if(partsView==='table')document.querySelector(`.rb-ptable tr[data-id="${id}"] input[type=text]`)?.focus();
scheduleSave();
}
@@ -996,6 +1327,75 @@ function updateLink(partId,linkId,field,value){
scheduleSave();
}
// ── 시세 조회(다나와) ─────────────────────────────────────────────────────────
// PC 부품 위주라 전자부품/소모품은 결과가 없을 수 있다 — 그땐 채팅으로 웹 검색을 권한다.
async function checkPartPrice(id){
const p=parts.find(x=>x.id===id);
if(!p)return;
if(!String(p.name).trim()){alert('부품 이름을 먼저 입력하세요.');return;}
openModal('💰 시세 조회 — '+p.name,'<div class="rb-empty">다나와 조회 중… (최대 10~20초)</div>');
try{
const r=await fetch('/api/workshop/part-price',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({name:p.name})});
if(!r.ok)throw new Error('HTTP '+r.status);
const d=await r.json();
if(!modalOpen())return; // 그 사이 닫힘
if(!d.count){
document.getElementById('modal-body').innerHTML='<div class="rb-empty">다나와에 결과가 없습니다. 다나와는 PC 부품 위주라 전자부품/소모품은 안 나올 수 있어요.<br>채팅에 "이 부품 최저가 찾아줘"라고 하면 웹 검색으로 찾아줍니다.</div>';
return;
}
const min=Number(d.min),med=Number(d.median),cnt=Number(d.count);
document.getElementById('modal-body').innerHTML=
'<div style="margin-bottom:8px">"'+escHtml(d.query)+'" 다나와 <b>'+cnt+'건</b> — 최저 <b>'+fmtWon(min)+'</b> · 중앙값 '+fmtWon(med)+'</div>'
+d.items.map(i=>'<div class="rb-price-item"><span>'+escHtml(i.name)+'</span><span>'+fmtWon(Number(i.price))+'</span></div>').join('')
+'<div style="display:flex;gap:8px;margin-top:12px;flex-wrap:wrap">'
+'<button class="rb-add-btn" style="margin:0" onclick="applyPartPrice(\''+id+'\','+min+','+med+','+cnt+',true)">최저가 '+fmtWon(min)+' 단가로 적용</button>'
+'<button class="rb-mini-btn" onclick="applyPartPrice(\''+id+'\','+min+','+med+','+cnt+',false)">시세만 기록</button></div>';
}catch(e){
if(modalOpen())document.getElementById('modal-body').innerHTML='<div class="rb-empty">시세 조회 실패: '+escHtml(e.message)+'</div>';
}
}
function applyPartPrice(id,min,median,count,setUnit){
const p=parts.find(x=>x.id===id);
if(!p)return;
p.priceCheck={min,median,count,at:Date.now()};
if(setUnit)p.unitPrice=min;
renderParts();
updateBudget();
scheduleSave();
closeModal();
}
// ── BOM 내보내기 ──────────────────────────────────────────────────────────────
// CSV는 엑셀이 한글을 깨뜨리지 않게 BOM(\uFEFF)을 붙이고, 수식 주입(=,+,-,@로 시작하는 셀)을 막는다.
function csvCell(v){
let t=String(v??'');
if(/^[=+\-@\t\r]/.test(t))t="'"+t;
return '"'+t.replace(/"/g,'""')+'"';
}
function exportBom(kind){
const proj=currentProject();
if(!proj||!parts.length){alert('내보낼 부품이 없습니다.');return;}
const total=parts.reduce((sum,p)=>sum+p.qty*p.unitPrice,0);
let text,mime,ext;
if(kind==='csv'){
const lines=[['부품','수량','단가','소계','상태','메모'].map(csvCell).join(',')];
for(const p of parts)lines.push([csvCell(p.name),p.qty,p.unitPrice,p.qty*p.unitPrice,csvCell(p.status),csvCell(p.memo)].join(','));
lines.push([csvCell('합계'),'','',total,'',''].join(','));
text='\uFEFF'+lines.join('\r\n');mime='text/csv;charset=utf-8';ext='csv';
}else{
const c=v=>String(v??'').replace(/\|/g,'\\|').replace(/\r?\n/g,' ');
const lines=['# '+proj.name+' — BOM','','| 부품 | 수량 | 단가 | 소계 | 상태 | 메모 |','|---|--:|--:|--:|---|---|'];
for(const p of parts)lines.push('| '+[c(p.name),p.qty,fmtWon(p.unitPrice),fmtWon(p.qty*p.unitPrice),c(p.status),c(p.memo)].join(' | ')+' |');
lines.push('| **합계** | | | **'+fmtWon(total)+'** | | |');
text=lines.join('\n')+'\n';mime='text/markdown;charset=utf-8';ext='md';
}
const a=document.createElement('a');
a.href=URL.createObjectURL(new Blob([text],{type:mime}));
a.download=(proj.name.replace(/[\\/:*?"<>|]/g,'_')||'project')+'_BOM_'+new Date().toISOString().slice(0,10)+'.'+ext;
document.body.appendChild(a);a.click();a.remove();
setTimeout(()=>URL.revokeObjectURL(a.href),1000);
}
function deleteLink(partId,linkId){
const p=parts.find(x=>x.id===partId);
if(!p||!p.links)return;
@@ -1004,22 +1404,60 @@ function deleteLink(partId,linkId){
scheduleSave();
}
function syncBudgetInput(){
const el=document.getElementById('budget-input');
if(el&&document.activeElement!==el)el.value=budget?String(budget):'';
}
function onBudgetChange(){
budget=Math.max(0,Math.round(Number(document.getElementById('budget-input').value)||0));
updateBudget();
scheduleSave();
}
function updateBudget(){
const sum=status=>parts.filter(p=>p.status===status).reduce((s,p)=>s+p.qty*p.unitPrice,0);
const review=sum('검토중'),ordered=sum('주문완료'),owned=sum('보유');
const total=review+ordered+owned;
document.getElementById('sum-review').textContent=fmtWon(review);
document.getElementById('sum-ordered').textContent=fmtWon(ordered);
document.getElementById('sum-owned').textContent=fmtWon(owned);
document.getElementById('sum-total').textContent=fmtWon(review+ordered+owned);
document.getElementById('sum-total').textContent=fmtWon(total);
// 목표 예산 게이지 — 80%부터 주황, 초과하면 빨강.
const g=document.getElementById('budget-gauge'),bar=document.getElementById('budget-gauge-bar'),tx=document.getElementById('budget-gauge-text');
if(budget>0){
const pct=total/budget*100;
g.style.display='block';
bar.style.width=Math.min(100,pct)+'%';
g.className='rb-gauge'+(pct>100?' over':pct>=80?' warn':'');
tx.className='rb-gauge-text'+(pct>100?' over':'');
tx.textContent=Math.round(pct)+'% 사용 · '+(total<=budget?'잔액 '+fmtWon(budget-total):'초과 '+fmtWon(total-budget));
}else{
g.style.display='none';tx.textContent='';tx.className='rb-gauge-text';
}
}
// ── 작업(단계) ────────────────────────────────────────────────────────────────
function taskStats(list){
const t=list.length,d=list.filter(x=>x.done).length;
return{t,d,pct:t?Math.round(d/t*100):0};
}
// 기한 상태: 지났으면 overdue, 3일 이내면 soon(완료된 작업은 표시 안 함).
function dueClass(due,done){
if(!due||done)return '';
const days=Math.floor((new Date(due+'T00:00:00')-new Date(new Date().toDateString()))/86400000);
return days<0?'overdue':days<=3?'soon':'';
}
function renderPhases(){
const container=document.getElementById('phases-container');
container.innerHTML=phases.map(ph=>`
container.innerHTML=phases.map(ph=>{
const st=taskStats(ph.tasks);
return `
<div class="rb-phase" data-id="${escAttr(ph.id)}">
<div class="rb-phase-hdr">
<input class="rb-phase-name" value="${escAttr(ph.name)}" oninput="updatePhaseName('${ph.id}',this.value)">
<span class="rb-phase-count">${st.d}/${st.t}</span>
<div class="rb-phase-prog rb-prog"><i style="width:${st.pct}%"></i></div>
<button class="rb-del-btn" onclick="deletePhase('${ph.id}')">✕</button>
</div>
<div class="rb-phase-tasks">
@@ -1027,13 +1465,41 @@ function renderPhases(){
<div class="rb-task-row" data-id="${escAttr(t.id)}">
<input type="checkbox" ${t.done?'checked':''} onchange="updateTask('${ph.id}','${t.id}','done',this.checked)">
<input type="text" class="${t.done?'done':''}" value="${escAttr(t.text)}" oninput="updateTask('${ph.id}','${t.id}','text',this.value)">
<input type="date" class="rb-due ${dueClass(t.due,t.done)}" value="${escAttr(t.due||'')}" title="기한" onchange="updateTask('${ph.id}','${t.id}','due',this.value)">
<button class="rb-del-btn" onclick="deleteTask('${ph.id}','${t.id}')">✕</button>
</div>
`).join('')}
<button class="rb-task-add" onclick="addTask('${ph.id}')">+ 작업 추가</button>
</div>
</div>
`).join('');
</div>`;}).join('');
updateProgressUI();
}
// 전체 진행률(작업 탭 상단 + 탭 라벨)과 단계별 카운트/바, 기한 색을 다시 계산해 그린다.
// 체크박스를 눌렀을 때 카드 전체를 다시 그리지 않고(포커스 유지) 이것만 갱신한다.
function updateProgressUI(){
const all=phases.flatMap(ph=>ph.tasks);
const st=taskStats(all);
const overdue=all.filter(t=>dueClass(t.due,t.done)==='overdue').length;
const tabBtn=document.querySelector('.rb-tab-btn[data-tab="tasks"]');
if(tabBtn)tabBtn.textContent=st.t?'작업 ('+st.d+'/'+st.t+')':'작업';
const box=document.getElementById('tasks-progress');
if(box){
box.innerHTML=st.t
?'<div class="rb-prog-label"><span>전체 진행</span><span>'+st.d+'/'+st.t+' ('+st.pct+'%)'+(overdue?' · <b style="color:#f87171">기한 지남 '+overdue+'건</b>':'')+'</span></div><div class="rb-prog"><i style="width:'+st.pct+'%"></i></div>'
:'';
}
for(const ph of phases){
const el=document.querySelector('.rb-phase[data-id="'+ph.id+'"]');
if(!el)continue;
const ps=taskStats(ph.tasks);
const cnt=el.querySelector('.rb-phase-count');if(cnt)cnt.textContent=ps.d+'/'+ps.t;
const bar=el.querySelector('.rb-phase-prog i');if(bar)bar.style.width=ps.pct+'%';
for(const t of ph.tasks){
const due=el.querySelector('.rb-task-row[data-id="'+t.id+'"] input.rb-due');
if(due)due.className='rb-due '+dueClass(t.due,t.done);
}
}
}
function updatePhaseName(phaseId,value){
@@ -1058,11 +1524,13 @@ function updateTask(phaseId,taskId,field,value){
if(!ph)return;
const t=ph.tasks.find(x=>x.id===taskId);
if(!t)return;
t[field]=value;
if(field==='due'){if(value)t.due=value;else delete t.due;}
else t[field]=value;
if(field==='done'){
const input=document.querySelector(`.rb-phase[data-id="${phaseId}"] .rb-task-row[data-id="${taskId}"] input[type=text]`);
if(input)input.classList.toggle('done',value);
}
if(field==='done'||field==='due')updateProgressUI();
scheduleSave();
}
@@ -1163,12 +1631,21 @@ function renderFiles(){
}
const STL_RE=/\.stl$/i;
function stlThumbFail(img){
const sp=document.createElement('span');sp.className='rb-file-icon';sp.textContent='🔧';
img.replaceWith(sp);
}
function renderFileCard(file){
const isStl=STL_RE.test(file.name);
const icon=isStl?'🔧':(FILE_ICONS[file.category]||FILE_ICONS.other);
const proj=currentProject();
// STL은 서버가 OpenSCAD로 렌더한 썸네일(캐시)을 쓴다 — ASCII STL/큰 파일/렌더 실패는 404라
// onerror로 기존 🔧 아이콘으로 되돌아간다.
const thumb=file.category==='photo'
? `<img src="${escAttr(file.url)}" alt="${escAttr(file.name)}" loading="lazy">`
: `<span class="rb-file-icon">${icon}</span>`;
: (isStl&&proj)
? `<img class="stl-thumb" src="/api/workshop/thumb?projectId=${encodeURIComponent(proj.id)}&relPath=${encodeURIComponent(file.relPath)}" alt="" loading="lazy" onerror="stlThumbFail(this)">`
: `<span class="rb-file-icon">${icon}</span>`;
// STL은 새탭 다운로드 대신 Three.js CAD 편집기 팝업으로 연다(작업실↔K2 CAD 플러그인 연동).
const openAttrs=isStl?`href="javascript:void(0)" onclick="openCadEditorForFile('${escJs(file.relPath)}')"`:`href="${escAttr(file.url)}" target="_blank" rel="noopener"`;
// STL만 슬라이서로 보낼 다중선택 체크박스(09-22) — 여러 부품 STL을 한 판에 얹어 같이 슬라이싱.
@@ -1514,6 +1991,55 @@ async function clearChat(){
addMsg('system','새 대화가 시작되었습니다.');
}
// ── 사진 첨부(📷) ─────────────────────────────────────────────────────────────
// 이 프로젝트의 사진을 골라 채팅 입력에 "[첨부 사진: 경로]"를 넣는다. 서버가 이 표시를 보면
// workshop_project view_image로 그 사진을 모델이 직접 보게 한다(비전 모델일 때).
async function toggleAttachPop(ev){
ev.stopPropagation();
const pop=document.getElementById('attach-pop');
if(pop.classList.contains('open')){pop.classList.remove('open');return;}
pop.classList.add('open');
pop.innerHTML='<div class="rb-empty">불러오는 중…</div>';
const proj=currentProject();
if(!proj)return;
let photos=[];
try{
const r=await fetch('/api/workshop/files?projectId='+encodeURIComponent(proj.id),{headers:authH()});
const d=await r.json();
photos=(d.files||[]).filter(f=>f.category==='photo').slice(-30).reverse();
}catch{}
pop.innerHTML='<label class="rb-mini-btn" style="display:block;text-align:center;cursor:pointer">+ 새 사진 올려서 첨부<input type="file" accept="image/*" style="display:none" onchange="attachUpload(event)"></label>'
+(photos.length
?'<div class="rb-attach-grid">'+photos.map(f=>'<img src="'+escAttr(f.url)+'" title="'+escAttr(f.relPath)+'" loading="lazy" onclick="attachPhoto(\''+escJs(f.relPath)+'\')">').join('')+'</div>'
:'<div class="rb-empty">이 프로젝트에 올린 사진이 없습니다.</div>');
}
function attachPhoto(relPath){
const inp=document.getElementById('chat-input');
inp.value=(inp.value?inp.value.replace(/\s+$/,'')+' ':'')+'[첨부 사진: '+relPath+'] ';
autoResize(inp);inp.focus();
document.getElementById('attach-pop').classList.remove('open');
}
async function attachUpload(ev){
const file=ev.target.files&&ev.target.files[0];
ev.target.value='';
const proj=currentProject();
if(!file||!proj)return;
const pop=document.getElementById('attach-pop');
pop.innerHTML='<div class="rb-empty">업로드 중… '+escHtml(file.name)+'</div>';
try{
const fd=new FormData();fd.append('file',file);
const r=await fetch('/api/workshop/upload?projectId='+encodeURIComponent(proj.id)+'&folder='+encodeURIComponent('사진'),{method:'POST',headers:authH(),body:fd});
const d=await r.json().catch(()=>({}));
if(!r.ok||!d.relPath)throw new Error(d.error||'업로드 실패');
if(document.getElementById('pane-files').classList.contains('active'))loadFiles();
attachPhoto(d.relPath);
}catch(e){pop.innerHTML='<div class="rb-empty">업로드 실패: '+escHtml(e.message)+'</div>';}
}
document.addEventListener('click',(e)=>{
const w=document.querySelector('.rb-attach-wrap');
if(w&&!w.contains(e.target))document.getElementById('attach-pop')?.classList.remove('open');
});
function handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendMessage();}}
function autoResize(el){el.style.height='auto';el.style.height=Math.min(el.scrollHeight,120)+'px';}
@@ -1584,6 +2110,73 @@ document.addEventListener('click',(e)=>{
if(pill && !pill.contains(e.target)) document.getElementById('model-dropdown')?.classList.remove('open');
});
// ── 모달 / 변경 이력 ─────────────────────────────────────────────────────────
function openModal(title,html){
document.getElementById('modal-title').textContent=title;
document.getElementById('modal-body').innerHTML=html;
document.getElementById('modal-bg').classList.add('open');
}
function closeModal(){document.getElementById('modal-bg').classList.remove('open');}
document.addEventListener('keydown',e=>{if(e.key==='Escape'&&modalOpen())closeModal();});
function fmtTs(ts){
const d=new Date(ts),p=n=>String(n).padStart(2,'0');
return (d.getMonth()+1)+'/'+d.getDate()+' '+p(d.getHours())+':'+p(d.getMinutes())+':'+p(d.getSeconds());
}
async function openHistory(){
const proj=currentProject();
if(!proj)return;
await flushNow(); // 지금 편집 중인 내용까지 서버에 반영한 뒤 이력을 본다
openModal('🕘 변경 이력 — '+proj.name,'<div class="rb-empty">불러오는 중…</div>');
try{
const [hr,tr]=await Promise.all([
fetch('/api/workshop/history/'+encodeURIComponent(proj.id),{headers:authH()}),
fetch('/api/workshop/trash',{headers:authH()}),
]);
const h=(await hr.json()).history||[];
const t=(await tr.json()).deleted||[];
if(!modalOpen())return;
let html='<h4>이 프로젝트 — 변경 "직전" 상태로 되돌리기</h4>';
html+=h.length?h.map(e=>'<div class="rb-hist-row"><span class="when">'+fmtTs(e.ts)+'</span>'
+'<span class="sum">부품 '+e.parts+' · 작업 '+e.tasksDone+'/'+e.tasks+' · 메모 '+e.notesLen+'자 · 개요 '+e.descLen+'자</span>'
+'<button onclick="restoreHistory('+Number(e.ts)+')">되돌리기</button></div>').join('')
:'<div class="rb-empty">아직 변경 이력이 없습니다. 수정하면 그 직전 상태가 자동으로 남습니다(최대 30개, 연속 편집은 45초 단위로 합쳐짐).</div>';
html+='<h4>삭제된 프로젝트 복구</h4>';
html+=t.length?t.map(e=>'<div class="rb-hist-row"><span class="when">'+fmtTs(e.ts)+'</span>'
+'<span class="sum">'+escHtml(e.name)+' (첨부파일은 복구 안 됨)</span>'
+'<button onclick="restoreTrash(\''+escJs(e.id)+'\')">복구</button></div>').join('')
:'<div class="rb-empty">삭제된 프로젝트가 없습니다.</div>';
document.getElementById('modal-body').innerHTML=html;
}catch(e){
if(modalOpen())document.getElementById('modal-body').innerHTML='<div class="rb-empty">이력을 불러오지 못했습니다: '+escHtml(e.message)+'</div>';
}
}
async function restoreHistory(ts){
const proj=currentProject();
if(!proj)return;
if(!confirm(fmtTs(ts)+' 변경 직전 상태로 "'+proj.name+'"을(를) 되돌릴까요?\n지금 상태도 이력에 남으므로 되돌린 뒤 다시 원래대로 복원할 수 있습니다.'))return;
clearConflict();
try{
const r=await fetch('/api/workshop/history/'+encodeURIComponent(proj.id)+'/restore',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({ts})});
if(!r.ok)throw new Error('HTTP '+r.status);
}catch(e){alert('되돌리기 실패: '+e.message);return;}
closeModal();
await softRefreshWorkshop();
showSaveIndicator('되돌림');
}
async function restoreTrash(id){
try{
const r=await fetch('/api/workshop/trash/'+encodeURIComponent(id)+'/restore',{method:'POST',headers:authH()});
if(!r.ok)throw new Error('HTTP '+r.status);
}catch(e){alert('복구 실패: '+e.message);return;}
closeModal();
await softRefreshWorkshop();
showSaveIndicator('복구됨');
}
// ── Init ──────────────────────────────────────────────────────────────────────
(async function init(){
if(!(await checkAuth()))return;
@@ -1591,6 +2184,9 @@ document.addEventListener('click',(e)=>{
await loadWorkshopData();
await loadHistory();
loadModelPill();
// 다른 탭/채팅 경로의 변경을 15초마다(보이는 탭에서만) 반영 — 편집 중이면 미룬다.
setInterval(()=>{if(!document.hidden)softRefreshWorkshop({poll:true});},15000);
document.addEventListener('visibilitychange',()=>{if(!document.hidden)softRefreshWorkshop({poll:true});});
addMsg('system',chatGreeting());
document.getElementById('chat-input').focus();
})();