Restore Space to commit 36e2e7f8

#1
This view is limited to 50 files because it contains too many changes. See the raw diff here.
Files changed (50) hide show
  1. .gitignore +0 -2
  2. AUTO_CONTINUE_FEATURE.md +0 -183
  3. BUILD_TRIGGER +0 -1
  4. Dockerfile +43 -114
  5. README.md +317 -43
  6. backend/document_parser.py +0 -319
  7. backend/session_logger.py +0 -467
  8. configs/__init__.py +0 -0
  9. configs/cli_agent_config.json +22 -0
  10. configs/frontend_agent_config.json +5 -8
  11. fix_space_error_report.json +0 -14
  12. patch_agent_backend.py +0 -87
  13. patch_agent_loop.py +0 -74
  14. patch_apodex_ling_beatapi.py +0 -145
  15. patch_auto_continue.py +0 -129
  16. patch_auto_continue_v10.py +0 -202
  17. patch_auto_continue_v11.py +0 -185
  18. patch_auto_continue_v12.py +0 -209
  19. patch_auto_continue_v13.py +0 -197
  20. patch_auto_continue_v14.py +0 -166
  21. patch_auto_continue_v15.py +0 -167
  22. patch_auto_continue_v16.py +0 -142
  23. patch_auto_continue_v17.py +0 -187
  24. patch_auto_continue_v2.py +0 -307
  25. patch_auto_continue_v3.py +0 -229
  26. patch_auto_continue_v4.py +0 -264
  27. patch_auto_continue_v5.py +0 -252
  28. patch_auto_continue_v6.py +0 -243
  29. patch_auto_continue_v7.py +0 -255
  30. patch_auto_continue_v8.py +0 -239
  31. patch_auto_continue_v9.py +0 -227
  32. patch_beatapi_routing.py +0 -182
  33. patch_catch_up_context_restore.py +0 -188
  34. patch_catch_up_dataset_fallback.py +0 -102
  35. patch_catch_up_fix_backend.py +0 -220
  36. patch_catch_up_fix_frontend.py +0 -289
  37. patch_catch_up_fix_v2.py +0 -758
  38. patch_catch_up_fix_v3.py +0 -675
  39. patch_chat_input.py +0 -105
  40. patch_document_upload.py +0 -225
  41. patch_fix_static_404.py +0 -105
  42. patch_flint_free.py +0 -229
  43. patch_flint_free_frontend.py +0 -51
  44. patch_frontend.py +0 -199
  45. patch_frontend_apodex_ling_beatapi.py +0 -110
  46. patch_frontend_multi.py +0 -120
  47. patch_frontend_new_models.py +0 -71
  48. patch_frontend_permanent_sessions.py +0 -45
  49. patch_frontend_uploads.py +0 -81
  50. patch_frontend_xing_nemotron_vl.py +0 -63
.gitignore DELETED
@@ -1,2 +0,0 @@
1
- __pycache__/
2
- *.pyc
 
 
 
AUTO_CONTINUE_FEATURE.md DELETED
@@ -1,183 +0,0 @@
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 DELETED
@@ -1 +0,0 @@
1
- v5-add-recursive-flint-v1.0-free-reasoning-fix-openrouter
 
 
Dockerfile CHANGED
@@ -1,131 +1,60 @@
 
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_frontend_xing_nemotron_vl.py /tmp/patch_frontend_xing_nemotron_vl.py
19
- RUN python3 /tmp/patch_frontend_xing_nemotron_vl.py || true
20
- COPY patch_flint_free_frontend.py /tmp/patch_flint_free_frontend.py
21
- RUN python3 /tmp/patch_flint_free_frontend.py || true
22
- COPY patch_catch_up_dataset_fallback.py /tmp/patch_catch_up_dataset_fallback.py
23
- RUN python3 /tmp/patch_catch_up_dataset_fallback.py || true
24
- # Run auto_continue patch BEFORE our frontend fix, since it also patches useAgentChat.ts
25
- COPY patch_auto_continue_v17.py /tmp/patch_auto_continue_v17.py
26
- RUN python3 /tmp/patch_auto_continue_v17.py || true
27
- # Run our frontend catch-up fix AFTER all other frontend patches
28
- COPY patch_catch_up_fix_frontend.py /tmp/patch_catch_up_fix_frontend.py
29
- RUN python3 /tmp/patch_catch_up_fix_frontend.py || true
30
- # v2: comprehensive catch-up fix + shareable URLs (frontend)
31
- COPY patch_catch_up_fix_v2.py /tmp/patch_catch_up_fix_v2.py
32
- RUN python3 /tmp/patch_catch_up_fix_v2.py || true
33
- # v3: FIX THE BUILD — v2 left dead imports (_catchupDone, ShareIcon) and an
34
- # untyped param that fail `npm run build` -> empty /app/static -> NO frontend.
35
- # This MUST run after v2 so it can complete v2's half-applied patches.
36
- COPY patch_catch_up_fix_v3.py /tmp/patch_catch_up_fix_v3.py
37
- RUN python3 /tmp/patch_catch_up_fix_v3.py || true
38
-
39
- WORKDIR /source/frontend
40
- RUN npm config set fetch-timeout 120000 && npm config set fetch-retries 3 && npm install && npm run build || echo "FRONTEND BUILD FAILED"
41
- # Guarantee the dist dir exists so COPY --from below never fails, even if npm failed.
42
- RUN mkdir -p /source/frontend/dist
43
 
 
44
  FROM python:3.12-slim
