feat: 작업실 개요를 파일 폴더 대신 개요 탭으로 + /api/files 텍스트 charset 수정
- workshop-app: 메모 탭과 동일 구조의 "개요" 탭(textarea) 추가 — project.description 직접 편집, 채팅 set_description으로 쓴 내용은 응답 후 softRefresh로 갱신 - readme.md 자동생성(syncProjectReadmes) 철회 — 파일탭이 산만해져서 사용자 지정으로 탭 방식으로 전환, 생성된 개요 폴더들도 삭제. description/set_description은 유지 - server.ts IMAGE_TYPES: 텍스트 계열(.md/.txt/.csv 등) charset=utf-8 명시 — charset 없이 text/plain으로 뱉으면 브라우저가 latin1로 해석해 한글이 전부 깨짐(실측) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1162,9 +1162,9 @@ export function createBuildTools(isOrchestrationSkillEnabled: () => boolean) {
|
||||
parameters: {
|
||||
type: 'object', required: ['action'],
|
||||
properties: {
|
||||
action: { type: 'string', enum: ['list_projects', 'create_project', 'set_active_project', 'get', 'set_description', 'add_part', 'update_part', 'delete_part', 'add_task', 'set_task_done', 'add_phase', 'add_note', 'add_link', 'delete_link'], description: 'list_projects: 전체 프로젝트 목록. create_project: 새 프로젝트 생성(+활성화). set_active_project: 대시보드에서 볼 프로젝트 전환. get: 프로젝트 조회(개요+예산 요약+부품+작업+메모). set_description: 프로젝트 서술형 개요 설정(파일탭의 개요/readme.md에 반영). add_part/update_part/delete_part: 부품 추가/수정/삭제. add_task: 단계에 작업 추가. set_task_done: 작업 완료/미완료. add_phase: 새 단계 추가. add_note: 메모 한 줄 추가. add_link/delete_link: 부품에 참고링크(구매처/대체품/업그레이드/레포 등) 추가/삭제.' },
|
||||
action: { type: 'string', enum: ['list_projects', 'create_project', 'set_active_project', 'get', 'set_description', 'add_part', 'update_part', 'delete_part', 'add_task', 'set_task_done', 'add_phase', 'add_note', 'add_link', 'delete_link'], description: 'list_projects: 전체 프로젝트 목록. create_project: 새 프로젝트 생성(+활성화). set_active_project: 대시보드에서 볼 프로젝트 전환. get: 프로젝트 조회(개요+예산 요약+부품+작업+메모). set_description: 프로젝트 서술형 개요 설정(대시보드 개요 탭에 반영). add_part/update_part/delete_part: 부품 추가/수정/삭제. add_task: 단계에 작업 추가. set_task_done: 작업 완료/미완료. add_phase: 새 단계 추가. add_note: 메모 한 줄 추가. add_link/delete_link: 부품에 참고링크(구매처/대체품/업그레이드/레포 등) 추가/삭제.' },
|
||||
project_name: { type: 'string', description: '(create_project/set_active_project 필수, 그 외 모든 액션 선택) 대상 프로젝트 이름(일부만 일치해도 됨). 생략하면 현재 활성 프로젝트를 대상으로 함.' },
|
||||
description: { type: 'string', description: '(set_description 필수) 프로젝트 개요 문단 — 무엇을 만드는지/목표/구성 등을 자유 서술. 파일탭의 workshop/<프로젝트id>/개요/readme.md 맨 위 "## 개요" 섹션으로 들어간다.' },
|
||||
description: { type: 'string', description: '(set_description 필수) 프로젝트 개요 문단 — 무엇을 만드는지/목표/구성 등을 자유 서술. 대시보드의 "개요" 탭에 표시된다.' },
|
||||
part_name: { type: 'string', description: '(update_part/delete_part/add_link/delete_link 필수) 대상 부품 이름(일부만 일치해도 됨, 예: "메카넘휠")' },
|
||||
name: { type: 'string', description: '(add_part 필수, update_part 선택) 부품 이름' },
|
||||
qty: { type: 'number', description: '(add_part/update_part 선택) 수량. 기본 1' },
|
||||
|
||||
@@ -14,7 +14,7 @@ import express from 'express';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { getUserWorkspace } from '../../config/config';
|
||||
import { loadWorkshop, saveWorkshop, syncProjectReadmes } from './workshop-storage';
|
||||
import { loadWorkshop, saveWorkshop } from './workshop-storage';
|
||||
import { caseFilesDir, resolveUploadPath, sanitizePathSegment, walkCaseFiles, fileCategory } from './case-storage';
|
||||
|
||||
const APP_TYPE = 'workshop';
|
||||
@@ -40,13 +40,7 @@ export function registerWorkshopRoutes(
|
||||
app.get('/api/workshop/data', (req, res) => {
|
||||
const session = getSessionUser(req);
|
||||
if (!session) return res.status(401).json({ error: 'Unauthorized' });
|
||||
const ws = getUserWorkspace(session.username);
|
||||
const data = loadWorkshop(ws);
|
||||
// 개요/readme.md 자동생성(09-23) — 편집 없이도 대시보드를 열면 기존 프로젝트의
|
||||
// readme가 채워지게(saveWorkshop 경로만으론 미수정 프로젝트가 밀림). 내용이 같으면
|
||||
// 쓰지 않으므로 폴링 비용은 파일 stat 몇 개다.
|
||||
syncProjectReadmes(ws, data);
|
||||
res.json(data);
|
||||
res.json(loadWorkshop(getUserWorkspace(session.username)));
|
||||
});
|
||||
|
||||
app.put('/api/workshop/data', (req, res) => {
|
||||
|
||||
@@ -151,13 +151,12 @@ export function saveWorkshop(workspaceRoot: string, data: WorkshopData): void {
|
||||
const tmp = `${p}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8');
|
||||
fs.renameSync(tmp, p);
|
||||
syncProjectReadmes(workspaceRoot, data);
|
||||
}
|
||||
|
||||
// ── 프로젝트 readme(2026-09-23) ──────────────────────────────────────────────
|
||||
// 대시보드 데이터를 그대로 마크다운으로 내보낸다 — workshop-project.ts의 formatProject를
|
||||
// 여기로 옮긴 것(순환 import 방지: 도구가 storage를 import하므로 반대 방향은 안 됨).
|
||||
// 도구의 'get' 액션과 개요/readme.md 생성이 같은 문서를 쓴다.
|
||||
// ── 프로젝트 개요 문서(2026-09-23) ──────────────────────────────────────────
|
||||
// 대시보드 데이터를 그대로 마크다운으로 내보낸다 — 도구의 'get' 액션이 쓴다.
|
||||
// 처음엔 workshop/<id>/개요/readme.md 파일로 뽑았으나 파일탭이 산만해져서(사용자
|
||||
// 지정) 폴더 생성은 철회하고 대시보드 "개요" 탭(textarea=description 편집)으로 이동.
|
||||
export function fmtWon(n: number): string {
|
||||
return `${Math.round(n).toLocaleString('ko-KR')}원`;
|
||||
}
|
||||
@@ -200,34 +199,9 @@ export function projectToReadme(project: WorkshopProject): string {
|
||||
lines.push('## 메모');
|
||||
lines.push(project.notes);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('---');
|
||||
lines.push('*이 파일은 작업실 대시보드 데이터에서 자동 생성됨. 부품·작업·메모를 바꾸면 갱신되니 이 파일을 직접 고치지 말 것.*');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// 프로젝트당 workshop/<프로젝트id>/개요/readme.md 를 유지한다 — 파일탭에서 프로젝트
|
||||
// 설명 문서가 항상 존재하게. 내용이 바뀌었을 때만 쓴다(전수 saveWorkshop 경로라 값싸게).
|
||||
// 개별 프로젝트 실패(권한 등)는 나머지에 영향을 주지 않게 전부 삼킨다.
|
||||
export function syncProjectReadmes(workspaceRoot: string, data: WorkshopData): void {
|
||||
for (const project of data.projects) {
|
||||
try {
|
||||
const dir = path.join(workspaceRoot, 'workshop', project.id, '개요');
|
||||
const file = path.join(dir, 'readme.md');
|
||||
const md = projectToReadme(project);
|
||||
try {
|
||||
if (fs.readFileSync(file, 'utf-8') === md) continue;
|
||||
} catch {
|
||||
// 파일이 없으면 새로 쓴다
|
||||
}
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(file, md, 'utf-8');
|
||||
} catch {
|
||||
// 파생 문서 생성 실패는 원본 데이터 저장을 막아선 안 된다
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function genWorkshopId(prefix: string): string {
|
||||
return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
@@ -487,8 +487,10 @@ const IMAGE_TYPES: Record<string, string> = {
|
||||
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
|
||||
'.bmp': 'image/bmp', '.ico': 'image/x-icon',
|
||||
'.pdf': 'application/pdf', '.txt': 'text/plain', '.json': 'application/json',
|
||||
'.csv': 'text/csv', '.html': 'text/html', '.md': 'text/plain',
|
||||
// 텍스트 계열은 반드시 charset=utf-8 — 2026-09-23 실측: charset 없이 text/plain으로
|
||||
// 보내면 브라우저가 latin1로 해석해 작업실 개요/readme.md의 한글이 전부 깨져 보였음.
|
||||
'.pdf': 'application/pdf', '.txt': 'text/plain; charset=utf-8', '.json': 'application/json; charset=utf-8',
|
||||
'.csv': 'text/csv; charset=utf-8', '.html': 'text/html; charset=utf-8', '.md': 'text/plain; charset=utf-8',
|
||||
};
|
||||
|
||||
// Code-editor sessions whose files live in the browser's local folder (File System
|
||||
|
||||
@@ -82,10 +82,10 @@ export const workshopProjectTool = {
|
||||
action: {
|
||||
type: 'string',
|
||||
enum: ['list_projects', 'create_project', 'set_active_project', 'get', 'set_description', 'add_part', 'update_part', 'delete_part', 'add_task', 'set_task_done', 'add_phase', 'add_note', 'add_link', 'delete_link'],
|
||||
description: 'list_projects: 전체 프로젝트 목록. create_project: 새 프로젝트 생성(+활성화). set_active_project: 대시보드에서 볼 프로젝트 전환. get: 프로젝트 조회(개요+예산 요약+부품+작업+메모). set_description: 프로젝트 서술형 개요 설정(개요/readme.md에 반영). add_part/update_part/delete_part: 부품 추가/수정/삭제. add_task: 단계에 작업 추가. set_task_done: 작업 완료/미완료. add_phase: 새 단계 추가. add_note: 메모 한 줄 추가. add_link/delete_link: 부품에 참고링크(구매처/대체품/업그레이드/레포 등) 추가/삭제.',
|
||||
description: 'list_projects: 전체 프로젝트 목록. create_project: 새 프로젝트 생성(+활성화). set_active_project: 대시보드에서 볼 프로젝트 전환. get: 프로젝트 조회(개요+예산 요약+부품+작업+메모). set_description: 프로젝트 서술형 개요 설정(대시보드 개요 탭에 반영). add_part/update_part/delete_part: 부품 추가/수정/삭제. add_task: 단계에 작업 추가. set_task_done: 작업 완료/미완료. add_phase: 새 단계 추가. add_note: 메모 한 줄 추가. add_link/delete_link: 부품에 참고링크(구매처/대체품/업그레이드/레포 등) 추가/삭제.',
|
||||
},
|
||||
project_name: { type: 'string', description: '(create_project/set_active_project 필수, 그 외 모든 액션 선택) 대상 프로젝트 이름(일부만 일치해도 됨). 생략하면 현재 활성 프로젝트를 대상으로 함.' },
|
||||
description: { type: 'string', description: '(set_description 필수) 프로젝트 개요 문단 — 무엇을 만드는지/목표/구성 등을 자유 서술. 파일탭의 workshop/<프로젝트id>/개요/readme.md 맨 위 "## 개요" 섹션으로 들어간다.' },
|
||||
description: { type: 'string', description: '(set_description 필수) 프로젝트 개요 문단 — 무엇을 만드는지/목표/구성 등을 자유 서술. 대시보드의 "개요" 탭에 표시된다.' },
|
||||
part_name: { type: 'string', description: '(update_part/delete_part/add_link/delete_link 필수) 대상 부품 이름(일부만 일치해도 됨, 예: "메카넘휠")' },
|
||||
name: { type: 'string', description: '(add_part 필수, update_part 선택) 부품 이름' },
|
||||
qty: { type: 'number', description: '(add_part/update_part 선택) 수량. 기본 1' },
|
||||
@@ -145,7 +145,7 @@ export const workshopProjectTool = {
|
||||
const desc = String(args?.description || '').trim();
|
||||
project.description = desc;
|
||||
saveWorkshop(workspaceRoot, data);
|
||||
return { success: true, stdout: `[${project.name}] 개요 ${desc ? '업데이트됨' : '비움'} — 파일탭의 개요/readme.md에 반영됨` };
|
||||
return { success: true, stdout: `[${project.name}] 개요 ${desc ? '업데이트됨' : '비움'} — 대시보드 개요 탭에 반영됨` };
|
||||
}
|
||||
|
||||
if (action === 'add_part') {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// workshop-storage.ts 프로젝트 개요 문서(projectToReadme) 단위 테스트 (2026-09-23)
|
||||
// 대시보드 데이터 → 마크다운 'get' 출력. readme 파일 생성은 철회됨(개요 탭으로 이동).
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { projectToReadme, WorkshopProject } from '../src/gateway/routes/workshop-storage.js';
|
||||
|
||||
function sampleProject(over: Partial<WorkshopProject> = {}): WorkshopProject {
|
||||
return {
|
||||
id: 'proj_test',
|
||||
name: 'SO-101 로봇팔',
|
||||
parts: [
|
||||
{ id: 'p1', name: 'STS3215 서보', qty: 12, unitPrice: 20000, status: '검토중', memo: 'Leader 6 + Follower 6', links: [{ id: 'lk1', label: '구매처', url: 'https://example.com' }] },
|
||||
{ id: 'p2', name: '나사 세트', qty: 1, unitPrice: 0, status: '보유', memo: '', links: [] },
|
||||
],
|
||||
phases: [
|
||||
{ id: 'ph1', name: '준비', tasks: [{ id: 't1', text: 'STL 다운로드', done: true }, { id: 't2', text: '출력', done: false }] },
|
||||
],
|
||||
notes: 'K2로 출력한다',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
test('projectToReadme: 개요 섹션은 description이 있을 때만', () => {
|
||||
const withDesc = projectToReadme(sampleProject({ description: 'LeRobot SO-101 오픈소스 로봇팔 조립 프로젝트' }));
|
||||
assert.ok(withDesc.includes('# SO-101 로봇팔'));
|
||||
assert.ok(withDesc.includes('## 개요\nLeRobot SO-101 오픈소스 로봇팔 조립 프로젝트'));
|
||||
|
||||
const withoutDesc = projectToReadme(sampleProject());
|
||||
assert.ok(!withoutDesc.includes('## 개요'));
|
||||
// 부품/작업/메모는 항상 실린다
|
||||
assert.ok(withoutDesc.includes('- [검토중] STS3215 서보 × 12 = 240,000원'));
|
||||
assert.ok(withoutDesc.includes('- [x] STL 다운로드'));
|
||||
assert.ok(withoutDesc.includes('## 메모'));
|
||||
});
|
||||
|
||||
test('projectToReadme: 부품 링크를 들여쓰기로 실음', () => {
|
||||
const md = projectToReadme(sampleProject());
|
||||
assert.ok(md.includes(' - 구매처: https://example.com'));
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
// workshop-storage.ts 개요/readme.md 자동생성 단위 테스트 (2026-09-23)
|
||||
// 네트워크/게이트웨이 없음 — 순수 문서 생성 + 임시 디렉토리 파일 동기화만 검증.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { projectToReadme, syncProjectReadmes, WorkshopData, WorkshopProject } from '../src/gateway/routes/workshop-storage.js';
|
||||
|
||||
function sampleProject(over: Partial<WorkshopProject> = {}): WorkshopProject {
|
||||
return {
|
||||
id: 'proj_test',
|
||||
name: 'SO-101 로봇팔',
|
||||
parts: [
|
||||
{ id: 'p1', name: 'STS3215 서보', qty: 12, unitPrice: 20000, status: '검토중', memo: 'Leader 6 + Follower 6', links: [{ id: 'lk1', label: '구매처', url: 'https://example.com' }] },
|
||||
{ id: 'p2', name: '나사 세트', qty: 1, unitPrice: 0, status: '보유', memo: '', links: [] },
|
||||
],
|
||||
phases: [
|
||||
{ id: 'ph1', name: '준비', tasks: [{ id: 't1', text: 'STL 다운로드', done: true }, { id: 't2', text: '출력', done: false }] },
|
||||
],
|
||||
notes: 'K2로 출력한다',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
test('projectToReadme: 개요 섹션은 description이 있을 때만', () => {
|
||||
const withDesc = projectToReadme(sampleProject({ description: 'LeRobot SO-101 오픈소스 로봇팔 조립 프로젝트' }));
|
||||
assert.ok(withDesc.includes('# SO-101 로봇팔'));
|
||||
assert.ok(withDesc.includes('## 개요\nLeRobot SO-101 오픈소스 로봇팔 조립 프로젝트'));
|
||||
|
||||
const withoutDesc = projectToReadme(sampleProject());
|
||||
assert.ok(!withoutDesc.includes('## 개요'));
|
||||
// 부품/작업/메모는 항상 실린다
|
||||
assert.ok(withoutDesc.includes('- [검토중] STS3215 서보 × 12 = 240,000원'));
|
||||
assert.ok(withoutDesc.includes('- [x] STL 다운로드'));
|
||||
assert.ok(withoutDesc.includes('## 메모'));
|
||||
assert.ok(withoutDesc.includes('자동 생성됨')); // 직접수정 방지 각주
|
||||
});
|
||||
|
||||
test('projectToReadme: 부품 링크를 들여쓰기로 실음', () => {
|
||||
const md = projectToReadme(sampleProject());
|
||||
assert.ok(md.includes(' - 구매처: https://example.com'));
|
||||
});
|
||||
|
||||
test('syncProjectReadmes: readme 생성 + 내용 변화 때만 재기록', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'workshop-readme-'));
|
||||
try {
|
||||
const data: WorkshopData = { projects: [sampleProject()], activeProjectId: 'proj_test' };
|
||||
syncProjectReadmes(tmp, data);
|
||||
const file = path.join(tmp, 'workshop', 'proj_test', '개요', 'readme.md');
|
||||
assert.ok(fs.existsSync(file));
|
||||
const first = fs.readFileSync(file, 'utf-8');
|
||||
|
||||
// 같은 내용이면 mtime 변화 없음(다시 쓰지 않음)
|
||||
const mtime1 = fs.statSync(file).mtimeMs;
|
||||
syncProjectReadmes(tmp, data);
|
||||
assert.equal(fs.statSync(file).mtimeMs, mtime1);
|
||||
|
||||
// 데이터를 바꾸면 갱신
|
||||
data.projects[0].description = '개요 문단';
|
||||
syncProjectReadmes(tmp, data);
|
||||
assert.ok(fs.readFileSync(file, 'utf-8').includes('## 개요\n개요 문단'));
|
||||
assert.notEqual(fs.statSync(file).mtimeMs, mtime1);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('syncProjectReadmes: 여러 프로젝트 동시 갱신, 개별 실패가 전체를 막지 않음', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'workshop-readme-'));
|
||||
try {
|
||||
const data: WorkshopData = {
|
||||
projects: [sampleProject(), sampleProject({ id: 'proj_second', name: '치과 스캐너' })],
|
||||
activeProjectId: 'proj_test',
|
||||
};
|
||||
// 두 번째 프로젝트 경로를 파일로 점거해 쓰기 실패 상황을 만든다 — 첫 프로젝트는 여전히 쓰여야 함
|
||||
const blocked = path.join(tmp, 'workshop', 'proj_second');
|
||||
fs.mkdirSync(blocked, { recursive: true });
|
||||
fs.writeFileSync(path.join(blocked, '개요'), 'blocker');
|
||||
syncProjectReadmes(tmp, data);
|
||||
assert.ok(fs.existsSync(path.join(tmp, 'workshop', 'proj_test', '개요', 'readme.md')));
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -218,6 +218,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
|
||||
<button class="rb-tab-btn" data-tab="tasks" onclick="switchTab('tasks')">작업</button>
|
||||
<button class="rb-tab-btn" data-tab="equip" onclick="switchTab('equip')">장비</button>
|
||||
<button class="rb-tab-btn" data-tab="files" onclick="switchTab('files')">파일</button>
|
||||
<button class="rb-tab-btn" data-tab="overview" onclick="switchTab('overview')">개요</button>
|
||||
<button class="rb-tab-btn" data-tab="notes" onclick="switchTab('notes')">메모</button>
|
||||
</div>
|
||||
|
||||
@@ -266,6 +267,10 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
|
||||
<div id="files-list-container"></div>
|
||||
</div>
|
||||
|
||||
<div class="rb-pane" id="pane-overview">
|
||||
<textarea class="rb-notes" id="overview-textarea" placeholder="프로젝트 개요 — 무엇을 만드는지/목표/구성. 채팅에서 '개요 써줘'로도 쓸 수 있다." oninput="onOverviewChange()"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="rb-pane" id="pane-notes">
|
||||
<textarea class="rb-notes" id="notes-textarea" placeholder="자유 메모..." oninput="onNotesChange()"></textarea>
|
||||
</div>
|
||||
@@ -323,6 +328,7 @@ let workshopData={projects:[],activeProjectId:''};
|
||||
let parts=[];
|
||||
let phases=[];
|
||||
let notes='';
|
||||
let overviewDesc='';
|
||||
let saveTimer=null;
|
||||
let saveDebounce=null;
|
||||
|
||||
@@ -347,6 +353,7 @@ function loadLocalFromProject(){
|
||||
parts=proj?(proj.parts||[]):[];
|
||||
phases=proj?(proj.phases||[]):[];
|
||||
notes=proj?(proj.notes||''):'';
|
||||
overviewDesc=proj?(proj.description||''):'';
|
||||
}
|
||||
|
||||
function syncLocalToProject(){
|
||||
@@ -355,6 +362,7 @@ function syncLocalToProject(){
|
||||
proj.parts=parts;
|
||||
proj.phases=phases;
|
||||
proj.notes=notes;
|
||||
proj.description=overviewDesc;
|
||||
}
|
||||
|
||||
function renderProjectSelector(){
|
||||
@@ -371,6 +379,7 @@ function refreshAllPanes(){
|
||||
renderParts();
|
||||
renderPhases();
|
||||
document.getElementById('notes-textarea').value=notes;
|
||||
document.getElementById('overview-textarea').value=overviewDesc;
|
||||
updateBudget();
|
||||
}
|
||||
|
||||
@@ -389,7 +398,7 @@ function createProjectPrompt(){
|
||||
const name=(prompt('새 프로젝트 이름을 입력하세요:')||'').trim();
|
||||
if(!name)return;
|
||||
const id=genId('proj');
|
||||
workshopData.projects.push({id,name,parts:[],phases:[],notes:''});
|
||||
workshopData.projects.push({id,name,parts:[],phases:[],notes:'',description:''});
|
||||
workshopData.activeProjectId=id;
|
||||
loadLocalFromProject();
|
||||
clearStlSelection();
|
||||
@@ -425,7 +434,7 @@ async function loadWorkshopData(){
|
||||
}catch{}
|
||||
if(!workshopData.projects.length){
|
||||
const id=genId('proj');
|
||||
workshopData.projects=[{id,name:'새 프로젝트',parts:[],phases:[],notes:''}];
|
||||
workshopData.projects=[{id,name:'새 프로젝트',parts:[],phases:[],notes:'',description:''}];
|
||||
workshopData.activeProjectId=id;
|
||||
}
|
||||
loadLocalFromProject();
|
||||
@@ -461,6 +470,8 @@ async function softRefreshWorkshop(){
|
||||
renderPhases();
|
||||
const ta=document.getElementById('notes-textarea');
|
||||
if(ta&&ta.value!==notes)ta.value=notes;
|
||||
const ota=document.getElementById('overview-textarea');
|
||||
if(ota&&ota.value!==overviewDesc)ota.value=overviewDesc;
|
||||
updateBudget();
|
||||
}catch{}
|
||||
}
|
||||
@@ -1052,6 +1063,14 @@ function onNotesChange(){
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
// ── 개요 ──────────────────────────────────────────────────────────────────────
|
||||
// 메모 탭과 같은 구조 — 프로젝트 서술형 설명(description). 채팅 도구 set_description
|
||||
// 으로도 쓸 수 있고, 채팅 응답 후 softRefreshWorkshop이 갱신해준다.
|
||||
function onOverviewChange(){
|
||||
overviewDesc=document.getElementById('overview-textarea').value;
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
// ── 채팅 패널 ─────────────────────────────────────────────────────────────────
|
||||
// 세션ID를 프로젝트별로 분리한다("ws_" 접두어는 유지 — tool-scope.ts가 이 세션에서는
|
||||
// 키워드 없이도 workshop_project 도구를 항상 열어주고, 메인 사이드바 목록에서도 걸러진다).
|
||||
|
||||
Reference in New Issue
Block a user