引入二级大纲功能,新增父子节点关系及子节点排序,同时调整API与UI以支持嵌套大纲管理
This commit is contained in:
@@ -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
16
.dockerignore
Normal 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
34
Dockerfile
Normal 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"]
|
||||||
@@ -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():
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
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
|
||||||
@@ -12,6 +15,9 @@ 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):
|
||||||
@@ -23,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=["*"],
|
||||||
@@ -41,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.html(Vue 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")
|
||||||
|
|||||||
@@ -39,10 +39,11 @@ 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)
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ from ..database import get_session, engine
|
|||||||
from ..models import AIConfig, ChatMessage, Novel
|
from ..models import AIConfig, ChatMessage, Novel
|
||||||
from ..schemas import ChatRequest, ChatMessageRead, ChatMessageList
|
from ..schemas import ChatRequest, ChatMessageRead, ChatMessageList
|
||||||
from ..services.ai_provider import (
|
from ..services.ai_provider import (
|
||||||
stream_chat, build_assistant_message, build_tool_results_messages, ToolCall,
|
stream_chat, simple_completion,
|
||||||
|
build_assistant_message, build_tool_results_messages, ToolCall,
|
||||||
)
|
)
|
||||||
from ..services.tools import get_openai_tools, get_anthropic_tools, execute_tool
|
from ..services.tools import get_openai_tools, get_anthropic_tools, execute_tool
|
||||||
|
|
||||||
@@ -20,12 +21,23 @@ MAX_TOOL_ROUNDS = 8
|
|||||||
|
|
||||||
# 工具名称到中文描述
|
# 工具名称到中文描述
|
||||||
TOOL_LABELS: dict[str, str] = {
|
TOOL_LABELS: dict[str, str] = {
|
||||||
|
"get_novel_info": "查看小说信息",
|
||||||
|
"update_novel_info": "更新小说信息",
|
||||||
"list_outlines": "查询大纲",
|
"list_outlines": "查询大纲",
|
||||||
"create_outline": "创建大纲",
|
"create_outline": "创建大纲",
|
||||||
"update_outline": "更新大纲",
|
"update_outline": "更新大纲",
|
||||||
"delete_outline": "删除大纲",
|
"delete_outline": "删除大纲",
|
||||||
|
"reorder_outlines": "调整大纲排序",
|
||||||
"get_world_setting": "查询世界观",
|
"get_world_setting": "查询世界观",
|
||||||
"save_world_setting": "保存世界观",
|
"save_world_setting": "保存世界观",
|
||||||
|
"list_characters": "查询角色",
|
||||||
|
"create_character": "创建角色",
|
||||||
|
"update_character": "更新角色",
|
||||||
|
"delete_character": "删除角色",
|
||||||
|
"list_chapters": "查询章节",
|
||||||
|
"create_chapter": "创建章节",
|
||||||
|
"update_chapter": "更新章节",
|
||||||
|
"delete_chapter": "删除章节",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -36,28 +48,50 @@ def _build_system_prompt(
|
|||||||
tools_enabled: bool = False,
|
tools_enabled: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""根据小说上下文和用户所在页面构建 system prompt"""
|
"""根据小说上下文和用户所在页面构建 system prompt"""
|
||||||
base = "你是一个专业的小说创作助手。"
|
|
||||||
if not novel_id:
|
|
||||||
return base
|
|
||||||
|
|
||||||
novel = session.get(Novel, novel_id)
|
|
||||||
if not novel:
|
|
||||||
return base
|
|
||||||
|
|
||||||
parts = [
|
parts = [
|
||||||
f"你是一个专业的小说创作助手,当前正在协助创作《{novel.title}》。",
|
"你是 Writing Red Dot 写作助手,专注于小说创作领域。",
|
||||||
f"小说简介:{novel.description or '暂无'}",
|
"你擅长:构思情节、塑造角色、打磨文笔、设计世界观、规划大纲结构。",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
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:
|
if page_context:
|
||||||
parts.append(f"\n用户当前正在查看:{page_context}。")
|
parts.append(f"\n用户当前正在查看:{page_context}。请结合当前页面场景提供针对性建议。")
|
||||||
|
|
||||||
if tools_enabled:
|
if tools_enabled:
|
||||||
parts.append(
|
parts.append(
|
||||||
"\n你可以使用工具来查看和管理大纲、世界观设定。"
|
"\n## 你的工具能力\n"
|
||||||
"\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)
|
return "\n".join(parts)
|
||||||
|
|
||||||
@@ -72,6 +106,23 @@ def _sse(data: dict) -> str:
|
|||||||
return f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
|
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,
|
||||||
@@ -101,7 +152,7 @@ async def chat_stream(
|
|||||||
messages = [{"role": m.role, "content": m.content} for m in data.messages]
|
messages = [{"role": m.role, "content": m.content} for m in data.messages]
|
||||||
|
|
||||||
# 构建上下文
|
# 构建上下文
|
||||||
use_tools = bool(data.novel_id and data.tools_enabled)
|
use_tools = bool(data.tools_enabled)
|
||||||
system_prompt = _build_system_prompt(
|
system_prompt = _build_system_prompt(
|
||||||
data.novel_id, session, data.page_context, use_tools
|
data.novel_id, session, data.page_context, use_tools
|
||||||
)
|
)
|
||||||
@@ -112,6 +163,8 @@ async def chat_stream(
|
|||||||
|
|
||||||
full_response = data.assistant_text or ""
|
full_response = data.assistant_text or ""
|
||||||
usage_info: dict = {}
|
usage_info: dict = {}
|
||||||
|
# 记录本次对话中的工具调用摘要(用于保存到 DB)
|
||||||
|
tool_call_logs: list[str] = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# ── 阶段一:如果有待审批的工具调用,先执行它们 ──
|
# ── 阶段一:如果有待审批的工具调用,先执行它们 ──
|
||||||
@@ -133,6 +186,7 @@ async def chat_stream(
|
|||||||
label = TOOL_LABELS.get(tc.name, tc.name)
|
label = TOOL_LABELS.get(tc.name, tc.name)
|
||||||
result = execute_tool(tc.name, tc.arguments, data.novel_id)
|
result = execute_tool(tc.name, tc.arguments, data.novel_id)
|
||||||
tool_results.append({"id": tc.id, "result": result})
|
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": {
|
yield _sse({"tool_result": {
|
||||||
"name": tc.name,
|
"name": tc.name,
|
||||||
"label": label,
|
"label": label,
|
||||||
@@ -164,7 +218,7 @@ async def chat_stream(
|
|||||||
full_response += round_text
|
full_response += round_text
|
||||||
break
|
break
|
||||||
|
|
||||||
# 有工具调用 → 暂停,发给前端审批
|
# 有工具调用
|
||||||
full_response += round_text
|
full_response += round_text
|
||||||
calls_data = [
|
calls_data = [
|
||||||
{
|
{
|
||||||
@@ -175,29 +229,48 @@ async def chat_stream(
|
|||||||
}
|
}
|
||||||
for tc in round_tool_calls
|
for tc in round_tool_calls
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if data.auto_approve_tools:
|
||||||
|
# 自动执行模式:直接执行工具,不暂停
|
||||||
|
yield _sse({"tool_calls_auto": calls_data})
|
||||||
|
|
||||||
|
assistant_msg = build_assistant_message(
|
||||||
|
config.provider, full_response, round_tool_calls
|
||||||
|
)
|
||||||
|
messages.append(assistant_msg)
|
||||||
|
|
||||||
|
tool_results = []
|
||||||
|
for tc in round_tool_calls:
|
||||||
|
label = TOOL_LABELS.get(tc.name, tc.name)
|
||||||
|
result = execute_tool(tc.name, tc.arguments, data.novel_id)
|
||||||
|
tool_results.append({"id": tc.id, "result": result})
|
||||||
|
tool_call_logs.append(f"[工具调用: {label}] 参数: {json.dumps(tc.arguments, ensure_ascii=False)} → 结果: {result}")
|
||||||
|
yield _sse({"tool_result": {
|
||||||
|
"name": tc.name,
|
||||||
|
"label": label,
|
||||||
|
"result": result,
|
||||||
|
}})
|
||||||
|
|
||||||
|
result_msgs = build_tool_results_messages(config.provider, tool_results)
|
||||||
|
messages.extend(result_msgs)
|
||||||
|
# 继续循环,让 LLM 基于工具结果生成回复
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
# 审批模式:暂停,发给前端审批
|
||||||
yield _sse({
|
yield _sse({
|
||||||
"tool_calls_pending": calls_data,
|
"tool_calls_pending": calls_data,
|
||||||
"assistant_text": full_response,
|
"assistant_text": full_response,
|
||||||
})
|
})
|
||||||
# 标记为待审批,前端需要续传
|
|
||||||
yield _sse({"done": True, "pending": True, "usage": usage_info or None})
|
yield _sse({"done": True, "pending": True, "usage": usage_info or None})
|
||||||
return # 暂停,不保存到 DB
|
return # 暂停,不保存到 DB
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
yield _sse({"error": str(e)})
|
yield _sse({"error": str(e)})
|
||||||
finally:
|
finally:
|
||||||
pass
|
# 无论连接是否断开,都保存已有的回复和工具调用到 DB
|
||||||
|
# 这确保刷新页面后 loadHistory 能恢复正确状态
|
||||||
|
_save_assistant_message(data.novel_id, full_response, tool_call_logs)
|
||||||
|
|
||||||
# 正常完成:保存 AI 回复
|
|
||||||
if full_response:
|
|
||||||
with Session(engine) as s:
|
|
||||||
ai_msg = ChatMessage(
|
|
||||||
novel_id=data.novel_id,
|
|
||||||
role="assistant",
|
|
||||||
content=full_response,
|
|
||||||
)
|
|
||||||
s.add(ai_msg)
|
|
||||||
s.commit()
|
|
||||||
yield _sse({"done": True, "usage": usage_info or None})
|
yield _sse({"done": True, "usage": usage_info or None})
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
@@ -255,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),
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
# 如果指定了 parent_id,校验父节点存在且是顶级节点
|
||||||
|
if data.parent_id is not None:
|
||||||
|
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(
|
max_order = session.exec(
|
||||||
select(func.max(Outline.sort_order)).where(Outline.novel_id == novel_id)
|
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()
|
).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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -175,6 +180,7 @@ class ChatRequest(BaseModel):
|
|||||||
novel_id: int | None = None
|
novel_id: int | None = None
|
||||||
page_context: str | None = None # 用户当前所在页面,如 "大纲页" "世界观页"
|
page_context: str | None = None # 用户当前所在页面,如 "大纲页" "世界观页"
|
||||||
tools_enabled: bool = True # 是否启用工具调用
|
tools_enabled: bool = True # 是否启用工具调用
|
||||||
|
auto_approve_tools: bool = False # 是否自动执行工具(无需人工审批)
|
||||||
# 工具审批续传
|
# 工具审批续传
|
||||||
pending_tool_calls: list[PendingToolCall] | None = None
|
pending_tool_calls: list[PendingToolCall] | None = None
|
||||||
assistant_text: str | None = None # LLM 在工具调用前输出的文本
|
assistant_text: str | None = None # LLM 在工具调用前输出的文本
|
||||||
@@ -191,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()
|
||||||
|
|||||||
@@ -103,6 +103,65 @@ def build_tool_results_messages(
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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 兼容 ──
|
# ── OpenAI 兼容 ──
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""工具注册表和执行器 —— 大纲 & 世界观 CRUD"""
|
"""工具注册表和执行器 —— 小说信息、大纲 & 世界观 CRUD"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -6,12 +6,31 @@ from datetime import datetime, timezone
|
|||||||
from sqlmodel import Session, select, func
|
from sqlmodel import Session, select, func
|
||||||
|
|
||||||
from ..database import engine
|
from ..database import engine
|
||||||
from ..models import Novel, Outline, WorldSetting
|
from ..models import Novel, Outline, WorldSetting, Character, Chapter
|
||||||
|
|
||||||
|
|
||||||
# ── 工具定义(provider 无关格式)──
|
# ── 工具定义(provider 无关格式)──
|
||||||
|
|
||||||
TOOL_DEFINITIONS: list[dict] = [
|
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",
|
"name": "list_outlines",
|
||||||
"description": "列出当前小说的所有大纲节点,返回每个节点的 id、排序、摘要和详情",
|
"description": "列出当前小说的所有大纲节点,返回每个节点的 id、排序、摘要和详情",
|
||||||
@@ -28,6 +47,7 @@ TOOL_DEFINITIONS: list[dict] = [
|
|||||||
"properties": {
|
"properties": {
|
||||||
"summary": {"type": "string", "description": "大纲摘要(简短标题,200字以内)"},
|
"summary": {"type": "string", "description": "大纲摘要(简短标题,200字以内)"},
|
||||||
"detail": {"type": "string", "description": "大纲详情(可选,1000字以内)"},
|
"detail": {"type": "string", "description": "大纲详情(可选,1000字以内)"},
|
||||||
|
"parent_id": {"type": "integer", "description": "父节点 ID,创建子节点时需提供"},
|
||||||
},
|
},
|
||||||
"required": ["summary"],
|
"required": ["summary"],
|
||||||
},
|
},
|
||||||
@@ -56,6 +76,25 @@ TOOL_DEFINITIONS: list[dict] = [
|
|||||||
"required": ["outline_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",
|
"name": "get_world_setting",
|
||||||
"description": "获取当前小说的世界观设定内容",
|
"description": "获取当前小说的世界观设定内容",
|
||||||
@@ -75,6 +114,96 @@ TOOL_DEFINITIONS: list[dict] = [
|
|||||||
"required": ["content"],
|
"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"],
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -101,12 +230,23 @@ def execute_tool(name: str, arguments: dict, novel_id: int) -> str:
|
|||||||
"""执行工具,返回结果字符串(JSON)"""
|
"""执行工具,返回结果字符串(JSON)"""
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
handlers = {
|
handlers = {
|
||||||
|
"get_novel_info": _get_novel_info,
|
||||||
|
"update_novel_info": _update_novel_info,
|
||||||
"list_outlines": _list_outlines,
|
"list_outlines": _list_outlines,
|
||||||
"create_outline": _create_outline,
|
"create_outline": _create_outline,
|
||||||
"update_outline": _update_outline,
|
"update_outline": _update_outline,
|
||||||
"delete_outline": _delete_outline,
|
"delete_outline": _delete_outline,
|
||||||
|
"reorder_outlines": _reorder_outlines,
|
||||||
"get_world_setting": _get_world_setting,
|
"get_world_setting": _get_world_setting,
|
||||||
"save_world_setting": _save_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)
|
handler = handlers.get(name)
|
||||||
if not handler:
|
if not handler:
|
||||||
@@ -117,27 +257,100 @@ def execute_tool(name: str, arguments: dict, novel_id: int) -> str:
|
|||||||
return json.dumps({"error": str(e)}, ensure_ascii=False)
|
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:
|
def _list_outlines(session: Session, novel_id: int, _args: dict) -> str:
|
||||||
items = session.exec(
|
# 查询顶级节点
|
||||||
|
top_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()
|
||||||
result = [
|
# 查询所有子节点,按 parent_id 分组
|
||||||
{"id": o.id, "sort_order": o.sort_order, "summary": o.summary, "detail": o.detail}
|
all_children = session.exec(
|
||||||
for o in items
|
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)
|
return json.dumps({"outlines": result, "total": len(result)}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
def _create_outline(session: Session, novel_id: int, args: dict) -> str:
|
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(
|
max_order = session.exec(
|
||||||
select(func.max(Outline.sort_order)).where(Outline.novel_id == novel_id)
|
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()
|
).one()
|
||||||
next_order = (max_order or 0) + 1
|
next_order = (max_order or 0) + 1
|
||||||
|
|
||||||
outline = Outline(
|
outline = Outline(
|
||||||
novel_id=novel_id,
|
novel_id=novel_id,
|
||||||
|
parent_id=parent_id,
|
||||||
sort_order=next_order,
|
sort_order=next_order,
|
||||||
summary=args["summary"],
|
summary=args["summary"],
|
||||||
detail=args.get("detail", ""),
|
detail=args.get("detail", ""),
|
||||||
@@ -145,10 +358,10 @@ def _create_outline(session: Session, novel_id: int, args: dict) -> str:
|
|||||||
session.add(outline)
|
session.add(outline)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(outline)
|
session.refresh(outline)
|
||||||
return json.dumps(
|
result = {"id": outline.id, "sort_order": outline.sort_order, "summary": outline.summary, "detail": outline.detail}
|
||||||
{"id": outline.id, "sort_order": outline.sort_order, "summary": outline.summary, "detail": outline.detail},
|
if parent_id is not None:
|
||||||
ensure_ascii=False,
|
result["parent_id"] = parent_id
|
||||||
)
|
return json.dumps(result, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
def _update_outline(session: Session, novel_id: int, args: dict) -> str:
|
def _update_outline(session: Session, novel_id: int, args: dict) -> str:
|
||||||
@@ -175,11 +388,43 @@ def _delete_outline(session: Session, novel_id: int, args: dict) -> str:
|
|||||||
if not outline or outline.novel_id != novel_id:
|
if not outline or outline.novel_id != novel_id:
|
||||||
return json.dumps({"error": "大纲不存在"}, ensure_ascii=False)
|
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.delete(outline)
|
||||||
session.commit()
|
session.commit()
|
||||||
return json.dumps({"deleted": True, "id": args["outline_id"]}, ensure_ascii=False)
|
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:
|
def _get_world_setting(session: Session, novel_id: int, _args: dict) -> str:
|
||||||
ws = session.exec(
|
ws = session.exec(
|
||||||
select(WorldSetting).where(WorldSetting.novel_id == novel_id)
|
select(WorldSetting).where(WorldSetting.novel_id == novel_id)
|
||||||
@@ -203,3 +448,148 @@ def _save_world_setting(session: Session, novel_id: int, args: dict) -> str:
|
|||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(ws)
|
session.refresh(ws)
|
||||||
return json.dumps({"saved": True, "content_length": len(ws.content)}, ensure_ascii=False)
|
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
17
docker-compose.yml
Normal 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:
|
||||||
@@ -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 {
|
||||||
@@ -59,6 +75,7 @@ export interface StreamChatOptions {
|
|||||||
novelId?: number
|
novelId?: number
|
||||||
pageContext?: string
|
pageContext?: string
|
||||||
toolsEnabled?: boolean
|
toolsEnabled?: boolean
|
||||||
|
autoApproveTools?: boolean
|
||||||
// 工具审批续传
|
// 工具审批续传
|
||||||
pendingToolCalls?: PendingToolCallData[]
|
pendingToolCalls?: PendingToolCallData[]
|
||||||
assistantText?: string
|
assistantText?: string
|
||||||
@@ -67,15 +84,16 @@ export interface StreamChatOptions {
|
|||||||
onError: (error: string) => void
|
onError: (error: string) => void
|
||||||
onDone: (usage?: TokenUsage, pending?: boolean) => void
|
onDone: (usage?: TokenUsage, pending?: boolean) => void
|
||||||
onToolCallsPending?: (calls: PendingToolCallData[], assistantText: string) => void
|
onToolCallsPending?: (calls: PendingToolCallData[], assistantText: string) => void
|
||||||
|
onToolCallsAuto?: (calls: PendingToolCallData[]) => void
|
||||||
onToolResult?: (info: ToolResultInfo) => 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 {
|
const {
|
||||||
messages, novelId, pageContext, toolsEnabled,
|
messages, novelId, pageContext, toolsEnabled, autoApproveTools,
|
||||||
pendingToolCalls, assistantText,
|
pendingToolCalls, assistantText,
|
||||||
onChunk, onError, onDone, onToolCallsPending, onToolResult,
|
onChunk, onError, onDone, onToolCallsPending, onToolCallsAuto, onToolResult,
|
||||||
signal,
|
signal,
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
@@ -84,6 +102,7 @@ export async function streamChat(options: StreamChatOptions): Promise<void> {
|
|||||||
novel_id: novelId ?? null,
|
novel_id: novelId ?? null,
|
||||||
page_context: pageContext ?? null,
|
page_context: pageContext ?? null,
|
||||||
tools_enabled: toolsEnabled ?? true,
|
tools_enabled: toolsEnabled ?? true,
|
||||||
|
auto_approve_tools: autoApproveTools ?? false,
|
||||||
pending_tool_calls: pendingToolCalls ?? null,
|
pending_tool_calls: pendingToolCalls ?? null,
|
||||||
assistant_text: assistantText ?? null,
|
assistant_text: assistantText ?? null,
|
||||||
})
|
})
|
||||||
@@ -133,6 +152,9 @@ export async function streamChat(options: StreamChatOptions): Promise<void> {
|
|||||||
if (event.tool_calls_pending && onToolCallsPending) {
|
if (event.tool_calls_pending && onToolCallsPending) {
|
||||||
onToolCallsPending(event.tool_calls_pending, event.assistant_text ?? '')
|
onToolCallsPending(event.tool_calls_pending, event.assistant_text ?? '')
|
||||||
}
|
}
|
||||||
|
if (event.tool_calls_auto && onToolCallsAuto) {
|
||||||
|
onToolCallsAuto(event.tool_calls_auto)
|
||||||
|
}
|
||||||
if (event.tool_result && onToolResult) {
|
if (event.tool_result && onToolResult) {
|
||||||
onToolResult(event.tool_result)
|
onToolResult(event.tool_result)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +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 { isToolCallGroup } from '@/types/chat'
|
||||||
import type { ToolCallGroup } 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 ChatToolCalls from './ChatToolCalls.vue'
|
||||||
import ChatConfigModal from './ChatConfigModal.vue'
|
import ChatConfigModal from './ChatConfigModal.vue'
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
open: boolean
|
open: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -16,13 +18,14 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
entries, isStreaming, streamingContent, lastUsage,
|
entries, isStreaming, isCompressing, streamingContent, lastUsage,
|
||||||
toolsEnabled, currentNovelId,
|
toolsEnabled, autoApproveTools, currentNovelId,
|
||||||
loadHistory, sendMessage, approveToolCalls, skipToolCalls,
|
loadHistory, sendMessage, approveToolCalls, skipToolCalls,
|
||||||
stopStreaming, clearChat,
|
stopStreaming, clearChat, compressChat,
|
||||||
} = useChatAgent()
|
} = 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)
|
||||||
|
|
||||||
@@ -54,12 +57,28 @@ const hasPendingTools = computed(() =>
|
|||||||
entries.value.some(e => isToolCallGroup(e) && e.status === 'pending')
|
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(() => {
|
nextTick(() => {
|
||||||
if (messagesRef.value) {
|
if (messagesRef.value) {
|
||||||
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
|
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSend() {
|
async function handleSend() {
|
||||||
@@ -84,8 +103,11 @@ function handleSkip(group: ToolCallGroup) {
|
|||||||
skipToolCalls(group)
|
skipToolCalls(group)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 自动滚动
|
// 自动滚动:消息变化时、面板打开时
|
||||||
watch([entries, streamingContent], scrollToBottom)
|
watch([entries, streamingContent], scrollToBottom)
|
||||||
|
watch(() => props.open, (isOpen) => {
|
||||||
|
if (isOpen) scrollToBottom()
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadHistory()
|
loadHistory()
|
||||||
@@ -106,6 +128,17 @@ 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
|
<button
|
||||||
v-if="currentNovelId"
|
v-if="currentNovelId"
|
||||||
class="icon-btn"
|
class="icon-btn"
|
||||||
@@ -117,6 +150,22 @@ onMounted(() => {
|
|||||||
<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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</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" />
|
||||||
@@ -158,6 +207,19 @@ onMounted(() => {
|
|||||||
@approve="handleApprove(entry as ToolCallGroup)"
|
@approve="handleApprove(entry as ToolCallGroup)"
|
||||||
@skip="handleSkip(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
|
<ChatMessageComp
|
||||||
v-else
|
v-else
|
||||||
@@ -279,6 +341,21 @@ 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 {
|
.icon-btn--active {
|
||||||
color: var(--bamboo);
|
color: var(--bamboo);
|
||||||
background: rgba(91, 140, 90, 0.1);
|
background: rgba(91, 140, 90, 0.1);
|
||||||
@@ -327,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;
|
||||||
|
|||||||
@@ -18,23 +18,48 @@ const isSkipped = computed(() => props.group.status === 'skipped')
|
|||||||
|
|
||||||
// 工具图标(SVG path)
|
// 工具图标(SVG path)
|
||||||
const TOOL_ICONS: Record<string, string> = {
|
const TOOL_ICONS: Record<string, string> = {
|
||||||
|
// 大纲
|
||||||
list_outlines: 'M3 4h10M3 8h10M3 12h6',
|
list_outlines: 'M3 4h10M3 8h10M3 12h6',
|
||||||
create_outline: 'M8 3v10M3 8h10',
|
create_outline: 'M8 3v10M3 8h10',
|
||||||
update_outline: 'M3 12l2-2 4 4-2 2H3v-4zM9 6l2-2 4 4-2 2-4-4z',
|
update_outline: 'M3 12l2-2 4 4-2 2H3v-4zM9 6l2-2 4 4-2 2-4-4z',
|
||||||
delete_outline: 'M3 5h10M5 5V3h6v2M4 5v8h8V5',
|
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',
|
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',
|
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> = {
|
const TOOL_COLORS: Record<string, string> = {
|
||||||
|
// 大纲
|
||||||
list_outlines: 'var(--bamboo)',
|
list_outlines: 'var(--bamboo)',
|
||||||
create_outline: 'var(--bamboo)',
|
create_outline: 'var(--bamboo)',
|
||||||
update_outline: '#d4a017',
|
update_outline: '#d4a017',
|
||||||
delete_outline: 'var(--danger)',
|
delete_outline: 'var(--danger)',
|
||||||
delete_world_setting: 'var(--danger)',
|
reorder_outlines: '#d4a017',
|
||||||
|
// 世界观
|
||||||
get_world_setting: 'var(--ink-500)',
|
get_world_setting: 'var(--ink-500)',
|
||||||
save_world_setting: 'var(--bamboo)',
|
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 {
|
function getIcon(name: string): string {
|
||||||
@@ -57,10 +82,28 @@ function formatArgs(call: ToolCallEntry): string {
|
|||||||
return `#${args.outline_id}` + (args.summary ? ` → ${args.summary}` : '')
|
return `#${args.outline_id}` + (args.summary ? ` → ${args.summary}` : '')
|
||||||
case 'delete_outline':
|
case 'delete_outline':
|
||||||
return `#${args.outline_id}`
|
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': {
|
case 'save_world_setting': {
|
||||||
const content = (args.content as string) ?? ''
|
const content = (args.content as string) ?? ''
|
||||||
return content.length > 80 ? content.slice(0, 80) + '…' : content
|
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:
|
default:
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
@@ -71,7 +114,11 @@ function formatResult(call: ToolCallEntry): string {
|
|||||||
if (!call.result) return ''
|
if (!call.result) return ''
|
||||||
try {
|
try {
|
||||||
const data = call.parsedResult ?? JSON.parse(call.result)
|
const data = call.parsedResult ?? JSON.parse(call.result)
|
||||||
|
// 通用错误检查
|
||||||
|
if ((data as { error?: string }).error) return `失败: ${(data as { error: string }).error}`
|
||||||
|
|
||||||
switch (call.name) {
|
switch (call.name) {
|
||||||
|
// 大纲
|
||||||
case 'list_outlines': {
|
case 'list_outlines': {
|
||||||
const items = (data as { outlines: { id: number; summary: string }[] }).outlines
|
const items = (data as { outlines: { id: number; summary: string }[] }).outlines
|
||||||
if (!items?.length) return '暂无大纲'
|
if (!items?.length) return '暂无大纲'
|
||||||
@@ -80,11 +127,12 @@ function formatResult(call: ToolCallEntry): string {
|
|||||||
case 'create_outline':
|
case 'create_outline':
|
||||||
return `已创建: ${(data as { summary: string }).summary}`
|
return `已创建: ${(data as { summary: string }).summary}`
|
||||||
case 'update_outline':
|
case 'update_outline':
|
||||||
if ((data as { error?: string }).error) return `失败: ${(data as { error: string }).error}`
|
|
||||||
return `已更新: ${(data as { summary: string }).summary}`
|
return `已更新: ${(data as { summary: string }).summary}`
|
||||||
case 'delete_outline':
|
case 'delete_outline':
|
||||||
if ((data as { error?: string }).error) return `失败: ${(data as { error: string }).error}`
|
|
||||||
return '已删除'
|
return '已删除'
|
||||||
|
case 'reorder_outlines':
|
||||||
|
return `已调整 ${(data as { count: number }).count} 个节点的排序`
|
||||||
|
// 世界观
|
||||||
case 'get_world_setting': {
|
case 'get_world_setting': {
|
||||||
const content = (data as { content: string }).content
|
const content = (data as { content: string }).content
|
||||||
if (!content) return '暂无世界观设定'
|
if (!content) return '暂无世界观设定'
|
||||||
@@ -92,6 +140,30 @@ function formatResult(call: ToolCallEntry): string {
|
|||||||
}
|
}
|
||||||
case 'save_world_setting':
|
case 'save_world_setting':
|
||||||
return `已保存 (${(data as { content_length: number }).content_length} 字)`
|
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:
|
default:
|
||||||
return call.result.slice(0, 100)
|
return call.result.slice(0, 100)
|
||||||
}
|
}
|
||||||
|
|||||||
183
frontend/src/components/novel/NovelSubNav.vue
Normal file
183
frontend/src/components/novel/NovelSubNav.vue
Normal 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>
|
||||||
187
frontend/src/components/outline/OutlineChildList.vue
Normal file
187
frontend/src/components/outline/OutlineChildList.vue
Normal 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>
|
||||||
237
frontend/src/components/outline/OutlineChildNode.vue
Normal file
237
frontend/src/components/outline/OutlineChildNode.vue
Normal 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>
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,19 +11,42 @@ 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 class="outline-node-wrapper">
|
||||||
<div
|
<div
|
||||||
class="outline-node"
|
class="outline-node"
|
||||||
:class="{ 'outline-node--expanded': expanded }"
|
:class="{
|
||||||
|
'outline-node--expanded': expanded,
|
||||||
|
'outline-node--has-children': hasChildren,
|
||||||
|
'outline-node--children-open': childrenExpanded,
|
||||||
|
}"
|
||||||
@click="toggle"
|
@click="toggle"
|
||||||
>
|
>
|
||||||
<!-- 拖拽手柄 -->
|
<!-- 拖拽手柄 -->
|
||||||
@@ -37,8 +61,28 @@ function toggle() {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 子节点数量角标 -->
|
||||||
|
<div v-if="hasChildren" class="child-badge" @click="toggleChildren">
|
||||||
|
<span class="child-badge-count">{{ childCount }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 概要 -->
|
<!-- 概要 -->
|
||||||
|
<div class="node-summary-row">
|
||||||
<h3 class="node-summary">{{ outline.summary }}</h3>
|
<h3 class="node-summary">{{ outline.summary }}</h3>
|
||||||
|
<!-- 子节点展开/折叠切换 -->
|
||||||
|
<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-detail-wrapper" :class="{ 'node-detail-wrapper--open': expanded }">
|
<div class="node-detail-wrapper" :class="{ 'node-detail-wrapper--open': expanded }">
|
||||||
@@ -54,6 +98,12 @@ function toggle() {
|
|||||||
</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')">
|
<button class="action-btn action-btn--delete" @click="emit('delete')">
|
||||||
<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="M2 4h10M5 4V2.5h4V4M3.5 4v8h7V4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
@@ -78,9 +128,29 @@ function toggle() {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 子节点展开引导三角 -->
|
||||||
|
<div v-if="childrenExpanded" class="children-arrow" />
|
||||||
|
|
||||||
|
<!-- 子节点区域 -->
|
||||||
|
<OutlineChildList
|
||||||
|
v-if="hasChildren || childrenExpanded"
|
||||||
|
:children="outline.children || []"
|
||||||
|
:expanded="childrenExpanded"
|
||||||
|
@add-child="emit('addChild')"
|
||||||
|
@edit-child="(child) => emit('editChild', child)"
|
||||||
|
@delete-child="(child) => emit('deleteChild', child)"
|
||||||
|
@reorder-children="(children) => emit('reorderChildren', children)"
|
||||||
|
/>
|
||||||
|
</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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,18 +1,108 @@
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { streamChat, fetchMessages, clearMessages as apiClearMessages } from '@/api/chat'
|
import { streamChat, fetchMessages, clearMessages as apiClearMessages, compressContext } from '@/api/chat'
|
||||||
import type { ChatMessage, ChatEntry, ToolCallGroup, ToolCallEntry } from '@/types/chat'
|
import type { ChatMessage, ChatEntry, ToolCallGroup } from '@/types/chat'
|
||||||
import type { TokenUsage, PendingToolCallData } from '@/api/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 entries = ref<ChatEntry[]>([])
|
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 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> = {
|
const PAGE_LABELS: Record<string, string> = {
|
||||||
'novel-detail': '小说详情页',
|
'novel-detail': '小说详情页',
|
||||||
@@ -42,14 +132,40 @@ export function useChatAgent() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
async function loadHistory(novelId?: number) {
|
async function loadHistory(novelId?: number) {
|
||||||
|
const targetId = novelId ?? currentNovelId.value
|
||||||
try {
|
try {
|
||||||
const list = await fetchMessages(novelId ?? currentNovelId.value)
|
const list = await fetchMessages(targetId)
|
||||||
entries.value = list.items as ChatEntry[]
|
// 解析 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 {
|
||||||
// 静默
|
// 静默
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 监听小说切换,自动重新加载对应小说的聊天历史
|
||||||
|
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) {
|
async function sendMessage(content: string) {
|
||||||
if (isStreaming.value || !content.trim()) return
|
if (isStreaming.value || !content.trim()) return
|
||||||
const novelId = currentNovelId.value
|
const novelId = currentNovelId.value
|
||||||
@@ -111,6 +227,7 @@ export function useChatAgent() {
|
|||||||
novelId,
|
novelId,
|
||||||
pageContext: pageContext.value,
|
pageContext: pageContext.value,
|
||||||
toolsEnabled: toolsEnabled.value,
|
toolsEnabled: toolsEnabled.value,
|
||||||
|
autoApproveTools: autoApproveTools.value,
|
||||||
pendingToolCalls,
|
pendingToolCalls,
|
||||||
assistantText,
|
assistantText,
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
@@ -144,10 +261,28 @@ export function useChatAgent() {
|
|||||||
entries.value.push(group)
|
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) {
|
onToolResult(info) {
|
||||||
// 找到正在执行的工具组,更新对应工具的结果
|
// 找到正在执行的工具组,更新对应工具的结果
|
||||||
const group = [...entries.value].reverse().find(
|
const group = [...entries.value].reverse().find(
|
||||||
(e): e is ToolCallGroup => 'type' in e && e.type === 'tool_calls' && e.status === 'executing'
|
(e): e is ToolCallGroup => 'type' in e && e.type === 'tool_calls' && (e.status === 'executing' || e.status === 'done')
|
||||||
)
|
)
|
||||||
if (group) {
|
if (group) {
|
||||||
const call = group.calls.find(c => c.name === info.name)
|
const call = group.calls.find(c => c.name === info.name)
|
||||||
@@ -160,6 +295,10 @@ export function useChatAgent() {
|
|||||||
group.status = 'done'
|
group.status = 'done'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 通知页面组件刷新数据
|
||||||
|
window.dispatchEvent(new CustomEvent('tool-executed', {
|
||||||
|
detail: { name: info.name },
|
||||||
|
}))
|
||||||
},
|
},
|
||||||
|
|
||||||
onError(error) {
|
onError(error) {
|
||||||
@@ -177,8 +316,6 @@ export function useChatAgent() {
|
|||||||
lastUsage.value = usage
|
lastUsage.value = usage
|
||||||
}
|
}
|
||||||
if (pending) {
|
if (pending) {
|
||||||
// 工具调用待审批,保持 isStreaming 状态让 UI 知道对话还没结束
|
|
||||||
// 但不再显示加载指示
|
|
||||||
isStreaming.value = false
|
isStreaming.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -195,6 +332,14 @@ export function useChatAgent() {
|
|||||||
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -217,12 +362,41 @@ export function useChatAgent() {
|
|||||||
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 {
|
||||||
entries,
|
entries,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
|
isCompressing,
|
||||||
streamingContent,
|
streamingContent,
|
||||||
lastUsage,
|
lastUsage,
|
||||||
toolsEnabled,
|
toolsEnabled,
|
||||||
|
autoApproveTools,
|
||||||
currentNovelId,
|
currentNovelId,
|
||||||
pageContext,
|
pageContext,
|
||||||
loadHistory,
|
loadHistory,
|
||||||
@@ -231,5 +405,6 @@ export function useChatAgent() {
|
|||||||
skipToolCalls,
|
skipToolCalls,
|
||||||
stopStreaming,
|
stopStreaming,
|
||||||
clearChat,
|
clearChat,
|
||||||
|
compressChat,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
23
frontend/src/composables/useToolRefresh.ts
Normal file
23
frontend/src/composables/useToolRefresh.ts
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -23,27 +23,34 @@ 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',
|
||||||
|
component: () => import('@/views/NovelWorkspace.vue'),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'outlines',
|
||||||
name: 'novel-outlines',
|
name: 'novel-outlines',
|
||||||
component: () => import('@/views/OutlineView.vue'),
|
component: () => import('@/views/OutlineView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/novels/:id/chapters',
|
path: 'chapters',
|
||||||
name: 'novel-chapters',
|
name: 'novel-chapters',
|
||||||
component: () => import('@/views/ChapterView.vue'),
|
component: () => import('@/views/ChapterView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/novels/:id/characters',
|
path: 'characters',
|
||||||
name: 'novel-characters',
|
name: 'novel-characters',
|
||||||
component: () => import('@/views/CharacterView.vue'),
|
component: () => import('@/views/CharacterView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/novels/:id/world-setting',
|
path: 'world-setting',
|
||||||
name: 'novel-world-setting',
|
name: 'novel-world-setting',
|
||||||
component: () => import('@/views/WorldSettingView.vue'),
|
component: () => import('@/views/WorldSettingView.vue'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 { fetchChapters, createChapter, updateChapter, deleteChapter, reorderChapters } from '@/api/chapters'
|
import { fetchChapters, createChapter, updateChapter, deleteChapter, reorderChapters } from '@/api/chapters'
|
||||||
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 { Chapter } from '@/types/chapter'
|
import type { Chapter } from '@/types/chapter'
|
||||||
import BaseButton from '@/components/ui/BaseButton.vue'
|
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||||
@@ -17,8 +17,9 @@ 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 chapters = ref<Chapter[]>([])
|
const chapters = ref<Chapter[]>([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
|
||||||
@@ -40,15 +41,19 @@ const deleting = ref(false)
|
|||||||
|
|
||||||
const totalChapters = computed(() => chapters.value.length)
|
const totalChapters = computed(() => chapters.value.length)
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
subtitle.value = `共 ${totalChapters.value} 章`
|
||||||
|
})
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const [n, list] = await Promise.all([
|
const list = await fetchChapters(novelId)
|
||||||
fetchNovel(novelId),
|
|
||||||
fetchChapters(novelId),
|
|
||||||
])
|
|
||||||
novel.value = n
|
|
||||||
chapters.value = list.items
|
chapters.value = list.items
|
||||||
|
// 默认展开最后一章
|
||||||
|
if (chapters.value.length > 0 && !activeChapter.value) {
|
||||||
|
activeChapter.value = chapters.value[chapters.value.length - 1]
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('加载失败')
|
toast.error('加载失败')
|
||||||
router.push({ name: 'home' })
|
router.push({ name: 'home' })
|
||||||
@@ -57,6 +62,9 @@ async function load() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AI 工具执行后自动刷新章节数据
|
||||||
|
useToolRefresh(['list_chapters', 'create_chapter', 'update_chapter', 'delete_chapter'], load)
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
editingChapter.value = null
|
editingChapter.value = null
|
||||||
showForm.value = true
|
showForm.value = true
|
||||||
@@ -155,19 +163,12 @@ onMounted(load)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="!loading && novel" class="chapter-view">
|
<div v-if="!loading" class="chapter-view">
|
||||||
<div class="chapter-header">
|
<Teleport to="#workspace-actions">
|
||||||
<BaseButton variant="ghost" @click="router.push({ name: 'novel-detail', params: { id: novelId } })">
|
|
||||||
← 返回
|
|
||||||
</BaseButton>
|
|
||||||
<div class="header-center">
|
|
||||||
<h1 class="chapter-title">{{ novel.title }}</h1>
|
|
||||||
<p class="chapter-subtitle">章节管理 · 共 {{ totalChapters }} 章</p>
|
|
||||||
</div>
|
|
||||||
<BaseButton v-if="totalChapters > 0" variant="primary" @click="openCreate">
|
<BaseButton v-if="totalChapters > 0" variant="primary" @click="openCreate">
|
||||||
新建章节
|
新建章节
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
</div>
|
</Teleport>
|
||||||
|
|
||||||
<template v-if="totalChapters > 0">
|
<template v-if="totalChapters > 0">
|
||||||
<!-- 双栏布局 -->
|
<!-- 双栏布局 -->
|
||||||
@@ -231,36 +232,7 @@ onMounted(load)
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.chapter-view {
|
.chapter-view {
|
||||||
max-width: 1100px;
|
/* max-width 由父 NovelWorkspace 控制 */
|
||||||
margin: 0 auto;
|
|
||||||
animation: fadeInUp var(--duration-normal) var(--ease-out-smooth) both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-header {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-title {
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: 1.8rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--ink-900);
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-subtitle {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--ink-400);
|
|
||||||
margin-top: var(--space-xs);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 双栏布局 */
|
/* 双栏布局 */
|
||||||
|
|||||||
@@ -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 { fetchCharacters, createCharacter, updateCharacter, deleteCharacter } from '@/api/characters'
|
import { fetchCharacters, createCharacter, updateCharacter, deleteCharacter } from '@/api/characters'
|
||||||
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 { Character, CharacterCreate, CharacterUpdate } from '@/types/character'
|
import type { Character, CharacterCreate, CharacterUpdate } from '@/types/character'
|
||||||
import BaseButton from '@/components/ui/BaseButton.vue'
|
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||||
@@ -16,8 +16,9 @@ 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 characters = ref<Character[]>([])
|
const characters = ref<Character[]>([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
|
||||||
@@ -33,14 +34,14 @@ const deleting = ref(false)
|
|||||||
|
|
||||||
const totalCharacters = computed(() => characters.value.length)
|
const totalCharacters = computed(() => characters.value.length)
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
subtitle.value = `共 ${totalCharacters.value} 个角色`
|
||||||
|
})
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const [n, list] = await Promise.all([
|
const list = await fetchCharacters(novelId)
|
||||||
fetchNovel(novelId),
|
|
||||||
fetchCharacters(novelId),
|
|
||||||
])
|
|
||||||
novel.value = n
|
|
||||||
characters.value = list.items
|
characters.value = list.items
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('加载失败')
|
toast.error('加载失败')
|
||||||
@@ -50,6 +51,9 @@ async function load() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AI 工具执行后自动刷新角色数据
|
||||||
|
useToolRefresh(['list_characters', 'create_character', 'update_character', 'delete_character'], load)
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
editingCharacter.value = null
|
editingCharacter.value = null
|
||||||
showForm.value = true
|
showForm.value = true
|
||||||
@@ -105,19 +109,12 @@ onMounted(load)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="!loading && novel" class="character-view">
|
<div v-if="!loading" class="character-view">
|
||||||
<div class="character-header">
|
<Teleport to="#workspace-actions">
|
||||||
<BaseButton variant="ghost" @click="router.push({ name: 'novel-detail', params: { id: novelId } })">
|
|
||||||
← 返回
|
|
||||||
</BaseButton>
|
|
||||||
<div class="header-center">
|
|
||||||
<h1 class="character-title">{{ novel.title }}</h1>
|
|
||||||
<p class="character-subtitle">角色管理 · 共 {{ totalCharacters }} 个角色</p>
|
|
||||||
</div>
|
|
||||||
<BaseButton v-if="totalCharacters > 0" variant="primary" @click="openCreate">
|
<BaseButton v-if="totalCharacters > 0" variant="primary" @click="openCreate">
|
||||||
添加角色
|
添加角色
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
</div>
|
</Teleport>
|
||||||
|
|
||||||
<CharacterGrid
|
<CharacterGrid
|
||||||
v-if="totalCharacters > 0"
|
v-if="totalCharacters > 0"
|
||||||
@@ -157,36 +154,7 @@ onMounted(load)
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.character-view {
|
.character-view {
|
||||||
max-width: 900px;
|
/* max-width 由父 NovelWorkspace 控制 */
|
||||||
margin: 0 auto;
|
|
||||||
animation: fadeInUp var(--duration-normal) var(--ease-out-smooth) both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.character-header {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.character-title {
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: 1.8rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--ink-900);
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.character-subtitle {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--ink-400);
|
|
||||||
margin-top: var(--space-xs);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Loading */
|
/* Loading */
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
91
frontend/src/views/NovelWorkspace.vue
Normal file
91
frontend/src/views/NovelWorkspace.vue
Normal 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>
|
||||||
@@ -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)
|
||||||
|
if (deletingIsChild.value) {
|
||||||
|
// 从父节点的 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)
|
outlines.value = outlines.value.filter(o => o.id !== deletingOutline.value!.id)
|
||||||
toast.success('大纲已删除')
|
}
|
||||||
|
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 } })">
|
|
||||||
← 返回
|
|
||||||
</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 {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, inject, onMounted, computed } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { fetchNovel } from '@/api/novels'
|
|
||||||
import { fetchWorldSetting, saveWorldSetting } from '@/api/worldSetting'
|
import { fetchWorldSetting, saveWorldSetting } from '@/api/worldSetting'
|
||||||
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 BaseTextarea from '@/components/ui/BaseTextarea.vue'
|
import BaseTextarea from '@/components/ui/BaseTextarea.vue'
|
||||||
@@ -12,8 +12,9 @@ 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 content = ref('')
|
const content = ref('')
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
@@ -26,14 +27,16 @@ const contentHint = computed(() => {
|
|||||||
return `${contentLength.value} 字`
|
return `${contentLength.value} 字`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 世界观页不需要副标题
|
||||||
|
subtitle.value = ''
|
||||||
|
|
||||||
|
// AI 工具执行后自动刷新世界观数据
|
||||||
|
useToolRefresh(['get_world_setting', 'save_world_setting'], load)
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const [n, ws] = await Promise.all([
|
const ws = await fetchWorldSetting(novelId)
|
||||||
fetchNovel(novelId),
|
|
||||||
fetchWorldSetting(novelId),
|
|
||||||
])
|
|
||||||
novel.value = n
|
|
||||||
content.value = ws.content
|
content.value = ws.content
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('加载失败')
|
toast.error('加载失败')
|
||||||
@@ -64,15 +67,8 @@ onMounted(load)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="!loading && novel" class="world-view">
|
<div v-if="!loading" class="world-view">
|
||||||
<div class="world-header">
|
<Teleport to="#workspace-actions">
|
||||||
<BaseButton variant="ghost" @click="router.push({ name: 'novel-detail', params: { id: novelId } })">
|
|
||||||
← 返回
|
|
||||||
</BaseButton>
|
|
||||||
<div class="header-center">
|
|
||||||
<h1 class="world-title">{{ novel.title }}</h1>
|
|
||||||
<p class="world-subtitle">世界观设定</p>
|
|
||||||
</div>
|
|
||||||
<BaseButton
|
<BaseButton
|
||||||
variant="primary"
|
variant="primary"
|
||||||
:loading="saving"
|
:loading="saving"
|
||||||
@@ -81,7 +77,7 @@ onMounted(load)
|
|||||||
>
|
>
|
||||||
保存
|
保存
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
</div>
|
</Teleport>
|
||||||
|
|
||||||
<div class="editor-container">
|
<div class="editor-container">
|
||||||
<div class="editor-hint">
|
<div class="editor-hint">
|
||||||
@@ -124,33 +120,6 @@ onMounted(load)
|
|||||||
animation: fadeInUp var(--duration-normal) var(--ease-out-smooth) both;
|
animation: fadeInUp var(--duration-normal) var(--ease-out-smooth) both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.world-header {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.world-title {
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: 1.8rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--ink-900);
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.world-subtitle {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--ink-400);
|
|
||||||
margin-top: var(--space-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-container {
|
.editor-container {
|
||||||
background: var(--paper-50);
|
background: var(--paper-50);
|
||||||
border: 1px solid var(--paper-200);
|
border: 1px solid var(--paper-200);
|
||||||
|
|||||||
26
start.bat
26
start.bat
@@ -1,7 +1,24 @@
|
|||||||
@echo off
|
@echo off
|
||||||
chcp 65001 >nul
|
chcp 65001 >nul
|
||||||
title Noval - 小说创作应用
|
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 ========================================
|
||||||
echo Noval 启动中...
|
echo Noval 启动中...
|
||||||
echo ========================================
|
echo ========================================
|
||||||
@@ -27,7 +44,7 @@ if not exist "frontend\node_modules" (
|
|||||||
)
|
)
|
||||||
|
|
||||||
echo [1/2] 启动后端 (port 8000)...
|
echo [1/2] 启动后端 (port 8000)...
|
||||||
start "Noval-Backend" cmd /c "cd backend && .venv\Scripts\uvicorn app.main:app --port 8000"
|
start "Noval-Backend" cmd /c "cd backend && .venv\Scripts\uvicorn app.main:app --port 8000 --reload"
|
||||||
|
|
||||||
echo [2/2] 启动前端 (port 5173)...
|
echo [2/2] 启动前端 (port 5173)...
|
||||||
start "Noval-Frontend" cmd /c "cd frontend && npm run dev"
|
start "Noval-Frontend" cmd /c "cd frontend && npm run dev"
|
||||||
@@ -42,8 +59,7 @@ echo Noval 已启动!
|
|||||||
echo 前端: http://localhost:5173
|
echo 前端: http://localhost:5173
|
||||||
echo 后端: http://localhost:8000/docs
|
echo 后端: http://localhost:8000/docs
|
||||||
echo ========================================
|
echo ========================================
|
||||||
echo 关闭此窗口不会停止服务
|
echo 再次运行此脚本会自动重启所有服务
|
||||||
echo 如需停止,请关闭 Noval-Backend 和
|
echo 后端已启用 --reload,修改代码自动生效
|
||||||
echo Noval-Frontend 两个终端窗口
|
|
||||||
echo ========================================
|
echo ========================================
|
||||||
pause
|
pause
|
||||||
|
|||||||
Reference in New Issue
Block a user