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