Compare commits
3 Commits
84e045f5ab
...
933799a882
| Author | SHA1 | Date | |
|---|---|---|---|
| 933799a882 | |||
| a0e5e4c301 | |||
| 7ab5aad938 |
19
.env
Normal file
19
.env
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
DJANGO_SECRET_KEY=replace-with-a-local-secret-key
|
||||||
|
DJANGO_DEBUG=true
|
||||||
|
DJANGO_ALLOWED_HOSTS=*
|
||||||
|
|
||||||
|
# SiliconFlow OpenAI-compatible API
|
||||||
|
LLM_PROVIDER=openai_compatible
|
||||||
|
LLM_API_KEY=sk-pgvkjondmmrlyxmrfhotgpuirgbtgzrpjpweorhwruflxmxw
|
||||||
|
LLM_BASE_URL=https://api.siliconflow.cn/v1
|
||||||
|
LLM_MODEL=Qwen/Qwen2.5-7B-Instruct
|
||||||
|
|
||||||
|
# SiliconFlow embedding model for RAG
|
||||||
|
EMBEDDING_API_KEY=sk-pgvkjondmmrlyxmrfhotgpuirgbtgzrpjpweorhwruflxmxw
|
||||||
|
EMBEDDING_BASE_URL=https://api.siliconflow.cn/v1
|
||||||
|
EMBEDDING_MODEL=BAAI/bge-m3
|
||||||
|
|
||||||
|
SCENARIO_CONFIG_DIR=configs
|
||||||
|
GOVERNANCE_CONFIG_PATH=configs/governance.yaml
|
||||||
|
UPLOAD_ROOT=data/uploads
|
||||||
|
CHROMA_PATH=data/chroma
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,3 @@
|
|||||||
.env
|
|
||||||
.venv/
|
.venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ from django.contrib import admin
|
|||||||
from django.contrib.auth.views import LoginView, LogoutView, PasswordChangeView
|
from django.contrib.auth.views import LoginView, LogoutView, PasswordChangeView
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from review_agent.views import workspace
|
from review_agent.views import stream_chat, workspace
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", workspace, name="home"),
|
path("", workspace, name="home"),
|
||||||
|
path("chat/stream/", stream_chat, name="chat_stream"),
|
||||||
path(
|
path(
|
||||||
"login/",
|
"login/",
|
||||||
LoginView.as_view(
|
LoginView.as_view(
|
||||||
|
|||||||
BIN
docs/原始材料/【模拟题二】试剂盒临床注册文件准备与审核Agent.docx
Normal file
BIN
docs/原始材料/【模拟题二】试剂盒临床注册文件准备与审核Agent.docx
Normal file
Binary file not shown.
@@ -0,0 +1,62 @@
|
|||||||
|
**试剂盒临床注册文件准备与审核智能体搭建**
|
||||||
|
|
||||||
|
**一、背景**
|
||||||
|
|
||||||
|
卡尤迪生物研发团队在推进NMPA(国家药品监督管理局)注册申报时,需准备大量合规性文件,包括产品技术要求、说明书、检测报告、临床评估资料等。
|
||||||
|
|
||||||
|
公司计划组建AI Agent新团队,目标为"试剂盒NMPA注册文件准备与审核智能体",实现文件目录自动汇总、法规完整性检查、关键信息自动提取与填写、缺失文件预警、文档一致性核查,提升注册效率并降低合规风险。
|
||||||
|
|
||||||
|
**二、任务目标**
|
||||||
|
|
||||||
|
请你作为 AI Agent 工程师候选人,设计并实现(或详细描述)一个智能体,能够:
|
||||||
|
|
||||||
|
1. 自动汇总注册申报文件夹中的所有文件及页数
|
||||||
|
2. 对照 NMPA 法规要求核查文件完整性并预警缺失
|
||||||
|
3. 提取产品关键信息并自动填写至申报文件
|
||||||
|
4. 核查文档结构与信息一致性
|
||||||
|
5. 输出合规风险预警与处理建议
|
||||||
|
|
||||||
|
**三、具体要求如下**
|
||||||
|
|
||||||
|
**1. 自动汇总文件夹文件目录与页数。**
|
||||||
|
|
||||||
|
文件目录参考附件。
|
||||||
|
|
||||||
|
**2. 按照NMPA现行法规要求核查文件完整性。**
|
||||||
|
|
||||||
|
- 对照NMPA法规检查所需文件是否齐全(如注册申报资料基本要求、产品技术要求、注册检验报告等)
|
||||||
|
- 自动识别缺失文件并通知责任人
|
||||||
|
- 参考法规来源网站:
|
||||||
|
|
||||||
|
<https://www.cmde.org.cn/xwdt/zxyw/20210930163300622.html、>
|
||||||
|
|
||||||
|
<https://www.nmpa.gov.cn/>
|
||||||
|
|
||||||
|
**3. 从产品文件中提取关键信息并自动填写至目标文件。**
|
||||||
|
|
||||||
|
- 自动提取:产品名称、检测靶标、适用范围、储存条件、性能指标等核心信息
|
||||||
|
- 将提取信息自动填入注册申报表格或对照清单
|
||||||
|
|
||||||
|
**4. 核查文档结构、信息一致性与章节规范性。**
|
||||||
|
|
||||||
|
- 检测章节是否完整(如分析灵敏度、特异性、重复性等必检项目)
|
||||||
|
- 不同文档间同一信息是否一致(如产品名称、规格型号等)
|
||||||
|
- 格式是否符合NMPA要求的规范章节结构
|
||||||
|
|
||||||
|
**5. 提供合规风险预警与处理建议。**
|
||||||
|
|
||||||
|
例如:"文件X:缺少临床评估报告,请补充"或"产品Y:说明书与检测报告中的适用范围描述不一致,请核对"
|
||||||
|
|
||||||
|
**附加要求【在复试时陈述,需结合 Demo 演示】**
|
||||||
|
|
||||||
|
**1. 架构搭建思路(基于 Demo 版)**
|
||||||
|
|
||||||
|
- 展示Demo运行结果(文件目录汇总表、法规完整性报告、信息提取对照表、异常预警列表)
|
||||||
|
- 结合你实现的Demo,说明智能体的整体工作流(如:文件扫描 → 目录汇总 → 法规匹配 → 信息提取 → 一致性核查 → 风险预警)
|
||||||
|
- 展示Demo中实际调用的关键工具/库(如 pdfplumber / PyMuPDF、正则表达式、规则引擎、向量检索等),并分析选用理由
|
||||||
|
- 简述Demo中如何体现文件完整性检测、信息一致性核查、法规条款匹配等难点规则的处理
|
||||||
|
|
||||||
|
**2. 基于 Demo 版的迭代规划**
|
||||||
|
|
||||||
|
- 说明当前Demo实现了哪些核心功能,哪些是模拟数据/简化逻辑
|
||||||
|
- 下一版本最想增加的一个功能以及需要投入的技术资源(如 NMPA 官网 API 对接、文件版本管理、多语言支持等),并说明为什么优先做它
|
||||||
@@ -53,6 +53,57 @@ def generate_reply(conversation, user_message: str) -> str:
|
|||||||
raise LLMRequestError("模型接口返回格式不符合预期。") from exc
|
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]]:
|
def build_messages(conversation, latest_user_message: str) -> list[dict[str, str]]:
|
||||||
"""Builds system and conversation history messages for the provider call."""
|
"""Builds system and conversation history messages for the provider call."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
from django.db.models import Q, QuerySet
|
from django.db.models import Q, QuerySet
|
||||||
from django.utils import timezone
|
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
|
from .models import Conversation, Message
|
||||||
|
|
||||||
|
|
||||||
@@ -81,6 +83,47 @@ def send_message(conversation: Conversation, content: str) -> tuple[Message, Mes
|
|||||||
return user_message, assistant_message
|
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:
|
def build_conversation_title(content: str) -> str:
|
||||||
"""Creates a concise title from the first user message."""
|
"""Creates a concise title from the first user message."""
|
||||||
|
|
||||||
@@ -88,3 +131,9 @@ def build_conversation_title(content: str) -> str:
|
|||||||
if not normalized:
|
if not normalized:
|
||||||
return "新对话"
|
return "新对话"
|
||||||
return normalized[:24]
|
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"
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
from django.contrib.auth.decorators import login_required
|
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.shortcuts import redirect, render
|
||||||
from django.views.decorators.http import require_http_methods
|
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
|
@login_required
|
||||||
@@ -45,3 +51,25 @@ def workspace(request: HttpRequest) -> HttpResponse:
|
|||||||
"messages": current.messages.all() if current else [],
|
"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
|
||||||
|
|||||||
@@ -470,14 +470,47 @@ input:focus {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: minmax(0, 1fr) auto;
|
grid-template-rows: minmax(0, 1fr) auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
height: calc(100vh - 60px);
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-scroll {
|
.chat-scroll-wrap {
|
||||||
|
position: relative;
|
||||||
min-height: 0;
|
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;
|
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,
|
.conversation-header,
|
||||||
@@ -533,10 +566,92 @@ input:focus {
|
|||||||
margin: 0;
|
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 {
|
.user-mark {
|
||||||
background: #dbe7ff;
|
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 {
|
.composer-wrap {
|
||||||
padding: 18px 24px 24px;
|
padding: 18px 24px 24px;
|
||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
@@ -604,6 +719,11 @@ input:focus {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.send-button:disabled {
|
||||||
|
background: #a8bee8;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
.sr-only {
|
.sr-only {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
@@ -693,6 +813,18 @@ input:focus {
|
|||||||
.conversation-header {
|
.conversation-header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-stage {
|
||||||
|
height: calc(100vh - 88px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-scroll {
|
||||||
|
padding-right: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-rail {
|
||||||
|
right: 14px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
@@ -738,4 +870,37 @@ input:focus {
|
|||||||
.send-button {
|
.send-button {
|
||||||
width: 100%;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
346
static/js/app.js
346
static/js/app.js
@@ -4,6 +4,14 @@
|
|||||||
var mobileSidebarToggle = document.getElementById("mobileSidebarToggle");
|
var mobileSidebarToggle = document.getElementById("mobileSidebarToggle");
|
||||||
var userMenu = document.getElementById("userMenu");
|
var userMenu = document.getElementById("userMenu");
|
||||||
var userMenuTrigger = document.getElementById("userMenuTrigger");
|
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) {
|
if (!workspace) {
|
||||||
return;
|
return;
|
||||||
@@ -32,6 +40,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function refreshNodeAnchors() {
|
||||||
|
nodeAnchors = Array.prototype.slice.call(document.querySelectorAll(".node-anchor"));
|
||||||
|
}
|
||||||
|
|
||||||
if (sidebarToggle) {
|
if (sidebarToggle) {
|
||||||
sidebarToggle.addEventListener("click", toggleSidebar);
|
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, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function nl2br(text) {
|
||||||
|
return escapeHtml(text).replace(/\n/g, "<br>");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 =
|
||||||
|
'<span class="history-title">' +
|
||||||
|
escapeHtml(encodedTitle) +
|
||||||
|
'</span><span class="history-meta">' +
|
||||||
|
meta +
|
||||||
|
"</span>";
|
||||||
|
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 =
|
||||||
|
'<div><p class="eyebrow">审核智能体</p><h1>' +
|
||||||
|
escapeHtml(title) +
|
||||||
|
'</h1></div><span class="conversation-meta">正在生成回复</span>';
|
||||||
|
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);
|
window.addEventListener("resize", syncSidebarState);
|
||||||
syncSidebarState();
|
syncSidebarState();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -92,10 +92,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="chat-stage">
|
<section class="chat-stage" data-stream-url="{% url 'chat_stream' %}">
|
||||||
<div class="chat-scroll">
|
<div class="chat-scroll-wrap">
|
||||||
|
<div class="chat-scroll" id="chatScroll">
|
||||||
{% if current_conversation %}
|
{% if current_conversation %}
|
||||||
<div class="conversation-header">
|
<div class="conversation-header" id="conversation-top" data-node-label="会话开始">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">审核智能体</p>
|
<p class="eyebrow">审核智能体</p>
|
||||||
<h1>{{ current_conversation.title|default:"新对话" }}</h1>
|
<h1>{{ current_conversation.title|default:"新对话" }}</h1>
|
||||||
@@ -104,7 +105,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% for message in messages %}
|
{% for message in messages %}
|
||||||
<article class="message {{ message.role }}">
|
<article
|
||||||
|
class="message {{ message.role }}"
|
||||||
|
id="message-{{ message.pk }}"
|
||||||
|
data-node-label="{% if message.role == 'assistant' %}AI{% else %}用户{% endif %} {{ forloop.counter }}"
|
||||||
|
>
|
||||||
<div class="message-avatar{% if message.role == 'user' %} user-mark{% endif %}">
|
<div class="message-avatar{% if message.role == 'user' %} user-mark{% endif %}">
|
||||||
{% if message.role == "assistant" %}AI{% else %}{{ request.user.username|slice:":1"|upper }}{% endif %}
|
{% if message.role == "assistant" %}AI{% else %}{{ request.user.username|slice:":1"|upper }}{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -121,14 +126,30 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
<nav class="node-rail{% if not current_conversation %} hidden{% endif %}" id="nodeRail" aria-label="对话节点导航">
|
||||||
|
<div class="node-rail-line"></div>
|
||||||
|
{% if current_conversation %}
|
||||||
|
{% for message in messages %}
|
||||||
|
{% if message.role == "user" %}
|
||||||
|
<a
|
||||||
|
class="node-anchor{% if forloop.last %} latest{% endif %}"
|
||||||
|
href="#message-{{ message.pk }}"
|
||||||
|
data-target="message-{{ message.pk }}"
|
||||||
|
title="用户 {{ forloop.counter }}"
|
||||||
|
>
|
||||||
|
<span class="node-dot"></span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="composer-wrap">
|
<div class="composer-wrap">
|
||||||
<form class="composer" action="/" method="post">
|
<form class="composer" action="/" method="post" id="chatComposer">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="action" value="send_message">
|
<input type="hidden" name="action" value="send_message">
|
||||||
{% if current_conversation %}
|
<input type="hidden" name="conversation_id" id="conversationIdInput" value="{% if current_conversation %}{{ current_conversation.pk }}{% endif %}">
|
||||||
<input type="hidden" name="conversation_id" value="{{ current_conversation.pk }}">
|
|
||||||
{% endif %}
|
|
||||||
<label class="sr-only" for="prompt">输入消息</label>
|
<label class="sr-only" for="prompt">输入消息</label>
|
||||||
<textarea id="prompt" name="prompt" rows="1" placeholder="输入审核问题、法规条款、说明书疑点或上传需求"></textarea>
|
<textarea id="prompt" name="prompt" rows="1" placeholder="输入审核问题、法规条款、说明书疑点或上传需求"></textarea>
|
||||||
<div class="composer-actions">
|
<div class="composer-actions">
|
||||||
@@ -137,7 +158,7 @@
|
|||||||
<span class="tool-chip passive-chip">说明书审核</span>
|
<span class="tool-chip passive-chip">说明书审核</span>
|
||||||
<span class="tool-chip passive-chip">风险识别</span>
|
<span class="tool-chip passive-chip">风险识别</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="send-button" type="submit">发送</button>
|
<button class="send-button" type="submit" id="sendButton">发送</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user