Refactor WorkflowEngine to node-based structure and add new JavaScript for index page: Transition WorkflowEngine from task-based to node-based processing. Implement plugin mechanism and update the execution flow. Add new JavaScript file to manage interactions on the index page with dynamic elements and localization support.

This commit is contained in:
2026-01-09 18:49:18 +00:00
parent 93f0049aa5
commit 1b68061ce7
5 changed files with 790 additions and 635 deletions

View File

@@ -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():

View File

@@ -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()}

View File

@@ -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
========================================================================== */

View File

@@ -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
? `<div class="amb-translation-hint">${this.escapeHtml(hintPrefix)} ${this.escapeHtml(baseValue)}</div>`
: '';
row.innerHTML = `
<td>
<code class="small">${this.escapeHtml(key)}</code>
${isMissing ? `<span class="amb-pill amb-pill-warning ms-2">${this.escapeHtml(t('ui.translations.missing_label', 'Missing'))}</span>` : ''}
</td>
<td>
<div class="amb-translation-field">
<input type="text" class="form-control form-control-sm" data-key="${this.escapeHtml(key)}"
value="${this.escapeHtml(value)}" placeholder="${this.escapeHtml(baseValue || '')}">
${hint}
</div>
</td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-link text-danger" title="${this.escapeHtml(t('ui.translations.table.delete_title', 'Delete key'))}">
<i class="bi bi-trash"></i>
</button>
</td>
`;
const input = row.querySelector('input[data-key]');
input.addEventListener('input', (event) => this.updateEntry(key, event.target.value));
const deleteButton = row.querySelector('button');
deleteButton.addEventListener('click', () => this.deleteEntry(key));
tbody.appendChild(row);
});
},
updateEntry(key, value) {
this.data[key] = value;
},
addEntry() {
const keyInput = document.getElementById('new-translation-key');
const valueInput = document.getElementById('new-translation-value');
if (!keyInput || !valueInput) return;
const key = keyInput.value.trim();
if (!key) {
alert(t('ui.translations.prompt.enter_key', 'Enter a key name first.'));
return;
}
if (this.data[key] !== undefined) {
if (!confirm(t('ui.translations.confirm.replace_key', 'This key already exists. Replace it?'))) {
return;
}
}
const baseValue = (this.baseData || {})[key] || '';
const value = valueInput.value.trim() || baseValue;
this.data[key] = value;
keyInput.value = '';
valueInput.value = '';
this.render();
},
prefillNewValue() {
const keyInput = document.getElementById('new-translation-key');
const valueInput = document.getElementById('new-translation-value');
if (!keyInput || !valueInput) return;
const key = keyInput.value.trim();
if (!key) {
alert(t('ui.translations.prompt.enter_key', 'Enter a key name first.'));
return;
}
const baseValue = (this.baseData || {})[key];
if (!baseValue) {
alert(t('ui.translations.prompt.no_english', 'No English text found for this key.'));
return;
}
valueInput.value = baseValue;
},
deleteEntry(key) {
if (!confirm(format(t('ui.translations.confirm.delete_key', 'Delete translation key "{key}"?'), { key }))) return;
delete this.data[key];
this.render();
},
filter(term) {
this.filterTerm = (term || '').trim().toLowerCase();
this.render();
},
toggleMissing(show) {
this.showMissing = Boolean(show);
this.render();
},
fillMissing() {
const baseEntries = Object.entries(this.baseData || {});
if (!baseEntries.length) return;
baseEntries.forEach(([key, value]) => {
if (!(key in this.data)) {
this.data[key] = value;
}
});
this.render();
},
reset() {
this.data = JSON.parse(JSON.stringify(this.originalData || {}));
this.render();
},
async save() {
if (!this.currentLang) return;
const content = {};
Object.keys(this.data || {}).sort().forEach(key => {
content[key] = this.data[key];
});
try {
const response = await fetch(`/api/translations/${this.currentLang}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content })
});
if (!response.ok) throw new Error(t('ui.translations.errors.save', 'Failed to save'));
this.originalData = JSON.parse(JSON.stringify(this.data));
Toast.show(format(t('ui.translations.notice.saved', 'Translation "{lang}" saved successfully!'), { lang: this.currentLang }), 'success');
} catch (error) {
alert(`${t('ui.translations.errors.save_prefix', 'Error saving translation: ')}${error.message}`);
}
},
async delete(lang) {
if (!confirm(format(t('ui.translations.confirm.delete_translation', 'Are you sure you want to delete the "{lang}" translation?'), { lang }))) return;
try {
const response = await fetch(`/api/translations/${lang}`, { method: 'DELETE' });
if (!response.ok) throw new Error(t('ui.translations.errors.delete', 'Failed to delete'));
location.reload();
} catch (error) {
alert(`${t('ui.translations.errors.delete_prefix', 'Error deleting translation: ')}${error.message}`);
}
},
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text ?? '';
return div.innerHTML;
}
};
const init = async () => {
let pluginDefinitions = {};
try {
pluginDefinitions = await fetchWorkflowPlugins();
} catch (error) {
console.error('Workflow plugin fetch failed', error);
}
try {
initWorkflowBuilder(pluginDefinitions);
} catch (error) {
console.error('Workflow builder failed to initialize', error);
}
wireRunModeToggles();
wirePromptChips();
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
window.toggleRawPrompt = toggleRawPrompt;
window.buildPromptYaml = buildPromptYaml;
window.PromptBuilder = PromptBuilder;
window.TranslationEditor = TranslationEditor;
})();

View File

@@ -15,353 +15,5 @@
{% block scripts %}
<script src="/static/js/workflow.js"></script>
<script>
const I18N = window.AMB_I18N || {};
const t = (key, fallback = '') => I18N[key] || fallback || key;
const format = (text, values = {}) => text.replace(/\{(\w+)\}/g, (_, name) => values[name] ?? '');
// Initialize workflow builder
const pluginDefinitions = {{ metadata.workflow_plugins | tojson | safe }};
function initWorkflowBuilder() {
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);
}
try {
initWorkflowBuilder();
} catch (error) {
console.error('Workflow builder failed to initialize', error);
}
// Run mode toggle
function 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;
}
}
document.querySelectorAll('input[name="mode"]').forEach(radio => {
radio.addEventListener('change', updateIterationsGroup);
});
updateIterationsGroup();
// Prompt editor functions
function toggleRawPrompt() {
const rawPanel = document.getElementById('prompt-raw');
const builder = document.getElementById('prompt-builder');
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 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();
}
};
document.querySelectorAll('[data-prompt-target]').forEach(button => {
button.addEventListener('click', () => {
PromptBuilder.append(button.dataset.promptTarget, button.dataset.promptSnippet || '');
});
});
function buildPromptYaml() {
const rawPanel = document.getElementById('prompt-raw');
if (rawPanel && !rawPanel.classList.contains('d-none')) {
return;
}
const systemContent = document.getElementById('system-prompt').value;
const userContent = document.getElementById('user-prompt').value;
const model = document.querySelector('select[name="model"]').value;
const yaml = `messages:
- role: system
content: >-
${systemContent.split('\n').join('\n ')}
- role: user
content: >-
${userContent.split('\n').join('\n ')}
model: ${model}
`;
document.getElementById('prompt-yaml').value = yaml;
}
document.getElementById('prompt-form')?.addEventListener('submit', () => {
buildPromptYaml();
});
// Translation editor
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();
// Highlight active language
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
? `<div class="amb-translation-hint">${this.escapeHtml(hintPrefix)} ${this.escapeHtml(baseValue)}</div>`
: '';
row.innerHTML = `
<td>
<code class="small">${this.escapeHtml(key)}</code>
${isMissing ? `<span class="amb-pill amb-pill-warning ms-2">${this.escapeHtml(t('ui.translations.missing_label', 'Missing'))}</span>` : ''}
</td>
<td>
<div class="amb-translation-field">
<input type="text" class="form-control form-control-sm" data-key="${this.escapeHtml(key)}"
value="${this.escapeHtml(value)}" placeholder="${this.escapeHtml(baseValue || '')}">
${hint}
</div>
</td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-link text-danger" title="${this.escapeHtml(t('ui.translations.table.delete_title', 'Delete key'))}">
<i class="bi bi-trash"></i>
</button>
</td>
`;
const input = row.querySelector('input[data-key]');
input.addEventListener('input', (event) => this.updateEntry(key, event.target.value));
const deleteButton = row.querySelector('button');
deleteButton.addEventListener('click', () => this.deleteEntry(key));
tbody.appendChild(row);
});
},
updateEntry(key, value) {
this.data[key] = value;
},
addEntry() {
const keyInput = document.getElementById('new-translation-key');
const valueInput = document.getElementById('new-translation-value');
if (!keyInput || !valueInput) return;
const key = keyInput.value.trim();
if (!key) {
alert(t('ui.translations.prompt.enter_key', 'Enter a key name first.'));
return;
}
if (this.data[key] !== undefined) {
if (!confirm(t('ui.translations.confirm.replace_key', 'This key already exists. Replace it?'))) {
return;
}
}
const baseValue = (this.baseData || {})[key] || '';
const value = valueInput.value.trim() || baseValue;
this.data[key] = value;
keyInput.value = '';
valueInput.value = '';
this.render();
},
prefillNewValue() {
const keyInput = document.getElementById('new-translation-key');
const valueInput = document.getElementById('new-translation-value');
if (!keyInput || !valueInput) return;
const key = keyInput.value.trim();
if (!key) {
alert(t('ui.translations.prompt.enter_key', 'Enter a key name first.'));
return;
}
const baseValue = (this.baseData || {})[key];
if (!baseValue) {
alert(t('ui.translations.prompt.no_english', 'No English text found for this key.'));
return;
}
valueInput.value = baseValue;
},
deleteEntry(key) {
if (!confirm(format(t('ui.translations.confirm.delete_key', 'Delete translation key "{key}"?'), { key }))) return;
delete this.data[key];
this.render();
},
filter(term) {
this.filterTerm = (term || '').trim().toLowerCase();
this.render();
},
toggleMissing(show) {
this.showMissing = Boolean(show);
this.render();
},
fillMissing() {
const baseEntries = Object.entries(this.baseData || {});
if (!baseEntries.length) return;
baseEntries.forEach(([key, value]) => {
if (!(key in this.data)) {
this.data[key] = value;
}
});
this.render();
},
reset() {
this.data = JSON.parse(JSON.stringify(this.originalData || {}));
this.render();
},
async save() {
if (!this.currentLang) return;
const content = {};
Object.keys(this.data || {}).sort().forEach(key => {
content[key] = this.data[key];
});
try {
const response = await fetch(`/api/translations/${this.currentLang}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content })
});
if (!response.ok) throw new Error(t('ui.translations.errors.save', 'Failed to save'));
this.originalData = JSON.parse(JSON.stringify(this.data));
Toast.show(format(t('ui.translations.notice.saved', 'Translation "{lang}" saved successfully!'), { lang: this.currentLang }), 'success');
} catch (error) {
alert(`${t('ui.translations.errors.save_prefix', 'Error saving translation: ')}${error.message}`);
}
},
async delete(lang) {
if (!confirm(format(t('ui.translations.confirm.delete_translation', 'Are you sure you want to delete the "{lang}" translation?'), { lang }))) return;
try {
const response = await fetch(`/api/translations/${lang}`, { method: 'DELETE' });
if (!response.ok) throw new Error(t('ui.translations.errors.delete', 'Failed to delete'));
location.reload();
} catch (error) {
alert(`${t('ui.translations.errors.delete_prefix', 'Error deleting translation: ')}${error.message}`);
}
},
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text ?? '';
return div.innerHTML;
}
};
window.PromptBuilder = PromptBuilder;
window.TranslationEditor = TranslationEditor;
</script>
<script src="/static/js/index.js"></script>
{% endblock %}