{% if message.role == "assistant" %}AI{% else %}{{ request.user.username|slice:":1"|upper }}{% endif %}
@@ -121,14 +126,30 @@
diff --git a/config/urls.py b/config/urls.py
index a80b0fb..ec39f6a 100644
--- a/config/urls.py
+++ b/config/urls.py
@@ -2,10 +2,11 @@ from django.contrib import admin
from django.contrib.auth.views import LoginView, LogoutView, PasswordChangeView
from django.urls import path
-from review_agent.views import workspace
+from review_agent.views import stream_chat, workspace
urlpatterns = [
path("", workspace, name="home"),
+ path("chat/stream/", stream_chat, name="chat_stream"),
path(
"login/",
LoginView.as_view(
diff --git a/docs/原始材料/【模拟题二】试剂盒临床注册文件准备与审核Agent.docx b/docs/原始材料/【模拟题二】试剂盒临床注册文件准备与审核Agent.docx
new file mode 100644
index 0000000..6c81cb5
Binary files /dev/null and b/docs/原始材料/【模拟题二】试剂盒临床注册文件准备与审核Agent.docx differ
diff --git a/review_agent/llm.py b/review_agent/llm.py
index 293fa14..6680f84 100644
--- a/review_agent/llm.py
+++ b/review_agent/llm.py
@@ -53,6 +53,57 @@ def generate_reply(conversation, user_message: str) -> str:
raise LLMRequestError("模型接口返回格式不符合预期。") from exc
+def stream_reply(conversation, user_message: str):
+ """Streams incremental assistant text from the SiliconFlow chat endpoint."""
+
+ if not settings.LLM_API_KEY:
+ raise LLMConfigurationError("缺少 LLM_API_KEY 配置。")
+ if not settings.LLM_MODEL:
+ raise LLMConfigurationError("缺少 LLM_MODEL 配置。")
+
+ payload = {
+ "model": settings.LLM_MODEL,
+ "messages": build_messages(conversation, user_message),
+ "temperature": 0.3,
+ "stream": True,
+ }
+ body = json.dumps(payload).encode("utf-8")
+ endpoint = f"{settings.LLM_BASE_URL.rstrip('/')}/chat/completions"
+
+ http_request = request.Request(
+ endpoint,
+ data=body,
+ headers={
+ "Authorization": f"Bearer {settings.LLM_API_KEY}",
+ "Content-Type": "application/json",
+ },
+ method="POST",
+ )
+
+ try:
+ with request.urlopen(http_request, timeout=300) as response:
+ for raw_line in response:
+ line = raw_line.decode("utf-8", errors="ignore").strip()
+ if not line or not line.startswith("data:"):
+ continue
+ data = line[5:].strip()
+ if data == "[DONE]":
+ break
+ payload = json.loads(data)
+ delta = (
+ payload.get("choices", [{}])[0]
+ .get("delta", {})
+ .get("content", "")
+ )
+ if delta:
+ yield delta
+ except error.HTTPError as exc:
+ details = exc.read().decode("utf-8", errors="ignore")
+ raise LLMRequestError(f"模型接口调用失败:HTTP {exc.code} {details}") from exc
+ except error.URLError as exc:
+ raise LLMRequestError(f"模型接口调用失败:{exc.reason}") from exc
+
+
def build_messages(conversation, latest_user_message: str) -> list[dict[str, str]]:
"""Builds system and conversation history messages for the provider call."""
diff --git a/review_agent/services.py b/review_agent/services.py
index d3b5494..43a3a2f 100644
--- a/review_agent/services.py
+++ b/review_agent/services.py
@@ -1,9 +1,11 @@
from __future__ import annotations
+import json
+
from django.db.models import Q, QuerySet
from django.utils import timezone
-from .llm import LLMConfigurationError, LLMRequestError, generate_reply
+from .llm import LLMConfigurationError, LLMRequestError, generate_reply, stream_reply
from .models import Conversation, Message
@@ -81,6 +83,47 @@ def send_message(conversation: Conversation, content: str) -> tuple[Message, Mes
return user_message, assistant_message
+def stream_message(conversation: Conversation, content: str):
+ """Yields SSE events while collecting a streamed assistant reply."""
+
+ user_message = append_user_message(conversation, content)
+ assistant_parts: list[str] = []
+
+ yield sse_event(
+ "meta",
+ {
+ "conversation_id": conversation.pk,
+ "title": conversation.title or build_conversation_title(content),
+ "user_message_id": user_message.pk,
+ "user_message": user_message.content,
+ },
+ )
+
+ try:
+ for chunk in stream_reply(conversation, content):
+ assistant_parts.append(chunk)
+ yield sse_event("chunk", {"delta": chunk})
+ except (LLMConfigurationError, LLMRequestError) as exc:
+ fallback = f"模型调用失败:{exc}"
+ assistant_parts = [fallback]
+ yield sse_event("error", {"message": fallback})
+
+ assistant_message = append_assistant_message(conversation, "".join(assistant_parts).strip())
+
+ if conversation.title.startswith("新对话"):
+ conversation.title = build_conversation_title(content)
+ conversation.save(update_fields=["title", "updated_at"])
+
+ yield sse_event(
+ "done",
+ {
+ "assistant_message_id": assistant_message.pk,
+ "conversation_id": conversation.pk,
+ "title": conversation.title,
+ },
+ )
+
+
def build_conversation_title(content: str) -> str:
"""Creates a concise title from the first user message."""
@@ -88,3 +131,9 @@ def build_conversation_title(content: str) -> str:
if not normalized:
return "新对话"
return normalized[:24]
+
+
+def sse_event(event_name: str, payload: dict[str, object]) -> str:
+ """Formats one server-sent event frame."""
+
+ return f"event: {event_name}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n"
diff --git a/review_agent/views.py b/review_agent/views.py
index 5432d28..e384834 100644
--- a/review_agent/views.py
+++ b/review_agent/views.py
@@ -1,9 +1,15 @@
from django.contrib.auth.decorators import login_required
-from django.http import HttpRequest, HttpResponse
+from django.http import HttpRequest, HttpResponse, JsonResponse, StreamingHttpResponse
from django.shortcuts import redirect, render
from django.views.decorators.http import require_http_methods
-from .services import create_conversation, get_conversation_for_user, list_conversations, send_message
+from .services import (
+ create_conversation,
+ get_conversation_for_user,
+ list_conversations,
+ send_message,
+ stream_message,
+)
@login_required
@@ -45,3 +51,25 @@ def workspace(request: HttpRequest) -> HttpResponse:
"messages": current.messages.all() if current else [],
},
)
+
+
+@login_required
+@require_http_methods(["POST"])
+def stream_chat(request: HttpRequest) -> HttpResponse:
+ """Streams one assistant reply so the UI can render incremental output."""
+
+ content = (request.POST.get("prompt") or "").strip()
+ if not content:
+ return JsonResponse({"error": "消息内容不能为空。"}, status=400)
+
+ conversation = get_conversation_for_user(request.user, request.POST.get("conversation_id"))
+ if not conversation:
+ conversation = create_conversation(request.user)
+
+ response = StreamingHttpResponse(
+ streaming_content=stream_message(conversation, content),
+ content_type="text/event-stream",
+ )
+ response["Cache-Control"] = "no-cache"
+ response["X-Accel-Buffering"] = "no"
+ return response
diff --git a/static/css/login.css b/static/css/login.css
index 5ebdc3a..7f4f93f 100644
--- a/static/css/login.css
+++ b/static/css/login.css
@@ -470,14 +470,47 @@ input:focus {
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
min-height: 0;
+ height: calc(100vh - 60px);
background: #ffffff;
overflow: hidden;
}
-.chat-scroll {
+.chat-scroll-wrap {
+ position: relative;
min-height: 0;
- padding: 32px min(6vw, 64px) 24px;
+ height: 100%;
+}
+
+.chat-scroll {
+ height: 100%;
+ min-height: 0;
+ padding: 32px 104px 24px min(6vw, 64px);
overflow-y: auto;
+ scroll-behavior: smooth;
+ scrollbar-width: thin;
+ scrollbar-color: #c4cfdd #f4f7fb;
+}
+
+.chat-scroll::-webkit-scrollbar {
+ width: 12px;
+}
+
+.chat-scroll::-webkit-scrollbar-track {
+ background: #f4f7fb;
+}
+
+.chat-scroll::-webkit-scrollbar-thumb {
+ border: 3px solid #f4f7fb;
+ border-radius: 999px;
+ background: #c4cfdd;
+}
+
+.chat-scroll::-webkit-scrollbar-thumb:hover {
+ background: #a9b8ca;
+}
+
+.hidden {
+ display: none;
}
.conversation-header,
@@ -533,10 +566,92 @@ input:focus {
margin: 0;
}
+.message-bubble.streaming {
+ position: relative;
+}
+
+.message-bubble.streaming::after {
+ content: "";
+ display: inline-block;
+ width: 8px;
+ height: 18px;
+ margin-left: 6px;
+ border-radius: 999px;
+ background: var(--accent);
+ vertical-align: middle;
+ animation: pulse-caret 0.9s ease-in-out infinite;
+}
+
+.message,
+.conversation-header {
+ scroll-margin-top: 20px;
+}
+
.user-mark {
background: #dbe7ff;
}
+.node-rail {
+ position: absolute;
+ top: 28px;
+ right: 28px;
+ bottom: 28px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 14px;
+ width: 28px;
+ pointer-events: none;
+}
+
+.node-rail-line {
+ position: absolute;
+ top: 10px;
+ bottom: 10px;
+ left: 50%;
+ width: 2px;
+ transform: translateX(-50%);
+ background: linear-gradient(180deg, #eef3fa 0%, #d6dfeb 100%);
+ border-radius: 999px;
+}
+
+.node-anchor {
+ position: relative;
+ z-index: 1;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 20px;
+ height: 20px;
+ border-radius: 999px;
+ text-decoration: none;
+ pointer-events: auto;
+}
+
+.node-dot {
+ width: 12px;
+ height: 12px;
+ border: 2px solid #d8e0eb;
+ border-radius: 999px;
+ background: #f5f8fc;
+ transition: transform 140ms ease, background 140ms ease, border-color 140ms ease;
+}
+
+.node-anchor:hover .node-dot {
+ transform: scale(1.08);
+ border-color: #9eb5df;
+}
+
+.node-anchor.active .node-dot {
+ border-color: var(--accent);
+ background: var(--accent);
+}
+
+.node-anchor.latest .node-dot {
+ background: #7f8da3;
+ border-color: #7f8da3;
+}
+
.composer-wrap {
padding: 18px 24px 24px;
border-top: 1px solid var(--line);
@@ -604,6 +719,11 @@ input:focus {
font-weight: 700;
}
+.send-button:disabled {
+ background: #a8bee8;
+ cursor: wait;
+}
+
.sr-only {
position: absolute;
width: 1px;
@@ -693,6 +813,18 @@ input:focus {
.conversation-header {
flex-direction: column;
}
+
+ .chat-stage {
+ height: calc(100vh - 88px);
+ }
+
+ .chat-scroll {
+ padding-right: 72px;
+ }
+
+ .node-rail {
+ right: 14px;
+ }
}
@media (max-width: 640px) {
@@ -738,4 +870,37 @@ input:focus {
.send-button {
width: 100%;
}
+
+ .chat-shell {
+ padding: 0;
+ }
+
+ .chat-stage {
+ height: calc(100vh - 126px);
+ }
+
+ .chat-scroll {
+ padding-right: 44px;
+ }
+
+ .node-rail {
+ right: 8px;
+ gap: 10px;
+ width: 20px;
+ }
+
+.node-dot {
+ width: 10px;
+ height: 10px;
+ }
+}
+
+@keyframes pulse-caret {
+ 0%,
+ 100% {
+ opacity: 0.25;
+ }
+ 50% {
+ opacity: 1;
+ }
}
diff --git a/static/js/app.js b/static/js/app.js
index cb5e451..1c3ee89 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -4,6 +4,14 @@
var mobileSidebarToggle = document.getElementById("mobileSidebarToggle");
var userMenu = document.getElementById("userMenu");
var userMenuTrigger = document.getElementById("userMenuTrigger");
+ var chatScroll = document.getElementById("chatScroll");
+ var nodeRail = document.getElementById("nodeRail");
+ var composer = document.getElementById("chatComposer");
+ var promptInput = document.getElementById("prompt");
+ var sendButton = document.getElementById("sendButton");
+ var conversationIdInput = document.getElementById("conversationIdInput");
+ var chatStage = document.querySelector(".chat-stage");
+ var nodeAnchors = [];
if (!workspace) {
return;
@@ -32,6 +40,10 @@
}
}
+ function refreshNodeAnchors() {
+ nodeAnchors = Array.prototype.slice.call(document.querySelectorAll(".node-anchor"));
+ }
+
if (sidebarToggle) {
sidebarToggle.addEventListener("click", toggleSidebar);
}
@@ -54,6 +66,340 @@
});
}
+ function setActiveNode() {
+ if (!chatScroll || !nodeAnchors.length) {
+ return;
+ }
+
+ var activeTarget = nodeAnchors[0].getAttribute("data-target");
+ var scrollTop = chatScroll.scrollTop;
+ var threshold = 80;
+
+ nodeAnchors.forEach(function (anchor) {
+ var targetId = anchor.getAttribute("data-target");
+ var target = document.getElementById(targetId);
+ if (!target) {
+ return;
+ }
+
+ if (target.offsetTop - threshold <= scrollTop) {
+ activeTarget = targetId;
+ }
+ });
+
+ nodeAnchors.forEach(function (anchor) {
+ anchor.classList.toggle("active", anchor.getAttribute("data-target") === activeTarget);
+ });
+ }
+
+ function bindNodeAnchorClicks() {
+ if (!chatScroll) {
+ return;
+ }
+ nodeAnchors.forEach(function (anchor) {
+ if (anchor.dataset.bound === "true") {
+ return;
+ }
+ anchor.dataset.bound = "true";
+ anchor.addEventListener("click", function (event) {
+ event.preventDefault();
+ var targetId = anchor.getAttribute("data-target");
+ var target = document.getElementById(targetId);
+ if (!target) {
+ return;
+ }
+ chatScroll.scrollTo({
+ top: Math.max(target.offsetTop - 20, 0),
+ behavior: "smooth",
+ });
+ });
+ });
+ }
+
+ function ensureNodeRailVisible() {
+ if (nodeRail) {
+ nodeRail.classList.remove("hidden");
+ }
+ }
+
+ function syncNodeRailVisibility() {
+ if (!nodeRail) {
+ return;
+ }
+ refreshNodeAnchors();
+ if (nodeAnchors.length) {
+ nodeRail.classList.remove("hidden");
+ } else {
+ nodeRail.classList.add("hidden");
+ }
+ }
+
+ function escapeHtml(text) {
+ return text
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/\"/g, """)
+ .replace(/'/g, "'");
+ }
+
+ function nl2br(text) {
+ return escapeHtml(text).replace(/\n/g, "
");
+ }
+
+ function scrollChatToBottom() {
+ if (chatScroll) {
+ chatScroll.scrollTop = chatScroll.scrollHeight;
+ }
+ }
+
+ function createMessage(role, content, messageId, label) {
+ var article = document.createElement("article");
+ article.className = "message " + role;
+ article.id = messageId;
+ if (label) {
+ article.setAttribute("data-node-label", label);
+ }
+
+ var avatar = document.createElement("div");
+ avatar.className = "message-avatar" + (role === "user" ? " user-mark" : "");
+ avatar.textContent = role === "assistant" ? "AI" : userMenuTrigger.querySelector(".avatar").textContent.trim();
+
+ var bubble = document.createElement("div");
+ bubble.className = "message-bubble";
+
+ var text = document.createElement("p");
+ text.innerHTML = nl2br(content);
+ bubble.appendChild(text);
+
+ article.appendChild(avatar);
+ article.appendChild(bubble);
+ chatScroll.appendChild(article);
+ return { article: article, bubble: bubble, text: text };
+ }
+
+ function appendNode(targetId, title, isLatest) {
+ if (!nodeRail) {
+ return;
+ }
+ ensureNodeRailVisible();
+ var anchor = document.createElement("a");
+ anchor.className = "node-anchor" + (isLatest ? " latest" : "");
+ anchor.href = "#" + targetId;
+ anchor.setAttribute("data-target", targetId);
+ anchor.title = title;
+
+ var dot = document.createElement("span");
+ dot.className = "node-dot";
+ anchor.appendChild(dot);
+ nodeRail.appendChild(anchor);
+ syncNodeRailVisibility();
+ bindNodeAnchorClicks();
+ setActiveNode();
+ }
+
+ function updateSidebarConversation(conversationId, title) {
+ if (!conversationId || !title) {
+ return;
+ }
+ var encodedTitle = title;
+ var existing = document.querySelector('.history-item[href*="conversation=' + conversationId + '"]');
+ var list = document.querySelector(".history-list");
+ var currentTime = new Date();
+ var month = String(currentTime.getMonth() + 1).padStart(2, "0");
+ var day = String(currentTime.getDate()).padStart(2, "0");
+ var hours = String(currentTime.getHours()).padStart(2, "0");
+ var minutes = String(currentTime.getMinutes()).padStart(2, "0");
+ var meta = month + "月" + day + "日 " + hours + ":" + minutes;
+
+ document.querySelectorAll(".history-item.active").forEach(function (item) {
+ item.classList.remove("active");
+ });
+
+ if (existing) {
+ existing.classList.add("active");
+ existing.querySelector(".history-title").textContent = encodedTitle;
+ existing.querySelector(".history-meta").textContent = meta;
+ if (list.firstElementChild !== existing) {
+ list.prepend(existing);
+ }
+ return;
+ }
+
+ if (!list) {
+ return;
+ }
+
+ var empty = list.querySelector(".history-empty");
+ if (empty) {
+ empty.remove();
+ }
+
+ var item = document.createElement("a");
+ item.className = "history-item active";
+ item.href = "/?conversation=" + conversationId;
+ item.innerHTML =
+ '' +
+ escapeHtml(encodedTitle) +
+ '";
+ list.prepend(item);
+ }
+
+ function setConversationTitle(title) {
+ if (!title) {
+ return;
+ }
+ var header = document.querySelector(".conversation-header h1");
+ var empty = document.querySelector(".empty-state");
+ if (empty) {
+ empty.remove();
+ var headerWrap = document.createElement("div");
+ headerWrap.className = "conversation-header";
+ headerWrap.id = "conversation-top";
+ headerWrap.setAttribute("data-node-label", "会话开始");
+ headerWrap.innerHTML =
+ '
审核智能体
审核智能体