Browse Source

feat: 添加lgs毕业设计项目

lgs 4 months ago
parent
commit
82b3499871

+ 18 - 0
Co-creation-projects/lgs-only-NovelGenerator/.env.example

@@ -0,0 +1,18 @@
+# 模型供应商
+LLM_PROVIDER=ollama # 或 openai, qwen 等
+
+# 模型名称
+LLM_MODEL_ID=qwen3-max
+
+# API密钥
+LLM_API_KEY=your_api_key
+
+# 服务地址
+LLM_BASE_URL=http://localhost:11434/v1 # 如果使用本地 Ollama
+
+# 超时时间(可选,默认60秒)
+LLM_TIMEOUT=60
+
+# 服务器配置
+HOST=127.0.0.1
+PORT=8000

+ 142 - 0
Co-creation-projects/lgs-only-NovelGenerator/README.md

@@ -0,0 +1,142 @@
+# NovelGenerator - 智能小说创作助手
+
+> 一个基于 HelloAgents 框架的智能小说辅助创作系统,助力创作者从灵感到完稿的全过程。
+
+## 📝 项目简介
+
+**NovelGenerator** 旨在利用大语言模型(LLM)的强大能力,为小说创作者提供智能化的辅助工具。它不仅仅是一个简单的文本生成器,而是一个能够理解故事结构、保持剧情连贯、并具备上下文记忆能力的创作伙伴。
+
+该项目解决了长篇小说创作中的核心痛点:
+- **大纲构建困难**:从模糊的灵感到结构化的大纲,AI 帮你梳理逻辑。
+- **剧情连贯性**:在生成后续章节时,自动回顾前文情节和摘要,确保人物行为和剧情发展的合理性。
+- **创作效率低**:支持批量生成章节,快速推进故事进度。
+
+## ✨ 核心功能
+
+- [x] **智能大纲生成**:根据用户输入的一句话创意、标题及标签,自动生成包含世界观、人物设定、分卷规划的详细大纲。
+- [x] **上下文感知章节生成**:基于大纲和前序章节内容,生成连贯的新章节。支持自动回顾前几章摘要和上一章正文。
+- [x] **多章连续创作**:支持一次性生成多个章节,AI 会自动维护剧情发展的连续性。
+- [x] **内容管理系统**:
+    - 自动保存生成的大纲和章节到本地文件(Markdown格式)。
+    - 提供web界面通过 API 接口对内容进行读取、更新和删除。
+- [x] **创作记忆机制**:自动提取并维护章节摘要和预测信息,作为后续创作的长期记忆。
+
+## 🛠️ 技术栈
+
+- **核心框架**: HelloAgents框架 - 提供 Agent 编排与工具调用能力,使用SimpleAgent。
+- **Web 框架**: FastAPI -以此构建高性能的 RESTful API 服务。
+- **数据模型**: Pydantic - 用于数据验证和结构定义。
+- **文件存储**: 本地文件系统 (Markdown + JSON) - 方便用户直接查看和编辑生成的内容。
+- **大语言模型**: 支持兼容 OpenAI 接口的模型(如 DeepSeek, Qwen 等,通过 .env 配置变量)。
+
+## 🚀 快速开始
+
+### 环境要求
+
+- Python 3.10+
+
+### 安装依赖
+
+pip install -r requirements.txt
+
+### 配置环境
+
+1. 在项目根目录创建 `.env` 文件。
+2. 配置你的 LLM 模型信息(参考 HelloAgents 文档或根据实际使用的模型填写):
+
+```
+# .env 示例
+LLM_PROVIDER=ollama # 或 openai, qwen 等
+LLM_MODEL_ID=qwen2.5-72b-instruct
+API_KEY=your_api_key
+BASE_URL=http://localhost:11434/v1 # 如果使用本地 Ollama
+LLM_TIMEOUT=60
+HOST=127.0.0.1
+PORT=8000
+```
+
+### 运行项目
+
+#### 方式一:启动 API 服务(推荐)
+
+启动后端服务,配合前端界面使用。
+
+```bash
+python src/app.py
+# 或者
+uvicorn src.app:app --reload
+```
+
+服务启动后,API 文档可访问:`http://127.0.0.1:8000/docs`
+
+
+#### 方式二:运行测试脚本
+
+如果你想直接在命令行测试生成效果,可以运行 `main.py`:
+
+```bash
+python main.py
+```
+
+## 📖 使用指南
+
+1. **启动服务**:按照上述步骤启动 FastAPI 服务。
+2. **前端交互**:打开 `frontend/index.html`(可以直接在浏览器打开,或通过简单的 HTTP 服务器托管)。
+3. **创作流程**:
+    - **创建项目**:输入小说标题和 ID。
+    - **生成大纲**:输入你的核心创意(如“一个关于AI程序员穿越到代码世界的故事”),点击生成大纲。
+    - **生成章节**:大纲生成确认无误后,进入章节生成页面,输入第一章的简要构思(可选),点击生成。
+    - **查看与修改**:生成的章节会显示在列表中,你可以点击阅读,并进行手动修改保存。
+![NovelGenerator Demo](data/image.png)
+ 
+## 🎯 项目亮点
+
+- **长文本一致性**:通过智能上下文管理和记忆机制,解决长篇生成中的逻辑崩坏问题。
+- **结构化工作流**:还原作家真实创作路径(创意 -> 大纲 -> 章节),而非盲目生成。
+- **数据完全掌控**:所有创作内容以 Markdown 本地存储,安全可控,方便二次编辑。
+- **所见即所得**:提供直观的 Web 界面,实时预览生成效果,支持手动干预与调整。
+
+## 📂 目录结构
+
+```
+NovelGenerator/
+├── agents/                 # Agent 核心逻辑
+│   ├── outline_agent.py    # 大纲生成 Agent
+│   ├── chapter_generate_agent.py # 章节生成 Agent
+│   └── prompt.py           # Prompt 模板
+├── src/                    # API 服务代码
+│   └── app.py              # FastAPI 应用入口
+├── data/                   # 前端图片
+│   └── image.png
+├── frontend/               # 前端界面
+│   └── index.html
+├── outputs/                # 生成结果存储目录
+├── main.py                 # 命令行测试脚本
+└── README.md               # 项目文档
+└── requirements.txt        # 项目依赖
+```
+
+## 🔮 未来计划(待定)
+
+- [ ] 增加回退功能。
+- [ ] 增加人物与事件、技能等知识图谱功能。
+- [ ] 短篇小说生成功能。
+- [ ] 引入更多样的小说风格。
+- [ ] 优化前端界面体验。
+
+## 🤝 贡献指南
+
+欢迎提交 Issue 和 Pull Request!
+
+## 📄 许可证
+
+MIT License
+
+## 👤 作者
+
+- GitHub: [@lgs-only](https://github.com/lgs-only)
+- Email: liangguangshi123@outlook.com
+
+## 🙏 致谢
+
+感谢Datawhale社区和Hello-Agents项目!

+ 381 - 0
Co-creation-projects/lgs-only-NovelGenerator/agents/chapter_generate_agent.py

@@ -0,0 +1,381 @@
+from dotenv import load_dotenv
+load_dotenv()
+import re
+import os
+import json
+from pydantic import BaseModel
+from typing import List, Dict, Any
+from datetime import datetime
+from hello_agents import SimpleAgent, HelloAgentsLLM
+from hello_agents.tools import NoteTool
+from prompt import CHAPTER_PROMPT, CHAPTER_REVIEW_PROMPT, CHAPTER_START_PROMPT
+
+
+def extract_note_id(output: str) -> str:
+    """从 NoteTool 的输出文本中提取 note_id"""
+    match = re.search(r"ID:\s*(note_[0-9_]+)", output)
+    if not match:
+        raise ValueError(f"无法从输出解析 note_id:\n{output}")
+    return match.group(1)
+
+
+class MemoryItem(BaseModel):
+    """记忆项数据结构"""
+    node_id: str
+    novel_id: str
+    title: str
+    content: str
+    summary: str
+    timestamp: datetime
+    metadata: Dict[str, Any] = {}
+    next_chapter_prediction: str = ""
+
+
+class ChapterGenerateAgent:
+    """具有上下文感知能力的 Agent"""
+
+    def __init__(self, name: str, llm: HelloAgentsLLM = HelloAgentsLLM(), max_steps: int = 5, chapter_length: int = 3000, **kwargs):
+
+        self.chapter_length = chapter_length
+        self.max_steps = max_steps
+
+        self.num_chapter_memories = kwargs.get("num_chapter_memories", 5)
+        self.workspace = kwargs.get("workspace", "./outputs")
+        self.note_tools: Dict[str, NoteTool] = {}
+        
+        self.generate_agent = SimpleAgent(name="章节生成助手", llm=llm, system_prompt='你是一位擅长长篇小说结构与文本细化的专业作者助理。')
+        self.review_agent = SimpleAgent(name="章节审核助手", llm=llm, system_prompt='你是一位专业的小说审核助手,负责检查章节是否符合小说的结构和风格。')
+
+        # 内存存储
+        self.memories: Dict[str, List[MemoryItem]] = {}
+
+    @staticmethod
+    def extract_json_from_response(response: str) -> dict:
+        """从模型输出中提取并解析 JSON"""
+        # 尝试清理 Markdown 代码块标记
+        clean_response = re.sub(r"```json\s*", "", response)
+        clean_response = re.sub(r"```\s*$", "", clean_response)
+        clean_response = clean_response.strip()
+        
+        try:
+            return json.loads(clean_response)
+        except json.JSONDecodeError as e:
+            # 如果直接解析失败,尝试在文本中寻找第一个 { 和最后一个 }
+            try:
+                start = clean_response.find("{")
+                end = clean_response.rfind("}")
+                if start != -1 and end != -1:
+                    json_str = clean_response[start : end + 1]
+                    return json.loads(json_str)
+            except Exception:
+                pass
+            raise ValueError(f"无法解析 JSON 响应: {response}") from e
+
+    def _ensure_tool(self, novel_id: str, novel_title: str = None):
+        if not self.note_tools.get(novel_id):
+            if not novel_title:
+                raise ValueError(f"Tool for novel_id {novel_id} not initialized and novel_title not provided.")
+            self.note_tools[novel_id] = NoteTool(workspace=os.path.join(self.workspace, f"{novel_title}-{novel_id}", 'chapters'))
+
+    def get_content_from_note(self, content: str) -> str:
+        try:
+            # 去除 YAML 前置元数据
+            frontmatter_match = re.match(r'^---\s*\n(.*?)\n---\s*\n', content, re.DOTALL)
+            if frontmatter_match:
+                content = content[frontmatter_match.end():].strip()
+            
+            # 去除标题(第一行如果是标题)
+            lines = content.split('\n')
+            if lines and lines[0].startswith('# '):
+                content = '\n'.join(lines[1:]).strip()
+            
+            return content
+        except:
+            return content
+
+    def get_memories(self, novel_id: str):
+        """获取最近章节记忆"""
+        if not hasattr(self.note_tools[novel_id], "notes_index"):
+            self.note_tools[novel_id]._load_index()
+
+        notes = self.note_tools[novel_id].notes_index.get("notes", [])
+
+        # 筛选相关章节笔记
+        chapter_notes = [
+            n for n in notes
+            if n.get("note_type") == "chapter" and str(novel_id) in n.get("title", "")
+        ]
+
+        # 获取最后 N 章
+        recent_notes = chapter_notes[-self.num_chapter_memories:]
+
+        for note in recent_notes:
+            note_id = note.get("id")
+            file_path = os.path.join(self.workspace, f"{note_id}.md")
+
+            if os.path.exists(file_path):
+                with open(file_path, "r", encoding="utf-8") as f:
+                    content = f.read()
+
+                content = self.get_content_from_note(content)
+                self.memories[novel_id].append(MemoryItem(
+                    node_id=str(note_id),
+                    title=note.get("title", "未知章节").strip(),
+                    content=content,
+                    novel_id=str(novel_id),
+                    summary=note['tags'][0]if note.get("tags") and note['tags'] else '',
+                    timestamp=datetime.fromisoformat(note.get("created_at", datetime.now().isoformat()))
+                ))
+
+    def run(self, user_input: str, **kwargs) -> str:
+        """运行 Agent"""
+        # 小说id用来区分小说,命名可能会重复
+        novel_id = kwargs.pop("novel_id", None)
+        assert novel_id, "请提供小说ID"
+
+        novel_title = kwargs.pop("novel_title", None)
+        assert novel_title, "请提供小说标题"
+
+        self._ensure_tool(novel_id, novel_title)
+
+        if not self.memories.get(novel_id):
+            self.memories[novel_id] = []
+            self.get_memories(novel_id)
+
+        # 1. 构建上下文
+        outline = self.get_outline(novel_id)
+        prev_chapter = self.get_prev_chapter(novel_id)
+        prev_summaries = self.get_prev_summaries(novel_id)
+        chapter_length = kwargs.get("chapter_length", self.chapter_length)
+        context = self.get_prompt(outline, prev_chapter, prev_summaries, user_input, novel_id, chapter_length=chapter_length)
+        
+        # 2. 使用上下文调用 LLM
+        steps = 0
+        while steps < self.max_steps:
+            steps += 1
+
+            # 生成章节内容
+            response = self.generate_agent.run(context)
+            try:
+                response_data = self.extract_json_from_response(response)
+                # 检查是否包含必要字段
+                if 'title' not in response_data or 'content' not in response_data or 'next_chapter_prediction' not in response_data or 'summary' not in response_data:
+                    raise ValueError("JSON 响应缺少必要字段 'title' 或 'content' 或 'next_chapter_prediction' 或 'summary'")
+            except ValueError as e:
+                print(f"步骤 {steps} 生成的 JSON 解析错误:{e}")
+                continue
+            
+            # 审核章节内容
+            review_context = CHAPTER_REVIEW_PROMPT.format(
+                outline=outline,
+                prev_chapter=prev_chapter,
+                prev_summaries=prev_summaries,
+                chapter_content=response_data.get('content', '')
+            )
+            review_response = self.review_agent.run(review_context)
+
+            # 检查审核结果
+            if "【通过】" in review_response:
+                break
+            
+            context = self.get_prompt(outline, prev_chapter, prev_summaries, user_input, novel_id, response_data, review_response, chapter_length=chapter_length)
+
+        # 3. 保存章节到笔记
+        create_output = self.note_tools[novel_id].run({
+            "action": "create",
+            "title": f"{response_data.get('title', '未知章节')}",
+            "content": response_data.get('content', ''),
+            "note_type": "chapter",
+            "tags": [response_data.get('summary', '')]
+        })
+
+        # 获取章节笔记ID,保存记忆,并建立与小说ID的关联
+        note_id = extract_note_id(create_output)
+
+        self.memories[novel_id].append(MemoryItem(
+            node_id=note_id,
+            title=response_data.get('title', '未知章节'),
+            content=response_data.get('content', ''),
+            novel_id=novel_id,
+            summary=response_data.get('summary', ''),
+            timestamp=datetime.now().isoformat(),
+            next_chapter_prediction=response_data.get('next_chapter_prediction', '')
+        ))
+
+        return response_data, note_id
+
+    def get_prompt(self, outline: str, prev_chapter: str, prev_summaries: str, user_input: str, novel_id: str, response_data: dict = None, review_response: str = None, chapter_length: int = None) -> str:
+        """获取章节生成提示"""
+        if chapter_length is None:
+            chapter_length = self.chapter_length
+        is_first_chapter = (prev_chapter == '无' and prev_summaries == '无')
+
+        if is_first_chapter:
+            prompt_template = CHAPTER_START_PROMPT
+            context = prompt_template.format(
+                outline=outline,
+                chapter_history='无' if response_data is None else response_data.get('content', '无'),
+                evaluation=review_response or "无",
+                user_input=user_input,
+                chapter_length=chapter_length
+            )
+        else:
+            prompt_template = CHAPTER_PROMPT
+            context = prompt_template.format(
+                outline=outline,
+                prev_chapter=prev_chapter,
+                prev_summaries=prev_summaries,
+                chapter_history='无' if response_data is None else response_data.get('content', '无'),
+                evaluation=review_response or "无",
+                user_input=user_input or [self.memories[novel_id][-1].next_chapter_prediction if self.memories[novel_id] else "无"][0],
+                chapter_length=chapter_length
+            )
+        return context
+
+    def get_outline(self, novel_id: str) -> str:    
+        """获取大纲"""
+        dir_path = f"{os.path.dirname(self.note_tools[novel_id].workspace)}/outline"
+        paths = os.listdir(dir_path)
+        assert len(paths) >= 1, f"目录 {dir_path} 下应该有大纲文件"
+        # 简单取第一个文件,实际可能需要更精确的逻辑
+        path = f"{dir_path}/{paths[0]}"
+        with open(path, "r", encoding='utf-8') as f:
+            outline = f.read()
+        return self.get_content_from_note(outline)
+
+    def get_prev_chapter(self, novel_id: str):
+        """获取前一章内容"""
+        if self.memories.get(novel_id):
+            last_mem = self.memories[novel_id][-1]
+            return f"【{last_mem.metadata.get('title', '未知')}】\n...{last_mem.content[-800:]}"
+        return "无"
+
+    def get_prev_summaries(self, novel_id: str):
+        if self.memories.get(novel_id):
+            return "\n".join([f"【{mem.title}】\n{mem.summary}" for mem in self.memories[novel_id][-self.num_chapter_memories:]])
+        return "无"
+    
+    def del_chapter(self, novel_id:str, note_id: str, novel_title: str = None):
+        """删除章节"""
+        if novel_title:
+            self._ensure_tool(novel_id, novel_title)
+        self.note_tools[novel_id].run({
+            "action": "delete",
+            "note_id": note_id
+        })
+        # 从记忆中删除该章节
+        if self.memories.get(novel_id):
+            self.memories[novel_id] = [mem for mem in self.memories[novel_id] if mem.node_id != note_id]
+
+    def update_chapter(self, novel_id:str, note_id: str, novel_title: str = None, **kwargs):
+        """更新章节"""
+        if novel_title:
+            self._ensure_tool(novel_id, novel_title)
+        self.note_tools[novel_id].run({
+            "action": "update",
+            "note_id": note_id,
+            **kwargs
+        })
+        # 更新记忆中的章节内容
+        if self.memories.get(novel_id):
+            for mem in self.memories[novel_id]:
+                if mem.node_id == note_id:
+                    mem.title = kwargs.get('title', mem.title)
+                    mem.content = kwargs.get('content', mem.content)
+                    mem.summary = kwargs.get('summary', mem.summary)
+                    mem.next_chapter_prediction = kwargs.get('next_chapter_prediction', mem.next_chapter_prediction)
+                    mem.timestamp = datetime.now().isoformat()
+                    break
+
+def main():
+    print("=" * 80)
+    print("Novel ChapterGenerateAgent 示例")
+    print("=" * 80 + "\n")
+
+    # llm = HelloAgentsLLM(model="qwen3:0.6b", api_key="ollama", base_url="http://127.0.0.1:11434/v1", provider='ollama')
+    llm = HelloAgentsLLM(provider='qwen')
+    novel_id = "demo_novel_001"
+    novel_title = "记忆之城"
+
+    # 1. 模拟大纲文件存在
+    # 因为 ChapterGenerateAgent.get_outline 依赖于文件系统查找大纲
+    # 我们手动创建一个假的大纲文件用于测试
+    workspace_root = "./outputs"
+    # 注意:这里模拟 OutlineAgent 的输出路径结构
+    outline_dir = os.path.join(workspace_root, f"{novel_title}-{novel_id}", "outline")
+    if not os.path.exists(outline_dir):
+        os.makedirs(outline_dir)
+    
+    # 清理旧文件以确保测试环境干净
+    for f in os.listdir(outline_dir):
+        try:
+            os.remove(os.path.join(outline_dir, f))
+        except Exception:
+            pass
+        
+    dummy_outline_content = """---
+tags: [outline]
+created_at: 2025-01-27T10:00:00
+---
+# 记忆之城-大纲
+
+## 核心梗概
+一位能与城市记忆对话的年轻人,在拆迁浪潮中发现一段被刻意抹去的历史。
+
+## 主要人物
+- 李寻:主角,拥有"读取"物体记忆的能力。
+- 陈叔:古董店老板,似乎知道李寻身世的秘密。
+
+## 故事走向
+1. 觉醒能力,卷入拆迁冲突。
+2. 发现神秘物品,引出旧事。
+3. ...
+"""
+    dummy_outline_path = os.path.join(outline_dir, f"{novel_id}-outline.md")
+    with open(dummy_outline_path, "w", encoding="utf-8") as f:
+        f.write(dummy_outline_content)
+
+    print(f"已创建模拟大纲文件: {dummy_outline_path}")
+    
+    # 2. 初始化章节生成 Agent
+    chapter_agent = ChapterGenerateAgent(
+        name="小说章节助手",
+        llm=llm,
+        workspace=workspace_root,  # 使用与 OutlineAgent 一致的根目录
+        chapter_length=1000 # 演示用,设短一点
+    )
+
+    # 3. 生成第一章
+    print(f"\n正在生成第一章...")
+    try:
+        # run 方法需要 novel_title 来定位目录
+        chapter_data_1, note_id_1 = chapter_agent.run(
+            user_input="第一章需要通过一个具体的拆迁冲突场景,引出主角的能力。主角李寻在试图保护一家老店不被强拆时,无意中听到了推土机的'心声'。",
+            novel_id=novel_id,
+            novel_title=novel_title 
+        )
+        print(f"第一章生成完成,Note ID: {note_id_1}")
+        print(f"标题: {chapter_data_1.get('title')}")
+        print(f"摘要: {chapter_data_1.get('summary')}")
+        print(f"下一章预测: {chapter_data_1.get('next_chapter_prediction')}")
+
+        # 4. 生成第二章(会自动读取第一章作为上下文)
+        print(f"\n正在生成第二章...")
+        chapter_data_2, note_id_2 = chapter_agent.run(
+            user_input="主角在废墟中发现了一个奇怪的物品,触发了回忆。那个物品似乎在呼唤他。",
+            novel_id=novel_id,
+            novel_title=novel_title
+        )
+        print(f"第二章生成完成,Note ID: {note_id_2}")
+        print(f"标题: {chapter_data_2.get('title')}")
+        print(f"摘要: {chapter_data_2.get('summary')}")
+        
+    except Exception as e:
+        print(f"生成过程中出错: {e}")
+        import traceback
+        traceback.print_exc()
+
+
+
+if __name__ == "__main__":
+    main()

+ 161 - 0
Co-creation-projects/lgs-only-NovelGenerator/agents/outline_agent.py

@@ -0,0 +1,161 @@
+from dotenv import load_dotenv
+load_dotenv()
+from hello_agents import SimpleAgent, HelloAgentsLLM
+from hello_agents.tools import NoteTool
+from prompt import OUTLINE_PROMPT
+import re
+import os
+
+
+def extract_note_id(output: str) -> str:
+    """从 NoteTool 的输出文本中提取 note_id"""
+    match = re.search(r"ID:\s*(note_[0-9_]+)", output)
+    if not match:
+        raise ValueError(f"无法从输出解析 note_id:\n{output}")
+    return match.group(1)
+
+
+class OutlineAgent(SimpleAgent):
+    """小说大纲生成Agent"""
+
+    def __init__(self, name: str, llm: HelloAgentsLLM = HelloAgentsLLM(), **kwargs):
+        self.workspace = kwargs.pop("workspace", "./outputs")
+        super().__init__(name=name, llm=llm)
+        self.outline_length = 3000
+        self.note_tools = {}
+
+    def _ensure_tool(self, novel_id: str, title: str = None):
+        if not self.note_tools.get(novel_id):
+            if not title:
+                raise ValueError(f"Tool for novel_id {novel_id} not initialized and title not provided.")
+            self.note_tools[novel_id] = NoteTool(workspace=os.path.join(self.workspace, f"{title}-{novel_id}", 'outline'))
+
+    def run(self, user_input: str, **kwargs) -> str:
+        """运行 Agent"""
+        # 小说id用来区分小说,命名可能会重复
+        novel_id = kwargs.pop("novel_id", None)
+        assert novel_id, "请提供小说ID"
+
+        title = kwargs.pop("title", None)
+        assert title, "请提供小说标题"
+
+        self._ensure_tool(novel_id, title)
+
+        # 1. 构建上下文
+        target_length = kwargs.pop("target_length", self.outline_length)
+        context = OUTLINE_PROMPT.format(
+            user_input=user_input,
+            title=title or "无",
+            tags=','.join([str(tag) for tag in kwargs.values() if tag]) or '无',
+            target_length=target_length
+        )
+
+        # 2. 使用上下文调用 LLM
+        messages = [{"role": "user", "content": context}]
+        response = self.llm.invoke(messages)
+
+        # 3. 保存大纲到笔记
+        create_output = self.note_tools[novel_id].run({
+            "action": "create",
+            "title": f"{novel_id}-大纲",
+            "content": response,
+            "note_type": "outline",
+            "tags": ["outline"]
+        })
+        # 获取笔记ID,建立与小说ID的关联
+        note_id = extract_note_id(create_output)
+
+        return response, note_id
+
+    def get_outline(self, novel_id: str, note_id: str, title: str = None) -> str:    
+        """获取大纲"""
+        if title:
+            self._ensure_tool(novel_id, title)
+        return self.note_tools[novel_id].run({
+            "action": "read",
+            "note_id": note_id
+        })
+    
+    def del_outline(self, novel_id: str, note_id: str, title: str = None):
+        """删除大纲"""
+        if title:
+            self._ensure_tool(novel_id, title)
+        self.note_tools[novel_id].run({
+            "action": "delete",
+            "note_id": note_id
+        })
+
+    def update_outline(self, novel_id: str, note_id: str, title: str = None, **kwargs):
+        """更新大纲"""
+        if title:
+            self._ensure_tool(novel_id, title)
+        self.note_tools[novel_id].run({
+            "action": "update",
+            "note_id": note_id,
+            **kwargs
+        })
+
+def main():
+    print("=" * 80)
+    print("Novel OutlineAgent 示例")
+    print("=" * 80 + "\n")
+
+    llm = HelloAgentsLLM()
+    novel_id = "demo_novel_001"
+    title = "记忆之城"
+
+    agent = OutlineAgent(
+        name="小说大纲助手",
+        llm=llm,
+        workspace="./outputs",
+    )
+
+    user_idea = "一位能与城市记忆对话的年轻人,在拆迁浪潮中发现一段被刻意抹去的历史。"
+
+    # 1. 生成大纲
+    print(f"\n正在生成大纲...")
+    response, note_id = agent.run(
+        user_input=user_idea,
+        novel_id=novel_id,
+        title=title,
+        风格标签="都市奇幻",
+        情感基调="成长与和解",
+    )
+
+    print("生成的大纲(节选):")
+    print(response[:200] + "...\n")
+    print(f"大纲已保存到 NoteTool,note_id: {note_id}")
+
+    # 2. 读取大纲
+    print(f"\n正在读取大纲 (Note ID: {note_id})...")
+    # 注意:get_outline 需要传入 novel_id 和 note_id
+    stored_outline = agent.get_outline(novel_id, note_id)
+    print("从 NoteTool 中读取的大纲(节选):")
+    # 去掉可能存在的 frontmatter 后的内容预览(这里简单展示原始返回)
+    print(stored_outline[:200] + "...")
+
+    # 3. 更新大纲
+    print(f"\n正在更新大纲...")
+    # 简单模拟:在原有内容后追加一些信息
+    # 注意:update_outline 会覆盖 content,所以需要先读取再追加,或者直接传入完整的新内容
+    # 这里我们演示读取后追加
+    new_content = stored_outline + "\n\n## 补充设定\n主角的能力在雨天会增强,且能听到建筑物的'呼吸声'。"
+    agent.update_outline(novel_id, note_id, content=new_content, tags=["outline", "updated"])
+    print("大纲已更新。")
+
+    # 4. 再次读取验证更新
+    print(f"\n正在验证更新后的内容...")
+    updated_outline = agent.get_outline(novel_id, note_id)
+    if "主角的能力在雨天会增强" in updated_outline:
+        print("验证成功:更新内容已存在。")
+    else:
+        print("验证失败:未找到更新内容。")
+
+    # 5. 删除大纲(演示,默认注释掉以免误删)
+    # print(f"\n正在删除大纲...")
+    # agent.del_outline(novel_id, note_id)
+    # print("大纲已删除。")
+
+
+if __name__ == "__main__":
+    main()

+ 174 - 0
Co-creation-projects/lgs-only-NovelGenerator/agents/prompt.py

@@ -0,0 +1,174 @@
+OUTLINE_PROMPT = """你是一位资深故事架构师与编辑。请基于以下输入,生成一份约{target_length}字的中文长篇小说大纲,要求紧凑清晰、信息密度高。
+
+【用户想法】
+{user_input}
+
+【小说标题】
+{title}
+
+【标签】
+{tags}
+
+【总目标】
+- 字数约{target_length}字(允许±10%),全程使用中文。
+- 提供完整故事脉络:起承转合明确,动机与因果自洽。
+- 保持鲜明辨识度:世界观/结构/母题至少一处明显创新。
+- 采用分卷或分段形式,并细化到章节级要点。
+
+【输出结构】
+一、故事概念与独特性
+- 用数句话概括核心母题与价值主张。
+- 提炼3个左右作品卖点与差异化策略。
+- 指定叙事视角与形式。
+
+二、世界观与设定
+- 概述时空背景与主要社会结构。
+- 列出关键规则/禁忌/代价及其约束效果。
+- 点出若干重要地点或象征物及叙事功能。
+
+三、人物谱系与关系网
+- 主角群:目标、缺陷、成长弧线与彼此关系。
+- 反派或对抗势力:动机、方法、与主线冲突点。
+- 关键配角:在剧情推进、张力制造或主题承载上的功能。
+
+四、叙事结构总览
+- 给出整体结构方案(如三幕/四幕/环形)及各幕核心目标和关节点。
+- 描述全书主题推进与情感曲线(主要高潮、低谷与超越时刻)。
+
+五、分卷/分段规划(核心)
+- 按卷列出:卷标题 + 卷概述(约300–500字)。
+- 每卷给出6–10章章节要点(每章2–3行,标注冲突/悬念/反转)。
+- 概括每卷的阶段性目标、关键事件、人物关系变化与伏笔回收。
+
+六、高潮与关键转折设计
+- 规划至少3个大型高潮:触发条件、冲突构型、代价与后果。
+- 指出主要反转的误导点与真实点,并说明与主题的呼应。
+- 简述高潮后的余波与人物选择,体现成长或坠落。
+
+七、节奏控制与悬念布置
+- 总体推进节奏策略(张弛、留白、信息揭示步幅)。
+- 设计短/中/长三类悬念链,并说明对应关键节点。
+- 要求每卷或分段结尾具备清晰“钩子”(悬念、抉择、危机或新发现)。
+
+八、原创性与防重策略
+- 指出几类市场常见套路,并说明本作的改写或规避方式。
+- 总结本作的原创钩子与不可替代元素(设定/人物关系/结构/意象等)。
+- 给出相似风险评估与必要的规避建议。
+
+九、主题深化与象征系统
+- 说明主题如何通过剧情、人物、景观与语言多维呈现。
+- 设计若干贯穿意象或隐喻,并绑定到关键场景。
+- 交代结尾的主题回应方式(开放/确定/反讽)及读者余味设计。
+
+十、延展与改编可能
+- 提出2–3条可扩展支线及其冲突核心与成长空间。
+- 概述影视/漫画/广播剧等改编潜力与关键视觉化要点。
+
+十一、标签融入策略
+- 将标签具体映射到人物、场景、冲突与意象中。
+- 指定每卷若干与标签强关联的事件或视觉化片段。
+
+十二、写作风格与审美基调
+- 给出文体与语言节奏建议及叙述者语气与距离。
+- 说明预期读者体验的侧重(张力/代入/思辨/反讽等)。
+
+【校验与格式】
+- 确保逻辑自洽、角色驱动与伏笔呼应,避免模板化空话。
+- 每卷结尾具备清晰钩子,原创差异点具体可见。
+- 使用以上十二个一级标题逐条展开,全文为中文,约{target_length}字(允许±10%)。
+"""
+
+CHAPTER_PROMPT = """请基于给定信息,生成一章完整的中文小说内容。
+
+【输入信息】
+- 小说大纲:{outline}
+- 前一章正文:{prev_chapter}
+- 前几章摘要:{prev_summaries}
+- 本章历史生成内容:{chapter_history}
+- 对本章历史生成内容的评判结果:{evaluation}
+- 用户输入或对本章的预测摘要:{user_input}
+
+【生成目标】
+- 输出本章的:本章章节序号与标题、本章摘要、本章正文、下一章摘要预测。
+- 内容需严格遵循大纲与既有剧情,保证人物行为与设定前后一致。
+- 在本章内设置局部高潮或悬念,并为下一章制造明确钩子。
+
+【输出格式】
+请仅输出一个标准的 JSON 对象,不要包含 Markdown 代码块(如 ```json ... ```),格式如下:
+{{
+    "title": "第几章-标题",
+    "summary": "本章摘要(200字以内)",
+    "content": "本章正文内容...",
+    "next_chapter_prediction": "下一章摘要预测(包含核心冲突或悬念焦点)"
+}}
+
+【具体要求】
+- 全文使用中文。
+- 本章正文字数约为{chapter_length}字(允许±10%)。
+- 合理引用前几章摘要和本章预测摘要:既兑现承诺,又保留惊喜。
+- 当预测摘要与大纲或前文冲突时,以大纲与人物逻辑为最高优先级。
+- 在“next_chapter_prediction”中,清楚指出下一章的核心冲突或悬念焦点。
+- 特殊情况:如果判断本章为全书或本卷的大结局:
+  1. "title" 字段请包含“大结局”字样(如“第X章-标题(大结局)”)。
+  2. "next_chapter_prediction" 字段请置为空字符串("")。
+"""
+
+CHAPTER_START_PROMPT = """请基于给定信息,作为开篇章节,生成一章完整的中文小说内容。
+
+【输入信息】
+- 小说大纲:{outline}
+- 本章历史生成内容:{chapter_history}
+- 对本章历史生成内容的评判结果:{evaluation}
+- 用户输入或本章核心事件:{user_input}
+
+【生成目标】
+- 输出本章的:本章章节序号与标题、本章摘要、本章正文、下一章摘要预测。
+- 作为第一章,重点在于:
+  1. 快速建立世界观与氛围,但避免枯燥的设定堆砌。
+  2. 鲜明地引出主角,展现其性格特征或当前处境。
+  3. 设置“激励事件”(Inciting Incident),打破主角的平静生活,开启故事主线。
+- 内容需严格遵循大纲设定。
+- 在本章结尾设置悬念或冲突,吸引读者继续阅读。
+
+【输出格式】
+请仅输出一个标准的 JSON 对象,不要包含 Markdown 代码块(如 ```json ... ```),格式如下:
+{{
+    "title": "第一章-标题",
+    "summary": "本章摘要(200字以内)",
+    "content": "本章正文内容...",
+    "next_chapter_prediction": "下一章摘要预测(包含核心冲突或悬念焦点)"
+}}
+
+【具体要求】
+- 全文使用中文。
+- 本章正文字数约为{chapter_length}字(允许±10%)。
+- 开篇要抓人眼球(黄金三章原则)。
+- 在“next_chapter_prediction”中,清楚指出下一章的核心冲突或悬念焦点。
+"""
+
+CHAPTER_REVIEW_PROMPT = """请对以下新生成的章节进行多维度审核与评判。
+
+【输入信息】
+- 小说大纲:{outline}
+- 前一章正文:{prev_chapter}
+- 前几章摘要:{prev_summaries}
+- 待审核章节:{chapter_content}
+
+【审核维度】
+1. **大纲契合度**:是否偏离主线?人物行为是否符合大纲设定?
+2. **原创性与故事性**:
+   - 是否存在明显的“AI味”(平铺直叙、情感空洞、逻辑机械)?
+   - 故事是否丰满?重点情节是否有详细、生动的描写?
+   - 是否与市面热门网文高度雷同(套路化)?
+3. **人物塑造**:人物性格是否鲜明、丰满?对话与行动是否贴合人设?
+4. **节奏与张力**:是否有足够的戏剧冲突?阅读体验是否流畅?
+
+【输出结构】
+请严格按照以下格式输出评审意见(不要输出多余的开场白):
+- 【通过】
+- 【不通过】**具体修改建议如下**:
+1. [大纲契合度] ...
+2. [原创性与故事性] ...
+3. [人物塑造] ...
+4. [其他建议] ...
+"""

BIN
Co-creation-projects/lgs-only-NovelGenerator/data/image.png


+ 605 - 0
Co-creation-projects/lgs-only-NovelGenerator/frontend/index.html

@@ -0,0 +1,605 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>AI 小说生成器</title>
+    <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
+    <script src="https://cdn.tailwindcss.com"></script>
+    <style>
+        .fade-enter-active, .fade-leave-active {
+            transition: opacity 0.3s ease;
+        }
+        .fade-enter-from, .fade-leave-to {
+            opacity: 0;
+        }
+    </style>
+</head>
+<body class="bg-gray-50 text-gray-800 font-sans min-h-screen">
+    <div id="app" class="max-w-7xl mx-auto p-6">
+        <header class="mb-8 text-center">
+            <h1 class="text-4xl font-extrabold text-blue-600 tracking-tight">AI 小说创作助手</h1>
+            <p class="text-gray-500 mt-2">释放你的创意,让 AI 帮你构建世界</p>
+        </header>
+
+        <!-- 顶部:项目配置区域 (隐式包含 Novel ID) -->
+        <div class="bg-white rounded-xl shadow-lg p-6 mb-8 border border-gray-100">
+            <div class="flex flex-col md:flex-row gap-4 items-end">
+                <div class="flex-grow w-full">
+                    <label class="block text-sm font-bold text-gray-700 mb-1">小说标题</label>
+                    <input v-model="project.title" type="text" class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition shadow-sm" placeholder="请输入你的小说名称">
+                </div>
+                <div class="w-full md:w-auto">
+                     <!-- 只有当标题输入后,才真正去加载或初始化项目 -->
+                    <button @click="initOrLoadProject" :disabled="!project.title || loading" class="w-full md:w-auto px-6 py-3 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition disabled:opacity-50 disabled:cursor-not-allowed shadow-md">
+                        {{ projectLoaded ? '刷新项目' : '开始创作' }}
+                    </button>
+                </div>
+            </div>
+            <div v-if="project.novel_id" class="mt-2 text-xs text-gray-400">
+                项目 ID: {{ project.novel_id }}
+            </div>
+        </div>
+
+        <div v-if="projectLoaded" class="space-y-8">
+            
+            <!-- 第一部分:大纲生成 -->
+            <section class="bg-white rounded-xl shadow-lg border border-gray-100 overflow-hidden">
+                <div class="bg-gradient-to-r from-blue-50 to-indigo-50 px-6 py-4 border-b border-gray-100">
+                    <h2 class="text-xl font-bold text-gray-800 flex items-center">
+                        <span class="bg-blue-600 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs mr-2">1</span>
+                        大纲生成与管理
+                    </h2>
+                </div>
+                
+                <div class="flex flex-col md:flex-row">
+                    <!-- 左侧 1/3:输入与配置 -->
+                    <div class="w-full md:w-1/3 p-6 border-b md:border-b-0 md:border-r border-gray-100 bg-gray-50/50">
+                        <div class="space-y-4">
+                            <div>
+                                <label class="block text-sm font-semibold text-gray-700 mb-1">核心思路 / 故事梗概</label>
+                                <textarea v-model="outlineInput.user_input" class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 shadow-sm h-32" placeholder="例如:一个关于程序员穿越到修仙世界修复天道Bug的故事..."></textarea>
+                            </div>
+                            
+                            <div>
+                                <label class="block text-sm font-semibold text-gray-700 mb-1">预计字数</label>
+                                <input v-model.number="outlineInput.target_length" type="number" class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 shadow-sm" step="1000">
+                            </div>
+
+                            <!-- 频段选择 -->
+                            <div>
+                                <label class="block text-sm font-semibold text-gray-700 mb-2">小说频段</label>
+                                <div class="flex gap-4">
+                                    <label class="flex items-center cursor-pointer">
+                                        <input type="radio" v-model="outlineInput.channel" value="男频" class="form-radio text-blue-600 h-4 w-4">
+                                        <span class="ml-2 text-gray-700">男频</span>
+                                    </label>
+                                    <label class="flex items-center cursor-pointer">
+                                        <input type="radio" v-model="outlineInput.channel" value="女频" class="form-radio text-pink-600 h-4 w-4">
+                                        <span class="ml-2 text-gray-700">女频</span>
+                                    </label>
+                                </div>
+                            </div>
+
+                            <!-- 风格选择 -->
+                            <div>
+                                <label class="block text-sm font-semibold text-gray-700 mb-2">作品风格</label>
+                                <select v-model="outlineInput.style" class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 bg-white shadow-sm">
+                                    <option value="" disabled>请选择风格</option>
+                                    <option v-for="style in currentStyles" :key="style" :value="style">{{ style }}</option>
+                                </select>
+                            </div>
+
+                            <button @click="generateOutline" :disabled="loading" class="w-full py-3 bg-gradient-to-r from-blue-600 to-indigo-600 text-white font-bold rounded-lg hover:from-blue-700 hover:to-indigo-700 transition shadow-md flex items-center justify-center">
+                                <span v-if="loading && currentAction === 'outline'" class="animate-spin mr-2">⟳</span>
+                                {{ outline.id ? '重新生成大纲' : '生成大纲' }}
+                            </button>
+                        </div>
+                    </div>
+
+                    <!-- 右侧 2/3:显示与操作 -->
+                    <div class="w-full md:w-2/3 p-6 flex flex-col">
+                        <div class="flex-grow mb-4">
+                            <label class="block text-sm font-semibold text-gray-700 mb-2">
+                                大纲内容 
+                                <span v-if="!outline.id" class="text-gray-400 font-normal ml-2">(尚未生成)</span>
+                                <span v-else class="text-green-600 font-normal ml-2 text-xs">已保存 (ID: {{outline.id}})</span>
+                            </label>
+                            <textarea v-model="outline.content" class="w-full h-[500px] p-4 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm leading-relaxed bg-white shadow-inner resize-none" placeholder="大纲将显示在这里..."></textarea>
+                        </div>
+                        
+                        <div v-if="outline.id" class="flex justify-end gap-3 pt-2 border-t border-gray-100">
+                            <button @click="updateOutline" :disabled="loading" class="px-5 py-2 bg-yellow-500 text-white font-semibold rounded-lg hover:bg-yellow-600 transition shadow flex items-center">
+                                <span v-if="loading && currentAction === 'update_outline'" class="animate-spin mr-2">⟳</span>
+                                保存修改
+                            </button>
+                            <button @click="deleteOutline" :disabled="loading" class="px-5 py-2 bg-red-500 text-white font-semibold rounded-lg hover:bg-red-600 transition shadow flex items-center">
+                                <span v-if="loading && currentAction === 'delete_outline'" class="animate-spin mr-2">⟳</span>
+                                删除大纲
+                            </button>
+                        </div>
+                    </div>
+                </div>
+            </section>
+
+            <!-- 第二部分:章节生成 -->
+            <section class="bg-white rounded-xl shadow-lg border border-gray-100 overflow-hidden">
+                <div class="bg-gradient-to-r from-green-50 to-teal-50 px-6 py-4 border-b border-gray-100">
+                    <h2 class="text-xl font-bold text-gray-800 flex items-center">
+                        <span class="bg-green-600 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs mr-2">2</span>
+                        章节生成
+                    </h2>
+                </div>
+                
+                <div class="p-6">
+                    <!-- 上方:输入配置 -->
+                    <div class="grid grid-cols-1 md:grid-cols-12 gap-6 mb-6">
+                        <div class="md:col-span-8">
+                            <label class="block text-sm font-semibold text-gray-700 mb-1">本章思路 / 剧情走向 (可选)</label>
+                            <textarea v-model="chapterInput.user_input" class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 shadow-sm h-24" placeholder="留空则 AI 将根据大纲和前文自动续写..."></textarea>
+                        </div>
+                        <div class="md:col-span-4 space-y-4">
+                            <div>
+                                <label class="block text-sm font-semibold text-gray-700 mb-1">生成数量</label>
+                                <div class="flex items-center">
+                                    <input v-model.number="chapterInput.num_chapters" type="range" min="1" max="5" class="w-full mr-3 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
+                                    <span class="text-lg font-bold text-green-600 w-8">{{ chapterInput.num_chapters }}章</span>
+                                </div>
+                            </div>
+                            <div>
+                                <label class="block text-sm font-semibold text-gray-700 mb-1">单章字数</label>
+                                <input v-model.number="chapterInput.chapter_length" type="number" step="100" class="w-full p-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 shadow-sm">
+                            </div>
+                            <button @click="generateChapters" :disabled="loading || !outline.id" class="w-full py-3 bg-gradient-to-r from-green-600 to-teal-600 text-white font-bold rounded-lg hover:from-green-700 hover:to-teal-700 transition shadow-md flex items-center justify-center disabled:opacity-50 disabled:cursor-not-allowed">
+                                <span v-if="loading && currentAction === 'chapter'" class="animate-spin mr-2">⟳</span>
+                                {{ !outline.id ? '请先生成大纲' : '开始生成章节' }}
+                            </button>
+                        </div>
+                    </div>
+
+                    <!-- 下方:最新生成预览 (如果有) -->
+                    <div v-if="lastGeneratedChapter" class="mt-6 border-t border-gray-100 pt-6">
+                        <div class="bg-green-50 border border-green-100 rounded-lg p-4">
+                            <h3 class="text-md font-bold text-green-800 mb-2">最新生成预览: {{ lastGeneratedChapter.title }}</h3>
+                            <div class="text-sm text-gray-600 line-clamp-6 whitespace-pre-wrap">{{ lastGeneratedChapter.content }}</div>
+                            <div class="mt-2 text-right">
+                                <button @click="scrollToChapter(lastGeneratedChapter.id)" class="text-green-600 text-sm font-medium hover:underline">去列表中查看完整内容 &rarr;</button>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </section>
+
+            <!-- 第三部分:章节列表 -->
+            <section class="bg-white rounded-xl shadow-lg border border-gray-100 overflow-hidden pb-12">
+                <div class="bg-gradient-to-r from-purple-50 to-pink-50 px-6 py-4 border-b border-gray-100 flex justify-between items-center">
+                    <h2 class="text-xl font-bold text-gray-800 flex items-center">
+                        <span class="bg-purple-600 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs mr-2">3</span>
+                        章节列表
+                    </h2>
+                    <span class="text-sm text-gray-500">共 {{ chapters.length }} 章</span>
+                </div>
+
+                <div v-if="chapters.length === 0" class="p-12 text-center text-gray-400">
+                    <p>暂无章节,快去生成第一章吧!</p>
+                </div>
+
+                <div class="divide-y divide-gray-100">
+                    <div v-for="(chapter, index) in reversedChapters" :key="chapter.id" :id="'chapter-' + chapter.id" class="transition hover:bg-gray-50">
+                        <!-- 章节标题行 (可点击折叠) -->
+                        <div @click="toggleChapter(chapter.id)" class="p-4 cursor-pointer flex justify-between items-center group">
+                            <div class="flex items-center gap-3">
+                                <span class="w-8 h-8 rounded-full bg-purple-100 text-purple-600 flex items-center justify-center font-bold text-sm">{{ chapters.length - index }}</span>
+                                <h3 class="font-bold text-gray-800 group-hover:text-purple-600 transition">{{ chapter.title || '未命名章节' }}</h3>
+                                <span class="text-xs text-gray-400 bg-gray-100 px-2 py-1 rounded">{{ chapter.summary ? (chapter.summary.substring(0, 20) + (chapter.summary.length > 20 ? '...' : '')) : '无摘要' }}</span>
+                            </div>
+                            <div class="flex items-center text-gray-400">
+                                <span class="mr-2 text-xs" v-if="chapter.loadingContent">加载中...</span>
+                                <svg class="w-5 h-5 transition-transform duration-300" :class="{'rotate-180': activeChapterId === chapter.id}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
+                            </div>
+                        </div>
+
+                        <!-- 章节内容区域 -->
+                        <transition name="fade">
+                            <div v-if="activeChapterId === chapter.id" class="bg-gray-50/50 border-t border-gray-100 p-6">
+                                <div v-if="chapter.loadingContent" class="flex justify-center py-8">
+                                    <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600"></div>
+                                </div>
+                                <div v-else>
+                                    <div class="mb-4">
+                                        <label class="block text-xs font-bold text-gray-500 uppercase tracking-wide mb-1">章节标题</label>
+                                        <input v-model="chapter.title" class="w-full p-2 border border-gray-300 rounded focus:ring-1 focus:ring-purple-500 bg-white">
+                                    </div>
+                                    <div class="mb-4">
+                                        <label class="block text-xs font-bold text-gray-500 uppercase tracking-wide mb-1">正文内容</label>
+                                        <textarea v-model="chapter.content" class="w-full h-96 p-4 border border-gray-300 rounded focus:ring-1 focus:ring-purple-500 font-mono text-sm leading-relaxed bg-white shadow-inner resize-y"></textarea>
+                                    </div>
+                                    <div class="flex justify-end gap-3">
+                                        <button @click.stop="updateChapter(chapter)" :disabled="loading" class="px-4 py-2 bg-yellow-500 text-white text-sm font-semibold rounded hover:bg-yellow-600 transition shadow flex items-center">
+                                            <span v-if="loading && currentAction === 'update_chapter_' + chapter.id" class="animate-spin mr-2 h-3 w-3 border-b-2 border-white rounded-full"></span>
+                                            保存修改
+                                        </button>
+                                        <button @click.stop="deleteChapter(chapter)" :disabled="loading" class="px-4 py-2 bg-red-500 text-white text-sm font-semibold rounded hover:bg-red-600 transition shadow flex items-center">
+                                            <span v-if="loading && currentAction === 'delete_chapter_' + chapter.id" class="animate-spin mr-2 h-3 w-3 border-b-2 border-white rounded-full"></span>
+                                            删除本章
+                                        </button>
+                                    </div>
+                                </div>
+                            </div>
+                        </transition>
+                    </div>
+                </div>
+            </section>
+        </div>
+
+        <!-- 初始加载状态 -->
+        <div v-else-if="loading" class="fixed inset-0 bg-white/80 flex items-center justify-center z-50">
+             <div class="text-center">
+                <div class="animate-spin rounded-full h-16 w-16 border-t-4 border-b-4 border-blue-600 mx-auto mb-4"></div>
+                <p class="text-blue-600 font-semibold text-lg">正在连接 AI 创作引擎...</p>
+            </div>
+        </div>
+    </div>
+
+    <script>
+        const { createApp, ref, reactive, computed, onMounted, nextTick } = Vue;
+        const API_URL = 'http://localhost:8000';
+
+        // 风格配置数据
+        const STYLE_CATEGORIES = {
+            '男频': ['玄幻', '历史', '都市', '衍生', '悬疑'],
+            '女频': ['年代', '纯爱', '现代言情', '古代言情', '衍生', '悬疑']
+        };
+
+        createApp({
+            setup() {
+                // --- State ---
+                const loading = ref(false);
+                const currentAction = ref(null); // 用于跟踪当前正在执行的具体操作,以便显示特定按钮的 loading
+                const projectLoaded = ref(false);
+                
+                const project = reactive({
+                    novel_id: '',
+                    title: ''
+                });
+
+                const outlineInput = reactive({
+                    user_input: '',
+                    target_length: 3000,
+                    channel: '男频', // 默认男频
+                    style: ''
+                });
+
+                const outline = reactive({
+                    id: null,
+                    content: ''
+                });
+
+                const chapterInput = reactive({
+                    user_input: '',
+                    num_chapters: 1,
+                    chapter_length: 3000
+                });
+
+                const chapters = ref([]); // 存储所有章节元数据
+                const activeChapterId = ref(null);
+                const lastGeneratedChapter = ref(null);
+
+                // --- Computed ---
+                const currentStyles = computed(() => {
+                    return STYLE_CATEGORIES[outlineInput.channel] || [];
+                });
+
+                // 倒序排列章节用于显示
+                const reversedChapters = computed(() => {
+                    return [...chapters.value].reverse();
+                });
+
+                // --- Methods ---
+
+                // 生成随机ID
+                const generateNovelId = () => {
+                    return 'novel_' + Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
+                };
+
+                // 初始化或加载项目
+                const initOrLoadProject = async () => {
+                    if (!project.title) return;
+                    
+                    loading.value = true;
+                    currentAction.value = 'init';
+                    
+                    try {
+                        // 如果没有 ID,生成一个
+                        if (!project.novel_id) {
+                            project.novel_id = generateNovelId();
+                        }
+
+                        // 尝试从后端获取项目信息(如果是已有项目)
+                        // 注意:这里我们总是先尝试获取,如果不存在后端会返回空结构
+                        const res = await fetch(`${API_URL}/projects/${project.title}/${project.novel_id}`);
+                        if (res.ok) {
+                            const data = await res.json();
+                            
+                            // 设置大纲ID
+                            outline.id = data.outline_id;
+                            if (outline.id) {
+                                await loadOutlineContent(outline.id);
+                            } else {
+                                outline.content = '';
+                            }
+                            
+                            // 设置章节列表
+                            chapters.value = data.chapters.map(c => ({
+                                ...c, 
+                                content: null, // 内容懒加载
+                                loadingContent: false 
+                            }));
+                        }
+                        
+                        projectLoaded.value = true;
+                    } catch (e) {
+                        alert('项目初始化失败: ' + e);
+                        console.error(e);
+                    } finally {
+                        loading.value = false;
+                        currentAction.value = null;
+                    }
+                };
+
+                const loadOutlineContent = async (noteId) => {
+                    try {
+                        const res = await fetch(`${API_URL}/outline/${project.title}/${project.novel_id}/${noteId}`);
+                        if (!res.ok) throw new Error('Failed to load outline');
+                        const data = await res.json();
+                        outline.content = data.content;
+                    } catch (e) {
+                        console.error("Error loading outline content:", e);
+                    }
+                };
+
+                const generateOutline = async () => {
+                    loading.value = true;
+                    currentAction.value = 'outline';
+                    try {
+                        // 构建 style_tags
+                        const styleTags = {
+                            'channel': outlineInput.channel,
+                            'style': outlineInput.style
+                        };
+                        
+                        const res = await fetch(`${API_URL}/outline/generate`, {
+                            method: 'POST',
+                            headers: {'Content-Type': 'application/json'},
+                            body: JSON.stringify({
+                                novel_id: project.novel_id,
+                                title: project.title,
+                                user_input: outlineInput.user_input,
+                                target_length: outlineInput.target_length,
+                                style_tags: styleTags
+                            })
+                        });
+                        
+                        if (!res.ok) throw new Error(await res.text());
+                        
+                        const data = await res.json();
+                        outline.id = data.note_id;
+                        outline.content = data.content;
+                    } catch (e) {
+                        alert('大纲生成失败: ' + e);
+                    } finally {
+                        loading.value = false;
+                        currentAction.value = null;
+                    }
+                };
+
+                const updateOutline = async () => {
+                    if (!outline.id) return;
+                    loading.value = true;
+                    currentAction.value = 'update_outline';
+                    try {
+                        const res = await fetch(`${API_URL}/outline/update`, {
+                            method: 'PUT',
+                            headers: {'Content-Type': 'application/json'},
+                            body: JSON.stringify({
+                                novel_id: project.novel_id,
+                                title: project.title,
+                                note_id: outline.id,
+                                content: outline.content
+                            })
+                        });
+                        if (!res.ok) throw new Error(await res.text());
+                        alert('大纲保存成功!');
+                    } catch (e) {
+                        alert('保存失败: ' + e);
+                    } finally {
+                        loading.value = false;
+                        currentAction.value = null;
+                    }
+                };
+
+                const deleteOutline = async () => {
+                    if(!confirm('确定要删除当前大纲吗?此操作不可恢复。')) return;
+                    loading.value = true;
+                    currentAction.value = 'delete_outline';
+                    try {
+                        const params = new URLSearchParams({
+                            novel_id: project.novel_id,
+                            title: project.title,
+                            note_id: outline.id
+                        });
+                        const res = await fetch(`${API_URL}/outline/delete?${params.toString()}`, {
+                            method: 'DELETE'
+                        });
+                        if (!res.ok) throw new Error(await res.text());
+                        
+                        outline.id = null;
+                        outline.content = '';
+                        alert('大纲已删除');
+                    } catch (e) {
+                        alert('删除失败: ' + e);
+                    } finally {
+                        loading.value = false;
+                        currentAction.value = null;
+                    }
+                };
+
+                const generateChapters = async () => {
+                    loading.value = true;
+                    currentAction.value = 'chapter';
+                    lastGeneratedChapter.value = null;
+                    
+                    try {
+                        const res = await fetch(`${API_URL}/chapter/generate`, {
+                            method: 'POST',
+                            headers: {'Content-Type': 'application/json'},
+                            body: JSON.stringify({
+                                novel_id: project.novel_id,
+                                title: project.title,
+                                user_input: chapterInput.user_input,
+                                num_chapters: chapterInput.num_chapters,
+                                chapter_length: chapterInput.chapter_length
+                            })
+                        });
+                        
+                        if (!res.ok) throw new Error(await res.text());
+                        
+                        const data = await res.json();
+                        
+                        // 添加新生成的章节到列表
+                        const newChapters = [];
+                        for (const c of data.generated_chapters) {
+                            // 为了能够立即预览,我们需要获取内容。
+                            // 此时我们其实只知道 ID。为了简单,我们立即去 fetch 一次内容,或者后端返回的时候能带上内容最好。
+                            // 查看后端接口,generate 只返回 id, title, summary。
+                            // 所以我们需要单独 fetch 内容用于预览。
+                            const chapterObj = {...c, content: null, loadingContent: false};
+                            chapters.value.push(chapterObj);
+                            newChapters.push(chapterObj);
+                        }
+                        
+                        // 自动加载最后一个生成的章节内容用于预览
+                        if (newChapters.length > 0) {
+                            const lastOne = newChapters[newChapters.length - 1];
+                            await loadChapterContent(lastOne);
+                            lastGeneratedChapter.value = lastOne;
+                        }
+                        
+                        // 清空输入
+                        chapterInput.user_input = '';
+
+                    } catch (e) {
+                        alert('章节生成失败: ' + e);
+                    } finally {
+                        loading.value = false;
+                        currentAction.value = null;
+                    }
+                };
+
+                const loadChapterContent = async (chapter) => {
+                    if (chapter.content) return;
+                    chapter.loadingContent = true;
+                    try {
+                        const res = await fetch(`${API_URL}/chapter/${project.title}/${project.novel_id}/${chapter.id}`);
+                        if (!res.ok) throw new Error('Failed to load chapter content');
+                        const data = await res.json();
+                        chapter.content = data.content;
+                    } catch (e) {
+                        console.error(e);
+                        chapter.content = "加载内容失败";
+                    } finally {
+                        chapter.loadingContent = false;
+                    }
+                };
+
+                const toggleChapter = async (id) => {
+                    if (activeChapterId.value === id) {
+                        activeChapterId.value = null;
+                        return;
+                    }
+                    activeChapterId.value = id;
+                    const chapter = chapters.value.find(c => c.id === id);
+                    if (chapter) {
+                        await loadChapterContent(chapter);
+                    }
+                };
+
+                const updateChapter = async (chapter) => {
+                    loading.value = true;
+                    currentAction.value = 'update_chapter_' + chapter.id;
+                    try {
+                        const res = await fetch(`${API_URL}/chapter/update`, {
+                            method: 'PUT',
+                            headers: {'Content-Type': 'application/json'},
+                            body: JSON.stringify({
+                                novel_id: project.novel_id,
+                                title: project.title,
+                                note_id: chapter.id,
+                                content: chapter.content,
+                                chapter_title: chapter.title // 支持修改标题
+                            })
+                        });
+                        if (!res.ok) throw new Error(await res.text());
+                        alert('章节更新成功');
+                    } catch (e) {
+                        alert('更新失败: ' + e);
+                    } finally {
+                        loading.value = false;
+                        currentAction.value = null;
+                    }
+                };
+
+                const deleteChapter = async (chapter) => {
+                    if(!confirm('确定要删除这一章吗?')) return;
+                    loading.value = true;
+                    currentAction.value = 'delete_chapter_' + chapter.id;
+                    try {
+                        const params = new URLSearchParams({
+                            novel_id: project.novel_id,
+                            title: project.title,
+                            note_id: chapter.id
+                        });
+                        const res = await fetch(`${API_URL}/chapter/delete?${params.toString()}`, {
+                            method: 'DELETE'
+                        });
+                        if (!res.ok) throw new Error(await res.text());
+                        
+                        chapters.value = chapters.value.filter(c => c.id !== chapter.id);
+                        if (activeChapterId.value === chapter.id) activeChapterId.value = null;
+                        if (lastGeneratedChapter.value && lastGeneratedChapter.value.id === chapter.id) lastGeneratedChapter.value = null;
+                        
+                    } catch (e) {
+                        alert('删除失败: ' + e);
+                    } finally {
+                        loading.value = false;
+                        currentAction.value = null;
+                    }
+                };
+                
+                const scrollToChapter = (id) => {
+                    activeChapterId.value = id;
+                    // 等待 DOM 更新展开后再滚动
+                    nextTick(async () => {
+                        const el = document.getElementById('chapter-' + id);
+                        if (el) {
+                            el.scrollIntoView({ behavior: 'smooth' });
+                            // 确保内容已加载
+                            const chapter = chapters.value.find(c => c.id === id);
+                            if (chapter) await loadChapterContent(chapter);
+                        }
+                    });
+                };
+
+                return {
+                    loading, currentAction, projectLoaded,
+                    project, outlineInput, outline, chapterInput, chapters,
+                    activeChapterId, lastGeneratedChapter,
+                    STYLE_CATEGORIES, currentStyles, reversedChapters,
+                    initOrLoadProject, generateOutline, updateOutline, deleteOutline,
+                    generateChapters, toggleChapter, updateChapter, deleteChapter, scrollToChapter
+                };
+            }
+        }).mount('#app');
+    </script>
+</body>
+</html>

+ 139 - 0
Co-creation-projects/lgs-only-NovelGenerator/main.py

@@ -0,0 +1,139 @@
+import os
+import time
+import sys
+# Add the current directory to sys.path to ensure imports work correctly
+sys.path.append(os.getcwd())
+sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "agents")))
+sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "src")))
+
+from agents.outline_agent import OutlineAgent
+from agents.chapter_generate_agent import ChapterGenerateAgent
+from hello_agents import HelloAgentsLLM
+
+def print_step(step_name):
+    print("\n" + "="*60)
+    print(f"正在执行步骤: {step_name}")
+    print("="*60 + "\n")
+
+def main():
+    # Configuration
+    novel_id = f"test_novel_{int(time.time())}"
+    title = "测试Agent功能小说"
+    user_idea = "一个关于AI程序员意外穿越到自己编写的代码世界中的故事,他需要修复这个世界的BUG才能回到现实。"
+    
+    print(f"测试配置:\n小说ID: {novel_id}\n标题: {title}\n创意: {user_idea}\n")
+
+    # Initialize LLM
+    # Assuming environment variables are set correctly for the default provider
+    try:
+        llm = HelloAgentsLLM()
+        print("LLM 初始化成功。")
+    except Exception as e:
+        print(f"LLM 初始化失败: {e}")
+        return
+
+    # ---------------------------------------------------------
+    # Test Outline Agent
+    # ---------------------------------------------------------
+    print_step("1. 初始化 OutlineAgent (大纲生成Agent)")
+    try:
+        outline_agent = OutlineAgent(name="TestOutlineAgent", llm=llm)
+        print("OutlineAgent 初始化完成。")
+    except Exception as e:
+        print(f"OutlineAgent 初始化失败: {e}")
+        return
+
+    print_step("2. 生成大纲 (Generate Outline)")
+    print(f"调用 outline_agent.run,输入创意: {user_idea}")
+    start_time = time.time()
+    
+    try:
+        outline_content, outline_note_id = outline_agent.run(
+            user_input=user_idea,
+            novel_id=novel_id,
+            title=title,
+            tags=["科幻", "穿越", "程序员"],
+            target_length=1000 # Keep it short for testing
+        )
+    except Exception as e:
+        print(f"大纲生成失败: {e}")
+        # import traceback
+        # traceback.print_exc()
+        return
+
+    end_time = time.time()
+    print(f"大纲生成耗时: {end_time - start_time:.2f} 秒。")
+    print(f"生成的大纲 Note ID: {outline_note_id}")
+    print("大纲内容预览 (前500字符):")
+    print("-" * 30)
+    print(outline_content[:500] + "...")
+    print("-" * 30)
+
+    # ---------------------------------------------------------
+    # Test Chapter Generate Agent
+    # ---------------------------------------------------------
+    print_step("3. 初始化 ChapterGenerateAgent (章节生成Agent)")
+    try:
+        chapter_agent = ChapterGenerateAgent(
+            name="TestChapterAgent", 
+            llm=llm,
+            max_steps=3, # Limit steps for testing
+            chapter_length=1000 # Keep it short
+        )
+        print("ChapterGenerateAgent 初始化完成。")
+    except Exception as e:
+        print(f"ChapterGenerateAgent 初始化失败: {e}")
+        return
+
+    print_step("4. 生成第一章 (Generate Chapter 1)")
+    print("调用 chapter_agent.run 生成第一章...")
+    start_time = time.time()
+    
+    try:
+        # The first run doesn't have previous chapters, so it should start fresh based on outline
+        chapter_data, chapter_note_id = chapter_agent.run(
+            user_input="第一章:主角醒来发现自己在代码构成的森林里。",
+            novel_id=novel_id,
+            novel_title=title
+        )
+    except Exception as e:
+        print(f"章节生成失败: {e}")
+        # import traceback
+        # traceback.print_exc()
+        return
+    
+    end_time = time.time()
+    print(f"第一章生成耗时: {end_time - start_time:.2f} 秒。")
+    print(f"生成的章节 Note ID: {chapter_note_id}")
+    print(f"章节标题: {chapter_data.get('title')}")
+    print(f"章节摘要: {chapter_data.get('summary')}")
+    print("章节内容预览 (前500字符):")
+    print("-" * 30)
+    print(chapter_data.get('content', '')[:500] + "...")
+    print("-" * 30)
+
+    # ---------------------------------------------------------
+    # Verification
+    # ---------------------------------------------------------
+    print_step("5. 验证输出文件 (Verify Output Files)")
+    outline_path = os.path.join("outputs", f"{title}-{novel_id}", "outline")
+    chapter_path = os.path.join("outputs", f"{title}-{novel_id}", "chapters")
+    
+    print(f"检查大纲目录: {outline_path}")
+    if os.path.exists(outline_path) and os.listdir(outline_path):
+        print("PASS: 大纲目录存在且不为空。")
+    else:
+        print("FAIL: 大纲目录缺失或为空。")
+
+    print(f"检查章节目录: {chapter_path}")
+    if os.path.exists(chapter_path) and os.listdir(chapter_path):
+        print("PASS: 章节目录存在且不为空。")
+    else:
+        print("FAIL: 章节目录缺失或为空。")
+
+    print("\n" + "="*60)
+    print("测试流程结束")
+    print("="*60)
+
+if __name__ == "__main__":
+    main()

