Files
NovalRedot/backend/app/routers/chat.py
ROOG b39377e487
All checks were successful
Deploy Noval to K3s / deploy (push) Successful in 47s
新增文件管理与技能管理功能:
- **文件管理**:
  - 支持文件上传、解析和删除。
  - 限制支持格式(txt, md, docx, pdf),最大 10MB。
  - 提供前端上传区、文件列表及后端解析服务。

- **技能管理**:
  - 新增技能的创建、更新及删除功能。
  - 支持分配自定义提示词和工具权限。
  - 提供技能列表及分组工具选择交互界面。

同时调整相关 API 和前端组件,优化协作体验。
2026-03-22 23:50:14 +08:00

411 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import json
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from sqlmodel import Session, select, func
from ..database import get_session, engine
from ..models import AIConfig, ChatMessage, Novel, Skill
from ..schemas import ChatRequest, ChatMessageRead, ChatMessageList
from ..services.ai_provider import (
stream_chat, simple_completion,
build_assistant_message, build_tool_results_messages, ToolCall,
)
from ..services.tools import get_openai_tools, get_anthropic_tools, execute_tool, get_tools_description
router = APIRouter(prefix="/api/chat", tags=["chat"])
# 最大工具调用轮次
MAX_TOOL_ROUNDS = 8
# 工具名称到中文描述
TOOL_LABELS: dict[str, str] = {
"get_novel_info": "查看小说信息",
"update_novel_info": "更新小说信息",
"list_outlines": "查询大纲",
"create_outline": "创建大纲",
"update_outline": "更新大纲",
"delete_outline": "删除大纲",
"reorder_outlines": "调整大纲排序",
"get_world_setting": "查询世界观",
"save_world_setting": "保存世界观",
"list_characters": "查询角色",
"create_character": "创建角色",
"update_character": "更新角色",
"delete_character": "删除角色",
"list_chapters": "查询章节",
"create_chapter": "创建章节",
"update_chapter": "更新章节",
"delete_chapter": "删除章节",
"list_files": "查询文件",
"read_file": "读取文件",
}
def _build_system_prompt(
novel_id: int | None,
session: Session,
page_context: str | None = None,
tools_enabled: bool = False,
skill: Skill | None = None,
allowed_tools: list[str] | None = None,
) -> str:
"""根据小说上下文、Skill 和工具配置构建 system prompt"""
parts = [
"你是 Writing Red Dot 写作助手,专注于小说创作领域。",
"你擅长:构思情节、塑造角色、打磨文笔、设计世界观、规划大纲结构。",
]
# 注入 Skill 提示词
if skill and skill.system_prompt:
parts.append(f"\n## 当前技能:{skill.name}\n{skill.system_prompt}")
if novel_id:
novel = session.get(Novel, novel_id)
if novel:
parts.append(f"\n当前小说:《{novel.title}ID: {novel.id}")
parts.append(f"小说简介:{novel.description or '暂无'}")
if page_context:
parts.append(f"\n用户当前正在查看:{page_context}。请结合当前页面场景提供针对性建议。")
if tools_enabled:
# 动态生成工具能力描述(根据 allowed_tools 过滤)
tools_desc = get_tools_description(allowed_tools)
if tools_desc:
parts.append(f"\n## 你的工具能力\n{tools_desc}")
if not novel_id:
parts.append("\n注意:工具操作需要关联到具体小说。如果用户需要使用工具,请提示他们先进入某本小说。")
return "\n".join(parts)
def _get_tools(provider: str, allowed: list[str] | None = None) -> list[dict]:
if provider == "anthropic":
return get_anthropic_tools(allowed)
return get_openai_tools(allowed)
def _sse(data: dict) -> str:
return f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
def _save_assistant_message(novel_id: int | None, full_response: str, tool_call_logs: list[str]):
"""保存 AI 回复到 DB含工具调用摘要。在 finally 中调用,确保断连也能保存。"""
save_content = full_response
if tool_call_logs:
tool_summary = "\n".join(tool_call_logs)
save_content = f"[以下操作已执行]\n{tool_summary}\n\n{full_response}"
if save_content.strip():
with Session(engine) as s:
ai_msg = ChatMessage(
novel_id=novel_id,
role="assistant",
content=save_content,
)
s.add(ai_msg)
s.commit()
@router.post("/stream")
async def chat_stream(
data: ChatRequest,
session: Session = Depends(get_session),
):
"""SSE 流式对话端点,支持 tool calling + 用户审批"""
config = session.exec(select(AIConfig)).first()
if not config or not config.api_key:
return StreamingResponse(
_error_stream("请先配置AI服务"),
media_type="text/event-stream",
)
# 保存用户消息(仅首次请求,续传审批时不保存)
if not data.pending_tool_calls:
user_msg = data.messages[-1] if data.messages else None
if user_msg and user_msg.role == "user":
db_msg = ChatMessage(
novel_id=data.novel_id,
role="user",
content=user_msg.content,
)
session.add(db_msg)
session.commit()
# 构建消息列表
messages = [{"role": m.role, "content": m.content} for m in data.messages]
# 读取 Skill 配置
skill: Skill | None = None
allowed_tools: list[str] | None = None
if data.skill_id:
skill = session.get(Skill, data.skill_id)
if skill and skill.allowed_tools:
try:
parsed = json.loads(skill.allowed_tools)
if isinstance(parsed, list) and len(parsed) > 0:
allowed_tools = parsed
except (json.JSONDecodeError, TypeError):
pass
# 构建上下文
use_tools = bool(data.tools_enabled)
system_prompt = _build_system_prompt(
data.novel_id, session, data.page_context, use_tools,
skill=skill, allowed_tools=allowed_tools,
)
tools = _get_tools(config.provider, allowed_tools) if use_tools else None
async def generate():
nonlocal messages
full_response = data.assistant_text or ""
usage_info: dict = {}
# 记录本次对话中的工具调用摘要(用于保存到 DB
tool_call_logs: list[str] = []
try:
# ── 阶段一:如果有待审批的工具调用,先执行它们 ──
if data.pending_tool_calls:
tool_calls = [
ToolCall(id=tc.id, name=tc.name, arguments=tc.arguments)
for tc in data.pending_tool_calls
]
# 构建 assistant 消息(含工具调用)
assistant_msg = build_assistant_message(
config.provider, data.assistant_text or "", tool_calls
)
messages.append(assistant_msg)
# 执行每个工具,发送结果
tool_results = []
for tc in tool_calls:
label = TOOL_LABELS.get(tc.name, tc.name)
result = execute_tool(tc.name, tc.arguments, data.novel_id)
tool_results.append({"id": tc.id, "result": result})
tool_call_logs.append(f"[工具调用: {label}] 参数: {json.dumps(tc.arguments, ensure_ascii=False)} → 结果: {result}")
yield _sse({"tool_result": {
"name": tc.name,
"label": label,
"result": result,
}})
# 追加工具结果消息
result_msgs = build_tool_results_messages(config.provider, tool_results)
messages.extend(result_msgs)
# ── 阶段二LLM 循环 ──
for _round in range(MAX_TOOL_ROUNDS):
round_text = ""
round_tool_calls = []
async for event in stream_chat(config, messages, system_prompt, tools):
if event.text:
round_text += event.text
yield _sse({"content": event.text})
if event.usage:
for k, v in event.usage.items():
if v:
usage_info[k] = usage_info.get(k, 0) + v
if event.tool_calls:
round_tool_calls = event.tool_calls
# 没有工具调用 → 完成
if not round_tool_calls:
full_response += round_text
break
# 有工具调用
full_response += round_text
calls_data = [
{
"id": tc.id,
"name": tc.name,
"label": TOOL_LABELS.get(tc.name, tc.name),
"arguments": tc.arguments,
}
for tc in round_tool_calls
]
if data.auto_approve_tools:
# 自动执行模式:直接执行工具,不暂停
yield _sse({"tool_calls_auto": calls_data})
assistant_msg = build_assistant_message(
config.provider, full_response, round_tool_calls
)
messages.append(assistant_msg)
tool_results = []
for tc in round_tool_calls:
label = TOOL_LABELS.get(tc.name, tc.name)
result = execute_tool(tc.name, tc.arguments, data.novel_id)
tool_results.append({"id": tc.id, "result": result})
tool_call_logs.append(f"[工具调用: {label}] 参数: {json.dumps(tc.arguments, ensure_ascii=False)} → 结果: {result}")
yield _sse({"tool_result": {
"name": tc.name,
"label": label,
"result": result,
}})
result_msgs = build_tool_results_messages(config.provider, tool_results)
messages.extend(result_msgs)
# 继续循环,让 LLM 基于工具结果生成回复
continue
else:
# 审批模式:暂停,发给前端审批
yield _sse({
"tool_calls_pending": calls_data,
"assistant_text": full_response,
})
yield _sse({"done": True, "pending": True, "usage": usage_info or None})
return # 暂停,不保存到 DB
except Exception as e:
yield _sse({"error": str(e)})
finally:
# 无论连接是否断开,都保存已有的回复和工具调用到 DB
# 这确保刷新页面后 loadHistory 能恢复正确状态
_save_assistant_message(data.novel_id, full_response, tool_call_logs)
yield _sse({"done": True, "usage": usage_info or None})
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
async def _error_stream(message: str):
yield _sse({"error": message})
yield _sse({"done": True})
@router.get("/messages", response_model=ChatMessageList)
def list_messages(
novel_id: int | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
session: Session = Depends(get_session),
):
query = select(ChatMessage)
count_query = select(func.count(ChatMessage.id))
if novel_id is not None:
query = query.where(ChatMessage.novel_id == novel_id)
count_query = count_query.where(ChatMessage.novel_id == novel_id)
else:
query = query.where(ChatMessage.novel_id.is_(None)) # type: ignore
count_query = count_query.where(ChatMessage.novel_id.is_(None)) # type: ignore
total = session.exec(count_query).one()
items = session.exec(
query.order_by(ChatMessage.created_at.desc()).limit(limit)
).all()
items = list(reversed(items))
return ChatMessageList(
items=[ChatMessageRead.model_validate(m, from_attributes=True) for m in items],
total=total,
)
@router.delete("/messages", status_code=204)
def clear_messages(
novel_id: int | None = Query(default=None),
session: Session = Depends(get_session),
):
query = select(ChatMessage)
if novel_id is not None:
query = query.where(ChatMessage.novel_id == novel_id)
else:
query = query.where(ChatMessage.novel_id.is_(None)) # type: ignore
messages = session.exec(query).all()
for msg in messages:
session.delete(msg)
session.commit()
# 保留最近对话轮次数(每轮 = 1 user + 1 assistant
KEEP_RECENT_ROUNDS = 2
@router.post("/compress")
async def compress_context(
novel_id: int | None = Query(default=None),
session: Session = Depends(get_session),
):
"""压缩对话上下文:用 LLM 总结旧消息,仅保留最近几轮对话。"""
config = session.exec(select(AIConfig)).first()
if not config or not config.api_key:
return {"error": "请先配置AI服务"}
# 查询所有消息(按时间正序)
query = select(ChatMessage)
if novel_id is not None:
query = query.where(ChatMessage.novel_id == novel_id)
else:
query = query.where(ChatMessage.novel_id.is_(None)) # type: ignore
all_msgs = list(session.exec(query.order_by(ChatMessage.created_at)).all())
if len(all_msgs) <= KEEP_RECENT_ROUNDS * 2:
return {"compressed": False, "reason": "消息太少,无需压缩"}
# 分割:旧消息(需要压缩)+ 最近消息(保留原文)
keep_count = KEEP_RECENT_ROUNDS * 2
old_msgs = all_msgs[:-keep_count]
recent_msgs = all_msgs[-keep_count:]
# 检查第一条旧消息是否已是摘要(避免重复压缩无效内容)
# 构建需要总结的对话文本
conversation_text = ""
for msg in old_msgs:
role_label = "用户" if msg.role == "user" else "AI助手"
content = msg.content
# 如果是已压缩的摘要,标注出来
if content.startswith("[对话摘要]"):
conversation_text += f"[之前的摘要]{content[5:]}\n\n"
else:
conversation_text += f"{role_label}: {content}\n\n"
# 调用 LLM 生成摘要
summary_prompt = (
"你是一个对话摘要助手。请将以下对话历史压缩为简洁的摘要,保留关键信息:\n"
"1. 用户提出的核心需求和决策\n"
"2. AI 执行的重要操作及结果(如创建/修改了哪些大纲、角色、章节等)\n"
"3. 达成的共识和待办事项\n\n"
"只输出摘要内容,不要加前缀或解释。用简洁的条目列表形式。"
)
summary_messages = [
{"role": "user", "content": f"请压缩以下对话历史:\n\n{conversation_text}"}
]
try:
summary = await simple_completion(config, summary_messages, summary_prompt)
except Exception as e:
return {"error": f"摘要生成失败: {str(e)}"}
# 删除旧消息
for msg in old_msgs:
session.delete(msg)
# 插入摘要消息(时间设为最早保留消息之前)
summary_msg = ChatMessage(
novel_id=novel_id,
role="assistant",
content=f"[对话摘要]\n{summary}",
created_at=recent_msgs[0].created_at,
)
session.add(summary_msg)
session.commit()
return {
"compressed": True,
"removed_count": len(old_msgs),
"kept_count": len(recent_msgs),
"summary_length": len(summary),
}