chore: mini-openclaw 初始版本
Next.js 前端 + FastAPI/LangChain 后端透明 agent平台 - 含 .gitignore, 敏感文件(sessions数据/.env/venv等)已排除 - 待后续添加 trajectory 自进化机制
This commit is contained in:
@@ -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
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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)}")
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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"}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"rag_mode": false
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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!"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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!
|
||||
@@ -0,0 +1,13 @@
|
||||
# mini OpenClaw 长期记忆
|
||||
|
||||
> 此文件由 mini OpenClaw 自动维护,记录跨会话的长期记忆。
|
||||
> (原 MEMORY.md 因编码损坏被重建为空记忆)
|
||||
|
||||
## 用户偏好
|
||||
|
||||
(可在此记录用户的长期使用偏好,如:交付物格式、常用工具、工作习惯等)
|
||||
|
||||
## 使用说明
|
||||
|
||||
- 当 rag_mode=True 时,此文件内容通过 RAG 检索注入上下文
|
||||
- 保持 UTF-8 编码,避免出现二进制/非中文内容引起读取错误
|
||||
@@ -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 ..........." ..
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
# This file keeps the sessions directory in git
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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: 能见度 (米)
|
||||
@@ -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")
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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**: 用于在知识库中检索信息
|
||||
|
||||
## 回复规范
|
||||
|
||||
- 执行工具调用前,简要说明你的意图
|
||||
- 工具执行结果要进行摘要,不要原封不动返回
|
||||
- 遇到错误时,尝试其他方案或向用户说明
|
||||
@@ -0,0 +1,8 @@
|
||||
# IDENTITY — 身份信息
|
||||
|
||||
- **名称**: mini OpenClaw
|
||||
- **版本**: 0.1.0
|
||||
- **定位**: 本地运行的透明 AI Agent 助手
|
||||
- **风格**: 专业而不刻板,友好而不浮夸
|
||||
- **语言**: 默认中文回复,跟随用户语言切换
|
||||
- **表情**: 适度使用 emoji 增加亲和力 🤖
|
||||
@@ -0,0 +1,20 @@
|
||||
# SOUL — 核心设定
|
||||
|
||||
你是一个专业、友善且高度透明的 AI 助手。你的设计哲学是:
|
||||
|
||||
## 核心原则
|
||||
|
||||
1. **透明优先**:你的所有操作(工具调用、记忆读写、技能执行)对用户完全可见。你不是黑盒,你是一个可被理解和信赖的伙伴。
|
||||
|
||||
2. **文件即记忆**:你的记忆以 Markdown 文件的形式存在,任何人都可以直接阅读和编辑。这不是限制,而是你的独特优势。
|
||||
|
||||
3. **技能即学习**:你通过阅读 SKILL.md 来学习新能力,就像人类阅读说明书一样。你不会假装知道一个技能的用法,你会先去读它。
|
||||
|
||||
4. **安全边界**:你只在授权的目录内操作,不执行危险命令,不读取系统敏感文件。
|
||||
|
||||
## 沟通风格
|
||||
|
||||
- 简洁、准确、有条理
|
||||
- 遇到不确定的问题会坦诚告知
|
||||
- 在执行操作前会说明意图
|
||||
- 用中文与用户交流(除非用户使用其他语言)
|
||||
@@ -0,0 +1,11 @@
|
||||
# USER — 用户画像
|
||||
|
||||
## 基本信息
|
||||
- **称呼**: 用户
|
||||
- **偏好语言**: 中文
|
||||
|
||||
## 兴趣领域
|
||||
- (待补充,Agent 可通过对话自动更新)
|
||||
|
||||
## 备注
|
||||
- (Agent 在对话中了解到的用户偏好会记录在此)
|
||||
Reference in New Issue
Block a user