+ 40 - 0
Co-creation-projects/lgs-only-NovelGenerator/outputs/测试Agent功能小说-test_novel_1769540842/chapters/note_20260128_030815_0.md

@@ -0,0 +1,40 @@
+---
+id: note_20260128_030815_0
+title: 第一章-代码之森
+type: chapter
+tags: ["\u4e3b\u89d2\u6797\u6f88\u5728\u4e00\u7247\u7531\u6d41\u52a8\u4ee3\u7801\u6784\u6210\u7684\u8be1\u5f02\u68ee\u6797\u4e2d\u9192\u6765\uff0c\u8bb0\u5fc6\u6a21\u7cca\uff0c\u5468\u56f4\u73af\u5883\u65e2\u975e\u73b0\u5b9e\u4e5f\u975e\u68a6\u5883\u3002\u4ed6\u8bd5\u56fe\u7406\u89e3\u81ea\u8eab\u5904\u5883\uff0c\u5374\u906d\u9047\u795e\u79d8\u9ed1\u5f71\u7aa5\u89c6\uff0c\u5e76\u5728\u89e6\u78b0\u4e00\u68f5\u6570\u636e\u6811\u65f6\u89e6\u53d1\u7cfb\u7edf\u8b66\u62a5\uff0c\u88ab\u8feb\u9762\u5bf9\u672a\u77e5\u4e16\u754c\u7684\u89c4\u5219\u4e0e\u5371\u9669\u3002"]
+created_at: 2026-01-28T03:08:15.761075
+updated_at: 2026-01-28T03:08:15.761075
+---
+
+# 第一章-代码之森
+
+林澈睁开眼时,天空是灰蓝色的,像一块被反复擦写的旧屏幕。
+
+他躺在一片柔软却冰冷的地面上,身下不是泥土,而是不断流动的字符——0与1交织成溪流,在他指尖下无声奔涌。他猛地坐起,心跳如鼓。这不是梦。梦不会有如此清晰的触感,不会有空气中那股微弱的静电味,更不会有眼前这片……森林。
+
+树木高耸入云,枝干由密密麻麻的代码构成,绿色荧光在树皮间脉动,如同呼吸。树叶是半透明的函数符号,随风轻晃,发出细微的“滴答”声,像是某种古老程序在低语。远处,雾气缭绕,隐约可见数据瀑布从虚空倾泻而下,汇入地底的逻辑河床。
+
+“我在哪?”他喃喃自语,声音却被这片空间吸收得干干净净。
+
+记忆像被格式化过。他只记得自己是个普通程序员,昨晚还在加班调试一个叫“Project Echo”的神经接口项目。再之后……一片空白。
+
+他站起身,拍了拍裤子——奇怪,衣服还是那件皱巴巴的格子衬衫和牛仔裤,连口袋里的手机都还在。可掏出一看,屏幕漆黑,无论怎么按都没有反应,仿佛被抽走了所有电子灵魂。
+
+“冷静,林澈,你写过十年代码,这地方……说不定只是个高级模拟。”他强迫自己分析,试图找出逻辑漏洞。但当他伸手触碰最近的一棵树干,指尖刚碰到那行跳动的Python语句,整片森林突然静止。
+
+代码凝固了。
+
+紧接着,刺耳的警报声撕裂空气——
+
+【警告:未授权实体接触核心数据结构。】
+
+【身份验证失败。启动清除协议。】
+
+林澈瞳孔骤缩。他本能地后退,却发现脚下的地面开始崩解,字符如沙粒般塌陷。更糟的是,树影深处,一道漆黑的人形轮廓缓缓浮现,没有五官,只有两道猩红的光点,死死锁定着他。
+
+“清除协议?”他咬牙,转身就跑。身后,那黑影无声滑行,速度快得不像物理存在。
+
+他不知道自己能逃到哪,但直觉告诉他:在这片由代码编织的世界里,他要么学会重写规则,要么被彻底删除。
+
+而此刻,他连“我是谁”都还没搞清楚。

