Compare commits

..

3 Commits

Author SHA1 Message Date
098ff4e16d 引入二级大纲功能,新增父子节点关系及子节点排序,同时调整API与UI以支持嵌套大纲管理 2026-03-22 16:53:33 +08:00
20c759150a 新增角色管理、世界观设定、章节管理和AI工具调用功能
- 角色管理:CRUD API + 卡片网格UI,每个角色约200字描述
- 世界观设定:单篇长文编辑器,建议500字以上
- 章节管理:CRUD + 排序 API,左右分栏编辑器,单章5000字上限
- AI工具调用:支持工具审批流程和结构化工具调用
- 小说详情页新增章节、角色、世界观入口按钮

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 13:58:17 +08:00
580dc8be52 加宽聊天面板并新增一键启动脚本
- 聊天面板宽度从380px调整为480px,提升阅读体验
- 新增 start.bat 一键启动脚本,自动启动前后端并打开浏览器

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 01:33:05 +08:00
52 changed files with 5834 additions and 282 deletions

View File

@@ -21,7 +21,9 @@
"Bash(curl -s -X POST http://127.0.0.1:8000/api/novels -H \"Content-Type: application/json\" --data-raw \"{\"\"title\"\":\"\"Novel A\"\",\"\"description\"\":\"\"Desc A\"\"}\")", "Bash(curl -s -X POST http://127.0.0.1:8000/api/novels -H \"Content-Type: application/json\" --data-raw \"{\"\"title\"\":\"\"Novel A\"\",\"\"description\"\":\"\"Desc A\"\"}\")",
"Bash(curl -s -X POST http://127.0.0.1:8000/api/novels -H \"Content-Type: application/json\" --data-raw \"{\"\"title\"\":\"\"Novel B\"\",\"\"description\"\":\"\"Desc B\"\"}\")", "Bash(curl -s -X POST http://127.0.0.1:8000/api/novels -H \"Content-Type: application/json\" --data-raw \"{\"\"title\"\":\"\"Novel B\"\",\"\"description\"\":\"\"Desc B\"\"}\")",
"Bash(taskkill //F //IM uvicorn.exe)", "Bash(taskkill //F //IM uvicorn.exe)",
"Bash(taskkill //F //IM python.exe)" "Bash(taskkill //F //IM python.exe)",
"Bash(git add:*)",
"Bash(git commit:*)"
] ]
} }
} }

16
.dockerignore Normal file
View File

@@ -0,0 +1,16 @@
__pycache__/
*.py[cod]
*.egg-info/
.venv/
node_modules/
frontend/dist/
*.db
.vscode/
.idea/
.claude/
.git/
.DS_Store
Thumbs.db
.env
.env.local
start.bat

34
Dockerfile Normal file
View File

@@ -0,0 +1,34 @@
# ── 阶段一:构建前端 ──
FROM node:20-alpine AS frontend-build
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm ci
COPY frontend/ ./
RUN npm run build-only
# ── 阶段二:运行后端 + 服务前端静态文件 ──
FROM python:3.12-slim
WORKDIR /app
# 安装 Python 依赖
COPY backend/requirements.txt ./requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
# 复制后端代码
COPY backend/app ./app
# 复制前端构建产物到后端静态目录
COPY --from=frontend-build /app/frontend/dist ./static
# 数据目录SQLite 存储)
RUN mkdir -p /app/data
VOLUME /app/data
ENV NOVAL_DATA_DIR=/app/data
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -1,9 +1,11 @@
import os
from pathlib import Path from pathlib import Path
from sqlalchemy import text
from sqlmodel import SQLModel, Session, create_engine from sqlmodel import SQLModel, Session, create_engine
# 数据库文件存放在 backend/data/ 目录下 # 数据目录优先使用环境变量Docker 中设为 /app/data否则用 backend/data/
DATA_DIR = Path(__file__).resolve().parent.parent / "data" DATA_DIR = Path(os.environ.get("NOVAL_DATA_DIR", Path(__file__).resolve().parent.parent / "data"))
DATA_DIR.mkdir(exist_ok=True) DATA_DIR.mkdir(exist_ok=True)
DATABASE_URL = f"sqlite:///{DATA_DIR / 'noval.db'}" DATABASE_URL = f"sqlite:///{DATA_DIR / 'noval.db'}"
@@ -13,6 +15,19 @@ engine = create_engine(DATABASE_URL, echo=False)
def init_db() -> None: def init_db() -> None:
SQLModel.metadata.create_all(engine) SQLModel.metadata.create_all(engine)
_migrate_outline_parent_id()
def _migrate_outline_parent_id() -> None:
"""迁移:为 outline 表添加 parent_id 列(如果尚不存在)"""
with Session(engine) as session:
# 检查 outline 表是否存在 parent_id 列
result = session.exec(text("PRAGMA table_info(outline)"))
columns = [row[1] for row in result]
if "parent_id" not in columns:
session.exec(text("ALTER TABLE outline ADD COLUMN parent_id INTEGER REFERENCES outline(id)"))
session.exec(text("CREATE INDEX IF NOT EXISTS ix_outline_parent_id ON outline(parent_id)"))
session.commit()
def get_session(): def get_session():

View File

