fix: 작업실 앱 전수 검토 결함 일괄 수정 — 채팅 저장 덮어쓰기·업로드 크래시·XSS 외

- 저장: saveDebounce 미해제로 채팅 도구 저장 내용이 새로고침 때 화면 옛 상태로 덮이던 버그.
  서버 동기 스냅샷 기반 dirty(바뀐 프로젝트만 PUT), 실패 시 재시도, 무변경 PUT 제거
- 업로드: 파일명 %(100%.stl)로 decodeURIComponent가 'end' 콜백에서 던져 프로세스가 죽던 버그 +
  수신 중 크기 제한, 콜백 예외 응답
- XSS: escAttr가 따옴표만 처리해 부품/프로젝트/파일명이 innerHTML에서 실행되던 것,
  항목 id 화이트리스트, 링크 href safeUrl
- 헛보고 가드: JS \b가 한글에 안 먹어 "저장했어/했다"가 통과하던 정규식, 명사 없는 후속 확인 질문
- 도구: 정확일치 우선 검색, 중복 생성/추가 방지, 세션(ws_<id>) 프로젝트 기본 대상
- 프로젝트 삭제 시 첨부파일·채팅 세션 정리, 손상 JSON 백업(.corrupt), 옛 전체문서 PUT 제거,
  PUT 본문 검증, 백그라운드 탭 장비 폴링 중지, 전송 중 프로젝트 전환 잠금