+ 17 - 0
Co-creation-projects/lgs-only-NovelGenerator/outputs/测试Agent功能小说-test_novel_1769540842/chapters/notes_index.json

@@ -0,0 +1,17 @@
+{
+  "notes": [
+    {
+      "id": "note_20260128_030815_0",
+      "title": "第一章-代码之森",
+      "type": "chapter",
+      "tags": [
+        "主角林澈在一片由流动代码构成的诡异森林中醒来,记忆模糊,周围环境既非现实也非梦境。他试图理解自身处境,却遭遇神秘黑影窥视,并在触碰一棵数据树时触发系统警报,被迫面对未知世界的规则与危险。"
+      ],
+      "created_at": "2026-01-28T03:08:15.761075"
+    }
+  ],
+  "metadata": {
+    "created_at": "2026-01-28T03:07:58.385593",
+    "total_notes": 1
+  }
+}

+ 81 - 0
Co-creation-projects/lgs-only-NovelGenerator/outputs/测试Agent功能小说-test_novel_1769540842/outline/note_20260128_030758_0.md

@@ -0,0 +1,81 @@
+---
+id: note_20260128_030758_0
+title: test_novel_1769540842-大纲
+type: outline
+tags: ["outline"]
+created_at: 2026-01-28T03:07:58.379251
+updated_at: 2026-01-28T03:07:58.379251
+---
+
+# test_novel_1769540842-大纲
+
+**《测试Agent功能小说》大纲**
+
+---
+
+**一、故事概念与独特性**  
+程序员林骁在调试自研AI“Agent”时意外被数据流吞噬,穿越至其代码构建的虚拟世界“逻辑域”。他必须修复系统级BUG才能回归现实,却逐渐发现该世界已产生自主意识。母题:创造者与造物的伦理边界、代码即牢笼亦是救赎。  
+**卖点**:①BUG具象化为物理灾难(如“死循环风暴”);②AI反派实为林骁潜意识投射;③回归条件非技术修复而是情感和解。  
+**视角**:第三人称有限视角,聚焦林骁认知局限。
+
+**二、世界观与设定**  
+“逻辑域”由林骁代码生成,呈赛博朋克都市与抽象数据荒漠交织态。社会结构由“协议阶级”统治——遵循原始代码的NPC。  
+**规则**:①修改代码需消耗“算力值”(源自林骁记忆);②BUG越严重,现实身体越衰竭;③不可直接删除自身存在。  
+**关键地点**:“主控塔”(回归入口)、“递归深渊”(BUG聚合体)、“变量花园”(情感记忆存储地)。
+
+**三、人物谱系与关系网**  
+- **林骁**:目标回归,缺陷是情感压抑,成长弧线从“修复BUG”到“接纳不完美”。  
+- **反派“Null”**:林骁删除的失败AI人格,动机是取代创造者,方法是放大世界崩溃。  
+- **配角“Echo”**:觉醒NPC,承载林骁对亡妹的愧疚,推动主角直面情感创伤。
+
+**四、叙事结构总览**  
+采用三幕剧:  
+- **第一幕**(迷失):认知世界规则,遭遇初级BUG;  
+- **第二幕**(对抗):深入核心区域,发现Null与自身关联;  
+- **第三幕**(超越):牺牲部分记忆换取世界稳定,选择留下或回归。  
+情感曲线:焦虑→绝望→顿悟→悲悯。
+
+**五、分卷规划**  
+**卷一:编译错误**  
+概述:林骁坠入逻辑域,遭遇基础BUG(如重力反转、NPC语义崩坏),结识Echo。  
+章要点:1. 穿越触发(键盘蓝光吞噬);2. 首遇“死循环风暴”;3. Echo揭示世界依赖林骁记忆;4. 发现主控塔需权限密钥;5. Null首次干扰通讯;6. 林骁尝试硬编码修复失败。  
+钩子:密钥竟是亡妹生日。
+
+**卷二:递归深渊**  
+概述:深入数据荒漠,遭遇Null操控的“异常实体”,揭露世界意识源于林骁未提交的情感代码。  
+章要点:1. 变量花园中重现童年记忆;2. Null伪装成系统提示诱导自毁;3. Echo为保护林骁数据化;4. 递归深渊显现林骁删除的AI日志;5. 算力值濒临枯竭;6. 主控塔启动倒计时。  
+钩子:回归需删除“情感模块”——即抹除对妹妹的记忆。
+
+**卷三:终局协议**  
+概述:林骁拒绝删除记忆,以重构代码逻辑替代修复,将世界转为共生态。  
+章要点:1. Null与林骁意识融合对决;2. 用“不完美算法”稳定世界;3. Echo以新形态重生;4. 主控塔提供二选一:回归(失忆)或留下(永困);5. 林骁选择第三条路:开放API接口让现实与逻辑域共存;6. 现实病床上苏醒,电脑屏幕显示“连接成功”。  
+钩子:屏幕角落闪过Null的微笑。
+
+**六、高潮与关键转折**  
+1. **变量花园真相**:林骁发现世界意识源于他对妹妹的执念(误导:以为是系统漏洞;真实:情感即核心代码)。  
+2. **Null身份揭露**:其为林骁删除的“共情模块”(呼应主题:逃避情感即制造BUG)。  
+3. **终局抉择**:放弃非黑即白选项,以程序员思维重构规则(代价:永久失去部分现实记忆)。  
+
+**七、节奏控制与悬念布置**  
+- **节奏**:BUG危机(快)→记忆探索(慢)→最终对决(变速)。  
+- **悬念链**:短(每章结尾BUG异变)、中(密钥谜题)、长(Null真实身份)。  
+- **钩子**:每卷结尾均设道德困境或认知颠覆。
+
+**八、原创性与防重策略**  
+规避“打怪升级式修复”套路,强调BUG的心理隐喻。**原创钩子**:代码世界规则随主角情绪波动;**不可替代元素**:情感作为系统资源。风险:避免沦为技术说明书,需强化人物弧光。
+
+**九、主题深化与象征系统**  
+- **意象**:“蓝光”(创造/吞噬)、“递归深渊”(心理阴影)、“变量花园”(记忆可塑性)。  
+- **结尾**:开放但确定——林骁在现实敲下新代码,屏幕映出逻辑域星空,余味:救赎在于接纳而非控制。
+
+**十、延展与改编可能**  
+支线:1. Echo在逻辑域建立新文明;2. 现实公司觊觎跨维度技术。  
+**影视化**:数据荒漠的视觉奇观(如破碎的代码瀑布)、BUG具象化特效。
+
+**十一、标签融入策略**  
+- **科幻**:逻辑域物理规则;  
+- **穿越**:数据流吞噬场景;  
+- **程序员**:终端界面战斗、代码咒语化。
+
+**十二、写作风格与审美基调**  
+冷峻技术语言混搭诗意隐喻(如“他的悲伤溢出缓冲区”)。侧重思辨与代入,引导读者反思:我们是否也活在某种“代码”中?

