Release 2.9.1: PPTX wizard project persistence + PDF column extraction
- PDF: PyMuPDF 2-column aware extraction (left col → right col, drop cap merge, header/footer/footnote removal), reference stripping, 200k char limit - Wizard: chunked parallel translation (15k chars/chunk, ~4x faster) - Wizard: save UPL_ (upload) papers to papers.json (were silently skipped) - Wizard: restore UPL_ papers from manifest with _isUpload/_localPdf fields - Wizard: project-papers endpoint correctly resolves UPL_ ko.txt in project dir - Wizard: images loaded on manifest restore (renderImgGrid unconditional) - Wizard: outline.json auto-save after generation and slide edits - Wizard: outline.json fallback load when no PPTX exists - Wizard: loadProjList() refresh after savePapersToProject() - Code editor: default font size 14→12px Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+616
-40
@@ -1245,7 +1245,7 @@ const TOOL_BLOCKS: Record<string, string> = {
|
||||
|
||||
desktop: `DESKTOP TOOLS: desktop_screenshot() → capture+OCR. desktop_find_window(name) → find window. desktop_focus_window(name) → bring to front (use SHORT process name: msedge, chrome, code). desktop_click(x,y,button) → click coords (button: "left" or "right"). desktop_type(text) → type. desktop_press_key(key) → key combo. desktop_drag(x1,y1,x2,y2) → drag. desktop_get_clipboard()/set_clipboard(text). Always screenshot first. Focus window before click/type. Fail twice on focus → stop and report. IMAGE DOWNLOAD: prefer shell("curl -o <path> <url>") or shell("python -c ...urllib...") for downloading images. Only use desktop right-click (desktop_click(x,y,"right") → screenshot → click "Save as") as a last resort when shell download fails due to auth or hotlink protection.`,
|
||||
|
||||
files: `FILE TOOLS: read_file(filename) → contents+line numbers. replace_lines(f,start,end,content) → surgical edit. insert_after(f,line,content). delete_lines(f,start,end). find_replace(f,find,replace). create_file(f,content) → new only (fails if exists). delete_file(f). list_files(dir?). RULES: list first; read before edit; surgical edits only — never rewrite whole file for small changes. CODE FILES: When writing code (scripts, programs, source files), always save under the code directory: create_file("code/filename", content). This keeps code files organized and visible in the Code editor tab. FILE LINKS — STRICT: NEVER manually construct /api/files/... paths from memory or assumption. Always use the EXACT path returned by the tool that created/saved the file. If you don't have a path, call list_files FIRST to find the actual location, then build the link from that result. Common locations (for context only — verify before using): Code files → code/, UI uploads → uploads/, email attachments → attachments/uid-{N}/, pubmed_fulltext/shell-saved files → workspace root or task subfolder, PPTX → <project-folder>/. Wrong-folder guesses produce broken download buttons. EXCEPTION: MCP tool results (e.g. dental-dict-sqlite) that return an image_url field — output that value directly as  markdown, no list_files needed.`,
|
||||
files: `FILE TOOLS: read_file(filename) → contents+line numbers. create_file(f,content) → create new file (if file exists, auto-converts to full replace_lines edit). replace_lines(f,start,end,content) → surgical edit. insert_after(f,line,content). delete_lines(f,start,end). find_replace(f,find,replace). delete_file(f). list_files(dir?). RULES: list first; read before edit; for small changes prefer replace_lines or insert_after over rewriting the whole file. CODE FILES: When writing code (scripts, programs, source files), always save under the code directory. For NEW files use create_file("code/filename", content). For EDITING existing code files use replace_lines or insert_after with the same "code/filename" path. This keeps code files organized and visible in the Code editor tab. FILE LINKS — STRICT: NEVER manually construct /api/files/... paths from memory or assumption. Always use the EXACT path returned by the tool that created/saved the file. If you don't have a path, call list_files FIRST to find the actual location, then build the link from that result. Common locations (for context only — verify before using): Code files → code/, UI uploads → uploads/, email attachments → attachments/uid-{N}/, pubmed_fulltext/shell-saved files → workspace root or task subfolder, PPTX → <project-folder>/. Wrong-folder guesses produce broken download buttons. EXCEPTION: MCP tool results (e.g. dental-dict-sqlite) that return an image_url field — output that value directly as  markdown, no list_objects needed.`,
|
||||
|
||||
task: `TASK TOOLS: task_control(action,...) actions: list/get/resume/rerun/pause/delete. start_task(title,prompt) → launch new background task. Check for existing tasks first before creating — never duplicate. Do NOT use read_file to check task state.`,
|
||||
|
||||
@@ -1575,7 +1575,7 @@ function buildTools() {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'create_file',
|
||||
description: 'Create a NEW file with content. Only use for files that do NOT exist yet.',
|
||||
description: 'Create a NEW file with content. If the file already exists, the content is applied as a full-file edit via replace_lines instead. For editing existing files, prefer replace_lines or insert_after directly.',
|
||||
parameters: {
|
||||
type: 'object', required: ['filename', 'content'],
|
||||
properties: {
|
||||
@@ -3281,9 +3281,58 @@ print(json.dumps({'slides': slides, 'total': len(prs.slides)}, ensure_ascii=Fals
|
||||
}
|
||||
const filePath = path.join(workspacePath, filename);
|
||||
if (!isPathInsideDir(workspacePath, filePath)) return { name, args, result: 'Access denied: path escapes workspace', error: true };
|
||||
if (fs.existsSync(filePath)) return { name, args, result: `"${filename}" already exists. Use replace_lines or insert_after to edit.`, error: true };
|
||||
if (fs.existsSync(filePath)) {
|
||||
// File exists — compute a surgical diff and apply only the changed lines,
|
||||
// then tell the model to use replace_lines/insert_after next time.
|
||||
console.log(`[v2] create_file("${filename}"): file exists, auto-converting to diff edit`);
|
||||
const oldLines = fs.readFileSync(filePath, 'utf-8').split('\n');
|
||||
const newLines = (args.content || '').split('\n');
|
||||
// Find common prefix length (unchanged lines at the start)
|
||||
let prefixLen = 0;
|
||||
while (prefixLen < oldLines.length && prefixLen < newLines.length && oldLines[prefixLen] === newLines[prefixLen]) prefixLen++;
|
||||
// Find common suffix length (unchanged lines at the end), not overlapping the prefix
|
||||
let suffixLen = 0;
|
||||
while (
|
||||
suffixLen < (oldLines.length - prefixLen) &&
|
||||
suffixLen < (newLines.length - prefixLen) &&
|
||||
oldLines[oldLines.length - 1 - suffixLen] === newLines[newLines.length - 1 - suffixLen]
|
||||
) suffixLen++;
|
||||
const startLine = prefixLen + 1; // 1-based
|
||||
const endLine = oldLines.length - suffixLen; // 1-based, inclusive
|
||||
const replacementLines = newLines.slice(prefixLen, newLines.length - suffixLen);
|
||||
const replacementContent = replacementLines.join('\n');
|
||||
|
||||
if (startLine > endLine) {
|
||||
// Pure insertion (old had fewer lines in the changed region)
|
||||
// Use insert_after with line just before the insertion point
|
||||
const insertAfterLine = startLine - 1;
|
||||
const oldContent = fs.readFileSync(filePath, 'utf-8');
|
||||
const allLines = oldContent.split('\n');
|
||||
allLines.splice(insertAfterLine, 0, ...replacementLines);
|
||||
fs.writeFileSync(filePath, allLines.join('\n'), 'utf-8');
|
||||
return {
|
||||
name, args,
|
||||
result: `${filename} updated — inserted ${replacementLines.length} line(s) after line ${insertAfterLine} (auto-converted from create_file). Next time, use insert_after or replace_lines for edits.`,
|
||||
error: false,
|
||||
};
|
||||
}
|
||||
// Apply the surgical edit: replace lines startLine..endLine with replacementContent
|
||||
const oldContent = fs.readFileSync(filePath, 'utf-8');
|
||||
const allLines = oldContent.split('\n');
|
||||
const removedCount = endLine - startLine + 1;
|
||||
allLines.splice(startLine - 1, removedCount, ...replacementLines);
|
||||
fs.writeFileSync(filePath, allLines.join('\n'), 'utf-8');
|
||||
const addedCount = replacementLines.length;
|
||||
const lineWord = addedCount === removedCount ? `${addedCount} line(s)` : `${removedCount}→${addedCount} line(s)`;
|
||||
return {
|
||||
name, args,
|
||||
result: `${filename} updated — replaced lines ${startLine}-${endLine} (${lineWord}, auto-converted from create_file). Next time, use replace_lines or insert_after for edits.`,
|
||||
error: false,
|
||||
};
|
||||
}
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, args.content || '', 'utf-8');
|
||||
console.log(`[v2] create_file("${filename}"): new file created (${(args.content || '').length} chars)`);
|
||||
return { name, args, result: `${filename} created`, error: false };
|
||||
}
|
||||
|
||||
@@ -4811,7 +4860,10 @@ async function handleChat(
|
||||
const historyTurns = (getConfig().getConfig() as any)?.session?.historyTurns ?? 8;
|
||||
const history = getHistoryForApiCall(sessionId, historyTurns, username);
|
||||
const isCodeAiSession = String(sessionId || '').startsWith('code_ai_');
|
||||
const codeAiBlockedTools = new Set(['start_task', 'task_control']);
|
||||
// ── Code AI session: block tools that break streaming ───────────────────
|
||||
// create_file writes to disk in one shot (breaks live streaming).
|
||||
// start_task / task_control would spawn background tasks (inappropriate in editor).
|
||||
const codeAiBlockedTools = new Set(['start_task', 'task_control', 'create_file', 'python_eval', 'shell', 'run_command']);
|
||||
const weatherToolNames = new Set(['weather_search', 'weather_airpollution', 'weather_openmeteo', 'weather_kma', 'weather_airkorea', 'weather_nasa_power', 'weather_era5', 'weather_cds', 'weather_cmip6']);
|
||||
const legalToolNames = new Set(['korean_law_search', 'korean_law_fetch', 'us_case_search']);
|
||||
const meteorologistEnabled = isSkillEnabledForUser('meteorologist', userWorkspace);
|
||||
@@ -5124,6 +5176,10 @@ async function handleChat(
|
||||
allToolResults.push({ name: toolName, args: toolArgs, result: '[BLOCKED] image_edit was called without an explicit user edit request. Do not edit images unless the user explicitly asks.', error: true });
|
||||
continue;
|
||||
}
|
||||
if (isCodeAiSession && codeAiBlockedTools.has(toolName)) {
|
||||
allToolResults.push({ name: toolName, args: toolArgs, result: `[BLOCKED] ${toolName} is not available in Code mode. Output the code as a fenced code block instead.`, error: true });
|
||||
continue;
|
||||
}
|
||||
const toolResult = await executeTool(toolName, toolArgs, workspacePath, sessionId, undefined, username);
|
||||
allToolResults.push(toolResult);
|
||||
logToolCall(workspacePath, toolName, toolArgs, toolResult.result, toolResult.error);
|
||||
@@ -5227,13 +5283,30 @@ async function handleChat(
|
||||
})();
|
||||
|
||||
const workflowCtx = getWorkflowContextBlock(); // empty string when 0 workflows
|
||||
// Code editor's Coder AI runs as an INDEPENDENT coding assistant: a dedicated
|
||||
// coding system prompt with NO main persona, NO user skills (meteorologist/
|
||||
// presenter…), and NO "keep responses short" rule (which otherwise made it
|
||||
// answer a "make space invaders" request with a single sentence and no code).
|
||||
const codeAiSystemPrompt = `You are a coding assistant embedded directly in the user's code editor. Current date: ${dateStr}, ${timeStr}.
|
||||
|
||||
RULES:
|
||||
1. When CREATING a new file, output the FULL code as ONE fenced code block with a filename comment on line 1. When EDITING an existing file, output ONLY the changed parts — use a fenced diff block like \`\`\`diff showing only added/removed/changed lines, prefixed with + or - and the line numbers. The code streams live into the editor — do NOT use create_file or shell tools.
|
||||
2. ALWAYS start the code with a filename comment on line 1: \`# filename: snake_game.py\` (Python), \`// filename: app.js\` (JS/TS/C/C++/Java/Rust), \`<!-- filename: index.html -->\` (HTML). Pick a descriptive name reflecting what the code DOES — never "main", "script", "untitled".
|
||||
3. When the user asks to MODIFY or UPGRADE existing code, use the SAME filename as the current file shown in the context. Output a DIFF block (not the whole file) — show only lines that changed, with - for removed lines and + for added lines, including line number context so the editor can apply the changes precisely.
|
||||
4. NEVER output the entire file when only parts changed. Outputting unchanged lines wastes tokens, time, and UX. ALWAYS use a \`\`\`diff block for edits — the editor applies diffs live. Full-file output for edits is a CRITICAL ERROR.
|
||||
5. NEVER repeat code you already wrote in this conversation. If continuing after a cutoff, write ONLY the remaining lines — do NOT restart from the beginning.
|
||||
6. Write code directly. Do NOT ask "진행할까요?" or wait for approval — all changes are shown as a diff for the user to review and accept or reject. Just write the code. You may briefly explain what you're changing (1 sentence) before the code block, but never ask for permission.
|
||||
7. If the request is ambiguous, ask a short clarifying question. Otherwise, proceed immediately.
|
||||
8. NEVER run pip install, npm install, apt-get, or any package installation command. The user's environment already has the necessary packages — if a package is missing, just write the code and let the user decide whether to install it. Do NOT attempt to install packages yourself.`;
|
||||
const messages: any[] = [
|
||||
{
|
||||
role: 'system',
|
||||
content: `${executionModeSystemBlock ? `${executionModeSystemBlock}\n\n` : ''}You are SmallClaw 🦞, a local AI assistant.\nCurrent date: ${dateStr}, ${timeStr}.\nNever search for or link SmallClaw repos unless the user is asking about SmallClaw itself.\nThis app runs on the user's own machine — browser/desktop automation requests are pre-authorized.\nKeep responses SHORT (1-2 sentences). Don't think out loud. Act and report. Greet naturally without tools.
|
||||
content: isCodeAiSession ? codeAiSystemPrompt : `${executionModeSystemBlock ? `${executionModeSystemBlock}\n\n` : ''}You are SmallClaw 🦞, a local AI assistant.\nCurrent date: ${dateStr}, ${timeStr}.\nNever search for or link SmallClaw repos unless the user is asking about SmallClaw itself.\nThis app runs on the user's own machine — browser/desktop automation requests are pre-authorized.\nKeep responses SHORT (1-2 sentences). Don't think out loud. Act and report. Greet naturally without tools.
|
||||
ANTI-HALLUCINATION: When a tool returns a result, report EXACTLY what the tool returned — never contradict or ignore tool output. If a tool says "(no rows)", say so. Never invent data, file contents, table names, or command output. If you don't know something, call a tool to find out or say you don't know.
|
||||
IMAGE EDITING RULE: NEVER call image_edit (or any editing tool) when a user uploads a photo without explicitly requesting edits. Uploading a photo is NOT a request to edit it. Only call image_edit when the user's message explicitly asks for an edit (e.g. "수채화로 바꿔줘", "회전해줘"). Violating this rule is a critical error.
|
||||
BROWSER RULE: NEVER call browser_open, browser_snapshot, browser_click, browser_fill, browser_scroll, or any browser_* tool unless the user EXPLICITLY asks to use the browser (e.g. "브라우저로 열어줘", "Chrome으로 봐줘", "사이트 직접 들어가봐"). "열어줘" or "보여줘" alone is NOT a browser request — answer from knowledge, use web_fetch/web_search, or output the relevant URL/embed. Pasting a URL or asking a question is also NOT a browser request.${callerContext ? '\n\n' + callerContext : ''}${browserStateCtx}${personalityCtx}${skillsManager.buildPromptContextForUser(username ? getUserWorkspace(username) : null, 16000)}${workflowCtx ? '\n\n' + workflowCtx : ''}`,
|
||||
BROWSER RULE: NEVER call browser_open, browser_snapshot, browser_click, browser_fill, browser_scroll, or any browser_* tool unless the user EXPLICITLY asks to use the browser (e.g. "브라우저로 열어줘", "Chrome으로 봐줘", "사이트 직접 들어가봐"). "열어줘" or "보여줘" alone is NOT a browser request — answer from knowledge, use web_fetch/web_search, or output the relevant URL/embed. Pasting a URL or asking a question is also NOT a browser request.
|
||||
CODE OUTPUT: When writing code in a fenced code block, start with a filename comment on line 1: \`# filename: snake_game.py\` (Python), \`// filename: app.js\` (JS/C), \`<!-- filename: index.html -->\` (HTML). Never repeat code already written in this conversation. For modifications to existing files, use replace_lines or insert_after (not create_file — it only works for NEW files). All code changes are presented as diffs for the user to review before being applied. Write code directly — do not ask for permission.
|
||||
PACKAGE INSTALL: NEVER run pip install, npm install, apt-get, or any package installation command. If a package is missing, just write the code and mention the package name in a comment — let the user decide whether to install it. Do NOT attempt to install packages yourself.${callerContext ? '\n\n' + callerContext : ''}${browserStateCtx}${personalityCtx}${skillsManager.buildPromptContextForUser(username ? getUserWorkspace(username) : null, 16000)}${workflowCtx ? '\n\n' + workflowCtx : ''}`,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -6422,7 +6495,7 @@ RULES:
|
||||
)
|
||||
);
|
||||
const primaryThinkMode: boolean | 'high' | 'medium' | 'low' = (multiAgentActive && !isActiveAutomationOp) ? true : false;
|
||||
const needsLongOutput = /pptx|powerpoint|presentation|슬라이드|발표|프레젠테이션/i.test(message);
|
||||
const needsLongOutput = /pptx|powerpoint|presentation|슬라이드|발표|프레젠테이션|게임|코드|스크립트|함수|클래스|구현해|만들어줘|만들어\s*줘|작성해|짜줘|build.*app|create.*app|write.*code|implement.*class|game.*make|전체.*코드|완성된.*코드/i.test(message);
|
||||
// Model priority: explicit request > skill override > config default
|
||||
const effectiveModel = String(modelOverride || '').trim()
|
||||
|| skillsManager.getModelOverrideForUser(username ? getUserWorkspace(username) : null)
|
||||
@@ -6436,7 +6509,7 @@ RULES:
|
||||
tools,
|
||||
temperature: 0.3,
|
||||
num_ctx: 8192,
|
||||
num_predict: needsLongOutput ? 8192 : 4096,
|
||||
num_predict: isCodeAiSession ? 16384 : (needsLongOutput ? 8192 : 4096),
|
||||
think: primaryThinkMode,
|
||||
model: effectiveModel,
|
||||
});
|
||||
@@ -6624,6 +6697,43 @@ RULES:
|
||||
}
|
||||
}
|
||||
|
||||
// ── Code AI auto-recover: two truncation patterns ──────────────────────
|
||||
if (isCodeAiSession && (!toolCalls || toolCalls.length === 0) && response.content
|
||||
&& continuationNudges < MAX_CONTINUATION_NUDGES) {
|
||||
const hasCodeFence = /```/.test(response.content);
|
||||
// Pattern 1: announcement without code — model says "만들겠습니다!" but
|
||||
// stops without producing any fenced code block. Nudge to write code.
|
||||
// (VSCode Copilot style: model should just write code, not ask for permission)
|
||||
if (!hasCodeFence) {
|
||||
const announceLike = response.content.trim().length < 240
|
||||
&& /(겠습니다|드릴게요|드리겠|만들어\s*드리|작성하겠|짜드리|짜\s*드리|구현하겠|I[''']?ll\b|let me\b|going to)/i.test(response.content);
|
||||
const buildRequest = /(만들|짜줘|짜봐|작성|구현|생성|고쳐|수정|build|create|make|write|implement|코드|게임|game|함수|클래스|스크립트|script|app|html|페이지)/i.test(message);
|
||||
if (announceLike && buildRequest) {
|
||||
console.log('[v2] CODE-AI: announcement without code — nudging');
|
||||
continuationNudges++;
|
||||
allThinking += (allThinking ? '\n\n' : '') + response.content;
|
||||
messages.push({ role: 'assistant', content: response.content });
|
||||
messages.push({ role: 'user', content: '코드를 작성해주세요. 기존 파일을 수정할 때는 전체 코드를 다시 출력하지 말고 ```diff 블록으로 변경된 줄만 출력하세요.' });
|
||||
sendSSE('info', { message: '코드를 작성하도록 재요청...' });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Pattern 2: truncated code fence — odd number of ``` = unclosed fence.
|
||||
// num_predict was hit mid-code. Give the model its partial output and
|
||||
// the last few lines so it continues from exactly where it stopped.
|
||||
const opens = (response.content.match(/```/g) || []).length;
|
||||
if (opens % 2 !== 0) {
|
||||
console.log('[v2] CODE-AI: truncated fence — auto-continuing');
|
||||
continuationNudges++;
|
||||
allThinking += (allThinking ? '\n\n' : '') + response.content;
|
||||
const tail = response.content.split('\n').slice(-5).join('\n');
|
||||
messages.push({ role: 'assistant', content: response.content });
|
||||
messages.push({ role: 'user', content: `Your code was cut off. Last lines:\n${tail}\n\nContinue EXACTLY from this point. Do NOT restart or repeat any code. Write ONLY the remaining lines and close the \`\`\` fence. If this was a diff block, continue the diff — do NOT output the full file.` });
|
||||
sendSSE('info', { message: '코드가 잘렸습니다. 이어서 작성 중...' });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-recover: if model dumped pure reasoning without calling any tools on a
|
||||
// question that clearly needs tools (search, file, browser), re-prompt once
|
||||
if ((!toolCalls || toolCalls.length === 0) && response.content && round === 0 && allToolResults.length === 0) {
|
||||
@@ -7656,6 +7766,15 @@ RULES:
|
||||
messages.push({ role: 'user', content: 'No valid element ref. Call browser_snapshot now to get the current page elements, then try again.' });
|
||||
continue;
|
||||
}
|
||||
// Block execution tools in Code AI sessions — the model should output code as text, not run it
|
||||
if (isCodeAiSession && codeAiBlockedTools.has(toolName)) {
|
||||
const blockedResult: ToolResult = { name: toolName, args: toolArgs, result: `[BLOCKED] ${toolName} is not available in Code mode. Output the code as a fenced code block instead — do not execute it.`, error: true };
|
||||
allToolResults.push(blockedResult);
|
||||
sendSSE('tool_result', { action: toolName, result: blockedResult.result, error: true, stepNum: allToolResults.length });
|
||||
messages.push({ role: 'tool', name: toolName, tool_call_id: toolCallId || undefined, content: blockedResult.result });
|
||||
messages.push({ role: 'user', content: `Tool ${toolName} is blocked in Code mode. Write the code in a fenced code block with a filename comment on line 1 instead of executing it. Example:\n\`\`\`python\n# filename: tetris.py\nimport pygame\n...\n\`\`\`` });
|
||||
continue;
|
||||
}
|
||||
const toolResult = await executeTool(toolName, toolArgs, workspacePath, sessionId, sendSSE, username);
|
||||
if (canReplayReadOnlyCall(toolName)) cachedReadOnlyToolResults.set(callKey, toolResult);
|
||||
allToolResults.push(toolResult);
|
||||
@@ -8437,7 +8556,8 @@ const AUTH_COOKIE = 'smallclaw_session';
|
||||
const app = express();
|
||||
app.set('trust proxy', 1);
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
app.use(express.urlencoded({ limit: '50mb', extended: true }));
|
||||
|
||||
const webUiPath = path.join(__dirname, '..', '..', 'web-ui');
|
||||
|
||||
@@ -9182,22 +9302,49 @@ app.get('/api/pptx/list', requireGatewayAuth, async (req: express.Request, res:
|
||||
const session = getSessionUser(req);
|
||||
const username = session?.username;
|
||||
const workspacePath = username ? getUserWorkspace(username) : getConfig().getWorkspacePath();
|
||||
const { execFile } = await import('child_process');
|
||||
const { promisify } = await import('util');
|
||||
const execFileAsync = promisify(execFile);
|
||||
const { stdout } = await execFileAsync('find', [workspacePath, '-name', '*.pptx', '-not', '-path', '*/preview/*', '-printf', '%T@ %p\n'], { timeout: 10000 });
|
||||
const items = stdout.trim().split('\n').filter(Boolean)
|
||||
.map(l => { const [ts, ...rest] = l.split(' '); return { ts: parseFloat(ts), absPath: rest.join(' ') }; })
|
||||
.sort((a, b) => b.ts - a.ts)
|
||||
.slice(0, 30)
|
||||
.map(({ ts, absPath }) => {
|
||||
const relPath = absPath.slice(workspacePath.length).replace(/^[\\/]/, '').replace(/\\/g, '/');
|
||||
const parts = relPath.split('/');
|
||||
const project = parts[0];
|
||||
const filename = parts[parts.length - 1];
|
||||
const url = '/api/files/' + relPath.split('/').map(s => encodeURIComponent(s)).join('/');
|
||||
return { relPath, project, filename, url, mtime: ts };
|
||||
});
|
||||
|
||||
// Collect project dirs inside pptx/ base folder
|
||||
const pptxBaseDir = path.join(workspacePath, PPTX_BASE);
|
||||
fs.mkdirSync(pptxBaseDir, { recursive: true });
|
||||
const projectMap = new Map<string, { mtime: number; pptxRelPath?: string; pptxFilename?: string }>();
|
||||
const topEntries = fs.readdirSync(pptxBaseDir, { withFileTypes: true });
|
||||
for (const entry of topEntries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const dir = path.join(pptxBaseDir, entry.name);
|
||||
let hasCandidates = false;
|
||||
let latestMtime = 0;
|
||||
let pptxRelPath: string | undefined;
|
||||
let pptxMtime = 0;
|
||||
try {
|
||||
for (const f of fs.readdirSync(dir)) {
|
||||
const abs = path.join(dir, f);
|
||||
let st: fs.Stats;
|
||||
try { st = fs.statSync(abs); } catch { continue; }
|
||||
if (f === 'papers.json' || f.endsWith('.pdf') || f.endsWith('.txt')) {
|
||||
hasCandidates = true;
|
||||
if (st.mtimeMs > latestMtime) latestMtime = st.mtimeMs;
|
||||
}
|
||||
if (f.endsWith('.pptx') && !abs.includes('/preview/')) {
|
||||
if (st.mtimeMs > pptxMtime) { pptxMtime = st.mtimeMs; pptxRelPath = `${PPTX_BASE}/${entry.name}/${f}`; }
|
||||
if (st.mtimeMs > latestMtime) latestMtime = st.mtimeMs;
|
||||
hasCandidates = true;
|
||||
}
|
||||
}
|
||||
} catch { continue; }
|
||||
if (!hasCandidates) continue;
|
||||
projectMap.set(entry.name, { mtime: latestMtime, pptxRelPath, pptxFilename: pptxRelPath ? path.basename(pptxRelPath) : undefined });
|
||||
}
|
||||
|
||||
const items = [...projectMap.entries()]
|
||||
.sort((a, b) => b[1].mtime - a[1].mtime)
|
||||
.slice(0, 50)
|
||||
.map(([project, info]) => ({
|
||||
project,
|
||||
relPath: info.pptxRelPath || project, // PPTX path if exists, else project folder name
|
||||
filename: info.pptxFilename || '(PPTX 없음)',
|
||||
hasPptx: !!info.pptxRelPath,
|
||||
mtime: info.mtime,
|
||||
}));
|
||||
res.json({ items });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: String(err) });
|
||||
@@ -9314,6 +9461,16 @@ app.get('/api/pubmed/fetch', requireGatewayAuth, async (req: express.Request, re
|
||||
}
|
||||
});
|
||||
|
||||
// Strip references/bibliography section from academic paper text (last occurrence)
|
||||
function stripReferences(text: string): { text: string; stripped: boolean } {
|
||||
const pattern = /\n(?:References|REFERENCES|Bibliography|BIBLIOGRAPHY|참고문헌|Reference List|REFERENCE LIST|Literature Cited|LITERATURE CITED)\s*\n/g;
|
||||
let lastIdx = -1;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(text)) !== null) lastIdx = match.index;
|
||||
if (lastIdx === -1) return { text, stripped: false };
|
||||
return { text: text.slice(0, lastIdx).trimEnd(), stripped: true };
|
||||
}
|
||||
|
||||
app.post('/api/pptx/read-pdf', requireGatewayAuth, async (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
const session = getSessionUser(req);
|
||||
@@ -9321,8 +9478,31 @@ app.post('/api/pptx/read-pdf', requireGatewayAuth, async (req: express.Request,
|
||||
const pdfPath = String(req.body?.pdf_path || '').trim();
|
||||
if (!pdfPath) { res.status(400).json({ error: 'pdf_path required' }); return; }
|
||||
const workspacePath = username ? getUserWorkspace(username) : getConfig().getWorkspacePath();
|
||||
const maxChars = Math.min(200_000, Number(req.body?.max_chars || 4000));
|
||||
const { pdfReadTool } = await import('../tools/pdf.js');
|
||||
const result = await pdfReadTool.execute({ path: pdfPath, max_chars: 4000, _workspacePath: workspacePath });
|
||||
const result = await pdfReadTool.execute({ path: pdfPath, max_chars: maxChars, _workspacePath: workspacePath });
|
||||
// Strip references section for PPTX prep (not useful for slide generation)
|
||||
if (result.success && result.stdout) {
|
||||
const { text: stripped, stripped: didStrip } = stripReferences(result.stdout);
|
||||
if (didStrip) {
|
||||
result.stdout = stripped;
|
||||
(result as any).references_stripped = true;
|
||||
}
|
||||
}
|
||||
// If save_path provided, write extracted text to disk in the project folder
|
||||
const savePath = String(req.body?.save_path || '').trim();
|
||||
if (savePath && (result.success || result.stdout)) {
|
||||
const textToSave = result.stdout || '';
|
||||
if (textToSave) {
|
||||
const absSavePath = path.join(workspacePath, savePath);
|
||||
if (isPathInsideDir(workspacePath, absSavePath)) {
|
||||
const saveDir = path.dirname(absSavePath);
|
||||
if (!fs.existsSync(saveDir)) fs.mkdirSync(saveDir, { recursive: true });
|
||||
fs.writeFileSync(absSavePath, textToSave, 'utf-8');
|
||||
(result as any).saved_path = savePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: String(err) });
|
||||
@@ -9373,6 +9553,14 @@ app.get('/api/semantic/search', requireGatewayAuth, async (req: express.Request,
|
||||
}
|
||||
});
|
||||
|
||||
const PPTX_BASE = 'pptx';
|
||||
function pptxProjectSlug(raw: string) {
|
||||
return String(raw || '').trim().replace(/[^a-zA-Z0-9가-힣_\-]/g, '_').replace(/_+/g, '_').replace(/^_+|_+$/g, '').toLowerCase().slice(0, 60);
|
||||
}
|
||||
function pptxRelPath(slug: string, ...parts: string[]) {
|
||||
return [PPTX_BASE, slug, ...parts].join('/');
|
||||
}
|
||||
|
||||
app.post('/api/pptx/download-pdf', requireGatewayAuth, async (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
const session = getSessionUser(req);
|
||||
@@ -9380,9 +9568,8 @@ app.post('/api/pptx/download-pdf', requireGatewayAuth, async (req: express.Reque
|
||||
const workspacePath = username ? getUserWorkspace(username) : getConfig().getWorkspacePath();
|
||||
|
||||
// If project slug provided, save PDF directly into project folder
|
||||
const projectSlug = String(req.body?.project || '').trim()
|
||||
.replace(/[^a-zA-Z0-9가-힣_\-]/g, '_').replace(/_+/g, '_').replace(/^_+|_+$/g, '').toLowerCase().slice(0, 60);
|
||||
const pdfBaseDir = projectSlug || 'pubmed';
|
||||
const projectSlug = pptxProjectSlug(req.body?.project);
|
||||
const pdfBaseDir = projectSlug ? pptxRelPath(projectSlug) : 'pubmed';
|
||||
|
||||
const directUrl = String(req.body?.url || '').trim();
|
||||
if (directUrl) {
|
||||
@@ -9403,7 +9590,7 @@ app.post('/api/pptx/download-pdf', requireGatewayAuth, async (req: express.Reque
|
||||
if (hit?.pmcid) {
|
||||
console.log(`[download-pdf] DOI→PMC: ${hit.pmcid}`);
|
||||
const { pubmedFulltextTool } = await import('../tools/pubmed.js');
|
||||
const savePath = projectSlug ? `${projectSlug}/${hit.pmcid}.pdf` : undefined;
|
||||
const savePath = projectSlug ? pptxRelPath(projectSlug, `${hit.pmcid}.pdf`) : undefined;
|
||||
const result = await pubmedFulltextTool.execute({ pmcid: hit.pmcid, format: 'pdf', save_path: savePath, _workspacePath: workspacePath });
|
||||
res.json(result);
|
||||
return;
|
||||
@@ -9451,27 +9638,287 @@ app.post('/api/pptx/download-pdf', requireGatewayAuth, async (req: express.Reque
|
||||
const pmcid = String(req.body?.pmcid || '').trim();
|
||||
if (!pmcid) { res.status(400).json({ error: 'pmcid or url required' }); return; }
|
||||
const { pubmedFulltextTool } = await import('../tools/pubmed.js');
|
||||
const savePath = projectSlug ? `${projectSlug}/${pmcid}.pdf` : undefined;
|
||||
const savePath = projectSlug ? pptxRelPath(projectSlug, `${pmcid}.pdf`) : undefined;
|
||||
const result = await pubmedFulltextTool.execute({ pmcid, format: 'pdf', save_path: savePath, _workspacePath: workspacePath });
|
||||
res.json(result);
|
||||
if (result.success) { res.json(result); return; }
|
||||
|
||||
// PDF failed — log and fall back to efetch full text
|
||||
console.warn(`[download-pdf] PDF failed for ${pmcid}: ${result.error?.split('\n')[0]} — trying text fallback`);
|
||||
const txtSavePath = projectSlug ? pptxRelPath(projectSlug, `${pmcid}.txt`) : `pubmed/${pmcid}.txt`;
|
||||
const txtResult = await pubmedFulltextTool.execute({ pmcid, format: 'text', save_path: txtSavePath, _workspacePath: workspacePath });
|
||||
if (txtResult.success) {
|
||||
console.log(`[download-pdf] Text fallback succeeded for ${pmcid}, saved to ${txtSavePath}`);
|
||||
res.json({ ...txtResult, _textFallback: true, _txtPath: txtSavePath });
|
||||
} else {
|
||||
res.json(result); // return original PDF error
|
||||
}
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/pptx/download-pdf-stream — PMC PDF download with SSE progress events
|
||||
app.post('/api/pptx/download-pdf-stream', requireGatewayAuth, async (req: express.Request, res: express.Response) => {
|
||||
const session = getSessionUser(req);
|
||||
const username = session?.username;
|
||||
const workspacePath = username ? getUserWorkspace(username) : getConfig().getWorkspacePath();
|
||||
const pmcid = String(req.body?.pmcid || '').trim();
|
||||
const projectSlug = pptxProjectSlug(req.body?.project);
|
||||
if (!pmcid) { res.status(400).json({ error: 'pmcid required' }); return; }
|
||||
|
||||
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
const send = (data: object) => res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
|
||||
try {
|
||||
const PMC_OA_BASE = 'https://www.ncbi.nlm.nih.gov/pmc/utils/oa/oa.fcgi';
|
||||
const EUTILS_BASE = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils';
|
||||
const numericId = pmcid.replace(/^PMC/i, '');
|
||||
const pdfDest = path.join(workspacePath, projectSlug ? pptxRelPath(projectSlug, `${pmcid}.pdf`) : `pubmed/${pmcid}.pdf`);
|
||||
fs.mkdirSync(path.dirname(pdfDest), { recursive: true });
|
||||
|
||||
// 1. OA API → FTP link + check OA
|
||||
send({ type: 'progress', msg: 'PMC OA 확인 중...' });
|
||||
let oaXml = '';
|
||||
try { oaXml = await (await fetch(`${PMC_OA_BASE}?id=${pmcid}`, { signal: AbortSignal.timeout(15_000) })).text(); } catch {}
|
||||
if (oaXml.includes('idIsNotOpenAccess')) {
|
||||
send({ type: 'done', success: false, error: `${pmcid}은 Open Access가 아닙니다.` });
|
||||
res.end(); return;
|
||||
}
|
||||
const ftpMatch = oaXml.match(/href="(ftp:\/\/[^"]+\.pdf)"/i);
|
||||
const ftpPdfUrl = ftpMatch ? ftpMatch[1].replace('ftp://ftp.ncbi.nlm.nih.gov/', 'https://ftp.ncbi.nlm.nih.gov/') : '';
|
||||
|
||||
// 2. Unpaywall URL via esummary + Unpaywall API
|
||||
let unpayUrl = '';
|
||||
try {
|
||||
send({ type: 'progress', msg: 'Unpaywall DOI 조회 중...' });
|
||||
const sum = await (await fetch(`${EUTILS_BASE}/esummary.fcgi?db=pmc&id=${numericId}&retmode=json`, { signal: AbortSignal.timeout(15_000) })).json() as any;
|
||||
const doi = (sum?.result?.[numericId]?.articleids || []).find((a: any) => a.idtype === 'doi')?.value || '';
|
||||
if (doi) {
|
||||
const uw = await (await fetch(`https://api.unpaywall.org/v2/${doi}?email=pubmed@smallclaw.local`, { signal: AbortSignal.timeout(10_000) })).json() as any;
|
||||
unpayUrl = (uw?.oa_locations || []).map((l: any) => l.url_for_pdf).find((u: any) => u) || '';
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const europePmcUrl = `https://europepmc.org/api/getPdf?pmcid=${pmcid}`;
|
||||
const sources: { label: string; url: string }[] = [
|
||||
ftpPdfUrl ? { label: 'NCBI FTP', url: ftpPdfUrl } : null,
|
||||
unpayUrl ? { label: 'Unpaywall', url: unpayUrl } : null,
|
||||
{ label: 'EuropePMC', url: europePmcUrl },
|
||||
].filter(Boolean) as { label: string; url: string }[];
|
||||
|
||||
// 3. Try each source
|
||||
let pdfBuf: Buffer | null = null;
|
||||
for (const src of sources) {
|
||||
send({ type: 'progress', msg: `${src.label} 시도 중...` });
|
||||
try {
|
||||
const r = await fetch(src.url, {
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36', 'Accept': 'application/pdf,*/*' },
|
||||
});
|
||||
if (!r.ok) { send({ type: 'progress', msg: `${src.label} 실패 (HTTP ${r.status})` }); continue; }
|
||||
const buf = Buffer.from(await r.arrayBuffer());
|
||||
if (!buf.slice(0, 5).toString('ascii').startsWith('%PDF')) {
|
||||
send({ type: 'progress', msg: `${src.label} → PDF 아님 (HTML 반환)` }); continue;
|
||||
}
|
||||
pdfBuf = buf;
|
||||
send({ type: 'progress', msg: `${src.label} 다운로드 성공 ✓` });
|
||||
break;
|
||||
} catch (e: any) { send({ type: 'progress', msg: `${src.label} 오류: ${e.message}` }); }
|
||||
}
|
||||
|
||||
if (pdfBuf) {
|
||||
fs.writeFileSync(pdfDest, pdfBuf);
|
||||
const relPath = path.relative(workspacePath, pdfDest);
|
||||
send({ type: 'done', success: true, path: relPath, stdout: `Saved to: ${relPath}` });
|
||||
res.end(); return;
|
||||
}
|
||||
|
||||
// 4. Text fallback via efetch
|
||||
send({ type: 'progress', msg: 'PDF 없음 → PMC 전문 텍스트 추출 중...' });
|
||||
const txtDest = path.join(workspacePath, projectSlug ? pptxRelPath(projectSlug, `${pmcid}.txt`) : `pubmed/${pmcid}.txt`);
|
||||
const { pubmedFulltextTool } = await import('../tools/pubmed.js');
|
||||
const txtSavePath = path.relative(workspacePath, txtDest);
|
||||
const txtResult = await pubmedFulltextTool.execute({ pmcid, format: 'text', save_path: txtSavePath, _workspacePath: workspacePath });
|
||||
if (txtResult.success) {
|
||||
send({ type: 'progress', msg: '전문 텍스트 추출 완료 ✓' });
|
||||
send({ type: 'done', success: true, _textFallback: true, _txtPath: txtSavePath, stdout: txtResult.stdout || '' });
|
||||
} else {
|
||||
send({ type: 'done', success: false, error: 'PDF 및 전문 텍스트 모두 실패' });
|
||||
}
|
||||
} catch (err: any) {
|
||||
send({ type: 'done', success: false, error: String(err?.message || err) });
|
||||
}
|
||||
res.end();
|
||||
});
|
||||
|
||||
// DELETE /api/pptx/delete-project — delete an entire project folder
|
||||
app.delete('/api/pptx/delete-project', requireGatewayAuth, (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
const session = getSessionUser(req);
|
||||
const username = session?.username;
|
||||
const workspacePath = username ? getUserWorkspace(username) : getConfig().getWorkspacePath();
|
||||
const project = pptxProjectSlug(req.body?.project);
|
||||
if (!project) { res.status(400).json({ success: false, error: 'project required' }); return; }
|
||||
const projectDir = path.join(workspacePath, PPTX_BASE, project);
|
||||
if (!projectDir.startsWith(workspacePath)) { res.status(403).json({ success: false, error: 'Forbidden' }); return; }
|
||||
if (!fs.existsSync(projectDir)) { res.json({ success: true }); return; }
|
||||
fs.rmSync(projectDir, { recursive: true, force: true });
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, error: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/pptx/delete-image — delete an image file from the workspace by relative path
|
||||
app.delete('/api/pptx/delete-image', requireGatewayAuth, (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
const session = getSessionUser(req);
|
||||
const username = session?.username;
|
||||
const workspacePath = username ? getUserWorkspace(username) : getConfig().getWorkspacePath();
|
||||
const relPath = String(req.body?.path || '').trim().replace(/\.\./g, '');
|
||||
if (!relPath) { res.status(400).json({ success: false, error: 'path required' }); return; }
|
||||
const absPath = path.join(workspacePath, relPath);
|
||||
if (!absPath.startsWith(workspacePath)) { res.status(403).json({ success: false, error: 'Forbidden' }); return; }
|
||||
if (fs.existsSync(absPath)) fs.unlinkSync(absPath);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, error: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/pptx/upload-image — upload image file directly to project directory
|
||||
app.post('/api/pptx/upload-image', requireGatewayAuth, (req: express.Request, res: express.Response) => {
|
||||
const contentType = String(req.headers['content-type'] || '');
|
||||
if (!contentType.includes('multipart/form-data')) {
|
||||
res.status(400).json({ success: false, error: 'Content-Type must be multipart/form-data' }); return;
|
||||
}
|
||||
const boundary = contentType.split('boundary=')[1];
|
||||
if (!boundary) { res.status(400).json({ success: false, error: 'Missing boundary' }); return; }
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
req.on('end', () => {
|
||||
const raw = Buffer.concat(chunks).toString('binary');
|
||||
const boundaryDelim = '--' + boundary;
|
||||
|
||||
let filename = 'upload.png';
|
||||
let fileData: Buffer | null = null;
|
||||
let project = '';
|
||||
|
||||
const parts = raw.split(boundaryDelim);
|
||||
for (const part of parts) {
|
||||
if (!part || part.trim() === '--' || part.trim() === '') continue;
|
||||
const headerEnd = part.indexOf('\r\n\r\n');
|
||||
if (headerEnd === -1) continue;
|
||||
const header = part.substring(0, headerEnd);
|
||||
|
||||
if (header.includes('name="project"')) {
|
||||
const body = part.substring(headerEnd + 4);
|
||||
const end = body.lastIndexOf('\r\n');
|
||||
project = (end > 0 ? body.substring(0, end) : body).trim()
|
||||
.replace(/[^a-zA-Z0-9가-힣_\-]/g, '_').replace(/_+/g, '_').replace(/^_+|_+$/g, '').toLowerCase().slice(0, 60);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!header.includes('name="image"')) continue;
|
||||
const fnMatch = header.match(/filename\*=UTF-8''([^\r\n]+)/i)
|
||||
?? header.match(/filename="([^"]+)"/);
|
||||
if (fnMatch) {
|
||||
const raw8 = decodeURIComponent(fnMatch[1]) === fnMatch[1]
|
||||
? Buffer.from(fnMatch[1], 'binary').toString('utf-8')
|
||||
: decodeURIComponent(fnMatch[1]);
|
||||
filename = raw8;
|
||||
}
|
||||
const bodyStart = headerEnd + 4;
|
||||
const bodyEnd = part.lastIndexOf('\r\n');
|
||||
if (bodyEnd <= bodyStart) continue;
|
||||
fileData = Buffer.from(part.substring(bodyStart, bodyEnd), 'binary');
|
||||
}
|
||||
|
||||
if (!fileData) { res.status(400).json({ success: false, error: 'No image file found' }); return; }
|
||||
if (fileData.length > 20 * 1024 * 1024) { res.status(400).json({ success: false, error: 'Image too large (max 20MB)' }); return; }
|
||||
if (!project) { res.status(400).json({ success: false, error: 'project required' }); return; }
|
||||
|
||||
const session = getSessionUser(req);
|
||||
const username = session?.username;
|
||||
const workspacePath = username ? getUserWorkspace(username) : getConfig().getWorkspacePath();
|
||||
const projectDir = path.join(workspacePath, PPTX_BASE, project);
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
const finalName = resolveUploadName(projectDir, filename);
|
||||
const filePath = path.join(projectDir, finalName);
|
||||
fs.writeFileSync(filePath, fileData);
|
||||
|
||||
const relativePath = pptxRelPath(project, finalName);
|
||||
res.json({ success: true, path: relativePath, url: `/api/files/${relativePath.split('/').map(encodeURIComponent).join('/')}` });
|
||||
});
|
||||
req.on('error', (err: any) => { res.status(500).json({ success: false, error: String(err?.message || err) }); });
|
||||
});
|
||||
|
||||
// POST /api/pptx/search-images — keyword image search via Pexels/Pixabay/Unsplash
|
||||
app.post('/api/pptx/search-images', requireGatewayAuth, async (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
const query = String(req.body?.query || '').trim();
|
||||
const count = Math.min(Math.max(Number(req.body?.count) || 6, 1), 6);
|
||||
if (!query) { res.status(400).json({ error: 'query required' }); return; }
|
||||
const raw = await imageSearch(query, count);
|
||||
const apiHasResults = !raw.startsWith('No image API') && !raw.startsWith('No images found');
|
||||
if (!apiHasResults) { res.json({ images: [] }); return; }
|
||||
const filtered = await filterImageMarkdown(raw);
|
||||
const images: { url: string; label: string }[] = [];
|
||||
const regex = /!\[([^\]]*)\]\((https?:\/\/[^)]+)\)/g;
|
||||
let m;
|
||||
while ((m = regex.exec(filtered.text)) !== null) {
|
||||
images.push({ label: m[1] || query, url: m[2] });
|
||||
}
|
||||
res.json({ images: images.slice(0, count) });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/pptx/save-image-url — download external image URL into project directory
|
||||
app.post('/api/pptx/save-image-url', requireGatewayAuth, async (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
const session = getSessionUser(req);
|
||||
const username = session?.username;
|
||||
const srcUrl = String(req.body?.url || '').trim();
|
||||
const project = pptxProjectSlug(req.body?.project);
|
||||
if (!srcUrl || !project) { res.status(400).json({ success: false, error: 'url and project required' }); return; }
|
||||
if (!/^https?:\/\//.test(srcUrl)) { res.status(400).json({ success: false, error: 'invalid url' }); return; }
|
||||
const resp = await fetch(srcUrl, { signal: AbortSignal.timeout(15000), headers: { 'User-Agent': 'Mozilla/5.0' } });
|
||||
if (!resp.ok) { res.status(400).json({ success: false, error: `fetch ${resp.status}` }); return; }
|
||||
const ct = resp.headers.get('content-type') || '';
|
||||
const ext = ct.includes('png') ? '.png' : ct.includes('gif') ? '.gif' : ct.includes('webp') ? '.webp' : '.jpg';
|
||||
const buf = Buffer.from(await resp.arrayBuffer());
|
||||
if (buf.length > 10 * 1024 * 1024) { res.status(400).json({ success: false, error: 'image too large (max 10MB)' }); return; }
|
||||
const workspacePath = username ? getUserWorkspace(username) : getConfig().getWorkspacePath();
|
||||
const projectDir = path.join(workspacePath, PPTX_BASE, project);
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
const baseName = `search_${Date.now()}${ext}`;
|
||||
const finalName = resolveUploadName(projectDir, baseName);
|
||||
fs.writeFileSync(path.join(projectDir, finalName), buf);
|
||||
const relativePath = pptxRelPath(project, finalName);
|
||||
res.json({ success: true, path: relativePath, url: `/api/files/${relativePath.split('/').map(encodeURIComponent).join('/')}` });
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, error: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/pptx/extract-images', requireGatewayAuth, async (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
const session = getSessionUser(req);
|
||||
const username = session?.username;
|
||||
const pdfPath = String(req.body?.pdf_path || '').trim();
|
||||
const projectSlugEx = String(req.body?.project || '').trim()
|
||||
.replace(/[^a-zA-Z0-9가-힣_\-]/g, '_').replace(/_+/g, '_').replace(/^_+|_+$/g, '').toLowerCase().slice(0, 60);
|
||||
const projectSlugEx = pptxProjectSlug(req.body?.project);
|
||||
// If project given and no explicit out_dir, put extracted images inside project folder
|
||||
const explicitOutDir = String(req.body?.out_dir || '').trim();
|
||||
let outDir = explicitOutDir;
|
||||
if (!outDir && projectSlugEx && pdfPath) {
|
||||
const pdfBase = path.basename(pdfPath, path.extname(pdfPath));
|
||||
outDir = `${projectSlugEx}/${pdfBase}_images`;
|
||||
outDir = pptxRelPath(projectSlugEx, `${pdfBase}_images`);
|
||||
}
|
||||
if (!pdfPath) { res.status(400).json({ error: 'pdf_path required' }); return; }
|
||||
const workspacePath = username ? getUserWorkspace(username) : getConfig().getWorkspacePath();
|
||||
@@ -9484,6 +9931,7 @@ app.post('/api/pptx/extract-images', requireGatewayAuth, async (req: express.Req
|
||||
});
|
||||
|
||||
// GET /api/pptx/project-pdfs?project=<slug> — list PDF files in project folder
|
||||
// Also returns companion .txt / .ko.txt paths if they exist.
|
||||
app.get('/api/pptx/project-pdfs', requireGatewayAuth, async (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
const session = getSessionUser(req);
|
||||
@@ -9491,11 +9939,23 @@ app.get('/api/pptx/project-pdfs', requireGatewayAuth, async (req: express.Reques
|
||||
const workspacePath = username ? getUserWorkspace(username) : getConfig().getWorkspacePath();
|
||||
const project = String(req.query.project || '').trim().replace(/\.\./g, '');
|
||||
if (!project) { res.status(400).json({ error: 'project required' }); return; }
|
||||
const projectDir = path.join(workspacePath, project);
|
||||
const projectDir = path.join(workspacePath, PPTX_BASE, project);
|
||||
if (!fs.existsSync(projectDir)) { res.json({ pdfs: [] }); return; }
|
||||
const pdfs = fs.readdirSync(projectDir)
|
||||
.filter(f => f.toLowerCase().endsWith('.pdf'))
|
||||
.map(f => ({ path: `${project}/${f}`, name: f.replace(/\.pdf$/i, '') }));
|
||||
.map(f => {
|
||||
const baseName = f.replace(/\.pdf$/i, '');
|
||||
const txtRel = pptxRelPath(project, `${baseName}.txt`);
|
||||
const koRel = pptxRelPath(project, `${baseName}.ko.txt`);
|
||||
const hasText = fs.existsSync(path.join(workspacePath, txtRel));
|
||||
const hasTranslation = fs.existsSync(path.join(workspacePath, koRel));
|
||||
return {
|
||||
path: pptxRelPath(project, f), name: baseName,
|
||||
txtPath: hasText ? txtRel : null,
|
||||
koPath: hasTranslation ? koRel : null,
|
||||
hasText, hasTranslation,
|
||||
};
|
||||
});
|
||||
res.json({ pdfs });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: String(err) });
|
||||
@@ -9511,7 +9971,7 @@ app.get('/api/pptx/project-images', requireGatewayAuth, async (req: express.Requ
|
||||
const workspacePath = username ? getUserWorkspace(username) : getConfig().getWorkspacePath();
|
||||
const project = String(req.query.project || '').trim().replace(/\.\./g, '');
|
||||
if (!project) { res.status(400).json({ error: 'project required' }); return; }
|
||||
const projectDir = path.join(workspacePath, project);
|
||||
const projectDir = path.join(workspacePath, PPTX_BASE, project);
|
||||
if (!fs.existsSync(projectDir)) { res.json({ images: [] }); return; }
|
||||
const IMG_EXT = /\.(jpe?g|png|gif|webp)$/i;
|
||||
const images: { path: string; name: string }[] = [];
|
||||
@@ -9522,7 +9982,7 @@ app.get('/api/pptx/project-images', requireGatewayAuth, async (req: express.Requ
|
||||
try {
|
||||
if (fs.statSync(abs).isDirectory()) { scan(abs, relF); continue; }
|
||||
} catch { continue; }
|
||||
if (IMG_EXT.test(f)) images.push({ path: `${project}/${relF}`, name: f });
|
||||
if (IMG_EXT.test(f)) images.push({ path: pptxRelPath(project, relF), name: f });
|
||||
}
|
||||
};
|
||||
scan(projectDir, '');
|
||||
@@ -9532,6 +9992,110 @@ app.get('/api/pptx/project-images', requireGatewayAuth, async (req: express.Requ
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/pptx/project-papers?project=<slug>
|
||||
// Returns papers.json manifest for a project, with hasText/hasTranslation flags
|
||||
app.get('/api/pptx/project-papers', requireGatewayAuth, async (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
const session = getSessionUser(req);
|
||||
const username = session?.username;
|
||||
const workspacePath = username ? getUserWorkspace(username) : getConfig().getWorkspacePath();
|
||||
const project = String(req.query.project || '').trim().replace(/\.\./g, '');
|
||||
if (!project) { res.status(400).json({ error: 'project required' }); return; }
|
||||
const manifestPath = path.join(workspacePath, PPTX_BASE, project, 'papers.json');
|
||||
if (!fs.existsSync(manifestPath)) { res.json({ success: true, papers: [] }); return; }
|
||||
const raw = fs.readFileSync(manifestPath, 'utf-8');
|
||||
const manifest = JSON.parse(raw);
|
||||
const papers = (manifest.papers || []).map((p: any) => {
|
||||
const isUpl = (p.key || '').startsWith('UPL_') || p._source === 'upload';
|
||||
if (isUpl) {
|
||||
// Upload papers: txt/pdf live in uploads/, ko.txt is written to project dir by save-papers
|
||||
const safeKey = (p.key || '').replace(/[^a-zA-Z0-9_\-]/g, '_');
|
||||
const koRelProject = pptxRelPath(project, `${safeKey}.ko.txt`);
|
||||
const txtPath = p.txtPath || null;
|
||||
const pdfPath = p.pdfPath || null;
|
||||
const hasText = !!txtPath && fs.existsSync(path.join(workspacePath, txtPath));
|
||||
const hasTranslation = fs.existsSync(path.join(workspacePath, koRelProject));
|
||||
return { ...p, txtPath, pdfPath, hasText,
|
||||
koPath: hasTranslation ? koRelProject : (p.koPath || null),
|
||||
hasTranslation };
|
||||
}
|
||||
// Regular papers: check project dir for companion files
|
||||
const base = (p.key || '').replace(/^UPL_/, '');
|
||||
const txtRel = base ? pptxRelPath(project, `${base}.txt`) : null;
|
||||
const koRel = base ? pptxRelPath(project, `${base}.ko.txt`) : null;
|
||||
const pdfRel = base ? pptxRelPath(project, `${base}.pdf`) : null;
|
||||
const hasText = !!txtRel && fs.existsSync(path.join(workspacePath, txtRel));
|
||||
const hasTranslation = !!koRel && fs.existsSync(path.join(workspacePath, koRel));
|
||||
const hasPdf = !!pdfRel && fs.existsSync(path.join(workspacePath, pdfRel));
|
||||
return {
|
||||
...p,
|
||||
txtPath: hasText ? txtRel : p.txtPath,
|
||||
koPath: hasTranslation ? koRel : p.koPath,
|
||||
pdfPath: hasPdf ? pdfRel : p.pdfPath,
|
||||
hasText,
|
||||
hasTranslation,
|
||||
};
|
||||
});
|
||||
res.json({ success: true, papers });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/pptx/save-papers
|
||||
// Saves papers.json manifest and optional .ko.txt translation files to project folder
|
||||
app.post('/api/pptx/save-papers', requireGatewayAuth, async (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
const session = getSessionUser(req);
|
||||
const username = session?.username;
|
||||
const workspacePath = username ? getUserWorkspace(username) : getConfig().getWorkspacePath();
|
||||
const project = pptxProjectSlug(req.body?.project);
|
||||
if (!project) { res.status(400).json({ error: 'project required' }); return; }
|
||||
const papers = req.body?.papers;
|
||||
if (!Array.isArray(papers)) { res.status(400).json({ error: 'papers array required' }); return; }
|
||||
const translations: Record<string, string> = req.body?.translations || {};
|
||||
const projectDir = path.join(workspacePath, PPTX_BASE, project);
|
||||
if (!fs.existsSync(projectDir)) fs.mkdirSync(projectDir, { recursive: true });
|
||||
// Security: project dir must be inside workspace
|
||||
if (!isPathInsideDir(workspacePath, projectDir)) { res.status(403).json({ error: 'Invalid project path' }); return; }
|
||||
// Write .ko.txt translation files
|
||||
for (const [key, text] of Object.entries(translations)) {
|
||||
if (typeof text !== 'string' || !text) continue;
|
||||
const safeKey = key.replace(/[^a-zA-Z0-9_\-]/g, '_');
|
||||
const koPath = path.join(projectDir, `${safeKey}.ko.txt`);
|
||||
if (isPathInsideDir(workspacePath, koPath)) {
|
||||
fs.writeFileSync(koPath, text, 'utf-8');
|
||||
}
|
||||
}
|
||||
// Write papers.json manifest
|
||||
const manifest = { version: 1, papers };
|
||||
fs.writeFileSync(path.join(projectDir, 'papers.json'), JSON.stringify(manifest, null, 2), 'utf-8');
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/pptx/save-outline — saves outline.json to project folder
|
||||
app.post('/api/pptx/save-outline', requireGatewayAuth, async (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
const session = getSessionUser(req);
|
||||
const username = session?.username;
|
||||
const workspacePath = username ? getUserWorkspace(username) : getConfig().getWorkspacePath();
|
||||
const project = pptxProjectSlug(req.body?.project);
|
||||
if (!project) { res.status(400).json({ error: 'project required' }); return; }
|
||||
const outlineData = req.body?.outline;
|
||||
if (!outlineData) { res.status(400).json({ error: 'outline required' }); return; }
|
||||
const projectDir = path.join(workspacePath, PPTX_BASE, project);
|
||||
if (!fs.existsSync(projectDir)) fs.mkdirSync(projectDir, { recursive: true });
|
||||
if (!isPathInsideDir(workspacePath, projectDir)) { res.status(403).json({ error: 'Invalid project path' }); return; }
|
||||
fs.writeFileSync(path.join(projectDir, 'outline.json'), JSON.stringify(outlineData), 'utf-8');
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/pptx/pdf-page?path=...&page=...&dpi=...
|
||||
// Renders a single PDF page as PNG, returns inline image + X-Page-Count header
|
||||
app.get('/api/pptx/pdf-page', requireGatewayAuth, async (req: express.Request, res: express.Response) => {
|
||||
@@ -9726,7 +10290,7 @@ app.post('/api/upload/image', (req: express.Request, res: express.Response) => {
|
||||
});
|
||||
|
||||
// Generic file upload endpoint -- accepts PDF, Excel, txt, docx, etc.
|
||||
app.post('/api/upload/file', (req: express.Request, res: express.Response) => {
|
||||
app.post('/api/upload/file', requireGatewayAuth, (req: express.Request, res: express.Response) => {
|
||||
const contentType = String(req.headers['content-type'] || '');
|
||||
if (!contentType.includes('multipart/form-data')) {
|
||||
res.status(400).json({ success: false, error: 'Content-Type must be multipart/form-data' }); return;
|
||||
@@ -12871,6 +13435,18 @@ app.get('/api/mcp/tools', (_req, res) => {
|
||||
app.use('/internal/agent-task', internalAgentTaskRouter);
|
||||
console.log('[InternalAgentTask] Endpoint mounted at POST /internal/agent-task');
|
||||
|
||||
// Global error handler — catches body-parser PayloadTooLarge and other middleware errors
|
||||
app.use((err: any, req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
if (err?.type === 'entity.too.large' || err?.status === 413) {
|
||||
const size = req.headers['content-length'] ? `${Math.round(Number(req.headers['content-length']) / 1024)}KB` : 'unknown size';
|
||||
console.error(`[413] PayloadTooLarge on ${req.method} ${req.path} (${size})`);
|
||||
res.status(413).json({ error: 'Request body too large', path: req.path });
|
||||
return;
|
||||
}
|
||||
console.error(`[500] Unhandled error on ${req.method} ${req.path}:`, err?.message || err);
|
||||
res.status(500).json({ error: String(err?.message || err) });
|
||||
});
|
||||
|
||||
app.get('/{*path}', (_req, res) => { res.sendFile(path.join(webUiPath, 'index.html')); });
|
||||
|
||||
|
||||
|
||||
+124
-4
@@ -15,6 +15,116 @@ function isPathInsideDir(base: string, target: string): boolean {
|
||||
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
const COLUMN_EXTRACT_SCRIPT = `
|
||||
import sys, fitz, re
|
||||
|
||||
pdf_path = sys.argv[1]
|
||||
page_from = int(sys.argv[2]) if len(sys.argv) > 2 else 1
|
||||
page_to = int(sys.argv[3]) if len(sys.argv) > 3 else 0
|
||||
|
||||
doc = fitz.open(pdf_path)
|
||||
total = doc.page_count
|
||||
end = min(page_to, total) if page_to > 0 else total
|
||||
|
||||
LINE_TOL = 4
|
||||
MARGIN = 0.07
|
||||
|
||||
FURNITURE_RE = re.compile(
|
||||
r'@[\\w.]+\\.|https?://|doi\\.org|\\u00a9|All rights are reserved'
|
||||
r'|Submitted,|Revised,|Accepted,|Published:'
|
||||
r'|Correspondence|Address correspondence'
|
||||
r'|Academic Editor|Licensee\\b|open access article'
|
||||
r'|ICMJE|Confl|\\$\\d+\\.\\d+|contributed equally|Potential Confl',
|
||||
re.IGNORECASE)
|
||||
HEADER_RE = re.compile(
|
||||
r'^(American Journal|Dentofacial Orthop|July \\d{4}|Vol \\d+|Issue \\d+|\\d{1,3}\\s*$)',
|
||||
re.IGNORECASE)
|
||||
|
||||
def is_furniture(text, y0, y1, ph):
|
||||
if y0 < ph*MARGIN or y1 > ph*(1-MARGIN): return True
|
||||
if HEADER_RE.search(text.strip()): return True
|
||||
if FURNITURE_RE.search(text): return True
|
||||
return False
|
||||
|
||||
def words_to_lines(wlist):
|
||||
wlist.sort(key=lambda w: (w[1], w[0]))
|
||||
groups, cur = [], []
|
||||
for w in wlist:
|
||||
if not cur or abs(w[1]-cur[-1][1]) <= LINE_TOL:
|
||||
cur.append(w)
|
||||
else:
|
||||
groups.append(cur); cur = [w]
|
||||
if cur: groups.append(cur)
|
||||
lines = []
|
||||
for g in groups:
|
||||
text = ' '.join(w[4] for w in sorted(g, key=lambda w: w[0]))
|
||||
text = re.sub(r'^([A-Z]) ([a-z][a-z])', lambda m: m.group(1)+m.group(2), text)
|
||||
lines.append((g[0][1], text))
|
||||
return lines
|
||||
|
||||
pages_text = []
|
||||
for pi in range(page_from-1, end):
|
||||
page = doc[pi]
|
||||
ph, pw = page.rect.height, page.rect.width
|
||||
mid = pw * 0.52
|
||||
full_w_thr = pw * 0.55
|
||||
|
||||
skip_rects = []
|
||||
for b in page.get_text("blocks"):
|
||||
x0,y0,x1,y1,txt = b[0],b[1],b[2],b[3],b[4]
|
||||
if is_furniture(txt, y0, y1, ph): skip_rects.append((x0,y0,x1,y1))
|
||||
|
||||
def in_skip(wx0,wy0,wx1,wy1):
|
||||
for sx0,sy0,sx1,sy1 in skip_rects:
|
||||
if wx0<sx1 and wx1>sx0 and wy0<sy1 and wy1>sy0: return True
|
||||
return False
|
||||
|
||||
block_info = {}
|
||||
for b in page.get_text("blocks"):
|
||||
bno=b[5]; bx0,by0,bx1,by1=b[0],b[1],b[2],b[3]
|
||||
bw=bx1-bx0
|
||||
block_info[bno]='full' if bw>=full_w_thr else ('left' if (bx0+bx1)/2<mid else 'right')
|
||||
|
||||
full_w, left_w, right_w = [], [], []
|
||||
for w in page.get_text("words"):
|
||||
wx0,wy0,wx1,wy1,word,bno=w[0],w[1],w[2],w[3],w[4],w[5]
|
||||
word=word.strip()
|
||||
if not word: continue
|
||||
if wy0<ph*MARGIN or wy1>ph*(1-MARGIN): continue
|
||||
if in_skip(wx0,wy0,wx1,wy1): continue
|
||||
col=block_info.get(bno,'left' if wx0<mid else 'right')
|
||||
if col=='full': full_w.append((wx0,wy0,wx1,wy1,word))
|
||||
elif col=='left': left_w.append((wx0,wy0,wx1,wy1,word))
|
||||
else: right_w.append((wx0,wy0,wx1,wy1,word))
|
||||
|
||||
full_lines = words_to_lines(full_w)
|
||||
left_lines = words_to_lines(left_w)
|
||||
right_lines = words_to_lines(right_w)
|
||||
|
||||
all_entries = [(y,'F',t) for y,t in full_lines] \
|
||||
+ [(y,'L',t) for y,t in left_lines] \
|
||||
+ [(y,'R',t) for y,t in right_lines]
|
||||
all_entries.sort(key=lambda e: (e[0] if e[1] in ('F','L') else e[0]+10000))
|
||||
pages_text.append('\\n'.join(t for _,_,t in all_entries))
|
||||
|
||||
full = '\\n\\n'.join(pages_text)
|
||||
|
||||
# drop cap 후처리
|
||||
lines = full.split('\\n')
|
||||
result = []
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
ln = lines[i].strip()
|
||||
if re.match(r'^[A-Z]$', ln) and i+1 < len(lines) and lines[i+1] and lines[i+1][0].islower():
|
||||
result.append(ln + lines[i+1])
|
||||
i += 2
|
||||
else:
|
||||
result.append(lines[i])
|
||||
i += 1
|
||||
|
||||
print('\\n'.join(result))
|
||||
`;
|
||||
|
||||
const OCR_SCRIPT = `
|
||||
import sys, os, subprocess, tempfile
|
||||
try:
|
||||
@@ -100,20 +210,29 @@ export const pdfReadTool = {
|
||||
return { success: false, error: 'File must have a .pdf extension' };
|
||||
}
|
||||
|
||||
const maxChars = Math.min(100_000, Math.max(1_000, Number(args?.max_chars ?? 20_000)));
|
||||
const maxChars = Math.min(200_000, Math.max(1_000, Number(args?.max_chars ?? 20_000)));
|
||||
const pageFrom = args?.page_from ? Math.max(1, Math.floor(Number(args.page_from))) : 1;
|
||||
const pageTo = args?.page_to ? Math.max(1, Math.floor(Number(args.page_to))) : 0;
|
||||
const forceOcr = args?.ocr === true;
|
||||
|
||||
let text = '';
|
||||
let method = 'pdftotext';
|
||||
let method = 'pymupdf';
|
||||
|
||||
if (!forceOcr) {
|
||||
const cmdArgs: string[] = ['-layout', '-enc', 'UTF-8'];
|
||||
// Primary: PyMuPDF column-aware extraction (handles 2-column academic PDFs)
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'python3', ['-c', COLUMN_EXTRACT_SCRIPT, resolved, String(pageFrom), String(pageTo)],
|
||||
{ maxBuffer: 20 * 1024 * 1024, timeout: 30_000 }
|
||||
);
|
||||
text = stdout.replace(/\r/g, '').trim();
|
||||
} catch (_pyErr) {
|
||||
// Fallback: pdftotext
|
||||
method = 'pdftotext';
|
||||
const cmdArgs: string[] = ['-enc', 'UTF-8'];
|
||||
if (pageFrom > 1) cmdArgs.push('-f', String(pageFrom));
|
||||
if (pageTo > 0) cmdArgs.push('-l', String(pageTo));
|
||||
cmdArgs.push(resolved, '-');
|
||||
|
||||
try {
|
||||
const result = await execFileAsync('pdftotext', cmdArgs, { maxBuffer: 20 * 1024 * 1024, timeout: 30_000 });
|
||||
text = result.stdout.replace(/\r/g, '').trim();
|
||||
@@ -125,6 +244,7 @@ export const pdfReadTool = {
|
||||
return { success: false, error: `pdftotext failed: ${msg}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to OCR if no text extracted
|
||||
if (!text) {
|
||||
|
||||
+1132
-101
File diff suppressed because it is too large
Load Diff
+679
-64
@@ -329,9 +329,33 @@ body{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,san
|
||||
<span id="s2-overall" style="display:none;font-size:11px;color:var(--brand2);font-weight:700"></span>
|
||||
</div>
|
||||
<div id="proglist"></div>
|
||||
<div id="img-search-section" style="margin-top:14px;border-top:1px solid var(--line);padding-top:10px">
|
||||
<div style="font-size:10px;color:var(--muted);font-weight:600;text-transform:uppercase;letter-spacing:.04em;margin-bottom:6px">🔍 이미지 검색</div>
|
||||
<div style="display:flex;gap:6px">
|
||||
<input id="img-search-input" type="text" placeholder="예: 구강 해부도, TMJ X-ray"
|
||||
style="flex:1;font-size:12px;padding:4px 8px;background:var(--panel2);border:1px solid var(--line);border-radius:5px;color:var(--fg);outline:none"
|
||||
onkeydown="if(event.key==='Enter')doImgSearch()">
|
||||
<button class="btn bg" style="font-size:11px;padding:3px 10px;flex-shrink:0" onclick="doImgSearch()">검색</button>
|
||||
</div>
|
||||
<div id="img-search-results" style="margin-top:8px;max-height:260px;overflow-y:auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex:2;min-width:0">
|
||||
<div style="font-size:10px;color:var(--muted);margin-bottom:8px;font-weight:600;text-transform:uppercase;letter-spacing:.04em">캡처 이미지 <span id="imgcnt"></span></div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
|
||||
<span style="font-size:10px;color:var(--muted);font-weight:600;text-transform:uppercase;letter-spacing:.04em">캡처 이미지 <span id="imgcnt"></span></span>
|
||||
<label style="display:flex;align-items:center;gap:4px;font-size:11px;color:var(--brand2);background:rgba(99,102,241,.1);border:1px solid var(--brand);border-radius:5px;padding:2px 8px;cursor:pointer;flex-shrink:0" title="이미지 파일 업로드">
|
||||
<input type="file" id="img-upload-input" accept="image/*" multiple style="display:none" onchange="handleImgUpload(this.files)">
|
||||
+ 이미지 업로드
|
||||
</label>
|
||||
</div>
|
||||
<div id="img-dropzone"
|
||||
style="border:2px dashed var(--line);border-radius:7px;padding:6px 10px;text-align:center;font-size:11px;color:var(--muted);margin-bottom:7px;cursor:pointer;transition:.15s"
|
||||
onclick="document.getElementById('img-upload-input').click()"
|
||||
ondragover="event.preventDefault();this.style.borderColor='var(--brand)';this.style.color='var(--brand2)'"
|
||||
ondragleave="this.style.borderColor='';this.style.color=''"
|
||||
ondrop="event.preventDefault();this.style.borderColor='';this.style.color='';handleImgUpload(event.dataTransfer.files)">
|
||||
이미지 파일을 여기에 드래그하거나 클릭
|
||||
</div>
|
||||
<div class="igrid" id="imgrid"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -365,6 +389,10 @@ body{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,san
|
||||
<div id="preview-grid" class="preview-grid">
|
||||
<div style="color:var(--muted);font-size:12px;text-align:center;padding:24px 0;grid-column:1/-1">PPTX 생성 후 프리뷰가 표시됩니다</div>
|
||||
</div>
|
||||
<div style="margin-top:14px;padding:8px 12px;border-top:1px solid var(--line);font-size:11px;color:var(--muted);line-height:1.7">
|
||||
↑ 상단 <strong>1 · 2 · 3</strong> 단계 버튼을 클릭하면 이전 단계로 돌아갈 수 있습니다. 
|
||||
논문 추가·변경 → <strong>1단계</strong>, PDF 처리 재실행 → <strong>2단계</strong>, 아웃라인 수정 → <strong>3단계</strong>.
|
||||
</div>
|
||||
</div>
|
||||
<div class="bbar">
|
||||
<span class="smsg" id="s4msg"></span><div class="sp"></div>
|
||||
@@ -379,6 +407,9 @@ body{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,san
|
||||
<div class="sidebar">
|
||||
<div class="ptitle">설정</div>
|
||||
<div class="scroll">
|
||||
<div class="field">
|
||||
<button class="btn bgreen" style="width:100%;font-size:12px;padding:7px 0" onclick="newProject()">+ 새 프로젝트</button>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="flabel">기존 프로젝트 열기</label>
|
||||
<div style="display:flex;gap:5px">
|
||||
@@ -386,6 +417,7 @@ body{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,san
|
||||
<option value="">— 선택 —</option>
|
||||
</select>
|
||||
<button class="btn bg" style="font-size:11px;padding:4px 8px;flex-shrink:0" onclick="loadProjList()" title="목록 새로고침">↺</button>
|
||||
<button class="btn bred" style="font-size:11px;padding:4px 8px;flex-shrink:0" onclick="deleteSelectedProject()" title="선택한 프로젝트 폴더 삭제">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
@@ -494,8 +526,10 @@ body{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,san
|
||||
<div class="mbg" id="txlbg" onclick="if(event.target===this)closeTxlModal()">
|
||||
<div class="modal txlmodal" onclick="event.stopPropagation()">
|
||||
<div class="txlmodal-hd">
|
||||
<span style="font-size:14px">📝</span>
|
||||
<span id="txl-title" style="font-size:13px;font-weight:700;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">번역</span>
|
||||
<span style="font-size:14px">📄</span>
|
||||
<span id="txl-title" style="font-size:13px;font-weight:700;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">전문</span>
|
||||
<button class="btn bg" style="padding:3px 10px;font-size:11px" id="txl-tab-en" onclick="switchTxlTab('en')">원문</button>
|
||||
<button class="btn bg" style="padding:3px 10px;font-size:11px" id="txl-tab-ko" onclick="switchTxlTab('ko')">번역</button>
|
||||
<button class="btn bg" style="padding:3px 10px;font-size:11px" onclick="copyTxlText()">복사</button>
|
||||
<button class="btn bg" style="padding:3px 10px;font-size:11px" onclick="closeTxlModal()">✕</button>
|
||||
</div>
|
||||
@@ -750,7 +784,105 @@ function paperLabel(key) {
|
||||
return key;
|
||||
}
|
||||
|
||||
// ── Open existing project ────────────────────────────────────────────────────
|
||||
// ── Save outline to project folder ──────────────────────────────────────────
|
||||
async function saveOutlineToProject() {
|
||||
if (!outline) return;
|
||||
const project = resolveProjectSlug();
|
||||
if (!project) return;
|
||||
try {
|
||||
await fetch('/api/pptx/save-outline', {
|
||||
...jh(), method: 'POST',
|
||||
body: JSON.stringify({ project, outline })
|
||||
});
|
||||
} catch (e) { console.warn('[wizard] save-outline failed:', e.message); }
|
||||
}
|
||||
|
||||
// ── Save papers to project (PDF paths, extracted texts, translations) ──────
|
||||
async function savePapersToProject() {
|
||||
const project = resolveProjectSlug();
|
||||
if (!project) return;
|
||||
const paperEntries = [];
|
||||
const translations = {};
|
||||
for (const key of selected) {
|
||||
const p = papers.find(x => paperKey(x) === key);
|
||||
if (!p) continue;
|
||||
const isUpl = key.startsWith('UPL_');
|
||||
const entry = {
|
||||
key,
|
||||
title: p.title || (isUpl ? key.slice(4) : ''),
|
||||
authors: p.authors || '',
|
||||
year: p.year || '',
|
||||
journal: p.journal || '',
|
||||
doi: p.doi || '',
|
||||
pmcid: p.pmcid || null,
|
||||
pmid: p.pmid || null,
|
||||
_source: isUpl ? 'upload' : (p._source || 'pubmed'),
|
||||
abstract: (paperMeta[key]?.abstract) || p.abstract || '',
|
||||
pdfPath: prepLog[key]?.pdfPath || null,
|
||||
txtPath: null,
|
||||
koPath: null,
|
||||
};
|
||||
// Determine txt/ko paths from pdfPath or key
|
||||
if (entry.pdfPath) {
|
||||
const baseName = entry.pdfPath.replace(/\.pdf$/i, '');
|
||||
if (prepLog[key]?.txt === 'done') entry.txtPath = baseName + '.txt';
|
||||
if (paperTranslations[key] && entry.txtPath) {
|
||||
entry.koPath = entry.txtPath.replace(/\.txt$/, '.ko.txt');
|
||||
translations[key] = paperTranslations[key];
|
||||
}
|
||||
} else if (prepLog[key]?.txt === 'done') {
|
||||
// No PDF but text exists (e.g. abstract-only fallback)
|
||||
entry.txtPath = project + '/' + key.replace(/[^a-zA-Z0-9_\-]/g, '_') + '.txt';
|
||||
if (paperTranslations[key]) {
|
||||
entry.koPath = entry.txtPath.replace(/\.txt$/, '.ko.txt');
|
||||
translations[key] = paperTranslations[key];
|
||||
}
|
||||
}
|
||||
paperEntries.push(entry);
|
||||
}
|
||||
if (!paperEntries.length) return;
|
||||
try {
|
||||
await fetch('/api/pptx/save-papers', {
|
||||
...jh(), method: 'POST',
|
||||
body: JSON.stringify({ project, papers: paperEntries, translations })
|
||||
});
|
||||
loadProjList();
|
||||
} catch (e) { console.warn('[wizard] save-papers failed:', e.message); }
|
||||
}
|
||||
|
||||
// ── New / Open existing project ──────────────────────────────────────────────
|
||||
function newProject(){
|
||||
papers=[]; selected=new Set(); paperMeta={};
|
||||
paperTexts={}; paperTranslations={}; txlStatus={};
|
||||
prepLog={}; extractedImages={}; abPaper=null;
|
||||
outline=null; outlineEdits={}; pptxRelPath=null; previewImages=[];
|
||||
document.querySelectorAll('.preview-card[data-edited]').forEach(c=>delete c.dataset.edited);
|
||||
const sel=document.getElementById('cfg-open-proj'); if(sel) sel.value='';
|
||||
const dlBtn=document.getElementById('dl-btn'); if(dlBtn) dlBtn.style.display='none';
|
||||
const regenBtn=document.getElementById('s4-regen'); if(regenBtn) regenBtn.style.display='none';
|
||||
document.getElementById('cfg-project').value='';
|
||||
document.getElementById('cfg-topic').value='';
|
||||
localStorage.removeItem('pptx-session');
|
||||
renderPapers(); renderProgList(); renderOutline();
|
||||
goStep(1);
|
||||
}
|
||||
async function deleteSelectedProject(){
|
||||
const sel=document.getElementById('cfg-open-proj');
|
||||
const relPath=sel?.value||'';
|
||||
if(!relPath){ alert('삭제할 프로젝트를 먼저 선택하세요.'); return; }
|
||||
const _dp=relPath.replace(/\\/g,'/').split('/');
|
||||
const project=relPath.endsWith('.pptx')&&_dp.length>=3 ? _dp[1] : _dp[0];
|
||||
if(!confirm(`프로젝트 폴더 "${project}" 를 완전히 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.`)) return;
|
||||
try{
|
||||
const r=await fetch('/api/pptx/delete-project',{...jh(),method:'DELETE',body:JSON.stringify({project})});
|
||||
const d=await r.json();
|
||||
if(!d.success) throw new Error(d.error||'삭제 실패');
|
||||
await loadProjList();
|
||||
sel.value='';
|
||||
alert(`"${project}" 삭제 완료.`);
|
||||
}catch(e){ alert('삭제 오류: '+e.message); }
|
||||
}
|
||||
|
||||
async function loadProjList(){
|
||||
const sel=document.getElementById('cfg-open-proj'); if(!sel)return;
|
||||
sel.innerHTML='<option value="">로딩 중...</option>';
|
||||
@@ -758,30 +890,101 @@ async function loadProjList(){
|
||||
const r=await fetch('/api/pptx/list',cr()); const d=await r.json();
|
||||
const items=d.items||[];
|
||||
sel.innerHTML='<option value="">— 선택 —</option>'
|
||||
+items.map(it=>`<option value="${esc(it.relPath)}">${esc(it.project)} / ${esc(it.filename)}</option>`).join('');
|
||||
+items.map(it=>`<option value="${esc(it.relPath)}">${esc(it.project)}${it.hasPptx?' / '+esc(it.filename):' 📂'}</option>`).join('');
|
||||
}catch(e){ sel.innerHTML='<option value="">오류: '+esc(e.message)+'</option>'; }
|
||||
}
|
||||
async function onOpenProjChange(relPath){
|
||||
if(!relPath)return;
|
||||
const parts=relPath.replace(/\\/g,'/').split('/');
|
||||
const project=parts[0];
|
||||
const hasPptx=relPath.endsWith('.pptx');
|
||||
// PPTX relPath = "pptx/{slug}/{file}.pptx" → parts[1], no-PPTX relPath = "{slug}" → parts[0]
|
||||
const project=(hasPptx && parts.length>=3) ? parts[1] : parts[0];
|
||||
const projInp=document.getElementById('cfg-project'); if(projInp) projInp.value=project;
|
||||
pptxRelPath=relPath;
|
||||
const dlBtn=document.getElementById('dl-btn');
|
||||
const fileUrl='/api/files/'+relPath.split('/').map(s=>encodeURIComponent(s)).join('/');
|
||||
dlBtn.href=fileUrl; dlBtn.style.display='inline-flex'; dlBtn.download=parts[parts.length-1];
|
||||
const regenBtn=document.getElementById('s4-regen'); if(regenBtn) regenBtn.style.display='';
|
||||
// 이전 편집 상태 초기화
|
||||
const regenBtn=document.getElementById('s4-regen');
|
||||
// 이전 편집/논문 상태 초기화
|
||||
outlineEdits={};
|
||||
document.querySelectorAll('.preview-card[data-edited]').forEach(c=>delete c.dataset.edited);
|
||||
// 아웃라인 역추출 — 편집 기능 활성화
|
||||
papers=[]; selected=new Set(); paperMeta={};
|
||||
paperTexts={}; paperTranslations={}; txlStatus={};
|
||||
prepLog={}; extractedImages=[]; abPaper=null;
|
||||
|
||||
// PPTX 없는 프로젝트 — step 1로 이동해서 논문 목록 확인
|
||||
if(!hasPptx){
|
||||
pptxRelPath=null;
|
||||
dlBtn.style.display='none';
|
||||
if(regenBtn) regenBtn.style.display='none';
|
||||
await loadExistingProjectPdfs();
|
||||
goStep(1);
|
||||
return;
|
||||
}
|
||||
|
||||
pptxRelPath=relPath;
|
||||
const fileUrl='/api/files/'+relPath.split('/').map(s=>encodeURIComponent(s)).join('/');
|
||||
dlBtn.href=fileUrl; dlBtn.style.display='inline-flex'; dlBtn.download=parts[parts.length-1];
|
||||
if(regenBtn) regenBtn.style.display='';
|
||||
|
||||
// Reload saved papers for this project
|
||||
let hasLoadedPapers = false;
|
||||
try {
|
||||
const pr = await fetch('/api/pptx/project-papers?project=' + encodeURIComponent(project), cr());
|
||||
const pd = await pr.json();
|
||||
if (pd.success && pd.papers && pd.papers.length) {
|
||||
// Restore papers array (excluding uploads which are session-only)
|
||||
papers = pd.papers.map(pe => ({
|
||||
title: pe.title || '',
|
||||
authors: pe.authors || '',
|
||||
year: pe.year || '',
|
||||
journal: pe.journal || '',
|
||||
doi: pe.doi || '',
|
||||
pmcid: pe.pmcid || null,
|
||||
pmid: pe.pmid || null,
|
||||
_source: pe._source || 'pubmed',
|
||||
abstract: pe.abstract || '',
|
||||
_fromSaved: true,
|
||||
}));
|
||||
// Restore selection
|
||||
selected = new Set(pd.papers.map(pe => pe.key));
|
||||
// Restore paperTexts and paperTranslations from saved files
|
||||
for (const pe of pd.papers) {
|
||||
if (pe.txtPath && pe.hasText) {
|
||||
try {
|
||||
const tr = await fetch('/api/files/' + pe.txtPath.split('/').map(s => encodeURIComponent(s)).join('/'), cr());
|
||||
if (tr.ok) paperTexts[pe.key] = await tr.text();
|
||||
} catch {}
|
||||
}
|
||||
if (pe.koPath && pe.hasTranslation) {
|
||||
try {
|
||||
const kr = await fetch('/api/files/' + pe.koPath.split('/').map(s => encodeURIComponent(s)).join('/'), cr());
|
||||
if (kr.ok) paperTranslations[pe.key] = await kr.text();
|
||||
} catch {}
|
||||
}
|
||||
if (pe.abstract) {
|
||||
paperMeta[pe.key] = { ...(paperMeta[pe.key] || {}), abstract: pe.abstract };
|
||||
}
|
||||
// Set prepLog so the prep UI shows completed state
|
||||
prepLog[pe.key] = {
|
||||
pdf: pe.pdfPath ? 'done' : 'skip',
|
||||
img: 'skip',
|
||||
txt: pe.txtPath && pe.hasText ? 'done' : (pe.abstract ? 'done' : 'skip'),
|
||||
pdfPath: pe.pdfPath || null,
|
||||
};
|
||||
if (pe.koPath && pe.hasTranslation) txlStatus[pe.key] = 'done';
|
||||
}
|
||||
renderPapers();
|
||||
updSelUI();
|
||||
renderProgList();
|
||||
hasLoadedPapers = true;
|
||||
}
|
||||
} catch (e) { console.warn('[wizard] project-papers load failed:', e.message); }
|
||||
|
||||
// 아웃라인 역추출 — PPTX → outline.json 순서로 시도
|
||||
try{
|
||||
const r=await fetch('/api/pptx/read-outline?path='+encodeURIComponent(relPath),cr());
|
||||
const d=await r.json();
|
||||
if(d.success&&d.outline){
|
||||
outline=d.outline;
|
||||
if(outline.slides) outline.slides=cleanSlideTexts(outline.slides);
|
||||
// 배경 스킨 동기화 — 편집창 색상 일치
|
||||
const skin=d.outline.background||'cream';
|
||||
selectedSkin=skin;
|
||||
document.querySelectorAll('.sed-skin-sw').forEach(sw=>sw.classList.toggle('active',sw.dataset.v===skin));
|
||||
@@ -789,8 +992,37 @@ async function onOpenProjChange(relPath){
|
||||
document.getElementById('s3create').disabled=false;
|
||||
}
|
||||
}catch(e){ console.warn('[wizard] outline read failed:',e.message); }
|
||||
// PPTX에서 못 읽었으면 outline.json 시도
|
||||
if(!outline){
|
||||
try{
|
||||
const proj=resolveProjectSlug();
|
||||
const ojPath='pptx/'+proj+'/outline.json';
|
||||
const r=await fetch('/api/files/'+ojPath.split('/').map(encodeURIComponent).join('/'),cr());
|
||||
if(r.ok){
|
||||
const d=await r.json();
|
||||
if(d && d.slides){
|
||||
outline=d;
|
||||
if(outline.slides) outline.slides=cleanSlideTexts(outline.slides);
|
||||
const skin=outline.background||'cream';
|
||||
selectedSkin=skin;
|
||||
document.querySelectorAll('.sed-skin-sw').forEach(sw=>sw.classList.toggle('active',sw.dataset.v===skin));
|
||||
renderOutline();
|
||||
document.getElementById('s3create').disabled=false;
|
||||
}
|
||||
}
|
||||
}catch{}
|
||||
}
|
||||
|
||||
// 디스크 PDF도 로드 시도 (papers.json 없는 프로젝트 포함)
|
||||
await loadExistingProjectPdfs();
|
||||
|
||||
if (hasLoadedPapers || papers.length > 0) {
|
||||
goStep(1);
|
||||
loadPreview(relPath); // 백그라운드로 프리뷰도 미리 로드
|
||||
} else {
|
||||
goStep(4);
|
||||
await loadPreview(relPath);
|
||||
}
|
||||
}
|
||||
(function(){
|
||||
loadProjList();
|
||||
@@ -976,7 +1208,8 @@ function goStep(n) {
|
||||
d.classList.toggle('active',i===n); d.classList.toggle('done',i<n);
|
||||
}
|
||||
currentStep=n;
|
||||
if(n===2){ renderProgList(); startPrep(); }
|
||||
if(n===1){ renderPapers(); renderUploadList(); updSelUI(); loadExistingProjectPdfs(); }
|
||||
if(n===2){ renderProgList(); loadExistingProjectPdfs(); }
|
||||
}
|
||||
|
||||
// ── STEP 1: Search ──────────────────────────────────────────────────────────
|
||||
@@ -1199,36 +1432,74 @@ function closeAbModal(){ document.getElementById('abg').classList.remove('open')
|
||||
|
||||
// ── PDF 텍스트 번역 ──────────────────────────────────────────────────────────
|
||||
const txlStartTime = {};
|
||||
const CHUNK_SIZE = 15000;
|
||||
|
||||
function splitChunks(text, size) {
|
||||
const chunks = [];
|
||||
let pos = 0;
|
||||
while (pos < text.length) {
|
||||
let end = pos + size;
|
||||
if (end < text.length) {
|
||||
const nl = text.lastIndexOf('\n\n', end);
|
||||
if (nl > pos) end = nl + 2;
|
||||
else { const n = text.lastIndexOf('\n', end); if (n > pos) end = n + 1; }
|
||||
}
|
||||
chunks.push(text.slice(pos, end));
|
||||
pos = end;
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
async function chatStream(msg, sessionId) {
|
||||
const r = await fetch('/api/chat', {method:'POST', credentials:'include',
|
||||
headers:{'Content-Type':'application/json','Accept':'text/event-stream'},
|
||||
body: JSON.stringify({message: msg, sessionId, direct: true})});
|
||||
if (!r.ok) throw new Error('API ' + r.status);
|
||||
const reader = r.body.getReader(), dec = new TextDecoder();
|
||||
let buf = '', reply = '';
|
||||
while (true) {
|
||||
const {done, value} = await reader.read(); if (done) break;
|
||||
buf += dec.decode(value, {stream: true});
|
||||
const lines = buf.split('\n'); buf = lines.pop();
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
try { const ev = JSON.parse(line.slice(6));
|
||||
if ((ev.type==='token'||ev.type==='text_delta') && ev.text) reply += ev.text;
|
||||
else if (ev.type==='final' && ev.text) reply = ev.text;
|
||||
else if (ev.type==='done' && ev.reply) reply = ev.reply;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return reply.trim();
|
||||
}
|
||||
|
||||
async function translatePdfText(key, text) {
|
||||
txlStatus[key]='running'; txlStartTime[key]=Date.now(); updProg(key);
|
||||
// 경과 시간 갱신 타이머
|
||||
const timer=setInterval(()=>{ if(txlStatus[key]==='running') updProg(key); else clearInterval(timer); },1000);
|
||||
const bodyEl=document.getElementById('txl-body');
|
||||
let reply='';
|
||||
try{
|
||||
const msg='다음 의학 논문 본문을 자연스러운 한국어로 번역해주세요. 원문의 단락 구조를 유지하세요:\n\n'+text.slice(0,3000);
|
||||
const r=await fetch('/api/chat',{method:'POST',credentials:'include',headers:{'Content-Type':'application/json','Accept':'text/event-stream'},body:JSON.stringify({message:msg,sessionId:'translate-pdf-'+key.replace(/[^a-z0-9]/gi,'_').slice(0,30),direct:true})});
|
||||
if(!r.ok) throw new Error('API '+r.status);
|
||||
const reader=r.body.getReader(),dec=new TextDecoder();
|
||||
let buf='';
|
||||
while(true){
|
||||
const {done,value}=await reader.read(); if(done)break;
|
||||
buf+=dec.decode(value,{stream:true});
|
||||
const lines=buf.split('\n'); buf=lines.pop();
|
||||
for(const line of lines){
|
||||
if(!line.startsWith('data: '))continue;
|
||||
try{ const ev=JSON.parse(line.slice(6));
|
||||
if((ev.type==='token'||ev.type==='text_delta')&&ev.text) reply+=ev.text;
|
||||
else if(ev.type==='final'&&ev.text) reply=ev.text;
|
||||
else if(ev.type==='done'&&ev.reply) reply=ev.reply;
|
||||
}catch{}
|
||||
const sid = 'translate-pdf-' + key.replace(/[^a-z0-9]/gi,'_').slice(0,30);
|
||||
const chunks = splitChunks(text, CHUNK_SIZE);
|
||||
const parts = new Array(chunks.length).fill(null);
|
||||
let failed = false;
|
||||
try {
|
||||
// 모든 청크를 병렬로 번역
|
||||
await Promise.all(chunks.map(async (chunk, i) => {
|
||||
const prompt = '다음 의학 논문 본문을 자연스러운 한국어로 번역해주세요. 원문의 단락 구조를 유지하세요:\n\n' + chunk;
|
||||
parts[i] = await chatStream(prompt, sid + '_c' + i);
|
||||
// 완료된 청크 수 표시
|
||||
const done = parts.filter(Boolean).length;
|
||||
paperTranslations[key] = parts.map((p,j)=>p||(j<done?'':'[번역 중...]')).filter(Boolean).join('\n\n');
|
||||
updProg(key);
|
||||
}));
|
||||
paperTranslations[key] = parts.join('\n\n').trim() || '번역 실패';
|
||||
txlStatus[key] = 'done';
|
||||
} catch(e) {
|
||||
paperTranslations[key] = parts.filter(Boolean).join('\n\n') + (parts.filter(Boolean).length ? '\n\n' : '') + '번역 오류: ' + e.message;
|
||||
txlStatus[key] = 'fail';
|
||||
}
|
||||
}
|
||||
paperTranslations[key]=reply.trim()||'번역 실패';
|
||||
txlStatus[key]='done';
|
||||
}catch(e){ paperTranslations[key]='번역 오류: '+e.message; txlStatus[key]='fail'; }
|
||||
clearInterval(timer);
|
||||
updProg(key);
|
||||
updOutlineBtn();
|
||||
savePapersToProject();
|
||||
}
|
||||
|
||||
function updOutlineBtn() {
|
||||
@@ -1255,13 +1526,36 @@ function updOutlineBtn() {
|
||||
}
|
||||
}
|
||||
|
||||
let txlViewKey=null, txlTabMode='en';
|
||||
function showPdfTranslation(key){
|
||||
document.getElementById('txl-title').textContent=paperLabel(key)+' — 번역';
|
||||
txlViewKey=key; txlTabMode='en';
|
||||
document.getElementById('txl-title').textContent=paperLabel(key)+' — 전문/번역';
|
||||
const body=document.getElementById('txl-body');
|
||||
body.textContent=paperTranslations[key]||'번역 없음';
|
||||
document.getElementById('txl-status').textContent=txlStatus[key]==='done'?'번역 완료':'번역 실패';
|
||||
body.textContent=paperTexts[key]||'전문 없음';
|
||||
document.getElementById('txl-status').textContent=txlStatus[key]==='done'?'번역 완료':txlStatus[key]==='fail'?'번역 실패':'';
|
||||
document.getElementById('txl-tab-en').style.background='var(--brand)';
|
||||
document.getElementById('txl-tab-en').style.color='#fff';
|
||||
document.getElementById('txl-tab-ko').style.background='';
|
||||
document.getElementById('txl-tab-ko').style.color='';
|
||||
document.getElementById('txlbg').classList.add('open');
|
||||
}
|
||||
function switchTxlTab(t){
|
||||
txlTabMode=t;
|
||||
const body=document.getElementById('txl-body');
|
||||
if(t==='en'){
|
||||
body.textContent=paperTexts[txlViewKey]||'전문 없음';
|
||||
document.getElementById('txl-tab-en').style.background='var(--brand)';
|
||||
document.getElementById('txl-tab-en').style.color='#fff';
|
||||
document.getElementById('txl-tab-ko').style.background='';
|
||||
document.getElementById('txl-tab-ko').style.color='';
|
||||
} else {
|
||||
body.textContent=paperTranslations[txlViewKey]||'번역 없음';
|
||||
document.getElementById('txl-tab-ko').style.background='var(--brand)';
|
||||
document.getElementById('txl-tab-ko').style.color='#fff';
|
||||
document.getElementById('txl-tab-en').style.background='';
|
||||
document.getElementById('txl-tab-en').style.color='';
|
||||
}
|
||||
}
|
||||
function closeTxlModal(){ document.getElementById('txlbg').classList.remove('open'); }
|
||||
function copyTxlText(){
|
||||
const t=document.getElementById('txl-body').textContent;
|
||||
@@ -1311,6 +1605,198 @@ function removeUpload(idx){
|
||||
}
|
||||
|
||||
// ── STEP 2: Prep ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Load existing PDFs from the project folder and papers.json manifest.
|
||||
// If the project already has PDFs on disk, add them as selectable papers
|
||||
// so the user doesn't have to re-download or re-search.
|
||||
async function loadExistingProjectPdfs() {
|
||||
const project = resolveProjectSlug();
|
||||
if (!project) return;
|
||||
|
||||
// 1) Try to load papers.json manifest first (has full metadata)
|
||||
let manifestPapers = [];
|
||||
try {
|
||||
const pr = await fetch('/api/pptx/project-papers?project=' + encodeURIComponent(project), cr());
|
||||
const pd = await pr.json();
|
||||
if (pd.success && pd.papers && pd.papers.length) {
|
||||
manifestPapers = pd.papers;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// 2) If we have manifest data and papers aren't already loaded, restore them
|
||||
if (manifestPapers.length && !papers.filter(p => !p._isUpload).length) {
|
||||
for (const pe of manifestPapers) {
|
||||
const existingKey = pe.key;
|
||||
if (selected.has(existingKey)) continue; // already in selection
|
||||
const isUplEntry = existingKey.startsWith('UPL_') || pe._source === 'upload';
|
||||
papers.push({
|
||||
title: pe.title || '',
|
||||
authors: pe.authors || '',
|
||||
year: pe.year || '',
|
||||
journal: pe.journal || '',
|
||||
doi: pe.doi || '',
|
||||
pmcid: pe.pmcid || null,
|
||||
pmid: pe.pmid || null,
|
||||
_source: pe._source || 'pubmed',
|
||||
abstract: pe.abstract || '',
|
||||
_fromSaved: true,
|
||||
...(isUplEntry ? { _isUpload: true, _localPdf: pe.pdfPath || '' } : {}),
|
||||
});
|
||||
selected.add(existingKey);
|
||||
|
||||
// Restore prepLog for already-processed papers
|
||||
if (!prepLog[existingKey]) {
|
||||
prepLog[existingKey] = {
|
||||
pdf: pe.pdfPath ? 'done' : 'skip',
|
||||
img: 'skip',
|
||||
txt: pe.txtPath && pe.hasText ? 'done' : 'skip',
|
||||
pdfPath: pe.pdfPath || null,
|
||||
};
|
||||
}
|
||||
// Restore paperTexts from .txt files
|
||||
if (pe.txtPath && pe.hasText && !paperTexts[existingKey]) {
|
||||
try {
|
||||
const tr = await fetch('/api/files/' + pe.txtPath.split('/').map(s => encodeURIComponent(s)).join('/'), cr());
|
||||
if (tr.ok) paperTexts[existingKey] = await tr.text();
|
||||
} catch {}
|
||||
}
|
||||
// Restore translations
|
||||
if (pe.koPath && pe.hasTranslation && !paperTranslations[existingKey]) {
|
||||
try {
|
||||
const kr = await fetch('/api/files/' + pe.koPath.split('/').map(s => encodeURIComponent(s)).join('/'), cr());
|
||||
if (kr.ok) { paperTranslations[existingKey] = await kr.text(); txlStatus[existingKey] = 'done'; }
|
||||
} catch {}
|
||||
}
|
||||
if (pe.abstract && !paperMeta[existingKey]) {
|
||||
paperMeta[existingKey] = { abstract: pe.abstract };
|
||||
}
|
||||
}
|
||||
renderPapers();
|
||||
updSelUI();
|
||||
// 프로젝트 폴더 이미지 로드 (UPL_ 전용 프로젝트도 포함)
|
||||
try {
|
||||
const ir = await fetch('/api/pptx/project-images?project=' + encodeURIComponent(project), cr());
|
||||
const id = await ir.json();
|
||||
if (id.images && id.images.length) {
|
||||
for (const f of id.images) {
|
||||
const url = '/api/files/' + f.path.split('/').map(s => encodeURIComponent(s)).join('/');
|
||||
if (!extractedImages.find(img => img.url === url))
|
||||
extractedImages.push({ url, label: f.name, pdf: null });
|
||||
}
|
||||
renderImgGrid();
|
||||
document.getElementById('imgcnt').textContent = '(' + extractedImages.length + ')';
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 3) Also scan for PDFs on disk that aren't in the manifest
|
||||
// (e.g. manually added, or from before papers.json was introduced)
|
||||
try {
|
||||
const r = await fetch('/api/pptx/project-pdfs?project=' + encodeURIComponent(project), cr());
|
||||
const d = await r.json();
|
||||
const pdfs = d.pdfs || [];
|
||||
for (const pdf of pdfs) {
|
||||
// pdf.path = "sinus/PMC8273047.pdf", pdf.name = "PMC8273047" (no extension)
|
||||
const pathFile = (pdf.path || '').split('/').pop() || '';
|
||||
const baseName = pdf.name || pathFile.replace(/\.pdf$/i, '') || '';
|
||||
if (!baseName) continue;
|
||||
const key = baseName.startsWith('PMC') ? baseName : 'UPL_' + baseName;
|
||||
if (selected.has(key) || papers.some(p => paperKey(p) === key)) continue; // already known
|
||||
|
||||
// Add as a selectable paper from existing disk file
|
||||
papers.push({
|
||||
_isUpload: true,
|
||||
_localPdf: pdf.path,
|
||||
_fromSaved: true,
|
||||
title: baseName,
|
||||
pmcid: baseName.startsWith('PMC') ? baseName.replace(/^PMC/i, '') : null,
|
||||
pmid: null, authors: '', journal: '', year: '', doi: '', pub_types: '',
|
||||
});
|
||||
selected.add(key);
|
||||
|
||||
// Set prepLog as done (PDF already on disk)
|
||||
if (!prepLog[key]) {
|
||||
prepLog[key] = { pdf: 'done', img: 'skip', txt: 'skip', pdfPath: pdf.path };
|
||||
}
|
||||
|
||||
// Load .txt text from companion file (server tells us if it exists)
|
||||
if (pdf.hasText && pdf.txtPath && !paperTexts[key]) {
|
||||
try {
|
||||
const tr = await fetch('/api/files/' + pdf.txtPath.split('/').map(s => encodeURIComponent(s)).join('/'), cr());
|
||||
if (tr.ok) {
|
||||
paperTexts[key] = await tr.text();
|
||||
prepLog[key].txt = 'done';
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
// Load .ko.txt translation from companion file
|
||||
if (pdf.hasTranslation && pdf.koPath && !paperTranslations[key]) {
|
||||
try {
|
||||
const kr = await fetch('/api/files/' + pdf.koPath.split('/').map(s => encodeURIComponent(s)).join('/'), cr());
|
||||
if (kr.ok) {
|
||||
paperTranslations[key] = await kr.text();
|
||||
txlStatus[key] = 'done';
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
// Try to load extracted images from {project}/{baseName}_images/ directory
|
||||
// Images are at the project directory root, scanned via project-images endpoint
|
||||
const imgDir = project + '/' + baseName + '_images';
|
||||
if (!extractedImages.some(img => img.url.includes(baseName + '_images'))) {
|
||||
try {
|
||||
const ir = await fetch('/api/pptx/project-images?project=' + encodeURIComponent(imgDir), cr());
|
||||
const id = await ir.json();
|
||||
if (id.images && id.images.length) {
|
||||
for (const f of id.images) {
|
||||
const url = '/api/files/' + f.path.split('/').map(s => encodeURIComponent(s)).join('/');
|
||||
if (!extractedImages.find(img => img.url === url)) {
|
||||
extractedImages.push({ url, label: baseName + ': ' + f.name, pdf: pdf.path });
|
||||
}
|
||||
}
|
||||
prepLog[key].img = 'done';
|
||||
prepLog[key].imgCount = id.images.length;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
// Also load images from the project directory root (extracted figures at root level)
|
||||
try {
|
||||
const ir = await fetch('/api/pptx/project-images?project=' + encodeURIComponent(project), cr());
|
||||
const id = await ir.json();
|
||||
if (id.images && id.images.length) {
|
||||
for (const f of id.images) {
|
||||
const url = '/api/files/' + f.path.split('/').map(s => encodeURIComponent(s)).join('/');
|
||||
if (!extractedImages.find(img => img.url === url)) {
|
||||
extractedImages.push({ url, label: f.name, pdf: null });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
if (pdfs.length) {
|
||||
renderPapers();
|
||||
renderUploadList();
|
||||
updSelUI();
|
||||
}
|
||||
renderImgGrid();
|
||||
document.getElementById('imgcnt').textContent = '(' + extractedImages.length + ')';
|
||||
} catch {}
|
||||
|
||||
// 5) Update UI — if all selected papers are already prepped, show completed state
|
||||
const allDone = Array.from(selected).every(k => {
|
||||
const s = prepLog[k] || {};
|
||||
return s.pdf === 'done' || s.pdf === 'skip';
|
||||
});
|
||||
renderProgList();
|
||||
if (allDone && selected.size > 0) {
|
||||
setMsg(2, '✓ 기존 PDF 복원됨 — 바로 아웃라인 생성 가능', 'var(--green)');
|
||||
// Show the outline button
|
||||
updOutlineBtn();
|
||||
// Change start button to "다시 실행"
|
||||
const btn = document.getElementById('s2start');
|
||||
if (btn) { btn.disabled = false; btn.textContent = '▶ 다시 실행'; }
|
||||
}
|
||||
}
|
||||
|
||||
function pspHtml(label, state, extra='') {
|
||||
const cls = state==='done'?'psp-done':state==='running'?'psp-run':state==='fail'?'psp-fail':state==='skip'?'psp-skip':'psp-wait';
|
||||
const icon = state==='done'?'✓ ':state==='running'?'⟳ ':state==='fail'?'✗ ':state==='skip'?'— ':'○ ';
|
||||
@@ -1339,17 +1825,35 @@ function progRowHtml(key, s) {
|
||||
|
||||
const sep='<span style="color:var(--muted);font-size:10px;margin:0 3px">→</span>';
|
||||
const pills=[pdfPill,imgPill,txtPill,txPill].filter(Boolean).join(sep);
|
||||
const subMsg=s.sub?`<div style="font-size:10px;color:var(--brand2);margin-top:3px;opacity:.85">⟳ ${esc(s.sub)}</div>`:'';
|
||||
|
||||
const capBtn=pdfPath?`<button class="btn bg" style="font-size:10px;padding:2px 8px;margin-top:5px" onclick="openPdfCapture('${esc(pdfPath)}','${esc(lbl)}')">📷 이미지 캡처</button>`:'';
|
||||
const txViewBtn=txSt==='done'?`<button class="btn bg" style="font-size:10px;padding:2px 8px;margin-top:5px;color:var(--green)" onclick="showPdfTranslation('${esc(key)}')">📝 번역 보기</button>`:'';
|
||||
const txtViewBtn=paperTexts[key]?`<button class="btn bg" style="font-size:10px;padding:2px 8px;margin-top:5px;color:var(--brand2)" onclick="showPdfTranslation('${esc(key)}')">📄 전문 보기</button>`:'';
|
||||
const txViewBtn=txSt==='done'?`<button class="btn bg" style="font-size:10px;padding:2px 8px;margin-top:5px;color:var(--green)" onclick="switchTxlTab('ko');showPdfTranslation('${esc(key)}');switchTxlTab('ko')">📝 번역 보기</button>`:'';
|
||||
const txStartBtn=(paperTexts[key]&&txSt!=='done'&&txSt!=='running')?`<button class="btn bg" style="font-size:10px;padding:2px 8px;margin-top:5px" onclick="translatePdfText('${esc(key)}',paperTexts['${esc(key)}'])">🌐 번역</button>`:'';
|
||||
|
||||
// 이미 추출된 번역/텍스트 미리보기 (첫 120자)
|
||||
const previewText = paperTranslations[key]
|
||||
? paperTranslations[key].slice(0, 120).replace(/\s+/g,' ').trim() + (paperTranslations[key].length > 120 ? '…' : '')
|
||||
: (paperTexts[key]
|
||||
? paperTexts[key].slice(0, 120).replace(/\s+/g,' ').trim() + (paperTexts[key].length > 120 ? '…' : '')
|
||||
: '');
|
||||
const previewTab = paperTranslations[key] ? 'ko' : 'en';
|
||||
const previewHtml = previewText
|
||||
? `<div style="font-size:10px;color:var(--muted);margin-top:4px;line-height:1.5;font-style:italic;cursor:pointer" onclick="switchTxlTab('${previewTab}');showPdfTranslation('${esc(key)}')" title="클릭하면 전문 보기">${esc(previewText)} <span style="color:var(--brand2);font-style:normal">전문 ↗</span></div>`
|
||||
: '';
|
||||
|
||||
return `<div style="font-size:11px;font-weight:600;margin-bottom:5px;word-break:break-all">${esc(lbl)}</div>
|
||||
<div style="display:flex;align-items:center;flex-wrap:wrap;gap:2px">${pills}</div>
|
||||
${previewHtml}
|
||||
${subMsg}
|
||||
${s.error?(s.error.includes('초록으로 대체')
|
||||
?`<div style="font-size:10px;color:#f59e0b;margin-top:4px">⚠ PDF 다운로드 불가 — 초록으로 대체됨</div>`
|
||||
:`<div style="font-size:10px;color:var(--red);margin-top:4px">${errHtml(s.error)}</div>`)
|
||||
:(s.error.includes('전문 텍스트로 대체')
|
||||
?`<div style="font-size:10px;color:#f59e0b;margin-top:4px">⚠ PDF 없음 — 전문 텍스트로 대체됨</div>`
|
||||
:`<div style="font-size:10px;color:var(--red);margin-top:4px">${errHtml(s.error)}</div>`))
|
||||
:''}
|
||||
${(capBtn||txViewBtn)?`<div style="display:flex;gap:6px;flex-wrap:wrap">${capBtn}${txViewBtn}</div>`:''}`;
|
||||
${(capBtn||txtViewBtn||txViewBtn||txStartBtn)?`<div style="display:flex;gap:6px;flex-wrap:wrap">${capBtn}${txtViewBtn}${txViewBtn}${txStartBtn}</div>`:''}`;
|
||||
}
|
||||
|
||||
function renderProgList() {
|
||||
@@ -1382,16 +1886,20 @@ function updOverall() {
|
||||
else { el.style.display='none'; }
|
||||
}
|
||||
|
||||
// project slug 결정: cfg-project 값 → 첫 번째 논문 키 기반 자동 생성
|
||||
// project slug 결정: cfg-project → cfg-topic → 첫 논문 키 → 타임스탬프
|
||||
function resolveProjectSlug(){
|
||||
const inp=(document.getElementById('cfg-project')?.value||'').trim();
|
||||
if(inp) return inp.replace(/[^a-zA-Z0-9가-힣_\-]/g,'_').replace(/_+/g,'_').replace(/^_+|_+$/g,'').toLowerCase().slice(0,60);
|
||||
// 검색 주제어로 추론
|
||||
const topic=(document.getElementById('cfg-topic')?.value||'').trim();
|
||||
if(topic) return topic.replace(/[^a-zA-Z0-9가-힣]/g,'_').replace(/_+/g,'_').replace(/^_+|_+$/g,'').toLowerCase().slice(0,40);
|
||||
// 첫 번째 선택 항목으로 추론
|
||||
const firstKey=Array.from(selected)[0]||'';
|
||||
if(firstKey.startsWith('PMC')) return firstKey.toLowerCase();
|
||||
const firstPaper=papers.find(p=>paperKey(p)===firstKey);
|
||||
if(firstPaper?.title) return firstPaper.title.replace(/[^a-zA-Z0-9가-힣]/g,'_').replace(/_+/g,'_').toLowerCase().slice(0,40);
|
||||
return '';
|
||||
// 최후 폴백: 날짜 기반
|
||||
return 'project_'+new Date().toISOString().slice(0,10).replace(/-/g,'');
|
||||
}
|
||||
|
||||
async function startPrep() {
|
||||
@@ -1399,15 +1907,13 @@ async function startPrep() {
|
||||
if(btn.disabled) return; // 이미 실행 중
|
||||
btn.disabled=true; btn.textContent='진행 중...';
|
||||
setMsg(2,'준비 중...','var(--brand2)');
|
||||
const keys=Array.from(selected); prepLog={}; renderProgList(); extractedImages=[]; paperTexts={};
|
||||
const keys=Array.from(selected); prepLog={}; renderProgList(); extractedImages=[];
|
||||
document.getElementById('imgrid').innerHTML='<div style="color:var(--muted);font-size:11px">📷 이미지 캡처 버튼으로 추가하세요</div>';
|
||||
|
||||
// 프로젝트 slug 결정 — PDF/이미지를 프로젝트 폴더에 직접 저장
|
||||
// 프로젝트 slug 결정 — PDF/이미지를 프로젝트 폴더에 직접 저장 (항상 결정)
|
||||
const prepProject=resolveProjectSlug();
|
||||
if(prepProject){
|
||||
const inp=document.getElementById('cfg-project');
|
||||
if(inp&&!inp.value) inp.value=prepProject;
|
||||
}
|
||||
|
||||
for(const key of keys){
|
||||
const isUpload=key.startsWith('UPL_');
|
||||
@@ -1439,11 +1945,45 @@ async function startPrep() {
|
||||
if(!pdfPath){ prepLog[key].img='fail'; prepLog[key].txt='fail'; prepLog[key].error='PDF 경로 확인 불가'; updProg(key); continue; }
|
||||
setMsg(2,paperLabel(key)+' 이미지 추출...','var(--brand2)');
|
||||
} else {
|
||||
// PMC 다운로드
|
||||
// PMC 다운로드 (SSE 스트리밍)
|
||||
setMsg(2,key+' PDF 다운로드...','var(--brand2)');
|
||||
const dlr=await fetch('/api/pptx/download-pdf',{...jh(),method:'POST',body:JSON.stringify({pmcid:key,project:prepProject})});
|
||||
const dld=await dlr.json();
|
||||
const dld = await new Promise(async (resolve)=>{
|
||||
const r=await fetch('/api/pptx/download-pdf-stream',{...jh(),method:'POST',body:JSON.stringify({pmcid:key,project:prepProject})});
|
||||
const reader=r.body.getReader(); const dec=new TextDecoder();
|
||||
let buf='', result=null;
|
||||
while(true){
|
||||
const {done,value}=await reader.read(); if(done) break;
|
||||
buf+=dec.decode(value,{stream:true});
|
||||
const lines=buf.split('\n'); buf=lines.pop();
|
||||
for(const line of lines){
|
||||
if(!line.startsWith('data: ')) continue;
|
||||
try{
|
||||
const ev=JSON.parse(line.slice(6));
|
||||
if(ev.type==='progress'){
|
||||
prepLog[key].sub=ev.msg; updProg(key);
|
||||
} else if(ev.type==='done'){
|
||||
result=ev;
|
||||
}
|
||||
}catch{}
|
||||
}
|
||||
}
|
||||
prepLog[key].sub=''; updProg(key);
|
||||
resolve(result||{success:false,error:'응답 없음'});
|
||||
});
|
||||
if(!dld.success&&!dld.stdout) throw new Error(dld.error||'다운로드 실패');
|
||||
|
||||
// 텍스트 폴백: PDF 대신 efetch 전문 텍스트를 받은 경우
|
||||
if(dld._textFallback && dld._txtPath){
|
||||
prepLog[key].pdf='fail'; prepLog[key].pdfPath=null;
|
||||
prepLog[key].img='skip'; prepLog[key].txt='done';
|
||||
prepLog[key].error='PDF 없음 → 전문 텍스트로 대체';
|
||||
const txtContent=dld.stdout||'';
|
||||
if(txtContent) paperTexts[key]=txtContent.slice(0,200000);
|
||||
updProg(key); updOverall();
|
||||
if(autoTranslate && paperTexts[key]) translatePdfText(key, paperTexts[key]);
|
||||
continue;
|
||||
}
|
||||
|
||||
const pm=(dld.stdout||dld.path||'').match(/((?:pubmed|[a-z0-9_-]+)\/[^\s'"]+\.pdf)/i);
|
||||
pdfPath=pm?pm[1]:null;
|
||||
prepLog[key].pdf='done'; prepLog[key].pdfPath=pdfPath; updProg(key);
|
||||
@@ -1482,9 +2022,10 @@ async function startPrep() {
|
||||
prepLog[key].txt='running'; updProg(key);
|
||||
setMsg(2,paperLabel(key)+' 텍스트 추출...','var(--brand2)');
|
||||
try{
|
||||
const txr=await fetch('/api/pptx/read-pdf',{...jh(),method:'POST',body:JSON.stringify({pdf_path:pdfPath})});
|
||||
const txtSavePath=pdfPath.replace(/\.pdf$/i,'.txt');
|
||||
const txr=await fetch('/api/pptx/read-pdf',{...jh(),method:'POST',body:JSON.stringify({pdf_path:pdfPath,save_path:txtSavePath,max_chars:200000})});
|
||||
const txd=await txr.json();
|
||||
if(txd.success||txd.stdout){ paperTexts[key]=(txd.stdout||txd.data?.text||'').slice(0,4000); prepLog[key].txt='done'; }
|
||||
if(txd.success||txd.stdout){ paperTexts[key]=(txd.stdout||txd.data?.text||'').slice(0,200000); prepLog[key].txt='done'; }
|
||||
else{ prepLog[key].txt='fail'; }
|
||||
}catch(e2){ prepLog[key].txt='fail'; }
|
||||
} else {
|
||||
@@ -1522,6 +2063,8 @@ async function startPrep() {
|
||||
document.getElementById('imgcnt').textContent='('+extractedImages.length+')';
|
||||
// 모든 논문이 PDF 번역 없이 끝났거나 일부가 실패한 경우에도 아웃라인 버튼을 노출
|
||||
updOutlineBtn();
|
||||
// Save paper metadata, texts, and translations to project folder
|
||||
savePapersToProject();
|
||||
}
|
||||
|
||||
function renderImgGrid(){
|
||||
@@ -1529,17 +2072,87 @@ function renderImgGrid(){
|
||||
document.getElementById('imgcnt').textContent='('+extractedImages.length+')';
|
||||
if(!extractedImages.length){g.innerHTML='<div style="color:var(--muted);font-size:11px">없음</div>';return;}
|
||||
g.innerHTML=extractedImages.map((img,i)=>
|
||||
`<div class="icard" title="${esc(img.label)}">
|
||||
<img src="${esc(img.url)}" loading="lazy" onerror="this.style.display='none'" onclick="showImgPreview('${esc(img.url)}','${esc(img.label)}')" style="cursor:zoom-in">
|
||||
<div class="ilabel" style="display:flex;align-items:center;gap:3px;padding:2px 4px">
|
||||
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:9px">${esc(img.label)}</span>
|
||||
<button onclick="event.stopPropagation();deleteImg(${i})" style="flex-shrink:0;background:rgba(239,68,68,.75);border:none;color:#fff;border-radius:2px;padding:1px 5px;font-size:9px;cursor:pointer;line-height:1.4" title="삭제">✕</button>
|
||||
</div>
|
||||
`<div class="icard" title="${esc(img.label)}" style="position:relative" onmouseenter="this.querySelector('.icard-del').style.opacity=1" onmouseleave="this.querySelector('.icard-del').style.opacity=0">
|
||||
<img src="${esc(img.url)}" loading="lazy" onerror="this.style.display='none'" onclick="showImgPreview('${esc(img.url)}','${esc(img.label)}')" style="cursor:zoom-in;width:100%;height:100%;object-fit:cover">
|
||||
<button class="icard-del" onclick="event.stopPropagation();deleteImg(${i})" style="position:absolute;top:4px;right:4px;opacity:0;transition:.15s;background:rgba(220,38,38,.85);border:none;color:#fff;border-radius:4px;width:22px;height:22px;font-size:13px;cursor:pointer;display:flex;align-items:center;justify-content:center;line-height:1" title="삭제">✕</button>
|
||||
<div class="ilabel" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:9px;padding:2px 5px">${esc(img.label)}</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
function deleteImg(i){
|
||||
extractedImages.splice(i,1);
|
||||
async function deleteImg(i){
|
||||
const img = extractedImages[i];
|
||||
if (img) {
|
||||
// /api/files/<relpath> 형태에서 relpath 추출
|
||||
const url = img.url || '';
|
||||
const prefix = '/api/files/';
|
||||
if (url.startsWith(prefix)) {
|
||||
const relPath = url.slice(prefix.length).split('/').map(decodeURIComponent).join('/');
|
||||
try {
|
||||
await fetch('/api/pptx/delete-image', { ...jh(), method: 'DELETE', body: JSON.stringify({ path: relPath }) });
|
||||
} catch (e) { console.warn('[deleteImg]', e); }
|
||||
}
|
||||
}
|
||||
extractedImages.splice(i, 1);
|
||||
renderImgGrid();
|
||||
}
|
||||
|
||||
async function doImgSearch() {
|
||||
const q = document.getElementById('img-search-input').value.trim();
|
||||
const res = document.getElementById('img-search-results');
|
||||
if (!q) return;
|
||||
res.innerHTML = '<div style="font-size:11px;color:var(--muted)">검색 중…</div>';
|
||||
try {
|
||||
const r = await fetch('/api/pptx/search-images', { ...jh(), method:'POST', body:JSON.stringify({query:q,count:6}) });
|
||||
const d = await r.json();
|
||||
if (!d.images?.length) { res.innerHTML = '<div style="font-size:11px;color:var(--muted)">결과 없음</div>'; return; }
|
||||
res.innerHTML = `<div class="igrid" style="grid-template-columns:repeat(auto-fill,minmax(85px,1fr))">${d.images.map(img=>`<div class="icard" style="aspect-ratio:4/3"><img src="${esc(img.url)}" loading="lazy" onerror="this.parentNode.style.display='none'" onclick="showImgPreview('${esc(img.url)}','${esc(img.label)}')" style="width:100%;height:100%;object-fit:cover;cursor:zoom-in"><div class="ilabel">${esc(img.label)}</div><button style="position:absolute;top:2px;right:2px;background:rgba(99,102,241,.85);border:none;color:#fff;border-radius:4px;width:20px;height:20px;font-size:13px;cursor:pointer;line-height:1" onclick="addSearchImg('${esc(img.url)}','${esc(img.label)}',this)" title="갤러리에 추가">+</button></div>`).join('')}</div>`;
|
||||
} catch(e) { res.innerHTML = `<div style="font-size:11px;color:var(--danger)">${esc(e.message)}</div>`; }
|
||||
}
|
||||
|
||||
async function addSearchImg(url, label, btn) {
|
||||
const project = resolveProjectSlug();
|
||||
if (!project) { alert('프로젝트를 먼저 선택하세요'); return; }
|
||||
btn.textContent = '…'; btn.disabled = true;
|
||||
try {
|
||||
const r = await fetch('/api/pptx/save-image-url', { ...jh(), method:'POST', body:JSON.stringify({url,project}) });
|
||||
const d = await r.json();
|
||||
if (d.success) {
|
||||
if (!extractedImages.find(img => img.url === d.url)) extractedImages.push({ url: d.url, label, pdf: null });
|
||||
renderImgGrid();
|
||||
btn.textContent = '✓'; btn.style.background = 'rgba(34,197,94,.85)';
|
||||
} else { btn.textContent = '✗'; btn.disabled = false; }
|
||||
} catch { btn.textContent = '✗'; btn.disabled = false; }
|
||||
}
|
||||
|
||||
async function handleImgUpload(files) {
|
||||
if (!files || !files.length) return;
|
||||
const project = resolveProjectSlug();
|
||||
if (!project) { alert('먼저 프로젝트 폴더명을 입력하거나 PDF 준비를 시작해주세요.'); return; }
|
||||
const dz = document.getElementById('img-dropzone');
|
||||
const origTxt = dz ? dz.textContent : '';
|
||||
const total = files.length;
|
||||
let done = 0;
|
||||
for (const file of files) {
|
||||
if (dz) dz.textContent = `업로드 중... ${done+1}/${total}`;
|
||||
const fd = new FormData();
|
||||
fd.append('image', file, file.name);
|
||||
fd.append('project', project);
|
||||
try {
|
||||
const r = await fetch('/api/pptx/upload-image', { method: 'POST', credentials: 'include', body: fd });
|
||||
const d = await r.json();
|
||||
if (d.success) {
|
||||
const label = file.name.replace(/\.[^.]+$/, '');
|
||||
if (!extractedImages.find(img => img.url === d.url)) {
|
||||
extractedImages.push({ url: d.url, label, pdf: null });
|
||||
}
|
||||
} else {
|
||||
console.warn('[img upload]', d.error);
|
||||
}
|
||||
} catch (e) { console.warn('[img upload]', e); }
|
||||
done++;
|
||||
}
|
||||
if (dz) dz.textContent = origTxt;
|
||||
document.getElementById('img-upload-input').value = '';
|
||||
renderImgGrid();
|
||||
}
|
||||
|
||||
@@ -1605,7 +2218,8 @@ async function generateOutline() {
|
||||
const m=reply.match(/```pptx-outline\s*\n([\s\S]*?)```/);
|
||||
stopTimer();
|
||||
if(m){ outline=JSON.parse(m[1]); if(outline.slides) outline.slides=cleanSlideTexts(outline.slides); renderOutline(); document.getElementById('s3create').disabled=false; setMsg(3,'아웃라인 준비 ('+(outline.slides||[]).length+'장)','var(--green)');
|
||||
const projInp=document.getElementById('cfg-project'); if(projInp&&!projInp.value&&outline.project) projInp.value=outline.project; }
|
||||
const projInp=document.getElementById('cfg-project'); if(projInp&&!projInp.value&&outline.project) projInp.value=outline.project;
|
||||
saveOutlineToProject(); }
|
||||
else setMsg(3,'pptx-outline 블록 없음. 다시 시도하세요.','var(--yellow)');
|
||||
}catch(e){ stopTimer(); setMsg(3,'오류: '+e.message,'var(--red)'); }
|
||||
finally{ clearInterval(phTimer); }
|
||||
@@ -2510,6 +3124,7 @@ function closeSed(){
|
||||
sedFromPreview=false;
|
||||
editorIdx=-1;
|
||||
renderOutline();
|
||||
if(wasDirty) saveOutlineToProject();
|
||||
if(wasPreview && wasDirty) afterSedPreview(savedIdx); // 실제 변경이 있을 때만
|
||||
}
|
||||
function afterSedPreview(editedIdx){
|
||||
|
||||
Reference in New Issue
Block a user