45
- COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
46
- RUN useradd -m -u 1000 user
47
- WORKDIR /app
48
- 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/*
49
-
50
- 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
51
-
52
- COPY scripts/build_kpis.py /tmp/build_kpis_patched.py
53
- 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"
54
 
55
- COPY patch_restore_full_app.py /tmp/patch_restore_full_app.py
56
- RUN python3 /tmp/patch_restore_full_app.py || true
57
-
58
- RUN uv sync --no-dev --frozen || echo "UV SYNC FAILED"
59
 
60
- COPY backend/session_logger.py /app/backend/session_logger.py
61
- COPY backend/document_parser.py /app/backend/document_parser.py
62
- COPY patch_models.py /tmp/patch_models.py
63
- RUN python /tmp/patch_models.py || true
64
- COPY patch_new_models.py /tmp/patch_new_models.py
65
- RUN python /tmp/patch_new_models.py || true
66
- COPY patch_apodex_ling_beatapi.py /tmp/patch_apodex_ling_beatapi.py
67
- RUN python3 /tmp/patch_apodex_ling_beatapi.py || true
68
- COPY patch_xing_nemotron_vl.py /tmp/patch_xing_nemotron_vl.py
69
- RUN python3 /tmp/patch_xing_nemotron_vl.py || true
70
- COPY patch_flint_free.py /tmp/patch_flint_free.py
71
- RUN python3 /tmp/patch_flint_free.py || true
72
- COPY patch_openrouter_fix.py /tmp/patch_openrouter_fix.py
73
- RUN python3 /tmp/patch_openrouter_fix.py || true
74
- COPY patch_beatapi_routing.py /tmp/patch_beatapi_routing.py
75
- RUN python3 /tmp/patch_beatapi_routing.py || true
76
- COPY patch_agent_backend.py /tmp/patch_agent_backend.py
77
- RUN python3 /tmp/patch_agent_backend.py || true
78
- COPY patch_document_upload.py /tmp/patch_document_upload.py
79
- RUN python3 /tmp/patch_document_upload.py || true
80
- COPY patch_multi_upload.py /tmp/patch_multi_upload.py
81
- RUN python3 /tmp/patch_multi_upload.py || true
82
- COPY patch_permanent_session_logging.py /tmp/patch_permanent_session_logging.py
83
- RUN python3 /tmp/patch_permanent_session_logging.py || true
84
- COPY patch_catch_up_fix_backend.py /tmp/patch_catch_up_fix_backend.py
85
- RUN python3 /tmp/patch_catch_up_fix_backend.py || true
86
- # v2: comprehensive catch-up fix + shareable URLs (backend — runs after all other backend patches)
87
- COPY patch_catch_up_fix_v2.py /tmp/patch_catch_up_fix_v2.py
88
- RUN python3 /tmp/patch_catch_up_fix_v2.py || true
89
- # v3 backend: fix the event-row filter in session_logger.py that v2 broke
90
- # (wrong indent -> SyntaxError -> filter never applied -> catch-up errors).
91
- COPY patch_catch_up_fix_v3.py /tmp/patch_catch_up_fix_v3.py
92
- RUN python3 /tmp/patch_catch_up_fix_v3.py || true
93
 
94
- # === PATCH: fix 404 on root and /api when frontend build is missing ===
95
- # Root cause: mkdir -p /app/static creates an empty dir; main.py mounts
96
- # StaticFiles on the empty dir, shadowing ALL API routes with 404s.
97
- COPY patch_fix_static_404.py /tmp/patch_fix_static_404.py
98
- RUN python3 /tmp/patch_fix_static_404.py || echo "STATIC FIX PATCH WARN (BUILD CONTINUES)"
99
 
100
- # === PATCH: lossless long-context memory (fix context loss for long sessions) ===
101
- COPY patch_lossless_context.py /tmp/patch_lossless_context.py
102
- RUN python /tmp/patch_lossless_context.py || echo "LOSSLESS CONTEXT PATCH WARN (BUILD CONTINUES)"
103
- 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/core/session.py','/app/agent/core/session_resume.py','/app/agent/config.py']]; print('ALL PATCHED FILES SYNTAX OK')" || echo "SYNTAX CHECK WARN"
 
 
104
 
105
- # === PATCH: restore full session context from HF dataset ===
106
- # Fixes: (1) "Catch me up" banner appears after every Space restart because
107
- # NoopSessionStore never restores sessions; (2) context lost after catch-up
108
- # because seed_from_summary only used a lossy summary. This patch makes the
109
- # backend reload full conversations from the permanent sessions dataset.
110
- COPY patch_catch_up_context_restore.py /tmp/patch_catch_up_context_restore.py
111
- RUN python3 /tmp/patch_catch_up_context_restore.py || echo "CONTEXT RESTORE PATCH WARN (BUILD CONTINUES)"
112
- RUN python3 -c "import ast; ast.parse(open('/app/backend/session_manager.py').read()); print('session_manager.py SYNTAX OK')" || echo "SESSION MANAGER SYNTAX CHECK WARN"
113
 
114
- # Copy frontend dist from builder stage using COPY --from (multi-stage)
115
- # The frontend-builder stage guarantees /source/frontend/dist exists (mkdir -p)
116
- COPY --from=frontend-builder /source/frontend/dist /app/static
117
- RUN echo "Static dir ready: $(ls /app/static 2>/dev/null | wc -l) files"
118
 
119
- RUN mkdir -p /app/configs
120
- RUN cp configs/frontend_agent_config.json /app/configs/frontend_agent_config.json 2>/dev/null || echo "No frontend config"
 
 
121
 
122
- 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
 
123
 
124
- RUN mkdir -p /app/session_logs && chown -R user:user /app
 
 
125
 
 
126
  USER user
127
- ENV HOME=/home/user PYTHONUNBUFFERED=1 PYTHONPATH=/app PATH="/app/.venv/bin:$PATH"
128
 
 
 
 
 
 
 
 
129
  EXPOSE 7860
 
 
130
  WORKDIR /app/backend
131
- CMD ["bash", "start.sh"]
 
1
+ # Stage 1: Build frontend
2
  FROM node:20-alpine AS frontend-builder
3
+ WORKDIR /app/frontend
4
+ COPY frontend/package.json frontend/package-lock.json ./
5
+ RUN npm install
6
+ COPY frontend/ ./
7
+ RUN npm run build
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
+ # Stage 2: Production
10
  FROM python:3.12-slim
 
 
 
 
 
 
 
 
 
11
 
12
+ # Install uv directly from official image
13
+ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
 
 
14
 
15
+ # Create user with UID 1000 (required for HF Spaces)
16
+ RUN useradd -m -u 1000 user
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
+ WORKDIR /app
 
 
 
 
19
 
20
+ # Install system dependencies
21
+ RUN apt-get update && apt-get install -y --no-install-recommends \
22
+ git \
23
+ curl \
24
+ bash \
25
+ && rm -rf /var/lib/apt/lists/*
26
 
27
+ # Copy dependency files
28
+ COPY pyproject.toml uv.lock ./
 
 
 
 
 
 
29
 
30
+ # Install dependencies into /app/.venv
31
+ # Use --frozen to ensure exact versions from uv.lock
32
+ RUN uv sync --no-dev --frozen
 
33
 
34
+ # Copy application code
35
+ COPY agent/ ./agent/
36
+ COPY backend/ ./backend/
37
+ COPY configs/ ./configs/
38
 
39
+ # Copy built frontend
40
+ COPY --from=frontend-builder /app/frontend/dist ./static/
41
 
42
+ # Create directories and set ownership
43
+ RUN mkdir -p /app/session_logs && \
44
+ chown -R user:user /app
45
 
46
+ # Switch to non-root user
47
  USER user
 
48
 
49
+ # Set environment
50
+ ENV HOME=/home/user \
51
+ PYTHONUNBUFFERED=1 \
52
+ PYTHONPATH=/app \
53
+ PATH="/app/.venv/bin:$PATH"
54
+
55
+ # Expose port
56
  EXPOSE 7860
57
+
58
+ # Run the application from backend directory
59
  WORKDIR /app/backend
60
+ CMD ["bash", "start.sh"]
README.md CHANGED
@@ -1,66 +1,340 @@
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
 
1
  ---
2
+ title: ML Intern
3
  emoji: 🤖
4
  colorFrom: yellow
5
  colorTo: blue
6
  sdk: docker
7
  app_port: 7860
 
8
  hf_oauth: true
9
  hf_oauth_expiration_minutes: 43200
10
  hf_oauth_scopes:
11
+ - read-repos
12
+ - write-repos
13
+ - contribute-repos
14
+ - manage-repos
15
+ - write-collections
16
+ - inference-api
17
+ - jobs
18
+ - write-discussions
 
 
 
 
19
  ---
20
 
21
+ <p align="center">
22
+ <img src="frontend/public/smolagents.webp" alt="smolagents logo" width="160" />
23
+ </p>
24
 
25
+ # ML Intern
26
 
27
+ An ML intern that autonomously researches, writes, and ships good quality ML related code using the Hugging Face ecosystem — with deep access to docs, papers, datasets, and cloud compute.
28
 
29
+ ## Quick Start
 
 
 
 
30
 
31
+ ### Installation
32
 
33
+ ```bash
34
+ git clone git@github.com:huggingface/ml-intern.git
35
+ cd ml-intern
36
+ uv sync
37
+ uv tool install -e .
38
+ ```
39
 
40
+ #### That's it. Now `ml-intern` works from any directory:
41
 
42
+ ```bash
43
+ ml-intern
44
+ ```
45
 
46
+ Create a `.env` file in the project root (or export these in your shell):
47
 
48
+ ```bash
49
+ ANTHROPIC_API_KEY=<your-anthropic-api-key> # if using anthropic models
50
+ OPENAI_API_KEY=<your-openai-api-key> # if using openai models
51
+ HF_TOKEN=<your-hugging-face-token>
52
+ GITHUB_TOKEN=<github-personal-access-token>
53
+ ```
54
+ If no `HF_TOKEN` is set, the CLI will prompt you to paste one on first launch. To get a GITHUB_TOKEN follow the tutorial [here](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-fine-grained-personal-access-token).
55
 
56
+ ### Usage
 
 
 
57
 
58
+ **Interactive mode** (start a chat session):
59
+
60
+ ```bash
61
+ ml-intern
62
+ ```
63
+
64
+ **Headless mode** (single prompt, auto-approve):
65
+
66
+ ```bash
67
+ ml-intern "fine-tune llama on my dataset"
68
+ ```
69
+
70
+ **Options:**
71
+
72
+ ```bash
73
+ ml-intern --model anthropic/claude-opus-4-6 "your prompt"
74
+ ml-intern --model openai/gpt-5.5 "your prompt"
75
+ ml-intern --max-iterations 100 "your prompt"
76
+ ml-intern --no-stream "your prompt"
77
+ ```
78
+
79
+ ## Sharing Traces
80
+
81
+ Every session is auto-uploaded to your **own private Hugging Face dataset**
82
+ in [Claude Code JSONL format](https://huggingface.co/changelog/agent-trace-viewer),
83
+ which the HF Agent Trace Viewer auto-detects so you can browse turns, tool
84
+ calls, and model responses directly on the Hub.
85
+
86
+ By default the dataset is named `{your-hf-username}/ml-intern-sessions` and is
87
+ **created private**. You can flip it to public from inside the CLI:
88
+
89
+ ```bash
90
+ /share-traces # show current visibility + dataset URL
91
+ /share-traces public # publish (anyone can view)
92
+ /share-traces private # lock it back down
93
+ ```
94
+
95
+ You can also flip visibility from the dataset page on huggingface.co — the
96
+ agent honours whatever you set there for subsequent uploads.
97
+
98
+ To opt out entirely, set in your CLI config (e.g. `configs/cli_agent_config.json`
99
+ or `~/.config/ml-intern/cli_agent_config.json`):
100
+
101
+ ```json
102
+ { "share_traces": false }
103
+ ```
104
+
105
+ To override the destination repo, set:
106
+
107
+ ```json
108
+ { "personal_trace_repo_template": "{hf_user}/my-custom-traces" }
109
+ ```
110
+
111
+ The shared `smolagents/ml-intern-sessions` dataset is unrelated and only
112
+ receives anonymized telemetry rows used by the backend KPI scheduler.
113
+
114
+ ## Supported Gateways
115
+
116
+ ML Intern currently supports one-way notification gateways from CLI sessions.
117
+ These gateways send out-of-band status updates; they do not accept inbound chat
118
+ messages.
119
+
120
+ ### Slack
121
+
122
+ Slack notifications use the Slack Web API to post messages when the agent needs
123
+ approval, hits an error, or completes a turn. Create a Slack app with a bot token
124
+ that has `chat:write`, invite the bot to the target channel, then set:
125
+
126
+ ```bash
127
+ SLACK_BOT_TOKEN=xoxb-...
128
+ SLACK_CHANNEL_ID=C...
129
+ ```
130
+
131
+ The CLI automatically creates a `slack.default` destination when both variables
132
+ are present. Optional environment variables for the env-only default:
133
+
134
+ ```bash
135
+ ML_INTERN_SLACK_NOTIFICATIONS=false
136
+ ML_INTERN_SLACK_DESTINATION=slack.ops
137
+ ML_INTERN_SLACK_AUTO_EVENTS=approval_required,error,turn_complete
138
+ ML_INTERN_SLACK_ALLOW_AGENT_TOOL=true
139
+ ML_INTERN_SLACK_ALLOW_AUTO_EVENTS=true
140
+ ```
141
+
142
+ For a persistent user-level config, put overrides in
143
+ `~/.config/ml-intern/cli_agent_config.json` or point `ML_INTERN_CLI_CONFIG` at a
144
+ JSON file:
145
+
146
+ ```json
147
+ {
148
+ "messaging": {
149
+ "enabled": true,
150
+ "auto_event_types": ["approval_required", "error", "turn_complete"],
151
+ "destinations": {
152
+ "slack.ops": {
153
+ "provider": "slack",
154
+ "token": "${SLACK_BOT_TOKEN}",
155
+ "channel": "${SLACK_CHANNEL_ID}",
156
+ "allow_agent_tool": true,
157
+ "allow_auto_events": true
158
+ }
159
+ }
160
+ }
161
+ }
162
+ ```
163
+
164
+ ## Architecture
165
+
166
+ ### Component Overview
167
+
168
+ ```
169
+ ┌─────────────────────────────────────────────────────────────┐
170
+ │ User/CLI │
171
+ └────────────┬─────────────────────────────────────┬──────────┘
172
+ │ Operations │ Events
173
+ ↓ (user_input, exec_approval, ↑
174
+ submission_queue interrupt, compact, ...) event_queue
175
+ │ │
176
+ ↓ │
177
+ ┌────────────────────────────────────────────────────┐ │
178
+ │ submission_loop (agent_loop.py) │ │
179
+ │ ┌──────────────────────────────────────────────┐ │ │
180
+ │ │ 1. Receive Operation from queue │ │ │
181
+ │ │ 2. Route to handler (run_agent/compact/...) │ │ │
182
+ │ └──────────────────────────────────────────────┘ │ │ │
183
+ │ ↓ │ │
184
+ │ ┌──────────────────────────────────────────────┐ │ │
185
+ │ │ Handlers.run_agent() │ ├──┤
186
+ │ │ │ │ │
187
+ │ │ ┌────────────────────────────────────────┐ │ │ │
188
+ │ │ │ Agentic Loop (max 300 iterations) │ │ │ │
189
+ │ │ │ │ │ │ │
190
+ │ │ │ ┌──────────────────────────────────┐ │ │ │ │
191
+ │ │ │ │ Session │ │ │ │ │
192
+ │ │ │ │ ┌────────────────────────────┐ │ │ │ │ │
193
+ │ │ │ │ │ ContextManager │ │ │ │ │ │
194
+ │ │ │ │ │ • Message history │ │ │ │ │ │
195
+ │ │ │ │ │ (litellm.Message[]) │ │ │ │ │ │
196
+ │ │ │ │ │ • Auto-compaction (170k) │ │ │ │ │ │
197
+ │ │ │ │ │ • Session upload to HF │ │ │ │ │ │
198
+ │ │ │ │ └────────────────────────────┘ │ │ │ │ │
199
+ │ │ │ │ │ │ │ │ │
200
+ │ │ │ │ ┌────────────────────────────┐ │ │ │ │ │
201
+ │ │ │ │ │ ToolRouter │ │ │ │ │ │
202
+ │ │ │ │ │ ├─ HF docs & research │ │ │ │ │ │
203
+ │ │ │ │ │ ├─ HF repos, datasets, │ │ │ │ │ │
204
+ │ │ │ │ │ │ jobs, papers │ │ │ │ │ │
205
+ │ │ │ │ │ ├─ GitHub code search │ │ │ │ │ │
206
+ │ │ │ │ │ ├─ Sandbox & local tools │ │ │ │ │ │
207
+ │ │ │ │ │ ├─ Planning │ │ │ │ │ │
208
+ │ │ │ │ │ └─ MCP server tools │ │ │ │ │ │
209
+ │ │ │ │ └────────────────────────────┘ │ │ │ │ │
210
+ │ │ │ └──────────────────────────────────┘ │ │ │ │
211
+ │ │ │ │ │ │ │
212
+ │ │ │ ┌──────────────────────────────────┐ │ │ │ │
213
+ │ │ │ │ Doom Loop Detector │ │ │ │ │
214
+ │ │ │ │ • Detects repeated tool patterns │ │ │ │ │
215
+ │ │ │ │ • Injects corrective prompts │ │ │ │ │
216
+ │ │ │ └──────────────────────────────────┘ │ │ │ │
217
+ │ │ │ │ │ │ │
218
+ │ │ │ Loop: │ │ │ │
219
+ │ │ │ 1. LLM call (litellm.acompletion) │ │ │ │
220
+ │ │ │ ↓ │ │ │ │
221
+ │ │ │ 2. Parse tool_calls[] │ │ │ │
222
+ │ │ │ ↓ │ │ │ │
223
+ │ │ │ 3. Approval check │ │ │ │
224
+ │ │ │ (jobs, sandbox, destructive ops) │ │ │ │
225
+ │ │ │ ↓ │ │ │ │
226
+ │ │ │ 4. Execute via ToolRouter │ │ │ │
227
+ │ │ │ ↓ │ │ │ │
228
+ │ │ │ 5. Add results to ContextManager │ │ │ │
229
+ │ │ │ ↓ │ │ │ │
230
+ │ │ │ 6. Repeat if tool_calls exist │ │ │ │
231
+ │ │ └────────────────────────────────────────┘ │ │ │
232
+ │ └──────────────────────────────────────────────┘ │ │
233
+ └────────────────────────────────────────────────────┴──┘
234
+ ```
235
+
236
+ ### Agentic Loop Flow
237
+
238
+ ```
239
+ User Message
240
+ ↓
241
+ [Add to ContextManager]
242
+ ↓
243
+ ╔═══════════════════════════════════════════╗
244
+ ║ Iteration Loop (max 300) ║
245
+ ║ ║
246
+ ║ Get messages + tool specs ║
247
+ ║ ↓ ║
248
+ ║ litellm.acompletion() ║
249
+ ║ ↓ ║
250
+ ║ Has tool_calls? ──No──> Done ║
251
+ ║ │ ║
252
+ ║ Yes ║
253
+ ║ ↓ ║
254
+ ║ Add assistant msg (with tool_calls) ║
255
+ ║ ↓ ║
256
+ ║ Doom loop check ║
257
+ ║ ↓ ║
258
+ ║ For each tool_call: ║
259
+ ║ • Needs approval? ──Yes──> Wait for ║
260
+ ║ │ user confirm ║
261
+ ║ No ║
262
+ ║ ↓ ║
263
+ ║ • ToolRouter.execute_tool() ║
264
+ ║ • Add result to ContextManager ║
265
+ ║ ↓ ║
266
+ ║ Continue loop ─────────────────┐ ║
267
+ ║ ↑ │ ║
268
+ ║ └───────────────────────┘ ║
269
+ ╚═══════════════════════════════════════════╝
270
+ ```
271
+
272
+ ## Events
273
+
274
+ The agent emits the following events via `event_queue`:
275
+
276
+ - `processing` - Starting to process user input
277
+ - `ready` - Agent is ready for input
278
+ - `assistant_chunk` - Streaming token chunk
279
+ - `assistant_message` - Complete LLM response text
280
+ - `assistant_stream_end` - Token stream finished
281
+ - `tool_call` - Tool being called with arguments
282
+ - `tool_output` - Tool execution result
283
+ - `tool_log` - Informational tool log message
284
+ - `tool_state_change` - Tool execution state transition
285
+ - `approval_required` - Requesting user approval for sensitive operations
286
+ - `turn_complete` - Agent finished processing
287
+ - `error` - Error occurred during processing
288
+ - `interrupted` - Agent was interrupted
289
+ - `compacted` - Context was compacted
290
+ - `undo_complete` - Undo operation completed
291
+ - `shutdown` - Agent shutting down
292
+
293
+ ## Development
294
+
295
+ ### Adding Built-in Tools
296
+
297
+ Edit `agent/core/tools.py`:
298
+
299
+ ```python
300
+ def create_builtin_tools() -> list[ToolSpec]:
301
+ return [
302
+ ToolSpec(
303
+ name="your_tool",
304
+ description="What your tool does",
305
+ parameters={
306
+ "type": "object",
307
+ "properties": {
308
+ "param": {"type": "string", "description": "Parameter description"}
309
+ },
310
+ "required": ["param"]
311
+ },
312
+ handler=your_async_handler
313
+ ),
314
+ # ... existing tools
315
+ ]
316
+ ```
317
+
318
+ ### Adding MCP Servers
319
+
320
+ Edit `configs/cli_agent_config.json` for CLI defaults, or
321
+ `configs/frontend_agent_config.json` for web-session defaults:
322
+
323
+ ```json
324
+ {
325
+ "model_name": "anthropic/claude-sonnet-4-5-20250929",
326
+ "mcpServers": {
327
+ "your-server-name": {
328
+ "transport": "http",
329
+ "url": "https://example.com/mcp",
330
+ "headers": {
331
+ "Authorization": "Bearer ${YOUR_TOKEN}"
332
+ }
333
+ }
334
+ }
335
+ }
336
+ ```
337
+
338
+ Note: Environment variables like `${YOUR_TOKEN}` are auto-substituted from `.env`.
339
 
340
+ ```
 
 
 
backend/document_parser.py DELETED
@@ -1,319 +0,0 @@
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 DELETED
@@ -1,467 +0,0 @@
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/__init__.py ADDED
File without changes
configs/cli_agent_config.json ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_name": "zai-org/GLM-5.2:novita",
3
+ "save_sessions": true,
4
+ "session_dataset_repo": "smolagents/ml-intern-sessions",
5
+ "share_traces": true,
6
+ "personal_trace_repo_template": "{hf_user}/ml-intern-sessions",
7
+ "yolo_mode": false,
8
+ "confirm_cpu_jobs": true,
9
+ "auto_file_upload": true,
10
+ "tool_runtime": "local",
11
+ "messaging": {
12
+ "enabled": false,
13
+ "auto_event_types": ["approval_required", "error", "turn_complete"],
14
+ "destinations": {}
15
+ },
16
+ "mcpServers": {
17
+ "hf-mcp-server": {
18
+ "transport": "http",
19
+ "url": "https://huggingface.co/mcp?login"
20
+ }
21
+ }
22
+ }
configs/frontend_agent_config.json CHANGED
@@ -1,19 +1,16 @@
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
- }
 
1
  {
2
+ "model_name": "${ML_INTERN_DEFAULT_MODEL_ID:-zai-org/GLM-5.2:novita}",
 
3
  "save_sessions": true,
4
+ "session_dataset_repo": "smolagents/ml-intern-sessions",
5
  "share_traces": true,
6
+ "personal_trace_repo_template": "{hf_user}/ml-intern-sessions",
7
  "yolo_mode": false,
8
  "confirm_cpu_jobs": true,
9
  "auto_file_upload": true,
 
 
 
10
  "mcpServers": {
11
  "hf-mcp-server": {
12
  "transport": "http",
13
+ "url": "https://huggingface.co/mcp?login"
14
  }
15
  }
16
+ }
fix_space_error_report.json DELETED
@@ -1,14 +0,0 @@
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 DELETED
@@ -1,87 +0,0 @@
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 DELETED
@@ -1,74 +0,0 @@
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 DELETED
@@ -1,145 +0,0 @@
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 DELETED
@@ -1,129 +0,0 @@
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 DELETED
@@ -1,202 +0,0 @@
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 DELETED
@@ -1,185 +0,0 @@
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 DELETED
@@ -1,209 +0,0 @@
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 DELETED
@@ -1,197 +0,0 @@
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 DELETED
@@ -1,166 +0,0 @@
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 DELETED
@@ -1,167 +0,0 @@
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 DELETED
@@ -1,142 +0,0 @@
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 DELETED
@@ -1,187 +0,0 @@
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 DELETED
@@ -1,307 +0,0 @@
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 DELETED
@@ -1,229 +0,0 @@
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 DELETED
@@ -1,264 +0,0 @@
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 DELETED
@@ -1,252 +0,0 @@
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 DELETED
@@ -1,243 +0,0 @@
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 DELETED
@@ -1,255 +0,0 @@
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 DELETED
@@ -1,239 +0,0 @@
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 DELETED
@@ -1,227 +0,0 @@
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 DELETED
@@ -1,182 +0,0 @@
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_context_restore.py DELETED
@@ -1,188 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Patch: Restore full session context from the permanent HF dataset.
4
-
5
- Fixes two related bugs in bep40/ml-intern (ML Intern V2 Space):
6
-
7
- 1. "Catch me up" banner appears constantly after a Space restart.
8
- The backend only loads sessions from get_session_store() (Mongo/Noop).
9
- On HF Spaces there is no MONGODB_URI, so NoopSessionStore.load_session()
10
- always returns None -> every previously-open session is treated as dead
11
- -> the frontend shows "Where were we? / Catch me up" for ALL old
12
- sessions, even though the full conversation is safely stored in the
13
- bep40/ml-intern-sessions dataset.
14
-
15
- 2. After clicking "Catch me up", the model forgets prior context.
16
- seed_from_summary() creates a brand-new session and seeds it with only a
17
- lossy LLM summary (or raw messages that then get pruned). The model
18
- never sees the full prior conversation, so it genuinely does not
19
- remember.
20
-
21
- Fix (both in backend/session_manager.py):
22
- - ensure_session_loaded(): when the persistence store has no session,
23
- fall back to the permanent HF dataset (backend.session_logger.
24
- load_session_history) and re-materialize the session with the FULL
25
- conversation, so old sessions come back alive with complete context.
26
- - seed_from_summary(): if the dataset has the full history for the old
27
- session, seed the new session with the full conversation instead of a
28
- lossy summary (falling back to the summary path when unavailable).
29
-
30
- Runs during Docker build; never fails the build.
31
- """
32
-
33
- import ast
34
- import os
35
-
36
- PATCHED_MARKER = "# === PATCH: dataset-context-restore ==="
37
-
38
-
39
- def _read(path: str) -> str:
40
- with open(path, encoding="utf-8") as f:
41
- return f.read()
42
-
43
-
44
- def _write(path: str, content: str) -> None:
45
- with open(path, "w", encoding="utf-8") as f:
46
- f.write(content)
47
-
48
-
49
- def _replace(content: str, old: str, new: str) -> tuple[str, bool]:
50
- if old in content:
51
- return content.replace(old, new, 1), True
52
- return content, False
53
-
54
-
55
- SESSION_MANAGER = "/app/backend/session_manager.py"
56
-
57
- if os.path.exists(SESSION_MANAGER):
58
- content = _read(SESSION_MANAGER)
59
- changed = False
60
-
61
- if PATCHED_MARKER in content:
62
- print("SKIP: session_manager.py already patched")
63
- else:
64
- # ---- 1) Dataset fallback in ensure_session_loaded -----------------
65
- old_load = (
66
- " store = self._store()\n"
67
- " loaded = await store.load_session(session_id)\n"
68
- " if not loaded:\n"
69
- " return None\n"
70
- )
71
- new_load = (
72
- " store = self._store()\n"
73
- " loaded = await store.load_session(session_id)\n"
74
- " if not loaded:\n"
75
- " # === PATCH: dataset-context-restore ===\n"
76
- " # The persistence store has no record for this session\n"
77
- " # (typical on HF Spaces: no MONGODB_URI -> NoopSessionStore).\n"
78
- " # Before declaring the session dead, try the permanent HF\n"
79
- " # dataset so old sessions survive a Space restart with\n"
80
- " # their FULL context intact.\n"
81
- " try:\n"
82
- " from backend.session_logger import load_session_history\n"
83
- " ds_rows = load_session_history(session_id)\n"
84
- " ds_messages = [\n"
85
- " r for r in ds_rows\n"
86
- " if r.get(\"role\") in (\"user\", \"assistant\", \"tool\")\n"
87
- " ]\n"
88
- " if ds_messages:\n"
89
- " loaded = {\n"
90
- " \"metadata\": {\n"
91
- " \"user_id\": user_id,\n"
92
- " \"model\": next(\n"
93
- " (r.get(\"model_name\") for r in ds_rows if r.get(\"model_name\")),\n"
94
- " self.config.model_name,\n"
95
- " ),\n"
96
- " },\n"
97
- " \"messages\": ds_messages,\n"
98
- " }\n"
99
- " logger.info(\n"
100
- " \"Restored session %s from HF dataset (%d messages)\",\n"
101
- " session_id, len(ds_messages),\n"
102
- " )\n"
103
- " except Exception as e:\n"
104
- " logger.debug(\"Dataset restore fallback failed for %s: %s\", session_id, e)\n"
105
- " if not loaded:\n"
106
- " return None\n"
107
- )
108
- content, ok = _replace(content, old_load, new_load)
109
- if ok:
110
- print("+ ensure_session_loaded falls back to HF dataset")
111
- changed = True
112
- else:
113
- print("INFO: store.load fallback anchor not found")
114
-
115
- # ---- 2) seed_from_summary: prefer full dataset history ------------
116
- old_seed = (
117
- " seed = Message(\n"
118
- " role=\"user\",\n"
119
- " content=(\n"
120
- " \"[SYSTEM: Your prior memory of this conversation \u2014 written \"\n"
121
- " \"in your own voice right before restart. Continue from here.]\\n\\n\"\n"
122
- " + (summary or \"(no summary returned)\")\n"
123
- " ),\n"
124
- " )\n"
125
- )
126
- new_seed = (
127
- " # === PATCH: dataset-context-restore ===\n"
128
- " # If the permanent dataset has the full history for this\n"
129
- " # session, seed the new session with the ACTUAL conversation\n"
130
- " # instead of a lossy summary so the model really remembers.\n"
131
- " seeded_from_dataset = 0\n"
132
- " try:\n"
133
- " from backend.session_logger import load_session_history\n"
134
- " ds_rows = load_session_history(session_id)\n"
135
- " full_msgs = [\n"
136
- " r for r in ds_rows\n"
137
- " if r.get(\"role\") in (\"user\", \"assistant\", \"tool\")\n"
138
- " ]\n"
139
- " if full_msgs:\n"
140
- " for raw in full_msgs:\n"
141
- " try:\n"
142
- " session.context_manager.items.append(Message.model_validate(raw))\n"
143
- " seeded_from_dataset += 1\n"
144
- " except Exception as e:\n"
145
- " logger.warning(\"Dropping dataset msg during seed: %s\", e)\n"
146
- " except Exception as e:\n"
147
- " logger.debug(\"Dataset seed fallback failed: %s\", e)\n"
148
- "\n"
149
- " if seeded_from_dataset:\n"
150
- " self._touch(agent_session)\n"
151
- " await self.persist_session_snapshot(agent_session, runtime_state=\"idle\")\n"
152
- " return seeded_from_dataset\n"
153
- "\n"
154
- " seed = Message(\n"
155
- " role=\"user\",\n"
156
- " content=(\n"
157
- " \"[SYSTEM: Your prior memory of this conversation \u2014 written \"\n"
158
- " \"in your own voice right before restart. Continue from here.]\\n\\n\"\n"
159
- " + (summary or \"(no summary returned)\")\n"
160
- " ),\n"
161
- " )\n"
162
- )
163
- content, ok = _replace(content, old_seed, new_seed)
164
- if ok:
165
- print("+ seed_from_summary restores full dataset history")
166
- changed = True
167
- else:
168
- print("INFO: seed block anchor not found")
169
-
170
- if changed:
171
- try:
172
- ast.parse(content)
173
- _write(SESSION_MANAGER, content)
174
- print("OK: session_manager.py patched (dataset context restore)")
175
- except SyntaxError as e:
176
- print("FAIL: session_manager.py syntax error:", e)
177
- else:
178
- print("SKIP: no changes applied")
179
-
180
- # ---- Verify importability of patched modules -----------------------------
181
- try:
182
- import ast as _ast
183
- for f in ["/app/backend/session_manager.py", "/app/backend/session_logger.py", "/app/backend/routes/agent.py"]:
184
- if os.path.exists(f):
185
- _ast.parse(open(f, encoding="utf-8").read())
186
- print("SYNTAX OK:", f)
187
- except Exception as e:
188
- print("VERIFY ERR:", e)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_catch_up_dataset_fallback.py DELETED
@@ -1,102 +0,0 @@
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_catch_up_fix_backend.py DELETED
@@ -1,220 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Patch: Fix catch-up backend robustness.
4
-
5
- Issues fixed:
6
- 1. /api/session-history/{session_id} returns ALL rows including 'event' type
7
- rows (role='event'). These cause Message.model_validate to silently drop them.
8
- Fix: Filter out non-message roles (event, system) from history endpoint.
9
-
10
- 2. seed_from_summary raises if summarization fails (API error, rate limit, credits),
11
- causing restore-summary to return 500 and the frontend to show "Couldn't catch up."
12
- Fix: Fall back to raw messages if summarization fails.
13
-
14
- 3. Event rows with role='event' fail Message.model_validate.
15
- Fix: Skip event rows explicitly in seed_from_summary.
16
- """
17
-
18
- import ast
19
- import os
20
- import sys
21
-
22
- PATCHED_MARKER = "# === PATCH: catch-up fix (backend) ==="
23
-
24
-
25
- def _safe_replace(content, old, new, count=1):
26
- if old in content:
27
- content = content.replace(old, new, count)
28
- return content, True
29
- return content, False
30
-
31
-
32
- def patch_file(path, patch_fn):
33
- if not os.path.exists(path):
34
- print(f"SKIP: {path} not found")
35
- return False
36
- try:
37
- with open(path, "r") as f:
38
- content = f.read()
39
- content, changed = patch_fn(content)
40
- if not changed:
41
- print(f"SKIP: {path} — no changes needed")
42
- return False
43
- try:
44
- ast.parse(content)
45
- except SyntaxError as e:
46
- print(f"FAIL: {path} syntax: {e}")
47
- return False
48
- with open(path, "w") as f:
49
- f.write(content)
50
- print(f"OK: {path} patched (catch-up fix)")
51
- return True
52
- except Exception as e:
53
- print(f"WARN: {path} patch error: {e}")
54
- return False
55
-
56
-
57
- # ─── Patch routes/agent.py: filter event rows from session-history ─────────
58
- print("=== Patching routes/agent.py (session-history filter) ===")
59
-
60
- ROUTES_AGENT = "/app/backend/routes/agent.py"
61
-
62
- def _patch_routes(content):
63
- changed = False
64
-
65
- if PATCHED_MARKER in content:
66
- print(" SKIP: already patched")
67
- return content, False
68
-
69
- # Patch the get_session_history endpoint to filter out event rows
70
- old_endpoint = (
71
- '@router.get("/session-history/{session_id}")\n'
72
- 'async def get_session_history(\n'
73
- ' session_id: str, user: dict = Depends(get_current_user)\n'
74
- ') -> list:\n'
75
- ' """Get full message history for a session from the permanent dataset."""\n'
76
- ' from backend.session_logger import load_session_history\n'
77
- ' return load_session_history(session_id)'
78
- )
79
-
80
- new_endpoint = (
81
- '@router.get("/session-history/{session_id}")\n'
82
- 'async def get_session_history(\n'
83
- ' session_id: str, user: dict = Depends(get_current_user)\n'
84
- ') -> list:\n'
85
- ' """Get full message history for a session from the permanent dataset.\n'
86
- '\n'
87
- ' Returns only valid conversation messages (user/assistant/tool roles).\n'
88
- " Event rows (role='event') are filtered out because they are internal\n"
89
- ' tracing data that cannot be parsed as Message objects and would be\n'
90
- ' silently dropped during catch-up, causing message loss.\n'
91
- ' """\n'
92
- ' from backend.session_logger import load_session_history\n'
93
- ' rows = load_session_history(session_id)\n'
94
- ' # Filter out event rows and any rows without valid roles\n'
95
- ' valid_roles = {"user", "assistant", "tool"}\n'
96
- ' return [r for r in rows if r.get("role") in valid_roles]'
97
- )
98
-
99
- content, ok = _safe_replace(content, old_endpoint, new_endpoint)
100
- if ok:
101
- print(" + Filtered event rows from session-history endpoint")
102
- changed = True
103
- else:
104
- print(" SKIP: target block not found")
105
-
106
- # Add marker
107
- content = content.rstrip() + "\n\n" + PATCHED_MARKER + "\n"
108
- changed = True
109
-
110
- return content, changed
111
-
112
- patch_file(ROUTES_AGENT, _patch_routes)
113
-
114
- # ─── Patch session_manager.py: robust seed_from_summary ─────────────────────
115
- print("\n=== Patching session_manager.py (seed_from_summary robustness) ===")
116
-
117
- SESSION_MANAGER = "/app/backend/session_manager.py"
118
-
119
- def _patch_seed_from_summary(content):
120
- changed = False
121
-
122
- # Check if already patched
123
- if PATCHED_MARKER in content:
124
- print(" SKIP: already patched")
125
- return content, False
126
-
127
- # Patch 1: Skip event rows explicitly in seed_from_summary
128
- old_parse = (
129
- ' # Parse into Message objects, tolerating malformed entries.\n'
130
- ' parsed: list[Message] = []\n'
131
- ' for raw in messages:\n'
132
- ' if raw.get("role") == "system":\n'
133
- ' continue # the new session has its own system prompt\n'
134
- ' try:\n'
135
- ' parsed.append(Message.model_validate(raw))\n'
136
- ' except Exception as e:\n'
137
- ' logger.warning("Dropping malformed message during seed: %s", e)'
138
- )
139
-
140
- new_parse = (
141
- ' # Parse into Message objects, tolerating malformed entries.\n'
142
- ' # === PATCH: skip event rows (not just system) ===\n'
143
- ' parsed: list[Message] = []\n'
144
- ' for raw in messages:\n'
145
- ' role = raw.get("role", "")\n'
146
- ' if role in ("system", "event"):\n'
147
- ' continue # not conversation messages\n'
148
- ' try:\n'
149
- ' parsed.append(Message.model_validate(raw))\n'
150
- ' except Exception as e:\n'
151
- ' logger.warning("Dropping malformed message during seed: %s", e)'
152
- )
153
-
154
- content, ok = _safe_replace(content, old_parse, new_parse)
155
- if ok:
156
- print(" + Skip event rows explicitly in seed_from_summary")
157
- changed = True
158
-
159
- # Patch 2: Fall back to raw messages if summarization fails
160
- old_try = (
161
- ' try:\n'
162
- ' summary, _ = await summarize_messages(\n'
163
- ' parsed,\n'
164
- ' model_name=session.config.model_name,\n'
165
- ' hf_token=session.hf_token,\n'
166
- ' max_tokens=4000,\n'
167
- ' prompt=_RESTORE_PROMPT,\n'
168
- ' tool_specs=tool_specs,\n'
169
- ' session=session,\n'
170
- ' kind="restore",\n'
171
- ' )\n'
172
- ' except Exception as e:\n'
173
- ' logger.error("Summary call failed during seed: %s", e)\n'
174
- ' raise\n'
175
- '\n'
176
- ' seed = Message('
177
- )
178
-
179
- new_try = (
180
- ' # === PATCH: fall back to raw messages if summarization fails ===\n'
181
- ' summary = None\n'
182
- ' try:\n'
183
- ' summary, _ = await summarize_messages(\n'
184
- ' parsed,\n'
185
- ' model_name=session.config.model_name,\n'
186
- ' hf_token=session.hf_token,\n'
187
- ' max_tokens=4000,\n'
188
- ' prompt=_RESTORE_PROMPT,\n'
189
- ' tool_specs=tool_specs,\n'
190
- ' session=session,\n'
191
- ' kind="restore",\n'
192
- ' )\n'
193
- ' except Exception as e:\n'
194
- ' logger.error("Summary call failed during seed, falling back to raw messages: %s", e)\n'
195
- ' # Fall back: inject raw messages directly into the new session context\n'
196
- ' # This preserves the full conversation instead of losing it\n'
197
- ' for msg in parsed:\n'
198
- ' session.context_manager.items.append(msg)\n'
199
- ' self._touch(agent_session)\n'
200
- ' await self.persist_session_snapshot(agent_session, runtime_state="idle")\n'
201
- ' return len(parsed)\n'
202
- '\n'
203
- ' seed = Message('
204
- )
205
-
206
- content, ok = _safe_replace(content, old_try, new_try)
207
- if ok:
208
- print(" + Fall back to raw messages if summarization fails")
209
- changed = True
210
-
211
- # Add marker
212
- if changed:
213
- content = content + "\n" + PATCHED_MARKER + "\n"
214
- changed = True
215
-
216
- return content, changed
217
-
218
- patch_file(SESSION_MANAGER, _patch_seed_from_summary)
219
-
220
- print("\n=== Backend catch-up fixes applied ===")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_catch_up_fix_frontend.py DELETED
@@ -1,289 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Patch: Fix frontend catch-up message loss.
4
-
5
- Issue: After catch-up succeeds, the new session's backend hydration
6
- fetches /api/session/{newId}/messages from the backend, which only
7
- contains the summary message. It then calls setMessages() and
8
- saveMessages(), OVERWRITING the old full messages that were moved to
9
- the new ID via renameSession -> moveMessages.
10
-
11
- Fix: In each hydration code path, only overwrite frontend messages if
12
- the backend returns MORE non-system messages than the frontend already has.
13
- If the frontend already has more messages (from localStorage), keep them.
14
-
15
- Also: Fix the catch-up dataset fallback in ExpiredBanner to properly
16
- filter event rows and handle malformed data.
17
-
18
- Runs during Docker build — never fails the build.
19
- """
20
-
21
- import ast
22
- import os
23
- import sys
24
-
25
- PATCHED_MARKER = "// PATCH: catch-up fix (frontend)"
26
-
27
-
28
- def _safe_replace(content, old, new, count=1):
29
- if old in content:
30
- content = content.replace(old, new, count)
31
- return content, True
32
- return content, False
33
-
34
-
35
- def _is_python_file(path):
36
- return path.endswith(".py")
37
-
38
-
39
- def patch_file(path, patch_fn):
40
- if not os.path.exists(path):
41
- print(f"SKIP: {path} not found")
42
- return False
43
- try:
44
- with open(path, "r", encoding="utf-8") as f:
45
- content = f.read()
46
- content, changed = patch_fn(content)
47
- if not changed:
48
- print(f"SKIP: {path} — no changes needed")
49
- return False
50
- # Only validate Python syntax for .py files
51
- if _is_python_file(path):
52
- try:
53
- ast.parse(content)
54
- except SyntaxError as e:
55
- print(f"FAIL: {path} syntax: {e}")
56
- return False
57
- with open(path, "w", encoding="utf-8") as f:
58
- f.write(content)
59
- print(f"OK: {path} patched (catch-up fix)")
60
- return True
61
- except Exception as e:
62
- print(f"WARN: {path} patch error: {e}")
63
- return False
64
-
65
-
66
- # ─── Patch useAgentChat.ts ──────────────────────────────────────────────────
67
- print("=== Patching useAgentChat.ts (prevent message overwrite) ===")
68
-
69
- USE_AGENT_CHAT = "/source/frontend/src/hooks/useAgentChat.ts"
70
-
71
- def _patch_use_agent_chat(content):
72
- changed = False
73
-
74
- if PATCHED_MARKER in content:
75
- print(" SKIP: already patched")
76
- return content, False
77
-
78
- # Patch 1: onRecoverMessages
79
- # The pattern uses 10-space indentation (inside a nested callback)
80
- old_pattern1 = (
81
- " const setMsgs = chatActionsRef.current.setMessages;\n"
82
- " if (setMsgs && uiMsgs.length >= currentMessageCount) {\n"
83
- " setMsgs(uiMsgs);\n"
84
- " saveMessages(sessionId, uiMsgs);\n"
85
- " }"
86
- )
87
-
88
- new_pattern1 = (
89
- " // === PATCH: catch-up fix (frontend) ===\n"
90
- " // After catch-up, the backend only has a summary message but\n"
91
- " // the frontend (via localStorage moveMessages) has the full\n"
92
- " // old conversation. Don't overwrite if backend has fewer.\n"
93
- " const _currentNonSystem = chatActionsRef.current.messages.filter(\n"
94
- " (m) => !(m.parts?.[0]?.type === 'text' && typeof m.parts?.[0]?.text === 'string' && m.parts?.[0].text.trimStart().startsWith('[SYSTEM:'))\n"
95
- " );\n"
96
- " const _shouldOverwrite = uiMsgs.length > _currentNonSystem.length || _currentNonSystem.length === 0;\n"
97
- " const setMsgs = chatActionsRef.current.setMessages;\n"
98
- " if (setMsgs && _shouldOverwrite) {\n"
99
- " setMsgs(uiMsgs);\n"
100
- " saveMessages(sessionId, uiMsgs);\n"
101
- " }"
102
- )
103
-
104
- content, ok = _safe_replace(content, old_pattern1, new_pattern1)
105
- if ok:
106
- print(" + Prevented backend overwrite in onRecoverMessages")
107
- changed = True
108
- else:
109
- print(" INFO: onRecoverMessages pattern not found")
110
-
111
- # Patch 2: refreshMessages
112
- # The pattern uses 6-space indentation and ends with 'return true;'
113
- old_pattern2 = (
114
- " const setMsgs = chatActionsRef.current.setMessages;\n"
115
- " if (setMsgs && uiMsgs.length > 0) {\n"
116
- " setMsgs(uiMsgs);\n"
117
- " saveMessages(sessionId, uiMsgs);\n"
118
- " }\n"
119
- " return true;"
120
- )
121
-
122
- new_pattern2 = (
123
- " // === PATCH: catch-up fix (frontend) ===\n"
124
- " const _currentNonSystem2 = chatActionsRef.current.messages.filter(\n"
125
- " (m) => !(m.parts?.[0]?.type === 'text' && typeof m.parts?.[0]?.text === 'string' && m.parts?.[0].text.trimStart().startsWith('[SYSTEM:'))\n"
126
- " );\n"
127
- " const _shouldOverwrite2 = uiMsgs.length > _currentNonSystem2.length || _currentNonSystem2.length === 0;\n"
128
- " const setMsgs = chatActionsRef.current.setMessages;\n"
129
- " if (setMsgs && _shouldOverwrite2) {\n"
130
- " setMsgs(uiMsgs);\n"
131
- " saveMessages(sessionId, uiMsgs);\n"
132
- " }\n"
133
- " return true;"
134
- )
135
-
136
- content, ok2 = _safe_replace(content, old_pattern2, new_pattern2)
137
- if ok2:
138
- print(" + Prevented backend overwrite in refreshMessages")
139
- changed = True
140
- else:
141
- print(" INFO: refreshMessages pattern not found")
142
-
143
- # Patch 3: Main hydration useEffect (uses chat.setMessages)
144
- # There are multiple code paths with different indentation levels:
145
- # - 6-space indent: in some code paths
146
- # - 10-space indent: main hydration useEffect
147
- # - 14-space indent: hydrateMessages helper (appears twice)
148
- # We patch each indentation level separately.
149
-
150
- old_pattern3a = (
151
- " if (uiMsgs.length > 0) {\n"
152
- " chat.setMessages(uiMsgs);\n"
153
- " saveMessages(sessionId, uiMsgs);\n"
154
- " }"
155
- )
156
-
157
- new_pattern3a = (
158
- " // === PATCH: catch-up fix (frontend) ===\n"
159
- " // Don't overwrite frontend messages with backend's fewer messages\n"
160
- " const _currentMsgs = (chatActionsRef.current.messages || []).filter(\n"
161
- " (m) => !(m.parts?.[0]?.type === 'text' && typeof m.parts?.[0]?.text === 'string' && m.parts?.[0].text.trimStart().startsWith('[SYSTEM:'))\n"
162
- " );\n"
163
- " const _shouldSet = uiMsgs.length > _currentMsgs.length || _currentMsgs.length === 0;\n"
164
- " if (_shouldSet) {\n"
165
- " chat.setMessages(uiMsgs);\n"
166
- " saveMessages(sessionId, uiMsgs);\n"
167
- " }"
168
- )
169
-
170
- # 10-space indentation variant
171
- old_pattern3b = (
172
- " if (uiMsgs.length > 0) {\n"
173
- " chat.setMessages(uiMsgs);\n"
174
- " saveMessages(sessionId, uiMsgs);\n"
175
- " }"
176
- )
177
-
178
- new_pattern3b = (
179
- " // === PATCH: catch-up fix (frontend) ===\n"
180
- " const _currentMsgsB = (chatActionsRef.current.messages || []).filter(\n"
181
- " (m) => !(m.parts?.[0]?.type === 'text' && typeof m.parts?.[0]?.text === 'string' && m.parts?.[0].text.trimStart().startsWith('[SYSTEM:'))\n"
182
- " );\n"
183
- " const _shouldSetB = uiMsgs.length > _currentMsgsB.length || _currentMsgsB.length === 0;\n"
184
- " if (_shouldSetB) {\n"
185
- " chat.setMessages(uiMsgs);\n"
186
- " saveMessages(sessionId, uiMsgs);\n"
187
- " }"
188
- )
189
-
190
- # 14-space indentation variant
191
- old_pattern3c = (
192
- " if (uiMsgs.length > 0) {\n"
193
- " chat.setMessages(uiMsgs);\n"
194
- " saveMessages(sessionId, uiMsgs);\n"
195
- " }"
196
- )
197
-
198
- new_pattern3c = (
199
- " // === PATCH: catch-up fix (frontend) ===\n"
200
- " const _currentMsgsC = (chatActionsRef.current.messages || []).filter(\n"
201
- " (m) => !(m.parts?.[0]?.type === 'text' && typeof m.parts?.[0]?.text === 'string' && m.parts?.[0].text.trimStart().startsWith('[SYSTEM:'))\n"
202
- " );\n"
203
- " const _shouldSetC = uiMsgs.length > _currentMsgsC.length || _currentMsgsC.length === 0;\n"
204
- " if (_shouldSetC) {\n"
205
- " chat.setMessages(uiMsgs);\n"
206
- " saveMessages(sessionId, uiMsgs);\n"
207
- " }"
208
- )
209
-
210
- count = 0
211
- for old_p, new_p in [(old_pattern3a, new_pattern3a), (old_pattern3b, new_pattern3b), (old_pattern3c, new_pattern3c)]:
212
- while True:
213
- content, ok3 = _safe_replace(content, old_p, new_p)
214
- if ok3:
215
- count += 1
216
- changed = True
217
- else:
218
- break
219
- if count > 0:
220
- print(f" + Prevented backend overwrite in {count} hydration code paths")
221
-
222
- if changed:
223
- content = content.replace(
224
- "import { logger } from '@/utils/logger';",
225
- "import { logger } from '@/utils/logger';\n// " + PATCHED_MARKER + " applied",
226
- 1
227
- )
228
-
229
- return content, changed
230
-
231
- patch_file(USE_AGENT_CHAT, _patch_use_agent_chat)
232
-
233
- # ─── Patch ExpiredBanner.tsx: filter event rows in dataset fallback ─────────
234
- print("\n=== Patching ExpiredBanner.tsx (dataset fallback filter) ===")
235
-
236
- EXPIRED_BANNER = "/source/frontend/src/components/Chat/ExpiredBanner.tsx"
237
-
238
- def _patch_expired_banner(content):
239
- changed = False
240
-
241
- if PATCHED_MARKER in content:
242
- print(" SKIP: already patched")
243
- return content, False
244
-
245
- # Check if the dataset fallback is present (from patch_catch_up_dataset_fallback.py)
246
- if "/api/session-history/" not in content:
247
- print(" SKIP: no dataset fallback found in ExpiredBanner.tsx")
248
- return content, False
249
-
250
- # Patch the dataset fallback mapping to filter event rows
251
- old_mapping = (
252
- " messages = history.map((row: any) => ({\n"
253
- " role: row.role || 'assistant',\n"
254
- " content: row.content || '',\n"
255
- " }));"
256
- )
257
-
258
- new_mapping = (
259
- " // === PATCH: catch-up fix (frontend) ===\n"
260
- " // Filter out 'event' rows — they are internal tracing\n"
261
- " // data that cannot be parsed as Message objects\n"
262
- " const validRoles = new Set(['user', 'assistant', 'tool']);\n"
263
- " messages = history\n"
264
- " .filter((row: any) => validRoles.has(row.role))\n"
265
- " .map((row: any) => ({\n"
266
- " role: row.role,\n"
267
- " content: row.content || '',\n"
268
- " }));"
269
- )
270
-
271
- content, ok = _safe_replace(content, old_mapping, new_mapping)
272
- if ok:
273
- print(" + Filtered event rows in dataset fallback")
274
- changed = True
275
- else:
276
- print(" INFO: dataset fallback mapping pattern not found")
277
-
278
- if changed:
279
- content = content.replace(
280
- "import { logger } from '@/utils/logger';",
281
- "import { logger } from '@/utils/logger';\n// " + PATCHED_MARKER + " applied",
282
- 1
283
- )
284
-
285
- return content, changed
286
-
287
- patch_file(EXPIRED_BANNER, _patch_expired_banner)
288
-
289
- print("\n=== Frontend catch-up fixes applied ===")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_catch_up_fix_v2.py DELETED
@@ -1,758 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Patch v2: Comprehensive catch-up + shareable URL fix for bep40/ml-intern.
4
-
5
- Addresses three problems:
6
- 1. "Catch me up" throws errors frequently
7
- 2. Context is lost after catch-up (context preserved in session but not restored)
8
- 3. Per-session shareable URLs
9
-
10
- Runs during Docker build — never fails the build.
11
- """
12
-
13
- import ast
14
- import os
15
- import re
16
- import sys
17
-
18
- # ─── Backend paths (exist in the Python/stage-2 build) ───────────────────────
19
-
20
- BACKEND_SESSION_MANAGER = "/app/backend/session_manager.py"
21
- BACKEND_SESSION_LOGGER = "/app/backend/session_logger.py"
22
- BACKEND_ROUTES_AGENT = "/app/backend/routes/agent.py"
23
-
24
- MARKER = "# === PATCH: catch-up-v2 ==="
25
-
26
-
27
- def _read(path):
28
- with open(path, "r", encoding="utf-8") as f:
29
- return f.read()
30
-
31
-
32
- def _write(path, content):
33
- with open(path, "w", encoding="utf-8") as f:
34
- f.write(content)
35
-
36
-
37
- def _safe_replace(content, old, new):
38
- if old in content:
39
- return content.replace(old, new, 1), True
40
- return content, False
41
-
42
-
43
- # ─── Backend session_manager.py ─────────────────────────────────────────────
44
-
45
- print("=== Patching session_manager.py ===")
46
-
47
- if os.path.exists(BACKEND_SESSION_MANAGER):
48
- content = _read(BACKEND_SESSION_MANAGER)
49
- changed = False
50
-
51
- # 1. seed_from_summary: fall back to raw messages on summary failure
52
- if 'logger.error("Summary call failed during seed: %s", e)' in content and "Fall back to raw messages" not in content:
53
- old = (
54
- ' except Exception as e:\n'
55
- ' logger.error("Summary call failed during seed: %s", e)\n'
56
- ' raise\n'
57
- )
58
- new = (
59
- ' except Exception as e:\n'
60
- ' logger.error("Summary call failed during seed, falling back to raw messages: %s", e)\n'
61
- ' for msg in parsed:\n'
62
- ' session.context_manager.items.append(msg)\n'
63
- ' self._touch(agent_session)\n'
64
- ' await self.persist_session_snapshot(agent_session, runtime_state="idle")\n'
65
- ' return len(parsed)\n'
66
- )
67
- if old in content:
68
- content, ok = _safe_replace(content, old, new)
69
- if ok:
70
- print(" + seed_from_summary falls back to raw messages")
71
- changed = True
72
- else:
73
- # Try regex match for the pattern
74
- pattern = re.compile(
75
- r' logger\.error\("Summary call failed during seed: %s", e\)\n raise\n'
76
- )
77
- match = pattern.search(content)
78
- if match:
79
- content = pattern.sub(
80
- ' logger.error("Summary call failed during seed, falling back to raw messages: %s", e)\n'
81
- ' for msg in parsed:\n'
82
- ' session.context_manager.items.append(msg)\n'
83
- ' self._touch(agent_session)\n'
84
- ' await self.persist_session_snapshot(agent_session, runtime_state="idle")\n'
85
- ' return len(parsed)\n',
86
- content, count=1
87
- )
88
- print(" + seed_from_summary fallback (regex match)")
89
- changed = True
90
-
91
- # 2. seed_from_summary: skip event rows
92
- if "skip event rows" not in content.lower():
93
- old_parse = (
94
- ' parsed: list[Message] = []\n'
95
- ' for raw in messages:\n'
96
- ' if raw.get("role") == "system":\n'
97
- ' continue # the new session has its own system prompt\n'
98
- )
99
- new_parse = (
100
- ' parsed: list[Message] = []\n'
101
- ' for raw in messages:\n'
102
- ' role = raw.get("role", "")\n'
103
- ' if role in ("system", "event"):\n'
104
- ' continue # not conversation messages\n'
105
- )
106
- if old_parse in content:
107
- content, ok = _safe_replace(content, old_parse, new_parse)
108
- if ok:
109
- print(" + seed_from_summary skips event rows")
110
- changed = True
111
-
112
- # 3. Add record_catchup_done method
113
- if "def record_catchup_done" not in content:
114
- insert_after = " return len(parsed)\n\n @staticmethod"
115
- if insert_after in content:
116
- new_method = (
117
- " return len(parsed)\n\n"
118
- " async def record_catchup_done(self, session_id: str) -> None:\n"
119
- " \"\"\"Record that a session was successfully catch-up restored.\"\"\"\n"
120
- " store = self._store()\n"
121
- " if not getattr(store, 'enabled', False):\n"
122
- " return\n"
123
- " try:\n"
124
- " await store.update_session_fields(\n"
125
- " session_id,\n"
126
- " catchup_done=True,\n"
127
- " )\n"
128
- " except Exception as e:\n"
129
- " logger.debug('record_catchup_done failed: %s', e)\n\n"
130
- " @staticmethod"
131
- )
132
- content, ok = _safe_replace(content, insert_after, new_method)
133
- if ok:
134
- print(" + Added record_catchup_done method")
135
- changed = True
136
-
137
- # 4. Record catchup_done after seed
138
- old_seed_done = (
139
- ' session.context_manager.items.append(seed)\n'
140
- ' self._touch(agent_session)\n'
141
- ' await self.persist_session_snapshot(agent_session, runtime_state="idle")\n'
142
- ' return len(parsed)\n'
143
- )
144
- new_seed_done = (
145
- ' session.context_manager.items.append(seed)\n'
146
- ' self._touch(agent_session)\n'
147
- ' await self.persist_session_snapshot(agent_session, runtime_state="idle")\n'
148
- ' # === PATCH: catch-up-v2 ===\n'
149
- ' try:\n'
150
- ' await self.record_catchup_done(session_id)\n'
151
- ' except Exception as e:\n'
152
- ' logger.debug("record_catchup_done failed: %s", e)\n'
153
- ' return len(parsed)\n'
154
- )
155
- if old_seed_done in content:
156
- content, ok = _safe_replace(content, old_seed_done, new_seed_done)
157
- if ok:
158
- print(" + seed_from_summary records catchup_done")
159
- changed = True
160
-
161
- if changed:
162
- if MARKER not in content:
163
- content = content + "\n" + MARKER + "\n"
164
- try:
165
- ast.parse(content)
166
- _write(BACKEND_SESSION_MANAGER, content)
167
- print("OK: session_manager.py patched")
168
- except SyntaxError as e:
169
- print(f"FAIL: session_manager.py syntax: {e}")
170
- else:
171
- print("SKIP: session_manager.py not found")
172
-
173
-
174
- # ─── Backend session_logger.py ───────────────────────────────────────────────
175
-
176
- print("\n=== Patching session_logger.py ===")
177
-
178
- if os.path.exists(BACKEND_SESSION_LOGGER):
179
- content = _read(BACKEND_SESSION_LOGGER)
180
- changed = False
181
-
182
- # load_session_history: filter event rows at source
183
- sort_line = ' rows.sort(key=lambda r: (r.get("seq", 0), r.get("timestamp", "")))'
184
- if sort_line in content and 'valid_roles' not in content.split(sort_line, 1)[1][:300]:
185
- idx = content.find(sort_line)
186
- end_idx = content.find("\n", idx) + 1
187
- insert_code = (
188
- ' # === PATCH: catch-up-v2 ===\n'
189
- ' # Filter out event/system rows so summarization never sees\n'
190
- ' # internal tracing data that cant be parsed as Message objects.\n'
191
- ' valid_roles = {"user", "assistant", "tool"}\n'
192
- ' rows = [r for r in rows if r.get("role") in valid_roles]\n'
193
- )
194
- content = content[:end_idx] + insert_code + content[end_idx:]
195
- print(" + load_session_history filters event rows")
196
- changed = True
197
-
198
- if changed:
199
- try:
200
- ast.parse(content)
201
- _write(BACKEND_SESSION_LOGGER, content)
202
- print("OK: session_logger.py patched")
203
- except SyntaxError as e:
204
- print(f"FAIL: session_logger.py syntax: {e}")
205
- else:
206
- print("SKIP: session_logger.py not found")
207
-
208
-
209
- # ─── Backend routes/agent.py ─────────────────────────────────────────────────
210
-
211
- print("\n=== Patching routes/agent.py ===")
212
-
213
- if os.path.exists(BACKEND_ROUTES_AGENT):
214
- content = _read(BACKEND_ROUTES_AGENT)
215
- changed = False
216
-
217
- # Ensure session-history endpoint filters events
218
- old_history = (
219
- ' from backend.session_logger import load_session_history\n'
220
- ' return load_session_history(session_id)'
221
- )
222
- new_history = (
223
- ' from backend.session_logger import load_session_history\n'
224
- ' rows = load_session_history(session_id)\n'
225
- ' valid_roles = {"user", "assistant", "tool"}\n'
226
- ' return [r for r in rows if r.get("role") in valid_roles]'
227
- )
228
- if old_history in content:
229
- content, ok = _safe_replace(content, old_history, new_history)
230
- if ok:
231
- print(" + session-history endpoint filters event rows")
232
- changed = True
233
-
234
- # Add catchup-done endpoint
235
- if "/session/{session_id}/catchup-done" not in content:
236
- catchup_endpoint = '''
237
-
238
- @router.post("/session/{session_id}/catchup-done")
239
- async def mark_catchup_done(
240
- session_id: str, user: dict = Depends(get_current_user)
241
- ) -> dict:
242
- """Mark that a session was successfully catch-up restored.
243
-
244
- The frontend calls this after a successful catch-up so the backend
245
- can track which sessions were restored from summary vs. fresh.
246
- """
247
- await _check_session_access(session_id, user, preload_sandbox=False)
248
- try:
249
- await session_manager.record_catchup_done(session_id)
250
- except Exception as e:
251
- logger.warning("record_catchup_done failed for %s: %s", session_id, e)
252
- return {"status": "ok", "session_id": session_id}
253
- '''
254
- content = content.rstrip() + "\n" + catchup_endpoint
255
- print(" + Added /session/{id}/catchup-done endpoint")
256
- changed = True
257
-
258
- if changed:
259
- try:
260
- ast.parse(content)
261
- _write(BACKEND_ROUTES_AGENT, content)
262
- print("OK: routes/agent.py patched")
263
- except SyntaxError as e:
264
- print(f"FAIL: routes/agent.py syntax: {e}")
265
- else:
266
- print("SKIP: routes/agent.py not found")
267
-
268
-
269
- # ─── Frontend patches ────────────────────────────────────────────────────────
270
-
271
- # These files are only present in the frontend-builder stage.
272
-
273
- FRONTEND_DIR = "/source/frontend/src"
274
- FE_MARKER = "// === PATCH: catch-up-v2 ==="
275
- URL_MARKER = "// === PATCH: shareable-url-v2 ==="
276
-
277
-
278
- def patch_frontend_file(relpath, patch_fn, label):
279
- path = os.path.join(FRONTEND_DIR, relpath)
280
- if not os.path.exists(path):
281
- print(f"SKIP: {path} not found")
282
- return
283
- with open(path, "r", encoding="utf-8") as f:
284
- content = f.read()
285
- content, changed = patch_fn(content, path)
286
- if not changed:
287
- print(f"SKIP: {path} — no changes needed ({label})")
288
- return
289
- with open(path, "w", encoding="utf-8") as f:
290
- f.write(content)
291
- print(f"OK: {path} patched ({label})")
292
-
293
-
294
- # ─── useAgentChat.ts ─────────────────────────────────────────────────────────
295
-
296
- print("\n=== Patching useAgentChat.ts ===")
297
-
298
-
299
- def patch_useAgentChat(content, _path):
300
- changed = False
301
-
302
- if FE_MARKER in content:
303
- print(" SKIP: already patched")
304
- return content, False
305
-
306
- # 1. Add catch-up guard + URL sync helpers after chatActionsRef
307
- ref_def = " const chatActionsRef = useRef<"
308
- if ref_def in content:
309
- idx = content.find(ref_def)
310
- end_idx = content.find("});", idx)
311
- if end_idx > 0:
312
- insert_after = content.find("\n", end_idx) + 1
313
- helper = '''
314
- // === PATCH: catch-up-v2 (frontend) ===
315
- // After catch-up (renameSession), the backend only has the summary message
316
- // but the frontend has the full conversation in localStorage. These guards
317
- // prevent the backend's sparse data from overwriting the frontend's rich
318
- // conversation. The ml-intern-catchup-done flag is set after renameSession
319
- // and cleared after the first message is sent.
320
- const _catchupDone = (() => {
321
- try {
322
- return localStorage.getItem(`ml-intern-catchup-done:${sessionId}`) === 'true';
323
- } catch {
324
- return false;
325
- }
326
- })();
327
- const _clearCatchupDone = useCallback(() => {
328
- try {
329
- localStorage.removeItem(`ml-intern-catchup-done:${sessionId}`);
330
- } catch {}
331
- }, [sessionId]);
332
- '''
333
- content = content[:insert_after] + helper + content[insert_after:]
334
- print(" + Added catchup guard helper")
335
- changed = True
336
-
337
- # 2. Guard onRecoverMessages
338
- old_recover = (
339
- " const setMsgs = chatActionsRef.current.setMessages;\n"
340
- " if (setMsgs && uiMsgs.length >= currentMessageCount) {\n"
341
- " setMsgs(uiMsgs);\n"
342
- " saveMessages(sessionId, uiMsgs);\n"
343
- " }"
344
- )
345
- new_recover = (
346
- " const setMsgs = chatActionsRef.current.setMessages;\n"
347
- " // === PATCH: catch-up-v2 (frontend) ===\n"
348
- " const _feCount = chatActionsRef.current.messages.length;\n"
349
- " if (setMsgs && (!_catchupDone || uiMsgs.length >= _feCount) && uiMsgs.length >= currentMessageCount) {\n"
350
- " setMsgs(uiMsgs);\n"
351
- " saveMessages(sessionId, uiMsgs);\n"
352
- " }"
353
- )
354
- content, ok = _safe_replace(content, old_recover, new_recover)
355
- if ok:
356
- print(" + Guarded onRecoverMessages")
357
- changed = True
358
-
359
- # 3. Guard refreshMessages
360
- old_refresh = (
361
- " const setMsgs = chatActionsRef.current.setMessages;\n"
362
- " if (setMsgs && uiMsgs.length > 0) {\n"
363
- " setMsgs(uiMsgs);\n"
364
- " saveMessages(sessionId, uiMsgs);\n"
365
- " }\n"
366
- " return true;"
367
- )
368
- new_refresh = (
369
- " const setMsgs = chatActionsRef.current.setMessages;\n"
370
- " // === PATCH: catch-up-v2 (frontend) ===\n"
371
- " const _feCountR = chatActionsRef.current.messages.length;\n"
372
- " if (setMsgs && (!_catchupDone || uiMsgs.length > _feCountR) && uiMsgs.length > 0) {\n"
373
- " setMsgs(uiMsgs);\n"
374
- " saveMessages(sessionId, uiMsgs);\n"
375
- " }\n"
376
- " return true;"
377
- )
378
- content, ok = _safe_replace(content, old_refresh, new_refresh)
379
- if ok:
380
- print(" + Guarded refreshMessages")
381
- changed = True
382
-
383
- # 4. Guard all chat.setMessages(uiMsgs) in hydration paths (10-space indent)
384
- old_hydrate = (
385
- " if (uiMsgs.length > 0) {\n"
386
- " chat.setMessages(uiMsgs);\n"
387
- " saveMessages(sessionId, uiMsgs);\n"
388
- " }"
389
- )
390
- new_hydrate = (
391
- " // === PATCH: catch-up-v2 (frontend) ===\n"
392
- " const _feCountH = chatActionsRef.current.messages.length;\n"
393
- " if (uiMsgs.length > 0 && (!_catchupDone || uiMsgs.length > _feCountH)) {\n"
394
- " chat.setMessages(uiMsgs);\n"
395
- " saveMessages(sessionId, uiMsgs);\n"
396
- " }"
397
- )
398
- count = 0
399
- while old_hydrate in content:
400
- content, ok = _safe_replace(content, old_hydrate, new_hydrate)
401
- if ok:
402
- count += 1
403
- changed = True
404
- else:
405
- break
406
- if count > 0:
407
- print(f" + Guarded {count} hydration setMessages paths")
408
-
409
- # 5. Wrap sendMessage to clear catchup flag + update URL
410
- old_send = " sendMessage: chat.sendMessage,\n stop,\n status: chat.status,\n"
411
- if old_send in content:
412
- new_send = (
413
- " sendMessage,\n"
414
- " stop,\n"
415
- " status: chat.status,\n"
416
- )
417
- # Actually, we need to insert the wrapper BEFORE the return and change sendMessage in it
418
- # Find the return block
419
- return_block = " return {\n messages: chat.messages,\n sendMessage: chat.sendMessage,\n stop,\n status: chat.status,\n undoLastTurn,\n"
420
- if return_block in content:
421
- wrapper_block = (
422
- " const _sendMessageWrapped = useCallback((msg) => {\n"
423
- " // === PATCH: catch-up-v2 (frontend) ===\n"
424
- " _clearCatchupDone();\n"
425
- " // === PATCH: shareable-url-v2 ===\n"
426
- " const params = new URLSearchParams(window.location.search);\n"
427
- " if (params.get('chat') !== sessionId) {\n"
428
- " params.set('chat', sessionId);\n"
429
- " const qs = params.toString();\n"
430
- " window.history.replaceState(null, '', qs ? `${window.location.pathname}?${qs}` : window.location.pathname);\n"
431
- " }\n"
432
- " return chat.sendMessage(msg);\n"
433
- " }, [chat, _clearCatchupDone, sessionId]);\n"
434
- "\n"
435
- " return {\n messages: chat.messages,\n sendMessage: _sendMessageWrapped,\n stop,\n status: chat.status,\n undoLastTurn,\n"
436
- )
437
- content, ok = _safe_replace(content, return_block, wrapper_block)
438
- if ok:
439
- print(" + Wrapped sendMessage (clear flag + URL sync)")
440
- changed = True
441
- else:
442
- # Try the simpler pattern
443
- simple_return = " return {\n messages: chat.messages,\n sendMessage: chat.sendMessage,\n"
444
- if simple_return in content:
445
- wrapper_block = (
446
- " const _sendMessageWrapped = useCallback((msg) => {\n"
447
- " // === PATCH: catch-up-v2 (frontend) ===\n"
448
- " _clearCatchupDone();\n"
449
- " // === PATCH: shareable-url-v2 ===\n"
450
- " const params = new URLSearchParams(window.location.search);\n"
451
- " if (params.get('chat') !== sessionId) {\n"
452
- " params.set('chat', sessionId);\n"
453
- " const qs = params.toString();\n"
454
- " window.history.replaceState(null, '', qs ? `${window.location.pathname}?${qs}` : window.location.pathname);\n"
455
- " }\n"
456
- " return chat.sendMessage(msg);\n"
457
- " }, [chat, _clearCatchupDone, sessionId]);\n"
458
- "\n"
459
- " return {\n messages: chat.messages,\n sendMessage: _sendMessageWrapped,\n"
460
- )
461
- content, ok = _safe_replace(content, simple_return, wrapper_block)
462
- if ok:
463
- print(" + Wrapped sendMessage (simple pattern)")
464
- changed = True
465
-
466
- if changed and "import { logger } from '@/utils/logger';" in content:
467
- content = content.replace(
468
- "import { logger } from '@/utils/logger';",
469
- "import { logger } from '@/utils/logger';\n" + FE_MARKER + " applied",
470
- 1
471
- )
472
-
473
- return content, changed
474
-
475
-
476
- # ─── ExpiredBanner.tsx ──────────────────────────────────────────────────────
477
-
478
- print("\n=== Patching ExpiredBanner.tsx ===")
479
-
480
-
481
- def patch_expired_banner(content, _path):
482
- changed = False
483
-
484
- if FE_MARKER in content:
485
- print(" SKIP: already patched")
486
- return content, False
487
-
488
- old_success = (
489
- " useAgentStore.getState().clearSessionState(sessionId);\n"
490
- " renameSession(sessionId, newId);\n"
491
- " if (data.model) updateSessionModel(newId, data.model);"
492
- )
493
- new_success = (
494
- " useAgentStore.getState().clearSessionState(sessionId);\n"
495
- " renameSession(sessionId, newId);\n"
496
- " if (data.model) updateSessionModel(newId, data.model);\n"
497
- " // === PATCH: catch-up-v2 (frontend) ===\n"
498
- " // Mark session as catch-up done so hydration paths don't\n"
499
- " // overwrite the full localStorage conversation with the\n"
500
- " // backend's sparse summary-only data.\n"
501
- " try {\n"
502
- " localStorage.setItem(`ml-intern-catchup-done:${newId}`, 'true');\n"
503
- " } catch {}\n"
504
- " // Also notify the backend\n"
505
- " try { await apiFetch(`/api/session/${newId}/catchup-done`, { method: 'POST' }); } catch {}"
506
- )
507
- content, ok = _safe_replace(content, old_success, new_success)
508
- if ok:
509
- print(" + Set catchup-done flag after successful catch-up")
510
- changed = True
511
- else:
512
- print(" INFO: catch-up success pattern not found")
513
-
514
- if changed and "import { logger } from '@/utils/logger';" in content:
515
- content = content.replace(
516
- "import { logger } from '@/utils/logger';",
517
- "import { logger } from '@/utils/logger';\n" + FE_MARKER + " applied",
518
- 1
519
- )
520
-
521
- return content, changed
522
-
523
-
524
- # ─── AppLayout.tsx ───────────────────────────────────────────────────────────
525
-
526
- print("\n=== Patching AppLayout.tsx ===")
527
-
528
-
529
- def patch_app_layout(content, _path):
530
- changed = False
531
-
532
- if URL_MARKER in content:
533
- print(" SKIP: already patched")
534
- return content, False
535
-
536
- # 1. Add ShareIcon import
537
- if "import LightModeOutlinedIcon from '@mui/icons-material/LightModeOutlined';" in content:
538
- content = content.replace(
539
- "import LightModeOutlinedIcon from '@mui/icons-material/LightModeOutlined';",
540
- "import LightModeOutlinedIcon from '@mui/icons-material/LightModeOutlined';\n"
541
- "import ShareIcon from '@mui/icons-material/Share';\n"
542
- "// === PATCH: shareable-url-v2 ===",
543
- 1
544
- )
545
- print(" + Added ShareIcon import")
546
- changed = True
547
-
548
- # 2. Add URL-based session selection on mount
549
- old_health = " // -- LLM health check on mount -----------------------------------------"
550
- new_code = """ // === PATCH: shareable-url-v2 ===
551
- // On mount, read ?chat={sessionId} from URL and auto-select that session
552
- useEffect(() => {
553
- const params = new URLSearchParams(window.location.search);
554
- const chatParam = params.get('chat');
555
- if (chatParam) {
556
- const { sessions: currentSessions, switchSession } = useSessionStore.getState();
557
- if (currentSessions.find((s) => s.id === chatParam)) {
558
- switchSession(chatParam);
559
- }
560
- }
561
- }, []); // eslint-disable-line react-hooks/exhaustive-deps
562
-
563
- // Update URL whenever the active session changes
564
- useEffect(() => {
565
- if (activeSessionId) {
566
- const params = new URLSearchParams(window.location.search);
567
- if (params.get('chat') !== activeSessionId) {
568
- params.set('chat', activeSessionId);
569
- const qs = params.toString();
570
- window.history.replaceState(null, '', qs ? `${window.location.pathname}?${qs}` : window.location.pathname);
571
- }
572
- }
573
- }, [activeSessionId]);
574
-
575
- // -- LLM health check on mount -----------------------------------------"""
576
- content, ok = _safe_replace(content, old_health, new_code)
577
- if ok:
578
- print(" + URL-based session selection + URL sync on active change")
579
- changed = True
580
-
581
- # 3. Add Share button to the header
582
- old_avatar = (
583
- " {user?.picture ? (\n"
584
- " <Avatar\n"
585
- " src={user.picture}\n"
586
- " alt={user.username || 'User'}\n"
587
- " sx={{ width: 28, height: 28, ml: 0.5 }}\n"
588
- " />\n"
589
- " ) : user?.username ? ("
590
- )
591
- new_avatar = (
592
- " {activeSessionId && (\n"
593
- " <Tooltip title=\"Share this chat\">\n"
594
- " <IconButton\n"
595
- " size=\"small\"\n"
596
- " onClick={async () => {\n"
597
- " const url = window.location.href;\n"
598
- " if (navigator.clipboard?.writeText) {\n"
599
- " await navigator.clipboard.writeText(url);\n"
600
- " } else {\n"
601
- " prompt('Copy this link:', url);\n"
602
- " }\n"
603
- " }}\n"
604
- " sx={{ color: 'text.secondary', ml: 0.5 }}\n"
605
- " >\n"
606
- " <ShareIcon fontSize=\"small\" />\n"
607
- " </IconButton>\n"
608
- " </Tooltip>\n"
609
- " )}\n"
610
- " {user?.picture ? (\n"
611
- " <Avatar\n"
612
- " src={user.picture}\n"
613
- " alt={user.username || 'User'}\n"
614
- " sx={{ width: 28, height: 28, ml: 0.5 }}\n"
615
- " />\n"
616
- " ) : user?.username ? ("
617
- )
618
- content, ok = _safe_replace(content, old_avatar, new_avatar)
619
- if ok:
620
- print(" + Added Share button to header")
621
- changed = True
622
-
623
- return content, changed
624
-
625
-
626
- # ─── SessionSidebar.tsx ──────────────────────────────────────────────────────
627
-
628
- print("\n=== Patching SessionSidebar.tsx ===")
629
-
630
-
631
- def patch_session_sidebar(content, _path):
632
- changed = False
633
-
634
- if URL_MARKER in content:
635
- print(" SKIP: already patched")
636
- return content, False
637
-
638
- # 1. URL update after handleNewSession
639
- old_create = (
640
- " createSession(data.session_id, data.model);\n"
641
- " void useUsageStore.getState().fetchUsage(data.session_id);\n"
642
- " setPlan([]);\n"
643
- " clearPanel();\n"
644
- " onClose?.();"
645
- )
646
- new_create = (
647
- " createSession(data.session_id, data.model);\n"
648
- " void useUsageStore.getState().fetchUsage(data.session_id);\n"
649
- " setPlan([]);\n"
650
- " clearPanel();\n"
651
- " // === PATCH: shareable-url-v2 ===\n"
652
- " const _params = new URLSearchParams(window.location.search);\n"
653
- " _params.set('chat', data.session_id);\n"
654
- " const _qs = _params.toString();\n"
655
- " window.history.replaceState(null, '', _qs ? `${window.location.pathname}?${_qs}` : window.location.pathname);\n"
656
- " onClose?.();"
657
- )
658
- content, ok = _safe_replace(content, old_create, new_create)
659
- if ok:
660
- print(" + URL update on new session")
661
- changed = True
662
-
663
- # 2. URL update on second new session (delete-last case)
664
- old_create2 = (
665
- " createSession(data.session_id, data.model);\n"
666
- " void useUsageStore.getState().fetchUsage(data.session_id);\n"
667
- " setPlan([]);\n"
668
- " clearPanel();"
669
- )
670
- new_create2 = (
671
- " createSession(data.session_id, data.model);\n"
672
- " void useUsageStore.getState().fetchUsage(data.session_id);\n"
673
- " setPlan([]);\n"
674
- " clearPanel();\n"
675
- " // === PATCH: shareable-url-v2 ===\n"
676
- " const _params2 = new URLSearchParams(window.location.search);\n"
677
- " _params2.set('chat', data.session_id);\n"
678
- " const _qs2 = _params2.toString();\n"
679
- " window.history.replaceState(null, '', _qs2 ? `${window.location.pathname}?${_qs2}` : window.location.pathname);"
680
- )
681
- content, ok = _safe_replace(content, old_create2, new_create2)
682
- if ok:
683
- print(" + URL update on new session (delete-last case)")
684
- changed = True
685
-
686
- # 3. URL update on session select
687
- old_select = (
688
- " const shouldActivateSession = sessionId !== activeSessionId;\n"
689
- " switchSession(sessionId);"
690
- )
691
- new_select = (
692
- " const shouldActivateSession = sessionId !== activeSessionId;\n"
693
- " switchSession(sessionId);\n"
694
- " // === PATCH: shareable-url-v2 ===\n"
695
- " const _p = new URLSearchParams(window.location.search);\n"
696
- " if (_p.get('chat') !== sessionId) {\n"
697
- " _p.set('chat', sessionId);\n"
698
- " const _q = _p.toString();\n"
699
- " window.history.replaceState(null, '', _q ? `${window.location.pathname}?${_q}` : window.location.pathname);\n"
700
- " }"
701
- )
702
- content, ok = _safe_replace(content, old_select, new_select)
703
- if ok:
704
- print(" + URL update on session select")
705
- changed = True
706
-
707
- return content, changed
708
-
709
-
710
- # ─── WelcomeScreen.tsx ───────────────────────────────────────────────────────
711
-
712
- print("\n=== Patching WelcomeScreen.tsx ===")
713
-
714
- WS_PATH = os.path.join(FRONTEND_DIR, "components/WelcomeScreen/WelcomeScreen.tsx")
715
- if os.path.exists(WS_PATH):
716
- with open(WS_PATH, "r", encoding="utf-8") as f:
717
- ws_content = f.read()
718
- if URL_MARKER not in ws_content:
719
- old_ws = (
720
- " createSession(data.session_id, data.model);\n"
721
- " void useUsageStore.getState().fetchUsage(data.session_id);\n"
722
- " setPlan([]);\n"
723
- " clearPanel();\n"
724
- " } catch"
725
- )
726
- new_ws = (
727
- " createSession(data.session_id, data.model);\n"
728
- " void useUsageStore.getState().fetchUsage(data.session_id);\n"
729
- " setPlan([]);\n"
730
- " clearPanel();\n"
731
- " // === PATCH: shareable-url-v2 ===\n"
732
- " const _params = new URLSearchParams(window.location.search);\n"
733
- " _params.set('chat', data.session_id);\n"
734
- " const _qs = _params.toString();\n"
735
- " window.history.replaceState(null, '', _qs ? `${window.location.pathname}?${_qs}` : window.location.pathname);\n"
736
- " } catch"
737
- )
738
- if old_ws in ws_content:
739
- ws_content = ws_content.replace(old_ws, new_ws, 1)
740
- with open(WS_PATH, "w", encoding="utf-8") as f:
741
- f.write(ws_content)
742
- print(" + URL update on welcome screen session creation")
743
- else:
744
- print(" INFO: welcome screen pattern not found")
745
- else:
746
- print(" SKIP: already patched")
747
- else:
748
- print("SKIP: WelcomeScreen.tsx not found")
749
-
750
-
751
- # ─── Execute frontend patches ─────────────────────────────────────────────────
752
-
753
- patch_frontend_file("hooks/useAgentChat.ts", patch_useAgentChat, "catch-up guard + URL sync")
754
- patch_frontend_file("components/Chat/ExpiredBanner.tsx", patch_expired_banner, "catchup-done flag")
755
- patch_frontend_file("components/Layout/AppLayout.tsx", patch_app_layout, "URL session selection + share button")
756
- patch_frontend_file("components/SessionSidebar/SessionSidebar.tsx", patch_session_sidebar, "URL update on session actions")
757
-
758
- print("\n=== All v2 patches applied ===")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_catch_up_fix_v3.py DELETED
@@ -1,675 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Patch v3: Fix the frontend build + complete catch-up & shareable-URL fixes.
4
-
5
- Build failure root cause (v2):
6
- v2's patch_catch_up_fix_v2.py introduced THREE TypeScript build errors
7
- because its inner patterns were already modified by earlier patches
8
- (patch_catch_up_fix_frontend.py ran first and changed the same code):
9
-
10
- src/components/Layout/AppLayout.tsx(20,1): error TS6133:
11
- 'ShareIcon' is declared but its value is never read. <- share button patch FAILED to match
12
- src/hooks/useAgentChat.ts(572,9): error TS6133:
13
- '_catchupDone' is declared but its value is never read. <- guard patches FAILED to match
14
- src/hooks/useAgentChat.ts(1174,44): error TS7006:
15
- Parameter 'msg' implicitly has an 'any' type. <- wrapper typed poorly
16
-
17
- Because npm run build fails, /app/static ends up EMPTY and the Space
18
- serves NO frontend. All user-visible symptoms (catch-up errors, context
19
- loss, no share URL) trace back to this.
20
-
21
- v3 fixes (runs AFTER all other patches, idempotent):
22
- 1. useAgentChat.ts
23
- a. Wire _catchupDone into the existing v1 guards so the flag is used
24
- (kills TS6133) AND the "backend sparse summary overwrites full
25
- local conversation" race is actually prevented.
26
- b. Type _sendMessageWrapped's msg parameter using the same type as
27
- chat.sendMessage (kills TS7006).
28
- 2. AppLayout.tsx
29
- a. Render the missing Share button in the header (kills TS6133 for
30
- ShareIcon). The button copies a PUBLIC share URL (?share=<id>).
31
- b. If the URL has ?share=<sessionId>, render a read-only ShareView
32
- instead of the chat (auth-free, works for anyone with the link).
33
- 3. ShareView.tsx (new file): read-only transcript viewer.
34
- 4. Backend routes/agent.py: add public GET /api/public/session/{id}
35
- (no auth) that serves sanitized conversation rows from the permanent
36
- public sessions dataset.
37
- 5. Backend session_logger.py: fix v2's indent bug (insert filter
38
- correctly so no SyntaxError) — event rows filtered at source.
39
-
40
- Never fails the build (all writes guarded by markers / ast.parse, and the
41
- Dockerfile already appends `|| true`).
42
- """
43
-
44
- import ast
45
- import os
46
-
47
-
48
- def _read(path):
49
- with open(path, "r", encoding="utf-8") as f:
50
- return f.read()
51
-
52
-
53
- def _write(path, content):
54
- with open(path, "w", encoding="utf-8") as f:
55
- f.write(content)
56
-
57
-
58
- def _replace(content, old, new):
59
- if old in content:
60
- return content.replace(old, new, 1), True
61
- return content, False
62
-
63
-
64
- MARKER3 = "// === PATCH: catch-up-v3 ==="
65
- MARKER3_PY = "# === PATCH: catch-up-v3 ==="
66
-
67
-
68
- def patch_frontend_file(relpath, patch_fn, label):
69
- path = os.path.join(FRONTEND_DIR, relpath)
70
- if not os.path.exists(path):
71
- print(f"SKIP: {path} not found ({label})")
72
- return
73
- content = _read(path)
74
- content, changed = patch_fn(content, path)
75
- if not changed:
76
- print(f"SKIP: {path} — no changes needed ({label})")
77
- return
78
- _write(path, content)
79
- print(f"OK: {path} patched ({label})")
80
-
81
-
82
- # ─── Frontend files ─────────────────────────────────────────────────────────
83
-
84
- FRONTEND_DIR = "/source/frontend/src"
85
-
86
-
87
- def patch_use_agent_chat(content, _path):
88
- """Wire _catchupDone into guards + type the sendMessage wrapper."""
89
- changed = False
90
- if MARKER3 in content:
91
- print(" SKIP: already patched")
92
- return content, False
93
-
94
- # (1a) onRecoverMessages guard (v1 already present):
95
- old_recover = (
96
- " const _shouldOverwrite = uiMsgs.length > _currentNonSystem.length || _currentNonSystem.length === 0;\n"
97
- " const setMsgs = chatActionsRef.current.setMessages;\n"
98
- " if (setMsgs && _shouldOverwrite) {"
99
- )
100
- new_recover = (
101
- " // === PATCH: catch-up-v3 ===\n"
102
- " // After catch-up the backend only has the summary message;\n"
103
- " // never let it clobber the full local conversation.\n"
104
- " const _shouldOverwrite = _catchupDone\n"
105
- " ? false\n"
106
- " : (uiMsgs.length > _currentNonSystem.length || _currentNonSystem.length === 0);\n"
107
- " const setMsgs = chatActionsRef.current.setMessages;\n"
108
- " if (setMsgs && _shouldOverwrite) {"
109
- )
110
- content, ok = _replace(content, old_recover, new_recover)
111
- if ok:
112
- print(" + onRecoverMessages guard honors _catchupDone")
113
- changed = True
114
- else:
115
- print(" INFO: onRecoverMessages guard pattern not found (may already be v3)")
116
-
117
- # refreshMessages guard (v1 already present):
118
- old_refresh = (
119
- " const _shouldOverwrite2 = uiMsgs.length > _currentNonSystem2.length || _currentNonSystem2.length === 0;\n"
120
- " const setMsgs = chatActionsRef.current.setMessages;\n"
121
- " if (setMsgs && _shouldOverwrite2) {"
122
- )
123
- new_refresh = (
124
- " // === PATCH: catch-up-v3 ===\n"
125
- " const _shouldOverwrite2 = _catchupDone\n"
126
- " ? false\n"
127
- " : (uiMsgs.length > _currentNonSystem2.length || _currentNonSystem2.length === 0);\n"
128
- " const setMsgs = chatActionsRef.current.setMessages;\n"
129
- " if (setMsgs && _shouldOverwrite2) {"
130
- )
131
- content, ok = _replace(content, old_refresh, new_refresh)
132
- if ok:
133
- print(" + refreshMessages guard honors _catchupDone")
134
- changed = True
135
- else:
136
- print(" INFO: refreshMessages guard pattern not found (may already be v3)")
137
-
138
- # (1b) Type the sendMessage wrapper param using chat.sendMessage's type.
139
- old_wrapper = " const _sendMessageWrapped = useCallback((msg) => {"
140
- new_wrapper = (
141
- " // === PATCH: catch-up-v3 ===\n"
142
- " type _ChatSendArgs = Parameters<typeof chat.sendMessage>[0];\n"
143
- " const _sendMessageWrapped = useCallback((msg: _ChatSendArgs) => {"
144
- )
145
- content, ok = _replace(content, old_wrapper, new_wrapper)
146
- if ok:
147
- print(" + Typed _sendMessageWrapped msg parameter")
148
- changed = True
149
- else:
150
- print(" INFO: wrapper pattern not found (may already be v3)")
151
-
152
- # (1c) Strengthen the remaining v1 count-based hydration guards
153
- # (_shouldSetB 10-space, _shouldSetC 14-space x2, _shouldSet 6-space).
154
- guard_rules = [
155
- (
156
- " const _shouldSetB = uiMsgs.length > _currentMsgsB.length || _currentMsgsB.length === 0;\n"
157
- " if (_shouldSetB) {",
158
- " // === PATCH: catch-up-v3 ===\n"
159
- " const _shouldSetB = _catchupDone\n"
160
- " ? false\n"
161
- " : (uiMsgs.length > _currentMsgsB.length || _currentMsgsB.length === 0);\n"
162
- " if (_shouldSetB) {",
163
- ),
164
- (
165
- " const _shouldSetC = uiMsgs.length > _currentMsgsC.length || _currentMsgsC.length === 0;\n"
166
- " if (_shouldSetC) {",
167
- " // === PATCH: catch-up-v3 ===\n"
168
- " const _shouldSetC = _catchupDone\n"
169
- " ? false\n"
170
- " : (uiMsgs.length > _currentMsgsC.length || _currentMsgsC.length === 0);\n"
171
- " if (_shouldSetC) {",
172
- ),
173
- (
174
- " const _shouldSet = uiMsgs.length > _currentMsgs.length || _currentMsgs.length === 0;\n"
175
- " if (_shouldSet) {",
176
- " // === PATCH: catch-up-v3 ===\n"
177
- " const _shouldSet = _catchupDone\n"
178
- " ? false\n"
179
- " : (uiMsgs.length > _currentMsgs.length || _currentMsgs.length === 0);\n"
180
- " if (_shouldSet) {",
181
- ),
182
- ]
183
- for old_g, new_g in guard_rules:
184
- n = content.count(old_g)
185
- if n:
186
- content = content.replace(old_g, new_g)
187
- print(f" + _catchupDone wired into hydration guard ({n} site(s))")
188
- changed = True
189
-
190
- # (1d) Extra safety: guard any remaining bare setMessages path in the
191
- # event-stream consume / visibility handlers that v1 did not touch.
192
- old_consume = (
193
- " if (uiMsgs.length > 0) {\n"
194
- " chat.setMessages(uiMsgs);\n"
195
- " saveMessages(sessionId, uiMsgs);\n"
196
- " }"
197
- )
198
- new_consume = (
199
- " // === PATCH: catch-up-v3 ===\n"
200
- " const _c3a = (chatActionsRef.current.messages || []).filter(\n"
201
- " (m) => !(m.parts?.[0]?.type === 'text' && typeof m.parts?.[0]?.text === 'string' && m.parts?.[0].text.trimStart().startsWith('[SYSTEM:'))\n"
202
- " );\n"
203
- " if (uiMsgs.length > 0 && (!_catchupDone || uiMsgs.length > _c3a.length)) {\n"
204
- " chat.setMessages(uiMsgs);\n"
205
- " saveMessages(sessionId, uiMsgs);\n"
206
- " }"
207
- )
208
- count = 0
209
- while old_consume in content:
210
- content, ok = _replace(content, old_consume, new_consume)
211
- if ok:
212
- count += 1
213
- changed = True
214
- else:
215
- break
216
- if count:
217
- print(f" + Guarded {count} event-stream/visibility setMessages paths")
218
-
219
- return content, changed
220
-
221
-
222
- def patch_app_layout(content, _path):
223
- """Share button (public ?share= URL) + read-only ShareView mode."""
224
- changed = False
225
- if MARKER3 in content:
226
- print(" SKIP: already patched")
227
- return content, False
228
-
229
- # (2a) Import ShareView (new component in the same folder).
230
- if "import ShareView" not in content:
231
- anchor = "import ShareIcon from '@mui/icons-material/Share';"
232
- if anchor in content:
233
- content = content.replace(
234
- anchor,
235
- anchor + "\nimport ShareView from '@/components/ShareView';\n" + MARKER3,
236
- 1,
237
- )
238
- changed = True
239
- print(" + Imported ShareView")
240
- else:
241
- print(" INFO: ShareIcon import not found for ShareView import")
242
-
243
- # (2b) Share button before theme toggle. Builds a PUBLIC share URL
244
- # (?share=<sessionId>) instead of the owner-only ?chat= URL.
245
- old_theme_btn = (
246
- " <IconButton\n"
247
- " onClick={toggleTheme}\n"
248
- " size=\"small\"\n"
249
- " sx={{\n"
250
- " color: 'text.secondary',\n"
251
- " '&:hover': { color: 'primary.main' },\n"
252
- " }}\n"
253
- " >\n"
254
- " {themeMode === 'dark' ? <LightModeOutlinedIcon fontSize=\"small\" /> : <DarkModeOutlinedIcon fontSize=\"small\" />}\n"
255
- " </IconButton>"
256
- )
257
- new_theme_btn = (
258
- " <IconButton\n"
259
- " onClick={toggleTheme}\n"
260
- " size=\"small\"\n"
261
- " sx={{\n"
262
- " color: 'text.secondary',\n"
263
- " '&:hover': { color: 'primary.main' },\n"
264
- " }}\n"
265
- " >\n"
266
- " {themeMode === 'dark' ? <LightModeOutlinedIcon fontSize=\"small\" /> : <DarkModeOutlinedIcon fontSize=\"small\" />}\n"
267
- " </IconButton>\n"
268
- "\n"
269
- " {activeSessionId && (\n"
270
- " <Tooltip title=\"Copy public share link\">\n"
271
- " <IconButton\n"
272
- " size=\"small\"\n"
273
- " onClick={(e: React.MouseEvent) => {\n"
274
- " e.stopPropagation();\n"
275
- " const _p = new URLSearchParams(window.location.search);\n"
276
- " const _sid = _p.get('chat') || activeSessionId || '';\n"
277
- " const _sp = new URLSearchParams();\n"
278
- " if (_sid) _sp.set('share', _sid);\n"
279
- " const _qs = _sp.toString();\n"
280
- " const url = `${window.location.origin}${window.location.pathname}${_qs ? `?${_qs}` : ''}`;\n"
281
- " if (navigator.clipboard?.writeText) {\n"
282
- " navigator.clipboard.writeText(url).catch(() => {\n"
283
- " prompt('Copy this link:', url);\n"
284
- " });\n"
285
- " } else {\n"
286
- " prompt('Copy this link:', url);\n"
287
- " }\n"
288
- " }}\n"
289
- " sx={{\n"
290
- " color: 'text.secondary',\n"
291
- " '&:hover': { color: 'primary.main' },\n"
292
- " }}\n"
293
- " >\n"
294
- " <ShareIcon fontSize=\"small\" />\n"
295
- " </IconButton>\n"
296
- " </Tooltip>\n"
297
- " )}"
298
- )
299
- content, ok = _replace(content, old_theme_btn, new_theme_btn)
300
- if ok:
301
- changed = True
302
- print(" + Added Share button before theme toggle (public ?share= URL)")
303
- else:
304
- print(" INFO: theme toggle pattern not found (may already be v3)")
305
-
306
- # Ensure Tooltip is imported (used by the Share button).
307
- if "Tooltip," not in content and "import Tooltip" not in content:
308
- if "import {\n Avatar," in content:
309
- content = content.replace(
310
- "import {\n Avatar,",
311
- "import {\n Tooltip,\n Avatar,",
312
- 1,
313
- )
314
- changed = True
315
- print(" + Added Tooltip import")
316
- elif "Avatar," in content:
317
- content = content.replace(" Avatar,", " Tooltip,\n Avatar,", 1)
318
- changed = True
319
- print(" + Added Tooltip import (fallback)")
320
- else:
321
- print(" INFO: could not add Tooltip import (Avatar import not found)")
322
-
323
- # (2c) Read-only share mode: if ?share=<id> is present, render ShareView.
324
- # Must come AFTER all hooks (it is — right before the WelcomeScreen return).
325
- old_welcome = " // -- Welcome screen: no sessions at all ---------------------------------\n"
326
- new_welcome = (
327
- " // === PATCH: catch-up-v3 ===\n"
328
- " // Public read-only share mode (?share=<sessionId>). Works without\n"
329
- " // login for anyone who has the link; data comes from the public\n"
330
- " // sessions dataset via GET /api/public/session/{id}.\n"
331
- " const _shareParam = new URLSearchParams(window.location.search).get('share');\n"
332
- " if (_shareParam) {\n"
333
- " return <ShareView sessionId={_shareParam} />;\n"
334
- " }\n"
335
- "\n"
336
- " // -- Welcome screen: no sessions at all ---------------------------------\n"
337
- )
338
- content, ok = _replace(content, old_welcome, new_welcome)
339
- if ok:
340
- changed = True
341
- print(" + ?share= renders read-only ShareView")
342
- else:
343
- print(" INFO: welcome-screen anchor not found for share mode")
344
-
345
- return content, changed
346
-
347
-
348
- def patch_share_view(path):
349
- """Create the read-only ShareView component (new file)."""
350
- if os.path.exists(path):
351
- print(" SKIP: ShareView.tsx already exists")
352
- return
353
- content = (
354
- "/**\n"
355
- " * Read-only public session viewer (no auth required).\n"
356
- " *\n"
357
- " * Rendered by AppLayout when the URL contains ?share=<sessionId>.\n"
358
- " * Fetches sanitized conversation rows from the public endpoint\n"
359
- " * GET /api/public/session/{id}, which reads the permanent public\n"
360
- " * sessions dataset. Anyone with the link can view the transcript.\n"
361
- " */\n"
362
- "// === PATCH: catch-up-v3 ===\n"
363
- "import { useEffect, useState } from 'react';\n"
364
- "import {\n"
365
- " Alert,\n"
366
- " Box,\n"
367
- " Button,\n"
368
- " CircularProgress,\n"
369
- " Paper,\n"
370
- " Typography,\n"
371
- "} from '@mui/material';\n"
372
- "\n"
373
- "interface ShareRow {\n"
374
- " role: string;\n"
375
- " content: string;\n"
376
- " timestamp?: string;\n"
377
- "}\n"
378
- "\n"
379
- "interface ShareViewProps {\n"
380
- " sessionId: string;\n"
381
- "}\n"
382
- "\n"
383
- "export default function ShareView({ sessionId }: ShareViewProps) {\n"
384
- " const [rows, setRows] = useState<ShareRow[]>([]);\n"
385
- " const [loading, setLoading] = useState(true);\n"
386
- " const [error, setError] = useState<string | null>(null);\n"
387
- "\n"
388
- " useEffect(() => {\n"
389
- " let cancelled = false;\n"
390
- " (async () => {\n"
391
- " try {\n"
392
- " const res = await fetch(\n"
393
- " `/api/public/session/${encodeURIComponent(sessionId)}`,\n"
394
- " { credentials: 'include' },\n"
395
- " );\n"
396
- " if (res.status === 404) {\n"
397
- " if (!cancelled) setError('Session not found.');\n"
398
- " } else if (!res.ok) {\n"
399
- " if (!cancelled) setError('Failed to load this session.');\n"
400
- " } else {\n"
401
- " const data = await res.json();\n"
402
- " if (!cancelled && Array.isArray(data)) setRows(data);\n"
403
- " }\n"
404
- " } catch {\n"
405
- " if (!cancelled) setError('Network error while loading this session.');\n"
406
- " } finally {\n"
407
- " if (!cancelled) setLoading(false);\n"
408
- " }\n"
409
- " })();\n"
410
- " return () => {\n"
411
- " cancelled = true;\n"
412
- " };\n"
413
- " }, [sessionId]);\n"
414
- "\n"
415
- " return (\n"
416
- " <Box\n"
417
- " sx={{\n"
418
- " minHeight: '100vh',\n"
419
- " bgcolor: 'var(--body-gradient)' as string,\n"
420
- " py: 4,\n"
421
- " px: { xs: 2, md: 6 },\n"
422
- " }}\n"
423
- " >\n"
424
- " <Box sx={{ maxWidth: 860, mx: 'auto' }}>\n"
425
- " <Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 2 }}>\n"
426
- " <Typography variant=\"h6\" sx={{ fontSize: '1rem', fontWeight: 700 }}>\n"
427
- " Shared session\n"
428
- " </Typography>\n"
429
- " <Button component=\"a\" href=\"/\" size=\"small\" sx={{ textTransform: 'none', ml: 'auto' }}>\n"
430
- " Open ML Intern\n"
431
- " </Button>\n"
432
- " </Box>\n"
433
- "\n"
434
- " {loading && <CircularProgress size={20} />}\n"
435
- " {error && (\n"
436
- " <Alert severity=\"warning\" variant=\"outlined\">\n"
437
- " {error}\n"
438
- " </Alert>\n"
439
- " )}\n"
440
- " {!loading && !error && rows.length === 0 && (\n"
441
- " <Typography color=\"text.secondary\" variant=\"body2\">\n"
442
- " No messages in this session.\n"
443
- " </Typography>\n"
444
- " )}\n"
445
- " {rows.map((row, i) => (\n"
446
- " <Paper\n"
447
- " key={i}\n"
448
- " sx={{\n"
449
- " mb: 1,\n"
450
- " p: 1.5,\n"
451
- " bgcolor: row.role === 'user' ? 'var(--hover-bg)' : 'var(--panel)',\n"
452
- " borderRadius: 2,\n"
453
- " }}\n"
454
- " >\n"
455
- " <Typography\n"
456
- " variant=\"caption\"\n"
457
- " sx={{ color: 'var(--muted-text)', fontWeight: 700, textTransform: 'uppercase' }}\n"
458
- " >\n"
459
- " {row.role}\n"
460
- " </Typography>\n"
461
- " <Typography\n"
462
- " variant=\"body2\"\n"
463
- " sx={{ whiteSpace: 'pre-wrap', mt: 0.5, wordBreak: 'break-word' }}\n"
464
- " >\n"
465
- " {row.content}\n"
466
- " </Typography>\n"
467
- " </Paper>\n"
468
- " ))}\n"
469
- " </Box>\n"
470
- " </Box>\n"
471
- " );\n"
472
- "}\n"
473
- )
474
- _write(path, content)
475
- print(" + Created ShareView.tsx")
476
-
477
-
478
- def patch_session_sidebar(content, _path):
479
- changed = False
480
- if MARKER3 in content:
481
- return content, False
482
- return content, changed
483
-
484
-
485
- def patch_expired_banner(content, _path):
486
- changed = False
487
- if MARKER3 in content:
488
- return content, False
489
- return content, changed
490
-
491
-
492
- def patch_welcome_screen(content, _path):
493
- changed = False
494
- if MARKER3 in content:
495
- return content, False
496
- return content, changed
497
-
498
-
499
- # ─── Backend routes/agent.py: public share endpoint ───────────────────────
500
-
501
- BACKEND_ROUTES_AGENT = "/app/backend/routes/agent.py"
502
- BACKEND_SESSION_LOGGER = "/app/backend/session_logger.py"
503
-
504
-
505
- def patch_routes_agent(content):
506
- """Add public GET /api/public/session/{id} (no auth)."""
507
- changed = False
508
- if "public/session/" in content:
509
- print(" SKIP: public endpoint already present")
510
- return content, False
511
-
512
- endpoint = '''
513
-
514
- @router.get("/public/session/{session_id}")
515
- async def public_session_transcript(session_id: str) -> list[dict]:
516
- """Public read-only transcript for a session (no auth required).
517
-
518
- Reads sanitized conversation rows from the permanent public sessions
519
- dataset (bep40/ml-intern-sessions). Returns only user/assistant/tool
520
- messages — never internal event/system rows. Useful for sharing a
521
- session transcript with anyone via a link.
522
- """
523
- from backend.session_logger import load_session_history
524
-
525
- rows = load_session_history(session_id)
526
- valid_roles = {"user", "assistant", "tool"}
527
- messages = [r for r in rows if r.get("role") in valid_roles]
528
- # Cap payload size: last 300 messages, each content truncated to 20k chars.
529
- truncated = []
530
- for r in messages[-300:]:
531
- content = r.get("content") or ""
532
- if isinstance(content, str) and len(content) > 20000:
533
- content = content[:20000] + "\\n...[truncated]"
534
- truncated.append(
535
- {
536
- "role": r.get("role", "assistant"),
537
- "content": content,
538
- "timestamp": r.get("timestamp"),
539
- }
540
- )
541
- return truncated
542
- '''
543
- content = content.rstrip() + "\n" + endpoint
544
- print(" + Added public /api/public/session/{id} endpoint")
545
- changed = True
546
- return content, changed
547
-
548
-
549
- def patch_session_logger(content):
550
- """Fix v2's broken insertion that caused a SyntaxError.
551
-
552
- v2 inserted the filter between `rows.sort(...)` and `return rows` at the
553
- WRONG indentation (4 spaces instead of 8), producing:
554
- expected 'except' or 'finally' block
555
- The whole patch was skipped, so event rows were never filtered.
556
-
557
- v3 inserts the filter at the correct 8-space indent inside the try block.
558
- """
559
- changed = False
560
- if MARKER3_PY in content:
561
- print(" SKIP: already patched")
562
- return content, False
563
-
564
- # Remove the broken v2 insertion if it ever made it in.
565
- broken = (
566
- " # === PATCH: catch-up-v2 ===\n"
567
- " # Filter out event/system rows so summarization never sees\n"
568
- " # internal tracing data that cant be parsed as Message objects.\n"
569
- " valid_roles = {\"user\", \"assistant\", \"tool\"}\n"
570
- " rows = [r for r in rows if r.get(\"role\") in valid_roles]\n"
571
- )
572
- if broken in content:
573
- content = content.replace(broken, "", 1)
574
- print(" + Removed broken v2 insertion")
575
- changed = True
576
-
577
- if "def _filter_valid_roles" not in content:
578
- old_sort = (
579
- " rows.sort(key=lambda r: (r.get(\"seq\", 0), r.get(\"timestamp\", \"\")))\n"
580
- " return rows\n"
581
- )
582
- new_sort = (
583
- " rows.sort(key=lambda r: (r.get(\"seq\", 0), r.get(\"timestamp\", \"\")))\n"
584
- " # === PATCH: catch-up-v3 ===\n"
585
- " # Summarization and Message.model_validate() cannot parse\n"
586
- " # internal tracing rows (role='event'); filter them here so\n"
587
- " # every consumer gets clean conversation data.\n"
588
- " valid_roles = {\"user\", \"assistant\", \"tool\"}\n"
589
- " return [r for r in rows if r.get(\"role\") in valid_roles]\n"
590
- )
591
- content, ok = _replace(content, old_sort, new_sort)
592
- if ok:
593
- print(" + Filter event rows at source (load_session_history)")
594
- changed = True
595
- else:
596
- # Try the already-v2-patched variant (sort only, return separate)
597
- old_sort2 = (
598
- " rows.sort(key=lambda r: (r.get(\"seq\", 0), r.get(\"timestamp\", \"\")))\n"
599
- )
600
- new_sort2 = (
601
- " rows.sort(key=lambda r: (r.get(\"seq\", 0), r.get(\"timestamp\", \"\")))\n"
602
- " # === PATCH: catch-up-v3 ===\n"
603
- " valid_roles = {\"user\", \"assistant\", \"tool\"}\n"
604
- " rows = [r for r in rows if r.get(\"role\") in valid_roles]\n"
605
- )
606
- content, ok = _replace(content, old_sort2, new_sort2)
607
- if ok:
608
- print(" + Filter event rows at source (sort-then-filter variant)")
609
- changed = True
610
-
611
- return content, changed
612
-
613
-
614
- # ─── Execute ────────────────────────────────────────────────────────────────
615
-
616
- print("=== v3: Frontend ===")
617
- patch_frontend_file(
618
- "hooks/useAgentChat.ts",
619
- patch_use_agent_chat,
620
- "wire _catchupDone + type wrapper",
621
- )
622
- patch_frontend_file(
623
- "components/Layout/AppLayout.tsx",
624
- patch_app_layout,
625
- "share button + share mode",
626
- )
627
- patch_share_view(os.path.join(FRONTEND_DIR, "components/ShareView.tsx"))
628
- patch_frontend_file(
629
- "components/SessionSidebar/SessionSidebar.tsx",
630
- patch_session_sidebar,
631
- "idempotency marker",
632
- )
633
- patch_frontend_file(
634
- "components/Chat/ExpiredBanner.tsx",
635
- patch_expired_banner,
636
- "idempotency marker",
637
- )
638
- patch_frontend_file(
639
- "components/WelcomeScreen/WelcomeScreen.tsx",
640
- patch_welcome_screen,
641
- "idempotency marker",
642
- )
643
-
644
- print("\n=== v3: Backend ===")
645
- if os.path.exists(BACKEND_ROUTES_AGENT):
646
- content = _read(BACKEND_ROUTES_AGENT)
647
- content, changed = patch_routes_agent(content)
648
- if changed:
649
- try:
650
- ast.parse(content)
651
- _write(BACKEND_ROUTES_AGENT, content)
652
- print("OK: routes/agent.py patched")
653
- except SyntaxError as e:
654
- print(f"FAIL: routes/agent.py syntax: {e}")
655
- else:
656
- print("SKIP: routes/agent.py — no changes needed")
657
- else:
658
- print("SKIP: routes/agent.py not found")
659
-
660
- if os.path.exists(BACKEND_SESSION_LOGGER):
661
- content = _read(BACKEND_SESSION_LOGGER)
662
- content, changed = patch_session_logger(content)
663
- if changed:
664
- try:
665
- ast.parse(content)
666
- _write(BACKEND_SESSION_LOGGER, content)
667
- print("OK: session_logger.py patched")
668
- except SyntaxError as e:
669
- print(f"FAIL: session_logger.py syntax: {e}")
670
- else:
671
- print("SKIP: session_logger.py — no changes needed")
672
- else:
673
- print("SKIP: session_logger.py not found")
674
-
675
- print("\n=== v3 done ===")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_chat_input.py DELETED
@@ -1,105 +0,0 @@
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 DELETED
@@ -1,225 +0,0 @@
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 DELETED
@@ -1,105 +0,0 @@
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_flint_free.py DELETED
@@ -1,229 +0,0 @@
1
- """Patch backend + frontend: add recursive/flint-v1.0:free (OpenRouter).
2
-
3
- Uses the same `openai/` path convention as Laguna S 2.1:free:
4
- - backend constant: RECURSIVE_FLINT_V1_FREE_MODEL_ID = "openai/recursive/flint-v1.0:free"
5
- - registered in _available_models() backend list
6
- - frontend: new ModelOption in ChatInput.tsx DEFAULT_MODEL_OPTIONS
7
-
8
- The `openai/` prefix is stripped in _resolve_llm_params and routed to
9
- OpenRouter, so no changes to llm_params.py routing are needed.
10
-
11
- IMPORTANT: recursive/flint-v1.0:free has `reasoning: { mandatory: true }` on
12
- OpenRouter. The agent loop sends `reasoning_effort` via HF Router's
13
- `extra_body`, which OpenRouter doesn't translate to the `reasoning` parameter
14
- expected by the Recursive provider. We patch _resolve_llm_params to inject
15
- `reasoning: true` into the OpenRouter params for this model so the provider
16
- accepts the request.
17
-
18
- Runs AFTER patch_models.py / patch_new_models.py / patch_xing_nemotron_vl.py.
19
- Idempotent.
20
- """
21
-
22
- import ast
23
- import os
24
-
25
- IDS_FILE = "/app/agent/core/model_ids.py"
26
- AGENT_FILE = "/app/backend/routes/agent.py"
27
- LLM_PARAMS_FILE = "/app/agent/core/llm_params.py"
28
- FRONTEND_FILE = "/source/frontend/src/components/Chat/ChatInput.tsx"
29
-
30
- # ---------- 1. model_ids.py ----------
31
- with open(IDS_FILE) as f:
32
- c = f.read()
33
-
34
- new_constant = 'RECURSIVE_FLINT_V1_FREE_MODEL_ID = "openai/recursive/flint-v1.0:free"\n'
35
-
36
- if "RECURSIVE_FLINT_V1_FREE_MODEL_ID" not in c:
37
- # Anchor: the NEMOTRON_3_5_SUPER_VL line injected by patch_xing_nemotron_vl.py.
38
- # Fall back to LAGUNA_S21 line from patch_models.py.
39
- anchors = [
40
- 'NEMOTRON_3_5_SUPER_VL_FREE_MODEL_ID = "openai/nvidia/nemotron-3.5-super-vl:free"',
41
- 'LAGUNA_S21_FREE_MODEL_ID = "openai/poolside/laguna-s-2.1:free"',
42
- ]
43
- inserted = False
44
- for anchor in anchors:
45
- if anchor in c:
46
- c = c.replace(anchor, anchor + "\n" + new_constant, 1)
47
- print(f"OK: added constant to model_ids.py after {anchor.split(' =')[0]}")
48
- inserted = True
49
- break
50
- if not inserted:
51
- print("FAIL: no anchor found in model_ids.py for constant insertion")
52
- raise SystemExit(1)
53
-
54
- with open(IDS_FILE, "w") as f:
55
- f.write(c)
56
- ast.parse(c)
57
- print("OK: model_ids.py written & syntax OK")
58
- else:
59
- print("OK: model_ids.py already has RECURSIVE_FLINT_V1_FREE_MODEL_ID (idempotent)")
60
-
61
- # ---------- 2. agent.py : imports ----------
62
- with open(AGENT_FILE) as f:
63
- ac = f.read()
64
-
65
- if "RECURSIVE_FLINT_V1_FREE_MODEL_ID" not in ac:
66
- # patch_xing_nemotron_vl.py injected NEMOTRON_3_5_SUPER_VL_FREE_MODEL_ID into
67
- # the import block after LING_3_0_FLASH_FREE_MODEL_ID.
68
- imp_anchor = " LING_3_0_FLASH_FREE_MODEL_ID,\n"
69
- if imp_anchor in ac:
70
- ac = ac.replace(
71
- imp_anchor,
72
- imp_anchor
73
- + " NEMOTRON_3_5_SUPER_VL_FREE_MODEL_ID,\n"
74
- + " RECURSIVE_FLINT_V1_FREE_MODEL_ID,\n",
75
- 1,
76
- )
77
- print("OK: added RECURSIVE_FLINT_V1 import to agent.py")
78
- else:
79
- print("FAIL: import anchor not found in agent.py")
80
- raise SystemExit(1)
81
-
82
- # ---------- 3. agent.py : _available_models ----------
83
- # Insert after the NEMOTRON_3_5_SUPER_VL entry in _available_models.
84
- line_anchor = (
85
- ' {"id": NEMOTRON_3_5_SUPER_VL_FREE_MODEL_ID, '
86
- '"label": "nvidia/nemotron-3.5-super-vl:free", "recommended": True},\n'
87
- )
88
- new_line = line_anchor + (
89
- ' {"id": RECURSIVE_FLINT_V1_FREE_MODEL_ID, '
90
- '"label": "recursive/flint-v1.0:free", "recommended": True},\n'
91
- )
92
-
93
- if line_anchor in ac:
94
- ac = ac.replace(line_anchor, new_line, 1)
95
- print("OK: added recursive/flint-v1.0:free to _available_models()")
96
- else:
97
- print("WARN: NEMOTRON VL line anchor not matched, trying fallback...")
98
- # Fallback: insert after the LING_3_0_FLASH entry (more stable across patches).
99
- alt_anchor = (
100
- ' {"id": LING_3_0_FLASH_FREE_MODEL_ID, '
101
- '"label": "inclusionai/ling-3.0-flash:free", "recommended": True},\n'
102
- )
103
- if alt_anchor in ac:
104
- ac = ac.replace(
105
- alt_anchor,
106
- alt_anchor + new_line,
107
- 1,
108
- )
109
- print("OK: added recursive/flint-v1.0:free to _available_models() (fallback)")
110
- else:
111
- print("WARN: fallback anchor also not matched - model may already be present or patches changed")
112
-
113
- with open(AGENT_FILE, "w") as f:
114
- f.write(ac)
115
- ast.parse(ac)
116
- print("OK: agent.py written & syntax OK")
117
- else:
118
- print("OK: agent.py already has RECURSIVE_FLINT_V1_FREE_MODEL_ID (idempotent)")
119
-
120
- # ---------- 4. llm_params.py : reasoning support for recursive/flint-v1.0 ----------
121
- # The Flint model has reasoning: { mandatory: true } on OpenRouter. Without
122
- # sending reasoning=true, the Recursive provider returns 400 "Inference request failed".
123
- # We patch the openai/ routing branch to inject reasoning=true for this model.
124
- with open(LLM_PARAMS_FILE) as f:
125
- lp = f.read()
126
-
127
- if "flint" not in lp.lower():
128
- # Find the openai/ routing block and add reasoning support for flint models.
129
- # The patched llm_params.py has an openai/ branch that strips the prefix.
130
- # We inject reasoning=true into the returned params for flint models.
131
- flint_patch = (
132
- ' # Flint requires reasoning to be explicitly enabled (mandatory on OpenRouter)\n'
133
- ' if "flint" in or_model.lower():\n'
134
- ' params = {\n'
135
- ' "model": or_model,\n'
136
- ' "api_base": "https://openrouter.ai/api/v1",\n'
137
- ' "api_key": os.environ.get("OPENROUTER_API_KEY") or api_key or "",\n'
138
- ' "reasoning": {"effort": "low"},\n'
139
- ' }\n'
140
- ' return params\n'
141
- '\n'
142
- )
143
-
144
- # Check what the current openai/ branch looks like and inject before it returns
145
- # The existing patched llm_params.py has the openai/ branch that looks like:
146
- # if normalized_model.startswith("openai/"):
147
- # or_model = normalized_model[len("openai/"):]
148
- # return {
149
- # "model": or_model,
150
- # "api_base": "https://openrouter.ai/api/v1",
151
- # "api_key": os.environ.get("OPENROUTER_API_KEY") or api_key or "",
152
- # }
153
- openai_block = (
154
- ' if normalized_model.startswith("openai/"):\n'
155
- ' or_model = normalized_model[len("openai/"):]\n'
156
- ' return {\n'
157
- ' "model": or_model,\n'
158
- ' "api_base": "https://openrouter.ai/api/v1",\n'
159
- ' "api_key": os.environ.get("OPENROUTER_API_KEY") or api_key or "",\n'
160
- ' }\n'
161
- )
162
-
163
- if openai_block in lp:
164
- lp = lp.replace(
165
- openai_block,
166
- openai_block + "\n" + flint_patch
167
- )
168
- print("OK: patched llm_params.py to inject reasoning for flint models")
169
- else:
170
- # Fallback: try the custom_llm_provider variant (patched by patch_openrouter_fix.py)
171
- openai_block_v2 = (
172
- ' if normalized_model.startswith("openai/"):\n'
173
- ' or_model = normalized_model[len("openai/"):]\n'
174
- ' return {\n'
175
- ' "model": or_model,\n'
176
- ' "api_base": "https://openrouter.ai/api/v1",\n'
177
- ' "api_key": os.environ.get("OPENROUTER_API_KEY") or api_key or "",\n'
178
- ' "custom_llm_provider": "openrouter",\n'
179
- ' }\n'
180
- )
181
- if openai_block_v2 in lp:
182
- lp = lp.replace(
183
- openai_block_v2,
184
- openai_block_v2 + "\n" + flint_patch
185
- )
186
- print("OK: patched llm_params.py (v2) to inject reasoning for flint models")
187
- else:
188
- print("WARN: could not find openai/ routing block in llm_params.py")
189
- print(" - Flint model will be registered but may fail with 400 'Inference request failed'")
190
- print(" - The model has reasoning:{mandatory:true} on OpenRouter")
191
-
192
- with open(LLM_PARAMS_FILE, "w") as f:
193
- f.write(lp)
194
- ast.parse(lp)
195
- print("OK: llm_params.py written & syntax OK")
196
- else:
197
- print("OK: llm_params.py already patched for flint (idempotent)")
198
-
199
- # ---------- 5. Frontend ChatInput.tsx ----------
200
- if os.path.exists(FRONTEND_FILE):
201
- with open(FRONTEND_FILE, "r", encoding="utf-8") as f:
202
- fc = f.read()
203
-
204
- if "flint-v1.0" in fc:
205
- print("OK: recursive/flint-v1.0:free already present in ChatInput.tsx (idempotent)")
206
- else:
207
- # Anchor: the deepseek-v4-flash-latest entry at the end of DEFAULT_MODEL_OPTIONS.
208
- anchor = " {\n id: 'deepseek-v4-flash-latest',\n"
209
- new_entry = (
210
- " {\n"
211
- " id: 'flint-v1.0-free',\n"
212
- " name: 'recursive/flint-v1.0:free',\n"
213
- " modelPath: 'openai/recursive/flint-v1.0:free',\n"
214
- " avatarUrl: 'https://huggingface.co/api/avatars/recursive',\n"
215
- " recommended: true,\n"
216
- " },\n"
217
- )
218
-
219
- if anchor in fc:
220
- fc = fc.replace(anchor, new_entry + anchor, 1)
221
- with open(FRONTEND_FILE, "w", encoding="utf-8") as f:
222
- f.write(fc)
223
- print("OK: added recursive/flint-v1.0:free to ChatInput.tsx DEFAULT_MODEL_OPTIONS")
224
- else:
225
- print("WARN: deepseek anchor not found in ChatInput.tsx - frontend entry not added")
226
- else:
227
- print(f"WARN: {FRONTEND_FILE} not found (frontend patch skipped)")
228
-
229
- print("DONE: recursive/flint-v1.0:free added with Laguna S 2.1:free path convention + reasoning fix")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_flint_free_frontend.py DELETED
@@ -1,51 +0,0 @@
1
- """Patch frontend ChatInput.tsx: add recursive/flint-v1.0:free (OpenRouter).
2
-
3
- Uses the same `openai/` modelPath convention as Laguna S 2.1:free.
4
- The frontend modelPath is sent to the backend, which strips the `openai/`
5
- prefix and routes to OpenRouter via the existing catch-all in _resolve_llm_params.
6
-
7
- Runs AFTER patch_frontend_xing_nemotron_vl.py (which already rewrote
8
- DEFAULT_MODEL_OPTIONS). Idempotent.
9
- """
10
-
11
- import os
12
-
13
- FILE = "/source/frontend/src/components/Chat/ChatInput.tsx"
14
-
15
-
16
- def patch():
17
- if not os.path.exists(FILE):
18
- print(f"SKIP: {FILE} not found")
19
- return
20
-
21
- with open(FILE, "r", encoding="utf-8") as f:
22
- content = f.read()
23
-
24
- if "flint-v1.0" in content:
25
- print("OK: recursive/flint-v1.0:free already present in ChatInput.tsx (idempotent)")
26
- return
27
-
28
- # Anchor: the deepseek-v4-flash-latest entry at the end of DEFAULT_MODEL_OPTIONS.
29
- anchor = " {\n id: 'deepseek-v4-flash-latest',\n"
30
- new_entry = (
31
- " {\n"
32
- " id: 'flint-v1.0-free',\n"
33
- " name: 'recursive/flint-v1.0:free',\n"
34
- " modelPath: 'openai/recursive/flint-v1.0:free',\n"
35
- " avatarUrl: 'https://huggingface.co/api/avatars/recursive',\n"
36
- " recommended: true,\n"
37
- " },\n"
38
- )
39
-
40
- if anchor in content:
41
- content = content.replace(anchor, new_entry + anchor, 1)
42
- with open(FILE, "w", encoding="utf-8") as f:
43
- f.write(content)
44
- print("OK: added recursive/flint-v1.0:free to ChatInput.tsx DEFAULT_MODEL_OPTIONS")
45
- else:
46
- print("FAIL: deepseek anchor not found in ChatInput.tsx")
47
- return
48
-
49
-
50
- if __name__ == "__main__":
51
- patch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_frontend.py DELETED
@@ -1,199 +0,0 @@
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 DELETED
@@ -1,110 +0,0 @@
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 DELETED
@@ -1,120 +0,0 @@
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 DELETED
@@ -1,71 +0,0 @@
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 DELETED
@@ -1,45 +0,0 @@
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 DELETED
@@ -1,81 +0,0 @@
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_frontend_xing_nemotron_vl.py DELETED
@@ -1,63 +0,0 @@
1
- """Patch frontend ChatInput.tsx: add xingchen-agi/xing4.0-29b-a4b:free and
2
- nvidia/nemotron-3.5-super-vl:free with the same openai/ path convention as
3
- Laguna S 2.1:free.
4
-
5
- New models (openai/ prefix -> OpenRouter):
6
- - xingchen-agi/xing4.0-29b-a4b:free modelPath: openai/xingchen-agi/xing4.0-29b-a4b:free
7
- - nvidia/nemotron-3.5-super-vl:free modelPath: openai/nvidia/nemotron-3.5-super-vl:free
8
-
9
- Runs AFTER patch_frontend.py and patch_frontend_new_models.py (which already
10
- replaced DEFAULT_MODEL_OPTIONS). Idempotent.
11
- """
12
-
13
- import os
14
-
15
- FILE = "/source/frontend/src/components/Chat/ChatInput.tsx"
16
-
17
-
18
- def patch():
19
- if not os.path.exists(FILE):
20
- print(f"SKIP: {FILE} not found")
21
- return
22
-
23
- with open(FILE, "r", encoding="utf-8") as f:
24
- content = f.read()
25
-
26
- if "xingchen-agi/xing4.0-29b-a4b" in content:
27
- print("OK: models already present in ChatInput.tsx")
28
- return
29
-
30
- # Insert right before the deepseek-v4-flash-latest entry — a stable anchor
31
- # that exists after patch_frontend.py rewrites DEFAULT_MODEL_OPTIONS.
32
- anchor = " {\n id: 'deepseek-v4-flash-latest',\n"
33
- new_entries = (
34
- " {\n"
35
- " id: 'xingchen-xing4-0-29b',\n"
36
- " name: 'xingchen-agi/xing4.0-29b-a4b:free',\n"
37
- " modelPath: 'openai/xingchen-agi/xing4.0-29b-a4b:free',\n"
38
- " avatarUrl: 'https://huggingface.co/api/avatars/xingchen-agi',\n"
39
- " recommended: true,\n"
40
- " },\n"
41
- " {\n"
42
- " id: 'nemotron-3.5-super-vl',\n"
43
- " name: 'nvidia/nemotron-3.5-super-vl:free',\n"
44
- " modelPath: 'openai/nvidia/nemotron-3.5-super-vl:free',\n"
45
- " avatarUrl: 'https://huggingface.co/api/avatars/nvidia',\n"
46
- " recommended: true,\n"
47
- " },\n"
48
- )
49
-
50
- if anchor in content:
51
- content = content.replace(anchor, new_entries + anchor, 1)
52
- print("OK: added 2 models before deepseek-v4-flash-latest entry")
53
- else:
54
- print("FAIL: could not find insertion anchor in DEFAULT_MODEL_OPTIONS")
55
- return
56
-
57
- with open(FILE, "w", encoding="utf-8") as f:
58
- f.write(content)
59
- print("OK: ChatInput.tsx patched")
60
-
61
-
62
- if __name__ == "__main__":
63
- patch()