diff --git a/.claude/settings.local.json b/.claude/settings.local.json index da10cf7..de1f063 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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 B\"\",\"\"description\"\":\"\"Desc B\"\"}\")", "Bash(taskkill //F //IM uvicorn.exe)", - "Bash(taskkill //F //IM python.exe)" + "Bash(taskkill //F //IM python.exe)", + "Bash(git add:*)", + "Bash(git commit:*)" ] } } diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5f91218 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4528994 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/backend/app/database.py b/backend/app/database.py index 473e3dc..ed06dc5 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -1,9 +1,11 @@ +import os from pathlib import Path +from sqlalchemy import text from sqlmodel import SQLModel, Session, create_engine -# 数据库文件存放在 backend/data/ 目录下 -DATA_DIR = Path(__file__).resolve().parent.parent / "data" +# 数据目录:优先使用环境变量(Docker 中设为 /app/data),否则用 backend/data/ +DATA_DIR = Path(os.environ.get("NOVAL_DATA_DIR", Path(__file__).resolve().parent.parent / "data")) DATA_DIR.mkdir(exist_ok=True) DATABASE_URL = f"sqlite:///{DATA_DIR / 'noval.db'}" @@ -13,6 +15,19 @@ engine = create_engine(DATABASE_URL, echo=False) def init_db() -> None: 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(): diff --git a/backend/app/main.py b/backend/app/main.py index 04eb3e8..4163b65 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,7 +1,10 @@ from contextlib import asynccontextmanager +from pathlib import Path from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from starlette.responses import FileResponse from .database import init_db 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.chat import router as chat_router +# 前端构建产物目录(Docker 中为 /app/static,本地开发时不存在则跳过) +STATIC_DIR = Path(__file__).resolve().parent.parent / "static" + @asynccontextmanager async def lifespan(app: FastAPI): @@ -23,7 +29,7 @@ app = FastAPI(title="Noval", version="0.1.0", lifespan=lifespan) app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost:5173"], + allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], @@ -41,3 +47,17 @@ app.include_router(chat_router) @app.get("/api/health") def health(): 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") diff --git a/backend/app/models.py b/backend/app/models.py index 1dfa1ed..872d340 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -39,10 +39,11 @@ class ChatMessage(SQLModel, table=True): created_at: datetime = Field(default_factory=_now_utc) class Outline(SQLModel, table=True): - """大纲表,属于小说的子资源""" + """大纲表,属于小说的子资源,支持两级结构(父+子)""" id: Optional[int] = Field(default=None, primary_key=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) summary: str = Field(max_length=200) detail: str = Field(default="", max_length=1000) diff --git a/backend/app/routers/chat.py b/backend/app/routers/chat.py index 2ffbe81..8337ad9 100644 --- a/backend/app/routers/chat.py +++ b/backend/app/routers/chat.py @@ -9,7 +9,8 @@ from ..database import get_session, engine from ..models import AIConfig, ChatMessage, Novel from ..schemas import ChatRequest, ChatMessageRead, ChatMessageList 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 @@ -20,12 +21,23 @@ MAX_TOOL_ROUNDS = 8 # 工具名称到中文描述 TOOL_LABELS: dict[str, str] = { + "get_novel_info": "查看小说信息", + "update_novel_info": "更新小说信息", "list_outlines": "查询大纲", "create_outline": "创建大纲", "update_outline": "更新大纲", "delete_outline": "删除大纲", + "reorder_outlines": "调整大纲排序", "get_world_setting": "查询世界观", "save_world_setting": "保存世界观", + "list_characters": "查询角色", + "create_character": "创建角色", + "update_character": "更新角色", + "delete_character": "删除角色", + "list_chapters": "查询章节", + "create_chapter": "创建章节", + "update_chapter": "更新章节", + "delete_chapter": "删除章节", } @@ -36,28 +48,50 @@ def _build_system_prompt( tools_enabled: bool = False, ) -> str: """根据小说上下文和用户所在页面构建 system prompt""" - base = "你是一个专业的小说创作助手。" - if not novel_id: - return base - - novel = session.get(Novel, novel_id) - if not novel: - return base - parts = [ - f"你是一个专业的小说创作助手,当前正在协助创作《{novel.title}》。", - f"小说简介:{novel.description or '暂无'}", + "你是 Writing Red Dot 写作助手,专注于小说创作领域。", + "你擅长:构思情节、塑造角色、打磨文笔、设计世界观、规划大纲结构。", ] + if novel_id: + novel = session.get(Novel, novel_id) + if novel: + parts.append(f"\n当前小说:《{novel.title}》(ID: {novel.id})") + parts.append(f"小说简介:{novel.description or '暂无'}") + if page_context: - parts.append(f"\n用户当前正在查看:{page_context}。") + parts.append(f"\n用户当前正在查看:{page_context}。请结合当前页面场景提供针对性建议。") if tools_enabled: 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) @@ -72,6 +106,23 @@ def _sse(data: dict) -> str: return f"data: {json.dumps(data, ensure_ascii=False)}\n\n" +def _save_assistant_message(novel_id: int | None, full_response: str, tool_call_logs: list[str]): + """保存 AI 回复到 DB(含工具调用摘要)。在 finally 中调用,确保断连也能保存。""" + save_content = full_response + if tool_call_logs: + tool_summary = "\n".join(tool_call_logs) + save_content = f"[以下操作已执行]\n{tool_summary}\n\n{full_response}" + if save_content.strip(): + with Session(engine) as s: + ai_msg = ChatMessage( + novel_id=novel_id, + role="assistant", + content=save_content, + ) + s.add(ai_msg) + s.commit() + + @router.post("/stream") async def chat_stream( data: ChatRequest, @@ -101,7 +152,7 @@ async def chat_stream( 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( data.novel_id, session, data.page_context, use_tools ) @@ -112,6 +163,8 @@ async def chat_stream( full_response = data.assistant_text or "" usage_info: dict = {} + # 记录本次对话中的工具调用摘要(用于保存到 DB) + tool_call_logs: list[str] = [] try: # ── 阶段一:如果有待审批的工具调用,先执行它们 ── @@ -133,6 +186,7 @@ async def chat_stream( 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, @@ -164,7 +218,7 @@ async def chat_stream( full_response += round_text break - # 有工具调用 → 暂停,发给前端审批 + # 有工具调用 full_response += round_text calls_data = [ { @@ -175,29 +229,48 @@ async def chat_stream( } for tc in round_tool_calls ] - yield _sse({ - "tool_calls_pending": calls_data, - "assistant_text": full_response, - }) - # 标记为待审批,前端需要续传 - yield _sse({"done": True, "pending": True, "usage": usage_info or None}) - return # 暂停,不保存到 DB + + if data.auto_approve_tools: + # 自动执行模式:直接执行工具,不暂停 + yield _sse({"tool_calls_auto": calls_data}) + + assistant_msg = build_assistant_message( + config.provider, full_response, round_tool_calls + ) + messages.append(assistant_msg) + + tool_results = [] + for tc in round_tool_calls: + label = TOOL_LABELS.get(tc.name, tc.name) + result = execute_tool(tc.name, tc.arguments, data.novel_id) + tool_results.append({"id": tc.id, "result": result}) + tool_call_logs.append(f"[工具调用: {label}] 参数: {json.dumps(tc.arguments, ensure_ascii=False)} → 结果: {result}") + yield _sse({"tool_result": { + "name": tc.name, + "label": label, + "result": result, + }}) + + result_msgs = build_tool_results_messages(config.provider, tool_results) + messages.extend(result_msgs) + # 继续循环,让 LLM 基于工具结果生成回复 + continue + else: + # 审批模式:暂停,发给前端审批 + yield _sse({ + "tool_calls_pending": calls_data, + "assistant_text": full_response, + }) + yield _sse({"done": True, "pending": True, "usage": usage_info or None}) + return # 暂停,不保存到 DB except Exception as e: yield _sse({"error": str(e)}) finally: - 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}) return StreamingResponse( @@ -255,3 +328,84 @@ def clear_messages( for msg in messages: session.delete(msg) session.commit() + + +# 保留最近对话轮次数(每轮 = 1 user + 1 assistant) +KEEP_RECENT_ROUNDS = 2 + + +@router.post("/compress") +async def compress_context( + novel_id: int | None = Query(default=None), + session: Session = Depends(get_session), +): + """压缩对话上下文:用 LLM 总结旧消息,仅保留最近几轮对话。""" + config = session.exec(select(AIConfig)).first() + if not config or not config.api_key: + return {"error": "请先配置AI服务"} + + # 查询所有消息(按时间正序) + query = select(ChatMessage) + if novel_id is not None: + query = query.where(ChatMessage.novel_id == novel_id) + else: + query = query.where(ChatMessage.novel_id.is_(None)) # type: ignore + all_msgs = list(session.exec(query.order_by(ChatMessage.created_at)).all()) + + if len(all_msgs) <= KEEP_RECENT_ROUNDS * 2: + return {"compressed": False, "reason": "消息太少,无需压缩"} + + # 分割:旧消息(需要压缩)+ 最近消息(保留原文) + keep_count = KEEP_RECENT_ROUNDS * 2 + old_msgs = all_msgs[:-keep_count] + recent_msgs = all_msgs[-keep_count:] + + # 检查第一条旧消息是否已是摘要(避免重复压缩无效内容) + # 构建需要总结的对话文本 + conversation_text = "" + for msg in old_msgs: + role_label = "用户" if msg.role == "user" else "AI助手" + content = msg.content + # 如果是已压缩的摘要,标注出来 + if content.startswith("[对话摘要]"): + conversation_text += f"[之前的摘要]{content[5:]}\n\n" + else: + conversation_text += f"{role_label}: {content}\n\n" + + # 调用 LLM 生成摘要 + summary_prompt = ( + "你是一个对话摘要助手。请将以下对话历史压缩为简洁的摘要,保留关键信息:\n" + "1. 用户提出的核心需求和决策\n" + "2. AI 执行的重要操作及结果(如创建/修改了哪些大纲、角色、章节等)\n" + "3. 达成的共识和待办事项\n\n" + "只输出摘要内容,不要加前缀或解释。用简洁的条目列表形式。" + ) + summary_messages = [ + {"role": "user", "content": f"请压缩以下对话历史:\n\n{conversation_text}"} + ] + + try: + summary = await simple_completion(config, summary_messages, summary_prompt) + except Exception as e: + return {"error": f"摘要生成失败: {str(e)}"} + + # 删除旧消息 + for msg in old_msgs: + session.delete(msg) + + # 插入摘要消息(时间设为最早保留消息之前) + summary_msg = ChatMessage( + novel_id=novel_id, + role="assistant", + content=f"[对话摘要]\n{summary}", + created_at=recent_msgs[0].created_at, + ) + session.add(summary_msg) + session.commit() + + return { + "compressed": True, + "removed_count": len(old_msgs), + "kept_count": len(recent_msgs), + "summary_length": len(summary), + } diff --git a/backend/app/routers/outlines.py b/backend/app/routers/outlines.py index 749f7fe..3e38cbd 100644 --- a/backend/app/routers/outlines.py +++ b/backend/app/routers/outlines.py @@ -32,24 +32,62 @@ def _get_outline_or_404( 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) def list_outlines( novel_id: int, session: Session = Depends(get_session), ): _get_novel_or_404(novel_id, session) - total = session.exec( - select(func.count(Outline.id)).where(Outline.novel_id == novel_id) - ).one() - items = session.exec( + # 只查顶级节点(parent_id IS NULL) + top_items = session.exec( 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) ).all() - return OutlineList( - items=[OutlineRead.model_validate(o, from_attributes=True) for o in items], - total=total, - ) + + # 查询所有子节点,按 parent_id 分组 + all_children = session.exec( + select(Outline) + .where(Outline.novel_id == novel_id, Outline.parent_id.isnot(None)) # type: ignore + .order_by(Outline.sort_order) + ).all() + children_map: dict[int, list[Outline]] = {} + for child in all_children: + children_map.setdefault(child.parent_id, []).append(child) + + 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) @@ -59,17 +97,41 @@ def create_outline( session: Session = Depends(get_session), ): _get_novel_or_404(novel_id, session) - # 自动设置 sort_order 为当前最大值 + 1 - max_order = session.exec( - select(func.max(Outline.sort_order)).where(Outline.novel_id == novel_id) - ).one() + + # 如果指定了 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( + select(func.max(Outline.sort_order)) + .where(Outline.novel_id == novel_id, Outline.parent_id == data.parent_id) + ).one() + else: + max_order = session.exec( + select(func.max(Outline.sort_order)) + .where(Outline.novel_id == novel_id, Outline.parent_id.is_(None)) # type: ignore + ).one() next_order = (max_order or 0) + 1 - 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.commit() session.refresh(outline) - return outline + + # 返回时附带空 children + return _build_outline_read(outline, []) @router.put("/reorder", response_model=OutlineList) @@ -78,25 +140,53 @@ def reorder_outlines( data: OutlineReorder, session: Session = Depends(get_session), ): + """仅处理顶级节点排序""" _get_novel_or_404(novel_id, session) now = datetime.now(timezone.utc) for idx, oid in enumerate(data.ordered_ids): 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.updated_at = now session.add(outline) 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) - .where(Outline.novel_id == novel_id) + .where(Outline.novel_id == novel_id, Outline.parent_id == outline_id) .order_by(Outline.sort_order) ).all() - total = len(items) - return OutlineList( - items=[OutlineRead.model_validate(o, from_attributes=True) for o in items], - total=total, - ) + session.refresh(parent) + return _build_outline_read(parent, list(children)) @router.get("/{outline_id}", response_model=OutlineRead) @@ -105,7 +195,14 @@ def get_outline( outline_id: int, 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) @@ -123,7 +220,14 @@ def update_outline( session.add(outline) session.commit() 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) @@ -133,5 +237,12 @@ def delete_outline( session: Session = Depends(get_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.commit() diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 52e15c0..276dfb2 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from datetime import datetime from pydantic import BaseModel, Field @@ -32,6 +34,7 @@ class NovelList(BaseModel): class OutlineCreate(BaseModel): summary: str = Field(min_length=1, max_length=200) detail: str = Field(default="", max_length=1000) + parent_id: int | None = None class OutlineUpdate(BaseModel): @@ -43,9 +46,11 @@ class OutlineUpdate(BaseModel): class OutlineRead(BaseModel): id: int novel_id: int + parent_id: int | None = None sort_order: int summary: str detail: str + children: list[OutlineRead] = [] created_at: datetime updated_at: datetime @@ -175,6 +180,7 @@ class ChatRequest(BaseModel): novel_id: int | None = None page_context: str | None = None # 用户当前所在页面,如 "大纲页" "世界观页" tools_enabled: bool = True # 是否启用工具调用 + auto_approve_tools: bool = False # 是否自动执行工具(无需人工审批) # 工具审批续传 pending_tool_calls: list[PendingToolCall] | None = None assistant_text: str | None = None # LLM 在工具调用前输出的文本 @@ -191,3 +197,7 @@ class ChatMessageRead(BaseModel): class ChatMessageList(BaseModel): items: list[ChatMessageRead] total: int + + +# 解决 OutlineRead 自引用 +OutlineRead.model_rebuild() diff --git a/backend/app/services/ai_provider.py b/backend/app/services/ai_provider.py index f8b9a2c..f04f86f 100644 --- a/backend/app/services/ai_provider.py +++ b/backend/app/services/ai_provider.py @@ -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 兼容 ── diff --git a/backend/app/services/tools.py b/backend/app/services/tools.py index 6da5ab1..2c5451c 100644 --- a/backend/app/services/tools.py +++ b/backend/app/services/tools.py @@ -1,4 +1,4 @@ -"""工具注册表和执行器 —— 大纲 & 世界观 CRUD""" +"""工具注册表和执行器 —— 小说信息、大纲 & 世界观 CRUD""" import json from datetime import datetime, timezone @@ -6,12 +6,31 @@ from datetime import datetime, timezone from sqlmodel import Session, select, func from ..database import engine -from ..models import Novel, Outline, WorldSetting +from ..models import Novel, Outline, WorldSetting, Character, Chapter # ── 工具定义(provider 无关格式)── TOOL_DEFINITIONS: list[dict] = [ + { + "name": "get_novel_info", + "description": "获取当前小说的基础信息,包括标题、简介、创建时间等", + "parameters": { + "type": "object", + "properties": {}, + }, + }, + { + "name": "update_novel_info", + "description": "更新当前小说的基础信息(标题或简介)", + "parameters": { + "type": "object", + "properties": { + "title": {"type": "string", "description": "新的小说标题(可选,1-100字)"}, + "description": {"type": "string", "description": "新的小说简介(可选)"}, + }, + }, + }, { "name": "list_outlines", "description": "列出当前小说的所有大纲节点,返回每个节点的 id、排序、摘要和详情", @@ -28,6 +47,7 @@ TOOL_DEFINITIONS: list[dict] = [ "properties": { "summary": {"type": "string", "description": "大纲摘要(简短标题,200字以内)"}, "detail": {"type": "string", "description": "大纲详情(可选,1000字以内)"}, + "parent_id": {"type": "integer", "description": "父节点 ID,创建子节点时需提供"}, }, "required": ["summary"], }, @@ -56,6 +76,25 @@ TOOL_DEFINITIONS: list[dict] = [ "required": ["outline_id"], }, }, + { + "name": "reorder_outlines", + "description": "调整大纲节点的排列顺序。传入按期望顺序排列的节点 ID 数组即可。可以排序顶级节点,也可以排序某个父节点下的子节点。", + "parameters": { + "type": "object", + "properties": { + "ordered_ids": { + "type": "array", + "items": {"type": "integer"}, + "description": "按期望顺序排列的大纲节点 ID 数组", + }, + "parent_id": { + "type": "integer", + "description": "父节点 ID。如果排序的是某个父节点下的子节点,需提供此参数;排序顶级节点时不传", + }, + }, + "required": ["ordered_ids"], + }, + }, { "name": "get_world_setting", "description": "获取当前小说的世界观设定内容", @@ -75,6 +114,96 @@ TOOL_DEFINITIONS: list[dict] = [ "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)""" with Session(engine) as session: handlers = { + "get_novel_info": _get_novel_info, + "update_novel_info": _update_novel_info, "list_outlines": _list_outlines, "create_outline": _create_outline, "update_outline": _update_outline, "delete_outline": _delete_outline, + "reorder_outlines": _reorder_outlines, "get_world_setting": _get_world_setting, "save_world_setting": _save_world_setting, + "list_characters": _list_characters, + "create_character": _create_character, + "update_character": _update_character, + "delete_character": _delete_character, + "list_chapters": _list_chapters, + "create_chapter": _create_chapter, + "update_chapter": _update_chapter, + "delete_chapter": _delete_chapter, } handler = handlers.get(name) if not handler: @@ -117,27 +257,100 @@ def execute_tool(name: str, arguments: dict, novel_id: int) -> str: 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: - items = session.exec( + # 查询顶级节点 + top_items = session.exec( 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) ).all() - result = [ - {"id": o.id, "sort_order": o.sort_order, "summary": o.summary, "detail": o.detail} - for o in items - ] + # 查询所有子节点,按 parent_id 分组 + all_children = session.exec( + select(Outline) + .where(Outline.novel_id == novel_id, Outline.parent_id.isnot(None)) # type: ignore + .order_by(Outline.sort_order) + ).all() + children_map: dict[int, list[Outline]] = {} + for child in all_children: + children_map.setdefault(child.parent_id, []).append(child) + + result = [] + for o in top_items: + node = {"id": o.id, "sort_order": o.sort_order, "summary": o.summary, "detail": o.detail} + kids = children_map.get(o.id, []) + if kids: + node["children"] = [ + {"id": c.id, "sort_order": c.sort_order, "summary": c.summary, "detail": c.detail, "parent_id": c.parent_id} + for c in kids + ] + result.append(node) return json.dumps({"outlines": result, "total": len(result)}, ensure_ascii=False) def _create_outline(session: Session, novel_id: int, args: dict) -> str: - max_order = session.exec( - select(func.max(Outline.sort_order)).where(Outline.novel_id == novel_id) - ).one() + parent_id = args.get("parent_id") + + # 如果指定了 parent_id,校验父节点存在且是顶级节点 + if parent_id is not None: + parent = session.get(Outline, parent_id) + if not parent or parent.novel_id != novel_id: + return json.dumps({"error": "父节点不存在"}, ensure_ascii=False) + if parent.parent_id is not None: + return json.dumps({"error": "仅支持两级结构,不能在子节点下创建子节点"}, ensure_ascii=False) + + # 同级内自增 sort_order + if parent_id is not None: + max_order = session.exec( + select(func.max(Outline.sort_order)) + .where(Outline.novel_id == novel_id, Outline.parent_id == parent_id) + ).one() + else: + max_order = session.exec( + select(func.max(Outline.sort_order)) + .where(Outline.novel_id == novel_id, Outline.parent_id.is_(None)) # type: ignore + ).one() next_order = (max_order or 0) + 1 outline = Outline( novel_id=novel_id, + parent_id=parent_id, sort_order=next_order, summary=args["summary"], detail=args.get("detail", ""), @@ -145,10 +358,10 @@ def _create_outline(session: Session, novel_id: int, args: dict) -> str: session.add(outline) session.commit() session.refresh(outline) - return json.dumps( - {"id": outline.id, "sort_order": outline.sort_order, "summary": outline.summary, "detail": outline.detail}, - ensure_ascii=False, - ) + result = {"id": outline.id, "sort_order": outline.sort_order, "summary": outline.summary, "detail": outline.detail} + if parent_id is not None: + result["parent_id"] = parent_id + return json.dumps(result, ensure_ascii=False) def _update_outline(session: Session, novel_id: int, args: dict) -> str: @@ -175,11 +388,43 @@ def _delete_outline(session: Session, novel_id: int, args: dict) -> str: if not outline or outline.novel_id != novel_id: return json.dumps({"error": "大纲不存在"}, ensure_ascii=False) + # 删除父节点时先删除所有子节点 + children = session.exec( + select(Outline) + .where(Outline.novel_id == novel_id, Outline.parent_id == outline.id) + ).all() + for child in children: + session.delete(child) session.delete(outline) session.commit() return json.dumps({"deleted": True, "id": args["outline_id"]}, ensure_ascii=False) +def _reorder_outlines(session: Session, novel_id: int, args: dict) -> str: + ordered_ids = args["ordered_ids"] + parent_id = args.get("parent_id") + + now = datetime.now(timezone.utc) + for idx, oid in enumerate(ordered_ids): + outline = session.get(Outline, oid) + if not outline or outline.novel_id != novel_id: + return json.dumps({"error": f"大纲节点 {oid} 不存在"}, ensure_ascii=False) + # 校验层级一致性 + if parent_id is None and outline.parent_id is not None: + return json.dumps({"error": f"节点 {oid} 不是顶级节点"}, ensure_ascii=False) + if parent_id is not None and outline.parent_id != parent_id: + return json.dumps({"error": f"节点 {oid} 不属于父节点 {parent_id}"}, ensure_ascii=False) + outline.sort_order = idx + 1 + outline.updated_at = now + session.add(outline) + session.commit() + return json.dumps({ + "reordered": True, + "count": len(ordered_ids), + "parent_id": parent_id, + }, ensure_ascii=False) + + def _get_world_setting(session: Session, novel_id: int, _args: dict) -> str: ws = session.exec( select(WorldSetting).where(WorldSetting.novel_id == novel_id) @@ -203,3 +448,148 @@ def _save_world_setting(session: Session, novel_id: int, args: dict) -> str: session.commit() session.refresh(ws) return json.dumps({"saved": True, "content_length": len(ws.content)}, ensure_ascii=False) + + +# ── 角色处理函数 ── + + +def _list_characters(session: Session, novel_id: int, _args: dict) -> str: + items = session.exec( + select(Character) + .where(Character.novel_id == novel_id) + .order_by(Character.created_at) + ).all() + result = [ + {"id": c.id, "name": c.name, "description": c.description} + for c in items + ] + return json.dumps({"characters": result, "total": len(result)}, ensure_ascii=False) + + +def _create_character(session: Session, novel_id: int, args: dict) -> str: + name = args["name"].strip() + if not name or len(name) > 100: + return json.dumps({"error": "角色名称长度需在1-100字之间"}, ensure_ascii=False) + + char = Character( + novel_id=novel_id, + name=name, + description=args.get("description", ""), + ) + session.add(char) + session.commit() + session.refresh(char) + return json.dumps( + {"id": char.id, "name": char.name, "description": char.description}, + ensure_ascii=False, + ) + + +def _update_character(session: Session, novel_id: int, args: dict) -> str: + char = session.get(Character, args["character_id"]) + if not char or char.novel_id != novel_id: + return json.dumps({"error": "角色不存在"}, ensure_ascii=False) + + if "name" in args: + name = args["name"].strip() + if not name or len(name) > 100: + return json.dumps({"error": "角色名称长度需在1-100字之间"}, ensure_ascii=False) + char.name = name + if "description" in args: + char.description = args["description"] + char.updated_at = datetime.now(timezone.utc) + session.add(char) + session.commit() + session.refresh(char) + return json.dumps( + {"id": char.id, "name": char.name, "description": char.description}, + ensure_ascii=False, + ) + + +def _delete_character(session: Session, novel_id: int, args: dict) -> str: + char = session.get(Character, args["character_id"]) + if not char or char.novel_id != novel_id: + return json.dumps({"error": "角色不存在"}, ensure_ascii=False) + session.delete(char) + session.commit() + return json.dumps({"deleted": True, "id": args["character_id"]}, ensure_ascii=False) + + +# ── 章节处理函数 ── + + +def _list_chapters(session: Session, novel_id: int, _args: dict) -> str: + items = session.exec( + select(Chapter) + .where(Chapter.novel_id == novel_id) + .order_by(Chapter.sort_order) + ).all() + result = [ + { + "id": c.id, + "sort_order": c.sort_order, + "title": c.title, + "content_preview": c.content[:100] + "…" if len(c.content) > 100 else c.content, + } + for c in items + ] + return json.dumps({"chapters": result, "total": len(result)}, ensure_ascii=False) + + +def _create_chapter(session: Session, novel_id: int, args: dict) -> str: + title = args["title"].strip() + if not title or len(title) > 200: + return json.dumps({"error": "章节标题长度需在1-200字之间"}, ensure_ascii=False) + + # 自增 sort_order + max_order = session.exec( + select(func.max(Chapter.sort_order)) + .where(Chapter.novel_id == novel_id) + ).one() + next_order = (max_order or 0) + 1 + + chapter = Chapter( + novel_id=novel_id, + sort_order=next_order, + title=title, + content=args.get("content", ""), + ) + session.add(chapter) + session.commit() + session.refresh(chapter) + return json.dumps( + {"id": chapter.id, "sort_order": chapter.sort_order, "title": chapter.title}, + ensure_ascii=False, + ) + + +def _update_chapter(session: Session, novel_id: int, args: dict) -> str: + chapter = session.get(Chapter, args["chapter_id"]) + if not chapter or chapter.novel_id != novel_id: + return json.dumps({"error": "章节不存在"}, ensure_ascii=False) + + if "title" in args: + title = args["title"].strip() + if not title or len(title) > 200: + return json.dumps({"error": "章节标题长度需在1-200字之间"}, ensure_ascii=False) + chapter.title = title + if "content" in args: + chapter.content = args["content"] + chapter.updated_at = datetime.now(timezone.utc) + session.add(chapter) + session.commit() + session.refresh(chapter) + return json.dumps( + {"id": chapter.id, "title": chapter.title, "content_length": len(chapter.content)}, + ensure_ascii=False, + ) + + +def _delete_chapter(session: Session, novel_id: int, args: dict) -> str: + chapter = session.get(Chapter, args["chapter_id"]) + if not chapter or chapter.novel_id != novel_id: + return json.dumps({"error": "章节不存在"}, ensure_ascii=False) + session.delete(chapter) + session.commit() + return json.dumps({"deleted": True, "id": args["chapter_id"]}, ensure_ascii=False) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..85ffec7 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/frontend/src/api/chat.ts b/frontend/src/api/chat.ts index 0b1b621..6db3741 100644 --- a/frontend/src/api/chat.ts +++ b/frontend/src/api/chat.ts @@ -33,6 +33,22 @@ export async function clearMessages(novelId?: number): Promise { 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 { + const params: Record = {} + if (novelId !== undefined) params.novel_id = novelId + const { data } = await http.post('/chat/compress', null, { params }) + return data +} + // ── 流式聊天 ── export interface TokenUsage { @@ -59,6 +75,7 @@ export interface StreamChatOptions { novelId?: number pageContext?: string toolsEnabled?: boolean + autoApproveTools?: boolean // 工具审批续传 pendingToolCalls?: PendingToolCallData[] assistantText?: string @@ -67,15 +84,16 @@ export interface StreamChatOptions { onError: (error: string) => void onDone: (usage?: TokenUsage, pending?: boolean) => void onToolCallsPending?: (calls: PendingToolCallData[], assistantText: string) => void + onToolCallsAuto?: (calls: PendingToolCallData[]) => void onToolResult?: (info: ToolResultInfo) => void signal?: AbortSignal } export async function streamChat(options: StreamChatOptions): Promise { const { - messages, novelId, pageContext, toolsEnabled, + messages, novelId, pageContext, toolsEnabled, autoApproveTools, pendingToolCalls, assistantText, - onChunk, onError, onDone, onToolCallsPending, onToolResult, + onChunk, onError, onDone, onToolCallsPending, onToolCallsAuto, onToolResult, signal, } = options @@ -84,6 +102,7 @@ export async function streamChat(options: StreamChatOptions): Promise { novel_id: novelId ?? null, page_context: pageContext ?? null, tools_enabled: toolsEnabled ?? true, + auto_approve_tools: autoApproveTools ?? false, pending_tool_calls: pendingToolCalls ?? null, assistant_text: assistantText ?? null, }) @@ -133,6 +152,9 @@ export async function streamChat(options: StreamChatOptions): Promise { if (event.tool_calls_pending && onToolCallsPending) { onToolCallsPending(event.tool_calls_pending, event.assistant_text ?? '') } + if (event.tool_calls_auto && onToolCallsAuto) { + onToolCallsAuto(event.tool_calls_auto) + } if (event.tool_result && onToolResult) { onToolResult(event.tool_result) } diff --git a/frontend/src/api/outlines.ts b/frontend/src/api/outlines.ts index 540181b..9464788 100644 --- a/frontend/src/api/outlines.ts +++ b/frontend/src/api/outlines.ts @@ -31,3 +31,8 @@ export async function reorderOutlines(novelId: number, orderedIds: number[]): Pr const { data } = await http.put(`${base(novelId)}/reorder`, { ordered_ids: orderedIds }) return data } + +export async function reorderChildren(novelId: number, outlineId: number, orderedIds: number[]): Promise { + const { data } = await http.put(`${base(novelId)}/${outlineId}/children/reorder`, { ordered_ids: orderedIds }) + return data +} diff --git a/frontend/src/components/chat/ChatPanel.vue b/frontend/src/components/chat/ChatPanel.vue index d09cc15..f9f05bd 100644 --- a/frontend/src/components/chat/ChatPanel.vue +++ b/frontend/src/components/chat/ChatPanel.vue @@ -1,13 +1,15 @@ + + + + diff --git a/frontend/src/components/outline/OutlineChildList.vue b/frontend/src/components/outline/OutlineChildList.vue new file mode 100644 index 0000000..893b0d9 --- /dev/null +++ b/frontend/src/components/outline/OutlineChildList.vue @@ -0,0 +1,187 @@ + + + + + diff --git a/frontend/src/components/outline/OutlineChildNode.vue b/frontend/src/components/outline/OutlineChildNode.vue new file mode 100644 index 0000000..9cf563d --- /dev/null +++ b/frontend/src/components/outline/OutlineChildNode.vue @@ -0,0 +1,237 @@ + + + + + diff --git a/frontend/src/components/outline/OutlineFormModal.vue b/frontend/src/components/outline/OutlineFormModal.vue index eff4e53..644c021 100644 --- a/frontend/src/components/outline/OutlineFormModal.vue +++ b/frontend/src/components/outline/OutlineFormModal.vue @@ -10,6 +10,7 @@ const props = defineProps<{ show: boolean outline: Outline | null saving: boolean + parentId?: number | null }>() const emit = defineEmits<{ @@ -21,7 +22,11 @@ const summary = ref('') const detail = ref('') 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 detailHint = computed(() => { @@ -42,17 +47,24 @@ watch(() => props.show, (val) => { function handleSubmit() { if (!canSubmit.value) return - emit('submit', { + const data: OutlineCreate | OutlineUpdate = { summary: summary.value.trim(), detail: detail.value.trim(), - }) + } + // 新建子节点时附带 parent_id + if (!isEdit.value && props.parentId) { + (data as OutlineCreate).parent_id = props.parentId + } + emit('submit', data) }