From 7ab5aad938ff6799853f1ecf9c8dc12a1e3c1e3f Mon Sep 17 00:00:00 2001 From: bruce Date: Fri, 5 Jun 2026 00:11:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=E6=94=AF=E6=8C=81=E6=B5=81?= =?UTF-8?q?=E5=BC=8F=E5=9B=9E=E5=A4=8D=E4=B8=8E=E7=94=A8=E6=88=B7=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E5=AF=BC=E8=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/urls.py | 3 +- ...拟题二】试剂盒临床注册文件准备与审核Agent.docx | Bin 0 -> 18601 bytes review_agent/llm.py | 51 +++ review_agent/services.py | 51 ++- review_agent/views.py | 32 +- static/css/login.css | 169 ++++++++- static/js/app.js | 346 ++++++++++++++++++ templates/home.html | 39 +- 8 files changed, 676 insertions(+), 15 deletions(-) create mode 100644 docs/原始材料/【模拟题二】试剂盒临床注册文件准备与审核Agent.docx 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 0000000000000000000000000000000000000000..6c81cb5e027a8afdaa115cb58a649a3af282a3e8 GIT binary patch literal 18601 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 OeE`2CG*;-3yZ;aR{W-w^ literal 0 HcmV?d00001 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 @@ -
-