v4.2.1: 작가 어시스턴트 앱에 이북 리더 추가 — 페이지 넘김 형태 + Gutenberg 검색/보관

- Project Gutenberg/Google Books/알라딘 검색 및 서재(EPUB) 보관
- 페이지 넘김 리더: 책 판형 비율 고정 페이지, 챕터 자동 새 페이지 시작,
  단어/문단 경계 유지, 브라우저 창 크기와 무관한 고정 페이지 분량(스케일만 조정)
- Gutenberg 원문의 강제 줄바꿈을 문단 단위로 재조판(reflowHardWrappedProse)
- 서재에서도 바로 읽기 지원(다운로드 시 원문 URL 함께 저장)
- TTS 낭독(현재 페이지 기준)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-07-22 22:56:35 +09:00
co-authored by Claude Sonnet 5
parent e0e9a97761
commit 0bab3f7924
5 changed files with 1594 additions and 1 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "smallclaw",
"version": "4.2.0",
"version": "4.2.1",
"description": "Local AI agent framework powered by Ollama - OpenClaw alternative",
"main": "dist/index.js",
"bin": {
+639
View File
@@ -0,0 +1,639 @@
import express from 'express';
import path from 'path';
import fs from 'fs';
import { getConfig, getUserWorkspace } from '../config/config';
import { getOllamaClient } from '../agents/ollama-client';
function getWorkspacePath(userId?: string): string {
if (userId) {
try { return getUserWorkspace(userId); } catch {}
}
return getConfig().getWorkspacePath();
}
function writerDir(username: string): string {
const dir = path.join(getWorkspacePath(username), 'writer');
fs.mkdirSync(dir, { recursive: true });
return dir;
}
function worksPath(username: string): string { return path.join(writerDir(username), 'works.json'); }
function notesPath(username: string): string { return path.join(writerDir(username), 'notes.json'); }
function loadJson<T>(p: string, fallback: T): T {
try { return JSON.parse(fs.readFileSync(p, 'utf-8')); } catch { return fallback; }
}
function saveJson(p: string, data: any): void {
fs.writeFileSync(p, JSON.stringify(data, null, 2), 'utf-8');
}
// 장르별 톤/관습 — 전문가 프롬프트에 주입해 시/소설/수필/시나리오마다 다른 기준으로 피드백하게 함.
const GENRE_INFO: Record<string, { name: string; note: string }> = {
poetry: { name: '시', note: '운율, 함축, 이미지, 행/연 구성을 중시하고 산문적 설명체를 지양하세요.' },
novel: { name: '소설', note: '서사, 인물의 입체성, 갈등과 전개, 시점 일관성을 중시하세요.' },
essay: { name: '수필', note: '진솔한 화자의 목소리, 사색과 통찰, 자연스러운 흐름을 중시하세요.' },
scenario: { name: '시나리오', note: '지문과 대사의 구분, 장면 전환(씬 넘버링), 연출 가능성을 중시하세요.' },
};
function getGenre(g: any): { code: string; info: typeof GENRE_INFO[string] } {
const code = GENRE_INFO[g] ? String(g) : 'novel';
return { code, info: GENRE_INFO[code] };
}
// 전문가 버튼 — 요소형(문학의 요소) + 프로세스형(집필 과정) 두 갈래를 하나의 맵으로 관리.
const EXPERT_INFO: Record<string, { name: string; role: string }> = {
theme: { name: '주제', role: '작품이 전하려는 주제의식이 명확하고 일관되게 드러나는지, 더 깊이 있게 벼릴 지점은 없는지 짚어주는 편집자' },
style: { name: '문체', role: '문장 리듬, 어휘 선택, 어조의 일관성을 다듬어주는 문장 코치' },
structure: { name: '구성', role: '전개 순서, 기승전결/장면 배치, 페이싱의 완급을 점검하는 구성 전문가' },
setting: { name: '배경', role: '시공간적 배경의 구체성과 몰입감, 고증 오류를 점검하는 배경 고증가' },
character: { name: '인물', role: '인물의 동기·일관성·성장을 점검하고 입체감을 살리는 캐릭터 컨설턴트' },
idea: { name: '발상', role: '막힌 지점을 뚫어줄 새로운 전개·소재·비유를 제안하는 브레인스토밍 파트너' },
edit: { name: '퇴고', role: '비문·중복·군더더기를 짚고 문장을 다듬어주는 꼼꼼한 교열자' },
};
export function registerWriterRoutes(app: express.Application): void {
// ── 작품(원고) CRUD ────────────────────────────────────────────────────
app.get('/api/writer/works', (req: express.Request, res: express.Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ success: false, error: 'Unauthorized' });
const works = loadJson<any[]>(worksPath(user.username), []);
// 목록은 본문 없이 메타만 — 원고 전문은 상세조회에서만.
res.json({ success: true, works: works.map(w => ({ id: w.id, title: w.title, genre: w.genre, updatedAt: w.updatedAt, charCount: (w.content || '').length })) });
});
app.get('/api/writer/works/:id', (req: express.Request, res: express.Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ success: false, error: 'Unauthorized' });
const works = loadJson<any[]>(worksPath(user.username), []);
const w = works.find(x => x.id === req.params.id);
if (!w) return res.status(404).json({ success: false, error: 'not found' });
res.json({ success: true, work: w });
});
app.post('/api/writer/works', (req: express.Request, res: express.Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ success: false, error: 'Unauthorized' });
const { title, genre } = req.body || {};
if (!String(title || '').trim()) return res.status(400).json({ success: false, error: 'title required' });
const works = loadJson<any[]>(worksPath(user.username), []);
const work = {
id: 'w_' + Math.random().toString(36).slice(2, 10),
title: String(title).trim().slice(0, 120),
genre: GENRE_INFO[genre] ? genre : 'novel',
content: '',
history: [] as { t: number; chars: number }[],
updatedAt: Date.now(),
};
works.unshift(work);
saveJson(worksPath(user.username), works);
res.json({ success: true, work });
});
// 본문 저장 시 분량 히스토리(최근 60개)를 같이 쌓아서 "작품 통계" 탭의 집필 추이 그래프 재료로 씀.
app.put('/api/writer/works/:id', (req: express.Request, res: express.Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ success: false, error: 'Unauthorized' });
const works = loadJson<any[]>(worksPath(user.username), []);
const idx = works.findIndex(x => x.id === req.params.id);
if (idx < 0) return res.status(404).json({ success: false, error: 'not found' });
const { title, genre, content } = req.body || {};
const w = works[idx];
if (title != null) w.title = String(title).trim().slice(0, 120);
if (genre != null && GENRE_INFO[genre]) w.genre = genre;
if (content != null) {
w.content = String(content);
const history: { t: number; chars: number }[] = Array.isArray(w.history) ? w.history : [];
const last = history[history.length - 1];
const now = Date.now();
// 같은 날 여러 번 저장해도 마지막 값만 갱신(히스토리가 저장 횟수로 도배되지 않게)
const oneDay = 24 * 3600 * 1000;
if (last && (now - last.t) < oneDay) last.chars = w.content.length;
else history.push({ t: now, chars: w.content.length });
w.history = history.slice(-60);
}
w.updatedAt = Date.now();
saveJson(worksPath(user.username), works);
res.json({ success: true, work: w });
});
app.delete('/api/writer/works/:id', (req: express.Request, res: express.Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ success: false, error: 'Unauthorized' });
const works = loadJson<any[]>(worksPath(user.username), []);
const next = works.filter(x => x.id !== req.params.id);
saveJson(worksPath(user.username), next);
// 작품 삭제시 딸린 노트도 정리
const allNotes = loadJson<Record<string, any[]>>(notesPath(user.username), {});
delete allNotes[String(req.params.id)];
saveJson(notesPath(user.username), allNotes);
res.json({ success: true });
});
// ── 캐릭터/세계관 노트 (작품별) ────────────────────────────────────────
app.get('/api/writer/notes/:workId', (req: express.Request, res: express.Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ success: false, error: 'Unauthorized' });
const allNotes = loadJson<Record<string, any[]>>(notesPath(user.username), {});
res.json({ success: true, notes: allNotes[String(req.params.workId)] || [] });
});
app.post('/api/writer/notes/:workId', (req: express.Request, res: express.Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ success: false, error: 'Unauthorized' });
const { type, name, content } = req.body || {};
if (!String(name || '').trim()) return res.status(400).json({ success: false, error: 'name required' });
const allNotes = loadJson<Record<string, any[]>>(notesPath(user.username), {});
const list = allNotes[String(req.params.workId)] || [];
const note = {
id: 'n_' + Math.random().toString(36).slice(2, 10),
type: ['character', 'setting', 'timeline', 'term'].includes(type) ? type : 'character',
name: String(name).trim().slice(0, 80),
content: String(content || '').slice(0, 4000),
};
list.push(note);
allNotes[String(req.params.workId)] = list;
saveJson(notesPath(user.username), allNotes);
res.json({ success: true, note });
});
app.put('/api/writer/notes/:workId/:noteId', (req: express.Request, res: express.Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ success: false, error: 'Unauthorized' });
const { type, name, content } = req.body || {};
const allNotes = loadJson<Record<string, any[]>>(notesPath(user.username), {});
const list = allNotes[String(req.params.workId)] || [];
const n = list.find((x: any) => x.id === req.params.noteId);
if (!n) return res.status(404).json({ success: false, error: 'not found' });
if (type != null && ['character', 'setting', 'timeline', 'term'].includes(type)) n.type = type;
if (name != null) n.name = String(name).trim().slice(0, 80);
if (content != null) n.content = String(content).slice(0, 4000);
saveJson(notesPath(user.username), allNotes);
res.json({ success: true, note: n });
});
app.delete('/api/writer/notes/:workId/:noteId', (req: express.Request, res: express.Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ success: false, error: 'Unauthorized' });
const allNotes = loadJson<Record<string, any[]>>(notesPath(user.username), {});
allNotes[String(req.params.workId)] = (allNotes[String(req.params.workId)] || []).filter((x: any) => x.id !== req.params.noteId);
saveJson(notesPath(user.username), allNotes);
res.json({ success: true });
});
// ── 전문가 피드백 (핵심 기능) ──────────────────────────────────────────
// 요소형/프로세스형 공통 — expertType에 따라 역할만 바꿔서 LLM에 위임.
// 캐릭터/세계관 노트가 있으면 컨텍스트로 같이 넣어 일관성 체크가 가능하게 함.
// 대화형 — 첫 턴엔 원고 전체를 시스템 프롬프트에 싣고, 이후엔 history(질답 왕복)만 이어붙인다.
// 매 턴 draft/notes를 시스템 프롬프트에 새로 넣으므로 채팅 중 원고를 고쳐도 최신 내용이 반영됨.
// model을 넘기면 그 모델로, 안 넘기면 기본(executor 역할) 모델로 응답.
app.post('/api/writer/expert', async (req: express.Request, res: express.Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ success: false, error: 'Unauthorized' });
try {
const { expertType, workId, content, message, model } = req.body || {};
const expert = EXPERT_INFO[expertType];
if (!expert) return res.status(400).json({ success: false, error: 'invalid expertType' });
const draft = String(content || '').slice(0, 12000);
const userMessage = String(message || '').trim().slice(0, 2000);
if (!draft.trim() && !userMessage) return res.status(400).json({ success: false, error: 'content or message required' });
const historyRaw = Array.isArray(req.body?.history) ? req.body.history : [];
const history = historyRaw.slice(-16).filter((m: any) => m?.role && m?.content).map((m: any) => ({
role: m.role === 'assistant' ? 'assistant' as const : 'user' as const,
content: String(m.content).slice(0, 2000),
}));
let genreCode = 'novel';
if (workId) {
const works = loadJson<any[]>(worksPath(user.username), []);
const w = works.find(x => x.id === workId);
if (w?.genre) genreCode = w.genre;
}
const { info: genreInfo } = getGenre(genreCode);
let notesBlock = '';
if (workId) {
const allNotes = loadJson<Record<string, any[]>>(notesPath(user.username), {});
const notes = allNotes[workId] || [];
if (notes.length) {
const typeLabel: Record<string, string> = { character: '인물', setting: '배경/설정', timeline: '타임라인', term: '용어' };
notesBlock = '\n\n[참고: 작가가 정리해둔 설정 노트 — 이 원고와의 일관성을 확인할 때 참고하세요]\n' +
notes.map((n: any) => `- (${typeLabel[n.type] || n.type}) ${n.name}: ${n.content}`).join('\n');
}
}
const ollama = getOllamaClient();
const sys = `당신은 ${genreInfo.name} 전문 ${expert.role}입니다. ${genreInfo.note} 한국어로, 구체적인 원문 인용과 함께 실질적인 피드백을 주세요. 막연한 칭찬이나 일반론은 피하고, 이 원고에서 실제로 고칠 수 있는 지점을 짚어주세요. 대화가 이어지면 이전 맥락을 기억하고 자연스럽게 이어가세요.` +
(draft.trim() ? `\n\n[작가의 현재 원고]${notesBlock}\n${draft}` : notesBlock);
const msgs: Array<{ role: 'system' | 'user' | 'assistant'; content: string }> = [
{ role: 'system', content: sys },
...history,
];
msgs.push({ role: 'user', content: userMessage || '이 원고에 대한 첫 피드백을 주세요.' });
// num_predict 1500→6000: 문학 피드백은 섹션 여러 개(원문 인용+수정 제안 등)로 길게
// 나오는 경우가 잦은데 1500토큰이면 중간에 뚝 끊긴다(2026-07-22, "조금 나오다가
// 짤린다" 리포트). num_ctx는 일부러 안 넘김 — 어댑터가 프롬프트 크기에 맞춰
// 자동으로 잡아준다(server-v2.ts 메인 채팅 파이프라인과 동일 패턴).
const result = await ollama.chatWithThinking(
msgs,
'executor',
{ num_predict: 6000, temperature: 0.6, think: false, model: model ? String(model) : undefined }
);
const feedback = String(result.message?.content || '').trim();
res.json({ success: true, feedback, expertType, genre: genreCode });
} catch (err: any) {
res.status(500).json({ success: false, error: String(err?.message || err) });
}
});
// ── 자료조사 (웹서치 래퍼) ─────────────────────────────────────────────
app.post('/api/writer/research', async (req: express.Request, res: express.Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ success: false, error: 'Unauthorized' });
try {
const query = String(req.body?.query || '').trim();
if (!query) return res.status(400).json({ success: false, error: 'query required' });
const { executeWebSearch } = await import('../tools/web.js');
const result = await executeWebSearch({ query, max_results: 6 });
if (!result.success) return res.json({ success: false, error: result.error || '검색 실패' });
const items = Array.isArray(result.data?.results) ? result.data.results : [];
res.json({ success: true, results: items.map((r: any) => ({ title: r.title || '', url: r.url || '', snippet: r.snippet || r.content || '' })) });
} catch (err: any) {
res.status(500).json({ success: false, error: String(err?.message || err) });
}
});
// ── E-Book 리더: 다중 공급자 ─────────────────────────────────────────────
// 대표적인 공급자 3곳 — 필요할 때마다 더 추가 가능하도록 레지스트리 구조로 관리.
// gutenberg : Project Gutenberg(Gutendex API 경유) — 키 불필요, 퍼블릭도메인,
// EPUB/텍스트 다운로드+인앱 읽기 전부 가능. 한국어 작품은 사실상 없음
// (2026-07-22 확인 — 사전 1건뿐, 영미 고전 위주).
// google_books : Google Books API — 키 필요(무료 티어). 카탈로그가 훨씬 넓지만
// 퍼블릭도메인(accessInfo.publicDomain)인 것만 실제 다운로드 가능,
// 나머지는 미리보기 링크만.
// aladin : 알라딘 Open API(TTBKey) — 한국 서점 카탈로그. 상업 서점이라 다운로드는
// 없고 검색+구매링크만(약관상 알라딘 상품페이지로 연결 필수).
// 키는 vault 경유 저장(updateConfig에 평문으로 넘기면 저장시 자동 vault화 — nvr 비번과 동일 패턴).
const PROVIDERS: Record<string, { name: string; country: string; needsKey: boolean; downloadable: boolean }> = {
gutenberg: { name: 'Project Gutenberg', country: '영미(다국어)', needsKey: false, downloadable: true },
google_books: { name: 'Google Books', country: '전세계', needsKey: true, downloadable: true },
aladin: { name: '알라딘', country: '한국', needsKey: true, downloadable: false },
};
function getProviderKey(providerId: string): string | undefined {
const cfg = getConfig().getConfig() as any;
const raw = cfg?.writer?.providers?.[providerId]?.apiKey;
return raw ? getConfig().resolveSecret(raw) : undefined;
}
app.get('/api/writer/ebooks/providers', (req: express.Request, res: express.Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ success: false, error: 'Unauthorized' });
const list = Object.entries(PROVIDERS).map(([id, p]) => ({
id, name: p.name, country: p.country, needsKey: p.needsKey, downloadable: p.downloadable,
configured: !p.needsKey || !!getProviderKey(id),
}));
res.json({ success: true, providers: list });
});
app.post('/api/writer/ebooks/providers/:id/key', (req: express.Request, res: express.Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ success: false, error: 'Unauthorized' });
const id = String(req.params.id);
if (!PROVIDERS[id]) return res.status(400).json({ success: false, error: 'unknown provider' });
const apiKey = String(req.body?.apiKey || '').trim();
if (!apiKey) return res.status(400).json({ success: false, error: 'apiKey required' });
const cm = getConfig();
const cfg = cm.getConfig() as any;
const writer = cfg?.writer || {};
const providers = writer.providers || {};
providers[id] = { apiKey };
cm.updateConfig({ writer: { ...writer, providers } } as any);
res.json({ success: true });
});
// gutendex.com(Cloudflare 경유)이 가끔 타임아웃/일시 오류를 낸다(2026-07-22 실측 —
// 같은 요청을 몇 초 뒤 재시도하면 대개 바로 성공함). 진짜 장애가 아니라 일과성 hiccup이라
// 재시도 없이 그대로 500을 내보내면 사용자가 매번 다시 검색버튼을 눌러야 해서 짜증남.
async function fetchWithRetry(url: string, attempts = 3): Promise<Response> {
let lastErr: any;
for (let i = 0; i < attempts; i++) {
try {
const r = await fetch(url, { signal: AbortSignal.timeout(8_000) });
if (r.ok || i === attempts - 1) return r;
lastErr = new Error(`HTTP ${r.status}`);
} catch (e) {
lastErr = e;
}
await new Promise((res) => setTimeout(res, 500 * (i + 1)));
}
throw lastErr;
}
async function searchGutenberg(q: string, page: number) {
const url = `https://gutendex.com/books?search=${encodeURIComponent(q)}&page=${page}`;
const r = await fetchWithRetry(url);
if (!r.ok) throw new Error(`Gutendex 응답 ${r.status}`);
const data: any = await r.json();
return {
books: (data.results || []).map((b: any) => ({
id: String(b.id), title: b.title, authors: (b.authors || []).map((a: any) => a.name).join(', '),
cover: b.formats?.['image/jpeg'] || '',
epubUrl: b.formats?.['application/epub+zip'] || '',
textUrl: b.formats?.['text/plain; charset=utf-8'] || b.formats?.['text/plain'] || '',
link: '', downloadable: true,
})),
hasMore: !!data.next,
};
}
async function searchGoogleBooks(q: string, page: number, apiKey: string) {
const startIndex = (page - 1) * 20;
const url = `https://www.googleapis.com/books/v1/volumes?q=${encodeURIComponent(q)}&startIndex=${startIndex}&maxResults=20&key=${encodeURIComponent(apiKey)}`;
const r = await fetch(url, { signal: AbortSignal.timeout(10_000) });
const data: any = await r.json();
if (!r.ok) throw new Error(data?.error?.message || `Google Books 응답 ${r.status}`);
const items = data.items || [];
return {
books: items.map((it: any) => {
const vi = it.volumeInfo || {};
const ai = it.accessInfo || {};
const isPublicDomain = !!ai.publicDomain;
return {
id: it.id, title: vi.title || '', authors: (vi.authors || []).join(', '),
cover: vi.imageLinks?.thumbnail || '',
epubUrl: isPublicDomain && ai.epub?.isAvailable ? ai.epub.downloadLink : '',
textUrl: '', // Google은 PDF/EPUB만 제공, 인앱 텍스트 읽기 대상 아님
link: vi.previewLink || vi.infoLink || '',
downloadable: isPublicDomain,
};
}),
hasMore: (startIndex + items.length) < (data.totalItems || 0),
};
}
async function searchAladin(q: string, page: number, apiKey: string) {
const url = `https://www.aladin.co.kr/ttb/api/ItemSearch.aspx?ttbkey=${encodeURIComponent(apiKey)}&Query=${encodeURIComponent(q)}&QueryType=Keyword&MaxResults=20&start=${page}&SearchTarget=Book&output=js&Version=20131101`;
const r = await fetch(url, { signal: AbortSignal.timeout(10_000) });
const data: any = await r.json();
if (data?.errorCode) throw new Error(data.errorMessage || '알라딘 API 오류');
const items = data.item || [];
return {
books: items.map((it: any) => ({
id: String(it.itemId), title: it.title || '', authors: it.author || '',
cover: it.cover || '', epubUrl: '', textUrl: '',
link: it.link || '', downloadable: false,
})),
hasMore: items.length >= 20,
attribution: '도서 DB 제공: 알라딘 인터넷서점(www.aladin.co.kr)',
};
}
app.get('/api/writer/ebooks/search', async (req: express.Request, res: express.Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ success: false, error: 'Unauthorized' });
try {
const providerId = String(req.query.provider || 'gutenberg');
const provider = PROVIDERS[providerId];
if (!provider) return res.status(400).json({ success: false, error: 'unknown provider' });
const q = String(req.query.q || '').trim();
if (!q) return res.status(400).json({ success: false, error: 'q required' });
const page = Math.max(1, Number(req.query.page) || 1);
if (provider.needsKey) {
const key = getProviderKey(providerId);
if (!key) return res.json({ success: false, needsKey: true, error: `${provider.name} API 키가 설정되지 않았습니다` });
const result = providerId === 'google_books' ? await searchGoogleBooks(q, page, key) : await searchAladin(q, page, key);
return res.json({ success: true, ...result });
}
const result = await searchGutenberg(q, page);
res.json({ success: true, ...result });
} catch (err: any) {
const msg = String(err?.message || err);
const friendly = /timeout|aborted|fetch failed|ECONNRESET/i.test(msg)
? '외부 도서 검색 서버가 일시적으로 응답이 없습니다. 잠시 후 다시 시도해주세요.'
: msg;
res.status(500).json({ success: false, error: friendly });
}
});
// Gutenberg 파일은 라이선스 규정상 항상 이 정확한 마커를 포함한다 — 그 사이만 잘라내면
// 앞뒤 라이선스/보일러플레이트가 제거된다(2026-07-22 셜록 홈즈로 실측 확인, 100% 안정적).
// 목차(Contents) 스킵은 책마다 형식이 달라 완벽할 수 없는 휴리스틱 — "Contents" 표제 뒤
// 첫 장문단(80자 이상 줄)이 나오는 지점까지를 목차로 간주하고 건너뛴다.
function trimGutenbergBoilerplate(raw: string): string {
const startMatch = raw.match(/\*\*\*\s*START OF (?:THE|THIS) PROJECT GUTENBERG EBOOK[^*]*\*\*\*/i);
const endMatch = raw.match(/\*\*\*\s*END OF (?:THE|THIS) PROJECT GUTENBERG EBOOK[^*]*\*\*\*/i);
let body = raw;
if (startMatch && startMatch.index != null) body = body.slice(startMatch.index + startMatch[0].length);
if (endMatch && endMatch.index != null) {
const endIdx = raw.indexOf(endMatch[0]);
const relativeEnd = endIdx - (raw.length - body.length);
if (relativeEnd > 0) body = body.slice(0, relativeEnd);
}
return body.trim();
}
function skipTableOfContents(body: string): string {
const tocHeading = body.match(/^\s*(contents|table of contents)\s*$/im);
if (!tocHeading || tocHeading.index == null) return body;
// Gutenberg 텍스트는 보통 70~75자로 강제 줄바꿈돼 있어서 "긴 줄"로 본문을 구분할 수
// 없다(2026-07-22 실측 — 셜록 홈즈 목차 스킵이 그 가정 때문에 실패했었음) — 대신
// 목차 항목은 "로마숫자/숫자 + 구분자 + 짧은 제목" 모양의 줄이 연속으로 나오는 걸로
// 판단하고, 그 연속 줄이 끊기는 지점까지만 건너뛴다.
const afterHeading = body.slice(tocHeading.index + tocHeading[0].length);
const lines = afterHeading.split(/\r?\n/);
const tocEntryRe = /^\s*(?:[IVXLCDM]+|\d+)[.)]\s+\S.{0,80}$/;
let i = 0, sawEntry = false;
while (i < lines.length) {
const line = lines[i];
if (line.trim() === '') { i++; continue; }
if (tocEntryRe.test(line)) { sawEntry = true; i++; continue; }
break;
}
if (!sawEntry) return body; // 목차 항목을 하나도 못 찾았으면 원문 그대로(과잉 삭제보다 안전)
return lines.slice(i).join('\n').trim();
}
// Gutenberg 텍스트는 본문 문단도 70~75자로 강제 줄바꿈돼 있어서, 우리 리더처럼 좁은
// 페이지 폭에 그대로 넣으면 문단 중간에 원본 줄바꿈이 그대로 남아 들쭉날쭉하게 끊겨
// 보인다(2026-07-22 실측). 빈 줄로 구분되지 않은 연속된 줄들을 한 문단으로 다시 이어
// 붙여, 우리 페이지 폭에 맞게 자연스럽게 재줄바꿈되도록 한다. 제목면·시·인용문처럼
// 의도적으로 들여쓰기/정렬된 줄(왼쪽 여백이 큰 줄)은 서식이 깨지지 않도록 그대로 둔다.
function reflowHardWrappedProse(body: string): string {
const lines = body.split(/\r?\n/);
const out: string[] = [];
let buf: string[] = [];
const flush = () => { if (buf.length) { out.push(buf.join(' ')); buf = []; } };
for (const line of lines) {
if (line.trim() === '') { flush(); out.push(''); continue; }
const leadingWs = line.match(/^[ \t]*/)?.[0].length ?? 0;
if (leadingWs >= 8) { flush(); out.push(line); continue; }
buf.push(line.trim());
}
flush();
return out.join('\n');
}
// 페이지네이션("더 보기")마다 전체 텍스트를 다시 받지 않도록 URL별로 잠깐 캐시
// (고전 전문은 수백KB라 매번 재다운로드하면 낭비) — 최대 20권, LRU 없이 단순 캡.
// 인앱 읽기는 현재 Gutenberg 텍스트만 지원(다른 공급자는 EPUB/PDF라 별도 렌더러 필요) —
// SSRF 방지 위해 프록시 대상은 gutenberg.org 도메인으로만 제한.
const ebookTextCache = new Map<string, string>();
app.get('/api/writer/ebooks/read', async (req: express.Request, res: express.Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ success: false, error: 'Unauthorized' });
try {
const url = String(req.query.url || '');
let parsed: URL;
try { parsed = new URL(url); } catch { return res.status(400).json({ success: false, error: 'invalid url' }); }
if (!/(^|\.)gutenberg\.org$/i.test(parsed.hostname)) {
return res.status(400).json({ success: false, error: 'gutenberg.org 도메인만 허용됩니다' });
}
const key = parsed.toString();
let text = ebookTextCache.get(key);
if (text === undefined) {
const r = await fetchWithRetry(key);
if (!r.ok) return res.json({ success: false, error: `다운로드 실패 (${r.status})` });
const raw = await r.text();
text = reflowHardWrappedProse(skipTableOfContents(trimGutenbergBoilerplate(raw)));
if (ebookTextCache.size >= 20) ebookTextCache.delete(ebookTextCache.keys().next().value as string);
ebookTextCache.set(key, text);
}
// percent(0~100)를 주면 그 위치로 바로 점프 — 진짜 "페이지"는 없는 텍스트라
// 위치 비율로 이동하는 게 가장 정직한 대체재.
let offset = Math.max(0, Number(req.query.offset) || 0);
if (req.query.percent != null) {
const pct = Math.min(100, Math.max(0, Number(req.query.percent) || 0));
offset = Math.floor((pct / 100) * text.length);
}
const chunkSize = 20000;
res.json({ success: true, text: text.slice(offset, offset + chunkSize), totalLength: text.length, nextOffset: offset + chunkSize < text.length ? offset + chunkSize : null, offset });
} catch (err: any) {
res.status(500).json({ success: false, error: String(err?.message || err) });
}
});
// 서재(e-book) 낭독 — /api/voice/tts는 서버 설정된 한국어 음성 고정이라 영어 고전
// 원문을 그대로 넣으면 발음이 심하게 어색해진다. Gutenberg 콘텐츠가 거의 영어라
// edge-tts를 영어 보이스로 직접 호출하는 전용 경로를 둔다(2026-07-22).
app.post('/api/writer/ebooks/tts', async (req: express.Request, res: express.Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ success: false, error: 'Unauthorized' });
const text = String(req.body?.text || '').trim();
if (!text) return res.status(400).json({ success: false, error: 'text required' });
try {
const os = await import('os');
const { spawn: spawnProc } = await import('child_process');
const voice = 'en-US-AriaNeural';
const mp3Path = path.join(os.tmpdir(), `writer_tts_${Date.now()}.mp3`);
const scriptPath = path.join(__dirname, '../../src/tools/edge_tts_synth.py');
await new Promise<void>((resolve, reject) => {
const py = spawnProc('python3', [scriptPath, voice, mp3Path]);
py.stdin.write(text.slice(0, 3000), 'utf8');
py.stdin.end();
let stderr = '';
py.stderr.on('data', (d: Buffer) => { stderr += d.toString('utf8'); });
py.on('close', (code: number | null) => code === 0 ? resolve() : reject(new Error(`edge-tts exited ${code}: ${stderr}`)));
py.on('error', reject);
});
const mp3 = fs.readFileSync(mp3Path);
try { fs.unlinkSync(mp3Path); } catch {}
res.set('Content-Type', 'audio/mpeg');
res.send(mp3);
} catch (err: any) {
res.status(500).json({ success: false, error: String(err?.message || err) });
}
});
// ── 서재: 서버 사이드 다운로드/보관 ────────────────────────────────────
// 브라우저로 바로 다운로드하면 그 기기에만 남고 우리 앱에서 다시 못 찾는다 —
// 유저 워크스페이스의 writer/ebooks/ 밑에 실제 파일을 저장해 "내 서재"로 관리한다
// (2026-07-22). 다운로드 대상 URL은 검색 결과에서만 나오지만, 그래도 서버가 실제로
// 파일을 써야 하는 만큼 방어적으로 도메인 화이트리스트를 건다(SSRF/임의 파일 저장 방지).
const EBOOK_DOWNLOAD_ALLOWED_HOSTS = [/(^|\.)gutenberg\.org$/i, /(^|\.)google(usercontent)?\.com$/i, /(^|\.)googleapis\.com$/i];
function ebooksDir(username: string): string {
const dir = path.join(writerDir(username), 'ebooks');
fs.mkdirSync(dir, { recursive: true });
return dir;
}
function ebookLibraryPath(username: string): string { return path.join(ebooksDir(username), 'library.json'); }
// Express 5(res.download/send)이 경로에 공백이 섞이면 실제 파일이 있어도 그냥 404를
// 낸다(2026-07-22 실측 재현 — 공백 없는 경로는 정상, 있으면 무조건 실패). 디스크에 쓰는
// 파일명에서는 공백을 아예 없애고, 예쁜 제목은 library.json의 title 필드에 따로 보관해서
// 다운로드시 Content-Disposition 파일명으로만 쓴다.
function sanitizeFilename(s: string): string {
return s.replace(/[\\/:*?"<>|]/g, '_').replace(/\s+/g, '_').trim().slice(0, 100) || 'untitled';
}
app.get('/api/writer/ebooks/library', (req: express.Request, res: express.Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ success: false, error: 'Unauthorized' });
const books = loadJson<any[]>(ebookLibraryPath(user.username), []);
res.json({ success: true, books });
});
app.post('/api/writer/ebooks/download', async (req: express.Request, res: express.Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ success: false, error: 'Unauthorized' });
try {
const { url, title, authors, source, readUrl } = req.body || {};
let parsed: URL;
try { parsed = new URL(String(url || '')); } catch { return res.status(400).json({ success: false, error: 'invalid url' }); }
if (!EBOOK_DOWNLOAD_ALLOWED_HOSTS.some((re) => re.test(parsed.hostname))) {
return res.status(400).json({ success: false, error: '허용되지 않은 다운로드 출처입니다' });
}
// 서재("내 서재")에서도 인앱 리더로 바로 열 수 있도록, 검색 결과의 원문 텍스트 URL을
// 함께 저장해둔다(2026-07-22) — 인앱 읽기는 gutenberg.org 텍스트만 지원하므로 그 외
// 호스트는 조용히 무시(잘못된 값이 와도 다운로드 자체는 계속 진행).
let safeReadUrl = '';
if (readUrl) {
try {
const ru = new URL(String(readUrl));
if (/(^|\.)gutenberg\.org$/i.test(ru.hostname)) safeReadUrl = ru.toString();
} catch { /* ignore invalid readUrl */ }
}
const r = await fetchWithRetry(parsed.toString());
if (!r.ok) return res.json({ success: false, error: `다운로드 실패 (${r.status})` });
const buf = Buffer.from(await r.arrayBuffer());
const ext = /\.epub(\W|$)/i.test(parsed.pathname) || /epub/i.test(r.headers.get('content-type') || '') ? 'epub' : 'bin';
const safeTitle = sanitizeFilename(String(title || 'untitled'));
const id = 'eb_' + Math.random().toString(36).slice(2, 10);
const filename = `${id}_${safeTitle}.${ext}`;
fs.writeFileSync(path.join(ebooksDir(user.username), filename), buf);
const books = loadJson<any[]>(ebookLibraryPath(user.username), []);
const entry = {
id, title: String(title || '제목 없음'), authors: String(authors || ''),
source: String(source || ''), filename, sizeBytes: buf.length, downloadedAt: Date.now(),
readUrl: safeReadUrl,
};
books.unshift(entry);
saveJson(ebookLibraryPath(user.username), books);
res.json({ success: true, book: entry });
} catch (err: any) {
res.status(500).json({ success: false, error: String(err?.message || err) });
}
});
app.get('/api/writer/ebooks/library/:id/file', (req: express.Request, res: express.Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ success: false, error: 'Unauthorized' });
const books = loadJson<any[]>(ebookLibraryPath(user.username), []);
const book = books.find((b) => b.id === req.params.id);
if (!book) return res.status(404).json({ success: false, error: 'not found' });
const filePath = path.join(ebooksDir(user.username), book.filename);
if (!fs.existsSync(filePath)) return res.status(404).json({ success: false, error: 'file missing' });
// dotfiles:'allow' 필수 — Express(send)의 기본 dotfiles:'ignore' 정책은 경로 중간에
// .으로 시작하는 디렉토리(우리 데이터 루트가 통째로 .smallclaw)가 있으면 파일이 실제로
// 있어도 그냥 404를 낸다(2026-07-22 실측 재현 — 공백 문제인 줄 알았다가 진짜 원인 찾음).
res.download(filePath, `${book.title}.${book.filename.split('.').pop()}`, { dotfiles: 'allow' } as any);
});
app.delete('/api/writer/ebooks/library/:id', (req: express.Request, res: express.Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ success: false, error: 'Unauthorized' });
const books = loadJson<any[]>(ebookLibraryPath(user.username), []);
const book = books.find((b) => b.id === req.params.id);
if (book) {
try { fs.unlinkSync(path.join(ebooksDir(user.username), book.filename)); } catch {}
}
saveJson(ebookLibraryPath(user.username), books.filter((b) => b.id !== req.params.id));
res.json({ success: true });
});
}
+2
View File
@@ -24,6 +24,7 @@ import { registerVoiceRealtimeRoutes, attachVoiceRealtimeWsProxy } from './route
import { registerMusicRoutes } from './routes-music';
import { registerMCPRoutes } from './routes-mcp';
import { registerLanguageRoutes } from './routes-language';
import { registerWriterRoutes } from './routes-writer';
import { registerDentalRoutes } from './routes-dental';
import { registerDetectiveDateRoutes } from './routes-detective-dates';
import {
@@ -14041,6 +14042,7 @@ app.get('/api/midi-download', async (req: any, res: any) => {
});
registerLanguageRoutes(app);
registerWriterRoutes(app);
registerDentalRoutes(app);
registerDetectiveDateRoutes(app, getSessionUser);
+1
View File
@@ -73,6 +73,7 @@
<button class="mode-btn" onclick="window.open('/traffic-app.html','_blank')" title="교통 CCTV">🚦 교통</button>
<button class="mode-btn" onclick="window.open('/nvr-app.html','_blank')" title="홈 CCTV">📹 홈CCTV</button>
<button class="mode-btn" onclick="window.open('/dental-agent.html?t='+Date.now(),'_blank')" title="치과 사전 이미지 에이전트">🦷 치과</button>
<button class="mode-btn" onclick="window.open('/writer-app.html','_blank')" title="작가 어시스턴트">✍️ 작가</button>
</div>
<div class="status-bar">
+951
View File
@@ -0,0 +1,951 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title id="doc-title">✍️ 작가</title>
<link rel="icon" type="image/png" sizes="64x64" href="homeclaw_logo.png">
<link rel="stylesheet" href="styles.css">
<script>try{var _t=localStorage.getItem('homeclaw_theme')||'dark';document.documentElement.setAttribute('data-theme',_t);}catch(e){}</script>
<style>
:root{--brand:#8b5cf6;}
html,body{height:100%;margin:0;padding:0;}
body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;display:flex;flex-direction:column;overflow:hidden;height:100dvh;}
.hdr{display:flex;align-items:center;gap:10px;padding:8px 14px;background:var(--panel);border-bottom:1px solid var(--line);flex-shrink:0;flex-wrap:wrap}
.hdr h1{font-size:14px;font-weight:800;margin:0;flex-shrink:0}
.hdr-sp{flex:1}
.hdr-btn{background:none;border:1.5px solid var(--line);border-radius:6px;padding:4px 10px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit}
.hdr-btn:hover{border-color:var(--brand);color:var(--brand)}
.genre-switch{display:flex;gap:4px;background:var(--panel-2);border-radius:8px;padding:3px}
.genre-btn{padding:5px 12px;font-size:12px;font-weight:700;border:none;border-radius:6px;background:none;color:var(--muted);cursor:pointer;font-family:inherit}
.genre-btn.active{background:var(--brand);color:#fff}
.work-sel{font-size:12px;padding:6px 8px;background:var(--panel-2);color:var(--text);border:1px solid var(--line);border-radius:6px;font-family:inherit;cursor:pointer;max-width:180px}
.work-meta{font-size:11px;color:var(--muted);flex-shrink:0}
.layout{flex:1;min-height:0;display:grid;grid-template-columns:190px 1fr 320px;overflow:hidden}
.layout.no-chat{grid-template-columns:190px 1fr}
.layout.no-chat .right-chat{display:none}
/* 왼쪽: 탭 네비 + 전문가 버튼 */
.left-sb{background:var(--panel);border-right:1px solid var(--line);display:flex;flex-direction:column;overflow-y:auto;padding:10px 8px;gap:4px}
.nav-btn{display:flex;align-items:center;gap:6px;padding:8px 10px;font-size:12px;font-weight:700;border:none;border-radius:8px;background:none;color:var(--muted);cursor:pointer;font-family:inherit;text-align:left}
.nav-btn.active{background:var(--brand);color:#fff}
.nav-btn:not(.active):hover{background:var(--panel-2);color:var(--text)}
.sb-divider{font-size:9px;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;padding:10px 10px 2px}
.expert-btn{display:flex;align-items:center;gap:6px;padding:8px 10px;font-size:12px;font-weight:600;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--text);cursor:pointer;font-family:inherit;text-align:left}
.expert-btn:hover{border-color:var(--brand);color:var(--brand)}
.expert-btn.active{border-color:var(--brand);background:color-mix(in srgb,var(--brand) 12%,transparent);color:var(--brand)}
.expert-btn.process{border-style:dashed}
/* 가운데: 작업창 */
.center-work{overflow-y:auto;padding:16px;box-sizing:border-box;display:flex;flex-direction:column;gap:12px}
.empty{color:var(--muted);font-size:12px;text-align:center;padding:30px}
#draft{width:100%;box-sizing:border-box;flex:1;min-height:300px;resize:vertical;font-size:14px;line-height:1.7;padding:12px 14px;background:var(--panel);color:var(--text);border:1px solid var(--line);border-radius:10px;font-family:inherit}
/* 오른쪽: 모델 선택 + 채팅 */
.right-chat{background:var(--panel);border-left:1px solid var(--line);display:flex;flex-direction:column;min-height:0}
.model-box{padding:10px;border-bottom:1px solid var(--line);flex-shrink:0}
.model-box label{font-size:9px;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;display:block;margin-bottom:4px}
#model-sel{width:100%;box-sizing:border-box;font-size:12px;padding:6px 8px;background:var(--panel-2);color:var(--text);border:1px solid var(--line);border-radius:6px;font-family:inherit;cursor:pointer}
.chat-active-tag{font-size:10px;font-weight:800;color:#fff;background:var(--brand);border-radius:5px;padding:2px 7px;display:inline-block;margin-top:6px}
.chat-msgs{flex:1;min-height:0;overflow-y:auto;padding:10px;display:flex;flex-direction:column;gap:10px}
.msg{font-size:12.5px;line-height:1.6;white-space:pre-wrap;padding:8px 10px;border-radius:8px;max-width:100%}
.msg.user{background:var(--panel-2);align-self:flex-end}
.msg.assistant{background:color-mix(in srgb,var(--brand) 8%,transparent);border:1px solid var(--line)}
.msg.pending{color:var(--muted);font-style:italic}
.chat-input-row{display:flex;gap:6px;padding:10px;border-top:1px solid var(--line);flex-shrink:0}
.chat-input-row textarea{flex:1;box-sizing:border-box;font-size:12.5px;padding:8px 10px;background:var(--panel-2);color:var(--text);border:1px solid var(--line);border-radius:8px;font-family:inherit;resize:none;min-height:36px;max-height:100px}
.chat-send{background:var(--brand);color:#fff;border:none;border-radius:8px;padding:0 14px;font-size:12px;font-weight:700;cursor:pointer;font-family:inherit}
.chat-send:disabled{opacity:.5;cursor:default}
/* 노트 탭 */
.note-toolbar{display:flex;gap:6px;flex-wrap:wrap}
.note-card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:12px}
.note-card .nc-top{display:flex;align-items:center;gap:8px;margin-bottom:6px}
.note-badge{font-size:9px;font-weight:800;padding:2px 7px;border-radius:8px;background:var(--panel-2);color:var(--muted)}
.note-card b{flex:1;font-size:13px}
.note-card .nc-actions{display:flex;gap:4px}
.note-card textarea{width:100%;box-sizing:border-box;font-size:12px;padding:6px 8px;background:var(--panel-2);color:var(--text);border:1px solid var(--line);border-radius:6px;font-family:inherit;resize:vertical;min-height:50px}
.icon-x{background:none;border:none;color:var(--muted);cursor:pointer;font-size:12px}
/* 낭독 탭 */
.tts-controls{display:flex;gap:8px;align-items:center}
#tts-audio{width:100%}
/* 통계 탭 */
.stat-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:10px}
.stat-tile{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px;text-align:center}
.stat-tile .v{font-size:22px;font-weight:800;color:var(--brand)}
.stat-tile .l{font-size:11px;color:var(--muted);margin-top:4px}
.hist-row{display:flex;justify-content:space-between;font-size:11px;color:var(--muted);padding:4px 0;border-bottom:1px solid var(--line)}
/* 자료조사 탭 */
.research-row{display:flex;gap:6px}
.research-row input{flex:1;font-size:13px;padding:8px 10px;background:var(--panel-2);color:var(--text);border:1px solid var(--line);border-radius:8px;font-family:inherit}
.research-item{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:10px 12px}
.research-item a{font-size:13px;font-weight:700;color:var(--brand);text-decoration:none}
.research-item p{font-size:12px;color:var(--muted);margin:4px 0 0}
/* 서재(E-Book) 탭 */
.provider-tabs{display:flex;gap:6px;flex-wrap:wrap}
.provider-tab{display:flex;align-items:center;gap:5px;padding:6px 12px;font-size:12px;font-weight:700;border:1.5px solid var(--line);border-radius:999px;background:var(--panel);color:var(--text);cursor:pointer;font-family:inherit}
.provider-tab.active{border-color:var(--brand);background:color-mix(in srgb,var(--brand) 12%,transparent);color:var(--brand)}
.provider-tab .pcountry{font-size:10px;color:var(--muted);font-weight:600}
.provider-tab.needs-key{opacity:.7}
.provider-tab .key-badge{font-size:9px;font-weight:800;background:#f59e0b;color:#fff;border-radius:5px;padding:1px 5px}
.ebook-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px}
.ebook-card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:10px;display:flex;flex-direction:column;gap:6px}
.ebook-card img{width:100%;aspect-ratio:2/3;object-fit:cover;border-radius:6px;background:var(--panel-2)}
.ebook-card .et{font-size:12px;font-weight:700;line-height:1.3}
.ebook-card .ea{font-size:11px;color:var(--muted)}
.ebook-card .eb-actions{display:flex;gap:4px;margin-top:auto}
.ebook-card .eb-actions a,.ebook-card .eb-actions button{flex:1;text-align:center;font-size:11px;padding:5px 0;border:1px solid var(--line);border-radius:6px;background:var(--panel-2);color:var(--text);text-decoration:none;cursor:pointer;font-family:inherit}
.ebook-card .eb-actions a:hover,.ebook-card .eb-actions button:hover{border-color:var(--brand);color:var(--brand)}
.ebook-page-viewport{flex:1;min-height:0;position:relative;overflow:hidden;background:var(--panel-2);border:1px solid var(--line);border-radius:10px}
.ebook-page-spread{position:absolute;display:flex;align-items:stretch;gap:0;background:#faf6ec;border-radius:4px;box-shadow:0 4px 24px rgba(0,0,0,.18),inset 0 0 40px rgba(0,0,0,.04)}
:root[data-theme="dark"] .ebook-page-spread{background:#242019;box-shadow:0 4px 24px rgba(0,0,0,.4),inset 0 0 40px rgba(0,0,0,.25)}
.ebook-page-viewport.spread .ebook-page-spread{gap:56px}
.ebook-page-viewport.spread .ebook-page-spread::after{content:'';position:absolute;top:0;bottom:0;left:50%;width:1px;background:rgba(0,0,0,.08);z-index:2;pointer-events:none}
:root[data-theme="dark"] .ebook-page-viewport.spread .ebook-page-spread::after{background:rgba(0,0,0,.35)}
.ebook-page-slot{box-sizing:border-box;padding:30px 40px;color:#2b2417;font-size:14.5px;line-height:1.85;white-space:pre-wrap;overflow:hidden;flex:1 1 0;min-width:0;opacity:1;transition:opacity .2s}
:root[data-theme="dark"] .ebook-page-slot{color:#dcd2b8}
.ebook-page-slot.hidden{display:none}
.ebook-page-slot.fading{opacity:0}
.ebook-turn-zone{position:absolute;top:0;bottom:0;width:14%;min-width:36px;display:flex;align-items:center;justify-content:center;font-size:22px;color:var(--muted);opacity:.55;cursor:pointer;transition:opacity .15s;z-index:3;user-select:none}
.ebook-turn-zone.left{left:0;background:linear-gradient(90deg,rgba(0,0,0,.06),transparent)}
.ebook-turn-zone.right{right:0;background:linear-gradient(270deg,rgba(0,0,0,.06),transparent)}
.ebook-turn-zone:hover{opacity:1;color:var(--brand)}
.ebook-turn-zone.disabled{opacity:.12!important;pointer-events:none}
.ebook-page-num{position:absolute;bottom:8px;left:0;right:0;text-align:center;font-size:10px;color:rgba(0,0,0,.35);z-index:2;pointer-events:none}
:root[data-theme="dark"] .ebook-page-num{color:rgba(255,255,255,.3)}
.toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%) translateY(20px);background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:10px 18px;font-size:12px;opacity:0;pointer-events:none;transition:.2s;z-index:999}
.toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
.toast.err{border-color:#ef4444;color:#ef4444}
</style>
</head>
<body>
<div class="hdr">
<a href="/" style="text-decoration:none;font-size:13px;color:var(--muted)">← 홈</a>
<h1>✍️ 작가</h1>
<div class="genre-switch" id="genre-switch"></div>
<select class="work-sel" id="work-sel" onchange="onWorkSelect()">
<option value="">작품을 선택하세요...</option>
</select>
<button class="hdr-btn" onclick="createWork()">+ 새 작품</button>
<button class="hdr-btn" onclick="deleteCurrentWork()" id="work-del-btn" style="display:none">삭제</button>
<span class="work-meta" id="work-meta"></span>
<div class="hdr-sp"></div>
<button class="hdr-btn" onclick="toggleTheme()">🌙</button>
</div>
<div class="layout" id="layout">
<!-- 왼쪽: 탭 네비 + 전문가 버튼 -->
<div class="left-sb">
<button class="nav-btn active" id="nav-write" onclick="setTab('write')">✍️ 작가</button>
<button class="nav-btn" id="nav-notes" onclick="setTab('notes')">🗂️ 캐릭터·세계관</button>
<button class="nav-btn" id="nav-read" onclick="setTab('read')">🔊 낭독</button>
<button class="nav-btn" id="nav-stats" onclick="setTab('stats')">📊 작품 통계</button>
<button class="nav-btn" id="nav-research" onclick="setTab('research')">🔎 자료조사</button>
<button class="nav-btn" id="nav-ebooks" onclick="setTab('ebooks')">📚 서재</button>
<div id="expert-block">
<div class="sb-divider">요소</div>
<div id="expert-list-elements" style="display:flex;flex-direction:column;gap:4px"></div>
<div class="sb-divider">프로세스</div>
<div id="expert-list-process" style="display:flex;flex-direction:column;gap:4px"></div>
</div>
</div>
<!-- 가운데: 작업창 -->
<div class="center-work" id="center-work">
<!-- 작가 탭 -->
<div id="pane-write" style="display:flex;flex-direction:column;flex:1;gap:12px">
<textarea id="draft" placeholder="여기에 원고를 쓰세요. 자동 저장됩니다." oninput="onDraftInput()"></textarea>
</div>
<!-- 캐릭터/세계관 노트 탭 -->
<div id="pane-notes" style="display:none;flex-direction:column;gap:12px">
<div class="note-toolbar">
<button class="hdr-btn" onclick="addNote('character')">+ 인물</button>
<button class="hdr-btn" onclick="addNote('setting')">+ 배경/설정</button>
<button class="hdr-btn" onclick="addNote('timeline')">+ 타임라인</button>
<button class="hdr-btn" onclick="addNote('term')">+ 용어</button>
</div>
<div id="notes-list"></div>
</div>
<!-- 낭독 탭 -->
<div id="pane-read" style="display:none;flex-direction:column;gap:12px">
<div class="tts-controls">
<button class="hdr-btn" onclick="readAloud()" id="tts-btn">🔊 원고 읽기</button>
<span class="work-meta" id="tts-status"></span>
</div>
<audio id="tts-audio" controls style="display:none"></audio>
</div>
<!-- 작품 통계 탭 -->
<div id="pane-stats" style="display:none;flex-direction:column;gap:12px">
<div class="stat-grid" id="stat-grid"></div>
<div id="hist-list"></div>
</div>
<!-- 자료조사 탭 -->
<div id="pane-research" style="display:none;flex-direction:column;gap:12px">
<div class="research-row">
<input id="research-q" placeholder="고증·전문지식이 필요한 걸 검색해보세요" onkeydown="if(event.key==='Enter')doResearch()">
<button class="hdr-btn" onclick="doResearch()">검색</button>
</div>
<div id="research-list"></div>
</div>
<!-- 서재(E-Book) 탭 -->
<div id="pane-ebooks" style="display:none;flex-direction:column;flex:1;min-height:0;gap:12px">
<div style="display:flex;gap:6px">
<button class="nav-btn active" id="ebook-view-search" onclick="setEbookView('search')" style="flex:none;padding:6px 14px">🔎 검색</button>
<button class="nav-btn" id="ebook-view-library" onclick="setEbookView('library')" style="flex:none;padding:6px 14px">📖 내 서재</button>
</div>
<div id="ebook-search-view" style="display:flex;flex-direction:column;gap:12px">
<div class="provider-tabs" id="provider-tabs"></div>
<div class="research-row">
<input id="ebook-q" placeholder="책 제목·저자로 검색" onkeydown="if(event.key==='Enter')doEbookSearch()">
<button class="hdr-btn" onclick="doEbookSearch()">검색</button>
</div>
<div id="ebook-attribution" class="work-meta"></div>
<div class="ebook-grid" id="ebook-results"></div>
</div>
<div id="ebook-library-view" style="display:none;flex-direction:column;gap:12px">
<div class="ebook-grid" id="ebook-library-list"></div>
</div>
<div id="ebook-reader" style="display:none;flex-direction:column;gap:8px;flex:1;min-height:0">
<div style="display:flex;align-items:center;gap:8px">
<button class="hdr-btn" onclick="closeEbookReader()">← 목록으로</button>
<b id="ebook-reader-title" style="font-size:13px"></b>
<button class="hdr-btn" onclick="readEbookAloud()" id="ebook-tts-btn">🔊 낭독</button>
<span class="work-meta" id="ebook-tts-status"></span>
<div class="hdr-sp"></div>
<span class="work-meta" id="ebook-page-label" style="font-weight:700"></span>
<label class="work-meta" style="display:flex;align-items:center;gap:4px">위치
<input type="range" id="ebook-jump-range" min="0" max="100" value="0" style="width:100px" oninput="document.getElementById('ebook-jump-label').textContent=this.value+'%'" onchange="jumpEbookTo(this.value)">
<span id="ebook-jump-label">0%</span>
</label>
</div>
<div style="font-size:10px;color:var(--muted)">Project Gutenberg 라이선스 안내문과 목차는 자동으로 건너뜁니다(책 형식에 따라 완벽하지 않을 수 있음) · 화면 가장자리 클릭 또는 ←/→ 키로 페이지를 넘길 수 있습니다</div>
<audio id="ebook-tts-audio" controls style="display:none"></audio>
<div class="ebook-page-viewport" id="ebook-page-viewport">
<div class="ebook-page-spread" id="ebook-page-spread">
<div class="ebook-page-slot" id="ebook-page-left"></div>
<div class="ebook-page-slot" id="ebook-page-right"></div>
</div>
<div class="ebook-turn-zone left" id="ebook-prev-zone" onclick="ebookPrevPage()">‹</div>
<div class="ebook-turn-zone right" id="ebook-next-zone" onclick="ebookNextPage()">›</div>
<div class="ebook-page-num" id="ebook-loading-note"></div>
</div>
</div>
</div>
</div>
<!-- 오른쪽: 모델 선택 + 채팅 -->
<div class="right-chat" id="right-chat">
<div class="model-box">
<label>모델</label>
<select id="model-sel"><option value="">기본 모델</option></select>
<div id="chat-active-tag"></div>
</div>
<div class="chat-msgs" id="chat-msgs">
<div class="empty">왼쪽에서 전문가를 선택하면 여기서 대화가 시작됩니다.</div>
</div>
<div class="chat-input-row">
<textarea id="chat-input" placeholder="전문가에게 물어보세요..." onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendChat();}"></textarea>
<button class="chat-send" id="chat-send-btn" onclick="sendChat()">전송</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
const TOKEN_KEY='smallclaw_token';
function getToken(){try{return sessionStorage.getItem(TOKEN_KEY)||localStorage.getItem(TOKEN_KEY)||'';}catch{return '';}}
function authH(extra){const t=getToken();const h={...(extra||{})};if(t)h['Authorization']='Bearer '+t;return h;}
function escHtml(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
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{}}
function showToast(msg,isErr){const t=document.getElementById('toast');t.textContent=msg;t.className='toast show'+(isErr?' err':'');setTimeout(()=>t.classList.remove('show'),2500);}
const GENRES=[
{code:'poetry',label:'시'},
{code:'novel',label:'소설'},
{code:'essay',label:'수필'},
{code:'scenario',label:'시나리오'},
];
const EXPERTS_ELEMENT=[
{code:'theme',label:'주제'},
{code:'style',label:'문체'},
{code:'structure',label:'구성'},
{code:'setting',label:'배경'},
{code:'character',label:'인물'},
];
const EXPERTS_PROCESS=[
{code:'idea',label:'발상'},
{code:'edit',label:'퇴고'},
];
const ALL_EXPERTS=[...EXPERTS_ELEMENT,...EXPERTS_PROCESS];
const NOTE_TYPE_LABEL={character:'인물',setting:'배경/설정',timeline:'타임라인',term:'용어'};
let WORKS=[];
let CURRENT=null;
let saveTimer=null;
let currentTab='write';
let activeExpert=null;
let chatHistory=[]; // [{role,content}]
let chatBusy=false;
function renderGenreSwitch(){
const el=document.getElementById('genre-switch');
el.innerHTML=GENRES.map(g=>`<button class="genre-btn" data-g="${g.code}" onclick="setGenre('${g.code}')">${g.label}</button>`).join('');
}
function setGenre(code){
if(!CURRENT) return;
CURRENT.genre=code;
document.querySelectorAll('.genre-btn').forEach(b=>b.classList.toggle('active',b.dataset.g===code));
saveWork();
}
function renderExpertButtons(){
document.getElementById('expert-list-elements').innerHTML=EXPERTS_ELEMENT.map(e=>
`<button class="expert-btn" id="exp-${e.code}" onclick="selectExpert('${e.code}')">${e.label}</button>`).join('');
document.getElementById('expert-list-process').innerHTML=EXPERTS_PROCESS.map(e=>
`<button class="expert-btn process" id="exp-${e.code}" onclick="selectExpert('${e.code}')">${e.label}</button>`).join('');
}
function setTab(t){
currentTab=t;
['write','notes','read','stats','research','ebooks'].forEach(k=>{
document.getElementById('nav-'+k).classList.toggle('active',k===t);
document.getElementById('pane-'+k).style.display=(k===t)?'flex':'none';
});
document.getElementById('expert-block').style.display=(t==='write')?'':'none';
document.getElementById('layout').classList.toggle('no-chat',t!=='write');
if(t==='notes') loadNotes();
if(t==='stats') renderStats();
if(t==='ebooks' && !EBOOK_PROVIDERS.length) loadEbookProviders();
}
// ── 모델 목록 ──
async function loadModels(){
try{
const r=await fetch('/api/ollama/models',{headers:authH()});
const d=await r.json();
const sel=document.getElementById('model-sel');
if(d.success && Array.isArray(d.models) && d.models.length){
sel.innerHTML='<option value="">기본 모델</option>'+
d.models.map(m=>`<option value="${escHtml(m.name)}">${escHtml(m.name)}${m.vision?' 👁️':''}</option>`).join('');
}
}catch(e){ /* 모델 목록 못 가져와도 기본 모델로 계속 진행 가능 */ }
}
// ── 작품 ──
async function loadWorks(){
try{
const r=await fetch('/api/writer/works',{headers:authH()});
const d=await r.json();
if(!d.success) throw new Error(d.error||'목록 조회 실패');
WORKS=d.works||[];
const sel=document.getElementById('work-sel');
sel.innerHTML='<option value="">작품을 선택하세요...</option>'+
WORKS.map(w=>`<option value="${w.id}">${escHtml(w.title)} (${escHtml((GENRES.find(g=>g.code===w.genre)||{}).label||w.genre)})</option>`).join('');
let lastId=null; try{lastId=localStorage.getItem('writer_last_work');}catch{}
if(lastId && WORKS.some(w=>w.id===lastId)){ sel.value=lastId; await onWorkSelect(); }
else if(WORKS.length){ sel.value=WORKS[0].id; await onWorkSelect(); }
}catch(e){ showToast('오류: '+e.message,true); }
}
async function createWork(){
const title=prompt('작품 제목');
if(!title||!title.trim()) return;
try{
const r=await fetch('/api/writer/works',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({title,genre:'novel'})});
const d=await r.json();
if(!d.success) throw new Error(d.error||'생성 실패');
await loadWorks();
document.getElementById('work-sel').value=d.work.id;
await onWorkSelect();
showToast('작품이 생성되었습니다');
}catch(e){ showToast('오류: '+e.message,true); }
}
async function deleteCurrentWork(){
if(!CURRENT) return;
if(!confirm(`"${CURRENT.title}"을(를) 삭제할까요? 딸린 노트도 함께 삭제됩니다.`)) return;
try{
await fetch('/api/writer/works/'+CURRENT.id,{method:'DELETE',headers:authH()});
CURRENT=null;
try{localStorage.removeItem('writer_last_work');}catch{}
await loadWorks();
if(!WORKS.length){ document.getElementById('draft').value=''; document.getElementById('work-meta').textContent=''; }
showToast('삭제됨');
}catch(e){ showToast('오류: '+e.message,true); }
}
async function onWorkSelect(){
const id=document.getElementById('work-sel').value;
resetChat();
if(!id){ CURRENT=null; document.getElementById('draft').value=''; document.getElementById('work-del-btn').style.display='none'; return; }
try{
const r=await fetch('/api/writer/works/'+id,{headers:authH()});
const d=await r.json();
if(!d.success) throw new Error(d.error||'조회 실패');
CURRENT=d.work;
try{localStorage.setItem('writer_last_work',id);}catch{}
document.getElementById('draft').value=CURRENT.content||'';
document.querySelectorAll('.genre-btn').forEach(b=>b.classList.toggle('active',b.dataset.g===CURRENT.genre));
document.getElementById('work-del-btn').style.display='';
updateWorkMeta();
if(currentTab==='notes') loadNotes();
if(currentTab==='stats') renderStats();
}catch(e){ showToast('오류: '+e.message,true); }
}
function updateWorkMeta(){
if(!CURRENT) return;
const chars=(document.getElementById('draft').value||'').length;
document.getElementById('work-meta').textContent=`${chars.toLocaleString()}자`;
}
function onDraftInput(){
updateWorkMeta();
if(saveTimer) clearTimeout(saveTimer);
saveTimer=setTimeout(saveWork,1200);
}
async function saveWork(){
if(!CURRENT) return;
const content=document.getElementById('draft').value;
try{
const r=await fetch('/api/writer/works/'+CURRENT.id,{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({content,genre:CURRENT.genre,title:CURRENT.title})});
const d=await r.json();
if(d.success){ CURRENT=d.work; }
}catch(e){ /* 조용히 실패 — 다음 입력에서 재시도됨 */ }
}
// ── 전문가 채팅 ──
function resetChat(){
activeExpert=null;
chatHistory=[];
document.querySelectorAll('.expert-btn').forEach(b=>b.classList.remove('active'));
document.getElementById('chat-active-tag').innerHTML='';
document.getElementById('chat-msgs').innerHTML='<div class="empty">왼쪽에서 전문가를 선택하면 여기서 대화가 시작됩니다.</div>';
}
function selectExpert(code){
if(!CURRENT){ showToast('먼저 작품을 선택/생성하세요',true); return; }
activeExpert=code;
chatHistory=[];
document.querySelectorAll('.expert-btn').forEach(b=>b.classList.toggle('active',b.id==='exp-'+code));
const label=(ALL_EXPERTS.find(e=>e.code===code)||{}).label||code;
document.getElementById('chat-active-tag').innerHTML=`<span class="chat-active-tag">${label} 전문가와 대화중</span>`;
document.getElementById('chat-msgs').innerHTML='';
sendChat(true); // 첫 피드백 자동 요청
}
function renderChat(){
const el=document.getElementById('chat-msgs');
el.innerHTML=chatHistory.map(m=>`<div class="msg ${m.role}">${escHtml(m.content)}</div>`).join('');
el.scrollTop=el.scrollHeight;
}
async function sendChat(isFirst){
if(!activeExpert || chatBusy) return;
const input=document.getElementById('chat-input');
const message=isFirst?'':input.value.trim();
if(!isFirst && !message) return;
if(!isFirst){ chatHistory.push({role:'user',content:message}); input.value=''; }
renderChat();
chatBusy=true;
document.getElementById('chat-send-btn').disabled=true;
const el=document.getElementById('chat-msgs');
el.insertAdjacentHTML('beforeend','<div class="msg assistant pending" id="pending-msg">생각하는 중...</div>');
el.scrollTop=el.scrollHeight;
try{
await saveWork();
const model=document.getElementById('model-sel').value;
const r=await fetch('/api/writer/expert',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({
expertType:activeExpert, workId:CURRENT.id, content:document.getElementById('draft').value,
message, history:chatHistory.slice(0,-1), model,
})});
const d=await r.json();
if(!d.success) throw new Error(d.error||'요청 실패');
chatHistory.push({role:'assistant',content:d.feedback});
renderChat();
}catch(e){
document.getElementById('pending-msg')?.remove();
showToast('오류: '+e.message,true);
if(!isFirst) chatHistory.pop();
renderChat();
}finally{
chatBusy=false;
document.getElementById('chat-send-btn').disabled=false;
}
}
// ── 캐릭터/세계관 노트 ──
async function loadNotes(){
const el=document.getElementById('notes-list');
if(!CURRENT){ el.innerHTML='<div class="empty">먼저 작품을 선택하세요.</div>'; return; }
el.innerHTML='<div class="empty">불러오는 중...</div>';
try{
const r=await fetch('/api/writer/notes/'+CURRENT.id,{headers:authH()});
const d=await r.json();
if(!d.success) throw new Error(d.error||'조회 실패');
if(!d.notes.length){ el.innerHTML='<div class="empty">아직 노트가 없습니다. 위 버튼으로 추가해보세요.</div>'; return; }
el.innerHTML=d.notes.map(n=>`
<div class="note-card" data-id="${n.id}" style="margin-bottom:10px">
<div class="nc-top">
<span class="note-badge">${NOTE_TYPE_LABEL[n.type]||n.type}</span>
<b>${escHtml(n.name)}</b>
<div class="nc-actions">
<button class="icon-x" onclick="saveNoteEdit('${n.id}')" title="저장">💾</button>
<button class="icon-x" onclick="deleteNote('${n.id}')" title="삭제">🗑️</button>
</div>
</div>
<textarea id="note-content-${n.id}">${escHtml(n.content)}</textarea>
</div>`).join('');
}catch(e){ el.innerHTML='<div class="empty">오류: '+escHtml(e.message)+'</div>'; }
}
async function addNote(type){
if(!CURRENT){ showToast('먼저 작품을 선택하세요',true); return; }
const name=prompt((NOTE_TYPE_LABEL[type]||type)+' 이름');
if(!name||!name.trim()) return;
try{
const r=await fetch('/api/writer/notes/'+CURRENT.id,{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({type,name,content:''})});
const d=await r.json();
if(!d.success) throw new Error(d.error||'추가 실패');
loadNotes();
}catch(e){ showToast('오류: '+e.message,true); }
}
async function saveNoteEdit(noteId){
const content=document.getElementById('note-content-'+noteId).value;
try{
const r=await fetch('/api/writer/notes/'+CURRENT.id+'/'+noteId,{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({content})});
const d=await r.json();
if(!d.success) throw new Error(d.error||'저장 실패');
showToast('저장됨');
}catch(e){ showToast('오류: '+e.message,true); }
}
async function deleteNote(noteId){
if(!confirm('이 노트를 삭제할까요?')) return;
try{
await fetch('/api/writer/notes/'+CURRENT.id+'/'+noteId,{method:'DELETE',headers:authH()});
loadNotes();
}catch(e){ showToast('오류: '+e.message,true); }
}
// ── 낭독 ──
async function readAloud(){
if(!CURRENT){ showToast('먼저 작품을 선택하세요',true); return; }
const text=document.getElementById('draft').value.trim();
if(!text){ showToast('원고 내용이 없습니다',true); return; }
const btn=document.getElementById('tts-btn');
btn.disabled=true;
document.getElementById('tts-status').textContent='음성 합성 중...';
try{
const r=await fetch('/api/voice/tts',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({text:text.slice(0,4000)})});
if(!r.ok){ const d=await r.json().catch(()=>({})); throw new Error(d.error||'TTS 실패'); }
const blob=await r.blob();
const url=URL.createObjectURL(blob);
const audio=document.getElementById('tts-audio');
audio.src=url;
audio.style.display='block';
audio.play().catch(()=>{});
document.getElementById('tts-status').textContent=text.length>4000?'앞 4000자만 재생됩니다':'';
}catch(e){
document.getElementById('tts-status').textContent='';
showToast('오류: '+e.message,true);
}finally{ btn.disabled=false; }
}
// ── 작품 통계 ──
function renderStats(){
const grid=document.getElementById('stat-grid');
const histEl=document.getElementById('hist-list');
if(!CURRENT){ grid.innerHTML=''; histEl.innerHTML='<div class="empty">먼저 작품을 선택하세요.</div>'; return; }
const text=document.getElementById('draft').value||'';
const chars=text.length;
const charsNoSpace=text.replace(/\s/g,'').length;
const dialogueMatches=text.match(/["'“‘][^"'”’]*["'”’]/g)||[];
const dialogueChars=dialogueMatches.reduce((s,m)=>s+m.length,0);
const dialoguePct=chars?Math.round(dialogueChars/chars*100):0;
grid.innerHTML=`
<div class="stat-tile"><div class="v">${chars.toLocaleString()}</div><div class="l">전체 글자수</div></div>
<div class="stat-tile"><div class="v">${charsNoSpace.toLocaleString()}</div><div class="l">공백 제외</div></div>
<div class="stat-tile"><div class="v">${dialoguePct}%</div><div class="l">대사 비중(추정)</div></div>
<div class="stat-tile"><div class="v">${(CURRENT.history||[]).length}</div><div class="l">저장 스냅샷 수</div></div>
`;
const hist=(CURRENT.history||[]).slice().reverse();
if(!hist.length){ histEl.innerHTML='<div class="empty">아직 집필 추이 기록이 없습니다 — 저장할 때마다 하루 단위로 쌓입니다.</div>'; return; }
histEl.innerHTML='<div style="font-size:11px;color:var(--muted);font-weight:700;margin-bottom:4px">집필 추이</div>'+
hist.map(h=>`<div class="hist-row"><span>${new Date(h.t).toLocaleDateString('ko-KR')}</span><span>${h.chars.toLocaleString()}자</span></div>`).join('');
}
// ── 자료조사 ──
async function doResearch(){
const q=document.getElementById('research-q').value.trim();
if(!q) return;
const el=document.getElementById('research-list');
el.innerHTML='<div class="empty">검색 중...</div>';
try{
const r=await fetch('/api/writer/research',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({query:q})});
const d=await r.json();
if(!d.success) throw new Error(d.error||'검색 실패');
if(!d.results.length){ el.innerHTML='<div class="empty">검색 결과가 없습니다.</div>'; return; }
el.innerHTML=d.results.map(it=>`
<div class="research-item">
<a href="${escHtml(it.url)}" target="_blank" rel="noopener">${escHtml(it.title||it.url)}</a>
<p>${escHtml((it.snippet||'').slice(0,200))}</p>
</div>`).join('');
}catch(e){ el.innerHTML='<div class="empty">오류: '+escHtml(e.message)+'</div>'; }
}
// ── 서재 (E-Book: Gutenberg/Google Books/알라딘) ──
let ebookReaderState=null; // {url, offset, title}
let EBOOK_PROVIDERS=[];
let activeProvider='gutenberg';
async function loadEbookProviders(){
try{
const r=await fetch('/api/writer/ebooks/providers',{headers:authH()});
const d=await r.json();
if(!d.success) throw new Error(d.error||'조회 실패');
EBOOK_PROVIDERS=d.providers;
renderProviderTabs();
}catch(e){ /* 탭 안 열어봤으면 조용히 무시 */ }
}
function renderProviderTabs(){
const el=document.getElementById('provider-tabs');
el.innerHTML=EBOOK_PROVIDERS.map(p=>`
<button class="provider-tab${p.id===activeProvider?' active':''}${p.needsKey&&!p.configured?' needs-key':''}" onclick="selectProvider('${p.id}')">
${escHtml(p.name)} <span class="pcountry">${escHtml(p.country)}</span>
${p.needsKey&&!p.configured?'<span class="key-badge">키 필요</span>':''}
</button>`).join('');
}
async function selectProvider(id){
const p=EBOOK_PROVIDERS.find(x=>x.id===id);
if(!p) return;
if(p.needsKey && !p.configured){
const key=prompt(`${p.name} API 키를 입력하세요(한 번만 입력하면 서버에 안전하게 저장됩니다):`);
if(!key||!key.trim()) return;
try{
const r=await fetch('/api/writer/ebooks/providers/'+id+'/key',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({apiKey:key.trim()})});
const d=await r.json();
if(!d.success) throw new Error(d.error||'저장 실패');
showToast(p.name+' 키가 저장되었습니다');
await loadEbookProviders();
}catch(e){ showToast('오류: '+e.message,true); return; }
}
activeProvider=id;
renderProviderTabs();
document.getElementById('ebook-results').innerHTML='';
document.getElementById('ebook-attribution').textContent='';
}
async function doEbookSearch(){
const q=document.getElementById('ebook-q').value.trim();
if(!q) return;
closeEbookReader();
const el=document.getElementById('ebook-results');
el.innerHTML='<div class="empty">검색 중...</div>';
document.getElementById('ebook-attribution').textContent='';
try{
const r=await fetch(`/api/writer/ebooks/search?provider=${activeProvider}&q=`+encodeURIComponent(q),{headers:authH()});
const d=await r.json();
if(d.needsKey){ el.innerHTML='<div class="empty">이 공급자는 API 키 설정이 필요합니다 — 위 탭을 눌러 키를 입력해주세요.</div>'; return; }
if(!d.success) throw new Error(d.error||'검색 실패');
if(d.attribution) document.getElementById('ebook-attribution').textContent=d.attribution;
if(!d.books.length){ el.innerHTML='<div class="empty">검색 결과가 없습니다.</div>'; return; }
el.innerHTML=d.books.map(b=>`
<div class="ebook-card">
${b.cover?`<img src="${escHtml(b.cover)}" alt="" loading="lazy">`:'<div style="aspect-ratio:2/3;background:var(--panel-2);border-radius:6px"></div>'}
<div class="et">${escHtml(b.title)}</div>
<div class="ea">${escHtml(b.authors||'저자 미상')}</div>
<div class="eb-actions">
${b.textUrl?`<button onclick='openEbookReader(${JSON.stringify(b.textUrl)},${JSON.stringify(b.title)})'>읽기</button>`:''}
${b.epubUrl?`<button onclick='downloadEbookToServer(${JSON.stringify(b.epubUrl)},${JSON.stringify(b.title)},${JSON.stringify(b.authors||'')},${JSON.stringify(activeProvider)},${JSON.stringify(b.textUrl||'')},this)'>다운로드</button>`:''}
${!b.epubUrl&&!b.textUrl&&b.link?`<a href="${escHtml(b.link)}" target="_blank" rel="noopener">보러가기</a>`:''}
</div>
</div>`).join('');
}catch(e){ el.innerHTML='<div class="empty">오류: '+escHtml(e.message)+'</div>'; }
}
// ── 서재 뷰 전환(검색/내 서재) + 서버사이드 다운로드 보관 ──
function setEbookView(v){
document.getElementById('ebook-view-search').classList.toggle('active',v==='search');
document.getElementById('ebook-view-library').classList.toggle('active',v==='library');
document.getElementById('ebook-search-view').style.display=v==='search'?'flex':'none';
document.getElementById('ebook-library-view').style.display=v==='library'?'flex':'none';
if(v==='library') loadEbookLibrary();
}
async function downloadEbookToServer(url,title,authors,source,readUrl,btn){
if(btn){ btn.disabled=true; btn.textContent='저장 중...'; }
try{
const r=await fetch('/api/writer/ebooks/download',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({url,title,authors,source,readUrl})});
const d=await r.json();
if(!d.success) throw new Error(d.error||'다운로드 실패');
showToast('내 서재에 저장되었습니다');
if(btn){ btn.textContent='저장됨 ✓'; }
}catch(e){
showToast('오류: '+e.message,true);
if(btn){ btn.disabled=false; btn.textContent='다운로드'; }
}
}
async function loadEbookLibrary(){
const el=document.getElementById('ebook-library-list');
el.innerHTML='<div class="empty">불러오는 중...</div>';
try{
const r=await fetch('/api/writer/ebooks/library',{headers:authH()});
const d=await r.json();
if(!d.success) throw new Error(d.error||'조회 실패');
if(!d.books.length){ el.innerHTML='<div class="empty">아직 저장한 책이 없습니다 — 검색 탭에서 다운로드해보세요.</div>'; return; }
el.innerHTML=d.books.map(b=>`
<div class="ebook-card">
<div class="et">${escHtml(b.title)}</div>
<div class="ea">${escHtml(b.authors||'저자 미상')}</div>
<div class="ea">${(b.sizeBytes/1024).toFixed(0)}KB · ${new Date(b.downloadedAt).toLocaleDateString('ko-KR')}</div>
<div class="eb-actions">
${b.readUrl?`<button onclick='openEbookReader(${JSON.stringify(b.readUrl)},${JSON.stringify(b.title)})'>읽기</button>`:''}
<a href="/api/writer/ebooks/library/${b.id}/file">받기</a>
<button onclick="deleteEbookFromLibrary('${b.id}')">삭제</button>
</div>
</div>`).join('');
}catch(e){ el.innerHTML='<div class="empty">오류: '+escHtml(e.message)+'</div>'; }
}
async function deleteEbookFromLibrary(id){
if(!confirm('이 책을 서재에서 삭제할까요?')) return;
try{
await fetch('/api/writer/ebooks/library/'+id,{method:'DELETE',headers:authH()});
loadEbookLibrary();
}catch(e){ showToast('오류: '+e.message,true); }
}
// 페이지 넘김 리더 상태.
// 처음엔 caretRangeFromPoint로 각 페이지에 정확히 들어갈 글자 구간을 실측했으나, 크로미움
// 멀티컬럼 클리핑 버그·뷰포트 밖 좌표 히트테스트 실패·문단 경계에서 창을 자르며 생기는
// 자투리 페이지 등 엣지케이스가 계속 나왔다. 그래서 실측을 포기하고, 페이지 크기(가로/세로
// 픽셀)에서 평균 글자폭·줄높이로 대략 몇 글자가 들어갈지 계산해 그 글자수로 그냥 자른다.
// 책마다, 페이지마다 하단 여백이 균일하지 않을 수 있지만(글자수 기준일 뿐 실제 줄바꿈은
// 안 재보므로) 버그 표면적이 훨씬 작다 — 사용자 요청으로 단순한 방식을 택함(2026-07-22).
let ebookPager={pageW:0,spread:1,charsPerPage:1500,pageOffsets:[0],pageIdx:0,fetching:false};
async function openEbookReader(url,title){
const returnView=document.getElementById('ebook-view-library').classList.contains('active')?'library':'search';
ebookReaderState={url,title,fullText:'',totalLength:0,nextOffset:null,returnView};
ebookPager={pageW:0,spread:1,charsPerPage:1500,pageOffsets:[0],pageIdx:0,fetching:false};
document.getElementById('ebook-search-view').style.display='none';
document.getElementById('ebook-library-view').style.display='none';
document.getElementById('ebook-reader').style.display='flex';
document.getElementById('ebook-reader-title').textContent=title;
document.getElementById('ebook-page-left').textContent='불러오는 중...';
document.getElementById('ebook-page-right').textContent='';
await loadMoreEbookText(true);
}
function closeEbookReader(){
const returnView=ebookReaderState?.returnView||'search';
ebookReaderState=null;
document.getElementById('ebook-reader').style.display='none';
setEbookView(returnView);
}
async function loadMoreEbookText(replace,extraParams){
if(!ebookReaderState) return;
try{
const params=extraParams||`offset=${replace?0:ebookReaderState.nextOffset}`;
const r=await fetch(`/api/writer/ebooks/read?url=${encodeURIComponent(ebookReaderState.url)}&${params}`,{headers:authH()});
const d=await r.json();
if(!d.success) throw new Error(d.error||'읽기 실패');
if(replace){ ebookReaderState.fullText=d.text; ebookPager.pageIdx=0; }
else{ ebookReaderState.fullText+=d.text; }
if(d.totalLength){
ebookReaderState.totalLength=d.totalLength;
const pct=Math.round((d.offset/d.totalLength)*100);
document.getElementById('ebook-jump-range').value=pct;
document.getElementById('ebook-jump-label').textContent=pct+'%';
}
ebookReaderState.nextOffset=d.nextOffset;
computeEbookLayout();
}catch(e){ showToast('오류: '+e.message,true); }
}
function jumpEbookTo(percent){
if(!ebookReaderState) return;
document.getElementById('ebook-page-left').textContent='이동 중...';
document.getElementById('ebook-page-right').textContent='';
loadMoreEbookText(true,`percent=${percent}`);
}
// 챕터/장/절 제목("CHAPTER I.", "PART ONE" 등, 줄 전체가 그 내용뿐인 경우)이 나오는
// 글자 위치를 찾는다 — 실제 책처럼 챕터가 항상 새 페이지에서 시작하게 하기 위함. 책마다
// 표기가 달라 완벽할 수 없는 휴리스틱이지만, 영어 Gutenberg 텍스트에서 가장 흔한 패턴이다.
function findChapterOffsets(text){
const offsets=[];
const re=/^[ \t]*(?:CHAPTER|PART|BOOK|VOLUME)\s+[IVXLCDM0-9]+\.?[ \t]*$/gim;
let m;
while((m=re.exec(text))) offsets.push(m.index);
return offsets;
}
// 목표 글자수 지점에서 끊되, 우선순위는: ① 챕터 제목이 걸리면 그 앞에서(챕터는 항상 새
// 페이지에서 시작) ② 근처(최대 200자 앞쪽)에 문단 경계(빈 줄)가 있으면 거기서(문단 중간에
// 안 끊기도록) ③ 그것도 없으면 가장 가까운 공백에서(최소한 단어는 안 끊기도록 — 그냥 글자수
// 로 딱 자르면 "furlough"가 "furlo"/"ugh"로 잘려나가 보기 흉했다). DOM 측정 없는 순수
// 문자열 검색이라 가볍고, 크로미움 렌더링 버그와도 무관하다.
function computePageOffsets(text,charsPerPage,chapterOffsets){
const offsets=[0];
let pos=0;
while(pos<text.length){
let next=Math.min(text.length,pos+charsPerPage);
const forced=chapterOffsets.find(c=>c>pos+20 && c<next);
if(forced!=null){
next=forced;
}else if(next<text.length){
const paraBack=Math.max(pos+Math.floor(charsPerPage*0.5),next-200);
const paraIdx=text.lastIndexOf('\n\n',next-1);
if(paraIdx>=paraBack && paraIdx>pos){
next=paraIdx+2;
}else if(!/\s/.test(text[next-1]) && !/\s/.test(text[next])){
let snap=-1;
const back=Math.max(pos+1,next-40);
for(let i=next;i>back;i--){ if(/\s/.test(text[i-1])){ snap=i; break; } }
if(snap===-1){
const fwd=Math.min(text.length,next+40);
for(let i=next;i<fwd;i++){ if(/\s/.test(text[i])){ snap=i+1; break; } }
}
if(snap>pos) next=snap;
}
}
offsets.push(next);
pos=next;
}
return offsets;
}
// 페이지 "내용"(페이지당 글자수)은 창 크기와 무관하게 고정된 기준 치수로만 계산한다 —
// 그래야 브라우저 창 크기를 바꿔도 페이지 구분이 흔들리지 않는다(2026-07-22 사용자 요청).
// 화면에는 이 고정 크기 그대로 그린 뒤 CSS transform:scale로 뷰포트에 맞춰 확대/축소만
// 한다 — 고정 레이아웃 PDF/이북처럼 내용은 안 변하고 화면에 맞춰 배율만 바뀌는 방식.
const REF_ASPECT=0.66, REF_SPREAD_PAGE_W=420, REF_SPREAD_GAP=56, REF_NARROW_PAGE_W=380;
function computeEbookLayout(){
const viewport=document.getElementById('ebook-page-viewport');
const spreadEl=document.getElementById('ebook-page-spread');
if(!viewport||!spreadEl||!ebookReaderState) return;
const vw=viewport.clientWidth, vh=viewport.clientHeight;
if(vw<10||vh<10) return;
const padX=40, padY=30;
const narrow=vw<760;
const spread=narrow?1:2;
const gap=narrow?0:REF_SPREAD_GAP;
const pageW=narrow?REF_NARROW_PAGE_W:REF_SPREAD_PAGE_W;
const bookH=Math.floor(pageW/REF_ASPECT);
const bookW=spread===2?pageW*2+gap:pageW;
const scale=Math.min(vw/bookW,vh/bookH);
spreadEl.style.width=bookW+'px';
spreadEl.style.height=bookH+'px';
spreadEl.style.left='0';
spreadEl.style.top='0';
spreadEl.style.transformOrigin='top left';
spreadEl.style.transform=`translate(${Math.round((vw-bookW*scale)/2)}px, ${Math.round((vh-bookH*scale)/2)}px) scale(${scale})`;
const pageInnerH=bookH-padY*2, pageInnerW=pageW-padX*2;
const LINE_H=26.8, AVG_CHAR_W=7.6, FILL=0.85; // 넘치지 않게 여유를 두고 어림(overflow:hidden이 안전망)
const lines=Math.max(1,Math.floor(pageInnerH/LINE_H));
const cols=Math.max(1,Math.floor(pageInnerW/AVG_CHAR_W));
const charsPerPage=Math.max(200,Math.floor(lines*cols*FILL));
// 페이지당 글자수 자체는 이제 고정이라 리사이즈로 안 바뀌지만(위 주석 참고), 좁은/넓은
// 화면 전환(spread 모드 변경) 시에는 바뀔 수 있어 읽던 위치(글자 오프셋)는 유지한다.
const charOffset=(ebookPager.pageOffsets&&ebookPager.pageOffsets[ebookPager.pageIdx])||0;
const chapterOffsets=findChapterOffsets(ebookReaderState.fullText);
const pageOffsets=computePageOffsets(ebookReaderState.fullText,charsPerPage,chapterOffsets);
let pageIdx=0;
while(pageIdx<pageOffsets.length-1 && pageOffsets[pageIdx+1]<=charOffset) pageIdx++;
ebookPager={...ebookPager,pageW,spread,charsPerPage,pageOffsets,pageIdx};
viewport.classList.toggle('spread',spread===2);
document.getElementById('ebook-page-right').classList.toggle('hidden',spread===1);
renderLeaves();
}
function renderLeaves(){
const p=ebookPager;
const fullText=ebookReaderState.fullText;
const totalPages=p.pageOffsets.length-1;
const maxPageIdx=Math.max(0,p.spread===2?Math.floor((totalPages-1)/2)*2:totalPages-1);
p.pageIdx=Math.min(Math.max(0,p.pageIdx),maxPageIdx);
const setSlot=(el,idx)=>{
const text=idx<totalPages?fullText.slice(p.pageOffsets[idx],p.pageOffsets[idx+1]):'';
if(el.textContent===text) return;
el.classList.add('fading');
requestAnimationFrame(()=>{
el.textContent=text;
requestAnimationFrame(()=>el.classList.remove('fading'));
});
};
setSlot(document.getElementById('ebook-page-left'),p.pageIdx);
if(p.spread===2) setSlot(document.getElementById('ebook-page-right'),p.pageIdx+1);
const hasMoreServer=ebookReaderState.nextOffset!=null;
const lastPageShown=p.pageIdx+p.spread-1;
const atStart=p.pageIdx<=0;
const atEnd=lastPageShown>=totalPages-1 && !hasMoreServer;
document.getElementById('ebook-prev-zone').classList.toggle('disabled',atStart);
document.getElementById('ebook-next-zone').classList.toggle('disabled',atEnd);
const from=p.pageIdx+1;
const to=Math.min(totalPages,from+p.spread-1);
const label=document.getElementById('ebook-page-label');
if(ebookReaderState.totalLength){
const estTotal=Math.max(totalPages,Math.round(ebookReaderState.totalLength/p.charsPerPage));
label.textContent=(from===to?`${from}p`:`${from}-${to}p`)+(hasMoreServer?` / 약${estTotal}p`:` / ${estTotal}p`);
}else{
label.textContent=(from===to?`${from}p`:`${from}-${to}p`);
}
// 로드된 텍스트의 마지막 스프레드 근처면 다음 서버 청크를 미리 당겨온다
if(!p.fetching && hasMoreServer && lastPageShown>=totalPages-2){
p.fetching=true;
loadMoreEbookText(false).finally(()=>{ ebookPager.fetching=false; });
}
}
async function ebookNextPage(){
const p=ebookPager;
const totalPages=p.pageOffsets.length-1;
const maxPageIdx=Math.max(0,p.spread===2?Math.floor((totalPages-1)/2)*2:totalPages-1);
if(p.pageIdx<maxPageIdx){ p.pageIdx+=p.spread; renderLeaves(); return; }
if(!ebookReaderState||p.fetching||ebookReaderState.nextOffset==null) return;
p.fetching=true;
document.getElementById('ebook-loading-note').textContent='다음 페이지 불러오는 중...';
await loadMoreEbookText(false);
p.fetching=false;
document.getElementById('ebook-loading-note').textContent='';
const newTotalPages=ebookPager.pageOffsets.length-1;
if(ebookPager.pageIdx+ebookPager.spread<newTotalPages){ ebookPager.pageIdx+=ebookPager.spread; renderLeaves(); }
}
function ebookPrevPage(){
const p=ebookPager;
if(p.pageIdx>0){ p.pageIdx=Math.max(0,p.pageIdx-p.spread); renderLeaves(); }
}
window.addEventListener('resize',()=>{ if(ebookReaderState) computeEbookLayout(); });
document.addEventListener('keydown',(e)=>{
if(!ebookReaderState) return;
if(document.getElementById('ebook-reader').style.display==='none') return;
const tag=(document.activeElement&&document.activeElement.tagName)||'';
if(tag==='INPUT'||tag==='TEXTAREA') return;
if(e.key==='ArrowRight'){ ebookNextPage(); }
else if(e.key==='ArrowLeft'){ ebookPrevPage(); }
});
async function readEbookAloud(){
const left=document.getElementById('ebook-page-left').textContent||'';
const right=ebookPager.spread===2?(document.getElementById('ebook-page-right').textContent||''):'';
const text=(left+' '+right).trim();
if(!text){ showToast('읽을 내용이 없습니다',true); return; }
const btn=document.getElementById('ebook-tts-btn');
btn.disabled=true;
document.getElementById('ebook-tts-status').textContent='음성 합성 중...';
try{
const r=await fetch('/api/writer/ebooks/tts',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({text:text.slice(0,3000)})});
if(!r.ok){ const d=await r.json().catch(()=>({})); throw new Error(d.error||'낭독 실패'); }
const blob=await r.blob();
const url=URL.createObjectURL(blob);
const audio=document.getElementById('ebook-tts-audio');
audio.src=url;
audio.style.display='block';
audio.play().catch(()=>{});
document.getElementById('ebook-tts-status').textContent=text.length>3000?'현재 화면의 앞 3000자만 재생됩니다':'';
}catch(e){
document.getElementById('ebook-tts-status').textContent='';
showToast('오류: '+e.message,true);
}finally{ btn.disabled=false; }
}
// ── INIT ──
renderGenreSwitch();
renderExpertButtons();
loadModels();
loadWorks();
</script>
</body>
</html>