feat(file-summary): 增加前端汇总面板
This commit is contained in:
74
docs/5.开发计划/1.自动汇总-前端线框图.md
Normal file
74
docs/5.开发计划/1.自动汇总-前端线框图.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# 自动汇总前端线框图
|
||||||
|
|
||||||
|
## 评审目标
|
||||||
|
|
||||||
|
在实现三栏页面前,先确认审核智能体工作台的信息架构、右侧文件汇总面板、工作流状态展示和移动端降级方式。
|
||||||
|
|
||||||
|
## 桌面端布局
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A["左栏:会话列表<br/>新对话 / 搜索 / 历史会话"] --> B["中栏:聊天区<br/>顶部导航 / 消息流 / 输入框"]
|
||||||
|
B --> C["右栏:文件汇总面板"]
|
||||||
|
C --> C1["上半区:上传区<br/>拖拽上传 / 选择文件 / 上传状态"]
|
||||||
|
C --> C2["中段:当前对话附件<br/>文件名 / 版本 / 大小 / 状态 / 删除"]
|
||||||
|
C --> C3["下半区:工作流卡片<br/>批次号 / 节点进度 / 下载入口"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 右侧面板结构
|
||||||
|
|
||||||
|
```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 渲染,用户消息仍按纯文本转义。
|
||||||
|
- 移动端优先保证聊天可用,文件汇总面板折叠或下移,不能遮挡输入框。
|
||||||
@@ -10,6 +10,7 @@ from .services import (
|
|||||||
send_message,
|
send_message,
|
||||||
stream_message,
|
stream_message,
|
||||||
)
|
)
|
||||||
|
from .models import FileAttachment, FileSummaryBatch
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -49,6 +50,8 @@ def workspace(request: HttpRequest) -> HttpResponse:
|
|||||||
"conversations": conversations,
|
"conversations": conversations,
|
||||||
"current_conversation": current,
|
"current_conversation": current,
|
||||||
"messages": current.messages.all() if current else [],
|
"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 [],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ input:focus {
|
|||||||
|
|
||||||
.workspace {
|
.workspace {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 296px minmax(0, 1fr);
|
grid-template-columns: 296px minmax(0, 1fr) 340px;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -760,9 +760,176 @@ input:focus {
|
|||||||
padding-right: 12px;
|
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) {
|
@media (max-width: 980px) {
|
||||||
.workspace {
|
.workspace {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
@@ -815,7 +982,14 @@ input:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chat-stage {
|
.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 {
|
.chat-scroll {
|
||||||
|
|||||||
189
static/js/app.js
189
static/js/app.js
@@ -11,6 +11,12 @@
|
|||||||
var sendButton = document.getElementById("sendButton");
|
var sendButton = document.getElementById("sendButton");
|
||||||
var conversationIdInput = document.getElementById("conversationIdInput");
|
var conversationIdInput = document.getElementById("conversationIdInput");
|
||||||
var chatStage = document.querySelector(".chat-stage");
|
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 = [];
|
var nodeAnchors = [];
|
||||||
|
|
||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
@@ -32,7 +38,7 @@
|
|||||||
|
|
||||||
function syncSidebarState() {
|
function syncSidebarState() {
|
||||||
if (isMobile()) {
|
if (isMobile()) {
|
||||||
if (workspace.getAttribute("data-sidebar-state") === "collapsed") {
|
if (workspace.getAttribute("data-sidebar-state") !== "closed") {
|
||||||
workspace.setAttribute("data-sidebar-state", "closed");
|
workspace.setAttribute("data-sidebar-state", "closed");
|
||||||
}
|
}
|
||||||
} else if (workspace.getAttribute("data-sidebar-state") === "closed") {
|
} else if (workspace.getAttribute("data-sidebar-state") === "closed") {
|
||||||
@@ -147,6 +153,13 @@
|
|||||||
return escapeHtml(text).replace(/\n/g, "<br>");
|
return escapeHtml(text).replace(/\n/g, "<br>");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderAssistantContent(text) {
|
||||||
|
if (window.marked && window.DOMPurify) {
|
||||||
|
return window.DOMPurify.sanitize(window.marked.parse(text || ""));
|
||||||
|
}
|
||||||
|
return nl2br(text || "");
|
||||||
|
}
|
||||||
|
|
||||||
function scrollChatToBottom() {
|
function scrollChatToBottom() {
|
||||||
if (chatScroll) {
|
if (chatScroll) {
|
||||||
chatScroll.scrollTop = chatScroll.scrollHeight;
|
chatScroll.scrollTop = chatScroll.scrollHeight;
|
||||||
@@ -169,7 +182,7 @@
|
|||||||
bubble.className = "message-bubble";
|
bubble.className = "message-bubble";
|
||||||
|
|
||||||
var text = document.createElement("p");
|
var text = document.createElement("p");
|
||||||
text.innerHTML = nl2br(content);
|
text.innerHTML = role === "assistant" ? renderAssistantContent(content) : nl2br(content);
|
||||||
bubble.appendChild(text);
|
bubble.appendChild(text);
|
||||||
|
|
||||||
article.appendChild(avatar);
|
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 = '<div class="panel-empty">暂无附件</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
attachments.forEach(function (attachment) {
|
||||||
|
var item = document.createElement("div");
|
||||||
|
item.className = "attachment-item";
|
||||||
|
item.setAttribute("data-attachment-id", attachment.id);
|
||||||
|
item.innerHTML =
|
||||||
|
"<div><strong>" +
|
||||||
|
escapeHtml(attachment.original_name) +
|
||||||
|
"</strong><span>v" +
|
||||||
|
attachment.version_no +
|
||||||
|
" · " +
|
||||||
|
attachment.file_size +
|
||||||
|
" bytes · " +
|
||||||
|
escapeHtml(attachment.upload_status) +
|
||||||
|
"</span></div>" +
|
||||||
|
(attachment.is_active ? "<em>active</em>" : "");
|
||||||
|
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 =
|
||||||
|
"<header><strong>" +
|
||||||
|
escapeHtml(batch.batch_no || "文件汇总") +
|
||||||
|
'</strong><span class="workflow-status status-running">running</span></header><ol></ol>';
|
||||||
|
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 = "<span>" + escapeHtml(node.node_name) + "</span><em>" + node.progress + "%</em>";
|
||||||
|
list.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function streamChat(event) {
|
async function streamChat(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!composer || !promptInput || !sendButton || !chatStage) {
|
if (!composer || !promptInput || !sendButton || !chatStage) {
|
||||||
@@ -356,11 +512,14 @@
|
|||||||
}
|
}
|
||||||
} else if (eventName === "chunk") {
|
} else if (eventName === "chunk") {
|
||||||
assistantText += payload.delta || "";
|
assistantText += payload.delta || "";
|
||||||
assistantMessage.text.innerHTML = nl2br(assistantText);
|
assistantMessage.text.innerHTML = renderAssistantContent(assistantText);
|
||||||
scrollChatToBottom();
|
scrollChatToBottom();
|
||||||
} else if (eventName === "error") {
|
} else if (eventName === "error") {
|
||||||
assistantText = payload.message || "模型调用失败。";
|
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") {
|
} else if (eventName === "done") {
|
||||||
if (payload.assistant_message_id) {
|
if (payload.assistant_message_id) {
|
||||||
assistantMessage.article.id = "message-" + payload.assistant_message_id;
|
assistantMessage.article.id = "message-" + payload.assistant_message_id;
|
||||||
@@ -400,6 +559,28 @@
|
|||||||
composer.addEventListener("submit", streamChat);
|
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);
|
window.addEventListener("resize", syncSidebarState);
|
||||||
syncSidebarState();
|
syncSidebarState();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -164,9 +164,77 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<aside
|
||||||
|
class="summary-panel"
|
||||||
|
id="summaryPanel"
|
||||||
|
data-attachment-url-template="/api/review-agent/conversations/__conversation_id__/attachments/"
|
||||||
|
data-status-url-template="/api/review-agent/file-summary/__batch_id__/status/"
|
||||||
|
data-events-url-template="/api/review-agent/file-summary/__batch_id__/events/"
|
||||||
|
>
|
||||||
|
<section class="summary-section upload-section">
|
||||||
|
<div class="summary-heading">
|
||||||
|
<h2>文件汇总</h2>
|
||||||
|
<span>当前对话</span>
|
||||||
|
</div>
|
||||||
|
<div class="upload-dropzone" id="uploadDropzone" tabindex="0" role="button">
|
||||||
|
<input id="attachmentInput" type="file" multiple hidden>
|
||||||
|
<strong>拖拽文件到这里</strong>
|
||||||
|
<span>支持多文件、zip、7z、rar</span>
|
||||||
|
</div>
|
||||||
|
<p class="upload-status" id="uploadStatus">上传后发送“自动汇总文件目录与页数”启动工作流。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="summary-section attachment-section">
|
||||||
|
<div class="summary-subheading">
|
||||||
|
<h3>附件</h3>
|
||||||
|
</div>
|
||||||
|
<div class="attachment-list" id="attachmentList">
|
||||||
|
{% for attachment in attachments %}
|
||||||
|
<div class="attachment-item" data-attachment-id="{{ attachment.pk }}">
|
||||||
|
<div>
|
||||||
|
<strong>{{ attachment.original_name }}</strong>
|
||||||
|
<span>v{{ attachment.version_no }} · {{ attachment.file_size }} bytes · {{ attachment.upload_status }}</span>
|
||||||
|
</div>
|
||||||
|
{% if attachment.is_active %}<em>active</em>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="panel-empty">暂无附件</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="summary-section workflow-section">
|
||||||
|
<div class="summary-subheading">
|
||||||
|
<h3>工作流</h3>
|
||||||
|
</div>
|
||||||
|
<div class="workflow-card-list" id="workflowCardList">
|
||||||
|
{% for batch in summary_batches %}
|
||||||
|
<article class="workflow-card" data-batch-id="{{ batch.pk }}">
|
||||||
|
<header>
|
||||||
|
<strong>{{ batch.batch_no }}</strong>
|
||||||
|
<span class="workflow-status status-{{ batch.status }}">{{ batch.status }}</span>
|
||||||
|
</header>
|
||||||
|
<ol>
|
||||||
|
{% for node in batch.node_runs.all %}
|
||||||
|
<li class="node-status status-{{ node.status }}" data-node-code="{{ node.node_code }}">
|
||||||
|
<span>{{ node.node_name }}</span>
|
||||||
|
<em>{{ node.progress }}%</em>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
</article>
|
||||||
|
{% empty %}
|
||||||
|
<div class="panel-empty">暂无工作流</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
</main>
|
</main>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.2.6/dist/purify.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/marked@15.0.12/marked.min.js"></script>
|
||||||
<script src="{% static 'js/app.js' %}"></script>
|
<script src="{% static 'js/app.js' %}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
22
tests/test_file_summary_frontend.py
Normal file
22
tests/test_file_summary_frontend.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user