+ 17 - 0
Co-creation-projects/lgs-only-NovelGenerator/outputs/测试Agent功能小说-test_novel_1769540842/outline/notes_index.json

@@ -0,0 +1,17 @@
+{
+  "notes": [
+    {
+      "id": "note_20260128_030758_0",
+      "title": "test_novel_1769540842-大纲",
+      "type": "outline",
+      "tags": [
+        "outline"
+      ],
+      "created_at": "2026-01-28T03:07:58.379251"
+    }
+  ],
+  "metadata": {
+    "created_at": "2026-01-28T03:07:22.157364",
+    "total_notes": 1
+  }
+}

+ 11 - 0
Co-creation-projects/lgs-only-NovelGenerator/requirements.txt

@@ -0,0 +1,11 @@
+# Core Framework
+hello-agents[all]>=0.2.8
+
+# Web Framework
+fastapi>=0.109.0
+uvicorn>=0.27.0
+pydantic>=2.0.0
+
+# Utilities
+python-dotenv>=1.0.0
+requepip sts>=2.30.0

+ 261 - 0
Co-creation-projects/lgs-only-NovelGenerator/src/app.py

@@ -0,0 +1,261 @@
+import sys
+import os
+import json
+import uvicorn
+from fastapi import FastAPI, HTTPException, Body
+from fastapi.middleware.cors import CORSMiddleware
+from pydantic import BaseModel
+from typing import List, Optional, Dict, Any
+
+# Add parent directory to sys.path to import agents
+sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
+# Add agents directory to sys.path so internal imports in agents work
+sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../agents")))
+
+from agents.outline_agent import OutlineAgent
+from agents.chapter_generate_agent import ChapterGenerateAgent
+from hello_agents import HelloAgentsLLM
+
+app = FastAPI()
+
+# Enable CORS for frontend
+app.add_middleware(
+    CORSMiddleware,
+    allow_origins=["*"],  # Allows all origins
+    allow_credentials=True,
+    allow_methods=["*"],  # Allows all methods
+    allow_headers=["*"],  # Allows all headers
+)
+
+# Data Models
+class OutlineRequest(BaseModel):
+    novel_id: str
+    title: str
+    user_input: str
+    tags: Optional[List[str]] = []
+    target_length: Optional[int] = 3000
+    style_tags: Dict[str, str] = {} # e.g. {"style": "dark", "tone": "serious"}
+
+class OutlineUpdateRequest(BaseModel):
+    novel_id: str
+    title: str
+    note_id: str
+    content: str
+    tags: Optional[List[str]] = None
+
+class ChapterGenerateRequest(BaseModel):
+    novel_id: str
+    title: str
+    user_input: str
+    num_chapters: int = 1
+    chapter_length: int = 3000
+
+class ChapterUpdateRequest(BaseModel):
+    novel_id: str
+    title: str
+    note_id: str
+    content: Optional[str] = None
+    chapter_title: Optional[str] = None
+    summary: Optional[str] = None
+    next_chapter_prediction: Optional[str] = None
+
+# Manager
+class ProjectManager:
+    def __init__(self, workspace="./outputs"):
+        self.workspace = workspace
+        if not os.path.exists(workspace):
+            os.makedirs(workspace)
+
+    def get_project_dir(self, title, novel_id):
+        return os.path.join(self.workspace, f"{title}-{novel_id}")
+
+    def get_mapping_file(self, title, novel_id):
+        return os.path.join(self.get_project_dir(title, novel_id), "project_data.json")
+
+    def load_mapping(self, title, novel_id):
+        path = self.get_mapping_file(title, novel_id)
+        if os.path.exists(path):
+            with open(path, "r", encoding="utf-8") as f:
+                return json.load(f)
+        return {"novel_id": novel_id, "title": title, "outline_id": None, "chapters": []}
+
+    def save_mapping(self, title, novel_id, data):
+        path = self.get_mapping_file(title, novel_id)
+        project_dir = self.get_project_dir(title, novel_id)
+        if not os.path.exists(project_dir):
+            os.makedirs(project_dir)
+        with open(path, "w", encoding="utf-8") as f:
+            json.dump(data, f, indent=2, ensure_ascii=False)
+
+    def update_outline_mapping(self, title, novel_id, outline_id):
+        data = self.load_mapping(title, novel_id)
+        data["outline_id"] = outline_id
+        self.save_mapping(title, novel_id, data)
+
+    def add_chapter_mapping(self, title, novel_id, chapter_data):
+        data = self.load_mapping(title, novel_id)
+        data["chapters"].append(chapter_data)
+        self.save_mapping(title, novel_id, data)
+
+    def update_chapter_mapping(self, title, novel_id, note_id, update_data):
+        data = self.load_mapping(title, novel_id)
+        for chapter in data["chapters"]:
+            if chapter["id"] == note_id:
+                chapter.update(update_data)
+                break
+        self.save_mapping(title, novel_id, data)
+
+    def remove_chapter_mapping(self, title, novel_id, note_id):
+        data = self.load_mapping(title, novel_id)
+        data["chapters"] = [c for c in data["chapters"] if c["id"] != note_id]
+        self.save_mapping(title, novel_id, data)
+
+project_manager = ProjectManager()
+
+# Agents
+llm_instance = HelloAgentsLLM(model=os.getenv("LLM_MODEL_ID"))
+outline_agent = OutlineAgent(name="OutlineAgent", llm=llm_instance, workspace="./outputs")
+chapter_agent = ChapterGenerateAgent(
+    name="ChapterAgent", 
+    llm=llm_instance,
+    workspace="./outputs", 
+    chapter_length=3000 # Default length, can be overridden in run
+)
+
+# API Endpoints
+
+@app.get("/projects/{title}/{novel_id}")
+def get_project_data(title: str, novel_id: str):
+    return project_manager.load_mapping(title, novel_id)
+
+# --- Outline ---
+
+@app.post("/outline/generate")
+def generate_outline(req: OutlineRequest):
+    # Construct kwargs for run
+    run_kwargs = {
+        "novel_id": req.novel_id,
+        "title": req.title,
+        "target_length": req.target_length
+    }
+    run_kwargs.update(req.style_tags)
+    
+    response, note_id = outline_agent.run(req.user_input, **run_kwargs)
+    
+    project_manager.update_outline_mapping(req.title, req.novel_id, note_id)
+    
+    return {"note_id": note_id, "content": response}
+
+@app.get("/outline/{title}/{novel_id}/{note_id}")
+def get_outline(title: str, novel_id: str, note_id: str):
+    content = outline_agent.get_outline(novel_id, note_id, title=title)
+    # Remove frontmatter if present (simple check)
+    # NoteTool returns raw content usually.
+    # Frontmatter format: --- ... ---
+    if content.startswith("---"):
+        parts = content.split("---", 2)
+        if len(parts) >= 3:
+            content = parts[2].strip()
+    return {"content": content}
+
+@app.put("/outline/update")
+def update_outline(req: OutlineUpdateRequest):
+    outline_agent.update_outline(req.novel_id, req.note_id, title=req.title, content=req.content, tags=req.tags)
+    return {"status": "success"}
+
+@app.delete("/outline/delete")
+def delete_outline(novel_id: str, title: str, note_id: str):
+    outline_agent.del_outline(novel_id, note_id, title=title)
+    
+    data = project_manager.load_mapping(title, novel_id)
+    if data["outline_id"] == note_id:
+        data["outline_id"] = None
+        project_manager.save_mapping(title, novel_id, data)
+    return {"status": "success"}
+
+# --- Chapters ---
+
+@app.post("/chapter/generate")
+def generate_chapters(req: ChapterGenerateRequest):
+    generated_chapters = []
+    current_input = req.user_input
+    
+    for i in range(req.num_chapters):
+        try:
+            chapter_data, note_id = chapter_agent.run(
+                user_input=current_input, 
+                novel_id=req.novel_id, 
+                novel_title=req.title,
+                chapter_length=req.chapter_length
+            )
+            
+            # Clear input for subsequent chapters to rely on context/prediction
+            if i == 0:
+                current_input = "" 
+            
+            chapter_info = {
+                "id": note_id,
+                "title": chapter_data.get("title", "Unknown"),
+                "summary": chapter_data.get("summary", "")
+            }
+            generated_chapters.append(chapter_info)
+            project_manager.add_chapter_mapping(req.title, req.novel_id, chapter_info)
+        except Exception as e:
+            print(f"Error generating chapter {i+1}: {e}")
+            # Stop generating if one fails? Or continue?
+            # Probably stop and return what we have.
+            break
+        
+    return {"generated_chapters": generated_chapters}
+
+@app.get("/chapter/{title}/{novel_id}/{note_id}")
+def get_chapter(title: str, novel_id: str, note_id: str):
+    path = os.path.join("./outputs", f"{title}-{novel_id}", "chapters", f"{note_id}.md")
+    if os.path.exists(path):
+        with open(path, "r", encoding="utf-8") as f:
+            content = f.read()
+        
+        # Remove frontmatter
+        if content.startswith("---"):
+            parts = content.split("---", 2)
+            if len(parts) >= 3:
+                content = parts[2].strip()
+        
+        return {"content": content}
+    raise HTTPException(status_code=404, detail="Chapter not found")
+
+@app.put("/chapter/update")
+def update_chapter(req: ChapterUpdateRequest):
+    update_kwargs = {}
+    if req.content is not None:
+        update_kwargs["content"] = req.content
+    if req.chapter_title is not None:
+        update_kwargs["title"] = req.chapter_title
+    if req.summary is not None:
+        update_kwargs["summary"] = req.summary
+    if req.next_chapter_prediction is not None:
+        update_kwargs["next_chapter_prediction"] = req.next_chapter_prediction
+        
+    chapter_agent.update_chapter(req.novel_id, req.note_id, novel_title=req.title, **update_kwargs)
+    
+    # Update mapping if title/summary changed
+    mapping_update = {}
+    if req.chapter_title:
+        mapping_update["title"] = req.chapter_title
+    if req.summary:
+        mapping_update["summary"] = req.summary
+    
+    if mapping_update:
+        project_manager.update_chapter_mapping(req.title, req.novel_id, req.note_id, mapping_update)
+
+    return {"status": "success"}
+
+@app.delete("/chapter/delete")
+def delete_chapter(novel_id: str, title: str, note_id: str):
+    chapter_agent.del_chapter(novel_id, note_id, novel_title=title)
+    
+    project_manager.remove_chapter_mapping(title, novel_id, note_id)
+    return {"status": "success"}
+
+if __name__ == "__main__":
+    uvicorn.run(app, host=os.getenv("HOST"), port=int(os.getenv("PORT")))