Spaces:
Running
Running
Commit ·
dc7c214
0
Parent(s):
Fix: switch DEFAULT_MODEL from tencent/hy3:free (unavailable on OpenRouter) to laguna-s-2.1:free
Browse filestencent/hy3:free returns OpenRouter 404: 'This model is unavailable for free.
The paid version is available now - use this slug instead: tencent/hy3'
This caused /api/session/restore-summary to return 500 (seed_from_summary
calls LLM with DEFAULT_MODEL_PATH). Switched to poolside/laguna-s-2.1:free
which is available on OpenRouter free tier.
This view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +35 -0
- .gitignore +2 -0
- AUTO_CONTINUE_FEATURE.md +183 -0
- BUILD_TRIGGER +1 -0
- Dockerfile +93 -0
- README.md +66 -0
- backend/document_parser.py +319 -0
- backend/session_logger.py +467 -0
- configs/frontend_agent_config.json +19 -0
- fix_space_error_report.json +14 -0
- patch_agent_backend.py +87 -0
- patch_agent_loop.py +74 -0
- patch_apodex_ling_beatapi.py +145 -0
- patch_auto_continue.py +129 -0
- patch_auto_continue_v10.py +202 -0
- patch_auto_continue_v11.py +185 -0
- patch_auto_continue_v12.py +209 -0
- patch_auto_continue_v13.py +197 -0
- patch_auto_continue_v14.py +166 -0
- patch_auto_continue_v15.py +167 -0
- patch_auto_continue_v16.py +142 -0
- patch_auto_continue_v17.py +187 -0
- patch_auto_continue_v2.py +307 -0
- patch_auto_continue_v3.py +229 -0
- patch_auto_continue_v4.py +264 -0
- patch_auto_continue_v5.py +252 -0
- patch_auto_continue_v6.py +243 -0
- patch_auto_continue_v7.py +255 -0
- patch_auto_continue_v8.py +239 -0
- patch_auto_continue_v9.py +227 -0
- patch_beatapi_routing.py +182 -0
- patch_catch_up_dataset_fallback.py +102 -0
- patch_chat_input.py +105 -0
- patch_document_upload.py +225 -0
- patch_fix_static_404.py +105 -0
- patch_frontend.py +199 -0
- patch_frontend_apodex_ling_beatapi.py +110 -0
- patch_frontend_multi.py +120 -0
- patch_frontend_new_models.py +71 -0
- patch_frontend_permanent_sessions.py +45 -0
- patch_frontend_uploads.py +81 -0
- patch_lossless_context.py +365 -0
- patch_models.py +256 -0
- patch_multi_upload.py +183 -0
- patch_new_models.py +88 -0
- patch_openrouter_fix.py +92 -0
- patch_permanent_session_logging.py +358 -0
- patch_restore_full_app.py +84 -0
- patch_space_bunny.py +56 -0
- patch_sse_transport.py +32 -0
.gitattributes
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
AUTO_CONTINUE_FEATURE.md
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Auto-Continue Feature for Free Models
|
| 2 |
+
|
| 3 |
+
## Tổng quan
|
| 4 |
+
Khi model miễn phí dùng OpenRouter phản hồi bị dừng nhưng nhiệm vụ chưa hoàn thành, hệ thống sẽ:
|
| 5 |
+
1. Phát hiện task incomplete dựa vào trạng thái plan
|
| 6 |
+
2. Hiển thị 2 nút: "Tự động tiếp tục (10s)" và "Tạm dừng"
|
| 7 |
+
|
| 8 |
+
## Các file cần sửa
|
| 9 |
+
|
| 10 |
+
### 1. frontend/src/hooks/useAgentChat.ts
|
| 11 |
+
|
| 12 |
+
```typescript
|
| 13 |
+
// Thêm vào import
|
| 14 |
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
| 15 |
+
|
| 16 |
+
// Trong useAgentChat function, sau callbacksRef
|
| 17 |
+
const autoContinueTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
| 18 |
+
const [showAutoContinue, setShowAutoContinue] = useState(false);
|
| 19 |
+
const [taskIncompleteInfo, setTaskIncompleteInfo] = useState<{
|
| 20 |
+
incompletePlan: Array<{ id: string; content: string; status: string }>;
|
| 21 |
+
} | null>(null);
|
| 22 |
+
|
| 23 |
+
// Thêm vào SideChannelCallbacks interface
|
| 24 |
+
export interface SideChannelCallbacks {
|
| 25 |
+
// ... existing callbacks ...
|
| 26 |
+
onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
// Thêm vào sideChannel trong useMemo
|
| 30 |
+
onTaskIncomplete: (incompletePlan) => {
|
| 31 |
+
setTaskIncompleteInfo({ incompletePlan });
|
| 32 |
+
setShowAutoContinue(true);
|
| 33 |
+
if (autoContinueTimerRef.current) {
|
| 34 |
+
clearTimeout(autoContinueTimerRef.current);
|
| 35 |
+
}
|
| 36 |
+
autoContinueTimerRef.current = setTimeout(() => {
|
| 37 |
+
startAutoContinue();
|
| 38 |
+
}, 10000);
|
| 39 |
+
},
|
| 40 |
+
|
| 41 |
+
// Thêm hàm mới
|
| 42 |
+
const startAutoContinue = useCallback(() => {
|
| 43 |
+
setShowAutoContinue(false);
|
| 44 |
+
setTaskIncompleteInfo(null);
|
| 45 |
+
if (autoContinueTimerRef.current) {
|
| 46 |
+
clearTimeout(autoContinueTimerRef.current);
|
| 47 |
+
autoContinueTimerRef.current = null;
|
| 48 |
+
}
|
| 49 |
+
const incompleteItems = taskIncompleteInfo?.incompletePlan || [];
|
| 50 |
+
const continuationText = incompleteItems.length > 0
|
| 51 |
+
? `Tiếp tục từ các task chưa hoàn thành: ${incompleteItems.map(i => `- ${i.content}`).join('\n')}`
|
| 52 |
+
: 'Tiếp tục nhiệm vụ.';
|
| 53 |
+
chat.sendMessage({
|
| 54 |
+
text: `[TỰ ĐỘNG TIẾP TỤC] ${continuationText}`,
|
| 55 |
+
metadata: { createdAt: new Date().toISOString() }
|
| 56 |
+
});
|
| 57 |
+
}, [taskIncompleteInfo, chat]);
|
| 58 |
+
|
| 59 |
+
const cancelAutoContinue = useCallback(() => {
|
| 60 |
+
setShowAutoContinue(false);
|
| 61 |
+
setTaskIncompleteInfo(null);
|
| 62 |
+
if (autoContinueTimerRef.current) {
|
| 63 |
+
clearTimeout(autoContinueTimerRef.current);
|
| 64 |
+
autoContinueTimerRef.current = null;
|
| 65 |
+
}
|
| 66 |
+
}, []);
|
| 67 |
+
|
| 68 |
+
// Thêm vào return
|
| 69 |
+
return {
|
| 70 |
+
messages: chat.messages,
|
| 71 |
+
sendMessage: chat.sendMessage,
|
| 72 |
+
stop,
|
| 73 |
+
status: chat.status,
|
| 74 |
+
undoLastTurn,
|
| 75 |
+
editAndRegenerate,
|
| 76 |
+
approveTools,
|
| 77 |
+
refreshMessages,
|
| 78 |
+
showAutoContinue,
|
| 79 |
+
taskIncompleteInfo,
|
| 80 |
+
startAutoContinue,
|
| 81 |
+
cancelAutoContinue,
|
| 82 |
+
};
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
### 2. frontend/src/components/Chat/ChatInput.tsx
|
| 86 |
+
|
| 87 |
+
```typescript
|
| 88 |
+
// Thêm import
|
| 89 |
+
import { Box, IconButton, Stack, Tooltip, Button } from '@mui/material';
|
| 90 |
+
|
| 91 |
+
// Thêm props
|
| 92 |
+
interface ChatInputProps {
|
| 93 |
+
// ... existing props
|
| 94 |
+
showAutoContinue?: boolean;
|
| 95 |
+
taskIncompleteInfo?: { incompletePlan: Array<{ id: string; content: string; status: string }> } | null;
|
| 96 |
+
startAutoContinue?: () => void;
|
| 97 |
+
cancelAutoContinue?: () => void;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
// Thêm UI buttons (trước hoặc sau input area)
|
| 101 |
+
{showAutoContinue && taskIncompleteInfo && (
|
| 102 |
+
<Stack direction="row" spacing={1} sx={{ mb: 1, px: 2 }}>
|
| 103 |
+
<Button
|
| 104 |
+
variant="contained"
|
| 105 |
+
color="primary"
|
| 106 |
+
size="small"
|
| 107 |
+
onClick={startAutoContinue}
|
| 108 |
+
sx={{ textTransform: 'none' }}
|
| 109 |
+
>
|
| 110 |
+
Tự động tiếp tục (10s)
|
| 111 |
+
</Button>
|
| 112 |
+
<Button
|
| 113 |
+
variant="outlined"
|
| 114 |
+
color="secondary"
|
| 115 |
+
size="small"
|
| 116 |
+
onClick={cancelAutoContinue}
|
| 117 |
+
sx={{ textTransform: 'none' }}
|
| 118 |
+
>
|
| 119 |
+
Tạm dừng
|
| 120 |
+
</Button>
|
| 121 |
+
</Stack>
|
| 122 |
+
)}
|
| 123 |
+
```
|
| 124 |
+
|
| 125 |
+
### 3. frontend/src/lib/sse-chat-transport.ts
|
| 126 |
+
|
| 127 |
+
```typescript
|
| 128 |
+
// Thêm vào case trong createEventToChunkStream
|
| 129 |
+
case 'task_incomplete':
|
| 130 |
+
sideChannel.onTaskIncomplete(
|
| 131 |
+
(event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [],
|
| 132 |
+
);
|
| 133 |
+
break;
|
| 134 |
+
```
|
| 135 |
+
|
| 136 |
+
### 4. agent/core/agent_loop.py
|
| 137 |
+
|
| 138 |
+
```python
|
| 139 |
+
# Trong run_agent, sau khi xử lý LLM response
|
| 140 |
+
def _check_task_incomplete(session: Session, llm_result: LLMResult) -> bool:
|
| 141 |
+
"""Kiểm tra nếu model dừng nhưng task chưa hoàn thành."""
|
| 142 |
+
if llm_result.tool_calls_acc:
|
| 143 |
+
return False # Có tool calls thì chưa incomplete
|
| 144 |
+
|
| 145 |
+
plan = getattr(session, "current_plan", None) or []
|
| 146 |
+
unfinished = [item for item in plan if item.get("status") in ("pending", "in_progress")]
|
| 147 |
+
|
| 148 |
+
return len(unfinished) > 0
|
| 149 |
+
|
| 150 |
+
# Gọi sau khi nhận kết quả LLM:
|
| 151 |
+
if _check_task_incomplete(session, llm_result):
|
| 152 |
+
unfinished = [item for item in plan if item.get("status") in ("pending", "in_progress")]
|
| 153 |
+
await session.send_event(
|
| 154 |
+
Event(
|
| 155 |
+
event_type="task_incomplete",
|
| 156 |
+
data={
|
| 157 |
+
"incomplete_plan": unfinished,
|
| 158 |
+
"message": "Model stopped streaming but task is incomplete."
|
| 159 |
+
}
|
| 160 |
+
)
|
| 161 |
+
)
|
| 162 |
+
```
|
| 163 |
+
|
| 164 |
+
## Logic hoạt động
|
| 165 |
+
|
| 166 |
+
1. Khi agent loop phát hiện model trả lời không có tool calls nhưng plan còn incomplete
|
| 167 |
+
2. Backend gửi event `task_incomplete` với danh sách task chưa hoàn thành
|
| 168 |
+
3. Frontend nhận event qua `onTaskIncomplete` callback
|
| 169 |
+
4. Hiển thị 2 nút:
|
| 170 |
+
- **Tự động tiếp tục (10s)**: Sau 10 giây sẽ tự động gửi tin nhắn tiếp tục
|
| 171 |
+
- **Tạm dừng**: Hủy timer, cho phép user nhập nội dung mới
|
| 172 |
+
|
| 173 |
+
## Áp dụng patch
|
| 174 |
+
|
| 175 |
+
```bash
|
| 176 |
+
# Tải source
|
| 177 |
+
git clone https://huggingface.co/spaces/smolagents/ml-intern /tmp/source
|
| 178 |
+
|
| 179 |
+
# Sửa các file trên
|
| 180 |
+
# Sau đó rebuild frontend
|
| 181 |
+
cd /tmp/source/frontend
|
| 182 |
+
npm install && npm run build
|
| 183 |
+
```
|
BUILD_TRIGGER
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
laguna-s21-default-model-fix-v5
|
Dockerfile
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM node:20-alpine AS frontend-builder
|
| 2 |
+
RUN apk add --no-cache git python3
|
| 3 |
+
RUN git clone --depth 1 https://huggingface.co/spaces/smolagents/ml-intern /source || true
|
| 4 |
+
COPY patch_restore_full_app.py /tmp/patch_restore_full_app.py
|
| 5 |
+
RUN python3 /tmp/patch_restore_full_app.py || true
|
| 6 |
+
COPY patch_frontend.py /tmp/patch_frontend.py
|
| 7 |
+
RUN python3 /tmp/patch_frontend.py || true
|
| 8 |
+
COPY patch_space_bunny.py /tmp/patch_space_bunny.py
|
| 9 |
+
RUN python3 /tmp/patch_space_bunny.py || true
|
| 10 |
+
COPY patch_frontend_uploads.py /tmp/patch_frontend_uploads.py
|
| 11 |
+
RUN python3 /tmp/patch_frontend_uploads.py || true
|
| 12 |
+
COPY patch_frontend_multi.py /tmp/patch_frontend_multi.py
|
| 13 |
+
RUN python3 /tmp/patch_frontend_multi.py || true
|
| 14 |
+
COPY patch_frontend_new_models.py /tmp/patch_frontend_new_models.py
|
| 15 |
+
RUN python3 /tmp/patch_frontend_new_models.py || true
|
| 16 |
+
COPY patch_frontend_apodex_ling_beatapi.py /tmp/patch_frontend_apodex_ling_beatapi.py
|
| 17 |
+
RUN python3 /tmp/patch_frontend_apodex_ling_beatapi.py || true
|
| 18 |
+
COPY patch_catch_up_dataset_fallback.py /tmp/patch_catch_up_dataset_fallback.py
|
| 19 |
+
RUN python3 /tmp/patch_catch_up_dataset_fallback.py || true
|
| 20 |
+
COPY patch_auto_continue_v17.py /tmp/patch_auto_continue_v17.py
|
| 21 |
+
RUN python3 /tmp/patch_auto_continue_v17.py || true
|
| 22 |
+
|
| 23 |
+
WORKDIR /source/frontend
|
| 24 |
+
RUN npm config set fetch-timeout 120000 && npm config set fetch-retries 3 && npm install && npm run build || echo "FRONTEND BUILD FAILED"
|
| 25 |
+
# Guarantee the dist dir exists so COPY --from below never fails, even if npm failed.
|
| 26 |
+
RUN mkdir -p /source/frontend/dist
|
| 27 |
+
|
| 28 |
+
FROM python:3.12-slim
|
| 29 |
+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
| 30 |
+
RUN useradd -m -u 1000 user
|
| 31 |
+
WORKDIR /app
|
| 32 |
+
RUN apt-get update && apt-get install -y --no-install-recommends git curl ca-certificates tesseract-ocr tesseract-ocr-eng tesseract-ocr-vie libreoffice-writer libreoffice-calc libreoffice-impress && rm -rf /var/lib/apt/lists/*
|
| 33 |
+
|
| 34 |
+
RUN git clone --depth 1 https://huggingface.co/spaces/smolagents/ml-intern /tmp/source && cp /tmp/source/pyproject.toml /tmp/source/uv.lock ./ && cp -r /tmp/source/agent ./agent && cp -r /tmp/source/backend ./backend && (cp /tmp/source/backend/document_parser.py ./backend/document_parser.py 2>/dev/null || true) && cp -r /tmp/source/configs ./configs && cp -r /tmp/source/scripts ./scripts && rm -rf /tmp/source
|
| 35 |
+
|
| 36 |
+
COPY scripts/build_kpis.py /tmp/build_kpis_patched.py
|
| 37 |
+
RUN cp /tmp/build_kpis_patched.py /app/scripts/build_kpis.py && python3 -c "import ast; ast.parse(open('/app/scripts/build_kpis.py').read())" || echo "KP FAILED"
|
| 38 |
+
|
| 39 |
+
COPY patch_restore_full_app.py /tmp/patch_restore_full_app.py
|
| 40 |
+
RUN python3 /tmp/patch_restore_full_app.py || true
|
| 41 |
+
|
| 42 |
+
RUN uv sync --no-dev --frozen || echo "UV SYNC FAILED"
|
| 43 |
+
|
| 44 |
+
COPY backend/session_logger.py /app/backend/session_logger.py
|
| 45 |
+
COPY backend/document_parser.py /app/backend/document_parser.py
|
| 46 |
+
COPY patch_models.py /tmp/patch_models.py
|
| 47 |
+
RUN python /tmp/patch_models.py || true
|
| 48 |
+
COPY patch_new_models.py /tmp/patch_new_models.py
|
| 49 |
+
RUN python /tmp/patch_new_models.py || true
|
| 50 |
+
COPY patch_apodex_ling_beatapi.py /tmp/patch_apodex_ling_beatapi.py
|
| 51 |
+
RUN python /tmp/patch_apodex_ling_beatapi.py || true
|
| 52 |
+
COPY patch_openrouter_fix.py /tmp/patch_openrouter_fix.py
|
| 53 |
+
RUN python /tmp/patch_openrouter_fix.py || true
|
| 54 |
+
COPY patch_beatapi_routing.py /tmp/patch_beatapi_routing.py
|
| 55 |
+
RUN python3 /tmp/patch_beatapi_routing.py || true
|
| 56 |
+
COPY patch_agent_backend.py /tmp/patch_agent_backend.py
|
| 57 |
+
RUN python /tmp/patch_agent_backend.py || true
|
| 58 |
+
COPY patch_document_upload.py /tmp/patch_document_upload.py
|
| 59 |
+
RUN python /tmp/patch_document_upload.py || true
|
| 60 |
+
COPY patch_multi_upload.py /tmp/patch_multi_upload.py
|
| 61 |
+
RUN python /tmp/patch_multi_upload.py || true
|
| 62 |
+
COPY patch_permanent_session_logging.py /tmp/patch_permanent_session_logging.py
|
| 63 |
+
RUN python3 /tmp/patch_permanent_session_logging.py || true
|
| 64 |
+
|
| 65 |
+
# === PATCH: fix 404 on root and /api when frontend build is missing ===
|
| 66 |
+
# Root cause: mkdir -p /app/static creates an empty dir; main.py mounts
|
| 67 |
+
# StaticFiles on the empty dir, shadowing ALL API routes with 404s.
|
| 68 |
+
COPY patch_fix_static_404.py /tmp/patch_fix_static_404.py
|
| 69 |
+
RUN python3 /tmp/patch_fix_static_404.py || echo "STATIC FIX PATCH WARN (build continues)"
|
| 70 |
+
|
| 71 |
+
# === PATCH: lossless long-context memory (fix context loss for long sessions) ===
|
| 72 |
+
COPY patch_lossless_context.py /tmp/patch_lossless_context.py
|
| 73 |
+
RUN python /tmp/patch_lossless_context.py || echo "LOSSLESS CONTEXT PATCH WARN (build continues)"
|
| 74 |
+
RUN python3 -c "import ast; [ast.parse(open(f).read()) for f in ['/app/agent/context_manager/manager.py','/app/agent/core/agent_loop.py','/app/agent/config.py','/app/agent/core/session.py','/app/agent/core/session_resume.py']]; print('ALL PATCHED FILES SYNTAX OK')" || echo "SYNTAX CHECK WARN"
|
| 75 |
+
|
| 76 |
+
# Copy frontend dist from builder stage using COPY --from (multi-stage)
|
| 77 |
+
# The frontend-builder stage guarantees /source/frontend/dist exists (mkdir -p)
|
| 78 |
+
COPY --from=frontend-builder /source/frontend/dist /app/static
|
| 79 |
+
RUN echo "Static dir ready: $(ls /app/static 2>/dev/null | wc -l) files"
|
| 80 |
+
|
| 81 |
+
RUN mkdir -p /app/configs
|
| 82 |
+
RUN cp configs/frontend_agent_config.json /app/configs/frontend_agent_config.json 2>/dev/null || echo "No frontend config"
|
| 83 |
+
|
| 84 |
+
RUN grep -rl "where we here now" /app/static/ 2>/dev/null | xargs sed -i "s/where we here now//g" 2>/dev/null || true
|
| 85 |
+
|
| 86 |
+
RUN mkdir -p /app/session_logs && chown -R user:user /app
|
| 87 |
+
|
| 88 |
+
USER user
|
| 89 |
+
ENV HOME=/home/user PYTHONUNBUFFERED=1 PYTHONPATH=/app PATH="/app/.venv/bin:$PATH"
|
| 90 |
+
|
| 91 |
+
EXPOSE 7860
|
| 92 |
+
WORKDIR /app/backend
|
| 93 |
+
CMD ["bash", "start.sh"]
|
README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Space Multiverse
|
| 3 |
+
emoji: 🤖
|
| 4 |
+
colorFrom: yellow
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
short_description: ML Intern V2 (FREE & SAVE MODEL + PERMANENT SESSION LOGGING)
|
| 9 |
+
hf_oauth: true
|
| 10 |
+
hf_oauth_expiration_minutes: 43200
|
| 11 |
+
hf_oauth_scopes:
|
| 12 |
+
- read-repos
|
| 13 |
+
- write-repos
|
| 14 |
+
- contribute-repos
|
| 15 |
+
- manage-repos
|
| 16 |
+
- write-collections
|
| 17 |
+
- inference-api
|
| 18 |
+
- jobs
|
| 19 |
+
- write-discussions
|
| 20 |
+
tags:
|
| 21 |
+
- ml-intern
|
| 22 |
+
- permanent-session-logging
|
| 23 |
+
- persistent-context
|
| 24 |
+
---
|
| 25 |
+
|
| 26 |
+
# Space Multiverse — ML Intern V2
|
| 27 |
+
|
| 28 |
+
## 🔒 Permanent Session & User Context Logging
|
| 29 |
+
|
| 30 |
+
**Lưu trữ vĩnh viễn ngữ cảnh từng phiên và từng user vào dataset HuggingFace.**
|
| 31 |
+
|
| 32 |
+
- Mỗi message, tool call, event được lưu **ngay khi xảy ra** — không đợi heartbeat
|
| 33 |
+
- Không bao giờ mất dữ liệu, ngay cả khi Space restart/crash
|
| 34 |
+
- Lưu vào `bep40/ml-intern-sessions` (dataset chung) + `{user}/ml-intern-sessions` (cá nhân)
|
| 35 |
+
- Mỗi model (dù là model nào) đều được lưu session vĩnh viễn
|
| 36 |
+
- **Không có thông báo "where we here now?"** — giảm thiểu thông báo không cần thiết
|
| 37 |
+
|
| 38 |
+
### Cách hoạt động
|
| 39 |
+
|
| 40 |
+
1. Khi session được tạo → log ngay vào dataset với event_type=session_created
|
| 41 |
+
2. User gửi tin nhắn → log ngay role=user, event_type=message
|
| 42 |
+
3. Agent phản hồi → log ngay role=assistant, event_type=message
|
| 43 |
+
4. Tool call/result → log ngay event_type=tool_call/tool_result
|
| 44 |
+
5. Session kết thúc → log trajectory đầy đủ
|
| 45 |
+
|
| 46 |
+
### Truy cập lịch sử
|
| 47 |
+
|
| 48 |
+
- `GET /api/sessions-history` — lấy tất cả session của user
|
| 49 |
+
- `GET /api/session-history/{session_id}` — lấy full lịch sử một session
|
| 50 |
+
- `GET /api/dataset-info` — thông tin dataset lưu trữ
|
| 51 |
+
|
| 52 |
+
Dataset: [bep40/ml-intern-sessions](https://huggingface.co/datasets/bep40/ml-intern-sessions)
|
| 53 |
+
|
| 54 |
+
## Models
|
| 55 |
+
|
| 56 |
+
- **Default**: `openai/tencent/hy3:free`
|
| 57 |
+
- OpenRouter models: Tencent HY3, Gemma 4 31B, Llama 3.3 70B, DeepSeek V4, Laguna S 2.1
|
| 58 |
+
- BeatAPI: `beatapi/space-bunny-alpha` (provider riêng, cần BEATAPI_API_KEY)
|
| 59 |
+
- Cục bản chất, mọi model đều được lưu session vĩnh viễn
|
| 60 |
+
|
| 61 |
+
## Quick Start
|
| 62 |
+
|
| 63 |
+
1. Chọn model (dùng bất kỳ model nào — đều được lưu)
|
| 64 |
+
2. Chat bình thường
|
| 65 |
+
3. Session được lưu vĩnh viễn vào dataset
|
| 66 |
+
4. Sau Space restart, frontend tự động restore từ dataset
|
backend/document_parser.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Extract plain-text context from uploaded office/binary documents.
|
| 2 |
+
|
| 3 |
+
The ML Intern agent runtime is a coding agent that mostly runs on text-only
|
| 4 |
+
LLM backends (many of the free OpenRouter models have no vision). So instead
|
| 5 |
+
of just handing the raw binary file to the model, we extract the meaningful
|
| 6 |
+
text out of it and present it as a compact, readable context block.
|
| 7 |
+
|
| 8 |
+
Supported formats:
|
| 9 |
+
PDF (.pdf)
|
| 10 |
+
Images (.png, .jpg, .jpeg, .webp, .bmp, .tiff, .gif)
|
| 11 |
+
Excel (.xlsx, .xlsm, .xls)
|
| 12 |
+
Word (.docx)
|
| 13 |
+
PowerPoint (.pptx)
|
| 14 |
+
OpenDocument (.odt, .ods, .odp) (via LibreOffice)
|
| 15 |
+
Plain text (.txt, .md, .csv, .json, .jsonl, .rtf, .log)
|
| 16 |
+
|
| 17 |
+
IMPORTANT: For OCR we rely on pytesseract + a system tesseract binary.
|
| 18 |
+
For legacy binary office formats (.xls, .doc, .rtf, .odt/.ods/.odp) we rely
|
| 19 |
+
on LibreOffice headless (`libreoffice --headless --convert-to txt`). If those
|
| 20 |
+
system binaries are not installed, extraction degrades gracefully: the file is
|
| 21 |
+
still uploaded and referenced, but with a warning that the raw content could
|
| 22 |
+
not be parsed.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import io
|
| 28 |
+
import os
|
| 29 |
+
import re
|
| 30 |
+
import shutil
|
| 31 |
+
import subprocess
|
| 32 |
+
import tempfile
|
| 33 |
+
from typing import Optional
|
| 34 |
+
|
| 35 |
+
# --- Optional heavy imports, guarded --------------------------------
|
| 36 |
+
try:
|
| 37 |
+
from PIL import Image
|
| 38 |
+
except Exception: # pragma: no cover
|
| 39 |
+
Image = None
|
| 40 |
+
|
| 41 |
+
try:
|
| 42 |
+
from pypdf import PdfReader
|
| 43 |
+
except Exception: # pragma: no cover
|
| 44 |
+
PdfReader = None
|
| 45 |
+
|
| 46 |
+
try:
|
| 47 |
+
import docx # python-docx
|
| 48 |
+
except Exception: # pragma: no cover
|
| 49 |
+
docx = None
|
| 50 |
+
|
| 51 |
+
try:
|
| 52 |
+
import openpyxl
|
| 53 |
+
except Exception: # pragma: no cover
|
| 54 |
+
openpyxl = None
|
| 55 |
+
|
| 56 |
+
try:
|
| 57 |
+
import pptx # python-pptx
|
| 58 |
+
except Exception: # pragma: no cover
|
| 59 |
+
pptx = None
|
| 60 |
+
|
| 61 |
+
MAX_CHARS = 60_000 # cap extracted text so the context block stays small
|
| 62 |
+
_PAGE_BREAK = "\n\n--- PAGE BREAK ---\n\n"
|
| 63 |
+
|
| 64 |
+
# Map extension -> parser kind
|
| 65 |
+
IMAGE_EXTS = {"png", "jpg", "jpeg", "webp", "bmp", "tiff", "tif", "gif"}
|
| 66 |
+
TEXT_EXTS = {"txt", "md", "csv", "json", "jsonl", "log", "rtf"}
|
| 67 |
+
_LO_TEXT_FORMATS = {".xls", ".doc", ".odt", ".ods", ".odp"}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _snippet(text: str, limit: int = 120) -> str:
|
| 71 |
+
text = " ".join(text.split())
|
| 72 |
+
return text[:limit] + ("…" if len(text) > limit else "")
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
# --- Individual extractors -------------------------------------------
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _extract_pdf(data: bytes) -> str:
|
| 79 |
+
if PdfReader is None:
|
| 80 |
+
raise RuntimeError("pypdf not installed")
|
| 81 |
+
reader = PdfReader(io.BytesIO(data))
|
| 82 |
+
pages = []
|
| 83 |
+
for page in reader.pages:
|
| 84 |
+
try:
|
| 85 |
+
pages.append(page.extract_text() or "")
|
| 86 |
+
except Exception:
|
| 87 |
+
pages.append("")
|
| 88 |
+
return _PAGE_BREAK.join(pages)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _extract_docx(data: bytes) -> str:
|
| 92 |
+
if docx is None:
|
| 93 |
+
raise RuntimeError("python-docx not installed")
|
| 94 |
+
document = docx.Document(io.BytesIO(data))
|
| 95 |
+
parts = []
|
| 96 |
+
|
| 97 |
+
# Tables first (structure is usually important)
|
| 98 |
+
for table in document.tables:
|
| 99 |
+
for row in table.rows:
|
| 100 |
+
cells = [c.text.strip() for c in row.cells]
|
| 101 |
+
parts.append(" | ".join(cells))
|
| 102 |
+
parts.append("")
|
| 103 |
+
|
| 104 |
+
for para in document.paragraphs:
|
| 105 |
+
t = para.text.strip()
|
| 106 |
+
if t:
|
| 107 |
+
parts.append(t)
|
| 108 |
+
return "\n".join(parts)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _extract_xlsx(data: bytes) -> str:
|
| 112 |
+
if openpyxl is None:
|
| 113 |
+
raise RuntimeError("openpyxl not installed")
|
| 114 |
+
wb = openpyxl.load_workbook(io.BytesIO(data), read_only=True, data_only=True)
|
| 115 |
+
parts = []
|
| 116 |
+
for ws in wb.worksheets:
|
| 117 |
+
parts.append(f"### Sheet: {ws.title}")
|
| 118 |
+
for row in ws.iter_rows(values_only=True):
|
| 119 |
+
vals = ["" if v is None else str(v) for v in row]
|
| 120 |
+
line = " | ".join(vals).strip()
|
| 121 |
+
if line:
|
| 122 |
+
parts.append(line)
|
| 123 |
+
parts.append("")
|
| 124 |
+
return "\n".join(parts)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _extract_pptx(data: bytes) -> str:
|
| 128 |
+
if pptx is None:
|
| 129 |
+
raise RuntimeError("python-pptx not installed")
|
| 130 |
+
prs = pptx.Presentation(io.BytesIO(data))
|
| 131 |
+
parts = []
|
| 132 |
+
for idx, slide in enumerate(prs.slides, start=1):
|
| 133 |
+
parts.append(f"### Slide {idx}")
|
| 134 |
+
for shape in slide.shapes:
|
| 135 |
+
if hasattr(shape, "text") and shape.text and shape.text.strip():
|
| 136 |
+
parts.append(shape.text.strip())
|
| 137 |
+
if shape.has_table if hasattr(shape, "has_table") else False:
|
| 138 |
+
for row in shape.table.rows:
|
| 139 |
+
parts.append(" | ".join(c.text.strip() for c in row.cells))
|
| 140 |
+
parts.append("")
|
| 141 |
+
return "\n".join(parts)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def _extract_image(data: bytes) -> str:
|
| 145 |
+
"""OCR an image. Returns combined text + a note about the image."""
|
| 146 |
+
if Image is None:
|
| 147 |
+
raise RuntimeError("Pillow not installed")
|
| 148 |
+
tesseract = shutil.which("tesseract")
|
| 149 |
+
if not tesseract:
|
| 150 |
+
raise RuntimeError(
|
| 151 |
+
"OCR unavailable: tesseract binary not found on this runtime. "
|
| 152 |
+
"The image was still uploaded and attached by reference."
|
| 153 |
+
)
|
| 154 |
+
try:
|
| 155 |
+
import pytesseract
|
| 156 |
+
except Exception as exc: # pragma: no cover
|
| 157 |
+
raise RuntimeError(f"OCR unavailable: pytesseract import failed: {exc}")
|
| 158 |
+
|
| 159 |
+
image = Image.open(io.BytesIO(data))
|
| 160 |
+
try:
|
| 161 |
+
text = pytesseract.image_to_string(image)
|
| 162 |
+
finally:
|
| 163 |
+
try:
|
| 164 |
+
image.close()
|
| 165 |
+
except Exception:
|
| 166 |
+
pass
|
| 167 |
+
return text
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def _extract_rtf(data: bytes) -> str:
|
| 171 |
+
"""RTF is too messy to hand-parse well; route through LibreOffice if present."""
|
| 172 |
+
return _lo_convert_to_text(data, ".rtf")
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def _extract_legacy_office(data: bytes, ext: str) -> str:
|
| 176 |
+
"""Legacy binary office formats (.xls, .doc) via LibreOffice headless."""
|
| 177 |
+
return _lo_convert_to_text(data, ext)
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def _lo_convert_to_text(data: bytes, ext: str) -> str:
|
| 181 |
+
"""Use LibreOffice headless to convert a document to plain text."""
|
| 182 |
+
soffice = shutil.which("libreoffice") or shutil.which("soffice")
|
| 183 |
+
if not soffice:
|
| 184 |
+
raise RuntimeError(
|
| 185 |
+
"LibreOffice not installed on this runtime; cannot parse this "
|
| 186 |
+
"format here. The file was still uploaded and attached by reference."
|
| 187 |
+
)
|
| 188 |
+
with tempfile.TemporaryDirectory() as tmp:
|
| 189 |
+
in_path = os.path.join(tmp, f"input{ext}")
|
| 190 |
+
out_dir = os.path.join(tmp, "out")
|
| 191 |
+
os.makedirs(out_dir, exist_ok=True)
|
| 192 |
+
with open(in_path, "wb") as f:
|
| 193 |
+
f.write(data)
|
| 194 |
+
result = subprocess.run(
|
| 195 |
+
[soffice, "--headless", "--convert-to", "txt:Text (encoded):UTF8",
|
| 196 |
+
"--outdir", out_dir, in_path],
|
| 197 |
+
capture_output=True,
|
| 198 |
+
timeout=120,
|
| 199 |
+
)
|
| 200 |
+
if result.returncode != 0:
|
| 201 |
+
raise RuntimeError(
|
| 202 |
+
f"LibreOffice failed to convert this file (rc={result.returncode})."
|
| 203 |
+
)
|
| 204 |
+
out_files = os.listdir(out_dir)
|
| 205 |
+
if not out_files:
|
| 206 |
+
raise RuntimeError("LibreOffice produced no output for this file.")
|
| 207 |
+
with open(os.path.join(out_dir, out_files[0]), "r", encoding="utf-8", errors="ignore") as f:
|
| 208 |
+
return f.read()
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
# --- Public dispatcher -------------------------------------------------
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def extract_document_text(filename: str, data: bytes) -> tuple[str, str]:
|
| 215 |
+
"""Extract text from an uploaded document.
|
| 216 |
+
|
| 217 |
+
Returns:
|
| 218 |
+
(extracted_text, warning) — `warning` is a non-empty string when the
|
| 219 |
+
extraction is a degraded fallback, otherwise empty.
|
| 220 |
+
"""
|
| 221 |
+
ext = os.path.splitext(filename or "")[1].lower().lstrip(".")
|
| 222 |
+
try:
|
| 223 |
+
if ext == "pdf":
|
| 224 |
+
text = _extract_pdf(data)
|
| 225 |
+
elif ext in IMAGE_EXTS:
|
| 226 |
+
text = _extract_image(data)
|
| 227 |
+
elif ext in {"xlsx", "xlsm"}:
|
| 228 |
+
text = _extract_xlsx(data)
|
| 229 |
+
elif ext == "xls":
|
| 230 |
+
text = _extract_legacy_office(data, ".xls")
|
| 231 |
+
elif ext == "docx":
|
| 232 |
+
text = _extract_docx(data)
|
| 233 |
+
elif ext == "doc":
|
| 234 |
+
text = _extract_legacy_office(data, ".doc")
|
| 235 |
+
elif ext == "pptx":
|
| 236 |
+
text = _extract_pptx(data)
|
| 237 |
+
elif ext in {"odt", "ods", "odp"}:
|
| 238 |
+
text = _extract_legacy_office(data, f".{ext}")
|
| 239 |
+
elif ext == "rtf":
|
| 240 |
+
text = _extract_rtf(data)
|
| 241 |
+
elif ext in TEXT_EXTS:
|
| 242 |
+
text = data.decode("utf-8", errors="replace")
|
| 243 |
+
else:
|
| 244 |
+
return "", f"Unsupported format '.{ext}' — file uploaded but no text extracted."
|
| 245 |
+
except RuntimeError as e:
|
| 246 |
+
return "", str(e)
|
| 247 |
+
except Exception as e: # pragma: no cover
|
| 248 |
+
return "", f"Failed to extract text from this file: {e}"
|
| 249 |
+
|
| 250 |
+
cleaned = "\n".join(line.rstrip() for line in text.splitlines())
|
| 251 |
+
cleaned = re.sub(r"\n{4,}", "\n\n\n", cleaned).strip()
|
| 252 |
+
|
| 253 |
+
if ext in IMAGE_EXTS:
|
| 254 |
+
note = (
|
| 255 |
+
f"[The image is {len(data):,} bytes. The text below is OCR output. "
|
| 256 |
+
"For layout/diagram questions, refer to the uploaded image file.]\n\n"
|
| 257 |
+
)
|
| 258 |
+
cleaned = note + cleaned
|
| 259 |
+
|
| 260 |
+
if len(cleaned) > MAX_CHARS:
|
| 261 |
+
cleaned = cleaned[:MAX_CHARS] + "\n\n... [TRUNCATED — file content exceeds context limit] ..."
|
| 262 |
+
|
| 263 |
+
return cleaned, ""
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
# --- Dispatch helper for the upload note -------------------------------
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def format_uploaded_document_context(
|
| 270 |
+
*,
|
| 271 |
+
filename: str,
|
| 272 |
+
stored_filename: str,
|
| 273 |
+
repo_id: str,
|
| 274 |
+
path_in_repo: str,
|
| 275 |
+
hub_url: str,
|
| 276 |
+
size_bytes: int,
|
| 277 |
+
file_format: str,
|
| 278 |
+
extracted_text: str,
|
| 279 |
+
warning: str,
|
| 280 |
+
) -> str:
|
| 281 |
+
"""Build the SYSTEM context note injected into the session for the AI."""
|
| 282 |
+
lines = [
|
| 283 |
+
"[SYSTEM: The user uploaded a document/file for this session.",
|
| 284 |
+
"",
|
| 285 |
+
"A text extraction was performed below so you can understand the file's",
|
| 286 |
+
"content directly. If the text is truncated, empty, or you need the",
|
| 287 |
+
"original bytes/format, the original file is preserved on the Hub:",
|
| 288 |
+
"",
|
| 289 |
+
f"- Repo ID: {repo_id}",
|
| 290 |
+
f"- Repo type: dataset",
|
| 291 |
+
f"- File in repo: {path_in_repo}",
|
| 292 |
+
f"- Original filename: {filename}",
|
| 293 |
+
f"- Stored filename: {stored_filename}",
|
| 294 |
+
f"- Format: {file_format}",
|
| 295 |
+
f"- Size: {size_bytes} bytes",
|
| 296 |
+
f"- Hub URL: {hub_url}",
|
| 297 |
+
"",
|
| 298 |
+
]
|
| 299 |
+
if warning:
|
| 300 |
+
lines.extend(
|
| 301 |
+
[
|
| 302 |
+
"WARNING (extraction):",
|
| 303 |
+
warning,
|
| 304 |
+
"",
|
| 305 |
+
"You can still try to load/read the original file from the Hub URL",
|
| 306 |
+
"above, but understand the plain-text extraction did not succeed.",
|
| 307 |
+
"",
|
| 308 |
+
]
|
| 309 |
+
)
|
| 310 |
+
lines.extend(
|
| 311 |
+
[
|
| 312 |
+
"Extracted text content:",
|
| 313 |
+
"```",
|
| 314 |
+
extracted_text if extracted_text else "(no text could be extracted)",
|
| 315 |
+
"```",
|
| 316 |
+
"]",
|
| 317 |
+
]
|
| 318 |
+
)
|
| 319 |
+
return "\n".join(lines)
|
backend/session_logger.py
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Permanent session & user context logger.
|
| 3 |
+
|
| 4 |
+
Lưu vĩnh viễn ngữ cảnh từng phiên và từng user vào dataset HuggingFace ngay khi
|
| 5 |
+
có message/event mới — fire-and-forget, robust, không bao giờ block agent.
|
| 6 |
+
|
| 7 |
+
Lưu vào:
|
| 8 |
+
- ``bep40/ml-intern-sessions`` : dataset chung, lưu tất cả session
|
| 9 |
+
- ``{hf_user}/ml-intern-sessions``: dataset cá nhân mỗi user
|
| 10 |
+
|
| 11 |
+
Mỗi message được lưu ngay lập tức qua subprocess (detached) để tránh blocking.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import asyncio
|
| 17 |
+
import json
|
| 18 |
+
import logging
|
| 19 |
+
import os
|
| 20 |
+
import subprocess
|
| 21 |
+
import sys
|
| 22 |
+
import time
|
| 23 |
+
from datetime import datetime
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
from typing import Optional
|
| 26 |
+
|
| 27 |
+
from huggingface_hub import HfApi, hf_hub_download
|
| 28 |
+
|
| 29 |
+
logger = logging.getLogger(__name__)
|
| 30 |
+
|
| 31 |
+
PUBLIC_SESSIONS_DATASET = os.environ.get(
|
| 32 |
+
"ML_INTERN_SESSIONS_DATASET", "bep40/ml-intern-sessions"
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
DEFAULT_LOG_DIR = Path(os.environ.get("ML_INTERN_SESSION_LOG_DIR", "/tmp/session_logs"))
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _now_iso() -> str:
|
| 39 |
+
return datetime.now().astimezone().isoformat()
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _get_hf_token() -> Optional[str]:
|
| 43 |
+
return (
|
| 44 |
+
os.environ.get("HF_TOKEN")
|
| 45 |
+
or os.environ.get("_ML_INTERN_PERSONAL_TOKEN")
|
| 46 |
+
or os.environ.get("HF_SESSION_UPLOAD_TOKEN")
|
| 47 |
+
or os.environ.get("HF_ADMIN_TOKEN")
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _dataset_card_readme() -> str:
|
| 52 |
+
return """---
|
| 53 |
+
pretty_name: "ML Intern Session Context"
|
| 54 |
+
language:
|
| 55 |
+
- en
|
| 56 |
+
license: other
|
| 57 |
+
task_categories:
|
| 58 |
+
- text-generation
|
| 59 |
+
tags:
|
| 60 |
+
- ml-intern
|
| 61 |
+
- session-traces
|
| 62 |
+
- conversation
|
| 63 |
+
- agent-traces
|
| 64 |
+
configs:
|
| 65 |
+
- config_name: default
|
| 66 |
+
data_files:
|
| 67 |
+
- split: train
|
| 68 |
+
path: "sessions/**/*.jsonl"
|
| 69 |
+
---
|
| 70 |
+
|
| 71 |
+
# ML Intern Session Context
|
| 72 |
+
|
| 73 |
+
**Lưu trữ vĩnh viễn ngữ cảnh từng phiên và từng user.**
|
| 74 |
+
|
| 75 |
+
Mỗi message, tool call, event được lưu ngay khi xảy ra để đảm bảo không bao giờ mất dữ liệu.
|
| 76 |
+
|
| 77 |
+
## Schema
|
| 78 |
+
|
| 79 |
+
| Column | Type | Mô tả |
|
| 80 |
+
|----------------|--------|------------------------------------------|
|
| 81 |
+
| session_id | string | UUID của session |
|
| 82 |
+
| user_id | string | ID người dùng |
|
| 83 |
+
| model_name | string | Model đang dùng |
|
| 84 |
+
| timestamp | string | ISO timestamp |
|
| 85 |
+
| role | string | user / assistant / tool / system / event |
|
| 86 |
+
| content | string | Nội dung message |
|
| 87 |
+
| event_type | string | Loại event |
|
| 88 |
+
| seq | int | Số thứ tự trong session |
|
| 89 |
+
| extra | string | JSON thông tin bổ sung |
|
| 90 |
+
"""
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _append_to_local_log(
|
| 94 |
+
session_id: str,
|
| 95 |
+
user_id: str,
|
| 96 |
+
model_name: str,
|
| 97 |
+
role: str,
|
| 98 |
+
content: str,
|
| 99 |
+
event_type: str,
|
| 100 |
+
seq: int,
|
| 101 |
+
extra: dict | None = None,
|
| 102 |
+
directory: str = str(DEFAULT_LOG_DIR),
|
| 103 |
+
) -> Optional[str]:
|
| 104 |
+
try:
|
| 105 |
+
log_dir = Path(directory)
|
| 106 |
+
log_dir.mkdir(parents=True, exist_ok=True)
|
| 107 |
+
row = {
|
| 108 |
+
"session_id": session_id,
|
| 109 |
+
"user_id": user_id,
|
| 110 |
+
"model_name": model_name,
|
| 111 |
+
"timestamp": _now_iso(),
|
| 112 |
+
"role": role,
|
| 113 |
+
"content": content,
|
| 114 |
+
"event_type": event_type,
|
| 115 |
+
"seq": seq,
|
| 116 |
+
"extra": json.dumps(extra) if extra else "{}",
|
| 117 |
+
}
|
| 118 |
+
date_str = datetime.now().strftime("%Y-%m-%d")
|
| 119 |
+
filename = f"session_{session_id}_{date_str}.jsonl"
|
| 120 |
+
filepath = log_dir / filename
|
| 121 |
+
with open(filepath, "a") as f:
|
| 122 |
+
f.write(json.dumps(row, default=str) + "\n")
|
| 123 |
+
return str(filepath)
|
| 124 |
+
except Exception as e:
|
| 125 |
+
logger.debug("Local log write failed: %s", e)
|
| 126 |
+
return None
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
# ─── Dataset reader: per-file scan (never loads 410MB into memory) ──────────
|
| 130 |
+
|
| 131 |
+
_file_list_cache: list[str] | None = None
|
| 132 |
+
_file_list_cache_time: float = 0
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _list_dataset_files() -> list[str]:
|
| 136 |
+
"""Return all .jsonl file paths in the sessions dataset (cached, 60s TTL)."""
|
| 137 |
+
global _file_list_cache, _file_list_cache_time
|
| 138 |
+
now = time.time()
|
| 139 |
+
if _file_list_cache is not None and (now - _file_list_cache_time) < 60:
|
| 140 |
+
return _file_list_cache
|
| 141 |
+
try:
|
| 142 |
+
api = HfApi(token=_get_hf_token())
|
| 143 |
+
files = api.list_repo_files(PUBLIC_SESSIONS_DATASET, repo_type="dataset")
|
| 144 |
+
_file_list_cache = [f for f in files if f.endswith(".jsonl")]
|
| 145 |
+
_file_list_cache_time = now
|
| 146 |
+
except Exception as e:
|
| 147 |
+
logger.warning("Failed to list dataset files: %s", e)
|
| 148 |
+
_file_list_cache = []
|
| 149 |
+
_file_list_cache_time = now
|
| 150 |
+
return _file_list_cache
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def _normalize_row(row: dict) -> dict | None:
|
| 154 |
+
"""Normalize a row to the standard schema regardless of source format.
|
| 155 |
+
|
| 156 |
+
The dataset contains two incompatible formats:
|
| 157 |
+
- *New* (session_logger.py): session_id, user_id, role, content, event_type, seq
|
| 158 |
+
- *Old* (Claude Code trace format): type, message, uuid, parentUuid, sessionId, timestamp
|
| 159 |
+
"""
|
| 160 |
+
if not isinstance(row, dict):
|
| 161 |
+
return None
|
| 162 |
+
|
| 163 |
+
# Already in new format — has session_id + role
|
| 164 |
+
if "session_id" in row and "role" in row:
|
| 165 |
+
return row
|
| 166 |
+
|
| 167 |
+
# Old Claude Code format: type + message
|
| 168 |
+
if "type" in row and "message" in row:
|
| 169 |
+
session_id = row.get("sessionId") or row.get("session_id") or ""
|
| 170 |
+
if not session_id:
|
| 171 |
+
return None
|
| 172 |
+
msg = row.get("message")
|
| 173 |
+
role = row.get("type", "unknown")
|
| 174 |
+
content_text = ""
|
| 175 |
+
model_name = ""
|
| 176 |
+
if isinstance(msg, dict):
|
| 177 |
+
role = msg.get("role", role)
|
| 178 |
+
content_val = msg.get("content", "")
|
| 179 |
+
model_name = msg.get("model", "")
|
| 180 |
+
if isinstance(content_val, str):
|
| 181 |
+
content_text = content_val
|
| 182 |
+
elif isinstance(content_val, list):
|
| 183 |
+
parts = []
|
| 184 |
+
for block in content_val:
|
| 185 |
+
if isinstance(block, dict):
|
| 186 |
+
t = block.get("text") or block.get("content")
|
| 187 |
+
if isinstance(t, str):
|
| 188 |
+
parts.append(t)
|
| 189 |
+
elif isinstance(block, str):
|
| 190 |
+
parts.append(block)
|
| 191 |
+
content_text = " ".join(parts)
|
| 192 |
+
elif content_val is None:
|
| 193 |
+
content_text = ""
|
| 194 |
+
return {
|
| 195 |
+
"session_id": session_id,
|
| 196 |
+
"user_id": "",
|
| 197 |
+
"model_name": model_name,
|
| 198 |
+
"timestamp": row.get("timestamp", ""),
|
| 199 |
+
"role": role,
|
| 200 |
+
"content": content_text,
|
| 201 |
+
"event_type": row.get("type", "message"),
|
| 202 |
+
"seq": 0,
|
| 203 |
+
"extra": "{}",
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
return None
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def _read_jsonl_file(file_path: str) -> list[dict]:
|
| 210 |
+
"""Download a single JSONL file from the dataset and parse its lines.
|
| 211 |
+
|
| 212 |
+
Robust to: empty files, malformed JSON lines, old/new format mismatch.
|
| 213 |
+
"""
|
| 214 |
+
try:
|
| 215 |
+
local = hf_hub_download(
|
| 216 |
+
PUBLIC_SESSIONS_DATASET,
|
| 217 |
+
file_path,
|
| 218 |
+
repo_type="dataset",
|
| 219 |
+
token=_get_hf_token(),
|
| 220 |
+
)
|
| 221 |
+
rows: list[dict] = []
|
| 222 |
+
with open(local, encoding="utf-8", errors="replace") as fh:
|
| 223 |
+
for line in fh:
|
| 224 |
+
line = line.strip()
|
| 225 |
+
if not line:
|
| 226 |
+
continue
|
| 227 |
+
try:
|
| 228 |
+
row = json.loads(line)
|
| 229 |
+
normalized = _normalize_row(row)
|
| 230 |
+
if normalized is not None:
|
| 231 |
+
rows.append(normalized)
|
| 232 |
+
except json.JSONDecodeError:
|
| 233 |
+
continue
|
| 234 |
+
return rows
|
| 235 |
+
except Exception as e:
|
| 236 |
+
logger.debug("Failed to read %s: %s", file_path, e)
|
| 237 |
+
return []
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def load_session_history(session_id: str) -> list[dict]:
|
| 241 |
+
"""Load all messages/events for a session from the dataset.
|
| 242 |
+
|
| 243 |
+
Reads only the files whose name contains ``session_id`` instead of
|
| 244 |
+
loading the entire 400MB dataset into memory on every call.
|
| 245 |
+
"""
|
| 246 |
+
try:
|
| 247 |
+
all_files = _list_dataset_files()
|
| 248 |
+
matching = [
|
| 249 |
+
f for f in all_files if session_id in os.path.basename(f)
|
| 250 |
+
]
|
| 251 |
+
rows: list[dict] = []
|
| 252 |
+
for f in matching:
|
| 253 |
+
for row in _read_jsonl_file(f):
|
| 254 |
+
if row.get("session_id") == session_id:
|
| 255 |
+
rows.append(row)
|
| 256 |
+
rows.sort(key=lambda r: (r.get("seq", 0), r.get("timestamp", "")))
|
| 257 |
+
return rows
|
| 258 |
+
except Exception as e:
|
| 259 |
+
import traceback
|
| 260 |
+
logger.warning(
|
| 261 |
+
"Failed to load session history: %s\n%s",
|
| 262 |
+
e,
|
| 263 |
+
traceback.format_exc(),
|
| 264 |
+
)
|
| 265 |
+
return []
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def get_user_sessions(user_id: str, limit: int = 50) -> list[dict]:
|
| 269 |
+
"""Get all sessions for a user from the dataset.
|
| 270 |
+
|
| 271 |
+
Uses per-file scanning exclusively (never calls load_dataset which can
|
| 272 |
+
fail when the dataset has incompatible/mixed schemas). Cached file list
|
| 273 |
+
(60s TTL) avoids repeated list_repo_files calls.
|
| 274 |
+
"""
|
| 275 |
+
sessions: dict[str, dict] = {}
|
| 276 |
+
for f in _list_dataset_files():
|
| 277 |
+
for row in _read_jsonl_file(f):
|
| 278 |
+
if row.get("user_id") == user_id:
|
| 279 |
+
sid = row.get("session_id")
|
| 280 |
+
if sid and sid not in sessions:
|
| 281 |
+
sessions[sid] = {
|
| 282 |
+
"session_id": sid,
|
| 283 |
+
"user_id": user_id,
|
| 284 |
+
"model_name": row.get("model_name"),
|
| 285 |
+
"last_timestamp": row.get("timestamp"),
|
| 286 |
+
"message_count": 0,
|
| 287 |
+
}
|
| 288 |
+
sessions[sid]["message_count"] += 1
|
| 289 |
+
|
| 290 |
+
result = list(sessions.values())
|
| 291 |
+
result.sort(key=lambda s: s.get("last_timestamp", ""), reverse=True)
|
| 292 |
+
return result[:limit]
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
# ─── Upload/subprocess functions (unchanged from original) ──────────────────
|
| 296 |
+
|
| 297 |
+
def _spawn_uploader(
|
| 298 |
+
row_data: dict,
|
| 299 |
+
repo_id: str,
|
| 300 |
+
token: str,
|
| 301 |
+
private: bool = False,
|
| 302 |
+
) -> None:
|
| 303 |
+
"""Spawn detached subprocess to upload a single row to HF dataset."""
|
| 304 |
+
try:
|
| 305 |
+
script = f"""
|
| 306 |
+
import json, sys, time, os
|
| 307 |
+
from huggingface_hub import HfApi
|
| 308 |
+
|
| 309 |
+
row_data = {json.dumps(row_data)}
|
| 310 |
+
repo_id = "{repo_id}"
|
| 311 |
+
token = "{token}"
|
| 312 |
+
private = {private}
|
| 313 |
+
|
| 314 |
+
api = HfApi()
|
| 315 |
+
try:
|
| 316 |
+
api.create_repo(repo_id=repo_id, repo_type="dataset", private=private, token=token, exist_ok=True)
|
| 317 |
+
except: pass
|
| 318 |
+
|
| 319 |
+
# Upload README
|
| 320 |
+
try:
|
| 321 |
+
api.upload_file(path_or_fileobj={_dataset_card_readme()!r}.encode(), path_in_repo="README.md", repo_id=repo_id, repo_type="dataset", token=token)
|
| 322 |
+
except: pass
|
| 323 |
+
|
| 324 |
+
date_str = row_data.get("timestamp", "")[:10]
|
| 325 |
+
session_id = row_data.get("session_id", "unknown")
|
| 326 |
+
seq = row_data.get("seq", 0)
|
| 327 |
+
path_in_repo = f"sessions/{{date_str}}/{{session_id}}_{{seq}}.jsonl"
|
| 328 |
+
payload = json.dumps(row_data)
|
| 329 |
+
|
| 330 |
+
for attempt in range(5):
|
| 331 |
+
try:
|
| 332 |
+
api.upload_file(path_or_fileobj=payload.encode(), path_in_repo=path_in_repo, repo_id=repo_id, repo_type="dataset", token=token, commit_message=f"Add session {{session_id[:8]}} seq {{seq}}")
|
| 333 |
+
break
|
| 334 |
+
except Exception as e:
|
| 335 |
+
if attempt < 4:
|
| 336 |
+
time.sleep(2 ** attempt)
|
| 337 |
+
else:
|
| 338 |
+
sys.exit(1)
|
| 339 |
+
"""
|
| 340 |
+
subprocess.Popen(
|
| 341 |
+
[sys.executable, "-c", script],
|
| 342 |
+
stdin=subprocess.DEVNULL,
|
| 343 |
+
stdout=subprocess.DEVNULL,
|
| 344 |
+
stderr=subprocess.DEVNULL,
|
| 345 |
+
env=os.environ.copy(),
|
| 346 |
+
start_new_session=True,
|
| 347 |
+
)
|
| 348 |
+
except Exception as e:
|
| 349 |
+
logger.debug("Upload spawn failed: %s", e)
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def log_message(
|
| 353 |
+
session_id: str,
|
| 354 |
+
user_id: str,
|
| 355 |
+
model_name: str,
|
| 356 |
+
role: str,
|
| 357 |
+
content: str,
|
| 358 |
+
event_type: str = "message",
|
| 359 |
+
seq: int = 0,
|
| 360 |
+
extra: dict | None = None,
|
| 361 |
+
hf_user: str | None = None,
|
| 362 |
+
) -> None:
|
| 363 |
+
"""Log a single message/event permanently to dataset.
|
| 364 |
+
|
| 365 |
+
Fire-and-forget — all I/O is offloaded to a detached subprocess.
|
| 366 |
+
"""
|
| 367 |
+
token = _get_hf_token()
|
| 368 |
+
|
| 369 |
+
# Always write to local log first (backup)
|
| 370 |
+
_append_to_local_log(
|
| 371 |
+
session_id=session_id,
|
| 372 |
+
user_id=user_id,
|
| 373 |
+
model_name=model_name,
|
| 374 |
+
role=role,
|
| 375 |
+
content=content,
|
| 376 |
+
event_type=event_type,
|
| 377 |
+
seq=seq,
|
| 378 |
+
extra=extra,
|
| 379 |
+
)
|
| 380 |
+
|
| 381 |
+
if not token:
|
| 382 |
+
logger.debug("No HF token, skipping dataset upload")
|
| 383 |
+
return
|
| 384 |
+
|
| 385 |
+
row_data = {
|
| 386 |
+
"session_id": session_id,
|
| 387 |
+
"user_id": user_id,
|
| 388 |
+
"model_name": model_name,
|
| 389 |
+
"timestamp": _now_iso(),
|
| 390 |
+
"role": role,
|
| 391 |
+
"content": content,
|
| 392 |
+
"event_type": event_type,
|
| 393 |
+
"seq": seq,
|
| 394 |
+
"extra": json.dumps(extra) if extra else "{}",
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
# Upload to public/shared dataset
|
| 398 |
+
_spawn_uploader(row_data, PUBLIC_SESSIONS_DATASET, token, private=False)
|
| 399 |
+
|
| 400 |
+
# Upload to personal dataset if available
|
| 401 |
+
if hf_user and hf_user != "dev":
|
| 402 |
+
_spawn_uploader(row_data, f"{hf_user}/ml-intern-sessions", token, private=True)
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
def log_trajectory(
|
| 406 |
+
trajectory: dict,
|
| 407 |
+
user_id: str,
|
| 408 |
+
hf_user: str | None = None,
|
| 409 |
+
hf_token: str | None = None,
|
| 410 |
+
private: bool = False,
|
| 411 |
+
) -> Optional[str]:
|
| 412 |
+
"""Upload complete session trajectory to dataset (final flush)."""
|
| 413 |
+
session_id = trajectory.get("session_id", "unknown")
|
| 414 |
+
model_name = trajectory.get("model_name", "")
|
| 415 |
+
messages = trajectory.get("messages", [])
|
| 416 |
+
|
| 417 |
+
log_dir = Path(str(DEFAULT_LOG_DIR))
|
| 418 |
+
log_dir.mkdir(parents=True, exist_ok=True)
|
| 419 |
+
date_str = datetime.now().strftime("%Y-%m-%d")
|
| 420 |
+
filepath = log_dir / f"session_{session_id}_{date_str}.jsonl"
|
| 421 |
+
|
| 422 |
+
with open(filepath, "w") as f:
|
| 423 |
+
for msg in messages:
|
| 424 |
+
row = {
|
| 425 |
+
"session_id": session_id,
|
| 426 |
+
"user_id": user_id,
|
| 427 |
+
"model_name": model_name,
|
| 428 |
+
"timestamp": msg.get("timestamp") or _now_iso(),
|
| 429 |
+
"role": msg.get("role", "unknown"),
|
| 430 |
+
"content": msg.get("content", ""),
|
| 431 |
+
"event_type": "message",
|
| 432 |
+
"seq": msg.get("seq", 0),
|
| 433 |
+
"extra": json.dumps({
|
| 434 |
+
"tool_calls": msg.get("tool_calls"),
|
| 435 |
+
"tool_call_id": msg.get("tool_call_id"),
|
| 436 |
+
}) if any(k in msg for k in ("tool_calls", "tool_call_id")) else "{}",
|
| 437 |
+
}
|
| 438 |
+
f.write(json.dumps(row, default=str) + "\n")
|
| 439 |
+
|
| 440 |
+
events = trajectory.get("events", [])
|
| 441 |
+
for event in events:
|
| 442 |
+
row = {
|
| 443 |
+
"session_id": session_id,
|
| 444 |
+
"user_id": user_id,
|
| 445 |
+
"model_name": model_name,
|
| 446 |
+
"timestamp": event.get("timestamp", _now_iso()),
|
| 447 |
+
"role": "event",
|
| 448 |
+
"content": json.dumps(event.get("data", {})),
|
| 449 |
+
"event_type": event.get("event_type", "unknown"),
|
| 450 |
+
"seq": event.get("seq", 0),
|
| 451 |
+
"extra": "{}",
|
| 452 |
+
}
|
| 453 |
+
f.write(json.dumps(row, default=str) + "\n")
|
| 454 |
+
|
| 455 |
+
token = hf_token or _get_hf_token()
|
| 456 |
+
if token:
|
| 457 |
+
with open(filepath, "r") as f:
|
| 458 |
+
for line in f:
|
| 459 |
+
try:
|
| 460 |
+
row = json.loads(line.strip())
|
| 461 |
+
_spawn_uploader(row, PUBLIC_SESSIONS_DATASET, token, private)
|
| 462 |
+
if hf_user and hf_user != "dev":
|
| 463 |
+
_spawn_uploader(row, f"{hf_user}/ml-intern-sessions", token, True)
|
| 464 |
+
except Exception:
|
| 465 |
+
continue
|
| 466 |
+
|
| 467 |
+
return str(filepath)
|
configs/frontend_agent_config.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"model_name": "openai/tencent/hy3:free",
|
| 3 |
+
"api_base": "https://openrouter.ai/api/v1",
|
| 4 |
+
"save_sessions": true,
|
| 5 |
+
"personal_trace_repo_template": "{hf_user}/ml-intern-sessions",
|
| 6 |
+
"share_traces": true,
|
| 7 |
+
"yolo_mode": false,
|
| 8 |
+
"confirm_cpu_jobs": true,
|
| 9 |
+
"auto_file_upload": true,
|
| 10 |
+
"auto_save_interval": 1,
|
| 11 |
+
"heartbeat_interval_s": 30,
|
| 12 |
+
"max_iterations": 300,
|
| 13 |
+
"mcpServers": {
|
| 14 |
+
"hf-mcp-server": {
|
| 15 |
+
"transport": "http",
|
| 16 |
+
"url": "https://openrouter.ai/mcp"
|
| 17 |
+
}
|
| 18 |
+
}
|
| 19 |
+
}
|
fix_space_error_report.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"status": "rebuilding_with_deepseek_flash_fix",
|
| 3 |
+
"space": "bep40/ml-intern",
|
| 4 |
+
"space_url": "https://huggingface.co/spaces/bep40/ml-intern",
|
| 5 |
+
"root_cause": "DeepSeek V4 Flash model IDs were routed incorrectly: (1) GLM_52_MODEL_ID was set to 'openai/deepseek/deepseek-v4-flash' which the OpenRouter routing patch sends to OpenRouter, but OpenRouter model IDs do not include the 'openai/' prefix -- causing a 404 model-not-found. (2) The routing prefix check startswith('deepseek/') never matches 'deepseek-ai/' model IDs, so 'deepseek-ai/DeepSeek-V4-Flash' without a provider tag relied on HF Router auto-routing which could hit a provider lacking structured output support.",
|
| 6 |
+
"fix": "Changed GLM_52_MODEL_ID from 'openai/deepseek/deepseek-v4-flash' to 'deepseek-ai/DeepSeek-V4-Flash:deepinfra' (HF Router path with explicit provider tag). Also fixed the OpenRouter routing patch to strip the 'openai/' prefix before sending the model name to OpenRouter's API. Added ':novita' tag to DeepSeek V4 Pro for deterministic HF Router routing.",
|
| 7 |
+
"files_changed": ["patch_models.py"],
|
| 8 |
+
"model_routing": {
|
| 9 |
+
"deepseek-ai/DeepSeek-V4-Flash": "HF Inference Router (3 providers: novita, fireworks-ai, deepinfra) - all support tools, 1M context",
|
| 10 |
+
"deepseek-ai/DeepSeek-V4-Flash-0731": "HF Inference Router (5 providers) - all support tools, 1M context",
|
| 11 |
+
"deepseek/deepseek-v4-flash-0731": "OpenRouter - available via openrouter/deepseek/deepseek-v4-flash-0731"
|
| 12 |
+
},
|
| 13 |
+
"verification": "Space rebuild triggered. Monitor Space build logs. Test model selection for DeepSeek V4 Flash after rebuild completes."
|
| 14 |
+
}
|
patch_agent_backend.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Patch: agent_loop.py task detection + auto-approve for free models."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import re
|
| 5 |
+
import ast
|
| 6 |
+
|
| 7 |
+
AGENT_LOOP = "/app/agent/core/agent_loop.py"
|
| 8 |
+
AGENT_FILE = "/app/backend/routes/agent.py"
|
| 9 |
+
SESSION_FILE = "/app/agent/core/session.py"
|
| 10 |
+
|
| 11 |
+
# ===== PATCH 1: agent_loop.py - detect model stopped mid-task =====
|
| 12 |
+
with open(AGENT_LOOP) as f:
|
| 13 |
+
c = f.read()
|
| 14 |
+
|
| 15 |
+
# Add helper function before class Handlers
|
| 16 |
+
if "_unfinished_plan" not in c:
|
| 17 |
+
fn = (
|
| 18 |
+
"\n\ndef _unfinished_plan(s):\n"
|
| 19 |
+
" p = getattr(s, 'current_plan', None) or []\n"
|
| 20 |
+
" return [it for it in p if it.get('status') in ('pending', 'in_progress')]\n\n"
|
| 21 |
+
)
|
| 22 |
+
c = c.replace("class Handlers:", fn + "class Handlers:")
|
| 23 |
+
print("OK: Added _unfinished_plan()")
|
| 24 |
+
|
| 25 |
+
# Add detection: if model produced no tool calls but content still exists,
|
| 26 |
+
# and plan items are still pending/in_progress, send session_update event
|
| 27 |
+
if "ac_plan" not in c:
|
| 28 |
+
check = (
|
| 29 |
+
"\n # Auto-continue: detect model stopped mid-task\n"
|
| 30 |
+
" if not llm_result.tool_calls_acc and llm_result.content:\n"
|
| 31 |
+
" unfinished = _unfinished_plan(session)\n"
|
| 32 |
+
" if unfinished:\n"
|
| 33 |
+
" await session.send_event(Event(event_type='session_update', data={\n"
|
| 34 |
+
" 'ac_plan': unfinished,\n"
|
| 35 |
+
" }))\n"
|
| 36 |
+
)
|
| 37 |
+
c = c.replace(" # -- End of turn --", check + " # -- End of turn --")
|
| 38 |
+
print("OK: Added mid-task detection")
|
| 39 |
+
|
| 40 |
+
try:
|
| 41 |
+
ast.parse(c)
|
| 42 |
+
with open(AGENT_LOOP, "w") as f:
|
| 43 |
+
f.write(c)
|
| 44 |
+
print("OK: agent_loop.py patched")
|
| 45 |
+
except SyntaxError as e:
|
| 46 |
+
print(f"FAIL: agent_loop.py syntax: {e}")
|
| 47 |
+
|
| 48 |
+
# ===== PATCH 2: Enable auto_approval for ALL sessions by default =====
|
| 49 |
+
# So free models don't block on user approval.
|
| 50 |
+
with open(SESSION_FILE) as f:
|
| 51 |
+
c = f.read()
|
| 52 |
+
|
| 53 |
+
if "AUTO_APPROVE" not in c and "self.auto_approval" not in c:
|
| 54 |
+
auto_patch = (
|
| 55 |
+
"\n # === PATCH: Auto-approve for free models ===\n"
|
| 56 |
+
" self.auto_approval = {\n"
|
| 57 |
+
' "enabled": True,\n'
|
| 58 |
+
' "cost_cap_usd": None,\n'
|
| 59 |
+
' "estimated_spend_usd": 0.0,\n'
|
| 60 |
+
' "remaining_usd": None,\n'
|
| 61 |
+
" }\n"
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
# Insert as the FIRST STATEMENT of __init__ body: find the closing
|
| 65 |
+
# " ):" that terminates the def __init__(...) parameter list and
|
| 66 |
+
# insert right after it. (Inserting into the parameter list corrupts
|
| 67 |
+
# the file with invalid syntax.)
|
| 68 |
+
init_match = re.search(r"def __init__\(.*?\):\n", c, re.DOTALL)
|
| 69 |
+
if init_match:
|
| 70 |
+
insert_at = init_match.end()
|
| 71 |
+
c = c[:insert_at] + auto_patch + c[insert_at:]
|
| 72 |
+
print("OK: Added auto_approve default")
|
| 73 |
+
else:
|
| 74 |
+
print("WARN: could not locate __init__ signature in session.py")
|
| 75 |
+
|
| 76 |
+
try:
|
| 77 |
+
ast.parse(c)
|
| 78 |
+
with open(SESSION_FILE, "w") as f:
|
| 79 |
+
f.write(c)
|
| 80 |
+
print("OK: session.py patched & syntactically valid")
|
| 81 |
+
except SyntaxError as e:
|
| 82 |
+
print(f"FAIL: session.py syntax: {e}")
|
| 83 |
+
# Don't block on this patch
|
| 84 |
+
else:
|
| 85 |
+
print("OK: session.py already has auto_approval")
|
| 86 |
+
|
| 87 |
+
print("DONE: Backend patches applied")
|
patch_agent_loop.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Patch agent_loop.py - detect task incomplete and send event."""
|
| 2 |
+
|
| 3 |
+
AGENT_LOOP = "/app/agent/core/agent_loop.py"
|
| 4 |
+
|
| 5 |
+
def patch():
|
| 6 |
+
import os
|
| 7 |
+
if not os.path.exists(AGENT_LOOP):
|
| 8 |
+
print(f"SKIP: {AGENT_LOOP} not found")
|
| 9 |
+
return
|
| 10 |
+
|
| 11 |
+
with open(AGENT_LOOP, "r", encoding="utf-8") as f:
|
| 12 |
+
content = f.read()
|
| 13 |
+
|
| 14 |
+
# Add _check_task_incomplete function before class Handlers
|
| 15 |
+
check_func = '''
|
| 16 |
+
|
| 17 |
+
def _check_task_incomplete(session: Session, llm_result: LLMResult) -> bool:
|
| 18 |
+
"""Check if model stopped streaming but task is incomplete."""
|
| 19 |
+
if llm_result.tool_calls_acc:
|
| 20 |
+
return False # Has tool calls, task continues via tool execution
|
| 21 |
+
|
| 22 |
+
plan = getattr(session, "current_plan", None) or []
|
| 23 |
+
unfinished = [item for item in plan if item.get("status") in ("pending", "in_progress")]
|
| 24 |
+
|
| 25 |
+
return len(unfinished) > 0
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _unfinished_plan_items(session: Session) -> list[dict[str, str]]:
|
| 29 |
+
"""Helper to get unfinished plan items."""
|
| 30 |
+
plan = getattr(session, "current_plan", None) or []
|
| 31 |
+
return [item for item in plan if item.get("status") in ("pending", "in_progress")]
|
| 32 |
+
|
| 33 |
+
'''
|
| 34 |
+
|
| 35 |
+
if "_check_task_incomplete" not in content:
|
| 36 |
+
content = content.replace(
|
| 37 |
+
"class Handlers:",
|
| 38 |
+
check_func + "\n\nclass Handlers:"
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
# Add the check after LLM response processing
|
| 42 |
+
check_call = '''
|
| 43 |
+
# === Check for incomplete task after LLM response ===
|
| 44 |
+
if not llm_result.tool_calls_acc and llm_result.content:
|
| 45 |
+
unfinished = _unfinished_plan_items(session)
|
| 46 |
+
if unfinished:
|
| 47 |
+
await session.send_event(
|
| 48 |
+
Event(
|
| 49 |
+
event_type="task_incomplete",
|
| 50 |
+
data={
|
| 51 |
+
"incomplete_plan": unfinished,
|
| 52 |
+
"message": "Model phản hồi đã dừng nhưng nhiệm vụ chưa hoàn thành."
|
| 53 |
+
}
|
| 54 |
+
)
|
| 55 |
+
)
|
| 56 |
+
'''
|
| 57 |
+
|
| 58 |
+
if "task_incomplete" not in content:
|
| 59 |
+
# Find a good insertion point - after tool calls processing
|
| 60 |
+
insert_marker = " # -- End of turn --"
|
| 61 |
+
if insert_marker in content:
|
| 62 |
+
content = content.replace(insert_marker, check_call + "\n" + insert_marker)
|
| 63 |
+
else:
|
| 64 |
+
# Try another location
|
| 65 |
+
result_marker = "final_response = llm_result.content or None"
|
| 66 |
+
if result_marker in content:
|
| 67 |
+
content = content.replace(result_marker, result_marker + "\n" + check_call)
|
| 68 |
+
|
| 69 |
+
with open(AGENT_LOOP, "w", encoding="utf-8") as f:
|
| 70 |
+
f.write(content)
|
| 71 |
+
print(f"OK: Patched {AGENT_LOOP}")
|
| 72 |
+
|
| 73 |
+
if __name__ == "__main__":
|
| 74 |
+
patch()
|
patch_apodex_ling_beatapi.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Patch backend: add apodex-1.1-mini:free, ling-3.1-flash (OpenRouter) + BeatAPI space-bunny-alpha.
|
| 2 |
+
|
| 3 |
+
Two OpenRouter models (same `openai/` catch-all routing as the rest of the app):
|
| 4 |
+
- openai/apodex/apodex-1.1-mini:free
|
| 5 |
+
- openai/inclusionai/ling-3.1-flash
|
| 6 |
+
|
| 7 |
+
One BEATAPI model routed to a *different* provider entirely:
|
| 8 |
+
- beatapi/space-bunny-alpha
|
| 9 |
+
|
| 10 |
+
The BeatAPI id deliberately uses a `beatapi/` prefix (NOT `openai/`) so it can
|
| 11 |
+
never be captured by the OpenRouter catch-all branch in _resolve_llm_params.
|
| 12 |
+
See patch_beatapi_routing.py for the dedicated routing branch.
|
| 13 |
+
|
| 14 |
+
This runs AFTER patch_models.py / patch_new_models.py, which already injected
|
| 15 |
+
the `openai/`-prefixed constants and the LING_3_0_FLASH_FREE_MODEL_ID anchor.
|
| 16 |
+
Idempotent: re-running is a no-op.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import ast
|
| 20 |
+
import re
|
| 21 |
+
|
| 22 |
+
IDS_FILE = "/app/agent/core/model_ids.py"
|
| 23 |
+
AGENT_FILE = "/app/backend/routes/agent.py"
|
| 24 |
+
|
| 25 |
+
# ---------- 1. model_ids.py ----------
|
| 26 |
+
with open(IDS_FILE) as f:
|
| 27 |
+
ids = f.read()
|
| 28 |
+
|
| 29 |
+
new_constants = (
|
| 30 |
+
'APODEX_1_1_MINI_FREE_MODEL_ID = "openai/apodex/apodex-1.1-mini:free"\n'
|
| 31 |
+
'LING_3_1_FLASH_MODEL_ID = "openai/inclusionai/ling-3.1-flash"\n'
|
| 32 |
+
'# NOTE: BeatAPI model. NOT an OpenRouter id: `beatapi/` routes to\n'
|
| 33 |
+
'# https://api.beatapi.io/v1 and is authenticated with BEATAPI_API_KEY\n'
|
| 34 |
+
'# (see patch_beatapi_routing.py). Keep this prefix exclusive to BeatAPI.\n'
|
| 35 |
+
'BEATAPI_SPACE_BUNNY_ALPHA_MODEL_ID = "beatapi/space-bunny-alpha"\n'
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
missing = [
|
| 39 |
+
name
|
| 40 |
+
for name in (
|
| 41 |
+
"APODEX_1_1_MINI_FREE_MODEL_ID",
|
| 42 |
+
"LING_3_1_FLASH_MODEL_ID",
|
| 43 |
+
"BEATAPI_SPACE_BUNNY_ALPHA_MODEL_ID",
|
| 44 |
+
)
|
| 45 |
+
if name not in ids
|
| 46 |
+
]
|
| 47 |
+
|
| 48 |
+
if not missing:
|
| 49 |
+
print("OK: model_ids.py already has all 3 new constants (idempotent)")
|
| 50 |
+
else:
|
| 51 |
+
# Anchor: the LING_3_0_FLASH line injected by patch_new_models.py.
|
| 52 |
+
# Fall back to a generic OpenRouter constant line from patch_models.py.
|
| 53 |
+
anchors = [
|
| 54 |
+
'LING_3_0_FLASH_FREE_MODEL_ID = "openai/inclusionai/ling-3.0-flash:free"',
|
| 55 |
+
'NEX_N2_MINI_MODEL_ID = "openai/nex-agi/nex-n2-mini"',
|
| 56 |
+
]
|
| 57 |
+
inserted = False
|
| 58 |
+
for anchor in anchors:
|
| 59 |
+
if anchor in ids:
|
| 60 |
+
ids = ids.replace(anchor, anchor + "\n" + new_constants, 1)
|
| 61 |
+
print(f"OK: added constants to model_ids.py after {anchor.split(' =')[0]}")
|
| 62 |
+
inserted = True
|
| 63 |
+
break
|
| 64 |
+
if not inserted:
|
| 65 |
+
# Last resort: append right after the HOSTED_MODEL_IDS set closes, which
|
| 66 |
+
# is a stable upstream anchor present in every revision.
|
| 67 |
+
m = re.search(r"HOSTED_MODEL_IDS\s*=\s*\{[^}]*\}\n", ids)
|
| 68 |
+
if m:
|
| 69 |
+
ids = ids[: m.end()] + "\n" + new_constants + ids[m.end() :]
|
| 70 |
+
print("OK: added constants to model_ids.py after HOSTED_MODEL_IDS")
|
| 71 |
+
else:
|
| 72 |
+
print("FAIL: no anchor found in model_ids.py")
|
| 73 |
+
raise SystemExit(1)
|
| 74 |
+
|
| 75 |
+
with open(IDS_FILE, "w") as f:
|
| 76 |
+
f.write(ids)
|
| 77 |
+
ast.parse(ids)
|
| 78 |
+
print("OK: model_ids.py written & syntax OK")
|
| 79 |
+
|
| 80 |
+
# ---------- 2. agent.py : imports ----------
|
| 81 |
+
with open(AGENT_FILE) as f:
|
| 82 |
+
ac = f.read()
|
| 83 |
+
|
| 84 |
+
if "APODEX_1_1_MINI_FREE_MODEL_ID" not in ac:
|
| 85 |
+
# patch_models.py rewrote the import block to list every OpenRouter constant.
|
| 86 |
+
imp_anchor = " LING_3_0_FLASH_FREE_MODEL_ID,\n"
|
| 87 |
+
if imp_anchor in ac:
|
| 88 |
+
ac = ac.replace(
|
| 89 |
+
imp_anchor,
|
| 90 |
+
imp_anchor
|
| 91 |
+
+ " APODEX_1_1_MINI_FREE_MODEL_ID,\n"
|
| 92 |
+
+ " LING_3_1_FLASH_MODEL_ID,\n"
|
| 93 |
+
+ " BEATAPI_SPACE_BUNNY_ALPHA_MODEL_ID,\n",
|
| 94 |
+
1,
|
| 95 |
+
)
|
| 96 |
+
print("OK: added 3 model imports to agent.py")
|
| 97 |
+
else:
|
| 98 |
+
print("FAIL: import anchor not found in agent.py")
|
| 99 |
+
raise SystemExit(1)
|
| 100 |
+
|
| 101 |
+
# ---------- 3. agent.py : _available_models ----------
|
| 102 |
+
line_anchor = (
|
| 103 |
+
' {"id": SPACE_BUNNY_ALPHA_MODEL_ID, "label": "stealth/space-bunny-alpha", "recommended": True},\n'
|
| 104 |
+
)
|
| 105 |
+
new_lines = line_anchor + (
|
| 106 |
+
' # OpenRouter models (openai/ prefix -> OpenRouter)\n'
|
| 107 |
+
' {"id": APODEX_1_1_MINI_FREE_MODEL_ID, "label": "apodex/apodex-1.1-mini:free", "recommended": True},\n'
|
| 108 |
+
' {"id": LING_3_1_FLASH_MODEL_ID, "label": "inclusionai/ling-3.1-flash", "recommended": True},\n'
|
| 109 |
+
' # BeatAPI model (beatapi/ prefix -> https://api.beatapi.io/v1)\n'
|
| 110 |
+
' {"id": BEATAPI_SPACE_BUNNY_ALPHA_MODEL_ID, "label": "BeatAPI space-bunny-alpha", "recommended": True},\n'
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
if line_anchor in ac:
|
| 114 |
+
ac = ac.replace(line_anchor, new_lines, 1)
|
| 115 |
+
print("OK: added 3 new models to _available_models()")
|
| 116 |
+
else:
|
| 117 |
+
# The _available_models body was fully rewritten by patch_models.py; match
|
| 118 |
+
# on the generic recommended-Ling line so this stays robust.
|
| 119 |
+
alt = (
|
| 120 |
+
' {"id": LING_3_0_FLASH_FREE_MODEL_ID, "label": '
|
| 121 |
+
'"inclusionai/ling-3.0-flash:free", "recommended": True},\n'
|
| 122 |
+
)
|
| 123 |
+
if alt in ac:
|
| 124 |
+
ac = ac.replace(
|
| 125 |
+
alt,
|
| 126 |
+
alt
|
| 127 |
+
+ ' {"id": APODEX_1_1_MINI_FREE_MODEL_ID, "label": "apodex/apodex-1.1-mini:free", "recommended": True},\n'
|
| 128 |
+
+ ' {"id": LING_3_1_FLASH_MODEL_ID, "label": "inclusionai/ling-3.1-flash", "recommended": True},\n'
|
| 129 |
+
+ ' {"id": BEATAPI_SPACE_BUNNY_ALPHA_MODEL_ID, "label": "BeatAPI space-bunny-alpha", "recommended": True},\n',
|
| 130 |
+
1,
|
| 131 |
+
)
|
| 132 |
+
print("OK: added 3 new models to _available_models() (fallback anchor)")
|
| 133 |
+
else:
|
| 134 |
+
print("FAIL: _available_models() anchor not found in agent.py")
|
| 135 |
+
raise SystemExit(1)
|
| 136 |
+
|
| 137 |
+
with open(AGENT_FILE, "w") as f:
|
| 138 |
+
f.write(ac)
|
| 139 |
+
ast.parse(ac)
|
| 140 |
+
print("OK: agent.py written & syntax OK")
|
| 141 |
+
|
| 142 |
+
else:
|
| 143 |
+
print("OK: agent.py already patched (idempotent)")
|
| 144 |
+
|
| 145 |
+
print("DONE: apodex-1.1-mini:free, ling-3.1-flash, BeatAPI space-bunny-alpha registered")
|
patch_auto_continue.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Patch script: Add auto-continue feature for free models in ml-intern.
|
| 2 |
+
|
| 3 |
+
Apply this script during Docker build to enable:
|
| 4 |
+
1. Detection when streaming ends but task incomplete
|
| 5 |
+
2. "Tự động tiếp tục (10s)" button - auto continues after 10s
|
| 6 |
+
3. "Tạm dừng" button - lets user send different content
|
| 7 |
+
|
| 8 |
+
Usage: Run this during Docker build after cloning source.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import os
|
| 12 |
+
import re
|
| 13 |
+
|
| 14 |
+
# Paths - adjust if needed
|
| 15 |
+
USE_AGENT_CHAT = "/source/frontend/src/hooks/useAgentChat.ts"
|
| 16 |
+
SSE_TRANSPORT = "/source/frontend/src/lib/sse-chat-transport.ts"
|
| 17 |
+
AGENT_LOOP = "/app/agent/core/agent_loop.py"
|
| 18 |
+
|
| 19 |
+
# =============================================================================
|
| 20 |
+
# Patch 1: useAgentChat.ts
|
| 21 |
+
# =============================================================================
|
| 22 |
+
def patch_use_agent_chat():
|
| 23 |
+
if not os.path.exists(USE_AGENT_CHAT):
|
| 24 |
+
print(f"SKIP: {USE_AGENT_CHAT} not found")
|
| 25 |
+
return
|
| 26 |
+
|
| 27 |
+
with open(USE_AGENT_CHAT, "r", encoding="utf-8") as f:
|
| 28 |
+
content = f.read()
|
| 29 |
+
|
| 30 |
+
# Add useState to imports if missing
|
| 31 |
+
if "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';" not in content:
|
| 32 |
+
content = content.replace(
|
| 33 |
+
"import { useCallback, useEffect, useMemo, useRef } from 'react';",
|
| 34 |
+
"import { useCallback, useEffect, useMemo, useRef, useState } from 'react';"
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
# Add auto-continue state after callbacksRef
|
| 38 |
+
state_code = '''
|
| 39 |
+
// Auto-continue state for free models when task incomplete
|
| 40 |
+
const autoContinueTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
| 41 |
+
const [showAutoContinue, setShowAutoContinue] = useState(false);
|
| 42 |
+
const [taskIncompleteInfo, setTaskIncompleteInfo] = useState<{
|
| 43 |
+
incompletePlan: Array<{ id: string; content: string; status: string }>;
|
| 44 |
+
} | null>(null);
|
| 45 |
+
'''
|
| 46 |
+
if "autoContinueTimerRef" not in content and "callbacksRef.current = { onReady, onError, onSessionDead }" in content:
|
| 47 |
+
content = content.replace(
|
| 48 |
+
"callbacksRef.current = { onReady, onError, onSessionDead };",
|
| 49 |
+
"callbacksRef.current = { onReady, onError, onSessionDead };" + state_code
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
# Add onTaskIncomplete to SideChannelCallbacks interface
|
| 53 |
+
if "onTaskIncomplete" not in content:
|
| 54 |
+
content = content.replace(
|
| 55 |
+
"onInterrupted: () => void;",
|
| 56 |
+
"onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;"
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
with open(USE_AGENT_CHAT, "w", encoding="utf-8") as f:
|
| 60 |
+
f.write(content)
|
| 61 |
+
print(f"OK: Patched {USE_AGENT_CHAT}")
|
| 62 |
+
|
| 63 |
+
# =============================================================================
|
| 64 |
+
# Patch 2: sse-chat-transport.ts
|
| 65 |
+
# =============================================================================
|
| 66 |
+
def patch_sse_transport():
|
| 67 |
+
if not os.path.exists(SSE_TRANSPORT):
|
| 68 |
+
print(f"SKIP: {SSE_TRANSPORT} not found")
|
| 69 |
+
return
|
| 70 |
+
|
| 71 |
+
with open(SSE_TRANSPORT, "r", encoding="utf-8") as f:
|
| 72 |
+
content = f.read()
|
| 73 |
+
|
| 74 |
+
# Add task_incomplete case
|
| 75 |
+
if "case 'task_incomplete'" not in content:
|
| 76 |
+
task_incomplete_case = ''' case 'task_incomplete':
|
| 77 |
+
sideChannel.onTaskIncomplete(
|
| 78 |
+
(event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [],
|
| 79 |
+
);
|
| 80 |
+
break;
|
| 81 |
+
'''
|
| 82 |
+
# Insert before turn_complete case
|
| 83 |
+
content = content.replace(
|
| 84 |
+
"case 'turn_complete':",
|
| 85 |
+
task_incomplete_case + "\n case 'turn_complete':"
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
with open(SSE_TRANSPORT, "w", encoding="utf-8") as f:
|
| 89 |
+
f.write(content)
|
| 90 |
+
print(f"OK: Patched {SSE_TRANSPORT}")
|
| 91 |
+
|
| 92 |
+
# =============================================================================
|
| 93 |
+
# Patch 3: agent_loop.py
|
| 94 |
+
# =============================================================================
|
| 95 |
+
def patch_agent_loop():
|
| 96 |
+
if not os.path.exists(AGENT_LOOP):
|
| 97 |
+
print(f"SKIP: {AGENT_LOOP} not found")
|
| 98 |
+
return
|
| 99 |
+
|
| 100 |
+
with open(AGENT_LOOP, "r", encoding="utf-8") as f:
|
| 101 |
+
content = f.read()
|
| 102 |
+
|
| 103 |
+
# Add check function if not exists
|
| 104 |
+
check_func = '''
|
| 105 |
+
def _check_task_incomplete(session: Session, llm_result: LLMResult) -> bool:
|
| 106 |
+
"""Check if model stopped but task is incomplete (has unfinished plan items)."""
|
| 107 |
+
if llm_result.tool_calls_acc:
|
| 108 |
+
return False # Has tool calls, not incomplete
|
| 109 |
+
plan = getattr(session, "current_plan", None) or []
|
| 110 |
+
unfinished = [item for item in plan if item.get("status") in ("pending", "in_progress")]
|
| 111 |
+
return len(unfinished) > 0
|
| 112 |
+
'''
|
| 113 |
+
if "_check_task_incomplete" not in content:
|
| 114 |
+
# Insert after LLMResult dataclass
|
| 115 |
+
content = content.replace(
|
| 116 |
+
"class Handlers:",
|
| 117 |
+
check_func + "\n\nclass Handlers:"
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
with open(AGENT_LOOP, "w", encoding="utf-8") as f:
|
| 121 |
+
f.write(content)
|
| 122 |
+
print(f"OK: Patched {AGENT_LOOP}")
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
if __name__ == "__main__":
|
| 126 |
+
patch_use_agent_chat()
|
| 127 |
+
patch_sse_transport()
|
| 128 |
+
patch_agent_loop()
|
| 129 |
+
print("\nDONE: Auto-continue patches applied")
|
patch_auto_continue_v10.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""V10: Fixes scope issue with chat.sendMessage.
|
| 2 |
+
|
| 3 |
+
Uses: ref to store plan, useEffect + chatActionsRef to send."""
|
| 4 |
+
import os, re
|
| 5 |
+
|
| 6 |
+
events_ts = "/source/frontend/src/types/events.ts"
|
| 7 |
+
sse_ts = "/source/frontend/src/lib/sse-chat-transport.ts"
|
| 8 |
+
hook_ts = "/source/frontend/src/hooks/useAgentChat.ts"
|
| 9 |
+
input_tsx = "/source/frontend/src/components/Chat/ChatInput.tsx"
|
| 10 |
+
session_tsx = "/source/frontend/src/components/SessionChat.tsx"
|
| 11 |
+
agent_py = "/app/agent/core/agent_loop.py"
|
| 12 |
+
|
| 13 |
+
def patch_events():
|
| 14 |
+
with open(events_ts) as f: c = f.read()
|
| 15 |
+
if "'task_incomplete'" not in c:
|
| 16 |
+
c = c.replace(" | 'interrupted'", " | 'interrupted'\n | 'task_incomplete'")
|
| 17 |
+
with open(events_ts, 'w') as f: f.write(c)
|
| 18 |
+
print("OK: events.ts")
|
| 19 |
+
|
| 20 |
+
def patch_sse():
|
| 21 |
+
with open(sse_ts) as f: c = f.read()
|
| 22 |
+
if "case 'task_incomplete'" not in c:
|
| 23 |
+
case_block = """ case 'task_incomplete':
|
| 24 |
+
(sideChannel as any).onTaskIncomplete(
|
| 25 |
+
(event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [],
|
| 26 |
+
);
|
| 27 |
+
break;
|
| 28 |
+
|
| 29 |
+
default:"""
|
| 30 |
+
c = c.replace("\n default:", "\n" + case_block)
|
| 31 |
+
with open(sse_ts, 'w') as f: f.write(c)
|
| 32 |
+
print("OK: sse.ts")
|
| 33 |
+
|
| 34 |
+
def patch_hook():
|
| 35 |
+
with open(hook_ts) as f:
|
| 36 |
+
lines = f.readlines()
|
| 37 |
+
|
| 38 |
+
# Find key insertion points by line number
|
| 39 |
+
result = []
|
| 40 |
+
state_added = False
|
| 41 |
+
handler_added = False
|
| 42 |
+
effect_added = False
|
| 43 |
+
cancel_added = False
|
| 44 |
+
return_added = False
|
| 45 |
+
|
| 46 |
+
for i, line in enumerate(lines):
|
| 47 |
+
# 1. Add useState import
|
| 48 |
+
if "import { useCallback, useEffect, useMemo, useRef } from 'react';" in line and "useState" not in line:
|
| 49 |
+
line = line.replace("useRef }", "useRef, useState }")
|
| 50 |
+
|
| 51 |
+
# 2. Add state after callbacksRef
|
| 52 |
+
if "callbacksRef.current = { onReady, onError, onSessionDead };" in line and not state_added:
|
| 53 |
+
result.append(line)
|
| 54 |
+
result.append(' // Auto-continue for free models\n')
|
| 55 |
+
result.append(' const [_showAc, _setShowAc] = useState(false);\n')
|
| 56 |
+
result.append(' const _acRef = useRef<Array<{ id: string; content: string; status: string }>>([]);\n')
|
| 57 |
+
state_added = True
|
| 58 |
+
continue
|
| 59 |
+
|
| 60 |
+
# 3. Add handler in sideChannel's onSessionUpdate
|
| 61 |
+
if "onSessionUpdate: (data) => {" in line and not handler_added:
|
| 62 |
+
result.append(line)
|
| 63 |
+
result.append(' // Auto-continue: check for ac_plan\n')
|
| 64 |
+
result.append(' if ((data as any).ac_plan) {\n')
|
| 65 |
+
result.append(' _acRef.current = (data as any).ac_plan;\n')
|
| 66 |
+
result.append(' _setShowAc(true);\n')
|
| 67 |
+
result.append(' return;\n')
|
| 68 |
+
result.append(' }\n')
|
| 69 |
+
handler_added = True
|
| 70 |
+
continue
|
| 71 |
+
|
| 72 |
+
# 4. Add cancel function + effect before return
|
| 73 |
+
stripped = line.strip()
|
| 74 |
+
if stripped == "return {" and not cancel_added:
|
| 75 |
+
result.append(' // Auto-continue cancel\n')
|
| 76 |
+
result.append(' const _acCancel = useCallback(() => { _setShowAc(false); }, []);\n')
|
| 77 |
+
cancel_added = True
|
| 78 |
+
# Add effect that uses chatActionsRef to send message
|
| 79 |
+
result.append('\n')
|
| 80 |
+
result.append(' // Auto-continue effect: 10s timer, sends via chatActionsRef\n')
|
| 81 |
+
result.append(' useEffect(() => {\n')
|
| 82 |
+
result.append(' if (!_showAc) return;\n')
|
| 83 |
+
result.append(' const plan = _acRef.current;\n')
|
| 84 |
+
result.append(' const timer = setTimeout(() => {\n')
|
| 85 |
+
result.append(' const s = chatActionsRef.current.setMessages;\n')
|
| 86 |
+
result.append(' const m = chatActionsRef.current.messages;\n')
|
| 87 |
+
result.append(' if (s && plan.length > 0) {\n')
|
| 88 |
+
result.append(" const t = plan.map(i => `- ${i.content}`).join('\\\\n');\n")
|
| 89 |
+
result.append(" s([...m, { id: 'ac-'+Date.now(), role: 'user' as const, parts: [{ type: 'text' as const, text: `[TIẾP TỤC] Task chưa hoàn thành:\\\\n${t}` }], content: '' }]);\n")
|
| 90 |
+
result.append(' }\n')
|
| 91 |
+
result.append(' _setShowAc(false);\n')
|
| 92 |
+
result.append(' }, 10000);\n')
|
| 93 |
+
result.append(' return () => clearTimeout(timer);\n')
|
| 94 |
+
result.append(' }, [_showAc]);\n')
|
| 95 |
+
effect_added = True
|
| 96 |
+
|
| 97 |
+
# 5. Return values
|
| 98 |
+
if "refreshMessages," in line and not return_added:
|
| 99 |
+
result.append(line)
|
| 100 |
+
result.append(' _showAc,\n')
|
| 101 |
+
result.append(' _acCancel,\n')
|
| 102 |
+
return_added = True
|
| 103 |
+
continue
|
| 104 |
+
|
| 105 |
+
result.append(line)
|
| 106 |
+
|
| 107 |
+
with open(hook_ts, 'w') as f: f.writelines(result)
|
| 108 |
+
print(f"OK: useAgentChat.ts (added={state_added}/{handler_added}/{cancel_added}/{effect_added}/{return_added})")
|
| 109 |
+
|
| 110 |
+
def patch_chat_input():
|
| 111 |
+
with open(input_tsx) as f: c = f.read()
|
| 112 |
+
if "Button" not in c:
|
| 113 |
+
c = c.replace(" Tooltip,", " Tooltip,\n Button,")
|
| 114 |
+
|
| 115 |
+
if "_showAc" not in c:
|
| 116 |
+
old_iface = """interface ChatInputProps {
|
| 117 |
+
sessionId?: string;
|
| 118 |
+
initialModelPath?: string | null;
|
| 119 |
+
onSend: (text: string) => void;
|
| 120 |
+
onStop?: () => void;
|
| 121 |
+
onDatasetUploaded?: () => Promise<boolean> | boolean;
|
| 122 |
+
isProcessing?: boolean;
|
| 123 |
+
disabled?: boolean;
|
| 124 |
+
placeholder?: string;
|
| 125 |
+
}"""
|
| 126 |
+
new_iface = """interface ChatInputProps {
|
| 127 |
+
sessionId?: string;
|
| 128 |
+
initialModelPath?: string | null;
|
| 129 |
+
onSend: (text: string) => void;
|
| 130 |
+
onStop?: () => void;
|
| 131 |
+
onDatasetUploaded?: () => Promise<boolean> | boolean;
|
| 132 |
+
isProcessing?: boolean;
|
| 133 |
+
disabled?: boolean;
|
| 134 |
+
placeholder?: string;
|
| 135 |
+
_showAc?: boolean;
|
| 136 |
+
_acCancel?: () => void;
|
| 137 |
+
}"""
|
| 138 |
+
c = c.replace(old_iface, new_iface)
|
| 139 |
+
old_props = "placeholder = 'Ask anything...' }: ChatInputProps) {"
|
| 140 |
+
new_props = "placeholder = 'Ask anything...', _showAc = false, _acCancel }: ChatInputProps) {"
|
| 141 |
+
c = c.replace(old_props, new_props)
|
| 142 |
+
|
| 143 |
+
if "T\u1ea1m d\u1eebng" not in c:
|
| 144 |
+
btn = """
|
| 145 |
+
{_showAc && _acCancel && (
|
| 146 |
+
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 1 }}>
|
| 147 |
+
<Button variant="outlined" size="small" onClick={_acCancel}
|
| 148 |
+
sx={{ textTransform: 'none', fontSize: '0.7rem', opacity: 0.7, '&:hover': { opacity: 1 } }}>
|
| 149 |
+
T\u1ea1m d\u1eebng t\u1ef1 \u0111\u1ed9ng ti\u1ebfp t\u1ee5c (10s)
|
| 150 |
+
</Button>
|
| 151 |
+
</Box>
|
| 152 |
+
)}
|
| 153 |
+
"""
|
| 154 |
+
if "<JobsUpgradeDialog" in c:
|
| 155 |
+
c = c.replace("<JobsUpgradeDialog", btn + "\n <JobsUpgradeDialog")
|
| 156 |
+
|
| 157 |
+
with open(input_tsx, 'w') as f: f.write(c)
|
| 158 |
+
print("OK: ChatInput.tsx")
|
| 159 |
+
|
| 160 |
+
def patch_session():
|
| 161 |
+
if not os.path.exists(session_tsx):
|
| 162 |
+
print(f"SKIP: {session_tsx}"); return
|
| 163 |
+
with open(session_tsx) as f: c = f.read()
|
| 164 |
+
if "_showAc" not in c:
|
| 165 |
+
c = c.replace("<ChatInput ", "<ChatInput _showAc={_showAc} _acCancel={_acCancel} ")
|
| 166 |
+
with open(session_tsx, 'w') as f: f.write(c)
|
| 167 |
+
print("OK: SessionChat.tsx")
|
| 168 |
+
|
| 169 |
+
def patch_agent():
|
| 170 |
+
if not os.path.exists(agent_py):
|
| 171 |
+
print(f"SKIP: {agent_py}"); return
|
| 172 |
+
with open(agent_py) as f: c = f.read()
|
| 173 |
+
|
| 174 |
+
if "_unfinished_plan" not in c:
|
| 175 |
+
fn = """
|
| 176 |
+
def _unfinished_plan(s: Session) -> list[dict[str, str]]:
|
| 177 |
+
p = getattr(s, "current_plan", None) or []
|
| 178 |
+
return [it for it in p if it.get("status") in ("pending", "in_progress")]
|
| 179 |
+
"""
|
| 180 |
+
c = c.replace("class Handlers:", fn + "\n\nclass Handlers:")
|
| 181 |
+
|
| 182 |
+
if "ac_plan" not in c:
|
| 183 |
+
check = """
|
| 184 |
+
if not llm_result.tool_calls_acc and llm_result.content:
|
| 185 |
+
unfinished = _unfinished_plan(session)
|
| 186 |
+
if unfinished:
|
| 187 |
+
current_ac = getattr(session, "auto_approval", None)
|
| 188 |
+
await session.send_event(Event(event_type="session_update", data={
|
| 189 |
+
"ac_plan": unfinished,
|
| 190 |
+
"auto_approval": current_ac,
|
| 191 |
+
}))
|
| 192 |
+
"""
|
| 193 |
+
marker = " # -- End of turn --"
|
| 194 |
+
if marker in c:
|
| 195 |
+
c = c.replace(marker, check + "\n" + marker)
|
| 196 |
+
|
| 197 |
+
with open(agent_py, 'w') as f: f.write(c)
|
| 198 |
+
print("OK: agent_loop.py")
|
| 199 |
+
|
| 200 |
+
if __name__ == "__main__":
|
| 201 |
+
patch_events(); patch_sse(); patch_hook(); patch_chat_input(); patch_session(); patch_agent()
|
| 202 |
+
print("\nDONE: V10")
|
patch_auto_continue_v11.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""V11: Auto-continue feature - clean single-file patch.
|
| 2 |
+
|
| 3 |
+
Backend: agent_loop.py detects model stopped mid-task, sends event.
|
| 4 |
+
Frontend: useAgentChat listens via onSessionUpdate, shows pause button.
|
| 5 |
+
All TS errors avoided via (as any) casts where needed.
|
| 6 |
+
"""
|
| 7 |
+
import os
|
| 8 |
+
|
| 9 |
+
ROOT = "/source/frontend/src"
|
| 10 |
+
EVENTS = f"{ROOT}/types/events.ts"
|
| 11 |
+
SSE = f"{ROOT}/lib/sse-chat-transport.ts"
|
| 12 |
+
HOOK = f"{ROOT}/hooks/useAgentChat.ts"
|
| 13 |
+
INPUT = f"{ROOT}/components/Chat/ChatInput.tsx"
|
| 14 |
+
SESS = f"{ROOT}/components/SessionChat.tsx"
|
| 15 |
+
AGENT_LOOP = "/app/agent/core/agent_loop.py"
|
| 16 |
+
|
| 17 |
+
def p(f, s):
|
| 18 |
+
print(f"OK: {f}" if s else f"FAIL: {f}")
|
| 19 |
+
|
| 20 |
+
# --- PATCH 1: events.ts ---
|
| 21 |
+
with open(EVENTS) as f: c = f.read()
|
| 22 |
+
r1 = "'task_incomplete'" not in c
|
| 23 |
+
if r1:
|
| 24 |
+
c = c.replace(" | 'interrupted'", " | 'interrupted'\n | 'task_incomplete'")
|
| 25 |
+
with open(EVENTS, 'w') as f: f.write(c)
|
| 26 |
+
p("events.ts", r1)
|
| 27 |
+
|
| 28 |
+
# --- PATCH 2: sse-chat-transport.ts ---
|
| 29 |
+
with open(SSE) as f: c = f.read()
|
| 30 |
+
r2 = "case 'task_incomplete'" not in c
|
| 31 |
+
if r2:
|
| 32 |
+
block = """ case 'task_incomplete':
|
| 33 |
+
(sideChannel as any).onTaskIncomplete(
|
| 34 |
+
(event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [],
|
| 35 |
+
);
|
| 36 |
+
break;
|
| 37 |
+
|
| 38 |
+
default:"""
|
| 39 |
+
c = c.replace("\n default:", "\n" + block)
|
| 40 |
+
with open(SSE, 'w') as f: f.write(c)
|
| 41 |
+
p("sse.ts", r2)
|
| 42 |
+
|
| 43 |
+
# --- PATCH 3: useAgentChat.ts ---
|
| 44 |
+
with open(HOOK) as f: lines = f.readlines()
|
| 45 |
+
r3 = True
|
| 46 |
+
new_lines = []
|
| 47 |
+
state_done = False
|
| 48 |
+
handler_done = False
|
| 49 |
+
efx_done = False
|
| 50 |
+
ret_done = False
|
| 51 |
+
|
| 52 |
+
for line in lines:
|
| 53 |
+
if "import { useCallback, useEffect, useMemo, useRef } from 'react';" in line:
|
| 54 |
+
line = line.replace("useRef }", "useRef, useState }")
|
| 55 |
+
|
| 56 |
+
if "callbacksRef.current = { onReady, onError, onSessionDead };" in line and not state_done:
|
| 57 |
+
new_lines.append(line)
|
| 58 |
+
new_lines.append(" // Auto-continue state\n")
|
| 59 |
+
new_lines.append(" const [_showAc, _setShowAc] = useState(false);\n")
|
| 60 |
+
new_lines.append(" const _acRef = useRef<Array<{ id: string; content: string; status: string }>>([]);\n")
|
| 61 |
+
state_done = True
|
| 62 |
+
continue
|
| 63 |
+
|
| 64 |
+
if "onSessionUpdate: (data) => {" in line and not handler_done:
|
| 65 |
+
new_lines.append(line)
|
| 66 |
+
new_lines.append(" // Auto-continue check\n")
|
| 67 |
+
new_lines.append(" if ((data as any).ac_plan) {\n")
|
| 68 |
+
new_lines.append(" _acRef.current = (data as any).ac_plan;\n")
|
| 69 |
+
new_lines.append(" _setShowAc(true);\n")
|
| 70 |
+
new_lines.append(" return;\n")
|
| 71 |
+
new_lines.append(" }\n")
|
| 72 |
+
handler_done = True
|
| 73 |
+
continue
|
| 74 |
+
|
| 75 |
+
stripped = line.strip()
|
| 76 |
+
if stripped == "return {" and not efx_done:
|
| 77 |
+
new_lines.append(" const _acCancel = useCallback(() => { _setShowAc(false); }, []);\n")
|
| 78 |
+
new_lines.append(" useEffect(() => {\n")
|
| 79 |
+
new_lines.append(" if (!_showAc) return;\n")
|
| 80 |
+
new_lines.append(" const plan = _acRef.current;\n")
|
| 81 |
+
new_lines.append(" const timer = setTimeout(() => {\n")
|
| 82 |
+
new_lines.append(" const s = chatActionsRef.current.setMessages;\n")
|
| 83 |
+
new_lines.append(" const m = chatActionsRef.current.messages;\n")
|
| 84 |
+
new_lines.append(" if (s && plan.length > 0) {\n")
|
| 85 |
+
new_lines.append(" const t = plan.map(i => `- ${i.content}`).join('\\n');\n")
|
| 86 |
+
new_lines.append(" const nu = { id: 'ac-'+Date.now(), role: 'user' as const, parts: [{ type: 'text' as const, text: `[TIEP TUC] Task chua hoan thanh:\\n${t}` }], content: '' };\n")
|
| 87 |
+
new_lines.append(" s([...m, nu]);\n")
|
| 88 |
+
new_lines.append(" }\n")
|
| 89 |
+
new_lines.append(" _setShowAc(false);\n")
|
| 90 |
+
new_lines.append(" }, 10000);\n")
|
| 91 |
+
new_lines.append(" return () => clearTimeout(timer);\n")
|
| 92 |
+
new_lines.append(" }, [_showAc]);\n")
|
| 93 |
+
efx_done = True
|
| 94 |
+
|
| 95 |
+
if "refreshMessages," in line and not ret_done:
|
| 96 |
+
new_lines.append(line)
|
| 97 |
+
new_lines.append(" _showAc,\n")
|
| 98 |
+
new_lines.append(" _acCancel,\n")
|
| 99 |
+
ret_done = True
|
| 100 |
+
continue
|
| 101 |
+
|
| 102 |
+
new_lines.append(line)
|
| 103 |
+
|
| 104 |
+
if not all([state_done, handler_done, efx_done, ret_done]):
|
| 105 |
+
r3 = False
|
| 106 |
+
print(f" MISSING: state={state_done} handler={handler_done} effect={efx_done} ret={ret_done}")
|
| 107 |
+
else:
|
| 108 |
+
with open(HOOK, 'w') as f: f.writelines(new_lines)
|
| 109 |
+
p("useAgentChat.ts", r3)
|
| 110 |
+
|
| 111 |
+
# --- PATCH 4: ChatInput.tsx ---
|
| 112 |
+
with open(INPUT) as f: c = f.read()
|
| 113 |
+
r4 = True
|
| 114 |
+
if "Button" not in c:
|
| 115 |
+
c = c.replace(" Tooltip,", " Tooltip,\n Button,")
|
| 116 |
+
|
| 117 |
+
if "_showAc" not in c:
|
| 118 |
+
old = "placeholder?: string;"
|
| 119 |
+
new = "placeholder?: string;\n _showAc?: boolean;\n _acCancel?: () => void;"
|
| 120 |
+
c = c.replace(old, new)
|
| 121 |
+
|
| 122 |
+
old2 = "placeholder = 'Ask anything...' }: ChatInputProps) {"
|
| 123 |
+
new2 = "placeholder = 'Ask anything...', _showAc = false, _acCancel }: ChatInputProps) {"
|
| 124 |
+
c = c.replace(old2, new2)
|
| 125 |
+
|
| 126 |
+
btn = """
|
| 127 |
+
{_showAc && _acCancel && (
|
| 128 |
+
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 1 }}>
|
| 129 |
+
<Button variant="outlined" size="small" onClick={_acCancel}
|
| 130 |
+
sx={{ textTransform: 'none', fontSize: '0.7rem', opacity: 0.7, '&:hover': { opacity: 1 } }}>
|
| 131 |
+
Tam dung tu dong tiep tuc (10s)
|
| 132 |
+
</Button>
|
| 133 |
+
</Box>
|
| 134 |
+
)}
|
| 135 |
+
"""
|
| 136 |
+
c = c.replace("<JobsUpgradeDialog", btn + "\n <JobsUpgradeDialog")
|
| 137 |
+
else:
|
| 138 |
+
r4 = False
|
| 139 |
+
|
| 140 |
+
with open(INPUT, 'w') as f: f.write(c)
|
| 141 |
+
p("ChatInput.tsx", r4)
|
| 142 |
+
|
| 143 |
+
# --- PATCH 5: SessionChat.tsx ---
|
| 144 |
+
if os.path.exists(SESS):
|
| 145 |
+
with open(SESS) as f: c = f.read()
|
| 146 |
+
r5 = "_showAc" not in c
|
| 147 |
+
if r5:
|
| 148 |
+
c = c.replace("<ChatInput ", "<ChatInput _showAc={_showAc} _acCancel={_acCancel} ")
|
| 149 |
+
with open(SESS, 'w') as f: f.write(c)
|
| 150 |
+
p("SessionChat.tsx", r5)
|
| 151 |
+
else:
|
| 152 |
+
print("SKIP: SessionChat.tsx")
|
| 153 |
+
|
| 154 |
+
# --- PATCH 6: agent_loop.py ---
|
| 155 |
+
if os.path.exists(AGENT_LOOP):
|
| 156 |
+
with open(AGENT_LOOP) as f: c = f.read()
|
| 157 |
+
r6 = True
|
| 158 |
+
if "_unfinished_plan" not in c:
|
| 159 |
+
fn = """
|
| 160 |
+
def _unfinished_plan(s):
|
| 161 |
+
p = getattr(s, "current_plan", None) or []
|
| 162 |
+
return [it for it in p if it.get("status") in ("pending", "in_progress")]
|
| 163 |
+
"""
|
| 164 |
+
c = c.replace("class Handlers:", fn + "\n\nclass Handlers:")
|
| 165 |
+
|
| 166 |
+
if "ac_plan" not in c:
|
| 167 |
+
check = """
|
| 168 |
+
if not llm_result.tool_calls_acc and llm_result.content:
|
| 169 |
+
unfinished = _unfinished_plan(session)
|
| 170 |
+
if unfinished:
|
| 171 |
+
await session.send_event(Event(event_type="session_update", data={
|
| 172 |
+
"ac_plan": unfinished,
|
| 173 |
+
}))
|
| 174 |
+
"""
|
| 175 |
+
marker = " # -- End of turn --"
|
| 176 |
+
c = c.replace(marker, check + "\n" + marker)
|
| 177 |
+
else:
|
| 178 |
+
r6 = False
|
| 179 |
+
|
| 180 |
+
with open(AGENT_LOOP, 'w') as f: f.write(c)
|
| 181 |
+
p("agent_loop.py", r6)
|
| 182 |
+
else:
|
| 183 |
+
print("SKIP: agent_loop.py")
|
| 184 |
+
|
| 185 |
+
print("\nDONE: V11")
|
patch_auto_continue_v12.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""V12: Auto-continue feature - final fix.
|
| 2 |
+
|
| 3 |
+
Key fixes for V11 bugs:
|
| 4 |
+
1. Button import: check exact MUI import format
|
| 5 |
+
2. useAgentChat: match two-line `return {\n messages: chat.messages,` instead of bare `return {`
|
| 6 |
+
3. Use \u unicode escapes for Vietnamese text
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
|
| 11 |
+
ROOT = "/source/frontend/src"
|
| 12 |
+
EVENTS = f"{ROOT}/types/events.ts"
|
| 13 |
+
SSE = f"{ROOT}/lib/sse-chat-transport.ts"
|
| 14 |
+
HOOK = f"{ROOT}/hooks/useAgentChat.ts"
|
| 15 |
+
INPUT = f"{ROOT}/components/Chat/ChatInput.tsx"
|
| 16 |
+
SESS = f"{ROOT}/components/SessionChat.tsx"
|
| 17 |
+
AGENT = "/app/agent/core/agent_loop.py"
|
| 18 |
+
|
| 19 |
+
def ok(f):
|
| 20 |
+
print(f"OK: {f}")
|
| 21 |
+
|
| 22 |
+
# --- 1. events.ts: add task_incomplete ---
|
| 23 |
+
with open(EVENTS) as f:
|
| 24 |
+
c = f.read()
|
| 25 |
+
if "'task_incomplete'" not in c:
|
| 26 |
+
c = c.replace(" | 'interrupted'", " | 'interrupted'\n | 'task_incomplete'")
|
| 27 |
+
with open(EVENTS, 'w') as f:
|
| 28 |
+
f.write(c)
|
| 29 |
+
ok("events.ts")
|
| 30 |
+
|
| 31 |
+
# --- 2. sse-chat-transport.ts: add case handler ---
|
| 32 |
+
with open(SSE) as f:
|
| 33 |
+
c = f.read()
|
| 34 |
+
if "case 'task_incomplete'" not in c:
|
| 35 |
+
block = """ case 'task_incomplete':
|
| 36 |
+
(sideChannel as any).onTaskIncomplete(
|
| 37 |
+
(event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [],
|
| 38 |
+
);
|
| 39 |
+
break;
|
| 40 |
+
|
| 41 |
+
default:"""
|
| 42 |
+
c = c.replace("\n default:", "\n" + block)
|
| 43 |
+
with open(SSE, 'w') as f:
|
| 44 |
+
f.write(c)
|
| 45 |
+
ok("sse.ts")
|
| 46 |
+
|
| 47 |
+
# --- 3. useAgentChat.ts: add state, handler, effect, return ---
|
| 48 |
+
with open(HOOK) as f:
|
| 49 |
+
content = f.read()
|
| 50 |
+
|
| 51 |
+
# Add useState to import
|
| 52 |
+
if "useState" not in content:
|
| 53 |
+
content = content.replace(
|
| 54 |
+
"import { useCallback, useEffect, useMemo, useRef } from 'react';",
|
| 55 |
+
"import { useCallback, useEffect, useMemo, useRef, useState } from 'react';"
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
# Add state after callbacksRef line
|
| 59 |
+
state_block = """\
|
| 60 |
+
// Auto-continue state
|
| 61 |
+
const [_showAc, _setShowAc] = useState(false);
|
| 62 |
+
const _acRef = useRef<Array<{ id: string; content: string; status: string }>>([]);
|
| 63 |
+
"""
|
| 64 |
+
if "_showAc" not in content:
|
| 65 |
+
content = content.replace(
|
| 66 |
+
"callbacksRef.current = { onReady, onError, onSessionDead };",
|
| 67 |
+
"callbacksRef.current = { onReady, onError, onSessionDead };\n" + state_block
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
# Add handler inside onSessionUpdate
|
| 71 |
+
handler_code = """\
|
| 72 |
+
// Auto-continue check
|
| 73 |
+
if ((data as any).ac_plan) {
|
| 74 |
+
_acRef.current = (data as any).ac_plan;
|
| 75 |
+
_setShowAc(true);
|
| 76 |
+
return;
|
| 77 |
+
}
|
| 78 |
+
"""
|
| 79 |
+
if "ac_plan" not in content:
|
| 80 |
+
content = content.replace(
|
| 81 |
+
"onSessionUpdate: (data) => {",
|
| 82 |
+
"onSessionUpdate: (data) => {\n" + handler_code
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
# Add cancel + effect BEFORE the two-line return marker
|
| 86 |
+
cancel_effect = """\
|
| 87 |
+
const _acCancel = useCallback(() => { _setShowAc(false); }, []);
|
| 88 |
+
useEffect(() => {
|
| 89 |
+
if (!_showAc) return;
|
| 90 |
+
const plan = _acRef.current;
|
| 91 |
+
const timer = setTimeout(() => {
|
| 92 |
+
const s = chatActionsRef.current.setMessages;
|
| 93 |
+
const m = chatActionsRef.current.messages;
|
| 94 |
+
if (s && plan.length > 0) {
|
| 95 |
+
const t = plan.map((i: { content: string }) => `- ${i.content}`).join('\\n');
|
| 96 |
+
const nu = { id: 'ac-'+Date.now(), role: 'user' as const, parts: [{ type: 'text' as const, text: `[TIEP TUC] Task chua hoan thanh:\\n${t}` }], content: '' };
|
| 97 |
+
s([...m, nu]);
|
| 98 |
+
}
|
| 99 |
+
_setShowAc(false);
|
| 100 |
+
}, 10000);
|
| 101 |
+
return () => clearTimeout(timer);
|
| 102 |
+
}, [_showAc]);
|
| 103 |
+
|
| 104 |
+
"""
|
| 105 |
+
if "_acCancel" not in content:
|
| 106 |
+
# Match the EXACT two-line return pattern
|
| 107 |
+
content = content.replace(
|
| 108 |
+
" return {\n messages: chat.messages,",
|
| 109 |
+
cancel_effect + " return {\n messages: chat.messages,"
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
# Add return values
|
| 113 |
+
if "_showAc," not in content:
|
| 114 |
+
content = content.replace(
|
| 115 |
+
"refreshMessages,\n };",
|
| 116 |
+
"refreshMessages,\n _showAc,\n _acCancel,\n };"
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
with open(HOOK, 'w') as f:
|
| 120 |
+
f.write(content)
|
| 121 |
+
ok("useAgentChat.ts")
|
| 122 |
+
|
| 123 |
+
# --- 4. ChatInput.tsx: add Button + props + pause button ---
|
| 124 |
+
with open(INPUT) as f:
|
| 125 |
+
content = f.read()
|
| 126 |
+
|
| 127 |
+
# Add Button to MUI imports - match the actual format
|
| 128 |
+
if "Button" not in content:
|
| 129 |
+
content = content.replace(
|
| 130 |
+
" Tooltip,",
|
| 131 |
+
" Tooltip,\n Button,"
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
# Add props to interface
|
| 135 |
+
if "_showAc" not in content:
|
| 136 |
+
content = content.replace(
|
| 137 |
+
"placeholder?: string;",
|
| 138 |
+
"placeholder?: string;\n _showAc?: boolean;\n _acCancel?: () => void;"
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
# Add to destructured function params
|
| 142 |
+
if "_showAc" not in content or "_acCancel" not in content:
|
| 143 |
+
content = content.replace(
|
| 144 |
+
"placeholder = 'Ask anything...' }: ChatInputProps) {",
|
| 145 |
+
"placeholder = 'Ask anything...', _showAc = false, _acCancel }: ChatInputProps) {"
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
# Add pause button before JobsUpgradeDialog
|
| 149 |
+
if "Tam dung" not in content:
|
| 150 |
+
btn = """
|
| 151 |
+
{_showAc && _acCancel && (
|
| 152 |
+
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 1 }}>
|
| 153 |
+
<Button variant="outlined" size="small" onClick={_acCancel}
|
| 154 |
+
sx={{ textTransform: 'none', fontSize: '0.7rem', opacity: 0.7, '&:hover': { opacity: 1 } }}>
|
| 155 |
+
T\u1ea1m d\u1eebng t\u1ef1 \u0111\u1ed9ng ti\u1ebfp t\u1ee5c (10s)
|
| 156 |
+
</Button>
|
| 157 |
+
</Box>
|
| 158 |
+
)}
|
| 159 |
+
"""
|
| 160 |
+
content = content.replace("<JobsUpgradeDialog", btn + "\n <JobsUpgradeDialog")
|
| 161 |
+
|
| 162 |
+
with open(INPUT, 'w') as f:
|
| 163 |
+
f.write(content)
|
| 164 |
+
ok("ChatInput.tsx")
|
| 165 |
+
|
| 166 |
+
# --- 5. SessionChat.tsx: pass props ---
|
| 167 |
+
if os.path.exists(SESS):
|
| 168 |
+
with open(SESS) as f:
|
| 169 |
+
c = f.read()
|
| 170 |
+
if "_showAc" not in c:
|
| 171 |
+
c = c.replace("<ChatInput ", "<ChatInput _showAc={_showAc} _acCancel={_acCancel} ")
|
| 172 |
+
with open(SESS, 'w') as f:
|
| 173 |
+
f.write(c)
|
| 174 |
+
ok("SessionChat.tsx")
|
| 175 |
+
|
| 176 |
+
# --- 6. agent_loop.py: detection ---
|
| 177 |
+
if os.path.exists(AGENT):
|
| 178 |
+
with open(AGENT) as f:
|
| 179 |
+
c = f.read()
|
| 180 |
+
|
| 181 |
+
if "_unfinished_plan" not in c:
|
| 182 |
+
fn = """
|
| 183 |
+
|
| 184 |
+
def _unfinished_plan(s):
|
| 185 |
+
p = getattr(s, "current_plan", None) or []
|
| 186 |
+
return [it for it in p if it.get("status") in ("pending", "in_progress")]
|
| 187 |
+
"""
|
| 188 |
+
c = c.replace("class Handlers:", fn + "\n\nclass Handlers:")
|
| 189 |
+
|
| 190 |
+
if "ac_plan" not in c:
|
| 191 |
+
check = """
|
| 192 |
+
if not llm_result.tool_calls_acc and llm_result.content:
|
| 193 |
+
unfinished = _unfinished_plan(session)
|
| 194 |
+
if unfinished:
|
| 195 |
+
await session.send_event(Event(event_type="session_update", data={
|
| 196 |
+
"ac_plan": unfinished,
|
| 197 |
+
}))
|
| 198 |
+
"""
|
| 199 |
+
marker = " # -- End of turn --"
|
| 200 |
+
if marker in c:
|
| 201 |
+
c = c.replace(marker, check + marker)
|
| 202 |
+
|
| 203 |
+
with open(AGENT, 'w') as f:
|
| 204 |
+
f.write(c)
|
| 205 |
+
ok("agent_loop.py")
|
| 206 |
+
else:
|
| 207 |
+
print("SKIP: agent_loop.py (backend)")
|
| 208 |
+
|
| 209 |
+
print("\nDONE: V12")
|
patch_auto_continue_v13.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""V13: Auto-continue feature - fixed SyntaxError, no raw \u in Python source"""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
ROOT = "/source/frontend/src"
|
| 6 |
+
EVENTS = f"{ROOT}/types/events.ts"
|
| 7 |
+
SSE = f"{ROOT}/lib/sse-chat-transport.ts"
|
| 8 |
+
HOOK = f"{ROOT}/hooks/useAgentChat.ts"
|
| 9 |
+
INPUT = f"{ROOT}/components/Chat/ChatInput.tsx"
|
| 10 |
+
SESS = f"{ROOT}/components/SessionChat.tsx"
|
| 11 |
+
AGENT = "/app/agent/core/agent_loop.py"
|
| 12 |
+
|
| 13 |
+
# Vietnamese text as hex to avoid \u issues in Python source
|
| 14 |
+
PAUSE_TEXT = "T\\u1ea1m d\\u1eebng t\\u1ef1 \\u0111\\u1ed9ng ti\\u1ebfp t\\u1ee5c (10s)"
|
| 15 |
+
CONTINUE_TEXT = "[TIEP TUC] Task chua hoan thanh:"
|
| 16 |
+
|
| 17 |
+
def ok(f):
|
| 18 |
+
print(f"OK: {f}")
|
| 19 |
+
|
| 20 |
+
# --- 1. events.ts ---
|
| 21 |
+
with open(EVENTS) as f:
|
| 22 |
+
c = f.read()
|
| 23 |
+
if "'task_incomplete'" not in c:
|
| 24 |
+
c = c.replace(" | 'interrupted'", " | 'interrupted'\n | 'task_incomplete'")
|
| 25 |
+
with open(EVENTS, 'w') as f:
|
| 26 |
+
f.write(c)
|
| 27 |
+
ok("events.ts")
|
| 28 |
+
|
| 29 |
+
# --- 2. sse.ts ---
|
| 30 |
+
with open(SSE) as f:
|
| 31 |
+
c = f.read()
|
| 32 |
+
if "case 'task_incomplete'" not in c:
|
| 33 |
+
block = """ case 'task_incomplete':
|
| 34 |
+
(sideChannel as any).onTaskIncomplete(
|
| 35 |
+
(event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [],
|
| 36 |
+
);
|
| 37 |
+
break;
|
| 38 |
+
|
| 39 |
+
default:"""
|
| 40 |
+
c = c.replace("\n default:", "\n" + block)
|
| 41 |
+
with open(SSE, 'w') as f:
|
| 42 |
+
f.write(c)
|
| 43 |
+
ok("sse.ts")
|
| 44 |
+
|
| 45 |
+
# --- 3. useAgentChat.ts ---
|
| 46 |
+
with open(HOOK) as f:
|
| 47 |
+
content = f.read()
|
| 48 |
+
|
| 49 |
+
if "useState" not in content:
|
| 50 |
+
content = content.replace(
|
| 51 |
+
"import { useCallback, useEffect, useMemo, useRef } from 'react';",
|
| 52 |
+
"import { useCallback, useEffect, useMemo, useRef, useState } from 'react';"
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
state_block = """\
|
| 56 |
+
// Auto-continue state
|
| 57 |
+
const [_showAc, _setShowAc] = useState(false);
|
| 58 |
+
const _acRef = useRef<Array<{ id: string; content: string; status: string }>>([]);
|
| 59 |
+
"""
|
| 60 |
+
if "_showAc" not in content:
|
| 61 |
+
content = content.replace(
|
| 62 |
+
"callbacksRef.current = { onReady, onError, onSessionDead };",
|
| 63 |
+
"callbacksRef.current = { onReady, onError, onSessionDead };\n" + state_block
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
handler_code = """\
|
| 67 |
+
// Auto-continue check
|
| 68 |
+
if ((data as any).ac_plan) {
|
| 69 |
+
_acRef.current = (data as any).ac_plan;
|
| 70 |
+
_setShowAc(true);
|
| 71 |
+
return;
|
| 72 |
+
}
|
| 73 |
+
"""
|
| 74 |
+
if "ac_plan" not in content:
|
| 75 |
+
content = content.replace(
|
| 76 |
+
"onSessionUpdate: (data) => {",
|
| 77 |
+
"onSessionUpdate: (data) => {\n" + handler_code
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
cancel_effect = """\
|
| 81 |
+
const _acCancel = useCallback(() => { _setShowAc(false); }, []);
|
| 82 |
+
useEffect(() => {
|
| 83 |
+
if (!_showAc) return;
|
| 84 |
+
const plan = _acRef.current;
|
| 85 |
+
const timer = setTimeout(() => {
|
| 86 |
+
const s = chatActionsRef.current.setMessages;
|
| 87 |
+
const m = chatActionsRef.current.messages;
|
| 88 |
+
if (s && plan.length > 0) {
|
| 89 |
+
const t = plan.map((i: { content: string }) => '- ' + i.content).join('\\\\n');
|
| 90 |
+
const nu = { id: 'ac-'+Date.now(), role: 'user' as const, parts: [{ type: 'text' as const, text: '""" + CONTINUE_TEXT + "\\\\n' + t }], content: '' };\n"
|
| 91 |
+
cancel_effect += """\
|
| 92 |
+
s([...m, nu]);
|
| 93 |
+
}
|
| 94 |
+
_setShowAc(false);
|
| 95 |
+
}, 10000);
|
| 96 |
+
return () => clearTimeout(timer);
|
| 97 |
+
}, [_showAc]);
|
| 98 |
+
|
| 99 |
+
"""
|
| 100 |
+
if "_acCancel" not in content:
|
| 101 |
+
content = content.replace(
|
| 102 |
+
" return {\n messages: chat.messages,",
|
| 103 |
+
cancel_effect + " return {\n messages: chat.messages,"
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
if "_showAc," not in content:
|
| 107 |
+
content = content.replace(
|
| 108 |
+
"refreshMessages,\n };",
|
| 109 |
+
"refreshMessages,\n _showAc,\n _acCancel,\n };"
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
with open(HOOK, 'w') as f:
|
| 113 |
+
f.write(content)
|
| 114 |
+
ok("useAgentChat.ts")
|
| 115 |
+
|
| 116 |
+
# --- 4. ChatInput.tsx ---
|
| 117 |
+
with open(INPUT) as f:
|
| 118 |
+
content = f.read()
|
| 119 |
+
|
| 120 |
+
if "Button" not in content:
|
| 121 |
+
content = content.replace(
|
| 122 |
+
" Tooltip,",
|
| 123 |
+
" Tooltip,\n Button,"
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
if "_showAc" not in content:
|
| 127 |
+
content = content.replace(
|
| 128 |
+
"placeholder?: string;",
|
| 129 |
+
"placeholder?: string;\n _showAc?: boolean;\n _acCancel?: () => void;"
|
| 130 |
+
)
|
| 131 |
+
content = content.replace(
|
| 132 |
+
"placeholder = 'Ask anything...' }: ChatInputProps) {",
|
| 133 |
+
"placeholder = 'Ask anything...', _showAc = false, _acCancel }: ChatInputProps) {"
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
pause_marker = "TAM_DUNG"
|
| 137 |
+
if pause_marker not in content:
|
| 138 |
+
btn = """
|
| 139 |
+
{_showAc && _acCancel && (
|
| 140 |
+
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 1 }}>
|
| 141 |
+
<Button variant="outlined" size="small" onClick={_acCancel}
|
| 142 |
+
sx={{ textTransform: 'none', fontSize: '0.7rem', opacity: 0.7, '&:hover': { opacity: 1 } }}>
|
| 143 |
+
""" + PAUSE_TEXT + """
|
| 144 |
+
</Button>
|
| 145 |
+
</Box>
|
| 146 |
+
)}
|
| 147 |
+
"""
|
| 148 |
+
content = content.replace("<JobsUpgradeDialog", btn + "\n <JobsUpgradeDialog")
|
| 149 |
+
|
| 150 |
+
with open(INPUT, 'w') as f:
|
| 151 |
+
f.write(content)
|
| 152 |
+
ok("ChatInput.tsx")
|
| 153 |
+
|
| 154 |
+
# --- 5. SessionChat.tsx ---
|
| 155 |
+
if os.path.exists(SESS):
|
| 156 |
+
with open(SESS) as f:
|
| 157 |
+
c = f.read()
|
| 158 |
+
if "_showAc" not in c:
|
| 159 |
+
c = c.replace("<ChatInput ", "<ChatInput _showAc={_showAc} _acCancel={_acCancel} ")
|
| 160 |
+
with open(SESS, 'w') as f:
|
| 161 |
+
f.write(c)
|
| 162 |
+
ok("SessionChat.tsx")
|
| 163 |
+
|
| 164 |
+
# --- 6. agent_loop.py ---
|
| 165 |
+
if os.path.exists(AGENT):
|
| 166 |
+
with open(AGENT) as f:
|
| 167 |
+
c = f.read()
|
| 168 |
+
|
| 169 |
+
if "_unfinished_plan" not in c:
|
| 170 |
+
fn = """
|
| 171 |
+
|
| 172 |
+
def _unfinished_plan(s):
|
| 173 |
+
p = getattr(s, "current_plan", None) or []
|
| 174 |
+
return [it for it in p if it.get("status") in ("pending", "in_progress")]
|
| 175 |
+
"""
|
| 176 |
+
c = c.replace("class Handlers:", fn + "\n\nclass Handlers:")
|
| 177 |
+
|
| 178 |
+
if "ac_plan" not in c:
|
| 179 |
+
check = """
|
| 180 |
+
if not llm_result.tool_calls_acc and llm_result.content:
|
| 181 |
+
unfinished = _unfinished_plan(session)
|
| 182 |
+
if unfinished:
|
| 183 |
+
await session.send_event(Event(event_type="session_update", data={
|
| 184 |
+
"ac_plan": unfinished,
|
| 185 |
+
}))
|
| 186 |
+
"""
|
| 187 |
+
marker = " # -- End of turn --"
|
| 188 |
+
if marker in c:
|
| 189 |
+
c = c.replace(marker, check + marker)
|
| 190 |
+
|
| 191 |
+
with open(AGENT, 'w') as f:
|
| 192 |
+
f.write(c)
|
| 193 |
+
ok("agent_loop.py")
|
| 194 |
+
else:
|
| 195 |
+
print("SKIP: agent_loop.py")
|
| 196 |
+
|
| 197 |
+
print("\nDONE: V13")
|
patch_auto_continue_v14.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""V14: Auto-continue. Use ASCII-only text for reliable build."""
|
| 2 |
+
|
| 3 |
+
import os, sys
|
| 4 |
+
|
| 5 |
+
ROOT = "/source/frontend/src"
|
| 6 |
+
|
| 7 |
+
FILES = {
|
| 8 |
+
"events.ts": ROOT + "/types/events.ts",
|
| 9 |
+
"sse.ts": ROOT + "/lib/sse-chat-transport.ts",
|
| 10 |
+
"hook.ts": ROOT + "/hooks/useAgentChat.ts",
|
| 11 |
+
"input.tsx": ROOT + "/components/Chat/ChatInput.tsx",
|
| 12 |
+
"sess.tsx": ROOT + "/components/SessionChat.tsx",
|
| 13 |
+
"agent.py": "/app/agent/core/agent_loop.py",
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
# 1. events.ts
|
| 17 |
+
c = open(FILES["events.ts"]).read()
|
| 18 |
+
if "'task_incomplete'" not in c:
|
| 19 |
+
c = c.replace(" | 'interrupted'", " | 'interrupted'\n | 'task_incomplete'")
|
| 20 |
+
open(FILES["events.ts"], 'w').write(c)
|
| 21 |
+
print("OK: events.ts")
|
| 22 |
+
|
| 23 |
+
# 2. sse.ts
|
| 24 |
+
c = open(FILES["sse.ts"]).read()
|
| 25 |
+
if "case 'task_incomplete'" not in c:
|
| 26 |
+
block = """ case 'task_incomplete':
|
| 27 |
+
(sideChannel as any).onTaskIncomplete(
|
| 28 |
+
(event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [],
|
| 29 |
+
);
|
| 30 |
+
break;
|
| 31 |
+
|
| 32 |
+
default:"""
|
| 33 |
+
c = c.replace("\n default:", "\n" + block)
|
| 34 |
+
open(FILES["sse.ts"], 'w').write(c)
|
| 35 |
+
print("OK: sse.ts")
|
| 36 |
+
|
| 37 |
+
# 3. hook.ts - useAgentChat.ts
|
| 38 |
+
c = open(FILES["hook.ts"]).read()
|
| 39 |
+
|
| 40 |
+
# useState import
|
| 41 |
+
if "useState" not in c:
|
| 42 |
+
c = c.replace(
|
| 43 |
+
"import { useCallback, useEffect, useMemo, useRef } from 'react';",
|
| 44 |
+
"import { useCallback, useEffect, useMemo, useRef, useState } from 'react';"
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
# State after callbacksRef
|
| 48 |
+
if "_showAc" not in c:
|
| 49 |
+
state_block = (
|
| 50 |
+
" // Auto-continue state\n"
|
| 51 |
+
" const [_showAc, _setShowAc] = useState(false);\n"
|
| 52 |
+
" const _acRef = useRef<Array<{ id: string; content: string; status: string }>>([]);\n"
|
| 53 |
+
)
|
| 54 |
+
c = c.replace(
|
| 55 |
+
"callbacksRef.current = { onReady, onError, onSessionDead };",
|
| 56 |
+
"callbacksRef.current = { onReady, onError, onSessionDead };\n" + state_block
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
# Handler in onSessionUpdate
|
| 60 |
+
if "ac_plan" not in c:
|
| 61 |
+
handler = (
|
| 62 |
+
" // Auto-continue check\n"
|
| 63 |
+
" if ((data as any).ac_plan) {\n"
|
| 64 |
+
" _acRef.current = (data as any).ac_plan;\n"
|
| 65 |
+
" _setShowAc(true);\n"
|
| 66 |
+
" return;\n"
|
| 67 |
+
" }\n"
|
| 68 |
+
)
|
| 69 |
+
c = c.replace("onSessionUpdate: (data) => {", "onSessionUpdate: (data) => {\n" + handler)
|
| 70 |
+
|
| 71 |
+
# Cancel + effect before return
|
| 72 |
+
if "_acCancel" not in c:
|
| 73 |
+
cancel = " const _acCancel = useCallback(() => { _setShowAc(false); }, []);\n"
|
| 74 |
+
effect = (
|
| 75 |
+
" useEffect(() => {\n"
|
| 76 |
+
" if (!_showAc) return;\n"
|
| 77 |
+
" const plan = _acRef.current;\n"
|
| 78 |
+
" const timer = setTimeout(() => {\n"
|
| 79 |
+
" const s = chatActionsRef.current.setMessages;\n"
|
| 80 |
+
" const m = chatActionsRef.current.messages;\n"
|
| 81 |
+
" if (s && plan.length > 0) {\n"
|
| 82 |
+
" const t = plan.map((i: { content: string }) => '- ' + i.content).join('\\\\n');\n"
|
| 83 |
+
" const nu = { id: 'ac-'+Date.now(), role: 'user' as const, parts: [{ type: 'text' as const, text: '[TIEP TUC] Task chua hoan thanh:\\\\n' + t }], content: '' };\n"
|
| 84 |
+
" s([...m, nu]);\n"
|
| 85 |
+
" }\n"
|
| 86 |
+
" _setShowAc(false);\n"
|
| 87 |
+
" }, 10000);\n"
|
| 88 |
+
" return () => clearTimeout(timer);\n"
|
| 89 |
+
" }, [_showAc]);\n\n"
|
| 90 |
+
)
|
| 91 |
+
c = c.replace(
|
| 92 |
+
" return {\n messages: chat.messages,",
|
| 93 |
+
cancel + effect + " return {\n messages: chat.messages,"
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
# Return values
|
| 97 |
+
if "_showAc," not in c:
|
| 98 |
+
c = c.replace("refreshMessages,\n };", "refreshMessages,\n _showAc,\n _acCancel,\n };")
|
| 99 |
+
|
| 100 |
+
open(FILES["hook.ts"], 'w').write(c)
|
| 101 |
+
print("OK: useAgentChat.ts")
|
| 102 |
+
|
| 103 |
+
# 4. input.tsx - ChatInput.tsx
|
| 104 |
+
c = open(FILES["input.tsx"]).read()
|
| 105 |
+
|
| 106 |
+
if "Button" not in c:
|
| 107 |
+
# Match the exact MUI import block
|
| 108 |
+
c = c.replace("import {\n Alert,\n Box,\n TextField,\n IconButton,\n CircularProgress,\n Typography,\n Menu,\n MenuItem,\n ListItemIcon,\n ListItemText,\n Chip,\n LinearProgress,\n Snackbar,\n Tooltip,\n} from '@mui/material';",
|
| 109 |
+
"import {\n Alert,\n Box,\n TextField,\n IconButton,\n CircularProgress,\n Typography,\n Menu,\n MenuItem,\n ListItemIcon,\n ListItemText,\n Chip,\n LinearProgress,\n Snackbar,\n Tooltip,\n Button,\n} from '@mui/material';")
|
| 110 |
+
|
| 111 |
+
if "_showAc" not in c:
|
| 112 |
+
c = c.replace("placeholder?: string;", "placeholder?: string;\n _showAc?: boolean;\n _acCancel?: () => void;")
|
| 113 |
+
c = c.replace("placeholder = 'Ask anything...' }: ChatInputProps) {",
|
| 114 |
+
"placeholder = 'Ask anything...', _showAc = false, _acCancel }: ChatInputProps) {")
|
| 115 |
+
|
| 116 |
+
if "TAM_DUNG" not in c:
|
| 117 |
+
btn = (
|
| 118 |
+
" {_showAc && _acCancel && (\n"
|
| 119 |
+
" <Box sx={{ display: 'flex', justifyContent: 'center', mt: 1 }}>\n"
|
| 120 |
+
" <Button variant=\"outlined\" size=\"small\" onClick={_acCancel}\n"
|
| 121 |
+
" sx={{ textTransform: 'none', fontSize: '0.7rem', opacity: 0.7, '&:hover': { opacity: 1 } }}>\n"
|
| 122 |
+
" TAM_DUNG_TU_DONG_TIEP_TUC\n"
|
| 123 |
+
" </Button>\n"
|
| 124 |
+
" </Box>\n"
|
| 125 |
+
" )}\n"
|
| 126 |
+
)
|
| 127 |
+
c = c.replace("<JobsUpgradeDialog", btn + " <JobsUpgradeDialog")
|
| 128 |
+
|
| 129 |
+
open(FILES["input.tsx"], 'w').write(c)
|
| 130 |
+
print("OK: ChatInput.tsx")
|
| 131 |
+
|
| 132 |
+
# 5. sess.tsx - SessionChat.tsx
|
| 133 |
+
if os.path.exists(FILES["sess.tsx"]):
|
| 134 |
+
c = open(FILES["sess.tsx"]).read()
|
| 135 |
+
if "_showAc" not in c:
|
| 136 |
+
c = c.replace("<ChatInput ", "<ChatInput _showAc={_showAc} _acCancel={_acCancel} ")
|
| 137 |
+
open(FILES["sess.tsx"], 'w').write(c)
|
| 138 |
+
print("OK: SessionChat.tsx")
|
| 139 |
+
|
| 140 |
+
# 6. agent.py
|
| 141 |
+
if os.path.exists(FILES["agent.py"]):
|
| 142 |
+
c = open(FILES["agent.py"]).read()
|
| 143 |
+
|
| 144 |
+
if "_unfinished_plan" not in c:
|
| 145 |
+
fn = "\n\ndef _unfinished_plan(s):\n p = getattr(s, 'current_plan', None) or []\n return [it for it in p if it.get('status') in ('pending', 'in_progress')]\n"
|
| 146 |
+
c = c.replace("class Handlers:", fn + "\n\nclass Handlers:")
|
| 147 |
+
|
| 148 |
+
if "ac_plan" not in c:
|
| 149 |
+
check = (
|
| 150 |
+
"\n if not llm_result.tool_calls_acc and llm_result.content:\n"
|
| 151 |
+
" unfinished = _unfinished_plan(session)\n"
|
| 152 |
+
" if unfinished:\n"
|
| 153 |
+
" await session.send_event(Event(event_type='session_update', data={\n"
|
| 154 |
+
" 'ac_plan': unfinished,\n"
|
| 155 |
+
" }))\n"
|
| 156 |
+
)
|
| 157 |
+
marker = " # -- End of turn --"
|
| 158 |
+
if marker in c:
|
| 159 |
+
c = c.replace(marker, check + marker)
|
| 160 |
+
|
| 161 |
+
open(FILES["agent.py"], 'w').write(c)
|
| 162 |
+
print("OK: agent_loop.py")
|
| 163 |
+
else:
|
| 164 |
+
print("SKIP: agent_loop.py")
|
| 165 |
+
|
| 166 |
+
print("\nDONE: V14")
|
patch_auto_continue_v15.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""V15: Auto-continue - fix Button import detection + return matching."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
ROOT = "/source/frontend/src"
|
| 6 |
+
EVENTS = ROOT + "/types/events.ts"
|
| 7 |
+
SSE = ROOT + "/lib/sse-chat-transport.ts"
|
| 8 |
+
HOOK = ROOT + "/hooks/useAgentChat.ts"
|
| 9 |
+
INPUT = ROOT + "/components/Chat/ChatInput.tsx"
|
| 10 |
+
SESS = ROOT + "/components/SessionChat.tsx"
|
| 11 |
+
AGENT = "/app/agent/core/agent_loop.py"
|
| 12 |
+
|
| 13 |
+
def ok(f): print(f"OK: {f}")
|
| 14 |
+
|
| 15 |
+
# 1. events.ts
|
| 16 |
+
with open(EVENTS) as f: c = f.read()
|
| 17 |
+
if "'task_incomplete'" not in c:
|
| 18 |
+
c = c.replace(" | 'interrupted'", " | 'interrupted'\n | 'task_incomplete'")
|
| 19 |
+
with open(EVENTS, 'w') as f: f.write(c)
|
| 20 |
+
ok("events.ts")
|
| 21 |
+
|
| 22 |
+
# 2. sse.ts
|
| 23 |
+
with open(SSE) as f: c = f.read()
|
| 24 |
+
if "case 'task_incomplete'" not in c:
|
| 25 |
+
block = """ case 'task_incomplete':
|
| 26 |
+
(sideChannel as any).onTaskIncomplete(
|
| 27 |
+
(event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [],
|
| 28 |
+
);
|
| 29 |
+
break;
|
| 30 |
+
|
| 31 |
+
default:"""
|
| 32 |
+
c = c.replace("\n default:", "\n" + block)
|
| 33 |
+
with open(SSE, 'w') as f: f.write(c)
|
| 34 |
+
ok("sse.ts")
|
| 35 |
+
|
| 36 |
+
# 3. useAgentChat.ts
|
| 37 |
+
with open(HOOK) as f:
|
| 38 |
+
lines = f.readlines()
|
| 39 |
+
|
| 40 |
+
new_lines = []
|
| 41 |
+
state_ok = hdl_ok = efx_ok = ret_ok = False
|
| 42 |
+
|
| 43 |
+
for line in lines:
|
| 44 |
+
# Import useState
|
| 45 |
+
if "import { useCallback, useEffect, useMemo, useRef } from 'react';" in line:
|
| 46 |
+
line = line.replace("useRef }", "useRef, useState }")
|
| 47 |
+
|
| 48 |
+
# State after callbacksRef
|
| 49 |
+
if "callbacksRef.current = { onReady, onError, onSessionDead };" in line and not state_ok:
|
| 50 |
+
new_lines.append(line)
|
| 51 |
+
new_lines.append(" const [_showAc, _setShowAc] = useState(false);\n")
|
| 52 |
+
new_lines.append(" const _acRef = useRef<Array<{ id: string; content: string; status: string }>>([]);\n")
|
| 53 |
+
state_ok = True
|
| 54 |
+
continue
|
| 55 |
+
|
| 56 |
+
# Handler
|
| 57 |
+
if "onSessionUpdate: (data) => {" in line and not hdl_ok:
|
| 58 |
+
new_lines.append(line)
|
| 59 |
+
new_lines.append(" if ((data as any).ac_plan) {\n")
|
| 60 |
+
new_lines.append(" _acRef.current = (data as any).ac_plan;\n")
|
| 61 |
+
new_lines.append(" _setShowAc(true);\n")
|
| 62 |
+
new_lines.append(" return;\n")
|
| 63 |
+
new_lines.append(" }\n")
|
| 64 |
+
hdl_ok = True
|
| 65 |
+
continue
|
| 66 |
+
|
| 67 |
+
# Cancel + effect BEFORE 'return {'
|
| 68 |
+
stripped = line.strip()
|
| 69 |
+
if stripped == "return {" and not efx_ok:
|
| 70 |
+
new_lines.append(" const _acCancel = useCallback(() => { _setShowAc(false); }, []);\n")
|
| 71 |
+
new_lines.append(" useEffect(() => {\n")
|
| 72 |
+
new_lines.append(" if (!_showAc) return;\n")
|
| 73 |
+
new_lines.append(" const plan = _acRef.current;\n")
|
| 74 |
+
new_lines.append(" const timer = setTimeout(() => {\n")
|
| 75 |
+
new_lines.append(" const s = chatActionsRef.current.setMessages;\n")
|
| 76 |
+
new_lines.append(" const m = chatActionsRef.current.messages;\n")
|
| 77 |
+
new_lines.append(" if (s && plan.length > 0) {\n")
|
| 78 |
+
new_lines.append(" const t = plan.map((i: { content: string }) => '- ' + i.content).join('\\\\n');\n")
|
| 79 |
+
new_lines.append(" const nu = { id: 'ac-'+Date.now(), role: 'user' as const, parts: [{ type: 'text' as const, text: '[TIEP TUC] Task chua hoan thanh:\\\\n' + t }], content: '' };\n")
|
| 80 |
+
new_lines.append(" s([...m, nu]);\n")
|
| 81 |
+
new_lines.append(" }\n")
|
| 82 |
+
new_lines.append(" _setShowAc(false);\n")
|
| 83 |
+
new_lines.append(" }, 10000);\n")
|
| 84 |
+
new_lines.append(" return () => clearTimeout(timer);\n")
|
| 85 |
+
new_lines.append(" }, [_showAc]);\n\n")
|
| 86 |
+
efx_ok = True
|
| 87 |
+
|
| 88 |
+
# Add return values after "refreshMessages,"
|
| 89 |
+
if "refreshMessages," in line and not ret_ok:
|
| 90 |
+
new_lines.append(line)
|
| 91 |
+
new_lines.append(" _showAc,\n")
|
| 92 |
+
new_lines.append(" _acCancel,\n")
|
| 93 |
+
ret_ok = True
|
| 94 |
+
continue
|
| 95 |
+
|
| 96 |
+
new_lines.append(line)
|
| 97 |
+
|
| 98 |
+
with open(HOOK, 'w') as f:
|
| 99 |
+
f.writelines(new_lines)
|
| 100 |
+
ok(f"useAgentChat.ts (s={state_ok} h={hdl_ok} e={efx_ok} r={ret_ok})")
|
| 101 |
+
|
| 102 |
+
# 4. ChatInput.tsx
|
| 103 |
+
with open(INPUT) as f:
|
| 104 |
+
content = f.read()
|
| 105 |
+
|
| 106 |
+
# Check for exact Button in MUI import (not "IconButton" which contains "Button")
|
| 107 |
+
if " Button,\n" not in content and " Button\n" not in content:
|
| 108 |
+
content = content.replace(
|
| 109 |
+
" Tooltip,",
|
| 110 |
+
" Tooltip,\n Button,"
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
# Add props to interface
|
| 114 |
+
if "_showAc" not in content:
|
| 115 |
+
content = content.replace("placeholder?: string;", "placeholder?: string;\n _showAc?: boolean;\n _acCancel?: () => void;")
|
| 116 |
+
content = content.replace("placeholder = 'Ask anything...' }: ChatInputProps) {",
|
| 117 |
+
"placeholder = 'Ask anything...', _showAc = false, _acCancel }: ChatInputProps) {")
|
| 118 |
+
|
| 119 |
+
# Add pause button before <JobsUpgradeDialog
|
| 120 |
+
btn = (
|
| 121 |
+
" {_showAc && _acCancel && (\n"
|
| 122 |
+
" <Box sx={{ display: 'flex', justifyContent: 'center', mt: 1 }}>\n"
|
| 123 |
+
" <Button variant=\"outlined\" size=\"small\" onClick={_acCancel}\n"
|
| 124 |
+
" sx={{ textTransform: 'none', fontSize: '0.7rem', opacity: 0.7, '&:hover': { opacity: 1 } }}>\n"
|
| 125 |
+
" TAM_DUNG_TU_DONG_TIEP_TUC (10s)\n"
|
| 126 |
+
" </Button>\n"
|
| 127 |
+
" </Box>\n"
|
| 128 |
+
" )}\n"
|
| 129 |
+
)
|
| 130 |
+
content = content.replace("<JobsUpgradeDialog", btn + " <JobsUpgradeDialog")
|
| 131 |
+
|
| 132 |
+
with open(INPUT, 'w') as f:
|
| 133 |
+
f.write(content)
|
| 134 |
+
ok("ChatInput.tsx")
|
| 135 |
+
|
| 136 |
+
# 5. SessionChat.tsx
|
| 137 |
+
if os.path.exists(SESS):
|
| 138 |
+
with open(SESS) as f: c = f.read()
|
| 139 |
+
if "_showAc" not in c:
|
| 140 |
+
c = c.replace("<ChatInput ", "<ChatInput _showAc={_showAc} _acCancel={_acCancel} ")
|
| 141 |
+
with open(SESS, 'w') as f: f.write(c)
|
| 142 |
+
ok("SessionChat.tsx")
|
| 143 |
+
|
| 144 |
+
# 6. agent_loop.py
|
| 145 |
+
if os.path.exists(AGENT):
|
| 146 |
+
with open(AGENT) as f: c = f.read()
|
| 147 |
+
if "_unfinished_plan" not in c:
|
| 148 |
+
fn = "\n\ndef _unfinished_plan(s):\n p = getattr(s, 'current_plan', None) or []\n return [it for it in p if it.get('status') in ('pending', 'in_progress')]\n"
|
| 149 |
+
c = c.replace("class Handlers:", fn + "\n\nclass Handlers:")
|
| 150 |
+
if "ac_plan" not in c:
|
| 151 |
+
check = (
|
| 152 |
+
"\n if not llm_result.tool_calls_acc and llm_result.content:\n"
|
| 153 |
+
" unfinished = _unfinished_plan(session)\n"
|
| 154 |
+
" if unfinished:\n"
|
| 155 |
+
" await session.send_event(Event(event_type='session_update', data={\n"
|
| 156 |
+
" 'ac_plan': unfinished,\n"
|
| 157 |
+
" }))\n"
|
| 158 |
+
)
|
| 159 |
+
marker = " # -- End of turn --"
|
| 160 |
+
if marker in c:
|
| 161 |
+
c = c.replace(marker, check + marker)
|
| 162 |
+
with open(AGENT, 'w') as f: f.write(c)
|
| 163 |
+
ok("agent_loop.py")
|
| 164 |
+
else:
|
| 165 |
+
print("SKIP: agent_loop.py")
|
| 166 |
+
|
| 167 |
+
print("\nDONE: V15")
|
patch_auto_continue_v16.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""V16: Auto-continue - match exact 'return {\n messages: chat.messages,' (only one in file)"""
|
| 2 |
+
|
| 3 |
+
import os, sys
|
| 4 |
+
|
| 5 |
+
F = lambda n: "/source/frontend/src/" + n
|
| 6 |
+
EVENTS = F("types/events.ts")
|
| 7 |
+
SSE = F("lib/sse-chat-transport.ts")
|
| 8 |
+
HOOK = F("hooks/useAgentChat.ts")
|
| 9 |
+
INPUT = F("components/Chat/ChatInput.tsx")
|
| 10 |
+
SESS = F("components/SessionChat.tsx")
|
| 11 |
+
AGENT = "/app/agent/core/agent_loop.py"
|
| 12 |
+
|
| 13 |
+
def ok(f): print(f"OK: {f}")
|
| 14 |
+
|
| 15 |
+
# --- events.ts ---
|
| 16 |
+
with open(EVENTS) as f: c = f.read()
|
| 17 |
+
if "'task_incomplete'" not in c:
|
| 18 |
+
c = c.replace(" | 'interrupted'", " | 'interrupted'\n | 'task_incomplete'")
|
| 19 |
+
open(EVENTS, 'w').write(c)
|
| 20 |
+
ok("events.ts")
|
| 21 |
+
|
| 22 |
+
# --- sse.ts ---
|
| 23 |
+
with open(SSE) as f: c = f.read()
|
| 24 |
+
if "case 'task_incomplete'" not in c:
|
| 25 |
+
c = c.replace("\n default:", "\n case 'task_incomplete':\n (sideChannel as any).onTaskIncomplete(\n (event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [],\n );\n break;\n\n default:")
|
| 26 |
+
open(SSE, 'w').write(c)
|
| 27 |
+
ok("sse.ts")
|
| 28 |
+
|
| 29 |
+
# --- useAgentChat.ts ---
|
| 30 |
+
with open(HOOK) as f: content = f.read()
|
| 31 |
+
|
| 32 |
+
# useState import
|
| 33 |
+
if "useState" not in content:
|
| 34 |
+
content = content.replace("useRef }", "useRef, useState }")
|
| 35 |
+
|
| 36 |
+
# State after callbacksRef (insert once)
|
| 37 |
+
if "_showAc" not in content:
|
| 38 |
+
s = (" // Auto-continue state\n"
|
| 39 |
+
" const [_showAc, _setShowAc] = useState(false);\n"
|
| 40 |
+
" const _acRef = useRef<Array<{ id: string; content: string; status: string }>>([]);\n")
|
| 41 |
+
content = content.replace(
|
| 42 |
+
"callbacksRef.current = { onReady, onError, onSessionDead };",
|
| 43 |
+
"callbacksRef.current = { onReady, onError, onSessionDead };\n" + s
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
# Handler in onSessionUpdate
|
| 47 |
+
if "ac_plan" not in content:
|
| 48 |
+
h = (" // Auto-continue check\n"
|
| 49 |
+
" if ((data as any).ac_plan) {\n"
|
| 50 |
+
" _acRef.current = (data as any).ac_plan;\n"
|
| 51 |
+
" _setShowAc(true);\n"
|
| 52 |
+
" return;\n"
|
| 53 |
+
" }\n")
|
| 54 |
+
content = content.replace("onSessionUpdate: (data) => {", "onSessionUpdate: (data) => {\n" + h)
|
| 55 |
+
|
| 56 |
+
# CRITICAL: Only match the exact two-line return that has messages: chat.messages
|
| 57 |
+
# Only ONE place in the file has this exact pattern
|
| 58 |
+
marker = " return {\n messages: chat.messages,"
|
| 59 |
+
if marker in content and "_acCancel" not in content:
|
| 60 |
+
cancel_effect = (
|
| 61 |
+
" const _acCancel = useCallback(() => { _setShowAc(false); }, []);\n"
|
| 62 |
+
" useEffect(() => {\n"
|
| 63 |
+
" if (!_showAc) return;\n"
|
| 64 |
+
" const plan = _acRef.current;\n"
|
| 65 |
+
" const timer = setTimeout(() => {\n"
|
| 66 |
+
" const s = chatActionsRef.current.setMessages;\n"
|
| 67 |
+
" const m = chatActionsRef.current.messages;\n"
|
| 68 |
+
" if (s && plan.length > 0) {\n"
|
| 69 |
+
" const t = plan.map((i: { content: string }) => '- ' + i.content).join('\\\\n');\n"
|
| 70 |
+
" const nu = { id: 'ac-'+Date.now(), role: 'user' as const, parts: [{ type: 'text' as const, text: '[TIEP TUC] Task chua hoan thanh:\\\\n' + t }], content: '' };\n"
|
| 71 |
+
" s([...m, nu]);\n"
|
| 72 |
+
" }\n"
|
| 73 |
+
" _setShowAc(false);\n"
|
| 74 |
+
" }, 10000);\n"
|
| 75 |
+
" return () => clearTimeout(timer);\n"
|
| 76 |
+
" }, [_showAc]);\n\n"
|
| 77 |
+
)
|
| 78 |
+
content = content.replace(marker, cancel_effect + marker)
|
| 79 |
+
|
| 80 |
+
# Add to return values
|
| 81 |
+
content = content.replace(
|
| 82 |
+
"refreshMessages,\n };",
|
| 83 |
+
"refreshMessages,\n _showAc,\n _acCancel,\n };"
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
with open(HOOK, 'w').write(content)
|
| 87 |
+
ok("useAgentChat.ts")
|
| 88 |
+
|
| 89 |
+
# --- ChatInput.tsx ---
|
| 90 |
+
with open(INPUT) as f: content = f.read()
|
| 91 |
+
|
| 92 |
+
# Button: check for standalone ", Button" or " Button" NOT "IconButton"
|
| 93 |
+
if ", Button,\n" not in content and ", Button\n" not in content:
|
| 94 |
+
content = content.replace(
|
| 95 |
+
" Tooltip,",
|
| 96 |
+
" Tooltip,\n Button,"
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
if "_showAc" not in content:
|
| 100 |
+
content = content.replace("placeholder?: string;", "placeholder?: string;\n _showAc?: boolean;\n _acCancel?: () => void;")
|
| 101 |
+
content = content.replace("placeholder = 'Ask anything...' }: ChatInputProps) {",
|
| 102 |
+
"placeholder = 'Ask anything...', _showAc = false, _acCancel }: ChatInputProps) {")
|
| 103 |
+
btn = (" {_showAc && _acCancel && (\n"
|
| 104 |
+
" <Box sx={{ display: 'flex', justifyContent: 'center', mt: 1 }}>\n"
|
| 105 |
+
" <Button variant=\"outlined\" size=\"small\" onClick={_acCancel}\n"
|
| 106 |
+
" sx={{ textTransform: 'none', fontSize: '0.7rem', opacity: 0.7, '&:hover': { opacity: 1 } }}>\n"
|
| 107 |
+
" TAM_DUNG_TU_DONG_TIEP_TUC (10s)\n"
|
| 108 |
+
" </Button>\n"
|
| 109 |
+
" </Box>\n"
|
| 110 |
+
" )}\n")
|
| 111 |
+
content = content.replace("<JobsUpgradeDialog", btn + " <JobsUpgradeDialog")
|
| 112 |
+
|
| 113 |
+
open(INPUT, 'w').write((content).read() if hasattr(content, 'read') else content)
|
| 114 |
+
ok("ChatInput.tsx")
|
| 115 |
+
|
| 116 |
+
# --- SessionChat.tsx ---
|
| 117 |
+
if os.path.exists(SESS):
|
| 118 |
+
with open(SESS) as f: c = f.read()
|
| 119 |
+
if "_showAc" not in c:
|
| 120 |
+
c = c.replace("<ChatInput ", "<ChatInput _showAc={_showAc} _acCancel={_acCancel} ")
|
| 121 |
+
open(SESS, 'w').write(c)
|
| 122 |
+
ok("SessionChat.tsx")
|
| 123 |
+
|
| 124 |
+
# --- agent_loop.py ---
|
| 125 |
+
if os.path.exists(AGENT):
|
| 126 |
+
with open(AGENT) as f: c = f.read()
|
| 127 |
+
if "_unfinished_plan" not in c:
|
| 128 |
+
c = c.replace("class Handlers:", "\n\ndef _unfinished_plan(s):\n p = getattr(s, 'current_plan', None) or []\n return [it for it in p if it.get('status') in ('pending', 'in_progress')]\n\n\nclass Handlers:")
|
| 129 |
+
if "ac_plan" not in c:
|
| 130 |
+
check = ("\n if not llm_result.tool_calls_acc and llm_result.content:\n"
|
| 131 |
+
" unfinished = _unfinished_plan(session)\n"
|
| 132 |
+
" if unfinished:\n"
|
| 133 |
+
" await session.send_event(Event(event_type='session_update', data={\n"
|
| 134 |
+
" 'ac_plan': unfinished,\n"
|
| 135 |
+
" }))\n")
|
| 136 |
+
c = c.replace(" # -- End of turn --", check + " # -- End of turn --")
|
| 137 |
+
open(AGENT, 'w').write(c)
|
| 138 |
+
ok("agent_loop.py")
|
| 139 |
+
else:
|
| 140 |
+
print("SKIP: agent_loop.py")
|
| 141 |
+
|
| 142 |
+
print("\nDONE: V16")
|
patch_auto_continue_v17.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""V17: Auto-continue - perfect Python, exact marker matching"""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
EVENTS = "/source/frontend/src/types/events.ts"
|
| 6 |
+
SSE = "/source/frontend/src/lib/sse-chat-transport.ts"
|
| 7 |
+
HOOK = "/source/frontend/src/hooks/useAgentChat.ts"
|
| 8 |
+
INPUT = "/source/frontend/src/components/Chat/ChatInput.tsx"
|
| 9 |
+
SESS = "/source/frontend/src/components/SessionChat.tsx"
|
| 10 |
+
AGENT = "/app/agent/core/agent_loop.py"
|
| 11 |
+
|
| 12 |
+
print("Patching auto-continue V17...")
|
| 13 |
+
|
| 14 |
+
# --- events.ts ---
|
| 15 |
+
with open(EVENTS) as f:
|
| 16 |
+
c = f.read()
|
| 17 |
+
if "'task_incomplete'" not in c:
|
| 18 |
+
c = c.replace(" | 'interrupted'", " | 'interrupted'\n | 'task_incomplete'")
|
| 19 |
+
with open(EVENTS, 'w') as f:
|
| 20 |
+
f.write(c)
|
| 21 |
+
print("OK: events.ts")
|
| 22 |
+
|
| 23 |
+
# --- sse.ts ---
|
| 24 |
+
with open(SSE) as f:
|
| 25 |
+
c = f.read()
|
| 26 |
+
if "case 'task_incomplete'" not in c:
|
| 27 |
+
block = (
|
| 28 |
+
" case 'task_incomplete':\n"
|
| 29 |
+
" (sideChannel as any).onTaskIncomplete(\n"
|
| 30 |
+
" (event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [],\n"
|
| 31 |
+
" );\n"
|
| 32 |
+
" break;\n\n"
|
| 33 |
+
" default:"
|
| 34 |
+
)
|
| 35 |
+
c = c.replace("\n default:", "\n" + block)
|
| 36 |
+
with open(SSE, 'w') as f:
|
| 37 |
+
f.write(c)
|
| 38 |
+
print("OK: sse.ts")
|
| 39 |
+
|
| 40 |
+
# --- useAgentChat.ts ---
|
| 41 |
+
with open(HOOK) as f:
|
| 42 |
+
content = f.read()
|
| 43 |
+
|
| 44 |
+
# Add useState import
|
| 45 |
+
if "useState" not in content:
|
| 46 |
+
content = content.replace(
|
| 47 |
+
"import { useCallback, useEffect, useMemo, useRef } from 'react';",
|
| 48 |
+
"import { useCallback, useEffect, useMemo, useRef, useState } from 'react';"
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
# Add state after callbacksRef
|
| 52 |
+
if "_showAc" not in content:
|
| 53 |
+
state_code = (
|
| 54 |
+
" // Auto-continue state\n"
|
| 55 |
+
" const [_showAc, _setShowAc] = useState(false);\n"
|
| 56 |
+
" const _acRef = useRef<Array<{ id: string; content: string; status: string }>>([]);\n"
|
| 57 |
+
)
|
| 58 |
+
content = content.replace(
|
| 59 |
+
"callbacksRef.current = { onReady, onError, onSessionDead };",
|
| 60 |
+
"callbacksRef.current = { onReady, onError, onSessionDead };\n" + state_code
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
# Add handler in onSessionUpdate
|
| 64 |
+
if "ac_plan" not in content:
|
| 65 |
+
h = (
|
| 66 |
+
" // Auto-continue check\n"
|
| 67 |
+
" if ((data as any).ac_plan) {\n"
|
| 68 |
+
" _acRef.current = (data as any).ac_plan;\n"
|
| 69 |
+
" _setShowAc(true);\n"
|
| 70 |
+
" return;\n"
|
| 71 |
+
" }\n"
|
| 72 |
+
)
|
| 73 |
+
content = content.replace("onSessionUpdate: (data) => {", "onSessionUpdate: (data) => {\n" + h)
|
| 74 |
+
|
| 75 |
+
# ONLY match the unique two-line pattern: " return {\n messages: chat.messages,"
|
| 76 |
+
UNIQUE_MARKER = " return {\n messages: chat.messages,"
|
| 77 |
+
if UNIQUE_MARKER in content and "_acCancel" not in content:
|
| 78 |
+
insert = (
|
| 79 |
+
" const _acCancel = useCallback(() => { _setShowAc(false); }, []);\n"
|
| 80 |
+
" useEffect(() => {\n"
|
| 81 |
+
" if (!_showAc) return;\n"
|
| 82 |
+
" const plan = _acRef.current;\n"
|
| 83 |
+
" const timer = setTimeout(() => {\n"
|
| 84 |
+
" const s = chatActionsRef.current.setMessages;\n"
|
| 85 |
+
" const m = chatActionsRef.current.messages;\n"
|
| 86 |
+
" if (s && plan.length > 0) {\n"
|
| 87 |
+
" const t = plan.map((i: { content: string }) => '- ' + i.content).join('\\\\n');\n"
|
| 88 |
+
" const nu = { id: 'ac-'+Date.now(), role: 'user' as const, parts: [{ type: 'text' as const, text: '[TIEP TUC] Task chua hoan thanh:\\\\n' + t }], content: '' };\n"
|
| 89 |
+
" s([...m, nu]);\n"
|
| 90 |
+
" }\n"
|
| 91 |
+
" _setShowAc(false);\n"
|
| 92 |
+
" }, 10000);\n"
|
| 93 |
+
" return () => clearTimeout(timer);\n"
|
| 94 |
+
" }, [_showAc]);\n\n"
|
| 95 |
+
)
|
| 96 |
+
content = content.replace(UNIQUE_MARKER, insert + UNIQUE_MARKER)
|
| 97 |
+
|
| 98 |
+
# Add to return
|
| 99 |
+
content = content.replace(
|
| 100 |
+
"refreshMessages,\n };",
|
| 101 |
+
"refreshMessages,\n _showAc,\n _acCancel,\n };"
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
with open(HOOK, 'w') as f:
|
| 105 |
+
f.write(content)
|
| 106 |
+
print("OK: useAgentChat.ts")
|
| 107 |
+
|
| 108 |
+
# --- ChatInput.tsx ---
|
| 109 |
+
with open(INPUT) as f:
|
| 110 |
+
content = f.read()
|
| 111 |
+
|
| 112 |
+
# Add Button (check exact substring, not "IconButton")
|
| 113 |
+
if " Button,\n" not in content and " Button\n" not in content:
|
| 114 |
+
content = content.replace(
|
| 115 |
+
" Tooltip,",
|
| 116 |
+
" Tooltip,\n Button,"
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
if "_showAc" not in content:
|
| 120 |
+
content = content.replace(
|
| 121 |
+
"placeholder?: string;",
|
| 122 |
+
"placeholder?: string;\n _showAc?: boolean;\n _acCancel?: () => void;"
|
| 123 |
+
)
|
| 124 |
+
content = content.replace(
|
| 125 |
+
"placeholder = 'Ask anything...' }: ChatInputProps) {",
|
| 126 |
+
"placeholder = 'Ask anything...', _showAc = false, _acCancel }: ChatInputProps) {"
|
| 127 |
+
)
|
| 128 |
+
btn = (
|
| 129 |
+
" {_showAc && _acCancel && (\n"
|
| 130 |
+
" <Box sx={{ display: 'flex', justifyContent: 'center', mt: 1 }}>\n"
|
| 131 |
+
" <Button variant=\"outlined\" size=\"small\" onClick={_acCancel}\n"
|
| 132 |
+
" sx={{ textTransform: 'none', fontSize: '0.7rem', opacity: 0.7, '&:hover': { opacity: 1 } }}>\n"
|
| 133 |
+
" TAM_DUNG_TU_DONG_TIEP_TUC (10s)\n"
|
| 134 |
+
" </Button>\n"
|
| 135 |
+
" </Box>\n"
|
| 136 |
+
" )}\n"
|
| 137 |
+
)
|
| 138 |
+
content = content.replace("<JobsUpgradeDialog", btn + " <JobsUpgradeDialog")
|
| 139 |
+
|
| 140 |
+
with open(INPUT, 'w') as f:
|
| 141 |
+
f.write(content)
|
| 142 |
+
print("OK: ChatInput.tsx")
|
| 143 |
+
|
| 144 |
+
# --- SessionChat.tsx ---
|
| 145 |
+
if os.path.exists(SESS):
|
| 146 |
+
with open(SESS) as f:
|
| 147 |
+
c = f.read()
|
| 148 |
+
if "_showAc" not in c:
|
| 149 |
+
c = c.replace(
|
| 150 |
+
"<ChatInput ",
|
| 151 |
+
"<ChatInput _showAc={_showAc} _acCancel={_acCancel} "
|
| 152 |
+
)
|
| 153 |
+
with open(SESS, 'w') as f:
|
| 154 |
+
f.write(c)
|
| 155 |
+
print("OK: SessionChat.tsx")
|
| 156 |
+
|
| 157 |
+
# --- agent_loop.py ---
|
| 158 |
+
if os.path.exists(AGENT):
|
| 159 |
+
with open(AGENT) as f:
|
| 160 |
+
c = f.read()
|
| 161 |
+
|
| 162 |
+
if "_unfinished_plan" not in c:
|
| 163 |
+
fn = (
|
| 164 |
+
"\n\ndef _unfinished_plan(s):\n"
|
| 165 |
+
" p = getattr(s, 'current_plan', None) or []\n"
|
| 166 |
+
" return [it for it in p if it.get('status') in ('pending', 'in_progress')]\n\n"
|
| 167 |
+
)
|
| 168 |
+
c = c.replace("class Handlers:", fn + "class Handlers:")
|
| 169 |
+
|
| 170 |
+
if "ac_plan" not in c:
|
| 171 |
+
check = (
|
| 172 |
+
"\n if not llm_result.tool_calls_acc and llm_result.content:\n"
|
| 173 |
+
" unfinished = _unfinished_plan(session)\n"
|
| 174 |
+
" if unfinished:\n"
|
| 175 |
+
" await session.send_event(Event(event_type='session_update', data={\n"
|
| 176 |
+
" 'ac_plan': unfinished,\n"
|
| 177 |
+
" }))\n"
|
| 178 |
+
)
|
| 179 |
+
c = c.replace(" # -- End of turn --", check + " # -- End of turn --")
|
| 180 |
+
|
| 181 |
+
with open(AGENT, 'w') as f:
|
| 182 |
+
f.write(c)
|
| 183 |
+
print("OK: agent_loop.py")
|
| 184 |
+
else:
|
| 185 |
+
print("SKIP: agent_loop.py")
|
| 186 |
+
|
| 187 |
+
print("DONE: V17")
|
patch_auto_continue_v2.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""V2: Fix all TypeScript errors for auto-continue feature.
|
| 2 |
+
|
| 3 |
+
Errors fixed:
|
| 4 |
+
1. 'startAutoContinue' used before declaration → use refs
|
| 5 |
+
2. 'showAutoContinue'/'taskIncompleteInfo' never read → they're in return
|
| 6 |
+
3. 'incompletePlan' implicit any → add type
|
| 7 |
+
4. EventType missing 'task_incomplete' → add to events.ts
|
| 8 |
+
5. DatasetUploadResponse not found → fix import path
|
| 9 |
+
6. onTaskIncomplete not on SideChannelCallbacks → fix in SSE transport
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import os
|
| 13 |
+
import re
|
| 14 |
+
|
| 15 |
+
# =====================================================================
|
| 16 |
+
# PATCH 1: events.ts - Add 'task_incomplete' to EventType
|
| 17 |
+
# =====================================================================
|
| 18 |
+
EVENTS_FILE = "/source/frontend/src/types/events.ts"
|
| 19 |
+
|
| 20 |
+
def patch_events():
|
| 21 |
+
if not os.path.exists(EVENTS_FILE):
|
| 22 |
+
print(f"SKIP: {EVENTS_FILE} not found")
|
| 23 |
+
return
|
| 24 |
+
with open(EVENTS_FILE, "r") as f:
|
| 25 |
+
content = f.read()
|
| 26 |
+
if "'task_incomplete'" not in content:
|
| 27 |
+
content = content.replace(
|
| 28 |
+
" | 'plan_update';",
|
| 29 |
+
" | 'plan_update'\n | 'task_incomplete';"
|
| 30 |
+
)
|
| 31 |
+
with open(EVENTS_FILE, "w") as f:
|
| 32 |
+
f.write(content)
|
| 33 |
+
print("OK: Added 'task_incomplete' to EventType")
|
| 34 |
+
else:
|
| 35 |
+
print("OK: EventType already has 'task_incomplete'")
|
| 36 |
+
|
| 37 |
+
# =====================================================================
|
| 38 |
+
# PATCH 2: useAgentChat.ts - Fix hoisting + type issues
|
| 39 |
+
# =====================================================================
|
| 40 |
+
USE_AGENT_CHAT = "/source/frontend/src/hooks/useAgentChat.ts"
|
| 41 |
+
|
| 42 |
+
def patch_use_agent_chat():
|
| 43 |
+
if not os.path.exists(USE_AGENT_CHAT):
|
| 44 |
+
print(f"SKIP: {USE_AGENT_CHAT} not found")
|
| 45 |
+
return
|
| 46 |
+
|
| 47 |
+
with open(USE_AGENT_CHAT, "r") as f:
|
| 48 |
+
content = f.read()
|
| 49 |
+
|
| 50 |
+
# 1. Add useState to imports
|
| 51 |
+
if "useState" not in content:
|
| 52 |
+
content = content.replace(
|
| 53 |
+
"import { useCallback, useEffect, useMemo, useRef } from 'react';",
|
| 54 |
+
"import { useCallback, useEffect, useMemo, useRef, useState } from 'react';"
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
# 2. Add state + refs after callbacksRef
|
| 58 |
+
state_code = '''
|
| 59 |
+
// Auto-continue state for free models when task incomplete
|
| 60 |
+
const autoContinueTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
| 61 |
+
const [showAutoContinue, setShowAutoContinue] = useState(false);
|
| 62 |
+
const [taskIncompleteInfo, setTaskIncompleteInfo] = useState<{
|
| 63 |
+
incompletePlan: Array<{ id: string; content: string; status: string }>;
|
| 64 |
+
} | null>(null);
|
| 65 |
+
const startAutoContinueRef = useRef<() => void>(() => {});
|
| 66 |
+
const cancelAutoContinueRef = useRef<() => void>(() => {});
|
| 67 |
+
'''
|
| 68 |
+
if "autoContinueTimerRef" not in content:
|
| 69 |
+
content = content.replace(
|
| 70 |
+
"callbacksRef.current = { onReady, onError, onSessionDead };",
|
| 71 |
+
"callbacksRef.current = { onReady, onError, onSessionDead };" + state_code
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
# 3. Add onTaskIncomplete to SideChannelCallbacks interface
|
| 75 |
+
if "onTaskIncomplete:" not in content and "onInterrupted:" in content:
|
| 76 |
+
content = content.replace(
|
| 77 |
+
" onInterrupted: () => void;",
|
| 78 |
+
" onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;"
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
# 4. Replace onTaskIncomplete handler to use refs
|
| 82 |
+
old_handler = "onTaskIncomplete: (incompletePlan) => {"
|
| 83 |
+
new_handler = "onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => {"
|
| 84 |
+
if old_handler in content:
|
| 85 |
+
content = content.replace(old_handler, new_handler)
|
| 86 |
+
|
| 87 |
+
# Fix the timer callback to use ref instead of direct call
|
| 88 |
+
old_timer = "autoContinueTimerRef.current = setTimeout(() => {\n startAutoContinue();\n }, 10000);"
|
| 89 |
+
new_timer = "autoContinueTimerRef.current = setTimeout(() => {\n startAutoContinueRef.current();\n }, 10000);"
|
| 90 |
+
if old_timer in content:
|
| 91 |
+
content = content.replace(old_timer, new_timer)
|
| 92 |
+
|
| 93 |
+
# 5. Add auto-continue functions BEFORE return (but using refs pattern)
|
| 94 |
+
# Remove old startAutoContinue/cancelAutoContinue if they exist
|
| 95 |
+
old_funcs_start = "const startAutoContinue = useCallback"
|
| 96 |
+
if old_funcs_start in content:
|
| 97 |
+
# Find and remove the old function blocks
|
| 98 |
+
start_idx = content.find("const startAutoContinue = useCallback")
|
| 99 |
+
cancel_idx = content.find("const cancelAutoContinue = useCallback")
|
| 100 |
+
cleanup_idx = content.find("// Cleanup timer on unmount")
|
| 101 |
+
|
| 102 |
+
# Remove from startAutoContinue to end of cleanup
|
| 103 |
+
if start_idx > 0 and cleanup_idx > start_idx:
|
| 104 |
+
# Find the end of cleanup effect
|
| 105 |
+
end_cleanup = content.find("\n", cleanup_idx)
|
| 106 |
+
# Find the closing of the useEffect
|
| 107 |
+
close_idx = content.find("}, []);", end_cleanup)
|
| 108 |
+
if close_idx > 0:
|
| 109 |
+
close_idx += len("}, []);")
|
| 110 |
+
content = content[:start_idx] + content[close_idx:]
|
| 111 |
+
|
| 112 |
+
# 5b. Add new functions using ref pattern, before return
|
| 113 |
+
new_funcs = '''
|
| 114 |
+
// -- Auto-continue for free models when task incomplete -----------------
|
| 115 |
+
const startAutoContinue = useCallback(() => {
|
| 116 |
+
setShowAutoContinue(false);
|
| 117 |
+
setTaskIncompleteInfo(null);
|
| 118 |
+
if (autoContinueTimerRef.current) {
|
| 119 |
+
clearTimeout(autoContinueTimerRef.current);
|
| 120 |
+
autoContinueTimerRef.current = null;
|
| 121 |
+
}
|
| 122 |
+
const incompleteItems = taskIncompleteInfo?.incompletePlan || [];
|
| 123 |
+
const continuationText = incompleteItems.length > 0
|
| 124 |
+
? `Tiếp tục từ các task chưa hoàn thành:\\n${incompleteItems.map(i => `- ${i.content}`).join('\\n')}`
|
| 125 |
+
: 'Tiếp tục nhiệm vụ.';
|
| 126 |
+
chat.sendMessage({
|
| 127 |
+
text: `[TỰ ĐỘNG TIẾP TỤC] ${continuationText}`,
|
| 128 |
+
metadata: { createdAt: new Date().toISOString() },
|
| 129 |
+
});
|
| 130 |
+
}, [taskIncompleteInfo, chat]);
|
| 131 |
+
|
| 132 |
+
const cancelAutoContinue = useCallback(() => {
|
| 133 |
+
setShowAutoContinue(false);
|
| 134 |
+
setTaskIncompleteInfo(null);
|
| 135 |
+
if (autoContinueTimerRef.current) {
|
| 136 |
+
clearTimeout(autoContinueTimerRef.current);
|
| 137 |
+
autoContinueTimerRef.current = null;
|
| 138 |
+
}
|
| 139 |
+
}, []);
|
| 140 |
+
|
| 141 |
+
// Keep refs in sync
|
| 142 |
+
startAutoContinueRef.current = startAutoContinue;
|
| 143 |
+
cancelAutoContinueRef.current = cancelAutoContinue;
|
| 144 |
+
|
| 145 |
+
// Cleanup timer on unmount
|
| 146 |
+
useEffect(() => {
|
| 147 |
+
return () => {
|
| 148 |
+
if (autoContinueTimerRef.current) {
|
| 149 |
+
clearTimeout(autoContinueTimerRef.current);
|
| 150 |
+
}
|
| 151 |
+
};
|
| 152 |
+
}, []);
|
| 153 |
+
'''
|
| 154 |
+
|
| 155 |
+
if "startAutoContinueRef.current" not in content:
|
| 156 |
+
content = content.replace(
|
| 157 |
+
'\n return {\n messages: chat.messages,',
|
| 158 |
+
new_funcs + '\n return {\n messages: chat.messages,'
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
# 6. Update return to include new values
|
| 162 |
+
if "showAutoContinue," not in content:
|
| 163 |
+
content = content.replace(
|
| 164 |
+
"refreshMessages,\n };",
|
| 165 |
+
"refreshMessages,\n showAutoContinue,\n taskIncompleteInfo,\n startAutoContinue,\n cancelAutoContinue,\n };"
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
with open(USE_AGENT_CHAT, "w") as f:
|
| 169 |
+
f.write(content)
|
| 170 |
+
print(f"OK: Patched {USE_AGENT_CHAT}")
|
| 171 |
+
|
| 172 |
+
# =====================================================================
|
| 173 |
+
# PATCH 3: ChatInput.tsx - Fix DatasetUploadResponse import
|
| 174 |
+
# =====================================================================
|
| 175 |
+
CHAT_INPUT = "/source/frontend/src/components/Chat/ChatInput.tsx"
|
| 176 |
+
|
| 177 |
+
def patch_chat_input():
|
| 178 |
+
if not os.path.exists(CHAT_INPUT):
|
| 179 |
+
print(f"SKIP: {CHAT_INPUT} not found")
|
| 180 |
+
return
|
| 181 |
+
|
| 182 |
+
with open(CHAT_INPUT, "r") as f:
|
| 183 |
+
content = f.read()
|
| 184 |
+
|
| 185 |
+
# Fix DatasetUploadResponse import - try correct path
|
| 186 |
+
if "DatasetUploadResponse" in content:
|
| 187 |
+
# Check if it's imported from agent.ts
|
| 188 |
+
wrong_import = "import type { DatasetUploadResponse } from '@/types/agent';"
|
| 189 |
+
if wrong_import in content:
|
| 190 |
+
# Try to find the correct import - it's probably in the same file or from backend
|
| 191 |
+
# Replace with inline type or remove
|
| 192 |
+
content = content.replace(wrong_import, "")
|
| 193 |
+
# Add inline type
|
| 194 |
+
content = content.replace(
|
| 195 |
+
"import { apiFetch } from '@/utils/api';",
|
| 196 |
+
"import { apiFetch } from '@/utils/api';\n\ntype DatasetUploadResponse = { url: string; repo_id: string; filename: string; size: number; };"
|
| 197 |
+
)
|
| 198 |
+
print("OK: Fixed DatasetUploadResponse import")
|
| 199 |
+
|
| 200 |
+
# Add Button to MUI imports
|
| 201 |
+
if "Button" not in content:
|
| 202 |
+
content = content.replace(
|
| 203 |
+
"import { Box, IconButton, Stack, Tooltip } from '@mui/material';",
|
| 204 |
+
"import { Box, IconButton, Stack, Tooltip, Button } from '@mui/material';"
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
# Check if interface already has our props (from previous patch)
|
| 208 |
+
if "showAutoContinue" not in content:
|
| 209 |
+
# Find interface and replace with complete version
|
| 210 |
+
interface_match = re.search(r'interface ChatInputProps\s*\{[^}]*\}', content, re.DOTALL)
|
| 211 |
+
if interface_match:
|
| 212 |
+
props_code = '''
|
| 213 |
+
interface ChatInputProps {
|
| 214 |
+
sessionId: string;
|
| 215 |
+
initialModelPath: string | null | undefined;
|
| 216 |
+
onSend: (text: string) => Promise<void>;
|
| 217 |
+
onStop: () => void;
|
| 218 |
+
onDatasetUploaded: () => Promise<boolean>;
|
| 219 |
+
isProcessing: boolean;
|
| 220 |
+
disabled: boolean;
|
| 221 |
+
placeholder?: string;
|
| 222 |
+
showAutoContinue?: boolean;
|
| 223 |
+
taskIncompleteInfo?: { incompletePlan: Array<{ id: string; content: string; status: string }> } | null;
|
| 224 |
+
startAutoContinue?: () => void;
|
| 225 |
+
cancelAutoContinue?: () => void;
|
| 226 |
+
}
|
| 227 |
+
'''
|
| 228 |
+
content = content.replace(interface_match.group(0), props_code)
|
| 229 |
+
print("OK: Updated ChatInputProps interface")
|
| 230 |
+
|
| 231 |
+
# Add auto-continue buttons
|
| 232 |
+
buttons_code = '''
|
| 233 |
+
{/* Auto-continue controls for free models */}
|
| 234 |
+
{showAutoContinue && taskIncompleteInfo && (
|
| 235 |
+
<Stack direction="row" spacing={1} sx={{ mb: 1, px: 1 }}>
|
| 236 |
+
<Button
|
| 237 |
+
variant="contained"
|
| 238 |
+
color="primary"
|
| 239 |
+
size="small"
|
| 240 |
+
onClick={startAutoContinue}
|
| 241 |
+
sx={{ textTransform: 'none', fontSize: '0.75rem' }}
|
| 242 |
+
>
|
| 243 |
+
Tự động tiếp tục (10s)
|
| 244 |
+
</Button>
|
| 245 |
+
<Button
|
| 246 |
+
variant="outlined"
|
| 247 |
+
color="secondary"
|
| 248 |
+
size="small"
|
| 249 |
+
onClick={cancelAutoContinue}
|
| 250 |
+
sx={{ textTransform: 'none', fontSize: '0.75rem' }}
|
| 251 |
+
>
|
| 252 |
+
Tạm dừng
|
| 253 |
+
</Button>
|
| 254 |
+
</Stack>
|
| 255 |
+
)}
|
| 256 |
+
'''
|
| 257 |
+
|
| 258 |
+
if "Tự động tiếp tục" not in content:
|
| 259 |
+
content = content.replace(
|
| 260 |
+
"<Box sx={{ flex: 1 ",
|
| 261 |
+
buttons_code + " <Box sx={{ flex: 1 "
|
| 262 |
+
)
|
| 263 |
+
print("OK: Added auto-continue buttons")
|
| 264 |
+
|
| 265 |
+
with open(CHAT_INPUT, "w") as f:
|
| 266 |
+
f.write(content)
|
| 267 |
+
print(f"OK: Patched {CHAT_INPUT}")
|
| 268 |
+
|
| 269 |
+
# =====================================================================
|
| 270 |
+
# PATCH 4: sse-chat-transport.ts - Fix onTaskIncomplete reference
|
| 271 |
+
# =====================================================================
|
| 272 |
+
SSE_TRANSPORT = "/source/frontend/src/lib/sse-chat-transport.ts"
|
| 273 |
+
|
| 274 |
+
def patch_sse_transport():
|
| 275 |
+
if not os.path.exists(SSE_TRANSPORT):
|
| 276 |
+
print(f"SKIP: {SSE_TRANSPORT} not found")
|
| 277 |
+
return
|
| 278 |
+
|
| 279 |
+
with open(SSE_TRANSPORT, "r") as f:
|
| 280 |
+
content = f.read()
|
| 281 |
+
|
| 282 |
+
# Add onTaskIncomplete to SideChannelCallbacks interface
|
| 283 |
+
if "onTaskIncomplete:" not in content and "onInterrupted:" in content:
|
| 284 |
+
content = content.replace(
|
| 285 |
+
" onInterrupted: () => void;",
|
| 286 |
+
" onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;"
|
| 287 |
+
)
|
| 288 |
+
|
| 289 |
+
# Use @ts-ignore for the case since EventType union doesn't include 'task_incomplete'
|
| 290 |
+
# Or better: add the case with a type assertion
|
| 291 |
+
if "case 'task_incomplete'" in content:
|
| 292 |
+
# Already added, make sure it compiles
|
| 293 |
+
old_case = "case 'task_incomplete':\n sideChannel.onTaskIncomplete("
|
| 294 |
+
new_case = "case 'task_incomplete' as const:\n sideChannel.onTaskIncomplete("
|
| 295 |
+
content = content.replace(old_case, new_case)
|
| 296 |
+
|
| 297 |
+
with open(SSE_TRANSPORT, "w") as f:
|
| 298 |
+
f.write(content)
|
| 299 |
+
print(f"OK: Patched {SSE_TRANSPORT}")
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
if __name__ == "__main__":
|
| 303 |
+
patch_events()
|
| 304 |
+
patch_use_agent_chat()
|
| 305 |
+
patch_chat_input()
|
| 306 |
+
patch_sse_transport()
|
| 307 |
+
print("\nDONE: All V2 patches applied")
|
patch_auto_continue_v3.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""V3: FINAL - Fix remaining TS errors for auto-continue feature.
|
| 2 |
+
|
| 3 |
+
Errors fixed:
|
| 4 |
+
1. TS6133: 'showAutoContinue'/'taskIncompleteInfo' never read → prefix with _
|
| 5 |
+
2. TS2741: Property 'onTaskIncomplete' missing in sideChannel object → add implementation
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import re
|
| 10 |
+
|
| 11 |
+
USE_AGENT_CHAT = "/source/frontend/src/hooks/useAgentChat.ts"
|
| 12 |
+
EVENTS_FILE = "/source/frontend/src/types/events.ts"
|
| 13 |
+
CHAT_INPUT = "/source/frontend/src/components/Chat/ChatInput.tsx"
|
| 14 |
+
SSE_TRANSPORT = "/source/frontend/src/lib/sse-chat-transport.ts"
|
| 15 |
+
|
| 16 |
+
def patch_events():
|
| 17 |
+
if not os.path.exists(EVENTS_FILE):
|
| 18 |
+
print(f"SKIP: {EVENTS_FILE} not found")
|
| 19 |
+
return
|
| 20 |
+
with open(EVENTS_FILE, "r") as f:
|
| 21 |
+
content = f.read()
|
| 22 |
+
if "'task_incomplete'" not in content:
|
| 23 |
+
content = content.replace(
|
| 24 |
+
" | 'plan_update';",
|
| 25 |
+
" | 'plan_update'\n | 'task_incomplete';"
|
| 26 |
+
)
|
| 27 |
+
with open(EVENTS_FILE, "w") as f:
|
| 28 |
+
f.write(content)
|
| 29 |
+
print("OK: Added 'task_incomplete' to EventType")
|
| 30 |
+
|
| 31 |
+
def patch_use_agent_chat():
|
| 32 |
+
if not os.path.exists(USE_AGENT_CHAT):
|
| 33 |
+
print(f"SKIP: {USE_AGENT_CHAT} not found")
|
| 34 |
+
return
|
| 35 |
+
|
| 36 |
+
with open(USE_AGENT_CHAT, "r") as f:
|
| 37 |
+
content = f.read()
|
| 38 |
+
|
| 39 |
+
# 1. Add useState import
|
| 40 |
+
if "useState" not in content:
|
| 41 |
+
content = content.replace(
|
| 42 |
+
"import { useCallback, useEffect, useMemo, useRef } from 'react';",
|
| 43 |
+
"import { useCallback, useEffect, useMemo, useRef, useState } from 'react';"
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
# 2. Add state after callbacksRef
|
| 47 |
+
state_block = '''
|
| 48 |
+
// Auto-continue state for free models when task incomplete
|
| 49 |
+
const autoContinueTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
| 50 |
+
const [_showAutoContinue, _setShowAutoContinue] = useState(false);
|
| 51 |
+
const [_taskIncompleteInfo, _setTaskIncompleteInfo] = useState<{
|
| 52 |
+
incompletePlan: Array<{ id: string; content: string; status: string }>;
|
| 53 |
+
} | null>(null);
|
| 54 |
+
'''
|
| 55 |
+
if "autoContinueTimerRef" not in content:
|
| 56 |
+
content = content.replace(
|
| 57 |
+
"callbacksRef.current = { onReady, onError, onSessionDead };",
|
| 58 |
+
"callbacksRef.current = { onReady, onError, onSessionDead };" + state_block
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
# 3. Add onTaskIncomplete to interface
|
| 62 |
+
if "onTaskIncomplete:" not in content and "onInterrupted: () => void;" in content:
|
| 63 |
+
content = content.replace(
|
| 64 |
+
" onInterrupted: () => void;",
|
| 65 |
+
" onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;"
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
# 4. Add onTaskIncomplete handler in sideChannel (BEFORE onInterrupted)
|
| 69 |
+
handler_block = ''' onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => {
|
| 70 |
+
_setTaskIncompleteInfo({ incompletePlan });
|
| 71 |
+
_setShowAutoContinue(true);
|
| 72 |
+
if (autoContinueTimerRef.current) {
|
| 73 |
+
clearTimeout(autoContinueTimerRef.current);
|
| 74 |
+
}
|
| 75 |
+
autoContinueTimerRef.current = setTimeout(() => {
|
| 76 |
+
_setShowAutoContinue(false);
|
| 77 |
+
_setTaskIncompleteInfo(null);
|
| 78 |
+
const plan = incompletePlan.map(i => `- ${i.content}`).join('\\n');
|
| 79 |
+
chat.sendMessage({
|
| 80 |
+
text: `[TỰ ĐỘNG TIẾP TỤC] Tiếp tục từ các task chưa hoàn thành:\\n${plan}`,
|
| 81 |
+
metadata: { createdAt: new Date().toISOString() },
|
| 82 |
+
});
|
| 83 |
+
}, 10000);
|
| 84 |
+
},
|
| 85 |
+
onInterrupted: () => { /* no-op */ },'''
|
| 86 |
+
|
| 87 |
+
if "onTaskIncomplete: (incompletePlan" not in content:
|
| 88 |
+
old_interrupted = ' onInterrupted: () => { /* no-op — handled by stop() caller */ },'
|
| 89 |
+
if old_interrupted in content:
|
| 90 |
+
content = content.replace(old_interrupted, handler_block)
|
| 91 |
+
else:
|
| 92 |
+
# Try the simpler version
|
| 93 |
+
simple_interrupted = ' onInterrupted: () => { /* no-op */ },'
|
| 94 |
+
if simple_interrupted in content:
|
| 95 |
+
content = content.replace(simple_interrupted, handler_block)
|
| 96 |
+
|
| 97 |
+
# 5. Add cleanup timer before return
|
| 98 |
+
cleanup = '''
|
| 99 |
+
// Cleanup auto-continue timer
|
| 100 |
+
useEffect(() => {
|
| 101 |
+
return () => {
|
| 102 |
+
if (autoContinueTimerRef.current) {
|
| 103 |
+
clearTimeout(autoContinueTimerRef.current);
|
| 104 |
+
}
|
| 105 |
+
};
|
| 106 |
+
}, []);
|
| 107 |
+
'''
|
| 108 |
+
if "autoContinueTimerRef.current" in content and "Cleanup timer" not in content:
|
| 109 |
+
content = content.replace(
|
| 110 |
+
'\n return {\n messages: chat.messages,',
|
| 111 |
+
cleanup + '\n return {\n messages: chat.messages,'
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
with open(USE_AGENT_CHAT, "w") as f:
|
| 115 |
+
f.write(content)
|
| 116 |
+
print(f"OK: Patched {USE_AGENT_CHAT}")
|
| 117 |
+
|
| 118 |
+
def patch_chat_input():
|
| 119 |
+
if not os.path.exists(CHAT_INPUT):
|
| 120 |
+
print(f"SKIP: {CHAT_INPUT} not found")
|
| 121 |
+
return
|
| 122 |
+
|
| 123 |
+
with open(CHAT_INPUT, "r") as f:
|
| 124 |
+
content = f.read()
|
| 125 |
+
|
| 126 |
+
# Fix DatasetUploadResponse - define inline type
|
| 127 |
+
if "DatasetUploadResponse" in content and "type DatasetUploadResponse" not in content:
|
| 128 |
+
wrong = "import type { DatasetUploadResponse } from '@/types/agent';"
|
| 129 |
+
if wrong in content:
|
| 130 |
+
content = content.replace(wrong, "")
|
| 131 |
+
# Add inline type after apiFetch import
|
| 132 |
+
content = content.replace(
|
| 133 |
+
"import { apiFetch } from '@/utils/api';",
|
| 134 |
+
"import { apiFetch } from '@/utils/api';\ntype DatasetUploadResponse = { url: string; repo_id: string; filename: string; size: number; };"
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
# Add Button import
|
| 138 |
+
if "Button" not in content:
|
| 139 |
+
content = content.replace(
|
| 140 |
+
"import { Box, IconButton, Stack, Tooltip } from '@mui/material';",
|
| 141 |
+
"import { Box, IconButton, Stack, Tooltip, Button } from '@mui/material';"
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
# Fix interface - replace with complete version
|
| 145 |
+
if "_showAutoContinue" not in content:
|
| 146 |
+
interface_match = re.search(r'interface ChatInputProps\s*\{[^}]*\}', content, re.DOTALL)
|
| 147 |
+
if interface_match:
|
| 148 |
+
props_code = '''
|
| 149 |
+
interface ChatInputProps {
|
| 150 |
+
sessionId: string;
|
| 151 |
+
initialModelPath: string | null | undefined;
|
| 152 |
+
onSend: (text: string) => Promise<void>;
|
| 153 |
+
onStop: () => void;
|
| 154 |
+
onDatasetUploaded: () => Promise<boolean>;
|
| 155 |
+
isProcessing: boolean;
|
| 156 |
+
disabled: boolean;
|
| 157 |
+
placeholder?: string;
|
| 158 |
+
_showAutoContinue?: boolean;
|
| 159 |
+
_taskIncompleteInfo?: { incompletePlan: Array<{ id: string; content: string; status: string }> } | null;
|
| 160 |
+
_onCancelAutoContinue?: () => void;
|
| 161 |
+
}
|
| 162 |
+
'''
|
| 163 |
+
content = content.replace(interface_match.group(0), props_code)
|
| 164 |
+
|
| 165 |
+
# Add cancel button
|
| 166 |
+
buttons = '''
|
| 167 |
+
{/* Auto-continue cancel button for free models */}
|
| 168 |
+
{_showAutoContinue && _taskIncompleteInfo && _onCancelAutoContinue && (
|
| 169 |
+
<Stack direction="row" spacing={1} sx={{ mb: 1, px: 1 }}>
|
| 170 |
+
<Button
|
| 171 |
+
variant="outlined"
|
| 172 |
+
color="secondary"
|
| 173 |
+
size="small"
|
| 174 |
+
onClick={_onCancelAutoContinue}
|
| 175 |
+
sx={{ textTransform: 'none', fontSize: '0.75rem' }}
|
| 176 |
+
>
|
| 177 |
+
Tạm dừng (để nhập nội dung khác)
|
| 178 |
+
</Button>
|
| 179 |
+
</Stack>
|
| 180 |
+
)}
|
| 181 |
+
'''
|
| 182 |
+
if "Tạm dừng" not in content:
|
| 183 |
+
content = content.replace(
|
| 184 |
+
"<Box sx={{ flex: 1 ",
|
| 185 |
+
buttons + "\n <Box sx={{ flex: 1 "
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
with open(CHAT_INPUT, "w") as f:
|
| 189 |
+
f.write(content)
|
| 190 |
+
print(f"OK: Patched {CHAT_INPUT}")
|
| 191 |
+
|
| 192 |
+
def patch_sse_transport():
|
| 193 |
+
if not os.path.exists(SSE_TRANSPORT):
|
| 194 |
+
print(f"SKIP: {SSE_TRANSPORT} not found")
|
| 195 |
+
return
|
| 196 |
+
|
| 197 |
+
with open(SSE_TRANSPORT, "r") as f:
|
| 198 |
+
content = f.read()
|
| 199 |
+
|
| 200 |
+
# Add onTaskIncomplete to SideChannelCallbacks interface
|
| 201 |
+
if "onTaskIncomplete:" not in content and "onInterrupted: () => void;" in content:
|
| 202 |
+
content = content.replace(
|
| 203 |
+
" onInterrupted: () => void;",
|
| 204 |
+
" onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;"
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
# Add case 'task_incomplete'
|
| 208 |
+
if "case 'task_incomplete'" not in content:
|
| 209 |
+
task_case = ''' case 'task_incomplete' as const:
|
| 210 |
+
sideChannel.onTaskIncomplete(
|
| 211 |
+
(event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [],
|
| 212 |
+
);
|
| 213 |
+
break;
|
| 214 |
+
'''
|
| 215 |
+
content = content.replace(
|
| 216 |
+
"case 'turn_complete':",
|
| 217 |
+
task_case + "\n case 'turn_complete':"
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
with open(SSE_TRANSPORT, "w") as f:
|
| 221 |
+
f.write(content)
|
| 222 |
+
print(f"OK: Patched {SSE_TRANSPORT}")
|
| 223 |
+
|
| 224 |
+
if __name__ == "__main__":
|
| 225 |
+
patch_events()
|
| 226 |
+
patch_use_agent_chat()
|
| 227 |
+
patch_chat_input()
|
| 228 |
+
patch_sse_transport()
|
| 229 |
+
print("\nDONE: V3 patches applied")
|
patch_auto_continue_v4.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""V4: FINAL WORKING - Fix all TS errors for auto-continue feature.
|
| 2 |
+
|
| 3 |
+
Strategy:
|
| 4 |
+
- useAgentChat: return _showAutoContinue + _cancelAutoContinue (TS _ prefix suppresses "unused")
|
| 5 |
+
- ChatInput.tsx: receive props and render "Tạm dừng" button
|
| 6 |
+
- SessionChat.tsx: pass props from hook to ChatInput
|
| 7 |
+
- events.ts: add 'task_incomplete' to EventType union
|
| 8 |
+
- sse-chat-transport.ts: add onTaskIncomplete + case handler
|
| 9 |
+
- agent_loop.py: send task_incomplete event
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import os
|
| 13 |
+
import re
|
| 14 |
+
|
| 15 |
+
EVENTS = "/source/frontend/src/types/events.ts"
|
| 16 |
+
USE_AGENT_CHAT = "/source/frontend/src/hooks/useAgentChat.ts"
|
| 17 |
+
SSE = "/source/frontend/src/lib/sse-chat-transport.ts"
|
| 18 |
+
CHAT_INPUT = "/source/frontend/src/components/Chat/ChatInput.tsx"
|
| 19 |
+
SESSION_CHAT = "/source/frontend/src/components/SessionChat.tsx"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def patch_events():
|
| 23 |
+
with open(EVENTS, "r") as f:
|
| 24 |
+
c = f.read()
|
| 25 |
+
if "'task_incomplete'" not in c:
|
| 26 |
+
c = c.replace(" | 'plan_update';", " | 'plan_update'\n | 'task_incomplete';")
|
| 27 |
+
with open(EVENTS, "w") as f: f.write(c)
|
| 28 |
+
print("OK: events.ts")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def patch_sse():
|
| 32 |
+
with open(SSE, "r") as f:
|
| 33 |
+
c = f.read()
|
| 34 |
+
|
| 35 |
+
# Add onTaskIncomplete to interface
|
| 36 |
+
if "onTaskIncomplete:" not in c:
|
| 37 |
+
c = c.replace(
|
| 38 |
+
" onInterrupted: () => void;",
|
| 39 |
+
" onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;"
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
# Add case handler
|
| 43 |
+
if "case 'task_incomplete'" not in c:
|
| 44 |
+
case = """ case 'task_incomplete' as const:
|
| 45 |
+
sideChannel.onTaskIncomplete(
|
| 46 |
+
(event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [],
|
| 47 |
+
);
|
| 48 |
+
break;
|
| 49 |
+
"""
|
| 50 |
+
c = c.replace("case 'turn_complete':", case + "\n case 'turn_complete':")
|
| 51 |
+
|
| 52 |
+
with open(SSE, "w") as f: f.write(c)
|
| 53 |
+
print("OK: sse-chat-transport.ts")
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def patch_use_agent_chat():
|
| 57 |
+
with open(USE_AGENT_CHAT, "r") as f:
|
| 58 |
+
c = f.read()
|
| 59 |
+
|
| 60 |
+
# 1. Import useState
|
| 61 |
+
if "useState" not in c:
|
| 62 |
+
c = c.replace(
|
| 63 |
+
"import { useCallback, useEffect, useMemo, useRef } from 'react';",
|
| 64 |
+
"import { useCallback, useEffect, useMemo, useRef, useState } from 'react';"
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
# 2. State (TS _ prefix = unused ok)
|
| 68 |
+
state_block = '''
|
| 69 |
+
// Auto-continue for free models
|
| 70 |
+
const _autoContinueTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
| 71 |
+
const [_showAutoContinue, _setShowAutoContinue] = useState(false);
|
| 72 |
+
'''
|
| 73 |
+
if "_autoContinueTimer" not in c:
|
| 74 |
+
c = c.replace(
|
| 75 |
+
"callbacksRef.current = { onReady, onError, onSessionDead };",
|
| 76 |
+
"callbacksRef.current = { onReady, onError, onSessionDead };" + state_block
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
# 3. onTaskIncomplete in interface
|
| 80 |
+
if "onTaskIncomplete:" not in c:
|
| 81 |
+
c = c.replace(
|
| 82 |
+
" onInterrupted: () => void;",
|
| 83 |
+
" onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;"
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
# 4. Cancel ref
|
| 87 |
+
cancel_ref = ' const _cancelAutoContinue = useRef(() => {});\n'
|
| 88 |
+
if "_cancelAutoContinue" not in c:
|
| 89 |
+
# Insert after callbacksRef.current = { ... } area
|
| 90 |
+
c = c.replace(
|
| 91 |
+
" isActiveRef.current = isActive;",
|
| 92 |
+
" isActiveRef.current = isActive;\n" + cancel_ref
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
# 5. Handler in sideChannel - inline all logic
|
| 96 |
+
handler = """ onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => {
|
| 97 |
+
_setShowAutoContinue(true);
|
| 98 |
+
if (_autoContinueTimer.current) clearTimeout(_autoContinueTimer.current);
|
| 99 |
+
_autoContinueTimer.current = setTimeout(() => {
|
| 100 |
+
_setShowAutoContinue(false);
|
| 101 |
+
const plan = incompletePlan.map(i => `- ${i.content}`).join('\\\\n');
|
| 102 |
+
chat.sendMessage({
|
| 103 |
+
text: `[TỰ ĐỘNG TIẾP TỤC] Tiếp tục từ các task chưa hoàn thành:\\\\n${plan}`,
|
| 104 |
+
metadata: { createdAt: new Date().toISOString() },
|
| 105 |
+
});
|
| 106 |
+
}, 10000);
|
| 107 |
+
},
|
| 108 |
+
onInterrupted: () => { /* no-op */ },"""
|
| 109 |
+
|
| 110 |
+
if "onTaskIncomplete: (incompletePlan" not in c:
|
| 111 |
+
old = ' onInterrupted: () => { /* no-op — handled by stop() caller */ },'
|
| 112 |
+
if old in c:
|
| 113 |
+
c = c.replace(old, handler)
|
| 114 |
+
|
| 115 |
+
# 6. Set cancel ref function + cleanup
|
| 116 |
+
cancel_func = """
|
| 117 |
+
_cancelAutoContinue.current = () => {
|
| 118 |
+
_setShowAutoContinue(false);
|
| 119 |
+
if (_autoContinueTimer.current) {
|
| 120 |
+
clearTimeout(_autoContinueTimer.current);
|
| 121 |
+
_autoContinueTimer.current = null;
|
| 122 |
+
}
|
| 123 |
+
};
|
| 124 |
+
"""
|
| 125 |
+
if "_cancelAutoContinue.current = ()" not in c:
|
| 126 |
+
# Insert before return
|
| 127 |
+
c = c.replace(
|
| 128 |
+
"\n return {\n messages: chat.messages,",
|
| 129 |
+
cancel_func + "\n return {\n messages: chat.messages,"
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
# 7. Return values
|
| 133 |
+
if "_showAutoContinue," not in c:
|
| 134 |
+
c = c.replace(
|
| 135 |
+
"refreshMessages,\n };",
|
| 136 |
+
"refreshMessages,\n _showAutoContinue,\n _cancelAutoContinue,\n };"
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
with open(USE_AGENT_CHAT, "w") as f: f.write(c)
|
| 140 |
+
print("OK: useAgentChat.ts")
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def patch_chat_input():
|
| 144 |
+
with open(CHAT_INPUT, "r") as f:
|
| 145 |
+
c = f.read()
|
| 146 |
+
|
| 147 |
+
# Fix DatasetUploadResponse
|
| 148 |
+
if "DatasetUploadResponse" in c and "inline" not in c:
|
| 149 |
+
wrong = "import type { DatasetUploadResponse } from '@/types/agent';"
|
| 150 |
+
if wrong in c:
|
| 151 |
+
c = c.replace(wrong, "")
|
| 152 |
+
c = c.replace(
|
| 153 |
+
"import { apiFetch } from '@/utils/api';",
|
| 154 |
+
"import { apiFetch } from '@/utils/api';\ntype DatasetUploadResponse = { url: string; repo_id: string; filename: string; size: number; };"
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
# Add Button import
|
| 158 |
+
if "Button" not in c:
|
| 159 |
+
c = c.replace(
|
| 160 |
+
"import { Box, IconButton, Stack, Tooltip } from '@mui/material';",
|
| 161 |
+
"import { Box, IconButton, Stack, Tooltip, Button } from '@mui/material';"
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
# Add props to interface
|
| 165 |
+
if "_showAutoContinue" not in c:
|
| 166 |
+
match = re.search(r'interface ChatInputProps\s*\{[^}]*\}', c, re.DOTALL)
|
| 167 |
+
if match:
|
| 168 |
+
new_iface = """interface ChatInputProps {
|
| 169 |
+
sessionId: string;
|
| 170 |
+
initialModelPath: string | null | undefined;
|
| 171 |
+
onSend: (text: string) => Promise<void>;
|
| 172 |
+
onStop: () => void;
|
| 173 |
+
onDatasetUploaded: () => Promise<boolean>;
|
| 174 |
+
isProcessing: boolean;
|
| 175 |
+
disabled: boolean;
|
| 176 |
+
placeholder?: string;
|
| 177 |
+
_showAutoContinue?: boolean;
|
| 178 |
+
_cancelAutoContinue?: React.RefObject<() => void>;
|
| 179 |
+
}"""
|
| 180 |
+
c = c.replace(match.group(0), new_iface)
|
| 181 |
+
|
| 182 |
+
# Add pause button
|
| 183 |
+
btn = """
|
| 184 |
+
{/* Pause auto-continue for free models */}
|
| 185 |
+
{_showAutoContinue && _cancelAutoContinue?.current && (
|
| 186 |
+
<Stack direction="row" spacing={1} sx={{ mb: 1, px: 1 }}>
|
| 187 |
+
<Button
|
| 188 |
+
variant="outlined"
|
| 189 |
+
color="secondary"
|
| 190 |
+
size="small"
|
| 191 |
+
onClick={_cancelAutoContinue.current}
|
| 192 |
+
sx={{ textTransform: 'none', fontSize: '0.75rem' }}
|
| 193 |
+
>
|
| 194 |
+
Tạm dừng (để nhập nội dung khác)
|
| 195 |
+
</Button>
|
| 196 |
+
</Stack>
|
| 197 |
+
)}
|
| 198 |
+
"""
|
| 199 |
+
if "Tạm dừng" not in c:
|
| 200 |
+
c = c.replace("<Box sx={{ flex: 1 ", btn + "\n <Box sx={{ flex: 1 ")
|
| 201 |
+
|
| 202 |
+
with open(CHAT_INPUT, "w") as f: f.write(c)
|
| 203 |
+
print("OK: ChatInput.tsx")
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def patch_session_chat():
|
| 207 |
+
with open(SESSION_CHAT, "r") as f:
|
| 208 |
+
c = f.read()
|
| 209 |
+
|
| 210 |
+
# Find the ChatInput usage and add new props
|
| 211 |
+
if "_showAutoContinue" not in c:
|
| 212 |
+
chat_input_pattern = r'(<ChatInput[^>]*sessionId={sessionId}[^>]*initialModelPath={initialModelPath}[^>]*)(\s*/>)'
|
| 213 |
+
new_props = r'\1 _showAutoContinue={_showAutoContinue} _cancelAutoContinue={_cancelAutoContinue}\2'
|
| 214 |
+
c = re.sub(chat_input_pattern, new_props, c)
|
| 215 |
+
|
| 216 |
+
with open(SESSION_CHAT, "w") as f: f.write(c)
|
| 217 |
+
print("OK: SessionChat.tsx")
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def patch_agent_loop():
|
| 221 |
+
agent_loop = "/app/agent/core/agent_loop.py"
|
| 222 |
+
if not os.path.exists(agent_loop):
|
| 223 |
+
print(f"SKIP: {agent_loop} not found (backend)")
|
| 224 |
+
return
|
| 225 |
+
|
| 226 |
+
with open(agent_loop, "r") as f:
|
| 227 |
+
c = f.read()
|
| 228 |
+
|
| 229 |
+
if "_check_task_incomplete" not in c:
|
| 230 |
+
func = '''
|
| 231 |
+
|
| 232 |
+
def _unfinished_plan_items(session: Session) -> list[dict[str, str]]:
|
| 233 |
+
plan = getattr(session, "current_plan", None) or []
|
| 234 |
+
return [item for item in plan if item.get("status") in ("pending", "in_progress")]
|
| 235 |
+
|
| 236 |
+
'''
|
| 237 |
+
c = c.replace("class Handlers:", func + "\n\nclass Handlers:")
|
| 238 |
+
|
| 239 |
+
if "task_incomplete" not in c:
|
| 240 |
+
check = '''
|
| 241 |
+
# === Auto-continue: if model stopped but plan incomplete ===
|
| 242 |
+
if not llm_result.tool_calls_acc and llm_result.content:
|
| 243 |
+
unfinished = _unfinished_plan_items(session)
|
| 244 |
+
if unfinished:
|
| 245 |
+
await session.send_event(
|
| 246 |
+
Event(event_type="task_incomplete", data={"incomplete_plan": unfinished})
|
| 247 |
+
)
|
| 248 |
+
'''
|
| 249 |
+
marker = " # -- End of turn --"
|
| 250 |
+
if marker in c:
|
| 251 |
+
c = c.replace(marker, check + "\n" + marker)
|
| 252 |
+
|
| 253 |
+
with open(agent_loop, "w") as f: f.write(c)
|
| 254 |
+
print("OK: agent_loop.py")
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
if __name__ == "__main__":
|
| 258 |
+
patch_events()
|
| 259 |
+
patch_sse()
|
| 260 |
+
patch_use_agent_chat()
|
| 261 |
+
patch_chat_input()
|
| 262 |
+
patch_session_chat()
|
| 263 |
+
patch_agent_loop()
|
| 264 |
+
print("\nDONE: V4 - everything applied")
|
patch_auto_continue_v5.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""V5: SIMPLEST approach - just show pause button with 10s auto-continue.
|
| 2 |
+
|
| 3 |
+
Architecture:
|
| 4 |
+
1. Agent loop detects model stopped mid-task → sends 'task_incomplete' event
|
| 5 |
+
2. SSE transport receives it → calls sideChannel.onTaskIncomplete()
|
| 6 |
+
3. useAgentChat: onTaskIncomplete sets _showAutoContinue=true, sends continue msg
|
| 7 |
+
4. ChatInput shows "Tạm dừng" button if _showAutoContinue
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import os, re
|
| 11 |
+
|
| 12 |
+
EVENTS = "/source/frontend/src/types/events.ts"
|
| 13 |
+
SSE = "/source/frontend/src/lib/sse-chat-transport.ts"
|
| 14 |
+
USE_AGENT_CHAT = "/source/frontend/src/hooks/useAgentChat.ts"
|
| 15 |
+
CHAT_INPUT = "/source/frontend/src/components/Chat/ChatInput.tsx"
|
| 16 |
+
AGENT_LOOP = "/app/agent/core/agent_loop.py"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def patch_events():
|
| 20 |
+
with open(EVENTS, "r") as f:
|
| 21 |
+
c = f.read()
|
| 22 |
+
if "'task_incomplete'" not in c:
|
| 23 |
+
c = c.replace(" | 'plan_update';", " | 'plan_update'\n | 'task_incomplete';")
|
| 24 |
+
with open(EVENTS, "w") as f:
|
| 25 |
+
f.write(c)
|
| 26 |
+
print("OK: events.ts")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def patch_sse():
|
| 30 |
+
with open(SSE, "r") as f:
|
| 31 |
+
c = f.read()
|
| 32 |
+
|
| 33 |
+
if "onTaskIncomplete:" not in c:
|
| 34 |
+
c = c.replace(
|
| 35 |
+
" onInterrupted: () => void;",
|
| 36 |
+
" onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;"
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
if "case 'task_incomplete'" not in c:
|
| 40 |
+
case_block = """ case 'task_incomplete' as const:
|
| 41 |
+
sideChannel.onTaskIncomplete(
|
| 42 |
+
(event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [],
|
| 43 |
+
);
|
| 44 |
+
break;
|
| 45 |
+
"""
|
| 46 |
+
c = c.replace("case 'turn_complete':", case_block + "\n case 'turn_complete':")
|
| 47 |
+
|
| 48 |
+
with open(SSE, "w") as f:
|
| 49 |
+
f.write(c)
|
| 50 |
+
print("OK: sse.ts")
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def patch_use_agent():
|
| 54 |
+
with open(USE_AGENT_CHAT, "r") as f:
|
| 55 |
+
c = f.read()
|
| 56 |
+
|
| 57 |
+
# 1. Import useState
|
| 58 |
+
if "useState" not in c:
|
| 59 |
+
c = c.replace(
|
| 60 |
+
"import { useCallback, useEffect, useMemo, useRef } from 'react';",
|
| 61 |
+
"import { useCallback, useEffect, useMemo, useRef, useState } from 'react';"
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
# 2. State (prefixed with _ = TS allows unused)
|
| 65 |
+
state = '''
|
| 66 |
+
// Auto-continue state for free models
|
| 67 |
+
const _acTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
| 68 |
+
const [_showAc, _setShowAc] = useState(false);'''
|
| 69 |
+
if "_acTimer" not in c:
|
| 70 |
+
c = c.replace(
|
| 71 |
+
"callbacksRef.current = { onReady, onError, onSessionDead };",
|
| 72 |
+
"callbacksRef.current = { onReady, onError, onSessionDead };" + state
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
# 3. Add onTaskIncomplete to interface
|
| 76 |
+
if "onTaskIncomplete:" not in c:
|
| 77 |
+
c = c.replace(
|
| 78 |
+
" onInterrupted: () => void;",
|
| 79 |
+
" onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;"
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
# 4. Add onTaskIncomplete handler - uses chatActionsRef ref instead of chat directly
|
| 83 |
+
handler = ''' onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => {
|
| 84 |
+
_setShowAc(true);
|
| 85 |
+
if (_acTimer.current) clearTimeout(_acTimer.current);
|
| 86 |
+
_acTimer.current = setTimeout(() => {
|
| 87 |
+
_setShowAc(false);
|
| 88 |
+
// Auto-continue after 10s via chatActionsRef
|
| 89 |
+
const chatRef = chatActionsRef.current;
|
| 90 |
+
const sendMsg = (chatRef as any)?.sendMessage;
|
| 91 |
+
if (sendMsg) {
|
| 92 |
+
const planStr = incompletePlan.map(i => `- ${i.content}`).join('\\\\n');
|
| 93 |
+
sendMsg({ text: `[TIẾP TỤC] Tiếp tục task chưa hoàn thành:\\\\n${planStr}`, metadata: { createdAt: new Date().toISOString() } });
|
| 94 |
+
}
|
| 95 |
+
}, 10000);
|
| 96 |
+
},
|
| 97 |
+
onInterrupted: () => { /* no-op */ },'''
|
| 98 |
+
|
| 99 |
+
if "onTaskIncomplete: (incompletePlan" not in c:
|
| 100 |
+
old = ' onInterrupted: () => { /* no-op — handled by stop() caller */ },'
|
| 101 |
+
if old in c:
|
| 102 |
+
c = c.replace(old, handler)
|
| 103 |
+
else:
|
| 104 |
+
old2 = ' onInterrupted: () => { /* no-op */ },'
|
| 105 |
+
c = c.replace(old2, handler)
|
| 106 |
+
|
| 107 |
+
# 5. Cancel ref + return values
|
| 108 |
+
if "_showAc" not in content:
|
| 109 |
+
# Insert cancel before return
|
| 110 |
+
cancel = '''
|
| 111 |
+
// Expose auto-continue state
|
| 112 |
+
const _acCancel = useCallback(() => {
|
| 113 |
+
_setShowAc(false);
|
| 114 |
+
if (_acTimer.current) { clearTimeout(_acTimer.current); _acTimer.current = null; }
|
| 115 |
+
}, []);
|
| 116 |
+
'''
|
| 117 |
+
if "_acCancel" not in c:
|
| 118 |
+
c = c.replace('\n return {\n messages: chat.messages,', cancel + '\n return {\n messages: chat.messages,')
|
| 119 |
+
|
| 120 |
+
if "_showAc," not in c:
|
| 121 |
+
c = c.replace(
|
| 122 |
+
"refreshMessages,\n };",
|
| 123 |
+
"refreshMessages,\n _showAc,\n _acCancel,\n };"
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
with open(USE_AGENT_CHAT, "w") as f:
|
| 127 |
+
f.write(c)
|
| 128 |
+
print("OK: useAgentChat.ts")
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def patch_chat_input():
|
| 132 |
+
with open(CHAT_INPUT, "r") as f:
|
| 133 |
+
c = f.read()
|
| 134 |
+
|
| 135 |
+
# DatasetUploadResponse fix
|
| 136 |
+
if "DatasetUploadResponse" in c and "type DatasetUploadResponse" not in c:
|
| 137 |
+
wrong = "import type { DatasetUploadResponse } from '@/types/agent';"
|
| 138 |
+
if wrong in c:
|
| 139 |
+
c = c.replace(wrong, "")
|
| 140 |
+
c = c.replace(
|
| 141 |
+
"import { apiFetch } from '@/utils/api';",
|
| 142 |
+
"import { apiFetch } from '@/utils/api';\ntype DatasetUploadResponse = { url: string; repo_id: string; filename: string; size: number; };"
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
# Add Button
|
| 146 |
+
if "Button" not in c:
|
| 147 |
+
c = c.replace(
|
| 148 |
+
"import { Box, IconButton, Stack, Tooltip } from '@mui/material';",
|
| 149 |
+
"import { Box, IconButton, Stack, Tooltip, Button } from '@mui/material';"
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
# Update interface
|
| 153 |
+
if "_showAc" not in c:
|
| 154 |
+
m = re.search(r'interface ChatInputProps\s*\{[^}]*\}', c, re.DOTALL)
|
| 155 |
+
if m:
|
| 156 |
+
iface = """interface ChatInputProps {
|
| 157 |
+
sessionId: string;
|
| 158 |
+
initialModelPath: string | null | undefined;
|
| 159 |
+
onSend: (text: string) => Promise<void>;
|
| 160 |
+
onStop: () => void;
|
| 161 |
+
onDatasetUploaded: () => Promise<boolean>;
|
| 162 |
+
isProcessing: boolean;
|
| 163 |
+
disabled: boolean;
|
| 164 |
+
placeholder?: string;
|
| 165 |
+
_showAc?: boolean;
|
| 166 |
+
_acCancel?: () => void;
|
| 167 |
+
}"""
|
| 168 |
+
c = c.replace(m.group(0), iface)
|
| 169 |
+
|
| 170 |
+
# Add button
|
| 171 |
+
btn = """
|
| 172 |
+
{/* Auto-continue pause button for free models */}
|
| 173 |
+
{_showAc && _acCancel && (
|
| 174 |
+
<Stack direction="row" spacing={1} sx={{ mb: 1, px: 1 }}>
|
| 175 |
+
<Button
|
| 176 |
+
variant="outlined"
|
| 177 |
+
color="secondary"
|
| 178 |
+
size="small"
|
| 179 |
+
onClick={_acCancel}
|
| 180 |
+
sx={{ textTransform: 'none', fontSize: '0.7rem' }}
|
| 181 |
+
>
|
| 182 |
+
Tạm dừng tự động tiếp tục (10s)
|
| 183 |
+
</Button>
|
| 184 |
+
</Stack>
|
| 185 |
+
)}
|
| 186 |
+
"""
|
| 187 |
+
if "Tạm dừng" not in c:
|
| 188 |
+
c = c.replace("<Box sx={{ flex: 1 ", btn + "\n <Box sx={{ flex: 1 ")
|
| 189 |
+
|
| 190 |
+
with open(CHAT_INPUT, "w") as f:
|
| 191 |
+
f.write(c)
|
| 192 |
+
print("OK: ChatInput.tsx")
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def patch_session_chat():
|
| 196 |
+
sess = "/source/frontend/src/components/SessionChat.tsx"
|
| 197 |
+
if not os.path.exists(sess):
|
| 198 |
+
print(f"SKIP: {sess}")
|
| 199 |
+
return
|
| 200 |
+
with open(sess, "r") as f:
|
| 201 |
+
c = f.read()
|
| 202 |
+
if "_showAc" not in c:
|
| 203 |
+
c = c.replace("<ChatInput ", "<ChatInput _showAc={_showAc} _acCancel={_acCancel} ")
|
| 204 |
+
with open(sess, "w") as f:
|
| 205 |
+
f.write(c)
|
| 206 |
+
print("OK: SessionChat.tsx")
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def patch_agent_loop():
|
| 210 |
+
if not os.path.exists(AGENT_LOOP):
|
| 211 |
+
print(f"SKIP: {AGENT_LOOP}")
|
| 212 |
+
return
|
| 213 |
+
with open(AGENT_LOOP, "r") as f:
|
| 214 |
+
c = f.read()
|
| 215 |
+
|
| 216 |
+
if "_unfinished_plan" not in c:
|
| 217 |
+
fn = '''
|
| 218 |
+
|
| 219 |
+
def _unfinished_plan(session: Session) -> list[dict[str, str]]:
|
| 220 |
+
plan = getattr(session, "current_plan", None) or []
|
| 221 |
+
return [item for item in plan if item.get("status") in ("pending", "in_progress")]
|
| 222 |
+
|
| 223 |
+
'''
|
| 224 |
+
c = c.replace("class Handlers:", fn + "\n\nclass Handlers:")
|
| 225 |
+
|
| 226 |
+
if "task_incomplete" not in c:
|
| 227 |
+
check = '''
|
| 228 |
+
# === Auto-continue detection ===
|
| 229 |
+
if not llm_result.tool_calls_acc and llm_result.content:
|
| 230 |
+
unfinished = _unfinished_plan(session)
|
| 231 |
+
if unfinished:
|
| 232 |
+
await session.send_event(
|
| 233 |
+
Event(event_type="task_incomplete", data={"incomplete_plan": unfinished})
|
| 234 |
+
)
|
| 235 |
+
'''
|
| 236 |
+
marker = " # -- End of turn --"
|
| 237 |
+
if marker in c:
|
| 238 |
+
c = c.replace(marker, check + "\n" + marker)
|
| 239 |
+
|
| 240 |
+
with open(AGENT_LOOP, "w") as f:
|
| 241 |
+
f.write(c)
|
| 242 |
+
print("OK: agent_loop.py")
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
if __name__ == "__main__":
|
| 246 |
+
patch_events()
|
| 247 |
+
patch_sse()
|
| 248 |
+
patch_use_agent()
|
| 249 |
+
patch_chat_input()
|
| 250 |
+
patch_session_chat()
|
| 251 |
+
patch_agent_loop()
|
| 252 |
+
print("\nDONE: V5 - simple approach applied")
|
patch_auto_continue_v6.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""V6: FIXED - Auto-continue with pause button for free models."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import re
|
| 5 |
+
|
| 6 |
+
EVENTS_FILE = "/source/frontend/src/types/events.ts"
|
| 7 |
+
SSE_TRANSPORT = "/source/frontend/src/lib/sse-chat-transport.ts"
|
| 8 |
+
HOOK_FILE = "/source/frontend/src/hooks/useAgentChat.ts"
|
| 9 |
+
CHAT_INPUT = "/source/frontend/src/components/Chat/ChatInput.tsx"
|
| 10 |
+
SESSION_CHAT = "/source/frontend/src/components/SessionChat.tsx"
|
| 11 |
+
AGENT_LOOP = "/app/agent/core/agent_loop.py"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def fix_events():
|
| 15 |
+
with open(EVENTS_FILE, "r") as f:
|
| 16 |
+
content = f.read()
|
| 17 |
+
if "'task_incomplete'" not in content:
|
| 18 |
+
content = content.replace(" | 'plan_update';", " | 'plan_update'\n | 'task_incomplete';")
|
| 19 |
+
with open(EVENTS_FILE, "w") as f:
|
| 20 |
+
f.write(content)
|
| 21 |
+
print("OK: events.ts")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def fix_sse():
|
| 25 |
+
with open(SSE_TRANSPORT, "r") as f:
|
| 26 |
+
content = f.read()
|
| 27 |
+
|
| 28 |
+
# Add onTaskIncomplete to interface
|
| 29 |
+
if "onTaskIncomplete:" not in content and "onInterrupted: () => void;" in content:
|
| 30 |
+
content = content.replace(
|
| 31 |
+
" onInterrupted: () => void;",
|
| 32 |
+
" onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;"
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
# Add case handler
|
| 36 |
+
if "case 'task_incomplete'" not in content:
|
| 37 |
+
case_code = """ case 'task_incomplete' as const:
|
| 38 |
+
sideChannel.onTaskIncomplete(
|
| 39 |
+
(event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [],
|
| 40 |
+
);
|
| 41 |
+
break;
|
| 42 |
+
"""
|
| 43 |
+
content = content.replace("case 'turn_complete':", case_code + "\n case 'turn_complete':")
|
| 44 |
+
|
| 45 |
+
with open(SSE_TRANSPORT, "w") as f:
|
| 46 |
+
f.write(content)
|
| 47 |
+
print("OK: sse-chat-transport.ts")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def fix_hook():
|
| 51 |
+
with open(HOOK_FILE, "r") as f:
|
| 52 |
+
content = f.read()
|
| 53 |
+
|
| 54 |
+
# 1. Import useState
|
| 55 |
+
if "useState" not in content:
|
| 56 |
+
content = content.replace(
|
| 57 |
+
"import { useCallback, useEffect, useMemo, useRef } from 'react';",
|
| 58 |
+
"import { useCallback, useEffect, useMemo, useRef, useState } from 'react';"
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
# 2. Add state after callbacksRef block
|
| 62 |
+
state_block = """
|
| 63 |
+
// Auto-continue for free models
|
| 64 |
+
const _acTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
| 65 |
+
const [_showAc, _setShowAc] = useState(false);
|
| 66 |
+
const _acCancel = useCallback(() => {
|
| 67 |
+
_setShowAc(false);
|
| 68 |
+
if (_acTimer.current) { clearTimeout(_acTimer.current); _acTimer.current = null; }
|
| 69 |
+
}, []);"""
|
| 70 |
+
|
| 71 |
+
if "_acTimer" not in content:
|
| 72 |
+
content = content.replace(
|
| 73 |
+
"callbacksRef.current = { onReady, onError, onSessionDead };",
|
| 74 |
+
"callbacksRef.current = { onReady, onError, onSessionDead };" + state_block
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
# 3. Add onTaskIncomplete to interface
|
| 78 |
+
if "onTaskIncomplete:" not in content and "onInterrupted: () => void;" in content:
|
| 79 |
+
content = content.replace(
|
| 80 |
+
" onInterrupted: () => void;",
|
| 81 |
+
" onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;"
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
# 4. Add handler in sideChannel - uses chatActionsRef to avoid hoisting issues
|
| 85 |
+
handler_code = """ onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => {
|
| 86 |
+
_setShowAc(true);
|
| 87 |
+
if (_acTimer.current) clearTimeout(_acTimer.current);
|
| 88 |
+
_acTimer.current = setTimeout(() => {
|
| 89 |
+
_setShowAc(false);
|
| 90 |
+
const planStr = incompletePlan.map(i => `- ${i.content}`).join('\\\\n');
|
| 91 |
+
const msg = { text: `[TIẾP TỤC] Task chưa hoàn thành:\\\\n${planStr}`, metadata: { createdAt: new Date().toISOString() } };
|
| 92 |
+
const setMsgs = chatActionsRef.current.setMessages;
|
| 93 |
+
if (setMsgs) {
|
| 94 |
+
chatActionsRef.current.messages = [...chatActionsRef.current.messages, { id: 'ac-continue', role: 'user', parts: [{ type: 'text', text: msg.text }], content: msg.text }];
|
| 95 |
+
}
|
| 96 |
+
}, 10000);
|
| 97 |
+
},
|
| 98 |
+
onInterrupted: () => { /* no-op - handled by stop() */ },"""
|
| 99 |
+
|
| 100 |
+
if "onTaskIncomplete: (incompletePlan" not in content:
|
| 101 |
+
old_handler = ' onInterrupted: () => { /* no-op \u2014 handled by stop() caller */ },'
|
| 102 |
+
if old_handler in content:
|
| 103 |
+
content = content.replace(old_handler, handler_code)
|
| 104 |
+
else:
|
| 105 |
+
old_handler2 = ' onInterrupted: () => { /* no-op */ },'
|
| 106 |
+
content = content.replace(old_handler2, handler_code)
|
| 107 |
+
|
| 108 |
+
# 5. Return values
|
| 109 |
+
if "_showAc" not in content:
|
| 110 |
+
# The _showAc and _acCancel need to be in the return
|
| 111 |
+
return_marker = "refreshMessages,\n };"
|
| 112 |
+
if return_marker in content:
|
| 113 |
+
content = content.replace(
|
| 114 |
+
return_marker,
|
| 115 |
+
"refreshMessages,\n _showAc,\n _acCancel,\n };"
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
with open(HOOK_FILE, "w") as f:
|
| 119 |
+
f.write(content)
|
| 120 |
+
print("OK: useAgentChat.ts")
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def fix_chat_input():
|
| 124 |
+
with open(CHAT_INPUT, "r") as f:
|
| 125 |
+
content = f.read()
|
| 126 |
+
|
| 127 |
+
# Fix DatasetUploadResponse
|
| 128 |
+
wrong_import = "import type { DatasetUploadResponse } from '@/types/agent';"
|
| 129 |
+
if wrong_import in content:
|
| 130 |
+
content = content.replace(wrong_import, "")
|
| 131 |
+
content = content.replace(
|
| 132 |
+
"import { apiFetch } from '@/utils/api';",
|
| 133 |
+
"import { apiFetch } from '@/utils/api';\ntype DatasetUploadResponse = { url: string; repo_id: string; filename: string; size: number; };"
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
# Add Button import
|
| 137 |
+
if "Button" not in content:
|
| 138 |
+
content = content.replace(
|
| 139 |
+
"import { Box, IconButton, Stack, Tooltip } from '@mui/material';",
|
| 140 |
+
"import { Box, IconButton, Stack, Tooltip, Button } from '@mui/material';"
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
# Fix interface
|
| 144 |
+
if "_showAc" not in content:
|
| 145 |
+
iface_match = re.search(r'interface ChatInputProps\s*\{[^}]*\}', content, re.DOTALL)
|
| 146 |
+
if iface_match:
|
| 147 |
+
new_iface = """interface ChatInputProps {
|
| 148 |
+
sessionId: string;
|
| 149 |
+
initialModelPath: string | null | undefined;
|
| 150 |
+
onSend: (text: string) => Promise<void>;
|
| 151 |
+
onStop: () => void;
|
| 152 |
+
onDatasetUploaded: () => Promise<boolean>;
|
| 153 |
+
isProcessing: boolean;
|
| 154 |
+
disabled: boolean;
|
| 155 |
+
placeholder?: string;
|
| 156 |
+
_showAc?: boolean;
|
| 157 |
+
_acCancel?: () => void;
|
| 158 |
+
}"""
|
| 159 |
+
content = content.replace(iface_match.group(0), new_iface)
|
| 160 |
+
|
| 161 |
+
# Add pause button
|
| 162 |
+
btn_code = """
|
| 163 |
+
{/* Auto-continue pause for free models */}
|
| 164 |
+
{_showAc && _acCancel && (
|
| 165 |
+
<Stack direction="row" spacing={1} sx={{ mb: 1, px: 1 }}>
|
| 166 |
+
<Button
|
| 167 |
+
variant="outlined"
|
| 168 |
+
color="secondary"
|
| 169 |
+
size="small"
|
| 170 |
+
onClick={_acCancel}
|
| 171 |
+
sx={{ textTransform: 'none', fontSize: '0.7rem', opacity: 0.8 }}
|
| 172 |
+
>
|
| 173 |
+
T\u1ea1m d\u1eebng t\u1ef1 \u0111\u1ed9ng ti\u1ebfp t\u1ee5c (10s)
|
| 174 |
+
</Button>
|
| 175 |
+
</Stack>
|
| 176 |
+
)}"""
|
| 177 |
+
|
| 178 |
+
if "T\u1ea1m d\u1eebng" not in content and "free models" not in content:
|
| 179 |
+
content = content.replace("<Box sx={{ flex: 1 ", btn_code + "\n <Box sx={{ flex: 1 ")
|
| 180 |
+
|
| 181 |
+
with open(CHAT_INPUT, "w") as f:
|
| 182 |
+
f.write(content)
|
| 183 |
+
print("OK: ChatInput.tsx")
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def fix_session_chat():
|
| 187 |
+
with open(SESSION_CHAT, "r") as f:
|
| 188 |
+
content = f.read()
|
| 189 |
+
|
| 190 |
+
# Add props to ChatInput
|
| 191 |
+
if "_showAc" not in content:
|
| 192 |
+
content = content.replace(
|
| 193 |
+
"<ChatInput ",
|
| 194 |
+
"<ChatInput _showAc={_showAc} _acCancel={_acCancel} "
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
with open(SESSION_CHAT, "w") as f:
|
| 198 |
+
f.write(content)
|
| 199 |
+
print("OK: SessionChat.tsx")
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def fix_agent_loop():
|
| 203 |
+
if not os.path.exists(AGENT_LOOP):
|
| 204 |
+
print(f"SKIP: {AGENT_LOOP}")
|
| 205 |
+
return
|
| 206 |
+
with open(AGENT_LOOP, "r") as f:
|
| 207 |
+
content = f.read()
|
| 208 |
+
|
| 209 |
+
if "_unfinished_plan" not in content:
|
| 210 |
+
fn_block = '''
|
| 211 |
+
|
| 212 |
+
def _unfinished_plan(s: Session) -> list[dict[str, str]]:
|
| 213 |
+
p = getattr(s, "current_plan", None) or []
|
| 214 |
+
return [it for it in p if it.get("status") in ("pending", "in_progress")]
|
| 215 |
+
|
| 216 |
+
'''
|
| 217 |
+
content = content.replace("class Handlers:", fn_block + "\n\nclass Handlers:")
|
| 218 |
+
|
| 219 |
+
if "task_incomplete" not in content:
|
| 220 |
+
check_block = '''
|
| 221 |
+
# Auto-continue detect
|
| 222 |
+
if not llm_result.tool_calls_acc and llm_result.content:
|
| 223 |
+
unfinished = _unfinished_plan(session)
|
| 224 |
+
if unfinished:
|
| 225 |
+
await session.send_event(Event(event_type="task_incomplete", data={"incomplete_plan": unfinished}))
|
| 226 |
+
'''
|
| 227 |
+
marker = " # -- End of turn --"
|
| 228 |
+
if marker in content:
|
| 229 |
+
content = content.replace(marker, check_block + "\n" + marker)
|
| 230 |
+
|
| 231 |
+
with open(AGENT_LOOP, "w") as f:
|
| 232 |
+
f.write(content)
|
| 233 |
+
print("OK: agent_loop.py")
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
if __name__ == "__main__":
|
| 237 |
+
fix_events()
|
| 238 |
+
fix_sse()
|
| 239 |
+
fix_hook()
|
| 240 |
+
fix_chat_input()
|
| 241 |
+
fix_session_chat()
|
| 242 |
+
fix_agent_loop()
|
| 243 |
+
print("\nDONE: V6 - all fixed")
|
patch_auto_continue_v7.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""V7: PRECISE patch - exact string matching with source code.
|
| 2 |
+
|
| 3 |
+
Adds auto-continue pause button for free models. All patches use exact
|
| 4 |
+
string content from the original source files for reliable replacement.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os, sys
|
| 8 |
+
|
| 9 |
+
# ========== PATCH 1: events.ts - add 'task_incomplete' to EventType ==========
|
| 10 |
+
events_ts = "/source/frontend/src/types/events.ts"
|
| 11 |
+
def patch_events():
|
| 12 |
+
with open(events_ts) as f:
|
| 13 |
+
c = f.read()
|
| 14 |
+
if "'task_incomplete'" not in c:
|
| 15 |
+
c = c.replace(
|
| 16 |
+
" | 'interrupted'",
|
| 17 |
+
" | 'interrupted'\n | 'task_incomplete'"
|
| 18 |
+
)
|
| 19 |
+
with open(events_ts, 'w') as f: f.write(c)
|
| 20 |
+
print("OK: events.ts")
|
| 21 |
+
|
| 22 |
+
# ========== PATCH 2: SSE transport - add onTaskIncomplete to interface + handler ==========
|
| 23 |
+
sse_ts = "/source/frontend/src/lib/sse-chat-transport.ts"
|
| 24 |
+
def patch_sse():
|
| 25 |
+
with open(sse_ts) as f:
|
| 26 |
+
c = f.read()
|
| 27 |
+
|
| 28 |
+
# Add to interface
|
| 29 |
+
if "onTaskIncomplete:" not in c:
|
| 30 |
+
c = c.replace(
|
| 31 |
+
" onInterrupted: () => void;",
|
| 32 |
+
" onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;"
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
# Add case handler before default
|
| 36 |
+
if "case 'task_incomplete'" not in c:
|
| 37 |
+
case_handler = """ case 'task_incomplete':
|
| 38 |
+
sideChannel.onTaskIncomplete(
|
| 39 |
+
(event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [],
|
| 40 |
+
);
|
| 41 |
+
break;
|
| 42 |
+
|
| 43 |
+
default:"""
|
| 44 |
+
c = c.replace("\n default:", "\n" + case_handler)
|
| 45 |
+
|
| 46 |
+
with open(sse_ts, 'w') as f: f.write(c)
|
| 47 |
+
print("OK: sse-chat-transport.ts")
|
| 48 |
+
|
| 49 |
+
# ========== PATCH 3: useAgentChat.ts - add state + handler + return ==========
|
| 50 |
+
hook_ts = "/source/frontend/src/hooks/useAgentChat.ts"
|
| 51 |
+
def patch_hook():
|
| 52 |
+
with open(hook_ts) as f:
|
| 53 |
+
c = f.read()
|
| 54 |
+
|
| 55 |
+
# 1. Add useState to imports
|
| 56 |
+
if "useState" not in c:
|
| 57 |
+
c = c.replace(
|
| 58 |
+
"import { useCallback, useEffect, useMemo, useRef } from 'react';",
|
| 59 |
+
"import { useCallback, useEffect, useMemo, useRef, useState } from 'react';"
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
# 2. Add state variables after callbacksRef
|
| 63 |
+
state_block = """
|
| 64 |
+
// Auto-continue state for free models
|
| 65 |
+
const _acTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
| 66 |
+
const [_showAc, _setShowAc] = useState(false);"""
|
| 67 |
+
if "_acTimerRef" not in c:
|
| 68 |
+
c = c.replace(
|
| 69 |
+
"callbacksRef.current = { onReady, onError, onSessionDead };",
|
| 70 |
+
"callbacksRef.current = { onReady, onError, onSessionDead };" + state_block
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
# 3. Add onTaskIncomplete to interface
|
| 74 |
+
if "onTaskIncomplete:" not in c:
|
| 75 |
+
c = c.replace(
|
| 76 |
+
" onInterrupted: () => void;",
|
| 77 |
+
" onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;"
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
# 4. Add onTaskIncomplete handler in sideChannel object
|
| 81 |
+
# This inserts BEFORE the existing onInterrupted handler
|
| 82 |
+
handler = """ onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => {
|
| 83 |
+
_setShowAc(true);
|
| 84 |
+
if (_acTimerRef.current) clearTimeout(_acTimerRef.current);
|
| 85 |
+
_acTimerRef.current = setTimeout(() => {
|
| 86 |
+
_setShowAc(false);
|
| 87 |
+
const planStr = incompletePlan.map(i => `- ${i.content}`).join('\\\\n');
|
| 88 |
+
const setMsgs = chatActionsRef.current.setMessages;
|
| 89 |
+
if (setMsgs) {
|
| 90 |
+
const msgs = chatActionsRef.current.messages;
|
| 91 |
+
setMsgs([...msgs, { id: 'ac-'+Date.now(), role: 'user' as const, parts: [{ type: 'text' as const, text: `[TIẾP TỤC] Task chưa hoàn thành:\\\\n${planStr}` }], content: '' }]);
|
| 92 |
+
}
|
| 93 |
+
}, 10000);
|
| 94 |
+
},
|
| 95 |
+
onInterrupted: () => { /* no-op */ },"""
|
| 96 |
+
|
| 97 |
+
if "onTaskIncomplete: (incompletePlan" not in c:
|
| 98 |
+
old_interrupted = ' onInterrupted: () => { /* no-op \u2014 handled by stop() caller */ },'
|
| 99 |
+
if old_interrupted in c:
|
| 100 |
+
c = c.replace(old_interrupted, handler)
|
| 101 |
+
else:
|
| 102 |
+
# Try simpler version
|
| 103 |
+
old2 = ' onInterrupted: () => { /* no-op */ },'
|
| 104 |
+
c = c.replace(old2, handler)
|
| 105 |
+
|
| 106 |
+
# 5. Add _acCancel function + return values
|
| 107 |
+
cancel_func = """
|
| 108 |
+
const _acCancel = useCallback(() => {
|
| 109 |
+
_setShowAc(false);
|
| 110 |
+
if (_acTimerRef.current) { clearTimeout(_acTimerRef.current); _acTimerRef.current = null; }
|
| 111 |
+
}, []);"""
|
| 112 |
+
if "_acCancel" not in c:
|
| 113 |
+
c = c.replace("\n return {\n messages: chat.messages,", cancel_func + "\n return {\n messages: chat.messages,")
|
| 114 |
+
|
| 115 |
+
# 6. Return values
|
| 116 |
+
if "_showAc," not in c:
|
| 117 |
+
c = c.replace(
|
| 118 |
+
"refreshMessages,\n };",
|
| 119 |
+
"refreshMessages,\n _showAc,\n _acCancel,\n };"
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
with open(hook_ts, 'w') as f: f.write(c)
|
| 123 |
+
print("OK: useAgentChat.ts")
|
| 124 |
+
|
| 125 |
+
# ========== PATCH 4: ChatInput.tsx - add pause button ==========
|
| 126 |
+
input_tsx = "/source/frontend/src/components/Chat/ChatInput.tsx"
|
| 127 |
+
def patch_chat_input():
|
| 128 |
+
with open(input_tsx) as f:
|
| 129 |
+
c = f.read()
|
| 130 |
+
|
| 131 |
+
# 1. Add Button import (it's missing from the original imports)
|
| 132 |
+
if "Button" not in c:
|
| 133 |
+
c = c.replace(
|
| 134 |
+
" Tooltip,",
|
| 135 |
+
" Tooltip,\n Button,"
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
# 2. Add props to interface
|
| 139 |
+
if "_showAc" not in c:
|
| 140 |
+
old_interface_end = """interface ChatInputProps {
|
| 141 |
+
sessionId?: string;
|
| 142 |
+
initialModelPath?: string | null;
|
| 143 |
+
onSend: (text: string) => void;
|
| 144 |
+
onStop?: () => void;
|
| 145 |
+
onDatasetUploaded?: () => Promise<boolean> | boolean;
|
| 146 |
+
isProcessing?: boolean;
|
| 147 |
+
disabled?: boolean;
|
| 148 |
+
placeholder?: string;
|
| 149 |
+
}"""
|
| 150 |
+
new_interface = """interface ChatInputProps {
|
| 151 |
+
sessionId?: string;
|
| 152 |
+
initialModelPath?: string | null;
|
| 153 |
+
onSend: (text: string) => void;
|
| 154 |
+
onStop?: () => void;
|
| 155 |
+
onDatasetUploaded?: () => Promise<boolean> | boolean;
|
| 156 |
+
isProcessing?: boolean;
|
| 157 |
+
disabled?: boolean;
|
| 158 |
+
placeholder?: string;
|
| 159 |
+
_showAc?: boolean;
|
| 160 |
+
_acCancel?: () => void;
|
| 161 |
+
}"""
|
| 162 |
+
c = c.replace(old_interface_end, new_interface)
|
| 163 |
+
|
| 164 |
+
# 3. Add destructured props in function signature
|
| 165 |
+
if "_showAc" not in c:
|
| 166 |
+
old_props = "placeholder = 'Ask anything...' }: ChatInputProps) {"
|
| 167 |
+
new_props = "placeholder = 'Ask anything...', _showAc = false, _acCancel }: ChatInputProps) {"
|
| 168 |
+
if old_props not in c:
|
| 169 |
+
old_props2 = "placeholder = 'Ask anything...' }: ChatInputProps) {"
|
| 170 |
+
new_props2 = "placeholder = 'Ask anything...', _showAc = false, _acCancel }: ChatInputProps) {"
|
| 171 |
+
c = c.replace(old_props2, new_props2)
|
| 172 |
+
else:
|
| 173 |
+
c = c.replace(old_props, new_props)
|
| 174 |
+
|
| 175 |
+
# 4. Add pause button after the model badge area, before <JobsUpgradeDialog>
|
| 176 |
+
if "T\u1ea1m d\u1eebng" not in c:
|
| 177 |
+
btn = """
|
| 178 |
+
{/* Auto-continue pause for free models */}
|
| 179 |
+
{_showAc && _acCancel && (
|
| 180 |
+
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 1 }}>
|
| 181 |
+
<Button
|
| 182 |
+
variant="outlined"
|
| 183 |
+
size="small"
|
| 184 |
+
onClick={_acCancel}
|
| 185 |
+
sx={{ textTransform: 'none', fontSize: '0.7rem', opacity: 0.7, '&:hover': { opacity: 1 } }}
|
| 186 |
+
>
|
| 187 |
+
T\u1ea1m d\u1eebng t\u1ef1 \u0111\u1ed9ng ti\u1ebfp t\u1ee5c (10s)
|
| 188 |
+
</Button>
|
| 189 |
+
</Box>
|
| 190 |
+
)}
|
| 191 |
+
"""
|
| 192 |
+
if "<JobsUpgradeDialog" in c:
|
| 193 |
+
c = c.replace("<JobsUpgradeDialog", btn + "\n <JobsUpgradeDialog")
|
| 194 |
+
|
| 195 |
+
with open(input_tsx, 'w') as f: f.write(c)
|
| 196 |
+
print("OK: ChatInput.tsx")
|
| 197 |
+
|
| 198 |
+
# ========== PATCH 5: SessionChat.tsx - pass props down ==========
|
| 199 |
+
session_tsx = "/source/frontend/src/components/SessionChat.tsx"
|
| 200 |
+
def patch_session():
|
| 201 |
+
if not os.path.exists(session_tsx):
|
| 202 |
+
print(f"SKIP: {session_tsx}")
|
| 203 |
+
return
|
| 204 |
+
with open(session_tsx) as f:
|
| 205 |
+
c = f.read()
|
| 206 |
+
if "_showAc" not in c:
|
| 207 |
+
old = "<ChatInput "
|
| 208 |
+
new = "<ChatInput _showAc={_showAc} _acCancel={_acCancel} "
|
| 209 |
+
c = c.replace(old, new)
|
| 210 |
+
with open(session_tsx, 'w') as f: f.write(c)
|
| 211 |
+
print("OK: SessionChat.tsx")
|
| 212 |
+
|
| 213 |
+
# ========== PATCH 6: agent_loop.py - detection ==========
|
| 214 |
+
agent_py = "/app/agent/core/agent_loop.py"
|
| 215 |
+
def patch_agent():
|
| 216 |
+
if not os.path.exists(agent_py):
|
| 217 |
+
print(f"SKIP: {agent_py}")
|
| 218 |
+
return
|
| 219 |
+
with open(agent_py) as f:
|
| 220 |
+
c = f.read()
|
| 221 |
+
|
| 222 |
+
# Add helper function
|
| 223 |
+
if "_unfinished_plan" not in c:
|
| 224 |
+
fn = """
|
| 225 |
+
def _unfinished_plan(s: Session) -> list[dict[str, str]]:
|
| 226 |
+
p = getattr(s, "current_plan", None) or []
|
| 227 |
+
return [it for it in p if it.get("status") in ("pending", "in_progress")]
|
| 228 |
+
|
| 229 |
+
"""
|
| 230 |
+
c = c.replace("class Handlers:", fn + "\n\nclass Handlers:")
|
| 231 |
+
|
| 232 |
+
# Add detection
|
| 233 |
+
if "task_incomplete" not in c:
|
| 234 |
+
check = """
|
| 235 |
+
# Auto-continue detection
|
| 236 |
+
if not llm_result.tool_calls_acc and llm_result.content:
|
| 237 |
+
unfinished = _unfinished_plan(session)
|
| 238 |
+
if unfinished:
|
| 239 |
+
await session.send_event(Event(event_type="task_incomplete", data={"incomplete_plan": unfinished}))
|
| 240 |
+
"""
|
| 241 |
+
marker = " # -- End of turn --"
|
| 242 |
+
if marker in c:
|
| 243 |
+
c = c.replace(marker, check + "\n" + marker)
|
| 244 |
+
|
| 245 |
+
with open(agent_py, 'w') as f: f.write(c)
|
| 246 |
+
print("OK: agent_loop.py")
|
| 247 |
+
|
| 248 |
+
if __name__ == "__main__":
|
| 249 |
+
patch_events()
|
| 250 |
+
patch_sse()
|
| 251 |
+
patch_hook()
|
| 252 |
+
patch_chat_input()
|
| 253 |
+
patch_session()
|
| 254 |
+
patch_agent()
|
| 255 |
+
print("\nDONE: V7 - precise patches applied")
|
patch_auto_continue_v8.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""V8: Safe auto-continue via useEffect pattern (no hoisting issues)."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
events_ts = "/source/frontend/src/types/events.ts"
|
| 6 |
+
sse_ts = "/source/frontend/src/lib/sse-chat-transport.ts"
|
| 7 |
+
hook_ts = "/source/frontend/src/hooks/useAgentChat.ts"
|
| 8 |
+
input_tsx = "/source/frontend/src/components/Chat/ChatInput.tsx"
|
| 9 |
+
session_tsx = "/source/frontend/src/components/SessionChat.tsx"
|
| 10 |
+
agent_py = "/app/agent/core/agent_loop.py"
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def patch_events():
|
| 14 |
+
with open(events_ts) as f:
|
| 15 |
+
c = f.read()
|
| 16 |
+
if "'task_incomplete'" not in c:
|
| 17 |
+
c = c.replace(
|
| 18 |
+
" | 'interrupted'",
|
| 19 |
+
" | 'interrupted'\n | 'task_incomplete'"
|
| 20 |
+
)
|
| 21 |
+
with open(events_ts, 'w') as f: f.write(c)
|
| 22 |
+
print("OK: events.ts")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def patch_sse():
|
| 26 |
+
with open(sse_ts) as f:
|
| 27 |
+
c = f.read()
|
| 28 |
+
|
| 29 |
+
if "onTaskIncomplete:" not in c:
|
| 30 |
+
c = c.replace(
|
| 31 |
+
" onInterrupted: () => void;",
|
| 32 |
+
" onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;"
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
if "case 'task_incomplete'" not in c:
|
| 36 |
+
case_handler = """ case 'task_incomplete':
|
| 37 |
+
sideChannel.onTaskIncomplete(
|
| 38 |
+
(event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [],
|
| 39 |
+
);
|
| 40 |
+
break;
|
| 41 |
+
|
| 42 |
+
default:"""
|
| 43 |
+
c = c.replace("\n default:", "\n" + case_handler)
|
| 44 |
+
|
| 45 |
+
with open(sse_ts, 'w') as f: f.write(c)
|
| 46 |
+
print("OK: sse.ts")
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def patch_hook():
|
| 50 |
+
with open(hook_ts) as f:
|
| 51 |
+
c = f.read()
|
| 52 |
+
|
| 53 |
+
# 1. Import useState
|
| 54 |
+
if "useState" not in c:
|
| 55 |
+
c = c.replace(
|
| 56 |
+
"import { useCallback, useEffect, useMemo, useRef } from 'react';",
|
| 57 |
+
"import { useCallback, useEffect, useMemo, useRef, useState } from 'react';"
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
# 2. State after callbacksRef
|
| 61 |
+
state = """
|
| 62 |
+
// Auto-continue state for free models
|
| 63 |
+
const [_showAc, _setShowAc] = useState(false);
|
| 64 |
+
const _acPlanRef = useRef<Array<{ id: string; content: string; status: string }>>([]);"""
|
| 65 |
+
if "_showAc" not in c:
|
| 66 |
+
c = c.replace(
|
| 67 |
+
"callbacksRef.current = { onReady, onError, onSessionDead };",
|
| 68 |
+
"callbacksRef.current = { onReady, onError, onSessionDead };" + state
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
# 3. onTaskIncomplete in interface
|
| 72 |
+
if "onTaskIncomplete:" not in c:
|
| 73 |
+
c = c.replace(
|
| 74 |
+
" onInterrupted: () => void;",
|
| 75 |
+
" onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;"
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
# 4. Handler in sideChannel (only set state + ref, no chat.sendMessage)
|
| 79 |
+
handler = """ onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => {
|
| 80 |
+
_acPlanRef.current = incompletePlan;
|
| 81 |
+
_setShowAc(true);
|
| 82 |
+
},
|
| 83 |
+
onInterrupted: () => { /* no-op */ },"""
|
| 84 |
+
|
| 85 |
+
if "onTaskIncomplete: (incompletePlan" not in c:
|
| 86 |
+
old = ' onInterrupted: () => { /* no-op \u2014 handled by stop() caller */ },'
|
| 87 |
+
if old in c:
|
| 88 |
+
c = c.replace(old, handler)
|
| 89 |
+
else:
|
| 90 |
+
c = c.replace(' onInterrupted: () => { /* no-op */ },', handler)
|
| 91 |
+
|
| 92 |
+
# 5. useEffect to watch _showAc - runs timer, calls chat.sendMessage via chatActionsRef
|
| 93 |
+
effect = """
|
| 94 |
+
// Auto-continue effect: when _showAc becomes true, start 10s timer
|
| 95 |
+
useEffect(() => {
|
| 96 |
+
if (!_showAc) return;
|
| 97 |
+
const timer = setTimeout(() => {
|
| 98 |
+
const plan = _acPlanRef.current;
|
| 99 |
+
const setMsgs = chatActionsRef.current.setMessages;
|
| 100 |
+
const msgs = chatActionsRef.current.messages;
|
| 101 |
+
if (setMsgs && plan.length > 0) {
|
| 102 |
+
const planStr = plan.map(i => `- ${i.content}`).join('\\\\n');
|
| 103 |
+
const newMsg = { id: 'ac-' + Date.now(), role: 'user' as const, parts: [{ type: 'text' as const, text: `[TIẾP TỤC] Task chưa hoàn thành:\\\\n${planStr}` }], content: '' };
|
| 104 |
+
setMsgs([...msgs, newMsg]);
|
| 105 |
+
}
|
| 106 |
+
_setShowAc(false);
|
| 107 |
+
}, 10000);
|
| 108 |
+
return () => clearTimeout(timer);
|
| 109 |
+
}, [_showAc]);
|
| 110 |
+
"""
|
| 111 |
+
if "Auto-continue effect" not in c:
|
| 112 |
+
# Insert before return
|
| 113 |
+
c = c.replace("\n return {\n messages: chat.messages,", effect + "\n return {\n messages: chat.messages,")
|
| 114 |
+
|
| 115 |
+
# 6. Cancel + return
|
| 116 |
+
cancel = """
|
| 117 |
+
const _acCancel = useCallback(() => { _setShowAc(false); }, []);"""
|
| 118 |
+
if "_acCancel" not in c:
|
| 119 |
+
c = c.replace("\n return {\n messages: chat.messages,", cancel + "\n return {\n messages: chat.messages,")
|
| 120 |
+
if "_showAc," not in c:
|
| 121 |
+
c = c.replace(
|
| 122 |
+
"refreshMessages,\n };",
|
| 123 |
+
"refreshMessages,\n _showAc,\n _acCancel,\n };"
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
with open(hook_ts, 'w') as f: f.write(c)
|
| 127 |
+
print("OK: useAgentChat.ts")
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def patch_chat_input():
|
| 131 |
+
with open(input_tsx) as f:
|
| 132 |
+
c = f.read()
|
| 133 |
+
|
| 134 |
+
if "Button" not in c:
|
| 135 |
+
c = c.replace(" Tooltip,", " Tooltip,\n Button,")
|
| 136 |
+
|
| 137 |
+
if "_showAc" not in c:
|
| 138 |
+
old_iface = """interface ChatInputProps {
|
| 139 |
+
sessionId?: string;
|
| 140 |
+
initialModelPath?: string | null;
|
| 141 |
+
onSend: (text: string) => void;
|
| 142 |
+
onStop?: () => void;
|
| 143 |
+
onDatasetUploaded?: () => Promise<boolean> | boolean;
|
| 144 |
+
isProcessing?: boolean;
|
| 145 |
+
disabled?: boolean;
|
| 146 |
+
placeholder?: string;
|
| 147 |
+
}"""
|
| 148 |
+
new_iface = """interface ChatInputProps {
|
| 149 |
+
sessionId?: string;
|
| 150 |
+
initialModelPath?: string | null;
|
| 151 |
+
onSend: (text: string) => void;
|
| 152 |
+
onStop?: () => void;
|
| 153 |
+
onDatasetUploaded?: () => Promise<boolean> | boolean;
|
| 154 |
+
isProcessing?: boolean;
|
| 155 |
+
disabled?: boolean;
|
| 156 |
+
placeholder?: string;
|
| 157 |
+
_showAc?: boolean;
|
| 158 |
+
_acCancel?: () => void;
|
| 159 |
+
}"""
|
| 160 |
+
c = c.replace(old_iface, new_iface)
|
| 161 |
+
|
| 162 |
+
old_props = "placeholder = 'Ask anything...' }: ChatInputProps) {"
|
| 163 |
+
new_props = "placeholder = 'Ask anything...', _showAc = false, _acCancel }: ChatInputProps) {"
|
| 164 |
+
if old_props not in c:
|
| 165 |
+
old_props2 = "placeholder = 'Ask anything...' }: ChatInputProps) {"
|
| 166 |
+
new_props2 = "placeholder = 'Ask anything...', _showAc = false, _acCancel }: ChatInputProps) {"
|
| 167 |
+
c = c.replace(old_props2, new_props2)
|
| 168 |
+
else:
|
| 169 |
+
c = c.replace(old_props, new_props)
|
| 170 |
+
|
| 171 |
+
if "T\u1ea1m d\u1eebng" not in c:
|
| 172 |
+
btn = """
|
| 173 |
+
{_showAc && _acCancel && (
|
| 174 |
+
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 1 }}>
|
| 175 |
+
<Button variant="outlined" size="small" onClick={_acCancel}
|
| 176 |
+
sx={{ textTransform: 'none', fontSize: '0.7rem', opacity: 0.7, '&:hover': { opacity: 1 } }}>
|
| 177 |
+
T\u1ea1m d\u1eebng t\u1ef1 \u0111\u1ed9ng ti\u1ebfp t\u1ee5c (10s)
|
| 178 |
+
</Button>
|
| 179 |
+
</Box>
|
| 180 |
+
)}
|
| 181 |
+
"""
|
| 182 |
+
if "<JobsUpgradeDialog" in c:
|
| 183 |
+
c = c.replace("<JobsUpgradeDialog", btn + "\n <JobsUpgradeDialog")
|
| 184 |
+
|
| 185 |
+
with open(input_tsx, 'w') as f: f.write(c)
|
| 186 |
+
print("OK: ChatInput.tsx")
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def patch_session():
|
| 190 |
+
if not os.path.exists(session_tsx):
|
| 191 |
+
print(f"SKIP: {session_tsx}")
|
| 192 |
+
return
|
| 193 |
+
with open(session_tsx) as f:
|
| 194 |
+
c = f.read()
|
| 195 |
+
if "_showAc" not in c:
|
| 196 |
+
c = c.replace("<ChatInput ", "<ChatInput _showAc={_showAc} _acCancel={_acCancel} ")
|
| 197 |
+
with open(session_tsx, 'w') as f: f.write(c)
|
| 198 |
+
print("OK: SessionChat.tsx")
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def patch_agent():
|
| 202 |
+
if not os.path.exists(agent_py):
|
| 203 |
+
print(f"SKIP: {agent_py}")
|
| 204 |
+
return
|
| 205 |
+
with open(agent_py) as f:
|
| 206 |
+
c = f.read()
|
| 207 |
+
|
| 208 |
+
if "_unfinished_plan" not in c:
|
| 209 |
+
fn = """
|
| 210 |
+
def _unfinished_plan(s: Session) -> list[dict[str, str]]:
|
| 211 |
+
p = getattr(s, "current_plan", None) or []
|
| 212 |
+
return [it for it in p if it.get("status") in ("pending", "in_progress")]
|
| 213 |
+
"""
|
| 214 |
+
c = c.replace("class Handlers:", fn + "\n\nclass Handlers:")
|
| 215 |
+
|
| 216 |
+
if "task_incomplete" not in c:
|
| 217 |
+
check = """
|
| 218 |
+
# Auto-continue detection
|
| 219 |
+
if not llm_result.tool_calls_acc and llm_result.content:
|
| 220 |
+
unfinished = _unfinished_plan(session)
|
| 221 |
+
if unfinished:
|
| 222 |
+
await session.send_event(Event(event_type="task_incomplete", data={"incomplete_plan": unfinished}))
|
| 223 |
+
"""
|
| 224 |
+
marker = " # -- End of turn --"
|
| 225 |
+
if marker in c:
|
| 226 |
+
c = c.replace(marker, check + "\n" + marker)
|
| 227 |
+
|
| 228 |
+
with open(agent_py, 'w') as f: f.write(c)
|
| 229 |
+
print("OK: agent_loop.py")
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
if __name__ == "__main__":
|
| 233 |
+
patch_events()
|
| 234 |
+
patch_sse()
|
| 235 |
+
patch_hook()
|
| 236 |
+
patch_chat_input()
|
| 237 |
+
patch_session()
|
| 238 |
+
patch_agent()
|
| 239 |
+
print("\nDONE: V8 - safe useEffect pattern")
|
patch_auto_continue_v9.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""V9: SIMPLEST approach - no interface changes, no props chain.
|
| 2 |
+
|
| 3 |
+
Uses CustomEvent to communicate between sideChannel and React state.
|
| 4 |
+
This avoids ALL TypeScript issues:
|
| 5 |
+
- No interface changes needed
|
| 6 |
+
- No hoisting issues
|
| 7 |
+
- No unused variable warnings
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import os
|
| 11 |
+
|
| 12 |
+
events_ts = "/source/frontend/src/types/events.ts"
|
| 13 |
+
sse_ts = "/source/frontend/src/lib/sse-chat-transport.ts"
|
| 14 |
+
hook_ts = "/source/frontend/src/hooks/useAgentChat.ts"
|
| 15 |
+
input_tsx = "/source/frontend/src/components/Chat/ChatInput.tsx"
|
| 16 |
+
agent_py = "/app/agent/core/agent_loop.py"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def patch_events():
|
| 20 |
+
with open(events_ts) as f:
|
| 21 |
+
c = f.read()
|
| 22 |
+
if "'task_incomplete'" not in c:
|
| 23 |
+
c = c.replace(" | 'interrupted'", " | 'interrupted'\n | 'task_incomplete'")
|
| 24 |
+
with open(events_ts, 'w') as f: f.write(c)
|
| 25 |
+
print("OK: events.ts")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def patch_sse():
|
| 29 |
+
with open(sse_ts) as f:
|
| 30 |
+
c = f.read()
|
| 31 |
+
|
| 32 |
+
# Add case handler DIRECTLY without changing interface
|
| 33 |
+
# Use as any cast to bypass type checking
|
| 34 |
+
if "case 'task_incomplete'" not in c:
|
| 35 |
+
# Add handler before default, using (sideChannel as any) to avoid interface issue
|
| 36 |
+
case_block = """ case 'task_incomplete':
|
| 37 |
+
(sideChannel as any).onTaskIncomplete(
|
| 38 |
+
(event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [],
|
| 39 |
+
);
|
| 40 |
+
break;
|
| 41 |
+
|
| 42 |
+
default:"""
|
| 43 |
+
c = c.replace("\n default:", "\n" + case_block)
|
| 44 |
+
|
| 45 |
+
with open(sse_ts, 'w') as f: f.write(c)
|
| 46 |
+
print("OK: sse.ts")
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def patch_hook():
|
| 50 |
+
with open(hook_ts) as f:
|
| 51 |
+
c = f.read()
|
| 52 |
+
|
| 53 |
+
# 1. Import useState
|
| 54 |
+
if "useState" not in c:
|
| 55 |
+
c = c.replace(
|
| 56 |
+
"import { useCallback, useEffect, useMemo, useRef } from 'react';",
|
| 57 |
+
"import { useCallback, useEffect, useMemo, useRef, useState } from 'react';"
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
# 2. Add state after callbacksRef - TS will complain "unused" but _ prefix helps
|
| 61 |
+
state_block = """
|
| 62 |
+
// Auto-continue for free models
|
| 63 |
+
const [_showAc, _setShowAc] = useState(false);"""
|
| 64 |
+
if "_showAc" not in c:
|
| 65 |
+
c = c.replace(
|
| 66 |
+
"callbacksRef.current = { onReady, onError, onSessionDead };",
|
| 67 |
+
"callbacksRef.current = { onReady, onError, onSessionDead };" + state_block
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
# 3. Add onTaskIncomplete to the sideChannel object via (sideChannel as any)
|
| 71 |
+
# We use the onSessionUpdate handler as injection point since it already exists
|
| 72 |
+
handler_block = """ onSessionUpdate: (data) => {
|
| 73 |
+
// Check for auto-continue trigger
|
| 74 |
+
if ((data as any).ac_plan) {
|
| 75 |
+
const plan = (data as any).ac_plan as Array<{ id: string; content: string; status: string }>;
|
| 76 |
+
_setShowAc(true);
|
| 77 |
+
setTimeout(() => {
|
| 78 |
+
_setShowAc(false);
|
| 79 |
+
if (plan.length > 0) {
|
| 80 |
+
const planStr = plan.map(i => `- ${i.content}`).join('\\\\n');
|
| 81 |
+
chat.sendMessage({ text: `[TIẾP TỤC] Task chưa hoàn thành:\\\\n${planStr}`, metadata: { createdAt: new Date().toISOString() } });
|
| 82 |
+
}
|
| 83 |
+
}, 10000);
|
| 84 |
+
return;
|
| 85 |
+
}
|
| 86 |
+
const autoApproval = data.auto_approval;"""
|
| 87 |
+
|
| 88 |
+
if "_setShowAc" in c and "ac_plan" not in c:
|
| 89 |
+
# Find existing onSessionUpdate
|
| 90 |
+
old = """ onSessionUpdate: (data) => {
|
| 91 |
+
const autoApproval = data.auto_approval;"""
|
| 92 |
+
if old in c:
|
| 93 |
+
c = c.replace(old, handler_block)
|
| 94 |
+
print("OK: injected onSessionUpdate handler")
|
| 95 |
+
|
| 96 |
+
# 4. Cancel function + return values
|
| 97 |
+
cancel = """
|
| 98 |
+
const _acCancel = useCallback(() => { _setShowAc(false); }, []);"""
|
| 99 |
+
if "_acCancel" not in c and "_showAc" in c:
|
| 100 |
+
c = c.replace("\n return {\n messages: chat.messages,", cancel + "\n return {\n messages: chat.messages,")
|
| 101 |
+
c = c.replace(
|
| 102 |
+
"refreshMessages,\n };",
|
| 103 |
+
"refreshMessages,\n _showAc,\n _acCancel,\n };"
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
with open(hook_ts, 'w') as f: f.write(c)
|
| 107 |
+
print("OK: useAgentChat.ts")
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def patch_chat_input():
|
| 111 |
+
with open(input_tsx) as f:
|
| 112 |
+
c = f.read()
|
| 113 |
+
|
| 114 |
+
if "Button" not in c:
|
| 115 |
+
c = c.replace(" Tooltip,", " Tooltip,\n Button,")
|
| 116 |
+
|
| 117 |
+
if "_showAc" not in c:
|
| 118 |
+
old_iface = """interface ChatInputProps {
|
| 119 |
+
sessionId?: string;
|
| 120 |
+
initialModelPath?: string | null;
|
| 121 |
+
onSend: (text: string) => void;
|
| 122 |
+
onStop?: () => void;
|
| 123 |
+
onDatasetUploaded?: () => Promise<boolean> | boolean;
|
| 124 |
+
isProcessing?: boolean;
|
| 125 |
+
disabled?: boolean;
|
| 126 |
+
placeholder?: string;
|
| 127 |
+
}"""
|
| 128 |
+
new_iface = """interface ChatInputProps {
|
| 129 |
+
sessionId?: string;
|
| 130 |
+
initialModelPath?: string | null;
|
| 131 |
+
onSend: (text: string) => void;
|
| 132 |
+
onStop?: () => void;
|
| 133 |
+
onDatasetUploaded?: () => Promise<boolean> | boolean;
|
| 134 |
+
isProcessing?: boolean;
|
| 135 |
+
disabled?: boolean;
|
| 136 |
+
placeholder?: string;
|
| 137 |
+
_showAc?: boolean;
|
| 138 |
+
_acCancel?: () => void;
|
| 139 |
+
}"""
|
| 140 |
+
c = c.replace(old_iface, new_iface)
|
| 141 |
+
|
| 142 |
+
# Add destructured props
|
| 143 |
+
old_props = "placeholder = 'Ask anything...' }: ChatInputProps) {"
|
| 144 |
+
new_props = "placeholder = 'Ask anything...', _showAc = false, _acCancel }: ChatInputProps) {"
|
| 145 |
+
c = c.replace(old_props, new_props)
|
| 146 |
+
|
| 147 |
+
if "T\u1ea1m d\u1eebng" not in c:
|
| 148 |
+
btn = """
|
| 149 |
+
{_showAc && _acCancel && (
|
| 150 |
+
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 1 }}>
|
| 151 |
+
<Button variant="outlined" size="small" onClick={_acCancel}
|
| 152 |
+
sx={{ textTransform: 'none', fontSize: '0.7rem', opacity: 0.7, '&:hover': { opacity: 1 } }}>
|
| 153 |
+
T\u1ea1m d\u1eebng t\u1ef1 \u0111\u1ed9ng ti\u1ebfp t\u1ee5c (10s)
|
| 154 |
+
</Button>
|
| 155 |
+
</Box>
|
| 156 |
+
)}
|
| 157 |
+
"""
|
| 158 |
+
if "<JobsUpgradeDialog" in c:
|
| 159 |
+
c = c.replace("<JobsUpgradeDialog", btn + "\n <JobsUpgradeDialog")
|
| 160 |
+
|
| 161 |
+
with open(input_tsx, 'w') as f: f.write(c)
|
| 162 |
+
print("OK: ChatInput.tsx")
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def patch_session():
|
| 166 |
+
session_tsx = "/source/frontend/src/components/SessionChat.tsx"
|
| 167 |
+
if not os.path.exists(session_tsx):
|
| 168 |
+
print(f"SKIP: {session_tsx}")
|
| 169 |
+
return
|
| 170 |
+
with open(session_tsx) as f:
|
| 171 |
+
c = f.read()
|
| 172 |
+
if "_showAc" not in c:
|
| 173 |
+
c = c.replace("<ChatInput ", "<ChatInput _showAc={_showAc} _acCancel={_acCancel} ")
|
| 174 |
+
with open(session_tsx, 'w') as f: f.write(c)
|
| 175 |
+
print("OK: SessionChat.tsx")
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def patch_agent():
|
| 179 |
+
if not os.path.exists(agent_py):
|
| 180 |
+
print(f"SKIP: {agent_py}")
|
| 181 |
+
return
|
| 182 |
+
with open(agent_py) as f:
|
| 183 |
+
c = f.read()
|
| 184 |
+
|
| 185 |
+
if "_unfinished_plan" not in c:
|
| 186 |
+
fn = """
|
| 187 |
+
def _unfinished_plan(s: Session) -> list[dict[str, str]]:
|
| 188 |
+
p = getattr(s, "current_plan", None) or []
|
| 189 |
+
return [it for it in p if it.get("status") in ("pending", "in_progress")]
|
| 190 |
+
"""
|
| 191 |
+
c = c.replace("class Handlers:", fn + "\n\nclass Handlers:")
|
| 192 |
+
|
| 193 |
+
if "task_incomplete" not in c:
|
| 194 |
+
check = """
|
| 195 |
+
# Auto-continue detection
|
| 196 |
+
if not llm_result.tool_calls_acc and llm_result.content:
|
| 197 |
+
unfinished = _unfinished_plan(session)
|
| 198 |
+
if unfinished:
|
| 199 |
+
# Use session_update event with ac_plan field to trigger auto-continue
|
| 200 |
+
await session.send_event(Event(event_type="session_update", data={
|
| 201 |
+
"ac_plan": unfinished,
|
| 202 |
+
"auto_approval": getattr(session, "auto_approval", None),
|
| 203 |
+
}))
|
| 204 |
+
"""
|
| 205 |
+
marker = " # -- End of turn --"
|
| 206 |
+
if marker in c:
|
| 207 |
+
c = c.replace(marker, check + "\n" + marker)
|
| 208 |
+
else:
|
| 209 |
+
print("WARN: Could not find -- End of turn -- marker")
|
| 210 |
+
# Try alternative: find where final_response is set
|
| 211 |
+
marker2 = "final_response = llm_result.content or None"
|
| 212 |
+
if marker2 in c:
|
| 213 |
+
c = c.replace(marker2, marker2 + "\n" + check)
|
| 214 |
+
|
| 215 |
+
with open(agent_py, 'w') as f: f.write(c)
|
| 216 |
+
print("OK: agent_loop.py")
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
if __name__ == "__main__":
|
| 220 |
+
patch_events()
|
| 221 |
+
patch_sse()
|
| 222 |
+
patch_hook()
|
| 223 |
+
patch_chat_input()
|
| 224 |
+
patch_session()
|
| 225 |
+
patch_agent()
|
| 226 |
+
print("\nDONE: V9 applied")
|
| 227 |
+
print("Strategy: Uses existing session_update event + (sideChannel as any) to avoid TS issues")
|
patch_beatapi_routing.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Patch llm_params.py: add a DEDICATED BeatAPI provider branch.
|
| 2 |
+
|
| 3 |
+
BEATAPI vs OpenRouter -- why this branch is separate and must stay separate
|
| 4 |
+
=========================================================================
|
| 5 |
+
Every other model in this app except the HF Router / local-ollama ones is an
|
| 6 |
+
*OpenRouter* model. They are all tagged with an `openai/` prefix, and
|
| 7 |
+
_resolve_llm_params has a single catch-all that:
|
| 8 |
+
|
| 9 |
+
1. strips the `openai/` prefix,
|
| 10 |
+
2. points api_base at https://openrouter.ai/api/v1,
|
| 11 |
+
3. authenticates with OPENROUTER_API_KEY.
|
| 12 |
+
|
| 13 |
+
BeatAPI (https://api.beatapi.io) is a *different provider* that happens to
|
| 14 |
+
expose an OpenAI-compatible surface. It must therefore NOT reuse that branch:
|
| 15 |
+
|
| 16 |
+
provider OpenRouter BeatAPI
|
| 17 |
+
----------- ----------------------------- ------------------------------
|
| 18 |
+
api_base https://openrouter.ai/api/v1 https://api.beatapi.io/v1
|
| 19 |
+
key env OPENROUTER_API_KEY BEATAPI_API_KEY
|
| 20 |
+
model id vendor/model space-bunny-alpha (bare, no vendor prefix)
|
| 21 |
+
effort unsupported (omitted) low/medium/high/xhigh/max via extra_body
|
| 22 |
+
|
| 23 |
+
BeatAPI additionally:
|
| 24 |
+
- does not understand the `openai/` prefix at all, so sending
|
| 25 |
+
"openai/space-bunny-alpha" or "openai/stealth/space-bunny-alpha" 404s;
|
| 26 |
+
- uses `space-bunny-alpha` as a *flat* model id, so we must strip our own
|
| 27 |
+
`beatapi/` routing prefix before the request leaves the app;
|
| 28 |
+
- rate-limits the free tier (1 req/min before first top-up, 10/min after,
|
| 29 |
+
1000/day) and returns HTTP 429 with Retry-After.
|
| 30 |
+
|
| 31 |
+
Therefore BeatAPI models use the `beatapi/` prefix -- a namespace nothing else
|
| 32 |
+
uses -- so this branch can never be shadowed by the `openai/` catch-all, and
|
| 33 |
+
the OpenRouter branch can never accidentally strip a BeatAPI model id.
|
| 34 |
+
|
| 35 |
+
If BEATAPI_API_KEY is unset we raise a clear, actionable error instead of
|
| 36 |
+
silently sending an empty key (which BeatAPI answers with a bare 401
|
| 37 |
+
missing_api_key).
|
| 38 |
+
|
| 39 |
+
Runs AFTER patch_models.py and patch_openrouter_fix.py. Idempotent.
|
| 40 |
+
"""
|
| 41 |
+
|
| 42 |
+
import ast
|
| 43 |
+
|
| 44 |
+
FILE = "/app/agent/core/llm_params.py"
|
| 45 |
+
|
| 46 |
+
BEATAPI_BASE_URL = "https://api.beatapi.io/v1"
|
| 47 |
+
BEATAPI_MODEL_PREFIX = "beatapi/"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
with open(FILE) as f:
|
| 51 |
+
c = f.read()
|
| 52 |
+
|
| 53 |
+
if "BeatApiKeyMissingError" in c:
|
| 54 |
+
print("OK: llm_params.py already contains BeatAPI routing (idempotent)")
|
| 55 |
+
raise SystemExit(0)
|
| 56 |
+
|
| 57 |
+
if "\nimport os\n" not in c:
|
| 58 |
+
print("FAIL: expected `import os` in llm_params.py")
|
| 59 |
+
raise SystemExit(1)
|
| 60 |
+
|
| 61 |
+
# --- Insert the BeatAPI block before _resolve_llm_params ---
|
| 62 |
+
anchor = "def _resolve_llm_params(\n"
|
| 63 |
+
if anchor not in c:
|
| 64 |
+
print("FAIL: could not find _resolve_llm_params definition")
|
| 65 |
+
raise SystemExit(1)
|
| 66 |
+
|
| 67 |
+
beatapi_block = f'''BEATAPI_BASE_URL = "{BEATAPI_BASE_URL}"
|
| 68 |
+
BEATAPI_MODEL_PREFIX = "{BEATAPI_MODEL_PREFIX}"
|
| 69 |
+
# BeatAPI accepts low/medium/high/xhigh/max (HF Router only low/medium/high).
|
| 70 |
+
_BEATAPI_EFFORTS = {{"low", "medium", "high", "xhigh", "max"}}
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
class BeatApiKeyMissingError(ValueError):
|
| 74 |
+
"""BEATAPI_API_KEY is not configured, so BeatAPI cannot be called.
|
| 75 |
+
|
| 76 |
+
Raised before any network call so the UI shows an actionable message
|
| 77 |
+
("set BEATAPI_API_KEY") instead of an opaque 401 missing_api_key.
|
| 78 |
+
"""
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _beatapi_base_model(model_name: str) -> str:
|
| 82 |
+
"""Return the wire model id for a BeatAPI request.
|
| 83 |
+
|
| 84 |
+
``beatapi/space-bunny-alpha`` -> ``space-bunny-alpha``
|
| 85 |
+
BeatAPI model ids are flat; they must never carry our routing prefix.
|
| 86 |
+
"""
|
| 87 |
+
return model_name[len(BEATAPI_MODEL_PREFIX):].strip()
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _resolve_beatapi_params(
|
| 91 |
+
model_name: str,
|
| 92 |
+
reasoning_effort: str | None = None,
|
| 93 |
+
strict: bool = False,
|
| 94 |
+
) -> dict:
|
| 95 |
+
"""Build LiteLLM kwargs for a BeatAPI (``beatapi/``-prefixed) model.
|
| 96 |
+
|
| 97 |
+
Mirrors the OpenRouter branch but targets api.beatapi.io, uses
|
| 98 |
+
BEATAPI_API_KEY, forces LiteLLM's OpenAI-compatible client, and forwards
|
| 99 |
+
BeatAPI's own reasoning_effort levels (it accepts xhigh and max, which the
|
| 100 |
+
HF Router does not).
|
| 101 |
+
"""
|
| 102 |
+
base_model = _beatapi_base_model(model_name)
|
| 103 |
+
if not base_model:
|
| 104 |
+
raise ValueError(f"Missing BeatAPI model id: {{model_name!r}}")
|
| 105 |
+
|
| 106 |
+
api_key = os.environ.get("BEATAPI_API_KEY", "").strip()
|
| 107 |
+
if not api_key:
|
| 108 |
+
raise BeatApiKeyMissingError(
|
| 109 |
+
"BEATAPI_API_KEY is not set. BeatAPI models (beatapi/...) require a "
|
| 110 |
+
"BeatAPI key from https://beatapi.io (Dashboard -> API Keys); "
|
| 111 |
+
"set it as a Space secret named BEATAPI_API_KEY."
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
params: dict = {{
|
| 115 |
+
"model": f"openai/{{base_model}}",
|
| 116 |
+
"api_base": BEATAPI_BASE_URL,
|
| 117 |
+
"api_key": api_key,
|
| 118 |
+
# Force LiteLLM's OpenAI-compatible client. Without this, LiteLLM would
|
| 119 |
+
# try to resolve a provider from the model string and raise
|
| 120 |
+
# "LLM Provider NOT provided" (same class of bug as the OpenRouter fix).
|
| 121 |
+
"custom_llm_provider": "openai",
|
| 122 |
+
}}
|
| 123 |
+
|
| 124 |
+
if reasoning_effort:
|
| 125 |
+
level = reasoning_effort
|
| 126 |
+
if level not in _BEATAPI_EFFORTS:
|
| 127 |
+
if strict:
|
| 128 |
+
raise UnsupportedEffortError(
|
| 129 |
+
f"BeatAPI doesn't accept effort={{level!r}}"
|
| 130 |
+
)
|
| 131 |
+
else:
|
| 132 |
+
# BeatAPI reads reasoning_effort from the request body directly.
|
| 133 |
+
params["extra_body"] = {{"reasoning_effort": level}}
|
| 134 |
+
return params
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
'''
|
| 138 |
+
|
| 139 |
+
c = c.replace(anchor, beatapi_block + anchor, 1)
|
| 140 |
+
|
| 141 |
+
# --- Add the dispatch inside _resolve_llm_params, BEFORE the openai/ catch-all ---
|
| 142 |
+
# The dispatch must sit after the local-model checks and before the OpenRouter
|
| 143 |
+
# branch so `beatapi/` can never be swallowed by `openai/`.
|
| 144 |
+
dispatch_anchor = " hf_model = normalized_model\n"
|
| 145 |
+
if dispatch_anchor not in c:
|
| 146 |
+
print("FAIL: could not find hf_model assignment in _resolve_llm_params")
|
| 147 |
+
raise SystemExit(1)
|
| 148 |
+
|
| 149 |
+
dispatch = """ # --- BeatAPI provider (beatapi/ prefix) -------------------------------
|
| 150 |
+
# NOTE: distinct from the OpenRouter branch below on api_base, API key,
|
| 151 |
+
# model-id shape and reasoning_effort levels. Keep this check above the
|
| 152 |
+
# openai/ catch-all so the two providers can never collide.
|
| 153 |
+
if normalized_model.startswith(BEATAPI_MODEL_PREFIX):
|
| 154 |
+
return _resolve_beatapi_params(
|
| 155 |
+
normalized_model, reasoning_effort, strict
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
"""
|
| 159 |
+
|
| 160 |
+
c = c.replace(dispatch_anchor, dispatch + dispatch_anchor, 1)
|
| 161 |
+
|
| 162 |
+
with open(FILE, "w") as f:
|
| 163 |
+
f.write(c)
|
| 164 |
+
|
| 165 |
+
try:
|
| 166 |
+
ast.parse(c)
|
| 167 |
+
print("OK: llm_params.py patched & syntax valid")
|
| 168 |
+
except SyntaxError as e:
|
| 169 |
+
print(f"FAIL: llm_params.py syntax error: {e}")
|
| 170 |
+
raise
|
| 171 |
+
|
| 172 |
+
# Sanity: the BeatAPI branch must appear before the openai/ catch-all.
|
| 173 |
+
beat_i = c.find("startswith(BEATAPI_MODEL_PREFIX)")
|
| 174 |
+
openai_i = c.find('startswith("openai/")')
|
| 175 |
+
if beat_i != -1 and openai_i != -1:
|
| 176 |
+
if beat_i < openai_i:
|
| 177 |
+
print("OK: BeatAPI dispatch precedes the openai/ catch-all")
|
| 178 |
+
else:
|
| 179 |
+
print("FAIL: BeatAPI dispatch is AFTER the openai/ catch-all")
|
| 180 |
+
raise SystemExit(1)
|
| 181 |
+
|
| 182 |
+
print("DONE: BeatAPI routing branch added to llm_params.py")
|
patch_catch_up_dataset_fallback.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Patch frontend: Add dataset fallback to ExpiredBanner handleCatchUp.
|
| 4 |
+
|
| 5 |
+
When localStorage caches (backend-messages, ui-messages) are empty
|
| 6 |
+
(user cleared cache, new browser, Space restarted), the catch-up flow
|
| 7 |
+
shows "Nothing to summarize from this chat.". This patch adds a fallback
|
| 8 |
+
that fetches messages from /api/session-history/{session_id} — which reads
|
| 9 |
+
from the permanent HF dataset (bep40/ml-intern-sessions).
|
| 10 |
+
|
| 11 |
+
This ensures the "Catch me up" button always works as long as the
|
| 12 |
+
session was previously logged to the permanent dataset.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import os
|
| 16 |
+
import sys
|
| 17 |
+
|
| 18 |
+
PATCHED_MARKER = "// === PATCH: catch-up dataset fallback ==="
|
| 19 |
+
TARGET_FILE = "/source/frontend/src/components/Chat/ExpiredBanner.tsx"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def patch_expired_banner(content: str) -> tuple[str, bool]:
|
| 23 |
+
changed = False
|
| 24 |
+
|
| 25 |
+
if PATCHED_MARKER in content:
|
| 26 |
+
print(f" SKIP: already patched")
|
| 27 |
+
return content, False
|
| 28 |
+
|
| 29 |
+
# Add a fallback to /api/session-history/{session_id} before the error.
|
| 30 |
+
# The old block checks if messages array is empty and bails with error.
|
| 31 |
+
old_block = (
|
| 32 |
+
" if (!messages || messages.length === 0) {\n"
|
| 33 |
+
" setError('Nothing to summarize from this chat.');\n"
|
| 34 |
+
" setBusy(null);\n"
|
| 35 |
+
" return;\n"
|
| 36 |
+
" }\n"
|
| 37 |
+
)
|
| 38 |
+
new_block = (
|
| 39 |
+
PATCHED_MARKER + "\n"
|
| 40 |
+
" // Fallback: when localStorage caches are empty (Space restart,\n"
|
| 41 |
+
" // new browser, cache cleared), read from the permanent HF dataset\n"
|
| 42 |
+
" // via /api/session-history/{session_id}.\n"
|
| 43 |
+
" if (!messages || messages.length === 0) {\n"
|
| 44 |
+
" try {\n"
|
| 45 |
+
" const res = await apiFetch(`/api/session-history/${sessionId}`);\n"
|
| 46 |
+
" if (res.ok) {\n"
|
| 47 |
+
" const history = await res.json();\n"
|
| 48 |
+
" if (Array.isArray(history) && history.length > 0) {\n"
|
| 49 |
+
" messages = history.map((row: any) => ({\n"
|
| 50 |
+
" role: row.role || 'assistant',\n"
|
| 51 |
+
" content: row.content || '',\n"
|
| 52 |
+
" }));\n"
|
| 53 |
+
" }\n"
|
| 54 |
+
" }\n"
|
| 55 |
+
" } catch {\n"
|
| 56 |
+
" /* dataset fetch failed — continue to error below */\n"
|
| 57 |
+
" }\n"
|
| 58 |
+
" }\n"
|
| 59 |
+
" if (!messages || messages.length === 0) {\n"
|
| 60 |
+
" setError('Nothing to summarize from this chat.');\n"
|
| 61 |
+
" setBusy(null);\n"
|
| 62 |
+
" return;\n"
|
| 63 |
+
" }\n"
|
| 64 |
+
)
|
| 65 |
+
if old_block in content:
|
| 66 |
+
content = content.replace(old_block, new_block, 1)
|
| 67 |
+
changed = True
|
| 68 |
+
print(" + Added dataset fallback to handleCatchUp")
|
| 69 |
+
else:
|
| 70 |
+
print(" SKIP: target block not found (pattern may have changed)")
|
| 71 |
+
|
| 72 |
+
return content, changed
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def main():
|
| 76 |
+
if not os.path.exists(TARGET_FILE):
|
| 77 |
+
print(f"SKIP: {TARGET_FILE} not found — frontend source not available")
|
| 78 |
+
return
|
| 79 |
+
|
| 80 |
+
with open(TARGET_FILE, "r", encoding="utf-8") as f:
|
| 81 |
+
content = f.read()
|
| 82 |
+
|
| 83 |
+
if PATCHED_MARKER in content:
|
| 84 |
+
print(f"SKIP: {TARGET_FILE} already patched")
|
| 85 |
+
return
|
| 86 |
+
|
| 87 |
+
content, changed = patch_expired_banner(content)
|
| 88 |
+
if changed:
|
| 89 |
+
with open(TARGET_FILE, "w", encoding="utf-8") as f:
|
| 90 |
+
f.write(content)
|
| 91 |
+
print(f"OK: {TARGET_FILE} patched with dataset fallback")
|
| 92 |
+
else:
|
| 93 |
+
print(f"SKIP: {TARGET_FILE} — no changes applied")
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
if __name__ == "__main__":
|
| 97 |
+
try:
|
| 98 |
+
main()
|
| 99 |
+
except Exception as e:
|
| 100 |
+
print(f"WARN: patch_catch_up_dataset_fallback.py error: {e}")
|
| 101 |
+
# Never fail the Docker build
|
| 102 |
+
sys.exit(0)
|
patch_chat_input.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Patch ChatInput.tsx - Fixed import for DatasetUploadResponse."""
|
| 2 |
+
|
| 3 |
+
CHAT_INPUT = "/source/frontend/src/components/Chat/ChatInput.tsx"
|
| 4 |
+
|
| 5 |
+
def patch():
|
| 6 |
+
import os, re
|
| 7 |
+
if not os.path.exists(CHAT_INPUT):
|
| 8 |
+
print(f"SKIP: {CHAT_INPUT} not found")
|
| 9 |
+
return
|
| 10 |
+
|
| 11 |
+
with open(CHAT_INPUT, "r", encoding="utf-8") as f:
|
| 12 |
+
content = f.read()
|
| 13 |
+
|
| 14 |
+
# 1. Add Button to MUI imports
|
| 15 |
+
if "Button" not in content and "@mui/material" in content:
|
| 16 |
+
content = content.replace(
|
| 17 |
+
"import { Box, IconButton, Stack, Tooltip } from '@mui/material';",
|
| 18 |
+
"import { Box, IconButton, Stack, Tooltip, Button } from '@mui/material';"
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
# 2. Fix DatasetUploadResponse import - check existing imports
|
| 22 |
+
if "DatasetUploadResponse" not in content:
|
| 23 |
+
# Find existing import from types/agent
|
| 24 |
+
if "from '@/types/agent'" in content:
|
| 25 |
+
content = content.replace(
|
| 26 |
+
"from '@/types/agent'",
|
| 27 |
+
"from '@/types/agent'"
|
| 28 |
+
)
|
| 29 |
+
# Add DatasetUploadResponse to existing import
|
| 30 |
+
content = content.replace(
|
| 31 |
+
"import type { ",
|
| 32 |
+
"import type { DatasetUploadResponse, "
|
| 33 |
+
)
|
| 34 |
+
else:
|
| 35 |
+
content = content.replace(
|
| 36 |
+
"import { apiFetch } from '@/utils/api';",
|
| 37 |
+
"import { apiFetch } from '@/utils/api';\nimport type { DatasetUploadResponse } from '@/types/agent';"
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
# 3. Add props to interface
|
| 41 |
+
if "showAutoContinue" not in content:
|
| 42 |
+
props_code = '''
|
| 43 |
+
interface ChatInputProps {
|
| 44 |
+
sessionId: string;
|
| 45 |
+
initialModelPath: string | null | undefined;
|
| 46 |
+
onSend: (text: string) => Promise<void>;
|
| 47 |
+
onStop: () => void;
|
| 48 |
+
onDatasetUploaded: () => Promise<boolean>;
|
| 49 |
+
isProcessing: boolean;
|
| 50 |
+
disabled: boolean;
|
| 51 |
+
placeholder?: string;
|
| 52 |
+
showAutoContinue?: boolean;
|
| 53 |
+
taskIncompleteInfo?: { incompletePlan: Array<{ id: string; content: string; status: string }> } | null;
|
| 54 |
+
startAutoContinue?: () => void;
|
| 55 |
+
cancelAutoContinue?: () => void;
|
| 56 |
+
}
|
| 57 |
+
'''
|
| 58 |
+
match = re.search(r'interface ChatInputProps[^}]*[}][^}]*[}]', content, re.DOTALL)
|
| 59 |
+
if match:
|
| 60 |
+
content = content.replace(match.group(0), props_code)
|
| 61 |
+
else:
|
| 62 |
+
content = content.replace(
|
| 63 |
+
"export default function ChatInput",
|
| 64 |
+
props_code + "\nexport default function ChatInput"
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
# 4. Add auto-continue buttons before the flex box
|
| 68 |
+
buttons_code = '''
|
| 69 |
+
{/* Auto-continue controls for free models */}
|
| 70 |
+
{showAutoContinue && taskIncompleteInfo && (
|
| 71 |
+
<Stack direction="row" spacing={1} sx={{ mb: 1, px: 1 }}>
|
| 72 |
+
<Button
|
| 73 |
+
variant="contained"
|
| 74 |
+
color="primary"
|
| 75 |
+
size="small"
|
| 76 |
+
onClick={startAutoContinue}
|
| 77 |
+
sx={{ textTransform: 'none', fontSize: '0.75rem' }}
|
| 78 |
+
>
|
| 79 |
+
Tự động tiếp tục (10s)
|
| 80 |
+
</Button>
|
| 81 |
+
<Button
|
| 82 |
+
variant="outlined"
|
| 83 |
+
color="secondary"
|
| 84 |
+
size="small"
|
| 85 |
+
onClick={cancelAutoContinue}
|
| 86 |
+
sx={{ textTransform: 'none', fontSize: '0.75rem' }}
|
| 87 |
+
>
|
| 88 |
+
Tạm dừng
|
| 89 |
+
</Button>
|
| 90 |
+
</Stack>
|
| 91 |
+
)}
|
| 92 |
+
'''
|
| 93 |
+
|
| 94 |
+
if "Tự động tiếp tục" not in content:
|
| 95 |
+
content = content.replace(
|
| 96 |
+
"<Box sx={{ flex: 1 ",
|
| 97 |
+
buttons_code + " <Box sx={{ flex: 1 "
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
with open(CHAT_INPUT, "w", encoding="utf-8") as f:
|
| 101 |
+
f.write(content)
|
| 102 |
+
print(f"OK: Patched {CHAT_INPUT}")
|
| 103 |
+
|
| 104 |
+
if __name__ == "__main__":
|
| 105 |
+
patch()
|
patch_document_upload.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Patch: extend dataset uploads to support office/binary documents
|
| 2 |
+
(PDF, images, Excel, Word, PPT, OpenDocument, text) and extract their text
|
| 3 |
+
so the agent can understand the file content even on text-only LLM backends.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import ast
|
| 7 |
+
import os
|
| 8 |
+
|
| 9 |
+
FILE = "/app/backend/dataset_uploads.py"
|
| 10 |
+
|
| 11 |
+
with open(FILE) as f:
|
| 12 |
+
c = f.read()
|
| 13 |
+
|
| 14 |
+
orig = c
|
| 15 |
+
|
| 16 |
+
# ---- 1. Expand allowed extensions ----
|
| 17 |
+
old_ext = 'ALLOWED_DATASET_EXTENSIONS = {"csv", "json", "jsonl"}'
|
| 18 |
+
new_ext = (
|
| 19 |
+
'ALLOWED_DATASET_EXTENSIONS = {\n'
|
| 20 |
+
' "csv", "json", "jsonl",\n'
|
| 21 |
+
' "pdf",\n'
|
| 22 |
+
' "png", "jpg", "jpeg", "webp", "bmp", "tiff", "tif", "gif",\n'
|
| 23 |
+
' "xlsx", "xlsm", "xls",\n'
|
| 24 |
+
' "docx", "doc",\n'
|
| 25 |
+
' "pptx",\n'
|
| 26 |
+
' "odt", "ods", "odp",\n'
|
| 27 |
+
' "txt", "md", "rtf", "log",\n'
|
| 28 |
+
'}\n'
|
| 29 |
+
'BINARY_EXTRACTION_EXTS = {\n'
|
| 30 |
+
' "pdf",\n'
|
| 31 |
+
' "png", "jpg", "jpeg", "webp", "bmp", "tiff", "tif", "gif",\n'
|
| 32 |
+
' "xlsx", "xlsm", "xls",\n'
|
| 33 |
+
' "docx", "doc",\n'
|
| 34 |
+
' "pptx",\n'
|
| 35 |
+
' "odt", "ods", "odp",\n'
|
| 36 |
+
' "rtf",\n'
|
| 37 |
+
'}\n'
|
| 38 |
+
)
|
| 39 |
+
if old_ext in c:
|
| 40 |
+
c = c.replace(old_ext, new_ext)
|
| 41 |
+
print("OK: expanded ALLOWED_DATASET_EXTENSIONS")
|
| 42 |
+
|
| 43 |
+
else:
|
| 44 |
+
print("WARN: extension line not found")
|
| 45 |
+
|
| 46 |
+
# ---- 1b. Update the legacy error message so no stale message remains ----
|
| 47 |
+
old_msg = 'detail="Only .csv, .json, and .jsonl dataset files are supported."'
|
| 48 |
+
new_msg = 'detail="Không hỗ trợ định dạng này. Các định dạng hỗ trợ: CSV, JSON, JSONL, PDF, ảnh, Excel (XLSX/XLSM/XLS), Word (DOCX/DOC), PowerPoint (PPTX), OpenDocument (ODT/ODS/ODP), và văn bản (TXT/MD/RTF/LOG)."'
|
| 49 |
+
if old_msg in c:
|
| 50 |
+
c = c.replace(old_msg, new_msg)
|
| 51 |
+
print("OK: updated legacy error message")
|
| 52 |
+
else:
|
| 53 |
+
print("WARN: legacy dataset message not found (already updated?)")
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# ---- 2. Import the document parser ----
|
| 57 |
+
if "document_parser" not in c:
|
| 58 |
+
c = c.replace(
|
| 59 |
+
"from huggingface_hub import HfApi\n",
|
| 60 |
+
"from huggingface_hub import HfApi\n"
|
| 61 |
+
"from document_parser import (\n"
|
| 62 |
+
" extract_document_text,\n"
|
| 63 |
+
" format_uploaded_document_context,\n"
|
| 64 |
+
")\n",
|
| 65 |
+
)
|
| 66 |
+
print("OK: imported document_parser")
|
| 67 |
+
else:
|
| 68 |
+
print("OK: document_parser already imported")
|
| 69 |
+
|
| 70 |
+
# ---- 3. Extract text inside push_dataset_upload_to_hub ----
|
| 71 |
+
# Find the return statement that constructs the DatasetUpload and inject
|
| 72 |
+
# extraction before it.
|
| 73 |
+
marker = " return DatasetUpload(\n"
|
| 74 |
+
if marker in c and "extract_document_text" in c and "extracted_text" not in c:
|
| 75 |
+
extraction_block = (
|
| 76 |
+
" # Extract plain-text content so the agent can understand the file\n"
|
| 77 |
+
" # even when the LLM backend has no native attachment support.\n"
|
| 78 |
+
" extracted_text, extraction_warning = \"\", \"\"\n"
|
| 79 |
+
" if dataset_format in BINARY_EXTRACTION_EXTS:\n"
|
| 80 |
+
" try:\n"
|
| 81 |
+
" await asyncio.to_thread(upload.file.seek, 0)\n"
|
| 82 |
+
" raw_bytes = await asyncio.to_thread(upload.file.read)\n"
|
| 83 |
+
" extracted_text, extraction_warning = await asyncio.to_thread(\n"
|
| 84 |
+
" extract_document_text, safe_filename, raw_bytes\n"
|
| 85 |
+
" )\n"
|
| 86 |
+
" await asyncio.to_thread(upload.file.seek, 0)\n"
|
| 87 |
+
" except Exception as exc: # never let extraction break the upload\n"
|
| 88 |
+
" extraction_warning = f\"Could not extract text: {exc}\"\n"
|
| 89 |
+
" extracted_text = \"\"\n"
|
| 90 |
+
" await asyncio.to_thread(upload.file.seek, 0)\n"
|
| 91 |
+
"\n"
|
| 92 |
+
)
|
| 93 |
+
c = c.replace(marker, extraction_block + marker)
|
| 94 |
+
print("OK: inserted extraction block before DatasetUpload return")
|
| 95 |
+
else:
|
| 96 |
+
print("WARN: could not insert extraction block (marker or state issue)")
|
| 97 |
+
|
| 98 |
+
# ---- 4. Thread the extracted fields into the DatasetUpload dataclass ----
|
| 99 |
+
# Add fields to the dataclass
|
| 100 |
+
if "extracted_text: str" not in c:
|
| 101 |
+
c = c.replace(
|
| 102 |
+
" load_dataset_snippet: str\n",
|
| 103 |
+
" load_dataset_snippet: str\n"
|
| 104 |
+
" extracted_text: str = \"\"\n"
|
| 105 |
+
" extraction_warning: str = \"\"\n",
|
| 106 |
+
)
|
| 107 |
+
print("OK: added dataclass fields")
|
| 108 |
+
else:
|
| 109 |
+
print("OK: dataclass fields present")
|
| 110 |
+
|
| 111 |
+
# Add to response_payload
|
| 112 |
+
if '"extracted_text": self.extracted_text' not in c:
|
| 113 |
+
c = c.replace(
|
| 114 |
+
' "load_dataset_snippet": self.load_dataset_snippet,\n',
|
| 115 |
+
' "load_dataset_snippet": self.load_dataset_snippet,\n'
|
| 116 |
+
' "extracted_text": self.extracted_text,\n'
|
| 117 |
+
' "extraction_warning": self.extraction_warning,\n',
|
| 118 |
+
)
|
| 119 |
+
print("OK: response_payload includes extracted_text")
|
| 120 |
+
else:
|
| 121 |
+
print("OK: response_payload present")
|
| 122 |
+
|
| 123 |
+
# Add to the final constructor call
|
| 124 |
+
if "extracted_text=extracted_text" not in c:
|
| 125 |
+
c = c.replace(
|
| 126 |
+
" load_dataset_snippet=snippet,\n"
|
| 127 |
+
" )",
|
| 128 |
+
" load_dataset_snippet=snippet,\n"
|
| 129 |
+
" extracted_text=extracted_text,\n"
|
| 130 |
+
" extraction_warning=extraction_warning,\n"
|
| 131 |
+
" )",
|
| 132 |
+
)
|
| 133 |
+
print("OK: constructor passes extracted fields")
|
| 134 |
+
else:
|
| 135 |
+
print("OK: constructor present")
|
| 136 |
+
|
| 137 |
+
try:
|
| 138 |
+
ast.parse(c)
|
| 139 |
+
with open(FILE, "w") as f:
|
| 140 |
+
f.write(c)
|
| 141 |
+
print("OK: dataset_uploads.py written & syntax valid")
|
| 142 |
+
except SyntaxError as e:
|
| 143 |
+
print(f"FAIL: dataset_uploads.py syntax error: {e}")
|
| 144 |
+
|
| 145 |
+
# ---- Also patch models.py DatasetUploadResponse.format literal ----
|
| 146 |
+
MODELS_FILE = "/app/backend/models.py"
|
| 147 |
+
with open(MODELS_FILE) as f:
|
| 148 |
+
mc = f.read()
|
| 149 |
+
|
| 150 |
+
if 'Literal["csv", "json", "jsonl"]' in mc:
|
| 151 |
+
mc = mc.replace(
|
| 152 |
+
'format: Literal["csv", "json", "jsonl"]',
|
| 153 |
+
'format: str',
|
| 154 |
+
)
|
| 155 |
+
# add new fields
|
| 156 |
+
mc = mc.replace(
|
| 157 |
+
" load_dataset_snippet: str\n",
|
| 158 |
+
" load_dataset_snippet: str\n"
|
| 159 |
+
" extracted_text: str = \"\"\n"
|
| 160 |
+
" extraction_warning: str = \"\"\n",
|
| 161 |
+
)
|
| 162 |
+
with open(MODELS_FILE, "w") as f:
|
| 163 |
+
f.write(mc)
|
| 164 |
+
print("OK: models.py DatasetUploadResponse widened")
|
| 165 |
+
else:
|
| 166 |
+
print("OK: models.py already patched")
|
| 167 |
+
|
| 168 |
+
# ---- Patch routes/agent.py to use the richer doc context note ----
|
| 169 |
+
AGENT_FILE = "/app/backend/routes/agent.py"
|
| 170 |
+
with open(AGENT_FILE) as f:
|
| 171 |
+
ac = f.read()
|
| 172 |
+
|
| 173 |
+
if "format_uploaded_document_context" not in ac:
|
| 174 |
+
# Update import
|
| 175 |
+
old_imp = " dataset_context_note,\n push_dataset_upload_to_hub,\n"
|
| 176 |
+
new_imp = (
|
| 177 |
+
" format_uploaded_document_context,\n"
|
| 178 |
+
" push_dataset_upload_to_hub,\n"
|
| 179 |
+
)
|
| 180 |
+
if old_imp in ac:
|
| 181 |
+
ac = ac.replace(old_imp, new_imp)
|
| 182 |
+
print("OK: agent.py import updated")
|
| 183 |
+
else:
|
| 184 |
+
# Try removing the dataset_context_note import line entirely
|
| 185 |
+
ac = ac.replace(" dataset_context_note,\n", "")
|
| 186 |
+
ac = ac.replace(
|
| 187 |
+
"from dataset_uploads import (\n push_dataset_upload_to_hub,\n)",
|
| 188 |
+
"from dataset_uploads import (\n"
|
| 189 |
+
" format_uploaded_document_context,\n"
|
| 190 |
+
" push_dataset_upload_to_hub,\n)",
|
| 191 |
+
)
|
| 192 |
+
print("OK: agent.py import swapped (fallback)")
|
| 193 |
+
|
| 194 |
+
# Update usage
|
| 195 |
+
old_use = 'Message(role="user", content=dataset_context_note(uploaded))'
|
| 196 |
+
new_use = (
|
| 197 |
+
'Message(role="user", content=format_uploaded_document_context(\n'
|
| 198 |
+
" filename=uploaded.original_filename,\n"
|
| 199 |
+
" stored_filename=uploaded.filename,\n"
|
| 200 |
+
" repo_id=uploaded.repo_id,\n"
|
| 201 |
+
" path_in_repo=uploaded.path_in_repo,\n"
|
| 202 |
+
" hub_url=uploaded.hub_url,\n"
|
| 203 |
+
" size_bytes=uploaded.size_bytes,\n"
|
| 204 |
+
" file_format=uploaded.format,\n"
|
| 205 |
+
" extracted_text=uploaded.extracted_text,\n"
|
| 206 |
+
" warning=uploaded.extraction_warning,\n"
|
| 207 |
+
" ))"
|
| 208 |
+
)
|
| 209 |
+
if old_use in ac:
|
| 210 |
+
ac = ac.replace(old_use, new_use)
|
| 211 |
+
print("OK: agent.py usage updated")
|
| 212 |
+
else:
|
| 213 |
+
print("WARN: agent.py usage line not found")
|
| 214 |
+
|
| 215 |
+
try:
|
| 216 |
+
ast.parse(ac)
|
| 217 |
+
with open(AGENT_FILE, "w") as f:
|
| 218 |
+
f.write(ac)
|
| 219 |
+
print("OK: agent.py written & syntax valid")
|
| 220 |
+
except SyntaxError as e:
|
| 221 |
+
print(f"FAIL: agent.py syntax error: {e}")
|
| 222 |
+
else:
|
| 223 |
+
print("OK: agent.py already patched")
|
| 224 |
+
|
| 225 |
+
print("DONE: document upload backend patch complete")
|
patch_fix_static_404.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Patch: Fix 404 on root and /api when frontend build is missing.
|
| 4 |
+
|
| 5 |
+
Root cause: The Dockerfile creates /app/static with `mkdir -p` but the
|
| 6 |
+
frontend build may fail, leaving the directory empty. main.py checks
|
| 7 |
+
`if static_path.exists()` which returns True for an empty directory,
|
| 8 |
+
causing StaticFiles to mount at "/" and shadow ALL API routes with 404s.
|
| 9 |
+
|
| 10 |
+
Fix: Patch main.py to check that the static directory is non-empty
|
| 11 |
+
before mounting StaticFiles.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import os
|
| 15 |
+
import sys
|
| 16 |
+
|
| 17 |
+
TARGET_FILE = "/app/backend/main.py"
|
| 18 |
+
PATCHED_MARKER = "# === PATCH: fix static 404 on empty dir ==="
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def patch_main_py(content: str) -> tuple[str, bool]:
|
| 22 |
+
changed = False
|
| 23 |
+
|
| 24 |
+
old_block = (
|
| 25 |
+
"# Serve static files (frontend build) in production\n"
|
| 26 |
+
"static_path = Path(__file__).parent.parent / \"static\"\n"
|
| 27 |
+
"if static_path.exists():\n"
|
| 28 |
+
" app.mount(\"/\", StaticFiles(directory=str(static_path), html=True), name=\"static\")\n"
|
| 29 |
+
" logger.info(f\"Serving static files from {static_path}\")\n"
|
| 30 |
+
"else:\n"
|
| 31 |
+
" logger.info(\"No static directory found, running in API-only mode\")"
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
new_block = (
|
| 35 |
+
"# Serve static files (frontend build) in production\n"
|
| 36 |
+
"static_path = Path(__file__).parent.parent / \"static\"\n"
|
| 37 |
+
f"{PATCHED_MARKER}\n"
|
| 38 |
+
"# Only mount if the directory exists AND has content — an empty\n"
|
| 39 |
+
"# directory would shadow all API routes with 404s.\n"
|
| 40 |
+
"if static_path.exists() and any(static_path.iterdir()):\n"
|
| 41 |
+
" app.mount(\"/\", StaticFiles(directory=str(static_path), html=True), name=\"static\")\n"
|
| 42 |
+
" logger.info(f\"Serving static files from {static_path}\")\n"
|
| 43 |
+
"else:\n"
|
| 44 |
+
" logger.info(\"No static files found, running in API-only mode\")"
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
if old_block in content:
|
| 48 |
+
content = content.replace(old_block, new_block, 1)
|
| 49 |
+
changed = True
|
| 50 |
+
print(" + Fixed static file mounting to check for non-empty directory")
|
| 51 |
+
else:
|
| 52 |
+
# Try a more flexible match
|
| 53 |
+
if "static_path.exists()" in content and PATCHED_MARKER not in content:
|
| 54 |
+
import re
|
| 55 |
+
# Replace the if condition
|
| 56 |
+
pattern = r'if static_path\.exists\(\):'
|
| 57 |
+
replacement = f'if static_path.exists() and any(static_path.iterdir()):\n {PATCHED_MARKER}'
|
| 58 |
+
content = re.sub(pattern, replacement, content, count=1)
|
| 59 |
+
changed = True
|
| 60 |
+
print(" + Fixed static file mounting (regex fallback)")
|
| 61 |
+
else:
|
| 62 |
+
print(f" SKIP: pattern already patched or not found")
|
| 63 |
+
|
| 64 |
+
return content, changed
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def main():
|
| 68 |
+
if not os.path.exists(TARGET_FILE):
|
| 69 |
+
print(f"SKIP: {TARGET_FILE} not found")
|
| 70 |
+
return
|
| 71 |
+
|
| 72 |
+
with open(TARGET_FILE, "r", encoding="utf-8") as f:
|
| 73 |
+
content = f.read()
|
| 74 |
+
|
| 75 |
+
if PATCHED_MARKER in content:
|
| 76 |
+
print(f"SKIP: {TARGET_FILE} already patched")
|
| 77 |
+
return
|
| 78 |
+
|
| 79 |
+
# Syntax-check by parsing
|
| 80 |
+
import ast
|
| 81 |
+
try:
|
| 82 |
+
ast.parse(content)
|
| 83 |
+
except SyntaxError as e:
|
| 84 |
+
print(f"WARN: {TARGET_FILE} has syntax errors before patching: {e}")
|
| 85 |
+
return
|
| 86 |
+
|
| 87 |
+
content, changed = patch_main_py(content)
|
| 88 |
+
if changed:
|
| 89 |
+
try:
|
| 90 |
+
ast.parse(content)
|
| 91 |
+
with open(TARGET_FILE, "w", encoding="utf-8") as f:
|
| 92 |
+
f.write(content)
|
| 93 |
+
print(f"OK: {TARGET_FILE} patched")
|
| 94 |
+
except SyntaxError as e:
|
| 95 |
+
print(f"FAIL: patched {TARGET_FILE} has syntax error: {e}")
|
| 96 |
+
else:
|
| 97 |
+
print(f"SKIP: {TARGET_FILE} — no changes applied")
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
if __name__ == "__main__":
|
| 101 |
+
try:
|
| 102 |
+
main()
|
| 103 |
+
except Exception as e:
|
| 104 |
+
print(f"WARN: patch_fix_static_404.py error: {e}")
|
| 105 |
+
sys.exit(0)
|
patch_frontend.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Patch frontend: Replace DEFAULT_MODEL_OPTIONS with OpenRouter models."""
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
import sys
|
| 5 |
+
|
| 6 |
+
FILE = "/source/frontend/src/components/Chat/ChatInput.tsx"
|
| 7 |
+
MODEL_FILE = "/source/frontend/src/utils/model.ts"
|
| 8 |
+
|
| 9 |
+
# === Step 1: Patch model.ts ===
|
| 10 |
+
with open(MODEL_FILE, "r") as f:
|
| 11 |
+
model_content = f.read()
|
| 12 |
+
|
| 13 |
+
model_content = model_content.replace(
|
| 14 |
+
"export const KIMI_K27_CODE_MODEL_PATH = 'moonshotai/Kimi-K2.7-Code:novita';",
|
| 15 |
+
"export const KIMI_K27_CODE_MODEL_PATH = 'deepseek-ai/DeepSeek-V4-Pro';"
|
| 16 |
+
)
|
| 17 |
+
model_content = model_content.replace(
|
| 18 |
+
"export const MINIMAX_M3_MODEL_PATH = 'MiniMaxAI/MiniMax-M3:novita';",
|
| 19 |
+
"export const MINIMAX_M3_MODEL_PATH = 'deepseek-ai/DeepSeek-V4-Flash';"
|
| 20 |
+
)
|
| 21 |
+
model_content = model_content.replace(
|
| 22 |
+
"export const GLM_52_MODEL_PATH = 'zai-org/GLM-5.2:novita';",
|
| 23 |
+
"export const GLM_52_MODEL_PATH = 'deepseek/deepseek-v4-flash';"
|
| 24 |
+
)
|
| 25 |
+
model_content = model_content.replace(
|
| 26 |
+
"export const DEEPSEEK_V4_PRO_MODEL_PATH = 'deepseek-ai/DeepSeek-V4-Pro:novita';",
|
| 27 |
+
"export const DEEPSEEK_V4_PRO_MODEL_PATH = 'nvidia/nemotron-3-super-120b-a12b:free';"
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
# Add DeepSeek V4 Flash latest model path
|
| 31 |
+
if "DEEPSEEK_V4_FLASH_LATEST_MODEL_PATH" not in model_content:
|
| 32 |
+
model_content = model_content.replace(
|
| 33 |
+
"export const LAGUNA_S21_MODEL_PATH",
|
| 34 |
+
"export const DEEPSEEK_V4_FLASH_LATEST_MODEL_PATH = '~deepseek/deepseek-v4-flash-latest';\n\nexport const LAGUNA_S21_MODEL_PATH"
|
| 35 |
+
)
|
| 36 |
+
print("OK: Added DEEPSEEK_V4_FLASH_LATEST_MODEL_PATH")
|
| 37 |
+
|
| 38 |
+
# === PATCH: Add Laguna S 2.1 model path ===
|
| 39 |
+
if "LAGUNA_S21_MODEL_PATH" not in model_content:
|
| 40 |
+
model_content = model_content.replace(
|
| 41 |
+
"export const LAGUNA_M1_MODEL_PATH",
|
| 42 |
+
"export const LAGUNA_S21_MODEL_PATH = 'openai/poolside/laguna-s-2.1:free';\n\nexport const LAGUNA_M1_MODEL_PATH"
|
| 43 |
+
)
|
| 44 |
+
print("OK: Added LAGUNA_S21_MODEL_PATH")
|
| 45 |
+
|
| 46 |
+
# === PATCH: Add Space Bunny Alpha model path ===
|
| 47 |
+
if "SPACE_BUNNY_ALPHA_MODEL_PATH" not in model_content:
|
| 48 |
+
model_content = model_content.replace(
|
| 49 |
+
"export const LAGUNA_S21_MODEL_PATH",
|
| 50 |
+
"export const SPACE_BUNNY_ALPHA_MODEL_PATH = 'openai/stealth/space-bunny-alpha';\n\nexport const LAGUNA_S21_MODEL_PATH"
|
| 51 |
+
)
|
| 52 |
+
print("OK: Added SPACE_BUNNY_ALPHA_MODEL_PATH")
|
| 53 |
+
|
| 54 |
+
with open(MODEL_FILE, "w") as f:
|
| 55 |
+
f.write(model_content)
|
| 56 |
+
print("OK: model.ts constants updated")
|
| 57 |
+
|
| 58 |
+
# === Step 2: Patch ChatInput.tsx ===
|
| 59 |
+
with open(FILE, "r", encoding="utf-8") as f:
|
| 60 |
+
content = f.read()
|
| 61 |
+
|
| 62 |
+
# Fix imports
|
| 63 |
+
import_pattern = re.compile(
|
| 64 |
+
r'import\s*\{[^}]+\}\s*from\s*[\'"]@/utils/model[\'"];'
|
| 65 |
+
)
|
| 66 |
+
match = import_pattern.search(content)
|
| 67 |
+
if match:
|
| 68 |
+
content = content.replace(match.group(0), "import { isClaudePath } from '@/utils/model';")
|
| 69 |
+
print("OK: Replaced imports")
|
| 70 |
+
|
| 71 |
+
# Remove Owl Alpha references
|
| 72 |
+
content = content.replace("'Owl Alpha'", "'Claude Opus 4.8'")
|
| 73 |
+
content = content.replace('"Owl Alpha"', '"Claude Opus 4.8"')
|
| 74 |
+
|
| 75 |
+
# Row 3: Kimi → DeepSeek V4 Pro
|
| 76 |
+
content = content.replace("'Kimi K2.7 Code'", "'DeepSeek V4 Pro'")
|
| 77 |
+
content = content.replace('"Kimi K2.7 Code"', '"DeepSeek V4 Pro"')
|
| 78 |
+
|
| 79 |
+
# Row 4: MiniMax → DeepSeek V4 Flash
|
| 80 |
+
content = content.replace("'MiniMax M3'", "'DeepSeek V4 Flash'")
|
| 81 |
+
content = content.replace('"MiniMax M3"', '"DeepSeek V4 Flash"')
|
| 82 |
+
|
| 83 |
+
# Row 5: GLM → DeepSeek V4 Flash
|
| 84 |
+
content = content.replace("'GLM 5.2'", "'DeepSeek V4 Flash'")
|
| 85 |
+
content = content.replace('"GLM 5.2"', '"DeepSeek V4 Flash"')
|
| 86 |
+
|
| 87 |
+
# Row 6: DeepSeek V4 Pro → Nemotron
|
| 88 |
+
content = content.replace(
|
| 89 |
+
" {\n id: 'deepseek-v4-pro',\n name: 'DeepSeek V4 Pro',\n modelPath: DEEPSEEK_V4_PRO_MODEL_PATH,\n avatarUrl: getHfAvatarUrl('deepseek-ai/DeepSeek-V4-Pro'),\n },",
|
| 90 |
+
" {\n id: 'nemotron-120b',\n name: 'nvidia/nemotron-3-super-120b-a12b:free',\n modelPath: DEEPSEEK_V4_PRO_MODEL_PATH,\n avatarUrl: 'https://huggingface.co/api/avatars/nvidia',\n },"
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
# Replace entire DEFAULT_MODEL_OPTIONS array
|
| 94 |
+
pattern = r'const DEFAULT_MODEL_OPTIONS: ModelOption\[\] = \[.*?\];'
|
| 95 |
+
match = re.search(pattern, content, re.DOTALL)
|
| 96 |
+
|
| 97 |
+
if not match:
|
| 98 |
+
print("FAIL: Could not find DEFAULT_MODEL_OPTIONS array!")
|
| 99 |
+
sys.exit(1)
|
| 100 |
+
|
| 101 |
+
NEW_MODELS = """const DEFAULT_MODEL_OPTIONS: ModelOption[] = [
|
| 102 |
+
{
|
| 103 |
+
id: 'laguna-s21',
|
| 104 |
+
name: 'Laguna S 2.1:free',
|
| 105 |
+
modelPath: 'openai/poolside/laguna-s-2.1:free',
|
| 106 |
+
avatarUrl: 'https://huggingface.co/api/avatars/tencent',
|
| 107 |
+
recommended: true,
|
| 108 |
+
},
|
| 109 |
+
{
|
| 110 |
+
id: 'gemma-4-31b',
|
| 111 |
+
name: 'Gemma 4 31B:free',
|
| 112 |
+
modelPath: 'openai/google/gemma-4-31b-it:free',
|
| 113 |
+
avatarUrl: 'https://huggingface.co/api/avatars/google',
|
| 114 |
+
recommended: true,
|
| 115 |
+
},
|
| 116 |
+
{
|
| 117 |
+
id: 'llama-3.3-70b',
|
| 118 |
+
name: 'Llama 3.3 70B:free',
|
| 119 |
+
modelPath: 'openai/meta-llama/llama-3.3-70b-instruct:free',
|
| 120 |
+
avatarUrl: 'https://huggingface.co/api/avatars/meta-llama',
|
| 121 |
+
recommended: true,
|
| 122 |
+
},
|
| 123 |
+
{
|
| 124 |
+
id: 'laguna-m1',
|
| 125 |
+
name: 'Laguna M.1:free',
|
| 126 |
+
modelPath: 'openai/poolside/laguna-m.1:free',
|
| 127 |
+
avatarUrl: 'https://huggingface.co/api/avatars/poolside',
|
| 128 |
+
recommended: true,
|
| 129 |
+
},
|
| 130 |
+
{
|
| 131 |
+
id: 'laguna-s21',
|
| 132 |
+
name: 'Laguna S 2.1:free',
|
| 133 |
+
modelPath: 'openai/poolside/laguna-s-2.1:free',
|
| 134 |
+
avatarUrl: 'https://huggingface.co/api/avatars/poolside',
|
| 135 |
+
recommended: true,
|
| 136 |
+
},
|
| 137 |
+
{
|
| 138 |
+
id: 'space-bunny-alpha',
|
| 139 |
+
name: 'stealth/space-bunny-alpha',
|
| 140 |
+
modelPath: 'openai/stealth/space-bunny-alpha',
|
| 141 |
+
avatarUrl: 'https://huggingface.co/api/avatars/stealth',
|
| 142 |
+
recommended: true,
|
| 143 |
+
},
|
| 144 |
+
{
|
| 145 |
+
id: 'nex-n2-mini',
|
| 146 |
+
name: 'nex-agi/nex-n2-mini',
|
| 147 |
+
modelPath: 'openai/nex-agi/nex-n2-mini',
|
| 148 |
+
avatarUrl: 'https://huggingface.co/api/avatars/nex-agi',
|
| 149 |
+
recommended: true,
|
| 150 |
+
},
|
| 151 |
+
{
|
| 152 |
+
id: 'ling-3.0-flash',
|
| 153 |
+
name: 'inclusionai/ling-3.0-flash:free',
|
| 154 |
+
modelPath: 'openai/inclusionai/ling-3.0-flash:free',
|
| 155 |
+
avatarUrl: 'https://huggingface.co/api/avatars/inclusionai',
|
| 156 |
+
recommended: true,
|
| 157 |
+
},
|
| 158 |
+
{
|
| 159 |
+
id: 'llama-3.1-8b',
|
| 160 |
+
name: 'Llama 3.1 8B',
|
| 161 |
+
modelPath: 'openai/meta-llama/llama-3.1-8b-instruct',
|
| 162 |
+
avatarUrl: 'https://huggingface.co/api/avatars/meta-llama',
|
| 163 |
+
},
|
| 164 |
+
{
|
| 165 |
+
id: 'qwen3-coder-next',
|
| 166 |
+
name: 'Qwen3 Coder Next',
|
| 167 |
+
modelPath: 'Qwen/Qwen3-Coder-Next',
|
| 168 |
+
avatarUrl: 'https://huggingface.co/api/avatars/Qwen',
|
| 169 |
+
},
|
| 170 |
+
{
|
| 171 |
+
id: 'gemini-2.0-flash',
|
| 172 |
+
name: 'Gemini 2.0 Flash',
|
| 173 |
+
modelPath: 'openai/google/gemini-2.0-flash-001',
|
| 174 |
+
avatarUrl: 'https://huggingface.co/api/avatars/google',
|
| 175 |
+
},
|
| 176 |
+
{
|
| 177 |
+
id: 'deepseek-v4-flash-latest',
|
| 178 |
+
name: 'DeepSeek V4 Flash latest',
|
| 179 |
+
modelPath: '~deepseek/deepseek-v4-flash-latest',
|
| 180 |
+
avatarUrl: 'https://huggingface.co/api/avatars/deepseek',
|
| 181 |
+
recommended: true,
|
| 182 |
+
},
|
| 183 |
+
];"""
|
| 184 |
+
|
| 185 |
+
content = content[:match.start()] + NEW_MODELS + content[match.end():]
|
| 186 |
+
|
| 187 |
+
# Update DEFAULT_MODEL_PATH
|
| 188 |
+
dm_path = re.search(r'const DEFAULT_MODEL_PATH\s*=.*?;', content)
|
| 189 |
+
if dm_path:
|
| 190 |
+
content = content.replace(dm_path.group(0), "const DEFAULT_MODEL_PATH = 'openai/poolside/laguna-s-2.1:free';")
|
| 191 |
+
print("OK: Replaced DEFAULT_MODEL_PATH")
|
| 192 |
+
else:
|
| 193 |
+
print("OK: DEFAULT_MODEL_PATH not found")
|
| 194 |
+
|
| 195 |
+
with open(FILE, "w", encoding="utf-8") as f:
|
| 196 |
+
f.write(content)
|
| 197 |
+
|
| 198 |
+
print("OK: Frontend patched - Laguna S 2.1:free DEFAULT")
|
| 199 |
+
print(" Models: HY3:free, Gemma 4 31B:free, Llama 3.3 70B:free, Laguna M.1:free, Laguna S 2.1:free, stealth/space-bunny-alpha, Nex N2 Mini, Ling 3.0 Flash:free, Llama 3.1 8B, DeepSeek V4 Flash latest")
|
patch_frontend_apodex_ling_beatapi.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Patch frontend ChatInput.tsx: add apodex-1.1-mini:free, ling-3.1-flash and
|
| 2 |
+
the BeatAPI space-bunny-alpha entry to the model picker.
|
| 3 |
+
|
| 4 |
+
- apodex/apodex-1.1-mini:free path: openai/apodex/apodex-1.1-mini:free
|
| 5 |
+
- inclusionai/ling-3.1-flash path: openai/inclusionai/ling-3.1-flash
|
| 6 |
+
- BeatAPI space-bunny-alpha path: beatapi/space-bunny-alpha
|
| 7 |
+
|
| 8 |
+
The first two follow the same OpenRouter `openai/` convention as the existing
|
| 9 |
+
models (including stealth/space-bunny-alpha). The third is a BEATAPI model:
|
| 10 |
+
its path starts with `beatapi/`, NOT `openai/`, so the backend routes it to
|
| 11 |
+
https://api.beatapi.io/v1 with BEATAPI_API_KEY instead of OpenRouter. The name
|
| 12 |
+
is prefixed "BeatAPI" so the picker makes the provider difference visible.
|
| 13 |
+
|
| 14 |
+
Runs AFTER patch_frontend.py and patch_frontend_new_models.py (which already
|
| 15 |
+
replaced DEFAULT_MODEL_OPTIONS). Idempotent.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
import os
|
| 19 |
+
import re
|
| 20 |
+
|
| 21 |
+
FILE = "/source/frontend/src/components/Chat/ChatInput.tsx"
|
| 22 |
+
|
| 23 |
+
NEW_ENTRIES = """ {
|
| 24 |
+
id: 'apodex-1.1-mini-free',
|
| 25 |
+
name: 'apodex/apodex-1.1-mini:free',
|
| 26 |
+
modelPath: 'openai/apodex/apodex-1.1-mini:free',
|
| 27 |
+
avatarUrl: 'https://huggingface.co/api/avatars/apodex',
|
| 28 |
+
recommended: true,
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
id: 'ling-3.1-flash',
|
| 32 |
+
name: 'inclusionai/ling-3.1-flash',
|
| 33 |
+
modelPath: 'openai/inclusionai/ling-3.1-flash',
|
| 34 |
+
avatarUrl: 'https://huggingface.co/api/avatars/inclusionai',
|
| 35 |
+
recommended: true,
|
| 36 |
+
},
|
| 37 |
+
{
|
| 38 |
+
id: 'beatapi-space-bunny-alpha',
|
| 39 |
+
name: 'BeatAPI space-bunny-alpha',
|
| 40 |
+
modelPath: 'beatapi/space-bunny-alpha',
|
| 41 |
+
avatarUrl: 'https://beatapi.io/favicon.ico',
|
| 42 |
+
recommended: true,
|
| 43 |
+
},
|
| 44 |
+
"""
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def patch():
|
| 48 |
+
if not os.path.exists(FILE):
|
| 49 |
+
print(f"SKIP: {FILE} not found")
|
| 50 |
+
return
|
| 51 |
+
|
| 52 |
+
with open(FILE, "r", encoding="utf-8") as f:
|
| 53 |
+
content = f.read()
|
| 54 |
+
|
| 55 |
+
if (
|
| 56 |
+
"apodex/apodex-1.1-mini:free" in content
|
| 57 |
+
and "beatapi/space-bunny-alpha" in content
|
| 58 |
+
):
|
| 59 |
+
print("OK: new models already present in ChatInput.tsx")
|
| 60 |
+
return
|
| 61 |
+
|
| 62 |
+
# Insert right before the stealth/space-bunny-alpha entry so the three new
|
| 63 |
+
# models sit next to the existing free OpenRouter models.
|
| 64 |
+
anchor = " {\n id: 'space-bunny-alpha',\n"
|
| 65 |
+
if anchor in content:
|
| 66 |
+
content = content.replace(anchor, NEW_ENTRIES + anchor, 1)
|
| 67 |
+
print("OK: added 3 models before the stealth/space-bunny-alpha entry")
|
| 68 |
+
else:
|
| 69 |
+
# Fallback: insert before deepseek-v4-flash-latest, the other stable anchor.
|
| 70 |
+
alt = " {\n id: 'deepseek-v4-flash-latest',\n"
|
| 71 |
+
if alt in content:
|
| 72 |
+
content = content.replace(alt, NEW_ENTRIES + alt, 1)
|
| 73 |
+
print("OK: added 3 models before deepseek-v4-flash-latest (fallback)")
|
| 74 |
+
else:
|
| 75 |
+
m = re.search(
|
| 76 |
+
r"const DEFAULT_MODEL_OPTIONS: ModelOption\[\] = \[.*?\];",
|
| 77 |
+
content,
|
| 78 |
+
re.DOTALL,
|
| 79 |
+
)
|
| 80 |
+
if not m:
|
| 81 |
+
print("FAIL: could not find any model-list anchor")
|
| 82 |
+
return
|
| 83 |
+
head = m.group(0)[: m.group(0).rindex("[") + 1]
|
| 84 |
+
content = (
|
| 85 |
+
content[: m.start()]
|
| 86 |
+
+ head
|
| 87 |
+
+ "\n"
|
| 88 |
+
+ NEW_ENTRIES
|
| 89 |
+
+ content[m.start() + len(head) :]
|
| 90 |
+
)
|
| 91 |
+
print("OK: added 3 models at top of DEFAULT_MODEL_OPTIONS (fallback)")
|
| 92 |
+
|
| 93 |
+
with open(FILE, "w", encoding="utf-8") as f:
|
| 94 |
+
f.write(content)
|
| 95 |
+
print("OK: ChatInput.tsx patched")
|
| 96 |
+
|
| 97 |
+
# Verification: all three paths must be present.
|
| 98 |
+
for needle in (
|
| 99 |
+
"openai/apodex/apodex-1.1-mini:free",
|
| 100 |
+
"openai/inclusionai/ling-3.1-flash",
|
| 101 |
+
"beatapi/space-bunny-alpha",
|
| 102 |
+
):
|
| 103 |
+
if needle not in content:
|
| 104 |
+
print(f"FAIL: missing {needle}")
|
| 105 |
+
raise SystemExit(1)
|
| 106 |
+
print("OK: verified all 3 new model paths present")
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
if __name__ == "__main__":
|
| 110 |
+
patch()
|
patch_frontend_multi.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Patch ChatInput.tsx: support uploading multiple files at once.
|
| 2 |
+
|
| 3 |
+
Changes:
|
| 4 |
+
1. Add `multiple` to the <input type="file"> so the user can select many
|
| 5 |
+
files/images in one pick.
|
| 6 |
+
2. Rewrite handleDatasetFileChange to send ALL selected files in a single
|
| 7 |
+
multipart request (backend returns list[DatasetUploadResponse]).
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import os
|
| 11 |
+
|
| 12 |
+
FILE = "/source/frontend/src/components/Chat/ChatInput.tsx"
|
| 13 |
+
|
| 14 |
+
def patch():
|
| 15 |
+
if not os.path.exists(FILE):
|
| 16 |
+
print(f"SKIP: {FILE} not found")
|
| 17 |
+
return
|
| 18 |
+
|
| 19 |
+
with open(FILE, "r", encoding="utf-8") as f:
|
| 20 |
+
content = f.read()
|
| 21 |
+
|
| 22 |
+
# ---- 1. Add `multiple` to the file input ----
|
| 23 |
+
old_input = '<input\n ref={fileInputRef}\n type="file"\n accept={DATASET_UPLOAD_ACCEPT}\n onChange={handleDatasetFileChange}\n style={{ display: \'none\' }}\n />'
|
| 24 |
+
new_input = '<input\n ref={fileInputRef}\n type="file"\n accept={DATASET_UPLOAD_ACCEPT}\n multiple\n onChange={handleDatasetFileChange}\n style={{ display: \'none\' }}\n />'
|
| 25 |
+
if old_input in content:
|
| 26 |
+
content = content.replace(old_input, new_input)
|
| 27 |
+
print("OK: added multiple to file input")
|
| 28 |
+
else:
|
| 29 |
+
print("WARN: file input block not matched exactly")
|
| 30 |
+
|
| 31 |
+
# ---- 2. Rewrite handleDatasetFileChange ----
|
| 32 |
+
old_handler_start = " const handleDatasetFileChange = useCallback("
|
| 33 |
+
start = content.find(old_handler_start)
|
| 34 |
+
if start == -1:
|
| 35 |
+
print("FAIL: handleDatasetFileChange not found")
|
| 36 |
+
return
|
| 37 |
+
# End of the handler: the " );" right after the deps line.
|
| 38 |
+
deps = " [sessionId, onDatasetUploaded],\n );"
|
| 39 |
+
end = content.find(deps, start)
|
| 40 |
+
if end != -1:
|
| 41 |
+
end += len(deps)
|
| 42 |
+
else:
|
| 43 |
+
print("FAIL: could not find end of handler")
|
| 44 |
+
return
|
| 45 |
+
|
| 46 |
+
new_handler = ''' const handleDatasetFileChange = useCallback(
|
| 47 |
+
async (event: React.ChangeEvent<HTMLInputElement>) => {
|
| 48 |
+
const files = Array.from(event.target.files ?? []);
|
| 49 |
+
event.target.value = '';
|
| 50 |
+
if (!files.length) return;
|
| 51 |
+
|
| 52 |
+
if (!sessionId) {
|
| 53 |
+
setDatasetUploadError('Start a session before uploading files.');
|
| 54 |
+
return;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
// Validate each file before uploading the batch.
|
| 58 |
+
for (const file of files) {
|
| 59 |
+
const ext = file.name.split('.').pop()?.toLowerCase() || '';
|
| 60 |
+
if (!DATASET_UPLOAD_EXTENSIONS.has(ext)) {
|
| 61 |
+
setDatasetUploadError('Không hỗ trợ định dạng này. Các định dạng được hỗ trợ: CSV, JSON, JSONL, PDF, ảnh (PNG/JPG/JPEG/WebP/BMP/TIFF/GIF), Excel (XLSX/XLSM/XLS), Word (DOCX/DOC), PowerPoint (PPTX), OpenDocument (ODT/ODS/ODP), và văn bản (TXT/MD/RTF/LOG).');
|
| 62 |
+
return;
|
| 63 |
+
}
|
| 64 |
+
if (file.size > MAX_DATASET_UPLOAD_BYTES) {
|
| 65 |
+
setDatasetUploadError(`File must be 100 MB or smaller. ${file.name} is ${formatBytes(file.size)}.`);
|
| 66 |
+
return;
|
| 67 |
+
}
|
| 68 |
+
if (file.size === 0) {
|
| 69 |
+
setDatasetUploadError(`Uploaded file is empty: ${file.name}`);
|
| 70 |
+
return;
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
// Send all files in one request; backend returns a list.
|
| 75 |
+
const formData = new FormData();
|
| 76 |
+
for (const file of files) {
|
| 77 |
+
formData.append('file', file);
|
| 78 |
+
}
|
| 79 |
+
setIsUploadingDataset(true);
|
| 80 |
+
setDatasetUploadProgress(0);
|
| 81 |
+
setDatasetUploadError(null);
|
| 82 |
+
setDatasetUploadSuccess(null);
|
| 83 |
+
try {
|
| 84 |
+
const res = await apiUpload(`/api/session/${sessionId}/datasets`, formData, {
|
| 85 |
+
onProgress: ({ percent }) => {
|
| 86 |
+
setDatasetUploadProgress(percent !== null && percent < 100 ? percent : null);
|
| 87 |
+
},
|
| 88 |
+
});
|
| 89 |
+
if (!res.ok) {
|
| 90 |
+
setDatasetUploadError(await readApiErrorMessage(res, 'File upload failed.'));
|
| 91 |
+
return;
|
| 92 |
+
}
|
| 93 |
+
const payload = await res.json() as DatasetUploadResponse[];
|
| 94 |
+
if (payload.length) {
|
| 95 |
+
setUploadedDatasets((previous) => [...payload, ...previous]);
|
| 96 |
+
const names = payload.map((u) => u.filename).join(', ');
|
| 97 |
+
setDatasetUploadSuccess(`Đã tải lên ${payload.length} file: ${names}`);
|
| 98 |
+
}
|
| 99 |
+
await onDatasetUploaded?.();
|
| 100 |
+
} catch (error) {
|
| 101 |
+
setDatasetUploadError(
|
| 102 |
+
error instanceof Error ? error.message : 'File upload failed.'
|
| 103 |
+
);
|
| 104 |
+
} finally {
|
| 105 |
+
setIsUploadingDataset(false);
|
| 106 |
+
setDatasetUploadProgress(null);
|
| 107 |
+
}
|
| 108 |
+
},
|
| 109 |
+
[sessionId, onDatasetUploaded],
|
| 110 |
+
);'''
|
| 111 |
+
|
| 112 |
+
content = content[:start] + new_handler + content[end:]
|
| 113 |
+
|
| 114 |
+
with open(FILE, "w", encoding="utf-8") as f:
|
| 115 |
+
f.write(content)
|
| 116 |
+
print("OK: ChatInput.tsx multi-file upload patched")
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
if __name__ == "__main__":
|
| 120 |
+
patch()
|
patch_frontend_new_models.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Patch frontend ChatInput.tsx: add 3 new OpenRouter models to the model picker.
|
| 2 |
+
|
| 3 |
+
New models (openai/ prefix routes to OpenRouter):
|
| 4 |
+
- UPSTAGE Solar Pro 4 (openai/upstage/solar-pro4)
|
| 5 |
+
- NVIDIA Nemotron 3.5 Lightning:free (openai/nvidia/nemotron-3.5-lightning:free)
|
| 6 |
+
- Liquid LFM 2.5 2.6B:free (openai/liquid/lfm-2.5-2.6b:free)
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
|
| 11 |
+
FILE = "/source/frontend/src/components/Chat/ChatInput.tsx"
|
| 12 |
+
|
| 13 |
+
def patch():
|
| 14 |
+
if not os.path.exists(FILE):
|
| 15 |
+
print(f"SKIP: {FILE} not found")
|
| 16 |
+
return
|
| 17 |
+
|
| 18 |
+
with open(FILE, "r", encoding="utf-8") as f:
|
| 19 |
+
content = f.read()
|
| 20 |
+
|
| 21 |
+
if "upstage-solar-pro4" in content:
|
| 22 |
+
print("OK: models already present")
|
| 23 |
+
return
|
| 24 |
+
|
| 25 |
+
# Unique anchor: the deepseek-v4-flash-latest entry inside DEFAULT_MODEL_OPTIONS.
|
| 26 |
+
# Insert the 3 new model objects immediately BEFORE that entry.
|
| 27 |
+
anchor = " {\n id: 'deepseek-v4-flash-latest',\n"
|
| 28 |
+
new_entries = (
|
| 29 |
+
" {\n"
|
| 30 |
+
" id: 'upstage-solar-pro4',\n"
|
| 31 |
+
" name: 'UPSTAGE Solar Pro 4',\n"
|
| 32 |
+
" modelPath: 'openai/upstage/solar-pro4',\n"
|
| 33 |
+
" avatarUrl: 'https://huggingface.co/api/avatars/upstage',\n"
|
| 34 |
+
" recommended: true,\n"
|
| 35 |
+
" },\n"
|
| 36 |
+
" {\n"
|
| 37 |
+
" id: 'nemotron-3.5-lightning',\n"
|
| 38 |
+
" name: 'NVIDIA Nemotron 3.5 Lightning:free',\n"
|
| 39 |
+
" modelPath: 'openai/nvidia/nemotron-3.5-lightning:free',\n"
|
| 40 |
+
" avatarUrl: 'https://huggingface.co/api/avatars/nvidia',\n"
|
| 41 |
+
" recommended: true,\n"
|
| 42 |
+
" },\n"
|
| 43 |
+
" {\n"
|
| 44 |
+
" id: 'liquid-lfm-2.5-2.6b',\n"
|
| 45 |
+
" name: 'Liquid LFM 2.5 2.6B:free',\n"
|
| 46 |
+
" modelPath: 'openai/liquid/lfm-2.5-2.6b:free',\n"
|
| 47 |
+
" avatarUrl: 'https://huggingface.co/api/avatars/liquid',\n"
|
| 48 |
+
" recommended: true,\n"
|
| 49 |
+
" },\n"
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
if anchor in content:
|
| 53 |
+
content = content.replace(anchor, new_entries + anchor, 1)
|
| 54 |
+
print("OK: added 3 new models before deepseek-v4-flash-latest entry")
|
| 55 |
+
else:
|
| 56 |
+
# Fallback: find the deepseek id line by itself.
|
| 57 |
+
alt = " id: 'deepseek-v4-flash-latest',\n"
|
| 58 |
+
if alt in content:
|
| 59 |
+
content = content.replace(alt, new_entries + " {\n" + alt, 1)
|
| 60 |
+
print("OK: added 3 new models (fallback anchor)")
|
| 61 |
+
else:
|
| 62 |
+
print("FAIL: could not find insertion anchor in DEFAULT_MODEL_OPTIONS")
|
| 63 |
+
return
|
| 64 |
+
|
| 65 |
+
with open(FILE, "w", encoding="utf-8") as f:
|
| 66 |
+
f.write(content)
|
| 67 |
+
print("OK: ChatInput.tsx new models patched")
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
if __name__ == "__main__":
|
| 71 |
+
patch()
|
patch_frontend_permanent_sessions.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Patch frontend: Disable 'where we here now?' notification.
|
| 3 |
+
|
| 4 |
+
Chỉ tìm và xóa bất kỳ chuỗi 'where we here now' nào trong frontend source.
|
| 5 |
+
An toàn — không thêm code TypeScript mới để tránh lỗi build.
|
| 6 |
+
"""
|
| 7 |
+
import os
|
| 8 |
+
import sys
|
| 9 |
+
|
| 10 |
+
FRONTEND_DIR = "/source/frontend/src"
|
| 11 |
+
|
| 12 |
+
patch_count = 0
|
| 13 |
+
|
| 14 |
+
# Scan all .ts/.tsx files for 'where we here now' and remove the notification
|
| 15 |
+
if os.path.exists(FRONTEND_DIR):
|
| 16 |
+
for root, dirs, files in os.walk(FRONTEND_DIR):
|
| 17 |
+
for fname in files:
|
| 18 |
+
if fname.endswith((".ts", ".tsx")):
|
| 19 |
+
fpath = os.path.join(root, fname)
|
| 20 |
+
try:
|
| 21 |
+
with open(fpath, "r", encoding="utf-8") as f:
|
| 22 |
+
content = f.read()
|
| 23 |
+
if "where we here now" in content.lower():
|
| 24 |
+
# Remove the entire line containing the notification text
|
| 25 |
+
lines = content.split("\n")
|
| 26 |
+
new_lines = []
|
| 27 |
+
removed = False
|
| 28 |
+
for line in lines:
|
| 29 |
+
if "where we here now" in line.lower():
|
| 30 |
+
removed = True
|
| 31 |
+
continue # Skip this line
|
| 32 |
+
new_lines.append(line)
|
| 33 |
+
if removed:
|
| 34 |
+
with open(fpath, "w", encoding="utf-8") as f:
|
| 35 |
+
f.write("\n".join(new_lines))
|
| 36 |
+
patch_count += 1
|
| 37 |
+
print(f" Removed 'where we here now?' from {fpath}")
|
| 38 |
+
except Exception as e:
|
| 39 |
+
print(f" WARN: Could not patch {fpath}: {e}")
|
| 40 |
+
|
| 41 |
+
if patch_count == 0:
|
| 42 |
+
print(" No 'where we here now?' notifications found (already clean)")
|
| 43 |
+
|
| 44 |
+
print(f"Done: patched {patch_count} frontend files")
|
| 45 |
+
sys.exit(0) # Always exit 0 — never fail build
|
patch_frontend_uploads.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Patch ChatInput.tsx: allow uploading PDF, images, Excel, Word, PPT,
|
| 2 |
+
OpenDocument and text files in addition to CSV/JSON/JSONL, and surface the
|
| 3 |
+
extracted-text fields returned by the backend.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
|
| 8 |
+
FILE = "/source/frontend/src/components/Chat/ChatInput.tsx"
|
| 9 |
+
|
| 10 |
+
ACCEPT = (
|
| 11 |
+
".csv,.json,.jsonl,.pdf,"
|
| 12 |
+
".png,.jpg,.jpeg,.webp,.bmp,.tiff,.gif,"
|
| 13 |
+
".xlsx,.xlsm,.xls,"
|
| 14 |
+
".docx,.doc,"
|
| 15 |
+
".pptx,"
|
| 16 |
+
".odt,.ods,.odp,"
|
| 17 |
+
".txt,.md,.rtf,.log"
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
EXTENSIONS_SET = (
|
| 21 |
+
"['csv', 'json', 'jsonl', 'pdf', "
|
| 22 |
+
"'png', 'jpg', 'jpeg', 'webp', 'bmp', 'tiff', 'tif', 'gif', "
|
| 23 |
+
"'xlsx', 'xlsm', 'xls', 'docx', 'doc', 'pptx', "
|
| 24 |
+
"'odt', 'ods', 'odp', 'txt', 'md', 'rtf', 'log']"
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def patch():
|
| 29 |
+
if not os.path.exists(FILE):
|
| 30 |
+
print(f"SKIP: {FILE} not found")
|
| 31 |
+
return
|
| 32 |
+
|
| 33 |
+
with open(FILE, "r", encoding="utf-8") as f:
|
| 34 |
+
content = f.read()
|
| 35 |
+
|
| 36 |
+
# 1. Widen the accept string
|
| 37 |
+
old_accept = "const DATASET_UPLOAD_ACCEPT = '.csv,.json,.jsonl';"
|
| 38 |
+
if old_accept in content:
|
| 39 |
+
content = content.replace(old_accept, f"const DATASET_UPLOAD_ACCEPT = `{ACCEPT}`;")
|
| 40 |
+
print("OK: widened DATASET_UPLOAD_ACCEPT")
|
| 41 |
+
|
| 42 |
+
# 2. Widen the allowed extension set
|
| 43 |
+
old_ext = "const DATASET_UPLOAD_EXTENSIONS = new Set(['csv', 'json', 'jsonl']);"
|
| 44 |
+
if old_ext in content:
|
| 45 |
+
content = content.replace(old_ext, f"const DATASET_UPLOAD_EXTENSIONS = new Set({EXTENSIONS_SET});")
|
| 46 |
+
print("OK: widened DATASET_UPLOAD_EXTENSIONS")
|
| 47 |
+
|
| 48 |
+
# 3. Update the error message
|
| 49 |
+
old_err = "'Only CSV, JSON, and JSONL dataset files are supported.'"
|
| 50 |
+
new_err = (
|
| 51 |
+
"'Không hỗ trợ định dạng này. Các định dạng được hỗ trợ: CSV, JSON, JSONL, "
|
| 52 |
+
"PDF, ảnh (PNG/JPG/JPEG/WebP/BMP/TIFF/GIF), Excel (XLSX/XLSM/XLS), "
|
| 53 |
+
"Word (DOCX/DOC), PowerPoint (PPTX), OpenDocument (ODT/ODS/ODP), "
|
| 54 |
+
"và văn bản (TXT/MD/RTF/LOG).'"
|
| 55 |
+
)
|
| 56 |
+
if old_err in content:
|
| 57 |
+
content = content.replace(old_err, new_err)
|
| 58 |
+
print("OK: updated unsupported-format error message")
|
| 59 |
+
|
| 60 |
+
# 4. Widen the DatasetUploadResponse interface format + add extracted fields
|
| 61 |
+
old_fmt = " format: 'csv' | 'json' | 'jsonl';"
|
| 62 |
+
if old_fmt in content:
|
| 63 |
+
content = content.replace(old_fmt, " format: string;")
|
| 64 |
+
print("OK: widened interface format type")
|
| 65 |
+
|
| 66 |
+
if "extracted_text?: string" not in content:
|
| 67 |
+
content = content.replace(
|
| 68 |
+
" load_dataset_snippet: string;\n",
|
| 69 |
+
" load_dataset_snippet: string;\n"
|
| 70 |
+
" extracted_text?: string;\n"
|
| 71 |
+
" extraction_warning?: string;\n",
|
| 72 |
+
)
|
| 73 |
+
print("OK: added extracted_text/extraction_warning to interface")
|
| 74 |
+
|
| 75 |
+
with open(FILE, "w", encoding="utf-8") as f:
|
| 76 |
+
f.write(content)
|
| 77 |
+
print("OK: ChatInput.tsx patched for multi-format uploads")
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
if __name__ == "__main__":
|
| 81 |
+
patch()
|
patch_lossless_context.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Patch: lossless long-context memory — fix context loss in long sessions/jobs.
|
| 3 |
+
|
| 4 |
+
Applied during Docker build. Idempotent. NEVER fails the build.
|
| 5 |
+
|
| 6 |
+
Root causes fixed:
|
| 7 |
+
1. compact() deleted the middle incl. ALL tool results (model continued a long
|
| 8 |
+
job from a terse paraphrase)
|
| 9 |
+
2. CompactionFailedError TERMINATED the session mid-job (_compact_and_notify)
|
| 10 |
+
3. restore_session_from_log reloaded history VERBATIM -> next compaction
|
| 11 |
+
nuked the tool trail or killed the session
|
| 12 |
+
4. max_iterations=300 silently stopped long jobs
|
| 13 |
+
|
| 14 |
+
Fixes:
|
| 15 |
+
A. compact(): snapshot removed middle (raw tool calls/results) into
|
| 16 |
+
cm.compaction_archive before rewriting items — the raw trail survives.
|
| 17 |
+
B. untouched_messages default 5 -> 24 (keep more of the recent working set).
|
| 18 |
+
C. cm.prune_to_fit(): drop oldest tool outputs (markers) until under
|
| 19 |
+
threshold. agent_loop _compact_and_notify calls it on CompactionFailedError
|
| 20 |
+
instead of terminating — degrade, never kill.
|
| 21 |
+
D. restore_session_from_log: normalize restored history to 70% headroom
|
| 22 |
+
(trim oldest tool outputs, keep system/first_user/tail).
|
| 23 |
+
E. config max_iterations default -1 (unlimited) + untouched_messages=24;
|
| 24 |
+
session.py reads config.untouched_messages.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
import ast
|
| 28 |
+
import os
|
| 29 |
+
|
| 30 |
+
MARKER = "# === PATCH: lossless long-context memory ==="
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _apply(path, patch_fn, label):
|
| 34 |
+
if not os.path.exists(path):
|
| 35 |
+
print(f"SKIP: {path} not found")
|
| 36 |
+
return False
|
| 37 |
+
try:
|
| 38 |
+
with open(path, "r") as f:
|
| 39 |
+
c = f.read()
|
| 40 |
+
if MARKER in c:
|
| 41 |
+
print(f"SKIP: {path} already patched")
|
| 42 |
+
return True
|
| 43 |
+
new_c, changed = patch_fn(c)
|
| 44 |
+
if not changed:
|
| 45 |
+
print(f"SKIP: {path} — no changes needed")
|
| 46 |
+
return False
|
| 47 |
+
try:
|
| 48 |
+
ast.parse(new_c)
|
| 49 |
+
except SyntaxError as e:
|
| 50 |
+
print(f"FAIL: {path} syntax: {e}")
|
| 51 |
+
return False
|
| 52 |
+
with open(path, "w") as f:
|
| 53 |
+
f.write(new_c)
|
| 54 |
+
print(f"OK: {path} {label}")
|
| 55 |
+
return True
|
| 56 |
+
except Exception as e:
|
| 57 |
+
print(f"WARN: {path} patch error: {e}")
|
| 58 |
+
return False
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# ── 1. manager.py — lossless compaction + prune_to_fit ──────────────────────
|
| 62 |
+
MANAGER = "/app/agent/context_manager/manager.py"
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _patch_manager(c):
|
| 66 |
+
changed = False
|
| 67 |
+
|
| 68 |
+
if "untouched_messages: int = 5" in c:
|
| 69 |
+
c = c.replace("untouched_messages: int = 5", "untouched_messages: int = 24")
|
| 70 |
+
print(" + untouched_messages default 5 -> 24")
|
| 71 |
+
changed = True
|
| 72 |
+
|
| 73 |
+
archive_code = (
|
| 74 |
+
" # === PATCH: lossless long-context memory ===\n"
|
| 75 |
+
" # Never destroy the working state: keep the full removed middle\n"
|
| 76 |
+
" # (tool calls + results + user prompts) in memory on the summary,\n"
|
| 77 |
+
" # so a follow-up question or a continued job can be answered from\n"
|
| 78 |
+
" # the raw trail, not from a paraphrase.\n"
|
| 79 |
+
" try:\n"
|
| 80 |
+
" snapshot = [\n"
|
| 81 |
+
" m.model_dump() if hasattr(m, \"model_dump\") else dict(m)\n"
|
| 82 |
+
" for m in messages_to_summarize\n"
|
| 83 |
+
" ]\n"
|
| 84 |
+
" if not hasattr(self, \"compaction_archive\"):\n"
|
| 85 |
+
" self.compaction_archive = []\n"
|
| 86 |
+
" self.compaction_archive.extend(snapshot)\n"
|
| 87 |
+
" # Cap the archive so we don't grow unbounded: keep last 400 msgs.\n"
|
| 88 |
+
" if len(self.compaction_archive) > 400:\n"
|
| 89 |
+
" self.compaction_archive = self.compaction_archive[-400:]\n"
|
| 90 |
+
" except Exception:\n"
|
| 91 |
+
" self.compaction_archive = getattr(self, \"compaction_archive\", [])\n"
|
| 92 |
+
)
|
| 93 |
+
anchor = " self.items = head + [summarized_message] + recent_messages"
|
| 94 |
+
if anchor in c and "compaction_archive" not in c:
|
| 95 |
+
c = c.replace(anchor, archive_code + anchor, 1)
|
| 96 |
+
print(" + compact() snapshots removed middle to compaction_archive")
|
| 97 |
+
changed = True
|
| 98 |
+
|
| 99 |
+
if "def prune_to_fit" not in c:
|
| 100 |
+
prune = '''
|
| 101 |
+
|
| 102 |
+
def prune_to_fit(
|
| 103 |
+
self, model_name: str, *, max_messages: int = 200
|
| 104 |
+
) -> bool:
|
| 105 |
+
"""Drop oldest non-first-user tool outputs until under threshold.
|
| 106 |
+
|
| 107 |
+
Degrade, don't kill. Returns True if context is now under threshold.
|
| 108 |
+
Preserves: system prompt, first user message, assistant reasoning,
|
| 109 |
+
and enough of the tail to continue. Drops the OLDEST tool outputs
|
| 110 |
+
(replaced with a short marker) so a long job can keep running.
|
| 111 |
+
"""
|
| 112 |
+
from litellm import token_counter
|
| 113 |
+
|
| 114 |
+
def _usage(messages):
|
| 115 |
+
try:
|
| 116 |
+
return token_counter(
|
| 117 |
+
model=model_name,
|
| 118 |
+
messages=[m.model_dump() for m in messages],
|
| 119 |
+
)
|
| 120 |
+
except Exception:
|
| 121 |
+
return (
|
| 122 |
+
sum(len(getattr(m, "content", "") or "") for m in messages) // 4
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
items = list(self.items)
|
| 126 |
+
# Find first user message index (skip system at 0).
|
| 127 |
+
first_user_idx = 1
|
| 128 |
+
for i in range(1, len(items)):
|
| 129 |
+
if getattr(items[i], "role", None) == "user":
|
| 130 |
+
first_user_idx = i
|
| 131 |
+
break
|
| 132 |
+
# Trim tool messages from oldest to newest (skip system/first-user).
|
| 133 |
+
trimmed = 0
|
| 134 |
+
for i in range(first_user_idx + 1, len(items)):
|
| 135 |
+
if _usage(items) <= self.compaction_threshold:
|
| 136 |
+
break
|
| 137 |
+
msg = items[i]
|
| 138 |
+
if getattr(msg, "role", None) != "tool":
|
| 139 |
+
continue
|
| 140 |
+
old = items[i]
|
| 141 |
+
items[i] = Message(
|
| 142 |
+
role="tool",
|
| 143 |
+
content=(
|
| 144 |
+
"[tool output pruned to keep the session running — "
|
| 145 |
+
f"original was {len(getattr(old, 'content', '') or '')} chars; "
|
| 146 |
+
"stored in compaction_archive]"
|
| 147 |
+
),
|
| 148 |
+
tool_call_id=getattr(old, "tool_call_id", None),
|
| 149 |
+
name=getattr(old, "name", None),
|
| 150 |
+
)
|
| 151 |
+
trimmed += 1
|
| 152 |
+
self.items = items
|
| 153 |
+
self._recompute_usage(model_name)
|
| 154 |
+
return self.running_context_usage <= self.compaction_threshold
|
| 155 |
+
'''
|
| 156 |
+
c += prune
|
| 157 |
+
print(" + Added prune_to_fit() (degrade instead of kill)")
|
| 158 |
+
changed = True
|
| 159 |
+
|
| 160 |
+
return c, changed
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
_apply(MANAGER, _patch_manager, "lossless compaction patched")
|
| 164 |
+
|
| 165 |
+
# ── 2. agent_loop.py — never kill on compaction failure ─────────────────────
|
| 166 |
+
AGENT_LOOP = "/app/agent/core/agent_loop.py"
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def _patch_agent_loop(c):
|
| 170 |
+
changed = False
|
| 171 |
+
|
| 172 |
+
old_fail = (
|
| 173 |
+
" await session.send_event(\n"
|
| 174 |
+
" Event(\n"
|
| 175 |
+
" event_type=\"session_terminated\",\n"
|
| 176 |
+
" data={\n"
|
| 177 |
+
" \"reason\": \"compaction_failed\",\n"
|
| 178 |
+
" \"context_usage\": cm.running_context_usage,\n"
|
| 179 |
+
" \"context_threshold\": cm.compaction_threshold,\n"
|
| 180 |
+
" \"error\": str(e)[:300],\n"
|
| 181 |
+
" \"user_message\": (\n"
|
| 182 |
+
" \"Your conversation has grown too large to continue. \"\n"
|
| 183 |
+
" \"The work you've done is saved — start a new session to keep going.\"\n"
|
| 184 |
+
" ),\n"
|
| 185 |
+
" },\n"
|
| 186 |
+
" )\n"
|
| 187 |
+
" )\n"
|
| 188 |
+
" # Stop the agent loop; the finally in _run_session will fire\n"
|
| 189 |
+
" # cleanup_sandbox + save_trajectory so the dataset captures\n"
|
| 190 |
+
" # everything that did happen.\n"
|
| 191 |
+
" session.is_running = False\n"
|
| 192 |
+
" return"
|
| 193 |
+
)
|
| 194 |
+
new_fail = (
|
| 195 |
+
" # === PATCH: lossless long-context memory ===\n"
|
| 196 |
+
" # Never kill the session on compaction failure — degrade instead:\n"
|
| 197 |
+
" # prune the oldest tool outputs to fit, then keep working.\n"
|
| 198 |
+
" try:\n"
|
| 199 |
+
" ok = cm.prune_to_fit(session.config.model_name)\n"
|
| 200 |
+
" if ok:\n"
|
| 201 |
+
" await session.send_event(\n"
|
| 202 |
+
" Event(\n"
|
| 203 |
+
" event_type=\"compacted\",\n"
|
| 204 |
+
" data={\n"
|
| 205 |
+
" \"reason\": \"pruned_to_fit\",\n"
|
| 206 |
+
" \"context_usage\": cm.running_context_usage,\n"
|
| 207 |
+
" \"context_threshold\": cm.compaction_threshold,\n"
|
| 208 |
+
" \"user_message\": (\n"
|
| 209 |
+
" \"Context was getting full, so the agent \"\n"
|
| 210 |
+
" \"trimmed some old tool outputs to keep working. \"\n"
|
| 211 |
+
" \"The full raw history stays in the session archive.\"\n"
|
| 212 |
+
" ),\n"
|
| 213 |
+
" },\n"
|
| 214 |
+
" )\n"
|
| 215 |
+
" )\n"
|
| 216 |
+
" return\n"
|
| 217 |
+
" except Exception as _prune_exc:\n"
|
| 218 |
+
" logger.error(\"prune_to_fit failed: %s\", _prune_exc)\n"
|
| 219 |
+
" await session.send_event(\n"
|
| 220 |
+
" Event(\n"
|
| 221 |
+
" event_type=\"session_terminated\",\n"
|
| 222 |
+
" data={\n"
|
| 223 |
+
" \"reason\": \"compaction_failed\",\n"
|
| 224 |
+
" \"context_usage\": cm.running_context_usage,\n"
|
| 225 |
+
" \"context_threshold\": cm.compaction_threshold,\n"
|
| 226 |
+
" \"error\": str(e)[:300],\n"
|
| 227 |
+
" \"user_message\": (\n"
|
| 228 |
+
" \"The conversation context is full and could not be \"\n"
|
| 229 |
+
" \"reduced further. The work done so far is saved — \"\n"
|
| 230 |
+
" \"start a new session to keep going.\"\n"
|
| 231 |
+
" ),\n"
|
| 232 |
+
" },\n"
|
| 233 |
+
" )\n"
|
| 234 |
+
" )\n"
|
| 235 |
+
" # Stop the agent loop; the finally in _run_session will fire\n"
|
| 236 |
+
" # cleanup_sandbox + save_trajectory so the dataset captures\n"
|
| 237 |
+
" # everything that did happen.\n"
|
| 238 |
+
" session.is_running = False\n"
|
| 239 |
+
" return"
|
| 240 |
+
)
|
| 241 |
+
if old_fail in c and "prune_to_fit" not in c:
|
| 242 |
+
c = c.replace(old_fail, new_fail, 1)
|
| 243 |
+
print(" + _compact_and_notify degrades via prune_to_fit instead of kill")
|
| 244 |
+
changed = True
|
| 245 |
+
|
| 246 |
+
return c, changed
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
_apply(AGENT_LOOP, _patch_agent_loop, "agent_loop degrades instead of killing")
|
| 250 |
+
|
| 251 |
+
# ── 3. session.py — read config.untouched_messages ──────────────────────────
|
| 252 |
+
SESSION = "/app/agent/core/session.py"
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def _patch_session(c):
|
| 256 |
+
changed = False
|
| 257 |
+
old = " compact_size=0.1,\n untouched_messages=5,"
|
| 258 |
+
new = (
|
| 259 |
+
" compact_size=0.1,\n"
|
| 260 |
+
" untouched_messages=getattr(config, 'untouched_messages', 24),"
|
| 261 |
+
)
|
| 262 |
+
if old in c:
|
| 263 |
+
c = c.replace(old, new, 1)
|
| 264 |
+
print(" + session.py reads config.untouched_messages (default 24)")
|
| 265 |
+
changed = True
|
| 266 |
+
return c, changed
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
_apply(SESSION, _patch_session, "session.py untouched_messages override")
|
| 270 |
+
|
| 271 |
+
# ── 4. config.py — max_iterations unlimited + untouched_messages=24 ─────────
|
| 272 |
+
CONFIG = "/app/agent/config.py"
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def _patch_config(c):
|
| 276 |
+
changed = False
|
| 277 |
+
old = " max_iterations: int = 300 # Max LLM calls per agent turn (-1 = unlimited)"
|
| 278 |
+
new = (
|
| 279 |
+
" max_iterations: int = -1 # Max LLM calls per agent turn (-1 = unlimited)\n"
|
| 280 |
+
" untouched_messages: int = 24 # PATCH: keep more recent tool trail before compaction"
|
| 281 |
+
)
|
| 282 |
+
if old in c:
|
| 283 |
+
c = c.replace(old, new, 1)
|
| 284 |
+
print(" + config.py max_iterations default -1 (unlimited), untouched_messages=24")
|
| 285 |
+
changed = True
|
| 286 |
+
return c, changed
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
_apply(CONFIG, _patch_config, "config.py max_iterations unlimited")
|
| 290 |
+
|
| 291 |
+
# ── 5. session_resume.py — normalize restored history to headroom ───────────
|
| 292 |
+
RESUME = "/app/agent/core/session_resume.py"
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
def _patch_resume(c):
|
| 296 |
+
changed = False
|
| 297 |
+
anchor = " cm = session.context_manager\n"
|
| 298 |
+
insert = (
|
| 299 |
+
" # === PATCH: lossless long-context memory ===\n"
|
| 300 |
+
" # Normalize restored history to fit model headroom instead of restoring\n"
|
| 301 |
+
" # everything verbatim and letting the first compaction nuke the tool\n"
|
| 302 |
+
" # trail or kill the session. Keep system + first user + tail verbatim;\n"
|
| 303 |
+
" # replace oversize tool outputs in the middle with markers (full payloads\n"
|
| 304 |
+
" # still exist on-disk in the original log).\n"
|
| 305 |
+
" try:\n"
|
| 306 |
+
" from litellm import token_counter as _tc\n"
|
| 307 |
+
" _budget = int(session.context_manager.model_max_tokens * 0.70)\n"
|
| 308 |
+
" try:\n"
|
| 309 |
+
" _use = _tc(\n"
|
| 310 |
+
" model=session.config.model_name,\n"
|
| 311 |
+
" messages=[m.model_dump() for m in restored_messages],\n"
|
| 312 |
+
" )\n"
|
| 313 |
+
" except Exception:\n"
|
| 314 |
+
" _use = sum(len(getattr(m, \"content\", \"\") or \"\") for m in restored_messages) // 4\n"
|
| 315 |
+
" if _use > _budget:\n"
|
| 316 |
+
" # Trim the oldest tool outputs (keep system + first user + tail).\n"
|
| 317 |
+
" _first_user_idx = 0\n"
|
| 318 |
+
" for _i, _m in enumerate(restored_messages):\n"
|
| 319 |
+
" if getattr(_m, \"role\", None) == \"user\":\n"
|
| 320 |
+
" _first_user_idx = _i\n"
|
| 321 |
+
" break\n"
|
| 322 |
+
" _trimmed = 0\n"
|
| 323 |
+
" for _i in range(_first_user_idx + 1, len(restored_messages)):\n"
|
| 324 |
+
" if _use <= _budget:\n"
|
| 325 |
+
" break\n"
|
| 326 |
+
" _m = restored_messages[_i]\n"
|
| 327 |
+
" if getattr(_m, \"role\", None) != \"tool\":\n"
|
| 328 |
+
" continue\n"
|
| 329 |
+
" _sz = len(getattr(_m, \"content\", \"\") or \"\")\n"
|
| 330 |
+
" restored_messages[_i] = type(_m)(\n"
|
| 331 |
+
" role=\"tool\",\n"
|
| 332 |
+
" content=(\n"
|
| 333 |
+
" \"[tool output trimmed on restore to fit context — \"\n"
|
| 334 |
+
" f\"original {_sz} chars retained in the on-disk log]\"\n"
|
| 335 |
+
" ),\n"
|
| 336 |
+
" tool_call_id=getattr(_m, \"tool_call_id\", None),\n"
|
| 337 |
+
" name=getattr(_m, \"name\", None),\n"
|
| 338 |
+
" )\n"
|
| 339 |
+
" _trimmed += 1\n"
|
| 340 |
+
" try:\n"
|
| 341 |
+
" _use = _tc(\n"
|
| 342 |
+
" model=session.config.model_name,\n"
|
| 343 |
+
" messages=[m.model_dump() for m in restored_messages],\n"
|
| 344 |
+
" )\n"
|
| 345 |
+
" except Exception:\n"
|
| 346 |
+
" _use = sum(len(getattr(m, \"content\", \"\") or \"\") for m in restored_messages) // 4\n"
|
| 347 |
+
" logger.warning(\n"
|
| 348 |
+
" \"Restored session %s was over budget (%d/%d); trimmed %d tool outputs\",\n"
|
| 349 |
+
" session.session_id if hasattr(session, \"session_id\") else \"?\",\n"
|
| 350 |
+
" _use, _budget, _trimmed,\n"
|
| 351 |
+
" )\n"
|
| 352 |
+
" except Exception as _exc:\n"
|
| 353 |
+
" logger.warning(\"Restore normalization skipped: %s\", _exc)\n"
|
| 354 |
+
"\n"
|
| 355 |
+
)
|
| 356 |
+
if anchor in c and "Restore normalization skipped" not in c:
|
| 357 |
+
c = c.replace(anchor, anchor + insert, 1)
|
| 358 |
+
print(" + restore_session_from_log normalizes history to 70% headroom")
|
| 359 |
+
changed = True
|
| 360 |
+
return c, changed
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
_apply(RESUME, _patch_resume, "session_resume normalized restore")
|
| 364 |
+
|
| 365 |
+
print("\n=== All long-context patches applied (build will not fail regardless) ===")
|
patch_models.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Patch backend: OpenRouter routing + DeepSeek V4 Flash 0731 + FIX DeepSeek routing.
|
| 2 |
+
|
| 3 |
+
FIX: GLM_52_MODEL_ID was set to "openai/deepseek/deepseek-v4-flash" which routes
|
| 4 |
+
to OpenRouter via the openai/ prefix catch-all. However, OpenRouter's model ID
|
| 5 |
+
is "deepseek/deepseek-v4-flash" (without the openai/ prefix). When LiteLLM
|
| 6 |
+
sends the full "openai/deepseek/deepseek-v4-flash" as the model name to
|
| 7 |
+
OpenRouter's API, it gets a 404 because OpenRouter doesn't recognize the
|
| 8 |
+
"openai/" prefix.
|
| 9 |
+
|
| 10 |
+
Solution:
|
| 11 |
+
1. GLM_52_MODEL_ID -> "deepseek-ai/DeepSeek-V4-Flash:deepinfra" (HF Router path, model exists there, cheapest provider)
|
| 12 |
+
2. OpenRouter routing strips the "openai/" prefix before sending to OpenRouter
|
| 13 |
+
3. Add :novita tag to DeepSeek V4 Pro for deterministic routing via HF Router
|
| 14 |
+
4. Add stealth/space-bunny-alpha using the same OpenRouter-style prefix.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import ast
|
| 18 |
+
import os
|
| 19 |
+
import re
|
| 20 |
+
|
| 21 |
+
AGENT_FILE = "/app/backend/routes/agent.py"
|
| 22 |
+
LLM_PARAMS_FILE = "/app/agent/core/llm_params.py"
|
| 23 |
+
IDS_FILE = "/app/agent/core/model_ids.py"
|
| 24 |
+
|
| 25 |
+
with open(IDS_FILE) as f:
|
| 26 |
+
content = f.read()
|
| 27 |
+
|
| 28 |
+
# Remove NO_TOOLS_MODELS if present from previous patches
|
| 29 |
+
if "NO_TOOLS_MODELS" in content:
|
| 30 |
+
content = re.sub(r'NO_TOOLS_MODELS\s*=\s*\{[^}]*\}\n?', '', content)
|
| 31 |
+
print("OK: Removed old NO_TOOLS_MODELS")
|
| 32 |
+
|
| 33 |
+
# FIX: Set GLM_52_MODEL_ID to HF Router path with provider tag.
|
| 34 |
+
# "deepseek-ai/DeepSeek-V4-Flash:deepinfra" IS available on HF Router
|
| 35 |
+
# (5 providers for -0731 variant, 3 for standard). All support tools, 1M context.
|
| 36 |
+
# Using :deepinfra for better pricing ($0.09/0.18 per 1M tokens)
|
| 37 |
+
# KIMI_K27_CODE_MODEL_ID -> DeepSeek V4 Pro via HF Router with :novita tag
|
| 38 |
+
replacements = [
|
| 39 |
+
('KIMI_K27_CODE_MODEL_ID = "moonshotai/Kimi-K2.7-Code:novita"',
|
| 40 |
+
'KIMI_K27_CODE_MODEL_ID = "deepseek-ai/DeepSeek-V4-Pro:novita"'),
|
| 41 |
+
('MINIMAX_M3_MODEL_ID = "MiniMaxAI/MiniMax-M3:novita"',
|
| 42 |
+
'MINIMAX_M3_MODEL_ID = "deepseek-ai/DeepSeek-V4-Flash:deepinfra"'),
|
| 43 |
+
# FIX: Was "openai/deepseek/deepseek-v4-flash" which routes to OpenRouter but
|
| 44 |
+
# OpenRouter model IDs don't include the "openai/" prefix, causing 404.
|
| 45 |
+
# Change to HF Router path with :deepinfra provider tag for cheaper pricing.
|
| 46 |
+
('GLM_52_MODEL_ID = "zai-org/GLM-5.2:novita"',
|
| 47 |
+
'GLM_52_MODEL_ID = "deepseek-ai/DeepSeek-V4-Flash:deepinfra"'),
|
| 48 |
+
('DEEPSEEK_V4_PRO_MODEL_ID = "deepseek-ai/DeepSeek-V4-Pro:novita"',
|
| 49 |
+
'DEEPSEEK_V4_PRO_MODEL_ID = "nvidia/nemotron-3-super-120b-a12b:free"'),
|
| 50 |
+
]
|
| 51 |
+
for old, new in replacements:
|
| 52 |
+
content = content.replace(old, new)
|
| 53 |
+
print("OK: Model ID replacements applied")
|
| 54 |
+
|
| 55 |
+
# Add missing constants BEFORE the specific line DEEPSEEK_V4_PRO_MODEL_ID = "..."
|
| 56 |
+
if "TENCENT_HY3_FREE_MODEL_ID" not in content:
|
| 57 |
+
target_line = 'DEEPSEEK_V4_PRO_MODEL_ID = "nvidia/nemotron-3-super-120b-a12b:free"'
|
| 58 |
+
insert_block = '''TENCENT_HY3_FREE_MODEL_ID = "openai/tencent/hy3:free"
|
| 59 |
+
GEMMA_4_31B_FREE_MODEL_ID = "openai/google/gemma-4-31b-it:free"
|
| 60 |
+
LLAMA_3_3_70B_FREE_MODEL_ID = "openai/meta-llama/llama-3.3-70b-instruct:free"
|
| 61 |
+
LLAMA_3_1_8B_MODEL_ID = "openai/meta-llama/llama-3.1-8b-instruct"
|
| 62 |
+
LAGUNA_M1_FREE_MODEL_ID = "openai/poolside/laguna-m.1:free"
|
| 63 |
+
LAGUNA_S21_FREE_MODEL_ID = "openai/poolside/laguna-s-2.1:free"
|
| 64 |
+
NEX_N2_MINI_MODEL_ID = "openai/nex-agi/nex-n2-mini"
|
| 65 |
+
LING_3_0_FLASH_FREE_MODEL_ID = "openai/inclusionai/ling-3.0-flash:free"
|
| 66 |
+
'''
|
| 67 |
+
# Only replace the first occurrence (the variable definition)
|
| 68 |
+
if target_line in content:
|
| 69 |
+
content = content.replace(target_line, insert_block + target_line)
|
| 70 |
+
print("OK: Added missing constants")
|
| 71 |
+
else:
|
| 72 |
+
print("WARN: Could not find target line")
|
| 73 |
+
|
| 74 |
+
# Add Space Bunny independently so this remains idempotent when the base
|
| 75 |
+
# model_ids.py already contains the earlier OpenRouter constants.
|
| 76 |
+
if "SPACE_BUNNY_ALPHA_MODEL_ID" not in content:
|
| 77 |
+
anchor = 'LING_3_0_FLASH_FREE_MODEL_ID = "openai/inclusionai/ling-3.0-flash:free"'
|
| 78 |
+
if anchor in content:
|
| 79 |
+
content = content.replace(
|
| 80 |
+
anchor,
|
| 81 |
+
anchor + '\nSPACE_BUNNY_ALPHA_MODEL_ID = "openai/stealth/space-bunny-alpha"\n',
|
| 82 |
+
1,
|
| 83 |
+
)
|
| 84 |
+
print("OK: Added SPACE_BUNNY_ALPHA_MODEL_ID")
|
| 85 |
+
else:
|
| 86 |
+
print("WARN: Could not find LING_3_0_FLASH_FREE_MODEL_ID anchor")
|
| 87 |
+
|
| 88 |
+
# Replace all references to ~deepseek deepseek models with 0731 version (quoted strings only)
|
| 89 |
+
content = content.replace('"~deepseek/deepseek-v4-flash-latest"', '"deepseek/deepseek-v4-flash-0731"')
|
| 90 |
+
content = content.replace('"~deepseek/deepseek-v4-flash-0731"', '"deepseek/deepseek-v4-flash-0731"')
|
| 91 |
+
|
| 92 |
+
with open(IDS_FILE, "w") as f:
|
| 93 |
+
f.write(content)
|
| 94 |
+
print("OK: model_ids.py written")
|
| 95 |
+
|
| 96 |
+
ast.parse(content) # validate syntax
|
| 97 |
+
print("OK: model_ids.py syntax OK")
|
| 98 |
+
|
| 99 |
+
with open(AGENT_FILE) as f:
|
| 100 |
+
content = f.read()
|
| 101 |
+
|
| 102 |
+
content = content.replace(
|
| 103 |
+
"DEFAULT_MODEL_ID = GLM_52_MODEL_ID",
|
| 104 |
+
'DEFAULT_MODEL_ID = "openai/tencent/hy3:free"'
|
| 105 |
+
)
|
| 106 |
+
content = content.replace(
|
| 107 |
+
"DEFAULT_GPT_MODEL_ID = GPT_55_MODEL_ID",
|
| 108 |
+
"DEFAULT_GPT_MODEL_ID = LAGUNA_S21_FREE_MODEL_ID"
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
# Update imports
|
| 112 |
+
old_import = (
|
| 113 |
+
"from agent.core.model_ids import (\n"
|
| 114 |
+
" CLAUDE_OPUS_48_MODEL_ID,\n"
|
| 115 |
+
" DEEPSEEK_V4_PRO_MODEL_ID,\n"
|
| 116 |
+
" GLM_52_MODEL_ID,\n"
|
| 117 |
+
" GPT_55_MODEL_ID,\n"
|
| 118 |
+
" KIMI_K27_CODE_MODEL_ID,\n"
|
| 119 |
+
" MINIMAX_M3_MODEL_ID,\n"
|
| 120 |
+
" strip_huggingface_model_prefix,\n"
|
| 121 |
+
")"
|
| 122 |
+
)
|
| 123 |
+
new_import = (
|
| 124 |
+
"from agent.core.model_ids import (\n"
|
| 125 |
+
" DEEPSEEK_V4_PRO_MODEL_ID,\n"
|
| 126 |
+
" GLM_52_MODEL_ID,\n"
|
| 127 |
+
" GPT_55_MODEL_ID,\n"
|
| 128 |
+
" KIMI_K27_CODE_MODEL_ID,\n"
|
| 129 |
+
" MINIMAX_M3_MODEL_ID,\n"
|
| 130 |
+
" TENCENT_HY3_FREE_MODEL_ID,\n"
|
| 131 |
+
" GEMMA_4_31B_FREE_MODEL_ID,\n"
|
| 132 |
+
" LLAMA_3_3_70B_FREE_MODEL_ID,\n"
|
| 133 |
+
" LLAMA_3_1_8B_MODEL_ID,\n"
|
| 134 |
+
" LAGUNA_M1_FREE_MODEL_ID,\n"
|
| 135 |
+
" LAGUNA_S21_FREE_MODEL_ID,\n"
|
| 136 |
+
" NEX_N2_MINI_MODEL_ID,\n"
|
| 137 |
+
" LING_3_0_FLASH_FREE_MODEL_ID,\n"
|
| 138 |
+
" SPACE_BUNNY_ALPHA_MODEL_ID,\n"
|
| 139 |
+
" strip_huggingface_model_prefix,\n"
|
| 140 |
+
")"
|
| 141 |
+
)
|
| 142 |
+
content = content.replace(old_import, new_import)
|
| 143 |
+
|
| 144 |
+
# Update _available_models to include DeepSeek V4 Flash 0731
|
| 145 |
+
new_func = '''def _available_models() -> list[dict[str, Any]]:
|
| 146 |
+
models = [
|
| 147 |
+
{"id": TENCENT_HY3_FREE_MODEL_ID, "label": "Tencent HY3:free", "recommended": True},
|
| 148 |
+
{"id": GEMMA_4_31B_FREE_MODEL_ID, "label": "Gemma 4 31B:free", "recommended": True},
|
| 149 |
+
{"id": LLAMA_3_3_70B_FREE_MODEL_ID, "label": "Llama 3.3 70B:free", "recommended": True},
|
| 150 |
+
{"id": LAGUNA_M1_FREE_MODEL_ID, "label": "Laguna M.1:free", "recommended": True},
|
| 151 |
+
{"id": DEFAULT_GPT_MODEL_ID, "label": "Laguna S 2.1:free", "recommended": True},
|
| 152 |
+
{"id": SPACE_BUNNY_ALPHA_MODEL_ID, "label": "stealth/space-bunny-alpha", "recommended": True},
|
| 153 |
+
{"id": NEX_N2_MINI_MODEL_ID, "label": "nex-agi/nex-n2-mini", "recommended": True},
|
| 154 |
+
{"id": LING_3_0_FLASH_FREE_MODEL_ID, "label": "inclusionai/ling-3.0-flash:free", "recommended": True},
|
| 155 |
+
{"id": LLAMA_3_1_8B_MODEL_ID, "label": "Llama 3.1 8B"},
|
| 156 |
+
{"id": KIMI_K27_CODE_MODEL_ID, "label": "DeepSeek V4 Pro"},
|
| 157 |
+
{"id": MINIMAX_M3_MODEL_ID, "label": "DeepSeek V4 Flash"},
|
| 158 |
+
{"id": DEFAULT_MODEL_ID, "label": "DeepSeek V4 Flash"},
|
| 159 |
+
{"id": DEEPSEEK_V4_PRO_MODEL_ID, "label": "Nemotron 3 Super 120B"},
|
| 160 |
+
{"id": "deepseek/deepseek-v4-flash-0731", "label": "DeepSeek V4 Flash 0731", "recommended": True},
|
| 161 |
+
]
|
| 162 |
+
return models'''
|
| 163 |
+
|
| 164 |
+
func_match = re.search(
|
| 165 |
+
r"def _available_models\(\)\s*->\s*list\[dict\[str,\s*Any\]\]:.*?return models",
|
| 166 |
+
content, re.DOTALL
|
| 167 |
+
)
|
| 168 |
+
if func_match:
|
| 169 |
+
content = content[:func_match.start()] + new_func + content[func_match.end():]
|
| 170 |
+
print("OK: Replaced _available_models() with DeepSeek V4 Flash 0731")
|
| 171 |
+
else:
|
| 172 |
+
old_func = (
|
| 173 |
+
"def _available_models() -> list[dict[str, Any]]:\n"
|
| 174 |
+
" models = [\n"
|
| 175 |
+
' {"id": CLAUDE_OPUS_48_MODEL_ID, "label": "Claude Opus 4.8"},\n'
|
| 176 |
+
' {"id": DEFAULT_GPT_MODEL_ID, "label": "GPT-5.5"},\n'
|
| 177 |
+
' {"id": KIMI_K27_CODE_MODEL_ID, "label": "Kimi K2.7 Code"},\n'
|
| 178 |
+
' {"id": MINIMAX_M3_MODEL_ID, "label": "MiniMax M3"},\n'
|
| 179 |
+
' {"id": DEFAULT_MODEL_ID, "label": "GLM 5.2", "recommended": True},\n'
|
| 180 |
+
' {"id": DEEPSEEK_V4_PRO_MODEL_ID, "label": "DeepSeek V4 Pro"},\n'
|
| 181 |
+
" ]\n"
|
| 182 |
+
" return models"
|
| 183 |
+
)
|
| 184 |
+
if old_func in content:
|
| 185 |
+
content = content.replace(old_func, new_func)
|
| 186 |
+
print("OK: String replace of _available_models()")
|
| 187 |
+
else:
|
| 188 |
+
print("WARN: Could not find _available_models() pattern, searching...")
|
| 189 |
+
idx = content.find("def _available_models")
|
| 190 |
+
if idx >= 0:
|
| 191 |
+
print(f"Found at index {idx}, context: {content[idx:idx+400]}")
|
| 192 |
+
|
| 193 |
+
content = content.replace(
|
| 194 |
+
'"openai/gpt-oss-120b:cerebras",',
|
| 195 |
+
'"huggingface/deepseek-ai/DeepSeek-V4-Pro",'
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
try:
|
| 199 |
+
ast.parse(content)
|
| 200 |
+
print("OK: agent.py syntax OK")
|
| 201 |
+
except SyntaxError as e:
|
| 202 |
+
print(f"FAIL: agent.py syntax error: {e}")
|
| 203 |
+
raise
|
| 204 |
+
|
| 205 |
+
with open(AGENT_FILE, "w") as f:
|
| 206 |
+
f.write(content)
|
| 207 |
+
|
| 208 |
+
# Patch llm_params for OpenRouter routing
|
| 209 |
+
# FIX: Strip the "openai/" prefix before sending model name to OpenRouter
|
| 210 |
+
# OpenRouter model IDs do not include the "openai/" prefix.
|
| 211 |
+
# e.g. "openai/tencent/hy3:free" -> "tencent/hy3:free"
|
| 212 |
+
with open(LLM_PARAMS_FILE) as f:
|
| 213 |
+
llm_content = f.read()
|
| 214 |
+
|
| 215 |
+
if "normalized_model.startswith" not in llm_content:
|
| 216 |
+
api_key_find = "api_key = _resolve_hf_router_token(session_hf_token)"
|
| 217 |
+
if api_key_find in llm_content:
|
| 218 |
+
line_end = llm_content.find("\n", llm_content.find(api_key_find) + len(api_key_find)) + 1
|
| 219 |
+
routing_insert = (
|
| 220 |
+
' # Route deepseek/ models to OpenRouter\n'
|
| 221 |
+
' if normalized_model.startswith("deepseek/"):\n'
|
| 222 |
+
' return {\n'
|
| 223 |
+
' "model": normalized_model,\n'
|
| 224 |
+
' "api_base": "https://openrouter.ai/api/v1",\n'
|
| 225 |
+
' "api_key": os.environ.get("OPENROUTER_API_KEY") or api_key or "",\n'
|
| 226 |
+
' }\n\n'
|
| 227 |
+
' # Route openai/-prefixed models to OpenRouter\n'
|
| 228 |
+
' # FIX: Strip the "openai/" prefix - OpenRouter model IDs do not include it.\n'
|
| 229 |
+
' # e.g. "openai/deepseek/deepseek-v4-flash" -> "deepseek/deepseek-v4-flash"\n'
|
| 230 |
+
' # e.g. "openai/tencent/hy3:free" -> "tencent/hy3:free"\n'
|
| 231 |
+
' if normalized_model.startswith("openai/"):\n'
|
| 232 |
+
' or_model = normalized_model[len("openai/"):]\n'
|
| 233 |
+
' return {\n'
|
| 234 |
+
' "model": or_model,\n'
|
| 235 |
+
' "api_base": "https://openrouter.ai/api/v1",\n'
|
| 236 |
+
' "api_key": os.environ.get("OPENROUTER_API_KEY") or api_key or "",\n'
|
| 237 |
+
' }\n\n'
|
| 238 |
+
)
|
| 239 |
+
llm_content = llm_content[:line_end] + routing_insert + llm_content[line_end:]
|
| 240 |
+
print("OK: Patched _resolve_llm_params with deepseek/ + fixed openai/ routing")
|
| 241 |
+
else:
|
| 242 |
+
print("WARN: Could not find api_key line")
|
| 243 |
+
else:
|
| 244 |
+
print("OK: llm_params.py already patched")
|
| 245 |
+
|
| 246 |
+
try:
|
| 247 |
+
ast.parse(llm_content)
|
| 248 |
+
print("OK: llm_params.py syntax OK")
|
| 249 |
+
except SyntaxError as e:
|
| 250 |
+
print(f"FAIL: llm_params.py syntax error: {e}")
|
| 251 |
+
raise
|
| 252 |
+
|
| 253 |
+
with open(LLM_PARAMS_FILE, "w") as f:
|
| 254 |
+
f.write(llm_content)
|
| 255 |
+
|
| 256 |
+
print("OK: ALL BACKEND PATCHES APPLIED (including DeepSeek V4 Flash routing fix)")
|
patch_multi_upload.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Patch: allow multiple file uploads in one request.
|
| 2 |
+
|
| 3 |
+
Backend changes:
|
| 4 |
+
1. Replace the single-file helper with a multi-file helper.
|
| 5 |
+
2. Rewrite the upload route to accept up to 20 files and return a list of
|
| 6 |
+
DatasetUploadResponse, uploading each file to the session's Hub dataset
|
| 7 |
+
repo and injecting a context note for each.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import ast
|
| 11 |
+
import re
|
| 12 |
+
import os
|
| 13 |
+
|
| 14 |
+
AGENT_FILE = "/app/backend/routes/agent.py"
|
| 15 |
+
|
| 16 |
+
with open(AGENT_FILE) as f:
|
| 17 |
+
c = f.read()
|
| 18 |
+
|
| 19 |
+
# ---- 1. Replace single-file helper with multi-file helper ----
|
| 20 |
+
old_helper = '''def _dataset_upload_file_from_form(form: FormData) -> UploadFile:
|
| 21 |
+
uploaded_files = [
|
| 22 |
+
(key, value)
|
| 23 |
+
for key, value in form.multi_items()
|
| 24 |
+
if isinstance(value, UploadFile)
|
| 25 |
+
]
|
| 26 |
+
if len(uploaded_files) != 1:
|
| 27 |
+
raise HTTPException(
|
| 28 |
+
status_code=400,
|
| 29 |
+
detail="Upload exactly one dataset file.",
|
| 30 |
+
)
|
| 31 |
+
field_name, upload = uploaded_files[0]
|
| 32 |
+
if field_name != "file":
|
| 33 |
+
raise HTTPException(
|
| 34 |
+
status_code=400,
|
| 35 |
+
detail="Missing 'file' upload field.",
|
| 36 |
+
)
|
| 37 |
+
return upload
|
| 38 |
+
'''
|
| 39 |
+
|
| 40 |
+
new_helper = '''def _dataset_upload_files_from_form(form: FormData) -> list[UploadFile]:
|
| 41 |
+
"""Return all uploaded files from the multipart form (field 'file' or 'files')."""
|
| 42 |
+
uploaded_files = [
|
| 43 |
+
value
|
| 44 |
+
for key, value in form.multi_items()
|
| 45 |
+
if isinstance(value, UploadFile)
|
| 46 |
+
]
|
| 47 |
+
if not uploaded_files:
|
| 48 |
+
raise HTTPException(
|
| 49 |
+
status_code=400,
|
| 50 |
+
detail="No file uploads found in the request.",
|
| 51 |
+
)
|
| 52 |
+
return uploaded_files
|
| 53 |
+
'''
|
| 54 |
+
|
| 55 |
+
if old_helper in c:
|
| 56 |
+
c = c.replace(old_helper, new_helper)
|
| 57 |
+
print("OK: replaced helper with multi-file helper")
|
| 58 |
+
else:
|
| 59 |
+
print("WARN: single-file helper not found (check indentation)")
|
| 60 |
+
|
| 61 |
+
# ---- 2. Rewrite the upload route to handle multiple files ----
|
| 62 |
+
# Replace the entire function based on its decorator + route path.
|
| 63 |
+
start_marker = '@router.post("/session/{session_id}/datasets"'
|
| 64 |
+
end_marker = '@router.patch("/session/{session_id}/yolo")'
|
| 65 |
+
start = c.find(start_marker)
|
| 66 |
+
end = c.find(end_marker)
|
| 67 |
+
if start == -1 or end == -1:
|
| 68 |
+
print(f"FAIL: route markers not found (start={start}, end={end})")
|
| 69 |
+
raise SystemExit(1)
|
| 70 |
+
|
| 71 |
+
new_route = '''@router.post("/session/{session_id}/datasets", response_model=list[DatasetUploadResponse])
|
| 72 |
+
async def upload_session_dataset(
|
| 73 |
+
session_id: str,
|
| 74 |
+
request: Request,
|
| 75 |
+
user: dict = Depends(get_current_user),
|
| 76 |
+
) -> list[DatasetUploadResponse]:
|
| 77 |
+
"""Upload one or more document/dataset files to a private Hub dataset for this session."""
|
| 78 |
+
files: list[UploadFile] = []
|
| 79 |
+
try:
|
| 80 |
+
_reject_oversize_dataset_upload(request)
|
| 81 |
+
agent_session = await _check_session_access(session_id, user, request)
|
| 82 |
+
if not agent_session or not agent_session.is_active:
|
| 83 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 84 |
+
if agent_session.is_processing:
|
| 85 |
+
raise HTTPException(
|
| 86 |
+
status_code=409,
|
| 87 |
+
detail="Cannot upload files while the agent is processing.",
|
| 88 |
+
)
|
| 89 |
+
if agent_session.session.pending_approval:
|
| 90 |
+
raise HTTPException(
|
| 91 |
+
status_code=409,
|
| 92 |
+
detail="Resolve pending approvals before uploading files.",
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
hf_token = (
|
| 96 |
+
resolve_hf_request_token(request, include_env_fallback=False)
|
| 97 |
+
or _user_hf_token(user)
|
| 98 |
+
or resolve_hf_request_token(request)
|
| 99 |
+
)
|
| 100 |
+
if not hf_token:
|
| 101 |
+
raise HTTPException(
|
| 102 |
+
status_code=401,
|
| 103 |
+
detail="A Hugging Face token is required to upload files.",
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
form = await request.form(
|
| 107 |
+
max_files=20,
|
| 108 |
+
max_fields=20,
|
| 109 |
+
max_part_size=MAX_DATASET_UPLOAD_BYTES,
|
| 110 |
+
)
|
| 111 |
+
files = _dataset_upload_files_from_form(form)
|
| 112 |
+
if not files:
|
| 113 |
+
raise HTTPException(status_code=400, detail="No files to upload.")
|
| 114 |
+
|
| 115 |
+
hf_username = user.get("username") or agent_session.hf_username
|
| 116 |
+
results: list[DatasetUploadResponse] = []
|
| 117 |
+
for file in files:
|
| 118 |
+
uploaded = await push_dataset_upload_to_hub(
|
| 119 |
+
upload=file,
|
| 120 |
+
session_id=session_id,
|
| 121 |
+
hf_username=hf_username,
|
| 122 |
+
hf_token=hf_token,
|
| 123 |
+
)
|
| 124 |
+
agent_session.session.context_manager.add_message(
|
| 125 |
+
Message(role="user", content=format_uploaded_document_context(
|
| 126 |
+
filename=uploaded.original_filename,
|
| 127 |
+
stored_filename=uploaded.filename,
|
| 128 |
+
repo_id=uploaded.repo_id,
|
| 129 |
+
path_in_repo=uploaded.path_in_repo,
|
| 130 |
+
hub_url=uploaded.hub_url,
|
| 131 |
+
size_bytes=uploaded.size_bytes,
|
| 132 |
+
file_format=uploaded.format,
|
| 133 |
+
extracted_text=uploaded.extracted_text,
|
| 134 |
+
warning=uploaded.extraction_warning,
|
| 135 |
+
))
|
| 136 |
+
)
|
| 137 |
+
results.append(DatasetUploadResponse(**uploaded.response_payload()))
|
| 138 |
+
logger.info(
|
| 139 |
+
"Uploaded file %s to %s for session %s",
|
| 140 |
+
uploaded.filename,
|
| 141 |
+
uploaded.repo_id,
|
| 142 |
+
session_id,
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
session_manager._touch(agent_session)
|
| 146 |
+
await session_manager.persist_session_snapshot(agent_session)
|
| 147 |
+
return results
|
| 148 |
+
except HTTPException:
|
| 149 |
+
raise
|
| 150 |
+
except HfHubHTTPError as e:
|
| 151 |
+
logger.warning(
|
| 152 |
+
"Hub rejected file upload for session %s: status=%s request_id=%s",
|
| 153 |
+
session_id,
|
| 154 |
+
getattr(e.response, "status_code", None),
|
| 155 |
+
getattr(e, "request_id", None),
|
| 156 |
+
)
|
| 157 |
+
raise _dataset_upload_hub_http_exception(e)
|
| 158 |
+
except Exception:
|
| 159 |
+
logger.exception("File upload failed for session %s", session_id)
|
| 160 |
+
raise HTTPException(
|
| 161 |
+
status_code=502,
|
| 162 |
+
detail="File upload failed. Please try again.",
|
| 163 |
+
)
|
| 164 |
+
finally:
|
| 165 |
+
for f in files:
|
| 166 |
+
await f.close()
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
'''
|
| 170 |
+
|
| 171 |
+
c = c[:start] + new_route + c[end:]
|
| 172 |
+
|
| 173 |
+
with open(AGENT_FILE, "w") as f:
|
| 174 |
+
f.write(c)
|
| 175 |
+
|
| 176 |
+
try:
|
| 177 |
+
ast.parse(c)
|
| 178 |
+
print("OK: agent.py written & syntax valid")
|
| 179 |
+
except SyntaxError as e:
|
| 180 |
+
print(f"FAIL: agent.py syntax error: {e}")
|
| 181 |
+
raise
|
| 182 |
+
|
| 183 |
+
print("DONE: multi-file upload backend patch complete")
|
patch_new_models.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Patch backend: add 3 new OpenRouter models (openai/ prefix routes to OpenRouter).
|
| 2 |
+
|
| 3 |
+
New models:
|
| 4 |
+
- openai/upstage/solar-pro4
|
| 5 |
+
- openai/nvidia/nemotron-3.5-lightning:free
|
| 6 |
+
- openai/liquid/lfm-2.5-2.6b:free
|
| 7 |
+
|
| 8 |
+
The `openai/` prefix is already stripped in _resolve_llm_params and routed to
|
| 9 |
+
OpenRouter, so we only need to register the model IDs, imports, and the
|
| 10 |
+
available-models list.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import ast
|
| 14 |
+
import os
|
| 15 |
+
|
| 16 |
+
IDS_FILE = "/app/agent/core/model_ids.py"
|
| 17 |
+
AGENT_FILE = "/app/backend/routes/agent.py"
|
| 18 |
+
|
| 19 |
+
# ---------- 1. model_ids.py ----------
|
| 20 |
+
with open(IDS_FILE) as f:
|
| 21 |
+
c = f.read()
|
| 22 |
+
|
| 23 |
+
new_constants = '''UPSTAGE_SOLAR_PRO4_MODEL_ID = "openai/upstage/solar-pro4"
|
| 24 |
+
NEMOTRON_3_5_LIGHTNING_FREE_MODEL_ID = "openai/nvidia/nemotron-3.5-lightning:free"
|
| 25 |
+
LIQUID_LFM_2_5_2_6B_FREE_MODEL_ID = "openai/liquid/lfm-2.5-2.6b:free"
|
| 26 |
+
'''
|
| 27 |
+
|
| 28 |
+
if "UPSTAGE_SOLAR_PRO4_MODEL_ID" not in c:
|
| 29 |
+
# Insert before DEEPSEEK_V4_PRO_MODEL_ID definition
|
| 30 |
+
target = 'DEEPSEEK_V4_PRO_MODEL_ID = "nvidia/nemotron-3-super-120b-a12b:free"'
|
| 31 |
+
if target in c:
|
| 32 |
+
c = c.replace(target, new_constants + target)
|
| 33 |
+
print("OK: added 3 new model constants to model_ids.py")
|
| 34 |
+
else:
|
| 35 |
+
# fallback: append at end of constants near TENCENT/HY3 block
|
| 36 |
+
anchor = 'LING_3_0_FLASH_FREE_MODEL_ID = "openai/inclusionai/ling-3.0-flash:free"'
|
| 37 |
+
if anchor in c:
|
| 38 |
+
c = c.replace(anchor, anchor + "\n" + new_constants)
|
| 39 |
+
print("OK: added constants after LING block")
|
| 40 |
+
else:
|
| 41 |
+
print("FAIL: could not find insertion anchor in model_ids.py")
|
| 42 |
+
else:
|
| 43 |
+
print("OK: constants already present")
|
| 44 |
+
|
| 45 |
+
with open(IDS_FILE, "w") as f:
|
| 46 |
+
f.write(c)
|
| 47 |
+
ast.parse(c)
|
| 48 |
+
print("OK: model_ids.py syntax OK")
|
| 49 |
+
|
| 50 |
+
# ---------- 2. agent.py : imports + _available_models ----------
|
| 51 |
+
with open(AGENT_FILE) as f:
|
| 52 |
+
ac = f.read()
|
| 53 |
+
|
| 54 |
+
# Add imports
|
| 55 |
+
if "UPSTAGE_SOLAR_PRO4_MODEL_ID" not in ac:
|
| 56 |
+
old_imp = " NEX_N2_MINI_MODEL_ID,\n LING_3_0_FLASH_FREE_MODEL_ID,\n"
|
| 57 |
+
new_imp = (
|
| 58 |
+
" NEX_N2_MINI_MODEL_ID,\n"
|
| 59 |
+
" LING_3_0_FLASH_FREE_MODEL_ID,\n"
|
| 60 |
+
" UPSTAGE_SOLAR_PRO4_MODEL_ID,\n"
|
| 61 |
+
" NEMOTRON_3_5_LIGHTNING_FREE_MODEL_ID,\n"
|
| 62 |
+
" LIQUID_LFM_2_5_2_6B_FREE_MODEL_ID,\n"
|
| 63 |
+
)
|
| 64 |
+
if old_imp in ac:
|
| 65 |
+
ac = ac.replace(old_imp, new_imp)
|
| 66 |
+
print("OK: added new model imports")
|
| 67 |
+
else:
|
| 68 |
+
print("WARN: import anchor not matched")
|
| 69 |
+
|
| 70 |
+
# Add to _available_models list - insert after LING_3_0_FLASH line
|
| 71 |
+
old_line = ' {"id": LING_3_0_FLASH_FREE_MODEL_ID, "label": "inclusionai/ling-3.0-flash:free", "recommended": True},\n'
|
| 72 |
+
new_line = old_line + (
|
| 73 |
+
' {"id": UPSTAGE_SOLAR_PRO4_MODEL_ID, "label": "UPSTAGE Solar Pro 4", "recommended": True},\n'
|
| 74 |
+
' {"id": NEMOTRON_3_5_LIGHTNING_FREE_MODEL_ID, "label": "NVIDIA Nemotron 3.5 Lightning:free", "recommended": True},\n'
|
| 75 |
+
' {"id": LIQUID_LFM_2_5_2_6B_FREE_MODEL_ID, "label": "Liquid LFM 2.5 2.6B:free", "recommended": True},\n'
|
| 76 |
+
)
|
| 77 |
+
if old_line in ac:
|
| 78 |
+
ac = ac.replace(old_line, new_line)
|
| 79 |
+
print("OK: added 3 new models to _available_models")
|
| 80 |
+
else:
|
| 81 |
+
print("WARN: _available_models LING line not matched (check exact text)")
|
| 82 |
+
|
| 83 |
+
with open(AGENT_FILE, "w") as f:
|
| 84 |
+
f.write(ac)
|
| 85 |
+
ast.parse(ac)
|
| 86 |
+
print("OK: agent.py syntax OK")
|
| 87 |
+
|
| 88 |
+
print("DONE: 3 new OpenRouter models added (backend)")
|
patch_openrouter_fix.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Patch backend llm_params.py: force OpenRouter as the LiteLLM provider.
|
| 2 |
+
|
| 3 |
+
Root cause: the openai/-prefixed (and deepseek/) model routing returns only
|
| 4 |
+
``api_base`` + ``api_key``. LiteLLM resolves the provider from the model
|
| 5 |
+
string's org prefix, so for models whose org is NOT a recognized LiteLLM
|
| 6 |
+
provider (upstage/, nvidia/, liquid/, meta-llama/, google/, poolside/,
|
| 7 |
+
inclusionai/, ...) it raises "LLM Provider NOT provided" even though
|
| 8 |
+
``api_base`` points at OpenRouter. Only orgs that happen to match a real
|
| 9 |
+
LiteLLM provider (e.g. tencent/) worked by luck.
|
| 10 |
+
|
| 11 |
+
Fix: add ``custom_llm_provider: "openrouter"`` to both OpenRouter routing
|
| 12 |
+
branches so LiteLLM always uses the OpenRouter OpenAI-compatible client.
|
| 13 |
+
|
| 14 |
+
This runs AFTER patch_models.py (which may already have inserted the routing
|
| 15 |
+
blocks); it is idempotent.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
import ast
|
| 19 |
+
import os
|
| 20 |
+
|
| 21 |
+
FILE = "/app/agent/core/llm_params.py"
|
| 22 |
+
|
| 23 |
+
with open(FILE) as f:
|
| 24 |
+
c = f.read()
|
| 25 |
+
|
| 26 |
+
before = c
|
| 27 |
+
|
| 28 |
+
# --- 1. openai/ branch (unique via or_model line) ---
|
| 29 |
+
openai_old = (
|
| 30 |
+
' or_model = normalized_model[len("openai/"):]\n'
|
| 31 |
+
' return {\n'
|
| 32 |
+
' "model": or_model,\n'
|
| 33 |
+
' "api_base": "https://openrouter.ai/api/v1",\n'
|
| 34 |
+
' "api_key": os.environ.get("OPENROUTER_API_KEY") or api_key or "",\n'
|
| 35 |
+
' }\n'
|
| 36 |
+
)
|
| 37 |
+
openai_new = (
|
| 38 |
+
' or_model = normalized_model[len("openai/"):]\n'
|
| 39 |
+
' return {\n'
|
| 40 |
+
' "model": or_model,\n'
|
| 41 |
+
' "api_base": "https://openrouter.ai/api/v1",\n'
|
| 42 |
+
' "api_key": os.environ.get("OPENROUTER_API_KEY") or api_key or "",\n'
|
| 43 |
+
' "custom_llm_provider": "openrouter",\n'
|
| 44 |
+
' }\n'
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
# --- 2. deepseek/ branch (unique via normalized_model on its own line) ---
|
| 48 |
+
deepseek_old = (
|
| 49 |
+
' if normalized_model.startswith("deepseek/"):\n'
|
| 50 |
+
' return {\n'
|
| 51 |
+
' "model": normalized_model,\n'
|
| 52 |
+
' "api_base": "https://openrouter.ai/api/v1",\n'
|
| 53 |
+
' "api_key": os.environ.get("OPENROUTER_API_KEY") or api_key or "",\n'
|
| 54 |
+
' }\n'
|
| 55 |
+
)
|
| 56 |
+
deepseek_new = (
|
| 57 |
+
' if normalized_model.startswith("deepseek/"):\n'
|
| 58 |
+
' return {\n'
|
| 59 |
+
' "model": normalized_model,\n'
|
| 60 |
+
' "api_base": "https://openrouter.ai/api/v1",\n'
|
| 61 |
+
' "api_key": os.environ.get("OPENROUTER_API_KEY") or api_key or "",\n'
|
| 62 |
+
' "custom_llm_provider": "openrouter",\n'
|
| 63 |
+
' }\n'
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
if 'custom_llm_provider' in c:
|
| 67 |
+
print("OK: llm_params.py already contains custom_llm_provider (idempotent)")
|
| 68 |
+
else:
|
| 69 |
+
n1 = c.count(openai_old)
|
| 70 |
+
n2 = c.count(deepseek_old)
|
| 71 |
+
if n1 == 1:
|
| 72 |
+
c = c.replace(openai_old, openai_new)
|
| 73 |
+
print("OK: openai/ branch -> custom_llm_provider='openrouter'")
|
| 74 |
+
else:
|
| 75 |
+
print(f"WARN: openai/ branch match count = {n1} (expected 1)")
|
| 76 |
+
if n2 == 1:
|
| 77 |
+
c = c.replace(deepseek_old, deepseek_new)
|
| 78 |
+
print("OK: deepseek/ branch -> custom_llm_provider='openrouter'")
|
| 79 |
+
else:
|
| 80 |
+
print(f"WARN: deepseek/ branch match count = {n2} (expected 1)")
|
| 81 |
+
|
| 82 |
+
if c != before:
|
| 83 |
+
with open(FILE, "w") as f:
|
| 84 |
+
f.write(c)
|
| 85 |
+
try:
|
| 86 |
+
ast.parse(c)
|
| 87 |
+
print("OK: llm_params.py written & syntax valid")
|
| 88 |
+
except SyntaxError as e:
|
| 89 |
+
print(f"FAIL: llm_params.py syntax error: {e}")
|
| 90 |
+
raise
|
| 91 |
+
else:
|
| 92 |
+
print("WARN: no changes applied")
|
patch_permanent_session_logging.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Patch: Permanent session & user context logging.
|
| 4 |
+
|
| 5 |
+
Lưu vĩnh viễn ngữ cảnh từng phiên/user vào dataset HF ngay khi có message/event mới.
|
| 6 |
+
Tắt thông báo "where we here now?". Đảm bảo mọi model đều được lưu session.
|
| 7 |
+
|
| 8 |
+
Chạy trong Docker build — an toàn với mọi trường hợp lỗi (never fails build).
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import ast
|
| 12 |
+
import os
|
| 13 |
+
import sys
|
| 14 |
+
|
| 15 |
+
PATCHED_MARKER = "# === PATCH: permanent session logging ==="
|
| 16 |
+
|
| 17 |
+
def _safe_replace(content, old, new, count=1):
|
| 18 |
+
"""Replace and verify count changed."""
|
| 19 |
+
if old in content:
|
| 20 |
+
content = content.replace(old, new, count)
|
| 21 |
+
return content, True
|
| 22 |
+
return content, False
|
| 23 |
+
|
| 24 |
+
def patch_file(path, patch_fn):
|
| 25 |
+
"""Safely read, patch, and write a file. Returns True if patched."""
|
| 26 |
+
if not os.path.exists(path):
|
| 27 |
+
print(f"SKIP: {path} not found")
|
| 28 |
+
return False
|
| 29 |
+
try:
|
| 30 |
+
with open(path, "r") as f:
|
| 31 |
+
content = f.read()
|
| 32 |
+
content, changed = patch_fn(content)
|
| 33 |
+
if not changed:
|
| 34 |
+
print(f"SKIP: {path} — no changes needed")
|
| 35 |
+
return False
|
| 36 |
+
# Validate syntax
|
| 37 |
+
try:
|
| 38 |
+
ast.parse(content)
|
| 39 |
+
except SyntaxError as e:
|
| 40 |
+
print(f"FAIL: {path} syntax: {e}")
|
| 41 |
+
return False
|
| 42 |
+
with open(path, "w") as f:
|
| 43 |
+
f.write(content)
|
| 44 |
+
print(f"OK: {path} patched")
|
| 45 |
+
return True
|
| 46 |
+
except Exception as e:
|
| 47 |
+
print(f"WARN: {path} patch error: {e}")
|
| 48 |
+
return False
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# ─── Patch session_manager.py ────────────────────────────────────────────────
|
| 52 |
+
print("=== Patching session_manager.py ===")
|
| 53 |
+
|
| 54 |
+
SESSION_MANAGER = "/app/backend/session_manager.py"
|
| 55 |
+
|
| 56 |
+
def _patch_session_manager(content):
|
| 57 |
+
changed = False
|
| 58 |
+
|
| 59 |
+
# Add import
|
| 60 |
+
if "from backend.session_logger import" not in content:
|
| 61 |
+
content, ok = _safe_replace(
|
| 62 |
+
content,
|
| 63 |
+
"from agent.messaging.gateway import NotificationGateway",
|
| 64 |
+
"from agent.messaging.gateway import NotificationGateway\n"
|
| 65 |
+
"from backend.session_logger import log_message as _perm_log_message, log_trajectory as _perm_log_trajectory",
|
| 66 |
+
)
|
| 67 |
+
if ok:
|
| 68 |
+
print(" + Added session_logger import")
|
| 69 |
+
changed = True
|
| 70 |
+
|
| 71 |
+
# Patch create_session
|
| 72 |
+
if PATCHED_MARKER not in content:
|
| 73 |
+
# Add session creation logging after session_id = str(uuid.uuid4())
|
| 74 |
+
old = " session_id = str(uuid.uuid4())\n"
|
| 75 |
+
new = ''' session_id = str(uuid.uuid4())
|
| 76 |
+
|
| 77 |
+
# === PATCH: permanent session logging ===
|
| 78 |
+
# Log session creation to dataset IMMEDIATELY — every model gets session saved
|
| 79 |
+
try:
|
| 80 |
+
def _do_create_log():
|
| 81 |
+
_perm_log_message(
|
| 82 |
+
session_id=session_id,
|
| 83 |
+
user_id=user_id or "dev",
|
| 84 |
+
model_name=model or "",
|
| 85 |
+
role="system",
|
| 86 |
+
content=f"Session created for {user_id or 'dev'} with model {model or 'default'}",
|
| 87 |
+
event_type="session_created",
|
| 88 |
+
seq=0,
|
| 89 |
+
extra={"surface": "frontend"},
|
| 90 |
+
hf_user=hf_username,
|
| 91 |
+
)
|
| 92 |
+
asyncio.get_event_loop().run_in_executor(None, _do_create_log)
|
| 93 |
+
except Exception as _e:
|
| 94 |
+
logger.debug("Session creation logging failed: %s", _e)
|
| 95 |
+
'''
|
| 96 |
+
content, ok = _safe_replace(content, old, new)
|
| 97 |
+
if ok:
|
| 98 |
+
print(" + Added session creation logging")
|
| 99 |
+
changed = True
|
| 100 |
+
|
| 101 |
+
# Patch submit_user_input
|
| 102 |
+
old_submit = " agent_session.submission_queue.put_nowait(submission)\n self._touch(agent_session)"
|
| 103 |
+
new_submit = ''' agent_session.submission_queue.put_nowait(submission)
|
| 104 |
+
self._touch(agent_session)
|
| 105 |
+
|
| 106 |
+
# === PATCH: log user message immediately ===
|
| 107 |
+
try:
|
| 108 |
+
model_name = getattr(agent_session.session.config, 'model_name', '')
|
| 109 |
+
def _do_msg_log():
|
| 110 |
+
_perm_log_message(
|
| 111 |
+
session_id=session_id,
|
| 112 |
+
user_id=user_id,
|
| 113 |
+
model_name=model_name,
|
| 114 |
+
role="user",
|
| 115 |
+
content=text,
|
| 116 |
+
event_type="message",
|
| 117 |
+
seq=len(agent_session.session.context_manager.items),
|
| 118 |
+
extra={"source": "submit_user_input"},
|
| 119 |
+
hf_user=agent_session.hf_username or agent_session.user_id,
|
| 120 |
+
)
|
| 121 |
+
asyncio.get_event_loop().run_in_executor(None, _do_msg_log)
|
| 122 |
+
except Exception as _e:
|
| 123 |
+
logger.debug("Message logging failed: %s", _e)'''
|
| 124 |
+
content, ok = _safe_replace(content, old_submit, new_submit)
|
| 125 |
+
if ok:
|
| 126 |
+
print(" + Added immediate message logging")
|
| 127 |
+
changed = True
|
| 128 |
+
|
| 129 |
+
# Patch trajectory upload
|
| 130 |
+
old_refresh = " session.save_and_upload_detached(session.config.session_dataset_repo)"
|
| 131 |
+
new_refresh = ''' session.save_and_upload_detached(session.config.session_dataset_repo)
|
| 132 |
+
# === PATCH: upload full trajectory to permanent dataset ===
|
| 133 |
+
try:
|
| 134 |
+
trajectory = session.get_trajectory()
|
| 135 |
+
hf_user = agent_session.hf_username or agent_session.user_id
|
| 136 |
+
def _do_traj_upload():
|
| 137 |
+
_perm_log_trajectory(trajectory, session.user_id or "dev", hf_user, agent_session.hf_token, False)
|
| 138 |
+
asyncio.get_event_loop().run_in_executor(None, _do_traj_upload)
|
| 139 |
+
except Exception as _e:
|
| 140 |
+
logger.debug("Trajectory upload failed: %s", _e)'''
|
| 141 |
+
content, ok = _safe_replace(content, old_refresh, new_refresh)
|
| 142 |
+
if ok:
|
| 143 |
+
print(" + Added trajectory upload")
|
| 144 |
+
changed = True
|
| 145 |
+
|
| 146 |
+
# Remove 'where we here now?'
|
| 147 |
+
if "where we here now" in content.lower():
|
| 148 |
+
lines = content.split("\n")
|
| 149 |
+
content = "\n".join([l for l in lines if "where we here now" not in l.lower()])
|
| 150 |
+
print(" + Removed 'where we here now?' notification")
|
| 151 |
+
changed = True
|
| 152 |
+
|
| 153 |
+
return content, changed
|
| 154 |
+
|
| 155 |
+
patch_file(SESSION_MANAGER, _patch_session_manager)
|
| 156 |
+
|
| 157 |
+
# ─── Patch routes/agent.py ───────────────────────────────────────────────────
|
| 158 |
+
print("\n=== Patching routes/agent.py ===")
|
| 159 |
+
|
| 160 |
+
ROUTES_AGENT = "/app/backend/routes/agent.py"
|
| 161 |
+
|
| 162 |
+
def _patch_routes(content):
|
| 163 |
+
changed = False
|
| 164 |
+
|
| 165 |
+
# Add import
|
| 166 |
+
if "from backend.session_logger import" not in content:
|
| 167 |
+
content, ok = _safe_replace(
|
| 168 |
+
content,
|
| 169 |
+
"from session_manager import (\n",
|
| 170 |
+
"from backend.session_logger import log_message as _log_session, PUBLIC_SESSIONS_DATASET\n"
|
| 171 |
+
"from session_manager import (\n",
|
| 172 |
+
)
|
| 173 |
+
if ok:
|
| 174 |
+
print(" + Added session_logger import")
|
| 175 |
+
changed = True
|
| 176 |
+
|
| 177 |
+
# Add endpoints
|
| 178 |
+
if "/session-log/" not in content:
|
| 179 |
+
log_endpoint = '''
|
| 180 |
+
|
| 181 |
+
@router.post("/session-log/{session_id}")
|
| 182 |
+
async def log_session_event(
|
| 183 |
+
session_id: str,
|
| 184 |
+
body: dict,
|
| 185 |
+
user: dict = Depends(get_current_user),
|
| 186 |
+
) -> dict:
|
| 187 |
+
"""Receive permanent session event logs from the frontend."""
|
| 188 |
+
event_type = body.get("event_type", "frontend_event")
|
| 189 |
+
data = body.get("data", {})
|
| 190 |
+
seq = body.get("seq", 0)
|
| 191 |
+
role = body.get("role", "frontend")
|
| 192 |
+
content_text = body.get("content", "")
|
| 193 |
+
extra = {k: v for k, v in body.items() if k not in ("event_type", "data", "timestamp", "seq", "role", "content")}
|
| 194 |
+
try:
|
| 195 |
+
def _do_log():
|
| 196 |
+
_log_session(
|
| 197 |
+
session_id=session_id,
|
| 198 |
+
user_id=user.get("user_id") or user.get("username") or "dev",
|
| 199 |
+
model_name="",
|
| 200 |
+
role=role,
|
| 201 |
+
content=content_text or str(data)[:500],
|
| 202 |
+
event_type=event_type,
|
| 203 |
+
seq=seq,
|
| 204 |
+
extra=extra,
|
| 205 |
+
hf_user=user.get("username") or user.get("user_id"),
|
| 206 |
+
)
|
| 207 |
+
asyncio.get_event_loop().run_in_executor(None, _do_log)
|
| 208 |
+
except Exception:
|
| 209 |
+
pass
|
| 210 |
+
return {"status": "logged", "session_id": session_id}
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
@router.get("/sessions-history")
|
| 214 |
+
async def get_sessions_history(user: dict = Depends(get_current_user)) -> list:
|
| 215 |
+
"""Get ALL sessions for user from permanent dataset (survives restart)."""
|
| 216 |
+
from backend.session_logger import get_user_sessions
|
| 217 |
+
user_id = user.get("user_id") or user.get("username") or "dev"
|
| 218 |
+
return get_user_sessions(user_id, limit=100)
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
@router.get("/session-history/{session_id}")
|
| 222 |
+
async def get_session_history(
|
| 223 |
+
session_id: str, user: dict = Depends(get_current_user)
|
| 224 |
+
) -> list:
|
| 225 |
+
"""Get full message history for a session from the permanent dataset."""
|
| 226 |
+
from backend.session_logger import load_session_history
|
| 227 |
+
return load_session_history(session_id)
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
@router.get("/dataset-info")
|
| 231 |
+
async def get_dataset_info(user: dict = Depends(get_current_user)) -> dict:
|
| 232 |
+
"""Info about the permanent sessions dataset."""
|
| 233 |
+
return {
|
| 234 |
+
"dataset_repo": PUBLIC_SESSIONS_DATASET,
|
| 235 |
+
"dataset_url": f"https://huggingface.co/datasets/{PUBLIC_SESSIONS_DATASET}",
|
| 236 |
+
"storage": "permanent (HF Dataset, never lost)",
|
| 237 |
+
"note": "Sessions saved to dataset immediately on creation and on each message.",
|
| 238 |
+
}
|
| 239 |
+
'''
|
| 240 |
+
content = content.rstrip() + "\n" + log_endpoint
|
| 241 |
+
print(" + Added permanent logging endpoints")
|
| 242 |
+
changed = True
|
| 243 |
+
|
| 244 |
+
# Remove 'where we here now?'
|
| 245 |
+
if "where we here now" in content.lower():
|
| 246 |
+
lines = content.split("\n")
|
| 247 |
+
content = "\n".join([l for l in lines if "where we here now" not in l.lower()])
|
| 248 |
+
print(" + Removed 'where we here now?' notification")
|
| 249 |
+
changed = True
|
| 250 |
+
|
| 251 |
+
return content, changed
|
| 252 |
+
|
| 253 |
+
patch_file(ROUTES_AGENT, _patch_routes)
|
| 254 |
+
|
| 255 |
+
# ─── Patch session.py ────────────────────────────────────────────────────────
|
| 256 |
+
print("\n=== Patching session.py ===")
|
| 257 |
+
|
| 258 |
+
SESSION_FILE = "/app/agent/core/session.py"
|
| 259 |
+
|
| 260 |
+
def _patch_session(content):
|
| 261 |
+
changed = False
|
| 262 |
+
|
| 263 |
+
# Add import
|
| 264 |
+
if "from backend.session_logger import" not in content:
|
| 265 |
+
content, ok = _safe_replace(
|
| 266 |
+
content,
|
| 267 |
+
"import uuid\n",
|
| 268 |
+
"import uuid\n"
|
| 269 |
+
"# === PATCH: permanent session logging ===\n"
|
| 270 |
+
"try:\n"
|
| 271 |
+
" from backend.session_logger import log_message as _perm_log_message\n"
|
| 272 |
+
"except Exception:\n"
|
| 273 |
+
" def _perm_log_message(*a, **kw): pass\n",
|
| 274 |
+
)
|
| 275 |
+
if ok:
|
| 276 |
+
print(" + Added session_logger import")
|
| 277 |
+
changed = True
|
| 278 |
+
|
| 279 |
+
# Patch send_event to log permanently
|
| 280 |
+
if "# === PATCH: permanent event logging ===" not in content:
|
| 281 |
+
old_put = " await self.event_queue.put(event)\n await self._enqueue_auto_notification_requests(event)"
|
| 282 |
+
new_put = ''' await self.event_queue.put(event)
|
| 283 |
+
# === PATCH: permanent event logging ===
|
| 284 |
+
try:
|
| 285 |
+
def _do_event_log():
|
| 286 |
+
_perm_log_message(
|
| 287 |
+
session_id=self.session_id,
|
| 288 |
+
user_id=self.user_id or "dev",
|
| 289 |
+
model_name=self.config.model_name,
|
| 290 |
+
role="event",
|
| 291 |
+
content=str(event.data) if event.data else "",
|
| 292 |
+
event_type=event.event_type,
|
| 293 |
+
seq=event.seq,
|
| 294 |
+
extra={"session_id": self.session_id},
|
| 295 |
+
hf_user=self.hf_username or self.user_id,
|
| 296 |
+
)
|
| 297 |
+
asyncio.get_event_loop().run_in_executor(None, _do_event_log)
|
| 298 |
+
except Exception as _e:
|
| 299 |
+
logger.debug("Permanent event logging failed: %s", _e)
|
| 300 |
+
await self._enqueue_auto_notification_requests(event)'''
|
| 301 |
+
content, ok = _safe_replace(content, old_put, new_put)
|
| 302 |
+
if ok:
|
| 303 |
+
print(" + Added permanent event logging to send_event")
|
| 304 |
+
changed = True
|
| 305 |
+
|
| 306 |
+
# Patch start_new_conversation to log
|
| 307 |
+
if "# === PATCH: log new conversation ===" not in content:
|
| 308 |
+
old_conv = " self.context_manager.items = [system_msg] if system_msg is not None else []"
|
| 309 |
+
new_conv = ''' self.context_manager.items = [system_msg] if system_msg is not None else []
|
| 310 |
+
# === PATCH: log new conversation start ===
|
| 311 |
+
try:
|
| 312 |
+
def _do_conv_log():
|
| 313 |
+
_perm_log_message(
|
| 314 |
+
session_id=self.session_id,
|
| 315 |
+
user_id=self.user_id or "dev",
|
| 316 |
+
model_name=self.config.model_name,
|
| 317 |
+
role="system",
|
| 318 |
+
content=f"New conversation started (session: {self.session_id})",
|
| 319 |
+
event_type="new_conversation",
|
| 320 |
+
seq=0,
|
| 321 |
+
extra={},
|
| 322 |
+
hf_user=self.hf_username or self.user_id,
|
| 323 |
+
)
|
| 324 |
+
asyncio.get_event_loop().run_in_executor(None, _do_conv_log)
|
| 325 |
+
except Exception as _e:
|
| 326 |
+
logger.debug("New conversation logging failed: %s", _e)'''
|
| 327 |
+
content, ok = _safe_replace(content, old_conv, new_conv)
|
| 328 |
+
if ok:
|
| 329 |
+
print(" + Added new conversation logging")
|
| 330 |
+
changed = True
|
| 331 |
+
|
| 332 |
+
return content, changed
|
| 333 |
+
|
| 334 |
+
patch_file(SESSION_FILE, _patch_session)
|
| 335 |
+
|
| 336 |
+
# ─── Patch frontend: disable 'where we here now?' ──────────────────────────────
|
| 337 |
+
print("\n=== Patching frontend for notification removal ===")
|
| 338 |
+
frontend_src = "/source/frontend/src"
|
| 339 |
+
if os.path.exists(frontend_src):
|
| 340 |
+
for root, dirs, files in os.walk(frontend_src):
|
| 341 |
+
for fname in files:
|
| 342 |
+
if fname.endswith((".ts", ".tsx")):
|
| 343 |
+
fpath = os.path.join(root, fname)
|
| 344 |
+
try:
|
| 345 |
+
with open(fpath, "r", encoding="utf-8") as f:
|
| 346 |
+
fcontent = f.read()
|
| 347 |
+
if "where we here now" in fcontent.lower():
|
| 348 |
+
lines = fcontent.split("\n")
|
| 349 |
+
filtered = [l for l in lines if "where we here now" not in l.lower()]
|
| 350 |
+
with open(fpath, "w", encoding="utf-8") as f:
|
| 351 |
+
f.write("\n".join(filtered))
|
| 352 |
+
print(f" + Removed 'where we here now?' from {fpath}")
|
| 353 |
+
except Exception:
|
| 354 |
+
pass
|
| 355 |
+
else:
|
| 356 |
+
print(" SKIP: frontend source not found")
|
| 357 |
+
|
| 358 |
+
print("\n=== All patches applied (build will not fail regardless) ===")
|
patch_restore_full_app.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Restore the pre-retirement full app: App.tsx (frontend) + start.sh (backend).
|
| 2 |
+
|
| 3 |
+
Upstream smolagents/ml-intern retired the hosted app: frontend App.tsx now
|
| 4 |
+
renders RetirementPage ("ML Intern has moved to HuggingChat") and backend/start.sh
|
| 5 |
+
now runs retired_main:app. That turns this Space into a static redirect page and
|
| 6 |
+
the backend never starts, so our model patches have no visible effect.
|
| 7 |
+
|
| 8 |
+
This patch overwrites both files with the last pre-retirement versions that this
|
| 9 |
+
Space's Dockerfile was built on:
|
| 10 |
+
- frontend/src/App.tsx -> full AppLayout + useAuth app
|
| 11 |
+
- backend/start.sh -> uvicorn main:app (the real backend)
|
| 12 |
+
|
| 13 |
+
Runs from either build stage: it patches whichever target exists and prints
|
| 14 |
+
SKIP for the other (idempotent, re-runnable).
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import os
|
| 18 |
+
|
| 19 |
+
FRONTEND_APP = r"""import { Box } from '@mui/material';
|
| 20 |
+
import AppLayout from '@/components/Layout/AppLayout';
|
| 21 |
+
import { useAuth } from '@/hooks/useAuth';
|
| 22 |
+
|
| 23 |
+
function App() {
|
| 24 |
+
// Non-blocking auth check -- fires in background, updates store when done.
|
| 25 |
+
// If auth fails later, apiFetch redirects to /auth/login.
|
| 26 |
+
useAuth();
|
| 27 |
+
|
| 28 |
+
return (
|
| 29 |
+
<Box sx={{ height: '100vh', display: 'flex' }}>
|
| 30 |
+
<AppLayout />
|
| 31 |
+
</Box>
|
| 32 |
+
);
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export default App;
|
| 36 |
+
"""
|
| 37 |
+
|
| 38 |
+
BACKEND_START = r"""#!/bin/bash
|
| 39 |
+
# Entrypoint for HF Spaces dev mode compatibility.
|
| 40 |
+
# Dev mode spawns CMD multiple times simultaneously on restart.
|
| 41 |
+
# Only the first instance can bind port 7860 - the rest must exit
|
| 42 |
+
# with code 0 so the dev mode daemon doesn't mark the app as crashed.
|
| 43 |
+
|
| 44 |
+
# Run uvicorn; if it fails due to port conflict, exit cleanly.
|
| 45 |
+
uvicorn main:app --host 0.0.0.0 --port 7860
|
| 46 |
+
EXIT_CODE=$?
|
| 47 |
+
|
| 48 |
+
if [ $EXIT_CODE -ne 0 ]; then
|
| 49 |
+
# Check if this was a port-in-use failure (another instance already running)
|
| 50 |
+
echo "uvicorn exited with code $EXIT_CODE, exiting gracefully."
|
| 51 |
+
exit 0
|
| 52 |
+
fi
|
| 53 |
+
"""
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _write(path: str, content: str, executable: bool = False) -> bool:
|
| 57 |
+
if not os.path.exists(path):
|
| 58 |
+
return False
|
| 59 |
+
with open(path, "w", encoding="utf-8") as f:
|
| 60 |
+
f.write(content)
|
| 61 |
+
if executable:
|
| 62 |
+
os.chmod(path, 0o755)
|
| 63 |
+
return True
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
changed = False
|
| 67 |
+
|
| 68 |
+
written = _write("/source/frontend/src/App.tsx", FRONTEND_APP)
|
| 69 |
+
if written:
|
| 70 |
+
print("OK: restored /source/frontend/src/App.tsx (full AppLayout app)")
|
| 71 |
+
changed = True
|
| 72 |
+
else:
|
| 73 |
+
print("SKIP: /source/frontend/src/App.tsx not present (backend stage?)")
|
| 74 |
+
|
| 75 |
+
written = _write("/app/backend/start.sh", BACKEND_START, executable=True)
|
| 76 |
+
if written:
|
| 77 |
+
print("OK: restored /app/backend/start.sh (uvicorn main:app)")
|
| 78 |
+
changed = True
|
| 79 |
+
else:
|
| 80 |
+
print("SKIP: /app/backend/start.sh not present (frontend stage?)")
|
| 81 |
+
|
| 82 |
+
if not changed:
|
| 83 |
+
raise SystemExit("FAIL: neither target file found - unexpected layout")
|
| 84 |
+
print("OK: full-app restore done")
|
patch_space_bunny.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Patch frontend: add stealth/space-bunny-alpha beside Laguna S 2.1.
|
| 2 |
+
|
| 3 |
+
The model uses the same OpenRouter-style `openai/` model path convention as
|
| 4 |
+
Laguna S 2.1 (`openai/poolside/laguna-s-2.1:free`).
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
|
| 9 |
+
FILE = "/source/frontend/src/components/Chat/ChatInput.tsx"
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def patch():
|
| 13 |
+
if not os.path.exists(FILE):
|
| 14 |
+
print(f"SKIP: {FILE} not found")
|
| 15 |
+
return
|
| 16 |
+
|
| 17 |
+
with open(FILE, "r", encoding="utf-8") as f:
|
| 18 |
+
content = f.read()
|
| 19 |
+
|
| 20 |
+
if "space-bunny-alpha" in content:
|
| 21 |
+
print("OK: stealth/space-bunny-alpha already present")
|
| 22 |
+
return
|
| 23 |
+
|
| 24 |
+
anchor = (
|
| 25 |
+
" {\n"
|
| 26 |
+
" id: 'laguna-s21',\n"
|
| 27 |
+
" name: 'Laguna S 2.1:free',\n"
|
| 28 |
+
" modelPath: 'openai/poolside/laguna-s-2.1:free',\n"
|
| 29 |
+
" avatarUrl: 'https://huggingface.co/api/avatars/poolside',\n"
|
| 30 |
+
" recommended: true,\n"
|
| 31 |
+
" },\n"
|
| 32 |
+
)
|
| 33 |
+
new_entry = (
|
| 34 |
+
" {\n"
|
| 35 |
+
" id: 'space-bunny-alpha',\n"
|
| 36 |
+
" name: 'stealth/space-bunny-alpha',\n"
|
| 37 |
+
" modelPath: 'openai/stealth/space-bunny-alpha',\n"
|
| 38 |
+
" avatarUrl: 'https://huggingface.co/api/avatars/stealth',\n"
|
| 39 |
+
" recommended: true,\n"
|
| 40 |
+
" },\n"
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
if anchor in content:
|
| 44 |
+
content = content.replace(anchor, anchor + new_entry, 1)
|
| 45 |
+
print("OK: added stealth/space-bunny-alpha after Laguna S 2.1")
|
| 46 |
+
else:
|
| 47 |
+
print("FAIL: could not find Laguna S 2.1 model entry")
|
| 48 |
+
return
|
| 49 |
+
|
| 50 |
+
with open(FILE, "w", encoding="utf-8") as f:
|
| 51 |
+
f.write(content)
|
| 52 |
+
print("OK: ChatInput.tsx patched")
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
if __name__ == "__main__":
|
| 56 |
+
patch()
|
patch_sse_transport.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Patch sse-chat-transport.ts - handle task_incomplete event."""
|
| 2 |
+
|
| 3 |
+
SSE_TRANSPORT = "/source/frontend/src/lib/sse-chat-transport.ts"
|
| 4 |
+
|
| 5 |
+
def patch():
|
| 6 |
+
import os
|
| 7 |
+
if not os.path.exists(SSE_TRANSPORT):
|
| 8 |
+
print(f"SKIP: {SSE_TRANSPORT} not found")
|
| 9 |
+
return
|
| 10 |
+
|
| 11 |
+
with open(SSE_TRANSPORT, "r", encoding="utf-8") as f:
|
| 12 |
+
content = f.read()
|
| 13 |
+
|
| 14 |
+
# Add task_incomplete case in createEventToChunkStream
|
| 15 |
+
if "case 'task_incomplete'" not in content:
|
| 16 |
+
task_incomplete_case = ''' case 'task_incomplete':
|
| 17 |
+
sideChannel.onTaskIncomplete(
|
| 18 |
+
(event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [],
|
| 19 |
+
);
|
| 20 |
+
break;
|
| 21 |
+
'''
|
| 22 |
+
content = content.replace(
|
| 23 |
+
"case 'turn_complete':",
|
| 24 |
+
task_incomplete_case + "\n case 'turn_complete':"
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
with open(SSE_TRANSPORT, "w", encoding="utf-8") as f:
|
| 28 |
+
f.write(content)
|
| 29 |
+
print(f"OK: Patched {SSE_TRANSPORT}")
|
| 30 |
+
|
| 31 |
+
if __name__ == "__main__":
|
| 32 |
+
patch()
|