From afa2da4cba864e3eb319c4cf779fe1154115dd81 Mon Sep 17 00:00:00 2001 From: Hemdan47 Date: Mon, 29 Jun 2026 22:25:25 +0300 Subject: [PATCH] test push --- .gitignore | 1 - ui/README.md | 75 +++ ui/app.js | 1591 ++++++++++++++++++++++++++++++++++++++++++++++++ ui/index.html | 220 +++++++ ui/run_demo.py | 175 ++++++ ui/styles.css | 1031 +++++++++++++++++++++++++++++++ 6 files changed, 3092 insertions(+), 1 deletion(-) create mode 100644 ui/README.md create mode 100644 ui/app.js create mode 100644 ui/index.html create mode 100644 ui/run_demo.py create mode 100644 ui/styles.css diff --git a/.gitignore b/.gitignore index 010ced5..2301fcc 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,6 @@ __pycache__/ #temp plan-* -ui # Distribution / packaging .Python diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 0000000..d0b7bd8 --- /dev/null +++ b/ui/README.md @@ -0,0 +1,75 @@ +# Agent Workspace UI + +A stark, high-contrast monochrome terminal-style frontend for configuring and testing the AI pipeline generation agent. + +## Features + +- **High-Contrast Monochrome Design**: Stark developer-terminal theme with zero rounded corners, monospaced typography, and red/green accenting reserved strictly for execution outcomes. +- **In-Memory Session Caching**: Chat history and active model configurations are saved inside the browser's `sessionStorage` (retains logs on page refresh but clears automatically when you close the tab or start a new session). +- **Collapsible thinking process**: Streamed thinking tokens (`[THINKING PROCESS]`) automatically collapse when finished to keep the logs readable. +- **Dynamic Tool Execution Cards**: Shows a real-time status spinner, turning green on success (`[OK]`) and collapsing, or red on failure (`[FAILED]`) while showing output details. +- **Inline Human-In-The-Loop (HITL) Handling**: Allows you to approve/deny bash commands and file writes, or answer clarifying questions directly inside the chat feed. +- **Local Credentials & Model Catalog**: Saves provider API keys locally, dynamically loads tool-calling models from `models.dev`, and supports adding custom OpenAI-compatible models. + +--- + +## How to Start the System + +There are two ways to start the servers: using the single-command helper (recommended) or launching each server manually. + +### Option 1: Single-Command Launch (Recommended) + +The helper script `ui/run_demo.py` launches both the FastAPI backend and the static UI server in the background, handles clean shutdowns, and verifies backend health before launching your browser. + +From the repository root: + +```bash +python ui/run_demo.py +``` + +If the browser does not open automatically, or if you prefer to open it yourself, use: + +```bash +python ui/run_demo.py --no-open +``` + +You can customize port bindings: + +```bash +python ui/run_demo.py --backend-port 8000 --ui-port 5173 +``` + +--- + +### Option 2: Manual Start + +If you prefer to run the backend and frontend in separate terminals: + +1. **Start the FastAPI Backend**: + From the repository root, add `src` to your python path and run `uvicorn`: + ```bash + # Windows (PowerShell) + $env:PYTHONPATH="src" + python -m uvicorn agent.api.api:app --host 127.0.0.1 --port 8000 + + # Linux/macOS + PYTHONPATH=src python -m uvicorn agent.api.api:app --host 127.0.0.1 --port 8000 + ``` + +2. **Start the UI Static Server**: + From the `ui/` directory, host the static HTML files using python's built-in server: + ```bash + python -m http.server 5173 --bind 127.0.0.1 --directory ui + ``` + +Once both servers are running, navigate to: +[http://127.0.0.1:5173/?backend=http://127.0.0.1:8000](http://127.0.0.1:5173/?backend=http://127.0.0.1:8000) + +--- + +## Active API Endpoints consumed by UI + +- `POST /generate/stream` — Starts the execution graph for pipeline generation. +- `POST /generate/{thread_id}/resume/permission` — Resumes a paused execution after human approval or denial of tool execution. +- `POST /generate/{thread_id}/resume/clarification` — Resumes execution by submitting user answers to clarifying questions. +- `POST /generate/runs/{run_id}/cancel` — Interrupts and terminates an active pipeline generation run. diff --git a/ui/app.js b/ui/app.js new file mode 100644 index 0000000..d6b3431 --- /dev/null +++ b/ui/app.js @@ -0,0 +1,1591 @@ +const state = { + threadId: null, + runId: null, + runState: "idle", + waiting: false, + awaitingHuman: false, + abortController: null, + + // Models catalog and configurations + modelsCatalog: null, + providers: [], + providerUrls: {}, + customModels: {}, + apiKeys: {}, + selectedProvider: "google", + selectedModel: "gemini-2.5-pro", + + // UI active references + activeToolBlock: null, + activeThinkingBlock: null, +}; + +const DEFAULT_CATALOG = { + providers: { + openai: { + name: "OpenAI", + api: "https://api.openai.com/v1", + models: { + "gpt-4o": { id: "gpt-4o", name: "GPT-4o", reasoning: false, tool_call: true }, + "gpt-4o-mini": { id: "gpt-4o-mini", name: "GPT-4o Mini", reasoning: false, tool_call: true }, + "o1-mini": { id: "o1-mini", name: "o1 Mini", reasoning: true, tool_call: true }, + "o1": { id: "o1", name: "o1", reasoning: true, tool_call: true }, + "o3-mini": { id: "o3-mini", name: "o3 Mini", reasoning: true, tool_call: true } + } + }, + anthropic: { + name: "Anthropic", + api: "https://api.anthropic.com/v1", + models: { + "claude-3-5-sonnet-20241022": { id: "claude-3-5-sonnet-20241022", name: "Claude 3.5 Sonnet", reasoning: false, tool_call: true }, + "claude-3-5-haiku-20241022": { id: "claude-3-5-haiku-20241022", name: "Claude 3.5 Haiku", reasoning: false, tool_call: true }, + "claude-3-7-sonnet": { id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", reasoning: true, tool_call: true } + } + }, + google: { + name: "Google Gemini", + api: "https://generativelanguage.googleapis.com", + models: { + "gemini-2.5-pro": { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", reasoning: true, tool_call: true }, + "gemini-2.5-flash": { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", reasoning: true, tool_call: true }, + "gemini-1.5-pro": { id: "gemini-1.5-pro", name: "Gemini 1.5 Pro", reasoning: false, tool_call: true }, + "gemini-1.5-flash": { id: "gemini-1.5-flash", name: "Gemini 1.5 Flash", reasoning: false, tool_call: true } + } + }, + groq: { + name: "Groq", + api: "https://api.groq.com/openai/v1", + models: { + "llama-3.3-70b-versatile": { id: "llama-3.3-70b-versatile", name: "Llama 3.3 70B", reasoning: false, tool_call: true }, + "llama3-70b-8192": { id: "llama3-70b-8192", name: "Llama 3 70B", reasoning: false, tool_call: true }, + "mixtral-8x7b-32768": { id: "mixtral-8x7b-32768", name: "Mixtral 8x7B", reasoning: false, tool_call: true } + } + } + } +}; + +const elements = { + backendUrl: document.getElementById("backendUrl"), + projectPath: document.getElementById("projectPath"), + platform: document.getElementById("platform"), + promptInput: document.getElementById("promptInput"), + startBtn: document.getElementById("startBtn"), + newThreadBtn: document.getElementById("newThreadBtn"), + cancelBtn: document.getElementById("cancelBtn"), + threadId: document.getElementById("threadId"), + runState: document.getElementById("runState"), + runId: document.getElementById("runId"), + chatLog: document.getElementById("chatLog"), + sessionTitle: document.getElementById("sessionTitle"), + sessionSubtitle: document.getElementById("sessionSubtitle"), + + // Model controls + modelSelectBtn: document.getElementById("modelSelectBtn"), + currentModelLabel: document.getElementById("currentModelLabel"), + modelDropdown: document.getElementById("modelDropdown"), + modelSearchInput: document.getElementById("modelSearchInput"), + modelList: document.getElementById("modelList"), + openaiCompatibleBtn: document.getElementById("openaiCompatibleBtn"), + reasoningEffort: document.getElementById("reasoningEffort"), + providerKeysList: document.getElementById("providerKeysList"), + + // API Key Modal + apiKeyModal: document.getElementById("apiKeyModal"), + apiKeyModalTitle: document.getElementById("apiKeyModalTitle"), + apiKeyModalProviderText: document.getElementById("apiKeyModalProviderText"), + apiKeyInput: document.getElementById("apiKeyInput"), + saveApiKeyBtn: document.getElementById("saveApiKeyBtn"), + deleteApiKeyBtn: document.getElementById("deleteApiKeyBtn"), + + // Custom Model Modal + customModelModal: document.getElementById("customModelModal"), + customDisplayName: document.getElementById("customDisplayName"), + customModelId: document.getElementById("customModelId"), + customBaseUrl: document.getElementById("customBaseUrl"), + customApiKey: document.getElementById("customApiKey"), + customReasoning: document.getElementById("customReasoning"), + saveCustomModelBtn: document.getElementById("saveCustomModelBtn"), +}; + +// State modifications +function setBusy(isBusy) { + state.waiting = isBusy; + elements.startBtn.disabled = isBusy; + elements.promptInput.disabled = isBusy; + elements.modelSelectBtn.disabled = isBusy; + elements.projectPath.disabled = isBusy; + elements.platform.disabled = isBusy; + + if (isBusy) { + elements.reasoningEffort.disabled = true; + } else { + updateReasoningEffortDropdown(state.selectedModel, state.selectedProvider); + } +} + +function setRunState(text) { + state.runState = text; + elements.runState.textContent = text.toUpperCase(); + elements.runState.className = `val badge badge-${text.toLowerCase()}`; + + const cancellableStates = new Set(["running", "resuming", "interrupted"]); + elements.cancelBtn.disabled = !cancellableStates.has(text); + saveChatSession(); +} + +function setThreadId(id) { + state.threadId = id || null; + elements.threadId.textContent = state.threadId || "not started"; + saveChatSession(); +} + +function setRunId(id) { + state.runId = id || null; + elements.runId.textContent = state.runId || "none"; + saveChatSession(); +} + +// Model Dropdown Actions +function toggleModelDropdown(e) { + e.stopPropagation(); + elements.modelDropdown.classList.toggle("hidden"); + if (!elements.modelDropdown.classList.contains("hidden")) { + elements.modelSearchInput.value = ""; + elements.modelSearchInput.focus(); + renderModelList(); + } +} + +function hideModelDropdown() { + elements.modelDropdown.classList.add("hidden"); +} + +function selectModel(modelId, providerId) { + state.selectedModel = modelId; + state.selectedProvider = providerId; + + localStorage.setItem("agent_selected_model", modelId); + localStorage.setItem("agent_selected_provider", providerId); + + elements.currentModelLabel.textContent = getModelDisplayName(modelId, providerId).toUpperCase(); + updateReasoningEffortDropdown(modelId, providerId); + + // If API key is not configured for this provider, prompt immediately + if (providerId !== "openai_compatible" && !state.customModels[modelId]) { + const isSet = !!state.apiKeys[providerId]; + if (!isSet) { + openApiKeyModal(providerId, getProviderName(providerId)); + } + } + saveChatSession(); +} + +function getModelDisplayName(modelId, providerId) { + if (providerId === "openai_compatible" && state.customModels[modelId]) { + return state.customModels[modelId].displayName; + } + const p = state.providers.find(prov => prov.id === providerId); + if (p) { + const m = p.models.find(model => model.id === modelId); + if (m) { + return `${p.name}: ${m.name}`; + } + } + return modelId; +} + +function getProviderName(providerId) { + const p = state.providers.find(prov => prov.id === providerId); + return p ? p.name : providerId; +} + +function updateReasoningEffortDropdown(modelId, providerId) { + let hasReasoning = false; + if (providerId === "openai_compatible" && state.customModels[modelId]) { + hasReasoning = state.customModels[modelId].reasoning; + } else { + const p = state.providers.find(prov => prov.id === providerId); + if (p) { + const m = p.models.find(model => model.id === modelId); + if (m) { + hasReasoning = m.reasoning === true; + } + } + } + + const select = elements.reasoningEffort; + if (hasReasoning) { + select.disabled = false; + select.classList.remove("disabled"); + } else { + select.disabled = true; + select.value = "none"; + select.classList.add("disabled"); + } +} + +// Modal management +let currentEditingProviderId = null; + +function openApiKeyModal(providerId, providerName) { + currentEditingProviderId = providerId; + elements.apiKeyModalTitle.textContent = `CONFIGURE API CREDENTIALS: ${providerName.toUpperCase()}`; + elements.apiKeyModalProviderText.textContent = `ENTER KEY VALUE FOR ${providerName.toUpperCase()}:`; + elements.apiKeyInput.value = state.apiKeys[providerId] || ""; + elements.apiKeyModal.classList.remove("hidden"); + elements.apiKeyInput.focus(); +} + +function closeApiKeyModal() { + elements.apiKeyModal.classList.add("hidden"); + currentEditingProviderId = null; +} + +// Fetch and load models from models.dev +async function loadModelsCatalog() { + try { + const savedKeys = localStorage.getItem("agent_api_keys"); + if (savedKeys) state.apiKeys = JSON.parse(savedKeys); + + const savedCustom = localStorage.getItem("agent_custom_models"); + if (savedCustom) state.customModels = JSON.parse(savedCustom); + } catch (e) { + console.error("Failed to load saved config from localStorage", e); + } + + try { + const cached = localStorage.getItem("agent_models_catalog"); + if (cached) { + state.modelsCatalog = JSON.parse(cached); + processCatalog(state.modelsCatalog); + } + } catch (e) { + console.error("Failed to parse cached catalog", e); + } + + if (!state.modelsCatalog) { + state.modelsCatalog = DEFAULT_CATALOG; + processCatalog(state.modelsCatalog); + } + + try { + const res = await fetch("https://models.dev/catalog.json"); + if (res.ok) { + const data = await res.json(); + state.modelsCatalog = data; + localStorage.setItem("agent_models_catalog", JSON.stringify(data)); + processCatalog(data); + } + } catch (err) { + console.warn("Could not fetch models.dev, using local copy:", err); + } + + loadSavedSelections(); + restoreChatSession(); +} + +function processCatalog(catalog) { + state.providers = []; + state.providerUrls = {}; + + if (!catalog || !catalog.providers) return; + + for (const [providerId, provider] of Object.entries(catalog.providers)) { + const toolModels = []; + if (provider.api) { + state.providerUrls[providerId] = provider.api; + } + + for (const [modelId, model] of Object.entries(provider.models || {})) { + if (model.tool_call === true) { + toolModels.push({ + id: model.id || modelId, + name: model.name || model.id || modelId, + reasoning: model.reasoning === true, + }); + } + } + + if (toolModels.length > 0) { + state.providers.push({ + id: providerId, + name: provider.name || providerId, + models: toolModels + }); + } + } + + const firstClass = ["openai", "anthropic", "google", "groq"]; + state.providers.sort((a, b) => { + const idxA = firstClass.indexOf(a.id); + const idxB = firstClass.indexOf(b.id); + if (idxA !== -1 && idxB !== -1) return idxA - idxB; + if (idxA !== -1) return -1; + if (idxB !== -1) return 1; + return a.name.localeCompare(b.name); + }); + + renderProviderKeysList(); + renderModelList(); +} + +function renderProviderKeysList() { + const container = elements.providerKeysList; + container.innerHTML = ""; + + const popularProviders = [ + { id: "openai", name: "OPENAI" }, + { id: "anthropic", name: "ANTHROPIC" }, + { id: "google", name: "GOOGLE GEMINI" }, + { id: "groq", name: "GROQ" }, + { id: "deepseek", name: "DEEPSEEK" }, + { id: "openrouter", name: "OPENROUTER" } + ]; + + popularProviders.forEach(p => { + const isSet = !!state.apiKeys[p.id]; + const item = document.createElement("div"); + item.className = "provider-key-item"; + item.innerHTML = ` + ${p.name} + + + ${isSet ? "CONFIGURED" : "NOT SET"} + + `; + item.addEventListener("click", () => openApiKeyModal(p.id, p.name)); + container.appendChild(item); + }); +} + +function renderModelList(filterText = "") { + const container = elements.modelList; + container.innerHTML = ""; + const query = filterText.toLowerCase().trim(); + + // Render Custom models + const customModelsList = Object.values(state.customModels); + if (customModelsList.length > 0) { + const matchingCustom = customModelsList.filter(m => + m.displayName.toLowerCase().includes(query) || m.model.toLowerCase().includes(query) + ); + if (matchingCustom.length > 0) { + const header = document.createElement("div"); + header.className = "provider-header"; + header.textContent = "CUSTOM MODELS"; + container.appendChild(header); + + matchingCustom.forEach(m => { + const isSelected = state.selectedModel === m.model && state.selectedProvider === "openai_compatible"; + const item = document.createElement("div"); + item.className = `model-item ${isSelected ? "active" : ""}`; + item.innerHTML = ` +
+ ${m.displayName.toUpperCase()} + ${m.model} +
+
+ ${m.reasoning ? 'REASONING' : ''} +
+ `; + item.addEventListener("click", () => { + selectModel(m.model, "openai_compatible"); + hideModelDropdown(); + }); + container.appendChild(item); + }); + } + } + + // Render catalog providers + state.providers.forEach(p => { + const matchingModels = p.models.filter(m => + m.name.toLowerCase().includes(query) || m.id.toLowerCase().includes(query) + ); + + if (matchingModels.length > 0) { + const header = document.createElement("div"); + header.className = "provider-header"; + header.textContent = p.name.toUpperCase(); + container.appendChild(header); + + matchingModels.forEach(m => { + const isSelected = state.selectedModel === m.id && state.selectedProvider === p.id; + const item = document.createElement("div"); + item.className = `model-item ${isSelected ? "active" : ""}`; + item.innerHTML = ` +
+ ${m.name.toUpperCase()} + ${m.id} +
+
+ ${m.reasoning ? 'REASONING' : ''} +
+ `; + item.addEventListener("click", () => { + selectModel(m.id, p.id); + hideModelDropdown(); + }); + container.appendChild(item); + }); + } + }); +} + +function loadSavedSelections() { + const savedModel = localStorage.getItem("agent_selected_model"); + const savedProvider = localStorage.getItem("agent_selected_provider"); + if (savedModel && savedProvider) { + state.selectedModel = savedModel; + state.selectedProvider = savedProvider; + } + + const savedPath = localStorage.getItem("agent_project_path"); + if (savedPath) { + elements.projectPath.value = savedPath; + } + + const savedPlatform = localStorage.getItem("agent_platform"); + if (savedPlatform) { + elements.platform.value = savedPlatform; + } + + elements.currentModelLabel.textContent = getModelDisplayName(state.selectedModel, state.selectedProvider).toUpperCase(); + updateReasoningEffortDropdown(state.selectedModel, state.selectedProvider); +} + +// API and Stream Logic +async function apiRequest(path, options = {}) { + const base = elements.backendUrl.value.trim().replace(/\/$/, ""); + const response = await fetch(`${base}${path}`, { + ...options, + headers: { + "Content-Type": "application/json", + ...(options.headers || {}), + }, + }); + + let payload; + try { + payload = await response.json(); + } catch (parseError) { + payload = { detail: "Backend returned non-JSON response" }; + } + + if (!response.ok) { + throw new Error(payload.detail || `HTTP ${response.status}`); + } + + return payload; +} + +function abortActiveStream() { + if (state.abortController) { + state.abortController.abort(); + state.abortController = null; + } +} + +const eventQueue = []; +let isProcessingQueue = false; + +function queueStreamEvent(event) { + eventQueue.push(event); + processEventQueue(); +} + +async function processEventQueue() { + if (isProcessingQueue) return; + isProcessingQueue = true; + + while (eventQueue.length > 0) { + const event = eventQueue.shift(); + await handleStreamEvent(event); + + if (eventQueue.length > 0) { + await new Promise(r => setTimeout(r, 2000)); + } + } + + isProcessingQueue = false; +} + +async function streamRequest(path, payload) { + const base = elements.backendUrl.value.trim().replace(/\/$/, ""); + const controller = new AbortController(); + state.abortController = controller; + + const response = await fetch(`${base}${path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + + if (!response.ok) { + const text = await response.text(); + let detail = text; + try { + const err = JSON.parse(text); + detail = err.detail || text; + } catch(e) {} + throw new Error(detail || `HTTP ${response.status}`); + } + + const headerThread = response.headers.get("X-Thread-Id"); + const headerRun = response.headers.get("X-Run-Id"); + if (headerThread) setThreadId(headerThread); + if (headerRun) setRunId(headerRun); + + if (!response.body) { + throw new Error("Streaming response body unavailable."); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + try { + const event = JSON.parse(trimmed); + queueStreamEvent(event); + } catch (error) { + console.warn("Could not parse NDJSON line:", trimmed); + } + } + } + + const finalLine = buffer.trim(); + if (finalLine) { + try { + queueStreamEvent(JSON.parse(finalLine)); + } catch (e) { + console.warn("Could not parse final NDJSON line:", finalLine); + } + } +} + +// Conversation log extraction for multi-turn history +function getHistory() { + const history = []; + const messages = elements.chatLog.querySelectorAll(".message.user, .message.agent"); + messages.forEach(msg => { + const role = msg.classList.contains("user") ? "user" : "assistant"; + const bubble = msg.querySelector(".bubble"); + if (bubble) { + const txt = bubble.textContent.trim(); + if (txt) { + history.push({ role, content: txt }); + } + } + }); + return history; +} + +function getLLMConfig() { + const modelId = state.selectedModel; + const providerId = state.selectedProvider; + + const config = { + temperature: 0, + reasoning_effort: elements.reasoningEffort.value || "none" + }; + + // Custom model override + if (providerId === "openai_compatible" && state.customModels[modelId]) { + const custom = state.customModels[modelId]; + config.provider = "openai_compatible"; + config.model = custom.model; + config.base_url = custom.baseUrl; + config.api_key = custom.apiKey || "temp"; + return config; + } + + if (providerId === "openai") { + config.provider = "openai"; + config.model = modelId; + config.api_key = state.apiKeys["openai"] || ""; + } else if (providerId === "anthropic") { + config.provider = "anthropic"; + config.model = modelId; + config.api_key = state.apiKeys["anthropic"] || ""; + } else if (providerId === "google") { + config.provider = "gemini"; + config.model = modelId; + config.api_key = state.apiKeys["google"] || ""; + } else if (providerId === "groq") { + config.provider = "groq"; + config.model = modelId; + config.api_key = state.apiKeys["groq"] || ""; + } else { + config.provider = "openai_compatible"; + config.model = modelId; + config.api_key = state.apiKeys[providerId] || "temp"; + + let baseUrl = state.providerUrls[providerId] || ""; + if (baseUrl && !baseUrl.includes("/v1") && !baseUrl.includes("/v1/")) { + baseUrl = baseUrl.replace(/\/$/, "") + "/v1"; + } + config.base_url = baseUrl; + } + + return config; +} + +async function runInitialGeneration() { + const projectPath = elements.projectPath.value.trim(); + const prompt = elements.promptInput.value.trim(); + + if (!projectPath) { + addErrorMessage("Project path is required in the sidebar."); + return; + } + if (!prompt) { + addErrorMessage("Please enter a prompt instruction."); + return; + } + + const provider = state.selectedProvider; + if (provider !== "openai_compatible" && !state.customModels[state.selectedModel]) { + if (!state.apiKeys[provider]) { + openApiKeyModal(provider, getProviderName(provider)); + return; + } + } + + localStorage.setItem("agent_project_path", projectPath); + localStorage.setItem("agent_platform", elements.platform.value); + + abortActiveStream(); + setBusy(true); + hideWelcome(); + + addMessage("user", prompt); + elements.promptInput.value = ""; + + const llmConfig = getLLMConfig(); + const history = getHistory(); + if (history.length > 0 && history[history.length - 1].content === prompt) { + history.pop(); + } + + if (!state.threadId) { + state.threadId = "thread_" + Math.random().toString(36).substring(2, 15); + setThreadId(state.threadId); + } + + setRunState("running"); + + try { + await streamRequest("/generate/stream", { + project_path: projectPath, + prompt: prompt, + target_platform: elements.platform.value, + thread_id: state.threadId, + history: history, + llm: llmConfig, + }); + } catch (error) { + if (error && error.name === "AbortError") return; + setRunState("failed"); + addErrorMessage(`Request error: ${error.message}`); + setBusy(false); + } +} + +async function resumePermission(decision, requestId) { + abortActiveStream(); + setBusy(true); + setRunState("resuming"); + + try { + await streamRequest(`/generate/${encodeURIComponent(state.threadId)}/resume/permission`, { + decision: decision, + request_id: requestId, + llm: getLLMConfig() + }); + } catch (error) { + if (error && error.name === "AbortError") return; + setRunState("failed"); + addErrorMessage(`Resume error: ${error.message}`); + setBusy(false); + } +} + +async function resumeClarification(answer) { + abortActiveStream(); + setBusy(true); + setRunState("resuming"); + + try { + await streamRequest(`/generate/${encodeURIComponent(state.threadId)}/resume/clarification`, { + answer: answer, + llm: getLLMConfig() + }); + } catch (error) { + if (error && error.name === "AbortError") return; + setRunState("failed"); + addErrorMessage(`Resume error: ${error.message}`); + setBusy(false); + } +} + +async function cancelRun() { + if (!state.runId) { + addSystemMessage("No active run to cancel."); + return; + } + + const previousState = state.runState; + setRunState("cancelling"); + + try { + await apiRequest(`/generate/runs/${encodeURIComponent(state.runId)}/cancel`, { + method: "POST" + }); + addSystemMessage("Cancel requested."); + } catch (error) { + addErrorMessage(`Cancel error: ${error.message}`); + setRunState(previousState); + } +} + +// In-memory sessionStorage caching +function saveChatSession() { + if (state.threadId) { + sessionStorage.setItem("current_thread_id", state.threadId); + sessionStorage.setItem("chat_history_" + state.threadId, elements.chatLog.innerHTML); + sessionStorage.setItem("chat_state_" + state.threadId, JSON.stringify({ + runId: state.runId, + runState: state.runState, + selectedModel: state.selectedModel, + selectedProvider: state.selectedProvider + })); + } +} + +function restoreChatSession() { + const currentThreadId = sessionStorage.getItem("current_thread_id"); + if (currentThreadId) { + state.threadId = currentThreadId; + elements.threadId.textContent = currentThreadId; + + const historyHtml = sessionStorage.getItem("chat_history_" + currentThreadId); + if (historyHtml) { + elements.chatLog.innerHTML = historyHtml; + rebindRestoredElements(); + hideWelcome(); + } + + const stateData = sessionStorage.getItem("chat_state_" + currentThreadId); + if (stateData) { + try { + const parsed = JSON.parse(stateData); + if (parsed.runId) setRunId(parsed.runId); + if (parsed.runState) setRunState(parsed.runState); + if (parsed.selectedModel && parsed.selectedProvider) { + selectModel(parsed.selectedModel, parsed.selectedProvider); + } + } catch (e) { + console.error("Failed to restore session state", e); + } + } + } +} + +function rebindRestoredElements() { + // Re-bind collapsible thinking process cards + elements.chatLog.querySelectorAll(".thinking-block").forEach(block => { + const header = block.querySelector(".thinking-header"); + const chevron = block.querySelector(".chevron"); + if (header && chevron) { + header.addEventListener("click", () => { + block.classList.toggle("collapsed"); + chevron.textContent = block.classList.contains("collapsed") ? "[+]" : "[-]"; + }); + } + }); + + // Re-bind collapsible tool execution cards + elements.chatLog.querySelectorAll(".tool-card").forEach(card => { + const header = card.querySelector(".tool-card-header"); + const chevron = card.querySelector(".tool-toggle-icon"); + if (header && chevron) { + header.addEventListener("click", () => { + card.classList.toggle("collapsed"); + chevron.textContent = card.classList.contains("collapsed") ? "[+]" : "[-]"; + }); + } + }); +} + +// Event Stream Handlers +async function handleStreamEvent(event) { + if (!event || typeof event !== "object") return; + + const type = event.type || ""; + const data = event.data && typeof event.data === "object" ? event.data : {}; + + // Release active thinking block when moving to a different event type + // so we don't append to it anymore, but leave it spread/open. + if (type !== "assistant.thinking" && state.activeThinkingBlock) { + state.activeThinkingBlock = null; + } + + switch (type) { + case "run.started": + setRunId(data.run_id); + setThreadId(data.thread_id); + setRunState("running"); + hideWelcome(); + addSystemMessage(`Run started (Run ID: ${data.run_id})`); + break; + + case "title.generated": + if (data.title) { + elements.sessionTitle.textContent = data.title.toUpperCase(); + elements.sessionSubtitle.textContent = "ACTIVE WORKSPACE SESSION"; + } + break; + + case "yaml.draft": + handleYamlDraft(data); + break; + + case "assistant.thinking": + await handleThinkingEvent(data); + break; + + case "assistant.update": + await handleAssistantUpdate(data); + break; + + case "plan.created": + handlePlanCreated(data); + break; + + case "tool.started": + handleToolStarted(data); + break; + + case "tool.completed": + handleToolCompleted(data); + break; + + case "tool.failed": + handleToolFailed(data); + break; + + case "tool.interrupted": + handleToolInterrupted(data); + break; + + case "permission.required": + setRunState("interrupted"); + handlePermissionRequired(data); + break; + + case "clarification.required": + setRunState("interrupted"); + handleClarificationRequired(data); + break; + + case "validation.result": + handleValidationResult(data); + break; + + case "llm.error": + addErrorMessage(`LLM ERROR: ${data.message || "Unknown error"}`); + break; + + case "run.completed": + setRunState("completed"); + addRunCompletedMessage(data); + setBusy(false); + break; + + case "run.failed": + setRunState("failed"); + addRunFailedMessage(data); + setBusy(false); + break; + + case "run.cancelled": + setRunState("cancelled"); + addSystemMessage("Run cancelled by user."); + setBusy(false); + break; + + default: + console.log("Unhandled event type:", type, data); + } + + saveChatSession(); +} + +// Detail handlers +async function handleThinkingEvent(data) { + const text = data.message || ""; + if (!text) return; + + if (state.activeThinkingBlock) { + const body = state.activeThinkingBlock.querySelector(".thinking-body"); + await typeText(body, text, true, true); + } else { + const block = document.createElement("div"); + block.className = "thinking-block"; + block.innerHTML = ` +
+ [THINKING PROCESS] + [-] +
+
+ `; + + const header = block.querySelector(".thinking-header"); + const chevron = block.querySelector(".chevron"); + + header.addEventListener("click", () => { + block.classList.toggle("collapsed"); + chevron.textContent = block.classList.contains("collapsed") ? "[+]" : "[-]"; + }); + + elements.chatLog.appendChild(block); + elements.chatLog.scrollTop = elements.chatLog.scrollHeight; + state.activeThinkingBlock = block; + + const body = block.querySelector(".thinking-body"); + await typeText(body, text, true, true); + } +} + +async function handleAssistantUpdate(data) { + const message = data.message || ""; + if (!message) return; + await addMessage("agent", message); +} + +let toolCounter = 0; +function handleToolStarted(data) { + toolCounter++; + const toolId = data.run_id || data.id || `tool-${toolCounter}`; + const toolName = data.tool || data.name || "tool"; + const label = data.label || `RUNNING ${toolName.toUpperCase()}`; + const args = data.args || {}; + + const card = document.createElement("div"); + card.className = "tool-card running"; + card.id = toolId; + card.dataset.toolName = toolName; + card.innerHTML = ` +
+ + ${label} + [-] +
+
+
+ ARGUMENTS: +
${JSON.stringify(args, null, 2)}
+
+ +
+ `; + + const header = card.querySelector(".tool-card-header"); + const chevron = card.querySelector(".tool-toggle-icon"); + + header.addEventListener("click", () => { + card.classList.toggle("collapsed"); + chevron.textContent = card.classList.contains("collapsed") ? "[+]" : "[-]"; + }); + + elements.chatLog.appendChild(card); + elements.chatLog.scrollTop = elements.chatLog.scrollHeight; +} + +function findMatchingToolCard(data) { + if (data.run_id && document.getElementById(data.run_id)) { + return document.getElementById(data.run_id); + } + if (data.id && document.getElementById(data.id)) { + return document.getElementById(data.id); + } + + const toolName = data.tool || data.name || "tool"; + const runningCards = elements.chatLog.querySelectorAll(`.tool-card.running[data-tool-name="${toolName}"]`); + if (runningCards.length > 0) { + return runningCards[0]; + } + + return elements.chatLog.querySelector(".tool-card.running"); +} + +function handleToolCompleted(data) { + const card = findMatchingToolCard(data); + if (!card) return; + + card.classList.remove("running"); + card.classList.add("completed"); + + const statusIcon = card.querySelector(".tool-status-icon"); + statusIcon.innerHTML = `[OK]`; + + const duration = data.duration_ms ? ` (${(data.duration_ms / 1000).toFixed(2)}s)` : ""; + const title = card.querySelector(".tool-title"); + title.textContent += ` COMPLETED${duration}`; + + const outputContainer = card.querySelector(".tool-output-container"); + const outputText = card.querySelector(".tool-output-text"); + + let resultStr = ""; + if (data.result !== undefined) { + if (typeof data.result === "object") { + resultStr = JSON.stringify(data.result, null, 2); + } else { + resultStr = String(data.result); + } + } + + if (resultStr) { + outputText.textContent = resultStr; + outputContainer.classList.remove("hidden"); + } + + card.classList.add("collapsed"); + const chevron = card.querySelector(".tool-toggle-icon"); + if (chevron) chevron.textContent = "[+]"; + + elements.chatLog.scrollTop = elements.chatLog.scrollHeight; +} + +function handleToolFailed(data) { + const card = findMatchingToolCard(data); + if (!card) return; + + card.classList.remove("running"); + card.classList.add("failed"); + + const statusIcon = card.querySelector(".tool-status-icon"); + statusIcon.innerHTML = `[FAILED]`; + + const duration = data.duration_ms ? ` (${(data.duration_ms / 1000).toFixed(2)}s)` : ""; + const title = card.querySelector(".tool-title"); + title.textContent += ` FAILED${duration}`; + + const outputContainer = card.querySelector(".tool-output-container"); + const outputText = card.querySelector(".tool-output-text"); + + outputText.textContent = data.error || "Unknown error"; + outputContainer.classList.remove("hidden"); + + card.classList.remove("collapsed"); + const chevron = card.querySelector(".tool-toggle-icon"); + if (chevron) chevron.textContent = "[-]"; + + elements.chatLog.scrollTop = elements.chatLog.scrollHeight; +} + +function handleToolInterrupted(data) { + const card = findMatchingToolCard(data); + if (!card) return; + + card.classList.remove("running"); + card.classList.add("interrupted"); + + const statusIcon = card.querySelector(".tool-status-icon"); + statusIcon.innerHTML = `[PAUSED]`; + + const title = card.querySelector(".tool-title"); + title.textContent += " INTERRUPTED"; + + const outputContainer = card.querySelector(".tool-output-container"); + const outputText = card.querySelector(".tool-output-text"); + + outputText.textContent = "Execution paused. Awaiting user response..."; + outputContainer.classList.remove("hidden"); + + elements.chatLog.scrollTop = elements.chatLog.scrollHeight; +} + +function handlePermissionRequired(data) { + const requestId = data.request_id; + const command = data.command || ""; + const message = data.message || "Permission required to proceed"; + const source = data.source || ""; + + const card = document.createElement("div"); + card.className = "hitl-card"; + card.innerHTML = ` +
+ [PERMISSION REQUIRED] (${source.toUpperCase()}) +
+
+

