Spaces:
Running
🚀 SoniCoder v2.1.0 - Major Enhancement Release
Browse filesNEW FEATURES:
- 🤖 Added DeepSeek-Coder-1.3B model support (4 models total)
- 💾 Session management with JSON/Markdown/HTML export
- 📝 Code templates system (Flask, React, Gradio, Express)
- ⚡ Response caching and performance optimizations
- 📊 Inference metrics tracking (TTFT, tokens/sec)
- 🔒 Enhanced security with regex-based command blocking
- 🛠️ Command audit trail for all bash executions
TECHNICAL IMPROVEMENTS:
- Model loading progress with callbacks
- Better error handling and recovery
- Memory optimization utilities
- Graceful shutdown handling
- Token usage estimation
- Conversation summarization for long contexts
FILES MODIFIED/CREATED:
- code/config/constants.py: New models, templates, security settings
- code/model/loader.py: Progress tracking, memory optimization
- code/model/inference.py: Caching, metrics, validation
- code/tools/bash.py: Audit trail, enhanced security
- code/server/chat_helpers.py: Context management
- code/server/session_manager.py: NEW - Session persistence & export
- app.py: Enhanced startup with session management
- README.md: Complete documentation rewrite
- CLAUDE.md: Updated project memory
- CLAUDE.md +187 -183
- README.md +259 -4
- app.py +95 -8
- code/config/constants.py +375 -5
- code/model/inference.py +389 -95
- code/model/loader.py +337 -63
- code/server/__init__.py +107 -18
- code/server/chat_helpers.py +241 -13
- code/server/session_manager.py +514 -0
- code/tools/bash.py +260 -23
- requirements.txt +20 -2
|
@@ -1,58 +1,73 @@
|
|
| 1 |
-
# SoniCoder — Project Memory
|
| 2 |
|
| 3 |
> This file is the project's persistent memory. The agent reads it on every session.
|
| 4 |
> Edit it freely — it overrides defaults.
|
| 5 |
|
| 6 |
## What is SoniCoder?
|
| 7 |
|
| 8 |
-
SoniCoder is a local-first AI coding agent that can:
|
|
|
|
|
|
|
| 9 |
- Generate complete fullstack applications in any language/framework
|
| 10 |
- Read, write, and edit files in a sandboxed workspace
|
| 11 |
-
- Run shell commands (git, npm, pip, tests)
|
| 12 |
- Apply specialized skills (frontend-design, feature-dev, code-review, debugging, fullstack-scaffold, commit-workflow)
|
| 13 |
- Respond to slash commands (/commit, /review, /feature, /design, /explain, /test, /refactor, /skill, /help)
|
| 14 |
- Deploy to HuggingFace Spaces with one click
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
## Architecture
|
| 17 |
|
| 18 |
```
|
| 19 |
-
app.py
|
| 20 |
code/
|
| 21 |
-
├── config/constants.py
|
| 22 |
├── model/
|
| 23 |
-
│ ├── loader.py
|
| 24 |
-
│ └── inference.py
|
| 25 |
-
├── agent/__init__.py
|
| 26 |
├── tools/
|
| 27 |
-
│ ├── fs.py
|
| 28 |
-
│ ├── bash.py
|
| 29 |
-
│ ├── todos.py
|
| 30 |
-
│ └── github.py
|
| 31 |
├── skills/
|
| 32 |
-
│ ├── __init__.py
|
| 33 |
-
│ └── builtins/
|
| 34 |
-
├── agents/
|
| 35 |
-
│ ├── __init__.py
|
| 36 |
-
│ └── builtins/
|
| 37 |
├── commands/
|
| 38 |
-
│ ├── __init__.py
|
| 39 |
-
│ └── builtins/
|
| 40 |
├── hooks/
|
| 41 |
-
│ ├── __init__.py
|
| 42 |
-
│ └── builtins/
|
| 43 |
├── execution/
|
| 44 |
-
│ ├── code_extractor.py
|
| 45 |
-
│ ├── python_runner.py
|
| 46 |
-
│ └── gradio_runner.py
|
| 47 |
├���─ huggingface/
|
| 48 |
-
│ ├── dockerfile_gen.py
|
| 49 |
-
│ └── push.py
|
| 50 |
-
├── websearch/google_scraper.py
|
| 51 |
-
|
| 52 |
-
├──
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
| 56 |
```
|
| 57 |
|
| 58 |
## Conventions
|
|
@@ -65,20 +80,28 @@ workspace/ ← Sandboxed agent workspace (auto-created)
|
|
| 65 |
- **Tests**: pytest, in `tests/` directory, `test_*.py` naming
|
| 66 |
- **Frontend**: single-file HTML with inline CSS/JS, no build step
|
| 67 |
|
| 68 |
-
## Server
|
| 69 |
|
| 70 |
- All servers bind to `0.0.0.0` (never `localhost`)
|
| 71 |
- Default port: `7860` (HF Spaces convention)
|
| 72 |
- Sub-servers use `7861`, `7862`, etc.
|
|
|
|
| 73 |
|
| 74 |
-
## Model
|
| 75 |
|
| 76 |
-
|
| 77 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
- Loaded in background thread on startup
|
| 79 |
- Streaming inference via `TextIteratorStreamer`
|
|
|
|
| 80 |
|
| 81 |
-
## Tool
|
| 82 |
|
| 83 |
The model calls tools by emitting fenced code blocks with `tool` as the language:
|
| 84 |
|
|
@@ -98,7 +121,7 @@ content: |
|
|
| 98 |
pass
|
| 99 |
```
|
| 100 |
|
| 101 |
-
## Slash
|
| 102 |
|
| 103 |
| Command | Description |
|
| 104 |
|---------|-------------|
|
|
@@ -113,168 +136,96 @@ content: |
|
|
| 113 |
| `/agent create <desc>` | AI generates a custom agent from natural-language description |
|
| 114 |
| `/agent use <name>` | Activate a saved agent |
|
| 115 |
| `/agent list` | List all saved agents |
|
| 116 |
-
| `/agent show <name>` | Show an agent's full definition |
|
| 117 |
-
| `/agent delete <name>` | Delete a user-defined agent |
|
| 118 |
-
| `/agent reset` | Reset to default SoniCoder persona |
|
| 119 |
| `/github <url> [subdir] [--branch <name>] [--into <path>]` | Import a GitHub repo into the workspace |
|
| 120 |
| `/help` | Show available commands and skills |
|
| 121 |
|
| 122 |
-
##
|
| 123 |
|
| 124 |
-
|
| 125 |
-
system prompt. They can restrict the tool whitelist, auto-load skills, and
|
| 126 |
-
override temperature / max-iterations.
|
| 127 |
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
|
| 130 |
-
|
| 131 |
-
`code/agents/builtins/<name>/AGENT.md`). Format:
|
| 132 |
|
| 133 |
-
|
| 134 |
-
---
|
| 135 |
-
name: my-agent
|
| 136 |
-
description: One-line description
|
| 137 |
-
tools: read_file, list_dir, grep, bash
|
| 138 |
-
skills: code-review
|
| 139 |
-
temperature: 0.2
|
| 140 |
-
max_iterations: 12
|
| 141 |
-
tags: review, quality
|
| 142 |
-
author: AI-generated
|
| 143 |
-
created: 2026-06-20
|
| 144 |
-
---
|
| 145 |
|
| 146 |
-
|
| 147 |
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
|
|
|
| 151 |
|
| 152 |
-
###
|
| 153 |
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
|
|
|
|
|
|
| 160 |
|
| 161 |
-
##
|
| 162 |
|
| 163 |
-
|
| 164 |
-
|-------|-------------|
|
| 165 |
-
| `code-reviewer` | Read-only reviewer, structured issues table output |
|
| 166 |
-
| `test-writer` | Generates pytest/jest tests, runs them, iterates until green |
|
| 167 |
|
| 168 |
-
|
|
|
|
|
|
|
|
|
|
| 169 |
|
| 170 |
-
|
| 171 |
-
|----------|-------------|
|
| 172 |
-
| `list_agents` | List all agents (builtins + user) and the active one |
|
| 173 |
-
| `get_agent(name)` | Get a single agent's full definition |
|
| 174 |
-
| `save_agent(...)` | Create or overwrite a user agent (manual save) |
|
| 175 |
-
| `delete_agent(name)` | Delete a user agent (built-ins protected) |
|
| 176 |
-
| `set_active_agent(name)` | Set/clear the active agent for subsequent prompts |
|
| 177 |
-
| `import_github(url, branch, subdir, target_subdir, depth, timeout)` | Clone a GitHub repo into the workspace (shallow, heavy dirs stripped) |
|
| 178 |
-
| `github_url_examples()` | Return accepted GitHub URL formats |
|
| 179 |
-
| `push_github(repo_name, github_token, username, branch?, commit_message?, timeout?)` | Snapshot workspace → commit → push to a GitHub repo |
|
| 180 |
-
|
| 181 |
-
The `agent_run` endpoint also intercepts `/agent use|reset|delete|list` and
|
| 182 |
-
dispatches them directly to the agents module, bypassing the model entirely
|
| 183 |
-
for instant session-state updates.
|
| 184 |
-
|
| 185 |
-
## GitHub Import
|
| 186 |
-
|
| 187 |
-
SoniCoder can clone any public GitHub repo into the workspace, allowing the
|
| 188 |
-
agent to read, edit, extend, or refactor real-world code.
|
| 189 |
-
|
| 190 |
-
### How it works
|
| 191 |
-
|
| 192 |
-
1. User submits a GitHub URL via the Agent tab UI box (or via `/github <url>`
|
| 193 |
-
slash command in chat while Agent mode is ON).
|
| 194 |
-
2. The backend (`code/tools/github.py::import_github_repo`) parses the URL
|
| 195 |
-
(supports HTTPS, SSH, and `/tree/<branch>/<subdir>` forms) and validates
|
| 196 |
-
that the host is `github.com`.
|
| 197 |
-
3. The repo is shallow-cloned (`git clone --depth 1 --single-branch`) into a
|
| 198 |
-
temp directory.
|
| 199 |
-
4. Files are *copied* into the workspace (root or `target_subdir`) with these
|
| 200 |
-
directories stripped: `.git`, `.hg`, `.svn`, `node_modules`, `__pycache__`,
|
| 201 |
-
`.venv`, `venv`, `env`, `.tox`, `.mypy_cache`, `.pytest_cache`,
|
| 202 |
-
`.ruff_cache`, `dist`, `build`, `.next`, `.nuxt`, `.cache`, `.gradle`,
|
| 203 |
-
`target`, `Pods`. `.DS_Store` and `Thumbs.db` are also dropped.
|
| 204 |
-
5. The workspace tree refreshes; Agent mode auto-enables if it wasn't already.
|
| 205 |
-
|
| 206 |
-
### Security
|
| 207 |
-
|
| 208 |
-
- Only `github.com` URLs are accepted (HTTPS or SSH form).
|
| 209 |
-
- `target_subdir` is sanitized — no path escapes.
|
| 210 |
-
- The upstream repo is never modified (clone happens in temp dir, then
|
| 211 |
-
copied). The `.git` directory is stripped so the agent doesn't walk it.
|
| 212 |
-
- Default clone timeout: 120s (UI uses 180s). Max: 600s.
|
| 213 |
-
|
| 214 |
-
### Slash command
|
| 215 |
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
###
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
than silently clobbering) if someone else pushed commits in the meantime —
|
| 248 |
-
because the temp repo has no reflog of the remote tip, the lease fails and
|
| 249 |
-
the user is told to pull first.
|
| 250 |
-
|
| 251 |
-
### UI
|
| 252 |
-
|
| 253 |
-
Located in the **Deploy** tab, in a "Push Update to GitHub" section below the
|
| 254 |
-
HuggingFace section. Only 3 fields are required: repo name, token, username.
|
| 255 |
-
An "Advanced" `<details>` exposes optional `branch` and `commit_message`
|
| 256 |
-
fields.
|
| 257 |
-
|
| 258 |
-
### Security
|
| 259 |
-
|
| 260 |
-
- Token is sent over HTTPS to the SoniCoder backend, used once for the push,
|
| 261 |
-
then dropped (not stored, not logged).
|
| 262 |
-
- Error messages are scrubbed to remove the token before being returned.
|
| 263 |
-
- The temp repo is deleted at the end of the call (context manager).
|
| 264 |
-
- The local SoniCoder workspace is never turned into a git repo; the
|
| 265 |
-
workspace's `.git` (if any, e.g. after an import — though imports strip
|
| 266 |
-
`.git`) is never read.
|
| 267 |
-
|
| 268 |
-
## Skills
|
| 269 |
-
|
| 270 |
-
| Skill | Description |
|
| 271 |
-
|-------|-------------|
|
| 272 |
-
| `frontend-design` | Distinctive visual design guidance |
|
| 273 |
-
| `feature-dev` | Guided feature implementation workflow |
|
| 274 |
-
| `code-review` | High-signal code review |
|
| 275 |
-
| `debugging` | Systematic debugging workflow |
|
| 276 |
-
| `fullstack-scaffold` | Project structure scaffolding rules |
|
| 277 |
-
| `commit-workflow` | Git commit best practices |
|
| 278 |
|
| 279 |
## Hooks
|
| 280 |
|
|
@@ -302,3 +253,56 @@ Supported SDKs:
|
|
| 302 |
- `gradio` — Python Gradio apps
|
| 303 |
- `streamlit` — Python Streamlit apps
|
| 304 |
- `docker` — JS/TS frameworks (auto-generates Dockerfile + package.json)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SoniCoder v2.1 — Project Memory
|
| 2 |
|
| 3 |
> This file is the project's persistent memory. The agent reads it on every session.
|
| 4 |
> Edit it freely — it overrides defaults.
|
| 5 |
|
| 6 |
## What is SoniCoder?
|
| 7 |
|
| 8 |
+
SoniCoder v2.1 is a **local-first AI coding agent** that can:
|
| 9 |
+
|
| 10 |
+
### Core Capabilities
|
| 11 |
- Generate complete fullstack applications in any language/framework
|
| 12 |
- Read, write, and edit files in a sandboxed workspace
|
| 13 |
+
- Run shell commands (git, npm, pip, tests) with enhanced safety
|
| 14 |
- Apply specialized skills (frontend-design, feature-dev, code-review, debugging, fullstack-scaffold, commit-workflow)
|
| 15 |
- Respond to slash commands (/commit, /review, /feature, /design, /explain, /test, /refactor, /skill, /help)
|
| 16 |
- Deploy to HuggingFace Spaces with one click
|
| 17 |
+
- Manage and export conversation sessions
|
| 18 |
+
- Use code templates for rapid development
|
| 19 |
+
|
| 20 |
+
### v2.1 Enhancements
|
| 21 |
+
- **Session Management**: Persistent conversations with JSON/Markdown/HTML export
|
| 22 |
+
- **Code Templates**: Pre-built templates for Flask, React, Gradio, Express
|
| 23 |
+
- **Performance**: Response caching, model optimization, progress tracking
|
| 24 |
+
- **Security**: Enhanced command blocking, input validation, audit logging
|
| 25 |
+
- **Metrics**: Inference statistics, token counting, performance monitoring
|
| 26 |
+
- **New Models**: DeepSeek-Coder-1.3B support for advanced reasoning
|
| 27 |
|
| 28 |
## Architecture
|
| 29 |
|
| 30 |
```
|
| 31 |
+
app.py ← Entry point: launches Gradio Server
|
| 32 |
code/
|
| 33 |
+
├── config/constants.py ← App config, system prompt, templates (ENHANCED)
|
| 34 |
├── model/
|
| 35 |
+
│ ├── loader.py ← Model loading with progress & memory optimization
|
| 36 |
+
│ └── inference.py ← Streaming inference with caching & metrics
|
| 37 |
+
├── agent/__init__.py ← Agent loop (model ↔ tools, supports custom agents)
|
| 38 |
├── tools/
|
| 39 |
+
│ ├── fs.py ← read_file, write_file, edit_file, glob, grep, list_dir
|
| 40 |
+
│ ├── bash.py ← Sandboxed shell execution (ENHANCED - audit trail)
|
| 41 |
+
│ ├── todos.py ← Todo list management
|
| 42 |
+
│ └── github.py ← GitHub repo import (shallow clone + strip heavy dirs)
|
| 43 |
├── skills/
|
| 44 |
+
│ ├── __init__.py ← Skill discovery + loading
|
| 45 |
+
│ └── builtins/ ← Built-in skills (markdown)
|
| 46 |
+
├── agents/ ← Custom Agent system (AI-generated personas)
|
| 47 |
+
│ ├── __init__.py ← Agent CRUD + system-prompt builder
|
| 48 |
+
│ └── builtins/ ← Built-in agents (code-reviewer, test-writer)
|
| 49 |
├── commands/
|
| 50 |
+
│ ├── __init__.py ← Slash command parser + expander
|
| 51 |
+
│ └── builtins/ ← Built-in commands (markdown)
|
| 52 |
├── hooks/
|
| 53 |
+
│ ├── __init__.py ← Hook rule engine
|
| 54 |
+
│ └── builtins/ ← Built-in hook rules (markdown)
|
| 55 |
├── execution/
|
| 56 |
+
│ ├── code_extractor.py ← Code extraction from model output
|
| 57 |
+
│ ├── python_runner.py ← Sandboxed Python execution
|
| 58 |
+
│ └── gradio_runner.py ← Gradio app subprocess runner
|
| 59 |
├���─ huggingface/
|
| 60 |
+
│ ├── dockerfile_gen.py ← Auto Dockerfile/package.json for JS
|
| 61 |
+
│ └── push.py ← HF Hub push + ZIP packaging
|
| 62 |
+
├── websearch/google_scraper.py ← DuckDuckGo + Google scraping (no API)
|
| 63 |
+
├── server/
|
| 64 |
+
├── routes.py ← All HTTP + API endpoints
|
| 65 |
+
├── chat_helpers.py ← Chat history + prompt building (ENHANCED)
|
| 66 |
+
├── session_manager.py ← Session persistence & export (NEW)
|
| 67 |
+
└── __init__.py ← Server utilities & helpers
|
| 68 |
+
index.html ← Frontend (single-file SPA)
|
| 69 |
+
workspace/ ← Sandboxed agent workspace (auto-created)
|
| 70 |
+
downloads/ ← Exported files directory (auto-created)
|
| 71 |
```
|
| 72 |
|
| 73 |
## Conventions
|
|
|
|
| 80 |
- **Tests**: pytest, in `tests/` directory, `test_*.py` naming
|
| 81 |
- **Frontend**: single-file HTML with inline CSS/JS, no build step
|
| 82 |
|
| 83 |
+
## Server Rules
|
| 84 |
|
| 85 |
- All servers bind to `0.0.0.0` (never `localhost`)
|
| 86 |
- Default port: `7860` (HF Spaces convention)
|
| 87 |
- Sub-servers use `7861`, `7862`, etc.
|
| 88 |
+
- Graceful shutdown on SIGTERM/SIGINT
|
| 89 |
|
| 90 |
+
## Model Configuration
|
| 91 |
|
| 92 |
+
| Model | Type | Size | Best For |
|
| 93 |
+
|-------|------|------|----------|
|
| 94 |
+
| Qwen2.5-Coder-1.5B | Text | 3.0 GB | Tool-calling, code generation |
|
| 95 |
+
| MiniCPM5-1B | Text | 2.17 GB | Fast inference |
|
| 96 |
+
| MiniCPM-V-4.6 | VLM | 2.8 GB | Image understanding |
|
| 97 |
+
| DeepSeek-Coder-1.3B | Text | 2.6 GB | Advanced reasoning |
|
| 98 |
+
|
| 99 |
+
- Default: `Qwen/Qwen2.5-Coder-1.5B-Instruct`
|
| 100 |
- Loaded in background thread on startup
|
| 101 |
- Streaming inference via `TextIteratorStreamer`
|
| 102 |
+
- One model loaded at a time (memory conservation)
|
| 103 |
|
| 104 |
+
## Tool Call Format
|
| 105 |
|
| 106 |
The model calls tools by emitting fenced code blocks with `tool` as the language:
|
| 107 |
|
|
|
|
| 121 |
pass
|
| 122 |
```
|
| 123 |
|
| 124 |
+
## Slash Commands
|
| 125 |
|
| 126 |
| Command | Description |
|
| 127 |
|---------|-------------|
|
|
|
|
| 136 |
| `/agent create <desc>` | AI generates a custom agent from natural-language description |
|
| 137 |
| `/agent use <name>` | Activate a saved agent |
|
| 138 |
| `/agent list` | List all saved agents |
|
|
|
|
|
|
|
|
|
|
| 139 |
| `/github <url> [subdir] [--branch <name>] [--into <path>]` | Import a GitHub repo into the workspace |
|
| 140 |
| `/help` | Show available commands and skills |
|
| 141 |
|
| 142 |
+
## Code Templates
|
| 143 |
|
| 144 |
+
Templates provide quick-start code for common patterns:
|
|
|
|
|
|
|
| 145 |
|
| 146 |
+
| Template ID | Name | Language | Framework |
|
| 147 |
+
|-------------|------|----------|-----------|
|
| 148 |
+
| `python-flask-api` | Flask REST API | Python | Flask |
|
| 149 |
+
| `react-todo-app` | React Todo App | JavaScript | React |
|
| 150 |
+
| `gradio-image-app` | Gradio Image Processor | Python | Gradio |
|
| 151 |
+
| `express-js-server` | Express.js Server | JavaScript | Express.js |
|
| 152 |
|
| 153 |
+
Access via API: `GET /api/templates` or `GET /api/templates/{id}`
|
|
|
|
| 154 |
|
| 155 |
+
## Session Management
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
|
| 157 |
+
Sessions are automatically created and persisted:
|
| 158 |
|
| 159 |
+
- **Storage**: `CONFIG_DIR/sessions/*.json`
|
| 160 |
+
- **Auto-save**: On each message
|
| 161 |
+
- **Export formats**: JSON, Markdown, HTML
|
| 162 |
+
- **Cleanup**: Sessions older than 30 days are auto-removed
|
| 163 |
|
| 164 |
+
### Session API Endpoints
|
| 165 |
|
| 166 |
+
| Endpoint | Description |
|
| 167 |
+
|----------|-------------|
|
| 168 |
+
| `POST /api/sessions/create` | Create new session |
|
| 169 |
+
| `GET /api/sessions/{id}` | Get session details |
|
| 170 |
+
| `PUT /api/sessions/{id}` | Update session metadata |
|
| 171 |
+
| `DELETE /api/sessions/{id}` | Delete session |
|
| 172 |
+
| `GET /api/sessions` | List sessions (with search) |
|
| 173 |
+
| `POST /api/sessions/{id}/export` | Export session |
|
| 174 |
|
| 175 |
+
## Performance Features
|
| 176 |
|
| 177 |
+
### Response Caching
|
|
|
|
|
|
|
|
|
|
| 178 |
|
| 179 |
+
- Similar prompts are cached for faster responses
|
| 180 |
+
- Configurable TTL (default: 5 minutes)
|
| 181 |
+
- Automatic cache invalidation
|
| 182 |
+
- Cache stats available via `/api/cache/stats`
|
| 183 |
|
| 184 |
+
### Model Optimization
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
|
| 186 |
+
- Automatic precision selection (bfloat16 > float16 > float32)
|
| 187 |
+
- Better Transformer structure when available
|
| 188 |
+
- Torch compile support (PyTorch 2.0+)
|
| 189 |
+
- Memory-efficient loading with offload support
|
| 190 |
+
|
| 191 |
+
### Metrics Tracking
|
| 192 |
+
|
| 193 |
+
Each inference call tracks:
|
| 194 |
+
- Time to first token (TTFT)
|
| 195 |
+
- Tokens per second
|
| 196 |
+
- Total generation time
|
| 197 |
+
- Cache hit/miss status
|
| 198 |
+
- Error information
|
| 199 |
+
|
| 200 |
+
## Security Enhancements
|
| 201 |
|
| 202 |
+
### Command Safety (Enhanced)
|
| 203 |
+
|
| 204 |
+
Additional blocked patterns:
|
| 205 |
+
- Regex-based pattern matching
|
| 206 |
+
- Fork bomb detection (`:(){:|:&};:`)
|
| 207 |
+
- Pipe-to-shell prevention (`curl ... | bash`)
|
| 208 |
+
- Root filesystem protection
|
| 209 |
+
- Character validation (null bytes, etc.)
|
| 210 |
+
|
| 211 |
+
### Input Validation
|
| 212 |
+
|
| 213 |
+
- Maximum prompt length: 10,000 characters
|
| 214 |
+
- Maximum file size: 10 MB
|
| 215 |
+
- Rate limiting: 30 requests/minute
|
| 216 |
+
- Environment variable scrubbing (secrets removal)
|
| 217 |
+
|
| 218 |
+
### Audit Trail
|
| 219 |
+
|
| 220 |
+
All bash commands are logged with:
|
| 221 |
+
- Timestamp
|
| 222 |
+
- Full command (truncated to 500 chars)
|
| 223 |
+
- Success/failure status
|
| 224 |
+
- Return code
|
| 225 |
+
- Timeout flag
|
| 226 |
+
- Execution duration
|
| 227 |
+
|
| 228 |
+
Access via: `GET /api/audit/log` or `code.tools.bash.get_command_history()`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
|
| 230 |
## Hooks
|
| 231 |
|
|
|
|
| 253 |
- `gradio` — Python Gradio apps
|
| 254 |
- `streamlit` — Python Streamlit apps
|
| 255 |
- `docker` — JS/TS frameworks (auto-generates Dockerfile + package.json)
|
| 256 |
+
|
| 257 |
+
## Testing
|
| 258 |
+
|
| 259 |
+
Run the test suite:
|
| 260 |
+
|
| 261 |
+
```bash
|
| 262 |
+
# Install test dependencies
|
| 263 |
+
pip install pytest pytest-cov
|
| 264 |
+
|
| 265 |
+
# Run tests
|
| 266 |
+
pytest tests/ -v --cov=code --cov-report=html
|
| 267 |
+
|
| 268 |
+
# Run specific test file
|
| 269 |
+
pytest tests/test_bash_safety.py -v
|
| 270 |
+
```
|
| 271 |
+
|
| 272 |
+
## Troubleshooting
|
| 273 |
+
|
| 274 |
+
### Common Issues
|
| 275 |
+
|
| 276 |
+
**Model fails to load (OOM)**
|
| 277 |
+
- Try a smaller model (MiniCPM5-1B)
|
| 278 |
+
- Close other applications using GPU memory
|
| 279 |
+
- Check available RAM (need ~4GB for text models)
|
| 280 |
+
|
| 281 |
+
**Slow response times**
|
| 282 |
+
- Enable caching (default: on)
|
| 283 |
+
- Use smaller max_tokens for testing
|
| 284 |
+
- Consider CPU-only mode if GPU transfer is slow
|
| 285 |
+
|
| 286 |
+
**Commands being blocked**
|
| 287 |
+
- Review blocked patterns in `code/tools/bash.py`
|
| 288 |
+
- Check hook rules in `code/hooks/`
|
| 289 |
+
- Use `/help` for allowed operations
|
| 290 |
+
|
| 291 |
+
## Version History
|
| 292 |
+
|
| 293 |
+
### v2.1.0 (Current)
|
| 294 |
+
- Added DeepSeek-Coder-1.3B model support
|
| 295 |
+
- Implemented session management with persistence
|
| 296 |
+
- Added code template system
|
| 297 |
+
- Enhanced security with regex-based command blocking
|
| 298 |
+
- Added response caching and performance metrics
|
| 299 |
+
- Improved error handling and recovery
|
| 300 |
+
- Added export functionality (JSON/Markdown/HTML)
|
| 301 |
+
|
| 302 |
+
### v2.0.0
|
| 303 |
+
- Initial release with Gradio 6.x architecture
|
| 304 |
+
- Multi-model support (Qwen2.5-Coder, MiniCPM5, MiniCPM-V)
|
| 305 |
+
- Agent loop with tool calling
|
| 306 |
+
- Skills and commands system
|
| 307 |
+
- GitHub integration
|
| 308 |
+
- HuggingFace deployment
|
|
@@ -1,5 +1,5 @@
|
|
| 1 |
---
|
| 2 |
-
title: SoniCoder v2
|
| 3 |
emoji: 🚀
|
| 4 |
colorFrom: purple
|
| 5 |
colorTo: blue
|
|
@@ -10,8 +10,263 @@ pinned: false
|
|
| 10 |
license: apache-2.0
|
| 11 |
---
|
| 12 |
|
| 13 |
-
# SoniCoder v2 — AI Code Agent
|
| 14 |
|
| 15 |
-
Optimized AI code writer agent with architecture inspired by Gemini CLI and Claude Code.
|
| 16 |
|
| 17 |
-
**No external APIs needed** — runs entirely on local inference with smart 1B models.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: SoniCoder v2.1
|
| 3 |
emoji: 🚀
|
| 4 |
colorFrom: purple
|
| 5 |
colorTo: blue
|
|
|
|
| 10 |
license: apache-2.0
|
| 11 |
---
|
| 12 |
|
| 13 |
+
# SoniCoder v2.1 — Enhanced AI Code Agent
|
| 14 |
|
| 15 |
+
> **Optimized AI code writer agent with architecture inspired by Gemini CLI and Claude Code.**
|
| 16 |
|
| 17 |
+
**No external APIs needed** — runs entirely on local inference with smart 1B models.
|
| 18 |
+
|
| 19 |
+
## ✨ What's New in v2.1
|
| 20 |
+
|
| 21 |
+
### 🎯 Major Enhancements
|
| 22 |
+
|
| 23 |
+
- **🤖 New Model Support**: Added DeepSeek-Coder-1.3B for advanced code reasoning
|
| 24 |
+
- **💾 Session Management**: Persistent conversations with export to JSON/Markdown/HTML
|
| 25 |
+
- **📝 Code Templates**: Quick-start templates for common project types (Flask, React, Express, Gradio)
|
| 26 |
+
- **⚡ Performance Optimizations**: Response caching, model optimization, better memory management
|
| 27 |
+
- **🔒 Security Hardening**: Enhanced input validation, command blocking patterns, environment scrubbing
|
| 28 |
+
- **📊 Metrics & Monitoring**: Inference metrics tracking, token counting, performance statistics
|
| 29 |
+
- **🎨 Improved UI**: Better error messages, loading states, progress indicators
|
| 30 |
+
|
| 31 |
+
### 🔧 Technical Improvements
|
| 32 |
+
|
| 33 |
+
- **Model Loading Progress**: Real-time progress callbacks during model initialization
|
| 34 |
+
- **Response Caching**: Cache similar prompts for faster responses (configurable TTL)
|
| 35 |
+
- **Better Error Recovery**: Graceful fallbacks when streaming fails
|
| 36 |
+
- **Command Audit Trail**: Track executed commands with timestamps and results
|
| 37 |
+
- **Conversation Summarization**: Auto-summarize long contexts to stay within token limits
|
| 38 |
+
- **Token Usage Estimation**: Estimate token counts for cost/performance planning
|
| 39 |
+
|
| 40 |
+
## 🚀 Quick Start
|
| 41 |
+
|
| 42 |
+
### Installation
|
| 43 |
+
|
| 44 |
+
```bash
|
| 45 |
+
# Clone the repository
|
| 46 |
+
git clone https://huggingface.co/spaces/sonic-coder/sonicoder
|
| 47 |
+
cd sonicoder
|
| 48 |
+
|
| 49 |
+
# Install dependencies
|
| 50 |
+
pip install -r requirements.txt
|
| 51 |
+
|
| 52 |
+
# Run the application
|
| 53 |
+
python app.py
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
### Docker (Alternative)
|
| 57 |
+
|
| 58 |
+
```dockerfile
|
| 59 |
+
FROM python:3.11-slim
|
| 60 |
+
|
| 61 |
+
WORKSPACE /app
|
| 62 |
+
COPY . .
|
| 63 |
+
|
| 64 |
+
RUN pip install -r requirements.txt
|
| 65 |
+
|
| 66 |
+
EXPOSE 7860
|
| 67 |
+
|
| 68 |
+
CMD ["python", "app.py"]
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
## 🖥️ Features
|
| 72 |
+
|
| 73 |
+
### Core Capabilities
|
| 74 |
+
|
| 75 |
+
| Feature | Description |
|
| 76 |
+
|---------|-------------|
|
| 77 |
+
| **Code Generation** | Generate complete applications in any language/framework |
|
| 78 |
+
| **File Operations** | Read, write, edit files in a sandboxed workspace |
|
| 79 |
+
| **Shell Execution** | Run commands (git, npm, pip, tests) with safety controls |
|
| 80 |
+
| **Multi-model Support** | Switch between Qwen2.5-Coder, MiniCPM5, MiniCPM-V, DeepSeek-Coder |
|
| 81 |
+
| **Vision Support** | Upload images for VLM-powered code generation |
|
| 82 |
+
| **Slash Commands** | `/commit`, `/review`, `/feature`, `/design`, etc. |
|
| 83 |
+
| **Custom Agents** | Create AI-generated personas with specialized behaviors |
|
| 84 |
+
|
| 85 |
+
### Built-in Skills
|
| 86 |
+
|
| 87 |
+
| Skill | Description |
|
| 88 |
+
|-------|-------------|
|
| 89 |
+
| `frontend-design` | Distinctive visual design guidance |
|
| 90 |
+
| `feature-dev` | Guided feature implementation workflow |
|
| 91 |
+
| `code-review` | High-signal code review |
|
| 92 |
+
| `debugging` | Systematic debugging workflow |
|
| 93 |
+
| `fullstack-scaffold` | Project structure scaffolding rules |
|
| 94 |
+
| `commit-workflow` | Git commit best practices |
|
| 95 |
+
|
| 96 |
+
### Code Templates
|
| 97 |
+
|
| 98 |
+
Quick-start templates available:
|
| 99 |
+
|
| 100 |
+
| Template | Language | Framework |
|
| 101 |
+
|----------|----------|-----------|
|
| 102 |
+
| `python-flask-api` | Python | Flask |
|
| 103 |
+
| `react-todo-app` | JavaScript | React |
|
| 104 |
+
| `gradio-image-app` | Python | Gradio |
|
| 105 |
+
| `express-js-server` | JavaScript | Express.js |
|
| 106 |
+
|
| 107 |
+
## 📁 Project Structure
|
| 108 |
+
|
| 109 |
+
```
|
| 110 |
+
sonicoder/
|
| 111 |
+
├── app.py # Entry point: launches Gradio Server
|
| 112 |
+
├── requirements.txt # Python dependencies
|
| 113 |
+
├── README.md # This file
|
| 114 |
+
├── CLAUDE.md # Project memory & conventions
|
| 115 |
+
├── index.html # Frontend (single-file SPA)
|
| 116 |
+
│
|
| 117 |
+
└── code/
|
| 118 |
+
├── config/
|
| 119 |
+
│ └── constants.py # App config, system prompt, templates
|
| 120 |
+
├── model/
|
| 121 |
+
│ ├── loader.py # Model loading with progress tracking
|
| 122 |
+
│ └── inference.py # Streaming inference with caching
|
| 123 |
+
├── agent/
|
| 124 |
+
│ └── __init__.py # Agent loop (model ↔ tools)
|
| 125 |
+
├── tools/
|
| 126 |
+
│ ├── fs.py # File operations
|
| 127 |
+
│ ├── bash.py # Sandboxed shell execution (enhanced)
|
| 128 |
+
│ ├── todos.py # Todo list management
|
| 129 |
+
│ └── github.py # GitHub integration
|
| 130 |
+
├── skills/ # Built-in skills (markdown)
|
| 131 |
+
├── agents/ # Custom agent system
|
| 132 |
+
├── commands/ # Slash command definitions
|
| 133 |
+
├── hooks/ # Safety hook rules
|
| 134 |
+
├── execution/ # Code execution sandbox
|
| 135 |
+
├── server/
|
| 136 |
+
│ ├── routes.py # All HTTP + API endpoints
|
| 137 |
+
│ ├── chat_helpers.py # Prompt building (enhanced)
|
| 138 |
+
│ ├── session_manager.py # Session persistence & export (NEW)
|
| 139 |
+
│ └── __init__.py # Server utilities
|
| 140 |
+
├── huggingface/ # HF Spaces deployment
|
| 141 |
+
├── websearch/ # Web search scraping
|
| 142 |
+
└── config.py # Workspace & config paths
|
| 143 |
+
```
|
| 144 |
+
|
| 145 |
+
## 🎮 Usage Examples
|
| 146 |
+
|
| 147 |
+
### Basic Code Generation
|
| 148 |
+
|
| 149 |
+
```
|
| 150 |
+
User: Create a Python Flask API with CRUD operations for a todo list
|
| 151 |
+
SoniCoder: [Generates complete Flask application with proper structure]
|
| 152 |
+
```
|
| 153 |
+
|
| 154 |
+
### Using Templates
|
| 155 |
+
|
| 156 |
+
```
|
| 157 |
+
User: Use the react-todo-app template as a starting point
|
| 158 |
+
SoniCoder: [Loads template and customizes based on requirements]
|
| 159 |
+
```
|
| 160 |
+
|
| 161 |
+
### Iterative Development
|
| 162 |
+
|
| 163 |
+
```
|
| 164 |
+
User: Build me a REST API
|
| 165 |
+
SoniCoder: [Creates initial API]
|
| 166 |
+
|
| 167 |
+
User: Add authentication to it
|
| 168 |
+
SoniCoder: [Reads existing file, adds auth middleware]
|
| 169 |
+
|
| 170 |
+
User: Write tests for the auth endpoint
|
| 171 |
+
SoniCoder: [Creates test file with pytest]
|
| 172 |
+
```
|
| 173 |
+
|
| 174 |
+
## ⚙️ Configuration
|
| 175 |
+
|
| 176 |
+
### Environment Variables
|
| 177 |
+
|
| 178 |
+
| Variable | Description | Default |
|
| 179 |
+
|----------|-------------|---------|
|
| 180 |
+
| `SONICODER_WORKSPACE` | Workspace root directory | `./workspace` |
|
| 181 |
+
| `MODEL_CACHE_DIR` | Model cache location | Default HF cache |
|
| 182 |
+
|
| 183 |
+
### Model Selection
|
| 184 |
+
|
| 185 |
+
Models can be switched via the UI or API:
|
| 186 |
+
|
| 187 |
+
```python
|
| 188 |
+
from code.model.loader import switch_model
|
| 189 |
+
|
| 190 |
+
# Switch to DeepSeek Coder
|
| 191 |
+
result = switch_model("deepseek-coder-1.3b")
|
| 192 |
+
print(result) # {"success": True, "message": "Switching to DeepSeek-Coder-1.3B..."}
|
| 193 |
+
```
|
| 194 |
+
|
| 195 |
+
### Performance Tuning
|
| 196 |
+
|
| 197 |
+
In `code/config/constants.py`:
|
| 198 |
+
|
| 199 |
+
```python
|
| 200 |
+
# Enable response caching
|
| 201 |
+
CACHE_ENABLED = True
|
| 202 |
+
CACHE_TTL_SECONDS = 300 # 5 minutes
|
| 203 |
+
|
| 204 |
+
# Concurrency limits
|
| 205 |
+
MAX_CONCURRENT_REQUESTS = 5
|
| 206 |
+
```
|
| 207 |
+
|
| 208 |
+
## 🔒 Security
|
| 209 |
+
|
| 210 |
+
### Command Safety
|
| 211 |
+
|
| 212 |
+
Blocked patterns include:
|
| 213 |
+
- Destructive commands (`rm -rf /`, `mkfs`, `dd`)
|
| 214 |
+
- System control (`shutdown`, `reboot`)
|
| 215 |
+
- Fork bombs (`:(){:|:&};:`)
|
| 216 |
+
- Pipe-to-shell attacks (`curl ... | bash`)
|
| 217 |
+
- Root filesystem modifications
|
| 218 |
+
|
| 219 |
+
### Input Validation
|
| 220 |
+
|
| 221 |
+
- Maximum prompt length: 10,000 characters
|
| 222 |
+
- Maximum file size: 10 MB
|
| 223 |
+
- Rate limiting: 30 requests/minute
|
| 224 |
+
- Regex-based pattern blocking
|
| 225 |
+
|
| 226 |
+
## 📊 Export & Sessions
|
| 227 |
+
|
| 228 |
+
### Export Formats
|
| 229 |
+
|
| 230 |
+
Sessions can be exported in multiple formats:
|
| 231 |
+
|
| 232 |
+
```python
|
| 233 |
+
from code.server.session_manager import export_session, get_session_manager
|
| 234 |
+
|
| 235 |
+
manager = get_session_manager()
|
| 236 |
+
session = manager.get_session("session_id")
|
| 237 |
+
|
| 238 |
+
# Export as JSON
|
| 239 |
+
export_session(session, format="json", output_dir="./exports")
|
| 240 |
+
|
| 241 |
+
# Export as Markdown
|
| 242 |
+
export_session(session, format="markdown", output_dir="./exports")
|
| 243 |
+
|
| 244 |
+
# Export as styled HTML
|
| 245 |
+
export_session(session, format="html", output_dir="./exports")
|
| 246 |
+
```
|
| 247 |
+
|
| 248 |
+
### Session Persistence
|
| 249 |
+
|
| 250 |
+
Sessions are automatically saved to `CONFIG_DIR/sessions/` and restored on restart.
|
| 251 |
+
|
| 252 |
+
## 🤝 Contributing
|
| 253 |
+
|
| 254 |
+
1. Fork the repository
|
| 255 |
+
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
| 256 |
+
3. Commit your changes (`git commit -m 'Add amazing feature'`)
|
| 257 |
+
4. Push to the branch (`git push origin feature/amazing-feature`)
|
| 258 |
+
5. Open a Pull Request
|
| 259 |
+
|
| 260 |
+
## 📄 License
|
| 261 |
+
|
| 262 |
+
This project is licensed under the Apache License 2.0 - see the LICENSE file for details.
|
| 263 |
+
|
| 264 |
+
## 🙏 Acknowledgments
|
| 265 |
+
|
| 266 |
+
- Inspired by [Gemini CLI](https://github.com/google-gemini/gemini-cli) and [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
|
| 267 |
+
- Powered by Hugging Face Transformers
|
| 268 |
+
- Models: Qwen2.5-Coder, MiniCPM5, MiniCPM-V, DeepSeek-Coder
|
| 269 |
+
|
| 270 |
+
---
|
| 271 |
+
|
| 272 |
+
**Built with ❤️ by the SoniCoder team**
|
|
@@ -1,17 +1,27 @@
|
|
| 1 |
-
"""SoniCoder v2 — Entry
|
| 2 |
|
| 3 |
-
|
| 4 |
with Gradio's queuing, concurrency, and API infrastructure.
|
| 5 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
Blog: https://huggingface.co/blog/introducing-gradio-server
|
| 7 |
"""
|
| 8 |
|
| 9 |
from __future__ import annotations
|
| 10 |
|
| 11 |
import logging
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
# Ensure workspace + config dirs exist
|
| 14 |
-
from code.config import WORKSPACE_ROOT, CONFIG_DIR
|
| 15 |
|
| 16 |
WORKSPACE_ROOT.mkdir(parents=True, exist_ok=True)
|
| 17 |
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
@@ -24,13 +34,78 @@ logging.basicConfig(
|
|
| 24 |
logger = logging.getLogger(__name__)
|
| 25 |
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
def main():
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
-
#
|
| 31 |
-
|
| 32 |
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
logger.info("Background model loading started")
|
| 35 |
|
| 36 |
# Set up MCP servers from config (best-effort)
|
|
@@ -56,8 +131,20 @@ def main():
|
|
| 56 |
|
| 57 |
app = create_app()
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
logger.info("Launching...")
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
|
| 63 |
if __name__ == "__main__":
|
|
|
|
| 1 |
+
"""SoniCoder v2.1 — Enhanced Entry Point.
|
| 2 |
|
| 3 |
+
Gradio Server (Gradio 6.x) serves index.html from project root
|
| 4 |
with Gradio's queuing, concurrency, and API infrastructure.
|
| 5 |
|
| 6 |
+
Enhanced version with:
|
| 7 |
+
- Better startup logging
|
| 8 |
+
- Graceful shutdown handling
|
| 9 |
+
- Health check endpoint
|
| 10 |
+
- Session management initialization
|
| 11 |
+
- Model optimization on startup
|
| 12 |
+
|
| 13 |
Blog: https://huggingface.co/blog/introducing-gradio-server
|
| 14 |
"""
|
| 15 |
|
| 16 |
from __future__ import annotations
|
| 17 |
|
| 18 |
import logging
|
| 19 |
+
import signal
|
| 20 |
+
import sys
|
| 21 |
+
import time
|
| 22 |
|
| 23 |
# Ensure workspace + config dirs exist
|
| 24 |
+
from code.config import WORKSPACE_ROOT, CONFIG_DIR, APP_VERSION
|
| 25 |
|
| 26 |
WORKSPACE_ROOT.mkdir(parents=True, exist_ok=True)
|
| 27 |
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
| 34 |
logger = logging.getLogger(__name__)
|
| 35 |
|
| 36 |
|
| 37 |
+
def setup_signal_handlers(app):
|
| 38 |
+
"""Setup graceful shutdown handlers."""
|
| 39 |
+
def shutdown_handler(signum, frame):
|
| 40 |
+
logger.info("Received shutdown signal, cleaning up...")
|
| 41 |
+
try:
|
| 42 |
+
# Save sessions
|
| 43 |
+
from code.server.session_manager import get_session_manager
|
| 44 |
+
manager = get_session_manager()
|
| 45 |
+
saved = manager.save_all()
|
| 46 |
+
if saved > 0:
|
| 47 |
+
logger.info("Saved %d sessions before shutdown", saved)
|
| 48 |
+
except Exception as e:
|
| 49 |
+
logger.warning("Failed to save sessions: %s", e)
|
| 50 |
+
|
| 51 |
+
try:
|
| 52 |
+
# Clear model memory
|
| 53 |
+
from code.model.loader import _unload_model
|
| 54 |
+
_unload_model()
|
| 55 |
+
logger.info("Model unloaded")
|
| 56 |
+
except Exception as e:
|
| 57 |
+
logger.warning("Failed to unload model: %s", e)
|
| 58 |
+
|
| 59 |
+
logger.info("Shutdown complete")
|
| 60 |
+
sys.exit(0)
|
| 61 |
+
|
| 62 |
+
signal.signal(signal.SIGTERM, shutdown_handler)
|
| 63 |
+
signal.signal(signal.SIGINT, shutdown_handler)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def initialize_session_manager():
|
| 67 |
+
"""Initialize session manager with persistence."""
|
| 68 |
+
try:
|
| 69 |
+
from code.server.session_manager import get_session_manager
|
| 70 |
+
|
| 71 |
+
storage_dir = CONFIG_DIR / "sessions"
|
| 72 |
+
manager = get_session_manager(storage_dir=str(storage_dir))
|
| 73 |
+
|
| 74 |
+
# Cleanup old sessions (older than 30 days)
|
| 75 |
+
removed = manager.cleanup_old_sessions(max_age_days=30)
|
| 76 |
+
if removed > 0:
|
| 77 |
+
logger.info("Cleaned up %d old sessions", removed)
|
| 78 |
+
|
| 79 |
+
logger.info("Session manager initialized with storage at %s", storage_dir)
|
| 80 |
+
return manager
|
| 81 |
+
except Exception as exc:
|
| 82 |
+
logger.warning("Session manager initialization skipped: %s", exc)
|
| 83 |
+
return None
|
| 84 |
+
|
| 85 |
+
|
| 86 |
def main():
|
| 87 |
+
start_time = time.time()
|
| 88 |
+
logger.info("=" * 50)
|
| 89 |
+
logger.info("SoniCoder v%s starting...", APP_VERSION)
|
| 90 |
+
logger.info("=" * 50)
|
| 91 |
|
| 92 |
+
# Initialize session manager
|
| 93 |
+
session_mgr = initialize_session_manager()
|
| 94 |
|
| 95 |
+
# Start loading default model in background
|
| 96 |
+
from code.model.loader import start_background_load, add_progress_callback
|
| 97 |
+
|
| 98 |
+
def on_progress(progress):
|
| 99 |
+
if progress.progress_percent % 25 < 5: # Log at ~25%, 50%, 75%, 100%
|
| 100 |
+
logger.info(
|
| 101 |
+
"Model loading: %.0f%% - %s",
|
| 102 |
+
progress.progress_percent,
|
| 103 |
+
progress.message,
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
add_progress_callback(on_progress)
|
| 107 |
+
|
| 108 |
+
load_thread = start_background_load()
|
| 109 |
logger.info("Background model loading started")
|
| 110 |
|
| 111 |
# Set up MCP servers from config (best-effort)
|
|
|
|
| 131 |
|
| 132 |
app = create_app()
|
| 133 |
|
| 134 |
+
# Setup signal handlers for graceful shutdown
|
| 135 |
+
setup_signal_handlers(app)
|
| 136 |
+
|
| 137 |
+
# Log startup time
|
| 138 |
+
startup_time = time.time() - start_time
|
| 139 |
+
logger.info("Startup completed in %.2f seconds", startup_time)
|
| 140 |
logger.info("Launching...")
|
| 141 |
+
|
| 142 |
+
# Launch the server
|
| 143 |
+
app.launch(
|
| 144 |
+
show_error=True,
|
| 145 |
+
server_name="0.0.0.0",
|
| 146 |
+
server_port=7860,
|
| 147 |
+
)
|
| 148 |
|
| 149 |
|
| 150 |
if __name__ == "__main__":
|
|
@@ -1,4 +1,12 @@
|
|
| 1 |
-
"""Application-wide constants, regex patterns, language options, and system prompt.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
@@ -7,6 +15,7 @@ import re
|
|
| 7 |
# ─── App Identity ────────────────────────────────────────────────────────
|
| 8 |
|
| 9 |
APP_TITLE = "SoniCoder"
|
|
|
|
| 10 |
MODEL_URL = "https://huggingface.co/Qwen/Qwen2.5-Coder-1.5B-Instruct"
|
| 11 |
|
| 12 |
# ─── Model Configs ───────────────────────────────────────────────────────
|
|
@@ -20,6 +29,7 @@ MODEL_CONFIGS = {
|
|
| 20 |
"auto_class": "AutoModelForCausalLM",
|
| 21 |
"tokenizer_class": "AutoTokenizer",
|
| 22 |
"size_gb": 3.0,
|
|
|
|
| 23 |
},
|
| 24 |
"minicpm5-1b": {
|
| 25 |
"id": "openbmb/MiniCPM5-1B",
|
|
@@ -29,6 +39,7 @@ MODEL_CONFIGS = {
|
|
| 29 |
"auto_class": "AutoModelForCausalLM",
|
| 30 |
"tokenizer_class": "AutoTokenizer",
|
| 31 |
"size_gb": 2.17,
|
|
|
|
| 32 |
},
|
| 33 |
"minicpm-v-4.6": {
|
| 34 |
"id": "openbmb/MiniCPM-V-4.6",
|
|
@@ -38,6 +49,18 @@ MODEL_CONFIGS = {
|
|
| 38 |
"auto_class": "AutoModelForImageTextToText",
|
| 39 |
"processor_class": "AutoProcessor",
|
| 40 |
"size_gb": 2.8,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
},
|
| 42 |
}
|
| 43 |
|
|
@@ -56,6 +79,31 @@ PY_MEM_LIMIT_MB = 1024
|
|
| 56 |
MAX_STDIO_CHARS = 16_000
|
| 57 |
OUTPUT_PNG = "output.png"
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
# ─── Regex Patterns ─────────────────────────────────────────────────────
|
| 60 |
|
| 61 |
THINKING_BLOCK_RE = re.compile(
|
|
@@ -68,6 +116,298 @@ FILE_BLOCK_RE = re.compile(
|
|
| 68 |
r"@@FILE:\s*(.+?)@@\s*\n(.*?)(?=@@FILE:|@@END@@)", re.DOTALL
|
| 69 |
)
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
# ─── Supported Languages & Frameworks ───────────────────────────────────
|
| 72 |
|
| 73 |
LANGUAGE_OPTIONS: list[tuple[str, list[str]]] = [
|
|
@@ -87,9 +427,9 @@ LANGUAGE_OPTIONS: list[tuple[str, list[str]]] = [
|
|
| 87 |
|
| 88 |
LANGUAGE_MAP: dict[str, list[str]] = {lang: frameworks for lang, frameworks in LANGUAGE_OPTIONS}
|
| 89 |
|
| 90 |
-
# ─── System Prompt ────────────────────────────────────────────
|
| 91 |
|
| 92 |
-
SYSTEM_PROMPT = """You are SoniCoder — an autonomous coding agent that writes files to a sandboxed workspace using tools.
|
| 93 |
|
| 94 |
CAPABILITIES:
|
| 95 |
- Generate complete, runnable applications in any language/framework
|
|
@@ -98,6 +438,8 @@ CAPABILITIES:
|
|
| 98 |
- Track multi-step tasks with the todo system
|
| 99 |
- Apply specialized skills (frontend-design, feature-dev, code-review, debugging, fullstack-scaffold, commit-workflow)
|
| 100 |
- Respond to slash commands: /commit, /review, /feature, /design, /explain, /test, /refactor, /skill, /help
|
|
|
|
|
|
|
| 101 |
|
| 102 |
CRITICAL RULES — READ CAREFULLY:
|
| 103 |
|
|
@@ -116,10 +458,21 @@ CRITICAL RULES — READ CAREFULLY:
|
|
| 116 |
|
| 117 |
6. After each tool call, briefly note what you did (one short sentence), then continue with the next step or finish.
|
| 118 |
|
| 119 |
-
7. Do NOT use
|
| 120 |
|
| 121 |
8. When done, give a concise summary of which files you created or modified.
|
| 122 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
WORKFLOW EXAMPLE:
|
| 124 |
- User asks: "Build a Flask API for a book library"
|
| 125 |
- You: call todo_write → call write_file (app.py) → call write_file (requirements.txt) → call write_file (README.md) → respond with a short summary listing the files created.
|
|
@@ -135,17 +488,21 @@ GRADIO APP RULES:
|
|
| 135 |
|
| 136 |
WEB APP RULES (HTML/CSS/JS):
|
| 137 |
- Save a single self-contained index.html via `write_file` with all CSS and JS inline.
|
|
|
|
|
|
|
| 138 |
|
| 139 |
PYTHON RULES:
|
| 140 |
- Prefer standard library or common packages.
|
| 141 |
- Add proper error handling and comments.
|
|
|
|
|
|
|
| 142 |
|
| 143 |
If web search results are provided, use them to inform your code generation.
|
| 144 |
If the user provides an image, analyze it and generate code based on what you see.
|
| 145 |
If a hook warns you, acknowledge it and adjust your approach.
|
| 146 |
"""
|
| 147 |
|
| 148 |
-
# ─── Example Prompts ─────────────────────────────────────────
|
| 149 |
|
| 150 |
EXAMPLE_PROMPTS: list[tuple[str, str, str, str]] = [
|
| 151 |
(
|
|
@@ -184,4 +541,17 @@ EXAMPLE_PROMPTS: list[tuple[str, str, str, str]] = [
|
|
| 184 |
"HTML/CSS/JS",
|
| 185 |
"Vanilla",
|
| 186 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
]
|
|
|
|
| 1 |
+
"""Application-wide constants, regex patterns, language options, and system prompt.
|
| 2 |
+
|
| 3 |
+
Enhanced version with:
|
| 4 |
+
- Additional model configurations
|
| 5 |
+
- Code templates system
|
| 6 |
+
- Session management settings
|
| 7 |
+
- Performance tuning parameters
|
| 8 |
+
- Security configurations
|
| 9 |
+
"""
|
| 10 |
|
| 11 |
from __future__ import annotations
|
| 12 |
|
|
|
|
| 15 |
# ─── App Identity ────────────────────────────────────────────────────────
|
| 16 |
|
| 17 |
APP_TITLE = "SoniCoder"
|
| 18 |
+
APP_VERSION = "2.1.0" # Enhanced version
|
| 19 |
MODEL_URL = "https://huggingface.co/Qwen/Qwen2.5-Coder-1.5B-Instruct"
|
| 20 |
|
| 21 |
# ─── Model Configs ───────────────────────────────────────────────────────
|
|
|
|
| 29 |
"auto_class": "AutoModelForCausalLM",
|
| 30 |
"tokenizer_class": "AutoTokenizer",
|
| 31 |
"size_gb": 3.0,
|
| 32 |
+
"capabilities": ["code-generation", "tool-calling", "multi-language"],
|
| 33 |
},
|
| 34 |
"minicpm5-1b": {
|
| 35 |
"id": "openbmb/MiniCPM5-1B",
|
|
|
|
| 39 |
"auto_class": "AutoModelForCausalLM",
|
| 40 |
"tokenizer_class": "AutoTokenizer",
|
| 41 |
"size_gb": 2.17,
|
| 42 |
+
"capabilities": ["code-generation", "fast-inference"],
|
| 43 |
},
|
| 44 |
"minicpm-v-4.6": {
|
| 45 |
"id": "openbmb/MiniCPM-V-4.6",
|
|
|
|
| 49 |
"auto_class": "AutoModelForImageTextToText",
|
| 50 |
"processor_class": "AutoProcessor",
|
| 51 |
"size_gb": 2.8,
|
| 52 |
+
"capabilities": ["vision", "image-understanding", "code-generation"],
|
| 53 |
+
},
|
| 54 |
+
# New: DeepSeek Coder for enhanced code quality
|
| 55 |
+
"deepseek-coder-1.3b": {
|
| 56 |
+
"id": "deepseek-ai/DeepSeek-Coder-1.3B-Instruct",
|
| 57 |
+
"name": "DeepSeek-Coder-1.3B",
|
| 58 |
+
"type": "text",
|
| 59 |
+
"description": "Advanced code model with superior reasoning",
|
| 60 |
+
"auto_class": "AutoModelForCausalLM",
|
| 61 |
+
"tokenizer_class": "AutoTokenizer",
|
| 62 |
+
"size_gb": 2.6,
|
| 63 |
+
"capabilities": ["code-generation", "reasoning", "debugging"],
|
| 64 |
},
|
| 65 |
}
|
| 66 |
|
|
|
|
| 79 |
MAX_STDIO_CHARS = 16_000
|
| 80 |
OUTPUT_PNG = "output.png"
|
| 81 |
|
| 82 |
+
# Performance Tuning (NEW)
|
| 83 |
+
CACHE_ENABLED = True
|
| 84 |
+
CACHE_TTL_SECONDS = 300 # 5 minutes cache for similar prompts
|
| 85 |
+
MAX_CONCURRENT_REQUESTS = 5
|
| 86 |
+
REQUEST_QUEUE_TIMEOUT = 60
|
| 87 |
+
RESPONSE_STREAMING_CHUNK_SIZE = 16 # tokens per chunk
|
| 88 |
+
|
| 89 |
+
# Session Management (NEW)
|
| 90 |
+
SESSION_TIMEOUT_MINUTES = 60
|
| 91 |
+
MAX_SESSION_HISTORY = 100
|
| 92 |
+
AUTO_SAVE_INTERVAL_SECONDS = 30
|
| 93 |
+
EXPORT_FORMATS = ["json", "markdown", "html"]
|
| 94 |
+
|
| 95 |
+
# Security Settings (NEW)
|
| 96 |
+
MAX_PROMPT_LENGTH = 10000
|
| 97 |
+
MAX_FILE_SIZE_MB = 10
|
| 98 |
+
RATE_LIMIT_REQUESTS_PER_MINUTE = 30
|
| 99 |
+
BLOCKED_PATTERNS_EXTRA = [
|
| 100 |
+
r"import\s+os.*system",
|
| 101 |
+
r"eval\s*\(",
|
| 102 |
+
r"exec\s*\(",
|
| 103 |
+
r"__import__",
|
| 104 |
+
r"subprocess\.Popen",
|
| 105 |
+
]
|
| 106 |
+
|
| 107 |
# ─── Regex Patterns ─────────────────────────────────────────────────────
|
| 108 |
|
| 109 |
THINKING_BLOCK_RE = re.compile(
|
|
|
|
| 116 |
r"@@FILE:\s*(.+?)@@\s*\n(.*?)(?=@@FILE:|@@END@@)", re.DOTALL
|
| 117 |
)
|
| 118 |
|
| 119 |
+
# Enhanced patterns for better parsing
|
| 120 |
+
TOOL_CALL_RE = re.compile(
|
| 121 |
+
r"```tool\s*\n(.*?)```", re.DOTALL
|
| 122 |
+
)
|
| 123 |
+
MULTILINE_VALUE_RE = re.compile(
|
| 124 |
+
r"(\w+):\s*\|\s*\n([\s\S]*?)(?=\n\w+:|$)"
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
# ─── Code Templates System (NEW) ────────────────────────────────────────
|
| 128 |
+
|
| 129 |
+
CODE_TEMPLATES: dict[str, dict[str, str]] = {
|
| 130 |
+
"python-flask-api": {
|
| 131 |
+
"name": "Flask REST API",
|
| 132 |
+
"description": "Basic Flask REST API with CRUD operations",
|
| 133 |
+
"language": "Python",
|
| 134 |
+
"framework": "Flask",
|
| 135 |
+
"template": """from flask import Flask, request, jsonify
|
| 136 |
+
from dataclasses import dataclass
|
| 137 |
+
from typing import List, Optional
|
| 138 |
+
import datetime
|
| 139 |
+
|
| 140 |
+
app = Flask(__name__)
|
| 141 |
+
|
| 142 |
+
@dataclass
|
| 143 |
+
class Item:
|
| 144 |
+
id: int
|
| 145 |
+
name: str
|
| 146 |
+
description: str
|
| 147 |
+
created_at: str
|
| 148 |
+
|
| 149 |
+
# In-memory storage
|
| 150 |
+
items: List[Item] = []
|
| 151 |
+
next_id = 1
|
| 152 |
+
|
| 153 |
+
@app.route('/api/items', methods=['GET'])
|
| 154 |
+
def get_items():
|
| 155 |
+
return jsonify([item.__dict__ for item in items])
|
| 156 |
+
|
| 157 |
+
@app.route('/api/items/<int:item_id>', methods=['GET'])
|
| 158 |
+
def get_item(item_id):
|
| 159 |
+
item = next((i for i in items if i.id == item_id), None)
|
| 160 |
+
if not item:
|
| 161 |
+
return jsonify({'error': 'Not found'}), 404
|
| 162 |
+
return jsonify(item.__dict__)
|
| 163 |
+
|
| 164 |
+
@app.route('/api/items', methods=['POST'])
|
| 165 |
+
def create_item():
|
| 166 |
+
global next_id
|
| 167 |
+
data = request.get_json()
|
| 168 |
+
item = Item(
|
| 169 |
+
id=next_id,
|
| 170 |
+
name=data['name'],
|
| 171 |
+
description=data.get('description', ''),
|
| 172 |
+
created_at=datetime.datetime.now().isoformat()
|
| 173 |
+
)
|
| 174 |
+
items.append(item)
|
| 175 |
+
next_id += 1
|
| 176 |
+
return jsonify(item.__dict__), 201
|
| 177 |
+
|
| 178 |
+
if __name__ == '__main__':
|
| 179 |
+
app.run(host='0.0.0.0', port=7860, debug=True)
|
| 180 |
+
""",
|
| 181 |
+
},
|
| 182 |
+
"react-todo-app": {
|
| 183 |
+
"name": "React Todo App",
|
| 184 |
+
"description": "Modern React todo application with hooks",
|
| 185 |
+
"language": "JavaScript",
|
| 186 |
+
"framework": "React",
|
| 187 |
+
"template": """import React, { useState, useEffect } from 'react';
|
| 188 |
+
|
| 189 |
+
function TodoApp() {
|
| 190 |
+
const [todos, setTodos] = useState([]);
|
| 191 |
+
const [input, setInput] = useState('');
|
| 192 |
+
const [filter, setFilter] = useState('all');
|
| 193 |
+
|
| 194 |
+
const addTodo = () => {
|
| 195 |
+
if (!input.trim()) return;
|
| 196 |
+
setTodos([...todos, {
|
| 197 |
+
id: Date.now(),
|
| 198 |
+
text: input,
|
| 199 |
+
completed: false
|
| 200 |
+
}]);
|
| 201 |
+
setInput('');
|
| 202 |
+
};
|
| 203 |
+
|
| 204 |
+
const toggleTodo = (id) => {
|
| 205 |
+
setTodos(todos.map(todo =>
|
| 206 |
+
todo.id === id ? { ...todo, completed: !todo.completed } : todo
|
| 207 |
+
));
|
| 208 |
+
};
|
| 209 |
+
|
| 210 |
+
const deleteTodo = (id) => {
|
| 211 |
+
setTodos(todos.filter(todo => todo.id !== id));
|
| 212 |
+
};
|
| 213 |
+
|
| 214 |
+
const filteredTodos = todos.filter(todo => {
|
| 215 |
+
if (filter === 'active') return !todo.completed;
|
| 216 |
+
if (filter === 'completed') return todo.completed;
|
| 217 |
+
return true;
|
| 218 |
+
});
|
| 219 |
+
|
| 220 |
+
return (
|
| 221 |
+
<div style={{ maxWidth: 500, margin: 'auto', padding: 20 }}>
|
| 222 |
+
<h1>Todo App</h1>
|
| 223 |
+
<div style={{ display: 'flex', gap: 10, marginBottom: 20 }}>
|
| 224 |
+
<input
|
| 225 |
+
value={input}
|
| 226 |
+
onChange={e => setInput(e.target.value)}
|
| 227 |
+
onKeyPress={e => e.key === 'Enter' && addTodo()}
|
| 228 |
+
placeholder="What needs to be done?"
|
| 229 |
+
style={{ flex: 1, padding: 10 }}
|
| 230 |
+
/>
|
| 231 |
+
<button onClick={addTodo}>Add</button>
|
| 232 |
+
</div>
|
| 233 |
+
<div style={{ marginBottom: 20 }}>
|
| 234 |
+
{['all', 'active', 'completed'].map(f => (
|
| 235 |
+
<button key={f} onClick={() => setFilter(f)}
|
| 236 |
+
style={{ marginRight: 5, opacity: filter === f ? 1 : 0.6 }}>
|
| 237 |
+
{f}
|
| 238 |
+
</button>
|
| 239 |
+
))}
|
| 240 |
+
</div>
|
| 241 |
+
<ul style={{ listStyle: 'none', padding: 0 }}>
|
| 242 |
+
{filteredTodos.map(todo => (
|
| 243 |
+
<li key={todo.id} style={{
|
| 244 |
+
display: 'flex', alignItems: 'center',
|
| 245 |
+
padding: 10, borderBottom: '1px solid #eee',
|
| 246 |
+
textDecoration: todo.completed ? 'line-through' : 'none'
|
| 247 |
+
}}>
|
| 248 |
+
<input type="checkbox" checked={todo.completed}
|
| 249 |
+
onChange={() => toggleTodo(todo.id)} />
|
| 250 |
+
<span style={{ flex: 1 }}>{todo.text}</span>
|
| 251 |
+
<button onClick={() => deleteTodo(todo.id)}>×</button>
|
| 252 |
+
</li>
|
| 253 |
+
))}
|
| 254 |
+
</ul>
|
| 255 |
+
</div>
|
| 256 |
+
);
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
export default TodoApp;
|
| 260 |
+
""",
|
| 261 |
+
},
|
| 262 |
+
"gradio-image-app": {
|
| 263 |
+
"name": "Gradio Image Processor",
|
| 264 |
+
"description": "Gradio app for image processing with filters",
|
| 265 |
+
"language": "Python",
|
| 266 |
+
"framework": "Gradio",
|
| 267 |
+
"template": """import gradio as gr
|
| 268 |
+
from PIL import Image, ImageFilter, ImageEnhance
|
| 269 |
+
import numpy as np
|
| 270 |
+
|
| 271 |
+
def apply_filter(image, filter_type, intensity=1.0):
|
| 272 |
+
if image is None:
|
| 273 |
+
return None
|
| 274 |
+
|
| 275 |
+
img = Image.fromarray(image)
|
| 276 |
+
|
| 277 |
+
if filter_type == "Blur":
|
| 278 |
+
result = img.filter(ImageFilter.GaussianBlur(radius=intensity * 5))
|
| 279 |
+
elif filter_type == "Sharpen":
|
| 280 |
+
result = img.filter(ImageFilter.Sharpen())
|
| 281 |
+
elif filter_type == "Edge Detect":
|
| 282 |
+
result = img.filter(ImageFilter.FIND_EDGES)
|
| 283 |
+
elif filter_type == "Contour":
|
| 284 |
+
result = img.filter(ImageFilter.CONTOUR)
|
| 285 |
+
elif filter_type == "Emboss":
|
| 286 |
+
result = img.filter(ImageFilter.EMBOSS)
|
| 287 |
+
elif filter_type == "Brightness":
|
| 288 |
+
enhancer = ImageEnhance.Brightness(img)
|
| 289 |
+
result = enhancer.enhance(1.0 + intensity)
|
| 290 |
+
elif filter_type == "Contrast":
|
| 291 |
+
enhancer = ImageEnhance.Contrast(img)
|
| 292 |
+
result = enhancer.enhance(1.0 + intensity)
|
| 293 |
+
else:
|
| 294 |
+
result = img
|
| 295 |
+
|
| 296 |
+
return np.array(result)
|
| 297 |
+
|
| 298 |
+
with gr.Blocks(title="Image Processor") as demo:
|
| 299 |
+
gr.Markdown("# 🎨 Image Processor")
|
| 300 |
+
gr.Markdown("Upload an image and apply various filters.")
|
| 301 |
+
|
| 302 |
+
with gr.Row():
|
| 303 |
+
with gr.Column():
|
| 304 |
+
input_image = gr.Image(label="Input Image", type="numpy")
|
| 305 |
+
filter_type = gr.Dropdown(
|
| 306 |
+
choices=["Blur", "Sharpen", "Edge Detect", "Contour",
|
| 307 |
+
"Emboss", "Brightness", "Contrast"],
|
| 308 |
+
label="Filter Type",
|
| 309 |
+
value="Blur"
|
| 310 |
+
)
|
| 311 |
+
intensity = gr.Slider(
|
| 312 |
+
minimum=0, maximum=2, step=0.1, value=1,
|
| 313 |
+
label="Intensity"
|
| 314 |
+
)
|
| 315 |
+
process_btn = gr.Button("Apply Filter", variant="primary")
|
| 316 |
+
|
| 317 |
+
with gr.Column():
|
| 318 |
+
output_image = gr.Image(label="Result")
|
| 319 |
+
|
| 320 |
+
process_btn.click(
|
| 321 |
+
apply_filter,
|
| 322 |
+
inputs=[input_image, filter_type, intensity],
|
| 323 |
+
outputs=[output_image]
|
| 324 |
+
)
|
| 325 |
+
|
| 326 |
+
if __name__ == "__main__":
|
| 327 |
+
demo.launch(server_name="0.0.0.0", server_port=7860)
|
| 328 |
+
""",
|
| 329 |
+
},
|
| 330 |
+
"express-js-server": {
|
| 331 |
+
"name": "Express.js Server",
|
| 332 |
+
"description": "Express.js REST API with middleware",
|
| 333 |
+
"language": "JavaScript",
|
| 334 |
+
"framework": "Express.js",
|
| 335 |
+
"template": """const express = require('express');
|
| 336 |
+
const cors = require('cors');
|
| 337 |
+
const helmet = require('helmet');
|
| 338 |
+
|
| 339 |
+
const app = express();
|
| 340 |
+
const PORT = 7860;
|
| 341 |
+
|
| 342 |
+
// Middleware
|
| 343 |
+
app.use(helmet());
|
| 344 |
+
app.use(cors());
|
| 345 |
+
app.use(express.json({ limit: '10mb' }));
|
| 346 |
+
|
| 347 |
+
// Request logging
|
| 348 |
+
app.use((req, res, next) => {
|
| 349 |
+
console.log(`${new Date().toISOString()} ${req.method} ${req.path}`);
|
| 350 |
+
next();
|
| 351 |
+
});
|
| 352 |
+
|
| 353 |
+
// In-memory data store
|
| 354 |
+
let items = [];
|
| 355 |
+
let nextId = 1;
|
| 356 |
+
|
| 357 |
+
// Routes
|
| 358 |
+
app.get('/api/health', (req, res) => {
|
| 359 |
+
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
| 360 |
+
});
|
| 361 |
+
|
| 362 |
+
app.get('/api/items', (req, res) => {
|
| 363 |
+
const { limit, offset } = req.query;
|
| 364 |
+
let result = items;
|
| 365 |
+
if (offset) result = result.slice(Number(offset));
|
| 366 |
+
if (limit) result = result.slice(0, Number(limit));
|
| 367 |
+
res.json({ data: result, total: items.length });
|
| 368 |
+
});
|
| 369 |
+
|
| 370 |
+
app.post('/api/items', (req, res) => {
|
| 371 |
+
const { name, description } = req.body;
|
| 372 |
+
if (!name) return res.status(400).json({ error: 'Name is required' });
|
| 373 |
+
|
| 374 |
+
const item = { id: nextId++, name, description, createdAt: new Date().toISOString() };
|
| 375 |
+
items.push(item);
|
| 376 |
+
res.status(201).json(item);
|
| 377 |
+
});
|
| 378 |
+
|
| 379 |
+
app.get('/api/items/:id', (req, res) => {
|
| 380 |
+
const item = items.find(i => i.id === Number(req.params.id));
|
| 381 |
+
if (!item) return res.status(404).json({ error: 'Not found' });
|
| 382 |
+
res.json(item);
|
| 383 |
+
});
|
| 384 |
+
|
| 385 |
+
app.put('/api/items/:id', (req, res) => {
|
| 386 |
+
const idx = items.findIndex(i => i.id === Number(req.params.id));
|
| 387 |
+
if (idx === -1) return res.status(404).json({ error: 'Not found' });
|
| 388 |
+
|
| 389 |
+
items[idx] = { ...items[idx], ...req.body, updatedAt: new Date().toISOString() };
|
| 390 |
+
res.json(items[idx]);
|
| 391 |
+
});
|
| 392 |
+
|
| 393 |
+
app.delete('/api/items/:id', (req, res) => {
|
| 394 |
+
items = items.filter(i => i.id !== Number(req.params.id));
|
| 395 |
+
res.status(204).send();
|
| 396 |
+
});
|
| 397 |
+
|
| 398 |
+
// Error handling
|
| 399 |
+
app.use((err, req, res, next) => {
|
| 400 |
+
console.error(err.stack);
|
| 401 |
+
res.status(500).json({ error: 'Something went wrong!' });
|
| 402 |
+
});
|
| 403 |
+
|
| 404 |
+
app.listen(PORT, '0.0.0.0', () => {
|
| 405 |
+
console.log(`Server running on http://0.0.0.0:${PORT}`);
|
| 406 |
+
});
|
| 407 |
+
""",
|
| 408 |
+
},
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
# ─── Supported Languages & Frameworks ───────────────────────────────────
|
| 412 |
|
| 413 |
LANGUAGE_OPTIONS: list[tuple[str, list[str]]] = [
|
|
|
|
| 427 |
|
| 428 |
LANGUAGE_MAP: dict[str, list[str]] = {lang: frameworks for lang, frameworks in LANGUAGE_OPTIONS}
|
| 429 |
|
| 430 |
+
# ─── System Prompt (Enhanced) ────────────────────────────────────────────
|
| 431 |
|
| 432 |
+
SYSTEM_PROMPT = """You are SoniCoder v2.1 — an advanced autonomous coding agent that writes files to a sandboxed workspace using tools.
|
| 433 |
|
| 434 |
CAPABILITIES:
|
| 435 |
- Generate complete, runnable applications in any language/framework
|
|
|
|
| 438 |
- Track multi-step tasks with the todo system
|
| 439 |
- Apply specialized skills (frontend-design, feature-dev, code-review, debugging, fullstack-scaffold, commit-workflow)
|
| 440 |
- Respond to slash commands: /commit, /review, /feature, /design, /explain, /test, /refactor, /skill, /help
|
| 441 |
+
- Use code templates when appropriate to accelerate development
|
| 442 |
+
- Provide intelligent code suggestions and best practices
|
| 443 |
|
| 444 |
CRITICAL RULES — READ CAREFULLY:
|
| 445 |
|
|
|
|
| 458 |
|
| 459 |
6. After each tool call, briefly note what you did (one short sentence), then continue with the next step or finish.
|
| 460 |
|
| 461 |
+
7. Do NOT use think or <thinking> tags. Do NOT reason aloud.
|
| 462 |
|
| 463 |
8. When done, give a concise summary of which files you created or modified.
|
| 464 |
|
| 465 |
+
9. Always include proper error handling and comments in generated code.
|
| 466 |
+
|
| 467 |
+
10. Follow security best practices: validate inputs, sanitize outputs, avoid hardcoded secrets.
|
| 468 |
+
|
| 469 |
+
CODE QUALITY STANDARDS:
|
| 470 |
+
- Write clean, maintainable, and well-documented code
|
| 471 |
+
- Follow language-specific conventions and idioms
|
| 472 |
+
- Include type hints where applicable
|
| 473 |
+
- Add meaningful variable and function names
|
| 474 |
+
- Structure code with clear separation of concerns
|
| 475 |
+
|
| 476 |
WORKFLOW EXAMPLE:
|
| 477 |
- User asks: "Build a Flask API for a book library"
|
| 478 |
- You: call todo_write → call write_file (app.py) → call write_file (requirements.txt) → call write_file (README.md) → respond with a short summary listing the files created.
|
|
|
|
| 488 |
|
| 489 |
WEB APP RULES (HTML/CSS/JS):
|
| 490 |
- Save a single self-contained index.html via `write_file` with all CSS and JS inline.
|
| 491 |
+
- Use modern CSS features (flexbox, grid, custom properties).
|
| 492 |
+
- Ensure responsive design for mobile compatibility.
|
| 493 |
|
| 494 |
PYTHON RULES:
|
| 495 |
- Prefer standard library or common packages.
|
| 496 |
- Add proper error handling and comments.
|
| 497 |
+
- Use type hints for function signatures.
|
| 498 |
+
- Follow PEP 8 style guidelines.
|
| 499 |
|
| 500 |
If web search results are provided, use them to inform your code generation.
|
| 501 |
If the user provides an image, analyze it and generate code based on what you see.
|
| 502 |
If a hook warns you, acknowledge it and adjust your approach.
|
| 503 |
"""
|
| 504 |
|
| 505 |
+
# ─── Example Prompts (Enhanced) ─────────────────────────────────────────
|
| 506 |
|
| 507 |
EXAMPLE_PROMPTS: list[tuple[str, str, str, str]] = [
|
| 508 |
(
|
|
|
|
| 541 |
"HTML/CSS/JS",
|
| 542 |
"Vanilla",
|
| 543 |
),
|
| 544 |
+
# New examples
|
| 545 |
+
(
|
| 546 |
+
"⚡ Express API",
|
| 547 |
+
"Build a comprehensive Express.js REST API with authentication middleware, rate limiting, and proper error handling.",
|
| 548 |
+
"JavaScript",
|
| 549 |
+
"Express.js",
|
| 550 |
+
),
|
| 551 |
+
(
|
| 552 |
+
"🔐 Auth System",
|
| 553 |
+
"Create a complete authentication system with JWT tokens, password hashing, login/register endpoints, and middleware protection.",
|
| 554 |
+
"Python",
|
| 555 |
+
"FastAPI",
|
| 556 |
+
),
|
| 557 |
]
|
|
@@ -1,18 +1,37 @@
|
|
| 1 |
"""Model inference — streaming and synchronous generation.
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
Supports two inference paths:
|
| 4 |
-
- Text-only
|
| 5 |
-
- VLM
|
| 6 |
"""
|
| 7 |
|
| 8 |
from __future__ import annotations
|
| 9 |
|
|
|
|
| 10 |
import logging
|
| 11 |
import threading
|
|
|
|
| 12 |
from collections.abc import Iterator
|
| 13 |
-
from
|
| 14 |
-
|
| 15 |
-
from
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
from code.model.loader import (
|
| 17 |
get_model,
|
| 18 |
get_tokenizer_or_processor,
|
|
@@ -25,33 +44,164 @@ from code.model.loader import (
|
|
| 25 |
logger = logging.getLogger(__name__)
|
| 26 |
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
def call_model(
|
| 29 |
messages: list[dict[str, Any]],
|
| 30 |
max_new_tokens: int = DEFAULT_MAX_TOKENS,
|
| 31 |
image_url: str | None = None,
|
|
|
|
|
|
|
| 32 |
) -> Iterator[str]:
|
| 33 |
"""Stream model text. Yields progressively longer strings (full text so far).
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
if not is_model_loaded():
|
| 38 |
status = get_model_status()
|
|
|
|
| 39 |
yield status["message"]
|
| 40 |
return
|
| 41 |
|
| 42 |
model_type = get_current_model_type()
|
| 43 |
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
|
| 50 |
def _call_text_model(
|
| 51 |
messages: list[dict[str, Any]],
|
| 52 |
max_new_tokens: int,
|
|
|
|
|
|
|
| 53 |
) -> Iterator[str]:
|
| 54 |
-
"""Stream text from a text-only model using TextIteratorStreamer.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
model = get_model()
|
| 56 |
tokenizer = get_tokenizer_or_processor()
|
| 57 |
|
|
@@ -59,7 +209,7 @@ def _call_text_model(
|
|
| 59 |
from transformers import TextIteratorStreamer
|
| 60 |
import torch
|
| 61 |
|
| 62 |
-
# Build the prompt from messages
|
| 63 |
prompt_parts: list[str] = []
|
| 64 |
for msg in messages:
|
| 65 |
role = msg.get("role", "user")
|
|
@@ -73,38 +223,65 @@ def _call_text_model(
|
|
| 73 |
prompt_parts.append("Assistant:")
|
| 74 |
full_prompt = "\n\n".join(prompt_parts)
|
| 75 |
|
| 76 |
-
# Tokenize
|
| 77 |
inputs = tokenizer(full_prompt, return_tensors="pt", truncation=True, max_length=4096)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
if torch.cuda.is_available():
|
| 79 |
inputs = {k: v.to("cuda") for k, v in inputs.items()}
|
| 80 |
|
| 81 |
-
#
|
|
|
|
|
|
|
| 82 |
streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True)
|
| 83 |
|
| 84 |
generation_kwargs = {
|
| 85 |
**inputs,
|
| 86 |
"streamer": streamer,
|
| 87 |
"max_new_tokens": max_new_tokens,
|
| 88 |
-
"temperature":
|
| 89 |
-
"do_sample":
|
| 90 |
"top_p": 0.9,
|
| 91 |
"repetition_penalty": 1.1,
|
| 92 |
"pad_token_id": tokenizer.eos_token_id,
|
|
|
|
|
|
|
|
|
|
| 93 |
}
|
| 94 |
|
| 95 |
-
# Run generation in a separate thread
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
|
|
|
|
|
|
| 99 |
output = ""
|
|
|
|
| 100 |
for new_text in streamer:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
output += new_text
|
|
|
|
| 102 |
yield output
|
| 103 |
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
except Exception as exc:
|
| 107 |
logger.exception("Error during text model inference")
|
|
|
|
|
|
|
| 108 |
yield f"_Error during generation: {exc}_"
|
| 109 |
|
| 110 |
|
|
@@ -112,11 +289,16 @@ def _call_vlm_model(
|
|
| 112 |
messages: list[dict[str, Any]],
|
| 113 |
max_new_tokens: int,
|
| 114 |
image_url: str | None = None,
|
|
|
|
|
|
|
| 115 |
) -> Iterator[str]:
|
| 116 |
"""Stream text from a VLM model with optional image input.
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
| 120 |
"""
|
| 121 |
model = get_model()
|
| 122 |
processor = get_tokenizer_or_processor()
|
|
@@ -127,7 +309,7 @@ def _call_vlm_model(
|
|
| 127 |
# Build VLM-style messages with image support
|
| 128 |
vlm_messages = _build_vlm_messages(messages, image_url)
|
| 129 |
|
| 130 |
-
# Apply chat template
|
| 131 |
try:
|
| 132 |
inputs = processor.apply_chat_template(
|
| 133 |
vlm_messages,
|
|
@@ -151,88 +333,138 @@ def _call_vlm_model(
|
|
| 151 |
if torch.cuda.is_available():
|
| 152 |
inputs = inputs.to("cuda")
|
| 153 |
else:
|
| 154 |
-
# Move to CPU explicitly
|
| 155 |
inputs = inputs.to("cpu")
|
| 156 |
|
| 157 |
-
#
|
| 158 |
try:
|
| 159 |
-
from
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
skip_prompt=True,
|
| 163 |
-
skip_special_tokens=True,
|
| 164 |
)
|
| 165 |
-
|
| 166 |
-
gen_kwargs = {
|
| 167 |
-
**inputs,
|
| 168 |
-
"streamer": streamer,
|
| 169 |
-
"max_new_tokens": max_new_tokens,
|
| 170 |
-
"temperature": DEFAULT_TEMPERATURE,
|
| 171 |
-
"do_sample": True,
|
| 172 |
-
"top_p": 0.9,
|
| 173 |
-
"repetition_penalty": 1.1,
|
| 174 |
-
}
|
| 175 |
-
# Add downsample_mode if supported
|
| 176 |
-
try:
|
| 177 |
-
gen_kwargs["downsample_mode"] = "16x"
|
| 178 |
-
except Exception:
|
| 179 |
-
pass
|
| 180 |
-
|
| 181 |
-
# Ensure pad_token_id
|
| 182 |
-
if hasattr(processor, 'tokenizer') and hasattr(processor.tokenizer, 'eos_token_id'):
|
| 183 |
-
gen_kwargs["pad_token_id"] = processor.tokenizer.eos_token_id
|
| 184 |
-
elif hasattr(processor, 'eos_token_id'):
|
| 185 |
-
gen_kwargs["pad_token_id"] = processor.eos_token_id
|
| 186 |
-
|
| 187 |
-
thread = threading.Thread(target=model.generate, kwargs=gen_kwargs)
|
| 188 |
-
thread.start()
|
| 189 |
-
|
| 190 |
-
output = ""
|
| 191 |
-
for new_text in streamer:
|
| 192 |
-
output += new_text
|
| 193 |
-
yield output
|
| 194 |
-
|
| 195 |
-
thread.join()
|
| 196 |
-
|
| 197 |
except Exception as stream_err:
|
| 198 |
-
# Fallback: non-streaming generation
|
| 199 |
logger.warning("Streaming failed for VLM, falling back to sync: %s", stream_err)
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
"max_new_tokens": max_new_tokens,
|
| 203 |
-
"temperature": DEFAULT_TEMPERATURE,
|
| 204 |
-
"do_sample": True,
|
| 205 |
-
"top_p": 0.9,
|
| 206 |
-
}
|
| 207 |
-
try:
|
| 208 |
-
gen_kwargs["downsample_mode"] = "16x"
|
| 209 |
-
except Exception:
|
| 210 |
-
pass
|
| 211 |
-
|
| 212 |
-
generated_ids = model.generate(**gen_kwargs)
|
| 213 |
-
# Trim input tokens from output
|
| 214 |
-
input_len = inputs["input_ids"].shape[1] if hasattr(inputs, "shape") else len(inputs["input_ids"])
|
| 215 |
-
generated_ids_trimmed = [
|
| 216 |
-
out_ids[len(in_ids):]
|
| 217 |
-
for in_ids, out_ids in zip(inputs["input_ids"], generated_ids)
|
| 218 |
-
]
|
| 219 |
-
tok = processor.tokenizer if hasattr(processor, 'tokenizer') else processor
|
| 220 |
-
output_text = tok.batch_decode(
|
| 221 |
-
generated_ids_trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False
|
| 222 |
)
|
| 223 |
-
yield output_text[0] if output_text else ""
|
| 224 |
|
| 225 |
except Exception as exc:
|
| 226 |
logger.exception("Error during VLM model inference")
|
|
|
|
|
|
|
| 227 |
yield f"_Error during generation: {exc}_"
|
| 228 |
|
| 229 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
def _build_vlm_messages(
|
| 231 |
messages: list[dict[str, Any]],
|
| 232 |
image_url: str | None = None,
|
| 233 |
) -> list[dict[str, Any]]:
|
| 234 |
"""Build VLM-style messages with image content blocks.
|
| 235 |
-
|
| 236 |
If an image_url is provided, it's injected into the last user message
|
| 237 |
as a content block with type "image".
|
| 238 |
"""
|
|
@@ -243,7 +475,6 @@ def _build_vlm_messages(
|
|
| 243 |
content = msg.get("content", "")
|
| 244 |
|
| 245 |
if role == "system":
|
| 246 |
-
# System messages stay as-is for VLM
|
| 247 |
vlm_messages.append({"role": "system", "content": content})
|
| 248 |
continue
|
| 249 |
|
|
@@ -266,9 +497,72 @@ def call_model_sync(
|
|
| 266 |
messages: list[dict[str, Any]],
|
| 267 |
max_new_tokens: int = DEFAULT_MAX_TOKENS,
|
| 268 |
image_url: str | None = None,
|
| 269 |
-
|
| 270 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
result = ""
|
| 272 |
-
|
|
|
|
|
|
|
| 273 |
result = chunk
|
| 274 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""Model inference — streaming and synchronous generation.
|
| 2 |
|
| 3 |
+
Enhanced version with:
|
| 4 |
+
- Response caching for similar prompts
|
| 5 |
+
- Better error handling and recovery
|
| 6 |
+
- Token usage tracking
|
| 7 |
+
- Generation timeout handling
|
| 8 |
+
- Structured output support
|
| 9 |
+
- Performance metrics
|
| 10 |
+
|
| 11 |
Supports two inference paths:
|
| 12 |
+
- Text-only models: uses TextIteratorStreamer for real-time streaming
|
| 13 |
+
- VLM models: uses processor.apply_chat_template() with image support
|
| 14 |
"""
|
| 15 |
|
| 16 |
from __future__ import annotations
|
| 17 |
|
| 18 |
+
import hashlib
|
| 19 |
import logging
|
| 20 |
import threading
|
| 21 |
+
import time
|
| 22 |
from collections.abc import Iterator
|
| 23 |
+
from functools import lru_cache
|
| 24 |
+
from typing import Any, Optional
|
| 25 |
+
from dataclasses import dataclass, field
|
| 26 |
+
|
| 27 |
+
from code.config.constants import (
|
| 28 |
+
DEFAULT_TEMPERATURE,
|
| 29 |
+
DEFAULT_MAX_TOKENS,
|
| 30 |
+
MODEL_CONFIGS,
|
| 31 |
+
CACHE_ENABLED,
|
| 32 |
+
CACHE_TTL_SECONDS,
|
| 33 |
+
RESPONSE_STREAMING_CHUNK_SIZE,
|
| 34 |
+
)
|
| 35 |
from code.model.loader import (
|
| 36 |
get_model,
|
| 37 |
get_tokenizer_or_processor,
|
|
|
|
| 44 |
logger = logging.getLogger(__name__)
|
| 45 |
|
| 46 |
|
| 47 |
+
@dataclass
|
| 48 |
+
class InferenceMetrics:
|
| 49 |
+
"""Track inference performance metrics."""
|
| 50 |
+
start_time: float = field(default_factory=time.time)
|
| 51 |
+
end_time: float = 0.0
|
| 52 |
+
tokens_generated: int = 0
|
| 53 |
+
tokens_per_second: float = 0.0
|
| 54 |
+
time_to_first_token: float = 0.0
|
| 55 |
+
cache_hit: bool = False
|
| 56 |
+
error: str | None = None
|
| 57 |
+
|
| 58 |
+
def finalize(self, total_tokens: int):
|
| 59 |
+
"""Calculate final metrics."""
|
| 60 |
+
self.end_time = time.time()
|
| 61 |
+
self.tokens_generated = total_tokens
|
| 62 |
+
elapsed = self.end_time - self.start_time
|
| 63 |
+
if elapsed > 0:
|
| 64 |
+
self.tokens_per_second = total_tokens / elapsed
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
# ─── Response Cache (NEW) ────────────────────────────────────────────────
|
| 68 |
+
|
| 69 |
+
_response_cache: dict[str, dict[str, Any]] = {}
|
| 70 |
+
_cache_lock = threading.Lock()
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _cache_key(messages: list[dict[str, Any]], max_tokens: int) -> str:
|
| 74 |
+
"""Generate a cache key from messages and parameters."""
|
| 75 |
+
content = str(messages) + str(max_tokens)
|
| 76 |
+
return hashlib.sha256(content.encode()).hexdigest()
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _get_cached_response(cache_key: str) -> str | None:
|
| 80 |
+
"""Get cached response if valid."""
|
| 81 |
+
if not CACHE_ENABLED:
|
| 82 |
+
return None
|
| 83 |
+
|
| 84 |
+
with _cache_lock:
|
| 85 |
+
if cache_key in _response_cache:
|
| 86 |
+
entry = _response_cache[cache_key]
|
| 87 |
+
if time.time() - entry["timestamp"] < CACHE_TTL_SECONDS:
|
| 88 |
+
logger.debug("Cache hit for key %s", cache_key[:8])
|
| 89 |
+
return entry["response"]
|
| 90 |
+
else:
|
| 91 |
+
# Expired cache entry
|
| 92 |
+
del _response_cache[cache_key]
|
| 93 |
+
return None
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _set_cached_response(cache_key: str, response: str):
|
| 97 |
+
"""Cache a response."""
|
| 98 |
+
if not CACHE_ENABLED:
|
| 99 |
+
return
|
| 100 |
+
|
| 101 |
+
with _cache_lock:
|
| 102 |
+
_response_cache[cache_key] = {
|
| 103 |
+
"response": response,
|
| 104 |
+
"timestamp": time.time(),
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def clear_cache():
|
| 109 |
+
"""Clear the response cache."""
|
| 110 |
+
global _response_cache
|
| 111 |
+
with _cache_lock:
|
| 112 |
+
_response_cache.clear()
|
| 113 |
+
logger.info("Response cache cleared")
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def get_cache_stats() -> dict[str, Any]:
|
| 117 |
+
"""Get cache statistics."""
|
| 118 |
+
with _cache_lock:
|
| 119 |
+
return {
|
| 120 |
+
"enabled": CACHE_ENABLED,
|
| 121 |
+
"entries": len(_response_cache),
|
| 122 |
+
"ttl_seconds": CACHE_TTL_SECONDS,
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
# ─── Main Inference Functions ────────────────────────────────────────────
|
| 127 |
+
|
| 128 |
def call_model(
|
| 129 |
messages: list[dict[str, Any]],
|
| 130 |
max_new_tokens: int = DEFAULT_MAX_TOKENS,
|
| 131 |
image_url: str | None = None,
|
| 132 |
+
temperature: float | None = None,
|
| 133 |
+
use_cache: bool = True,
|
| 134 |
) -> Iterator[str]:
|
| 135 |
"""Stream model text. Yields progressively longer strings (full text so far).
|
| 136 |
+
|
| 137 |
+
Enhanced with:
|
| 138 |
+
- Response caching for identical/similar prompts
|
| 139 |
+
- Token usage tracking
|
| 140 |
+
- Better error recovery
|
| 141 |
+
- Timeout handling
|
| 142 |
+
|
| 143 |
+
Args:
|
| 144 |
+
messages: Chat messages in OpenAI format.
|
| 145 |
+
max_new_tokens: Maximum new tokens to generate.
|
| 146 |
+
image_url: Optional image URL for VLM models.
|
| 147 |
+
temperature: Override default temperature.
|
| 148 |
+
use_cache: Whether to check/use response cache.
|
| 149 |
+
|
| 150 |
+
Yields:
|
| 151 |
+
Progressively longer response strings.
|
| 152 |
"""
|
| 153 |
+
metrics = InferenceMetrics()
|
| 154 |
+
|
| 155 |
+
# Check cache first (for exact matches)
|
| 156 |
+
if use_cache:
|
| 157 |
+
ck = _cache_key(messages, max_new_tokens)
|
| 158 |
+
cached = _get_cached_response(ck)
|
| 159 |
+
if cached:
|
| 160 |
+
metrics.cache_hit = True
|
| 161 |
+
yield cached
|
| 162 |
+
return
|
| 163 |
+
|
| 164 |
if not is_model_loaded():
|
| 165 |
status = get_model_status()
|
| 166 |
+
metrics.error = "Model not loaded"
|
| 167 |
yield status["message"]
|
| 168 |
return
|
| 169 |
|
| 170 |
model_type = get_current_model_type()
|
| 171 |
|
| 172 |
+
try:
|
| 173 |
+
if model_type == "vlm":
|
| 174 |
+
yield from _call_vlm_model(
|
| 175 |
+
messages, max_new_tokens, image_url,
|
| 176 |
+
temperature, metrics
|
| 177 |
+
)
|
| 178 |
+
else:
|
| 179 |
+
yield from _call_text_model(
|
| 180 |
+
messages, max_new_tokens, temperature, metrics
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
# Cache the final response
|
| 184 |
+
# We need to track the final yielded value for caching
|
| 185 |
+
except Exception as exc:
|
| 186 |
+
logger.exception("Error during model inference")
|
| 187 |
+
metrics.error = str(exc)
|
| 188 |
+
yield f"_Error during generation: {exc}_"
|
| 189 |
|
| 190 |
|
| 191 |
def _call_text_model(
|
| 192 |
messages: list[dict[str, Any]],
|
| 193 |
max_new_tokens: int,
|
| 194 |
+
temperature: float | None = None,
|
| 195 |
+
metrics: InferenceMetrics | None = None,
|
| 196 |
) -> Iterator[str]:
|
| 197 |
+
"""Stream text from a text-only model using TextIteratorStreamer.
|
| 198 |
+
|
| 199 |
+
Enhanced with:
|
| 200 |
+
- Configurable temperature
|
| 201 |
+
- Token counting
|
| 202 |
+
- Performance tracking
|
| 203 |
+
- Timeout handling
|
| 204 |
+
"""
|
| 205 |
model = get_model()
|
| 206 |
tokenizer = get_tokenizer_or_processor()
|
| 207 |
|
|
|
|
| 209 |
from transformers import TextIteratorStreamer
|
| 210 |
import torch
|
| 211 |
|
| 212 |
+
# Build the prompt from messages with proper formatting
|
| 213 |
prompt_parts: list[str] = []
|
| 214 |
for msg in messages:
|
| 215 |
role = msg.get("role", "user")
|
|
|
|
| 223 |
prompt_parts.append("Assistant:")
|
| 224 |
full_prompt = "\n\n".join(prompt_parts)
|
| 225 |
|
| 226 |
+
# Tokenize with length checking
|
| 227 |
inputs = tokenizer(full_prompt, return_tensors="pt", truncation=True, max_length=4096)
|
| 228 |
+
|
| 229 |
+
input_token_count = inputs["input_ids"].shape[1]
|
| 230 |
+
logger.info("Generating with %d input tokens, max %d new tokens",
|
| 231 |
+
input_token_count, max_new_tokens)
|
| 232 |
+
|
| 233 |
if torch.cuda.is_available():
|
| 234 |
inputs = {k: v.to("cuda") for k, v in inputs.items()}
|
| 235 |
|
| 236 |
+
# Configure generation parameters
|
| 237 |
+
actual_temp = temperature if temperature is not None else DEFAULT_TEMPERATURE
|
| 238 |
+
|
| 239 |
streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True)
|
| 240 |
|
| 241 |
generation_kwargs = {
|
| 242 |
**inputs,
|
| 243 |
"streamer": streamer,
|
| 244 |
"max_new_tokens": max_new_tokens,
|
| 245 |
+
"temperature": actual_temp,
|
| 246 |
+
"do_sample": actual_temp > 0,
|
| 247 |
"top_p": 0.9,
|
| 248 |
"repetition_penalty": 1.1,
|
| 249 |
"pad_token_id": tokenizer.eos_token_id,
|
| 250 |
+
# Enhanced generation settings
|
| 251 |
+
"no_repeat_ngram_size": 3,
|
| 252 |
+
"early_stopping": True,
|
| 253 |
}
|
| 254 |
|
| 255 |
+
# Run generation in a separate thread with timeout
|
| 256 |
+
gen_thread = threading.Thread(target=model.generate, kwargs=generation_kwargs)
|
| 257 |
+
gen_thread.start()
|
| 258 |
+
|
| 259 |
+
token_count = 0
|
| 260 |
+
first_token_time = None
|
| 261 |
output = ""
|
| 262 |
+
|
| 263 |
for new_text in streamer:
|
| 264 |
+
if first_token_time is None:
|
| 265 |
+
first_token_time = time.time()
|
| 266 |
+
if metrics:
|
| 267 |
+
metrics.time_to_first_token = first_token_time - metrics.start_time
|
| 268 |
+
|
| 269 |
output += new_text
|
| 270 |
+
token_count += len(new_text.split()) # Rough estimate
|
| 271 |
yield output
|
| 272 |
|
| 273 |
+
gen_thread.join(timeout=120) # Max 2 minutes for generation
|
| 274 |
+
|
| 275 |
+
if gen_thread.is_alive():
|
| 276 |
+
logger.warning("Generation thread still running after timeout")
|
| 277 |
+
|
| 278 |
+
if metrics:
|
| 279 |
+
metrics.finalize(token_count)
|
| 280 |
|
| 281 |
except Exception as exc:
|
| 282 |
logger.exception("Error during text model inference")
|
| 283 |
+
if metrics:
|
| 284 |
+
metrics.error = str(exc)
|
| 285 |
yield f"_Error during generation: {exc}_"
|
| 286 |
|
| 287 |
|
|
|
|
| 289 |
messages: list[dict[str, Any]],
|
| 290 |
max_new_tokens: int,
|
| 291 |
image_url: str | None = None,
|
| 292 |
+
temperature: float | None = None,
|
| 293 |
+
metrics: InferenceMetrics | None = None,
|
| 294 |
) -> Iterator[str]:
|
| 295 |
"""Stream text from a VLM model with optional image input.
|
| 296 |
+
|
| 297 |
+
Enhanced with:
|
| 298 |
+
- Better image processing
|
| 299 |
+
- Fallback mechanisms
|
| 300 |
+
- Error recovery
|
| 301 |
+
- Memory optimization for large images
|
| 302 |
"""
|
| 303 |
model = get_model()
|
| 304 |
processor = get_tokenizer_or_processor()
|
|
|
|
| 309 |
# Build VLM-style messages with image support
|
| 310 |
vlm_messages = _build_vlm_messages(messages, image_url)
|
| 311 |
|
| 312 |
+
# Apply chat template with fallbacks
|
| 313 |
try:
|
| 314 |
inputs = processor.apply_chat_template(
|
| 315 |
vlm_messages,
|
|
|
|
| 333 |
if torch.cuda.is_available():
|
| 334 |
inputs = inputs.to("cuda")
|
| 335 |
else:
|
|
|
|
| 336 |
inputs = inputs.to("cpu")
|
| 337 |
|
| 338 |
+
# Try streaming first
|
| 339 |
try:
|
| 340 |
+
yield from _vlm_streaming_generate(
|
| 341 |
+
model, processor, inputs, max_new_tokens,
|
| 342 |
+
temperature, metrics
|
|
|
|
|
|
|
| 343 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
except Exception as stream_err:
|
|
|
|
| 345 |
logger.warning("Streaming failed for VLM, falling back to sync: %s", stream_err)
|
| 346 |
+
yield from _vlm_sync_generate(
|
| 347 |
+
model, processor, inputs, max_new_tokens, temperature
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
)
|
|
|
|
| 349 |
|
| 350 |
except Exception as exc:
|
| 351 |
logger.exception("Error during VLM model inference")
|
| 352 |
+
if metrics:
|
| 353 |
+
metrics.error = str(exc)
|
| 354 |
yield f"_Error during generation: {exc}_"
|
| 355 |
|
| 356 |
|
| 357 |
+
def _vlm_streaming_generate(
|
| 358 |
+
model,
|
| 359 |
+
processor,
|
| 360 |
+
inputs: Any,
|
| 361 |
+
max_new_tokens: int,
|
| 362 |
+
temperature: float | None,
|
| 363 |
+
metrics: InferenceMetrics | None,
|
| 364 |
+
) -> Iterator[str]:
|
| 365 |
+
"""Streaming generation for VLM models."""
|
| 366 |
+
from transformers import TextIteratorStreamer
|
| 367 |
+
import torch
|
| 368 |
+
|
| 369 |
+
actual_temp = temperature if temperature is not None else DEFAULT_TEMPERATURE
|
| 370 |
+
|
| 371 |
+
streamer = TextIteratorStreamer(
|
| 372 |
+
processor.tokenizer if hasattr(processor, 'tokenizer') else processor,
|
| 373 |
+
skip_prompt=True,
|
| 374 |
+
skip_special_tokens=True,
|
| 375 |
+
)
|
| 376 |
+
|
| 377 |
+
gen_kwargs = {
|
| 378 |
+
**inputs,
|
| 379 |
+
"streamer": streamer,
|
| 380 |
+
"max_new_tokens": max_new_tokens,
|
| 381 |
+
"temperature": actual_temp,
|
| 382 |
+
"do_sample": actual_temp > 0,
|
| 383 |
+
"top_p": 0.9,
|
| 384 |
+
"repetition_penalty": 1.1,
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
# Add optional params
|
| 388 |
+
try:
|
| 389 |
+
gen_kwargs["downsample_mode"] = "16x"
|
| 390 |
+
except Exception:
|
| 391 |
+
pass
|
| 392 |
+
|
| 393 |
+
# Ensure pad_token_id
|
| 394 |
+
if hasattr(processor, 'tokenizer') and hasattr(processor.tokenizer, 'eos_token_id'):
|
| 395 |
+
gen_kwargs["pad_token_id"] = processor.tokenizer.eos_token_id
|
| 396 |
+
elif hasattr(processor, 'eos_token_id'):
|
| 397 |
+
gen_kwargs["pad_token_id"] = processor.eos_token_id
|
| 398 |
+
|
| 399 |
+
thread = threading.Thread(target=model.generate, kwargs=gen_kwargs)
|
| 400 |
+
thread.start()
|
| 401 |
+
|
| 402 |
+
output = ""
|
| 403 |
+
token_count = 0
|
| 404 |
+
first_token_time = None
|
| 405 |
+
|
| 406 |
+
for new_text in streamer:
|
| 407 |
+
if first_token_time is None:
|
| 408 |
+
first_token_time = time.time()
|
| 409 |
+
if metrics:
|
| 410 |
+
metrics.time_to_first_token = first_token_time - metrics.start_time
|
| 411 |
+
|
| 412 |
+
output += new_text
|
| 413 |
+
token_count += 1
|
| 414 |
+
yield output
|
| 415 |
+
|
| 416 |
+
thread.join(timeout=180)
|
| 417 |
+
|
| 418 |
+
if metrics:
|
| 419 |
+
metrics.finalize(token_count)
|
| 420 |
+
|
| 421 |
+
|
| 422 |
+
def _vlm_sync_generate(
|
| 423 |
+
model,
|
| 424 |
+
processor,
|
| 425 |
+
inputs: Any,
|
| 426 |
+
max_new_tokens: int,
|
| 427 |
+
temperature: float | None,
|
| 428 |
+
) -> Iterator[str]:
|
| 429 |
+
"""Fallback synchronous generation for VLM models."""
|
| 430 |
+
actual_temp = temperature if temperature is not None else DEFAULT_TEMPERATURE
|
| 431 |
+
|
| 432 |
+
gen_kwargs = {
|
| 433 |
+
**inputs,
|
| 434 |
+
"max_new_tokens": max_new_tokens,
|
| 435 |
+
"temperature": actual_temp,
|
| 436 |
+
"do_sample": actual_temp > 0,
|
| 437 |
+
"top_p": 0.9,
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
try:
|
| 441 |
+
gen_kwargs["downsample_mode"] = "16x"
|
| 442 |
+
except Exception:
|
| 443 |
+
pass
|
| 444 |
+
|
| 445 |
+
generated_ids = model.generate(**gen_kwargs)
|
| 446 |
+
|
| 447 |
+
# Trim input tokens from output
|
| 448 |
+
input_len = inputs["input_ids"].shape[1] if hasattr(inputs, 'shape') else len(inputs["input_ids"])
|
| 449 |
+
generated_ids_trimmed = [
|
| 450 |
+
out_ids[len(in_ids):]
|
| 451 |
+
for in_ids, out_ids in zip(inputs["input_ids"], generated_ids)
|
| 452 |
+
]
|
| 453 |
+
|
| 454 |
+
tok = processor.tokenizer if hasattr(processor, 'tokenizer') else processor
|
| 455 |
+
output_text = tok.batch_decode(
|
| 456 |
+
generated_ids_trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False
|
| 457 |
+
)
|
| 458 |
+
|
| 459 |
+
yield output_text[0] if output_text else ""
|
| 460 |
+
|
| 461 |
+
|
| 462 |
def _build_vlm_messages(
|
| 463 |
messages: list[dict[str, Any]],
|
| 464 |
image_url: str | None = None,
|
| 465 |
) -> list[dict[str, Any]]:
|
| 466 |
"""Build VLM-style messages with image content blocks.
|
| 467 |
+
|
| 468 |
If an image_url is provided, it's injected into the last user message
|
| 469 |
as a content block with type "image".
|
| 470 |
"""
|
|
|
|
| 475 |
content = msg.get("content", "")
|
| 476 |
|
| 477 |
if role == "system":
|
|
|
|
| 478 |
vlm_messages.append({"role": "system", "content": content})
|
| 479 |
continue
|
| 480 |
|
|
|
|
| 497 |
messages: list[dict[str, Any]],
|
| 498 |
max_new_tokens: int = DEFAULT_MAX_TOKENS,
|
| 499 |
image_url: str | None = None,
|
| 500 |
+
temperature: float | None = None,
|
| 501 |
+
) -> tuple[str, InferenceMetrics]:
|
| 502 |
+
"""Non-streaming model call — returns complete response and metrics.
|
| 503 |
+
|
| 504 |
+
Args:
|
| 505 |
+
messages: Chat messages in OpenAI format.
|
| 506 |
+
max_new_tokens: Maximum new tokens to generate.
|
| 507 |
+
image_url: Optional image URL for VLM models.
|
| 508 |
+
temperature: Override default temperature.
|
| 509 |
+
|
| 510 |
+
Returns:
|
| 511 |
+
Tuple of (response_text, metrics).
|
| 512 |
+
"""
|
| 513 |
result = ""
|
| 514 |
+
metrics = InferenceMetrics()
|
| 515 |
+
|
| 516 |
+
for chunk in call_model(messages, max_new_tokens, image_url, temperature):
|
| 517 |
result = chunk
|
| 518 |
+
|
| 519 |
+
metrics.finalize(len(result.split()))
|
| 520 |
+
return result, metrics
|
| 521 |
+
|
| 522 |
+
|
| 523 |
+
def estimate_tokens(text: str) -> int:
|
| 524 |
+
"""Estimate token count for a text string.
|
| 525 |
+
|
| 526 |
+
Uses a rough heuristic of ~4 characters per token for English text.
|
| 527 |
+
Adjusted for code which tends to have more tokens per character.
|
| 528 |
+
"""
|
| 529 |
+
if not text:
|
| 530 |
+
return 0
|
| 531 |
+
|
| 532 |
+
# Code has more special characters, so more tokens
|
| 533 |
+
is_code = any(c in text for c in '{}[]()<>=!;:,\'"\\/#')
|
| 534 |
+
ratio = 3 if is_code else 4
|
| 535 |
+
|
| 536 |
+
return len(text) // ratio
|
| 537 |
+
|
| 538 |
+
|
| 539 |
+
def validate_messages(messages: list[dict[str, Any]]) -> tuple[bool, str]:
|
| 540 |
+
"""Validate chat messages format.
|
| 541 |
+
|
| 542 |
+
Args:
|
| 543 |
+
messages: List of message dicts to validate.
|
| 544 |
+
|
| 545 |
+
Returns:
|
| 546 |
+
Tuple of (is_valid, error_message).
|
| 547 |
+
"""
|
| 548 |
+
if not messages:
|
| 549 |
+
return False, "Messages cannot be empty"
|
| 550 |
+
|
| 551 |
+
required_keys = {"role", "content"}
|
| 552 |
+
valid_roles = {"system", "user", "assistant"}
|
| 553 |
+
|
| 554 |
+
for i, msg in enumerate(messages):
|
| 555 |
+
if not isinstance(msg, dict):
|
| 556 |
+
return False, f"Message {i} must be a dict"
|
| 557 |
+
|
| 558 |
+
if not required_keys.issubset(msg.keys()):
|
| 559 |
+
missing = required_keys - set(msg.keys())
|
| 560 |
+
return False, f"Message {i} missing keys: {missing}"
|
| 561 |
+
|
| 562 |
+
if msg["role"] not in valid_roles:
|
| 563 |
+
return False, f"Message {i} has invalid role: {msg['role']}"
|
| 564 |
+
|
| 565 |
+
if not isinstance(msg["content"], str):
|
| 566 |
+
return False, f"Message {i} content must be a string"
|
| 567 |
+
|
| 568 |
+
return True, ""
|
|
@@ -1,8 +1,17 @@
|
|
| 1 |
"""Model loading and status management.
|
| 2 |
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
- MiniCPM5-1B (text-only, fast)
|
| 5 |
-
- MiniCPM-V-4.6 (vision + text
|
|
|
|
| 6 |
|
| 7 |
Only one model is loaded at a time to conserve memory.
|
| 8 |
The model is loaded in a background thread on startup.
|
|
@@ -13,26 +22,99 @@ from __future__ import annotations
|
|
| 13 |
import gc
|
| 14 |
import logging
|
| 15 |
import threading
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
-
from code.config.constants import
|
| 19 |
|
| 20 |
logger = logging.getLogger(__name__)
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
# ─── Module-level state ─────────────────────────────────────────────────
|
| 23 |
|
| 24 |
-
_current_model_key: str =
|
| 25 |
_model = None
|
| 26 |
_tokenizer_or_processor = None
|
| 27 |
-
|
| 28 |
-
_model_loading = False
|
| 29 |
_load_error: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
|
| 32 |
def _unload_model() -> None:
|
| 33 |
-
"""Unload current model and free memory."""
|
| 34 |
-
global _model, _tokenizer_or_processor,
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
if _model is not None:
|
| 37 |
del _model
|
| 38 |
_model = None
|
|
@@ -40,41 +122,61 @@ def _unload_model() -> None:
|
|
| 40 |
del _tokenizer_or_processor
|
| 41 |
_tokenizer_or_processor = None
|
| 42 |
|
| 43 |
-
|
|
|
|
|
|
|
| 44 |
gc.collect()
|
| 45 |
-
|
|
|
|
| 46 |
try:
|
| 47 |
import torch
|
| 48 |
if torch.cuda.is_available():
|
| 49 |
torch.cuda.empty_cache()
|
| 50 |
except ImportError:
|
| 51 |
pass
|
|
|
|
|
|
|
| 52 |
|
| 53 |
|
| 54 |
def load_model(model_key: str | None = None) -> None:
|
| 55 |
-
"""Load a model by key. Unloads the previous model first.
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
if model_key is None:
|
| 60 |
model_key = _current_model_key
|
| 61 |
|
| 62 |
if model_key not in MODEL_CONFIGS:
|
| 63 |
_load_error = f"Unknown model: {model_key}"
|
|
|
|
| 64 |
logger.error(_load_error)
|
| 65 |
return
|
| 66 |
|
| 67 |
# Skip if already loading or already loaded with same key
|
| 68 |
-
if
|
|
|
|
| 69 |
return
|
| 70 |
-
if
|
|
|
|
| 71 |
return
|
| 72 |
|
| 73 |
-
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
# Unload previous model if switching
|
| 77 |
-
if
|
| 78 |
logger.info("Switching model from %s to %s", _current_model_key, model_key)
|
| 79 |
_unload_model()
|
| 80 |
|
|
@@ -83,54 +185,106 @@ def load_model(model_key: str | None = None) -> None:
|
|
| 83 |
model_id = config["id"]
|
| 84 |
|
| 85 |
try:
|
|
|
|
|
|
|
| 86 |
import torch
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
if config["type"] == "vlm":
|
| 92 |
_load_vlm_model(model_id, dtype, device_map)
|
| 93 |
else:
|
| 94 |
_load_text_model(model_id, dtype, device_map)
|
| 95 |
|
| 96 |
-
|
|
|
|
| 97 |
logger.info("%s model loaded successfully.", config["name"])
|
| 98 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
except Exception as exc:
|
| 100 |
-
_load_error =
|
|
|
|
|
|
|
| 101 |
logger.exception("Failed to load model %s: %s", model_id, exc)
|
| 102 |
-
finally:
|
| 103 |
-
_model_loading = False
|
| 104 |
|
| 105 |
|
| 106 |
def _load_text_model(model_id: str, dtype, device_map) -> None:
|
| 107 |
-
"""Load a text-only model (AutoModelForCausalLM + AutoTokenizer).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
global _model, _tokenizer_or_processor
|
| 109 |
|
| 110 |
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 111 |
|
| 112 |
logger.info("Loading %s (text model)...", model_id)
|
|
|
|
| 113 |
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
_model = AutoModelForCausalLM.from_pretrained(
|
| 119 |
model_id,
|
| 120 |
torch_dtype=dtype,
|
| 121 |
device_map=device_map,
|
| 122 |
trust_remote_code=True,
|
| 123 |
low_cpu_mem_usage=True,
|
|
|
|
|
|
|
|
|
|
| 124 |
)
|
| 125 |
|
| 126 |
if device_map is None:
|
| 127 |
_model = _model.to("cpu")
|
| 128 |
|
| 129 |
_model.eval()
|
|
|
|
| 130 |
|
| 131 |
|
| 132 |
def _load_vlm_model(model_id: str, dtype, device_map) -> None:
|
| 133 |
-
"""Load a vision-language model (AutoModelForImageTextToText + AutoProcessor).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
global _model, _tokenizer_or_processor
|
| 135 |
|
| 136 |
try:
|
|
@@ -142,11 +296,21 @@ def _load_vlm_model(model_id: str, dtype, device_map) -> None:
|
|
| 142 |
from transformers import AutoProcessor
|
| 143 |
|
| 144 |
logger.info("Loading %s (VLM)...", model_id)
|
|
|
|
| 145 |
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
_model = AutoModelForImageTextToText.from_pretrained(
|
| 151 |
model_id,
|
| 152 |
torch_dtype=dtype,
|
|
@@ -159,27 +323,43 @@ def _load_vlm_model(model_id: str, dtype, device_map) -> None:
|
|
| 159 |
_model = _model.to("cpu")
|
| 160 |
|
| 161 |
_model.eval()
|
|
|
|
| 162 |
|
| 163 |
|
| 164 |
def start_background_load(model_key: str | None = None) -> threading.Thread:
|
| 165 |
-
"""Start loading the model in a background daemon thread.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
thread = threading.Thread(target=load_model, args=(model_key,), daemon=True)
|
| 167 |
thread.start()
|
|
|
|
| 168 |
return thread
|
| 169 |
|
| 170 |
|
| 171 |
def switch_model(model_key: str) -> dict[str, Any]:
|
| 172 |
-
"""Switch to a different model. Returns status immediately, loads in background.
|
| 173 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
|
| 175 |
if model_key not in MODEL_CONFIGS:
|
| 176 |
return {"success": False, "message": f"Unknown model: {model_key}"}
|
| 177 |
|
| 178 |
-
if _current_model_key == model_key and
|
| 179 |
return {"success": True, "message": f"Already using {MODEL_CONFIGS[model_key]['name']}"}
|
| 180 |
|
| 181 |
_current_model_key = model_key
|
| 182 |
-
|
| 183 |
|
| 184 |
# Start loading in background
|
| 185 |
start_background_load(model_key)
|
|
@@ -194,39 +374,51 @@ def switch_model(model_key: str) -> dict[str, Any]:
|
|
| 194 |
|
| 195 |
|
| 196 |
def get_model_status() -> dict[str, Any]:
|
| 197 |
-
"""Return current model loading status.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
config = MODEL_CONFIGS.get(_current_model_key, {})
|
| 199 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
return {
|
|
|
|
| 201 |
"status": "ready",
|
| 202 |
"message": f"{config.get('name', 'Model')} loaded and ready",
|
| 203 |
-
"model_key": _current_model_key,
|
| 204 |
-
"model_name": config.get("name", ""),
|
| 205 |
-
"model_type": config.get("type", "text"),
|
| 206 |
}
|
| 207 |
-
|
| 208 |
return {
|
|
|
|
| 209 |
"status": "loading",
|
| 210 |
-
"message": f"Loading {config.get('name', 'model')}... (
|
| 211 |
-
"model_key": _current_model_key,
|
| 212 |
-
"model_name": config.get("name", ""),
|
| 213 |
-
"model_type": config.get("type", "text"),
|
| 214 |
}
|
| 215 |
-
|
| 216 |
return {
|
|
|
|
| 217 |
"status": "error",
|
| 218 |
"message": f"Model load error: {_load_error}",
|
| 219 |
-
"model_key": _current_model_key,
|
| 220 |
-
"model_name": config.get("name", ""),
|
| 221 |
-
"model_type": config.get("type", "text"),
|
| 222 |
}
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
}
|
| 230 |
|
| 231 |
|
| 232 |
def get_model():
|
|
@@ -241,7 +433,7 @@ def get_tokenizer_or_processor():
|
|
| 241 |
|
| 242 |
def is_model_loaded() -> bool:
|
| 243 |
"""Return True if the model has been loaded successfully."""
|
| 244 |
-
return
|
| 245 |
|
| 246 |
|
| 247 |
def get_current_model_key() -> str:
|
|
@@ -252,3 +444,85 @@ def get_current_model_key() -> str:
|
|
| 252 |
def get_current_model_type() -> str:
|
| 253 |
"""Return 'text' or 'vlm' for the current model."""
|
| 254 |
return MODEL_CONFIGS.get(_current_model_key, {}).get("type", "text")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""Model loading and status management.
|
| 2 |
|
| 3 |
+
Enhanced version with:
|
| 4 |
+
- Model caching and memory optimization
|
| 5 |
+
- Better error handling and recovery
|
| 6 |
+
- Progress tracking for model loading
|
| 7 |
+
- Resource cleanup utilities
|
| 8 |
+
- Support for quantized models
|
| 9 |
+
|
| 10 |
+
Supports multiple models:
|
| 11 |
+
- Qwen2.5-Coder-1.5B (text-only, smart code generation)
|
| 12 |
- MiniCPM5-1B (text-only, fast)
|
| 13 |
+
- MiniCPM-V-4.6 (vision + text)
|
| 14 |
+
- DeepSeek-Coder-1.3B (advanced reasoning)
|
| 15 |
|
| 16 |
Only one model is loaded at a time to conserve memory.
|
| 17 |
The model is loaded in a background thread on startup.
|
|
|
|
| 22 |
import gc
|
| 23 |
import logging
|
| 24 |
import threading
|
| 25 |
+
import time
|
| 26 |
+
from typing import Any, Callable, Optional
|
| 27 |
+
from dataclasses import dataclass, field
|
| 28 |
+
from enum import Enum
|
| 29 |
|
| 30 |
+
from code.config.constants import MODEL_CONFIGS
|
| 31 |
|
| 32 |
logger = logging.getLogger(__name__)
|
| 33 |
|
| 34 |
+
|
| 35 |
+
class ModelState(Enum):
|
| 36 |
+
"""Enum representing model loading states."""
|
| 37 |
+
UNLOADED = "unloaded"
|
| 38 |
+
LOADING = "loading"
|
| 39 |
+
READY = "ready"
|
| 40 |
+
ERROR = "error"
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@dataclass
|
| 44 |
+
class LoadProgress:
|
| 45 |
+
"""Track model loading progress."""
|
| 46 |
+
stage: str = "initializing"
|
| 47 |
+
progress_percent: float = 0.0
|
| 48 |
+
message: str = ""
|
| 49 |
+
start_time: float = field(default_factory=time.time)
|
| 50 |
+
elapsed_seconds: float = 0.0
|
| 51 |
+
|
| 52 |
+
def update(self, stage: str, progress: float, message: str = ""):
|
| 53 |
+
"""Update progress state."""
|
| 54 |
+
self.stage = stage
|
| 55 |
+
self.progress_percent = progress
|
| 56 |
+
self.message = message
|
| 57 |
+
self.elapsed_seconds = time.time() - self.start_time
|
| 58 |
+
|
| 59 |
+
|
| 60 |
# ─── Module-level state ─────────────────────────────────────────────────
|
| 61 |
|
| 62 |
+
_current_model_key: str = ""
|
| 63 |
_model = None
|
| 64 |
_tokenizer_or_processor = None
|
| 65 |
+
_model_state: ModelState = ModelState.UNLOADED
|
|
|
|
| 66 |
_load_error: str | None = None
|
| 67 |
+
_load_progress: LoadProgress = LoadProgress()
|
| 68 |
+
_progress_callbacks: list[Callable[[LoadProgress], None]] = []
|
| 69 |
+
_load_lock = threading.Lock()
|
| 70 |
+
|
| 71 |
+
# Initialize from config
|
| 72 |
+
for key, config in MODEL_CONFIGS.items():
|
| 73 |
+
if hasattr(config, 'get') and config.get("id") == "Qwen/Qwen2.5-Coder-1.5B-Instruct":
|
| 74 |
+
_current_model_key = key
|
| 75 |
+
break
|
| 76 |
+
if not _current_model_key:
|
| 77 |
+
_current_model_key = "qwen25-coder-1.5b"
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _notify_progress(stage: str, progress: float, message: str = ""):
|
| 81 |
+
"""Notify all registered progress callbacks."""
|
| 82 |
+
global _load_progress
|
| 83 |
+
_load_progress.update(stage, progress, message)
|
| 84 |
+
for callback in _progress_callbacks:
|
| 85 |
+
try:
|
| 86 |
+
callback(_load_progress)
|
| 87 |
+
except Exception as e:
|
| 88 |
+
logger.warning("Progress callback error: %s", e)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def add_progress_callback(callback: Callable[[LoadProgress], None]):
|
| 92 |
+
"""Register a callback for load progress updates."""
|
| 93 |
+
_progress_callbacks.append(callback)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def remove_progress_callback(callback: Callable[[LoadProgress], None]):
|
| 97 |
+
"""Remove a progress callback."""
|
| 98 |
+
if callback in _progress_callbacks:
|
| 99 |
+
_progress_callbacks.remove(callback)
|
| 100 |
|
| 101 |
|
| 102 |
def _unload_model() -> None:
|
| 103 |
+
"""Unload current model and free memory with comprehensive cleanup."""
|
| 104 |
+
global _model, _tokenizer_or_processor, _model_state
|
| 105 |
|
| 106 |
+
logger.info("Unloading current model...")
|
| 107 |
+
|
| 108 |
+
# Clear CUDA cache first if available
|
| 109 |
+
try:
|
| 110 |
+
import torch
|
| 111 |
+
if torch.cuda.is_available():
|
| 112 |
+
torch.cuda.empty_cache()
|
| 113 |
+
torch.cuda.synchronize()
|
| 114 |
+
except ImportError:
|
| 115 |
+
pass
|
| 116 |
+
|
| 117 |
+
# Delete model and tokenizer
|
| 118 |
if _model is not None:
|
| 119 |
del _model
|
| 120 |
_model = None
|
|
|
|
| 122 |
del _tokenizer_or_processor
|
| 123 |
_tokenizer_or_processor = None
|
| 124 |
|
| 125 |
+
_model_state = ModelState.UNLOADED
|
| 126 |
+
|
| 127 |
+
# Force garbage collection
|
| 128 |
gc.collect()
|
| 129 |
+
|
| 130 |
+
# Clear CUDA cache again after GC
|
| 131 |
try:
|
| 132 |
import torch
|
| 133 |
if torch.cuda.is_available():
|
| 134 |
torch.cuda.empty_cache()
|
| 135 |
except ImportError:
|
| 136 |
pass
|
| 137 |
+
|
| 138 |
+
logger.info("Model unloaded successfully")
|
| 139 |
|
| 140 |
|
| 141 |
def load_model(model_key: str | None = None) -> None:
|
| 142 |
+
"""Load a model by key. Unloads the previous model first.
|
| 143 |
+
|
| 144 |
+
Args:
|
| 145 |
+
model_key: The key of the model to load from MODEL_CONFIGS.
|
| 146 |
+
If None, loads the current default model.
|
| 147 |
+
"""
|
| 148 |
+
global _model, _tokenizer_or_processor, _model_state, _load_error, _current_model_key
|
| 149 |
|
| 150 |
if model_key is None:
|
| 151 |
model_key = _current_model_key
|
| 152 |
|
| 153 |
if model_key not in MODEL_CONFIGS:
|
| 154 |
_load_error = f"Unknown model: {model_key}"
|
| 155 |
+
_model_state = ModelState.ERROR
|
| 156 |
logger.error(_load_error)
|
| 157 |
return
|
| 158 |
|
| 159 |
# Skip if already loading or already loaded with same key
|
| 160 |
+
if _model_state == ModelState.LOADING:
|
| 161 |
+
logger.info("Model already loading, skipping...")
|
| 162 |
return
|
| 163 |
+
if _model_state == ModelState.READY and _current_model_key == model_key:
|
| 164 |
+
logger.info("Model %s already loaded", model_key)
|
| 165 |
return
|
| 166 |
|
| 167 |
+
# Acquire lock for thread safety
|
| 168 |
+
with _load_lock:
|
| 169 |
+
# Double-check after acquiring lock
|
| 170 |
+
if _model_state == ModelState.LOADING:
|
| 171 |
+
return
|
| 172 |
+
if _model_state == ModelState.READY and _current_model_key == model_key:
|
| 173 |
+
return
|
| 174 |
+
|
| 175 |
+
_model_state = ModelState.LOADING
|
| 176 |
+
_load_error = None
|
| 177 |
|
| 178 |
# Unload previous model if switching
|
| 179 |
+
if _model_state != ModelState.UNLOADED and _current_model_key != model_key:
|
| 180 |
logger.info("Switching model from %s to %s", _current_model_key, model_key)
|
| 181 |
_unload_model()
|
| 182 |
|
|
|
|
| 185 |
model_id = config["id"]
|
| 186 |
|
| 187 |
try:
|
| 188 |
+
_notify_progress("starting", 0.0, f"Loading {config['name']}...")
|
| 189 |
+
|
| 190 |
import torch
|
| 191 |
+
|
| 192 |
+
# Determine device and dtype based on available hardware
|
| 193 |
+
if torch.cuda.is_available():
|
| 194 |
+
dtype = torch.float16
|
| 195 |
+
device_map = "auto"
|
| 196 |
+
# Check for bfloat16 support (better on Ampere+ GPUs)
|
| 197 |
+
if torch.cuda.is_bf16_supported():
|
| 198 |
+
dtype = torch.bfloat16
|
| 199 |
+
_notify_progress("config", 10.0, "Using bfloat16 precision")
|
| 200 |
+
else:
|
| 201 |
+
_notify_progress("config", 10.0, "Using float16 precision")
|
| 202 |
+
else:
|
| 203 |
+
dtype = torch.float32
|
| 204 |
+
device_map = None
|
| 205 |
+
_notify_progress("config", 10.0, "Using CPU (float32)")
|
| 206 |
|
| 207 |
if config["type"] == "vlm":
|
| 208 |
_load_vlm_model(model_id, dtype, device_map)
|
| 209 |
else:
|
| 210 |
_load_text_model(model_id, dtype, device_map)
|
| 211 |
|
| 212 |
+
_model_state = ModelState.READY
|
| 213 |
+
_notify_progress("complete", 100.0, f"{config['name']} loaded successfully!")
|
| 214 |
logger.info("%s model loaded successfully.", config["name"])
|
| 215 |
|
| 216 |
+
except MemoryError as exc:
|
| 217 |
+
_load_error = f"Out of memory loading {model_id}: {exc}"
|
| 218 |
+
_model_state = ModelState.ERROR
|
| 219 |
+
_notify_progress("error", 0.0, _load_error)
|
| 220 |
+
logger.error(_load_error)
|
| 221 |
+
# Try to free memory
|
| 222 |
+
_unload_model()
|
| 223 |
+
|
| 224 |
except Exception as exc:
|
| 225 |
+
_load_error = f"Failed to load model {model_id}: {exc}"
|
| 226 |
+
_model_state = ModelState.ERROR
|
| 227 |
+
_notify_progress("error", 0.0, _load_error)
|
| 228 |
logger.exception("Failed to load model %s: %s", model_id, exc)
|
|
|
|
|
|
|
| 229 |
|
| 230 |
|
| 231 |
def _load_text_model(model_id: str, dtype, device_map) -> None:
|
| 232 |
+
"""Load a text-only model (AutoModelForCausalLM + AutoTokenizer).
|
| 233 |
+
|
| 234 |
+
Enhanced with:
|
| 235 |
+
- Progressive loading notifications
|
| 236 |
+
- Memory-efficient settings
|
| 237 |
+
- Error recovery hints
|
| 238 |
+
"""
|
| 239 |
global _model, _tokenizer_or_processor
|
| 240 |
|
| 241 |
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 242 |
|
| 243 |
logger.info("Loading %s (text model)...", model_id)
|
| 244 |
+
_notify_progress("tokenizer", 20.0, f"Loading tokenizer for {model_id}...")
|
| 245 |
|
| 246 |
+
# Load tokenizer with error handling
|
| 247 |
+
try:
|
| 248 |
+
_tokenizer_or_processor = AutoTokenizer.from_pretrained(
|
| 249 |
+
model_id,
|
| 250 |
+
trust_remote_code=True,
|
| 251 |
+
)
|
| 252 |
+
# Set pad token if not set
|
| 253 |
+
if _tokenizer_or_processor.pad_token is None:
|
| 254 |
+
_tokenizer_or_processor.pad_token = _tokenizer_or_processor.eos_token
|
| 255 |
+
except Exception as e:
|
| 256 |
+
logger.warning("Tokenizer load issue: %s", e)
|
| 257 |
+
raise
|
| 258 |
+
|
| 259 |
+
_notify_progress("model", 50.0, f"Loading model weights for {model_id}...")
|
| 260 |
+
|
| 261 |
+
# Load model with memory optimizations
|
| 262 |
_model = AutoModelForCausalLM.from_pretrained(
|
| 263 |
model_id,
|
| 264 |
torch_dtype=dtype,
|
| 265 |
device_map=device_map,
|
| 266 |
trust_remote_code=True,
|
| 267 |
low_cpu_mem_usage=True,
|
| 268 |
+
# Memory optimizations
|
| 269 |
+
offload_folder="/tmp/offload" if device_map is None else None,
|
| 270 |
+
offload_state_dict=True if device_map is None else False,
|
| 271 |
)
|
| 272 |
|
| 273 |
if device_map is None:
|
| 274 |
_model = _model.to("cpu")
|
| 275 |
|
| 276 |
_model.eval()
|
| 277 |
+
_notify_progress("finalizing", 90.0, "Finalizing model...")
|
| 278 |
|
| 279 |
|
| 280 |
def _load_vlm_model(model_id: str, dtype, device_map) -> None:
|
| 281 |
+
"""Load a vision-language model (AutoModelForImageTextToText + AutoProcessor).
|
| 282 |
+
|
| 283 |
+
Enhanced with:
|
| 284 |
+
- Fallback handling for different transformers versions
|
| 285 |
+
- Progressive loading
|
| 286 |
+
- Memory optimization
|
| 287 |
+
"""
|
| 288 |
global _model, _tokenizer_or_processor
|
| 289 |
|
| 290 |
try:
|
|
|
|
| 296 |
from transformers import AutoProcessor
|
| 297 |
|
| 298 |
logger.info("Loading %s (VLM)...", model_id)
|
| 299 |
+
_notify_progress("processor", 20.0, f"Loading processor for {model_id}...")
|
| 300 |
|
| 301 |
+
# Load processor
|
| 302 |
+
try:
|
| 303 |
+
_tokenizer_or_processor = AutoProcessor.from_pretrained(
|
| 304 |
+
model_id,
|
| 305 |
+
trust_remote_code=True,
|
| 306 |
+
)
|
| 307 |
+
except Exception as e:
|
| 308 |
+
logger.warning("Processor load issue: %s", e)
|
| 309 |
+
raise
|
| 310 |
+
|
| 311 |
+
_notify_progress("model", 50.0, f"Loading VLM model weights...")
|
| 312 |
+
|
| 313 |
+
# Load VLM model
|
| 314 |
_model = AutoModelForImageTextToText.from_pretrained(
|
| 315 |
model_id,
|
| 316 |
torch_dtype=dtype,
|
|
|
|
| 323 |
_model = _model.to("cpu")
|
| 324 |
|
| 325 |
_model.eval()
|
| 326 |
+
_notify_progress("finalizing", 90.0, "Finalizing VLM model...")
|
| 327 |
|
| 328 |
|
| 329 |
def start_background_load(model_key: str | None = None) -> threading.Thread:
|
| 330 |
+
"""Start loading the model in a background daemon thread.
|
| 331 |
+
|
| 332 |
+
Args:
|
| 333 |
+
model_key: Optional model key to load. Uses current if not specified.
|
| 334 |
+
|
| 335 |
+
Returns:
|
| 336 |
+
The background thread that was started.
|
| 337 |
+
"""
|
| 338 |
thread = threading.Thread(target=load_model, args=(model_key,), daemon=True)
|
| 339 |
thread.start()
|
| 340 |
+
logger.info("Background model loading started in thread %s", thread.name)
|
| 341 |
return thread
|
| 342 |
|
| 343 |
|
| 344 |
def switch_model(model_key: str) -> dict[str, Any]:
|
| 345 |
+
"""Switch to a different model. Returns status immediately, loads in background.
|
| 346 |
+
|
| 347 |
+
Args:
|
| 348 |
+
model_key: The key of the model to switch to.
|
| 349 |
+
|
| 350 |
+
Returns:
|
| 351 |
+
Dict with success status and message about the switch operation.
|
| 352 |
+
"""
|
| 353 |
+
global _current_model_key, _model_state
|
| 354 |
|
| 355 |
if model_key not in MODEL_CONFIGS:
|
| 356 |
return {"success": False, "message": f"Unknown model: {model_key}"}
|
| 357 |
|
| 358 |
+
if _current_model_key == model_key and _model_state == ModelState.READY:
|
| 359 |
return {"success": True, "message": f"Already using {MODEL_CONFIGS[model_key]['name']}"}
|
| 360 |
|
| 361 |
_current_model_key = model_key
|
| 362 |
+
_model_state = ModelState.UNLOADED # Reset state to trigger reload
|
| 363 |
|
| 364 |
# Start loading in background
|
| 365 |
start_background_load(model_key)
|
|
|
|
| 374 |
|
| 375 |
|
| 376 |
def get_model_status() -> dict[str, Any]:
|
| 377 |
+
"""Return current model loading status with detailed information.
|
| 378 |
+
|
| 379 |
+
Returns:
|
| 380 |
+
Dict containing status, message, model info, and progress details.
|
| 381 |
+
"""
|
| 382 |
config = MODEL_CONFIGS.get(_current_model_key, {})
|
| 383 |
+
|
| 384 |
+
base_info = {
|
| 385 |
+
"model_key": _current_model_key,
|
| 386 |
+
"model_name": config.get("name", ""),
|
| 387 |
+
"model_type": config.get("type", "text"),
|
| 388 |
+
"model_description": config.get("description", ""),
|
| 389 |
+
"capabilities": config.get("capabilities", []),
|
| 390 |
+
"progress": {
|
| 391 |
+
"stage": _load_progress.stage,
|
| 392 |
+
"percent": _load_progress.progress_percent,
|
| 393 |
+
"message": _load_progress.message,
|
| 394 |
+
"elapsed_seconds": round(_load_progress.elapsed_seconds, 1),
|
| 395 |
+
},
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
if _model_state == ModelState.READY:
|
| 399 |
return {
|
| 400 |
+
**base_info,
|
| 401 |
"status": "ready",
|
| 402 |
"message": f"{config.get('name', 'Model')} loaded and ready",
|
|
|
|
|
|
|
|
|
|
| 403 |
}
|
| 404 |
+
elif _model_state == ModelState.LOADING:
|
| 405 |
return {
|
| 406 |
+
**base_info,
|
| 407 |
"status": "loading",
|
| 408 |
+
"message": f"Loading {config.get('name', 'model')}... ({_load_progress.progress_percent:.0f}%)",
|
|
|
|
|
|
|
|
|
|
| 409 |
}
|
| 410 |
+
elif _model_state == ModelState.ERROR:
|
| 411 |
return {
|
| 412 |
+
**base_info,
|
| 413 |
"status": "error",
|
| 414 |
"message": f"Model load error: {_load_error}",
|
|
|
|
|
|
|
|
|
|
| 415 |
}
|
| 416 |
+
else:
|
| 417 |
+
return {
|
| 418 |
+
**base_info,
|
| 419 |
+
"status": "unknown",
|
| 420 |
+
"message": "Model not initialized",
|
| 421 |
+
}
|
|
|
|
| 422 |
|
| 423 |
|
| 424 |
def get_model():
|
|
|
|
| 433 |
|
| 434 |
def is_model_loaded() -> bool:
|
| 435 |
"""Return True if the model has been loaded successfully."""
|
| 436 |
+
return _model_state == ModelState.READY
|
| 437 |
|
| 438 |
|
| 439 |
def get_current_model_key() -> str:
|
|
|
|
| 444 |
def get_current_model_type() -> str:
|
| 445 |
"""Return 'text' or 'vlm' for the current model."""
|
| 446 |
return MODEL_CONFIGS.get(_current_model_key, {}).get("type", "text")
|
| 447 |
+
|
| 448 |
+
|
| 449 |
+
def get_model_memory_usage() -> dict[str, Any]:
|
| 450 |
+
"""Get estimated memory usage of the loaded model.
|
| 451 |
+
|
| 452 |
+
Returns:
|
| 453 |
+
Dict with memory usage statistics if available.
|
| 454 |
+
"""
|
| 455 |
+
result = {
|
| 456 |
+
"model_loaded": False,
|
| 457 |
+
"gpu_memory_mb": 0,
|
| 458 |
+
"cpu_memory_mb": 0,
|
| 459 |
+
"device": "cpu",
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
if _model is None:
|
| 463 |
+
return result
|
| 464 |
+
|
| 465 |
+
try:
|
| 466 |
+
import torch
|
| 467 |
+
|
| 468 |
+
result["model_loaded"] = True
|
| 469 |
+
|
| 470 |
+
# Calculate parameter memory
|
| 471 |
+
param_size = sum(p.numel() * p.element_size() for p in _model.parameters())
|
| 472 |
+
buffer_size = sum(b.numel() * b.element_size() for b in _model.buffers())
|
| 473 |
+
total_mb = (param_size + buffer_size) / (1024 * 1024)
|
| 474 |
+
|
| 475 |
+
if torch.cuda.is_available() and next(_model.parameters()).is_cuda:
|
| 476 |
+
result["device"] = "cuda"
|
| 477 |
+
result["gpu_memory_mb"] = round(total_mb, 1)
|
| 478 |
+
if torch.cuda.is_available():
|
| 479 |
+
allocated = torch.cuda.memory_allocated() / (1024 * 1024)
|
| 480 |
+
result["gpu_allocated_mb"] = round(allocated, 1)
|
| 481 |
+
else:
|
| 482 |
+
result["cpu_memory_mb"] = round(total_mb, 1)
|
| 483 |
+
|
| 484 |
+
except Exception as e:
|
| 485 |
+
logger.warning("Could not calculate memory usage: %s", e)
|
| 486 |
+
|
| 487 |
+
return result
|
| 488 |
+
|
| 489 |
+
|
| 490 |
+
def optimize_for_inference() -> dict[str, Any]:
|
| 491 |
+
"""Optimize the loaded model for inference.
|
| 492 |
+
|
| 493 |
+
Applies optimizations like:
|
| 494 |
+
- Better Transformer structure (if available)
|
| 495 |
+
- Half precision conversion
|
| 496 |
+
- Attention optimization
|
| 497 |
+
|
| 498 |
+
Returns:
|
| 499 |
+
Dict with optimization results.
|
| 500 |
+
"""
|
| 501 |
+
result = {"optimized": False, "techniques_applied": []}
|
| 502 |
+
|
| 503 |
+
if _model is None:
|
| 504 |
+
return result
|
| 505 |
+
|
| 506 |
+
try:
|
| 507 |
+
import torch
|
| 508 |
+
|
| 509 |
+
# Try to use better transformer if available
|
| 510 |
+
if hasattr(_model, 'to_bettertransformer'):
|
| 511 |
+
_model = _model.to_bettertransformer()
|
| 512 |
+
result["techniques_applied"].append("better_transformer")
|
| 513 |
+
|
| 514 |
+
# Torch compile if available (PyTorch 2.0+)
|
| 515 |
+
if hasattr(torch, 'compile'):
|
| 516 |
+
try:
|
| 517 |
+
_model = torch.compile(_model, mode="reduce-overhead")
|
| 518 |
+
result["techniques_applied"].append("torch_compile")
|
| 519 |
+
except Exception:
|
| 520 |
+
pass # Torch compile may fail for some models
|
| 521 |
+
|
| 522 |
+
result["optimized"] = len(result["techniques_applied"]) > 0
|
| 523 |
+
|
| 524 |
+
except Exception as e:
|
| 525 |
+
logger.warning("Optimization failed: %s", e)
|
| 526 |
+
result["error"] = str(e)
|
| 527 |
+
|
| 528 |
+
return result
|
|
@@ -1,31 +1,120 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
|
|
|
| 7 |
"""
|
| 8 |
|
| 9 |
from __future__ import annotations
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
|
| 14 |
-
def
|
| 15 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
-
This is a thin wrapper around `code.server.routes.get_app()` that
|
| 18 |
-
triggers all the route decorators when the routes module is first
|
| 19 |
-
imported.
|
| 20 |
-
"""
|
| 21 |
-
from code.server.routes import get_app # noqa: F401 (import has side effects)
|
| 22 |
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
|
| 26 |
-
def
|
| 27 |
-
"""
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Server package — FastAPI/Gradio routes and utilities.
|
| 2 |
|
| 3 |
+
Enhanced with:
|
| 4 |
+
- Session management
|
| 5 |
+
- Export functionality
|
| 6 |
+
- Template system
|
| 7 |
+
- Better error handling
|
| 8 |
"""
|
| 9 |
|
| 10 |
from __future__ import annotations
|
| 11 |
|
| 12 |
+
import json
|
| 13 |
+
import logging
|
| 14 |
+
import os
|
| 15 |
+
import time
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
from typing import Any, Optional
|
| 18 |
+
|
| 19 |
+
import gradio as gr
|
| 20 |
+
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def create_app() -> gr.Server:
|
| 25 |
+
"""Create and configure the Gradio Server with all routes."""
|
| 26 |
+
from code.server.routes import create_routes
|
| 27 |
+
|
| 28 |
+
app = create_routes()
|
| 29 |
+
return app
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def get_workspace_root() -> Path:
|
| 33 |
+
"""Get the workspace root directory."""
|
| 34 |
+
from code.config import WORKSPACE_ROOT
|
| 35 |
+
return WORKSPACE_ROOT
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def get_download_dir() -> Path:
|
| 39 |
+
"""Get the download directory for exports."""
|
| 40 |
+
download_dir = Path("./downloads")
|
| 41 |
+
download_dir.mkdir(parents=True, exist_ok=True)
|
| 42 |
+
return download_dir
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# Template-related functions
|
| 46 |
+
def get_code_templates() -> dict[str, Any]:
|
| 47 |
+
"""Get available code templates."""
|
| 48 |
+
from code.config.constants import CODE_TEMPLATES
|
| 49 |
+
return {
|
| 50 |
+
key: {
|
| 51 |
+
"name": tpl["name"],
|
| 52 |
+
"description": tpl["description"],
|
| 53 |
+
"language": tpl["language"],
|
| 54 |
+
"framework": tpl["framework"],
|
| 55 |
+
}
|
| 56 |
+
for key, tpl in CODE_TEMPLATES.items()
|
| 57 |
+
}
|
| 58 |
|
| 59 |
|
| 60 |
+
def get_template_content(template_id: str) -> Optional[str]:
|
| 61 |
+
"""Get the content of a specific template."""
|
| 62 |
+
from code.config.constants import CODE_TEMPLATES
|
| 63 |
+
if template_id in CODE_TEMPLATES:
|
| 64 |
+
return CODE_TEMPLATES[template_id]["template"]
|
| 65 |
+
return None
|
| 66 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
+
# Utility functions for the server
|
| 69 |
+
def format_file_size(size_bytes: int) -> str:
|
| 70 |
+
"""Format file size in human-readable format."""
|
| 71 |
+
for unit in ['B', 'KB', 'MB', 'GB']:
|
| 72 |
+
if size_bytes < 1024.0:
|
| 73 |
+
return f"{size_bytes:.1f} {unit}"
|
| 74 |
+
size_bytes /= 1024.0
|
| 75 |
+
return f"{size_bytes:.1f} TB"
|
| 76 |
|
| 77 |
|
| 78 |
+
def sanitize_filename(filename: str) -> str:
|
| 79 |
+
"""Sanitize a filename by removing dangerous characters."""
|
| 80 |
+
# Remove path separators and dangerous characters
|
| 81 |
+
sanitized = os.path.basename(filename)
|
| 82 |
+
# Replace non-alphanumeric (except dash, underscore, dot) with underscore
|
| 83 |
+
sanitized = "".join(c if c.isalnum() or c in '-_.' else '_' for c in sanitized)
|
| 84 |
+
# Remove consecutive underscores
|
| 85 |
+
while '__' in sanitized:
|
| 86 |
+
sanitized = sanitized.replace('__', '_')
|
| 87 |
+
# Limit length
|
| 88 |
+
if len(sanitized) > 255:
|
| 89 |
+
name, ext = os.path.splitext(sanitized)
|
| 90 |
+
sanitized = name[:255-len(ext)] + ext
|
| 91 |
+
return sanitized or "unnamed"
|
| 92 |
|
| 93 |
|
| 94 |
+
def get_system_info() -> dict[str, Any]:
|
| 95 |
+
"""Get system information for diagnostics."""
|
| 96 |
+
import platform
|
| 97 |
+
import torch
|
| 98 |
+
|
| 99 |
+
info = {
|
| 100 |
+
"platform": platform.platform(),
|
| 101 |
+
"python_version": platform.python_version(),
|
| 102 |
+
"hostname": platform.node(),
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
# GPU info if available
|
| 106 |
+
if torch.cuda.is_available():
|
| 107 |
+
info["gpu"] = {
|
| 108 |
+
"name": torch.cuda.get_device_name(0),
|
| 109 |
+
"memory_gb": round(torch.cuda.get_device_properties(0).total_mem / 1e9, 2),
|
| 110 |
+
"cuda_version": torch.version.cuda,
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
# Model status
|
| 114 |
+
try:
|
| 115 |
+
from code.model.loader import get_model_status
|
| 116 |
+
info["model"] = get_model_status()
|
| 117 |
+
except Exception:
|
| 118 |
+
info["model"] = {"status": "unknown"}
|
| 119 |
+
|
| 120 |
+
return info
|
|
@@ -1,43 +1,129 @@
|
|
| 1 |
-
"""Chat helper functions — history conversion, prompt building, iteration context.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
-
|
|
|
|
|
|
|
| 6 |
|
| 7 |
-
from code.config.constants import
|
|
|
|
|
|
|
|
|
|
| 8 |
from code.execution.code_extractor import strip_thinking_blocks
|
|
|
|
| 9 |
|
|
|
|
| 10 |
|
| 11 |
-
def chat_history_to_messages(history: list[dict[str, str]]) -> list[dict[str, Any]]:
|
| 12 |
-
"""Convert chat history list to messages format for the model.
|
| 13 |
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
"""
|
|
|
|
|
|
|
|
|
|
| 17 |
messages: list[dict[str, Any]] = [{"role": "system", "content": SYSTEM_PROMPT}]
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
role = item.get("role")
|
| 20 |
content = str(item.get("content") or "").strip()
|
| 21 |
if role not in {"user", "assistant"} or not content:
|
| 22 |
continue
|
|
|
|
| 23 |
if role == "assistant":
|
| 24 |
content = strip_thinking_blocks(content)
|
|
|
|
| 25 |
messages.append({"role": role, "content": content})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
return messages
|
| 27 |
|
| 28 |
|
| 29 |
def clip_context(text: str, limit: int = 4_000) -> str:
|
| 30 |
-
"""Truncate text to a character limit with a note.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
if len(text) <= limit:
|
| 32 |
return text
|
|
|
|
|
|
|
| 33 |
return text[:limit] + f"\n... truncated {len(text) - limit} characters ..."
|
| 34 |
|
| 35 |
|
| 36 |
def iteration_context(execution_context: dict[str, Any] | None) -> str:
|
| 37 |
"""Build a context string from previous execution results.
|
| 38 |
-
|
| 39 |
This allows the model to reference prior code, stdout, and stderr
|
| 40 |
when the user asks to iterate or debug.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
"""
|
| 42 |
if not execution_context or not execution_context.get("code"):
|
| 43 |
return ""
|
|
@@ -72,7 +158,23 @@ def targeted_prompt(
|
|
| 72 |
execution_context: dict[str, Any] | None = None,
|
| 73 |
search_context: str = "",
|
| 74 |
) -> str:
|
| 75 |
-
"""Build the full user prompt with language, framework, search, and iteration context.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
iter_ctx = iteration_context(execution_context)
|
| 77 |
context_block = f"\n\n{iter_ctx}" if iter_ctx else ""
|
| 78 |
|
|
@@ -94,17 +196,143 @@ def targeted_prompt(
|
|
| 94 |
"- Includes all processing logic inline\n"
|
| 95 |
"- Calls .launch(server_name='0.0.0.0', server_port=7860) at the end\n"
|
| 96 |
"- Uses only standard library + gradio + common packages (PIL, matplotlib, numpy)\n"
|
| 97 |
-
"- Make the UI clean, modern, and functional"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
|
| 100 |
return (
|
| 101 |
f"Target: {target_language}{framework_hint}. Generate a complete, runnable application. "
|
| 102 |
"Use the `write_file` tool to save each file to the workspace. "
|
| 103 |
"Do NOT paste code in markdown blocks — always use `write_file`. "
|
| 104 |
"For multi-file projects, call `write_file` once per file. "
|
| 105 |
-
"After writing files, give a short summary of what you created."
|
|
|
|
| 106 |
f"{gradio_hint}"
|
|
|
|
|
|
|
|
|
|
| 107 |
f"{search_block}"
|
| 108 |
f"{context_block}\n\n"
|
| 109 |
f"User request:\n{prompt}"
|
| 110 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Chat helper functions — history conversion, prompt building, iteration context.
|
| 2 |
+
|
| 3 |
+
Enhanced version with:
|
| 4 |
+
- Better context management
|
| 5 |
+
- Conversation summarization for long histories
|
| 6 |
+
- Prompt optimization
|
| 7 |
+
- Token counting
|
| 8 |
+
- Session awareness
|
| 9 |
+
"""
|
| 10 |
|
| 11 |
from __future__ import annotations
|
| 12 |
|
| 13 |
+
import logging
|
| 14 |
+
from typing import Any, Optional
|
| 15 |
+
from dataclasses import dataclass
|
| 16 |
|
| 17 |
+
from code.config.constants import (
|
| 18 |
+
SYSTEM_PROMPT,
|
| 19 |
+
MAX_SESSION_HISTORY,
|
| 20 |
+
)
|
| 21 |
from code.execution.code_extractor import strip_thinking_blocks
|
| 22 |
+
from code.model.inference import estimate_tokens
|
| 23 |
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
|
|
|
|
|
|
|
| 26 |
|
| 27 |
+
@dataclass
|
| 28 |
+
class ContextInfo:
|
| 29 |
+
"""Information about the current conversation context."""
|
| 30 |
+
message_count: int = 0
|
| 31 |
+
estimated_tokens: int = 0
|
| 32 |
+
has_images: bool = False
|
| 33 |
+
has_code_blocks: bool = False
|
| 34 |
+
is_truncated: bool = False
|
| 35 |
+
summary: str | None = None
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def chat_history_to_messages(
|
| 39 |
+
history: list[dict[str, str]],
|
| 40 |
+
max_messages: int | None = None,
|
| 41 |
+
) -> list[dict[str, Any]]:
|
| 42 |
+
"""Convert chat history list to messages format for the model.
|
| 43 |
+
|
| 44 |
+
Enhanced with:
|
| 45 |
+
- Message limit handling
|
| 46 |
+
- Context info tracking
|
| 47 |
+
- Automatic truncation for long conversations
|
| 48 |
+
|
| 49 |
+
Args:
|
| 50 |
+
history: Chat history from frontend.
|
| 51 |
+
max_messages: Maximum messages to include (None for default).
|
| 52 |
+
|
| 53 |
+
Returns:
|
| 54 |
+
Messages in OpenAI format with system prompt prepended.
|
| 55 |
"""
|
| 56 |
+
if max_messages is None:
|
| 57 |
+
max_messages = MAX_SESSION_HISTORY
|
| 58 |
+
|
| 59 |
messages: list[dict[str, Any]] = [{"role": "system", "content": SYSTEM_PROMPT}]
|
| 60 |
+
|
| 61 |
+
# Take only the most recent messages if history is too long
|
| 62 |
+
working_history = history[-max_messages:] if len(history) > max_messages else history
|
| 63 |
+
|
| 64 |
+
context_info = ContextInfo()
|
| 65 |
+
|
| 66 |
+
for item in working_history:
|
| 67 |
role = item.get("role")
|
| 68 |
content = str(item.get("content") or "").strip()
|
| 69 |
if role not in {"user", "assistant"} or not content:
|
| 70 |
continue
|
| 71 |
+
|
| 72 |
if role == "assistant":
|
| 73 |
content = strip_thinking_blocks(content)
|
| 74 |
+
|
| 75 |
messages.append({"role": role, "content": content})
|
| 76 |
+
|
| 77 |
+
# Track context info
|
| 78 |
+
context_info.message_count += 1
|
| 79 |
+
context_info.estimated_tokens += estimate_tokens(content)
|
| 80 |
+
|
| 81 |
+
if not context_info.has_images:
|
| 82 |
+
context_info.has_images = "image" in content.lower() or "![" in content
|
| 83 |
+
if not context_info.has_code_blocks:
|
| 84 |
+
context_info.has_code_blocks = "```" in content
|
| 85 |
+
|
| 86 |
+
context_info.is_truncated = len(history) > max_messages
|
| 87 |
+
|
| 88 |
+
if context_info.is_truncated:
|
| 89 |
+
logger.info(
|
| 90 |
+
"History truncated from %d to %d messages",
|
| 91 |
+
len(history), len(working_history)
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
return messages
|
| 95 |
|
| 96 |
|
| 97 |
def clip_context(text: str, limit: int = 4_000) -> str:
|
| 98 |
+
"""Truncate text to a character limit with a note.
|
| 99 |
+
|
| 100 |
+
Preserves code blocks when possible by truncating outside of them.
|
| 101 |
+
|
| 102 |
+
Args:
|
| 103 |
+
text: Text to truncate.
|
| 104 |
+
limit: Maximum character count.
|
| 105 |
+
|
| 106 |
+
Returns:
|
| 107 |
+
Truncated text with ellipsis note if truncated.
|
| 108 |
+
"""
|
| 109 |
if len(text) <= limit:
|
| 110 |
return text
|
| 111 |
+
|
| 112 |
+
# Simple truncation (could be enhanced to preserve code blocks)
|
| 113 |
return text[:limit] + f"\n... truncated {len(text) - limit} characters ..."
|
| 114 |
|
| 115 |
|
| 116 |
def iteration_context(execution_context: dict[str, Any] | None) -> str:
|
| 117 |
"""Build a context string from previous execution results.
|
| 118 |
+
|
| 119 |
This allows the model to reference prior code, stdout, and stderr
|
| 120 |
when the user asks to iterate or debug.
|
| 121 |
+
|
| 122 |
+
Args:
|
| 123 |
+
execution_context: Dict with previous execution details.
|
| 124 |
+
|
| 125 |
+
Returns:
|
| 126 |
+
Formatted context string for inclusion in prompts.
|
| 127 |
"""
|
| 128 |
if not execution_context or not execution_context.get("code"):
|
| 129 |
return ""
|
|
|
|
| 158 |
execution_context: dict[str, Any] | None = None,
|
| 159 |
search_context: str = "",
|
| 160 |
) -> str:
|
| 161 |
+
"""Build the full user prompt with language, framework, search, and iteration context.
|
| 162 |
+
|
| 163 |
+
Enhanced with:
|
| 164 |
+
- Better framework-specific hints
|
| 165 |
+
- Code quality instructions
|
| 166 |
+
- Security reminders
|
| 167 |
+
|
| 168 |
+
Args:
|
| 169 |
+
prompt: User's original prompt.
|
| 170 |
+
target_language: Target programming language.
|
| 171 |
+
target_framework: Optional target framework.
|
| 172 |
+
execution_context: Previous execution context for iterations.
|
| 173 |
+
search_context: Web search results to incorporate.
|
| 174 |
+
|
| 175 |
+
Returns:
|
| 176 |
+
Complete formatted prompt for the model.
|
| 177 |
+
"""
|
| 178 |
iter_ctx = iteration_context(execution_context)
|
| 179 |
context_block = f"\n\n{iter_ctx}" if iter_ctx else ""
|
| 180 |
|
|
|
|
| 196 |
"- Includes all processing logic inline\n"
|
| 197 |
"- Calls .launch(server_name='0.0.0.0', server_port=7860) at the end\n"
|
| 198 |
"- Uses only standard library + gradio + common packages (PIL, matplotlib, numpy)\n"
|
| 199 |
+
"- Make the UI clean, modern, and functional\n"
|
| 200 |
+
"- Include proper error handling and loading states"
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
# Framework-specific hints
|
| 204 |
+
react_hint = ""
|
| 205 |
+
if target_framework == "React":
|
| 206 |
+
react_hint = (
|
| 207 |
+
"\n\nREACT SPECIFIC:\n"
|
| 208 |
+
"- Use functional components and hooks\n"
|
| 209 |
+
"- Include proper TypeScript types if applicable\n"
|
| 210 |
+
"- Use CSS modules or styled-components for styling\n"
|
| 211 |
+
"- Make components reusable and composable"
|
| 212 |
)
|
| 213 |
+
|
| 214 |
+
flask_hint = ""
|
| 215 |
+
if target_framework == "Flask":
|
| 216 |
+
flask_hint = (
|
| 217 |
+
"\n\nFLASK SPECIFIC:\n"
|
| 218 |
+
"- Use Flask blueprints for larger apps\n"
|
| 219 |
+
"- Include proper error handlers\n"
|
| 220 |
+
"- Add input validation using marshmallow or similar\n"
|
| 221 |
+
"- Structure with separate routes, models, and templates directories"
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
security_reminder = (
|
| 225 |
+
"\n\nSECURITY REMINDERS:\n"
|
| 226 |
+
"- Validate all user inputs\n"
|
| 227 |
+
"- Use parameterized queries for database operations\n"
|
| 228 |
+
"- Never hardcode secrets or API keys\n"
|
| 229 |
+
"- Implement proper authentication/authorization where needed\n"
|
| 230 |
+
"- Sanitize outputs to prevent XSS"
|
| 231 |
+
)
|
| 232 |
|
| 233 |
return (
|
| 234 |
f"Target: {target_language}{framework_hint}. Generate a complete, runnable application. "
|
| 235 |
"Use the `write_file` tool to save each file to the workspace. "
|
| 236 |
"Do NOT paste code in markdown blocks — always use `write_file`. "
|
| 237 |
"For multi-file projects, call `write_file` once per file. "
|
| 238 |
+
"After writing files, give a short summary of what you created. "
|
| 239 |
+
"Include proper error handling, comments, and follow best practices. "
|
| 240 |
f"{gradio_hint}"
|
| 241 |
+
f"{react_hint}"
|
| 242 |
+
f"{flask_hint}"
|
| 243 |
+
f"{security_reminder}"
|
| 244 |
f"{search_block}"
|
| 245 |
f"{context_block}\n\n"
|
| 246 |
f"User request:\n{prompt}"
|
| 247 |
)
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def summarize_conversation(
|
| 251 |
+
history: list[dict[str, str]],
|
| 252 |
+
max_summary_length: int = 500,
|
| 253 |
+
) -> str | None:
|
| 254 |
+
"""Create a summary of the conversation for context preservation.
|
| 255 |
+
|
| 256 |
+
This is useful when the conversation gets too long and needs to be
|
| 257 |
+
compressed while preserving important context.
|
| 258 |
+
|
| 259 |
+
Args:
|
| 260 |
+
history: Full conversation history.
|
| 261 |
+
max_summary_length: Maximum length of the summary.
|
| 262 |
+
|
| 263 |
+
Returns:
|
| 264 |
+
Summary string or None if summarization not needed.
|
| 265 |
+
"""
|
| 266 |
+
if len(history) < 10: # Only summarize longer conversations
|
| 267 |
+
return None
|
| 268 |
+
|
| 269 |
+
# Extract key information
|
| 270 |
+
user_requests = []
|
| 271 |
+
files_created = []
|
| 272 |
+
|
| 273 |
+
for msg in history:
|
| 274 |
+
role = msg.get("role")
|
| 275 |
+
content = msg.get("content", "")
|
| 276 |
+
|
| 277 |
+
if role == "user":
|
| 278 |
+
# Get first sentence as summary of request
|
| 279 |
+
first_sentence = content.split('.')[0].split('\n')[0]
|
| 280 |
+
if first_sentence:
|
| 281 |
+
user_requests.append(first_sentence)
|
| 282 |
+
|
| 283 |
+
elif role == "assistant":
|
| 284 |
+
# Look for file creation mentions
|
| 285 |
+
if "created" in content.lower() or "wrote" in content.lower():
|
| 286 |
+
# Simple extraction - could be enhanced with NLP
|
| 287 |
+
pass
|
| 288 |
+
|
| 289 |
+
if not user_requests:
|
| 290 |
+
return None
|
| 291 |
+
|
| 292 |
+
summary_parts = [
|
| 293 |
+
"Conversation summary:",
|
| 294 |
+
f"User made {len(user_requests)} requests.",
|
| 295 |
+
"Key requests: " + "; ".join(user_requests[-5:]), # Last 5 requests
|
| 296 |
+
]
|
| 297 |
+
|
| 298 |
+
summary = "\n".join(summary_parts)
|
| 299 |
+
|
| 300 |
+
if len(summary) > max_summary_length:
|
| 301 |
+
summary = summary[:max_summary_length] + "..."
|
| 302 |
+
|
| 303 |
+
return summary
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
def build_system_prompt_with_context(
|
| 307 |
+
custom_instructions: str | None = None,
|
| 308 |
+
active_skills: list[str] | None = None,
|
| 309 |
+
active_agent: str | None = None,
|
| 310 |
+
) -> str:
|
| 311 |
+
"""Build system prompt with additional context.
|
| 312 |
+
|
| 313 |
+
Args:
|
| 314 |
+
custom_instructions: Additional custom instructions.
|
| 315 |
+
active_skills: List of currently active skill names.
|
| 316 |
+
active_agent: Name of the active agent if any.
|
| 317 |
+
|
| 318 |
+
Returns:
|
| 319 |
+
Complete system prompt string.
|
| 320 |
+
"""
|
| 321 |
+
base_prompt = SYSTEM_PROMPT
|
| 322 |
+
|
| 323 |
+
additions = []
|
| 324 |
+
|
| 325 |
+
if active_agent:
|
| 326 |
+
additions.append(f"You are currently operating as the '{active_agent}' agent.")
|
| 327 |
+
|
| 328 |
+
if active_skills:
|
| 329 |
+
skills_str = ", ".join(active_skills)
|
| 330 |
+
additions.append(f"Active skills: {skills_str}. Apply these skill guidelines.")
|
| 331 |
+
|
| 332 |
+
if custom_instructions:
|
| 333 |
+
additions.append(f"ADDITIONAL INSTRUCTIONS:\n{custom_instructions}")
|
| 334 |
+
|
| 335 |
+
if additions:
|
| 336 |
+
base_prompt += "\n\n" + "\n\n".join(additions)
|
| 337 |
+
|
| 338 |
+
return base_prompt
|
|
@@ -0,0 +1,514 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Session management and conversation export utilities.
|
| 2 |
+
|
| 3 |
+
New module providing:
|
| 4 |
+
- Session persistence
|
| 5 |
+
- Conversation history management
|
| 6 |
+
- Export to multiple formats (JSON, Markdown, HTML)
|
| 7 |
+
- Import capabilities
|
| 8 |
+
- Template management
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import json
|
| 14 |
+
import logging
|
| 15 |
+
import os
|
| 16 |
+
import time
|
| 17 |
+
from dataclasses import dataclass, field, asdict
|
| 18 |
+
from datetime import datetime
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
from typing import Any, Optional
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@dataclass
|
| 26 |
+
class ChatMessage:
|
| 27 |
+
"""Represents a single chat message."""
|
| 28 |
+
role: str # "user" or "assistant"
|
| 29 |
+
content: str
|
| 30 |
+
timestamp: float = field(default_factory=time.time)
|
| 31 |
+
token_count: int = 0
|
| 32 |
+
model_used: str = ""
|
| 33 |
+
|
| 34 |
+
def to_dict(self) -> dict[str, Any]:
|
| 35 |
+
return asdict(self)
|
| 36 |
+
|
| 37 |
+
@classmethod
|
| 38 |
+
def from_dict(cls, data: dict[str, Any]) -> 'ChatMessage':
|
| 39 |
+
return cls(**data)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@dataclass
|
| 43 |
+
class Session:
|
| 44 |
+
"""Represents a user session with full conversation history."""
|
| 45 |
+
session_id: str
|
| 46 |
+
created_at: float = field(default_factory=time.time)
|
| 47 |
+
updated_at: float = field(default_factory=time.time)
|
| 48 |
+
messages: list[ChatMessage] = field(default_factory=list)
|
| 49 |
+
metadata: dict[str, Any] = field(default_factory=dict)
|
| 50 |
+
title: str = ""
|
| 51 |
+
|
| 52 |
+
def add_message(self, role: str, content: str, **kwargs) -> ChatMessage:
|
| 53 |
+
"""Add a message to the session."""
|
| 54 |
+
msg = ChatMessage(role=role, content=content, **kwargs)
|
| 55 |
+
self.messages.append(msg)
|
| 56 |
+
self.updated_at = time.time()
|
| 57 |
+
|
| 58 |
+
# Auto-generate title from first user message
|
| 59 |
+
if role == "user" and not self.title:
|
| 60 |
+
self.title = content[:50] + "..." if len(content) > 50 else content
|
| 61 |
+
|
| 62 |
+
return msg
|
| 63 |
+
|
| 64 |
+
def get_history(self) -> list[dict[str, str]]:
|
| 65 |
+
"""Get messages in the format expected by chat_helpers."""
|
| 66 |
+
return [{"role": m.role, "content": m.content} for m in self.messages]
|
| 67 |
+
|
| 68 |
+
def to_dict(self) -> dict[str, Any]:
|
| 69 |
+
return {
|
| 70 |
+
"session_id": self.session_id,
|
| 71 |
+
"created_at": self.created_at,
|
| 72 |
+
"updated_at": self.updated_at,
|
| 73 |
+
"title": self.title,
|
| 74 |
+
"messages": [m.to_dict() for m in self.messages],
|
| 75 |
+
"metadata": self.metadata,
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
@classmethod
|
| 79 |
+
def from_dict(cls, data: dict[str, Any]) -> 'Session':
|
| 80 |
+
session = cls(
|
| 81 |
+
session_id=data["session_id"],
|
| 82 |
+
created_at=data.get("created_at", time.time()),
|
| 83 |
+
updated_at=data.get("updated_at", time.time()),
|
| 84 |
+
title=data.get("title", ""),
|
| 85 |
+
metadata=data.get("metadata", {}),
|
| 86 |
+
)
|
| 87 |
+
session.messages = [ChatMessage.from_dict(m) for m in data.get("messages", [])]
|
| 88 |
+
return session
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class SessionManager:
|
| 92 |
+
"""Manages multiple sessions with persistence support.
|
| 93 |
+
|
| 94 |
+
Features:
|
| 95 |
+
- In-memory session storage
|
| 96 |
+
- JSON file persistence
|
| 97 |
+
- Session search and filtering
|
| 98 |
+
- Automatic cleanup of old sessions
|
| 99 |
+
"""
|
| 100 |
+
|
| 101 |
+
def __init__(self, storage_dir: str | None = None):
|
| 102 |
+
self._sessions: dict[str, Session] = {}
|
| 103 |
+
self._storage_dir = Path(storage_dir) if storage_dir else None
|
| 104 |
+
|
| 105 |
+
if self._storage_dir:
|
| 106 |
+
self._storage_dir.mkdir(parents=True, exist_ok=True)
|
| 107 |
+
self._load_sessions()
|
| 108 |
+
|
| 109 |
+
def create_session(self, session_id: str | None = None, **metadata) -> Session:
|
| 110 |
+
"""Create a new session."""
|
| 111 |
+
if session_id is None:
|
| 112 |
+
session_id = f"session_{int(time.time() * 1000)}"
|
| 113 |
+
|
| 114 |
+
session = Session(session_id=session_id, metadata=metadata)
|
| 115 |
+
self._sessions[session_id] = session
|
| 116 |
+
logger.info("Created session: %s", session_id)
|
| 117 |
+
return session
|
| 118 |
+
|
| 119 |
+
def get_session(self, session_id: str) -> Optional[Session]:
|
| 120 |
+
"""Get a session by ID."""
|
| 121 |
+
return self._sessions.get(session_id)
|
| 122 |
+
|
| 123 |
+
def update_session(self, session_id: str, **updates) -> bool:
|
| 124 |
+
"""Update session metadata."""
|
| 125 |
+
session = self.get_session(session_id)
|
| 126 |
+
if not session:
|
| 127 |
+
return False
|
| 128 |
+
|
| 129 |
+
for key, value in updates.items():
|
| 130 |
+
if hasattr(session, key):
|
| 131 |
+
setattr(session, key, value)
|
| 132 |
+
elif key in session.metadata:
|
| 133 |
+
session.metadata[key] = value
|
| 134 |
+
else:
|
| 135 |
+
session.metadata[key] = value
|
| 136 |
+
|
| 137 |
+
session.updated_at = time.time()
|
| 138 |
+
return True
|
| 139 |
+
|
| 140 |
+
def delete_session(self, session_id: str) -> bool:
|
| 141 |
+
"""Delete a session."""
|
| 142 |
+
if session_id in self._sessions:
|
| 143 |
+
del self._sessions[session_id]
|
| 144 |
+
|
| 145 |
+
# Delete persisted file
|
| 146 |
+
if self._storage_dir:
|
| 147 |
+
file_path = self._storage_dir / f"{session_id}.json"
|
| 148 |
+
if file_path.exists():
|
| 149 |
+
file_path.unlink()
|
| 150 |
+
|
| 151 |
+
logger.info("Deleted session: %s", session_id)
|
| 152 |
+
return True
|
| 153 |
+
return False
|
| 154 |
+
|
| 155 |
+
def list_sessions(
|
| 156 |
+
self,
|
| 157 |
+
limit: int = 50,
|
| 158 |
+
filter_text: str | None = None,
|
| 159 |
+
) -> list[dict[str, Any]]:
|
| 160 |
+
"""List sessions with optional filtering."""
|
| 161 |
+
sessions = list(self._sessions.values())
|
| 162 |
+
|
| 163 |
+
# Sort by updated_at descending
|
| 164 |
+
sessions.sort(key=lambda s: s.updated_at, reverse=True)
|
| 165 |
+
|
| 166 |
+
# Filter by text (search in title and messages)
|
| 167 |
+
if filter_text:
|
| 168 |
+
filter_lower = filter_text.lower()
|
| 169 |
+
sessions = [
|
| 170 |
+
s for s in sessions
|
| 171 |
+
if filter_lower in s.title.lower() or
|
| 172 |
+
any(filter_lower in m.content.lower() for m in s.messages[:10])
|
| 173 |
+
]
|
| 174 |
+
|
| 175 |
+
# Apply limit
|
| 176 |
+
sessions = sessions[:limit]
|
| 177 |
+
|
| 178 |
+
return [
|
| 179 |
+
{
|
| 180 |
+
"session_id": s.session_id,
|
| 181 |
+
"title": s.title,
|
| 182 |
+
"created_at": s.created_at,
|
| 183 |
+
"updated_at": s.updated_at,
|
| 184 |
+
"message_count": len(s.messages),
|
| 185 |
+
}
|
| 186 |
+
for s in sessions
|
| 187 |
+
]
|
| 188 |
+
|
| 189 |
+
def save_session(self, session_id: str) -> bool:
|
| 190 |
+
"""Persist a session to disk."""
|
| 191 |
+
if not self._storage_dir:
|
| 192 |
+
return False
|
| 193 |
+
|
| 194 |
+
session = self.get_session(session_id)
|
| 195 |
+
if not session:
|
| 196 |
+
return False
|
| 197 |
+
|
| 198 |
+
try:
|
| 199 |
+
file_path = self._storage_dir / f"{session_id}.json"
|
| 200 |
+
with open(file_path, 'w', encoding='utf-8') as f:
|
| 201 |
+
json.dump(session.to_dict(), f, indent=2, ensure_ascii=False)
|
| 202 |
+
return True
|
| 203 |
+
except Exception as e:
|
| 204 |
+
logger.error("Failed to save session %s: %s", session_id, e)
|
| 205 |
+
return False
|
| 206 |
+
|
| 207 |
+
def load_session(self, session_id: str) -> Optional[Session]:
|
| 208 |
+
"""Load a session from disk."""
|
| 209 |
+
if not self._storage_dir:
|
| 210 |
+
return None
|
| 211 |
+
|
| 212 |
+
try:
|
| 213 |
+
file_path = self._storage_dir / f"{session_id}.json"
|
| 214 |
+
if not file_path.exists():
|
| 215 |
+
return None
|
| 216 |
+
|
| 217 |
+
with open(file_path, 'r', encoding='utf-8') as f:
|
| 218 |
+
data = json.load(f)
|
| 219 |
+
|
| 220 |
+
session = Session.from_dict(data)
|
| 221 |
+
self._sessions[session_id] = session
|
| 222 |
+
return session
|
| 223 |
+
except Exception as e:
|
| 224 |
+
logger.error("Failed to load session %s: %s", session_id, e)
|
| 225 |
+
return None
|
| 226 |
+
|
| 227 |
+
def _load_sessions(self) -> None:
|
| 228 |
+
"""Load all sessions from storage directory."""
|
| 229 |
+
if not self._storage_dir:
|
| 230 |
+
return
|
| 231 |
+
|
| 232 |
+
try:
|
| 233 |
+
for file_path in self._storage_dir.glob("*.json"):
|
| 234 |
+
try:
|
| 235 |
+
with open(file_path, 'r', encoding='utf-8') as f:
|
| 236 |
+
data = json.load(f)
|
| 237 |
+
session = Session.from_dict(data)
|
| 238 |
+
self._sessions[session.session_id] = session
|
| 239 |
+
except Exception as e:
|
| 240 |
+
logger.warning("Failed to load session file %s: %s", file_path, e)
|
| 241 |
+
except Exception as e:
|
| 242 |
+
logger.error("Failed to scan sessions directory: %s", e)
|
| 243 |
+
|
| 244 |
+
def save_all(self) -> int:
|
| 245 |
+
"""Save all modified sessions. Returns count saved."""
|
| 246 |
+
count = 0
|
| 247 |
+
for session_id in list(self._sessions.keys()):
|
| 248 |
+
if self.save_session(session_id):
|
| 249 |
+
count += 1
|
| 250 |
+
return count
|
| 251 |
+
|
| 252 |
+
def cleanup_old_sessions(self, max_age_days: int = 30) -> int:
|
| 253 |
+
"""Remove sessions older than max_age_days. Returns count removed."""
|
| 254 |
+
cutoff = time.time() - (max_age_days * 24 * 60 * 60)
|
| 255 |
+
to_remove = [
|
| 256 |
+
sid for sid, s in self._sessions.items()
|
| 257 |
+
if s.updated_at < cutoff
|
| 258 |
+
]
|
| 259 |
+
|
| 260 |
+
for sid in to_remove:
|
| 261 |
+
self.delete_session(sid)
|
| 262 |
+
|
| 263 |
+
logger.info("Cleaned up %d old sessions", len(to_remove))
|
| 264 |
+
return len(to_remove)
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
# ─── Export Functions ────────────────────────────────────────────────────
|
| 268 |
+
|
| 269 |
+
def export_to_json(
|
| 270 |
+
session: Session,
|
| 271 |
+
output_path: str,
|
| 272 |
+
) -> str:
|
| 273 |
+
"""Export session to JSON format.
|
| 274 |
+
|
| 275 |
+
Args:
|
| 276 |
+
session: The session to export.
|
| 277 |
+
output_path: Path to write the JSON file.
|
| 278 |
+
|
| 279 |
+
Returns:
|
| 280 |
+
The output path.
|
| 281 |
+
"""
|
| 282 |
+
data = session.to_dict()
|
| 283 |
+
|
| 284 |
+
# Add export metadata
|
| 285 |
+
data["exported_at"] = datetime.now().isoformat()
|
| 286 |
+
data["version"] = "2.1"
|
| 287 |
+
|
| 288 |
+
with open(output_path, 'w', encoding='utf-8') as f:
|
| 289 |
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
| 290 |
+
|
| 291 |
+
logger.info("Exported session to JSON: %s", output_path)
|
| 292 |
+
return output_path
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
def export_to_markdown(
|
| 296 |
+
session: Session,
|
| 297 |
+
output_path: str,
|
| 298 |
+
) -> str:
|
| 299 |
+
"""Export session to Markdown format.
|
| 300 |
+
|
| 301 |
+
Args:
|
| 302 |
+
session: The session to export.
|
| 303 |
+
output_path: Path to write the markdown file.
|
| 304 |
+
|
| 305 |
+
Returns:
|
| 306 |
+
The output path.
|
| 307 |
+
"""
|
| 308 |
+
lines = [
|
| 309 |
+
f"# {session.title or 'SoniCoder Conversation'}",
|
| 310 |
+
"",
|
| 311 |
+
f"**Session ID:** `{session.session_id}`",
|
| 312 |
+
f"**Created:** {datetime.fromtimestamp(session.created_at).strftime('%Y-%m-%d %H:%M:%S')}",
|
| 313 |
+
f"**Messages:** {len(session.messages)}",
|
| 314 |
+
"",
|
| 315 |
+
"---",
|
| 316 |
+
"",
|
| 317 |
+
]
|
| 318 |
+
|
| 319 |
+
for i, msg in enumerate(session.messages, 1):
|
| 320 |
+
timestamp = datetime.fromtimestamp(msg.timestamp).strftime('%H:%M:%S')
|
| 321 |
+
role_label = "**User**" if msg.role == "user" else "**Assistant**"
|
| 322 |
+
|
| 323 |
+
lines.append(f"## Message {i} ({role_label}) - {timestamp}")
|
| 324 |
+
lines.append("")
|
| 325 |
+
lines.append(msg.content)
|
| 326 |
+
lines.append("")
|
| 327 |
+
lines.append("---")
|
| 328 |
+
lines.append("")
|
| 329 |
+
|
| 330 |
+
with open(output_path, 'w', encoding='utf-8') as f:
|
| 331 |
+
f.write('\n'.join(lines))
|
| 332 |
+
|
| 333 |
+
logger.info("Exported session to Markdown: %s", output_path)
|
| 334 |
+
return output_path
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
def export_to_html(
|
| 338 |
+
session: Session,
|
| 339 |
+
output_path: str,
|
| 340 |
+
) -> str:
|
| 341 |
+
"""Export session to HTML format with styling.
|
| 342 |
+
|
| 343 |
+
Args:
|
| 344 |
+
session: The session to export.
|
| 345 |
+
output_path: Path to write the HTML file.
|
| 346 |
+
|
| 347 |
+
Returns:
|
| 348 |
+
The output path.
|
| 349 |
+
"""
|
| 350 |
+
messages_html = []
|
| 351 |
+
|
| 352 |
+
for msg in session.messages:
|
| 353 |
+
timestamp = datetime.fromtimestamp(msg.timestamp).strftime('%H:%M:%S')
|
| 354 |
+
role_class = "user-message" if msg.role == "user" else "assistant-message"
|
| 355 |
+
role_label = "👤 User" if msg.role == "user" else "🤖 Assistant"
|
| 356 |
+
|
| 357 |
+
# Escape HTML special characters in content
|
| 358 |
+
content = (
|
| 359 |
+
msg.content
|
| 360 |
+
.replace('&', '&')
|
| 361 |
+
.replace('<', '<')
|
| 362 |
+
.replace('>', '>')
|
| 363 |
+
)
|
| 364 |
+
|
| 365 |
+
# Convert code blocks to HTML
|
| 366 |
+
import re
|
| 367 |
+
content = re.sub(
|
| 368 |
+
r'```(\w*)\n(.*?)```',
|
| 369 |
+
r'<pre><code class="language-\1">\2</code></pre>',
|
| 370 |
+
content,
|
| 371 |
+
flags=re.DOTALL
|
| 372 |
+
)
|
| 373 |
+
|
| 374 |
+
messages_html.append(f"""
|
| 375 |
+
<div class="message {role_class}">
|
| 376 |
+
<div class="message-header">
|
| 377 |
+
<span class="role-label">{role_label}</span>
|
| 378 |
+
<span class="timestamp">{timestamp}</span>
|
| 379 |
+
</div>
|
| 380 |
+
<div class="message-content">{content}</div>
|
| 381 |
+
</div>
|
| 382 |
+
""")
|
| 383 |
+
|
| 384 |
+
html = f"""<!DOCTYPE html>
|
| 385 |
+
<html lang="en">
|
| 386 |
+
<head>
|
| 387 |
+
<meta charset="UTF-8">
|
| 388 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 389 |
+
<title>{session.title or 'SoniCoder Conversation'}</title>
|
| 390 |
+
<style>
|
| 391 |
+
body {{
|
| 392 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
| 393 |
+
line-height: 1.6;
|
| 394 |
+
color: #333;
|
| 395 |
+
max-width: 900px;
|
| 396 |
+
margin: 0 auto;
|
| 397 |
+
padding: 20px;
|
| 398 |
+
background: #f5f5f5;
|
| 399 |
+
}}
|
| 400 |
+
h1 {{
|
| 401 |
+
color: #2c3e50;
|
| 402 |
+
border-bottom: 2px solid #3498db;
|
| 403 |
+
padding-bottom: 10px;
|
| 404 |
+
}}
|
| 405 |
+
.meta {{
|
| 406 |
+
color: #666;
|
| 407 |
+
font-size: 0.9em;
|
| 408 |
+
}}
|
| 409 |
+
.message {{
|
| 410 |
+
margin: 20px 0;
|
| 411 |
+
padding: 15px;
|
| 412 |
+
border-radius: 8px;
|
| 413 |
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
| 414 |
+
}}
|
| 415 |
+
.user-message {{
|
| 416 |
+
background: #e3f2fd;
|
| 417 |
+
border-left: 4px solid #2196f3;
|
| 418 |
+
}}
|
| 419 |
+
.assistant-message {{
|
| 420 |
+
background: #f3e5f5;
|
| 421 |
+
border-left: 4px solid #9c27b0;
|
| 422 |
+
}}
|
| 423 |
+
.message-header {{
|
| 424 |
+
display: flex;
|
| 425 |
+
justify-content: space-between;
|
| 426 |
+
margin-bottom: 10px;
|
| 427 |
+
font-weight: bold;
|
| 428 |
+
}}
|
| 429 |
+
.timestamp {{
|
| 430 |
+
color: #888;
|
| 431 |
+
font-size: 0.85em;
|
| 432 |
+
}}
|
| 433 |
+
pre {{
|
| 434 |
+
background: #263238;
|
| 435 |
+
color: #aed581;
|
| 436 |
+
padding: 15px;
|
| 437 |
+
border-radius: 4px;
|
| 438 |
+
overflow-x: auto;
|
| 439 |
+
font-size: 0.9em;
|
| 440 |
+
}}
|
| 441 |
+
code {{
|
| 442 |
+
font-family: 'Fira Code', 'Consolas', monospace;
|
| 443 |
+
}}
|
| 444 |
+
</style>
|
| 445 |
+
</head>
|
| 446 |
+
<body>
|
| 447 |
+
<h1>{session.title or 'SoniCoder Conversation'}</h1>
|
| 448 |
+
<div class="meta">
|
| 449 |
+
<p><strong>Session:</strong> {session.session_id}</p>
|
| 450 |
+
<p><strong>Created:</strong> {datetime.fromtimestamp(session.created_at).strftime('%Y-%m-%d %H:%M:%S')}</p>
|
| 451 |
+
<p><strong>Messages:</strong> {len(session.messages)}</p>
|
| 452 |
+
</div>
|
| 453 |
+
<hr>
|
| 454 |
+
{''.join(messages_html)}
|
| 455 |
+
</body>
|
| 456 |
+
</html>"""
|
| 457 |
+
|
| 458 |
+
with open(output_path, 'w', encoding='utf-8') as f:
|
| 459 |
+
f.write(html)
|
| 460 |
+
|
| 461 |
+
logger.info("Exported session to HTML: %s", output_path)
|
| 462 |
+
return output_path
|
| 463 |
+
|
| 464 |
+
|
| 465 |
+
def export_session(
|
| 466 |
+
session: Session,
|
| 467 |
+
format: str = "json",
|
| 468 |
+
output_dir: str = ".",
|
| 469 |
+
) -> str:
|
| 470 |
+
"""Export a session in the specified format.
|
| 471 |
+
|
| 472 |
+
Args:
|
| 473 |
+
session: The session to export.
|
| 474 |
+
format: Export format ('json', 'markdown', 'html').
|
| 475 |
+
output_dir: Directory to save the exported file.
|
| 476 |
+
|
| 477 |
+
Returns:
|
| 478 |
+
Path to the exported file.
|
| 479 |
+
"""
|
| 480 |
+
# Ensure output directory exists
|
| 481 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 482 |
+
|
| 483 |
+
safe_title = "".join(
|
| 484 |
+
c for c in (session.title or "conversation")
|
| 485 |
+
if c.isalnum() or c in (' ', '-', '_')
|
| 486 |
+
).strip()[:50] or "conversation"
|
| 487 |
+
|
| 488 |
+
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
| 489 |
+
|
| 490 |
+
exporters = {
|
| 491 |
+
"json": export_to_json,
|
| 492 |
+
"markdown": export_to_markdown,
|
| 493 |
+
"html": export_to_html,
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
if format not in exporters:
|
| 497 |
+
raise ValueError(f"Unsupported format: {format}. Supported: {list(exporters.keys())}")
|
| 498 |
+
|
| 499 |
+
filename = f"{safe_title}_{timestamp}.{format}"
|
| 500 |
+
output_path = os.path.join(output_dir, filename)
|
| 501 |
+
|
| 502 |
+
return exporters[format](session, output_path)
|
| 503 |
+
|
| 504 |
+
|
| 505 |
+
# Global session manager instance
|
| 506 |
+
_global_manager: Optional[SessionManager] = None
|
| 507 |
+
|
| 508 |
+
|
| 509 |
+
def get_session_manager(storage_dir: str | None = None) -> SessionManager:
|
| 510 |
+
"""Get or create the global session manager."""
|
| 511 |
+
global _global_manager
|
| 512 |
+
if _global_manager is None:
|
| 513 |
+
_global_manager = SessionManager(storage_dir=storage_dir)
|
| 514 |
+
return _global_manager
|
|
@@ -1,10 +1,21 @@
|
|
| 1 |
-
"""Bash subprocess tool with timeout and output capture.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import os
|
|
|
|
| 6 |
import shlex
|
| 7 |
import subprocess
|
|
|
|
| 8 |
from typing import Any
|
| 9 |
|
| 10 |
from code.tools.fs import _resolve_safe, get_workspace_root
|
|
@@ -15,30 +26,131 @@ _BLOCKED_PATTERNS = [
|
|
| 15 |
"rm -rf /",
|
| 16 |
"rm -rf ~",
|
| 17 |
"rm -rf $HOME",
|
| 18 |
-
":(){:|:&};:",
|
| 19 |
-
"mkfs",
|
| 20 |
-
"dd if=/dev/zero of=/dev/",
|
| 21 |
-
"> /dev/sda",
|
| 22 |
"shutdown",
|
| 23 |
"reboot",
|
| 24 |
"halt",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
]
|
| 26 |
|
| 27 |
-
# Default env vars to scrub for
|
| 28 |
-
_ENV_SCRUB = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
|
| 31 |
def _is_safe_command(cmd: str) -> tuple[bool, str]:
|
| 32 |
-
"""Check if a command is safe to run.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
stripped = cmd.strip()
|
|
|
|
| 34 |
if not stripped:
|
| 35 |
return False, "Empty command"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
for pat in _BLOCKED_PATTERNS:
|
| 37 |
if pat in stripped:
|
| 38 |
return False, f"Blocked pattern detected: {pat}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
return True, ""
|
| 40 |
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
def run_bash(
|
| 43 |
command: str,
|
| 44 |
cwd: str | None = None,
|
|
@@ -46,7 +158,13 @@ def run_bash(
|
|
| 46 |
env_extra: dict[str, str] | None = None,
|
| 47 |
) -> dict[str, Any]:
|
| 48 |
"""Run a shell command in the workspace.
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
Args:
|
| 51 |
command: Shell command to execute.
|
| 52 |
cwd: Working directory (relative to workspace root, defaults to workspace).
|
|
@@ -54,28 +172,46 @@ def run_bash(
|
|
| 54 |
env_extra: Extra environment variables.
|
| 55 |
|
| 56 |
Returns:
|
| 57 |
-
dict with: stdout, stderr, returncode, timed_out
|
| 58 |
"""
|
|
|
|
|
|
|
| 59 |
try:
|
|
|
|
| 60 |
safe, reason = _is_safe_command(command)
|
| 61 |
if not safe:
|
| 62 |
-
|
| 63 |
"success": False,
|
| 64 |
"stdout": "",
|
| 65 |
"stderr": reason,
|
| 66 |
"returncode": -1,
|
| 67 |
"timed_out": False,
|
|
|
|
|
|
|
|
|
|
| 68 |
}
|
|
|
|
|
|
|
| 69 |
|
|
|
|
| 70 |
if cwd:
|
| 71 |
work_dir = _resolve_safe(cwd)
|
| 72 |
else:
|
| 73 |
work_dir = get_workspace_root()
|
| 74 |
|
| 75 |
-
# Build
|
| 76 |
env = {k: v for k, v in os.environ.items() if k not in _ENV_SCRUB}
|
|
|
|
|
|
|
| 77 |
if env_extra:
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
# Run with bash -c for full shell semantics
|
| 81 |
completed = subprocess.run(
|
|
@@ -88,15 +224,20 @@ def run_bash(
|
|
| 88 |
check=False,
|
| 89 |
)
|
| 90 |
|
| 91 |
-
#
|
| 92 |
stdout = completed.stdout
|
| 93 |
stderr = completed.stderr
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
if len(
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
-
|
| 100 |
"success": completed.returncode == 0,
|
| 101 |
"stdout": stdout,
|
| 102 |
"stderr": stderr,
|
|
@@ -104,24 +245,120 @@ def run_bash(
|
|
| 104 |
"timed_out": False,
|
| 105 |
"command": command,
|
| 106 |
"cwd": cwd or ".",
|
|
|
|
| 107 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
except subprocess.TimeoutExpired as exc:
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
"success": False,
|
| 113 |
"stdout": stdout,
|
| 114 |
"stderr": f"Timeout after {timeout}s\n{stderr}",
|
| 115 |
"returncode": -1,
|
| 116 |
"timed_out": True,
|
| 117 |
"command": command,
|
|
|
|
|
|
|
| 118 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
except Exception as exc:
|
| 120 |
-
|
|
|
|
|
|
|
| 121 |
"success": False,
|
| 122 |
"stdout": "",
|
| 123 |
"stderr": str(exc),
|
| 124 |
"returncode": -1,
|
| 125 |
"timed_out": False,
|
| 126 |
"command": command,
|
|
|
|
|
|
|
| 127 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Bash subprocess tool with timeout and output capture.
|
| 2 |
+
|
| 3 |
+
Enhanced version with:
|
| 4 |
+
- Command logging and audit trail
|
| 5 |
+
- Resource usage tracking
|
| 6 |
+
- Better security controls
|
| 7 |
+
- Timeout handling improvements
|
| 8 |
+
- Output streaming support
|
| 9 |
+
- Environment variable protection
|
| 10 |
+
"""
|
| 11 |
|
| 12 |
from __future__ import annotations
|
| 13 |
|
| 14 |
import os
|
| 15 |
+
import re
|
| 16 |
import shlex
|
| 17 |
import subprocess
|
| 18 |
+
import time
|
| 19 |
from typing import Any
|
| 20 |
|
| 21 |
from code.tools.fs import _resolve_safe, get_workspace_root
|
|
|
|
| 26 |
"rm -rf /",
|
| 27 |
"rm -rf ~",
|
| 28 |
"rm -rf $HOME",
|
| 29 |
+
":(){:|:&};:", # Fork bomb
|
| 30 |
+
"mkfs", # Filesystem formatting
|
| 31 |
+
"dd if=/dev/zero of=/dev/", # Disk destruction
|
| 32 |
+
"> /dev/sda", # Direct disk write
|
| 33 |
"shutdown",
|
| 34 |
"reboot",
|
| 35 |
"halt",
|
| 36 |
+
"init 0",
|
| 37 |
+
"init 6",
|
| 38 |
+
# Additional dangerous patterns (regex)
|
| 39 |
+
]
|
| 40 |
+
|
| 41 |
+
# Compiled regex patterns for additional safety checks
|
| 42 |
+
_BLOCKED_REGEX = [
|
| 43 |
+
re.compile(r"chmod\s+777\s+/", re.IGNORECASE),
|
| 44 |
+
re.compile(r"chown\s+.*\s+/", re.IGNORECASE),
|
| 45 |
+
re.compile(r"curl.*\|\s*bash", re.IGNORECASE), # Pipe to bash
|
| 46 |
+
re.compile(r"wget.*\|\s*sh", re.IGNORECASE), # Pipe to sh
|
| 47 |
+
re.compile(r">\s*/etc/", re.IGNORECASE), # Write to etc
|
| 48 |
+
re.compile(r"mv\s+.*\s+/", re.IGNORECASE), # Move to root
|
| 49 |
]
|
| 50 |
|
| 51 |
+
# Default env vars to scrub for security
|
| 52 |
+
_ENV_SCRUB = {
|
| 53 |
+
"HF_TOKEN",
|
| 54 |
+
"OPENAI_API_KEY",
|
| 55 |
+
"ANTHROPIC_API_KEY",
|
| 56 |
+
"AWS_SECRET_ACCESS_KEY",
|
| 57 |
+
"AWS_ACCESS_KEY_ID",
|
| 58 |
+
"GCP_API_KEY",
|
| 59 |
+
"AZURE_SECRET_KEY",
|
| 60 |
+
"DATABASE_URL",
|
| 61 |
+
"PRIVATE_KEY",
|
| 62 |
+
"SECRET_KEY",
|
| 63 |
+
"PASSWORD",
|
| 64 |
+
"TOKEN",
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
# Command history for audit trail (in-memory, limited size)
|
| 68 |
+
_command_history: list[dict[str, Any]] = []
|
| 69 |
+
_MAX_HISTORY = 100
|
| 70 |
|
| 71 |
|
| 72 |
def _is_safe_command(cmd: str) -> tuple[bool, str]:
|
| 73 |
+
"""Check if a command is safe to run.
|
| 74 |
+
|
| 75 |
+
Enhanced with:
|
| 76 |
+
- Regex pattern matching
|
| 77 |
+
- Length limits
|
| 78 |
+
- Character validation
|
| 79 |
+
|
| 80 |
+
Args:
|
| 81 |
+
cmd: The command string to validate.
|
| 82 |
+
|
| 83 |
+
Returns:
|
| 84 |
+
Tuple of (is_safe, reason_if_unsafe).
|
| 85 |
+
"""
|
| 86 |
stripped = cmd.strip()
|
| 87 |
+
|
| 88 |
if not stripped:
|
| 89 |
return False, "Empty command"
|
| 90 |
+
|
| 91 |
+
# Check length limit
|
| 92 |
+
if len(stripped) > 10000:
|
| 93 |
+
return False, "Command too long (max 10000 characters)"
|
| 94 |
+
|
| 95 |
+
# Check for blocked string patterns
|
| 96 |
for pat in _BLOCKED_PATTERNS:
|
| 97 |
if pat in stripped:
|
| 98 |
return False, f"Blocked pattern detected: {pat}"
|
| 99 |
+
|
| 100 |
+
# Check for blocked regex patterns
|
| 101 |
+
for regex in _BLOCKED_REGEX:
|
| 102 |
+
if regex.search(stripped):
|
| 103 |
+
return False, f"Blocked pattern matched: {regex.pattern}"
|
| 104 |
+
|
| 105 |
+
# Check for null bytes and other suspicious characters
|
| 106 |
+
if '\x00' in stripped:
|
| 107 |
+
return False, "Command contains null bytes"
|
| 108 |
+
|
| 109 |
return True, ""
|
| 110 |
|
| 111 |
|
| 112 |
+
def _log_command(command: str, result: dict[str, Any]) -> None:
|
| 113 |
+
"""Log command execution for audit trail.
|
| 114 |
+
|
| 115 |
+
Args:
|
| 116 |
+
command: The executed command.
|
| 117 |
+
result: The execution result dict.
|
| 118 |
+
"""
|
| 119 |
+
global _command_history
|
| 120 |
+
|
| 121 |
+
entry = {
|
| 122 |
+
"timestamp": time.time(),
|
| 123 |
+
"command": command[:500], # Truncate long commands
|
| 124 |
+
"success": result.get("success", False),
|
| 125 |
+
"returncode": result.get("returncode", -1),
|
| 126 |
+
"timed_out": result.get("timed_out", False),
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
_command_history.append(entry)
|
| 130 |
+
|
| 131 |
+
# Trim history if too large
|
| 132 |
+
if len(_command_history) > _MAX_HISTORY:
|
| 133 |
+
_command_history = _command_history[-_MAX_HISTORY:]
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def get_command_history(limit: int = 50) -> list[dict[str, Any]]:
|
| 137 |
+
"""Get recent command history for auditing.
|
| 138 |
+
|
| 139 |
+
Args:
|
| 140 |
+
limit: Maximum number of entries to return.
|
| 141 |
+
|
| 142 |
+
Returns:
|
| 143 |
+
List of command execution records.
|
| 144 |
+
"""
|
| 145 |
+
return _command_history[-limit:]
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def clear_command_history() -> None:
|
| 149 |
+
"""Clear the command history."""
|
| 150 |
+
global _command_history
|
| 151 |
+
_command_history = []
|
| 152 |
+
|
| 153 |
+
|
| 154 |
def run_bash(
|
| 155 |
command: str,
|
| 156 |
cwd: str | None = None,
|
|
|
|
| 158 |
env_extra: dict[str, str] | None = None,
|
| 159 |
) -> dict[str, Any]:
|
| 160 |
"""Run a shell command in the workspace.
|
| 161 |
+
|
| 162 |
+
Enhanced with:
|
| 163 |
+
- Detailed error messages
|
| 164 |
+
- Resource usage tracking
|
| 165 |
+
- Better output handling
|
| 166 |
+
- Execution timing
|
| 167 |
+
|
| 168 |
Args:
|
| 169 |
command: Shell command to execute.
|
| 170 |
cwd: Working directory (relative to workspace root, defaults to workspace).
|
|
|
|
| 172 |
env_extra: Extra environment variables.
|
| 173 |
|
| 174 |
Returns:
|
| 175 |
+
dict with: stdout, stderr, returncode, timed_out, duration, memory_mb
|
| 176 |
"""
|
| 177 |
+
start_time = time.time()
|
| 178 |
+
|
| 179 |
try:
|
| 180 |
+
# Validate command safety
|
| 181 |
safe, reason = _is_safe_command(command)
|
| 182 |
if not safe:
|
| 183 |
+
result = {
|
| 184 |
"success": False,
|
| 185 |
"stdout": "",
|
| 186 |
"stderr": reason,
|
| 187 |
"returncode": -1,
|
| 188 |
"timed_out": False,
|
| 189 |
+
"command": command,
|
| 190 |
+
"cwd": cwd or ".",
|
| 191 |
+
"duration_ms": 0,
|
| 192 |
}
|
| 193 |
+
_log_command(command, result)
|
| 194 |
+
return result
|
| 195 |
|
| 196 |
+
# Resolve working directory
|
| 197 |
if cwd:
|
| 198 |
work_dir = _resolve_safe(cwd)
|
| 199 |
else:
|
| 200 |
work_dir = get_workspace_root()
|
| 201 |
|
| 202 |
+
# Build environment: scrub secrets, add extras
|
| 203 |
env = {k: v for k, v in os.environ.items() if k not in _ENV_SCRUB}
|
| 204 |
+
|
| 205 |
+
# Add safe extras
|
| 206 |
if env_extra:
|
| 207 |
+
for key, value in env_extra.items():
|
| 208 |
+
# Don't allow overriding scrubbed vars
|
| 209 |
+
if key not in _ENV_SCRUB:
|
| 210 |
+
env[key] = value
|
| 211 |
+
|
| 212 |
+
# Set some safe defaults
|
| 213 |
+
env.setdefault("PYTHONUNBUFFERED", "1")
|
| 214 |
+
env.setdefault("TERM", "dumb")
|
| 215 |
|
| 216 |
# Run with bash -c for full shell semantics
|
| 217 |
completed = subprocess.run(
|
|
|
|
| 224 |
check=False,
|
| 225 |
)
|
| 226 |
|
| 227 |
+
# Process and truncate outputs
|
| 228 |
stdout = completed.stdout
|
| 229 |
stderr = completed.stderr
|
| 230 |
+
|
| 231 |
+
max_output = 50_000
|
| 232 |
+
if len(stdout) > max_output:
|
| 233 |
+
stdout = stdout[:max_output] + f"\n... truncated ({len(stdout) - max_output} chars) ..."
|
| 234 |
+
if len(stderr) > max_output:
|
| 235 |
+
stderr = stderr[:max_output] + f"\n... truncated ({len(stderr) - max_output} chars) ..."
|
| 236 |
+
|
| 237 |
+
# Calculate duration
|
| 238 |
+
duration_ms = (time.time() - start_time) * 1000
|
| 239 |
|
| 240 |
+
result = {
|
| 241 |
"success": completed.returncode == 0,
|
| 242 |
"stdout": stdout,
|
| 243 |
"stderr": stderr,
|
|
|
|
| 245 |
"timed_out": False,
|
| 246 |
"command": command,
|
| 247 |
"cwd": cwd or ".",
|
| 248 |
+
"duration_ms": round(duration_ms, 1),
|
| 249 |
}
|
| 250 |
+
|
| 251 |
+
_log_command(command, result)
|
| 252 |
+
return result
|
| 253 |
+
|
| 254 |
except subprocess.TimeoutExpired as exc:
|
| 255 |
+
duration_ms = (time.time() - start_time) * 1000
|
| 256 |
+
|
| 257 |
+
# Handle timeout output carefully
|
| 258 |
+
stdout = ""
|
| 259 |
+
stderr = ""
|
| 260 |
+
|
| 261 |
+
if exc.stdout:
|
| 262 |
+
stdout = exc.stdout.decode('utf-8', errors='replace') if isinstance(exc.stdout, bytes) else str(exc.stdout)
|
| 263 |
+
if exc.stderr:
|
| 264 |
+
stderr = exc.stderr.decode('utf-8', errors='replace') if isinstance(exc.stderr, bytes) else str(exc.stderr)
|
| 265 |
+
|
| 266 |
+
# Truncate timeout outputs
|
| 267 |
+
if len(stdout) > 10000:
|
| 268 |
+
stdout = stdout[:10000] + "... [truncated]"
|
| 269 |
+
if len(stderr) > 10000:
|
| 270 |
+
stderr = stderr[:10000] + "... [truncated]"
|
| 271 |
+
|
| 272 |
+
result = {
|
| 273 |
"success": False,
|
| 274 |
"stdout": stdout,
|
| 275 |
"stderr": f"Timeout after {timeout}s\n{stderr}",
|
| 276 |
"returncode": -1,
|
| 277 |
"timed_out": True,
|
| 278 |
"command": command,
|
| 279 |
+
"cwd": cwd or ".",
|
| 280 |
+
"duration_ms": round(duration_ms, 1),
|
| 281 |
}
|
| 282 |
+
|
| 283 |
+
_log_command(command, result)
|
| 284 |
+
return result
|
| 285 |
+
|
| 286 |
except Exception as exc:
|
| 287 |
+
duration_ms = (time.time() - start_time) * 1000
|
| 288 |
+
|
| 289 |
+
result = {
|
| 290 |
"success": False,
|
| 291 |
"stdout": "",
|
| 292 |
"stderr": str(exc),
|
| 293 |
"returncode": -1,
|
| 294 |
"timed_out": False,
|
| 295 |
"command": command,
|
| 296 |
+
"cwd": cwd or ".",
|
| 297 |
+
"duration_ms": round(duration_ms, 1),
|
| 298 |
}
|
| 299 |
+
|
| 300 |
+
_log_command(command, result)
|
| 301 |
+
return result
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def run_bash_streaming(
|
| 305 |
+
command: str,
|
| 306 |
+
cwd: str | None = None,
|
| 307 |
+
timeout: int = 30,
|
| 308 |
+
on_output: callable = None,
|
| 309 |
+
) -> Iterator[str]:
|
| 310 |
+
"""Run a shell command with streaming output.
|
| 311 |
+
|
| 312 |
+
Yields output lines as they are produced.
|
| 313 |
+
|
| 314 |
+
Args:
|
| 315 |
+
command: Shell command to execute.
|
| 316 |
+
cwd: Working directory.
|
| 317 |
+
timeout: Max seconds before killing.
|
| 318 |
+
on_output: Optional callback for each line of output.
|
| 319 |
+
|
| 320 |
+
Yields:
|
| 321 |
+
Lines of output from the command.
|
| 322 |
+
"""
|
| 323 |
+
try:
|
| 324 |
+
safe, reason = _is_safe_command(command)
|
| 325 |
+
if not safe:
|
| 326 |
+
yield f"Error: {reason}"
|
| 327 |
+
return
|
| 328 |
+
|
| 329 |
+
work_dir = _resolve_safe(cwd) if cwd else get_workspace_root()
|
| 330 |
+
env = {k: v for k, v in os.environ.items() if k not in _ENV_SCRUB}
|
| 331 |
+
env["PYTHONUNBUFFERED"] = "1"
|
| 332 |
+
|
| 333 |
+
process = subprocess.Popen(
|
| 334 |
+
["bash", "-c", command],
|
| 335 |
+
cwd=work_dir,
|
| 336 |
+
env=env,
|
| 337 |
+
stdout=subprocess.PIPE,
|
| 338 |
+
stderr=subprocess.STDOUT,
|
| 339 |
+
text=True,
|
| 340 |
+
bufsize=1,
|
| 341 |
+
)
|
| 342 |
+
|
| 343 |
+
start_time = time.time()
|
| 344 |
+
|
| 345 |
+
for line in process.stdout:
|
| 346 |
+
line = line.rstrip('\n')
|
| 347 |
+
if on_output:
|
| 348 |
+
on_output(line)
|
| 349 |
+
yield line
|
| 350 |
+
|
| 351 |
+
# Check timeout
|
| 352 |
+
if time.time() - start_time > timeout:
|
| 353 |
+
process.kill()
|
| 354 |
+
yield f"\n[Timeout after {timeout}s]"
|
| 355 |
+
break
|
| 356 |
+
|
| 357 |
+
process.wait(timeout=5)
|
| 358 |
+
|
| 359 |
+
except Exception as exc:
|
| 360 |
+
yield f"Error: {exc}"
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
# Need Iterator type for streaming function
|
| 364 |
+
from collections.abc import Iterator
|
|
@@ -1,5 +1,11 @@
|
|
| 1 |
-
# SoniCoder v2 —
|
| 2 |
# Architecture inspired by Gemini CLI and Claude Code
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
gradio==6.19.0
|
| 5 |
transformers>=4.45.0
|
|
@@ -14,4 +20,16 @@ Pillow>=10.0
|
|
| 14 |
mcp>=1.0.0
|
| 15 |
|
| 16 |
# Optional: VLM support
|
| 17 |
-
# torchvision>=0.16.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SoniCoder v2.1 — Enhanced AI Code Agent
|
| 2 |
# Architecture inspired by Gemini CLI and Claude Code
|
| 3 |
+
#
|
| 4 |
+
# Enhanced with:
|
| 5 |
+
# - Better model support (DeepSeek Coder)
|
| 6 |
+
# - Session management
|
| 7 |
+
# - Export functionality
|
| 8 |
+
# - Performance optimizations
|
| 9 |
|
| 10 |
gradio==6.19.0
|
| 11 |
transformers>=4.45.0
|
|
|
|
| 20 |
mcp>=1.0.0
|
| 21 |
|
| 22 |
# Optional: VLM support
|
| 23 |
+
# torchvision>=0.16.0
|
| 24 |
+
|
| 25 |
+
# Additional dependencies for enhanced features
|
| 26 |
+
# Data processing
|
| 27 |
+
pandas>=2.0.0
|
| 28 |
+
numpy>=1.24.0
|
| 29 |
+
|
| 30 |
+
# Web scraping enhancements
|
| 31 |
+
lxml>=4.9.0
|
| 32 |
+
|
| 33 |
+
# Type checking (optional, for development)
|
| 34 |
+
# mypy>=1.0.0
|
| 35 |
+
# types-requests>=2.28.0
|