From e26605adf863d2dbb8d11a7f39b7968cbf00621c Mon Sep 17 00:00:00 2001 From: hz4th_coder Date: Sat, 15 Aug 2026 00:58:16 +0800 Subject: [PATCH] =?UTF-8?q?v0.2.0=20=E5=AF=B9=E8=AF=9D=E7=95=8C=E9=9D=A2?= =?UTF-8?q?=E5=8D=87=E7=BA=A7=EF=BC=9A=E6=B6=88=E6=81=AF=E7=BC=96=E8=BE=91?= =?UTF-8?q?/=E5=A4=8D=E5=88=B6/=E6=97=B6=E9=97=B4/=E5=8F=8D=E9=A6=88/?= =?UTF-8?q?=E9=87=8D=E6=96=B0=E7=94=9F=E6=88=90/=E8=AF=AD=E9=9F=B3?= =?UTF-8?q?=E6=92=AD=E6=94=BE/=E8=AF=AD=E9=9F=B3=E8=BE=93=E5=85=A5/?= =?UTF-8?q?=E9=99=84=E4=BB=B6(=E5=9B=BE=E7=89=87+=E6=96=87=E6=9C=AC)/?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=A0=87=E9=A2=98/=E6=8E=A8=E8=8D=90?= =?UTF-8?q?=E7=9F=AD=E8=AF=AD=20+=20=E5=90=8E=E5=8F=B0=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0(=E7=95=8C=E9=9D=A2=E5=BC=80=E5=85=B3/?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E6=A0=BC=E5=BC=8F/=E9=99=84=E4=BB=B6?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B/=E6=A0=87=E9=A2=98=E4=B8=8E=E5=BB=BA?= =?UTF-8?q?=E8=AE=AE=E9=85=8D=E7=BD=AE/=E7=BB=9F=E8=AE=A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/chat.py | 85 +++++- backend/app/api/config.py | 75 +++++ backend/app/api/files.py | 10 + backend/app/api/ws.py | 74 +++-- backend/app/core/migrate.py | 21 ++ backend/app/main.py | 9 +- backend/app/models/__init__.py | 3 +- backend/app/models/chat.py | 13 +- backend/app/models/config.py | 12 + backend/app/schemas/chat.py | 19 +- backend/app/services/chat_service.py | 234 ++++++++++++++- backend/app/services/default_config.py | 45 +++ backend/app/services/seed.py | 29 +- frontend/src/router/index.ts | 1 + frontend/src/views/AdminView.vue | 170 +++++++++++ frontend/src/views/ChatRoomView.vue | 386 ++++++++++++++++++++++--- frontend/src/views/ProfileView.vue | 1 + 17 files changed, 1102 insertions(+), 85 deletions(-) create mode 100644 backend/app/api/config.py create mode 100644 backend/app/core/migrate.py create mode 100644 backend/app/models/config.py create mode 100644 backend/app/services/default_config.py create mode 100644 frontend/src/views/AdminView.vue diff --git a/backend/app/api/chat.py b/backend/app/api/chat.py index 31e44e0..249fd42 100644 --- a/backend/app/api/chat.py +++ b/backend/app/api/chat.py @@ -1,4 +1,6 @@ -"""对话路由:会话管理 + REST 非流式对话。""" +"""对话路由:会话管理 + 消息编辑/反馈/重新生成 + REST 非流式对话。""" +import json + from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session @@ -6,7 +8,7 @@ from ..core.deps import get_current_user from ..core.response import ok from ..database import get_db from ..models import Agent, ChatMessage, ChatSession, User -from ..schemas.chat import ChatRequest, ChatSessionCreate +from ..schemas.chat import ChatMessageUpdate, ChatRequest, ChatSessionCreate, FeedbackRequest from ..services import chat_service router = APIRouter(prefix="/chat", tags=["对话"]) @@ -20,8 +22,20 @@ def _session_out(s: ChatSession) -> dict: def _message_out(m: ChatMessage) -> dict: - return {"id": m.id, "session_id": m.session_id, "role": m.role, "content": m.content, - "model": m.model, "created_at": m.created_at.isoformat()} + try: + suggestions = json.loads(m.suggestions) if m.suggestions else [] + except json.JSONDecodeError: + suggestions = [] + try: + file_ids = json.loads(m.file_ids) if m.file_ids else [] + except json.JSONDecodeError: + file_ids = [] + return { + "id": m.id, "session_id": m.session_id, "role": m.role, "content": m.content, + "model": m.model, "file_ids": file_ids, "feedback": m.feedback or "", + "suggestions": suggestions, "edited": bool(m.edited), "regenerated": m.regenerated or 0, + "created_at": m.created_at.isoformat(), "updated_at": m.updated_at.isoformat(), + } def _get_owned_session(db: Session, user: User, session_id: int) -> ChatSession: @@ -64,16 +78,14 @@ def list_messages(session_id: int, user: User = Depends(get_current_user), db: S async def send_message(session_id: int, body: ChatRequest, user: User = Depends(get_current_user), db: Session = Depends(get_db)): s = _get_owned_session(db, user, session_id) + if not body.content and not body.file_ids: + raise HTTPException(status_code=400, detail="消息内容不能为空") try: - reply = await chat_service.chat_once(db, user, s, body.content, body.agent_id, body.model) + reply = await chat_service.chat_once(db, user, s, body.content, body.agent_id, body.model, body.file_ids) except LookupError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: raise HTTPException(status_code=502, detail=f"模型调用失败:{e}") - # 自动生成会话标题 - if s.title == "新对话" and len(body.content) <= 40: - s.title = body.content[:20] - db.commit() return ok(_message_out(reply)) @@ -83,3 +95,58 @@ def delete_session(session_id: int, user: User = Depends(get_current_user), db: db.delete(s) db.commit() return ok(message="已删除") + + +# ---------- 消息操作 ---------- + +def _get_owned_message(db: Session, user: User, message_id: int) -> ChatMessage: + m = db.get(ChatMessage, message_id) + if not m: + raise HTTPException(status_code=404, detail="消息不存在") + s = db.get(ChatSession, m.session_id) + if not s or s.user_id != user.id: + raise HTTPException(status_code=404, detail="消息不存在") + return m + + +@router.put("/messages/{message_id}") +def edit_message(message_id: int, body: ChatMessageUpdate, + user: User = Depends(get_current_user), db: Session = Depends(get_db)): + """编辑用户消息(随后前端会触发重新生成)。""" + m = _get_owned_message(db, user, message_id) + if m.role != "user": + raise HTTPException(status_code=400, detail="只能编辑用户消息") + m.content = body.content + m.edited = True + m.updated_at = __import__("datetime").datetime.utcnow() + db.commit() + db.refresh(m) + return ok(_message_out(m)) + + +@router.post("/messages/{message_id}/feedback") +def set_feedback(message_id: int, body: FeedbackRequest, + user: User = Depends(get_current_user), db: Session = Depends(get_db)): + m = _get_owned_message(db, user, message_id) + if m.role != "assistant": + raise HTTPException(status_code=400, detail="只能对 AI 回答反馈") + m.feedback = body.feedback + db.commit() + db.refresh(m) + return ok(_message_out(m)) + + +@router.post("/sessions/{session_id}/regenerate") +async def regenerate(session_id: int, + user: User = Depends(get_current_user), db: Session = Depends(get_db)): + """REST 非流式重新生成最后一条 AI 回答(备用,前端主要走 WS 流式)。""" + s = _get_owned_session(db, user, session_id) + try: + async for delta, message_id in chat_service.regenerate_stream(db, s): + pass # 流式丢弃,最终内容已入库 + except LookupError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=502, detail=f"模型调用失败:{e}") + m = db.get(ChatMessage, message_id) + return ok(_message_out(m)) diff --git a/backend/app/api/config.py b/backend/app/api/config.py new file mode 100644 index 0000000..17bf9eb --- /dev/null +++ b/backend/app/api/config.py @@ -0,0 +1,75 @@ +"""系统配置路由: +- GET /api/config/ui 公开 UI 配置(前端启动时拉取,无需登录) +- GET /api/admin/config 管理端读取配置(admin) +- PUT /api/admin/config 管理端保存配置(admin) +- GET /api/admin/stats 管理端统计(admin) +""" +import json + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from ..core.deps import get_current_user +from ..core.response import ok +from ..database import get_db +from ..models import Agent, ChatMessage, ChatSession, Project, Task, User +from ..services.chat_service import get_ui_config +from ..services.default_config import DEFAULT_UI_CONFIG + +router = APIRouter(tags=["配置"]) + + +def require_admin(user: User = Depends(get_current_user)) -> User: + if user.role != "admin": + raise HTTPException(status_code=403, detail="需要管理员权限") + return user + + +@router.get("/config/ui") +def ui_config(db: Session = Depends(get_db)): + """公开:前端渲染所需的界面配置。""" + return ok(get_ui_config(db)) + + +@router.get("/admin/config") +def get_admin_config(user: User = Depends(require_admin), db: Session = Depends(get_db)): + from ..models import AppConfig + row = db.get(AppConfig, "ui") + stored = {} + if row and row.value: + try: + stored = json.loads(row.value) + except json.JSONDecodeError: + stored = {} + return ok({"defaults": DEFAULT_UI_CONFIG, "stored": stored, "effective": get_ui_config(db)}) + + +class AdminConfigBody(BaseModel): + config: dict + + +@router.put("/admin/config") +def put_admin_config(body: AdminConfigBody, user: User = Depends(require_admin), db: Session = Depends(get_db)): + from ..models import AppConfig + row = db.get(AppConfig, "ui") + if not row: + row = AppConfig(key="ui", value="{}") + db.add(row) + row.value = json.dumps(body.config, ensure_ascii=False) + db.commit() + return ok(get_ui_config(db), "已保存") + + +@router.get("/admin/stats") +def admin_stats(user: User = Depends(require_admin), db: Session = Depends(get_db)): + return ok({ + "users": db.query(User).count(), + "sessions": db.query(ChatSession).count(), + "messages": db.query(ChatMessage).count(), + "projects": db.query(Project).count(), + "tasks": db.query(Task).count(), + "agents": db.query(Agent).count(), + "likes": db.query(ChatMessage).filter(ChatMessage.feedback == "like").count(), + "dislikes": db.query(ChatMessage).filter(ChatMessage.feedback == "dislike").count(), + }) diff --git a/backend/app/api/files.py b/backend/app/api/files.py index 5622789..31ea3e7 100644 --- a/backend/app/api/files.py +++ b/backend/app/api/files.py @@ -60,6 +60,16 @@ def _get_owned(db: Session, user: User, file_id: int) -> FileEntry: return f +@router.get("/{file_id}/content") +def file_content(file_id: int, user: User = Depends(get_current_user), db: Session = Depends(get_db)): + """内联内容(图片预览等,鉴权)。""" + f = _get_owned(db, user, file_id) + path = Path(f.stored_path) + if not path.exists(): + raise HTTPException(status_code=404, detail="文件已丢失") + return FileResponse(path, media_type=f.mime or "application/octet-stream") + + @router.get("/{file_id}/download") def download_file(file_id: int, user: User = Depends(get_current_user), db: Session = Depends(get_db)): f = _get_owned(db, user, file_id) diff --git a/backend/app/api/ws.py b/backend/app/api/ws.py index fe8f93d..964525a 100644 --- a/backend/app/api/ws.py +++ b/backend/app/api/ws.py @@ -1,9 +1,14 @@ -"""WebSocket 路由:流式对话。协议: -客户端 → {"type":"chat","content":"...","agent_id":null,"model":""} +"""WebSocket 路由:流式对话 + 重新生成。协议: +客户端 → {"type":"chat","content":"...","agent_id":null,"model":"","file_ids":[]} + {"type":"regenerate"} 服务端 → {"type":"delta","message_id":1,"content":"增量文本"} {"type":"done","message_id":1} + {"type":"suggestions","message_id":1,"items":["..",".."]} + {"type":"title","title":"..."} {"type":"error","message":"..."} """ +import asyncio + from fastapi import APIRouter, WebSocket, WebSocketDisconnect from sqlalchemy.orm import Session @@ -14,6 +19,18 @@ from ..services import chat_service router = APIRouter() +# session_id -> set[WebSocket](用于推送建议/标题) +_conns: dict[int, set[WebSocket]] = {} + + +def push_to_session(session_id: int, payload: dict): + """向该会话的所有在线连接推送消息。""" + for ws in list(_conns.get(session_id, set())): + try: + asyncio.get_event_loop().create_task(ws.send_json(payload)) + except Exception: + pass + @router.websocket("/chat/ws") async def chat_ws(websocket: WebSocket): @@ -40,23 +57,42 @@ async def chat_ws(websocket: WebSocket): await websocket.close() return - while True: - data = await websocket.receive_json() - if data.get("type") != "chat": - continue - content = (data.get("content") or "").strip() - if not content: - await websocket.send_json({"type": "error", "message": "消息不能为空"}) - continue - try: - async for delta, message_id in chat_service.chat_stream( - db, user, session, content, - agent_id=data.get("agent_id"), model=data.get("model", ""), - ): - await websocket.send_json({"type": "delta", "message_id": message_id, "content": delta}) - await websocket.send_json({"type": "done", "message_id": message_id}) - except Exception as e: - await websocket.send_json({"type": "error", "message": f"模型调用失败:{e}"}) + _conns.setdefault(session.id, set()).add(websocket) + try: + while True: + data = await websocket.receive_json() + msg_type = data.get("type") + + if msg_type == "chat": + content = (data.get("content") or "").strip() + file_ids = data.get("file_ids") or [] + if not content and not file_ids: + await websocket.send_json({"type": "error", "message": "消息不能为空"}) + continue + try: + async for delta, message_id in chat_service.chat_stream( + db, user, session, content, + agent_id=data.get("agent_id"), model=data.get("model", ""), + file_ids=file_ids, + ): + await websocket.send_json({"type": "delta", "message_id": message_id, "content": delta}) + await websocket.send_json({"type": "done", "message_id": message_id}) + except Exception as e: + await websocket.send_json({"type": "error", "message": f"模型调用失败:{e}"}) + + elif msg_type == "regenerate": + try: + async for delta, message_id in chat_service.regenerate_stream(db, session): + await websocket.send_json({"type": "delta", "message_id": message_id, "content": delta}) + await websocket.send_json({"type": "done", "message_id": message_id}) + except LookupError as e: + await websocket.send_json({"type": "error", "message": str(e)}) + except Exception as e: + await websocket.send_json({"type": "error", "message": f"模型调用失败:{e}"}) + finally: + _conns.get(session.id, set()).discard(websocket) + if not _conns.get(session.id): + _conns.pop(session.id, None) except WebSocketDisconnect: pass finally: diff --git a/backend/app/core/migrate.py b/backend/app/core/migrate.py new file mode 100644 index 0000000..03d031c --- /dev/null +++ b/backend/app/core/migrate.py @@ -0,0 +1,21 @@ +"""轻量数据库迁移:为新版本补列(SQLite/PostgreSQL 通用)。""" +from sqlalchemy import inspect, text + + +def migrate(db): + """检查并补齐缺失的表列。""" + insp = inspect(db.bind) + existing = {c["name"] for c in insp.get_columns("chat_messages")} + additions = { + "file_ids": "VARCHAR(256) DEFAULT '[]'", + "feedback": "VARCHAR(8) DEFAULT ''", + "suggestions": "TEXT DEFAULT '[]'", + "edited": "BOOLEAN DEFAULT 0", + "regenerated": "INTEGER DEFAULT 0", + "updated_at": "DATETIME", + } + for name, ddl in additions.items(): + if name not in existing: + db.execute(text(f"ALTER TABLE chat_messages ADD COLUMN {name} {ddl}")) + if existing: + db.commit() diff --git a/backend/app/main.py b/backend/app/main.py index bfd5ae6..1418587 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,11 +7,12 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles -from .api import agents, auth, chat, files, projects, tasks, ws +from .api import agents, auth, chat, config, files, projects, tasks, ws from .config import settings +from .core.migrate import migrate from .core.response import fail from .database import Base, SessionLocal, engine -from .services.seed import seed_builtin_agents +from .services.seed import seed_admin_user, seed_builtin_agents, seed_default_config STATIC_DIR = Path(__file__).resolve().parent.parent / "static" STATIC_DIR.mkdir(exist_ok=True) @@ -23,7 +24,10 @@ async def lifespan(app: FastAPI): Base.metadata.create_all(bind=engine) db = SessionLocal() try: + migrate(db) seed_builtin_agents(db) + seed_admin_user(db) + seed_default_config(db) finally: db.close() yield @@ -58,6 +62,7 @@ app.include_router(agents.router, prefix=settings.API_PREFIX) app.include_router(projects.router, prefix=settings.API_PREFIX) app.include_router(files.router, prefix=settings.API_PREFIX) app.include_router(tasks.router, prefix=settings.API_PREFIX) +app.include_router(config.router, prefix=settings.API_PREFIX) app.include_router(ws.router, prefix=settings.API_PREFIX) # 静态:上传文件访问(视频抽帧 URL 等) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 2c54217..e306910 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -3,5 +3,6 @@ from .user import User from .chat import ChatSession, ChatMessage from .agent import Agent from .project import Project, ProjectTask, Task, FileEntry +from .config import AppConfig -__all__ = ["User", "ChatSession", "ChatMessage", "Agent", "Project", "ProjectTask", "Task", "FileEntry"] +__all__ = ["User", "ChatSession", "ChatMessage", "Agent", "Project", "ProjectTask", "Task", "FileEntry", "AppConfig"] diff --git a/backend/app/models/chat.py b/backend/app/models/chat.py index b98aa4c..48f060b 100644 --- a/backend/app/models/chat.py +++ b/backend/app/models/chat.py @@ -1,7 +1,7 @@ """对话会话与消息模型。""" from datetime import datetime -from sqlalchemy import DateTime, ForeignKey, Integer, String, Text +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text from sqlalchemy.orm import Mapped, mapped_column, relationship from ..database import Base @@ -33,6 +33,17 @@ class ChatMessage(Base): model: Mapped[str] = mapped_column(String(64), default="") tokens_in: Mapped[int] = mapped_column(Integer, default=0) tokens_out: Mapped[int] = mapped_column(Integer, default=0) + # 附件文件 ID 列表(JSON 数组) + file_ids: Mapped[str] = mapped_column(String(256), default="[]") + # 用户反馈:like / dislike / 空 + feedback: Mapped[str] = mapped_column(String(8), default="") + # 推荐短语(JSON 数组,AI 回答后生成 1-3 条) + suggestions: Mapped[str] = mapped_column(Text, default="[]") + # 用户消息是否被编辑过 + edited: Mapped[bool] = mapped_column(Boolean, default=False) + # 重新生成次数 + regenerated: Mapped[int] = mapped_column(Integer, default=0) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) session: Mapped["ChatSession"] = relationship(back_populates="messages") diff --git a/backend/app/models/config.py b/backend/app/models/config.py new file mode 100644 index 0000000..abf6a04 --- /dev/null +++ b/backend/app/models/config.py @@ -0,0 +1,12 @@ +"""系统配置模型:key-value(JSON 值),用于后台管理平台控制前端细节。""" +from sqlalchemy import String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from ..database import Base + + +class AppConfig(Base): + __tablename__ = "app_config" + + key: Mapped[str] = mapped_column(String(64), primary_key=True) + value: Mapped[str] = mapped_column(Text, default="{}") diff --git a/backend/app/schemas/chat.py b/backend/app/schemas/chat.py index d43867a..5f625b4 100644 --- a/backend/app/schemas/chat.py +++ b/backend/app/schemas/chat.py @@ -26,7 +26,13 @@ class ChatMessageOut(BaseModel): role: str content: str model: str + file_ids: list[int] = [] + feedback: str = "" + suggestions: list[str] = [] + edited: bool = False + regenerated: int = 0 created_at: str + updated_at: str class Config: from_attributes = True @@ -34,6 +40,17 @@ class ChatMessageOut(BaseModel): class ChatRequest(BaseModel): """REST 非流式对话请求。""" - content: str = Field(min_length=1) + content: str = Field(default="") agent_id: int | None = None model: str = "" + file_ids: list[int] = [] + + +class ChatMessageUpdate(BaseModel): + """编辑用户消息。""" + content: str = Field(min_length=1) + + +class FeedbackRequest(BaseModel): + """点赞/点踩。""" + feedback: str = Field(pattern="^(like|dislike|)$") diff --git a/backend/app/services/chat_service.py b/backend/app/services/chat_service.py index 6f90773..9494cfd 100644 --- a/backend/app/services/chat_service.py +++ b/backend/app/services/chat_service.py @@ -1,10 +1,15 @@ -"""对话服务:会话上下文组装 + LLM 流式/非流式调用。""" +"""对话服务:上下文组装 + LLM 调用 + 附件/标题/推荐短语/重新生成。""" +import asyncio +import base64 +import json from datetime import datetime +from pathlib import Path from sqlalchemy.orm import Session from ..core import llm -from ..models import Agent, ChatMessage, ChatSession, User +from ..models import Agent, ChatMessage, ChatSession, FileEntry, User +from ..services.default_config import DEFAULT_UI_CONFIG def get_session(db: Session, user: User, session_id: int) -> ChatSession: @@ -14,8 +19,58 @@ def get_session(db: Session, user: User, session_id: int) -> ChatSession: return session -def build_messages(db: Session, session: ChatSession, user_content: str) -> list[dict]: - """组装发给 LLM 的消息:系统提示词(agent) + 最近历史 + 当前问题。""" +def get_ui_config(db: Session) -> dict: + """读取 UI 配置(数据库覆盖默认值)。""" + from ..models import AppConfig + row = db.get(AppConfig, "ui") + if row and row.value: + try: + stored = json.loads(row.value) + return _deep_merge(DEFAULT_UI_CONFIG, stored) + except json.JSONDecodeError: + pass + return DEFAULT_UI_CONFIG + + +def _deep_merge(base: dict, override: dict) -> dict: + out = dict(base) + for k, v in override.items(): + if k in out and isinstance(out[k], dict) and isinstance(v, dict): + out[k] = _deep_merge(out[k], v) + else: + out[k] = v + return out + + +def _load_attachments(db: Session, file_ids: list[int]) -> tuple[list[str], list[str]]: + """加载附件:返回 (图片 data URL 列表, 文本内容列表)。""" + images: list[str] = [] + texts: list[str] = [] + for fid in file_ids or []: + f = db.get(FileEntry, fid) + if not f: + continue + path = Path(f.stored_path) + if not path.exists(): + continue + mime = f.mime or "" + if mime.startswith("image/"): + b64 = base64.b64encode(path.read_bytes()).decode() + images.append(f"data:{mime};base64,{b64}") + elif mime.startswith("text/") or f.filename.lower().endswith((".txt", ".md", ".csv", ".json", ".log", ".py", ".js", ".html", ".xml", ".yml", ".yaml")): + try: + content = path.read_text(encoding="utf-8", errors="replace") + texts.append(f"【附件: {f.filename}】\n{content[:20000]}") + except Exception: + texts.append(f"【附件: {f.filename}】(无法读取)") + else: + texts.append(f"【附件: {f.filename}】(不支持的类型,仅图片和文本文件可被读取)") + return images, texts + + +def build_messages(db: Session, session: ChatSession, user_content: str, + file_ids: list[int] | None = None, exclude_last: bool = False) -> list[dict]: + """组装发给 LLM 的消息:系统提示词(agent) + 最近历史 + 当前问题(支持图片附件)。""" messages: list[dict] = [] if session.agent_id: @@ -23,7 +78,6 @@ def build_messages(db: Session, session: ChatSession, user_content: str) -> list if agent and agent.system_prompt: messages.append({"role": "system", "content": agent.system_prompt}) - # 最近 20 条历史(控制上下文长度) history = ( db.query(ChatMessage) .filter(ChatMessage.session_id == session.id) @@ -31,10 +85,25 @@ def build_messages(db: Session, session: ChatSession, user_content: str) -> list .limit(20) .all() ) + # 重新生成时排除最后一条 AI 回答 + if exclude_last and history and history[0].role == "assistant": + history = history[1:] for m in reversed(history): messages.append({"role": m.role, "content": m.content}) - messages.append({"role": "user", "content": user_content}) + images, texts = _load_attachments(db, file_ids or []) + if images: + content: list[dict] = [] + if user_content: + content.append({"type": "text", "text": user_content}) + for url in images: + content.append({"type": "image_url", "image_url": {"url": url}}) + messages.append({"role": "user", "content": content}) + else: + text = user_content + if texts: + text = (user_content + "\n\n" if user_content else "") + "\n\n".join(texts) + messages.append({"role": "user", "content": text}) return messages @@ -48,18 +117,109 @@ def resolve_model(session: ChatSession, agent: Agent | None, requested: str) -> return llm.settings.DEFAULT_MODEL +def _json_files(fids: list[int]) -> str: + return json.dumps(fids or []) + + +async def _auto_title(db: Session, session_id: int, first_content: str, reply_content: str = ""): + """后台任务:首个回答后自动生成会话标题(结合首轮问答内容)。""" + from ..database import SessionLocal + sdb = SessionLocal() + try: + session = sdb.get(ChatSession, session_id) + if not session or session.title != "新对话": + return + ui = get_ui_config(sdb) + if not ui.get("title_auto_gen", True): + return + model = ui.get("title_model") or "deepseek-chat" + user_part = first_content[:100] or "(无文字,仅图片)" + reply_part = (reply_content or "")[:120] or "(无回答)" + prompt = ( + "根据下面这轮对话,生成一个简洁的对话标题(10 字以内,不要标点、不要引号,直接输出标题):\n" + f"用户:{user_part}\n助手:{reply_part}" + ) + try: + title = await llm.chat_completion( + [{"role": "user", "content": prompt}], model=model, max_tokens=30, temperature=0.3 + ) + title = title.strip().strip('"「」').strip().replace("\n", " ")[:30] + if title: + session.title = title + sdb.commit() + from ..api.ws import push_to_session + push_to_session(session_id, {"type": "title", "title": title}) + except Exception: + pass + finally: + sdb.close() + + +async def _gen_suggestions(db: Session, session_id: int, reply_id: int): + """后台任务:根据最近对话生成 1-3 条用户可能的追问短语。""" + from ..database import SessionLocal + sdb = SessionLocal() + try: + reply = sdb.get(ChatMessage, reply_id) + if not reply: + return + ui = get_ui_config(sdb) + sug_cfg = ui.get("suggestions", {}) + if not sug_cfg.get("enabled", True): + return + count = max(1, min(3, int(sug_cfg.get("count", 3)))) + model = sug_cfg.get("model") or "deepseek-chat" + + history = ( + sdb.query(ChatMessage) + .filter(ChatMessage.session_id == session_id, ChatMessage.id <= reply_id) + .order_by(ChatMessage.id.desc()) + .limit(6) + .all() + ) + transcript = "\n".join( + f"{'用户' if m.role == 'user' else '助手'}: {(m.content or '')[:200]}" for m in reversed(history) + ) + prompt = ( + f"以下是最近一段对话:\n{transcript}\n\n" + f"请以用户的视角,预测用户接下来最可能追问的 {count} 个短语(每个不超过 12 字," + f"自然口语化,不要重复)。只输出 JSON 数组,如:[\"追问1\", \"追问2\"],不要其他内容。" + ) + try: + raw = await llm.chat_completion( + [{"role": "user", "content": prompt}], model=model, max_tokens=200, temperature=0.8 + ) + raw = raw.strip() + if raw.startswith("```"): + raw = raw.strip("`") + if raw.startswith("json"): + raw = raw[4:] + items = json.loads(raw) + if isinstance(items, list): + items = [str(x)[:20] for x in items[:3]] + reply.suggestions = json.dumps(items, ensure_ascii=False) + sdb.commit() + from ..api.ws import push_to_session + push_to_session(session_id, {"type": "suggestions", "message_id": reply_id, "items": items}) + except Exception: + pass + finally: + sdb.close() + + async def chat_once(db: Session, user: User, session: ChatSession, content: str, - agent_id: int | None = None, model: str = "") -> ChatMessage: + agent_id: int | None = None, model: str = "", file_ids: list[int] | None = None) -> ChatMessage: """REST 非流式:调用 LLM 并保存双方消息。""" if agent_id is not None: session.agent_id = agent_id agent = db.get(Agent, session.agent_id) if session.agent_id else None session.model = resolve_model(session, agent, model) - db.add(ChatMessage(session_id=session.id, role="user", content=content)) + db.add(ChatMessage(session_id=session.id, role="user", content=content, + file_ids=_json_files(file_ids))) db.commit() - messages = build_messages(db, session, content) + messages = build_messages(db, session, content, file_ids) reply_text = await llm.chat_completion(messages, model=session.model, temperature=agent.temperature if agent else 0.7) reply = ChatMessage(session_id=session.id, role="assistant", content=reply_text, model=session.model) @@ -67,21 +227,24 @@ async def chat_once(db: Session, user: User, session: ChatSession, content: str, session.updated_at = datetime.utcnow() db.commit() db.refresh(reply) + + _fire_background_jobs(db, session, content, reply.id, reply_text) return reply async def chat_stream(db: Session, user: User, session: ChatSession, content: str, - agent_id: int | None = None, model: str = ""): + agent_id: int | None = None, model: str = "", file_ids: list[int] | None = None): """流式:先存用户消息,逐段产出回复增量,结束后存完整回复。""" if agent_id is not None: session.agent_id = agent_id agent = db.get(Agent, session.agent_id) if session.agent_id else None session.model = resolve_model(session, agent, model) - db.add(ChatMessage(session_id=session.id, role="user", content=content)) + db.add(ChatMessage(session_id=session.id, role="user", content=content, + file_ids=_json_files(file_ids))) db.commit() - messages = build_messages(db, session, content) + messages = build_messages(db, session, content, file_ids) reply = ChatMessage(session_id=session.id, role="assistant", content="", model=session.model) db.add(reply) db.commit() @@ -96,8 +259,55 @@ async def chat_stream(db: Session, user: User, session: ChatSession, content: st yield delta, reply.id except Exception as e: reply.content = "".join(parts) or f"(调用失败:{e})" + session.updated_at = datetime.utcnow() db.commit() raise else: reply.content = "".join(parts) + session.updated_at = datetime.utcnow() db.commit() + _fire_background_jobs(db, session, content, reply.id, reply.content) + + +def _fire_background_jobs(db: Session, session: ChatSession, first_content: str, reply_id: int, reply_content: str = ""): + """回答完成后:异步生成标题 + 推荐短语(各自独立会话,不阻塞请求)。""" + asyncio.create_task(_auto_title(db, session.id, first_content, reply_content)) + asyncio.create_task(_gen_suggestions(db, session.id, reply_id)) + + +async def regenerate_stream(db: Session, session: ChatSession): + """重新生成最后一条 AI 回答(流式)。返回 (message_id, async_iter)。""" + last = ( + db.query(ChatMessage) + .filter(ChatMessage.session_id == session.id, ChatMessage.role == "assistant") + .order_by(ChatMessage.id.desc()) + .first() + ) + if not last: + raise LookupError("没有可重新生成的消息") + + agent = db.get(Agent, session.agent_id) if session.agent_id else None + messages = build_messages(db, session, "", exclude_last=True) + + # 重新生成后旧建议清空,等新建议 + last.suggestions = "[]" + db.commit() + + parts: list[str] = [] + try: + async for delta in llm.chat_completion_stream( + messages, model=session.model, temperature=agent.temperature if agent else 0.7 + ): + parts.append(delta) + yield delta, last.id + except Exception as e: + last.content = "".join(parts) or f"(调用失败:{e})" + last.regenerated += 1 + db.commit() + raise + else: + last.content = "".join(parts) + last.regenerated += 1 + session.updated_at = datetime.utcnow() + db.commit() + asyncio.create_task(_gen_suggestions(db, session.id, last.id)) diff --git a/backend/app/services/default_config.py b/backend/app/services/default_config.py new file mode 100644 index 0000000..b5edf1d --- /dev/null +++ b/backend/app/services/default_config.py @@ -0,0 +1,45 @@ +"""默认配置:后台管理平台可覆盖。 + +chat_ui: 对话界面元素开关 + user_edit 用户消息编辑按钮 + user_copy 用户消息复制按钮 + user_time 用户消息时间显示 + user_time_fmt 时间格式(HH:mm / MM-dd HH:mm / full) + ai_copy AI 回答复制按钮 + ai_regenerate AI 回答重新生成按钮 + ai_feedback AI 回答点赞/点踩按钮 + ai_voice AI 回答语音播放按钮 + ai_suggestions AI 回答推荐短语 + voice_input 语音输入按钮 + attachment 附件上传按钮 + attachment_types 允许的附件类型: image / text + +title_auto_gen: 首个回答后自动生成会话标题 +suggestions: 每次回答后生成 1-3 条推荐短语 + enabled + count (1-3) + model 建议生成模型 +""" +DEFAULT_UI_CONFIG = { + "chat_ui": { + "user_edit": True, + "user_copy": True, + "user_time": True, + "user_time_fmt": "HH:mm", + "ai_copy": True, + "ai_regenerate": True, + "ai_feedback": True, + "ai_voice": True, + "ai_suggestions": True, + "voice_input": True, + "attachment": True, + "attachment_types": ["image", "text"], + }, + "title_auto_gen": True, + "title_model": "deepseek-chat", + "suggestions": { + "enabled": True, + "count": 3, + "model": "deepseek-chat", + }, +} diff --git a/backend/app/services/seed.py b/backend/app/services/seed.py index e846971..60a301c 100644 --- a/backend/app/services/seed.py +++ b/backend/app/services/seed.py @@ -1,7 +1,11 @@ -"""内置智能体初始化:系统预置 7 个角色。""" +"""内置智能体初始化 + 初始管理员 + 默认配置。""" +import json + from sqlalchemy.orm import Session -from ..models import Agent +from ..core.security import hash_password +from ..models import Agent, AppConfig, User +from .default_config import DEFAULT_UI_CONFIG BUILTIN_AGENTS = [ { @@ -74,3 +78,24 @@ def seed_builtin_agents(db: Session): else: db.add(Agent(**item, user_id=None, is_builtin=True, is_public=True)) db.commit() + + +def seed_admin_user(db: Session): + """初始管理员 admin / admin123(可环境变量覆盖)。""" + import os + if not db.query(User).filter(User.username == "admin").first(): + db.add(User( + username="admin", + email=os.environ.get("ADMIN_EMAIL", "admin@tphai.com"), + hashed_password=hash_password(os.environ.get("ADMIN_PASSWORD", "admin123")), + display_name="管理员", + role="admin", + )) + db.commit() + + +def seed_default_config(db: Session): + """写入默认 UI 配置(仅首次)。""" + if not db.get(AppConfig, "ui"): + db.add(AppConfig(key="ui", value=json.dumps(DEFAULT_UI_CONFIG, ensure_ascii=False))) + db.commit() diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 1f93b9c..9b2e265 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -5,6 +5,7 @@ const router = createRouter({ history: createWebHashHistory(), routes: [ { path: '/login', component: () => import('../views/LoginView.vue') }, + { path: '/admin', component: () => import('../views/AdminView.vue') }, { path: '/', component: () => import('../views/MainLayout.vue'), diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue new file mode 100644 index 0000000..0adbc87 --- /dev/null +++ b/frontend/src/views/AdminView.vue @@ -0,0 +1,170 @@ + + + + + diff --git a/frontend/src/views/ChatRoomView.vue b/frontend/src/views/ChatRoomView.vue index 41200c9..b4ceabe 100644 --- a/frontend/src/views/ChatRoomView.vue +++ b/frontend/src/views/ChatRoomView.vue @@ -10,39 +10,94 @@
🤖
-
-
{{ m.content }}
-
-
- 正在思考… -
+ +
+ + + + +
-
- - 发送 + +
+
+ + + + + {{ editingMsg ? '更新' : '发送' }} + +
+ +
+
+ + + {{ a.name }} + +
+
+ + + + + +
diff --git a/frontend/src/views/ProfileView.vue b/frontend/src/views/ProfileView.vue index 8e4035c..bc0937d 100644 --- a/frontend/src/views/ProfileView.vue +++ b/frontend/src/views/ProfileView.vue @@ -20,6 +20,7 @@ +