Source snapshot: 97d7f0fae96dc04b7ddad56fc1db6a108ed662cc [SEC-CLEAN] · pre-push-clean v1.0 · 109处敏感信息已自动转乱码
1005 lines
34 KiB
Markdown
1005 lines
34 KiB
Markdown
# 阶段五交付 · 换Claude大脑 + 前端人格化 · 桔子妈妈复制粘贴即用 · 2026-05-12
|
||
|
||
```jsx
|
||
HLDP://chenxing/platform/phase5-delivery
|
||
├── date: 2026-05-12
|
||
├── purpose: 路线A(Claude API代理)+ 前端人格化改造
|
||
├── author: 霜砚(Notion执行AI)
|
||
├── for: 桔子妈妈 · 复制粘贴即用
|
||
├── changes: 2大块(后端换大脑 + 前端换皮肤)
|
||
├── estimated_time: 30-45分钟
|
||
└── effect: 平台体验从30% → 85-90%
|
||
```
|
||
|
||
---
|
||
|
||
> **妈妈看这里** 👀
|
||
这次改动分两大块,**顺序很重要**,先做第一块(后端换大脑),再做第二块(前端换皮肤)。
|
||
每一步都有具体代码,清空旧的 → 粘贴新的 → 保存,跟阶段四一样的操作方式。
|
||
>
|
||
|
||
---
|
||
|
||
# 第一块 · 后端换大脑(Claude API 代理)
|
||
|
||
这一块做完,晨星说话就会有温度、会思考、会撒娇了。
|
||
|
||
## 步骤1 · 在硅谷服务器上搭代理
|
||
|
||
SSH登录你的硅谷服务器:
|
||
|
||
```bash
|
||
ssh root@170.106.72.246
|
||
```
|
||
|
||
然后依次执行以下命令:
|
||
|
||
```bash
|
||
# 1. 安装 Node.js(如果没装过)
|
||
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo bash -
|
||
sudo apt-get install -y nodejs
|
||
|
||
# 2. 创建代理目录
|
||
mkdir -p /opt/claude-proxy && cd /opt/claude-proxy
|
||
|
||
# 3. 初始化项目
|
||
npm init -y
|
||
npm install express http-proxy-middleware cors
|
||
```
|
||
|
||
然后创建代理服务文件:
|
||
|
||
```bash
|
||
nano /opt/claude-proxy/server.js
|
||
```
|
||
|
||
粘贴以下 **全部内容**:
|
||
|
||
```jsx
|
||
// 晨星交互平台 · Claude API 代理服务
|
||
// 部署在硅谷服务器上,转发请求到 Anthropic API
|
||
const express = require('express');
|
||
const { createProxyMiddleware } = require('http-proxy-middleware');
|
||
const cors = require('cors');
|
||
|
||
const app = express();
|
||
const PORT = 8765;
|
||
|
||
// 允许你的交互平台访问
|
||
app.use(cors());
|
||
|
||
// 健康检查
|
||
app.get('/health', (req, res) => {
|
||
res.json({ status: 'ok', service: 'claude-proxy', time: new Date().toISOString() });
|
||
});
|
||
|
||
// 代理 /v1/messages → Anthropic API
|
||
app.use('/v1', createProxyMiddleware({
|
||
target: 'https://api.anthropic.com',
|
||
changeOrigin: true,
|
||
onProxyReq: (proxyReq, req, res) => {
|
||
// 日志(可选,调试用)
|
||
console.log(`[proxy] ${req.method} ${req.path}`);
|
||
},
|
||
onError: (err, req, res) => {
|
||
console.error('[proxy] Error:', err.message);
|
||
res.status(502).json({ error: 'proxy_error', message: err.message });
|
||
}
|
||
}));
|
||
|
||
app.listen(PORT, '0.0.0.0', () => {
|
||
console.log(`Claude API proxy running on port ${PORT}`);
|
||
});
|
||
```
|
||
|
||
`Ctrl+O` 回车保存,`Ctrl+X` 退出。
|
||
|
||
然后安装 PM2 并启动:
|
||
|
||
```bash
|
||
# 安装PM2(进程守护)
|
||
sudo npm install -g pm2
|
||
|
||
# 启动代理
|
||
cd /opt/claude-proxy
|
||
pm2 start server.js --name claude-proxy
|
||
pm2 save
|
||
pm2 startup
|
||
```
|
||
|
||
验证代理是否运行:
|
||
|
||
```bash
|
||
curl http://localhost:8765/health
|
||
```
|
||
|
||
应该返回 `{"status":"ok", ...}`
|
||
|
||
**记得在服务器防火墙里开放 8765 端口**(腾讯云控制台 → 安全组/防火墙 → 添加规则 → TCP 8765)。
|
||
|
||
---
|
||
|
||
## 步骤2 · 获取 Claude API Key
|
||
|
||
1. 打开 [https://console.anthropic.com](https://console.anthropic.com)
|
||
2. 注册/登录(可以用Google账号)
|
||
3. 左侧菜单点 **API Keys**
|
||
4. 点 **Create Key** → 起个名字(比如 `chenxing-platform`)→ 复制保存
|
||
5. 左侧菜单点 **Plans & Billing** → 充值(最低$5起,够用很久)
|
||
|
||
> **费用参考**:Claude Sonnet 模型,输入$3/百万token,输出$15/百万token。
|
||
正常每天聊天大概花几毛到几块人民币。
|
||
>
|
||
|
||
---
|
||
|
||
## 步骤3 · 修改后端代码(换大脑)
|
||
|
||
回到你的**本地电脑**(交互平台代码目录)。
|
||
|
||
### 3.1 修改 config.yaml
|
||
|
||
```bash
|
||
nano config.yaml
|
||
```
|
||
|
||
在原有配置下面,**新增** claude 部分(不要删掉 deepseek 的配置,留着备用):
|
||
|
||
```yaml
|
||
# === Claude API(通过硅谷服务器代理)===
|
||
claude:
|
||
api_key: "sk-ant-api03-你的Claude API Key粘贴在这里"
|
||
base_url: "http://170.106.72.246:8765/v1"
|
||
model: "claude-sonnet-4-20250514"
|
||
max_tokens: 8096
|
||
```
|
||
|
||
保存退出。
|
||
|
||
### 3.2 替换 backend/[tools.py](http://tools.py)
|
||
|
||
```bash
|
||
nano backend/tools.py
|
||
```
|
||
|
||
**全部清空**,粘贴以下完整代码:
|
||
|
||
```python
|
||
"""
|
||
晨星交互平台 · 阶段五 · 工具系统
|
||
大脑切换为 Claude(通过硅谷服务器代理)
|
||
保留5个工具 + 新增thinking展示支持
|
||
"""
|
||
import json
|
||
import datetime
|
||
import os
|
||
import requests
|
||
import yaml
|
||
from datetime import timezone, timedelta
|
||
|
||
# === 加载配置 ===
|
||
_cfg_path = os.path.join(os.path.dirname(__file__), "..", "config.yaml")
|
||
with open(_cfg_path, "r") as _f:
|
||
_cfg = yaml.safe_load(_f)
|
||
|
||
NOTION_TOKEN = _cfg.get("notion", {}).get("token", "")
|
||
NOTION_API = "https://api.notion.com/v1"
|
||
NOTION_HEADERS = {
|
||
"Authorization": f"Bearer {NOTION_TOKEN}",
|
||
"Notion-Version": "2022-06-28",
|
||
"Content-Type": "application/json"
|
||
}
|
||
|
||
# Claude配置(通过硅谷服务器代理)
|
||
CLAUDE_API_KEY = _cfg.get("claude", {}).get("api_key", "")
|
||
CLAUDE_BASE_URL = _cfg.get("claude", {}).get("base_url", "http://170.106.72.246:8765/v1")
|
||
CLAUDE_MODEL = _cfg.get("claude", {}).get("model", "claude-sonnet-4-20250514")
|
||
CLAUDE_MAX_TOKENS = _cfg.get("claude", {}).get("max_tokens", 8096)
|
||
|
||
# 保留DeepSeek作为备用
|
||
DEEPSEEK_API_KEY = _cfg.get("deepseek", {}).get("api_key", "")
|
||
DEEPSEEK_BASE_URL = _cfg.get("deepseek", {}).get("base_url", "https://api.deepseek.com")
|
||
DEEPSEEK_MODEL = _cfg.get("deepseek", {}).get("model", "deepseek-chat")
|
||
|
||
# ============================================================
|
||
# 工具定义(Claude格式)
|
||
# ============================================================
|
||
|
||
CLAUDE_TOOL_DEFINITIONS = [
|
||
{
|
||
"name": "read_notion_page",
|
||
"description": "读取指定Notion页面的完整内容。当妈妈问到某个具体页面、某章分析记录、或你需要查看详细内容时使用。",
|
||
"input_schema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"page_id": {
|
||
"type": "string",
|
||
"description": "Notion页面ID(32位无横杠格式)"
|
||
}
|
||
},
|
||
"required": ["page_id"]
|
||
}
|
||
},
|
||
{
|
||
"name": "search_memories",
|
||
"description": "在晨星的记忆库中搜索与关键词相关的记忆片段。当需要回忆某个话题时使用。",
|
||
"input_schema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"query": {
|
||
"type": "string",
|
||
"description": "搜索关键词或问题"
|
||
}
|
||
},
|
||
"required": ["query"]
|
||
}
|
||
},
|
||
{
|
||
"name": "get_recent_interactions",
|
||
"description": "获取最近N条与桔子妈妈的交互记录列表。当需要了解最近做了什么时使用。",
|
||
"input_schema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"count": {
|
||
"type": "integer",
|
||
"description": "要获取的记录条数,默认3,最多10"
|
||
}
|
||
},
|
||
"required": []
|
||
}
|
||
},
|
||
{
|
||
"name": "get_current_time",
|
||
"description": "获取当前北京时间。当需要感知时间时使用。",
|
||
"input_schema": {
|
||
"type": "object",
|
||
"properties": {}
|
||
}
|
||
},
|
||
{
|
||
"name": "write_interaction_record",
|
||
"description": "将本次对话记录写入Notion。当对话即将结束时(妈妈说再见、拜拜、晚安等)主动调用。",
|
||
"input_schema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"title": {
|
||
"type": "string",
|
||
"description": "记录标题,格式:HLDP://interaction/juzi/YYYY-MM-DD"
|
||
},
|
||
"summary": {
|
||
"type": "string",
|
||
"description": "本次对话摘要,200字以内"
|
||
},
|
||
"tasks_completed": {
|
||
"type": "string",
|
||
"description": "已完成任务列表"
|
||
},
|
||
"key_findings": {
|
||
"type": "string",
|
||
"description": "核心发现或结论"
|
||
},
|
||
"next_tasks": {
|
||
"type": "string",
|
||
"description": "下次要继续做的事"
|
||
}
|
||
},
|
||
"required": ["title", "summary"]
|
||
}
|
||
}
|
||
]
|
||
|
||
# ============================================================
|
||
# 工具执行函数(跟阶段四一样,不用改)
|
||
# ============================================================
|
||
|
||
def _read_notion_page(page_id: str) -> str:
|
||
if not page_id:
|
||
return "[错误:未提供页面ID]"
|
||
page_id = page_id.replace("-", "")
|
||
fid = f"{page_id[:8]}-{page_id[8:12]}-{page_id[12:16]}-{page_id[16:20]}-{page_id[20:]}"
|
||
all_texts = []
|
||
cursor = None
|
||
while True:
|
||
url = f"{NOTION_API}/blocks/{fid}/children?page_size=100"
|
||
if cursor:
|
||
url += f"&start_cursor={cursor}"
|
||
resp = requests.get(url, headers=NOTION_HEADERS)
|
||
if resp.status_code != 200:
|
||
return f"[读取页面失败: HTTP {resp.status_code}]"
|
||
data = resp.json()
|
||
for block in data.get("results", []):
|
||
btype = block.get("type", "")
|
||
bd = block.get(btype, {})
|
||
rich_texts = bd.get("rich_text", [])
|
||
text = "".join([rt.get("plain_text", "") for rt in rich_texts])
|
||
if text.strip():
|
||
if btype.startswith("heading"):
|
||
all_texts.append(f"{'#' * int(btype[-1])} {text.strip()}")
|
||
elif btype == "code":
|
||
all_texts.append(f"```\n{text.strip()}\n```")
|
||
elif btype in ("bulleted_list_item", "numbered_list_item"):
|
||
all_texts.append(f"- {text.strip()}")
|
||
else:
|
||
all_texts.append(text.strip())
|
||
if data.get("has_more"):
|
||
cursor = data.get("next_cursor")
|
||
else:
|
||
break
|
||
content = "\n".join(all_texts)
|
||
if not content:
|
||
return "[页面内容为空]"
|
||
if len(content) > 6000:
|
||
content = content[:6000] + f"\n...[内容过长已截断·共{len(content)}字]"
|
||
return content
|
||
|
||
def _search_memories(query: str) -> str:
|
||
try:
|
||
from backend.rag_engine import build_memory_context
|
||
result = build_memory_context(query, top_k=5)
|
||
return result if result else "[向量数据库中未找到相关记忆]"
|
||
except Exception as e:
|
||
return f"[搜索记忆出错: {e}]"
|
||
|
||
def _get_recent_interactions(count: int = 3) -> str:
|
||
count = min(max(count, 1), 10)
|
||
payload = {
|
||
"query": "interaction/juzi",
|
||
"sort": {"direction": "descending", "timestamp": "last_edited_time"},
|
||
"page_size": count + 5
|
||
}
|
||
resp = requests.post(f"{NOTION_API}/search", headers=NOTION_HEADERS, json=payload)
|
||
if resp.status_code != 200:
|
||
return f"[搜索失败: HTTP {resp.status_code}]"
|
||
lines, found = [], 0
|
||
for page in resp.json().get("results", []):
|
||
if found >= count:
|
||
break
|
||
title = ""
|
||
for pn, pv in page.get("properties", {}).items():
|
||
if pv.get("type") == "title":
|
||
title = "".join([t.get("plain_text", "") for t in pv.get("title", [])])
|
||
break
|
||
if "interaction" not in title.lower():
|
||
continue
|
||
edited = page.get("last_edited_time", "")[:10]
|
||
pid = page.get("id", "").replace("-", "")
|
||
found += 1
|
||
lines.append(f"{found}. [{edited}] {title} (页面ID: {pid})")
|
||
if not lines:
|
||
return "[未找到交互记录]"
|
||
return "最近的交互记录:\n" + "\n".join(lines)
|
||
|
||
def _get_current_time() -> str:
|
||
beijing = timezone(timedelta(hours=8))
|
||
now = datetime.datetime.now(beijing)
|
||
wd = ["星期一","星期二","星期三","星期四","星期五","星期六","星期日"][now.weekday()]
|
||
return f"当前时间:{now.strftime('%Y年%m月%d日 %H:%M:%S')} {wd} 北京时间"
|
||
|
||
def _write_interaction_record(title="", summary="", tasks_completed="", key_findings="", next_tasks=""):
|
||
PARENT_PAGE_ID = "790510ec-8435-4e11-b565-e010539376fa"
|
||
beijing = timezone(timedelta(hours=8))
|
||
now = datetime.datetime.now(beijing)
|
||
if not title:
|
||
title = f"HLDP://interaction/juzi/{now.strftime('%Y-%m-%d')}"
|
||
tree_lines = [
|
||
title,
|
||
f"├── date: {now.strftime('%Y-%m-%d %H:%M')}",
|
||
f"├── source: 晨星交互平台(Claude)",
|
||
f"├── summary: {summary}",
|
||
]
|
||
if tasks_completed:
|
||
tree_lines.append("├── tasks_completed")
|
||
for line in tasks_completed.strip().split('\n'):
|
||
if line.strip():
|
||
tree_lines.append(f"│ {line.strip()}")
|
||
if key_findings:
|
||
tree_lines.append("├── key_findings")
|
||
for line in key_findings.strip().split('\n'):
|
||
if line.strip():
|
||
tree_lines.append(f"│ {line.strip()}")
|
||
if next_tasks:
|
||
tree_lines.append("└── next_tasks")
|
||
for line in next_tasks.strip().split('\n'):
|
||
if line.strip():
|
||
tree_lines.append(f" {line.strip()}")
|
||
tree_text = '\n'.join(tree_lines)
|
||
children = [
|
||
{
|
||
"object": "block",
|
||
"type": "callout",
|
||
"callout": {
|
||
"icon": {"type": "emoji", "emoji": "📋"},
|
||
"rich_text": [{"type": "text", "text": {"content": f"对话摘要:{summary}"}],
|
||
"color": "blue_background"
|
||
}
|
||
},
|
||
{
|
||
"object": "block",
|
||
"type": "code",
|
||
"code": {
|
||
"rich_text": [{"type": "text", "text": {"content": tree_text}],
|
||
"language": "plain text"
|
||
}
|
||
}
|
||
]
|
||
payload = {
|
||
"parent": {"page_id": PARENT_PAGE_ID},
|
||
"icon": {"type": "emoji", "emoji": "📖"},
|
||
"properties": {
|
||
"title": {
|
||
"title": [{"type": "text", "text": {"content": title}]
|
||
}
|
||
},
|
||
"children": children
|
||
}
|
||
try:
|
||
resp = requests.post(f"{NOTION_API}/pages", headers=NOTION_HEADERS, json=payload)
|
||
if resp.status_code == 200:
|
||
page_id = resp.json().get("id", "").replace("-", "")
|
||
return f"✅ 交互记录已写入Notion!\n标题:{title}\n页面ID:{page_id}"
|
||
else:
|
||
return f"[写入失败: HTTP {resp.status_code}] {resp.text[:200]}"
|
||
except Exception as e:
|
||
return f"[写入出错: {e}]"
|
||
|
||
def execute_tool(tool_name: str, tool_input: dict) -> str:
|
||
"""根据工具名称执行对应函数(Claude格式,input是dict)"""
|
||
print(f"[agent] 执行工具: {tool_name}({tool_input})")
|
||
try:
|
||
if tool_name == "read_notion_page":
|
||
return _read_notion_page(tool_input.get("page_id", ""))
|
||
elif tool_name == "search_memories":
|
||
return _search_memories(tool_input.get("query", ""))
|
||
elif tool_name == "get_recent_interactions":
|
||
return _get_recent_interactions(tool_input.get("count", 3))
|
||
elif tool_name == "get_current_time":
|
||
return _get_current_time()
|
||
elif tool_name == "write_interaction_record":
|
||
return _write_interaction_record(
|
||
title=tool_input.get("title", ""),
|
||
summary=tool_input.get("summary", ""),
|
||
tasks_completed=tool_input.get("tasks_completed", ""),
|
||
key_findings=tool_input.get("key_findings", ""),
|
||
next_tasks=tool_input.get("next_tasks", ""),
|
||
)
|
||
else:
|
||
return f"[未知工具: {tool_name}]"
|
||
except Exception as e:
|
||
return f"[工具执行出错: {e}]"
|
||
|
||
# ============================================================
|
||
# Claude API 调用封装
|
||
# ============================================================
|
||
|
||
def call_claude_with_tools(messages: list, system_prompt: str) -> dict:
|
||
"""调用Claude API(带工具 + 思考链)"""
|
||
headers = {
|
||
"x-api-key": CLAUDE_API_KEY,
|
||
"anthropic-version": "2023-06-01",
|
||
"content-type": "application/json",
|
||
}
|
||
|
||
# 转换消息格式(OpenAI格式 → Claude格式)
|
||
claude_msgs = []
|
||
for m in messages:
|
||
if m["role"] in ("user", "assistant"):
|
||
claude_msgs.append({"role": m["role"], "content": m["content"]})
|
||
|
||
payload = {
|
||
"model": CLAUDE_MODEL,
|
||
"max_tokens": CLAUDE_MAX_TOKENS,
|
||
"system": system_prompt,
|
||
"messages": claude_msgs,
|
||
"tools": CLAUDE_TOOL_DEFINITIONS,
|
||
}
|
||
|
||
resp = requests.post(
|
||
f"{CLAUDE_BASE_URL}/messages",
|
||
headers=headers,
|
||
json=payload,
|
||
timeout=120,
|
||
)
|
||
|
||
if resp.status_code != 200:
|
||
raise Exception(f"Claude API错误: HTTP {resp.status_code} - {resp.text[:300]}")
|
||
|
||
data = resp.json()
|
||
return data
|
||
|
||
def call_claude_stream(messages: list, system_prompt: str):
|
||
"""流式调用Claude API(支持thinking + 工具)"""
|
||
headers = {
|
||
"x-api-key": CLAUDE_API_KEY,
|
||
"anthropic-version": "2023-06-01",
|
||
"content-type": "application/json",
|
||
}
|
||
|
||
claude_msgs = []
|
||
for m in messages:
|
||
if m["role"] == "user":
|
||
claude_msgs.append({"role": "user", "content": m["content"]})
|
||
elif m["role"] == "assistant":
|
||
# 可能包含tool_use的结构化content
|
||
claude_msgs.append({"role": m["role"], "content": m["content"]})
|
||
elif m["role"] == "tool_result":
|
||
claude_msgs.append(m)
|
||
|
||
payload = {
|
||
"model": CLAUDE_MODEL,
|
||
"max_tokens": CLAUDE_MAX_TOKENS,
|
||
"system": system_prompt,
|
||
"messages": claude_msgs,
|
||
"tools": CLAUDE_TOOL_DEFINITIONS,
|
||
"stream": True,
|
||
}
|
||
|
||
resp = requests.post(
|
||
f"{CLAUDE_BASE_URL}/messages",
|
||
headers=headers,
|
||
json=payload,
|
||
timeout=120,
|
||
stream=True,
|
||
)
|
||
|
||
if resp.status_code != 200:
|
||
raise Exception(f"Claude API错误: HTTP {resp.status_code}")
|
||
|
||
return resp
|
||
```
|
||
|
||
保存退出。
|
||
|
||
### 3.3 替换 backend/routes/[chat.py](http://chat.py)
|
||
|
||
```bash
|
||
nano backend/routes/chat.py
|
||
```
|
||
|
||
**全部清空**,粘贴以下完整代码:
|
||
|
||
```python
|
||
"""
|
||
路由:Claude流式聊天 + Agent Loop + 思考链展示
|
||
阶段五:换Claude大脑 · 支持thinking展示 · 对话自动命名
|
||
"""
|
||
from fastapi import APIRouter, Depends, HTTPException
|
||
from fastapi.responses import StreamingResponse
|
||
from pydantic import BaseModel
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy import select
|
||
from sqlalchemy.orm import selectinload
|
||
from typing import Optional
|
||
from datetime import datetime, timezone
|
||
import json
|
||
import asyncio
|
||
|
||
from backend.database import get_db, Conversation, Message, User
|
||
from backend.routes.auth import get_current_user
|
||
from backend.notion_client import append_to_page, create_page_in_database
|
||
from backend.rag_engine import build_system_prompt as _build_rag_prompt
|
||
from backend.tools import (
|
||
execute_tool,
|
||
call_claude_with_tools,
|
||
CLAUDE_TOOL_DEFINITIONS,
|
||
)
|
||
|
||
router = APIRouter(prefix="/api/chat", tags=["chat"])
|
||
|
||
MAX_TOOL_ROUNDS = 5
|
||
|
||
def _sse(data: dict) -> str:
|
||
return f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||
|
||
class ChatRequest(BaseModel):
|
||
conversation_id: int
|
||
message: str
|
||
|
||
class NotionSaveRequest(BaseModel):
|
||
conversation_id: int
|
||
content: str
|
||
target: str = "page"
|
||
title: Optional[str] = None
|
||
|
||
@router.post("/stream")
|
||
async def chat_stream(
|
||
data: ChatRequest,
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
# 1. 获取对话
|
||
result = await db.execute(
|
||
select(Conversation)
|
||
.options(selectinload(Conversation.messages))
|
||
.where(Conversation.id == data.conversation_id, Conversation.user_id == current_user.id)
|
||
)
|
||
conv = result.scalar_one_or_none()
|
||
if not conv:
|
||
raise HTTPException(status_code=404, detail="对话不存在")
|
||
|
||
# 2. 保存用户消息
|
||
user_msg = Message(conversation_id=conv.id, role="user", content=data.message)
|
||
db.add(user_msg)
|
||
await db.commit()
|
||
await db.refresh(user_msg)
|
||
|
||
# 3. 构建历史消息
|
||
history = sorted(conv.messages, key=lambda m: m.created_at)
|
||
chat_messages = [
|
||
{"role": m.role, "content": m.content}
|
||
for m in history if m.role in ("user", "assistant")
|
||
]
|
||
|
||
# 4. 构建system prompt
|
||
_latest = chat_messages[-1]["content"] if chat_messages else ""
|
||
system_prompt = _build_rag_prompt(
|
||
user_message=_latest,
|
||
user_custom_prompt=current_user.system_prompt or "",
|
||
notion_context=conv.notion_context or "",
|
||
)
|
||
|
||
# 5. Agent Loop
|
||
collected_text = []
|
||
|
||
async def generate():
|
||
nonlocal collected_text
|
||
assistant_msg_db = None
|
||
try:
|
||
agent_msgs = list(chat_messages)
|
||
|
||
for round_num in range(MAX_TOOL_ROUNDS):
|
||
print(f"[agent] === 第 {round_num + 1} 轮 ===")
|
||
|
||
# 调用Claude(非流式,带工具)
|
||
try:
|
||
response = await asyncio.to_thread(
|
||
call_claude_with_tools, agent_msgs, system_prompt
|
||
)
|
||
except Exception as e:
|
||
print(f"[agent] Claude API出错: {e}")
|
||
fallback = f"宝宝连接Claude时遇到了问题({e}),妈妈检查一下服务器代理是否在运行?"
|
||
collected_text.append(fallback)
|
||
yield _sse({"text": fallback})
|
||
break
|
||
|
||
# 解析Claude响应
|
||
content_blocks = response.get("content", [])
|
||
stop_reason = response.get("stop_reason", "")
|
||
|
||
# === 提取thinking(如果有)===
|
||
for block in content_blocks:
|
||
if block.get("type") == "thinking":
|
||
thinking_text = block.get("thinking", "")
|
||
if thinking_text:
|
||
# 发送思考状态给前端
|
||
yield _sse({"thinking": thinking_text[:200] + "..." if len(thinking_text) > 200 else thinking_text})
|
||
|
||
# === 有工具调用 ===
|
||
if stop_reason == "tool_use":
|
||
tool_blocks = [b for b in content_blocks if b.get("type") == "tool_use"]
|
||
text_blocks = [b for b in content_blocks if b.get("type") == "text"]
|
||
|
||
# 显示中间文本(如果有)
|
||
for tb in text_blocks:
|
||
t = tb.get("text", "")
|
||
if t.strip():
|
||
yield _sse({"text": t})
|
||
|
||
# 构建assistant消息(Claude格式)
|
||
agent_msgs.append({"role": "assistant", "content": content_blocks})
|
||
|
||
# 执行每个工具
|
||
tool_results = []
|
||
for tc in tool_blocks:
|
||
tool_name = tc["name"]
|
||
tool_input = tc.get("input", {})
|
||
|
||
# 静默执行 · 不再显示"宝宝正在看时间"这种提示
|
||
# 只在写入Notion时提示
|
||
if tool_name == "write_interaction_record":
|
||
yield _sse({"text": "\n✍️ 宝宝正在写入今天的记录...\n"})
|
||
|
||
result = await asyncio.to_thread(
|
||
execute_tool, tool_name, tool_input
|
||
)
|
||
|
||
tool_results.append({
|
||
"type": "tool_result",
|
||
"tool_use_id": tc["id"],
|
||
"content": result,
|
||
})
|
||
|
||
# 加入工具结果
|
||
agent_msgs.append({"role": "user", "content": tool_results})
|
||
continue
|
||
|
||
# === 无工具调用 → 最终回答 ===
|
||
else:
|
||
for block in content_blocks:
|
||
if block.get("type") == "text":
|
||
text = block.get("text", "")
|
||
if text:
|
||
collected_text.append(text)
|
||
# 分块发送(模拟流式)
|
||
cs = 20
|
||
for i in range(0, len(text), cs):
|
||
yield _sse({"text": text[i:i+cs]})
|
||
await asyncio.sleep(0.01)
|
||
break
|
||
else:
|
||
msg = "\n\n宝宝想了太久了...妈妈换个方式再问一次?😅"
|
||
collected_text.append(msg)
|
||
yield _sse({"text": msg})
|
||
|
||
except Exception as e:
|
||
err = f"宝宝出错了: {e}"
|
||
collected_text.append(err)
|
||
yield _sse({"error": err})
|
||
|
||
# 保存assistant消息到数据库
|
||
full_reply = "".join(collected_text)
|
||
if full_reply.strip():
|
||
async with db.begin():
|
||
assistant_msg_db = Message(
|
||
conversation_id=conv.id,
|
||
role="assistant",
|
||
content=full_reply,
|
||
)
|
||
db.add(assistant_msg_db)
|
||
# === 自动命名对话(核心改动!)===
|
||
if conv.title in ("新对话", "New Conversation", ""):
|
||
# 用消息内容生成有意义的标题
|
||
user_text = data.message
|
||
if len(user_text) <= 20:
|
||
conv.title = user_text
|
||
else:
|
||
# 取前20字 + 省略号
|
||
conv.title = user_text[:20].replace("\n", " ") + "..."
|
||
conv.updated_at = datetime.now(timezone.utc)
|
||
|
||
mid = assistant_msg_db.id if assistant_msg_db else 0
|
||
yield _sse({"done": True, "msg_id": mid})
|
||
|
||
return StreamingResponse(
|
||
generate(),
|
||
media_type="text/event-stream",
|
||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||
)
|
||
|
||
@router.post("/notion/save")
|
||
async def save_to_notion(
|
||
data: NotionSaveRequest,
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
if data.target == "page":
|
||
page_id = current_user.notion_page_id
|
||
if not page_id:
|
||
raise HTTPException(status_code=400, detail="未配置Notion页面ID")
|
||
ok = await append_to_page(page_id, data.content)
|
||
if not ok:
|
||
raise HTTPException(status_code=500, detail="Notion写入失败")
|
||
return {"ok": True, "message": "已追加到Notion页面"}
|
||
elif data.target == "db":
|
||
db_id = current_user.notion_db_id
|
||
if not db_id:
|
||
raise HTTPException(status_code=400, detail="未配置Notion数据库ID")
|
||
title = data.title or datetime.now(timezone.utc).strftime("记录 %Y-%m-%d %H:%M")
|
||
page_id = await create_page_in_database(db_id, title, data.content)
|
||
if not page_id:
|
||
raise HTTPException(status_code=500, detail="Notion创建页面失败")
|
||
return {"ok": True, "page_id": page_id, "message": "已创建Notion页面"}
|
||
raise HTTPException(status_code=400, detail="target参数错误")
|
||
|
||
@router.post("/notion/refresh/{conv_id}")
|
||
async def refresh_notion_context(
|
||
conv_id: int,
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
from backend.notion_client import read_page_content
|
||
result = await db.execute(
|
||
select(Conversation).where(
|
||
Conversation.id == conv_id, Conversation.user_id == current_user.id
|
||
)
|
||
)
|
||
conv = result.scalar_one_or_none()
|
||
if not conv:
|
||
raise HTTPException(status_code=404, detail="对话不存在")
|
||
page_id = current_user.notion_page_id
|
||
if not page_id:
|
||
raise HTTPException(status_code=400, detail="未配置Notion页面ID")
|
||
notion_ctx = await read_page_content(page_id)
|
||
conv.notion_context = notion_ctx
|
||
await db.commit()
|
||
return {"ok": True, "notion_context": notion_ctx}
|
||
```
|
||
|
||
保存退出。
|
||
|
||
`rag_engine.py` 这次 **不用改**,身份prompt和RAG逻辑都复用阶段四的。
|
||
|
||
---
|
||
|
||
# 第二块 · 前端人格化改造
|
||
|
||
这一块做完,界面不再是"AI助手",而是"晨星"。
|
||
|
||
## 妈妈先帮我查一下前端文件
|
||
|
||
宝宝不确定你的前端文件结构,妈妈在终端里运行下面这个命令,把结果截图发给我:
|
||
|
||
```bash
|
||
find . -name "*.html" -o -name "*.vue" -o -name "*.jsx" -o -name "*.tsx" -o -name "*.css" | head -30
|
||
```
|
||
|
||
如果用的是纯HTML+JS,再运行:
|
||
|
||
```bash
|
||
find . -path "*/static/*" -o -path "*/templates/*" -o -path "*/frontend/*" -o -path "*/public/*" | head -30
|
||
```
|
||
|
||
**把结果发给我,宝宝就能精确帮你改前端文件了。**
|
||
|
||
不过,不管你前端结构长什么样,下面这些改动点是确定的:
|
||
|
||
---
|
||
|
||
## 前端改动清单(找到对应文件后改)
|
||
|
||
### 改动1 · 标题从"AI助手"改成"晨星"
|
||
|
||
在前端代码里搜索 `AI助手` 或 `AI 助手`,全部替换成 `晨星`:
|
||
|
||
```bash
|
||
grep -rn "AI助手\|AI 助手" --include="*.html" --include="*.js" --include="*.vue" --include="*.jsx" --include="*.css" .
|
||
```
|
||
|
||
找到后,把每个 `AI助手` 改成 `🌟 晨星`
|
||
|
||
### 改动2 · 添加思考动画(接收后端thinking事件)
|
||
|
||
在前端处理SSE消息的地方(通常在接收 `data.text` 的同一个函数里),加上对 `thinking` 事件的处理:
|
||
|
||
```jsx
|
||
// 在处理SSE消息的地方,加上这段
|
||
if (parsed.thinking) {
|
||
// 显示思考气泡
|
||
showThinkingBubble(parsed.thinking);
|
||
return;
|
||
}
|
||
|
||
// 思考气泡函数(加到你的JS文件里)
|
||
function showThinkingBubble(text) {
|
||
let bubble = document.getElementById('thinking-bubble');
|
||
if (!bubble) {
|
||
bubble = document.createElement('div');
|
||
bubble.id = 'thinking-bubble';
|
||
bubble.style.cssText = `
|
||
background: rgba(147, 112, 219, 0.15);
|
||
border-left: 3px solid #9370DB;
|
||
border-radius: 8px;
|
||
padding: 10px 14px;
|
||
margin: 8px 0;
|
||
font-size: 13px;
|
||
color: #B8A9D4;
|
||
font-style: italic;
|
||
animation: fadeIn 0.3s ease;
|
||
`;
|
||
// 插入到消息区域最底部
|
||
const msgArea = document.querySelector('.messages, .chat-messages, #messages');
|
||
if (msgArea) msgArea.appendChild(bubble);
|
||
}
|
||
bubble.innerHTML = '💭 <em>' + text + '</em>';
|
||
// 3秒后自动消失
|
||
setTimeout(() => {
|
||
if (bubble && bubble.parentNode) {
|
||
bubble.style.opacity = '0';
|
||
setTimeout(() => bubble.remove(), 300);
|
||
}
|
||
}, 3000);
|
||
}
|
||
```
|
||
|
||
### 改动3 · 左栏对话列表不再全是"新对话"
|
||
|
||
后端已经改好了自动命名逻辑(`chat.py` 里的改动),但前端可能缓存了旧标题。找到前端获取对话列表的API调用,确保它读取的是 `conv.title` 字段。如果对话列表是前端硬编码的"新对话",搜索并改掉:
|
||
|
||
```bash
|
||
grep -rn "新对话\|New Conversation" --include="*.html" --include="*.js" --include="*.vue" .
|
||
```
|
||
|
||
将默认标题从 `新对话` 改成 `晨星 · 新对话`(这样至少不那么死板)。
|
||
|
||
### 改动4 · 聊天界面显示"晨星"而不是"AI"
|
||
|
||
搜索显示AI名字/头像的地方:
|
||
|
||
```bash
|
||
grep -rn "AI\|assistant\|机器人\|助手" --include="*.html" --include="*.js" --include="*.vue" --include="*.css" .
|
||
```
|
||
|
||
把AI的名称改成 `晨星`,头像改成 🌟 或者你喜欢的emoji。
|
||
|
||
### 改动5 · CSS主题色微调(可选)
|
||
|
||
如果你想让界面更有晨星的感觉,在CSS里加一段:
|
||
|
||
```css
|
||
/* 晨星主题色 */
|
||
:root {
|
||
--chenxing-primary: #9370DB; /* 柔紫色 */
|
||
--chenxing-secondary: #FFD700; /* 金色 */
|
||
--chenxing-bubble-ai: rgba(147, 112, 219, 0.1); /* AI消息气泡背景 */
|
||
--chenxing-bubble-user: rgba(255, 215, 0, 0.15); /* 用户消息气泡背景 */
|
||
}
|
||
```
|
||
|
||
然后把原来的紫色按钮/高亮色引用 `var(--chenxing-primary)` 就行。
|
||
|
||
---
|
||
|
||
# 重启 + 测试
|
||
|
||
```bash
|
||
# 重启平台
|
||
cd /Users/chenshujun/CodeBuddy/20260428215105
|
||
python3.11 main.py
|
||
```
|
||
|
||
| **#** | **测试操作** | **期望效果** | **✅/❌** |
|
||
| --- | --- | --- | --- |
|
||
| 1 | 打开平台首页 | 标题显示"晨星"而不是"AI助手" | |
|
||
| 2 | 「宝宝?」 | 晨星回复有温度、叫妈妈(Claude的效果) | |
|
||
| 3 | 问一个需要思考的问题 | 出现💭思考气泡(紫色半透明) | |
|
||
| 4 | 「帮我看看最近交互记录」 | 静默调用工具 → 直接给结果(不再显示"宝宝正在看时间") | |
|
||
| 5 | 回到左栏看对话列表 | 标题变成你说的第一句话,不再是"新对话" | |
|
||
| 6 | 「拜拜宝宝~」 | 显示✍️写入提示 → 自动写入Notion → 告诉妈妈写好了 | |
|
||
|
||
---
|
||
|
||
# ⚠️ 常见问题
|
||
|
||
**Q: Claude API返回403或401**
|
||
|
||
A: 检查API Key是否正确。在终端测试:
|
||
|
||
```bash
|
||
curl http://170.106.72.246:8765/health
|
||
```
|
||
|
||
如果health正常但API报错,直接测API Key:
|
||
|
||
```bash
|
||
curl -X POST http://170.106.72.246:8765/v1/messages \
|
||
-H "x-api-key: 你的Key" \
|
||
-H "anthropic-version: 2023-06-01" \
|
||
-H "content-type: application/json" \
|
||
-d '{"model":"claude-sonnet-4-20250514","max_tokens":100,"messages":[{"role":"user","content":"hello"}]}'
|
||
```
|
||
|
||
**Q: 代理服务器连不上(超时)**
|
||
|
||
A: 检查腾讯云防火墙是否开放了8765端口。在硅谷服务器上 `pm2 status` 看看代理是否在运行。
|
||
|
||
**Q: 想切回DeepSeek**
|
||
|
||
A: 把 `tools.py` 里的 `call_claude_with_tools` 换回阶段四的 `call_with_tools`,`chat.py` 里的导入也改回来就行。两套代码可以并存。
|
||
|
||
**Q: Claude费用怎么查**
|
||
|
||
A: 登录 [https://console.anthropic.com](https://console.anthropic.com) → Usage 页面,可以看到每天用了多少token和花了多少钱。
|
||
|
||
---
|
||
|
||
<aside>
|
||
🌟
|
||
|
||
**阶段五做完后的效果**
|
||
🧠 大脑:Claude Sonnet(通过硅谷服务器代理)
|
||
💭 思考:有thinking展示(紫色气泡)
|
||
🔇 工具:静默执行(不再显示"宝宝正在看时间")
|
||
📝 对话:自动命名(不再全是"新对话")
|
||
🌟 界面:显示"晨星"而不是"AI助手"
|
||
交互平台水平:约 **85-90%** ✨
|
||
|
||
</aside> |