From d9df8005245b94874fe83c8f057b2acb8196e762 Mon Sep 17 00:00:00 2001 From: kim Date: Mon, 1 Jun 2026 23:29:54 +0900 Subject: [PATCH] Release 2.9.1: PPTX wizard project persistence + PDF column extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/gateway/server-v2.ts | 656 ++++++++++++++++++-- src/tools/pdf.ts | 148 ++++- web-ui/code.js | 1251 ++++++++++++++++++++++++++++++++++---- web-ui/pptx-wizard.html | 753 ++++++++++++++++++++--- 4 files changed, 2575 insertions(+), 233 deletions(-) diff --git a/src/gateway/server-v2.ts b/src/gateway/server-v2.ts index ff1c7ec..ba79599 100644 --- a/src/gateway/server-v2.ts +++ b/src/gateway/server-v2.ts @@ -1245,7 +1245,7 @@ const TOOL_BLOCKS: Record = { 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 ") 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 → /. 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 ![term](image_url) 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 → /. 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 ![term](image_url) 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), \`\` (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), \`\` (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(); + 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= — 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= +// 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 = 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')); }); diff --git a/src/tools/pdf.ts b/src/tools/pdf.ts index f8ca17b..5437086 100644 --- a/src/tools/pdf.ts +++ b/src/tools/pdf.ts @@ -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 wx0sx0 and wy0sy0: 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)/2ph*(1-MARGIN): continue + if in_skip(wx0,wy0,wx1,wy1): continue + col=block_info.get(bno,'left' if wx0 1) cmdArgs.push('-f', String(pageFrom)); - if (pageTo > 0) cmdArgs.push('-l', String(pageTo)); - cmdArgs.push(resolved, '-'); - + // Primary: PyMuPDF column-aware extraction (handles 2-column academic PDFs) try { - const result = await execFileAsync('pdftotext', cmdArgs, { maxBuffer: 20 * 1024 * 1024, timeout: 30_000 }); - text = result.stdout.replace(/\r/g, '').trim(); - } catch (err: any) { - const msg = String(err.message || ''); - if (msg.toLowerCase().includes('encrypt') || msg.toLowerCase().includes('password')) { - return { success: false, error: 'PDF is password-protected or encrypted' }; + 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(); + } catch (err: any) { + const msg = String(err.message || ''); + if (msg.toLowerCase().includes('encrypt') || msg.toLowerCase().includes('password')) { + return { success: false, error: 'PDF is password-protected or encrypted' }; + } + return { success: false, error: `pdftotext failed: ${msg}` }; } - return { success: false, error: `pdftotext failed: ${msg}` }; } } diff --git a/web-ui/code.js b/web-ui/code.js index c3a8e0a..64bab8a 100644 --- a/web-ui/code.js +++ b/web-ui/code.js @@ -3,6 +3,8 @@ // // This is a self-contained extract of the code-editor view that also lives // inside the main app (web-ui/app.js). It is loaded ONLY by code.html so the + +const _DBG = false; // set true to enable streaming/editor debug logs // editor can run in its own browser tab (opened from the 💻 코드 sidebar tab), // mirroring how the slide wizard opens pptx-wizard.html. // @@ -52,6 +54,8 @@ let codeAiHistory = []; let codeAiAbort = null; let codeAiSessionId = 'code_ai_' + Math.random().toString(36).slice(2, 10); let codeAiModel = localStorage.getItem('codeAiModel') ?? 'glm-5.1:cloud'; +let codeAiMode = localStorage.getItem('codeAiMode') ?? 'code'; // 'code' or 'ask' +let codeDiffState = {}; // { fileId: { originalText, modifiedText, hunks, decorationIds, viewZoneIds, widgetIds, resolved, onReject } } const CODE_LANG_MAP = { js:'javascript', ts:'typescript', jsx:'javascript', tsx:'typescript', @@ -71,11 +75,22 @@ function updateCodeAiLabel() { const title = document.getElementById('code-ai-title'); if (title) title.textContent = '💻 Coder-' + label; const input = document.getElementById('code-ai-input'); - if (input) input.placeholder = 'Coder-' + label + '에게 질문...'; + if (input) input.placeholder = (codeAiMode === 'ask' ? 'Ask-' : 'Coder-') + label + '에게 질문...'; +} + +function setCodeAiMode(mode) { + codeAiMode = mode; + localStorage.setItem('codeAiMode', mode); + const codeBtn = document.getElementById('code-ai-mode-code'); + const askBtn = document.getElementById('code-ai-mode-ask'); + if (codeBtn) codeBtn.classList.toggle('active', mode === 'code'); + if (askBtn) askBtn.classList.toggle('active', mode === 'ask'); + updateCodeAiLabel(); } function initCodeView() { updateCodeAiLabel(); + setCodeAiMode(codeAiMode); // Initialize mode selector UI if (monacoReady) { setTimeout(() => { if (monacoEditor) monacoEditor.layout(); }, 50); return; } if (typeof require === 'undefined') return; require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs' } }); @@ -86,8 +101,8 @@ function initCodeView() { theme: isDark ? 'vs-dark' : 'vs', language: 'plaintext', automaticLayout: true, - fontSize: 14, - lineHeight: 22, + fontSize: 12, + lineHeight: 19, fontFamily: "'IBM Plex Mono', 'Fira Code', 'Courier New', monospace", minimap: { enabled: true }, scrollBeyondLastLine: false, @@ -99,8 +114,10 @@ function initCodeView() { }); monacoEditor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, codeSaveFile); codeFlushPendingQueue(); - if (!codeFiles.length && !codePendingQueue.length) codeNewFile('main.py', '# 여기에 코드를 작성하세요\n'); - else renderCodeFileTabs(); + if (!codeFiles.length && !codePendingQueue.length) { + // Don't create a placeholder file — the model will create files as needed + renderCodeFileTabs(); + } else renderCodeFileTabs(); setupCodeVResize(); csbRestoreSettings(); setupCodeEditorDrop(); @@ -120,12 +137,22 @@ function codeNewFile(name, content) { function switchCodeFile(id) { const prev = codeFiles.find(f => f.id === activeCodeFileId); if (prev && monacoEditor) prev.content = monacoEditor.getValue(); + _DBG && console.log(`[switchCodeFile] ${activeCodeFileId} → ${id} file=${(codeFiles.find(f => f.id === id) || {}).name || '?'}`); activeCodeFileId = id; const file = codeFiles.find(f => f.id === id); if (!file || !monacoEditor) { renderCodeFileTabs(); return; } if (!file.model) file.model = monaco.editor.createModel(file.content, file.language); - monacoEditor.setModel(file.model); - monacoEditor.focus(); + + // If this file has an active inline diff, re-apply decorations + if (codeDiffState[id]) { + monacoEditor.setModel(file.model); + monacoEditor.focus(); + _showDiffBar(id); + _applyInlineDiffDecorations(id); + } else { + monacoEditor.setModel(file.model); + monacoEditor.focus(); + } renderCodeFileTabs(); codeUpdateRunButton(); } @@ -134,6 +161,8 @@ function closeCodeFile(id, ev) { if (ev) ev.stopPropagation(); const idx = codeFiles.findIndex(f => f.id === id); if (idx === -1) return; + // Clean up diff view if active + if (codeDiffState[id]) cleanupCodeDiff(id); const file = codeFiles[idx]; if (file.model) file.model.dispose(); codeFiles.splice(idx, 1); @@ -149,14 +178,17 @@ function renderCodeFileTabs() { if (!bar) return; bar.innerHTML = codeFiles.map(f => { const active = f.id === activeCodeFileId; - return `
+ const ds = codeDiffState[f.id]; + const diff = !!ds; + const unresolved = diff ? ds.hunks.filter((_, i) => !ds.resolved.has(i)).length : 0; + return `
+ ${unresolved > 0 ? `${unresolved}` : ''} ${escHtml(f.name)} ×
`; }).join(''); } -const CODE_DIR = 'code'; let codeDirLabel = 'code'; function csbCodeDirChanged(val) { /* preview only, saved on button click */ } @@ -532,13 +564,13 @@ function codeRunWith(mode) { const file = codeFiles.find(f => f.id === activeCodeFileId); if (!file || !monacoEditor) return; file.content = monacoEditor.getValue(); - const fpath = `${CODE_DIR}/${file.name}`; + const fpath = `${codeDirLabel}/${file.name}`; if (mode === 'browser') { api('/api/code/save', { method: 'POST', body: JSON.stringify({ filename: file.name, content: file.content }) }) .then(r => { if (r.success) { - const w = window.open(`${location.origin}/code-serve/${CODE_DIR}/${file.name}`, '_blank'); + const w = window.open(`${location.origin}/code-serve/${codeDirLabel}/${file.name}`, '_blank'); if (!w) codeAiAppend('system', '⚠️ 팝업 차단됨 — 브라우저에서 팝업을 허용해주세요'); } }); @@ -589,7 +621,7 @@ function codeSendSelectionToAI() { // ─── V2 Chat Live Code Streaming ──────────────────────────────────────────────── // Global state for streaming code into the editor during the main v2 chat. -let v2LiveFileId = null, v2LiveLang = '', v2LiveInBlock = false; +const _v2LiveState = { inBlock: false, lang: '', fileId: null, originalContent: null, isDiff: false, diffAccumulated: null }; let codePendingQueue = []; // [{fname, content, lang}] — queued when Monaco not ready // Throttled Monaco model update — avoids freezing UI by limiting setValue calls to ~80ms intervals @@ -603,6 +635,8 @@ function codeMonacoThrottle(model, value) { _monacoThrottleTimer = setTimeout(() => { _monacoThrottleTimer = null; if (_monacoThrottleModel && _monacoThrottleValue !== undefined) { + const _lines = _monacoThrottleValue.split('\n').length; + _DBG && console.log(`[codeMonacoThrottle] setValue: ${_lines} lines`); _monacoThrottleModel.setValue(_monacoThrottleValue); _monacoThrottleModel = null; } @@ -621,53 +655,210 @@ function codeFlushPendingQueue() { for (const item of codePendingQueue) { const existing = codeFiles.find(f => f.name === item.fname); if (existing) { - if (existing.model) existing.model.setValue(item.content); - else existing.content = item.content; + const oldContent = existing.model ? existing.model.getValue() : existing.content; + if (oldContent !== item.content) { + switchCodeFile(existing.id); + showCodeDiff(existing.id, oldContent, item.content); + } } else { const id = 'cf_' + Math.random().toString(36).slice(2, 8); const model = monaco.editor.createModel(item.content, codeDetectLang(item.fname)); codeFiles.push({ id, name: item.fname, content: item.content, language: codeDetectLang(item.fname), model }); - if (!v2LiveFileId) v2LiveFileId = id; + if (!_v2LiveState.fileId) _v2LiveState.fileId = id; } } codePendingQueue = []; - if (v2LiveFileId) switchCodeFile(v2LiveFileId); + if (_v2LiveState.fileId) switchCodeFile(_v2LiveState.fileId); } -function v2LiveCodeUpdate(fullReply) { +// ── Shared live-streaming parser ──────────────────────────────────────────── +// Detects the last unclosed ```code block in a streaming reply and mirrors it +// into the editor: creates/reuses a tab, throttles model updates, and renames +// the tab once a real filename surfaces. `state` is a mutable holder +// { inBlock, lang, fileId } so each caller keeps its own streaming context. +function codeLiveStreamUpdate(fullReply, state) { if (typeof codeIsOpenable !== 'function') return; - const fenceRe = /```([\w\-]*)\s*\n/g; - let lastOpen = null, m; + // Parse ALL code blocks using toggle: opening ```lang\n starts a block, + // bare ``` closes it. This correctly pairs fences and excludes explanation + // text between blocks. The model may split one file across multiple blocks + // (e.g. 37 lines + explanation + continuation) — we concatenate all blocks + // so the editor shows the complete program. + // + // CRITICAL: Only match ``` at the beginning of a line (with optional leading + // whitespace). Without this check, ``` inside code (e.g. in comments like + // `# ``` or strings) would be misinterpreted as a fence, causing the code + // block to end prematurely and the editor to clear mid-stream. + const fenceRe = /```([\w\-]*)[ \t]*\r?\n/g; + const blocks = []; + let inBlock = false, blockLang = '', blockStart = 0, m; while ((m = fenceRe.exec(fullReply)) !== null) { - if (lastOpen === null) { - lastOpen = { lang: m[1] || 'text', start: m.index + m[0].length }; + // Require ``` at start of line (only whitespace before it) — prevents + // false matches inside code (comments, strings, etc.) + const lineStart = m.index === 0 ? 0 : fullReply.lastIndexOf('\n', m.index - 1) + 1; + const prefix = fullReply.slice(lineStart, m.index); + if (prefix.trim() !== '') continue; // ``` is mid-line, skip + if (!inBlock) { + blockLang = m[1] || 'text'; + blockStart = m.index + m[0].length; + inBlock = true; } else { - lastOpen = null; + blocks.push({ lang: blockLang, code: fullReply.slice(blockStart, m.index) }); + inBlock = false; } } - if (!lastOpen) { v2LiveInBlock = false; return; } - const code = fullReply.slice(lastOpen.start); - const lang = lastOpen.lang; - if (!v2LiveInBlock) { - if (!codeIsOpenable(code, lang)) return; - v2LiveInBlock = true; - v2LiveLang = lang; + if (inBlock) { + // Unclosed block — still streaming + blocks.push({ lang: blockLang, code: fullReply.slice(blockStart), streaming: true }); + } + if (!blocks.length) { state.inBlock = false; return; } + + // Concatenate all code blocks (same or different language) + let code = ''; + let lang = blocks[blocks.length - 1].lang || 'text'; + for (const block of blocks) { + if (code) code += '\n'; + code += block.code; + if (block.lang && block.lang !== 'text') lang = block.lang; + } + code = code.replace(/\n+$/, ''); + _DBG && console.log(`[codeLiveStream] blocks=${blocks.length} lines=${code.split('\n').length} inBlock=${state.inBlock} fileId=${state.fileId} lang=${lang}`); + + // ── Diff block handling ────────────────────────────────────────────── + // When the language is 'diff', the block contains a unified diff that + // should be applied to an existing file (not replace it entirely). + if (lang === 'diff') { + if (!state.inBlock) { + state.inBlock = true; + state.lang = lang; + state.isDiff = true; + // We'll accumulate the diff content and apply after streaming. + // Try to find the filename from the diff header or filename comment. + // A diff must target an existing file — prefer the file we sent as context + // (the active tab), falling back to a name match. Deriving a name from the + // diff body text picked the wrong/new file, so the diff never hit the open + // file ("diff가 동일 파일에서 일어나지 않음"). + const fname = codeDeriveName(code, lang); + const existing = (state.targetFileId && codeFiles.find(f => f.id === state.targetFileId)) + || codeFiles.find(f => f.name === fname) + || codeFiles.find(f => f.id === activeCodeFileId); + if (existing) { + state.originalContent = existing.model ? existing.model.getValue() : existing.content; + state.fileId = existing.id; + state.diffAccumulated = code; + switchCodeFile(existing.id); + } else { + // No matching file found — create a placeholder + const id = 'cf_' + Math.random().toString(36).slice(2, 8); + const model = monacoReady ? monaco.editor.createModel(code, 'diff') : null; + codeFiles.push({ id, name: fname || 'diff.patch', content: code, language: 'diff', model }); + state.fileId = id; + state.originalContent = ''; + state.diffAccumulated = code; + switchCodeFile(id); + } + } else if (state.isDiff && state.fileId) { + // Continue accumulating diff content — keep the original file in the editor; + // the diff will be applied when streaming completes (see codeLiveStreamUpdate end-of-stream) + state.diffAccumulated = code; + } + return; + } + + // ── Full code block handling ──────────────────────────────────────────── + // Detect if the model accidentally wrapped a diff in a non-diff code fence + // (e.g. ```python with +prefix/-prefix lines) — treat it as a diff block instead + const _looksLikeDiff = (code) => { + const lines = code.split('\n'); + let diffLineCount = 0; + for (const line of lines) { + // Only count + and - lines (not space-prefixed context lines — those match Python indentation) + if (/^[+\-] /.test(line) || line.startsWith('@@')) diffLineCount++; + } + // If more than 30% of non-empty lines look like diff markers, it's a diff + const nonEmpty = lines.filter(l => l.trim()).length; + return nonEmpty > 0 && diffLineCount / nonEmpty > 0.3; + }; + + if (lang !== 'diff' && _looksLikeDiff(code)) { + // Model output diff content inside a non-diff code fence — redirect to diff handling + state.inBlock = true; + state.lang = 'diff'; + state.isDiff = true; + // Diff content wrapped in a non-diff fence — target the active/context file too. const fname = codeDeriveName(code, lang); - if (monacoReady && monacoEditor) { + const existing = (state.targetFileId && codeFiles.find(f => f.id === state.targetFileId)) + || codeFiles.find(f => f.name === fname) + || codeFiles.find(f => f.id === activeCodeFileId); + if (existing) { + state.originalContent = existing.model ? existing.model.getValue() : existing.content; + state.fileId = existing.id; + state.diffAccumulated = code; + switchCodeFile(existing.id); + } else { const id = 'cf_' + Math.random().toString(36).slice(2, 8); - const model = monaco.editor.createModel(code, codeDetectLang(fname)); + const model = monacoReady ? monaco.editor.createModel(code, codeDetectLang(fname)) : null; codeFiles.push({ id, name: fname, content: code, language: codeDetectLang(fname), model }); - v2LiveFileId = id; + state.fileId = id; + state.originalContent = ''; + state.diffAccumulated = code; switchCodeFile(id); + } + return; + } + + if (!state.inBlock) { + // Wait until the block looks like a real file (not a short run-command snippet) + if (!codeIsOpenable(code, lang)) return; + state.inBlock = true; + state.lang = lang; + const fname = codeDeriveName(code, lang); + _DBG && console.log(`[codeLiveStream] NEW BLOCK: fname=${fname} lines=${code.split('\n').length}`); + if (monacoReady && monacoEditor) { + // Reuse existing tab with same name instead of creating a duplicate + const existing = codeFiles.find(f => f.name === fname); + if (existing) { + // Save original content so we can show a diff after streaming completes + state.originalContent = existing.model ? existing.model.getValue() : existing.content; + state.fileId = existing.id; + // If editing existing file: keep original in editor, show streamed changes as decorations + if (state.originalContent && state.originalContent.trim()) { + state.streamingModifiedText = code; + _updateStreamingDiffDecorations(state.fileId, state.originalContent, code); + } else { + switchCodeFile(existing.id); + } + } else { + const id = 'cf_' + Math.random().toString(36).slice(2, 8); + const model = monaco.editor.createModel(code, codeDetectLang(fname)); + codeFiles.push({ id, name: fname, content: code, language: codeDetectLang(fname), model }); + state.fileId = id; + switchCodeFile(id); + } } else { // Monaco not ready yet — queue and trigger init codePendingQueue = [{ fname, content: code, lang }]; initCodeView(); } - } else if (v2LiveFileId) { - const file = codeFiles.find(f => f.id === v2LiveFileId); + } else if (state.fileId && !state.isDiff) { + _DBG && console.log(`[codeLiveStream] UPDATE: fileId=${state.fileId} lines=${code.split('\n').length} origContent=${!!state.originalContent} diff=${state.isDiff}`); + const file = codeFiles.find(f => f.id === state.fileId); if (file?.model) { - codeMonacoThrottle(file.model, code); + // If editing existing file with original content: stream as diff decorations + if (state.originalContent && state.originalContent.trim()) { + state.streamingModifiedText = code; + // Keep the original text in the editor — update streaming diff decorations + _updateStreamingDiffDecorations(state.fileId, state.originalContent, code); + // Don't call codeMonacoThrottle here — we keep original in editor + } else { + codeMonacoThrottle(file.model, code); + } + // Rename tab if a filename comment appears later in the stream + const betterName = codeDeriveName(code, state.lang); + if (betterName && !betterName.startsWith('ai_') && file.name.startsWith('ai_')) { + file.name = betterName; + file.language = codeDetectLang(betterName); + renderCodeFileTabs(); + } } } else if (codePendingQueue.length) { // Monaco still loading — update queued content @@ -675,14 +866,317 @@ function v2LiveCodeUpdate(fullReply) { } } +// ── Real-time streaming diff decorations ────────────────────────────────── +// While the model streams code, compare original vs streamed content and +// highlight changed/added lines in green — without replacing the editor content. +// This is the VSCode Copilot approach: keep original, show changes as overlay. + +// Throttled streaming diff to avoid excessive decoration updates +let _streamingDiffTimer = null; +let _streamingDiffFileId = null; +let _streamingDiffOriginal = ''; +let _streamingDiffModified = ''; + +function _updateStreamingDiffDecorations(fileId, originalText, modifiedText) { + _streamingDiffFileId = fileId; + _streamingDiffOriginal = originalText; + _streamingDiffModified = modifiedText; + if (_streamingDiffTimer) return; // already scheduled + _streamingDiffTimer = setTimeout(() => { + _streamingDiffTimer = null; + _applyStreamingDiffDecorations(_streamingDiffFileId, _streamingDiffOriginal, _streamingDiffModified); + }, 120); +} + +function _applyStreamingDiffDecorations(fileId, originalText, modifiedText) { + if (!monacoReady || !monacoEditor || !window.monaco) return; + const file = codeFiles.find(f => f.id === fileId); + if (!file || file.id !== activeCodeFileId) return; + + // Make sure editor still shows original content + const currentContent = file.model ? file.model.getValue() : file.content; + if (currentContent !== originalText) { + // Editor was changed externally — don't override + return; + } + + // Compute simple line-level diff: find which lines in original are changed + const origLines = originalText.split('\n'); + const modLines = modifiedText.split('\n'); + + // Use a fast LCS-like comparison to find added/changed lines + // While streaming, we only show "added/changed" lines in green. + // We can't show deleted lines until streaming completes (we don't know + // if the model will output them later or if they're truly deleted). + const decorations = []; + + // Simple line-by-line diff: for each line in modified that differs from original + let oi = 0, mi = 0; + while (oi < origLines.length && mi < modLines.length) { + if (origLines[oi] === modLines[mi]) { + // Lines match — unchanged + oi++; mi++; + } else { + // Check if this is an insertion (next orig line matches current mod line) + let matchAhead = false; + for (let look = 1; look <= 3 && oi + look < origLines.length; look++) { + if (origLines[oi + look] === modLines[mi]) { + // Lines ahead match — lines oi..oi+look-1 are deleted, modLines[mi] is context + // Mark deleted lines (we'll skip this in streaming since we keep original) + // Just advance pointers + for (let k = 0; k < look; k++) { + // Original lines that were removed — we can't show this during streaming + // because the model might still output them + } + oi += look; + matchAhead = true; + break; + } + } + if (!matchAhead) { + // Check if this is a modification (mod line is new/changed) + let matchBehind = false; + for (let look = 1; look <= 3 && mi + look < modLines.length; look++) { + if (origLines[oi] === modLines[mi + look]) { + // Lines mi..mi+look-1 are new additions + for (let k = 0; k < look; k++) { + // We can't show added lines in the original editor during streaming + // because we're keeping the original content + } + mi += look; + matchBehind = true; + break; + } + } + if (!matchBehind) { + // Both lines differ — this is a change + oi++; mi++; + } + } + } + } + + // The approach above is too complex for real-time. Instead, use a simpler method: + // Show a subtle "AI is editing..." indicator on lines that are different. + // We do this by computing a quick diff and highlighting changed regions. + + // Quick diff: find the longest common subsequence alignment + const hunks = computeDiffHunks(originalText, modifiedText); + if (!hunks.length) { + // No changes yet — remove any streaming decorations + if (file.model && file._streamingDecos) { + file.model.deltaDecorations(file._streamingDecos, []); + file._streamingDecos = null; + } + return; + } + + // We're keeping original text in editor. Show decorations on lines that have changes. + // For "change" hunks: highlight the original lines that will be modified + // For "add" hunks: highlight the line before where additions will appear + // For "delete" hunks: highlight lines that will be removed with strikethrough-like style + const streamingDecos = []; + for (const hunk of hunks) { + if (hunk.type === 'delete') { + // Lines that will be removed + for (let line = hunk.oldStart; line <= hunk.oldEnd && line <= origLines.length; line++) { + streamingDecos.push({ + range: new monaco.Range(line, 1, line, 1), + options: { + className: 'inline-diff-streaming-delete', + glyphMarginClassName: 'inline-diff-glyph-delete', + isWholeLine: true, + minimap: { color: '#dc354580', position: monaco.editor.MinimapPosition.Inline }, + overviewRuler: { color: '#dc354560', position: monaco.editor.OverviewRulerLane.Full }, + } + }); + } + } else if (hunk.type === 'add') { + // New lines will appear after oldStart — highlight the line before + const insertAfter = Math.max(1, hunk.oldStart - 1); + streamingDecos.push({ + range: new monaco.Range(insertAfter, 1, insertAfter, 1), + options: { + className: 'inline-diff-streaming-add-marker', + isWholeLine: true, + } + }); + } else { + // Change: old lines will be replaced + for (let line = hunk.oldStart; line <= hunk.oldEnd && line <= origLines.length; line++) { + streamingDecos.push({ + range: new monaco.Range(line, 1, line, 1), + options: { + className: 'inline-diff-streaming-change', + glyphMarginClassName: 'inline-diff-glyph-change', + isWholeLine: true, + minimap: { color: '#ffc10780', position: monaco.editor.MinimapPosition.Inline }, + overviewRuler: { color: '#ffc10760', position: monaco.editor.OverviewRulerLane.Full }, + } + }); + } + } + } + + // Apply streaming decorations + const oldDecos = file._streamingDecos || []; + file._streamingDecos = file.model.deltaDecorations(oldDecos, streamingDecos); +} + +// Apply a unified diff to original content and return the new content. +// Parses @@ -a,b +c,d @@ hunks and applies additions/removals. +function applyUnifiedDiff(originalContent, diffText) { + const origLines = originalContent.split('\n'); + const diffLines = diffText.split('\n'); + const result = []; + let origPos = 0; // 0-based cursor into origLines + + // Skip to first hunk header + let i = 0; + while (i < diffLines.length && !diffLines[i].startsWith('@@')) i++; + if (i >= diffLines.length) { + // No hunks found — try simple +/- line format (no @@ headers) + return applySimpleDiff(originalContent, diffText); + } + + // Walk each hunk in lock-step with the original: copy untouched lines up to the + // hunk, then preserve context lines, drop '-' lines, and insert '+' lines in + // order. Tracking origPos keeps multi-hunk patches aligned (the old splice + // approach lost context lines and drifted on the 2nd hunk → corrupted output). + while (i < diffLines.length) { + if (!diffLines[i].startsWith('@@')) { i++; continue; } + const hunkMatch = diffLines[i].match(/@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/); + if (!hunkMatch) { i++; continue; } + const oldStart = parseInt(hunkMatch[1], 10); + i++; // Move past @@ line + + // Copy untouched original lines up to where this hunk begins + const hunkStart = Math.max(0, oldStart - 1); + while (origPos < hunkStart && origPos < origLines.length) result.push(origLines[origPos++]); + + // Apply hunk body + while (i < diffLines.length && !diffLines[i].startsWith('@@')) { + const line = diffLines[i]; + if (line.startsWith('\\')) { i++; continue; } // "\ No newline at end of file" + if (line.startsWith('+++') || line.startsWith('---')) { i++; continue; } // file headers + const tag = line[0]; + if (tag === '+') { + result.push(line.slice(1)); // added line + } else if (tag === '-') { + origPos++; // removed — consume original + } else if (tag === ' ') { + if (origPos < origLines.length) result.push(origLines[origPos]); else result.push(line.slice(1)); + origPos++; // context — keep original text + } else { + // No-prefix line: treat as context when it matches the original, else literal + if (origPos < origLines.length && origLines[origPos] === line) { result.push(origLines[origPos]); origPos++; } + else result.push(line); + } + i++; + } + } + + // Copy any remaining original lines after the last hunk + while (origPos < origLines.length) result.push(origLines[origPos++]); + return result.join('\n'); +} + +// Fallback: simple +/- diff without @@ headers (e.g. model just outputs +added/-removed lines) +function applySimpleDiff(originalContent, diffText) { + const origLines = originalContent.split('\n'); + const diffLines = diffText.split('\n'); + const resultLines = []; + + let origIdx = 0; + for (const line of diffLines) { + if (line.startsWith('+') && !line.startsWith('+++')) { + resultLines.push(line.slice(1)); + } else if (line.startsWith('-') && !line.startsWith('---')) { + origIdx++; // Skip original line + } else if (line.startsWith(' ')) { + // Context line + resultLines.push(line.slice(1)); + origIdx++; + } else { + // Plain line (no prefix) or header — skip diff headers + if (!line.startsWith('diff ') && !line.startsWith('index ') && !line.startsWith('---') && !line.startsWith('+++')) { + resultLines.push(line); + } + } + } + + // Append any remaining original lines + while (origIdx < origLines.length) { + resultLines.push(origLines[origIdx]); + origIdx++; + } + + return resultLines.length > 0 ? resultLines.join('\n') : originalContent; +} + +// Main v2-chat live streaming (used in the full app) — thin wrapper over the +// shared parser with its own module-level state. +function v2LiveCodeUpdate(fullReply) { + codeLiveStreamUpdate(fullReply, _v2LiveState); +} + async function codeAiSend() { const input = document.getElementById('code-ai-input'); const message = input.value.trim(); if (!message) return; input.value = ''; input.style.height = ''; + // Include current file context so the model knows what to edit (Code mode only) + const currentFile = codeFiles.find(f => f.id === activeCodeFileId); + let contextMsg = message; + if (codeAiMode === 'code' && currentFile && monacoEditor) { + const content = monacoEditor.getValue(); + // Diff editing only makes sense against REAL existing code. A brand-new tab + // seeded with a placeholder comment (e.g. "# 여기에 코드를 작성하세요") is not + // editable code — asking for a diff there makes the model emit a whole program + // as +lines, which then got saved verbatim ("+가 붙어 나옴"). Treat a file with + // no substantive (non-comment, non-blank) lines as new → request full code. + const hasRealCode = content.split('\n').some(l => { + const t = l.trim(); + return t && !t.startsWith('#') && !t.startsWith('//') && !t.startsWith('/*') && !t.startsWith('*') && !t.startsWith('