diff --git a/apps/backend/src/routes/agent.js b/apps/backend/src/routes/agent.js index 53d5cad..c797c1d 100644 --- a/apps/backend/src/routes/agent.js +++ b/apps/backend/src/routes/agent.js @@ -2,6 +2,180 @@ import { callOpenAICompatible } from '../services/llmService.js'; import { runToolAgent } from '../services/agentService.js'; import { getLang, t } from '../i18n/index.js'; +function stripCodeFence(text) { + const raw = String(text || '').trim(); + if (!raw.startsWith('```')) return raw; + return raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim(); +} + +function tryParseReplyPayload(text) { + const raw = stripCodeFence(text); + if (!raw) return null; + const candidates = [raw]; + const first = raw.indexOf('{'); + const last = raw.lastIndexOf('}'); + if (first >= 0 && last > first) { + candidates.push(raw.slice(first, last + 1)); + } + for (const candidate of candidates) { + try { + const parsed = JSON.parse(candidate); + if (parsed && typeof parsed === 'object') return parsed; + } catch { + // ignore + } + } + return null; +} + +function decodeEscapedChar(ch) { + switch (ch) { + case 'n': return '\n'; + case 'r': return '\r'; + case 't': return '\t'; + case 'b': return '\b'; + case 'f': return '\f'; + case '"': return '"'; + case '\'': return '\''; + case '\\': return '\\'; + case '/': return '/'; + default: return ch; + } +} + +function parseQuotedString(text, quote, startIndex) { + let i = startIndex; + let out = ''; + while (i < text.length) { + const ch = text[i]; + if (ch === '\\') { + const next = text[i + 1]; + if (next === 'u' && /^[0-9a-fA-F]{4}$/.test(text.slice(i + 2, i + 6))) { + out += String.fromCharCode(Number.parseInt(text.slice(i + 2, i + 6), 16)); + i += 6; + continue; + } + if (next) { + out += decodeEscapedChar(next); + i += 2; + continue; + } + i += 1; + continue; + } + if (ch === quote) { + return { value: out, end: i }; + } + out += ch; + i += 1; + } + return { value: out, end: text.length - 1 }; +} + +function extractFieldFromJsonLike(text, field) { + const raw = stripCodeFence(text); + if (!raw) return ''; + const matcher = new RegExp(`["']?${field}["']?\\s*:\\s*`, 'i'); + const match = matcher.exec(raw); + if (!match) return ''; + let i = match.index + match[0].length; + while (i < raw.length && /\s/.test(raw[i])) i += 1; + if (i >= raw.length) return ''; + const lead = raw[i]; + if (lead === '"' || lead === '\'') { + const parsed = parseQuotedString(raw, lead, i + 1); + return parsed.value.trim(); + } + let j = i; + while (j < raw.length && ![',', '\n', '\r', '}'].includes(raw[j])) j += 1; + return raw.slice(i, j).trim(); +} + +function extractMessageContent(value) { + if (typeof value === 'string') return value; + if (Array.isArray(value)) { + return value + .map((item) => { + if (typeof item === 'string') return item; + if (item && typeof item === 'object' && typeof item.text === 'string') return item.text; + return ''; + }) + .join('') + .trim(); + } + return ''; +} + +function extractCompletionText(parsed) { + if (!parsed || typeof parsed !== 'object') return ''; + + const choices = Array.isArray(parsed.choices) ? parsed.choices : []; + const choiceMessage = choices[0]?.message; + const choiceContent = extractMessageContent(choiceMessage?.content); + if (choiceContent) return choiceContent; + if (typeof choices[0]?.text === 'string') return choices[0].text; + + if (typeof parsed.output_text === 'string') return parsed.output_text; + + const candidateParts = parsed?.candidates?.[0]?.content?.parts; + if (Array.isArray(candidateParts)) { + const geminiText = candidateParts.map((part) => String(part?.text || '')).join('').trim(); + if (geminiText) return geminiText; + } + + return ''; +} + +function normalizeAgentReply(inputReply, inputSuggestion = '') { + let replyCandidate = + typeof inputReply === 'string' + ? inputReply + : JSON.stringify(inputReply || ''); + let suggestionCandidate = + typeof inputSuggestion === 'string' + ? inputSuggestion + : JSON.stringify(inputSuggestion || ''); + + for (let depth = 0; depth < 4; depth += 1) { + const parsed = tryParseReplyPayload(replyCandidate); + if (!parsed) { + const fallbackReply = extractFieldFromJsonLike(replyCandidate, 'reply') + || extractFieldFromJsonLike(replyCandidate, 'message'); + const fallbackSuggestion = extractFieldFromJsonLike(replyCandidate, 'suggestion'); + if (fallbackReply) replyCandidate = fallbackReply; + if (fallbackSuggestion) suggestionCandidate = fallbackSuggestion; + break; + } + + const completionText = extractCompletionText(parsed); + if (completionText && completionText !== replyCandidate) { + replyCandidate = completionText; + continue; + } + + const nestedReply = typeof parsed.reply === 'string' + ? parsed.reply + : (typeof parsed.message === 'string' ? parsed.message : ''); + const nestedSuggestion = typeof parsed.suggestion === 'string' ? parsed.suggestion : ''; + if (nestedReply) replyCandidate = nestedReply; + if (nestedSuggestion) suggestionCandidate = nestedSuggestion; + if (!nestedReply) break; + } + + if (!suggestionCandidate) { + suggestionCandidate = extractFieldFromJsonLike(replyCandidate, 'suggestion') || ''; + } + + if (!replyCandidate) { + return { + reply: String(inputReply || ''), + suggestion: String(inputSuggestion || '') + }; + } + + return { reply: replyCandidate, suggestion: suggestionCandidate }; +} + export function registerAgentRoutes(fastify) { fastify.post('/api/agent/run', async (req) => { const lang = getLang(req); @@ -54,7 +228,9 @@ export function registerAgentRoutes(fastify) { } if (mode === 'tools') { - return runToolAgent({ projectId, activePath, task, prompt, selection, compileLog, llmConfig, lang }); + const toolResult = await runToolAgent({ projectId, activePath, task, prompt, selection, compileLog, llmConfig, lang }); + const normalized = normalizeAgentReply(toolResult?.reply || '', toolResult?.suggestion || ''); + return { ...toolResult, reply: normalized.reply, suggestion: normalized.suggestion }; } const system = @@ -97,15 +273,9 @@ export function registerAgentRoutes(fastify) { }; } - let reply = ''; - let suggestion = ''; - try { - const parsed = JSON.parse(result.content); - reply = parsed.reply || ''; - suggestion = parsed.suggestion || ''; - } catch { - reply = result.content; - } + const normalized = normalizeAgentReply(result.content, ''); + const reply = normalized.reply; + const suggestion = normalized.suggestion; return { ok: true, reply, suggestion }; }); diff --git a/apps/backend/src/services/agentService.js b/apps/backend/src/services/agentService.js index 8efbf26..f933d70 100644 --- a/apps/backend/src/services/agentService.js +++ b/apps/backend/src/services/agentService.js @@ -30,15 +30,36 @@ export async function runToolAgent({ const projectRoot = await getProjectRoot(projectId); const pendingPatches = []; + const READ_FILE_MAX_CHARS = Number(process.env.OPENPRISM_TOOL_READ_MAX_CHARS || 8000); + const LIST_FILES_MAX_COUNT = Number(process.env.OPENPRISM_TOOL_LIST_MAX_COUNT || 400); const readFileTool = new DynamicStructuredTool({ name: 'read_file', - description: 'Read a UTF-8 file from the project. Input: { path } (relative to project root).', - schema: z.object({ path: z.string() }), - func: async ({ path: filePath }) => { + description: 'Read a UTF-8 file from the project. Input: { path, startLine?, endLine? } (relative to project root).', + schema: z.object({ + path: z.string(), + startLine: z.number().int().min(1).optional(), + endLine: z.number().int().min(1).optional() + }), + func: async ({ path: filePath, startLine, endLine }) => { const abs = safeJoin(projectRoot, filePath); const content = await fs.readFile(abs, 'utf8'); - return content.slice(0, 20000); + const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + const lines = normalized.split('\n'); + const totalLines = lines.length; + const start = Math.max(1, Math.min(startLine || 1, totalLines)); + const end = Math.max(start, Math.min(endLine || totalLines, totalLines)); + const selected = lines.slice(start - 1, end).join('\n'); + if (selected.length <= READ_FILE_MAX_CHARS) { + return `[read_file] ${filePath} lines ${start}-${end} of ${totalLines}\n${selected}`; + } + const truncated = selected.slice(0, READ_FILE_MAX_CHARS); + return [ + `[read_file] ${filePath} lines ${start}-${end} of ${totalLines} (truncated to ${READ_FILE_MAX_CHARS} chars)`, + truncated, + `\n...[truncated ${selected.length - READ_FILE_MAX_CHARS} chars]`, + 'If needed, call read_file again with a narrower line range.' + ].join('\n'); } }); @@ -49,8 +70,16 @@ export async function runToolAgent({ func: async ({ dir }) => { const root = dir ? safeJoin(projectRoot, dir) : projectRoot; const items = await listFilesRecursive(root, ''); - const files = items.filter((item) => item.type === 'file').map((item) => item.path); - return JSON.stringify({ files }); + const files = items + .filter((item) => item.type === 'file') + .map((item) => item.path) + .sort(); + const limited = files.slice(0, LIST_FILES_MAX_COUNT); + return JSON.stringify({ + files: limited, + total: files.length, + truncated: files.length > limited.length + }); } }); @@ -186,7 +215,12 @@ export async function runToolAgent({ const tools = [readFileTool, listFilesTool, proposePatchTool, applyPatchTool, compileLogTool, arxivSearchTool, arxivBibtexTool]; const agent = await createOpenAIToolsAgent({ llm, tools, prompt: promptTemplate }); - const executor = new AgentExecutor({ agent, tools }); + const executor = new AgentExecutor({ + agent, + tools, + maxIterations: Number(process.env.OPENPRISM_TOOL_AGENT_MAX_ITERATIONS || 6), + earlyStoppingMethod: 'force' + }); const result = await executor.invoke({ input: userInput }); return { diff --git a/apps/backend/src/services/compileService.js b/apps/backend/src/services/compileService.js index 6e598df..b4e1d75 100644 --- a/apps/backend/src/services/compileService.js +++ b/apps/backend/src/services/compileService.js @@ -130,6 +130,9 @@ export async function runCompile({ projectId, mainFile, engine = 'pdflatex' }) { pdfBase64 = ''; } const log = logChunks.join(''); + console.log('--- COMPILATION LOG START ---'); + console.log(log); + console.log('--- COMPILATION LOG END ---'); await fs.rm(outDir, { recursive: true, force: true }); if (!pdfBase64) { return { ok: false, error: 'No PDF generated.', log, status: code ?? -1 }; diff --git a/apps/frontend/src/api/client.ts b/apps/frontend/src/api/client.ts index 8e77535..0e8b50e 100644 --- a/apps/frontend/src/api/client.ts +++ b/apps/frontend/src/api/client.ts @@ -94,6 +94,186 @@ function getAuthHeader(): Record { return { Authorization: `Bearer ${token}` }; } +function stripCodeFence(text: string) { + const raw = String(text || '').trim(); + if (!raw.startsWith('```')) return raw; + return raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim(); +} + +function decodeEscapedChar(ch: string) { + switch (ch) { + case 'n': return '\n'; + case 'r': return '\r'; + case 't': return '\t'; + case 'b': return '\b'; + case 'f': return '\f'; + case '"': return '"'; + case '\'': return '\''; + case '\\': return '\\'; + case '/': return '/'; + default: return ch; + } +} + +function parseQuotedString(text: string, quote: string, startIndex: number) { + let i = startIndex; + let out = ''; + while (i < text.length) { + const ch = text[i]; + if (ch === '\\') { + const next = text[i + 1]; + if (next === 'u' && /^[0-9a-fA-F]{4}$/.test(text.slice(i + 2, i + 6))) { + out += String.fromCharCode(Number.parseInt(text.slice(i + 2, i + 6), 16)); + i += 6; + continue; + } + if (next) { + out += decodeEscapedChar(next); + i += 2; + continue; + } + i += 1; + continue; + } + if (ch === quote) { + return { value: out, end: i }; + } + out += ch; + i += 1; + } + return { value: out, end: text.length - 1 }; +} + +function extractFieldFromJsonLike(text: string, field: string) { + const raw = stripCodeFence(text); + if (!raw) return ''; + const matcher = new RegExp(`["']?${field}["']?\\s*:\\s*`, 'i'); + const match = matcher.exec(raw); + if (!match) return ''; + let i = match.index + match[0].length; + while (i < raw.length && /\s/.test(raw[i])) i += 1; + if (i >= raw.length) return ''; + const lead = raw[i]; + if (lead === '"' || lead === '\'') { + return parseQuotedString(raw, lead, i + 1).value.trim(); + } + let j = i; + while (j < raw.length && ![',', '\n', '\r', '}'].includes(raw[j])) j += 1; + return raw.slice(i, j).trim(); +} + +function tryParseObjectText(text: string): Record | null { + const raw = stripCodeFence(text); + if (!raw) return null; + const candidates = [raw]; + const first = raw.indexOf('{'); + const last = raw.lastIndexOf('}'); + if (first >= 0 && last > first) { + candidates.push(raw.slice(first, last + 1)); + } + for (const candidate of candidates) { + try { + const parsed = JSON.parse(candidate); + if (parsed && typeof parsed === 'object') return parsed as Record; + } catch { + // ignore + } + } + return null; +} + +function extractMessageContent(value: unknown) { + if (typeof value === 'string') return value; + if (Array.isArray(value)) { + return value + .map((item) => { + if (typeof item === 'string') return item; + if (item && typeof item === 'object' && typeof (item as { text?: unknown }).text === 'string') { + return String((item as { text?: unknown }).text); + } + return ''; + }) + .join('') + .trim(); + } + return ''; +} + +function toTextCandidate(value: unknown): string { + if (typeof value === 'string') return value; + if (!value || typeof value !== 'object') return String(value || ''); + const obj = value as Record; + + const directReply = typeof obj.reply === 'string' ? obj.reply : ''; + if (directReply) return directReply; + const directMessage = typeof obj.message === 'string' ? obj.message : ''; + if (directMessage) return directMessage; + const directOutput = typeof obj.output_text === 'string' ? obj.output_text : ''; + if (directOutput) return directOutput; + + const choices = Array.isArray(obj.choices) ? obj.choices : []; + const firstChoice = choices[0] as Record | undefined; + if (firstChoice) { + const message = firstChoice.message as Record | undefined; + const messageContent = extractMessageContent(message?.content); + if (messageContent) return messageContent; + if (typeof firstChoice.text === 'string') return firstChoice.text; + } + + const candidates = Array.isArray(obj.candidates) ? obj.candidates : []; + const firstCandidate = candidates[0] as Record | undefined; + const parts = firstCandidate?.content && typeof firstCandidate.content === 'object' + ? (firstCandidate.content as { parts?: unknown[] }).parts + : undefined; + if (Array.isArray(parts)) { + const text = parts.map((part) => String((part as { text?: unknown })?.text || '')).join('').trim(); + if (text) return text; + } + + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function normalizeAgentText( + rawReply: unknown, + rawSuggestion: unknown +): { reply: string; suggestion: string } { + let directReply = toTextCandidate(rawReply); + let directSuggestion = toTextCandidate(rawSuggestion); + + for (let depth = 0; depth < 4; depth += 1) { + const parsed = tryParseObjectText(directReply); + if (!parsed) { + const fallbackReply = extractFieldFromJsonLike(directReply, 'reply') + || extractFieldFromJsonLike(directReply, 'message'); + const fallbackSuggestion = extractFieldFromJsonLike(directReply, 'suggestion'); + if (fallbackReply) directReply = fallbackReply; + if (fallbackSuggestion) directSuggestion = fallbackSuggestion; + break; + } + + const completionText = toTextCandidate(parsed); + if (completionText && completionText !== directReply) { + directReply = completionText; + continue; + } + + const nestedReply = + typeof parsed.reply === 'string' + ? parsed.reply + : (typeof parsed.message === 'string' ? parsed.message : ''); + const nestedSuggestion = typeof parsed.suggestion === 'string' ? parsed.suggestion : ''; + if (nestedReply) directReply = nestedReply; + if (nestedSuggestion) directSuggestion = nestedSuggestion; + if (!nestedReply) break; + } + + return { reply: directReply, suggestion: directSuggestion }; +} + async function request(url: string, options?: RequestInit): Promise { const lang = getLangHeader(); const mergedHeaders: Record = { @@ -289,9 +469,12 @@ export function runAgent(payload: { interaction?: 'chat' | 'agent'; history?: { role: 'user' | 'assistant'; content: string }[]; }) { - return request<{ ok: boolean; reply: string; suggestion: string; patches?: { path: string; diff: string; content: string }[] }>(`/api/agent/run`, { + return request<{ ok: boolean; reply: unknown; suggestion: unknown; patches?: { path: string; diff: string; content: string }[] }>(`/api/agent/run`, { method: 'POST', body: JSON.stringify(payload) + }).then((res) => { + const normalized = normalizeAgentText(res.reply, res.suggestion); + return { ...res, reply: normalized.reply, suggestion: normalized.suggestion }; }); } diff --git a/apps/frontend/src/app/App.css b/apps/frontend/src/app/App.css index ec09d64..b78d6d9 100644 --- a/apps/frontend/src/app/App.css +++ b/apps/frontend/src/app/App.css @@ -382,6 +382,11 @@ body { background: rgba(180, 74, 47, 0.1); } +.icon-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + .panel-search { padding: 8px 12px; border-bottom: 1px solid var(--border); @@ -2162,6 +2167,7 @@ body { background: #fffaf3; color: #2b2522; overflow: hidden; + position: relative; } .split-column { @@ -2181,6 +2187,75 @@ body { border-bottom: 1px solid rgba(120, 98, 83, 0.2); } +.split-nav { + position: absolute; + right: 10px; + top: 8px; + z-index: 2; + display: flex; + align-items: center; + gap: 6px; + padding: 4px 6px; + border-radius: 999px; + background: rgba(255, 250, 243, 0.92); + border: 1px solid rgba(120, 98, 83, 0.22); +} + +.split-nav-btn { + width: 24px; + height: 24px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + color: #4e453f; +} + +.split-nav-status { + min-width: 92px; + text-align: center; + font-size: 11px; + font-weight: 600; + color: #6f5f54; +} + +.split-nav-action { + min-width: 40px; + height: 24px; + border-radius: 999px; + padding: 0 8px; + font-size: 11px; + font-weight: 600; +} + +.split-nav-action.accept { + color: #0f766e; + background: rgba(16, 185, 129, 0.16); +} + +.split-nav-action.reject { + color: #b91c1c; + background: rgba(239, 68, 68, 0.14); +} + +.split-hunk { + border-bottom: 1px dashed rgba(120, 98, 83, 0.2); +} + +.split-hunk-label { + position: sticky; + top: 0; + z-index: 1; + padding: 4px 10px; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #8a7565; + background: rgba(243, 232, 215, 0.9); + border-bottom: 1px solid rgba(120, 98, 83, 0.15); +} + .split-row { display: grid; grid-template-columns: 40px 1fr; @@ -2191,6 +2266,10 @@ body { line-height: 1.4; } +.split-row.clickable { + cursor: pointer; +} + .split-row.added { background: rgba(16, 185, 129, 0.12); } @@ -2199,6 +2278,24 @@ body { background: rgba(239, 68, 68, 0.12); } +.split-row.changed { + background: rgba(245, 158, 11, 0.13); +} + +.split-row.side-empty { + background: rgba(120, 98, 83, 0.05); +} + +.split-row.side-empty .line-text { + opacity: 0.45; +} + +.split-row.is-active-change { + outline: 1px solid rgba(180, 74, 47, 0.55); + outline-offset: -1px; + background: rgba(180, 74, 47, 0.14); +} + .line-no { text-align: right; color: rgba(122, 111, 103, 0.7); diff --git a/apps/frontend/src/app/EditorPage.tsx b/apps/frontend/src/app/EditorPage.tsx index f924bd7..364466f 100644 --- a/apps/frontend/src/app/EditorPage.tsx +++ b/apps/frontend/src/app/EditorPage.tsx @@ -10,7 +10,7 @@ import { Compartment, EditorState, StateEffect, StateField } from '@codemirror/s import { Decoration, EditorView, DecorationSet, WidgetType, keymap, gutter, GutterMarker } from '@codemirror/view'; import { search, searchKeymap } from '@codemirror/search'; import { autocompletion, CompletionContext } from '@codemirror/autocomplete'; -import { toggleComment } from '@codemirror/commands'; +import { toggleComment, undo, redo } from '@codemirror/commands'; import { foldKeymap, foldService, indentOnInput } from '@codemirror/language'; import { GlobalWorkerOptions, getDocument, renderTextLayer } from 'pdfjs-dist'; import pdfWorker from 'pdfjs-dist/build/pdf.worker.min?url'; @@ -848,41 +848,216 @@ const editorTheme = EditorView.theme( { dark: false } ); -function buildSplitDiff(original: string, proposed: string) { - const parts = diffLines(original, proposed); +type SplitDiffRow = { + id: string; + left?: string; + right?: string; + leftNo?: number; + rightNo?: number; + type: 'context' | 'added' | 'removed' | 'changed'; + isChange: boolean; +}; + +type SplitDiffHunk = { + id: string; + rows: SplitDiffRow[]; +}; + +type SplitDiffChangeBlock = { + id: string; + anchorRowId: string; + rowIds: string[]; + oldStart: number; + oldEnd: number; + newStart: number; + newEnd: number; +}; + +type SplitDiffModel = { + hunks: SplitDiffHunk[]; + changeBlocks: SplitDiffChangeBlock[]; + rowToBlockId: Record; +}; + +function buildSplitDiff(original: string, proposed: string): SplitDiffModel { + const normalizeLineEndings = (input: string) => input.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + const parts = diffLines(normalizeLineEndings(original), normalizeLineEndings(proposed)); let leftLine = 1; let rightLine = 1; - const rows: { - left?: string; - right?: string; - leftNo?: number; - rightNo?: number; - type: 'context' | 'added' | 'removed'; - }[] = []; - - parts.forEach((part) => { - const lines = part.value.split('\n'); - if (lines[lines.length - 1] === '') { - lines.pop(); + let rowIdx = 0; + let oldCursor = 0; + let newCursor = 0; + const rows: SplitDiffRow[] = []; + const changeBlocks: SplitDiffChangeBlock[] = []; + const rowToBlockId: Record = {}; + + const toLines = (value: string) => { + const lines = value.split('\n'); + if (lines[lines.length - 1] === '') lines.pop(); + return lines; + }; + + for (let i = 0; i < parts.length; i += 1) { + const part = parts[i]; + const nextPart = parts[i + 1]; + + // Pair removed + added segments so line-level edits align side by side. + if (part.removed && nextPart?.added) { + const removedLines = toLines(part.value); + const addedLines = toLines(nextPart.value); + const count = Math.max(removedLines.length, addedLines.length); + const rowIds: string[] = []; + for (let j = 0; j < count; j += 1) { + const left = removedLines[j]; + const right = addedLines[j]; + const type: SplitDiffRow['type'] = left && right ? 'changed' : left ? 'removed' : 'added'; + const rowId = `r-${rowIdx++}`; + rowIds.push(rowId); + rows.push({ + id: rowId, + left, + right, + leftNo: left !== undefined ? leftLine++ : undefined, + rightNo: right !== undefined ? rightLine++ : undefined, + type, + isChange: true + }); + } + if (rowIds.length > 0) { + const blockId = `c-${changeBlocks.length}`; + rowIds.forEach((rowId) => { + rowToBlockId[rowId] = blockId; + }); + changeBlocks.push({ + id: blockId, + anchorRowId: rowIds[0], + rowIds, + oldStart: oldCursor, + oldEnd: oldCursor + removedLines.length, + newStart: newCursor, + newEnd: newCursor + addedLines.length + }); + } + oldCursor += removedLines.length; + newCursor += addedLines.length; + i += 1; + continue; } - lines.forEach((line) => { - if (part.added) { - rows.push({ right: line, rightNo: rightLine++, type: 'added' }); - } else if (part.removed) { - rows.push({ left: line, leftNo: leftLine++, type: 'removed' }); - } else { + + const lines = toLines(part.value); + if (part.added) { + const rowIds: string[] = []; + lines.forEach((line) => { + const rowId = `r-${rowIdx++}`; + rowIds.push(rowId); rows.push({ - left: line, + id: rowId, right: line, - leftNo: leftLine++, rightNo: rightLine++, - type: 'context' + type: 'added', + isChange: true + }); + }); + if (rowIds.length > 0) { + const blockId = `c-${changeBlocks.length}`; + rowIds.forEach((rowId) => { + rowToBlockId[rowId] = blockId; + }); + changeBlocks.push({ + id: blockId, + anchorRowId: rowIds[0], + rowIds, + oldStart: oldCursor, + oldEnd: oldCursor, + newStart: newCursor, + newEnd: newCursor + lines.length + }); + } + newCursor += lines.length; + continue; + } + if (part.removed) { + const rowIds: string[] = []; + lines.forEach((line) => { + const rowId = `r-${rowIdx++}`; + rowIds.push(rowId); + rows.push({ + id: rowId, + left: line, + leftNo: leftLine++, + type: 'removed', + isChange: true + }); + }); + if (rowIds.length > 0) { + const blockId = `c-${changeBlocks.length}`; + rowIds.forEach((rowId) => { + rowToBlockId[rowId] = blockId; + }); + changeBlocks.push({ + id: blockId, + anchorRowId: rowIds[0], + rowIds, + oldStart: oldCursor, + oldEnd: oldCursor + lines.length, + newStart: newCursor, + newEnd: newCursor }); } + oldCursor += lines.length; + continue; + } + + lines.forEach((line) => { + rows.push({ + id: `r-${rowIdx++}`, + left: line, + right: line, + leftNo: leftLine++, + rightNo: rightLine++, + type: 'context', + isChange: false + }); }); + oldCursor += lines.length; + newCursor += lines.length; + } + + const contextRadius = 2; + const changeIndexes = rows + .map((row, index) => (row.isChange ? index : -1)) + .filter((index) => index >= 0); + + if (changeIndexes.length === 0) { + return { + hunks: [{ id: 'h-0', rows }], + changeBlocks: [], + rowToBlockId: {} + }; + } + + const ranges: Array<{ start: number; end: number }> = []; + changeIndexes.forEach((index) => { + const start = Math.max(0, index - contextRadius); + const end = Math.min(rows.length - 1, index + contextRadius); + const prev = ranges[ranges.length - 1]; + if (!prev || start > prev.end + 1) { + ranges.push({ start, end }); + } else { + prev.end = Math.max(prev.end, end); + } }); - return rows; + const hunks = ranges.map((range, hunkIndex) => ({ + id: `h-${hunkIndex}`, + rows: rows.slice(range.start, range.end + 1) + })); + + return { + hunks, + changeBlocks, + rowToBlockId + }; } type CompileError = { @@ -950,10 +1125,27 @@ function replaceSelection(source: string, start: number, end: number, replacemen return source.slice(0, start) + replacement + source.slice(end); } -function SplitDiffView({ rows }: { rows: ReturnType }) { +function SplitDiffView({ + model, + onLeftRowClick, + onRightRowClick, + onAcceptBlock, + onRejectBlock, + actionBusy = false +}: { + model: SplitDiffModel; + onLeftRowClick?: (line: number) => void; + onRightRowClick?: (line: number) => void; + onAcceptBlock?: (block: SplitDiffChangeBlock) => void; + onRejectBlock?: (block: SplitDiffChangeBlock) => void; + actionBusy?: boolean; +}) { const { t } = useTranslation(); const leftRef = useRef(null); const rightRef = useRef(null); + const leftChangeRefs = useRef>({}); + const rightChangeRefs = useRef>({}); + const [activeBlock, setActiveBlock] = useState(0); const lockRef = useRef(false); const syncScroll = (source: HTMLDivElement | null, target: HTMLDivElement | null) => { @@ -966,18 +1158,90 @@ function SplitDiffView({ rows }: { rows: ReturnType }) { }); }; + useEffect(() => { + setActiveBlock(0); + leftChangeRefs.current = {}; + rightChangeRefs.current = {}; + }, [model]); + + const jumpToChange = useCallback((nextIndex: number) => { + if (model.changeBlocks.length === 0) return; + const normalized = (nextIndex + model.changeBlocks.length) % model.changeBlocks.length; + setActiveBlock(normalized); + const rowId = model.changeBlocks[normalized].anchorRowId; + leftChangeRefs.current[rowId]?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + rightChangeRefs.current[rowId]?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, [model.changeBlocks]); + + const activeBlockId = model.changeBlocks[activeBlock]?.id; + const activeBlockData = model.changeBlocks[activeBlock]; + const leftTone = useCallback((row: SplitDiffRow) => { + if (row.type === 'added') return 'side-empty'; + if (row.type === 'removed') return 'removed'; + if (row.type === 'changed') return 'changed'; + return 'context'; + }, []); + const rightTone = useCallback((row: SplitDiffRow) => { + if (row.type === 'removed') return 'side-empty'; + if (row.type === 'added') return 'added'; + if (row.type === 'changed') return 'changed'; + return 'context'; + }, []); + const activateRow = useCallback((rowId: string) => { + const blockId = model.rowToBlockId[rowId]; + if (!blockId) return; + const idx = model.changeBlocks.findIndex((block) => block.id === blockId); + if (idx >= 0) { + setActiveBlock(idx); + } + }, [model.changeBlocks, model.rowToBlockId]); + return (
+ {model.changeBlocks.length > 0 && ( +
+ +
{t('改动 {{current}} / {{total}}', { current: activeBlock + 1, total: model.changeBlocks.length })}
+ {onAcceptBlock && activeBlockData && ( + + )} + {onRejectBlock && activeBlockData && ( + + )} + +
+ )}
syncScroll(leftRef.current, rightRef.current)} >
{t('Before')}
- {rows.map((row, idx) => ( -
-
{row.leftNo ?? ''}
-
{row.left ?? ''}
+ {model.hunks.map((hunk, hunkIndex) => ( +
+
{t('改动块 {{index}}', { index: hunkIndex + 1 })}
+ {hunk.rows.map((row) => ( +
{ + if (row.isChange) leftChangeRefs.current[row.id] = node; + }} + onClick={() => { + activateRow(row.id); + if (onLeftRowClick && row.leftNo) { + onLeftRowClick(row.leftNo); + } + }} + > +
{row.leftNo ?? ''}
+
{row.left ?? ''}
+
+ ))}
))}
@@ -987,10 +1251,27 @@ function SplitDiffView({ rows }: { rows: ReturnType }) { onScroll={() => syncScroll(rightRef.current, leftRef.current)} >
{t('After')}
- {rows.map((row, idx) => ( -
-
{row.rightNo ?? ''}
-
{row.right ?? ''}
+ {model.hunks.map((hunk, hunkIndex) => ( +
+
{t('改动块 {{index}}', { index: hunkIndex + 1 })}
+ {hunk.rows.map((row) => ( +
{ + if (row.isChange) rightChangeRefs.current[row.id] = node; + }} + onClick={() => { + activateRow(row.id); + if (onRightRowClick && row.rightNo) { + onRightRowClick(row.rightNo); + } + }} + > +
{row.rightNo ?? ''}
+
{row.right ?? ''}
+
+ ))}
))}
@@ -1251,7 +1532,9 @@ export default function EditorPage() { const [wsTexDropdownOpen, setWsTexDropdownOpen] = useState(false); const [plotTypeDropdownOpen, setPlotTypeDropdownOpen] = useState(false); const [figureDropdownOpen, setFigureDropdownOpen] = useState(false); + const [agentBusy, setAgentBusy] = useState(false); const [pendingChanges, setPendingChanges] = useState([]); + const [pendingChunkBusy, setPendingChunkBusy] = useState>({}); const [compileLog, setCompileLog] = useState(''); const [pdfUrl, setPdfUrl] = useState(''); const [pdfScale, setPdfScale] = useState(1); @@ -1352,6 +1635,8 @@ export default function EditorPage() { const acceptChunkRef = useRef<() => void>(() => {}); const clearSuggestionRef = useRef<() => void>(() => {}); const saveActiveFileRef = useRef<() => void>(() => {}); + const pendingChangesRef = useRef([]); + const pendingChunkBusyRef = useRef>({}); const gridRef = useRef(null); const editorSplitRef = useRef(null); const pdfContainerRef = useRef(null); @@ -3065,6 +3350,176 @@ export default function EditorPage() { } }; + const jumpToDiffLine = async (filePath: string, line: number) => { + const view = cmViewRef.current; + if (!view || !line || line < 1 || !isTextFile(filePath)) return; + let content = ''; + try { + content = filePath === activePath ? editorValue : await openFile(filePath); + } catch { + return; + } + if (!content) return; + const offset = findLineOffset(content, line); + view.dispatch({ + selection: { anchor: offset, head: offset }, + scrollIntoView: true + }); + view.focus(); + }; + + const normalizeDiffLines = (input: string) => { + const normalized = input.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + const hasTrailingNewline = normalized.endsWith('\n'); + const lines = normalized.split('\n'); + if (hasTrailingNewline) lines.pop(); + return { lines, hasTrailingNewline }; + }; + + const composeDiffLines = (lines: string[], hasTrailingNewline: boolean) => { + const body = lines.join('\n'); + return hasTrailingNewline ? `${body}\n` : body; + }; + + useEffect(() => { + pendingChangesRef.current = pendingChanges; + }, [pendingChanges]); + + const updatePendingByFile = useCallback((filePath: string, nextChange: PendingChange | null) => { + setPendingChanges((prev) => { + if (!nextChange) return prev.filter((item) => item.filePath !== filePath); + let replaced = false; + const next = prev.map((item) => { + if (item.filePath !== filePath) return item; + replaced = true; + return nextChange; + }); + return replaced ? next : [...prev, nextChange]; + }); + setDiffFocus((prev) => { + if (!prev || prev.filePath !== filePath) return prev; + return nextChange; + }); + }, []); + + const setChunkBusyForFile = useCallback((filePath: string, busy: boolean) => { + if (busy) { + pendingChunkBusyRef.current[filePath] = true; + setPendingChunkBusy((prev) => ({ ...prev, [filePath]: true })); + return; + } + delete pendingChunkBusyRef.current[filePath]; + setPendingChunkBusy((prev) => { + if (!prev[filePath]) return prev; + const next = { ...prev }; + delete next[filePath]; + return next; + }); + }, []); + + const runChunkAction = useCallback(async (filePath: string, action: () => Promise | void) => { + if (!filePath) return; + if (pendingChunkBusyRef.current[filePath]) return; + setChunkBusyForFile(filePath, true); + try { + await action(); + } finally { + setChunkBusyForFile(filePath, false); + } + }, [setChunkBusyForFile]); + + const resolveLatestChunkContext = useCallback((filePath: string, blockId: string) => { + const latestChange = pendingChangesRef.current.find((item) => item.filePath === filePath); + if (!latestChange) return null; + const latestModel = buildSplitDiff(latestChange.original, latestChange.proposed); + const latestBlock = latestModel.changeBlocks.find((item) => item.id === blockId); + if (!latestBlock) return null; + return { change: latestChange, block: latestBlock }; + }, []); + + const acceptPendingBlock = useCallback(async (filePath: string, blockId: string) => { + await runChunkAction(filePath, async () => { + const resolved = resolveLatestChunkContext(filePath, blockId); + if (!resolved) return; + const { change, block } = resolved; + + const originalState = normalizeDiffLines(change.original); + const proposedState = normalizeDiffLines(change.proposed); + + const oldStart = Math.max(0, Math.min(block.oldStart, originalState.lines.length)); + const oldEnd = Math.max(oldStart, Math.min(block.oldEnd, originalState.lines.length)); + const newStart = Math.max(0, Math.min(block.newStart, proposedState.lines.length)); + const newEnd = Math.max(newStart, Math.min(block.newEnd, proposedState.lines.length)); + + const replacement = proposedState.lines.slice(newStart, newEnd); + const nextOriginalLines = [...originalState.lines]; + nextOriginalLines.splice(oldStart, oldEnd - oldStart, ...replacement); + + let nextOriginalTrailing = originalState.hasTrailingNewline; + if (oldEnd === originalState.lines.length && newEnd === proposedState.lines.length) { + nextOriginalTrailing = proposedState.hasTrailingNewline; + } + const nextOriginal = composeDiffLines(nextOriginalLines, nextOriginalTrailing); + + try { + await writeFileCompat(change.filePath, nextOriginal); + setFiles((prev) => ({ ...prev, [change.filePath]: nextOriginal })); + if (activePath === change.filePath && !collabActiveRef.current) { + setEditorDoc(nextOriginal); + } + const nextChange = + nextOriginal === change.proposed + ? null + : { + ...change, + original: nextOriginal, + diff: createTwoFilesPatch(change.filePath, change.filePath, nextOriginal, change.proposed, 'current', 'proposed') + }; + updatePendingByFile(change.filePath, nextChange); + setStatus(t('已应用当前改动')); + } catch (err) { + setStatus(t('操作失败: {{error}}', { error: String(err) })); + } + }); + }, [activePath, runChunkAction, resolveLatestChunkContext, setEditorDoc, t, updatePendingByFile, writeFileCompat]); + + const rejectPendingBlock = useCallback(async (filePath: string, blockId: string) => { + await runChunkAction(filePath, async () => { + const resolved = resolveLatestChunkContext(filePath, blockId); + if (!resolved) return; + const { change, block } = resolved; + + const originalState = normalizeDiffLines(change.original); + const proposedState = normalizeDiffLines(change.proposed); + + const oldStart = Math.max(0, Math.min(block.oldStart, originalState.lines.length)); + const oldEnd = Math.max(oldStart, Math.min(block.oldEnd, originalState.lines.length)); + const newStart = Math.max(0, Math.min(block.newStart, proposedState.lines.length)); + const newEnd = Math.max(newStart, Math.min(block.newEnd, proposedState.lines.length)); + + const replacement = originalState.lines.slice(oldStart, oldEnd); + const nextProposedLines = [...proposedState.lines]; + nextProposedLines.splice(newStart, newEnd - newStart, ...replacement); + + let nextProposedTrailing = proposedState.hasTrailingNewline; + if (newEnd === proposedState.lines.length && oldEnd === originalState.lines.length) { + nextProposedTrailing = originalState.hasTrailingNewline; + } + const nextProposed = composeDiffLines(nextProposedLines, nextProposedTrailing); + + const nextChange = + nextProposed === change.original + ? null + : { + ...change, + proposed: nextProposed, + diff: createTwoFilesPatch(change.filePath, change.filePath, change.original, nextProposed, 'current', 'proposed') + }; + updatePendingByFile(change.filePath, nextChange); + setStatus(t('已拒绝当前改动')); + }); + }, [runChunkAction, resolveLatestChunkContext, t, updatePendingByFile]); + const renderTree = (nodes: TreeNode[], depth = 0) => nodes.map((node) => { const isDir = node.type === 'dir'; @@ -3431,6 +3886,7 @@ export default function EditorPage() { }, []); const sendPrompt = async () => { + if (agentBusy) return; const isChat = assistantMode === 'chat'; if (!activePath && !isChat) return; if (isChat === false && task === 'translate') { @@ -3444,7 +3900,24 @@ export default function EditorPage() { const history = isChat ? chatMessages : agentMessages; const nextHistory = [...history, userMsg]; setHistory(nextHistory); + setAgentBusy(true); try { + const isLikelyFullDocumentSuggestion = (suggestion: string, fullText: string) => { + const sug = suggestion.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim(); + const src = fullText.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim(); + if (!src) return true; + if (!sug) return false; + const lineCount = (text: string) => Math.max(1, text.split('\n').length); + const charRatio = sug.length / Math.max(1, src.length); + const lineRatio = lineCount(sug) / lineCount(src); + const hasDocEnvelope = /\\documentclass|\\begin\{document\}|\\end\{document\}/.test(sug); + const hasSectioning = /\\section\{|\\subsection\{|\\chapter\{/.test(sug); + if (hasDocEnvelope) return true; + if (charRatio >= 0.55 && lineRatio >= 0.45) return true; + if (charRatio >= 0.7 && hasSectioning) return true; + return false; + }; + let effectivePrompt = prompt; let effectiveSelection = selectionText; let effectiveContent = editorValue; @@ -3512,6 +3985,10 @@ export default function EditorPage() { setPendingChanges(nextPending); setRightView('diff'); } else if (!isChat && res.suggestion) { + if (!selectionText && !isLikelyFullDocumentSuggestion(res.suggestion, editorValue)) { + setStatus(t('检测到返回的是局部建议,已阻止整文替换。请先选中要修改的段落,或使用 Tools 模式生成补丁。')); + return; + } const proposed = selectionText ? replaceSelection(editorValue, selectionRange[0], selectionRange[1], res.suggestion) : res.suggestion; @@ -3521,6 +3998,8 @@ export default function EditorPage() { } } catch (err) { setHistory((prev) => [...prev, { role: 'assistant', content: t('请求失败: {{error}}', { error: String(err) }) }]); + } finally { + setAgentBusy(false); } }; @@ -3692,6 +4171,8 @@ export default function EditorPage() {
+ + {selectionText && assistantMode === 'agent' && (
{t('已选择 {{count}} 字符,将用于任务输入', { count: selectionText.length })}
@@ -5306,14 +5787,29 @@ Be thorough. Read ALL .tex files before reporting. Group findings by category. I {pendingGrouped.length === 0 &&
{t('暂无待确认修改。')}
} {pendingGrouped.map((change) => ( (() => { - const rows = buildSplitDiff(change.original, change.proposed); + const model = buildSplitDiff(change.original, change.proposed); return (
{change.filePath}
- + { + void jumpToDiffLine(change.filePath, line); + }} + onRightRowClick={(line) => { + void jumpToDiffLine(change.filePath, line); + }} + onAcceptBlock={(block) => { + void acceptPendingBlock(change.filePath, block.id); + }} + onRejectBlock={(block) => { + void rejectPendingBlock(change.filePath, block.id); + }} + actionBusy={Boolean(pendingChunkBusy[change.filePath])} + />
@@ -5550,7 +6046,22 @@ Be thorough. Read ALL .tex files before reporting. Group findings by category. I
- + { + void jumpToDiffLine(diffFocus.filePath, line); + }} + onRightRowClick={(line) => { + void jumpToDiffLine(diffFocus.filePath, line); + }} + onAcceptBlock={(block) => { + void acceptPendingBlock(diffFocus.filePath, block.id); + }} + onRejectBlock={(block) => { + void rejectPendingBlock(diffFocus.filePath, block.id); + }} + actionBusy={Boolean(pendingChunkBusy[diffFocus.filePath])} + />
diff --git a/apps/frontend/src/i18n/locales/en-US.json b/apps/frontend/src/i18n/locales/en-US.json index 2647233..e498c28 100644 --- a/apps/frontend/src/i18n/locales/en-US.json +++ b/apps/frontend/src/i18n/locales/en-US.json @@ -233,6 +233,7 @@ "插入引用": "Insert citation", "插入目标 TeX": "Insert target TeX", "插入选中引用": "Insert selected citations", + "撤回": "Undo", "搜索文件...": "Search files...", "搜索过滤中无法拖拽排序。": "Cannot reorder while filtering.", "搜索过滤中无法拖拽移动。": "Cannot move while filtering.", @@ -366,6 +367,7 @@ "逐条总结已生成。": "Per-paper summaries generated.", "逐条总结生成失败。": "Failed to generate per-paper summaries.", "逐条总结解析失败。": "Failed to parse per-paper summaries.", + "重做": "Redo", "重命名": "Rename", "重命名失败: {{error}}": "Rename failed: {{error}}", "错误: {{error}}": "Error: {{error}}", @@ -402,7 +404,6 @@ "landing.feat.review.tab": "Review", "landing.feat.review.title": "Quality Review", "landing.feat.review.desc": "One-click checks for terminology consistency, missing citations, and compile errors with AI-powered fix suggestions.", - "欢迎回来": "Welcome back", "所有项目": "All Projects", "我的项目": "My Projects", @@ -413,7 +414,6 @@ "名称": "Name", "标签": "Tags", "最后修改": "Last Modified", - "操作": "Actions", "最近修改": "Recently Modified", "按名称": "By Name", "创建时间": "Created", @@ -452,8 +452,6 @@ "断开": "Disconnect", "连接": "Connect", "生成邀请链接": "Generate invite link", - "生成中...": "Generating...", - "复制": "Copy", "尚未生成邀请链接": "Invite link not generated", "当前文件: {{path}}": "Current file: {{path}}", "未选择文件": "No file selected", @@ -468,7 +466,6 @@ "生成邀请失败: {{error}}": "Failed to generate invite: {{error}}", "邀请链接已生成": "Invite link generated", "邀请链接已复制": "Invite link copied", - "复制失败: {{error}}": "Copy failed: {{error}}", "协作已同步 {{path}}": "Synced {{path}}", "协作同步失败: {{error}}": "Sync failed: {{error}}", "协作连接失败: {{error}}": "Collab connection failed: {{error}}", @@ -495,7 +492,14 @@ "选择源文件...": "Select source file...", "未找到 .tex 文件": "No .tex files found", "LLM API Endpoint": "LLM API Endpoint", - "LLM API Key": "LLM API Key", - "LLM Model": "LLM Model", - "展开": "Expand" + "展开": "Expand", + "上一处改动": "Previous change", + "下一处改动": "Next change", + "改动 {{current}} / {{total}}": "Change {{current}} / {{total}}", + "改动块 {{index}}": "Hunk {{index}}", + "接受当前改动": "Accept current change", + "拒绝当前改动": "Reject current change", + "已应用当前改动": "Applied current change", + "已拒绝当前改动": "Rejected current change", + "检测到返回的是局部建议,已阻止整文替换。请先选中要修改的段落,或使用 Tools 模式生成补丁。": "Detected a partial suggestion and blocked full-document replacement. Select the target paragraph first, or use Tools mode to generate patches." } diff --git a/apps/frontend/src/i18n/locales/zh-CN.json b/apps/frontend/src/i18n/locales/zh-CN.json index 8dacd0e..539d9d2 100644 --- a/apps/frontend/src/i18n/locales/zh-CN.json +++ b/apps/frontend/src/i18n/locales/zh-CN.json @@ -233,6 +233,7 @@ "插入引用": "插入引用", "插入目标 TeX": "插入目标 TeX", "插入选中引用": "插入选中引用", + "撤回": "撤回", "搜索文件...": "搜索文件...", "搜索过滤中无法拖拽排序。": "搜索过滤中无法拖拽排序。", "搜索过滤中无法拖拽移动。": "搜索过滤中无法拖拽移动。", @@ -366,6 +367,7 @@ "逐条总结已生成。": "逐条总结已生成。", "逐条总结生成失败。": "逐条总结生成失败。", "逐条总结解析失败。": "逐条总结解析失败。", + "重做": "重做", "重命名": "重命名", "重命名失败: {{error}}": "重命名失败: {{error}}", "错误: {{error}}": "错误: {{error}}", @@ -402,7 +404,6 @@ "landing.feat.review.tab": "质量评审", "landing.feat.review.title": "论文质量评审", "landing.feat.review.desc": "一键检查术语一致性、引用缺失、编译错误,AI 辅助发现潜在问题并给出修复建议。", - "欢迎回来": "欢迎回来", "所有项目": "所有项目", "我的项目": "我的项目", @@ -413,7 +414,6 @@ "名称": "名称", "标签": "标签", "最后修改": "最后修改", - "操作": "操作", "最近修改": "最近修改", "按名称": "按名称", "创建时间": "创建时间", @@ -452,8 +452,6 @@ "断开": "断开", "连接": "连接", "生成邀请链接": "生成邀请链接", - "生成中...": "生成中...", - "复制": "复制", "尚未生成邀请链接": "尚未生成邀请链接", "当前文件: {{path}}": "当前文件: {{path}}", "未选择文件": "未选择文件", @@ -468,7 +466,6 @@ "生成邀请失败: {{error}}": "生成邀请失败: {{error}}", "邀请链接已生成": "邀请链接已生成", "邀请链接已复制": "邀请链接已复制", - "复制失败: {{error}}": "复制失败: {{error}}", "协作已同步 {{path}}": "协作已同步 {{path}}", "协作同步失败: {{error}}": "协作同步失败: {{error}}", "协作连接失败: {{error}}": "协作连接失败: {{error}}", @@ -495,7 +492,14 @@ "选择源文件...": "选择源文件...", "未找到 .tex 文件": "未找到 .tex 文件", "LLM API Endpoint": "LLM API Endpoint", - "LLM API Key": "LLM API Key", - "LLM Model": "LLM Model", - "展开": "展开" + "展开": "展开", + "上一处改动": "上一处改动", + "下一处改动": "下一处改动", + "改动 {{current}} / {{total}}": "改动 {{current}} / {{total}}", + "改动块 {{index}}": "改动块 {{index}}", + "接受当前改动": "接受当前改动", + "拒绝当前改动": "拒绝当前改动", + "已应用当前改动": "已应用当前改动", + "已拒绝当前改动": "已拒绝当前改动", + "检测到返回的是局部建议,已阻止整文替换。请先选中要修改的段落,或使用 Tools 模式生成补丁。": "检测到返回的是局部建议,已阻止整文替换。请先选中要修改的段落,或使用 Tools 模式生成补丁。" }