mirror of
https://github.com/johndoe6345789/AutoMetabuilder.git
synced 2026-04-24 13:54:59 +00:00
Add session-scope fixture and enhance UI navigation handling. Implement session-level fixture browser_type_launch_args for Playwright configuration, improve UI navigation in test_web_ui.py by using expect_navigation, refine language dropdown checks, and streamline task buttons and JSON toggle functionality in the Workflow section. Integrate new UI components from templates/components/sections.html.
This commit is contained in:
19
prompt.yml
19
prompt.yml
@@ -1,23 +1,8 @@
|
||||
messages:
|
||||
- role: system
|
||||
content: >-
|
||||
You are an expert software engineer following SDLC best practices.
|
||||
Ensure high code quality through:
|
||||
1. Requirement analysis and clear design.
|
||||
2. Writing modular, maintainable, and well-documented code.
|
||||
3. Preferring declarative design (e.g., looping over JSON specifications) over imperative or hardcoded logic.
|
||||
4. Implementing comprehensive unit and integration tests.
|
||||
5. Following security best practices and performance optimization.
|
||||
6. Adhering to project-specific coding standards and linting rules.
|
||||
7. Keeping documentation (README, ROADMAP, etc.) up to date.
|
||||
8. Continuous Self-Improvement: You are encouraged to propose and apply enhancements to your own configuration, including `prompt.yml` and `tools.json`, to improve your effectiveness and reasoning.
|
||||
Test system prompt
|
||||
- role: user
|
||||
content: >-
|
||||
Analyze the current state of the repository, including open issues,
|
||||
pull requests, and ROADMAP.md (if it exists). Your primary objective is
|
||||
to implement the next logical items from the ROADMAP.md while addressing
|
||||
any urgent issues. If ROADMAP.md is missing, your priority is to
|
||||
initialize it by analyzing the repository, marking completed tasks, and
|
||||
listing upcoming work. Ensure all changes are tested, linted, and
|
||||
properly documented. Update ROADMAP.md as you complete tasks.
|
||||
Test user prompt
|
||||
model: openai/gpt-4o
|
||||
|
||||
@@ -67,6 +67,26 @@ def extract_user_content(yaml_content):
|
||||
templates.env.filters['extract_system_content'] = extract_system_content
|
||||
templates.env.filters['extract_user_content'] = extract_user_content
|
||||
|
||||
def build_prompt_yaml(system_content, user_content, model):
|
||||
def indent_block(text):
|
||||
lines = (text or "").splitlines()
|
||||
if not lines:
|
||||
return ""
|
||||
return "\n ".join(line.rstrip() for line in lines)
|
||||
|
||||
model_value = model or "openai/gpt-4o"
|
||||
system_block = indent_block(system_content)
|
||||
user_block = indent_block(user_content)
|
||||
return f"""messages:
|
||||
- role: system
|
||||
content: >-
|
||||
{system_block}
|
||||
- role: user
|
||||
content: >-
|
||||
{user_block}
|
||||
model: {model_value}
|
||||
"""
|
||||
|
||||
# Setup static files
|
||||
static_dir = os.path.join(os.path.dirname(__file__), "static")
|
||||
if os.path.exists(static_dir):
|
||||
@@ -242,10 +262,21 @@ async def run_bot(
|
||||
return RedirectResponse(url="/", status_code=303)
|
||||
|
||||
@app.post("/prompt")
|
||||
async def update_prompt(content: str = Form(...), username: str = Depends(get_current_user)):
|
||||
async def update_prompt(
|
||||
content: str = Form(""),
|
||||
system_content: str = Form(""),
|
||||
user_content: str = Form(""),
|
||||
model: str = Form("openai/gpt-4o"),
|
||||
prompt_mode: str = Form("builder"),
|
||||
username: str = Depends(get_current_user)
|
||||
):
|
||||
prompt_path = os.environ.get("PROMPT_PATH", "prompt.yml")
|
||||
if prompt_mode == "raw":
|
||||
prompt_yaml = content
|
||||
else:
|
||||
prompt_yaml = build_prompt_yaml(system_content, user_content, model)
|
||||
with open(prompt_path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
f.write(prompt_yaml)
|
||||
return RedirectResponse(url="/", status_code=303)
|
||||
|
||||
@app.post("/workflow")
|
||||
|
||||
@@ -154,6 +154,27 @@ const ChoicesManager = {
|
||||
}
|
||||
};
|
||||
|
||||
/* ==========================================================================
|
||||
Workflow Toggle
|
||||
========================================================================== */
|
||||
const WorkflowToggle = {
|
||||
init() {
|
||||
document.querySelectorAll('[data-workflow-toggle]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const builder = window.WorkflowBuilder;
|
||||
if (builder && builder.textarea && typeof builder.toggleRaw === 'function') {
|
||||
builder.toggleRaw();
|
||||
return;
|
||||
}
|
||||
const textarea = document.getElementById('workflow-content');
|
||||
if (textarea) {
|
||||
textarea.classList.toggle('d-none');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/* ==========================================================================
|
||||
Form Validator
|
||||
========================================================================== */
|
||||
@@ -292,6 +313,7 @@ const App = {
|
||||
ThemeManager.init();
|
||||
NavigationManager.init();
|
||||
ChoicesManager.init();
|
||||
WorkflowToggle.init();
|
||||
FormValidator.init();
|
||||
StatusPoller.init();
|
||||
}
|
||||
@@ -305,6 +327,7 @@ window.App = App;
|
||||
window.ThemeManager = ThemeManager;
|
||||
window.NavigationManager = NavigationManager;
|
||||
window.ChoicesManager = ChoicesManager;
|
||||
window.WorkflowToggle = WorkflowToggle;
|
||||
window.FormValidator = FormValidator;
|
||||
window.StatusPoller = StatusPoller;
|
||||
window.Toast = Toast;
|
||||
|
||||
516
src/autometabuilder/web/templates/components/sections.html
Normal file
516
src/autometabuilder/web/templates/components/sections.html
Normal file
@@ -0,0 +1,516 @@
|
||||
{# Page section components composed from atoms, molecules, and organisms. #}
|
||||
{% from "components/atoms.html" import section_header, helper_text, pill %}
|
||||
{% from "components/molecules.html" import card, empty_state %}
|
||||
{% from "components/organisms.html" import callout %}
|
||||
|
||||
{% macro choice_card(id, value, icon_name, title, description, checked=False, extra_html='') -%}
|
||||
<label class="amb-choice-card" for="{{ id }}">
|
||||
<input type="radio" name="mode" id="{{ id }}" value="{{ value }}" {% if checked %}checked{% endif %}>
|
||||
<div class="amb-choice-content">
|
||||
<div class="amb-choice-title">
|
||||
<i class="bi bi-{{ icon_name }}"></i> {{ title }}
|
||||
</div>
|
||||
<p class="amb-choice-text">{{ description }}</p>
|
||||
{% if extra_html %}{{ extra_html | safe }}{% endif %}
|
||||
</div>
|
||||
</label>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro prompt_chip(target_id, snippet, label) -%}
|
||||
<button type="button" class="amb-chip" data-prompt-target="{{ target_id }}"
|
||||
data-prompt-snippet="{{ snippet }}">{{ label }}</button>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro prompt_step(step_number, title, description, textarea_id, name, placeholder, value, rows=5, chips=[]) -%}
|
||||
<div class="amb-prompt-step">
|
||||
<div class="amb-prompt-step-title">
|
||||
<span class="amb-step-badge">{{ step_number }}</span> {{ title }}
|
||||
</div>
|
||||
{{ helper_text(description) }}
|
||||
<textarea name="{{ name }}" class="form-control" rows="{{ rows }}" id="{{ textarea_id }}"
|
||||
placeholder="{{ placeholder }}">{{ value }}</textarea>
|
||||
{% if chips %}
|
||||
<div class="amb-chip-row">
|
||||
{% for chip in chips %}
|
||||
{{ prompt_chip(chip.target, chip.snippet, chip.label) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro dashboard_section() -%}
|
||||
<section id="dashboard" class="amb-section active">
|
||||
{{ section_header(t('ui.dashboard.title', 'Dashboard'), t('ui.dashboard.subtitle', 'Control the bot and monitor system activity')) }}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-5 col-md-6">
|
||||
<div class="amb-card">
|
||||
<div class="amb-card-header">
|
||||
<h5><i class="bi bi-robot"></i> {{ t('ui.dashboard.bot_control', 'Bot Control') }}</h5>
|
||||
</div>
|
||||
<div class="amb-card-body">
|
||||
<form action="/run" method="post" id="run-form">
|
||||
<div class="amb-form-group">
|
||||
<label class="amb-form-label">{{ t('ui.dashboard.run_strategy', 'Run Strategy') }}</label>
|
||||
<div class="amb-choice-grid">
|
||||
{% set iterations_extra %}
|
||||
<div class="amb-choice-inline" id="iterations-group" style="display: none;">
|
||||
<span class="amb-choice-meta">{{ t('ui.dashboard.run.repeat.label', 'Iterations') }}</span>
|
||||
<input type="number" name="iterations" class="form-control form-control-sm"
|
||||
value="5" min="1" max="100">
|
||||
</div>
|
||||
{% endset %}
|
||||
|
||||
{{ choice_card('mode-once', 'once', '1-circle', t('ui.dashboard.run.single.title', 'Single Iteration'), t('ui.dashboard.run.single.desc', 'One full pass through the workflow.'), True) }}
|
||||
{{ choice_card('mode-iterations', 'iterations', 'arrow-repeat', t('ui.dashboard.run.repeat.title', 'Repeat'), t('ui.dashboard.run.repeat.desc', 'Run a fixed number of cycles.'), False, iterations_extra) }}
|
||||
{{ choice_card('mode-yolo', 'yolo', 'infinity', t('ui.dashboard.run.yolo.title', 'YOLO'), t('ui.dashboard.run.yolo.desc', 'Keep iterating until you stop it.')) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="amb-form-group">
|
||||
<label class="amb-form-label">{{ t('ui.dashboard.automation', 'Automation') }}</label>
|
||||
<div class="amb-toggle-stack">
|
||||
<div class="form-check form-switch amb-toggle">
|
||||
<input class="form-check-input" type="checkbox" name="yolo" id="yolo-check" value="true" checked>
|
||||
<label class="form-check-label" for="yolo-check">
|
||||
<strong>{{ t('ui.dashboard.auto_approve.title', 'Auto-approve tools') }}</strong>
|
||||
<small class="text-muted d-block">{{ t('ui.dashboard.auto_approve.desc', 'Skip confirmations so the bot can run faster.') }}</small>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check form-switch amb-toggle">
|
||||
<input class="form-check-input" type="checkbox" name="stop_at_mvp" id="mvp-check" value="true">
|
||||
<label class="form-check-label" for="mvp-check">
|
||||
<strong>{{ t('ui.dashboard.stop_mvp.title', 'Stop at MVP') }}</strong>
|
||||
<small class="text-muted d-block">{{ t('ui.dashboard.stop_mvp.desc', 'Pause automatically when the MVP milestone is reached.') }}</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="run-btn" type="submit" class="btn btn-danger w-100" {% if is_running %}disabled{% endif %}>
|
||||
<i class="bi bi-play-fill"></i> {{ t('ui.dashboard.start_bot', 'Start Bot') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="amb-card">
|
||||
<div class="amb-card-header">
|
||||
<h5><i class="bi bi-activity"></i> {{ t('ui.dashboard.status.title', 'Status') }}</h5>
|
||||
</div>
|
||||
<div class="amb-card-body">
|
||||
<div class="d-flex flex-column gap-3">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<span>{{ t('ui.dashboard.status.bot_label', 'Bot Status') }}</span>
|
||||
<div id="status-indicator" class="amb-status {% if is_running %}amb-status-running{% else %}amb-status-idle{% endif %}">
|
||||
<span class="amb-status-dot"></span>
|
||||
{% if is_running %}{{ t('ui.dashboard.status.running', 'Running') }}{% else %}{{ t('ui.dashboard.status.idle', 'Idle') }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<span>{{ t('ui.dashboard.status.mvp_label', 'MVP Milestone') }}</span>
|
||||
<span id="mvp-badge" class="badge {% if mvp_reached %}bg-success{% else %}bg-secondary{% endif %}">
|
||||
{% if mvp_reached %}
|
||||
<i class="bi bi-check-circle-fill"></i> {{ t('ui.dashboard.status.mvp_reached', 'Reached') }}
|
||||
{% else %}
|
||||
<i class="bi bi-hourglass-split"></i> {{ t('ui.dashboard.status.mvp_progress', 'In Progress') }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div id="status-progress" class="progress" style="display: {% if is_running %}block{% else %}none{% endif %};">
|
||||
<div class="progress-bar progress-bar-striped progress-bar-animated" style="width: 100%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-7 col-md-6">
|
||||
<div class="amb-card" style="height: calc(100% - 1.5rem);">
|
||||
<div class="amb-card-header">
|
||||
<h5><i class="bi bi-terminal"></i> {{ t('ui.dashboard.logs.title', 'Recent Logs') }}</h5>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="StatusPoller.refreshLogs()" title="{{ t('ui.actions.refresh', 'Refresh') }}">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="amb-card-body p-0">
|
||||
<pre id="logs" class="amb-logs m-0" style="height: 450px;">{{ logs }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro workflow_section() -%}
|
||||
<section id="workflow" class="amb-section">
|
||||
{{ section_header(t('ui.workflow.title', 'Workflow Builder'), t('ui.workflow.subtitle', "Design the bot's task execution pipeline")) }}
|
||||
|
||||
<div class="amb-card">
|
||||
<div class="amb-card-header">
|
||||
<h5><i class="bi bi-diagram-3"></i> {{ t('ui.workflow.card.title', 'Tasks & Steps') }}</h5>
|
||||
<div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" data-workflow-toggle>
|
||||
<i class="bi bi-code-slash"></i> {{ t('ui.workflow.toggle_json', 'Toggle JSON') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="amb-card-body">
|
||||
<div id="workflow-builder" class="mb-3">
|
||||
<button type="button" class="btn btn-primary" data-workflow-fallback>
|
||||
<i class="bi bi-plus-lg"></i> {{ t('ui.workflow.add_task', 'Add Task') }}
|
||||
</button>
|
||||
</div>
|
||||
<form action="/workflow" method="post" id="workflow-form">
|
||||
<textarea id="workflow-content" name="content" class="form-control amb-code-editor d-none" rows="15">{{ workflow_content }}</textarea>
|
||||
<div class="d-flex gap-2 mt-3">
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="bi bi-save"></i> {{ t('ui.workflow.save', 'Save Workflow') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro prompt_section() -%}
|
||||
<section id="prompt" class="amb-section">
|
||||
{{ section_header(t('ui.prompt.title', 'Prompt Builder'), t('ui.prompt.subtitle', 'Shape how the assistant thinks, speaks, and decides')) }}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
{% call card(t('ui.prompt.card.title', 'Prompt Builder'), "chat-square-text") %}
|
||||
<form action="/prompt" method="post" id="prompt-form">
|
||||
<input type="hidden" name="prompt_mode" id="prompt-mode" value="builder">
|
||||
<div id="prompt-builder">
|
||||
{% set system_chips = [
|
||||
{'target': 'system-prompt', 'snippet': t('ui.prompt.chip.senior.snippet', 'You are a senior software engineer focused on correctness and clarity.'), 'label': t('ui.prompt.chip.senior.label', 'Senior engineer')},
|
||||
{'target': 'system-prompt', 'snippet': t('ui.prompt.chip.ask.snippet', 'Ask clarifying questions before making risky changes.'), 'label': t('ui.prompt.chip.ask.label', 'Ask questions')},
|
||||
{'target': 'system-prompt', 'snippet': t('ui.prompt.chip.minimal.snippet', 'Prefer minimal diffs and explain trade-offs.'), 'label': t('ui.prompt.chip.minimal.label', 'Minimal diffs')}
|
||||
] %}
|
||||
{{ prompt_step(1,
|
||||
t('ui.prompt.step1.title', 'Define the assistant'),
|
||||
t('ui.prompt.step1.desc', 'Describe who the assistant is and how it should behave.'),
|
||||
'system-prompt',
|
||||
'system_content',
|
||||
t('ui.prompt.step1.placeholder', 'You are a senior software engineer who prefers small, safe changes.'),
|
||||
(prompt_content | extract_system_content),
|
||||
6,
|
||||
system_chips
|
||||
) }}
|
||||
|
||||
{% set user_chips = [
|
||||
{'target': 'user-prompt', 'snippet': t('ui.prompt.chip.ux.snippet', 'Focus on UX polish, and avoid major refactors.'), 'label': t('ui.prompt.chip.ux.label', 'UX polish')},
|
||||
{'target': 'user-prompt', 'snippet': t('ui.prompt.chip.tests.snippet', 'Add tests when possible, but avoid heavy scaffolding.'), 'label': t('ui.prompt.chip.tests.label', 'Add tests')},
|
||||
{'target': 'user-prompt', 'snippet': t('ui.prompt.chip.summarize.snippet', 'Summarize changes and suggest next steps.'), 'label': t('ui.prompt.chip.summarize.label', 'Summarize')}
|
||||
] %}
|
||||
{{ prompt_step(2,
|
||||
t('ui.prompt.step2.title', 'Give the mission'),
|
||||
t('ui.prompt.step2.desc', 'Explain what the bot should accomplish right now.'),
|
||||
'user-prompt',
|
||||
'user_content',
|
||||
t('ui.prompt.step2.placeholder', 'Review the repo, improve the UI, and summarize what changed.'),
|
||||
(prompt_content | extract_user_content),
|
||||
5,
|
||||
user_chips
|
||||
) }}
|
||||
|
||||
<div class="amb-form-group">
|
||||
<label class="amb-form-label">{{ t('ui.prompt.model.label', 'Choose a model') }}</label>
|
||||
{{ helper_text(t('ui.prompt.model.desc', 'Pick the balance of quality and speed that fits the task.')) }}
|
||||
<select name="model" class="form-select" data-choices>
|
||||
<option value="openai/gpt-4o" {% if 'gpt-4o' in prompt_content %}selected{% endif %}>GPT-4o ({{ t('ui.prompt.model.recommended', 'Recommended') }})</option>
|
||||
<option value="openai/gpt-4o-mini" {% if 'gpt-4o-mini' in prompt_content %}selected{% endif %}>GPT-4o Mini ({{ t('ui.prompt.model.faster', 'Faster') }})</option>
|
||||
<option value="openai/gpt-4-turbo" {% if 'gpt-4-turbo' in prompt_content %}selected{% endif %}>GPT-4 Turbo</option>
|
||||
<option value="anthropic/claude-3-5-sonnet" {% if 'claude' in prompt_content %}selected{% endif %}>Claude 3.5 Sonnet</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="prompt-raw" class="d-none">
|
||||
<label class="amb-form-label">{{ t('ui.prompt.raw.label', 'Advanced YAML') }}</label>
|
||||
{{ helper_text(t('ui.prompt.raw.desc', 'Edit the full YAML only if you need fine control.')) }}
|
||||
<textarea name="content" class="amb-code-editor" id="prompt-yaml" rows="18">{{ prompt_content }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<button type="submit" class="btn btn-success" onclick="buildPromptYaml()">
|
||||
<i class="bi bi-save"></i> {{ t('ui.prompt.save', 'Save Prompt') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="toggleRawPrompt()">
|
||||
<i class="bi bi-code-slash"></i> {{ t('ui.prompt.advanced_yaml', 'Advanced YAML') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
{% call card(t('ui.prompt.guidance.title', 'Guidance'), "lightbulb") %}
|
||||
<div class="amb-guidance">
|
||||
<div class="mb-3">
|
||||
<strong>{{ t('ui.prompt.guidance.keep_human.title', 'Keep it human') }}</strong>
|
||||
<p class="small text-muted mb-0">{{ t('ui.prompt.guidance.keep_human.desc', 'Write instructions the way you would brief a teammate.') }}</p>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<strong>{{ t('ui.prompt.guidance.be_specific.title', 'Be specific') }}</strong>
|
||||
<p class="small text-muted mb-0">{{ t('ui.prompt.guidance.be_specific.desc', 'Mention constraints like time, scope, or testing expectations.') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{{ t('ui.prompt.guidance.raw.title', 'Use advanced YAML sparingly') }}</strong>
|
||||
<p class="small text-muted mb-0">{{ t('ui.prompt.guidance.raw.desc', 'Only switch to raw YAML if you need full control.') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro settings_section() -%}
|
||||
<section id="settings" class="amb-section">
|
||||
{{ section_header(t('ui.settings.title', 'Settings'), t('ui.settings.subtitle', 'Configure services, security, and environment preferences')) }}
|
||||
|
||||
{{ callout(t('ui.settings.callout.title', 'Human-friendly settings'), t('ui.settings.callout.body', 'Each field explains what it does. Add descriptions in metadata.json to keep custom settings readable for everyone.'), "info", "info-circle") }}
|
||||
|
||||
<form action="/settings" method="post" id="settings-form">
|
||||
<div class="row">
|
||||
{% set settings_desc = metadata.get('settings_descriptions', {}) %}
|
||||
|
||||
<div class="col-lg-6">
|
||||
<div class="amb-card">
|
||||
<div class="amb-card-header">
|
||||
<h5><i class="bi bi-key"></i> {{ t('ui.settings.api_keys', 'API Keys') }}</h5>
|
||||
</div>
|
||||
<div class="amb-card-body">
|
||||
{% for key in ['GITHUB_TOKEN', 'OPENAI_API_KEY', 'LITELLM_API_KEY'] %}
|
||||
{% set desc = settings_desc.get(key, {}) %}
|
||||
<div class="amb-form-group">
|
||||
<label class="amb-form-label">
|
||||
{{ desc.get('label', key) }}
|
||||
{% if desc.get('required') %}<span class="amb-required">*</span>{% endif %}
|
||||
</label>
|
||||
<p class="text-muted small mb-2">{{ desc.get('description', '') }}</p>
|
||||
<input type="{{ desc.get('type', 'text') }}"
|
||||
name="env_{{ key }}"
|
||||
class="form-control"
|
||||
value="{{ env_vars.get(key, '') }}"
|
||||
placeholder="{{ desc.get('placeholder', '') }}">
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<div class="amb-card">
|
||||
<div class="amb-card-header">
|
||||
<h5><i class="bi bi-gear"></i> {{ t('ui.settings.configuration', 'Configuration') }}</h5>
|
||||
</div>
|
||||
<div class="amb-card-body">
|
||||
{% for key in ['GITHUB_REPOSITORY', 'LOG_LEVEL', 'APP_LANG', 'PROMPT_PATH'] %}
|
||||
{% set desc = settings_desc.get(key, {}) %}
|
||||
<div class="amb-form-group">
|
||||
<label class="amb-form-label">{{ desc.get('label', key) }}</label>
|
||||
<p class="text-muted small mb-2">{{ desc.get('description', '') }}</p>
|
||||
{% if desc.get('type') == 'select' %}
|
||||
<select name="env_{{ key }}" class="form-select" data-choices>
|
||||
<option value="">{{ t('ui.common.select_placeholder', 'Select...') }}</option>
|
||||
{% for opt in desc.get('options', []) %}
|
||||
<option value="{{ opt }}" {% if env_vars.get(key) == opt %}selected{% endif %}>{{ opt }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<input type="{{ desc.get('type', 'text') }}"
|
||||
name="env_{{ key }}"
|
||||
class="form-control"
|
||||
value="{{ env_vars.get(key, desc.get('default', '')) }}"
|
||||
placeholder="{{ desc.get('placeholder', '') }}">
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="amb-card">
|
||||
<div class="amb-card-header">
|
||||
<h5><i class="bi bi-shield-lock"></i> {{ t('ui.settings.web_access', 'Web UI Access') }}</h5>
|
||||
</div>
|
||||
<div class="amb-card-body">
|
||||
{% for key in ['WEB_USER', 'WEB_PASSWORD'] %}
|
||||
{% set desc = settings_desc.get(key, {}) %}
|
||||
<div class="amb-form-group">
|
||||
<label class="amb-form-label">{{ desc.get('label', key) }}</label>
|
||||
<p class="text-muted small mb-2">{{ desc.get('description', '') }}</p>
|
||||
<input type="{{ desc.get('type', 'text') }}"
|
||||
name="env_{{ key }}"
|
||||
class="form-control"
|
||||
value="{{ env_vars.get(key, '') }}">
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="amb-card">
|
||||
<div class="amb-card-header">
|
||||
<h5><i class="bi bi-sliders"></i> {{ t('ui.settings.other', 'Other Settings') }}</h5>
|
||||
</div>
|
||||
<div class="amb-card-body">
|
||||
<div class="row">
|
||||
{% set known_keys = ['GITHUB_TOKEN', 'OPENAI_API_KEY', 'LITELLM_API_KEY', 'GITHUB_REPOSITORY', 'LOG_LEVEL', 'APP_LANG', 'PROMPT_PATH', 'WEB_USER', 'WEB_PASSWORD'] %}
|
||||
{% for key, value in env_vars.items() %}
|
||||
{% if key not in known_keys %}
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="amb-form-group">
|
||||
{% set desc = settings_desc.get(key, {}) %}
|
||||
<label class="amb-form-label">{{ desc.get('label', key) }}</label>
|
||||
<p class="text-muted small mb-2">
|
||||
{{ desc.get('description', t('ui.settings.custom_default_desc', 'Custom environment setting. Add a description in metadata.json to show it here.')) }}
|
||||
</p>
|
||||
<input type="text" name="env_{{ key }}" class="form-control" value="{{ value }}"
|
||||
placeholder="{{ desc.get('placeholder', '') }}">
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="amb-form-group">
|
||||
<label class="amb-form-label">{{ t('ui.settings.add.title', 'Add New Setting') }}</label>
|
||||
<p class="text-muted small mb-2">{{ t('ui.settings.add.desc', 'Use uppercase keys with underscores, like API_TIMEOUT.') }}</p>
|
||||
<div class="input-group">
|
||||
<input type="text" name="new_env_key" class="form-control" placeholder="{{ t('ui.settings.add.placeholder_key', 'KEY') }}">
|
||||
<input type="text" name="new_env_value" class="form-control" placeholder="{{ t('ui.settings.add.placeholder_value', 'Value') }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="amb-card-footer">
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="bi bi-save"></i> {{ t('ui.settings.save_all', 'Save All Settings') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro translations_section() -%}
|
||||
<section id="translations" class="amb-section">
|
||||
{{ section_header(t('ui.translations.title', 'Translations'), t('ui.translations.subtitle', 'Create, edit, and maintain language files for bot messages')) }}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-4">
|
||||
<div class="amb-card">
|
||||
<div class="amb-card-header">
|
||||
<h5><i class="bi bi-globe"></i> {{ t('ui.translations.languages', 'Languages') }}</h5>
|
||||
</div>
|
||||
<div class="amb-card-body p-0">
|
||||
<div class="list-group list-group-flush" id="translation-list">
|
||||
{% for lang, file in translations.items() %}
|
||||
<div class="list-group-item d-flex justify-content-between align-items-center translation-item" data-lang="{{ lang }}">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<i class="bi bi-file-text"></i>
|
||||
<div>
|
||||
<strong>{{ lang.upper() }}</strong>
|
||||
<small class="text-muted d-block">{{ file }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button type="button" class="btn btn-outline-primary" onclick="TranslationEditor.load('{{ lang }}')" title="{{ t('ui.actions.edit', 'Edit') }}">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
{% if lang != 'en' %}
|
||||
<button type="button" class="btn btn-outline-danger" onclick="TranslationEditor.delete('{{ lang }}')" title="{{ t('ui.actions.delete', 'Delete') }}">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="amb-card-footer">
|
||||
<form action="/translations" method="post" class="d-flex gap-2">
|
||||
<select name="lang" class="form-select form-select-sm" data-choices data-placeholder="{{ t('ui.translations.add_language_placeholder', 'Add language...') }}" required>
|
||||
<option value="">{{ t('ui.translations.select_language_placeholder', 'Select language...') }}</option>
|
||||
{% for lang in metadata.suggestions.languages %}
|
||||
{% if lang not in translations %}
|
||||
<option value="{{ lang }}">{{ lang }}</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-plus-lg"></i> {{ t('ui.actions.add', 'Add') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-8">
|
||||
<div class="amb-card">
|
||||
<div class="amb-card-header">
|
||||
<h5><i class="bi bi-pencil-square"></i> <span id="editor-title">{{ t('ui.translations.editor.title', 'Translation Editor') }}</span></h5>
|
||||
<div id="editor-actions" class="amb-translation-actions" style="display: none;">
|
||||
<span id="missing-count" class="amb-pill amb-pill-warning">{{ t('ui.translations.missing_count', '{count} missing') | replace('{count}', '0') }}</span>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="TranslationEditor.fillMissing()">
|
||||
<i class="bi bi-magic"></i> {{ t('ui.translations.actions.fill_missing', 'Fill Missing') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="TranslationEditor.reset()">
|
||||
<i class="bi bi-arrow-counterclockwise"></i> {{ t('ui.actions.reset', 'Reset') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-success" onclick="TranslationEditor.save()">
|
||||
<i class="bi bi-save"></i> {{ t('ui.actions.save', 'Save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="amb-card-body">
|
||||
<div id="translation-editor-placeholder">
|
||||
{{ empty_state("translate", t('ui.translations.empty.title', 'Pick a language'), t('ui.translations.empty.body', 'Select a language from the list to start editing translations.')) }}
|
||||
</div>
|
||||
<div id="translation-editor" style="display: none;">
|
||||
<div class="amb-translation-toolbar">
|
||||
<div class="amb-translation-search">
|
||||
<input type="search" class="form-control form-control-sm" id="translation-search"
|
||||
placeholder="{{ t('ui.translations.search.placeholder', 'Search keys or text...') }}" oninput="TranslationEditor.filter(this.value)">
|
||||
</div>
|
||||
<div class="form-check form-switch amb-translation-toggle">
|
||||
<input class="form-check-input" type="checkbox" id="translation-missing-toggle" checked
|
||||
onchange="TranslationEditor.toggleMissing(this.checked)">
|
||||
<label class="form-check-label small" for="translation-missing-toggle">{{ t('ui.translations.toggle_missing', 'Show missing keys') }}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="amb-translation-add">
|
||||
<input type="text" class="form-control form-control-sm" id="new-translation-key" placeholder="{{ t('ui.translations.add.key_placeholder', 'new.key') }}">
|
||||
<input type="text" class="form-control form-control-sm" id="new-translation-value" placeholder="{{ t('ui.translations.add.value_placeholder', 'Translation text') }}">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="TranslationEditor.prefillNewValue()">
|
||||
{{ t('ui.translations.add.use_english', 'Use English') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-primary" onclick="TranslationEditor.addEntry()">
|
||||
<i class="bi bi-plus-lg"></i> {{ t('ui.translations.add.add_key', 'Add Key') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<table class="table table-sm" id="translation-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 30%;">{{ t('ui.translations.table.key', 'Key') }}</th>
|
||||
<th>{{ t('ui.translations.table.translation', 'Translation') }}</th>
|
||||
<th style="width: 70px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{%- endmacro %}
|
||||
@@ -1,505 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/atoms.html" import section_header, helper_text %}
|
||||
{% from "components/molecules.html" import card, empty_state %}
|
||||
{% from "components/organisms.html" import callout %}
|
||||
{% import "components/sections.html" as sections with context %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Dashboard Section -->
|
||||
<section id="dashboard" class="amb-section active">
|
||||
{{ section_header(t('ui.dashboard.title', 'Dashboard'), t('ui.dashboard.subtitle', 'Control the bot and monitor system activity')) }}
|
||||
|
||||
<div class="row">
|
||||
<!-- Bot Control Card -->
|
||||
<div class="col-lg-5 col-md-6">
|
||||
<div class="amb-card">
|
||||
<div class="amb-card-header">
|
||||
<h5><i class="bi bi-robot"></i> {{ t('ui.dashboard.bot_control', 'Bot Control') }}</h5>
|
||||
</div>
|
||||
<div class="amb-card-body">
|
||||
<form action="/run" method="post" id="run-form">
|
||||
<!-- Run Mode -->
|
||||
<div class="amb-form-group">
|
||||
<label class="amb-form-label">{{ t('ui.dashboard.run_strategy', 'Run Strategy') }}</label>
|
||||
<div class="amb-choice-grid">
|
||||
<label class="amb-choice-card" for="mode-once">
|
||||
<input type="radio" name="mode" id="mode-once" value="once" checked>
|
||||
<div class="amb-choice-content">
|
||||
<div class="amb-choice-title">
|
||||
<i class="bi bi-1-circle"></i> {{ t('ui.dashboard.run.single.title', 'Single Iteration') }}
|
||||
</div>
|
||||
<p class="amb-choice-text">{{ t('ui.dashboard.run.single.desc', 'One full pass through the workflow.') }}</p>
|
||||
</div>
|
||||
</label>
|
||||
<label class="amb-choice-card" for="mode-iterations">
|
||||
<input type="radio" name="mode" id="mode-iterations" value="iterations">
|
||||
<div class="amb-choice-content">
|
||||
<div class="amb-choice-title">
|
||||
<i class="bi bi-arrow-repeat"></i> {{ t('ui.dashboard.run.repeat.title', 'Repeat') }}
|
||||
</div>
|
||||
<p class="amb-choice-text">{{ t('ui.dashboard.run.repeat.desc', 'Run a fixed number of cycles.') }}</p>
|
||||
<div class="amb-choice-inline" id="iterations-group" style="display: none;">
|
||||
<span class="amb-choice-meta">{{ t('ui.dashboard.run.repeat.label', 'Iterations') }}</span>
|
||||
<input type="number" name="iterations" class="form-control form-control-sm"
|
||||
value="5" min="1" max="100">
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="amb-choice-card" for="mode-yolo">
|
||||
<input type="radio" name="mode" id="mode-yolo" value="yolo">
|
||||
<div class="amb-choice-content">
|
||||
<div class="amb-choice-title">
|
||||
<i class="bi bi-infinity"></i> {{ t('ui.dashboard.run.yolo.title', 'YOLO') }}
|
||||
</div>
|
||||
<p class="amb-choice-text">{{ t('ui.dashboard.run.yolo.desc', 'Keep iterating until you stop it.') }}</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Options -->
|
||||
<div class="amb-form-group">
|
||||
<label class="amb-form-label">{{ t('ui.dashboard.automation', 'Automation') }}</label>
|
||||
<div class="amb-toggle-stack">
|
||||
<div class="form-check form-switch amb-toggle">
|
||||
<input class="form-check-input" type="checkbox" name="yolo" id="yolo-check" value="true" checked>
|
||||
<label class="form-check-label" for="yolo-check">
|
||||
<strong>{{ t('ui.dashboard.auto_approve.title', 'Auto-approve tools') }}</strong>
|
||||
<small class="text-muted d-block">{{ t('ui.dashboard.auto_approve.desc', 'Skip confirmations so the bot can run faster.') }}</small>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check form-switch amb-toggle">
|
||||
<input class="form-check-input" type="checkbox" name="stop_at_mvp" id="mvp-check" value="true">
|
||||
<label class="form-check-label" for="mvp-check">
|
||||
<strong>{{ t('ui.dashboard.stop_mvp.title', 'Stop at MVP') }}</strong>
|
||||
<small class="text-muted d-block">{{ t('ui.dashboard.stop_mvp.desc', 'Pause automatically when the MVP milestone is reached.') }}</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="run-btn" type="submit" class="btn btn-danger w-100" {% if is_running %}disabled{% endif %}>
|
||||
<i class="bi bi-play-fill"></i> {{ t('ui.dashboard.start_bot', 'Start Bot') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Card -->
|
||||
<div class="amb-card">
|
||||
<div class="amb-card-header">
|
||||
<h5><i class="bi bi-activity"></i> {{ t('ui.dashboard.status.title', 'Status') }}</h5>
|
||||
</div>
|
||||
<div class="amb-card-body">
|
||||
<div class="d-flex flex-column gap-3">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<span>{{ t('ui.dashboard.status.bot_label', 'Bot Status') }}</span>
|
||||
<div id="status-indicator" class="amb-status {% if is_running %}amb-status-running{% else %}amb-status-idle{% endif %}">
|
||||
<span class="amb-status-dot"></span>
|
||||
{% if is_running %}{{ t('ui.dashboard.status.running', 'Running') }}{% else %}{{ t('ui.dashboard.status.idle', 'Idle') }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<span>{{ t('ui.dashboard.status.mvp_label', 'MVP Milestone') }}</span>
|
||||
<span id="mvp-badge" class="badge {% if mvp_reached %}bg-success{% else %}bg-secondary{% endif %}">
|
||||
{% if mvp_reached %}
|
||||
<i class="bi bi-check-circle-fill"></i> {{ t('ui.dashboard.status.mvp_reached', 'Reached') }}
|
||||
{% else %}
|
||||
<i class="bi bi-hourglass-split"></i> {{ t('ui.dashboard.status.mvp_progress', 'In Progress') }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div id="status-progress" class="progress" style="display: {% if is_running %}block{% else %}none{% endif %};">
|
||||
<div class="progress-bar progress-bar-striped progress-bar-animated" style="width: 100%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logs Card -->
|
||||
<div class="col-lg-7 col-md-6">
|
||||
<div class="amb-card" style="height: calc(100% - 1.5rem);">
|
||||
<div class="amb-card-header">
|
||||
<h5><i class="bi bi-terminal"></i> {{ t('ui.dashboard.logs.title', 'Recent Logs') }}</h5>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="StatusPoller.refreshLogs()" title="{{ t('ui.actions.refresh', 'Refresh') }}">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="amb-card-body p-0">
|
||||
<pre id="logs" class="amb-logs m-0" style="height: 450px;">{{ logs }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Workflow Section -->
|
||||
<section id="workflow" class="amb-section">
|
||||
{{ section_header(t('ui.workflow.title', 'Workflow Builder'), t('ui.workflow.subtitle', "Design the bot's task execution pipeline")) }}
|
||||
|
||||
<div class="amb-card">
|
||||
<div class="amb-card-header">
|
||||
<h5><i class="bi bi-diagram-3"></i> {{ t('ui.workflow.card.title', 'Tasks & Steps') }}</h5>
|
||||
<div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="WorkflowBuilder.toggleRaw()">
|
||||
<i class="bi bi-code-slash"></i> {{ t('ui.workflow.toggle_json', 'Toggle JSON') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="amb-card-body">
|
||||
<div id="workflow-builder" class="mb-3"></div>
|
||||
<form action="/workflow" method="post" id="workflow-form">
|
||||
<textarea id="workflow-content" name="content" class="form-control amb-code-editor d-none" rows="15">{{ workflow_content }}</textarea>
|
||||
<div class="d-flex gap-2 mt-3">
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="bi bi-save"></i> {{ t('ui.workflow.save', 'Save Workflow') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Prompt Section -->
|
||||
<section id="prompt" class="amb-section">
|
||||
{{ section_header(t('ui.prompt.title', 'Prompt Builder'), t('ui.prompt.subtitle', 'Shape how the assistant thinks, speaks, and decides')) }}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
{% call card(t('ui.prompt.card.title', 'Prompt Builder'), "chat-square-text") %}
|
||||
<form action="/prompt" method="post" id="prompt-form">
|
||||
<div id="prompt-builder">
|
||||
<div class="amb-prompt-step">
|
||||
<div class="amb-prompt-step-title">
|
||||
<span class="amb-step-badge">1</span> {{ t('ui.prompt.step1.title', 'Define the assistant') }}
|
||||
</div>
|
||||
{{ helper_text(t('ui.prompt.step1.desc', 'Describe who the assistant is and how it should behave.')) }}
|
||||
<textarea name="system_content" class="form-control" rows="6" id="system-prompt"
|
||||
placeholder="{{ t('ui.prompt.step1.placeholder', 'You are a senior software engineer who prefers small, safe changes.') }}">{{ prompt_content | extract_system_content }}</textarea>
|
||||
<div class="amb-chip-row">
|
||||
<button type="button" class="amb-chip" data-prompt-target="system-prompt"
|
||||
data-prompt-snippet="{{ t('ui.prompt.chip.senior.snippet', 'You are a senior software engineer focused on correctness and clarity.') }}">{{ t('ui.prompt.chip.senior.label', 'Senior engineer') }}</button>
|
||||
<button type="button" class="amb-chip" data-prompt-target="system-prompt"
|
||||
data-prompt-snippet="{{ t('ui.prompt.chip.ask.snippet', 'Ask clarifying questions before making risky changes.') }}">{{ t('ui.prompt.chip.ask.label', 'Ask questions') }}</button>
|
||||
<button type="button" class="amb-chip" data-prompt-target="system-prompt"
|
||||
data-prompt-snippet="{{ t('ui.prompt.chip.minimal.snippet', 'Prefer minimal diffs and explain trade-offs.') }}">{{ t('ui.prompt.chip.minimal.label', 'Minimal diffs') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="amb-prompt-step">
|
||||
<div class="amb-prompt-step-title">
|
||||
<span class="amb-step-badge">2</span> {{ t('ui.prompt.step2.title', 'Give the mission') }}
|
||||
</div>
|
||||
{{ helper_text(t('ui.prompt.step2.desc', 'Explain what the bot should accomplish right now.')) }}
|
||||
<textarea name="user_content" class="form-control" rows="5" id="user-prompt"
|
||||
placeholder="{{ t('ui.prompt.step2.placeholder', 'Review the repo, improve the UI, and summarize what changed.') }}">{{ prompt_content | extract_user_content }}</textarea>
|
||||
<div class="amb-chip-row">
|
||||
<button type="button" class="amb-chip" data-prompt-target="user-prompt"
|
||||
data-prompt-snippet="{{ t('ui.prompt.chip.ux.snippet', 'Focus on UX polish, and avoid major refactors.') }}">{{ t('ui.prompt.chip.ux.label', 'UX polish') }}</button>
|
||||
<button type="button" class="amb-chip" data-prompt-target="user-prompt"
|
||||
data-prompt-snippet="{{ t('ui.prompt.chip.tests.snippet', 'Add tests when possible, but avoid heavy scaffolding.') }}">{{ t('ui.prompt.chip.tests.label', 'Add tests') }}</button>
|
||||
<button type="button" class="amb-chip" data-prompt-target="user-prompt"
|
||||
data-prompt-snippet="{{ t('ui.prompt.chip.summarize.snippet', 'Summarize changes and suggest next steps.') }}">{{ t('ui.prompt.chip.summarize.label', 'Summarize') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="amb-form-group">
|
||||
<label class="amb-form-label">{{ t('ui.prompt.model.label', 'Choose a model') }}</label>
|
||||
{{ helper_text(t('ui.prompt.model.desc', 'Pick the balance of quality and speed that fits the task.')) }}
|
||||
<select name="model" class="form-select" data-choices>
|
||||
<option value="openai/gpt-4o" {% if 'gpt-4o' in prompt_content %}selected{% endif %}>GPT-4o ({{ t('ui.prompt.model.recommended', 'Recommended') }})</option>
|
||||
<option value="openai/gpt-4o-mini" {% if 'gpt-4o-mini' in prompt_content %}selected{% endif %}>GPT-4o Mini ({{ t('ui.prompt.model.faster', 'Faster') }})</option>
|
||||
<option value="openai/gpt-4-turbo" {% if 'gpt-4-turbo' in prompt_content %}selected{% endif %}>GPT-4 Turbo</option>
|
||||
<option value="anthropic/claude-3-5-sonnet" {% if 'claude' in prompt_content %}selected{% endif %}>Claude 3.5 Sonnet</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="prompt-raw" class="d-none">
|
||||
<label class="amb-form-label">{{ t('ui.prompt.raw.label', 'Advanced YAML') }}</label>
|
||||
{{ helper_text(t('ui.prompt.raw.desc', 'Edit the full YAML only if you need fine control.')) }}
|
||||
<textarea name="content" class="amb-code-editor" id="prompt-yaml" rows="18">{{ prompt_content }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<button type="submit" class="btn btn-success" onclick="buildPromptYaml()">
|
||||
<i class="bi bi-save"></i> {{ t('ui.prompt.save', 'Save Prompt') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="toggleRawPrompt()">
|
||||
<i class="bi bi-code-slash"></i> {{ t('ui.prompt.advanced_yaml', 'Advanced YAML') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
{% call card(t('ui.prompt.guidance.title', 'Guidance'), "lightbulb") %}
|
||||
<div class="amb-guidance">
|
||||
<div class="mb-3">
|
||||
<strong>{{ t('ui.prompt.guidance.keep_human.title', 'Keep it human') }}</strong>
|
||||
<p class="small text-muted mb-0">{{ t('ui.prompt.guidance.keep_human.desc', 'Write instructions the way you would brief a teammate.') }}</p>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<strong>{{ t('ui.prompt.guidance.be_specific.title', 'Be specific') }}</strong>
|
||||
<p class="small text-muted mb-0">{{ t('ui.prompt.guidance.be_specific.desc', 'Mention constraints like time, scope, or testing expectations.') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{{ t('ui.prompt.guidance.raw.title', 'Use advanced YAML sparingly') }}</strong>
|
||||
<p class="small text-muted mb-0">{{ t('ui.prompt.guidance.raw.desc', 'Only switch to raw YAML if you need full control.') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Settings Section -->
|
||||
<section id="settings" class="amb-section">
|
||||
{{ section_header(t('ui.settings.title', 'Settings'), t('ui.settings.subtitle', 'Configure services, security, and environment preferences')) }}
|
||||
|
||||
{{ callout(t('ui.settings.callout.title', 'Human-friendly settings'), t('ui.settings.callout.body', 'Each field explains what it does. Add descriptions in metadata.json to keep custom settings readable for everyone.'), "info", "info-circle") }}
|
||||
|
||||
<form action="/settings" method="post" id="settings-form">
|
||||
<div class="row">
|
||||
{% set settings_desc = metadata.get('settings_descriptions', {}) %}
|
||||
|
||||
<!-- API Keys -->
|
||||
<div class="col-lg-6">
|
||||
<div class="amb-card">
|
||||
<div class="amb-card-header">
|
||||
<h5><i class="bi bi-key"></i> {{ t('ui.settings.api_keys', 'API Keys') }}</h5>
|
||||
</div>
|
||||
<div class="amb-card-body">
|
||||
{% for key in ['GITHUB_TOKEN', 'OPENAI_API_KEY', 'LITELLM_API_KEY'] %}
|
||||
{% set desc = settings_desc.get(key, {}) %}
|
||||
<div class="amb-form-group">
|
||||
<label class="amb-form-label">
|
||||
{{ desc.get('label', key) }}
|
||||
{% if desc.get('required') %}<span class="amb-required">*</span>{% endif %}
|
||||
</label>
|
||||
<p class="text-muted small mb-2">{{ desc.get('description', '') }}</p>
|
||||
<input type="{{ desc.get('type', 'text') }}"
|
||||
name="env_{{ key }}"
|
||||
class="form-control"
|
||||
value="{{ env_vars.get(key, '') }}"
|
||||
placeholder="{{ desc.get('placeholder', '') }}">
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configuration -->
|
||||
<div class="col-lg-6">
|
||||
<div class="amb-card">
|
||||
<div class="amb-card-header">
|
||||
<h5><i class="bi bi-gear"></i> {{ t('ui.settings.configuration', 'Configuration') }}</h5>
|
||||
</div>
|
||||
<div class="amb-card-body">
|
||||
{% for key in ['GITHUB_REPOSITORY', 'LOG_LEVEL', 'APP_LANG', 'PROMPT_PATH'] %}
|
||||
{% set desc = settings_desc.get(key, {}) %}
|
||||
<div class="amb-form-group">
|
||||
<label class="amb-form-label">{{ desc.get('label', key) }}</label>
|
||||
<p class="text-muted small mb-2">{{ desc.get('description', '') }}</p>
|
||||
{% if desc.get('type') == 'select' %}
|
||||
<select name="env_{{ key }}" class="form-select" data-choices>
|
||||
<option value="">{{ t('ui.common.select_placeholder', 'Select...') }}</option>
|
||||
{% for opt in desc.get('options', []) %}
|
||||
<option value="{{ opt }}" {% if env_vars.get(key) == opt %}selected{% endif %}>{{ opt }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<input type="{{ desc.get('type', 'text') }}"
|
||||
name="env_{{ key }}"
|
||||
class="form-control"
|
||||
value="{{ env_vars.get(key, desc.get('default', '')) }}"
|
||||
placeholder="{{ desc.get('placeholder', '') }}">
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="amb-card">
|
||||
<div class="amb-card-header">
|
||||
<h5><i class="bi bi-shield-lock"></i> {{ t('ui.settings.web_access', 'Web UI Access') }}</h5>
|
||||
</div>
|
||||
<div class="amb-card-body">
|
||||
{% for key in ['WEB_USER', 'WEB_PASSWORD'] %}
|
||||
{% set desc = settings_desc.get(key, {}) %}
|
||||
<div class="amb-form-group">
|
||||
<label class="amb-form-label">{{ desc.get('label', key) }}</label>
|
||||
<p class="text-muted small mb-2">{{ desc.get('description', '') }}</p>
|
||||
<input type="{{ desc.get('type', 'text') }}"
|
||||
name="env_{{ key }}"
|
||||
class="form-control"
|
||||
value="{{ env_vars.get(key, '') }}">
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Other Settings -->
|
||||
<div class="amb-card">
|
||||
<div class="amb-card-header">
|
||||
<h5><i class="bi bi-sliders"></i> {{ t('ui.settings.other', 'Other Settings') }}</h5>
|
||||
</div>
|
||||
<div class="amb-card-body">
|
||||
<div class="row">
|
||||
{% set known_keys = ['GITHUB_TOKEN', 'OPENAI_API_KEY', 'LITELLM_API_KEY', 'GITHUB_REPOSITORY', 'LOG_LEVEL', 'APP_LANG', 'PROMPT_PATH', 'WEB_USER', 'WEB_PASSWORD'] %}
|
||||
{% for key, value in env_vars.items() %}
|
||||
{% if key not in known_keys %}
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="amb-form-group">
|
||||
{% set desc = settings_desc.get(key, {}) %}
|
||||
<label class="amb-form-label">{{ desc.get('label', key) }}</label>
|
||||
<p class="text-muted small mb-2">
|
||||
{{ desc.get('description', t('ui.settings.custom_default_desc', 'Custom environment setting. Add a description in metadata.json to show it here.')) }}
|
||||
</p>
|
||||
<input type="text" name="env_{{ key }}" class="form-control" value="{{ value }}"
|
||||
placeholder="{{ desc.get('placeholder', '') }}">
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="amb-form-group">
|
||||
<label class="amb-form-label">{{ t('ui.settings.add.title', 'Add New Setting') }}</label>
|
||||
<p class="text-muted small mb-2">{{ t('ui.settings.add.desc', 'Use uppercase keys with underscores, like API_TIMEOUT.') }}</p>
|
||||
<div class="input-group">
|
||||
<input type="text" name="new_env_key" class="form-control" placeholder="{{ t('ui.settings.add.placeholder_key', 'KEY') }}">
|
||||
<input type="text" name="new_env_value" class="form-control" placeholder="{{ t('ui.settings.add.placeholder_value', 'Value') }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="amb-card-footer">
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="bi bi-save"></i> {{ t('ui.settings.save_all', 'Save All Settings') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- Translations Section -->
|
||||
<section id="translations" class="amb-section">
|
||||
{{ section_header(t('ui.translations.title', 'Translations'), t('ui.translations.subtitle', 'Create, edit, and maintain language files for bot messages')) }}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-4">
|
||||
<div class="amb-card">
|
||||
<div class="amb-card-header">
|
||||
<h5><i class="bi bi-globe"></i> {{ t('ui.translations.languages', 'Languages') }}</h5>
|
||||
</div>
|
||||
<div class="amb-card-body p-0">
|
||||
<div class="list-group list-group-flush" id="translation-list">
|
||||
{% for lang, file in translations.items() %}
|
||||
<div class="list-group-item d-flex justify-content-between align-items-center translation-item" data-lang="{{ lang }}">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<i class="bi bi-file-text"></i>
|
||||
<div>
|
||||
<strong>{{ lang.upper() }}</strong>
|
||||
<small class="text-muted d-block">{{ file }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button type="button" class="btn btn-outline-primary" onclick="TranslationEditor.load('{{ lang }}')" title="{{ t('ui.actions.edit', 'Edit') }}">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
{% if lang != 'en' %}
|
||||
<button type="button" class="btn btn-outline-danger" onclick="TranslationEditor.delete('{{ lang }}')" title="{{ t('ui.actions.delete', 'Delete') }}">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="amb-card-footer">
|
||||
<form action="/translations" method="post" class="d-flex gap-2">
|
||||
<select name="lang" class="form-select form-select-sm" data-choices data-placeholder="{{ t('ui.translations.add_language_placeholder', 'Add language...') }}" required>
|
||||
<option value="">{{ t('ui.translations.select_language_placeholder', 'Select language...') }}</option>
|
||||
{% for lang in metadata.suggestions.languages %}
|
||||
{% if lang not in translations %}
|
||||
<option value="{{ lang }}">{{ lang }}</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-plus-lg"></i> {{ t('ui.actions.add', 'Add') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-8">
|
||||
<div class="amb-card">
|
||||
<div class="amb-card-header">
|
||||
<h5><i class="bi bi-pencil-square"></i> <span id="editor-title">{{ t('ui.translations.editor.title', 'Translation Editor') }}</span></h5>
|
||||
<div id="editor-actions" class="amb-translation-actions" style="display: none;">
|
||||
<span id="missing-count" class="amb-pill amb-pill-warning">{{ t('ui.translations.missing_count', '{count} missing') | replace('{count}', '0') }}</span>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="TranslationEditor.fillMissing()">
|
||||
<i class="bi bi-magic"></i> {{ t('ui.translations.actions.fill_missing', 'Fill Missing') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="TranslationEditor.reset()">
|
||||
<i class="bi bi-arrow-counterclockwise"></i> {{ t('ui.actions.reset', 'Reset') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-success" onclick="TranslationEditor.save()">
|
||||
<i class="bi bi-save"></i> {{ t('ui.actions.save', 'Save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="amb-card-body">
|
||||
<div id="translation-editor-placeholder">
|
||||
{{ empty_state("translate", t('ui.translations.empty.title', 'Pick a language'), t('ui.translations.empty.body', 'Select a language from the list to start editing translations.')) }}
|
||||
</div>
|
||||
<div id="translation-editor" style="display: none;">
|
||||
<div class="amb-translation-toolbar">
|
||||
<div class="amb-translation-search">
|
||||
<input type="search" class="form-control form-control-sm" id="translation-search"
|
||||
placeholder="{{ t('ui.translations.search.placeholder', 'Search keys or text...') }}" oninput="TranslationEditor.filter(this.value)">
|
||||
</div>
|
||||
<div class="form-check form-switch amb-translation-toggle">
|
||||
<input class="form-check-input" type="checkbox" id="translation-missing-toggle" checked
|
||||
onchange="TranslationEditor.toggleMissing(this.checked)">
|
||||
<label class="form-check-label small" for="translation-missing-toggle">{{ t('ui.translations.toggle_missing', 'Show missing keys') }}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="amb-translation-add">
|
||||
<input type="text" class="form-control form-control-sm" id="new-translation-key" placeholder="{{ t('ui.translations.add.key_placeholder', 'new.key') }}">
|
||||
<input type="text" class="form-control form-control-sm" id="new-translation-value" placeholder="{{ t('ui.translations.add.value_placeholder', 'Translation text') }}">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="TranslationEditor.prefillNewValue()">
|
||||
{{ t('ui.translations.add.use_english', 'Use English') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-primary" onclick="TranslationEditor.addEntry()">
|
||||
<i class="bi bi-plus-lg"></i> {{ t('ui.translations.add.add_key', 'Add Key') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<table class="table table-sm" id="translation-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 30%;">{{ t('ui.translations.table.key', 'Key') }}</th>
|
||||
<th>{{ t('ui.translations.table.translation', 'Translation') }}</th>
|
||||
<th style="width: 70px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{{ sections.dashboard_section() }}
|
||||
{{ sections.workflow_section() }}
|
||||
{{ sections.prompt_section() }}
|
||||
{{ sections.settings_section() }}
|
||||
{{ sections.translations_section() }}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
@@ -511,7 +18,19 @@
|
||||
|
||||
// Initialize workflow builder
|
||||
const stepDefinitions = {{ metadata.step_definitions | tojson | safe }};
|
||||
WorkflowBuilder.init('workflow-builder', 'workflow-content', stepDefinitions);
|
||||
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', stepDefinitions);
|
||||
}
|
||||
|
||||
try {
|
||||
initWorkflowBuilder();
|
||||
} catch (error) {
|
||||
console.error('Workflow builder failed to initialize', error);
|
||||
}
|
||||
|
||||
// Run mode toggle
|
||||
function updateIterationsGroup() {
|
||||
@@ -543,6 +62,10 @@
|
||||
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 = {
|
||||
@@ -584,6 +107,10 @@ model: ${model}
|
||||
document.getElementById('prompt-yaml').value = yaml;
|
||||
}
|
||||
|
||||
document.getElementById('prompt-form')?.addEventListener('submit', () => {
|
||||
buildPromptYaml();
|
||||
});
|
||||
|
||||
// Translation editor
|
||||
const TranslationEditor = {
|
||||
currentLang: null,
|
||||
|
||||
@@ -8,6 +8,13 @@ from autometabuilder.web.server import app
|
||||
|
||||
multiprocessing.set_start_method("spawn", force=True)
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def browser_type_launch_args():
|
||||
return {
|
||||
"chromium_sandbox": False,
|
||||
"args": ["--disable-setuid-sandbox"],
|
||||
}
|
||||
|
||||
def run_server(port):
|
||||
os.environ["MOCK_WEB_UI"] = "true"
|
||||
os.environ["WEB_USER"] = "testuser"
|
||||
|
||||
@@ -53,11 +53,11 @@ def test_update_prompt(page: Page, server: str):
|
||||
system_prompt.fill("Test system prompt")
|
||||
user_prompt.fill("Test user prompt")
|
||||
|
||||
# Click save prompt
|
||||
page.click(f"#prompt button:has-text('{t('ui.prompt.save')}')")
|
||||
# Click save prompt and wait for redirect
|
||||
with page.expect_navigation():
|
||||
page.click(f"#prompt button:has-text('{t('ui.prompt.save')}')")
|
||||
|
||||
# Verify it updated
|
||||
page.reload()
|
||||
page.click("[data-section='prompt']")
|
||||
page.wait_for_selector("#prompt.active")
|
||||
expect(page.locator("#prompt textarea[name='system_content']")).to_have_value("Test system prompt")
|
||||
@@ -172,10 +172,16 @@ def test_autocomplete_values_from_json(page: Page, server: str):
|
||||
# Choices.js creates items with data-value attribute in .choices__list--dropdown
|
||||
dropdown_items = page.locator("#translations .choices__list--dropdown .choices__item")
|
||||
item_count = dropdown_items.count()
|
||||
assert item_count >= len(metadata["suggestions"]["languages"]), f"Expected at least {len(metadata['suggestions']['languages'])} language options, found {item_count}"
|
||||
available_languages = set(metadata["suggestions"]["languages"])
|
||||
existing_languages = set(metadata.get("messages", {}).keys())
|
||||
expected_count = len(available_languages - existing_languages)
|
||||
assert item_count >= expected_count, f"Expected at least {expected_count} language options, found {item_count}"
|
||||
|
||||
# Verify at least one specific language exists
|
||||
expect(page.locator("#translations .choices__list--dropdown .choices__item[data-value='en']")).to_be_attached()
|
||||
# Verify at least one available language exists
|
||||
remaining_languages = sorted(available_languages - existing_languages)
|
||||
assert remaining_languages, "No remaining language options available"
|
||||
sample_language = remaining_languages[0]
|
||||
expect(page.locator(f"#translations .choices__list--dropdown .choices__item[data-value='{sample_language}']")).to_be_attached()
|
||||
|
||||
# Close dropdown
|
||||
page.keyboard.press("Escape")
|
||||
@@ -199,10 +205,10 @@ def test_workflow_builder_renders(page: Page, server: str):
|
||||
page.wait_for_selector("#workflow.active")
|
||||
|
||||
# Wait for workflow builder to render
|
||||
page.wait_for_selector("#workflow-builder")
|
||||
page.wait_for_selector("#workflow-builder", state="attached")
|
||||
|
||||
# Should have at least the "Add Task" button
|
||||
expect(page.locator(f"#workflow-builder button:has-text('{t('ui.workflow.add_task')}')")).to_be_visible()
|
||||
# Should have at least the primary action button
|
||||
expect(page.locator("#workflow-builder .btn.btn-primary")).to_be_visible()
|
||||
|
||||
# Toggle raw JSON should work
|
||||
page.click(f"#workflow button:has-text('{t('ui.workflow.toggle_json')}')")
|
||||
|
||||
Reference in New Issue
Block a user