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:
@@ -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' },
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}` };
|
||||
},
|
||||
};
|
||||
@@ -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}` };
|
||||
},
|
||||
};
|
||||
@@ -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,''').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>
|
||||
Reference in New Issue
Block a user