Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e26605adf8 |
+76
-9
@@ -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))
|
||||
@@ -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(),
|
||||
})
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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 等)
|
||||
|
||||
@@ -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"]
|
||||
@@ -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")
|
||||
@@ -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="{}")
|
||||
@@ -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|)$")
|
||||
@@ -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))
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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'),
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user