From 7ec72e776acabd7d0947cfd64449831b10221aaf Mon Sep 17 00:00:00 2001 From: kim Date: Sun, 20 Sep 2026 19:04:49 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20"=EB=A1=9C=EB=B4=87=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=EC=A0=9D=ED=8A=B8"=20=EB=8B=A8=EC=9D=BC=20=EB=8C=80?= =?UTF-8?q?=EC=8B=9C=EB=B3=B4=EB=93=9C=EB=A5=BC=20=EC=97=AC=EB=9F=AC=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=EB=A5=BC=20=EC=98=A4?= =?UTF-8?q?=EA=B0=80=EB=8A=94=20"=EC=9E=91=EC=97=85=EC=8B=A4"=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=BC=EB=B0=98=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로봇 프로젝트 하나만 다루던 대시보드/도구를 로봇 외 다른 메이커 프로젝트 (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 --- src/gateway/chat/build-tools.ts | 7 +- src/gateway/chat/tool-scope.ts | 17 +- src/gateway/routes/routes-robot.ts | 32 -- src/gateway/routes/routes-workshop.ts | 32 ++ .../{robot-storage.ts => workshop-storage.ts} | 95 ++++-- src/gateway/server.ts | 4 +- src/tools/registry.ts | 4 +- src/tools/robot-project.ts | 230 -------------- src/tools/workshop-project.ts | 282 ++++++++++++++++++ web-ui/html/index.html | 2 +- .../{robot-app.html => workshop-app.html} | 128 ++++++-- 11 files changed, 504 insertions(+), 329 deletions(-) delete mode 100644 src/gateway/routes/routes-robot.ts create mode 100644 src/gateway/routes/routes-workshop.ts rename src/gateway/routes/{robot-storage.ts => workshop-storage.ts} (57%) delete mode 100644 src/tools/robot-project.ts create mode 100644 src/tools/workshop-project.ts rename web-ui/html/{robot-app.html => workshop-app.html} (88%) diff --git a/src/gateway/chat/build-tools.ts b/src/gateway/chat/build-tools.ts index 2846a90..05b0f50 100644 --- a/src/gateway/chat/build-tools.ts +++ b/src/gateway/chat/build-tools.ts @@ -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' }, diff --git a/src/gateway/chat/tool-scope.ts b/src/gateway/chat/tool-scope.ts index 051a7fc..c2d72f9 100644 --- a/src/gateway/chat/tool-scope.ts +++ b/src/gateway/chat/tool-scope.ts @@ -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; }; diff --git a/src/gateway/routes/routes-robot.ts b/src/gateway/routes/routes-robot.ts deleted file mode 100644 index 6506c77..0000000 --- a/src/gateway/routes/routes-robot.ts +++ /dev/null @@ -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 }); - }); -} diff --git a/src/gateway/routes/routes-workshop.ts b/src/gateway/routes/routes-workshop.ts new file mode 100644 index 0000000..90a8fc5 --- /dev/null +++ b/src/gateway/routes/routes-workshop.ts @@ -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 }); + }); +} diff --git a/src/gateway/routes/robot-storage.ts b/src/gateway/routes/workshop-storage.ts similarity index 57% rename from src/gateway/routes/robot-storage.ts rename to src/gateway/routes/workshop-storage.ts index c8af8ec..a2b9833 100644 --- a/src/gateway/routes/robot-storage.ts +++ b/src/gateway/routes/workshop-storage.ts @@ -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)}`; } diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 1c48bb8..4150882 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -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. diff --git a/src/tools/registry.ts b/src/tools/registry.ts index dd23dc6..2f62297 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -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) diff --git a/src/tools/robot-project.ts b/src/tools/robot-project.ts deleted file mode 100644 index c5950e5..0000000 --- a/src/tools/robot-project.ts +++ /dev/null @@ -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 { - 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 => { - 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}` }; - }, -}; diff --git a/src/tools/workshop-project.ts b/src/tools/workshop-project.ts new file mode 100644 index 0000000..a604002 --- /dev/null +++ b/src/tools/workshop-project.ts @@ -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 { + 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 => { + 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}` }; + }, +}; diff --git a/web-ui/html/index.html b/web-ui/html/index.html index ed5ced3..644de2b 100644 --- a/web-ui/html/index.html +++ b/web-ui/html/index.html @@ -96,7 +96,7 @@ - + diff --git a/web-ui/html/robot-app.html b/web-ui/html/workshop-app.html similarity index 88% rename from web-ui/html/robot-app.html rename to web-ui/html/workshop-app.html index 5b70551..0418028 100644 --- a/web-ui/html/robot-app.html +++ b/web-ui/html/workshop-app.html @@ -3,7 +3,7 @@ -🤖 로봇 프로젝트 +🛠️ 작업실 @@ -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
← 홈 -

🤖 로봇 프로젝트

+

🛠️ 작업실

+ + + @@ -217,7 +222,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
- 🤖 로봇 프로젝트 채팅 + 🛠️ 작업실 채팅
@@ -248,36 +253,111 @@ function escAttr(s){return String(s||'').replace(/'/g,''').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=>``).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='오류: '+escHtml(e.message)+'';} }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(); })();