1 Commits
17 changed files with 1102 additions and 85 deletions
+76 -9
View File
@@ -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))
+75
View File
@@ -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(),
})
+10
View File
@@ -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)
+55 -19
View File
@@ -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:
+21
View File
@@ -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()
+7 -2
View File
@@ -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 等)
+2 -1
View File
@@ -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"]
+12 -1
View File
@@ -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")
+12
View File
@@ -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="{}")
+18 -1
View File
@@ -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|)$")
+222 -12
View File
@@ -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))
+45
View File
@@ -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",
},
}
+27 -2
View File
@@ -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()
+1
View File
@@ -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'),
+170
View File
@@ -0,0 +1,170 @@
<template>
<div class="admin-page">
<van-nav-bar title="后台管理 · 对话界面配置" fixed placeholder left-arrow @click-left="router.push('/profile')" />
<div class="admin-body" v-if="loaded">
<!-- 统计 -->
<div class="stat-grid" v-if="stats">
<div v-for="s in statCards" :key="s.label" class="stat-card">
<div class="stat-num">{{ stats[s.key] ?? 0 }}</div>
<div class="stat-label">{{ s.label }}</div>
</div>
</div>
<!-- 对话界面开关 -->
<van-cell-group inset title="① 对话界面元素开关">
<van-cell v-for="s in uiSwitches" :key="s.key" :title="s.label" :label="s.desc">
<template #right-icon>
<van-switch v-model="cfg.chat_ui[s.key]" size="22" />
</template>
</van-cell>
</van-cell-group>
<!-- 时间格式 -->
<van-cell-group inset title="② 用户消息时间格式">
<van-cell title="时间格式">
<template #value>
<van-radio-group v-model="cfg.chat_ui.user_time_fmt" direction="horizontal">
<van-radio name="HH:mm">HH:mm</van-radio>
<van-radio name="MM-dd HH:mm">MM-dd HH:mm</van-radio>
<van-radio name="full">完整时间</van-radio>
</van-radio-group>
</template>
</van-cell>
</van-cell-group>
<!-- 附件类型 -->
<van-cell-group inset title="③ 允许的附件类型">
<van-cell title="上传类型(图片 / 可读文本)">
<template #value>
<van-checkbox-group v-model="cfg.chat_ui.attachment_types" direction="horizontal">
<van-checkbox name="image">图片</van-checkbox>
<van-checkbox name="text">文本文件</van-checkbox>
</van-checkbox-group>
</template>
</van-cell>
</van-cell-group>
<!-- 自动标题 -->
<van-cell-group inset title="④ 自动生成会话标题(首个回答后)">
<van-cell title="启用自动标题">
<template #right-icon>
<van-switch v-model="cfg.title_auto_gen" size="22" />
</template>
</van-cell>
<van-field v-model="cfg.title_model" label="标题模型" placeholder="deepseek-chat" />
</van-cell-group>
<!-- 推荐短语 -->
<van-cell-group inset title="⑤ 推荐短语(每次回答后生成 1-3 条)">
<van-cell title="启用推荐短语">
<template #right-icon>
<van-switch v-model="cfg.suggestions.enabled" size="22" />
</template>
</van-cell>
<van-cell title="生成数量">
<template #value>
<van-stepper v-model="cfg.suggestions.count" :min="1" :max="3" />
</template>
</van-cell>
<van-field v-model="cfg.suggestions.model" label="建议模型" placeholder="deepseek-chat" />
</van-cell-group>
<div style="padding: 20px 16px; display: flex; gap: 12px">
<van-button block plain type="primary" @click="reset">恢复默认</van-button>
<van-button block type="primary" :loading="saving" @click="save">保存配置</van-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from 'vant'
import client from '../api/client'
import { useAuthStore } from '../stores/auth'
const router = useRouter()
const auth = useAuthStore()
const loaded = ref(false)
const saving = ref(false)
const cfg = ref<any>({ chat_ui: {}, title_auto_gen: true, title_model: 'deepseek-chat', suggestions: { enabled: true, count: 3, model: 'deepseek-chat' } })
const stats = ref<any>(null)
const uiSwitches = [
{ key: 'user_edit', label: '用户消息 · 编辑按钮', desc: '点击可修改消息并重新生成回答' },
{ key: 'user_copy', label: '用户消息 · 复制按钮' },
{ key: 'user_time', label: '用户消息 · 提交时间', desc: '在编辑/复制按钮旁显示' },
{ key: 'ai_copy', label: 'AI 回答 · 复制按钮' },
{ key: 'ai_regenerate', label: 'AI 回答 · 重新生成按钮' },
{ key: 'ai_feedback', label: 'AI 回答 · 点赞/点踩' },
{ key: 'ai_voice', label: 'AI 回答 · 语音播放', desc: '浏览器 TTS 朗读' },
{ key: 'ai_suggestions', label: 'AI 回答 · 推荐短语', desc: '回答下方显示 1-3 条追问建议' },
{ key: 'voice_input', label: '输入区 · 语音输入按钮', desc: '浏览器语音识别(Chrome/Edge' },
{ key: 'attachment', label: '输入区 · 附件上传按钮', desc: '支持图片和可读文本文件' },
]
const statCards = [
{ key: 'users', label: '用户' },
{ key: 'sessions', label: '会话' },
{ key: 'messages', label: '消息' },
{ key: 'projects', label: '项目' },
{ key: 'agents', label: '智能体' },
{ key: 'likes', label: '点赞' },
{ key: 'dislikes', label: '点踩' },
]
onMounted(async () => {
if (auth.user?.role !== 'admin') {
showToast('需要管理员权限')
router.replace('/profile')
return
}
try {
const data = await client.get('/admin/config')
// 用 effective(含默认值)作为编辑底稿,保证结构完整
const eff = JSON.parse(JSON.stringify(data.effective))
cfg.value = eff
stats.value = await client.get('/admin/stats')
loaded.value = true
} catch (e: any) {
showToast(e.message)
}
})
async function save() {
saving.value = true
try {
await client.put('/admin/config', { config: cfg.value })
showToast('已保存,前端下次加载生效')
} catch (e: any) {
showToast(e.message)
} finally {
saving.value = false
}
}
async function reset() {
cfg.value = {
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' },
}
showToast('已恢复默认,请点击保存生效')
}
</script>
<style scoped>
.admin-page { min-height: 100vh; background: #f7f8fa; }
.admin-body { max-width: 860px; margin: 0 auto; padding-bottom: 40px; }
.stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; padding: 12px 16px; }
.stat-card { background: #fff; border-radius: 10px; padding: 12px; text-align: center; box-shadow: 0 1px 4px rgba(0,0,0,.05); }
.stat-num { font-size: 22px; font-weight: 700; color: #1989fa; }
.stat-label { font-size: 12px; color: #969799; margin-top: 2px; }
</style>
+348 -38
View File
@@ -10,39 +10,94 @@
<van-empty v-if="messages.length === 0" description="发送第一条消息开始对话" />
<div v-for="m in messages" :key="m.id" :class="['msg-row', m.role === 'user' ? 'msg-user' : 'msg-ai']">
<div v-if="m.role !== 'user'" class="msg-avatar">🤖</div>
<div class="bubble">
<div v-if="m.role === 'user'" class="plain-text">{{ m.content }}</div>
<div v-else class="markdown-body" v-html="renderMd(m.content)"></div>
<div v-if="m.role === 'assistant' && m.id === streamingId" class="typing">
<van-loading size="14" /> 正在思考
</div>
<div class="msg-body">
<!-- 用户消息 -->
<template v-if="m.role === 'user'">
<div class="bubble">
<div class="plain-text">{{ m.content }}</div>
<!-- 附件图片 -->
<div v-if="m.file_ids?.length" class="attach-preview">
<img v-for="id in m.file_ids" :key="id" v-if="imgUrlFor(id)" :src="imgUrlFor(id)" class="attach-img" @click="previewImg(imgUrlFor(id))" />
</div>
<div v-if="m.edited" class="edited-tag">已编辑</div>
</div>
<div v-if="ui.chat_ui?.user_edit || ui.chat_ui?.user_copy || ui.chat_ui?.user_time" class="msg-tools user-tools">
<van-icon v-if="ui.chat_ui?.user_edit" name="edit" @click="startEdit(m)" />
<van-icon v-if="ui.chat_ui?.user_copy" name="copy" @click="copyText(m.content)" />
<span v-if="ui.chat_ui?.user_time" class="msg-time">{{ fmtTime(m.created_at) }}</span>
</div>
</template>
<!-- AI 回答 -->
<template v-else>
<div class="bubble">
<div class="markdown-body" v-html="renderMd(m.content)"></div>
<div v-if="m.id === streamingId" class="typing">
<van-loading size="14" /> 正在思考
</div>
<!-- 推荐短语 -->
<div v-if="ui.chat_ui?.ai_suggestions && m.suggestions?.length && m.id !== streamingId" class="sugg-chips">
<div v-for="s in m.suggestions" :key="s" class="sugg-chip" @click="quickSend(s)">{{ s }}</div>
</div>
</div>
<div v-if="hasAiTools" class="msg-tools ai-tools">
<van-icon v-if="ui.chat_ui?.ai_copy" name="copy" @click="copyText(m.content)" />
<van-icon v-if="ui.chat_ui?.ai_regenerate" name="replay" @click="regenerate()" />
<van-icon v-if="ui.chat_ui?.ai_feedback" name="good-job" :class="{ active: m.feedback === 'like' }" @click="feedback(m, 'like')" />
<van-icon v-if="ui.chat_ui?.ai_feedback" name="bad-job" :class="{ active: m.feedback === 'dislike' }" @click="feedback(m, 'dislike')" />
<van-icon v-if="ui.chat_ui?.ai_voice" :name="speakingId === m.id ? 'volume-o' : 'volume'" @click="speak(m)" />
</div>
</template>
</div>
</div>
</div>
<div class="input-bar" style="max-width: 860px; margin: 0 auto">
<van-field
v-model="input"
type="textarea"
autosize
rows="1"
maxlength="4000"
placeholder="输入消息…"
@keydown.enter.exact.prevent="send"
/>
<van-button type="primary" :disabled="sending || !input.trim()" @click="send">发送</van-button>
<!-- 输入区 -->
<div class="input-bar">
<div class="input-row">
<van-icon v-if="ui.chat_ui?.voice_input" name="audio" size="22" class="input-icon" @click="startVoice" />
<van-icon v-if="ui.chat_ui?.attachment" name="plus" size="22" class="input-icon" @click="showAttachSheet = true" />
<van-field
v-model="input"
type="textarea"
autosize
rows="1"
maxlength="4000"
:placeholder="editingMsg ? '编辑消息…(保存后自动重新生成回答)' : '输入消息…'"
@keydown.enter.exact.prevent="send"
/>
<van-button type="primary" :disabled="sending || (!input.trim() && attachments.length === 0)" @click="send">
{{ editingMsg ? '更新' : '发送' }}
</van-button>
</div>
<!-- 已选附件 -->
<div v-if="attachments.length" class="attach-list">
<div v-for="(a, i) in attachments" :key="i" class="attach-item">
<img v-if="a.type === 'image'" :src="a.preview" class="attach-thumb" />
<van-icon v-else name="description" size="22" color="#1989fa" />
<span class="attach-name">{{ a.name }}</span>
<van-icon name="cross" class="attach-del" @click="attachments.splice(i, 1)" />
</div>
</div>
</div>
<!-- 附件类型选择 -->
<van-action-sheet v-model:show="showAttachSheet" :actions="attachActions" cancel-text="取消" @select="onAttachSelect" />
<!-- 图片预览 -->
<van-image-preview v-model:show="showPreview" :images="previewList" :start-position="previewIndex" />
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showConfirmDialog, showToast } from 'vant'
import { showConfirmDialog, showImagePreview, showToast } from 'vant'
import { marked } from 'marked'
import hljs from 'highlight.js'
import 'highlight.js/styles/github.css'
import client, { wsUrl } from '../api/client'
import client, { API_BASE, wsUrl } from '../api/client'
marked.setOptions({
highlight(code: string, lang: string) {
@@ -61,13 +116,70 @@ const input = ref('')
const sending = ref(false)
const streamingId = ref<number | null>(null)
const scrollRef = ref<HTMLElement>()
const ui = ref<any>({ chat_ui: {} })
const attachments = ref<any[]>([])
const showAttachSheet = ref(false)
const editingMsg = ref<any>(null)
const speakingId = ref<number | null>(null)
const showPreview = ref(false)
const previewList = ref<string[]>([])
const previewIndex = ref(0)
// 附件图片缓存:file_id -> objectURL
const imgCache = new Map<number, string>()
function imgUrlFor(id: number): string {
return imgCache.get(id) || ''
}
async function scanImages() {
const ids = new Set<number>()
messages.value.forEach((m: any) => (m.file_ids || []).forEach((id: number) => ids.add(id)))
for (const id of ids) {
if (imgCache.has(id)) continue
try {
const resp = await fetch(`${API_BASE}/files/${id}/content`, {
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
})
if (!resp.ok) continue
const blob = await resp.blob()
if (blob.type.startsWith('image/')) {
imgCache.set(id, URL.createObjectURL(blob))
}
} catch {}
}
}
let ws: WebSocket | null = null
let titleTimer: number | null = null
let suggTimer: number | null = null
const hasAiTools = computed(() => {
const c = ui.value.chat_ui || {}
return c.ai_copy || c.ai_regenerate || c.ai_feedback || c.ai_voice
})
const attachActions = computed(() => {
const types = ui.value.chat_ui?.attachment_types || ['image', 'text']
const actions: any[] = []
if (types.includes('image')) actions.push({ name: '上传图片', subname: 'JPG/PNG/GIF/WebP', value: 'image' })
if (types.includes('text')) actions.push({ name: '上传文本文件', subname: 'TXT/MD/CSV/JSON/代码文件', value: 'text' })
return actions
})
function renderMd(text: string) {
return marked.parse(text || '')
}
function fmtTime(iso: string) {
const d = new Date(iso)
const fmt = ui.value.chat_ui?.user_time_fmt || 'HH:mm'
const p = (n: number) => String(n).padStart(2, '0')
if (fmt === 'full') return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
if (fmt === 'MM-dd HH:mm') return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
return `${p(d.getHours())}:${p(d.getMinutes())}`
}
function scrollBottom() {
nextTick(() => {
const el = scrollRef.value
@@ -76,6 +188,9 @@ function scrollBottom() {
}
async function load() {
try {
ui.value = await client.get('/config/ui')
} catch {}
try {
const sessions = await client.get('/chat/sessions')
session.value = sessions.find((s: any) => s.id === sessionId.value) || null
@@ -86,15 +201,27 @@ async function load() {
}
}
// ---------- 发送 ----------
function send() {
const content = input.value.trim()
if (!content || sending.value) return
if (sending.value) return
if (!content && attachments.value.length === 0) return
if (editingMsg.value) {
// 编辑:更新消息内容,然后重新生成
saveEdit(content)
return
}
input.value = ''
sending.value = true
const fileIds = attachments.value.map((a) => a.fileId)
attachments.value = []
const tempId = -Date.now()
messages.value.push({ id: tempId, role: 'user', content })
const aiMsg = { id: -Date.now() - 1, role: 'assistant', content: '' }
messages.value.push({ id: tempId, role: 'user', content, file_ids: fileIds })
const aiMsg = { id: -Date.now() - 1, role: 'assistant', content: '', suggestions: [] }
messages.value.push(aiMsg)
streamingId.value = aiMsg.id
scrollBottom()
@@ -102,7 +229,7 @@ function send() {
if (!ws || ws.readyState !== WebSocket.OPEN) {
ws = new WebSocket(wsUrl(sessionId.value))
ws.onmessage = (ev) => handleWs(ev, aiMsg)
ws.onopen = () => ws!.send(JSON.stringify({ type: 'chat', content }))
ws.onopen = () => ws!.send(JSON.stringify({ type: 'chat', content, file_ids: fileIds }))
ws.onerror = () => {
showToast('连接失败,请重试')
sending.value = false
@@ -110,7 +237,43 @@ function send() {
}
ws.onclose = () => { ws = null }
} else {
ws.send(JSON.stringify({ type: 'chat', content }))
ws.send(JSON.stringify({ type: 'chat', content, file_ids: fileIds }))
}
}
async function saveEdit(content: string) {
const target = editingMsg.value
sending.value = true
try {
await client.put(`/chat/messages/${target.id}`, { content })
// 本地更新
const msg = messages.value.find((m) => m.id === target.id)
if (msg) { msg.content = content; msg.edited = true }
// 触发重新生成
regenerate()
} catch (e: any) {
showToast(e.message)
sending.value = false
}
}
function regenerate() {
const aiMsg = { id: -Date.now() - 2, role: 'assistant', content: '', suggestions: [] }
messages.value.push(aiMsg)
streamingId.value = aiMsg.id
sending.value = true
editingMsg.value = null
scrollBottom()
const doSend = () => ws!.send(JSON.stringify({ type: 'regenerate' }))
if (!ws || ws.readyState !== WebSocket.OPEN) {
ws = new WebSocket(wsUrl(sessionId.value))
ws.onmessage = (ev) => handleWs(ev, aiMsg)
ws.onopen = doSend
ws.onerror = () => { showToast('连接失败'); sending.value = false; streamingId.value = null }
ws.onclose = () => { ws = null }
} else {
doSend()
}
}
@@ -122,11 +285,13 @@ function handleWs(ev: MessageEvent, aiMsg: any) {
} else if (data.type === 'done') {
streamingId.value = null
sending.value = false
// 刷新消息列表拿到真实 id
client.get(`/chat/sessions/${sessionId.value}/messages`).then((ms) => {
messages.value = ms
scrollBottom()
})
refreshMessages()
scheduleSuggestRefresh()
} else if (data.type === 'suggestions') {
const msg = messages.value.find((m) => m.id === data.message_id)
if (msg) msg.suggestions = data.items
} else if (data.type === 'title') {
if (session.value) session.value.title = data.title
} else if (data.type === 'error') {
aiMsg.content = `⚠️ ${data.message}`
streamingId.value = null
@@ -135,6 +300,135 @@ function handleWs(ev: MessageEvent, aiMsg: any) {
}
}
async function refreshMessages() {
try {
messages.value = await client.get(`/chat/sessions/${sessionId.value}/messages`)
const sessions = await client.get('/chat/sessions')
session.value = sessions.find((s: any) => s.id === sessionId.value) || session.value
scanImages()
scrollBottom()
} catch {}
}
function scheduleSuggestRefresh() {
// 建议异步生成,稍后刷新一次
if (suggTimer) window.clearTimeout(suggTimer)
suggTimer = window.setTimeout(refreshMessages, 4000)
titleTimer = window.setTimeout(async () => {
try {
const sessions = await client.get('/chat/sessions')
session.value = sessions.find((s: any) => s.id === sessionId.value) || session.value
} catch {}
}, 6000)
}
// ---------- 消息操作 ----------
function startEdit(m: any) {
editingMsg.value = m
input.value = m.content
scrollBottom()
}
function copyText(text: string) {
navigator.clipboard?.writeText(text || '').then(
() => showToast('已复制'),
() => showToast('复制失败')
)
}
async function feedback(m: any, kind: string) {
const next = m.feedback === kind ? '' : kind
m.feedback = next
try {
await client.post(`/chat/messages/${m.id}/feedback`, { feedback: next })
} catch {
showToast('操作失败')
}
}
// 语音播放(浏览器 TTS
function speak(m: any) {
if (!('speechSynthesis' in window)) return showToast('当前浏览器不支持语音播放')
if (speakingId.value === m.id) {
window.speechSynthesis.cancel()
speakingId.value = null
return
}
window.speechSynthesis.cancel()
const text = (m.content || '').replace(/[#*`>|~\-\[\]()]/g, ' ').slice(0, 2000)
const u = new SpeechSynthesisUtterance(text)
u.lang = 'zh-CN'
u.rate = 1
u.onend = () => { speakingId.value = null }
u.onerror = () => { speakingId.value = null }
speakingId.value = m.id
window.speechSynthesis.speak(u)
}
// 语音输入
function startVoice() {
const SR: any = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition
if (!SR) return showToast('当前浏览器不支持语音输入(建议用 Chrome/Edge')
const rec = new SR()
rec.lang = 'zh-CN'
rec.interimResults = true
rec.continuous = true
rec.onresult = (e: any) => {
let text = ''
for (let i = 0; i < e.results.length; i++) text += e.results[i][0].transcript
input.value = text
}
rec.onend = () => showToast('语音输入结束')
rec.onerror = (e: any) => showToast(`语音识别失败:${e.error || ''}`)
showToast('请说话…')
rec.start()
}
// ---------- 附件 ----------
function onAttachSelect(action: any) {
showAttachSheet.value = false
const isImage = action.value === 'image'
const fileInput = document.createElement('input')
fileInput.type = 'file'
fileInput.accept = isImage ? 'image/*' : '.txt,.md,.csv,.json,.log,.py,.js,.ts,.html,.css,.xml,.yml,.yaml,.sh'
fileInput.onchange = async () => {
const file = fileInput.files?.[0]
if (!file) return
if (file.size > 20 * 1024 * 1024) return showToast('文件不能超过 20MB')
const preview = isImage ? URL.createObjectURL(file) : ''
// 先展示,再上传
const item = { name: file.name, type: isImage ? 'image' : 'text', preview, fileId: 0, uploading: true }
attachments.value.push(item)
try {
const fd = new FormData()
fd.append('file', file)
const f = await client.post('/files/upload', fd, { headers: { 'Content-Type': 'multipart/form-data' } })
item.fileId = f.id
item.uploading = false
} catch (e: any) {
showToast(`上传失败:${e.message}`)
attachments.value = attachments.value.filter((a) => a !== item)
}
}
fileInput.click()
}
function imageUrls(m: any) {
return (m.file_ids || []).map((id: number) => ({ id, url: '' })).filter(() => false)
}
async function previewImg(url: string) {
showImagePreview([url])
}
onMounted(load)
onBeforeUnmount(() => {
ws?.close()
if (suggTimer) window.clearTimeout(suggTimer)
if (titleTimer) window.clearTimeout(titleTimer)
window.speechSynthesis?.cancel()
})
async function delSession() {
try {
await showConfirmDialog({ title: '删除会话', message: '确定删除该会话及全部消息?' })
@@ -142,23 +436,39 @@ async function delSession() {
router.replace('/chat')
} catch {}
}
onMounted(load)
onBeforeUnmount(() => ws?.close())
</script>
<style scoped>
.chat-room { display: flex; flex-direction: column; height: 100vh; max-width: 860px; margin: 0 auto; background: #f7f8fa; }
.msg-list { flex: 1; overflow-y: auto; padding: 12px 14px 8px; }
.msg-row { display: flex; margin-bottom: 12px; }
.msg-row { display: flex; margin-bottom: 14px; }
.msg-user { justify-content: flex-end; }
.msg-ai { justify-content: flex-start; align-items: flex-start; }
.msg-avatar { width: 34px; height: 34px; border-radius: 50%; background: #e8f3ff; display: flex; align-items: center; justify-content: center; font-size: 18px; margin-right: 8px; flex-shrink: 0; }
.bubble { max-width: 82%; padding: 9px 12px; border-radius: 10px; font-size: 14px; line-height: 1.65; }
.msg-body { max-width: 82%; display: flex; flex-direction: column; }
.msg-user .msg-body { align-items: flex-end; }
.bubble { padding: 9px 12px; border-radius: 10px; font-size: 14px; line-height: 1.65; word-break: break-word; }
.msg-user .bubble { background: #1989fa; color: #fff; border-top-right-radius: 2px; }
.msg-ai .bubble { background: #fff; border: 1px solid #ebedf0; border-top-left-radius: 2px; }
.plain-text { white-space: pre-wrap; word-break: break-word; }
.msg-ai .bubble { background: #fff; border: 1px solid #ebedf0; border-top-left-radius: 2px; width: fit-content; }
.plain-text { white-space: pre-wrap; }
.edited-tag { font-size: 10px; opacity: .7; margin-top: 4px; text-align: right; }
.msg-tools { display: flex; align-items: center; gap: 14px; margin-top: 4px; color: #969799; font-size: 15px; padding: 0 4px; }
.user-tools { justify-content: flex-end; }
.msg-tools .van-icon { cursor: pointer; }
.msg-tools .van-icon.active { color: #1989fa; }
.msg-time { font-size: 11px; color: #c8c9cc; }
.typing { display: flex; align-items: center; gap: 6px; color: #969799; font-size: 12px; margin-top: 4px; }
.input-bar { display: flex; align-items: flex-end; gap: 8px; padding: 8px 12px calc(12px + env(safe-area-inset-bottom)); background: #fff; border-top: 1px solid #ebedf0; }
.input-bar .van-field { flex: 1; background: #f7f8fa; border-radius: 8px; padding: 4px 10px; }
.sugg-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.sugg-chip { background: #e8f3ff; color: #1989fa; font-size: 12px; padding: 4px 10px; border-radius: 14px; cursor: pointer; }
.attach-preview { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.attach-img { width: 120px; height: 90px; object-fit: cover; border-radius: 6px; }
.input-bar { border-top: 1px solid #ebedf0; background: #fff; padding: 6px 10px calc(8px + env(safe-area-inset-bottom)); }
.input-row { display: flex; align-items: flex-end; gap: 8px; max-width: 860px; margin: 0 auto; }
.input-icon { color: #646566; flex-shrink: 0; margin-bottom: 8px; }
.input-row .van-field { flex: 1; background: #f7f8fa; border-radius: 8px; padding: 4px 10px; }
.attach-list { display: flex; flex-wrap: wrap; gap: 8px; padding: 8px 4px 0; max-width: 860px; margin: 0 auto; }
.attach-item { display: flex; align-items: center; gap: 6px; background: #f7f8fa; border-radius: 8px; padding: 4px 8px; font-size: 12px; position: relative; }
.attach-thumb { width: 32px; height: 32px; object-fit: cover; border-radius: 6px; }
.attach-name { max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.attach-del { color: #969799; }
</style>
+1
View File
@@ -20,6 +20,7 @@
</van-cell-group>
<van-cell-group inset style="margin-top: 12px">
<van-cell v-if="user?.role === 'admin'" title="⚙️ 后台管理" label="对话界面配置" is-link to="/admin" />
<van-cell title="关于随身助手" is-link @click="showAbout = true" />
<van-cell title="退出登录" is-link @click="logout" />
</van-cell-group>