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 @@
+
+