mirror of
https://github.com/johndoe6345789/AutoMetabuilder.git
synced 2026-04-24 13:54:59 +00:00
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:
@@ -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():
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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
|
||||
========================================================================== */
|
||||
|
||||
384
src/autometabuilder/web/static/js/index.js
Normal file
384
src/autometabuilder/web/static/js/index.js
Normal 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;
|
||||
})();
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user