新增角色管理、世界观设定、章节管理和AI工具调用功能
- 角色管理:CRUD API + 卡片网格UI,每个角色约200字描述 - 世界观设定:单篇长文编辑器,建议500字以上 - 章节管理:CRUD + 排序 API,左右分栏编辑器,单章5000字上限 - AI工具调用:支持工具审批流程和结构化工具调用 - 小说详情页新增章节、角色、世界观入口按钮 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
136
backend/app/routers/chapters.py
Normal file
136
backend/app/routers/chapters.py
Normal file
@@ -0,0 +1,136 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlmodel import Session, select, func
|
||||
|
||||
from ..database import get_session
|
||||
from ..models import Novel, Chapter
|
||||
from ..schemas import (
|
||||
ChapterCreate,
|
||||
ChapterUpdate,
|
||||
ChapterRead,
|
||||
ChapterList,
|
||||
ChapterReorder,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/novels/{novel_id}/chapters", tags=["chapters"])
|
||||
|
||||
|
||||
def _get_novel_or_404(novel_id: int, session: Session) -> Novel:
|
||||
novel = session.get(Novel, novel_id)
|
||||
if not novel:
|
||||
raise HTTPException(status_code=404, detail="小说不存在")
|
||||
return novel
|
||||
|
||||
|
||||
def _get_chapter_or_404(
|
||||
chapter_id: int, novel_id: int, session: Session
|
||||
) -> Chapter:
|
||||
chapter = session.get(Chapter, chapter_id)
|
||||
if not chapter or chapter.novel_id != novel_id:
|
||||
raise HTTPException(status_code=404, detail="章节不存在")
|
||||
return chapter
|
||||
|
||||
|
||||
@router.get("", response_model=ChapterList)
|
||||
def list_chapters(
|
||||
novel_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_get_novel_or_404(novel_id, session)
|
||||
total = session.exec(
|
||||
select(func.count(Chapter.id)).where(Chapter.novel_id == novel_id)
|
||||
).one()
|
||||
items = session.exec(
|
||||
select(Chapter)
|
||||
.where(Chapter.novel_id == novel_id)
|
||||
.order_by(Chapter.sort_order)
|
||||
).all()
|
||||
return ChapterList(
|
||||
items=[ChapterRead.model_validate(c, from_attributes=True) for c in items],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=ChapterRead, status_code=201)
|
||||
def create_chapter(
|
||||
novel_id: int,
|
||||
data: ChapterCreate,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_get_novel_or_404(novel_id, session)
|
||||
max_order = session.exec(
|
||||
select(func.max(Chapter.sort_order)).where(Chapter.novel_id == novel_id)
|
||||
).one()
|
||||
next_order = (max_order or 0) + 1
|
||||
|
||||
chapter = Chapter(novel_id=novel_id, sort_order=next_order, **data.model_dump())
|
||||
session.add(chapter)
|
||||
session.commit()
|
||||
session.refresh(chapter)
|
||||
return chapter
|
||||
|
||||
|
||||
@router.put("/reorder", response_model=ChapterList)
|
||||
def reorder_chapters(
|
||||
novel_id: int,
|
||||
data: ChapterReorder,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_get_novel_or_404(novel_id, session)
|
||||
now = datetime.now(timezone.utc)
|
||||
for idx, cid in enumerate(data.ordered_ids):
|
||||
chapter = _get_chapter_or_404(cid, novel_id, session)
|
||||
chapter.sort_order = idx + 1
|
||||
chapter.updated_at = now
|
||||
session.add(chapter)
|
||||
session.commit()
|
||||
|
||||
items = session.exec(
|
||||
select(Chapter)
|
||||
.where(Chapter.novel_id == novel_id)
|
||||
.order_by(Chapter.sort_order)
|
||||
).all()
|
||||
total = len(items)
|
||||
return ChapterList(
|
||||
items=[ChapterRead.model_validate(c, from_attributes=True) for c in items],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{chapter_id}", response_model=ChapterRead)
|
||||
def get_chapter(
|
||||
novel_id: int,
|
||||
chapter_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
return _get_chapter_or_404(chapter_id, novel_id, session)
|
||||
|
||||
|
||||
@router.patch("/{chapter_id}", response_model=ChapterRead)
|
||||
def update_chapter(
|
||||
novel_id: int,
|
||||
chapter_id: int,
|
||||
data: ChapterUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
chapter = _get_chapter_or_404(chapter_id, novel_id, session)
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(chapter, key, value)
|
||||
chapter.updated_at = datetime.now(timezone.utc)
|
||||
session.add(chapter)
|
||||
session.commit()
|
||||
session.refresh(chapter)
|
||||
return chapter
|
||||
|
||||
|
||||
@router.delete("/{chapter_id}", status_code=204)
|
||||
def delete_chapter(
|
||||
novel_id: int,
|
||||
chapter_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
chapter = _get_chapter_or_404(chapter_id, novel_id, session)
|
||||
session.delete(chapter)
|
||||
session.commit()
|
||||
103
backend/app/routers/characters.py
Normal file
103
backend/app/routers/characters.py
Normal file
@@ -0,0 +1,103 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlmodel import Session, select, func
|
||||
|
||||
from ..database import get_session
|
||||
from ..models import Novel, Character
|
||||
from ..schemas import (
|
||||
CharacterCreate,
|
||||
CharacterUpdate,
|
||||
CharacterRead,
|
||||
CharacterList,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/novels/{novel_id}/characters", tags=["characters"])
|
||||
|
||||
|
||||
def _get_novel_or_404(novel_id: int, session: Session) -> Novel:
|
||||
novel = session.get(Novel, novel_id)
|
||||
if not novel:
|
||||
raise HTTPException(status_code=404, detail="小说不存在")
|
||||
return novel
|
||||
|
||||
|
||||
def _get_character_or_404(
|
||||
character_id: int, novel_id: int, session: Session
|
||||
) -> Character:
|
||||
character = session.get(Character, character_id)
|
||||
if not character or character.novel_id != novel_id:
|
||||
raise HTTPException(status_code=404, detail="角色不存在")
|
||||
return character
|
||||
|
||||
|
||||
@router.get("", response_model=CharacterList)
|
||||
def list_characters(
|
||||
novel_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_get_novel_or_404(novel_id, session)
|
||||
total = session.exec(
|
||||
select(func.count(Character.id)).where(Character.novel_id == novel_id)
|
||||
).one()
|
||||
items = session.exec(
|
||||
select(Character)
|
||||
.where(Character.novel_id == novel_id)
|
||||
.order_by(Character.created_at)
|
||||
).all()
|
||||
return CharacterList(
|
||||
items=[CharacterRead.model_validate(c, from_attributes=True) for c in items],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=CharacterRead, status_code=201)
|
||||
def create_character(
|
||||
novel_id: int,
|
||||
data: CharacterCreate,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_get_novel_or_404(novel_id, session)
|
||||
character = Character(novel_id=novel_id, **data.model_dump())
|
||||
session.add(character)
|
||||
session.commit()
|
||||
session.refresh(character)
|
||||
return character
|
||||
|
||||
|
||||
@router.get("/{character_id}", response_model=CharacterRead)
|
||||
def get_character(
|
||||
novel_id: int,
|
||||
character_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
return _get_character_or_404(character_id, novel_id, session)
|
||||
|
||||
|
||||
@router.patch("/{character_id}", response_model=CharacterRead)
|
||||
def update_character(
|
||||
novel_id: int,
|
||||
character_id: int,
|
||||
data: CharacterUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
character = _get_character_or_404(character_id, novel_id, session)
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(character, key, value)
|
||||
character.updated_at = datetime.now(timezone.utc)
|
||||
session.add(character)
|
||||
session.commit()
|
||||
session.refresh(character)
|
||||
return character
|
||||
|
||||
|
||||
@router.delete("/{character_id}", status_code=204)
|
||||
def delete_character(
|
||||
novel_id: int,
|
||||
character_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
character = _get_character_or_404(character_id, novel_id, session)
|
||||
session.delete(character)
|
||||
session.commit()
|
||||
@@ -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],
|
||||
|
||||
56
backend/app/routers/world_setting.py
Normal file
56
backend/app/routers/world_setting.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ..database import get_session
|
||||
from ..models import Novel, WorldSetting
|
||||
from ..schemas import WorldSettingUpdate, WorldSettingRead
|
||||
|
||||
router = APIRouter(prefix="/api/novels/{novel_id}/world-setting", tags=["world-setting"])
|
||||
|
||||
|
||||
def _get_novel_or_404(novel_id: int, session: Session) -> Novel:
|
||||
novel = session.get(Novel, novel_id)
|
||||
if not novel:
|
||||
raise HTTPException(status_code=404, detail="小说不存在")
|
||||
return novel
|
||||
|
||||
|
||||
@router.get("", response_model=WorldSettingRead)
|
||||
def get_world_setting(
|
||||
novel_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_get_novel_or_404(novel_id, session)
|
||||
ws = session.exec(
|
||||
select(WorldSetting).where(WorldSetting.novel_id == novel_id)
|
||||
).first()
|
||||
if not ws:
|
||||
# 不存在则自动创建空记录
|
||||
ws = WorldSetting(novel_id=novel_id, content="")
|
||||
session.add(ws)
|
||||
session.commit()
|
||||
session.refresh(ws)
|
||||
return ws
|
||||
|
||||
|
||||
@router.put("", response_model=WorldSettingRead)
|
||||
def save_world_setting(
|
||||
novel_id: int,
|
||||
data: WorldSettingUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_get_novel_or_404(novel_id, session)
|
||||
ws = session.exec(
|
||||
select(WorldSetting).where(WorldSetting.novel_id == novel_id)
|
||||
).first()
|
||||
if ws:
|
||||
ws.content = data.content
|
||||
ws.updated_at = datetime.now(timezone.utc)
|
||||
else:
|
||||
ws = WorldSetting(novel_id=novel_id, content=data.content)
|
||||
session.add(ws)
|
||||
session.commit()
|
||||
session.refresh(ws)
|
||||
return ws
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
205
backend/app/services/tools.py
Normal file
205
backend/app/services/tools.py
Normal file
@@ -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)
|
||||
33
frontend/src/api/chapters.ts
Normal file
33
frontend/src/api/chapters.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { http } from './client'
|
||||
import type { Chapter, ChapterCreate, ChapterUpdate, ChapterList } from '@/types/chapter'
|
||||
|
||||
const base = (novelId: number) => `/novels/${novelId}/chapters`
|
||||
|
||||
export async function fetchChapters(novelId: number): Promise<ChapterList> {
|
||||
const { data } = await http.get<ChapterList>(base(novelId))
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchChapter(novelId: number, chapterId: number): Promise<Chapter> {
|
||||
const { data } = await http.get<Chapter>(`${base(novelId)}/${chapterId}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createChapter(novelId: number, payload: ChapterCreate): Promise<Chapter> {
|
||||
const { data } = await http.post<Chapter>(base(novelId), payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateChapter(novelId: number, chapterId: number, payload: ChapterUpdate): Promise<Chapter> {
|
||||
const { data } = await http.patch<Chapter>(`${base(novelId)}/${chapterId}`, payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteChapter(novelId: number, chapterId: number): Promise<void> {
|
||||
await http.delete(`${base(novelId)}/${chapterId}`)
|
||||
}
|
||||
|
||||
export async function reorderChapters(novelId: number, orderedIds: number[]): Promise<ChapterList> {
|
||||
const { data } = await http.put<ChapterList>(`${base(novelId)}/reorder`, { ordered_ids: orderedIds })
|
||||
return data
|
||||
}
|
||||
23
frontend/src/api/characters.ts
Normal file
23
frontend/src/api/characters.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { http } from './client'
|
||||
import type { Character, CharacterCreate, CharacterUpdate, CharacterList } from '@/types/character'
|
||||
|
||||
const base = (novelId: number) => `/novels/${novelId}/characters`
|
||||
|
||||
export async function fetchCharacters(novelId: number): Promise<CharacterList> {
|
||||
const { data } = await http.get<CharacterList>(base(novelId))
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createCharacter(novelId: number, payload: CharacterCreate): Promise<Character> {
|
||||
const { data } = await http.post<Character>(base(novelId), payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateCharacter(novelId: number, characterId: number, payload: CharacterUpdate): Promise<Character> {
|
||||
const { data } = await http.patch<Character>(`${base(novelId)}/${characterId}`, payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteCharacter(novelId: number, characterId: number): Promise<void> {
|
||||
await http.delete(`${base(novelId)}/${characterId}`)
|
||||
}
|
||||
@@ -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<string, unknown>
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 {
|
||||
// 忽略解析错误
|
||||
}
|
||||
|
||||
14
frontend/src/api/worldSetting.ts
Normal file
14
frontend/src/api/worldSetting.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { http } from './client'
|
||||
import type { WorldSetting, WorldSettingUpdate } from '@/types/worldSetting'
|
||||
|
||||
const base = (novelId: number) => `/novels/${novelId}/world-setting`
|
||||
|
||||
export async function fetchWorldSetting(novelId: number): Promise<WorldSetting> {
|
||||
const { data } = await http.get<WorldSetting>(base(novelId))
|
||||
return data
|
||||
}
|
||||
|
||||
export async function saveWorldSetting(novelId: number, payload: WorldSettingUpdate): Promise<WorldSetting> {
|
||||
const { data } = await http.put<WorldSetting>(base(novelId), payload)
|
||||
return data
|
||||
}
|
||||
193
frontend/src/components/chapter/ChapterEditor.vue
Normal file
193
frontend/src/components/chapter/ChapterEditor.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import type { Chapter } from '@/types/chapter'
|
||||
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||
import BaseTextarea from '@/components/ui/BaseTextarea.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
chapter: Chapter
|
||||
saving: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
save: [data: { title: string; content: string }]
|
||||
back: []
|
||||
}>()
|
||||
|
||||
const title = ref('')
|
||||
const content = ref('')
|
||||
const hasChanges = ref(false)
|
||||
|
||||
const contentLength = computed(() => content.value.length)
|
||||
const contentHint = computed(() => {
|
||||
if (contentLength.value === 0) return '上限 5000 字'
|
||||
return `${contentLength.value} / 5000 字`
|
||||
})
|
||||
|
||||
watch(() => props.chapter, (ch) => {
|
||||
title.value = ch.title
|
||||
content.value = ch.content
|
||||
hasChanges.value = false
|
||||
}, { immediate: true })
|
||||
|
||||
function onInput() {
|
||||
hasChanges.value = true
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
emit('save', {
|
||||
title: title.value.trim(),
|
||||
content: content.value,
|
||||
})
|
||||
hasChanges.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chapter-editor">
|
||||
<div class="editor-header">
|
||||
<button class="back-btn" @click="emit('back')">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M10 3L5 8l5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
返回列表
|
||||
</button>
|
||||
<div class="editor-title-wrap">
|
||||
<input
|
||||
v-model="title"
|
||||
class="editor-title-input"
|
||||
placeholder="章节标题"
|
||||
maxlength="200"
|
||||
@input="onInput"
|
||||
/>
|
||||
</div>
|
||||
<div class="editor-actions">
|
||||
<span v-if="hasChanges" class="unsaved-hint">未保存</span>
|
||||
<BaseButton
|
||||
variant="primary"
|
||||
size="small"
|
||||
:loading="saving"
|
||||
:disabled="!hasChanges || !title.trim()"
|
||||
@click="handleSave"
|
||||
>
|
||||
保存
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-body">
|
||||
<BaseTextarea
|
||||
v-model="content"
|
||||
placeholder="在此书写章节内容..."
|
||||
:rows="20"
|
||||
:maxlength="5000"
|
||||
:max-height="800"
|
||||
@input="onInput"
|
||||
/>
|
||||
<div class="editor-footer">
|
||||
<span class="word-count" :class="{ 'word-count--near': contentLength > 4500 }">
|
||||
{{ contentHint }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chapter-editor {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding-bottom: var(--space-lg);
|
||||
border-bottom: 1px solid var(--paper-200);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--ink-400);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--duration-fast) ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
color: var(--ink-700);
|
||||
background: var(--paper-100);
|
||||
}
|
||||
|
||||
.editor-title-wrap {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.editor-title-input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: none;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
color: var(--ink-900);
|
||||
outline: none;
|
||||
padding: var(--space-xs) 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: border-color var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.editor-title-input:focus {
|
||||
border-bottom-color: var(--vermilion);
|
||||
}
|
||||
|
||||
.editor-title-input::placeholder {
|
||||
color: var(--ink-200);
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.unsaved-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vermilion);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.editor-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.editor-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.word-count {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink-300);
|
||||
}
|
||||
|
||||
.word-count--near {
|
||||
color: var(--vermilion);
|
||||
}
|
||||
</style>
|
||||
93
frontend/src/components/chapter/ChapterEmptyState.vue
Normal file
93
frontend/src/components/chapter/ChapterEmptyState.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||
|
||||
defineEmits<{
|
||||
create: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="empty-state">
|
||||
<!-- 书页SVG -->
|
||||
<svg class="book-svg" viewBox="0 0 200 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- 打开的书 -->
|
||||
<path class="book-left" d="M30 40 Q100 35 100 45 L100 150 Q100 140 30 145 Z" fill="#faf8f5" stroke="#e8e0d4" stroke-width="1" />
|
||||
<path class="book-right" d="M170 40 Q100 35 100 45 L100 150 Q100 140 170 145 Z" fill="#faf8f5" stroke="#e8e0d4" stroke-width="1" />
|
||||
|
||||
<!-- 书脊 -->
|
||||
<line x1="100" y1="38" x2="100" y2="150" stroke="#d9cebf" stroke-width="1.5" />
|
||||
|
||||
<!-- 左页文字线 -->
|
||||
<line class="text-line tl-1" x1="45" y1="65" x2="90" y2="65" stroke="#e8e0d4" stroke-width="0.8" stroke-dasharray="3 2" />
|
||||
<line class="text-line tl-2" x1="45" y1="78" x2="85" y2="78" stroke="#e8e0d4" stroke-width="0.8" stroke-dasharray="3 2" />
|
||||
<line class="text-line tl-3" x1="45" y1="91" x2="88" y2="91" stroke="#e8e0d4" stroke-width="0.8" stroke-dasharray="3 2" />
|
||||
<line class="text-line tl-4" x1="45" y1="104" x2="80" y2="104" stroke="#e8e0d4" stroke-width="0.8" stroke-dasharray="3 2" />
|
||||
|
||||
<!-- 右页空白(等待书写) -->
|
||||
<text class="page-hint" x="135" y="95" text-anchor="middle" font-size="11" fill="#d9cebf">?</text>
|
||||
|
||||
<!-- 翻页效果 -->
|
||||
<path class="page-flip" d="M100 45 Q115 42 130 50 L125 140 Q110 135 100 150 Z" fill="#f5f0ea" stroke="#e8e0d4" stroke-width="0.8" opacity="0.5" />
|
||||
</svg>
|
||||
|
||||
<h3 class="empty-title">开始书写你的故事</h3>
|
||||
<p class="empty-desc">还没有章节,创建第一章来展开你的叙事</p>
|
||||
<BaseButton variant="primary" @click="$emit('create')">
|
||||
创建第一章
|
||||
</BaseButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: var(--space-3xl) var(--space-xl);
|
||||
animation: fadeInUp var(--duration-slow) var(--ease-out-smooth) both;
|
||||
}
|
||||
|
||||
.book-svg {
|
||||
width: 200px;
|
||||
height: 180px;
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.book-left {
|
||||
animation: fadeIn 0.6s ease 0.2s both;
|
||||
}
|
||||
.book-right {
|
||||
animation: fadeIn 0.6s ease 0.4s both;
|
||||
}
|
||||
.page-flip {
|
||||
animation: fadeIn 0.6s ease 0.6s both;
|
||||
}
|
||||
|
||||
.text-line {
|
||||
opacity: 0;
|
||||
animation: fadeIn 0.4s ease both;
|
||||
}
|
||||
.tl-1 { animation-delay: 0.6s; }
|
||||
.tl-2 { animation-delay: 0.8s; }
|
||||
.tl-3 { animation-delay: 1.0s; }
|
||||
.tl-4 { animation-delay: 1.2s; }
|
||||
|
||||
.page-hint {
|
||||
animation: fadeIn 0.4s ease 1.4s both;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: var(--ink-900);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.empty-desc {
|
||||
font-size: 0.9rem;
|
||||
color: var(--ink-400);
|
||||
margin-bottom: var(--space-xl);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
79
frontend/src/components/chapter/ChapterFormModal.vue
Normal file
79
frontend/src/components/chapter/ChapterFormModal.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import type { Chapter } from '@/types/chapter'
|
||||
import BaseModal from '@/components/ui/BaseModal.vue'
|
||||
import BaseInput from '@/components/ui/BaseInput.vue'
|
||||
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
chapter: Chapter | null
|
||||
saving: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [data: { title: string }]
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const title = ref('')
|
||||
|
||||
const isEdit = computed(() => !!props.chapter)
|
||||
const modalTitle = computed(() => isEdit.value ? '重命名章节' : '新建章节')
|
||||
const canSubmit = computed(() => title.value.trim().length > 0 && !props.saving)
|
||||
|
||||
watch(() => props.show, (val) => {
|
||||
if (val) {
|
||||
title.value = props.chapter?.title ?? ''
|
||||
}
|
||||
})
|
||||
|
||||
function handleSubmit() {
|
||||
if (!canSubmit.value) return
|
||||
emit('submit', { title: title.value.trim() })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal :show="show" @close="emit('close')">
|
||||
<form class="chapter-form" @submit.prevent="handleSubmit">
|
||||
<h2 class="form-title">{{ modalTitle }}</h2>
|
||||
|
||||
<BaseInput
|
||||
v-model="title"
|
||||
label="章节标题"
|
||||
:maxlength="200"
|
||||
required
|
||||
/>
|
||||
|
||||
<div class="form-actions">
|
||||
<BaseButton variant="secondary" type="button" @click="emit('close')">取消</BaseButton>
|
||||
<BaseButton variant="primary" type="submit" :loading="saving" :disabled="!canSubmit">
|
||||
{{ isEdit ? '保存' : '创建' }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</form>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chapter-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: var(--ink-900);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-sm);
|
||||
padding-top: var(--space-sm);
|
||||
}
|
||||
</style>
|
||||
172
frontend/src/components/chapter/ChapterItem.vue
Normal file
172
frontend/src/components/chapter/ChapterItem.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<script setup lang="ts">
|
||||
import type { Chapter } from '@/types/chapter'
|
||||
|
||||
const props = defineProps<{
|
||||
chapter: Chapter
|
||||
index: number
|
||||
active: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
select: [chapter: Chapter]
|
||||
edit: [chapter: Chapter]
|
||||
delete: [chapter: Chapter]
|
||||
}>()
|
||||
|
||||
const wordCount = computed(() => props.chapter.content.length)
|
||||
|
||||
import { computed } from 'vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="chapter-item"
|
||||
:class="{ 'chapter-item--active': active }"
|
||||
draggable="true"
|
||||
@click="$emit('select', chapter)"
|
||||
>
|
||||
<div class="item-drag-handle" title="拖拽排序">
|
||||
<svg width="10" height="14" viewBox="0 0 10 14" fill="none">
|
||||
<circle cx="3" cy="2" r="1.2" fill="currentColor" />
|
||||
<circle cx="7" cy="2" r="1.2" fill="currentColor" />
|
||||
<circle cx="3" cy="7" r="1.2" fill="currentColor" />
|
||||
<circle cx="7" cy="7" r="1.2" fill="currentColor" />
|
||||
<circle cx="3" cy="12" r="1.2" fill="currentColor" />
|
||||
<circle cx="7" cy="12" r="1.2" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<span class="item-order">{{ index + 1 }}</span>
|
||||
|
||||
<div class="item-info">
|
||||
<span class="item-title">{{ chapter.title }}</span>
|
||||
<span class="item-meta">{{ wordCount }} 字</span>
|
||||
</div>
|
||||
|
||||
<div class="item-actions">
|
||||
<button class="action-btn" title="重命名" @click.stop="$emit('edit', chapter)">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M10.5 1.5l2 2-8 8H2.5v-2z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="action-btn action-btn--danger" title="删除" @click.stop="$emit('delete', chapter)">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M2 4h10M5 4V2.5h4V4M3.5 4v8h7V4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chapter-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
background: var(--paper-50);
|
||||
border: 1px solid var(--paper-200);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-fast) var(--ease-out-smooth);
|
||||
}
|
||||
|
||||
.chapter-item:hover {
|
||||
border-color: var(--paper-300);
|
||||
box-shadow: 0 2px 8px rgba(26, 26, 46, 0.06);
|
||||
}
|
||||
|
||||
.chapter-item--active {
|
||||
border-color: var(--vermilion);
|
||||
background: #fef8f6;
|
||||
}
|
||||
|
||||
.item-drag-handle {
|
||||
color: var(--ink-200);
|
||||
cursor: grab;
|
||||
padding: var(--space-xs);
|
||||
opacity: 0;
|
||||
transition: opacity var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.chapter-item:hover .item-drag-handle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.item-order {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--paper-100);
|
||||
color: var(--ink-400);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chapter-item--active .item-order {
|
||||
background: var(--vermilion);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-size: 0.95rem;
|
||||
color: var(--ink-800);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink-300);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
opacity: 0;
|
||||
transition: opacity var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.chapter-item:hover .item-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--paper-100);
|
||||
color: var(--ink-400);
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--paper-200);
|
||||
color: var(--ink-700);
|
||||
}
|
||||
|
||||
.action-btn--danger:hover {
|
||||
background: #fef2f2;
|
||||
color: var(--vermilion);
|
||||
}
|
||||
</style>
|
||||
91
frontend/src/components/chapter/ChapterList.vue
Normal file
91
frontend/src/components/chapter/ChapterList.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { Chapter } from '@/types/chapter'
|
||||
import ChapterItem from './ChapterItem.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
chapters: Chapter[]
|
||||
activeId: number | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [chapter: Chapter]
|
||||
edit: [chapter: Chapter]
|
||||
delete: [chapter: Chapter]
|
||||
reorder: [chapters: Chapter[]]
|
||||
}>()
|
||||
|
||||
const dragIndex = ref(-1)
|
||||
const dragOverIndex = ref(-1)
|
||||
|
||||
function onDragStart(e: DragEvent, index: number) {
|
||||
dragIndex.value = index
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
function onDragOver(e: DragEvent, index: number) {
|
||||
e.preventDefault()
|
||||
dragOverIndex.value = index
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
if (dragIndex.value !== -1 && dragOverIndex.value !== -1 && dragIndex.value !== dragOverIndex.value) {
|
||||
const reordered = [...props.chapters]
|
||||
const [moved] = reordered.splice(dragIndex.value, 1)
|
||||
reordered.splice(dragOverIndex.value, 0, moved)
|
||||
emit('reorder', reordered)
|
||||
}
|
||||
dragIndex.value = -1
|
||||
dragOverIndex.value = -1
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chapter-list">
|
||||
<div
|
||||
v-for="(chapter, index) in chapters"
|
||||
:key="chapter.id"
|
||||
class="chapter-list-item"
|
||||
:class="{
|
||||
'chapter-list-item--drag-over': dragOverIndex === index && dragIndex !== index,
|
||||
}"
|
||||
@dragstart="onDragStart($event, index)"
|
||||
@dragover="onDragOver($event, index)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<ChapterItem
|
||||
:chapter="chapter"
|
||||
:index="index"
|
||||
:active="chapter.id === activeId"
|
||||
@select="emit('select', $event)"
|
||||
@edit="emit('edit', $event)"
|
||||
@delete="emit('delete', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chapter-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.chapter-list-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chapter-list-item--drag-over::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--vermilion);
|
||||
border-radius: 1px;
|
||||
}
|
||||
</style>
|
||||
106
frontend/src/components/character/CharacterCard.vue
Normal file
106
frontend/src/components/character/CharacterCard.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
import type { Character } from '@/types/character'
|
||||
|
||||
defineProps<{
|
||||
character: Character
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
edit: [character: Character]
|
||||
delete: [character: Character]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="character-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-name">{{ character.name }}</h3>
|
||||
<div class="card-actions">
|
||||
<button class="action-btn" title="编辑" @click="$emit('edit', character)">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M10.5 1.5l2 2-8 8H2.5v-2z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="action-btn action-btn--danger" title="删除" @click="$emit('delete', character)">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M2 4h10M5 4V2.5h4V4M3.5 4v8h7V4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="card-desc">{{ character.description || '暂无描述' }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.character-card {
|
||||
background: var(--paper-50);
|
||||
border: 1px solid var(--paper-200);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-lg);
|
||||
transition: transform var(--duration-fast) var(--ease-out-smooth),
|
||||
box-shadow var(--duration-fast) var(--ease-out-smooth);
|
||||
}
|
||||
|
||||
.character-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 24px rgba(26, 26, 46, 0.08);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--ink-900);
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
opacity: 0;
|
||||
transition: opacity var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.character-card:hover .card-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--paper-100);
|
||||
color: var(--ink-400);
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--paper-200);
|
||||
color: var(--ink-700);
|
||||
}
|
||||
|
||||
.action-btn--danger:hover {
|
||||
background: #fef2f2;
|
||||
color: var(--vermilion);
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 0.9rem;
|
||||
color: var(--ink-500);
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
83
frontend/src/components/character/CharacterEmptyState.vue
Normal file
83
frontend/src/components/character/CharacterEmptyState.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||
|
||||
defineEmits<{
|
||||
create: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="empty-state">
|
||||
<!-- 人物剪影SVG -->
|
||||
<svg class="person-svg" viewBox="0 0 200 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- 中间人物 -->
|
||||
<circle class="head head-center" cx="100" cy="50" r="18" fill="#e8e0d4" stroke="#9090a8" stroke-width="1" />
|
||||
<path class="body body-center" d="M75 90 Q100 75 125 90 L120 140 H80 Z" fill="#e8e0d4" stroke="#9090a8" stroke-width="1" />
|
||||
|
||||
<!-- 左侧人物 -->
|
||||
<circle class="head head-left" cx="45" cy="65" r="14" fill="#f0ebe4" stroke="#b0b0c0" stroke-width="0.8" opacity="0.6" />
|
||||
<path class="body body-left" d="M28 95 Q45 83 62 95 L59 135 H31 Z" fill="#f0ebe4" stroke="#b0b0c0" stroke-width="0.8" opacity="0.6" />
|
||||
|
||||
<!-- 右侧人物 -->
|
||||
<circle class="head head-right" cx="155" cy="65" r="14" fill="#f0ebe4" stroke="#b0b0c0" stroke-width="0.8" opacity="0.6" />
|
||||
<path class="body body-right" d="M138 95 Q155 83 172 95 L169 135 H141 Z" fill="#f0ebe4" stroke="#b0b0c0" stroke-width="0.8" opacity="0.6" />
|
||||
|
||||
<!-- 连接线 -->
|
||||
<line class="connect-line" x1="65" y1="80" x2="82" y2="70" stroke="#d9cebf" stroke-width="1" stroke-dasharray="3 3" />
|
||||
<line class="connect-line" x1="135" y1="80" x2="118" y2="70" stroke="#d9cebf" stroke-width="1" stroke-dasharray="3 3" />
|
||||
</svg>
|
||||
|
||||
<h3 class="empty-title">塑造你的角色</h3>
|
||||
<p class="empty-desc">还没有角色,创建第一个来丰富你的故事世界</p>
|
||||
<BaseButton variant="primary" @click="$emit('create')">
|
||||
创建第一个角色
|
||||
</BaseButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: var(--space-3xl) var(--space-xl);
|
||||
animation: fadeInUp var(--duration-slow) var(--ease-out-smooth) both;
|
||||
}
|
||||
|
||||
.person-svg {
|
||||
width: 200px;
|
||||
height: 180px;
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.head-center {
|
||||
animation: fadeIn 0.6s ease 0.2s both;
|
||||
}
|
||||
.body-center {
|
||||
animation: fadeIn 0.6s ease 0.4s both;
|
||||
}
|
||||
.head-left, .body-left {
|
||||
animation: fadeIn 0.6s ease 0.6s both;
|
||||
}
|
||||
.head-right, .body-right {
|
||||
animation: fadeIn 0.6s ease 0.8s both;
|
||||
}
|
||||
.connect-line {
|
||||
animation: fadeIn 0.4s ease 1.0s both;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: var(--ink-900);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.empty-desc {
|
||||
font-size: 0.9rem;
|
||||
color: var(--ink-400);
|
||||
margin-bottom: var(--space-xl);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
123
frontend/src/components/character/CharacterFormModal.vue
Normal file
123
frontend/src/components/character/CharacterFormModal.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import type { Character, CharacterCreate, CharacterUpdate } from '@/types/character'
|
||||
import BaseModal from '@/components/ui/BaseModal.vue'
|
||||
import BaseInput from '@/components/ui/BaseInput.vue'
|
||||
import BaseTextarea from '@/components/ui/BaseTextarea.vue'
|
||||
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
character: Character | null
|
||||
saving: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [data: CharacterCreate | CharacterUpdate]
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const name = ref('')
|
||||
const description = ref('')
|
||||
|
||||
const isEdit = computed(() => !!props.character)
|
||||
const title = computed(() => isEdit.value ? '编辑角色' : '新建角色')
|
||||
|
||||
const descLength = computed(() => description.value.length)
|
||||
const descHint = computed(() => {
|
||||
if (descLength.value === 0) return '建议约 200 字'
|
||||
if (descLength.value < 100) return `${descLength.value} 字,建议至少 100 字`
|
||||
if (descLength.value <= 300) return `${descLength.value} 字`
|
||||
return `${descLength.value} 字,建议不超过 300 字`
|
||||
})
|
||||
|
||||
const canSubmit = computed(() => name.value.trim().length > 0 && !props.saving)
|
||||
|
||||
watch(() => props.show, (val) => {
|
||||
if (val) {
|
||||
name.value = props.character?.name ?? ''
|
||||
description.value = props.character?.description ?? ''
|
||||
}
|
||||
})
|
||||
|
||||
function handleSubmit() {
|
||||
if (!canSubmit.value) return
|
||||
emit('submit', {
|
||||
name: name.value.trim(),
|
||||
description: description.value.trim(),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal :show="show" @close="emit('close')">
|
||||
<form class="character-form" @submit.prevent="handleSubmit">
|
||||
<h2 class="form-title">{{ title }}</h2>
|
||||
|
||||
<BaseInput
|
||||
v-model="name"
|
||||
label="角色名称"
|
||||
:maxlength="100"
|
||||
required
|
||||
/>
|
||||
|
||||
<div class="desc-field">
|
||||
<BaseTextarea
|
||||
v-model="description"
|
||||
label="角色描述"
|
||||
:rows="6"
|
||||
:maxlength="1000"
|
||||
:max-height="300"
|
||||
/>
|
||||
<span class="desc-counter" :class="{ 'desc-counter--warn': descLength > 300 }">
|
||||
{{ descHint }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<BaseButton variant="secondary" type="button" @click="emit('close')">取消</BaseButton>
|
||||
<BaseButton variant="primary" type="submit" :loading="saving" :disabled="!canSubmit">
|
||||
{{ isEdit ? '保存' : '创建' }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</form>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.character-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: var(--ink-900);
|
||||
}
|
||||
|
||||
.desc-field {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.desc-counter {
|
||||
display: block;
|
||||
text-align: right;
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink-300);
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
.desc-counter--warn {
|
||||
color: var(--vermilion);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-sm);
|
||||
padding-top: var(--space-sm);
|
||||
}
|
||||
</style>
|
||||
33
frontend/src/components/character/CharacterGrid.vue
Normal file
33
frontend/src/components/character/CharacterGrid.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import type { Character } from '@/types/character'
|
||||
import CharacterCard from './CharacterCard.vue'
|
||||
|
||||
defineProps<{
|
||||
characters: Character[]
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
edit: [character: Character]
|
||||
delete: [character: Character]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="character-grid">
|
||||
<CharacterCard
|
||||
v-for="c in characters"
|
||||
:key="c.id"
|
||||
:character="c"
|
||||
@edit="$emit('edit', $event)"
|
||||
@delete="$emit('delete', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.character-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, watch, onMounted } from 'vue'
|
||||
import { useChatAgent } from '@/composables/useChatAgent'
|
||||
import { isToolCallGroup } from '@/types/chat'
|
||||
import type { ToolCallGroup } from '@/types/chat'
|
||||
import ChatMessageComp from './ChatMessage.vue'
|
||||
import ChatToolCalls from './ChatToolCalls.vue'
|
||||
import ChatConfigModal from './ChatConfigModal.vue'
|
||||
|
||||
defineProps<{
|
||||
@@ -12,28 +15,20 @@ const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const { messages, isStreaming, streamingContent, lastUsage, loadHistory, sendMessage, stopStreaming, clearChat } = useChatAgent()
|
||||
const {
|
||||
entries, isStreaming, streamingContent, lastUsage,
|
||||
toolsEnabled, currentNovelId,
|
||||
loadHistory, sendMessage, approveToolCalls, skipToolCalls,
|
||||
stopStreaming, clearChat,
|
||||
} = useChatAgent()
|
||||
|
||||
const input = ref('')
|
||||
const messagesRef = ref<HTMLElement>()
|
||||
const showConfig = ref(false)
|
||||
|
||||
// 常见模型的上下文窗口大小
|
||||
const MODEL_CONTEXT: Record<string, number> = {
|
||||
'gpt-4o': 128000,
|
||||
'gpt-4o-mini': 128000,
|
||||
'gpt-4-turbo': 128000,
|
||||
'gpt-4': 8192,
|
||||
'gpt-3.5-turbo': 16385,
|
||||
'claude-sonnet-4-20250514': 200000,
|
||||
'claude-opus-4-20250514': 200000,
|
||||
'claude-haiku-4-5-20251001': 200000,
|
||||
'claude-3-5-sonnet-20241022': 200000,
|
||||
'claude-3-opus-20240229': 200000,
|
||||
}
|
||||
// Token用量
|
||||
const DEFAULT_CONTEXT = 128000
|
||||
|
||||
// Token用量
|
||||
const totalUsed = computed(() => {
|
||||
if (!lastUsage.value) return 0
|
||||
const u = lastUsage.value
|
||||
@@ -54,6 +49,11 @@ const usageLabel = computed(() => {
|
||||
return `${total.toLocaleString()} / ${contextLimit.value.toLocaleString()} tokens(输入 ${u.prompt_tokens.toLocaleString()} · 输出 ${u.completion_tokens.toLocaleString()})`
|
||||
})
|
||||
|
||||
// 是否有待审批的工具调用
|
||||
const hasPendingTools = computed(() =>
|
||||
entries.value.some(e => isToolCallGroup(e) && e.status === 'pending')
|
||||
)
|
||||
|
||||
function scrollToBottom() {
|
||||
nextTick(() => {
|
||||
if (messagesRef.value) {
|
||||
@@ -64,7 +64,7 @@ function scrollToBottom() {
|
||||
|
||||
async function handleSend() {
|
||||
const text = input.value.trim()
|
||||
if (!text || isStreaming.value) return
|
||||
if (!text || isStreaming.value || hasPendingTools.value) return
|
||||
input.value = ''
|
||||
await sendMessage(text)
|
||||
}
|
||||
@@ -76,8 +76,16 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApprove(group: ToolCallGroup) {
|
||||
await approveToolCalls(group)
|
||||
}
|
||||
|
||||
function handleSkip(group: ToolCallGroup) {
|
||||
skipToolCalls(group)
|
||||
}
|
||||
|
||||
// 自动滚动
|
||||
watch([messages, streamingContent], scrollToBottom)
|
||||
watch([entries, streamingContent], scrollToBottom)
|
||||
|
||||
onMounted(() => {
|
||||
loadHistory()
|
||||
@@ -87,7 +95,7 @@ onMounted(() => {
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="panel">
|
||||
<div v-if="open" class="chat-overlay" @click.self="emit('close')">
|
||||
<div v-if="open" class="chat-overlay">
|
||||
<div class="chat-panel">
|
||||
<!-- 头部 -->
|
||||
<div class="panel-header">
|
||||
@@ -98,6 +106,17 @@ onMounted(() => {
|
||||
<path d="M2 4h12M5 4V2.5h6V4M3.5 4v9.5h9V4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="currentNovelId"
|
||||
class="icon-btn"
|
||||
:class="{ 'icon-btn--active': toolsEnabled }"
|
||||
:title="toolsEnabled ? '工具已启用(点击关闭)' : '工具已关闭(点击开启)'"
|
||||
@click="toolsEnabled = !toolsEnabled"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M9.5 1.5L13.5 5.5L6.5 12.5H2.5V8.5L9.5 1.5Z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="icon-btn" title="AI配置" @click="showConfig = true">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="2.5" stroke="currentColor" stroke-width="1.2" />
|
||||
@@ -126,17 +145,26 @@ onMounted(() => {
|
||||
|
||||
<!-- 消息区 -->
|
||||
<div ref="messagesRef" class="panel-messages">
|
||||
<div v-if="messages.length === 0 && !isStreaming" class="empty-chat">
|
||||
<div v-if="entries.length === 0 && !isStreaming" class="empty-chat">
|
||||
<p class="empty-hint">有什么可以帮你的?</p>
|
||||
<p class="empty-sub">关于小说创作的任何问题,都可以问我</p>
|
||||
</div>
|
||||
|
||||
<ChatMessageComp
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
:role="msg.role"
|
||||
:content="msg.content"
|
||||
/>
|
||||
<template v-for="entry in entries" :key="entry.id">
|
||||
<!-- 工具调用组 -->
|
||||
<ChatToolCalls
|
||||
v-if="isToolCallGroup(entry)"
|
||||
:group="entry"
|
||||
@approve="handleApprove(entry as ToolCallGroup)"
|
||||
@skip="handleSkip(entry as ToolCallGroup)"
|
||||
/>
|
||||
<!-- 普通消息 -->
|
||||
<ChatMessageComp
|
||||
v-else
|
||||
:role="entry.role"
|
||||
:content="entry.content"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 流式消息 -->
|
||||
<ChatMessageComp
|
||||
@@ -159,13 +187,13 @@ onMounted(() => {
|
||||
class="input-field"
|
||||
placeholder="输入消息..."
|
||||
rows="1"
|
||||
:disabled="isStreaming"
|
||||
:disabled="isStreaming || hasPendingTools"
|
||||
@keydown="handleKeydown"
|
||||
/>
|
||||
<button
|
||||
v-if="!isStreaming"
|
||||
class="send-btn"
|
||||
:disabled="!input.trim()"
|
||||
:disabled="!input.trim() || hasPendingTools"
|
||||
@click="handleSend"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
@@ -194,6 +222,7 @@ onMounted(() => {
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 面板 */
|
||||
@@ -206,6 +235,7 @@ onMounted(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: -8px 0 30px rgba(26, 26, 46, 0.1);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* 头部 */
|
||||
@@ -249,6 +279,16 @@ onMounted(() => {
|
||||
color: var(--ink-900);
|
||||
}
|
||||
|
||||
.icon-btn--active {
|
||||
color: var(--bamboo);
|
||||
background: rgba(91, 140, 90, 0.1);
|
||||
}
|
||||
|
||||
.icon-btn--active:hover {
|
||||
color: var(--bamboo);
|
||||
background: rgba(91, 140, 90, 0.18);
|
||||
}
|
||||
|
||||
/* Token用量进度条 */
|
||||
.usage-bar {
|
||||
padding: var(--space-sm) var(--space-lg);
|
||||
|
||||
337
frontend/src/components/chat/ChatToolCalls.vue
Normal file
337
frontend/src/components/chat/ChatToolCalls.vue
Normal file
@@ -0,0 +1,337 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { ToolCallGroup, ToolCallEntry } from '@/types/chat'
|
||||
|
||||
const props = defineProps<{
|
||||
group: ToolCallGroup
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
approve: []
|
||||
skip: []
|
||||
}>()
|
||||
|
||||
const isPending = computed(() => props.group.status === 'pending')
|
||||
const isExecuting = computed(() => props.group.status === 'executing')
|
||||
const isDone = computed(() => props.group.status === 'done')
|
||||
const isSkipped = computed(() => props.group.status === 'skipped')
|
||||
|
||||
// 工具图标(SVG path)
|
||||
const TOOL_ICONS: Record<string, string> = {
|
||||
list_outlines: 'M3 4h10M3 8h10M3 12h6',
|
||||
create_outline: 'M8 3v10M3 8h10',
|
||||
update_outline: 'M3 12l2-2 4 4-2 2H3v-4zM9 6l2-2 4 4-2 2-4-4z',
|
||||
delete_outline: 'M3 5h10M5 5V3h6v2M4 5v8h8V5',
|
||||
get_world_setting: 'M8 1a7 7 0 100 14A7 7 0 008 1zM1 8h14M8 1c2 2 3 4.5 3 7s-1 5-3 7M8 1c-2 2-3 4.5-3 7s1 5 3 7',
|
||||
save_world_setting: 'M8 1a7 7 0 100 14A7 7 0 008 1zM4 8l3 3 5-5',
|
||||
}
|
||||
|
||||
// 工具颜色主题
|
||||
const TOOL_COLORS: Record<string, string> = {
|
||||
list_outlines: 'var(--bamboo)',
|
||||
create_outline: 'var(--bamboo)',
|
||||
update_outline: '#d4a017',
|
||||
delete_outline: 'var(--danger)',
|
||||
delete_world_setting: 'var(--danger)',
|
||||
get_world_setting: 'var(--ink-500)',
|
||||
save_world_setting: 'var(--bamboo)',
|
||||
}
|
||||
|
||||
function getIcon(name: string): string {
|
||||
return TOOL_ICONS[name] ?? 'M8 3v10M3 8h10'
|
||||
}
|
||||
|
||||
function getColor(name: string): string {
|
||||
return TOOL_COLORS[name] ?? 'var(--ink-400)'
|
||||
}
|
||||
|
||||
/** 格式化参数为可读文本 */
|
||||
function formatArgs(call: ToolCallEntry): string {
|
||||
const args = call.arguments
|
||||
if (!args || Object.keys(args).length === 0) return ''
|
||||
|
||||
switch (call.name) {
|
||||
case 'create_outline':
|
||||
return args.summary as string ?? ''
|
||||
case 'update_outline':
|
||||
return `#${args.outline_id}` + (args.summary ? ` → ${args.summary}` : '')
|
||||
case 'delete_outline':
|
||||
return `#${args.outline_id}`
|
||||
case 'save_world_setting': {
|
||||
const content = (args.content as string) ?? ''
|
||||
return content.length > 80 ? content.slice(0, 80) + '…' : content
|
||||
}
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** 格式化工具结果 */
|
||||
function formatResult(call: ToolCallEntry): string {
|
||||
if (!call.result) return ''
|
||||
try {
|
||||
const data = call.parsedResult ?? JSON.parse(call.result)
|
||||
switch (call.name) {
|
||||
case 'list_outlines': {
|
||||
const items = (data as { outlines: { id: number; summary: string }[] }).outlines
|
||||
if (!items?.length) return '暂无大纲'
|
||||
return items.map((o: { id: number; summary: string }) => `• ${o.summary}`).join('\n')
|
||||
}
|
||||
case 'create_outline':
|
||||
return `已创建: ${(data as { summary: string }).summary}`
|
||||
case 'update_outline':
|
||||
if ((data as { error?: string }).error) return `失败: ${(data as { error: string }).error}`
|
||||
return `已更新: ${(data as { summary: string }).summary}`
|
||||
case 'delete_outline':
|
||||
if ((data as { error?: string }).error) return `失败: ${(data as { error: string }).error}`
|
||||
return '已删除'
|
||||
case 'get_world_setting': {
|
||||
const content = (data as { content: string }).content
|
||||
if (!content) return '暂无世界观设定'
|
||||
return content.length > 120 ? content.slice(0, 120) + '…' : content
|
||||
}
|
||||
case 'save_world_setting':
|
||||
return `已保存 (${(data as { content_length: number }).content_length} 字)`
|
||||
default:
|
||||
return call.result.slice(0, 100)
|
||||
}
|
||||
} catch {
|
||||
return call.result.slice(0, 100)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tool-calls-card">
|
||||
<div class="tool-calls-header">
|
||||
<span class="tool-calls-title">工具调用</span>
|
||||
<span v-if="isPending" class="tool-calls-badge badge--pending">待确认</span>
|
||||
<span v-else-if="isExecuting" class="tool-calls-badge badge--executing">执行中</span>
|
||||
<span v-else-if="isDone" class="tool-calls-badge badge--done">已完成</span>
|
||||
<span v-else-if="isSkipped" class="tool-calls-badge badge--skipped">已跳过</span>
|
||||
</div>
|
||||
|
||||
<!-- 每个工具调用 -->
|
||||
<div
|
||||
v-for="call in group.calls"
|
||||
:key="call.id"
|
||||
class="tool-item"
|
||||
:class="{ 'tool-item--danger': call.name.includes('delete') }"
|
||||
>
|
||||
<div class="tool-item-header">
|
||||
<svg class="tool-item-icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path :d="getIcon(call.name)" :stroke="getColor(call.name)" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" fill="none" />
|
||||
</svg>
|
||||
<span class="tool-item-label" :style="{ color: getColor(call.name) }">{{ call.label }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 参数 -->
|
||||
<div v-if="formatArgs(call)" class="tool-item-args">{{ formatArgs(call) }}</div>
|
||||
|
||||
<!-- 结果 -->
|
||||
<div v-if="call.result" class="tool-item-result">
|
||||
<pre class="result-text">{{ formatResult(call) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 审批按钮 -->
|
||||
<div v-if="isPending" class="tool-actions">
|
||||
<button class="tool-btn tool-btn--approve" @click="emit('approve')">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M3 7l3 3 5-5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
执行
|
||||
</button>
|
||||
<button class="tool-btn tool-btn--skip" @click="emit('skip')">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M10 4L4 10M4 4l6 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
跳过
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 执行中指示 -->
|
||||
<div v-if="isExecuting" class="tool-executing">
|
||||
<span class="dot" /><span class="dot" /><span class="dot" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tool-calls-card {
|
||||
background: var(--paper-50);
|
||||
border: 1px solid var(--paper-200);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
margin-bottom: var(--space-md);
|
||||
animation: fadeInUp var(--duration-fast) var(--ease-out-smooth) both;
|
||||
}
|
||||
|
||||
.tool-calls-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.tool-calls-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink-400);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.tool-calls-badge {
|
||||
font-size: 0.68rem;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge--pending {
|
||||
background: rgba(212, 160, 23, 0.12);
|
||||
color: #b8860b;
|
||||
}
|
||||
|
||||
.badge--executing {
|
||||
background: rgba(91, 140, 90, 0.12);
|
||||
color: var(--bamboo);
|
||||
animation: toolPulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.badge--done {
|
||||
background: rgba(91, 140, 90, 0.12);
|
||||
color: var(--bamboo);
|
||||
}
|
||||
|
||||
.badge--skipped {
|
||||
background: var(--paper-100);
|
||||
color: var(--ink-300);
|
||||
}
|
||||
|
||||
.tool-item {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--paper-100);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.tool-item--danger {
|
||||
background: rgba(220, 53, 69, 0.04);
|
||||
}
|
||||
|
||||
.tool-item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tool-item-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tool-item-label {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tool-item-args {
|
||||
font-size: 0.78rem;
|
||||
color: var(--ink-500);
|
||||
margin-top: 2px;
|
||||
padding-left: 22px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tool-item-result {
|
||||
margin-top: 4px;
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
.result-text {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink-600);
|
||||
background: var(--paper-50);
|
||||
border: 1px solid var(--paper-200);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
/* 审批按钮 */
|
||||
.tool-actions {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-sm);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.tool-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.tool-btn--approve {
|
||||
background: var(--bamboo);
|
||||
border-color: var(--bamboo);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tool-btn--approve:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.tool-btn--skip {
|
||||
background: var(--paper-50);
|
||||
border-color: var(--paper-300);
|
||||
color: var(--ink-500);
|
||||
}
|
||||
|
||||
.tool-btn--skip:hover {
|
||||
background: var(--paper-100);
|
||||
color: var(--ink-700);
|
||||
}
|
||||
|
||||
/* 执行中 */
|
||||
.tool-executing {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
justify-content: center;
|
||||
padding: var(--space-xs) 0;
|
||||
}
|
||||
|
||||
.tool-executing .dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--bamboo);
|
||||
animation: typingBounce 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.tool-executing .dot:nth-child(2) { animation-delay: 0.2s; }
|
||||
.tool-executing .dot:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes toolPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
@keyframes typingBounce {
|
||||
0%, 60%, 100% { transform: translateY(0); }
|
||||
30% { transform: translateY(-4px); }
|
||||
}
|
||||
</style>
|
||||
@@ -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<ChatMessage[]>([])
|
||||
const entries = ref<ChatEntry[]>([])
|
||||
const isStreaming = ref(false)
|
||||
const streamingContent = ref('')
|
||||
const lastUsage = ref<TokenUsage | null>(null)
|
||||
const toolsEnabled = ref(true)
|
||||
let abortController: AbortController | null = null
|
||||
let idCounter = Date.now()
|
||||
|
||||
// 路由名称 → 页面上下文描述
|
||||
const PAGE_LABELS: Record<string, string> = {
|
||||
'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,
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
29
frontend/src/types/chapter.ts
Normal file
29
frontend/src/types/chapter.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export interface Chapter {
|
||||
id: number
|
||||
novel_id: number
|
||||
sort_order: number
|
||||
title: string
|
||||
content: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ChapterCreate {
|
||||
title: string
|
||||
content?: string
|
||||
}
|
||||
|
||||
export interface ChapterUpdate {
|
||||
title?: string
|
||||
content?: string
|
||||
sort_order?: number
|
||||
}
|
||||
|
||||
export interface ChapterList {
|
||||
items: Chapter[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface ChapterReorder {
|
||||
ordered_ids: number[]
|
||||
}
|
||||
23
frontend/src/types/character.ts
Normal file
23
frontend/src/types/character.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export interface Character {
|
||||
id: number
|
||||
novel_id: number
|
||||
name: string
|
||||
description: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface CharacterCreate {
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface CharacterUpdate {
|
||||
name?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface CharacterList {
|
||||
items: Character[]
|
||||
total: number
|
||||
}
|
||||
@@ -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<string, unknown>
|
||||
result?: string
|
||||
parsedResult?: unknown
|
||||
}
|
||||
|
||||
/** 对话条目:普通消息或工具调用组 */
|
||||
export type ChatEntry = ChatMessage | ToolCallGroup
|
||||
|
||||
export function isToolCallGroup(entry: ChatEntry): entry is ToolCallGroup {
|
||||
return 'type' in entry && entry.type === 'tool_calls'
|
||||
}
|
||||
|
||||
export interface ChatMessageList {
|
||||
items: ChatMessage[]
|
||||
total: number
|
||||
|
||||
11
frontend/src/types/worldSetting.ts
Normal file
11
frontend/src/types/worldSetting.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export interface WorldSetting {
|
||||
id: number
|
||||
novel_id: number
|
||||
content: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface WorldSettingUpdate {
|
||||
content: string
|
||||
}
|
||||
340
frontend/src/views/ChapterView.vue
Normal file
340
frontend/src/views/ChapterView.vue
Normal file
@@ -0,0 +1,340 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { fetchNovel } from '@/api/novels'
|
||||
import { fetchChapters, createChapter, updateChapter, deleteChapter, reorderChapters } from '@/api/chapters'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import type { Novel } from '@/types/novel'
|
||||
import type { Chapter } from '@/types/chapter'
|
||||
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||
import ChapterList from '@/components/chapter/ChapterList.vue'
|
||||
import ChapterEditor from '@/components/chapter/ChapterEditor.vue'
|
||||
import ChapterEmptyState from '@/components/chapter/ChapterEmptyState.vue'
|
||||
import ChapterFormModal from '@/components/chapter/ChapterFormModal.vue'
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
const novelId = Number(route.params.id)
|
||||
const novel = ref<Novel | null>(null)
|
||||
const chapters = ref<Chapter[]>([])
|
||||
const loading = ref(true)
|
||||
|
||||
// 当前选中的章节(用于编辑)
|
||||
const activeChapter = ref<Chapter | null>(null)
|
||||
|
||||
// 表单弹窗(新建/重命名)
|
||||
const showForm = ref(false)
|
||||
const editingChapter = ref<Chapter | null>(null)
|
||||
const formSaving = ref(false)
|
||||
|
||||
// 编辑器保存状态
|
||||
const editorSaving = ref(false)
|
||||
|
||||
// 删除确认
|
||||
const showDeleteConfirm = ref(false)
|
||||
const deletingChapter = ref<Chapter | null>(null)
|
||||
const deleting = ref(false)
|
||||
|
||||
const totalChapters = computed(() => chapters.value.length)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [n, list] = await Promise.all([
|
||||
fetchNovel(novelId),
|
||||
fetchChapters(novelId),
|
||||
])
|
||||
novel.value = n
|
||||
chapters.value = list.items
|
||||
} catch {
|
||||
toast.error('加载失败')
|
||||
router.push({ name: 'home' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingChapter.value = null
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
function openRename(chapter: Chapter) {
|
||||
editingChapter.value = chapter
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
function selectChapter(chapter: Chapter) {
|
||||
activeChapter.value = chapter
|
||||
}
|
||||
|
||||
function backToList() {
|
||||
activeChapter.value = null
|
||||
}
|
||||
|
||||
async function handleFormSubmit(data: { title: string }) {
|
||||
formSaving.value = true
|
||||
try {
|
||||
if (editingChapter.value) {
|
||||
const updated = await updateChapter(novelId, editingChapter.value.id, { title: data.title })
|
||||
const idx = chapters.value.findIndex(c => c.id === updated.id)
|
||||
if (idx !== -1) chapters.value[idx] = updated
|
||||
if (activeChapter.value?.id === updated.id) activeChapter.value = updated
|
||||
toast.success('章节已重命名')
|
||||
} else {
|
||||
const created = await createChapter(novelId, { title: data.title })
|
||||
chapters.value.push(created)
|
||||
activeChapter.value = created
|
||||
toast.success('章节已创建')
|
||||
}
|
||||
showForm.value = false
|
||||
} catch {
|
||||
toast.error('保存失败')
|
||||
} finally {
|
||||
formSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEditorSave(data: { title: string; content: string }) {
|
||||
if (!activeChapter.value) return
|
||||
editorSaving.value = true
|
||||
try {
|
||||
const updated = await updateChapter(novelId, activeChapter.value.id, {
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
})
|
||||
const idx = chapters.value.findIndex(c => c.id === updated.id)
|
||||
if (idx !== -1) chapters.value[idx] = updated
|
||||
activeChapter.value = updated
|
||||
toast.success('章节已保存')
|
||||
} catch {
|
||||
toast.error('保存失败')
|
||||
} finally {
|
||||
editorSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(chapter: Chapter) {
|
||||
deletingChapter.value = chapter
|
||||
showDeleteConfirm.value = true
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deletingChapter.value) return
|
||||
deleting.value = true
|
||||
try {
|
||||
await deleteChapter(novelId, deletingChapter.value.id)
|
||||
if (activeChapter.value?.id === deletingChapter.value.id) {
|
||||
activeChapter.value = null
|
||||
}
|
||||
chapters.value = chapters.value.filter(c => c.id !== deletingChapter.value!.id)
|
||||
toast.success('章节已删除')
|
||||
showDeleteConfirm.value = false
|
||||
} catch {
|
||||
toast.error('删除失败')
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReorder(reordered: Chapter[]) {
|
||||
chapters.value = reordered
|
||||
try {
|
||||
const result = await reorderChapters(novelId, reordered.map(c => c.id))
|
||||
chapters.value = result.items
|
||||
} catch {
|
||||
toast.error('排序保存失败')
|
||||
await load()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!loading && novel" class="chapter-view">
|
||||
<div class="chapter-header">
|
||||
<BaseButton variant="ghost" @click="router.push({ name: 'novel-detail', params: { id: novelId } })">
|
||||
← 返回
|
||||
</BaseButton>
|
||||
<div class="header-center">
|
||||
<h1 class="chapter-title">{{ novel.title }}</h1>
|
||||
<p class="chapter-subtitle">章节管理 · 共 {{ totalChapters }} 章</p>
|
||||
</div>
|
||||
<BaseButton v-if="totalChapters > 0" variant="primary" @click="openCreate">
|
||||
新建章节
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<template v-if="totalChapters > 0">
|
||||
<!-- 双栏布局 -->
|
||||
<div class="chapter-layout">
|
||||
<aside class="chapter-sidebar" :class="{ 'chapter-sidebar--hidden': activeChapter }">
|
||||
<ChapterList
|
||||
:chapters="chapters"
|
||||
:active-id="activeChapter?.id ?? null"
|
||||
@select="selectChapter"
|
||||
@edit="openRename"
|
||||
@delete="confirmDelete"
|
||||
@reorder="handleReorder"
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<main v-if="activeChapter" class="chapter-main">
|
||||
<ChapterEditor
|
||||
:chapter="activeChapter"
|
||||
:saving="editorSaving"
|
||||
@save="handleEditorSave"
|
||||
@back="backToList"
|
||||
/>
|
||||
</main>
|
||||
|
||||
<div v-else class="chapter-placeholder">
|
||||
<p>选择一个章节开始编辑</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ChapterEmptyState v-else @create="openCreate" />
|
||||
|
||||
<ChapterFormModal
|
||||
:show="showForm"
|
||||
:chapter="editingChapter"
|
||||
:saving="formSaving"
|
||||
@submit="handleFormSubmit"
|
||||
@close="showForm = false"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:show="showDeleteConfirm"
|
||||
title="确认删除"
|
||||
:message="`确定要删除「${deletingChapter?.title}」吗?章节内容将无法恢复。`"
|
||||
confirm-text="删除"
|
||||
:loading="deleting"
|
||||
@confirm="handleDelete"
|
||||
@cancel="showDeleteConfirm = false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loading" class="chapter-loading">
|
||||
<div class="loading-pulse" style="height: 2rem; width: 40%; margin: 0 auto var(--space-lg)" />
|
||||
<div class="loading-pulse" style="height: 1rem; width: 25%; margin: 0 auto var(--space-2xl)" />
|
||||
<div class="loading-layout">
|
||||
<div class="loading-pulse" style="height: 300px; width: 280px" />
|
||||
<div class="loading-pulse" style="height: 300px; flex: 1" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chapter-view {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
animation: fadeInUp var(--duration-normal) var(--ease-out-smooth) both;
|
||||
}
|
||||
|
||||
.chapter-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-2xl);
|
||||
}
|
||||
|
||||
.header-center {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.chapter-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--ink-900);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.chapter-subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--ink-400);
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
/* 双栏布局 */
|
||||
.chapter-layout {
|
||||
display: flex;
|
||||
gap: var(--space-xl);
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.chapter-sidebar {
|
||||
width: 300px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chapter-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: var(--paper-50);
|
||||
border: 1px solid var(--paper-200);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-xl);
|
||||
}
|
||||
|
||||
.chapter-placeholder {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--paper-50);
|
||||
border: 1px dashed var(--paper-300);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--ink-300);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.chapter-loading {
|
||||
max-width: 1100px;
|
||||
margin: var(--space-3xl) auto;
|
||||
}
|
||||
|
||||
.loading-layout {
|
||||
display: flex;
|
||||
gap: var(--space-xl);
|
||||
}
|
||||
|
||||
.loading-pulse {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--paper-100) 25%,
|
||||
var(--paper-200) 50%,
|
||||
var(--paper-100) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
border-radius: var(--radius-sm);
|
||||
animation: shimmer 1.5s ease infinite;
|
||||
}
|
||||
|
||||
/* 小屏适配:隐藏侧边栏,编辑器全屏 */
|
||||
@media (max-width: 768px) {
|
||||
.chapter-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chapter-sidebar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chapter-sidebar--hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chapter-placeholder {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
215
frontend/src/views/CharacterView.vue
Normal file
215
frontend/src/views/CharacterView.vue
Normal file
@@ -0,0 +1,215 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { fetchNovel } from '@/api/novels'
|
||||
import { fetchCharacters, createCharacter, updateCharacter, deleteCharacter } from '@/api/characters'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import type { Novel } from '@/types/novel'
|
||||
import type { Character, CharacterCreate, CharacterUpdate } from '@/types/character'
|
||||
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||
import CharacterGrid from '@/components/character/CharacterGrid.vue'
|
||||
import CharacterEmptyState from '@/components/character/CharacterEmptyState.vue'
|
||||
import CharacterFormModal from '@/components/character/CharacterFormModal.vue'
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
const novelId = Number(route.params.id)
|
||||
const novel = ref<Novel | null>(null)
|
||||
const characters = ref<Character[]>([])
|
||||
const loading = ref(true)
|
||||
|
||||
// 表单弹窗
|
||||
const showForm = ref(false)
|
||||
const editingCharacter = ref<Character | null>(null)
|
||||
const formSaving = ref(false)
|
||||
|
||||
// 删除确认
|
||||
const showDeleteConfirm = ref(false)
|
||||
const deletingCharacter = ref<Character | null>(null)
|
||||
const deleting = ref(false)
|
||||
|
||||
const totalCharacters = computed(() => characters.value.length)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [n, list] = await Promise.all([
|
||||
fetchNovel(novelId),
|
||||
fetchCharacters(novelId),
|
||||
])
|
||||
novel.value = n
|
||||
characters.value = list.items
|
||||
} catch {
|
||||
toast.error('加载失败')
|
||||
router.push({ name: 'home' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingCharacter.value = null
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
function openEdit(character: Character) {
|
||||
editingCharacter.value = character
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
async function handleFormSubmit(data: CharacterCreate | CharacterUpdate) {
|
||||
formSaving.value = true
|
||||
try {
|
||||
if (editingCharacter.value) {
|
||||
const updated = await updateCharacter(novelId, editingCharacter.value.id, data as CharacterUpdate)
|
||||
const idx = characters.value.findIndex(c => c.id === updated.id)
|
||||
if (idx !== -1) characters.value[idx] = updated
|
||||
toast.success('角色已更新')
|
||||
} else {
|
||||
const created = await createCharacter(novelId, data as CharacterCreate)
|
||||
characters.value.push(created)
|
||||
toast.success('角色已创建')
|
||||
}
|
||||
showForm.value = false
|
||||
} catch {
|
||||
toast.error('保存失败')
|
||||
} finally {
|
||||
formSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(character: Character) {
|
||||
deletingCharacter.value = character
|
||||
showDeleteConfirm.value = true
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deletingCharacter.value) return
|
||||
deleting.value = true
|
||||
try {
|
||||
await deleteCharacter(novelId, deletingCharacter.value.id)
|
||||
characters.value = characters.value.filter(c => c.id !== deletingCharacter.value!.id)
|
||||
toast.success('角色已删除')
|
||||
showDeleteConfirm.value = false
|
||||
} catch {
|
||||
toast.error('删除失败')
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!loading && novel" class="character-view">
|
||||
<div class="character-header">
|
||||
<BaseButton variant="ghost" @click="router.push({ name: 'novel-detail', params: { id: novelId } })">
|
||||
← 返回
|
||||
</BaseButton>
|
||||
<div class="header-center">
|
||||
<h1 class="character-title">{{ novel.title }}</h1>
|
||||
<p class="character-subtitle">角色管理 · 共 {{ totalCharacters }} 个角色</p>
|
||||
</div>
|
||||
<BaseButton v-if="totalCharacters > 0" variant="primary" @click="openCreate">
|
||||
添加角色
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<CharacterGrid
|
||||
v-if="totalCharacters > 0"
|
||||
:characters="characters"
|
||||
@edit="openEdit"
|
||||
@delete="confirmDelete"
|
||||
/>
|
||||
<CharacterEmptyState v-else @create="openCreate" />
|
||||
|
||||
<CharacterFormModal
|
||||
:show="showForm"
|
||||
:character="editingCharacter"
|
||||
:saving="formSaving"
|
||||
@submit="handleFormSubmit"
|
||||
@close="showForm = false"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:show="showDeleteConfirm"
|
||||
title="确认删除"
|
||||
:message="`确定要删除角色「${deletingCharacter?.name}」吗?`"
|
||||
confirm-text="删除"
|
||||
:loading="deleting"
|
||||
@confirm="handleDelete"
|
||||
@cancel="showDeleteConfirm = false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loading" class="character-loading">
|
||||
<div class="loading-pulse" style="height: 2rem; width: 40%; margin: 0 auto var(--space-lg)" />
|
||||
<div class="loading-pulse" style="height: 1rem; width: 25%; margin: 0 auto var(--space-2xl)" />
|
||||
<div class="loading-grid">
|
||||
<div v-for="i in 4" :key="i" class="loading-pulse" style="height: 120px" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.character-view {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
animation: fadeInUp var(--duration-normal) var(--ease-out-smooth) both;
|
||||
}
|
||||
|
||||
.character-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-2xl);
|
||||
}
|
||||
|
||||
.header-center {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.character-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--ink-900);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.character-subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--ink-400);
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.character-loading {
|
||||
max-width: 900px;
|
||||
margin: var(--space-3xl) auto;
|
||||
}
|
||||
|
||||
.loading-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.loading-pulse {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--paper-100) 25%,
|
||||
var(--paper-200) 50%,
|
||||
var(--paper-100) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
border-radius: var(--radius-sm);
|
||||
animation: shimmer 1.5s ease infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -87,6 +87,15 @@ onMounted(load)
|
||||
<RouterLink :to="{ name: 'novel-outlines', params: { id: novel.id } }">
|
||||
<BaseButton variant="primary">大纲</BaseButton>
|
||||
</RouterLink>
|
||||
<RouterLink :to="{ name: 'novel-chapters', params: { id: novel.id } }">
|
||||
<BaseButton variant="primary">章节</BaseButton>
|
||||
</RouterLink>
|
||||
<RouterLink :to="{ name: 'novel-characters', params: { id: novel.id } }">
|
||||
<BaseButton variant="primary">角色</BaseButton>
|
||||
</RouterLink>
|
||||
<RouterLink :to="{ name: 'novel-world-setting', params: { id: novel.id } }">
|
||||
<BaseButton variant="primary">世界观</BaseButton>
|
||||
</RouterLink>
|
||||
<RouterLink :to="{ name: 'novel-edit', params: { id: novel.id } }">
|
||||
<BaseButton variant="secondary">编辑</BaseButton>
|
||||
</RouterLink>
|
||||
|
||||
212
frontend/src/views/WorldSettingView.vue
Normal file
212
frontend/src/views/WorldSettingView.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { fetchNovel } from '@/api/novels'
|
||||
import { fetchWorldSetting, saveWorldSetting } from '@/api/worldSetting'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import type { Novel } from '@/types/novel'
|
||||
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||
import BaseTextarea from '@/components/ui/BaseTextarea.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
const novelId = Number(route.params.id)
|
||||
const novel = ref<Novel | null>(null)
|
||||
const content = ref('')
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const hasChanges = ref(false)
|
||||
|
||||
const contentLength = computed(() => content.value.length)
|
||||
const contentHint = computed(() => {
|
||||
if (contentLength.value === 0) return '建议至少 500 字'
|
||||
if (contentLength.value < 500) return `${contentLength.value} 字,建议至少 500 字`
|
||||
return `${contentLength.value} 字`
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [n, ws] = await Promise.all([
|
||||
fetchNovel(novelId),
|
||||
fetchWorldSetting(novelId),
|
||||
])
|
||||
novel.value = n
|
||||
content.value = ws.content
|
||||
} catch {
|
||||
toast.error('加载失败')
|
||||
router.push({ name: 'home' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onInput() {
|
||||
hasChanges.value = true
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
await saveWorldSetting(novelId, { content: content.value })
|
||||
hasChanges.value = false
|
||||
toast.success('世界观已保存')
|
||||
} catch {
|
||||
toast.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!loading && novel" class="world-view">
|
||||
<div class="world-header">
|
||||
<BaseButton variant="ghost" @click="router.push({ name: 'novel-detail', params: { id: novelId } })">
|
||||
← 返回
|
||||
</BaseButton>
|
||||
<div class="header-center">
|
||||
<h1 class="world-title">{{ novel.title }}</h1>
|
||||
<p class="world-subtitle">世界观设定</p>
|
||||
</div>
|
||||
<BaseButton
|
||||
variant="primary"
|
||||
:loading="saving"
|
||||
:disabled="!hasChanges"
|
||||
@click="handleSave"
|
||||
>
|
||||
保存
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="editor-container">
|
||||
<div class="editor-hint">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.2" />
|
||||
<path d="M8 4v5M8 11v1" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
|
||||
</svg>
|
||||
<span>描述故事发生的世界背景、历史、规则、地理环境等设定</span>
|
||||
</div>
|
||||
|
||||
<BaseTextarea
|
||||
v-model="content"
|
||||
placeholder="在此描述你的故事世界..."
|
||||
:rows="16"
|
||||
:maxlength="5000"
|
||||
:max-height="600"
|
||||
@input="onInput"
|
||||
/>
|
||||
|
||||
<div class="editor-footer">
|
||||
<span class="word-count" :class="{ 'word-count--low': contentLength < 500 && contentLength > 0 }">
|
||||
{{ contentHint }}
|
||||
</span>
|
||||
<span v-if="hasChanges" class="unsaved-hint">未保存的更改</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loading" class="world-loading">
|
||||
<div class="loading-pulse" style="height: 2rem; width: 40%; margin: 0 auto var(--space-lg)" />
|
||||
<div class="loading-pulse" style="height: 1rem; width: 20%; margin: 0 auto var(--space-2xl)" />
|
||||
<div class="loading-pulse" style="height: 300px; width: 100%" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.world-view {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
animation: fadeInUp var(--duration-normal) var(--ease-out-smooth) both;
|
||||
}
|
||||
|
||||
.world-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-2xl);
|
||||
}
|
||||
|
||||
.header-center {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.world-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--ink-900);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.world-subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--ink-400);
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
background: var(--paper-50);
|
||||
border: 1px solid var(--paper-200);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-xl);
|
||||
}
|
||||
|
||||
.editor-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
font-size: 0.85rem;
|
||||
color: var(--ink-400);
|
||||
margin-bottom: var(--space-lg);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--paper-100);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.editor-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.word-count {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink-300);
|
||||
}
|
||||
|
||||
.word-count--low {
|
||||
color: var(--vermilion);
|
||||
}
|
||||
|
||||
.unsaved-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vermilion);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.world-loading {
|
||||
max-width: 800px;
|
||||
margin: var(--space-3xl) auto;
|
||||
}
|
||||
|
||||
.loading-pulse {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--paper-100) 25%,
|
||||
var(--paper-200) 50%,
|
||||
var(--paper-100) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
border-radius: var(--radius-sm);
|
||||
animation: shimmer 1.5s ease infinite;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user