cct-cli 0.7.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- calc_terminal/__init__.py +14 -0
- calc_terminal/__main__.py +14 -0
- calc_terminal/activity.py +1334 -0
- calc_terminal/agent.py +3387 -0
- calc_terminal/agent_runtime.py +519 -0
- calc_terminal/ai_context.py +447 -0
- calc_terminal/ai_modes.py +752 -0
- calc_terminal/ai_personalization.py +286 -0
- calc_terminal/ai_preview_feedback.py +213 -0
- calc_terminal/aicore.py +2572 -0
- calc_terminal/anim.py +367 -0
- calc_terminal/app.py +3685 -0
- calc_terminal/art.py +639 -0
- calc_terminal/atomsim.py +368 -0
- calc_terminal/attachments.py +743 -0
- calc_terminal/benchmark_system.py +414 -0
- calc_terminal/browser/__init__.py +36 -0
- calc_terminal/browser/browser_state.py +346 -0
- calc_terminal/browser/devserver.py +176 -0
- calc_terminal/browser/engine.py +494 -0
- calc_terminal/browser/navigation.py +84 -0
- calc_terminal/browser/preview.py +429 -0
- calc_terminal/browser/preview_entry.py +95 -0
- calc_terminal/browser/project_detector.py +144 -0
- calc_terminal/browser/server.py +449 -0
- calc_terminal/browser/state.py +75 -0
- calc_terminal/browser/watcher.py +99 -0
- calc_terminal/browser_gui/__init__.py +1 -0
- calc_terminal/browser_gui/__main__.py +3 -0
- calc_terminal/browser_gui/launcher.py +173 -0
- calc_terminal/browser_gui/playwright_browser.py +117 -0
- calc_terminal/browser_gui/qt_browser.py +1501 -0
- calc_terminal/browser_gui/webview_browser.py +57 -0
- calc_terminal/capabilities/__init__.py +35 -0
- calc_terminal/capabilities/adapters/__init__.py +33 -0
- calc_terminal/capabilities/adapters/bioinformatics.py +204 -0
- calc_terminal/capabilities/adapters/browser_adapter.py +205 -0
- calc_terminal/capabilities/adapters/filesystem.py +206 -0
- calc_terminal/capabilities/adapters/git_adapter.py +202 -0
- calc_terminal/capabilities/adapters/jupyter_adapter.py +138 -0
- calc_terminal/capabilities/adapters/ml_frameworks.py +158 -0
- calc_terminal/capabilities/adapters/platforms.py +200 -0
- calc_terminal/capabilities/adapters/python_exec.py +93 -0
- calc_terminal/capabilities/adapters/quantum_adapter.py +150 -0
- calc_terminal/capabilities/adapters/scientific_comp.py +123 -0
- calc_terminal/capabilities/adapters/structural_bio.py +161 -0
- calc_terminal/capabilities/adapters/terminal.py +99 -0
- calc_terminal/capabilities/bus.py +178 -0
- calc_terminal/capabilities/discovery.py +207 -0
- calc_terminal/capabilities/schema.py +221 -0
- calc_terminal/cat.ico +0 -0
- calc_terminal/cat_browser.py +2018 -0
- calc_terminal/chat_store.py +703 -0
- calc_terminal/cli.py +1178 -0
- calc_terminal/code_editor.py +640 -0
- calc_terminal/collaboration.py +723 -0
- calc_terminal/commands_data.py +139 -0
- calc_terminal/compatibility_engine.py +352 -0
- calc_terminal/compute/__init__.py +31 -0
- calc_terminal/compute/fabric.py +350 -0
- calc_terminal/config.py +227 -0
- calc_terminal/core/__init__.py +41 -0
- calc_terminal/core/checkpoint.py +156 -0
- calc_terminal/core/input/__init__.py +45 -0
- calc_terminal/core/mode_registry.py +300 -0
- calc_terminal/core/project_graph.py +172 -0
- calc_terminal/core/recovery.py +129 -0
- calc_terminal/core/security_layer.py +112 -0
- calc_terminal/core/task_graph.py +202 -0
- calc_terminal/core/unified_runtime.py +184 -0
- calc_terminal/core/verification.py +257 -0
- calc_terminal/customization.py +1566 -0
- calc_terminal/derivations.py +153 -0
- calc_terminal/device_control.py +263 -0
- calc_terminal/diagnostics/__init__.py +27 -0
- calc_terminal/diagnostics/doctor_engine.py +382 -0
- calc_terminal/diagnostics/self_test.py +247 -0
- calc_terminal/doctor.py +519 -0
- calc_terminal/easter_eggs.py +274 -0
- calc_terminal/editor/__init__.py +1 -0
- calc_terminal/editor/actions.py +263 -0
- calc_terminal/editor/commands.py +160 -0
- calc_terminal/editor/shortcuts.py +226 -0
- calc_terminal/engine.py +259 -0
- calc_terminal/errors.py +120 -0
- calc_terminal/event_stream.py +146 -0
- calc_terminal/eventbus.py +133 -0
- calc_terminal/extensions.py +733 -0
- calc_terminal/fallback_cli.py +1321 -0
- calc_terminal/first_run.py +265 -0
- calc_terminal/fomoji_auth.py +1043 -0
- calc_terminal/formulas.py +82 -0
- calc_terminal/fs_cache.py +121 -0
- calc_terminal/fs_watcher.py +277 -0
- calc_terminal/game.py +193 -0
- calc_terminal/gen1.py +5 -0
- calc_terminal/generators.py +245 -0
- calc_terminal/gestures/__init__.py +42 -0
- calc_terminal/gestures/bindings.py +175 -0
- calc_terminal/gestures/manager.py +477 -0
- calc_terminal/goodbye.py +363 -0
- calc_terminal/gpu3d.py +290 -0
- calc_terminal/graphs.py +358 -0
- calc_terminal/hardware_analyzer.py +440 -0
- calc_terminal/host/__init__.py +30 -0
- calc_terminal/host/browser_manager.py +187 -0
- calc_terminal/host/desktop.py +1386 -0
- calc_terminal/host/launcher.py +395 -0
- calc_terminal/host/terminal.py +279 -0
- calc_terminal/identity.py +216 -0
- calc_terminal/input/__init__.py +54 -0
- calc_terminal/input/capabilities.py +258 -0
- calc_terminal/input/focus.py +87 -0
- calc_terminal/input/gestures.py +64 -0
- calc_terminal/input/pointer.py +114 -0
- calc_terminal/input/touch.py +345 -0
- calc_terminal/keys.py +84 -0
- calc_terminal/live_automation.py +165 -0
- calc_terminal/mathtext.py +433 -0
- calc_terminal/mcp.py +386 -0
- calc_terminal/memory.py +337 -0
- calc_terminal/memory_v2.py +479 -0
- calc_terminal/metrics.py +333 -0
- calc_terminal/mode_detection.py +146 -0
- calc_terminal/model.py +2431 -0
- calc_terminal/model_router.py +665 -0
- calc_terminal/models/__init__.py +0 -0
- calc_terminal/models/active_state.py +187 -0
- calc_terminal/models/dynamic_registry.py +584 -0
- calc_terminal/models/manager.py +781 -0
- calc_terminal/models/model_metadata.json +3526 -0
- calc_terminal/models/profiles.py +194 -0
- calc_terminal/models/registry.py +265 -0
- calc_terminal/models/schema.py +197 -0
- calc_terminal/models/validator.py +287 -0
- calc_terminal/models/verification_engine.py +368 -0
- calc_terminal/native_picker.py +215 -0
- calc_terminal/ollama_catalog.py +279 -0
- calc_terminal/ollama_download.py +233 -0
- calc_terminal/orchestrator.py +304 -0
- calc_terminal/package_research.py +322 -0
- calc_terminal/packages.py +1024 -0
- calc_terminal/pc_specs.py +116 -0
- calc_terminal/permissions.py +334 -0
- calc_terminal/pet.py +106 -0
- calc_terminal/pipeline.py +505 -0
- calc_terminal/platform/__init__.py +491 -0
- calc_terminal/platform/desktop.py +491 -0
- calc_terminal/platform/web.py +781 -0
- calc_terminal/preview/__init__.py +1 -0
- calc_terminal/preview/dev_server.py +303 -0
- calc_terminal/preview/diagnostics.py +131 -0
- calc_terminal/preview/live_reload.py +66 -0
- calc_terminal/preview/manager.py +129 -0
- calc_terminal/project_stats.py +209 -0
- calc_terminal/projects.py +328 -0
- calc_terminal/providers/__init__.py +0 -0
- calc_terminal/providers/adapters/__init__.py +80 -0
- calc_terminal/providers/adapters/anthropic_adapter.py +127 -0
- calc_terminal/providers/adapters/base.py +106 -0
- calc_terminal/providers/adapters/chinese_adapters.py +420 -0
- calc_terminal/providers/adapters/gemini_adapter.py +101 -0
- calc_terminal/providers/adapters/ollama_adapter.py +83 -0
- calc_terminal/providers/adapters/openai_adapter.py +159 -0
- calc_terminal/providers/adapters/other_adapters.py +246 -0
- calc_terminal/providers/anthropic_provider.py +172 -0
- calc_terminal/providers/auto_update.py +416 -0
- calc_terminal/providers/base_provider.py +105 -0
- calc_terminal/providers/discovery_manager.py +207 -0
- calc_terminal/providers/gemini_provider.py +178 -0
- calc_terminal/providers/lifecycle.py +767 -0
- calc_terminal/providers/ollama_adapter.py +707 -0
- calc_terminal/providers/openai_provider.py +254 -0
- calc_terminal/providers/provider_manager.py +1827 -0
- calc_terminal/providers/providers.json +4075 -0
- calc_terminal/reactionsim.py +279 -0
- calc_terminal/registry.py +337 -0
- calc_terminal/report.py +162 -0
- calc_terminal/research/__init__.py +45 -0
- calc_terminal/research/artifact_intel.py +126 -0
- calc_terminal/research/data_lineage.py +123 -0
- calc_terminal/research/experiment_ledger.py +303 -0
- calc_terminal/research/reproducibility.py +131 -0
- calc_terminal/resilience/__init__.py +47 -0
- calc_terminal/resilience/agent_state.py +121 -0
- calc_terminal/resilience/capability_matcher.py +174 -0
- calc_terminal/resilience/circuit_breaker.py +158 -0
- calc_terminal/resilience/failover_engine.py +230 -0
- calc_terminal/resilience/health_monitor.py +192 -0
- calc_terminal/resilience/ollama_adapter.py +125 -0
- calc_terminal/resilience/orchestrator.py +312 -0
- calc_terminal/resilience/types.py +134 -0
- calc_terminal/sandbox.py +98 -0
- calc_terminal/scires.py +558 -0
- calc_terminal/security_scanner.py +126 -0
- calc_terminal/session.py +294 -0
- calc_terminal/sim3d.py +206 -0
- calc_terminal/solver.py +276 -0
- calc_terminal/sound.py +127 -0
- calc_terminal/task_reports.py +287 -0
- calc_terminal/terminal_host.py +201 -0
- calc_terminal/terminal_identity.py +411 -0
- calc_terminal/test_ai_mode_reliability.py +344 -0
- calc_terminal/test_browser.py +368 -0
- calc_terminal/test_code_editor_upgrade.py +485 -0
- calc_terminal/test_customization.py +1148 -0
- calc_terminal/test_customization_ui.py +612 -0
- calc_terminal/test_dynamic_registry.py +304 -0
- calc_terminal/test_extensions.py +436 -0
- calc_terminal/test_overhaul.py +557 -0
- calc_terminal/test_project_detect.py +255 -0
- calc_terminal/test_root_cause_fix.py +527 -0
- calc_terminal/test_stability.py +532 -0
- calc_terminal/test_terminal_identity.py +132 -0
- calc_terminal/test_v079_speed.py +460 -0
- calc_terminal/theme.py +1107 -0
- calc_terminal/timeline.py +139 -0
- calc_terminal/todos.py +246 -0
- calc_terminal/tool_call_normalizer.py +419 -0
- calc_terminal/tui.py +104 -0
- calc_terminal/ui/__init__.py +8 -0
- calc_terminal/ui/activity_panel.py +231 -0
- calc_terminal/ui/activity_stream_panel.py +238 -0
- calc_terminal/ui/animations.py +122 -0
- calc_terminal/ui/app.py +7271 -0
- calc_terminal/ui/attach_panel.py +597 -0
- calc_terminal/ui/attachments.py +424 -0
- calc_terminal/ui/backup_panel.py +810 -0
- calc_terminal/ui/browser_shell.py +887 -0
- calc_terminal/ui/cat_agent.py +357 -0
- calc_terminal/ui/chats_panel.py +899 -0
- calc_terminal/ui/command_palette.py +125 -0
- calc_terminal/ui/command_palette_modal.py +166 -0
- calc_terminal/ui/composer.py +1141 -0
- calc_terminal/ui/context_menu.py +197 -0
- calc_terminal/ui/conversation.py +1435 -0
- calc_terminal/ui/customization_panel.py +1229 -0
- calc_terminal/ui/dashboard.py +404 -0
- calc_terminal/ui/design_system.py +557 -0
- calc_terminal/ui/diff_panel.py +213 -0
- calc_terminal/ui/editor.py +2102 -0
- calc_terminal/ui/empty_state.py +302 -0
- calc_terminal/ui/events.py +487 -0
- calc_terminal/ui/extensions_panel.py +815 -0
- calc_terminal/ui/footer.py +166 -0
- calc_terminal/ui/gestures_panel.py +383 -0
- calc_terminal/ui/goodbye_screen.py +100 -0
- calc_terminal/ui/header.py +1034 -0
- calc_terminal/ui/help_panel.py +254 -0
- calc_terminal/ui/live_activities.py +914 -0
- calc_terminal/ui/mcp_panel.py +570 -0
- calc_terminal/ui/memory_center.py +524 -0
- calc_terminal/ui/mode_colors_panel.py +525 -0
- calc_terminal/ui/nav_screens.py +747 -0
- calc_terminal/ui/ollama_panel.py +536 -0
- calc_terminal/ui/palette.py +221 -0
- calc_terminal/ui/permission_panel.py +269 -0
- calc_terminal/ui/personalization_panel.py +517 -0
- calc_terminal/ui/personalize_center.py +1568 -0
- calc_terminal/ui/preview_panel.py +441 -0
- calc_terminal/ui/resizers.py +402 -0
- calc_terminal/ui/sidebar.py +1285 -0
- calc_terminal/ui/statusbar.py +168 -0
- calc_terminal/ui/theme_css.py +1396 -0
- calc_terminal/ui/thinking.py +226 -0
- calc_terminal/ui/timeline_panel.py +102 -0
- calc_terminal/ui/todo_panel.py +193 -0
- calc_terminal/ui/viewport.py +136 -0
- calc_terminal/ui/vision_panel.py +489 -0
- calc_terminal/ui/welcome_modal.py +343 -0
- calc_terminal/ui/widgets.py +160 -0
- calc_terminal/ui/workspace.py +831 -0
- calc_terminal/viewers/__init__.py +1 -0
- calc_terminal/viewers/document_viewer.py +252 -0
- calc_terminal/viewers/image_viewer.py +241 -0
- calc_terminal/viewers/pdf_viewer.py +203 -0
- calc_terminal/viewers/presentation_viewer.py +164 -0
- calc_terminal/viewers/registry.py +120 -0
- calc_terminal/viewers/spreadsheet_viewer.py +204 -0
- calc_terminal/vision/__init__.py +89 -0
- calc_terminal/vision/analysis.py +194 -0
- calc_terminal/vision/annotations.py +297 -0
- calc_terminal/vision/capture.py +171 -0
- calc_terminal/vision/context.py +231 -0
- calc_terminal/vision/correlation.py +169 -0
- calc_terminal/vision/cursor.py +258 -0
- calc_terminal/vision/events.py +66 -0
- calc_terminal/vision/frame_pipeline.py +259 -0
- calc_terminal/vision/priority.py +218 -0
- calc_terminal/vision/provider.py +180 -0
- calc_terminal/vision/safety.py +149 -0
- calc_terminal/vision/session.py +281 -0
- calc_terminal/vision/verify.py +162 -0
- calc_terminal/vision.py +514 -0
- calc_terminal/vscode_integration.py +113 -0
- calc_terminal/web/__init__.py +8 -0
- calc_terminal/web/cat_runtime.py +710 -0
- calc_terminal/web/server.py +2891 -0
- calc_terminal/web/static/css/app.css +3152 -0
- calc_terminal/web/static/icons/badge-72.png +0 -0
- calc_terminal/web/static/icons/cat.ico +0 -0
- calc_terminal/web/static/icons/icon-128.png +0 -0
- calc_terminal/web/static/icons/icon-144.png +0 -0
- calc_terminal/web/static/icons/icon-152.png +0 -0
- calc_terminal/web/static/icons/icon-192.png +0 -0
- calc_terminal/web/static/icons/icon-384.png +0 -0
- calc_terminal/web/static/icons/icon-512.png +0 -0
- calc_terminal/web/static/icons/icon-72.png +0 -0
- calc_terminal/web/static/icons/icon-96.png +0 -0
- calc_terminal/web/static/icons/icon.svg +34 -0
- calc_terminal/web/static/icons/new-project.png +0 -0
- calc_terminal/web/static/icons/open-project.png +0 -0
- calc_terminal/web/static/index.html +734 -0
- calc_terminal/web/static/js/app.js +2403 -0
- calc_terminal/web/static/manifest.json +88 -0
- calc_terminal/web/static/sw.js +230 -0
- calc_terminal/workflow_engine.py +769 -0
- calc_terminal/workspace.py +593 -0
- calc_terminal/workspace_index.py +385 -0
- cct_cli-0.7.9.0.dist-info/METADATA +210 -0
- cct_cli-0.7.9.0.dist-info/RECORD +325 -0
- cct_cli-0.7.9.0.dist-info/WHEEL +5 -0
- cct_cli-0.7.9.0.dist-info/entry_points.txt +4 -0
- cct_cli-0.7.9.0.dist-info/licenses/LICENSE +21 -0
- cct_cli-0.7.9.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,2891 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FATTY CAT Web Server
|
|
3
|
+
|
|
4
|
+
FastAPI server that serves the PWA frontend and bridges to CAT core.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
import uuid
|
|
15
|
+
from contextlib import asynccontextmanager
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Dict, List, Optional, Set
|
|
18
|
+
|
|
19
|
+
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, WebSocket, WebSocketDisconnect
|
|
20
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
21
|
+
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
|
22
|
+
from fastapi.staticfiles import StaticFiles
|
|
23
|
+
from pydantic import BaseModel
|
|
24
|
+
|
|
25
|
+
# Add parent directory to path for CAT core imports (source-checkout only).
|
|
26
|
+
# When installed via pip, `calc_terminal` is already importable — do not
|
|
27
|
+
# mutate sys.path. The guard below keeps `python -m calc_terminal.web.server`
|
|
28
|
+
# working identically in both layouts.
|
|
29
|
+
try:
|
|
30
|
+
import calc_terminal as _ct_check # noqa: F401
|
|
31
|
+
except Exception:
|
|
32
|
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
33
|
+
|
|
34
|
+
from calc_terminal import aicore, projects, workspace as ws_paths
|
|
35
|
+
from calc_terminal.browser.preview import PreviewController, _shutdown_all as preview_shutdown_all
|
|
36
|
+
from calc_terminal.browser.server import LiveServer
|
|
37
|
+
from calc_terminal.browser.devserver import DevServerProcess
|
|
38
|
+
from calc_terminal.browser.project_detector import detect_project, dependencies_installed
|
|
39
|
+
from calc_terminal.browser.preview_entry import find_entry_file, relative_url_for
|
|
40
|
+
from calc_terminal.browser.watcher import PreviewFileWatcher, is_web_file
|
|
41
|
+
from calc_terminal.browser.state import PreviewState, ServerState
|
|
42
|
+
from calc_terminal.permissions import manager as perm_manager, PERMISSION_DEFS, MODES, MODE_LABELS
|
|
43
|
+
from calc_terminal.web.cat_runtime import runtime
|
|
44
|
+
from calc_terminal import eventbus
|
|
45
|
+
from calc_terminal import (
|
|
46
|
+
chat_store,
|
|
47
|
+
mcp,
|
|
48
|
+
theme,
|
|
49
|
+
extensions as cct_ext,
|
|
50
|
+
fomoji_auth,
|
|
51
|
+
identity,
|
|
52
|
+
ai_personalization as ap,
|
|
53
|
+
customization as cust,
|
|
54
|
+
)
|
|
55
|
+
from calc_terminal.providers import provider_manager as pm
|
|
56
|
+
from calc_terminal.ui import theme_css, header as cct_header, welcome_modal
|
|
57
|
+
from calc_terminal.gestures import manager as gestures_mgr
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# --- Models ---
|
|
61
|
+
|
|
62
|
+
class CommandRequest(BaseModel):
|
|
63
|
+
cmd: str
|
|
64
|
+
cwd: Optional[str] = None
|
|
65
|
+
env: Optional[Dict[str, str]] = None
|
|
66
|
+
timeout: Optional[float] = 30.0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class CommandResponse(BaseModel):
|
|
70
|
+
returncode: int
|
|
71
|
+
stdout: str
|
|
72
|
+
stderr: str
|
|
73
|
+
success: bool
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class FileReadRequest(BaseModel):
|
|
77
|
+
path: str
|
|
78
|
+
encoding: Optional[str] = "utf-8"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class FileWriteRequest(BaseModel):
|
|
82
|
+
path: str
|
|
83
|
+
content: str
|
|
84
|
+
encoding: Optional[str] = "utf-8"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class PreviewStartRequest(BaseModel):
|
|
88
|
+
file_path: str
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class PreviewNavigateRequest(BaseModel):
|
|
92
|
+
url: str
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class ChatRequest(BaseModel):
|
|
96
|
+
message: str
|
|
97
|
+
mode: Optional[str] = "chat"
|
|
98
|
+
project_path: Optional[str] = None
|
|
99
|
+
chat_id: Optional[str] = None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class WorkspaceRequest(BaseModel):
|
|
103
|
+
path: str
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class PermissionToggleRequest(BaseModel):
|
|
107
|
+
key: str
|
|
108
|
+
enabled: bool
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class PermissionModeRequest(BaseModel):
|
|
112
|
+
mode: str
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class PermissionDecideRequest(BaseModel):
|
|
116
|
+
key: str
|
|
117
|
+
decision: str
|
|
118
|
+
action_text: Optional[str] = ""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class ModeSwitchRequest(BaseModel):
|
|
122
|
+
mode: str
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class CustomModeRequest(BaseModel):
|
|
126
|
+
key: str
|
|
127
|
+
label: str
|
|
128
|
+
icon: str
|
|
129
|
+
accent_hex: str
|
|
130
|
+
purpose: Optional[str] = ""
|
|
131
|
+
system_prompt: Optional[str] = ""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class ModelSwitchRequest(BaseModel):
|
|
135
|
+
provider: str
|
|
136
|
+
model: str
|
|
137
|
+
api_key: Optional[str] = None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class CommandDispatchRequest(BaseModel):
|
|
141
|
+
command: str
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class FactRequest(BaseModel):
|
|
145
|
+
fact: str
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class CustomizationLayoutRequest(BaseModel):
|
|
149
|
+
component_id: str
|
|
150
|
+
updates: Dict[str, Any]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class ExtensionToggleRequest(BaseModel):
|
|
154
|
+
id: str
|
|
155
|
+
enabled: bool
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class ExtensionCreateRequest(BaseModel):
|
|
159
|
+
id: str
|
|
160
|
+
name: str
|
|
161
|
+
description: Optional[str] = ""
|
|
162
|
+
icon: Optional[str] = "⚡"
|
|
163
|
+
publisher: Optional[str] = "Custom"
|
|
164
|
+
category: Optional[str] = "Custom"
|
|
165
|
+
version: Optional[str] = "1.0.0"
|
|
166
|
+
script_code: Optional[str] = ""
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class ScienceSolveRequest(BaseModel):
|
|
170
|
+
key: Optional[str] = None
|
|
171
|
+
formula: Optional[str] = None
|
|
172
|
+
values: Optional[Dict[str, Any]] = None
|
|
173
|
+
params: Optional[Dict[str, Any]] = None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class ScienceDeriveRequest(BaseModel):
|
|
177
|
+
keyword: Optional[str] = None
|
|
178
|
+
derivation: Optional[str] = None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class ScienceAtomRequest(BaseModel):
|
|
182
|
+
element: str
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class RecentPinRequest(BaseModel):
|
|
186
|
+
path: str
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class RecentRemoveRequest(BaseModel):
|
|
190
|
+
path: str
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class ChatCreateRequest(BaseModel):
|
|
194
|
+
name: Optional[str] = None
|
|
195
|
+
project_path: Optional[str] = None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class ChatRenameRequest(BaseModel):
|
|
199
|
+
name: str
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class McpServerRequest(BaseModel):
|
|
203
|
+
id: Optional[str] = None
|
|
204
|
+
name: str
|
|
205
|
+
kind: Optional[str] = "remote"
|
|
206
|
+
command: Optional[str] = ""
|
|
207
|
+
url: Optional[str] = ""
|
|
208
|
+
headers: Optional[Dict[str, str]] = None
|
|
209
|
+
enabled: Optional[bool] = True
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class BackupProvidersSaveRequest(BaseModel):
|
|
213
|
+
providers: List[Any]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class ThemeSelectRequest(BaseModel):
|
|
217
|
+
theme: str
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class GestureToggleRequest(BaseModel):
|
|
221
|
+
id: str
|
|
222
|
+
enabled: bool
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class ProfileActiveRequest(BaseModel):
|
|
226
|
+
id: Optional[str] = None
|
|
227
|
+
name: Optional[str] = None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class ProfileSaveRequest(BaseModel):
|
|
231
|
+
id: Optional[str] = None
|
|
232
|
+
name: Optional[str] = None
|
|
233
|
+
prompt: Optional[str] = None
|
|
234
|
+
temperature: Optional[float] = None
|
|
235
|
+
tone: Optional[str] = None
|
|
236
|
+
profile: Optional[Dict[str, Any]] = None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# --- Global State ---
|
|
240
|
+
|
|
241
|
+
class ServerStateManager:
|
|
242
|
+
def __init__(self):
|
|
243
|
+
self.preview_controllers: Dict[str, PreviewController] = {}
|
|
244
|
+
self.workspace_roots: Dict[str, str] = {}
|
|
245
|
+
self.active_websockets: Set[WebSocket] = set()
|
|
246
|
+
self.processes: Dict[str, asyncio.subprocess.Process] = {}
|
|
247
|
+
|
|
248
|
+
def get_preview_controller(self, workspace_id: str) -> Optional[PreviewController]:
|
|
249
|
+
return self.preview_controllers.get(workspace_id)
|
|
250
|
+
|
|
251
|
+
def set_workspace_root(self, workspace_id: str, path: str):
|
|
252
|
+
self.workspace_roots[workspace_id] = os.path.abspath(path)
|
|
253
|
+
|
|
254
|
+
def get_workspace_root(self, workspace_id: str) -> Optional[str]:
|
|
255
|
+
if workspace_id in self.workspace_roots:
|
|
256
|
+
return self.workspace_roots[workspace_id]
|
|
257
|
+
if workspace_id in ("current", "default", "", None):
|
|
258
|
+
active = ws_paths.active_project()
|
|
259
|
+
if active and os.path.isdir(active):
|
|
260
|
+
return active
|
|
261
|
+
candidate = os.path.abspath(os.path.expanduser(r"c:\Users\ADMIN\Downloads\compressed"))
|
|
262
|
+
if os.path.isdir(candidate):
|
|
263
|
+
return candidate
|
|
264
|
+
try:
|
|
265
|
+
recent = projects.recent()
|
|
266
|
+
if recent and os.path.isdir(recent[0]):
|
|
267
|
+
return recent[0]
|
|
268
|
+
except Exception:
|
|
269
|
+
pass
|
|
270
|
+
return os.getcwd()
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
state_manager = ServerStateManager()
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# --- Lifespan ---
|
|
278
|
+
|
|
279
|
+
@asynccontextmanager
|
|
280
|
+
async def lifespan(app: FastAPI):
|
|
281
|
+
# Startup
|
|
282
|
+
print("Starting FATTY CAT server...")
|
|
283
|
+
candidate = os.path.abspath(os.path.expanduser(r"c:\Users\ADMIN\Downloads\compressed"))
|
|
284
|
+
if os.path.isdir(candidate):
|
|
285
|
+
state_manager.set_workspace_root("default", candidate)
|
|
286
|
+
state_manager.set_workspace_root("current", candidate)
|
|
287
|
+
ws_paths.set_active_project(candidate)
|
|
288
|
+
try:
|
|
289
|
+
projects.record_opened(candidate)
|
|
290
|
+
except Exception:
|
|
291
|
+
pass
|
|
292
|
+
yield
|
|
293
|
+
# Shutdown
|
|
294
|
+
print("Shutting down FATTY CAT server...")
|
|
295
|
+
preview_shutdown_all()
|
|
296
|
+
for proc in state_manager.processes.values():
|
|
297
|
+
try:
|
|
298
|
+
proc.terminate()
|
|
299
|
+
except Exception:
|
|
300
|
+
pass
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# --- FastAPI App ---
|
|
304
|
+
|
|
305
|
+
app = FastAPI(
|
|
306
|
+
title="FATTY CAT",
|
|
307
|
+
description="Mobile PWA for CAT - Coding Agent Terminal",
|
|
308
|
+
version="0.7.9.0",
|
|
309
|
+
lifespan=lifespan,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
app.add_middleware(
|
|
313
|
+
CORSMiddleware,
|
|
314
|
+
allow_origins=["*"],
|
|
315
|
+
allow_credentials=True,
|
|
316
|
+
allow_methods=["*"],
|
|
317
|
+
allow_headers=["*"],
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# --- Static Files & PWA ---
|
|
322
|
+
|
|
323
|
+
# Mount static files
|
|
324
|
+
static_dir = Path(__file__).parent / "static"
|
|
325
|
+
if static_dir.exists():
|
|
326
|
+
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
|
327
|
+
|
|
328
|
+
# Mount workspace files for preview. Writable preview workspaces MUST live in
|
|
329
|
+
# the user's app-data dir — never inside site-packages (read-only when
|
|
330
|
+
# installed) and never tied to the git checkout. Falls back to the legacy
|
|
331
|
+
# repo-relative location only if the app-data dir is unavailable.
|
|
332
|
+
def _preview_workspaces_dir() -> Path:
|
|
333
|
+
try:
|
|
334
|
+
from calc_terminal.first_run import data_dir as _app_data_dir
|
|
335
|
+
|
|
336
|
+
p = Path(_app_data_dir()) / "workspaces"
|
|
337
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
338
|
+
return p
|
|
339
|
+
except Exception:
|
|
340
|
+
pass
|
|
341
|
+
try:
|
|
342
|
+
legacy = Path(__file__).parent.parent.parent / "workspaces"
|
|
343
|
+
legacy.mkdir(exist_ok=True)
|
|
344
|
+
return legacy
|
|
345
|
+
except Exception:
|
|
346
|
+
return Path.cwd() / "workspaces"
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
workspaces_dir = _preview_workspaces_dir()
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# --- Root Route (PWA Entry) ---
|
|
353
|
+
|
|
354
|
+
@app.get("/", response_class=HTMLResponse)
|
|
355
|
+
async def serve_index():
|
|
356
|
+
index_path = static_dir / "index.html"
|
|
357
|
+
if index_path.exists():
|
|
358
|
+
return HTMLResponse(content=index_path.read_text(encoding="utf-8"))
|
|
359
|
+
return HTMLResponse(content="<h1>FATTY CAT</h1><p>index.html not found.</p>", status_code=404)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# --- Health Check ---
|
|
363
|
+
|
|
364
|
+
@app.get("/health")
|
|
365
|
+
async def health_check():
|
|
366
|
+
return {"status": "ok", "service": "FATTY CAT", "version": "0.7.9.0"}
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# --- Platform Info ---
|
|
370
|
+
|
|
371
|
+
@app.get("/api/platform")
|
|
372
|
+
async def get_platform_info():
|
|
373
|
+
from calc_terminal.platform import get_platform
|
|
374
|
+
platform = get_platform()
|
|
375
|
+
info = platform.info
|
|
376
|
+
return {
|
|
377
|
+
"type": info.type.value,
|
|
378
|
+
"name": info.name,
|
|
379
|
+
"version": info.version,
|
|
380
|
+
"is_mobile": info.is_mobile,
|
|
381
|
+
"supports_file_system_access": info.supports_file_system_access,
|
|
382
|
+
"supports_notifications": info.supports_notifications,
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# --- Capability Discovery & System Health ---
|
|
387
|
+
|
|
388
|
+
@app.get("/api/capabilities")
|
|
389
|
+
async def get_capabilities():
|
|
390
|
+
"""Return complete dynamic CAT capability registry and version contract."""
|
|
391
|
+
return runtime.get_capabilities()
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
@app.get("/api/auth/status")
|
|
395
|
+
async def get_auth_status():
|
|
396
|
+
"""Get Fomoji identity and authentication state."""
|
|
397
|
+
try:
|
|
398
|
+
from calc_terminal import fomoji_auth
|
|
399
|
+
return {
|
|
400
|
+
"authenticated": fomoji_auth.is_authenticated(),
|
|
401
|
+
"identity": fomoji_auth.get_identity(),
|
|
402
|
+
"state": fomoji_auth.status(),
|
|
403
|
+
"fomoji_url": fomoji_auth.get_fomoji_url(),
|
|
404
|
+
}
|
|
405
|
+
except Exception as e:
|
|
406
|
+
return {"authenticated": False, "identity": None, "error": str(e)}
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
@app.get("/api/diagnostics")
|
|
410
|
+
async def get_diagnostics():
|
|
411
|
+
"""Return runtime metrics, event log, active model/provider, hardware specs."""
|
|
412
|
+
return runtime.get_diagnostics()
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
# --- Permissions ---
|
|
416
|
+
|
|
417
|
+
@app.get("/api/permissions")
|
|
418
|
+
async def get_permissions():
|
|
419
|
+
"""Get current permission state."""
|
|
420
|
+
snapshot = perm_manager.snapshot()
|
|
421
|
+
return {
|
|
422
|
+
"mode": perm_manager.mode,
|
|
423
|
+
"mode_label": perm_manager.mode_label(),
|
|
424
|
+
"permissions": [
|
|
425
|
+
{"key": k, "label": l, "enabled": v} for k, l, v in snapshot
|
|
426
|
+
],
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
@app.post("/api/permissions/mode")
|
|
431
|
+
async def set_permission_mode(request: PermissionModeRequest):
|
|
432
|
+
"""Set permission mode (ask/restricted/full)."""
|
|
433
|
+
if request.mode not in MODES:
|
|
434
|
+
raise HTTPException(status_code=400, detail=f"Invalid mode: {request.mode}")
|
|
435
|
+
perm_manager.set_mode(request.mode)
|
|
436
|
+
return {"mode": perm_manager.mode, "mode_label": perm_manager.mode_label()}
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
@app.post("/api/permissions/toggle")
|
|
440
|
+
async def toggle_permission(request: PermissionToggleRequest):
|
|
441
|
+
"""Toggle a permission key on/off."""
|
|
442
|
+
result = perm_manager.set(request.key, request.enabled)
|
|
443
|
+
return {"key": request.key, "enabled": request.enabled}
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
@app.post("/api/permissions/decide")
|
|
447
|
+
async def decide_permission(request: PermissionDecideRequest):
|
|
448
|
+
"""Record a permission decision (allow_once/always_allow/deny/always_deny)."""
|
|
449
|
+
allowed = perm_manager.decide(request.key, request.decision, request.action_text or "")
|
|
450
|
+
return {"key": request.key, "decision": request.decision, "allowed": allowed}
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
@app.get("/api/permissions/log")
|
|
454
|
+
async def permission_log():
|
|
455
|
+
"""Get the permission decision log."""
|
|
456
|
+
return {
|
|
457
|
+
"log": [
|
|
458
|
+
{"time": t, "key": k, "action": a, "decision": d}
|
|
459
|
+
for t, k, a, d in perm_manager.log
|
|
460
|
+
]
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
# --- Main Menu Dynamic Endpoint ---
|
|
465
|
+
|
|
466
|
+
@app.get("/api/menu")
|
|
467
|
+
async def get_main_menu():
|
|
468
|
+
"""Dynamically get the exact Main Menu structure from CAT CLI.
|
|
469
|
+
Directly bridges to calc_terminal.ui.header.get_nav_items().
|
|
470
|
+
Includes dynamic 'Downloaded Extensions ({count})' and real installed extensions.
|
|
471
|
+
"""
|
|
472
|
+
try:
|
|
473
|
+
items = cct_header.get_nav_items()
|
|
474
|
+
except Exception:
|
|
475
|
+
items = cct_header._NAV_ITEMS_BASE
|
|
476
|
+
menu_items = []
|
|
477
|
+
for item in items:
|
|
478
|
+
action_id, icon, label = item
|
|
479
|
+
menu_items.append({
|
|
480
|
+
"action": action_id,
|
|
481
|
+
"icon": icon,
|
|
482
|
+
"label": label,
|
|
483
|
+
"is_separator": action_id.startswith("sep"),
|
|
484
|
+
"is_section_header": action_id.startswith("__section"),
|
|
485
|
+
"is_placeholder": action_id.startswith("__placeholder") or action_id.startswith("__hint"),
|
|
486
|
+
})
|
|
487
|
+
installed = cct_ext.list_installed()
|
|
488
|
+
return {
|
|
489
|
+
"items": menu_items,
|
|
490
|
+
"downloaded_count": len(installed),
|
|
491
|
+
"installed": installed,
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
# --- Filesystem Browser & Path Validation (Method A & Method B) ---
|
|
496
|
+
|
|
497
|
+
@app.get("/api/fs/browse")
|
|
498
|
+
async def browse_filesystem(path: Optional[str] = None):
|
|
499
|
+
"""Real filesystem browser for Method A (Browse).
|
|
500
|
+
Navigates actual folders on the host, parent folders, drives (Windows),
|
|
501
|
+
and enforces protected directory warnings.
|
|
502
|
+
"""
|
|
503
|
+
import string
|
|
504
|
+
if not path or not path.strip():
|
|
505
|
+
active = ws_paths.active_project()
|
|
506
|
+
if active and os.path.isdir(active):
|
|
507
|
+
target = os.path.abspath(active)
|
|
508
|
+
else:
|
|
509
|
+
target = os.path.abspath(os.path.expanduser("~"))
|
|
510
|
+
else:
|
|
511
|
+
target = os.path.abspath(os.path.expanduser(path.strip()))
|
|
512
|
+
|
|
513
|
+
if not os.path.exists(target):
|
|
514
|
+
target = os.path.abspath(os.path.expanduser("~"))
|
|
515
|
+
|
|
516
|
+
drives = []
|
|
517
|
+
if sys.platform == "win32":
|
|
518
|
+
for letter in string.ascii_uppercase:
|
|
519
|
+
d_root = f"{letter}:\\"
|
|
520
|
+
if os.path.exists(d_root):
|
|
521
|
+
drives.append(d_root)
|
|
522
|
+
else:
|
|
523
|
+
drives.append("/")
|
|
524
|
+
|
|
525
|
+
parent_path = str(Path(target).parent) if Path(target).parent != Path(target) else None
|
|
526
|
+
items = []
|
|
527
|
+
err = None
|
|
528
|
+
try:
|
|
529
|
+
with os.scandir(target) as it:
|
|
530
|
+
for entry in it:
|
|
531
|
+
try:
|
|
532
|
+
if entry.is_dir(follow_symlinks=False):
|
|
533
|
+
p = entry.path
|
|
534
|
+
is_prot = ws_paths.path_is_protected(p)
|
|
535
|
+
items.append({
|
|
536
|
+
"name": entry.name,
|
|
537
|
+
"path": p,
|
|
538
|
+
"is_dir": True,
|
|
539
|
+
"protected": is_prot,
|
|
540
|
+
})
|
|
541
|
+
except (PermissionError, OSError):
|
|
542
|
+
continue
|
|
543
|
+
items.sort(key=lambda x: x["name"].lower())
|
|
544
|
+
except (PermissionError, OSError) as e:
|
|
545
|
+
err = str(e)
|
|
546
|
+
|
|
547
|
+
return {
|
|
548
|
+
"current_path": target,
|
|
549
|
+
"parent_path": parent_path,
|
|
550
|
+
"drives": drives,
|
|
551
|
+
"folders": items,
|
|
552
|
+
"error": err,
|
|
553
|
+
"protected": ws_paths.path_is_protected(target),
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
@app.get("/api/fs/validate")
|
|
558
|
+
async def validate_filesystem_path(path: str):
|
|
559
|
+
"""Validate path for Method B (Enter Path) with live validation."""
|
|
560
|
+
if not path or not path.strip():
|
|
561
|
+
return {"valid": False, "message": "Please enter a folder path"}
|
|
562
|
+
expanded = os.path.abspath(os.path.expanduser(path.strip()))
|
|
563
|
+
if not os.path.exists(expanded):
|
|
564
|
+
return {"valid": False, "message": "⚠ Path does not exist", "path": expanded}
|
|
565
|
+
if not os.path.isdir(expanded):
|
|
566
|
+
return {"valid": False, "message": "⚠ Not a directory", "path": expanded}
|
|
567
|
+
if not os.access(expanded, os.R_OK):
|
|
568
|
+
return {"valid": False, "message": "⚠ No read permission", "path": expanded}
|
|
569
|
+
if ws_paths.path_is_protected(expanded):
|
|
570
|
+
return {"valid": False, "message": "⚠ Protected system folder — refusing to use as workspace", "path": expanded}
|
|
571
|
+
name = os.path.basename(expanded) or expanded
|
|
572
|
+
return {"valid": True, "message": f"✓ {name} — folder is valid", "path": expanded, "name": name}
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
# --- Workspace Management ---
|
|
576
|
+
|
|
577
|
+
@app.post("/api/workspace/open")
|
|
578
|
+
async def open_workspace(request: WorkspaceRequest):
|
|
579
|
+
"""Open folder using the exact CAT workspace logic."""
|
|
580
|
+
path = os.path.abspath(os.path.expanduser(request.path))
|
|
581
|
+
if not os.path.exists(path):
|
|
582
|
+
try:
|
|
583
|
+
os.makedirs(path, exist_ok=True)
|
|
584
|
+
except Exception:
|
|
585
|
+
raise HTTPException(status_code=400, detail=f"Path does not exist and could not be created: {path}")
|
|
586
|
+
if not os.path.isdir(path):
|
|
587
|
+
raise HTTPException(status_code=400, detail=f"Not a valid directory: {path}")
|
|
588
|
+
if not os.access(path, os.R_OK):
|
|
589
|
+
raise HTTPException(status_code=403, detail=f"No read permission for directory: {path}")
|
|
590
|
+
if ws_paths.path_is_protected(path):
|
|
591
|
+
raise HTTPException(status_code=403, detail=f"Refusing to open protected system folder: {path}")
|
|
592
|
+
|
|
593
|
+
# Set as active project in both CAT and server
|
|
594
|
+
state_manager.set_workspace_root("default", path)
|
|
595
|
+
state_manager.set_workspace_root("current", path)
|
|
596
|
+
ws_paths.set_active_project(path)
|
|
597
|
+
try:
|
|
598
|
+
projects.record_opened(path)
|
|
599
|
+
except Exception as e:
|
|
600
|
+
print(f"projects.record_opened failed: {e}")
|
|
601
|
+
|
|
602
|
+
try:
|
|
603
|
+
eventbus.bus.publish(eventbus.WORKSPACE_OPENED, path=path)
|
|
604
|
+
except Exception:
|
|
605
|
+
pass
|
|
606
|
+
|
|
607
|
+
return {
|
|
608
|
+
"id": "current",
|
|
609
|
+
"workspace_id": "current",
|
|
610
|
+
"path": path,
|
|
611
|
+
"name": os.path.basename(path) or path,
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
@app.get("/api/workspace/recent")
|
|
616
|
+
async def recent_workspaces():
|
|
617
|
+
"""Get full list of workspaces from projects.workspaces()."""
|
|
618
|
+
try:
|
|
619
|
+
ws_list = projects.workspaces()
|
|
620
|
+
for w in ws_list:
|
|
621
|
+
w["exists"] = os.path.isdir(w.get("path", ""))
|
|
622
|
+
return {"workspaces": ws_list, "recent": projects.recent()}
|
|
623
|
+
except Exception as e:
|
|
624
|
+
return {"workspaces": [], "recent": [], "error": str(e)}
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
@app.post("/api/workspace/recent/pin")
|
|
628
|
+
async def toggle_pin_recent(request: RecentPinRequest):
|
|
629
|
+
"""Toggle pin state for a recent workspace."""
|
|
630
|
+
pinned = projects.toggle_pin(request.path)
|
|
631
|
+
return {"path": request.path, "pinned": pinned}
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
@app.post("/api/workspace/recent/remove")
|
|
635
|
+
async def remove_recent_workspace(request: RecentRemoveRequest):
|
|
636
|
+
"""Remove workspace from recent history."""
|
|
637
|
+
removed = projects.remove(request.path)
|
|
638
|
+
return {"path": request.path, "removed": removed}
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
@app.post("/api/workspace/recent/clear")
|
|
642
|
+
async def clear_recent_workspaces():
|
|
643
|
+
"""Clear all recent workspaces."""
|
|
644
|
+
cleared = projects.clear_history()
|
|
645
|
+
return {"cleared": cleared}
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
# --- Chats & Session History (Real CAT chat_store) ---
|
|
649
|
+
|
|
650
|
+
@app.get("/api/chats")
|
|
651
|
+
async def list_saved_chats():
|
|
652
|
+
"""List all saved chat summaries from CAT chat_store."""
|
|
653
|
+
return {"chats": chat_store.get_chat_summaries()}
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
@app.get("/api/chats/{chat_id}")
|
|
657
|
+
async def get_chat_session(chat_id: str):
|
|
658
|
+
"""Get full chat with turns."""
|
|
659
|
+
chat = chat_store.get_chat(chat_id)
|
|
660
|
+
if not chat:
|
|
661
|
+
raise HTTPException(status_code=404, detail="Chat session not found")
|
|
662
|
+
return chat
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
@app.post("/api/chats")
|
|
666
|
+
async def create_new_chat(req: ChatCreateRequest):
|
|
667
|
+
"""Create a new empty chat session."""
|
|
668
|
+
active_ws = ws_paths.active_project() or ""
|
|
669
|
+
chat = chat_store.create_chat(
|
|
670
|
+
name=req.name,
|
|
671
|
+
workspace_root=req.project_path or active_ws,
|
|
672
|
+
project_path=req.project_path or active_ws,
|
|
673
|
+
)
|
|
674
|
+
return chat
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
@app.post("/api/chats/{chat_id}/rename")
|
|
678
|
+
async def rename_chat_session(chat_id: str, req: ChatRenameRequest):
|
|
679
|
+
"""Rename a chat session."""
|
|
680
|
+
ok = chat_store.rename_chat(chat_id, req.name)
|
|
681
|
+
if not ok:
|
|
682
|
+
raise HTTPException(status_code=404, detail="Chat session not found")
|
|
683
|
+
return {"success": True, "id": chat_id, "name": req.name}
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
@app.delete("/api/chats/{chat_id}")
|
|
687
|
+
async def delete_chat_session(chat_id: str):
|
|
688
|
+
"""Delete a chat session from disk."""
|
|
689
|
+
ok = chat_store.delete_chat(chat_id)
|
|
690
|
+
return {"success": ok}
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
# --- Model Context Protocol (Real CAT mcp.py) ---
|
|
694
|
+
|
|
695
|
+
@app.get("/api/mcp/servers")
|
|
696
|
+
async def get_mcp_servers():
|
|
697
|
+
"""List all configured MCP servers with real live status."""
|
|
698
|
+
servers = mcp.load_servers()
|
|
699
|
+
enriched = []
|
|
700
|
+
for s in servers:
|
|
701
|
+
st = mcp.server_status(s)
|
|
702
|
+
rec = dict(s)
|
|
703
|
+
rec.update(st)
|
|
704
|
+
enriched.append(rec)
|
|
705
|
+
return {"servers": enriched}
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
@app.post("/api/mcp/servers")
|
|
709
|
+
async def add_or_update_mcp_server(req: McpServerRequest):
|
|
710
|
+
"""Add or update an MCP server configuration."""
|
|
711
|
+
entry = req.dict()
|
|
712
|
+
sid = mcp.add_server(entry)
|
|
713
|
+
return {"id": sid, "success": True}
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
@app.delete("/api/mcp/servers/{server_id}")
|
|
717
|
+
async def remove_mcp_server(server_id: str):
|
|
718
|
+
"""Remove an MCP server."""
|
|
719
|
+
mcp.disconnect_server(server_id)
|
|
720
|
+
mcp.remove_server(server_id)
|
|
721
|
+
return {"success": True}
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
@app.post("/api/mcp/servers/{server_id}/connect")
|
|
725
|
+
async def connect_mcp_server(server_id: str):
|
|
726
|
+
"""Connect to an MCP server."""
|
|
727
|
+
servers = mcp.load_servers()
|
|
728
|
+
matched = next((s for s in servers if s.get("id") == server_id), None)
|
|
729
|
+
if not matched:
|
|
730
|
+
raise HTTPException(status_code=404, detail="Server not found")
|
|
731
|
+
ok, msg = mcp.connect_server(matched)
|
|
732
|
+
status = mcp.server_status(matched)
|
|
733
|
+
return {"success": ok, "message": msg, "status": status}
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
@app.post("/api/mcp/servers/{server_id}/disconnect")
|
|
737
|
+
async def disconnect_mcp_server(server_id: str):
|
|
738
|
+
"""Disconnect from an MCP server."""
|
|
739
|
+
mcp.disconnect_server(server_id)
|
|
740
|
+
return {"success": True}
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
# --- Backup Providers (Automatic Failover Chain) ---
|
|
744
|
+
|
|
745
|
+
@app.get("/api/backup-providers")
|
|
746
|
+
async def get_backup_providers():
|
|
747
|
+
"""Get the failover chain of backup providers."""
|
|
748
|
+
providers = pm.load_backup_providers()
|
|
749
|
+
return {"providers": providers, "configs": pm.backup_configs()}
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
DEFAULT_BACKUP_MODELS = {
|
|
753
|
+
"ollama": "llama3.3",
|
|
754
|
+
"groq": "llama-3.3-70b-versatile",
|
|
755
|
+
"openrouter": "openai/gpt-4o-mini",
|
|
756
|
+
"openai": "gpt-4o-mini",
|
|
757
|
+
"deepseek": "deepseek-chat",
|
|
758
|
+
"anthropic": "claude-3-5-sonnet-20241022",
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
@app.post("/api/backup-providers")
|
|
763
|
+
async def save_backup_providers_chain(req: BackupProvidersSaveRequest):
|
|
764
|
+
"""Save the reordered/configured failover chain."""
|
|
765
|
+
formatted = []
|
|
766
|
+
for item in req.providers:
|
|
767
|
+
if isinstance(item, str):
|
|
768
|
+
p = item.lower()
|
|
769
|
+
m = DEFAULT_BACKUP_MODELS.get(p, "default-model")
|
|
770
|
+
formatted.append({
|
|
771
|
+
"provider": p,
|
|
772
|
+
"model": m,
|
|
773
|
+
"enabled": True,
|
|
774
|
+
"api_style": "ollama" if p == "ollama" else "openai",
|
|
775
|
+
"base_url": "http://localhost:11434" if p == "ollama" else "",
|
|
776
|
+
})
|
|
777
|
+
elif isinstance(item, dict):
|
|
778
|
+
entry = dict(item)
|
|
779
|
+
p = str(entry.get("provider", "")).lower()
|
|
780
|
+
if not entry.get("model"):
|
|
781
|
+
entry["model"] = DEFAULT_BACKUP_MODELS.get(p, "default-model")
|
|
782
|
+
formatted.append(entry)
|
|
783
|
+
ok = pm.save_backup_providers(formatted)
|
|
784
|
+
return {"success": ok, "providers": pm.load_backup_providers()}
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
# --- Themes & Styling (CAT theme.py + theme_css.py) ---
|
|
788
|
+
|
|
789
|
+
@app.get("/api/themes")
|
|
790
|
+
async def get_themes_info():
|
|
791
|
+
"""Get current theme, available themes, and Textual CSS variables."""
|
|
792
|
+
cur = theme.get_theme()
|
|
793
|
+
avail = theme.available_themes()
|
|
794
|
+
themed_list = []
|
|
795
|
+
for name in avail:
|
|
796
|
+
resolved = theme.resolve_theme_name(name)
|
|
797
|
+
th_obj = theme._THEMES.get(resolved)
|
|
798
|
+
is_dark = th_obj.dark if th_obj else True
|
|
799
|
+
themed_list.append({
|
|
800
|
+
"name": name,
|
|
801
|
+
"label": theme.theme_label(name),
|
|
802
|
+
"active": name == cur,
|
|
803
|
+
"is_light": not is_dark,
|
|
804
|
+
})
|
|
805
|
+
return {
|
|
806
|
+
"current": cur,
|
|
807
|
+
"is_light": theme.is_light(),
|
|
808
|
+
"themes": themed_list,
|
|
809
|
+
"css_variables": theme_css.css_variables(),
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
@app.post("/api/themes/select")
|
|
814
|
+
async def select_theme(req: ThemeSelectRequest):
|
|
815
|
+
"""Apply theme by name and return updated CSS variables."""
|
|
816
|
+
applied = theme.set_theme(req.theme)
|
|
817
|
+
return {
|
|
818
|
+
"theme": applied,
|
|
819
|
+
"css_variables": theme_css.css_variables(),
|
|
820
|
+
"is_light": theme.is_light(),
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
# --- Extensions Management ---
|
|
825
|
+
|
|
826
|
+
@app.post("/api/extensions/{ext_id}/install")
|
|
827
|
+
async def install_extension(ext_id: str):
|
|
828
|
+
ok = cct_ext.install(ext_id)
|
|
829
|
+
return {"id": ext_id, "success": ok, "state": cct_ext.get_state(ext_id)}
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
@app.post("/api/extensions/{ext_id}/uninstall")
|
|
833
|
+
async def uninstall_extension(ext_id: str):
|
|
834
|
+
ok = cct_ext.uninstall(ext_id)
|
|
835
|
+
return {"id": ext_id, "success": ok, "state": cct_ext.get_state(ext_id)}
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
@app.post("/api/extensions/{ext_id}/enable")
|
|
839
|
+
async def enable_extension(ext_id: str):
|
|
840
|
+
ok = cct_ext.enable(ext_id)
|
|
841
|
+
return {"id": ext_id, "success": ok, "state": cct_ext.get_state(ext_id)}
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
@app.post("/api/extensions/{ext_id}/disable")
|
|
845
|
+
async def disable_extension(ext_id: str):
|
|
846
|
+
ok = cct_ext.disable(ext_id)
|
|
847
|
+
return {"id": ext_id, "success": ok, "state": cct_ext.get_state(ext_id)}
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
# --- Users & Authentication ---
|
|
851
|
+
|
|
852
|
+
@app.get("/api/user/profile")
|
|
853
|
+
async def get_user_profile():
|
|
854
|
+
"""Get full user identity, app metadata, and session status."""
|
|
855
|
+
ident = fomoji_auth.get_identity() or {}
|
|
856
|
+
auth_ok = fomoji_auth.is_authenticated()
|
|
857
|
+
mem_turns = 0
|
|
858
|
+
try:
|
|
859
|
+
from calc_terminal import memory
|
|
860
|
+
mem_turns = len(memory.get_facts())
|
|
861
|
+
except Exception:
|
|
862
|
+
pass
|
|
863
|
+
saved_chats = len(chat_store.get_chat_summaries())
|
|
864
|
+
cfg = aicore.load_config()
|
|
865
|
+
|
|
866
|
+
return {
|
|
867
|
+
"authenticated": auth_ok,
|
|
868
|
+
"user": {
|
|
869
|
+
"name": ident.get("name", "Local Developer"),
|
|
870
|
+
"email": ident.get("email", "local@cat.terminal"),
|
|
871
|
+
"role": ident.get("role", "developer"),
|
|
872
|
+
},
|
|
873
|
+
"app": {
|
|
874
|
+
"name": identity.APP_NAME,
|
|
875
|
+
"short_name": identity.SHORT_NAME,
|
|
876
|
+
"version": identity.APP_VERSION,
|
|
877
|
+
"creator": identity.CREATOR_NAME,
|
|
878
|
+
"tagline": identity.APP_TAGLINE,
|
|
879
|
+
"tech_stack": identity.tech_stack_sentence(cfg),
|
|
880
|
+
},
|
|
881
|
+
"active_model": cfg.get("model", "deepseek-r1"),
|
|
882
|
+
"active_provider": cfg.get("provider", "ollama"),
|
|
883
|
+
"memory_facts_count": mem_turns,
|
|
884
|
+
"saved_chats_count": saved_chats,
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
@app.post("/api/auth/logout")
|
|
889
|
+
async def perform_logout():
|
|
890
|
+
"""Full CAT logout: revokes token, stops auth server, clears session."""
|
|
891
|
+
try:
|
|
892
|
+
fomoji_auth.logout()
|
|
893
|
+
except Exception as e:
|
|
894
|
+
print(f"Logout error: {e}")
|
|
895
|
+
return {"success": True, "message": "Signed out successfully."}
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
# --- Startup & Welcome Lifecycle (Matching Image 2) ---
|
|
899
|
+
|
|
900
|
+
@app.get("/api/startup/status")
|
|
901
|
+
async def get_startup_status():
|
|
902
|
+
"""Get startup status for WelcomeModal lifecycle."""
|
|
903
|
+
seen = welcome_modal.has_seen_version()
|
|
904
|
+
highlights = [
|
|
905
|
+
{"icon": h[0], "name": h[1], "desc": h[2]}
|
|
906
|
+
for h in welcome_modal._HIGHLIGHTS
|
|
907
|
+
]
|
|
908
|
+
return {
|
|
909
|
+
"version": welcome_modal._VERSION,
|
|
910
|
+
"has_seen": seen,
|
|
911
|
+
"highlights": highlights,
|
|
912
|
+
"tagline": "Coding \u00b7 Agents \u00b7 Intelligence \u2014 the terminal for builders.",
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
@app.post("/api/startup/dismiss")
|
|
917
|
+
async def dismiss_startup():
|
|
918
|
+
"""Mark version welcome as seen."""
|
|
919
|
+
welcome_modal.mark_seen()
|
|
920
|
+
return {"success": True}
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
# --- Gestures & Personalization ---
|
|
924
|
+
|
|
925
|
+
@app.get("/api/gestures")
|
|
926
|
+
async def get_gestures():
|
|
927
|
+
"""Get all gesture bindings from gestures manager."""
|
|
928
|
+
return {"gestures": gestures_mgr.list_gestures()}
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
@app.post("/api/gestures/toggle")
|
|
932
|
+
async def toggle_gesture(req: GestureToggleRequest):
|
|
933
|
+
"""Toggle gesture enabled state."""
|
|
934
|
+
g = gestures_mgr.update_gesture(req.id, enabled=req.enabled)
|
|
935
|
+
return {"id": req.id, "enabled": req.enabled, "success": g is not None}
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
@app.get("/api/personalize")
|
|
939
|
+
async def get_personalize():
|
|
940
|
+
"""Get personal profiles and active profile."""
|
|
941
|
+
data = ap.load_profiles()
|
|
942
|
+
return {
|
|
943
|
+
"active": data.get("active", ""),
|
|
944
|
+
"profiles": data.get("profiles", []),
|
|
945
|
+
"tones": ap.TONES,
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
@app.post("/api/personalize/active")
|
|
950
|
+
async def set_active_personalize_profile(req: ProfileActiveRequest):
|
|
951
|
+
"""Switch active profile."""
|
|
952
|
+
target = req.name or req.id or ""
|
|
953
|
+
try:
|
|
954
|
+
ap.set_active(target)
|
|
955
|
+
return {"name": target, "id": target, "success": True}
|
|
956
|
+
except Exception:
|
|
957
|
+
return {"name": target, "id": target, "success": False}
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
@app.post("/api/personalize/save")
|
|
961
|
+
async def save_personalize_profile(req: ProfileSaveRequest):
|
|
962
|
+
"""Save/update profile."""
|
|
963
|
+
prof = req.profile or req.dict(exclude_unset=True)
|
|
964
|
+
if "profile" in prof:
|
|
965
|
+
prof.pop("profile", None)
|
|
966
|
+
name = prof.get("name") or prof.get("id") or "Custom"
|
|
967
|
+
tone = prof.get("tone", "balanced")
|
|
968
|
+
additions = prof.get("additions") or prof.get("prompt") or ""
|
|
969
|
+
temp = prof.get("temperature")
|
|
970
|
+
try:
|
|
971
|
+
res = ap.add_profile(name=name, tone=tone, additions=additions, temperature=temp)
|
|
972
|
+
return {"profile": res, "success": True}
|
|
973
|
+
except Exception as e:
|
|
974
|
+
return {"profile": prof, "success": False, "error": str(e)}
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
@app.get("/api/workspace/current")
|
|
978
|
+
async def current_workspace():
|
|
979
|
+
"""Get currently active workspace root and info."""
|
|
980
|
+
root = state_manager.get_workspace_root("current")
|
|
981
|
+
if not root:
|
|
982
|
+
return {"id": None, "path": None, "name": None}
|
|
983
|
+
return {
|
|
984
|
+
"id": "current",
|
|
985
|
+
"workspace_id": "current",
|
|
986
|
+
"path": root,
|
|
987
|
+
"name": os.path.basename(root) or root,
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
@app.get("/api/workspace/{workspace_id}/tree")
|
|
992
|
+
async def get_workspace_tree(workspace_id: str):
|
|
993
|
+
"""Return tree structure and indexed language/file summary for workspace."""
|
|
994
|
+
root = state_manager.get_workspace_root(workspace_id)
|
|
995
|
+
if not root or not os.path.isdir(root):
|
|
996
|
+
raise HTTPException(status_code=404, detail="Workspace not found")
|
|
997
|
+
|
|
998
|
+
# Run scan for stats
|
|
999
|
+
try:
|
|
1000
|
+
from calc_terminal import workspace_index
|
|
1001
|
+
ctx = workspace_index.scan(root)
|
|
1002
|
+
except Exception:
|
|
1003
|
+
ctx = {"languages": [], "file_count": 0}
|
|
1004
|
+
|
|
1005
|
+
skip_names = {".git", ".pytest_cache", "__pycache__", "node_modules", ".venv", "venv", ".idea", ".vscode"}
|
|
1006
|
+
|
|
1007
|
+
def walk_dir(current_path: Path, rel_path: str = ""):
|
|
1008
|
+
children = []
|
|
1009
|
+
try:
|
|
1010
|
+
entries = sorted(list(current_path.iterdir()), key=lambda e: (not e.is_dir(), e.name.lower()))
|
|
1011
|
+
for entry in entries:
|
|
1012
|
+
if entry.name in skip_names or entry.name.startswith("."):
|
|
1013
|
+
continue
|
|
1014
|
+
entry_rel = f"{rel_path}/{entry.name}" if rel_path else entry.name
|
|
1015
|
+
if entry.is_dir():
|
|
1016
|
+
children.append({
|
|
1017
|
+
"name": entry.name,
|
|
1018
|
+
"path": entry_rel,
|
|
1019
|
+
"type": "directory",
|
|
1020
|
+
"children": walk_dir(entry, entry_rel),
|
|
1021
|
+
})
|
|
1022
|
+
elif entry.is_file():
|
|
1023
|
+
children.append({
|
|
1024
|
+
"name": entry.name,
|
|
1025
|
+
"path": entry_rel,
|
|
1026
|
+
"type": "file",
|
|
1027
|
+
"ext": entry.suffix.lower(),
|
|
1028
|
+
"size": entry.stat().st_size,
|
|
1029
|
+
})
|
|
1030
|
+
except Exception:
|
|
1031
|
+
pass
|
|
1032
|
+
return children
|
|
1033
|
+
|
|
1034
|
+
langs = ctx.get("languages", [])
|
|
1035
|
+
f_count = ctx.get("file_count", 0)
|
|
1036
|
+
summary_str = f"{', '.join(langs[:3])} · {f_count}" if langs else (f"{f_count} files" if f_count else "")
|
|
1037
|
+
|
|
1038
|
+
return {
|
|
1039
|
+
"name": os.path.basename(root) or root,
|
|
1040
|
+
"path": "",
|
|
1041
|
+
"full_path": root,
|
|
1042
|
+
"type": "directory",
|
|
1043
|
+
"children": walk_dir(Path(root)),
|
|
1044
|
+
"summary": {
|
|
1045
|
+
"languages": langs,
|
|
1046
|
+
"file_count": f_count,
|
|
1047
|
+
"summary_line": summary_str,
|
|
1048
|
+
},
|
|
1049
|
+
"recent": [
|
|
1050
|
+
{"path": p, "name": os.path.basename(p.rstrip(os.sep)) or p}
|
|
1051
|
+
for p in projects.recent()[:10]
|
|
1052
|
+
]
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
@app.get("/api/workspace/{workspace_id}/files")
|
|
1057
|
+
async def list_workspace_files(workspace_id: str):
|
|
1058
|
+
root = state_manager.get_workspace_root(workspace_id)
|
|
1059
|
+
if not root:
|
|
1060
|
+
raise HTTPException(status_code=404, detail="Workspace not found")
|
|
1061
|
+
|
|
1062
|
+
files = []
|
|
1063
|
+
for entry in Path(root).rglob("*"):
|
|
1064
|
+
if entry.is_file():
|
|
1065
|
+
rel = entry.relative_to(root)
|
|
1066
|
+
stat = entry.stat()
|
|
1067
|
+
files.append({
|
|
1068
|
+
"path": str(rel),
|
|
1069
|
+
"size": stat.st_size,
|
|
1070
|
+
"mtime": stat.st_mtime,
|
|
1071
|
+
"is_binary": _is_binary_file(entry),
|
|
1072
|
+
})
|
|
1073
|
+
return {"files": files}
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
@app.get("/api/workspace/{workspace_id}/file")
|
|
1077
|
+
async def read_workspace_file(workspace_id: str, path: str):
|
|
1078
|
+
root = state_manager.get_workspace_root(workspace_id)
|
|
1079
|
+
if not root:
|
|
1080
|
+
raise HTTPException(status_code=404, detail="Workspace not found")
|
|
1081
|
+
|
|
1082
|
+
file_path = Path(root) / path
|
|
1083
|
+
if not file_path.exists() or not file_path.is_file():
|
|
1084
|
+
raise HTTPException(status_code=404, detail="File not found")
|
|
1085
|
+
|
|
1086
|
+
# Security: ensure path is within workspace
|
|
1087
|
+
try:
|
|
1088
|
+
file_path.resolve().relative_to(Path(root).resolve())
|
|
1089
|
+
except ValueError:
|
|
1090
|
+
raise HTTPException(status_code=403, detail="Access denied")
|
|
1091
|
+
|
|
1092
|
+
if _is_binary_file(file_path):
|
|
1093
|
+
return FileResponse(str(file_path))
|
|
1094
|
+
|
|
1095
|
+
content = file_path.read_text(encoding="utf-8", errors="replace")
|
|
1096
|
+
return {"path": path, "content": content}
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
@app.post("/api/workspace/{workspace_id}/file")
|
|
1100
|
+
async def write_workspace_file(workspace_id: str, request: FileWriteRequest):
|
|
1101
|
+
root = state_manager.get_workspace_root(workspace_id)
|
|
1102
|
+
if not root:
|
|
1103
|
+
raise HTTPException(status_code=404, detail="Workspace not found")
|
|
1104
|
+
|
|
1105
|
+
file_path = Path(root) / request.path
|
|
1106
|
+
try:
|
|
1107
|
+
file_path.resolve().relative_to(Path(root).resolve())
|
|
1108
|
+
except ValueError:
|
|
1109
|
+
raise HTTPException(status_code=403, detail="Access denied")
|
|
1110
|
+
|
|
1111
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1112
|
+
file_path.write_text(request.content, encoding=request.encoding or "utf-8")
|
|
1113
|
+
|
|
1114
|
+
# Notify preview controller of file change
|
|
1115
|
+
ctrl = state_manager.get_preview_controller(workspace_id)
|
|
1116
|
+
if ctrl:
|
|
1117
|
+
ctrl.notify_ai_wrote(str(file_path))
|
|
1118
|
+
|
|
1119
|
+
return {"success": True, "path": request.path}
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
@app.delete("/api/workspace/{workspace_id}/file")
|
|
1123
|
+
async def delete_workspace_file(workspace_id: str, path: str):
|
|
1124
|
+
root = state_manager.get_workspace_root(workspace_id)
|
|
1125
|
+
if not root:
|
|
1126
|
+
raise HTTPException(status_code=404, detail="Workspace not found")
|
|
1127
|
+
|
|
1128
|
+
file_path = Path(root) / path
|
|
1129
|
+
try:
|
|
1130
|
+
file_path.resolve().relative_to(Path(root).resolve())
|
|
1131
|
+
except ValueError:
|
|
1132
|
+
raise HTTPException(status_code=403, detail="Access denied")
|
|
1133
|
+
|
|
1134
|
+
if file_path.exists():
|
|
1135
|
+
file_path.unlink()
|
|
1136
|
+
|
|
1137
|
+
return {"success": True}
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
# --- Command Execution ---
|
|
1141
|
+
|
|
1142
|
+
@app.post("/api/command", response_model=CommandResponse)
|
|
1143
|
+
async def run_command(request: CommandRequest):
|
|
1144
|
+
cwd = request.cwd or os.getcwd()
|
|
1145
|
+
env = os.environ.copy()
|
|
1146
|
+
if request.env:
|
|
1147
|
+
env.update(request.env)
|
|
1148
|
+
|
|
1149
|
+
# Run command in thread pool to avoid blocking the event loop
|
|
1150
|
+
loop = asyncio.get_event_loop()
|
|
1151
|
+
result = await loop.run_in_executor(
|
|
1152
|
+
None, _run_command_sync, request.cmd, cwd, env, request.timeout
|
|
1153
|
+
)
|
|
1154
|
+
return result
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
def _run_command_sync(cmd: str, cwd: str, env: dict, timeout: float) -> CommandResponse:
|
|
1158
|
+
"""Run a shell command synchronously (called from thread)."""
|
|
1159
|
+
import subprocess
|
|
1160
|
+
try:
|
|
1161
|
+
proc = subprocess.Popen(
|
|
1162
|
+
cmd,
|
|
1163
|
+
shell=True,
|
|
1164
|
+
cwd=cwd,
|
|
1165
|
+
env=env,
|
|
1166
|
+
stdout=subprocess.PIPE,
|
|
1167
|
+
stderr=subprocess.PIPE,
|
|
1168
|
+
stdin=subprocess.DEVNULL,
|
|
1169
|
+
)
|
|
1170
|
+
try:
|
|
1171
|
+
stdout, stderr = proc.communicate(timeout=timeout)
|
|
1172
|
+
except subprocess.TimeoutExpired:
|
|
1173
|
+
proc.kill()
|
|
1174
|
+
stdout, stderr = proc.communicate()
|
|
1175
|
+
return CommandResponse(
|
|
1176
|
+
returncode=-1,
|
|
1177
|
+
stdout=stdout.decode("utf-8", errors="replace"),
|
|
1178
|
+
stderr=stderr.decode("utf-8", errors="replace") + "\n[Timed out]",
|
|
1179
|
+
success=False,
|
|
1180
|
+
)
|
|
1181
|
+
return CommandResponse(
|
|
1182
|
+
returncode=proc.returncode or 0,
|
|
1183
|
+
stdout=stdout.decode("utf-8", errors="replace"),
|
|
1184
|
+
stderr=stderr.decode("utf-8", errors="replace"),
|
|
1185
|
+
success=(proc.returncode or 0) == 0,
|
|
1186
|
+
)
|
|
1187
|
+
except Exception as e:
|
|
1188
|
+
return CommandResponse(
|
|
1189
|
+
returncode=-1, stdout="", stderr=str(e), success=False,
|
|
1190
|
+
)
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
# --- Preview / Live Server ---
|
|
1194
|
+
|
|
1195
|
+
@app.post("/api/preview/start")
|
|
1196
|
+
async def start_preview(request: PreviewStartRequest, workspace_id: Optional[str] = "current"):
|
|
1197
|
+
ws_id = workspace_id or "current"
|
|
1198
|
+
root = state_manager.get_workspace_root(ws_id)
|
|
1199
|
+
if not root:
|
|
1200
|
+
raise HTTPException(status_code=404, detail="Workspace not found")
|
|
1201
|
+
|
|
1202
|
+
file_path = os.path.join(root, request.file_path)
|
|
1203
|
+
if not os.path.exists(file_path):
|
|
1204
|
+
raise HTTPException(status_code=404, detail="File not found")
|
|
1205
|
+
|
|
1206
|
+
# Determine effective project root for preview server
|
|
1207
|
+
project_dir = os.path.dirname(file_path) if os.path.isfile(file_path) else root
|
|
1208
|
+
ctrl_key = f"{ws_id}_{project_dir}"
|
|
1209
|
+
|
|
1210
|
+
# Create or get preview controller
|
|
1211
|
+
if ctrl_key not in state_manager.preview_controllers:
|
|
1212
|
+
state_manager.preview_controllers[ctrl_key] = PreviewController(
|
|
1213
|
+
root=project_dir,
|
|
1214
|
+
on_event=lambda kind, **info: asyncio.create_task(
|
|
1215
|
+
_broadcast_preview_event(ws_id, kind, info)
|
|
1216
|
+
),
|
|
1217
|
+
)
|
|
1218
|
+
|
|
1219
|
+
ctrl = state_manager.preview_controllers[ctrl_key]
|
|
1220
|
+
success = ctrl.start_for_file(file_path)
|
|
1221
|
+
|
|
1222
|
+
if not success:
|
|
1223
|
+
raise HTTPException(status_code=500, detail=f"Preview failed: {ctrl.last_error}")
|
|
1224
|
+
|
|
1225
|
+
return {
|
|
1226
|
+
"url": ctrl.url,
|
|
1227
|
+
"base_url": ctrl.base_url,
|
|
1228
|
+
"entry_url": ctrl.entry_url,
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
@app.post("/api/preview/stop")
|
|
1233
|
+
async def stop_preview(workspace_id: str):
|
|
1234
|
+
ctrl = state_manager.get_preview_controller(workspace_id)
|
|
1235
|
+
if ctrl:
|
|
1236
|
+
ctrl.stop_preview()
|
|
1237
|
+
del state_manager.preview_controllers[workspace_id]
|
|
1238
|
+
return {"success": True}
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
@app.post("/api/preview/reload")
|
|
1242
|
+
async def reload_preview(workspace_id: str):
|
|
1243
|
+
ctrl = state_manager.get_preview_controller(workspace_id)
|
|
1244
|
+
if not ctrl:
|
|
1245
|
+
raise HTTPException(status_code=404, detail="Preview not running")
|
|
1246
|
+
|
|
1247
|
+
snap = ctrl.reload()
|
|
1248
|
+
return {"success": snap.ok if snap else False, "error": snap.error if snap and not snap.ok else None}
|
|
1249
|
+
|
|
1250
|
+
|
|
1251
|
+
@app.get("/api/preview/status")
|
|
1252
|
+
async def preview_status(workspace_id: str):
|
|
1253
|
+
ctrl = state_manager.get_preview_controller(workspace_id)
|
|
1254
|
+
if not ctrl:
|
|
1255
|
+
return {"running": False}
|
|
1256
|
+
|
|
1257
|
+
return {
|
|
1258
|
+
"running": ctrl.running,
|
|
1259
|
+
"url": ctrl.url,
|
|
1260
|
+
"base_url": ctrl.base_url,
|
|
1261
|
+
"server_state": ctrl.server_state.name,
|
|
1262
|
+
"preview_state": ctrl.preview_state.name,
|
|
1263
|
+
"error": ctrl.last_error,
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
|
|
1267
|
+
# --- AI Chat ---
|
|
1268
|
+
|
|
1269
|
+
@app.post("/api/chat")
|
|
1270
|
+
async def chat(request: ChatRequest):
|
|
1271
|
+
# Set workspace if provided
|
|
1272
|
+
if request.project_path:
|
|
1273
|
+
ws_paths.set_active_project(request.project_path)
|
|
1274
|
+
|
|
1275
|
+
# Record user turn if chat_id provided
|
|
1276
|
+
cid = request.chat_id
|
|
1277
|
+
if cid:
|
|
1278
|
+
try:
|
|
1279
|
+
chat_store.add_turn(cid, "user", request.message)
|
|
1280
|
+
except Exception:
|
|
1281
|
+
pass
|
|
1282
|
+
|
|
1283
|
+
# Get AI config
|
|
1284
|
+
config = aicore.load_config()
|
|
1285
|
+
|
|
1286
|
+
# Process the message (run_in_executor to avoid blocking)
|
|
1287
|
+
try:
|
|
1288
|
+
loop = asyncio.get_event_loop()
|
|
1289
|
+
result = await loop.run_in_executor(
|
|
1290
|
+
None, _process_chat_message, request.message, request.mode or "build", config
|
|
1291
|
+
)
|
|
1292
|
+
if cid:
|
|
1293
|
+
result["chat_id"] = cid
|
|
1294
|
+
if result.get("response"):
|
|
1295
|
+
try:
|
|
1296
|
+
chat_store.add_turn(cid, "assistant", result["response"])
|
|
1297
|
+
except Exception:
|
|
1298
|
+
pass
|
|
1299
|
+
return result
|
|
1300
|
+
except Exception as e:
|
|
1301
|
+
prov = config.get("provider", "ollama").upper()
|
|
1302
|
+
mod = config.get("model", "deepseek-r1")
|
|
1303
|
+
err_res = {
|
|
1304
|
+
"response": f"Error communicating with AI: {e}",
|
|
1305
|
+
"provider": prov,
|
|
1306
|
+
"model": mod,
|
|
1307
|
+
"tokens": 0,
|
|
1308
|
+
"time_str": "0:01",
|
|
1309
|
+
"timings": {"ttfb_ms": 500, "memory_ms": 20, "loop_ms": 520},
|
|
1310
|
+
"calls": 1,
|
|
1311
|
+
"success": False,
|
|
1312
|
+
}
|
|
1313
|
+
if cid:
|
|
1314
|
+
err_res["chat_id"] = cid
|
|
1315
|
+
return err_res
|
|
1316
|
+
|
|
1317
|
+
|
|
1318
|
+
# --- Agent / Build / Plan ---
|
|
1319
|
+
|
|
1320
|
+
class AgentRequest(BaseModel):
|
|
1321
|
+
message: str
|
|
1322
|
+
mode: Optional[str] = "agent" # "agent", "ai", "build", "plan"
|
|
1323
|
+
project_path: Optional[str] = None
|
|
1324
|
+
max_steps: Optional[int] = 15
|
|
1325
|
+
chat_id: Optional[str] = None
|
|
1326
|
+
|
|
1327
|
+
@app.post("/api/agent")
|
|
1328
|
+
async def run_agent_endpoint(request: AgentRequest):
|
|
1329
|
+
"""Run the CAT agent (same as CLI /agent mode)."""
|
|
1330
|
+
if request.project_path:
|
|
1331
|
+
ws_paths.set_active_project(request.project_path)
|
|
1332
|
+
cid = getattr(request, "chat_id", None)
|
|
1333
|
+
if cid:
|
|
1334
|
+
try:
|
|
1335
|
+
chat_store.add_turn(cid, "user", request.message)
|
|
1336
|
+
except Exception:
|
|
1337
|
+
pass
|
|
1338
|
+
loop = asyncio.get_event_loop()
|
|
1339
|
+
try:
|
|
1340
|
+
result = await loop.run_in_executor(
|
|
1341
|
+
None, lambda: _run_agent_sync(
|
|
1342
|
+
request.message, request.mode, request.max_steps
|
|
1343
|
+
)
|
|
1344
|
+
)
|
|
1345
|
+
if cid:
|
|
1346
|
+
result["chat_id"] = cid
|
|
1347
|
+
if result.get("response"):
|
|
1348
|
+
try:
|
|
1349
|
+
chat_store.add_turn(cid, "assistant", result["response"])
|
|
1350
|
+
except Exception:
|
|
1351
|
+
pass
|
|
1352
|
+
return result
|
|
1353
|
+
except Exception as e:
|
|
1354
|
+
err_res = {"response": "Agent error: " + str(e), "steps": [], "meta": {}}
|
|
1355
|
+
if cid:
|
|
1356
|
+
err_res["chat_id"] = cid
|
|
1357
|
+
return err_res
|
|
1358
|
+
|
|
1359
|
+
|
|
1360
|
+
def _run_agent_sync(message: str, mode: str, max_steps: int) -> dict:
|
|
1361
|
+
"""Run agent synchronously (called from thread)."""
|
|
1362
|
+
try:
|
|
1363
|
+
from calc_terminal.agent import run_agent as _run_agent
|
|
1364
|
+
agent_mode = "agent" if mode in ("agent", "build", "plan") else "ai"
|
|
1365
|
+
text, steps, meta = _run_agent(
|
|
1366
|
+
message,
|
|
1367
|
+
max_steps=max_steps,
|
|
1368
|
+
verbose=False,
|
|
1369
|
+
mode=agent_mode,
|
|
1370
|
+
)
|
|
1371
|
+
# Format steps for web display
|
|
1372
|
+
step_list = []
|
|
1373
|
+
for s in steps:
|
|
1374
|
+
if len(s) >= 3:
|
|
1375
|
+
step_list.append({
|
|
1376
|
+
"tool": s[0],
|
|
1377
|
+
"args": s[1] if isinstance(s[1], dict) else {},
|
|
1378
|
+
"result": str(s[2])[:500],
|
|
1379
|
+
})
|
|
1380
|
+
from calc_terminal import aicore
|
|
1381
|
+
cfg = aicore.load_config()
|
|
1382
|
+
return {
|
|
1383
|
+
"response": text or "",
|
|
1384
|
+
"steps": step_list,
|
|
1385
|
+
"meta": meta or {},
|
|
1386
|
+
"provider": (cfg.get("provider") or "ollama").upper(),
|
|
1387
|
+
"model": cfg.get("model") or "gemma4:e4b",
|
|
1388
|
+
"tokens": max(15, len((text or "").split()) * 4 // 3),
|
|
1389
|
+
"calls": max(1, len(step_list)),
|
|
1390
|
+
"success": True,
|
|
1391
|
+
}
|
|
1392
|
+
except Exception as e:
|
|
1393
|
+
return {"response": "Agent error: " + str(e), "steps": [], "meta": {}}
|
|
1394
|
+
|
|
1395
|
+
|
|
1396
|
+
# --- Git Operations ---
|
|
1397
|
+
|
|
1398
|
+
class GitRequest(BaseModel):
|
|
1399
|
+
action: str # status, diff, add, commit, branch, checkout, log
|
|
1400
|
+
message: Optional[str] = None
|
|
1401
|
+
file: Optional[str] = None
|
|
1402
|
+
branch: Optional[str] = None
|
|
1403
|
+
|
|
1404
|
+
@app.post("/api/git")
|
|
1405
|
+
async def git_operation(request: GitRequest):
|
|
1406
|
+
"""Execute git operations."""
|
|
1407
|
+
root = ws_paths.active_project() or os.getcwd()
|
|
1408
|
+
loop = asyncio.get_event_loop()
|
|
1409
|
+
try:
|
|
1410
|
+
result = await loop.run_in_executor(
|
|
1411
|
+
None, lambda: _git_op_sync(request.action, root, request.message, request.file, request.branch)
|
|
1412
|
+
)
|
|
1413
|
+
return result
|
|
1414
|
+
except Exception as e:
|
|
1415
|
+
return {"success": False, "error": str(e)}
|
|
1416
|
+
|
|
1417
|
+
|
|
1418
|
+
def _git_op_sync(action: str, root: str, message: str = None, file: str = None, branch: str = None) -> dict:
|
|
1419
|
+
"""Execute a git operation synchronously."""
|
|
1420
|
+
import subprocess
|
|
1421
|
+
def run_git(args):
|
|
1422
|
+
proc = subprocess.Popen(
|
|
1423
|
+
["git"] + args,
|
|
1424
|
+
cwd=root,
|
|
1425
|
+
stdout=subprocess.PIPE,
|
|
1426
|
+
stderr=subprocess.PIPE,
|
|
1427
|
+
)
|
|
1428
|
+
stdout, stderr = proc.communicate(timeout=15)
|
|
1429
|
+
return proc.returncode, stdout.decode("utf-8", errors="replace"), stderr.decode("utf-8", errors="replace")
|
|
1430
|
+
|
|
1431
|
+
if action == "status":
|
|
1432
|
+
code, out, err = run_git(["status", "--porcelain"])
|
|
1433
|
+
branch_code, branch_out, _ = run_git(["branch", "--show-current"])
|
|
1434
|
+
lines = [l for l in out.strip().split("\n") if l.strip()]
|
|
1435
|
+
modified = [l[3:] for l in lines if l.startswith(" M") or l.startswith("M")]
|
|
1436
|
+
added = [l[3:] for l in lines if l.startswith("A")]
|
|
1437
|
+
deleted = [l[3:] for l in lines if l.startswith("D")]
|
|
1438
|
+
untracked = [l[3:] for l in lines if l.startswith("??")]
|
|
1439
|
+
return {
|
|
1440
|
+
"success": True,
|
|
1441
|
+
"branch": branch_out.strip(),
|
|
1442
|
+
"modified": modified,
|
|
1443
|
+
"added": added,
|
|
1444
|
+
"deleted": deleted,
|
|
1445
|
+
"untracked": untracked,
|
|
1446
|
+
"total": len(lines),
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
elif action == "diff":
|
|
1450
|
+
target = file or ""
|
|
1451
|
+
args = ["diff"]
|
|
1452
|
+
if target:
|
|
1453
|
+
args.append(target)
|
|
1454
|
+
code, out, err = run_git(args)
|
|
1455
|
+
return {"success": True, "diff": out}
|
|
1456
|
+
|
|
1457
|
+
elif action == "add":
|
|
1458
|
+
target = file or "."
|
|
1459
|
+
code, out, err = run_git(["add", target])
|
|
1460
|
+
return {"success": code == 0, "output": out or err}
|
|
1461
|
+
|
|
1462
|
+
elif action == "commit":
|
|
1463
|
+
msg = message or "Web commit"
|
|
1464
|
+
code, out, err = run_git(["commit", "-m", msg])
|
|
1465
|
+
return {"success": code == 0, "output": out or err}
|
|
1466
|
+
|
|
1467
|
+
elif action == "branch":
|
|
1468
|
+
code, out, err = run_git(["branch"])
|
|
1469
|
+
branches = [l.strip().replace("* ", "") for l in out.strip().split("\n") if l.strip()]
|
|
1470
|
+
return {"success": True, "branches": branches}
|
|
1471
|
+
|
|
1472
|
+
elif action == "checkout":
|
|
1473
|
+
br = branch or "main"
|
|
1474
|
+
code, out, err = run_git(["checkout", br])
|
|
1475
|
+
return {"success": code == 0, "output": out or err}
|
|
1476
|
+
|
|
1477
|
+
elif action == "log":
|
|
1478
|
+
code, out, err = run_git(["log", "--oneline", "-20"])
|
|
1479
|
+
return {"success": True, "log": out}
|
|
1480
|
+
|
|
1481
|
+
elif action == "pull":
|
|
1482
|
+
code, out, err = run_git(["pull"])
|
|
1483
|
+
return {"success": code == 0, "output": out or err}
|
|
1484
|
+
|
|
1485
|
+
elif action == "push":
|
|
1486
|
+
code, out, err = run_git(["push"])
|
|
1487
|
+
return {"success": code == 0, "output": out or err}
|
|
1488
|
+
|
|
1489
|
+
return {"success": False, "error": "Unknown git action: " + action}
|
|
1490
|
+
|
|
1491
|
+
|
|
1492
|
+
# --- File Operations (CRUD) ---
|
|
1493
|
+
|
|
1494
|
+
class FileCRUDRequest(BaseModel):
|
|
1495
|
+
action: str # create, rename, delete
|
|
1496
|
+
path: str
|
|
1497
|
+
new_path: Optional[str] = None
|
|
1498
|
+
is_dir: Optional[bool] = False
|
|
1499
|
+
content: Optional[str] = None
|
|
1500
|
+
|
|
1501
|
+
@app.post("/api/file")
|
|
1502
|
+
async def file_crud(request: FileCRUDRequest):
|
|
1503
|
+
"""Create, rename, or delete files/folders."""
|
|
1504
|
+
root = ws_paths.active_project()
|
|
1505
|
+
if not root:
|
|
1506
|
+
raise HTTPException(status_code=400, detail="No workspace open")
|
|
1507
|
+
|
|
1508
|
+
target = Path(root) / request.path
|
|
1509
|
+
try:
|
|
1510
|
+
target.resolve().relative_to(Path(root).resolve())
|
|
1511
|
+
except ValueError:
|
|
1512
|
+
raise HTTPException(status_code=403, detail="Access denied")
|
|
1513
|
+
|
|
1514
|
+
try:
|
|
1515
|
+
if request.action == "create":
|
|
1516
|
+
if request.is_dir:
|
|
1517
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
1518
|
+
else:
|
|
1519
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
1520
|
+
target.write_text(request.content or "", encoding="utf-8")
|
|
1521
|
+
return {"success": True, "path": str(target.relative_to(root))}
|
|
1522
|
+
|
|
1523
|
+
elif request.action == "rename":
|
|
1524
|
+
if not request.new_path:
|
|
1525
|
+
raise HTTPException(status_code=400, detail="new_path required")
|
|
1526
|
+
new_target = Path(root) / request.new_path
|
|
1527
|
+
try:
|
|
1528
|
+
new_target.resolve().relative_to(Path(root).resolve())
|
|
1529
|
+
except ValueError:
|
|
1530
|
+
raise HTTPException(status_code=403, detail="Access denied")
|
|
1531
|
+
target.rename(new_target)
|
|
1532
|
+
return {"success": True, "path": str(new_target.relative_to(root))}
|
|
1533
|
+
|
|
1534
|
+
elif request.action == "delete":
|
|
1535
|
+
if target.is_dir():
|
|
1536
|
+
import shutil
|
|
1537
|
+
shutil.rmtree(target)
|
|
1538
|
+
else:
|
|
1539
|
+
target.unlink()
|
|
1540
|
+
return {"success": True}
|
|
1541
|
+
|
|
1542
|
+
else:
|
|
1543
|
+
raise HTTPException(status_code=400, detail="Unknown action: " + request.action)
|
|
1544
|
+
except HTTPException:
|
|
1545
|
+
raise
|
|
1546
|
+
except Exception as e:
|
|
1547
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
1548
|
+
|
|
1549
|
+
|
|
1550
|
+
# --- Streaming AI (SSE) ---
|
|
1551
|
+
|
|
1552
|
+
@app.post("/api/chat/stream")
|
|
1553
|
+
async def chat_stream(request: ChatRequest):
|
|
1554
|
+
"""Stream real token-by-token AI response using Server-Sent Events."""
|
|
1555
|
+
from fastapi.responses import StreamingResponse
|
|
1556
|
+
|
|
1557
|
+
async def generate():
|
|
1558
|
+
try:
|
|
1559
|
+
async for chunk in runtime.stream_chat(
|
|
1560
|
+
message=request.message,
|
|
1561
|
+
mode=request.mode or "notebook",
|
|
1562
|
+
project_path=request.project_path,
|
|
1563
|
+
):
|
|
1564
|
+
yield f"data: {json.dumps(chunk)}\n\n"
|
|
1565
|
+
except Exception as e:
|
|
1566
|
+
yield f"data: {json.dumps({'type': 'error', 'error': str(e)})}\n\n"
|
|
1567
|
+
|
|
1568
|
+
return StreamingResponse(
|
|
1569
|
+
generate(),
|
|
1570
|
+
media_type="text/event-stream",
|
|
1571
|
+
headers={
|
|
1572
|
+
"Cache-Control": "no-cache",
|
|
1573
|
+
"Connection": "keep-alive",
|
|
1574
|
+
"X-Accel-Buffering": "no",
|
|
1575
|
+
}
|
|
1576
|
+
)
|
|
1577
|
+
|
|
1578
|
+
|
|
1579
|
+
# --- AI Modes Management ---
|
|
1580
|
+
|
|
1581
|
+
@app.get("/api/modes")
|
|
1582
|
+
async def get_modes():
|
|
1583
|
+
"""Return all AI modes, order, and current active mode."""
|
|
1584
|
+
return runtime.get_modes()
|
|
1585
|
+
|
|
1586
|
+
|
|
1587
|
+
@app.post("/api/modes/switch")
|
|
1588
|
+
async def switch_mode(request: ModeSwitchRequest):
|
|
1589
|
+
"""Switch active AI mode."""
|
|
1590
|
+
res = runtime.set_mode(request.mode)
|
|
1591
|
+
return {"current": res, "success": True}
|
|
1592
|
+
|
|
1593
|
+
|
|
1594
|
+
@app.post("/api/modes/custom")
|
|
1595
|
+
async def create_custom_mode(request: CustomModeRequest):
|
|
1596
|
+
"""Create a new user-defined AI mode."""
|
|
1597
|
+
ok, msg = runtime.create_custom_mode(
|
|
1598
|
+
request.key, request.label, request.icon, request.accent_hex,
|
|
1599
|
+
request.purpose or "", request.system_prompt or ""
|
|
1600
|
+
)
|
|
1601
|
+
if not ok:
|
|
1602
|
+
raise HTTPException(status_code=400, detail=msg)
|
|
1603
|
+
return {"success": True, "key": msg}
|
|
1604
|
+
|
|
1605
|
+
|
|
1606
|
+
@app.delete("/api/modes/custom/{key}")
|
|
1607
|
+
async def delete_custom_mode(key: str):
|
|
1608
|
+
"""Delete a custom AI mode."""
|
|
1609
|
+
ok, msg = runtime.delete_custom_mode(key)
|
|
1610
|
+
if not ok:
|
|
1611
|
+
raise HTTPException(status_code=400, detail=msg)
|
|
1612
|
+
return {"success": True, "message": msg}
|
|
1613
|
+
|
|
1614
|
+
|
|
1615
|
+
# --- Providers & Models Management ---
|
|
1616
|
+
|
|
1617
|
+
@app.get("/api/providers")
|
|
1618
|
+
async def list_providers():
|
|
1619
|
+
"""List all 155+ AI providers."""
|
|
1620
|
+
return {"providers": runtime.list_providers()}
|
|
1621
|
+
|
|
1622
|
+
|
|
1623
|
+
@app.get("/api/models")
|
|
1624
|
+
async def list_models(provider_id: str, force_refresh: bool = False):
|
|
1625
|
+
"""Get models for a specific provider with metadata and categories."""
|
|
1626
|
+
return {"models": runtime.get_models_for_provider(provider_id, force_refresh=force_refresh)}
|
|
1627
|
+
|
|
1628
|
+
|
|
1629
|
+
@app.post("/api/models/switch")
|
|
1630
|
+
async def switch_model(request: ModelSwitchRequest):
|
|
1631
|
+
"""Switch active provider and model in CAT configuration."""
|
|
1632
|
+
return runtime.switch_model(request.provider, request.model, request.api_key)
|
|
1633
|
+
|
|
1634
|
+
|
|
1635
|
+
# --- Ollama Support ---
|
|
1636
|
+
|
|
1637
|
+
@app.get("/api/ollama/status")
|
|
1638
|
+
async def get_ollama_status():
|
|
1639
|
+
"""Check Ollama installation and connection status."""
|
|
1640
|
+
return runtime.get_ollama_status()
|
|
1641
|
+
|
|
1642
|
+
|
|
1643
|
+
@app.get("/api/ollama/catalog")
|
|
1644
|
+
async def get_ollama_catalog(query: str = ""):
|
|
1645
|
+
"""Browse curated 250+ model catalog."""
|
|
1646
|
+
return {"catalog": runtime.get_ollama_catalog(query)}
|
|
1647
|
+
|
|
1648
|
+
|
|
1649
|
+
@app.post("/api/ollama/start")
|
|
1650
|
+
async def start_ollama():
|
|
1651
|
+
"""Start local Ollama serve process."""
|
|
1652
|
+
ok, msg = runtime.ensure_ollama_started()
|
|
1653
|
+
return {"success": ok, "message": msg}
|
|
1654
|
+
|
|
1655
|
+
|
|
1656
|
+
# --- Commands Management ---
|
|
1657
|
+
|
|
1658
|
+
@app.get("/api/commands")
|
|
1659
|
+
async def get_commands():
|
|
1660
|
+
"""Get all 103 slash commands with descriptions."""
|
|
1661
|
+
return {"commands": runtime.get_commands()}
|
|
1662
|
+
|
|
1663
|
+
|
|
1664
|
+
@app.post("/api/command/dispatch")
|
|
1665
|
+
async def dispatch_command(request: CommandDispatchRequest):
|
|
1666
|
+
"""Dispatch a slash command to CAT command handlers."""
|
|
1667
|
+
return runtime.dispatch_command(request.command)
|
|
1668
|
+
|
|
1669
|
+
|
|
1670
|
+
# --- Memory & Context ---
|
|
1671
|
+
|
|
1672
|
+
@app.get("/api/memory/facts")
|
|
1673
|
+
async def get_memory_facts():
|
|
1674
|
+
"""Get durable user facts and topic activity."""
|
|
1675
|
+
return {
|
|
1676
|
+
"facts": runtime.get_memory_facts(),
|
|
1677
|
+
"activity": runtime.get_memory_activity(),
|
|
1678
|
+
"topics": runtime.get_memory_topics(),
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
|
|
1682
|
+
@app.post("/api/memory/fact")
|
|
1683
|
+
async def add_memory_fact(request: FactRequest):
|
|
1684
|
+
"""Add a durable fact to CAT memory."""
|
|
1685
|
+
runtime.add_memory_fact(request.fact)
|
|
1686
|
+
return {"success": True, "facts": runtime.get_memory_facts()}
|
|
1687
|
+
|
|
1688
|
+
|
|
1689
|
+
@app.delete("/api/memory/fact/{index}")
|
|
1690
|
+
async def remove_memory_fact(index: int):
|
|
1691
|
+
"""Remove a fact by index."""
|
|
1692
|
+
runtime.remove_memory_fact(index)
|
|
1693
|
+
return {"success": True, "facts": runtime.get_memory_facts()}
|
|
1694
|
+
|
|
1695
|
+
|
|
1696
|
+
# --- Customization & Extensions ---
|
|
1697
|
+
|
|
1698
|
+
@app.get("/api/customization")
|
|
1699
|
+
async def get_customization():
|
|
1700
|
+
"""Get complete layout and styling customization state."""
|
|
1701
|
+
return runtime.get_customization()
|
|
1702
|
+
|
|
1703
|
+
|
|
1704
|
+
@app.post("/api/customization/layout")
|
|
1705
|
+
async def update_customization_layout(request: CustomizationLayoutRequest):
|
|
1706
|
+
"""Update layout position/dock/size for a panel."""
|
|
1707
|
+
ok = runtime.update_layout_component(request.component_id, request.updates)
|
|
1708
|
+
return {"success": ok}
|
|
1709
|
+
|
|
1710
|
+
|
|
1711
|
+
@app.post("/api/customization/reset")
|
|
1712
|
+
async def reset_customization():
|
|
1713
|
+
"""Reset customization to defaults."""
|
|
1714
|
+
ok = runtime.reset_customization()
|
|
1715
|
+
return {"success": ok}
|
|
1716
|
+
|
|
1717
|
+
|
|
1718
|
+
@app.get("/api/extensions")
|
|
1719
|
+
async def list_extensions():
|
|
1720
|
+
"""List installable and active extensions."""
|
|
1721
|
+
return {"extensions": runtime.list_extensions()}
|
|
1722
|
+
|
|
1723
|
+
|
|
1724
|
+
@app.post("/api/extensions/toggle")
|
|
1725
|
+
async def toggle_extension(request: ExtensionToggleRequest):
|
|
1726
|
+
"""Enable or disable an extension."""
|
|
1727
|
+
ok = runtime.toggle_extension(request.id, request.enabled)
|
|
1728
|
+
return {"success": ok}
|
|
1729
|
+
|
|
1730
|
+
|
|
1731
|
+
@app.post("/api/extensions/create")
|
|
1732
|
+
async def create_extension(request: ExtensionCreateRequest):
|
|
1733
|
+
"""Create a new custom extension."""
|
|
1734
|
+
ok, res = cct_ext.create_extension(
|
|
1735
|
+
extension_id=request.id,
|
|
1736
|
+
name=request.name,
|
|
1737
|
+
description=request.description or "",
|
|
1738
|
+
icon=request.icon or "⚡",
|
|
1739
|
+
publisher=request.publisher or "Custom",
|
|
1740
|
+
category=request.category or "Custom",
|
|
1741
|
+
version=request.version or "1.0.0",
|
|
1742
|
+
script_code=request.script_code or "",
|
|
1743
|
+
)
|
|
1744
|
+
return {"success": ok, "message": res, "extension": res, "extensions": runtime.list_extensions()}
|
|
1745
|
+
|
|
1746
|
+
|
|
1747
|
+
# --- Science Engine & Notebook ---
|
|
1748
|
+
|
|
1749
|
+
@app.post("/api/science/solve")
|
|
1750
|
+
async def science_solve(request: ScienceSolveRequest):
|
|
1751
|
+
"""Solve formula symbolically."""
|
|
1752
|
+
key = request.key or request.formula or "ideal_gas"
|
|
1753
|
+
raw_vals = request.values if request.values is not None else (request.params or {})
|
|
1754
|
+
clean_vals = {}
|
|
1755
|
+
for k, v in raw_vals.items():
|
|
1756
|
+
if v is not None and v != "":
|
|
1757
|
+
try:
|
|
1758
|
+
clean_vals[k] = float(v)
|
|
1759
|
+
except (ValueError, TypeError):
|
|
1760
|
+
clean_vals[k] = v
|
|
1761
|
+
return runtime.solve_formula(key, clean_vals)
|
|
1762
|
+
|
|
1763
|
+
|
|
1764
|
+
@app.post("/api/science/derive")
|
|
1765
|
+
async def science_derive(request: ScienceDeriveRequest):
|
|
1766
|
+
"""Get step-by-step calculus derivation."""
|
|
1767
|
+
kw = request.keyword or request.derivation or "first"
|
|
1768
|
+
return runtime.get_derivation(kw)
|
|
1769
|
+
|
|
1770
|
+
|
|
1771
|
+
@app.post("/api/science/atom")
|
|
1772
|
+
async def science_atom(request: ScienceAtomRequest):
|
|
1773
|
+
"""Get atomic simulation details."""
|
|
1774
|
+
return runtime.get_atom_sim(request.element)
|
|
1775
|
+
|
|
1776
|
+
|
|
1777
|
+
# --- Live Event Stream (SSE) ---
|
|
1778
|
+
|
|
1779
|
+
@app.get("/api/events/stream")
|
|
1780
|
+
async def events_stream():
|
|
1781
|
+
"""Live Server-Sent Events stream of workspace and agent events."""
|
|
1782
|
+
from fastapi.responses import StreamingResponse
|
|
1783
|
+
|
|
1784
|
+
async def event_generator():
|
|
1785
|
+
q: asyncio.Queue = asyncio.Queue()
|
|
1786
|
+
loop = asyncio.get_event_loop()
|
|
1787
|
+
|
|
1788
|
+
def _sub(topic, **kwargs):
|
|
1789
|
+
loop.call_soon_threadsafe(
|
|
1790
|
+
q.put_nowait,
|
|
1791
|
+
{"topic": topic, "time": time.time(), "data": {k: str(v)[:150] for k, v in kwargs.items()}}
|
|
1792
|
+
)
|
|
1793
|
+
|
|
1794
|
+
for top in (
|
|
1795
|
+
eventbus.FILE_CREATED, eventbus.FILE_SAVED, eventbus.FILE_DELETED,
|
|
1796
|
+
eventbus.AI_RESPONSE_GENERATED, eventbus.TERMINAL_COMMAND,
|
|
1797
|
+
eventbus.WORKSPACE_OPENED, eventbus.MODE_SWITCHED,
|
|
1798
|
+
eventbus.PACKAGE_INSTALLED, eventbus.TODO_CREATED, eventbus.TODO_COMPLETED
|
|
1799
|
+
):
|
|
1800
|
+
eventbus.bus.subscribe(top, lambda t=top, **kw: _sub(t, **kw))
|
|
1801
|
+
|
|
1802
|
+
try:
|
|
1803
|
+
yield f"data: {json.dumps({'topic': 'connected', 'time': time.time()})}\n\n"
|
|
1804
|
+
while True:
|
|
1805
|
+
evt = await q.get()
|
|
1806
|
+
yield f"data: {json.dumps(evt)}\n\n"
|
|
1807
|
+
except asyncio.CancelledError:
|
|
1808
|
+
pass
|
|
1809
|
+
|
|
1810
|
+
return StreamingResponse(
|
|
1811
|
+
event_generator(),
|
|
1812
|
+
media_type="text/event-stream",
|
|
1813
|
+
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}
|
|
1814
|
+
)
|
|
1815
|
+
|
|
1816
|
+
|
|
1817
|
+
# --- Server Status / Debug ---
|
|
1818
|
+
|
|
1819
|
+
@app.get("/api/status")
|
|
1820
|
+
async def server_status():
|
|
1821
|
+
"""Check server status and AI config."""
|
|
1822
|
+
try:
|
|
1823
|
+
config = aicore.load_config()
|
|
1824
|
+
provider = config.get("provider", "none")
|
|
1825
|
+
model = config.get("model", "none")
|
|
1826
|
+
has_key = bool(config.get("api_key"))
|
|
1827
|
+
return {
|
|
1828
|
+
"status": "ok",
|
|
1829
|
+
"version": "0.7.9.0",
|
|
1830
|
+
"provider": provider,
|
|
1831
|
+
"model": model,
|
|
1832
|
+
"has_api_key": has_key,
|
|
1833
|
+
"workspace_count": len(state_manager.workspace_roots),
|
|
1834
|
+
}
|
|
1835
|
+
except Exception as e:
|
|
1836
|
+
return {"status": "error", "error": str(e)}
|
|
1837
|
+
|
|
1838
|
+
|
|
1839
|
+
# --- Calculator ---
|
|
1840
|
+
|
|
1841
|
+
class CalcRequest(BaseModel):
|
|
1842
|
+
expression: str
|
|
1843
|
+
|
|
1844
|
+
@app.post("/api/calc")
|
|
1845
|
+
async def calculator(request: CalcRequest):
|
|
1846
|
+
"""Scientific calculator - safe eval with math functions."""
|
|
1847
|
+
import math
|
|
1848
|
+
import re
|
|
1849
|
+
expr = request.expression.strip()
|
|
1850
|
+
if not expr:
|
|
1851
|
+
raise HTTPException(status_code=400, detail="Empty expression")
|
|
1852
|
+
# Replace common symbols
|
|
1853
|
+
expr = expr.replace('^', '**')
|
|
1854
|
+
expr = expr.replace('×', '*')
|
|
1855
|
+
expr = expr.replace('÷', '/')
|
|
1856
|
+
# Allow only safe characters
|
|
1857
|
+
safe_pattern = r'^[0-9+\-*/.() pi,e,sqrt,log,log10,sin,cos,tan,asin,acos,atan,abs,ceil,floor,round,pow,min,max,radians,deg,gcd,factorial,isfinite,isnan,inf ]+$'
|
|
1858
|
+
# Build safe namespace
|
|
1859
|
+
safe_math = {
|
|
1860
|
+
'pi': math.pi, 'e': math.e, 'inf': math.inf,
|
|
1861
|
+
'sqrt': math.sqrt, 'log': math.log, 'log10': math.log10,
|
|
1862
|
+
'sin': math.sin, 'cos': math.cos, 'tan': math.tan,
|
|
1863
|
+
'asin': math.asin, 'acos': math.acos, 'atan': math.atan,
|
|
1864
|
+
'abs': abs, 'ceil': math.ceil, 'floor': math.floor,
|
|
1865
|
+
'round': round, 'pow': pow, 'min': min, 'max': max,
|
|
1866
|
+
'radians': math.radians, 'deg': math.degrees,
|
|
1867
|
+
'gcd': math.gcd, 'factorial': math.factorial,
|
|
1868
|
+
}
|
|
1869
|
+
try:
|
|
1870
|
+
result = eval(expr, {"__builtins__": {}}, safe_math)
|
|
1871
|
+
return {"expression": request.expression, "result": result}
|
|
1872
|
+
except Exception as e:
|
|
1873
|
+
raise HTTPException(status_code=400, detail=f"Calculation error: {e}")
|
|
1874
|
+
|
|
1875
|
+
|
|
1876
|
+
# --- Web Search ---
|
|
1877
|
+
|
|
1878
|
+
class SearchRequest(BaseModel):
|
|
1879
|
+
query: str
|
|
1880
|
+
max_results: Optional[int] = 6
|
|
1881
|
+
|
|
1882
|
+
@app.post("/api/websearch")
|
|
1883
|
+
async def web_search_endpoint(request: SearchRequest):
|
|
1884
|
+
"""Search the web via DuckDuckGo."""
|
|
1885
|
+
loop = asyncio.get_event_loop()
|
|
1886
|
+
try:
|
|
1887
|
+
results = await loop.run_in_executor(
|
|
1888
|
+
None, lambda: aicore.web_search(request.query, request.max_results)
|
|
1889
|
+
)
|
|
1890
|
+
return {"results": results}
|
|
1891
|
+
except Exception as e:
|
|
1892
|
+
return {"results": [], "error": str(e)}
|
|
1893
|
+
|
|
1894
|
+
|
|
1895
|
+
class ResearchRequest(BaseModel):
|
|
1896
|
+
topic: str
|
|
1897
|
+
num_queries: Optional[int] = 3
|
|
1898
|
+
|
|
1899
|
+
@app.post("/api/research")
|
|
1900
|
+
async def deep_research_endpoint(request: ResearchRequest):
|
|
1901
|
+
"""Deep research - multiple searches + AI synthesis."""
|
|
1902
|
+
loop = asyncio.get_event_loop()
|
|
1903
|
+
try:
|
|
1904
|
+
summary, sources = await loop.run_in_executor(
|
|
1905
|
+
None, lambda: aicore.deep_research(request.topic, request.num_queries)
|
|
1906
|
+
)
|
|
1907
|
+
return {"summary": summary, "sources": sources}
|
|
1908
|
+
except Exception as e:
|
|
1909
|
+
return {"summary": "", "sources": [], "error": str(e)}
|
|
1910
|
+
|
|
1911
|
+
|
|
1912
|
+
# --- Formula Library ---
|
|
1913
|
+
|
|
1914
|
+
@app.get("/api/formulas")
|
|
1915
|
+
async def get_formulas():
|
|
1916
|
+
"""Get the formula library categories and formulas."""
|
|
1917
|
+
try:
|
|
1918
|
+
from calc_terminal import solver
|
|
1919
|
+
categories = {}
|
|
1920
|
+
for cat, formulas in solver.FORMULA_LIBRARY.items():
|
|
1921
|
+
categories[cat] = []
|
|
1922
|
+
for f in formulas:
|
|
1923
|
+
categories[cat].append({
|
|
1924
|
+
"name": f.get("name", ""),
|
|
1925
|
+
"formula": f.get("formula", ""),
|
|
1926
|
+
"variables": f.get("variables", {}),
|
|
1927
|
+
"description": f.get("description", ""),
|
|
1928
|
+
})
|
|
1929
|
+
return {"categories": categories}
|
|
1930
|
+
except Exception as e:
|
|
1931
|
+
return {"categories": {}, "error": str(e)}
|
|
1932
|
+
|
|
1933
|
+
|
|
1934
|
+
class SolveRequest(BaseModel):
|
|
1935
|
+
formula: str
|
|
1936
|
+
known_values: Dict[str, float]
|
|
1937
|
+
solve_for: str
|
|
1938
|
+
|
|
1939
|
+
@app.post("/api/solve")
|
|
1940
|
+
async def solve_formula(request: SolveRequest):
|
|
1941
|
+
"""Solve a formula for an unknown variable."""
|
|
1942
|
+
try:
|
|
1943
|
+
from calc_terminal import solver as _solver
|
|
1944
|
+
result = _solver.solve_symbolic(request.formula, request.known_values, request.solve_for)
|
|
1945
|
+
return {"result": result}
|
|
1946
|
+
except Exception as e:
|
|
1947
|
+
return {"result": None, "error": str(e)}
|
|
1948
|
+
|
|
1949
|
+
|
|
1950
|
+
# --- Workspace Commands ---
|
|
1951
|
+
|
|
1952
|
+
@app.get("/api/workspace/info")
|
|
1953
|
+
async def workspace_info():
|
|
1954
|
+
"""Get workspace info and categories."""
|
|
1955
|
+
try:
|
|
1956
|
+
from calc_terminal import workspace as _ws
|
|
1957
|
+
root = _ws.root_dir()
|
|
1958
|
+
cats = {}
|
|
1959
|
+
for cat in _ws.CATEGORIES:
|
|
1960
|
+
files = _ws.list_category(cat)
|
|
1961
|
+
if files:
|
|
1962
|
+
cats[cat] = [{"name": f["name"], "path": f["path"], "size": f["size"]} for f in files[:10]]
|
|
1963
|
+
return {
|
|
1964
|
+
"root": str(root),
|
|
1965
|
+
"categories": cats,
|
|
1966
|
+
"summary": _ws.summary(),
|
|
1967
|
+
}
|
|
1968
|
+
except Exception as e:
|
|
1969
|
+
return {"root": "", "categories": {}, "error": str(e)}
|
|
1970
|
+
|
|
1971
|
+
|
|
1972
|
+
@app.get("/api/workspace/detect")
|
|
1973
|
+
async def workspace_detect():
|
|
1974
|
+
"""Auto-detect workspace from current directory."""
|
|
1975
|
+
try:
|
|
1976
|
+
from calc_terminal import workspace as _ws
|
|
1977
|
+
detected = _ws.detect_workspace(os.getcwd())
|
|
1978
|
+
if detected:
|
|
1979
|
+
_ws.set_active_project(detected)
|
|
1980
|
+
projects.record_opened(detected)
|
|
1981
|
+
return {"path": detected, "name": os.path.basename(detected)}
|
|
1982
|
+
return {"path": None}
|
|
1983
|
+
except Exception as e:
|
|
1984
|
+
return {"path": None, "error": str(e)}
|
|
1985
|
+
|
|
1986
|
+
|
|
1987
|
+
@app.get("/api/workspace/git")
|
|
1988
|
+
async def workspace_git():
|
|
1989
|
+
"""Get git status for current workspace."""
|
|
1990
|
+
try:
|
|
1991
|
+
from calc_terminal import workspace as _ws
|
|
1992
|
+
root = _ws.active_project() or os.getcwd()
|
|
1993
|
+
branch = _ws.git_branch(root)
|
|
1994
|
+
changed, added, deleted = _ws.git_status_summary(root)
|
|
1995
|
+
return {
|
|
1996
|
+
"branch": branch,
|
|
1997
|
+
"changed": changed,
|
|
1998
|
+
"added": added,
|
|
1999
|
+
"deleted": deleted,
|
|
2000
|
+
}
|
|
2001
|
+
except Exception as e:
|
|
2002
|
+
return {"branch": None, "changed": 0, "added": 0, "deleted": 0}
|
|
2003
|
+
|
|
2004
|
+
|
|
2005
|
+
# --- Memory ---
|
|
2006
|
+
|
|
2007
|
+
@app.get("/api/memory")
|
|
2008
|
+
async def get_memory():
|
|
2009
|
+
"""Get AI conversation memory stats."""
|
|
2010
|
+
try:
|
|
2011
|
+
from calc_terminal import aicore as _ac
|
|
2012
|
+
usage = _ac.get_session_usage()
|
|
2013
|
+
return {
|
|
2014
|
+
"prompt_tokens": usage.get("prompt_tokens", 0),
|
|
2015
|
+
"completion_tokens": usage.get("completion_tokens", 0),
|
|
2016
|
+
"total_tokens": usage.get("total_tokens", 0),
|
|
2017
|
+
"requests": usage.get("requests", 0),
|
|
2018
|
+
"context_window": usage.get("context_window", 0),
|
|
2019
|
+
}
|
|
2020
|
+
except Exception as e:
|
|
2021
|
+
return {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0, "requests": 0}
|
|
2022
|
+
|
|
2023
|
+
|
|
2024
|
+
# --- Settings ---
|
|
2025
|
+
|
|
2026
|
+
@app.get("/api/settings")
|
|
2027
|
+
async def get_settings():
|
|
2028
|
+
"""Get current settings."""
|
|
2029
|
+
try:
|
|
2030
|
+
config = aicore.load_config()
|
|
2031
|
+
return {
|
|
2032
|
+
"provider": config.get("provider", "none"),
|
|
2033
|
+
"model": config.get("model", "none"),
|
|
2034
|
+
"theme": config.get("theme", "dark"),
|
|
2035
|
+
"precision": config.get("precision", 3),
|
|
2036
|
+
"sound": config.get("sound", False),
|
|
2037
|
+
}
|
|
2038
|
+
except Exception as e:
|
|
2039
|
+
return {"provider": "none", "model": "none", "theme": "dark", "precision": 3}
|
|
2040
|
+
|
|
2041
|
+
|
|
2042
|
+
class SettingsUpdate(BaseModel):
|
|
2043
|
+
theme: Optional[str] = None
|
|
2044
|
+
precision: Optional[int] = None
|
|
2045
|
+
sound: Optional[bool] = None
|
|
2046
|
+
|
|
2047
|
+
@app.post("/api/settings")
|
|
2048
|
+
async def update_settings(request: SettingsUpdate):
|
|
2049
|
+
"""Update settings."""
|
|
2050
|
+
try:
|
|
2051
|
+
config = aicore.load_config()
|
|
2052
|
+
if request.theme is not None:
|
|
2053
|
+
config["theme"] = request.theme
|
|
2054
|
+
if request.precision is not None:
|
|
2055
|
+
config["precision"] = request.precision
|
|
2056
|
+
if request.sound is not None:
|
|
2057
|
+
config["sound"] = request.sound
|
|
2058
|
+
aicore.save_config(config)
|
|
2059
|
+
return {"success": True}
|
|
2060
|
+
except Exception as e:
|
|
2061
|
+
return {"success": False, "error": str(e)}
|
|
2062
|
+
|
|
2063
|
+
|
|
2064
|
+
# --- WebSocket for Real-time Updates ---
|
|
2065
|
+
|
|
2066
|
+
@app.websocket("/ws/{workspace_id}")
|
|
2067
|
+
async def websocket_endpoint(websocket: WebSocket, workspace_id: str):
|
|
2068
|
+
await websocket.accept()
|
|
2069
|
+
state_manager.active_websockets.add(websocket)
|
|
2070
|
+
|
|
2071
|
+
try:
|
|
2072
|
+
while True:
|
|
2073
|
+
data = await websocket.receive_text()
|
|
2074
|
+
message = json.loads(data)
|
|
2075
|
+
await _handle_websocket_message(websocket, workspace_id, message)
|
|
2076
|
+
except WebSocketDisconnect:
|
|
2077
|
+
pass
|
|
2078
|
+
finally:
|
|
2079
|
+
state_manager.active_websockets.discard(websocket)
|
|
2080
|
+
|
|
2081
|
+
|
|
2082
|
+
# --- Helper Functions ---
|
|
2083
|
+
|
|
2084
|
+
def _is_binary_file(path: Path) -> bool:
|
|
2085
|
+
"""Check if file is binary."""
|
|
2086
|
+
binary_extensions = {
|
|
2087
|
+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico",
|
|
2088
|
+
".woff", ".woff2", ".ttf", ".otf", ".eot",
|
|
2089
|
+
".pdf", ".zip", ".gz", ".tar", ".rar",
|
|
2090
|
+
".wasm", ".exe", ".dll", ".so", ".dylib",
|
|
2091
|
+
}
|
|
2092
|
+
return path.suffix.lower() in binary_extensions
|
|
2093
|
+
|
|
2094
|
+
|
|
2095
|
+
def _process_chat_message(message: str, mode: str, config: dict) -> dict:
|
|
2096
|
+
"""Process a chat message through CAT's AI system (synchronous).
|
|
2097
|
+
|
|
2098
|
+
v0.7.10: Enhanced error resilience — auto-retries on transient failures,
|
|
2099
|
+
validates responses against error signatures, and provides clearer
|
|
2100
|
+
diagnostics to the user.
|
|
2101
|
+
"""
|
|
2102
|
+
t0 = time.time()
|
|
2103
|
+
prov = config.get("provider", "ollama") if config else "ollama"
|
|
2104
|
+
mod = config.get("model", "deepseek-r1") if config else "deepseek-r1"
|
|
2105
|
+
# Only invoke multi-turn agent when explicitly in 'agent' mode, or when explicit file/workspace mutation is commanded
|
|
2106
|
+
is_explicit_agent_task = mode == "agent" or (mode in ("build", "plan") and bool(re.search(r"^\s*(?:create|write|delete|edit|refactor|generate)\s+(?:file|folder|dir|component|app|project)\b", message, re.I)))
|
|
2107
|
+
if is_explicit_agent_task:
|
|
2108
|
+
try:
|
|
2109
|
+
agent_res = _run_agent_sync(message, "agent", max_steps=4)
|
|
2110
|
+
if agent_res and agent_res.get("response") and not agent_res.get("response", "").startswith("Agent error:"):
|
|
2111
|
+
total_time = time.time() - t0
|
|
2112
|
+
loop_ms = max(50, int(total_time * 1000))
|
|
2113
|
+
mins, secs = divmod(int(total_time), 60)
|
|
2114
|
+
return {
|
|
2115
|
+
"response": agent_res["response"],
|
|
2116
|
+
"steps": agent_res.get("steps", []),
|
|
2117
|
+
"provider": prov.upper(),
|
|
2118
|
+
"model": mod,
|
|
2119
|
+
"tokens": max(12, len(agent_res["response"].split()) * 4 // 3),
|
|
2120
|
+
"time_str": f"{mins}:{secs:02d}",
|
|
2121
|
+
"duration": total_time,
|
|
2122
|
+
"timings": {"ttfb_ms": 300, "memory_ms": 25, "loop_ms": loop_ms},
|
|
2123
|
+
"calls": max(1, len(agent_res.get("steps", []))),
|
|
2124
|
+
"success": True,
|
|
2125
|
+
}
|
|
2126
|
+
except Exception:
|
|
2127
|
+
pass
|
|
2128
|
+
|
|
2129
|
+
# Standard AI chat with auto-retry on transient failures
|
|
2130
|
+
max_retries = 2
|
|
2131
|
+
last_response = ""
|
|
2132
|
+
last_error = ""
|
|
2133
|
+
|
|
2134
|
+
for attempt in range(max_retries + 1):
|
|
2135
|
+
try:
|
|
2136
|
+
from calc_terminal import aicore as _aicore
|
|
2137
|
+
from calc_terminal import ai_modes as _am
|
|
2138
|
+
|
|
2139
|
+
# Reload config on retry to pick up any changes
|
|
2140
|
+
if attempt > 0:
|
|
2141
|
+
try:
|
|
2142
|
+
config = _aicore.load_config()
|
|
2143
|
+
prov = config.get("provider", prov)
|
|
2144
|
+
mod = config.get("model", mod)
|
|
2145
|
+
except Exception:
|
|
2146
|
+
pass
|
|
2147
|
+
|
|
2148
|
+
system_prompt = _am.system_prompt_for(mode) if hasattr(_am, "system_prompt_for") else _aicore.DEFAULT_SYSTEM_PROMPT
|
|
2149
|
+
response = _aicore.query_ai(
|
|
2150
|
+
message,
|
|
2151
|
+
system_prompt=system_prompt,
|
|
2152
|
+
config=config,
|
|
2153
|
+
)
|
|
2154
|
+
|
|
2155
|
+
# Validate response — check if it's an error signature
|
|
2156
|
+
if response and not _aicore.is_error_response(response):
|
|
2157
|
+
# Good response
|
|
2158
|
+
total_time = time.time() - t0
|
|
2159
|
+
loop_ms = max(50, int(total_time * 1000))
|
|
2160
|
+
ttfb_ms = max(30, int(loop_ms * 0.85))
|
|
2161
|
+
mem_ms = max(10, int(total_time * 15))
|
|
2162
|
+
tokens = max(1, len((response or "").split()) * 4 // 3)
|
|
2163
|
+
mins, secs = divmod(int(total_time), 60)
|
|
2164
|
+
time_str = f"{mins}:{secs:02d}"
|
|
2165
|
+
return {
|
|
2166
|
+
"response": response,
|
|
2167
|
+
"provider": prov.upper(),
|
|
2168
|
+
"model": mod,
|
|
2169
|
+
"tokens": tokens,
|
|
2170
|
+
"time_str": time_str,
|
|
2171
|
+
"duration": total_time,
|
|
2172
|
+
"timings": {
|
|
2173
|
+
"ttfb_ms": ttfb_ms,
|
|
2174
|
+
"memory_ms": mem_ms,
|
|
2175
|
+
"loop_ms": loop_ms,
|
|
2176
|
+
},
|
|
2177
|
+
"calls": attempt + 1,
|
|
2178
|
+
"success": True,
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
# Error response — save and retry if we have attempts left
|
|
2182
|
+
last_response = response or "No response received from AI."
|
|
2183
|
+
if attempt < max_retries:
|
|
2184
|
+
time.sleep(0.5 * (attempt + 1)) # brief backoff
|
|
2185
|
+
continue
|
|
2186
|
+
|
|
2187
|
+
except Exception as e:
|
|
2188
|
+
last_error = str(e)
|
|
2189
|
+
last_response = last_response or f"Error: {last_error}"
|
|
2190
|
+
if attempt < max_retries:
|
|
2191
|
+
time.sleep(0.5 * (attempt + 1))
|
|
2192
|
+
continue
|
|
2193
|
+
|
|
2194
|
+
# All retries exhausted — return best error info
|
|
2195
|
+
total_time = time.time() - t0
|
|
2196
|
+
mins, secs = divmod(int(total_time), 60)
|
|
2197
|
+
time_str = f"{mins}:{secs:02d}"
|
|
2198
|
+
timed_out = "timed out" in (last_response + last_error).lower() or total_time >= 25.0
|
|
2199
|
+
resp_text = last_response if last_response else ("(Generation timed out)" if timed_out else f"Error: {last_error}")
|
|
2200
|
+
return {
|
|
2201
|
+
"response": resp_text,
|
|
2202
|
+
"provider": prov.upper(),
|
|
2203
|
+
"model": mod,
|
|
2204
|
+
"tokens": max(5, int(total_time * 2)),
|
|
2205
|
+
"time_str": time_str,
|
|
2206
|
+
"duration": total_time,
|
|
2207
|
+
"timings": {
|
|
2208
|
+
"ttfb_ms": max(100, int(total_time * 1000)),
|
|
2209
|
+
"memory_ms": 337,
|
|
2210
|
+
"loop_ms": max(120, int(total_time * 1000) + 30),
|
|
2211
|
+
},
|
|
2212
|
+
"calls": max_retries + 1,
|
|
2213
|
+
"success": False,
|
|
2214
|
+
"timed_out": timed_out,
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
|
|
2218
|
+
|
|
2219
|
+
async def _broadcast_preview_event(workspace_id: str, kind: str, info: dict):
|
|
2220
|
+
"""Broadcast preview events to connected websockets."""
|
|
2221
|
+
message = json.dumps({"type": "preview", "kind": kind, "data": info}, default=str)
|
|
2222
|
+
for ws in state_manager.active_websockets.copy():
|
|
2223
|
+
try:
|
|
2224
|
+
await ws.send_text(message)
|
|
2225
|
+
except Exception:
|
|
2226
|
+
state_manager.active_websockets.discard(ws)
|
|
2227
|
+
|
|
2228
|
+
|
|
2229
|
+
async def _handle_websocket_message(websocket: WebSocket, workspace_id: str, message: dict):
|
|
2230
|
+
"""Handle incoming websocket messages."""
|
|
2231
|
+
msg_type = message.get("type")
|
|
2232
|
+
|
|
2233
|
+
if msg_type == "preview_navigate":
|
|
2234
|
+
ctrl = state_manager.get_preview_controller(workspace_id)
|
|
2235
|
+
if ctrl:
|
|
2236
|
+
ctrl.navigate(message.get("url", ""))
|
|
2237
|
+
|
|
2238
|
+
elif msg_type == "preview_reload":
|
|
2239
|
+
ctrl = state_manager.get_preview_controller(workspace_id)
|
|
2240
|
+
if ctrl:
|
|
2241
|
+
ctrl.reload()
|
|
2242
|
+
|
|
2243
|
+
elif msg_type == "file_change":
|
|
2244
|
+
# Handle file changes from editor
|
|
2245
|
+
ctrl = state_manager.get_preview_controller(workspace_id)
|
|
2246
|
+
if ctrl:
|
|
2247
|
+
paths = message.get("paths", [])
|
|
2248
|
+
ctrl.on_files_changed(paths)
|
|
2249
|
+
|
|
2250
|
+
|
|
2251
|
+
# ============================================================
|
|
2252
|
+
# Vision Agent (CAT v0.8.a)
|
|
2253
|
+
# ============================================================
|
|
2254
|
+
|
|
2255
|
+
from calc_terminal.vision.session import VisionSessionManager, SessionState
|
|
2256
|
+
from calc_terminal.vision.capture import CaptureConfig, CaptureMode
|
|
2257
|
+
from calc_terminal.vision.context import VisionContextEngine
|
|
2258
|
+
from calc_terminal.vision.verify import BeforeAfterVerifier
|
|
2259
|
+
from calc_terminal.vision.safety import SafetyConfig, PrivacyController
|
|
2260
|
+
from calc_terminal.vision.provider import VisionProviderRouter
|
|
2261
|
+
from calc_terminal.vision.frame_pipeline import FrameMeta, ChangeLevel
|
|
2262
|
+
from calc_terminal.vision.annotations import AnnotationEngine, AnnotationTool, Color
|
|
2263
|
+
|
|
2264
|
+
vision_manager = VisionSessionManager()
|
|
2265
|
+
vision_context = VisionContextEngine()
|
|
2266
|
+
vision_verifier = BeforeAfterVerifier()
|
|
2267
|
+
vision_router = VisionProviderRouter()
|
|
2268
|
+
|
|
2269
|
+
|
|
2270
|
+
class VisionSessionRequest(BaseModel):
|
|
2271
|
+
mode: Optional[str] = "display"
|
|
2272
|
+
fps: Optional[float] = 1.0
|
|
2273
|
+
quality: Optional[int] = 85
|
|
2274
|
+
max_frames: Optional[int] = 500
|
|
2275
|
+
width: Optional[int] = None
|
|
2276
|
+
height: Optional[int] = None
|
|
2277
|
+
auto_analyze: Optional[bool] = True
|
|
2278
|
+
privacy_mode: Optional[bool] = True
|
|
2279
|
+
annotations_enabled: Optional[bool] = True
|
|
2280
|
+
|
|
2281
|
+
|
|
2282
|
+
class VisionFrameRequest(BaseModel):
|
|
2283
|
+
session_id: str
|
|
2284
|
+
frame_b64: str
|
|
2285
|
+
timestamp: Optional[float] = None
|
|
2286
|
+
pointer_x: Optional[float] = None
|
|
2287
|
+
pointer_y: Optional[float] = None
|
|
2288
|
+
pointer_down: Optional[bool] = False
|
|
2289
|
+
scroll_y: Optional[int] = 0
|
|
2290
|
+
active_element: Optional[str] = ""
|
|
2291
|
+
url: Optional[str] = ""
|
|
2292
|
+
title: Optional[str] = ""
|
|
2293
|
+
|
|
2294
|
+
|
|
2295
|
+
class VisionAnalyzeRequest(BaseModel):
|
|
2296
|
+
session_id: str
|
|
2297
|
+
prompt: Optional[str] = ""
|
|
2298
|
+
mode: Optional[str] = "analyze"
|
|
2299
|
+
error_message: Optional[str] = ""
|
|
2300
|
+
|
|
2301
|
+
|
|
2302
|
+
class VisionAnnotationRequest(BaseModel):
|
|
2303
|
+
session_id: str
|
|
2304
|
+
tool: str = "pen"
|
|
2305
|
+
points: List[List[float]] = []
|
|
2306
|
+
color: Optional[Dict] = None
|
|
2307
|
+
line_width: Optional[float] = 3.0
|
|
2308
|
+
text: Optional[str] = ""
|
|
2309
|
+
frame_index: Optional[int] = -1
|
|
2310
|
+
|
|
2311
|
+
|
|
2312
|
+
class VisionContextRequest(BaseModel):
|
|
2313
|
+
session_id: str
|
|
2314
|
+
file_path: Optional[str] = ""
|
|
2315
|
+
file_content: Optional[str] = ""
|
|
2316
|
+
cursor_line: Optional[int] = 0
|
|
2317
|
+
language: Optional[str] = ""
|
|
2318
|
+
project_type: Optional[str] = ""
|
|
2319
|
+
|
|
2320
|
+
|
|
2321
|
+
class VisionVerifyRequest(BaseModel):
|
|
2322
|
+
session_id: str
|
|
2323
|
+
fix_description: Optional[str] = ""
|
|
2324
|
+
code_change: Optional[str] = ""
|
|
2325
|
+
|
|
2326
|
+
|
|
2327
|
+
@app.post("/api/vision/session")
|
|
2328
|
+
async def vision_session_create(req: VisionSessionRequest):
|
|
2329
|
+
try:
|
|
2330
|
+
mode_map = {
|
|
2331
|
+
"display": CaptureMode.DISPLAY,
|
|
2332
|
+
"tab": CaptureMode.TAB,
|
|
2333
|
+
"window": CaptureMode.WINDOW,
|
|
2334
|
+
"audio_video": CaptureMode.AUDIO_VIDEO,
|
|
2335
|
+
}
|
|
2336
|
+
config = CaptureConfig(
|
|
2337
|
+
mode=mode_map.get(req.mode, CaptureMode.DISPLAY),
|
|
2338
|
+
fps=req.fps,
|
|
2339
|
+
quality=req.quality,
|
|
2340
|
+
max_frames=req.max_frames,
|
|
2341
|
+
width=req.width,
|
|
2342
|
+
height=req.height,
|
|
2343
|
+
auto_analyze=req.auto_analyze,
|
|
2344
|
+
privacy_mode=req.privacy_mode,
|
|
2345
|
+
annotations_enabled=req.annotations_enabled,
|
|
2346
|
+
)
|
|
2347
|
+
session = vision_manager.create_session(config)
|
|
2348
|
+
return {"ok": True, "session": session.to_dict()}
|
|
2349
|
+
except Exception as e:
|
|
2350
|
+
raise HTTPException(400, str(e))
|
|
2351
|
+
|
|
2352
|
+
|
|
2353
|
+
@app.post("/api/vision/session/{session_id}/start")
|
|
2354
|
+
async def vision_session_start(session_id: str):
|
|
2355
|
+
try:
|
|
2356
|
+
session = await vision_manager.start_session(session_id)
|
|
2357
|
+
return {"ok": True, "session": session.to_dict()}
|
|
2358
|
+
except KeyError:
|
|
2359
|
+
raise HTTPException(404, "Session not found")
|
|
2360
|
+
except Exception as e:
|
|
2361
|
+
raise HTTPException(400, str(e))
|
|
2362
|
+
|
|
2363
|
+
|
|
2364
|
+
@app.post("/api/vision/session/{session_id}/stop")
|
|
2365
|
+
async def vision_session_stop(session_id: str):
|
|
2366
|
+
try:
|
|
2367
|
+
session = await vision_manager.stop_session(session_id)
|
|
2368
|
+
return {"ok": True, "session": session.to_dict()}
|
|
2369
|
+
except KeyError:
|
|
2370
|
+
raise HTTPException(404, "Session not found")
|
|
2371
|
+
except Exception as e:
|
|
2372
|
+
raise HTTPException(400, str(e))
|
|
2373
|
+
|
|
2374
|
+
|
|
2375
|
+
@app.post("/api/vision/session/{session_id}/pause")
|
|
2376
|
+
async def vision_session_pause(session_id: str):
|
|
2377
|
+
try:
|
|
2378
|
+
await vision_manager.pause_session(session_id)
|
|
2379
|
+
session = vision_manager.get_session(session_id)
|
|
2380
|
+
return {"ok": True, "session": session.to_dict() if session else None}
|
|
2381
|
+
except Exception as e:
|
|
2382
|
+
raise HTTPException(400, str(e))
|
|
2383
|
+
|
|
2384
|
+
|
|
2385
|
+
@app.post("/api/vision/session/{session_id}/resume")
|
|
2386
|
+
async def vision_session_resume(session_id: str):
|
|
2387
|
+
try:
|
|
2388
|
+
await vision_manager.resume_session(session_id)
|
|
2389
|
+
session = vision_manager.get_session(session_id)
|
|
2390
|
+
return {"ok": True, "session": session.to_dict() if session else None}
|
|
2391
|
+
except Exception as e:
|
|
2392
|
+
raise HTTPException(400, str(e))
|
|
2393
|
+
|
|
2394
|
+
|
|
2395
|
+
@app.delete("/api/vision/session/{session_id}")
|
|
2396
|
+
async def vision_session_destroy(session_id: str):
|
|
2397
|
+
try:
|
|
2398
|
+
await vision_manager.destroy_session(session_id)
|
|
2399
|
+
return {"ok": True}
|
|
2400
|
+
except Exception as e:
|
|
2401
|
+
raise HTTPException(400, str(e))
|
|
2402
|
+
|
|
2403
|
+
|
|
2404
|
+
@app.get("/api/vision/sessions")
|
|
2405
|
+
async def vision_sessions_list():
|
|
2406
|
+
return {"ok": True, "sessions": vision_manager.list_sessions()}
|
|
2407
|
+
|
|
2408
|
+
|
|
2409
|
+
@app.get("/api/vision/session/{session_id}")
|
|
2410
|
+
async def vision_session_get(session_id: str):
|
|
2411
|
+
session = vision_manager.get_session(session_id)
|
|
2412
|
+
if not session:
|
|
2413
|
+
raise HTTPException(404, "Session not found")
|
|
2414
|
+
return {"ok": True, "session": session.to_dict()}
|
|
2415
|
+
|
|
2416
|
+
|
|
2417
|
+
@app.get("/api/vision/session/{session_id}/config")
|
|
2418
|
+
async def vision_capture_config(session_id: str):
|
|
2419
|
+
session = vision_manager.get_session(session_id)
|
|
2420
|
+
if not session:
|
|
2421
|
+
raise HTTPException(404, "Session not found")
|
|
2422
|
+
return {"ok": True, "config": session.capture.get_capture_config()}
|
|
2423
|
+
|
|
2424
|
+
|
|
2425
|
+
@app.get("/api/vision/capabilities")
|
|
2426
|
+
async def vision_capabilities():
|
|
2427
|
+
return {
|
|
2428
|
+
"ok": True,
|
|
2429
|
+
"capture": vision_manager._privacy.get_privacy_summary(),
|
|
2430
|
+
"providers": vision_router.get_all_capabilities(),
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
|
|
2434
|
+
@app.post("/api/vision/frame")
|
|
2435
|
+
async def vision_frame_submit(req: VisionFrameRequest):
|
|
2436
|
+
session = vision_manager.get_session(req.session_id)
|
|
2437
|
+
if not session:
|
|
2438
|
+
raise HTTPException(404, "Session not found")
|
|
2439
|
+
meta = FrameMeta(
|
|
2440
|
+
timestamp=req.timestamp or time.time(),
|
|
2441
|
+
pointer_x=req.pointer_x or 0,
|
|
2442
|
+
pointer_y=req.pointer_y or 0,
|
|
2443
|
+
pointer_down=req.pointer_down or False,
|
|
2444
|
+
scroll_y=req.scroll_y or 0,
|
|
2445
|
+
active_element=req.active_element or "",
|
|
2446
|
+
url=req.url or "",
|
|
2447
|
+
title=req.title or "",
|
|
2448
|
+
)
|
|
2449
|
+
result = vision_manager.process_frame(req.session_id, req.frame_b64, meta)
|
|
2450
|
+
if result is None:
|
|
2451
|
+
return {"ok": True, "processed": False, "reason": "frame_not_selected"}
|
|
2452
|
+
vision_manager._privacy.record_capture()
|
|
2453
|
+
return {"ok": True, "processed": True, "frame": result}
|
|
2454
|
+
|
|
2455
|
+
|
|
2456
|
+
@app.post("/api/vision/annotate")
|
|
2457
|
+
async def vision_annotate(req: VisionAnnotationRequest):
|
|
2458
|
+
session = vision_manager.get_session(req.session_id)
|
|
2459
|
+
if not session:
|
|
2460
|
+
raise HTTPException(404, "Session not found")
|
|
2461
|
+
tool_map = {
|
|
2462
|
+
"pen": AnnotationTool.PEN,
|
|
2463
|
+
"arrow": AnnotationTool.ARROW,
|
|
2464
|
+
"rect": AnnotationTool.RECT,
|
|
2465
|
+
"circle": AnnotationTool.CIRCLE,
|
|
2466
|
+
"text": AnnotationTool.TEXT,
|
|
2467
|
+
"highlight": AnnotationTool.HIGHLIGHT,
|
|
2468
|
+
"crosshair": AnnotationTool.CROSSHAIR,
|
|
2469
|
+
"eraser": AnnotationTool.ERASER,
|
|
2470
|
+
}
|
|
2471
|
+
tool = tool_map.get(req.tool, AnnotationTool.PEN)
|
|
2472
|
+
color = Color.from_dict(req.color) if req.color else None
|
|
2473
|
+
points = [(p[0], p[1]) for p in req.points]
|
|
2474
|
+
ann = session.annotations.add_annotation(
|
|
2475
|
+
tool=tool, points=points, color=color,
|
|
2476
|
+
line_width=req.line_width, text=req.text,
|
|
2477
|
+
frame_index=req.frame_index,
|
|
2478
|
+
)
|
|
2479
|
+
return {"ok": True, "annotation": ann.to_dict()}
|
|
2480
|
+
|
|
2481
|
+
|
|
2482
|
+
@app.get("/api/vision/annotations/{session_id}")
|
|
2483
|
+
async def vision_annotations_list(session_id: str):
|
|
2484
|
+
session = vision_manager.get_session(session_id)
|
|
2485
|
+
if not session:
|
|
2486
|
+
raise HTTPException(404, "Session not found")
|
|
2487
|
+
return {"ok": True, "annotations": session.annotations.to_list()}
|
|
2488
|
+
|
|
2489
|
+
|
|
2490
|
+
@app.post("/api/vision/annotations/{session_id}/undo")
|
|
2491
|
+
async def vision_annotations_undo(session_id: str):
|
|
2492
|
+
session = vision_manager.get_session(session_id)
|
|
2493
|
+
if not session:
|
|
2494
|
+
raise HTTPException(404, "Session not found")
|
|
2495
|
+
ann = session.annotations.undo()
|
|
2496
|
+
return {"ok": True, "undone": ann.to_dict() if ann else None}
|
|
2497
|
+
|
|
2498
|
+
|
|
2499
|
+
@app.post("/api/vision/annotations/{session_id}/redo")
|
|
2500
|
+
async def vision_annotations_redo(session_id: str):
|
|
2501
|
+
session = vision_manager.get_session(session_id)
|
|
2502
|
+
if not session:
|
|
2503
|
+
raise HTTPException(404, "Session not found")
|
|
2504
|
+
ann = session.annotations.redo()
|
|
2505
|
+
return {"ok": True, "redone": ann.to_dict() if ann else None}
|
|
2506
|
+
|
|
2507
|
+
|
|
2508
|
+
@app.post("/api/vision/annotations/{session_id}/clear")
|
|
2509
|
+
async def vision_annotations_clear(session_id: str):
|
|
2510
|
+
session = vision_manager.get_session(session_id)
|
|
2511
|
+
if not session:
|
|
2512
|
+
raise HTTPException(404, "Session not found")
|
|
2513
|
+
session.annotations.clear()
|
|
2514
|
+
return {"ok": True}
|
|
2515
|
+
|
|
2516
|
+
|
|
2517
|
+
@app.post("/api/vision/context")
|
|
2518
|
+
async def vision_context_set(req: VisionContextRequest):
|
|
2519
|
+
session = vision_manager.get_session(req.session_id)
|
|
2520
|
+
if not session:
|
|
2521
|
+
raise HTTPException(404, "Session not found")
|
|
2522
|
+
vision_context.set_code_context(
|
|
2523
|
+
file_path=req.file_path,
|
|
2524
|
+
content=req.file_content,
|
|
2525
|
+
cursor_line=req.cursor_line,
|
|
2526
|
+
language=req.language,
|
|
2527
|
+
)
|
|
2528
|
+
if req.project_type:
|
|
2529
|
+
vision_context.set_project_context(project_type=req.project_type)
|
|
2530
|
+
return {"ok": True}
|
|
2531
|
+
|
|
2532
|
+
|
|
2533
|
+
@app.post("/api/vision/analyze")
|
|
2534
|
+
async def vision_analyze(req: VisionAnalyzeRequest):
|
|
2535
|
+
session = vision_manager.get_session(req.session_id)
|
|
2536
|
+
if not session:
|
|
2537
|
+
raise HTTPException(404, "Session not found")
|
|
2538
|
+
best_frame = session.pipeline.get_best_frame()
|
|
2539
|
+
if not best_frame:
|
|
2540
|
+
raise HTTPException(400, "No frames available for analysis")
|
|
2541
|
+
pointer_telemetry = {
|
|
2542
|
+
"active_element": "",
|
|
2543
|
+
"cursor_moving": False,
|
|
2544
|
+
}
|
|
2545
|
+
visual_ctx = vision_context.build_context(
|
|
2546
|
+
frame_b64=best_frame.frame_b64,
|
|
2547
|
+
frame_meta=best_frame.meta.to_dict(),
|
|
2548
|
+
annotations=session.annotations.to_list(),
|
|
2549
|
+
pointer_telemetry=pointer_telemetry,
|
|
2550
|
+
analysis_prompt=req.prompt,
|
|
2551
|
+
)
|
|
2552
|
+
try:
|
|
2553
|
+
from calc_terminal import aicore
|
|
2554
|
+
config, caps = vision_router.find_vision_capable() or (None, None)
|
|
2555
|
+
if config is None:
|
|
2556
|
+
return {
|
|
2557
|
+
"ok": False,
|
|
2558
|
+
"error": "No vision-capable model configured",
|
|
2559
|
+
"suggestion": "Configure a vision model via /model (e.g. gpt-4o, claude-3.5-sonnet)",
|
|
2560
|
+
}
|
|
2561
|
+
messages = visual_ctx.to_ai_messages()
|
|
2562
|
+
user_msg = messages[-1] if messages else {"role": "user", "content": req.prompt}
|
|
2563
|
+
system_msg = messages[0] if messages else {"role": "system", "content": "Analyze the screen."}
|
|
2564
|
+
from calc_terminal.attachments import Attachment
|
|
2565
|
+
att = Attachment.__new__(Attachment)
|
|
2566
|
+
att.id = f"att-vision-{abs(hash(best_frame.frame_b64[:64])) % 1000000}"
|
|
2567
|
+
att.name = "screenshot"
|
|
2568
|
+
att.path = ""
|
|
2569
|
+
att.extension = ".png"
|
|
2570
|
+
att.mime_type = "image/png"
|
|
2571
|
+
att.size = len(best_frame.frame_b64)
|
|
2572
|
+
att.kind = "image"
|
|
2573
|
+
att.content = "[screen capture]"
|
|
2574
|
+
att.metadata = {"inline_b64": best_frame.frame_b64}
|
|
2575
|
+
att.extraction_status = "ready"
|
|
2576
|
+
att.error = None
|
|
2577
|
+
att.created_at = time.time()
|
|
2578
|
+
att.source = "vision_agent"
|
|
2579
|
+
analysis = aicore.query_ai(
|
|
2580
|
+
user_msg.get("content", req.prompt) if isinstance(user_msg.get("content"), str) else req.prompt,
|
|
2581
|
+
system_prompt=system_msg.get("content", "You are CAT Vision Agent."),
|
|
2582
|
+
history=None,
|
|
2583
|
+
config=config,
|
|
2584
|
+
attachments=[att],
|
|
2585
|
+
size_class="large",
|
|
2586
|
+
)
|
|
2587
|
+
return {"ok": True, "analysis": analysis, "frame_index": best_frame.index}
|
|
2588
|
+
except Exception as e:
|
|
2589
|
+
return {"ok": False, "error": str(e)}
|
|
2590
|
+
|
|
2591
|
+
|
|
2592
|
+
@app.post("/api/vision/verify")
|
|
2593
|
+
async def vision_verify(req: VisionVerifyRequest):
|
|
2594
|
+
session = vision_manager.get_session(req.session_id)
|
|
2595
|
+
if not session:
|
|
2596
|
+
raise HTTPException(404, "Session not found")
|
|
2597
|
+
verification = vision_verifier.start_verification()
|
|
2598
|
+
best_frame = session.pipeline.get_best_frame()
|
|
2599
|
+
if best_frame:
|
|
2600
|
+
vision_verifier.set_before_frame(
|
|
2601
|
+
best_frame.frame_b64, best_frame.meta.to_dict()
|
|
2602
|
+
)
|
|
2603
|
+
vision_verifier.set_fix_applied(req.fix_description)
|
|
2604
|
+
return {
|
|
2605
|
+
"ok": True,
|
|
2606
|
+
"verification": verification.to_dict(),
|
|
2607
|
+
"message": "Before frame captured. Apply fix, then submit after frame.",
|
|
2608
|
+
}
|
|
2609
|
+
|
|
2610
|
+
|
|
2611
|
+
@app.post("/api/vision/verify/after")
|
|
2612
|
+
async def vision_verify_after(session_id: str = Form(...),
|
|
2613
|
+
frame_b64: str = Form(...)):
|
|
2614
|
+
vision_verifier.set_after_frame(frame_b64)
|
|
2615
|
+
prompt = vision_verifier.build_comparison_prompt()
|
|
2616
|
+
try:
|
|
2617
|
+
from calc_terminal import aicore
|
|
2618
|
+
config, caps = vision_router.find_vision_capable() or (None, None)
|
|
2619
|
+
if config and vision_verifier.current and vision_verifier.current.before_frame_b64:
|
|
2620
|
+
from calc_terminal.attachments import Attachment
|
|
2621
|
+
att_before = Attachment.__new__(Attachment)
|
|
2622
|
+
att_before.id = "att-vision-before"
|
|
2623
|
+
att_before.name = "before"
|
|
2624
|
+
att_before.path = ""
|
|
2625
|
+
att_before.extension = ".png"
|
|
2626
|
+
att_before.mime_type = "image/png"
|
|
2627
|
+
att_before.size = len(vision_verifier.current.before_frame_b64)
|
|
2628
|
+
att_before.kind = "image"
|
|
2629
|
+
att_before.content = "[before frame]"
|
|
2630
|
+
att_before.metadata = {"inline_b64": vision_verifier.current.before_frame_b64}
|
|
2631
|
+
att_before.extraction_status = "ready"
|
|
2632
|
+
att_before.error = None
|
|
2633
|
+
att_before.created_at = time.time()
|
|
2634
|
+
att_before.source = "vision_verify"
|
|
2635
|
+
att_after = Attachment.__new__(Attachment)
|
|
2636
|
+
att_after.id = "att-vision-after"
|
|
2637
|
+
att_after.name = "after"
|
|
2638
|
+
att_after.path = ""
|
|
2639
|
+
att_after.extension = ".png"
|
|
2640
|
+
att_after.mime_type = "image/png"
|
|
2641
|
+
att_after.size = len(frame_b64)
|
|
2642
|
+
att_after.kind = "image"
|
|
2643
|
+
att_after.content = "[after frame]"
|
|
2644
|
+
att_after.metadata = {"inline_b64": frame_b64}
|
|
2645
|
+
att_after.extraction_status = "ready"
|
|
2646
|
+
att_after.error = None
|
|
2647
|
+
att_after.created_at = time.time()
|
|
2648
|
+
att_after.source = "vision_verify"
|
|
2649
|
+
analysis = aicore.query_ai(
|
|
2650
|
+
prompt,
|
|
2651
|
+
system_prompt="You are CAT Vision verification agent. Compare before and after states.",
|
|
2652
|
+
history=None,
|
|
2653
|
+
config=config,
|
|
2654
|
+
attachments=[att_before, att_after],
|
|
2655
|
+
size_class="large",
|
|
2656
|
+
)
|
|
2657
|
+
passed = "PASS" in (analysis or "").upper()[:200]
|
|
2658
|
+
vision_verifier.complete(
|
|
2659
|
+
ai_analysis=analysis or "",
|
|
2660
|
+
passed=passed,
|
|
2661
|
+
confidence=0.8 if passed else 0.3,
|
|
2662
|
+
)
|
|
2663
|
+
return {"ok": True, "result": vision_verifier.history[-1].to_dict()}
|
|
2664
|
+
except Exception as e:
|
|
2665
|
+
vision_verifier.error(str(e))
|
|
2666
|
+
return {"ok": True, "result": verification_status()}
|
|
2667
|
+
|
|
2668
|
+
|
|
2669
|
+
def verification_status():
|
|
2670
|
+
if vision_verifier.current:
|
|
2671
|
+
return vision_verifier.current.to_dict()
|
|
2672
|
+
return {"status": "idle"}
|
|
2673
|
+
|
|
2674
|
+
|
|
2675
|
+
@app.get("/api/vision/verify/status")
|
|
2676
|
+
async def vision_verify_status():
|
|
2677
|
+
return {"ok": True, "verification": verification_status()}
|
|
2678
|
+
|
|
2679
|
+
|
|
2680
|
+
@app.get("/api/vision/verify/history")
|
|
2681
|
+
async def vision_verify_history():
|
|
2682
|
+
return {"ok": True, "history": vision_verifier.history}
|
|
2683
|
+
|
|
2684
|
+
|
|
2685
|
+
@app.post("/api/vision/verify/clear")
|
|
2686
|
+
async def vision_verify_clear():
|
|
2687
|
+
vision_verifier.clear_history()
|
|
2688
|
+
return {"ok": True}
|
|
2689
|
+
|
|
2690
|
+
|
|
2691
|
+
@app.post("/api/vision/provider/route")
|
|
2692
|
+
async def vision_provider_route(session_id: str = Form(...)):
|
|
2693
|
+
session = vision_manager.get_session(session_id)
|
|
2694
|
+
if not session:
|
|
2695
|
+
raise HTTPException(404, "Session not found")
|
|
2696
|
+
best = session.pipeline.get_best_frame()
|
|
2697
|
+
size = len(best.frame_b64) if best else 0
|
|
2698
|
+
result = vision_router.route_vision_request(best.frame_b64 if best else "", size)
|
|
2699
|
+
return {"ok": True, "route": result}
|
|
2700
|
+
|
|
2701
|
+
|
|
2702
|
+
@app.get("/api/vision/provider/capabilities")
|
|
2703
|
+
async def vision_provider_capabilities():
|
|
2704
|
+
return {"ok": True, "capabilities": vision_router.get_all_capabilities()}
|
|
2705
|
+
|
|
2706
|
+
|
|
2707
|
+
@app.get("/api/vision/privacy")
|
|
2708
|
+
async def vision_privacy_summary():
|
|
2709
|
+
return {"ok": True, "privacy": vision_manager._privacy.get_privacy_summary()}
|
|
2710
|
+
|
|
2711
|
+
|
|
2712
|
+
@app.get("/api/vision/privacy/log")
|
|
2713
|
+
async def vision_privacy_log(limit: int = 100):
|
|
2714
|
+
return {"ok": True, "log": vision_manager._privacy.get_access_log(limit)}
|
|
2715
|
+
|
|
2716
|
+
|
|
2717
|
+
class VisionCursorRequest(BaseModel):
|
|
2718
|
+
session_id: str
|
|
2719
|
+
x: float = 0.0
|
|
2720
|
+
y: float = 0.0
|
|
2721
|
+
pointer_type: Optional[str] = "mouse"
|
|
2722
|
+
pointer_id: Optional[int] = 0
|
|
2723
|
+
button: Optional[int] = 0
|
|
2724
|
+
pressure: Optional[float] = 0.5
|
|
2725
|
+
movement_x: Optional[float] = 0.0
|
|
2726
|
+
movement_y: Optional[float] = 0.0
|
|
2727
|
+
is_click: Optional[bool] = False
|
|
2728
|
+
is_double_click: Optional[bool] = False
|
|
2729
|
+
is_drag: Optional[bool] = False
|
|
2730
|
+
is_scroll: Optional[bool] = False
|
|
2731
|
+
scroll_delta: Optional[float] = 0.0
|
|
2732
|
+
|
|
2733
|
+
|
|
2734
|
+
@app.post("/api/vision/cursor")
|
|
2735
|
+
async def vision_cursor(req: VisionCursorRequest):
|
|
2736
|
+
session = vision_manager.get_session(req.session_id)
|
|
2737
|
+
if not session:
|
|
2738
|
+
raise HTTPException(404, "Session not found")
|
|
2739
|
+
try:
|
|
2740
|
+
session.cursor.add_raw(
|
|
2741
|
+
x=req.x, y=req.y,
|
|
2742
|
+
pointer_type=req.pointer_type or "mouse",
|
|
2743
|
+
pointer_id=req.pointer_id or 0,
|
|
2744
|
+
button=req.button or 0,
|
|
2745
|
+
pressure=req.pressure or 0.5,
|
|
2746
|
+
movement_x=req.movement_x or 0.0,
|
|
2747
|
+
movement_y=req.movement_y or 0.0,
|
|
2748
|
+
is_click=bool(req.is_click),
|
|
2749
|
+
is_double_click=bool(req.is_double_click),
|
|
2750
|
+
is_drag=bool(req.is_drag),
|
|
2751
|
+
is_scroll=bool(req.is_scroll),
|
|
2752
|
+
scroll_delta=req.scroll_delta or 0.0,
|
|
2753
|
+
)
|
|
2754
|
+
# Priority signals from cursor
|
|
2755
|
+
if req.is_click:
|
|
2756
|
+
session.priority.signal_click()
|
|
2757
|
+
elif req.is_drag:
|
|
2758
|
+
session.priority.add_signal("cursor_move")
|
|
2759
|
+
# live status
|
|
2760
|
+
return {"ok": True, "cursor": session.cursor.get_cursor_context()}
|
|
2761
|
+
except Exception as e:
|
|
2762
|
+
raise HTTPException(400, str(e))
|
|
2763
|
+
|
|
2764
|
+
|
|
2765
|
+
@app.get("/api/vision/cursor/{session_id}")
|
|
2766
|
+
async def vision_cursor_get(session_id: str):
|
|
2767
|
+
session = vision_manager.get_session(session_id)
|
|
2768
|
+
if not session:
|
|
2769
|
+
raise HTTPException(404, "Session not found")
|
|
2770
|
+
return {"ok": True, "cursor": session.cursor.get_cursor_context(), "dwells": [d.to_dict() for d in session.cursor.dwells]}
|
|
2771
|
+
|
|
2772
|
+
|
|
2773
|
+
@app.post("/api/vision/correlate")
|
|
2774
|
+
async def vision_correlate(req: VisionContextRequest):
|
|
2775
|
+
"""Screen→Code correlation (spec 20). Honest mapping, never hallucinates."""
|
|
2776
|
+
session = vision_manager.get_session(req.session_id)
|
|
2777
|
+
if not session:
|
|
2778
|
+
raise HTTPException(404, "Session not found")
|
|
2779
|
+
try:
|
|
2780
|
+
from calc_terminal.vision.correlation import correlate as do_correlate
|
|
2781
|
+
workspace_root = None
|
|
2782
|
+
try:
|
|
2783
|
+
from calc_terminal import workspace as ws
|
|
2784
|
+
workspace_root = ws.root_dir() or ws.active_project()
|
|
2785
|
+
except Exception:
|
|
2786
|
+
workspace_root = None
|
|
2787
|
+
# preview context if preview running
|
|
2788
|
+
preview_ctx = None
|
|
2789
|
+
try:
|
|
2790
|
+
# try to get preview url from workspace state
|
|
2791
|
+
preview_ctx = {"entry": req.file_path or ""}
|
|
2792
|
+
except Exception:
|
|
2793
|
+
pass
|
|
2794
|
+
result = do_correlate(
|
|
2795
|
+
annotations=session.annotations.to_list(),
|
|
2796
|
+
cursor=session.cursor.get_cursor_context() if hasattr(session, 'cursor') else None,
|
|
2797
|
+
preview_context=preview_ctx,
|
|
2798
|
+
workspace_root=workspace_root,
|
|
2799
|
+
)
|
|
2800
|
+
return {"ok": True, "correlation": result}
|
|
2801
|
+
except Exception as e:
|
|
2802
|
+
return {"ok": False, "error": str(e)}
|
|
2803
|
+
|
|
2804
|
+
|
|
2805
|
+
@app.get("/api/vision/pipeline/summary/{session_id}")
|
|
2806
|
+
async def vision_pipeline_summary(session_id: str):
|
|
2807
|
+
session = vision_manager.get_session(session_id)
|
|
2808
|
+
if not session:
|
|
2809
|
+
raise HTTPException(404, "Session not found")
|
|
2810
|
+
return {"ok": True, "summary": session.pipeline.get_summary()}
|
|
2811
|
+
|
|
2812
|
+
|
|
2813
|
+
@app.get("/api/vision/pipeline/context/{session_id}")
|
|
2814
|
+
async def vision_pipeline_context(session_id: str, n: int = 5):
|
|
2815
|
+
session = vision_manager.get_session(session_id)
|
|
2816
|
+
if not session:
|
|
2817
|
+
raise HTTPException(404, "Session not found")
|
|
2818
|
+
return {"ok": True, "frames": session.pipeline.get_context_frames(n)}
|
|
2819
|
+
|
|
2820
|
+
|
|
2821
|
+
# ============================================================
|
|
2822
|
+
# Server Lifecycle & Auto-Start Utilities
|
|
2823
|
+
# ============================================================
|
|
2824
|
+
|
|
2825
|
+
def is_server_running(host: str = "127.0.0.1", port: int = 8765, timeout: float = 1.0) -> bool:
|
|
2826
|
+
"""Check if Fatty CAT server is reachable."""
|
|
2827
|
+
import socket
|
|
2828
|
+
try:
|
|
2829
|
+
with socket.create_connection((host, port), timeout=timeout):
|
|
2830
|
+
return True
|
|
2831
|
+
except OSError:
|
|
2832
|
+
return False
|
|
2833
|
+
|
|
2834
|
+
|
|
2835
|
+
def ensure_fatty_server(timeout: float = 10.0, auto_start: bool = True) -> bool:
|
|
2836
|
+
"""Ensure the Fatty CAT web server is running.
|
|
2837
|
+
If not running and auto_start is True, spawn it as a background process.
|
|
2838
|
+
Returns True if reachable (already or after auto-start).
|
|
2839
|
+
"""
|
|
2840
|
+
if is_server_running(timeout=0.8):
|
|
2841
|
+
return True
|
|
2842
|
+
if not auto_start:
|
|
2843
|
+
return False
|
|
2844
|
+
|
|
2845
|
+
import subprocess
|
|
2846
|
+
python_exe = sys.executable or "python"
|
|
2847
|
+
|
|
2848
|
+
creationflags = 0
|
|
2849
|
+
if sys.platform == "win32":
|
|
2850
|
+
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)
|
|
2851
|
+
|
|
2852
|
+
try:
|
|
2853
|
+
# `python -m calc_terminal.web.server` works from ANY cwd when CAT is
|
|
2854
|
+
# pip-installed (and from a source checkout too), so do not force
|
|
2855
|
+
# cwd to the git repo root — that path does not exist on a friend's
|
|
2856
|
+
# computer. Inherit the caller's cwd instead.
|
|
2857
|
+
subprocess.Popen(
|
|
2858
|
+
[python_exe, "-m", "calc_terminal.web.server"],
|
|
2859
|
+
stdout=subprocess.DEVNULL,
|
|
2860
|
+
stderr=subprocess.DEVNULL,
|
|
2861
|
+
creationflags=creationflags,
|
|
2862
|
+
)
|
|
2863
|
+
except Exception as e:
|
|
2864
|
+
print(f"[FATTY CAT] Failed to auto-start server: {e}", file=sys.stderr)
|
|
2865
|
+
return False
|
|
2866
|
+
|
|
2867
|
+
deadline = time.time() + timeout
|
|
2868
|
+
while time.time() < deadline:
|
|
2869
|
+
if is_server_running(timeout=0.5):
|
|
2870
|
+
return True
|
|
2871
|
+
time.sleep(0.3)
|
|
2872
|
+
return False
|
|
2873
|
+
|
|
2874
|
+
|
|
2875
|
+
# ============================================================
|
|
2876
|
+
# Main Entry Point
|
|
2877
|
+
# ============================================================
|
|
2878
|
+
|
|
2879
|
+
def main():
|
|
2880
|
+
import uvicorn
|
|
2881
|
+
uvicorn.run(
|
|
2882
|
+
"calc_terminal.web.server:app",
|
|
2883
|
+
host="0.0.0.0",
|
|
2884
|
+
port=8765,
|
|
2885
|
+
reload=False,
|
|
2886
|
+
log_level="info",
|
|
2887
|
+
)
|
|
2888
|
+
|
|
2889
|
+
|
|
2890
|
+
if __name__ == "__main__":
|
|
2891
|
+
main()
|