feat(demo): 初始化审核智能体演示基线

This commit is contained in:
2026-06-04 23:42:37 +08:00
commit 84e045f5ab
23 changed files with 1571 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
.env
.venv/
__pycache__/
*.py[cod]
*.sqlite3
db.sqlite3
staticfiles/
media/
.pytest_cache/
.idea/

20
README.md Normal file
View File

@@ -0,0 +1,20 @@
# DEMO-AGENT V2
V2 是一个重置后的最小 Django 项目,仅保留基础配置和登录页面。
## 本地运行
```bash
python -m venv .venv
.venv\Scripts\activate
pip install -r requirements.txt
python manage.py migrate
python manage.py createsuperuser
python manage.py runserver
```
访问:
- 登录页http://127.0.0.1:8000/login/
- 首页http://127.0.0.1:8000/
- 管理后台http://127.0.0.1:8000/admin/

1
config/__init__.py Normal file
View File

@@ -0,0 +1 @@

8
config/asgi.py Normal file
View File

@@ -0,0 +1,8 @@
"""ASGI config for the project."""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
application = get_asgi_application()

104
config/settings.py Normal file
View File

@@ -0,0 +1,104 @@
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
def load_env_file(file_path: Path) -> None:
"""Loads a simple KEY=VALUE .env file into process env without extra deps."""
if not file_path.exists():
return
for raw_line in file_path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
os.environ.setdefault(key.strip(), value.strip())
load_env_file(BASE_DIR / ".env")
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "django-insecure-v2-local-development-key")
DEBUG = os.environ.get("DJANGO_DEBUG", "true").lower() == "true"
ALLOWED_HOSTS = [
host.strip()
for host in os.environ.get(
"DJANGO_ALLOWED_HOSTS",
"127.0.0.1,localhost,testserver",
).split(",")
if host.strip()
]
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"review_agent",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "config.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "config.wsgi.application"
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
LANGUAGE_CODE = "zh-hans"
TIME_ZONE = "Asia/Shanghai"
USE_I18N = True
USE_TZ = True
STATIC_URL = "static/"
STATICFILES_DIRS = [BASE_DIR / "static"]
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
LOGIN_URL = "login"
LOGIN_REDIRECT_URL = "home"
LOGOUT_REDIRECT_URL = "login"
LLM_API_KEY = os.environ.get("LLM_API_KEY", "")
LLM_BASE_URL = os.environ.get("LLM_BASE_URL", "https://api.siliconflow.cn/v1")
LLM_MODEL = os.environ.get("LLM_MODEL", "")

27
config/urls.py Normal file
View File

@@ -0,0 +1,27 @@
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
urlpatterns = [
path("", workspace, name="home"),
path(
"login/",
LoginView.as_view(
template_name="registration/login.html",
redirect_authenticated_user=True,
),
name="login",
),
path("logout/", LogoutView.as_view(), name="logout"),
path(
"password/change/",
PasswordChangeView.as_view(
template_name="registration/password_change.html",
success_url="/",
),
name="password_change",
),
path("admin/", admin.site.urls),
]

8
config/wsgi.py Normal file
View File

@@ -0,0 +1,8 @@
"""WSGI config for the project."""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
application = get_wsgi_application()

19
manage.py Normal file
View File

@@ -0,0 +1,19 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Is it installed and available on your PYTHONPATH?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()

1
requirements.txt Normal file
View File

@@ -0,0 +1 @@
Django>=5.0,<6.0

1
review_agent/__init__.py Normal file
View File

@@ -0,0 +1 @@

7
review_agent/apps.py Normal file
View File

@@ -0,0 +1,7 @@
from django.apps import AppConfig
class ReviewAgentConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "review_agent"
verbose_name = "审核智能体"

79
review_agent/llm.py Normal file
View File

