From 20c759150af229d3523624ccc0b882c970f3d0ba Mon Sep 17 00:00:00 2001 From: ROOG Date: Sat, 21 Mar 2026 13:58:17 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=A7=92=E8=89=B2=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E3=80=81=E4=B8=96=E7=95=8C=E8=A7=82=E8=AE=BE=E5=AE=9A?= =?UTF-8?q?=E3=80=81=E7=AB=A0=E8=8A=82=E7=AE=A1=E7=90=86=E5=92=8CAI?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E8=B0=83=E7=94=A8=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 角色管理:CRUD API + 卡片网格UI,每个角色约200字描述 - 世界观设定:单篇长文编辑器,建议500字以上 - 章节管理:CRUD + 排序 API,左右分栏编辑器,单章5000字上限 - AI工具调用:支持工具审批流程和结构化工具调用 - 小说详情页新增章节、角色、世界观入口按钮 Co-Authored-By: Claude Opus 4.6 --- backend/app/main.py | 6 + backend/app/models.py | 33 ++ backend/app/routers/chapters.py | 136 +++++++ backend/app/routers/characters.py | 103 ++++++ backend/app/routers/chat.py | 211 ++++++++--- backend/app/routers/world_setting.py | 56 +++ backend/app/schemas.py | 88 +++++ backend/app/services/ai_provider.py | 211 +++++++++-- backend/app/services/tools.py | 205 +++++++++++ frontend/src/api/chapters.ts | 33 ++ frontend/src/api/characters.ts | 23 ++ frontend/src/api/chat.ts | 49 ++- frontend/src/api/worldSetting.ts | 14 + .../src/components/chapter/ChapterEditor.vue | 193 ++++++++++ .../components/chapter/ChapterEmptyState.vue | 93 +++++ .../components/chapter/ChapterFormModal.vue | 79 ++++ .../src/components/chapter/ChapterItem.vue | 172 +++++++++ .../src/components/chapter/ChapterList.vue | 91 +++++ .../components/character/CharacterCard.vue | 106 ++++++ .../character/CharacterEmptyState.vue | 83 +++++ .../character/CharacterFormModal.vue | 123 +++++++ .../components/character/CharacterGrid.vue | 33 ++ frontend/src/components/chat/ChatPanel.vue | 94 +++-- .../src/components/chat/ChatToolCalls.vue | 337 +++++++++++++++++ frontend/src/composables/useChatAgent.ts | 171 +++++++-- frontend/src/router/index.ts | 15 + frontend/src/types/chapter.ts | 29 ++ frontend/src/types/character.ts | 23 ++ frontend/src/types/chat.ts | 25 ++ frontend/src/types/worldSetting.ts | 11 + frontend/src/views/ChapterView.vue | 340 ++++++++++++++++++ frontend/src/views/CharacterView.vue | 215 +++++++++++ frontend/src/views/NovelDetailView.vue | 9 + frontend/src/views/WorldSettingView.vue | 212 +++++++++++ 34 files changed, 3495 insertions(+), 127 deletions(-) create mode 100644 backend/app/routers/chapters.py create mode 100644 backend/app/routers/characters.py create mode 100644 backend/app/routers/world_setting.py create mode 100644 backend/app/services/tools.py create mode 100644 frontend/src/api/chapters.ts create mode 100644 frontend/src/api/characters.ts create mode 100644 frontend/src/api/worldSetting.ts create mode 100644 frontend/src/components/chapter/ChapterEditor.vue create mode 100644 frontend/src/components/chapter/ChapterEmptyState.vue create mode 100644 frontend/src/components/chapter/ChapterFormModal.vue create mode 100644 frontend/src/components/chapter/ChapterItem.vue create mode 100644 frontend/src/components/chapter/ChapterList.vue create mode 100644 frontend/src/components/character/CharacterCard.vue create mode 100644 frontend/src/components/character/CharacterEmptyState.vue create mode 100644 frontend/src/components/character/CharacterFormModal.vue create mode 100644 frontend/src/components/character/CharacterGrid.vue create mode 100644 frontend/src/components/chat/ChatToolCalls.vue create mode 100644 frontend/src/types/chapter.ts create mode 100644 frontend/src/types/character.ts create mode 100644 frontend/src/types/worldSetting.ts create mode 100644 frontend/src/views/ChapterView.vue create mode 100644 frontend/src/views/CharacterView.vue create mode 100644 frontend/src/views/WorldSettingView.vue diff --git a/backend/app/main.py b/backend/app/main.py index 82f038e..04eb3e8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,6 +6,9 @@ from fastapi.middleware.cors import CORSMiddleware from .database import init_db from .routers.novels import router as novels_router from .routers.outlines import router as outlines_router +from .routers.characters import router as characters_router +from .routers.chapters import router as chapters_router +from .routers.world_setting import router as world_setting_router from .routers.ai_config import router as ai_config_router from .routers.chat import router as chat_router @@ -28,6 +31,9 @@ app.add_middleware( app.include_router(novels_router) app.include_router(outlines_router) +app.include_router(characters_router) +app.include_router(chapters_router) +app.include_router(world_setting_router) app.include_router(ai_config_router) app.include_router(chat_router) diff --git a/backend/app/models.py b/backend/app/models.py index 3ac2690..1dfa1ed 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -48,3 +48,36 @@ class Outline(SQLModel, table=True): detail: str = Field(default="", max_length=1000) created_at: datetime = Field(default_factory=_now_utc) updated_at: datetime = Field(default_factory=_now_utc) + + +class Character(SQLModel, table=True): + """角色表,属于小说的子资源""" + + id: Optional[int] = Field(default=None, primary_key=True) + novel_id: int = Field(foreign_key="novel.id", index=True) + name: str = Field(max_length=100) + description: str = Field(default="", max_length=1000) + created_at: datetime = Field(default_factory=_now_utc) + updated_at: datetime = Field(default_factory=_now_utc) + + +class Chapter(SQLModel, table=True): + """章节表,属于小说的子资源""" + + id: Optional[int] = Field(default=None, primary_key=True) + novel_id: int = Field(foreign_key="novel.id", index=True) + sort_order: int = Field(default=0) + title: str = Field(max_length=200) + content: str = Field(default="", max_length=5000) + created_at: datetime = Field(default_factory=_now_utc) + updated_at: datetime = Field(default_factory=_now_utc) + + +class WorldSetting(SQLModel, table=True): + """世界观设定,每部小说一条记录""" + + id: Optional[int] = Field(default=None, primary_key=True) + novel_id: int = Field(foreign_key="novel.id", index=True, unique=True) + content: str = Field(default="", max_length=5000) + created_at: datetime = Field(default_factory=_now_utc) + updated_at: datetime = Field(default_factory=_now_utc) diff --git a/backend/app/routers/chapters.py b/backend/app/routers/chapters.py new file mode 100644 index 0000000..6da3ea7 --- /dev/null +++ b/backend/app/routers/chapters.py @@ -0,0 +1,136 @@ +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session, select, func + +from ..database import get_session +from ..models import Novel, Chapter +from ..schemas import ( + ChapterCreate, + ChapterUpdate, + ChapterRead, + ChapterList, + ChapterReorder, +) + +router = APIRouter(prefix="/api/novels/{novel_id}/chapters", tags=["chapters"]) + + +def _get_novel_or_404(novel_id: int, session: Session) -> Novel: + novel = session.get(Novel, novel_id) + if not novel: + raise HTTPException(status_code=404, detail="小说不存在") + return novel + + +def _get_chapter_or_404( + chapter_id: int, novel_id: int, session: Session +) -> Chapter: + chapter = session.get(Chapter, chapter_id) + if not chapter or chapter.novel_id != novel_id: + raise HTTPException(status_code=404, detail="章节不存在") + return chapter + + +@router.get("", response_model=ChapterList) +def list_chapters( + novel_id: int, + session: Session = Depends(get_session), +): + _get_novel_or_404(novel_id, session) + total = session.exec( + select(func.count(Chapter.id)).where(Chapter.novel_id == novel_id) + ).one() + items = session.exec( + select(Chapter) + .where(Chapter.novel_id == novel_id) + .order_by(Chapter.sort_order) + ).all() + return ChapterList( + items=[ChapterRead.model_validate(c, from_attributes=True) for c in items], + total=total, + ) + + +@router.post("", response_model=ChapterRead, status_code=201) +def create_chapter( + novel_id: int, + data: ChapterCreate, + session: Session = Depends(get_session), +): + _get_novel_or_404(novel_id, session) + max_order = session.exec( + select(func.max(Chapter.sort_order)).where(Chapter.novel_id == novel_id) + ).one() + next_order = (max_order or 0) + 1 + + chapter = Chapter(novel_id=novel_id, sort_order=next_order, **data.model_dump()) + session.add(chapter) + session.commit() + session.refresh(chapter) + return chapter + + +@router.put("/reorder", response_model=ChapterList) +def reorder_chapters( + novel_id: int, + data: ChapterReorder, + session: Session = Depends(get_session), +): + _get_novel_or_404(novel_id, session) + now = datetime.now(timezone.utc) + for idx, cid in enumerate(data.ordered_ids): + chapter = _get_chapter_or_404(cid, novel_id, session) + chapter.sort_order = idx + 1 + chapter.updated_at = now + session.add(chapter) + session.commit() + + items = session.exec( + select(Chapter) + .where(Chapter.novel_id == novel_id) + .order_by(Chapter.sort_order) + ).all() + total = len(items) + return ChapterList( + items=[ChapterRead.model_validate(c, from_attributes=True) for c in items], + total=total, + ) + + +@router.get("/{chapter_id}", response_model=ChapterRead) +def get_chapter( + novel_id: int, + chapter_id: int, + session: Session = Depends(get_session), +): + return _get_chapter_or_404(chapter_id, novel_id, session) + + +@router.patch("/{chapter_id}", response_model=ChapterRead) +def update_chapter( + novel_id: int, + chapter_id: int, + data: ChapterUpdate, + session: Session = Depends(get_session), +): + chapter = _get_chapter_or_404(chapter_id, novel_id, session) + update_data = data.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(chapter, key, value) + chapter.updated_at = datetime.now(timezone.utc) + session.add(chapter) + session.commit() + session.refresh(chapter) + return chapter + + +@router.delete("/{chapter_id}", status_code=204) +def delete_chapter( + novel_id: int, + chapter_id: int, + session: Session = Depends(get_session), +): + chapter = _get_chapter_or_404(chapter_id, novel_id, session) + session.delete(chapter) + session.commit() diff --git a/backend/app/routers/characters.py b/backend/app/routers/characters.py new file mode 100644 index 0000000..e780978 --- /dev/null +++ b/backend/app/routers/characters.py @@ -0,0 +1,103 @@ +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session, select, func + +from ..database import get_session +from ..models import Novel, Character +from ..schemas import ( + CharacterCreate, + CharacterUpdate, + CharacterRead, + CharacterList, +) + +router = APIRouter(prefix="/api/novels/{novel_id}/characters", tags=["characters"]) + + +def _get_novel_or_404(novel_id: int, session: Session) -> Novel: + novel = session.get(Novel, novel_id) + if not novel: + raise HTTPException(status_code=404, detail="小说不存在") + return novel + + +def _get_character_or_404( + character_id: int, novel_id: int, session: Session +) -> Character: + character = session.get(Character, character_id) + if not character or character.novel_id != novel_id: + raise HTTPException(status_code=404, detail="角色不存在") + return character + + +@router.get("", response_model=CharacterList) +def list_characters( + novel_id: int, + session: Session = Depends(get_session), +): + _get_novel_or_404(novel_id, session) + total = session.exec( + select(func.count(Character.id)).where(Character.novel_id == novel_id) + ).one() + items = session.exec( + select(Character) + .where(Character.novel_id == novel_id) + .order_by(Character.created_at) + ).all() + return CharacterList( + items=[CharacterRead.model_validate(c, from_attributes=True) for c in items], + total=total, + ) + + +@router.post("", response_model=CharacterRead, status_code=201) +def create_character( + novel_id: int, + data: CharacterCreate, + session: Session = Depends(get_session), +): + _get_novel_or_404(novel_id, session) + character = Character(novel_id=novel_id, **data.model_dump()) + session.add(character) + session.commit() + session.refresh(character) + return character + + +@router.get("/{character_id}", response_model=CharacterRead) +def get_character( + novel_id: int, + character_id: int, + session: Session = Depends(get_session), +): + return _get_character_or_404(character_id, novel_id, session) + + +@router.patch("/{character_id}", response_model=CharacterRead) +def update_character( + novel_id: int, + character_id: int, + data: CharacterUpdate, + session: Session = Depends(get_session), +): + character = _get_character_or_404(character_id, novel_id, session) + update_data = data.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(character, key, value) + character.updated_at = datetime.now(timezone.utc) + session.add(character) + session.commit() + session.refresh(character) + return character + + +@router.delete("/{character_id}", status_code=204) +def delete_character( + novel_id: int, + character_id: int, + session: Session = Depends(get_session), +): + character = _get_character_or_404(character_id, novel_id, session) + session.delete(character) + session.commit() diff --git a/backend/app/routers/chat.py b/backend/app/routers/chat.py index a853294..2ffbe81 100644 --- a/backend/app/routers/chat.py +++ b/backend/app/routers/chat.py @@ -6,19 +6,78 @@ from fastapi.responses import StreamingResponse from sqlmodel import Session, select, func from ..database import get_session, engine -from ..models import AIConfig, ChatMessage +from ..models import AIConfig, ChatMessage, Novel from ..schemas import ChatRequest, ChatMessageRead, ChatMessageList -from ..services.ai_provider import stream_chat +from ..services.ai_provider import ( + stream_chat, build_assistant_message, build_tool_results_messages, ToolCall, +) +from ..services.tools import get_openai_tools, get_anthropic_tools, execute_tool router = APIRouter(prefix="/api/chat", tags=["chat"]) +# 最大工具调用轮次 +MAX_TOOL_ROUNDS = 8 + +# 工具名称到中文描述 +TOOL_LABELS: dict[str, str] = { + "list_outlines": "查询大纲", + "create_outline": "创建大纲", + "update_outline": "更新大纲", + "delete_outline": "删除大纲", + "get_world_setting": "查询世界观", + "save_world_setting": "保存世界观", +} + + +def _build_system_prompt( + novel_id: int | None, + session: Session, + page_context: str | None = None, + 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 '暂无'}", + ] + + if page_context: + parts.append(f"\n用户当前正在查看:{page_context}。") + + if tools_enabled: + parts.append( + "\n你可以使用工具来查看和管理大纲、世界观设定。" + "\n当用户要求查看、添加、修改或删除大纲或世界观时,请使用相应的工具。" + "\n执行完工具操作后,请向用户简要说明操作结果。" + ) + + return "\n".join(parts) + + +def _get_tools(provider: str) -> list[dict]: + if provider == "anthropic": + return get_anthropic_tools() + return get_openai_tools() + + +def _sse(data: dict) -> str: + return f"data: {json.dumps(data, ensure_ascii=False)}\n\n" + @router.post("/stream") async def chat_stream( data: ChatRequest, session: Session = Depends(get_session), ): - """SSE 流式对话端点""" + """SSE 流式对话端点,支持 tool calling + 用户审批""" config = session.exec(select(AIConfig)).first() if not config or not config.api_key: return StreamingResponse( @@ -26,53 +85,120 @@ async def chat_stream( media_type="text/event-stream", ) - # 保存用户消息 - user_msg = data.messages[-1] if data.messages else None - if user_msg and user_msg.role == "user": - db_msg = ChatMessage( - novel_id=data.novel_id, - role="user", - content=user_msg.content, - ) - session.add(db_msg) - session.commit() + # 保存用户消息(仅首次请求,续传审批时不保存) + if not data.pending_tool_calls: + user_msg = data.messages[-1] if data.messages else None + if user_msg and user_msg.role == "user": + db_msg = ChatMessage( + novel_id=data.novel_id, + role="user", + content=user_msg.content, + ) + session.add(db_msg) + session.commit() # 构建消息列表 messages = [{"role": m.role, "content": m.content} for m in data.messages] - # 构建上下文摘要(前端展示用) - context_preview = [{"role": m["role"], "content": m["content"][:200]} for m in messages] + # 构建上下文 + use_tools = bool(data.novel_id and data.tools_enabled) + system_prompt = _build_system_prompt( + data.novel_id, session, data.page_context, use_tools + ) + tools = _get_tools(config.provider) if use_tools else None async def generate(): - # 先发送上下文预览 - yield f"data: {json.dumps({'context': context_preview})}\n\n" + nonlocal messages + + full_response = data.assistant_text or "" + usage_info: dict = {} - full_response = "" - usage_info = {} try: - async for event in stream_chat(config, messages): - if event.text: - full_response += event.text - yield f"data: {json.dumps({'content': event.text})}\n\n" - if event.usage: - # 累加usage - for k, v in event.usage.items(): - if v: - usage_info[k] = usage_info.get(k, 0) + v + # ── 阶段一:如果有待审批的工具调用,先执行它们 ── + if data.pending_tool_calls: + tool_calls = [ + ToolCall(id=tc.id, name=tc.name, arguments=tc.arguments) + for tc in data.pending_tool_calls + ] + + # 构建 assistant 消息(含工具调用) + assistant_msg = build_assistant_message( + config.provider, data.assistant_text or "", tool_calls + ) + messages.append(assistant_msg) + + # 执行每个工具,发送结果 + tool_results = [] + for tc in tool_calls: + label = TOOL_LABELS.get(tc.name, tc.name) + result = execute_tool(tc.name, tc.arguments, data.novel_id) + tool_results.append({"id": tc.id, "result": result}) + yield _sse({"tool_result": { + "name": tc.name, + "label": label, + "result": result, + }}) + + # 追加工具结果消息 + result_msgs = build_tool_results_messages(config.provider, tool_results) + messages.extend(result_msgs) + + # ── 阶段二:LLM 循环 ── + for _round in range(MAX_TOOL_ROUNDS): + round_text = "" + round_tool_calls = [] + + async for event in stream_chat(config, messages, system_prompt, tools): + if event.text: + round_text += event.text + yield _sse({"content": event.text}) + if event.usage: + for k, v in event.usage.items(): + if v: + usage_info[k] = usage_info.get(k, 0) + v + if event.tool_calls: + round_tool_calls = event.tool_calls + + # 没有工具调用 → 完成 + if not round_tool_calls: + full_response += round_text + break + + # 有工具调用 → 暂停,发给前端审批 + full_response += round_text + calls_data = [ + { + "id": tc.id, + "name": tc.name, + "label": TOOL_LABELS.get(tc.name, tc.name), + "arguments": tc.arguments, + } + for tc in round_tool_calls + ] + 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 f"data: {json.dumps({'error': str(e)})}\n\n" + yield _sse({"error": str(e)}) finally: - # 保存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 f"data: {json.dumps({'done': True, 'usage': usage_info or None})}\n\n" + pass + + # 正常完成:保存 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( generate(), @@ -85,8 +211,8 @@ async def chat_stream( async def _error_stream(message: str): - yield f"data: {json.dumps({'error': message})}\n\n" - yield f"data: {json.dumps({'done': True})}\n\n" + yield _sse({"error": message}) + yield _sse({"done": True}) @router.get("/messages", response_model=ChatMessageList) @@ -108,7 +234,6 @@ def list_messages( items = session.exec( query.order_by(ChatMessage.created_at.desc()).limit(limit) ).all() - # 返回时按时间正序 items = list(reversed(items)) return ChatMessageList( items=[ChatMessageRead.model_validate(m, from_attributes=True) for m in items], diff --git a/backend/app/routers/world_setting.py b/backend/app/routers/world_setting.py new file mode 100644 index 0000000..bc51418 --- /dev/null +++ b/backend/app/routers/world_setting.py @@ -0,0 +1,56 @@ +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session, select + +from ..database import get_session +from ..models import Novel, WorldSetting +from ..schemas import WorldSettingUpdate, WorldSettingRead + +router = APIRouter(prefix="/api/novels/{novel_id}/world-setting", tags=["world-setting"]) + + +def _get_novel_or_404(novel_id: int, session: Session) -> Novel: + novel = session.get(Novel, novel_id) + if not novel: + raise HTTPException(status_code=404, detail="小说不存在") + return novel + + +@router.get("", response_model=WorldSettingRead) +def get_world_setting( + novel_id: int, + session: Session = Depends(get_session), +): + _get_novel_or_404(novel_id, session) + ws = session.exec( + select(WorldSetting).where(WorldSetting.novel_id == novel_id) + ).first() + if not ws: + # 不存在则自动创建空记录 + ws = WorldSetting(novel_id=novel_id, content="") + session.add(ws) + session.commit() + session.refresh(ws) + return ws + + +@router.put("", response_model=WorldSettingRead) +def save_world_setting( + novel_id: int, + data: WorldSettingUpdate, + session: Session = Depends(get_session), +): + _get_novel_or_404(novel_id, session) + ws = session.exec( + select(WorldSetting).where(WorldSetting.novel_id == novel_id) + ).first() + if ws: + ws.content = data.content + ws.updated_at = datetime.now(timezone.utc) + else: + ws = WorldSetting(novel_id=novel_id, content=data.content) + session.add(ws) + session.commit() + session.refresh(ws) + return ws diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 905ae57..52e15c0 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -60,6 +60,82 @@ class OutlineReorder(BaseModel): ordered_ids: list[int] +# ── 角色 ── + + +class CharacterCreate(BaseModel): + name: str = Field(min_length=1, max_length=100) + description: str = Field(default="", max_length=1000) + + +class CharacterUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=100) + description: str | None = Field(default=None, max_length=1000) + + +class CharacterRead(BaseModel): + id: int + novel_id: int + name: str + description: str + created_at: datetime + updated_at: datetime + + +class CharacterList(BaseModel): + items: list[CharacterRead] + total: int + + +# ── 章节 ── + + +class ChapterCreate(BaseModel): + title: str = Field(min_length=1, max_length=200) + content: str = Field(default="", max_length=5000) + + +class ChapterUpdate(BaseModel): + title: str | None = Field(default=None, min_length=1, max_length=200) + content: str | None = Field(default=None, max_length=5000) + sort_order: int | None = None + + +class ChapterRead(BaseModel): + id: int + novel_id: int + sort_order: int + title: str + content: str + created_at: datetime + updated_at: datetime + + +class ChapterList(BaseModel): + items: list[ChapterRead] + total: int + + +class ChapterReorder(BaseModel): + """排序请求:按顺序传入章节id列表""" + ordered_ids: list[int] + + +# ── 世界观 ── + + +class WorldSettingUpdate(BaseModel): + content: str = Field(default="", max_length=5000) + + +class WorldSettingRead(BaseModel): + id: int + novel_id: int + content: str + created_at: datetime + updated_at: datetime + + # ── AI配置 ── @@ -87,9 +163,21 @@ class ChatMessageInput(BaseModel): content: str +class PendingToolCall(BaseModel): + """待审批的工具调用""" + id: str + name: str + arguments: dict + + class ChatRequest(BaseModel): messages: list[ChatMessageInput] novel_id: int | None = None + page_context: str | None = None # 用户当前所在页面,如 "大纲页" "世界观页" + tools_enabled: bool = True # 是否启用工具调用 + # 工具审批续传 + pending_tool_calls: list[PendingToolCall] | None = None + assistant_text: str | None = None # LLM 在工具调用前输出的文本 class ChatMessageRead(BaseModel): diff --git a/backend/app/services/ai_provider.py b/backend/app/services/ai_provider.py index 22f3930..f8b9a2c 100644 --- a/backend/app/services/ai_provider.py +++ b/backend/app/services/ai_provider.py @@ -1,6 +1,8 @@ -"""AI Provider 抽象层,支持 OpenAI 兼容 API 和 Anthropic 原生 API。""" +"""AI Provider 抽象层,支持 OpenAI 兼容 API 和 Anthropic 原生 API。 +支持文本流式输出和 tool calling。""" import json +from dataclasses import dataclass, field from typing import AsyncGenerator import httpx @@ -8,33 +10,109 @@ import httpx from ..models import AIConfig +@dataclass +class ToolCall: + """一次工具调用""" + id: str + name: str + arguments: dict + + class StreamEvent: - """流式事件:文本chunk或usage信息""" - def __init__(self, text: str = "", usage: dict | None = None): + """流式事件:文本chunk、usage信息、或工具调用""" + def __init__( + self, + text: str = "", + usage: dict | None = None, + tool_calls: list[ToolCall] | None = None, + ): self.text = text self.usage = usage + self.tool_calls = tool_calls async def stream_chat( config: AIConfig, messages: list[dict], system_prompt: str = "", + tools: list[dict] | None = None, ) -> AsyncGenerator[StreamEvent, None]: """根据 provider 类型调用对应的流式 API,返回 StreamEvent。""" if config.provider == "anthropic": - async for event in _stream_anthropic(config, messages, system_prompt): + async for event in _stream_anthropic(config, messages, system_prompt, tools): yield event else: - async for event in _stream_openai(config, messages, system_prompt): + async for event in _stream_openai(config, messages, system_prompt, tools): yield event +# ── 消息格式构建(用于 tool call 循环中追加消息)── + + +def build_assistant_message(provider: str, text: str, tool_calls: list[ToolCall]) -> dict: + """构建包含工具调用的 assistant 消息""" + if provider == "anthropic": + content = [] + if text: + content.append({"type": "text", "text": text}) + for tc in tool_calls: + content.append({ + "type": "tool_use", + "id": tc.id, + "name": tc.name, + "input": tc.arguments, + }) + return {"role": "assistant", "content": content} + else: + # OpenAI 格式 + msg: dict = { + "role": "assistant", + "content": text or None, + "tool_calls": [ + { + "id": tc.id, + "type": "function", + "function": { + "name": tc.name, + "arguments": json.dumps(tc.arguments, ensure_ascii=False), + }, + } + for tc in tool_calls + ], + } + return msg + + +def build_tool_results_messages( + provider: str, results: list[dict], +) -> list[dict]: + """构建工具结果消息。results: [{"id": str, "result": str}] + OpenAI: 返回多条 tool message + Anthropic: 返回一条 user message,content 是 tool_result 数组 + """ + if provider == "anthropic": + content = [ + {"type": "tool_result", "tool_use_id": r["id"], "content": r["result"]} + for r in results + ] + return [{"role": "user", "content": content}] + else: + return [ + {"role": "tool", "tool_call_id": r["id"], "content": r["result"]} + for r in results + ] + + +# ── OpenAI 兼容 ── + + async def _stream_openai( config: AIConfig, messages: list[dict], system_prompt: str, + tools: list[dict] | None = None, ) -> AsyncGenerator[StreamEvent, None]: - """调用 OpenAI 兼容 API(stream=true)。""" + """调用 OpenAI 兼容 API(stream=true),支持 tool calling。""" url = f"{config.base_url.rstrip('/')}/chat/completions" payload_messages = [] @@ -42,12 +120,14 @@ async def _stream_openai( payload_messages.append({"role": "system", "content": system_prompt}) payload_messages.extend(messages) - body = { + body: dict = { "model": config.model, "messages": payload_messages, "stream": True, "stream_options": {"include_usage": True}, } + if tools: + body["tools"] = tools async with httpx.AsyncClient(timeout=120) as client: async with client.stream( @@ -72,6 +152,9 @@ async def _stream_openai( if "text/html" in ct: raise RuntimeError(f"API地址返回了HTML页面,请检查API地址是否正确(当前: {url})") + # 累积工具调用 + pending_tool_calls: dict[int, dict] = {} # index -> {id, name, arguments} + async for line in resp.aiter_lines(): if not line.startswith("data: "): continue @@ -80,7 +163,8 @@ async def _stream_openai( break try: chunk = json.loads(data) - # 提取usage(OpenAI在最后一个chunk返回) + + # 提取 usage usage = chunk.get("usage") if usage: yield StreamEvent(usage={ @@ -88,22 +172,63 @@ async def _stream_openai( "completion_tokens": usage.get("completion_tokens", 0), "total_tokens": usage.get("total_tokens", 0), }) + choices = chunk.get("choices", []) - if choices: - delta = choices[0].get("delta", {}) - content = delta.get("content") - if content: - yield StreamEvent(text=content) + if not choices: + continue + + choice = choices[0] + delta = choice.get("delta", {}) + finish_reason = choice.get("finish_reason") + + # 文本内容 + content = delta.get("content") + if content: + yield StreamEvent(text=content) + + # 工具调用 chunk + for tc_delta in delta.get("tool_calls", []): + idx = tc_delta["index"] + if idx not in pending_tool_calls: + pending_tool_calls[idx] = {"id": "", "name": "", "arguments": ""} + entry = pending_tool_calls[idx] + if "id" in tc_delta: + entry["id"] = tc_delta["id"] + func = tc_delta.get("function", {}) + if "name" in func: + entry["name"] = func["name"] + if "arguments" in func: + entry["arguments"] += func["arguments"] + + # 结束:如果有工具调用,发出事件 + if finish_reason == "tool_calls" and pending_tool_calls: + tool_calls = [] + for tc_data in pending_tool_calls.values(): + try: + args = json.loads(tc_data["arguments"]) if tc_data["arguments"] else {} + except json.JSONDecodeError: + args = {} + tool_calls.append(ToolCall( + id=tc_data["id"], + name=tc_data["name"], + arguments=args, + )) + yield StreamEvent(tool_calls=tool_calls) + except (json.JSONDecodeError, KeyError, IndexError): continue +# ── Anthropic 原生 ── + + async def _stream_anthropic( config: AIConfig, messages: list[dict], system_prompt: str, + tools: list[dict] | None = None, ) -> AsyncGenerator[StreamEvent, None]: - """调用 Anthropic Messages API(stream=true)。""" + """调用 Anthropic Messages API(stream=true),支持 tool calling。""" url = f"{config.base_url.rstrip('/')}/messages" body: dict = { @@ -114,6 +239,8 @@ async def _stream_anthropic( } if system_prompt: body["system"] = system_prompt + if tools: + body["tools"] = tools async with httpx.AsyncClient(timeout=120) as client: async with client.stream( @@ -139,16 +266,39 @@ async def _stream_anthropic( if "text/html" in ct: raise RuntimeError(f"API地址返回了HTML页面,请检查API地址是否正确(当前: {url})") + # 累积工具调用 + pending_tool_uses: dict[int, dict] = {} # block_index -> {id, name, input_json} + stop_reason = None + async for line in resp.aiter_lines(): if not line.startswith("data: "): continue try: event = json.loads(line[6:]) - if event.get("type") == "content_block_delta": - text = event.get("delta", {}).get("text", "") - if text: - yield StreamEvent(text=text) - elif event.get("type") == "message_delta": + event_type = event.get("type") + + if event_type == "content_block_start": + block = event.get("content_block", {}) + idx = event.get("index", 0) + if block.get("type") == "tool_use": + pending_tool_uses[idx] = { + "id": block["id"], + "name": block["name"], + "input_json": "", + } + + elif event_type == "content_block_delta": + idx = event.get("index", 0) + delta = event.get("delta", {}) + if delta.get("type") == "text_delta": + text = delta.get("text", "") + if text: + yield StreamEvent(text=text) + elif delta.get("type") == "input_json_delta": + if idx in pending_tool_uses: + pending_tool_uses[idx]["input_json"] += delta.get("partial_json", "") + + elif event_type == "message_delta": usage = event.get("usage", {}) if usage: yield StreamEvent(usage={ @@ -156,7 +306,9 @@ async def _stream_anthropic( "completion_tokens": usage.get("output_tokens", 0), "total_tokens": 0, }) - elif event.get("type") == "message_start": + stop_reason = event.get("delta", {}).get("stop_reason") + + elif event_type == "message_start": msg = event.get("message", {}) usage = msg.get("usage", {}) if usage: @@ -165,7 +317,24 @@ async def _stream_anthropic( "completion_tokens": 0, "total_tokens": 0, }) - elif event.get("type") == "message_stop": + + elif event_type == "message_stop": break + except json.JSONDecodeError: continue + + # 如果有工具调用,发出事件 + if stop_reason == "tool_use" and pending_tool_uses: + tool_calls = [] + for tu_data in pending_tool_uses.values(): + try: + args = json.loads(tu_data["input_json"]) if tu_data["input_json"] else {} + except json.JSONDecodeError: + args = {} + tool_calls.append(ToolCall( + id=tu_data["id"], + name=tu_data["name"], + arguments=args, + )) + yield StreamEvent(tool_calls=tool_calls) diff --git a/backend/app/services/tools.py b/backend/app/services/tools.py new file mode 100644 index 0000000..6da5ab1 --- /dev/null +++ b/backend/app/services/tools.py @@ -0,0 +1,205 @@ +"""工具注册表和执行器 —— 大纲 & 世界观 CRUD""" + +import json +from datetime import datetime, timezone + +from sqlmodel import Session, select, func + +from ..database import engine +from ..models import Novel, Outline, WorldSetting + + +# ── 工具定义(provider 无关格式)── + +TOOL_DEFINITIONS: list[dict] = [ + { + "name": "list_outlines", + "description": "列出当前小说的所有大纲节点,返回每个节点的 id、排序、摘要和详情", + "parameters": { + "type": "object", + "properties": {}, + }, + }, + { + "name": "create_outline", + "description": "为当前小说创建一个新的大纲节点", + "parameters": { + "type": "object", + "properties": { + "summary": {"type": "string", "description": "大纲摘要(简短标题,200字以内)"}, + "detail": {"type": "string", "description": "大纲详情(可选,1000字以内)"}, + }, + "required": ["summary"], + }, + }, + { + "name": "update_outline", + "description": "更新指定大纲节点的摘要或详情", + "parameters": { + "type": "object", + "properties": { + "outline_id": {"type": "integer", "description": "要更新的大纲节点 ID"}, + "summary": {"type": "string", "description": "新的摘要(可选)"}, + "detail": {"type": "string", "description": "新的详情(可选)"}, + }, + "required": ["outline_id"], + }, + }, + { + "name": "delete_outline", + "description": "删除指定的大纲节点", + "parameters": { + "type": "object", + "properties": { + "outline_id": {"type": "integer", "description": "要删除的大纲节点 ID"}, + }, + "required": ["outline_id"], + }, + }, + { + "name": "get_world_setting", + "description": "获取当前小说的世界观设定内容", + "parameters": { + "type": "object", + "properties": {}, + }, + }, + { + "name": "save_world_setting", + "description": "保存/更新当前小说的世界观设定", + "parameters": { + "type": "object", + "properties": { + "content": {"type": "string", "description": "世界观设定内容(5000字以内)"}, + }, + "required": ["content"], + }, + }, +] + + +def get_openai_tools() -> list[dict]: + """转换为 OpenAI function calling 格式""" + return [ + {"type": "function", "function": t} + for t in TOOL_DEFINITIONS + ] + + +def get_anthropic_tools() -> list[dict]: + """转换为 Anthropic tool_use 格式""" + return [ + {"name": t["name"], "description": t["description"], "input_schema": t["parameters"]} + for t in TOOL_DEFINITIONS + ] + + +# ── 工具执行 ── + + +def execute_tool(name: str, arguments: dict, novel_id: int) -> str: + """执行工具,返回结果字符串(JSON)""" + with Session(engine) as session: + handlers = { + "list_outlines": _list_outlines, + "create_outline": _create_outline, + "update_outline": _update_outline, + "delete_outline": _delete_outline, + "get_world_setting": _get_world_setting, + "save_world_setting": _save_world_setting, + } + handler = handlers.get(name) + if not handler: + return json.dumps({"error": f"未知工具: {name}"}, ensure_ascii=False) + try: + return handler(session, novel_id, arguments) + except Exception as e: + return json.dumps({"error": str(e)}, ensure_ascii=False) + + +def _list_outlines(session: Session, novel_id: int, _args: dict) -> str: + items = session.exec( + select(Outline) + .where(Outline.novel_id == novel_id) + .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 + ] + 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() + next_order = (max_order or 0) + 1 + + outline = Outline( + novel_id=novel_id, + sort_order=next_order, + summary=args["summary"], + detail=args.get("detail", ""), + ) + 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, + ) + + +def _update_outline(session: Session, novel_id: int, args: dict) -> str: + outline = session.get(Outline, args["outline_id"]) + if not outline or outline.novel_id != novel_id: + return json.dumps({"error": "大纲不存在"}, ensure_ascii=False) + + if "summary" in args: + outline.summary = args["summary"] + if "detail" in args: + outline.detail = args["detail"] + outline.updated_at = datetime.now(timezone.utc) + session.add(outline) + session.commit() + session.refresh(outline) + return json.dumps( + {"id": outline.id, "summary": outline.summary, "detail": outline.detail}, + ensure_ascii=False, + ) + + +def _delete_outline(session: Session, novel_id: int, args: dict) -> str: + outline = session.get(Outline, args["outline_id"]) + if not outline or outline.novel_id != novel_id: + return json.dumps({"error": "大纲不存在"}, ensure_ascii=False) + + session.delete(outline) + session.commit() + return json.dumps({"deleted": True, "id": args["outline_id"]}, ensure_ascii=False) + + +def _get_world_setting(session: Session, novel_id: int, _args: dict) -> str: + ws = session.exec( + select(WorldSetting).where(WorldSetting.novel_id == novel_id) + ).first() + if not ws: + return json.dumps({"content": "", "exists": False}, ensure_ascii=False) + return json.dumps({"content": ws.content, "exists": True}, ensure_ascii=False) + + +def _save_world_setting(session: Session, novel_id: int, args: dict) -> str: + ws = session.exec( + select(WorldSetting).where(WorldSetting.novel_id == novel_id) + ).first() + now = datetime.now(timezone.utc) + if ws: + ws.content = args["content"] + ws.updated_at = now + else: + ws = WorldSetting(novel_id=novel_id, content=args["content"]) + session.add(ws) + session.commit() + session.refresh(ws) + return json.dumps({"saved": True, "content_length": len(ws.content)}, ensure_ascii=False) diff --git a/frontend/src/api/chapters.ts b/frontend/src/api/chapters.ts new file mode 100644 index 0000000..9e29e21 --- /dev/null +++ b/frontend/src/api/chapters.ts @@ -0,0 +1,33 @@ +import { http } from './client' +import type { Chapter, ChapterCreate, ChapterUpdate, ChapterList } from '@/types/chapter' + +const base = (novelId: number) => `/novels/${novelId}/chapters` + +export async function fetchChapters(novelId: number): Promise { + const { data } = await http.get(base(novelId)) + return data +} + +export async function fetchChapter(novelId: number, chapterId: number): Promise { + const { data } = await http.get(`${base(novelId)}/${chapterId}`) + return data +} + +export async function createChapter(novelId: number, payload: ChapterCreate): Promise { + const { data } = await http.post(base(novelId), payload) + return data +} + +export async function updateChapter(novelId: number, chapterId: number, payload: ChapterUpdate): Promise { + const { data } = await http.patch(`${base(novelId)}/${chapterId}`, payload) + return data +} + +export async function deleteChapter(novelId: number, chapterId: number): Promise { + await http.delete(`${base(novelId)}/${chapterId}`) +} + +export async function reorderChapters(novelId: number, orderedIds: number[]): Promise { + const { data } = await http.put(`${base(novelId)}/reorder`, { ordered_ids: orderedIds }) + return data +} diff --git a/frontend/src/api/characters.ts b/frontend/src/api/characters.ts new file mode 100644 index 0000000..524885e --- /dev/null +++ b/frontend/src/api/characters.ts @@ -0,0 +1,23 @@ +import { http } from './client' +import type { Character, CharacterCreate, CharacterUpdate, CharacterList } from '@/types/character' + +const base = (novelId: number) => `/novels/${novelId}/characters` + +export async function fetchCharacters(novelId: number): Promise { + const { data } = await http.get(base(novelId)) + return data +} + +export async function createCharacter(novelId: number, payload: CharacterCreate): Promise { + const { data } = await http.post(base(novelId), payload) + return data +} + +export async function updateCharacter(novelId: number, characterId: number, payload: CharacterUpdate): Promise { + const { data } = await http.patch(`${base(novelId)}/${characterId}`, payload) + return data +} + +export async function deleteCharacter(novelId: number, characterId: number): Promise { + await http.delete(`${base(novelId)}/${characterId}`) +} diff --git a/frontend/src/api/chat.ts b/frontend/src/api/chat.ts index a0227f8..0b1b621 100644 --- a/frontend/src/api/chat.ts +++ b/frontend/src/api/chat.ts @@ -1,5 +1,5 @@ import { http } from './client' -import type { AIConfig, AIConfigSave, ChatMessageList } from '@/types/chat' +import type { AIConfig, AIConfigSave, ChatMessageList, ToolCallEntry } from '@/types/chat' // ── AI配置 ── @@ -41,27 +41,51 @@ export interface TokenUsage { total_tokens: number } -export interface ContextMessage { - role: string - content: string +export interface ToolResultInfo { + name: string + label: string + result: string +} + +export interface PendingToolCallData { + id: string + name: string + label: string + arguments: Record } export interface StreamChatOptions { messages: { role: string; content: string }[] novelId?: number + pageContext?: string + toolsEnabled?: boolean + // 工具审批续传 + pendingToolCalls?: PendingToolCallData[] + assistantText?: string + // 回调 onChunk: (text: string) => void onError: (error: string) => void - onDone: (usage?: TokenUsage) => void - onContext?: (context: ContextMessage[]) => void + onDone: (usage?: TokenUsage, pending?: boolean) => void + onToolCallsPending?: (calls: PendingToolCallData[], assistantText: string) => void + onToolResult?: (info: ToolResultInfo) => void signal?: AbortSignal } export async function streamChat(options: StreamChatOptions): Promise { - const { messages, novelId, onChunk, onError, onDone, onContext, signal } = options + const { + messages, novelId, pageContext, toolsEnabled, + pendingToolCalls, assistantText, + onChunk, onError, onDone, onToolCallsPending, onToolResult, + signal, + } = options const body = JSON.stringify({ messages, novel_id: novelId ?? null, + page_context: pageContext ?? null, + tools_enabled: toolsEnabled ?? true, + pending_tool_calls: pendingToolCalls ?? null, + assistant_text: assistantText ?? null, }) const resp = await fetch('/api/chat/stream', { @@ -95,7 +119,7 @@ export async function streamChat(options: StreamChatOptions): Promise { try { const event = JSON.parse(line.slice(6)) if (event.done) { - onDone(event.usage ?? undefined) + onDone(event.usage ?? undefined, event.pending ?? false) return } if (event.error) { @@ -103,12 +127,15 @@ export async function streamChat(options: StreamChatOptions): Promise { onDone() return } - if (event.context && onContext) { - onContext(event.context) - } if (event.content) { onChunk(event.content) } + if (event.tool_calls_pending && onToolCallsPending) { + onToolCallsPending(event.tool_calls_pending, event.assistant_text ?? '') + } + if (event.tool_result && onToolResult) { + onToolResult(event.tool_result) + } } catch { // 忽略解析错误 } diff --git a/frontend/src/api/worldSetting.ts b/frontend/src/api/worldSetting.ts new file mode 100644 index 0000000..c6612f6 --- /dev/null +++ b/frontend/src/api/worldSetting.ts @@ -0,0 +1,14 @@ +import { http } from './client' +import type { WorldSetting, WorldSettingUpdate } from '@/types/worldSetting' + +const base = (novelId: number) => `/novels/${novelId}/world-setting` + +export async function fetchWorldSetting(novelId: number): Promise { + const { data } = await http.get(base(novelId)) + return data +} + +export async function saveWorldSetting(novelId: number, payload: WorldSettingUpdate): Promise { + const { data } = await http.put(base(novelId), payload) + return data +} diff --git a/frontend/src/components/chapter/ChapterEditor.vue b/frontend/src/components/chapter/ChapterEditor.vue new file mode 100644 index 0000000..89608a9 --- /dev/null +++ b/frontend/src/components/chapter/ChapterEditor.vue @@ -0,0 +1,193 @@ + + + + + diff --git a/frontend/src/components/chapter/ChapterEmptyState.vue b/frontend/src/components/chapter/ChapterEmptyState.vue new file mode 100644 index 0000000..3771fb6 --- /dev/null +++ b/frontend/src/components/chapter/ChapterEmptyState.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/frontend/src/components/chapter/ChapterFormModal.vue b/frontend/src/components/chapter/ChapterFormModal.vue new file mode 100644 index 0000000..adbbe92 --- /dev/null +++ b/frontend/src/components/chapter/ChapterFormModal.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/frontend/src/components/chapter/ChapterItem.vue b/frontend/src/components/chapter/ChapterItem.vue new file mode 100644 index 0000000..51ae4db --- /dev/null +++ b/frontend/src/components/chapter/ChapterItem.vue @@ -0,0 +1,172 @@ + + + + + diff --git a/frontend/src/components/chapter/ChapterList.vue b/frontend/src/components/chapter/ChapterList.vue new file mode 100644 index 0000000..6d38c0e --- /dev/null +++ b/frontend/src/components/chapter/ChapterList.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/frontend/src/components/character/CharacterCard.vue b/frontend/src/components/character/CharacterCard.vue new file mode 100644 index 0000000..b15198f --- /dev/null +++ b/frontend/src/components/character/CharacterCard.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/frontend/src/components/character/CharacterEmptyState.vue b/frontend/src/components/character/CharacterEmptyState.vue new file mode 100644 index 0000000..e79eb1f --- /dev/null +++ b/frontend/src/components/character/CharacterEmptyState.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/frontend/src/components/character/CharacterFormModal.vue b/frontend/src/components/character/CharacterFormModal.vue new file mode 100644 index 0000000..bba5c21 --- /dev/null +++ b/frontend/src/components/character/CharacterFormModal.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/frontend/src/components/character/CharacterGrid.vue b/frontend/src/components/character/CharacterGrid.vue new file mode 100644 index 0000000..b7754e9 --- /dev/null +++ b/frontend/src/components/character/CharacterGrid.vue @@ -0,0 +1,33 @@ + + + + + diff --git a/frontend/src/components/chat/ChatPanel.vue b/frontend/src/components/chat/ChatPanel.vue index bfec2de..d09cc15 100644 --- a/frontend/src/components/chat/ChatPanel.vue +++ b/frontend/src/components/chat/ChatPanel.vue @@ -1,7 +1,10 @@ + + + + diff --git a/frontend/src/composables/useChatAgent.ts b/frontend/src/composables/useChatAgent.ts index 68c9d8b..c9acc41 100644 --- a/frontend/src/composables/useChatAgent.ts +++ b/frontend/src/composables/useChatAgent.ts @@ -1,30 +1,59 @@ -import { ref } from 'vue' +import { ref, computed } from 'vue' +import { useRoute } from 'vue-router' import { streamChat, fetchMessages, clearMessages as apiClearMessages } from '@/api/chat' -import type { ChatMessage } from '@/types/chat' -import type { TokenUsage } from '@/api/chat' +import type { ChatMessage, ChatEntry, ToolCallGroup, ToolCallEntry } from '@/types/chat' +import type { TokenUsage, PendingToolCallData } from '@/api/chat' // 全局状态(跨组件共享) -const messages = ref([]) +const entries = ref([]) const isStreaming = ref(false) const streamingContent = ref('') const lastUsage = ref(null) +const toolsEnabled = ref(true) let abortController: AbortController | null = null let idCounter = Date.now() +// 路由名称 → 页面上下文描述 +const PAGE_LABELS: Record = { + 'novel-detail': '小说详情页', + 'novel-outlines': '大纲管理页', + 'novel-chapters': '章节管理页', + 'novel-characters': '角色管理页', + 'novel-world-setting': '世界观设定页', + 'novel-edit': '小说编辑页', +} + export function useChatAgent() { + const route = useRoute() + + const currentNovelId = computed(() => { + const id = route.params.id + return id ? Number(id) : undefined + }) + + const pageContext = computed(() => { + const name = route.name as string + return PAGE_LABELS[name] ?? undefined + }) + + /** 仅普通消息(用于发送给 API) */ + const chatMessages = computed(() => + entries.value.filter((e): e is ChatMessage => !('type' in e)) + ) + async function loadHistory(novelId?: number) { try { - const list = await fetchMessages(novelId) - messages.value = list.items + const list = await fetchMessages(novelId ?? currentNovelId.value) + entries.value = list.items as ChatEntry[] } catch { - // 静默失败 + // 静默 } } - async function sendMessage(content: string, novelId?: number) { + async function sendMessage(content: string) { if (isStreaming.value || !content.trim()) return + const novelId = currentNovelId.value - // 添加用户消息到列表 const userMsg: ChatMessage = { id: idCounter++, novel_id: novelId ?? null, @@ -32,49 +61,136 @@ export function useChatAgent() { content: content.trim(), created_at: new Date().toISOString(), } - messages.value.push(userMsg) + entries.value.push(userMsg) - // 开始流式请求 + await _doStreamRequest(novelId) + } + + /** 用户批准工具调用 */ + async function approveToolCalls(group: ToolCallGroup) { + group.status = 'executing' + const novelId = currentNovelId.value + await _doStreamRequest(novelId, group) + } + + /** 用户跳过工具调用 */ + function skipToolCalls(group: ToolCallGroup) { + group.status = 'skipped' + // 把 LLM 在工具调用前的文本作为 assistant 消息保存 + if (group.assistantText) { + entries.value.push({ + id: idCounter++, + novel_id: currentNovelId.value ?? null, + role: 'assistant', + content: group.assistantText, + created_at: new Date().toISOString(), + }) + } + isStreaming.value = false + } + + /** 核心:发起流式请求 */ + async function _doStreamRequest(novelId?: number, approvedGroup?: ToolCallGroup) { isStreaming.value = true streamingContent.value = '' abortController = new AbortController() - // 构建发送给API的消息列表(取最近20条) - const recentMessages = messages.value - .filter(m => m.role === 'user' || m.role === 'assistant') + // 构建消息列表(取最近 20 条) + const recentMessages = chatMessages.value .slice(-20) .map(m => ({ role: m.role, content: m.content })) + // 审批续传参数 + const pendingToolCalls = approvedGroup + ? approvedGroup.calls.map(c => ({ id: c.id, name: c.name, label: c.label, arguments: c.arguments })) + : undefined + const assistantText = approvedGroup?.assistantText + await streamChat({ messages: recentMessages, novelId, + pageContext: pageContext.value, + toolsEnabled: toolsEnabled.value, + pendingToolCalls, + assistantText, signal: abortController.signal, + onChunk(text) { streamingContent.value += text }, + + onToolCallsPending(calls: PendingToolCallData[], aText: string) { + // 先把当前 streaming 文本保存(如果有) + // LLM 在调用工具前可能输出了一些文本 + if (streamingContent.value) { + // 不作为独立消息,因为它会在续传后被合并 + // 这段文本已包含在 aText 中 + } + streamingContent.value = '' + + // 创建工具调用组 + const group: ToolCallGroup = { + id: idCounter++, + type: 'tool_calls', + calls: calls.map(c => ({ + id: c.id, + name: c.name, + label: c.label, + arguments: c.arguments, + })), + status: 'pending', + assistantText: aText, + } + entries.value.push(group) + }, + + onToolResult(info) { + // 找到正在执行的工具组,更新对应工具的结果 + const group = [...entries.value].reverse().find( + (e): e is ToolCallGroup => 'type' in e && e.type === 'tool_calls' && e.status === 'executing' + ) + if (group) { + const call = group.calls.find(c => c.name === info.name) + if (call) { + call.result = info.result + try { call.parsedResult = JSON.parse(info.result) } catch { /* ok */ } + } + // 所有工具都有结果 → 标记完成 + if (group.calls.every(c => c.result)) { + group.status = 'done' + } + } + }, + onError(error) { - const errMsg: ChatMessage = { + entries.value.push({ id: idCounter++, novel_id: novelId ?? null, role: 'assistant', content: `⚠️ ${error}`, created_at: new Date().toISOString(), - } - messages.value.push(errMsg) + }) }, - onDone(usage) { + + onDone(usage, pending) { if (usage) { lastUsage.value = usage } + if (pending) { + // 工具调用待审批,保持 isStreaming 状态让 UI 知道对话还没结束 + // 但不再显示加载指示 + isStreaming.value = false + return + } + // 正常完成 if (streamingContent.value) { - const aiMsg: ChatMessage = { + entries.value.push({ id: idCounter++, novel_id: novelId ?? null, role: 'assistant', content: streamingContent.value, created_at: new Date().toISOString(), - } - messages.value.push(aiMsg) + }) } streamingContent.value = '' isStreaming.value = false @@ -90,24 +206,29 @@ export function useChatAgent() { } } - async function clearChat(novelId?: number) { + async function clearChat() { try { - await apiClearMessages(novelId) + await apiClearMessages(currentNovelId.value) } catch { // 静默 } - messages.value = [] + entries.value = [] streamingContent.value = '' lastUsage.value = null } return { - messages, + entries, isStreaming, streamingContent, lastUsage, + toolsEnabled, + currentNovelId, + pageContext, loadHistory, sendMessage, + approveToolCalls, + skipToolCalls, stopStreaming, clearChat, } diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 05e04d3..36feec8 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -28,6 +28,21 @@ const router = createRouter({ name: 'novel-outlines', component: () => import('@/views/OutlineView.vue'), }, + { + path: '/novels/:id/chapters', + name: 'novel-chapters', + component: () => import('@/views/ChapterView.vue'), + }, + { + path: '/novels/:id/characters', + name: 'novel-characters', + component: () => import('@/views/CharacterView.vue'), + }, + { + path: '/novels/:id/world-setting', + name: 'novel-world-setting', + component: () => import('@/views/WorldSettingView.vue'), + }, ], }) diff --git a/frontend/src/types/chapter.ts b/frontend/src/types/chapter.ts new file mode 100644 index 0000000..5d38e2b --- /dev/null +++ b/frontend/src/types/chapter.ts @@ -0,0 +1,29 @@ +export interface Chapter { + id: number + novel_id: number + sort_order: number + title: string + content: string + created_at: string + updated_at: string +} + +export interface ChapterCreate { + title: string + content?: string +} + +export interface ChapterUpdate { + title?: string + content?: string + sort_order?: number +} + +export interface ChapterList { + items: Chapter[] + total: number +} + +export interface ChapterReorder { + ordered_ids: number[] +} diff --git a/frontend/src/types/character.ts b/frontend/src/types/character.ts new file mode 100644 index 0000000..643b02e --- /dev/null +++ b/frontend/src/types/character.ts @@ -0,0 +1,23 @@ +export interface Character { + id: number + novel_id: number + name: string + description: string + created_at: string + updated_at: string +} + +export interface CharacterCreate { + name: string + description?: string +} + +export interface CharacterUpdate { + name?: string + description?: string +} + +export interface CharacterList { + items: Character[] + total: number +} diff --git a/frontend/src/types/chat.ts b/frontend/src/types/chat.ts index 90adbeb..e3a2f9f 100644 --- a/frontend/src/types/chat.ts +++ b/frontend/src/types/chat.ts @@ -6,6 +6,31 @@ export interface ChatMessage { created_at: string } +/** 工具调用组(在对话中显示) */ +export interface ToolCallGroup { + id: number + type: 'tool_calls' + calls: ToolCallEntry[] + status: 'pending' | 'executing' | 'done' | 'skipped' + assistantText: string // LLM 在调用工具前输出的文本 +} + +export interface ToolCallEntry { + id: string + name: string + label: string + arguments: Record + result?: string + parsedResult?: unknown +} + +/** 对话条目:普通消息或工具调用组 */ +export type ChatEntry = ChatMessage | ToolCallGroup + +export function isToolCallGroup(entry: ChatEntry): entry is ToolCallGroup { + return 'type' in entry && entry.type === 'tool_calls' +} + export interface ChatMessageList { items: ChatMessage[] total: number diff --git a/frontend/src/types/worldSetting.ts b/frontend/src/types/worldSetting.ts new file mode 100644 index 0000000..13360d3 --- /dev/null +++ b/frontend/src/types/worldSetting.ts @@ -0,0 +1,11 @@ +export interface WorldSetting { + id: number + novel_id: number + content: string + created_at: string + updated_at: string +} + +export interface WorldSettingUpdate { + content: string +} diff --git a/frontend/src/views/ChapterView.vue b/frontend/src/views/ChapterView.vue new file mode 100644 index 0000000..05daa86 --- /dev/null +++ b/frontend/src/views/ChapterView.vue @@ -0,0 +1,340 @@ + + + + + diff --git a/frontend/src/views/CharacterView.vue b/frontend/src/views/CharacterView.vue new file mode 100644 index 0000000..42167bb --- /dev/null +++ b/frontend/src/views/CharacterView.vue @@ -0,0 +1,215 @@ + + + + + diff --git a/frontend/src/views/NovelDetailView.vue b/frontend/src/views/NovelDetailView.vue index db47ea6..45c9d5c 100644 --- a/frontend/src/views/NovelDetailView.vue +++ b/frontend/src/views/NovelDetailView.vue @@ -87,6 +87,15 @@ onMounted(load) 大纲 + + 章节 + + + 角色 + + + 世界观 + 编辑 diff --git a/frontend/src/views/WorldSettingView.vue b/frontend/src/views/WorldSettingView.vue new file mode 100644 index 0000000..97a2441 --- /dev/null +++ b/frontend/src/views/WorldSettingView.vue @@ -0,0 +1,212 @@ + + + + +