From 57a0a59247f9af2d60fbe08d1fdf29c22273c5c6 Mon Sep 17 00:00:00 2001 From: nfebe Date: Sun, 7 Jun 2026 18:53:45 +0100 Subject: [PATCH 1/7] feat(plan): Review changes before applying deployment mutations Mutating actions run directly by default. When a deployment has plan review required, the same buttons transparently switch to a preview flow: the change is computed server side and shown in a review modal with summary counts, per-change reasons and expandable before/after diffs, with sensitive values masked behind an explicit reveal. Apply and discard act on the saved plan, and a stale preview surfaces as a warning instead of applying blindly. Deployment settings moved under the Configuration tab, which now uses a reusable attached sub-tab component, and were restyled as a single expandable list with a Require Plan Review switch next to Protected Mode. Start, stop, restart, rebuild and pull are split buttons that target the whole deployment or any single service, and each service row gains its own start, stop, restart and rebuild controls. --- package-lock.json | 10 + package.json | 1 + src/App.vue | 2 + src/components/DomainsManager.vue | 30 +- src/components/base/SplitActionButton.vue | 147 +++++++ src/components/base/SubTabs.vue | 93 ++++ src/components/plan/DiffView.test.ts | 46 ++ src/components/plan/DiffView.vue | 83 ++++ src/components/plan/PlanChangeCard.test.ts | 82 ++++ src/components/plan/PlanChangeCard.vue | 175 ++++++++ src/components/plan/PlanFlowHost.vue | 16 + src/components/plan/PlanReviewModal.test.ts | 139 ++++++ src/components/plan/PlanReviewModal.vue | 232 ++++++++++ src/composables/usePlanFlow.test.ts | 206 +++++++++ src/composables/usePlanFlow.ts | 16 + src/services/api.ts | 91 +++- src/stores/planFlow.ts | 98 +++++ src/types/index.ts | 51 +++ src/views/DeploymentDetailView.test.ts | 9 +- src/views/DeploymentDetailView.vue | 460 ++++++++++++++------ vitest.config.ts | 1 + vitest.setup.ts | 34 ++ 22 files changed, 1857 insertions(+), 165 deletions(-) create mode 100644 src/components/base/SplitActionButton.vue create mode 100644 src/components/base/SubTabs.vue create mode 100644 src/components/plan/DiffView.test.ts create mode 100644 src/components/plan/DiffView.vue create mode 100644 src/components/plan/PlanChangeCard.test.ts create mode 100644 src/components/plan/PlanChangeCard.vue create mode 100644 src/components/plan/PlanFlowHost.vue create mode 100644 src/components/plan/PlanReviewModal.test.ts create mode 100644 src/components/plan/PlanReviewModal.vue create mode 100644 src/composables/usePlanFlow.test.ts create mode 100644 src/composables/usePlanFlow.ts create mode 100644 src/stores/planFlow.ts create mode 100644 vitest.setup.ts diff --git a/package-lock.json b/package-lock.json index d34b125..47b6fc4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@xterm/addon-web-links": "^0.11.0", "@xterm/xterm": "^5.5.0", "axios": "^1.6.7", + "diff": "^9.0.0", "lucide-vue-next": "^0.554.0", "pinia": "^2.1.7", "primeicons": "^6.0.1", @@ -2218,6 +2219,15 @@ "node": ">=0.4.0" } }, + "node_modules/diff": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", diff --git a/package.json b/package.json index fa4896d..2455bad 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@xterm/addon-web-links": "^0.11.0", "@xterm/xterm": "^5.5.0", "axios": "^1.6.7", + "diff": "^9.0.0", "lucide-vue-next": "^0.554.0", "pinia": "^2.1.7", "primeicons": "^6.0.1", diff --git a/src/App.vue b/src/App.vue index c934471..42b136e 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,6 +1,7 @@ @@ -1640,11 +1709,15 @@ import DomainFormModal from "@/components/DomainFormModal.vue"; import ContainerResourcesModal from "@/components/ContainerResourcesModal.vue"; import { extractComposeMounts, extractComposeServiceNames } from "@/utils/compose"; import { matchTypeHints, describeBlockedRule } from "@/utils/protectedMode"; +import { usePlanFlow } from "@/composables/usePlanFlow"; +import SplitActionButton from "@/components/base/SplitActionButton.vue"; +import SubTabs from "@/components/base/SubTabs.vue"; const route = useRoute(); const router = useRouter(); const notifications = useNotificationsStore(); const authStore = useAuthStore(); +const { runGuarded } = usePlanFlow(); const canWrite = authStore.hasPermission("deployments:write"); const canDelete = authStore.hasPermission("deployments:delete"); @@ -1724,7 +1797,6 @@ const tabs = [ { id: "actions", label: "Quick Actions", icon: "pi pi-bolt" }, { id: "backups", label: "Backups", icon: "pi pi-history" }, { id: "security", label: "Security", icon: "pi pi-shield" }, - { id: "settings", label: "Settings", icon: "pi pi-sliders-h" }, { id: "config", label: "Configuration", icon: "pi pi-cog" }, ]; @@ -1816,7 +1888,13 @@ const isEditingConfig = ref(false); const serviceConfig = ref(""); const isEditingServiceConfig = ref(false); const configExtensions = [yaml(), oneDark]; -const activeConfigTab = ref<"compose" | "service">("compose"); +const activeConfigTab = ref<"compose" | "service" | "settings">("compose"); + +const configSubTabs = computed(() => [ + { id: "compose", label: composeFilename.value, icon: "pi pi-file" }, + { id: "service", label: "service.yml", icon: "pi pi-cog" }, + { id: "settings", label: "Settings", icon: "pi pi-sliders-h" }, +]); const showOperationModal = ref(false); const operationTitle = ref(""); @@ -1918,6 +1996,31 @@ const syncProtectedModeFromDeployment = () => { blocked_command_rules: [...(current?.blocked_command_rules || [])], disable_terminal: current?.disable_terminal || false, }; + requirePlan.value = Boolean(deployment.value?.metadata?.require_plan); +}; + +const requirePlan = ref(false); +const savingRequirePlan = ref(false); +const openSettingsSection = ref("protected"); + +const toggleSettingsSection = (id: string) => { + openSettingsSection.value = openSettingsSection.value === id ? null : id; +}; + +const saveRequirePlan = async () => { + savingRequirePlan.value = true; + try { + await deploymentsApi.updateMetadata(route.params.name as string, { require_plan: requirePlan.value }); + if (deployment.value?.metadata) { + deployment.value.metadata.require_plan = requirePlan.value; + } + notifications.success("Saved", requirePlan.value ? "Plan review is now required" : "Plan review is now optional"); + } catch (e: any) { + notifications.error("Error", e.response?.data?.error || "Failed to save setting"); + requirePlan.value = Boolean(deployment.value?.metadata?.require_plan); + } finally { + savingRequirePlan.value = false; + } }; const saveProtectedMode = async () => { @@ -2117,12 +2220,14 @@ const fetchDeployment = async () => { const handleSetupProxy = async () => { settingUpProxy.value = true; try { - await proxyApi.setup(route.params.name as string); + const result = await runGuarded( + () => proxyApi.setup(route.params.name as string), + () => proxyApi.setup(route.params.name as string, { plan: true }), + "Setup Failed", + ); + if (result === false) return; notifications.success("Proxy Setup", "Virtual host has been configured"); await fetchDeployment(); - } catch (err: any) { - const msg = err.response?.data?.error || err.message; - notifications.error("Setup Failed", msg); } finally { settingUpProxy.value = false; } @@ -2452,17 +2557,20 @@ const confirmDelete = () => { const deleteDeployment = async () => { deletingDeployment.value = true; try { - await deploymentsApi.delete(route.params.name as string, { + const options = { deleteSSL: deleteOptions.value.deleteSSL, deleteDatabase: deleteOptions.value.deleteDatabase, deleteVhost: deleteOptions.value.deleteVhost, - }); + }; + const result = await runGuarded( + () => deploymentsApi.delete(route.params.name as string, options), + () => deploymentsApi.delete(route.params.name as string, options, { plan: true }), + "Delete Failed", + ); + if (result === false) return; showDeleteDeploymentModal.value = false; notifications.success("Deleted", `Deployment "${deployment.value?.name}" has been deleted`); router.push(backPath.value); - } catch (err: any) { - const msg = err.response?.data?.error || err.message; - notifications.error("Delete Failed", msg); } finally { deletingDeployment.value = false; } @@ -2502,8 +2610,50 @@ const viewServiceLogs = (service: any) => { fetchLogs(); }; -const restartService = async (service: any) => { - console.log("Restarting service:", service.name); +const serviceNames = computed(() => services.value.map((s) => s.name)); + +const handleScopedAction = (action: "start" | "stop" | "restart" | "rebuild" | "pull", scope: string) => { + if (scope === "deployment") { + if (action === "rebuild") { + showRebuildModal.value = true; + } else if (action === "pull") { + openPullImageModal(); + } else { + handleOperation(action); + } + return; + } + runServiceAction({ name: scope }, action); +}; + +const serviceActionBusy = ref(""); + +const serviceActionPastTense: Record = { + start: "started", + stop: "stopped", + restart: "restarted", + rebuild: "rebuilt", + pull: "image pulled", +}; + +const runServiceAction = async ( + service: { name: string }, + action: "start" | "stop" | "restart" | "rebuild" | "pull", +) => { + serviceActionBusy.value = service.name; + try { + const name = route.params.name as string; + const result = await runGuarded( + () => deploymentsApi.serviceAction(name, service.name, action), + () => deploymentsApi.serviceAction(name, service.name, action, { plan: true }), + "Service Action Failed", + ); + if (result === false) return; + notifications.success("Service Updated", `${service.name} ${serviceActionPastTense[action]}`); + await fetchDeployment(); + } finally { + serviceActionBusy.value = ""; + } }; const terminalRef = ref | null>(null); @@ -2540,12 +2690,12 @@ const saveEnvVarsToServer = async () => { savingEnvVars.value = true; try { const envData = envVars.value.map((e) => ({ key: e.key, value: e.value })); - await deploymentsApi.updateEnvVars(route.params.name as string, envData); - return true; - } catch (err: any) { - const msg = err.response?.data?.error || err.message; - notifications.error("Save Failed", msg); - return false; + const result = await runGuarded( + () => deploymentsApi.updateEnvVars(route.params.name as string, envData), + () => deploymentsApi.updateEnvVars(route.params.name as string, envData, { plan: true }), + "Save Failed", + ); + return result !== false; } finally { savingEnvVars.value = false; } @@ -2767,17 +2917,15 @@ const cancelServiceConfigEdit = () => { }; const saveConfig = async () => { - try { - await deploymentsApi.update(route.params.name as string, { - compose_content: composeConfig.value, - }); - originalConfig = composeConfig.value; - isEditingConfig.value = false; - notifications.success("Saved", "Configuration saved successfully"); - } catch (err: any) { - const msg = err.response?.data?.error || err.message; - notifications.error("Save Failed", msg); - } + const result = await runGuarded( + () => deploymentsApi.update(route.params.name as string, { compose_content: composeConfig.value }), + () => deploymentsApi.update(route.params.name as string, { compose_content: composeConfig.value }, { plan: true }), + "Save Failed", + ); + if (result === false) return; + originalConfig = composeConfig.value; + isEditingConfig.value = false; + notifications.success("Saved", "Configuration saved successfully"); }; const saveServiceConfig = async () => { @@ -3642,43 +3790,6 @@ onUnmounted(() => { padding: var(--space-4); } -.config-sub-tabs { - display: flex; - gap: var(--space-2); - margin-bottom: var(--space-4); - border-bottom: 1px solid var(--color-gray-200); - padding-bottom: var(--space-2); -} - -.config-sub-tab { - display: flex; - align-items: center; - gap: var(--space-2); - padding: var(--space-2) var(--space-4); - border: none; - background: transparent; - border-radius: var(--radius-sm); - font-size: var(--text-sm); - font-weight: var(--font-medium); - color: var(--color-gray-500); - cursor: pointer; - transition: all var(--transition-base); -} - -.config-sub-tab:hover { - color: var(--color-gray-700); - background: var(--color-gray-100); -} - -.config-sub-tab.active { - color: var(--color-primary-600); - background: var(--color-primary-50); -} - -.config-sub-tab i { - font-size: var(--text-sm); -} - .config-section { display: flex; flex-direction: column; @@ -4614,6 +4725,81 @@ onUnmounted(() => { gap: 1.25rem; } +.settings-list { + background: white; + border: 1px solid #e5e7eb; + border-radius: var(--radius-md); + overflow: hidden; + margin: 5px; +} + +.settings-row + .settings-row { + border-top: 1px solid #f3f4f6; +} + +.settings-row-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + padding: 1rem 1.25rem; + cursor: pointer; +} + +.settings-row-header:hover { + background: #fafafa; +} + +.row-title { + display: flex; + align-items: flex-start; + gap: 0.75rem; +} + +.row-title i { + color: #3b82f6; + font-size: 1rem; + margin-top: 0.15rem; +} + +.row-text h4 { + margin: 0; + font-size: 0.9rem; + font-weight: 600; + color: var(--text-primary, #1e293b); +} + +.row-text p { + margin: 0.15rem 0 0; + font-size: 0.8rem; + color: #6b7280; +} + +.row-controls { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.chevron-btn { + border: none; + background: none; + color: #9ca3af; + cursor: pointer; + padding: 0.25rem; +} + +.settings-row-body { + padding: 0 1.25rem 1.25rem; +} + +.row-description { + margin: 0 0 0.75rem; + font-size: 0.8125rem; + color: #6b7280; + line-height: 1.5; +} + .security-section { background: white; border-radius: var(--radius-sm); diff --git a/vitest.config.ts b/vitest.config.ts index b7ed526..11ff67e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ test: { environment: "happy-dom", globals: true, + setupFiles: ["./vitest.setup.ts"], }, resolve: { alias: { diff --git a/vitest.setup.ts b/vitest.setup.ts new file mode 100644 index 0000000..1d2a1f8 --- /dev/null +++ b/vitest.setup.ts @@ -0,0 +1,34 @@ +// Node 22+ defines global web storage stubs that are non-functional without +// --localstorage-file and shadow happy-dom's implementation, so tests get a +// localStorage whose methods are undefined. Replace both globals with a +// working in-memory implementation. +const createMemoryStorage = (): Storage => { + let store = new Map(); + return { + get length() { + return store.size; + }, + clear: () => { + store = new Map(); + }, + getItem: (key: string) => (store.has(key) ? store.get(key)! : null), + key: (index: number) => Array.from(store.keys())[index] ?? null, + removeItem: (key: string) => { + store.delete(key); + }, + setItem: (key: string, value: string) => { + store.set(key, String(value)); + }, + }; +}; + +for (const name of ["localStorage", "sessionStorage"] as const) { + const storage = createMemoryStorage(); + for (const target of [globalThis, window]) { + Object.defineProperty(target, name, { + value: storage, + writable: true, + configurable: true, + }); + } +} From 4657aa0160f1873610c8344079a5962f9229e77c Mon Sep 17 00:00:00 2001 From: nfebe Date: Sun, 7 Jun 2026 19:54:58 +0100 Subject: [PATCH 2/7] feat(ai): Add the AI assistant across the dashboard Every surface that shows logs now carries an AI action in the log toolbar: deployment logs, operation output in both operation modals, and any future use of the shared log viewer. The assistant opens in one global modal where the user can switch between diagnose, improve, secure and explain, and ask free-text follow-up questions against the same context. When AI is not configured, the entry points stay visible and lead to the settings page instead of silently hiding. Analyses can return suggested actions; each is shown with its command or action and reasoning, and runs only on explicit click through the same APIs a human would use, so permissions, protected mode and plan review apply unchanged, with output shown inline. Failed operations default to diagnosis and successful ones to explanation. The assistant is configured from a new settings tab: any OpenAI-compatible endpoint, model name and a write-only API key, applied to the running agent without a restart. --- package-lock.json | 30 +++ package.json | 2 + src/App.vue | 2 + src/components/LogViewer.vue | 29 +++ src/components/OperationModal.vue | 17 ++ src/components/ai/AssistButton.vue | 27 ++ src/components/ai/AssistHost.vue | 24 ++ src/components/ai/AssistModal.test.ts | 122 +++++++++ src/components/ai/AssistModal.vue | 360 ++++++++++++++++++++++++++ src/services/api.ts | 36 ++- src/stores/ai.test.ts | 50 ++++ src/stores/ai.ts | 22 ++ src/stores/assist.ts | 148 +++++++++++ src/views/DeploymentDetailView.vue | 26 ++ src/views/SettingsView.test.ts | 5 +- src/views/SettingsView.vue | 99 ++++++- 16 files changed, 992 insertions(+), 7 deletions(-) create mode 100644 src/components/ai/AssistButton.vue create mode 100644 src/components/ai/AssistHost.vue create mode 100644 src/components/ai/AssistModal.test.ts create mode 100644 src/components/ai/AssistModal.vue create mode 100644 src/stores/ai.test.ts create mode 100644 src/stores/ai.ts create mode 100644 src/stores/assist.ts diff --git a/package-lock.json b/package-lock.json index 47b6fc4..0c22ba0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,9 @@ "@xterm/xterm": "^5.5.0", "axios": "^1.6.7", "diff": "^9.0.0", + "dompurify": "^3.4.8", "lucide-vue-next": "^0.554.0", + "marked": "^18.0.5", "pinia": "^2.1.7", "primeicons": "^6.0.1", "primevue": "^3.50.0", @@ -1321,6 +1323,13 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/whatwg-mimetype": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", @@ -2254,6 +2263,15 @@ "node": ">=6.0.0" } }, + "node_modules/dompurify": { + "version": "3.4.8", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz", + "integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3404,6 +3422,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", diff --git a/package.json b/package.json index 2455bad..1e880f4 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,9 @@ "@xterm/xterm": "^5.5.0", "axios": "^1.6.7", "diff": "^9.0.0", + "dompurify": "^3.4.8", "lucide-vue-next": "^0.554.0", + "marked": "^18.0.5", "pinia": "^2.1.7", "primeicons": "^6.0.1", "primevue": "^3.50.0", diff --git a/src/App.vue b/src/App.vue index 42b136e..2ec13e3 100644 --- a/src/App.vue +++ b/src/App.vue @@ -2,6 +2,7 @@
+
@@ -13,6 +14,7 @@ import { ref, onMounted } from "vue"; import ToastNotifications from "@/components/ToastNotifications.vue"; import PlanFlowHost from "@/components/plan/PlanFlowHost.vue"; +import AssistHost from "@/components/ai/AssistHost.vue"; import Logo from "@/components/base/Logo.vue"; import { useSetupStore } from "@/stores/setup"; import { useRouter } from "vue-router"; diff --git a/src/components/LogViewer.vue b/src/components/LogViewer.vue index a65026d..3f2e36b 100644 --- a/src/components/LogViewer.vue +++ b/src/components/LogViewer.vue @@ -32,6 +32,14 @@ Follow + @@ -64,6 +72,7 @@ import { FitAddon } from "@xterm/addon-fit"; import { SearchAddon } from "@xterm/addon-search"; import { WebLinksAddon } from "@xterm/addon-web-links"; import "@xterm/xterm/css/xterm.css"; +import { useAssistStore, type AssistContext } from "@/stores/assist"; const props = withDefaults( defineProps<{ @@ -74,6 +83,7 @@ const props = withDefaults( theme?: "dark" | "light"; fontSize?: number; lineHeight?: number; + assistContext?: AssistContext | null; }>(), { logs: "", @@ -83,6 +93,7 @@ const props = withDefaults( theme: "dark", fontSize: 13, lineHeight: 1.4, + assistContext: null, }, ); @@ -90,6 +101,24 @@ defineEmits<{ refresh: []; }>(); +// Every surface that renders logs gets the AI action for free. A +// parent can pass a richer context (deployment scope unlocks gathered +// sources and runnable suggestions); without one the visible logs are +// analyzed as host-level output. +const openAssist = () => { + const store = useAssistStore(); + if (props.assistContext) { + store.open(props.assistContext); + return; + } + store.open({ + scope: "system", + subject: props.fileName.replace(/\.txt$/, ""), + intent: "diagnose", + sources: [{ type: "provided", label: "Logs", content: props.logs }], + }); +}; + const terminalContainer = ref(null); const autoScroll = ref(true); const showSearch = ref(false); diff --git a/src/components/OperationModal.vue b/src/components/OperationModal.vue index 3c13429..b0d8d6e 100644 --- a/src/components/OperationModal.vue +++ b/src/components/OperationModal.vue @@ -26,6 +26,7 @@ empty-message="Waiting for output..." :file-name="`${deploymentName}-${operation}.txt`" :max-height="300" + :assist-context="isRunning ? null : assistContext" />
@@ -39,6 +40,7 @@ import { ref, computed, watch } from "vue"; import BaseModal from "./base/BaseModal.vue"; import LogViewer from "./LogViewer.vue"; +import type { AssistContext } from "@/stores/assist"; const props = defineProps<{ visible: boolean; @@ -51,6 +53,21 @@ const props = defineProps<{ const emit = defineEmits(["close"]); +const assistContext = computed(() => ({ + scope: "deployment", + deployment: props.deploymentName, + subject: props.deploymentName, + intent: props.isSuccess ? "explain" : "diagnose", + sources: [ + { + type: "provided", + label: `Output of ${props.operation} (${props.isSuccess ? "succeeded" : "failed"})`, + content: props.output || "(no output captured)", + }, + { type: "compose" }, + ], +})); + const startTime = ref(null); const elapsedTime = ref("0s"); diff --git a/src/components/ai/AssistButton.vue b/src/components/ai/AssistButton.vue new file mode 100644 index 0000000..aa974a2 --- /dev/null +++ b/src/components/ai/AssistButton.vue @@ -0,0 +1,27 @@ + + + diff --git a/src/components/ai/AssistHost.vue b/src/components/ai/AssistHost.vue new file mode 100644 index 0000000..4e4218f --- /dev/null +++ b/src/components/ai/AssistHost.vue @@ -0,0 +1,24 @@ + + + diff --git a/src/components/ai/AssistModal.test.ts b/src/components/ai/AssistModal.test.ts new file mode 100644 index 0000000..d02f4ad --- /dev/null +++ b/src/components/ai/AssistModal.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, vi } from "vitest"; +import { mount } from "@vue/test-utils"; +import AssistModal from "./AssistModal.vue"; +import type { AIAnalysis } from "@/services/api"; + +// DOMPurify misbehaves under happy-dom (drops safe tags, keeps event +// handlers), so the mock verifies the component routes its output +// through sanitize rather than re-testing the library. +vi.mock("dompurify", () => ({ + default: { + sanitize: (html: string) => + html.replace(/\son\w+="[^"]*"/g, "").replace(//g, ""), + }, +})); + +const result: AIAnalysis = { + analysis: "## Diagnosis\nThe **database** is unreachable.", + suggested_actions: [ + { + kind: "service_action", + service: "db", + action: "restart", + title: "Restart the database", + reason: "connection refused", + }, + { kind: "exec", service: "web", command: "php artisan config:clear", title: "Clear config cache" }, + ], + intent: "diagnose", + model: "test-model", + redactions: 3, +}; + +const mountModal = (props = {}) => + mount(AssistModal, { + props: { + visible: true, + subject: "myapp", + ...props, + }, + global: { + stubs: { Teleport: true, Transition: false, RouterLink: { template: "" } }, + }, + }); + +describe("AssistModal", () => { + it("shows a loading state", () => { + const wrapper = mountModal({ loading: true }); + expect(wrapper.find(".assist-loading").exists()).toBe(true); + expect(wrapper.text()).toContain("Analyzing myapp"); + }); + + it("shows the error state", () => { + const wrapper = mountModal({ error: "provider returned 401" }); + expect(wrapper.find(".assist-error").text()).toContain("provider returned 401"); + }); + + it("offers a settings link when AI is not configured", () => { + const wrapper = mountModal({ error: "not configured", settingsHint: true }); + expect(wrapper.find(".assist-error.informational").exists()).toBe(true); + expect(wrapper.text()).toContain("Open AI Settings"); + expect(wrapper.find(".intent-bar").exists()).toBe(false); + }); + + it("renders intent chips and emits rerun on click", async () => { + const wrapper = mountModal({ result }); + const chips = wrapper.findAll(".intent-chip"); + expect(chips.map((c) => c.text())).toEqual(["Diagnose", "Improve", "Secure", "Explain"]); + + await chips[2].trigger("click"); + const emitted = wrapper.emitted("rerun"); + expect(emitted).toHaveLength(1); + expect(emitted![0][0]).toBe("secure"); + }); + + it("emits rerun with the question on Ask", async () => { + const wrapper = mountModal({ result, intent: "explain" }); + await wrapper.find(".question-input").setValue("what does this service do?"); + await wrapper.find(".question-bar button").trigger("click"); + const emitted = wrapper.emitted("rerun"); + expect(emitted![0]).toEqual(["explain", "what does this service do?"]); + }); + + it("renders the analysis as sanitized markdown", () => { + const wrapper = mountModal({ result }); + const html = wrapper.find(".analysis-markdown").html(); + expect(html).toContain("

"); + expect(html).toContain("database"); + }); + + it("strips scriptable content from the analysis", () => { + const wrapper = mountModal({ + result: { ...result, analysis: 'hello ' }, + }); + const html = wrapper.find(".analysis-markdown").html(); + expect(html).not.toContain("onerror"); + expect(html).not.toContain(" + + diff --git a/src/services/api.ts b/src/services/api.ts index f615aa2..c342c11 100755 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -296,20 +296,46 @@ export interface AIStatus { enabled: boolean; model?: string; base_url_host?: string; + intents?: string[]; +} + +export type AssistIntent = "diagnose" | "improve" | "secure" | "explain"; + +export interface AssistSource { + type: "logs" | "compose" | "provided"; + label?: string; + content?: string; + tail?: number; +} + +export interface AssistRequestBody { + intent: AssistIntent; + sources?: AssistSource[]; + question?: string; +} + +export interface AISuggestedAction { + kind: "exec" | "service_action"; + service?: string; + action?: "start" | "stop" | "restart" | "rebuild" | "pull"; + command?: string; + title: string; + reason?: string; } export interface AIAnalysis { analysis: string; + suggested_actions: AISuggestedAction[]; + intent: string; model: string; redactions: number; } export const aiApi = { status: () => apiClient.get("/ai/status"), - analyze: ( - name: string, - body: { mode: "logs" | "operation"; tail?: number; operation?: string; operation_output?: string }, - ) => apiClient.post(`/deployments/${name}/ai/analyze`, body), + assistDeployment: (name: string, body: AssistRequestBody) => + apiClient.post(`/deployments/${name}/ai/analyze`, body), + assistSystem: (body: AssistRequestBody) => apiClient.post("/ai/analyze", body), }; export const pluginsApi = { @@ -419,6 +445,8 @@ export const containersApi = { restart: (id: string) => apiClient.post(`/containers/${id}/restart`), remove: (id: string) => apiClient.delete(`/containers/${id}`), logs: (id: string) => apiClient.get(`/containers/${id}/logs`), + exec: (id: string, command: string, args: string[] = []) => + apiClient.post<{ output: string }>(`/containers/${id}/exec`, { command, args }), getAllStats: () => apiClient.get<{ stats: Array<{ diff --git a/src/stores/ai.test.ts b/src/stores/ai.test.ts new file mode 100644 index 0000000..280ec12 --- /dev/null +++ b/src/stores/ai.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createPinia, setActivePinia } from "pinia"; +import { useAIStore } from "./ai"; + +vi.mock("@/services/api", () => ({ + aiApi: { + status: vi.fn(), + }, +})); + +describe("ai store", () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.clearAllMocks(); + }); + + it("caches the status after the first fetch", async () => { + const { aiApi } = await import("@/services/api"); + vi.mocked(aiApi.status).mockResolvedValue({ data: { enabled: true, model: "llama3" } } as any); + + const store = useAIStore(); + await store.fetchStatus(); + await store.fetchStatus(); + + expect(aiApi.status).toHaveBeenCalledTimes(1); + expect(store.status?.enabled).toBe(true); + expect(store.status?.model).toBe("llama3"); + }); + + it("treats a failed status fetch as disabled", async () => { + const { aiApi } = await import("@/services/api"); + vi.mocked(aiApi.status).mockRejectedValue(new Error("network")); + + const store = useAIStore(); + await store.fetchStatus(); + + expect(store.status?.enabled).toBe(false); + }); + + it("refetches when forced", async () => { + const { aiApi } = await import("@/services/api"); + vi.mocked(aiApi.status).mockResolvedValue({ data: { enabled: false } } as any); + + const store = useAIStore(); + await store.fetchStatus(); + await store.fetchStatus(true); + + expect(aiApi.status).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/stores/ai.ts b/src/stores/ai.ts new file mode 100644 index 0000000..11ea8fc --- /dev/null +++ b/src/stores/ai.ts @@ -0,0 +1,22 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; +import { aiApi, type AIStatus } from "@/services/api"; + +export const useAIStore = defineStore("ai", () => { + const status = ref(null); + const loaded = ref(false); + + async function fetchStatus(force = false) { + if (loaded.value && !force) return; + try { + const response = await aiApi.status(); + status.value = response.data; + } catch { + status.value = { enabled: false }; + } finally { + loaded.value = true; + } + } + + return { status, loaded, fetchStatus }; +}); diff --git a/src/stores/assist.ts b/src/stores/assist.ts new file mode 100644 index 0000000..bcba12c --- /dev/null +++ b/src/stores/assist.ts @@ -0,0 +1,148 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; +import { + aiApi, + containersApi, + deploymentsApi, + type AIAnalysis, + type AISuggestedAction, + type AssistIntent, + type AssistSource, +} from "@/services/api"; +import { useAIStore } from "@/stores/ai"; +import { usePlanFlowStore } from "@/stores/planFlow"; + +export const AI_DISABLED_MESSAGE = + "The AI assistant is not configured. An admin can connect any OpenAI-compatible provider under Settings, AI Assistant."; + +// AssistContext describes what the assistant is looking at. Any +// surface in the app can open the assistant by supplying one; the +// modal lets the user switch intent or ask follow-up questions against +// the same context. +export interface AssistContext { + scope: "deployment" | "system"; + deployment?: string; + subject: string; + sources: AssistSource[]; + intent?: AssistIntent; + question?: string; +} + +export const useAssistStore = defineStore("assist", () => { + const visible = ref(false); + const loading = ref(false); + const error = ref(""); + const result = ref(null); + const context = ref(null); + const intent = ref("diagnose"); + const runningIndex = ref(null); + const suggestionOutputs = ref>({}); + + function resetRun() { + loading.value = false; + error.value = ""; + result.value = null; + runningIndex.value = null; + suggestionOutputs.value = {}; + } + + async function ensureEnabled(): Promise { + const aiStore = useAIStore(); + await aiStore.fetchStatus(); + if (aiStore.status?.enabled) return true; + error.value = AI_DISABLED_MESSAGE; + return false; + } + + async function execute() { + const ctx = context.value; + if (!ctx) return; + resetRun(); + if (!(await ensureEnabled())) return; + loading.value = true; + try { + const body = { intent: intent.value, sources: ctx.sources, question: ctx.question }; + const response = + ctx.scope === "deployment" && ctx.deployment + ? await aiApi.assistDeployment(ctx.deployment, body) + : await aiApi.assistSystem(body); + result.value = response.data; + } catch (err: any) { + error.value = err.response?.data?.error || err.message; + } finally { + loading.value = false; + } + } + + async function open(ctx: AssistContext) { + context.value = ctx; + intent.value = ctx.intent || "diagnose"; + visible.value = true; + await execute(); + } + + async function rerun(newIntent: AssistIntent, question?: string) { + if (!context.value) return; + intent.value = newIntent; + context.value = { ...context.value, question }; + await execute(); + } + + async function runSuggestion(suggestion: AISuggestedAction, index: number) { + const name = context.value?.deployment; + if (!name) return; + runningIndex.value = index; + try { + if (suggestion.kind === "service_action" && suggestion.service && suggestion.action) { + const planFlow = usePlanFlowStore(); + const actionResult = await planFlow.runGuarded( + () => deploymentsApi.serviceAction(name, suggestion.service!, suggestion.action!), + () => deploymentsApi.serviceAction(name, suggestion.service!, suggestion.action!, { plan: true }), + "Action Failed", + ); + if (actionResult === false) return; + suggestionOutputs.value = { ...suggestionOutputs.value, [index]: actionResult.data?.output || "Done" }; + } else if (suggestion.kind === "exec" && suggestion.command) { + const services = await deploymentsApi.getServices(name); + const container = services.data.services?.find((svc) => svc.name === suggestion.service); + if (!container?.container_id) { + suggestionOutputs.value = { + ...suggestionOutputs.value, + [index]: `No running container found for service "${suggestion.service}"`, + }; + return; + } + const response = await containersApi.exec(container.container_id, "sh", ["-c", suggestion.command]); + suggestionOutputs.value = { ...suggestionOutputs.value, [index]: response.data.output || "(no output)" }; + } + } catch (err: any) { + suggestionOutputs.value = { + ...suggestionOutputs.value, + [index]: err.response?.data?.output || err.response?.data?.error || err.message, + }; + } finally { + runningIndex.value = null; + } + } + + function close() { + visible.value = false; + context.value = null; + resetRun(); + } + + return { + visible, + loading, + error, + result, + context, + intent, + runningIndex, + suggestionOutputs, + open, + rerun, + runSuggestion, + close, + }; +}); diff --git a/src/views/DeploymentDetailView.vue b/src/views/DeploymentDetailView.vue index 7f5c737..2e33160 100755 --- a/src/views/DeploymentDetailView.vue +++ b/src/views/DeploymentDetailView.vue @@ -507,6 +507,7 @@ :loading="logsLoading" :file-name="`${deployment?.name || 'deployment'}-logs.txt`" empty-message="No logs available" + :assist-context="logsAssistContext" @refresh="fetchLogs" > From 54195289be63719c98adc003a12e4cf10e17abbd Mon Sep 17 00:00:00 2001 From: nfebe Date: Sun, 7 Jun 2026 20:07:37 +0100 Subject: [PATCH 3/7] fix(ai): Make assistant icons render and status refresh after setup The AI icon used a glyph that only exists in PrimeIcons 7 while the app pinned version 6, so every assistant button rendered as an empty space; the icon set is upgraded, which is additive and keeps all existing icons. The assistant also kept telling users to enable AI after they had just configured it, because the enabled status was cached from before setup; the status is now re-checked when it reads disabled and refreshed immediately after saving AI settings. --- package-lock.json | 8 ++++---- package.json | 2 +- src/stores/assist.ts | 4 ++++ src/views/SettingsView.vue | 2 ++ 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0c22ba0..2c1d4d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "lucide-vue-next": "^0.554.0", "marked": "^18.0.5", "pinia": "^2.1.7", - "primeicons": "^6.0.1", + "primeicons": "^7.0.0", "primevue": "^3.50.0", "vue": "^3.4.21", "vue-codemirror": "^6.1.1", @@ -3854,9 +3854,9 @@ } }, "node_modules/primeicons": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/primeicons/-/primeicons-6.0.1.tgz", - "integrity": "sha512-KDeO94CbWI4pKsPnYpA1FPjo79EsY9I+M8ywoPBSf9XMXoe/0crjbUK7jcQEDHuc0ZMRIZsxH3TYLv4TUtHmAA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/primeicons/-/primeicons-7.0.0.tgz", + "integrity": "sha512-jK3Et9UzwzTsd6tzl2RmwrVY/b8raJ3QZLzoDACj+oTJ0oX7L9Hy+XnVwgo4QVKlKpnP/Ur13SXV/pVh4LzaDw==", "license": "MIT" }, "node_modules/primevue": { diff --git a/package.json b/package.json index 1e880f4..69be4de 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "lucide-vue-next": "^0.554.0", "marked": "^18.0.5", "pinia": "^2.1.7", - "primeicons": "^6.0.1", + "primeicons": "^7.0.0", "primevue": "^3.50.0", "vue": "^3.4.21", "vue-codemirror": "^6.1.1", diff --git a/src/stores/assist.ts b/src/stores/assist.ts index bcba12c..cb673fa 100644 --- a/src/stores/assist.ts +++ b/src/stores/assist.ts @@ -49,6 +49,10 @@ export const useAssistStore = defineStore("assist", () => { async function ensureEnabled(): Promise { const aiStore = useAIStore(); await aiStore.fetchStatus(); + if (!aiStore.status?.enabled) { + // The cached status may predate the assistant being configured. + await aiStore.fetchStatus(true); + } if (aiStore.status?.enabled) return true; error.value = AI_DISABLED_MESSAGE; return false; diff --git a/src/views/SettingsView.vue b/src/views/SettingsView.vue index f9124c0..41bff27 100644 --- a/src/views/SettingsView.vue +++ b/src/views/SettingsView.vue @@ -976,6 +976,7 @@ import type { DomainSettings } from "@/services/api"; import type { ProtectedCommandRule, ProtectedModeConfig, RegistryCredential, RegistryType } from "@/types"; import { useNotificationsStore } from "@/stores/notifications"; import { useAuthStore } from "@/stores/auth"; +import { useAIStore } from "@/stores/ai"; import SecurityHealthCard from "@/components/SecurityHealthCard.vue"; import { matchTypeHints, describeBlockedRule } from "@/utils/protectedMode"; @@ -1563,6 +1564,7 @@ const saveAISettings = async () => { } const response = await configApi.set("ai.enabled", aiSettings.enabled); aiSettings.api_key = ""; + await useAIStore().fetchStatus(true); notifications.success("Saved", response.data.applied ? "AI settings applied immediately" : "AI settings saved"); } catch (e: any) { notifications.error("Error", e.response?.data?.error || "Failed to save AI settings"); From 710e993420e43a18fe4be6e4f4496bbac31df748 Mon Sep 17 00:00:00 2001 From: nfebe Date: Sun, 7 Jun 2026 20:25:01 +0100 Subject: [PATCH 4/7] fix(ai): Use lucide icons for the AI assistant All AI entry points (log toolbar action, assistant modal header and hints, the assist button, the AI settings tab) now render the lucide sparkles icon instead of a PrimeIcons glyph that only exists in a newer icon-font version than the app ships, which left every AI button as an empty space. The icon set version is reverted; new features should use lucide as the project moves away from PrimeIcons, and the settings tab bar can now render lucide icons alongside the legacy class-based ones. --- package-lock.json | 8 ++--- package.json | 2 +- src/components/LogViewer.vue | 3 +- src/components/ai/AssistButton.vue | 3 +- src/components/ai/AssistModal.vue | 56 ++++++++++++++++++++++++------ src/views/SettingsView.test.ts | 2 +- src/views/SettingsView.vue | 14 +++++--- 7 files changed, 65 insertions(+), 23 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2c1d4d1..0c22ba0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "lucide-vue-next": "^0.554.0", "marked": "^18.0.5", "pinia": "^2.1.7", - "primeicons": "^7.0.0", + "primeicons": "^6.0.1", "primevue": "^3.50.0", "vue": "^3.4.21", "vue-codemirror": "^6.1.1", @@ -3854,9 +3854,9 @@ } }, "node_modules/primeicons": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/primeicons/-/primeicons-7.0.0.tgz", - "integrity": "sha512-jK3Et9UzwzTsd6tzl2RmwrVY/b8raJ3QZLzoDACj+oTJ0oX7L9Hy+XnVwgo4QVKlKpnP/Ur13SXV/pVh4LzaDw==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/primeicons/-/primeicons-6.0.1.tgz", + "integrity": "sha512-KDeO94CbWI4pKsPnYpA1FPjo79EsY9I+M8ywoPBSf9XMXoe/0crjbUK7jcQEDHuc0ZMRIZsxH3TYLv4TUtHmAA==", "license": "MIT" }, "node_modules/primevue": { diff --git a/package.json b/package.json index 69be4de..1e880f4 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "lucide-vue-next": "^0.554.0", "marked": "^18.0.5", "pinia": "^2.1.7", - "primeicons": "^7.0.0", + "primeicons": "^6.0.1", "primevue": "^3.50.0", "vue": "^3.4.21", "vue-codemirror": "^6.1.1", diff --git a/src/components/LogViewer.vue b/src/components/LogViewer.vue index 3f2e36b..0580bf0 100644 --- a/src/components/LogViewer.vue +++ b/src/components/LogViewer.vue @@ -38,7 +38,7 @@ title="Analyze these logs with the AI assistant" @click="openAssist" > - +
@@ -455,8 +459,11 @@ @@ -1053,6 +1067,37 @@ onMounted(() => { z-index: 50; } +.ai-ask-launcher { + position: absolute; + left: 50%; + transform: translateX(-50%); + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.45rem 1rem; + border: 1px solid #dbeafe; + border-radius: 999px; + background: linear-gradient(180deg, #eff6ff, #f5f9ff); + color: #2563eb; + font-size: 0.82rem; + font-weight: 500; + cursor: pointer; + transition: + box-shadow 0.15s ease, + border-color 0.15s ease; +} + +.ai-ask-launcher:hover { + border-color: #bfdbfe; + box-shadow: 0 2px 8px rgba(37, 99, 235, 0.15); +} + +@media (max-width: 900px) { + .ai-ask-launcher span { + display: none; + } +} + .breadcrumb { font-size: 0.75rem; color: #64748b; diff --git a/src/services/api.ts b/src/services/api.ts index c342c11..6402257 100755 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -331,11 +331,46 @@ export interface AIAnalysis { redactions: number; } +export interface AIToolCall { + id: string; + name: string; + arguments: string; +} + +export interface AIToolStep { + name: string; + arguments: string; + result?: string; +} + +export interface AITurn { + role: "user" | "assistant"; + content?: string; + tool_steps?: AIToolStep[]; +} + +export interface AISession { + id: string; + scope: "system" | "deployment"; + deployment?: string; + auto_run: boolean; + status: "ready" | "awaiting_approval"; + model?: string; + messages: AITurn[]; + pending: AIToolCall[]; + suggested_actions: AISuggestedAction[]; +} + export const aiApi = { status: () => apiClient.get("/ai/status"), - assistDeployment: (name: string, body: AssistRequestBody) => - apiClient.post(`/deployments/${name}/ai/analyze`, body), - assistSystem: (body: AssistRequestBody) => apiClient.post("/ai/analyze", body), + createSession: (body: { scope: "system" | "deployment"; deployment?: string; auto_run: boolean; message: string }) => + apiClient.post("/ai/sessions", body), + sessionMessage: (id: string, message: string) => + apiClient.post(`/ai/sessions/${id}/messages`, { message }), + approveSession: (id: string, approved: Record) => + apiClient.post(`/ai/sessions/${id}/approve`, { approved }), + getSession: (id: string) => apiClient.get(`/ai/sessions/${id}`), + deleteSession: (id: string) => apiClient.delete<{ message: string; id: string }>(`/ai/sessions/${id}`), }; export const pluginsApi = { diff --git a/src/stores/assist.test.ts b/src/stores/assist.test.ts new file mode 100644 index 0000000..6f34897 --- /dev/null +++ b/src/stores/assist.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createPinia, setActivePinia } from "pinia"; +import { useAssistStore, AI_DISABLED_MESSAGE } from "./assist"; +import { useAIStore } from "./ai"; +import type { AISession } from "@/services/api"; + +vi.mock("@/services/api", () => ({ + aiApi: { + status: vi.fn(), + createSession: vi.fn(), + sessionMessage: vi.fn(), + approveSession: vi.fn(), + }, + containersApi: { exec: vi.fn() }, + deploymentsApi: { serviceAction: vi.fn(), getServices: vi.fn() }, +})); + +const session = (over: Partial = {}): AISession => ({ + id: "ais_1", + scope: "system", + auto_run: true, + status: "ready", + messages: [ + { role: "user", content: "what deployments do I have?" }, + { role: "assistant", content: "You have one: myapp.", tool_steps: [{ name: "list_deployments", arguments: "{}", result: "- myapp" }] }, + ], + pending: [], + suggested_actions: [], + ...over, +}); + +describe("assist store", () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.clearAllMocks(); + }); + + async function enable() { + const { aiApi } = await import("@/services/api"); + vi.mocked(aiApi.status).mockResolvedValue({ data: { enabled: true } } as any); + } + + it("shows the disabled hint when AI is off", async () => { + const { aiApi } = await import("@/services/api"); + vi.mocked(aiApi.status).mockResolvedValue({ data: { enabled: false } } as any); + + const store = useAssistStore(); + await store.open({ scope: "system", subject: "this instance" }); + + expect(store.error).toBe(AI_DISABLED_MESSAGE); + expect(store.session).toBeNull(); + }); + + it("creates a session from a seed message", async () => { + await enable(); + const { aiApi } = await import("@/services/api"); + vi.mocked(aiApi.createSession).mockResolvedValue({ data: session() } as any); + + const store = useAssistStore(); + await store.open({ scope: "deployment", deployment: "myapp", subject: "myapp", seedMessage: "diagnose" }); + + expect(aiApi.createSession).toHaveBeenCalledWith({ + scope: "deployment", + deployment: "myapp", + auto_run: true, + message: "diagnose", + }); + expect(store.session?.id).toBe("ais_1"); + }); + + it("sends follow-up messages on the existing session", async () => { + await enable(); + const { aiApi } = await import("@/services/api"); + vi.mocked(aiApi.createSession).mockResolvedValue({ data: session() } as any); + vi.mocked(aiApi.sessionMessage).mockResolvedValue({ data: session({ id: "ais_1" }) } as any); + + const store = useAssistStore(); + await store.open({ scope: "system", subject: "x", seedMessage: "first" }); + await store.send("second"); + + expect(aiApi.sessionMessage).toHaveBeenCalledWith("ais_1", "second"); + }); + + it("approves all pending tools", async () => { + await enable(); + const { aiApi } = await import("@/services/api"); + const pending = session({ + status: "awaiting_approval", + pending: [ + { id: "c1", name: "list_networks", arguments: "{}" }, + { id: "c2", name: "read_deployment_file", arguments: '{"path":"x"}' }, + ], + }); + vi.mocked(aiApi.createSession).mockResolvedValue({ data: pending } as any); + vi.mocked(aiApi.approveSession).mockResolvedValue({ data: session() } as any); + + const store = useAssistStore(); + await store.open({ scope: "system", subject: "x", seedMessage: "check", autoRun: false }); + store.approveAll(); + await Promise.resolve(); + + expect(aiApi.approveSession).toHaveBeenCalledWith("ais_1", { c1: true, c2: true }); + }); + + it("declines all pending tools", async () => { + await enable(); + const { aiApi } = await import("@/services/api"); + const pending = session({ + status: "awaiting_approval", + pending: [{ id: "c1", name: "list_networks", arguments: "{}" }], + }); + vi.mocked(aiApi.createSession).mockResolvedValue({ data: pending } as any); + vi.mocked(aiApi.approveSession).mockResolvedValue({ data: session() } as any); + + const store = useAssistStore(); + await store.open({ scope: "system", subject: "x", seedMessage: "check", autoRun: false }); + store.declineAll(); + await Promise.resolve(); + + expect(aiApi.approveSession).toHaveBeenCalledWith("ais_1", { c1: false }); + }); + + it("refreshes a stale disabled status before giving up", async () => { + const { aiApi } = await import("@/services/api"); + vi.mocked(aiApi.status) + .mockResolvedValueOnce({ data: { enabled: false } } as any) + .mockResolvedValueOnce({ data: { enabled: true } } as any); + vi.mocked(aiApi.createSession).mockResolvedValue({ data: session() } as any); + + // Prime the ai store cache as disabled. + const aiStore = useAIStore(); + await aiStore.fetchStatus(); + + const store = useAssistStore(); + await store.open({ scope: "system", subject: "x", seedMessage: "hi" }); + + expect(store.error).toBe(""); + expect(store.session?.id).toBe("ais_1"); + }); +}); diff --git a/src/stores/assist.ts b/src/stores/assist.ts index cb673fa..c3c84c6 100644 --- a/src/stores/assist.ts +++ b/src/stores/assist.ts @@ -4,10 +4,8 @@ import { aiApi, containersApi, deploymentsApi, - type AIAnalysis, + type AISession, type AISuggestedAction, - type AssistIntent, - type AssistSource, } from "@/services/api"; import { useAIStore } from "@/stores/ai"; import { usePlanFlowStore } from "@/stores/planFlow"; @@ -15,33 +13,35 @@ import { usePlanFlowStore } from "@/stores/planFlow"; export const AI_DISABLED_MESSAGE = "The AI assistant is not configured. An admin can connect any OpenAI-compatible provider under Settings, AI Assistant."; -// AssistContext describes what the assistant is looking at. Any -// surface in the app can open the assistant by supplying one; the -// modal lets the user switch intent or ask follow-up questions against -// the same context. +// AssistContext opens the assistant against something. scope "system" +// asks about the whole instance; "deployment" binds tools to one +// deployment and unlocks one-click suggested actions. seedMessage, when +// given, is sent as the first turn (used by log/operation entry points +// that already hold the content the user is looking at). export interface AssistContext { - scope: "deployment" | "system"; + scope: "system" | "deployment"; deployment?: string; subject: string; - sources: AssistSource[]; - intent?: AssistIntent; - question?: string; + seedMessage?: string; + autoRun?: boolean; } export const useAssistStore = defineStore("assist", () => { const visible = ref(false); + const subject = ref(""); + const scope = ref<"system" | "deployment">("system"); + const deployment = ref(undefined); + const autoRun = ref(true); + const session = ref(null); const loading = ref(false); const error = ref(""); - const result = ref(null); - const context = ref(null); - const intent = ref("diagnose"); const runningIndex = ref(null); const suggestionOutputs = ref>({}); - function resetRun() { + function reset() { + session.value = null; loading.value = false; error.value = ""; - result.value = null; runningIndex.value = null; suggestionOutputs.value = {}; } @@ -49,28 +49,40 @@ export const useAssistStore = defineStore("assist", () => { async function ensureEnabled(): Promise { const aiStore = useAIStore(); await aiStore.fetchStatus(); - if (!aiStore.status?.enabled) { - // The cached status may predate the assistant being configured. - await aiStore.fetchStatus(true); - } + if (!aiStore.status?.enabled) await aiStore.fetchStatus(true); if (aiStore.status?.enabled) return true; error.value = AI_DISABLED_MESSAGE; return false; } - async function execute() { - const ctx = context.value; - if (!ctx) return; - resetRun(); + async function open(ctx: AssistContext) { + reset(); + visible.value = true; + subject.value = ctx.subject; + scope.value = ctx.scope; + deployment.value = ctx.deployment; + autoRun.value = ctx.autoRun ?? true; if (!(await ensureEnabled())) return; + if (ctx.seedMessage) { + await send(ctx.seedMessage); + } + } + + async function send(message: string) { + if (loading.value) return; + error.value = ""; + suggestionOutputs.value = {}; loading.value = true; try { - const body = { intent: intent.value, sources: ctx.sources, question: ctx.question }; - const response = - ctx.scope === "deployment" && ctx.deployment - ? await aiApi.assistDeployment(ctx.deployment, body) - : await aiApi.assistSystem(body); - result.value = response.data; + const response = session.value + ? await aiApi.sessionMessage(session.value.id, message) + : await aiApi.createSession({ + scope: scope.value, + deployment: deployment.value, + auto_run: autoRun.value, + message, + }); + session.value = response.data; } catch (err: any) { error.value = err.response?.data?.error || err.message; } finally { @@ -78,22 +90,35 @@ export const useAssistStore = defineStore("assist", () => { } } - async function open(ctx: AssistContext) { - context.value = ctx; - intent.value = ctx.intent || "diagnose"; - visible.value = true; - await execute(); + async function resolveApprovals(approved: Record) { + if (!session.value || loading.value) return; + loading.value = true; + try { + const response = await aiApi.approveSession(session.value.id, approved); + session.value = response.data; + } catch (err: any) { + error.value = err.response?.data?.error || err.message; + } finally { + loading.value = false; + } + } + + function approveAll() { + if (!session.value) return; + const map: Record = {}; + session.value.pending.forEach((p) => (map[p.id] = true)); + resolveApprovals(map); } - async function rerun(newIntent: AssistIntent, question?: string) { - if (!context.value) return; - intent.value = newIntent; - context.value = { ...context.value, question }; - await execute(); + function declineAll() { + if (!session.value) return; + const map: Record = {}; + session.value.pending.forEach((p) => (map[p.id] = false)); + resolveApprovals(map); } async function runSuggestion(suggestion: AISuggestedAction, index: number) { - const name = context.value?.deployment; + const name = deployment.value; if (!name) return; runningIndex.value = index; try { @@ -131,21 +156,25 @@ export const useAssistStore = defineStore("assist", () => { function close() { visible.value = false; - context.value = null; - resetRun(); + reset(); } return { visible, + subject, + scope, + deployment, + autoRun, + session, loading, error, - result, - context, - intent, runningIndex, suggestionOutputs, open, - rerun, + send, + approveAll, + declineAll, + resolveApprovals, runSuggestion, close, }; diff --git a/src/views/DeploymentDetailView.vue b/src/views/DeploymentDetailView.vue index 2e33160..3835ad4 100755 --- a/src/views/DeploymentDetailView.vue +++ b/src/views/DeploymentDetailView.vue @@ -2633,23 +2633,19 @@ const logsAssistContext = computed(() => ({ scope: "deployment", deployment: route.params.name as string, subject: route.params.name as string, - intent: "diagnose", - sources: [{ type: "logs", tail: 300 }, { type: "compose" }], + seedMessage: + "Review this deployment's recent logs (read them with your tools) and tell me whether anything is wrong.", })); const operationAssistContext = computed(() => ({ scope: "deployment", deployment: route.params.name as string, subject: route.params.name as string, - intent: operationError.value ? "diagnose" : "explain", - sources: [ - { - type: "provided", - label: `Output of "${operationTitle.value}" operation`, - content: operationOutput.value || operationError.value || "(no output captured)", - }, - { type: "compose" }, - ], + seedMessage: `The "${operationTitle.value}" operation just ${ + operationError.value ? "failed" : "finished" + } with this output. Explain what happened${operationError.value ? " and how to fix it" : ""}:\n\n\`\`\`\n${ + operationOutput.value || operationError.value || "(no output captured)" + }\n\`\`\``, })); const serviceActionBusy = ref(""); From 426d20c85e182bf1959ebb5c405f6c3992f98856 Mon Sep 17 00:00:00 2001 From: nfebe Date: Sun, 7 Jun 2026 23:21:39 +0100 Subject: [PATCH 6/7] feat(ai): Inline allow/decline on tool calls and keep logs out of the transcript A proposed read-only lookup now shows with Allow and Decline buttons beside it, like a CLI tool prompt: a single pending call resolves on one click, and when several are proposed each gets its own decision (with allow-all and decline-all), showing each outcome as the batch runs. Seeded analyses (from a log view or an operation result) send the logs or output to the model as hidden context while the conversation shows only a short prompt, so the chat no longer echoes a wall of log lines. The header launcher is larger and now reads as running the instance, not just asking, and the empty prompt invites both questions and actions. --- src/components/LogViewer.vue | 27 +++++++++++----- src/components/OperationModal.vue | 5 ++- src/components/ai/AssistChat.test.ts | 28 +++++++++++++++++ src/components/ai/AssistChat.vue | 46 +++++++++++++++++++++++----- src/layouts/DashboardLayout.vue | 18 ++++++----- src/services/api.ts | 13 +++++--- src/stores/assist.test.ts | 3 +- src/stores/assist.ts | 28 +++++++++++++++-- src/views/DeploymentDetailView.vue | 7 ++--- 9 files changed, 136 insertions(+), 39 deletions(-) diff --git a/src/components/LogViewer.vue b/src/components/LogViewer.vue index fc11480..10d0a3b 100644 --- a/src/components/LogViewer.vue +++ b/src/components/LogViewer.vue @@ -106,18 +106,29 @@ defineEmits<{ // parent can pass a richer context (deployment scope unlocks gathered // sources and runnable suggestions); without one the visible logs are // analyzed as host-level output. +// The viewer already holds the logs on screen, so it hands them to the +// assistant directly: the model analyzes what the user is looking at +// instead of hunting for it with tools. The parent context only +// supplies scope/deployment/subject. const openAssist = () => { const store = useAssistStore(); - if (props.assistContext) { - store.open(props.assistContext); + const base = props.assistContext ?? { scope: "system" as const, subject: props.fileName.replace(/\.txt$/, "") }; + if (base.seedMessage) { + store.open(base); return; } - const subject = props.fileName.replace(/\.txt$/, ""); - store.open({ - scope: "system", - subject, - seedMessage: `Here are logs from ${subject}. Tell me what they show and whether anything is wrong.\n\n\`\`\`\n${props.logs}\n\`\`\``, - }); + if (props.logs) { + store.open({ + ...base, + seedMessage: `Analyze the recent logs for ${base.subject}: summarize what they show, report any problems with potential solutions, and say so plainly if everything looks normal.`, + seedContext: `\`\`\`\n${props.logs}\n\`\`\``, + }); + } else { + store.open({ + ...base, + seedMessage: `Review the recent logs for ${base.subject}, summarize them and report any problems with potential solutions.`, + }); + } }; const terminalContainer = ref(null); diff --git a/src/components/OperationModal.vue b/src/components/OperationModal.vue index bfb39c7..6475242 100644 --- a/src/components/OperationModal.vue +++ b/src/components/OperationModal.vue @@ -59,9 +59,8 @@ const assistContext = computed(() => ({ subject: props.deploymentName, seedMessage: `The "${props.operation}" operation just ${ props.isSuccess ? "finished" : "failed" - } with this output. Explain what happened${props.isSuccess ? "" : " and how to fix it"}:\n\n\`\`\`\n${ - props.output || "(no output captured)" - }\n\`\`\``, + }. Explain what happened${props.isSuccess ? "" : " and how to fix it"}.`, + seedContext: `\`\`\`\n${props.output || "(no output captured)"}\n\`\`\``, })); const startTime = ref(null); diff --git a/src/components/ai/AssistChat.test.ts b/src/components/ai/AssistChat.test.ts index 55f4357..c070f7a 100644 --- a/src/components/ai/AssistChat.test.ts +++ b/src/components/ai/AssistChat.test.ts @@ -9,6 +9,12 @@ vi.mock("dompurify", () => ({ default: { sanitize: (html: string) => html.replace(//g, "") }, })); +vi.mock("@/services/api", () => ({ + aiApi: { approveSession: vi.fn().mockResolvedValue({ data: { messages: [], pending: [], suggested_actions: [] } }) }, + containersApi: { exec: vi.fn() }, + deploymentsApi: { serviceAction: vi.fn(), getServices: vi.fn() }, +})); + const mountChat = () => mount(AssistChat, { global: { stubs: { Teleport: true, Transition: false, RouterLink: { template: "" } } }, @@ -65,7 +71,29 @@ describe("AssistChat", () => { const card = wrapper.find(".approval-card"); expect(card.exists()).toBe(true); expect(card.text()).toContain("exec in service: env"); + expect(card.find(".approval-buttons").exists()).toBe(true); expect(card.text()).toContain("Allow"); + expect(card.text()).toContain("Decline"); + }); + + it("submits a single decision on one click", async () => { + const { aiApi } = await import("@/services/api"); + const store = useAssistStore(); + store.visible = true; + store.session = { + id: "ais_1", + scope: "system", + auto_run: false, + status: "awaiting_approval", + messages: [{ role: "user", content: "check" }], + pending: [{ id: "c1", name: "list_networks", arguments: "{}" }], + suggested_actions: [], + } as AISession; + + const wrapper = mountChat(); + const allow = wrapper.findAll(".approval-buttons button").find((b) => b.text() === "Allow"); + await allow!.trigger("click"); + expect(vi.mocked(aiApi.approveSession)).toHaveBeenCalledWith("ais_1", { c1: true }); }); it("disables input while awaiting approval", () => { diff --git a/src/components/ai/AssistChat.vue b/src/components/ai/AssistChat.vue index 08f5510..13cebdc 100644 --- a/src/components/ai/AssistChat.vue +++ b/src/components/ai/AssistChat.vue @@ -48,13 +48,20 @@
-

The assistant wants to run {{ pending.length }} lookup(s)

+

The assistant wants to run a read-only lookup

{{ toolDisplay(call) }} +
+ {{ store.decisions[call.id] ? "Allowed" : "Declined" }} +
+
+ + +
-
- - +
+ +
@@ -124,8 +131,8 @@ const headerSubtitle = computed(() => ); const emptyPrompt = computed(() => store.scope === "deployment" - ? `Ask anything about ${store.subject}, or ask it to investigate a problem.` - : "Ask about this instance, its deployments, networks, or anything that is going wrong.", + ? `What do you want to know about ${store.subject}, or what should I do?` + : "What do you want to know, or what do you want me to do?", ); const inputPlaceholder = computed(() => pending.value.length ? "Resolve the lookups above to continue..." : "Ask a question...", @@ -359,13 +366,36 @@ watch( font-weight: 600; color: #92400e; } +.approval-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.3rem 0; +} .approval-item code { - display: block; + flex: 1; font-size: 0.74rem; - padding: 0.2rem 0; color: #78350f; overflow-wrap: anywhere; } +.approval-buttons { + display: flex; + gap: 0.35rem; + flex-shrink: 0; +} +.approval-decided { + font-size: 0.72rem; + font-weight: 600; + color: #92400e; + flex-shrink: 0; +} +.approval-decided.allowed { + color: #15803d; +} +.btn-xs { + padding: 0.2rem 0.55rem; + font-size: 0.72rem; +} .approval-actions { display: flex; justify-content: flex-end; diff --git a/src/layouts/DashboardLayout.vue b/src/layouts/DashboardLayout.vue index 6012270..501efb2 100644 --- a/src/layouts/DashboardLayout.vue +++ b/src/layouts/DashboardLayout.vue @@ -420,8 +420,8 @@

@@ -1073,23 +1073,27 @@ onMounted(() => { transform: translateX(-50%); display: inline-flex; align-items: center; - gap: 0.45rem; - padding: 0.45rem 1rem; + justify-content: center; + gap: 0.55rem; + min-width: 280px; + padding: 0.65rem 1.5rem; border: 1px solid #dbeafe; border-radius: 999px; background: linear-gradient(180deg, #eff6ff, #f5f9ff); color: #2563eb; - font-size: 0.82rem; + font-size: 0.9rem; font-weight: 500; cursor: pointer; transition: box-shadow 0.15s ease, - border-color 0.15s ease; + border-color 0.15s ease, + transform 0.15s ease; } .ai-ask-launcher:hover { border-color: #bfdbfe; - box-shadow: 0 2px 8px rgba(37, 99, 235, 0.15); + box-shadow: 0 4px 14px rgba(37, 99, 235, 0.18); + transform: translateX(-50%) translateY(-1px); } @media (max-width: 900px) { diff --git a/src/services/api.ts b/src/services/api.ts index 6402257..3b5eeaf 100755 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -363,10 +363,15 @@ export interface AISession { export const aiApi = { status: () => apiClient.get("/ai/status"), - createSession: (body: { scope: "system" | "deployment"; deployment?: string; auto_run: boolean; message: string }) => - apiClient.post("/ai/sessions", body), - sessionMessage: (id: string, message: string) => - apiClient.post(`/ai/sessions/${id}/messages`, { message }), + createSession: (body: { + scope: "system" | "deployment"; + deployment?: string; + auto_run: boolean; + message: string; + context?: string; + }) => apiClient.post("/ai/sessions", body), + sessionMessage: (id: string, message: string, context?: string) => + apiClient.post(`/ai/sessions/${id}/messages`, { message, context }), approveSession: (id: string, approved: Record) => apiClient.post(`/ai/sessions/${id}/approve`, { approved }), getSession: (id: string) => apiClient.get(`/ai/sessions/${id}`), diff --git a/src/stores/assist.test.ts b/src/stores/assist.test.ts index 6f34897..97a8171 100644 --- a/src/stores/assist.test.ts +++ b/src/stores/assist.test.ts @@ -64,6 +64,7 @@ describe("assist store", () => { deployment: "myapp", auto_run: true, message: "diagnose", + context: undefined, }); expect(store.session?.id).toBe("ais_1"); }); @@ -78,7 +79,7 @@ describe("assist store", () => { await store.open({ scope: "system", subject: "x", seedMessage: "first" }); await store.send("second"); - expect(aiApi.sessionMessage).toHaveBeenCalledWith("ais_1", "second"); + expect(aiApi.sessionMessage).toHaveBeenCalledWith("ais_1", "second", undefined); }); it("approves all pending tools", async () => { diff --git a/src/stores/assist.ts b/src/stores/assist.ts index c3c84c6..aa25ed2 100644 --- a/src/stores/assist.ts +++ b/src/stores/assist.ts @@ -22,7 +22,10 @@ export interface AssistContext { scope: "system" | "deployment"; deployment?: string; subject: string; + // seedMessage is the short prompt shown in the transcript; seedContext + // is bulky material (logs, output) sent to the model but not shown. seedMessage?: string; + seedContext?: string; autoRun?: boolean; } @@ -37,6 +40,9 @@ export const useAssistStore = defineStore("assist", () => { const error = ref(""); const runningIndex = ref(null); const suggestionOutputs = ref>({}); + // Per-call decisions while an approval batch is open; once every + // pending call is decided the batch is submitted. + const decisions = ref>({}); function reset() { session.value = null; @@ -44,6 +50,7 @@ export const useAssistStore = defineStore("assist", () => { error.value = ""; runningIndex.value = null; suggestionOutputs.value = {}; + decisions.value = {}; } async function ensureEnabled(): Promise { @@ -64,23 +71,24 @@ export const useAssistStore = defineStore("assist", () => { autoRun.value = ctx.autoRun ?? true; if (!(await ensureEnabled())) return; if (ctx.seedMessage) { - await send(ctx.seedMessage); + await send(ctx.seedMessage, ctx.seedContext); } } - async function send(message: string) { + async function send(message: string, context?: string) { if (loading.value) return; error.value = ""; suggestionOutputs.value = {}; loading.value = true; try { const response = session.value - ? await aiApi.sessionMessage(session.value.id, message) + ? await aiApi.sessionMessage(session.value.id, message, context) : await aiApi.createSession({ scope: scope.value, deployment: deployment.value, auto_run: autoRun.value, message, + context, }); session.value = response.data; } catch (err: any) { @@ -93,6 +101,7 @@ export const useAssistStore = defineStore("assist", () => { async function resolveApprovals(approved: Record) { if (!session.value || loading.value) return; loading.value = true; + decisions.value = {}; try { const response = await aiApi.approveSession(session.value.id, approved); session.value = response.data; @@ -103,6 +112,17 @@ export const useAssistStore = defineStore("assist", () => { } } + // decide records one call's allow/deny; when every pending call has a + // decision the whole batch is submitted, so a single pending call + // resolves on one click. + function decide(id: string, allow: boolean) { + if (!session.value) return; + decisions.value = { ...decisions.value, [id]: allow }; + if (session.value.pending.every((p) => p.id in decisions.value)) { + resolveApprovals({ ...decisions.value }); + } + } + function approveAll() { if (!session.value) return; const map: Record = {}; @@ -170,8 +190,10 @@ export const useAssistStore = defineStore("assist", () => { error, runningIndex, suggestionOutputs, + decisions, open, send, + decide, approveAll, declineAll, resolveApprovals, diff --git a/src/views/DeploymentDetailView.vue b/src/views/DeploymentDetailView.vue index 3835ad4..d2a2f63 100755 --- a/src/views/DeploymentDetailView.vue +++ b/src/views/DeploymentDetailView.vue @@ -2633,8 +2633,6 @@ const logsAssistContext = computed(() => ({ scope: "deployment", deployment: route.params.name as string, subject: route.params.name as string, - seedMessage: - "Review this deployment's recent logs (read them with your tools) and tell me whether anything is wrong.", })); const operationAssistContext = computed(() => ({ @@ -2643,9 +2641,8 @@ const operationAssistContext = computed(() => ({ subject: route.params.name as string, seedMessage: `The "${operationTitle.value}" operation just ${ operationError.value ? "failed" : "finished" - } with this output. Explain what happened${operationError.value ? " and how to fix it" : ""}:\n\n\`\`\`\n${ - operationOutput.value || operationError.value || "(no output captured)" - }\n\`\`\``, + }. Explain what happened${operationError.value ? " and how to fix it" : ""}.`, + seedContext: `\`\`\`\n${operationOutput.value || operationError.value || "(no output captured)"}\n\`\`\``, })); const serviceActionBusy = ref(""); From 7d5ee52f8617dd9511e791bd37a51ce5fef6d28c Mon Sep 17 00:00:00 2001 From: nfebe Date: Sun, 7 Jun 2026 23:31:09 +0100 Subject: [PATCH 7/7] style: Apply prettier formatting to AI assistant files --- src/components/ai/AssistButton.vue | 4 +- src/components/ai/AssistChat.test.ts | 3 +- src/components/ai/AssistChat.vue | 35 +- src/stores/assist.test.ts | 6 +- src/stores/assist.ts | 8 +- src/views/DeploymentDetailView.vue | 456 ++++++++++++++------------- src/views/SettingsView.vue | 4 +- 7 files changed, 264 insertions(+), 252 deletions(-) diff --git a/src/components/ai/AssistButton.vue b/src/components/ai/AssistButton.vue index 4957482..67ba290 100644 --- a/src/components/ai/AssistButton.vue +++ b/src/components/ai/AssistButton.vue @@ -1,7 +1,5 @@