-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathweb.py
More file actions
241 lines (202 loc) · 8.23 KB
/
Copy pathweb.py
File metadata and controls
241 lines (202 loc) · 8.23 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
import os
import glob
import uuid
import asyncio
import time
from urllib.parse import quote
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from core import run_anki_agent_generator
from session_context import output_dir_var
app = FastAPI()
# Mount static directory
app.mount("/static", StaticFiles(directory="static"), name="static")
# Ensure outputs directory exists
OUTPUTS_BASE = os.path.join(os.getcwd(), "static", "outputs")
os.makedirs(OUTPUTS_BASE, exist_ok=True)
# Global session store - maps session_id to generated file info
session_files = {}
@app.get("/")
async def get():
return FileResponse("static/index.html")
@app.head("/")
async def head():
return FileResponse("static/index.html")
async def run_generation_task(
websocket: WebSocket, prompt: str, session_dir: str, session_id: str
):
"""
Helper to run the generator and handle its output.
"""
try:
# Track generated files in the SESSION directory
existing_files = set(glob.glob(os.path.join(session_dir, "*.apkg")))
start_time = time.perf_counter()
async for event in run_anki_agent_generator(prompt, verbose=True):
try:
# Ensure usage object is serializable if it exists
if event.get("type") == "usage" and hasattr(
event["usage"], "model_dump"
):
# If it's a Pydantic model (v2)
event["usage"] = event["usage"].model_dump()
elif event.get("type") == "usage" and hasattr(event["usage"], "dict"):
# If it's a Pydantic model (v1)
event["usage"] = event["usage"].dict()
await websocket.send_json(event)
except (RuntimeError, WebSocketDisconnect):
# Socket is closed, stop generating and exit loop to trigger cleanup
print(
f"WebSocket closed for session {session_id}, stopping generation."
)
return
except Exception as e:
# Catch any other serialization or transmission errors
print(f"Error sending event for session {session_id}: {e}")
return
end_time = time.perf_counter()
elapsed_time = end_time - start_time
# Find new file in session directory
current_files = set(glob.glob(os.path.join(session_dir, "*.apkg")))
new_files = current_files - existing_files
generated_file_path = None
if new_files:
generated_file_path = max(new_files, key=os.path.getctime)
if generated_file_path:
filename = os.path.basename(generated_file_path)
yield_msg = f"✨ 成功生成 Anki 包: {filename}"
try:
await websocket.send_json({"type": "log", "message": yield_msg})
print(f"File generated for {session_id}: {generated_file_path}")
# Store in global session store
session_files[session_id] = {
"filepath": generated_file_path,
"filename": filename,
}
# Send session_id and filename
await websocket.send_json(
{
"type": "complete",
"session_id": session_id,
"filename": filename,
"elapsed_time": elapsed_time,
}
)
except (RuntimeError, WebSocketDisconnect):
pass
else:
try:
await websocket.send_json(
{"type": "error", "message": "No .apkg file was generated."}
)
except (RuntimeError, WebSocketDisconnect):
pass
except asyncio.CancelledError:
print(f"Generation task for session {session_id} was cancelled.")
# The 'async with' in core.py will handle the actual Claude process cleanup
raise
except Exception as e:
import traceback
traceback.print_exc()
try:
await websocket.send_json({"type": "error", "message": str(e)})
except Exception:
pass
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
session_id = str(uuid.uuid4())
session_dir = os.path.join(OUTPUTS_BASE, session_id)
os.makedirs(session_dir, exist_ok=True)
# Set the context var for this session
token = output_dir_var.set(session_dir)
print(f"New session started: {session_id}")
generation_task = None
try:
while True:
data = await websocket.receive_text()
prompt = data.strip()
print(f"User Query [{session_id}]: {prompt}")
# Cancel previous task if still running
if generation_task and not generation_task.done():
generation_task.cancel()
try:
await generation_task
except asyncio.CancelledError:
pass
# Send start signal
await websocket.send_json({"type": "start"})
# Start new generation task
generation_task = asyncio.create_task(
run_generation_task(websocket, prompt, session_dir, session_id)
)
except WebSocketDisconnect:
print(f"Client {session_id} disconnected.")
finally:
if generation_task and not generation_task.done():
print(f"Cancelling active generation task for {session_id}")
generation_task.cancel()
try:
# IMPORTANT: We must await the cancelled task to allow its
# finally blocks and async context managers to clean up.
await generation_task
except asyncio.CancelledError:
print(
f"Active generation task for {session_id} successfully cancelled."
)
except Exception as e:
print(f"Error during task cancellation for {session_id}: {e}")
output_dir_var.reset(token)
print(f"Session cleaned up: {session_id}")
@app.get("/download/{session_id}")
async def download_file(session_id: str):
"""
Simple secure download using global session store
"""
# Check if session_id exists in our global store
if session_id not in session_files:
raise HTTPException(status_code=404, detail="File not found")
file_info = session_files[session_id]
filepath = file_info["filepath"]
filename = file_info["filename"]
print(f"User triggered download for session {session_id}: {filename}")
# Simple security: check file exists and has .apkg extension
if not os.path.exists(filepath) or not filepath.endswith(".apkg"):
raise HTTPException(status_code=404, detail="File not found")
# Encode filename for safe HTTP header
encoded_filename = quote(filename, safe="")
return FileResponse(
filepath,
media_type="application/octet-stream",
filename=encoded_filename,
headers={
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
},
)
if __name__ == "__main__":
import uvicorn
from uvicorn.config import LOGGING_CONFIG
import argparse
# Parse command line arguments
parser = argparse.ArgumentParser(
description="Run the Anki card generation web server"
)
parser.add_argument(
"--port",
type=int,
default=8001,
help="Port to run the server on (default: 8001)",
)
args = parser.parse_args()
# 修改日志格式,加入时间戳
LOGGING_CONFIG["formatters"]["default"]["fmt"] = (
"%(asctime)s %(levelprefix)s %(message)s"
)
LOGGING_CONFIG["formatters"]["access"]["fmt"] = (
'%(asctime)s %(levelprefix)s %(client_addr)s - "%(request_line)s" %(status_code)s'
)
LOGGING_CONFIG["formatters"]["default"]["datefmt"] = "%Y-%m-%d %H:%M:%S"
LOGGING_CONFIG["formatters"]["access"]["datefmt"] = "%Y-%m-%d %H:%M:%S"
print(f"Starting server on port {args.port}")
uvicorn.run("web:app", host="0.0.0.0", port=args.port, reload=False)