diff --git a/static/css/login.css b/static/css/login.css index 3162919..ea762c6 100644 --- a/static/css/login.css +++ b/static/css/login.css @@ -125,10 +125,20 @@ input:focus { overflow: hidden; } +.app-shell { + display: grid; + grid-template-rows: 60px minmax(0, 1fr); + height: 100vh; + min-height: 0; + background: var(--bg); +} + .workspace { display: grid; grid-template-columns: 296px minmax(0, 1fr) 340px; - min-height: 100vh; + min-height: 0; + height: 100%; + overflow: hidden; } .sidebar { @@ -136,6 +146,8 @@ input:focus { flex-direction: column; gap: 24px; padding: 18px; + min-height: 0; + overflow-y: auto; background: linear-gradient(180deg, var(--sidebar) 0%, var(--sidebar-strong) 100%); border-right: 1px solid var(--line); transition: width 180ms ease, padding 180ms ease, transform 180ms ease; @@ -146,6 +158,12 @@ input:focus { gap: 14px; } +.sidebar-header { + display: flex; + align-items: center; + gap: 12px; +} + .brand { display: flex; align-items: center; @@ -310,8 +328,9 @@ input:focus { .chat-shell { display: grid; - grid-template-rows: auto minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr); min-width: 0; + min-height: 0; padding: 0; } @@ -322,6 +341,8 @@ input:focus { gap: 16px; padding: 0 24px; min-height: 60px; + position: relative; + z-index: 30; border-bottom: 1px solid var(--line); background: #ffffff; } @@ -470,7 +491,7 @@ input:focus { display: grid; grid-template-rows: minmax(0, 1fr) auto; min-height: 0; - height: calc(100vh - 60px); + height: 100%; background: #ffffff; overflow: hidden; } @@ -562,8 +583,26 @@ input:focus { line-height: 1.7; } -.message-bubble p { +.message-bubble p, +.message-content p { margin: 0; + line-height: 1.8; +} + +.message-content { + display: grid; + gap: 14px; + line-height: 1.8; +} + +.message-content a { + color: var(--accent); + font-weight: 700; + text-decoration: none; +} + +.message-content a:hover { + text-decoration: underline; } .message-bubble.streaming { @@ -737,7 +776,7 @@ input:focus { } .workspace[data-sidebar-state="collapsed"] { - grid-template-columns: 88px minmax(0, 1fr); + grid-template-columns: 88px minmax(0, 1fr) 340px; } .workspace[data-sidebar-state="collapsed"] .brand-text, @@ -760,12 +799,20 @@ input:focus { padding-right: 12px; } +.workspace[data-sidebar-state="collapsed"] .sidebar-header { + justify-content: center; +} + +.workspace[data-sidebar-state="collapsed"] .brand { + display: none; +} + .summary-panel { display: grid; grid-template-rows: auto auto minmax(0, 1fr); gap: 14px; min-width: 0; - max-height: 100vh; + max-height: 100%; padding: 16px; overflow: auto; border-left: 1px solid var(--line); @@ -915,6 +962,7 @@ input:focus { width: 100%; border-collapse: collapse; font-size: 13px; + line-height: 1.6; } .message-bubble th, @@ -926,15 +974,26 @@ input:focus { } @media (max-width: 980px) { + .app-body { + overflow: auto; + } + + .app-shell { + grid-template-rows: 60px auto; + height: auto; + min-height: 100vh; + } + .workspace { grid-template-columns: minmax(0, 1fr); - min-height: 100vh; - overflow: auto; + height: auto; + min-height: 0; + overflow: visible; } .sidebar { position: fixed; - inset: 0 auto 0 0; + inset: 60px auto 0 0; width: 280px; z-index: 20; box-shadow: var(--shadow); @@ -953,10 +1012,6 @@ input:focus { display: inline-flex; } - .sidebar-toggle { - display: none; - } - .topbar, .chat-scroll, .composer-wrap { @@ -965,16 +1020,22 @@ input:focus { } .topbar { - align-items: flex-start; - flex-direction: column; - min-height: auto; - padding-top: 12px; + align-items: center; + flex-direction: row; + min-height: 60px; + padding-top: 0; padding-bottom: 0; } + .topbar-left { + flex: 1 1 auto; + overflow: hidden; + } + .topbar-right { - width: 100%; - justify-content: space-between; + flex: 0 0 auto; + width: auto; + justify-content: flex-end; } .conversation-header { @@ -982,7 +1043,7 @@ input:focus { } .chat-stage { - min-height: calc(100vh - 88px); + min-height: calc(100vh - 60px); height: auto; } @@ -1050,7 +1111,8 @@ input:focus { } .chat-stage { - height: calc(100vh - 126px); + min-height: calc(100vh - 60px); + height: auto; } .chat-scroll { diff --git a/static/js/app.js b/static/js/app.js index e8d2155..a87c4b2 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -153,11 +153,105 @@ return escapeHtml(text).replace(/\n/g, "
"); } + function renderInlineMarkdown(text) { + return escapeHtml(text || "").replace(/\[([^\]]+)\]\(([^)]+)\)/g, function (_match, label, href) { + var safeHref = escapeHtml(href); + var safeLabel = escapeHtml(label); + if (!/^\/[^/\\]/.test(href) && !/^https?:\/\//.test(href)) { + return safeLabel; + } + return '' + safeLabel + ""; + }); + } + + function renderMarkdownTable(lines, startIndex) { + var header = lines[startIndex].trim(); + var separator = lines[startIndex + 1] ? lines[startIndex + 1].trim() : ""; + if (header.charAt(0) !== "|" || separator.indexOf("---") === -1) { + return null; + } + + function cells(line) { + return line + .trim() + .replace(/^\|/, "") + .replace(/\|$/, "") + .split("|") + .map(function (cell) { + return cell.trim(); + }); + } + + var html = ""; + cells(header).forEach(function (cell) { + html += ""; + }); + html += ""; + + var index = startIndex + 2; + while (index < lines.length && lines[index].trim().charAt(0) === "|") { + html += ""; + cells(lines[index]).forEach(function (cell) { + html += ""; + }); + html += ""; + index += 1; + } + html += "
" + renderInlineMarkdown(cell) + "
" + renderInlineMarkdown(cell || "-") + "
"; + return { html: html, nextIndex: index }; + } + + function renderBasicMarkdown(text) { + var lines = (text || "").split(/\r?\n/); + var html = ""; + var paragraph = []; + var index = 0; + + function flushParagraph() { + if (!paragraph.length) { + return; + } + html += "

" + renderInlineMarkdown(paragraph.join("\n")).replace(/\n/g, "
") + "

"; + paragraph = []; + } + + while (index < lines.length) { + var line = lines[index]; + var table = renderMarkdownTable(lines, index); + if (table) { + flushParagraph(); + html += table.html; + index = table.nextIndex; + continue; + } + if (!line.trim()) { + flushParagraph(); + } else { + paragraph.push(line); + } + index += 1; + } + flushParagraph(); + return html; + } + function renderAssistantContent(text) { if (window.marked && window.DOMPurify) { return window.DOMPurify.sanitize(window.marked.parse(text || "")); } - return nl2br(text || ""); + return renderBasicMarkdown(text || ""); + } + + function renderExistingAssistantMessages() { + document.querySelectorAll(".message.assistant .message-bubble").forEach(function (bubble) { + var target = bubble.querySelector(".markdown-content"); + var raw = bubble.querySelector(".message-raw"); + if (!target || !raw || target.dataset.rendered === "true") { + return; + } + target.innerHTML = renderAssistantContent(raw.content ? raw.content.textContent : raw.textContent); + target.dataset.rendered = "true"; + }); } function scrollChatToBottom() { @@ -181,7 +275,10 @@ var bubble = document.createElement("div"); bubble.className = "message-bubble"; - var text = document.createElement("p"); + var text = document.createElement(role === "assistant" ? "div" : "p"); + if (role === "assistant") { + text.className = "message-content markdown-content"; + } text.innerHTML = role === "assistant" ? renderAssistantContent(content) : nl2br(content); bubble.appendChild(text); @@ -549,6 +646,7 @@ syncNodeRailVisibility(); bindNodeAnchorClicks(); + renderExistingAssistantMessages(); if (chatScroll) { chatScroll.addEventListener("scroll", setActiveNode, { passive: true }); diff --git a/templates/home.html b/templates/home.html index 87f9ccd..e88a4d7 100644 --- a/templates/home.html +++ b/templates/home.html @@ -5,18 +5,56 @@ {% block body_class %}app-body{% endblock %} {% block content %} -
+
+
+
+
+ + + + +
+
+ +
+
+ + +
+
+
+ +
-
-
- -
- - - - -
-
- -
-
- - -
-
-
-
@@ -114,7 +113,12 @@ {% if message.role == "assistant" %}AI{% else %}{{ request.user.username|slice:":1"|upper }}{% endif %}
-

{{ message.content|linebreaksbr }}

+ {% if message.role == "assistant" %} +
+ + {% else %} +

{{ message.content|linebreaksbr }}

+ {% endif %}
{% endfor %} @@ -230,6 +234,7 @@
+
{% endblock %} diff --git a/tests/test_file_summary_e2e.py b/tests/test_file_summary_e2e.py index 4275e4b..99baddb 100644 --- a/tests/test_file_summary_e2e.py +++ b/tests/test_file_summary_e2e.py @@ -2,7 +2,7 @@ from pathlib import Path import pytest -from review_agent.models import Conversation +from review_agent.models import Conversation, Message pytestmark = pytest.mark.django_db @@ -26,7 +26,18 @@ def test_file_summary_panel_desktop_and_mobile_with_playwright(live_server, djan pytest.skip("No Chrome or Edge executable available for Playwright E2E.") user = django_user_model.objects.create_user(username="e2e_user", password="e2e-pass-123") - Conversation.objects.create(user=user, title="E2E 会话") + conversation = Conversation.objects.create(user=user, title="E2E 会话") + Message.objects.create( + conversation=conversation, + role=Message.Role.ASSISTANT, + content=( + "文件目录与页数汇总已完成。\n\n" + "| 序号 | 文件名 | 页数 | 状态 |\n" + "| --- | --- | --- | --- |\n" + "| 1 | a.pdf | 4 | success |\n\n" + "[下载 Markdown 报告](/api/review-agent/file-summary/exports/1/download/)" + ), + ) with playwright_api.sync_playwright() as p: browser = p.chromium.launch(headless=True, executable_path=executable_path) @@ -40,6 +51,8 @@ def test_file_summary_panel_desktop_and_mobile_with_playwright(live_server, djan playwright_api.expect(page.locator("#summaryPanel")).to_be_visible() playwright_api.expect(page.locator("#uploadDropzone")).to_be_visible() playwright_api.expect(page.locator("#workflowCardList")).to_be_visible() + playwright_api.expect(page.locator(".message.assistant table")).to_be_visible() + playwright_api.expect(page.locator('.message.assistant a[href="/api/review-agent/file-summary/exports/1/download/"]')).to_be_visible() page.set_viewport_size({"width": 390, "height": 844}) playwright_api.expect(page.locator("#summaryPanel")).to_be_visible() diff --git a/tests/test_file_summary_frontend.py b/tests/test_file_summary_frontend.py index 71d0318..a638aa8 100644 --- a/tests/test_file_summary_frontend.py +++ b/tests/test_file_summary_frontend.py @@ -1,7 +1,7 @@ import pytest from django.urls import reverse -from review_agent.models import Conversation +from review_agent.models import Conversation, Message pytestmark = pytest.mark.django_db @@ -10,6 +10,11 @@ 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="会话") + Message.objects.create( + conversation=conversation, + role=Message.Role.ASSISTANT, + content="| 序号 | 文件名 |\n| --- | --- |\n| 1 | a.pdf |\n\n[下载](/api/review-agent/file-summary/exports/1/download/)", + ) client.force_login(user) response = client.get(f"{reverse('home')}?conversation={conversation.pk}") @@ -19,4 +24,6 @@ def test_workspace_renders_summary_panel(client, django_user_model): assert 'id="summaryPanel"' in content assert 'id="uploadDropzone"' in content assert 'id="workflowCardList"' in content + assert 'class="message-content markdown-content"' in content + assert 'class="message-raw"' in content assert "自动汇总文件目录与页数" in content