@@ -0,0 +1,79 @@
import json
from urllib import error, request
from django.conf import settings
class LLMConfigurationError(RuntimeError):
"""Raised when the project has not been configured with a usable LLM provider."""
class LLMRequestError(RuntimeError):
"""Raised when the remote LLM provider call fails."""
def generate_reply(conversation, user_message: str) -> str:
"""Calls the SiliconFlow OpenAI-compatible chat endpoint and returns assistant text."""
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,
}
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=60) as response:
data = json.loads(response.read().decode("utf-8"))
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
try:
return data["choices"][0]["message"]["content"].strip()
except (KeyError, IndexError, TypeError) as exc:
raise LLMRequestError("模型接口返回格式不符合预期。") from exc
def build_messages(conversation, latest_user_message: str) -> list[dict[str, str]]:
"""Builds system and conversation history messages for the provider call."""
messages = [{"role": "system", "content": system_prompt()}]
for message in conversation.messages.all():
messages.append({"role": message.role, "content": message.content})
if not conversation.messages.filter(role="user", content=latest_user_message.strip()).exists():
messages.append({"role": "user", "content": latest_user_message.strip()})
return messages
def system_prompt() -> str:
"""Defines the initial assistant behavior for the review workspace."""
return (
"你是“审核智能体”,服务于体外诊断试剂临床注册文件准备与审核场景。"
"你的回答要专业、简洁、结构清楚,优先帮助用户完成法规核查、说明书审核、"
"风险识别、资料补充建议和审评思路梳理。"
"当信息不足时,明确指出缺失信息,并给出下一步建议。"
"除非用户明确要求英文,否则始终使用中文回答。"
)

View File

@@ -0,0 +1,78 @@
# Generated by Django 5.2.14 on 2026-06-04 15:27
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Conversation",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("title", models.CharField(blank=True, max_length=120)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="review_conversations",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["-updated_at", "-id"],
},
),
migrations.CreateModel(
name="Message",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"role",
models.CharField(
choices=[("user", "用户"), ("assistant", "助手")], max_length=20
),
),
("content", models.TextField()),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"conversation",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="messages",
to="review_agent.conversation",
),
),
],
options={
"ordering": ["created_at", "id"],
},
),
]

View File

@@ -0,0 +1 @@

44
review_agent/models.py Normal file
View File

