Merge pull request #48 from agent0ai/development

Development
This commit is contained in:
Wabifocus 2026-03-28 19:41:43 -07:00 committed by GitHub
commit 14de2ab442
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 2452 additions and 199 deletions

View file

@ -7,58 +7,30 @@ class MissingApiKeyCheck(Extension):
"""Check if API keys are configured for selected model providers."""
LOCAL_PROVIDERS = {"ollama", "lm_studio"}
LOCAL_EMBEDDING = {"huggingface"}
CONFIGURE_MODEL_SETTINGS_LINK = (
"""<a href="#" onclick="(async()=>{"""
"""const { store: s } = await import('/components/plugins/plugin-settings-store.js');"""
"""if(s&&s.openConfig){await s.openConfig('_model_config');}"""
"""})();return false;">"""
"""Configure model settings</a>"""
"""<div class="onboarding-banner-btn-container" style="margin-top: 12px;">"""
"""<button class="btn btn-ok" onclick="window.openModal('/plugins/_onboarding/webui/onboarding.html');return false;">"""
"""Start Onboarding</button>"""
"""</div>"""
)
async def execute(self, banners: list = [], frontend_context: dict = {}, **kwargs):
cfg = plugins.get_plugin_config("_model_config") or {}
missing_providers = []
checks = [
("Chat Model", cfg.get("chat_model", {})),
("Utility Model", cfg.get("utility_model", {})),
("Embedding Model", cfg.get("embedding_model", {})),
]
missing_providers = model_config.get_missing_api_key_providers()
for label, model_cfg in checks:
provider = model_cfg.get("provider", "")
if not provider:
continue
provider_lower = provider.lower()
if provider_lower in self.LOCAL_PROVIDERS:
continue
if label == "Embedding Model" and provider_lower in self.LOCAL_EMBEDDING:
continue
if not model_config.has_provider_api_key(
provider_lower,
model_cfg.get("api_key", ""),
):
missing_providers.append({
"model_type": label,
"provider": provider,
})
if missing_providers:
model_list = ", ".join(
f"{p['model_type']} ({p['provider']})" for p in missing_providers
)
banners.append({
"id": "missing-api-key",
"type": "error",
"priority": 100,
"title": "Missing LLM API Key for current settings",
"html": f"""No API key configured for: {model_list}.<br>
Agent Zero will not be able to function properly unless you provide an API key or change your settings.<br>
"title": "Welcome to Agent Zero!",
"html": f"""You're almost ready to chat. Please configure your models to continue.<br>
Insert your API key in the onboarding wizard.
{self.CONFIGURE_MODEL_SETTINGS_LINK}""",
"dismissible": False,
"source": "backend"
"source": "backend",
# For programmatic clients (e.g. chat composer) reusing this banner pipeline
"missing_providers": missing_providers,
})
# Check preset providers for missing API keys (warning level)

View file

@ -1,4 +1,4 @@
<!-- Model Switcher - Floating Preset Selector -->
<!-- Model Switcher - Floating Preset Selector (left-aligned with inline model display) -->
<script type="module">
import { store } from "/plugins/_model_config/webui/model-config-store.js";
</script>
@ -17,12 +17,31 @@
:class="{ 'has-override': !!$store.modelConfig.switcherOverride }"
@click="showDropdown = !showDropdown"
@click.outside="showDropdown = false">
<span class="material-symbols-outlined">neurology</span>
<span class="material-symbols-outlined" style="font-size: 16px;">neurology</span>
<span class="model-switcher-label" x-text="$store.modelConfig.getSwitcherLabel()"></span>
<span class="material-symbols-outlined" style="font-size: 0.75rem;"
<span class="material-symbols-outlined" style="font-size: 0.7rem;"
x-text="showDropdown ? 'expand_less' : 'expand_more'"></span>
</button>
<!-- Inline active model pills (shown when a preset is active) -->
<template x-if="$store.modelConfig.switcherOverride">
<div class="model-switcher-active-pills">
<template x-if="$store.modelConfig.getActiveModels().main">
<div class="model-pill">
<span class="model-pill-role">Main</span>
<span class="model-pill-name" x-text="$store.modelConfig.getActiveModels().main.name"></span>
</div>
</template>
<template x-if="$store.modelConfig.getActiveModels().utility">
<div class="model-pill">
<span class="model-pill-role">Util</span>
<span class="model-pill-name" x-text="$store.modelConfig.getActiveModels().utility.name"></span>
</div>
</template>
</div>
</template>
<!-- Dropdown (opens upward) -->
<div class="model-switcher-dropdown" x-show="showDropdown" x-transition.opacity>
<!-- Use Default -->
<template x-if="$store.modelConfig.switcherOverride">
@ -99,6 +118,9 @@
}
.model-switcher-anchor {
position: relative;
display: flex;
align-items: center;
gap: 6px;
}
.model-switcher-btn {
width: auto;
@ -108,6 +130,7 @@
border: none;
font-size: 0.75rem;
white-space: nowrap;
flex-shrink: 0;
}
.model-switcher-btn:hover {
border: none;
@ -116,15 +139,53 @@
/* color: var(--color-text); */
}
.model-switcher-label {
max-width: 160px;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
}
/* Inline active model pills */
.model-switcher-active-pills {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 1;
min-width: 0;
}
.model-pill {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 12px;
background: color-mix(in srgb, var(--color-highlight) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--color-highlight) 15%, transparent);
font-size: 0.68rem;
white-space: nowrap;
min-width: 0;
overflow: hidden;
}
.model-pill-role {
font-weight: 600;
opacity: 0.55;
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.03em;
flex-shrink: 0;
}
.model-pill-name {
opacity: 0.85;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
/* Dropdown - opens upward, aligned to left */
.model-switcher-dropdown {
position: absolute;
bottom: calc(100% + 6px);
right: 0;
min-width: 250px;
left: 0;
min-width: 280px;
max-height: 550px;
overflow-y: auto;
background-color: var(--color-panel);
@ -202,4 +263,11 @@
opacity: 0.6;
font-size: 0.75rem;
}
/* Responsive: hide pills on narrow screens */
@media (max-width: 600px) {
.model-switcher-active-pills {
display: none;
}
}
</style>

View file

@ -557,6 +557,21 @@ export const store = createStore("modelConfig", {
return o.preset_name || o.name || o.provider || 'Custom';
},
getActivePreset() {
const o = this.switcherOverride;
if (!o || !o.preset_name) return null;
return this.switcherPresets.find(p => p.name === o.preset_name) || null;
},
getActiveModels() {
const preset = this.getActivePreset();
if (!preset) return { main: null, utility: null };
return {
main: preset.chat?.name ? { provider: preset.chat.provider, name: preset.chat.name } : null,
utility: preset.utility?.name ? { provider: preset.utility.provider, name: preset.utility.name } : null,
};
},
// Text conversion utilities (accessible from templates via $store.modelConfig)
textToKwargs,
textToHeaders,

View file

@ -0,0 +1,8 @@
name: _onboarding
title: Onboarding Wizard
description: Built-in onboarding wizard for configuring first-time models.
version: 1.0.0
settings_sections: []
always_enabled: true
per_project_config: false
per_agent_config: false

View file

@ -0,0 +1,101 @@
import { createStore } from "/js/AlpineStore.js";
import { store as modelConfigStore } from "/plugins/_model_config/webui/model-config-store.js";
import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
const fetchApi = globalThis.fetchApi;
export const store = createStore("onboarding", {
step: 1,
config: null,
loading: true,
async init() {
this.step = 1;
this.loading = true;
this.config = null;
},
async onOpen() {
await this.init();
await modelConfigStore.ensureLoaded();
modelConfigStore.resetApiKeyDrafts();
await modelConfigStore.refreshApiKeyStatus();
// Fetch current config
const response = await fetchApi("/plugins", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "get_config",
plugin_name: "_model_config",
project_name: "",
agent_profile: "",
}),
});
const result = await response.json().catch(() => ({}));
this.config = result.ok ? (result.data || {}) : {};
// Ensure slots exist
if (!this.config.chat_model) this.config.chat_model = { provider: "", name: "", api_key: "" };
if (!this.config.utility_model) this.config.utility_model = { provider: "", name: "", api_key: "" };
modelConfigStore.initConfigFields(this.config);
this.loading = false;
},
cleanup() {
this.step = 1;
this.config = null;
this.loading = true;
},
prev() {
if (this.step > 1) {
this.step--;
}
},
next() {
if (this.step < 3) {
this.step++;
}
},
async finish() {
this.loading = true;
try {
// Save model config
await fetchApi("/plugins", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "save_config",
plugin_name: "_model_config",
project_name: "",
agent_profile: "",
settings: this.config,
}),
});
// Save API keys
await modelConfigStore.persistApiKeysForConfig(this.config);
// Open a new chat after finishing
window.closeModal?.();
chatsStore.newChat();
} catch (e) {
console.error("Failed to finish onboarding", e);
globalThis.justToast?.("Failed to save settings", "error");
} finally {
this.loading = false;
}
},
async openAdvancedSettings() {
window.closeModal?.();
// Dynamic import since we just removed the static import to fix cyclic imports
const { store: pluginSettingsStore } = await import("/components/plugins/plugin-settings-store.js");
await pluginSettingsStore.openConfig("_model_config");
}
});

View file

@ -0,0 +1,396 @@
<html>
<head>
<title>Welcome to Agent Zero</title>
<script type="module">
import { store } from "/plugins/_onboarding/webui/onboarding-store.js";
</script>
<style>
.onboarding-logo {
text-align: center;
margin-bottom: 24px;
}
.onboarding-logo img {
width: 200px;
max-width: 100%;
height: auto;
}
.onboarding-welcome-text {
text-align: center;
margin: var(--spacing-md) 0;
color: var(--text-2);
font-size: 1.1rem;
line-height: 1.5;
}
.onboarding-welcome-title {
color: var(--text-1);
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 12px;
}
.onboarding-success {
text-align: center;
padding: 40px 0;
}
.onboarding-success-icon {
font-size: 64px;
color: var(--success, #22c55e);
margin-bottom: 24px;
}
.onboarding-success-text {
margin-bottom: 0;
}
.onboarding-advanced-link {
text-align: center;
margin-top: 32px;
padding-top: 16px;
border-top: 1px solid var(--surface-3);
}
.onboarding-advanced-link a {
color: var(--text-3);
text-decoration: none;
font-size: 0.9rem;
display: inline-flex;
align-items: center;
gap: 4px;
}
.onboarding-advanced-link a:hover {
color: var(--text-1);
}
.onboarding-advanced-link-icon {
font-size: 16px;
}
/* Scoped overrides to make the fields look nice here */
.onboarding-body .model-section {
background: var(--surface-2);
border-radius: 8px;
border: 1px solid var(--surface-3);
}
.onboarding-body .section-title { margin-bottom: 8px; }
.onboarding-body .section-description { margin-bottom: 24px; }
.onboarding-body .loading-container { height: 200px; }
.onboarding-body .input-with-icon { padding-right: 32px; }
.onboarding-body .relative-container { position: relative; }
.onboarding-footer-left { flex: 1; display: flex; gap: 8px; }
.onboarding-footer-right { display: flex; gap: 8px; }
.onboarding-icon-right { font-size: 18px; margin-left: 4px; }
.onboarding-banner-btn-container { margin-top: 12px; }
/* Same as plugins/_model_config/webui/config.html: icons sit inside padded inputs */
.onboarding-body .eye-toggle {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
font-size: 18px;
cursor: pointer;
user-select: none;
opacity: 0.6;
z-index: 1;
}
.onboarding-body .eye-toggle:hover {
opacity: 1;
}
.onboarding-body .model-search-btn {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
width: 20px;
height: 20px;
display: grid;
place-items: center;
cursor: pointer;
user-select: none;
opacity: 0.6;
z-index: 1;
}
.onboarding-body .model-search-btn:hover {
opacity: 1;
}
.onboarding-body .model-search-btn > span {
grid-area: 1 / 1;
font-size: 18px;
transition: opacity 0.15s;
}
.onboarding-body .model-search-spinner {
animation: onboarding-model-search-spin 0.8s linear infinite;
}
@keyframes onboarding-model-search-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.onboarding-body .model-search-results {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
max-height: 200px;
overflow-y: auto;
background: var(--color-input);
border: 1px solid var(--color-border);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 50;
padding: 4px;
}
.onboarding-body .model-search-item {
padding: 5px 8px;
font-size: 0.8rem;
border-radius: 4px;
cursor: pointer;
word-break: break-all;
}
.onboarding-body .model-search-item:hover {
background: var(--color-background-hover, rgba(255,255,255,0.06));
}
.onboarding-body .model-search-item.disabled {
opacity: 0.4;
cursor: default;
font-style: italic;
}
.onboarding-body .model-search-item.matched {
font-weight: 500;
}
.onboarding-body .model-search-separator {
height: 1px;
margin: 4px 8px;
background: var(--color-border);
opacity: 0.5;
}
.onboarding-body .model-search-item.disabled:hover {
background: transparent;
}
</style>
</head>
<body>
<div x-data>
<template x-if="$store.onboarding">
<div x-init="$store.onboarding.onOpen()" x-destroy="$store.onboarding.cleanup()">
<div class="modal-header">
<div class="onboarding-logo">
<img src="/public/a0-fullDark.svg" alt="Agent Zero">
</div>
</div>
<div class="modal-scroll">
<div class="modal-bd onboarding-body">
<div x-show="$store.onboarding.loading" class="loading loading-container"></div>
<template x-if="!$store.onboarding.loading && $store.onboarding.config">
<div>
<!-- Step 1: Main Model -->
<div x-show="$store.onboarding.step === 1">
<div class="onboarding-welcome-text">
<div class="onboarding-welcome-title">Welcome to Agent Zero</div>
Let's get your models configured. The <b>Main Model</b> handles chat, tool calls, skills, and browser automation.<br> We recommend a capable model like Claude Sonnet 4.6, GPT-5.4, Kimi 2.5, or similar.
</div>
<div class="model-section">
<div class="section-title" x-text="$store.modelConfig.MODEL_SECTIONS[0].title"></div>
<div class="section-description" x-text="$store.modelConfig.MODEL_SECTIONS[0].desc"></div>
<!-- Provider -->
<div class="field">
<div class="field-label">
<div class="field-title">Provider</div>
</div>
<div class="field-control">
<select x-model="$store.onboarding.config.chat_model.provider"
x-effect="$nextTick(() => { if ($store.modelConfig.getProviders('chat_model').length) $el.value = $store.onboarding.config.chat_model.provider })">
<template x-for="p in $store.modelConfig.getProviders('chat_model')" :key="p.value">
<option :value="p.value" x-text="p.label"></option>
</template>
</select>
</div>
</div>
<!-- Model search -->
<div class="field">
<div class="field-label">
<div class="field-title">Model name</div>
<div class="field-description">Model identifier. Click the search icon to browse available models.</div>
</div>
<div class="field-control relative-container"
x-data="{ results: [], open: false, searching: false,
doSearch() { this.searching = true; $store.modelConfig.searchModels($store.onboarding.config.chat_model.provider, $store.onboarding.config.chat_model.name, $store.modelConfig.getSearchType('chat_model'), $store.onboarding.config.chat_model.api_base).then(r => { this.results = r; this.open = true; }).finally(() => this.searching = false); },
grouped() { return $store.modelConfig.groupResults(this.results, $store.onboarding.config.chat_model.name); }
}"
@click.outside="open = false">
<input type="text" x-model="$store.onboarding.config.chat_model.name" class="input-with-icon" @keydown.enter.prevent="doSearch()" />
<span class="model-search-btn" @click="if (!searching) doSearch()" title="Search available models">
<span class="material-symbols-outlined" :style="searching && 'opacity:0'">search</span>
<span class="material-symbols-outlined model-search-spinner" :style="!searching && 'opacity:0'">progress_activity</span>
</span>
<div class="model-search-results" x-show="open && results.length > 0" x-transition.opacity>
<template x-for="m in grouped().matched" :key="'m_'+m">
<div class="model-search-item matched" @click="$store.onboarding.config.chat_model.name = m; open = false;" x-text="m"></div>
</template>
<div class="model-search-separator" x-show="grouped().matched.length > 0 && grouped().rest.length > 0"></div>
<template x-for="m in grouped().rest" :key="'r_'+m">
<div class="model-search-item" @click="$store.onboarding.config.chat_model.name = m; open = false;" x-text="m"></div>
</template>
</div>
<div class="model-search-results" x-show="open && results.length === 0 && !searching">
<div class="model-search-item disabled">No models found</div>
</div>
</div>
</div>
<!-- API Key -->
<div class="field">
<div class="field-label">
<div class="field-title">API key</div>
</div>
<div class="field-control relative-container" x-data="{ showKey: false }">
<input :type="showKey ? 'text' : 'password'"
x-model="$store.modelConfig.apiKeyValues[$store.onboarding.config.chat_model.provider]"
:placeholder="$store.modelConfig.apiKeyStatus[$store.onboarding.config.chat_model.provider] ? '••••••••••••' : ''"
autocomplete="off"
class="input-with-icon"
@input="$store.modelConfig.touchApiKey($store.onboarding.config.chat_model.provider)" />
<span class="material-symbols-outlined eye-toggle"
@click="
showKey = !showKey;
const prov = $store.onboarding.config.chat_model.provider;
if (showKey && !$store.modelConfig.apiKeyValues[prov] && $store.modelConfig.apiKeyStatus[prov]) {
$store.modelConfig.revealApiKey(prov).then(v => { if (v) $store.modelConfig.apiKeyValues[prov] = v; });
}
"
x-text="showKey ? 'visibility' : 'visibility_off'"></span>
</div>
</div>
</div>
</div>
<!-- Step 2: Utility Model -->
<div x-show="$store.onboarding.step === 2">
<div class="onboarding-welcome-text">
<div class="onboarding-welcome-title">Almost there!</div>
The <b>Utility Model</b> handles background tasks like summarization and memory updates.<br> A fast, cheap model like GPT-5.4-mini, Gemini 3.1 Flash Lite, or similar works best here.
</div>
<div class="model-section">
<div class="section-title" x-text="$store.modelConfig.MODEL_SECTIONS[1].title"></div>
<div class="section-description" x-text="$store.modelConfig.MODEL_SECTIONS[1].desc"></div>
<!-- Provider -->
<div class="field">
<div class="field-label">
<div class="field-title">Provider</div>
</div>
<div class="field-control">
<select x-model="$store.onboarding.config.utility_model.provider"
x-effect="$nextTick(() => { if ($store.modelConfig.getProviders('utility_model').length) $el.value = $store.onboarding.config.utility_model.provider })">
<template x-for="p in $store.modelConfig.getProviders('utility_model')" :key="p.value">
<option :value="p.value" x-text="p.label"></option>
</template>
</select>
</div>
</div>
<!-- Model search -->
<div class="field">
<div class="field-label">
<div class="field-title">Model name</div>
<div class="field-description">Model identifier. Click the search icon to browse available models.</div>
</div>
<div class="field-control relative-container"
x-data="{ results: [], open: false, searching: false,
doSearch() { this.searching = true; $store.modelConfig.searchModels($store.onboarding.config.utility_model.provider, $store.onboarding.config.utility_model.name, $store.modelConfig.getSearchType('utility_model'), $store.onboarding.config.utility_model.api_base).then(r => { this.results = r; this.open = true; }).finally(() => this.searching = false); },
grouped() { return $store.modelConfig.groupResults(this.results, $store.onboarding.config.utility_model.name); }
}"
@click.outside="open = false">
<input type="text" x-model="$store.onboarding.config.utility_model.name" class="input-with-icon" @keydown.enter.prevent="doSearch()" />
<span class="model-search-btn" @click="if (!searching) doSearch()" title="Search available models">
<span class="material-symbols-outlined" :style="searching && 'opacity:0'">search</span>
<span class="material-symbols-outlined model-search-spinner" :style="!searching && 'opacity:0'">progress_activity</span>
</span>
<div class="model-search-results" x-show="open && results.length > 0" x-transition.opacity>
<template x-for="m in grouped().matched" :key="'m_'+m">
<div class="model-search-item matched" @click="$store.onboarding.config.utility_model.name = m; open = false;" x-text="m"></div>
</template>
<div class="model-search-separator" x-show="grouped().matched.length > 0 && grouped().rest.length > 0"></div>
<template x-for="m in grouped().rest" :key="'r_'+m">
<div class="model-search-item" @click="$store.onboarding.config.utility_model.name = m; open = false;" x-text="m"></div>
</template>
</div>
<div class="model-search-results" x-show="open && results.length === 0 && !searching">
<div class="model-search-item disabled">No models found</div>
</div>
</div>
</div>
<!-- API Key -->
<div class="field">
<div class="field-label">
<div class="field-title">API key</div>
</div>
<div class="field-control relative-container" x-data="{ showKey: false }">
<input :type="showKey ? 'text' : 'password'"
x-model="$store.modelConfig.apiKeyValues[$store.onboarding.config.utility_model.provider]"
:placeholder="$store.modelConfig.apiKeyStatus[$store.onboarding.config.utility_model.provider] ? '••••••••••••' : ''"
autocomplete="off"
class="input-with-icon"
@input="$store.modelConfig.touchApiKey($store.onboarding.config.utility_model.provider)" />
<span class="material-symbols-outlined eye-toggle"
@click="
showKey = !showKey;
const prov = $store.onboarding.config.utility_model.provider;
if (showKey && !$store.modelConfig.apiKeyValues[prov] && $store.modelConfig.apiKeyStatus[prov]) {
$store.modelConfig.revealApiKey(prov).then(v => { if (v) $store.modelConfig.apiKeyValues[prov] = v; });
}
"
x-text="showKey ? 'visibility' : 'visibility_off'"></span>
</div>
</div>
</div>
</div>
<!-- Step 3: Success -->
<div x-show="$store.onboarding.step === 3" class="onboarding-success">
<div class="material-symbols-outlined onboarding-success-icon">check_circle</div>
<div class="onboarding-welcome-title">Ready to chat!</div>
<div class="onboarding-welcome-text onboarding-success-text">
Your models are configured. You can change these anytime in Settings.
</div>
</div>
<div class="onboarding-advanced-link" x-show="$store.onboarding.step < 3">
<a href="#" @click.prevent="$store.onboarding.openAdvancedSettings()">
Advanced Settings <span class="material-symbols-outlined onboarding-advanced-link-icon">arrow_drop_down</span>
</a>
</div>
</div>
</template>
</div>
</div>
<div class="modal-footer" data-modal-footer>
<div class="onboarding-footer-left">
<button class="btn btn-cancel" @click="window.closeModal()" :disabled="$store.onboarding.loading">Cancel</button>
</div>
<div class="onboarding-footer-right">
<button class="btn" x-show="$store.onboarding.step > 1" @click="$store.onboarding.prev()" :disabled="$store.onboarding.loading">
Back
</button>
<button class="btn btn-ok" x-show="$store.onboarding.step < 3" @click="$store.onboarding.next()" :disabled="$store.onboarding.loading">
Next <span class="material-symbols-outlined onboarding-icon-right">arrow_forward</span>
</button>
<button class="btn btn-ok" x-show="$store.onboarding.step === 3" @click="$store.onboarding.finish()" :disabled="$store.onboarding.loading">
Start Chatting
</button>
</div>
</div>
</div>
</template>
</div>
</body>
</html>

View file

@ -1,8 +1,7 @@
import { createStore } from "/js/AlpineStore.js";
import * as api from "/js/api.js";
import { addBlankTargetsToLinks } from "/js/messages.js";
import { openModal } from "/js/modals.js";
import { marked } from "/vendor/marked/marked.esm.js";
import { renderSafeMarkdown } from "/js/safe-markdown.js";
import { toastFrontendSuccess, toastFrontendError } from "/components/notifications/notification-store.js";
import { showConfirmDialog } from "/js/confirmDialog.js";
import { store as imageViewerStore } from "/components/modals/image-viewer/image-viewer-store.js";
@ -80,52 +79,6 @@ const model = {
return url.replace("https://github.com/", "https://raw.githubusercontent.com/");
},
_rebaseReadmeLinks(html, githubUrl, branch) {
if (!html || typeof html !== "string" || !githubUrl || !branch) return html;
let repoUrl;
try {
repoUrl = new URL(githubUrl.trim().replace(/\.git$/i, ""));
} catch {
return html;
}
if (repoUrl.hostname !== "github.com") return html;
const [owner, repo] = repoUrl.pathname
.replace(/^\/+|\/+$/g, "")
.split("/");
if (!owner || !repo) return html;
const repoBlobBase = `https://github.com/${owner}/${repo}/blob/${branch}`;
const doc = new DOMParser().parseFromString(html, "text/html");
doc.querySelectorAll("a[href]").forEach((anchor) => {
const href = (anchor.getAttribute("href") || "").trim();
if (
!href ||
href.startsWith("#") ||
href.startsWith("//") ||
/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(href)
) {
return;
}
try {
const resolved = new URL(href, "https://repo-root.invalid/");
const repoPath = resolved.pathname.replace(/^\/+/, "");
anchor.setAttribute(
"href",
`${repoBlobBase}/${repoPath}${resolved.search}${resolved.hash}`,
);
} catch {
// Leave malformed links unchanged.
}
});
return doc.body.innerHTML;
},
_pluginPrimaryTag(plugin) {
const tags = Array.isArray(plugin?.tags) ? plugin.tags.filter(Boolean) : [];
return tags[0] || "";
@ -551,9 +504,10 @@ const model = {
if (!response.ok) continue;
const readme = await response.text();
let html = marked.parse(readme, { breaks: true });
html = this._rebaseReadmeLinks(html, plugin?.github, branch);
this.readmeContent = addBlankTargetsToLinks(html);
this.readmeContent = renderSafeMarkdown(readme, {
githubUrl: plugin?.github,
branch,
});
return;
} catch (error) {
lastError = error;

View file

@ -11,11 +11,13 @@ This plugin builds a structured scanning prompt from a selectable checklist, run
- **Prompt-driven scan**
- Loads scan checks and a markdown prompt template from the plugin's `webui/` assets.
- **Temporary scan context**
- Creates a temporary chat context, sends the generated prompt as a user message, waits for the model result, and then removes the chat.
- Creates a temporary chat context, logs the generated prompt into it, starts the agent immediately, and waits for the model result.
- **Parallel-friendly execution**
- Each scan runs in its own chat context; the plugin does not serialize scans behind a "wait for another scan" queue.
- **Selectable checks**
- Supports scanning all checks by default or only the subset selected by the caller.
- **UI integration**
- Includes API endpoints and web UI files for queueing, starting, and running scans.
- Includes API endpoints and web UI files for logging the prompt, starting the scan, and running scans synchronously.
## Key Files
@ -24,8 +26,8 @@ This plugin builds a structured scanning prompt from a selectable checklist, run
- **Prompt builder**
- `helpers/prompt.py` loads check definitions and renders the final scan prompt.
- **Additional APIs**
- `api/plugin_scan_queue.py`
- `api/plugin_scan_start.py`
- `api/plugin_scan_queue.py` logs the prompt into the temporary chat.
- `api/plugin_scan_start.py` starts the agent in that chat.
## Configuration Scope

View file

@ -4,12 +4,11 @@ from helpers import message_queue as mq
class PluginScanQueue(ApiHandler):
"""Log the scan prompt into a chat. Optionally set progress to 'Queued'."""
"""Log the scan prompt into a chat before the scan starts."""
async def process(self, input: Input, request: Request) -> Output:
ctxid: str = input.get("context", "")
text: str = input.get("text", "")
queued: bool = input.get("queued", False)
if not ctxid or not text:
return Response("Missing 'context' or 'text'.", 400)
@ -20,7 +19,4 @@ class PluginScanQueue(ApiHandler):
mq.log_user_message(context, text, [])
if queued:
context.log.set_progress("icon://hourglass_empty Queued - waiting for another scan to finish", 0, True)
return {"ok": True, "context": ctxid}

View file

@ -67,10 +67,6 @@ function formatRatingIcons(ratings) {
return Object.values(ratings).map((r) => r.icon).join("/");
}
let _pollGen = 0;
/** @type {{ gen: number, ctxId: string, prompt: string }[]} */
let _queue = [];
/** @type {{ gen: number, ctxId: string } | null} */
let _running = null;
const POLL_INTERVAL = 2000;
const MAX_POLL_MS = 10 * 60 * 1000;
const SCAN_TITLE = "Plugin Scanner";
@ -86,7 +82,6 @@ export const store = createStore("pluginScan", {
prompt: "",
output: "",
scanning: false,
queued: false,
scanCtxId: "",
get renderedOutput() {
@ -105,7 +100,6 @@ export const store = createStore("pluginScan", {
async onOpen(url) {
this.output = "";
this.scanning = false;
this.queued = false;
if (url) this.gitUrl = url;
const cfg = await loadConfig();
if (cfg && Object.keys(this.checks).length === 0) {
@ -171,11 +165,7 @@ export const store = createStore("pluginScan", {
}
},
/**
* Create a context immediately and either execute or queue the scan.
* Queued scans have their prompt logged to the chat + progress bar set to "Queued",
* but the agent is NOT started until it's their turn.
*/
/** Create a fresh context, log the prompt into it, and start the scan immediately. */
async runScan() {
if (!this.gitUrl.trim()) {
void toastFrontendError("Please enter a Git URL", SCAN_TITLE);
@ -198,26 +188,15 @@ export const store = createStore("pluginScan", {
}
this.scanCtxId = ctxId;
if (_running) {
try {
await api.callJsonApi("/plugins/_plugin_scan/plugin_scan_queue", { context: ctxId, text: capturedPrompt, queued: true });
} catch { /* best-effort */ }
_queue.push({ gen, ctxId, prompt: capturedPrompt });
this.queued = true;
this.scanning = false;
} else {
try {
await api.callJsonApi("/plugins/_plugin_scan/plugin_scan_queue", { context: ctxId, text: capturedPrompt });
} catch { /* best-effort */ }
this.queued = false;
this.scanning = true;
this._runNext(gen, ctxId, capturedPrompt);
}
try {
await api.callJsonApi("/plugins/_plugin_scan/plugin_scan_queue", { context: ctxId, text: capturedPrompt });
} catch { /* best-effort */ }
this.scanning = true;
this._runNext(gen, ctxId, capturedPrompt);
},
/** @param {number} gen @param {string} ctxId @param {string} prompt */
async _runNext(gen, ctxId, prompt) {
_running = { gen, ctxId };
try {
await api.callJsonApi("/plugins/_plugin_scan/plugin_scan_start", { text: prompt, context: ctxId });
await this._pollLoop(gen, ctxId);
@ -225,19 +204,6 @@ export const store = createStore("pluginScan", {
if (gen === _pollGen) {
void toastFrontendError(`Scan failed: ${formatErrorMessage(e)}`, SCAN_TITLE);
this.scanning = false;
this.queued = false;
}
} finally {
_running = null;
while (_queue.length) {
const next = /** @type {{ gen: number, ctxId: string, prompt: string }} */ (_queue.shift());
if (!next || next.gen !== _pollGen) {
continue;
}
this.queued = false;
this.scanning = true;
this._runNext(next.gen, next.ctxId, next.prompt);
break;
}
}
},

View file

@ -40,11 +40,9 @@
<!-- Actions -->
<div class="scan-actions">
<button class="button" @click="$store.pluginScan.copyPrompt()">Copy Prompt</button>
<button class="button confirm" @click="$store.pluginScan.runScan()"
:disabled="$store.pluginScan.scanning || $store.pluginScan.queued">
<span x-show="$store.pluginScan.queued"><span class="scan-spinner"></span>Queued…</span>
<span x-show="$store.pluginScan.scanning && !$store.pluginScan.queued"><span class="scan-spinner"></span>Scanning…</span>
<span x-show="!$store.pluginScan.scanning && !$store.pluginScan.queued">Run Scan</span>
<button class="button confirm" @click="$store.pluginScan.runScan()">
<span x-show="$store.pluginScan.scanning"><span class="scan-spinner"></span>Run Another Scan</span>
<span x-show="!$store.pluginScan.scanning">Run Scan</span>
</button>
<button class="button" @click="$store.pluginScan.openChatInNewWindow()"
x-show="$store.pluginScan.scanCtxId"

View file

@ -3,6 +3,7 @@ import threading
import types
from pathlib import Path
import pytest
from flask import Flask
@ -44,6 +45,7 @@ sys.modules["watchdog.observers"] = watchdog.observers
sys.modules["watchdog.events"] = watchdog.events
from plugins._model_config.api.api_keys import ApiKeys
from plugins._model_config.extensions.python.banners import _20_missing_api_key as missing_key_banner
import models
@ -66,12 +68,29 @@ def test_model_config_api_keys_can_be_cleared_via_backend(monkeypatch, tmp_path)
assert handler._reveal_key({"provider": "openrouter"}) == {"ok": True, "value": ""}
@pytest.mark.asyncio
async def test_missing_api_key_banner_exposes_missing_providers(monkeypatch):
from plugins._model_config.helpers import model_config
fake = [{"model_type": "Chat Model", "provider": "openai"}]
monkeypatch.setattr(model_config, "get_missing_api_key_providers", lambda: fake)
banners = []
await missing_key_banner.MissingApiKeyCheck(agent=None).execute(
banners=banners, frontend_context={}
)
row = next(b for b in banners if b.get("id") == "missing-api-key")
assert row.get("missing_providers") == fake
def test_model_config_frontend_tracks_inline_api_key_edits():
store_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "model-config-store.js"
composer_store_path = PROJECT_ROOT / "webui" / "components" / "chat" / "input" / "composer-banner-store.js"
config_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "config.html"
modal_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "api-keys.html"
store_content = store_path.read_text(encoding="utf-8")
composer_store_content = composer_store_path.read_text(encoding="utf-8")
config_content = config_path.read_text(encoding="utf-8")
modal_content = modal_path.read_text(encoding="utf-8")
@ -79,6 +98,9 @@ def test_model_config_frontend_tracks_inline_api_key_edits():
assert "resetApiKeyDrafts()" in store_content
assert "!provider || seen.has(provider) || !this.apiKeyDirty[provider]" in store_content
assert "normalized[provider] = value.trim() ? value : '';" in store_content
assert '"missing-api-key"' in composer_store_content
assert 'callJsonApi("/banners"' in composer_store_content
assert "/plugins/_model_config/missing_api_key_status" not in composer_store_content
assert "$store.modelConfig.resetApiKeyDrafts();" in config_content
assert '@input="$store.modelConfig.touchApiKey(config[section.key].provider)"' in config_content
assert "updates[provider] = this.keys[provider] || '';" in modal_content

View file

@ -156,7 +156,20 @@
#chat-input::-webkit-scrollbar-thumb { background-color: rgba(155,155,155,0.5); border-radius: 6px; -webkit-transition: background-color 0.2s ease; transition: background-color 0.2s ease; }
#chat-input::-webkit-scrollbar-thumb:hover { background-color: rgba(155,155,155,0.7); }
#chat-input:focus { outline: 0.05rem solid rgba(155,155,155,0.5); font-size: 0.955rem; padding-top: 0.45rem; background-color: var(--color-input-focus); }
#chat-input::placeholder { color: var(--color-text-muted); opacity: 0.7; }
#chat-input::placeholder { color: var(--color-text-muted); opacity: 0.7; transition: color 0.3s ease; }
/* Progress ghost text animation — gentle pulse on placeholder */
#chat-input.progress-active::placeholder {
animation: placeholder-pulse 2s ease-in-out infinite;
}
@keyframes placeholder-pulse {
0%, 100% { opacity: 0.45; }
50% { opacity: 0.85; }
}
#chat-input.progress-active {
border-color: color-mix(in srgb, var(--color-highlight) 20%, transparent);
outline-color: color-mix(in srgb, var(--color-highlight) 15%, transparent);
}
#expand-button {
position: absolute;

View file

@ -3,13 +3,31 @@
<script type="module">
import { store } from "/components/chat/input/input-store.js";
import { store as messageQueueStore } from "/components/chat/message-queue/message-queue-store.js";
import { store as composerBannerStore } from "/components/chat/input/composer-banner-store.js";
</script>
</head>
<body>
<div id="input-section" x-data>
<x-extension id="chat-input-start"></x-extension>
<template x-if="$store.chatInput">
<div style="width: 100%; display: contents;">
<div style="width: 100%; display: contents;"
x-init="$store.composerBanner?.init()"
x-effect="$store.chats?.selected && $store.composerBanner?.refresh()">
<!-- Missing API keys (global model config) -->
<template x-if="$store.composerBanner && $store.composerBanner.hasMissingApiKeys">
<div class="composer-banner composer-banner--danger" role="alert">
<span class="material-symbols-outlined composer-banner-icon" aria-hidden="true">error</span>
<div class="composer-banner-text">
<span class="composer-banner-title">API key missing</span>
<span class="composer-banner-detail" x-text="$store.composerBanner.missingApiKeysSummaryText"></span>
</div>
<button type="button" class="btn btn-ok composer-banner-cta"
@click="window.openModal('/plugins/_onboarding/webui/onboarding.html')">
Insert API key
</button>
</div>
</template>
<!-- Message Queue section -->
<x-component path="chat/message-queue/message-queue.html"></x-component>
@ -46,7 +64,61 @@
@media (max-width: 768px) {
#input-section { align-items: normal !important; }
}
.composer-banner {
display: flex;
align-items: center;
gap: var(--spacing-sm);
width: 100%;
padding: var(--spacing-xs) var(--spacing-sm);
margin-bottom: var(--spacing-xxs);
background: var(--color-panel);
border: 1px solid var(--color-border);
border-radius: 6px;
box-sizing: border-box;
}
.composer-banner--danger {
border-left: 4px solid #F44336;
}
.composer-banner--danger .composer-banner-icon {
color: #F44336;
}
.composer-banner-icon {
flex-shrink: 0;
font-size: 1.25rem;
}
.composer-banner-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
text-align: left;
}
.composer-banner-title {
font-weight: 600;
font-size: 0.85rem;
color: var(--color-text);
}
.composer-banner-detail {
font-size: 0.78rem;
color: var(--color-secondary);
line-height: 1.35;
word-break: break-word;
}
.composer-banner-cta {
flex-shrink: 0;
font-size: 0.8rem;
padding: 0.35rem 0.65rem;
}
@media (max-width: 768px) {
.composer-banner {
flex-wrap: wrap;
}
.composer-banner-cta {
width: 100%;
}
}
</style>
</body>
</html>

View file

@ -0,0 +1,68 @@
import { createStore } from "/js/AlpineStore.js";
import { callJsonApi } from "/js/api.js";
function buildBannersContext() {
return {
url: window.location.href,
protocol: window.location.protocol,
hostname: window.location.hostname,
port: window.location.port,
browser: navigator.userAgent,
timestamp: new Date().toISOString(),
};
}
export const store = createStore("composerBanner", {
missingApiKeys: [],
hasMissingApiKeyBanner: false,
loading: false,
lastRefresh: 0,
_modalCloseBound: false,
get hasMissingApiKeys() {
return this.hasMissingApiKeyBanner;
},
get missingApiKeysSummaryText() {
if (!this.hasMissingApiKeys) return "";
if (!Array.isArray(this.missingApiKeys) || this.missingApiKeys.length === 0) {
return "Configure your model provider API keys to continue.";
}
return this.missingApiKeys
.map((p) => `${p.model_type} (${p.provider})`)
.join(", ");
},
init() {
if (this._modalCloseBound) return;
this._modalCloseBound = true;
document.addEventListener("modal-closed", () => {
this.refresh(true);
});
},
async refresh(force = false) {
const now = Date.now();
if (!force && now - this.lastRefresh < 1000) return;
this.lastRefresh = now;
this.loading = true;
try {
const response = await callJsonApi("/banners", {
banners: [],
context: buildBannersContext(),
});
const banners = Array.isArray(response?.banners) ? response.banners : [];
const missingApiKeyBanner = banners.find((banner) => banner?.id === "missing-api-key");
this.hasMissingApiKeyBanner = !!missingApiKeyBanner;
this.missingApiKeys = Array.isArray(missingApiKeyBanner?.missing_providers)
? missingApiKeyBanner.missing_providers
: [];
} catch (e) {
console.error("composerBanner refresh failed", e);
this.hasMissingApiKeyBanner = false;
this.missingApiKeys = [];
} finally {
this.loading = false;
}
},
});

View file

@ -10,6 +10,8 @@ const model = {
message: "",
/** Composer + menu (bottom actions moved into dropdown) */
chatMoreMenuOpen: false,
progressText: "",
progressActive: false,
toggleChatMoreMenu() {
this.chatMoreMenuOpen = !this.chatMoreMenuOpen;
@ -32,6 +34,10 @@ const model = {
get inputPlaceholder() {
const state = this._getSendState();
if (state === "all") return "Press Enter to send queued messages";
// Show progress as ghost text when agent is working and input is empty
if (this.progressText && !this.message) {
return "|> " + this.progressText;
}
return "Type your message here...";
},

View file

@ -8,9 +8,10 @@
<body>
<div id="progress-bar-box" x-data>
<x-extension id="chat-input-progress-start"></x-extension>
<h4 id="progress-bar-h">
<span id="progress-bar-i">|></span><span id="progress-bar"></span>
</h4>
<!-- Hidden legacy progress-bar element (keeps JS updater happy, not displayed) -->
<span id="progress-bar" style="display:none;"></span>
<div id="progress-bar-right">
<h4 id="progress-bar-stop-speech" x-data x-cloak x-show="$store.speech.isSpeaking">
<span id="stop-speech" @click="$store.speech.stop()" style="cursor: pointer" title="Stop Speech" aria-label="Stop Speech">
@ -38,12 +39,10 @@
<style>
#progress-bar-box { background-color: var(--color-panel); padding: var(--spacing-sm) var(--spacing-md) var(--spacing-xs) var(--spacing-md); display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; z-index: 1001; gap: var(--spacing-sm); }
#progress-bar-box { background-color: var(--color-panel); padding: var(--spacing-xs) var(--spacing-md) 0 var(--spacing-md); display: flex; flex-wrap: nowrap; justify-content: flex-start; align-items: center; z-index: 1001; gap: var(--spacing-sm); min-height: 0; }
#progress-bar-box > x-extension { display: contents; }
#progress-bar-box h4 { margin: 0 1.2em 0 0; }
#progress-bar-h { color: var(--color-primary); display: flex; align-items: left; justify-content: flex-start; height: 1.2em; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; font-weight: normal; }
#progress-bar-i { font-weight: bold; padding-right: 0.5em; color: var(--color-secondary); }
#progress-bar-right { display: flex; align-items: center; gap: var(--spacing-sm); flex-shrink: 0; }
#progress-bar-box h4 { margin: 0; }
#progress-bar-right { display: flex; align-items: center; gap: var(--spacing-sm); flex-shrink: 0; margin-left: auto; margin-right: calc(5.5rem + var(--spacing-xs)); }
#progress-bar-right > x-extension { display: contents; }
#chat-nav-buttons { display: flex; align-items: center; gap: 2px; opacity: 0.85; }
#chat-nav-buttons .btn-icon-action { padding: var(--spacing-xs); }
@ -52,8 +51,6 @@
}
.shiny-text { background: linear-gradient(to right, var(--color-primary-dark) 20%, var(--color-text) 40%, var(--color-text) 60%, var(--color-primary-dark) 60%); background-size: 200% auto; color: transparent; -webkit-background-clip: text; background-clip: text; animation: shine 1s linear infinite; }
@keyframes shine { to { background-position: -200% center; } }
.light-mode #progress-bar-i { color: var(--color-border-dark); opacity: 0.5; }
</style>
</body>
</html>

View file

@ -1,5 +1,5 @@
import { marked } from "/vendor/marked/marked.esm.js";
import { createStore } from "/js/AlpineStore.js";
import { renderSafeMarkdown } from "/js/safe-markdown.js";
export const store = createStore("markdownModal", {
title: "",
@ -14,7 +14,7 @@ export const store = createStore("markdownModal", {
get renderedHtml() {
if (!this.content) return "";
return marked.parse(this.content, { breaks: true });
return renderSafeMarkdown(this.content);
},
cleanup() {

View file

@ -1,7 +1,6 @@
import { createStore } from "/js/AlpineStore.js";
import * as api from "/js/api.js";
import { marked } from "/vendor/marked/marked.esm.js";
import { addBlankTargetsToLinks } from "/js/messages.js";
import { renderSafeMarkdown } from "/js/safe-markdown.js";
import { store as pluginSettingsStore } from "/components/plugins/plugin-settings-store.js";
import { store as pluginToggleStore } from "/components/plugins/toggle/plugin-toggle-store.js";
import { store as pluginExecuteStore } from "/components/plugins/list/plugin-execute-store.js";
@ -167,8 +166,7 @@ const model = {
doc: "readme",
});
if (response?.error) throw new Error(response.error);
const html = marked.parse(response.content || "", { breaks: true });
this.readmeContent = addBlankTargetsToLinks(html);
this.readmeContent = renderSafeMarkdown(response.content || "");
} catch (e) {
const error = e instanceof Error ? e : new Error(String(e));
this.readmeError = error.message || "Failed to load README";

View file

@ -481,16 +481,33 @@ function speakMessages(logs) {
}
function updateProgress(progress, active) {
const progressBarEl = document.getElementById("progress-bar");
if (!progressBarEl) return;
if (!progress) progress = "";
setProgressBarShine(progressBarEl, active);
// Strip HTML tags for plain-text placeholder use
const plainText = progress.replace(/<[^>]*>/g, "").trim();
progress = msgs.convertIcons(progress);
// Update the input store so the placeholder reflects progress
inputStore.progressText = plainText;
inputStore.progressActive = !!active;
if (progressBarEl.innerHTML != progress) {
progressBarEl.innerHTML = progress;
// Apply shimmer class to the textarea when active
const chatInputEl = document.getElementById("chat-input");
if (chatInputEl) {
if (active && plainText) {
addClassToElement(chatInputEl, "progress-active");
} else {
removeClassFromElement(chatInputEl, "progress-active");
}
}
// Also update legacy progress bar element if it still exists
const progressBarEl = document.getElementById("progress-bar");
if (progressBarEl) {
setProgressBarShine(progressBarEl, active);
const html = msgs.convertIcons(progress);
if (progressBarEl.innerHTML != html) {
progressBarEl.innerHTML = html;
}
}
}

27
webui/js/html-links.js Normal file
View file

@ -0,0 +1,27 @@
export function addBlankTargetsToLinks(str) {
const doc = new DOMParser().parseFromString(str, "text/html");
doc.querySelectorAll("a").forEach((anchor) => {
const href = anchor.getAttribute("href") || "";
if (
href.startsWith("#") ||
href.trim().toLowerCase().startsWith("javascript")
) {
return;
}
if (
!anchor.hasAttribute("target") ||
anchor.getAttribute("target") === ""
) {
anchor.setAttribute("target", "_blank");
}
const rel = (anchor.getAttribute("rel") || "").split(/\s+/).filter(Boolean);
if (!rel.includes("noopener")) rel.push("noopener");
if (!rel.includes("noreferrer")) rel.push("noreferrer");
anchor.setAttribute("rel", rel.join(" "));
});
return doc.body.innerHTML;
}

View file

@ -13,6 +13,7 @@ import { store as preferencesStore } from "/components/sidebar/bottom/preference
import { formatDuration } from "./time-utils.js";
import { Scroller } from "./scroller.js";
import { callJsExtensions } from "/js/extensions.js";
import { addBlankTargetsToLinks } from "/js/html-links.js";
// Delay before collapsing previous steps when a new step is added
const STEP_COLLAPSE_DELAY = {
@ -762,30 +763,7 @@ export function _drawMessage({
return messageDiv;
}
export function addBlankTargetsToLinks(str) {
const doc = new DOMParser().parseFromString(str, "text/html");
doc.querySelectorAll("a").forEach((anchor) => {
const href = anchor.getAttribute("href") || "";
if (
href.startsWith("#") ||
href.trim().toLowerCase().startsWith("javascript")
)
return;
if (
!anchor.hasAttribute("target") ||
anchor.getAttribute("target") === ""
) {
anchor.setAttribute("target", "_blank");
}
const rel = (anchor.getAttribute("rel") || "").split(/\s+/).filter(Boolean);
if (!rel.includes("noopener")) rel.push("noopener");
if (!rel.includes("noreferrer")) rel.push("noreferrer");
anchor.setAttribute("rel", rel.join(" "));
});
return doc.body.innerHTML;
}
export { addBlankTargetsToLinks };
/**
* @param {MessageHandlerArgs & Record<string, any>} param0

182
webui/js/safe-markdown.js Normal file
View file

@ -0,0 +1,182 @@
import DOMPurify from "/vendor/dompurify/purify.es.mjs";
import { marked } from "/vendor/marked/marked.esm.js";
import { addBlankTargetsToLinks } from "/js/html-links.js";
const GITHUB_REPO_ROUTE_PREFIXES = new Set([
"actions",
"blob",
"branches",
"commit",
"commits",
"compare",
"discussions",
"issues",
"labels",
"milestones",
"packages",
"projects",
"pulls",
"raw",
"releases",
"security",
"tags",
"tree",
"wiki",
]);
const DOMPURIFY_CONFIG = Object.freeze({
USE_PROFILES: { html: true },
FORBID_TAGS: ["script", "iframe", "object", "embed", "svg", "math"],
});
function parseGithubRepoContext(githubUrl) {
if (!githubUrl || typeof githubUrl !== "string") return null;
let repoUrl;
try {
repoUrl = new URL(githubUrl.trim().replace(/\.git$/i, ""));
} catch {
return null;
}
if (repoUrl.hostname !== "github.com") return null;
const [owner, repo] = repoUrl.pathname
.replace(/^\/+|\/+$/g, "")
.split("/");
if (!owner || !repo) return null;
return { owner, repo };
}
function shouldSkipRebase(value) {
return (
!value ||
value.startsWith("#") ||
value.startsWith("//") ||
/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(value)
);
}
function resolveRepoPath(value) {
if (shouldSkipRebase(value)) return null;
try {
const resolved = new URL(value, "https://repo-root.invalid/");
return `${resolved.pathname.replace(/^\/+/, "")}${resolved.search}${resolved.hash}`;
} catch {
return null;
}
}
function isGithubRepoRoutePath(repoPath) {
const pathOnly = repoPath
.split(/[?#]/, 1)[0]
.replace(/^\/+|\/+$/g, "");
if (!pathOnly) return false;
const firstSegment = pathOnly.split("/")[0].toLowerCase();
return GITHUB_REPO_ROUTE_PREFIXES.has(firstSegment);
}
function isSafeUrlValue(value, attributeName) {
const normalized = String(value || "").trim();
if (!normalized) return true;
if (
normalized.startsWith("#") ||
normalized.startsWith("/") ||
normalized.startsWith("./") ||
normalized.startsWith("../") ||
normalized.startsWith("?")
) {
return true;
}
try {
const url = new URL(normalized, "https://sanitizer.invalid/");
if (url.origin === "https://sanitizer.invalid") {
return true;
}
const protocol = url.protocol.toLowerCase();
if (protocol === "http:" || protocol === "https:") return true;
if (attributeName === "href" && (protocol === "mailto:" || protocol === "tel:")) {
return true;
}
} catch {
return false;
}
return false;
}
function stripUnsafeUrlAttributes(html) {
const doc = new DOMParser().parseFromString(html, "text/html");
doc.querySelectorAll("[href], [src]").forEach((element) => {
for (const attributeName of ["href", "src"]) {
if (!element.hasAttribute(attributeName)) continue;
const value = element.getAttribute(attributeName) || "";
if (!isSafeUrlValue(value, attributeName)) {
element.removeAttribute(attributeName);
}
}
});
return doc.body.innerHTML;
}
export function sanitizeHtml(html) {
if (!html || typeof html !== "string") return "";
const sanitized = DOMPurify.sanitize(html, DOMPURIFY_CONFIG);
return stripUnsafeUrlAttributes(sanitized);
}
export function rebaseGithubReadmeHtml(html, githubUrl, branch) {
if (!html || typeof html !== "string" || !branch) return html;
const repoContext = parseGithubRepoContext(githubUrl);
if (!repoContext) return html;
const { owner, repo } = repoContext;
const repoWebBase = `https://github.com/${owner}/${repo}`;
const repoBlobBase = `${repoWebBase}/blob/${branch}`;
const repoRawBase = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}`;
const doc = new DOMParser().parseFromString(html, "text/html");
// Single-segment links like "releases" are ambiguous, so README rebasing
// needs an explicit GitHub repo-route allowlist instead of a single base URL.
doc.querySelectorAll("a[href]").forEach((anchor) => {
const href = (anchor.getAttribute("href") || "").trim();
const repoPath = resolveRepoPath(href);
if (!repoPath) return;
const base = isGithubRepoRoutePath(repoPath) ? repoWebBase : repoBlobBase;
anchor.setAttribute("href", `${base}/${repoPath}`);
});
doc.querySelectorAll("img[src]").forEach((image) => {
const src = (image.getAttribute("src") || "").trim();
const repoPath = resolveRepoPath(src);
if (!repoPath) return;
image.setAttribute("src", `${repoRawBase}/${repoPath}`);
});
return doc.body.innerHTML;
}
export function renderSafeMarkdown(markdown, options = {}) {
if (!markdown) return "";
const { githubUrl = "", branch = "", openExternalLinksInNewTab = true } = options;
let html = marked.parse(markdown, { breaks: true });
if (githubUrl && branch) {
html = rebaseGithubReadmeHtml(html, githubUrl, branch);
}
html = sanitizeHtml(html);
if (openExternalLinksInNewTab) {
html = addBlankTargetsToLinks(html);
}
return html;
}

1397
webui/vendor/dompurify/purify.es.mjs vendored Normal file

File diff suppressed because it is too large Load diff