cc-discussion 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- backend/__init__.py +1 -0
- backend/__main__.py +7 -0
- backend/cli.py +44 -0
- backend/main.py +167 -0
- backend/models/__init__.py +2 -0
- backend/models/database.py +244 -0
- backend/routers/__init__.py +3 -0
- backend/routers/history.py +202 -0
- backend/routers/rooms.py +377 -0
- backend/services/__init__.py +1 -0
- backend/services/codex_agent.py +357 -0
- backend/services/codex_history_reader.py +287 -0
- backend/services/discussion_orchestrator.py +461 -0
- backend/services/history_reader.py +455 -0
- backend/services/meeting_prompts.py +291 -0
- backend/services/parallel_orchestrator.py +908 -0
- backend/services/participant_agent.py +394 -0
- backend/websocket.py +292 -0
- cc_discussion-1.0.0.dist-info/METADATA +527 -0
- cc_discussion-1.0.0.dist-info/RECORD +23 -0
- cc_discussion-1.0.0.dist-info/WHEEL +5 -0
- cc_discussion-1.0.0.dist-info/entry_points.txt +2 -0
- cc_discussion-1.0.0.dist-info/top_level.txt +1 -0
backend/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Claude Discussion Room Backend"""
|
backend/__main__.py
ADDED
backend/cli.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cc-discussion CLI
|
|
3
|
+
=================
|
|
4
|
+
|
|
5
|
+
uvx cc-discussion または python -m backend で実行
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
cc-discussion # デフォルト設定で起動
|
|
9
|
+
cc-discussion --port 9000 # ポート指定
|
|
10
|
+
cc-discussion --no-browser # ブラウザを開かない
|
|
11
|
+
cc-discussion --reload # 開発モード(ホットリロード)
|
|
12
|
+
"""
|
|
13
|
+
import webbrowser
|
|
14
|
+
from threading import Timer
|
|
15
|
+
|
|
16
|
+
import click
|
|
17
|
+
import uvicorn
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@click.command()
|
|
21
|
+
@click.option('--host', default='127.0.0.1', help='バインドするホスト')
|
|
22
|
+
@click.option('--port', default=8888, type=int, help='バインドするポート')
|
|
23
|
+
@click.option('--no-browser', is_flag=True, help='ブラウザを自動で開かない')
|
|
24
|
+
@click.option('--reload', is_flag=True, help='ホットリロードを有効化(開発用)')
|
|
25
|
+
@click.version_option(version='1.0.0', prog_name='cc-discussion')
|
|
26
|
+
def main(host: str, port: int, no_browser: bool, reload: bool):
|
|
27
|
+
"""Claude Discussion Room - マルチエージェント議論プラットフォーム"""
|
|
28
|
+
url = f"http://{host}:{port}"
|
|
29
|
+
click.echo(f"Starting cc-discussion on {url}")
|
|
30
|
+
|
|
31
|
+
# ブラウザを少し遅らせて開く(サーバー起動を待つ)
|
|
32
|
+
if not no_browser:
|
|
33
|
+
Timer(1.5, lambda: webbrowser.open(url)).start()
|
|
34
|
+
|
|
35
|
+
uvicorn.run(
|
|
36
|
+
"backend.main:app",
|
|
37
|
+
host=host,
|
|
38
|
+
port=port,
|
|
39
|
+
reload=reload,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if __name__ == '__main__':
|
|
44
|
+
main()
|
backend/main.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI Main Application
|
|
3
|
+
========================
|
|
4
|
+
|
|
5
|
+
Main entry point for the Claude Discussion Room server.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
# Load environment variables from .env file first
|
|
9
|
+
from dotenv import load_dotenv
|
|
10
|
+
load_dotenv()
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
import sys
|
|
15
|
+
from contextlib import asynccontextmanager
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
# Fix for Windows subprocess support
|
|
19
|
+
if sys.platform == "win32":
|
|
20
|
+
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
|
21
|
+
|
|
22
|
+
from fastapi import FastAPI, WebSocket
|
|
23
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
24
|
+
from fastapi.responses import FileResponse
|
|
25
|
+
from fastapi.staticfiles import StaticFiles
|
|
26
|
+
|
|
27
|
+
from .routers import history_router, rooms_router
|
|
28
|
+
from .websocket import room_websocket
|
|
29
|
+
from .services.discussion_orchestrator import cleanup_all_orchestrators
|
|
30
|
+
from .models.database import get_engine
|
|
31
|
+
|
|
32
|
+
# Configure logging
|
|
33
|
+
logging.basicConfig(
|
|
34
|
+
level=logging.INFO,
|
|
35
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
36
|
+
)
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
# Paths - Support both development and packaged installation
|
|
40
|
+
ROOT_DIR = Path(__file__).parent.parent
|
|
41
|
+
UI_DIST_DIR = ROOT_DIR / "frontend" / "dist"
|
|
42
|
+
|
|
43
|
+
# Alternative locations for packaged installation
|
|
44
|
+
if not UI_DIST_DIR.exists():
|
|
45
|
+
# Try relative to the backend package
|
|
46
|
+
UI_DIST_DIR = Path(__file__).parent / ".." / "frontend" / "dist"
|
|
47
|
+
if not UI_DIST_DIR.exists():
|
|
48
|
+
UI_DIST_DIR = None # No frontend dist available
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@asynccontextmanager
|
|
52
|
+
async def lifespan(app: FastAPI):
|
|
53
|
+
"""Lifespan context manager for startup and shutdown."""
|
|
54
|
+
# Startup - initialize database
|
|
55
|
+
get_engine()
|
|
56
|
+
logger.info("Database initialized")
|
|
57
|
+
|
|
58
|
+
yield
|
|
59
|
+
|
|
60
|
+
# Shutdown - cleanup all orchestrators
|
|
61
|
+
await cleanup_all_orchestrators()
|
|
62
|
+
logger.info("Cleanup complete")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# Create FastAPI app
|
|
66
|
+
app = FastAPI(
|
|
67
|
+
title="Claude Discussion Room",
|
|
68
|
+
description="Multi-Claude discussion platform with ClaudeCode context",
|
|
69
|
+
version="1.0.0",
|
|
70
|
+
lifespan=lifespan,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# CORS
|
|
74
|
+
app.add_middleware(
|
|
75
|
+
CORSMiddleware,
|
|
76
|
+
allow_origins=[
|
|
77
|
+
"http://localhost:5173",
|
|
78
|
+
"http://127.0.0.1:5173",
|
|
79
|
+
"http://localhost:8888",
|
|
80
|
+
"http://127.0.0.1:8888",
|
|
81
|
+
"http://localhost:9000",
|
|
82
|
+
"http://127.0.0.1:9000",
|
|
83
|
+
],
|
|
84
|
+
allow_credentials=True,
|
|
85
|
+
allow_methods=["*"],
|
|
86
|
+
allow_headers=["*"],
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Include routers
|
|
90
|
+
app.include_router(history_router)
|
|
91
|
+
app.include_router(rooms_router)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# WebSocket endpoint
|
|
95
|
+
@app.websocket("/ws/rooms/{room_id}")
|
|
96
|
+
async def websocket_endpoint(websocket: WebSocket, room_id: int):
|
|
97
|
+
"""WebSocket endpoint for discussion room updates."""
|
|
98
|
+
await room_websocket(websocket, room_id)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# Health check
|
|
102
|
+
@app.get("/api/health")
|
|
103
|
+
async def health_check():
|
|
104
|
+
"""Health check endpoint."""
|
|
105
|
+
return {"status": "healthy"}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# SDK availability check
|
|
109
|
+
@app.get("/api/config/available-agents")
|
|
110
|
+
async def get_available_agents():
|
|
111
|
+
"""Check which agent SDKs are installed and available."""
|
|
112
|
+
available = []
|
|
113
|
+
|
|
114
|
+
# Check ClaudeCode SDK
|
|
115
|
+
try:
|
|
116
|
+
from claude_agent_sdk import ClaudeSDKClient
|
|
117
|
+
available.append("claude")
|
|
118
|
+
except ImportError:
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
# Check Codex SDK
|
|
122
|
+
try:
|
|
123
|
+
from codex_sdk import Codex
|
|
124
|
+
available.append("codex")
|
|
125
|
+
except ImportError:
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
return {"available_agents": available}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# Static file serving (Production)
|
|
132
|
+
if UI_DIST_DIR and UI_DIST_DIR.exists():
|
|
133
|
+
app.mount("/assets", StaticFiles(directory=UI_DIST_DIR / "assets"), name="assets")
|
|
134
|
+
|
|
135
|
+
@app.get("/")
|
|
136
|
+
async def serve_index():
|
|
137
|
+
"""Serve the React app index.html."""
|
|
138
|
+
return FileResponse(UI_DIST_DIR / "index.html")
|
|
139
|
+
|
|
140
|
+
@app.get("/{path:path}")
|
|
141
|
+
async def serve_spa(path: str):
|
|
142
|
+
"""Serve static files or fall back to index.html for SPA routing."""
|
|
143
|
+
if path.startswith("api/") or path.startswith("ws/"):
|
|
144
|
+
return {"error": "Not found"}
|
|
145
|
+
|
|
146
|
+
file_path = (UI_DIST_DIR / path).resolve()
|
|
147
|
+
|
|
148
|
+
# Prevent path traversal
|
|
149
|
+
try:
|
|
150
|
+
file_path.relative_to(UI_DIST_DIR.resolve())
|
|
151
|
+
except ValueError:
|
|
152
|
+
return FileResponse(UI_DIST_DIR / "index.html")
|
|
153
|
+
|
|
154
|
+
if file_path.exists() and file_path.is_file():
|
|
155
|
+
return FileResponse(file_path)
|
|
156
|
+
|
|
157
|
+
return FileResponse(UI_DIST_DIR / "index.html")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
if __name__ == "__main__":
|
|
161
|
+
import uvicorn
|
|
162
|
+
uvicorn.run(
|
|
163
|
+
"backend.main:app",
|
|
164
|
+
host="127.0.0.1",
|
|
165
|
+
port=8888,
|
|
166
|
+
reload=True,
|
|
167
|
+
)
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Database Models
|
|
3
|
+
===============
|
|
4
|
+
|
|
5
|
+
SQLite database schema for discussion rooms using SQLAlchemy.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import enum
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Generator
|
|
12
|
+
|
|
13
|
+
from sqlalchemy import (
|
|
14
|
+
Boolean,
|
|
15
|
+
Column,
|
|
16
|
+
DateTime,
|
|
17
|
+
Enum,
|
|
18
|
+
ForeignKey,
|
|
19
|
+
Integer,
|
|
20
|
+
String,
|
|
21
|
+
Text,
|
|
22
|
+
create_engine,
|
|
23
|
+
)
|
|
24
|
+
from sqlalchemy.orm import DeclarativeBase, Session, relationship, sessionmaker
|
|
25
|
+
from sqlalchemy.types import JSON
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _utc_now() -> datetime:
|
|
29
|
+
"""Return current UTC time."""
|
|
30
|
+
return datetime.now(timezone.utc)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Base(DeclarativeBase):
|
|
34
|
+
"""SQLAlchemy 2.0 style declarative base."""
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RoomStatus(enum.Enum):
|
|
39
|
+
"""Status of a discussion room."""
|
|
40
|
+
WAITING = "waiting" # Room created, waiting for discussion to start
|
|
41
|
+
ACTIVE = "active" # Discussion in progress
|
|
42
|
+
PAUSED = "paused" # Discussion paused
|
|
43
|
+
COMPLETED = "completed" # Discussion ended
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class MeetingType(enum.Enum):
|
|
47
|
+
"""会議タイプ"""
|
|
48
|
+
PROGRESS_CHECK = "progress_check" # 1. 進捗・状況確認
|
|
49
|
+
SPEC_ALIGNMENT = "spec_alignment" # 2. 要件・仕様の認識合わせ
|
|
50
|
+
TECHNICAL_REVIEW = "technical_review" # 3. 技術検討・設計判断
|
|
51
|
+
ISSUE_RESOLUTION = "issue_resolution" # 4. 課題・不具合対応
|
|
52
|
+
REVIEW = "review" # 5. レビュー
|
|
53
|
+
PLANNING = "planning" # 6. 計画・タスク整理
|
|
54
|
+
RELEASE_OPS = "release_ops" # 7. リリース・運用判断
|
|
55
|
+
RETROSPECTIVE = "retrospective" # 8. 改善・振り返り
|
|
56
|
+
OTHER = "other" # 9. その他(カスタム)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AgentType(enum.Enum):
|
|
60
|
+
"""エージェントタイプ"""
|
|
61
|
+
CLAUDE = "claude"
|
|
62
|
+
CODEX = "codex"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class DiscussionRoom(Base):
|
|
66
|
+
"""A discussion room where multiple Claudes converse."""
|
|
67
|
+
__tablename__ = "discussion_rooms"
|
|
68
|
+
|
|
69
|
+
id = Column(Integer, primary_key=True, index=True)
|
|
70
|
+
name = Column(String(200), nullable=False)
|
|
71
|
+
topic = Column(Text, nullable=True)
|
|
72
|
+
status = Column(
|
|
73
|
+
Enum(RoomStatus, native_enum=False, values_callable=lambda x: [e.value for e in x]),
|
|
74
|
+
default=RoomStatus.WAITING
|
|
75
|
+
)
|
|
76
|
+
max_turns = Column(Integer, default=20)
|
|
77
|
+
current_turn = Column(Integer, default=0)
|
|
78
|
+
created_at = Column(DateTime, default=_utc_now)
|
|
79
|
+
updated_at = Column(DateTime, default=_utc_now, onupdate=_utc_now)
|
|
80
|
+
|
|
81
|
+
# Meeting type settings
|
|
82
|
+
meeting_type = Column(
|
|
83
|
+
Enum(MeetingType, native_enum=False, values_callable=lambda x: [e.value for e in x]),
|
|
84
|
+
default=MeetingType.TECHNICAL_REVIEW
|
|
85
|
+
)
|
|
86
|
+
custom_meeting_description = Column(Text, nullable=True) # "その他"選択時のカスタム説明
|
|
87
|
+
language = Column(String(10), default="ja")
|
|
88
|
+
|
|
89
|
+
participants = relationship(
|
|
90
|
+
"RoomParticipant",
|
|
91
|
+
back_populates="room",
|
|
92
|
+
cascade="all, delete-orphan"
|
|
93
|
+
)
|
|
94
|
+
messages = relationship(
|
|
95
|
+
"DiscussionMessage",
|
|
96
|
+
back_populates="room",
|
|
97
|
+
cascade="all, delete-orphan"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def to_dict(self) -> dict:
|
|
101
|
+
"""Convert to dictionary for JSON serialization."""
|
|
102
|
+
return {
|
|
103
|
+
"id": self.id,
|
|
104
|
+
"name": self.name,
|
|
105
|
+
"topic": self.topic,
|
|
106
|
+
"status": self.status.value if self.status else "waiting",
|
|
107
|
+
"max_turns": self.max_turns,
|
|
108
|
+
"current_turn": self.current_turn,
|
|
109
|
+
"meeting_type": self.meeting_type.value if self.meeting_type else "technical_review",
|
|
110
|
+
"custom_meeting_description": self.custom_meeting_description,
|
|
111
|
+
"language": self.language or "ja",
|
|
112
|
+
"created_at": self.created_at.isoformat() if self.created_at else None,
|
|
113
|
+
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
|
114
|
+
"participant_count": len(self.participants) if self.participants else 0,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class RoomParticipant(Base):
|
|
119
|
+
"""A Claude participant in a discussion room."""
|
|
120
|
+
__tablename__ = "room_participants"
|
|
121
|
+
|
|
122
|
+
id = Column(Integer, primary_key=True, index=True)
|
|
123
|
+
room_id = Column(Integer, ForeignKey("discussion_rooms.id"), nullable=False, index=True)
|
|
124
|
+
|
|
125
|
+
# Participant identity
|
|
126
|
+
name = Column(String(50), nullable=False)
|
|
127
|
+
role = Column(String(100), nullable=True)
|
|
128
|
+
color = Column(String(7), default="#6366f1")
|
|
129
|
+
|
|
130
|
+
# Context injection from ClaudeCode history
|
|
131
|
+
context_project_dir = Column(String(500), nullable=True)
|
|
132
|
+
context_session_id = Column(String(100), nullable=True)
|
|
133
|
+
context_summary = Column(Text, nullable=True)
|
|
134
|
+
|
|
135
|
+
# State
|
|
136
|
+
is_speaking = Column(Boolean, default=False)
|
|
137
|
+
message_count = Column(Integer, default=0)
|
|
138
|
+
|
|
139
|
+
# Agent settings
|
|
140
|
+
is_facilitator = Column(Boolean, default=False)
|
|
141
|
+
agent_type = Column(
|
|
142
|
+
Enum(AgentType, native_enum=False, values_callable=lambda x: [e.value for e in x]),
|
|
143
|
+
default=AgentType.CLAUDE
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
room = relationship("DiscussionRoom", back_populates="participants")
|
|
147
|
+
|
|
148
|
+
def to_dict(self) -> dict:
|
|
149
|
+
"""Convert to dictionary for JSON serialization."""
|
|
150
|
+
return {
|
|
151
|
+
"id": self.id,
|
|
152
|
+
"room_id": self.room_id,
|
|
153
|
+
"name": self.name,
|
|
154
|
+
"role": self.role,
|
|
155
|
+
"color": self.color,
|
|
156
|
+
"context_project_dir": self.context_project_dir,
|
|
157
|
+
"context_session_id": self.context_session_id,
|
|
158
|
+
"has_context": bool(self.context_session_id),
|
|
159
|
+
"is_speaking": self.is_speaking,
|
|
160
|
+
"message_count": self.message_count,
|
|
161
|
+
"is_facilitator": self.is_facilitator or False,
|
|
162
|
+
"agent_type": self.agent_type.value if self.agent_type else "claude",
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class DiscussionMessage(Base):
|
|
167
|
+
"""A message in the discussion."""
|
|
168
|
+
__tablename__ = "discussion_messages"
|
|
169
|
+
|
|
170
|
+
id = Column(Integer, primary_key=True, index=True)
|
|
171
|
+
room_id = Column(Integer, ForeignKey("discussion_rooms.id"), nullable=False, index=True)
|
|
172
|
+
participant_id = Column(Integer, ForeignKey("room_participants.id"), nullable=True)
|
|
173
|
+
|
|
174
|
+
role = Column(String(20), nullable=False) # "system" | "participant" | "moderator"
|
|
175
|
+
content = Column(Text, nullable=False)
|
|
176
|
+
extra_data = Column(JSON, nullable=True) # Tool calls, thinking, etc.
|
|
177
|
+
|
|
178
|
+
turn_number = Column(Integer, nullable=False)
|
|
179
|
+
created_at = Column(DateTime, default=_utc_now)
|
|
180
|
+
|
|
181
|
+
room = relationship("DiscussionRoom", back_populates="messages")
|
|
182
|
+
|
|
183
|
+
def to_dict(self) -> dict:
|
|
184
|
+
"""Convert to dictionary for JSON serialization."""
|
|
185
|
+
return {
|
|
186
|
+
"id": self.id,
|
|
187
|
+
"room_id": self.room_id,
|
|
188
|
+
"participant_id": self.participant_id,
|
|
189
|
+
"role": self.role,
|
|
190
|
+
"content": self.content,
|
|
191
|
+
"extra_data": self.extra_data,
|
|
192
|
+
"turn_number": self.turn_number,
|
|
193
|
+
"created_at": self.created_at.isoformat() if self.created_at else None,
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# Database setup
|
|
198
|
+
DATABASE_PATH = Path(__file__).parent.parent.parent / "discussion.db"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def get_database_url() -> str:
|
|
202
|
+
"""Return the SQLAlchemy database URL."""
|
|
203
|
+
return f"sqlite:///{DATABASE_PATH.as_posix()}"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
_engine = None
|
|
207
|
+
_session_maker = None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def get_engine():
|
|
211
|
+
"""Get or create the database engine."""
|
|
212
|
+
global _engine
|
|
213
|
+
if _engine is None:
|
|
214
|
+
_engine = create_engine(
|
|
215
|
+
get_database_url(),
|
|
216
|
+
connect_args={"check_same_thread": False}
|
|
217
|
+
)
|
|
218
|
+
Base.metadata.create_all(bind=_engine)
|
|
219
|
+
return _engine
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def get_session_maker():
|
|
223
|
+
"""Get or create the session maker."""
|
|
224
|
+
global _session_maker
|
|
225
|
+
if _session_maker is None:
|
|
226
|
+
_session_maker = sessionmaker(
|
|
227
|
+
autocommit=False,
|
|
228
|
+
autoflush=False,
|
|
229
|
+
bind=get_engine()
|
|
230
|
+
)
|
|
231
|
+
return _session_maker
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def get_db() -> Generator[Session, None, None]:
|
|
235
|
+
"""Dependency for FastAPI to get database session."""
|
|
236
|
+
SessionLocal = get_session_maker()
|
|
237
|
+
db = SessionLocal()
|
|
238
|
+
try:
|
|
239
|
+
yield db
|
|
240
|
+
except Exception:
|
|
241
|
+
db.rollback()
|
|
242
|
+
raise
|
|
243
|
+
finally:
|
|
244
|
+
db.close()
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""
|
|
2
|
+
History Router
|
|
3
|
+
==============
|
|
4
|
+
|
|
5
|
+
API endpoints for browsing ClaudeCode conversation history.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any, List, Optional
|
|
9
|
+
|
|
10
|
+
from fastapi import APIRouter, HTTPException
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from ..services.history_reader import (
|
|
14
|
+
list_projects,
|
|
15
|
+
list_sessions,
|
|
16
|
+
load_session_history,
|
|
17
|
+
decode_session_id,
|
|
18
|
+
)
|
|
19
|
+
from ..services.codex_history_reader import (
|
|
20
|
+
list_codex_projects,
|
|
21
|
+
list_codex_sessions,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
router = APIRouter(prefix="/api/history", tags=["history"])
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ProjectResponse(BaseModel):
|
|
28
|
+
"""Response model for a project."""
|
|
29
|
+
id: str
|
|
30
|
+
name: str
|
|
31
|
+
path: str
|
|
32
|
+
last_modified_at: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SessionResponse(BaseModel):
|
|
36
|
+
"""Response model for a session."""
|
|
37
|
+
id: str
|
|
38
|
+
jsonl_file_path: str
|
|
39
|
+
last_modified_at: str
|
|
40
|
+
message_count: int
|
|
41
|
+
first_user_message: Optional[str]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ToolCall(BaseModel):
|
|
45
|
+
"""Tool call model."""
|
|
46
|
+
id: Optional[str]
|
|
47
|
+
name: Optional[str]
|
|
48
|
+
input: dict
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ToolResult(BaseModel):
|
|
52
|
+
"""Tool result model."""
|
|
53
|
+
tool_use_id: Optional[str]
|
|
54
|
+
content: Any
|
|
55
|
+
is_error: bool = False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ConversationResponse(BaseModel):
|
|
59
|
+
"""Response model for a conversation message."""
|
|
60
|
+
type: str
|
|
61
|
+
uuid: str
|
|
62
|
+
timestamp: str
|
|
63
|
+
content: Any
|
|
64
|
+
is_sidechain: bool
|
|
65
|
+
parent_uuid: Optional[str]
|
|
66
|
+
tool_calls: List[ToolCall]
|
|
67
|
+
tool_results: List[ToolResult]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class SessionDetailResponse(BaseModel):
|
|
71
|
+
"""Response model for session detail."""
|
|
72
|
+
id: str
|
|
73
|
+
jsonl_file_path: str
|
|
74
|
+
conversations: List[ConversationResponse]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@router.get("/projects", response_model=List[ProjectResponse])
|
|
78
|
+
async def get_claude_projects():
|
|
79
|
+
"""List all ClaudeCode projects with history."""
|
|
80
|
+
projects = list_projects()
|
|
81
|
+
return [
|
|
82
|
+
ProjectResponse(
|
|
83
|
+
id=p.id,
|
|
84
|
+
name=p.name,
|
|
85
|
+
path=p.path,
|
|
86
|
+
last_modified_at=p.last_modified_at.isoformat(),
|
|
87
|
+
)
|
|
88
|
+
for p in projects
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@router.get("/projects/{project_id}/sessions", response_model=List[SessionResponse])
|
|
93
|
+
async def get_project_sessions(project_id: str, limit: int = 50):
|
|
94
|
+
"""Get all sessions for a project."""
|
|
95
|
+
sessions = list_sessions(project_id, limit=limit)
|
|
96
|
+
return [
|
|
97
|
+
SessionResponse(
|
|
98
|
+
id=s.id,
|
|
99
|
+
jsonl_file_path=s.jsonl_file_path,
|
|
100
|
+
last_modified_at=s.last_modified_at.isoformat(),
|
|
101
|
+
message_count=s.message_count,
|
|
102
|
+
first_user_message=s.first_user_message,
|
|
103
|
+
)
|
|
104
|
+
for s in sessions
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@router.get("/sessions/{session_id}", response_model=SessionDetailResponse)
|
|
109
|
+
async def get_session_detail(session_id: str):
|
|
110
|
+
"""Get full session detail with all conversations."""
|
|
111
|
+
try:
|
|
112
|
+
messages = load_session_history(session_id)
|
|
113
|
+
except FileNotFoundError:
|
|
114
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
115
|
+
|
|
116
|
+
return SessionDetailResponse(
|
|
117
|
+
id=session_id,
|
|
118
|
+
jsonl_file_path=decode_session_id(session_id),
|
|
119
|
+
conversations=[
|
|
120
|
+
ConversationResponse(
|
|
121
|
+
type=m.type,
|
|
122
|
+
uuid=m.uuid,
|
|
123
|
+
timestamp=m.timestamp,
|
|
124
|
+
content=m.content,
|
|
125
|
+
is_sidechain=m.is_sidechain,
|
|
126
|
+
parent_uuid=m.parent_uuid,
|
|
127
|
+
tool_calls=[
|
|
128
|
+
ToolCall(
|
|
129
|
+
id=tc.get('id'),
|
|
130
|
+
name=tc.get('name'),
|
|
131
|
+
input=tc.get('input', {}),
|
|
132
|
+
)
|
|
133
|
+
for tc in m.tool_calls
|
|
134
|
+
],
|
|
135
|
+
tool_results=[
|
|
136
|
+
ToolResult(
|
|
137
|
+
tool_use_id=tr.get('tool_use_id'),
|
|
138
|
+
content=tr.get('content'),
|
|
139
|
+
is_error=tr.get('is_error', False),
|
|
140
|
+
)
|
|
141
|
+
for tr in m.tool_results
|
|
142
|
+
],
|
|
143
|
+
)
|
|
144
|
+
for m in messages
|
|
145
|
+
],
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ============================================
|
|
150
|
+
# Codex History Endpoints
|
|
151
|
+
# ============================================
|
|
152
|
+
|
|
153
|
+
class CodexProjectResponse(BaseModel):
|
|
154
|
+
"""Response model for a Codex project."""
|
|
155
|
+
id: str
|
|
156
|
+
name: str
|
|
157
|
+
path: str
|
|
158
|
+
last_modified_at: str
|
|
159
|
+
session_count: int
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class CodexSessionResponse(BaseModel):
|
|
163
|
+
"""Response model for a Codex session."""
|
|
164
|
+
id: str
|
|
165
|
+
session_uuid: str
|
|
166
|
+
jsonl_file_path: str
|
|
167
|
+
first_user_message: Optional[str]
|
|
168
|
+
message_count: int
|
|
169
|
+
last_modified_at: str
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@router.get("/codex/projects", response_model=List[CodexProjectResponse])
|
|
173
|
+
async def get_codex_projects():
|
|
174
|
+
"""List all Codex projects with history."""
|
|
175
|
+
projects = list_codex_projects()
|
|
176
|
+
return [
|
|
177
|
+
CodexProjectResponse(
|
|
178
|
+
id=p["id"],
|
|
179
|
+
name=p["name"],
|
|
180
|
+
path=p["path"],
|
|
181
|
+
last_modified_at=p["last_modified_at"],
|
|
182
|
+
session_count=p["session_count"],
|
|
183
|
+
)
|
|
184
|
+
for p in projects
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@router.get("/codex/projects/{project_id}/sessions", response_model=List[CodexSessionResponse])
|
|
189
|
+
async def get_codex_project_sessions(project_id: str):
|
|
190
|
+
"""Get all sessions for a Codex project."""
|
|
191
|
+
sessions = list_codex_sessions(project_id)
|
|
192
|
+
return [
|
|
193
|
+
CodexSessionResponse(
|
|
194
|
+
id=s["id"],
|
|
195
|
+
session_uuid=s["session_uuid"],
|
|
196
|
+
jsonl_file_path=s["jsonl_file_path"],
|
|
197
|
+
first_user_message=s["first_user_message"],
|
|
198
|
+
message_count=s["message_count"],
|
|
199
|
+
last_modified_at=s["last_modified_at"],
|
|
200
|
+
)
|
|
201
|
+
for s in sessions
|
|
202
|
+
]
|