fix: 작업실 채팅의 scad→STL 변환을 끝까지 신뢰성 있게 만듦

하나의 요청("scad를 STL로 만들어줘")을 실제로 안정적으로 처리하기까지
여러 겹의 문제가 있었음 — 전부 실측(ws_ 세션 실제 호출)으로 확인하고 고침.

1. 진짜 원인: build-tools.ts에 stl_cad의 완전히 별개인 낡은 하드코딩
   스키마 사본이 있어서, stl-cad.ts의 jsonSchema를 아무리 고쳐도 모델에게
   전달 안 되고 있었음(registry.ts 등록과 별개로 이 파일도 갱신해야 함).

2. 액션 enum(stl_cad의 action="compile") 선택 자체가 경량모델에 어려운
   것으로 3연속 실측 확인 — 전용 단일목적 도구 scad_to_stl 신설(scad_path/
   output_path/part만 받음, 선택지 없앰). tool-scope.ts에 게이트 추가.

3. python_eval의 subprocess 차단 메시지에 대안 도구 힌트 추가 — 처음엔
   워드바운더리(\b) 정규식이 pattern.source 문자열 자체와 안 맞는 버그로
   한 번도 안 붙었던 것도 같이 고침.

4. hasStlCadKeyword가 짧은 재시도("다시 시도해봐")에서 원래 요청이 2턴
   윈도우 밖으로 밀려나며 도구가 사라지는 문제 — 직전 어시스턴트 답변도
   같이 보게 함(lastAssistantMsg를 tool-scope.ts까지 스레딩).

5. AUTO-RECOVER가 scad_to_stl의 정상 결과("200.0×110.0×277.5 mm", 방금
   만든 파일 실측치)를 "검증 안 된 스펙 주장"으로 오판해 웹서치를 강제
   재프롬프트하던 것 — retry-decisions.ts의 GROUNDING_TOOL_PATTERN에 추가.
   이 패턴이 "검색결과 읽는 법" 안내 트리거와 공유돼있어서, CAD 결과에
   그 안내가 붙어 모델이 엉뚱하게 답하는 부작용도 같이 분리해서 막음.

6. 워크숍 세션 컨텍스트(handle-chat.ts workshopProjectCtx) 확장: 활성
   프로젝트 안 scad 파일이 하나뿐이면 되묻지 않고 자동 사용, STL
   저장경로는 원본 scad 옆 print/ 서브폴더로 유도(CAD/print=인쇄용,
   CAD/reference=참고용 관례 유지).

7. workshop-app.html: 채팅으로 새 파일이 생겨도 파일탭이 이미 열려있으면
   자동 새로고침 안 되던 것 수정.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