${message}

+
${command}
+
+ + `; + + const allowBtn = card.querySelector(".hitl-allow-btn"); + const denyBtn = card.querySelector(".hitl-deny-btn"); + + const respond = async (decision) => { + allowBtn.disabled = true; + denyBtn.disabled = true; + + const statusText = document.createElement("div"); + statusText.style.fontSize = "0.75rem"; + statusText.style.fontWeight = "700"; + statusText.style.color = decision === "allow" ? "var(--success)" : "var(--danger)"; + statusText.textContent = `DECISION: ${decision.toUpperCase()}`; + + card.querySelector(".hitl-card-footer").replaceWith(statusText); + + await resumePermission(decision, requestId); + }; + + allowBtn.addEventListener("click", () => respond("allow")); + denyBtn.addEventListener("click", () => respond("deny")); + + elements.chatLog.appendChild(card); + elements.chatLog.scrollTop = elements.chatLog.scrollHeight; + + setBusy(false); +} + +function handleClarificationRequired(data) { + const question = data.question || "Clarification needed"; + const source = data.source || ""; + + const card = document.createElement("div"); + card.className = "hitl-card"; + card.innerHTML = ` +
+ [CLARIFICATION REQUIRED] (${source.toUpperCase()}) +
+
+

${question}

+ +
+ + `; + + const textarea = card.querySelector(".hitl-textarea"); + const submitBtn = card.querySelector(".hitl-submit-btn"); + + const respond = async () => { + const answer = textarea.value.trim(); + if (!answer) return; + + textarea.disabled = true; + submitBtn.disabled = true; + + const answerDisplay = document.createElement("div"); + answerDisplay.style.fontSize = "0.75rem"; + answerDisplay.style.marginTop = "0.5rem"; + answerDisplay.style.color = "var(--text-secondary)"; + answerDisplay.innerHTML = `ANSWER: ${answer}`; + + card.querySelector(".hitl-card-body").appendChild(answerDisplay); + card.querySelector(".hitl-card-footer").remove(); + + await resumeClarification(answer); + }; + + submitBtn.addEventListener("click", respond); + textarea.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + respond(); + } + }); + + elements.chatLog.appendChild(card); + elements.chatLog.scrollTop = elements.chatLog.scrollHeight; + textarea.focus(); + + setBusy(false); +} + +function handlePlanCreated(data) { + const steps = data.steps || []; + const filepath = data.planned_filepath || ""; + if (steps.length === 0) return; + + const card = document.createElement("div"); + card.className = "validation-card passed"; + + const stepsHtml = steps.map((step, idx) => `
  • STEP ${idx + 1}: ${step}
  • `).join(""); + + card.innerHTML = ` +
    + [PIPELINE EXECUTION PLAN] +
    +
    + ${filepath ? `

    TARGET FILE: ${filepath}

    ` : ""} +
      + ${stepsHtml} +
    +
    + `; + + elements.chatLog.appendChild(card); + elements.chatLog.scrollTop = elements.chatLog.scrollHeight; +} + +function handleYamlDraft(data) { + const content = data.content || ""; + const filepath = data.filepath || "draft.yml"; + + const card = document.createElement("div"); + card.className = "tool-card completed"; + card.innerHTML = ` +
    + [DRAFT] + PREVIEW: ${filepath} + [-] +
    +
    +
    + FILE CONTENT: +
    ${content.replace(//g, ">")}
    +
    +
    + `; + + const header = card.querySelector(".tool-card-header"); + const chevron = card.querySelector(".tool-toggle-icon"); + + header.addEventListener("click", () => { + card.classList.toggle("collapsed"); + chevron.textContent = card.classList.contains("collapsed") ? "[+]" : "[-]"; + }); + + elements.chatLog.appendChild(card); + elements.chatLog.scrollTop = elements.chatLog.scrollHeight; +} + +function handleValidationResult(data) { + const passed = data.passed === true; + const errors = data.errors || []; + const warnings = data.warnings || []; + const retryCount = data.retry_count || 0; + + const card = document.createElement("div"); + card.className = `validation-card ${passed ? "passed" : "failed"}`; + + let detailsHtml = ""; + if (errors.length > 0) { + detailsHtml += `
    ERRORS:
    `; + } + if (warnings.length > 0) { + detailsHtml += `
    WARNINGS:
    `; + } + if (retryCount > 0) { + detailsHtml += `
    RETRY COUNT: ${retryCount}
    `; + } + + card.innerHTML = ` +
    + ${passed ? "VALIDATION PASSED" : "VALIDATION FAILED"} +
    + ${detailsHtml ? `
    ${detailsHtml}
    ` : ""} + `; + + elements.chatLog.appendChild(card); + elements.chatLog.scrollTop = elements.chatLog.scrollHeight; +} + +function addRunCompletedMessage(data) { + const outputPath = data.output_path || ""; + const card = document.createElement("div"); + card.className = "validation-card passed"; + + card.innerHTML = ` +
    + [SUCCESS] PIPELINE GENERATED SUCCESSFULLY +
    +
    +

    The agent has written the CI/CD pipeline code successfully.

    + ${outputPath ? `

    FILE LOCATION: ${outputPath}

    ` : ""} +
    + `; + + elements.chatLog.appendChild(card); + elements.chatLog.scrollTop = elements.chatLog.scrollHeight; +} + +function addRunFailedMessage(data) { + const message = data.message || "An unexpected error occurred during execution."; + const detail = data.detail || ""; + + const card = document.createElement("div"); + card.className = "validation-card failed"; + + card.innerHTML = ` +
    + [FAILED] GENERATION ERROR +
    +
    +

    ERROR: ${message}

    + ${detail ? `

    DETAILS: ${detail}

    ` : ""} +
    + `; + + elements.chatLog.appendChild(card); + elements.chatLog.scrollTop = elements.chatLog.scrollHeight; +} + +// Helpers +async function typeText(bubble, text, append = false, isFast = false) { + const charDelay = isFast ? 1 : 6; + const chunkSize = isFast ? 5 : 1; + let currentText = append ? bubble.textContent : ""; + bubble.classList.add("typing"); + for (let i = 0; i < text.length; i += chunkSize) { + currentText += text.substring(i, i + chunkSize); + bubble.textContent = currentText; + elements.chatLog.scrollTop = elements.chatLog.scrollHeight; + await new Promise(r => setTimeout(r, charDelay)); + } + bubble.classList.remove("typing"); + saveChatSession(); +} + +async function addMessage(role, text) { + hideWelcome(); + + const node = document.createElement("article"); + node.className = `message ${role}`; + node.innerHTML = ` +
    ${role.toUpperCase()}
    +
    + `; + const bubble = node.querySelector(".bubble"); + elements.chatLog.appendChild(node); + + if (role === "agent") { + await typeText(bubble, text); + } else { + bubble.textContent = text; + } + + elements.chatLog.scrollTop = elements.chatLog.scrollHeight; + saveChatSession(); + return node; +} + +// System logging +function addSystemMessage(text) { + const node = document.createElement("article"); + node.className = "message system"; + node.innerHTML = `
    ${text.toUpperCase()}
    `; + elements.chatLog.appendChild(node); + elements.chatLog.scrollTop = elements.chatLog.scrollHeight; + saveChatSession(); + return node; +} + +function addErrorMessage(text) { + const card = document.createElement("div"); + card.className = "validation-card failed"; + card.innerHTML = ` +
    [ERROR]
    +
    ${text.toUpperCase()}
    + `; + elements.chatLog.appendChild(card); + elements.chatLog.scrollTop = elements.chatLog.scrollHeight; + saveChatSession(); +} + +function hideWelcome() { + const welcome = elements.chatLog.querySelector(".welcome-card"); + if (welcome) welcome.remove(); +} + +function resetSession() { + eventQueue.length = 0; + abortActiveStream(); + if (state.threadId) { + sessionStorage.removeItem("chat_history_" + state.threadId); + sessionStorage.removeItem("chat_state_" + state.threadId); + } + sessionStorage.removeItem("current_thread_id"); + + setThreadId(null); + setRunId(null); + setRunState("idle"); + + elements.chatLog.innerHTML = ` +
    +

    READY TO GENERATE PIPELINE

    +

    Configure the target project directory in the sidebar, select a model below, and input generation instructions.

    +
    +
    Create a GitHub actions workflow to build Node.js and run jest tests
    +
    Create CI to run python linting and pytest, then deploy to AWS on main branch
    +
    +
    + `; + + setupExampleChips(); + elements.sessionTitle.textContent = "PIPELINE WORKSPACE"; + elements.sessionSubtitle.textContent = "Awaiting run requests..."; + setBusy(false); +} + +function setupExampleChips() { + elements.chatLog.querySelectorAll(".example-chip").forEach(chip => { + chip.addEventListener("click", () => { + elements.promptInput.value = chip.textContent.trim(); + elements.promptInput.focus(); + }); + }); +} + +function applyBackendUrlFromQuery() { + const params = new URLSearchParams(window.location.search); + const backendFromQuery = (params.get("backend") || "").trim(); + if (backendFromQuery) { + try { + const parsed = new URL(backendFromQuery); + if (parsed.protocol === "http:" || parsed.protocol === "https:") { + elements.backendUrl.value = backendFromQuery.replace(/\/$/, ""); + } + } catch (e) { + console.warn("Ignoring invalid backend URL from query:", backendFromQuery); + } + } +} + +// Event bindings +elements.startBtn.addEventListener("click", runInitialGeneration); +elements.newThreadBtn.addEventListener("click", resetSession); +elements.cancelBtn.addEventListener("click", cancelRun); + +elements.modelSelectBtn.addEventListener("click", toggleModelDropdown); +elements.modelSearchInput.addEventListener("input", (e) => { + renderModelList(e.target.value); +}); + +document.addEventListener("click", (e) => { + if (!elements.modelSelectBtn.contains(e.target) && !elements.modelDropdown.contains(e.target)) { + hideModelDropdown(); + } +}); + +elements.promptInput.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + if (!state.waiting) { + runInitialGeneration(); + } + } +}); + +// API Key Modal Listeners +elements.saveApiKeyBtn.addEventListener("click", () => { + if (currentEditingProviderId) { + const val = elements.apiKeyInput.value.trim(); + if (val) { + state.apiKeys[currentEditingProviderId] = val; + } else { + delete state.apiKeys[currentEditingProviderId]; + } + localStorage.setItem("agent_api_keys", JSON.stringify(state.apiKeys)); + renderProviderKeysList(); + closeApiKeyModal(); + } +}); + +elements.deleteApiKeyBtn.addEventListener("click", () => { + if (currentEditingProviderId) { + delete state.apiKeys[currentEditingProviderId]; + localStorage.setItem("agent_api_keys", JSON.stringify(state.apiKeys)); + renderProviderKeysList(); + closeApiKeyModal(); + } +}); + +// Close Modals on close buttons +document.querySelectorAll(".close-modal-btn").forEach(btn => { + btn.addEventListener("click", () => { + closeApiKeyModal(); + elements.customModelModal.classList.add("hidden"); + }); +}); + +// Close Modals on backdrop click +document.querySelectorAll(".modal-overlay").forEach(overlay => { + overlay.addEventListener("click", (e) => { + if (e.target === overlay) { + closeApiKeyModal(); + overlay.classList.add("hidden"); + } + }); +}); + +// Custom Model Button Trigger +elements.openaiCompatibleBtn.addEventListener("click", (e) => { + e.stopPropagation(); + hideModelDropdown(); + elements.customDisplayName.value = ""; + elements.customModelId.value = ""; + elements.customBaseUrl.value = ""; + elements.customApiKey.value = ""; + elements.customReasoning.checked = false; + elements.customModelModal.classList.remove("hidden"); +}); + +// Custom Model Save +elements.saveCustomModelBtn.addEventListener("click", () => { + const displayName = elements.customDisplayName.value.trim(); + const modelId = elements.customModelId.value.trim(); + const baseUrl = elements.customBaseUrl.value.trim(); + const apiKey = elements.customApiKey.value.trim(); + const reasoning = elements.customReasoning.checked; + + if (!displayName || !modelId || !baseUrl) { + alert("Display Name, Model ID and Base URL are required."); + return; + } + + state.customModels[modelId] = { + displayName, + model: modelId, + baseUrl, + apiKey, + reasoning + }; + + localStorage.setItem("agent_custom_models", JSON.stringify(state.customModels)); + elements.customModelModal.classList.add("hidden"); + processCatalog(state.modelsCatalog); // Re-render lists + selectModel(modelId, "openai_compatible"); +}); + +// Init +applyBackendUrlFromQuery(); +loadModelsCatalog(); +resetSession(); diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..388a690 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,220 @@ + + + + + + AutoPipeline Agent + + + + + + +
    + + + + +
    +
    +
    +

    PIPELINE WORKSPACE

    +

    Awaiting run requests...

    +
    +
    + + +
    +
    +

    READY TO GENERATE PIPELINE

    +

    Configure the target project directory in the sidebar, select a model below, and input generation instructions.

    +
    +
    Create a GitHub actions workflow to build Node.js and run jest tests
    +
    Create CI to run python linting and pytest, then deploy to AWS on main branch
    +
    +
    +
    + + +
    +
    + + +
    +
    + +
    + + + + +
    + + +
    + +
    +
    + +
    + + +
    +
    +
    +
    +
    +
    + + + + + + + + + + + + diff --git a/ui/run_demo.py b/ui/run_demo.py new file mode 100644 index 0000000..aa5ed52 --- /dev/null +++ b/ui/run_demo.py @@ -0,0 +1,175 @@ +"""Run backend API and static UI server for the demo in one command. + +Usage: + python ui/run_demo.py + python ui/run_demo.py --backend-port 8000 --ui-port 5173 +""" + +from __future__ import annotations + +import argparse +import os +import signal +import subprocess +import sys +import threading +import time +from pathlib import Path +from urllib.error import URLError +from urllib.parse import quote +from urllib.request import urlopen + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Start agent backend and demo UI servers") + parser.add_argument("--host", default="127.0.0.1", help="Host for both servers") + parser.add_argument("--backend-port", type=int, default=8000, help="Backend API port") + parser.add_argument("--ui-port", type=int, default=5173, help="UI static server port") + parser.add_argument("--no-open", action="store_true", help="Do not auto-open browser") + return parser.parse_args() + + +def wait_for_health(url: str, timeout_seconds: float = 45.0) -> bool: + deadline = time.time() + timeout_seconds + while time.time() < deadline: + try: + with urlopen(url, timeout=2) as response: + if response.status == 200: + return True + except URLError: + pass + time.sleep(0.5) + return False + + +def terminate_process(proc: subprocess.Popen[bytes] | None) -> None: + if not proc or proc.poll() is not None: + return + + try: + if os.name == "nt": + proc.send_signal(signal.CTRL_BREAK_EVENT) + else: + proc.terminate() + proc.wait(timeout=5) + except Exception: + proc.kill() + + +def open_browser_async(url: str) -> None: + """Try to open the UI URL without blocking demo startup.""" + + def _open() -> None: + try: + import webbrowser + + opened = webbrowser.open(url, new=2) + if not opened: + print(f"Could not auto-open browser. Open manually: {url}") + except Exception as exc: + print(f"Could not auto-open browser ({exc}). Open manually: {url}") + + thread = threading.Thread(target=_open, name="browser-open", daemon=True) + thread.start() + + +def main() -> int: + args = parse_args() + + repo_root = Path(__file__).resolve().parents[1] + ui_dir = repo_root / "ui" + + backend_env = os.environ.copy() + src_path = str(repo_root / "src") + existing_pythonpath = backend_env.get("PYTHONPATH", "") + backend_env["PYTHONPATH"] = src_path if not existing_pythonpath else f"{src_path}{os.pathsep}{existing_pythonpath}" + + backend_cmd = [ + sys.executable, + "-m", + "uvicorn", + "agent.api.api:app", + "--host", + args.host, + "--port", + str(args.backend_port), + ] + + ui_cmd = [ + sys.executable, + "-m", + "http.server", + str(args.ui_port), + "--bind", + args.host, + "--directory", + str(ui_dir), + ] + + creationflags = subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0 + + health_url = f"http://{args.host}:{args.backend_port}/health" + backend_proc: subprocess.Popen[bytes] | None = None + backend_owned = False + + if wait_for_health(health_url, timeout_seconds=1.5): + print("Using existing backend API...") + else: + print("Starting backend API...") + backend_proc = subprocess.Popen( + backend_cmd, + cwd=str(repo_root), + env=backend_env, + creationflags=creationflags, + ) + backend_owned = True + + if not wait_for_health(health_url): + print("Backend did not become healthy in time. Stopping.") + if backend_owned: + terminate_process(backend_proc) + return 1 + + print("Starting UI server...") + ui_proc = subprocess.Popen( + ui_cmd, + cwd=str(repo_root), + creationflags=creationflags, + ) + + backend_base = f"http://{args.host}:{args.backend_port}" + ui_url = f"http://{args.host}:{args.ui_port}/?backend={quote(backend_base, safe='')}" + print("") + print("Demo is running") + print(f"Backend: {health_url}") + print(f"UI: {ui_url}") + print("Press Ctrl+C to stop both servers.") + + if not args.no_open: + open_browser_async(ui_url) + + try: + while True: + backend_code = backend_proc.poll() if backend_owned and backend_proc else None + ui_code = ui_proc.poll() + + if backend_code is not None: + print(f"Backend exited with code {backend_code}. Stopping UI.") + break + if ui_code is not None: + print(f"UI server exited with code {ui_code}. Stopping backend.") + break + + time.sleep(0.5) + except KeyboardInterrupt: + print("\nStopping demo servers...") + finally: + terminate_process(ui_proc) + if backend_owned: + terminate_process(backend_proc) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ui/styles.css b/ui/styles.css new file mode 100644 index 0000000..35837dd --- /dev/null +++ b/ui/styles.css @@ -0,0 +1,1031 @@ +:root { + --bg-dark: #000000; + --bg-sidebar: #0a0a0a; + --bg-chat: #000000; + --bg-card: #0c0c0c; + --bg-card-hover: #141414; + + --border-color: #262626; + --border-focus: #ffffff; + + --text-primary: #ffffff; + --text-secondary: #8a8a8a; + --text-muted: #555555; + + --success: #00ff66; + --success-bg: rgba(0, 255, 102, 0.05); + --success-border: #00ff66; + + --warning: #ffcc00; + --warning-bg: rgba(255, 204, 0, 0.05); + --warning-border: #ffcc00; + + --danger: #ff3333; + --danger-bg: rgba(255, 51, 51, 0.05); + --danger-border: #ff3333; + + --font-mono: "IBM Plex Mono", Consolas, Monaco, monospace; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; + border-radius: 0 !important; /* Force no rounded corners everywhere */ +} + +body { + margin: 0; + font-family: var(--font-mono); + background-color: var(--bg-dark); + color: var(--text-primary); + min-height: 100vh; + overflow-x: hidden; +} + +/* App Shell Structure */ +.app-shell { + display: grid; + grid-template-columns: 260px 1fr; /* Compacted from 320px */ + height: 100vh; + width: 100vw; + overflow: hidden; +} + +/* Sidebar / Control Panel */ +.control-panel { + background: var(--bg-sidebar); + border-right: 1px solid var(--border-color); + display: flex; + flex-direction: column; + height: 100vh; + max-height: 100vh; + overflow: hidden; +} + +.sidebar-header { + padding: 1rem; /* Compacted from 1.5rem */ + border-bottom: 1px solid var(--border-color); +} + +.logo-text h2 { + font-size: 0.95rem; /* Compacted from 1.1rem */ + font-weight: 700; + letter-spacing: 0.05em; + color: var(--text-primary); +} + +.logo-sub { + font-size: 0.65rem; /* Compacted from 0.7rem */ + color: var(--text-secondary); + letter-spacing: 0.1em; +} + +.sidebar-scrollable { + flex: 1; + overflow-y: auto; + min-height: 0; + padding: 1rem 1rem 1.5rem 1rem; + display: flex; + flex-direction: column; + gap: 0.9rem; +} + +/* Scrollbars */ +::-webkit-scrollbar { + width: 6px; /* Compacted from 8px */ + height: 6px; +} + +::-webkit-scrollbar-track { + background: #000000; +} + +::-webkit-scrollbar-thumb { + background: #262626; + border: 1px solid #000000; +} + +::-webkit-scrollbar-thumb:hover { + background: #404040; +} + +/* Form Groups */ +.settings-group { + display: flex; + flex-direction: column; + gap: 0.45rem; /* Compacted from 0.75rem */ +} + +.settings-group h3 { + font-size: 0.7rem; /* Compacted from 0.75rem */ + letter-spacing: 0.1em; + color: var(--text-secondary); + font-weight: 600; + border-left: 2px solid #ffffff; + padding-left: 0.4rem; +} + +.section-desc { + font-size: 0.65rem; + color: var(--text-muted); + line-height: 1.3; +} + +.input-wrapper { + display: flex; + flex-direction: column; + gap: 0.2rem; /* Compacted from 0.35rem */ +} + +.input-wrapper label { + font-size: 0.65rem; /* Compacted from 0.7rem */ + color: var(--text-secondary); + font-weight: 500; +} + +input[type="text"], +input[type="password"], +select, +textarea { + width: 100%; + background: #000000; + border: 1px solid var(--border-color); + padding: 0.45rem 0.5rem; /* Compacted from 0.6rem */ + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 0.75rem; /* Compacted from 0.8rem */ + outline: none; + transition: border-color 0.1s; +} + +input[type="text"]:focus, +input[type="password"]:focus, +select:focus, +textarea:focus { + border-color: var(--border-focus); +} + +/* Provider List */ +.provider-keys-list { + display: flex; + flex-direction: column; + gap: 0.35rem; /* Compacted from 0.4rem */ +} + +.provider-key-item { + display: flex; + justify-content: space-between; + align-items: center; + background: #000000; + border: 1px solid var(--border-color); + padding: 0.4rem 0.5rem; /* Compacted from 0.5rem */ + font-size: 0.7rem; /* Compacted from 0.75rem */ + cursor: pointer; + transition: background 0.1s; +} + +.provider-key-item:hover { + background: var(--bg-card-hover); + border-color: #404040; +} + +.provider-key-item .provider-name { + font-weight: 600; +} + +.provider-key-item .key-status { + display: flex; + align-items: center; + gap: 0.3rem; +} + +.dot { + width: 4px; /* Compacted from 5px */ + height: 4px; + display: inline-block; +} + +.dot-set { + background-color: var(--success); +} + +.dot-unset { + background-color: var(--text-muted); +} + +/* Status Panel */ +.status-meta { + background: #000000; + border: 1px solid var(--border-color); + padding: 0.5rem 0.6rem; /* Compacted from 0.75rem */ + display: flex; + flex-direction: column; + gap: 0.35rem; /* Compacted from 0.5rem */ +} + +.status-row { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.7rem; /* Compacted from 0.75rem */ +} + +.status-row .label { + color: var(--text-secondary); +} + +.status-row .val { + font-weight: 500; +} + +.code-font { + font-size: 0.7rem; + color: var(--text-primary); +} + +.badge { + padding: 0.1rem 0.35rem; /* Compacted from 0.15rem 0.4rem */ + font-size: 0.65rem; /* Compacted from 0.7rem */ + font-weight: 600; + border: 1px solid var(--border-color); +} + +.badge-idle { color: var(--text-secondary); border-color: var(--text-secondary); } +.badge-running { color: #ffffff; border-color: #ffffff; } +.badge-resuming { color: #ffffff; border-color: #ffffff; } +.badge-interrupted { color: var(--warning); border-color: var(--warning); } +.badge-completed { color: var(--success); border-color: var(--success); } +.badge-failed { color: var(--danger); border-color: var(--danger); } +.badge-cancelled { color: var(--text-muted); border-color: var(--text-muted); } + +/* Chat Panel */ +.chat-panel { + background: var(--bg-chat); + display: grid; + grid-template-rows: 46px 1fr auto; /* Compacted from 60px */ + height: 100vh; + position: relative; +} + +.chat-header { + border-bottom: 1px solid var(--border-color); + padding: 0 1rem; /* Compacted from 1.5rem */ + display: flex; + align-items: center; +} + +.chat-header-info h2 { + font-size: 0.85rem; /* Compacted from 0.95rem */ + font-weight: 700; + letter-spacing: 0.05em; +} + +.chat-header-info p { + font-size: 0.65rem; /* Compacted from 0.7rem */ + color: var(--text-secondary); +} + +/* Chat feed log */ +.chat-log { + padding: 1rem; /* Compacted from 1.5rem */ + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.75rem; /* Compacted from 1rem */ +} + +.welcome-card { + max-width: 600px; /* Compacted from 650px */ + margin: 1rem auto; /* Compacted from 2rem */ + background: var(--bg-card); + border: 1px solid var(--border-color); + padding: 1rem; /* Compacted from 1.5rem */ +} + +.welcome-card h3 { + font-size: 0.8rem; /* Compacted from 0.9rem */ + margin-bottom: 0.4rem; + font-weight: 700; +} + +.welcome-card p { + color: var(--text-secondary); + font-size: 0.75rem; /* Compacted from 0.8rem */ + line-height: 1.45; + margin-bottom: 0.75rem; +} + +.welcome-examples { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.example-chip { + background: #000000; + border: 1px solid var(--border-color); + padding: 0.4rem 0.6rem; /* Compacted from 0.5rem 0.75rem */ + font-size: 0.7rem; /* Compacted from 0.75rem */ + color: var(--text-secondary); + cursor: pointer; + transition: all 0.1s; +} + +.example-chip:hover { + border-color: #ffffff; + color: #ffffff; +} + +/* Message Bubbles */ +.message { + display: flex; + flex-direction: column; + max-width: 90%; +} + +.message.user { + align-self: flex-end; +} + +.message.agent { + align-self: flex-start; +} + +.message.system { + align-self: center; + max-width: 100%; +} + +.message .role { + font-size: 0.65rem; /* Compacted from 0.7rem */ + font-weight: 700; + color: var(--text-secondary); + margin-bottom: 0.2rem; +} + +.message.user .role { + align-self: flex-end; +} + +.message .bubble { + background: var(--bg-card); + border: 1px solid var(--border-color); + padding: 0.5rem 0.75rem; /* Compacted from 0.75rem 1rem */ + font-size: 0.75rem; /* Compacted from 0.8rem */ + line-height: 1.4; + white-space: pre-wrap; + word-break: break-word; +} + +.message.user .bubble { + background: #000000; + border-color: #ffffff; +} + +.message.system .bubble { + background: transparent; + border: none; + color: var(--text-muted); + font-size: 0.7rem; + padding: 0; + text-align: center; +} + +/* Thinking Blocks */ +.thinking-block { + background: #000000; + border: 1px solid var(--border-color); + margin: 0.3rem 0; /* Compacted from 0.5rem */ + width: 100%; + max-width: 800px; +} + +.thinking-header { + padding: 0.4rem 0.6rem; /* Compacted from 0.5rem 0.75rem */ + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.7rem; /* Compacted from 0.75rem */ + color: var(--text-secondary); + font-weight: 600; + cursor: pointer; + user-select: none; + background: #0a0a0a; +} + +.thinking-header:hover { + background: #141414; +} + +.thinking-header .chevron { + margin-left: auto; + font-size: 0.65rem; +} + +.thinking-body { + padding: 0.5rem 0.6rem; /* Compacted from 0.75rem */ + font-size: 0.7rem; /* Compacted from 0.75rem */ + color: var(--text-secondary); + white-space: pre-wrap; + border-top: 1px solid var(--border-color); + line-height: 1.35; +} + +.thinking-block.collapsed .thinking-body { + display: none; +} + +/* Tool execution card */ +.tool-card { + background: #000000; + border: 1px solid var(--border-color); + margin: 0.3rem 0; /* Compacted from 0.5rem */ + width: 100%; + max-width: 800px; +} + +.tool-card-header { + padding: 0.4rem 0.6rem; /* Compacted from 0.5rem 0.75rem */ + display: flex; + align-items: center; + gap: 0.4rem; + cursor: pointer; + user-select: none; +} + +.tool-card-header:hover { + background: #0a0a0a; +} + +.tool-status-icon { + display: flex; + align-items: center; + justify-content: center; + font-size: 0.7rem; + font-weight: 700; +} + +.tool-title { + font-size: 0.7rem; /* Compacted from 0.75rem */ + font-weight: 600; + color: var(--text-primary); + flex: 1; +} + +.tool-toggle-icon { + font-size: 0.65rem; + color: var(--text-secondary); +} + +.tool-card-body { + padding: 0.5rem 0.6rem; /* Compacted from 0.75rem */ + border-top: 1px solid var(--border-color); + display: flex; + flex-direction: column; + gap: 0.4rem; + font-size: 0.7rem; +} + +.tool-card.collapsed .tool-card-body { + display: none; +} + +.tool-meta pre { + margin-top: 0.2rem; + padding: 0.4rem; + background: #050505; + border: 1px solid var(--border-color); + font-family: var(--font-mono); + font-size: 0.7rem; /* Compacted from 0.75rem */ + overflow-x: auto; + color: var(--text-secondary); +} + +.tool-output-container pre { + margin-top: 0.2rem; + padding: 0.4rem; + background: #050505; + border: 1px solid var(--border-color); + font-family: var(--font-mono); + font-size: 0.7rem; /* Compacted from 0.75rem */ + max-height: 180px; /* Compacted from 250px */ + overflow: auto; + color: var(--text-primary); +} + +/* Tool execution status states */ +.tool-card.running { + border-color: var(--text-muted); +} + +.tool-card.running .tool-title { + color: var(--text-muted); +} + +.tool-card.completed { + border-color: var(--success-border); +} + +.tool-card.completed .tool-status-icon { + color: var(--success); +} + +.tool-card.failed { + border-color: var(--danger-border); +} + +.tool-card.failed .tool-status-icon { + color: var(--danger); +} + +.tool-card.failed .tool-output-container pre { + color: var(--danger); +} + +.tool-card.interrupted { + border-color: var(--warning-border); +} + +.tool-card.interrupted .tool-status-icon { + color: var(--warning); +} + +/* Square retro spinner */ +.spinner { + width: 9px; /* Compacted from 10px */ + height: 9px; + border: 2px solid #555555; + border-top-color: #ffffff; + animation: spin 0.8s linear infinite; + display: inline-block; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Interactive HITL Cards (Inline) */ +.hitl-card { + background: #0c0c0c; + border: 1px solid var(--warning-border); + padding: 0.75rem; /* Compacted from 1rem */ + width: 100%; + max-width: 600px; + margin: 0.5rem 0; /* Compacted from 0.75rem */ + align-self: flex-start; +} + +.hitl-card-header { + font-weight: 700; + font-size: 0.75rem; /* Compacted from 0.8rem */ + color: var(--warning); + margin-bottom: 0.4rem; +} + +.hitl-card-body { + font-size: 0.7rem; /* Compacted from 0.75rem */ + line-height: 1.35; + margin-bottom: 0.6rem; +} + +.hitl-command-block { + font-family: var(--font-mono); + font-size: 0.7rem; /* Compacted from 0.75rem */ + padding: 0.4rem; + background: #000000; + border: 1px solid var(--border-color); + margin-top: 0.3rem; + color: var(--warning); + overflow-x: auto; +} + +.hitl-card-footer { + display: flex; + gap: 0.4rem; + align-items: center; +} + +.hitl-textarea { + flex: 1; + background: #000000; + border: 1px solid var(--border-color); + padding: 0.35rem; + font-size: 0.7rem; /* Compacted from 0.75rem */ + resize: none; +} + +/* Validation Card */ +.validation-card { + background: #000000; + border: 1px solid var(--border-color); + margin: 0.3rem 0; /* Compacted from 0.5rem */ + width: 100%; + max-width: 800px; + padding: 0.5rem 0.6rem; /* Compacted from 0.75rem */ +} + +.validation-card.passed { border-color: var(--success-border); } +.validation-card.failed { border-color: var(--danger-border); } + +.validation-title { + font-weight: 700; + font-size: 0.7rem; /* Compacted from 0.75rem */ + margin-bottom: 0.35rem; +} + +.validation-card.passed .validation-title { color: var(--success); } +.validation-card.failed .validation-title { color: var(--danger); } + +.validation-details { + font-family: var(--font-mono); + font-size: 0.7rem; /* Compacted from 0.75rem */ + padding: 0.4rem; + background: #050505; + border: 1px solid var(--border-color); + overflow-x: auto; + white-space: pre-wrap; +} + +/* Prompt Box */ +.prompt-box-container { + padding: 0.5rem 1rem 0.75rem; /* Compacted from 1rem 1.5rem 1.5rem */ + border-top: 1px solid var(--border-color); + background: #000000; +} + +.prompt-input-card { + background: #000000; + border: 1px solid var(--border-color); + padding: 0.4rem; /* Compacted from 0.5rem */ + display: flex; + flex-direction: column; +} + +.prompt-input-card:focus-within { + border-color: #ffffff; +} + +.prompt-input-card textarea { + background: transparent; + border: none; + padding: 0.15rem; /* Compacted from 0.25rem */ + resize: none; + font-size: 0.75rem; /* Compacted from 0.8rem */ + color: var(--text-primary); + line-height: 1.35; +} + +.prompt-input-card textarea:focus { + border: none; + box-shadow: none; +} + +.prompt-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + border-top: 1px solid var(--border-color); + padding-top: 0.35rem; /* Compacted from 0.5rem */ + margin-top: 0.15rem; /* Compacted from 0.25rem */ +} + +.toolbar-left { + display: flex; + gap: 0.35rem; + align-items: center; +} + +.toolbar-right { + display: flex; + gap: 0.35rem; + align-items: center; +} + +/* Toolbar Controls */ +.toolbar-btn { + background: #000000; + border: 1px solid var(--border-color); + padding: 0.3rem 0.5rem; /* Compacted from 0.4rem 0.6rem */ + color: var(--text-secondary); + font-size: 0.7rem; /* Compacted from 0.75rem */ + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.25rem; +} + +.toolbar-btn:hover { + border-color: #ffffff; + color: #ffffff; +} + +.chevron-arrow { + font-size: 0.65rem; + color: var(--text-secondary); +} + +.toolbar-select { + background: #000000; + border: 1px solid var(--border-color); + padding: 0.3rem 0.5rem; /* Compacted from 0.4rem 0.6rem */ + color: var(--text-secondary); + font-size: 0.7rem; /* Compacted from 0.75rem */ + font-weight: 600; + width: auto; + cursor: pointer; +} + +.toolbar-select:hover { + border-color: #ffffff; + color: #ffffff; +} + +.toolbar-select:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +/* Button Classes */ +.btn { + font-family: var(--font-mono); + font-weight: 700; + font-size: 0.7rem; /* Compacted from 0.75rem */ + padding: 0.3rem 0.6rem; /* Compacted from 0.4rem 0.8rem */ + border: 1px solid var(--border-color); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + background: #000000; + color: #ffffff; + transition: all 0.1s; +} + +.primary-btn { + background: #ffffff; + color: #000000; + border-color: #ffffff; +} + +.primary-btn:hover:not(:disabled) { + background: #cccccc; +} + +.primary-btn:disabled { + background: #1c1c1c; + color: #555555; + border-color: #262626; + cursor: not-allowed; +} + +.danger-btn { + border-color: var(--danger-border); + color: var(--danger); +} + +.danger-btn:hover:not(:disabled) { + background: var(--danger); + color: #000000; +} + +.danger-btn:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.ghost-btn { + background: #000000; + border-color: var(--border-color); + color: var(--text-secondary); +} + +.ghost-btn:hover:not(:disabled) { + border-color: #ffffff; + color: #ffffff; +} + +.w-100 { width: 100%; } +.mt-sm { margin-top: 0.4rem; } +.flex-center { display: flex; align-items: center; justify-content: center; gap: 0.35rem; } + +/* Model Picker Wrapper and Dropdown */ +.model-picker-wrapper { + position: relative; +} + +.model-dropdown { + position: absolute; + bottom: calc(100% + 5px); + left: 0; + width: 280px; /* Compacted from 320px */ + background: #000000; + border: 1px solid #ffffff; + display: flex; + flex-direction: column; + max-height: 300px; /* Compacted from 380px */ + z-index: 1000; +} + +.model-search-box { + padding: 0.4rem; + border-bottom: 1px solid var(--border-color); +} + +.model-list { + flex: 1; + overflow-y: auto; + max-height: 180px; /* Compacted from 250px */ +} + +.provider-header { + font-size: 0.65rem; + color: #ffffff; + font-weight: 700; + padding: 0.4rem 0.6rem 0.2rem; + border-bottom: 1px dotted var(--border-color); +} + +.model-item { + padding: 0.35rem 0.6rem; /* Compacted from 0.4rem 0.75rem */ + font-size: 0.7rem; /* Compacted from 0.75rem */ + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; +} + +.model-item:hover { + background: #141414; +} + +.model-item.active { + background: #ffffff; + color: #000000; +} + +.model-item.active .model-item-id { + color: #555555; +} + +.model-item-details { + display: flex; + flex-direction: column; +} + +.model-item-name { + font-weight: 600; +} + +.model-item-id { + font-size: 0.65rem; /* Compacted from 0.7rem */ + color: var(--text-secondary); +} + +.model-badges { + display: flex; + gap: 0.3rem; +} + +.model-badge { + font-size: 0.6rem; /* Compacted from 0.65rem */ + padding: 0.05rem 0.2rem; + border: 1px solid var(--border-color); + font-weight: 600; +} + +.model-item.active .model-badge { + border-color: #000000; +} + +.model-dropdown-footer { + border-top: 1px solid var(--border-color); + padding: 0.4rem; + background: #0a0a0a; +} + +/* Modals Overlay */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.85); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; +} + +.modal { + background: #000000; + border: 1px solid #ffffff; + width: min(400px, 92vw); /* Compacted from 450px */ + display: flex; + flex-direction: column; + overflow: hidden; +} + +.modal-header { + padding: 0.75rem 1rem; /* Compacted from 1rem 1.25rem */ + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; +} + +.modal-header h3 { + font-size: 0.75rem; /* Compacted from 0.85rem */ + font-weight: 700; +} + +.close-modal-btn { + background: transparent; + border: none; + color: var(--text-secondary); + font-size: 0.75rem; + cursor: pointer; +} + +.close-modal-btn:hover { + color: #ffffff; +} + +.modal-body { + padding: 1rem; /* Compacted from 1.25rem */ + display: flex; + flex-direction: column; + gap: 0.75rem; /* Compacted from 1rem */ +} + +.modal-hint { + font-size: 0.65rem; + color: var(--text-muted); + line-height: 1.35; +} + +.modal-footer { + padding: 0.6rem 1rem; /* Compacted from 0.75rem 1.25rem */ + background: #0a0a0a; + border-top: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; +} + +.row-actions { + display: flex; + gap: 0.4rem; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.form-group label { + font-size: 0.65rem; /* Compacted from 0.7rem */ + color: var(--text-secondary); + font-weight: 600; +} + +.checkbox-row { + flex-direction: row; + align-items: center; + gap: 0.4rem; + margin-top: 0.15rem; +} + +.checkbox-row label { + cursor: pointer; + user-select: none; + font-size: 0.65rem; +} + +.checkbox-row input[type="checkbox"] { + width: 12px; + height: 12px; + accent-color: #ffffff; + cursor: pointer; +} + +/* Hidden Class */ +.hidden { + display: none !important; +} + +/* Responsive Overrides */ +@media (max-width: 900px) { + .app-shell { + grid-template-columns: 1fr; + } + + .control-panel { + display: none; + } + + .chat-panel { + height: 100vh; + } +}