- 회귀 테스트 16건 추가(프런트 저장 로직은 vm 시뮬레이션)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-09-24 11:26:19 +09:00
co-authored by Claude Sonnet 5
parent eaa34714cf
commit 8e9226e117
7 changed files with 475 additions and 106 deletions
+1 -1
View File
@@ -1415,7 +1415,7 @@ print(json.dumps({'slides': slides, 'total': len(prs.slides)}, ensure_ascii=Fals
// browser/desktop, memory, workflow, presentation) keep their own branches.
const tool = getToolRegistry().get(name);
if (!tool) return { name, args, result: `Unknown tool: ${name}`, error: true };
const tr = await tool.execute({ ...args, _workspace: workspacePath, _workspacePath: workspacePath });
const tr = await tool.execute({ ...args, _workspace: workspacePath, _workspacePath: workspacePath, ...(name === 'workshop_project' ? { _sessionId: sessionId } : {}) });
const _resultText = tr.stdout || tr.error || '';
const _hasImageMd = /!\[[^\]]*\]\(\/api\/files\/[^)]+\)/.test(_resultText);
// 작업실(ws_) 세션 크로스 프로젝트 경고 — list/python_eval 등 default 분기로 오는 모든
+10 -4
View File
@@ -378,14 +378,20 @@ export function claimsMessageSent(text: string): boolean {
// get만 하고 add_note 없이 저장 보고). 프롬프트 규칙(b327eef)으로도 안 잡혀 가드로 승격.
export function isWorkshopMutationRequest(message: string): boolean {
const m = String(message || '');
return /(메모|개요|부품|작업|단계|프로젝트|작업실|readme)/i.test(m)
&& /(저장|추가|등록|업데이트|수정|삭제|옮기|정리|바꿔|변경|넣어|체크)/.test(m);
// ① 대상 명사 + 변경 동사("메모 저장해줘", "부품 추가")
if (/(메모|개요|부품|작업|단계|프로젝트|작업실|readme)/i.test(m)
&& /(저장|추가|등록|업데이트|수정|삭제|옮기|정리|바꿔|변경|넣어|체크)/.test(m)) return true;
// ② 명사 없는 후속 확인 질문("저장 했니?", "등록됐어?") — 09-24 실측: 이 턴의 답변
// "저장 완료했습니다"가 명사가 없다는 이유로 가드를 통과했다.
return /(저장|등록|반영|추가|업데이트|수정)\s*(했|됐|되었)\s*(니|나|냐|어\s*\?|어요\s*\?|습니까|지\s*\?)/.test(m);
}
export function claimsWorkshopSaved(text: string): boolean {
const s = String(text || '');
// 종결접미사(습니다/어요/어/다) 필수 — "저장했너?"/"저장했던" 같은 질문형·수식형은 안 걸린다.
return /(저장|등록|추가|업데이트|수정|반영)(했|됐|되었)(습니다|어요|어\b|다\b)|저장\s*완료|반영\s*완료|저장해\s?드렸습니다|넣어\s?드렸습니다|등록\s*완료/.test(s)
// 종결접미사 필수 — "저장했너?"/"저장했던"/"저장했다는" 같은 질문형·수식형은 안 걸린다.
// 끝 경계는 \b가 아니라 (?![가-힣?]): JS의 \b는 한글을 단어문자로 안 봐서 "저장했어."
// "저장했다."가 통째로 빠져나갔다(09-24 검토에서 재현).
return /(저장|등록|추가|업데이트|수정|반영)\s?(했|됐|되었)(습니다|어요|어|다|네요)(?![가-힣??])|저장\s*완료|반영\s*완료|저장해\s?드렸습니다|넣어\s?드렸습니다|등록\s*완료/.test(s)
|| /\b(saved (it|to the)|has been saved|was saved)\b/i.test(s);
}
+99 -68
View File
@@ -14,11 +14,24 @@ import express from 'express';
import fs from 'fs';
import path from 'path';
import { getUserWorkspace } from '../../config/config';
import { loadWorkshop, saveWorkshop, upsertProject, removeProject } from './workshop-storage';
import { loadWorkshop, saveWorkshop, upsertProject, removeProject, normalizeProjectInput } from './workshop-storage';
import { clearHistory } from '../session';
import { caseFilesDir, resolveUploadPath, sanitizePathSegment, walkCaseFiles, fileCategory } from './case-storage';
const APP_TYPE = 'workshop';
// multipart filename 디코딩. 옛 코드는 decodeURIComponent를 그냥 불러 "100%.stl" 같은
// 파일명에서 URIError가 났고, 그 위치가 req 'end' 콜백이라 게이트웨이 프로세스가 죽었다
// (2026-09-24 검토에서 재현). RFC5987(filename*=UTF-8''%..)일 때만 퍼센트 디코딩하고,
// 실패하면 원문을 쓴다. 일반 filename="..."은 바이너리→UTF-8 복원.
export function decodeUploadFilename(raw: string): string {
try {
const decoded = decodeURIComponent(raw);
if (decoded !== raw) return decoded;
} catch { /* 퍼센트가 리터럴인 이름 — 아래에서 그대로 처리 */ }
return Buffer.from(raw, 'binary').toString('utf-8');
}
// walkCaseFiles만으로는 파일이 하나도 없는 빈 폴더가 안 보이므로, 디렉토리 구조를 따로 순회.
function listCaseFolders(dir: string, relBase = ''): string[] {
if (!fs.existsSync(dir)) return [];
@@ -43,16 +56,8 @@ export function registerWorkshopRoutes(
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 });
});
// (옛 전체문서 PUT /api/workshop/data는 last-write-wins로 다른 프로젝트 편집을 통째로 덮어써서
// 2026-09-24 제거 — 저장은 아래 프로젝트 단위 API만 쓴다.)
// ── 프로젝트 단위 저장 API(2026-09-24) ─────────────────────────────────────
// 예전의 전체문서 PUT(last-write-wins)은 대시보드 저장과 채팅 도구 저장이 겹치면
@@ -64,14 +69,11 @@ export function registerWorkshopRoutes(
if (!session) return res.status(401).json({ error: 'Unauthorized' });
const id = String(req.params.id || '');
if (!/^[a-zA-Z0-9_-]+$/.test(id)) return res.status(400).json({ error: 'invalid projectId' });
const incoming = req.body || {};
if (incoming.id !== id) return res.status(400).json({ error: 'body.id must match :id' });
if (!Array.isArray(incoming.parts) || !Array.isArray(incoming.phases) || !String(incoming.name || '').trim()) {
return res.status(400).json({ error: 'name/parts/phases required' });
}
const checked = normalizeProjectInput(req.body, id);
if (!checked.ok) return res.status(400).json({ error: checked.error });
const workspace = getUserWorkspace(session.username);
const data = loadWorkshop(workspace);
upsertProject(data, { ...incoming, id });
upsertProject(data, checked.project);
saveWorkshop(workspace, data);
res.json({ success: true });
});
@@ -85,7 +87,15 @@ export function registerWorkshopRoutes(
const data = loadWorkshop(workspace);
if (!removeProject(data, id)) return res.status(404).json({ error: 'not found' });
saveWorkshop(workspace, data);
res.json({ success: true });
// 고아 정리: 첨부파일 폴더(workshop/<id>/)와 이 프로젝트 전용 채팅 세션(ws_<id>).
// 삭제 확인창이 "되돌릴 수 없습니다"라고 하므로 실제로도 남기지 않는다.
let filesRemoved = true;
try {
const dir = caseFilesDir(session.username, APP_TYPE, id);
if (isPathInsideDir(path.join(workspace, APP_TYPE), dir) && fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
} catch { filesRemoved = false; }
try { clearHistory(`ws_${id}`, session.username); } catch { /* 세션이 없으면 무시 */ }
res.json({ success: true, filesRemoved });
});
// 활성 프로젝트 포인터만 단독 저장 — 포인터는 값 하나라 last-write-wins로 충분하다.
@@ -153,61 +163,82 @@ export function registerWorkshopRoutes(
const boundary = contentType.split('boundary=')[1];
if (!boundary) { res.status(400).json({ success: false, error: 'Missing boundary' }); return; }
// 100MB 제한을 body를 다 받은 뒤가 아니라 받는 도중에 건다 — 안 그러면 거대한 요청이
// 바이너리 문자열 변환까지 거치며 수백 MB를 잡아먹는다(멀티파트 오버헤드 1MB 여유).
const MAX_BODY = 100 * 1024 * 1024 + 1024 * 1024;
const declared = Number(req.headers['content-length'] || 0);
if (declared > MAX_BODY) { res.status(413).json({ success: false, error: 'File too large (max 100MB)' }); return; }
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => {
const raw = Buffer.concat(chunks).toString('binary');
const boundaryDelim = '--' + boundary;
let filename = 'upload.bin';
let filetype = 'application/octet-stream';
let fileData: Buffer | null = null;
const parts = raw.split(boundaryDelim);
for (const part of parts) {
if (!part || part.trim() === '--' || part.trim() === '') continue;
const headerEnd = part.indexOf('\r\n\r\n');
if (headerEnd === -1) continue;
const header = part.substring(0, headerEnd);
if (!header.includes('name="file"')) continue;
const fnMatch = header.match(/filename\*=UTF-8''([^\r\n]+)/i)
?? header.match(/filename="([^"]+)"/);
if (fnMatch) {
const raw8 = decodeURIComponent(fnMatch[1]) === fnMatch[1]
? Buffer.from(fnMatch[1], 'binary').toString('utf-8')
: decodeURIComponent(fnMatch[1]);
filename = raw8;
}
const ctMatch = header.match(/Content-Type:\s*([^\r\n]+)/i);
if (ctMatch) filetype = ctMatch[1].trim();
const bodyStart = headerEnd + 4;
const bodyEnd = part.lastIndexOf('\r\n');
if (bodyEnd <= bodyStart) continue;
fileData = Buffer.from(part.substring(bodyStart, bodyEnd), 'binary');
break;
let received = 0;
let aborted = false;
req.on('data', (chunk: Buffer) => {
if (aborted) return;
received += chunk.length;
if (received > MAX_BODY) {
aborted = true;
chunks.length = 0;
res.status(413).json({ success: false, error: 'File too large (max 100MB)' });
req.resume();
return;
}
chunks.push(chunk);
});
req.on('end', () => {
if (aborted) return;
try {
const raw = Buffer.concat(chunks).toString('binary');
const boundaryDelim = '--' + boundary;
if (!fileData) { res.status(400).json({ success: false, error: 'No file found in upload' }); return; }
if (fileData.length > 100 * 1024 * 1024) { res.status(400).json({ success: false, error: 'File too large (max 100MB)' }); return; }
let filename = 'upload.bin';
let filetype = 'application/octet-stream';
let fileData: Buffer | null = null;
const dir = caseFilesDir(session.username, APP_TYPE, projectId);
fs.mkdirSync(dir, { recursive: true });
const targetName = folder ? `${folder}/${filename}` : filename;
const { relPath, absPath } = resolveUploadPath(dir, targetName);
fs.writeFileSync(absPath, fileData);
const parts = raw.split(boundaryDelim);
for (const part of parts) {
if (!part || part.trim() === '--' || part.trim() === '') continue;
const headerEnd = part.indexOf('\r\n\r\n');
if (headerEnd === -1) continue;
const header = part.substring(0, headerEnd);
if (!header.includes('name="file"')) continue;
res.json({
success: true,
name: path.basename(relPath),
relPath,
size: fileData.length,
type: filetype,
url: `/api/files/${APP_TYPE}/${projectId}/${relPath.split('/').map(encodeURIComponent).join('/')}`,
category: fileCategory(relPath),
});
const fnMatch = header.match(/filename\*=UTF-8''([^\r\n]+)/i)
?? header.match(/filename="([^"]+)"/);
if (fnMatch) {
filename = decodeUploadFilename(fnMatch[1]);
}
const ctMatch = header.match(/Content-Type:\s*([^\r\n]+)/i);
if (ctMatch) filetype = ctMatch[1].trim();
const bodyStart = headerEnd + 4;
const bodyEnd = part.lastIndexOf('\r\n');
if (bodyEnd <= bodyStart) continue;
fileData = Buffer.from(part.substring(bodyStart, bodyEnd), 'binary');
break;
}
if (!fileData) { res.status(400).json({ success: false, error: 'No file found in upload' }); return; }
if (fileData.length > 100 * 1024 * 1024) { res.status(400).json({ success: false, error: 'File too large (max 100MB)' }); return; }
const dir = caseFilesDir(session.username, APP_TYPE, projectId);
fs.mkdirSync(dir, { recursive: true });
const targetName = folder ? `${folder}/${filename}` : filename;
const { relPath, absPath } = resolveUploadPath(dir, targetName);
fs.writeFileSync(absPath, fileData);
res.json({
success: true,
name: path.basename(relPath),
relPath,
size: fileData.length,
type: filetype,
url: `/api/files/${APP_TYPE}/${projectId}/${relPath.split('/').map(encodeURIComponent).join('/')}`,
category: fileCategory(relPath),
});
} catch (e: any) {
// 'end' 콜백 안의 예외는 express가 못 잡아 프로세스가 죽는다 — 반드시 여기서 응답.
if (!res.headersSent) res.status(500).json({ success: false, error: String(e?.message || e) });
}
});
req.on('error', (err: any) => { res.status(500).json({ success: false, error: String(err?.message || err) }); });
});
+54 -2
View File
@@ -73,6 +73,55 @@ export function removeProject(data: WorkshopData, id: string): boolean {
return true;
}
// 프로젝트 PUT 본문 검증·정규화(순수함수). 필수 필드가 없으면 에러 문자열을, 통과하면
// 누락된 선택 필드(notes/description/tasks/links)를 채운 프로젝트를 돌려준다. 검증이
// 없으면 notes가 없는 본문이 저장돼 이후 채팅 도구의 project.notes.trim()이 죽는다.
export function normalizeProjectInput(incoming: any, id: string): { ok: true; project: WorkshopProject } | { ok: false; error: string } {
if (!incoming || typeof incoming !== 'object') return { ok: false, error: 'body required' };
if (incoming.id !== id) return { ok: false, error: 'body.id must match :id' };
if (!Array.isArray(incoming.parts) || !Array.isArray(incoming.phases) || !String(incoming.name || '').trim()) {
return { ok: false, error: 'name/parts/phases required' };
}
if (incoming.notes != null && typeof incoming.notes !== 'string') return { ok: false, error: 'notes must be string' };
if (incoming.description != null && typeof incoming.description !== 'string') return { ok: false, error: 'description must be string' };
for (const ph of incoming.phases) {
if (!ph || typeof ph !== 'object' || (ph.tasks != null && !Array.isArray(ph.tasks))) return { ok: false, error: 'phases[].tasks must be array' };
}
for (const pt of incoming.parts) {
if (!pt || typeof pt !== 'object' || (pt.links != null && !Array.isArray(pt.links))) return { ok: false, error: 'parts[].links must be array' };
}
// 항목 id는 클라이언트가 onclick="fn('<id>')"에 그대로 끼워 넣는다 — 안전한 문자만 허용하고
// 아니면 새 id로 교체한다(따옴표가 든 id로 인라인 핸들러에 코드가 주입되는 것 방지).
const safeId = (v: any, prefix: string) => (typeof v === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(v) ? v : genWorkshopId(prefix));
const project: WorkshopProject = {
...incoming,
id,
notes: incoming.notes ?? '',
description: incoming.description ?? '',
phases: incoming.phases.map((ph: any) => ({
...ph,
id: safeId(ph.id, 'ph'),
tasks: (ph.tasks ?? []).map((t: any) => ({ ...t, id: safeId(t?.id, 't') })),
})),
parts: incoming.parts.map((pt: any) => ({
...pt,
id: safeId(pt.id, 'p'),
links: (pt.links ?? []).map((l: any) => ({ ...l, id: safeId(l?.id, 'lk') })),
})),
};
return { ok: true, project };
}
// 이름 검색 공통 규칙: 정확히 같은 이름이 있으면 그것(대소문자 무시)을 우선하고, 없을 때만
// 부분일치. "모터"와 "모터드라이버"가 함께 있어도 "모터"가 정확히 지정된다.
export function matchByName<T>(items: T[], query: string, nameOf: (t: T) => string): { index: number }[] {
const q = query.trim().toLowerCase();
const all = items.map((t, index) => ({ index, name: nameOf(t).toLowerCase() }));
const exact = all.filter(x => x.name === q);
if (exact.length) return exact.map(x => ({ index: x.index }));
return all.filter(x => x.name.includes(q)).map(x => ({ index: x.index }));
}
export function workshopPath(workspaceRoot: string): string {
return path.join(workspaceRoot, '.smallclaw', 'workshop.json');
}
@@ -144,6 +193,9 @@ export function loadWorkshop(workspaceRoot: string): WorkshopData {
try {
return JSON.parse(fs.readFileSync(p, 'utf-8'));
} catch {
// 손상된 파일을 시드로 조용히 대체하면 다음 저장이 원본을 덮어써 영구 유실된다 —
// 먼저 .corrupt-<ts>로 옮겨 보존한다(config.json 3회 손상 사고와 같은 클래스).
try { fs.copyFileSync(p, `${p}.corrupt-${Date.now()}`); } catch { /* 백업 실패해도 서비스는 계속 */ }
return defaultWorkshopData();
}
}
@@ -213,11 +265,11 @@ export function projectToReadme(project: WorkshopProject): string {
lines.push('## 작업');
for (const ph of project.phases) {
lines.push(`### ${ph.name}`);
for (const t of ph.tasks) {
for (const t of ph.tasks || []) {
lines.push(`- [${t.done ? 'x' : ' '}] ${t.text}`);
}
}
if (project.notes.trim()) {
if (String(project.notes || '').trim()) {
lines.push('');
lines.push('## 메모');
lines.push(project.notes);
+43 -21
View File
@@ -3,7 +3,7 @@ import { getWorkspacePath } from '../config/paths.js';
import {
loadWorkshop, saveWorkshop, genWorkshopId,
WorkshopData, WorkshopProject, WorkshopPart, WorkshopPhase,
budgetSummary, projectToReadme, fmtWon,
budgetSummary, projectToReadme, fmtWon, matchByName,
} from '../gateway/routes/workshop-storage.js';
// "작업실"(여러 메이커 프로젝트 관리 대시보드) 전용 채팅도구. 원래 로봇 프로젝트 하나만
@@ -23,49 +23,51 @@ function ensureLinks(p: WorkshopPart): NonNullable<WorkshopPart['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));
const matches = matchByName(data.projects, query, pr => pr.name).map(m => ({ pr: data.projects[m.index], i: m.index }));
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 } {
// project_name이 주어지면 그 프로젝트를, 없으면 이 채팅 세션(ws_<projectId>)의 프로젝트를,
// 그것도 없으면 전역 활성 프로젝트를 대상으로 삼는다. 전역 활성 포인터만 쓰면 탭 두 개로
// 다른 프로젝트를 볼 때 한쪽 채팅이 다른 프로젝트를 수정하는 사고가 난다(09-24 검토).
export function sessionProjectId(sessionId: any): string {
const m = /^ws_(.+)$/.exec(String(sessionId || ''));
return m ? m[1] : '';
}
function resolveProject(data: WorkshopData, projectNameArg: any, sessionId?: 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 sid = sessionProjectId(sessionId);
const sessionProj = sid ? data.projects.find(p => p.id === sid) : undefined;
if (sessionProj) return { ok: true, project: sessionProj };
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));
const matches = matchByName(project.parts, query, p => p.name).map(m => ({ p: project.parts[m.index], i: m.index }));
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));
const matches = matchByName(project.phases, query, ph => ph.name).map(m => ({ ph: project.phases[m.index], i: m.index }));
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 });
});
});
const flat: { phaseIndex: number; taskIndex: number; text: string }[] = [];
project.phases.forEach((ph, pi) => ph.tasks.forEach((t, ti) => flat.push({ phaseIndex: pi, taskIndex: ti, text: t.text })));
const matches = matchByName(flat, query, f => f.text).map(m => flat[m.index]);
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 };
@@ -118,6 +120,13 @@ export const workshopProjectTool = {
if (action === 'create_project') {
const name = String(args?.project_name || '').trim();
if (!name) return { success: false, error: 'project_name이 필요합니다.' };
// 같은 이름이 둘이 되면 이후 project_name으로 어느 쪽도 지정할 수 없다(모델 재시도로 잘 생김).
const dup = data.projects.find(p => p.name.trim().toLowerCase() === name.toLowerCase());
if (dup) {
data.activeProjectId = dup.id;
saveWorkshop(workspaceRoot, data);
return { success: true, stdout: `이미 같은 이름의 프로젝트가 있어 새로 만들지 않고 그 프로젝트로 전환함: ${dup.name}` };
}
const project: WorkshopProject = { id: genWorkshopId('proj'), name, parts: [], phases: [], notes: '' };
data.projects.push(project);
data.activeProjectId = project.id;
@@ -133,7 +142,7 @@ export const workshopProjectTool = {
return { success: true, stdout: `활성 프로젝트 전환됨: ${data.projects[found.index].name}` };
}
const resolved = resolveProject(data, args?.project_name);
const resolved = resolveProject(data, args?.project_name, args?._sessionId);
if (!resolved.ok) return { success: false, error: resolved.error };
const project = resolved.project;
@@ -158,6 +167,10 @@ export const workshopProjectTool = {
if (action === 'add_part') {
const name = String(args?.name || '').trim();
if (!name) return { success: false, error: 'name이 필요합니다.' };
const existing = project.parts.find(p => p.name.trim().toLowerCase() === name.toLowerCase());
if (existing) {
return { success: true, stdout: `[${project.name}] 이미 같은 이름의 부품이 등록돼 있어 추가하지 않음: ${existing.name} (${existing.status}, ${existing.qty}개). 수량/가격/상태를 바꾸려면 update_part를 쓸 것.` };
}
const status = STATUSES.includes(args?.status) ? args.status : '검토중';
const part: WorkshopPart = {
id: genWorkshopId('p'),
@@ -177,7 +190,7 @@ export const workshopProjectTool = {
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?.name != null && String(args.name).trim()) p.name = String(args.name).trim();
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;
@@ -200,6 +213,9 @@ export const workshopProjectTool = {
const text = String(args?.task_text || '').trim();
if (!text) return { success: false, error: 'task_text가 필요합니다.' };
const phase: WorkshopPhase = project.phases[foundPhase.index];
if (phase.tasks.some(t => t.text.trim().toLowerCase() === text.toLowerCase())) {
return { success: true, stdout: `[${project.name}] "${phase.name}"에 같은 작업이 이미 있어 추가하지 않음: ${text}` };
}
phase.tasks.push({ id: genWorkshopId('t'), text, done: false });
saveWorkshop(workspaceRoot, data);
return { success: true, stdout: `[${project.name}] "${phase.name}"에 작업 추가됨: ${text}` };
@@ -218,6 +234,9 @@ export const workshopProjectTool = {
if (action === 'add_phase') {
const name = String(args?.new_phase_name || '').trim();
if (!name) return { success: false, error: 'new_phase_name이 필요합니다.' };
if (project.phases.some(ph => ph.name.trim().toLowerCase() === name.toLowerCase())) {
return { success: true, stdout: `[${project.name}] 같은 이름의 단계가 이미 있어 추가하지 않음: ${name}` };
}
project.phases.push({ id: genWorkshopId('ph'), name, tasks: [] });
saveWorkshop(workspaceRoot, data);
return { success: true, stdout: `[${project.name}] 단계 추가됨: ${name}` };
@@ -226,7 +245,11 @@ export const workshopProjectTool = {
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;
const curNotes = String(project.notes || '');
if (curNotes.split('\n').some(l => l.trim() === text)) {
return { success: true, stdout: `[${project.name}] 같은 메모 줄이 이미 있어 추가하지 않음: ${text}` };
}
project.notes = String(project.notes || '').trim() ? `${String(project.notes).trim()}\n${text}` : text;
saveWorkshop(workspaceRoot, data);
return { success: true, stdout: `[${project.name}] 메모 추가됨: ${text}` };
}
@@ -249,8 +272,7 @@ export const workshopProjectTool = {
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));
const matches = matchByName(links, String(args?.link_label || ''), l => l.label).map(m => links[m.index]);
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);
+209
View File
@@ -0,0 +1,209 @@
// 작업실 앱 전수 검토(2026-09-24)에서 발견·수정한 결함들의 회귀 테스트.
// 서버측(저장소/도구/업로드 파일명/가드 정규식) + 프런트엔드 저장 로직(vm 시뮬레이션).
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'fs';
import os from 'os';
import path from 'path';
import vm from 'vm';
import {
loadWorkshop, saveWorkshop, workshopPath, matchByName, normalizeProjectInput, WorkshopData,
} from '../src/gateway/routes/workshop-storage';
import { decodeUploadFilename } from '../src/gateway/routes/routes-workshop';
import { workshopProjectTool, sessionProjectId } from '../src/tools/workshop-project';
import { claimsWorkshopSaved, isWorkshopMutationRequest } from '../src/gateway/guards/prompt-gates';
function tmpWorkspace(data?: WorkshopData): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ws-test-'));
if (data) saveWorkshop(dir, data);
return dir;
}
const proj = (id: string, name: string, extra: any = {}) => ({ id, name, parts: [], phases: [], notes: '', ...extra });
const run = (ws: string, args: any) => workshopProjectTool.execute({ ...args, _workspacePath: ws });
describe('헛보고 가드 정규식 — 한글 종결형', () => {
test('반말/평서 종결도 클레임으로 잡는다(옛 \\b 버그)', () => {
for (const s of ['저장했습니다.', '저장했어요', '저장했어.', '저장했다.', '추가했다', '수정됐어', '메모에 저장했어'])
assert.equal(claimsWorkshopSaved(s), true, s);
});
test('질문형·수식형·미래형은 클레임이 아니다', () => {
for (const s of ['저장했너?', '저장했어?', '저장했던 메모', '저장했다는 얘기', '저장하겠습니다', '저장했다가 지웠다'])
assert.equal(claimsWorkshopSaved(s), false, s);
});
test('명사 없는 후속 확인 질문도 저장 요청 계열로 본다', () => {
assert.equal(isWorkshopMutationRequest('저장 했니?'), true);
assert.equal(isWorkshopMutationRequest('등록됐어?'), true);
assert.equal(isWorkshopMutationRequest('오늘 날씨 어때'), false);
});
});
describe('업로드 파일명 디코딩 — % 포함 이름이 프로세스를 죽이지 않는다', () => {
test('리터럴 %', () => {
assert.equal(decodeUploadFilename('100%.stl'), '100%.stl');
assert.equal(decodeUploadFilename('50%_infill.stl'), '50%_infill.stl');
});
test('RFC5987 퍼센트 인코딩과 평문', () => {
assert.equal(decodeUploadFilename('%ED%95%9C%EA%B8%80.stl'), '한글.stl');
assert.equal(decodeUploadFilename('a b.stl'), 'a b.stl');
});
});
describe('저장소', () => {
test('matchByName: 정확일치 우선, 없으면 부분일치', () => {
const items = ['모터', '모터드라이버', '서보'];
assert.deepEqual(matchByName(items, '모터', s => s), [{ index: 0 }]);
assert.deepEqual(matchByName(items, '드라이버', s => s), [{ index: 1 }]);
assert.deepEqual(matchByName(items, '모', s => s).length, 2);
assert.deepEqual(matchByName(items, '없음', s => s), []);
});
test('손상된 workshop.json은 시드로 대체하되 원본을 .corrupt로 보존', () => {
const ws = tmpWorkspace();
fs.mkdirSync(path.dirname(workshopPath(ws)), { recursive: true });
fs.writeFileSync(workshopPath(ws), '{"projects":[{"id":"x","name":"소중한 데이터"', 'utf-8');
const data = loadWorkshop(ws);
assert.equal(data.projects[0].id, 'robot');
const dir = path.dirname(workshopPath(ws));
const backups = fs.readdirSync(dir).filter(f => f.includes('.corrupt-'));
assert.equal(backups.length, 1);
assert.ok(fs.readFileSync(path.join(dir, backups[0]), 'utf-8').includes('소중한 데이터'));
});
test('normalizeProjectInput: 누락 필드 보정 + 잘못된 타입/id 거부·교체', () => {
const ok = normalizeProjectInput({ id: 'p', name: 'A', parts: [{ id: "x'); alert(1);//", name: 'n' }], phases: [{ id: 'ph1', name: 's' }] }, 'p');
assert.ok(ok.ok);
if (ok.ok) {
assert.equal(ok.project.notes, '');
assert.deepEqual(ok.project.phases[0].tasks, []);
assert.deepEqual(ok.project.parts[0].links, []);
assert.match(ok.project.parts[0].id, /^p_[a-z0-9]+$/); // 위험한 id는 교체
assert.equal(ok.project.phases[0].id, 'ph1');
}
assert.equal(normalizeProjectInput({ id: 'p', name: 'A', parts: [], phases: [], notes: 5 }, 'p').ok, false);
assert.equal(normalizeProjectInput({ id: 'p', name: 'A', parts: [], phases: [{ tasks: 'x' }] }, 'p').ok, false);
assert.equal(normalizeProjectInput({ id: 'other', name: 'A', parts: [], phases: [] }, 'p').ok, false);
});
});
describe('workshop_project 도구', () => {
test('정확한 이름이 있으면 부분일치 모호 오류 없이 그 부품을 수정', async () => {
const ws = tmpWorkspace({
activeProjectId: 'a',
projects: [proj('a', 'A', { parts: [
{ id: 'p1', name: '모터', qty: 1, unitPrice: 100, status: '검토중', memo: '', links: [] },
{ id: 'p2', name: '모터드라이버', qty: 1, unitPrice: 200, status: '검토중', memo: '', links: [] },
] })],
});
const r = await run(ws, { action: 'update_part', part_name: '모터', qty: 4 });
assert.equal(r.success, true, r.error);
const d = loadWorkshop(ws);
assert.equal(d.projects[0].parts[0].qty, 4);
assert.equal(d.projects[0].parts[1].qty, 1);
});
test('project_name 없으면 세션(ws_<id>)의 프로젝트가 대상 — 전역 활성 포인터 무시', async () => {
const ws = tmpWorkspace({ activeProjectId: 'a', projects: [proj('a', 'A'), proj('b', 'B')] });
assert.equal(sessionProjectId('ws_b'), 'b');
const r = await run(ws, { action: 'add_note', note_text: 'B에 쓰는 메모', _sessionId: 'ws_b' });
assert.equal(r.success, true);
const d = loadWorkshop(ws);
assert.equal(d.projects[1].notes, 'B에 쓰는 메모');
assert.equal(d.projects[0].notes, '');
// 세션 정보가 없으면 옛 동작(활성 프로젝트)
await run(ws, { action: 'add_note', note_text: 'A 메모' });
assert.equal(loadWorkshop(ws).projects[0].notes, 'A 메모');
});
test('create_project 같은 이름 중복 생성 안 함(전환만)', async () => {
const ws = tmpWorkspace({ activeProjectId: 'a', projects: [proj('a', '로봇팔'), proj('b', '기타')] });
const r = await run(ws, { action: 'create_project', project_name: '로봇팔' });
assert.equal(r.success, true);
const d = loadWorkshop(ws);
assert.equal(d.projects.length, 2);
assert.equal(d.activeProjectId, 'a');
});
test('add_part/add_note/add_task/add_phase 중복은 추가하지 않고 성공(가드 재시도가 중복을 못 만든다)', async () => {
const ws = tmpWorkspace({ activeProjectId: 'a', projects: [proj('a', 'A', { phases: [{ id: 'ph', name: '준비', tasks: [] }] })] });
for (let i = 0; i < 2; i++) {
assert.equal((await run(ws, { action: 'add_part', name: '608ZZ', qty: 4 })).success, true);
assert.equal((await run(ws, { action: 'add_note', note_text: '메모 한 줄' })).success, true);
assert.equal((await run(ws, { action: 'add_task', phase_name: '준비', task_text: '출력' })).success, true);
assert.equal((await run(ws, { action: 'add_phase', new_phase_name: '조립' })).success, true);
}
const p = loadWorkshop(ws).projects[0];
assert.equal(p.parts.length, 1);
assert.equal(p.notes, '메모 한 줄');
assert.equal(p.phases.length, 2);
assert.equal(p.phases[0].tasks.length, 1);
});
});
// ── 프런트엔드 저장 로직(workshop-app.html 상태/저장 구간을 vm으로 실행) ──────────────
describe('workshop-app.html 저장 로직', () => {
const html = fs.readFileSync(path.join(__dirname, '../web-ui/html/workshop-app.html'), 'utf-8').split('\n');
const start = html.findIndex(l => l.startsWith('function escAttr'));
const from = html.findIndex(l => l.startsWith('let workshopData='));
const to = html.findIndex(l => l.startsWith('// ── Tabs'));
const escHtmlLine = html.find(l => l.startsWith('function escHtml('))!;
function boot(server: any) {
const puts: any[] = [];
const el: any = { textContent: '', value: '', classList: { contains: () => false }, innerHTML: '' };
const ctx: any = {
fetch: async (url: string, opt: any = {}) => {
if (url === '/api/workshop/data') return { ok: true, json: async () => JSON.parse(JSON.stringify(server)) };
const m = url.match(/^\/api\/workshop\/project\/(.+)$/);
if (m && opt.method === 'PUT') {
const p = JSON.parse(opt.body); puts.push(p);
const i = server.projects.findIndex((x: any) => x.id === p.id);
if (i >= 0) server.projects[i] = p; else server.projects.push(p);
}
return { ok: true, json: async () => ({}) };
},
document: { getElementById: () => el }, window: { addEventListener() {} }, setTimeout, clearTimeout, JSON, console,
authH: () => ({}), renderParts() {}, renderPhases() {}, updateBudget() {}, refreshChatForProject() {}, clearStlSelection() {},
loadFiles() {}, expandedParts: new Set(), stlSelection: new Set(),
};
vm.createContext(ctx);
vm.runInContext([escHtmlLine, ...html.slice(start, start + 1), ...html.slice(from, to),
'this.api={loadWorkshopData,softRefreshWorkshop,setNotes:(v)=>{notes=v;scheduleSave();},escAttr};'].join('\n'), ctx);
return { api: ctx.api, puts };
}
const mkServer = () => ({ projects: [proj('p1', 'A')], activeProjectId: 'p1' });
test('대시보드 편집 후 채팅 도구가 서버에 쓴 내용이 새로고침에 덮이지 않는다(핵심 회귀)', async () => {
const server: any = mkServer();
const { api } = boot(server);
await api.loadWorkshopData();
api.setNotes('사용자 메모');
await new Promise(r => setTimeout(r, 900)); // debounce 발동 → 저장
server.projects[0].notes += '\n[채팅 도구가 추가한 줄]';
await api.softRefreshWorkshop();
assert.equal(server.projects[0].notes, '사용자 메모\n[채팅 도구가 추가한 줄]');
});
test('바뀐 게 없으면 PUT하지 않는다', async () => {
const server: any = mkServer();
const { api, puts } = boot(server);
await api.loadWorkshopData();
await api.softRefreshWorkshop();
assert.equal(puts.length, 0);
});
test('저장 대기 중인 편집은 새로고침 전에 먼저 flush된다', async () => {
const server: any = mkServer();
const { api } = boot(server);
await api.loadWorkshopData();
api.setNotes('쓰는 중'); // 600ms 타이머 대기 상태
await api.softRefreshWorkshop();
assert.equal(server.projects[0].notes, '쓰는 중');
});
test('escAttr는 텍스트 노드용 < > & 도 이스케이프(XSS)', () => {
const { api } = boot(mkServer());
assert.equal(api.escAttr('<img src=x onerror=alert(1)>'), '&lt;img src=x onerror=alert(1)&gt;');
assert.equal(api.escAttr('A&B "q"'), 'A&amp;B &quot;q&quot;');
assert.equal(api.escAttr(0), '0');
});
});
+59 -10
View File
@@ -332,7 +332,9 @@ async function checkAuth(){
// ── Theme ─────────────────────────────────────────────────────────────────────
function toggleTheme(){const d=document.documentElement;const n=d.getAttribute('data-theme')==='dark'?'light':'dark';d.setAttribute('data-theme',n);try{localStorage.setItem('homeclaw_theme',n);}catch{}const tb=document.getElementById('theme-btn');if(tb)tb.textContent=n==='light'?'☀️':'🌙';}
function escAttr(s){return String(s||'').replace(/'/g,'&#39;').replace(/"/g,'&quot;');}
// 속성값·텍스트 노드 양쪽에 쓰인다 — 예전엔 따옴표만 이스케이프해서 텍스트 노드에 들어간
// 부품/프로젝트/파일 이름의 <img onerror=...> 가 그대로 실행됐다(09-24 검토). & < > 도 처리.
function escAttr(s){return escHtml(s);}
// onclick="fn('...')" 안의 JS 문자열 컨텍스트용 — escAttr의 HTML 엔티티(&#39;)는
// HTML 파서가 속성값을 JS로 넘기기 전에 다시 ' 로 디코딩해버려 따옴표 주입을 못 막으므로
// JS 수준에서 이스케이프한다. 폴더/파일 이름(사용자 입력)을 싣는 데만 쓴다.
@@ -356,6 +358,25 @@ let saveDebounce=null;
// 동시에 저장해도 한쪽 편집이 통째로 사라지는 일이 없다. 어디가 더러운지는
// dirtyProjects로 추적하고 saveProject()가 그 대상만 순차 전송한다.
const dirtyProjects=new Set();
// 서버와 마지막으로 동기화된 프로젝트 JSON(id→문자열). 실제로 바뀐 프로젝트만 dirty로 잡기
// 위한 기준 — 이게 없으면 saveProject가 매번 현재 프로젝트를 무조건 PUT해서, 화면이 옛
// 상태일 때 채팅 도구가 서버에 쓴 내용을 덮어썼다(09-24 검토에서 재현).
const savedJson=new Map();
function normalizeProjects(list){
for(const p of list){
p.notes=typeof p.notes==='string'?p.notes:'';
p.description=typeof p.description==='string'?p.description:'';
if(!Array.isArray(p.parts))p.parts=[];
if(!Array.isArray(p.phases))p.phases=[];
for(const ph of p.phases)if(!Array.isArray(ph.tasks))ph.tasks=[];
for(const pt of p.parts)if(!Array.isArray(pt.links))pt.links=[];
}
return list;
}
function snapshotProjects(){
savedJson.clear();
for(const p of workshopData.projects)savedJson.set(p.id,JSON.stringify(p));
}
function currentProject(){
return workshopData.projects.find(p=>p.id===workshopData.activeProjectId)||workshopData.projects[0];
@@ -388,7 +409,7 @@ function syncLocalToProject(){
proj.phases=phases;
proj.notes=notes;
proj.description=overviewDesc;
dirtyProjects.add(proj.id);
if(savedJson.get(proj.id)!==JSON.stringify(proj))dirtyProjects.add(proj.id);
}
function renderProjectSelector(){
@@ -445,7 +466,7 @@ function deleteProjectPrompt(){
const proj=currentProject();
if(!proj)return;
if(workshopData.projects.length<=1){alert('마지막 남은 프로젝트는 삭제할 수 없습니다.');return;}
if(!confirm(`"${proj.name}" 프로젝트를 삭제할까요? 되돌릴 수 없습니다.`))return;
if(!confirm(`"${proj.name}" 프로젝트를 삭제할까요? 부품/작업/메모와 첨부파일, 이 프로젝트의 채팅 기록이 모두 지워지며 되돌릴 수 없습니다.`))return;
dirtyProjects.delete(proj.id);
workshopData.projects=workshopData.projects.filter(p=>p.id!==proj.id);
workshopData.activeProjectId=workshopData.projects[0].id;
@@ -466,7 +487,7 @@ async function loadWorkshopData(){
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.projects=normalizeProjects(Array.isArray(d.projects)?d.projects:[]);
workshopData.activeProjectId=d.activeProjectId||(workshopData.projects[0]?.id||'');
}
}catch{}
@@ -474,7 +495,9 @@ async function loadWorkshopData(){
const id=genId('proj');
workshopData.projects=[{id,name:'새 프로젝트',parts:[],phases:[],notes:'',description:''}];
workshopData.activeProjectId=id;
dirtyProjects.add(id); // 서버에 아직 없는 자리표시 프로젝트 — 첫 저장 때 서버에도 만든다
}
snapshotProjects();
loadLocalFromProject();
refreshAllPanes();
}
@@ -483,6 +506,9 @@ function scheduleSave(){
clearTimeout(saveDebounce);
saveDebounce=setTimeout(saveProject,600);
}
// 타이머가 발동하면 saveDebounce를 반드시 비운다 — 옛 timeout id가 남아 있으면 "저장 대기
// 중"으로 오판해서, 채팅 응답 후 softRefreshWorkshop이 서버 값을 받기 전에 화면의 옛 상태를
// 먼저 PUT해 채팅이 저장한 내용을 지웠다.
// 탭 닫기/새로고침 시 대기 중인 저장을 즉시 flush — 600ms debounce가 끝나기 전에
// 닫아버리면 마지막 편집이 그대로 사라진다. unload 중엔 일반 fetch가 버려질 수 있어
@@ -514,12 +540,13 @@ async function softRefreshWorkshop(){
const r=await fetch('/api/workshop/data',{headers:authH()});
if(!r.ok)return;
const d=await r.json();
const newProjects=Array.isArray(d.projects)?d.projects:[];
const newProjects=normalizeProjects(Array.isArray(d.projects)?d.projects:[]);
const newActive=d.activeProjectId||(newProjects[0]?.id||'');
if(JSON.stringify(newProjects)===JSON.stringify(workshopData.projects)&&newActive===workshopData.activeProjectId)return;
const activeChanged=newActive!==workshopData.activeProjectId;
workshopData.projects=newProjects;
workshopData.activeProjectId=newActive;
snapshotProjects();
loadLocalFromProject();
const liveIds=new Set(parts.map(p=>p.id));
for(const id of [...expandedParts])if(!liveIds.has(id))expandedParts.delete(id);
@@ -544,6 +571,7 @@ async function softRefreshWorkshop(){
}
async function saveProject(){
saveDebounce=null;
syncLocalToProject();
const ids=[...dirtyProjects];
dirtyProjects.clear();
@@ -553,11 +581,17 @@ async function saveProject(){
for(const id of ids){
const p=workshopData.projects.find(x=>x.id===id);
if(!p)continue; // 이번 턴에 삭제된 프로젝트 — deleteProjectPrompt가 서버 DELETE로 처리
const r=await fetch('/api/workshop/project/'+encodeURIComponent(id),{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify(p)});
const body=JSON.stringify(p);
const r=await fetch('/api/workshop/project/'+encodeURIComponent(id),{method:'PUT',headers:authH({'Content-Type':'application/json'}),body});
if(!r.ok)throw new Error('HTTP '+r.status);
savedJson.set(id,body);
}
showSaveIndicator('저장됨');
}catch{showSaveIndicator('저장 실패');}
}catch{
// 실패한 프로젝트는 dirty로 되돌려 다음 저장에서 재시도한다(안 그러면 조용히 유실).
for(const id of ids)if(savedJson.get(id)!==JSON.stringify(workshopData.projects.find(x=>x.id===id)))dirtyProjects.add(id);
showSaveIndicator('저장 실패');
}
}
// 활성 프로젝트 포인터는 값 하나라 별도 즉시 저장(debounce 없음) — switch/create/delete에서 호출.
@@ -629,7 +663,7 @@ function startEquipTimers(){
if(equipTimer)return;
refreshK2Widget();
refreshMillWidget();
equipTimer=setInterval(()=>{refreshK2Widget();refreshMillWidget();},20000);
equipTimer=setInterval(()=>{if(document.hidden)return;refreshK2Widget();refreshMillWidget();},20000);
}
function stopEquipTimers(){
clearInterval(equipTimer);
@@ -638,6 +672,11 @@ function stopEquipTimers(){
stopMillLive();
}
// 백그라운드 탭에선 폴링을 멈췄다가 다시 보이면 즉시 한 번 갱신한다.
document.addEventListener('visibilitychange',()=>{
if(!document.hidden&&equipTimer){refreshK2Widget();refreshMillWidget();}
});
function refreshMillWidget(){
if(millLive)return; // 라이브 중엔 스냅샷 폴링 건너뜀 — 실시간 비디오가 이미 떠있고 간섭·낭비 방지
const img=document.getElementById('mill-frame');
@@ -947,7 +986,8 @@ function updateLink(partId,linkId,field,value){
a.className='rb-link-open'; a.target='_blank'; a.rel='noopener'; a.textContent='↗';
row.querySelector('.rb-del-btn').before(a);
}
a.href=value;
const su=safeUrl(value);
if(su)a.href=su;else a.removeAttribute('href'); // javascript: 등은 링크로 만들지 않는다
}else if(a){
a.remove();
}
@@ -1396,9 +1436,12 @@ function chatGreeting(){
// 프로젝트 전환(수동/생성/삭제) 시 채팅창을 그 프로젝트의 세션 기록으로 리셋한다 —
// 세션ID가 프로젝트별로 분리돼 있으므로 여기서 지우고 다시 당겨오는 것으로 충분하다.
let _chatRefreshSeq=0;
async function refreshChatForProject(){
const seq=++_chatRefreshSeq;
document.getElementById('chat-msgs').innerHTML='';
await loadHistory();
if(seq!==_chatRefreshSeq)return; // 그 사이 다른 프로젝트로 또 전환됨 — 인사말 중복 방지
addMsg('system',chatGreeting());
}
@@ -1411,9 +1454,15 @@ async function sendMessage(){
await callChat(msg);
}
function setProjectControlsBusy(busy){
for(const el of document.querySelectorAll('#project-select,.rb-hdr .hdr-btn:not(#theme-btn)'))el.disabled=busy;
}
async function callChat(message){
const btn=document.getElementById('send-btn');
btn.disabled=true;btn.textContent='...';
// 응답을 받는 동안 프로젝트를 바꾸면 채팅창이 지워져 진행 중이던 답변이 안 보인다 — 잠근다.
setProjectControlsBusy(true);
const msgs=document.getElementById('chat-msgs');
const typingDiv=document.createElement('div');typingDiv.className='rb-msg assistant';
typingDiv.innerHTML='<div class="rb-typing"><div class="rb-dot"></div><div class="rb-dot"></div><div class="rb-dot"></div></div>';
@@ -1448,7 +1497,7 @@ async function callChat(message){
}catch(e){
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='전송';}
}finally{btn.disabled=false;btn.textContent='전송';setProjectControlsBusy(false);}
// 채팅으로 데이터가 바뀌었을 수 있으니(workshop_project 도구 호출) 대시보드를 새로고침한다.
// 단, 데이터가 안 바뀌었으면(도구가 get 처럼 읽기만 한 경우) 화면을 건드리지 않는다 —
// 펼쳐둔 부품 카드가 접히거나 편집 중이던 input의 포커스가 날아가는 사고를 막기 위함.