diff --git a/src/gateway/chat/build-tools.ts b/src/gateway/chat/build-tools.ts index bc28120..1cf2330 100644 --- a/src/gateway/chat/build-tools.ts +++ b/src/gateway/chat/build-tools.ts @@ -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=""\'로 전달된다. 생략하면 파일의 기본 동작(보통 전체 조립)을 그대로 렌더링 — 부품 목록을 모르면 먼저 scad 파일을 read로 읽어 PART 분기(if (PART == "...")) 목록을 확인할 것.' }, + }, + }, + }, + }, { type: 'function', function: { diff --git a/src/gateway/chat/handle-chat.ts b/src/gateway/chat/handle-chat.ts index cd649b6..e83f9ea 100644 --- a/src/gateway/chat/handle-chat.ts +++ b/src/gateway/chat/handle-chat.ts @@ -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) }); } diff --git a/src/gateway/chat/retry-decisions.ts b/src/gateway/chat/retry-decisions.ts index da4cf57..5443b32 100644 --- a/src/gateway/chat/retry-decisions.ts +++ b/src/gateway/chat/retry-decisions.ts @@ -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. */ diff --git a/src/gateway/chat/tool-scope.ts b/src/gateway/chat/tool-scope.ts index 539e9bc..e58cbda 100644 --- a/src/gateway/chat/tool-scope.ts +++ b/src/gateway/chat/tool-scope.ts @@ -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; diff --git a/src/tools/python.ts b/src/tools/python.ts index 1f07a2e..1af6bb7 100644 --- a/src/tools/python.ts +++ b/src/tools/python.ts @@ -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}` }; } } diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 411caa1..3da93f9 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -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) diff --git a/src/tools/stl-cad.ts b/src/tools/stl-cad.ts index 5407884..1d8b26f 100644 --- a/src/tools/stl-cad.ts +++ b/src/tools/stl-cad.ts @@ -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=""\'로 전달된다. 생략하면 파일의 기본 동작(보통 전체 조립)을 그대로 렌더링 — 부품 목록을 모르면 먼저 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=""\'로 전달된다. 생략하면 파일의 기본 동작(보통 전체 조립)을 그대로 렌더링 — 부품 목록을 모르면 먼저 scad 파일을 read로 읽어 PART 분기(if (PART == "...")) 목록을 확인할 것.' }, + }, + additionalProperties: false, + }, + execute: async (args: any): Promise => { + 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}`, + }; + }, +}; diff --git a/web-ui/html/workshop-app.html b/web-ui/html/workshop-app.html index e753c7c..5d9bf00 100644 --- a/web-ui/html/workshop-app.html +++ b/web-ui/html/workshop-app.html @@ -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(){