feat: robot_project 전용 채팅도구 추가 — 로봇 프로젝트 대시보드를 채팅으로 편집
로봇 프로젝트("메카넘휠" 이동로봇) 대화에서 부품 상태 변경/추가/삭제, 작업
완료 체크, 새 단계/작업 추가, 메모 추가를 채팅으로 바로 할 수 있게 하는
도구. robot-app.html 대시보드와 같은 저장소(robot-storage.ts로 분리)를
쓰므로 채팅에서 바꾸면 대시보드에도, 대시보드에서 바꾸면 다음 채팅
조회에도 즉시 반영된다.
- robot-storage.ts: routes-robot.ts에서 인라인으로 있던 저장 로직을
분리(case-storage.ts와 같은 목적) — 라우트와 도구가 공유
- robot-project.ts: get/add_part/update_part/delete_part/add_task/
set_task_done/add_phase/add_note 8개 액션, 이름 일부일치 검색이라
내부 id를 몰라도 채팅으로 지칭 가능. 물리적 동작이 없어 confirm 불필요
- registry.ts/build-tools.ts/tool-scope.ts 3곳 등록. 게이트 키워드는
"로봇청소기"(기존 Valetudo 대화)와 안 겹치게 프로젝트 고유 어휘로만 매칭
- 실제 채팅으로 "예산 얼마야?+메카넘휠 주문완료로 바꿔줘" 동시 요청 테스트,
정확한 응답+저장 확인
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1122,6 +1122,30 @@ export function createBuildTools(isOrchestrationSkillEnabled: () => boolean) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'robot_project',
|
||||
description: '"로봇 프로젝트"(메카넘휠 이동로봇) 관리 대시보드의 부품/작업/메모를 조회·수정한다. 사용자가 로봇 프로젝트의 부품 구매/상태 변경, 작업 완료 체크, 예산 확인, 메모 추가 등을 말하면 이 도구로 실제 대시보드(robot-app.html)를 갱신한다. 물리적 동작이 없는 순수 데이터 편집이라 확인 없이 바로 실행해도 된다.',
|
||||
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'], description: 'get: 전체 조회(예산 요약 포함). add_part/update_part/delete_part: 부품 추가/수정/삭제. add_task: 특정 단계에 작업 추가. set_task_done: 작업 완료/미완료 표시. add_phase: 새 단계 추가. add_note: 메모에 한 줄 추가.' },
|
||||
part_name: { type: 'string', description: '(update_part/delete_part 필수) 대상 부품 이름(일부만 일치해도 됨, 예: "메카넘휠")' },
|
||||
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 필수) 메모에 추가할 한 줄' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
|
||||
@@ -178,6 +178,9 @@ 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);
|
||||
// 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
|
||||
@@ -369,6 +372,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' && !hasRobotProjectKeyword) return false;
|
||||
if (name === 'run_command' && !hasRunCommandKeyword) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* robot-storage.ts
|
||||
* "로봇 프로젝트" 대시보드(routes-robot.ts, web-ui/html/robot-app.html)와 전용 채팅도구
|
||||
* (src/tools/robot-project.ts)가 같은 저장소를 읽고 쓰도록 공유하는 모듈 — 하나만 갱신하고
|
||||
* 다른 쪽이 stale해지는 일이 없게 로직을 한 곳에 둔다(case-storage.ts와 같은 목적).
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export interface RobotPart {
|
||||
id: string;
|
||||
name: string;
|
||||
qty: number;
|
||||
unitPrice: number;
|
||||
status: '검토중' | '주문완료' | '보유';
|
||||
memo: string;
|
||||
}
|
||||
|
||||
export interface RobotTask {
|
||||
id: string;
|
||||
text: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
export interface RobotPhase {
|
||||
id: string;
|
||||
name: string;
|
||||
tasks: RobotTask[];
|
||||
}
|
||||
|
||||
export interface RobotProjectData {
|
||||
parts: RobotPart[];
|
||||
phases: RobotPhase[];
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export function robotProjectPath(workspaceRoot: string): string {
|
||||
return path.join(workspaceRoot, '.smallclaw', 'robot-project.json');
|
||||
}
|
||||
|
||||
// 2026-09-19 project_robot_dog.md 메모 기준 — 첫 로드 시 빈 화면 대신 실제 진행상황을 반영.
|
||||
// 사용자가 처음 편집/저장하기 전까진 파일로 실제 기록되지 않는다(읽기 전용 기본값).
|
||||
export function defaultRobotSeed(): RobotProjectData {
|
||||
return {
|
||||
parts: [
|
||||
{ id: 'p1', name: '라즈베리파이5 8GB(중고)', qty: 1, unitPrice: 200000, status: '주문완료', memo: '' },
|
||||
{ id: 'p2', name: 'RPLidar A1(중고)', qty: 1, unitPrice: 60000, status: '주문완료', memo: '' },
|
||||
{ id: 'p3', name: '메카넘휠 4WD 이중데크 섀시 키트', qty: 1, unitPrice: 44000, status: '검토중', memo: 'TT모터4+메카넘휠4+섀시2단' },
|
||||
{ id: 'p4', name: '4채널 모터드라이버(L298N 등)', qty: 1, unitPrice: 10000, status: '검토중', memo: '' },
|
||||
{ id: 'p5', name: 'MPU-6050 자이로/가속도', qty: 1, unitPrice: 2500, status: '검토중', memo: '' },
|
||||
{ id: 'p6', name: '18650 셀 6개(3S2P)', qty: 1, unitPrice: 0, status: '보유', memo: '기존 보유분 재사용' },
|
||||
{ id: 'p7', name: '3S-40A BMS', qty: 1, unitPrice: 2310, status: '검토중', memo: '' },
|
||||
{ id: 'p8', name: '18650 홀더(6구)', qty: 1, unitPrice: 4000, status: '검토중', memo: '' },
|
||||
{ id: 'p9', name: '와이어링/커넥터', qty: 1, unitPrice: 5000, status: '검토중', memo: '' },
|
||||
{ id: 'p10', name: '나사/스탠드오프', qty: 1, unitPrice: 10000, status: '검토중', memo: '' },
|
||||
{ id: 'p11', name: '재활용 NEMA17 스테퍼×5+리드스크류+브라켓', qty: 1, unitPrice: 0, status: '보유', memo: '폐기된 Anet A8 Plus에서 회수, 팔 텔레스코픽 관절용' },
|
||||
{ id: 'p12', name: 'OAK-D-Lite 스테레오 카메라', qty: 1, unitPrice: 175000, status: '검토중', memo: '2단계(팔 추가) 때 그립 타겟팅용' },
|
||||
{ id: 'p13', name: '로봇팔 키트(SO-101 계열, 방식 미정)', qty: 1, unitPrice: 200000, status: '검토중', memo: '옵션 A/B/E 중 미결정 — project_robot_dog.md 참고' },
|
||||
],
|
||||
phases: [
|
||||
{
|
||||
id: 'ph1', name: '1단계: 소형 프로토타입(SLAM 검증)',
|
||||
tasks: [
|
||||
{ id: 't1', text: '라즈베리파이5 8GB 주문', done: true },
|
||||
{ id: 't2', text: 'RPLidar A1 중고 주문', done: true },
|
||||
{ id: 't3', text: '메카넘휠 섀시+모터드라이버 구매', done: false },
|
||||
{ id: 't4', text: 'ROS2 Nav2 SLAM 소프트웨어 검증', done: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ph2', name: '2단계: 팔 추가',
|
||||
tasks: [
|
||||
{ id: 't5', text: '팔 방식 최종 결정(SO-101 스케일업 vs AR4 vs 하이브리드)', done: false },
|
||||
{ id: 't6', text: 'OAK-D-Lite 카메라 구매', done: false },
|
||||
{ id: 't7', text: '스테퍼+리드스크류 텔레스코픽 관절 설계/제작', done: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ph3', name: 'V2: 1m급 대형화(장기)',
|
||||
tasks: [
|
||||
{ id: 't8', text: '필요토크 계산 및 고토크모터 스펙 확정', done: false },
|
||||
{ id: 't9', text: '대형 메카넘휠/알루미늄 프로파일 프레임 설계', done: false },
|
||||
{ id: 't10', text: '하드웨어 전면 교체(소프트웨어 스택은 재사용)', done: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
notes: '',
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
export function saveRobotProject(workspaceRoot: string, data: RobotProjectData): void {
|
||||
const p = robotProjectPath(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 {
|
||||
return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
@@ -2,94 +2,12 @@
|
||||
* routes-robot.ts
|
||||
* "로봇 프로젝트" 관리 대시보드 — 부품 리스트/단계별 작업/메모를 담은 단일 문서를
|
||||
* 사용자별로 저장한다. doctor/lawyer 케이스 앱과 달리 여러 건이 아니라 프로젝트
|
||||
* 하나뿐이라 통째로 GET/PUT하는 단일 JSON 문서 방식(케이스 배열이 아님) — 원자적
|
||||
* 쓰기(tmp+rename)는 routes-doctor-cases.ts와 동일한 관용구.
|
||||
* 하나뿐이라 통째로 GET/PUT하는 단일 JSON 문서 방식(케이스 배열이 아님). 실제 저장
|
||||
* 로직은 robot-storage.ts에 있음 — 전용 채팅도구(src/tools/robot-project.ts)와 공유한다.
|
||||
*/
|
||||
import express from 'express';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { getUserWorkspace } from '../../config/config';
|
||||
|
||||
interface RobotPart {
|
||||
id: string;
|
||||
name: string;
|
||||
qty: number;
|
||||
unitPrice: number;
|
||||
status: '검토중' | '주문완료' | '보유';
|
||||
memo: string;
|
||||
}
|
||||
|
||||
interface RobotTask {
|
||||
id: string;
|
||||
text: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
interface RobotPhase {
|
||||
id: string;
|
||||
name: string;
|
||||
tasks: RobotTask[];
|
||||
}
|
||||
|
||||
interface RobotProjectData {
|
||||
parts: RobotPart[];
|
||||
phases: RobotPhase[];
|
||||
notes: string;
|
||||
}
|
||||
|
||||
function robotProjectPath(username: string): string {
|
||||
return path.join(getUserWorkspace(username), '.smallclaw', 'robot-project.json');
|
||||
}
|
||||
|
||||
// 2026-09-19 project_robot_dog.md 메모 기준 — 첫 로드 시 빈 화면 대신 실제 진행상황을 반영.
|
||||
// 사용자가 처음 편집해서 PUT하기 전까진 파일로 저장되지 않는다(읽기 전용 기본값).
|
||||
function defaultSeed(): RobotProjectData {
|
||||
return {
|
||||
parts: [
|
||||
{ id: 'p1', name: '라즈베리파이5 8GB(중고)', qty: 1, unitPrice: 200000, status: '주문완료', memo: '' },
|
||||
{ id: 'p2', name: 'RPLidar A1(중고)', qty: 1, unitPrice: 60000, status: '주문완료', memo: '' },
|
||||
{ id: 'p3', name: '메카넘휠 4WD 이중데크 섀시 키트', qty: 1, unitPrice: 44000, status: '검토중', memo: 'TT모터4+메카넘휠4+섀시2단' },
|
||||
{ id: 'p4', name: '4채널 모터드라이버(L298N 등)', qty: 1, unitPrice: 10000, status: '검토중', memo: '' },
|
||||
{ id: 'p5', name: 'MPU-6050 자이로/가속도', qty: 1, unitPrice: 2500, status: '검토중', memo: '' },
|
||||
{ id: 'p6', name: '18650 셀 6개(3S2P)', qty: 1, unitPrice: 0, status: '보유', memo: '기존 보유분 재사용' },
|
||||
{ id: 'p7', name: '3S-40A BMS', qty: 1, unitPrice: 2310, status: '검토중', memo: '' },
|
||||
{ id: 'p8', name: '18650 홀더(6구)', qty: 1, unitPrice: 4000, status: '검토중', memo: '' },
|
||||
{ id: 'p9', name: '와이어링/커넥터', qty: 1, unitPrice: 5000, status: '검토중', memo: '' },
|
||||
{ id: 'p10', name: '나사/스탠드오프', qty: 1, unitPrice: 10000, status: '검토중', memo: '' },
|
||||
{ id: 'p11', name: '재활용 NEMA17 스테퍼×5+리드스크류+브라켓', qty: 1, unitPrice: 0, status: '보유', memo: '폐기된 Anet A8 Plus에서 회수, 팔 텔레스코픽 관절용' },
|
||||
{ id: 'p12', name: 'OAK-D-Lite 스테레오 카메라', qty: 1, unitPrice: 175000, status: '검토중', memo: '2단계(팔 추가) 때 그립 타겟팅용' },
|
||||
{ id: 'p13', name: '로봇팔 키트(SO-101 계열, 방식 미정)', qty: 1, unitPrice: 200000, status: '검토중', memo: '옵션 A/B/E 중 미결정 — project_robot_dog.md 참고' },
|
||||
],
|
||||
phases: [
|
||||
{
|
||||
id: 'ph1', name: '1단계: 소형 프로토타입(SLAM 검증)',
|
||||
tasks: [
|
||||
{ id: 't1', text: '라즈베리파이5 8GB 주문', done: true },
|
||||
{ id: 't2', text: 'RPLidar A1 중고 주문', done: true },
|
||||
{ id: 't3', text: '메카넘휠 섀시+모터드라이버 구매', done: false },
|
||||
{ id: 't4', text: 'ROS2 Nav2 SLAM 소프트웨어 검증', done: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ph2', name: '2단계: 팔 추가',
|
||||
tasks: [
|
||||
{ id: 't5', text: '팔 방식 최종 결정(SO-101 스케일업 vs AR4 vs 하이브리드)', done: false },
|
||||
{ id: 't6', text: 'OAK-D-Lite 카메라 구매', done: false },
|
||||
{ id: 't7', text: '스테퍼+리드스크류 텔레스코픽 관절 설계/제작', done: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ph3', name: 'V2: 1m급 대형화(장기)',
|
||||
tasks: [
|
||||
{ id: 't8', text: '필요토크 계산 및 고토크모터 스펙 확정', done: false },
|
||||
{ id: 't9', text: '대형 메카넘휠/알루미늄 프로파일 프레임 설계', done: false },
|
||||
{ id: 't10', text: '하드웨어 전면 교체(소프트웨어 스택은 재사용)', done: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
notes: '',
|
||||
};
|
||||
}
|
||||
import { loadRobotProject, saveRobotProject } from './robot-storage';
|
||||
|
||||
export function registerRobotRoutes(
|
||||
app: express.Application,
|
||||
@@ -98,13 +16,7 @@ export function registerRobotRoutes(
|
||||
app.get('/api/robot/project', (req, res) => {
|
||||
const session = getSessionUser(req);
|
||||
if (!session) return res.status(401).json({ error: 'Unauthorized' });
|
||||
const p = robotProjectPath(session.username);
|
||||
if (!fs.existsSync(p)) return res.json(defaultSeed());
|
||||
try {
|
||||
res.json(JSON.parse(fs.readFileSync(p, 'utf-8')));
|
||||
} catch {
|
||||
res.json(defaultSeed());
|
||||
}
|
||||
res.json(loadRobotProject(getUserWorkspace(session.username)));
|
||||
});
|
||||
|
||||
app.put('/api/robot/project', (req, res) => {
|
||||
@@ -114,12 +26,7 @@ export function registerRobotRoutes(
|
||||
if (!Array.isArray(parts) || !Array.isArray(phases)) {
|
||||
return res.status(400).json({ error: 'parts/phases array required' });
|
||||
}
|
||||
const data: RobotProjectData = { parts, phases, notes: String(notes || '') };
|
||||
const p = robotProjectPath(session.username);
|
||||
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);
|
||||
saveRobotProject(getUserWorkspace(session.username), { parts, phases, notes: String(notes || '') });
|
||||
res.json({ success: true });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -32,6 +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';
|
||||
|
||||
export interface Tool {
|
||||
name: string;
|
||||
@@ -238,6 +239,7 @@ class ToolRegistry {
|
||||
// 집 CCTV 한 프레임 캡처 → 비전 모델이 직접 확인 (감시카메라/마당/현관/택배 확인 요청)
|
||||
this.registerSafe(nvrSnapshotTool);
|
||||
this.registerSafe(print3dModelTool);
|
||||
this.registerSafe(robotProjectTool);
|
||||
// News (NewsData.io — real headlines with real publish dates, for news queries)
|
||||
this.registerSafe(newsSearchTool);
|
||||
// Email tools (IMAP + SMTP)
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { ToolResult } from '../types.js';
|
||||
import { getWorkspacePath } from '../config/paths.js';
|
||||
import {
|
||||
loadRobotProject, saveRobotProject, genRobotId,
|
||||
RobotProjectData, RobotPart, RobotPhase,
|
||||
} from '../gateway/routes/robot-storage.js';
|
||||
|
||||
// "로봇 프로젝트"(메카넘휠 이동로봇, 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})` : ''}`);
|
||||
}
|
||||
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'],
|
||||
description: 'get: 전체 조회(예산 요약 포함). add_part/update_part/delete_part: 부품 추가/수정/삭제. add_task: 특정 단계에 작업 추가. set_task_done: 작업 완료/미완료 표시. add_phase: 새 단계 추가. add_note: 메모에 한 줄 추가.',
|
||||
},
|
||||
part_name: { type: 'string', description: '(update_part/delete_part 필수) 대상 부품 이름(일부만 일치해도 됨, 예: "메카넘휠")' },
|
||||
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 필수) 메모에 추가할 한 줄' },
|
||||
},
|
||||
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 || ''),
|
||||
};
|
||||
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}` };
|
||||
}
|
||||
|
||||
return { success: false, error: `알 수 없는 action: ${action}` };
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user