mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-03-21 12:53:09 +00:00
Compare commits
30 Commits
v0.1.3
...
9eb08d3f71
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9eb08d3f71 | ||
|
|
8d76deb75f | ||
|
|
3a31761542 | ||
|
|
96feb38aea | ||
|
|
1925818d49 | ||
|
|
38fc8788a2 | ||
|
|
b439e2d241 | ||
|
|
b0490be501 | ||
|
|
13a3ff9ac1 | ||
|
|
71f17c73c2 | ||
|
|
46ac373748 | ||
|
|
0d04a062a2 | ||
|
|
7d08700f3a | ||
|
|
5ecf74cb31 | ||
|
|
9259a799e3 | ||
|
|
f24c7cbf62 | ||
|
|
f664378775 | ||
|
|
a52f191a54 | ||
|
|
c0aaac241c | ||
|
|
547f1e7d9b | ||
|
|
73d6cfcd36 | ||
|
|
d15fd37e33 | ||
|
|
97a3250a37 | ||
|
|
a752ece70c | ||
|
|
3c61496021 | ||
|
|
6d4a198380 | ||
|
|
13785325d7 | ||
|
|
70131f2271 | ||
|
|
035e8fdfca | ||
|
|
f4facb3200 |
46
.env.example
46
.env.example
@@ -9,11 +9,6 @@
|
|||||||
# - webkit: Safari engine
|
# - webkit: Safari engine
|
||||||
# - msedge: Microsoft Edge
|
# - msedge: Microsoft Edge
|
||||||
# PLAYWRIGHT_BROWSER=firefox
|
# PLAYWRIGHT_BROWSER=firefox
|
||||||
#
|
|
||||||
# PLAYWRIGHT_HEADLESS: Run browser without visible window
|
|
||||||
# - true: Browser runs in background, saves CPU (default)
|
|
||||||
# - false: Browser opens a visible window (useful for debugging)
|
|
||||||
# PLAYWRIGHT_HEADLESS=true
|
|
||||||
|
|
||||||
# Extra Read Paths (Optional)
|
# Extra Read Paths (Optional)
|
||||||
# Comma-separated list of absolute paths for read-only access to external directories.
|
# Comma-separated list of absolute paths for read-only access to external directories.
|
||||||
@@ -25,40 +20,37 @@
|
|||||||
# Google Cloud Vertex AI Configuration (Optional)
|
# Google Cloud Vertex AI Configuration (Optional)
|
||||||
# To use Claude via Vertex AI on Google Cloud Platform, uncomment and set these variables.
|
# To use Claude via Vertex AI on Google Cloud Platform, uncomment and set these variables.
|
||||||
# Requires: gcloud CLI installed and authenticated (run: gcloud auth application-default login)
|
# Requires: gcloud CLI installed and authenticated (run: gcloud auth application-default login)
|
||||||
# Note: Use @ instead of - in model names (e.g., claude-opus-4-5@20251101)
|
# Note: Use @ instead of - in model names for date-suffixed models (e.g., claude-sonnet-4-5@20250929)
|
||||||
#
|
#
|
||||||
# CLAUDE_CODE_USE_VERTEX=1
|
# CLAUDE_CODE_USE_VERTEX=1
|
||||||
# CLOUD_ML_REGION=us-east5
|
# CLOUD_ML_REGION=us-east5
|
||||||
# ANTHROPIC_VERTEX_PROJECT_ID=your-gcp-project-id
|
# ANTHROPIC_VERTEX_PROJECT_ID=your-gcp-project-id
|
||||||
# ANTHROPIC_DEFAULT_OPUS_MODEL=claude-opus-4-5@20251101
|
# ANTHROPIC_DEFAULT_OPUS_MODEL=claude-opus-4-6
|
||||||
# ANTHROPIC_DEFAULT_SONNET_MODEL=claude-sonnet-4-5@20250929
|
# ANTHROPIC_DEFAULT_SONNET_MODEL=claude-sonnet-4-5@20250929
|
||||||
# ANTHROPIC_DEFAULT_HAIKU_MODEL=claude-3-5-haiku@20241022
|
# ANTHROPIC_DEFAULT_HAIKU_MODEL=claude-3-5-haiku@20241022
|
||||||
|
|
||||||
# GLM/Alternative API Configuration (Optional)
|
# ===================
|
||||||
# To use Zhipu AI's GLM models instead of Claude, uncomment and set these variables.
|
# Alternative API Providers (GLM, Ollama, Kimi, Custom)
|
||||||
# This only affects AutoForge - your global Claude Code settings remain unchanged.
|
# ===================
|
||||||
# Get an API key at: https://z.ai/subscribe
|
# Configure via Settings UI (recommended) or set env vars below.
|
||||||
|
# When both are set, env vars take precedence.
|
||||||
#
|
#
|
||||||
|
# GLM (Zhipu AI):
|
||||||
# ANTHROPIC_BASE_URL=https://api.z.ai/api/anthropic
|
# ANTHROPIC_BASE_URL=https://api.z.ai/api/anthropic
|
||||||
# ANTHROPIC_AUTH_TOKEN=your-zhipu-api-key
|
# ANTHROPIC_AUTH_TOKEN=your-glm-api-key
|
||||||
# API_TIMEOUT_MS=3000000
|
|
||||||
# ANTHROPIC_DEFAULT_SONNET_MODEL=glm-4.7
|
|
||||||
# ANTHROPIC_DEFAULT_OPUS_MODEL=glm-4.7
|
# ANTHROPIC_DEFAULT_OPUS_MODEL=glm-4.7
|
||||||
# ANTHROPIC_DEFAULT_HAIKU_MODEL=glm-4.5-air
|
# ANTHROPIC_DEFAULT_SONNET_MODEL=glm-4.7
|
||||||
|
# ANTHROPIC_DEFAULT_HAIKU_MODEL=glm-4.7
|
||||||
# Ollama Local Model Configuration (Optional)
|
|
||||||
# To use local models via Ollama instead of Claude, uncomment and set these variables.
|
|
||||||
# Requires Ollama v0.14.0+ with Anthropic API compatibility.
|
|
||||||
# See: https://ollama.com/blog/claude
|
|
||||||
#
|
#
|
||||||
|
# Ollama (Local):
|
||||||
# ANTHROPIC_BASE_URL=http://localhost:11434
|
# ANTHROPIC_BASE_URL=http://localhost:11434
|
||||||
# ANTHROPIC_AUTH_TOKEN=ollama
|
|
||||||
# API_TIMEOUT_MS=3000000
|
|
||||||
# ANTHROPIC_DEFAULT_SONNET_MODEL=qwen3-coder
|
|
||||||
# ANTHROPIC_DEFAULT_OPUS_MODEL=qwen3-coder
|
# ANTHROPIC_DEFAULT_OPUS_MODEL=qwen3-coder
|
||||||
|
# ANTHROPIC_DEFAULT_SONNET_MODEL=qwen3-coder
|
||||||
# ANTHROPIC_DEFAULT_HAIKU_MODEL=qwen3-coder
|
# ANTHROPIC_DEFAULT_HAIKU_MODEL=qwen3-coder
|
||||||
#
|
#
|
||||||
# Model recommendations:
|
# Kimi (Moonshot):
|
||||||
# - For best results, use a capable coding model like qwen3-coder or deepseek-coder-v2
|
# ANTHROPIC_BASE_URL=https://api.kimi.com/coding/
|
||||||
# - You can use the same model for all tiers, or different models per tier
|
# ANTHROPIC_API_KEY=your-kimi-api-key
|
||||||
# - Larger models (70B+) work best for Opus tier, smaller (7B-20B) for Haiku
|
# ANTHROPIC_DEFAULT_OPUS_MODEL=kimi-k2.5
|
||||||
|
# ANTHROPIC_DEFAULT_SONNET_MODEL=kimi-k2.5
|
||||||
|
# ANTHROPIC_DEFAULT_HAIKU_MODEL=kimi-k2.5
|
||||||
|
|||||||
37
CLAUDE.md
37
CLAUDE.md
@@ -408,44 +408,23 @@ Run coding agents via Google Cloud Vertex AI:
|
|||||||
CLAUDE_CODE_USE_VERTEX=1
|
CLAUDE_CODE_USE_VERTEX=1
|
||||||
CLOUD_ML_REGION=us-east5
|
CLOUD_ML_REGION=us-east5
|
||||||
ANTHROPIC_VERTEX_PROJECT_ID=your-gcp-project-id
|
ANTHROPIC_VERTEX_PROJECT_ID=your-gcp-project-id
|
||||||
ANTHROPIC_DEFAULT_OPUS_MODEL=claude-opus-4-5@20251101
|
ANTHROPIC_DEFAULT_OPUS_MODEL=claude-opus-4-6
|
||||||
ANTHROPIC_DEFAULT_SONNET_MODEL=claude-sonnet-4-5@20250929
|
ANTHROPIC_DEFAULT_SONNET_MODEL=claude-sonnet-4-5@20250929
|
||||||
ANTHROPIC_DEFAULT_HAIKU_MODEL=claude-3-5-haiku@20241022
|
ANTHROPIC_DEFAULT_HAIKU_MODEL=claude-3-5-haiku@20241022
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:** Use `@` instead of `-` in model names for Vertex AI.
|
**Note:** Use `@` instead of `-` in model names for Vertex AI.
|
||||||
|
|
||||||
### Ollama Local Models (Optional)
|
### Alternative API Providers (GLM, Ollama, Kimi, Custom)
|
||||||
|
|
||||||
Run coding agents using local models via Ollama v0.14.0+:
|
Alternative providers are configured via the **Settings UI** (gear icon > API Provider section). Select a provider, set the base URL, auth token, and model — no `.env` changes needed.
|
||||||
|
|
||||||
1. Install Ollama: https://ollama.com
|
**Available providers:** Claude (default), GLM (Zhipu AI), Ollama (local models), Kimi (Moonshot), Custom
|
||||||
2. Start Ollama: `ollama serve`
|
|
||||||
3. Pull a coding model: `ollama pull qwen3-coder`
|
|
||||||
4. Configure `.env`:
|
|
||||||
```
|
|
||||||
ANTHROPIC_BASE_URL=http://localhost:11434
|
|
||||||
ANTHROPIC_AUTH_TOKEN=ollama
|
|
||||||
API_TIMEOUT_MS=3000000
|
|
||||||
ANTHROPIC_DEFAULT_SONNET_MODEL=qwen3-coder
|
|
||||||
ANTHROPIC_DEFAULT_OPUS_MODEL=qwen3-coder
|
|
||||||
ANTHROPIC_DEFAULT_HAIKU_MODEL=qwen3-coder
|
|
||||||
```
|
|
||||||
5. Run AutoForge normally - it will use your local Ollama models
|
|
||||||
|
|
||||||
**Recommended coding models:**
|
**Ollama notes:**
|
||||||
- `qwen3-coder` - Good balance of speed and capability
|
- Requires Ollama v0.14.0+ with Anthropic API compatibility
|
||||||
- `deepseek-coder-v2` - Strong coding performance
|
- Install: https://ollama.com → `ollama serve` → `ollama pull qwen3-coder`
|
||||||
- `codellama` - Meta's code-focused model
|
- Recommended models: `qwen3-coder`, `deepseek-coder-v2`, `codellama`
|
||||||
|
|
||||||
**Model tier mapping:**
|
|
||||||
- Use the same model for all tiers, or map different models per capability level
|
|
||||||
- Larger models (70B+) work best for Opus tier
|
|
||||||
- Smaller models (7B-20B) work well for Haiku tier
|
|
||||||
|
|
||||||
**Known limitations:**
|
|
||||||
- Smaller context windows than Claude (model-dependent)
|
|
||||||
- Extended context beta disabled (not supported by Ollama)
|
|
||||||
- Performance depends on local hardware (GPU recommended)
|
- Performance depends on local hardware (GPU recommended)
|
||||||
|
|
||||||
## Claude Code Integration
|
## Claude Code Integration
|
||||||
|
|||||||
38
README.md
38
README.md
@@ -6,9 +6,9 @@ A long-running autonomous coding agent powered by the Claude Agent SDK. This too
|
|||||||
|
|
||||||
## Video Tutorial
|
## Video Tutorial
|
||||||
|
|
||||||
[](https://youtu.be/lGWFlpffWk4)
|
[](https://youtu.be/nKiPOxDpcJY)
|
||||||
|
|
||||||
> **[Watch the setup and usage guide →](https://youtu.be/lGWFlpffWk4)**
|
> **[Watch the setup and usage guide →](https://youtu.be/nKiPOxDpcJY)**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -326,37 +326,13 @@ When test progress increases, the agent sends:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Using GLM Models (Alternative to Claude)
|
### Alternative API Providers (GLM, Ollama, Kimi, Custom)
|
||||||
|
|
||||||
Add these variables to your `.env` file to use Zhipu AI's GLM models:
|
Alternative providers are configured via the **Settings UI** (gear icon > API Provider). Select your provider, set the base URL, auth token, and model directly in the UI — no `.env` changes needed.
|
||||||
|
|
||||||
```bash
|
Available providers: **Claude** (default), **GLM** (Zhipu AI), **Ollama** (local models), **Kimi** (Moonshot), **Custom**
|
||||||
ANTHROPIC_BASE_URL=https://api.z.ai/api/anthropic
|
|
||||||
ANTHROPIC_AUTH_TOKEN=your-zhipu-api-key
|
|
||||||
API_TIMEOUT_MS=3000000
|
|
||||||
ANTHROPIC_DEFAULT_SONNET_MODEL=glm-4.7
|
|
||||||
ANTHROPIC_DEFAULT_OPUS_MODEL=glm-4.7
|
|
||||||
ANTHROPIC_DEFAULT_HAIKU_MODEL=glm-4.5-air
|
|
||||||
```
|
|
||||||
|
|
||||||
This routes AutoForge's API requests through Zhipu's Claude-compatible API, allowing you to use GLM-4.7 and other models. **This only affects AutoForge** - your global Claude Code settings remain unchanged.
|
For Ollama, install [Ollama v0.14.0+](https://ollama.com), run `ollama serve`, and pull a coding model (e.g., `ollama pull qwen3-coder`). Then select "Ollama" in the Settings UI.
|
||||||
|
|
||||||
Get an API key at: https://z.ai/subscribe
|
|
||||||
|
|
||||||
### Using Ollama Local Models
|
|
||||||
|
|
||||||
Add these variables to your `.env` file to run agents with local models via Ollama v0.14.0+:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ANTHROPIC_BASE_URL=http://localhost:11434
|
|
||||||
ANTHROPIC_AUTH_TOKEN=ollama
|
|
||||||
API_TIMEOUT_MS=3000000
|
|
||||||
ANTHROPIC_DEFAULT_SONNET_MODEL=qwen3-coder
|
|
||||||
ANTHROPIC_DEFAULT_OPUS_MODEL=qwen3-coder
|
|
||||||
ANTHROPIC_DEFAULT_HAIKU_MODEL=qwen3-coder
|
|
||||||
```
|
|
||||||
|
|
||||||
See the [CLAUDE.md](CLAUDE.md) for recommended models and known limitations.
|
|
||||||
|
|
||||||
### Using Vertex AI
|
### Using Vertex AI
|
||||||
|
|
||||||
@@ -366,7 +342,7 @@ Add these variables to your `.env` file to run agents via Google Cloud Vertex AI
|
|||||||
CLAUDE_CODE_USE_VERTEX=1
|
CLAUDE_CODE_USE_VERTEX=1
|
||||||
CLOUD_ML_REGION=us-east5
|
CLOUD_ML_REGION=us-east5
|
||||||
ANTHROPIC_VERTEX_PROJECT_ID=your-gcp-project-id
|
ANTHROPIC_VERTEX_PROJECT_ID=your-gcp-project-id
|
||||||
ANTHROPIC_DEFAULT_OPUS_MODEL=claude-opus-4-5@20251101
|
ANTHROPIC_DEFAULT_OPUS_MODEL=claude-opus-4-6
|
||||||
ANTHROPIC_DEFAULT_SONNET_MODEL=claude-sonnet-4-5@20250929
|
ANTHROPIC_DEFAULT_SONNET_MODEL=claude-sonnet-4-5@20250929
|
||||||
ANTHROPIC_DEFAULT_HAIKU_MODEL=claude-3-5-haiku@20241022
|
ANTHROPIC_DEFAULT_HAIKU_MODEL=claude-3-5-haiku@20241022
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -44,8 +44,10 @@ from dotenv import load_dotenv
|
|||||||
# IMPORTANT: Must be called BEFORE importing other modules that read env vars at load time
|
# IMPORTANT: Must be called BEFORE importing other modules that read env vars at load time
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
from agent import run_autonomous_agent
|
from agent import run_autonomous_agent
|
||||||
from registry import DEFAULT_MODEL, get_project_path
|
from registry import DEFAULT_MODEL, get_effective_sdk_env, get_project_path
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
@@ -195,6 +197,14 @@ def main() -> None:
|
|||||||
# Note: Authentication is handled by start.bat/start.sh before this script runs.
|
# Note: Authentication is handled by start.bat/start.sh before this script runs.
|
||||||
# The Claude SDK auto-detects credentials from ~/.claude/.credentials.json
|
# The Claude SDK auto-detects credentials from ~/.claude/.credentials.json
|
||||||
|
|
||||||
|
# Apply UI-configured provider settings to this process's environment.
|
||||||
|
# This ensures CLI-launched agents respect Settings UI provider config (GLM, Ollama, etc.).
|
||||||
|
# Uses setdefault so explicit env vars / .env file take precedence.
|
||||||
|
sdk_overrides = get_effective_sdk_env()
|
||||||
|
for key, value in sdk_overrides.items():
|
||||||
|
if value: # Only set non-empty values (empty values are used to clear conflicts)
|
||||||
|
os.environ.setdefault(key, value)
|
||||||
|
|
||||||
# Handle deprecated --parallel flag
|
# Handle deprecated --parallel flag
|
||||||
if args.parallel is not None:
|
if args.parallel is not None:
|
||||||
print("WARNING: --parallel is deprecated. Use --concurrency instead.", flush=True)
|
print("WARNING: --parallel is deprecated. Use --concurrency instead.", flush=True)
|
||||||
|
|||||||
0
bin/autoforge.js
Normal file → Executable file
0
bin/autoforge.js
Normal file → Executable file
21
client.py
21
client.py
@@ -16,7 +16,6 @@ from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
|||||||
from claude_agent_sdk.types import HookContext, HookInput, HookMatcher, SyncHookJSONOutput
|
from claude_agent_sdk.types import HookContext, HookInput, HookMatcher, SyncHookJSONOutput
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from env_constants import API_ENV_VARS
|
|
||||||
from security import SENSITIVE_DIRECTORIES, bash_security_hook
|
from security import SENSITIVE_DIRECTORIES, bash_security_hook
|
||||||
|
|
||||||
# Load environment variables from .env file if present
|
# Load environment variables from .env file if present
|
||||||
@@ -46,8 +45,9 @@ def convert_model_for_vertex(model: str) -> str:
|
|||||||
"""
|
"""
|
||||||
Convert model name format for Vertex AI compatibility.
|
Convert model name format for Vertex AI compatibility.
|
||||||
|
|
||||||
Vertex AI uses @ to separate model name from version (e.g., claude-opus-4-5@20251101)
|
Vertex AI uses @ to separate model name from version (e.g., claude-sonnet-4-5@20250929)
|
||||||
while the Anthropic API uses - (e.g., claude-opus-4-5-20251101).
|
while the Anthropic API uses - (e.g., claude-sonnet-4-5-20250929).
|
||||||
|
Models without a date suffix (e.g., claude-opus-4-6) pass through unchanged.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
model: Model name in Anthropic format (with hyphens)
|
model: Model name in Anthropic format (with hyphens)
|
||||||
@@ -61,7 +61,7 @@ def convert_model_for_vertex(model: str) -> str:
|
|||||||
return model
|
return model
|
||||||
|
|
||||||
# Pattern: claude-{name}-{version}-{date} -> claude-{name}-{version}@{date}
|
# Pattern: claude-{name}-{version}-{date} -> claude-{name}-{version}@{date}
|
||||||
# Example: claude-opus-4-5-20251101 -> claude-opus-4-5@20251101
|
# Example: claude-sonnet-4-5-20250929 -> claude-sonnet-4-5@20250929
|
||||||
# The date is always 8 digits at the end
|
# The date is always 8 digits at the end
|
||||||
match = re.match(r'^(claude-.+)-(\d{8})$', model)
|
match = re.match(r'^(claude-.+)-(\d{8})$', model)
|
||||||
if match:
|
if match:
|
||||||
@@ -449,14 +449,11 @@ def create_client(
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Build environment overrides for API endpoint configuration
|
# Build environment overrides for API endpoint configuration
|
||||||
# These override system env vars for the Claude CLI subprocess,
|
# Uses get_effective_sdk_env() which reads provider settings from the database,
|
||||||
# allowing AutoForge to use alternative APIs (e.g., GLM) without
|
# ensuring UI-configured alternative providers (GLM, Ollama, Kimi, Custom) propagate
|
||||||
# affecting the user's global Claude Code settings
|
# correctly to the Claude CLI subprocess
|
||||||
sdk_env = {}
|
from registry import get_effective_sdk_env
|
||||||
for var in API_ENV_VARS:
|
sdk_env = get_effective_sdk_env()
|
||||||
value = os.getenv(var)
|
|
||||||
if value:
|
|
||||||
sdk_env[var] = value
|
|
||||||
|
|
||||||
# Detect alternative API mode (Ollama, GLM, or Vertex AI)
|
# Detect alternative API mode (Ollama, GLM, or Vertex AI)
|
||||||
base_url = sdk_env.get("ANTHROPIC_BASE_URL", "")
|
base_url = sdk_env.get("ANTHROPIC_BASE_URL", "")
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ API_ENV_VARS: list[str] = [
|
|||||||
# Core API configuration
|
# Core API configuration
|
||||||
"ANTHROPIC_BASE_URL", # Custom API endpoint (e.g., https://api.z.ai/api/anthropic)
|
"ANTHROPIC_BASE_URL", # Custom API endpoint (e.g., https://api.z.ai/api/anthropic)
|
||||||
"ANTHROPIC_AUTH_TOKEN", # API authentication token
|
"ANTHROPIC_AUTH_TOKEN", # API authentication token
|
||||||
|
"ANTHROPIC_API_KEY", # API key (used by Kimi and other providers)
|
||||||
"API_TIMEOUT_MS", # Request timeout in milliseconds
|
"API_TIMEOUT_MS", # Request timeout in milliseconds
|
||||||
# Model tier overrides
|
# Model tier overrides
|
||||||
"ANTHROPIC_DEFAULT_SONNET_MODEL", # Model override for Sonnet
|
"ANTHROPIC_DEFAULT_SONNET_MODEL", # Model override for Sonnet
|
||||||
|
|||||||
@@ -984,5 +984,35 @@ def feature_set_dependencies(
|
|||||||
return json.dumps({"error": f"Failed to set dependencies: {str(e)}"})
|
return json.dumps({"error": f"Failed to set dependencies: {str(e)}"})
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def ask_user(
|
||||||
|
questions: Annotated[list[dict], Field(description="List of questions to ask, each with question, header, options (list of {label, description}), and multiSelect (bool)")]
|
||||||
|
) -> str:
|
||||||
|
"""Ask the user structured questions with selectable options.
|
||||||
|
|
||||||
|
Use this when you need clarification or want to offer choices to the user.
|
||||||
|
Each question has a short header, the question text, and 2-4 clickable options.
|
||||||
|
The user's selections will be returned as your next message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
questions: List of questions, each with:
|
||||||
|
- question (str): The question to ask
|
||||||
|
- header (str): Short label (max 12 chars)
|
||||||
|
- options (list): Each with label (str) and description (str)
|
||||||
|
- multiSelect (bool): Allow multiple selections (default false)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Acknowledgment that questions were presented to the user
|
||||||
|
"""
|
||||||
|
# Validate input
|
||||||
|
for i, q in enumerate(questions):
|
||||||
|
if not all(key in q for key in ["question", "header", "options"]):
|
||||||
|
return json.dumps({"error": f"Question at index {i} missing required fields"})
|
||||||
|
if len(q["options"]) < 2 or len(q["options"]) > 4:
|
||||||
|
return json.dumps({"error": f"Question at index {i} must have 2-4 options"})
|
||||||
|
|
||||||
|
return "Questions presented to the user. Their response will arrive as your next message."
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
mcp.run()
|
mcp.run()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "autoforge-ai",
|
"name": "autoforge-ai",
|
||||||
"version": "0.1.3",
|
"version": "0.1.9",
|
||||||
"description": "Autonomous coding agent with web UI - build complete apps with AI",
|
"description": "Autonomous coding agent with web UI - build complete apps with AI",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
167
registry.py
167
registry.py
@@ -46,10 +46,16 @@ def _migrate_registry_dir() -> None:
|
|||||||
# Available models with display names
|
# Available models with display names
|
||||||
# To add a new model: add an entry here with {"id": "model-id", "name": "Display Name"}
|
# To add a new model: add an entry here with {"id": "model-id", "name": "Display Name"}
|
||||||
AVAILABLE_MODELS = [
|
AVAILABLE_MODELS = [
|
||||||
{"id": "claude-opus-4-5-20251101", "name": "Claude Opus 4.5"},
|
{"id": "claude-opus-4-6", "name": "Claude Opus"},
|
||||||
{"id": "claude-sonnet-4-5-20250929", "name": "Claude Sonnet 4.5"},
|
{"id": "claude-sonnet-4-5-20250929", "name": "Claude Sonnet"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Map legacy model IDs to their current replacements.
|
||||||
|
# Used by get_all_settings() to auto-migrate stale values on first read after upgrade.
|
||||||
|
LEGACY_MODEL_MAP = {
|
||||||
|
"claude-opus-4-5-20251101": "claude-opus-4-6",
|
||||||
|
}
|
||||||
|
|
||||||
# List of valid model IDs (derived from AVAILABLE_MODELS)
|
# List of valid model IDs (derived from AVAILABLE_MODELS)
|
||||||
VALID_MODELS = [m["id"] for m in AVAILABLE_MODELS]
|
VALID_MODELS = [m["id"] for m in AVAILABLE_MODELS]
|
||||||
|
|
||||||
@@ -59,7 +65,7 @@ VALID_MODELS = [m["id"] for m in AVAILABLE_MODELS]
|
|||||||
_env_default_model = os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL")
|
_env_default_model = os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL")
|
||||||
if _env_default_model is not None:
|
if _env_default_model is not None:
|
||||||
_env_default_model = _env_default_model.strip()
|
_env_default_model = _env_default_model.strip()
|
||||||
DEFAULT_MODEL = _env_default_model or "claude-opus-4-5-20251101"
|
DEFAULT_MODEL = _env_default_model or "claude-opus-4-6"
|
||||||
|
|
||||||
# Ensure env-provided DEFAULT_MODEL is in VALID_MODELS for validation consistency
|
# Ensure env-provided DEFAULT_MODEL is in VALID_MODELS for validation consistency
|
||||||
# (idempotent: only adds if missing, doesn't alter AVAILABLE_MODELS semantics)
|
# (idempotent: only adds if missing, doesn't alter AVAILABLE_MODELS semantics)
|
||||||
@@ -598,6 +604,9 @@ def get_all_settings() -> dict[str, str]:
|
|||||||
"""
|
"""
|
||||||
Get all settings as a dictionary.
|
Get all settings as a dictionary.
|
||||||
|
|
||||||
|
Automatically migrates legacy model IDs (e.g. claude-opus-4-5-20251101 -> claude-opus-4-6)
|
||||||
|
on first read after upgrade. This is a one-time silent migration.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary mapping setting keys to values.
|
Dictionary mapping setting keys to values.
|
||||||
"""
|
"""
|
||||||
@@ -606,9 +615,159 @@ def get_all_settings() -> dict[str, str]:
|
|||||||
session = SessionLocal()
|
session = SessionLocal()
|
||||||
try:
|
try:
|
||||||
settings = session.query(Settings).all()
|
settings = session.query(Settings).all()
|
||||||
return {s.key: s.value for s in settings}
|
result = {s.key: s.value for s in settings}
|
||||||
|
|
||||||
|
# Auto-migrate legacy model IDs
|
||||||
|
migrated = False
|
||||||
|
for key in ("model", "api_model"):
|
||||||
|
old_id = result.get(key)
|
||||||
|
if old_id and old_id in LEGACY_MODEL_MAP:
|
||||||
|
new_id = LEGACY_MODEL_MAP[old_id]
|
||||||
|
setting = session.query(Settings).filter(Settings.key == key).first()
|
||||||
|
if setting:
|
||||||
|
setting.value = new_id
|
||||||
|
setting.updated_at = datetime.now()
|
||||||
|
result[key] = new_id
|
||||||
|
migrated = True
|
||||||
|
logger.info("Migrated setting '%s': %s -> %s", key, old_id, new_id)
|
||||||
|
|
||||||
|
if migrated:
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return result
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to read settings: %s", e)
|
logger.warning("Failed to read settings: %s", e)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# API Provider Definitions
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
API_PROVIDERS: dict[str, dict[str, Any]] = {
|
||||||
|
"claude": {
|
||||||
|
"name": "Claude (Anthropic)",
|
||||||
|
"base_url": None,
|
||||||
|
"requires_auth": False,
|
||||||
|
"models": [
|
||||||
|
{"id": "claude-opus-4-6", "name": "Claude Opus"},
|
||||||
|
{"id": "claude-sonnet-4-5-20250929", "name": "Claude Sonnet"},
|
||||||
|
],
|
||||||
|
"default_model": "claude-opus-4-6",
|
||||||
|
},
|
||||||
|
"kimi": {
|
||||||
|
"name": "Kimi K2.5 (Moonshot)",
|
||||||
|
"base_url": "https://api.kimi.com/coding/",
|
||||||
|
"requires_auth": True,
|
||||||
|
"auth_env_var": "ANTHROPIC_API_KEY",
|
||||||
|
"models": [{"id": "kimi-k2.5", "name": "Kimi K2.5"}],
|
||||||
|
"default_model": "kimi-k2.5",
|
||||||
|
},
|
||||||
|
"glm": {
|
||||||
|
"name": "GLM (Zhipu AI)",
|
||||||
|
"base_url": "https://api.z.ai/api/anthropic",
|
||||||
|
"requires_auth": True,
|
||||||
|
"auth_env_var": "ANTHROPIC_AUTH_TOKEN",
|
||||||
|
"models": [
|
||||||
|
{"id": "glm-4.7", "name": "GLM 4.7"},
|
||||||
|
{"id": "glm-4.5-air", "name": "GLM 4.5 Air"},
|
||||||
|
],
|
||||||
|
"default_model": "glm-4.7",
|
||||||
|
},
|
||||||
|
"ollama": {
|
||||||
|
"name": "Ollama (Local)",
|
||||||
|
"base_url": "http://localhost:11434",
|
||||||
|
"requires_auth": False,
|
||||||
|
"models": [
|
||||||
|
{"id": "qwen3-coder", "name": "Qwen3 Coder"},
|
||||||
|
{"id": "deepseek-coder-v2", "name": "DeepSeek Coder V2"},
|
||||||
|
],
|
||||||
|
"default_model": "qwen3-coder",
|
||||||
|
},
|
||||||
|
"custom": {
|
||||||
|
"name": "Custom Provider",
|
||||||
|
"base_url": "",
|
||||||
|
"requires_auth": True,
|
||||||
|
"auth_env_var": "ANTHROPIC_AUTH_TOKEN",
|
||||||
|
"models": [],
|
||||||
|
"default_model": "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_effective_sdk_env() -> dict[str, str]:
|
||||||
|
"""Build environment variable dict for Claude SDK based on current API provider settings.
|
||||||
|
|
||||||
|
When api_provider is "claude" (or unset), falls back to existing env vars (current behavior).
|
||||||
|
For other providers, builds env dict from stored settings (api_base_url, api_auth_token, api_model).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict ready to merge into subprocess env or pass to SDK.
|
||||||
|
"""
|
||||||
|
all_settings = get_all_settings()
|
||||||
|
provider_id = all_settings.get("api_provider", "claude")
|
||||||
|
|
||||||
|
if provider_id == "claude":
|
||||||
|
# Default behavior: forward existing env vars
|
||||||
|
from env_constants import API_ENV_VARS
|
||||||
|
sdk_env: dict[str, str] = {}
|
||||||
|
for var in API_ENV_VARS:
|
||||||
|
value = os.getenv(var)
|
||||||
|
if value:
|
||||||
|
sdk_env[var] = value
|
||||||
|
return sdk_env
|
||||||
|
|
||||||
|
# Alternative provider: build env from settings
|
||||||
|
provider = API_PROVIDERS.get(provider_id)
|
||||||
|
if not provider:
|
||||||
|
logger.warning("Unknown API provider '%s', falling back to claude", provider_id)
|
||||||
|
from env_constants import API_ENV_VARS
|
||||||
|
sdk_env = {}
|
||||||
|
for var in API_ENV_VARS:
|
||||||
|
value = os.getenv(var)
|
||||||
|
if value:
|
||||||
|
sdk_env[var] = value
|
||||||
|
return sdk_env
|
||||||
|
|
||||||
|
sdk_env: dict[str, str] = {}
|
||||||
|
|
||||||
|
# Explicitly clear credentials that could leak from the server process env.
|
||||||
|
# For providers using ANTHROPIC_AUTH_TOKEN (GLM, Custom), clear ANTHROPIC_API_KEY.
|
||||||
|
# For providers using ANTHROPIC_API_KEY (Kimi), clear ANTHROPIC_AUTH_TOKEN.
|
||||||
|
# This prevents the Claude CLI from using the wrong credentials.
|
||||||
|
auth_env_var = provider.get("auth_env_var", "ANTHROPIC_AUTH_TOKEN")
|
||||||
|
if auth_env_var == "ANTHROPIC_AUTH_TOKEN":
|
||||||
|
sdk_env["ANTHROPIC_API_KEY"] = ""
|
||||||
|
elif auth_env_var == "ANTHROPIC_API_KEY":
|
||||||
|
sdk_env["ANTHROPIC_AUTH_TOKEN"] = ""
|
||||||
|
|
||||||
|
# Clear Vertex AI vars when using non-Vertex alternative providers
|
||||||
|
sdk_env["CLAUDE_CODE_USE_VERTEX"] = ""
|
||||||
|
sdk_env["CLOUD_ML_REGION"] = ""
|
||||||
|
sdk_env["ANTHROPIC_VERTEX_PROJECT_ID"] = ""
|
||||||
|
|
||||||
|
# Base URL
|
||||||
|
base_url = all_settings.get("api_base_url") or provider.get("base_url")
|
||||||
|
if base_url:
|
||||||
|
sdk_env["ANTHROPIC_BASE_URL"] = base_url
|
||||||
|
|
||||||
|
# Auth token
|
||||||
|
auth_token = all_settings.get("api_auth_token")
|
||||||
|
if auth_token:
|
||||||
|
sdk_env[auth_env_var] = auth_token
|
||||||
|
|
||||||
|
# Model - set all three tier overrides to the same model
|
||||||
|
model = all_settings.get("api_model") or provider.get("default_model")
|
||||||
|
if model:
|
||||||
|
sdk_env["ANTHROPIC_DEFAULT_OPUS_MODEL"] = model
|
||||||
|
sdk_env["ANTHROPIC_DEFAULT_SONNET_MODEL"] = model
|
||||||
|
sdk_env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = model
|
||||||
|
|
||||||
|
# Timeout
|
||||||
|
timeout = all_settings.get("api_timeout_ms")
|
||||||
|
if timeout:
|
||||||
|
sdk_env["API_TIMEOUT_MS"] = timeout
|
||||||
|
|
||||||
|
return sdk_env
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ def _get_settings_defaults() -> tuple[bool, str, int, bool, int]:
|
|||||||
|
|
||||||
settings = get_all_settings()
|
settings = get_all_settings()
|
||||||
yolo_mode = (settings.get("yolo_mode") or "false").lower() == "true"
|
yolo_mode = (settings.get("yolo_mode") or "false").lower() == "true"
|
||||||
model = settings.get("model", DEFAULT_MODEL)
|
model = settings.get("api_model") or settings.get("model", DEFAULT_MODEL)
|
||||||
|
|
||||||
# Parse testing agent settings with defaults
|
# Parse testing agent settings with defaults
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from ..services.assistant_database import (
|
|||||||
get_conversations,
|
get_conversations,
|
||||||
)
|
)
|
||||||
from ..utils.project_helpers import get_project_path as _get_project_path
|
from ..utils.project_helpers import get_project_path as _get_project_path
|
||||||
from ..utils.validation import is_valid_project_name as validate_project_name
|
from ..utils.validation import validate_project_name
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -207,30 +207,38 @@ async def assistant_chat_websocket(websocket: WebSocket, project_name: str):
|
|||||||
Client -> Server:
|
Client -> Server:
|
||||||
- {"type": "start", "conversation_id": int | null} - Start/resume session
|
- {"type": "start", "conversation_id": int | null} - Start/resume session
|
||||||
- {"type": "message", "content": "..."} - Send user message
|
- {"type": "message", "content": "..."} - Send user message
|
||||||
|
- {"type": "answer", "answers": {...}} - Answer to structured questions
|
||||||
- {"type": "ping"} - Keep-alive ping
|
- {"type": "ping"} - Keep-alive ping
|
||||||
|
|
||||||
Server -> Client:
|
Server -> Client:
|
||||||
- {"type": "conversation_created", "conversation_id": int} - New conversation created
|
- {"type": "conversation_created", "conversation_id": int} - New conversation created
|
||||||
- {"type": "text", "content": "..."} - Text chunk from Claude
|
- {"type": "text", "content": "..."} - Text chunk from Claude
|
||||||
- {"type": "tool_call", "tool": "...", "input": {...}} - Tool being called
|
- {"type": "tool_call", "tool": "...", "input": {...}} - Tool being called
|
||||||
|
- {"type": "question", "questions": [...]} - Structured questions for user
|
||||||
- {"type": "response_done"} - Response complete
|
- {"type": "response_done"} - Response complete
|
||||||
- {"type": "error", "content": "..."} - Error message
|
- {"type": "error", "content": "..."} - Error message
|
||||||
- {"type": "pong"} - Keep-alive pong
|
- {"type": "pong"} - Keep-alive pong
|
||||||
"""
|
"""
|
||||||
if not validate_project_name(project_name):
|
# Always accept WebSocket first to avoid opaque 403 errors
|
||||||
|
await websocket.accept()
|
||||||
|
|
||||||
|
try:
|
||||||
|
project_name = validate_project_name(project_name)
|
||||||
|
except HTTPException:
|
||||||
|
await websocket.send_json({"type": "error", "content": "Invalid project name"})
|
||||||
await websocket.close(code=4000, reason="Invalid project name")
|
await websocket.close(code=4000, reason="Invalid project name")
|
||||||
return
|
return
|
||||||
|
|
||||||
project_dir = _get_project_path(project_name)
|
project_dir = _get_project_path(project_name)
|
||||||
if not project_dir:
|
if not project_dir:
|
||||||
|
await websocket.send_json({"type": "error", "content": "Project not found in registry"})
|
||||||
await websocket.close(code=4004, reason="Project not found in registry")
|
await websocket.close(code=4004, reason="Project not found in registry")
|
||||||
return
|
return
|
||||||
|
|
||||||
if not project_dir.exists():
|
if not project_dir.exists():
|
||||||
|
await websocket.send_json({"type": "error", "content": "Project directory not found"})
|
||||||
await websocket.close(code=4004, reason="Project directory not found")
|
await websocket.close(code=4004, reason="Project directory not found")
|
||||||
return
|
return
|
||||||
|
|
||||||
await websocket.accept()
|
|
||||||
logger.info(f"Assistant WebSocket connected for project: {project_name}")
|
logger.info(f"Assistant WebSocket connected for project: {project_name}")
|
||||||
|
|
||||||
session: Optional[AssistantChatSession] = None
|
session: Optional[AssistantChatSession] = None
|
||||||
@@ -297,6 +305,34 @@ async def assistant_chat_websocket(websocket: WebSocket, project_name: str):
|
|||||||
async for chunk in session.send_message(user_content):
|
async for chunk in session.send_message(user_content):
|
||||||
await websocket.send_json(chunk)
|
await websocket.send_json(chunk)
|
||||||
|
|
||||||
|
elif msg_type == "answer":
|
||||||
|
# User answered a structured question
|
||||||
|
if not session:
|
||||||
|
session = get_session(project_name)
|
||||||
|
if not session:
|
||||||
|
await websocket.send_json({
|
||||||
|
"type": "error",
|
||||||
|
"content": "No active session. Send 'start' first."
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Format the answers as a natural response
|
||||||
|
answers = message.get("answers", {})
|
||||||
|
if isinstance(answers, dict):
|
||||||
|
response_parts = []
|
||||||
|
for question_idx, answer_value in answers.items():
|
||||||
|
if isinstance(answer_value, list):
|
||||||
|
response_parts.append(", ".join(answer_value))
|
||||||
|
else:
|
||||||
|
response_parts.append(str(answer_value))
|
||||||
|
user_response = "; ".join(response_parts) if response_parts else "OK"
|
||||||
|
else:
|
||||||
|
user_response = str(answers)
|
||||||
|
|
||||||
|
# Stream Claude's response
|
||||||
|
async for chunk in session.send_message(user_response):
|
||||||
|
await websocket.send_json(chunk)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
await websocket.send_json({
|
await websocket.send_json({
|
||||||
"type": "error",
|
"type": "error",
|
||||||
|
|||||||
@@ -104,19 +104,26 @@ async def expand_project_websocket(websocket: WebSocket, project_name: str):
|
|||||||
- {"type": "error", "content": "..."} - Error message
|
- {"type": "error", "content": "..."} - Error message
|
||||||
- {"type": "pong"} - Keep-alive pong
|
- {"type": "pong"} - Keep-alive pong
|
||||||
"""
|
"""
|
||||||
|
# Always accept the WebSocket first to avoid opaque 403 errors.
|
||||||
|
# Starlette returns 403 if we close before accepting.
|
||||||
|
await websocket.accept()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
project_name = validate_project_name(project_name)
|
project_name = validate_project_name(project_name)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
await websocket.send_json({"type": "error", "content": "Invalid project name"})
|
||||||
await websocket.close(code=4000, reason="Invalid project name")
|
await websocket.close(code=4000, reason="Invalid project name")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Look up project directory from registry
|
# Look up project directory from registry
|
||||||
project_dir = _get_project_path(project_name)
|
project_dir = _get_project_path(project_name)
|
||||||
if not project_dir:
|
if not project_dir:
|
||||||
|
await websocket.send_json({"type": "error", "content": "Project not found in registry"})
|
||||||
await websocket.close(code=4004, reason="Project not found in registry")
|
await websocket.close(code=4004, reason="Project not found in registry")
|
||||||
return
|
return
|
||||||
|
|
||||||
if not project_dir.exists():
|
if not project_dir.exists():
|
||||||
|
await websocket.send_json({"type": "error", "content": "Project directory not found"})
|
||||||
await websocket.close(code=4004, reason="Project directory not found")
|
await websocket.close(code=4004, reason="Project directory not found")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -124,11 +131,10 @@ async def expand_project_websocket(websocket: WebSocket, project_name: str):
|
|||||||
from autoforge_paths import get_prompts_dir
|
from autoforge_paths import get_prompts_dir
|
||||||
spec_path = get_prompts_dir(project_dir) / "app_spec.txt"
|
spec_path = get_prompts_dir(project_dir) / "app_spec.txt"
|
||||||
if not spec_path.exists():
|
if not spec_path.exists():
|
||||||
|
await websocket.send_json({"type": "error", "content": "Project has no spec. Create a spec first before expanding."})
|
||||||
await websocket.close(code=4004, reason="Project has no spec. Create spec first.")
|
await websocket.close(code=4004, reason="Project has no spec. Create spec first.")
|
||||||
return
|
return
|
||||||
|
|
||||||
await websocket.accept()
|
|
||||||
|
|
||||||
session: Optional[ExpandChatSession] = None
|
session: Optional[ExpandChatSession] = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -7,12 +7,11 @@ Settings are stored in the registry database and shared across all projects.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from ..schemas import ModelInfo, ModelsResponse, SettingsResponse, SettingsUpdate
|
from ..schemas import ModelInfo, ModelsResponse, ProviderInfo, ProvidersResponse, SettingsResponse, SettingsUpdate
|
||||||
from ..services.chat_constants import ROOT_DIR
|
from ..services.chat_constants import ROOT_DIR
|
||||||
|
|
||||||
# Mimetype fix for Windows - must run before StaticFiles is mounted
|
# Mimetype fix for Windows - must run before StaticFiles is mounted
|
||||||
@@ -23,9 +22,11 @@ if str(ROOT_DIR) not in sys.path:
|
|||||||
sys.path.insert(0, str(ROOT_DIR))
|
sys.path.insert(0, str(ROOT_DIR))
|
||||||
|
|
||||||
from registry import (
|
from registry import (
|
||||||
|
API_PROVIDERS,
|
||||||
AVAILABLE_MODELS,
|
AVAILABLE_MODELS,
|
||||||
DEFAULT_MODEL,
|
DEFAULT_MODEL,
|
||||||
get_all_settings,
|
get_all_settings,
|
||||||
|
get_setting,
|
||||||
set_setting,
|
set_setting,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,26 +38,40 @@ def _parse_yolo_mode(value: str | None) -> bool:
|
|||||||
return (value or "false").lower() == "true"
|
return (value or "false").lower() == "true"
|
||||||
|
|
||||||
|
|
||||||
def _is_glm_mode() -> bool:
|
@router.get("/providers", response_model=ProvidersResponse)
|
||||||
"""Check if GLM API is configured via environment variables."""
|
async def get_available_providers():
|
||||||
base_url = os.getenv("ANTHROPIC_BASE_URL", "")
|
"""Get list of available API providers."""
|
||||||
# GLM mode is when ANTHROPIC_BASE_URL is set but NOT pointing to Ollama
|
current = get_setting("api_provider", "claude") or "claude"
|
||||||
return bool(base_url) and not _is_ollama_mode()
|
providers = []
|
||||||
|
for pid, pdata in API_PROVIDERS.items():
|
||||||
|
providers.append(ProviderInfo(
|
||||||
def _is_ollama_mode() -> bool:
|
id=pid,
|
||||||
"""Check if Ollama API is configured via environment variables."""
|
name=pdata["name"],
|
||||||
base_url = os.getenv("ANTHROPIC_BASE_URL", "")
|
base_url=pdata.get("base_url"),
|
||||||
return "localhost:11434" in base_url or "127.0.0.1:11434" in base_url
|
models=[ModelInfo(id=m["id"], name=m["name"]) for m in pdata.get("models", [])],
|
||||||
|
default_model=pdata.get("default_model", ""),
|
||||||
|
requires_auth=pdata.get("requires_auth", False),
|
||||||
|
))
|
||||||
|
return ProvidersResponse(providers=providers, current=current)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/models", response_model=ModelsResponse)
|
@router.get("/models", response_model=ModelsResponse)
|
||||||
async def get_available_models():
|
async def get_available_models():
|
||||||
"""Get list of available models.
|
"""Get list of available models.
|
||||||
|
|
||||||
Frontend should call this to get the current list of models
|
Returns models for the currently selected API provider.
|
||||||
instead of hardcoding them.
|
|
||||||
"""
|
"""
|
||||||
|
current_provider = get_setting("api_provider", "claude") or "claude"
|
||||||
|
provider = API_PROVIDERS.get(current_provider)
|
||||||
|
|
||||||
|
if provider and current_provider != "claude":
|
||||||
|
provider_models = provider.get("models", [])
|
||||||
|
return ModelsResponse(
|
||||||
|
models=[ModelInfo(id=m["id"], name=m["name"]) for m in provider_models],
|
||||||
|
default=provider.get("default_model", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Default: return Claude models
|
||||||
return ModelsResponse(
|
return ModelsResponse(
|
||||||
models=[ModelInfo(id=m["id"], name=m["name"]) for m in AVAILABLE_MODELS],
|
models=[ModelInfo(id=m["id"], name=m["name"]) for m in AVAILABLE_MODELS],
|
||||||
default=DEFAULT_MODEL,
|
default=DEFAULT_MODEL,
|
||||||
@@ -85,14 +100,23 @@ async def get_settings():
|
|||||||
"""Get current global settings."""
|
"""Get current global settings."""
|
||||||
all_settings = get_all_settings()
|
all_settings = get_all_settings()
|
||||||
|
|
||||||
|
api_provider = all_settings.get("api_provider", "claude")
|
||||||
|
|
||||||
|
glm_mode = api_provider == "glm"
|
||||||
|
ollama_mode = api_provider == "ollama"
|
||||||
|
|
||||||
return SettingsResponse(
|
return SettingsResponse(
|
||||||
yolo_mode=_parse_yolo_mode(all_settings.get("yolo_mode")),
|
yolo_mode=_parse_yolo_mode(all_settings.get("yolo_mode")),
|
||||||
model=all_settings.get("model", DEFAULT_MODEL),
|
model=all_settings.get("model", DEFAULT_MODEL),
|
||||||
glm_mode=_is_glm_mode(),
|
glm_mode=glm_mode,
|
||||||
ollama_mode=_is_ollama_mode(),
|
ollama_mode=ollama_mode,
|
||||||
testing_agent_ratio=_parse_int(all_settings.get("testing_agent_ratio"), 1),
|
testing_agent_ratio=_parse_int(all_settings.get("testing_agent_ratio"), 1),
|
||||||
playwright_headless=_parse_bool(all_settings.get("playwright_headless"), default=True),
|
playwright_headless=_parse_bool(all_settings.get("playwright_headless"), default=True),
|
||||||
batch_size=_parse_int(all_settings.get("batch_size"), 3),
|
batch_size=_parse_int(all_settings.get("batch_size"), 3),
|
||||||
|
api_provider=api_provider,
|
||||||
|
api_base_url=all_settings.get("api_base_url"),
|
||||||
|
api_has_auth_token=bool(all_settings.get("api_auth_token")),
|
||||||
|
api_model=all_settings.get("api_model"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -114,14 +138,47 @@ async def update_settings(update: SettingsUpdate):
|
|||||||
if update.batch_size is not None:
|
if update.batch_size is not None:
|
||||||
set_setting("batch_size", str(update.batch_size))
|
set_setting("batch_size", str(update.batch_size))
|
||||||
|
|
||||||
|
# API provider settings
|
||||||
|
if update.api_provider is not None:
|
||||||
|
old_provider = get_setting("api_provider", "claude")
|
||||||
|
set_setting("api_provider", update.api_provider)
|
||||||
|
|
||||||
|
# When provider changes, auto-set defaults for the new provider
|
||||||
|
if update.api_provider != old_provider:
|
||||||
|
provider = API_PROVIDERS.get(update.api_provider)
|
||||||
|
if provider:
|
||||||
|
# Auto-set base URL from provider definition
|
||||||
|
if provider.get("base_url"):
|
||||||
|
set_setting("api_base_url", provider["base_url"])
|
||||||
|
# Auto-set model to provider's default
|
||||||
|
if provider.get("default_model") and update.api_model is None:
|
||||||
|
set_setting("api_model", provider["default_model"])
|
||||||
|
|
||||||
|
if update.api_base_url is not None:
|
||||||
|
set_setting("api_base_url", update.api_base_url)
|
||||||
|
|
||||||
|
if update.api_auth_token is not None:
|
||||||
|
set_setting("api_auth_token", update.api_auth_token)
|
||||||
|
|
||||||
|
if update.api_model is not None:
|
||||||
|
set_setting("api_model", update.api_model)
|
||||||
|
|
||||||
# Return updated settings
|
# Return updated settings
|
||||||
all_settings = get_all_settings()
|
all_settings = get_all_settings()
|
||||||
|
api_provider = all_settings.get("api_provider", "claude")
|
||||||
|
glm_mode = api_provider == "glm"
|
||||||
|
ollama_mode = api_provider == "ollama"
|
||||||
|
|
||||||
return SettingsResponse(
|
return SettingsResponse(
|
||||||
yolo_mode=_parse_yolo_mode(all_settings.get("yolo_mode")),
|
yolo_mode=_parse_yolo_mode(all_settings.get("yolo_mode")),
|
||||||
model=all_settings.get("model", DEFAULT_MODEL),
|
model=all_settings.get("model", DEFAULT_MODEL),
|
||||||
glm_mode=_is_glm_mode(),
|
glm_mode=glm_mode,
|
||||||
ollama_mode=_is_ollama_mode(),
|
ollama_mode=ollama_mode,
|
||||||
testing_agent_ratio=_parse_int(all_settings.get("testing_agent_ratio"), 1),
|
testing_agent_ratio=_parse_int(all_settings.get("testing_agent_ratio"), 1),
|
||||||
playwright_headless=_parse_bool(all_settings.get("playwright_headless"), default=True),
|
playwright_headless=_parse_bool(all_settings.get("playwright_headless"), default=True),
|
||||||
batch_size=_parse_int(all_settings.get("batch_size"), 3),
|
batch_size=_parse_int(all_settings.get("batch_size"), 3),
|
||||||
|
api_provider=api_provider,
|
||||||
|
api_base_url=all_settings.get("api_base_url"),
|
||||||
|
api_has_auth_token=bool(all_settings.get("api_auth_token")),
|
||||||
|
api_model=all_settings.get("api_model"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from ..services.spec_chat_session import (
|
|||||||
remove_session,
|
remove_session,
|
||||||
)
|
)
|
||||||
from ..utils.project_helpers import get_project_path as _get_project_path
|
from ..utils.project_helpers import get_project_path as _get_project_path
|
||||||
from ..utils.validation import is_valid_project_name as validate_project_name
|
from ..utils.validation import is_valid_project_name, validate_project_name
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ async def list_spec_sessions():
|
|||||||
@router.get("/sessions/{project_name}", response_model=SpecSessionStatus)
|
@router.get("/sessions/{project_name}", response_model=SpecSessionStatus)
|
||||||
async def get_session_status(project_name: str):
|
async def get_session_status(project_name: str):
|
||||||
"""Get status of a spec creation session."""
|
"""Get status of a spec creation session."""
|
||||||
if not validate_project_name(project_name):
|
if not is_valid_project_name(project_name):
|
||||||
raise HTTPException(status_code=400, detail="Invalid project name")
|
raise HTTPException(status_code=400, detail="Invalid project name")
|
||||||
|
|
||||||
session = get_session(project_name)
|
session = get_session(project_name)
|
||||||
@@ -67,7 +67,7 @@ async def get_session_status(project_name: str):
|
|||||||
@router.delete("/sessions/{project_name}")
|
@router.delete("/sessions/{project_name}")
|
||||||
async def cancel_session(project_name: str):
|
async def cancel_session(project_name: str):
|
||||||
"""Cancel and remove a spec creation session."""
|
"""Cancel and remove a spec creation session."""
|
||||||
if not validate_project_name(project_name):
|
if not is_valid_project_name(project_name):
|
||||||
raise HTTPException(status_code=400, detail="Invalid project name")
|
raise HTTPException(status_code=400, detail="Invalid project name")
|
||||||
|
|
||||||
session = get_session(project_name)
|
session = get_session(project_name)
|
||||||
@@ -95,7 +95,7 @@ async def get_spec_file_status(project_name: str):
|
|||||||
This is used for polling to detect when Claude has finished writing spec files.
|
This is used for polling to detect when Claude has finished writing spec files.
|
||||||
Claude writes this status file as the final step after completing all spec work.
|
Claude writes this status file as the final step after completing all spec work.
|
||||||
"""
|
"""
|
||||||
if not validate_project_name(project_name):
|
if not is_valid_project_name(project_name):
|
||||||
raise HTTPException(status_code=400, detail="Invalid project name")
|
raise HTTPException(status_code=400, detail="Invalid project name")
|
||||||
|
|
||||||
project_dir = _get_project_path(project_name)
|
project_dir = _get_project_path(project_name)
|
||||||
@@ -166,22 +166,28 @@ async def spec_chat_websocket(websocket: WebSocket, project_name: str):
|
|||||||
- {"type": "error", "content": "..."} - Error message
|
- {"type": "error", "content": "..."} - Error message
|
||||||
- {"type": "pong"} - Keep-alive pong
|
- {"type": "pong"} - Keep-alive pong
|
||||||
"""
|
"""
|
||||||
if not validate_project_name(project_name):
|
# Always accept WebSocket first to avoid opaque 403 errors
|
||||||
|
await websocket.accept()
|
||||||
|
|
||||||
|
try:
|
||||||
|
project_name = validate_project_name(project_name)
|
||||||
|
except HTTPException:
|
||||||
|
await websocket.send_json({"type": "error", "content": "Invalid project name"})
|
||||||
await websocket.close(code=4000, reason="Invalid project name")
|
await websocket.close(code=4000, reason="Invalid project name")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Look up project directory from registry
|
# Look up project directory from registry
|
||||||
project_dir = _get_project_path(project_name)
|
project_dir = _get_project_path(project_name)
|
||||||
if not project_dir:
|
if not project_dir:
|
||||||
|
await websocket.send_json({"type": "error", "content": "Project not found in registry"})
|
||||||
await websocket.close(code=4004, reason="Project not found in registry")
|
await websocket.close(code=4004, reason="Project not found in registry")
|
||||||
return
|
return
|
||||||
|
|
||||||
if not project_dir.exists():
|
if not project_dir.exists():
|
||||||
|
await websocket.send_json({"type": "error", "content": "Project directory not found"})
|
||||||
await websocket.close(code=4004, reason="Project directory not found")
|
await websocket.close(code=4004, reason="Project directory not found")
|
||||||
return
|
return
|
||||||
|
|
||||||
await websocket.accept()
|
|
||||||
|
|
||||||
session: Optional[SpecChatSession] = None
|
session: Optional[SpecChatSession] = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from ..services.terminal_manager import (
|
|||||||
stop_terminal_session,
|
stop_terminal_session,
|
||||||
)
|
)
|
||||||
from ..utils.project_helpers import get_project_path as _get_project_path
|
from ..utils.project_helpers import get_project_path as _get_project_path
|
||||||
from ..utils.validation import is_valid_project_name as validate_project_name
|
from ..utils.validation import is_valid_project_name
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ async def list_project_terminals(project_name: str) -> list[TerminalInfoResponse
|
|||||||
Returns:
|
Returns:
|
||||||
List of terminal info objects
|
List of terminal info objects
|
||||||
"""
|
"""
|
||||||
if not validate_project_name(project_name):
|
if not is_valid_project_name(project_name):
|
||||||
raise HTTPException(status_code=400, detail="Invalid project name")
|
raise HTTPException(status_code=400, detail="Invalid project name")
|
||||||
|
|
||||||
project_dir = _get_project_path(project_name)
|
project_dir = _get_project_path(project_name)
|
||||||
@@ -122,7 +122,7 @@ async def create_project_terminal(
|
|||||||
Returns:
|
Returns:
|
||||||
The created terminal info
|
The created terminal info
|
||||||
"""
|
"""
|
||||||
if not validate_project_name(project_name):
|
if not is_valid_project_name(project_name):
|
||||||
raise HTTPException(status_code=400, detail="Invalid project name")
|
raise HTTPException(status_code=400, detail="Invalid project name")
|
||||||
|
|
||||||
project_dir = _get_project_path(project_name)
|
project_dir = _get_project_path(project_name)
|
||||||
@@ -148,7 +148,7 @@ async def rename_project_terminal(
|
|||||||
Returns:
|
Returns:
|
||||||
The updated terminal info
|
The updated terminal info
|
||||||
"""
|
"""
|
||||||
if not validate_project_name(project_name):
|
if not is_valid_project_name(project_name):
|
||||||
raise HTTPException(status_code=400, detail="Invalid project name")
|
raise HTTPException(status_code=400, detail="Invalid project name")
|
||||||
|
|
||||||
if not validate_terminal_id(terminal_id):
|
if not validate_terminal_id(terminal_id):
|
||||||
@@ -180,7 +180,7 @@ async def delete_project_terminal(project_name: str, terminal_id: str) -> dict:
|
|||||||
Returns:
|
Returns:
|
||||||
Success message
|
Success message
|
||||||
"""
|
"""
|
||||||
if not validate_project_name(project_name):
|
if not is_valid_project_name(project_name):
|
||||||
raise HTTPException(status_code=400, detail="Invalid project name")
|
raise HTTPException(status_code=400, detail="Invalid project name")
|
||||||
|
|
||||||
if not validate_terminal_id(terminal_id):
|
if not validate_terminal_id(terminal_id):
|
||||||
@@ -221,8 +221,12 @@ async def terminal_websocket(websocket: WebSocket, project_name: str, terminal_i
|
|||||||
- {"type": "pong"} - Keep-alive response
|
- {"type": "pong"} - Keep-alive response
|
||||||
- {"type": "error", "message": "..."} - Error message
|
- {"type": "error", "message": "..."} - Error message
|
||||||
"""
|
"""
|
||||||
|
# Always accept WebSocket first to avoid opaque 403 errors
|
||||||
|
await websocket.accept()
|
||||||
|
|
||||||
# Validate project name
|
# Validate project name
|
||||||
if not validate_project_name(project_name):
|
if not is_valid_project_name(project_name):
|
||||||
|
await websocket.send_json({"type": "error", "message": "Invalid project name"})
|
||||||
await websocket.close(
|
await websocket.close(
|
||||||
code=TerminalCloseCode.INVALID_PROJECT_NAME, reason="Invalid project name"
|
code=TerminalCloseCode.INVALID_PROJECT_NAME, reason="Invalid project name"
|
||||||
)
|
)
|
||||||
@@ -230,6 +234,7 @@ async def terminal_websocket(websocket: WebSocket, project_name: str, terminal_i
|
|||||||
|
|
||||||
# Validate terminal ID
|
# Validate terminal ID
|
||||||
if not validate_terminal_id(terminal_id):
|
if not validate_terminal_id(terminal_id):
|
||||||
|
await websocket.send_json({"type": "error", "message": "Invalid terminal ID"})
|
||||||
await websocket.close(
|
await websocket.close(
|
||||||
code=TerminalCloseCode.INVALID_PROJECT_NAME, reason="Invalid terminal ID"
|
code=TerminalCloseCode.INVALID_PROJECT_NAME, reason="Invalid terminal ID"
|
||||||
)
|
)
|
||||||
@@ -238,6 +243,7 @@ async def terminal_websocket(websocket: WebSocket, project_name: str, terminal_i
|
|||||||
# Look up project directory from registry
|
# Look up project directory from registry
|
||||||
project_dir = _get_project_path(project_name)
|
project_dir = _get_project_path(project_name)
|
||||||
if not project_dir:
|
if not project_dir:
|
||||||
|
await websocket.send_json({"type": "error", "message": "Project not found in registry"})
|
||||||
await websocket.close(
|
await websocket.close(
|
||||||
code=TerminalCloseCode.PROJECT_NOT_FOUND,
|
code=TerminalCloseCode.PROJECT_NOT_FOUND,
|
||||||
reason="Project not found in registry",
|
reason="Project not found in registry",
|
||||||
@@ -245,6 +251,7 @@ async def terminal_websocket(websocket: WebSocket, project_name: str, terminal_i
|
|||||||
return
|
return
|
||||||
|
|
||||||
if not project_dir.exists():
|
if not project_dir.exists():
|
||||||
|
await websocket.send_json({"type": "error", "message": "Project directory not found"})
|
||||||
await websocket.close(
|
await websocket.close(
|
||||||
code=TerminalCloseCode.PROJECT_NOT_FOUND,
|
code=TerminalCloseCode.PROJECT_NOT_FOUND,
|
||||||
reason="Project directory not found",
|
reason="Project directory not found",
|
||||||
@@ -254,14 +261,13 @@ async def terminal_websocket(websocket: WebSocket, project_name: str, terminal_i
|
|||||||
# Verify terminal exists in metadata
|
# Verify terminal exists in metadata
|
||||||
terminal_info = get_terminal_info(project_name, terminal_id)
|
terminal_info = get_terminal_info(project_name, terminal_id)
|
||||||
if not terminal_info:
|
if not terminal_info:
|
||||||
|
await websocket.send_json({"type": "error", "message": "Terminal not found"})
|
||||||
await websocket.close(
|
await websocket.close(
|
||||||
code=TerminalCloseCode.PROJECT_NOT_FOUND,
|
code=TerminalCloseCode.PROJECT_NOT_FOUND,
|
||||||
reason="Terminal not found",
|
reason="Terminal not found",
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
await websocket.accept()
|
|
||||||
|
|
||||||
# Get or create terminal session for this project/terminal
|
# Get or create terminal session for this project/terminal
|
||||||
session = get_terminal_session(project_name, project_dir, terminal_id)
|
session = get_terminal_session(project_name, project_dir, terminal_id)
|
||||||
|
|
||||||
|
|||||||
@@ -190,8 +190,11 @@ class AgentStartRequest(BaseModel):
|
|||||||
@field_validator('model')
|
@field_validator('model')
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_model(cls, v: str | None) -> str | None:
|
def validate_model(cls, v: str | None) -> str | None:
|
||||||
"""Validate model is in the allowed list."""
|
"""Validate model is in the allowed list (Claude) or allow any model for alternative providers."""
|
||||||
if v is not None and v not in VALID_MODELS:
|
if v is not None and v not in VALID_MODELS:
|
||||||
|
from registry import get_all_settings
|
||||||
|
settings = get_all_settings()
|
||||||
|
if settings.get("api_provider", "claude") == "claude":
|
||||||
raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}")
|
raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}")
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@@ -391,15 +394,35 @@ class ModelInfo(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderInfo(BaseModel):
|
||||||
|
"""Information about an API provider."""
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
base_url: str | None = None
|
||||||
|
models: list[ModelInfo]
|
||||||
|
default_model: str
|
||||||
|
requires_auth: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ProvidersResponse(BaseModel):
|
||||||
|
"""Response schema for available providers list."""
|
||||||
|
providers: list[ProviderInfo]
|
||||||
|
current: str
|
||||||
|
|
||||||
|
|
||||||
class SettingsResponse(BaseModel):
|
class SettingsResponse(BaseModel):
|
||||||
"""Response schema for global settings."""
|
"""Response schema for global settings."""
|
||||||
yolo_mode: bool = False
|
yolo_mode: bool = False
|
||||||
model: str = DEFAULT_MODEL
|
model: str = DEFAULT_MODEL
|
||||||
glm_mode: bool = False # True if GLM API is configured via .env
|
glm_mode: bool = False # True when api_provider is "glm"
|
||||||
ollama_mode: bool = False # True if Ollama API is configured via .env
|
ollama_mode: bool = False # True when api_provider is "ollama"
|
||||||
testing_agent_ratio: int = 1 # Regression testing agents (0-3)
|
testing_agent_ratio: int = 1 # Regression testing agents (0-3)
|
||||||
playwright_headless: bool = True
|
playwright_headless: bool = True
|
||||||
batch_size: int = 3 # Features per coding agent batch (1-3)
|
batch_size: int = 3 # Features per coding agent batch (1-3)
|
||||||
|
api_provider: str = "claude"
|
||||||
|
api_base_url: str | None = None
|
||||||
|
api_has_auth_token: bool = False # Never expose actual token
|
||||||
|
api_model: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ModelsResponse(BaseModel):
|
class ModelsResponse(BaseModel):
|
||||||
@@ -415,11 +438,29 @@ class SettingsUpdate(BaseModel):
|
|||||||
testing_agent_ratio: int | None = None # 0-3
|
testing_agent_ratio: int | None = None # 0-3
|
||||||
playwright_headless: bool | None = None
|
playwright_headless: bool | None = None
|
||||||
batch_size: int | None = None # Features per agent batch (1-3)
|
batch_size: int | None = None # Features per agent batch (1-3)
|
||||||
|
api_provider: str | None = None
|
||||||
|
api_base_url: str | None = Field(None, max_length=500)
|
||||||
|
api_auth_token: str | None = Field(None, max_length=500) # Write-only, never returned
|
||||||
|
api_model: str | None = Field(None, max_length=200)
|
||||||
|
|
||||||
|
@field_validator('api_base_url')
|
||||||
|
@classmethod
|
||||||
|
def validate_api_base_url(cls, v: str | None) -> str | None:
|
||||||
|
if v is not None and v.strip():
|
||||||
|
v = v.strip()
|
||||||
|
if not v.startswith(("http://", "https://")):
|
||||||
|
raise ValueError("api_base_url must start with http:// or https://")
|
||||||
|
return v
|
||||||
|
|
||||||
@field_validator('model')
|
@field_validator('model')
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_model(cls, v: str | None) -> str | None:
|
def validate_model(cls, v: str | None, info) -> str | None: # type: ignore[override]
|
||||||
if v is not None and v not in VALID_MODELS:
|
if v is not None:
|
||||||
|
# Skip VALID_MODELS check when using an alternative API provider
|
||||||
|
api_provider = info.data.get("api_provider")
|
||||||
|
if api_provider and api_provider != "claude":
|
||||||
|
return v
|
||||||
|
if v not in VALID_MODELS:
|
||||||
raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}")
|
raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}")
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@@ -533,8 +574,11 @@ class ScheduleCreate(BaseModel):
|
|||||||
@field_validator('model')
|
@field_validator('model')
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_model(cls, v: str | None) -> str | None:
|
def validate_model(cls, v: str | None) -> str | None:
|
||||||
"""Validate model is in the allowed list."""
|
"""Validate model is in the allowed list (Claude) or allow any model for alternative providers."""
|
||||||
if v is not None and v not in VALID_MODELS:
|
if v is not None and v not in VALID_MODELS:
|
||||||
|
from registry import get_all_settings
|
||||||
|
settings = get_all_settings()
|
||||||
|
if settings.get("api_provider", "claude") == "claude":
|
||||||
raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}")
|
raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}")
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@@ -555,8 +599,11 @@ class ScheduleUpdate(BaseModel):
|
|||||||
@field_validator('model')
|
@field_validator('model')
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_model(cls, v: str | None) -> str | None:
|
def validate_model(cls, v: str | None) -> str | None:
|
||||||
"""Validate model is in the allowed list."""
|
"""Validate model is in the allowed list (Claude) or allow any model for alternative providers."""
|
||||||
if v is not None and v not in VALID_MODELS:
|
if v is not None and v not in VALID_MODELS:
|
||||||
|
from registry import get_all_settings
|
||||||
|
settings = get_all_settings()
|
||||||
|
if settings.get("api_provider", "claude") == "claude":
|
||||||
raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}")
|
raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}")
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from .assistant_database import (
|
|||||||
create_conversation,
|
create_conversation,
|
||||||
get_messages,
|
get_messages,
|
||||||
)
|
)
|
||||||
from .chat_constants import API_ENV_VARS, ROOT_DIR
|
from .chat_constants import ROOT_DIR
|
||||||
|
|
||||||
# Load environment variables from .env file if present
|
# Load environment variables from .env file if present
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
@@ -47,8 +47,13 @@ FEATURE_MANAGEMENT_TOOLS = [
|
|||||||
"mcp__features__feature_skip",
|
"mcp__features__feature_skip",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Interactive tools
|
||||||
|
INTERACTIVE_TOOLS = [
|
||||||
|
"mcp__features__ask_user",
|
||||||
|
]
|
||||||
|
|
||||||
# Combined list for assistant
|
# Combined list for assistant
|
||||||
ASSISTANT_FEATURE_TOOLS = READONLY_FEATURE_MCP_TOOLS + FEATURE_MANAGEMENT_TOOLS
|
ASSISTANT_FEATURE_TOOLS = READONLY_FEATURE_MCP_TOOLS + FEATURE_MANAGEMENT_TOOLS + INTERACTIVE_TOOLS
|
||||||
|
|
||||||
# Read-only built-in tools (no Write, Edit, Bash)
|
# Read-only built-in tools (no Write, Edit, Bash)
|
||||||
READONLY_BUILTIN_TOOLS = [
|
READONLY_BUILTIN_TOOLS = [
|
||||||
@@ -123,6 +128,9 @@ If the user asks you to modify code, explain that you're a project assistant and
|
|||||||
- **feature_create_bulk**: Create multiple features at once
|
- **feature_create_bulk**: Create multiple features at once
|
||||||
- **feature_skip**: Move a feature to the end of the queue
|
- **feature_skip**: Move a feature to the end of the queue
|
||||||
|
|
||||||
|
**Interactive:**
|
||||||
|
- **ask_user**: Present structured multiple-choice questions to the user. Use this when you need to clarify requirements, offer design choices, or guide a decision. The user sees clickable option buttons and their selection is returned as your next message.
|
||||||
|
|
||||||
## Creating Features
|
## Creating Features
|
||||||
|
|
||||||
When a user asks to add a feature, use the `feature_create` or `feature_create_bulk` MCP tools directly:
|
When a user asks to add a feature, use the `feature_create` or `feature_create_bulk` MCP tools directly:
|
||||||
@@ -157,7 +165,7 @@ class AssistantChatSession:
|
|||||||
"""
|
"""
|
||||||
Manages a read-only assistant conversation for a project.
|
Manages a read-only assistant conversation for a project.
|
||||||
|
|
||||||
Uses Claude Opus 4.5 with only read-only tools enabled.
|
Uses Claude Opus with only read-only tools enabled.
|
||||||
Persists conversation history to SQLite.
|
Persists conversation history to SQLite.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -258,15 +266,11 @@ class AssistantChatSession:
|
|||||||
system_cli = shutil.which("claude")
|
system_cli = shutil.which("claude")
|
||||||
|
|
||||||
# Build environment overrides for API configuration
|
# Build environment overrides for API configuration
|
||||||
sdk_env: dict[str, str] = {}
|
from registry import DEFAULT_MODEL, get_effective_sdk_env
|
||||||
for var in API_ENV_VARS:
|
sdk_env = get_effective_sdk_env()
|
||||||
value = os.getenv(var)
|
|
||||||
if value:
|
|
||||||
sdk_env[var] = value
|
|
||||||
|
|
||||||
# Determine model from environment or use default
|
# Determine model from SDK env (provider-aware) or fallback to env/default
|
||||||
# This allows using alternative APIs (e.g., GLM via z.ai) that may not support Claude model names
|
model = sdk_env.get("ANTHROPIC_DEFAULT_OPUS_MODEL") or os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", DEFAULT_MODEL)
|
||||||
model = os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info("Creating ClaudeSDKClient...")
|
logger.info("Creating ClaudeSDKClient...")
|
||||||
@@ -406,6 +410,17 @@ class AssistantChatSession:
|
|||||||
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
|
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
|
||||||
tool_name = block.name
|
tool_name = block.name
|
||||||
tool_input = getattr(block, "input", {})
|
tool_input = getattr(block, "input", {})
|
||||||
|
|
||||||
|
# Intercept ask_user tool calls -> yield as question message
|
||||||
|
if tool_name == "mcp__features__ask_user":
|
||||||
|
questions = tool_input.get("questions", [])
|
||||||
|
if questions:
|
||||||
|
yield {
|
||||||
|
"type": "question",
|
||||||
|
"questions": questions,
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
|
||||||
yield {
|
yield {
|
||||||
"type": "tool_call",
|
"type": "tool_call",
|
||||||
"tool": tool_name,
|
"tool": tool_name,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from ..schemas import ImageAttachment
|
from ..schemas import ImageAttachment
|
||||||
from .chat_constants import API_ENV_VARS, ROOT_DIR, make_multimodal_message
|
from .chat_constants import ROOT_DIR, make_multimodal_message
|
||||||
|
|
||||||
# Load environment variables from .env file if present
|
# Load environment variables from .env file if present
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
@@ -154,16 +154,11 @@ class ExpandChatSession:
|
|||||||
system_prompt = skill_content.replace("$ARGUMENTS", project_path)
|
system_prompt = skill_content.replace("$ARGUMENTS", project_path)
|
||||||
|
|
||||||
# Build environment overrides for API configuration
|
# Build environment overrides for API configuration
|
||||||
# Filter to only include vars that are actually set (non-None)
|
from registry import DEFAULT_MODEL, get_effective_sdk_env
|
||||||
sdk_env: dict[str, str] = {}
|
sdk_env = get_effective_sdk_env()
|
||||||
for var in API_ENV_VARS:
|
|
||||||
value = os.getenv(var)
|
|
||||||
if value:
|
|
||||||
sdk_env[var] = value
|
|
||||||
|
|
||||||
# Determine model from environment or use default
|
# Determine model from SDK env (provider-aware) or fallback to env/default
|
||||||
# This allows using alternative APIs (e.g., GLM via z.ai) that may not support Claude model names
|
model = sdk_env.get("ANTHROPIC_DEFAULT_OPUS_MODEL") or os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", DEFAULT_MODEL)
|
||||||
model = os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101")
|
|
||||||
|
|
||||||
# Build MCP servers config for feature creation
|
# Build MCP servers config for feature creation
|
||||||
mcp_servers = {
|
mcp_servers = {
|
||||||
|
|||||||
@@ -227,6 +227,46 @@ class AgentProcessManager:
|
|||||||
"""Remove lock file."""
|
"""Remove lock file."""
|
||||||
self.lock_file.unlink(missing_ok=True)
|
self.lock_file.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def _cleanup_stale_features(self) -> None:
|
||||||
|
"""Clear in_progress flag for all features when agent stops/crashes.
|
||||||
|
|
||||||
|
When the agent process exits (normally or crash), any features left
|
||||||
|
with in_progress=True were being worked on and didn't complete.
|
||||||
|
Reset them so they can be picked up on next agent start.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from autoforge_paths import get_features_db_path
|
||||||
|
features_db = get_features_db_path(self.project_dir)
|
||||||
|
if not features_db.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from api.database import Feature
|
||||||
|
|
||||||
|
engine = create_engine(f"sqlite:///{features_db}")
|
||||||
|
Session = sessionmaker(bind=engine)
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
stuck = session.query(Feature).filter(
|
||||||
|
Feature.in_progress == True, # noqa: E712
|
||||||
|
Feature.passes == False, # noqa: E712
|
||||||
|
).all()
|
||||||
|
if stuck:
|
||||||
|
for f in stuck:
|
||||||
|
f.in_progress = False
|
||||||
|
session.commit()
|
||||||
|
logger.info(
|
||||||
|
"Cleaned up %d stuck feature(s) for %s",
|
||||||
|
len(stuck), self.project_name,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
engine.dispose()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to cleanup features for %s: %s", self.project_name, e)
|
||||||
|
|
||||||
async def _broadcast_output(self, line: str) -> None:
|
async def _broadcast_output(self, line: str) -> None:
|
||||||
"""Broadcast output line to all registered callbacks."""
|
"""Broadcast output line to all registered callbacks."""
|
||||||
with self._callbacks_lock:
|
with self._callbacks_lock:
|
||||||
@@ -288,6 +328,7 @@ class AgentProcessManager:
|
|||||||
self.status = "crashed"
|
self.status = "crashed"
|
||||||
elif self.status == "running":
|
elif self.status == "running":
|
||||||
self.status = "stopped"
|
self.status = "stopped"
|
||||||
|
self._cleanup_stale_features()
|
||||||
self._remove_lock()
|
self._remove_lock()
|
||||||
|
|
||||||
async def start(
|
async def start(
|
||||||
@@ -305,7 +346,7 @@ class AgentProcessManager:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
yolo_mode: If True, run in YOLO mode (skip testing agents)
|
yolo_mode: If True, run in YOLO mode (skip testing agents)
|
||||||
model: Model to use (e.g., claude-opus-4-5-20251101)
|
model: Model to use (e.g., claude-opus-4-6)
|
||||||
parallel_mode: DEPRECATED - ignored, always uses unified orchestrator
|
parallel_mode: DEPRECATED - ignored, always uses unified orchestrator
|
||||||
max_concurrency: Max concurrent coding agents (1-5, default 1)
|
max_concurrency: Max concurrent coding agents (1-5, default 1)
|
||||||
testing_agent_ratio: Number of regression testing agents (0-3, default 1)
|
testing_agent_ratio: Number of regression testing agents (0-3, default 1)
|
||||||
@@ -320,6 +361,9 @@ class AgentProcessManager:
|
|||||||
if not self._check_lock():
|
if not self._check_lock():
|
||||||
return False, "Another agent instance is already running for this project"
|
return False, "Another agent instance is already running for this project"
|
||||||
|
|
||||||
|
# Clean up features stuck from a previous crash/stop
|
||||||
|
self._cleanup_stale_features()
|
||||||
|
|
||||||
# Store for status queries
|
# Store for status queries
|
||||||
self.yolo_mode = yolo_mode
|
self.yolo_mode = yolo_mode
|
||||||
self.model = model
|
self.model = model
|
||||||
@@ -359,12 +403,22 @@ class AgentProcessManager:
|
|||||||
# stdin=DEVNULL prevents blocking if Claude CLI or child process tries to read stdin
|
# stdin=DEVNULL prevents blocking if Claude CLI or child process tries to read stdin
|
||||||
# CREATE_NO_WINDOW on Windows prevents console window pop-ups
|
# CREATE_NO_WINDOW on Windows prevents console window pop-ups
|
||||||
# PYTHONUNBUFFERED ensures output isn't delayed
|
# PYTHONUNBUFFERED ensures output isn't delayed
|
||||||
|
# Build subprocess environment with API provider settings
|
||||||
|
from registry import get_effective_sdk_env
|
||||||
|
api_env = get_effective_sdk_env()
|
||||||
|
subprocess_env = {
|
||||||
|
**os.environ,
|
||||||
|
"PYTHONUNBUFFERED": "1",
|
||||||
|
"PLAYWRIGHT_HEADLESS": "true" if playwright_headless else "false",
|
||||||
|
**api_env,
|
||||||
|
}
|
||||||
|
|
||||||
popen_kwargs: dict[str, Any] = {
|
popen_kwargs: dict[str, Any] = {
|
||||||
"stdin": subprocess.DEVNULL,
|
"stdin": subprocess.DEVNULL,
|
||||||
"stdout": subprocess.PIPE,
|
"stdout": subprocess.PIPE,
|
||||||
"stderr": subprocess.STDOUT,
|
"stderr": subprocess.STDOUT,
|
||||||
"cwd": str(self.project_dir),
|
"cwd": str(self.project_dir),
|
||||||
"env": {**os.environ, "PYTHONUNBUFFERED": "1", "PLAYWRIGHT_HEADLESS": "true" if playwright_headless else "false"},
|
"env": subprocess_env,
|
||||||
}
|
}
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||||||
@@ -425,6 +479,7 @@ class AgentProcessManager:
|
|||||||
result.children_terminated, result.children_killed
|
result.children_terminated, result.children_killed
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self._cleanup_stale_features()
|
||||||
self._remove_lock()
|
self._remove_lock()
|
||||||
self.status = "stopped"
|
self.status = "stopped"
|
||||||
self.process = None
|
self.process = None
|
||||||
@@ -502,6 +557,7 @@ class AgentProcessManager:
|
|||||||
if poll is not None:
|
if poll is not None:
|
||||||
# Process has terminated
|
# Process has terminated
|
||||||
if self.status in ("running", "paused"):
|
if self.status in ("running", "paused"):
|
||||||
|
self._cleanup_stale_features()
|
||||||
self.status = "crashed"
|
self.status = "crashed"
|
||||||
self._remove_lock()
|
self._remove_lock()
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from ..schemas import ImageAttachment
|
from ..schemas import ImageAttachment
|
||||||
from .chat_constants import API_ENV_VARS, ROOT_DIR, make_multimodal_message
|
from .chat_constants import ROOT_DIR, make_multimodal_message
|
||||||
|
|
||||||
# Load environment variables from .env file if present
|
# Load environment variables from .env file if present
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
@@ -140,16 +140,11 @@ class SpecChatSession:
|
|||||||
system_cli = shutil.which("claude")
|
system_cli = shutil.which("claude")
|
||||||
|
|
||||||
# Build environment overrides for API configuration
|
# Build environment overrides for API configuration
|
||||||
# Filter to only include vars that are actually set (non-None)
|
from registry import DEFAULT_MODEL, get_effective_sdk_env
|
||||||
sdk_env: dict[str, str] = {}
|
sdk_env = get_effective_sdk_env()
|
||||||
for var in API_ENV_VARS:
|
|
||||||
value = os.getenv(var)
|
|
||||||
if value:
|
|
||||||
sdk_env[var] = value
|
|
||||||
|
|
||||||
# Determine model from environment or use default
|
# Determine model from SDK env (provider-aware) or fallback to env/default
|
||||||
# This allows using alternative APIs (e.g., GLM via z.ai) that may not support Claude model names
|
model = sdk_env.get("ANTHROPIC_DEFAULT_OPUS_MODEL") or os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", DEFAULT_MODEL)
|
||||||
model = os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.client = ClaudeSDKClient(
|
self.client = ClaudeSDKClient(
|
||||||
|
|||||||
@@ -640,9 +640,7 @@ class ConnectionManager:
|
|||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
async def connect(self, websocket: WebSocket, project_name: str):
|
async def connect(self, websocket: WebSocket, project_name: str):
|
||||||
"""Accept a WebSocket connection for a project."""
|
"""Register a WebSocket connection for a project (must already be accepted)."""
|
||||||
await websocket.accept()
|
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
if project_name not in self.active_connections:
|
if project_name not in self.active_connections:
|
||||||
self.active_connections[project_name] = set()
|
self.active_connections[project_name] = set()
|
||||||
@@ -727,16 +725,22 @@ async def project_websocket(websocket: WebSocket, project_name: str):
|
|||||||
- Agent status changes
|
- Agent status changes
|
||||||
- Agent stdout/stderr lines
|
- Agent stdout/stderr lines
|
||||||
"""
|
"""
|
||||||
|
# Always accept WebSocket first to avoid opaque 403 errors
|
||||||
|
await websocket.accept()
|
||||||
|
|
||||||
if not validate_project_name(project_name):
|
if not validate_project_name(project_name):
|
||||||
|
await websocket.send_json({"type": "error", "content": "Invalid project name"})
|
||||||
await websocket.close(code=4000, reason="Invalid project name")
|
await websocket.close(code=4000, reason="Invalid project name")
|
||||||
return
|
return
|
||||||
|
|
||||||
project_dir = _get_project_path(project_name)
|
project_dir = _get_project_path(project_name)
|
||||||
if not project_dir:
|
if not project_dir:
|
||||||
|
await websocket.send_json({"type": "error", "content": "Project not found in registry"})
|
||||||
await websocket.close(code=4004, reason="Project not found in registry")
|
await websocket.close(code=4004, reason="Project not found in registry")
|
||||||
return
|
return
|
||||||
|
|
||||||
if not project_dir.exists():
|
if not project_dir.exists():
|
||||||
|
await websocket.send_json({"type": "error", "content": "Project directory not found"})
|
||||||
await websocket.close(code=4004, reason="Project directory not found")
|
await websocket.close(code=4004, reason="Project directory not found")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -879,8 +883,7 @@ async def project_websocket(websocket: WebSocket, project_name: str):
|
|||||||
break
|
break
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
logger.warning(f"Invalid JSON from WebSocket: {data[:100] if data else 'empty'}")
|
logger.warning(f"Invalid JSON from WebSocket: {data[:100] if data else 'empty'}")
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.warning(f"WebSocket error: {e}")
|
|
||||||
break
|
break
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
7
start.py
7
start.py
@@ -390,8 +390,11 @@ def run_agent(project_name: str, project_dir: Path) -> None:
|
|||||||
print(f"Location: {project_dir}")
|
print(f"Location: {project_dir}")
|
||||||
print("-" * 50)
|
print("-" * 50)
|
||||||
|
|
||||||
# Build the command - pass absolute path
|
# Build the command - pass absolute path and model from settings
|
||||||
cmd = [sys.executable, "autonomous_agent_demo.py", "--project-dir", str(project_dir.resolve())]
|
from registry import DEFAULT_MODEL, get_all_settings
|
||||||
|
settings = get_all_settings()
|
||||||
|
model = settings.get("api_model") or settings.get("model", DEFAULT_MODEL)
|
||||||
|
cmd = [sys.executable, "autonomous_agent_demo.py", "--project-dir", str(project_dir.resolve()), "--model", model]
|
||||||
|
|
||||||
# Run the agent with stderr capture to detect auth errors
|
# Run the agent with stderr capture to detect auth errors
|
||||||
# stdout goes directly to terminal for real-time output
|
# stdout goes directly to terminal for real-time output
|
||||||
|
|||||||
@@ -40,15 +40,15 @@ class TestConvertModelForVertex(unittest.TestCase):
|
|||||||
def test_returns_model_unchanged_when_vertex_disabled(self):
|
def test_returns_model_unchanged_when_vertex_disabled(self):
|
||||||
os.environ.pop("CLAUDE_CODE_USE_VERTEX", None)
|
os.environ.pop("CLAUDE_CODE_USE_VERTEX", None)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
convert_model_for_vertex("claude-opus-4-5-20251101"),
|
convert_model_for_vertex("claude-opus-4-6"),
|
||||||
"claude-opus-4-5-20251101",
|
"claude-opus-4-6",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_returns_model_unchanged_when_vertex_set_to_zero(self):
|
def test_returns_model_unchanged_when_vertex_set_to_zero(self):
|
||||||
os.environ["CLAUDE_CODE_USE_VERTEX"] = "0"
|
os.environ["CLAUDE_CODE_USE_VERTEX"] = "0"
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
convert_model_for_vertex("claude-opus-4-5-20251101"),
|
convert_model_for_vertex("claude-opus-4-6"),
|
||||||
"claude-opus-4-5-20251101",
|
"claude-opus-4-6",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_returns_model_unchanged_when_vertex_set_to_empty(self):
|
def test_returns_model_unchanged_when_vertex_set_to_empty(self):
|
||||||
@@ -60,13 +60,20 @@ class TestConvertModelForVertex(unittest.TestCase):
|
|||||||
|
|
||||||
# --- Vertex AI enabled: standard conversions ---
|
# --- Vertex AI enabled: standard conversions ---
|
||||||
|
|
||||||
def test_converts_opus_model(self):
|
def test_converts_legacy_opus_model(self):
|
||||||
os.environ["CLAUDE_CODE_USE_VERTEX"] = "1"
|
os.environ["CLAUDE_CODE_USE_VERTEX"] = "1"
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
convert_model_for_vertex("claude-opus-4-5-20251101"),
|
convert_model_for_vertex("claude-opus-4-5-20251101"),
|
||||||
"claude-opus-4-5@20251101",
|
"claude-opus-4-5@20251101",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_opus_4_6_passthrough_on_vertex(self):
|
||||||
|
os.environ["CLAUDE_CODE_USE_VERTEX"] = "1"
|
||||||
|
self.assertEqual(
|
||||||
|
convert_model_for_vertex("claude-opus-4-6"),
|
||||||
|
"claude-opus-4-6",
|
||||||
|
)
|
||||||
|
|
||||||
def test_converts_sonnet_model(self):
|
def test_converts_sonnet_model(self):
|
||||||
os.environ["CLAUDE_CODE_USE_VERTEX"] = "1"
|
os.environ["CLAUDE_CODE_USE_VERTEX"] = "1"
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -86,8 +93,8 @@ class TestConvertModelForVertex(unittest.TestCase):
|
|||||||
def test_already_vertex_format_unchanged(self):
|
def test_already_vertex_format_unchanged(self):
|
||||||
os.environ["CLAUDE_CODE_USE_VERTEX"] = "1"
|
os.environ["CLAUDE_CODE_USE_VERTEX"] = "1"
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
convert_model_for_vertex("claude-opus-4-5@20251101"),
|
convert_model_for_vertex("claude-sonnet-4-5@20250929"),
|
||||||
"claude-opus-4-5@20251101",
|
"claude-sonnet-4-5@20250929",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_non_claude_model_unchanged(self):
|
def test_non_claude_model_unchanged(self):
|
||||||
@@ -100,8 +107,8 @@ class TestConvertModelForVertex(unittest.TestCase):
|
|||||||
def test_model_without_date_suffix_unchanged(self):
|
def test_model_without_date_suffix_unchanged(self):
|
||||||
os.environ["CLAUDE_CODE_USE_VERTEX"] = "1"
|
os.environ["CLAUDE_CODE_USE_VERTEX"] = "1"
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
convert_model_for_vertex("claude-opus-4-5"),
|
convert_model_for_vertex("claude-opus-4-6"),
|
||||||
"claude-opus-4-5",
|
"claude-opus-4-6",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_empty_string_unchanged(self):
|
def test_empty_string_unchanged(self):
|
||||||
|
|||||||
47
ui/e2e/tooltip.spec.ts
Normal file
47
ui/e2e/tooltip.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E2E tooltip tests for header icon buttons.
|
||||||
|
*
|
||||||
|
* Run tests:
|
||||||
|
* cd ui && npm run test:e2e
|
||||||
|
* cd ui && npm run test:e2e -- tooltip.spec.ts
|
||||||
|
*/
|
||||||
|
test.describe('Header tooltips', () => {
|
||||||
|
test.setTimeout(30000)
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
await page.waitForSelector('button:has-text("Select Project")', { timeout: 10000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
async function selectProject(page: import('@playwright/test').Page) {
|
||||||
|
const projectSelector = page.locator('button:has-text("Select Project")')
|
||||||
|
if (await projectSelector.isVisible()) {
|
||||||
|
await projectSelector.click()
|
||||||
|
const items = page.locator('.neo-dropdown-item')
|
||||||
|
const itemCount = await items.count()
|
||||||
|
if (itemCount === 0) return false
|
||||||
|
await items.first().click()
|
||||||
|
await expect(projectSelector).not.toBeVisible({ timeout: 5000 }).catch(() => {})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Settings tooltip shows on hover', async ({ page }) => {
|
||||||
|
const hasProject = await selectProject(page)
|
||||||
|
if (!hasProject) {
|
||||||
|
test.skip(true, 'No projects available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsButton = page.locator('button[aria-label="Open Settings"]')
|
||||||
|
await expect(settingsButton).toBeVisible()
|
||||||
|
|
||||||
|
await settingsButton.hover()
|
||||||
|
|
||||||
|
const tooltip = page.locator('[data-slot="tooltip-content"]', { hasText: 'Settings' })
|
||||||
|
await expect(tooltip).toBeVisible({ timeout: 2000 })
|
||||||
|
})
|
||||||
|
})
|
||||||
1552
ui/package-lock.json
generated
1552
ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@
|
|||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tanstack/react-query": "^5.72.0",
|
"@tanstack/react-query": "^5.72.0",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/addon-web-links": "^0.12.0",
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
@@ -32,6 +33,8 @@
|
|||||||
"lucide-react": "^0.475.0",
|
"lucide-react": "^0.475.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"tailwind-merge": "^3.4.0"
|
"tailwind-merge": "^3.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
152
ui/src/App.tsx
152
ui/src/App.tsx
@@ -33,6 +33,7 @@ import type { Feature } from './lib/types'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
||||||
|
|
||||||
const STORAGE_KEY = 'autoforge-selected-project'
|
const STORAGE_KEY = 'autoforge-selected-project'
|
||||||
const VIEW_MODE_KEY = 'autoforge-view-mode'
|
const VIEW_MODE_KEY = 'autoforge-view-mode'
|
||||||
@@ -178,8 +179,8 @@ function App() {
|
|||||||
setShowAddFeature(true)
|
setShowAddFeature(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// E : Expand project with AI (when project selected and has features)
|
// E : Expand project with AI (when project selected, has spec and has features)
|
||||||
if ((e.key === 'e' || e.key === 'E') && selectedProject && features &&
|
if ((e.key === 'e' || e.key === 'E') && selectedProject && hasSpec && features &&
|
||||||
(features.pending.length + features.in_progress.length + features.done.length) > 0) {
|
(features.pending.length + features.in_progress.length + features.done.length) > 0) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setShowExpandProject(true)
|
setShowExpandProject(true)
|
||||||
@@ -239,7 +240,7 @@ function App() {
|
|||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown)
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
}, [selectedProject, showAddFeature, showExpandProject, selectedFeature, debugOpen, debugActiveTab, assistantOpen, features, showSettings, showKeyboardHelp, isSpecCreating, viewMode, showResetModal, wsState.agentStatus])
|
}, [selectedProject, showAddFeature, showExpandProject, selectedFeature, debugOpen, debugActiveTab, assistantOpen, features, showSettings, showKeyboardHelp, isSpecCreating, viewMode, showResetModal, wsState.agentStatus, hasSpec])
|
||||||
|
|
||||||
// Combine WebSocket progress with feature data
|
// Combine WebSocket progress with feature data
|
||||||
const progress = wsState.progress.total > 0 ? wsState.progress : {
|
const progress = wsState.progress.total > 0 ? wsState.progress : {
|
||||||
@@ -260,18 +261,19 @@ function App() {
|
|||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="sticky top-0 z-50 bg-card/80 backdrop-blur-md text-foreground border-b-2 border-border">
|
<header className="sticky top-0 z-50 bg-card/80 backdrop-blur-md text-foreground border-b-2 border-border">
|
||||||
<div className="max-w-7xl mx-auto px-4 py-4">
|
<div className="max-w-7xl mx-auto px-4 py-3">
|
||||||
<div className="flex items-center justify-between">
|
<TooltipProvider>
|
||||||
{/* Logo and Title */}
|
{/* Row 1: Branding + Project + Utility icons */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Logo and Title */}
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<img src="/logo.png" alt="AutoForge" className="h-9 w-9 rounded-full" />
|
<img src="/logo.png" alt="AutoForge" className="h-9 w-9 rounded-full" />
|
||||||
<h1 className="font-display text-2xl font-bold tracking-tight uppercase">
|
<h1 className="font-display text-2xl font-bold tracking-tight uppercase hidden md:block">
|
||||||
AutoForge
|
AutoForge
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Project selector */}
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<ProjectSelector
|
<ProjectSelector
|
||||||
projects={projects ?? []}
|
projects={projects ?? []}
|
||||||
selectedProject={selectedProject}
|
selectedProject={selectedProject}
|
||||||
@@ -280,8 +282,69 @@ function App() {
|
|||||||
onSpecCreatingChange={setIsSpecCreating}
|
onSpecCreatingChange={setIsSpecCreating}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Spacer */}
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{/* Ollama Mode Indicator */}
|
||||||
|
{selectedProject && settings?.ollama_mode && (
|
||||||
|
<div
|
||||||
|
className="hidden sm:flex items-center gap-1.5 px-2 py-1 bg-card rounded border-2 border-border shadow-sm"
|
||||||
|
title="Using Ollama local models"
|
||||||
|
>
|
||||||
|
<img src="/ollama.png" alt="Ollama" className="w-5 h-5" />
|
||||||
|
<span className="text-xs font-bold text-foreground">Ollama</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* GLM Mode Badge */}
|
||||||
|
{selectedProject && settings?.glm_mode && (
|
||||||
|
<Badge
|
||||||
|
className="hidden sm:inline-flex bg-purple-500 text-white hover:bg-purple-600"
|
||||||
|
title="Using GLM API"
|
||||||
|
>
|
||||||
|
GLM
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Utility icons - always visible */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
onClick={() => window.open('https://autoforge.cc', '_blank')}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
aria-label="Open Documentation"
|
||||||
|
>
|
||||||
|
<BookOpen size={18} />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Docs</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<ThemeSelector
|
||||||
|
themes={themes}
|
||||||
|
currentTheme={theme}
|
||||||
|
onThemeChange={setTheme}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
onClick={toggleDarkMode}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
aria-label="Toggle dark mode"
|
||||||
|
>
|
||||||
|
{darkMode ? <Sun size={18} /> : <Moon size={18} />}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Toggle theme</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: Project controls - only when a project is selected */}
|
||||||
{selectedProject && (
|
{selectedProject && (
|
||||||
<>
|
<div className="flex items-center gap-3 mt-2 pt-2 border-t border-border/50">
|
||||||
<AgentControl
|
<AgentControl
|
||||||
projectName={selectedProject}
|
projectName={selectedProject}
|
||||||
status={wsState.agentStatus}
|
status={wsState.agentStatus}
|
||||||
@@ -294,80 +357,39 @@ function App() {
|
|||||||
url={wsState.devServerUrl}
|
url={wsState.devServerUrl}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setShowSettings(true)}
|
onClick={() => setShowSettings(true)}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
title="Settings (,)"
|
|
||||||
aria-label="Open Settings"
|
aria-label="Open Settings"
|
||||||
>
|
>
|
||||||
<Settings size={18} />
|
<Settings size={18} />
|
||||||
</Button>
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Settings (,)</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setShowResetModal(true)}
|
onClick={() => setShowResetModal(true)}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
title="Reset Project (R)"
|
|
||||||
aria-label="Reset Project"
|
aria-label="Reset Project"
|
||||||
disabled={wsState.agentStatus === 'running'}
|
disabled={wsState.agentStatus === 'running'}
|
||||||
>
|
>
|
||||||
<RotateCcw size={18} />
|
<RotateCcw size={18} />
|
||||||
</Button>
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
{/* Ollama Mode Indicator */}
|
<TooltipContent>Reset (R)</TooltipContent>
|
||||||
{settings?.ollama_mode && (
|
</Tooltip>
|
||||||
<div
|
|
||||||
className="flex items-center gap-1.5 px-2 py-1 bg-card rounded border-2 border-border shadow-sm"
|
|
||||||
title="Using Ollama local models (configured via .env)"
|
|
||||||
>
|
|
||||||
<img src="/ollama.png" alt="Ollama" className="w-5 h-5" />
|
|
||||||
<span className="text-xs font-bold text-foreground">Ollama</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
{/* GLM Mode Badge */}
|
|
||||||
{settings?.glm_mode && (
|
|
||||||
<Badge
|
|
||||||
className="bg-purple-500 text-white hover:bg-purple-600"
|
|
||||||
title="Using GLM API (configured via .env)"
|
|
||||||
>
|
|
||||||
GLM
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Docs link */}
|
|
||||||
<Button
|
|
||||||
onClick={() => window.open('https://autoforge.cc', '_blank')}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
title="Documentation"
|
|
||||||
aria-label="Open Documentation"
|
|
||||||
>
|
|
||||||
<BookOpen size={18} />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Theme selector */}
|
|
||||||
<ThemeSelector
|
|
||||||
themes={themes}
|
|
||||||
currentTheme={theme}
|
|
||||||
onThemeChange={setTheme}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Dark mode toggle - always visible */}
|
|
||||||
<Button
|
|
||||||
onClick={toggleDarkMode}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
title="Toggle dark mode"
|
|
||||||
aria-label="Toggle dark mode"
|
|
||||||
>
|
|
||||||
{darkMode ? <Sun size={18} /> : <Moon size={18} />}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -490,7 +512,7 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Expand Project Modal - AI-powered bulk feature creation */}
|
{/* Expand Project Modal - AI-powered bulk feature creation */}
|
||||||
{showExpandProject && selectedProject && (
|
{showExpandProject && selectedProject && hasSpec && (
|
||||||
<ExpandProjectModal
|
<ExpandProjectModal
|
||||||
isOpen={showExpandProject}
|
isOpen={showExpandProject}
|
||||||
projectName={selectedProject}
|
projectName={selectedProject}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export function AgentControl({ projectName, status, defaultConcurrency = 3 }: Ag
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-2 sm:gap-4">
|
||||||
{/* Concurrency slider - visible when stopped */}
|
{/* Concurrency slider - visible when stopped */}
|
||||||
{isStopped && (
|
{isStopped && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Send, Loader2, Wifi, WifiOff, Plus, History } from 'lucide-react'
|
|||||||
import { useAssistantChat } from '../hooks/useAssistantChat'
|
import { useAssistantChat } from '../hooks/useAssistantChat'
|
||||||
import { ChatMessage as ChatMessageComponent } from './ChatMessage'
|
import { ChatMessage as ChatMessageComponent } from './ChatMessage'
|
||||||
import { ConversationHistory } from './ConversationHistory'
|
import { ConversationHistory } from './ConversationHistory'
|
||||||
|
import { QuestionOptions } from './QuestionOptions'
|
||||||
import type { ChatMessage } from '../lib/types'
|
import type { ChatMessage } from '../lib/types'
|
||||||
import { isSubmitEnter } from '../lib/keyboard'
|
import { isSubmitEnter } from '../lib/keyboard'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -52,8 +53,10 @@ export function AssistantChat({
|
|||||||
isLoading,
|
isLoading,
|
||||||
connectionStatus,
|
connectionStatus,
|
||||||
conversationId: activeConversationId,
|
conversationId: activeConversationId,
|
||||||
|
currentQuestions,
|
||||||
start,
|
start,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
|
sendAnswer,
|
||||||
clearMessages,
|
clearMessages,
|
||||||
} = useAssistantChat({
|
} = useAssistantChat({
|
||||||
projectName,
|
projectName,
|
||||||
@@ -268,6 +271,16 @@ export function AssistantChat({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Structured questions from assistant */}
|
||||||
|
{currentQuestions && (
|
||||||
|
<div className="border-t border-border bg-background">
|
||||||
|
<QuestionOptions
|
||||||
|
questions={currentQuestions}
|
||||||
|
onSubmit={sendAnswer}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Input area */}
|
{/* Input area */}
|
||||||
<div className="border-t border-border p-4 bg-card">
|
<div className="border-t border-border p-4 bg-card">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -277,13 +290,13 @@ export function AssistantChat({
|
|||||||
onChange={(e) => setInputValue(e.target.value)}
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Ask about the codebase..."
|
placeholder="Ask about the codebase..."
|
||||||
disabled={isLoading || isLoadingConversation || connectionStatus !== 'connected'}
|
disabled={isLoading || isLoadingConversation || connectionStatus !== 'connected' || !!currentQuestions}
|
||||||
className="flex-1 resize-none min-h-[44px] max-h-[120px]"
|
className="flex-1 resize-none min-h-[44px] max-h-[120px]"
|
||||||
rows={1}
|
rows={1}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
disabled={!inputValue.trim() || isLoading || isLoadingConversation || connectionStatus !== 'connected'}
|
disabled={!inputValue.trim() || isLoading || isLoadingConversation || connectionStatus !== 'connected' || !!currentQuestions}
|
||||||
title="Send message"
|
title="Send message"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -294,7 +307,7 @@ export function AssistantChat({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
Press Enter to send, Shift+Enter for new line
|
{currentQuestions ? 'Select an option above to continue' : 'Press Enter to send, Shift+Enter for new line'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* Manages conversation state with localStorage persistence.
|
* Manages conversation state with localStorage persistence.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { X, Bot } from 'lucide-react'
|
import { X, Bot } from 'lucide-react'
|
||||||
import { AssistantChat } from './AssistantChat'
|
import { AssistantChat } from './AssistantChat'
|
||||||
import { useConversation } from '../hooks/useConversations'
|
import { useConversation } from '../hooks/useConversations'
|
||||||
@@ -20,6 +20,10 @@ interface AssistantPanelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY_PREFIX = 'assistant-conversation-'
|
const STORAGE_KEY_PREFIX = 'assistant-conversation-'
|
||||||
|
const WIDTH_STORAGE_KEY = 'assistant-panel-width'
|
||||||
|
const DEFAULT_WIDTH = 400
|
||||||
|
const MIN_WIDTH = 300
|
||||||
|
const MAX_WIDTH_VW = 90
|
||||||
|
|
||||||
function getStoredConversationId(projectName: string): number | null {
|
function getStoredConversationId(projectName: string): number | null {
|
||||||
try {
|
try {
|
||||||
@@ -100,6 +104,49 @@ export function AssistantPanel({ projectName, isOpen, onClose }: AssistantPanelP
|
|||||||
setConversationId(id)
|
setConversationId(id)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Resizable panel width
|
||||||
|
const [panelWidth, setPanelWidth] = useState<number>(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(WIDTH_STORAGE_KEY)
|
||||||
|
if (stored) return Math.max(MIN_WIDTH, parseInt(stored, 10))
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return DEFAULT_WIDTH
|
||||||
|
})
|
||||||
|
const isResizing = useRef(false)
|
||||||
|
|
||||||
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
isResizing.current = true
|
||||||
|
const startX = e.clientX
|
||||||
|
const startWidth = panelWidth
|
||||||
|
const maxWidth = window.innerWidth * (MAX_WIDTH_VW / 100)
|
||||||
|
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (!isResizing.current) return
|
||||||
|
const delta = startX - e.clientX
|
||||||
|
const newWidth = Math.min(maxWidth, Math.max(MIN_WIDTH, startWidth + delta))
|
||||||
|
setPanelWidth(newWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
isResizing.current = false
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove)
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp)
|
||||||
|
document.body.style.cursor = ''
|
||||||
|
document.body.style.userSelect = ''
|
||||||
|
// Persist width
|
||||||
|
setPanelWidth((w) => {
|
||||||
|
localStorage.setItem(WIDTH_STORAGE_KEY, String(w))
|
||||||
|
return w
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.style.cursor = 'col-resize'
|
||||||
|
document.body.style.userSelect = 'none'
|
||||||
|
document.addEventListener('mousemove', handleMouseMove)
|
||||||
|
document.addEventListener('mouseup', handleMouseUp)
|
||||||
|
}, [panelWidth])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Backdrop - click to close */}
|
{/* Backdrop - click to close */}
|
||||||
@@ -115,17 +162,25 @@ export function AssistantPanel({ projectName, isOpen, onClose }: AssistantPanelP
|
|||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
fixed right-0 top-0 bottom-0 z-50
|
fixed right-0 top-0 bottom-0 z-50
|
||||||
w-[400px] max-w-[90vw]
|
|
||||||
bg-card
|
bg-card
|
||||||
border-l border-border
|
border-l border-border
|
||||||
transform transition-transform duration-300 ease-out
|
transform transition-transform duration-300 ease-out
|
||||||
flex flex-col shadow-xl
|
flex flex-col shadow-xl
|
||||||
${isOpen ? 'translate-x-0' : 'translate-x-full'}
|
${isOpen ? 'translate-x-0' : 'translate-x-full'}
|
||||||
`}
|
`}
|
||||||
|
style={{ width: `${panelWidth}px`, maxWidth: `${MAX_WIDTH_VW}vw` }}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label="Project Assistant"
|
aria-label="Project Assistant"
|
||||||
aria-hidden={!isOpen}
|
aria-hidden={!isOpen}
|
||||||
>
|
>
|
||||||
|
{/* Resize handle */}
|
||||||
|
<div
|
||||||
|
className="absolute left-0 top-0 bottom-0 w-1.5 cursor-col-resize z-10 group"
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-y-0 left-0 w-0.5 bg-border group-hover:bg-primary transition-colors" />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-primary text-primary-foreground">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-primary text-primary-foreground">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
|
|
||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
import { Bot, User, Info } from 'lucide-react'
|
import { Bot, User, Info } from 'lucide-react'
|
||||||
|
import ReactMarkdown, { type Components } from 'react-markdown'
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
import type { ChatMessage as ChatMessageType } from '../lib/types'
|
import type { ChatMessage as ChatMessageType } from '../lib/types'
|
||||||
import { Card } from '@/components/ui/card'
|
import { Card } from '@/components/ui/card'
|
||||||
|
|
||||||
@@ -14,8 +16,16 @@ interface ChatMessageProps {
|
|||||||
message: ChatMessageType
|
message: ChatMessageType
|
||||||
}
|
}
|
||||||
|
|
||||||
// Module-level regex to avoid recreating on each render
|
// Stable references for memo — avoids re-renders
|
||||||
const BOLD_REGEX = /\*\*(.*?)\*\*/g
|
const remarkPlugins = [remarkGfm]
|
||||||
|
|
||||||
|
const markdownComponents: Components = {
|
||||||
|
a: ({ children, href, ...props }) => (
|
||||||
|
<a href={href} target="_blank" rel="noopener noreferrer" {...props}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
export const ChatMessage = memo(function ChatMessage({ message }: ChatMessageProps) {
|
export const ChatMessage = memo(function ChatMessage({ message }: ChatMessageProps) {
|
||||||
const { role, content, attachments, timestamp, isStreaming } = message
|
const { role, content, attachments, timestamp, isStreaming } = message
|
||||||
@@ -86,39 +96,11 @@ export const ChatMessage = memo(function ChatMessage({ message }: ChatMessagePro
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Card className={`${config.bgColor} px-4 py-3 border ${isStreaming ? 'animate-pulse' : ''}`}>
|
<Card className={`${config.bgColor} px-4 py-3 border ${isStreaming ? 'animate-pulse' : ''}`}>
|
||||||
{/* Parse content for basic markdown-like formatting */}
|
|
||||||
{content && (
|
{content && (
|
||||||
<div className={`whitespace-pre-wrap text-sm leading-relaxed ${config.textColor}`}>
|
<div className={`text-sm leading-relaxed ${config.textColor} chat-prose${role === 'user' ? ' chat-prose-user' : ''}`}>
|
||||||
{content.split('\n').map((line, i) => {
|
<ReactMarkdown remarkPlugins={remarkPlugins} components={markdownComponents}>
|
||||||
// Bold text - use module-level regex, reset lastIndex for each line
|
{content}
|
||||||
BOLD_REGEX.lastIndex = 0
|
</ReactMarkdown>
|
||||||
const parts = []
|
|
||||||
let lastIndex = 0
|
|
||||||
let match
|
|
||||||
|
|
||||||
while ((match = BOLD_REGEX.exec(line)) !== null) {
|
|
||||||
if (match.index > lastIndex) {
|
|
||||||
parts.push(line.slice(lastIndex, match.index))
|
|
||||||
}
|
|
||||||
parts.push(
|
|
||||||
<strong key={`bold-${i}-${match.index}`} className="font-bold">
|
|
||||||
{match[1]}
|
|
||||||
</strong>
|
|
||||||
)
|
|
||||||
lastIndex = match.index + match[0].length
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastIndex < line.length) {
|
|
||||||
parts.push(line.slice(lastIndex))
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span key={i}>
|
|
||||||
{parts.length > 0 ? parts : line}
|
|
||||||
{i < content.split('\n').length - 1 && '\n'}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
182
ui/src/components/DevServerConfigDialog.tsx
Normal file
182
ui/src/components/DevServerConfigDialog.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Loader2, RotateCcw, Terminal } from 'lucide-react'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { useDevServerConfig, useUpdateDevServerConfig } from '@/hooks/useProjects'
|
||||||
|
import { startDevServer } from '@/lib/api'
|
||||||
|
|
||||||
|
interface DevServerConfigDialogProps {
|
||||||
|
projectName: string
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
autoStartOnSave?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DevServerConfigDialog({
|
||||||
|
projectName,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
autoStartOnSave = false,
|
||||||
|
}: DevServerConfigDialogProps) {
|
||||||
|
const { data: config } = useDevServerConfig(isOpen ? projectName : null)
|
||||||
|
const updateConfig = useUpdateDevServerConfig(projectName)
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const [command, setCommand] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
|
||||||
|
// Sync input with config when dialog opens or config loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && config) {
|
||||||
|
setCommand(config.custom_command ?? config.effective_command ?? '')
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
}, [isOpen, config])
|
||||||
|
|
||||||
|
const hasCustomCommand = !!config?.custom_command
|
||||||
|
|
||||||
|
const handleSaveAndStart = async () => {
|
||||||
|
const trimmed = command.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
setError('Please enter a dev server command.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateConfig.mutateAsync(trimmed)
|
||||||
|
|
||||||
|
if (autoStartOnSave) {
|
||||||
|
await startDevServer(projectName)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['dev-server-status', projectName] })
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to save configuration')
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClear = async () => {
|
||||||
|
setIsSaving(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateConfig.mutateAsync(null)
|
||||||
|
setCommand(config?.detected_command ?? '')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to clear configuration')
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-primary/10 text-primary">
|
||||||
|
<Terminal size={20} />
|
||||||
|
</div>
|
||||||
|
<DialogTitle>Dev Server Configuration</DialogTitle>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogDescription asChild>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Detection info */}
|
||||||
|
<div className="rounded-lg border-2 border-border bg-muted/50 p-3 text-sm">
|
||||||
|
{config?.detected_type ? (
|
||||||
|
<p>
|
||||||
|
Detected project type: <strong className="text-foreground">{config.detected_type}</strong>
|
||||||
|
{config.detected_command && (
|
||||||
|
<span className="text-muted-foreground"> — {config.detected_command}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
No project type detected. Enter a custom command below.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Command input */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="dev-command" className="text-foreground">Dev server command</Label>
|
||||||
|
<Input
|
||||||
|
id="dev-command"
|
||||||
|
value={command}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCommand(e.target.value)
|
||||||
|
setError(null)
|
||||||
|
}}
|
||||||
|
placeholder="npm run dev"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && !isSaving) {
|
||||||
|
handleSaveAndStart()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Allowed runners: npm, npx, pnpm, yarn, python, uvicorn, flask, poetry, cargo, go
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Clear custom command button */}
|
||||||
|
{hasCustomCommand && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleClear}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
<RotateCcw size={14} />
|
||||||
|
Clear custom command (use auto-detection)
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error display */}
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm font-mono text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogDescription>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={isSaving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSaveAndStart} disabled={isSaving}>
|
||||||
|
{isSaving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={16} className="animate-spin mr-1.5" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : autoStartOnSave ? (
|
||||||
|
'Save & Start'
|
||||||
|
) : (
|
||||||
|
'Save'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Globe, Square, Loader2, ExternalLink, AlertTriangle } from 'lucide-react'
|
import { useState } from 'react'
|
||||||
|
import { Globe, Square, Loader2, ExternalLink, AlertTriangle, Settings2 } from 'lucide-react'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import type { DevServerStatus } from '../lib/types'
|
import type { DevServerStatus } from '../lib/types'
|
||||||
import { startDevServer, stopDevServer } from '../lib/api'
|
import { startDevServer, stopDevServer } from '../lib/api'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { DevServerConfigDialog } from './DevServerConfigDialog'
|
||||||
|
|
||||||
// Re-export DevServerStatus from lib/types for consumers that import from here
|
// Re-export DevServerStatus from lib/types for consumers that import from here
|
||||||
export type { DevServerStatus }
|
export type { DevServerStatus }
|
||||||
@@ -59,17 +61,27 @@ interface DevServerControlProps {
|
|||||||
* - Shows loading state during operations
|
* - Shows loading state during operations
|
||||||
* - Displays clickable URL when server is running
|
* - Displays clickable URL when server is running
|
||||||
* - Uses neobrutalism design with cyan accent when running
|
* - Uses neobrutalism design with cyan accent when running
|
||||||
|
* - Config dialog for setting custom dev commands
|
||||||
*/
|
*/
|
||||||
export function DevServerControl({ projectName, status, url }: DevServerControlProps) {
|
export function DevServerControl({ projectName, status, url }: DevServerControlProps) {
|
||||||
const startDevServerMutation = useStartDevServer(projectName)
|
const startDevServerMutation = useStartDevServer(projectName)
|
||||||
const stopDevServerMutation = useStopDevServer(projectName)
|
const stopDevServerMutation = useStopDevServer(projectName)
|
||||||
|
const [showConfigDialog, setShowConfigDialog] = useState(false)
|
||||||
|
const [autoStartOnSave, setAutoStartOnSave] = useState(false)
|
||||||
|
|
||||||
const isLoading = startDevServerMutation.isPending || stopDevServerMutation.isPending
|
const isLoading = startDevServerMutation.isPending || stopDevServerMutation.isPending
|
||||||
|
|
||||||
const handleStart = () => {
|
const handleStart = () => {
|
||||||
// Clear any previous errors before starting
|
// Clear any previous errors before starting
|
||||||
stopDevServerMutation.reset()
|
stopDevServerMutation.reset()
|
||||||
startDevServerMutation.mutate()
|
startDevServerMutation.mutate(undefined, {
|
||||||
|
onError: (err) => {
|
||||||
|
if (err.message?.includes('No dev command available')) {
|
||||||
|
setAutoStartOnSave(true)
|
||||||
|
setShowConfigDialog(true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
const handleStop = () => {
|
const handleStop = () => {
|
||||||
// Clear any previous errors before stopping
|
// Clear any previous errors before stopping
|
||||||
@@ -77,6 +89,19 @@ export function DevServerControl({ projectName, status, url }: DevServerControlP
|
|||||||
stopDevServerMutation.mutate()
|
stopDevServerMutation.mutate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleOpenConfig = () => {
|
||||||
|
setAutoStartOnSave(false)
|
||||||
|
setShowConfigDialog(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCloseConfig = () => {
|
||||||
|
setShowConfigDialog(false)
|
||||||
|
// Clear the start error if config dialog was opened reactively
|
||||||
|
if (startDevServerMutation.error?.message?.includes('No dev command available')) {
|
||||||
|
startDevServerMutation.reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Server is stopped when status is 'stopped' or 'crashed' (can restart)
|
// Server is stopped when status is 'stopped' or 'crashed' (can restart)
|
||||||
const isStopped = status === 'stopped' || status === 'crashed'
|
const isStopped = status === 'stopped' || status === 'crashed'
|
||||||
// Server is in a running state
|
// Server is in a running state
|
||||||
@@ -84,9 +109,14 @@ export function DevServerControl({ projectName, status, url }: DevServerControlP
|
|||||||
// Server has crashed
|
// Server has crashed
|
||||||
const isCrashed = status === 'crashed'
|
const isCrashed = status === 'crashed'
|
||||||
|
|
||||||
|
// Hide inline error when config dialog is handling it
|
||||||
|
const startError = startDevServerMutation.error
|
||||||
|
const showInlineError = startError && !startError.message?.includes('No dev command available')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{isStopped ? (
|
{isStopped ? (
|
||||||
|
<>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleStart}
|
onClick={handleStart}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
@@ -103,6 +133,16 @@ export function DevServerControl({ projectName, status, url }: DevServerControlP
|
|||||||
<Globe size={18} />
|
<Globe size={18} />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleOpenConfig}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
title="Configure Dev Server"
|
||||||
|
aria-label="Configure Dev Server"
|
||||||
|
>
|
||||||
|
<Settings2 size={16} />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleStop}
|
onClick={handleStop}
|
||||||
@@ -139,12 +179,20 @@ export function DevServerControl({ projectName, status, url }: DevServerControlP
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error display */}
|
{/* Error display (hide "no dev command" error when config dialog handles it) */}
|
||||||
{(startDevServerMutation.error || stopDevServerMutation.error) && (
|
{(showInlineError || stopDevServerMutation.error) && (
|
||||||
<span className="text-xs font-mono text-destructive ml-2">
|
<span className="text-xs font-mono text-destructive ml-2">
|
||||||
{String((startDevServerMutation.error || stopDevServerMutation.error)?.message || 'Operation failed')}
|
{String((showInlineError ? startError : stopDevServerMutation.error)?.message || 'Operation failed')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Dev Server Config Dialog */}
|
||||||
|
<DevServerConfigDialog
|
||||||
|
projectName={projectName}
|
||||||
|
isOpen={showConfigDialog}
|
||||||
|
onClose={handleCloseConfig}
|
||||||
|
autoStartOnSave={autoStartOnSave}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandPr
|
|||||||
onFeatureClick={onFeatureClick}
|
onFeatureClick={onFeatureClick}
|
||||||
onAddFeature={onAddFeature}
|
onAddFeature={onAddFeature}
|
||||||
onExpandProject={onExpandProject}
|
onExpandProject={onExpandProject}
|
||||||
showExpandButton={hasFeatures}
|
showExpandButton={hasFeatures && hasSpec}
|
||||||
onCreateSpec={onCreateSpec}
|
onCreateSpec={onCreateSpec}
|
||||||
showCreateSpec={!hasSpec && !hasFeatures}
|
showCreateSpec={!hasSpec && !hasFeatures}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const shortcuts: Shortcut[] = [
|
|||||||
{ key: 'D', description: 'Toggle debug panel' },
|
{ key: 'D', description: 'Toggle debug panel' },
|
||||||
{ key: 'T', description: 'Toggle terminal tab' },
|
{ key: 'T', description: 'Toggle terminal tab' },
|
||||||
{ key: 'N', description: 'Add new feature', context: 'with project' },
|
{ key: 'N', description: 'Add new feature', context: 'with project' },
|
||||||
{ key: 'E', description: 'Expand project with AI', context: 'with features' },
|
{ key: 'E', description: 'Expand project with AI', context: 'with spec & features' },
|
||||||
{ key: 'A', description: 'Toggle AI assistant', context: 'with project' },
|
{ key: 'A', description: 'Toggle AI assistant', context: 'with project' },
|
||||||
{ key: 'G', description: 'Toggle Kanban/Graph view', context: 'with project' },
|
{ key: 'G', description: 'Toggle Kanban/Graph view', context: 'with project' },
|
||||||
{ key: ',', description: 'Open settings' },
|
{ key: ',', description: 'Open settings' },
|
||||||
|
|||||||
@@ -73,16 +73,16 @@ export function ProjectSelector({
|
|||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="min-w-[200px] justify-between"
|
className="min-w-[140px] sm:min-w-[200px] justify-between"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Loader2 size={18} className="animate-spin" />
|
<Loader2 size={18} className="animate-spin" />
|
||||||
) : selectedProject ? (
|
) : selectedProject ? (
|
||||||
<>
|
<>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2 truncate">
|
||||||
<FolderOpen size={18} />
|
<FolderOpen size={18} className="shrink-0" />
|
||||||
{selectedProject}
|
<span className="truncate">{selectedProject}</span>
|
||||||
</span>
|
</span>
|
||||||
{selectedProjectData && selectedProjectData.stats.total > 0 && (
|
{selectedProjectData && selectedProjectData.stats.total > 0 && (
|
||||||
<Badge className="ml-2">{selectedProjectData.stats.percentage}%</Badge>
|
<Badge className="ml-2">{selectedProjectData.stats.percentage}%</Badge>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Loader2, AlertCircle, Check, Moon, Sun } from 'lucide-react'
|
import { useState } from 'react'
|
||||||
import { useSettings, useUpdateSettings, useAvailableModels } from '../hooks/useProjects'
|
import { Loader2, AlertCircle, Check, Moon, Sun, Eye, EyeOff, ShieldCheck } from 'lucide-react'
|
||||||
|
import { useSettings, useUpdateSettings, useAvailableModels, useAvailableProviders } from '../hooks/useProjects'
|
||||||
import { useTheme, THEMES } from '../hooks/useTheme'
|
import { useTheme, THEMES } from '../hooks/useTheme'
|
||||||
|
import type { ProviderInfo } from '../lib/types'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -17,12 +19,26 @@ interface SettingsModalProps {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PROVIDER_INFO_TEXT: Record<string, string> = {
|
||||||
|
claude: 'Default provider. Uses your Claude CLI credentials.',
|
||||||
|
kimi: 'Get an API key at kimi.com',
|
||||||
|
glm: 'Get an API key at open.bigmodel.cn',
|
||||||
|
ollama: 'Run models locally. Install from ollama.com',
|
||||||
|
custom: 'Connect to any OpenAI-compatible API endpoint.',
|
||||||
|
}
|
||||||
|
|
||||||
export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||||
const { data: settings, isLoading, isError, refetch } = useSettings()
|
const { data: settings, isLoading, isError, refetch } = useSettings()
|
||||||
const { data: modelsData } = useAvailableModels()
|
const { data: modelsData } = useAvailableModels()
|
||||||
|
const { data: providersData } = useAvailableProviders()
|
||||||
const updateSettings = useUpdateSettings()
|
const updateSettings = useUpdateSettings()
|
||||||
const { theme, setTheme, darkMode, toggleDarkMode } = useTheme()
|
const { theme, setTheme, darkMode, toggleDarkMode } = useTheme()
|
||||||
|
|
||||||
|
const [showAuthToken, setShowAuthToken] = useState(false)
|
||||||
|
const [authTokenInput, setAuthTokenInput] = useState('')
|
||||||
|
const [customModelInput, setCustomModelInput] = useState('')
|
||||||
|
const [customBaseUrlInput, setCustomBaseUrlInput] = useState('')
|
||||||
|
|
||||||
const handleYoloToggle = () => {
|
const handleYoloToggle = () => {
|
||||||
if (settings && !updateSettings.isPending) {
|
if (settings && !updateSettings.isPending) {
|
||||||
updateSettings.mutate({ yolo_mode: !settings.yolo_mode })
|
updateSettings.mutate({ yolo_mode: !settings.yolo_mode })
|
||||||
@@ -31,7 +47,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
|||||||
|
|
||||||
const handleModelChange = (modelId: string) => {
|
const handleModelChange = (modelId: string) => {
|
||||||
if (!updateSettings.isPending) {
|
if (!updateSettings.isPending) {
|
||||||
updateSettings.mutate({ model: modelId })
|
updateSettings.mutate({ api_model: modelId })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,12 +63,51 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleProviderChange = (providerId: string) => {
|
||||||
|
if (!updateSettings.isPending) {
|
||||||
|
updateSettings.mutate({ api_provider: providerId })
|
||||||
|
// Reset local state
|
||||||
|
setAuthTokenInput('')
|
||||||
|
setShowAuthToken(false)
|
||||||
|
setCustomModelInput('')
|
||||||
|
setCustomBaseUrlInput('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveAuthToken = () => {
|
||||||
|
if (authTokenInput.trim() && !updateSettings.isPending) {
|
||||||
|
updateSettings.mutate({ api_auth_token: authTokenInput.trim() })
|
||||||
|
setAuthTokenInput('')
|
||||||
|
setShowAuthToken(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveCustomBaseUrl = () => {
|
||||||
|
if (customBaseUrlInput.trim() && !updateSettings.isPending) {
|
||||||
|
updateSettings.mutate({ api_base_url: customBaseUrlInput.trim() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveCustomModel = () => {
|
||||||
|
if (customModelInput.trim() && !updateSettings.isPending) {
|
||||||
|
updateSettings.mutate({ api_model: customModelInput.trim() })
|
||||||
|
setCustomModelInput('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const providers = providersData?.providers ?? []
|
||||||
const models = modelsData?.models ?? []
|
const models = modelsData?.models ?? []
|
||||||
const isSaving = updateSettings.isPending
|
const isSaving = updateSettings.isPending
|
||||||
|
const currentProvider = settings?.api_provider ?? 'claude'
|
||||||
|
const currentProviderInfo: ProviderInfo | undefined = providers.find(p => p.id === currentProvider)
|
||||||
|
const isAlternativeProvider = currentProvider !== 'claude'
|
||||||
|
const showAuthField = isAlternativeProvider && currentProviderInfo?.requires_auth
|
||||||
|
const showBaseUrlField = currentProvider === 'custom'
|
||||||
|
const showCustomModelInput = currentProvider === 'custom' || currentProvider === 'ollama'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||||
<DialogContent className="sm:max-w-sm">
|
<DialogContent aria-describedby={undefined} className="sm:max-w-sm max-h-[85vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
Settings
|
Settings
|
||||||
@@ -159,6 +214,147 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
|||||||
|
|
||||||
<hr className="border-border" />
|
<hr className="border-border" />
|
||||||
|
|
||||||
|
{/* API Provider Selection */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="font-medium">API Provider</Label>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{providers.map((provider) => (
|
||||||
|
<button
|
||||||
|
key={provider.id}
|
||||||
|
onClick={() => handleProviderChange(provider.id)}
|
||||||
|
disabled={isSaving}
|
||||||
|
className={`py-1.5 px-3 text-sm font-medium rounded-md border transition-colors ${
|
||||||
|
currentProvider === provider.id
|
||||||
|
? 'bg-primary text-primary-foreground border-primary'
|
||||||
|
: 'bg-background text-foreground border-border hover:bg-muted'
|
||||||
|
} ${isSaving ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
>
|
||||||
|
{provider.name.split(' (')[0]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{PROVIDER_INFO_TEXT[currentProvider] ?? ''}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Auth Token Field */}
|
||||||
|
{showAuthField && (
|
||||||
|
<div className="space-y-2 pt-1">
|
||||||
|
<Label className="text-sm">API Key</Label>
|
||||||
|
{settings.api_has_auth_token && !authTokenInput && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<ShieldCheck size={14} className="text-green-500" />
|
||||||
|
<span>Configured</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-auto py-0.5 px-2 text-xs"
|
||||||
|
onClick={() => setAuthTokenInput(' ')}
|
||||||
|
>
|
||||||
|
Change
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(!settings.api_has_auth_token || authTokenInput) && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<input
|
||||||
|
type={showAuthToken ? 'text' : 'password'}
|
||||||
|
value={authTokenInput.trim()}
|
||||||
|
onChange={(e) => setAuthTokenInput(e.target.value)}
|
||||||
|
placeholder="Enter API key..."
|
||||||
|
className="w-full py-1.5 px-3 pe-9 text-sm border rounded-md bg-background"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAuthToken(!showAuthToken)}
|
||||||
|
className="absolute end-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{showAuthToken ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSaveAuthToken}
|
||||||
|
disabled={!authTokenInput.trim() || isSaving}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Custom Base URL Field */}
|
||||||
|
{showBaseUrlField && (
|
||||||
|
<div className="space-y-2 pt-1">
|
||||||
|
<Label className="text-sm">Base URL</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customBaseUrlInput || settings.api_base_url || ''}
|
||||||
|
onChange={(e) => setCustomBaseUrlInput(e.target.value)}
|
||||||
|
placeholder="https://api.example.com/v1"
|
||||||
|
className="flex-1 py-1.5 px-3 text-sm border rounded-md bg-background"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSaveCustomBaseUrl}
|
||||||
|
disabled={!customBaseUrlInput.trim() || isSaving}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Model Selection */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-medium">Model</Label>
|
||||||
|
{models.length > 0 && (
|
||||||
|
<div className="flex rounded-lg border overflow-hidden">
|
||||||
|
{models.map((model) => (
|
||||||
|
<button
|
||||||
|
key={model.id}
|
||||||
|
onClick={() => handleModelChange(model.id)}
|
||||||
|
disabled={isSaving}
|
||||||
|
className={`flex-1 py-2 px-3 text-sm font-medium transition-colors ${
|
||||||
|
(settings.api_model ?? settings.model) === model.id
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-background text-foreground hover:bg-muted'
|
||||||
|
} ${isSaving ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
>
|
||||||
|
<span className="block">{model.name}</span>
|
||||||
|
<span className="block text-xs opacity-60">{model.id}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Custom model input for Ollama/Custom */}
|
||||||
|
{showCustomModelInput && (
|
||||||
|
<div className="flex gap-2 pt-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customModelInput}
|
||||||
|
onChange={(e) => setCustomModelInput(e.target.value)}
|
||||||
|
placeholder="Custom model name..."
|
||||||
|
className="flex-1 py-1.5 px-3 text-sm border rounded-md bg-background"
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSaveCustomModel()}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSaveCustomModel}
|
||||||
|
disabled={!customModelInput.trim() || isSaving}
|
||||||
|
>
|
||||||
|
Set
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="border-border" />
|
||||||
|
|
||||||
{/* YOLO Mode Toggle */}
|
{/* YOLO Mode Toggle */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
@@ -195,27 +391,6 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Model Selection */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="font-medium">Model</Label>
|
|
||||||
<div className="flex rounded-lg border overflow-hidden">
|
|
||||||
{models.map((model) => (
|
|
||||||
<button
|
|
||||||
key={model.id}
|
|
||||||
onClick={() => handleModelChange(model.id)}
|
|
||||||
disabled={isSaving}
|
|
||||||
className={`flex-1 py-2 px-3 text-sm font-medium transition-colors ${
|
|
||||||
settings.model === model.id
|
|
||||||
? 'bg-primary text-primary-foreground'
|
|
||||||
: 'bg-background text-foreground hover:bg-muted'
|
|
||||||
} ${isSaving ? 'opacity-50 cursor-not-allowed' : ''}`}
|
|
||||||
>
|
|
||||||
{model.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Regression Agents */}
|
{/* Regression Agents */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="font-medium">Regression Agents</Label>
|
<Label className="font-medium">Regression Agents</Label>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef, useEffect } from 'react'
|
||||||
import { Palette, Check } from 'lucide-react'
|
import { Palette, Check } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
||||||
import type { ThemeId, ThemeOption } from '../hooks/useTheme'
|
import type { ThemeId, ThemeOption } from '../hooks/useTheme'
|
||||||
|
|
||||||
interface ThemeSelectorProps {
|
interface ThemeSelectorProps {
|
||||||
@@ -97,16 +98,20 @@ export function ThemeSelector({ themes, currentTheme, onThemeChange }: ThemeSele
|
|||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={handleMouseEnter}
|
||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
>
|
>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
title="Theme"
|
|
||||||
aria-label="Select theme"
|
aria-label="Select theme"
|
||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
>
|
>
|
||||||
<Palette size={18} />
|
<Palette size={18} />
|
||||||
</Button>
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Theme</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
{/* Dropdown */}
|
{/* Dropdown */}
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
|
|||||||
65
ui/src/components/ui/tooltip.tsx
Normal file
65
ui/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function TooltipProvider({
|
||||||
|
delayDuration = 250,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Provider> & {
|
||||||
|
delayDuration?: number
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Provider
|
||||||
|
data-slot="tooltip-provider"
|
||||||
|
delayDuration={delayDuration}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||||
|
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
side = "bottom",
|
||||||
|
align = "center",
|
||||||
|
sideOffset = 8,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
side={side}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 overflow-hidden rounded-md border bg-neutral-900 px-3 py-2 text-sm text-white shadow-md leading-tight min-h-7",
|
||||||
|
"data-[state=delayed-open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=delayed-open]:fade-in-0 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TooltipPrimitive.Arrow
|
||||||
|
data-slot="tooltip-arrow"
|
||||||
|
className="fill-neutral-900"
|
||||||
|
/>
|
||||||
|
</TooltipPrimitive.Content>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback, useRef, useEffect } from "react";
|
import { useState, useCallback, useRef, useEffect } from "react";
|
||||||
import type { ChatMessage, AssistantChatServerMessage } from "../lib/types";
|
import type { ChatMessage, AssistantChatServerMessage, SpecQuestion } from "../lib/types";
|
||||||
|
|
||||||
type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error";
|
type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error";
|
||||||
|
|
||||||
@@ -17,8 +17,10 @@ interface UseAssistantChatReturn {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
connectionStatus: ConnectionStatus;
|
connectionStatus: ConnectionStatus;
|
||||||
conversationId: number | null;
|
conversationId: number | null;
|
||||||
|
currentQuestions: SpecQuestion[] | null;
|
||||||
start: (conversationId?: number | null) => void;
|
start: (conversationId?: number | null) => void;
|
||||||
sendMessage: (content: string) => void;
|
sendMessage: (content: string) => void;
|
||||||
|
sendAnswer: (answers: Record<string, string | string[]>) => void;
|
||||||
disconnect: () => void;
|
disconnect: () => void;
|
||||||
clearMessages: () => void;
|
clearMessages: () => void;
|
||||||
}
|
}
|
||||||
@@ -36,6 +38,7 @@ export function useAssistantChat({
|
|||||||
const [connectionStatus, setConnectionStatus] =
|
const [connectionStatus, setConnectionStatus] =
|
||||||
useState<ConnectionStatus>("disconnected");
|
useState<ConnectionStatus>("disconnected");
|
||||||
const [conversationId, setConversationId] = useState<number | null>(null);
|
const [conversationId, setConversationId] = useState<number | null>(null);
|
||||||
|
const [currentQuestions, setCurrentQuestions] = useState<SpecQuestion[] | null>(null);
|
||||||
|
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const currentAssistantMessageRef = useRef<string | null>(null);
|
const currentAssistantMessageRef = useRef<string | null>(null);
|
||||||
@@ -204,6 +207,25 @@ export function useAssistantChat({
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "question": {
|
||||||
|
// Claude is asking structured questions via ask_user tool
|
||||||
|
setCurrentQuestions(data.questions);
|
||||||
|
setIsLoading(false);
|
||||||
|
|
||||||
|
// Attach questions to the last assistant message for display context
|
||||||
|
setMessages((prev) => {
|
||||||
|
const lastMessage = prev[prev.length - 1];
|
||||||
|
if (lastMessage?.role === "assistant" && lastMessage.isStreaming) {
|
||||||
|
return [
|
||||||
|
...prev.slice(0, -1),
|
||||||
|
{ ...lastMessage, isStreaming: false, questions: data.questions },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "conversation_created": {
|
case "conversation_created": {
|
||||||
setConversationId(data.conversation_id);
|
setConversationId(data.conversation_id);
|
||||||
break;
|
break;
|
||||||
@@ -327,6 +349,49 @@ export function useAssistantChat({
|
|||||||
[onError],
|
[onError],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const sendAnswer = useCallback(
|
||||||
|
(answers: Record<string, string | string[]>) => {
|
||||||
|
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
|
||||||
|
onError?.("Not connected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format answers as display text for user message
|
||||||
|
const answerParts: string[] = [];
|
||||||
|
for (const [, value] of Object.entries(answers)) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
answerParts.push(value.join(", "));
|
||||||
|
} else {
|
||||||
|
answerParts.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const displayText = answerParts.join("; ");
|
||||||
|
|
||||||
|
// Add user message to chat
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: generateId(),
|
||||||
|
role: "user",
|
||||||
|
content: displayText,
|
||||||
|
timestamp: new Date(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
setCurrentQuestions(null);
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
// Send structured answer to server
|
||||||
|
wsRef.current.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "answer",
|
||||||
|
answers,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[onError],
|
||||||
|
);
|
||||||
|
|
||||||
const disconnect = useCallback(() => {
|
const disconnect = useCallback(() => {
|
||||||
reconnectAttempts.current = maxReconnectAttempts; // Prevent reconnection
|
reconnectAttempts.current = maxReconnectAttempts; // Prevent reconnection
|
||||||
if (pingIntervalRef.current) {
|
if (pingIntervalRef.current) {
|
||||||
@@ -350,8 +415,10 @@ export function useAssistantChat({
|
|||||||
isLoading,
|
isLoading,
|
||||||
connectionStatus,
|
connectionStatus,
|
||||||
conversationId,
|
conversationId,
|
||||||
|
currentQuestions,
|
||||||
start,
|
start,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
|
sendAnswer,
|
||||||
disconnect,
|
disconnect,
|
||||||
clearMessages,
|
clearMessages,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -107,16 +107,20 @@ export function useExpandChat({
|
|||||||
}, 30000)
|
}, 30000)
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = (event) => {
|
||||||
setConnectionStatus('disconnected')
|
setConnectionStatus('disconnected')
|
||||||
if (pingIntervalRef.current) {
|
if (pingIntervalRef.current) {
|
||||||
clearInterval(pingIntervalRef.current)
|
clearInterval(pingIntervalRef.current)
|
||||||
pingIntervalRef.current = null
|
pingIntervalRef.current = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Don't retry on application-level errors (4xxx codes won't resolve on retry)
|
||||||
|
const isAppError = event.code >= 4000 && event.code <= 4999
|
||||||
|
|
||||||
// Attempt reconnection if not intentionally closed
|
// Attempt reconnection if not intentionally closed
|
||||||
if (
|
if (
|
||||||
!manuallyDisconnectedRef.current &&
|
!manuallyDisconnectedRef.current &&
|
||||||
|
!isAppError &&
|
||||||
reconnectAttempts.current < maxReconnectAttempts &&
|
reconnectAttempts.current < maxReconnectAttempts &&
|
||||||
!isCompleteRef.current
|
!isCompleteRef.current
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import * as api from '../lib/api'
|
import * as api from '../lib/api'
|
||||||
import type { FeatureCreate, FeatureUpdate, ModelsResponse, ProjectSettingsUpdate, Settings, SettingsUpdate } from '../lib/types'
|
import type { DevServerConfig, FeatureCreate, FeatureUpdate, ModelsResponse, ProjectSettingsUpdate, ProvidersResponse, Settings, SettingsUpdate } from '../lib/types'
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Projects
|
// Projects
|
||||||
@@ -254,20 +254,41 @@ export function useValidatePath() {
|
|||||||
// Default models response for placeholder (until API responds)
|
// Default models response for placeholder (until API responds)
|
||||||
const DEFAULT_MODELS: ModelsResponse = {
|
const DEFAULT_MODELS: ModelsResponse = {
|
||||||
models: [
|
models: [
|
||||||
{ id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5' },
|
{ id: 'claude-opus-4-6', name: 'Claude Opus' },
|
||||||
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5' },
|
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet' },
|
||||||
],
|
],
|
||||||
default: 'claude-opus-4-5-20251101',
|
default: 'claude-opus-4-6',
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_SETTINGS: Settings = {
|
const DEFAULT_SETTINGS: Settings = {
|
||||||
yolo_mode: false,
|
yolo_mode: false,
|
||||||
model: 'claude-opus-4-5-20251101',
|
model: 'claude-opus-4-6',
|
||||||
glm_mode: false,
|
glm_mode: false,
|
||||||
ollama_mode: false,
|
ollama_mode: false,
|
||||||
testing_agent_ratio: 1,
|
testing_agent_ratio: 1,
|
||||||
playwright_headless: true,
|
playwright_headless: true,
|
||||||
batch_size: 3,
|
batch_size: 3,
|
||||||
|
api_provider: 'claude',
|
||||||
|
api_base_url: null,
|
||||||
|
api_has_auth_token: false,
|
||||||
|
api_model: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PROVIDERS: ProvidersResponse = {
|
||||||
|
providers: [
|
||||||
|
{ id: 'claude', name: 'Claude (Anthropic)', base_url: null, models: DEFAULT_MODELS.models, default_model: 'claude-opus-4-6', requires_auth: false },
|
||||||
|
],
|
||||||
|
current: 'claude',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAvailableProviders() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['available-providers'],
|
||||||
|
queryFn: api.getAvailableProviders,
|
||||||
|
staleTime: 300000,
|
||||||
|
retry: 1,
|
||||||
|
placeholderData: DEFAULT_PROVIDERS,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAvailableModels() {
|
export function useAvailableModels() {
|
||||||
@@ -319,6 +340,41 @@ export function useUpdateSettings() {
|
|||||||
},
|
},
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['settings'] })
|
queryClient.invalidateQueries({ queryKey: ['settings'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['available-models'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['available-providers'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Dev Server Config
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Default config for placeholder (until API responds)
|
||||||
|
const DEFAULT_DEV_SERVER_CONFIG: DevServerConfig = {
|
||||||
|
detected_type: null,
|
||||||
|
detected_command: null,
|
||||||
|
custom_command: null,
|
||||||
|
effective_command: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDevServerConfig(projectName: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['dev-server-config', projectName],
|
||||||
|
queryFn: () => api.getDevServerConfig(projectName!),
|
||||||
|
enabled: !!projectName,
|
||||||
|
staleTime: 30_000,
|
||||||
|
placeholderData: DEFAULT_DEV_SERVER_CONFIG,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateDevServerConfig(projectName: string) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (customCommand: string | null) =>
|
||||||
|
api.updateDevServerConfig(projectName, customCommand),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['dev-server-config', projectName] })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,15 +157,18 @@ export function useSpecChat({
|
|||||||
}, 30000)
|
}, 30000)
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = (event) => {
|
||||||
setConnectionStatus('disconnected')
|
setConnectionStatus('disconnected')
|
||||||
if (pingIntervalRef.current) {
|
if (pingIntervalRef.current) {
|
||||||
clearInterval(pingIntervalRef.current)
|
clearInterval(pingIntervalRef.current)
|
||||||
pingIntervalRef.current = null
|
pingIntervalRef.current = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Don't retry on application-level errors (4xxx codes won't resolve on retry)
|
||||||
|
const isAppError = event.code >= 4000 && event.code <= 4999
|
||||||
|
|
||||||
// Attempt reconnection if not intentionally closed
|
// Attempt reconnection if not intentionally closed
|
||||||
if (reconnectAttempts.current < maxReconnectAttempts && !isCompleteRef.current) {
|
if (!isAppError && reconnectAttempts.current < maxReconnectAttempts && !isCompleteRef.current) {
|
||||||
reconnectAttempts.current++
|
reconnectAttempts.current++
|
||||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 10000)
|
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 10000)
|
||||||
reconnectTimeoutRef.current = window.setTimeout(connect, delay)
|
reconnectTimeoutRef.current = window.setTimeout(connect, delay)
|
||||||
|
|||||||
@@ -335,10 +335,14 @@ export function useProjectWebSocket(projectName: string | null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = (event) => {
|
||||||
setState(prev => ({ ...prev, isConnected: false }))
|
setState(prev => ({ ...prev, isConnected: false }))
|
||||||
wsRef.current = null
|
wsRef.current = null
|
||||||
|
|
||||||
|
// Don't retry on application-level errors (4xxx codes won't resolve on retry)
|
||||||
|
const isAppError = event.code >= 4000 && event.code <= 4999
|
||||||
|
if (isAppError) return
|
||||||
|
|
||||||
// Exponential backoff reconnection
|
// Exponential backoff reconnection
|
||||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 30000)
|
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 30000)
|
||||||
reconnectAttempts.current++
|
reconnectAttempts.current++
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import type {
|
|||||||
Settings,
|
Settings,
|
||||||
SettingsUpdate,
|
SettingsUpdate,
|
||||||
ModelsResponse,
|
ModelsResponse,
|
||||||
|
ProvidersResponse,
|
||||||
DevServerStatusResponse,
|
DevServerStatusResponse,
|
||||||
DevServerConfig,
|
DevServerConfig,
|
||||||
TerminalInfo,
|
TerminalInfo,
|
||||||
@@ -399,6 +400,10 @@ export async function getAvailableModels(): Promise<ModelsResponse> {
|
|||||||
return fetchJSON('/settings/models')
|
return fetchJSON('/settings/models')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getAvailableProviders(): Promise<ProvidersResponse> {
|
||||||
|
return fetchJSON('/settings/providers')
|
||||||
|
}
|
||||||
|
|
||||||
export async function getSettings(): Promise<Settings> {
|
export async function getSettings(): Promise<Settings> {
|
||||||
return fetchJSON('/settings')
|
return fetchJSON('/settings')
|
||||||
}
|
}
|
||||||
@@ -440,6 +445,16 @@ export async function getDevServerConfig(projectName: string): Promise<DevServer
|
|||||||
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/devserver/config`)
|
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/devserver/config`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateDevServerConfig(
|
||||||
|
projectName: string,
|
||||||
|
customCommand: string | null
|
||||||
|
): Promise<DevServerConfig> {
|
||||||
|
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/devserver/config`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ custom_command: customCommand }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Terminal API
|
// Terminal API
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -465,6 +465,11 @@ export interface AssistantChatConversationCreatedMessage {
|
|||||||
conversation_id: number
|
conversation_id: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AssistantChatQuestionMessage {
|
||||||
|
type: 'question'
|
||||||
|
questions: SpecQuestion[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface AssistantChatPongMessage {
|
export interface AssistantChatPongMessage {
|
||||||
type: 'pong'
|
type: 'pong'
|
||||||
}
|
}
|
||||||
@@ -472,6 +477,7 @@ export interface AssistantChatPongMessage {
|
|||||||
export type AssistantChatServerMessage =
|
export type AssistantChatServerMessage =
|
||||||
| AssistantChatTextMessage
|
| AssistantChatTextMessage
|
||||||
| AssistantChatToolCallMessage
|
| AssistantChatToolCallMessage
|
||||||
|
| AssistantChatQuestionMessage
|
||||||
| AssistantChatResponseDoneMessage
|
| AssistantChatResponseDoneMessage
|
||||||
| AssistantChatErrorMessage
|
| AssistantChatErrorMessage
|
||||||
| AssistantChatConversationCreatedMessage
|
| AssistantChatConversationCreatedMessage
|
||||||
@@ -525,6 +531,20 @@ export interface ModelsResponse {
|
|||||||
default: string
|
default: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProviderInfo {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
base_url: string | null
|
||||||
|
models: ModelInfo[]
|
||||||
|
default_model: string
|
||||||
|
requires_auth: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProvidersResponse {
|
||||||
|
providers: ProviderInfo[]
|
||||||
|
current: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
yolo_mode: boolean
|
yolo_mode: boolean
|
||||||
model: string
|
model: string
|
||||||
@@ -533,6 +553,10 @@ export interface Settings {
|
|||||||
testing_agent_ratio: number // Regression testing agents (0-3)
|
testing_agent_ratio: number // Regression testing agents (0-3)
|
||||||
playwright_headless: boolean
|
playwright_headless: boolean
|
||||||
batch_size: number // Features per coding agent batch (1-3)
|
batch_size: number // Features per coding agent batch (1-3)
|
||||||
|
api_provider: string
|
||||||
|
api_base_url: string | null
|
||||||
|
api_has_auth_token: boolean
|
||||||
|
api_model: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SettingsUpdate {
|
export interface SettingsUpdate {
|
||||||
@@ -541,6 +565,10 @@ export interface SettingsUpdate {
|
|||||||
testing_agent_ratio?: number
|
testing_agent_ratio?: number
|
||||||
playwright_headless?: boolean
|
playwright_headless?: boolean
|
||||||
batch_size?: number
|
batch_size?: number
|
||||||
|
api_provider?: string
|
||||||
|
api_base_url?: string
|
||||||
|
api_auth_token?: string
|
||||||
|
api_model?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectSettingsUpdate {
|
export interface ProjectSettingsUpdate {
|
||||||
|
|||||||
@@ -1271,6 +1271,186 @@
|
|||||||
margin: 2rem 0;
|
margin: 2rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Chat Prose Typography (for markdown in chat bubbles)
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
.chat-prose {
|
||||||
|
line-height: 1.6;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-prose > :first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-prose > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-prose h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-prose h2 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-prose h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
margin-bottom: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-prose h4,
|
||||||
|
.chat-prose h5,
|
||||||
|
.chat-prose h6 {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-prose p {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-prose ul,
|
||||||
|
.chat-prose ol {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-prose ul {
|
||||||
|
list-style-type: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-prose ol {
|
||||||
|
list-style-type: decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-prose li {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-prose li > ul,
|
||||||
|
.chat-prose li > ol {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-prose pre {
|
||||||
|
background: var(--muted);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.75rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-prose code:not(pre code) {
|
||||||
|
background: var(--muted);
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-prose table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-prose th {
|
||||||
|
background: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-prose td {
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-prose blockquote {
|
||||||
|
border-left: 3px solid var(--primary);
|
||||||
|
padding-left: 0.75rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-prose a {
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-prose a:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-prose strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-prose hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-prose img {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* User message overrides - need contrast against primary-colored bubble */
|
||||||
|
.chat-prose-user pre {
|
||||||
|
background: rgb(255 255 255 / 0.15);
|
||||||
|
border-color: rgb(255 255 255 / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-prose-user code:not(pre code) {
|
||||||
|
background: rgb(255 255 255 / 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-prose-user th {
|
||||||
|
background: rgb(255 255 255 / 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-prose-user th,
|
||||||
|
.chat-prose-user td {
|
||||||
|
border-color: rgb(255 255 255 / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-prose-user blockquote {
|
||||||
|
border-left-color: rgb(255 255 255 / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-prose-user a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-prose-user hr {
|
||||||
|
border-top-color: rgb(255 255 255 / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
Scrollbar Styling
|
Scrollbar Styling
|
||||||
============================================================================ */
|
============================================================================ */
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export default defineConfig({
|
|||||||
'@radix-ui/react-slot',
|
'@radix-ui/react-slot',
|
||||||
'@radix-ui/react-switch',
|
'@radix-ui/react-switch',
|
||||||
],
|
],
|
||||||
|
// Markdown rendering
|
||||||
|
'vendor-markdown': ['react-markdown', 'remark-gfm'],
|
||||||
// Icons and utilities
|
// Icons and utilities
|
||||||
'vendor-utils': [
|
'vendor-utils': [
|
||||||
'lucide-react',
|
'lucide-react',
|
||||||
|
|||||||
Reference in New Issue
Block a user