mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-07-09 17:08:29 +00:00
Add deferred first-send model gate
Move missing-model setup out of the welcome screen and into an in-thread gate that preserves the pending prompt, survives refresh, and auto-dispatches after a usable model is configured. Reuse existing onboarding, OAuth account settings, and advanced model configuration surfaces instead of duplicating provider forms. Connected OAuth accounts can now populate Main and Utility defaults when no chat model is configured. Update model readiness checks, focused static regressions, and DOX contracts for the new deferred setup flow.
This commit is contained in:
parent
038f88848d
commit
04ecaac0cd
23 changed files with 695 additions and 285 deletions
|
|
@ -16,6 +16,7 @@
|
|||
- CTA actions must match supported welcome-screen action contracts.
|
||||
- Keep card IDs unique and plugin-prefixed.
|
||||
- The Welcome screen `welcome-actions-end` surface renders feature channel cards plus the compact OAuth account-provider card; other hero discovery cards stay out of the lower welcome grid.
|
||||
- Do not hide discovery cards solely because model setup is incomplete; model setup gating belongs to the chat thread.
|
||||
|
||||
## Work Guidance
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
<div class="discovery-slot"
|
||||
@modal-closed.window="$store.discoveryStore.refreshCards()"
|
||||
x-create="$store.discoveryStore.refreshCards()"
|
||||
x-show="!($store.welcomeStore?.banners || []).some(b => b.id === 'missing-api-key') && ($store.discoveryStore.featureCards.length > 0 || $store.discoveryStore.oauthAccountCards.length > 0 || $store.discoveryStore.hasDismissedCards)">
|
||||
x-show="$store.discoveryStore.featureCards.length > 0 || $store.discoveryStore.oauthAccountCards.length > 0 || $store.discoveryStore.hasDismissedCards">
|
||||
|
||||
<section class="discovery-features-panel" x-show="$store.discoveryStore.featureCards.length > 0">
|
||||
<header class="discovery-panel-header">
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
- Project Settings `llm` payloads are owned here through the generic `helpers.projects` project extension-data hooks; keep project helper code agnostic to `_model_config` paths, presets, and inheritance rules.
|
||||
- Keep provider metadata and API-key checks safe around secrets.
|
||||
- Coordinate OAuth-backed providers with `_oauth` instead of hardcoding provider-specific auth here.
|
||||
- `model_config_get` exposes `model_configured` as a derived chat-model readiness flag from provider, model name, and API-key availability.
|
||||
- Applying a model preset may inherit durable tuning such as context windows and rate limits, but must replace or clear per-slot `kwargs` so provider-specific extra params never leak across model providers.
|
||||
- Repair provider-specific model-config aliases at the model-config read/build boundary; keep provider-specific repairs out of provider-agnostic core wrappers such as `models.py`.
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,10 @@ class ModelConfigGet(ApiHandler):
|
|||
key = models.get_api_key(pid)
|
||||
api_key_status[pid] = bool(key and key.strip() and key != "None")
|
||||
|
||||
chat_model = config.get("chat_model", {}) if isinstance(config, dict) else {}
|
||||
chat_provider = str(chat_model.get("provider") or "").strip()
|
||||
chat_name = str(chat_model.get("name") or "").strip()
|
||||
|
||||
return {
|
||||
"config": config,
|
||||
"chat_providers": chat_providers,
|
||||
|
|
@ -43,4 +47,8 @@ class ModelConfigGet(ApiHandler):
|
|||
"chat_provider_details": chat_provider_details,
|
||||
"embedding_provider_details": embedding_provider_details,
|
||||
"api_key_status": api_key_status,
|
||||
"model_configured": model_config.is_chat_model_configured(config),
|
||||
"model_configured_label": " / ".join(
|
||||
part for part in (chat_provider, chat_name) if part
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,10 +26,6 @@ class MissingApiKeyCheck(Extension):
|
|||
"cta_action": f"open-modal:{self.ONBOARDING_MODAL_PATH}",
|
||||
"dismissible": False,
|
||||
"source": "backend",
|
||||
"auto_modal_path": self.ONBOARDING_MODAL_PATH,
|
||||
"auto_modal_reason": "missing-api-key",
|
||||
"auto_modal_priority": 100,
|
||||
"auto_modal_surfaces": ["welcome", "chat-created"],
|
||||
# For programmatic clients (e.g. chat composer) reusing this banner pipeline
|
||||
"missing_providers": missing_providers,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -671,3 +671,13 @@ def get_missing_api_key_providers(agent=None) -> list[dict]:
|
|||
missing.append({"model_type": label, "provider": provider})
|
||||
|
||||
return missing
|
||||
|
||||
|
||||
def is_chat_model_configured(config: dict | None = None) -> bool:
|
||||
cfg = config if isinstance(config, dict) else get_config()
|
||||
chat_cfg = cfg.get("chat_model", {}) if isinstance(cfg, dict) else {}
|
||||
provider = str(chat_cfg.get("provider") or "").strip()
|
||||
name = str(chat_cfg.get("name") or "").strip()
|
||||
if not provider or not name:
|
||||
return False
|
||||
return has_provider_api_key(provider.lower(), chat_cfg.get("api_key", ""), "chat")
|
||||
|
|
|
|||
|
|
@ -158,6 +158,8 @@ export const store = createStore("modelConfig", {
|
|||
embeddingProviders: [],
|
||||
chatProviderDetails: [],
|
||||
embeddingProviderDetails: [],
|
||||
modelConfigured: false,
|
||||
modelConfiguredLabel: "",
|
||||
_loaded: false,
|
||||
|
||||
// API Keys state (from mixin)
|
||||
|
|
@ -203,6 +205,8 @@ export const store = createStore("modelConfig", {
|
|||
this.chatProviderDetails = data.chat_provider_details || [];
|
||||
this.embeddingProviderDetails = data.embedding_provider_details || [];
|
||||
this.apiKeyStatus = data.api_key_status || {};
|
||||
this.modelConfigured = !!data.model_configured;
|
||||
this.modelConfiguredLabel = data.model_configured_label || "";
|
||||
const keys = {};
|
||||
const dirty = {};
|
||||
const seen = new Set();
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@
|
|||
- OAuth settings pending-auth controls such as device codes, manual callback input, and provider setup fields must render inline under the relevant provider row, not as a detached section below all providers.
|
||||
- OAuth device-code polling must honor provider `interval`, `expires_at`, and `slow_down` updates; do not poll immediately or keep a stale fixed interval after a provider asks the client to slow down.
|
||||
- OAuth settings model slots must keep provider choice editable per slot, list only connected OAuth account providers, and persist the selected provider IDs into `chat_model.provider` and `utility_model.provider`.
|
||||
- The OAuth WebUI store owns connected-provider default selection helpers reused by settings and deferred chat setup gates.
|
||||
- OAuth settings must dispatch `model-configured` when a provider connection completes. If no chat model is configured, the settings flow may apply the connected provider defaults to Main and Utility before notifying deferred send gates.
|
||||
- `helpers/providers/registry.py` is the source of truth for connectable OAuth providers.
|
||||
- OAuth provider config must not expose the dummy `oauth` API key in `conf/model_providers.yaml`; the dummy key is a runtime-only shim supplied by the `get_api_key` extension after the account provider reports connected.
|
||||
- Usage-plan metadata belongs only to connectable providers. Do not add metadata-only subscription families for providers this plugin cannot connect.
|
||||
|
|
|
|||
|
|
@ -287,6 +287,15 @@ export const store = createStore("oauthConfig", {
|
|||
return status.display_name || providerId;
|
||||
},
|
||||
|
||||
providerDefaultModel(providerId, slotKey = "chat_model") {
|
||||
const status = this.providerStatus(providerId);
|
||||
const defaults = Array.isArray(status.default_models)
|
||||
? status.default_models.map((model) => String(model || "").trim()).filter(Boolean)
|
||||
: [];
|
||||
if (slotKey === "utility_model" && defaults[1]) return defaults[1];
|
||||
return String(status.default_model || defaults[0] || "").trim();
|
||||
},
|
||||
|
||||
providerUseLabel(providerId) {
|
||||
const status = this.providerStatus(providerId);
|
||||
return status.use_label || `Use ${status.short_name || status.display_name || providerId}`;
|
||||
|
|
@ -670,6 +679,61 @@ export const store = createStore("oauthConfig", {
|
|||
}
|
||||
},
|
||||
|
||||
async currentChatModelConfigured() {
|
||||
try {
|
||||
const response = await callJsonApi(`${MODEL_CONFIG_API}/model_config_get`, {});
|
||||
return Boolean(response?.model_configured);
|
||||
} catch (error) {
|
||||
console.error("Could not check model configuration:", error);
|
||||
return true;
|
||||
}
|
||||
},
|
||||
|
||||
async autoApplyConnectedProviderIfNeeded(providerId) {
|
||||
if (!this.providerConnected(providerId)) return false;
|
||||
if (await this.currentChatModelConfigured()) return false;
|
||||
|
||||
await this.loadModelConfig();
|
||||
if (!this.modelConfig) return false;
|
||||
|
||||
const chatModel = this.providerDefaultModel(providerId, "chat_model");
|
||||
if (!chatModel) return false;
|
||||
const utilityModel = this.providerDefaultModel(providerId, "utility_model") || chatModel;
|
||||
const chat = this.modelSlot("chat_model");
|
||||
const utility = this.modelSlot("utility_model");
|
||||
chat.provider = providerId;
|
||||
chat.name = chatModel;
|
||||
chat.api_base = "";
|
||||
if (!chat.kwargs || typeof chat.kwargs !== "object") chat.kwargs = {};
|
||||
utility.provider = providerId;
|
||||
utility.name = utilityModel;
|
||||
utility.api_base = "";
|
||||
if (!utility.kwargs || typeof utility.kwargs !== "object") utility.kwargs = {};
|
||||
this.activeModelProvider = providerId;
|
||||
this.models = this.activeProviderModels();
|
||||
this.modelConfigDirty = true;
|
||||
this.modelSlotDirty = { chat_model: true, utility_model: true };
|
||||
await this.saveModelConfigIfDirty();
|
||||
return true;
|
||||
},
|
||||
|
||||
notifyModelConfigured(providerId) {
|
||||
if (typeof document === "undefined") return;
|
||||
document.dispatchEvent(new CustomEvent("model-configured", {
|
||||
detail: { source: "_oauth", providerId },
|
||||
}));
|
||||
},
|
||||
|
||||
async handleProviderConnected(providerId, { statusLoaded = false } = {}) {
|
||||
if (!statusLoaded) await this.loadStatus();
|
||||
try {
|
||||
await this.autoApplyConnectedProviderIfNeeded(providerId);
|
||||
} catch (error) {
|
||||
void toastFrontendError(messageOf(error), "OAuth Connections");
|
||||
}
|
||||
this.notifyModelConfigured(providerId);
|
||||
},
|
||||
|
||||
async loadStatus() {
|
||||
if (this.loadingStatus) return;
|
||||
this.loadingStatus = true;
|
||||
|
|
@ -788,7 +852,7 @@ export const store = createStore("oauthConfig", {
|
|||
throw new Error(response?.error || `Could not finish ${this.providerLabel(providerId)} sign-in.`);
|
||||
}
|
||||
if (response.completed) {
|
||||
await this.loadStatus();
|
||||
await this.handleProviderConnected(providerId);
|
||||
this.clearProviderDevice(providerId);
|
||||
this.stopPolling(providerId);
|
||||
if (this.connectingProvider === providerId) this.connectingProvider = "";
|
||||
|
|
@ -840,6 +904,7 @@ export const store = createStore("oauthConfig", {
|
|||
const tick = async () => {
|
||||
await this.loadStatus();
|
||||
if (this.providerConnected(providerId)) {
|
||||
await this.handleProviderConnected(providerId, { statusLoaded: true });
|
||||
this.clearProviderDevice(providerId);
|
||||
this.stopCallbackPolling(providerId);
|
||||
if (this.connectingProvider === providerId) this.connectingProvider = "";
|
||||
|
|
@ -932,7 +997,7 @@ export const store = createStore("oauthConfig", {
|
|||
this.providerUiFor(providerId).manualCallback = "";
|
||||
this.clearProviderDevice(providerId);
|
||||
this.stopCallbackPolling(providerId);
|
||||
await this.loadStatus();
|
||||
await this.handleProviderConnected(providerId);
|
||||
void toastFrontendSuccess(`${this.providerLabel(providerId)} connected.`, "OAuth Connections");
|
||||
} catch (error) {
|
||||
void toastFrontendError(messageOf(error), "OAuth Connections");
|
||||
|
|
|
|||
|
|
@ -69,6 +69,25 @@ def test_model_config_api_keys_can_be_cleared_via_backend(monkeypatch, tmp_path)
|
|||
assert handler._reveal_key({"provider": "openrouter"}) == {"ok": True, "value": ""}
|
||||
|
||||
|
||||
def test_chat_model_configured_requires_identity_and_key(monkeypatch):
|
||||
from plugins._model_config.helpers import model_config
|
||||
|
||||
monkeypatch.setattr(
|
||||
model_config,
|
||||
"has_provider_api_key",
|
||||
lambda provider, configured_api_key="", model_type="chat": provider == "openrouter",
|
||||
)
|
||||
|
||||
assert not model_config.is_chat_model_configured({"chat_model": {}})
|
||||
assert not model_config.is_chat_model_configured({"chat_model": {"provider": "openrouter"}})
|
||||
assert model_config.is_chat_model_configured(
|
||||
{"chat_model": {"provider": "openrouter", "name": "anthropic/claude"}}
|
||||
)
|
||||
assert not model_config.is_chat_model_configured(
|
||||
{"chat_model": {"provider": "openai", "name": "gpt-5"}}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_api_key_banner_exposes_missing_providers(monkeypatch):
|
||||
from plugins._model_config.helpers import model_config
|
||||
|
|
@ -90,7 +109,7 @@ async def test_missing_api_key_banner_exposes_missing_providers(monkeypatch):
|
|||
def test_model_config_frontend_tracks_inline_api_key_edits():
|
||||
store_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "model-config-store.js"
|
||||
api_keys_mixin_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "api-keys-mixin.js"
|
||||
composer_store_path = PROJECT_ROOT / "webui" / "components" / "chat" / "input" / "composer-banner-store.js"
|
||||
model_gate_path = PROJECT_ROOT / "webui" / "components" / "chat" / "model-gate-store.js"
|
||||
config_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "config.html"
|
||||
model_field_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "model-field.html"
|
||||
modal_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "api-keys.html"
|
||||
|
|
@ -100,7 +119,7 @@ def test_model_config_frontend_tracks_inline_api_key_edits():
|
|||
+ "\n"
|
||||
+ api_keys_mixin_path.read_text(encoding="utf-8")
|
||||
)
|
||||
composer_store_content = composer_store_path.read_text(encoding="utf-8")
|
||||
model_gate_content = model_gate_path.read_text(encoding="utf-8")
|
||||
config_content = (
|
||||
config_path.read_text(encoding="utf-8")
|
||||
+ "\n"
|
||||
|
|
@ -112,9 +131,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 'callJsonApi("/plugins/_model_config/model_config_get"' in model_gate_content
|
||||
assert "dispatchPendingIfConfigured()" in model_gate_content
|
||||
assert "/plugins/_model_config/missing_api_key_status" not in model_gate_content
|
||||
assert "$store.modelConfig.resetApiKeyDrafts();" in config_content
|
||||
assert '@input="$store.modelConfig.setApiKeyValue(_prov, $el.value)"' in config_content
|
||||
assert "persistAllDirtyApiKeys()" in modal_content
|
||||
|
|
@ -251,7 +270,7 @@ def test_ollama_cloud_provider_config_requires_key_and_base_url():
|
|||
assert "api_key_mode" not in ollama_cloud
|
||||
|
||||
|
||||
def test_missing_api_key_banner_includes_auto_modal_metadata(monkeypatch):
|
||||
def test_missing_api_key_banner_does_not_include_auto_modal_metadata(monkeypatch):
|
||||
from plugins._model_config.helpers import model_config
|
||||
|
||||
fake = [{"model_type": "Chat Model", "provider": "openai"}]
|
||||
|
|
@ -267,9 +286,10 @@ def test_missing_api_key_banner_includes_auto_modal_metadata(monkeypatch):
|
|||
import asyncio
|
||||
row = asyncio.run(run())
|
||||
|
||||
assert row["auto_modal_path"] == "/plugins/_onboarding/webui/onboarding.html"
|
||||
assert row["auto_modal_reason"] == "missing-api-key"
|
||||
assert row["auto_modal_priority"] == 100
|
||||
assert "auto_modal_path" not in row
|
||||
assert "auto_modal_reason" not in row
|
||||
assert "auto_modal_priority" not in row
|
||||
assert "auto_modal_surfaces" not in row
|
||||
assert row["type"] == "warning"
|
||||
assert row["dismissible"] is False
|
||||
assert row["missing_providers"] == fake
|
||||
|
|
|
|||
|
|
@ -116,6 +116,14 @@ def test_oauth_model_slots_reuse_model_config_api():
|
|||
assert "if (!this.providerConnected(providerId)) return;" in store_js
|
||||
assert "const providerId = slot.provider;" in store_js
|
||||
assert "const providerId = this.isOauthProvider(this.activeModelProvider)" not in store_js
|
||||
assert "autoApplyConnectedProviderIfNeeded(providerId)" in store_js
|
||||
assert "currentChatModelConfigured()" in store_js
|
||||
assert 'providerDefaultModel(providerId, "chat_model")' in store_js
|
||||
assert 'providerDefaultModel(providerId, "utility_model")' in store_js
|
||||
assert "this.modelSlotDirty = { chat_model: true, utility_model: true };" in store_js
|
||||
assert 'new CustomEvent("model-configured"' in store_js
|
||||
assert 'detail: { source: "_oauth", providerId }' in store_js
|
||||
assert "await this.handleProviderConnected(providerId)" in store_js
|
||||
|
||||
|
||||
def test_browser_callback_completion_is_observed_from_modal():
|
||||
|
|
@ -124,6 +132,7 @@ def test_browser_callback_completion_is_observed_from_modal():
|
|||
assert "startCallbackPolling(providerId)" in store_js
|
||||
assert "stopCallbackPolling(providerId)" in store_js
|
||||
assert "this.providerConnected(providerId)" in store_js
|
||||
assert "await this.handleProviderConnected(providerId, { statusLoaded: true })" in store_js
|
||||
|
||||
|
||||
def test_device_polling_honors_provider_interval_updates():
|
||||
|
|
|
|||
|
|
@ -22,17 +22,14 @@ def test_welcome_screen_embeds_shared_new_chat_composer() -> None:
|
|||
assert 'path="chat/input/chat-bar-input.html"' in welcome
|
||||
assert "x-text=\"$store.welcomeStore.heroSubtitle\"" in welcome
|
||||
assert "Hello! I'm Agent Zero" in welcome
|
||||
assert "'is-setup-required': $store.welcomeStore.hasBlockingSetupBanner" in welcome
|
||||
assert 'class="welcome-setup-composer"' in welcome
|
||||
assert "Configure your models to start chatting" in welcome
|
||||
assert "Start Onboarding" in welcome
|
||||
assert "openBlockingSetup()" in welcome
|
||||
assert "is-setup-required" not in welcome
|
||||
assert "welcome-setup-composer" not in welcome
|
||||
assert "Configure your models to start chatting" not in welcome
|
||||
assert "Start Onboarding" not in welcome
|
||||
assert "openBlockingSetup()" not in welcome
|
||||
assert '.filter((b) => b.id !== "missing-api-key")' in welcome_store
|
||||
assert "get blockingSetupBanner()" in welcome_store
|
||||
assert "get heroSubtitle()" in welcome_store
|
||||
assert 'return "One setup step before chatting.";' in welcome_store
|
||||
assert 'return "How can I help you today?";' in welcome_store
|
||||
assert "openBlockingSetup()" in welcome_store
|
||||
assert '<h2>Quick Actions</h2>' in welcome
|
||||
assert 'class="welcome-lower-grid"' in welcome
|
||||
assert 'x-extension id="welcome-actions-end"' in welcome
|
||||
|
|
@ -64,9 +61,6 @@ def test_welcome_screen_embeds_shared_new_chat_composer() -> None:
|
|||
assert 'action.startsWith("open-modal:")' in welcome_store
|
||||
assert 'const hashIndex = path.indexOf("#");' in welcome_store
|
||||
assert 'history.replaceState(null, "", `#${hash}`);' in welcome_store
|
||||
assert ".welcome-banner-html .onboarding-banner-btn-container" in welcome
|
||||
assert "justify-content: flex-end;" in welcome
|
||||
assert "justify-content: flex-start;" in welcome
|
||||
assert ".welcome-banner-html .btn,\n .welcome-banner-html button" in welcome
|
||||
assert "background-color: #4248f1;" in welcome
|
||||
assert 'aria-label="Dismiss Connect Channels"' in discovery_cards
|
||||
|
|
@ -79,6 +73,7 @@ def test_welcome_screen_embeds_shared_new_chat_composer() -> None:
|
|||
assert "discovery-account-header" in discovery_cards
|
||||
assert "discovery-account-cta" in discovery_cards
|
||||
assert "discovery-account-icon" not in discovery_cards
|
||||
assert "some(b => b.id === 'missing-api-key')" not in discovery_cards
|
||||
|
||||
assert "x-if=\"$store.welcomeStore && $store.welcomeStore.isVisible\"" in index
|
||||
assert "x-if=\"!$store.welcomeStore || !$store.welcomeStore.isVisible\"" in index
|
||||
|
|
@ -94,18 +89,60 @@ def test_welcome_screen_embeds_shared_new_chat_composer() -> None:
|
|||
def test_welcome_composer_can_create_a_chat_before_sending() -> None:
|
||||
input_store = _read("webui/components/chat/input/input-store.js")
|
||||
chats_store = _read("webui/components/sidebar/chats/chats-store.js")
|
||||
index_js = _read("webui/index.js")
|
||||
gate_store = _read("webui/components/chat/model-gate-store.js")
|
||||
gate_component = _read("webui/components/chat/model-setup-gate.html")
|
||||
|
||||
assert 'return "Ask anything to start a new chat";' in input_store
|
||||
assert "if (!chatsStore.selected" in input_store
|
||||
assert "await chatsStore.newChat()" in input_store
|
||||
assert "return response.ctxid;" in chats_store
|
||||
assert 'return "arrow_forward";' in input_store
|
||||
assert "modelGateStore.canSendToModel()" in index_js
|
||||
assert "modelGateStore.mergeSyntheticMessages(snapshot.logs, context)" in index_js
|
||||
assert 'type: "model_setup_gate"' in gate_store
|
||||
assert 'STORAGE_KEY = "a0:model-gate-pending:v1"' in gate_store
|
||||
assert "SYNTHETIC_MESSAGE_NO_BASE = Number.MAX_SAFE_INTEGER - 2" in gate_store
|
||||
assert "return synthetic.length ? [...(logs || []), ...synthetic] : logs;" in gate_store
|
||||
assert "restorePending()" in gate_store
|
||||
assert "savePending()" in gate_store
|
||||
assert "clearSavedPending()" in gate_store
|
||||
assert "sessionStorage.setItem(STORAGE_KEY" in gate_store
|
||||
assert "sessionStorage.getItem(STORAGE_KEY)" in gate_store
|
||||
assert "sessionStorage.removeItem(STORAGE_KEY)" in gate_store
|
||||
assert 'import("/plugins/_oauth/webui/oauth-config-store.js")' in gate_store
|
||||
assert "tryConnectedAccountDefaults()" in gate_store
|
||||
assert "oauthConfigStore.connectedProviderCards()[0]?.provider_id" in gate_store
|
||||
assert "oauthConfigStore.autoApplyConnectedProviderIfNeeded(providerId)" in gate_store
|
||||
assert "const refreshed = await callJsonApi(\"/plugins/_model_config/model_config_get\", {});" in gate_store
|
||||
assert "void this.dispatchPendingIfConfigured();" in gate_store
|
||||
assert "this.choice = \"\";" in gate_store
|
||||
assert 'document.addEventListener("model-configured"' in gate_store
|
||||
assert "const currentContext = globalThis.getContext?.();" in gate_store
|
||||
assert "bypassModelGate: true" in gate_store
|
||||
assert 'openPluginConfig("_model_config", "Advanced model configuration")' in gate_store
|
||||
assert 'openPluginConfig("_oauth", "OAuth Connections")' in gate_store
|
||||
assert "Your message sends automatically once a model is connected." in gate_component
|
||||
assert "openOnboarding('cloud')" in gate_component
|
||||
assert "openOnboarding('local')" in gate_component
|
||||
assert "openOauthConfiguration()" in gate_component
|
||||
assert "Advanced model configuration" in gate_component
|
||||
assert "Advanced settings" not in gate_component
|
||||
assert "model-gate-fields" not in gate_component
|
||||
assert "Connect model" not in gate_component
|
||||
assert "accountsOpen" not in gate_store
|
||||
assert "saveInlineSetup" not in gate_store
|
||||
assert """.model-gate-card {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}""" in gate_component
|
||||
assert "Connect a model to send" in input_store
|
||||
|
||||
|
||||
def test_welcome_composer_does_not_overlap_idle_progress_placeholder() -> None:
|
||||
input_store = _read("webui/components/chat/input/input-store.js")
|
||||
|
||||
assert "!!chatsStore.selected &&\n this._getSendState() !== \"all\"" in input_store
|
||||
assert "!!chatsStore.selected &&\n ![\"all\", \"blocked\"].includes(this._getSendState())" in input_store
|
||||
|
||||
|
||||
def test_welcome_composer_buttons_keep_target_geometry_without_glow() -> None:
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
- `message-queue/` owns queued message display and store state.
|
||||
- `navigation/` owns chat navigation state.
|
||||
- `top-section/` owns chat header/top area.
|
||||
- `model-gate-store.js` and `model-setup-gate.html` own the deferred in-thread model setup gate.
|
||||
|
||||
## Local Contracts
|
||||
|
||||
|
|
@ -19,6 +20,11 @@
|
|||
- Do not bypass CSRF or WebSocket state-sync expectations.
|
||||
- The shared composer can be mounted on the Welcome screen with no selected chat; sending from that state must create and select a chat context before dispatch.
|
||||
- Composer text uses the main UI font by default; typing a triple-backtick fence and pressing Enter turns that line into a visual code block that serializes back to fenced Markdown, while pasted fenced Markdown stays plain text.
|
||||
- Missing model setup is gated at send intent: the first unconfigured send renders an in-thread setup card, keeps the pending prompt in browser session storage for refresh recovery, and must not call `/message_async` until a chat model is configured.
|
||||
- While the setup gate is open, the composer remains typeable but send is blocked until setup succeeds.
|
||||
- The setup gate must delegate Cloud/Local setup, account connections, and advanced model configuration to the existing onboarding and plugin settings modals; do not duplicate provider/model/key forms inline.
|
||||
- Before blocking, the setup gate should reuse existing connected OAuth account defaults when they can make the chat model usable.
|
||||
- Model setup surfaces that make the chat model usable must notify the gate with `model-configured` or an existing modal/onboarding completion signal so the pending prompt can retry automatically.
|
||||
|
||||
## Work Guidance
|
||||
|
||||
|
|
@ -28,6 +34,7 @@
|
|||
## Verification
|
||||
|
||||
- Smoke-test sending, queued messages, attachments, drag/drop, and navigation after visible changes.
|
||||
- Smoke-test the unconfigured first-send gate and automatic dispatch after model setup when touching model setup or send interception.
|
||||
|
||||
## Child DOX Index
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,8 @@
|
|||
<div id="chat-buttons-wrapper">
|
||||
<!-- Send button -->
|
||||
<button class="chat-button" id="send-button" aria-label="Send message" @click="$store.chatInput.sendMessage()"
|
||||
:class="$store.chatInput.sendButtonClass" :title="$store.chatInput.sendButtonTitle">
|
||||
:class="$store.chatInput.sendButtonClass" :title="$store.chatInput.sendButtonTitle"
|
||||
:disabled="$store.chatInput.sendDisabled">
|
||||
<span class="material-symbols-outlined" x-text="$store.chatInput.sendButtonIcon"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -387,6 +388,14 @@
|
|||
filter: none;
|
||||
}
|
||||
|
||||
#send-button.model-gate-blocked,
|
||||
#send-button.model-gate-blocked:hover {
|
||||
background-color: color-mix(in srgb, var(--color-border) 55%, transparent);
|
||||
color: color-mix(in srgb, var(--color-text) 45%, transparent);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
#send-button:active {
|
||||
background-color: #2b309c;
|
||||
transform: translateY(1px) scale(0.98);
|
||||
|
|
|
|||
|
|
@ -3,31 +3,13 @@
|
|||
<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;"
|
||||
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>
|
||||
|
||||
<div style="width: 100%; display: contents;">
|
||||
<!-- Message Queue section -->
|
||||
<x-component path="chat/message-queue/message-queue.html"></x-component>
|
||||
|
||||
|
|
@ -65,60 +47,6 @@
|
|||
#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>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { openLatest as openLatestSurface } from "/js/surfaces.js";
|
|||
import { store as messageQueueStore } from "/components/chat/message-queue/message-queue-store.js";
|
||||
import { store as attachmentsStore } from "/components/chat/attachments/attachmentsStore.js";
|
||||
import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
|
||||
import { store as modelGateStore } from "/components/chat/model-gate-store.js";
|
||||
|
||||
const ICON_MARKER_RE = /icon:\/\/([a-zA-Z0-9_]+)(\[(?:\\.|[^\]])*\])?/g;
|
||||
const FENCE_LINE_RE = /^```([A-Za-z0-9_-]*)?$/;
|
||||
|
|
@ -83,6 +84,7 @@ const model = {
|
|||
const hasQueue = !!messageQueueStore?.hasQueue;
|
||||
const running = !!chatsStore.selectedContext?.running;
|
||||
|
||||
if (modelGateStore?.isBlockingSend) return "blocked";
|
||||
if (hasQueue && !hasInput) return "all";
|
||||
if ((running || hasQueue) && hasInput) return "queue";
|
||||
return "normal";
|
||||
|
|
@ -91,6 +93,7 @@ const model = {
|
|||
get inputPlaceholder() {
|
||||
if (!chatsStore.selected) return "Ask anything to start a new chat";
|
||||
const state = this._getSendState();
|
||||
if (state === "blocked") return "Connect a model to send";
|
||||
if (state === "all") return "Press Enter to send queued messages";
|
||||
if (this.showProgressPlaceholder) return "";
|
||||
return "Type your message here...";
|
||||
|
|
@ -99,7 +102,7 @@ const model = {
|
|||
get showProgressPlaceholder() {
|
||||
return (
|
||||
!!chatsStore.selected &&
|
||||
this._getSendState() !== "all" &&
|
||||
!["all", "blocked"].includes(this._getSendState()) &&
|
||||
!!this.progressText &&
|
||||
!this.message
|
||||
);
|
||||
|
|
@ -115,6 +118,7 @@ const model = {
|
|||
// Computed: send button icon type
|
||||
get sendButtonIcon() {
|
||||
const state = this._getSendState();
|
||||
if (state === "blocked") return "settings";
|
||||
if (state === "all") return "send_and_archive";
|
||||
if (state === "queue") return "schedule_send";
|
||||
return "arrow_forward";
|
||||
|
|
@ -123,6 +127,7 @@ const model = {
|
|||
// Computed: send button CSS class
|
||||
get sendButtonClass() {
|
||||
const state = this._getSendState();
|
||||
if (state === "blocked") return "model-gate-blocked";
|
||||
if (state === "all") return "send-queue send-all";
|
||||
if (state === "queue") return "send-queue queue";
|
||||
return "";
|
||||
|
|
@ -131,17 +136,23 @@ const model = {
|
|||
// Computed: send button title
|
||||
get sendButtonTitle() {
|
||||
const state = this._getSendState();
|
||||
if (state === "blocked") return "Connect a model to send";
|
||||
if (state === "all") return "Send all queued messages";
|
||||
if (state === "queue") return "Add to queue";
|
||||
return "Send message";
|
||||
},
|
||||
|
||||
get sendDisabled() {
|
||||
return this._getSendState() === "blocked";
|
||||
},
|
||||
|
||||
init() {
|
||||
console.log("Input store initialized");
|
||||
// Event listeners are now handled via Alpine directives in the component
|
||||
},
|
||||
|
||||
async sendMessage() {
|
||||
if (this.sendDisabled) return;
|
||||
this._syncMessageFromEditor();
|
||||
|
||||
// Capture sent prompt to per-chat history (bash-style)
|
||||
|
|
|
|||
244
webui/components/chat/model-gate-store.js
Normal file
244
webui/components/chat/model-gate-store.js
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
import { createStore } from "/js/AlpineStore.js";
|
||||
import { callJsonApi } from "/js/api.js";
|
||||
import { store as modelConfigStore } from "/plugins/_model_config/webui/model-config-store.js";
|
||||
import { store as onboardingStore } from "/plugins/_onboarding/webui/onboarding-store.js";
|
||||
import { store as pluginSettingsStore } from "/components/plugins/plugin-settings-store.js";
|
||||
import { toastFrontendError } from "/components/notifications/notification-store.js";
|
||||
|
||||
const ONBOARDING_MODAL_PATH = "/plugins/_onboarding/webui/onboarding.html";
|
||||
const STORAGE_KEY = "a0:model-gate-pending:v1";
|
||||
const SYNTHETIC_MESSAGE_NO_BASE = Number.MAX_SAFE_INTEGER - 2;
|
||||
|
||||
export const store = createStore("modelGate", {
|
||||
active: false,
|
||||
connected: false,
|
||||
connectedLabel: "",
|
||||
pending: null,
|
||||
gateMessageId: "",
|
||||
choice: "",
|
||||
dispatching: false,
|
||||
_initialized: false,
|
||||
|
||||
get isBlockingSend() {
|
||||
this.init();
|
||||
if (!this.active || this.connected) return false;
|
||||
const currentContext = globalThis.getContext?.();
|
||||
return !this.pending?.context || !currentContext || this.pending.context === currentContext;
|
||||
},
|
||||
|
||||
get introText() {
|
||||
if (this.connected) {
|
||||
return `Model connected: ${this.connectedLabel || "ready"}`;
|
||||
}
|
||||
return "I'm ready to work on this — I just need a model to think with. Pick one and I'll answer right away.";
|
||||
},
|
||||
|
||||
init() {
|
||||
if (this._initialized) return;
|
||||
this._initialized = true;
|
||||
this.restorePending();
|
||||
document.addEventListener("onboarding-configured", () => {
|
||||
void this.dispatchPendingIfConfigured();
|
||||
});
|
||||
document.addEventListener("model-configured", () => {
|
||||
void this.dispatchPendingIfConfigured();
|
||||
});
|
||||
document.addEventListener("modal-closed", () => {
|
||||
if (this.active && !this.connected) {
|
||||
this.choice = "";
|
||||
this.savePending();
|
||||
void this.dispatchPendingIfConfigured();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async canSendToModel() {
|
||||
try {
|
||||
const data = await callJsonApi("/plugins/_model_config/model_config_get", {});
|
||||
this.applyModelStatus(data);
|
||||
if (data?.model_configured) return true;
|
||||
if (!(await this.tryConnectedAccountDefaults())) return false;
|
||||
const refreshed = await callJsonApi("/plugins/_model_config/model_config_get", {});
|
||||
this.applyModelStatus(refreshed);
|
||||
return !!refreshed?.model_configured;
|
||||
} catch (error) {
|
||||
console.error("Could not check model configuration:", error);
|
||||
return true;
|
||||
}
|
||||
},
|
||||
|
||||
async tryConnectedAccountDefaults() {
|
||||
try {
|
||||
const { store: oauthConfigStore } = await import("/plugins/_oauth/webui/oauth-config-store.js");
|
||||
await oauthConfigStore.loadStatus();
|
||||
const providerId = oauthConfigStore.connectedProviderCards()[0]?.provider_id || "";
|
||||
return providerId ? oauthConfigStore.autoApplyConnectedProviderIfNeeded(providerId) : false;
|
||||
} catch (error) {
|
||||
console.error("Could not apply connected account defaults:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
applyModelStatus(data = {}) {
|
||||
modelConfigStore.modelConfigured = !!data.model_configured;
|
||||
modelConfigStore.modelConfiguredLabel = data.model_configured_label || "";
|
||||
this.connectedLabel = data.model_configured_label || this.connectedLabel || "";
|
||||
},
|
||||
|
||||
start({ message, attachments, messageId, context }) {
|
||||
this.init();
|
||||
this.active = true;
|
||||
this.connected = false;
|
||||
this.pending = { message, attachments, messageId, context };
|
||||
this.gateMessageId = this.gateMessageId || `model-gate-${messageId}`;
|
||||
this.savePending();
|
||||
},
|
||||
|
||||
syntheticMessages(currentContext) {
|
||||
this.init();
|
||||
if (!this.active || !this.pending || this.pending.context !== currentContext) return [];
|
||||
return [
|
||||
{
|
||||
no: SYNTHETIC_MESSAGE_NO_BASE,
|
||||
id: this.pending.messageId,
|
||||
type: "user",
|
||||
content: this.pending.message,
|
||||
kvps: { attachments: this.pending.attachments },
|
||||
},
|
||||
{
|
||||
no: SYNTHETIC_MESSAGE_NO_BASE + 1,
|
||||
id: this.gateMessageId,
|
||||
type: "model_setup_gate",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
mergeSyntheticMessages(logs, currentContext) {
|
||||
this.init();
|
||||
const synthetic = this.syntheticMessages(currentContext);
|
||||
return synthetic.length ? [...(logs || []), ...synthetic] : logs;
|
||||
},
|
||||
|
||||
onCardCreate() {
|
||||
this.init();
|
||||
void this.dispatchPendingIfConfigured();
|
||||
},
|
||||
|
||||
openOnboarding(choice) {
|
||||
this.choice = choice === "local" ? "local" : "cloud";
|
||||
this.savePending();
|
||||
const modalPromise = window.openModal?.(ONBOARDING_MODAL_PATH);
|
||||
void this.applyOnboardingChoice(this.choice);
|
||||
void Promise.resolve(modalPromise).then(() => this.dispatchPendingIfConfigured());
|
||||
},
|
||||
|
||||
async applyOnboardingChoice(choice) {
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
if (onboardingStore.config && !onboardingStore.loading) {
|
||||
onboardingStore.choosePath(choice);
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
},
|
||||
|
||||
async openAdvancedModelConfiguration() {
|
||||
await this.openPluginConfig("_model_config", "Advanced model configuration");
|
||||
},
|
||||
|
||||
async openOauthConfiguration() {
|
||||
await this.openPluginConfig("_oauth", "OAuth Connections");
|
||||
},
|
||||
|
||||
async openPluginConfig(pluginName, title) {
|
||||
try {
|
||||
await pluginSettingsStore.openConfig(pluginName);
|
||||
await this.dispatchPendingIfConfigured();
|
||||
} catch (error) {
|
||||
console.error(`Could not open ${pluginName} configuration:`, error);
|
||||
void toastFrontendError(error?.message || "Could not open configuration.", title);
|
||||
}
|
||||
},
|
||||
|
||||
async dispatchPendingIfConfigured() {
|
||||
this.init();
|
||||
if (this.dispatching || !this.pending) return;
|
||||
const configured = await this.canSendToModel();
|
||||
if (!configured) return;
|
||||
|
||||
const pending = this.pending;
|
||||
this.pending = null;
|
||||
this.connected = true;
|
||||
this.dispatching = true;
|
||||
this.clearSavedPending();
|
||||
try {
|
||||
await globalThis.sendMessage?.({
|
||||
bypassModelGate: true,
|
||||
skipExtensions: true,
|
||||
preserveInput: true,
|
||||
message: pending.message,
|
||||
attachments: pending.attachments,
|
||||
messageId: pending.messageId,
|
||||
context: pending.context,
|
||||
});
|
||||
} finally {
|
||||
this.dispatching = false;
|
||||
}
|
||||
},
|
||||
|
||||
savePending() {
|
||||
const message = String(this.pending?.message || "").trim();
|
||||
const context = String(this.pending?.context || "");
|
||||
const messageId = String(this.pending?.messageId || "");
|
||||
const attachments = Array.isArray(this.pending?.attachments) ? this.pending.attachments : [];
|
||||
if (!message || !context || !messageId || attachments.length) {
|
||||
this.clearSavedPending();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||
pending: { message, attachments: [], messageId, context },
|
||||
gateMessageId: this.gateMessageId || `model-gate-${messageId}`,
|
||||
choice: this.choice,
|
||||
}));
|
||||
} catch (_error) {
|
||||
// Session storage can be unavailable in private or locked-down browser modes.
|
||||
}
|
||||
},
|
||||
|
||||
restorePending() {
|
||||
if (this.pending) return;
|
||||
|
||||
let saved = null;
|
||||
try {
|
||||
saved = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || "null");
|
||||
} catch (_error) {
|
||||
this.clearSavedPending();
|
||||
return;
|
||||
}
|
||||
|
||||
const message = String(saved?.pending?.message || "").trim();
|
||||
const context = String(saved?.pending?.context || "");
|
||||
const messageId = String(saved?.pending?.messageId || "");
|
||||
if (!message || !context || !messageId) {
|
||||
this.clearSavedPending();
|
||||
return;
|
||||
}
|
||||
|
||||
this.active = true;
|
||||
this.connected = false;
|
||||
this.pending = { message, attachments: [], messageId, context };
|
||||
this.gateMessageId = String(saved?.gateMessageId || `model-gate-${messageId}`);
|
||||
this.choice = saved?.choice === "local" || saved?.choice === "cloud" ? saved.choice : "";
|
||||
},
|
||||
|
||||
clearSavedPending() {
|
||||
try {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
} catch (_error) {
|
||||
// Ignore unavailable storage; the in-memory gate state is still authoritative.
|
||||
}
|
||||
},
|
||||
|
||||
});
|
||||
179
webui/components/chat/model-setup-gate.html
Normal file
179
webui/components/chat/model-setup-gate.html
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
<html>
|
||||
<head>
|
||||
<script type="module">
|
||||
import { store as modelGateStore } from "/components/chat/model-gate-store.js";
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div x-data>
|
||||
<template x-if="$store.modelGate">
|
||||
<section class="model-gate" x-create="$store.modelGate.onCardCreate()">
|
||||
<template x-if="$store.modelGate.connected">
|
||||
<div class="model-gate-chip">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">check_circle</span>
|
||||
<span x-text="$store.modelGate.introText"></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!$store.modelGate.connected">
|
||||
<div class="model-gate-body">
|
||||
<p class="model-gate-intro" x-text="$store.modelGate.introText"></p>
|
||||
|
||||
<div class="model-gate-card">
|
||||
<div class="model-gate-fork">
|
||||
<button type="button"
|
||||
class="model-gate-option"
|
||||
:class="{ selected: $store.modelGate.choice === 'cloud' }"
|
||||
@click="$store.modelGate.openOnboarding('cloud')">
|
||||
<span class="model-gate-option-title">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">cloud</span>
|
||||
<span>Cloud provider</span>
|
||||
</span>
|
||||
<span class="model-gate-option-sub">API key or account connection</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
class="model-gate-option"
|
||||
:class="{ selected: $store.modelGate.choice === 'local' }"
|
||||
@click="$store.modelGate.openOnboarding('local')">
|
||||
<span class="model-gate-option-title">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">memory</span>
|
||||
<span>Local model</span>
|
||||
</span>
|
||||
<span class="model-gate-option-sub">Runs on your machine</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="model-gate-accounts">
|
||||
<span>Or connect an account: Codex, Copilot, Gemini, Grok</span>
|
||||
<button type="button" @click="$store.modelGate.openOauthConfiguration()">
|
||||
<span>Show accounts</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="model-gate-footnote">
|
||||
<span>Your message sends automatically once a model is connected.</span>
|
||||
<button type="button" @click="$store.modelGate.openAdvancedModelConfiguration()">Advanced model configuration</button>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.model-gate {
|
||||
width: min(100%, 36rem);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-family-main, "Rubik", Arial, Helvetica, sans-serif);
|
||||
}
|
||||
|
||||
.model-gate-body {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.model-gate-intro {
|
||||
margin: 0;
|
||||
color: color-mix(in srgb, var(--color-text) 84%, transparent);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.model-gate-card {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.model-gate-fork {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.model-gate-option {
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 76%, transparent);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--color-background) 52%, transparent);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.model-gate-option {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
padding: 0.72rem;
|
||||
}
|
||||
|
||||
.model-gate-option:hover,
|
||||
.model-gate-option:focus-visible,
|
||||
.model-gate-option.selected {
|
||||
border-color: color-mix(in srgb, var(--color-border) 86%, var(--color-text) 14%);
|
||||
background: color-mix(in srgb, var(--color-background-hover) 60%, var(--color-panel));
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.model-gate-option-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-weight: 560;
|
||||
}
|
||||
|
||||
.model-gate-option-title .material-symbols-outlined {
|
||||
font-size: 1.15rem;
|
||||
color: color-mix(in srgb, var(--color-text) 72%, transparent);
|
||||
}
|
||||
|
||||
.model-gate-option-sub,
|
||||
.model-gate-footnote {
|
||||
color: color-mix(in srgb, var(--color-text) 58%, transparent);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.model-gate-accounts,
|
||||
.model-gate-footnote {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.7rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.model-gate-accounts button,
|
||||
.model-gate-footnote button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: color-mix(in srgb, #6f8cff 82%, var(--color-text));
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.model-gate-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
border: 1px solid color-mix(in srgb, #7bc995 44%, var(--color-border));
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 0.65rem;
|
||||
color: color-mix(in srgb, var(--color-text) 88%, #7bc995);
|
||||
background: color-mix(in srgb, #7bc995 10%, var(--color-panel));
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.model-gate-chip .material-symbols-outlined {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.model-gate-fork {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -16,6 +16,7 @@
|
|||
- Banner body links may use `data-banner-action`; these actions route through the same welcome action dispatcher as CTA buttons.
|
||||
- `open-modal:` banner actions may include a `#section-id` fragment, which updates the page hash before opening the modal so settings sections can deep-link correctly.
|
||||
- Do not show setup prompts for already configured plugins when backend status can prevent it.
|
||||
- Do not replace the welcome composer with blocking model setup UI; missing model setup is deferred to the chat thread on first send.
|
||||
- The welcome screen mounts the shared chat composer to start a new chat; keep it mutually exclusive with the normal chat input DOM.
|
||||
- Render `system-resources` as the dedicated System Resources panel, not as a generic alert banner.
|
||||
- Utility quick actions from welcome must keep the first screen intact; use modal/floating entry points instead of docking the right canvas beside welcome content.
|
||||
|
|
|
|||
|
|
@ -19,27 +19,7 @@
|
|||
<p x-text="$store.welcomeStore.heroSubtitle"></p>
|
||||
</header>
|
||||
|
||||
<section class="welcome-composer"
|
||||
:class="{ 'is-setup-required': $store.welcomeStore.hasBlockingSetupBanner }"
|
||||
:aria-label="$store.welcomeStore.hasBlockingSetupBanner ? 'Configure models to start chatting' : 'Start a new chat'">
|
||||
<template x-if="$store.welcomeStore.hasBlockingSetupBanner">
|
||||
<button type="button"
|
||||
class="welcome-setup-composer"
|
||||
@click="$store.welcomeStore.openBlockingSetup()"
|
||||
aria-label="Start onboarding">
|
||||
<span class="welcome-setup-icon" aria-hidden="true">
|
||||
<span class="material-symbols-outlined">warning</span>
|
||||
</span>
|
||||
<span class="welcome-setup-copy">
|
||||
<span class="welcome-setup-title">Configure your models to start chatting</span>
|
||||
<span class="welcome-setup-detail">Insert your API key in the onboarding wizard.</span>
|
||||
</span>
|
||||
<span class="welcome-setup-cta">
|
||||
<span>Start Onboarding</span>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">arrow_forward</span>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
<section class="welcome-composer" aria-label="Start a new chat">
|
||||
<x-component path="chat/attachments/inputPreview.html"></x-component>
|
||||
<x-component path="chat/input/chat-bar-input.html"></x-component>
|
||||
</section>
|
||||
|
|
@ -265,106 +245,6 @@
|
|||
display: contents;
|
||||
}
|
||||
|
||||
.welcome-composer.is-setup-required > x-component {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.welcome-setup-composer {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
min-height: 4.9rem;
|
||||
padding: 0.74rem 0.7rem 0.74rem 0.86rem;
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--color-panel) 64%, var(--color-background) 36%);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-family-main, "Rubik", Arial, Helvetica, sans-serif);
|
||||
text-align: left;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.035),
|
||||
0 18px 46px rgba(0, 0, 0, 0.18);
|
||||
transition:
|
||||
border-color 0.18s ease,
|
||||
background-color 0.18s ease,
|
||||
box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.welcome-setup-composer:hover,
|
||||
.welcome-setup-composer:focus-visible {
|
||||
border-color: color-mix(in srgb, var(--color-border) 88%, var(--color-text) 12%);
|
||||
background: color-mix(in srgb, var(--color-panel) 74%, var(--color-background) 26%);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.045),
|
||||
0 22px 54px rgba(0, 0, 0, 0.22);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.welcome-setup-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 8px;
|
||||
color: #f59e0b;
|
||||
background: color-mix(in srgb, #f59e0b 9%, transparent);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, #f59e0b 12%, transparent);
|
||||
}
|
||||
|
||||
.welcome-setup-icon .material-symbols-outlined {
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.welcome-setup-copy {
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.welcome-setup-title {
|
||||
color: var(--color-text);
|
||||
font-size: 1rem;
|
||||
font-weight: 520;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.welcome-setup-detail {
|
||||
color: color-mix(in srgb, var(--color-text) 58%, transparent);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.welcome-setup-cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.36rem;
|
||||
min-height: 2.75rem;
|
||||
border-radius: 12px;
|
||||
padding: 0.62rem 0.95rem;
|
||||
background-color: #4248f1;
|
||||
color: #fff;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||
transition: background-color 0.18s ease;
|
||||
}
|
||||
|
||||
.welcome-setup-composer:hover .welcome-setup-cta,
|
||||
.welcome-setup-composer:focus-visible .welcome-setup-cta {
|
||||
background-color: #353bc5;
|
||||
}
|
||||
|
||||
.welcome-setup-cta .material-symbols-outlined {
|
||||
font-size: 1.24rem;
|
||||
}
|
||||
|
||||
.welcome-composer .input-row {
|
||||
min-height: 4.9rem;
|
||||
padding: 0.58rem 0.7rem 0.58rem 0.86rem;
|
||||
|
|
@ -479,14 +359,6 @@
|
|||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.welcome-banner-html .onboarding-banner-btn-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 0.55rem;
|
||||
margin-top: 0.9rem !important;
|
||||
}
|
||||
|
||||
.welcome-banner-cta,
|
||||
.welcome-banner-html .btn,
|
||||
.welcome-banner-html button {
|
||||
|
|
@ -875,22 +747,6 @@
|
|||
margin-left: auto;
|
||||
}
|
||||
|
||||
.welcome-setup-composer {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 0.8rem;
|
||||
min-height: 4.35rem;
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.welcome-setup-cta {
|
||||
grid-column: 2;
|
||||
justify-self: start;
|
||||
min-height: 2.45rem;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 0.85rem;
|
||||
}
|
||||
|
||||
.welcome-banner {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 0.82rem;
|
||||
|
|
@ -912,11 +768,6 @@
|
|||
max-width: none;
|
||||
}
|
||||
|
||||
.welcome-banner-html .onboarding-banner-btn-container {
|
||||
justify-content: flex-start;
|
||||
margin-top: 0.75rem !important;
|
||||
}
|
||||
|
||||
.welcome-banner-cta {
|
||||
grid-column: 2;
|
||||
justify-self: start;
|
||||
|
|
|
|||
|
|
@ -152,28 +152,10 @@ const model = {
|
|||
return this.banners.find((b) => b.id === "system-resources") || null;
|
||||
},
|
||||
|
||||
get blockingSetupBanner() {
|
||||
return this.banners.find((b) => b.id === "missing-api-key") || null;
|
||||
},
|
||||
|
||||
get hasBlockingSetupBanner() {
|
||||
return Boolean(this.blockingSetupBanner);
|
||||
},
|
||||
|
||||
get heroSubtitle() {
|
||||
if (this.hasBlockingSetupBanner) {
|
||||
return "One setup step before chatting.";
|
||||
}
|
||||
return "How can I help you today?";
|
||||
},
|
||||
|
||||
openBlockingSetup() {
|
||||
const path =
|
||||
this.blockingSetupBanner?.auto_modal_path ||
|
||||
"/plugins/_onboarding/webui/onboarding.html";
|
||||
window.openModal(path);
|
||||
},
|
||||
|
||||
executeBannerAction(action) {
|
||||
if (!action) return;
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { store as _tooltipsStore } from "/components/tooltips/tooltip-store.js";
|
|||
import { store as messageQueueStore } from "/components/chat/message-queue/message-queue-store.js";
|
||||
import { store as syncStore } from "/components/sync/sync-store.js"
|
||||
import { store as welcomeStore } from "/components/welcome/welcome-store.js";
|
||||
import { store as modelGateStore } from "/components/chat/model-gate-store.js";
|
||||
import { getUserHour12, getUserTimezone } from "/js/time-utils.js";
|
||||
|
||||
globalThis.fetchApi = api.fetchApi; // TODO - backward compatibility for non-modular scripts, remove once refactored to alpine
|
||||
|
|
@ -39,17 +40,24 @@ let skipOneSpeech = false;
|
|||
|
||||
// Sidebar toggle logic is now handled by sidebar-store.js
|
||||
|
||||
export async function sendMessage() {
|
||||
export async function sendMessage(options = {}) {
|
||||
try {
|
||||
let message = inputStore.message.trim();
|
||||
let attachmentsWithUrls = attachmentsStore.getAttachmentsForSending();
|
||||
const hasAttachments = attachmentsWithUrls.length > 0;
|
||||
if (!options.bypassModelGate && modelGateStore.isBlockingSend) return;
|
||||
|
||||
const sendCtx = { message, attachments: attachmentsWithUrls, context, cancel: false };
|
||||
await callJsExtensions("send_message_before", sendCtx);
|
||||
const hasProvidedMessage = Object.prototype.hasOwnProperty.call(options, "message");
|
||||
let message = String(hasProvidedMessage ? options.message : inputStore.message).trim();
|
||||
let attachmentsWithUrls = options.attachments || attachmentsStore.getAttachmentsForSending();
|
||||
let hasAttachments = attachmentsWithUrls.length > 0;
|
||||
|
||||
const sendCtx = { message, attachments: attachmentsWithUrls, context: options.context || context, cancel: false };
|
||||
if (!options.skipExtensions) await callJsExtensions("send_message_before", sendCtx);
|
||||
if (sendCtx.cancel) return;
|
||||
message = sendCtx.message;
|
||||
attachmentsWithUrls = sendCtx.attachments;
|
||||
hasAttachments = attachmentsWithUrls.length > 0;
|
||||
const sendContext = options.context || context;
|
||||
const messageId = options.messageId || generateGUID();
|
||||
const shouldResetInput = !hasProvidedMessage && !options.preserveInput;
|
||||
|
||||
// If empty input but has queued messages, send all queued
|
||||
if (!message && !hasAttachments && messageQueueStore.hasQueue) {
|
||||
|
|
@ -58,12 +66,30 @@ export async function sendMessage() {
|
|||
}
|
||||
|
||||
if (message || hasAttachments) {
|
||||
if (!options.bypassModelGate && !(await modelGateStore.canSendToModel())) {
|
||||
modelGateStore.start({
|
||||
message,
|
||||
attachments: attachmentsWithUrls,
|
||||
messageId,
|
||||
context: sendContext,
|
||||
});
|
||||
|
||||
if (shouldResetInput) {
|
||||
inputStore.reset();
|
||||
adjustTextareaHeight();
|
||||
}
|
||||
|
||||
await setMessages(modelGateStore.syntheticMessages(sendContext));
|
||||
forceScrollChatToBottom();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if agent is busy - queue instead of sending
|
||||
if (chatsStore.selectedContext?.running || messageQueueStore.hasQueue) {
|
||||
const success = messageQueueStore.addToQueue(message, attachmentsWithUrls);
|
||||
// no await for the queue
|
||||
// if (success) {
|
||||
inputStore.reset();
|
||||
if (shouldResetInput) inputStore.reset();
|
||||
adjustTextareaHeight();
|
||||
// }
|
||||
return;
|
||||
|
|
@ -74,11 +100,12 @@ export async function sendMessage() {
|
|||
forceScrollChatToBottom();
|
||||
|
||||
let response;
|
||||
const messageId = generateGUID();
|
||||
|
||||
// Clear input and attachments
|
||||
inputStore.reset();
|
||||
adjustTextareaHeight();
|
||||
// Clear input and attachments
|
||||
if (shouldResetInput) {
|
||||
inputStore.reset();
|
||||
adjustTextareaHeight();
|
||||
}
|
||||
|
||||
// Include attachments in the user message
|
||||
if (hasAttachments) {
|
||||
|
|
@ -97,7 +124,7 @@ export async function sendMessage() {
|
|||
|
||||
const formData = new FormData();
|
||||
formData.append("text", message);
|
||||
formData.append("context", context);
|
||||
formData.append("context", sendContext);
|
||||
formData.append("message_id", messageId);
|
||||
|
||||
for (let i = 0; i < attachmentsWithUrls.length; i++) {
|
||||
|
|
@ -112,7 +139,7 @@ export async function sendMessage() {
|
|||
// For text-only messages
|
||||
const data = {
|
||||
text: message,
|
||||
context,
|
||||
context: sendContext,
|
||||
message_id: messageId,
|
||||
};
|
||||
response = await api.fetchApi("/message_async", {
|
||||
|
|
@ -352,7 +379,7 @@ export async function applySnapshot(snapshot, options = {}) {
|
|||
const chatHistoryEl = document.getElementById("chat-history");
|
||||
if (chatHistoryEl) chatHistoryEl.innerHTML = "";
|
||||
}
|
||||
await setMessages(snapshot.logs);
|
||||
await setMessages(modelGateStore.mergeSyntheticMessages(snapshot.logs, context));
|
||||
afterMessagesUpdate(snapshot.logs);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -119,6 +119,8 @@ export async function getMessageHandler(type) {
|
|||
return drawMessageUtil;
|
||||
case "hint":
|
||||
return drawMessageHint;
|
||||
case "model_setup_gate":
|
||||
return drawMessageModelSetupGate;
|
||||
default:
|
||||
return await getHandlerFromExtensions(type);
|
||||
}
|
||||
|
|
@ -966,6 +968,22 @@ export function drawMessageResponse({
|
|||
return { element: container };
|
||||
}
|
||||
|
||||
export function drawMessageModelSetupGate({ id }) {
|
||||
const container = getOrCreateMessageContainer(id, "left");
|
||||
container.classList.add("model-setup-gate-container");
|
||||
container.innerHTML = "";
|
||||
|
||||
const messageDiv = document.createElement("div");
|
||||
messageDiv.className = "message message-agent-response model-setup-gate-message";
|
||||
|
||||
const component = document.createElement("x-component");
|
||||
component.setAttribute("path", "chat/model-setup-gate.html");
|
||||
messageDiv.appendChild(component);
|
||||
container.appendChild(messageDiv);
|
||||
|
||||
return { element: container };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MessageHandlerArgs & Record<string, any>} param0
|
||||
* @returns {MessageHandlerResult}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue