refactor: "로봇 프로젝트" 단일 대시보드를 여러 프로젝트를 오가는 "작업실"로 일반화

로봇 프로젝트 하나만 다루던 대시보드/도구를 로봇 외 다른 메이커 프로젝트
(3D프린팅, 밀링 등)도 같은 틀로 관리할 수 있게 재설계. 사용자가 이름을
"작업실"로 정함.

- workshop-storage.ts(구 robot-storage.ts): 단일 문서 대신
  {projects:[], activeProjectId} 구조. 기존 실사용 중이던
  robot-project.json이 있으면 빈 시드로 덮어쓰지 않고 그 실제 데이터를
  그대로 첫 프로젝트("로봇 프로젝트")로 마이그레이션
- routes-workshop.ts(구 routes-robot.ts): GET/PUT /api/workshop/data
- workshop-project.ts(구 robot-project.ts) 채팅도구: project_name 파라미터
  추가(생략시 활성 프로젝트 대상), list_projects/create_project/
  set_active_project 액션 신설
- workshop-app.html(구 robot-app.html): 헤더에 프로젝트 선택 드롭다운 +
  새 프로젝트/삭제 버튼 추가. 장비(K2/밀링) 탭은 프로젝트와 무관하게
  전역 유지
- 세션 접두사 rb_→ws_(tool-scope.ts의 isWorkshopAppSession), 도구명
  robot_project→workshop_project
- 마이그레이션+새 프로젝트 생성/전환/삭제+채팅 연동까지 브라우저로 실제
  확인함

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-09-20 19:04:49 +09:00
co-authored by Claude Sonnet 5
parent 4911ab005a
commit 7ec72e776a
11 changed files with 504 additions and 329 deletions
+157
View File
@@ -0,0 +1,157 @@
/**
* 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;
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);
}
export function genWorkshopId(prefix: string): string {
return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
}