@@ -1,14 +1,23 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from starlette.responses import FileResponse
from .database import init_db from .database import init_db
from .routers.novels import router as novels_router from .routers.novels import router as novels_router
from .routers.outlines import router as outlines_router from .routers.outlines import router as outlines_router
from .routers.characters import router as characters_router
from .routers.chapters import router as chapters_router
from .routers.world_setting import router as world_setting_router
from .routers.ai_config import router as ai_config_router from .routers.ai_config import router as ai_config_router
from .routers.chat import router as chat_router from .routers.chat import router as chat_router
# 前端构建产物目录Docker 中为 /app/static本地开发时不存在则跳过
STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
@@ -20,7 +29,7 @@ app = FastAPI(title="Noval", version="0.1.0", lifespan=lifespan)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["http://localhost:5173"], allow_origins=["*"],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
@@ -28,6 +37,9 @@ app.add_middleware(
app.include_router(novels_router) app.include_router(novels_router)
app.include_router(outlines_router) app.include_router(outlines_router)
app.include_router(characters_router)
app.include_router(chapters_router)
app.include_router(world_setting_router)
app.include_router(ai_config_router) app.include_router(ai_config_router)
app.include_router(chat_router) app.include_router(chat_router)
@@ -35,3 +47,17 @@ app.include_router(chat_router)
@app.get("/api/health") @app.get("/api/health")
def health(): def health():
return {"status": "ok"} return {"status": "ok"}
# 生产环境serve 前端静态文件Vue SPA
if STATIC_DIR.is_dir():
# 静态资源js/css/img
app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")
# 所有非 /api 路径返回 index.htmlVue Router history 模式)
@app.get("/{path:path}")
async def spa_fallback(path: str):
file_path = STATIC_DIR / path
if file_path.is_file():
return FileResponse(file_path)
return FileResponse(STATIC_DIR / "index.html")

View File

@@ -39,12 +39,46 @@ class ChatMessage(SQLModel, table=True):
created_at: datetime = Field(default_factory=_now_utc) created_at: datetime = Field(default_factory=_now_utc)
class Outline(SQLModel, table=True): class Outline(SQLModel, table=True):
"""大纲表,属于小说的子资源""" """大纲表,属于小说的子资源,支持两级结构(父+子)"""
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
novel_id: int = Field(foreign_key="novel.id", index=True) novel_id: int = Field(foreign_key="novel.id", index=True)
parent_id: int | None = Field(default=None, foreign_key="outline.id", index=True)
sort_order: int = Field(default=0) sort_order: int = Field(default=0)
summary: str = Field(max_length=200) summary: str = Field(max_length=200)
detail: str = Field(default="", max_length=1000) detail: str = Field(default="", max_length=1000)
created_at: datetime = Field(default_factory=_now_utc) created_at: datetime = Field(default_factory=_now_utc)
updated_at: datetime = Field(default_factory=_now_utc) updated_at: datetime = Field(default_factory=_now_utc)
class Character(SQLModel, table=True):
"""角色表,属于小说的子资源"""
id: Optional[int] = Field(default=None, primary_key=True)
novel_id: int = Field(foreign_key="novel.id", index=True)
name: str = Field(max_length=100)
description: str = Field(default="", max_length=1000)
created_at: datetime = Field(default_factory=_now_utc)
updated_at: datetime = Field(default_factory=_now_utc)
class Chapter(SQLModel, table=True):
"""章节表,属于小说的子资源"""
id: Optional[int] = Field(default=None, primary_key=True)
novel_id: int = Field(foreign_key="novel.id", index=True)
sort_order: int = Field(default=0)
title: str = Field(max_length=200)
content: str = Field(default="", max_length=5000)
created_at: datetime = Field(default_factory=_now_utc)
updated_at: datetime = Field(default_factory=_now_utc)
class WorldSetting(SQLModel, table=True):
"""世界观设定,每部小说一条记录"""
id: Optional[int] = Field(default=None, primary_key=True)
novel_id: int = Field(foreign_key="novel.id", index=True, unique=True)
content: str = Field(default="", max_length=5000)
created_at: datetime = Field(default_factory=_now_utc)
updated_at: datetime = Field(default_factory=_now_utc)

View File

@@ -0,0 +1,136 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select, func
from ..database import get_session
from ..models import Novel, Chapter
from ..schemas import (
ChapterCreate,
ChapterUpdate,
ChapterRead,
ChapterList,
ChapterReorder,
)
router = APIRouter(prefix="/api/novels/{novel_id}/chapters", tags=["chapters"])
def _get_novel_or_404(novel_id: int, session: Session) -> Novel:
novel = session.get(Novel, novel_id)
if not novel:
raise HTTPException(status_code=404, detail="小说不存在")
return novel
def _get_chapter_or_404(
chapter_id: int, novel_id: int, session: Session
) -> Chapter:
chapter = session.get(Chapter, chapter_id)
if not chapter or chapter.novel_id != novel_id:
raise HTTPException(status_code=404, detail="章节不存在")
return chapter
@router.get("", response_model=ChapterList)
def list_chapters(
novel_id: int,
session: Session = Depends(get_session),
):
_get_novel_or_404(novel_id, session)
total = session.exec(
select(func.count(Chapter.id)).where(Chapter.novel_id == novel_id)
).one()
items = session.exec(
select(Chapter)
.where(Chapter.novel_id == novel_id)
.order_by(Chapter.sort_order)
).all()
return ChapterList(
items=[ChapterRead.model_validate(c, from_attributes=True) for c in items],
total=total,
)
@router.post("", response_model=ChapterRead, status_code=201)
def create_chapter(
novel_id: int,
data: ChapterCreate,
session: Session = Depends(get_session),
):
_get_novel_or_404(novel_id, session)
max_order = session.exec(
select(func.max(Chapter.sort_order)).where(Chapter.novel_id == novel_id)
).one()
next_order = (max_order or 0) + 1
chapter = Chapter(novel_id=novel_id, sort_order=next_order, **data.model_dump())
session.add(chapter)
session.commit()
session.refresh(chapter)
return chapter
@router.put("/reorder", response_model=ChapterList)
def reorder_chapters(
novel_id: int,
data: ChapterReorder,
session: Session = Depends(get_session),
):
_get_novel_or_404(novel_id, session)
now = datetime.now(timezone.utc)
for idx, cid in enumerate(data.ordered_ids):
chapter = _get_chapter_or_404(cid, novel_id, session)
chapter.sort_order = idx + 1
chapter.updated_at = now
session.add(chapter)
session.commit()
items = session.exec(
select(Chapter)
.where(Chapter.novel_id == novel_id)
.order_by(Chapter.sort_order)
).all()
total = len(items)
return ChapterList(
items=[ChapterRead.model_validate(c, from_attributes=True) for c in items],
total=total,
)
@router.get("/{chapter_id}", response_model=ChapterRead)
def get_chapter(
novel_id: int,
chapter_id: int,
session: Session = Depends(get_session),
):
return _get_chapter_or_404(chapter_id, novel_id, session)
@router.patch("/{chapter_id}", response_model=ChapterRead)
def update_chapter(
novel_id: int,
chapter_id: int,
data: ChapterUpdate,
session: Session = Depends(get_session),
):
chapter = _get_chapter_or_404(chapter_id, novel_id, session)
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(chapter, key, value)
chapter.updated_at = datetime.now(timezone.utc)
session.add(chapter)
session.commit()
session.refresh(chapter)
return chapter
@router.delete("/{chapter_id}", status_code=204)
def delete_chapter(
novel_id: int,
chapter_id: int,
session: Session = Depends(get_session),
):
chapter = _get_chapter_or_404(chapter_id, novel_id, session)
session.delete(chapter)
session.commit()

View File

@@ -0,0 +1,103 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select, func
from ..database import get_session
from ..models import Novel, Character
from ..schemas import (
CharacterCreate,
CharacterUpdate,
CharacterRead,
CharacterList,
)
router = APIRouter(prefix="/api/novels/{novel_id}/characters", tags=["characters"])
def _get_novel_or_404(novel_id: int, session: Session) -> Novel:
novel = session.get(Novel, novel_id)
if not novel:
raise HTTPException(status_code=404, detail="小说不存在")
return novel
def _get_character_or_404(
character_id: int, novel_id: int, session: Session
) -> Character:
character = session.get(Character, character_id)
if not character or character.novel_id != novel_id:
raise HTTPException(status_code=404, detail="角色不存在")
return character
@router.get("", response_model=CharacterList)
def list_characters(
novel_id: int,
session: Session = Depends(get_session),
):
_get_novel_or_404(novel_id, session)
total = session.exec(
select(func.count(Character.id)).where(Character.novel_id == novel_id)
).one()
items = session.exec(
select(Character)
.where(Character.novel_id == novel_id)
.order_by(Character.created_at)
).all()
return CharacterList(
items=[CharacterRead.model_validate(c, from_attributes=True) for c in items],
total=total,
)
@router.post("", response_model=CharacterRead, status_code=201)
def create_character(
novel_id: int,
data: CharacterCreate,
session: Session = Depends(get_session),
):
_get_novel_or_404(novel_id, session)
character = Character(novel_id=novel_id, **data.model_dump())
session.add(character)
session.commit()
session.refresh(character)
return character
@router.get("/{character_id}", response_model=CharacterRead)
def get_character(
novel_id: int,
character_id: int,
session: Session = Depends(get_session),
):
return _get_character_or_404(character_id, novel_id, session)
@router.patch("/{character_id}", response_model=CharacterRead)
def update_character(
novel_id: int,
character_id: int,
data: CharacterUpdate,
session: Session = Depends(get_session),
):
character = _get_character_or_404(character_id, novel_id, session)
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(character, key, value)
character.updated_at = datetime.now(timezone.utc)
session.add(character)
session.commit()
session.refresh(character)
return character
@router.delete("/{character_id}", status_code=204)
def delete_character(
novel_id: int,
character_id: int,
session: Session = Depends(get_session),
):
character = _get_character_or_404(character_id, novel_id, session)
session.delete(character)
session.commit()

View File

@@ -6,19 +6,129 @@ from fastapi.responses import StreamingResponse
from sqlmodel import Session, select, func from sqlmodel import Session, select, func
from ..database import get_session, engine from ..database import get_session, engine
from ..models import AIConfig, ChatMessage from ..models import AIConfig, ChatMessage, Novel
from ..schemas import ChatRequest, ChatMessageRead, ChatMessageList from ..schemas import ChatRequest, ChatMessageRead, ChatMessageList
from ..services.ai_provider import stream_chat 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
router = APIRouter(prefix="/api/chat", tags=["chat"]) 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": "删除章节",
}
def _build_system_prompt(
novel_id: int | None,
session: Session,
page_context: str | None = None,
tools_enabled: bool = False,
) -> str:
"""根据小说上下文和用户所在页面构建 system prompt"""
parts = [
"你是 Writing Red Dot 写作助手,专注于小说创作领域。",
"你擅长:构思情节、塑造角色、打磨文笔、设计世界观、规划大纲结构。",
]
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:
parts.append(
"\n## 你的工具能力\n"
"你是一个具备工具调用能力的写作助手。你拥有以下专用工具,没有其他任何工具:\n"
"\n### 小说信息\n"
"1. get_novel_info — 查看当前小说的标题、简介等基础信息\n"
"2. update_novel_info — 修改小说标题或简介\n"
"\n### 大纲管理\n"
"3. list_outlines — 列出所有大纲节点\n"
"4. create_outline — 创建新的大纲节点需要提供摘要可选详情和父节点ID\n"
"5. update_outline — 更新指定大纲节点的摘要或详情\n"
"6. delete_outline — 删除指定大纲节点\n"
"\n### 世界观\n"
"7. get_world_setting — 获取世界观设定内容\n"
"8. save_world_setting — 保存或更新世界观设定\n"
"\n### 角色管理\n"
"9. list_characters — 列出所有角色\n"
"10. create_character — 创建新角色(需要提供名称,可选描述)\n"
"11. update_character — 更新指定角色的名称或描述\n"
"12. delete_character — 删除指定角色\n"
"\n### 章节管理\n"
"13. list_chapters — 列出所有章节\n"
"14. create_chapter — 创建新章节(需要提供标题,可选正文)\n"
"15. update_chapter — 更新指定章节的标题或正文\n"
"16. delete_chapter — 删除指定章节\n"
"\n当用户询问你有哪些工具或能力时,请据实回答上述工具。"
"\n当用户要求查看或修改小说信息、大纲、世界观、角色或章节时,请主动使用相应工具完成操作,并简要说明结果。"
)
if not novel_id:
parts.append("\n注意:工具操作需要关联到具体小说。如果用户需要使用工具,请提示他们先进入某本小说。")
return "\n".join(parts)
def _get_tools(provider: str) -> list[dict]:
if provider == "anthropic":
return get_anthropic_tools()
return get_openai_tools()
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") @router.post("/stream")
async def chat_stream( async def chat_stream(
data: ChatRequest, data: ChatRequest,
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""SSE 流式对话端点""" """SSE 流式对话端点,支持 tool calling + 用户审批"""
config = session.exec(select(AIConfig)).first() config = session.exec(select(AIConfig)).first()
if not config or not config.api_key: if not config or not config.api_key:
return StreamingResponse( return StreamingResponse(
@@ -26,53 +136,142 @@ async def chat_stream(
media_type="text/event-stream", media_type="text/event-stream",
) )
# 保存用户消息 # 保存用户消息(仅首次请求,续传审批时不保存)
user_msg = data.messages[-1] if data.messages else None if not data.pending_tool_calls:
if user_msg and user_msg.role == "user": user_msg = data.messages[-1] if data.messages else None
db_msg = ChatMessage( if user_msg and user_msg.role == "user":
novel_id=data.novel_id, db_msg = ChatMessage(
role="user", novel_id=data.novel_id,
content=user_msg.content, role="user",
) content=user_msg.content,
session.add(db_msg) )
session.commit() session.add(db_msg)
session.commit()
# 构建消息列表 # 构建消息列表
messages = [{"role": m.role, "content": m.content} for m in data.messages] messages = [{"role": m.role, "content": m.content} for m in data.messages]
# 构建上下文摘要(前端展示用) # 构建上下文
context_preview = [{"role": m["role"], "content": m["content"][:200]} for m in messages] use_tools = bool(data.tools_enabled)
system_prompt = _build_system_prompt(
data.novel_id, session, data.page_context, use_tools
)
tools = _get_tools(config.provider) if use_tools else None
async def generate(): async def generate():
# 先发送上下文预览 nonlocal messages
yield f"data: {json.dumps({'context': context_preview})}\n\n"
full_response = data.assistant_text or ""
usage_info: dict = {}
# 记录本次对话中的工具调用摘要(用于保存到 DB
tool_call_logs: list[str] = []
full_response = ""
usage_info = {}
try: try:
async for event in stream_chat(config, messages): # ── 阶段一:如果有待审批的工具调用,先执行它们 ──
if event.text: if data.pending_tool_calls:
full_response += event.text tool_calls = [
yield f"data: {json.dumps({'content': event.text})}\n\n" ToolCall(id=tc.id, name=tc.name, arguments=tc.arguments)
if event.usage: for tc in data.pending_tool_calls
# 累加usage ]
for k, v in event.usage.items():
if v: # 构建 assistant 消息(含工具调用)
usage_info[k] = usage_info.get(k, 0) + v assistant_msg = build_assistant_message(
except Exception as e: config.provider, data.assistant_text or "", tool_calls
yield f"data: {json.dumps({'error': str(e)})}\n\n" )
finally: messages.append(assistant_msg)
# 保存AI回复
if full_response: # 执行每个工具,发送结果
with Session(engine) as s: tool_results = []
ai_msg = ChatMessage( for tc in tool_calls:
novel_id=data.novel_id, label = TOOL_LABELS.get(tc.name, tc.name)
role="assistant", result = execute_tool(tc.name, tc.arguments, data.novel_id)
content=full_response, 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
) )
s.add(ai_msg) messages.append(assistant_msg)
s.commit()
yield f"data: {json.dumps({'done': True, 'usage': usage_info or None})}\n\n" 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( return StreamingResponse(
generate(), generate(),
@@ -85,8 +284,8 @@ async def chat_stream(
async def _error_stream(message: str): async def _error_stream(message: str):
yield f"data: {json.dumps({'error': message})}\n\n" yield _sse({"error": message})
yield f"data: {json.dumps({'done': True})}\n\n" yield _sse({"done": True})
@router.get("/messages", response_model=ChatMessageList) @router.get("/messages", response_model=ChatMessageList)
@@ -108,7 +307,6 @@ def list_messages(
items = session.exec( items = session.exec(
query.order_by(ChatMessage.created_at.desc()).limit(limit) query.order_by(ChatMessage.created_at.desc()).limit(limit)
).all() ).all()
# 返回时按时间正序
items = list(reversed(items)) items = list(reversed(items))
return ChatMessageList( return ChatMessageList(
items=[ChatMessageRead.model_validate(m, from_attributes=True) for m in items], items=[ChatMessageRead.model_validate(m, from_attributes=True) for m in items],
@@ -130,3 +328,84 @@ def clear_messages(
for msg in messages: for msg in messages:
session.delete(msg) session.delete(msg)
session.commit() 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),
}

View File

@@ -32,24 +32,62 @@ def _get_outline_or_404(
return outline return outline
def _build_outline_read(outline: Outline, children: list[Outline]) -> OutlineRead:
"""将 Outline ORM 对象转为 OutlineRead附带子节点列表"""
return OutlineRead(
id=outline.id,
novel_id=outline.novel_id,
parent_id=outline.parent_id,
sort_order=outline.sort_order,
summary=outline.summary,
detail=outline.detail,
children=[
OutlineRead(
id=c.id,
novel_id=c.novel_id,
parent_id=c.parent_id,
sort_order=c.sort_order,
summary=c.summary,
detail=c.detail,
children=[],
created_at=c.created_at,
updated_at=c.updated_at,
)
for c in children
],
created_at=outline.created_at,
updated_at=outline.updated_at,
)
@router.get("", response_model=OutlineList) @router.get("", response_model=OutlineList)
def list_outlines( def list_outlines(
novel_id: int, novel_id: int,
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
_get_novel_or_404(novel_id, session) _get_novel_or_404(novel_id, session)
total = session.exec( # 只查顶级节点parent_id IS NULL
select(func.count(Outline.id)).where(Outline.novel_id == novel_id) top_items = session.exec(
).one()
items = session.exec(
select(Outline) select(Outline)
.where(Outline.novel_id == novel_id) .where(Outline.novel_id == novel_id, Outline.parent_id.is_(None)) # type: ignore
.order_by(Outline.sort_order) .order_by(Outline.sort_order)
).all() ).all()
return OutlineList(
items=[OutlineRead.model_validate(o, from_attributes=True) for o in items], # 查询所有子节点,按 parent_id 分组
total=total, all_children = session.exec(
) select(Outline)
.where(Outline.novel_id == novel_id, Outline.parent_id.isnot(None)) # type: ignore
.order_by(Outline.sort_order)
).all()
children_map: dict[int, list[Outline]] = {}
for child in all_children:
children_map.setdefault(child.parent_id, []).append(child)
items = [
_build_outline_read(o, children_map.get(o.id, []))
for o in top_items
]
return OutlineList(items=items, total=len(items))
@router.post("", response_model=OutlineRead, status_code=201) @router.post("", response_model=OutlineRead, status_code=201)
@@ -59,17 +97,41 @@ def create_outline(
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
_get_novel_or_404(novel_id, session) _get_novel_or_404(novel_id, session)
# 自动设置 sort_order 为当前最大值 + 1
max_order = session.exec( # 如果指定了 parent_id校验父节点存在且是顶级节点
select(func.max(Outline.sort_order)).where(Outline.novel_id == novel_id) if data.parent_id is not None:
).one() parent = session.get(Outline, data.parent_id)
if not parent or parent.novel_id != novel_id:
raise HTTPException(status_code=404, detail="父节点不存在")
if parent.parent_id is not None:
raise HTTPException(status_code=400, detail="仅支持两级结构,不能在子节点下创建子节点")
# 同级内自增 sort_order同 parent_id 下的 max + 1
if data.parent_id is not None:
max_order = session.exec(
select(func.max(Outline.sort_order))
.where(Outline.novel_id == novel_id, Outline.parent_id == data.parent_id)
).one()
else:
max_order = session.exec(
select(func.max(Outline.sort_order))
.where(Outline.novel_id == novel_id, Outline.parent_id.is_(None)) # type: ignore
).one()
next_order = (max_order or 0) + 1 next_order = (max_order or 0) + 1
outline = Outline(novel_id=novel_id, sort_order=next_order, **data.model_dump()) outline = Outline(
novel_id=novel_id,
parent_id=data.parent_id,
sort_order=next_order,
summary=data.summary,
detail=data.detail,
)
session.add(outline) session.add(outline)
session.commit() session.commit()
session.refresh(outline) session.refresh(outline)
return outline
# 返回时附带空 children
return _build_outline_read(outline, [])
@router.put("/reorder", response_model=OutlineList) @router.put("/reorder", response_model=OutlineList)
@@ -78,25 +140,53 @@ def reorder_outlines(
data: OutlineReorder, data: OutlineReorder,
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""仅处理顶级节点排序"""
_get_novel_or_404(novel_id, session) _get_novel_or_404(novel_id, session)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
for idx, oid in enumerate(data.ordered_ids): for idx, oid in enumerate(data.ordered_ids):
outline = _get_outline_or_404(oid, novel_id, session) outline = _get_outline_or_404(oid, novel_id, session)
if outline.parent_id is not None:
raise HTTPException(status_code=400, detail=f"节点 {oid} 不是顶级节点,不能在此排序")
outline.sort_order = idx + 1 outline.sort_order = idx + 1
outline.updated_at = now outline.updated_at = now
session.add(outline) session.add(outline)
session.commit() session.commit()
items = session.exec( # 重新查询并返回嵌套结构
return list_outlines(novel_id, session)
@router.put("/{outline_id}/children/reorder", response_model=OutlineRead)
def reorder_children(
novel_id: int,
outline_id: int,
data: OutlineReorder,
session: Session = Depends(get_session),
):
"""子节点排序"""
_get_novel_or_404(novel_id, session)
parent = _get_outline_or_404(outline_id, novel_id, session)
if parent.parent_id is not None:
raise HTTPException(status_code=400, detail="只有顶级节点可以排序子节点")
now = datetime.now(timezone.utc)
for idx, cid in enumerate(data.ordered_ids):
child = _get_outline_or_404(cid, novel_id, session)
if child.parent_id != outline_id:
raise HTTPException(status_code=400, detail=f"节点 {cid} 不是该父节点的子节点")
child.sort_order = idx + 1
child.updated_at = now
session.add(child)
session.commit()
# 查询更新后的子节点
children = session.exec(
select(Outline) select(Outline)
.where(Outline.novel_id == novel_id) .where(Outline.novel_id == novel_id, Outline.parent_id == outline_id)
.order_by(Outline.sort_order) .order_by(Outline.sort_order)
).all() ).all()
total = len(items) session.refresh(parent)
return OutlineList( return _build_outline_read(parent, list(children))
items=[OutlineRead.model_validate(o, from_attributes=True) for o in items],
total=total,
)
@router.get("/{outline_id}", response_model=OutlineRead) @router.get("/{outline_id}", response_model=OutlineRead)
@@ -105,7 +195,14 @@ def get_outline(
outline_id: int, outline_id: int,
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
return _get_outline_or_404(outline_id, novel_id, session) outline = _get_outline_or_404(outline_id, novel_id, session)
# 查询子节点
children = session.exec(
select(Outline)
.where(Outline.novel_id == novel_id, Outline.parent_id == outline_id)
.order_by(Outline.sort_order)
).all()
return _build_outline_read(outline, list(children))
@router.patch("/{outline_id}", response_model=OutlineRead) @router.patch("/{outline_id}", response_model=OutlineRead)
@@ -123,7 +220,14 @@ def update_outline(
session.add(outline) session.add(outline)
session.commit() session.commit()
session.refresh(outline) session.refresh(outline)
return outline
# 查询子节点
children = session.exec(
select(Outline)
.where(Outline.novel_id == novel_id, Outline.parent_id == outline_id)
.order_by(Outline.sort_order)
).all()
return _build_outline_read(outline, list(children))
@router.delete("/{outline_id}", status_code=204) @router.delete("/{outline_id}", status_code=204)
@@ -133,5 +237,12 @@ def delete_outline(
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
outline = _get_outline_or_404(outline_id, novel_id, session) outline = _get_outline_or_404(outline_id, novel_id, session)
# 删除父节点时先删除所有子节点
children = session.exec(
select(Outline)
.where(Outline.novel_id == novel_id, Outline.parent_id == outline_id)
).all()
for child in children:
session.delete(child)
session.delete(outline) session.delete(outline)
session.commit() session.commit()

View File

@@ -0,0 +1,56 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from ..database import get_session
from ..models import Novel, WorldSetting
from ..schemas import WorldSettingUpdate, WorldSettingRead
router = APIRouter(prefix="/api/novels/{novel_id}/world-setting", tags=["world-setting"])
def _get_novel_or_404(novel_id: int, session: Session) -> Novel:
novel = session.get(Novel, novel_id)
if not novel:
raise HTTPException(status_code=404, detail="小说不存在")
return novel
@router.get("", response_model=WorldSettingRead)
def get_world_setting(
novel_id: int,
session: Session = Depends(get_session),
):
_get_novel_or_404(novel_id, session)
ws = session.exec(
select(WorldSetting).where(WorldSetting.novel_id == novel_id)
).first()
if not ws:
# 不存在则自动创建空记录
ws = WorldSetting(novel_id=novel_id, content="")
session.add(ws)
session.commit()
session.refresh(ws)
return ws
@router.put("", response_model=WorldSettingRead)
def save_world_setting(
novel_id: int,
data: WorldSettingUpdate,
session: Session = Depends(get_session),
):
_get_novel_or_404(novel_id, session)
ws = session.exec(
select(WorldSetting).where(WorldSetting.novel_id == novel_id)
).first()
if ws:
ws.content = data.content
ws.updated_at = datetime.now(timezone.utc)
else:
ws = WorldSetting(novel_id=novel_id, content=data.content)
session.add(ws)
session.commit()
session.refresh(ws)
return ws

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
from datetime import datetime from datetime import datetime
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -32,6 +34,7 @@ class NovelList(BaseModel):
class OutlineCreate(BaseModel): class OutlineCreate(BaseModel):
summary: str = Field(min_length=1, max_length=200) summary: str = Field(min_length=1, max_length=200)
detail: str = Field(default="", max_length=1000) detail: str = Field(default="", max_length=1000)
parent_id: int | None = None
class OutlineUpdate(BaseModel): class OutlineUpdate(BaseModel):
@@ -43,9 +46,11 @@ class OutlineUpdate(BaseModel):
class OutlineRead(BaseModel): class OutlineRead(BaseModel):
id: int id: int
novel_id: int novel_id: int
parent_id: int | None = None
sort_order: int sort_order: int
summary: str summary: str
detail: str detail: str
children: list[OutlineRead] = []
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@@ -60,6 +65,82 @@ class OutlineReorder(BaseModel):
ordered_ids: list[int] ordered_ids: list[int]
# ── 角色 ──
class CharacterCreate(BaseModel):
name: str = Field(min_length=1, max_length=100)
description: str = Field(default="", max_length=1000)
class CharacterUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=100)
description: str | None = Field(default=None, max_length=1000)
class CharacterRead(BaseModel):
id: int
novel_id: int
name: str
description: str
created_at: datetime
updated_at: datetime
class CharacterList(BaseModel):
items: list[CharacterRead]
total: int
# ── 章节 ──
class ChapterCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
content: str = Field(default="", max_length=5000)
class ChapterUpdate(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=200)
content: str | None = Field(default=None, max_length=5000)
sort_order: int | None = None
class ChapterRead(BaseModel):
id: int
novel_id: int
sort_order: int
title: str
content: str
created_at: datetime
updated_at: datetime
class ChapterList(BaseModel):
items: list[ChapterRead]
total: int
class ChapterReorder(BaseModel):
"""排序请求按顺序传入章节id列表"""
ordered_ids: list[int]
# ── 世界观 ──
class WorldSettingUpdate(BaseModel):
content: str = Field(default="", max_length=5000)
class WorldSettingRead(BaseModel):
id: int
novel_id: int
content: str
created_at: datetime
updated_at: datetime
# ── AI配置 ── # ── AI配置 ──
@@ -87,9 +168,22 @@ class ChatMessageInput(BaseModel):
content: str content: str
class PendingToolCall(BaseModel):
"""待审批的工具调用"""
id: str
name: str
arguments: dict
class ChatRequest(BaseModel): class ChatRequest(BaseModel):
messages: list[ChatMessageInput] messages: list[ChatMessageInput]
novel_id: int | None = None novel_id: int | None = None
page_context: str | None = None # 用户当前所在页面,如 "大纲页" "世界观页"
tools_enabled: bool = True # 是否启用工具调用
auto_approve_tools: bool = False # 是否自动执行工具(无需人工审批)
# 工具审批续传
pending_tool_calls: list[PendingToolCall] | None = None
assistant_text: str | None = None # LLM 在工具调用前输出的文本
class ChatMessageRead(BaseModel): class ChatMessageRead(BaseModel):
@@ -103,3 +197,7 @@ class ChatMessageRead(BaseModel):
class ChatMessageList(BaseModel): class ChatMessageList(BaseModel):
items: list[ChatMessageRead] items: list[ChatMessageRead]
total: int total: int
# 解决 OutlineRead 自引用
OutlineRead.model_rebuild()

View File

@@ -1,6 +1,8 @@
"""AI Provider 抽象层,支持 OpenAI 兼容 API 和 Anthropic 原生 API。""" """AI Provider 抽象层,支持 OpenAI 兼容 API 和 Anthropic 原生 API。
支持文本流式输出和 tool calling。"""
import json import json
from dataclasses import dataclass, field
from typing import AsyncGenerator from typing import AsyncGenerator
import httpx import httpx
@@ -8,33 +10,168 @@ import httpx
from ..models import AIConfig from ..models import AIConfig
@dataclass
class ToolCall:
"""一次工具调用"""
id: str
name: str
arguments: dict
class StreamEvent: class StreamEvent:
"""流式事件文本chunkusage信息""" """流式事件文本chunkusage信息、或工具调用"""
def __init__(self, text: str = "", usage: dict | None = None): def __init__(
self,
text: str = "",
usage: dict | None = None,
tool_calls: list[ToolCall] | None = None,
):
self.text = text self.text = text
self.usage = usage self.usage = usage
self.tool_calls = tool_calls
async def stream_chat( async def stream_chat(
config: AIConfig, config: AIConfig,
messages: list[dict], messages: list[dict],
system_prompt: str = "", system_prompt: str = "",
tools: list[dict] | None = None,
) -> AsyncGenerator[StreamEvent, None]: ) -> AsyncGenerator[StreamEvent, None]:
"""根据 provider 类型调用对应的流式 API返回 StreamEvent。""" """根据 provider 类型调用对应的流式 API返回 StreamEvent。"""
if config.provider == "anthropic": if config.provider == "anthropic":
async for event in _stream_anthropic(config, messages, system_prompt): async for event in _stream_anthropic(config, messages, system_prompt, tools):
yield event yield event
else: else:
async for event in _stream_openai(config, messages, system_prompt): async for event in _stream_openai(config, messages, system_prompt, tools):
yield event yield event
# ── 消息格式构建(用于 tool call 循环中追加消息)──
def build_assistant_message(provider: str, text: str, tool_calls: list[ToolCall]) -> dict:
"""构建包含工具调用的 assistant 消息"""
if provider == "anthropic":
content = []
if text:
content.append({"type": "text", "text": text})
for tc in tool_calls:
content.append({
"type": "tool_use",
"id": tc.id,
"name": tc.name,
"input": tc.arguments,
})
return {"role": "assistant", "content": content}
else:
# OpenAI 格式
msg: dict = {
"role": "assistant",
"content": text or None,
"tool_calls": [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.name,
"arguments": json.dumps(tc.arguments, ensure_ascii=False),
},
}
for tc in tool_calls
],
}
return msg
def build_tool_results_messages(
provider: str, results: list[dict],
) -> list[dict]:
"""构建工具结果消息。results: [{"id": str, "result": str}]
OpenAI: 返回多条 tool message
Anthropic: 返回一条 user messagecontent 是 tool_result 数组
"""
if provider == "anthropic":
content = [
{"type": "tool_result", "tool_use_id": r["id"], "content": r["result"]}
for r in results
]
return [{"role": "user", "content": content}]
else:
return [
{"role": "tool", "tool_call_id": r["id"], "content": r["result"]}
for r in results
]
async def simple_completion(
config: AIConfig,
messages: list[dict],
system_prompt: str = "",
) -> str:
"""非流式调用,返回完整文本。用于摘要压缩等场景。"""
if config.provider == "anthropic":
return await _simple_anthropic(config, messages, system_prompt)
return await _simple_openai(config, messages, system_prompt)
async def _simple_openai(config: AIConfig, messages: list[dict], system_prompt: str) -> str:
url = f"{config.base_url.rstrip('/')}/chat/completions"
payload_messages = []
if system_prompt:
payload_messages.append({"role": "system", "content": system_prompt})
payload_messages.extend(messages)
body = {"model": config.model, "messages": payload_messages, "stream": False}
async with httpx.AsyncClient(timeout=120) as client:
resp = await client.post(
url,
headers={"Authorization": f"Bearer {config.api_key}", "Content-Type": "application/json"},
json=body,
)
resp.raise_for_status()
data = resp.json()
return data["choices"][0]["message"]["content"] or ""
async def _simple_anthropic(config: AIConfig, messages: list[dict], system_prompt: str) -> str:
url = f"{config.base_url.rstrip('/')}/messages"
body: dict = {
"model": config.model,
"max_tokens": 4096,
"messages": messages,
"stream": False,
}
if system_prompt:
body["system"] = system_prompt
async with httpx.AsyncClient(timeout=120) as client:
resp = await client.post(
url,
headers={
"x-api-key": config.api_key,
"anthropic-version": "2023-06-01",
"Content-Type": "application/json",
},
json=body,
)
resp.raise_for_status()
data = resp.json()
for block in data.get("content", []):
if block.get("type") == "text":
return block["text"]
return ""
# ── OpenAI 兼容 ──
async def _stream_openai( async def _stream_openai(
config: AIConfig, config: AIConfig,
messages: list[dict], messages: list[dict],
system_prompt: str, system_prompt: str,
tools: list[dict] | None = None,
) -> AsyncGenerator[StreamEvent, None]: ) -> AsyncGenerator[StreamEvent, None]:
"""调用 OpenAI 兼容 APIstream=true""" """调用 OpenAI 兼容 APIstream=true,支持 tool calling"""
url = f"{config.base_url.rstrip('/')}/chat/completions" url = f"{config.base_url.rstrip('/')}/chat/completions"
payload_messages = [] payload_messages = []
@@ -42,12 +179,14 @@ async def _stream_openai(
payload_messages.append({"role": "system", "content": system_prompt}) payload_messages.append({"role": "system", "content": system_prompt})
payload_messages.extend(messages) payload_messages.extend(messages)
body = { body: dict = {
"model": config.model, "model": config.model,
"messages": payload_messages, "messages": payload_messages,
"stream": True, "stream": True,
"stream_options": {"include_usage": True}, "stream_options": {"include_usage": True},
} }
if tools:
body["tools"] = tools
async with httpx.AsyncClient(timeout=120) as client: async with httpx.AsyncClient(timeout=120) as client:
async with client.stream( async with client.stream(
@@ -72,6 +211,9 @@ async def _stream_openai(
if "text/html" in ct: if "text/html" in ct:
raise RuntimeError(f"API地址返回了HTML页面请检查API地址是否正确当前: {url}") raise RuntimeError(f"API地址返回了HTML页面请检查API地址是否正确当前: {url}")
# 累积工具调用
pending_tool_calls: dict[int, dict] = {} # index -> {id, name, arguments}
async for line in resp.aiter_lines(): async for line in resp.aiter_lines():
if not line.startswith("data: "): if not line.startswith("data: "):
continue continue
@@ -80,7 +222,8 @@ async def _stream_openai(
break break
try: try:
chunk = json.loads(data) chunk = json.loads(data)
# 提取usageOpenAI在最后一个chunk返回
# 提取 usage
usage = chunk.get("usage") usage = chunk.get("usage")
if usage: if usage:
yield StreamEvent(usage={ yield StreamEvent(usage={
@@ -88,22 +231,63 @@ async def _stream_openai(
"completion_tokens": usage.get("completion_tokens", 0), "completion_tokens": usage.get("completion_tokens", 0),
"total_tokens": usage.get("total_tokens", 0), "total_tokens": usage.get("total_tokens", 0),
}) })
choices = chunk.get("choices", []) choices = chunk.get("choices", [])
if choices: if not choices:
delta = choices[0].get("delta", {}) continue
content = delta.get("content")
if content: choice = choices[0]
yield StreamEvent(text=content) delta = choice.get("delta", {})
finish_reason = choice.get("finish_reason")
# 文本内容
content = delta.get("content")
if content:
yield StreamEvent(text=content)
# 工具调用 chunk
for tc_delta in delta.get("tool_calls", []):
idx = tc_delta["index"]
if idx not in pending_tool_calls:
pending_tool_calls[idx] = {"id": "", "name": "", "arguments": ""}
entry = pending_tool_calls[idx]
if "id" in tc_delta:
entry["id"] = tc_delta["id"]
func = tc_delta.get("function", {})
if "name" in func:
entry["name"] = func["name"]
if "arguments" in func:
entry["arguments"] += func["arguments"]
# 结束:如果有工具调用,发出事件
if finish_reason == "tool_calls" and pending_tool_calls:
tool_calls = []
for tc_data in pending_tool_calls.values():
try:
args = json.loads(tc_data["arguments"]) if tc_data["arguments"] else {}
except json.JSONDecodeError:
args = {}
tool_calls.append(ToolCall(
id=tc_data["id"],
name=tc_data["name"],
arguments=args,
))
yield StreamEvent(tool_calls=tool_calls)
except (json.JSONDecodeError, KeyError, IndexError): except (json.JSONDecodeError, KeyError, IndexError):
continue continue
# ── Anthropic 原生 ──
async def _stream_anthropic( async def _stream_anthropic(
config: AIConfig, config: AIConfig,
messages: list[dict], messages: list[dict],
system_prompt: str, system_prompt: str,
tools: list[dict] | None = None,
) -> AsyncGenerator[StreamEvent, None]: ) -> AsyncGenerator[StreamEvent, None]:
"""调用 Anthropic Messages APIstream=true""" """调用 Anthropic Messages APIstream=true,支持 tool calling"""
url = f"{config.base_url.rstrip('/')}/messages" url = f"{config.base_url.rstrip('/')}/messages"
body: dict = { body: dict = {
@@ -114,6 +298,8 @@ async def _stream_anthropic(
} }
if system_prompt: if system_prompt:
body["system"] = system_prompt body["system"] = system_prompt
if tools:
body["tools"] = tools
async with httpx.AsyncClient(timeout=120) as client: async with httpx.AsyncClient(timeout=120) as client:
async with client.stream( async with client.stream(
@@ -139,16 +325,39 @@ async def _stream_anthropic(
if "text/html" in ct: if "text/html" in ct:
raise RuntimeError(f"API地址返回了HTML页面请检查API地址是否正确当前: {url}") raise RuntimeError(f"API地址返回了HTML页面请检查API地址是否正确当前: {url}")
# 累积工具调用
pending_tool_uses: dict[int, dict] = {} # block_index -> {id, name, input_json}
stop_reason = None
async for line in resp.aiter_lines(): async for line in resp.aiter_lines():
if not line.startswith("data: "): if not line.startswith("data: "):
continue continue
try: try:
event = json.loads(line[6:]) event = json.loads(line[6:])
if event.get("type") == "content_block_delta": event_type = event.get("type")
text = event.get("delta", {}).get("text", "")
if text: if event_type == "content_block_start":
yield StreamEvent(text=text) block = event.get("content_block", {})
elif event.get("type") == "message_delta": idx = event.get("index", 0)
if block.get("type") == "tool_use":
pending_tool_uses[idx] = {
"id": block["id"],
"name": block["name"],
"input_json": "",
}
elif event_type == "content_block_delta":
idx = event.get("index", 0)
delta = event.get("delta", {})
if delta.get("type") == "text_delta":
text = delta.get("text", "")
if text:
yield StreamEvent(text=text)
elif delta.get("type") == "input_json_delta":
if idx in pending_tool_uses:
pending_tool_uses[idx]["input_json"] += delta.get("partial_json", "")
elif event_type == "message_delta":
usage = event.get("usage", {}) usage = event.get("usage", {})
if usage: if usage:
yield StreamEvent(usage={ yield StreamEvent(usage={
@@ -156,7 +365,9 @@ async def _stream_anthropic(
"completion_tokens": usage.get("output_tokens", 0), "completion_tokens": usage.get("output_tokens", 0),
"total_tokens": 0, "total_tokens": 0,
}) })
elif event.get("type") == "message_start": stop_reason = event.get("delta", {}).get("stop_reason")
elif event_type == "message_start":
msg = event.get("message", {}) msg = event.get("message", {})
usage = msg.get("usage", {}) usage = msg.get("usage", {})
if usage: if usage:
@@ -165,7 +376,24 @@ async def _stream_anthropic(
"completion_tokens": 0, "completion_tokens": 0,
"total_tokens": 0, "total_tokens": 0,
}) })
elif event.get("type") == "message_stop":
elif event_type == "message_stop":
break break
except json.JSONDecodeError: except json.JSONDecodeError:
continue continue
# 如果有工具调用,发出事件
if stop_reason == "tool_use" and pending_tool_uses:
tool_calls = []
for tu_data in pending_tool_uses.values():
try:
args = json.loads(tu_data["input_json"]) if tu_data["input_json"] else {}
except json.JSONDecodeError:
args = {}
tool_calls.append(ToolCall(
id=tu_data["id"],
name=tu_data["name"],
arguments=args,
))
yield StreamEvent(tool_calls=tool_calls)

View File

@@ -0,0 +1,595 @@
"""工具注册表和执行器 —— 小说信息、大纲 & 世界观 CRUD"""
import json
from datetime import datetime, timezone
from sqlmodel import Session, select, func
from ..database import engine
from ..models import Novel, Outline, WorldSetting, Character, Chapter
# ── 工具定义provider 无关格式)──
TOOL_DEFINITIONS: list[dict] = [
{
"name": "get_novel_info",
"description": "获取当前小说的基础信息,包括标题、简介、创建时间等",
"parameters": {
"type": "object",
"properties": {},
},
},
{
"name": "update_novel_info",
"description": "更新当前小说的基础信息(标题或简介)",
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "新的小说标题可选1-100字"},
"description": {"type": "string", "description": "新的小说简介(可选)"},
},
},
},
{
"name": "list_outlines",
"description": "列出当前小说的所有大纲节点,返回每个节点的 id、排序、摘要和详情",
"parameters": {
"type": "object",
"properties": {},
},
},
{
"name": "create_outline",
"description": "为当前小说创建一个新的大纲节点",
"parameters": {
"type": "object",
"properties": {
"summary": {"type": "string", "description": "大纲摘要简短标题200字以内"},
"detail": {"type": "string", "description": "大纲详情可选1000字以内"},
"parent_id": {"type": "integer", "description": "父节点 ID创建子节点时需提供"},
},
"required": ["summary"],
},
},
{
"name": "update_outline",
"description": "更新指定大纲节点的摘要或详情",
"parameters": {
"type": "object",
"properties": {
"outline_id": {"type": "integer", "description": "要更新的大纲节点 ID"},
"summary": {"type": "string", "description": "新的摘要(可选)"},
"detail": {"type": "string", "description": "新的详情(可选)"},
},
"required": ["outline_id"],
},
},
{
"name": "delete_outline",
"description": "删除指定的大纲节点",
"parameters": {
"type": "object",
"properties": {
"outline_id": {"type": "integer", "description": "要删除的大纲节点 ID"},
},
"required": ["outline_id"],
},
},
{
"name": "reorder_outlines",
"description": "调整大纲节点的排列顺序。传入按期望顺序排列的节点 ID 数组即可。可以排序顶级节点,也可以排序某个父节点下的子节点。",
"parameters": {
"type": "object",
"properties": {
"ordered_ids": {
"type": "array",
"items": {"type": "integer"},
"description": "按期望顺序排列的大纲节点 ID 数组",
},
"parent_id": {
"type": "integer",
"description": "父节点 ID。如果排序的是某个父节点下的子节点需提供此参数排序顶级节点时不传",
},
},
"required": ["ordered_ids"],
},
},
{
"name": "get_world_setting",
"description": "获取当前小说的世界观设定内容",
"parameters": {
"type": "object",
"properties": {},
},
},
{
"name": "save_world_setting",
"description": "保存/更新当前小说的世界观设定",
"parameters": {
"type": "object",
"properties": {
"content": {"type": "string", "description": "世界观设定内容5000字以内"},
},
"required": ["content"],
},
},
# ── 角色工具 ──
{
"name": "list_characters",
"description": "列出当前小说的所有角色,返回每个角色的 id、名称和描述",
"parameters": {
"type": "object",
"properties": {},
},
},
{
"name": "create_character",
"description": "为当前小说创建一个新角色",
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "角色名称100字以内"},
"description": {"type": "string", "description": "角色描述外貌、性格、背景等可选1000字以内"},
},
"required": ["name"],
},
},
{
"name": "update_character",
"description": "更新指定角色的名称或描述",
"parameters": {
"type": "object",
"properties": {
"character_id": {"type": "integer", "description": "要更新的角色 ID"},
"name": {"type": "string", "description": "新的角色名称(可选)"},
"description": {"type": "string", "description": "新的角色描述(可选)"},
},
"required": ["character_id"],
},
},
{
"name": "delete_character",
"description": "删除指定的角色",
"parameters": {
"type": "object",
"properties": {
"character_id": {"type": "integer", "description": "要删除的角色 ID"},
},
"required": ["character_id"],
},
},
# ── 章节工具 ──
{
"name": "list_chapters",
"description": "列出当前小说的所有章节,按排序顺序返回每个章节的 id、标题和内容摘要",
"parameters": {
"type": "object",
"properties": {},
},
},
{
"name": "create_chapter",
"description": "为当前小说创建一个新章节",
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "章节标题200字以内"},
"content": {"type": "string", "description": "章节正文可选5000字以内"},
},
"required": ["title"],
},
},
{
"name": "update_chapter",
"description": "更新指定章节的标题或正文内容",
"parameters": {
"type": "object",
"properties": {
"chapter_id": {"type": "integer", "description": "要更新的章节 ID"},
"title": {"type": "string", "description": "新的章节标题(可选)"},
"content": {"type": "string", "description": "新的章节正文(可选)"},
},
"required": ["chapter_id"],
},
},
{
"name": "delete_chapter",
"description": "删除指定的章节",
"parameters": {
"type": "object",
"properties": {
"chapter_id": {"type": "integer", "description": "要删除的章节 ID"},
},
"required": ["chapter_id"],
},
},
]
def get_openai_tools() -> list[dict]:
"""转换为 OpenAI function calling 格式"""
return [
{"type": "function", "function": t}
for t in TOOL_DEFINITIONS
]
def get_anthropic_tools() -> list[dict]:
"""转换为 Anthropic tool_use 格式"""
return [
{"name": t["name"], "description": t["description"], "input_schema": t["parameters"]}
for t in TOOL_DEFINITIONS
]
# ── 工具执行 ──
def execute_tool(name: str, arguments: dict, novel_id: int) -> str:
"""执行工具返回结果字符串JSON"""
with Session(engine) as session:
handlers = {
"get_novel_info": _get_novel_info,
"update_novel_info": _update_novel_info,
"list_outlines": _list_outlines,
"create_outline": _create_outline,
"update_outline": _update_outline,
"delete_outline": _delete_outline,
"reorder_outlines": _reorder_outlines,
"get_world_setting": _get_world_setting,
"save_world_setting": _save_world_setting,
"list_characters": _list_characters,
"create_character": _create_character,
"update_character": _update_character,
"delete_character": _delete_character,
"list_chapters": _list_chapters,
"create_chapter": _create_chapter,
"update_chapter": _update_chapter,
"delete_chapter": _delete_chapter,
}
handler = handlers.get(name)
if not handler:
return json.dumps({"error": f"未知工具: {name}"}, ensure_ascii=False)
try:
return handler(session, novel_id, arguments)
except Exception as e:
return json.dumps({"error": str(e)}, ensure_ascii=False)
def _get_novel_info(session: Session, novel_id: int, _args: dict) -> str:
novel = session.get(Novel, novel_id)
if not novel:
return json.dumps({"error": "小说不存在"}, ensure_ascii=False)
return json.dumps({
"id": novel.id,
"title": novel.title,
"description": novel.description or "",
"created_at": novel.created_at.isoformat() if novel.created_at else None,
"updated_at": novel.updated_at.isoformat() if novel.updated_at else None,
}, ensure_ascii=False)
def _update_novel_info(session: Session, novel_id: int, args: dict) -> str:
novel = session.get(Novel, novel_id)
if not novel:
return json.dumps({"error": "小说不存在"}, ensure_ascii=False)
if "title" in args:
title = args["title"].strip()
if not title or len(title) > 100:
return json.dumps({"error": "标题长度需在1-100字之间"}, ensure_ascii=False)
novel.title = title
if "description" in args:
novel.description = args["description"]
novel.updated_at = datetime.now(timezone.utc)
session.add(novel)
session.commit()
session.refresh(novel)
return json.dumps({
"id": novel.id,
"title": novel.title,
"description": novel.description or "",
"updated": True,
}, ensure_ascii=False)
def _list_outlines(session: Session, novel_id: int, _args: dict) -> str:
# 查询顶级节点
top_items = session.exec(
select(Outline)
.where(Outline.novel_id == novel_id, Outline.parent_id.is_(None)) # type: ignore
.order_by(Outline.sort_order)
).all()
# 查询所有子节点,按 parent_id 分组
all_children = session.exec(
select(Outline)
.where(Outline.novel_id == novel_id, Outline.parent_id.isnot(None)) # type: ignore
.order_by(Outline.sort_order)
).all()
children_map: dict[int, list[Outline]] = {}
for child in all_children:
children_map.setdefault(child.parent_id, []).append(child)
result = []
for o in top_items:
node = {"id": o.id, "sort_order": o.sort_order, "summary": o.summary, "detail": o.detail}
kids = children_map.get(o.id, [])
if kids:
node["children"] = [
{"id": c.id, "sort_order": c.sort_order, "summary": c.summary, "detail": c.detail, "parent_id": c.parent_id}
for c in kids
]
result.append(node)
return json.dumps({"outlines": result, "total": len(result)}, ensure_ascii=False)
def _create_outline(session: Session, novel_id: int, args: dict) -> str:
parent_id = args.get("parent_id")
# 如果指定了 parent_id校验父节点存在且是顶级节点
if parent_id is not None:
parent = session.get(Outline, parent_id)
if not parent or parent.novel_id != novel_id:
return json.dumps({"error": "父节点不存在"}, ensure_ascii=False)
if parent.parent_id is not None:
return json.dumps({"error": "仅支持两级结构,不能在子节点下创建子节点"}, ensure_ascii=False)
# 同级内自增 sort_order
if parent_id is not None:
max_order = session.exec(
select(func.max(Outline.sort_order))
.where(Outline.novel_id == novel_id, Outline.parent_id == parent_id)
).one()
else:
max_order = session.exec(
select(func.max(Outline.sort_order))
.where(Outline.novel_id == novel_id, Outline.parent_id.is_(None)) # type: ignore
).one()
next_order = (max_order or 0) + 1
outline = Outline(
novel_id=novel_id,
parent_id=parent_id,
sort_order=next_order,
summary=args["summary"],
detail=args.get("detail", ""),
)
session.add(outline)
session.commit()
session.refresh(outline)
result = {"id": outline.id, "sort_order": outline.sort_order, "summary": outline.summary, "detail": outline.detail}
if parent_id is not None:
result["parent_id"] = parent_id
return json.dumps(result, ensure_ascii=False)
def _update_outline(session: Session, novel_id: int, args: dict) -> str:
outline = session.get(Outline, args["outline_id"])
if not outline or outline.novel_id != novel_id:
return json.dumps({"error": "大纲不存在"}, ensure_ascii=False)
if "summary" in args:
outline.summary = args["summary"]
if "detail" in args:
outline.detail = args["detail"]
outline.updated_at = datetime.now(timezone.utc)
session.add(outline)
session.commit()
session.refresh(outline)
return json.dumps(
{"id": outline.id, "summary": outline.summary, "detail": outline.detail},
ensure_ascii=False,
)
def _delete_outline(session: Session, novel_id: int, args: dict) -> str:
outline = session.get(Outline, args["outline_id"])
if not outline or outline.novel_id != novel_id:
return json.dumps({"error": "大纲不存在"}, ensure_ascii=False)
# 删除父节点时先删除所有子节点
children = session.exec(
select(Outline)
.where(Outline.novel_id == novel_id, Outline.parent_id == outline.id)
).all()
for child in children:
session.delete(child)
session.delete(outline)
session.commit()
return json.dumps({"deleted": True, "id": args["outline_id"]}, ensure_ascii=False)
def _reorder_outlines(session: Session, novel_id: int, args: dict) -> str:
ordered_ids = args["ordered_ids"]
parent_id = args.get("parent_id")
now = datetime.now(timezone.utc)
for idx, oid in enumerate(ordered_ids):
outline = session.get(Outline, oid)
if not outline or outline.novel_id != novel_id:
return json.dumps({"error": f"大纲节点 {oid} 不存在"}, ensure_ascii=False)
# 校验层级一致性
if parent_id is None and outline.parent_id is not None:
return json.dumps({"error": f"节点 {oid} 不是顶级节点"}, ensure_ascii=False)
if parent_id is not None and outline.parent_id != parent_id:
return json.dumps({"error": f"节点 {oid} 不属于父节点 {parent_id}"}, ensure_ascii=False)
outline.sort_order = idx + 1
outline.updated_at = now
session.add(outline)
session.commit()
return json.dumps({
"reordered": True,
"count": len(ordered_ids),
"parent_id": parent_id,
}, ensure_ascii=False)
def _get_world_setting(session: Session, novel_id: int, _args: dict) -> str:
ws = session.exec(
select(WorldSetting).where(WorldSetting.novel_id == novel_id)
).first()
if not ws:
return json.dumps({"content": "", "exists": False}, ensure_ascii=False)
return json.dumps({"content": ws.content, "exists": True}, ensure_ascii=False)
def _save_world_setting(session: Session, novel_id: int, args: dict) -> str:
ws = session.exec(
select(WorldSetting).where(WorldSetting.novel_id == novel_id)
).first()
now = datetime.now(timezone.utc)
if ws:
ws.content = args["content"]
ws.updated_at = now
else:
ws = WorldSetting(novel_id=novel_id, content=args["content"])
session.add(ws)
session.commit()
session.refresh(ws)
return json.dumps({"saved": True, "content_length": len(ws.content)}, ensure_ascii=False)
# ── 角色处理函数 ──
def _list_characters(session: Session, novel_id: int, _args: dict) -> str:
items = session.exec(
select(Character)
.where(Character.novel_id == novel_id)
.order_by(Character.created_at)
).all()
result = [
{"id": c.id, "name": c.name, "description": c.description}
for c in items
]
return json.dumps({"characters": result, "total": len(result)}, ensure_ascii=False)
def _create_character(session: Session, novel_id: int, args: dict) -> str:
name = args["name"].strip()
if not name or len(name) > 100:
return json.dumps({"error": "角色名称长度需在1-100字之间"}, ensure_ascii=False)
char = Character(
novel_id=novel_id,
name=name,
description=args.get("description", ""),
)
session.add(char)
session.commit()
session.refresh(char)
return json.dumps(
{"id": char.id, "name": char.name, "description": char.description},
ensure_ascii=False,
)
def _update_character(session: Session, novel_id: int, args: dict) -> str:
char = session.get(Character, args["character_id"])
if not char or char.novel_id != novel_id:
return json.dumps({"error": "角色不存在"}, ensure_ascii=False)
if "name" in args:
name = args["name"].strip()
if not name or len(name) > 100:
return json.dumps({"error": "角色名称长度需在1-100字之间"}, ensure_ascii=False)
char.name = name
if "description" in args:
char.description = args["description"]
char.updated_at = datetime.now(timezone.utc)
session.add(char)
session.commit()
session.refresh(char)
return json.dumps(
{"id": char.id, "name": char.name, "description": char.description},
ensure_ascii=False,
)
def _delete_character(session: Session, novel_id: int, args: dict) -> str:
char = session.get(Character, args["character_id"])
if not char or char.novel_id != novel_id:
return json.dumps({"error": "角色不存在"}, ensure_ascii=False)
session.delete(char)
session.commit()
return json.dumps({"deleted": True, "id": args["character_id"]}, ensure_ascii=False)
# ── 章节处理函数 ──
def _list_chapters(session: Session, novel_id: int, _args: dict) -> str:
items = session.exec(
select(Chapter)
.where(Chapter.novel_id == novel_id)
.order_by(Chapter.sort_order)
).all()
result = [
{
"id": c.id,
"sort_order": c.sort_order,
"title": c.title,
"content_preview": c.content[:100] + "" if len(c.content) > 100 else c.content,
}
for c in items
]
return json.dumps({"chapters": result, "total": len(result)}, ensure_ascii=False)
def _create_chapter(session: Session, novel_id: int, args: dict) -> str:
title = args["title"].strip()
if not title or len(title) > 200:
return json.dumps({"error": "章节标题长度需在1-200字之间"}, ensure_ascii=False)
# 自增 sort_order
max_order = session.exec(
select(func.max(Chapter.sort_order))
.where(Chapter.novel_id == novel_id)
).one()
next_order = (max_order or 0) + 1
chapter = Chapter(
novel_id=novel_id,
sort_order=next_order,
title=title,
content=args.get("content", ""),
)
session.add(chapter)
session.commit()
session.refresh(chapter)
return json.dumps(
{"id": chapter.id, "sort_order": chapter.sort_order, "title": chapter.title},
ensure_ascii=False,
)
def _update_chapter(session: Session, novel_id: int, args: dict) -> str:
chapter = session.get(Chapter, args["chapter_id"])
if not chapter or chapter.novel_id != novel_id:
return json.dumps({"error": "章节不存在"}, ensure_ascii=False)
if "title" in args:
title = args["title"].strip()
if not title or len(title) > 200:
return json.dumps({"error": "章节标题长度需在1-200字之间"}, ensure_ascii=False)
chapter.title = title
if "content" in args:
chapter.content = args["content"]
chapter.updated_at = datetime.now(timezone.utc)
session.add(chapter)
session.commit()
session.refresh(chapter)
return json.dumps(
{"id": chapter.id, "title": chapter.title, "content_length": len(chapter.content)},
ensure_ascii=False,
)
def _delete_chapter(session: Session, novel_id: int, args: dict) -> str:
chapter = session.get(Chapter, args["chapter_id"])
if not chapter or chapter.novel_id != novel_id:
return json.dumps({"error": "章节不存在"}, ensure_ascii=False)
session.delete(chapter)
session.commit()
return json.dumps({"deleted": True, "id": args["chapter_id"]}, ensure_ascii=False)

17
docker-compose.yml Normal file
View File

@@ -0,0 +1,17 @@
services:
noval:
build: .
container_name: noval
ports:
- "80:8000"
expose:
- "8000"
volumes:
# SQLite 数据持久化
- noval-data:/app/data
environment:
- TZ=Asia/Shanghai
restart: unless-stopped
volumes:
noval-data:

View File

@@ -0,0 +1,33 @@
import { http } from './client'
import type { Chapter, ChapterCreate, ChapterUpdate, ChapterList } from '@/types/chapter'
const base = (novelId: number) => `/novels/${novelId}/chapters`
export async function fetchChapters(novelId: number): Promise<ChapterList> {
const { data } = await http.get<ChapterList>(base(novelId))
return data
}
export async function fetchChapter(novelId: number, chapterId: number): Promise<Chapter> {
const { data } = await http.get<Chapter>(`${base(novelId)}/${chapterId}`)
return data
}
export async function createChapter(novelId: number, payload: ChapterCreate): Promise<Chapter> {
const { data } = await http.post<Chapter>(base(novelId), payload)
return data
}
export async function updateChapter(novelId: number, chapterId: number, payload: ChapterUpdate): Promise<Chapter> {
const { data } = await http.patch<Chapter>(`${base(novelId)}/${chapterId}`, payload)
return data
}
export async function deleteChapter(novelId: number, chapterId: number): Promise<void> {
await http.delete(`${base(novelId)}/${chapterId}`)
}
export async function reorderChapters(novelId: number, orderedIds: number[]): Promise<ChapterList> {
const { data } = await http.put<ChapterList>(`${base(novelId)}/reorder`, { ordered_ids: orderedIds })
return data
}

View File

@@ -0,0 +1,23 @@
import { http } from './client'
import type { Character, CharacterCreate, CharacterUpdate, CharacterList } from '@/types/character'
const base = (novelId: number) => `/novels/${novelId}/characters`
export async function fetchCharacters(novelId: number): Promise<CharacterList> {
const { data } = await http.get<CharacterList>(base(novelId))
return data
}
export async function createCharacter(novelId: number, payload: CharacterCreate): Promise<Character> {
const { data } = await http.post<Character>(base(novelId), payload)
return data
}
export async function updateCharacter(novelId: number, characterId: number, payload: CharacterUpdate): Promise<Character> {
const { data } = await http.patch<Character>(`${base(novelId)}/${characterId}`, payload)
return data
}
export async function deleteCharacter(novelId: number, characterId: number): Promise<void> {
await http.delete(`${base(novelId)}/${characterId}`)
}

View File

@@ -1,5 +1,5 @@
import { http } from './client' import { http } from './client'
import type { AIConfig, AIConfigSave, ChatMessageList } from '@/types/chat' import type { AIConfig, AIConfigSave, ChatMessageList, ToolCallEntry } from '@/types/chat'
// ── AI配置 ── // ── AI配置 ──
@@ -33,6 +33,22 @@ export async function clearMessages(novelId?: number): Promise<void> {
await http.delete('/chat/messages', { params }) await http.delete('/chat/messages', { params })
} }
export interface CompressResult {
compressed: boolean
reason?: string
error?: string
removed_count?: number
kept_count?: number
summary_length?: number
}
export async function compressContext(novelId?: number): Promise<CompressResult> {
const params: Record<string, unknown> = {}
if (novelId !== undefined) params.novel_id = novelId
const { data } = await http.post<CompressResult>('/chat/compress', null, { params })
return data
}
// ── 流式聊天 ── // ── 流式聊天 ──
export interface TokenUsage { export interface TokenUsage {
@@ -41,27 +57,54 @@ export interface TokenUsage {
total_tokens: number total_tokens: number
} }
export interface ContextMessage { export interface ToolResultInfo {
role: string name: string
content: string label: string
result: string
}
export interface PendingToolCallData {
id: string
name: string
label: string
arguments: Record<string, unknown>
} }
export interface StreamChatOptions { export interface StreamChatOptions {
messages: { role: string; content: string }[] messages: { role: string; content: string }[]
novelId?: number novelId?: number
pageContext?: string
toolsEnabled?: boolean
autoApproveTools?: boolean
// 工具审批续传
pendingToolCalls?: PendingToolCallData[]
assistantText?: string
// 回调
onChunk: (text: string) => void onChunk: (text: string) => void
onError: (error: string) => void onError: (error: string) => void
onDone: (usage?: TokenUsage) => void onDone: (usage?: TokenUsage, pending?: boolean) => void
onContext?: (context: ContextMessage[]) => void onToolCallsPending?: (calls: PendingToolCallData[], assistantText: string) => void
onToolCallsAuto?: (calls: PendingToolCallData[]) => void
onToolResult?: (info: ToolResultInfo) => void
signal?: AbortSignal signal?: AbortSignal
} }
export async function streamChat(options: StreamChatOptions): Promise<void> { export async function streamChat(options: StreamChatOptions): Promise<void> {
const { messages, novelId, onChunk, onError, onDone, onContext, signal } = options const {
messages, novelId, pageContext, toolsEnabled, autoApproveTools,
pendingToolCalls, assistantText,
onChunk, onError, onDone, onToolCallsPending, onToolCallsAuto, onToolResult,
signal,
} = options
const body = JSON.stringify({ const body = JSON.stringify({
messages, messages,
novel_id: novelId ?? null, novel_id: novelId ?? null,
page_context: pageContext ?? null,
tools_enabled: toolsEnabled ?? true,
auto_approve_tools: autoApproveTools ?? false,
pending_tool_calls: pendingToolCalls ?? null,
assistant_text: assistantText ?? null,
}) })
const resp = await fetch('/api/chat/stream', { const resp = await fetch('/api/chat/stream', {
@@ -95,7 +138,7 @@ export async function streamChat(options: StreamChatOptions): Promise<void> {
try { try {
const event = JSON.parse(line.slice(6)) const event = JSON.parse(line.slice(6))
if (event.done) { if (event.done) {
onDone(event.usage ?? undefined) onDone(event.usage ?? undefined, event.pending ?? false)
return return
} }
if (event.error) { if (event.error) {
@@ -103,12 +146,18 @@ export async function streamChat(options: StreamChatOptions): Promise<void> {
onDone() onDone()
return return
} }
if (event.context && onContext) {
onContext(event.context)
}
if (event.content) { if (event.content) {
onChunk(event.content) onChunk(event.content)
} }
if (event.tool_calls_pending && onToolCallsPending) {
onToolCallsPending(event.tool_calls_pending, event.assistant_text ?? '')
}
if (event.tool_calls_auto && onToolCallsAuto) {
onToolCallsAuto(event.tool_calls_auto)
}
if (event.tool_result && onToolResult) {
onToolResult(event.tool_result)
}
} catch { } catch {
// 忽略解析错误 // 忽略解析错误
} }

View File

@@ -31,3 +31,8 @@ export async function reorderOutlines(novelId: number, orderedIds: number[]): Pr
const { data } = await http.put<OutlineList>(`${base(novelId)}/reorder`, { ordered_ids: orderedIds }) const { data } = await http.put<OutlineList>(`${base(novelId)}/reorder`, { ordered_ids: orderedIds })
return data return data
} }
export async function reorderChildren(novelId: number, outlineId: number, orderedIds: number[]): Promise<Outline> {
const { data } = await http.put<Outline>(`${base(novelId)}/${outlineId}/children/reorder`, { ordered_ids: orderedIds })
return data
}

View File

@@ -0,0 +1,14 @@
import { http } from './client'
import type { WorldSetting, WorldSettingUpdate } from '@/types/worldSetting'
const base = (novelId: number) => `/novels/${novelId}/world-setting`
export async function fetchWorldSetting(novelId: number): Promise<WorldSetting> {
const { data } = await http.get<WorldSetting>(base(novelId))
return data
}
export async function saveWorldSetting(novelId: number, payload: WorldSettingUpdate): Promise<WorldSetting> {
const { data } = await http.put<WorldSetting>(base(novelId), payload)
return data
}

View File

@@ -0,0 +1,193 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import type { Chapter } from '@/types/chapter'
import BaseButton from '@/components/ui/BaseButton.vue'
import BaseTextarea from '@/components/ui/BaseTextarea.vue'
const props = defineProps<{
chapter: Chapter
saving: boolean
}>()
const emit = defineEmits<{
save: [data: { title: string; content: string }]
back: []
}>()
const title = ref('')
const content = ref('')
const hasChanges = ref(false)
const contentLength = computed(() => content.value.length)
const contentHint = computed(() => {
if (contentLength.value === 0) return '上限 5000 字'
return `${contentLength.value} / 5000 字`
})
watch(() => props.chapter, (ch) => {
title.value = ch.title
content.value = ch.content
hasChanges.value = false
}, { immediate: true })
function onInput() {
hasChanges.value = true
}
function handleSave() {
emit('save', {
title: title.value.trim(),
content: content.value,
})
hasChanges.value = false
}
</script>
<template>
<div class="chapter-editor">
<div class="editor-header">
<button class="back-btn" @click="emit('back')">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M10 3L5 8l5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
返回列表
</button>
<div class="editor-title-wrap">
<input
v-model="title"
class="editor-title-input"
placeholder="章节标题"
maxlength="200"
@input="onInput"
/>
</div>
<div class="editor-actions">
<span v-if="hasChanges" class="unsaved-hint">未保存</span>
<BaseButton
variant="primary"
size="small"
:loading="saving"
:disabled="!hasChanges || !title.trim()"
@click="handleSave"
>
保存
</BaseButton>
</div>
</div>
<div class="editor-body">
<BaseTextarea
v-model="content"
placeholder="在此书写章节内容..."
:rows="20"
:maxlength="5000"
:max-height="800"
@input="onInput"
/>
<div class="editor-footer">
<span class="word-count" :class="{ 'word-count--near': contentLength > 4500 }">
{{ contentHint }}
</span>
</div>
</div>
</div>
</template>
<style scoped>
.chapter-editor {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.editor-header {
display: flex;
align-items: center;
gap: var(--space-md);
padding-bottom: var(--space-lg);
border-bottom: 1px solid var(--paper-200);
margin-bottom: var(--space-lg);
}
.back-btn {
display: flex;
align-items: center;
gap: var(--space-xs);
border: none;
background: none;
color: var(--ink-400);
font-size: 0.85rem;
cursor: pointer;
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-sm);
transition: all var(--duration-fast) ease;
flex-shrink: 0;
}
.back-btn:hover {
color: var(--ink-700);
background: var(--paper-100);
}
.editor-title-wrap {
flex: 1;
min-width: 0;
}
.editor-title-input {
width: 100%;
border: none;
background: none;
font-family: var(--font-display);
font-size: 1.3rem;
font-weight: 700;
color: var(--ink-900);
outline: none;
padding: var(--space-xs) 0;
border-bottom: 2px solid transparent;
transition: border-color var(--duration-fast) ease;
}
.editor-title-input:focus {
border-bottom-color: var(--vermilion);
}
.editor-title-input::placeholder {
color: var(--ink-200);
}
.editor-actions {
display: flex;
align-items: center;
gap: var(--space-sm);
flex-shrink: 0;
}
.unsaved-hint {
font-size: 0.75rem;
color: var(--vermilion);
font-style: italic;
}
.editor-body {
flex: 1;
display: flex;
flex-direction: column;
}
.editor-footer {
display: flex;
justify-content: flex-end;
margin-top: var(--space-sm);
}
.word-count {
font-size: 0.75rem;
color: var(--ink-300);
}
.word-count--near {
color: var(--vermilion);
}
</style>

View File

@@ -0,0 +1,93 @@
<script setup lang="ts">
import BaseButton from '@/components/ui/BaseButton.vue'
defineEmits<{
create: []
}>()
</script>
<template>
<div class="empty-state">
<!-- 书页SVG -->
<svg class="book-svg" viewBox="0 0 200 180" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- 打开的书 -->
<path class="book-left" d="M30 40 Q100 35 100 45 L100 150 Q100 140 30 145 Z" fill="#faf8f5" stroke="#e8e0d4" stroke-width="1" />
<path class="book-right" d="M170 40 Q100 35 100 45 L100 150 Q100 140 170 145 Z" fill="#faf8f5" stroke="#e8e0d4" stroke-width="1" />
<!-- 书脊 -->
<line x1="100" y1="38" x2="100" y2="150" stroke="#d9cebf" stroke-width="1.5" />
<!-- 左页文字线 -->
<line class="text-line tl-1" x1="45" y1="65" x2="90" y2="65" stroke="#e8e0d4" stroke-width="0.8" stroke-dasharray="3 2" />
<line class="text-line tl-2" x1="45" y1="78" x2="85" y2="78" stroke="#e8e0d4" stroke-width="0.8" stroke-dasharray="3 2" />
<line class="text-line tl-3" x1="45" y1="91" x2="88" y2="91" stroke="#e8e0d4" stroke-width="0.8" stroke-dasharray="3 2" />
<line class="text-line tl-4" x1="45" y1="104" x2="80" y2="104" stroke="#e8e0d4" stroke-width="0.8" stroke-dasharray="3 2" />
<!-- 右页空白等待书写 -->
<text class="page-hint" x="135" y="95" text-anchor="middle" font-size="11" fill="#d9cebf">?</text>
<!-- 翻页效果 -->
<path class="page-flip" d="M100 45 Q115 42 130 50 L125 140 Q110 135 100 150 Z" fill="#f5f0ea" stroke="#e8e0d4" stroke-width="0.8" opacity="0.5" />
</svg>
<h3 class="empty-title">开始书写你的故事</h3>
<p class="empty-desc">还没有章节创建第一章来展开你的叙事</p>
<BaseButton variant="primary" @click="$emit('create')">
创建第一章
</BaseButton>
</div>
</template>
<style scoped>
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: var(--space-3xl) var(--space-xl);
animation: fadeInUp var(--duration-slow) var(--ease-out-smooth) both;
}
.book-svg {
width: 200px;
height: 180px;
margin-bottom: var(--space-xl);
}
.book-left {
animation: fadeIn 0.6s ease 0.2s both;
}
.book-right {
animation: fadeIn 0.6s ease 0.4s both;
}
.page-flip {
animation: fadeIn 0.6s ease 0.6s both;
}
.text-line {
opacity: 0;
animation: fadeIn 0.4s ease both;
}
.tl-1 { animation-delay: 0.6s; }
.tl-2 { animation-delay: 0.8s; }
.tl-3 { animation-delay: 1.0s; }
.tl-4 { animation-delay: 1.2s; }
.page-hint {
animation: fadeIn 0.4s ease 1.4s both;
}
.empty-title {
font-family: var(--font-display);
font-size: 1.4rem;
font-weight: 700;
color: var(--ink-900);
margin-bottom: var(--space-sm);
}
.empty-desc {
font-size: 0.9rem;
color: var(--ink-400);
margin-bottom: var(--space-xl);
text-align: center;
}
</style>

View File

@@ -0,0 +1,79 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import type { Chapter } from '@/types/chapter'
import BaseModal from '@/components/ui/BaseModal.vue'
import BaseInput from '@/components/ui/BaseInput.vue'
import BaseButton from '@/components/ui/BaseButton.vue'
const props = defineProps<{
show: boolean
chapter: Chapter | null
saving: boolean
}>()
const emit = defineEmits<{
submit: [data: { title: string }]
close: []
}>()
const title = ref('')
const isEdit = computed(() => !!props.chapter)
const modalTitle = computed(() => isEdit.value ? '重命名章节' : '新建章节')
const canSubmit = computed(() => title.value.trim().length > 0 && !props.saving)
watch(() => props.show, (val) => {
if (val) {
title.value = props.chapter?.title ?? ''
}
})
function handleSubmit() {
if (!canSubmit.value) return
emit('submit', { title: title.value.trim() })
}
</script>
<template>
<BaseModal :show="show" @close="emit('close')">
<form class="chapter-form" @submit.prevent="handleSubmit">
<h2 class="form-title">{{ modalTitle }}</h2>
<BaseInput
v-model="title"
label="章节标题"
:maxlength="200"
required
/>
<div class="form-actions">
<BaseButton variant="secondary" type="button" @click="emit('close')">取消</BaseButton>
<BaseButton variant="primary" type="submit" :loading="saving" :disabled="!canSubmit">
{{ isEdit ? '保存' : '创建' }}
</BaseButton>
</div>
</form>
</BaseModal>
</template>
<style scoped>
.chapter-form {
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.form-title {
font-family: var(--font-display);
font-size: 1.4rem;
font-weight: 700;
color: var(--ink-900);
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-sm);
padding-top: var(--space-sm);
}
</style>

View File

@@ -0,0 +1,172 @@
<script setup lang="ts">
import type { Chapter } from '@/types/chapter'
const props = defineProps<{
chapter: Chapter
index: number
active: boolean
}>()
defineEmits<{
select: [chapter: Chapter]
edit: [chapter: Chapter]
delete: [chapter: Chapter]
}>()
const wordCount = computed(() => props.chapter.content.length)
import { computed } from 'vue'
</script>
<template>
<div
class="chapter-item"
:class="{ 'chapter-item--active': active }"
draggable="true"
@click="$emit('select', chapter)"
>
<div class="item-drag-handle" title="拖拽排序">
<svg width="10" height="14" viewBox="0 0 10 14" fill="none">
<circle cx="3" cy="2" r="1.2" fill="currentColor" />
<circle cx="7" cy="2" r="1.2" fill="currentColor" />
<circle cx="3" cy="7" r="1.2" fill="currentColor" />
<circle cx="7" cy="7" r="1.2" fill="currentColor" />
<circle cx="3" cy="12" r="1.2" fill="currentColor" />
<circle cx="7" cy="12" r="1.2" fill="currentColor" />
</svg>
</div>
<span class="item-order">{{ index + 1 }}</span>
<div class="item-info">
<span class="item-title">{{ chapter.title }}</span>
<span class="item-meta">{{ wordCount }} </span>
</div>
<div class="item-actions">
<button class="action-btn" title="重命名" @click.stop="$emit('edit', chapter)">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M10.5 1.5l2 2-8 8H2.5v-2z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button class="action-btn action-btn--danger" title="删除" @click.stop="$emit('delete', chapter)">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M2 4h10M5 4V2.5h4V4M3.5 4v8h7V4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
</div>
</template>
<style scoped>
.chapter-item {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-md) var(--space-lg);
background: var(--paper-50);
border: 1px solid var(--paper-200);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-out-smooth);
}
.chapter-item:hover {
border-color: var(--paper-300);
box-shadow: 0 2px 8px rgba(26, 26, 46, 0.06);
}
.chapter-item--active {
border-color: var(--vermilion);
background: #fef8f6;
}
.item-drag-handle {
color: var(--ink-200);
cursor: grab;
padding: var(--space-xs);
opacity: 0;
transition: opacity var(--duration-fast) ease;
}
.chapter-item:hover .item-drag-handle {
opacity: 1;
}
.item-order {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--paper-100);
color: var(--ink-400);
font-size: 0.75rem;
font-weight: 600;
flex-shrink: 0;
}
.chapter-item--active .item-order {
background: var(--vermilion);
color: white;
}
.item-info {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: var(--space-sm);
}
.item-title {
font-size: 0.95rem;
color: var(--ink-800);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-meta {
font-size: 0.75rem;
color: var(--ink-300);
flex-shrink: 0;
}
.item-actions {
display: flex;
gap: var(--space-xs);
opacity: 0;
transition: opacity var(--duration-fast) ease;
}
.chapter-item:hover .item-actions {
opacity: 1;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: var(--radius-sm);
background: var(--paper-100);
color: var(--ink-400);
cursor: pointer;
transition: all var(--duration-fast) ease;
}
.action-btn:hover {
background: var(--paper-200);
color: var(--ink-700);
}
.action-btn--danger:hover {
background: #fef2f2;
color: var(--vermilion);
}
</style>

View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { Chapter } from '@/types/chapter'
import ChapterItem from './ChapterItem.vue'
const props = defineProps<{
chapters: Chapter[]
activeId: number | null
}>()
const emit = defineEmits<{
select: [chapter: Chapter]
edit: [chapter: Chapter]
delete: [chapter: Chapter]
reorder: [chapters: Chapter[]]
}>()
const dragIndex = ref(-1)
const dragOverIndex = ref(-1)
function onDragStart(e: DragEvent, index: number) {
dragIndex.value = index
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move'
}
}
function onDragOver(e: DragEvent, index: number) {
e.preventDefault()
dragOverIndex.value = index
}
function onDragEnd() {
if (dragIndex.value !== -1 && dragOverIndex.value !== -1 && dragIndex.value !== dragOverIndex.value) {
const reordered = [...props.chapters]
const [moved] = reordered.splice(dragIndex.value, 1)
reordered.splice(dragOverIndex.value, 0, moved)
emit('reorder', reordered)
}
dragIndex.value = -1
dragOverIndex.value = -1
}
</script>
<template>
<div class="chapter-list">
<div
v-for="(chapter, index) in chapters"
:key="chapter.id"
class="chapter-list-item"
:class="{
'chapter-list-item--drag-over': dragOverIndex === index && dragIndex !== index,
}"
@dragstart="onDragStart($event, index)"
@dragover="onDragOver($event, index)"
@dragend="onDragEnd"
>
<ChapterItem
:chapter="chapter"
:index="index"
:active="chapter.id === activeId"
@select="emit('select', $event)"
@edit="emit('edit', $event)"
@delete="emit('delete', $event)"
/>
</div>
</div>
</template>
<style scoped>
.chapter-list {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.chapter-list-item {
position: relative;
}
.chapter-list-item--drag-over::before {
content: '';
position: absolute;
top: -3px;
left: 0;
right: 0;
height: 2px;
background: var(--vermilion);
border-radius: 1px;
}
</style>

View File

@@ -0,0 +1,106 @@
<script setup lang="ts">
import type { Character } from '@/types/character'
defineProps<{
character: Character
}>()
defineEmits<{
edit: [character: Character]
delete: [character: Character]
}>()
</script>
<template>
<div class="character-card">
<div class="card-header">
<h3 class="card-name">{{ character.name }}</h3>
<div class="card-actions">
<button class="action-btn" title="编辑" @click="$emit('edit', character)">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M10.5 1.5l2 2-8 8H2.5v-2z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button class="action-btn action-btn--danger" title="删除" @click="$emit('delete', character)">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M2 4h10M5 4V2.5h4V4M3.5 4v8h7V4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
</div>
<p class="card-desc">{{ character.description || '暂无描述' }}</p>
</div>
</template>
<style scoped>
.character-card {
background: var(--paper-50);
border: 1px solid var(--paper-200);
border-radius: var(--radius-md);
padding: var(--space-lg);
transition: transform var(--duration-fast) var(--ease-out-smooth),
box-shadow var(--duration-fast) var(--ease-out-smooth);
}
.character-card:hover {
transform: translateY(-3px);
box-shadow: 0 8px 24px rgba(26, 26, 46, 0.08);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-sm);
}
.card-name {
font-family: var(--font-display);
font-size: 1.1rem;
font-weight: 700;
color: var(--ink-900);
}
.card-actions {
display: flex;
gap: var(--space-xs);
opacity: 0;
transition: opacity var(--duration-fast) ease;
}
.character-card:hover .card-actions {
opacity: 1;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: var(--radius-sm);
background: var(--paper-100);
color: var(--ink-400);
cursor: pointer;
transition: all var(--duration-fast) ease;
}
.action-btn:hover {
background: var(--paper-200);
color: var(--ink-700);
}
.action-btn--danger:hover {
background: #fef2f2;
color: var(--vermilion);
}
.card-desc {
font-size: 0.9rem;
color: var(--ink-500);
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
}
</style>

View File

@@ -0,0 +1,83 @@
<script setup lang="ts">
import BaseButton from '@/components/ui/BaseButton.vue'
defineEmits<{
create: []
}>()
</script>
<template>
<div class="empty-state">
<!-- 人物剪影SVG -->
<svg class="person-svg" viewBox="0 0 200 180" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- 中间人物 -->
<circle class="head head-center" cx="100" cy="50" r="18" fill="#e8e0d4" stroke="#9090a8" stroke-width="1" />
<path class="body body-center" d="M75 90 Q100 75 125 90 L120 140 H80 Z" fill="#e8e0d4" stroke="#9090a8" stroke-width="1" />
<!-- 左侧人物 -->
<circle class="head head-left" cx="45" cy="65" r="14" fill="#f0ebe4" stroke="#b0b0c0" stroke-width="0.8" opacity="0.6" />
<path class="body body-left" d="M28 95 Q45 83 62 95 L59 135 H31 Z" fill="#f0ebe4" stroke="#b0b0c0" stroke-width="0.8" opacity="0.6" />
<!-- 右侧人物 -->
<circle class="head head-right" cx="155" cy="65" r="14" fill="#f0ebe4" stroke="#b0b0c0" stroke-width="0.8" opacity="0.6" />
<path class="body body-right" d="M138 95 Q155 83 172 95 L169 135 H141 Z" fill="#f0ebe4" stroke="#b0b0c0" stroke-width="0.8" opacity="0.6" />
<!-- 连接线 -->
<line class="connect-line" x1="65" y1="80" x2="82" y2="70" stroke="#d9cebf" stroke-width="1" stroke-dasharray="3 3" />
<line class="connect-line" x1="135" y1="80" x2="118" y2="70" stroke="#d9cebf" stroke-width="1" stroke-dasharray="3 3" />
</svg>
<h3 class="empty-title">塑造你的角色</h3>
<p class="empty-desc">还没有角色创建第一个来丰富你的故事世界</p>
<BaseButton variant="primary" @click="$emit('create')">
创建第一个角色
</BaseButton>
</div>
</template>
<style scoped>
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: var(--space-3xl) var(--space-xl);
animation: fadeInUp var(--duration-slow) var(--ease-out-smooth) both;
}
.person-svg {
width: 200px;
height: 180px;
margin-bottom: var(--space-xl);
}
.head-center {
animation: fadeIn 0.6s ease 0.2s both;
}
.body-center {
animation: fadeIn 0.6s ease 0.4s both;
}
.head-left, .body-left {
animation: fadeIn 0.6s ease 0.6s both;
}
.head-right, .body-right {
animation: fadeIn 0.6s ease 0.8s both;
}
.connect-line {
animation: fadeIn 0.4s ease 1.0s both;
}
.empty-title {
font-family: var(--font-display);
font-size: 1.4rem;
font-weight: 700;
color: var(--ink-900);
margin-bottom: var(--space-sm);
}
.empty-desc {
font-size: 0.9rem;
color: var(--ink-400);
margin-bottom: var(--space-xl);
text-align: center;
}
</style>

View File

@@ -0,0 +1,123 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import type { Character, CharacterCreate, CharacterUpdate } from '@/types/character'
import BaseModal from '@/components/ui/BaseModal.vue'
import BaseInput from '@/components/ui/BaseInput.vue'
import BaseTextarea from '@/components/ui/BaseTextarea.vue'
import BaseButton from '@/components/ui/BaseButton.vue'
const props = defineProps<{
show: boolean
character: Character | null
saving: boolean
}>()
const emit = defineEmits<{
submit: [data: CharacterCreate | CharacterUpdate]
close: []
}>()
const name = ref('')
const description = ref('')
const isEdit = computed(() => !!props.character)
const title = computed(() => isEdit.value ? '编辑角色' : '新建角色')
const descLength = computed(() => description.value.length)
const descHint = computed(() => {
if (descLength.value === 0) return '建议约 200 字'
if (descLength.value < 100) return `${descLength.value} 字,建议至少 100 字`
if (descLength.value <= 300) return `${descLength.value}`
return `${descLength.value} 字,建议不超过 300 字`
})
const canSubmit = computed(() => name.value.trim().length > 0 && !props.saving)
watch(() => props.show, (val) => {
if (val) {
name.value = props.character?.name ?? ''
description.value = props.character?.description ?? ''
}
})
function handleSubmit() {
if (!canSubmit.value) return
emit('submit', {
name: name.value.trim(),
description: description.value.trim(),
})
}
</script>
<template>
<BaseModal :show="show" @close="emit('close')">
<form class="character-form" @submit.prevent="handleSubmit">
<h2 class="form-title">{{ title }}</h2>
<BaseInput
v-model="name"
label="角色名称"
:maxlength="100"
required
/>
<div class="desc-field">
<BaseTextarea
v-model="description"
label="角色描述"
:rows="6"
:maxlength="1000"
:max-height="300"
/>
<span class="desc-counter" :class="{ 'desc-counter--warn': descLength > 300 }">
{{ descHint }}
</span>
</div>
<div class="form-actions">
<BaseButton variant="secondary" type="button" @click="emit('close')">取消</BaseButton>
<BaseButton variant="primary" type="submit" :loading="saving" :disabled="!canSubmit">
{{ isEdit ? '保存' : '创建' }}
</BaseButton>
</div>
</form>
</BaseModal>
</template>
<style scoped>
.character-form {
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.form-title {
font-family: var(--font-display);
font-size: 1.4rem;
font-weight: 700;
color: var(--ink-900);
}
.desc-field {
position: relative;
}
.desc-counter {
display: block;
text-align: right;
font-size: 0.75rem;
color: var(--ink-300);
margin-top: var(--space-xs);
}
.desc-counter--warn {
color: var(--vermilion);
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-sm);
padding-top: var(--space-sm);
}
</style>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { Character } from '@/types/character'
import CharacterCard from './CharacterCard.vue'
defineProps<{
characters: Character[]
}>()
defineEmits<{
edit: [character: Character]
delete: [character: Character]
}>()
</script>
<template>
<div class="character-grid">
<CharacterCard
v-for="c in characters"
:key="c.id"
:character="c"
@edit="$emit('edit', $event)"
@delete="$emit('delete', $event)"
/>
</div>
</template>
<style scoped>
.character-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-lg);
}
</style>

