diff --git a/docs/5.开发计划/1.自动汇总-前端线框图.md b/docs/5.开发计划/1.自动汇总-前端线框图.md new file mode 100644 index 0000000..3bb0ed1 --- /dev/null +++ b/docs/5.开发计划/1.自动汇总-前端线框图.md @@ -0,0 +1,74 @@ +# 自动汇总前端线框图 + +## 评审目标 + +在实现三栏页面前,先确认审核智能体工作台的信息架构、右侧文件汇总面板、工作流状态展示和移动端降级方式。 + +## 桌面端布局 + +```mermaid +flowchart LR + A["左栏:会话列表
新对话 / 搜索 / 历史会话"] --> B["中栏:聊天区
顶部导航 / 消息流 / 输入框"] + B --> C["右栏:文件汇总面板"] + C --> C1["上半区:上传区
拖拽上传 / 选择文件 / 上传状态"] + C --> C2["中段:当前对话附件
文件名 / 版本 / 大小 / 状态 / 删除"] + C --> C3["下半区:工作流卡片
批次号 / 节点进度 / 下载入口"] +``` + +## 右侧面板结构 + +```mermaid +flowchart TB + P["文件汇总面板"] --> U["上传拖拽区"] + U --> U0["无附件:提示上传文件或压缩包"] + U --> U1["上传中:显示文件名和处理中状态"] + U --> U2["上传失败:展示错误并允许重试"] + P --> L["附件列表"] + L --> L1["active 版本优先展示"] + L --> L2["历史版本保留展示"] + L --> L3["逻辑删除后从默认候选移除"] + P --> W["工作流卡片列表"] + W --> W1["运行中:节点逐项更新"] + W --> W2["成功:展示 Markdown/Excel 下载"] + W --> W3["失败:展示失败节点和错误说明"] +``` + +## 工作流状态流转 + +```mermaid +stateDiagram-v2 + [*] --> Pending: 用户上传附件 + Pending --> Running: 发送自动汇总提示词 + Running --> Extracting: 固化附件 + Extracting --> Scanning: 解压完成或跳过 + Scanning --> Counting: 生成文件清单 + Counting --> Detecting: 页数统计完成 + Detecting --> Reporting: 产品名识别完成 + Reporting --> Success: 生成报告与下载 + Running --> Failed: 批次级异常 + Extracting --> Failed: 解压安全检查失败 + Reporting --> Failed: 报告生成失败 + Success --> Restored: 刷新页面后状态恢复 + Failed --> Restored: 刷新页面后状态恢复 +``` + +## 移动端布局 + +```mermaid +flowchart TB + M["移动端工作台"] --> T["顶部:侧栏按钮 / 当前页面 / 用户菜单"] + T --> Chat["聊天区优先展示"] + Chat --> Composer["底部输入框"] + T --> Drawer["会话侧栏抽屉"] + Chat --> Panel["文件汇总面板下移或折叠"] + Panel --> Upload["上传区"] + Panel --> Workflow["工作流卡片"] +``` + +## 关键评审点 + +- 桌面端保持左侧会话、中间聊天、右侧文件汇总三栏,不改变现有聊天主路径。 +- 右侧面板上半部分用于上传和附件列表,下半部分用于批次工作流卡片。 +- 工作流卡片节点顺序固定为:附件固化、压缩包解压、文件扫描、页数统计、产品识别、报告输出、完成。 +- 助手消息中的文件汇总结果使用安全 Markdown 渲染,用户消息仍按纯文本转义。 +- 移动端优先保证聊天可用,文件汇总面板折叠或下移,不能遮挡输入框。 diff --git a/review_agent/views.py b/review_agent/views.py index e384834..a2aa67e 100644 --- a/review_agent/views.py +++ b/review_agent/views.py @@ -10,6 +10,7 @@ from .services import ( send_message, stream_message, ) +from .models import FileAttachment, FileSummaryBatch @login_required @@ -49,6 +50,8 @@ def workspace(request: HttpRequest) -> HttpResponse: "conversations": conversations, "current_conversation": current, "messages": current.messages.all() if current else [], + "attachments": FileAttachment.objects.filter(conversation=current).order_by("original_name", "-version_no") if current else [], + "summary_batches": FileSummaryBatch.objects.filter(conversation=current).prefetch_related("node_runs").order_by("-created_at")[:5] if current else [], }, ) diff --git a/static/css/login.css b/static/css/login.css index 7f4f93f..3162919 100644 --- a/static/css/login.css +++ b/static/css/login.css @@ -127,7 +127,7 @@ input:focus { .workspace { display: grid; - grid-template-columns: 296px minmax(0, 1fr); + grid-template-columns: 296px minmax(0, 1fr) 340px; min-height: 100vh; } @@ -760,9 +760,176 @@ input:focus { padding-right: 12px; } +.summary-panel { + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + gap: 14px; + min-width: 0; + max-height: 100vh; + padding: 16px; + overflow: auto; + border-left: 1px solid var(--line); + background: #ffffff; +} + +.summary-section { + display: grid; + gap: 12px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel-soft); +} + +.summary-heading, +.summary-subheading, +.workflow-card header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.summary-heading h2, +.summary-subheading h3 { + margin: 0; + font-size: 16px; +} + +.summary-heading span { + color: var(--muted); + font-size: 12px; +} + +.upload-dropzone { + display: grid; + place-items: center; + gap: 6px; + min-height: 112px; + padding: 18px; + border: 1px dashed var(--accent); + border-radius: 8px; + background: #f5f9ff; + color: var(--text); + cursor: pointer; + text-align: center; +} + +.upload-dropzone.dragging { + border-color: var(--accent-dark); + background: #eaf2ff; +} + +.upload-dropzone span, +.upload-status, +.attachment-item span, +.workflow-card em { + color: var(--muted); + font-size: 12px; +} + +.upload-status { + margin: 0; + line-height: 1.5; +} + +.attachment-list, +.workflow-card-list { + display: grid; + gap: 10px; +} + +.attachment-item, +.workflow-card { + display: grid; + gap: 10px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: #ffffff; +} + +.attachment-item { + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; +} + +.attachment-item strong, +.workflow-card strong { + display: block; + overflow-wrap: anywhere; + font-size: 13px; +} + +.attachment-item em, +.workflow-status { + padding: 3px 8px; + border-radius: 999px; + background: #eaf2ff; + color: var(--accent); + font-size: 11px; + font-style: normal; + font-weight: 700; +} + +.workflow-card ol { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.node-status { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 0; + border-top: 1px solid var(--line); + font-size: 13px; +} + +.status-running, +.status-retrying { + color: var(--accent); +} + +.status-success { + color: #047857; +} + +.status-failed { + color: var(--danger-text); +} + +.panel-empty { + padding: 14px; + border: 1px dashed var(--line); + border-radius: 8px; + color: var(--muted); + text-align: center; +} + +.message-bubble table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.message-bubble th, +.message-bubble td { + padding: 8px; + border: 1px solid var(--line); + text-align: left; + vertical-align: top; +} + @media (max-width: 980px) { .workspace { grid-template-columns: minmax(0, 1fr); + min-height: 100vh; + overflow: auto; } .sidebar { @@ -815,7 +982,14 @@ input:focus { } .chat-stage { - height: calc(100vh - 88px); + min-height: calc(100vh - 88px); + height: auto; + } + + .summary-panel { + max-height: none; + border-left: 0; + border-top: 1px solid var(--line); } .chat-scroll { @@ -889,7 +1063,7 @@ input:focus { width: 20px; } -.node-dot { + .node-dot { width: 10px; height: 10px; } diff --git a/static/js/app.js b/static/js/app.js index 1c3ee89..e8d2155 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -11,6 +11,12 @@ var sendButton = document.getElementById("sendButton"); var conversationIdInput = document.getElementById("conversationIdInput"); var chatStage = document.querySelector(".chat-stage"); + var summaryPanel = document.getElementById("summaryPanel"); + var uploadDropzone = document.getElementById("uploadDropzone"); + var attachmentInput = document.getElementById("attachmentInput"); + var attachmentList = document.getElementById("attachmentList"); + var uploadStatus = document.getElementById("uploadStatus"); + var workflowCardList = document.getElementById("workflowCardList"); var nodeAnchors = []; if (!workspace) { @@ -32,7 +38,7 @@ function syncSidebarState() { if (isMobile()) { - if (workspace.getAttribute("data-sidebar-state") === "collapsed") { + if (workspace.getAttribute("data-sidebar-state") !== "closed") { workspace.setAttribute("data-sidebar-state", "closed"); } } else if (workspace.getAttribute("data-sidebar-state") === "closed") { @@ -147,6 +153,13 @@ return escapeHtml(text).replace(/\n/g, "
"); } + function renderAssistantContent(text) { + if (window.marked && window.DOMPurify) { + return window.DOMPurify.sanitize(window.marked.parse(text || "")); + } + return nl2br(text || ""); + } + function scrollChatToBottom() { if (chatScroll) { chatScroll.scrollTop = chatScroll.scrollHeight; @@ -169,7 +182,7 @@ bubble.className = "message-bubble"; var text = document.createElement("p"); - text.innerHTML = nl2br(content); + text.innerHTML = role === "assistant" ? renderAssistantContent(content) : nl2br(content); bubble.appendChild(text); article.appendChild(avatar); @@ -271,6 +284,149 @@ } } + function currentConversationId() { + return conversationIdInput ? conversationIdInput.value : ""; + } + + function templateUrl(attributeName, token, value) { + if (!summaryPanel) { + return ""; + } + return summaryPanel.getAttribute(attributeName).replace(token, value); + } + + function renderAttachments(attachments) { + if (!attachmentList) { + return; + } + attachmentList.innerHTML = ""; + if (!attachments.length) { + attachmentList.innerHTML = '
暂无附件
'; + return; + } + attachments.forEach(function (attachment) { + var item = document.createElement("div"); + item.className = "attachment-item"; + item.setAttribute("data-attachment-id", attachment.id); + item.innerHTML = + "
" + + escapeHtml(attachment.original_name) + + "v" + + attachment.version_no + + " · " + + attachment.file_size + + " bytes · " + + escapeHtml(attachment.upload_status) + + "
" + + (attachment.is_active ? "active" : ""); + attachmentList.appendChild(item); + }); + } + + async function refreshAttachments() { + var conversationId = currentConversationId(); + if (!conversationId || !summaryPanel) { + return; + } + var response = await fetch(templateUrl("data-attachment-url-template", "__conversation_id__", conversationId)); + if (!response.ok) { + return; + } + var payload = await response.json(); + renderAttachments(payload.attachments || []); + } + + async function uploadFiles(files) { + var conversationId = currentConversationId(); + if (!conversationId || !files.length || !summaryPanel) { + if (uploadStatus) { + uploadStatus.textContent = "请先创建或选择一个对话。"; + } + return; + } + var data = new FormData(); + Array.prototype.forEach.call(files, function (file) { + data.append("files", file); + }); + var csrf = new FormData(composer).get("csrfmiddlewaretoken"); + if (uploadStatus) { + uploadStatus.textContent = "正在上传 " + files.length + " 个文件..."; + } + try { + var response = await fetch(templateUrl("data-attachment-url-template", "__conversation_id__", conversationId), { + method: "POST", + headers: { "X-CSRFToken": csrf }, + body: data, + }); + if (!response.ok) { + throw new Error("上传失败。"); + } + var payload = await response.json(); + renderAttachments(payload.attachments || []); + if (uploadStatus) { + uploadStatus.textContent = "上传完成,可发送自动汇总提示词。"; + } + await refreshAttachments(); + } catch (error) { + if (uploadStatus) { + uploadStatus.textContent = "上传失败,请重试。"; + } + } + } + + function ensureWorkflowCard(batch) { + if (!workflowCardList || !batch) { + return null; + } + var empty = workflowCardList.querySelector(".panel-empty"); + if (empty) { + empty.remove(); + } + var card = workflowCardList.querySelector('[data-batch-id="' + batch.batch_id + '"]'); + if (card) { + return card; + } + card = document.createElement("article"); + card.className = "workflow-card"; + card.setAttribute("data-batch-id", batch.batch_id); + card.innerHTML = + "
" + + escapeHtml(batch.batch_no || "文件汇总") + + 'running
    '; + workflowCardList.prepend(card); + return card; + } + + async function refreshWorkflowCard(batchId) { + if (!summaryPanel || !batchId) { + return; + } + var response = await fetch(templateUrl("data-status-url-template", "__batch_id__", batchId)); + if (!response.ok) { + return; + } + var payload = await response.json(); + var card = ensureWorkflowCard({ + batch_id: payload.batch.id, + batch_no: payload.batch.batch_no, + }); + if (!card) { + return; + } + var status = card.querySelector(".workflow-status"); + status.textContent = payload.batch.status; + status.className = "workflow-status status-" + payload.batch.status; + var list = card.querySelector("ol"); + list.innerHTML = ""; + (payload.nodes || []).forEach(function (node) { + var item = document.createElement("li"); + item.className = "node-status status-" + node.status; + item.setAttribute("data-node-code", node.node_code); + item.innerHTML = "" + escapeHtml(node.node_name) + "" + node.progress + "%"; + list.appendChild(item); + }); + } + async function streamChat(event) { event.preventDefault(); if (!composer || !promptInput || !sendButton || !chatStage) { @@ -356,11 +512,14 @@ } } else if (eventName === "chunk") { assistantText += payload.delta || ""; - assistantMessage.text.innerHTML = nl2br(assistantText); + assistantMessage.text.innerHTML = renderAssistantContent(assistantText); scrollChatToBottom(); } else if (eventName === "error") { assistantText = payload.message || "模型调用失败。"; - assistantMessage.text.innerHTML = nl2br(assistantText); + assistantMessage.text.innerHTML = renderAssistantContent(assistantText); + } else if (eventName === "workflow_started") { + ensureWorkflowCard(payload); + refreshWorkflowCard(payload.batch_id); } else if (eventName === "done") { if (payload.assistant_message_id) { assistantMessage.article.id = "message-" + payload.assistant_message_id; @@ -400,6 +559,28 @@ composer.addEventListener("submit", streamChat); } + if (uploadDropzone && attachmentInput) { + uploadDropzone.addEventListener("click", function () { + attachmentInput.click(); + }); + uploadDropzone.addEventListener("dragover", function (event) { + event.preventDefault(); + uploadDropzone.classList.add("dragging"); + }); + uploadDropzone.addEventListener("dragleave", function () { + uploadDropzone.classList.remove("dragging"); + }); + uploadDropzone.addEventListener("drop", function (event) { + event.preventDefault(); + uploadDropzone.classList.remove("dragging"); + uploadFiles(event.dataTransfer.files); + }); + attachmentInput.addEventListener("change", function () { + uploadFiles(attachmentInput.files); + attachmentInput.value = ""; + }); + } + window.addEventListener("resize", syncSidebarState); syncSidebarState(); })(); diff --git a/templates/home.html b/templates/home.html index 88c8c26..87f9ccd 100644 --- a/templates/home.html +++ b/templates/home.html @@ -164,9 +164,77 @@ + + {% endblock %} {% block scripts %} + + {% endblock %} diff --git a/tests/test_file_summary_frontend.py b/tests/test_file_summary_frontend.py new file mode 100644 index 0000000..71d0318 --- /dev/null +++ b/tests/test_file_summary_frontend.py @@ -0,0 +1,22 @@ +import pytest +from django.urls import reverse + +from review_agent.models import Conversation + + +pytestmark = pytest.mark.django_db + + +def test_workspace_renders_summary_panel(client, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + client.force_login(user) + + response = client.get(f"{reverse('home')}?conversation={conversation.pk}") + + assert response.status_code == 200 + content = response.content.decode("utf-8") + assert 'id="summaryPanel"' in content + assert 'id="uploadDropzone"' in content + assert 'id="workflowCardList"' in content + assert "自动汇总文件目录与页数" in content