/** * workshop-storage.ts * "작업실" — 여러 메이커 프로젝트(로봇, 3D프린팅, 밀링 등)를 각각 부품/작업/메모로 관리하는 * 대시보드의 저장소. 원래는 "로봇 프로젝트" 하나만 다루는 robot-storage.ts였는데, 여러 * 프로젝트를 오갈 수 있게 일반화(2026-09-20)하면서 이름을 바꿨다. routes-workshop.ts와 * 전용 채팅도구(src/tools/workshop-project.ts)가 이 모듈을 공유한다. */ import fs from 'fs'; import path from 'path'; export interface WorkshopPartLink { id: string; label: string; // 예: "쿠팡", "대체품", "업그레이드", "GitHub 레포" url: string; } export interface WorkshopPart { id: string; name: string; qty: number; unitPrice: number; status: '검토중' | '주문완료' | '보유'; memo: string; links: WorkshopPartLink[]; } export interface WorkshopTask { id: string; text: string; done: boolean; } export interface WorkshopPhase { id: string; name: string; tasks: WorkshopTask[]; } export interface WorkshopProject { id: string; name: string; description?: string; // 서술형 프로젝트 개요(set_description 액션). 개요/readme.md의 "## 개요" 섹션 parts: WorkshopPart[]; phases: WorkshopPhase[]; notes: string; } export interface WorkshopData { projects: WorkshopProject[]; activeProjectId: string; } export function workshopPath(workspaceRoot: string): string { return path.join(workspaceRoot, '.smallclaw', 'workshop.json'); } // 2026-09-20 이전 "로봇 프로젝트 전용" 버전이 쓰던 단일문서 경로 — 마이그레이션용으로만 참조. function legacyRobotProjectPath(workspaceRoot: string): string { return path.join(workspaceRoot, '.smallclaw', 'robot-project.json'); } // 2026-09-19 project_robot_dog.md 메모 기준 — 완전 첫 설치(레거시 파일도 없을 때) 시드. export function defaultRobotProject(): WorkshopProject { return { id: 'robot', name: '로봇 프로젝트', parts: [ { id: 'p1', name: '라즈베리파이5 8GB(중고)', qty: 1, unitPrice: 200000, status: '주문완료', memo: '', links: [] }, { id: 'p2', name: 'RPLidar A1(중고)', qty: 1, unitPrice: 60000, status: '주문완료', memo: '', links: [] }, { id: 'p3', name: '메카넘휠 4WD 이중데크 섀시 키트', qty: 1, unitPrice: 44000, status: '검토중', memo: 'TT모터4+메카넘휠4+섀시2단', links: [] }, { id: 'p4', name: '4채널 모터드라이버(L298N 등)', qty: 1, unitPrice: 10000, status: '검토중', memo: '', links: [] }, { id: 'p5', name: 'MPU-6050 자이로/가속도', qty: 1, unitPrice: 2500, status: '검토중', memo: '', links: [] }, { id: 'p6', name: '18650 셀 6개(3S2P)', qty: 1, unitPrice: 0, status: '보유', memo: '기존 보유분 재사용', links: [] }, { id: 'p7', name: '3S-40A BMS', qty: 1, unitPrice: 2310, status: '검토중', memo: '', links: [] }, { id: 'p8', name: '18650 홀더(6구)', qty: 1, unitPrice: 4000, status: '검토중', memo: '', links: [] }, { id: 'p9', name: '와이어링/커넥터', qty: 1, unitPrice: 5000, status: '검토중', memo: '', links: [] }, { id: 'p10', name: '나사/스탠드오프', qty: 1, unitPrice: 10000, status: '검토중', memo: '', links: [] }, { id: 'p11', name: '재활용 NEMA17 스테퍼×5+리드스크류+브라켓', qty: 1, unitPrice: 0, status: '보유', memo: '폐기된 Anet A8 Plus에서 회수, 팔 텔레스코픽 관절용', links: [] }, { id: 'p12', name: 'OAK-D-Lite 스테레오 카메라', qty: 1, unitPrice: 175000, status: '검토중', memo: '2단계(팔 추가) 때 그립 타겟팅용', links: [] }, { id: 'p13', name: '로봇팔 키트(SO-101 계열, 방식 미정)', qty: 1, unitPrice: 200000, status: '검토중', memo: '옵션 A/B/E 중 미결정 — project_robot_dog.md 참고', links: [] }, ], phases: [ { id: 'ph1', name: '1단계: 소형 프로토타입(SLAM 검증)', tasks: [ { id: 't1', text: '라즈베리파이5 8GB 주문', done: true }, { id: 't2', text: 'RPLidar A1 중고 주문', done: true }, { id: 't3', text: '메카넘휠 섀시+모터드라이버 구매', done: false }, { id: 't4', text: 'ROS2 Nav2 SLAM 소프트웨어 검증', done: false }, ], }, { id: 'ph2', name: '2단계: 팔 추가', tasks: [ { id: 't5', text: '팔 방식 최종 결정(SO-101 스케일업 vs AR4 vs 하이브리드)', done: false }, { id: 't6', text: 'OAK-D-Lite 카메라 구매', done: false }, { id: 't7', text: '스테퍼+리드스크류 텔레스코픽 관절 설계/제작', done: false }, ], }, { id: 'ph3', name: 'V2: 1m급 대형화(장기)', tasks: [ { id: 't8', text: '필요토크 계산 및 고토크모터 스펙 확정', done: false }, { id: 't9', text: '대형 메카넘휠/알루미늄 프로파일 프레임 설계', done: false }, { id: 't10', text: '하드웨어 전면 교체(소프트웨어 스택은 재사용)', done: false }, ], }, ], notes: '', }; } function defaultWorkshopData(): WorkshopData { const robot = defaultRobotProject(); return { projects: [robot], activeProjectId: robot.id }; } export function loadWorkshop(workspaceRoot: string): WorkshopData { const p = workshopPath(workspaceRoot); if (fs.existsSync(p)) { try { return JSON.parse(fs.readFileSync(p, 'utf-8')); } catch { return defaultWorkshopData(); } } // 마이그레이션: "작업실"로 일반화되기 전(로봇 프로젝트 전용 시절)에 이미 실사용 중이던 // 데이터가 있으면 빈 시드로 덮어쓰지 않고 그 실제 데이터를 그대로 첫 프로젝트로 가져온다. const legacyPath = legacyRobotProjectPath(workspaceRoot); if (fs.existsSync(legacyPath)) { try { const legacy = JSON.parse(fs.readFileSync(legacyPath, 'utf-8')); const robot: WorkshopProject = { id: 'robot', name: '로봇 프로젝트', parts: Array.isArray(legacy.parts) ? legacy.parts : [], phases: Array.isArray(legacy.phases) ? legacy.phases : [], notes: String(legacy.notes || ''), }; return { projects: [robot], activeProjectId: robot.id }; } catch { // 레거시 파일이 깨져있으면 기본 시드로 폴백. } } return defaultWorkshopData(); } export function saveWorkshop(workspaceRoot: string, data: WorkshopData): void { const p = workshopPath(workspaceRoot); fs.mkdirSync(path.dirname(p), { recursive: true }); const tmp = `${p}.tmp`; fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8'); fs.renameSync(tmp, p); syncProjectReadmes(workspaceRoot, data); } // ── 프로젝트 readme(2026-09-23) ────────────────────────────────────────────── // 대시보드 데이터를 그대로 마크다운으로 내보낸다 — workshop-project.ts의 formatProject를 // 여기로 옮긴 것(순환 import 방지: 도구가 storage를 import하므로 반대 방향은 안 됨). // 도구의 'get' 액션과 개요/readme.md 생성이 같은 문서를 쓴다. export function fmtWon(n: number): string { return `${Math.round(n).toLocaleString('ko-KR')}원`; } export function budgetSummary(parts: WorkshopPart[]): string { const sum = (status: string) => parts.filter(p => p.status === status).reduce((s, p) => s + p.qty * p.unitPrice, 0); const review = sum('검토중'), ordered = sum('주문완료'), owned = sum('보유'); return `검토중 ${fmtWon(review)} · 주문완료 ${fmtWon(ordered)} · 보유 ${fmtWon(owned)} · 총 예상비용 ${fmtWon(review + ordered + owned)}`; } export function projectToReadme(project: WorkshopProject): string { const lines: string[] = []; lines.push(`# ${project.name}`); const desc = String(project.description || '').trim(); if (desc) { lines.push(''); lines.push('## 개요'); lines.push(desc); } lines.push(''); lines.push(`예산: ${budgetSummary(project.parts)}`); lines.push(''); lines.push('## 부품'); for (const p of project.parts) { lines.push(`- [${p.status}] ${p.name} × ${p.qty} = ${fmtWon(p.qty * p.unitPrice)}${p.memo ? ` (${p.memo})` : ''}`); for (const link of p.links || []) { lines.push(` - ${link.label}: ${link.url}`); } } lines.push(''); lines.push('## 작업'); for (const ph of project.phases) { lines.push(`### ${ph.name}`); for (const t of ph.tasks) { lines.push(`- [${t.done ? 'x' : ' '}] ${t.text}`); } } if (project.notes.trim()) { lines.push(''); lines.push('## 메모'); lines.push(project.notes); } lines.push(''); lines.push('---'); lines.push('*이 파일은 작업실 대시보드 데이터에서 자동 생성됨. 부품·작업·메모를 바꾸면 갱신되니 이 파일을 직접 고치지 말 것.*'); return lines.join('\n'); } // 프로젝트당 workshop/<프로젝트id>/개요/readme.md 를 유지한다 — 파일탭에서 프로젝트 // 설명 문서가 항상 존재하게. 내용이 바뀌었을 때만 쓴다(전수 saveWorkshop 경로라 값싸게). // 개별 프로젝트 실패(권한 등)는 나머지에 영향을 주지 않게 전부 삼킨다. export function syncProjectReadmes(workspaceRoot: string, data: WorkshopData): void { for (const project of data.projects) { try { const dir = path.join(workspaceRoot, 'workshop', project.id, '개요'); const file = path.join(dir, 'readme.md'); const md = projectToReadme(project); try { if (fs.readFileSync(file, 'utf-8') === md) continue; } catch { // 파일이 없으면 새로 쓴다 } fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(file, md, 'utf-8'); } catch { // 파생 문서 생성 실패는 원본 데이터 저장을 막아선 안 된다 } } } export function genWorkshopId(prefix: string): string { return `${prefix}_${Math.random().toString(36).slice(2, 10)}`; }