View File

@@ -1,10 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, nextTick, watch, onMounted } from 'vue' import { ref, computed, nextTick, watch, onMounted } from 'vue'
import { marked } from 'marked'
import { useChatAgent } from '@/composables/useChatAgent' import { useChatAgent } from '@/composables/useChatAgent'
import { useToast } from '@/composables/useToast'
import { isToolCallGroup } from '@/types/chat'
import type { ToolCallGroup } from '@/types/chat'
import ChatMessageComp from './ChatMessage.vue' import ChatMessageComp from './ChatMessage.vue'
import ChatToolCalls from './ChatToolCalls.vue'
import ChatConfigModal from './ChatConfigModal.vue' import ChatConfigModal from './ChatConfigModal.vue'
defineProps<{ const props = defineProps<{
open: boolean open: boolean
}>() }>()
@@ -12,28 +17,21 @@ const emit = defineEmits<{
close: [] close: []
}>() }>()
const { messages, isStreaming, streamingContent, lastUsage, loadHistory, sendMessage, stopStreaming, clearChat } = useChatAgent() const {
entries, isStreaming, isCompressing, streamingContent, lastUsage,
toolsEnabled, autoApproveTools, currentNovelId,
loadHistory, sendMessage, approveToolCalls, skipToolCalls,
stopStreaming, clearChat, compressChat,
} = useChatAgent()
const input = ref('') const input = ref('')
const toast = useToast()
const messagesRef = ref<HTMLElement>() const messagesRef = ref<HTMLElement>()
const showConfig = ref(false) const showConfig = ref(false)
// 常见模型的上下文窗口大小 // Token用量
const MODEL_CONTEXT: Record<string, number> = {
'gpt-4o': 128000,
'gpt-4o-mini': 128000,
'gpt-4-turbo': 128000,
'gpt-4': 8192,
'gpt-3.5-turbo': 16385,
'claude-sonnet-4-20250514': 200000,
'claude-opus-4-20250514': 200000,
'claude-haiku-4-5-20251001': 200000,
'claude-3-5-sonnet-20241022': 200000,
'claude-3-opus-20240229': 200000,
}
const DEFAULT_CONTEXT = 128000 const DEFAULT_CONTEXT = 128000
// Token用量
const totalUsed = computed(() => { const totalUsed = computed(() => {
if (!lastUsage.value) return 0 if (!lastUsage.value) return 0
const u = lastUsage.value const u = lastUsage.value
@@ -54,17 +52,38 @@ const usageLabel = computed(() => {
return `${total.toLocaleString()} / ${contextLimit.value.toLocaleString()} tokens输入 ${u.prompt_tokens.toLocaleString()} · 输出 ${u.completion_tokens.toLocaleString()}` return `${total.toLocaleString()} / ${contextLimit.value.toLocaleString()} tokens输入 ${u.prompt_tokens.toLocaleString()} · 输出 ${u.completion_tokens.toLocaleString()}`
}) })
// 是否有待审批的工具调用
const hasPendingTools = computed(() =>
entries.value.some(e => isToolCallGroup(e) && e.status === 'pending')
)
async function handleCompress() {
const result = await compressChat()
if (result.success) {
toast.success(result.message)
} else {
toast.info(result.message)
}
}
function renderMarkdown(text: string): string {
return marked.parse(text) as string
}
function scrollToBottom() { function scrollToBottom() {
// 双重 nextTick第一次等 v-if 渲染面板 DOM第二次等内容渲染完成
nextTick(() => { nextTick(() => {
if (messagesRef.value) { nextTick(() => {
messagesRef.value.scrollTop = messagesRef.value.scrollHeight if (messagesRef.value) {
} messagesRef.value.scrollTop = messagesRef.value.scrollHeight
}
})
}) })
} }
async function handleSend() { async function handleSend() {
const text = input.value.trim() const text = input.value.trim()
if (!text || isStreaming.value) return if (!text || isStreaming.value || hasPendingTools.value) return
input.value = '' input.value = ''
await sendMessage(text) await sendMessage(text)
} }
@@ -76,8 +95,19 @@ function handleKeydown(e: KeyboardEvent) {
} }
} }
// 自动滚动 async function handleApprove(group: ToolCallGroup) {
watch([messages, streamingContent], scrollToBottom) await approveToolCalls(group)
}
function handleSkip(group: ToolCallGroup) {
skipToolCalls(group)
}
// 自动滚动:消息变化时、面板打开时
watch([entries, streamingContent], scrollToBottom)
watch(() => props.open, (isOpen) => {
if (isOpen) scrollToBottom()
})
onMounted(() => { onMounted(() => {
loadHistory() loadHistory()
@@ -87,7 +117,7 @@ onMounted(() => {
<template> <template>
<Teleport to="body"> <Teleport to="body">
<Transition name="panel"> <Transition name="panel">
<div v-if="open" class="chat-overlay" @click.self="emit('close')"> <div v-if="open" class="chat-overlay">
<div class="chat-panel"> <div class="chat-panel">
<!-- 头部 --> <!-- 头部 -->
<div class="panel-header"> <div class="panel-header">
@@ -98,6 +128,44 @@ onMounted(() => {
<path d="M2 4h12M5 4V2.5h6V4M3.5 4v9.5h9V4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" /> <path d="M2 4h12M5 4V2.5h6V4M3.5 4v9.5h9V4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg> </svg>
</button> </button>
<button
class="icon-btn"
:class="{ 'icon-btn--compressing': isCompressing }"
:title="isCompressing ? '正在压缩上下文...' : '压缩上下文(总结旧消息,释放空间)'"
:disabled="isCompressing || isStreaming"
@click="handleCompress"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M4 2v4l4-2-4-2zM12 14v-4l-4 2 4 2zM2 8h12" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button
v-if="currentNovelId"
class="icon-btn"
:class="{ 'icon-btn--active': toolsEnabled }"
:title="toolsEnabled ? '工具已启用(点击关闭)' : '工具已关闭(点击开启)'"
@click="toolsEnabled = !toolsEnabled"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M9.5 1.5L13.5 5.5L6.5 12.5H2.5V8.5L9.5 1.5Z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button
v-if="currentNovelId && toolsEnabled"
class="icon-btn"
:class="{ 'icon-btn--active': autoApproveTools }"
:title="autoApproveTools ? '工具自动执行(点击切换为需审批)' : '工具需审批(点击切换为自动执行)'"
@click="autoApproveTools = !autoApproveTools"
>
<!-- 自动执行闪电图标需审批盾牌图标 -->
<svg v-if="autoApproveTools" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M8.5 1L3.5 9H7.5L7 15L12.5 7H8.5L8.5 1Z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<svg v-else width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M8 1.5L2.5 4V7.5C2.5 11 5 13.5 8 14.5C11 13.5 13.5 11 13.5 7.5V4L8 1.5Z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M6 8L7.5 9.5L10 6.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button class="icon-btn" title="AI配置" @click="showConfig = true"> <button class="icon-btn" title="AI配置" @click="showConfig = true">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"> <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="2.5" stroke="currentColor" stroke-width="1.2" /> <circle cx="8" cy="8" r="2.5" stroke="currentColor" stroke-width="1.2" />
@@ -126,17 +194,39 @@ onMounted(() => {
<!-- 消息区 --> <!-- 消息区 -->
<div ref="messagesRef" class="panel-messages"> <div ref="messagesRef" class="panel-messages">
<div v-if="messages.length === 0 && !isStreaming" class="empty-chat"> <div v-if="entries.length === 0 && !isStreaming" class="empty-chat">
<p class="empty-hint">有什么可以帮你的</p> <p class="empty-hint">有什么可以帮你的</p>
<p class="empty-sub">关于小说创作的任何问题都可以问我</p> <p class="empty-sub">关于小说创作的任何问题都可以问我</p>
</div> </div>
<ChatMessageComp <template v-for="entry in entries" :key="entry.id">
v-for="msg in messages" <!-- 工具调用组 -->
:key="msg.id" <ChatToolCalls
:role="msg.role" v-if="isToolCallGroup(entry)"
:content="msg.content" :group="entry"
/> @approve="handleApprove(entry as ToolCallGroup)"
@skip="handleSkip(entry as ToolCallGroup)"
/>
<!-- 对话摘要压缩后的历史总结 -->
<div
v-else-if="entry.role === 'assistant' && entry.content.startsWith('[对话摘要]')"
class="context-summary"
>
<div class="summary-header">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
<path d="M4 2v4l4-2-4-2zM12 14v-4l-4 2 4 2zM2 8h12" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span>上下文摘要</span>
</div>
<div class="summary-content" v-html="renderMarkdown(entry.content.replace('[对话摘要]\n', ''))" />
</div>
<!-- 普通消息 -->
<ChatMessageComp
v-else
:role="entry.role"
:content="entry.content"
/>
</template>
<!-- 流式消息 --> <!-- 流式消息 -->
<ChatMessageComp <ChatMessageComp
@@ -159,13 +249,13 @@ onMounted(() => {
class="input-field" class="input-field"
placeholder="输入消息..." placeholder="输入消息..."
rows="1" rows="1"
:disabled="isStreaming" :disabled="isStreaming || hasPendingTools"
@keydown="handleKeydown" @keydown="handleKeydown"
/> />
<button <button
v-if="!isStreaming" v-if="!isStreaming"
class="send-btn" class="send-btn"
:disabled="!input.trim()" :disabled="!input.trim() || hasPendingTools"
@click="handleSend" @click="handleSend"
> >
<svg width="18" height="18" viewBox="0 0 18 18" fill="none"> <svg width="18" height="18" viewBox="0 0 18 18" fill="none">
@@ -194,11 +284,12 @@ onMounted(() => {
z-index: 1000; z-index: 1000;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
pointer-events: none;
} }
/* 面板 */ /* 面板 */
.chat-panel { .chat-panel {
width: 380px; width: 480px;
max-width: 100vw; max-width: 100vw;
height: 100vh; height: 100vh;
background: var(--paper-50); background: var(--paper-50);
@@ -206,6 +297,7 @@ onMounted(() => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
box-shadow: -8px 0 30px rgba(26, 26, 46, 0.1); box-shadow: -8px 0 30px rgba(26, 26, 46, 0.1);
pointer-events: auto;
} }
/* 头部 */ /* 头部 */
@@ -249,6 +341,31 @@ onMounted(() => {
color: var(--ink-900); color: var(--ink-900);
} }
.icon-btn--compressing {
animation: pulse 1.2s ease-in-out infinite;
color: #d4a017;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.icon-btn:disabled {
opacity: 0.3;
cursor: default;
}
.icon-btn--active {
color: var(--bamboo);
background: rgba(91, 140, 90, 0.1);
}
.icon-btn--active:hover {
color: var(--bamboo);
background: rgba(91, 140, 90, 0.18);
}
/* Token用量进度条 */ /* Token用量进度条 */
.usage-bar { .usage-bar {
padding: var(--space-sm) var(--space-lg); padding: var(--space-sm) var(--space-lg);
@@ -287,6 +404,34 @@ onMounted(() => {
font-family: var(--font-mono, monospace); font-family: var(--font-mono, monospace);
} }
/* 上下文摘要卡片 */
.context-summary {
margin-bottom: var(--space-md);
padding: var(--space-sm) var(--space-md);
background: linear-gradient(135deg, rgba(212, 160, 23, 0.06), rgba(212, 160, 23, 0.02));
border: 1px dashed rgba(212, 160, 23, 0.3);
border-radius: var(--radius-md);
font-size: 0.82rem;
color: var(--ink-500);
}
.summary-header {
display: flex;
align-items: center;
gap: var(--space-xs);
font-weight: 600;
color: #d4a017;
margin-bottom: var(--space-xs);
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.summary-content :deep(p) { margin: 0 0 0.3em; }
.summary-content :deep(p:last-child) { margin-bottom: 0; }
.summary-content :deep(ul) { margin: 0.2em 0; padding-left: 1.3em; }
.summary-content :deep(li) { margin-bottom: 0.1em; }
/* 消息区 */ /* 消息区 */
.panel-messages { .panel-messages {
flex: 1; flex: 1;

View File

@@ -0,0 +1,409 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ToolCallGroup, ToolCallEntry } from '@/types/chat'
const props = defineProps<{
group: ToolCallGroup
}>()
const emit = defineEmits<{
approve: []
skip: []
}>()
const isPending = computed(() => props.group.status === 'pending')
const isExecuting = computed(() => props.group.status === 'executing')
const isDone = computed(() => props.group.status === 'done')
const isSkipped = computed(() => props.group.status === 'skipped')
// 工具图标SVG path
const TOOL_ICONS: Record<string, string> = {
// 大纲
list_outlines: 'M3 4h10M3 8h10M3 12h6',
create_outline: 'M8 3v10M3 8h10',
update_outline: 'M3 12l2-2 4 4-2 2H3v-4zM9 6l2-2 4 4-2 2-4-4z',
delete_outline: 'M3 5h10M5 5V3h6v2M4 5v8h8V5',
reorder_outlines: 'M3 4h7M3 8h7M3 12h7M12 3l2 2-2 2M12 9l2 2-2 2',
// 世界观
get_world_setting: 'M8 1a7 7 0 100 14A7 7 0 008 1zM1 8h14M8 1c2 2 3 4.5 3 7s-1 5-3 7M8 1c-2 2-3 4.5-3 7s1 5 3 7',
save_world_setting: 'M8 1a7 7 0 100 14A7 7 0 008 1zM4 8l3 3 5-5',
// 角色
list_characters: 'M5.5 5a2.5 2.5 0 105 0 2.5 2.5 0 00-5 0zM3 13c0-2.5 2-4 5-4s5 1.5 5 4',
create_character: 'M5 5a2.5 2.5 0 105 0 2.5 2.5 0 00-5 0zM2 13c0-2.5 2-4 5-4M12 6v5M9.5 8.5h5',
update_character: 'M5 5a2.5 2.5 0 105 0 2.5 2.5 0 00-5 0zM3 13c0-2.5 2-4 5-4M10 10l2-2 2 2-2 2z',
delete_character: 'M5 5a2.5 2.5 0 105 0 2.5 2.5 0 00-5 0zM3 13c0-2.5 2-4 5-4M10 8l4 4M14 8l-4 4',
// 章节
list_chapters: 'M4 2h8v12H4zM6 5h4M6 8h4M6 11h2',
create_chapter: 'M3 2h7v12H3zM12 5v5M9.5 7.5h5',
update_chapter: 'M4 2h8v12H4zM7 7l2-2 2 2-2 2z',
delete_chapter: 'M4 2h8v12H4zM6 6l4 4M10 6l-4 4',
}
// 工具颜色主题
const TOOL_COLORS: Record<string, string> = {
// 大纲
list_outlines: 'var(--bamboo)',
create_outline: 'var(--bamboo)',
update_outline: '#d4a017',
delete_outline: 'var(--danger)',
reorder_outlines: '#d4a017',
// 世界观
get_world_setting: 'var(--ink-500)',
save_world_setting: 'var(--bamboo)',
// 角色
list_characters: '#7c6bc4',
create_character: '#7c6bc4',
update_character: '#d4a017',
delete_character: 'var(--danger)',
// 章节
list_chapters: '#4a90d9',
create_chapter: '#4a90d9',
update_chapter: '#d4a017',
delete_chapter: 'var(--danger)',
}
function getIcon(name: string): string {
return TOOL_ICONS[name] ?? 'M8 3v10M3 8h10'
}
function getColor(name: string): string {
return TOOL_COLORS[name] ?? 'var(--ink-400)'
}
/** 格式化参数为可读文本 */
function formatArgs(call: ToolCallEntry): string {
const args = call.arguments
if (!args || Object.keys(args).length === 0) return ''
switch (call.name) {
case 'create_outline':
return args.summary as string ?? ''
case 'update_outline':
return `#${args.outline_id}` + (args.summary ? `${args.summary}` : '')
case 'delete_outline':
return `#${args.outline_id}`
case 'reorder_outlines': {
const ids = args.ordered_ids as number[]
return ids ? `${ids.length} 个节点` + (args.parent_id ? ` (父节点 #${args.parent_id})` : '') : ''
}
case 'save_world_setting': {
const content = (args.content as string) ?? ''
return content.length > 80 ? content.slice(0, 80) + '…' : content
}
// 角色
case 'create_character':
return args.name as string ?? ''
case 'update_character':
return `#${args.character_id}` + (args.name ? `${args.name}` : '')
case 'delete_character':
return `#${args.character_id}`
// 章节
case 'create_chapter':
return args.title as string ?? ''
case 'update_chapter':
return `#${args.chapter_id}` + (args.title ? `${args.title}` : '')
case 'delete_chapter':
return `#${args.chapter_id}`
default:
return ''
}
}
/** 格式化工具结果 */
function formatResult(call: ToolCallEntry): string {
if (!call.result) return ''
try {
const data = call.parsedResult ?? JSON.parse(call.result)
// 通用错误检查
if ((data as { error?: string }).error) return `失败: ${(data as { error: string }).error}`
switch (call.name) {
// 大纲
case 'list_outlines': {
const items = (data as { outlines: { id: number; summary: string }[] }).outlines
if (!items?.length) return '暂无大纲'
return items.map((o: { id: number; summary: string }) => `${o.summary}`).join('\n')
}
case 'create_outline':
return `已创建: ${(data as { summary: string }).summary}`
case 'update_outline':
return `已更新: ${(data as { summary: string }).summary}`
case 'delete_outline':
return '已删除'
case 'reorder_outlines':
return `已调整 ${(data as { count: number }).count} 个节点的排序`
// 世界观
case 'get_world_setting': {
const content = (data as { content: string }).content
if (!content) return '暂无世界观设定'
return content.length > 120 ? content.slice(0, 120) + '…' : content
}
case 'save_world_setting':
return `已保存 (${(data as { content_length: number }).content_length} 字)`
// 角色
case 'list_characters': {
const chars = (data as { characters: { id: number; name: string }[] }).characters
if (!chars?.length) return '暂无角色'
return chars.map((c: { id: number; name: string }) => `${c.name}`).join('\n')
}
case 'create_character':
return `已创建: ${(data as { name: string }).name}`
case 'update_character':
return `已更新: ${(data as { name: string }).name}`
case 'delete_character':
return '已删除'
// 章节
case 'list_chapters': {
const chs = (data as { chapters: { id: number; title: string }[] }).chapters
if (!chs?.length) return '暂无章节'
return chs.map((c: { id: number; title: string }) => `${c.title}`).join('\n')
}
case 'create_chapter':
return `已创建: ${(data as { title: string }).title}`
case 'update_chapter':
return `已更新: ${(data as { title: string }).title}`
case 'delete_chapter':
return '已删除'
default:
return call.result.slice(0, 100)
}
} catch {
return call.result.slice(0, 100)
}
}
</script>
<template>
<div class="tool-calls-card">
<div class="tool-calls-header">
<span class="tool-calls-title">工具调用</span>
<span v-if="isPending" class="tool-calls-badge badge--pending">待确认</span>
<span v-else-if="isExecuting" class="tool-calls-badge badge--executing">执行中</span>
<span v-else-if="isDone" class="tool-calls-badge badge--done">已完成</span>
<span v-else-if="isSkipped" class="tool-calls-badge badge--skipped">已跳过</span>
</div>
<!-- 每个工具调用 -->
<div
v-for="call in group.calls"
:key="call.id"
class="tool-item"
:class="{ 'tool-item--danger': call.name.includes('delete') }"
>
<div class="tool-item-header">
<svg class="tool-item-icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path :d="getIcon(call.name)" :stroke="getColor(call.name)" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" fill="none" />
</svg>
<span class="tool-item-label" :style="{ color: getColor(call.name) }">{{ call.label }}</span>
</div>
<!-- 参数 -->
<div v-if="formatArgs(call)" class="tool-item-args">{{ formatArgs(call) }}</div>
<!-- 结果 -->
<div v-if="call.result" class="tool-item-result">
<pre class="result-text">{{ formatResult(call) }}</pre>
</div>
</div>
<!-- 审批按钮 -->
<div v-if="isPending" class="tool-actions">
<button class="tool-btn tool-btn--approve" @click="emit('approve')">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M3 7l3 3 5-5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
执行
</button>
<button class="tool-btn tool-btn--skip" @click="emit('skip')">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M10 4L4 10M4 4l6 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
跳过
</button>
</div>
<!-- 执行中指示 -->
<div v-if="isExecuting" class="tool-executing">
<span class="dot" /><span class="dot" /><span class="dot" />
</div>
</div>
</template>
<style scoped>
.tool-calls-card {
background: var(--paper-50);
border: 1px solid var(--paper-200);
border-radius: var(--radius-md);
padding: var(--space-sm) var(--space-md);
margin-bottom: var(--space-md);
animation: fadeInUp var(--duration-fast) var(--ease-out-smooth) both;
}
.tool-calls-header {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-sm);
}
.tool-calls-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--ink-400);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.tool-calls-badge {
font-size: 0.68rem;
padding: 1px 6px;
border-radius: 8px;
font-weight: 500;
}
.badge--pending {
background: rgba(212, 160, 23, 0.12);
color: #b8860b;
}
.badge--executing {
background: rgba(91, 140, 90, 0.12);
color: var(--bamboo);
animation: toolPulse 1.5s ease-in-out infinite;
}
.badge--done {
background: rgba(91, 140, 90, 0.12);
color: var(--bamboo);
}
.badge--skipped {
background: var(--paper-100);
color: var(--ink-300);
}
.tool-item {
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-sm);
background: var(--paper-100);
margin-bottom: var(--space-xs);
}
.tool-item--danger {
background: rgba(220, 53, 69, 0.04);
}
.tool-item-header {
display: flex;
align-items: center;
gap: 6px;
}
.tool-item-icon {
flex-shrink: 0;
}
.tool-item-label {
font-size: 0.82rem;
font-weight: 600;
}
.tool-item-args {
font-size: 0.78rem;
color: var(--ink-500);
margin-top: 2px;
padding-left: 22px;
word-break: break-word;
}
.tool-item-result {
margin-top: 4px;
padding-left: 22px;
}
.result-text {
font-size: 0.75rem;
color: var(--ink-600);
background: var(--paper-50);
border: 1px solid var(--paper-200);
border-radius: var(--radius-sm);
padding: var(--space-xs) var(--space-sm);
margin: 0;
white-space: pre-wrap;
word-break: break-word;
max-height: 120px;
overflow-y: auto;
font-family: var(--font-body);
}
/* 审批按钮 */
.tool-actions {
display: flex;
gap: var(--space-sm);
margin-top: var(--space-sm);
justify-content: flex-end;
}
.tool-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
border-radius: var(--radius-sm);
border: 1px solid;
font-size: 0.78rem;
font-weight: 500;
cursor: pointer;
transition: all var(--duration-fast) ease;
}
.tool-btn--approve {
background: var(--bamboo);
border-color: var(--bamboo);
color: white;
}
.tool-btn--approve:hover {
opacity: 0.85;
}
.tool-btn--skip {
background: var(--paper-50);
border-color: var(--paper-300);
color: var(--ink-500);
}
.tool-btn--skip:hover {
background: var(--paper-100);
color: var(--ink-700);
}
/* 执行中 */
.tool-executing {
display: flex;
gap: 4px;
justify-content: center;
padding: var(--space-xs) 0;
}
.tool-executing .dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--bamboo);
animation: typingBounce 1.4s ease-in-out infinite;
}
.tool-executing .dot:nth-child(2) { animation-delay: 0.2s; }
.tool-executing .dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes toolPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes typingBounce {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-4px); }
}
</style>

View File

@@ -0,0 +1,183 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const props = defineProps<{
novelId: number
novelTitle: string
/** 当前页面的辅助信息,如 "共 5 个节点" */
subtitle?: string
}>()
const route = useRoute()
const NAV_ITEMS = [
{ name: 'novel-outlines', label: '大纲', icon: 'M3 4h10M3 8h10M3 12h6' },
{ name: 'novel-chapters', label: '章节', icon: 'M4 2h8v12H4zM6 5h4M6 8h4' },
{ name: 'novel-characters', label: '角色', icon: 'M5.5 5a2.5 2.5 0 105 0 2.5 2.5 0 00-5 0zM3 13c0-2.5 2-4 5-4s5 1.5 5 4' },
{ name: 'novel-world-setting', label: '世界观', icon: 'M8 1a7 7 0 100 14A7 7 0 008 1zM1 8h14M8 1c2 2 3 4.5 3 7s-1 5-3 7M8 1c-2 2-3 4.5-3 7s1 5 3 7' },
]
const currentRoute = computed(() => route.name as string)
</script>
<template>
<div class="sub-nav">
<!-- 第一行返回 + 标题 + 右侧操作 -->
<div class="sub-nav-top">
<RouterLink
:to="{ name: 'novel-detail', params: { id: novelId } }"
class="back-link"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M10 3L5 8l5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span class="back-title">{{ novelTitle }}</span>
</RouterLink>
<div class="sub-nav-actions">
<slot name="actions" />
</div>
</div>
<!-- 第二行导航标签 + 辅助信息 -->
<div class="sub-nav-bar">
<nav class="nav-tabs">
<RouterLink
v-for="item in NAV_ITEMS"
:key="item.name"
:to="{ name: item.name, params: { id: novelId } }"
class="nav-tab"
:class="{ 'nav-tab--active': currentRoute === item.name }"
>
<svg class="nav-tab-icon" width="14" height="14" viewBox="0 0 16 16" fill="none">
<path :d="item.icon" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" fill="none" />
</svg>
<span>{{ item.label }}</span>
</RouterLink>
</nav>
<span v-if="subtitle" class="nav-subtitle">{{ subtitle }}</span>
</div>
</div>
</template>
<style scoped>
.sub-nav {
margin-bottom: var(--space-xl);
animation: fadeInUp var(--duration-normal) var(--ease-out-smooth) both;
}
/* ── 顶部行:返回 + 操作按钮 ── */
.sub-nav-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-md);
}
.back-link {
display: inline-flex;
align-items: center;
gap: 6px;
text-decoration: none;
color: var(--ink-400);
transition: color var(--duration-fast) ease;
min-width: 0;
}
.back-link:hover {
color: var(--vermilion);
}
.back-link svg {
flex-shrink: 0;
transition: transform var(--duration-fast) ease;
}
.back-link:hover svg {
transform: translateX(-2px);
}
.back-title {
font-family: var(--font-display);
font-size: 1.35rem;
font-weight: 700;
color: var(--ink-900);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: color var(--duration-fast) ease;
}
.back-link:hover .back-title {
color: var(--vermilion);
}
.sub-nav-actions {
flex-shrink: 0;
}
/* ── 导航标签栏 ── */
.sub-nav-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
border-bottom: 1px solid var(--paper-200);
padding-bottom: 0;
}
.nav-tabs {
display: flex;
gap: 2px;
}
.nav-tab {
display: inline-flex;
align-items: center;
gap: 5px;
padding: var(--space-sm) var(--space-md);
font-size: 0.88rem;
font-weight: 500;
color: var(--ink-400);
text-decoration: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px; /* 与底部边线重叠 */
transition: all var(--duration-fast) ease;
position: relative;
}
.nav-tab:hover {
color: var(--ink-700);
background: var(--paper-100);
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
}
.nav-tab--active {
color: var(--vermilion);
border-bottom-color: var(--vermilion);
font-weight: 600;
}
.nav-tab--active:hover {
color: var(--vermilion);
background: transparent;
}
.nav-tab-icon {
flex-shrink: 0;
opacity: 0.7;
}
.nav-tab--active .nav-tab-icon {
opacity: 1;
}
.nav-subtitle {
font-size: 0.8rem;
color: var(--ink-300);
white-space: nowrap;
padding-bottom: var(--space-sm);
}
</style>

View File

@@ -0,0 +1,187 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { Outline } from '@/types/outline'
import OutlineChildNode from './OutlineChildNode.vue'
const props = defineProps<{
children: Outline[]
expanded: boolean
}>()
const emit = defineEmits<{
addChild: []
editChild: [child: Outline]
deleteChild: [child: Outline]
reorderChildren: [children: Outline[]]
}>()
// 子节点拖拽排序
const dragIndex = ref<number | null>(null)
const dragOverIndex = ref<number | null>(null)
function onDragStart(index: number) {
dragIndex.value = index
}
function onDragOver(index: number) {
if (dragIndex.value === null || dragIndex.value === index) return
dragOverIndex.value = index
}
function onDragEnd() {
if (dragIndex.value !== null && dragOverIndex.value !== null && dragIndex.value !== dragOverIndex.value) {
const items = [...props.children]
const [moved] = items.splice(dragIndex.value, 1)
items.splice(dragOverIndex.value, 0, moved)
emit('reorderChildren', items)
}
dragIndex.value = null
dragOverIndex.value = null
}
</script>
<template>
<div class="child-list-wrapper" :class="{ 'child-list-wrapper--open': expanded }">
<div class="child-list-inner">
<div class="child-list">
<!-- 竹青竖线 -->
<div class="child-line" />
<!-- 子节点卡片 -->
<div
v-for="(child, index) in children"
:key="child.id"
class="child-item"
:class="{
'child-item--dragging': dragIndex === index,
'child-item--drag-over': dragOverIndex === index,
}"
draggable="true"
@dragstart.stop="onDragStart(index)"
@dragover.prevent.stop="onDragOver(index)"
@dragend.stop="onDragEnd"
>
<OutlineChildNode
:child="child"
:index="index"
@edit="emit('editChild', child)"
@delete="emit('deleteChild', child)"
/>
</div>
<!-- 添加子节点按钮 -->
<div class="child-add-wrapper">
<button class="child-add-btn" @click.stop="emit('addChild')">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M7 3v8M3 7h8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
</svg>
<span>添加子节点</span>
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* 展开容器grid-template-rows 过渡 */
.child-list-wrapper {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows var(--duration-normal) var(--ease-out-smooth);
overflow: hidden;
}
.child-list-wrapper--open {
grid-template-rows: 1fr;
}
.child-list-inner {
min-height: 0;
overflow: hidden;
}
.child-list {
position: relative;
padding: var(--space-md) 0 var(--space-sm) var(--space-xl);
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
/* 竹青竖线 */
.child-line {
position: absolute;
left: 10px;
top: 0;
bottom: var(--space-md);
width: 2px;
background: linear-gradient(
to bottom,
var(--bamboo),
rgba(91, 140, 90, 0.3)
);
border-radius: 1px;
}
/* 子节点项 */
.child-item {
position: relative;
transition: opacity var(--duration-fast) ease;
}
.child-item--dragging {
opacity: 0.4;
}
.child-item--drag-over::before {
content: '';
position: absolute;
top: -4px;
left: 0;
right: 0;
height: 2px;
background: var(--bamboo);
border-radius: 1px;
z-index: 5;
}
/* 添加子节点按钮 */
.child-add-wrapper {
padding-left: var(--space-sm);
}
.child-add-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
background: none;
border: 1px dashed rgba(91, 140, 90, 0.4);
border-radius: var(--radius-sm);
color: var(--bamboo);
font-size: 0.76rem;
font-family: var(--font-display);
cursor: pointer;
transition: all var(--duration-fast) ease;
opacity: 0.7;
}
.child-add-btn:hover {
opacity: 1;
border-color: var(--bamboo);
background: rgba(91, 140, 90, 0.06);
transform: translateY(-1px);
}
/* 响应式 */
@media (max-width: 700px) {
.child-list {
padding-left: 36px;
}
.child-line {
left: 28px;
}
}
</style>

View File

@@ -0,0 +1,237 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { Outline } from '@/types/outline'
defineProps<{
child: Outline
index: number
}>()
const emit = defineEmits<{
edit: []
delete: []
}>()
const expanded = ref(false)
function toggle() {
expanded.value = !expanded.value
}
</script>
<template>
<div
class="child-node"
:class="{ 'child-node--expanded': expanded }"
:style="{ '--child-delay': `${index * 80}ms` }"
@click="toggle"
>
<!-- 左侧竹青圆点 -->
<div class="child-dot" />
<div class="child-content">
<!-- 摘要 -->
<h4 class="child-summary">{{ child.summary }}</h4>
<!-- 详情可展开 -->
<div class="child-detail-wrapper" :class="{ 'child-detail-wrapper--open': expanded }">
<div class="child-detail-inner">
<p v-if="child.detail" class="child-detail">{{ child.detail }}</p>
<p v-else class="child-detail child-detail--empty">暂无详情</p>
<!-- 操作按钮 -->
<div class="child-actions" @click.stop>
<button class="child-action-btn child-action-btn--edit" @click="emit('edit')">
<svg width="12" height="12" viewBox="0 0 14 14" fill="none">
<path d="M10.5 1.5l2 2-8 8H2.5v-2l8-8z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
编辑
</button>
<button class="child-action-btn child-action-btn--delete" @click="emit('delete')">
<svg width="12" height="12" viewBox="0 0 14 14" fill="none">
<path d="M2 4h10M5 4V2.5h4V4M3.5 4v8h7V4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
删除
</button>
</div>
</div>
</div>
<!-- 展开箭头 -->
<div class="child-expand-hint">
<svg
class="child-chevron"
:class="{ 'child-chevron--open': expanded }"
width="10"
height="10"
viewBox="0 0 12 12"
fill="none"
>
<path d="M3 4.5l3 3 3-3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
</div>
</div>
</template>
<style scoped>
.child-node {
position: relative;
display: flex;
align-items: flex-start;
gap: var(--space-md);
padding: var(--space-md) var(--space-lg);
background: var(--paper-100);
border: 1px solid var(--paper-200);
border-radius: var(--radius-md);
cursor: pointer;
transition:
transform var(--duration-fast) ease,
box-shadow var(--duration-fast) ease,
border-color var(--duration-fast) ease;
animation: childFadeInUp var(--duration-normal) var(--ease-out-smooth) both;
animation-delay: var(--child-delay);
}
.child-node:hover {
transform: translateY(-2px);
box-shadow: 0 2px 12px rgba(91, 140, 90, 0.1);
border-color: var(--bamboo);
}
.child-node--expanded {
border-color: var(--bamboo);
border-left-width: 3px;
background: var(--paper-50);
box-shadow: 0 2px 12px rgba(91, 140, 90, 0.08);
}
/* 左侧竹青圆点 */
.child-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--bamboo);
flex-shrink: 0;
margin-top: 6px;
opacity: 0.7;
transition: all var(--duration-fast) ease;
}
.child-node:hover .child-dot,
.child-node--expanded .child-dot {
opacity: 1;
transform: scale(1.2);
}
/* 内容区 */
.child-content {
flex: 1;
min-width: 0;
}
.child-summary {
font-family: var(--font-display);
font-size: 0.9rem;
font-weight: 600;
color: var(--ink-800);
line-height: 1.5;
}
/* 详情展开区 */
.child-detail-wrapper {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows var(--duration-normal) var(--ease-out-smooth);
overflow: hidden;
}
.child-detail-wrapper--open {
grid-template-rows: 1fr;
}
.child-detail-inner {
min-height: 0;
overflow: hidden;
}
.child-detail {
margin-top: var(--space-sm);
padding-top: var(--space-sm);
border-top: 1px dashed rgba(91, 140, 90, 0.25);
font-size: 0.82rem;
color: var(--ink-500);
line-height: 1.7;
}
.child-detail--empty {
font-style: italic;
color: var(--ink-300);
}
/* 操作按钮 */
.child-actions {
display: flex;
gap: var(--space-xs);
margin-top: var(--space-sm);
}
.child-action-btn {
display: flex;
align-items: center;
gap: 3px;
padding: 2px 8px;
background: none;
border: 1px solid var(--paper-200);
border-radius: var(--radius-sm);
font-size: 0.72rem;
cursor: pointer;
transition: all var(--duration-fast) ease;
font-family: var(--font-body);
}
.child-action-btn--edit {
color: var(--bamboo);
}
.child-action-btn--edit:hover {
background: rgba(91, 140, 90, 0.08);
border-color: var(--bamboo);
}
.child-action-btn--delete {
color: var(--danger);
}
.child-action-btn--delete:hover {
background: var(--danger-ghost);
border-color: var(--danger);
}
/* 展开箭头 */
.child-expand-hint {
display: flex;
justify-content: center;
margin-top: 4px;
color: var(--ink-300);
}
.child-chevron {
transition: transform var(--duration-normal) var(--ease-out-back);
}
.child-chevron--open {
transform: rotate(180deg);
}
@keyframes childFadeInUp {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -10,6 +10,7 @@ const props = defineProps<{
show: boolean show: boolean
outline: Outline | null outline: Outline | null
saving: boolean saving: boolean
parentId?: number | null
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -21,7 +22,11 @@ const summary = ref('')
const detail = ref('') const detail = ref('')
const isEdit = computed(() => !!props.outline) const isEdit = computed(() => !!props.outline)
const title = computed(() => isEdit.value ? '编辑大纲' : '新建大纲') const isChild = computed(() => !!props.parentId)
const title = computed(() => {
if (isEdit.value) return isChild.value ? '编辑子节点' : '编辑大纲'
return isChild.value ? '新建子节点' : '新建大纲'
})
const detailLength = computed(() => detail.value.length) const detailLength = computed(() => detail.value.length)
const detailHint = computed(() => { const detailHint = computed(() => {
@@ -42,17 +47,24 @@ watch(() => props.show, (val) => {
function handleSubmit() { function handleSubmit() {
if (!canSubmit.value) return if (!canSubmit.value) return
emit('submit', { const data: OutlineCreate | OutlineUpdate = {
summary: summary.value.trim(), summary: summary.value.trim(),
detail: detail.value.trim(), detail: detail.value.trim(),
}) }
// 新建子节点时附带 parent_id
if (!isEdit.value && props.parentId) {
(data as OutlineCreate).parent_id = props.parentId
}
emit('submit', data)
} }
</script> </script>
<template> <template>
<BaseModal :show="show" @close="emit('close')"> <BaseModal :show="show" @close="emit('close')">
<form class="outline-form" @submit.prevent="handleSubmit"> <form class="outline-form" @submit.prevent="handleSubmit">
<h2 class="form-title">{{ title }}</h2> <h2 class="form-title" :class="{ 'form-title--child': isChild }">
{{ title }}
</h2>
<BaseInput <BaseInput
v-model="summary" v-model="summary"
@@ -98,6 +110,10 @@ function handleSubmit() {
color: var(--ink-900); color: var(--ink-900);
} }
.form-title--child {
color: var(--bamboo);
}
.detail-field { .detail-field {
position: relative; position: relative;
} }

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, computed, watch } from 'vue'
import type { Outline } from '@/types/outline' import type { Outline } from '@/types/outline'
import OutlineChildList from './OutlineChildList.vue'
const props = defineProps<{ const props = defineProps<{
outline: Outline outline: Outline
@@ -10,77 +11,146 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
edit: [] edit: []
delete: [] delete: []
addChild: []
editChild: [child: Outline]
deleteChild: [child: Outline]
reorderChildren: [children: Outline[]]
}>() }>()
const expanded = ref(false) const expanded = ref(false)
const childrenExpanded = ref(false)
const childCount = computed(() => props.outline.children?.length ?? 0)
const hasChildren = computed(() => childCount.value > 0)
// 子节点从无变有时自动展开(如 AI 创建了子节点)
watch(hasChildren, (val, oldVal) => {
if (val && !oldVal) childrenExpanded.value = true
})
function toggle() { function toggle() {
expanded.value = !expanded.value expanded.value = !expanded.value
} }
function toggleChildren(e: Event) {
e.stopPropagation()
childrenExpanded.value = !childrenExpanded.value
}
</script> </script>
<template> <template>
<div <div class="outline-node-wrapper">
class="outline-node" <div
:class="{ 'outline-node--expanded': expanded }" class="outline-node"
@click="toggle" :class="{
> 'outline-node--expanded': expanded,
<!-- 拖拽手柄 --> 'outline-node--has-children': hasChildren,
<div class="node-drag-handle" @click.stop> 'outline-node--children-open': childrenExpanded,
<svg width="12" height="16" viewBox="0 0 12 16" fill="none"> }"
<circle cx="3" cy="2" r="1.5" fill="currentColor" /> @click="toggle"
<circle cx="9" cy="2" r="1.5" fill="currentColor" /> >
<circle cx="3" cy="8" r="1.5" fill="currentColor" /> <!-- 拖拽手柄 -->
<circle cx="9" cy="8" r="1.5" fill="currentColor" /> <div class="node-drag-handle" @click.stop>
<circle cx="3" cy="14" r="1.5" fill="currentColor" /> <svg width="12" height="16" viewBox="0 0 12 16" fill="none">
<circle cx="9" cy="14" r="1.5" fill="currentColor" /> <circle cx="3" cy="2" r="1.5" fill="currentColor" />
</svg> <circle cx="9" cy="2" r="1.5" fill="currentColor" />
</div> <circle cx="3" cy="8" r="1.5" fill="currentColor" />
<circle cx="9" cy="8" r="1.5" fill="currentColor" />
<circle cx="3" cy="14" r="1.5" fill="currentColor" />
<circle cx="9" cy="14" r="1.5" fill="currentColor" />
</svg>
</div>
<!-- 概要 --> <!-- 子节点数量角标 -->
<h3 class="node-summary">{{ outline.summary }}</h3> <div v-if="hasChildren" class="child-badge" @click="toggleChildren">
<span class="child-badge-count">{{ childCount }}</span>
</div>
<!-- 详情可展开 --> <!-- 概要 -->
<div class="node-detail-wrapper" :class="{ 'node-detail-wrapper--open': expanded }"> <div class="node-summary-row">
<div class="node-detail-inner"> <h3 class="node-summary">{{ outline.summary }}</h3>
<p v-if="outline.detail" class="node-detail">{{ outline.detail }}</p> <!-- 子节点展开/折叠切换 -->
<p v-else class="node-detail node-detail--empty">暂无详情点击编辑添加</p> <button
v-if="hasChildren"
class="children-toggle"
:class="{ 'children-toggle--open': childrenExpanded }"
:title="childrenExpanded ? '折叠子节点' : '展开子节点'"
@click="toggleChildren"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M4.5 3l5 4-5 4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span class="children-toggle-label">{{ childCount }} 个子节点</span>
</button>
</div>
<!-- 操作按钮 --> <!-- 详情可展开 -->
<div class="node-actions" @click.stop> <div class="node-detail-wrapper" :class="{ 'node-detail-wrapper--open': expanded }">
<button class="action-btn action-btn--edit" @click="emit('edit')"> <div class="node-detail-inner">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"> <p v-if="outline.detail" class="node-detail">{{ outline.detail }}</p>
<path d="M10.5 1.5l2 2-8 8H2.5v-2l8-8z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" /> <p v-else class="node-detail node-detail--empty">暂无详情点击编辑添加</p>
</svg>
编辑 <!-- 操作按钮 -->
</button> <div class="node-actions" @click.stop>
<button class="action-btn action-btn--delete" @click="emit('delete')"> <button class="action-btn action-btn--edit" @click="emit('edit')">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"> <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M2 4h10M5 4V2.5h4V4M3.5 4v8h7V4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" /> <path d="M10.5 1.5l2 2-8 8H2.5v-2l8-8z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg> </svg>
删除 编辑
</button> </button>
<button class="action-btn action-btn--child" @click="emit('addChild')">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M7 3v8M3 7h8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
</svg>
子节点
</button>
<button class="action-btn action-btn--delete" @click="emit('delete')">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M2 4h10M5 4V2.5h4V4M3.5 4v8h7V4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
删除
</button>
</div>
</div> </div>
</div> </div>
<!-- 展开提示 -->
<div class="node-expand-hint">
<svg
class="expand-chevron"
:class="{ 'expand-chevron--open': expanded }"
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
>
<path d="M3 4.5l3 3 3-3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
</div> </div>
<!-- 展开提示 --> <!-- 子节点展开引导三角 -->
<div class="node-expand-hint"> <div v-if="childrenExpanded" class="children-arrow" />
<svg
class="expand-chevron" <!-- 子节点区域 -->
:class="{ 'expand-chevron--open': expanded }" <OutlineChildList
width="12" v-if="hasChildren || childrenExpanded"
height="12" :children="outline.children || []"
viewBox="0 0 12 12" :expanded="childrenExpanded"
fill="none" @add-child="emit('addChild')"
> @edit-child="(child) => emit('editChild', child)"
<path d="M3 4.5l3 3 3-3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" /> @delete-child="(child) => emit('deleteChild', child)"
</svg> @reorder-children="(children) => emit('reorderChildren', children)"
</div> />
</div> </div>
</template> </template>
<style scoped> <style scoped>
.outline-node-wrapper {
display: flex;
flex-direction: column;
}
.outline-node { .outline-node {
position: relative; position: relative;
background: var(--paper-50); background: var(--paper-50);
@@ -107,6 +177,33 @@ function toggle() {
border-left-width: 3px; border-left-width: 3px;
} }
.outline-node--children-open {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
/* 子节点展开引导三角 */
.children-arrow {
width: 12px;
height: 8px;
margin: 0 auto;
position: relative;
z-index: 2;
}
.children-arrow::before {
content: '';
position: absolute;
left: 0;
right: 0;
top: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 8px solid var(--bamboo);
opacity: 0.6;
}
/* 拖拽手柄 */ /* 拖拽手柄 */
.node-drag-handle { .node-drag-handle {
position: absolute; position: absolute;
@@ -127,6 +224,44 @@ function toggle() {
cursor: grabbing; cursor: grabbing;
} }
/* 子节点数量角标 */
.child-badge {
position: absolute;
top: -6px;
right: var(--space-xl);
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 10px;
background: var(--bamboo);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform var(--duration-fast) var(--ease-out-back);
z-index: 4;
box-shadow: 0 1px 4px rgba(91, 140, 90, 0.3);
}
.child-badge:hover {
transform: scale(1.15);
}
.child-badge-count {
font-family: var(--font-display);
font-size: 0.68rem;
font-weight: 700;
color: white;
line-height: 1;
}
/* 概要行 */
.node-summary-row {
display: flex;
flex-direction: column;
gap: 4px;
}
/* 概要标题 */ /* 概要标题 */
.node-summary { .node-summary {
font-family: var(--font-display); font-family: var(--font-display);
@@ -137,6 +272,41 @@ function toggle() {
padding-right: var(--space-lg); padding-right: var(--space-lg);
} }
/* 子节点展开/折叠切换 */
.children-toggle {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 2px 8px;
background: rgba(91, 140, 90, 0.06);
border: 1px solid rgba(91, 140, 90, 0.2);
border-radius: 12px;
color: var(--bamboo);
font-size: 0.72rem;
font-family: var(--font-display);
cursor: pointer;
transition: all var(--duration-fast) ease;
width: fit-content;
}
.children-toggle:hover {
background: rgba(91, 140, 90, 0.12);
border-color: var(--bamboo);
}
.children-toggle svg {
transition: transform var(--duration-normal) var(--ease-out-back);
flex-shrink: 0;
}
.children-toggle--open svg {
transform: rotate(90deg);
}
.children-toggle-label {
white-space: nowrap;
}
/* 详情展开区 */ /* 详情展开区 */
.node-detail-wrapper { .node-detail-wrapper {
display: grid; display: grid;
@@ -199,6 +369,15 @@ function toggle() {
border-color: var(--bamboo); border-color: var(--bamboo);
} }
.action-btn--child {
color: var(--bamboo);
}
.action-btn--child:hover {
background: rgba(91, 140, 90, 0.08);
border-color: var(--bamboo);
}
.action-btn--delete { .action-btn--delete {
color: var(--danger); color: var(--danger);
} }

View File

@@ -12,6 +12,10 @@ const emit = defineEmits<{
delete: [outline: Outline] delete: [outline: Outline]
reorder: [outlines: Outline[]] reorder: [outlines: Outline[]]
add: [] add: []
addChild: [parent: Outline]
editChild: [child: Outline]
deleteChild: [child: Outline]
reorderChildren: [parentId: number, children: Outline[]]
}>() }>()
// 拖拽排序 // 拖拽排序
@@ -69,13 +73,17 @@ function onDragEnd() {
<!-- 连接横线 --> <!-- 连接横线 -->
<div class="timeline-bridge" /> <div class="timeline-bridge" />
<!-- 节点卡片 --> <!-- 节点卡片含子节点 -->
<div class="timeline-card"> <div class="timeline-card">
<OutlineNode <OutlineNode
:outline="outline" :outline="outline"
:index="index" :index="index"
@edit="emit('edit', outline)" @edit="emit('edit', outline)"
@delete="emit('delete', outline)" @delete="emit('delete', outline)"
@add-child="emit('addChild', outline)"
@edit-child="(child) => emit('editChild', child)"
@delete-child="(child) => emit('deleteChild', child)"
@reorder-children="(children) => emit('reorderChildren', outline.id, children)"
/> />
</div> </div>
</div> </div>

View File

@@ -1,30 +1,175 @@
import { ref } from 'vue' import { ref, computed, watch } from 'vue'
import { streamChat, fetchMessages, clearMessages as apiClearMessages } from '@/api/chat' import { useRoute } from 'vue-router'
import type { ChatMessage } from '@/types/chat' import { streamChat, fetchMessages, clearMessages as apiClearMessages, compressContext } from '@/api/chat'
import type { TokenUsage } from '@/api/chat' import type { ChatMessage, ChatEntry, ToolCallGroup } from '@/types/chat'
import type { TokenUsage, PendingToolCallData } from '@/api/chat'
// 中文标签 → 英文工具名(用于解析历史消息中的工具调用日志)
const LABEL_TO_TOOL_NAME: Record<string, string> = {
'查看小说信息': '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',
}
/**
* 解析历史 assistant 消息中的工具调用日志,拆分为 ToolCallGroup + 纯文本消息
* 格式:[以下操作已执行]\n[工具调用: 标签] 参数: {...} → 结果: {...}\n\n回复文本
*/
function parseHistoryMessage(msg: ChatMessage): ChatEntry[] {
const content = msg.content
if (!content.startsWith('[以下操作已执行]')) {
return [msg]
}
const entries: ChatEntry[] = []
// 用双换行分隔工具调用块和后续回复
const firstBlankLine = content.indexOf('\n\n')
const toolSection = firstBlankLine >= 0 ? content.substring(0, firstBlankLine) : content
const replyText = firstBlankLine >= 0 ? content.substring(firstBlankLine + 2).trim() : ''
// 解析工具调用行
const toolLines = toolSection.split('\n').slice(1) // 跳过 [以下操作已执行]
const calls = toolLines
.map(line => {
const match = line.match(/^\[工具调用: (.+?)\] 参数: (.+?) → 结果: (.+)$/)
if (!match) return null
const label = match[1]
const name = LABEL_TO_TOOL_NAME[label] || label
let args: Record<string, unknown> = {}
try { args = JSON.parse(match[2]) } catch { /* ok */ }
const resultStr = match[3]
let parsedResult: unknown = undefined
try { parsedResult = JSON.parse(resultStr) } catch { /* ok */ }
return {
id: `hist-${idCounter++}`,
name,
label,
arguments: args,
result: resultStr,
parsedResult,
}
})
.filter(Boolean) as ToolCallGroup['calls']
if (calls.length > 0) {
entries.push({
id: idCounter++,
type: 'tool_calls',
calls,
status: 'done',
assistantText: '',
} as ToolCallGroup)
}
if (replyText) {
entries.push({
...msg,
content: replyText,
})
}
return entries
}
// 全局状态(跨组件共享) // 全局状态(跨组件共享)
const messages = ref<ChatMessage[]>([]) const entries = ref<ChatEntry[]>([])
const isStreaming = ref(false) const isStreaming = ref(false)
const streamingContent = ref('') const streamingContent = ref('')
const lastUsage = ref<TokenUsage | null>(null) const lastUsage = ref<TokenUsage | null>(null)
const toolsEnabled = ref(true)
const autoApproveTools = ref(false)
const isCompressing = ref(false)
let abortController: AbortController | null = null let abortController: AbortController | null = null
let idCounter = Date.now() let idCounter = Date.now()
// 上下文窗口大小(默认 128k后续可从配置读取
const DEFAULT_CONTEXT_WINDOW = 128000
// 自动压缩阈值
const AUTO_COMPRESS_THRESHOLD = 0.85
// 当前已加载历史的小说 ID用于检测切换
let loadedNovelId: number | undefined | null = null
// 路由名称 → 页面上下文描述
const PAGE_LABELS: Record<string, string> = {
'novel-detail': '小说详情页',
'novel-outlines': '大纲管理页',
'novel-chapters': '章节管理页',
'novel-characters': '角色管理页',
'novel-world-setting': '世界观设定页',
'novel-edit': '小说编辑页',
}
export function useChatAgent() { export function useChatAgent() {
const route = useRoute()
const currentNovelId = computed(() => {
const id = route.params.id
return id ? Number(id) : undefined
})
const pageContext = computed(() => {
const name = route.name as string
return PAGE_LABELS[name] ?? undefined
})
/** 仅普通消息(用于发送给 API */
const chatMessages = computed(() =>
entries.value.filter((e): e is ChatMessage => !('type' in e))
)
async function loadHistory(novelId?: number) { async function loadHistory(novelId?: number) {
const targetId = novelId ?? currentNovelId.value
try { try {
const list = await fetchMessages(novelId) const list = await fetchMessages(targetId)
messages.value = list.items // 解析 assistant 消息中的工具调用日志,还原为 ToolCallGroup 卡片
const parsed: ChatEntry[] = []
for (const msg of list.items) {
if (msg.role === 'assistant') {
parsed.push(...parseHistoryMessage(msg))
} else {
parsed.push(msg)
}
}
entries.value = parsed
loadedNovelId = targetId
lastUsage.value = null
} catch { } catch {
// 静默失败 // 静默
} }
} }
async function sendMessage(content: string, novelId?: number) { // 监听小说切换,自动重新加载对应小说的聊天历史
if (isStreaming.value || !content.trim()) return watch(currentNovelId, (newId) => {
if (newId !== loadedNovelId) {
// 如果正在流式输出,先中断
if (isStreaming.value && abortController) {
abortController.abort()
abortController = null
isStreaming.value = false
streamingContent.value = ''
}
loadHistory(newId)
}
})
async function sendMessage(content: string) {
if (isStreaming.value || !content.trim()) return
const novelId = currentNovelId.value
// 添加用户消息到列表
const userMsg: ChatMessage = { const userMsg: ChatMessage = {
id: idCounter++, id: idCounter++,
novel_id: novelId ?? null, novel_id: novelId ?? null,
@@ -32,53 +177,169 @@ export function useChatAgent() {
content: content.trim(), content: content.trim(),
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
} }
messages.value.push(userMsg) entries.value.push(userMsg)
// 开始流式请求 await _doStreamRequest(novelId)
}
/** 用户批准工具调用 */
async function approveToolCalls(group: ToolCallGroup) {
group.status = 'executing'
const novelId = currentNovelId.value
await _doStreamRequest(novelId, group)
}
/** 用户跳过工具调用 */
function skipToolCalls(group: ToolCallGroup) {
group.status = 'skipped'
// 把 LLM 在工具调用前的文本作为 assistant 消息保存
if (group.assistantText) {
entries.value.push({
id: idCounter++,
novel_id: currentNovelId.value ?? null,
role: 'assistant',
content: group.assistantText,
created_at: new Date().toISOString(),
})
}
isStreaming.value = false
}
/** 核心:发起流式请求 */
async function _doStreamRequest(novelId?: number, approvedGroup?: ToolCallGroup) {
isStreaming.value = true isStreaming.value = true
streamingContent.value = '' streamingContent.value = ''
abortController = new AbortController() abortController = new AbortController()
// 构建发送给API的消息列表取最近20条 // 构建消息列表(取最近 20 条)
const recentMessages = messages.value const recentMessages = chatMessages.value
.filter(m => m.role === 'user' || m.role === 'assistant')
.slice(-20) .slice(-20)
.map(m => ({ role: m.role, content: m.content })) .map(m => ({ role: m.role, content: m.content }))
// 审批续传参数
const pendingToolCalls = approvedGroup
? approvedGroup.calls.map(c => ({ id: c.id, name: c.name, label: c.label, arguments: c.arguments }))
: undefined
const assistantText = approvedGroup?.assistantText
await streamChat({ await streamChat({
messages: recentMessages, messages: recentMessages,
novelId, novelId,
pageContext: pageContext.value,
toolsEnabled: toolsEnabled.value,
autoApproveTools: autoApproveTools.value,
pendingToolCalls,
assistantText,
signal: abortController.signal, signal: abortController.signal,
onChunk(text) { onChunk(text) {
streamingContent.value += text streamingContent.value += text
}, },
onToolCallsPending(calls: PendingToolCallData[], aText: string) {
// 先把当前 streaming 文本保存(如果有)
// LLM 在调用工具前可能输出了一些文本
if (streamingContent.value) {
// 不作为独立消息,因为它会在续传后被合并
// 这段文本已包含在 aText 中
}
streamingContent.value = ''
// 创建工具调用组
const group: ToolCallGroup = {
id: idCounter++,
type: 'tool_calls',
calls: calls.map(c => ({
id: c.id,
name: c.name,
label: c.label,
arguments: c.arguments,
})),
status: 'pending',
assistantText: aText,
}
entries.value.push(group)
},
onToolCallsAuto(calls: PendingToolCallData[]) {
// 自动执行模式:直接创建工具调用组,状态为 executing
streamingContent.value = ''
const group: ToolCallGroup = {
id: idCounter++,
type: 'tool_calls',
calls: calls.map(c => ({
id: c.id,
name: c.name,
label: c.label,
arguments: c.arguments,
})),
status: 'executing',
assistantText: '',
}
entries.value.push(group)
},
onToolResult(info) {
// 找到正在执行的工具组,更新对应工具的结果
const group = [...entries.value].reverse().find(
(e): e is ToolCallGroup => 'type' in e && e.type === 'tool_calls' && (e.status === 'executing' || e.status === 'done')
)
if (group) {
const call = group.calls.find(c => c.name === info.name)
if (call) {
call.result = info.result
try { call.parsedResult = JSON.parse(info.result) } catch { /* ok */ }
}
// 所有工具都有结果 → 标记完成
if (group.calls.every(c => c.result)) {
group.status = 'done'
}
}
// 通知页面组件刷新数据
window.dispatchEvent(new CustomEvent('tool-executed', {
detail: { name: info.name },
}))
},
onError(error) { onError(error) {
const errMsg: ChatMessage = { entries.value.push({
id: idCounter++, id: idCounter++,
novel_id: novelId ?? null, novel_id: novelId ?? null,
role: 'assistant', role: 'assistant',
content: `⚠️ ${error}`, content: `⚠️ ${error}`,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
} })
messages.value.push(errMsg)
}, },
onDone(usage) {
onDone(usage, pending) {
if (usage) { if (usage) {
lastUsage.value = usage lastUsage.value = usage
} }
if (pending) {
isStreaming.value = false
return
}
// 正常完成
if (streamingContent.value) { if (streamingContent.value) {
const aiMsg: ChatMessage = { entries.value.push({
id: idCounter++, id: idCounter++,
novel_id: novelId ?? null, novel_id: novelId ?? null,
role: 'assistant', role: 'assistant',
content: streamingContent.value, content: streamingContent.value,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
} })
messages.value.push(aiMsg)
} }
streamingContent.value = '' streamingContent.value = ''
isStreaming.value = false isStreaming.value = false
abortController = null abortController = null
// 自动压缩检测:超过 85% 上下文窗口时触发
if (usage) {
const total = usage.total_tokens || (usage.prompt_tokens + usage.completion_tokens)
if (total > DEFAULT_CONTEXT_WINDOW * AUTO_COMPRESS_THRESHOLD) {
compressChat()
}
}
}, },
}) })
} }
@@ -90,25 +351,60 @@ export function useChatAgent() {
} }
} }
async function clearChat(novelId?: number) { async function clearChat() {
try { try {
await apiClearMessages(novelId) await apiClearMessages(currentNovelId.value)
} catch { } catch {
// 静默 // 静默
} }
messages.value = [] entries.value = []
streamingContent.value = '' streamingContent.value = ''
lastUsage.value = null lastUsage.value = null
} }
/** 压缩上下文:总结旧消息,保留最近两轮对话 */
async function compressChat(): Promise<{ success: boolean; message: string }> {
if (isCompressing.value || isStreaming.value) {
return { success: false, message: '请等待当前操作完成' }
}
isCompressing.value = true
try {
const result = await compressContext(currentNovelId.value)
if (result.error) {
return { success: false, message: result.error }
}
if (!result.compressed) {
return { success: false, message: result.reason || '无需压缩' }
}
// 压缩成功,重新加载历史
await loadHistory()
return {
success: true,
message: `已压缩 ${result.removed_count} 条消息为摘要`,
}
} catch {
return { success: false, message: '压缩请求失败' }
} finally {
isCompressing.value = false
}
}
return { return {
messages, entries,
isStreaming, isStreaming,
isCompressing,
streamingContent, streamingContent,
lastUsage, lastUsage,
toolsEnabled,
autoApproveTools,
currentNovelId,
pageContext,
loadHistory, loadHistory,
sendMessage, sendMessage,
approveToolCalls,
skipToolCalls,
stopStreaming, stopStreaming,
clearChat, clearChat,
compressChat,
} }
} }

View File

@@ -0,0 +1,23 @@
import { onMounted, onUnmounted } from 'vue'
/**
* 监听 AI 工具执行事件,当匹配的工具完成时自动刷新数据
* @param toolNames 需要监听的工具名列表
* @param refresh 刷新回调
*/
export function useToolRefresh(toolNames: string[], refresh: () => void) {
function handler(e: Event) {
const name = (e as CustomEvent).detail?.name
if (toolNames.includes(name)) {
refresh()
}
}
onMounted(() => {
window.addEventListener('tool-executed', handler)
})
onUnmounted(() => {
window.removeEventListener('tool-executed', handler)
})
}

View File

@@ -23,10 +23,32 @@ const router = createRouter({
name: 'novel-edit', name: 'novel-edit',
component: () => import('@/views/NovelFormView.vue'), component: () => import('@/views/NovelFormView.vue'),
}, },
// 工作区:大纲/章节/角色/世界观共享父布局(导航栏持久化)
{ {
path: '/novels/:id/outlines', path: '/novels/:id',
name: 'novel-outlines', component: () => import('@/views/NovelWorkspace.vue'),
component: () => import('@/views/OutlineView.vue'), children: [
{
path: 'outlines',
name: 'novel-outlines',
component: () => import('@/views/OutlineView.vue'),
},
{
path: 'chapters',
name: 'novel-chapters',
component: () => import('@/views/ChapterView.vue'),
},
{
path: 'characters',
name: 'novel-characters',
component: () => import('@/views/CharacterView.vue'),
},
{
path: 'world-setting',
name: 'novel-world-setting',
component: () => import('@/views/WorldSettingView.vue'),
},
],
}, },
], ],
}) })

View File

@@ -0,0 +1,29 @@
export interface Chapter {
id: number
novel_id: number
sort_order: number
title: string
content: string
created_at: string
updated_at: string
}
export interface ChapterCreate {
title: string
content?: string
}
export interface ChapterUpdate {
title?: string
content?: string
sort_order?: number
}
export interface ChapterList {
items: Chapter[]
total: number
}
export interface ChapterReorder {
ordered_ids: number[]
}

View File

@@ -0,0 +1,23 @@
export interface Character {
id: number
novel_id: number
name: string
description: string
created_at: string
updated_at: string
}
export interface CharacterCreate {
name: string
description?: string
}
export interface CharacterUpdate {
name?: string
description?: string
}
export interface CharacterList {
items: Character[]
total: number
}

View File

@@ -6,6 +6,31 @@ export interface ChatMessage {
created_at: string created_at: string
} }
/** 工具调用组(在对话中显示) */
export interface ToolCallGroup {
id: number
type: 'tool_calls'
calls: ToolCallEntry[]
status: 'pending' | 'executing' | 'done' | 'skipped'
assistantText: string // LLM 在调用工具前输出的文本
}
export interface ToolCallEntry {
id: string
name: string
label: string
arguments: Record<string, unknown>
result?: string
parsedResult?: unknown
}
/** 对话条目:普通消息或工具调用组 */
export type ChatEntry = ChatMessage | ToolCallGroup
export function isToolCallGroup(entry: ChatEntry): entry is ToolCallGroup {
return 'type' in entry && entry.type === 'tool_calls'
}
export interface ChatMessageList { export interface ChatMessageList {
items: ChatMessage[] items: ChatMessage[]
total: number total: number

View File

@@ -1,9 +1,11 @@
export interface Outline { export interface Outline {
id: number id: number
novel_id: number novel_id: number
parent_id: number | null
sort_order: number sort_order: number
summary: string summary: string
detail: string detail: string
children: Outline[]
created_at: string created_at: string
updated_at: string updated_at: string
} }
@@ -11,6 +13,7 @@ export interface Outline {
export interface OutlineCreate { export interface OutlineCreate {
summary: string summary: string
detail?: string detail?: string
parent_id?: number
} }
export interface OutlineUpdate { export interface OutlineUpdate {

View File

@@ -0,0 +1,11 @@
export interface WorldSetting {
id: number
novel_id: number
content: string
created_at: string
updated_at: string
}
export interface WorldSettingUpdate {
content: string
}

View File

@@ -0,0 +1,312 @@
<script setup lang="ts">
import { ref, inject, onMounted, computed, watchEffect } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { fetchChapters, createChapter, updateChapter, deleteChapter, reorderChapters } from '@/api/chapters'
import { useToast } from '@/composables/useToast'
import { useToolRefresh } from '@/composables/useToolRefresh'
import type { Novel } from '@/types/novel'
import type { Chapter } from '@/types/chapter'
import BaseButton from '@/components/ui/BaseButton.vue'
import ChapterList from '@/components/chapter/ChapterList.vue'
import ChapterEditor from '@/components/chapter/ChapterEditor.vue'
import ChapterEmptyState from '@/components/chapter/ChapterEmptyState.vue'
import ChapterFormModal from '@/components/chapter/ChapterFormModal.vue'
import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const novelId = inject<number>('workspace-novel-id', Number(route.params.id))
const novel = inject<import('vue').Ref<Novel | null>>('workspace-novel', ref(null))
const subtitle = inject<import('vue').Ref<string>>('workspace-subtitle', ref(''))
const chapters = ref<Chapter[]>([])
const loading = ref(true)
// 当前选中的章节(用于编辑)
const activeChapter = ref<Chapter | null>(null)
// 表单弹窗(新建/重命名)
const showForm = ref(false)
const editingChapter = ref<Chapter | null>(null)
const formSaving = ref(false)
// 编辑器保存状态
const editorSaving = ref(false)
// 删除确认
const showDeleteConfirm = ref(false)
const deletingChapter = ref<Chapter | null>(null)
const deleting = ref(false)
const totalChapters = computed(() => chapters.value.length)
watchEffect(() => {
subtitle.value = `${totalChapters.value}`
})
async function load() {
loading.value = true
try {
const list = await fetchChapters(novelId)
chapters.value = list.items
// 默认展开最后一章
if (chapters.value.length > 0 && !activeChapter.value) {
activeChapter.value = chapters.value[chapters.value.length - 1]
}
} catch {
toast.error('加载失败')
router.push({ name: 'home' })
} finally {
loading.value = false
}
}
// AI 工具执行后自动刷新章节数据
useToolRefresh(['list_chapters', 'create_chapter', 'update_chapter', 'delete_chapter'], load)
function openCreate() {
editingChapter.value = null
showForm.value = true
}
function openRename(chapter: Chapter) {
editingChapter.value = chapter
showForm.value = true
}
function selectChapter(chapter: Chapter) {
activeChapter.value = chapter
}
function backToList() {
activeChapter.value = null
}
async function handleFormSubmit(data: { title: string }) {
formSaving.value = true
try {
if (editingChapter.value) {
const updated = await updateChapter(novelId, editingChapter.value.id, { title: data.title })
const idx = chapters.value.findIndex(c => c.id === updated.id)
if (idx !== -1) chapters.value[idx] = updated
if (activeChapter.value?.id === updated.id) activeChapter.value = updated
toast.success('章节已重命名')
} else {
const created = await createChapter(novelId, { title: data.title })
chapters.value.push(created)
activeChapter.value = created
toast.success('章节已创建')
}
showForm.value = false
} catch {
toast.error('保存失败')
} finally {
formSaving.value = false
}
}
async function handleEditorSave(data: { title: string; content: string }) {
if (!activeChapter.value) return
editorSaving.value = true
try {
const updated = await updateChapter(novelId, activeChapter.value.id, {
title: data.title,
content: data.content,
})
const idx = chapters.value.findIndex(c => c.id === updated.id)
if (idx !== -1) chapters.value[idx] = updated
activeChapter.value = updated
toast.success('章节已保存')
} catch {
toast.error('保存失败')
} finally {
editorSaving.value = false
}
}
function confirmDelete(chapter: Chapter) {
deletingChapter.value = chapter
showDeleteConfirm.value = true
}
async function handleDelete() {
if (!deletingChapter.value) return
deleting.value = true
try {
await deleteChapter(novelId, deletingChapter.value.id)
if (activeChapter.value?.id === deletingChapter.value.id) {
activeChapter.value = null
}
chapters.value = chapters.value.filter(c => c.id !== deletingChapter.value!.id)
toast.success('章节已删除')
showDeleteConfirm.value = false
} catch {
toast.error('删除失败')
} finally {
deleting.value = false
}
}
async function handleReorder(reordered: Chapter[]) {
chapters.value = reordered
try {
const result = await reorderChapters(novelId, reordered.map(c => c.id))
chapters.value = result.items
} catch {
toast.error('排序保存失败')
await load()
}
}
onMounted(load)
</script>
<template>
<div v-if="!loading" class="chapter-view">
<Teleport to="#workspace-actions">
<BaseButton v-if="totalChapters > 0" variant="primary" @click="openCreate">
新建章节
</BaseButton>
</Teleport>
<template v-if="totalChapters > 0">
<!-- 双栏布局 -->
<div class="chapter-layout">
<aside class="chapter-sidebar" :class="{ 'chapter-sidebar--hidden': activeChapter }">
<ChapterList
:chapters="chapters"
:active-id="activeChapter?.id ?? null"
@select="selectChapter"
@edit="openRename"
@delete="confirmDelete"
@reorder="handleReorder"
/>
</aside>
<main v-if="activeChapter" class="chapter-main">
<ChapterEditor
:chapter="activeChapter"
:saving="editorSaving"
@save="handleEditorSave"
@back="backToList"
/>
</main>
<div v-else class="chapter-placeholder">
<p>选择一个章节开始编辑</p>
</div>
</div>
</template>
<ChapterEmptyState v-else @create="openCreate" />
<ChapterFormModal
:show="showForm"
:chapter="editingChapter"
:saving="formSaving"
@submit="handleFormSubmit"
@close="showForm = false"
/>
<ConfirmDialog
:show="showDeleteConfirm"
title="确认删除"
:message="`确定要删除「${deletingChapter?.title}」吗?章节内容将无法恢复。`"
confirm-text="删除"
:loading="deleting"
@confirm="handleDelete"
@cancel="showDeleteConfirm = false"
/>
</div>
<div v-else-if="loading" class="chapter-loading">
<div class="loading-pulse" style="height: 2rem; width: 40%; margin: 0 auto var(--space-lg)" />
<div class="loading-pulse" style="height: 1rem; width: 25%; margin: 0 auto var(--space-2xl)" />
<div class="loading-layout">
<div class="loading-pulse" style="height: 300px; width: 280px" />
<div class="loading-pulse" style="height: 300px; flex: 1" />
</div>
</div>
</template>
<style scoped>
.chapter-view {
/* max-width 由父 NovelWorkspace 控制 */
}
/* 双栏布局 */
.chapter-layout {
display: flex;
gap: var(--space-xl);
min-height: 500px;
}
.chapter-sidebar {
width: 300px;
flex-shrink: 0;
}
.chapter-main {
flex: 1;
min-width: 0;
background: var(--paper-50);
border: 1px solid var(--paper-200);
border-radius: var(--radius-md);
padding: var(--space-xl);
}
.chapter-placeholder {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: var(--paper-50);
border: 1px dashed var(--paper-300);
border-radius: var(--radius-md);
color: var(--ink-300);
font-size: 0.9rem;
}
/* Loading */
.chapter-loading {
max-width: 1100px;
margin: var(--space-3xl) auto;
}
.loading-layout {
display: flex;
gap: var(--space-xl);
}
.loading-pulse {
background: linear-gradient(
90deg,
var(--paper-100) 25%,
var(--paper-200) 50%,
var(--paper-100) 75%
);
background-size: 200% 100%;
border-radius: var(--radius-sm);
animation: shimmer 1.5s ease infinite;
}
/* 小屏适配:隐藏侧边栏,编辑器全屏 */
@media (max-width: 768px) {
.chapter-layout {
flex-direction: column;
}
.chapter-sidebar {
width: 100%;
}
.chapter-sidebar--hidden {
display: none;
}
.chapter-placeholder {
display: none;
}
}
</style>

View File

@@ -0,0 +1,183 @@
<script setup lang="ts">
import { ref, inject, onMounted, computed, watchEffect } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { fetchCharacters, createCharacter, updateCharacter, deleteCharacter } from '@/api/characters'
import { useToast } from '@/composables/useToast'
import { useToolRefresh } from '@/composables/useToolRefresh'
import type { Novel } from '@/types/novel'
import type { Character, CharacterCreate, CharacterUpdate } from '@/types/character'
import BaseButton from '@/components/ui/BaseButton.vue'
import CharacterGrid from '@/components/character/CharacterGrid.vue'
import CharacterEmptyState from '@/components/character/CharacterEmptyState.vue'
import CharacterFormModal from '@/components/character/CharacterFormModal.vue'
import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const novelId = inject<number>('workspace-novel-id', Number(route.params.id))
const novel = inject<import('vue').Ref<Novel | null>>('workspace-novel', ref(null))
const subtitle = inject<import('vue').Ref<string>>('workspace-subtitle', ref(''))
const characters = ref<Character[]>([])
const loading = ref(true)
// 表单弹窗
const showForm = ref(false)
const editingCharacter = ref<Character | null>(null)
const formSaving = ref(false)
// 删除确认
const showDeleteConfirm = ref(false)
const deletingCharacter = ref<Character | null>(null)
const deleting = ref(false)
const totalCharacters = computed(() => characters.value.length)
watchEffect(() => {
subtitle.value = `${totalCharacters.value} 个角色`
})
async function load() {
loading.value = true
try {
const list = await fetchCharacters(novelId)
characters.value = list.items
} catch {
toast.error('加载失败')
router.push({ name: 'home' })
} finally {
loading.value = false
}
}
// AI 工具执行后自动刷新角色数据
useToolRefresh(['list_characters', 'create_character', 'update_character', 'delete_character'], load)
function openCreate() {
editingCharacter.value = null
showForm.value = true
}
function openEdit(character: Character) {
editingCharacter.value = character
showForm.value = true
}
async function handleFormSubmit(data: CharacterCreate | CharacterUpdate) {
formSaving.value = true
try {
if (editingCharacter.value) {
const updated = await updateCharacter(novelId, editingCharacter.value.id, data as CharacterUpdate)
const idx = characters.value.findIndex(c => c.id === updated.id)
if (idx !== -1) characters.value[idx] = updated
toast.success('角色已更新')
} else {
const created = await createCharacter(novelId, data as CharacterCreate)
characters.value.push(created)
toast.success('角色已创建')
}
showForm.value = false
} catch {
toast.error('保存失败')
} finally {
formSaving.value = false
}
}
function confirmDelete(character: Character) {
deletingCharacter.value = character
showDeleteConfirm.value = true
}
async function handleDelete() {
if (!deletingCharacter.value) return
deleting.value = true
try {
await deleteCharacter(novelId, deletingCharacter.value.id)
characters.value = characters.value.filter(c => c.id !== deletingCharacter.value!.id)
toast.success('角色已删除')
showDeleteConfirm.value = false
} catch {
toast.error('删除失败')
} finally {
deleting.value = false
}
}
onMounted(load)
</script>
<template>
<div v-if="!loading" class="character-view">
<Teleport to="#workspace-actions">
<BaseButton v-if="totalCharacters > 0" variant="primary" @click="openCreate">
添加角色
</BaseButton>
</Teleport>
<CharacterGrid
v-if="totalCharacters > 0"
:characters="characters"
@edit="openEdit"
@delete="confirmDelete"
/>
<CharacterEmptyState v-else @create="openCreate" />
<CharacterFormModal
:show="showForm"
:character="editingCharacter"
:saving="formSaving"
@submit="handleFormSubmit"
@close="showForm = false"
/>
<ConfirmDialog
:show="showDeleteConfirm"
title="确认删除"
:message="`确定要删除角色「${deletingCharacter?.name}」吗?`"
confirm-text="删除"
:loading="deleting"
@confirm="handleDelete"
@cancel="showDeleteConfirm = false"
/>
</div>
<div v-else-if="loading" class="character-loading">
<div class="loading-pulse" style="height: 2rem; width: 40%; margin: 0 auto var(--space-lg)" />
<div class="loading-pulse" style="height: 1rem; width: 25%; margin: 0 auto var(--space-2xl)" />
<div class="loading-grid">
<div v-for="i in 4" :key="i" class="loading-pulse" style="height: 120px" />
</div>
</div>
</template>
<style scoped>
.character-view {
/* max-width 由父 NovelWorkspace 控制 */
}
/* Loading */
.character-loading {
max-width: 900px;
margin: var(--space-3xl) auto;
}
.loading-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-lg);
}
.loading-pulse {
background: linear-gradient(
90deg,
var(--paper-100) 25%,
var(--paper-200) 50%,
var(--paper-100) 75%
);
background-size: 200% 100%;
border-radius: var(--radius-sm);
animation: shimmer 1.5s ease infinite;
}
</style>

View File

@@ -3,6 +3,7 @@ import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { fetchNovel, deleteNovel } from '@/api/novels' import { fetchNovel, deleteNovel } from '@/api/novels'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { useToolRefresh } from '@/composables/useToolRefresh'
import type { Novel } from '@/types/novel' import type { Novel } from '@/types/novel'
import BaseButton from '@/components/ui/BaseButton.vue' import BaseButton from '@/components/ui/BaseButton.vue'
import ConfirmDialog from '@/components/ui/ConfirmDialog.vue' import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
@@ -18,6 +19,9 @@ const deleting = ref(false)
const novelId = Number(route.params.id) const novelId = Number(route.params.id)
// AI 工具执行后自动刷新小说详情
useToolRefresh(['get_novel_info', 'update_novel_info'], load)
async function load() { async function load() {
loading.value = true loading.value = true
try { try {
@@ -87,6 +91,15 @@ onMounted(load)
<RouterLink :to="{ name: 'novel-outlines', params: { id: novel.id } }"> <RouterLink :to="{ name: 'novel-outlines', params: { id: novel.id } }">
<BaseButton variant="primary">大纲</BaseButton> <BaseButton variant="primary">大纲</BaseButton>
</RouterLink> </RouterLink>
<RouterLink :to="{ name: 'novel-chapters', params: { id: novel.id } }">
<BaseButton variant="primary">章节</BaseButton>
</RouterLink>
<RouterLink :to="{ name: 'novel-characters', params: { id: novel.id } }">
<BaseButton variant="primary">角色</BaseButton>
</RouterLink>
<RouterLink :to="{ name: 'novel-world-setting', params: { id: novel.id } }">
<BaseButton variant="primary">世界观</BaseButton>
</RouterLink>
<RouterLink :to="{ name: 'novel-edit', params: { id: novel.id } }"> <RouterLink :to="{ name: 'novel-edit', params: { id: novel.id } }">
<BaseButton variant="secondary">编辑</BaseButton> <BaseButton variant="secondary">编辑</BaseButton>
</RouterLink> </RouterLink>

View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
import { ref, onMounted, provide } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { fetchNovel } from '@/api/novels'
import { useToast } from '@/composables/useToast'
import type { Novel } from '@/types/novel'
import NovelSubNav from '@/components/novel/NovelSubNav.vue'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const novelId = Number(route.params.id)
const novel = ref<Novel | null>(null)
const loading = ref(true)
/** 子页面可设置的副标题(如 "共 5 个节点" */
const subtitle = ref('')
async function load() {
loading.value = true
try {
novel.value = await fetchNovel(novelId)
} catch {
toast.error('作品不存在')
router.push({ name: 'home' })
} finally {
loading.value = false
}
}
// 提供给子路由
provide('workspace-novel', novel)
provide('workspace-novel-id', novelId)
provide('workspace-subtitle', subtitle)
onMounted(load)
</script>
<template>
<div v-if="!loading && novel" class="workspace">
<NovelSubNav
:novel-id="novelId"
:novel-title="novel.title"
:subtitle="subtitle"
>
<template #actions>
<!-- 子页面通过 Teleport 注入操作按钮 -->
<div id="workspace-actions" />
</template>
</NovelSubNav>
<div class="workspace-content">
<RouterView />
</div>
</div>
<div v-else-if="loading" class="workspace-loading">
<div class="loading-pulse" style="height: 1.5rem; width: 30%; margin-bottom: var(--space-md)" />
<div class="loading-pulse" style="height: 1rem; width: 60%; margin-bottom: var(--space-2xl)" />
<div class="loading-pulse" style="height: 20rem; width: 100%" />
</div>
</template>
<style scoped>
.workspace {
max-width: 900px;
margin: 0 auto;
}
.workspace-content {
animation: fadeInUp var(--duration-normal) var(--ease-out-smooth) both;
}
.workspace-loading {
max-width: 900px;
margin: var(--space-3xl) auto;
}
.loading-pulse {
background: linear-gradient(
90deg,
var(--paper-100) 25%,
var(--paper-200) 50%,
var(--paper-100) 75%
);
background-size: 200% 100%;
border-radius: var(--radius-sm);
animation: shimmer 1.5s ease infinite;
}
</style>

View File

@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue' import { ref, inject, onMounted, computed, watchEffect } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { fetchNovel } from '@/api/novels' import { fetchOutlines, createOutline, updateOutline, deleteOutline, reorderOutlines, reorderChildren as apiReorderChildren } from '@/api/outlines'
import { fetchOutlines, createOutline, updateOutline, deleteOutline, reorderOutlines } from '@/api/outlines'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { useToolRefresh } from '@/composables/useToolRefresh'
import type { Novel } from '@/types/novel' import type { Novel } from '@/types/novel'
import type { Outline, OutlineCreate, OutlineUpdate } from '@/types/outline' import type { Outline, OutlineCreate, OutlineUpdate } from '@/types/outline'
import BaseButton from '@/components/ui/BaseButton.vue' import BaseButton from '@/components/ui/BaseButton.vue'
@@ -16,31 +16,35 @@ const route = useRoute()
const router = useRouter() const router = useRouter()
const toast = useToast() const toast = useToast()
const novelId = Number(route.params.id) const novelId = inject<number>('workspace-novel-id', Number(route.params.id))
const novel = ref<Novel | null>(null) const novel = inject<import('vue').Ref<Novel | null>>('workspace-novel', ref(null))
const subtitle = inject<import('vue').Ref<string>>('workspace-subtitle', ref(''))
const outlines = ref<Outline[]>([]) const outlines = ref<Outline[]>([])
const loading = ref(true) const loading = ref(true)
// 表单弹窗 // 表单弹窗
const showForm = ref(false) const showForm = ref(false)
const editingOutline = ref<Outline | null>(null) const editingOutline = ref<Outline | null>(null)
const formParentId = ref<number | null>(null)
const formSaving = ref(false) const formSaving = ref(false)
// 删除确认 // 删除确认
const showDeleteConfirm = ref(false) const showDeleteConfirm = ref(false)
const deletingOutline = ref<Outline | null>(null) const deletingOutline = ref<Outline | null>(null)
const deletingIsChild = ref(false)
const deleting = ref(false) const deleting = ref(false)
const totalOutlines = computed(() => outlines.value.length) const totalOutlines = computed(() => outlines.value.length)
// 同步副标题到父布局
watchEffect(() => {
subtitle.value = `${totalOutlines.value} 个节点`
})
async function load() { async function load() {
loading.value = true loading.value = true
try { try {
const [n, list] = await Promise.all([ const list = await fetchOutlines(novelId)
fetchNovel(novelId),
fetchOutlines(novelId),
])
novel.value = n
outlines.value = list.items outlines.value = list.items
} catch { } catch {
toast.error('加载失败') toast.error('加载失败')
@@ -50,28 +54,54 @@ async function load() {
} }
} }
// AI 工具执行后自动刷新大纲数据
useToolRefresh(['list_outlines', 'create_outline', 'update_outline', 'delete_outline', 'reorder_outlines'], load)
// ── 顶级节点操作 ──
function openCreate() { function openCreate() {
editingOutline.value = null editingOutline.value = null
formParentId.value = null
showForm.value = true showForm.value = true
} }
function openEdit(outline: Outline) { function openEdit(outline: Outline) {
editingOutline.value = outline editingOutline.value = outline
formParentId.value = null
showForm.value = true showForm.value = true
} }
// ── 子节点操作 ──
function openCreateChild(parent: Outline) {
editingOutline.value = null
formParentId.value = parent.id
showForm.value = true
}
function openEditChild(child: Outline) {
editingOutline.value = child
formParentId.value = child.parent_id
showForm.value = true
}
// ── 统一表单提交 ──
async function handleFormSubmit(data: OutlineCreate | OutlineUpdate) { async function handleFormSubmit(data: OutlineCreate | OutlineUpdate) {
formSaving.value = true formSaving.value = true
try { try {
if (editingOutline.value) { if (editingOutline.value) {
// 编辑(顶级 or 子节点都一样)
const updated = await updateOutline(novelId, editingOutline.value.id, data as OutlineUpdate) const updated = await updateOutline(novelId, editingOutline.value.id, data as OutlineUpdate)
const idx = outlines.value.findIndex(o => o.id === updated.id) _replaceNode(updated)
if (idx !== -1) outlines.value[idx] = updated toast.success('已更新')
toast.success('大纲已更新')
} else { } else {
const created = await createOutline(novelId, data as OutlineCreate) // 新建
outlines.value.push(created) await createOutline(novelId, data as OutlineCreate)
toast.success('大纲已创建') // 重新加载以获取完整嵌套结构
const list = await fetchOutlines(novelId)
outlines.value = list.items
toast.success(formParentId.value ? '子节点已创建' : '大纲已创建')
} }
showForm.value = false showForm.value = false
} catch { } catch {
@@ -81,8 +111,38 @@ async function handleFormSubmit(data: OutlineCreate | OutlineUpdate) {
} }
} }
/** 在 outlines 树中替换更新后的节点 */
function _replaceNode(updated: Outline) {
// 尝试在顶级查找
const topIdx = outlines.value.findIndex(o => o.id === updated.id)
if (topIdx !== -1) {
// 保留 children
updated.children = outlines.value[topIdx].children || []
outlines.value[topIdx] = updated
return
}
// 在子节点中查找
for (const parent of outlines.value) {
if (!parent.children) continue
const childIdx = parent.children.findIndex(c => c.id === updated.id)
if (childIdx !== -1) {
parent.children[childIdx] = updated
return
}
}
}
// ── 删除 ──
function confirmDelete(outline: Outline) { function confirmDelete(outline: Outline) {
deletingOutline.value = outline deletingOutline.value = outline
deletingIsChild.value = false
showDeleteConfirm.value = true
}
function confirmDeleteChild(child: Outline) {
deletingOutline.value = child
deletingIsChild.value = true
showDeleteConfirm.value = true showDeleteConfirm.value = true
} }
@@ -91,8 +151,20 @@ async function handleDelete() {
deleting.value = true deleting.value = true
try { try {
await deleteOutline(novelId, deletingOutline.value.id) await deleteOutline(novelId, deletingOutline.value.id)
outlines.value = outlines.value.filter(o => o.id !== deletingOutline.value!.id) if (deletingIsChild.value) {
toast.success('大纲已删除') // 从父节点的 children 中移除
for (const parent of outlines.value) {
if (!parent.children) continue
const idx = parent.children.findIndex(c => c.id === deletingOutline.value!.id)
if (idx !== -1) {
parent.children.splice(idx, 1)
break
}
}
} else {
outlines.value = outlines.value.filter(o => o.id !== deletingOutline.value!.id)
}
toast.success('已删除')
showDeleteConfirm.value = false showDeleteConfirm.value = false
} catch { } catch {
toast.error('删除失败') toast.error('删除失败')
@@ -101,6 +173,8 @@ async function handleDelete() {
} }
} }
// ── 排序 ──
async function handleReorder(reordered: Outline[]) { async function handleReorder(reordered: Outline[]) {
outlines.value = reordered outlines.value = reordered
try { try {
@@ -112,23 +186,33 @@ async function handleReorder(reordered: Outline[]) {
} }
} }
async function handleChildReorder(parentId: number, reordered: Outline[]) {
// 乐观更新
const parent = outlines.value.find(o => o.id === parentId)
if (parent) {
parent.children = reordered
}
try {
await apiReorderChildren(novelId, parentId, reordered.map(c => c.id))
// 重新加载获取最新数据
const list = await fetchOutlines(novelId)
outlines.value = list.items
} catch {
toast.error('子节点排序保存失败')
await load()
}
}
onMounted(load) onMounted(load)
</script> </script>
<template> <template>
<div v-if="!loading && novel" class="outline-view"> <div v-if="!loading" class="outline-view">
<div class="outline-header"> <Teleport to="#workspace-actions">
<BaseButton variant="ghost" @click="router.push({ name: 'novel-detail', params: { id: novelId } })">
&larr; 返回
</BaseButton>
<div class="header-center">
<h1 class="outline-title">{{ novel.title }}</h1>
<p class="outline-subtitle">故事大纲 · {{ totalOutlines }} 个节点</p>
</div>
<BaseButton v-if="totalOutlines > 0" variant="primary" @click="openCreate"> <BaseButton v-if="totalOutlines > 0" variant="primary" @click="openCreate">
添加节点 添加节点
</BaseButton> </BaseButton>
</div> </Teleport>
<OutlineTimeline <OutlineTimeline
v-if="totalOutlines > 0" v-if="totalOutlines > 0"
@@ -137,6 +221,10 @@ onMounted(load)
@delete="confirmDelete" @delete="confirmDelete"
@reorder="handleReorder" @reorder="handleReorder"
@add="openCreate" @add="openCreate"
@add-child="openCreateChild"
@edit-child="openEditChild"
@delete-child="confirmDeleteChild"
@reorder-children="handleChildReorder"
/> />
<OutlineEmptyState v-else @create="openCreate" /> <OutlineEmptyState v-else @create="openCreate" />
@@ -144,6 +232,7 @@ onMounted(load)
:show="showForm" :show="showForm"
:outline="editingOutline" :outline="editingOutline"
:saving="formSaving" :saving="formSaving"
:parent-id="formParentId"
@submit="handleFormSubmit" @submit="handleFormSubmit"
@close="showForm = false" @close="showForm = false"
/> />
@@ -151,7 +240,9 @@ onMounted(load)
<ConfirmDialog <ConfirmDialog
:show="showDeleteConfirm" :show="showDeleteConfirm"
title="确认删除" title="确认删除"
:message="`确定要删除「${deletingOutline?.summary}」吗?`" :message="deletingIsChild
? `确定要删除子节点「${deletingOutline?.summary}」吗?`
: `确定要删除「${deletingOutline?.summary}」吗?${(deletingOutline?.children?.length ?? 0) > 0 ? '其下所有子节点也将被删除。' : ''}`"
confirm-text="删除" confirm-text="删除"
:loading="deleting" :loading="deleting"
@confirm="handleDelete" @confirm="handleDelete"
@@ -171,37 +262,10 @@ onMounted(load)
<style scoped> <style scoped>
.outline-view { .outline-view {
max-width: 900px; /* max-width 由父 NovelWorkspace 控制 */
margin: 0 auto;
animation: fadeInUp var(--duration-normal) var(--ease-out-smooth) both;
} }
.outline-header { /* header 已由 NovelSubNav 处理 */
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-md);
margin-bottom: var(--space-2xl);
}
.header-center {
flex: 1;
text-align: center;
}
.outline-title {
font-family: var(--font-display);
font-size: 1.8rem;
font-weight: 700;
color: var(--ink-900);
line-height: 1.3;
}
.outline-subtitle {
font-size: 0.85rem;
color: var(--ink-400);
margin-top: var(--space-xs);
}
/* Loading */ /* Loading */
.outline-loading { .outline-loading {

View File

@@ -0,0 +1,181 @@
<script setup lang="ts">
import { ref, inject, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { fetchWorldSetting, saveWorldSetting } from '@/api/worldSetting'
import { useToast } from '@/composables/useToast'
import { useToolRefresh } from '@/composables/useToolRefresh'
import type { Novel } from '@/types/novel'
import BaseButton from '@/components/ui/BaseButton.vue'
import BaseTextarea from '@/components/ui/BaseTextarea.vue'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const novelId = inject<number>('workspace-novel-id', Number(route.params.id))
const novel = inject<import('vue').Ref<Novel | null>>('workspace-novel', ref(null))
const subtitle = inject<import('vue').Ref<string>>('workspace-subtitle', ref(''))
const content = ref('')
const loading = ref(true)
const saving = ref(false)
const hasChanges = ref(false)
const contentLength = computed(() => content.value.length)
const contentHint = computed(() => {
if (contentLength.value === 0) return '建议至少 500 字'
if (contentLength.value < 500) return `${contentLength.value} 字,建议至少 500 字`
return `${contentLength.value}`
})
// 世界观页不需要副标题
subtitle.value = ''
// AI 工具执行后自动刷新世界观数据
useToolRefresh(['get_world_setting', 'save_world_setting'], load)
async function load() {
loading.value = true
try {
const ws = await fetchWorldSetting(novelId)
content.value = ws.content
} catch {
toast.error('加载失败')
router.push({ name: 'home' })
} finally {
loading.value = false
}
}
function onInput() {
hasChanges.value = true
}
async function handleSave() {
saving.value = true
try {
await saveWorldSetting(novelId, { content: content.value })
hasChanges.value = false
toast.success('世界观已保存')
} catch {
toast.error('保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<template>
<div v-if="!loading" class="world-view">
<Teleport to="#workspace-actions">
<BaseButton
variant="primary"
:loading="saving"
:disabled="!hasChanges"
@click="handleSave"
>
保存
</BaseButton>
</Teleport>
<div class="editor-container">
<div class="editor-hint">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.2" />
<path d="M8 4v5M8 11v1" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
</svg>
<span>描述故事发生的世界背景历史规则地理环境等设定</span>
</div>
<BaseTextarea
v-model="content"
placeholder="在此描述你的故事世界..."
:rows="16"
:maxlength="5000"
:max-height="600"
@input="onInput"
/>
<div class="editor-footer">
<span class="word-count" :class="{ 'word-count--low': contentLength < 500 && contentLength > 0 }">
{{ contentHint }}
</span>
<span v-if="hasChanges" class="unsaved-hint">未保存的更改</span>
</div>
</div>
</div>
<div v-else-if="loading" class="world-loading">
<div class="loading-pulse" style="height: 2rem; width: 40%; margin: 0 auto var(--space-lg)" />
<div class="loading-pulse" style="height: 1rem; width: 20%; margin: 0 auto var(--space-2xl)" />
<div class="loading-pulse" style="height: 300px; width: 100%" />
</div>
</template>
<style scoped>
.world-view {
max-width: 800px;
margin: 0 auto;
animation: fadeInUp var(--duration-normal) var(--ease-out-smooth) both;
}
.editor-container {
background: var(--paper-50);
border: 1px solid var(--paper-200);
border-radius: var(--radius-md);
padding: var(--space-xl);
}
.editor-hint {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: 0.85rem;
color: var(--ink-400);
margin-bottom: var(--space-lg);
padding: var(--space-sm) var(--space-md);
background: var(--paper-100);
border-radius: var(--radius-sm);
}
.editor-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: var(--space-sm);
}
.word-count {
font-size: 0.75rem;
color: var(--ink-300);
}
.word-count--low {
color: var(--vermilion);
}
.unsaved-hint {
font-size: 0.75rem;
color: var(--vermilion);
font-style: italic;
}
/* Loading */
.world-loading {
max-width: 800px;
margin: var(--space-3xl) auto;
}
.loading-pulse {
background: linear-gradient(
90deg,
var(--paper-100) 25%,
var(--paper-200) 50%,
var(--paper-100) 75%
);
background-size: 200% 100%;
border-radius: var(--radius-sm);
animation: shimmer 1.5s ease infinite;
}
</style>

65
start.bat Normal file
View File

@@ -0,0 +1,65 @@
@echo off
chcp 65001 >nul
title Noval - 小说创作应用
:: 先关闭已有的 Noval 进程
tasklist /FI "WINDOWTITLE eq Noval-Backend*" 2>nul | find /I "cmd.exe" >nul
if not errorlevel 1 (
echo [*] 检测到已运行的后端,正在关闭...
taskkill /FI "WINDOWTITLE eq Noval-Backend*" /F >nul 2>&1
)
tasklist /FI "WINDOWTITLE eq Noval-Frontend*" 2>nul | find /I "cmd.exe" >nul
if not errorlevel 1 (
echo [*] 检测到已运行的前端,正在关闭...
taskkill /FI "WINDOWTITLE eq Noval-Frontend*" /F >nul 2>&1
)
:: 清理可能残留的占用 8000 端口的进程
for /f "tokens=5" %%a in ('netstat -ano ^| findstr ":8000.*LISTENING"') do (
taskkill /PID %%a /F >nul 2>&1
)
timeout /t 1 /nobreak >nul
echo ========================================
echo Noval 启动中...
echo ========================================
echo.
:: 检查后端虚拟环境
if not exist "backend\.venv\Scripts\uvicorn.exe" (
echo [!] 未检测到后端虚拟环境,正在初始化...
cd backend
py -3 -m venv .venv
.venv\Scripts\pip install -r requirements.txt
cd ..
echo.
)
:: 检查前端依赖
if not exist "frontend\node_modules" (
echo [!] 未检测到前端依赖,正在安装...
cd frontend
call npm install
cd ..
echo.
)
echo [1/2] 启动后端 (port 8000)...
start "Noval-Backend" cmd /c "cd backend && .venv\Scripts\uvicorn app.main:app --port 8000 --reload"
echo [2/2] 启动前端 (port 5173)...
start "Noval-Frontend" cmd /c "cd frontend && npm run dev"
:: 等待服务就绪后打开浏览器
timeout /t 3 /nobreak >nul
start http://localhost:5173
echo.
echo ========================================
echo Noval 已启动!
echo 前端: http://localhost:5173
echo 后端: http://localhost:8000/docs
echo ========================================
echo 再次运行此脚本会自动重启所有服务
echo 后端已启用 --reload修改代码自动生效
echo ========================================
pause