Files
agent_jrxml/frontend/src/App.vue
T
panda bd5bfbac2d fix: band-level windowed refine_layout + programmatic map_fields to prevent 91.5% content loss
Root cause: LLM receiving full 34k-char JRXML would regenerate from scratch
instead of modifying coordinates in-place, shrinking output to ~3k chars.

Solution (programmatic node control, not prompt engineering):

- New agent/jrxml_windower.py: decompose JRXML into header (never sent to
  LLM) + individual bands. Split bands >4000 chars at element boundaries.
  Reassemble with element count validation (>10% change = rollback).

- Rewrite refine_layout: per-band windowed LLM processing (~2-4k chars
  each). LLM cannot "reimagine" the entire report.

- Rewrite map_fields: 100% programmatic regex $F{field_N} -> real name
  replacement. Zero LLM calls, zero content loss.

- _sanitize_field_name: non-ASCII chars escaped to _uXXXX_ format for
  valid JRXML identifiers.

- Tests: 48 new unit tests (windower 28 + map_fields 20). All passing.
  Full suite 385 tests, zero regressions.
2026-05-24 08:55:38 +08:00

197 lines
5.5 KiB
Vue

<script setup lang="ts">
import { watch, nextTick, ref } from 'vue'
import { useChatStore } from './stores/chat'
import { useSessionStore } from './stores/session'
import { api } from './api/client'
import Sidebar from './components/Sidebar.vue'
import ChatMessages from './components/ChatMessages.vue'
import ProcessSection from './components/ProcessSection.vue'
import SummaryCard from './components/SummaryCard.vue'
import UnifiedInput from './components/UnifiedInput.vue'
import KbSelector from './components/KbSelector.vue'
import KbManager from './components/KbManager.vue'
import { useKbStore } from './stores/kb'
const chat = useChatStore()
const session = useSessionStore()
const kb = useKbStore()
function handleKbChange(kbId: string) {
if (session.currentId) {
kb.bindKbToSession(session.currentId, kbId)
}
}
const chatContainer = ref<HTMLElement | null>(null)
async function scrollToBottom() {
await nextTick()
if (chatContainer.value) {
chatContainer.value.scrollTop = chatContainer.value.scrollHeight
}
}
watch(
() => [chat.messages.length, chat.streamText],
() => scrollToBottom(),
{ flush: 'post' }
)
async function handleSend(text: string, files: File[]) {
if (!session.currentId) {
const sid = await session.createSession()
await session.switchSession(sid)
}
// Upload files first
const remoteIds: string[] = []
for (const f of files) {
try {
const info = await api.uploadFile(f, session.currentId)
remoteIds.push(info.file_id)
} catch (e) {
console.error('文件上传失败:', e)
chat.setError('文件上传失败')
return
}
}
chat.addMessage({ role: 'user', content: text || '[附加文件]' })
scrollToBottom()
chat.startStreaming()
try {
await api.chat(session.currentId, text, remoteIds, {
onNodeStart(data) {
chat.addNode({ node: data.node, label: data.label, step_index: data.step_index })
},
onNodeComplete(data) {
chat.completeNode(data)
},
onStreamToken(data) {
chat.appendStreamToken(data.text)
scrollToBottom()
},
onAgentComplete(data) {
chat.finishStreaming({
intent: data.intent,
status: data.status,
jrxml_length: data.jrxml_length,
error_msg: data.error_msg,
natural_explanation: data.natural_explanation,
consult_answer: data.consult_answer,
retry_count: data.retry_count,
total_duration_ms: data.total_duration_ms,
ocr_extraction_result: data.ocr_extraction_result,
})
const streamContent = chat.streamText
if (data.status === 'pass') {
if (streamContent) {
chat.addMessage({ role: 'assistant', content: streamContent, type: 'jrxml' })
}
chat.addMessage({ role: 'assistant', content: 'JRXML 生成成功!可从侧边栏下载。', type: 'success' })
} else if (data.status && data.status !== 'pass') {
chat.addMessage({
role: 'assistant',
content: `经过 ${data.retry_count} 次重试后失败。\n\n错误: ${data.error_msg}${data.natural_explanation ? '\n\n原因: ' + data.natural_explanation : ''}`,
type: 'error',
})
} else if (data.intent === 'consult_question') {
// 咨询回答:优先用 streamContent,其次用 consult_answer
const answerText = streamContent || data.consult_answer || ''
if (answerText) {
chat.addMessage({ role: 'assistant', content: answerText, type: 'consult' })
} else {
chat.addMessage({ role: 'assistant', content: '咨询已完成,但未获取到回答内容。', type: 'error' })
}
} else {
if (streamContent) {
chat.addMessage({ role: 'assistant', content: streamContent, type: 'jrxml' })
}
}
// Refresh session sidebar data after a short delay
setTimeout(() => session.refreshFromApi(), 500)
},
onAgentError(data) {
chat.setError(data.error)
chat.addMessage({ role: 'assistant', content: `执行异常: ${data.error}`, type: 'error' })
setTimeout(() => session.refreshFromApi(), 500)
},
})
} catch (e: any) {
chat.setError(e.message || '网络请求失败')
chat.addMessage({ role: 'assistant', content: `请求失败: ${e.message}`, type: 'error' })
chat.finishStreaming({ status: '' })
} finally {
if (chat.streaming) {
chat.finishStreaming({ status: '' })
}
}
}
</script>
<template>
<div class="app-layout">
<Sidebar @quickAction="(text) => handleSend(text, [])" />
<main class="main-area">
<KbSelector @change="handleKbChange" />
<div class="chat-container" ref="chatContainer">
<ChatMessages />
<ProcessSection />
<SummaryCard />
</div>
<UnifiedInput
:disabled="chat.streaming"
@send="handleSend"
/>
</main>
<KbManager />
</div>
</template>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: #11111b;
color: #cdd6f4;
}
#app {
width: 100vw;
height: 100vh;
}
.app-layout {
display: flex;
width: 100%;
height: 100%;
}
.main-area {
flex: 1;
display: flex;
flex-direction: column;
height: 100vh;
min-width: 0;
}
.chat-container {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
}
</style>