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
+4 -3
View File
@@ -1125,12 +1125,13 @@ export function createBuildTools(isOrchestrationSkillEnabled: () => boolean) {
{
type: 'function',
function: {
name: 'robot_project',
description: '"로봇 프로젝트"(메카넘휠 이동로봇) 관리 대시보드의 부품/작업/메모를 조회·수정한다. 사용자가 로봇 프로젝트의 부품 구매/상태 변경, 작업 완료 체크, 예산 확인, 메모 추가 등을 말하면 이 도구로 실제 대시보드(robot-app.html)를 갱신한다. 물리적 동작이 없는 순수 데이터 편집이라 확인 없이 바로 실행해도 된다.',
name: 'workshop_project',
description: '"작업실" 대시보드(여러 메이커 프로젝트를 각각 부품/작업/메모로 관리)를 조회·수정한다. 사용자가 어떤 프로젝트(로봇, 3D프린팅 등)의 부품 구매/상태 변경, 작업 완료 체크, 예산 확인, 메모 추가, 새 프로젝트 생성 등을 말하면 이 도구로 실제 대시보드(workshop-app.html)를 갱신한다. project_name을 안 주면 사용자가 대시보드에서 마지막으로 보고 있던 프로젝트를 대상으로 한다. 물리적 동작이 없는 순수 데이터 편집이라 확인 없이 바로 실행해도 된다.',
parameters: {
type: 'object', required: ['action'],
properties: {
action: { type: 'string', enum: ['get', 'add_part', 'update_part', 'delete_part', 'add_task', 'set_task_done', 'add_phase', 'add_note', 'add_link', 'delete_link'], description: 'get: 전체 조회(예산 요약 포함, 부품별 링크 포함). add_part/update_part/delete_part: 부품 추가/수정/삭제. add_task: 특정 단계에 작업 추가. set_task_done: 작업 완료/미완료 표시. add_phase: 새 단계 추가. add_note: 메모에 한 줄 추가. add_link/delete_link: 부품에 참고링크(구매처/대체품/업그레이드/관련 레포 등) 추가/삭제 — 사용자가 "이거 어디서 팔아?" 류로 물어서 web_search로 찾았으면 이 액션으로 그 부품에 바로 붙여줄 것.' },
action: { type: 'string', enum: ['list_projects', 'create_project', 'set_active_project', 'get', '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: 프로젝트 조회(예산 요약+부품+작업+메모). add_part/update_part/delete_part: 부품 추가/수정/삭제. add_task: 단계에 작업 추가. set_task_done: 작업 완료/미완료. add_phase: 새 단계 추가. add_note: 메모 한 줄 추가. add_link/delete_link: 부품에 참고링크(구매처/대체품/업그레이드/레포 등) 추가/삭제.' },
project_name: { type: 'string', description: '(create_project/set_active_project 필수, 그 외 모든 액션 선택) 대상 프로젝트 이름(일부만 일치해도 됨). 생략하면 현재 활성 프로젝트를 대상으로 함.' },
part_name: { type: 'string', description: '(update_part/delete_part/add_link/delete_link 필수) 대상 부품 이름(일부만 일치해도 됨, 예: "메카넘휠")' },
name: { type: 'string', description: '(add_part 필수, update_part 선택) 부품 이름' },
qty: { type: 'number', description: '(add_part/update_part 선택) 수량. 기본 1' },
+10 -7
View File
@@ -178,9 +178,11 @@ export function buildSkillToolFilter(input: ToolScopeInput): (toolName: string)
// print3d_model — STL 슬라이싱+K2 프린터 업로드. topicText로 검사(prepare→start 확인
// 왕복이 여러 턴 걸치므로 nvr_snapshot과 같은 이유로 최근 대화 전체를 봄).
const hasPrint3dKeyword = /3d\s*프린트|3d\s*print|stl|슬라이싱|슬라이서|g-?code|출력해|프린터로|k2\s*(?:콤보|프린터)?/i.test(topicText);
// robot_project — "로봇 프로젝트"(메카넘휠 이동로봇) 대시보드 부품/작업 편집. "로봇" 단독은
// 로봇청소기(Valetudo) 대화와 겹치므로 프로젝트 고유 어휘로만 매칭한다.
const hasRobotProjectKeyword = /로봇\s*프로젝트|메카넘|mecanum|로봇팔|robot\s*arm|so-?101|so-?arm|rplidar|라이다.*로봇|로봇.*(?:부품|예산|작업|단계|대시보드)/i.test(topicText);
// workshop_project — "작업실"(여러 메이커 프로젝트) 대시보드 부품/작업 편집. "로봇" 단독은
// 로봇청소기(Valetudo) 대화와 겹치므로 프로젝트 고유 어휘로만 매칭한다. 새 프로젝트 종류가
// 늘어날 때마다 이 목록도 넓혀줘야 함(앱 세션 안에서는 아래 isWorkshopAppSession이 대신
// 커버하므로 메인 채팅에서 키워드 없이 말할 때만 이 게이트가 문제가 됨).
const hasWorkshopKeyword = /작업실\s*프로젝트|로봇\s*프로젝트|메카넘|mecanum|로봇팔|robot\s*arm|so-?101|so-?arm|rplidar|라이다.*로봇|로봇.*(?:부품|예산|작업|단계|대시보드)|esp32|하우징/i.test(topicText);
// 2026-08-10: news_search was gated on the literal word "뉴스" (hasNewsKeyword below), so a
// disaster question with no news-flavored wording ("돌핀 지금 어디 있지?") never got access to
// it — even after handle-chat.ts's reminder started explicitly recommending news_search for
@@ -305,9 +307,10 @@ export function buildSkillToolFilter(input: ToolScopeInput): (toolName: string)
const isDetectiveAppSession = /^dt_/.test(String(sessionId || ''));
const isInvestorAppSession = /^iv_/.test(String(sessionId || ''));
const isDoctorAppSession = /^dr_/.test(String(sessionId || ''));
// robot-app.html의 전용 채팅 패널('rb_' 세션) 안에서는 그 대화 전체가 이미 로봇 프로젝트
// 얘기라는 게 확실하므로, 키워드 없이 짧게 말해도("이 부품 지워줘") robot_project가 열리게 함.
const isRobotAppSession = /^rb_/.test(String(sessionId || ''));
// workshop-app.html의 전용 채팅 패널('ws_' 세션) 안에서는 그 대화 전체가 이미 어떤
// 프로젝트 얘기라는 게 확실하므로, 키워드 없이 짧게 말해도("이 부품 지워줘") workshop_project가
// 열리게 함(원래 robot-app.html/'rb_' 세션이었던 것을 여러 프로젝트로 일반화, 2026-09-20).
const isWorkshopAppSession = /^ws_/.test(String(sessionId || ''));
const filter = (name: string): boolean => {
if (dormantAgentTools.has(name)) return false;
if (dormantBrowserTools.has(name)) return false;
@@ -375,7 +378,7 @@ export function buildSkillToolFilter(input: ToolScopeInput): (toolName: string)
if ((name === 'eonet_events' || name === 'satellite_snapshot') && !hasSatelliteKeyword) return false;
if (name === 'nvr_snapshot' && !hasCctvKeyword) return false;
if (name === 'print3d_model' && !hasPrint3dKeyword) return false;
if (name === 'robot_project' && !isRobotAppSession && !hasRobotProjectKeyword) return false;
if (name === 'workshop_project' && !isWorkshopAppSession && !hasWorkshopKeyword) return false;
if (name === 'run_command' && !hasRunCommandKeyword) return false;
return true;
};
-32
View File
@@ -1,32 +0,0 @@
/**
* routes-robot.ts
* "로봇 프로젝트" 관리 대시보드 — 부품 리스트/단계별 작업/메모를 담은 단일 문서를
* 사용자별로 저장한다. doctor/lawyer 케이스 앱과 달리 여러 건이 아니라 프로젝트
* 하나뿐이라 통째로 GET/PUT하는 단일 JSON 문서 방식(케이스 배열이 아님). 실제 저장
* 로직은 robot-storage.ts에 있음 — 전용 채팅도구(src/tools/robot-project.ts)와 공유한다.
*/
import express from 'express';
import { getUserWorkspace } from '../../config/config';
import { loadRobotProject, saveRobotProject } from './robot-storage';
export function registerRobotRoutes(
app: express.Application,
getSessionUser: (req: express.Request) => any,
): void {
app.get('/api/robot/project', (req, res) => {
const session = getSessionUser(req);
if (!session) return res.status(401).json({ error: 'Unauthorized' });
res.json(loadRobotProject(getUserWorkspace(session.username)));
});
app.put('/api/robot/project', (req, res) => {
const session = getSessionUser(req);
if (!session) return res.status(401).json({ error: 'Unauthorized' });
const { parts, phases, notes } = req.body || {};
if (!Array.isArray(parts) || !Array.isArray(phases)) {
return res.status(400).json({ error: 'parts/phases array required' });
}
saveRobotProject(getUserWorkspace(session.username), { parts, phases, notes: String(notes || '') });
res.json({ success: true });
});
}
+32
View File
@@ -0,0 +1,32 @@
/**
* routes-workshop.ts
* "작업실" — 여러 메이커 프로젝트를 관리하는 대시보드(web-ui/html/workshop-app.html, 원래
* routes-robot.ts/robot-app.html이었던 것을 여러 프로젝트를 오갈 수 있게 일반화했다,
* 2026-09-20). 저장 로직은 workshop-storage.ts에 있음 — 전용 채팅도구
* (src/tools/workshop-project.ts)와 공유한다.
*/
import express from 'express';
import { getUserWorkspace } from '../../config/config';
import { loadWorkshop, saveWorkshop } from './workshop-storage';
export function registerWorkshopRoutes(
app: express.Application,
getSessionUser: (req: express.Request) => any,
): void {
app.get('/api/workshop/data', (req, res) => {
const session = getSessionUser(req);
if (!session) return res.status(401).json({ error: 'Unauthorized' });
res.json(loadWorkshop(getUserWorkspace(session.username)));
});
app.put('/api/workshop/data', (req, res) => {
const session = getSessionUser(req);
if (!session) return res.status(401).json({ error: 'Unauthorized' });
const { projects, activeProjectId } = req.body || {};
if (!Array.isArray(projects)) {
return res.status(400).json({ error: 'projects array required' });
}
saveWorkshop(getUserWorkspace(session.username), { projects, activeProjectId: String(activeProjectId || '') });
res.json({ success: true });
});
}
@@ -1,54 +1,68 @@
/**
* robot-storage.ts
* "로봇 프로젝트" 대시보드(routes-robot.ts, web-ui/html/robot-app.html)와 전용 채팅도구
* (src/tools/robot-project.ts)가 같은 저장소를 읽고 쓰도록 공유하는 모듈 — 하나만 갱신하고
* 다른 쪽이 stale해지는 일이 없게 로직을 한 곳에 둔다(case-storage.ts와 같은 목적).
* 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 RobotPartLink {
export interface WorkshopPartLink {
id: string;
label: string; // 예: "쿠팡", "대체품", "업그레이드", "GitHub 레포"
url: string;
}
export interface RobotPart {
export interface WorkshopPart {
id: string;
name: string;
qty: number;
unitPrice: number;
status: '검토중' | '주문완료' | '보유';
memo: string;
links: RobotPartLink[];
links: WorkshopPartLink[];
}
export interface RobotTask {
export interface WorkshopTask {
id: string;
text: string;
done: boolean;
}
export interface RobotPhase {
export interface WorkshopPhase {
id: string;
name: string;
tasks: RobotTask[];
tasks: WorkshopTask[];
}
export interface RobotProjectData {
parts: RobotPart[];
phases: RobotPhase[];
export interface WorkshopProject {
id: string;
name: string;
parts: WorkshopPart[];
phases: WorkshopPhase[];
notes: string;
}
export function robotProjectPath(workspaceRoot: string): 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 defaultRobotSeed(): RobotProjectData {
// 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: [] },
@@ -95,24 +109,49 @@ export function defaultRobotSeed(): RobotProjectData {
};
}
export function loadRobotProject(workspaceRoot: string): RobotProjectData {
const p = robotProjectPath(workspaceRoot);
if (!fs.existsSync(p)) return defaultRobotSeed();
try {
return JSON.parse(fs.readFileSync(p, 'utf-8'));
} catch {
return defaultRobotSeed();
}
function defaultWorkshopData(): WorkshopData {
const robot = defaultRobotProject();
return { projects: [robot], activeProjectId: robot.id };
}
export function saveRobotProject(workspaceRoot: string, data: RobotProjectData): void {
const p = robotProjectPath(workspaceRoot);
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 genRobotId(prefix: string): string {
export function genWorkshopId(prefix: string): string {
return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
}
+2 -2
View File
@@ -95,7 +95,7 @@ import { registerDentalRoutes } from './routes/routes-dental';
import { registerDetectiveDateRoutes } from './routes/routes-detective-dates';
import { registerLawyerCaseRoutes } from './routes/routes-lawyer-cases';
import { registerDoctorCaseRoutes } from './routes/routes-doctor-cases';
import { registerRobotRoutes } from './routes/routes-robot';
import { registerWorkshopRoutes } from './routes/routes-workshop';
import { caseFilesDir, resolveUploadPath, registerWopiRoutes } from './routes/case-storage';
import {
getConfig,
@@ -4612,7 +4612,7 @@ registerLawyerCaseRoutes(app, getSessionUser, isPathInsideDir);
registerWopiRoutes(app, '/api/lawyer');
registerDoctorCaseRoutes(app, getSessionUser, isPathInsideDir);
registerWopiRoutes(app, '/api/doctor');
registerRobotRoutes(app, getSessionUser);
registerWorkshopRoutes(app, getSessionUser);
// Redirect legacy top-level HTML routes into /html/ after web-ui reorganization.
+2 -2
View File
@@ -32,7 +32,7 @@ import { cmsHospitalCompareTool } from './cms.js';
import { drugDurCheckTool } from './mfds.js';
import { nvrSnapshotTool } from './nvr.js';
import { print3dModelTool } from './print3d.js';
import { robotProjectTool } from './robot-project.js';
import { workshopProjectTool } from './workshop-project.js';
export interface Tool {
name: string;
@@ -239,7 +239,7 @@ class ToolRegistry {
// 집 CCTV 한 프레임 캡처 → 비전 모델이 직접 확인 (감시카메라/마당/현관/택배 확인 요청)
this.registerSafe(nvrSnapshotTool);
this.registerSafe(print3dModelTool);
this.registerSafe(robotProjectTool);
this.registerSafe(workshopProjectTool);
// News (NewsData.io — real headlines with real publish dates, for news queries)
this.registerSafe(newsSearchTool);
// Email tools (IMAP + SMTP)
-230
View File
@@ -1,230 +0,0 @@
import { ToolResult } from '../types.js';
import { getWorkspacePath } from '../config/paths.js';
import {
loadRobotProject, saveRobotProject, genRobotId,
RobotProjectData, RobotPart, RobotPhase,
} from '../gateway/routes/robot-storage.js';
// links가 없는(2026-09-20 이전 저장분) 부품을 만나도 죽지 않게 항상 배열을 보장한다.
function ensureLinks(p: RobotPart): NonNullable<RobotPart['links']> {
if (!p.links) p.links = [];
return p.links;
}
// "로봇 프로젝트"(메카넘휠 이동로봇, robot-app.html 대시보드) 전용 채팅도구.
// 대시보드와 같은 저장소(robot-storage.ts)를 읽고 쓰므로 채팅에서 바꾼 내용이 대시보드에도,
// 대시보드에서 바꾼 내용이 채팅에도 곧바로 반영된다. 물리적 동작이 없는 순수 데이터
// 조회/편집이라 print3d_model 같은 confirm 게이트는 필요 없음.
const STATUSES = ['검토중', '주문완료', '보유'] as const;
function fmtWon(n: number): string {
return `${Math.round(n).toLocaleString('ko-KR')}원`;
}
function budgetSummary(parts: RobotPart[]): 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)}`;
}
function formatProject(data: RobotProjectData): string {
const lines: string[] = [];
lines.push(`예산: ${budgetSummary(data.parts)}`);
lines.push('');
lines.push('## 부품');
for (const p of data.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 data.phases) {
lines.push(`### ${ph.name}`);
for (const t of ph.tasks) {
lines.push(`- [${t.done ? 'x' : ' '}] ${t.text}`);
}
}
if (data.notes.trim()) {
lines.push('');
lines.push('## 메모');
lines.push(data.notes);
}
return lines.join('\n');
}
function findPartIndex(data: RobotProjectData, query: string): { ok: true; index: number } | { ok: false; error: string } {
const q = query.trim().toLowerCase();
const matches = data.parts.map((p, i) => ({ p, i })).filter(({ p }) => p.name.toLowerCase().includes(q));
if (matches.length === 0) return { ok: false, error: `"${query}"와 일치하는 부품이 없습니다. 현재 부품: ${data.parts.map(p => p.name).join(', ')}` };
if (matches.length > 1) return { ok: false, error: `"${query}"에 여러 부품이 일치합니다: ${matches.map(m => m.p.name).join(', ')} — 더 구체적으로 지정하세요.` };
return { ok: true, index: matches[0].i };
}
function findPhaseIndex(data: RobotProjectData, query: string): { ok: true; index: number } | { ok: false; error: string } {
const q = query.trim().toLowerCase();
const matches = data.phases.map((ph, i) => ({ ph, i })).filter(({ ph }) => ph.name.toLowerCase().includes(q));
if (matches.length === 0) return { ok: false, error: `"${query}"와 일치하는 단계가 없습니다. 현재 단계: ${data.phases.map(p => p.name).join(', ')}` };
if (matches.length > 1) return { ok: false, error: `"${query}"에 여러 단계가 일치합니다: ${matches.map(m => m.ph.name).join(', ')} — 더 구체적으로 지정하세요.` };
return { ok: true, index: matches[0].i };
}
function findTaskLocation(data: RobotProjectData, query: string): { ok: true; phaseIndex: number; taskIndex: number } | { ok: false; error: string } {
const q = query.trim().toLowerCase();
const matches: { phaseIndex: number; taskIndex: number; text: string }[] = [];
data.phases.forEach((ph, pi) => {
ph.tasks.forEach((t, ti) => {
if (t.text.toLowerCase().includes(q)) matches.push({ phaseIndex: pi, taskIndex: ti, text: t.text });
});
});
if (matches.length === 0) return { ok: false, error: `"${query}"와 일치하는 작업이 없습니다.` };
if (matches.length > 1) return { ok: false, error: `"${query}"에 여러 작업이 일치합니다: ${matches.map(m => m.text).join(', ')} — 더 구체적으로 지정하세요.` };
return { ok: true, phaseIndex: matches[0].phaseIndex, taskIndex: matches[0].taskIndex };
}
export const robotProjectTool = {
name: 'robot_project',
description: '"로봇 프로젝트"(메카넘휠 이동로봇) 관리 대시보드의 부품/작업/메모를 조회·수정한다. 사용자가 로봇 프로젝트의 부품 구매/상태 변경, 작업 완료 체크, 예산 확인, 메모 추가 등을 말하면 이 도구로 실제 대시보드(robot-app.html)를 갱신한다. 물리적 동작이 없는 순수 데이터 편집이라 확인 없이 바로 실행해도 된다.',
schema: { action: 'string' },
jsonSchema: {
type: 'object',
required: ['action'],
properties: {
action: {
type: 'string',
enum: ['get', 'add_part', 'update_part', 'delete_part', 'add_task', 'set_task_done', 'add_phase', 'add_note', 'add_link', 'delete_link'],
description: 'get: 전체 조회(예산 요약 포함, 부품별 링크 포함). add_part/update_part/delete_part: 부품 추가/수정/삭제. add_task: 특정 단계에 작업 추가. set_task_done: 작업 완료/미완료 표시. add_phase: 새 단계 추가. add_note: 메모에 한 줄 추가. add_link/delete_link: 부품에 참고링크(구매처/대체품/업그레이드/관련 레포 등) 추가/삭제 — 사용자가 "이거 어디서 팔아?" 류로 물어서 web_search로 찾았으면 이 액션으로 그 부품에 바로 붙여줄 것.',
},
part_name: { type: 'string', description: '(update_part/delete_part/add_link/delete_link 필수) 대상 부품 이름(일부만 일치해도 됨, 예: "메카넘휠")' },
name: { type: 'string', description: '(add_part 필수, update_part 선택) 부품 이름' },
qty: { type: 'number', description: '(add_part/update_part 선택) 수량. 기본 1' },
unit_price: { type: 'number', description: '(add_part/update_part 선택) 단가(원)' },
status: { type: 'string', enum: ['검토중', '주문완료', '보유'], description: '(add_part/update_part 선택) 상태. 기본 검토중' },
memo: { type: 'string', description: '(add_part/update_part 선택) 메모' },
phase_name: { type: 'string', description: '(add_task 필수) 작업을 추가할 단계 이름(일부만 일치해도 됨)' },
task_text: { type: 'string', description: '(add_task 필수) 새 작업 내용, (set_task_done 필수) 대상 작업(일부만 일치해도 됨)' },
done: { type: 'boolean', description: '(set_task_done 필수) true=완료 표시, false=미완료로 되돌림' },
new_phase_name: { type: 'string', description: '(add_phase 필수) 새 단계 이름' },
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' },
},
additionalProperties: false,
},
execute: async (args: any): Promise<ToolResult> => {
const workspaceRoot = getWorkspacePath(args);
const data = loadRobotProject(workspaceRoot);
const action = String(args?.action || '');
if (action === 'get') {
return { success: true, stdout: formatProject(data) };
}
if (action === 'add_part') {
const name = String(args?.name || '').trim();
if (!name) return { success: false, error: 'name이 필요합니다.' };
const status = STATUSES.includes(args?.status) ? args.status : '검토중';
const part: RobotPart = {
id: genRobotId('p'),
name,
qty: Number.isFinite(Number(args?.qty)) ? Math.max(0, Number(args.qty)) : 1,
unitPrice: Number.isFinite(Number(args?.unit_price)) ? Math.max(0, Number(args.unit_price)) : 0,
status,
memo: String(args?.memo || ''),
links: [],
};
data.parts.push(part);
saveRobotProject(workspaceRoot, data);
return { success: true, stdout: `부품 추가됨: ${part.name} (${part.status})\n\n${budgetSummary(data.parts)}` };
}
if (action === 'update_part') {
const found = findPartIndex(data, String(args?.part_name || ''));
if (!found.ok) return { success: false, error: found.error };
const p = data.parts[found.index];
if (args?.name != null) p.name = String(args.name);
if (args?.qty != null && Number.isFinite(Number(args.qty))) p.qty = Math.max(0, Number(args.qty));
if (args?.unit_price != null && Number.isFinite(Number(args.unit_price))) p.unitPrice = Math.max(0, Number(args.unit_price));
if (STATUSES.includes(args?.status)) p.status = args.status;
if (args?.memo != null) p.memo = String(args.memo);
saveRobotProject(workspaceRoot, data);
return { success: true, stdout: `부품 수정됨: ${p.name} (${p.status}, ${fmtWon(p.qty * p.unitPrice)})\n\n${budgetSummary(data.parts)}` };
}
if (action === 'delete_part') {
const found = findPartIndex(data, String(args?.part_name || ''));
if (!found.ok) return { success: false, error: found.error };
const [removed] = data.parts.splice(found.index, 1);
saveRobotProject(workspaceRoot, data);
return { success: true, stdout: `부품 삭제됨: ${removed.name}\n\n${budgetSummary(data.parts)}` };
}
if (action === 'add_task') {
const foundPhase = findPhaseIndex(data, String(args?.phase_name || ''));
if (!foundPhase.ok) return { success: false, error: foundPhase.error };
const text = String(args?.task_text || '').trim();
if (!text) return { success: false, error: 'task_text가 필요합니다.' };
const phase: RobotPhase = data.phases[foundPhase.index];
phase.tasks.push({ id: genRobotId('t'), text, done: false });
saveRobotProject(workspaceRoot, data);
return { success: true, stdout: `"${phase.name}"에 작업 추가됨: ${text}` };
}
if (action === 'set_task_done') {
if (typeof args?.done !== 'boolean') return { success: false, error: 'done(true/false)이 필요합니다.' };
const found = findTaskLocation(data, String(args?.task_text || ''));
if (!found.ok) return { success: false, error: found.error };
const task = data.phases[found.phaseIndex].tasks[found.taskIndex];
task.done = args.done;
saveRobotProject(workspaceRoot, data);
return { success: true, stdout: `작업 "${task.text}" ${args.done ? '완료 처리됨' : '미완료로 되돌림'}` };
}
if (action === 'add_phase') {
const name = String(args?.new_phase_name || '').trim();
if (!name) return { success: false, error: 'new_phase_name이 필요합니다.' };
data.phases.push({ id: genRobotId('ph'), name, tasks: [] });
saveRobotProject(workspaceRoot, data);
return { success: true, stdout: `단계 추가됨: ${name}` };
}
if (action === 'add_note') {
const text = String(args?.note_text || '').trim();
if (!text) return { success: false, error: 'note_text가 필요합니다.' };
data.notes = data.notes.trim() ? `${data.notes.trim()}\n${text}` : text;
saveRobotProject(workspaceRoot, data);
return { success: true, stdout: `메모 추가됨: ${text}` };
}
if (action === 'add_link') {
const found = findPartIndex(data, String(args?.part_name || ''));
if (!found.ok) return { success: false, error: found.error };
const label = String(args?.link_label || '').trim();
const url = String(args?.link_url || '').trim();
if (!label) return { success: false, error: 'link_label이 필요합니다.' };
if (!url) return { success: false, error: 'link_url이 필요합니다.' };
const p = data.parts[found.index];
ensureLinks(p).push({ id: genRobotId('lk'), label, url });
saveRobotProject(workspaceRoot, data);
return { success: true, stdout: `"${p.name}"에 링크 추가됨: ${label} → ${url}` };
}
if (action === 'delete_link') {
const found = findPartIndex(data, String(args?.part_name || ''));
if (!found.ok) return { success: false, error: found.error };
const p = data.parts[found.index];
const links = ensureLinks(p);
const q = String(args?.link_label || '').trim().toLowerCase();
const matches = links.filter(l => l.label.toLowerCase().includes(q));
if (matches.length === 0) return { success: false, error: `"${p.name}"에 "${args?.link_label}"와 일치하는 링크가 없습니다. 현재 링크: ${links.map(l => l.label).join(', ') || '없음'}` };
if (matches.length > 1) return { success: false, error: `여러 링크가 일치합니다: ${matches.map(l => l.label).join(', ')} — 더 구체적으로 지정하세요.` };
p.links = links.filter(l => l.id !== matches[0].id);
saveRobotProject(workspaceRoot, data);
return { success: true, stdout: `"${p.name}"의 링크 삭제됨: ${matches[0].label}` };
}
return { success: false, error: `알 수 없는 action: ${action}` };
},
};
+282
View File
@@ -0,0 +1,282 @@
import { ToolResult } from '../types.js';
import { getWorkspacePath } from '../config/paths.js';
import {
loadWorkshop, saveWorkshop, genWorkshopId,
WorkshopData, WorkshopProject, WorkshopPart, WorkshopPhase,
} from '../gateway/routes/workshop-storage.js';
// "작업실"(여러 메이커 프로젝트 관리 대시보드) 전용 채팅도구. 원래 로봇 프로젝트 하나만
// 다루던 robot_project를 여러 프로젝트를 오갈 수 있게 일반화(2026-09-20). 대시보드와 같은
// 저장소(workshop-storage.ts)를 읽고 쓰므로 채팅에서 바꾼 내용이 대시보드에도, 대시보드에서
// 바꾼 내용이 채팅에도 곧바로 반영된다. 물리적 동작이 없는 순수 데이터 조회/편집이라
// print3d_model 같은 confirm 게이트는 필요 없음.
const STATUSES = ['검토중', '주문완료', '보유'] as const;
function fmtWon(n: number): string {
return `${Math.round(n).toLocaleString('ko-KR')}원`;
}
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)}`;
}
function formatProject(project: WorkshopProject): string {
const lines: string[] = [];
lines.push(`# ${project.name}`);
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);
}
return lines.join('\n');
}
function ensureLinks(p: WorkshopPart): NonNullable<WorkshopPart['links']> {
if (!p.links) p.links = [];
return p.links;
}
function findProjectIndex(data: WorkshopData, query: string): { ok: true; index: number } | { ok: false; error: string } {
const q = query.trim().toLowerCase();
const matches = data.projects.map((pr, i) => ({ pr, i })).filter(({ pr }) => pr.name.toLowerCase().includes(q));
if (matches.length === 0) return { ok: false, error: `"${query}"와 일치하는 프로젝트가 없습니다. 현재 프로젝트: ${data.projects.map(p => p.name).join(', ')}` };
if (matches.length > 1) return { ok: false, error: `"${query}"에 여러 프로젝트가 일치합니다: ${matches.map(m => m.pr.name).join(', ')} — 더 구체적으로 지정하세요.` };
return { ok: true, index: matches[0].i };
}
// project_name이 주어지면 그 프로젝트를, 없으면 현재 활성 프로젝트를 대상으로 삼는다.
function resolveProject(data: WorkshopData, projectNameArg: any): { ok: true; project: WorkshopProject } | { ok: false; error: string } {
if (projectNameArg) {
const found = findProjectIndex(data, String(projectNameArg));
if (!found.ok) return found;
return { ok: true, project: data.projects[found.index] };
}
const active = data.projects.find(p => p.id === data.activeProjectId) || data.projects[0];
if (!active) return { ok: false, error: '작업실에 프로젝트가 하나도 없습니다. create_project로 먼저 만드세요.' };
return { ok: true, project: active };
}
function findPartIndex(project: WorkshopProject, query: string): { ok: true; index: number } | { ok: false; error: string } {
const q = query.trim().toLowerCase();
const matches = project.parts.map((p, i) => ({ p, i })).filter(({ p }) => p.name.toLowerCase().includes(q));
if (matches.length === 0) return { ok: false, error: `"${query}"와 일치하는 부품이 없습니다. 현재 부품(${project.name}): ${project.parts.map(p => p.name).join(', ')}` };
if (matches.length > 1) return { ok: false, error: `"${query}"에 여러 부품이 일치합니다: ${matches.map(m => m.p.name).join(', ')} — 더 구체적으로 지정하세요.` };
return { ok: true, index: matches[0].i };
}
function findPhaseIndex(project: WorkshopProject, query: string): { ok: true; index: number } | { ok: false; error: string } {
const q = query.trim().toLowerCase();
const matches = project.phases.map((ph, i) => ({ ph, i })).filter(({ ph }) => ph.name.toLowerCase().includes(q));
if (matches.length === 0) return { ok: false, error: `"${query}"와 일치하는 단계가 없습니다. 현재 단계: ${project.phases.map(p => p.name).join(', ')}` };
if (matches.length > 1) return { ok: false, error: `"${query}"에 여러 단계가 일치합니다: ${matches.map(m => m.ph.name).join(', ')} — 더 구체적으로 지정하세요.` };
return { ok: true, index: matches[0].i };
}
function findTaskLocation(project: WorkshopProject, query: string): { ok: true; phaseIndex: number; taskIndex: number } | { ok: false; error: string } {
const q = query.trim().toLowerCase();
const matches: { phaseIndex: number; taskIndex: number; text: string }[] = [];
project.phases.forEach((ph, pi) => {
ph.tasks.forEach((t, ti) => {
if (t.text.toLowerCase().includes(q)) matches.push({ phaseIndex: pi, taskIndex: ti, text: t.text });
});
});
if (matches.length === 0) return { ok: false, error: `"${query}"와 일치하는 작업이 없습니다.` };
if (matches.length > 1) return { ok: false, error: `"${query}"에 여러 작업이 일치합니다: ${matches.map(m => m.text).join(', ')} — 더 구체적으로 지정하세요.` };
return { ok: true, phaseIndex: matches[0].phaseIndex, taskIndex: matches[0].taskIndex };
}
export const workshopProjectTool = {
name: 'workshop_project',
description: '"작업실" 대시보드(여러 메이커 프로젝트를 각각 부품/작업/메모로 관리)를 조회·수정한다. 사용자가 어떤 프로젝트(로봇, 3D프린팅 등)의 부품 구매/상태 변경, 작업 완료 체크, 예산 확인, 메모 추가, 새 프로젝트 생성 등을 말하면 이 도구로 실제 대시보드(workshop-app.html)를 갱신한다. project_name을 안 주면 사용자가 대시보드에서 마지막으로 보고 있던 프로젝트를 대상으로 한다. 물리적 동작이 없는 순수 데이터 편집이라 확인 없이 바로 실행해도 된다.',
schema: { action: 'string' },
jsonSchema: {
type: 'object',
required: ['action'],
properties: {
action: {
type: 'string',
enum: ['list_projects', 'create_project', 'set_active_project', 'get', '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: 프로젝트 조회(예산 요약+부품+작업+메모). add_part/update_part/delete_part: 부품 추가/수정/삭제. add_task: 단계에 작업 추가. set_task_done: 작업 완료/미완료. add_phase: 새 단계 추가. add_note: 메모 한 줄 추가. add_link/delete_link: 부품에 참고링크(구매처/대체품/업그레이드/레포 등) 추가/삭제.',
},
project_name: { type: 'string', description: '(create_project/set_active_project 필수, 그 외 모든 액션 선택) 대상 프로젝트 이름(일부만 일치해도 됨). 생략하면 현재 활성 프로젝트를 대상으로 함.' },
part_name: { type: 'string', description: '(update_part/delete_part/add_link/delete_link 필수) 대상 부품 이름(일부만 일치해도 됨, 예: "메카넘휠")' },
name: { type: 'string', description: '(add_part 필수, update_part 선택) 부품 이름' },
qty: { type: 'number', description: '(add_part/update_part 선택) 수량. 기본 1' },
unit_price: { type: 'number', description: '(add_part/update_part 선택) 단가(원)' },
status: { type: 'string', enum: ['검토중', '주문완료', '보유'], description: '(add_part/update_part 선택) 상태. 기본 검토중' },
memo: { type: 'string', description: '(add_part/update_part 선택) 메모' },
phase_name: { type: 'string', description: '(add_task 필수) 작업을 추가할 단계 이름(일부만 일치해도 됨)' },
task_text: { type: 'string', description: '(add_task 필수) 새 작업 내용, (set_task_done 필수) 대상 작업(일부만 일치해도 됨)' },
done: { type: 'boolean', description: '(set_task_done 필수) true=완료 표시, false=미완료로 되돌림' },
new_phase_name: { type: 'string', description: '(add_phase 필수) 새 단계 이름' },
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' },
},
additionalProperties: false,
},
execute: async (args: any): Promise<ToolResult> => {
const workspaceRoot = getWorkspacePath(args);
const data = loadWorkshop(workspaceRoot);
const action = String(args?.action || '');
if (action === 'list_projects') {
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') || '프로젝트가 없습니다.',
};
}
if (action === 'create_project') {
const name = String(args?.project_name || '').trim();
if (!name) return { success: false, error: 'project_name이 필요합니다.' };
const project: WorkshopProject = { id: genWorkshopId('proj'), name, parts: [], phases: [], notes: '' };
data.projects.push(project);
data.activeProjectId = project.id;
saveWorkshop(workspaceRoot, data);
return { success: true, stdout: `새 프로젝트 생성 및 활성화됨: ${name}` };
}
if (action === 'set_active_project') {
const found = findProjectIndex(data, String(args?.project_name || ''));
if (!found.ok) return { success: false, error: found.error };
data.activeProjectId = data.projects[found.index].id;
saveWorkshop(workspaceRoot, data);
return { success: true, stdout: `활성 프로젝트 전환됨: ${data.projects[found.index].name}` };
}
const resolved = resolveProject(data, args?.project_name);
if (!resolved.ok) return { success: false, error: resolved.error };
const project = resolved.project;
if (action === 'get') {
return { success: true, stdout: formatProject(project) };
}
if (action === 'add_part') {
const name = String(args?.name || '').trim();
if (!name) return { success: false, error: 'name이 필요합니다.' };
const status = STATUSES.includes(args?.status) ? args.status : '검토중';
const part: WorkshopPart = {
id: genWorkshopId('p'),
name,
qty: Number.isFinite(Number(args?.qty)) ? Math.max(0, Number(args.qty)) : 1,
unitPrice: Number.isFinite(Number(args?.unit_price)) ? Math.max(0, Number(args.unit_price)) : 0,
status,
memo: String(args?.memo || ''),
links: [],
};
project.parts.push(part);
saveWorkshop(workspaceRoot, data);
return { success: true, stdout: `[${project.name}] 부품 추가됨: ${part.name} (${part.status})\n\n${budgetSummary(project.parts)}` };
}
if (action === 'update_part') {
const found = findPartIndex(project, String(args?.part_name || ''));
if (!found.ok) return { success: false, error: found.error };
const p = project.parts[found.index];
if (args?.name != null) p.name = String(args.name);
if (args?.qty != null && Number.isFinite(Number(args.qty))) p.qty = Math.max(0, Number(args.qty));
if (args?.unit_price != null && Number.isFinite(Number(args.unit_price))) p.unitPrice = Math.max(0, Number(args.unit_price));
if (STATUSES.includes(args?.status)) p.status = args.status;
if (args?.memo != null) p.memo = String(args.memo);
saveWorkshop(workspaceRoot, data);
return { success: true, stdout: `[${project.name}] 부품 수정됨: ${p.name} (${p.status}, ${fmtWon(p.qty * p.unitPrice)})\n\n${budgetSummary(project.parts)}` };
}
if (action === 'delete_part') {
const found = findPartIndex(project, String(args?.part_name || ''));
if (!found.ok) return { success: false, error: found.error };
const [removed] = project.parts.splice(found.index, 1);
saveWorkshop(workspaceRoot, data);
return { success: true, stdout: `[${project.name}] 부품 삭제됨: ${removed.name}\n\n${budgetSummary(project.parts)}` };
}
if (action === 'add_task') {
const foundPhase = findPhaseIndex(project, String(args?.phase_name || ''));
if (!foundPhase.ok) return { success: false, error: foundPhase.error };
const text = String(args?.task_text || '').trim();
if (!text) return { success: false, error: 'task_text가 필요합니다.' };
const phase: WorkshopPhase = project.phases[foundPhase.index];
phase.tasks.push({ id: genWorkshopId('t'), text, done: false });
saveWorkshop(workspaceRoot, data);
return { success: true, stdout: `[${project.name}] "${phase.name}"에 작업 추가됨: ${text}` };
}
if (action === 'set_task_done') {
if (typeof args?.done !== 'boolean') return { success: false, error: 'done(true/false)이 필요합니다.' };
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];
task.done = args.done;
saveWorkshop(workspaceRoot, data);
return { success: true, stdout: `[${project.name}] 작업 "${task.text}" ${args.done ? '완료 처리됨' : '미완료로 되돌림'}` };
}
if (action === 'add_phase') {
const name = String(args?.new_phase_name || '').trim();
if (!name) return { success: false, error: 'new_phase_name이 필요합니다.' };
project.phases.push({ id: genWorkshopId('ph'), name, tasks: [] });
saveWorkshop(workspaceRoot, data);
return { success: true, stdout: `[${project.name}] 단계 추가됨: ${name}` };
}
if (action === 'add_note') {
const text = String(args?.note_text || '').trim();
if (!text) return { success: false, error: 'note_text가 필요합니다.' };
project.notes = project.notes.trim() ? `${project.notes.trim()}\n${text}` : text;
saveWorkshop(workspaceRoot, data);
return { success: true, stdout: `[${project.name}] 메모 추가됨: ${text}` };
}
if (action === 'add_link') {
const found = findPartIndex(project, String(args?.part_name || ''));
if (!found.ok) return { success: false, error: found.error };
const label = String(args?.link_label || '').trim();
const url = String(args?.link_url || '').trim();
if (!label) return { success: false, error: 'link_label이 필요합니다.' };
if (!url) return { success: false, error: 'link_url이 필요합니다.' };
const p = project.parts[found.index];
ensureLinks(p).push({ id: genWorkshopId('lk'), label, url });
saveWorkshop(workspaceRoot, data);
return { success: true, stdout: `[${project.name}] "${p.name}"에 링크 추가됨: ${label} → ${url}` };
}
if (action === 'delete_link') {
const found = findPartIndex(project, String(args?.part_name || ''));
if (!found.ok) return { success: false, error: found.error };
const p = project.parts[found.index];
const links = ensureLinks(p);
const q = String(args?.link_label || '').trim().toLowerCase();
const matches = links.filter(l => l.label.toLowerCase().includes(q));
if (matches.length === 0) return { success: false, error: `"${p.name}"에 "${args?.link_label}"와 일치하는 링크가 없습니다. 현재 링크: ${links.map(l => l.label).join(', ') || '없음'}` };
if (matches.length > 1) return { success: false, error: `여러 링크가 일치합니다: ${matches.map(l => l.label).join(', ')} — 더 구체적으로 지정하세요.` };
p.links = links.filter(l => l.id !== matches[0].id);
saveWorkshop(workspaceRoot, data);
return { success: true, stdout: `[${project.name}]의 링크 삭제됨: ${matches[0].label}` };
}
return { success: false, error: `알 수 없는 action: ${action}` };
},
};
+1 -1
View File
@@ -96,7 +96,7 @@
<button onclick="window.open('/html/applecherry-app.html','_blank')">🌸 애플체리</button>
<button onclick="window.open('/html/k2-app.html','_blank')">🖨️ K2 콤보</button>
<button onclick="window.open('/html/milling-app.html','_blank')">⚙️ CNC 밀링</button>
<button onclick="window.open('/html/robot-app.html','_blank')">🤖 로봇</button>
<button onclick="window.open('/html/workshop-app.html','_blank')">🛠️ 작업실</button>
</div>
</div>
<button class="mode-btn" onclick="window.open('/dental-agent.html?t='+Date.now(),'_blank')" title="치과 사전 이미지 에이전트">🦷 치과</button>
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🤖 로봇 프로젝트</title>
<title>🛠️ 작업실</title>
<link rel="icon" type="image/png" sizes="64x64" href="/homeclaw_logo.png">
<link rel="stylesheet" href="../css/styles.css">
<script>try{var _t=localStorage.getItem('homeclaw_theme')||'dark';document.documentElement.setAttribute('data-theme',_t);}catch(e){}</script>
@@ -17,6 +17,8 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
.hdr-btn{background:none;border:1.5px solid var(--line);border-radius:6px;padding:3px 9px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;transition:.15s;}
.hdr-btn:hover{border-color:#f59e0b;color:#f59e0b;}
.rb-save-indicator{font-size:10px;color:var(--muted);transition:.3s;flex-shrink:0;}
.ws-project-select{background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:4px 8px;font-size:12px;font-weight:700;color:var(--text);font-family:inherit;outline:none;max-width:220px;}
.ws-project-select:focus{border-color:#f59e0b;}
.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);}
@@ -151,7 +153,10 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
<div class="rb-hdr">
<a href="/" style="text-decoration:none;font-size:13px;color:var(--muted)">← 홈</a>
<h1>🤖 로봇 프로젝트</h1>
<h1>🛠️ 작업실</h1>
<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>
<span class="rb-hdr-sp"></span>
<span class="rb-save-indicator" id="save-indicator"></span>
<button class="hdr-btn" onclick="toggleTheme()">🌙</button>
@@ -217,7 +222,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
<div class="rb-chat" id="chat-panel">
<div class="rb-chat-hdr">
<span class="rb-chat-label">🤖 로봇 프로젝트 채팅</span>
<span class="rb-chat-label" id="chat-label">🛠️ 작업실 채팅</span>
<span class="rb-hdr-sp"></span>
<button class="rb-chat-clear" onclick="clearChat()" title="대화 지우기">🗑️</button>
</div>
@@ -248,36 +253,111 @@ function escAttr(s){return String(s||'').replace(/'/g,'&#39;').replace(/"/g,'&qu
function genId(prefix){return prefix+'_'+Math.random().toString(36).slice(2,10);}
// ── 상태 ──────────────────────────────────────────────────────────────────────
// "작업실"은 여러 프로젝트를 담는다(workshopData.projects) — 화면엔 그중 하나
// (activeProjectId)만 표시하고, parts/phases/notes는 그 프로젝트의 내용을 가리키는
// 지역 변수다(기존 로봇전용 시절 코드를 최대한 그대로 재사용하기 위한 구조 — 함수 대부분은
// parts/phases/notes를 그대로 조작하고, 저장 직전에만 현재 프로젝트 객체로 동기화한다).
let workshopData={projects:[],activeProjectId:''};
let parts=[];
let phases=[];
let notes='';
let saveTimer=null;
let saveDebounce=null;
async function loadProject(){
try{
const r=await fetch('/api/robot/project',{headers:authH()});
if(r.ok){
const d=await r.json();
parts=Array.isArray(d.parts)?d.parts:[];
phases=Array.isArray(d.phases)?d.phases:[];
notes=d.notes||'';
}
}catch{}
function currentProject(){
return workshopData.projects.find(p=>p.id===workshopData.activeProjectId)||workshopData.projects[0];
}
function loadLocalFromProject(){
const proj=currentProject();
parts=proj?(proj.parts||[]):[];
phases=proj?(proj.phases||[]):[];
notes=proj?(proj.notes||''):'';
}
function syncLocalToProject(){
const proj=currentProject();
if(!proj)return;
proj.parts=parts;
proj.phases=phases;
proj.notes=notes;
}
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 label=document.getElementById('chat-label');
const proj=currentProject();
if(label&&proj)label.textContent='🛠️ '+proj.name+' 채팅';
}
function refreshAllPanes(){
expandedParts.clear();
renderProjectSelector();
renderParts();
renderPhases();
document.getElementById('notes-textarea').value=notes;
updateBudget();
}
function switchProject(id){
syncLocalToProject();
workshopData.activeProjectId=id;
loadLocalFromProject();
refreshAllPanes();
scheduleSave();
}
function createProjectPrompt(){
const name=(prompt('새 프로젝트 이름을 입력하세요:')||'').trim();
if(!name)return;
const id=genId('proj');
workshopData.projects.push({id,name,parts:[],phases:[],notes:''});
workshopData.activeProjectId=id;
loadLocalFromProject();
refreshAllPanes();
scheduleSave();
}
function deleteProjectPrompt(){
const proj=currentProject();
if(!proj)return;
if(workshopData.projects.length<=1){alert('마지막 남은 프로젝트는 삭제할 수 없습니다.');return;}
if(!confirm(`"${proj.name}" 프로젝트를 삭제할까요? 되돌릴 수 없습니다.`))return;
workshopData.projects=workshopData.projects.filter(p=>p.id!==proj.id);
workshopData.activeProjectId=workshopData.projects[0].id;
loadLocalFromProject();
refreshAllPanes();
scheduleSave();
}
async function loadWorkshopData(){
try{
const r=await fetch('/api/workshop/data',{headers:authH()});
if(r.ok){
const d=await r.json();
workshopData.projects=Array.isArray(d.projects)?d.projects:[];
workshopData.activeProjectId=d.activeProjectId||(workshopData.projects[0]?.id||'');
}
}catch{}
if(!workshopData.projects.length){
const id=genId('proj');
workshopData.projects=[{id,name:'새 프로젝트',parts:[],phases:[],notes:''}];
workshopData.activeProjectId=id;
}
loadLocalFromProject();
refreshAllPanes();
}
function scheduleSave(){
clearTimeout(saveDebounce);
saveDebounce=setTimeout(saveProject,600);
}
async function saveProject(){
syncLocalToProject();
try{
await fetch('/api/robot/project',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({parts,phases,notes})});
await fetch('/api/workshop/data',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({projects:workshopData.projects,activeProjectId:workshopData.activeProjectId})});
showSaveIndicator('저장됨');
}catch{showSaveIndicator('저장 실패');}
}
@@ -644,14 +724,14 @@ function onNotesChange(){
}
// ── 채팅 패널 ─────────────────────────────────────────────────────────────────
// 세션ID를 "rb_"로 시작하게 만들어서 tool-scope.ts가 이 세션에서는 키워드 없이도
// robot_project 도구를 항상 열어주게 한다(doctor/lawyer 앱과 같은 패턴).
const APP_KEY='rb';
// 세션ID를 "ws_"로 시작하게 만들어서 tool-scope.ts가 이 세션에서는 키워드 없이도
// workshop_project 도구를 항상 열어주게 한다(doctor/lawyer 앱과 같은 패턴).
const APP_KEY='ws';
let _sessionId=null;
function getSessionId(){return _sessionId||'rb_main';}
function getSessionId(){return _sessionId||'ws_main';}
async function initSessionId(){
try{const r=await fetch('/api/user/app-sessions',{headers:authH()});if(r.ok){const d=await r.json();if(d[APP_KEY]){_sessionId=d[APP_KEY];return;}}}catch{}
_sessionId='rb_'+Math.random().toString(36).slice(2,12);
_sessionId='ws_'+Math.random().toString(36).slice(2,12);
try{await fetch('/api/user/app-sessions',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({appKey:APP_KEY,sessionId:_sessionId})});}catch{}
}
@@ -715,7 +795,7 @@ async function callChat(message){
try{
const body={
message,sessionId:getSessionId(),useTools:true,
skillContext:'이 대화는 "로봇 프로젝트"(메카넘휠 이동로봇) 관리 대시보드 페이지의 전용 채팅입니다. robot_project 도구로 부품/작업/메모를 바로 조회·수정하세요.',
skillContext:'이 대화는 "작업실"(여러 메이커 프로젝트 관리) 대시보드 페이지의 전용 채팅입니다. 현재 선택된 프로젝트: "'+(currentProject()?.name||'')+'". workshop_project 도구로 이 프로젝트의 부품/작업/메모를 바로 조회·수정하세요(project_name을 생략하면 이 프로젝트가 대상입니다. 다른 프로젝트를 다루려면 project_name을 명시하거나 set_active_project로 먼저 전환하세요).',
};
const r=await fetch('/api/chat',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify(body)});
if(!r.ok)throw new Error('서버 오류 '+r.status);
@@ -741,8 +821,8 @@ async function callChat(message){
typingDiv.remove();
if(e.name!=='AbortError'){if(!bubble)bubble=addMsg('assistant','');bubble.innerHTML='<span style="color:#ef4444">오류: '+escHtml(e.message)+'</span>';}
}finally{btn.disabled=false;btn.textContent='전송';}
// 채팅으로 데이터가 바뀌었을 수 있으니(robot_project 도구 호출) 대시보드를 새로고침한다.
await loadProject();
// 채팅으로 데이터가 바뀌었을 수 있으니(workshop_project 도구 호출) 대시보드를 새로고침한다.
await loadWorkshopData();
}
async function clearChat(){
@@ -770,10 +850,10 @@ function autoResize(el){el.style.height='auto';el.style.height=Math.min(el.scrol
// ── Init ──────────────────────────────────────────────────────────────────────
(async function init(){
if(!(await checkAuth()))return;
await loadProject();
await loadWorkshopData();
await initSessionId();
await loadHistory();
addMsg('system','🤖 로봇 프로젝트 채팅입니다. 부품 구매/상태 변경, 작업 완료 체크, 예산 확인 등을 물어보세요.');
addMsg('system','🛠️ 작업실 채팅입니다. 현재 프로젝트: "'+(currentProject()?.name||'')+'". 부품 구매/상태 변경, 작업 완료 체크, 예산 확인, 새 프로젝트 생성 등을 물어보세요.');
document.getElementById('chat-input').focus();
})();
</script>