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) + + '' + + meta + + ""; + 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 = + '

审核智能体

' + + escapeHtml(title) + + '

正在生成回复'; + chatScroll.prepend(headerWrap); + return; + } + if (header) { + header.textContent = title; + } + } + + async function streamChat(event) { + event.preventDefault(); + if (!composer || !promptInput || !sendButton || !chatStage) { + return; + } + + var prompt = promptInput.value.trim(); + if (!prompt || sendButton.disabled) { + return; + } + + sendButton.disabled = true; + sendButton.textContent = "生成中..."; + + var formData = new FormData(composer); + var csrfToken = formData.get("csrfmiddlewaretoken"); + var streamUrl = chatStage.getAttribute("data-stream-url"); + var tempUserId = "message-user-temp-" + Date.now(); + var tempAssistantId = "message-ai-temp-" + (Date.now() + 1); + var userLabel = "用户 " + (document.querySelectorAll(".message").length + 1); + + setConversationTitle((prompt || "").slice(0, 24)); + var userMessage = createMessage("user", prompt, tempUserId, userLabel); + var assistantMessage = createMessage("assistant", "", tempAssistantId, ""); + assistantMessage.bubble.classList.add("streaming"); + appendNode(userMessage.article.id, userLabel, false); + scrollChatToBottom(); + promptInput.value = ""; + + try { + var response = await fetch(streamUrl, { + method: "POST", + headers: { + "X-CSRFToken": csrfToken, + }, + body: formData, + }); + + if (!response.ok || !response.body) { + throw new Error("流式请求失败。"); + } + + var reader = response.body.getReader(); + var decoder = new TextDecoder("utf-8"); + var buffer = ""; + var assistantText = ""; + + while (true) { + var readResult = await reader.read(); + if (readResult.done) { + break; + } + + buffer += decoder.decode(readResult.value, { stream: true }); + var events = buffer.split("\n\n"); + buffer = events.pop(); + + events.forEach(function (frame) { + var eventName = ""; + var dataText = ""; + frame.split("\n").forEach(function (line) { + if (line.indexOf("event:") === 0) { + eventName = line.slice(6).trim(); + } + if (line.indexOf("data:") === 0) { + dataText += line.slice(5).trim(); + } + }); + + if (!eventName || !dataText) { + return; + } + + var payload = JSON.parse(dataText); + if (eventName === "meta") { + if (payload.conversation_id) { + conversationIdInput.value = payload.conversation_id; + window.history.replaceState({}, "", "/?conversation=" + payload.conversation_id); + } + if (payload.title) { + setConversationTitle(payload.title); + updateSidebarConversation(payload.conversation_id, payload.title); + } + } else if (eventName === "chunk") { + assistantText += payload.delta || ""; + assistantMessage.text.innerHTML = nl2br(assistantText); + scrollChatToBottom(); + } else if (eventName === "error") { + assistantText = payload.message || "模型调用失败。"; + assistantMessage.text.innerHTML = nl2br(assistantText); + } else if (eventName === "done") { + if (payload.assistant_message_id) { + assistantMessage.article.id = "message-" + payload.assistant_message_id; + } + if (payload.title) { + setConversationTitle(payload.title); + updateSidebarConversation(payload.conversation_id, payload.title); + } + } + }); + } + + assistantMessage.bubble.classList.remove("streaming"); + syncNodeRailVisibility(); + bindNodeAnchorClicks(); + setActiveNode(); + scrollChatToBottom(); + } catch (error) { + assistantMessage.bubble.classList.remove("streaming"); + assistantMessage.text.textContent = "请求失败,请稍后重试。"; + } finally { + sendButton.disabled = false; + sendButton.textContent = "发送"; + promptInput.focus(); + } + } + + syncNodeRailVisibility(); + bindNodeAnchorClicks(); + + if (chatScroll) { + chatScroll.addEventListener("scroll", setActiveNode, { passive: true }); + setActiveNode(); + } + + if (composer) { + composer.addEventListener("submit", streamChat); + } + window.addEventListener("resize", syncSidebarState); syncSidebarState(); })(); diff --git a/templates/home.html b/templates/home.html index 28a73fc..88c8c26 100644 --- a/templates/home.html +++ b/templates/home.html @@ -92,10 +92,11 @@ -
-
+
+
+
{% if current_conversation %} -
+

审核智能体

{{ current_conversation.title|default:"新对话" }}

@@ -104,7 +105,11 @@
{% for message in messages %} -
+
{% if message.role == "assistant" %}AI{% else %}{{ request.user.username|slice:":1"|upper }}{% endif %}
@@ -121,14 +126,30 @@
{% endif %}
+ +
-
+ {% csrf_token %} - {% if current_conversation %} - - {% endif %} +
@@ -137,7 +158,7 @@ 说明书审核 风险识别
- +