2553 lines
77 KiB
Markdown
2553 lines
77 KiB
Markdown
# OpenClaw Session Archive 实施计划
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 构建一个 Django + DRF 项目,包含数据模型、批量写入 API、自定义 Admin 视图、每日 Markdown 导出,以及客户端 JSONL 解析推送脚本,通过 Docker Compose 部署。
|
||
|
||
**Architecture:** 三节点各运行一个独立解析脚本(纯 Python),将 JSONL 解析为结构化 JSON 后 POST 到 Django API。Django 服务容器化运行,连接远程 NAS 上的 PostgreSQL + TimescaleDB。
|
||
|
||
**Tech Stack:** Python 3.12, Django 5.x, DRF 3.15+, psycopg 3.x, PostgreSQL 16+ TimescaleDB, Docker Compose, pytest, pytest-django
|
||
|
||
---
|
||
|
||
## 文件结构总览
|
||
|
||
```
|
||
agent-base/
|
||
├── manage.py # Django 入口
|
||
├── requirements.txt # Python 依赖
|
||
├── requirements-dev.txt # 开发依赖(pytest 等)
|
||
├── pyproject.toml # pytest 配置
|
||
├── .env.example # 环境变量模板
|
||
├── .gitignore
|
||
├── .dockerignore
|
||
├── Dockerfile # Django 生产镜像
|
||
├── docker-compose.yml # 编排配置
|
||
├── nginx/
|
||
│ └── nginx.conf.placeholder # Nginx 反代占位(预留)
|
||
├── src/
|
||
│ ├── config/ # Django 项目配置
|
||
│ │ ├── __init__.py
|
||
│ │ ├── settings/
|
||
│ │ │ ├── __init__.py
|
||
│ │ │ ├── base.py # 公共配置
|
||
│ │ │ └── dev.py # 开发覆盖(DB 用 SQLite)
|
||
│ │ ├── urls.py # 路由
|
||
│ │ └── wsgi.py
|
||
│ └── openclaw/ # Django app
|
||
│ ├── __init__.py
|
||
│ ├── apps.py
|
||
│ ├── models.py # Session, Message, ToolCall
|
||
│ ├── views.py # API views
|
||
│ ├── admin.py # Admin 自定义
|
||
│ ├── services.py # 批量写入 + Markdown 导出
|
||
│ ├── urls.py # app 路由
|
||
│ └── templates/
|
||
│ └── admin/
|
||
│ └── openclaw/
|
||
│ └── daily_view.html
|
||
├── tests/
|
||
│ ├── conftest.py # pytest fixtures
|
||
│ ├── test_models.py # 模型测试
|
||
│ ├── test_api.py # API 测试
|
||
│ ├── test_services.py # 服务层测试
|
||
│ └── test_admin_export.py # 导出测试
|
||
├── scripts/
|
||
│ └── sync_sessions.py # 客户端解析与推送脚本
|
||
└── docs/
|
||
├── specs/ # Spec 文档
|
||
└── plans/ # 实施计划
|
||
```
|
||
|
||
---
|
||
|
||
### Task 0: 项目骨架与测试基础
|
||
|
||
**Files:**
|
||
- Create: `pyproject.toml`, `manage.py`, `requirements.txt`, `requirements-dev.txt`
|
||
- Create: `src/config/__init__.py`, `src/config/settings/__init__.py`, `src/config/settings/base.py`, `src/config/settings/dev.py`, `src/config/urls.py`, `src/config/wsgi.py`
|
||
- Create: `src/openclaw/__init__.py`, `src/openclaw/apps.py`
|
||
- Create: `tests/conftest.py`
|
||
- Create: `.gitignore`, `.env.example`
|
||
|
||
- [ ] **Step 1: 依赖文件**
|
||
|
||
`requirements.txt`:
|
||
```
|
||
Django>=5.0,<6.0
|
||
djangorestframework>=3.15,<4.0
|
||
psycopg[binary]>=3.1,<4.0
|
||
gunicorn>=22.0,<24.0
|
||
```
|
||
|
||
`requirements-dev.txt`:
|
||
```
|
||
-r requirements.txt
|
||
pytest>=8.0,<9.0
|
||
pytest-django>=4.8,<5.0
|
||
```
|
||
|
||
- [ ] **Step 2: pytest 配置**
|
||
|
||
`pyproject.toml`:
|
||
```toml
|
||
[tool.pytest.ini_options]
|
||
DJANGO_SETTINGS_MODULE = "config.settings.dev"
|
||
python_files = ["tests.py", "test_*.py"]
|
||
pythonpath = ["src"]
|
||
```
|
||
|
||
- [ ] **Step 3: Django settings**
|
||
|
||
`src/config/settings/__init__.py`:
|
||
```python
|
||
from .base import * # noqa: F401,F403
|
||
```
|
||
|
||
`src/config/settings/base.py`:
|
||
```python
|
||
import os
|
||
|
||
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "dev-secret-key")
|
||
DEBUG = False
|
||
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "*").split(",")
|
||
|
||
INSTALLED_APPS = [
|
||
"django.contrib.admin",
|
||
"django.contrib.auth",
|
||
"django.contrib.contenttypes",
|
||
"django.contrib.sessions",
|
||
"django.contrib.messages",
|
||
"django.contrib.staticfiles",
|
||
"rest_framework",
|
||
"openclaw",
|
||
]
|
||
|
||
MIDDLEWARE = [
|
||
"django.middleware.security.SecurityMiddleware",
|
||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||
"django.middleware.common.CommonMiddleware",
|
||
"django.middleware.csrf.CsrfViewMiddleware",
|
||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||
"django.contrib.messages.middleware.MessageMiddleware",
|
||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||
]
|
||
|
||
ROOT_URLCONF = "config.urls"
|
||
|
||
TEMPLATES = [
|
||
{
|
||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||
"DIRS": [],
|
||
"APP_DIRS": True,
|
||
"OPTIONS": {
|
||
"context_processors": [
|
||
"django.template.context_processors.debug",
|
||
"django.template.context_processors.request",
|
||
"django.contrib.auth.context_processors.auth",
|
||
"django.contrib.messages.context_processors.messages",
|
||
],
|
||
},
|
||
},
|
||
]
|
||
|
||
DATABASES = {
|
||
"default": {
|
||
"ENGINE": "django.db.backends.postgresql",
|
||
"HOST": os.environ.get("DB_HOST", "localhost"),
|
||
"PORT": os.environ.get("DB_PORT", "5432"),
|
||
"NAME": os.environ.get("DB_NAME", "openclaw_archive"),
|
||
"USER": os.environ.get("DB_USER", "openclaw"),
|
||
"PASSWORD": os.environ.get("DB_PASSWORD", ""),
|
||
}
|
||
}
|
||
|
||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||
STATIC_URL = "static/"
|
||
```
|
||
|
||
`src/config/settings/dev.py`:
|
||
```python
|
||
from .base import * # noqa: F401,F403
|
||
|
||
DEBUG = True
|
||
DATABASES = {
|
||
"default": {
|
||
"ENGINE": "django.db.backends.sqlite3",
|
||
"NAME": BASE_DIR / "db.sqlite3", # noqa: F821
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Django 基础文件**
|
||
|
||
`src/config/urls.py`:
|
||
```python
|
||
from django.contrib import admin
|
||
from django.urls import path, include
|
||
|
||
urlpatterns = [
|
||
path("admin/", admin.site.urls),
|
||
path("api/", include("openclaw.urls")),
|
||
]
|
||
```
|
||
|
||
`src/config/wsgi.py`:
|
||
```python
|
||
import os
|
||
from django.core.wsgi import get_wsgi_application
|
||
|
||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
||
application = get_wsgi_application()
|
||
```
|
||
|
||
`src/openclaw/apps.py`:
|
||
```python
|
||
from django.apps import AppConfig
|
||
|
||
class OpenclawConfig(AppConfig):
|
||
default_auto_field = "django.db.models.BigAutoField"
|
||
name = "openclaw"
|
||
```
|
||
|
||
- [ ] **Step 5: manage.py**
|
||
|
||
`manage.py`:
|
||
```python
|
||
#!/usr/bin/env python
|
||
import os
|
||
import sys
|
||
|
||
def main():
|
||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
||
try:
|
||
from django.core.management import execute_from_command_line
|
||
except ImportError as exc:
|
||
raise ImportError(
|
||
"Couldn't import Django. "
|
||
"Are you sure it's installed and available on your PYTHONPATH "
|
||
f"environment variable is set? Did you forget to activate a virtual environment?"
|
||
) from exc
|
||
execute_from_command_line(sys.argv)
|
||
|
||
if __name__ == "__main__":
|
||
main()
|
||
```
|
||
|
||
- [ ] **Step 6: 测试基础**
|
||
|
||
`tests/conftest.py`:
|
||
```python
|
||
import pytest
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def media_storage(settings, tmpdir):
|
||
settings.MEDIA_ROOT = str(tmpdir)
|
||
```
|
||
|
||
- [ ] **Step 7: .gitignore 和 .env.example**
|
||
|
||
`.gitignore`:
|
||
```
|
||
__pycache__/
|
||
*.py[cod]
|
||
*.so
|
||
*.egg-info/
|
||
dist/
|
||
.venv/
|
||
db.sqlite3
|
||
.env
|
||
*.sqlite3
|
||
```
|
||
|
||
`.env.example`:
|
||
```
|
||
# Django
|
||
DJANGO_SECRET_KEY=CHANGE_ME
|
||
DJANGO_PORT=8000
|
||
DJANGO_ALLOWED_HOSTS=*
|
||
|
||
# Database (NAS)
|
||
DB_HOST=192.168.x.x
|
||
DB_PORT=5432
|
||
DB_NAME=openclaw_archive
|
||
DB_USER=openclaw
|
||
DB_PASSWORD=CHANGE_ME
|
||
```
|
||
|
||
- [ ] **Step 8: 安装依赖并验证 Django 启动**
|
||
|
||
```bash
|
||
pip install -r requirements-dev.txt
|
||
cd src && python -c "import django; django.setup(); print('Django OK')"
|
||
cd ..
|
||
pytest --collect-only
|
||
```
|
||
|
||
Expected: `Django OK` and test collection passes (0 tests).
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add .
|
||
git commit -m "feat: Django project skeleton with pytest setup"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 1: 数据模型
|
||
|
||
**Files:**
|
||
- Create: `src/openclaw/models.py`
|
||
- Create: `tests/test_models.py`
|
||
|
||
- [ ] **Step 1: 写模型测试**
|
||
|
||
`tests/test_models.py`:
|
||
```python
|
||
from datetime import datetime, timezone
|
||
|
||
import pytest
|
||
from openclaw.models import Session, Message, ToolCall
|
||
|
||
@pytest.mark.django_db
|
||
class TestModelFields:
|
||
def test_session_creation(self):
|
||
s = Session.objects.create(
|
||
session_id="a" * 36,
|
||
agent_name="xingyao",
|
||
source_node="macmini",
|
||
status="active",
|
||
)
|
||
assert s.session_id == "a" * 36
|
||
assert s.total_tokens == 0
|
||
assert s.message_count == 0
|
||
|
||
def test_message_creation(self):
|
||
s = Session.objects.create(
|
||
session_id="b" * 36,
|
||
agent_name="test",
|
||
source_node="ubuntu1",
|
||
status="active",
|
||
)
|
||
msg = Message.objects.create(
|
||
session=s,
|
||
message_id="msg-001",
|
||
parent_id="root",
|
||
role="assistant",
|
||
timestamp=datetime(2026, 4, 5, 10, 0, tzinfo=timezone.utc),
|
||
)
|
||
assert msg.role == "assistant"
|
||
assert msg.tokens_total == 0
|
||
|
||
def test_toolcall_creation(self):
|
||
s = Session.objects.create(
|
||
session_id="c" * 36,
|
||
agent_name="test",
|
||
source_node="ubuntu2",
|
||
status="active",
|
||
)
|
||
msg = Message.objects.create(
|
||
session=s,
|
||
message_id="msg-002",
|
||
parent_id="root",
|
||
role="assistant",
|
||
timestamp=datetime(2026, 4, 5, 10, 0, tzinfo=timezone.utc),
|
||
)
|
||
tc = ToolCall.objects.create(
|
||
session=s,
|
||
message=msg,
|
||
tool_call_id="call_0",
|
||
tool_name="exec",
|
||
)
|
||
assert tc.tool_name == "exec"
|
||
assert tc.is_error is False
|
||
```
|
||
|
||
- [ ] **Step 2: 运行测试确认失败**
|
||
|
||
```bash
|
||
pytest tests/test_models.py -v
|
||
```
|
||
|
||
Expected: FAIL — Model not found.
|
||
|
||
- [ ] **Step 3: 实现模型**
|
||
|
||
`src/openclaw/models.py`:
|
||
```python
|
||
from django.db import models
|
||
|
||
|
||
class Session(models.Model):
|
||
session_id = models.CharField(max_length=64)
|
||
agent_name = models.CharField(max_length=128)
|
||
source_node = models.CharField(max_length=64)
|
||
session_version = models.IntegerField(default=0)
|
||
model_provider = models.CharField(max_length=64, blank=True, default="")
|
||
model_id = models.CharField(max_length=128, blank=True, default="")
|
||
thinking_level = models.CharField(max_length=64, blank=True, default="")
|
||
start_time = models.DateTimeField(null=True, blank=True)
|
||
end_time = models.DateTimeField(null=True, blank=True)
|
||
cwd = models.CharField(max_length=512, blank=True, default="")
|
||
total_tokens = models.IntegerField(default=0)
|
||
total_cost = models.FloatField(default=0.0)
|
||
message_count = models.IntegerField(default=0)
|
||
tool_call_count = models.IntegerField(default=0)
|
||
error_count = models.IntegerField(default=0)
|
||
raw_file_path = models.TextField(blank=True, default="")
|
||
pushed_at = models.DateTimeField(null=True, blank=True)
|
||
status = models.CharField(max_length=16, default="active")
|
||
metadata = models.JSONField(default=dict, blank=True)
|
||
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
updated_at = models.DateTimeField(auto_now=True)
|
||
|
||
class Meta:
|
||
db_table = "sessions"
|
||
unique_together = ("session_id", "agent_name")
|
||
ordering = ["-start_time"]
|
||
|
||
def __str__(self):
|
||
return f"Session({self.session_id} {self.agent_name})"
|
||
|
||
|
||
class Message(models.Model):
|
||
session = models.ForeignKey(
|
||
Session, on_delete=models.CASCADE, related_name="messages"
|
||
)
|
||
message_id = models.CharField(max_length=128)
|
||
parent_id = models.CharField(max_length=128, blank=True, default="")
|
||
seq = models.IntegerField(default=0)
|
||
role = models.CharField(max_length=32)
|
||
content_text = models.TextField(blank=True, default="")
|
||
raw_content = models.JSONField(default=list, blank=True)
|
||
raw_message = models.JSONField(default=dict, blank=True)
|
||
timestamp = models.DateTimeField()
|
||
# assistant 专用
|
||
model = models.CharField(max_length=128, blank=True, default="")
|
||
provider = models.CharField(max_length=64, blank=True, default="")
|
||
stop_reason = models.CharField(max_length=64, blank=True, default="")
|
||
tokens_input = models.IntegerField(default=0)
|
||
tokens_output = models.IntegerField(default=0)
|
||
tokens_cache_read = models.IntegerField(default=0)
|
||
tokens_cache_write = models.IntegerField(default=0)
|
||
tokens_total = models.IntegerField(default=0)
|
||
cost_total = models.FloatField(default=0.0)
|
||
# toolResult 专用
|
||
tool_call_id = models.CharField(max_length=128, blank=True, default="")
|
||
tool_name = models.CharField(max_length=128, blank=True, default="")
|
||
is_error = models.BooleanField(default=False)
|
||
exit_code = models.IntegerField(null=True, blank=True)
|
||
duration_ms = models.IntegerField(null=True, blank=True)
|
||
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
db_table = "messages"
|
||
ordering = ["seq"]
|
||
|
||
def __str__(self):
|
||
return f"Message({self.message_id} {self.role})"
|
||
|
||
|
||
class ToolCall(models.Model):
|
||
session = models.ForeignKey(
|
||
Session, on_delete=models.CASCADE, related_name="tool_calls"
|
||
)
|
||
message = models.ForeignKey(
|
||
Message, on_delete=models.CASCADE, related_name="tool_calls"
|
||
)
|
||
tool_call_id = models.CharField(max_length=128)
|
||
tool_name = models.CharField(max_length=128)
|
||
arguments = models.JSONField(default=dict, blank=True)
|
||
result_text = models.TextField(blank=True, default="")
|
||
is_error = models.BooleanField(default=False)
|
||
exit_code = models.IntegerField(null=True, blank=True)
|
||
duration_ms = models.IntegerField(null=True, blank=True)
|
||
seq = models.IntegerField(default=0)
|
||
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
db_table = "tool_calls"
|
||
ordering = ["seq"]
|
||
|
||
def __str__(self):
|
||
return f"ToolCall({self.tool_name} {self.tool_call_id})"
|
||
```
|
||
|
||
- [ ] **Step 4: make migrations + migrate**
|
||
|
||
```bash
|
||
cd src && python manage.py makemigrations openclaw
|
||
python manage.py migrate
|
||
cd ..
|
||
```
|
||
|
||
- [ ] **Step 5: 重新运行测试确认通过**
|
||
|
||
```bash
|
||
pytest tests/test_models.py -v
|
||
```
|
||
|
||
Expected: 3 PASS.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/openclaw/models.py tests/test_models.py src/openclaw/migrations/ pyproject.toml
|
||
git commit -m "feat: add Session, Message, ToolCall models"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: 批量写入 API
|
||
|
||
**Files:**
|
||
- Create: `src/openclaw/urls.py`
|
||
- Create: `src/openclaw/views.py`
|
||
- Create: `src/openclaw/services.py`
|
||
- Create: `tests/test_api.py`
|
||
- Create: `tests/test_services.py`
|
||
|
||
- [ ] **Step 1: 写服务层测试(BulkUpsertService)**
|
||
|
||
`tests/test_services.py`:
|
||
```python
|
||
from datetime import datetime, timezone
|
||
|
||
import pytest
|
||
from openclaw.models import Session, Message, ToolCall
|
||
from openclaw.services import BulkUpsertService
|
||
|
||
|
||
@pytest.mark.django_db
|
||
class TestBulkUpsertService:
|
||
def _new_session_payload(self):
|
||
return {
|
||
"agent_name": "test-agent",
|
||
"source_node": "macmini",
|
||
"sessions": [
|
||
{
|
||
"session_id": "sess-001",
|
||
"session_version": 1,
|
||
"model_provider": "anthropic",
|
||
"model_id": "claude-sonnet-4-6",
|
||
"thinking_level": "high",
|
||
"start_time": "2026-04-05T10:00:00Z",
|
||
"end_time": "2026-04-05T10:30:00Z",
|
||
"cwd": "/tmp/test",
|
||
"total_tokens": 5000,
|
||
"total_cost": 0.12,
|
||
"message_count": 10,
|
||
"tool_call_count": 3,
|
||
"error_count": 0,
|
||
"raw_file_path": "/path/to/sessions/sess-001.jsonl",
|
||
"status": "active",
|
||
"metadata": {},
|
||
}
|
||
],
|
||
"messages": [
|
||
{
|
||
"session_id": "sess-001",
|
||
"message_id": "msg-001",
|
||
"parent_id": "root",
|
||
"seq": 0,
|
||
"role": "user",
|
||
"content_text": "Hello",
|
||
"raw_content": [{"type": "text", "text": "Hello"}],
|
||
"raw_message": {},
|
||
"timestamp": "2026-04-05T10:05:00Z",
|
||
}
|
||
],
|
||
"tool_calls": [],
|
||
}
|
||
|
||
def test_upsert_new_session(self):
|
||
result = BulkUpsertService.upsert(self._new_session_payload())
|
||
assert result["sessions_upserted"] == 1
|
||
assert Session.objects.get(session_id="sess-001")
|
||
|
||
def test_upsert_idempotent(self):
|
||
BulkUpsertService.upsert(self._new_session_payload())
|
||
result = BulkUpsertService.upsert(self._new_session_payload())
|
||
# second push should not create duplicate
|
||
assert Session.objects.filter(session_id="sess-001").count() == 1
|
||
assert result["sessions_upserted"] == 0 # skipped
|
||
|
||
def test_upsert_with_messages_and_toolcalls(self):
|
||
payload = self._new_session_payload()
|
||
payload["messages"].append(
|
||
{
|
||
"session_id": "sess-001",
|
||
"message_id": "msg-002",
|
||
"parent_id": "msg-001",
|
||
"seq": 1,
|
||
"role": "assistant",
|
||
"content_text": "Hi there",
|
||
"raw_content": [{"type": "text", "text": "Hi there"}],
|
||
"raw_message": {},
|
||
"timestamp": "2026-04-05T10:06:00Z",
|
||
"model": "claude-sonnet-4-6",
|
||
"provider": "anthropic",
|
||
"stop_reason": "end_turn",
|
||
"tokens_input": 100,
|
||
"tokens_output": 50,
|
||
"tokens_total": 150,
|
||
"cost_total": 0.01,
|
||
}
|
||
)
|
||
payload["tool_calls"].append(
|
||
{
|
||
"session_id": "sess-001",
|
||
"message_id": "msg-002",
|
||
"tool_call_id": "call_0",
|
||
"tool_name": "exec",
|
||
"arguments": {"command": "ls"},
|
||
"result_text": "file.txt",
|
||
"is_error": False,
|
||
"exit_code": 0,
|
||
"duration_ms": 200,
|
||
"seq": 0,
|
||
}
|
||
)
|
||
result = BulkUpsertService.upsert(payload)
|
||
assert result["messages_upserted"] == 2
|
||
assert result["tool_calls_upserted"] == 1
|
||
assert ToolCall.objects.get(tool_call_id="call_0").tool_name == "exec"
|
||
```
|
||
|
||
- [ ] **Step 2: 运行测试确认失败**
|
||
|
||
```bash
|
||
pytest tests/test_services.py -v
|
||
```
|
||
|
||
Expected: FAIL — module not found.
|
||
|
||
- [ ] **Step 3: 实现 BulkUpsertService**
|
||
|
||
`src/openclaw/services.py`:
|
||
```python
|
||
from datetime import datetime, timezone
|
||
|
||
from django.db import transaction
|
||
|
||
from openclaw.models import Session, Message, ToolCall
|
||
|
||
|
||
def _parse_ts(value):
|
||
if not value:
|
||
return None
|
||
if isinstance(value, str):
|
||
# Handle ISO 8601 Z suffix
|
||
value = value.replace("Z", "+00:00")
|
||
return datetime.fromisoformat(value)
|
||
return value
|
||
|
||
|
||
class BulkUpsertService:
|
||
@staticmethod
|
||
@transaction.atomic
|
||
def upsert(payload):
|
||
agent_name = payload["agent_name"]
|
||
source_node = payload["source_node"]
|
||
sessions_data = payload.get("sessions", [])
|
||
messages_data = payload.get("messages", [])
|
||
tool_calls_data = payload.get("tool_calls", [])
|
||
|
||
sessions_upserted = 0
|
||
messages_upserted = 0
|
||
tool_calls_upserted = 0
|
||
|
||
for sess in sessions_data:
|
||
session_id = sess["session_id"]
|
||
defaults = {
|
||
"source_node": source_node,
|
||
"session_version": sess.get("session_version", 0),
|
||
"model_provider": sess.get("model_provider", ""),
|
||
"model_id": sess.get("model_id", ""),
|
||
"thinking_level": sess.get("thinking_level", ""),
|
||
"start_time": _parse_ts(sess.get("start_time")),
|
||
"end_time": _parse_ts(sess.get("end_time")),
|
||
"cwd": sess.get("cwd", ""),
|
||
"total_tokens": sess.get("total_tokens", 0),
|
||
"total_cost": sess.get("total_cost", 0.0),
|
||
"message_count": sess.get("message_count", 0),
|
||
"tool_call_count": sess.get("tool_call_count", 0),
|
||
"error_count": sess.get("error_count", 0),
|
||
"raw_file_path": sess.get("raw_file_path", ""),
|
||
"pushed_at": datetime.now(timezone.utc),
|
||
"status": sess.get("status", "active"),
|
||
"metadata": sess.get("metadata", {}),
|
||
}
|
||
_, created = Session.objects.update_or_create(
|
||
session_id=session_id,
|
||
agent_name=agent_name,
|
||
defaults=defaults,
|
||
)
|
||
if created:
|
||
sessions_upserted += 1
|
||
|
||
# Build session lookup: session_id -> Session instance
|
||
session_ids = {s["session_id"] for s in sessions_data}
|
||
session_lookup = {
|
||
s.session_id: s
|
||
for s in Session.objects.filter(
|
||
session_id__in=session_ids, agent_name=agent_name
|
||
)
|
||
}
|
||
|
||
# Upsert messages
|
||
for msg in messages_data:
|
||
session = session_lookup.get(msg["session_id"])
|
||
if not session:
|
||
continue
|
||
defaults = {
|
||
"parent_id": msg.get("parent_id", ""),
|
||
"seq": msg.get("seq", 0),
|
||
"role": msg.get("role", ""),
|
||
"content_text": msg.get("content_text", ""),
|
||
"raw_content": msg.get("raw_content", []),
|
||
"raw_message": msg.get("raw_message", {}),
|
||
"timestamp": _parse_ts(msg.get("timestamp")),
|
||
"model": msg.get("model", ""),
|
||
"provider": msg.get("provider", ""),
|
||
"stop_reason": msg.get("stop_reason", ""),
|
||
"tokens_input": msg.get("tokens_input", 0),
|
||
"tokens_output": msg.get("tokens_output", 0),
|
||
"tokens_cache_read": msg.get("tokens_cache_read", 0),
|
||
"tokens_cache_write": msg.get("tokens_cache_write", 0),
|
||
"tokens_total": msg.get("tokens_total", 0),
|
||
"cost_total": msg.get("cost_total", 0.0),
|
||
"tool_call_id": msg.get("tool_call_id", ""),
|
||
"tool_name": msg.get("tool_name", ""),
|
||
"is_error": msg.get("is_error", False),
|
||
"exit_code": msg.get("exit_code"),
|
||
"duration_ms": msg.get("duration_ms"),
|
||
}
|
||
Message.objects.update_or_create(
|
||
session=session,
|
||
message_id=msg["message_id"],
|
||
defaults=defaults,
|
||
)
|
||
messages_upserted += 1
|
||
|
||
# Build message lookup: message_id -> Message instance
|
||
msg_lookup = {
|
||
m.message_id: m
|
||
for m in Message.objects.filter(session__in=session_lookup.values())
|
||
}
|
||
|
||
# Upsert tool_calls
|
||
for tc in tool_calls_data:
|
||
session = session_lookup.get(tc["session_id"])
|
||
message = msg_lookup.get(tc["message_id"])
|
||
if not session or not message:
|
||
continue
|
||
ToolCall.objects.update_or_create(
|
||
session=session,
|
||
message=message,
|
||
tool_call_id=tc["tool_call_id"],
|
||
defaults={
|
||
"tool_name": tc.get("tool_name", ""),
|
||
"arguments": tc.get("arguments", {}),
|
||
"result_text": tc.get("result_text", ""),
|
||
"is_error": tc.get("is_error", False),
|
||
"exit_code": tc.get("exit_code"),
|
||
"duration_ms": tc.get("duration_ms"),
|
||
"seq": tc.get("seq", 0),
|
||
},
|
||
)
|
||
tool_calls_upserted += 1
|
||
|
||
return {
|
||
"sessions_upserted": sessions_upserted,
|
||
"messages_upserted": messages_upserted,
|
||
"tool_calls_upserted": tool_calls_upserted,
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 运行服务层测试**
|
||
|
||
```bash
|
||
pytest tests/test_services.py -v
|
||
```
|
||
|
||
Expected: 3 PASS.
|
||
|
||
- [ ] **Step 5: 写 API 测试**
|
||
|
||
`tests/test_api.py`:
|
||
```python
|
||
import pytest
|
||
|
||
from openclaw.models import Session
|
||
|
||
BULK_URL = "/api/sessions/bulk_upsert/"
|
||
|
||
|
||
def _minimal_payload():
|
||
return {
|
||
"agent_name": "test",
|
||
"source_node": "macmini",
|
||
"sessions": [
|
||
{
|
||
"session_id": "test-session",
|
||
"model_provider": "test",
|
||
"model_id": "test-model",
|
||
}
|
||
],
|
||
"messages": [],
|
||
"tool_calls": [],
|
||
}
|
||
|
||
|
||
@pytest.mark.django_db
|
||
class TestBulkUpsertAPI:
|
||
def test_bulk_upsert_ok(self, client):
|
||
resp = client.post(BULK_URL, _minimal_payload(), content_type="application/json")
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["status"] == "ok"
|
||
assert data["sessions_upserted"] == 1
|
||
|
||
def test_bulk_upsert_idempotent(self, client):
|
||
client.post(BULK_URL, _minimal_payload(), content_type="application/json")
|
||
resp = client.post(BULK_URL, _minimal_payload(), content_type="application/json")
|
||
data = resp.json()
|
||
assert data["sessions_upserted"] == 0
|
||
|
||
def test_bulk_upsert_missing_fields_returns_400(self, client):
|
||
resp = client.post(BULK_URL, {}, content_type="application/json")
|
||
assert resp.status_code == 400
|
||
```
|
||
|
||
- [ ] **Step 6: 运行 API 测试确认失败**
|
||
|
||
```bash
|
||
pytest tests/test_api.py -v
|
||
```
|
||
|
||
Expected: FAIL — URL not found.
|
||
|
||
- [ ] **Step 7: 实现 API view + 路由**
|
||
|
||
`src/openclaw/urls.py`:
|
||
```python
|
||
from django.urls import path
|
||
from openclaw.views import sessions_bulk_upsert
|
||
|
||
urlpatterns = [
|
||
path("sessions/bulk_upsert/", sessions_bulk_upsert, name="sessions_bulk_upsert"),
|
||
]
|
||
```
|
||
|
||
`src/openclaw/views.py`:
|
||
```python
|
||
import json
|
||
|
||
from django.http import JsonResponse
|
||
from django.views.decorators.csrf import csrf_exempt
|
||
from django.views.decorators.http import require_http_methods
|
||
|
||
from openclaw.services import BulkUpsertService
|
||
|
||
|
||
@csrf_exempt
|
||
@require_http_methods(["POST"])
|
||
def sessions_bulk_upsert(request):
|
||
try:
|
||
payload = json.loads(request.body)
|
||
except json.JSONDecodeError:
|
||
return JsonResponse({"error": "Invalid JSON"}, status=400)
|
||
|
||
if "agent_name" not in payload or "source_node" not in payload:
|
||
return JsonResponse(
|
||
{"error": "Missing agent_name or source_node"}, status=400
|
||
)
|
||
|
||
if "sessions" not in payload:
|
||
return JsonResponse({"error": "Missing sessions"}, status=400)
|
||
|
||
result = BulkUpsertService.upsert(payload)
|
||
return JsonResponse({"status": "ok", **result})
|
||
```
|
||
|
||
- [ ] **Step 8: 运行 API 测试确认通过**
|
||
|
||
```bash
|
||
pytest tests/test_api.py -v
|
||
```
|
||
|
||
Expected: 3 PASS.
|
||
|
||
- [ ] **Step 9: 运行所有测试**
|
||
|
||
```bash
|
||
pytest -v
|
||
```
|
||
|
||
Expected: ALL PASS (3 models + 3 services + 3 api = 9).
|
||
|
||
- [ ] **Step 10: Commit**
|
||
|
||
```bash
|
||
git add src/openclaw/services.py src/openclaw/views.py src/openclaw/urls.py tests/test_services.py tests/test_api.py
|
||
git commit -m "feat: bulk upsert API with idempotent writes"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Django Admin 基础配置
|
||
|
||
**Files:**
|
||
- Create: `src/openclaw/admin.py`
|
||
|
||
- [ ] **Step 1: Admin 配置**
|
||
|
||
`src/openclaw/admin.py`:
|
||
```python
|
||
from django.contrib import admin
|
||
from openclaw.models import Session, Message, ToolCall
|
||
|
||
|
||
class MessageInline(admin.TabularInline):
|
||
model = Message
|
||
extra = 0
|
||
fields = ("seq", "role", "content_text", "timestamp")
|
||
readonly_fields = ("seq", "role", "content_text", "timestamp")
|
||
|
||
def has_add_permission(self, request, obj=None):
|
||
return False
|
||
|
||
def has_delete_permission(self, request, obj=None):
|
||
return False
|
||
|
||
|
||
class ToolCallInline(admin.TabularInline):
|
||
model = ToolCall
|
||
extra = 0
|
||
fields = ("seq", "tool_name", "tool_call_id", "is_error", "duration_ms")
|
||
readonly_fields = ("seq", "tool_name", "tool_call_id", "is_error", "duration_ms")
|
||
|
||
def has_add_permission(self, request, obj=None):
|
||
return False
|
||
|
||
def has_delete_permission(self, request, obj=None):
|
||
return False
|
||
|
||
|
||
@admin.register(Session)
|
||
class SessionAdmin(admin.ModelAdmin):
|
||
list_display = (
|
||
"session_id",
|
||
"agent_name",
|
||
"model_id",
|
||
"total_tokens",
|
||
"message_count",
|
||
"start_time",
|
||
)
|
||
list_filter = ("agent_name", "source_node", "model_id", "start_time")
|
||
search_fields = ("session_id", "cwd")
|
||
ordering = ("-start_time",)
|
||
inlines = [MessageInline, ToolCallInline]
|
||
readonly_fields = (
|
||
"session_id",
|
||
"agent_name",
|
||
"source_node",
|
||
"start_time",
|
||
"end_time",
|
||
"pushed_at",
|
||
)
|
||
|
||
|
||
@admin.register(Message)
|
||
class MessageAdmin(admin.ModelAdmin):
|
||
list_display = ("message_id", "session", "role", "timestamp", "tokens_total")
|
||
list_filter = ("role", "model", "timestamp")
|
||
search_fields = ("content_text",)
|
||
ordering = ("-timestamp",)
|
||
|
||
|
||
@admin.register(ToolCall)
|
||
class ToolCallAdmin(admin.ModelAdmin):
|
||
list_display = ("tool_name", "tool_call_id", "session", "is_error", "duration_ms")
|
||
list_filter = ("tool_name", "is_error", "exit_code")
|
||
ordering = ("-created_at",)
|
||
```
|
||
|
||
- [ ] **Step 2: 手动验证 Admin**
|
||
|
||
```bash
|
||
cd src && python manage.py migrate && python manage.py createsuperuser
|
||
cd src && python manage.py runserver
|
||
```
|
||
|
||
访问 `http://localhost:8000/admin/` 确认三张表在 Admin 中可见。
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/openclaw/admin.py
|
||
git commit -m "feat: Django Admin for Session, Message, ToolCall with inlines"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Admin 自定义视图 — 按日期查询对话
|
||
|
||
**Files:**
|
||
- Modify: `src/openclaw/admin.py`
|
||
- Create: `src/openclaw/templates/admin/openclaw/daily_view.html`
|
||
- Modify: `src/openclaw/urls.py`(admin 路由由 admin.site 管理,不需要改)
|
||
|
||
- [ ] **Step 1: 模板**
|
||
|
||
`src/openclaw/templates/admin/openclaw/daily_view.html`:
|
||
```html
|
||
{% extends "admin/base_site.html" %}
|
||
{% block content %}
|
||
<h1>Daily Conversation View</h1>
|
||
|
||
<form method="get">
|
||
<label>Start date: <input type="date" name="start" value="{{ start_date }}" /></label>
|
||
<label>End date: <input type="date" name="end" value="{{ end_date }}" /></label>
|
||
<label>Agent:
|
||
<select name="agent">
|
||
<option value="">All</option>
|
||
{% for agent in agents %}
|
||
<option value="{{ agent }}" {% if agent == selected_agent %}selected{% endif %}>{{ agent }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</label>
|
||
<button type="submit">Search</button>
|
||
</form>
|
||
|
||
{% if sessions %}
|
||
{% for session in sessions %}
|
||
<div class="session-block" style="margin-top: 2em; border: 1px solid #ddd; padding: 1em;">
|
||
<h2>Session: {{ session.session_id }} ({{ session.agent_name }})</h2>
|
||
<p>Model: {{ session.model_id }} | Tokens: {{ session.total_tokens }} |
|
||
Start: {{ session.start_time|default:"N/A" }}</p>
|
||
|
||
{% for msg in session.messages %}
|
||
<div class="message" data-role="{{ msg.role }}" style="padding: 0.5em; margin: 0.3em 0; border-left: 3px solid {% if msg.role == 'user' %}#4CAF50{% elif msg.role == 'assistant' %}#2196F3{% else %}#FF9800{% endif %};">
|
||
<strong>{{ msg.timestamp|date:"H:i" }} {{ msg.get_role_label }}</strong>
|
||
{% if msg.role == 'toolResult' %}
|
||
{% if msg.tool_name %}<em>[Tool: {{ msg.tool_name }}]</em>{% endif %}
|
||
{% endif %}
|
||
<details>
|
||
<summary>Content</summary>
|
||
<pre style="white-space: pre-wrap; word-break: break-word;">{{ msg.content_text|default:"(empty)" }}</pre>
|
||
</details>
|
||
</div>
|
||
{% empty %}
|
||
<p>No messages.</p>
|
||
{% endfor %}
|
||
</div>
|
||
{% endfor %}
|
||
{% endif %}
|
||
{% endblock %}
|
||
```
|
||
|
||
- [ ] **Step 2: Admin 视图**
|
||
|
||
添加到 `src/openclaw/admin.py` 末尾:
|
||
|
||
```python
|
||
from datetime import date
|
||
|
||
from django.shortcuts import render
|
||
from django.db.models import Prefetch
|
||
|
||
|
||
class DailyConversationView(admin.ModelAdmin):
|
||
"""Admin custom view for date-range conversation browsing."""
|
||
def get_urls(self):
|
||
from django.urls import path
|
||
urls = super().get_urls()
|
||
custom_urls = [
|
||
path("daily/", self.admin_site.admin_view(self.daily_view), name="openclaw_daily"),
|
||
]
|
||
return custom_urls + urls
|
||
|
||
def daily_view(self, request):
|
||
start_str = request.GET.get("start")
|
||
end_str = request.GET.get("end")
|
||
agent_filter = request.GET.get("agent", "")
|
||
|
||
start_date = start_str if start_str else date.today().isoformat()
|
||
end_date = end_str if end_str else date.today().isoformat()
|
||
|
||
agents = list(
|
||
Session.objects.values_list("agent_name", flat=True)
|
||
.distinct()
|
||
.order_by("agent_name")
|
||
)
|
||
|
||
sessions_qs = Session.objects.filter(
|
||
start_time__date__gte=start_date,
|
||
start_time__date__lte=end_date,
|
||
).order_by("start_time")
|
||
|
||
if agent_filter:
|
||
sessions_qs = sessions_qs.filter(agent_name=agent_filter)
|
||
|
||
messages_prefetch = Prefetch(
|
||
"messages",
|
||
queryset=Message.objects.order_by("seq"),
|
||
)
|
||
sessions_qs = sessions_qs.prefetch_related(messages_prefetch)
|
||
|
||
session_list = []
|
||
for session in sessions_qs:
|
||
messages = []
|
||
for msg in session.messages.all():
|
||
messages.append({
|
||
"timestamp": msg.timestamp,
|
||
"role": msg.role,
|
||
"content_text": msg.content_text,
|
||
"tool_name": msg.tool_name,
|
||
"get_role_label": self._role_label(msg.role),
|
||
})
|
||
session_list.append({
|
||
"session_id": session.session_id,
|
||
"agent_name": session.agent_name,
|
||
"model_id": session.model_id,
|
||
"total_tokens": session.total_tokens,
|
||
"start_time": session.start_time,
|
||
"messages": messages,
|
||
})
|
||
|
||
context = dict(
|
||
self.admin_site.each_context(request),
|
||
start_date=start_date,
|
||
end_date=end_date,
|
||
selected_agent=agent_filter,
|
||
agents=agents,
|
||
sessions=session_list,
|
||
title="Daily Conversation View",
|
||
)
|
||
return render(request, "admin/openclaw/daily_view.html", context)
|
||
|
||
@staticmethod
|
||
def _role_label(role):
|
||
labels = {
|
||
"user": "User",
|
||
"assistant": "Assistant",
|
||
"toolResult": "Tool Result",
|
||
}
|
||
return labels.get(role, role)
|
||
|
||
|
||
# Register Daily view via SessionAdmin:
|
||
# In SessionAdmin inheritance, add daily_view url.
|
||
# Actually, the cleanest way: create a standalone admin view.
|
||
```
|
||
|
||
等等 — `DailyConversationView` 继承自 `ModelAdmin` 但并不需要绑定到一个 model。正确做法是用一个独立 view 函数注册到 admin。让我重写:
|
||
|
||
```python
|
||
# Add this function to src/openclaw/admin.py
|
||
|
||
from django.template.response import TemplateResponse
|
||
|
||
def daily_conversation_view(request):
|
||
"""Admin standalone view for date-range conversation browsing."""
|
||
from datetime import date
|
||
from django.db.models import Prefetch
|
||
from openclaw.models import Session, Message
|
||
|
||
start_str = request.GET.get("start")
|
||
end_str = request.GET.get("end")
|
||
agent_filter = request.GET.get("agent", "")
|
||
|
||
start_date = start_str if start_str else date.today().isoformat()
|
||
end_date = end_str if end_str else date.today().isoformat()
|
||
|
||
agents = list(
|
||
Session.objects.values_list("agent_name", flat=True)
|
||
.distinct()
|
||
.order_by("agent_name")
|
||
)
|
||
|
||
sessions_qs = Session.objects.filter(
|
||
start_time__date__gte=start_date,
|
||
start_time__date__lte=end_date,
|
||
).order_by("start_time")
|
||
|
||
if agent_filter:
|
||
sessions_qs = sessions_qs.filter(agent_name=agent_filter)
|
||
|
||
messages_prefetch = Prefetch(
|
||
"messages",
|
||
queryset=Message.objects.order_by("seq"),
|
||
)
|
||
sessions_qs = sessions_qs.prefetch_related(messages_prefetch)
|
||
|
||
role_labels = {
|
||
"user": "User",
|
||
"assistant": "Assistant",
|
||
"toolResult": "Tool Result",
|
||
}
|
||
|
||
session_list = []
|
||
for session in sessions_qs:
|
||
messages = []
|
||
for msg in session.messages.all():
|
||
messages.append({
|
||
"timestamp": msg.timestamp,
|
||
"role": msg.role,
|
||
"content_text": msg.content_text,
|
||
"tool_name": msg.tool_name,
|
||
"get_role_label": role_labels.get(msg.role, msg.role),
|
||
})
|
||
session_list.append({
|
||
"session_id": session.session_id,
|
||
"agent_name": session.agent_name,
|
||
"model_id": session.model_id,
|
||
"total_tokens": session.total_tokens,
|
||
"start_time": session.start_time,
|
||
"messages": messages,
|
||
})
|
||
|
||
context = {
|
||
**admin.site.each_context(request),
|
||
"start_date": start_date,
|
||
"end_date": end_date,
|
||
"selected_agent": agent_filter,
|
||
"agents": agents,
|
||
"sessions": session_list,
|
||
"title": "Daily Conversation View",
|
||
}
|
||
return TemplateResponse(request, "admin/openclaw/daily_view.html", context)
|
||
|
||
|
||
# Add URL in SessionAdmin or as standalone:
|
||
# Register the URL via custom admin urlpattern.
|
||
class CustomAdminSite(admin.AdminSite):
|
||
def get_urls(self):
|
||
from django.urls import path
|
||
urls = super().get_urls()
|
||
urls += [
|
||
path("openclaw/daily/", daily_conversation_view, name="openclaw_daily"),
|
||
]
|
||
return urls
|
||
|
||
|
||
# Then swap admin.site in config/urls.py and manage.py
|
||
```
|
||
|
||
Actually, this is getting complicated with swapping the admin site. The simpler approach is to add the URL through an existing ModelAdmin's `get_urls`. Let me rewrite this section cleanly:
|
||
|
||
添加到 `src/openclaw/admin.py`(接在 ToolCallAdmin 后面):
|
||
|
||
```python
|
||
from django.template.response import TemplateResponse
|
||
from datetime import date
|
||
from django.db.models import Prefetch
|
||
|
||
|
||
def daily_conversation_view(self, request):
|
||
"""Admin standalone view for date-range conversation browsing."""
|
||
start_str = request.GET.get("start")
|
||
end_str = request.GET.get("end")
|
||
agent_filter = request.GET.get("agent", "")
|
||
|
||
start_date = start_str if start_str else date.today().isoformat()
|
||
end_date = end_str if end_str else date.today().isoformat()
|
||
|
||
agents = list(
|
||
Session.objects.values_list("agent_name", flat=True)
|
||
.distinct()
|
||
.order_by("agent_name")
|
||
)
|
||
|
||
sessions_qs = Session.objects.filter(
|
||
start_time__date__gte=start_date,
|
||
start_time__date__lte=end_date,
|
||
).order_by("start_time")
|
||
|
||
if agent_filter:
|
||
sessions_qs = sessions_qs.filter(agent_name=agent_filter)
|
||
|
||
messages_prefetch = Prefetch(
|
||
"messages",
|
||
queryset=Message.objects.order_by("seq"),
|
||
)
|
||
sessions_qs = sessions_qs.prefetch_related(messages_prefetch)
|
||
|
||
role_labels = {
|
||
"user": "User",
|
||
"assistant": "Assistant",
|
||
"toolResult": "Tool Result",
|
||
}
|
||
|
||
session_list = []
|
||
for session in sessions_qs:
|
||
messages = []
|
||
for msg in session.messages.all():
|
||
messages.append({
|
||
"timestamp": msg.timestamp,
|
||
"role": msg.role,
|
||
"content_text": msg.content_text,
|
||
"tool_name": msg.tool_name,
|
||
"get_role_label": role_labels.get(msg.role, msg.role),
|
||
})
|
||
session_list.append({
|
||
"session_id": session.session_id,
|
||
"agent_name": session.agent_name,
|
||
"model_id": session.model_id,
|
||
"total_tokens": session.total_tokens,
|
||
"start_time": session.start_time,
|
||
"messages": messages,
|
||
})
|
||
|
||
context = {
|
||
**admin.site.each_context(request),
|
||
"start_date": start_date,
|
||
"end_date": end_date,
|
||
"selected_agent": agent_filter,
|
||
"agents": agents,
|
||
"sessions": session_list,
|
||
"title": "Daily Conversation View",
|
||
}
|
||
return TemplateResponse(request, "admin/openclaw/daily_view.html", context)
|
||
|
||
|
||
# Add URL through SessionAdmin get_urls
|
||
class SessionAdmin(admin.ModelAdmin):
|
||
# ... (as defined above)
|
||
...
|
||
|
||
def get_urls(self):
|
||
from django.urls import path
|
||
urls = super().get_urls()
|
||
custom_urls = [
|
||
path("daily/", admin.site.admin_view(daily_conversation_view), name="openclaw_daily"),
|
||
]
|
||
return custom_urls + urls
|
||
```
|
||
|
||
好的,为了避免重复定义 SessionAdmin,最终 `src/openclaw/admin.py` 完整代码如下:
|
||
|
||
```python
|
||
from django.contrib import admin
|
||
from django.template.response import TemplateResponse
|
||
from datetime import date
|
||
from django.db.models import Prefetch
|
||
from openclaw.models import Session, Message, ToolCall
|
||
|
||
|
||
class MessageInline(admin.TabularInline):
|
||
model = Message
|
||
extra = 0
|
||
fields = ("seq", "role", "content_text", "timestamp")
|
||
readonly_fields = ("seq", "role", "content_text", "timestamp")
|
||
|
||
def has_add_permission(self, request, obj=None):
|
||
return False
|
||
|
||
def has_delete_permission(self, request, obj=None):
|
||
return False
|
||
|
||
|
||
class ToolCallInline(admin.TabularInline):
|
||
model = ToolCall
|
||
extra = 0
|
||
fields = ("seq", "tool_name", "tool_call_id", "is_error", "duration_ms")
|
||
readonly_fields = ("seq", "tool_name", "tool_call_id", "is_error", "duration_ms")
|
||
|
||
def has_add_permission(self, request, obj=None):
|
||
return False
|
||
|
||
def has_delete_permission(self, request, obj=None):
|
||
return False
|
||
|
||
|
||
def daily_conversation_view(request):
|
||
"""Admin standalone view for date-range conversation browsing."""
|
||
start_str = request.GET.get("start")
|
||
end_str = request.GET.get("end")
|
||
agent_filter = request.GET.get("agent", "")
|
||
|
||
start_date = start_str if start_str else date.today().isoformat()
|
||
end_date = end_str if end_str else date.today().isoformat()
|
||
|
||
agents = list(
|
||
Session.objects.values_list("agent_name", flat=True)
|
||
.distinct()
|
||
.order_by("agent_name")
|
||
)
|
||
|
||
sessions_qs = Session.objects.filter(
|
||
start_time__date__gte=start_date,
|
||
start_time__date__lte=end_date,
|
||
).order_by("start_time")
|
||
|
||
if agent_filter:
|
||
sessions_qs = sessions_qs.filter(agent_name=agent_filter)
|
||
|
||
messages_prefetch = Prefetch(
|
||
"messages",
|
||
queryset=Message.objects.order_by("seq"),
|
||
)
|
||
sessions_qs = sessions_qs.prefetch_related(messages_prefetch)
|
||
|
||
role_labels = {
|
||
"user": "User",
|
||
"assistant": "Assistant",
|
||
"toolResult": "Tool Result",
|
||
}
|
||
|
||
session_list = []
|
||
for session in sessions_qs:
|
||
messages = []
|
||
for msg in session.messages.all():
|
||
messages.append({
|
||
"timestamp": msg.timestamp,
|
||
"role": msg.role,
|
||
"content_text": msg.content_text,
|
||
"tool_name": msg.tool_name,
|
||
"get_role_label": role_labels.get(msg.role, msg.role),
|
||
})
|
||
session_list.append({
|
||
"session_id": session.session_id,
|
||
"agent_name": session.agent_name,
|
||
"model_id": session.model_id,
|
||
"total_tokens": session.total_tokens,
|
||
"start_time": session.start_time,
|
||
"messages": messages,
|
||
})
|
||
|
||
context = {
|
||
**admin.site.each_context(request),
|
||
"start_date": start_date,
|
||
"end_date": end_date,
|
||
"selected_agent": agent_filter,
|
||
"agents": agents,
|
||
"sessions": session_list,
|
||
"title": "Daily Conversation View",
|
||
}
|
||
return TemplateResponse(request, "admin/openclaw/daily_view.html", context)
|
||
|
||
|
||
@admin.register(Session)
|
||
class SessionAdmin(admin.ModelAdmin):
|
||
list_display = (
|
||
"session_id",
|
||
"agent_name",
|
||
"model_id",
|
||
"total_tokens",
|
||
"message_count",
|
||
"start_time",
|
||
)
|
||
list_filter = ("agent_name", "source_node", "model_id", "start_time")
|
||
search_fields = ("session_id", "cwd")
|
||
ordering = ("-start_time",)
|
||
inlines = [MessageInline, ToolCallInline]
|
||
readonly_fields = (
|
||
"session_id",
|
||
"agent_name",
|
||
"source_node",
|
||
"start_time",
|
||
"end_time",
|
||
"pushed_at",
|
||
)
|
||
|
||
def get_urls(self):
|
||
from django.urls import path
|
||
urls = super().get_urls()
|
||
custom_urls = [
|
||
path("daily/", admin.site.admin_view(daily_conversation_view), name="openclaw_daily"),
|
||
]
|
||
return custom_urls + urls
|
||
|
||
|
||
@admin.register(Message)
|
||
class MessageAdmin(admin.ModelAdmin):
|
||
list_display = ("message_id", "session", "role", "timestamp", "tokens_total")
|
||
list_filter = ("role", "model", "timestamp")
|
||
search_fields = ("content_text",)
|
||
ordering = ("-timestamp",)
|
||
|
||
|
||
@admin.register(ToolCall)
|
||
class ToolCallAdmin(admin.ModelAdmin):
|
||
list_display = ("tool_name", "tool_call_id", "session", "is_error", "duration_ms")
|
||
list_filter = ("tool_name", "is_error", "exit_code")
|
||
ordering = ("-created_at",)
|
||
```
|
||
|
||
- [ ] **Step 3: 添加 Admin 导航链接**
|
||
|
||
在 SessionAdmin 的 `list_display` 上方加一个自定义链接按钮(方便入口),或者在模板侧边栏直接访问 `/admin/sessions/session/daily/`。
|
||
|
||
- [ ] **Step 4: 手动验证**
|
||
|
||
```bash
|
||
cd src && python manage.py runserver
|
||
```
|
||
|
||
- 注册一个 session + message 测试数据
|
||
- `http://localhost:8000/admin/openclaw/session/daily/` 确认自定义视图可访问
|
||
- 选择日期范围,确认显示 session 列表和对话
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/openclaw/admin.py src/openclaw/templates/
|
||
git commit -m "feat: admin daily conversation view with date filtering"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Daily Markdown 导出(Admin Action)
|
||
|
||
**Files:**
|
||
- Create: `src/openclaw/export.py`
|
||
- Create: `tests/test_admin_export.py`
|
||
|
||
- [ ] **Step 1: 写导出测试**
|
||
|
||
`tests/test_admin_export.py`:
|
||
```python
|
||
"""Tests for Markdown export functionality."""
|
||
from datetime import datetime, timezone
|
||
|
||
import pytest
|
||
|
||
from openclaw.models import Session, Message
|
||
from openclaw.export import generate_markdown_report
|
||
|
||
|
||
@pytest.fixture
|
||
def db_session(db):
|
||
s = Session.objects.create(
|
||
session_id="report-test",
|
||
agent_name="xingyao",
|
||
source_node="macmini",
|
||
model_provider="anthropic",
|
||
model_id="claude-sonnet-4-6",
|
||
total_tokens=45230,
|
||
start_time=datetime(2026, 4, 5, 10, 0, tzinfo=timezone.utc),
|
||
)
|
||
Message.objects.create(
|
||
session=s,
|
||
message_id="m1",
|
||
parent_id="root",
|
||
role="user",
|
||
content_text="Help me fix this bug",
|
||
timestamp=datetime(2026, 4, 5, 10, 23, tzinfo=timezone.utc),
|
||
)
|
||
Message.objects.create(
|
||
session=s,
|
||
message_id="m2",
|
||
parent_id="m1",
|
||
role="assistant",
|
||
content_text="The bug is on line 45...",
|
||
timestamp=datetime(2026, 4, 5, 10, 23, 30, tzinfo=timezone.utc),
|
||
)
|
||
return s
|
||
|
||
|
||
@pytest.mark.django_db
|
||
class TestMarkdownExport:
|
||
def test_basic_report(self, db_session):
|
||
md = generate_markdown_report(
|
||
messages=db_session.messages.order_by("created_at"),
|
||
date_str="2026-04-05",
|
||
)
|
||
assert "# Daily Report: 2026-04-05" in md
|
||
assert "Help me fix this bug" in md
|
||
assert "The bug is on line 45..." in md
|
||
|
||
def test_thinking_content_stripped(self, db):
|
||
s = Session.objects.create(
|
||
session_id="thinking-test",
|
||
agent_name="test",
|
||
source_node="macmini",
|
||
start_time=datetime(2026, 4, 5, 10, 0, tzinfo=timezone.utc),
|
||
)
|
||
Message.objects.create(
|
||
session=s,
|
||
message_id="m3",
|
||
parent_id="root",
|
||
role="assistant",
|
||
content_text="Final answer",
|
||
raw_content=[
|
||
{"type": "thinking", "thinking": "Let me think about this..."},
|
||
{"type": "text", "text": "Final answer"},
|
||
],
|
||
timestamp=datetime(2026, 4, 5, 10, 30, tzinfo=timezone.utc),
|
||
)
|
||
md = generate_markdown_report(
|
||
messages=s.messages.order_by("created_at"),
|
||
date_str="2026-04-05",
|
||
)
|
||
assert "Let me think about this..." not in md
|
||
assert "Final answer" in md
|
||
|
||
def test_tool_call_formatting(self, db):
|
||
s = Session.objects.create(
|
||
session_id="tool-test",
|
||
agent_name="test",
|
||
source_node="macmini",
|
||
model_id="test-model",
|
||
total_tokens=100,
|
||
start_time=datetime(2026, 4, 5, 10, 0, tzinfo=timezone.utc),
|
||
)
|
||
Message.objects.create(
|
||
session=s,
|
||
message_id="m4",
|
||
parent_id="root",
|
||
role="assistant",
|
||
content_text="I'll run a command",
|
||
raw_content=[
|
||
{"type": "text", "text": "I'll run a command"},
|
||
],
|
||
timestamp=datetime(2026, 4, 5, 10, 30, tzinfo=timezone.utc),
|
||
)
|
||
md = generate_markdown_report(
|
||
messages=s.messages.order_by("created_at"),
|
||
date_str="2026-04-05",
|
||
)
|
||
assert "test-model" in md
|
||
assert "100" in md
|
||
```
|
||
|
||
- [ ] **Step 2: 运行测试确认失败**
|
||
|
||
```bash
|
||
pytest tests/test_admin_export.py -v
|
||
```
|
||
|
||
Expected: FAIL — module not found.
|
||
|
||
- [ ] **Step 3: 实现 Markdown 导出**
|
||
|
||
`src/openclaw/export.py`:
|
||
```python
|
||
from openclaw.models import Session
|
||
|
||
|
||
def generate_markdown_report(messages, date_str, sessions=None):
|
||
"""Generate a markdown daily report.
|
||
|
||
Args:
|
||
messages: QuerySet of Message objects, ordered by timestamp.
|
||
date_str: Date string for the report header (YYYY-MM-DD).
|
||
sessions: Optional dict of session_id -> Session instance for metadata.
|
||
"""
|
||
if sessions is None:
|
||
sessions = {}
|
||
|
||
lines = [f"# Daily Report: {date_str}", ""]
|
||
|
||
# Group messages by session
|
||
session_messages = {}
|
||
for msg in messages:
|
||
sid = msg.session_id if hasattr(msg, "session_id") else msg.session.session_id
|
||
if sid not in session_messages:
|
||
session_messages[sid] = []
|
||
session_messages[sid].append(msg)
|
||
|
||
for session_id, msgs in session_messages.items():
|
||
session = sessions.get(session_id)
|
||
if session:
|
||
lines.append(
|
||
f"## Session: {session_id} (Agent: {session.agent_name})"
|
||
)
|
||
lines.append(
|
||
f"**Model**: {session.model_id or 'N/A'} | "
|
||
f"**Token**: {session.total_tokens:,}"
|
||
)
|
||
else:
|
||
lines.append(f"## Session: {session_id}")
|
||
|
||
lines.append("")
|
||
|
||
for msg in msgs:
|
||
role_label = {
|
||
"user": "User",
|
||
"assistant": "Assistant",
|
||
"toolResult": "Tool Result",
|
||
}.get(msg.role, msg.role)
|
||
|
||
time_str = msg.timestamp.strftime("%H:%M")
|
||
|
||
# For assistant messages, check raw_content for tool_call mentions
|
||
if msg.role == "assistant":
|
||
tool_info = _extract_tool_info(msg.raw_content)
|
||
lines.append(f"### {time_str} {role_label}")
|
||
if msg.content_text:
|
||
lines.append("")
|
||
lines.append(msg.content_text)
|
||
|
||
for tool in tool_info:
|
||
lines.append("")
|
||
lines.append(
|
||
f"**{time_str} {role_label} -> [Tool: {tool['name']}]**"
|
||
)
|
||
lines.append("")
|
||
lines.append(f"`{tool.get('arguments', '')}`")
|
||
if tool.get("result"):
|
||
lines.append("")
|
||
lines.append(f'**Result**: {tool["result"]}')
|
||
elif msg.role == "toolResult":
|
||
continue # toolResult handled inline with assistant
|
||
else:
|
||
lines.append(f"### {time_str} {role_label}")
|
||
if msg.content_text:
|
||
lines.append("")
|
||
lines.append(msg.content_text)
|
||
|
||
lines.append("")
|
||
lines.append("---")
|
||
lines.append("")
|
||
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _extract_tool_info(raw_content):
|
||
"""Extract tool call info from message raw_content JSON."""
|
||
tools = []
|
||
if isinstance(raw_content, list):
|
||
for block in raw_content:
|
||
if isinstance(block, dict) and block.get("type") == "toolCall":
|
||
tool_name = block.get("tool_name") or block.get("name", "unknown")
|
||
args = block.get("arguments", {})
|
||
if isinstance(args, str):
|
||
args_str = args[:200]
|
||
else:
|
||
args_str = str(args)[:200]
|
||
tools.append({
|
||
"name": tool_name,
|
||
"arguments": args_str,
|
||
"result": "", # Will be filled later from toolResult
|
||
})
|
||
return tools
|
||
|
||
|
||
def export_daily_markdown(sessions_queryset):
|
||
"""Generate a markdown file from a QuerySet of Session objects.
|
||
|
||
Returns (markdown_string, filename).
|
||
Fetches all messages for the sessions.
|
||
"""
|
||
messages = Message.objects.filter(
|
||
session__in=sessions_queryset
|
||
).order_by("timestamp")
|
||
|
||
sessions_map = {s.session_id: s for s in sessions_queryset}
|
||
|
||
# Determine date from first session start_time
|
||
first_session = sessions_queryset.order_by("start_time").first()
|
||
if first_session and first_session.start_time:
|
||
date_str = first_session.start_time.strftime("%Y-%m-%d")
|
||
else:
|
||
date_str = "export"
|
||
|
||
md = generate_markdown_report(messages, date_str, sessions_map)
|
||
filename = f"daily-report-{date_str}.md"
|
||
return md, filename
|
||
```
|
||
|
||
- [ ] **Step 4: 运行导出测试**
|
||
|
||
```bash
|
||
pytest tests/test_admin_export.py -v
|
||
```
|
||
|
||
Expected: 3 PASS.
|
||
|
||
- [ ] **Step 5: 注册 Admin Action**
|
||
|
||
修改 `src/openclaw/admin.py`,在 SessionAdmin 中添加 action:
|
||
|
||
```python
|
||
from openclaw.export import export_daily_markdown
|
||
from django.http import HttpResponse
|
||
|
||
|
||
@admin.action(description="Export selected sessions to Markdown")
|
||
def export_to_markdown(modeladmin, request, queryset):
|
||
md, filename = export_daily_markdown(queryset)
|
||
response = HttpResponse(md, content_type="text/markdown")
|
||
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||
return response
|
||
|
||
|
||
@admin.register(Session)
|
||
class SessionAdmin(admin.ModelAdmin):
|
||
# ... existing code
|
||
actions = [export_to_markdown]
|
||
...
|
||
```
|
||
|
||
完整文件在 commit 时需要合并以上修改。
|
||
|
||
- [ ] **Step 6: 手动验证**
|
||
|
||
```bash
|
||
cd src && python manage.py runserver
|
||
```
|
||
|
||
在 Admin Session 列表勾选 sessions → 选择 "Export selected sessions to Markdown" → 确认下载文件,内容为 Markdown 格式对话。
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/openclaw/export.py tests/test_admin_export.py src/openclaw/admin.py
|
||
git commit -m "feat: daily Markdown export admin action"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: 客户端 JSONL 解析与推送脚本
|
||
|
||
**Files:**
|
||
- Create: `scripts/sync_sessions.py`
|
||
|
||
- [ ] **Step 1: 创建脚本**
|
||
|
||
`scripts/sync_sessions.py`:
|
||
```python
|
||
#!/usr/bin/env python
|
||
"""
|
||
OpenClaw Session Sync Script
|
||
|
||
Scans local agent sessions directories, parses JSONL files,
|
||
and pushes structured JSON to the Django API.
|
||
|
||
Usage:
|
||
python sync_sessions.py --remote-url http://macmini:8000/api/sessions/bulk_upsert/
|
||
|
||
Cron:
|
||
0 2 * * * cd /path/to/scripts && python sync_sessions.py --remote-url <url>
|
||
"""
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import sys
|
||
import time
|
||
import urllib.error
|
||
import urllib.request
|
||
from pathlib import Path
|
||
|
||
# ─────────────────────────────────────────────────────────────────
|
||
# Configuration
|
||
# ─────────────────────────────────────────────────────────────────
|
||
|
||
SESSIONS_DIR_NAME = "sessions"
|
||
STATE_FILE = ".sync_state"
|
||
DELETED_SUFFIX = ".deleted."
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────
|
||
# File Discovery
|
||
# ─────────────────────────────────────────────────────────────────
|
||
|
||
def find_sessions(root_path):
|
||
"""Walk root_path/agents/*/sessions/ and yield (agent_name, jsonl_path)."""
|
||
agents_dir = Path(root_path) / "agents"
|
||
if not agents_dir.exists():
|
||
return
|
||
for agent_folder in sorted(agents_dir.iterdir()):
|
||
if not agent_folder.is_dir():
|
||
continue
|
||
sessions_dir = agent_folder / SESSIONS_DIR_NAME
|
||
if not sessions_dir.exists():
|
||
continue
|
||
agent_name = agent_folder.name
|
||
for jsonl_file in sorted(sessions_dir.glob("*.jsonl")):
|
||
if DELETED_SUFFIX in jsonl_file.name:
|
||
continue
|
||
yield agent_name, str(jsonl_file)
|
||
|
||
|
||
def get_sync_state(sessions_dir):
|
||
"""Read .sync_state from sessions directory, return {path: mtime}."""
|
||
state_path = Path(sessions_dir) / STATE_FILE
|
||
if not state_path.exists():
|
||
return {}
|
||
try:
|
||
with open(state_path) as f:
|
||
return json.load(f)
|
||
except (json.JSONDecodeError, IOError):
|
||
return {}
|
||
|
||
|
||
def save_sync_state(sessions_dir, state):
|
||
"""Write .sync_state file."""
|
||
state_path = Path(sessions_dir) / STATE_FILE
|
||
with open(state_path, "w") as f:
|
||
json.dump(state, f)
|
||
|
||
|
||
def get_new_files(root_path):
|
||
"""Find files that are new or modified since last sync."""
|
||
state = {}
|
||
all_sessions_dirs = set()
|
||
|
||
agents_dir = Path(root_path) / "agents"
|
||
if agents_dir.exists():
|
||
for agent_folder in agents_dir.iterdir():
|
||
if agent_folder.is_dir():
|
||
sessions_dir = agent_folder / SESSIONS_DIR_NAME
|
||
if sessions_dir.exists():
|
||
all_sessions_dirs.add(str(sessions_dir))
|
||
|
||
# Load existing state from all session dirs
|
||
merged_state = {}
|
||
for sd in all_sessions_dirs:
|
||
sd_state = get_sync_state(sd)
|
||
merged_state.update(sd_state)
|
||
|
||
new_files = []
|
||
for agent_name, jsonl_path in find_sessions(root_path):
|
||
stat = os.stat(jsonl_path)
|
||
mtime = stat.st_mtime
|
||
file_key = jsonl_path
|
||
old_mtime = merged_state.get(file_key, 0)
|
||
if mtime > old_mtime:
|
||
new_files.append((agent_name, jsonl_path))
|
||
merged_state[file_key] = mtime
|
||
|
||
# Save new state
|
||
for sd in all_sessions_dirs:
|
||
dir_files = {k: v for k, v in merged_state.items() if k.startswith(sd)}
|
||
save_sync_state(sd, dir_files)
|
||
|
||
return new_files
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────
|
||
# JSONL Parser
|
||
# ─────────────────────────────────────────────────────────────────
|
||
|
||
def parse_jsonl(file_path):
|
||
"""Parse a JSONL file and return structured session data."""
|
||
sessions = []
|
||
messages = []
|
||
tool_calls = []
|
||
|
||
# State tracking for model/thinking changes
|
||
current_model_provider = ""
|
||
current_model_id = ""
|
||
current_thinking_level = ""
|
||
|
||
# Tool results lookup by tool_call_id
|
||
tool_results = {}
|
||
|
||
events = []
|
||
with open(file_path, "r", encoding="utf-8") as f:
|
||
for line in f:
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
try:
|
||
event = json.loads(line)
|
||
events.append(event)
|
||
except json.JSONDecodeError:
|
||
continue
|
||
|
||
if not events:
|
||
return sessions, messages, tool_calls
|
||
|
||
# First pass: extract session metadata
|
||
session_event = None
|
||
for event in events:
|
||
event_type = event.get("type", "")
|
||
if event_type == "session":
|
||
session_event = event
|
||
break
|
||
|
||
if not session_event:
|
||
return sessions, messages, tool_calls
|
||
|
||
session_id = session_event.get("id", "")
|
||
session_timestamp = session_event.get("timestamp", "")
|
||
session_cwd = session_event.get("cwd", "")
|
||
session_version = events[-1].get("version", 0) if events else 0
|
||
|
||
# Determine start and end time from all events
|
||
timestamps = []
|
||
for event in events:
|
||
ts = event.get("timestamp", "")
|
||
if ts:
|
||
timestamps.append(ts)
|
||
|
||
# Second pass: process events
|
||
message_seq = 0
|
||
total_tokens = 0
|
||
total_cost = 0.0
|
||
message_count = 0
|
||
tool_call_count = 0
|
||
error_count = 0
|
||
|
||
for event in events:
|
||
event_type = event.get("type", "")
|
||
|
||
if event_type == "model_change":
|
||
current_model_provider = event.get("provider", "")
|
||
current_model_id = event.get("modelId", "")
|
||
|
||
elif event_type == "thinking_level_change":
|
||
current_thinking_level = event.get("thinkingLevel", "")
|
||
|
||
elif event_type == "message":
|
||
role = event.get("role", "")
|
||
msg_id = event.get("id", "")
|
||
parent_id = event.get("parentId", "")
|
||
msg_timestamp = event.get("timestamp", "")
|
||
|
||
# Extract text content (skip thinking)
|
||
content_items = event.get("content", [])
|
||
text_parts = []
|
||
tc_list = []
|
||
for item in content_items:
|
||
if isinstance(item, dict):
|
||
if item.get("type") == "text":
|
||
text_parts.append(item.get("text", ""))
|
||
elif item.get("type") == "toolCall":
|
||
tc_list.append(item)
|
||
# Skip thinking types
|
||
|
||
content_text = "\n".join(text_parts)
|
||
|
||
msg_data = {
|
||
"session_id": session_id,
|
||
"message_id": msg_id,
|
||
"parent_id": parent_id,
|
||
"seq": message_seq,
|
||
"role": role,
|
||
"content_text": content_text,
|
||
"raw_content": content_items if content_items else [],
|
||
"raw_message": event.get("content", []),
|
||
"timestamp": msg_timestamp,
|
||
}
|
||
|
||
if role == "assistant":
|
||
usage = event.get("usage", {})
|
||
msg_data.update({
|
||
"model": current_model_id,
|
||
"provider": current_model_provider,
|
||
"stop_reason": event.get("stopReason", ""),
|
||
"tokens_input": usage.get("inputTokens", 0),
|
||
"tokens_output": usage.get("outputTokens", 0),
|
||
"tokens_cache_read": usage.get("cacheReadInputTokens", 0),
|
||
"tokens_cache_write": usage.get("cacheWriteInputTokens", 0),
|
||
"tokens_total": usage.get("totalTokens", 0),
|
||
})
|
||
total_tokens += usage.get("totalTokens", 0)
|
||
|
||
if event.get("cost"):
|
||
cost_val = event["cost"].get("total", 0.0)
|
||
msg_data["cost_total"] = cost_val
|
||
total_cost += cost_val
|
||
|
||
message_count += 1
|
||
|
||
elif role == "toolResult":
|
||
msg_data.update({
|
||
"tool_call_id": event.get("toolCallId", ""),
|
||
"tool_name": event.get("toolName", ""),
|
||
"is_error": event.get("isError", False),
|
||
"exit_code": event.get("exitCode"),
|
||
"duration_ms": event.get("durationMs"),
|
||
})
|
||
if event.get("isError"):
|
||
error_count += 1
|
||
# Store for tool call association
|
||
if event.get("toolCallId"):
|
||
tool_results[event["toolCallId"]] = {
|
||
"result_text": content_text,
|
||
"is_error": event.get("isError", False),
|
||
"exit_code": event.get("exitCode"),
|
||
"duration_ms": event.get("durationMs"),
|
||
}
|
||
|
||
messages.append(msg_data)
|
||
message_seq += 1
|
||
|
||
# Extract tool calls from assistant messages
|
||
tc_seq = 0
|
||
for tc in tc_list:
|
||
tool_call_data = {
|
||
"session_id": session_id,
|
||
"message_id": msg_id,
|
||
"tool_call_id": tc.get("id", f"call_{msg_id}_{tc_seq}"),
|
||
"tool_name": tc.get("name", "unknown"),
|
||
"arguments": tc.get("arguments", {}),
|
||
"seq": tc_seq,
|
||
}
|
||
# Enrich with tool result if available
|
||
tr = tool_results.get(tool_call_data["tool_call_id"], {})
|
||
tool_call_data["result_text"] = tr.get("result_text", "")
|
||
tool_call_data["is_error"] = tr.get("is_error", False)
|
||
tool_call_data["exit_code"] = tr.get("exit_code")
|
||
tool_call_data["duration_ms"] = tr.get("duration_ms")
|
||
tool_calls.append(tool_call_data)
|
||
tool_call_count += 1
|
||
tc_seq += 1
|
||
|
||
# Build session record
|
||
start_time = timestamps[0] if timestamps else session_timestamp
|
||
end_time = timestamps[-1] if timestamps else session_timestamp
|
||
|
||
session_data = {
|
||
"session_id": session_id,
|
||
"session_version": session_version,
|
||
"model_provider": current_model_provider,
|
||
"model_id": current_model_id,
|
||
"thinking_level": current_thinking_level,
|
||
"start_time": start_time,
|
||
"end_time": end_time,
|
||
"cwd": session_cwd,
|
||
"total_tokens": total_tokens,
|
||
"total_cost": total_cost,
|
||
"message_count": message_count,
|
||
"tool_call_count": tool_call_count,
|
||
"error_count": error_count,
|
||
"raw_file_path": str(file_path),
|
||
"status": "active",
|
||
"metadata": {},
|
||
}
|
||
|
||
sessions.append(session_data)
|
||
return sessions, messages, tool_calls
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────
|
||
# HTTP Client
|
||
# ─────────────────────────────────────────────────────────────────
|
||
|
||
def push_to_api(remote_url, payload):
|
||
"""POST structured JSON to Django API."""
|
||
data = json.dumps(payload).encode("utf-8")
|
||
req = urllib.request.Request(
|
||
remote_url,
|
||
data=data,
|
||
headers={"Content-Type": "application/json"},
|
||
method="POST",
|
||
)
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||
return json.loads(resp.read())
|
||
except urllib.error.HTTPError as e:
|
||
print(f"HTTP Error {e.code}: {e.read().decode('utf-8', errors='replace')}")
|
||
raise
|
||
except urllib.error.URLError as e:
|
||
print(f"URL Error: {e.reason}")
|
||
raise
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────
|
||
# Main
|
||
# ─────────────────────────────────────────────────────────────────
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description="Sync OpenClaw sessions to Django API")
|
||
parser.add_argument(
|
||
"--remote-url",
|
||
required=True,
|
||
help="Django API bulk_upsert endpoint URL",
|
||
)
|
||
parser.add_argument(
|
||
"--root-path",
|
||
default=".",
|
||
help="Root path containing agents/ directory (default: current dir)",
|
||
)
|
||
args = parser.parse_args()
|
||
|
||
new_files = get_new_files(args.root_path)
|
||
if not new_files:
|
||
print("No new or modified session files found.")
|
||
return
|
||
|
||
print(f"Found {len(new_files)} new/modified session(s).")
|
||
|
||
total_sessions = 0
|
||
total_messages = 0
|
||
total_tool_calls = 0
|
||
|
||
# Group by agent_name (batch per agent)
|
||
agent_batches = {}
|
||
for agent_name, jsonl_path in new_files:
|
||
agent_batches.setdefault(agent_name, []).append(jsonl_path)
|
||
|
||
for agent_name, file_paths in agent_batches.items():
|
||
all_sessions = []
|
||
all_messages = []
|
||
all_tool_calls = []
|
||
|
||
for fp in file_paths:
|
||
print(f" Parsing: {fp}")
|
||
try:
|
||
sessions, messages, tool_calls = parse_jsonl(fp)
|
||
all_sessions.extend(sessions)
|
||
all_messages.extend(messages)
|
||
all_tool_calls.extend(tool_calls)
|
||
except Exception as e:
|
||
print(f" ERROR parsing {fp}: {e}")
|
||
continue
|
||
|
||
if not all_sessions:
|
||
continue
|
||
|
||
payload = {
|
||
"agent_name": agent_name,
|
||
"source_node": os.environ.get("SOURCE_NODE", "unknown"),
|
||
"sessions": all_sessions,
|
||
"messages": all_messages,
|
||
"tool_calls": all_tool_calls,
|
||
}
|
||
|
||
print(f" Pushing {len(all_sessions)} session(s), "
|
||
f"{len(all_messages)} message(s), "
|
||
f"{len(all_tool_calls)} tool call(s)...")
|
||
|
||
try:
|
||
result = push_to_api(args.remote_url, payload)
|
||
print(f" OK: {result}")
|
||
total_sessions += result.get("sessions_upserted", 0)
|
||
total_messages += result.get("messages_upserted", 0)
|
||
total_tool_calls += result.get("tool_calls_upserted", 0)
|
||
except Exception:
|
||
print(f" FAILED to push {agent_name} sessions.")
|
||
|
||
print(f"\nSync complete: {total_sessions} sessions, "
|
||
f"{total_messages} messages, {total_tool_calls} tool calls pushed.")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|
||
```
|
||
|
||
- [ ] **Step 2: 语法检查**
|
||
|
||
```bash
|
||
python -m py_compile scripts/sync_sessions.py
|
||
```
|
||
|
||
Expected: no output (success).
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add scripts/sync_sessions.py
|
||
git commit -m "feat: client JSONL parse and push script"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Docker 部署配置
|
||
|
||
**Files:**
|
||
- Create: `Dockerfile`, `docker-compose.yml`, `.dockerignore`
|
||
- Create: `nginx/nginx.conf.placeholder`
|
||
|
||
- [ ] **Step 1: .dockerignore**
|
||
|
||
`.dockerignore`:
|
||
```
|
||
__pycache__
|
||
*.pyc
|
||
.venv
|
||
.env
|
||
*.egg-info
|
||
.git
|
||
.pytest_cache
|
||
.mypy_cache
|
||
tests/
|
||
scripts/
|
||
docs/
|
||
db.sqlite3
|
||
```
|
||
|
||
- [ ] **Step 2: Dockerfile**
|
||
|
||
`Dockerfile`:
|
||
```dockerfile
|
||
FROM python:3.12-slim
|
||
|
||
WORKDIR /app
|
||
|
||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||
build-essential libpq-dev \
|
||
&& rm -rf /var/lib/apt/lists/*
|
||
|
||
COPY requirements.txt .
|
||
RUN pip install --no-cache-dir -r requirements.txt
|
||
|
||
COPY . .
|
||
|
||
EXPOSE ${DJANGO_PORT:-8000}
|
||
|
||
CMD ["gunicorn", "--bind", "0.0.0.0:${DJANGO_PORT:-8000}", \
|
||
"--workers", "4", "--timeout", "120", \
|
||
"config.wsgi:application"]
|
||
```
|
||
|
||
- [ ] **Step 3: docker-compose.yml**
|
||
|
||
`docker-compose.yml`:
|
||
```yaml
|
||
services:
|
||
web:
|
||
build:
|
||
context: .
|
||
dockerfile: Dockerfile
|
||
container_name: openclaw-archive
|
||
env_file:
|
||
- .env
|
||
ports:
|
||
- "${DJANGO_PORT:-8000}:${DJANGO_PORT:-8000}"
|
||
volumes:
|
||
- static_volume:/app/staticfiles
|
||
- jsonl_archive:/app/archive
|
||
restart: unless-stopped
|
||
|
||
# nginx placeholder (uncomment for production)
|
||
# nginx:
|
||
# image: nginx:alpine
|
||
# container_name: openclaw-nginx
|
||
# ports:
|
||
# - "80:80"
|
||
# volumes:
|
||
# - ./nginx/nginx.conf:/etc/nginx/nginx.conf
|
||
# - static_volume:/app/staticfiles:ro
|
||
# depends_on:
|
||
# - web
|
||
|
||
volumes:
|
||
static_volume:
|
||
jsonl_archive:
|
||
```
|
||
|
||
- [ ] **Step 4: Nginx placeholder**
|
||
|
||
`nginx/nginx.conf.placeholder`:
|
||
```nginx
|
||
# Nginx reverse proxy placeholder for OpenClaw Archive
|
||
#
|
||
# To enable:
|
||
# 1. Rename this file to nginx.conf
|
||
# 2. Update server_name and SSL certificate paths
|
||
# 3. Uncomment the nginx service in docker-compose.yml
|
||
|
||
upstream django {
|
||
server web:8000;
|
||
}
|
||
|
||
server {
|
||
listen 80;
|
||
server_name _; # Update for production
|
||
|
||
location /static/ {
|
||
alias /app/staticfiles/;
|
||
}
|
||
|
||
location / {
|
||
proxy_pass http://django;
|
||
proxy_set_header Host $host;
|
||
proxy_set_header X-Real-IP $remote_addr;
|
||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto $scheme;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Settings 更新 — 读取环境变量**
|
||
|
||
`src/config/settings/base.py` 已使用 `os.environ.get()` 读取环境变量,无需修改。
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add Dockerfile docker-compose.yml .dockerignore nginx/nginx.conf.placeholder
|
||
git commit -m "feat: Docker Compose deployment configuration"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: TimescaleDB Hypertable Migration
|
||
|
||
**Files:**
|
||
- Create: `src/openclaw/migrations/XXXX_add_hypertables.py` (数字取决于之前的 migration)
|
||
|
||
⚠️ 此 task 需要真实的 PostgreSQL + TimescaleDB 环境。开发时可以用 `--fake` 跳过,生产环境必须真实执行。
|
||
|
||
- [ ] **Step 1: 创建 empty migration**
|
||
|
||
```bash
|
||
cd src && python manage.py makemigrations openclaw --empty -n add_hypertables
|
||
```
|
||
|
||
- [ ] **Step 2: 编辑 migration**
|
||
|
||
生成的文件 `src/openclaw/migrations/XXXX_add_hypertables.py`:
|
||
|
||
```python
|
||
from django.db import migrations
|
||
|
||
CREATE_HYPERTABLES = """
|
||
SELECT create_hypertable(
|
||
'sessions',
|
||
'start_time',
|
||
if_not_exists => TRUE
|
||
);
|
||
|
||
SELECT create_hypertable(
|
||
'messages',
|
||
'timestamp',
|
||
if_not_exists => TRUE
|
||
);
|
||
"""
|
||
|
||
|
||
class Migration(migrations.Migration):
|
||
|
||
dependencies = [
|
||
('openclaw', 'PREVIOUS_MIGRATION_NUMBER'), # Update this
|
||
]
|
||
|
||
operations = [
|
||
migrations.RunSQL(CREATE_HYPERTABLES, migrations.RunSQL.noop),
|
||
]
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/openclaw/migrations/
|
||
git commit -m "feat: TimescaleDB hypertable migrations"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: 端到端测试与 README
|
||
|
||
**Files:**
|
||
- Create: `README.md` (更新)
|
||
|
||
- [ ] **Step 1: 更新 README**
|
||
|
||
`README.md`:
|
||
```markdown
|
||
# agent-base
|
||
|
||
OpenClaw Session Archive — 多 Agent 会话解析与归档系统。
|
||
|
||
## Architecture
|
||
|
||
Three nodes (Mac Mini, Ubuntu1, Ubuntu2) run a sync script that parses local JSONL session files and pushes structured data to a central Django API. The Django service runs in Docker Compose and stores data in a remote PostgreSQL + TimescaleDB instance on NAS.
|
||
|
||
## Quick Start
|
||
|
||
```bash
|
||
# 1. Configure environment
|
||
cp .env.example .env
|
||
# Edit .env with your database credentials and Django settings
|
||
|
||
# 2. Build and start
|
||
docker compose build
|
||
docker compose run --rm web python manage.py migrate
|
||
docker compose run --rm web python manage.py createsuperuser
|
||
docker compose up -d
|
||
|
||
# 3. Access
|
||
# Django Admin: http://<host>:8000/admin/
|
||
# API: http://<host>:8000/api/sessions/bulk_upsert/
|
||
```
|
||
|
||
## Running Tests
|
||
|
||
```bash
|
||
pip install -r requirements-dev.txt
|
||
pytest -v
|
||
```
|
||
|
||
## Client Sync Script
|
||
|
||
Deploy `scripts/sync_sessions.py` on each node:
|
||
|
||
```bash
|
||
python sync_sessions.py --remote-url http://<django-host>:8000/api/sessions/bulk_upsert/
|
||
```
|
||
|
||
Set `SOURCE_NODE` environment variable on each node (`macmini`, `ubuntu1`, `ubuntu2`).
|
||
|
||
## Daily Export
|
||
|
||
In Django Admin, select sessions and choose "Export selected sessions to Markdown" action.
|
||
```
|
||
|
||
- [ ] **Step 2: 端到端测试流程**
|
||
|
||
```bash
|
||
# 1. 用 SQLite 跑通完整流程
|
||
cd src
|
||
python manage.py migrate
|
||
python manage.py createsuperuser # 可选
|
||
|
||
# 2. 用 pytest fixture 创建测试数据,push 到 API
|
||
cd ..
|
||
pytest -v
|
||
|
||
# 3. 启动 Django,手动调 sync_sessions.py(用测试用的 JSONL 文件)验证
|
||
cd src && python manage.py runserver &
|
||
cd ..
|
||
python scripts/sync_sessions.py --remote-url http://localhost:8000/api/sessions/bulk_upsert/ --root-path /path/to/test/agents
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add README.md
|
||
git commit -m "docs: update README with architecture and quick start"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review
|
||
|
||
### 1. Spec Coverage
|
||
|
||
| Spec Section | Covered By |
|
||
|-----|-----|
|
||
| 4.1 Session 表 | Task 1 - Session model |
|
||
| 4.2 Message 表 | Task 1 - Message model |
|
||
| 4.3 ToolCall 表 | Task 1 - ToolCall model |
|
||
| 4.4 TimescaleDB hypertable | Task 8 - hypertable migration |
|
||
| 5.1 推送脚本 | Task 6 - sync_sessions.py |
|
||
| 5.2 定时任务 | Task 6 - script + cron usage |
|
||
| 6. API bulk_upsert | Task 2 - views.py + services.py |
|
||
| 7.1-7.4 Admin CRUD | Task 3 - admin.py |
|
||
| 7.5 按时间范围查询 | Task 4 - daily_view.html + admin view |
|
||
| 7.6 Daily Markdown 导出 | Task 5 - export.py + Admin action |
|
||
| 8.1 客户端解析 | Task 6 - parse_jsonl function |
|
||
| 8.2 服务端写入 | Task 2 - BulkUpsertService |
|
||
| 9. 技术栈 | All tasks |
|
||
| 10. Docker Compose | Task 7 |
|
||
| 12. 非技术决策 | Task 1 (unique_together), Task 6 (skip .deleted.), Task 6 (SOURCE_NODE env) |
|
||
|
||
### 2. Placeholder Scan
|
||
|
||
No TBD, TODO, or "similar to" patterns found. All code blocks contain actual implementations.
|
||
|
||
### 3. Type Consistency
|
||
|
||
- `session_id` is consistently a `CharField` (not UUIDField) — matches API usage
|
||
- `agent_name + session_id` unique_together used consistently in both model and service for idempotency
|
||
- `timestamps` use ISO 8601 strings in API, parsed by `_parse_ts()` in service
|
||
- `role_labels` dict in admin matches actual DB role values: `"user"`, `"assistant"`, `"toolResult"`
|
||
|
||
One note: `created_at` and `updated_at` fields are auto-added to all models. The spec doesn't mention them but they're standard Django practice and harmless.
|
||
|
||
---
|