mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-04-28 03:30:23 +00:00
commit
14de2ab442
24 changed files with 2452 additions and 199 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
8
plugins/_onboarding/plugin.yaml
Normal file
8
plugins/_onboarding/plugin.yaml
Normal 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
|
||||
101
plugins/_onboarding/webui/onboarding-store.js
Normal file
101
plugins/_onboarding/webui/onboarding-store.js
Normal 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");
|
||||
}
|
||||
});
|
||||
396
plugins/_onboarding/webui/onboarding.html
Normal file
396
plugins/_onboarding/webui/onboarding.html
Normal 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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
68
webui/components/chat/input/composer-banner-store.js
Normal file
68
webui/components/chat/input/composer-banner-store.js
Normal 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;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
@ -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...";
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
27
webui/js/html-links.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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
182
webui/js/safe-markdown.js
Normal 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
1397
webui/vendor/dompurify/purify.es.mjs
vendored
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue