Files
homeclaw/src/gateway/routes/workshop-storage.ts
T
kimandClaude Code 91ed19bda0 feat: 작업실 프로젝트별 개요 폴더 + readme.md 자동생성
- workshop/<프로젝트id>/개요/readme.md — 대시보드 데이터(개요/예산/부품/작업/메모)에서
  자동 생성, saveWorkshop과 대시보드 data GET에서 내용 변화 때만 동기화(기존 프로젝트 백필 포함)
- WorkshopProject.description 신설 + workshop_project set_description 액션 — 채팅 모델이
  서술형 개요를 쓰면 readme 맨 위 개요 섹션으로 들어감
- formatProject/budgetSummary/fmtWon을 storage로 이동(도구 get과 readme가 같은 문서 소스)
- 파일탭에는 개요 폴더 그룹으로 자동 표시(walkCaseFiles)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 16:32:45 +09:00

234 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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)}`;
}