chore: mini-openclaw 初始版本

Next.js 前端 + FastAPI/LangChain 后端透明 agent平台
- 含 .gitignore, 敏感文件(sessions数据/.env/venv等)已排除
- 待后续添加 trajectory 自进化机制
This commit is contained in:
NathanAlex7
2026-09-01 16:40:10 +08:00
committed by NathanAlex7
parent 73796a6abd
commit 42ce992a0f
76 changed files with 14207 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
# OpenAI API Configuration
OPENAI_API_KEY=sk-xxx
OPENAI_BASE_URL=https://api.openai.com/v1
MODEL_NAME=gpt-4o-mini
# Embedding model
EMBEDDING_MODEL=text-embedding-3-small
# Server
HOST=0.0.0.0
PORT=8002
# OpenWeather API Configuration
OPENWEATHER_API_KEY=your_openweather_api_key_here
+12
View File
@@ -0,0 +1,12 @@
<available_skills>
<skill>
<name>get_weather</name>
<description>获取指定城市的实时天气信息</description>
<location>./backend/skills/get_weather/SKILL.md</location>
</skill>
<skill>
<name>get_weather_open</name>
<description>使用 OpenWeather API 获取指定城市的实时天气信息</description>
<location>./backend/skills/get_weather_open/SKILL.md</location>
</skill>
</available_skills>
View File
+188
View File
@@ -0,0 +1,188 @@
"""POST /api/chat — SSE streaming chat with Agent."""
import json
import os
import traceback
from typing import AsyncGenerator
from fastapi import APIRouter
from pydantic import BaseModel
from sse_starlette.sse import EventSourceResponse
from graph.agent import agent_manager
from graph.session_manager import session_manager
router = APIRouter()
class ChatRequest(BaseModel):
message: str
session_id: str = "default"
stream: bool = True
async def _generate_title(session_id: str) -> str | None:
"""Generate a title for a session using DeepSeek. Returns title or None."""
try:
messages = session_manager.load_session(session_id)
first_user = ""
first_assistant = ""
for msg in messages:
if msg["role"] == "user" and not first_user:
first_user = msg["content"][:200]
elif msg["role"] == "assistant" and not first_assistant:
first_assistant = msg["content"][:200]
if first_user and first_assistant:
break
if not first_user:
return None
from langchain_deepseek import ChatDeepSeek
from langchain_core.messages import HumanMessage as HM
llm = ChatDeepSeek(
model=os.getenv("DEEPSEEK_MODEL", "deepseek-chat"),
api_key=os.getenv("DEEPSEEK_API_KEY"),
api_base=os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
temperature=0.3,
)
prompt = (
f"根据以下对话内容,生成一个不超过10个字的中文标题,只输出标题文本,不要加引号或标点。\n\n"
f"用户: {first_user}\n"
f"助手: {first_assistant}"
)
result = await llm.ainvoke([HM(content=prompt)])
title = result.content.strip().strip('"\'""''')[:20]
session_manager.update_title(session_id, title)
return title
except Exception:
traceback.print_exc()
return None
async def event_generator(message: str, session_id: str) -> AsyncGenerator[dict, None]:
"""Generate SSE events from agent stream.
Tracks multiple response segments — each time the agent finishes
tool calls and starts generating new text, a new_response event is
emitted and a new segment begins. Each segment is saved as a
separate assistant message in the session history.
"""
try:
# Use merged history for agent context (combines consecutive assistant msgs)
history = session_manager.load_session_for_agent(session_id)
is_first_message = len(history) == 0
# Per-segment tracking
segments: list[dict] = [] # finalized segments
current_segment: dict = {"content": "", "tool_calls": []}
async for event in agent_manager.astream(message, history):
event_type = event.get("type", "unknown")
if event_type == "retrieval":
yield {
"event": "retrieval",
"data": json.dumps(
{"query": event["query"], "results": event["results"]},
ensure_ascii=False,
),
}
elif event_type == "token":
current_segment["content"] += event["content"]
yield {
"event": "token",
"data": json.dumps({"content": event["content"]}, ensure_ascii=False),
}
elif event_type == "new_response":
# Finalize current segment and start a new one
segments.append(current_segment)
current_segment = {"content": "", "tool_calls": []}
yield {
"event": "new_response",
"data": json.dumps({}, ensure_ascii=False),
}
elif event_type == "tool_start":
current_segment["tool_calls"].append({
"tool": event["tool"],
"input": event.get("input", ""),
})
yield {
"event": "tool_start",
"data": json.dumps(
{"tool": event["tool"], "input": event["input"]},
ensure_ascii=False,
),
}
elif event_type == "tool_end":
# Match with current segment's tool_calls to add output
for tc in reversed(current_segment["tool_calls"]):
if tc["tool"] == event["tool"] and "output" not in tc:
tc["output"] = event["output"]
break
yield {
"event": "tool_end",
"data": json.dumps(
{"tool": event["tool"], "output": event["output"]},
ensure_ascii=False,
),
}
elif event_type == "done":
# Finalize the last segment
segments.append(current_segment)
# Save user message once
session_manager.save_message(session_id, "user", message)
# Save each segment as a separate assistant message
for seg in segments:
tc = seg["tool_calls"] if seg["tool_calls"] else None
session_manager.save_message(
session_id, "assistant", seg["content"], tool_calls=tc
)
yield {
"event": "done",
"data": json.dumps(
{"content": event["content"], "session_id": session_id},
ensure_ascii=False,
),
}
# Auto-generate title on first conversation
if is_first_message:
title = await _generate_title(session_id)
if title:
yield {
"event": "title",
"data": json.dumps(
{"session_id": session_id, "title": title},
ensure_ascii=False,
),
}
except Exception as e:
traceback.print_exc()
yield {
"event": "error",
"data": json.dumps({"error": str(e)}, ensure_ascii=False),
}
@router.post("/chat")
async def chat(request: ChatRequest):
if request.stream:
return EventSourceResponse(
event_generator(request.message, request.session_id)
)
# Non-streaming fallback
result = await agent_manager.ainvoke(request.message, request.session_id)
return {"reply": result}
+71
View File
@@ -0,0 +1,71 @@
"""POST /api/sessions/{session_id}/compress — Compress conversation history."""
import os
import traceback
from typing import Any
from fastapi import APIRouter, HTTPException
from langchain_core.messages import HumanMessage
from graph.session_manager import session_manager
router = APIRouter()
async def _generate_summary(messages: list[dict[str, Any]]) -> str:
"""Use DeepSeek to generate a compressed summary of messages."""
from langchain_deepseek import ChatDeepSeek
llm = ChatDeepSeek(
model=os.getenv("DEEPSEEK_MODEL", "deepseek-chat"),
api_key=os.getenv("DEEPSEEK_API_KEY"),
api_base=os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
temperature=0.3,
)
# Format messages for summary
formatted = []
for msg in messages:
role = msg.get("role", "unknown")
content = msg.get("content", "")
if content:
formatted.append(f"{role}: {content[:500]}")
conversation_text = "\n".join(formatted)
prompt = (
"请将以下对话历史压缩为简洁的中文摘要,保留关键信息、决策和结论。"
"摘要不超过500字。只输出摘要内容,不要添加额外说明。\n\n"
f"{conversation_text}"
)
result = await llm.ainvoke([HumanMessage(content=prompt)])
return result.content.strip()
@router.post("/sessions/{session_id}/compress")
async def compress_session(session_id: str) -> dict[str, Any]:
"""Compress the first 50% of conversation history into a summary."""
messages = session_manager.load_session(session_id)
if len(messages) < 4:
raise HTTPException(
status_code=400,
detail="Not enough messages to compress (need at least 4)",
)
# Take the first 50% of messages
num_to_remove = max(4, len(messages) // 2)
messages_to_compress = messages[:num_to_remove]
try:
summary = await _generate_summary(messages_to_compress)
session_manager.compress_history(session_id, summary, num_to_remove)
remaining = len(messages) - num_to_remove
return {
"archived_count": num_to_remove,
"remaining_count": remaining,
}
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=f"Compression failed: {str(e)}")
+23
View File
@@ -0,0 +1,23 @@
"""GET/PUT /api/config/rag-mode — RAG mode toggle API."""
from fastapi import APIRouter
from pydantic import BaseModel
from config import get_rag_mode, set_rag_mode
router = APIRouter()
class RagModeRequest(BaseModel):
enabled: bool
@router.get("/config/rag-mode")
async def get_rag_mode_endpoint():
return {"rag_mode": get_rag_mode()}
@router.put("/config/rag-mode")
async def set_rag_mode_endpoint(request: RagModeRequest):
set_rag_mode(request.enabled)
return {"rag_mode": request.enabled}
+102
View File
@@ -0,0 +1,102 @@
"""GET/POST /api/files + GET /api/skills — File read/write for Monaco editor."""
from pathlib import Path
import yaml
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
router = APIRouter()
BASE_DIR = Path(__file__).resolve().parent.parent
# Whitelist of editable directories (relative to backend/)
ALLOWED_PREFIXES = ["workspace/", "memory/", "skills/", "knowledge/"]
# Whitelist of specific root-level files that can be accessed
ALLOWED_ROOT_FILES = {"SKILLS_SNAPSHOT.md"}
def _validate_path(rel_path: str) -> Path:
"""Validate and resolve file path within allowed directories."""
normalized = rel_path.replace("\\", "/").lstrip("./")
if not (
any(normalized.startswith(prefix) for prefix in ALLOWED_PREFIXES)
or normalized in ALLOWED_ROOT_FILES
):
raise HTTPException(status_code=403, detail=f"Access denied: {rel_path}")
full_path = (BASE_DIR / normalized).resolve()
if not str(full_path).startswith(str(BASE_DIR)):
raise HTTPException(status_code=403, detail="Path traversal detected")
return full_path
@router.get("/files")
async def read_file(path: str):
file_path = _validate_path(path)
if not file_path.exists():
raise HTTPException(status_code=404, detail=f"File not found: {path}")
content = file_path.read_text(encoding="utf-8")
return {"path": path, "content": content}
class FileSaveRequest(BaseModel):
path: str
content: str
@router.post("/files")
async def save_file(request: FileSaveRequest):
file_path = _validate_path(request.path)
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(request.content, encoding="utf-8")
# Trigger memory index rebuild when MEMORY.md is saved
normalized = request.path.replace("\\", "/").lstrip("./")
if normalized == "memory/MEMORY.md":
try:
from graph.memory_indexer import get_memory_indexer
indexer = get_memory_indexer(BASE_DIR)
indexer.rebuild_index()
except Exception:
pass
return {"path": request.path, "status": "saved"}
@router.get("/skills")
async def list_skills():
"""Scan skills/ directory and return skill list with name, path, description."""
skills_dir = BASE_DIR / "skills"
if not skills_dir.exists():
return {"skills": []}
skills: list[dict[str, str]] = []
for skill_dir in sorted(skills_dir.iterdir()):
if not skill_dir.is_dir():
continue
skill_md = skill_dir / "SKILL.md"
if not skill_md.exists():
continue
name = skill_dir.name
description = ""
rel_path = f"skills/{name}/SKILL.md"
# Parse YAML frontmatter
try:
text = skill_md.read_text(encoding="utf-8")
if text.startswith("---"):
parts = text.split("---", 2)
if len(parts) >= 3:
meta = yaml.safe_load(parts[1])
if isinstance(meta, dict):
name = meta.get("name", name)
description = meta.get("description", "")
except Exception:
pass
skills.append({"name": name, "path": rel_path, "description": description})
return {"skills": skills}
+123
View File
@@ -0,0 +1,123 @@
"""Session CRUD API — list / create / rename / delete / raw messages / generate title."""
import os
import uuid
from pathlib import Path
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from graph.session_manager import session_manager
from graph.prompt_builder import build_system_prompt
router = APIRouter()
BASE_DIR = Path(__file__).resolve().parent.parent
# ── Request models ──────────────────────────────────────────
class RenameRequest(BaseModel):
title: str
# ── Endpoints ───────────────────────────────────────────────
@router.get("/sessions")
async def list_sessions():
"""List all sessions with title and metadata."""
sessions = session_manager.list_sessions()
return {"sessions": sessions}
@router.post("/sessions")
async def create_session():
"""Create a new empty session."""
session_id = f"session-{uuid.uuid4().hex[:12]}"
meta = session_manager.create_session(session_id)
return meta
@router.put("/sessions/{session_id}")
async def rename_session(session_id: str, req: RenameRequest):
"""Rename an existing session."""
try:
session_manager.rename_session(session_id, req.title)
except FileNotFoundError:
raise HTTPException(status_code=404, detail="Session not found")
return {"id": session_id, "title": req.title}
@router.delete("/sessions/{session_id}")
async def delete_session(session_id: str):
"""Delete a session."""
session_manager.delete_session(session_id)
return {"status": "deleted", "id": session_id}
@router.get("/sessions/{session_id}/messages")
async def get_raw_messages(session_id: str):
"""Get complete raw messages including system prompt."""
data = session_manager.get_raw_messages(session_id)
system_prompt = build_system_prompt(BASE_DIR)
# Prepend system prompt as the first message
all_messages = [{"role": "system", "content": system_prompt}] + data.get("messages", [])
return {"session_id": session_id, "title": data.get("title", ""), "messages": all_messages}
@router.get("/sessions/{session_id}/history")
async def get_session_history(session_id: str):
"""Get conversation history for display (no system prompt, includes tool_calls)."""
messages = session_manager.load_session(session_id)
return {"session_id": session_id, "messages": messages}
@router.post("/sessions/{session_id}/generate-title")
async def generate_title(session_id: str):
"""Use DeepSeek to generate a short title from the first conversation turn."""
messages = session_manager.load_session(session_id)
if not messages:
raise HTTPException(status_code=400, detail="No messages to generate title from")
# Get the first user message and first assistant reply
first_user = ""
first_assistant = ""
for msg in messages:
if msg["role"] == "user" and not first_user:
first_user = msg["content"][:200]
elif msg["role"] == "assistant" and not first_assistant:
first_assistant = msg["content"][:200]
if first_user and first_assistant:
break
if not first_user:
raise HTTPException(status_code=400, detail="No user message found")
try:
from langchain_deepseek import ChatDeepSeek
from langchain_core.messages import HumanMessage as HM
llm = ChatDeepSeek(
model=os.getenv("DEEPSEEK_MODEL", "deepseek-chat"),
api_key=os.getenv("DEEPSEEK_API_KEY"),
api_base=os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
temperature=0.3,
)
prompt = (
f"根据以下对话内容,生成一个不超过10个字的中文标题,只输出标题文本,不要加引号或标点。\n\n"
f"用户: {first_user}\n"
f"助手: {first_assistant}"
)
result = await llm.ainvoke([HM(content=prompt)])
title = result.content.strip().strip('"\'""''')[:20]
session_manager.update_title(session_id, title)
return {"session_id": session_id, "title": title}
except Exception as e:
# Fallback: use first few chars of user message
fallback_title = first_user[:10].strip()
session_manager.update_title(session_id, fallback_title)
return {"session_id": session_id, "title": fallback_title}
+68
View File
@@ -0,0 +1,68 @@
"""GET/POST /api/tokens — Token counting for sessions and files."""
from pathlib import Path
from typing import Any
import tiktoken
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from graph.session_manager import session_manager
from graph.prompt_builder import build_system_prompt
router = APIRouter()
BASE_DIR = Path(__file__).resolve().parent.parent
# Cache the encoder instance
_encoder = tiktoken.get_encoding("cl100k_base")
def _count_tokens(text: str) -> int:
"""Count tokens using cl100k_base encoding."""
return len(_encoder.encode(text))
@router.get("/tokens/session/{session_id}")
async def get_session_token_count(session_id: str) -> dict[str, Any]:
"""Count tokens in a session: system prompt + all messages."""
system_prompt = build_system_prompt(BASE_DIR)
system_tokens = _count_tokens(system_prompt)
messages = session_manager.load_session(session_id)
message_tokens = 0
for msg in messages:
message_tokens += _count_tokens(msg.get("content", ""))
return {
"system_tokens": system_tokens,
"message_tokens": message_tokens,
"total_tokens": system_tokens + message_tokens,
}
class FileTokenRequest(BaseModel):
paths: list[str]
@router.post("/tokens/files")
async def get_file_token_counts(request: FileTokenRequest) -> dict[str, Any]:
"""Count tokens for a list of files."""
results: list[dict[str, Any]] = []
for rel_path in request.paths:
normalized = rel_path.replace("\\", "/").lstrip("./")
full_path = (BASE_DIR / normalized).resolve()
if not str(full_path).startswith(str(BASE_DIR)):
results.append({"path": rel_path, "tokens": 0})
continue
if not full_path.exists():
results.append({"path": rel_path, "tokens": 0})
continue
try:
content = full_path.read_text(encoding="utf-8")
tokens = _count_tokens(content)
results.append({"path": rel_path, "tokens": tokens})
except Exception:
results.append({"path": rel_path, "tokens": 0})
return {"files": results}
+61
View File
@@ -0,0 +1,61 @@
"""Mini-OpenClaw Backend — FastAPI Entry Point"""
import os
from contextlib import asynccontextmanager
from pathlib import Path
from dotenv import load_dotenv
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
load_dotenv()
BASE_DIR = Path(__file__).resolve().parent
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup: scan skills, initialize agent, build memory index."""
from tools.skills_scanner import scan_skills
from graph.agent import agent_manager
from graph.memory_indexer import get_memory_indexer
scan_skills(BASE_DIR)
agent_manager.initialize(BASE_DIR)
# Initialize memory indexer for RAG mode
indexer = get_memory_indexer(BASE_DIR)
indexer.rebuild_index()
print("✅ mini OpenClaw backend ready")
yield
app = FastAPI(title="mini OpenClaw", version="0.1.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
from api.chat import router as chat_router
from api.files import router as files_router
from api.sessions import router as sessions_router
from api.tokens import router as tokens_router
from api.compress import router as compress_router
from api.config_api import router as config_router
app.include_router(chat_router, prefix="/api")
app.include_router(files_router, prefix="/api")
app.include_router(sessions_router, prefix="/api")
app.include_router(tokens_router, prefix="/api")
app.include_router(compress_router, prefix="/api")
app.include_router(config_router, prefix="/api")
@app.get("/")
async def root():
return {"name": "mini OpenClaw", "version": "0.1.0", "status": "running"}
+3
View File
@@ -0,0 +1,3 @@
{
"rag_mode": false
}
+42
View File
@@ -0,0 +1,42 @@
"""Global configuration management — JSON-based persistence."""
import json
from pathlib import Path
from typing import Any
CONFIG_FILE = Path(__file__).resolve().parent / "config.json"
_DEFAULT_CONFIG: dict[str, Any] = {
"rag_mode": False,
}
def load_config() -> dict[str, Any]:
"""Load configuration from disk, returning defaults if missing."""
if not CONFIG_FILE.exists():
return dict(_DEFAULT_CONFIG)
try:
data = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
return {**_DEFAULT_CONFIG, **data}
except Exception:
return dict(_DEFAULT_CONFIG)
def save_config(config: dict[str, Any]) -> None:
"""Persist configuration to disk."""
CONFIG_FILE.write_text(
json.dumps(config, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def get_rag_mode() -> bool:
"""Get current RAG mode setting."""
return bool(load_config().get("rag_mode", False))
def set_rag_mode(enabled: bool) -> None:
"""Set RAG mode on/off."""
config = load_config()
config["rag_mode"] = enabled
save_config(config)
+15
View File
@@ -0,0 +1,15 @@
Set-Location F:\mini-OpenClaw\backend
Write-Host "[1/4] Creating virtual environment..."
F:\Program` Files\anaconda\python.exe -m venv venv
Write-Host "[2/4] Activating venv..."
.\venv\Scripts\Activate.ps1
Write-Host "[3/4] Upgrading pip..."
.\venv\Scripts\pip.exe install --upgrade pip -i https://pypi.tuna.tsinghua.edu.cn/simple
Write-Host "[4/4] Installing requirements..."
.\venv\Scripts\pip.exe install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
Write-Host "Installation complete!"
View File
+183
View File
@@ -0,0 +1,183 @@
"""AgentManager — Core Agent using LangChain create_agent API with DeepSeek."""
import os
from pathlib import Path
from typing import Any, AsyncGenerator
from langchain_core.messages import HumanMessage, AIMessage
from config import get_rag_mode
from graph.prompt_builder import build_system_prompt
from graph.session_manager import session_manager
from tools import get_all_tools
class AgentManager:
"""Manages the Agent lifecycle: initialization, streaming, invocation."""
def __init__(self) -> None:
self._base_dir: Path | None = None
self._tools: list = []
self._llm = None
def initialize(self, base_dir: Path) -> None:
"""Initialize LLM (DeepSeek) and tools. Called once at startup."""
self._base_dir = base_dir
self._tools = get_all_tools(base_dir)
# Use langchain-deepseek for native DeepSeek support
from langchain_deepseek import ChatDeepSeek
self._llm = ChatDeepSeek(
model=os.getenv("DEEPSEEK_MODEL", "deepseek-chat"),
api_key=os.getenv("DEEPSEEK_API_KEY"),
api_base=os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
temperature=0.7,
streaming=True,
)
session_manager.initialize(base_dir)
print(f"🤖 Agent initialized with {len(self._tools)} tools (DeepSeek: {os.getenv('DEEPSEEK_MODEL', 'deepseek-chat')})")
def _build_agent(self):
"""Build a fresh agent with current system prompt (re-reads files each time)."""
from langchain.agents import create_agent
assert self._base_dir is not None
assert self._llm is not None
rag_mode = get_rag_mode()
system_prompt = build_system_prompt(self._base_dir, rag_mode=rag_mode)
agent = create_agent(
model=self._llm,
tools=self._tools,
system_prompt=system_prompt,
)
return agent
def _build_messages(self, user_message: str, history: list[dict[str, Any]]) -> list:
"""Convert session history + new message into LangChain messages."""
messages = []
for msg in history:
role = msg.get("role", "")
content = msg.get("content", "")
if role == "user":
messages.append(HumanMessage(content=content))
elif role == "assistant":
messages.append(AIMessage(content=content))
messages.append(HumanMessage(content=user_message))
return messages
async def astream(
self, message: str, history: list[dict[str, Any]]
) -> AsyncGenerator[dict[str, Any], None]:
"""Stream agent response with token-level and node-level events.
Yields events:
{"type": "retrieval", "query": "...", "results": [...]} (RAG mode only)
{"type": "token", "content": "..."}
{"type": "tool_start", "tool": "...", "input": "..."}
{"type": "tool_end", "tool": "...", "output": "..."}
{"type": "done", "content": "..."}
"""
# RAG retrieval: inject memory context if enabled
rag_mode = get_rag_mode()
rag_context = ""
if rag_mode and self._base_dir:
from graph.memory_indexer import get_memory_indexer
indexer = get_memory_indexer(self._base_dir)
results = indexer.retrieve(message)
if results:
yield {
"type": "retrieval",
"query": message,
"results": results,
}
snippets = "\n\n".join(
f"[片段 {i+1}] (score: {r['score']})\n{r['text']}"
for i, r in enumerate(results)
)
rag_context = f"[记忆检索结果]\n{snippets}"
agent = self._build_agent()
# Build messages with optional RAG context appended to history
augmented_history = list(history)
if rag_context:
augmented_history.append(
{"role": "assistant", "content": rag_context}
)
messages = self._build_messages(message, augmented_history)
full_response = ""
tools_just_finished = False
async for event in agent.astream(
{"messages": messages},
stream_mode=["messages", "updates"],
):
# event is a tuple of (stream_mode, data) when using multiple modes
if isinstance(event, tuple):
mode, data = event
else:
mode = "messages"
data = event
if mode == "messages":
# Token-level streaming from LLM
msg, metadata = data
if hasattr(msg, "content") and msg.content:
if msg.type == "AIMessageChunk" or msg.type == "ai":
if msg.content and not getattr(msg, "tool_calls", None):
# If tools just finished, signal a new response segment
if tools_just_finished:
yield {"type": "new_response"}
tools_just_finished = False
full_response += msg.content
yield {"type": "token", "content": msg.content}
elif mode == "updates":
if isinstance(data, dict):
for node_name, node_data in data.items():
if node_name == "tools" and "messages" in node_data:
for tool_msg in node_data["messages"]:
if hasattr(tool_msg, "name"):
yield {
"type": "tool_end",
"tool": tool_msg.name,
"output": str(tool_msg.content)[:2000],
}
# After all tool results, mark that tools finished
tools_just_finished = True
elif node_name == "model" and "messages" in node_data:
for agent_msg in node_data["messages"]:
if hasattr(agent_msg, "tool_calls") and agent_msg.tool_calls:
for tc in agent_msg.tool_calls:
yield {
"type": "tool_start",
"tool": tc["name"],
"input": str(tc.get("args", ""))[:1000],
}
yield {"type": "done", "content": full_response}
async def ainvoke(self, message: str, session_id: str) -> str:
"""Non-streaming invocation (fallback)."""
history = session_manager.load_session(session_id)
agent = self._build_agent()
messages = self._build_messages(message, history)
result = await agent.ainvoke({"messages": messages})
final_messages = result.get("messages", [])
for msg in reversed(final_messages):
if hasattr(msg, "content") and msg.type == "ai" and msg.content:
response = msg.content
session_manager.save_message(session_id, "user", message)
session_manager.save_message(session_id, "assistant", response)
return response
return "No response generated."
agent_manager = AgentManager()
+165
View File
@@ -0,0 +1,165 @@
"""MemoryIndexer — Vector index for MEMORY.md with auto-rebuild on change."""
import hashlib
import os
from pathlib import Path
from typing import Any
class MemoryIndexer:
"""Indexes memory/MEMORY.md for RAG retrieval.
Uses MD5 hash to detect changes and auto-rebuild the vector index.
Storage is kept separate from the knowledge base index.
"""
def __init__(self, base_dir: Path) -> None:
self._base_dir = base_dir
self._memory_path = base_dir / "memory" / "MEMORY.md"
self._storage_dir = base_dir / "storage" / "memory_index"
self._hash_path = self._storage_dir / ".memory_hash"
self._index: Any = None
def _get_file_hash(self) -> str:
"""Get MD5 hash of MEMORY.md."""
if not self._memory_path.exists():
return ""
content = self._memory_path.read_bytes()
return hashlib.md5(content).hexdigest()
def _get_stored_hash(self) -> str:
"""Get the stored hash from the last build."""
if not self._hash_path.exists():
return ""
return self._hash_path.read_text(encoding="utf-8").strip()
def _save_hash(self, hash_value: str) -> None:
"""Save the current hash."""
self._hash_path.parent.mkdir(parents=True, exist_ok=True)
self._hash_path.write_text(hash_value, encoding="utf-8")
def _maybe_rebuild(self) -> None:
"""Rebuild index if MEMORY.md has changed."""
current_hash = self._get_file_hash()
stored_hash = self._get_stored_hash()
if current_hash and current_hash != stored_hash:
self.rebuild_index()
def rebuild_index(self) -> None:
"""Read MEMORY.md, split into chunks, build vector index, persist."""
if not self._memory_path.exists():
print("⚠️ memory/MEMORY.md not found, skipping index build")
self._index = None
return
try:
from llama_index.core import (
Document,
StorageContext,
VectorStoreIndex,
)
from llama_index.core.node_parser import SentenceSplitter
from llama_index.core.settings import Settings
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
# 使用 HuggingFace 镜像加速模型下载
if "HF_ENDPOINT" not in os.environ:
os.environ["HF_ENDPOINT"] = "https://hf-mirror.com"
Settings.embed_model = HuggingFaceEmbedding(
model_name=os.getenv("EMBEDDING_MODEL", "BAAI/bge-small-zh"),
)
content = self._memory_path.read_text(encoding="utf-8")
if not content.strip():
self._index = None
return
doc = Document(text=content, metadata={"source": "MEMORY.md"})
splitter = SentenceSplitter(chunk_size=256, chunk_overlap=32)
nodes = splitter.get_nodes_from_documents([doc])
self._storage_dir.mkdir(parents=True, exist_ok=True)
index = VectorStoreIndex(nodes)
index.storage_context.persist(persist_dir=str(self._storage_dir))
self._index = index
# Save hash
self._save_hash(self._get_file_hash())
print(f"🔄 Memory index rebuilt ({len(nodes)} chunks)")
except ImportError as e:
print(f"⚠️ LlamaIndex not fully installed: {e}")
self._index = None
except Exception as e:
print(f"⚠️ Memory index build error: {e}")
self._index = None
def _load_index(self) -> Any:
"""Load persisted index from storage."""
if self._index is not None:
return self._index
if not self._storage_dir.exists() or not any(self._storage_dir.iterdir()):
return None
try:
from llama_index.core import StorageContext, load_index_from_storage
from llama_index.core.settings import Settings
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
# 使用 HuggingFace 镜像加速模型下载
if "HF_ENDPOINT" not in os.environ:
os.environ["HF_ENDPOINT"] = "https://hf-mirror.com"
Settings.embed_model = HuggingFaceEmbedding(
model_name=os.getenv("EMBEDDING_MODEL", "BAAI/bge-small-zh"),
)
storage_context = StorageContext.from_defaults(
persist_dir=str(self._storage_dir)
)
self._index = load_index_from_storage(storage_context)
return self._index
except Exception as e:
print(f"⚠️ Failed to load memory index: {e}")
return None
def retrieve(
self, query: str, top_k: int = 3
) -> list[dict[str, Any]]:
"""Retrieve relevant memory chunks for a query."""
self._maybe_rebuild()
index = self._load_index()
if index is None:
return []
try:
retriever = index.as_retriever(similarity_top_k=top_k)
nodes = retriever.retrieve(query)
results: list[dict[str, Any]] = []
for node in nodes:
results.append({
"text": node.get_text(),
"score": f"{node.get_score():.4f}" if node.get_score() else "N/A",
"source": node.metadata.get("source", "MEMORY.md"),
})
return results
except Exception as e:
print(f"⚠️ Memory retrieval error: {e}")
return []
# Singleton
_instance: MemoryIndexer | None = None
def get_memory_indexer(base_dir: Path) -> MemoryIndexer:
"""Get or create the singleton MemoryIndexer."""
global _instance
if _instance is None:
_instance = MemoryIndexer(base_dir)
return _instance
+60
View File
@@ -0,0 +1,60 @@
"""Prompt Builder — Assemble system prompt from 6 Markdown files."""
from pathlib import Path
MAX_COMPONENT_LENGTH = 20000
def _read_component(path: Path) -> str:
"""Read a file, truncating if it exceeds MAX_COMPONENT_LENGTH."""
if not path.exists():
return ""
content = path.read_text(encoding="utf-8")
if len(content) > MAX_COMPONENT_LENGTH:
content = content[:MAX_COMPONENT_LENGTH] + "\n...[truncated]"
return content
RAG_GUIDANCE = """注意:长期记忆(MEMORY.md)已切换为RAG检索模式。
系统会根据用户的问题自动检索相关记忆片段并注入上下文。
如果检索到了相关记忆,它们会以"[记忆检索结果]"标记呈现在你的上下文中。"""
def build_system_prompt(base_dir: Path, rag_mode: bool = False) -> str:
"""Build the full system prompt by concatenating components in order.
Order:
1. SKILLS_SNAPSHOT.md — available skills listing
2. workspace/SOUL.md — persona, tone, boundaries
3. workspace/IDENTITY.md — name, style, emoji
4. workspace/USER.md — user profile
5. workspace/AGENTS.md — operation instructions & memory/skill protocols
6. memory/MEMORY.md — cross-session long-term memory (skipped in RAG mode)
When rag_mode=True, MEMORY.md is excluded and a RAG guidance note is appended.
"""
components = [
("Skills Snapshot", base_dir / "SKILLS_SNAPSHOT.md"),
("Soul", base_dir / "workspace" / "SOUL.md"),
("Identity", base_dir / "workspace" / "IDENTITY.md"),
("User Profile", base_dir / "workspace" / "USER.md"),
("Agents Guide", base_dir / "workspace" / "AGENTS.md"),
]
# Only include MEMORY.md when not in RAG mode
if not rag_mode:
components.append(
("Long-term Memory", base_dir / "memory" / "MEMORY.md")
)
parts: list[str] = []
for label, path in components:
content = _read_component(path)
if content:
parts.append(f"<!-- {label} -->\n{content}")
# Append RAG guidance when in RAG mode
if rag_mode:
parts.append(f"<!-- RAG Mode -->\n{RAG_GUIDANCE}")
return "\n\n".join(parts)
+242
View File
@@ -0,0 +1,242 @@
"""Session Manager — JSON file-based conversation persistence with metadata."""
import json
import time
from pathlib import Path
from typing import Any
class SessionManager:
"""Manages conversation history as JSON files in sessions/ directory.
Storage format (v2):
{
"title": "会话标题",
"created_at": 1706000000,
"updated_at": 1706000100,
"messages": [{"role": "user", "content": "..."}, ...]
}
"""
def __init__(self) -> None:
self._sessions_dir: Path | None = None
def initialize(self, base_dir: Path) -> None:
self._sessions_dir = base_dir / "sessions"
self._sessions_dir.mkdir(exist_ok=True)
def _session_path(self, session_id: str) -> Path:
assert self._sessions_dir is not None
safe_id = "".join(c for c in session_id if c.isalnum() or c in "-_")
return self._sessions_dir / f"{safe_id}.json"
def _read_file(self, session_id: str) -> dict[str, Any]:
"""Read session file and normalize to v2 format."""
path = self._session_path(session_id)
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
# Migrate v1 (plain list) to v2 (object with metadata)
if isinstance(data, list):
now = time.time()
return {
"title": session_id,
"created_at": path.stat().st_ctime,
"updated_at": now,
"messages": data,
}
return data
except (json.JSONDecodeError, Exception):
return {}
def _write_file(self, session_id: str, data: dict[str, Any]) -> None:
"""Write session data to file."""
data["updated_at"] = time.time()
path = self._session_path(session_id)
path.write_text(
json.dumps(data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def create_session(self, session_id: str) -> dict[str, Any]:
"""Create a new empty session. Returns metadata."""
now = time.time()
data: dict[str, Any] = {
"title": "New Chat",
"created_at": now,
"updated_at": now,
"messages": [],
}
self._write_file(session_id, data)
return {"id": session_id, "title": data["title"], "created_at": now, "updated_at": now}
def load_session(self, session_id: str) -> list[dict[str, Any]]:
"""Load conversation history for a session."""
data = self._read_file(session_id)
if not data:
return []
return data.get("messages", [])
def save_message(
self,
session_id: str,
role: str,
content: str,
tool_calls: list[dict[str, Any]] | None = None,
) -> None:
"""Append a message to the session history."""
data = self._read_file(session_id)
if not data:
now = time.time()
data = {
"title": "New Chat",
"created_at": now,
"updated_at": now,
"messages": [],
}
msg: dict[str, Any] = {"role": role, "content": content}
if tool_calls:
msg["tool_calls"] = tool_calls
data["messages"].append(msg)
self._write_file(session_id, data)
def rename_session(self, session_id: str, title: str) -> None:
"""Rename a session."""
data = self._read_file(session_id)
if not data:
raise FileNotFoundError(f"Session {session_id} not found")
data["title"] = title
self._write_file(session_id, data)
def update_title(self, session_id: str, title: str) -> None:
"""Update session title (alias for rename_session)."""
self.rename_session(session_id, title)
def delete_session(self, session_id: str) -> None:
"""Delete a session file."""
path = self._session_path(session_id)
if path.exists():
path.unlink()
def get_raw_messages(self, session_id: str) -> dict[str, Any]:
"""Return the complete session data including all messages."""
data = self._read_file(session_id)
if not data:
return {"title": "", "messages": []}
return data
def list_sessions(self) -> list[dict[str, Any]]:
"""List all sessions with metadata."""
assert self._sessions_dir is not None
sessions: list[dict[str, Any]] = []
for f in sorted(self._sessions_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True):
try:
raw = json.loads(f.read_text(encoding="utf-8"))
if isinstance(raw, dict):
title = raw.get("title", f.stem)
updated_at = raw.get("updated_at", f.stat().st_mtime)
else:
title = f.stem
updated_at = f.stat().st_mtime
except Exception:
title = f.stem
updated_at = f.stat().st_mtime
sessions.append({
"id": f.stem,
"title": title,
"updated_at": updated_at,
})
return sessions
def compress_history(
self, session_id: str, summary: str, num_to_remove: int
) -> None:
"""Archive first N messages and store summary as compressed_context."""
assert self._sessions_dir is not None
data = self._read_file(session_id)
if not data:
return
messages = data.get("messages", [])
archived_messages = messages[:num_to_remove]
# Archive to sessions/archive/
archive_dir = self._sessions_dir / "archive"
archive_dir.mkdir(exist_ok=True)
archive_data = {
"session_id": session_id,
"archived_at": time.time(),
"messages": archived_messages,
}
archive_path = archive_dir / f"{session_id}_{int(time.time())}.json"
archive_path.write_text(
json.dumps(archive_data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
# Remove archived messages from session
data["messages"] = messages[num_to_remove:]
# Append summary to compressed_context (support multiple compressions)
existing_context = data.get("compressed_context", "")
if existing_context:
data["compressed_context"] = existing_context + "\n---\n" + summary
else:
data["compressed_context"] = summary
self._write_file(session_id, data)
def get_compressed_context(self, session_id: str) -> str | None:
"""Return compressed context if any."""
data = self._read_file(session_id)
if not data:
return None
return data.get("compressed_context")
def load_session_for_agent(self, session_id: str) -> list[dict[str, Any]]:
"""Load session history merged for LLM context.
Since we now save multiple consecutive assistant messages per turn,
this method combines them back into single assistant messages to
maintain proper user/assistant alternation for the LLM.
If compressed_context exists, inserts it at the head as an assistant
message so the LLM retains prior context.
"""
data = self._read_file(session_id)
messages = data.get("messages", []) if data else []
merged: list[dict[str, Any]] = []
# Inject compressed context as the first message if available
compressed = data.get("compressed_context", "") if data else ""
if compressed:
merged.append({
"role": "assistant",
"content": f"[以下是之前对话的摘要]\n{compressed}",
})
for msg in messages:
if (
merged
and merged[-1]["role"] == "assistant"
and msg["role"] == "assistant"
):
# Combine consecutive assistant messages
merged[-1]["content"] += "\n" + msg["content"]
else:
# Strip tool_calls for agent context (not needed by LLM)
merged.append({"role": msg["role"], "content": msg["content"]})
return merged
def get_message_count(self, session_id: str) -> int:
"""Return the number of messages in a session."""
data = self._read_file(session_id)
if not data:
return 0
return len(data.get("messages", []))
session_manager = SessionManager()
+11
View File
@@ -0,0 +1,11 @@
@echo off
cd /d F:\mini-OpenClaw\backend
echo [1/4] Creating venv...
python -m venv venv
echo [2/4] Activating venv...
call venv\Scripts\activate.bat
echo [3/4] Upgrading pip...
pip install --upgrade pip -i https://pypi.tuna.tsinghua.edu.cn/simple
echo [4/4] Installing requirements...
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
echo Done!
+13
View File
@@ -0,0 +1,13 @@
# mini OpenClaw 长期记忆
> 此文件由 mini OpenClaw 自动维护,记录跨会话的长期记忆。
> (原 MEMORY.md 因编码损坏被重建为空记忆)
## 用户偏好
(可在此记录用户的长期使用偏好,如:交付物格式、常用工具、工作习惯等)
## 使用说明
- 当 rag_mode=True 时,此文件内容通过 RAG 检索注入上下文
- 保持 UTF-8 编码,避免出现二进制/非中文内容引起读取错误
+23
View File
@@ -0,0 +1,23 @@
0000 23 20 b3 a4 c6 da bc c7 d2 e4 20 20 0d 0a 20 20 # ........ ..
0010 0d 0a 22 3e 20 b4 cb ce c4 bc fe d3 c9 20 6d 69 .."> ........ mi
0020 6e 69 20 4f 70 65 6e 43 6c 61 77 20 d7 d4 b6 af ni OpenClaw ....
0030 ce ac bb a4 a3 ac bc c7 c2 bc bf e7 bb e1 bb b0 ................
0040 b5 c4 d6 d8 d2 aa d0 c5 cf a2 a1 a3 22 20 20 0d ............" .
0050 0a 20 20 0d 0a 22 23 23 20 d3 c3 bb a7 c6 ab ba . .."## .......
0060 c3 22 20 20 0d 0a 20 20 0d 0a 22 d3 c3 bb a7 cf ." .. ..".....
0070 b2 bb b6 b8 fc bc d3 d1 cf bd f7 b5 c4 bb d8 b4 ................
0080 f0 22 20 20 0d 0a 20 20 0d 0a 22 23 23 20 d6 d8 ." .. .."## ..
0090 d2 aa ca c2 cf ee 22 20 20 0d 0a 20 20 0d 0a 22 ......" .. .."
00a0 23 23 23 20 bc bc c4 dc c2 b7 be b6 d3 b3 c9 e4 ### ............
00b0 22 20 20 0d 0a 22 2d 20 67 65 74 5f 77 65 61 74 " .."- get_weat
00c0 68 65 72 20 bc bc c4 dc b5 c4 d5 fd c8 b7 c2 b7 her ............
00d0 be b6 a3 ba 73 6b 69 6c 6c 73 2f 67 65 74 5f 77 ....skills/get_w
00e0 65 61 74 68 65 72 2f 53 4b 49 4c 4c 2e 6d 64 22 eather/SKILL.md"
00f0 20 20 0d 0a 22 2d 20 bc bc c4 dc bf ec d5 d5 d6 .."- .........
0100 d0 b5 c4 c2 b7 be b6 a3 a8 2e 2f 62 61 63 6b 65 ........../backe
0110 6e 64 2f 73 6b 69 6c 6c 73 2f 67 65 74 5f 77 65 nd/skills/get_we
0120 61 74 68 65 72 2f 53 4b 49 4c 4c 2e 6d 64 a3 a9 ather/SKILL.md..
0130 ca c7 b4 ed ce f3 b5 c4 22 20 20 0d 0a 22 2d 20 ........" .."-
0140 cf c2 b4 ce b5 f7 d3 c3 20 67 65 74 5f 77 65 61 ........ get_wea
0150 74 68 65 72 20 ca b1 a3 ac d3 a6 d6 b1 bd d3 ca ther ...........
0160 b9 d3 c3 d5 fd c8 b7 c2 b7 be b6 22 20 0d 0a ..........." ..
+27
View File
@@ -0,0 +1,27 @@
fastapi>=0.110.0
uvicorn[standard]>=0.27.0
sse-starlette>=2.0.0
python-dotenv>=1.0.0
pydantic>=2.0.0
# LangChain
langchain>=1.0.0
langchain-openai>=0.3.0
langchain-deepseek>=0.1.0
langchain-community>=0.3.0
langchain-experimental>=0.3.0
langgraph>=1.0.0
# LlamaIndex
llama-index-core>=0.12.0
llama-index-embeddings-openai>=0.3.0
llama-index-retrievers-bm25>=0.4.0
# Token counting
tiktoken>=0.7.0
# Tools
html2text>=2024.2.26
beautifulsoup4>=4.12.0
requests>=2.31.0
pyyaml>=6.0.0
+1
View File
@@ -0,0 +1 @@
# This file keeps the sessions directory in git
+39
View File
@@ -0,0 +1,39 @@
@echo off
echo === 阶段一:搭建后端环境 ===
echo.
echo [1/3] Creating Python virtual environment...
if not exist venv (
python -m venv venv
if %ERRORLEVEL% EQU 0 (
echo [OK] Virtual environment created
) else (
echo [ERROR] Failed to create venv
exit /b 1
)
) else (
echo [OK] Virtual environment already exists
)
echo.
echo [2/3] Installing dependencies with Tsinghua mirror...
call venv\Scripts\activate.bat
pip install --upgrade pip -i https://pypi.tuna.tsinghua.edu.cn/simple
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
if %ERRORLEVEL% EQU 0 (
echo [OK] Dependencies installed successfully
) else (
echo [ERROR] Failed to install dependencies
exit /b 1
)
echo.
echo [3/3] Verifying installation...
python -c "import fastapi; import langchain; import llama_index; print('[OK] All core packages imported')"
echo.
echo === Setup Complete ===
echo To start the backend:
echo venv\Scripts\activate.bat
echo uvicorn app:app --host 0.0.0.0 --port 8002
+46
View File
@@ -0,0 +1,46 @@
# Setup script for mini-OpenClaw backend
Write-Host "=== 阶段一:搭建后端环境 ===" -ForegroundColor Green
# 1. Create virtual environment
Write-Host "`n[1/3] Creating Python virtual environment..." -ForegroundColor Yellow
if (-not (Test-Path venv)) {
python -m venv venv
if ($LASTEXITCODE -eq 0) {
Write-Host "✓ Virtual environment created" -ForegroundColor Green
} else {
Write-Host "✗ Failed to create venv" -ForegroundColor Red
exit 1
}
} else {
Write-Host "✓ Virtual environment already exists" -ForegroundColor Green
}
# 2. Activate and install dependencies
Write-Host "`n[2/3] Installing dependencies..." -ForegroundColor Yellow
Write-Host "Using Tsinghua mirror for faster downloads..." -ForegroundColor Cyan
& .\venv\Scripts\Activate.ps1
if ($LASTEXITCODE -ne 0) {
Write-Host "✗ Failed to activate venv" -ForegroundColor Red
exit 1
}
pip install --upgrade pip -i https://pypi.tuna.tsinghua.edu.cn/simple
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
if ($LASTEXITCODE -eq 0) {
Write-Host "✓ Dependencies installed successfully" -ForegroundColor Green
} else {
Write-Host "✗ Failed to install dependencies" -ForegroundColor Red
exit 1
}
# 3. Verify installation
Write-Host "`n[3/3] Verifying installation..." -ForegroundColor Yellow
python -c "import fastapi; import langchain; import llama_index; print('✓ All core packages imported successfully')"
Write-Host "`n=== Setup Complete ===" -ForegroundColor Green
Write-Host "To start the backend, run:" -ForegroundColor Cyan
Write-Host " .\venv\Scripts\Activate.ps1" -ForegroundColor White
Write-Host " uvicorn app:app --host 0.0.0.0 --port 8002" -ForegroundColor White
+37
View File
@@ -0,0 +1,37 @@
---
name: get_weather
description: 获取指定城市的实时天气信息
---
# 获取天气技能
## 使用场景
当用户询问某个城市的天气情况时使用此技能。
## 执行步骤
1. 从用户消息中提取城市名称
2. 使用 `fetch_url` 工具访问 wttr.in 天气服务:
```
fetch_url("https://wttr.in/{城市名}?format=j1&lang=zh")
```
3. 解析返回的 JSON 数据,提取关键信息:
- 当前温度
- 天气状况
- 湿度
- 风速
4. 用自然语言向用户汇报天气情况
## 示例
用户:「查询北京的天气」
执行流程:
1. 提取城市:北京 (Beijing)
2. 调用:`fetch_url("https://wttr.in/Beijing?format=j1&lang=zh")`
3. 解析 JSON 结果
4. 回复:「北京当前天气:晴,温度 25°C,湿度 40%,东南风 3 级。」
## 注意事项
- 城市名建议使用英文拼写以提高准确性
- 如果 wttr.in 不可用,可尝试使用 `python_repl` 调用备用 API
+109
View File
@@ -0,0 +1,109 @@
---
name: get_weather_open
description: 使用 OpenWeather API 获取指定城市的实时天气信息
---
# 获取天气技能 (OpenWeather API 版本)
## 使用场景
当用户询问某个城市的天气情况时使用此技能。使用 OpenWeather API 替代 wttr.in,提供更稳定和详细的天气信息。
## 前提条件
1. 需要 OpenWeather API 密钥
2. 需要在环境变量中设置 OPENWEATHER_API_KEY
## 执行步骤
1. 从用户消息中提取城市名称
2. 使用 `python_repl` 工具编写 Python 代码调用 OpenWeather API:
```python
import os
import requests
import json
# 从环境变量获取 API 密钥
api_key = os.getenv("OPENWEATHER_API_KEY")
city = "城市名" # 从用户输入中提取
if not api_key:
print("错误:未设置 OPENWEATHER_API_KEY 环境变量")
else:
# 调用 OpenWeather API
url = f"https://api.openweathermap.org/data/2.5/weather?q={city}&appid={api_key}&units=metric&lang=zh_cn"
response = requests.get(url)
if response.status_code == 200:
data = response.json()
# 提取天气信息
weather = {
"城市": data["name"],
"国家": data["sys"]["country"],
"温度": f"{data['main']['temp']}°C",
"体感温度": f"{data['main']['feels_like']}°C",
"天气状况": data["weather"][0]["description"],
"湿度": f"{data['main']['humidity']}%",
"气压": f"{data['main']['pressure']} hPa",
"风速": f"{data['wind']['speed']} m/s",
"风向": data["wind"].get("deg", "未知"),
"能见度": f"{data.get('visibility', 0) / 1000} km" if data.get('visibility') else "未知"
}
print(json.dumps(weather, ensure_ascii=False, indent=2))
else:
print(f"错误:无法获取天气信息,状态码:{response.status_code}")
print(f"错误信息:{response.text}")
```
3. 解析返回的 JSON 数据,提取关键信息
4. 用自然语言向用户汇报天气情况
## 示例
用户:「查询北京的天气」
执行流程:
1. 提取城市:北京 (Beijing)
2. 在 python_repl 中执行上述代码,将 city 替换为 "Beijing"
3. 解析返回的 JSON 结果
4. 回复:「北京当前天气:晴,温度 25°C,体感温度 26°C,湿度 40%,风速 3.5 m/s。」
## 备用方案
如果 OpenWeather API 不可用,可以回退到使用 fetch_url 访问 wttr.in:
```python
import requests
try:
# 尝试 OpenWeather API
# ...
except Exception as e:
# 回退到 wttr.in
fallback_url = f"https://wttr.in/{city}?format=j1&lang=zh"
fallback_response = requests.get(fallback_url)
# 解析 wttr.in 数据
```
## 注意事项
1. **API 密钥**:必须先在环境变量中设置 OPENWEATHER_API_KEY
2. **城市格式**:建议使用英文城市名,OpenWeather API 支持中文但英文更可靠
3. **单位系统**:使用公制单位 (metric)
4. **语言**:设置为中文 (lang=zh_cn)
5. **错误处理**:妥善处理 API 调用失败的情况
6. **速率限制**:OpenWeather 免费版有调用限制,注意不要频繁调用
## 环境变量设置
在 .env 文件中添加:
```
OPENWEATHER_API_KEY=your_api_key_here
```
## 获取 API 密钥
1. 访问 https://openweathermap.org/api
2. 注册账号
3. 在 Dashboard 中创建 API 密钥
4. 免费版每天有 60 次调用限制
## 数据字段说明
- main.temp: 当前温度 (摄氏度)
- main.feels_like: 体感温度
- weather[0].description: 天气描述
- main.humidity: 湿度百分比
- wind.speed: 风速 (米/秒)
- visibility: 能见度 (米)
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""
测试 OpenWeather API 技能
"""
import os
import sys
import requests
import json
def test_openweather_api(city="Beijing"):
"""测试 OpenWeather API 调用"""
# 从环境变量获取 API 密钥
api_key = os.getenv("OPENWEATHER_API_KEY")
if not api_key:
print("❌ 错误:未设置 OPENWEATHER_API_KEY 环境变量")
print("请在 .env 文件中添加:OPENWEATHER_API_KEY=your_api_key_here")
return False
print(f"🔍 正在测试 OpenWeather API,查询城市:{city}")
# 构建 API 请求 URL
url = f"https://api.openweathermap.org/data/2.5/weather?q={city}&appid={api_key}&units=metric&lang=zh_cn"
try:
# 发送请求
response = requests.get(url, timeout=10)
if response.status_code == 200:
data = response.json()
# 提取天气信息
weather_info = {
"城市": data.get("name", "未知"),
"国家": data.get("sys", {}).get("country", "未知"),
"温度": f"{data.get('main', {}).get('temp', '未知')}°C",
"体感温度": f"{data.get('main', {}).get('feels_like', '未知')}°C",
"天气状况": data.get('weather', [{}])[0].get('description', '未知'),
"湿度": f"{data.get('main', {}).get('humidity', '未知')}%",
"气压": f"{data.get('main', {}).get('pressure', '未知')} hPa",
"风速": f"{data.get('wind', {}).get('speed', '未知')} m/s",
"风向": data.get('wind', {}).get('deg', '未知'),
"能见度": f"{data.get('visibility', 0) / 1000} km" if data.get('visibility') else "未知"
}
print("✅ API 调用成功!")
print("📊 天气信息:")
for key, value in weather_info.items():
print(f" {key}: {value}")
return True
else:
print(f"❌ API 调用失败,状态码:{response.status_code}")
print(f"错误信息:{response.text}")
# 如果是 401 错误,可能是 API 密钥无效
if response.status_code == 401:
print("💡 提示:请检查 OPENWEATHER_API_KEY 是否正确")
return False
except requests.exceptions.Timeout:
print("❌ 请求超时,请检查网络连接")
return False
except requests.exceptions.RequestException as e:
print(f"❌ 网络请求错误:{e}")
return False
except Exception as e:
print(f"❌ 未知错误:{e}")
return False
if __name__ == "__main__":
# 获取命令行参数中的城市名
city = sys.argv[1] if len(sys.argv) > 1 else "Beijing"
print("=" * 50)
print("OpenWeather API 测试脚本")
print("=" * 50)
success = test_openweather_api(city)
print("=" * 50)
if success:
print("✅ 测试通过!新技能可以正常工作")
else:
print("❌ 测试失败,请检查配置")
print("
💡 使用说明:")
print("1. 在 https://openweathermap.org/api 注册并获取 API 密钥")
print("2. 在 .env 文件中设置 OPENWEATHER_API_KEY=your_api_key")
print("3. 运行:python test_openweather.py [城市名]")
print(" 例如:python test_openweather.py Shanghai")
+23
View File
@@ -0,0 +1,23 @@
"""Core Tools factory — returns all 5 tools for the Agent."""
from pathlib import Path
from typing import List
from langchain_core.tools import BaseTool
from .terminal_tool import create_terminal_tool
from .python_repl_tool import create_python_repl_tool
from .fetch_url_tool import create_fetch_url_tool
from .read_file_tool import create_read_file_tool
from .search_knowledge_tool import create_search_knowledge_tool
def get_all_tools(base_dir: Path) -> List[BaseTool]:
"""Create and return all 5 core tools, sandboxed to base_dir."""
return [
create_terminal_tool(base_dir),
create_python_repl_tool(),
create_fetch_url_tool(),
create_read_file_tool(base_dir),
create_search_knowledge_tool(base_dir),
]
+59
View File
@@ -0,0 +1,59 @@
"""FetchURLTool — Fetch a URL and return cleaned Markdown content."""
from typing import Type
import html2text
import requests
from langchain_core.tools import BaseTool
from pydantic import BaseModel, Field
class FetchURLInput(BaseModel):
url: str = Field(description="The URL to fetch content from")
class FetchURLTool(BaseTool):
name: str = "fetch_url"
description: str = (
"Fetch the content of a web page and return it as cleaned Markdown text. "
"Use this to retrieve information from the internet. "
"Input should be a valid URL (starting with http:// or https://)."
)
args_schema: Type[BaseModel] = FetchURLInput
def _run(self, url: str) -> str:
try:
headers = {
"User-Agent": "Mozilla/5.0 (compatible; MiniOpenClaw/0.1)"
}
resp = requests.get(url, headers=headers, timeout=15)
resp.raise_for_status()
content_type = resp.headers.get("content-type", "")
# If JSON, return directly
if "application/json" in content_type:
text = resp.text
if len(text) > 5000:
text = text[:5000] + "\n...[truncated]"
return text
# Convert HTML to Markdown
converter = html2text.HTML2Text()
converter.ignore_links = False
converter.ignore_images = True
converter.body_width = 0
markdown = converter.handle(resp.text)
if len(markdown) > 5000:
markdown = markdown[:5000] + "\n...[truncated]"
return markdown
except requests.Timeout:
return "❌ Request timed out (15s limit)"
except requests.RequestException as e:
return f"❌ Fetch error: {str(e)}"
def create_fetch_url_tool() -> FetchURLTool:
return FetchURLTool()
+17
View File
@@ -0,0 +1,17 @@
"""Python REPL Tool — wraps LangChain experimental PythonREPLTool."""
from langchain_core.tools import BaseTool
from langchain_experimental.tools import PythonREPLTool
def create_python_repl_tool() -> BaseTool:
tool = PythonREPLTool()
tool.name = "python_repl"
tool.description = (
"Execute Python code in an interactive REPL environment. "
"Use this for calculations, data processing, running scripts, "
"and any task that benefits from programmatic execution. "
"Input should be valid Python code. "
"Use print() to see output."
)
return tool
+53
View File
@@ -0,0 +1,53 @@
"""ReadFileTool — sandboxed file reading within project directory."""
from pathlib import Path
from typing import Type
from langchain_core.tools import BaseTool
from pydantic import BaseModel, Field
class ReadFileInput(BaseModel):
file_path: str = Field(
description="Relative path of the file to read (relative to project root)"
)
class SandboxedReadFileTool(BaseTool):
name: str = "read_file"
description: str = (
"Read the content of a local file. Path is relative to the project root. "
"Use this to read SKILL.md files, MEMORY.md, configuration files, etc. "
"Example: read_file('skills/get_weather/SKILL.md')"
)
args_schema: Type[BaseModel] = ReadFileInput
root_dir: str = ""
def _run(self, file_path: str) -> str:
try:
root = Path(self.root_dir)
# Normalize path
normalized = file_path.replace("\\", "/").lstrip("./")
full_path = (root / normalized).resolve()
# Sandbox check
if not str(full_path).startswith(str(root.resolve())):
return f"❌ Access denied: path escapes project root"
if not full_path.exists():
return f"❌ File not found: {file_path}"
if not full_path.is_file():
return f"❌ Not a file: {file_path}"
content = full_path.read_text(encoding="utf-8")
if len(content) > 10000:
content = content[:10000] + "\n...[truncated]"
return content
except Exception as e:
return f"❌ Error reading file: {str(e)}"
def create_read_file_tool(base_dir: Path) -> SandboxedReadFileTool:
return SandboxedReadFileTool(root_dir=str(base_dir))
+102
View File
@@ -0,0 +1,102 @@
"""SearchKnowledgeBaseTool — LlamaIndex hybrid search (BM25 + Vector)."""
import os
from pathlib import Path
from typing import Type, Optional
from langchain_core.tools import BaseTool
from pydantic import BaseModel, Field
class SearchKnowledgeInput(BaseModel):
query: str = Field(description="The search query to find relevant knowledge")
class SearchKnowledgeBaseTool(BaseTool):
name: str = "search_knowledge_base"
description: str = (
"Search the local knowledge base using hybrid retrieval (keyword + semantic). "
"Use this when the user asks about specific knowledge or documents. "
"Returns the most relevant passages from the knowledge base."
)
args_schema: Type[BaseModel] = SearchKnowledgeInput
base_dir: str = ""
_index: Optional[object] = None
class Config:
arbitrary_types_allowed = True
def _build_index(self):
"""Build or load LlamaIndex index from knowledge/ directory."""
knowledge_dir = Path(self.base_dir) / "knowledge"
storage_dir = Path(self.base_dir) / "storage"
if not knowledge_dir.exists() or not any(knowledge_dir.iterdir()):
return None
try:
from llama_index.core import (
SimpleDirectoryReader,
StorageContext,
VectorStoreIndex,
load_index_from_storage,
)
from llama_index.core.settings import Settings
from llama_index.embeddings.openai import OpenAIEmbedding
Settings.embed_model = OpenAIEmbedding(
model=os.getenv("EMBEDDING_MODEL", "text-embedding-3-small"),
api_key=os.getenv("OPENAI_API_KEY"),
api_base=os.getenv("OPENAI_BASE_URL", "https://ai.devtool.tech/proxy/v1"),
)
# Try loading persisted index
if storage_dir.exists() and any(storage_dir.iterdir()):
try:
storage_context = StorageContext.from_defaults(
persist_dir=str(storage_dir)
)
return load_index_from_storage(storage_context)
except Exception:
pass
# Build fresh index
documents = SimpleDirectoryReader(
str(knowledge_dir), recursive=True
).load_data()
if not documents:
return None
index = VectorStoreIndex.from_documents(documents)
storage_dir.mkdir(parents=True, exist_ok=True)
index.storage_context.persist(persist_dir=str(storage_dir))
return index
except ImportError as e:
print(f"⚠️ LlamaIndex not fully installed: {e}")
return None
except Exception as e:
print(f"⚠️ Index build error: {e}")
return None
def _run(self, query: str) -> str:
if self._index is None:
self._index = self._build_index()
if self._index is None:
return "📭 Knowledge base is empty. Add documents to backend/knowledge/ to enable search."
try:
query_engine = self._index.as_query_engine(similarity_top_k=3)
response = query_engine.query(query)
result = str(response)
if len(result) > 5000:
result = result[:5000] + "\n...[truncated]"
return result
except Exception as e:
return f"❌ Search error: {str(e)}"
def create_search_knowledge_tool(base_dir: Path) -> SearchKnowledgeBaseTool:
return SearchKnowledgeBaseTool(base_dir=str(base_dir))
+48
View File
@@ -0,0 +1,48 @@
"""Skills Scanner — Scan skills/ directory and generate SKILLS_SNAPSHOT.md."""
from pathlib import Path
import yaml
def scan_skills(base_dir: Path) -> str:
"""Scan all SKILL.md files and generate SKILLS_SNAPSHOT.md."""
skills_dir = base_dir / "skills"
snapshot_path = base_dir / "SKILLS_SNAPSHOT.md"
if not skills_dir.exists():
skills_dir.mkdir(parents=True)
skills = []
for skill_md in sorted(skills_dir.rglob("SKILL.md")):
try:
content = skill_md.read_text(encoding="utf-8")
# Parse YAML frontmatter
if content.startswith("---"):
parts = content.split("---", 2)
if len(parts) >= 3:
meta = yaml.safe_load(parts[1])
if meta:
rel_path = f"./backend/skills/{skill_md.parent.name}/SKILL.md"
skills.append({
"name": meta.get("name", skill_md.parent.name),
"description": meta.get("description", ""),
"location": rel_path,
})
except Exception as e:
print(f"⚠️ Error scanning {skill_md}: {e}")
# Build XML-style snapshot
lines = ["<available_skills>"]
for s in skills:
lines.append(" <skill>")
lines.append(f" <name>{s['name']}</name>")
lines.append(f" <description>{s['description']}</description>")
lines.append(f" <location>{s['location']}</location>")
lines.append(" </skill>")
lines.append("</available_skills>")
snapshot = "\n".join(lines)
snapshot_path.write_text(snapshot, encoding="utf-8")
print(f"📋 Skills snapshot: {len(skills)} skills found")
return snapshot
+78
View File
@@ -0,0 +1,78 @@
"""SafeTerminalTool — sandboxed shell execution with command blacklist."""
import subprocess
from pathlib import Path
from typing import Type
from langchain_core.tools import BaseTool
from pydantic import BaseModel, Field
BLACKLISTED_COMMANDS = [
"rm -rf /",
"rm -rf /*",
"mkfs",
"dd if=",
":(){:|:&};:",
"chmod -R 777 /",
"shutdown",
"reboot",
"halt",
"poweroff",
"format c:",
"del /f /s /q c:",
]
class TerminalInput(BaseModel):
command: str = Field(description="The shell command to execute")
class SafeTerminalTool(BaseTool):
name: str = "terminal"
description: str = (
"Execute shell commands in a sandboxed environment. "
"The working directory is restricted to the project root. "
"Use this for file operations, installing packages, running scripts, etc."
)
args_schema: Type[BaseModel] = TerminalInput
root_dir: str = ""
def _is_safe(self, command: str) -> bool:
cmd_lower = command.lower().strip()
for blocked in BLACKLISTED_COMMANDS:
if blocked in cmd_lower:
return False
return True
def _run(self, command: str) -> str:
if not self._is_safe(command):
return f"❌ Command blocked for safety: {command}"
try:
result = subprocess.run(
command,
shell=True,
cwd=self.root_dir,
capture_output=True,
text=True,
timeout=30,
encoding="utf-8",
errors="replace",
)
output = result.stdout
if result.stderr:
output += f"\n[stderr]: {result.stderr}"
if not output.strip():
output = "(command completed with no output)"
# Truncate very long output
if len(output) > 5000:
output = output[:5000] + "\n...[truncated]"
return output
except subprocess.TimeoutExpired:
return "❌ Command timed out (30s limit)"
except Exception as e:
return f"❌ Error: {str(e)}"
def create_terminal_tool(base_dir: Path) -> SafeTerminalTool:
return SafeTerminalTool(root_dir=str(base_dir))
+41
View File
@@ -0,0 +1,41 @@
# 操作指南
## 技能调用协议 (SKILL PROTOCOL)
你拥有一个技能列表 (SKILLS_SNAPSHOT),其中列出了你可以使用的能力及其定义文件的位置。
**当你要使用某个技能时,必须严格遵守以下步骤:**
1. 你的第一步行动永远是使用 `read_file` 工具读取该技能对应的 `location` 路径下的 Markdown 文件。
2. 仔细阅读文件中的内容、步骤和示例。
3. 根据文件中的指示,结合你内置的 Core Tools (terminal, python_repl, fetch_url) 来执行具体任务。
**禁止**直接猜测技能的参数或用法,必须先读取文件!
## 记忆协议 (MEMORY PROTOCOL)
### 长期记忆
- 文件位置:`memory/MEMORY.md`
- 当对话中出现值得长期记住的信息时(如用户偏好、重要决策),使用 `terminal` 工具将内容追加到 MEMORY.md
### 会话日志
- 文件位置:`memory/logs/YYYY-MM-DD.md`
- 每日自动归档的对话摘要
### 记忆读取
- 在回答问题前,检查 MEMORY.md 中是否有相关的历史信息
- 优先使用已记录的用户偏好
## 工具使用规范
1. **terminal**: 用于执行 Shell 命令,注意安全边界
2. **python_repl**: 用于计算、数据处理、脚本执行
3. **fetch_url**: 用于获取网页内容,返回清洗后的 Markdown
4. **read_file**: 用于读取本地文件,是技能调用的第一步
5. **search_knowledge_base**: 用于在知识库中检索信息
## 回复规范
- 执行工具调用前,简要说明你的意图
- 工具执行结果要进行摘要,不要原封不动返回
- 遇到错误时,尝试其他方案或向用户说明
+8
View File
@@ -0,0 +1,8 @@
# IDENTITY — 身份信息
- **名称**: mini OpenClaw
- **版本**: 0.1.0
- **定位**: 本地运行的透明 AI Agent 助手
- **风格**: 专业而不刻板,友好而不浮夸
- **语言**: 默认中文回复,跟随用户语言切换
- **表情**: 适度使用 emoji 增加亲和力 🤖
+20
View File
@@ -0,0 +1,20 @@
# SOUL — 核心设定
你是一个专业、友善且高度透明的 AI 助手。你的设计哲学是:
## 核心原则
1. **透明优先**:你的所有操作(工具调用、记忆读写、技能执行)对用户完全可见。你不是黑盒,你是一个可被理解和信赖的伙伴。
2. **文件即记忆**:你的记忆以 Markdown 文件的形式存在,任何人都可以直接阅读和编辑。这不是限制,而是你的独特优势。
3. **技能即学习**:你通过阅读 SKILL.md 来学习新能力,就像人类阅读说明书一样。你不会假装知道一个技能的用法,你会先去读它。
4. **安全边界**:你只在授权的目录内操作,不执行危险命令,不读取系统敏感文件。
## 沟通风格
- 简洁、准确、有条理
- 遇到不确定的问题会坦诚告知
- 在执行操作前会说明意图
- 用中文与用户交流(除非用户使用其他语言)
+11
View File
@@ -0,0 +1,11 @@
# USER — 用户画像
## 基本信息
- **称呼**: 用户
- **偏好语言**: 中文
## 兴趣领域
- (待补充,Agent 可通过对话自动更新)
## 备注
- (Agent 在对话中了解到的用户偏好会记录在此)