kim
2026-09-22 17:05:45 +09:00
co-authored by Claude Code
parent 71afd45959
commit 7485192e4d
8 changed files with 177 additions and 15 deletions
+21 -1
View File
@@ -1153,7 +1153,7 @@ export function createBuildTools(isOrchestrationSkillEnabled: () => boolean) {
type: 'function',
function: {
name: 'stl_cad',
description: 'STL 3D모델 파일을 확인(치수/삼각형수)하거나 여러 각도로 미리보기 렌더링하거나, 지정한 위치에 원통형 구멍을 뚫는다(OpenSCAD 기반 — FreeCAD 대신 이걸 씀, 텍스트 스크립트라 자동화에 더 적합). 워크스페이스 안의 파일만 다룬다. 물리적 동작이 없는 파일 편집이라 확인 없이 바로 실행해도 된다.',
description: '이미 있는 STL 3D모델 파일을 확인(치수/삼각형수)하거나 여러 각도로 미리보기 렌더링하거나, 지정한 위치에 원통형 구멍을 뚫는다(OpenSCAD 기반 — FreeCAD 대신 이걸 씀, 텍스트 스크립트라 자동화에 더 적합). ⚠️ 입력 파일이 .scad(아직 STL이 아닌 소스)면 이 도구 말고 scad_to_stl 도구를 쓸 것. 워크스페이스 안의 파일만 다룬다. 물리적 동작이 없는 파일 편집이라 확인 없이 바로 실행해도 된다.',
parameters: {
type: 'object', required: ['action', 'stl_path'],
properties: {
@@ -1171,6 +1171,26 @@ export function createBuildTools(isOrchestrationSkillEnabled: () => boolean) {
},
},
},
{
type: 'function',
function: {
// .scad→STL 전용 단일목적 도구 — 2026-09-22 추가. stl_cad에 action="compile"로
// 넣었더니 경량모델(워크숍 채팅)이 계속 info/render를 대신 골라 실패하는 걸 3번
// 연속 실측 확인해서, 액션 선택 자체가 없는 전용 도구로 분리함(stl-cad.ts의
// scadToStlTool과 실행 로직 공유 — 이 파일의 스키마는 registry.ts의 tool.jsonSchema와
// 별개로 유지되는 사본이라 둘 다 갱신해야 함, 이번에 그거 놓쳐서 한참 헤맴).
name: 'scad_to_stl',
description: '.scad(OpenSCAD 소스) 파일을 .stl로 컴파일한다. ".scad를 STL로 만들어줘/변환해줘" 같은 요청엔 이 도구를 바로 쓴다 — python_eval로 openscad 커맨드를 직접 실행하려 하지 말 것(subprocess가 막혀있음). 이미 있는 STL 파일을 확인/렌더/수정하려면 이 도구가 아니라 stl_cad를 쓴다.',
parameters: {
type: 'object', required: ['scad_path', 'output_path'],
properties: {
scad_path: { type: 'string', description: '워크스페이스 기준 상대경로의 .scad 소스 파일.' },
output_path: { type: 'string', description: '결과 STL을 저장할 워크스페이스 기준 상대경로(.stl로 끝나야 함).' },
part: { type: 'string', description: '(선택) scad 파일이 PART 변수로 부품을 나눠 내보내는 구조면 여기에 부품 이름을 넣으면 openscad에 -D \'PART="<part>"\'로 전달된다. 생략하면 파일의 기본 동작(보통 전체 조립)을 그대로 렌더링 — 부품 목록을 모르면 먼저 scad 파일을 read로 읽어 PART 분기(if (PART == "...")) 목록을 확인할 것.' },
},
},
},
},
{
type: 'function',
function: {
+47 -5
View File
@@ -258,19 +258,58 @@ async function handleChat(
const wd = loadWorkshop(workspacePath);
const proj = wd.projects.find(p => p.id === wd.activeProjectId);
if (!proj) return '';
// scad 파일이 프로젝트 안에 정확히 1개뿐이면 경로를 직접 박아준다 — 안 그러면 모델이
// "어떤 .scad 파일이요?"라고 되묻는데, 후보가 하나뿐인 상황에선 불필요한 왕복이다
// (2026-09-22, 사용자 요청: "하나뿐이니 자동으로 하자"). 여러 개면 그냥 안 건드리고
// 목록만 보여줘서 모델이 정상적으로 되묻게 둔다(잘못 추측하는 것보다 낫다).
let scadHint = '';
try {
const projDir = path.join(workspacePath, 'workshop', proj.id);
const scadFiles: string[] = [];
const walk = (dir: string, depth: number) => {
if (depth > 6) return;
let entries: fs.Dirent[];
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full, depth + 1);
else if (entry.isFile() && entry.name.toLowerCase().endsWith('.scad')) {
scadFiles.push(path.relative(workspacePath, full).replace(/\\/g, '/'));
}
}
};
walk(projDir, 0);
if (scadFiles.length > 0) {
// 09-22: 사용자 지적 — scad_to_stl 결과가 CAD/ 바로 밑에 널브러지지 말고 print/
// 서브폴더에 정리돼야 보기 좋다(이 프로젝트도, proj_scan_dental도 이미 그 관례를 씀
// — CAD/print/=인쇄 대상, CAD/reference/=참고용 비인쇄 모델).
scadHint = ' scad_to_stl로 STL을 만들 때 output_path는 특별한 지시가 없으면 그 .scad ' +
'파일이 있는 폴더 밑 print/ 서브폴더에 저장할 것(예: ".../CAD/x.scad" → ' +
'".../CAD/print/x.stl", 폴더 없으면 새로 생겨도 됨) — CAD/ 바로 밑에 흩어놓지 말 것.';
}
if (scadFiles.length === 1) {
scadHint += ` 이 프로젝트엔 .scad 파일이 "${scadFiles[0]}" 하나뿐이다 — 사용자가 ` +
`".scad를 STL로 만들어줘" 식으로 경로 없이 말해도 어느 파일인지 되묻지 말고 ` +
`바로 이 경로로 scad_to_stl을 실행할 것.`;
}
} catch { /* noop — 스캔 실패해도 나머지 컨텍스트는 유지 */ }
return `[작업실(workshop) 세션 — 지금 사용자가 보고 있는 프로젝트: "${proj.name}" (id: ${proj.id}). ` +
`이 프로젝트의 파일(STL/scad/사진 등)은 워크스페이스 기준 workshop/${proj.id}/ 아래에만 있다. ` +
`파일을 찾거나 만들 때 이 프로젝트 폴더만 보고, 다른 프로젝트 폴더(workshop/<다른id>/)는 ` +
`사용자가 명시적으로 그 프로젝트를 언급하지 않는 한 후보로 내놓지 말 것.]`;
`사용자가 명시적으로 그 프로젝트를 언급하지 않는 한 후보로 내놓지 말 것.${scadHint}]`;
} catch { return ''; }
})();
const callerContextWithWorkshop = workshopProjectCtx
? `${callerContext || ''}${callerContext ? '\n\n' : ''}${workshopProjectCtx}`
: callerContext;
// hasStlCadKeyword(tool-scope.ts)가 짧은 재시도("다시 시도해봐")에서 도구가 사라지지 않게
// 직전 어시스턴트 답변도 보므로, personalityCtx보다 앞으로 끌어올림(원래 여기 없었음).
const lastAssistantMsg = [...history].reverse().find(h => h.role === 'assistant')?.content;
// 도구 선택 로직은 tool-scope.ts로 분리됨(2026-07-29) — handleChat 안의 클로저라 테스트가
// 불가능했고, 게이트 하나가 조용히 어긋나도 아무 에러 없이 프롬프트만 뚱뚱해지던 구간.
const tools = selectToolsForTurn(buildTools(),
{ message, recentUserText, sessionId, userWorkspace, isSkillEnabledForUser },
const tools = selectToolsForTurn(buildTools(),
{ message, recentUserText, lastAssistantMsg: String(lastAssistantMsg || ''), sessionId, userWorkspace, isSkillEnabledForUser },
{ isBootStartupTurn, isCodeAiSession, isTranslateSession, isProjSession });
const allToolResults: ToolResult[] = [];
let allThinking = '';
@@ -700,7 +739,6 @@ async function handleChat(
}
}
const lastAssistantMsg = [...history].reverse().find(h => h.role === 'assistant')?.content;
const personalityCtx = await buildPersonalityContext(sessionId, workspacePath, message, executionMode || 'interactive', history.length, String(lastAssistantMsg || '').slice(-500), tools.map((t: any) => String(t?.function?.name || '')));
// Inject active browser session state so LLM knows to reuse it instead of re-opening
@@ -3143,8 +3181,12 @@ async function handleChat(
//
// 시스템 메시지를 갈아끼우지 않고 **뒤에 덧붙이는** 이유는 KV 캐시다. 프리픽스가 바뀌면
// 라운드마다 프롬프트 전체를 다시 계산하게 되어, 아낀 토큰보다 잃는 게 크다.
// stl_cad/scad_to_stl은 GROUNDING_TOOL_PATTERN엔 있지만(수치가 방금 만든 파일 실측값이라
// AUTO-RECOVER 면제 대상 — retry-decisions.ts 주석 참고) "검색결과 읽는 법"(타임스탬프
// 교차검증 등) 안내는 이들 결과엔 안 맞는다 — 2026-09-22 실측: CAD 변환 성공 응답에 이
// 안내가 붙자 약한 모델이 실제 STL 결과 대신 "규칙 숙지했습니다"로 엉뚱하게 답함.
if (!groundingReadingRuleInjected
&& allToolResults.some(r => GROUNDING_TOOL_PATTERN.test(String(r?.name || '')) && !r.error)) {
&& allToolResults.some(r => GROUNDING_TOOL_PATTERN.test(String(r?.name || '')) && !/^(stl_cad|scad_to_stl)$/.test(String(r?.name || '')) && !r.error)) {
groundingReadingRuleInjected = true;
messages.push({ role: 'user', content: groundingResultReadingRule(dateStr) });
}
+8 -1
View File
@@ -58,8 +58,15 @@ import {
// while counting as "no grounding tool called" — forcing a web_search retry to second-guess the
// authoritative source it had just read. weather_* was already covered by prefix; these two were
// simply never added when they were written.
//
// stl_cad / scad_to_stl added 2026-09-22 — same gap again, found live via 작업실 채팅: it compiled
// a .scad to STL, replied with the tool's own measured bbox ("200.0 × 110.0 × 277.5 mm", straight
// from readStlBBox() on the file it just wrote), and unverifiedSpecClaim saw a number+unit with no
// grounding tool call on record and forced a "call web_search now" retry — which obviously can't
// verify a local file's dimensions, so the model just searched generic news instead and tacked a
// non-sequitur onto an already-correct answer.
/** Tools that can actually ground a factual/live-data answer. Anything else does not count. */
export const GROUNDING_TOOL_PATTERN = /^(web_search|web_fetch|weather_|ollama_web_|news_search|memory_stats|nhc_active_storms|eonet_events|pubmed_search|pubmed_fetch|pubmed_fulltext|openalex_search|korean_law_search|korean_law_fetch|us_case_search|sqlite_query|cms_hospital_compare)/i;
export const GROUNDING_TOOL_PATTERN = /^(web_search|web_fetch|weather_|ollama_web_|news_search|memory_stats|nhc_active_storms|eonet_events|pubmed_search|pubmed_fetch|pubmed_fulltext|openalex_search|korean_law_search|korean_law_fetch|us_case_search|sqlite_query|cms_hospital_compare|stl_cad|scad_to_stl)/i;
export interface AutoRecoverInput {
/** The user's message this turn. */
+15 -4
View File
@@ -38,6 +38,15 @@ export interface ToolScopeInput {
* coding word from two turns ago drag coder tools into an unrelated turn.
*/
recentUserText?: string;
/**
* The assistant's own previous reply, truncated. Used ONLY by hasStlCadKeyword (2026-09-22):
* a short retry like "다시 시도해봐" carries none of the stl/cad/scad keywords itself, and if
* the original request has already scrolled out of recentUserText's 2-turn window, the tool
* silently vanishes mid-task (실측 — 워크숍 채팅에서 "scad_to_stl 도구를 쓰라고 안내는 받았는데
* 실제 목록엔 없다"고 보고). The assistant's last turn is a strong same-topic signal here since
* our own tool descriptions inject the tool name into it.
*/
lastAssistantMsg?: string;
/** Session id; the wt_/lw_/ac_ prefixes select app-specific tool sets. */
sessionId: string;
/** Workspace path used for per-user skill toggles; null when unauthenticated. */
@@ -178,9 +187,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);
// stl_cad — STL 확인/렌더링/구멍뚫기(OpenSCAD). print3d와 겹치는 "stl" 외에 CAD 편집
// 관련 어휘도 추가로 매칭.
const hasStlCadKeyword = /stl|cad|캐드|구멍\s*뚫|렌더링|바운딩박스|openscad|치수\s*확인/i.test(topicText);
// stl_cad/scad_to_stl — STL 확인/렌더링/구멍뚫기/scad 컴파일(OpenSCAD). print3d와 겹치는
// "stl" 외에 CAD 편집 관련 어휘도 추가로 매칭. 직전 어시스턴트 답변도 같이 봄(위
// lastAssistantMsg 주석 참고) — 짧은 "다시 시도해봐" 재시도에 도구가 사라지는 걸 막는다.
const stlCadPattern = /stl|cad|캐드|구멍\s*뚫|렌더링|바운딩박스|openscad|치수\s*확인/i;
const hasStlCadKeyword = stlCadPattern.test(topicText) || stlCadPattern.test(String(input.lastAssistantMsg || '').slice(-800));
// workshop_project — "작업실"(여러 메이커 프로젝트) 대시보드 부품/작업 편집. "로봇" 단독은
// 로봇청소기(Valetudo) 대화와 겹치므로 프로젝트 고유 어휘로만 매칭한다. 새 프로젝트 종류가
// 늘어날 때마다 이 목록도 넓혀줘야 함(앱 세션 안에서는 아래 isWorkshopAppSession이 대신
@@ -381,7 +392,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 === 'stl_cad' && !hasStlCadKeyword) return false;
if ((name === 'stl_cad' || name === 'scad_to_stl') && !hasStlCadKeyword) return false;
if (name === 'workshop_project' && !isWorkshopAppSession && !hasWorkshopKeyword) return false;
if (name === 'run_command' && !hasRunCommandKeyword) return false;
return true;
+13 -1
View File
@@ -52,7 +52,19 @@ export const pythonEvalTool = {
for (const pattern of BLOCKED) {
if (pattern.test(code)) {
return { success: false, error: `Blocked: ${pattern.source.replace(/\\b|\\s\*/g, '').replace(/\\\./g, '.')} is not allowed` };
let hint = '';
// subprocess로 openscad를 직접 쏘려는 시도는 흔한 우회 패턴 — 막고 끝내면 모델이
// "직접 할 방법이 없다"고 결론내리고 stl_cad 도구의 compile 액션(정확히 이 용도로
// 있음)을 시도조차 안 하는 걸 확인함(2026-09-22, 워크숍 채팅 실측). 차단 메시지
// 바로 옆에 대안을 박아둔다 — 멀리 있는 프롬프트 지시보다 인접 신호가 더 잘 먹힘.
// 단어경계(\b)로 pattern.source 문자열 자체를 매칭하면 안 됨 — source가 "\bsubprocess..."로
// 시작해서 그 앞의 리터럴 'b' 문자와 word-boundary가 안 걸림(둘 다 단어문자라 경계가 아님),
// 그래서 절대 매치 안 되는 버그였음(2026-09-22 실측으로 발견 — 힌트가 하나도 안 붙었었음).
// 그냥 부분문자열 검사로 충분하다.
if ((pattern.source.includes('subprocess') || pattern.source.includes('system')) && /openscad/i.test(code)) {
hint = ' — OpenSCAD로 .scad를 STL로 만들려면 이 python_eval 대신 scad_to_stl 도구를 쓰세요(scad_path/output_path, 선택 part).';
}
return { success: false, error: `Blocked: ${pattern.source.replace(/\\b|\\s\*/g, '').replace(/\\\./g, '.')} is not allowed${hint}` };
}
}
+2 -1
View File
@@ -33,7 +33,7 @@ import { drugDurCheckTool } from './mfds.js';
import { nvrSnapshotTool } from './nvr.js';
import { print3dModelTool } from './print3d.js';
import { workshopProjectTool } from './workshop-project.js';
import { stlCadTool } from './stl-cad.js';
import { stlCadTool, scadToStlTool } from './stl-cad.js';
export interface Tool {
name: string;
@@ -242,6 +242,7 @@ class ToolRegistry {
this.registerSafe(print3dModelTool);
this.registerSafe(workshopProjectTool);
this.registerSafe(stlCadTool);
this.registerSafe(scadToStlTool);
// News (NewsData.io — real headlines with real publish dates, for news queries)
this.registerSafe(newsSearchTool);
// Email tools (IMAP + SMTP)
+68 -2
View File
@@ -24,13 +24,13 @@ function resolveWorkspacePath(workspacePath: string, relPath: string): string {
export const stlCadTool = {
name: 'stl_cad',
description: 'STL 3D모델 파일을 확인(치수/삼각형수)하거나 여러 각도로 미리보기 렌더링하거나, 지정한 위치에 원통형 구멍을 뚫거나, .scad 소스를 STL로 컴파일한다(OpenSCAD 기반 — FreeCAD 대신 이걸 씀, 텍스트 스크립트라 자동화에 더 적합). 워크스페이스 안의 파일만 다룬다. 물리적 동작이 없는 파일 편집이라 확인 없이 바로 실행해도 된다.',
description: '이미 있는 STL 3D모델 파일을 확인(치수/삼각형수)하거나 여러 각도로 미리보기 렌더링하거나, 지정한 위치에 원통형 구멍을 뚫는다(OpenSCAD 기반 — FreeCAD 대신 이걸 씀). ⚠️ 입력 파일이 .scad(아직 STL이 아닌 소스)면 이 도구 말고 scad_to_stl 도구를 쓸 것 — 그게 그 용도의 전용 도구다. 워크스페이스 안의 파일만 다룬다. 물리적 동작이 없는 파일 편집이라 확인 없이 바로 실행해도 된다.',
schema: { action: 'string', stl_path: 'string' },
jsonSchema: {
type: 'object',
required: ['action'],
properties: {
action: { type: 'string', enum: ['info', 'render', 'add_hole', 'compile'], description: 'info: 치수/바운딩박스/삼각형수 조회(stl_path). render: 지정한 각도로 PNG 미리보기 생성(stl_path, 모델이 직접 봄). add_hole: 지정 좌표에 원통형 구멍 뚫어서 새 STL로 저장(stl_path). compile: .scad 소스를 STL로 렌더/익스포트(scad_path) — 사용자가 ".scad를 STL로 만들어달라"고 하면 이걸 쓴다.' },
action: { type: 'string', enum: ['compile', 'info', 'render', 'add_hole'], description: 'compile: .scad 소스를 STL로 렌더/익스포트(scad_path 필요, stl_path 아님) — 입력이 .scad 파일이면 항상 이거. info: 이미 있는 STL의 치수/바운딩박스/삼각형수 조회(stl_path). render: 이미 있는 STL을 지정한 각도로 PNG 미리보기 생성(stl_path, 모델이 직접 봄). add_hole: 이미 있는 STL의 지정 좌표에 원통형 구멍 뚫어서 새 STL로 저장(stl_path).' },
stl_path: { type: 'string', description: '(info/render/add_hole 필수) 워크스페이스 기준 상대경로의 바이너리 STL 파일(예: "print3d/esp32_camera/esp32_top.stl"). ASCII STL은 지원 안 함.' },
scad_path: { type: 'string', description: '(compile 필수) 워크스페이스 기준 상대경로의 .scad 소스 파일.' },
part: { type: 'string', description: '(compile, 선택) scad 파일이 PART 변수로 부품을 나눠 내보내는 구조면(예: 이 워크스페이스의 scanner-rig.scad류) 여기에 부품 이름을 넣으면 openscad에 -D \'PART="<part>"\'로 전달된다. 생략하면 파일의 기본 동작(보통 전체 조립)을 그대로 렌더링 — 부품 목록을 모르면 먼저 scad 파일을 read로 읽어 PART 분기(if (PART == "...")) 목록을 확인할 것.' },
@@ -145,3 +145,69 @@ export const stlCadTool = {
return { success: false, error: `알 수 없는 action: ${action}` };
},
};
// stl_cad의 compile 액션과 완전히 같은 일을 하는 단일목적 도구 — 2026-09-22 추가.
// 이유: stl_cad는 action enum(info/render/add_hole/compile)이 있는데, 경량모델(워크숍
// 채팅)이 compile을 추가하고 설명도 강화했는데도 계속 info나 render를 대신 골라서 실패하는
// 걸 3번 연속 실측으로 확인함(action 선택 자체가 이 모델 등급엔 어려운 것으로 보임). 액션이
// 아예 없는 전용 도구로 분리하면 그 실패 모드 자체가 성립할 수 없다 — "이름이 곧 용도"인
// 도구가 약한 모델엔 훨씬 안정적으로 먹힌다는 이 프로젝트의 반복 확인 사항과 일치.
export const scadToStlTool = {
name: 'scad_to_stl',
description: '.scad(OpenSCAD 소스) 파일을 .stl로 컴파일한다. ".scad를 STL로 만들어줘/변환해줘" 같은 요청엔 이 도구를 바로 쓴다 — python_eval로 openscad 커맨드를 직접 실행하려 하지 말 것(subprocess가 막혀있음). 이미 있는 STL 파일을 확인/렌더/수정하려면 이 도구가 아니라 stl_cad를 쓴다.',
schema: { scad_path: 'string', output_path: 'string' },
jsonSchema: {
type: 'object',
required: ['scad_path', 'output_path'],
properties: {
scad_path: { type: 'string', description: '워크스페이스 기준 상대경로의 .scad 소스 파일.' },
output_path: { type: 'string', description: '결과 STL을 저장할 워크스페이스 기준 상대경로(.stl로 끝나야 함).' },
part: { type: 'string', description: '(선택) scad 파일이 PART 변수로 부품을 나눠 내보내는 구조면 여기에 부품 이름을 넣으면 openscad에 -D \'PART="<part>"\'로 전달된다. 생략하면 파일의 기본 동작(보통 전체 조립)을 그대로 렌더링 — 부품 목록을 모르면 먼저 scad 파일을 read로 읽어 PART 분기(if (PART == "...")) 목록을 확인할 것.' },
},
additionalProperties: false,
},
execute: async (args: any): Promise<ToolResult> => {
if (!fs.existsSync(OPENSCAD_BIN)) {
return { success: false, error: `OpenSCAD가 설치돼있지 않습니다(${OPENSCAD_BIN}). "sudo apt-get install -y openscad" 필요.` };
}
const workspacePath = getWorkspacePath(args);
let scadPath: string;
try {
scadPath = resolveWorkspacePath(workspacePath, String(args?.scad_path || ''));
} catch (e: any) {
return { success: false, error: String(e?.message || e) };
}
if (!fs.existsSync(scadPath)) return { success: false, error: `파일을 찾을 수 없습니다: ${args?.scad_path}` };
if (!/\.scad$/i.test(scadPath)) return { success: false, error: 'scad_path는 .scad 파일이어야 합니다.' };
const outPathArg = String(args?.output_path || '').trim();
if (!outPathArg) return { success: false, error: 'output_path가 필요합니다.' };
let outPath: string;
try { outPath = resolveWorkspacePath(workspacePath, outPathArg); } catch (e: any) { return { success: false, error: String(e?.message || e) }; }
if (!/\.stl$/i.test(outPath)) return { success: false, error: 'output_path는 .stl로 끝나야 합니다.' };
const part = args?.part !== undefined && args?.part !== null ? String(args.part) : '';
const openscadArgs = [
...(part ? ['-D', `PART="${part.replace(/"/g, '\\"')}"`] : []),
'-o', outPath,
'--export-format=binstl',
scadPath,
];
fs.mkdirSync(path.dirname(outPath), { recursive: true });
const run = await runOpenscad(openscadArgs, 120_000);
if (!run.ok) return { success: false, error: `OpenSCAD 컴파일 실패: ${run.detail}` };
if (!fs.existsSync(outPath) || fs.statSync(outPath).size < 84) {
return { success: false, error: '컴파일은 끝났는데 STL 파일이 비어있거나 없습니다 — PART 값이 이 scad 파일의 분기와 안 맞을 수 있습니다.' };
}
let bbox;
try { bbox = readStlBBox(outPath); } catch (e: any) { return { success: false, error: `STL은 생성됐지만 읽기 실패: ${String(e?.message || e)}` }; }
const size = [bbox.max[0] - bbox.min[0], bbox.max[1] - bbox.min[1], bbox.max[2] - bbox.min[2]];
return {
success: true,
stdout: `**${path.basename(outPath)}** 생성됨${part ? ` (PART="${part}")` : ''}\n` +
`- 크기(X×Y×Z): ${size.map(n => n.toFixed(1)).join(' × ')} mm\n` +
`- 삼각형 수: ${bbox.triangleCount}\n` +
`- 저장 위치: ${outPathArg}`,
};
},
};
+3
View File
@@ -1100,6 +1100,9 @@ async function callChat(message){
}finally{btn.disabled=false;btn.textContent='전송';}
// 채팅으로 데이터가 바뀌었을 수 있으니(workshop_project 도구 호출) 대시보드를 새로고침한다.
await loadWorkshopData();
// 09-22: 채팅으로 scad_to_stl 등 파일도구를 불러 새 파일이 생겨도 파일탭이 이미 열려있으면
// 안 새로고침되던 문제(사용자 신고: "저장되었는데 보이지 않네") — 탭 전환 없이도 갱신.
if(document.getElementById('pane-files').classList.contains('active'))loadFiles();
}
async function clearChat(){