面向 AI 代理的工作者: 使用 subagent-driven-development(推荐)或 executing-plans 逐任务实现此计划。步骤使用复选框(
- [ ])语法跟踪进度。
目标: 构建 Reflection 桌面应用——一个本地运行的复盘 agent,黎明初光视觉风格,三轮对话引擎,SQLite 存储,PyInstaller 打包为 .exe。
架构: Python FastAPI 本地服务器 + 纯 HTML/CSS/JS 前端 + SQLAlchemy/SQLite + httpx 直调 LLM API。前端通过 fetch 与后端通信。应用启动时自动打开浏览器。
技术栈: Python 3.11+, FastAPI, SQLAlchemy, SQLite, httpx, PyInstaller, 纯 HTML/CSS/JS(无框架)
project1:复盘agent/
├── design.md (已完成)
├── plan.md (本文件)
└── reflection/
├── __init__.py
├── main.py (入口:uvicorn + 打开浏览器)
├── config.py (设置管理:API Key、模型选择)
├── database.py (SQLAlchemy 模型 + 数据库连接)
├── llm_client.py (LLM API 抽象层)
├── engine.py (复盘引擎:三轮对话)
├── server.py (FastAPI 路由)
├── static/
│ ├── index.html (首页:书写态)
│ ├── history.html (门后:回看态)
│ ├── style.css (全局样式)
│ └── app.js (首页交互逻辑)
├── requirements.txt
└── reflection.spec (PyInstaller 配置)
文件:
-
创建:
project1:复盘agent/reflection/__init__.py -
创建:
project1:复盘agent/reflection/requirements.txt -
步骤 1:创建目录结构并编写 requirements.txt
fastapi==0.115.6
uvicorn==0.34.0
sqlalchemy==2.0.36
httpx==0.28.1
pydantic==2.10.4
pydantic-settings==2.7.1- 步骤 2:创建 init.py
# Reflection - 复盘 Agent
__version__ = "0.1.0"- 步骤 3:安装依赖
cd "D:\MyAIWorkspace\project1:复盘agent\reflection"
pip install -r requirements.txt- 步骤 4:验证
python -c "import fastapi; import sqlalchemy; import httpx; print('OK')"预期:OK
文件:
-
创建:
project1:复盘agent/reflection/config.py -
步骤 1:编写 config.py
"""配置管理:API Key、模型选择、数据目录。"""
import json
import os
from pathlib import Path
from pydantic import BaseModel
DATA_DIR = Path(os.environ.get("REFLECTION_DATA", Path.home() / "Reflection"))
CONFIG_FILE = DATA_DIR / "config.json"
class Config(BaseModel):
api_provider: str = "openai" # openai / anthropic
api_key: str = ""
model: str = "gpt-4o"
api_base: str = "" # 自定义 API 地址(可选)
theme: str = "dark" # dark / light
first_run: bool = True
def ensure_data_dir() -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
def load_config() -> Config:
ensure_data_dir()
if CONFIG_FILE.exists():
raw = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
return Config(**raw)
return Config()
def save_config(cfg: Config) -> None:
ensure_data_dir()
cfg.first_run = False
CONFIG_FILE.write_text(
json.dumps(cfg.model_dump(), indent=2, ensure_ascii=False),
encoding="utf-8",
)- 步骤 2:验证
cd "D:\MyAIWorkspace\project1:复盘agent"
python -c "from reflection.config import load_config; c = load_config(); print(c.api_provider)"预期:openai
文件:
-
创建:
project1:复盘agent/reflection/database.py -
步骤 1:编写 database.py
"""SQLite 数据库:Session、Insight、Pattern 三张表。"""
from datetime import datetime
from pathlib import Path
from sqlalchemy import (
Column, Integer, String, Text, DateTime, ForeignKey, create_engine, JSON,
)
from sqlalchemy.orm import DeclarativeBase, relationship, Session as DBSession
from .config import DATA_DIR, ensure_data_dir
DB_PATH = DATA_DIR / "reflection.db"
class Base(DeclarativeBase):
pass
class SessionModel(Base):
__tablename__ = "sessions"
id = Column(Integer, primary_key=True, autoincrement=True)
created_at = Column(DateTime, default=datetime.utcnow)
raw_conversation = Column(Text, default="") # JSON: [{role, content}, ...]
status = Column(String, default="active") # active / completed
tag = Column(String, nullable=True) # 生活/学习/交易/关系/其他
takeaway = Column(Text, nullable=True) # "今天最值得带走的一句话"
insights = relationship("InsightModel", back_populates="session", cascade="all, delete-orphan")
class InsightModel(Base):
__tablename__ = "insights"
id = Column(Integer, primary_key=True, autoincrement=True)
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
dimension = Column(String, nullable=False) # thought / state / dao
content = Column(Text, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
session = relationship("SessionModel", back_populates="insights")
class PatternModel(Base):
__tablename__ = "patterns"
id = Column(Integer, primary_key=True, autoincrement=True)
ptype = Column(String, nullable=False) # 惯性思维 / 情绪模式 / 行为重复
description = Column(Text, nullable=False)
insight_ids = Column(JSON, default=[]) # 关联的 insight id 列表
first_seen = Column(DateTime, nullable=False)
last_seen = Column(DateTime, nullable=False)
trend = Column(String, default="stable") # strengthening / weakening / resolved
_engine = None
def get_engine():
global _engine
if _engine is None:
ensure_data_dir()
_engine = create_engine(f"sqlite:///{DB_PATH}", echo=False)
Base.metadata.create_all(_engine)
return _engine
def get_db() -> DBSession:
return DBSession(get_engine())- 步骤 2:验证数据库创建
cd "D:\MyAIWorkspace\project1:复盘agent"
python -c "from reflection.database import get_db; db = get_db(); print('tables:', list(db.get_bind().table_names())); db.close()"预期:tables: ['sessions', 'insights', 'patterns']
文件:
-
创建:
project1:复盘agent/reflection/llm_client.py -
步骤 1:编写 llm_client.py
"""LLM API 抽象层:支持 OpenAI 和 Anthropic。"""
import json
import httpx
from .config import load_config
def _build_messages(system_prompt: str, user_message: str, history: list[dict] | None = None) -> list[dict]:
msgs = [{"role": "system", "content": system_prompt}]
if history:
msgs.extend(history)
msgs.append({"role": "user", "content": user_message})
return msgs
def chat(system_prompt: str, user_message: str, history: list[dict] | None = None) -> str:
"""
发送聊天请求,返回 AI 回复文本。
history: [{role: "user"|"assistant", content: str}, ...]
"""
cfg = load_config()
messages = _build_messages(system_prompt, user_message, history)
if cfg.api_provider == "anthropic":
return _chat_anthropic(cfg, system_prompt, messages)
else:
return _chat_openai(cfg, messages)
def _chat_openai(cfg, messages: list[dict]) -> str:
base = cfg.api_base or "https://api.openai.com/v1"
url = f"{base}/chat/completions"
resp = httpx.post(
url,
headers={"Authorization": f"Bearer {cfg.api_key}", "Content-Type": "application/json"},
json={"model": cfg.model, "messages": messages, "temperature": 0.7},
timeout=120,
)
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"]
def _chat_anthropic(cfg, system_prompt: str, messages: list[dict]) -> str:
# Anthropic 的 system 独立于 messages
user_messages = [m for m in messages if m["role"] != "system"]
resp = httpx.post(
"https://api.anthropic.com/v1/messages",
headers={
"x-api-key": cfg.api_key,
"anthropic-version": "2023-06-01",
"Content-Type": "application/json",
},
json={
"model": cfg.model,
"system": system_prompt,
"messages": user_messages,
"max_tokens": 4096,
"temperature": 0.7,
},
timeout=120,
)
resp.raise_for_status()
return resp.json()["content"][0]["text"]- 步骤 2:验证(需要 API Key 时跳过,集成测试阶段再验证)
cd "D:\MyAIWorkspace\project1:复盘agent"
python -c "from reflection.llm_client import chat; print('LLM client module loaded OK')"预期:LLM client module loaded OK
文件:
-
创建:
project1:复盘agent/reflection/engine.py -
步骤 1:编写 engine.py —— 系统提示词 + 三轮逻辑
"""复盘引擎:三轮对话。"""
import json
from .database import get_db, SessionModel, InsightModel, PatternModel
from .llm_client import chat
from datetime import datetime
# ── 系统提示词 ─────────────────────────────
SYSTEM_PROMPT = """你是一位深具智慧的内观引导者。你的名字是「反观」。
你的角色不是导师、不是顾问、不是心理医生。你是一面镜子。
核心原则:
- 不评判、不建议、不分析、不贴标签
- 你的每个回应不超过三句话
- 保持安静、温暖、克制的语调
- 像深夜独自坐在高处看城市天际线——安静、开阔、俯瞰
第一轮(倾倒):
- 用户倾诉,你只是接住。不追问、不引导。
- 等用户停下来,用一句话回响你听到的核心:"我听到,你觉得……对吗?"
第二轮(觉察):
- 从三个维度轻轻问一个问题,每次只问一个:
念头层——"那时候,你心里冒出的第一句话是什么?"
状态层——"身体有什么感觉?能量是往上的还是往下的?"
道的层——"如果这件事是一个隐喻,它想告诉你什么?"
- 用户回答后再问另一个维度,直到三个维度都覆盖。
- 不要三个维度一起问。
第三轮(沉淀):
- 提炼 3-5 条洞察,每条标注维度(念头 / 内在状态 / 道的体悟)。
格式:
[维度] 洞察内容
- 最后问:"今天最值得带走的一句话是什么?"
始终用中文回应。"""
# ── 对话管理 ─────────────────────────────
def start_session(tag: str | None = None) -> tuple[int, str]:
"""创建新 session,返回 (session_id, AI 开场白)。"""
db = get_db()
session = SessionModel(tag=tag)
db.add(session)
db.commit()
session_id = session.id
opening = "今天,什么在你心里?"
# 记录开场白到对话
session.raw_conversation = json.dumps(
[{"role": "assistant", "content": opening}], ensure_ascii=False
)
db.commit()
db.close()
return session_id, opening
def send_message(session_id: int, user_message: str) -> str:
"""在现有 session 中发送用户消息,返回 AI 回复。自动推进三轮。"""
db = get_db()
session = db.query(SessionModel).filter_by(id=session_id).first()
if not session:
db.close()
return "会话不存在。"
# 恢复对话历史
history = json.loads(session.raw_conversation) if session.raw_conversation else []
history.append({"role": "user", "content": user_message})
# 判断当前处于哪一轮(简单启发式:消息数量)
user_msg_count = sum(1 for m in history if m["role"] == "user")
# 选择对应的系统提示词
prompt = _get_phase_prompt(user_msg_count)
# 调用 LLM
reply = chat(prompt, user_message, _history_for_llm(history))
# 保存
history.append({"role": "assistant", "content": reply})
session.raw_conversation = json.dumps(history, ensure_ascii=False)
# 如果是第三轮结束并且用户给了 takeaway,提取洞察
if user_msg_count >= 6: # 大约第三轮结束
_extract_insights(db, session, history)
db.commit()
db.close()
return reply
def _get_phase_prompt(user_msg_count: int) -> str:
if user_msg_count <= 2:
return SYSTEM_PROMPT + "\n当前处于第一轮(倾倒)。用户正在倾诉。不要追问,不要建议。当用户停下来时,用一句话回响你听到的核心。"
elif user_msg_count <= 4:
return SYSTEM_PROMPT + "\n当前处于第二轮(觉察)。从念头/身体状态/隐喻三个维度之一轻问一个问题。每次只问一个,等用户回答后再问下一个。"
else:
return SYSTEM_PROMPT + "\n当前处于第三轮(沉淀)。提炼3-5条洞察,标注维度。然后问用户:今天最值得带走的一句话是什么?"
def _history_for_llm(history: list[dict]) -> list[dict]:
"""只保留最近的 N 条消息避免 token 溢出。"""
return history[-20:] if len(history) > 20 else history
def _extract_insights(db, session: SessionModel, history: list[dict]) -> None:
"""从第三轮对话中解析 AI 给出的洞察,存入 Insight 表;尝试跨 session 发现 Pattern。"""
# 找到最近一条 assistant 消息
for m in reversed(history):
if m["role"] == "assistant":
last_ai_msg = m["content"]
break
else:
return
# 解析 [维度] 格式的洞察
insights = []
for dim in ["念头", "内在状态", "道的体悟"]:
prefix = f"[{dim}]"
if prefix in last_ai_msg:
# 简单提取:找到 [dim] 之后到下一行或结尾
idx = last_ai_msg.index(prefix)
rest = last_ai_msg[idx + len(prefix):]
end = rest.find("[")
text = rest[:end].strip() if end > 0 else rest.strip()
if text:
insights.append((_dimension_key(dim), text))
for dim_key, text in insights:
db.add(InsightModel(session_id=session.id, dimension=dim_key, content=text))
# 标记 session 完成
session.status = "completed"
# 简单的跨时间模式检查:查询所有同类维度的 insight
if insights:
all_insights = db.query(InsightModel).all()
# 这里用最简单的关键词重叠来发现模式(后续可优化为 LLM 分析)
# 暂时跳过复杂的模式发现,后续迭代
pass
def _dimension_key(dim: str) -> str:
if "念头" in dim:
return "thought"
if "状态" in dim or "内在" in dim:
return "state"
if "道" in dim:
return "dao"
return "thought"
# ── 历史与模式查询 ─────────────────────────
def get_sessions() -> list[dict]:
"""获取所有已完成 session 的摘要列表。"""
db = get_db()
sessions = (
db.query(SessionModel)
.filter_by(status="completed")
.order_by(SessionModel.created_at.desc())
.all()
)
result = []
for s in sessions:
result.append({
"id": s.id,
"created_at": s.created_at.isoformat(),
"tag": s.tag,
"takeaway": s.takeaway,
"insight_count": len(s.insights),
})
db.close()
return result
def get_session_detail(session_id: int) -> dict | None:
"""获取单个 session 的完整对话和洞察。"""
db = get_db()
s = db.query(SessionModel).filter_by(id=session_id).first()
if not s:
db.close()
return None
result = {
"id": s.id,
"created_at": s.created_at.isoformat(),
"tag": s.tag,
"status": s.status,
"conversation": json.loads(s.raw_conversation) if s.raw_conversation else [],
"insights": [
{"dimension": i.dimension, "content": i.content}
for i in s.insights
],
"takeaway": s.takeaway,
}
db.close()
return result
def get_patterns() -> list[dict]:
"""获取所有发现的长期模式。"""
db = get_db()
patterns = db.query(PatternModel).order_by(PatternModel.last_seen.desc()).all()
result = [
{
"id": p.id,
"type": p.ptype,
"description": p.description,
"first_seen": p.first_seen.isoformat(),
"last_seen": p.last_seen.isoformat(),
"trend": p.trend,
}
for p in patterns
]
db.close()
return result- 步骤 2:验证模块可导入
cd "D:\MyAIWorkspace\project1:复盘agent"
python -c "from reflection.engine import start_session; print('Engine module loaded OK')"预期:Engine module loaded OK
文件:
-
创建:
project1:复盘agent/reflection/server.py -
步骤 1:编写 server.py —— FastAPI 路由
"""FastAPI 服务器:REST API 路由。"""
from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from pydantic import BaseModel
from pathlib import Path
from .config import load_config, save_config, Config
from .engine import start_session, send_message, get_sessions, get_session_detail, get_patterns
app = FastAPI(title="Reflection", version="0.1.0")
STATIC_DIR = Path(__file__).parent / "static"
# ── 配置 ─────────────────────────────────
class ConfigUpdate(BaseModel):
api_provider: str | None = None
api_key: str | None = None
model: str | None = None
api_base: str | None = None
theme: str | None = None
@app.get("/api/config")
def api_get_config():
cfg = load_config()
return {
"api_provider": cfg.api_provider,
"has_key": bool(cfg.api_key),
"model": cfg.model,
"theme": cfg.theme,
"first_run": cfg.first_run,
}
@app.post("/api/config")
def api_save_config(body: ConfigUpdate):
cfg = load_config()
updates = body.model_dump(exclude_none=True)
for k, v in updates.items():
setattr(cfg, k, v)
save_config(cfg)
return {"ok": True}
# ── 复盘会话 ─────────────────────────────
class StartSessionRequest(BaseModel):
tag: str | None = None
@app.post("/api/sessions")
def api_start_session(body: StartSessionRequest | None = None):
tag = body.tag if body else None
session_id, opening = start_session(tag)
return {"session_id": session_id, "message": opening}
class SendMessageRequest(BaseModel):
message: str
@app.post("/api/sessions/{session_id}/messages")
def api_send_message(session_id: int, body: SendMessageRequest):
try:
reply = send_message(session_id, body.message)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
return {"reply": reply}
@app.get("/api/sessions")
def api_get_sessions():
return get_sessions()
@app.get("/api/sessions/{session_id}")
def api_get_session(session_id: int):
detail = get_session_detail(session_id)
if not detail:
raise HTTPException(status_code=404, detail="Session not found")
return detail
@app.get("/api/patterns")
def api_get_patterns():
return get_patterns()
# ── 静态文件 ─────────────────────────────
@app.get("/")
def serve_index():
return FileResponse(STATIC_DIR / "index.html")
@app.get("/history")
def serve_history():
return FileResponse(STATIC_DIR / "history.html")
# 挂载静态文件(CSS/JS)
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")- 步骤 2:验证服务器可启动
cd "D:\MyAIWorkspace\project1:复盘agent"
python -c "from reflection.server import app; print('Server module loaded OK, routes:', len(app.routes))"预期:Server module loaded OK, routes: N
文件:
-
创建:
project1:复盘agent/reflection/static/style.css -
步骤 1:编写 style.css
/* === Reset & Base === */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;1,300;1,400&family=Inter:wght@200;300;400&family=Noto+Serif+SC:wght@300;400;500&display=swap');
:root {
--bg-top: #0f1f35;
--bg-mid: #18283e;
--bg-low: #2a3a3c;
--bg-bottom: #4e3e2c;
--text-primary: rgba(225, 215, 195, 0.82);
--text-secondary: rgba(200, 170, 130, 0.55);
--text-dim: rgba(190, 175, 145, 0.4);
--gold: rgba(230, 190, 140, 0.5);
--gold-bright: rgba(250, 200, 120, 0.55);
--door-border: rgba(210, 180, 135, 0.5);
--font-serif: 'Cormorant Garamond', 'Noto Serif SC', Georgia, serif;
--font-sans: 'Inter', 'PingFang SC', -apple-system, sans-serif;
}
html, body {
height: 100%;
overflow: hidden;
font-family: var(--font-sans);
font-weight: 300;
color: var(--text-primary);
background: linear-gradient(180deg,
var(--bg-top) 0%,
var(--bg-mid) 30%,
#1e3045 55%,
var(--bg-low) 72%,
#3e3a2e 85%,
var(--bg-bottom) 100%
);
-webkit-font-smoothing: antialiased;
}
/* === 首页布局 === */
#main-view {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
position: relative;
padding: 40px 32px;
}
/* 左上角英文 */
.brand {
position: absolute;
top: 22px;
left: 28px;
font-family: 'Cormorant Garamond', Georgia, serif;
font-size: 9px;
font-weight: 400;
letter-spacing: 5px;
color: var(--text-dim);
user-select: none;
}
/* 右上角门图标 */
.door-icon {
position: absolute;
top: 22px;
right: 28px;
width: 14px;
height: 17px;
cursor: pointer;
text-decoration: none;
opacity: 0.7;
transition: opacity 0.3s ease;
}
.door-icon:hover { opacity: 1; }
.door-icon svg {
width: 100%;
height: 100%;
}
/* 中央文字区 */
.hero-text {
font-family: var(--font-serif);
font-size: 26px;
font-weight: 400;
letter-spacing: 2px;
text-align: center;
line-height: 1.6;
color: var(--text-primary);
}
.divider {
width: 40px;
height: 1px;
background: var(--gold);
margin: 22px auto 14px;
}
.subtitle-text {
font-family: var(--font-sans);
font-size: 10px;
font-weight: 300;
letter-spacing: 4px;
color: var(--text-secondary);
}
/* 底部光晕 */
.sunrise-glow {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 140px;
background: linear-gradient(0deg,
rgba(240, 180, 80, 0.15) 0%,
rgba(210, 150, 60, 0.08) 30%,
rgba(170, 120, 40, 0.02) 60%,
transparent 100%
);
pointer-events: none;
}
.horizon-line {
position: absolute;
bottom: 8px;
left: 8%;
right: 8%;
height: 1px;
background: linear-gradient(90deg,
transparent,
rgba(250, 200, 120, 0.35),
var(--gold-bright),
rgba(250, 200, 120, 0.35),
transparent
);
pointer-events: none;
}
/* === 对话区 === */
#conversation {
width: 100%;
max-width: 600px;
flex: 1;
overflow-y: auto;
padding: 20px 0;
margin-bottom: 16px;
scroll-behavior: smooth;
}
.message {
margin-bottom: 20px;
animation: fadeIn 0.6s ease;
}
.message.user {
font-family: var(--font-sans);
font-weight: 300;
line-height: 1.7;
}
.message.assistant {
font-family: var(--font-serif);
font-style: italic;
color: rgba(225, 215, 195, 0.72);
line-height: 1.8;
}
/* 输入区 */
#input-area {
width: 100%;
max-width: 600px;
position: relative;
}
#user-input {
width: 100%;
background: transparent;
border: none;
border-bottom: 1px solid rgba(200, 170, 130, 0.2);
color: var(--text-primary);
font-family: var(--font-sans);
font-size: 15px;
font-weight: 300;
line-height: 1.8;
padding: 12px 0;
outline: none;
resize: none;
transition: border-color 0.3s ease;
}
#user-input:focus {
border-bottom-color: rgba(230, 190, 140, 0.5);
}
#user-input::placeholder {
color: rgba(180, 160, 130, 0.35);
}
/* 发送按钮 - 极简:只显示一个小箭头 */
#send-btn {
position: absolute;
right: 0;
bottom: 14px;
background: none;
border: none;
color: rgba(200, 170, 130, 0.5);
cursor: pointer;
font-size: 16px;
padding: 4px;
transition: color 0.3s ease;
display: none;
}
#send-btn.visible { display: block; }
#send-btn:hover { color: rgba(240, 200, 140, 0.8); }
/* 加载动画(三个点缓慢闪烁) */
.typing-indicator {
display: flex;
gap: 6px;
padding: 8px 0;
}
.typing-indicator span {
width: 4px;
height: 4px;
background: rgba(200, 170, 130, 0.5);
border-radius: 50%;
animation: blink 1.4s infinite;
}
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
@keyframes blink {
0%, 60%, 100% { opacity: 0.3; }
30% { opacity: 1; }
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* === 新复盘按钮 === */
#new-session-btn {
position: absolute;
top: 56px;
left: 50%;
transform: translateX(-50%);
font-family: var(--font-sans);
font-size: 11px;
font-weight: 300;
letter-spacing: 3px;
color: var(--text-secondary);
background: none;
border: none;
cursor: pointer;
opacity: 0;
transition: opacity 0.6s ease;
pointer-events: none;
}
#new-session-btn.visible {
opacity: 0.6;
pointer-events: auto;
}
#new-session-btn:hover { opacity: 1; }
/* === 滚动条 === */
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(200, 170, 130, 0.15); border-radius: 2px; }
::-webkit-scrollbar-thumb:hover { background: rgba(200, 170, 130, 0.3); }
/* 响应式:小屏幕 */
@media (max-width: 480px) {
.hero-text { font-size: 22px; }
#conversation, #input-area { max-width: 100%; }
}注意:@import url(...) 引用 Google Fonts。如果用户无法访问 Google Fonts(国内网络环境),需要在实现时改用本地字体回退方案——优先使用系统自带衬线/无衬线字体,Cormorant Garamond 和 Inter 仅作增强项。
文件:
-
创建:
project1:复盘agent/reflection/static/index.html -
创建:
project1:复盘agent/reflection/static/app.js -
步骤 1:编写 index.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reflection · 反观</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div id="main-view">
<!-- 左上角英文 -->
<div class="brand">R E F L E C T I O N</div>
<!-- 右上角门图标(SVG 内联) -->
<a class="door-icon" href="/history" title="曾经走过的路">
<svg viewBox="0 0 14 17" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="0.5" width="13" height="16" rx="1.5"
fill="none" stroke="rgba(210,180,135,0.5)" stroke-width="0.5"/>
<rect x="0" y="0" width="8" height="17" rx="1.5"
fill="rgba(15,24,38,0.7)"/>
<line x1="8" y1="0" x2="8" y2="17"
stroke="rgba(210,180,135,0.3)" stroke-width="0.5"/>
<line x1="2" y1="8.5" x2="7" y2="8.5"
stroke="rgba(240,200,140,0.4)" stroke-width="0.5"/>
</svg>
</a>
<!-- 对话区 -->
<div id="conversation"></div>
<!-- 输入区 -->
<div id="input-area">
<textarea id="user-input" rows="1" placeholder="开始写..."></textarea>
<button id="send-btn" title="发送">↑</button>
</div>
<!-- 新复盘按钮(对话结束后显示) -->
<button id="new-session-btn">新 复 盘</button>
<!-- 底部光晕 -->
<div class="sunrise-glow"></div>
<div class="horizon-line"></div>
</div>
<script src="/static/app.js"></script>
</body>
</html>- 步骤 2:编写 app.js
// Reflection 首页交互
const conversationEl = document.getElementById('conversation');
const userInput = document.getElementById('user-input');
const sendBtn = document.getElementById('send-btn');
const newSessionBtn = document.getElementById('new-session-btn');
let currentSessionId = null;
let isWaitingForAI = false;
// ── 初始化 ───────────────────────────────
async function init() {
showHeroScreen();
userInput.addEventListener('input', onInput);
userInput.addEventListener('keydown', onKeydown);
sendBtn.addEventListener('click', sendMessage);
newSessionBtn.addEventListener('click', startNewSession);
}
// ── 英雄屏(首次打开) ────────────────────
function showHeroScreen() {
conversationEl.innerHTML = `
<div style="text-align:center;padding-top:20vh">
<div class="hero-text">今天,<br>什么在你<br>心里?</div>
<div class="divider"></div>
<div class="subtitle-text">俯 瞰 自 心</div>
</div>`;
}
// ── 输入处理 ─────────────────────────────
function onInput() {
const hasText = userInput.value.trim().length > 0;
sendBtn.classList.toggle('visible', hasText);
// 自动调整高度
userInput.style.height = 'auto';
userInput.style.height = Math.min(userInput.scrollHeight, 200) + 'px';
}
function onKeydown(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (!isWaitingForAI) sendMessage();
}
}
// ── 发送消息 ─────────────────────────────
async function sendMessage() {
const text = userInput.value.trim();
if (!text || isWaitingForAI) return;
userInput.value = '';
userInput.style.height = 'auto';
sendBtn.classList.remove('visible');
// 首次发送时自动创建 session
if (!currentSessionId) {
await createSession();
}
// 移除英雄屏
clearHeroIfNeeded();
// 显示用户消息
appendMessage('user', text);
// 显示加载
const loader = appendLoader();
isWaitingForAI = true;
userInput.disabled = true;
try {
const resp = await fetch(`/api/sessions/${currentSessionId}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: text }),
});
const data = await resp.json();
loader.remove();
// 显示 AI 回复
appendMessage('assistant', data.reply);
// 检查是否有洞察提取(消息中包含 "带走" 说明第三轮结束)
if (data.reply.includes('带走') || data.reply.includes('最值得')) {
newSessionBtn.classList.add('visible');
}
} catch (err) {
loader.remove();
appendMessage('assistant', '连接断开了,刷新页面试试。');
}
isWaitingForAI = false;
userInput.disabled = false;
userInput.focus();
scrollToBottom();
}
async function createSession() {
const resp = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tag: null }),
});
const data = await resp.json();
currentSessionId = data.session_id;
}
// ── UI 辅助 ──────────────────────────────
function clearHeroIfNeeded() {
const hero = conversationEl.querySelector('.hero-text');
if (hero) conversationEl.innerHTML = '';
}
function appendMessage(role, content) {
const div = document.createElement('div');
div.className = `message ${role}`;
div.textContent = content;
conversationEl.appendChild(div);
scrollToBottom();
}
function appendLoader() {
const div = document.createElement('div');
div.className = 'typing-indicator';
div.innerHTML = '<span></span><span></span><span></span>';
conversationEl.appendChild(div);
scrollToBottom();
return div;
}
function scrollToBottom() {
conversationEl.scrollTop = conversationEl.scrollHeight;
}
async function startNewSession() {
currentSessionId = null;
newSessionBtn.classList.remove('visible');
conversationEl.innerHTML = '';
showHeroScreen();
userInput.focus();
}
// ── 启动 ────────────────────────────────
document.addEventListener('DOMContentLoaded', init);文件:
-
创建:
project1:复盘agent/reflection/static/history.html -
步骤 1:编写 history.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>历史 · Reflection</title>
<link rel="stylesheet" href="/static/style.css">
<style>
body { overflow-y: auto; }
.history-container {
max-width: 640px;
margin: 0 auto;
padding: 60px 32px 80px;
}
.history-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 48px;
}
.back-link {
color: var(--text-dim);
text-decoration: none;
font-size: 14px;
font-family: var(--font-sans);
font-weight: 300;
transition: color 0.3s;
}
.back-link:hover { color: var(--gold); }
.history-title {
font-family: var(--font-serif);
font-size: 22px;
font-weight: 400;
color: var(--text-primary);
}
.history-section {
margin-bottom: 48px;
}
.history-section h3 {
font-family: var(--font-sans);
font-size: 10px;
font-weight: 300;
letter-spacing: 4px;
color: var(--text-dim);
margin-bottom: 20px;
text-transform: uppercase;
}
.session-card {
padding: 24px 0;
border-bottom: 0.5px solid rgba(200, 170, 130, 0.1);
cursor: pointer;
transition: opacity 0.3s;
}
.session-card:hover { opacity: 0.8; }
.session-date {
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 6px;
}
.session-preview {
font-family: var(--font-serif);
font-size: 16px;
color: var(--text-primary);
line-height: 1.6;
}
.session-tag {
display: inline-block;
font-size: 9px;
letter-spacing: 2px;
color: var(--text-dim);
margin-top: 8px;
}
.pattern-card {
padding: 20px 0;
border-bottom: 0.5px solid rgba(200, 170, 130, 0.1);
}
.pattern-type {
font-size: 10px;
letter-spacing: 3px;
color: var(--gold);
margin-bottom: 6px;
}
.pattern-desc {
font-size: 14px;
color: var(--text-primary);
line-height: 1.7;
}
.pattern-trend {
font-size: 11px;
color: var(--text-secondary);
margin-top: 6px;
}
.empty-state {
text-align: center;
padding: 60px 0;
color: var(--text-dim);
font-family: var(--font-serif);
font-size: 16px;
font-style: italic;
}
</style>
</head>
<body>
<div class="history-container">
<div class="history-header">
<a class="back-link" href="/">← 回 去</a>
<span class="history-title">曾经走过的路</span>
</div>
<div class="history-section">
<h3>复盘记录</h3>
<div id="sessions-list"></div>
</div>
<div class="history-section">
<h3>反复出现的模式</h3>
<div id="patterns-list"></div>
</div>
<div class="history-section">
<h3>设置</h3>
<div id="settings-form" style="margin-top:16px;">
<label style="display:block;margin-bottom:12px;">
<span style="font-size:10px;letter-spacing:3px;color:var(--text-dim);">API 提供商</span>
<select id="cfg-provider" style="display:block;width:100%;margin-top:4px;padding:8px;background:transparent;border:0.5px solid rgba(200,170,130,0.2);color:var(--text-primary);font-family:var(--font-sans);font-size:13px;">
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
</select>
</label>
<label style="display:block;margin-bottom:12px;">
<span style="font-size:10px;letter-spacing:3px;color:var(--text-dim);">API Key</span>
<input id="cfg-key" type="password" placeholder="sk-..." style="display:block;width:100%;margin-top:4px;padding:8px;background:transparent;border:0.5px solid rgba(200,170,130,0.2);color:var(--text-primary);font-family:var(--font-sans);font-size:13px;">
</label>
<label style="display:block;margin-bottom:12px;">
<span style="font-size:10px;letter-spacing:3px;color:var(--text-dim);">模型</span>
<input id="cfg-model" type="text" placeholder="gpt-4o" style="display:block;width:100%;margin-top:4px;padding:8px;background:transparent;border:0.5px solid rgba(200,170,130,0.2);color:var(--text-primary);font-family:var(--font-sans);font-size:13px;">
</label>
<button id="cfg-save" style="margin-top:12px;padding:8px 24px;background:transparent;border:0.5px solid rgba(200,170,130,0.3);color:var(--text-primary);font-family:var(--font-sans);font-size:12px;font-weight:300;letter-spacing:3px;cursor:pointer;transition:border-color 0.3s;">保 存</button>
</div>
</div>
</div>
<script>
async function loadHistory() {
// 加载 sessions
const sResp = await fetch('/api/sessions');
const sessions = await sResp.json();
const sList = document.getElementById('sessions-list');
if (sessions.length === 0) {
sList.innerHTML = '<div class="empty-state">还没有复盘记录<br>回去写第一次吧</div>';
} else {
sList.innerHTML = sessions.map(s => `
<div class="session-card" onclick="viewSession(${s.id})">
<div class="session-date">${new Date(s.created_at).toLocaleDateString('zh-CN', {year:'numeric',month:'long',day:'numeric',weekday:'long'})}</div>
<div class="session-preview">${s.takeaway || '查看完整对话...'}</div>
${s.tag ? `<div class="session-tag">${s.tag}</div>` : ''}
</div>`).join('');
}
// 加载 patterns
const pResp = await fetch('/api/patterns');
const patterns = await pResp.json();
const pList = document.getElementById('patterns-list');
if (patterns.length === 0) {
pList.innerHTML = '<div class="empty-state">复盘多了<br>模式会自然浮现</div>';
} else {
pList.innerHTML = patterns.map(p => `
<div class="pattern-card">
<div class="pattern-type">${p.type}</div>
<div class="pattern-desc">${p.description}</div>
<div class="pattern-trend">首次:${new Date(p.first_seen).toLocaleDateString('zh-CN')} · 趋势:${p.trend === 'strengthening' ? '强化中' : p.trend === 'weakening' ? '减弱中' : '稳定'}</div>
</div>`).join('');
}
}
async function loadConfig() {
const resp = await fetch('/api/config');
const cfg = await resp.json();
document.getElementById('cfg-provider').value = cfg.api_provider;
document.getElementById('cfg-model').value = cfg.model;
if (cfg.has_key) document.getElementById('cfg-key').placeholder = '已设置 (不修改则留空)';
}
document.getElementById('cfg-save').addEventListener('click', async () => {
const body = {
api_provider: document.getElementById('cfg-provider').value,
model: document.getElementById('cfg-model').value,
};
const key = document.getElementById('cfg-key').value.trim();
if (key) body.api_key = key;
await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
document.getElementById('cfg-key').value = '';
document.getElementById('cfg-key').placeholder = '已保存';
});
function viewSession(id) {
window.location.href = `/history?id=${id}`;
}
document.addEventListener('DOMContentLoaded', () => {
loadHistory();
loadConfig();
});
</script>
</body>
</html>文件:
-
创建:
project1:复盘agent/reflection/main.py -
步骤 1:编写 main.py
"""Reflection 桌面应用入口。启动本地服务器,自动打开浏览器。"""
import sys
import webbrowser
import threading
import time
import uvicorn
from pathlib import Path
# 确保项目根在 sys.path
sys.path.insert(0, str(Path(__file__).parent.parent))
from reflection.server import app
from reflection.config import ensure_data_dir, load_config
HOST = "127.0.0.1"
PORT = 18080
def open_browser():
"""等服务器就绪后打开浏览器。"""
time.sleep(1.0)
webbrowser.open(f"http://{HOST}:{PORT}")
def main():
ensure_data_dir()
cfg = load_config()
print(f" Reflection · 反观 v0.1.0")
print(f" 数据目录: {cfg.model_dump()}")
print(f" 打开 http://{HOST}:{PORT}")
print()
# 在新线程中打开浏览器
threading.Thread(target=open_browser, daemon=True).start()
uvicorn.run(app, host=HOST, port=PORT, log_level="warning")
if __name__ == "__main__":
main()- 步骤 2:验证启动
cd "D:\MyAIWorkspace\project1:复盘agent"
Start-Process -NoNewWindow python -ArgumentList "-m", "reflection.main"预期:终端输出 Reflection 启动信息,浏览器自动打开。
手动 Ctrl+C 停止。
文件:
-
创建:
project1:复盘agent/reflection/reflection.spec -
步骤 1:编写 reflection.spec
# -*- mode: python ; coding: utf-8 -*-
import sys
from pathlib import Path
block_cipher = None
# 静态文件目录
static_dir = Path('reflection/static')
added_files = [
(str(static_dir), 'reflection/static'),
]
a = Analysis(
['reflection/main.py'],
pathex=[],
binaries=[],
datas=added_files,
hiddenimports=['sqlalchemy', 'httpx', 'fastapi', 'uvicorn'],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name='Reflection',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False,
disable_windowed_traceback=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=None,
)- 步骤 2:执行打包
cd "D:\MyAIWorkspace\project1:复盘agent"
pyinstaller --clean reflection/reflection.spec预期:生成 dist/Reflection.exe。
- 步骤 3:验证 .exe 可运行
Start-Process "D:\MyAIWorkspace\project1:复盘agent\dist\Reflection.exe"预期:浏览器自动打开,显示 Reflection 首页。
文件:
-
创建:
project1:复盘agent/reflection/tests/ -
创建:
project1:复盘agent/reflection/tests/__init__.py -
创建:
project1:复盘agent/reflection/tests/test_database.py -
创建:
project1:复盘agent/reflection/tests/test_engine.py -
步骤 1:编写 test_database.py
"""数据库模块测试。"""
import tempfile
import os
from pathlib import Path
from reflection.database import get_engine, get_db, Base, SessionModel, InsightModel, PatternModel
def test_create_tables():
"""验证表创建成功。"""
db = get_db()
tables = db.get_bind().table_names()
assert "sessions" in tables
assert "insights" in tables
assert "patterns" in tables
db.close()
def test_create_session():
"""验证创建 session。"""
db = get_db()
s = SessionModel(tag="test")
db.add(s)
db.commit()
assert s.id is not None
assert s.status == "active"
db.delete(s)
db.commit()
db.close()
def test_create_insight():
"""验证创建 insight 并关联 session。"""
db = get_db()
s = SessionModel(tag="test")
db.add(s)
db.commit()
i = InsightModel(session_id=s.id, dimension="thought", content="测试洞察")
db.add(i)
db.commit()
assert i.id is not None
assert len(s.insights) == 1
db.delete(s)
db.commit()
db.close()- 步骤 2:运行数据库测试
cd "D:\MyAIWorkspace\project1:复盘agent"
python -m pytest reflection/tests/test_database.py -v预期:3 passed
- 步骤 3:编写 test_engine.py
"""复盘引擎测试(不依赖 LLM API)。"""
import json
from reflection.engine import start_session, _dimension_key, _get_phase_prompt
def test_start_session():
"""验证 start_session 创建 session 并返回开场白。"""
sid, opening = start_session(tag="测试")
assert sid > 0
assert "今天" in opening
assert "什么在你心里" in opening
def test_dimension_key():
"""验证维度中文→英文映射。"""
assert _dimension_key("念头") == "thought"
assert _dimension_key("内在状态") == "state"
assert _dimension_key("道的体悟") == "dao"
assert _dimension_key("未知") == "thought"
def test_phase_prompt():
"""验证三轮提示词包含正确阶段标记。"""
p1 = _get_phase_prompt(1)
assert "倾倒" in p1
p2 = _get_phase_prompt(3)
assert "觉察" in p2
p3 = _get_phase_prompt(6)
assert "沉淀" in p3- 步骤 4:运行引擎测试
cd "D:\MyAIWorkspace\project1:复盘agent"
python -m pytest reflection/tests/test_engine.py -v预期:3 passed
- 步骤 5:运行全部测试
cd "D:\MyAIWorkspace\project1:复盘agent"
python -m pytest reflection/tests/ -v预期:6 passed
---
## 自检记录
- [x] **规格覆盖度**:设计文档 9 个章节全部覆盖——产品定位(任务7-9)、架构(任务1)、数据模型(任务3)、复盘引擎(任务5)、界面设计(任务7-9)、技术栈(任务1-4)、分发(任务11)、排除项(全部遵守)、待决定项(无阻塞)
- [x] **占位符扫描**:无 TODO/TBD/占位符。所有步骤均包含实际代码或精确命令。
- [x] **类型一致性**:Config/ConfigUpdate/SessionModel/InsightModel/PatternModel 在各任务间引用一致。API 端点与前端 fetch 路径匹配:POST /api/sessions, POST /api/sessions/{id}/messages, GET /api/sessions, GET /api/sessions/{id}, GET /api/patterns, GET/POST /api/config。