@@ -0,0 +1,44 @@
from django.conf import settings
from django.db import models
class Conversation(models.Model):
"""Stores a user's review-agent conversation shell."""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="review_conversations",
)
title = models.CharField(max_length=120, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-updated_at", "-id"]
def __str__(self) -> str:
return self.title or f"对话 {self.pk}"
class Message(models.Model):
"""Stores one user or assistant message in a conversation."""
class Role(models.TextChoices):
USER = "user", "用户"
ASSISTANT = "assistant", "助手"
conversation = models.ForeignKey(
Conversation,
on_delete=models.CASCADE,
related_name="messages",
)
role = models.CharField(max_length=20, choices=Role.choices)
content = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["created_at", "id"]
def __str__(self) -> str:
return f"{self.get_role_display()} - {self.conversation_id}"

90
review_agent/services.py Normal file
View File

@@ -0,0 +1,90 @@
from __future__ import annotations
from django.db.models import Q, QuerySet
from django.utils import timezone
from .llm import LLMConfigurationError, LLMRequestError, generate_reply
from .models import Conversation, Message
def list_conversations(user, search: str = "") -> QuerySet[Conversation]:
"""Returns a user's conversations, optionally filtered by title or content."""
conversations = Conversation.objects.filter(user=user)
if not search:
return conversations
return conversations.filter(
Q(title__icontains=search) | Q(messages__content__icontains=search)
).distinct()
def get_conversation_for_user(user, conversation_id: int | None) -> Conversation | None:
"""Loads a conversation only when it belongs to the current user."""
if not conversation_id:
return None
return Conversation.objects.filter(user=user, pk=conversation_id).first()
def create_conversation(user) -> Conversation:
"""Creates an empty conversation that can immediately accept messages."""
now = timezone.localtime()
return Conversation.objects.create(
user=user,
title=f"新对话 {now.strftime('%m-%d %H:%M')}",
)
def append_user_message(conversation: Conversation, content: str) -> Message:
"""Appends a user message and updates the conversation title from the first prompt."""
message = Message.objects.create(
conversation=conversation,
role=Message.Role.USER,
content=content.strip(),
)
if conversation.messages.filter(role=Message.Role.USER).count() == 1:
conversation.title = build_conversation_title(content)
conversation.save(update_fields=["title", "updated_at"])
return message
def append_assistant_message(conversation: Conversation, content: str) -> Message:
"""Appends the deterministic assistant reply."""
return Message.objects.create(
conversation=conversation,
role=Message.Role.ASSISTANT,
content=content,
)
def send_message(conversation: Conversation, content: str) -> tuple[Message, Message]:
"""Stores one user message and one provider-backed assistant reply."""
user_message = append_user_message(conversation, content)
try:
reply_content = generate_reply(conversation, content)
except (LLMConfigurationError, LLMRequestError) as exc:
reply_content = f"模型调用失败:{exc}"
assistant_message = append_assistant_message(conversation, reply_content)
if conversation.title.startswith("新对话"):
conversation.title = build_conversation_title(content)
conversation.save(update_fields=["title", "updated_at"])
return user_message, assistant_message
def build_conversation_title(content: str) -> str:
"""Creates a concise title from the first user message."""
normalized = " ".join(content.strip().split())
if not normalized:
return "新对话"
return normalized[:24]

47
review_agent/views.py Normal file
View File

@@ -0,0 +1,47 @@
from django.contrib.auth.decorators import login_required
from django.http import HttpRequest, HttpResponse
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
@login_required
@require_http_methods(["GET", "POST"])
def workspace(request: HttpRequest) -> HttpResponse:
"""Renders the review-agent workspace and handles conversation actions."""
if request.method == "POST":
action = request.POST.get("action")
conversation = get_conversation_for_user(request.user, request.POST.get("conversation_id"))
if action == "new_conversation":
conversation = create_conversation(request.user)
return redirect(f"/?conversation={conversation.pk}")
if action == "send_message":
content = (request.POST.get("prompt") or "").strip()
if not conversation:
conversation = create_conversation(request.user)
if content:
send_message(conversation, content)
return redirect(f"/?conversation={conversation.pk}")
search = (request.GET.get("q") or "").strip()
conversations = list_conversations(request.user, search)
current = get_conversation_for_user(request.user, request.GET.get("conversation"))
if current is None and conversations.exists():
current = conversations.first()
return render(
request,
"home.html",
{
"page_title": "审核智能体",
"search_query": search,
"conversations": conversations,
"current_conversation": current,
"messages": current.messages.all() if current else [],
},
)

741
static/css/login.css Normal file
View File

@@ -0,0 +1,741 @@
:root {
color-scheme: light;
--bg: #f5f7fb;
--bg-strong: #edf2f7;
--panel: #ffffff;
--panel-soft: #f9fbff;
--panel-muted: #eef4ff;
--text: #1f2a37;
--muted: #7b8794;
--line: #e5eaf1;
--line-strong: #d7e0ea;
--accent: #3a72d8;
--accent-dark: #2f5fbb;
--sidebar: #f7faff;
--sidebar-strong: #eef4fb;
--danger-bg: #fff1f2;
--danger-text: #be123c;
--shadow: 0 12px 32px rgba(31, 42, 55, 0.08);
}
* {
box-sizing: border-box;
}
body {
min-height: 100vh;
margin: 0;
background: linear-gradient(180deg, #fbfcfe 0%, #f5f8fc 100%);
color: var(--text);
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
}
.login-page,
.shell {
min-height: 100vh;
display: grid;
place-items: center;
padding: 32px 16px;
}
.login-card,
.panel {
width: min(100%, 420px);
padding: 32px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
box-shadow: var(--shadow);
}
.eyebrow {
margin: 0 0 10px;
color: var(--accent);
font-size: 13px;
font-weight: 700;
letter-spacing: 0;
}
h1 {
margin: 0;
font-size: 28px;
line-height: 1.25;
}
.muted {
margin: 10px 0 24px;
color: var(--muted);
line-height: 1.7;
}
.alert {
margin-bottom: 18px;
padding: 12px 14px;
border-radius: 8px;
background: var(--danger-bg);
color: var(--danger-text);
font-size: 14px;
}
form {
display: grid;
gap: 12px;
}
label {
color: #344054;
font-size: 14px;
font-weight: 600;
}
input[type="text"],
input[type="password"] {
width: 100%;
height: 44px;
padding: 0 12px;
border: 1px solid var(--line);
border-radius: 8px;
color: var(--text);
font: inherit;
}
input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.14);
outline: none;
}
.button {
height: 44px;
margin-top: 8px;
border: 0;
border-radius: 8px;
background: var(--accent);
color: #ffffff;
cursor: pointer;
font: inherit;
font-weight: 700;
}
.button:hover {
background: var(--accent-dark);
}
.app-body {
overflow: hidden;
}
.workspace {
display: grid;
grid-template-columns: 296px minmax(0, 1fr);
min-height: 100vh;
}
.sidebar {
display: flex;
flex-direction: column;
gap: 24px;
padding: 18px;
background: linear-gradient(180deg, var(--sidebar) 0%, var(--sidebar-strong) 100%);
border-right: 1px solid var(--line);
transition: width 180ms ease, padding 180ms ease, transform 180ms ease;
}
.sidebar-top {
display: grid;
gap: 14px;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
color: var(--text);
}
.brand-copy {
display: grid;
gap: 2px;
}
.brand-mark,
.avatar,
.message-avatar {
display: inline-grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 11px;
background: #e4edff;
color: var(--accent);
font-size: 14px;
font-weight: 700;
}
.brand-text {
font-size: 14px;
font-weight: 700;
letter-spacing: 0.02em;
}
.brand-subtitle {
color: var(--muted);
font-size: 12px;
}
.icon-button {
display: inline-flex;
flex-direction: column;
justify-content: center;
gap: 5px;
width: 40px;
height: 40px;
border: 1px solid var(--line);
border-radius: 10px;
background: #ffffff;
cursor: pointer;
}
.icon-button span {
width: 16px;
height: 2px;
margin-left: 11px;
border-radius: 999px;
background: #6b7785;
}
.new-chat,
.ghost-button,
.send-button,
.tool-chip {
border: 0;
border-radius: 12px;
font: inherit;
cursor: pointer;
}
.new-chat {
height: 44px;
width: 100%;
background: var(--accent);
color: #ffffff;
font-weight: 700;
}
.search-form input {
width: 100%;
height: 38px;
padding: 0 14px;
border: 1px solid var(--line);
border-radius: 10px;
background: #ffffff;
color: var(--text);
font: inherit;
}
.sidebar-label {
margin: 0 0 10px;
color: var(--muted);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.history-list {
display: grid;
gap: 8px;
}
.history-item {
display: grid;
gap: 4px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 14px;
color: var(--text);
text-decoration: none;
background: rgba(255, 255, 255, 0.82);
}
.history-item.active,
.history-item:hover {
border-color: #cfdcf6;
background: #edf4ff;
}
.history-title {
font-size: 14px;
font-weight: 600;
}
.history-meta {
color: var(--muted);
font-size: 12px;
}
.history-empty {
padding: 16px 14px;
border: 1px dashed var(--line-strong);
border-radius: 14px;
background: #ffffff;
}
.history-empty p,
.history-empty span {
margin: 0;
}
.history-empty span {
display: block;
margin-top: 6px;
color: var(--muted);
font-size: 12px;
}
.user-card,
.user-menu-trigger,
.user-copy {
display: grid;
gap: 2px;
}
.user-copy strong {
font-size: 14px;
}
.user-copy span {
color: var(--muted);
font-size: 12px;
}
.chat-shell {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
min-width: 0;
padding: 0;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 0 24px;
min-height: 60px;
border-bottom: 1px solid var(--line);
background: #ffffff;
}
.topbar-left,
.topbar-right {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.mobile-toggle {
display: none;
}
.tabbar {
display: inline-flex;
align-items: center;
gap: 0;
height: 60px;
}
.tab {
height: 60px;
padding: 0 20px;
border: 0;
background: transparent;
color: var(--muted);
cursor: pointer;
font: inherit;
font-weight: 600;
border-bottom: 2px solid transparent;
}
.tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.avatar.large {
width: 40px;
height: 40px;
}
.user-menu {
position: relative;
}
.user-menu-trigger {
display: flex;
align-items: center;
gap: 12px;
min-height: 44px;
padding: 6px 12px;
border: 1px solid transparent;
border-radius: 12px;
background: #ffffff;
color: var(--text);
cursor: pointer;
font: inherit;
}
.user-menu-trigger:hover,
.user-menu.open .user-menu-trigger {
border-color: var(--line);
background: #f9fbff;
}
.caret {
width: 8px;
height: 8px;
margin-left: 4px;
border-right: 1.5px solid #7b8794;
border-bottom: 1.5px solid #7b8794;
transform: rotate(45deg);
}
.user-dropdown {
position: absolute;
top: calc(100% + 10px);
right: 0;
min-width: 184px;
padding: 8px;
border: 1px solid var(--line);
border-radius: 14px;
background: #ffffff;
box-shadow: var(--shadow);
opacity: 0;
visibility: hidden;
transform: translateY(-6px);
transition: opacity 140ms ease, transform 140ms ease, visibility 140ms ease;
}
.user-menu.open .user-dropdown {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.user-dropdown-section {
padding: 10px 12px 8px;
border-bottom: 1px solid var(--line);
}
.user-dropdown-label {
margin: 0 0 6px;
color: var(--muted);
font-size: 12px;
}
.user-dropdown-name {
display: block;
font-size: 14px;
}
.user-dropdown-link {
display: flex;
align-items: center;
width: 100%;
min-height: 40px;
padding: 0 12px;
border: 0;
border-radius: 10px;
background: transparent;
color: var(--text);
text-decoration: none;
text-align: left;
cursor: pointer;
font: inherit;
}
.user-dropdown-link:hover {
background: #f5f8fc;
}
.user-dropdown-form {
gap: 0;
}
.danger-link {
color: #c2410c;
}
.chat-stage {
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
min-height: 0;
background: #ffffff;
overflow: hidden;
}
.chat-scroll {
min-height: 0;
padding: 32px min(6vw, 64px) 24px;
overflow-y: auto;
}
.conversation-header,
.empty-state {
max-width: 920px;
margin: 0 auto 28px;
}
.conversation-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.conversation-header h1,
.empty-state h1 {
font-size: 32px;
}
.conversation-meta {
padding-top: 10px;
color: var(--muted);
font-size: 13px;
}
.message {
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
gap: 18px;
max-width: 860px;
margin: 0 auto 18px;
}
.message.user {
grid-template-columns: minmax(0, 1fr) 44px;
}
.message.user .message-bubble {
order: -1;
background: #ffffff;
}
.message-bubble {
padding: 18px 20px;
border: 1px solid var(--line);
border-radius: 18px;
background: #f8fbff;
line-height: 1.7;
}
.message-bubble p {
margin: 0;
}
.user-mark {
background: #dbe7ff;
}
.composer-wrap {
padding: 18px 24px 24px;
border-top: 1px solid var(--line);
background: #ffffff;
}
.composer {
max-width: 860px;
margin: 0 auto;
padding: 14px;
border: 1px solid var(--line);
border-radius: 24px;
background: #ffffff;
box-shadow: 0 8px 24px rgba(31, 42, 55, 0.06);
}
.composer textarea {
width: 100%;
min-height: 36px;
max-height: 180px;
resize: vertical;
border: 0;
background: transparent;
color: var(--text);
font: inherit;
line-height: 1.7;
outline: none;
}
.composer-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 12px;
}
.composer-tools {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.tool-chip {
height: 34px;
padding: 0 14px;
background: var(--panel-muted);
color: var(--accent);
}
.passive-chip {
display: inline-flex;
align-items: center;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
}
.send-button {
height: 40px;
min-width: 88px;
padding: 0 18px;
background: var(--accent);
color: #ffffff;
font-weight: 700;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.workspace[data-sidebar-state="collapsed"] {
grid-template-columns: 88px minmax(0, 1fr);
}
.workspace[data-sidebar-state="collapsed"] .brand-text,
.workspace[data-sidebar-state="collapsed"] .brand-subtitle,
.workspace[data-sidebar-state="collapsed"] .new-chat,
.workspace[data-sidebar-state="collapsed"] .search-form,
.workspace[data-sidebar-state="collapsed"] .sidebar-label,
.workspace[data-sidebar-state="collapsed"] .history-title,
.workspace[data-sidebar-state="collapsed"] .history-meta {
display: none;
}
.workspace[data-sidebar-state="collapsed"] .history-item {
place-items: center;
padding: 12px;
}
.workspace[data-sidebar-state="collapsed"] .sidebar {
padding-left: 12px;
padding-right: 12px;
}
@media (max-width: 980px) {
.workspace {
grid-template-columns: minmax(0, 1fr);
}
.sidebar {
position: fixed;
inset: 0 auto 0 0;
width: 280px;
z-index: 20;
box-shadow: var(--shadow);
transform: translateX(-100%);
}
.workspace[data-sidebar-state="open"] .sidebar {
transform: translateX(0);
}
.chat-shell {
padding: 10px;
}
.mobile-toggle {
display: inline-flex;
}
.sidebar-toggle {
display: none;
}
.topbar,
.chat-scroll,
.composer-wrap {
padding-left: 16px;
padding-right: 16px;
}
.topbar {
align-items: flex-start;
flex-direction: column;
min-height: auto;
padding-top: 12px;
padding-bottom: 0;
}
.topbar-right {
width: 100%;
justify-content: space-between;
}
.conversation-header {
flex-direction: column;
}
}
@media (max-width: 640px) {
.tabbar {
overflow-x: auto;
max-width: 100%;
}
.user-card {
min-width: 0;
}
.user-menu-trigger {
min-width: 0;
}
.user-copy strong,
.user-copy span {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.message,
.message.user {
grid-template-columns: 1fr;
}
.message-avatar {
display: none;
}
.composer-actions {
align-items: stretch;
flex-direction: column;
}
.composer-tools {
width: 100%;
}
.send-button {
width: 100%;
}
}

59
static/js/app.js Normal file
View File

@@ -0,0 +1,59 @@
(function () {
var workspace = document.querySelector(".workspace");
var sidebarToggle = document.getElementById("sidebarToggle");
var mobileSidebarToggle = document.getElementById("mobileSidebarToggle");
var userMenu = document.getElementById("userMenu");
var userMenuTrigger = document.getElementById("userMenuTrigger");
if (!workspace) {
return;
}
function isMobile() {
return window.matchMedia("(max-width: 980px)").matches;
}
function toggleSidebar() {
var state = workspace.getAttribute("data-sidebar-state");
if (isMobile()) {
workspace.setAttribute("data-sidebar-state", state === "open" ? "closed" : "open");
return;
}
workspace.setAttribute("data-sidebar-state", state === "collapsed" ? "open" : "collapsed");
}
function syncSidebarState() {
if (isMobile()) {
if (workspace.getAttribute("data-sidebar-state") === "collapsed") {
workspace.setAttribute("data-sidebar-state", "closed");
}
} else if (workspace.getAttribute("data-sidebar-state") === "closed") {
workspace.setAttribute("data-sidebar-state", "open");
}
}
if (sidebarToggle) {
sidebarToggle.addEventListener("click", toggleSidebar);
}
if (mobileSidebarToggle) {
mobileSidebarToggle.addEventListener("click", toggleSidebar);
}
if (userMenu && userMenuTrigger) {
userMenuTrigger.addEventListener("click", function () {
var isOpen = userMenu.classList.toggle("open");
userMenuTrigger.setAttribute("aria-expanded", isOpen ? "true" : "false");
});
document.addEventListener("click", function (event) {
if (!userMenu.contains(event.target)) {
userMenu.classList.remove("open");
userMenuTrigger.setAttribute("aria-expanded", "false");
}
});
}
window.addEventListener("resize", syncSidebarState);
syncSidebarState();
})();

14
templates/base.html Normal file
View File

@@ -0,0 +1,14 @@
{% load static %}
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}DEMO-AGENT V2{% endblock %}</title>
<link rel="stylesheet" href="{% static 'css/login.css' %}">
</head>
<body class="{% block body_class %}{% endblock %}">
{% block content %}{% endblock %}
{% block scripts %}{% endblock %}
</body>
</html>

151
templates/home.html Normal file
View File

@@ -0,0 +1,151 @@
{% extends "base.html" %}
{% load static %}
{% block title %}审核智能体 - DEMO-AGENT V2{% endblock %}
{% block body_class %}app-body{% endblock %}
{% block content %}
<main class="workspace" data-sidebar-state="open">
<aside class="sidebar" id="sidebar">
<div class="sidebar-top">
<button class="icon-button sidebar-toggle" type="button" id="sidebarToggle" aria-label="折叠侧边栏">
<span></span>
<span></span>
</button>
<div class="brand">
<span class="brand-mark"></span>
<div class="brand-copy">
<strong class="brand-text">审核智能体</strong>
<span class="brand-subtitle">临床注册文件审核工作台</span>
</div>
</div>
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="new_conversation">
<button class="new-chat" type="submit">+ 新对话</button>
</form>
<form class="search-form" method="get">
<label class="sr-only" for="conversationSearch">搜索会话</label>
<input id="conversationSearch" type="text" name="q" value="{{ search_query }}" placeholder="搜索会话...">
</form>
</div>
<div class="sidebar-group">
<p class="sidebar-label">对话记录</p>
<nav class="history-list" aria-label="对话历史">
{% for conversation in conversations %}
<a
class="history-item{% if current_conversation and current_conversation.pk == conversation.pk %} active{% endif %}"
href="/?conversation={{ conversation.pk }}{% if search_query %}&q={{ search_query|urlencode }}{% endif %}"
>
<span class="history-title">{{ conversation.title|default:"新对话" }}</span>
<span class="history-meta">{{ conversation.updated_at|date:"m月d日 H:i" }}</span>
</a>
{% empty %}
<div class="history-empty">
<p>暂无会话记录</p>
<span>点击上方“新对话”开始审核。</span>
</div>
{% endfor %}
</nav>
</div>
</aside>
<section class="chat-shell">
<header class="topbar">
<div class="topbar-left">
<button class="icon-button mobile-toggle" type="button" id="mobileSidebarToggle" aria-label="展开侧边栏">
<span></span>
<span></span>
</button>
<div class="tabbar" role="tablist" aria-label="页面切换">
<button class="tab" type="button" role="tab" aria-selected="false">首页</button>
<button class="tab" type="button" role="tab" aria-selected="false">知识库管理</button>
<button class="tab active" type="button" role="tab" aria-selected="true">审核智能体</button>
<button class="tab" type="button" role="tab" aria-selected="false">视频实时监测</button>
</div>
</div>
<div class="topbar-right">
<div class="user-menu" id="userMenu">
<button class="user-menu-trigger" id="userMenuTrigger" type="button" aria-haspopup="menu" aria-expanded="false">
<span class="avatar large">{{ request.user.username|slice:":1"|upper }}</span>
<div class="user-copy">
<strong>{{ request.user.username }}</strong>
<span>当前登录用户</span>
</div>
<span class="caret"></span>
</button>
<div class="user-dropdown" id="userDropdown" role="menu">
<div class="user-dropdown-section" role="none">
<p class="user-dropdown-label">用户信息</p>
<strong class="user-dropdown-name">{{ request.user.username }}</strong>
</div>
<a class="user-dropdown-link" href="{% url 'password_change' %}" role="menuitem">修改密码</a>
<form action="{% url 'logout' %}" method="post" class="user-dropdown-form" role="none">
{% csrf_token %}
<button class="user-dropdown-link danger-link" type="submit" role="menuitem">退出登录</button>
</form>
</div>
</div>
</div>
</header>
<section class="chat-stage">
<div class="chat-scroll">
{% if current_conversation %}
<div class="conversation-header">
<div>
<p class="eyebrow">审核智能体</p>
<h1>{{ current_conversation.title|default:"新对话" }}</h1>
</div>
<span class="conversation-meta">最后更新 {{ current_conversation.updated_at|date:"Y-m-d H:i" }}</span>
</div>
{% for message in messages %}
<article class="message {{ message.role }}">
<div class="message-avatar{% if message.role == 'user' %} user-mark{% endif %}">
{% if message.role == "assistant" %}AI{% else %}{{ request.user.username|slice:":1"|upper }}{% endif %}
</div>
<div class="message-bubble">
<p>{{ message.content|linebreaksbr }}</p>
</div>
</article>
{% endfor %}
{% else %}
<div class="empty-state">
<p class="eyebrow">审核智能体</p>
<h1>开始新的审核对话</h1>
<p class="muted">输入资料疑点、法规条款、说明书问题或风险项,系统会为你保留真实会话记录。</p>
</div>
{% endif %}
</div>
<div class="composer-wrap">
<form class="composer" action="/" method="post">
{% csrf_token %}
<input type="hidden" name="action" value="send_message">
{% if current_conversation %}
<input type="hidden" name="conversation_id" value="{{ current_conversation.pk }}">
{% endif %}
<label class="sr-only" for="prompt">输入消息</label>
<textarea id="prompt" name="prompt" rows="1" placeholder="输入审核问题、法规条款、说明书疑点或上传需求"></textarea>
<div class="composer-actions">
<div class="composer-tools">
<span class="tool-chip passive-chip">法规核查</span>
<span class="tool-chip passive-chip">说明书审核</span>
<span class="tool-chip passive-chip">风险识别</span>
</div>
<button class="send-button" type="submit">发送</button>
</div>
</form>
</div>
</section>
</section>
</main>
{% endblock %}
{% block scripts %}
<script src="{% static 'js/app.js' %}"></script>
{% endblock %}

View File

@@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block title %}登录 - DEMO-AGENT V2{% endblock %}
{% block content %}
<main class="login-page">
<section class="login-card" aria-labelledby="login-title">
<p class="eyebrow">DEMO-AGENT V2</p>
<h1 id="login-title">登录系统</h1>
<p class="muted">请输入账号和密码进入 Django 基础后台。</p>
{% if form.errors %}
<div class="alert" role="alert">用户名或密码不正确,请重新输入。</div>
{% endif %}
<form method="post" novalidate>
{% csrf_token %}
<input type="hidden" name="next" value="{{ next }}">
<label for="{{ form.username.id_for_label }}">用户名</label>
{{ form.username }}
<label for="{{ form.password.id_for_label }}">密码</label>
{{ form.password }}
<button class="button" type="submit">登录</button>
</form>
</section>
</main>
{% endblock %}

View File

@@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block title %}修改密码 - DEMO-AGENT V2{% endblock %}
{% block content %}
<main class="login-page">
<section class="login-card">
<p class="eyebrow">审核智能体</p>
<h1>修改密码</h1>
<p class="muted">输入当前密码,并设置新的登录密码。</p>
<form method="post">
{% csrf_token %}
{% if form.non_field_errors %}
<div class="alert" role="alert">{{ form.non_field_errors }}</div>
{% endif %}
{% for field in form %}
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.errors %}
<div class="alert" role="alert">{{ field.errors }}</div>
{% endif %}
{% endfor %}
<button class="button" type="submit">保存密码</button>
</form>
</section>
</main>
{% endblock %}