diff --git a/src/autometabuilder/main.py b/src/autometabuilder/main.py index a5c01f6..7d9321f 100644 --- a/src/autometabuilder/main.py +++ b/src/autometabuilder/main.py @@ -272,38 +272,312 @@ def handle_tool_calls(resp_msg, tool_map: dict, gh: GitHubIntegration, msgs: dic class WorkflowEngine: - """Interpret and execute a JSON-defined workflow.""" + """Interpret and execute a node-based workflow.""" def __init__(self, workflow_config, context): - self.workflow_config = workflow_config + self.workflow_config = workflow_config or {} self.context = context - self.state = {} + self.store = {} + self.plugins = self._build_plugins() def execute(self): - """Execute the workflow sequence.""" - for phase in self.workflow_config: - if phase.get("type") == "loop": - self._execute_loop(phase) - else: - self._execute_phase(phase) + nodes = self.workflow_config.get("nodes") + if not isinstance(nodes, list): + logger.error("Workflow config missing nodes list.") + return + self._execute_nodes(nodes) - def _call_tool(self, tool_name, **kwargs): - tool = self.context["tool_map"].get(tool_name) - if not tool: - msg = self.context["msgs"].get( - "error_tool_not_found", - "Tool {name} not found or unavailable." - ).format(name=tool_name) - logger.error(msg) - return msg + def _execute_nodes(self, nodes): + for node in nodes: + self._execute_node(node) + + def _execute_node(self, node): + node_type = node.get("type") + if not node_type: + logger.error("Workflow node missing type.") + return None + + when_value = node.get("when") + if when_value is not None: + if not self._coerce_bool(self._resolve_binding(when_value)): + return None + + if node_type == "control.loop": + return self._execute_loop(node) + + plugin = self.plugins.get(node_type) + if not plugin: + logger.error(f"Unknown node type: {node_type}") + return None + + inputs = self._resolve_inputs(node.get("inputs", {})) + result = plugin(inputs) + if not isinstance(result, dict): + result = {"result": result} + + outputs = node.get("outputs", {}) + if outputs: + for output_name, store_key in outputs.items(): + if output_name in result: + self.store[store_key] = result[output_name] + else: + for output_name, value in result.items(): + self.store[output_name] = value + + return result + + def _execute_loop(self, node): + inputs = node.get("inputs", {}) + max_iterations = self._resolve_binding(inputs.get("max_iterations", 1)) + stop_when_raw = inputs.get("stop_when") + stop_on_raw = inputs.get("stop_on", True) - filtered_kwargs = {k: v for k, v in kwargs.items() if v is not None} try: - return tool(**filtered_kwargs) - except Exception as e: - error_msg = f"Error executing {tool_name}: {e}" - logger.error(error_msg) - return error_msg + max_iterations = int(max_iterations) + except (TypeError, ValueError): + max_iterations = 1 + + if self.context["args"].once: + max_iterations = min(max_iterations, 1) + + stop_on = self._coerce_bool(self._resolve_binding(stop_on_raw)) + + body = node.get("body", []) + if not isinstance(body, list): + logger.error("Loop body must be a list of nodes.") + return None + + iteration = 0 + while iteration < max_iterations: + iteration += 1 + logger.info(f"--- Loop iteration {iteration} ---") + self._execute_nodes(body) + + if stop_when_raw is not None: + stop_value = self._resolve_binding(stop_when_raw) + if self._coerce_bool(stop_value) == stop_on: + break + + return None + + def _build_plugins(self): + return { + "core.load_context": self._plugin_load_context, + "core.seed_messages": self._plugin_seed_messages, + "core.append_context_message": self._plugin_append_context_message, + "core.append_user_instruction": self._plugin_append_user_instruction, + "core.ai_request": self._plugin_ai_request, + "core.run_tool_calls": self._plugin_run_tool_calls, + "core.append_tool_results": self._plugin_append_tool_results, + "tools.list_files": self._plugin_list_files, + "tools.read_file": self._plugin_read_file, + "tools.run_tests": self._plugin_run_tests, + "tools.run_lint": self._plugin_run_lint, + "tools.create_branch": self._plugin_create_branch, + "tools.create_pull_request": self._plugin_create_pull_request, + "utils.filter_list": self._plugin_filter_list, + "utils.map_list": self._plugin_map_list, + "utils.reduce_list": self._plugin_reduce_list, + "utils.branch_condition": self._plugin_branch_condition, + "utils.not": self._plugin_not, + } + + def _plugin_load_context(self, inputs): + return {"context": get_sdlc_context(self.context["gh"], self.context["msgs"])} + + def _plugin_seed_messages(self, inputs): + prompt = self.context["prompt"] + return {"messages": list(prompt["messages"])} + + def _plugin_append_context_message(self, inputs): + messages = list(inputs.get("messages") or []) + context_val = inputs.get("context") + if context_val: + messages.append( + { + "role": "system", + "content": f"{self.context['msgs']['sdlc_context_label']}{context_val}", + } + ) + return {"messages": messages} + + def _plugin_append_user_instruction(self, inputs): + messages = list(inputs.get("messages") or []) + messages.append({"role": "user", "content": self.context["msgs"]["user_next_step"]}) + return {"messages": messages} + + def _plugin_ai_request(self, inputs): + messages = list(inputs.get("messages") or []) + response = get_completion( + self.context["client"], + self.context["model_name"], + messages, + self.context["tools"] + ) + resp_msg = response.choices[0].message + logger.info( + resp_msg.content + if resp_msg.content + else self.context["msgs"]["info_tool_call_requested"] + ) + messages.append(resp_msg) + tool_calls = getattr(resp_msg, "tool_calls", None) or [] + return { + "response": resp_msg, + "has_tool_calls": bool(tool_calls), + "tool_calls_count": len(tool_calls) + } + + def _plugin_run_tool_calls(self, inputs): + resp_msg = inputs.get("response") + tool_calls = getattr(resp_msg, "tool_calls", None) or [] + if not resp_msg: + return {"tool_results": [], "no_tool_calls": True} + + tool_results = handle_tool_calls( + resp_msg, + self.context["tool_map"], + self.context["gh"], + self.context["msgs"], + dry_run=self.context["args"].dry_run, + yolo=self.context["args"].yolo + ) + if not tool_calls and resp_msg.content: + notify_all(f"AutoMetabuilder task complete: {resp_msg.content[:100]}...") + return { + "tool_results": tool_results, + "no_tool_calls": not bool(tool_calls) + } + + def _plugin_append_tool_results(self, inputs): + messages = list(inputs.get("messages") or []) + tool_results = inputs.get("tool_results") or [] + if tool_results: + messages.extend(tool_results) + + if self.context["args"].yolo and is_mvp_reached(): + logger.info("MVP reached. Stopping YOLO loop.") + notify_all("AutoMetabuilder YOLO loop stopped: MVP reached.") + + return {"messages": messages} + + def _plugin_list_files(self, inputs): + result = self._call_tool("list_files", directory=inputs.get("path", ".")) + return {"files": result} + + def _plugin_read_file(self, inputs): + result = self._call_tool("read_file", path=inputs.get("path")) + return {"content": result} + + def _plugin_run_tests(self, inputs): + result = self._call_tool("run_tests", path=inputs.get("path", "tests")) + return {"results": result} + + def _plugin_run_lint(self, inputs): + result = self._call_tool("run_lint", path=inputs.get("path", "src")) + return {"results": result} + + def _plugin_create_branch(self, inputs): + result = self._call_tool( + "create_branch", + branch_name=inputs.get("branch_name"), + base_branch=inputs.get("base_branch", "main") + ) + return {"result": result} + + def _plugin_create_pull_request(self, inputs): + result = self._call_tool( + "create_pull_request", + title=inputs.get("title"), + body=inputs.get("body"), + head_branch=inputs.get("head_branch"), + base_branch=inputs.get("base_branch", "main") + ) + return {"result": result} + + def _plugin_filter_list(self, inputs): + items = self._ensure_list(inputs.get("items")) + mode = inputs.get("mode", "contains") + pattern = inputs.get("pattern", "") + filtered = [] + for item in items: + candidate = str(item) + matched = False + if mode == "contains": + matched = pattern in candidate + elif mode == "regex": + matched = bool(re.search(pattern, candidate)) + elif mode == "equals": + matched = candidate == pattern + elif mode == "not_equals": + matched = candidate != pattern + elif mode == "starts_with": + matched = candidate.startswith(pattern) + elif mode == "ends_with": + matched = candidate.endswith(pattern) + if matched: + filtered.append(item) + return {"items": filtered} + + def _plugin_map_list(self, inputs): + items = self._ensure_list(inputs.get("items")) + template = inputs.get("template", "{item}") + mapped = [] + for item in items: + try: + mapped.append(template.format(item=item)) + except Exception: + mapped.append(str(item)) + return {"items": mapped} + + def _plugin_reduce_list(self, inputs): + items = self._ensure_list(inputs.get("items")) + separator = self._normalize_separator(inputs.get("separator", "")) + reduced = separator.join([str(item) for item in items]) + return {"result": reduced} + + def _plugin_branch_condition(self, inputs): + value = inputs.get("value") + mode = inputs.get("mode", "is_truthy") + compare = inputs.get("compare", "") + decision = False + + if mode == "is_empty": + decision = not self._ensure_list(value) + elif mode == "is_truthy": + decision = bool(value) + elif mode == "equals": + decision = str(value) == compare + elif mode == "not_equals": + decision = str(value) != compare + elif mode == "contains": + decision = compare in str(value) + elif mode == "regex": + decision = bool(re.search(compare, str(value))) + + return {"result": decision} + + def _plugin_not(self, inputs): + return {"result": not self._coerce_bool(inputs.get("value"))} + + def _resolve_inputs(self, inputs): + return {key: self._resolve_binding(value) for key, value in (inputs or {}).items()} + + def _resolve_binding(self, value): + if isinstance(value, str) and value.startswith("$"): + return self.store.get(value[1:]) + return value + + def _coerce_bool(self, value): + if isinstance(value, bool): + return value + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in ("true", "yes", "1"): + return True + if lowered in ("false", "no", "0", ""): + return False + return bool(value) def _ensure_list(self, value): if value is None: @@ -319,271 +593,28 @@ class WorkflowEngine: def _normalize_separator(self, text): if text is None: return "" - return text.replace("\\n", "\n").replace("\\t", "\t") + if isinstance(text, str): + return text.replace("\\n", "\n").replace("\\t", "\t") + return str(text) - def _execute_phase(self, phase): - """Execute a phase which contains steps.""" - logger.info(f"--- Executing phase: {phase.get('name', 'unnamed')} ---") - for step in phase.get("steps", []): - self._execute_step(step) - - def _execute_loop(self, phase): - """Execute a loop of steps.""" - max_iterations = phase.get("max_iterations", 10) - if self.context["args"].once: - max_iterations = 2 # At most 2 passes for --once - - iteration = 0 - while iteration < max_iterations: - iteration += 1 - logger.info(f"--- {phase.get('name', 'loop')} Iteration {iteration} ---") - should_stop = False - for step in phase.get("steps", []): - result = self._execute_step(step) - if step.get("stop_if_no_tools") and result is True: - should_stop = True - break - - if should_stop or (self.context["args"].once and iteration >= 1 and not self.state.get("llm_response").tool_calls): - break - - if self.context["args"].once and iteration == 2: - break - - def _execute_step(self, step): - """Execute a single workflow step.""" - step_type = step.get("type") - output_key = step.get("output_key") + def _call_tool(self, tool_name, **kwargs): + tool = self.context["tool_map"].get(tool_name) + if not tool: + msg = self.context["msgs"].get( + "error_tool_not_found", + "Tool {name} not found or unavailable." + ).format(name=tool_name) + logger.error(msg) + return msg + filtered_kwargs = {k: v for k, v in kwargs.items() if v is not None} try: - if step_type == "load_context": - sdlc_context = get_sdlc_context(self.context["gh"], self.context["msgs"]) - if output_key: - self.state[output_key] = sdlc_context - return sdlc_context - - elif step_type == "seed_messages": - prompt = self.context["prompt"] - messages = list(prompt["messages"]) - if output_key: - self.state[output_key] = messages - return messages - - elif step_type == "prepare_messages": - prompt = self.context["prompt"] - msgs = self.context["msgs"] - sdlc_context_val = self.state.get(step.get("input_context")) - messages = list(prompt["messages"]) - if sdlc_context_val: - messages.append( - { - "role": "system", - "content": f"{msgs['sdlc_context_label']}{sdlc_context_val}", - } - ) - messages.append({"role": "user", "content": msgs["user_next_step"]}) - if output_key: - self.state[output_key] = messages - return messages - - elif step_type in ("append_context_message",): - msgs = self.context["msgs"] - sdlc_context_val = self.state.get(step.get("input_context")) - target_messages = self.state.get(step.get("target_messages")) - if sdlc_context_val and target_messages is not None: - target_messages.append( - { - "role": "system", - "content": f"{msgs['sdlc_context_label']}{sdlc_context_val}", - } - ) - return target_messages - - elif step_type in ("append_user_instruction",): - msgs = self.context["msgs"] - target_messages = self.state.get(step.get("target_messages")) - if target_messages is not None: - target_messages.append({"role": "user", "content": msgs["user_next_step"]}) - return target_messages - - elif step_type in ("llm_gen", "ai_request"): - messages = self.state.get(step.get("input_messages")) - response = get_completion( - self.context["client"], - self.context["model_name"], - messages, - self.context["tools"] - ) - resp_msg = response.choices[0].message - logger.info( - resp_msg.content - if resp_msg.content - else self.context["msgs"]["info_tool_call_requested"] - ) - messages.append(resp_msg) - if output_key: - self.state[output_key] = resp_msg - return resp_msg - - elif step_type in ("process_response", "run_tool_calls"): - resp_msg = self.state.get(step.get("input_response")) - tool_results = handle_tool_calls( - resp_msg, - self.context["tool_map"], - self.context["gh"], - self.context["msgs"], - dry_run=self.context["args"].dry_run, - yolo=self.context["args"].yolo - ) - if output_key: - self.state[output_key] = tool_results - - if step.get("stop_if_no_tools") and not resp_msg.tool_calls: - notify_all(f"AutoMetabuilder task complete: {resp_msg.content[:100]}...") - return True - return False - - elif step_type in ("update_messages", "append_tool_results"): - tool_results = self.state.get(step.get("input_results")) - target_messages = self.state.get(step.get("target_messages")) - if tool_results and target_messages is not None: - target_messages.extend(tool_results) - - if self.context["args"].yolo and is_mvp_reached(): - logger.info("MVP reached. Stopping YOLO loop.") - notify_all("AutoMetabuilder YOLO loop stopped: MVP reached.") - return True - - elif step_type == "list_files": - result = self._call_tool("list_files", directory=step.get("path", ".")) - if output_key: - self.state[output_key] = result - return result - - elif step_type == "read_file": - result = self._call_tool("read_file", path=step.get("path")) - if output_key: - self.state[output_key] = result - return result - - elif step_type == "run_tests": - result = self._call_tool("run_tests", path=step.get("path", "tests")) - if output_key: - self.state[output_key] = result - return result - - elif step_type == "run_lint": - result = self._call_tool("run_lint", path=step.get("path", "src")) - if output_key: - self.state[output_key] = result - return result - - elif step_type == "create_branch": - return self._call_tool( - "create_branch", - branch_name=step.get("branch_name"), - base_branch=step.get("base_branch", "main") - ) - - elif step_type == "create_pull_request": - return self._call_tool( - "create_pull_request", - title=step.get("title"), - body=step.get("body"), - head_branch=step.get("head_branch"), - base_branch=step.get("base_branch", "main") - ) - - elif step_type == "update_roadmap": - content = step.get("content") or self.state.get(step.get("input_key")) - result = self._call_tool("update_roadmap", content=content) - if output_key: - self.state[output_key] = result - return result - - elif step_type == "filter_list": - items = self._ensure_list(self.state.get(step.get("input_key"))) - mode = step.get("mode", "contains") - pattern = step.get("pattern", "") - filtered = [] - for item in items: - candidate = str(item) - matched = False - if mode == "contains": - matched = pattern in candidate - elif mode == "regex": - matched = bool(re.search(pattern, candidate)) - elif mode == "equals": - matched = candidate == pattern - elif mode == "not_equals": - matched = candidate != pattern - elif mode == "starts_with": - matched = candidate.startswith(pattern) - elif mode == "ends_with": - matched = candidate.endswith(pattern) - if matched: - filtered.append(item) - if output_key: - self.state[output_key] = filtered - return filtered - - elif step_type == "map_list": - items = self._ensure_list(self.state.get(step.get("input_key"))) - template = step.get("template", "{item}") - mapped = [] - for item in items: - try: - mapped.append(template.format(item=item)) - except Exception: - mapped.append(str(item)) - if output_key: - self.state[output_key] = mapped - return mapped - - elif step_type == "reduce_list": - items = self._ensure_list(self.state.get(step.get("input_key"))) - separator = self._normalize_separator(step.get("separator", "")) - reduced = separator.join([str(item) for item in items]) - if output_key: - self.state[output_key] = reduced - return reduced - - elif step_type == "branch": - value = self.state.get(step.get("input_key")) - mode = step.get("mode", "is_truthy") - compare = step.get("compare", "") - decision = False - - if mode == "is_empty": - decision = not self._ensure_list(value) - elif mode == "is_truthy": - decision = bool(value) - elif mode == "equals": - decision = str(value) == compare - elif mode == "not_equals": - decision = str(value) != compare - elif mode == "contains": - decision = compare in str(value) - elif mode == "regex": - decision = bool(re.search(compare, str(value))) - - if output_key: - self.state[output_key] = decision - - branch_steps = step.get("then_steps") if decision else step.get("else_steps") - if isinstance(branch_steps, list): - for branch_step in branch_steps: - self._execute_step(branch_step) - return decision - - else: - logger.error(f"Unknown step type: {step_type}") - + result = tool(**filtered_kwargs) + return result if result is not None else "Success" except Exception as e: - logger.error(f"Error executing step {step_type}: {e}") - raise - - return None + error_msg = f"Error executing {tool_name}: {e}" + logger.error(error_msg) + return error_msg def main(): diff --git a/src/autometabuilder/web/server.py b/src/autometabuilder/web/server.py index 6db6a76..bc4f8e9 100644 --- a/src/autometabuilder/web/server.py +++ b/src/autometabuilder/web/server.py @@ -2,7 +2,7 @@ import os import json import secrets from fastapi import FastAPI, Request, Form, BackgroundTasks, Depends, HTTPException, status -from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse from fastapi.templating import Jinja2Templates from fastapi.staticfiles import StaticFiles from fastapi.security import HTTPBasic, HTTPBasicCredentials @@ -313,6 +313,11 @@ async def get_status(username: str = Depends(get_current_user)): "mvp_reached": is_mvp_reached() } +@app.get("/api/workflow/plugins", response_class=JSONResponse) +async def get_workflow_plugins(username: str = Depends(get_current_user)): + metadata = get_metadata() + return metadata.get("workflow_plugins", {}) + @app.get("/api/logs") async def get_logs(username: str = Depends(get_current_user)): return {"logs": get_recent_logs()} diff --git a/src/autometabuilder/web/static/css/main.css b/src/autometabuilder/web/static/css/main.css index bddec81..bf6e6f8 100644 --- a/src/autometabuilder/web/static/css/main.css +++ b/src/autometabuilder/web/static/css/main.css @@ -886,6 +886,89 @@ body { font-size: 0.85rem; } +.amb-workflow-node { + background: var(--amb-bg-secondary); + border: 1px solid var(--amb-border-color); + border-radius: 0.85rem; + box-shadow: var(--amb-card-shadow); + overflow: hidden; +} + +.amb-workflow-node-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.85rem 1rem; + border-bottom: 1px solid var(--amb-border-color); + background: linear-gradient(135deg, rgba(13, 110, 253, 0.12), rgba(13, 110, 253, 0.02)); +} + +.amb-workflow-node-main { + display: flex; + align-items: center; + gap: 0.6rem; + flex-wrap: wrap; + flex: 1; +} + +.amb-workflow-node-actions { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.amb-workflow-node-badge { + background: var(--amb-bg-secondary); + border: 1px solid var(--amb-border-color); + border-radius: 999px; + padding: 0.2rem 0.65rem; + font-size: 0.75rem; + font-weight: 600; + color: var(--amb-text-primary); +} + +.amb-workflow-node-id { + min-width: 160px; + max-width: 200px; +} + +.amb-workflow-node-meta { + padding: 0.85rem 1rem 0; +} + +.amb-workflow-node-body { + display: grid; + gap: 1rem; + padding: 1rem; +} + +.amb-workflow-node-section { + background: var(--amb-bg-tertiary); + border: 1px solid var(--amb-border-color); + border-radius: 0.6rem; + padding: 0.75rem; +} + +.amb-workflow-node-section h6 { + margin-bottom: 0.75rem; + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--amb-text-muted); +} + +.amb-workflow-node-nested { + margin: 1rem 0 0 0.5rem; + padding-left: 0.75rem; + border-left: 2px dashed var(--amb-border-color); +} + +.amb-workflow-node-nested-header { + margin-bottom: 0.75rem; + color: var(--amb-text-muted); +} + /* ========================================================================== 11. Buttons ========================================================================== */ diff --git a/src/autometabuilder/web/static/js/index.js b/src/autometabuilder/web/static/js/index.js new file mode 100644 index 0000000..302903c --- /dev/null +++ b/src/autometabuilder/web/static/js/index.js @@ -0,0 +1,384 @@ +/** + * AutoMetabuilder - Index Page Scripts + */ +(() => { + const translations = window.AMB_I18N || {}; + const t = (key, fallback = '') => translations[key] || fallback || key; + const format = (text, values = {}) => text.replace(/\{(\w+)\}/g, (_, name) => values[name] ?? ''); + + const fetchWorkflowPlugins = async () => { + const response = await fetch('/api/workflow/plugins'); + if (!response.ok) { + throw new Error(`Plugin fetch failed: ${response.status}`); + } + return response.json(); + }; + + const initWorkflowBuilder = (pluginDefinitions) => { + if (!window.WorkflowBuilder) return; + const container = document.getElementById('workflow-builder'); + const textarea = document.getElementById('workflow-content'); + if (!container || !textarea) return; + window.WorkflowBuilder.init('workflow-builder', 'workflow-content', pluginDefinitions); + }; + + const updateIterationsGroup = () => { + const group = document.getElementById('iterations-group'); + if (!group) return; + const isIterations = document.getElementById('mode-iterations')?.checked; + group.style.display = isIterations ? 'flex' : 'none'; + const input = group.querySelector('input[name="iterations"]'); + if (input) { + input.disabled = !isIterations; + } + }; + + const wireRunModeToggles = () => { + document.querySelectorAll('input[name="mode"]').forEach(radio => { + radio.addEventListener('change', updateIterationsGroup); + }); + updateIterationsGroup(); + }; + + const PromptBuilder = { + append(targetId, snippet) { + const field = document.getElementById(targetId); + if (!field) return; + const current = field.value.trim(); + const spacer = current ? '\n' : ''; + field.value = `${current}${spacer}${snippet}`.trim(); + field.focus(); + } + }; + + const buildPromptYaml = () => { + const rawPanel = document.getElementById('prompt-raw'); + if (rawPanel && !rawPanel.classList.contains('d-none')) { + return; + } + + const systemField = document.getElementById('system-prompt'); + const userField = document.getElementById('user-prompt'); + const modelSelect = document.querySelector('select[name="model"]'); + const outputField = document.getElementById('prompt-yaml'); + if (!systemField || !userField || !modelSelect || !outputField) return; + + const systemContent = systemField.value; + const userContent = userField.value; + const model = modelSelect.value; + + const yaml = `messages: + - role: system + content: >- + ${systemContent.split('\n').join('\n ')} + - role: user + content: >- + ${userContent.split('\n').join('\n ')} +model: ${model} +`; + outputField.value = yaml; + }; + + const toggleRawPrompt = () => { + const rawPanel = document.getElementById('prompt-raw'); + const builder = document.getElementById('prompt-builder'); + if (!rawPanel) return; + + if (rawPanel.classList.contains('d-none')) { + buildPromptYaml(); + rawPanel.classList.remove('d-none'); + builder?.classList.add('d-none'); + } else { + rawPanel.classList.add('d-none'); + builder?.classList.remove('d-none'); + } + const modeInput = document.getElementById('prompt-mode'); + if (modeInput) { + modeInput.value = rawPanel.classList.contains('d-none') ? 'builder' : 'raw'; + } + }; + + const wirePromptChips = () => { + document.querySelectorAll('[data-prompt-target]').forEach(button => { + button.addEventListener('click', () => { + PromptBuilder.append(button.dataset.promptTarget, button.dataset.promptSnippet || ''); + }); + }); + + document.getElementById('prompt-form')?.addEventListener('submit', () => { + buildPromptYaml(); + }); + }; + + const TranslationEditor = { + currentLang: null, + data: {}, + baseData: {}, + originalData: {}, + filterTerm: '', + showMissing: true, + + async load(lang) { + try { + const response = await fetch(`/api/translations/${lang}`); + if (!response.ok) throw new Error(t('ui.translations.errors.load', 'Failed to load translation')); + const result = await response.json(); + + this.currentLang = lang; + this.data = result.content || {}; + this.originalData = JSON.parse(JSON.stringify(this.data)); + this.filterTerm = ''; + this.showMissing = true; + + if (lang !== 'en') { + const baseResponse = await fetch('/api/translations/en'); + if (baseResponse.ok) { + const baseResult = await baseResponse.json(); + this.baseData = baseResult.content || {}; + } else { + this.baseData = {}; + } + } else { + this.baseData = result.content || {}; + } + + document.getElementById('editor-title').textContent = + `${t('ui.translations.editing_label', 'Editing')}: ${lang.toUpperCase()}`; + document.getElementById('editor-actions').style.display = 'flex'; + document.getElementById('translation-editor-placeholder').style.display = 'none'; + document.getElementById('translation-editor').style.display = 'block'; + document.getElementById('translation-search').value = ''; + document.getElementById('translation-missing-toggle').checked = true; + document.getElementById('new-translation-key').value = ''; + document.getElementById('new-translation-value').value = ''; + + this.render(); + + document.querySelectorAll('.translation-item').forEach(el => el.classList.remove('active')); + document.querySelector(`.translation-item[data-lang="${lang}"]`)?.classList.add('active'); + } catch (error) { + alert(`${t('ui.translations.errors.load_prefix', 'Error loading translation: ')}${error.message}`); + } + }, + + render() { + const tbody = document.querySelector('#translation-table tbody'); + if (!tbody) return; + tbody.innerHTML = ''; + + const keys = new Set(Object.keys(this.data || {})); + if (this.showMissing) { + Object.keys(this.baseData || {}).forEach(key => keys.add(key)); + } + + const sortedKeys = Array.from(keys).sort(); + const missingKeys = Object.keys(this.baseData || {}).filter(key => !(key in this.data)); + const missingCount = missingKeys.length; + const missingLabel = document.getElementById('missing-count'); + if (missingLabel) { + const missingText = missingCount + ? format(t('ui.translations.missing_count', '{count} missing'), { count: missingCount }) + : t('ui.translations.all_set', 'All set'); + missingLabel.textContent = missingText; + missingLabel.classList.toggle('amb-pill-success', missingCount === 0); + missingLabel.classList.toggle('amb-pill-warning', missingCount > 0); + } + + sortedKeys.forEach(key => { + const value = this.data[key] ?? ''; + const baseValue = (this.baseData || {})[key]; + const isMissing = key in (this.baseData || {}) && !(key in this.data); + + const haystack = `${key} ${value} ${baseValue || ''}`.toLowerCase(); + if (this.filterTerm && !haystack.includes(this.filterTerm)) { + return; + } + + const row = document.createElement('tr'); + row.className = isMissing ? 'amb-translation-missing' : ''; + + const hintPrefix = t('ui.translations.hint_prefix', 'EN:'); + const hint = isMissing && baseValue + ? `
${this.escapeHtml(key)}
+ ${isMissing ? `${this.escapeHtml(t('ui.translations.missing_label', 'Missing'))}` : ''}
+