feat: ship agent-first mission control v0.2.57
CI - Build & Test / Backend (.NET) (push) Successful in 42s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 2m46s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Successful in 56s

This commit is contained in:
AzuTear
2026-07-31 22:39:47 +02:00
parent 3bc7622977
commit f5552218bc
535 changed files with 95242 additions and 8791 deletions
+134
View File
@@ -0,0 +1,134 @@
import { expect, test } from '@playwright/test'
import { mockNexusApi, PROPOSAL_ID } from './support/nexusApi'
test.describe('agent proposal lifecycle', () => {
test('an owner creates, reviews, and approves a proposal with mutation guards', async ({ page }) => {
const api = await mockNexusApi(page, { role: 'owner' })
await page.goto('/agents/new')
await expect(page.getByRole('heading', { name: 'Agent vorschlagen' })).toBeVisible()
await page.getByRole('textbox', { name: 'Name Pflichtfeld', exact: true }).fill('Release Sentinel')
await page.getByLabel('Rolle').fill('Release Operations')
await page.getByLabel('Zweck und Verantwortungsbereich').fill(
'Verifies release readiness and reports blockers.',
)
await page.getByLabel('OpenClaw-Modell').selectOption('openai/gpt-5.4')
await page.getByLabel('Datei').selectOption('SOUL.md')
await page.getByLabel('SOUL.md Inhalt').fill('# Release Sentinel\nVerify before reporting.')
await page.getByRole('button', { name: 'Vorschlag anlegen' }).click()
await expect(page).toHaveURL(new RegExp(`/agents/proposals/${PROPOSAL_ID}$`))
await expect(page.getByText('Freigabe offen', { exact: true })).toBeVisible()
const createRequest = api.proposalRequests.find(request =>
request.path === '/api/v1/openclaw/agent-proposals'
)
expect(createRequest).toBeDefined()
expect(createRequest?.headers['idempotency-key']).toBeTruthy()
expect(createRequest?.headers['x-correlation-id']).toBeTruthy()
expect(createRequest?.body).toMatchObject({
name: 'Release Sentinel',
role: 'Release Operations',
model: 'openai/gpt-5.4',
files: {
'SOUL.md': '# Release Sentinel\nVerify before reporting.',
},
})
await page.getByRole('button', { name: 'Freigeben' }).click()
const dialog = page.getByRole('dialog', { name: 'Agent-Provisionierung freigeben' })
await expect(dialog).toBeVisible()
await dialog.getByLabel('Notiz (optional)').fill('Reviewed in the owner E2E flow.')
await dialog.getByRole('button', { name: 'Bestätigen' }).click()
await expect(page.getByText('Bereit', { exact: true })).toBeVisible()
await expect(page.getByText('Agent ist bereit.', { exact: true })).toBeVisible()
const approveRequest = api.proposalRequests.find(request =>
request.path.endsWith('/approve')
)
expect(approveRequest).toBeDefined()
expect(approveRequest?.headers['idempotency-key']).toBeTruthy()
expect(approveRequest?.headers['x-correlation-id']).toBeTruthy()
expect(approveRequest?.body).toMatchObject({
expectedRevision: 1,
reason: 'Reviewed in the owner E2E flow.',
})
})
test('a non-owner sees the permission boundary without owner API traffic', async ({ page }) => {
const api = await mockNexusApi(page, { role: 'member' })
await page.goto('/agents')
await expect(page.getByText(
'Agent-Vorschläge und OpenClaw-Provisionierung sind ausschließlich für Owner sichtbar.',
)).toBeVisible()
await expect(page.getByRole('link', { name: 'Agent vorschlagen' })).toHaveCount(0)
await page.goto('/agents/new')
await expect(page.getByRole('alert')).toContainText('Owner-Berechtigung erforderlich')
await expect(page.getByRole('button', { name: 'Vorschlag anlegen' })).toHaveCount(0)
await page.goto(`/agents/proposals/${PROPOSAL_ID}`)
await expect(page.getByRole('alert')).toContainText('Owner-Berechtigung erforderlich')
await expect(page.getByRole('button', { name: 'Freigeben' })).toHaveCount(0)
expect(api.proposalRequests).toEqual([])
})
test('an Iris proposal event becomes a structured approval card and deep link', async ({ page }) => {
const api = await mockNexusApi(page, { role: 'owner' })
await page.goto('/dashboard')
await page.getByRole('button', { name: 'Iris Chat öffnen' }).click()
const chat = page.getByRole('dialog', { name: 'Iris Chat' })
await expect(chat).toBeVisible()
await chat.getByRole('textbox', { name: 'Nachricht an Iris' }).fill(
'Schlage einen Release Sentinel Agenten vor, aber provisioniere ihn nicht.',
)
await chat.getByRole('button', { name: 'Nachricht senden' }).click()
await expect(chat.getByText('OpenClaw accepted the Iris request.')).toBeVisible()
expect(api.chatRequests).toHaveLength(1)
expect(api.chatRequests[0]?.body).toMatchObject({
agentId: 'iris',
message: 'Schlage einen Release Sentinel Agenten vor, aber provisioniere ihn nicht.',
})
expect(api.proposalRequests).toEqual([])
await page.evaluate(({ proposalId, occurredAt }) => {
window.dispatchEvent(new CustomEvent('nexus:domain-event', {
detail: {
sequence: 73,
eventType: 'agent.proposal.created',
entity: {
type: 'agent-proposal',
id: proposalId,
label: 'Release Sentinel',
},
entityRevision: 1,
occurredAt,
payload: { state: 'awaiting_approval' },
},
}))
}, { proposalId: PROPOSAL_ID, occurredAt: '2026-07-31T10:00:00.000Z' })
await expect(chat.getByText(
'Iris hat einen Agent-Vorschlag zur Owner-Freigabe angelegt.',
)).toBeVisible()
const proposalLink = chat.getByRole('link', {
name: /Agent-Vorschlag Release Sentinel/,
})
await expect(proposalLink).toHaveAttribute(
'href',
`/agents/proposals/${PROPOSAL_ID}`,
)
await proposalLink.click()
await expect(page).toHaveURL(`/agents/proposals/${PROPOSAL_ID}`)
await expect(page.getByRole('heading', {
level: 1,
name: 'Release Sentinel',
})).toBeVisible()
await expect(chat).toHaveCount(0)
})
})
+255
View File
@@ -0,0 +1,255 @@
import { expect, test } from '@playwright/test'
import {
DOC_PATH,
INCIDENT_NAME,
MEMORY_NAME,
mockNexusApi,
PROJECT_ID,
PROPOSAL_ID,
} from './support/nexusApi'
const coreRoutes = [
{ path: '/dashboard', heading: 'Live-Orchestrierung' },
{ path: '/agents', heading: 'Agents' },
{ path: '/agents/iris', heading: 'Iris' },
{ path: '/agents/new', heading: 'Agent vorschlagen' },
{ path: `/agents/proposals/${PROPOSAL_ID}`, heading: 'Release Sentinel' },
{ path: '/projects', heading: 'Projects' },
{ path: `/projects/${PROJECT_ID}`, heading: 'Release Readiness' },
{ path: '/tasks', heading: 'Aufgaben' },
{ path: '/tasks/task-open-1', heading: 'E2E Backlog task' },
{ path: '/runs', heading: 'Run Control' },
{ path: '/runs/run-e2e-1', heading: 'Run detail' },
{ path: '/calendar', heading: 'Calendar' },
{ path: '/memory', heading: 'Memory' },
{ path: '/docs', heading: 'Docs' },
{ path: '/incidents', heading: 'Incidents' },
{ path: '/models', heading: 'Models' },
{ path: '/activity', heading: 'Activity' },
{ path: '/notifications', heading: 'Benachrichtigungen' },
{ path: '/security', heading: 'Security Center' },
{ path: '/settings', heading: 'Einstellungen' },
] as const
test.describe('authenticated route and deep-link smoke', () => {
test('redirects an unauthenticated deep link to login and preserves the target', async ({ page }) => {
await mockNexusApi(page, { authenticated: false })
await page.goto(`/agents/proposals/${PROPOSAL_ID}`)
await expect(page).toHaveURL(/\/login\?redirect=/)
const loginUrl = new URL(page.url())
expect(loginUrl.pathname).toBe('/login')
expect(loginUrl.searchParams.get('redirect')).toBe(`/agents/proposals/${PROPOSAL_ID}`)
await expect(page.getByRole('button', { name: 'Anmelden' })).toBeVisible()
})
test('renders the core owner deep links without a client-side exception', async ({ page }) => {
test.setTimeout(90_000)
await mockNexusApi(page, { role: 'owner' })
const pageErrors: string[] = []
page.on('pageerror', error => pageErrors.push(error.message))
for (const route of coreRoutes) {
await page.goto(route.path)
await expect(page).toHaveURL(new RegExp(`${route.path.replaceAll('/', '\\/')}$`))
await expect(page.getByRole('heading', { name: route.heading, exact: true }).first()).toBeVisible()
}
expect(pageErrors).toEqual([])
})
test('links the Projects index to a directly addressable project result', async ({ page }) => {
await mockNexusApi(page, { role: 'owner' })
await page.goto('/projects')
await expect(page.getByRole('heading', { name: 'Projects', exact: true })).toBeVisible()
const projectLink = page.getByRole('link', { name: /Release Readiness/ })
await expect(projectLink).toHaveAttribute('href', `/projects/${PROJECT_ID}`)
await projectLink.click()
await expect(page).toHaveURL(`/projects/${PROJECT_ID}`)
await expect(page.getByRole('heading', { name: 'Release Readiness', exact: true })).toBeVisible()
await expect(page.getByRole('link', { name: /E2E Project task/ })).toBeVisible()
await expect(page.getByText('E2E Unrelated task', { exact: true })).toHaveCount(0)
await expect(page.getByRole('link', { name: /Agent iris/ })).toHaveAttribute(
'href',
'/agents/iris',
)
await expect(page.getByRole('link', {
name: /Run Release readiness verification/,
})).toHaveAttribute(
'href',
'/runs/33333333-3333-4333-8333-333333333333',
)
await expect(page.getByText(
/A project reference is not delivered by the current incident contract/,
)).toBeVisible()
// The detail route is also a durable deep link, not only a client-side
// transition from the index.
await page.goto(`/projects/${PROJECT_ID}`)
await expect(page.getByRole('heading', { name: 'Release Readiness', exact: true })).toBeVisible()
})
test('deduplicates the shared OpenClaw overview across dashboard consumers', async ({ page }) => {
const api = await mockNexusApi(page, { role: 'owner' })
await page.goto('/dashboard')
await expect(page.getByRole('heading', {
name: 'Live-Orchestrierung',
exact: true,
})).toBeVisible()
await expect.poll(() => api.openClawOverviewRequestCount).toBe(1)
})
test('keeps Iris and run mutation entry points unavailable to non-owners', async ({ page }) => {
await mockNexusApi(page, { role: 'member' })
await page.goto('/dashboard?iris=1')
await expect(page).toHaveURL('/dashboard')
await expect(page.getByRole('button', {
name: 'Iris Chat ist nur für Owner verfügbar',
})).toBeDisabled()
await expect(page.locator('#iris-chat-dialog')).toHaveCount(0)
await page.keyboard.press('Control+k')
const commandDialog = page.getByRole('dialog', {
name: 'Mission Control commands',
})
await expect(commandDialog).toBeVisible()
await expect(commandDialog.getByRole('button', { name: /Ask Iris/ })).toHaveCount(0)
await expect(commandDialog.getByRole('button', { name: /Start OpenClaw run/ })).toHaveCount(0)
await page.keyboard.press('Escape')
await page.goto('/tasks?iris=1')
await expect(page).toHaveURL('/tasks')
await expect(page.getByRole('button', {
name: 'Iris chat is available to owners only',
})).toBeDisabled()
await expect(page.locator('#iris-chat-dialog')).toHaveCount(0)
})
test('resolves Activity and Notification results to the linked task', async ({ page }) => {
await mockNexusApi(page, { role: 'owner' })
await page.goto('/activity')
await page.getByRole('button', { name: /Nexus task deep link is ready/ }).click()
const activityDialog = page.getByRole('dialog', { name: 'task.updated' })
const activityTaskLink = activityDialog.getByRole('link', {
name: /E2E Backlog task/,
})
await expect(activityTaskLink).toHaveAttribute('href', '/tasks/task-open-1')
await activityTaskLink.click()
await expect(page).toHaveURL('/tasks/task-open-1')
await page.goto('/notifications')
await page.getByRole('button', {
name: /Inspect E2E backlog task/,
}).click()
await expect(page).toHaveURL('/tasks/task-open-1')
await expect(page.getByRole('heading', {
level: 1,
name: 'E2E Backlog task',
})).toBeVisible()
})
test('links a Dashboard focus result directly to its task detail', async ({ page }) => {
await mockNexusApi(page, { role: 'owner' })
await page.goto('/dashboard')
const taskLink = page.getByRole('link', {
name: /E2E Backlog task/,
})
await expect(taskLink).toHaveAttribute('href', '/tasks/task-open-1')
await taskLink.click()
await expect(page).toHaveURL('/tasks/task-open-1')
await expect(page.getByRole('heading', {
level: 1,
name: 'E2E Backlog task',
})).toBeVisible()
})
test('opens OpenClaw content with its Iris source deep link', async ({ page }) => {
await mockNexusApi(page, { role: 'owner' })
await page.goto('/docs')
await page.getByRole('button', { name: new RegExp(DOC_PATH.split('/').at(-1)!) }).click()
const docContent = page.locator('.memory-rendered')
await expect(docContent.getByRole('heading', {
name: 'Mission Control Runbook',
})).toBeVisible()
await expect(docContent).toContainText('OpenClaw-backed documentation is visible in Nexus.')
await expect(page.locator('.source-context').getByRole('link', {
name: 'iris',
exact: true,
})).toHaveAttribute('href', '/agents/iris')
await expect(page.locator('.source-context')).toContainText(DOC_PATH)
await page.goto('/memory')
await page.getByRole('button', { name: new RegExp(MEMORY_NAME) }).click()
const memoryContent = page.locator('.memory-rendered')
await expect(memoryContent.getByRole('heading', {
name: 'Daily Memory',
})).toBeVisible()
await expect(memoryContent).toContainText('Agent-first context is loaded from Iris.')
await expect(page.locator('.source-context').getByRole('link', {
name: 'iris',
exact: true,
})).toHaveAttribute('href', '/agents/iris')
await expect(page.locator('.source-context')).toContainText(`memory/${MEMORY_NAME}`)
await page.goto('/incidents')
await page.getByRole('button', { name: /Gateway Timeout Recovered/ }).click()
const incidentContent = page.locator('.incident-rendered')
await expect(incidentContent.getByRole('heading', {
name: 'Gateway Timeout Recovered',
})).toBeVisible()
await expect(incidentContent).toContainText(
'The gateway recovered without duplicate provisioning.',
)
await expect(page.locator('.source-context').getByRole('link', {
name: 'iris',
exact: true,
})).toHaveAttribute('href', '/agents/iris')
await expect(page.locator('.source-context')).toContainText(
`memory/incidents/${INCIDENT_NAME}`,
)
})
})
const shellViewports = [
{ width: 375, height: 812 },
{ width: 768, height: 900 },
{ width: 1024, height: 900 },
{ width: 1440, height: 900 },
{ width: 1920, height: 1080 },
]
for (const viewport of shellViewports) {
test(`keeps every registered core route inside the ${viewport.width}px page shell`, async ({ page }) => {
test.setTimeout(120_000)
await page.setViewportSize(viewport)
await mockNexusApi(page, { role: 'owner' })
for (const route of coreRoutes) {
await page.goto(route.path)
await expect(page.getByRole('heading', { name: route.heading, exact: true }).first()).toBeVisible()
const geometry = await page.evaluate(() => ({
viewportWidth: document.documentElement.clientWidth,
rootOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
bodyOverflow: document.body.scrollWidth - document.documentElement.clientWidth,
}))
expect(
geometry.rootOverflow,
`${route.path} overflows the root at ${viewport.width}px`,
).toBeLessThanOrEqual(1)
expect(
geometry.bodyOverflow,
`${route.path} overflows the body at ${viewport.width}px`,
).toBeLessThanOrEqual(1)
}
})
}
File diff suppressed because it is too large Load Diff
+148
View File
@@ -0,0 +1,148 @@
import { expect, test } from '@playwright/test'
import { mockNexusApi } from './support/nexusApi'
test.describe('task board query lifecycle', () => {
test('shows authoritative data after refresh and appends Done pagination', async ({ page }) => {
const api = await mockNexusApi(page, { role: 'owner' })
await page.goto('/tasks')
const board = page.locator('.board-columns')
await expect(board.getByText('E2E Backlog task', { exact: true })).toBeVisible()
await expect(board.getByText('E2E Done page one', { exact: true })).toBeVisible()
await expect(board.getByText('E2E Done page two', { exact: true })).toHaveCount(0)
await expect(page.getByText('Revision board-r1', { exact: true })).toBeVisible()
await page.getByRole('button', { name: 'Weitere erledigte laden' }).click()
await expect(board.getByText('E2E Done page two', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: 'Weitere erledigte laden' })).toHaveCount(0)
expect(api.boardRequestUrls.some(url => url.includes('doneCursor=done-page-2'))).toBe(true)
await page.reload()
await expect(board.getByText('E2E Backlog task', { exact: true })).toBeVisible()
await expect(board.getByText('E2E Done page one', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: 'Weitere erledigte laden' })).toBeVisible()
expect(api.boardRequestUrls.filter(url => !url.includes('doneCursor=')).length).toBeGreaterThanOrEqual(2)
})
test('advances the SSE cursor and performs exactly one visible-data resync', async ({ page }) => {
const api = await mockNexusApi(page, {
role: 'owner',
domainResyncSequence: 25,
boardRefreshDelayMs: 700,
boardRefreshTitle: 'E2E Backlog task refreshed',
})
await page.goto('/tasks')
const board = page.locator('.board-columns')
await expect(board.getByText('E2E Backlog task', { exact: true })).toBeVisible()
await expect.poll(
() => api.boardRequestUrls.filter(url => !url.includes('doneCursor=')).length,
).toBe(2)
// A background refresh must retain the authoritative snapshot instead of
// replacing it with a loading state.
await expect(board.getByText('E2E Backlog task', { exact: true })).toBeVisible()
await expect(board.getByText('E2E Backlog task refreshed', { exact: true })).toBeVisible()
await expect(page.getByText('Revision board-r2', { exact: true })).toBeVisible()
await expect.poll(() => api.domainEventRequests.length).toBeGreaterThanOrEqual(2)
expect(api.domainEventRequests[0]?.lastEventId).toBeNull()
expect(api.domainEventRequests.slice(1).every(item => item.lastEventId === '25')).toBe(true)
expect(api.resyncResponseCount).toBe(1)
// Give another reconnect enough time to prove the stale cursor does not
// trigger a second resync or a second REST refresh.
await page.waitForTimeout(1_200)
expect(api.resyncResponseCount).toBe(1)
expect(api.boardRequestUrls.filter(url => !url.includes('doneCursor='))).toHaveLength(2)
})
test('persists a drag-and-drop move and keeps the card in its new column', async ({ page }) => {
const api = await mockNexusApi(page, { role: 'owner' })
await page.goto('/tasks')
const source = page.locator('.card').filter({ hasText: 'E2E Backlog task' }).first()
const reviewColumn = page.locator('.col').filter({
has: page.locator('.col-name', { hasText: 'Review' }),
})
await expect(source).toBeVisible()
const dataTransfer = await page.evaluateHandle(() => new DataTransfer())
await source.dispatchEvent('dragstart', { dataTransfer })
await reviewColumn.dispatchEvent('dragover', { dataTransfer })
await reviewColumn.dispatchEvent('drop', { dataTransfer })
await source.dispatchEvent('dragend', { dataTransfer })
await expect.poll(() => api.taskMutationRequests.length).toBe(1)
expect(api.taskMutationRequests[0]?.path).toBe('/api/dashboard/tasks/task-open-1/move')
expect(api.taskMutationRequests[0]?.body).toEqual({ state: 'review' })
await expect(reviewColumn.getByText('E2E Backlog task', { exact: true })).toBeVisible()
await expect(
page.locator('.col').filter({
has: page.locator('.col-name', { hasText: 'Offen' }),
}).getByText('E2E Backlog task', { exact: true }),
).toHaveCount(0)
const operationTray = page.getByRole('complementary', {
name: 'Letztes Operationsergebnis',
})
await expect(operationTray.getByText('Task verschoben', { exact: true })).toBeVisible()
const taskResultLink = operationTray.getByRole('link', { name: /E2E Backlog task/ })
await expect(taskResultLink).toHaveAttribute('href', '/tasks/task-open-1')
await taskResultLink.click()
await expect(page).toHaveURL('/tasks/task-open-1')
})
})
const viewports = [
{ width: 375, height: 812 },
{ width: 768, height: 900 },
{ width: 1024, height: 900 },
{ width: 1440, height: 900 },
{ width: 1920, height: 1080 },
]
for (const viewport of viewports) {
test(`contains horizontal board scrolling at ${viewport.width}px without page overflow`, async ({ page }) => {
await page.setViewportSize(viewport)
await mockNexusApi(page, { role: 'owner' })
await page.goto('/tasks')
await expect(page.locator('.board-columns').getByText('E2E Backlog task', { exact: true })).toBeVisible()
const geometry = await page.evaluate(() => {
const root = document.documentElement
const body = document.body
const main = document.querySelector<HTMLElement>('.legacy-main')
const scroller = document.querySelector<HTMLElement>('.board-columns')
if (!main || !scroller) throw new Error('Expected Task Board geometry was not rendered.')
const mainRect = main.getBoundingClientRect()
const scrollerRect = scroller.getBoundingClientRect()
scroller.scrollLeft = 100
return {
viewportWidth: root.clientWidth,
rootOverflow: root.scrollWidth - root.clientWidth,
bodyOverflow: body.scrollWidth - root.clientWidth,
mainLeft: mainRect.left,
mainRight: mainRect.right,
scrollerLeft: scrollerRect.left,
scrollerRight: scrollerRect.right,
scrollerClientWidth: scroller.clientWidth,
scrollerScrollWidth: scroller.scrollWidth,
scrollerScrollLeft: scroller.scrollLeft,
scrollerOverflowX: getComputedStyle(scroller).overflowX,
}
})
expect(geometry.rootOverflow).toBeLessThanOrEqual(1)
expect(geometry.bodyOverflow).toBeLessThanOrEqual(1)
expect(geometry.mainLeft).toBeGreaterThanOrEqual(-1)
expect(geometry.mainRight).toBeLessThanOrEqual(geometry.viewportWidth + 1)
expect(geometry.scrollerLeft).toBeGreaterThanOrEqual(-1)
expect(geometry.scrollerRight).toBeLessThanOrEqual(geometry.viewportWidth + 1)
expect(geometry.scrollerScrollWidth).toBeGreaterThanOrEqual(geometry.scrollerClientWidth)
expect(geometry.scrollerOverflowX).toBe('auto')
if (viewport.width <= 1024) {
expect(geometry.scrollerScrollWidth - geometry.scrollerClientWidth).toBeGreaterThan(1)
expect(geometry.scrollerScrollLeft).toBeGreaterThan(0)
}
})
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["node", "@playwright/test"]
},
"include": ["./**/*.ts", "../playwright.config.ts"]
}
+14 -4
View File
@@ -5,26 +5,36 @@
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vue-tsc --noEmit && vite build",
"typecheck": "vue-tsc --noEmit",
"test": "vitest run"
"build": "pnpm typecheck && vite build",
"typecheck": "vue-tsc --noEmit -p tsconfig.app.json && tsc --noEmit -p e2e/tsconfig.json",
"test": "vitest run",
"test:e2e": "playwright test",
"openapi:generate": "openapi-typescript ../backend/openapi/Nexus.Api.json -o src/api/generated/schema.d.ts",
"openapi:check": "pnpm openapi:generate && git diff --exit-code -- src/api/generated/schema.d.ts"
},
"dependencies": {
"@lucide/vue": "1.17.0",
"@tailwindcss/vite": "^4.1.8",
"@tanstack/vue-query": "5.101.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"eventsource-parser": "3.1.0",
"openapi-fetch": "0.17.0",
"pinia": "^3.0.3",
"radix-vue": "^1.9.17",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.1.8",
"tailwindcss-animate": "^1.0.7",
"vue": "^3.5.16",
"vue-router": "^4.5.1"
"vue-router": "^4.5.1",
"web-vitals": "5.3.0"
},
"devDependencies": {
"@playwright/test": "1.62.0",
"@types/node": "^22.15.29",
"@vitejs/plugin-vue": "^5.2.4",
"openapi-typescript": "7.13.0",
"postcss": "8.5.18",
"typescript": "~5.7.3",
"vite": "^6.3.5",
"vitest": "^3.1.3",
+39
View File
@@ -0,0 +1,39 @@
import { defineConfig, devices } from '@playwright/test'
const port = Number(process.env.NEXUS_E2E_PORT ?? 4175)
const baseURL = `http://127.0.0.1:${port}`
export default defineConfig({
testDir: './e2e',
testMatch: '**/*.e2e.ts',
outputDir: 'test-results/e2e',
fullyParallel: true,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0,
// Vite transforms the route-split view graph on demand. A small bounded
// worker pool keeps deep-link startup deterministic on developer machines;
// CI stays serial to reduce variance further.
workers: process.env.CI ? 1 : 2,
reporter: [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }]],
use: {
baseURL,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
viewport: { width: 1440, height: 900 },
},
},
],
webServer: {
command: `pnpm exec vite --host 127.0.0.1 --port ${port} --strictPort`,
url: baseURL,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
})
+309 -8
View File
@@ -14,12 +14,21 @@ importers:
'@tailwindcss/vite':
specifier: ^4.1.8
version: 4.3.0(vite@6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0))
'@tanstack/vue-query':
specifier: 5.101.4
version: 5.101.4(vue@3.5.35(typescript@5.7.3))
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
clsx:
specifier: ^2.1.1
version: 2.1.1
eventsource-parser:
specifier: 3.1.0
version: 3.1.0
openapi-fetch:
specifier: 0.17.0
version: 0.17.0
pinia:
specifier: ^3.0.3
version: 3.0.4(typescript@5.7.3)(vue@3.5.35(typescript@5.7.3))
@@ -41,13 +50,25 @@ importers:
vue-router:
specifier: ^4.5.1
version: 4.6.4(vue@3.5.35(typescript@5.7.3))
web-vitals:
specifier: 5.3.0
version: 5.3.0
devDependencies:
'@playwright/test':
specifier: 1.62.0
version: 1.62.0
'@types/node':
specifier: ^22.15.29
version: 22.19.20
'@vitejs/plugin-vue':
specifier: ^5.2.4
version: 5.2.4(vite@6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0))(vue@3.5.35(typescript@5.7.3))
openapi-typescript:
specifier: 7.13.0
version: 7.13.0(typescript@5.7.3)
postcss:
specifier: 8.5.18
version: 8.5.18
typescript:
specifier: ~5.7.3
version: 5.7.3
@@ -63,6 +84,10 @@ importers:
packages:
'@babel/code-frame@7.29.7':
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'}
'@babel/helper-string-parser@7.29.7':
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
engines: {node: '>=6.9.0'}
@@ -275,6 +300,21 @@ packages:
peerDependencies:
vue: '>=3.0.1'
'@playwright/test@1.62.0':
resolution: {integrity: sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==}
engines: {node: '>=20'}
hasBin: true
'@redocly/ajv@8.11.2':
resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==}
'@redocly/config@0.22.0':
resolution: {integrity: sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==}
'@redocly/openapi-core@1.34.18':
resolution: {integrity: sha512-UyKIm0wTPw5BcY7Z2PkbK1Ma260um96LSBWXHrdSMe+ZV0EPMyDfAcUcjjm3qEiGST9OK/1TriekdPCZkn4Q3A==}
engines: {node: '>=18.17.0', npm: '>=9.5.0'}
'@rollup/rollup-android-arm-eabi@4.61.1':
resolution: {integrity: sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==}
cpu: [arm]
@@ -493,9 +533,25 @@ packages:
peerDependencies:
vite: ^5.2.0 || ^6 || ^7 || ^8
'@tanstack/match-sorter-utils@8.19.4':
resolution: {integrity: sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==}
engines: {node: '>=12'}
'@tanstack/query-core@5.101.4':
resolution: {integrity: sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==}
'@tanstack/virtual-core@3.17.0':
resolution: {integrity: sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==}
'@tanstack/vue-query@5.101.4':
resolution: {integrity: sha512-UYjkUZhnWQIFGNb7SdgjCitAftNyYQfOIVUh6vBUQAG4SQKNMSBKeQERFDubpxLAMpIBJTaOrE8fM4c1kZcIGQ==}
peerDependencies:
'@vue/composition-api': ^1.1.2
vue: ^2.6.0 || ^3.3.0
peerDependenciesMeta:
'@vue/composition-api':
optional: true
'@tanstack/vue-virtual@3.13.28':
resolution: {integrity: sha512-A+jWpXtMpWXKhGLKQrXeC9mk1VgYeMWSJ+o0CTCEi+HLYMSQFdVmPG9lJz7d4XJyIkc5xVwZU9QY67QpScqnxA==}
peerDependencies:
@@ -622,9 +678,20 @@ packages:
'@vueuse/shared@10.11.1':
resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==}
agent-base@7.1.4:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
alien-signals@1.0.13:
resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==}
ansi-colors@4.1.3:
resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
engines: {node: '>=6'}
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
aria-hidden@1.2.6:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
@@ -650,6 +717,9 @@ packages:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'}
change-case@5.4.4:
resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==}
check-error@2.1.3:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
engines: {node: '>= 16'}
@@ -661,6 +731,9 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
colorette@1.4.0:
resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==}
copy-anything@4.0.5:
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
engines: {node: '>=18'}
@@ -713,6 +786,10 @@ packages:
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
eventsource-parser@3.1.0:
resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==}
engines: {node: '>=18.0.0'}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
@@ -729,6 +806,11 @@ packages:
picomatch:
optional: true
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -744,6 +826,14 @@ packages:
hookable@5.5.3:
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
https-proxy-agent@7.0.6:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
index-to-position@1.2.0:
resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==}
engines: {node: '>=18'}
is-what@5.5.0:
resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}
engines: {node: '>=18'}
@@ -752,9 +842,23 @@ packages:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
js-levenshtein@1.1.6:
resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==}
engines: {node: '>=0.10.0'}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
js-tokens@9.0.1:
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
js-yaml@4.3.0:
resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==}
hasBin: true
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
lightningcss-android-arm64@1.32.0:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'}
@@ -831,6 +935,10 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
minimatch@5.1.9:
resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==}
engines: {node: '>=10'}
minimatch@9.0.9:
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
engines: {node: '>=16 || 14 >=14.17'}
@@ -854,6 +962,22 @@ packages:
engines: {node: ^18 || >=20}
hasBin: true
openapi-fetch@0.17.0:
resolution: {integrity: sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==}
openapi-typescript-helpers@0.1.0:
resolution: {integrity: sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==}
openapi-typescript@7.13.0:
resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==}
hasBin: true
peerDependencies:
typescript: ^5.x
parse-json@8.3.0:
resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==}
engines: {node: '>=18'}
path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
@@ -883,8 +1007,22 @@ packages:
typescript:
optional: true
postcss@8.5.15:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
playwright-core@1.62.0:
resolution: {integrity: sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==}
engines: {node: '>=20'}
hasBin: true
playwright@1.62.0:
resolution: {integrity: sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==}
engines: {node: '>=20'}
hasBin: true
pluralize@8.0.0:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
postcss@8.5.18:
resolution: {integrity: sha512-xdB1oSLHbz1vRWgCDalrCqEFTWzFlhqFC5tIHLMOSUIjhm3XXQ1qrFy8S/ESr1JYRRXqM3c1QFiMZUJdUTqyMQ==}
engines: {node: ^10 || ^12 || >=14}
radix-vue@1.9.17:
@@ -892,6 +1030,13 @@ packages:
peerDependencies:
vue: '>= 3.2.0'
remove-accents@0.5.0:
resolution: {integrity: sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==}
require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
rfdc@1.4.1:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
@@ -924,6 +1069,10 @@ packages:
resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==}
engines: {node: '>=16'}
supports-color@10.2.2:
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
engines: {node: '>=18'}
tailwind-merge@3.6.0:
resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==}
@@ -964,6 +1113,10 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
type-fest@4.41.0:
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
engines: {node: '>=16'}
typescript@5.7.3:
resolution: {integrity: sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==}
engines: {node: '>=14.17'}
@@ -972,6 +1125,9 @@ packages:
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
uri-js-replace@1.0.1:
resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==}
vite-node@3.2.4:
resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
@@ -1078,13 +1234,29 @@ packages:
typescript:
optional: true
web-vitals@5.3.0:
resolution: {integrity: sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==}
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
yaml-ast-parser@0.0.43:
resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==}
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
snapshots:
'@babel/code-frame@7.29.7':
dependencies:
'@babel/helper-validator-identifier': 7.29.7
js-tokens: 4.0.0
picocolors: 1.1.1
'@babel/helper-string-parser@7.29.7': {}
'@babel/helper-validator-identifier@7.29.7': {}
@@ -1227,6 +1399,33 @@ snapshots:
dependencies:
vue: 3.5.35(typescript@5.7.3)
'@playwright/test@1.62.0':
dependencies:
playwright: 1.62.0
'@redocly/ajv@8.11.2':
dependencies:
fast-deep-equal: 3.1.3
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
uri-js-replace: 1.0.1
'@redocly/config@0.22.0': {}
'@redocly/openapi-core@1.34.18(supports-color@10.2.2)':
dependencies:
'@redocly/ajv': 8.11.2
'@redocly/config': 0.22.0
colorette: 1.4.0
https-proxy-agent: 7.0.6(supports-color@10.2.2)
js-levenshtein: 1.1.6
js-yaml: 4.3.0
minimatch: 5.1.9
pluralize: 8.0.0
yaml-ast-parser: 0.0.43
transitivePeerDependencies:
- supports-color
'@rollup/rollup-android-arm-eabi@4.61.1':
optional: true
@@ -1374,8 +1573,22 @@ snapshots:
tailwindcss: 4.3.0
vite: 6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)
'@tanstack/match-sorter-utils@8.19.4':
dependencies:
remove-accents: 0.5.0
'@tanstack/query-core@5.101.4': {}
'@tanstack/virtual-core@3.17.0': {}
'@tanstack/vue-query@5.101.4(vue@3.5.35(typescript@5.7.3))':
dependencies:
'@tanstack/match-sorter-utils': 8.19.4
'@tanstack/query-core': 5.101.4
'@vue/devtools-api': 6.6.4
vue: 3.5.35(typescript@5.7.3)
vue-demi: 0.14.10(vue@3.5.35(typescript@5.7.3))
'@tanstack/vue-virtual@3.13.28(vue@3.5.35(typescript@5.7.3))':
dependencies:
'@tanstack/virtual-core': 3.17.0
@@ -1477,7 +1690,7 @@ snapshots:
'@vue/shared': 3.5.35
estree-walker: 2.0.2
magic-string: 0.30.21
postcss: 8.5.15
postcss: 8.5.18
source-map-js: 1.2.1
'@vue/compiler-ssr@3.5.35':
@@ -1566,8 +1779,14 @@ snapshots:
- '@vue/composition-api'
- vue
agent-base@7.1.4: {}
alien-signals@1.0.13: {}
ansi-colors@4.1.3: {}
argparse@2.0.1: {}
aria-hidden@1.2.6:
dependencies:
tslib: 2.8.1
@@ -1592,6 +1811,8 @@ snapshots:
loupe: 3.2.1
pathval: 2.0.1
change-case@5.4.4: {}
check-error@2.1.3: {}
class-variance-authority@0.7.1:
@@ -1600,6 +1821,8 @@ snapshots:
clsx@2.1.1: {}
colorette@1.4.0: {}
copy-anything@4.0.5:
dependencies:
is-what: 5.5.0
@@ -1608,9 +1831,11 @@ snapshots:
de-indent@1.0.2: {}
debug@4.4.3:
debug@4.4.3(supports-color@10.2.2):
dependencies:
ms: 2.1.3
optionalDependencies:
supports-color: 10.2.2
deep-eql@5.0.2: {}
@@ -1662,6 +1887,8 @@ snapshots:
dependencies:
'@types/estree': 1.0.9
eventsource-parser@3.1.0: {}
expect-type@1.3.0: {}
fast-deep-equal@3.1.3: {}
@@ -1670,6 +1897,9 @@ snapshots:
optionalDependencies:
picomatch: 4.0.4
fsevents@2.3.2:
optional: true
fsevents@2.3.3:
optional: true
@@ -1679,12 +1909,31 @@ snapshots:
hookable@5.5.3: {}
https-proxy-agent@7.0.6(supports-color@10.2.2):
dependencies:
agent-base: 7.1.4
debug: 4.4.3(supports-color@10.2.2)
transitivePeerDependencies:
- supports-color
index-to-position@1.2.0: {}
is-what@5.5.0: {}
jiti@2.7.0: {}
js-levenshtein@1.1.6: {}
js-tokens@4.0.0: {}
js-tokens@9.0.1: {}
js-yaml@4.3.0:
dependencies:
argparse: 2.0.1
json-schema-traverse@1.0.0: {}
lightningcss-android-arm64@1.32.0:
optional: true
@@ -1740,6 +1989,10 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
minimatch@5.1.9:
dependencies:
brace-expansion: 2.1.1
minimatch@9.0.9:
dependencies:
brace-expansion: 2.1.1
@@ -1754,6 +2007,28 @@ snapshots:
nanoid@5.1.11: {}
openapi-fetch@0.17.0:
dependencies:
openapi-typescript-helpers: 0.1.0
openapi-typescript-helpers@0.1.0: {}
openapi-typescript@7.13.0(typescript@5.7.3):
dependencies:
'@redocly/openapi-core': 1.34.18(supports-color@10.2.2)
ansi-colors: 4.1.3
change-case: 5.4.4
parse-json: 8.3.0
supports-color: 10.2.2
typescript: 5.7.3
yargs-parser: 21.1.1
parse-json@8.3.0:
dependencies:
'@babel/code-frame': 7.29.7
index-to-position: 1.2.0
type-fest: 4.41.0
path-browserify@1.0.1: {}
pathe@2.0.3: {}
@@ -1773,7 +2048,17 @@ snapshots:
optionalDependencies:
typescript: 5.7.3
postcss@8.5.15:
playwright-core@1.62.0: {}
playwright@1.62.0:
dependencies:
playwright-core: 1.62.0
optionalDependencies:
fsevents: 2.3.2
pluralize@8.0.0: {}
postcss@8.5.18:
dependencies:
nanoid: 3.3.12
picocolors: 1.1.1
@@ -1796,6 +2081,10 @@ snapshots:
transitivePeerDependencies:
- '@vue/composition-api'
remove-accents@0.5.0: {}
require-from-string@2.0.2: {}
rfdc@1.4.1: {}
rollup@4.61.1:
@@ -1847,6 +2136,8 @@ snapshots:
dependencies:
copy-anything: 4.0.5
supports-color@10.2.2: {}
tailwind-merge@3.6.0: {}
tailwindcss-animate@1.0.7(tailwindcss@4.3.0):
@@ -1874,14 +2165,18 @@ snapshots:
tslib@2.8.1: {}
type-fest@4.41.0: {}
typescript@5.7.3: {}
undici-types@6.21.0: {}
uri-js-replace@1.0.1: {}
vite-node@3.2.4(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0):
dependencies:
cac: 6.7.14
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)
@@ -1904,7 +2199,7 @@ snapshots:
esbuild: 0.25.12
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
postcss: 8.5.15
postcss: 8.5.18
rollup: 4.61.1
tinyglobby: 0.2.17
optionalDependencies:
@@ -1924,7 +2219,7 @@ snapshots:
'@vitest/spy': 3.2.6
'@vitest/utils': 3.2.6
chai: 5.3.3
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
expect-type: 1.3.0
magic-string: 0.30.21
pathe: 2.0.3
@@ -1981,7 +2276,13 @@ snapshots:
optionalDependencies:
typescript: 5.7.3
web-vitals@5.3.0: {}
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
yaml-ast-parser@0.0.43: {}
yargs-parser@21.1.1: {}
+212 -71
View File
@@ -1,114 +1,248 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { Activity } from '@lucide/vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router'
import { useOperationsStore } from './stores/operations'
import { useAuthStore } from './stores/auth'
import AppSidebar from './components/layout/AppSidebar.vue'
import AppHeader from './components/layout/AppHeader.vue'
import ModuleView from './components/ModuleView.vue'
import ToastContainer from './components/ui/ToastContainer.vue'
import GalaxyBackground from './components/background/GalaxyBackground.vue'
import OpenClawConnectionNotice from './components/openclaw/OpenClawConnectionNotice.vue'
import { useOpenClawStore } from './stores/openclaw'
import { useChatStore } from './stores/chat'
import { useMissionControlUiStore } from './stores/missionControlUi'
import CommandPalette from './components/mission-control/CommandPalette.vue'
import IrisChat from './components/dashboard/v2/IrisChat.vue'
import { buildMissionControlContext } from './utils/missionControlContext'
import { useTaskBoard } from './api/taskBoard'
import { useOpenClawOverviewQuery } from './api/openclawRuntime'
import { subscribeDomainEventState, type SseConnectionState } from './services/domainEvents'
import OperationResultTray from './components/mission-control/OperationResultTray.vue'
const store = useOperationsStore()
const auth = useAuthStore()
const openClaw = useOpenClawStore()
const chat = useChatStore()
const missionControlUi = useMissionControlUiStore()
const route = useRoute()
const router = useRouter()
const taskBoard = useTaskBoard(
50,
computed(() => auth.isAuthenticated && route.name !== 'Login'),
)
const overviewQuery = useOpenClawOverviewQuery(
computed(() => auth.isAuthenticated && route.name !== 'Login'),
)
let unsubscribeDomainState: (() => void) | null = null
const activeView = computed(() => {
if (route.name === 'Settings') return 'Settings'
if (route.name === 'ProjectDetail') return 'ProjectDetail'
if (route.name === 'AgentDetail' || route.name === 'AgentCreate' || route.name === 'AgentProposalDetail') return 'Agents'
if (route.name === 'ProjectDetail') return 'Projects'
if (route.name === 'TaskDetail') return 'Task Board'
if (route.name === 'RunDetail') return 'Run Control'
return String(route.name ?? 'Dashboard')
})
const routePaths: Record<string, string> = {
Dashboard: '/dashboard', Memory: '/memory', Docs: '/docs', Security: '/security',
Projects: '/projects', 'Task Board': '/tasks', Incidents: '/incidents', Calendar: '/calendar',
Agents: '/agents', Models: '/models', Activity: '/activity', 'Mobile Chat': '/chat', Notifications: '/notifications', Settings: '/settings',
}
const navigate = (label: string) => {
const navigate = () => {
mobileNavOpen.value = false
return router.push(routePaths[label] ?? '/dashboard')
}
const mobileNavOpen = ref(false)
const standaloneViews = computed(() => {
if (route.name === 'Dashboard') return true
if (route.meta?.standalone) return true
return false
})
const queuedTasks = computed(() =>
taskBoard.board.value.offen.length
+ taskBoard.board.value.inProgress.length
+ taskBoard.board.value.review.length
+ taskBoard.board.value.blocked.length,
)
const blockedTasks = computed(() => taskBoard.board.value.blocked.length)
const missionContext = computed(() => buildMissionControlContext(route))
function syncAuthenticatedSession(authenticated: boolean) {
if (authenticated) return
chat.stopPolling()
missionControlUi.closeCommand()
missionControlUi.closeIris()
missionControlUi.dismissOperation()
}
function syncDomainMode(state: SseConnectionState) {
if (!auth.isAuthenticated || state === 'closed') openClaw.setSyncMode('stopped')
else if (state === 'open') openClaw.setSyncMode('live')
else if (state === 'unsupported') openClaw.setSyncMode('polling')
else openClaw.setSyncMode('connecting')
}
function onGlobalKeydown(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === 'k') {
event.preventDefault()
if (!auth.isAuthenticated) return
if (missionControlUi.commandOpen) missionControlUi.closeCommand()
else missionControlUi.openCommand()
}
}
function askIrisFromCommand() {
if (!auth.isOwner) return
missionControlUi.openIris()
}
async function closeIris() {
missionControlUi.closeIris()
if (route.query.iris === '1') {
const query = { ...route.query }
delete query.iris
await router.replace({ query })
}
}
function sendIrisMessage(text: string) {
if (!auth.isOwner) return
void chat.sendMessage(text, missionContext.value)
}
watch(
() => auth.isAuthenticated,
authenticated => syncAuthenticatedSession(authenticated),
)
watch(
[
() => route.query.iris,
() => auth.isAuthenticated,
() => auth.isOwner,
],
([value, isAuthenticated, isOwner]) => {
if (!isOwner) {
missionControlUi.closeIris()
chat.stopPolling()
if (value === '1' && isAuthenticated) void closeIris()
return
}
if (value === '1' && isAuthenticated) missionControlUi.openIris()
},
{ immediate: true },
)
watch(
() => missionControlUi.irisOpen,
isOpen => {
if (isOpen && auth.isAuthenticated && auth.isOwner) chat.startPolling()
else chat.stopPolling()
},
)
onMounted(() => {
if (auth.isAuthenticated) store.refresh()
syncAuthenticatedSession(auth.isAuthenticated)
unsubscribeDomainState = subscribeDomainEventState(syncDomainMode)
document.addEventListener('keydown', onGlobalKeydown)
})
onUnmounted(() => {
unsubscribeDomainState?.()
unsubscribeDomainState = null
openClaw.setSyncMode('stopped')
chat.stopPolling()
document.removeEventListener('keydown', onGlobalKeydown)
})
</script>
<template>
<RouterView v-if="route.name === 'Login' || route.name === 'Dashboard'" />
<div v-else class="shell">
<div v-else class="shell legacy-shell">
<GalaxyBackground aria-hidden="true" />
<AppSidebar
:active-view="activeView"
:mobile-nav-open="mobileNavOpen"
:queued-tasks="store.snapshot.metrics.queuedTasks"
:incidents="store.snapshot.metrics.incidents"
:queued-tasks="queuedTasks"
:incidents="blockedTasks"
@navigate="navigate"
/>
<button
v-if="mobileNavOpen"
type="button"
class="mobile-nav-backdrop"
aria-label="Close navigation"
@click="mobileNavOpen = false"
></button>
<main>
<main class="legacy-main">
<AppHeader
:connected="store.connected"
:connected="overviewQuery.data.value?.connection.connected ?? false"
:command-open="missionControlUi.commandOpen"
:iris-open="missionControlUi.irisOpen"
:can-open-iris="auth.isOwner"
@toggle-mobile-nav="mobileNavOpen = !mobileNavOpen"
@open-command="missionControlUi.openCommand"
@open-iris="missionControlUi.openIris"
/>
<section class="content">
<RouterView v-if="standaloneViews" />
<template v-else>
<div class="page-heading">
<div>
<span class="eyebrow">MISSION CONTROL</span>
<h1>{{ activeView }}</h1>
<p>System overview and operational intelligence across Noveria.</p>
</div>
<button class="refresh" @click="store.refresh()">
<Activity :size="15" :class="{ spin: store.loading }" />
Refresh
</button>
</div>
<ModuleView
:view="activeView"
:snapshot="store.snapshot"
:routing="store.routing"
@create-project="store.createProject"
@create-task="store.createTask"
@update-task-state="store.updateTaskState"
/>
</template>
<section
class="content legacy-content v2-scroll"
:data-route="String(route.name ?? '')"
>
<OpenClawConnectionNotice />
<RouterView />
</section>
</main>
<ToastContainer />
</div>
<CommandPalette
v-if="auth.isAuthenticated"
:open="missionControlUi.commandOpen"
:context="missionContext"
:can-open-iris="auth.isOwner"
:can-start-run="auth.isOwner"
@close="missionControlUi.closeCommand"
@ask-iris="askIrisFromCommand"
/>
<IrisChat
v-if="auth.isAuthenticated && auth.isOwner && missionControlUi.irisOpen"
:messages="chat.messageList"
:is-thinking="chat.isThinking"
:error="chat.error"
:context="missionContext"
@send="sendIrisMessage"
@close="closeIris"
/>
<OperationResultTray
v-if="auth.isAuthenticated && missionControlUi.operationResult"
:result="missionControlUi.operationResult"
:title="missionControlUi.operationTitle"
@close="missionControlUi.dismissOperation"
/>
</template>
<style scoped>
.shell {
display: flex;
height: 100vh;
height: 100dvh;
overflow: hidden;
position: relative;
background: var(--space-0);
}
main {
.legacy-main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
position: relative;
z-index: 1;
}
.content {
flex: 1;
display: flex;
flex-direction: column;
gap: 12px;
overflow-y: auto;
padding: 20px;
overflow-x: hidden;
padding: var(--page-pad);
}
.content > :last-child {
flex: 1 0 auto;
}
.page-heading {
@@ -118,35 +252,42 @@ main {
margin-bottom: 20px;
gap: 12px;
}
.page-heading h1 { margin: 0; font-size: 18px; }
.page-heading p { margin: 4px 0 0; font-size: 10px; color: var(--nx-text-dim); }
.page-heading h1 { margin: 0; }
.page-heading p { margin: 4px 0 0; color: var(--tx-2); }
.eyebrow {
font-size: 8.5px;
display: inline-block;
margin-bottom: 4px;
font-family: var(--font-mono-v2);
font-size: 11px;
font-weight: 700;
letter-spacing: .12em;
color: var(--nx-accent);
color: var(--a-mid);
text-transform: uppercase;
}
.refresh {
display: flex;
align-items: center;
gap: 5px;
flex-shrink: 0;
padding: 6px 11px;
border: 1px solid var(--nx-line);
border-radius: 6px;
background: transparent;
color: var(--nx-text-dim);
font-size: 9px;
cursor: pointer;
transition: background .15s;
}
.refresh:hover { background: var(--nx-accent-soft); color: #d8dbe3; }
.spin { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 860px) {
.kanban { grid-template-columns: 1fr; }
.mobile-nav-backdrop {
display: none;
}
@media (max-width: 900px) {
.content {
padding: var(--page-pad-mobile);
}
.mobile-nav-backdrop {
display: block;
position: fixed;
inset: 0;
z-index: 50;
border: 0;
background: color-mix(in srgb, var(--space-0) 62%, transparent);
backdrop-filter: blur(4px);
}
}
</style>
+63
View File
@@ -0,0 +1,63 @@
import { useQuery } from '@tanstack/vue-query'
import type { components } from './generated/schema'
import { apiClient } from './client'
import {
throwApiProblem,
type EntityRefDto,
type EntityType,
} from './contracts'
import { queryKeys } from './queryClient'
type GeneratedActivityItemDto = components['schemas']['ActivityItemDto']
type GeneratedActivityPageDto = components['schemas']['ActivityPageDto']
type ActivityItemWireDto = GeneratedActivityItemDto & {
entity?: components['schemas']['EntityRefDto'] | null
}
export type ActivityItemDto = GeneratedActivityItemDto & {
entity: EntityRefDto | null
}
export type ActivityPageDto = Omit<GeneratedActivityPageDto, 'items'> & {
items: ActivityItemDto[]
}
export async function fetchActivity(
pageSize = 200,
signal?: AbortSignal,
): Promise<ActivityPageDto> {
const boundedPageSize = Math.min(Math.max(pageSize, 1), 200)
const { data, error, response } = await apiClient.GET('/api/v1/activity', {
params: {
query: {
page: 1,
pageSize: boundedPageSize,
sort: 'newest',
},
},
signal,
})
if (!response.ok || error || !data) {
await throwApiProblem(response, 'Nexus-Aktivität konnte nicht geladen werden')
}
const page = data as GeneratedActivityPageDto & { items: ActivityItemWireDto[] }
return {
...page,
items: page.items.map(item => ({
...item,
entity: item.entity
? {
...item.entity,
type: item.entity.type as EntityType,
}
: null,
})),
}
}
export function useActivity(pageSize = 200) {
return useQuery({
queryKey: queryKeys.activity(pageSize),
queryFn: ({ signal }) => fetchActivity(pageSize, signal),
})
}
+243
View File
@@ -0,0 +1,243 @@
import { computed, toValue, type MaybeRefOrGetter } from 'vue'
import { useQuery, type QueryClient } from '@tanstack/vue-query'
import type { components } from './generated/schema'
import { apiClient } from './client'
import { throwApiProblem } from './contracts'
import { queryKeys } from './queryClient'
import { reportOperationResult } from '../services/operationResults'
export type AgentDetailDto =
components['schemas']['AgentDetailResponse']
export type AgentActivityDto =
components['schemas']['AgentActivityResponse']
export type AgentSummaryDto =
components['schemas']['AgentSummaryResponse']
export type AgentFileCollectionDto =
components['schemas']['OpenClawAgentFileCollectionDto']
export type AgentFileDto =
components['schemas']['OpenClawAgentFileDto']
export type AgentFileWriteDto =
components['schemas']['OpenClawAgentFileWriteDto']
async function requireData<T>(
response: Response,
data: T | undefined,
error: unknown,
fallback: string,
): Promise<T> {
if (!response.ok || error || !data) {
await throwApiProblem(response, fallback)
}
return data as T
}
export async function fetchAgentDetail(
id: string,
signal?: AbortSignal,
): Promise<AgentDetailDto> {
const { data, error, response } = await apiClient.GET('/api/v1/agents/{id}', {
params: { path: { id } },
signal,
})
return requireData(
response,
data,
error,
`Agent "${id}" wurde nicht gefunden`,
)
}
export async function fetchAgentActivity(
id: string,
signal?: AbortSignal,
): Promise<AgentActivityDto[]> {
const { data, error, response } = await apiClient.GET(
'/api/v1/agents/{id}/activity',
{
params: { path: { id } },
signal,
},
)
return requireData(
response,
data,
error,
'Agent-Aktivität konnte nicht geladen werden',
)
}
export async function fetchAgentSummary(
id: string,
signal?: AbortSignal,
): Promise<AgentSummaryDto> {
const { data, error, response } = await apiClient.GET(
'/api/v1/agents/{id}/summary',
{
params: { path: { id } },
signal,
},
)
return requireData(
response,
data,
error,
'Agent-Zusammenfassung konnte nicht geladen werden',
)
}
export async function fetchAgentFiles(
id: string,
signal?: AbortSignal,
): Promise<AgentFileCollectionDto> {
const { data, error, response } = await apiClient.GET(
'/api/v1/openclaw/agents/{agentId}/files',
{
params: { path: { agentId: id } },
signal,
},
)
if (!response.ok || error || !data) {
await throwApiProblem(response, 'Agent-Dateiliste konnte nicht geladen werden')
}
return data as AgentFileCollectionDto
}
export async function fetchAgentFile(
id: string,
fileName: string,
signal?: AbortSignal,
): Promise<AgentFileDto> {
const { data, error, response } = await apiClient.GET(
'/api/v1/openclaw/agents/{agentId}/files/{fileName}',
{
params: { path: { agentId: id, fileName } },
signal,
},
)
if (!response.ok || error || !data) {
await throwApiProblem(response, `${fileName} konnte nicht geladen werden`)
}
return data as AgentFileDto
}
export async function applyAgentFileWrite(
client: QueryClient,
agentId: string,
result: AgentFileWriteDto,
) {
reportOperationResult(result.operation, `${result.file.name} gespeichert`)
const fileName = result.file.name
client.setQueryData<AgentFileDto>(
queryKeys.agentFile(agentId, fileName),
result.file,
)
client.setQueryData<AgentFileCollectionDto>(
queryKeys.agentFiles(agentId),
current => current
? {
...current,
checkedAt: result.file.checkedAt,
files: current.files.map(file => file.name === fileName
? {
name: result.file.name,
missing: result.file.missing,
size: result.file.size,
updatedAt: result.file.updatedAt,
contentHash: result.file.contentHash,
}
: file),
}
: current,
)
await Promise.all([
client.invalidateQueries({
queryKey: queryKeys.agentFiles(agentId),
exact: true,
}),
client.invalidateQueries({
queryKey: queryKeys.agentFile(agentId, fileName),
exact: true,
refetchType: 'none',
}),
])
}
function resolvedInput(
id: MaybeRefOrGetter<string>,
enabled: MaybeRefOrGetter<boolean>,
) {
const resolvedId = computed(() => toValue(id))
const queryEnabled = computed(() => Boolean(resolvedId.value) && toValue(enabled))
return { resolvedId, queryEnabled }
}
export function useAgentDetailQuery(
id: MaybeRefOrGetter<string>,
enabled: MaybeRefOrGetter<boolean> = true,
) {
const { resolvedId, queryEnabled } = resolvedInput(id, enabled)
return useQuery({
queryKey: computed(() => queryKeys.agentDetail(resolvedId.value)),
queryFn: ({ signal }) => fetchAgentDetail(resolvedId.value, signal),
enabled: queryEnabled,
})
}
export function useAgentActivityQuery(
id: MaybeRefOrGetter<string>,
enabled: MaybeRefOrGetter<boolean> = true,
) {
const { resolvedId, queryEnabled } = resolvedInput(id, enabled)
return useQuery({
queryKey: computed(() => queryKeys.agentActivity(resolvedId.value)),
queryFn: ({ signal }) => fetchAgentActivity(resolvedId.value, signal),
enabled: queryEnabled,
})
}
export function useAgentSummaryQuery(
id: MaybeRefOrGetter<string>,
enabled: MaybeRefOrGetter<boolean> = true,
) {
const { resolvedId, queryEnabled } = resolvedInput(id, enabled)
return useQuery({
queryKey: computed(() => queryKeys.agentSummary(resolvedId.value)),
queryFn: ({ signal }) => fetchAgentSummary(resolvedId.value, signal),
enabled: queryEnabled,
})
}
export function useAgentFilesQuery(
id: MaybeRefOrGetter<string>,
enabled: MaybeRefOrGetter<boolean> = true,
) {
const { resolvedId, queryEnabled } = resolvedInput(id, enabled)
return useQuery({
queryKey: computed(() => queryKeys.agentFiles(resolvedId.value)),
queryFn: ({ signal }) => fetchAgentFiles(resolvedId.value, signal),
enabled: queryEnabled,
})
}
export function useAgentFileQuery(
id: MaybeRefOrGetter<string>,
fileName: MaybeRefOrGetter<string>,
enabled: MaybeRefOrGetter<boolean> = true,
) {
const resolvedId = computed(() => toValue(id))
const resolvedFileName = computed(() => toValue(fileName))
const queryEnabled = computed(() =>
Boolean(resolvedId.value)
&& Boolean(resolvedFileName.value)
&& toValue(enabled),
)
return useQuery({
queryKey: computed(() =>
queryKeys.agentFile(resolvedId.value, resolvedFileName.value),
),
queryFn: ({ signal }) =>
fetchAgentFile(resolvedId.value, resolvedFileName.value, signal),
enabled: queryEnabled,
})
}
+285
View File
@@ -0,0 +1,285 @@
import { computed, toValue, type MaybeRefOrGetter } from 'vue'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import type { components } from './generated/schema'
import { apiClient } from './client'
import { ApiProblem, type ProblemDetailsDto } from './contracts'
import { queryKeys } from './queryClient'
import { createMutationRequestContext } from '../services/mutationContext'
export type AgentCreateOptionsDto = components['schemas']['AgentCreateOptionsDto']
export type AgentProposalDto = components['schemas']['AgentProposalDto']
export type AgentProposalCollectionDto = components['schemas']['AgentProposalCollectionDto']
export type AgentProposalOperationDto = components['schemas']['AgentProposalOperationDto']
export type CreateAgentProposalRequest = components['schemas']['CreateAgentProposalRequest']
export type AgentProposalActionRequest = components['schemas']['AgentProposalActionRequest']
export interface AgentProposalListFilters {
limit?: number
cursor?: string
status?: string
}
export interface AgentProposalActionInput extends AgentProposalActionRequest {
proposalId: string
clientRequestId?: string
}
export interface AgentProposalStatusMeta {
label: string
tone: 'neutral' | 'info' | 'success' | 'warning' | 'danger'
description: string
}
const STATUS_META: Record<string, AgentProposalStatusMeta> = {
draft: {
label: 'Entwurf',
tone: 'neutral',
description: 'Der Vorschlag wurde noch nicht zur Freigabe eingereicht.',
},
awaiting_approval: {
label: 'Freigabe offen',
tone: 'warning',
description: 'Ein Owner muss den Vorschlag prüfen und ausdrücklich freigeben.',
},
provisioning: {
label: 'Wird provisioniert',
tone: 'info',
description: 'Nexus führt den freigegebenen OpenClaw-Auftrag aus und prüft das Ergebnis.',
},
ready: {
label: 'Bereit',
tone: 'success',
description: 'Der Agent wurde in OpenClaw erstellt und erfolgreich zurückgelesen.',
},
partial: {
label: 'Teilweise bereit',
tone: 'warning',
description: 'Der Agent existiert, aber mindestens ein abschließender Schritt ist fehlgeschlagen.',
},
failed: {
label: 'Fehlgeschlagen',
tone: 'danger',
description: 'Die Provisionierung wurde beendet, ohne einen bestätigten Erfolgszustand zu erreichen.',
},
in_doubt: {
label: 'Manuelle Prüfung',
tone: 'danger',
description: 'Nexus kann nicht sicher feststellen, ob OpenClaw die Mutation ausgeführt hat.',
},
rejected: {
label: 'Abgelehnt',
tone: 'neutral',
description: 'Ein Owner hat den Vorschlag abgelehnt.',
},
}
function asProblem(value: unknown): ProblemDetailsDto | null {
return value && typeof value === 'object' ? value as ProblemDetailsDto : null
}
function fail(response: Response, error: unknown, fallback: string): never {
throw new ApiProblem(response.status, asProblem(error), fallback)
}
export function getAgentProposalStatusMeta(status: string): AgentProposalStatusMeta {
return STATUS_META[status.toLowerCase()] ?? {
label: status || 'Unbekannt',
tone: 'neutral',
description: 'OpenClaw hat einen noch nicht unterstützten Proposal-Status gemeldet.',
}
}
export function canApproveAgentProposal(proposal: AgentProposalDto): boolean {
return proposal.status === 'awaiting_approval'
}
export function canRejectAgentProposal(proposal: AgentProposalDto): boolean {
return proposal.status === 'awaiting_approval'
}
export function canRetryAgentProposal(proposal: AgentProposalDto): boolean {
return proposal.status === 'failed'
|| proposal.status === 'partial'
|| proposal.status === 'in_doubt'
}
export async function fetchAgentCreateOptions(signal?: AbortSignal): Promise<AgentCreateOptionsDto> {
const { data, error, response } = await apiClient.GET('/api/v1/openclaw/agents/create-options', { signal })
if (!response.ok || !data) fail(response, error, 'Agent-Optionen konnten nicht geladen werden')
return data as AgentCreateOptionsDto
}
export async function fetchAgentProposals(
filters: AgentProposalListFilters = {},
signal?: AbortSignal,
): Promise<AgentProposalCollectionDto> {
const { data, error, response } = await apiClient.GET('/api/v1/openclaw/agent-proposals', {
params: {
query: {
...(filters.limit ? { limit: filters.limit } : {}),
...(filters.cursor ? { cursor: filters.cursor } : {}),
...(filters.status ? { status: filters.status } : {}),
},
},
signal,
})
if (!response.ok || !data) fail(response, error, 'Agent-Vorschläge konnten nicht geladen werden')
return data as AgentProposalCollectionDto
}
export async function fetchAgentProposal(
proposalId: string,
signal?: AbortSignal,
): Promise<AgentProposalDto> {
const { data, error, response } = await apiClient.GET('/api/v1/openclaw/agent-proposals/{id}', {
params: { path: { id: proposalId } },
signal,
})
if (!response.ok || !data) fail(response, error, 'Agent-Vorschlag konnte nicht geladen werden')
return data as AgentProposalDto
}
export async function createAgentProposal(
input: CreateAgentProposalRequest,
): Promise<AgentProposalOperationDto> {
const clientRequestId = input.clientRequestId?.trim() || crypto.randomUUID()
const requestContext = createMutationRequestContext('agent-proposal-create', clientRequestId)
const { data, error, response } = await apiClient.POST('/api/v1/openclaw/agent-proposals', {
headers: requestContext.headers,
body: { ...input, clientRequestId },
})
if (!response.ok || !data) fail(response, error, 'Agent-Vorschlag konnte nicht erstellt werden')
return data as AgentProposalOperationDto
}
async function mutateAgentProposal(
action: 'approve' | 'reject' | 'retry',
input: AgentProposalActionInput,
): Promise<AgentProposalOperationDto> {
const requestContext = createMutationRequestContext(
`agent-proposal-${action}`,
input.clientRequestId,
)
const path = `/api/v1/openclaw/agent-proposals/{id}/${action}` as const
const { data, error, response } = await apiClient.POST(path, {
headers: requestContext.headers,
params: { path: { id: input.proposalId } },
body: {
expectedRevision: input.expectedRevision,
reason: input.reason?.trim() || null,
},
})
if (!response.ok || !data) {
const labels = {
approve: 'Agent-Vorschlag konnte nicht freigegeben werden',
reject: 'Agent-Vorschlag konnte nicht abgelehnt werden',
retry: 'Provisionierung konnte nicht erneut gestartet werden',
}
fail(response, error, labels[action])
}
return data as AgentProposalOperationDto
}
export function approveAgentProposal(input: AgentProposalActionInput): Promise<AgentProposalOperationDto> {
return mutateAgentProposal('approve', input)
}
export function rejectAgentProposal(input: AgentProposalActionInput): Promise<AgentProposalOperationDto> {
return mutateAgentProposal('reject', input)
}
export function retryAgentProposal(input: AgentProposalActionInput): Promise<AgentProposalOperationDto> {
return mutateAgentProposal('retry', input)
}
export function useAgentCreateOptionsQuery(
enabled: MaybeRefOrGetter<boolean> = true,
) {
return useQuery({
queryKey: queryKeys.agentCreateOptions(),
queryFn: ({ signal }) => fetchAgentCreateOptions(signal),
enabled: computed(() => toValue(enabled)),
staleTime: 30_000,
})
}
export function useAgentProposalsQuery(
filters: AgentProposalListFilters = {},
enabled: MaybeRefOrGetter<boolean> = true,
) {
const stableFilters = {
...(filters.limit ? { limit: filters.limit } : {}),
...(filters.cursor ? { cursor: filters.cursor } : {}),
...(filters.status ? { status: filters.status } : {}),
}
return useQuery({
queryKey: ['openclaw', 'agent-proposals', stableFilters] as const,
queryFn: ({ signal }) => fetchAgentProposals(stableFilters, signal),
enabled: computed(() => toValue(enabled)),
})
}
export function useAgentProposalQuery(
proposalId: MaybeRefOrGetter<string>,
enabled: MaybeRefOrGetter<boolean> = true,
) {
const resolvedId = computed(() => toValue(proposalId).trim())
return useQuery({
queryKey: computed(() => queryKeys.agentProposal(resolvedId.value)),
queryFn: ({ signal }) => fetchAgentProposal(resolvedId.value, signal),
enabled: computed(() => Boolean(resolvedId.value) && toValue(enabled)),
refetchInterval(query) {
const proposal = query.state.data as AgentProposalDto | undefined
return proposal?.status === 'provisioning' ? 2_000 : false
},
})
}
function useAgentProposalMutation(
mutationFn: (input: AgentProposalActionInput) => Promise<AgentProposalOperationDto>,
) {
const queryClient = useQueryClient()
return useMutation({
mutationFn,
onSuccess: async operation => {
if (operation.proposal) {
queryClient.setQueryData(
queryKeys.agentProposal(operation.proposal.id),
operation.proposal,
)
}
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.agentProposals() }),
queryClient.invalidateQueries({ queryKey: queryKeys.openClawAgents() }),
])
},
})
}
export function useCreateAgentProposalMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: createAgentProposal,
onSuccess: async operation => {
if (operation.proposal) {
queryClient.setQueryData(
queryKeys.agentProposal(operation.proposal.id),
operation.proposal,
)
}
await queryClient.invalidateQueries({ queryKey: queryKeys.agentProposals() })
},
})
}
export function useApproveAgentProposalMutation() {
return useAgentProposalMutation(approveAgentProposal)
}
export function useRejectAgentProposalMutation() {
return useAgentProposalMutation(rejectAgentProposal)
}
export function useRetryAgentProposalMutation() {
return useAgentProposalMutation(retryAgentProposal)
}
+15
View File
@@ -0,0 +1,15 @@
import createClient from 'openapi-fetch'
import { apiFetch } from '../services/api'
import type { paths } from './generated/schema'
// openapi-fetch constructs a Request before invoking our authenticated fetch
// wrapper. A concrete same-origin base keeps that construction valid in Node
// contract tests while preserving same-origin requests in the browser.
const apiBaseUrl = typeof window !== 'undefined' && window.location?.origin
? window.location.origin
: 'http://localhost'
export const apiClient = createClient<paths>({
baseUrl: apiBaseUrl,
fetch: apiFetch,
})
+93
View File
@@ -0,0 +1,93 @@
export type EntityType =
| 'activity'
| 'agent'
| 'agent-proposal'
| 'project'
| 'task'
| 'run'
| 'cron'
| 'incident'
| 'document'
| 'notification'
| 'task-board'
| 'openclaw-task'
| 'session'
| 'approval'
| 'config'
| 'agent-file'
| 'event-stream'
export type EntityRefDto =
Omit<components['schemas']['EntityRefDto'], 'type'>
& { type: EntityType }
export interface OperationResultDto
extends Omit<
components['schemas']['OperationResultDto'],
'revision' | 'primaryRef' | 'affectedRefs'
> {
revision: number
primaryRef: EntityRefDto | null
affectedRefs: EntityRefDto[]
}
export interface DomainEventDto<TPayload = unknown> {
sequence: number
eventType: string
occurredAt: string
entity: EntityRefDto
entityRevision: number
payload: TPayload
}
export function normalizeOperationResult(
value: components['schemas']['OperationResultDto'] | null | undefined,
): OperationResultDto | null {
if (!value) return null
return {
...value,
revision: Number(value.revision),
primaryRef: value.primaryRef
? { ...value.primaryRef, type: value.primaryRef.type as EntityType }
: null,
affectedRefs: value.affectedRefs.map(entity => ({
...entity,
type: entity.type as EntityType,
})),
}
}
export interface ProblemDetailsDto {
type?: string
title?: string
status?: number
detail?: string
instance?: string
traceId?: string
currentRevision?: number
errors?: Record<string, string[]>
}
export class ApiProblem extends Error {
readonly status: number
readonly problem: ProblemDetailsDto | null
constructor(status: number, problem: ProblemDetailsDto | null, fallback: string) {
super(problem?.detail || problem?.title || fallback)
this.name = 'ApiProblem'
this.status = status
this.problem = problem
}
}
export async function throwApiProblem(response: Response, fallback: string): Promise<never> {
let problem: ProblemDetailsDto | null = null
try {
problem = await response.json() as ProblemDetailsDto
} catch {
// A structured ProblemDetails response is preferred, but legacy endpoints
// can still return an empty error body during the migration.
}
throw new ApiProblem(response.status, problem, fallback)
}
import type { components } from './generated/schema'
File diff suppressed because it is too large Load Diff
+87
View File
@@ -0,0 +1,87 @@
import { computed, toValue, type MaybeRefOrGetter } from 'vue'
import { useQuery } from '@tanstack/vue-query'
import type { components } from './generated/schema'
import { apiClient } from './client'
import { throwApiProblem } from './contracts'
import { queryKeys } from './queryClient'
export type IncidentFileDto = components['schemas']['IncidentSummary']
export type IncidentDetailDto = components['schemas']['IncidentDetail']
async function requireData<T>(
response: Response,
data: T | undefined,
error: unknown,
fallback: string,
): Promise<T> {
if (!response.ok || error || !data) {
await throwApiProblem(response, fallback)
}
return data as T
}
export async function fetchIncidents(
agentId = 'iris',
signal?: AbortSignal,
): Promise<IncidentFileDto[]> {
const { data, error, response } = await apiClient.GET('/api/v1/incidents', {
params: { query: { agentId } },
signal,
})
return requireData(
response,
data,
error,
'Incidents konnten nicht geladen werden',
)
}
export async function fetchIncident(
name: string,
agentId = 'iris',
signal?: AbortSignal,
): Promise<IncidentDetailDto> {
const { data, error, response } = await apiClient.GET(
'/api/v1/incidents/{name}',
{
params: {
path: { name },
query: { agentId },
},
signal,
},
)
return requireData(
response,
data,
error,
'Incident konnte nicht geladen werden',
)
}
export function useIncidentsQuery(
agentId: MaybeRefOrGetter<string> = 'iris',
) {
const resolvedAgentId = computed(() => toValue(agentId))
return useQuery({
queryKey: computed(() => queryKeys.incidents(resolvedAgentId.value)),
queryFn: ({ signal }) => fetchIncidents(resolvedAgentId.value, signal),
})
}
export function useIncidentQuery(
name: MaybeRefOrGetter<string>,
enabled: MaybeRefOrGetter<boolean> = true,
agentId: MaybeRefOrGetter<string> = 'iris',
) {
const resolvedName = computed(() => toValue(name))
const resolvedAgentId = computed(() => toValue(agentId))
return useQuery({
queryKey: computed(() =>
queryKeys.incident(resolvedAgentId.value, resolvedName.value),
),
queryFn: ({ signal }) =>
fetchIncident(resolvedName.value, resolvedAgentId.value, signal),
enabled: computed(() => Boolean(resolvedName.value) && toValue(enabled)),
})
}
+194
View File
@@ -0,0 +1,194 @@
import { computed, toValue, type MaybeRefOrGetter } from 'vue'
import { useQuery } from '@tanstack/vue-query'
import type { components } from './generated/schema'
import { apiClient } from './client'
import { throwApiProblem } from './contracts'
import { queryKeys } from './queryClient'
export type DocFileDto = components['schemas']['DocFileInfo']
export type DocDetailDto = components['schemas']['DocFileContent']
export type MemoryFileDto = components['schemas']['MemoryFileInfo']
export type MemoryDetailDto = components['schemas']['MemoryFileContent']
export type MemorySearchResultDto =
components['schemas']['MemorySearchResult']
async function requireData<T>(
response: Response,
data: T | undefined,
error: unknown,
fallback: string,
): Promise<T> {
if (!response.ok || error || !data) {
await throwApiProblem(response, fallback)
}
return data as T
}
export async function fetchDocs(
agentId = 'iris',
signal?: AbortSignal,
): Promise<DocFileDto[]> {
const { data, error, response } = await apiClient.GET('/api/v1/docs', {
params: { query: { agentId } },
signal,
})
return requireData(
response,
data,
error,
'Dokumente konnten nicht geladen werden',
)
}
export async function fetchDoc(
path: string,
agentId = 'iris',
signal?: AbortSignal,
): Promise<DocDetailDto> {
const { data, error, response } = await apiClient.GET(
'/api/v1/docs/{path}',
{
params: {
path: { path },
query: { agentId },
},
signal,
},
)
return requireData(
response,
data,
error,
'Dokument konnte nicht geladen werden',
)
}
export async function fetchMemoryFiles(
agentId = 'iris',
signal?: AbortSignal,
): Promise<MemoryFileDto[]> {
const { data, error, response } = await apiClient.GET('/api/v1/memory', {
params: { query: { agentId } },
signal,
})
return requireData(
response,
data,
error,
'Memory-Dateien konnten nicht geladen werden',
)
}
export async function fetchMemoryFile(
name: string,
agentId = 'iris',
signal?: AbortSignal,
): Promise<MemoryDetailDto> {
const { data, error, response } = await apiClient.GET(
'/api/v1/memory/{name}',
{
params: {
path: { name },
query: { agentId },
},
signal,
},
)
return requireData(
response,
data,
error,
'Memory-Inhalt konnte nicht geladen werden',
)
}
export async function fetchMemorySearch(
query: string,
agentId = 'iris',
signal?: AbortSignal,
): Promise<MemorySearchResultDto[]> {
const { data, error, response } = await apiClient.GET(
'/api/v1/memory/search',
{
params: { query: { q: query, agentId } },
signal,
},
)
return requireData(
response,
data,
error,
'Memory-Suche ist fehlgeschlagen',
)
}
export function useDocsQuery(
agentId: MaybeRefOrGetter<string> = 'iris',
) {
const resolvedAgentId = computed(() => toValue(agentId))
return useQuery({
queryKey: computed(() => queryKeys.docs(resolvedAgentId.value)),
queryFn: ({ signal }) => fetchDocs(resolvedAgentId.value, signal),
})
}
export function useDocQuery(
path: MaybeRefOrGetter<string>,
enabled: MaybeRefOrGetter<boolean> = true,
agentId: MaybeRefOrGetter<string> = 'iris',
) {
const resolvedPath = computed(() => toValue(path))
const resolvedAgentId = computed(() => toValue(agentId))
return useQuery({
queryKey: computed(() =>
queryKeys.doc(resolvedAgentId.value, resolvedPath.value),
),
queryFn: ({ signal }) =>
fetchDoc(resolvedPath.value, resolvedAgentId.value, signal),
enabled: computed(() => Boolean(resolvedPath.value) && toValue(enabled)),
})
}
export function useMemoryFilesQuery(
agentId: MaybeRefOrGetter<string> = 'iris',
) {
const resolvedAgentId = computed(() => toValue(agentId))
return useQuery({
queryKey: computed(() => queryKeys.memory(resolvedAgentId.value)),
queryFn: ({ signal }) => fetchMemoryFiles(resolvedAgentId.value, signal),
})
}
export function useMemoryFileQuery(
name: MaybeRefOrGetter<string>,
enabled: MaybeRefOrGetter<boolean> = true,
agentId: MaybeRefOrGetter<string> = 'iris',
) {
const resolvedName = computed(() => toValue(name))
const resolvedAgentId = computed(() => toValue(agentId))
return useQuery({
queryKey: computed(() =>
queryKeys.memoryFile(resolvedAgentId.value, resolvedName.value),
),
queryFn: ({ signal }) =>
fetchMemoryFile(resolvedName.value, resolvedAgentId.value, signal),
enabled: computed(() => Boolean(resolvedName.value) && toValue(enabled)),
})
}
export function useMemorySearchQuery(
search: MaybeRefOrGetter<string>,
enabled: MaybeRefOrGetter<boolean> = true,
agentId: MaybeRefOrGetter<string> = 'iris',
) {
const resolvedSearch = computed(() => toValue(search).trim())
const resolvedAgentId = computed(() => toValue(agentId))
return useQuery({
queryKey: computed(() =>
queryKeys.memorySearch(resolvedAgentId.value, resolvedSearch.value),
),
queryFn: ({ signal }) =>
fetchMemorySearch(resolvedSearch.value, resolvedAgentId.value, signal),
enabled: computed(() => resolvedSearch.value.length >= 2 && toValue(enabled)),
})
}
+100
View File
@@ -0,0 +1,100 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import type { components } from './generated/schema'
import { apiClient } from './client'
import { throwApiProblem } from './contracts'
import { queryKeys } from './queryClient'
import { reportOperationResult } from '../services/operationResults'
export type NotificationDto = components['schemas']['NotificationDto']
export type NotificationSnapshotDto = components['schemas']['NotificationSnapshotDto']
export interface NotificationQueryOptions {
forUser?: string
limit?: number
unreadOnly?: boolean
}
function normalizeOptions(options: NotificationQueryOptions = {}) {
return {
forUser: options.forUser?.trim().toLowerCase() || 'bao',
limit: Math.min(Math.max(options.limit ?? 50, 1), 200),
unreadOnly: options.unreadOnly ?? false,
}
}
export async function fetchNotificationSnapshot(
options: NotificationQueryOptions = {},
signal?: AbortSignal,
): Promise<NotificationSnapshotDto> {
const normalized = normalizeOptions(options)
const { data, error, response } = await apiClient.GET(
'/api/dashboard/notifications/snapshot',
{
params: { query: normalized },
signal,
},
)
if (!response.ok || error || !data) {
await throwApiProblem(response, 'Benachrichtigungen konnten nicht geladen werden')
}
return data as NotificationSnapshotDto
}
async function markNotificationRead(id: string): Promise<NotificationDto> {
const { data, error, response } = await apiClient.PATCH(
'/api/dashboard/notifications/{id}/read',
{ params: { path: { id } } },
)
if (!response.ok || error || !data) {
await throwApiProblem(response, 'Benachrichtigung konnte nicht als gelesen markiert werden')
}
const notification = data as NotificationDto
reportOperationResult(notification.operation, 'Benachrichtigung gelesen')
return notification
}
async function markAllNotificationsRead(forUser: string): Promise<components['schemas']['NotificationReadAllResultDto']> {
const { data, error, response } = await apiClient.PATCH(
'/api/dashboard/notifications/read-all',
{ params: { query: { forUser } } },
)
if (!response.ok || error || !data) {
await throwApiProblem(response, 'Benachrichtigungen konnten nicht aktualisiert werden')
}
const result = data as components['schemas']['NotificationReadAllResultDto']
reportOperationResult(result.operation, 'Benachrichtigungen gelesen')
return result
}
export function useNotificationSnapshot(options: NotificationQueryOptions = {}) {
const normalized = normalizeOptions(options)
const client = useQueryClient()
const query = useQuery({
queryKey: queryKeys.notifications(
normalized.forUser,
normalized.limit,
normalized.unreadOnly,
),
queryFn: ({ signal }) => fetchNotificationSnapshot(normalized, signal),
})
const refreshNotificationQueries = () =>
client.invalidateQueries({ queryKey: ['notifications'] })
const markReadMutation = useMutation({
mutationFn: markNotificationRead,
onSuccess: refreshNotificationQueries,
})
const markAllReadMutation = useMutation({
mutationFn: () => markAllNotificationsRead(normalized.forUser),
onSuccess: refreshNotificationQueries,
})
return {
query,
markAsRead: (id: string) => markReadMutation.mutateAsync(id),
markAllAsRead: () => markAllReadMutation.mutateAsync(),
markingRead: markReadMutation.isPending,
markingAllRead: markAllReadMutation.isPending,
}
}
+505
View File
@@ -0,0 +1,505 @@
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
type InfiniteData,
type QueryClient,
} from '@tanstack/vue-query'
import { computed, toValue, type MaybeRefOrGetter } from 'vue'
import { apiFetch } from '../services/api'
import { createMutationRequestContext } from '../services/mutationContext'
import type { components, paths } from './generated/schema'
import { queryKeys } from './queryClient'
import type {
OpenClawCollection,
OpenClawCronJob,
OpenClawCronJobDetail,
OpenClawCronRun,
OpenClawCronRunEnqueue,
OpenClawOperation,
} from '../types/openclaw'
import { reportOperationEnvelope } from '../services/operationResults'
type GeneratedCreateCronRequest =
components['schemas']['CreateOpenClawCronJobRequest']
type GeneratedPatchCronRequest =
components['schemas']['PatchOpenClawCronJobRequest']
type GeneratedCronListQuery = NonNullable<
paths['/api/v1/openclaw/cron']['get']['parameters']['query']
>
type JsonBody<TResponse> = TResponse extends {
content: { 'application/json': infer TBody }
}
? TBody
: never
type GeneratedCronCollection = JsonBody<
paths['/api/v1/openclaw/cron']['get']['responses'][200]
>
type GeneratedCronJob = components['schemas']['OpenClawCronJobDto']
type GeneratedCronJobDetail = components['schemas']['OpenClawCronJobDetailDto']
type GeneratedCronDetailOperation = JsonBody<
paths['/api/v1/openclaw/cron/{jobId}']['get']['responses'][200]
>
export type OpenClawCronCollectionDto = OpenClawCollection<OpenClawCronJob>
export type OpenClawCronDetailOperationDto = GeneratedCronDetailOperation
export type CreateOpenClawCronJobRequest = Omit<
GeneratedCreateCronRequest,
'schedule' | 'payload' | 'delivery' | 'trigger'
> & {
schedule: Record<string, unknown>
payload: Record<string, unknown>
delivery?: Record<string, unknown> | null
trigger?: Record<string, unknown> | null
}
export type PatchOpenClawCronJobRequest = Omit<
GeneratedPatchCronRequest,
'patch'
> & {
patch: Record<string, unknown>
}
export interface OpenClawCronListOptions {
includeDisabled?: boolean
limit?: number
}
interface OpenClawErrorPayload {
detail?: string
title?: string
message?: string
recovery?: string
state?: string
}
async function readJson<T>(
path: string,
init?: RequestInit,
fallback = 'OpenClaw-Anfrage ist fehlgeschlagen',
operationTitle?: string,
): Promise<T> {
const response = await apiFetch(path, init)
const payload = await response.json().catch(() => null) as T | OpenClawErrorPayload | null
if (operationTitle) reportOperationEnvelope(payload, operationTitle)
if (!response.ok) {
const failure = payload as OpenClawErrorPayload | null
const message = failure?.detail
|| failure?.message
|| failure?.title
|| fallback
throw new Error(failure?.recovery ? `${message} ${failure.recovery}` : message)
}
return payload as T
}
function mutationHeaders(operation: string, expectedHash?: string | null): Headers {
const headers = createMutationRequestContext(operation).headers
if (expectedHash) headers.set('If-Match', expectedHash)
return headers
}
function requireOperationData<T>(result: OpenClawOperation<T>): T {
if (!result.ok || !result.data) {
throw new Error(result.recovery
? `${result.message} ${result.recovery}`
: result.message)
}
return result.data
}
function normalizeListOptions(
options: OpenClawCronListOptions = {},
): Required<OpenClawCronListOptions> {
return {
includeDisabled: options.includeDisabled ?? true,
limit: Math.min(Math.max(options.limit ?? 50, 1), 200),
}
}
function nullableNumber(value: number | string | null): number | null {
if (value === null) return null
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
function normalizeCronJob(job: GeneratedCronJob): OpenClawCronJob {
return {
...job,
resourceHash: job.resourceHash ?? null,
}
}
function normalizeCronDetail(detail: GeneratedCronJobDetail): OpenClawCronJobDetail {
return {
...detail,
schedule: {
...detail.schedule,
everyMs: nullableNumber(detail.schedule.everyMs),
anchorMs: nullableNumber(detail.schedule.anchorMs),
staggerMs: nullableNumber(detail.schedule.staggerMs),
},
payload: {
...detail.payload,
timeoutSeconds: nullableNumber(detail.payload.timeoutSeconds),
noOutputTimeoutSeconds: nullableNumber(detail.payload.noOutputTimeoutSeconds),
outputMaxBytes: nullableNumber(detail.payload.outputMaxBytes),
},
failureAlert: detail.failureAlert
? {
...detail.failureAlert,
after: nullableNumber(detail.failureAlert.after),
cooldownMs: nullableNumber(detail.failureAlert.cooldownMs),
}
: null,
}
}
export async function fetchOpenClawCronJobs(
options: OpenClawCronListOptions,
cursor: string | null,
signal?: AbortSignal,
): Promise<OpenClawCronCollectionDto> {
const normalized = normalizeListOptions(options)
const query: GeneratedCronListQuery = {
includeDisabled: normalized.includeDisabled,
limit: normalized.limit,
}
if (cursor) query.cursor = cursor
const parameters = new URLSearchParams()
parameters.set('includeDisabled', String(query.includeDisabled))
parameters.set('limit', String(query.limit))
if (query.cursor) parameters.set('cursor', query.cursor)
const collection = await readJson<GeneratedCronCollection>(
`/api/v1/openclaw/cron?${parameters}`,
{ signal },
'OpenClaw-Zeitpläne konnten nicht geladen werden',
)
return {
...collection,
state: collection.state as OpenClawCronCollectionDto['state'],
items: collection.items.map(normalizeCronJob),
}
}
export async function fetchOpenClawCronDetail(
jobId: string,
signal?: AbortSignal,
): Promise<OpenClawCronJobDetail> {
const result = await readJson<OpenClawCronDetailOperationDto>(
`/api/v1/openclaw/cron/${encodeURIComponent(jobId)}`,
{ signal },
'Zeitplandetails konnten nicht geladen werden',
)
if (!result.ok || !result.data) {
throw new Error(result.recovery
? `${result.message} ${result.recovery}`
: result.message)
}
return normalizeCronDetail(result.data)
}
export async function fetchOpenClawCronRuns(
jobId: string,
runId: string | null,
cursor: string | null,
signal?: AbortSignal,
): Promise<OpenClawCollection<OpenClawCronRun>> {
const parameters = new URLSearchParams({ limit: '25' })
if (runId) parameters.set('runId', runId)
if (cursor) parameters.set('cursor', cursor)
return readJson(
`/api/v1/openclaw/cron/${encodeURIComponent(jobId)}/runs?${parameters}`,
{ signal },
'Cron-Verlauf konnte nicht geladen werden',
)
}
export function mergeOpenClawCronPages<T>(
data: InfiniteData<OpenClawCollection<T>, string | null> | undefined,
): OpenClawCollection<T> | null {
if (!data?.pages.length) return null
const last = data.pages.at(-1)!
const items = new Map<string, T>()
for (const page of data.pages) {
for (const item of page.items) {
const id = (item as { id?: unknown }).id
if (typeof id === 'string') items.set(id, item)
}
}
return { ...last, items: [...items.values()] }
}
function toCronSummary(detail: OpenClawCronJobDetail): OpenClawCronJob {
return {
id: detail.id,
name: detail.name,
description: detail.description,
schedule: detail.schedule.expression ?? detail.schedule.kind,
timeZone: detail.schedule.timeZone,
enabled: detail.enabled,
status: detail.lastRunStatus ?? 'idle',
agentId: detail.agentId,
sessionKey: detail.sessionKey,
nextRunAt: detail.nextRunAt,
lastRunAt: detail.lastRunAt,
lastRunStatus: detail.lastRunStatus,
lastError: detail.lastError,
canRun: detail.canRun,
resourceHash: detail.resourceHash,
}
}
export function applyOpenClawCronDetail(
client: QueryClient,
detail: OpenClawCronJobDetail,
): void {
const summary = toCronSummary(detail)
for (const [queryKey, current] of client.getQueriesData<
InfiniteData<OpenClawCollection<OpenClawCronJob>, string | null>
>({ queryKey: queryKeys.openClawCron() })) {
if (!current?.pages || typeof queryKey[2] !== 'object') continue
const filters = queryKey[2] as OpenClawCronListOptions
const pages = current.pages.map((page, pageIndex) => {
const withoutCurrent = page.items.filter(item => item.id !== detail.id)
const shouldInclude = filters.includeDisabled !== false || detail.enabled
const items = pageIndex === 0 && shouldInclude
? [summary, ...withoutCurrent]
: withoutCurrent
return { ...page, items }
})
client.setQueryData(queryKey, { ...current, pages })
}
}
export function removeOpenClawCronFromCache(
client: QueryClient,
jobId: string,
): void {
for (const [queryKey, current] of client.getQueriesData<
InfiniteData<OpenClawCollection<OpenClawCronJob>, string | null>
>({ queryKey: queryKeys.openClawCron() })) {
if (!current?.pages) continue
client.setQueryData(queryKey, {
...current,
pages: current.pages.map(page => ({
...page,
items: page.items.filter(item => item.id !== jobId),
})),
})
}
client.removeQueries({ queryKey: queryKeys.openClawCronDetail(jobId) })
client.removeQueries({ queryKey: ['openclaw', 'cron', 'runs', jobId] })
}
async function createCronJob(
request: CreateOpenClawCronJobRequest,
): Promise<OpenClawOperation<OpenClawCronJobDetail>> {
const result = await readJson<OpenClawOperation<OpenClawCronJobDetail>>('/api/v1/openclaw/cron', {
method: 'POST',
headers: mutationHeaders('openclaw-cron-create'),
body: JSON.stringify(request),
}, 'Zeitplan konnte nicht erstellt werden', 'Cronjob erstellt')
if (!result.ok || (!result.data && result.state !== 'replayed')) {
requireOperationData(result)
}
return result
}
async function patchCronJob(input: {
jobId: string
patch: Record<string, unknown>
expectedHash: string
}): Promise<OpenClawOperation<OpenClawCronJobDetail>> {
if (!input.expectedHash.trim()) throw new Error('A current OpenClaw resource hash is required.')
const body: PatchOpenClawCronJobRequest = {
patch: input.patch,
expectedHash: input.expectedHash,
}
const result = await readJson<OpenClawOperation<OpenClawCronJobDetail>>(
`/api/v1/openclaw/cron/${encodeURIComponent(input.jobId)}`, {
method: 'PATCH',
headers: mutationHeaders('openclaw-cron-update', input.expectedHash),
body: JSON.stringify(body),
}, 'Zeitplan konnte nicht aktualisiert werden', 'Cronjob aktualisiert')
if (!result.ok || (!result.data && result.state !== 'replayed')) {
requireOperationData(result)
}
return result
}
async function deleteCronJob(input: {
jobId: string
expectedHash: string
}): Promise<OpenClawOperation<Record<string, unknown>>> {
if (!input.expectedHash.trim()) throw new Error('A current OpenClaw resource hash is required.')
const result = await readJson<OpenClawOperation<Record<string, unknown>>>(
`/api/v1/openclaw/cron/${encodeURIComponent(input.jobId)}?expectedHash=${encodeURIComponent(input.expectedHash)}`,
{
method: 'DELETE',
headers: mutationHeaders('openclaw-cron-delete', input.expectedHash),
},
'Zeitplan konnte nicht gelöscht werden',
'Cronjob gelöscht',
)
if (!result.ok) requireOperationData(result)
return result
}
async function runCronJob(input: {
jobId: string
expectedHash: string
}): Promise<OpenClawOperation<OpenClawCronRunEnqueue>> {
if (!input.expectedHash.trim()) throw new Error('A current OpenClaw resource hash is required.')
const result = await readJson<OpenClawOperation<OpenClawCronRunEnqueue>>(
`/api/v1/openclaw/cron/${encodeURIComponent(input.jobId)}/run?expectedHash=${encodeURIComponent(input.expectedHash)}`,
{
method: 'POST',
headers: mutationHeaders('openclaw-cron-run', input.expectedHash),
},
'Cron-Run konnte nicht eingereiht werden',
'Cronjob gestartet',
)
if (!result.ok || (!result.data && result.state !== 'replayed')) {
requireOperationData(result)
}
if (result.data && !result.data.enqueued) {
throw new Error(result.recovery
? `${result.message} ${result.recovery}`
: result.message)
}
return result
}
export function useOpenClawCronJobs(
options: MaybeRefOrGetter<OpenClawCronListOptions>,
) {
const normalized = computed(() => normalizeListOptions(toValue(options)))
const query = useInfiniteQuery({
queryKey: computed(() => queryKeys.openClawCron(normalized.value)),
queryFn: ({ pageParam, signal }) =>
fetchOpenClawCronJobs(normalized.value, pageParam, signal),
initialPageParam: null as string | null,
getNextPageParam: lastPage => lastPage.nextCursor ?? undefined,
placeholderData: previous => previous,
})
const collection = computed(() => mergeOpenClawCronPages(
query.data.value as InfiniteData<OpenClawCronCollectionDto, string | null> | undefined,
))
return { query, collection }
}
export function useOpenClawCronDetail(
jobId: MaybeRefOrGetter<string>,
enabled: MaybeRefOrGetter<boolean>,
) {
const resolvedId = computed(() => toValue(jobId))
return useQuery({
queryKey: computed(() => queryKeys.openClawCronDetail(resolvedId.value)),
queryFn: ({ signal }) => fetchOpenClawCronDetail(resolvedId.value, signal),
enabled: computed(() => toValue(enabled) && resolvedId.value.length > 0),
placeholderData: (previous, previousQuery) =>
previousQuery?.queryKey.at(-1) === resolvedId.value ? previous : undefined,
})
}
export function useOpenClawCronRuns(
jobId: MaybeRefOrGetter<string>,
runId: MaybeRefOrGetter<string | null>,
enabled: MaybeRefOrGetter<boolean>,
) {
const resolvedJobId = computed(() => toValue(jobId))
const resolvedRunId = computed(() => toValue(runId))
const query = useInfiniteQuery({
queryKey: computed(() =>
queryKeys.openClawCronRuns(resolvedJobId.value, resolvedRunId.value),
),
queryFn: ({ pageParam, signal }) =>
fetchOpenClawCronRuns(
resolvedJobId.value,
resolvedRunId.value,
pageParam,
signal,
),
initialPageParam: null as string | null,
getNextPageParam: lastPage => lastPage.nextCursor ?? undefined,
enabled: computed(() => toValue(enabled) && resolvedJobId.value.length > 0),
placeholderData: (previous, previousQuery) =>
previousQuery?.queryKey[3] === resolvedJobId.value
&& (previousQuery.queryKey[4] as { runId?: string | null })?.runId === resolvedRunId.value
? previous
: undefined,
})
const collection = computed(() => mergeOpenClawCronPages(
query.data.value as InfiniteData<
OpenClawCollection<OpenClawCronRun>,
string | null
> | undefined,
))
return { query, collection }
}
export function useOpenClawCronMutations() {
const client = useQueryClient()
const refreshLists = () =>
client.invalidateQueries({
predicate: query => {
const key = query.queryKey
return key[0] === 'openclaw'
&& key[1] === 'cron'
&& (
key.length === 2
|| (typeof key[2] === 'object' && key[2] !== null)
)
},
})
const createMutation = useMutation({
mutationFn: createCronJob,
onSuccess: result => {
if (result.data) {
client.setQueryData(queryKeys.openClawCronDetail(result.data.id), result.data)
applyOpenClawCronDetail(client, result.data)
}
void refreshLists()
},
})
const patchMutation = useMutation({
mutationFn: patchCronJob,
onSuccess: (result, input) => {
if (result.data) {
client.setQueryData(queryKeys.openClawCronDetail(input.jobId), result.data)
applyOpenClawCronDetail(client, result.data)
}
void client.invalidateQueries({
queryKey: queryKeys.openClawCronDetail(input.jobId),
})
void refreshLists()
},
})
const deleteMutation = useMutation({
mutationFn: deleteCronJob,
onSuccess: (result, input) => {
removeOpenClawCronFromCache(client, input.jobId)
void refreshLists()
},
})
const runMutation = useMutation({
mutationFn: runCronJob,
onSuccess: (result, input) => {
void client.invalidateQueries({
queryKey: ['openclaw', 'cron', 'runs', input.jobId],
})
void refreshLists()
},
})
return {
createMutation,
patchMutation,
deleteMutation,
runMutation,
}
}
+66
View File
@@ -0,0 +1,66 @@
import { useQuery } from '@tanstack/vue-query'
import { computed, toValue, type MaybeRefOrGetter } from 'vue'
import { apiClient } from './client'
import { throwApiProblem } from './contracts'
import type { components, paths } from './generated/schema'
import { queryClient, queryKeys } from './queryClient'
type JsonBody<TResponse> = TResponse extends {
content: { 'application/json': infer TBody }
}
? TBody
: never
type GeneratedModelAuthQuery = NonNullable<
paths['/api/v1/openclaw/models/auth-status']['get']['parameters']['query']
>
export type OpenClawModelAuthCollectionDto = JsonBody<
paths['/api/v1/openclaw/models/auth-status']['get']['responses'][200]
>
export type OpenClawModelAuthProviderDto =
components['schemas']['OpenClawModelAuthProviderDto']
export async function fetchOpenClawModelAuthStatus(
refresh = false,
signal?: AbortSignal,
): Promise<OpenClawModelAuthCollectionDto> {
const query: GeneratedModelAuthQuery = { refresh }
const { data, error, response } = await apiClient.GET(
'/api/v1/openclaw/models/auth-status',
{
params: { query },
signal,
},
)
if (!response.ok || error || !data) {
await throwApiProblem(response, 'Provider-Authentifizierungsstatus konnte nicht geladen werden')
}
return data as OpenClawModelAuthCollectionDto
}
export function openClawModelAuthQueryOptions() {
return {
queryKey: queryKeys.openClawModelAuth(),
queryFn: ({ signal }: { signal: AbortSignal }) =>
fetchOpenClawModelAuthStatus(false, signal),
staleTime: 30_000,
} as const
}
export function useOpenClawModelAuthQuery(
enabled: MaybeRefOrGetter<boolean> = true,
) {
return useQuery({
...openClawModelAuthQueryOptions(),
enabled: computed(() => toValue(enabled)),
})
}
export function refreshOpenClawModelAuthStatus(): Promise<OpenClawModelAuthCollectionDto> {
return queryClient.fetchQuery({
...openClawModelAuthQueryOptions(),
queryFn: ({ signal }) => fetchOpenClawModelAuthStatus(true, signal),
staleTime: 0,
})
}
+237
View File
@@ -0,0 +1,237 @@
import {
useMutation,
useQuery,
useQueryClient,
type QueryClient,
} from '@tanstack/vue-query'
import { computed, toValue, type MaybeRefOrGetter } from 'vue'
import { createMutationRequestContext } from '../services/mutationContext'
import { apiClient } from './client'
import { throwApiProblem } from './contracts'
import type { components, paths } from './generated/schema'
import { queryKeys } from './queryClient'
export type OpenClawRunDto = components['schemas']['OpenClawRunDto']
export type OpenClawRunCollectionDto = components['schemas']['OpenClawRunCollectionDto']
export type OpenClawRunHistoryResponse = components['schemas']['OpenClawRunHistoryResponse']
export type OpenClawRunOperationDto = components['schemas']['OpenClawRunOperationDto']
export type StartOpenClawRunRequest = components['schemas']['StartOpenClawRunRequest']
export type OpenClawRunActionRequest = components['schemas']['OpenClawRunActionRequest']
type GeneratedRunQuery = NonNullable<
paths['/api/v1/openclaw/runs']['get']['parameters']['query']
>
export interface OpenClawRunFilters {
limit?: number
cursor?: string | null
status?: string | null
taskId?: string | null
projectId?: string | null
sessionKey?: string | null
}
export type OpenClawRunAction = 'stop' | 'resume' | 'retry'
function normalizeFilters(filters: OpenClawRunFilters = {}): OpenClawRunFilters {
return {
limit: Math.min(Math.max(filters.limit ?? 50, 1), 200),
cursor: filters.cursor?.trim() || null,
status: filters.status?.trim() || null,
taskId: filters.taskId?.trim() || null,
projectId: filters.projectId?.trim() || null,
sessionKey: filters.sessionKey?.trim() || null,
}
}
function toGeneratedQuery(filters: OpenClawRunFilters): GeneratedRunQuery {
const query: GeneratedRunQuery = { limit: filters.limit }
if (filters.cursor) query.cursor = filters.cursor
if (filters.status) query.status = filters.status
if (filters.taskId) query.taskId = filters.taskId
if (filters.projectId) query.projectId = filters.projectId
if (filters.sessionKey) query.sessionKey = filters.sessionKey
return query
}
export async function fetchOpenClawRuns(
filters: OpenClawRunFilters = {},
signal?: AbortSignal,
): Promise<OpenClawRunCollectionDto> {
const normalized = normalizeFilters(filters)
const { data, error, response } = await apiClient.GET('/api/v1/openclaw/runs', {
params: { query: toGeneratedQuery(normalized) },
signal,
})
if (!response.ok || error || !data) {
await throwApiProblem(response, 'Runs konnten nicht geladen werden')
}
return data as OpenClawRunCollectionDto
}
export async function fetchOpenClawRunHistory(
id: string,
signal?: AbortSignal,
): Promise<OpenClawRunHistoryResponse> {
const { data, error, response } = await apiClient.GET(
'/api/v1/openclaw/runs/{id}/history',
{
params: {
path: { id },
query: { gatewayLimit: 200 },
},
signal,
},
)
if (!response.ok || error || !data) {
await throwApiProblem(response, 'Run-Verlauf konnte nicht geladen werden')
}
return data as OpenClawRunHistoryResponse
}
export async function startOpenClawRun(
request: StartOpenClawRunRequest,
): Promise<OpenClawRunOperationDto> {
const context = createMutationRequestContext('openclaw-run-start')
const { data, error, response } = await apiClient.POST('/api/v1/openclaw/runs', {
body: request,
headers: context.headers,
})
if (!response.ok || error || !data) {
await throwApiProblem(response, 'Run konnte nicht gestartet werden')
}
return data as OpenClawRunOperationDto
}
async function executeOpenClawRunAction(input: {
id: string
action: OpenClawRunAction
reason?: string
}): Promise<OpenClawRunOperationDto> {
const context = createMutationRequestContext(`openclaw-run-${input.action}`)
const options = {
params: { path: { id: input.id } },
body: { reason: input.reason?.trim() || null } satisfies OpenClawRunActionRequest,
headers: context.headers,
}
const result = input.action === 'stop'
? await apiClient.POST('/api/v1/openclaw/runs/{id}/stop', options)
: input.action === 'resume'
? await apiClient.POST('/api/v1/openclaw/runs/{id}/resume', options)
: await apiClient.POST('/api/v1/openclaw/runs/{id}/retry', options)
if (!result.response.ok || result.error || !result.data) {
await throwApiProblem(result.response, `Run-Aktion ${input.action} ist fehlgeschlagen`)
}
return result.data as OpenClawRunOperationDto
}
function runMatchesFilters(run: OpenClawRunDto, filters: OpenClawRunFilters): boolean {
return (!filters.status || run.status === filters.status)
&& (!filters.taskId || run.taskId === filters.taskId)
&& (!filters.projectId || run.projectId === filters.projectId)
&& (!filters.sessionKey || run.sessionKey === filters.sessionKey)
}
function listFiltersFromKey(queryKey: readonly unknown[]): OpenClawRunFilters | null {
const value = queryKey[2]
if (value === undefined) return {}
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
return value as OpenClawRunFilters
}
export function applyOpenClawRunOperation(
client: QueryClient,
operation: OpenClawRunOperationDto,
): void {
const changedRuns = [operation.run, operation.resultRun]
.filter((run): run is OpenClawRunDto => run !== null)
for (const run of changedRuns) {
client.setQueryData(queryKeys.openClawRun(run.id), run)
client.setQueryData<OpenClawRunHistoryResponse>(
queryKeys.openClawRunHistory(run.id),
current => current ? { ...current, run } : current,
)
}
for (const [queryKey, current] of client.getQueriesData<OpenClawRunCollectionDto>({
queryKey: queryKeys.openClawRuns(),
})) {
if (!current || !Array.isArray(current.items)) continue
const filters = listFiltersFromKey(queryKey)
if (!filters) continue
const changedIds = new Set(changedRuns.map(run => run.id))
const items = current.items
.filter(run => !changedIds.has(run.id))
.concat(changedRuns.filter(run => runMatchesFilters(run, filters)))
.sort((left, right) =>
Date.parse(right.updatedAt) - Date.parse(left.updatedAt),
)
client.setQueryData<OpenClawRunCollectionDto>(queryKey, { ...current, items })
}
}
function invalidateRunQueries(
client: QueryClient,
operation: OpenClawRunOperationDto,
): void {
const ids = new Set(
[operation.run, operation.resultRun]
.filter((run): run is OpenClawRunDto => run !== null)
.map(run => run.id),
)
void client.invalidateQueries({
predicate: query => {
const key = query.queryKey
if (key[0] !== 'openclaw' || key[1] !== 'runs') return false
const isList = key.length === 2
|| (typeof key[2] === 'object' && key[2] !== null)
return isList
|| (
(key[2] === 'detail' || key[2] === 'history')
&& typeof key[3] === 'string'
&& ids.has(key[3])
)
},
})
}
export function useOpenClawRuns(
filters: MaybeRefOrGetter<OpenClawRunFilters> = {},
) {
const normalized = computed(() => normalizeFilters(toValue(filters)))
return useQuery({
queryKey: computed(() => queryKeys.openClawRuns(normalized.value)),
queryFn: ({ signal }) => fetchOpenClawRuns(normalized.value, signal),
placeholderData: previous => previous,
})
}
export function useOpenClawRunHistory(id: MaybeRefOrGetter<string>) {
const resolvedId = computed(() => toValue(id))
return useQuery({
queryKey: computed(() => queryKeys.openClawRunHistory(resolvedId.value)),
queryFn: ({ signal }) => fetchOpenClawRunHistory(resolvedId.value, signal),
enabled: computed(() => resolvedId.value.length > 0),
placeholderData: (previous, previousQuery) => {
const previousId = previousQuery?.queryKey.at(-1)
return previousId === resolvedId.value ? previous : undefined
},
})
}
export function useOpenClawRunMutations() {
const client = useQueryClient()
const onSuccess = (operation: OpenClawRunOperationDto) => {
applyOpenClawRunOperation(client, operation)
invalidateRunQueries(client, operation)
}
const startMutation = useMutation({
mutationFn: startOpenClawRun,
onSuccess,
})
const actionMutation = useMutation({
mutationFn: executeOpenClawRunAction,
onSuccess,
})
return { startMutation, actionMutation }
}
+203
View File
@@ -0,0 +1,203 @@
import { computed, toValue, type MaybeRefOrGetter } from 'vue'
import { useQuery } from '@tanstack/vue-query'
import type { paths } from './generated/schema'
import { apiClient } from './client'
import { ApiProblem, type ProblemDetailsDto } from './contracts'
import { queryClient, queryKeys } from './queryClient'
import type { AgentNodeData } from '../composables/useFlowLayout'
type JsonBody<TResponse> = TResponse extends {
content: { 'application/json': infer TBody }
}
? TBody
: never
type GeneratedOverview = JsonBody<
paths['/api/v1/openclaw/overview']['get']['responses'][200]
>
type GeneratedCapabilities = JsonBody<
paths['/api/v1/openclaw/capabilities']['get']['responses'][200]
>
type GeneratedAgents = JsonBody<
paths['/api/v1/openclaw/agents']['get']['responses'][200]
>
export type OpenClawOverviewDto = GeneratedOverview
export type OpenClawCapabilitiesDto = GeneratedCapabilities
export type OpenClawAgentsDto = GeneratedAgents
type OpenClawAgentDto = OpenClawAgentsDto['items'][number]
function asProblem(value: unknown): ProblemDetailsDto | null {
return value && typeof value === 'object' ? value as ProblemDetailsDto : null
}
function fail(response: Response, error: unknown, fallback: string): never {
throw new ApiProblem(response.status, asProblem(error), fallback)
}
export async function fetchOpenClawOverview(
signal?: AbortSignal,
): Promise<OpenClawOverviewDto> {
const { data, error, response } = await apiClient.GET('/api/v1/openclaw/overview', { signal })
if (!response.ok || !data) fail(response, error, 'OpenClaw-Übersicht konnte nicht geladen werden')
return data
}
export async function fetchOpenClawCapabilities(
signal?: AbortSignal,
): Promise<OpenClawCapabilitiesDto> {
const { data, error, response } = await apiClient.GET('/api/v1/openclaw/capabilities', { signal })
if (!response.ok || !data) fail(response, error, 'OpenClaw-Capabilities konnten nicht geladen werden')
return data
}
export async function fetchOpenClawAgents(
signal?: AbortSignal,
): Promise<OpenClawAgentsDto> {
const { data, error, response } = await apiClient.GET('/api/v1/openclaw/agents', { signal })
if (!response.ok || !data) fail(response, error, 'OpenClaw-Agenten konnten nicht geladen werden')
return data
}
export function openClawOverviewQueryOptions() {
return {
queryKey: queryKeys.openClawOverview(),
queryFn: ({ signal }: { signal: AbortSignal }) => fetchOpenClawOverview(signal),
staleTime: 15_000,
} as const
}
export function openClawCapabilitiesQueryOptions() {
return {
queryKey: queryKeys.openClawCapabilities(),
queryFn: ({ signal }: { signal: AbortSignal }) => fetchOpenClawCapabilities(signal),
staleTime: 30_000,
} as const
}
export function openClawAgentsQueryOptions() {
return {
queryKey: queryKeys.openClawAgents(),
queryFn: ({ signal }: { signal: AbortSignal }) => fetchOpenClawAgents(signal),
staleTime: 15_000,
} as const
}
export function useOpenClawOverviewQuery(
enabled: MaybeRefOrGetter<boolean> = true,
) {
return useQuery({
...openClawOverviewQueryOptions(),
enabled: computed(() => toValue(enabled)),
})
}
export function useOpenClawCapabilitiesQuery(
enabled: MaybeRefOrGetter<boolean> = true,
) {
return useQuery({
...openClawCapabilitiesQueryOptions(),
enabled: computed(() => toValue(enabled)),
})
}
export function useOpenClawAgentsQuery(
enabled: MaybeRefOrGetter<boolean> = true,
) {
return useQuery({
...openClawAgentsQueryOptions(),
enabled: computed(() => toValue(enabled)),
})
}
export async function refreshOpenClawOverview(): Promise<OpenClawOverviewDto> {
await queryClient.invalidateQueries({
queryKey: queryKeys.openClawOverview(),
refetchType: 'none',
})
return queryClient.fetchQuery({
...openClawOverviewQueryOptions(),
staleTime: 0,
})
}
export async function invalidateOpenClawRuntime(): Promise<void> {
await queryClient.invalidateQueries({ queryKey: ['openclaw'] })
}
function agentStatus(
agent: OpenClawAgentDto,
hasActiveTask: boolean,
hasActiveSession: boolean,
): AgentNodeData['status'] {
const status = agent.status.trim().toLocaleLowerCase()
if (['blocked', 'failed', 'error', 'unavailable'].includes(status)) return 'block'
if (['thinking', 'planning'].includes(status)) return 'think'
if (hasActiveTask || hasActiveSession || ['active', 'running', 'working'].includes(status)) return 'work'
return 'idle'
}
function statusLabel(status: AgentNodeData['status']): string {
if (status === 'work') return 'Arbeitet'
if (status === 'think') return 'Plant'
if (status === 'block') return 'Blockiert'
return 'Bereit'
}
function avatarFor(id: string, name: string): string {
if (id === 'iris') return 'IR'
if (id === 'programmer' || id === 'developer') return '</>'
return name.slice(0, 2).toUpperCase()
}
function formatTokenCount(value: number): string {
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(value >= 10_000_000 ? 0 : 1)}M`
if (value >= 1_000) return `${(value / 1_000).toFixed(value >= 100_000 ? 0 : 1)}k`
return Math.max(0, Math.round(value)).toLocaleString('de-DE')
}
export function projectOpenClawAgentNodes(
overview: OpenClawOverviewDto | null | undefined,
): AgentNodeData[] {
if (!overview) return []
return overview.agents.items.map(agent => {
const task = overview.tasks.items.find(item =>
item.agentId === agent.id && ['queued', 'running', 'active'].includes(item.status),
)
const session = overview.sessions.items.find(item =>
item.agentId === agent.id && ['running', 'active'].includes(item.status),
)
const status = agentStatus(agent, Boolean(task), Boolean(session))
const tokens = overview.sessions.items
.filter(item => item.agentId === agent.id)
.reduce((sum, item) => sum + Number(item.totalTokens ?? 0), 0)
return {
id: agent.id,
name: agent.name,
role: agent.id === 'iris' ? 'Chief of Staff' : 'OpenClaw Agent',
roleBadge: agent.id === 'iris' ? 'badge-violet' : 'badge-slate',
model: session?.model ?? agent.model ?? 'OpenClaw default',
avatar: avatarFor(agent.id, agent.name),
status,
statusLabel: statusLabel(status),
task: task?.title ?? session?.title ?? null,
goal: task?.summary ?? agent.description ?? null,
progress: task?.progress == null ? null : Number(task.progress),
elapsed: null,
next: null,
tokens: tokens > 0 ? formatTokenCount(tokens) : null,
cost: null,
statusDetail: task?.summary ?? agent.description ?? null,
}
})
}
export function projectOpenClawModels(
overview: OpenClawOverviewDto | null | undefined,
): Array<{ id: string; alias: string }> {
return (overview?.models.items ?? [])
.filter(model => model.available || model.configured)
.map(model => ({ id: model.id, alias: model.name }))
}
+141
View File
@@ -0,0 +1,141 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, toValue, type MaybeRefOrGetter } from 'vue'
import type { components } from './generated/schema'
import { apiClient } from './client'
import { throwApiProblem } from './contracts'
import { queryKeys } from './queryClient'
import { reportOperationResult } from '../services/operationResults'
export type ProjectDto = components['schemas']['ProjectDto']
export type ProjectTaskDto = components['schemas']['ProjectTaskDto']
export interface CreateProjectInput {
name: string
description?: string | null
}
export interface UpdateProjectInput {
name?: string | null
description?: string | null
status?: string | null
}
export async function fetchProjects(signal?: AbortSignal): Promise<ProjectDto[]> {
const { data, error, response } = await apiClient.GET('/api/v1/projects', { signal })
if (!response.ok || error || !data) {
await throwApiProblem(response, 'Projekte konnten nicht geladen werden')
}
return data as ProjectDto[]
}
export async function fetchProject(id: string, signal?: AbortSignal): Promise<ProjectDto> {
const { data, error, response } = await apiClient.GET('/api/v1/projects/{id}', {
params: { path: { id } },
signal,
})
if (!response.ok || error || !data) {
await throwApiProblem(response, 'Projekt konnte nicht geladen werden')
}
return data as ProjectDto
}
async function createProject(input: CreateProjectInput): Promise<ProjectDto> {
const { data, error, response } = await apiClient.POST('/api/v1/projects', {
body: {
name: input.name,
description: input.description ?? null,
},
})
if (!response.ok || error || !data) {
await throwApiProblem(response, 'Projekt konnte nicht erstellt werden')
}
const project = data as ProjectDto
reportOperationResult(project.operation, 'Projekt erstellt')
return withoutProjectOperation(project)
}
export async function updateProject(id: string, input: UpdateProjectInput): Promise<ProjectDto> {
const { data, error, response } = await apiClient.PATCH('/api/v1/projects/{id}', {
params: { path: { id } },
body: input as components['schemas']['UpdateProjectRequest'],
})
if (!response.ok || error || !data) {
await throwApiProblem(response, 'Projekt konnte nicht gespeichert werden')
}
const project = data as ProjectDto
reportOperationResult(project.operation, 'Projekt aktualisiert')
return withoutProjectOperation(project)
}
function withoutProjectOperation(project: ProjectDto): ProjectDto {
const { operation: _operation, ...persistedProject } = project
return persistedProject
}
export async function fetchProjectTasks(
id: string,
signal?: AbortSignal,
): Promise<ProjectTaskDto[]> {
const { data, error, response } = await apiClient.GET('/api/v1/projects/{id}/tasks', {
params: { path: { id } },
signal,
})
if (!response.ok || error || !data) {
await throwApiProblem(response, 'Projektaufgaben konnten nicht geladen werden')
}
return data as ProjectTaskDto[]
}
export function useProjects() {
const client = useQueryClient()
const query = useQuery<ProjectDto[]>({
queryKey: queryKeys.projects(),
queryFn: ({ signal }) => fetchProjects(signal),
})
const createMutation = useMutation({
mutationFn: createProject,
onSuccess: project => {
client.setQueryData<ProjectDto[]>(queryKeys.projects(), current =>
current ? [project, ...current.filter(item => item.id !== project.id)] : [project])
client.setQueryData(queryKeys.project(project.id), project)
},
})
return {
query,
createProject: (input: CreateProjectInput) => createMutation.mutateAsync(input),
creating: createMutation.isPending,
createError: createMutation.error,
}
}
export function useProject(id: MaybeRefOrGetter<string>) {
const client = useQueryClient()
const query = useQuery<ProjectDto>({
queryKey: computed(() => queryKeys.project(toValue(id))),
queryFn: ({ signal }) => fetchProject(toValue(id), signal),
enabled: () => Boolean(toValue(id)),
})
const updateMutation = useMutation({
mutationFn: (input: UpdateProjectInput) => updateProject(toValue(id), input),
onSuccess: project => {
client.setQueryData(queryKeys.project(project.id), project)
client.setQueryData<ProjectDto[]>(queryKeys.projects(), current =>
current?.map(item => item.id === project.id ? project : item))
},
})
return {
query,
updateProject: (input: UpdateProjectInput) => updateMutation.mutateAsync(input),
updating: updateMutation.isPending,
updateError: updateMutation.error,
}
}
export function useProjectTasks(id: MaybeRefOrGetter<string>) {
return useQuery<ProjectTaskDto[]>({
queryKey: computed(() => queryKeys.projectTasks(toValue(id))),
queryFn: ({ signal }) => fetchProjectTasks(toValue(id), signal),
enabled: () => Boolean(toValue(id)),
})
}
+82
View File
@@ -0,0 +1,82 @@
import { QueryClient } from '@tanstack/vue-query'
export const queryKeys = {
taskBoard: (doneLimit = 50) => ['tasks', 'board', { doneLimit }] as const,
task: (id: string) => ['tasks', 'detail', id] as const,
taskChildren: (id: string) => ['tasks', 'detail', id, 'children'] as const,
taskActivity: (id: string) => ['tasks', 'detail', id, 'activity'] as const,
taskRuns: (id: string) => ['openclaw', 'runs', { taskId: id, limit: 20 }] as const,
projects: () => ['projects'] as const,
project: (id: string) => ['projects', id] as const,
projectTasks: (id: string) => ['projects', id, 'tasks'] as const,
agentProposals: (filters?: { limit?: number; cursor?: string; status?: string }) => filters
? ['openclaw', 'agent-proposals', filters] as const
: ['openclaw', 'agent-proposals'] as const,
agentProposal: (id: string) => ['openclaw', 'agent-proposals', id] as const,
agentCreateOptions: () => ['openclaw', 'agents', 'create-options'] as const,
agentDetail: (id: string) => ['agents', 'detail', id] as const,
agentActivity: (id: string) => ['agents', 'detail', id, 'activity'] as const,
agentSummary: (id: string) => ['agents', 'detail', id, 'summary'] as const,
agentFiles: (id: string) => ['openclaw', 'agents', id, 'files'] as const,
agentFile: (id: string, fileName: string) =>
['openclaw', 'agents', id, 'files', fileName] as const,
openClawOverview: () => ['openclaw', 'overview'] as const,
openClawAgents: () => ['openclaw', 'agents'] as const,
openClawModelAuth: () => ['openclaw', 'models', 'auth-status'] as const,
openClawRuns: (filters?: {
limit?: number
cursor?: string | null
status?: string | null
taskId?: string | null
projectId?: string | null
sessionKey?: string | null
}) => filters
? ['openclaw', 'runs', filters] as const
: ['openclaw', 'runs'] as const,
openClawRun: (id: string) => ['openclaw', 'runs', 'detail', id] as const,
openClawRunHistory: (id: string) => ['openclaw', 'runs', 'history', id] as const,
openClawCron: (filters?: { includeDisabled?: boolean; limit?: number }) => filters
? ['openclaw', 'cron', filters] as const
: ['openclaw', 'cron'] as const,
openClawCronDetail: (id: string) => ['openclaw', 'cron', 'detail', id] as const,
openClawCronRuns: (id: string, runId?: string | null) =>
['openclaw', 'cron', 'runs', id, { runId: runId ?? null }] as const,
openClawCapabilities: () => ['openclaw', 'capabilities'] as const,
notifications: (forUser = 'bao', limit = 50, unreadOnly = false) =>
['notifications', { forUser, limit, unreadOnly }] as const,
activity: (pageSize = 200) => ['activity', { pageSize }] as const,
docs: (agentId = 'iris') => ['openclaw', 'agents', agentId, 'docs'] as const,
doc: (agentId: string, path: string) =>
['openclaw', 'agents', agentId, 'docs', path] as const,
memory: (agentId = 'iris') =>
['openclaw', 'agents', agentId, 'memory'] as const,
memoryFile: (agentId: string, name: string) =>
['openclaw', 'agents', agentId, 'memory', name] as const,
memorySearch: (agentId: string, query: string) =>
['openclaw', 'agents', agentId, 'memory', 'search', query] as const,
incidents: (agentId = 'iris') =>
['openclaw', 'agents', agentId, 'incidents'] as const,
incident: (agentId: string, name: string) =>
['openclaw', 'agents', agentId, 'incidents', name] as const,
securityStatus: () => ['security', 'status'] as const,
}
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 15_000,
gcTime: 5 * 60_000,
refetchOnWindowFocus: true,
retry(failureCount, error) {
const status = typeof error === 'object' && error && 'status' in error
? Number((error as { status?: unknown }).status)
: 0
if (status >= 400 && status < 500) return false
return failureCount < 2
},
},
mutations: {
retry: false,
},
},
})
+25
View File
@@ -0,0 +1,25 @@
import { useQuery } from '@tanstack/vue-query'
import type { components } from './generated/schema'
import { apiClient } from './client'
import { throwApiProblem } from './contracts'
import { queryKeys } from './queryClient'
export async function fetchSecurityStatus(
signal?: AbortSignal,
): Promise<components['schemas']['SecurityStatusDto']> {
const { data, error, response } = await apiClient.GET(
'/api/v1/security/status',
{ signal },
)
if (!response.ok || error || !data) {
await throwApiProblem(response, 'Security-Status konnte nicht geladen werden')
}
return data as components['schemas']['SecurityStatusDto']
}
export function useSecurityStatusQuery() {
return useQuery({
queryKey: queryKeys.securityStatus(),
queryFn: ({ signal }) => fetchSecurityStatus(signal),
})
}
+301
View File
@@ -0,0 +1,301 @@
import { computed, toValue, watch, type MaybeRefOrGetter } from 'vue'
import {
useInfiniteQuery,
useMutation,
useQueryClient,
type InfiniteData,
} from '@tanstack/vue-query'
import type { components } from './generated/schema'
import { apiClient } from './client'
import { queryClient, queryKeys } from './queryClient'
import { apiFetch } from '../services/api'
import { markPerformance, measurePerformance } from '../services/browserTelemetry'
import { throwApiProblem } from './contracts'
import { reportOperationResult } from '../services/operationResults'
export type TaskBoardCardDto = components['schemas']['TaskBoardCardDto']
export type TaskBoardPageDto = components['schemas']['TaskBoardPageDto']
export interface TaskBoardColumns {
offen: TaskBoardCardDto[]
inProgress: TaskBoardCardDto[]
review: TaskBoardCardDto[]
blocked: TaskBoardCardDto[]
done: TaskBoardCardDto[]
}
const EMPTY_BOARD: TaskBoardColumns = {
offen: [],
inProgress: [],
review: [],
blocked: [],
done: [],
}
function uniqueTasks(tasks: TaskBoardCardDto[]): TaskBoardCardDto[] {
const byId = new Map<string, TaskBoardCardDto>()
for (const task of tasks) byId.set(task.id, task)
return [...byId.values()]
}
async function fetchTaskBoard(doneLimit: number, doneCursor: string | null, signal: AbortSignal): Promise<TaskBoardPageDto> {
const { data, error, response } = await apiClient.GET('/api/v1/tasks/board', {
params: {
query: {
doneLimit,
...(doneCursor ? { doneCursor } : {}),
},
},
signal,
})
if (!response.ok || error || !data) {
await throwApiProblem(response, 'Task Board konnte nicht geladen werden')
}
return data as TaskBoardPageDto
}
export async function fetchTaskBoardCard(
id: string,
signal?: AbortSignal,
): Promise<TaskBoardCardDto | null> {
const { data, response } = await apiClient.GET('/api/v1/tasks/{id}/board-card', {
params: { path: { id } },
signal,
})
if (response.status === 404) return null
if (!response.ok || !data) {
await throwApiProblem(response, 'Task-Delta konnte nicht geladen werden')
}
return data as TaskBoardCardDto
}
async function mutateTaskState(id: string, state: string): Promise<string> {
const response = await apiFetch(`/api/dashboard/tasks/${encodeURIComponent(id)}/move`, {
method: 'PATCH',
body: JSON.stringify({ state }),
})
if (!response.ok) await throwApiProblem(response, 'Task-Status konnte nicht geändert werden')
const updated = await response.json() as components['schemas']['DashboardTaskDto']
reportOperationResult(updated.operation, 'Task verschoben')
return id
}
async function createTask(input: {
title: string
detail?: string | null
priority?: string
assignedTo?: string
}): Promise<string> {
const response = await apiFetch('/api/dashboard/tasks', {
method: 'POST',
body: JSON.stringify({
title: input.title,
detail: input.detail ?? null,
priority: input.priority ?? 'Medium',
assignedTo: input.assignedTo ?? 'bao',
source: 'bao',
}),
})
if (!response.ok) await throwApiProblem(response, 'Aufgabe konnte nicht erstellt werden')
const created = await response.json() as components['schemas']['DashboardTaskDto']
reportOperationResult(created.operation, 'Task erstellt')
if (!created.id) throw new Error('Die erstellte Aufgabe enthält keine ID')
return created.id
}
async function updateTask(id: string, input: {
title?: string
detail?: string | null
priority?: string
assignedTo?: string | null
dueDate?: string | null
}): Promise<string> {
const response = await apiFetch(`/api/dashboard/tasks/${encodeURIComponent(id)}`, {
method: 'PUT',
body: JSON.stringify(input),
})
if (!response.ok) await throwApiProblem(response, 'Aufgabe konnte nicht gespeichert werden')
const updated = await response.json() as components['schemas']['DashboardTaskDto']
reportOperationResult(updated.operation, 'Task aktualisiert')
return id
}
function canonicalState(state: string): string {
const states: Record<string, string> = {
offen: 'Backlog',
inProgress: 'In progress',
review: 'Review',
blocked: 'Blocked',
done: 'Done',
}
return states[state] ?? state
}
function targetColumn(state: string): keyof TaskBoardColumns {
const canonical = canonicalState(state).toLowerCase()
if (canonical === 'in progress') return 'inProgress'
if (canonical === 'review') return 'review'
if (canonical === 'blocked') return 'blocked'
if (canonical === 'done') return 'done'
return 'offen'
}
type TaskBoardInfiniteData = InfiniteData<TaskBoardPageDto, string | null>
function copyWithoutTask(page: TaskBoardPageDto, id: string): TaskBoardPageDto {
return {
...page,
offen: page.offen.filter(task => task.id !== id),
inProgress: page.inProgress.filter(task => task.id !== id),
review: page.review.filter(task => task.id !== id),
blocked: page.blocked.filter(task => task.id !== id),
done: page.done.filter(task => task.id !== id),
}
}
export function removeTaskBoardCardDelta(id: string): void {
queryClient.setQueriesData<TaskBoardInfiniteData>(
{ queryKey: ['tasks', 'board'] },
current => current
? { ...current, pages: current.pages.map(page => copyWithoutTask(page, id)) }
: current,
)
}
export function applyTaskBoardCardDelta(card: TaskBoardCardDto): void {
queryClient.setQueriesData<TaskBoardInfiniteData>(
{ queryKey: ['tasks', 'board'] },
current => {
if (!current?.pages.length) return current
const pages = current.pages.map(page => copyWithoutTask(page, card.id))
const first = { ...pages[0] }
const column = targetColumn(card.state)
const nextColumn = [card, ...first[column]]
.sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt))
first[column] = nextColumn
pages[0] = first
return { ...current, pages }
},
)
}
const taskDeltaRequests = new Map<string, Promise<void>>()
export function reconcileTaskBoardCard(id: string): Promise<void> {
const existing = taskDeltaRequests.get(id)
if (existing) return existing
const request = fetchTaskBoardCard(id)
.then(card => {
if (card) applyTaskBoardCardDelta(card)
else removeTaskBoardCardDelta(id)
})
.catch(() => queryClient.invalidateQueries({ queryKey: ['tasks', 'board'] }))
.finally(() => taskDeltaRequests.delete(id))
taskDeltaRequests.set(id, request)
return request
}
export function useTaskBoard(
doneLimit = 50,
enabled: MaybeRefOrGetter<boolean> = true,
) {
markPerformance('board-navigation')
const client = useQueryClient()
const key = queryKeys.taskBoard(doneLimit)
const query = useInfiniteQuery({
queryKey: key,
initialPageParam: null as string | null,
queryFn: ({ pageParam, signal }) => fetchTaskBoard(doneLimit, pageParam, signal),
getNextPageParam: lastPage => lastPage.hasMoreDone
? lastPage.nextDoneCursor || undefined
: undefined,
enabled: () => toValue(enabled),
staleTime: 15_000,
})
const board = computed<TaskBoardColumns>(() => {
const pages = query.data.value?.pages
if (!pages?.length) return EMPTY_BOARD
const first = pages[0]
return {
offen: first.offen,
inProgress: first.inProgress,
review: first.review,
blocked: first.blocked,
done: uniqueTasks(pages.flatMap(page => page.done)),
}
})
const revision = computed(() => query.data.value?.pages[0]?.revision ?? '')
watch(() => query.isSuccess.value, success => {
if (!success) return
requestAnimationFrame(() => {
markPerformance('board-content-visible')
void measurePerformance('board_content_visible', 'board-navigation', 'board-content-visible')
})
}, { immediate: true })
const moveMutation = useMutation({
mutationFn: ({ id, state }: { id: string; state: string }) => mutateTaskState(id, state),
onMutate: async ({ id, state }) => {
await client.cancelQueries({ queryKey: key })
const previous = client.getQueryData(key)
client.setQueryData(key, (current: typeof query.data.value) => {
if (!current) return current
let moved: TaskBoardCardDto | null = null
const pages = current.pages.map((page, pageIndex) => {
const copy: TaskBoardPageDto = {
...page,
offen: [...page.offen],
inProgress: [...page.inProgress],
review: [...page.review],
blocked: [...page.blocked],
done: [...page.done],
}
for (const column of ['offen', 'inProgress', 'review', 'blocked', 'done'] as const) {
const index = copy[column].findIndex(task => task.id === id)
if (index >= 0) {
moved = { ...copy[column][index], state: canonicalState(state) }
copy[column].splice(index, 1)
}
}
if (pageIndex === 0 && moved) copy[targetColumn(state)].push(moved)
return copy
})
return { ...current, pages }
})
return { previous }
},
onError: (_error, _variables, context) => {
if (context?.previous) client.setQueryData(key, context.previous)
},
onSuccess: id => reconcileTaskBoardCard(id),
})
const createMutation = useMutation({
mutationFn: createTask,
onSuccess: id => reconcileTaskBoardCard(id),
})
const updateMutation = useMutation({
mutationFn: ({ id, input }: {
id: string
input: Parameters<typeof updateTask>[1]
}) => updateTask(id, input),
onSuccess: id => reconcileTaskBoardCard(id),
})
return {
query,
board,
revision,
moveTask: (id: string, state: string) => moveMutation.mutateAsync({ id, state }),
createTask: (input: Parameters<typeof createTask>[0]) => createMutation.mutateAsync(input),
updateTask: (id: string, input: Parameters<typeof updateTask>[1]) => updateMutation.mutateAsync({ id, input }),
loadMoreDone: () => query.fetchNextPage(),
hasMoreDone: computed(() => Boolean(query.hasNextPage.value)),
loadingMoreDone: computed(() => query.isFetchingNextPage.value),
}
}
+139
View File
@@ -0,0 +1,139 @@
import { computed, toValue, type MaybeRefOrGetter } from 'vue'
import { useQuery } from '@tanstack/vue-query'
import type { components } from './generated/schema'
import { apiClient } from './client'
import { queryKeys } from './queryClient'
import { throwApiProblem } from './contracts'
export type DashboardTaskDto = components['schemas']['DashboardTaskDto']
export type TaskActivityDto = components['schemas']['ActivityEvent']
export type TaskRunDto = components['schemas']['OpenClawRunDto']
async function fetchTask(id: string, signal: AbortSignal): Promise<DashboardTaskDto> {
const { data, error, response } = await apiClient.GET('/api/dashboard/tasks/{id}', {
params: { path: { id } },
signal,
})
if (!response.ok || error || !data) {
await throwApiProblem(response, 'Aufgabe konnte nicht geladen werden')
}
return data as DashboardTaskDto
}
async function fetchTaskChildren(id: string, signal: AbortSignal): Promise<DashboardTaskDto[]> {
const { data, error, response } = await apiClient.GET('/api/dashboard/tasks/{id}/children', {
params: { path: { id } },
signal,
})
if (!response.ok || error || !data) {
await throwApiProblem(response, 'Child-Tasks konnten nicht geladen werden')
}
return data as DashboardTaskDto[]
}
async function fetchTaskActivity(id: string, signal: AbortSignal): Promise<TaskActivityDto[]> {
const { data, error, response } = await apiClient.GET('/api/dashboard/tasks/{id}/activity', {
params: { path: { id } },
signal,
})
if (!response.ok || error || !data) {
await throwApiProblem(response, 'Task-Aktivität konnte nicht geladen werden')
}
return data as TaskActivityDto[]
}
async function fetchTaskRuns(id: string, signal: AbortSignal): Promise<TaskRunDto[]> {
const { data, error, response } = await apiClient.GET('/api/v1/openclaw/runs', {
params: {
query: {
limit: 20,
taskId: id,
},
},
signal,
})
if (!response.ok || error || !data) {
await throwApiProblem(response, 'Zugehörige Runs konnten nicht geladen werden')
}
return (data as components['schemas']['OpenClawRunCollectionDto']).items
}
function resolvedQueryInput(
id: MaybeRefOrGetter<string>,
enabled: MaybeRefOrGetter<boolean>,
) {
const resolvedId = computed(() => toValue(id))
const queryEnabled = computed(() => Boolean(resolvedId.value) && toValue(enabled))
return { resolvedId, queryEnabled }
}
export function useTaskQuery(
id: MaybeRefOrGetter<string>,
enabled: MaybeRefOrGetter<boolean> = true,
) {
const { resolvedId, queryEnabled } = resolvedQueryInput(id, enabled)
return useQuery({
queryKey: computed(() => queryKeys.task(resolvedId.value)),
queryFn: ({ signal }) => fetchTask(resolvedId.value, signal),
enabled: queryEnabled,
})
}
export function useTaskChildren(
id: MaybeRefOrGetter<string>,
enabled: MaybeRefOrGetter<boolean> = true,
) {
const { resolvedId, queryEnabled } = resolvedQueryInput(id, enabled)
return useQuery({
queryKey: computed(() => queryKeys.taskChildren(resolvedId.value)),
queryFn: ({ signal }) => fetchTaskChildren(resolvedId.value, signal),
enabled: queryEnabled,
})
}
export function useTaskActivity(
id: MaybeRefOrGetter<string>,
enabled: MaybeRefOrGetter<boolean> = true,
) {
const { resolvedId, queryEnabled } = resolvedQueryInput(id, enabled)
return useQuery({
queryKey: computed(() => queryKeys.taskActivity(resolvedId.value)),
queryFn: ({ signal }) => fetchTaskActivity(resolvedId.value, signal),
enabled: queryEnabled,
})
}
export function useTaskRuns(
id: MaybeRefOrGetter<string>,
enabled: MaybeRefOrGetter<boolean> = true,
) {
const { resolvedId, queryEnabled } = resolvedQueryInput(id, enabled)
return useQuery({
queryKey: computed(() => queryKeys.taskRuns(resolvedId.value)),
queryFn: ({ signal }) => fetchTaskRuns(resolvedId.value, signal),
enabled: queryEnabled,
})
}
export function useTaskDetail(id: MaybeRefOrGetter<string>) {
const taskQuery = useTaskQuery(id)
const childrenQuery = useTaskChildren(id)
const activityQuery = useTaskActivity(id)
const runsQuery = useTaskRuns(id)
return {
taskQuery,
childrenQuery,
activityQuery,
runsQuery,
task: computed(() => taskQuery.data.value ?? null),
children: computed(() => childrenQuery.data.value ?? []),
activity: computed(() => activityQuery.data.value ?? []),
relatedRuns: computed(() => runsQuery.data.value ?? []),
detailsLoading: computed(() =>
childrenQuery.isPending.value
|| activityQuery.isPending.value
|| runsQuery.isPending.value,
),
}
}
File diff suppressed because it is too large Load Diff
+75 -2
View File
@@ -22,19 +22,27 @@
--tx: #ece9ff;
--tx-2: #a8a3d6;
--tx-3: #6f6aa0;
--tx-on-accent: #ffffff;
/* ── Accent Gradient ──────────────────────────────── */
--a-blue: #4f7cff;
--a-cyan: #34d6f5;
--a-purple: #b557f6;
--a-mid: #7c6cff;
--grad: linear-gradient(120deg, var(--a-blue), var(--a-purple));
--grad-soft: linear-gradient(120deg, rgba(79,124,255,.18), rgba(181,87,246,.18));
--accent-scroll: rgba(124,108,255,.32);
--accent-wash: rgba(124,108,255,.07);
--accent-wash-strong: rgba(124,108,255,.13);
--field-surface: rgba(10,8,24,.62);
--field-surface-focus: rgba(14,12,32,.82);
/* ── Status ───────────────────────────────────────── */
--st-work: #3ddc97;
--st-think: #34d6f5;
--st-queue: #fbbf24;
--st-block: #fb7185;
--st-review: #fb923c;
--st-idle: #6b6796;
/* ── Glows ────────────────────────────────────────── */
@@ -53,6 +61,63 @@
--sidebar-w: 248px;
--topbar-h: 62px;
--rail-w: 360px;
/* Typography */
--font-body: 'Manrope', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-display: 'Space Grotesk', var(--font-body);
--font-mono-v2: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace;
/* Page geometry */
--page-pad: 20px;
--page-pad-mobile: 14px;
--page-max-workspace: 1440px;
--page-max-standard: 1180px;
--page-max-reading: 880px;
/* Semantic presentation tokens */
--status-work-bg: rgba(61, 220, 151, .10);
--status-work-line: rgba(61, 220, 151, .28);
--status-think-bg: rgba(52, 214, 245, .10);
--status-think-line: rgba(52, 214, 245, .28);
--status-queue-bg: rgba(251, 191, 36, .10);
--status-queue-line: rgba(251, 191, 36, .28);
--status-block-bg: rgba(251, 113, 133, .10);
--status-block-line: rgba(251, 113, 133, .30);
--status-idle-bg: rgba(107, 103, 150, .10);
--status-idle-line: rgba(107, 103, 150, .24);
--panel-shadow: 0 22px 60px rgba(3, 2, 16, .20);
--focus-ring: 0 0 0 3px rgba(79, 124, 255, .26);
/*
* Legacy aliases
* Keep old views on the V2 source of truth without changing any runtime
* contract. Do not add new color values outside this token file.
*/
--nx-bg: var(--space-0);
--nx-panel: var(--glass);
--nx-panel-soft: var(--glass-2);
--nx-line: var(--line);
--nx-muted: var(--tx-2);
--nx-accent: var(--a-mid);
--nx-accent-soft: var(--grad-soft);
--nx-green: var(--st-work);
--nx-text: var(--tx);
--nx-text-dim: var(--tx-3);
--panel: var(--glass);
--panel-soft: var(--glass-2);
--surface: var(--glass);
--surface-raised: var(--glass-2);
--text-primary: var(--tx);
--text-secondary: var(--tx-2);
--text-muted: var(--tx-3);
--text-dim: var(--tx-3);
--accent-secondary: var(--a-purple);
--accent-soft: var(--grad-soft);
--success: var(--st-work);
--warning: var(--st-queue);
--danger: var(--st-block);
color-scheme: dark;
}
/* ── Glass card utility ────────────────────────────── */
@@ -106,5 +171,13 @@
.v2-scroll::-webkit-scrollbar-track { background: transparent; }
/* ── Typography helpers ────────────────────────────── */
.font-display { font-family: 'Space Grotesk', sans-serif; }
.font-mono-v2 { font-family: 'JetBrains Mono', monospace; font-variant-numeric: tabular-nums; }
.font-display { font-family: var(--font-display); }
.font-mono-v2 { font-family: var(--font-mono-v2); font-variant-numeric: tabular-nums; }
@media (prefers-reduced-motion: reduce) {
.status-dot,
.runtime-dot,
.spin {
animation: none !important;
}
}
+181 -71
View File
@@ -1,11 +1,61 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { Bot, CheckCircle2, Clock3, MessageSquareText, Send, ShieldAlert, Zap, ChevronLeft, ChevronRight, Edit2, Save, X, Trash2 } from '@lucide/vue'
import type { AgentInfo, OperationsSnapshot, RoutingTarget } from '../types'
import { TASK_STATES } from '../types'
import type { AgentInfo } from '../types'
import { useOpenClawAgentsQuery } from '../api/openclawRuntime'
import { apiFetch } from '../services/api'
import { useAuthStore } from '../stores/auth'
import { useOperationsStore, type PendingApprovalTask } from '../stores/operations'
import { reportOperationEnvelope } from '../services/operationResults'
type TaskState = 'Backlog' | 'In progress' | 'Review' | 'Blocked' | 'Done'
const TASK_STATES: TaskState[] = ['Backlog', 'In progress', 'Review', 'Blocked', 'Done']
interface OperationsSnapshot {
projects: Array<{
id: string
name: string
status: string
progress: number
}>
tasks: Array<{
id: string
title: string
state: TaskState
priority: string
projectId?: string | null
updatedAt: string
}>
activity: Array<{
id?: number | string
type: string
message: string
at: string
}>
}
interface RoutingTarget {
priority: number
provider: string
model: string
purpose: string
status: string
detail: string
}
interface PendingApprovalTask {
id: string
title: string
state: string
priority: string
projectId?: string | null
updatedAt: string
}
interface ChatPayload {
detail?: string
conversationId?: string
content?: string
}
const props = defineProps<{ view: string; snapshot: OperationsSnapshot; routing: RoutingTarget[] }>()
const emit = defineEmits<{
@@ -13,20 +63,46 @@ const emit = defineEmits<{
createTask: [title: string, priority: string]
updateTaskState: [id: string, state: string]
}>()
const store = useOperationsStore()
const auth = useAuthStore()
const agents = ref<AgentInfo[]>([])
const agentsLoading = ref(false)
const agentsQuery = useOpenClawAgentsQuery(computed(() => props.view === 'Agents'))
const agents = computed<AgentInfo[]>(() =>
(agentsQuery.data.value?.items ?? []).map(agent => ({
id: agent.id,
name: agent.name,
role: agent.id === 'iris' ? 'orchestrator' : 'agent',
model: agent.model ?? 'OpenClaw default',
status: agent.status,
workspace: agent.workspace ?? undefined,
description: agent.description ?? undefined,
})),
)
const agentsLoading = computed(() => agentsQuery.isPending.value)
const pendingApprovals = ref<PendingApprovalTask[]>([])
const pendingApprovalsLoading = ref(false)
const pendingApprovalsError = ref('')
const canModerateApprovals = computed(() => auth.user?.role === 'owner')
async function loadAgents() {
if (agentsLoading.value) return
agentsLoading.value = true
agents.value = await store.fetchAgents()
agentsLoading.value = false
async function requireSuccessfulResponse(
response: Response,
fallback: string,
): Promise<void> {
if (response.ok) return
const payload = await response.json().catch(() => null) as
| { detail?: string; message?: string }
| null
throw new Error(payload?.detail ?? payload?.message ?? fallback)
}
async function consumeMutationResponse(
response: Response,
fallback: string,
title: string,
): Promise<void> {
const payload = await response.json().catch(() => null) as
| { detail?: string; message?: string }
| null
reportOperationEnvelope(payload, title)
if (!response.ok) throw new Error(payload?.detail ?? payload?.message ?? fallback)
}
async function loadPendingApprovals() {
@@ -39,7 +115,9 @@ async function loadPendingApprovals() {
pendingApprovalsLoading.value = true
pendingApprovalsError.value = ''
try {
pendingApprovals.value = await store.fetchPendingApprovals()
const response = await apiFetch('/api/v1/tasks/pending-approval')
await requireSuccessfulResponse(response, 'Failed to load pending approvals')
pendingApprovals.value = await response.json() as PendingApprovalTask[]
} catch (e) {
pendingApprovalsError.value = e instanceof Error ? e.message : 'Failed to load pending approvals'
} finally {
@@ -48,12 +126,10 @@ async function loadPendingApprovals() {
}
onMounted(() => {
if (props.view === 'Agents') loadAgents()
if (props.view === 'Task Board') void loadPendingApprovals()
})
watch(() => props.view, (v) => {
if (v === 'Agents') loadAgents()
if (v === 'Task Board') void loadPendingApprovals()
})
@@ -87,7 +163,10 @@ async function handleApproveTask(id: string) {
approvingTaskId.value = id
taskActionError.value = ''
try {
await store.approveTask(id)
const response = await apiFetch(`/api/v1/tasks/${encodeURIComponent(id)}/approve`, {
method: 'POST',
})
await consumeMutationResponse(response, 'Failed to approve task', 'Task freigegeben')
pendingApprovals.value = pendingApprovals.value.filter(task => task.id !== id)
} catch (e) {
taskActionError.value = e instanceof Error ? e.message : 'Failed to approve task'
@@ -100,7 +179,10 @@ async function handleRejectTask(id: string) {
approvingTaskId.value = id
taskActionError.value = ''
try {
await store.rejectTask(id)
const response = await apiFetch(`/api/v1/tasks/${encodeURIComponent(id)}/reject`, {
method: 'POST',
})
await consumeMutationResponse(response, 'Failed to reject task', 'Task abgelehnt')
pendingApprovals.value = pendingApprovals.value.filter(task => task.id !== id)
} catch (e) {
taskActionError.value = e instanceof Error ? e.message : 'Failed to reject task'
@@ -116,7 +198,10 @@ const deleteError = ref('')
async function confirmDeleteTask(id: string) {
deleteError.value = ''
try {
await store.deleteTask(id)
const response = await apiFetch(`/api/v1/tasks/${encodeURIComponent(id)}`, {
method: 'DELETE',
})
await consumeMutationResponse(response, 'Failed to delete task', 'Task gelöscht')
deletingTaskId.value = null
} catch (e) {
deleteError.value = e instanceof Error ? e.message : 'Failed to delete task'
@@ -174,11 +259,15 @@ function startEditTask(task: { id: string; title: string; priority: string; proj
async function saveEditTask(id: string) {
try {
await store.updateTask(id, {
title: editTaskTitle.value.trim() || undefined,
priority: editTaskPriority.value || undefined,
projectId: editTaskProjectId.value || undefined,
const response = await apiFetch(`/api/v1/tasks/${encodeURIComponent(id)}`, {
method: 'PATCH',
body: JSON.stringify({
title: editTaskTitle.value.trim() || null,
priority: editTaskPriority.value || null,
projectId: editTaskProjectId.value,
}),
})
await consumeMutationResponse(response, 'Failed to update task', 'Task aktualisiert')
editingTaskId.value = null
} catch (e) {
console.error('Failed to update task', e)
@@ -201,11 +290,16 @@ async function sendMessage() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: value, conversationId: conversationId.value, agentId: 'iris' }),
})
const payload = await response.json()
const payload = await response.json() as ChatPayload
if (!response.ok) throw new Error(payload.detail ?? 'Iris is currently unavailable.')
conversationId.value = payload.conversationId
localStorage.setItem('nexus-conversation-id', payload.conversationId)
chatMessages.value.push({ role: 'iris', content: payload.content })
if (payload.conversationId) {
conversationId.value = payload.conversationId
localStorage.setItem('nexus-conversation-id', payload.conversationId)
}
chatMessages.value.push({
role: 'iris',
content: payload.content ?? 'OpenClaw accepted the request.',
})
} catch (error) {
chatMessages.value.push({ role: 'error', content: error instanceof Error ? error.message : 'Iris is currently unavailable.' })
} finally {
@@ -215,9 +309,19 @@ async function sendMessage() {
</script>
<template>
<form v-if="view === 'Projects'" class="quick-create" @submit.prevent="newProject.trim() && (emit('createProject', newProject.trim()), newProject = '')"><input v-model="newProject" placeholder="New project name" /><button>Create project</button></form>
<form v-if="view === 'Projects'" class="quick-create" @submit.prevent="newProject.trim() && (emit('createProject', newProject.trim()), newProject = '')"><input v-model="newProject" placeholder="New project name" /><button type="submit">Create project</button></form>
<div v-if="view === 'Projects'" class="module-grid">
<article v-for="project in snapshot.projects" :key="project.id" class="module-card project-card" @click="$router.push(`/projects/${project.id}`)">
<article
v-for="project in snapshot.projects"
:key="project.id"
class="module-card project-card"
role="link"
tabindex="0"
:aria-label="`${project.name} project details`"
@click="$router.push(`/projects/${project.id}`)"
@keydown.enter="$router.push(`/projects/${project.id}`)"
@keydown.space.prevent="$router.push(`/projects/${project.id}`)"
>
<div class="module-card-head"><span class="project-letter">{{ project.name[0] }}</span><span class="badge positive">{{ project.status }}</span></div>
<h3>{{ project.name }}</h3><p>Operational workspace managed through Nexus.</p>
<div class="progress"><i :style="{ width: `${project.progress}%` }"></i></div>
@@ -225,7 +329,7 @@ async function sendMessage() {
</article>
</div>
<form v-else-if="view === 'Task Board'" class="quick-create" @submit.prevent="newTask.trim() && (emit('createTask', newTask.trim(), 'Normal'), newTask = '')"><input v-model="newTask" placeholder="New task title" /><button>Create task</button></form>
<form v-else-if="view === 'Task Board'" class="quick-create" @submit.prevent="newTask.trim() && (emit('createTask', newTask.trim(), 'Normal'), newTask = '')"><input v-model="newTask" placeholder="New task title" /><button type="submit">Create task</button></form>
<section v-if="view === 'Task Board' && canModerateApprovals" class="approval-strip">
<header class="approval-strip-head">
<div>
@@ -244,8 +348,8 @@ async function sendMessage() {
<p>{{ task.priority }} · {{ new Date(task.updatedAt).toLocaleString() }}</p>
</div>
<div class="approval-actions">
<button class="task-approve-btn" :disabled="approvingTaskId === task.id" @click="handleApproveTask(task.id)"><CheckCircle2 :size="13" /></button>
<button class="task-reject-btn" :disabled="approvingTaskId === task.id" @click="handleRejectTask(task.id)"><X :size="13" /></button>
<button type="button" class="task-approve-btn" :aria-label="`Approve ${task.title}`" :disabled="approvingTaskId === task.id" @click="handleApproveTask(task.id)"><CheckCircle2 :size="13" /></button>
<button type="button" class="task-reject-btn" :aria-label="`Reject ${task.title}`" :disabled="approvingTaskId === task.id" @click="handleRejectTask(task.id)"><X :size="13" /></button>
</div>
</article>
</div>
@@ -258,20 +362,20 @@ async function sendMessage() {
<template v-if="editingTaskId === task.id">
<input v-model="editTaskTitle" class="task-edit-input" placeholder="Task title" maxlength="240" />
<div class="task-edit-row">
<select v-model="editTaskPriority">
<select v-model="editTaskPriority" aria-label="Task priority">
<option value="Critical">Critical</option>
<option value="High">High</option>
<option value="Normal">Normal</option>
<option value="Low">Low</option>
</select>
<select v-model="editTaskProjectId">
<select v-model="editTaskProjectId" aria-label="Task project">
<option :value="null">No project</option>
<option v-for="p in snapshot.projects" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<div class="task-edit-actions">
<button class="task-edit-save" @click="saveEditTask(task.id)"><Save :size="13" /> Save</button>
<button class="task-edit-cancel" @click="cancelEditTask"><X :size="13" /> Cancel</button>
<button type="button" class="task-edit-save" @click="saveEditTask(task.id)"><Save :size="13" /> Save</button>
<button type="button" class="task-edit-cancel" @click="cancelEditTask"><X :size="13" /> Cancel</button>
</div>
</template>
<template v-else>
@@ -280,29 +384,35 @@ async function sendMessage() {
<div class="task-card-actions">
<template v-if="task.state === 'In progress' && canModerateApprovals">
<button
type="button"
class="task-approve-btn"
:aria-label="`Approve ${task.title}`"
title="Approve"
:disabled="approvingTaskId === task.id"
@click="handleApproveTask(task.id)"
><CheckCircle2 :size="13" /></button>
<button
type="button"
class="task-reject-btn"
:aria-label="`Reject ${task.title}`"
title="Reject"
:disabled="approvingTaskId === task.id"
@click="handleRejectTask(task.id)"
><X :size="13" /></button>
</template>
<button class="task-edit-btn" @click="startEditTask(task)" title="Edit task"><Edit2 :size="12" /></button>
<button type="button" class="task-edit-btn" :aria-label="`Edit ${task.title}`" @click="startEditTask(task)" title="Edit task"><Edit2 :size="12" /></button>
<button
v-if="task.state === 'Done' || task.state === 'Backlog'"
type="button"
class="task-delete-btn"
:aria-label="`Delete ${task.title}`"
title="Delete task"
@click="deletingTaskId = task.id; deleteError = ''"
><Trash2 :size="12" /></button>
</div>
</div>
<h3>{{ task.title }}</h3>
<select :value="task.state" @change="emit('updateTaskState', task.id, ($event.target as HTMLSelectElement).value)">
<select :value="task.state" :aria-label="`State for ${task.title}`" @change="emit('updateTaskState', task.id, ($event.target as HTMLSelectElement).value)">
<option v-for="state in TASK_STATES" :key="state" :value="state">{{ state }}</option>
</select>
<footer><Clock3 :size="13" /> {{ new Date(task.updatedAt).toLocaleString() }}</footer>
@@ -342,14 +452,14 @@ async function sendMessage() {
<div class="activity-filters">
<div class="filter-group">
<label>Type</label>
<select v-model="activityTypeFilter">
<select v-model="activityTypeFilter" aria-label="Filter activity by type">
<option value="">All types</option>
<option v-for="type in availableTypes" :key="type" :value="type">{{ type }}</option>
</select>
</div>
<div class="filter-group">
<label>Sort</label>
<select v-model="activitySort">
<select v-model="activitySort" aria-label="Sort activity">
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
</select>
@@ -369,9 +479,9 @@ async function sendMessage() {
</article>
</div>
<div v-if="activityTotalPages > 1" class="activity-pagination">
<button :disabled="activityPage <= 1" @click="activityPage--"><ChevronLeft :size="14" /></button>
<button type="button" aria-label="Previous activity page" :disabled="activityPage <= 1" @click="activityPage--"><ChevronLeft :size="14" /></button>
<span>{{ activityPage }} / {{ activityTotalPages }}</span>
<button :disabled="activityPage >= activityTotalPages" @click="activityPage++"><ChevronRight :size="14" /></button>
<button type="button" aria-label="Next activity page" :disabled="activityPage >= activityTotalPages" @click="activityPage++"><ChevronRight :size="14" /></button>
</div>
</div>
@@ -382,7 +492,7 @@ async function sendMessage() {
<div v-else-if="view === 'Mobile Chat'" class="chat-shell panel">
<header><div class="agent-avatar"><MessageSquareText :size="20" /></div><div><h3>Iris Mobile</h3><p>Secure owner operations channel</p></div><span class="badge warning">Preview</span></header>
<div class="messages"><div class="message iris"><strong>Iris</strong><p>Nexus is online. Messages are routed through the OpenClaw runtime.</p></div><div v-for="(item, index) in chatMessages" :key="index" :class="['message', item.role]"><strong>{{ item.role === 'owner' ? 'Owner' : item.role === 'iris' ? 'Iris' : 'Runtime' }}</strong><p>{{ item.content }}</p></div><div v-if="chatPending" class="message iris pending"><strong>Iris</strong><p>Working...</p></div></div>
<form @submit.prevent="sendMessage"><input v-model="message" :disabled="chatPending" placeholder="Ask for status or create a task..." /><button :disabled="chatPending"><Send :size="15" /></button></form>
<form @submit.prevent="sendMessage"><input v-model="message" :disabled="chatPending" placeholder="Ask for status or create a task..." /><button type="submit" aria-label="Send message" :disabled="chatPending"><Send :size="15" /></button></form>
</div>
<!-- Task deletion confirmation dialog -->
@@ -393,8 +503,8 @@ async function sendMessage() {
<p>This action cannot be undone. The task will be permanently removed.</p>
<p v-if="deleteError" class="delete-error">{{ deleteError }}</p>
<div class="delete-actions">
<button class="delete-cancel" @click="cancelDeleteTask">Cancel</button>
<button class="delete-confirm" @click="confirmDeleteTask(deletingTaskId)">Delete</button>
<button type="button" class="delete-cancel" @click="cancelDeleteTask">Cancel</button>
<button type="button" class="delete-confirm" @click="confirmDeleteTask(deletingTaskId)">Delete</button>
</div>
</div>
</div>
@@ -405,9 +515,9 @@ async function sendMessage() {
.approval-strip {
margin: 0 0 18px;
padding: 14px 16px;
border: 1px solid var(--line, #1e2030);
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(255,255,255,.025);
background: color-mix(in srgb, var(--tx) 2.5%, transparent);
}
.approval-strip-head {
display: flex;
@@ -420,10 +530,10 @@ async function sendMessage() {
}
.approval-strip-note {
margin: 10px 0 0;
color: #8e96a8;
color: var(--tx-2);
}
.approval-strip-note.error {
color: #e16e75;
color: var(--st-block);
}
.approval-list {
display: grid;
@@ -436,13 +546,13 @@ async function sendMessage() {
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border: 1px solid rgba(255,255,255,.06);
border: 1px solid color-mix(in srgb, var(--tx) 6%, transparent);
border-radius: 12px;
background: rgba(8, 10, 18, .35);
background: color-mix(in srgb, var(--space-1) 35%, transparent);
}
.approval-card p {
margin: 4px 0 0;
color: #8e96a8;
color: var(--tx-2);
font-size: 12px;
}
.approval-actions {
@@ -487,13 +597,13 @@ async function sendMessage() {
opacity: 1;
}
.task-delete-btn:hover {
color: var(--danger, #e74c3c);
color: var(--st-review);
}
.task-edit-input {
width: 100%;
padding: 0.35rem 0.5rem;
background: var(--surface-raised);
border: 1px solid var(--border);
border: 1px solid var(--line);
border-radius: 6px;
font-size: 0.9rem;
color: var(--text-primary);
@@ -508,7 +618,7 @@ async function sendMessage() {
flex: 1;
padding: 0.25rem 0.4rem;
background: var(--surface-raised);
border: 1px solid var(--border);
border: 1px solid var(--line);
border-radius: 6px;
font-size: 0.8rem;
color: var(--text-primary);
@@ -523,7 +633,7 @@ async function sendMessage() {
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: var(--nx-accent);
color: #fff;
color: var(--tx);
border: none;
border-radius: 4px;
font-size: 0.78rem;
@@ -536,7 +646,7 @@ async function sendMessage() {
padding: 0.25rem 0.5rem;
background: var(--surface-raised);
color: var(--text-secondary);
border: 1px solid var(--border);
border: 1px solid var(--line);
border-radius: 4px;
font-size: 0.78rem;
cursor: pointer;
@@ -565,7 +675,7 @@ async function sendMessage() {
.filter-group select {
padding: 0.35rem 0.5rem;
background: var(--surface-raised);
border: 1px solid var(--border);
border: 1px solid var(--line);
border-radius: 6px;
font-size: 0.85rem;
color: var(--text-primary);
@@ -576,14 +686,14 @@ async function sendMessage() {
justify-content: center;
gap: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border);
border-top: 1px solid var(--line);
}
.activity-pagination button {
display: flex;
align-items: center;
padding: 0.3rem 0.5rem;
background: var(--surface-raised);
border: 1px solid var(--border);
border: 1px solid var(--line);
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
@@ -602,7 +712,7 @@ async function sendMessage() {
}
.project-card:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
box-shadow: 0 4px 12px color-mix(in srgb, var(--space-0) 10%, transparent);
}
.settings-redirect {
padding: 2rem;
@@ -621,7 +731,7 @@ async function sendMessage() {
.delete-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
background: color-mix(in srgb, var(--space-0) 50%, transparent);
display: flex;
align-items: center;
justify-content: center;
@@ -629,12 +739,12 @@ async function sendMessage() {
}
.delete-dialog {
background: var(--surface);
border: 1px solid var(--border);
border: 1px solid var(--line);
border-radius: 12px;
padding: 1.5rem;
max-width: 380px;
width: 90%;
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
box-shadow: 0 8px 24px color-mix(in srgb, var(--space-0) 30%, transparent);
}
.delete-dialog h3 {
margin: 0 0 0.5rem;
@@ -647,7 +757,7 @@ async function sendMessage() {
line-height: 1.4;
}
.delete-error {
color: var(--danger, #e74c3c) !important;
color: var(--st-review) !important;
font-weight: 600;
}
.delete-actions {
@@ -658,7 +768,7 @@ async function sendMessage() {
.delete-cancel {
padding: 0.45rem 1rem;
background: var(--surface-raised);
border: 1px solid var(--border);
border: 1px solid var(--line);
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
@@ -666,10 +776,10 @@ async function sendMessage() {
}
.delete-confirm {
padding: 0.45rem 1rem;
background: var(--danger, #e74c3c);
background: var(--st-review);
border: none;
border-radius: 6px;
color: #fff;
color: var(--tx);
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
@@ -688,7 +798,7 @@ async function sendMessage() {
.agent-model-tag {
font-size: 0.7rem;
background: var(--surface-raised);
border: 1px solid var(--border);
border: 1px solid var(--line);
border-radius: 4px;
padding: 0.1rem 0.35rem;
color: var(--text-muted);
@@ -721,17 +831,17 @@ async function sendMessage() {
opacity: 1;
}
.task-approve-btn {
color: var(--success, #27ae60);
color: var(--st-work);
}
.task-approve-btn:hover {
color: var(--success, #27ae60);
color: var(--st-work);
filter: brightness(1.2);
}
.task-reject-btn {
color: var(--warning, #f39c12);
color: var(--st-queue);
}
.task-reject-btn:hover {
color: var(--danger, #e74c3c);
color: var(--st-review);
}
.task-approve-btn:disabled,
.task-reject-btn:disabled {
@@ -0,0 +1,219 @@
<script setup lang="ts">
import {
ArrowLeft,
CircleAlert,
FileText,
Folder,
Loader2,
RefreshCw,
X,
} from '@lucide/vue'
import { computed, onMounted, ref } from 'vue'
import { apiFetch } from '../../services/api'
interface WorkspaceEntry {
path: string
name: string
kind: string
size: number | null
updatedAt: string | null
}
interface WorkspaceCollection {
agentId: string
path: string
parentPath: string | null
entries: WorkspaceEntry[]
totalEntries: number
offset: number
checkedAt: string
}
interface WorkspaceFile {
agentId: string
path: string
name: string
size: number
updatedAt: string | null
mimeType: string
encoding: string
content: string
contentHash: string
checkedAt: string
}
const props = defineProps<{ agentId: string }>()
const collection = ref<WorkspaceCollection | null>(null)
const selectedFile = ref<WorkspaceFile | null>(null)
const loading = ref(false)
const error = ref('')
const currentLabel = computed(() => collection.value?.path || 'Workspace root')
async function loadDirectory(path = '') {
loading.value = true
error.value = ''
selectedFile.value = null
try {
const query = new URLSearchParams({ path, offset: '0', limit: '250' })
const response = await apiFetch(
`/api/v1/openclaw/agents/${encodeURIComponent(props.agentId)}/workspace?${query}`,
)
const payload = await response.json().catch(() => null) as
| WorkspaceCollection
| { message?: string }
| null
if (!response.ok) throw new Error((payload as { message?: string } | null)?.message || `HTTP ${response.status}`)
collection.value = payload as WorkspaceCollection
} catch (cause) {
error.value = cause instanceof Error ? cause.message : 'Workspace konnte nicht geladen werden.'
} finally {
loading.value = false
}
}
async function openEntry(entry: WorkspaceEntry) {
if (entry.kind === 'directory' || entry.kind === 'folder') {
await loadDirectory(entry.path)
return
}
loading.value = true
error.value = ''
try {
const query = new URLSearchParams({ path: entry.path })
const response = await apiFetch(
`/api/v1/openclaw/agents/${encodeURIComponent(props.agentId)}/workspace/file?${query}`,
)
const payload = await response.json().catch(() => null) as
| WorkspaceFile
| { message?: string }
| null
if (!response.ok) throw new Error((payload as { message?: string } | null)?.message || `HTTP ${response.status}`)
selectedFile.value = payload as WorkspaceFile
} catch (cause) {
error.value = cause instanceof Error ? cause.message : 'Workspace-Datei konnte nicht geladen werden.'
} finally {
loading.value = false
}
}
function formatSize(size: number | null) {
if (size === null) return '—'
if (size < 1024) return `${size} B`
return `${(size / 1024).toFixed(1)} KB`
}
onMounted(() => loadDirectory())
</script>
<template>
<section class="workspace-browser" aria-labelledby="workspace-browser-title">
<header>
<div>
<span class="eyebrow">READ-ONLY WORKSPACE</span>
<h3 id="workspace-browser-title">Zusätzliche Agent-Dateien</h3>
<p>Custom-Dokumente werden sicher über OpenClaw gelesen und bleiben unverändert.</p>
</div>
<button
type="button"
class="nexus-button"
:disabled="loading"
@click="loadDirectory(collection?.path || '')"
>
<Loader2 v-if="loading" :size="14" class="spin" aria-hidden="true" />
<RefreshCw v-else :size="14" aria-hidden="true" />
Aktualisieren
</button>
</header>
<div class="workspace-path">
<button
type="button"
class="nexus-button"
:disabled="!collection?.parentPath && collection?.path === ''"
@click="loadDirectory(collection?.parentPath || '')"
>
<ArrowLeft :size="14" aria-hidden="true" />
Eine Ebene hoch
</button>
<code>{{ currentLabel }}</code>
<span>{{ collection?.totalEntries ?? 0 }} Einträge</span>
</div>
<div v-if="error" class="workspace-state workspace-state--error" role="alert">
<CircleAlert :size="17" aria-hidden="true" />
<p>{{ error }}</p>
<button type="button" class="nexus-button" @click="loadDirectory(collection?.path || '')">Erneut laden</button>
</div>
<div v-else-if="loading && !collection" class="workspace-state" role="status">
<Loader2 :size="17" class="spin" aria-hidden="true" />
<p>Workspace wird geladen</p>
</div>
<div v-else-if="collection?.entries.length" class="workspace-list">
<button
v-for="entry in collection.entries"
:key="entry.path"
type="button"
class="workspace-entry"
@click="openEntry(entry)"
>
<Folder v-if="entry.kind === 'directory' || entry.kind === 'folder'" :size="16" aria-hidden="true" />
<FileText v-else :size="16" aria-hidden="true" />
<span><strong>{{ entry.name }}</strong><code>{{ entry.path }}</code></span>
<small>{{ formatSize(entry.size) }}</small>
</button>
</div>
<div v-else class="workspace-state">
<Folder :size="17" aria-hidden="true" />
<p>OpenClaw meldet in diesem Ordner keine lesbaren Einträge.</p>
</div>
<div v-if="selectedFile" class="workspace-preview">
<header>
<div>
<strong>{{ selectedFile.name }}</strong>
<code>{{ selectedFile.path }} · sha256:{{ selectedFile.contentHash.slice(0, 12) }}</code>
</div>
<button type="button" aria-label="Dateivorschau schließen" @click="selectedFile = null">
<X :size="16" aria-hidden="true" />
</button>
</header>
<pre tabindex="0">{{ selectedFile.content }}</pre>
</div>
</section>
</template>
<style scoped>
.workspace-browser { display: grid; gap: 12px; margin-top: 16px; padding: 15px; border: 1px solid var(--line); border-radius: var(--r); background: var(--glass); }
.workspace-browser > header, .workspace-path, .workspace-entry, .workspace-preview > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.workspace-browser h3 { margin: 3px 0 0; font-family: var(--font-display); font-size: 15px; }
.workspace-browser header p { margin: 4px 0 0; color: var(--tx-3); line-height: 1.5; }
.workspace-path { min-width: 0; padding: 9px; border: 1px solid var(--line); border-radius: var(--r-sm); background: var(--accent-wash); }
.workspace-path code { min-width: 0; overflow: hidden; color: var(--tx-2); font-family: var(--font-mono-v2); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.workspace-path > span { flex: 0 0 auto; color: var(--tx-3); font-size: 11px; }
.workspace-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; }
.workspace-entry { min-width: 0; padding: 10px; border: 1px solid var(--line); border-radius: var(--r-sm); background: var(--accent-wash); color: var(--tx-2); text-align: left; cursor: pointer; }
.workspace-entry:hover { border-color: var(--line-3); }
.workspace-entry > svg { flex: 0 0 auto; color: var(--a-mid); }
.workspace-entry > span { display: grid; flex: 1; gap: 3px; min-width: 0; }
.workspace-entry strong, .workspace-entry code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.workspace-entry code, .workspace-entry small { color: var(--tx-3); font-family: var(--font-mono-v2); font-size: 10px; }
.workspace-state { display: flex; align-items: center; gap: 9px; min-height: 58px; padding: 12px; border: 1px dashed var(--line-2); border-radius: var(--r-sm); color: var(--tx-3); }
.workspace-state p { flex: 1; margin: 0; }
.workspace-state--error { border-style: solid; border-color: var(--status-block-line); background: var(--status-block-bg); color: var(--st-block); }
.workspace-preview { overflow: hidden; border: 1px solid var(--line-2); border-radius: var(--r-sm); }
.workspace-preview > header { padding: 11px 13px; border-bottom: 1px solid var(--line); background: var(--accent-wash); }
.workspace-preview header div { display: grid; gap: 3px; min-width: 0; }
.workspace-preview header code { overflow-wrap: anywhere; color: var(--tx-3); font-family: var(--font-mono-v2); font-size: 10px; }
.workspace-preview header button { width: 34px; height: 34px; display: grid; place-items: center; flex: 0 0 auto; border: 1px solid var(--line); border-radius: var(--r-sm); background: var(--glass); color: var(--tx-2); cursor: pointer; }
.workspace-preview pre { max-height: 420px; margin: 0; overflow: auto; padding: 14px; background: var(--field-surface); color: var(--tx-2); font-family: var(--font-mono-v2); font-size: 11px; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
.spin { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 680px) {
.workspace-browser > header, .workspace-path { align-items: flex-start; flex-direction: column; }
.workspace-list { grid-template-columns: 1fr; }
.workspace-path code { white-space: normal; overflow-wrap: anywhere; }
}
</style>
+63 -40
View File
@@ -13,6 +13,9 @@ defineProps<{
backupStatus: string
reloadStatus: string
reloadMessage: string
contentHash: string
verified: boolean
readOnly: boolean
}>()
defineEmits<{
@@ -20,11 +23,6 @@ defineEmits<{
save: []
}>()
function onInput(event: Event) {
const textarea = event.target as HTMLTextAreaElement
// Pass content change up, parent handles dirty detection
;(event.target as HTMLTextAreaElement).dispatchEvent(new Event('input', { bubbles: true }))
}
</script>
<template>
@@ -38,6 +36,9 @@ function onInput(event: Event) {
<span class="meta-sep">·</span>
{{ fileModified }}
</span>
<code v-if="contentHash" class="editor-hash" :title="contentHash">
sha256:{{ contentHash.slice(0, 12) }}
</code>
</div>
<!-- Save button & status -->
@@ -51,9 +52,10 @@ function onInput(event: Event) {
{{ saveMessage }}
</span>
<button
type="button"
class="save-btn"
:class="{ dirty, saving }"
:disabled="!dirty || saving"
:disabled="readOnly || !dirty || saving"
@click="$emit('save')"
>
<Loader2 v-if="saving" :size="14" class="spin" />
@@ -64,15 +66,18 @@ function onInput(event: Event) {
</div>
<div v-if="reloadMessage" class="editor-health">
<span class="health-pill" :class="backupStatus">Backup {{ backupStatus }}</span>
<span class="health-pill" :class="reloadStatus">Reload {{ reloadStatus }}</span>
<span class="health-pill" :class="verified ? 'verified' : reloadStatus">
{{ verified ? 'Read-back verifiziert' : 'Runtime-Aktivierung unbestätigt' }}
</span>
<span class="health-note">{{ reloadMessage }}</span>
</div>
<!-- Text editor -->
<textarea
class="config-editor"
:aria-label="fileName ? `Edit ${fileName}` : 'Edit agent configuration'"
:value="content"
:readonly="readOnly"
@input="$emit('updateContent', ($event.target as HTMLTextAreaElement).value)"
spellcheck="false"
wrap="off"
@@ -82,11 +87,11 @@ function onInput(event: Event) {
<style scoped>
.editor-panel {
border: 1px solid var(--line, #1e2030);
border: 1px solid var(--line);
border-top: none;
border-radius: 0 0 10px 10px;
overflow: hidden;
background: var(--panel, #13141f);
background: var(--glass);
}
.editor-header {
@@ -94,8 +99,8 @@ function onInput(event: Event) {
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: rgba(255,255,255,.02);
border-bottom: 1px solid var(--line, #1e2030);
background: color-mix(in srgb, var(--tx) 2%, transparent);
border-bottom: 1px solid var(--line);
gap: 12px;
}
.editor-health {
@@ -103,9 +108,9 @@ function onInput(event: Event) {
align-items: center;
gap: 8px;
padding: 8px 14px;
border-bottom: 1px solid var(--line, #1e2030);
background: rgba(255,255,255,.015);
color: #8e96a8;
border-bottom: 1px solid var(--line);
background: color-mix(in srgb, var(--tx) 1.5%, transparent);
color: var(--tx-3);
font-size: 10.5px;
flex-wrap: wrap;
}
@@ -118,17 +123,26 @@ function onInput(event: Event) {
.editor-filename {
font-size: 11.5px;
font-weight: 600;
color: #d0d4dd;
color: var(--tx);
white-space: nowrap;
}
.editor-file-meta {
font-size: 10px;
color: #6b7385;
color: var(--tx-3);
white-space: nowrap;
}
.editor-hash {
max-width: 180px;
overflow: hidden;
color: var(--tx-3);
font-family: var(--font-mono-v2);
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta-sep {
margin: 0 4px;
color: #3d4152;
color: var(--line-3);
}
.editor-actions {
display: flex;
@@ -145,10 +159,10 @@ function onInput(event: Event) {
white-space: nowrap;
}
.save-indicator.success {
color: #51d49a;
color: var(--st-work);
}
.save-indicator.error {
color: #e16e75;
color: var(--st-block);
max-width: 240px;
}
@@ -157,10 +171,10 @@ function onInput(event: Event) {
align-items: center;
gap: 6px;
padding: 6px 14px;
border: 1px solid var(--line, #1e2030);
border: 1px solid var(--line);
border-radius: 7px;
background: rgba(139,124,246,.08);
color: #8b7cf6;
background: color-mix(in srgb, var(--a-mid) 8%, transparent);
color: var(--a-mid);
font-size: 10.5px;
font-weight: 500;
cursor: pointer;
@@ -169,13 +183,13 @@ function onInput(event: Event) {
line-height: 1;
}
.save-btn:hover:not(:disabled) {
background: rgba(139,124,246,.14);
border-color: #443d7c;
background: color-mix(in srgb, var(--a-mid) 14%, transparent);
border-color: var(--line-3);
}
.save-btn.dirty {
background: rgba(139,124,246,.18);
border-color: #5c4ed6;
color: #a99cff;
background: color-mix(in srgb, var(--a-mid) 18%, transparent);
border-color: var(--a-mid);
color: var(--tx);
}
.save-btn.saving {
opacity: 0.7;
@@ -188,26 +202,31 @@ function onInput(event: Event) {
.health-pill {
display: inline-flex;
align-items: center;
border: 1px solid var(--line, #1e2030);
border: 1px solid var(--line);
border-radius: 999px;
padding: 2px 8px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.health-pill.created {
color: #51d49a;
border-color: rgba(81,212,154,.3);
color: var(--st-work);
border-color: color-mix(in srgb, var(--st-work) 30%, transparent);
}
.health-pill.not_applicable {
color: #d4b26a;
border-color: rgba(212,178,106,.25);
color: var(--st-queue);
border-color: color-mix(in srgb, var(--st-queue) 25%, transparent);
}
.health-pill.not_supported {
color: #9aa4bb;
border-color: rgba(154,164,187,.25);
color: var(--tx-2);
border-color: color-mix(in srgb, var(--tx-2) 25%, transparent);
}
.health-pill.verified {
color: var(--st-work);
border-color: color-mix(in srgb, var(--st-work) 30%, transparent);
background: var(--status-work-bg);
}
.health-note {
color: #7e8799;
color: var(--tx-3);
}
.config-editor {
@@ -215,9 +234,9 @@ function onInput(event: Event) {
min-height: 400px;
padding: 16px;
border: none;
background: #0d0e17;
color: #c8cbe0;
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
background: var(--field-surface);
color: var(--tx);
font-family: var(--font-mono-v2);
font-size: 12px;
line-height: 1.6;
resize: vertical;
@@ -226,7 +245,11 @@ function onInput(event: Event) {
box-sizing: border-box;
}
.config-editor:focus {
background: #0f101b;
background: var(--field-surface-focus);
}
.config-editor:read-only {
cursor: default;
color: var(--tx-2);
}
.spin {
+10 -8
View File
@@ -18,8 +18,10 @@ function tabLabel(tab: string): string {
<button
v-for="(tab, idx) in tabs"
:key="tab"
type="button"
class="config-tab"
:class="{ active: activeTab === idx }"
:aria-current="activeTab === idx ? 'page' : undefined"
@click="$emit('switchTab', idx)"
>
{{ tabLabel(tab) }}
@@ -31,18 +33,18 @@ function tabLabel(tab: string): string {
.config-tabs {
display: flex;
gap: 1px;
background: var(--line, #1e2030);
background: var(--line);
border-radius: 10px 10px 0 0;
overflow: hidden;
border: 1px solid var(--line, #1e2030);
border: 1px solid var(--line);
border-bottom: none;
}
.config-tab {
flex: 1;
padding: 10px 12px;
background: var(--panel, #13141f);
background: var(--glass);
border: none;
color: #6b7385;
color: var(--tx-3);
font-size: 10.5px;
font-weight: 500;
cursor: pointer;
@@ -52,12 +54,12 @@ function tabLabel(tab: string): string {
font-family: inherit;
}
.config-tab:hover {
background: rgba(139,124,246,.06);
color: #a0a8b8;
background: color-mix(in srgb, var(--a-mid) 6%, transparent);
color: var(--tx-2);
}
.config-tab.active {
background: rgba(139,124,246,.1);
color: #c8cbe0;
background: color-mix(in srgb, var(--a-mid) 10%, transparent);
color: var(--tx);
font-weight: 600;
}
@@ -0,0 +1,320 @@
<script setup lang="ts">
import { Bot, CheckCircle2, RotateCcw, ShieldCheck, Sparkles } from '@lucide/vue'
import { computed, ref, watch } from 'vue'
const props = defineProps<{
content: string
readOnly?: boolean
}>()
const emit = defineEmits<{
apply: [content: string]
}>()
const startMarker = '<!-- NEXUS:STANDING_ORDERS:START -->'
const endMarker = '<!-- NEXUS:STANDING_ORDERS:END -->'
const goals = ref('')
const triggers = ref('')
const allowedActions = ref('')
const approvalBoundaries = ref('')
const escalation = ref('')
const verification = ref('')
const initializedFromContent = ref('')
const hasControlledSection = computed(() =>
props.content.includes(startMarker) && props.content.includes(endMarker),
)
function bulletLines(value: string) {
return value
.split('\n')
.map(line => line.trim().replace(/^[-*]\s*/, ''))
.filter(Boolean)
}
function renderList(value: string) {
const lines = bulletLines(value)
return lines.length ? lines.map(line => `- ${line}`).join('\n') : '- Noch festzulegen'
}
function extractSection(source: string, title: string) {
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const match = source.match(new RegExp(`### ${escaped}\\s*\\n([\\s\\S]*?)(?=\\n### |$)`))
if (!match) return ''
return match[1]
.split('\n')
.map(line => line.trim())
.filter(line => /^[-*]\s+/.test(line))
.map(line => line.replace(/^[-*]\s+/, ''))
.join('\n')
}
function hydrate() {
if (initializedFromContent.value === props.content) return
initializedFromContent.value = props.content
const start = props.content.indexOf(startMarker)
const end = props.content.indexOf(endMarker)
if (start < 0 || end <= start) {
goals.value = ''
triggers.value = ''
allowedActions.value = ''
approvalBoundaries.value = ''
escalation.value = ''
verification.value = ''
return
}
const block = props.content.slice(start + startMarker.length, end)
goals.value = extractSection(block, 'Ziele')
triggers.value = extractSection(block, 'Trigger')
allowedActions.value = extractSection(block, 'Erlaubte Aktionen')
approvalBoundaries.value = extractSection(block, 'Approval-Grenzen')
escalation.value = extractSection(block, 'Eskalation')
verification.value = extractSection(block, 'Verify und Report')
}
function buildBlock() {
return [
startMarker,
'## Nexus Standing Orders',
'',
'> Von Nexus verwalteter, überprüfbarer Agent-First-Abschnitt. Änderungen gelten erst nach dem Speichern und bestätigten OpenClaw-Read-back.',
'',
'### Ziele',
renderList(goals.value),
'',
'### Trigger',
renderList(triggers.value),
'',
'### Erlaubte Aktionen',
renderList(allowedActions.value),
'',
'### Approval-Grenzen',
renderList(approvalBoundaries.value),
'',
'### Eskalation',
renderList(escalation.value),
'',
'### Verify und Report',
renderList(verification.value),
endMarker,
].join('\n')
}
function apply() {
const block = buildBlock()
const start = props.content.indexOf(startMarker)
const end = props.content.indexOf(endMarker)
const next = start >= 0 && end > start
? `${props.content.slice(0, start)}${block}${props.content.slice(end + endMarker.length)}`
: `${props.content.trimEnd()}\n\n${block}\n`
emit('apply', next)
}
function reset() {
initializedFromContent.value = ''
hydrate()
}
watch(() => props.content, hydrate, { immediate: true })
</script>
<template>
<section class="standing-orders" aria-labelledby="standing-orders-title">
<header>
<div>
<span class="standing-orders__eyebrow">AGENT-FIRST CONTRACT</span>
<h3 id="standing-orders-title">
<Bot :size="17" aria-hidden="true" />
Standing Orders
</h3>
<p>
Nexus pflegt nur diesen markierten Abschnitt in <code>AGENTS.md</code>.
Der übrige Agent-Inhalt bleibt unangetastet.
</p>
</div>
<span class="standing-orders__state" :class="{ ready: hasControlledSection }">
<CheckCircle2 v-if="hasControlledSection" :size="13" aria-hidden="true" />
<Sparkles v-else :size="13" aria-hidden="true" />
{{ hasControlledSection ? 'Vorhanden' : 'Neu' }}
</span>
</header>
<div class="standing-orders__grid">
<label>
<span>Ziele</span>
<textarea v-model="goals" rows="4" :disabled="readOnly" placeholder="Ein Ziel pro Zeile" />
</label>
<label>
<span>Trigger</span>
<textarea v-model="triggers" rows="4" :disabled="readOnly" placeholder="Wann der Agent selbstständig beginnt" />
</label>
<label>
<span>Erlaubte Aktionen</span>
<textarea v-model="allowedActions" rows="4" :disabled="readOnly" placeholder="Aktionen innerhalb des delegierten Scopes" />
</label>
<label>
<span>Approval-Grenzen</span>
<textarea v-model="approvalBoundaries" rows="4" :disabled="readOnly" placeholder="Was immer explizite Freigabe benötigt" />
</label>
<label>
<span>Eskalation</span>
<textarea v-model="escalation" rows="4" :disabled="readOnly" placeholder="Wann und an wen eskaliert wird" />
</label>
<label>
<span>Verify und Report</span>
<textarea v-model="verification" rows="4" :disabled="readOnly" placeholder="Pflichtprüfungen und Ergebnisformat" />
</label>
</div>
<footer>
<span>
<ShieldCheck :size="14" aria-hidden="true" />
Die Vorschau wird in den normalen, hash-geschützten Datei-Editor übernommen.
</span>
<div>
<button type="button" class="nexus-button" :disabled="readOnly" @click="reset">
<RotateCcw :size="13" aria-hidden="true" />
Zurücksetzen
</button>
<button type="button" class="nexus-button nexus-button--primary" :disabled="readOnly" @click="apply">
<Sparkles :size="13" aria-hidden="true" />
In AGENTS.md übernehmen
</button>
</div>
</footer>
</section>
</template>
<style scoped>
.standing-orders {
display: grid;
gap: 13px;
margin-top: 12px;
padding: 15px;
border: 1px solid var(--line-2);
border-radius: var(--r);
background: linear-gradient(145deg, var(--glass), var(--accent-wash));
}
.standing-orders header,
.standing-orders footer,
.standing-orders footer > div {
display: flex;
gap: 12px;
align-items: flex-start;
justify-content: space-between;
}
.standing-orders__eyebrow {
display: block;
margin-bottom: 4px;
color: var(--a-purple);
font: 10px var(--font-mono-v2);
letter-spacing: .12em;
}
.standing-orders h3 {
display: flex;
gap: 7px;
align-items: center;
margin: 0;
color: var(--tx);
font: 700 15px var(--font-display);
}
.standing-orders header p {
max-width: 630px;
margin: 5px 0 0;
color: var(--tx-2);
font-size: 11px;
line-height: 1.5;
}
.standing-orders code {
font-family: var(--font-mono-v2);
}
.standing-orders__state {
display: flex;
gap: 5px;
align-items: center;
flex: 0 0 auto;
padding: 5px 8px;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--tx-3);
font: 10px var(--font-mono-v2);
}
.standing-orders__state.ready {
border-color: var(--status-work-line);
background: var(--status-work-bg);
color: var(--st-work);
}
.standing-orders__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 9px;
}
.standing-orders label {
display: grid;
gap: 5px;
min-width: 0;
}
.standing-orders label > span {
color: var(--tx-2);
font-size: 11px;
font-weight: 700;
}
.standing-orders textarea {
width: 100%;
min-width: 0;
resize: vertical;
padding: 9px 10px;
border: 1px solid var(--line);
border-radius: var(--r-sm);
outline: 0;
background: var(--field-surface);
color: var(--tx);
font: 11px/1.5 var(--font-body);
}
.standing-orders textarea:focus-visible {
border-color: var(--a-blue);
box-shadow: var(--focus-ring);
}
.standing-orders footer {
align-items: center;
}
.standing-orders footer > span {
display: flex;
gap: 7px;
align-items: center;
color: var(--tx-3);
font: 10px var(--font-mono-v2);
}
@media (max-width: 768px) {
.standing-orders__grid {
grid-template-columns: 1fr;
}
.standing-orders header,
.standing-orders footer {
align-items: stretch;
flex-direction: column;
}
.standing-orders footer > div {
justify-content: flex-start;
flex-wrap: wrap;
}
}
</style>
@@ -2,6 +2,7 @@
import { ref, onMounted, onUnmounted, watch } from 'vue'
import type { AgentDetailData } from './types'
import { icons } from '../../../composables/icons'
import { X } from '@lucide/vue'
const props = defineProps<{
agent: AgentDetailData
@@ -14,37 +15,11 @@ const emit = defineEmits<{
changeModel: [agentId: string, modelId: string]
}>()
/* ── Progress animation ────────────────────────── */
const displayProgress = ref(0)
function animateProgress() {
displayProgress.value = 0
setTimeout(() => { displayProgress.value = props.agent.progress }, 60)
}
/* ── Typewriter ────────────────────────────────── */
const thinkDisplay = ref('')
let thinkTimer: ReturnType<typeof setInterval> | null = null
function startTypewriter() {
if (thinkTimer) clearInterval(thinkTimer)
thinkDisplay.value = ''
if (!props.agent.think) return
const text = props.agent.think
let i = 0
thinkTimer = setInterval(() => {
thinkDisplay.value = text.slice(0, i)
i = i >= text.length ? 0 : i + 1
}, 38)
}
/* ── Selected model ────────────────────────────── */
const selectedModel = ref(props.agent.model)
watch(() => props.agent.id, () => {
watch(() => [props.agent.id, props.agent.model], () => {
selectedModel.value = props.agent.model
animateProgress()
startTypewriter()
})
function selectModel(alias: string) {
@@ -72,13 +47,10 @@ function onBackdrop(e: MouseEvent) {
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
animateProgress()
startTypewriter()
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
if (thinkTimer) clearInterval(thinkTimer)
})
const statusColors: Record<string, string> = {
@@ -97,10 +69,12 @@ function avatarLabel() {
<template>
<div class="modal-ov" @click="onBackdrop">
<div class="modal-card">
<div class="modal-card" role="dialog" aria-modal="true" :aria-labelledby="`agent-modal-${agent.id}`">
<!-- Close -->
<button class="m-close" @click="emit('close')">×</button>
<button type="button" class="m-close" aria-label="Close agent details" @click="emit('close')">
<X :size="17" aria-hidden="true" />
</button>
<!-- Header -->
<div class="m-head">
@@ -108,7 +82,7 @@ function avatarLabel() {
{{ avatarLabel() }}
</div>
<div style="flex:1; min-width:0">
<div class="m-name">{{ agent.name }}</div>
<div :id="`agent-modal-${agent.id}`" class="m-name">{{ agent.name }}</div>
<div class="m-sub">
<span :class="['badge', agent.roleBadge]">{{ agent.role }}</span>
<span class="m-pill">{{ selectedModel }}</span>
@@ -124,16 +98,21 @@ function avatarLabel() {
<div v-if="agent.task" class="m-sec">
<h4>Aktuelle Aufgabe</h4>
<div class="m-task">{{ agent.task }}</div>
<div class="m-goal">
<div v-if="agent.goal" class="m-goal">
<span v-html="icons.target || ''"></span>
Ziel: {{ agent.goal }}
</div>
<div class="m-bar" :class="{ work: agent.status === 'work' }">
<i :style="{ width: displayProgress + '%' }"></i>
</div>
<div class="m-pct-row">
<div class="m-pct grad-tx">{{ displayProgress }}%</div>
<div class="m-next"> {{ agent.next }}</div>
<template v-if="agent.progress !== null">
<div class="m-bar" :class="{ work: agent.status === 'work' }">
<i :style="{ width: Math.min(100, Math.max(0, agent.progress)) + '%' }"></i>
</div>
<div class="m-pct-row">
<div class="m-pct grad-tx">{{ agent.progress }}%</div>
<div v-if="agent.next" class="m-next"> {{ agent.next }}</div>
</div>
</template>
<div v-else class="m-telemetry-note">
OpenClaw meldet für diesen Run keinen numerischen Fortschritt.
</div>
</div>
@@ -143,27 +122,26 @@ function avatarLabel() {
<div class="m-metrics">
<div class="m-metric">
<div class="mk">Elapsed</div>
<div class="mv">{{ agent.elapsed }}</div>
<div class="mv">{{ agent.elapsed || 'Nicht gemeldet' }}</div>
</div>
<div class="m-metric">
<div class="mk">Token</div>
<div class="mv">{{ agent.tokens }}</div>
<div class="mv">{{ agent.tokens || 'Nicht gemeldet' }}</div>
</div>
<div class="m-metric">
<div class="mk">Kosten</div>
<div class="mv grad-tx">${{ agent.cost }}</div>
<div class="mv" :class="{ 'grad-tx': agent.cost }">{{ agent.cost ? `$${agent.cost}` : 'Nicht gemeldet' }}</div>
</div>
<div class="m-metric">
<div class="mk">Fortschritt</div>
<div class="mv">{{ agent.progress }}%</div>
<div class="mv">{{ agent.progress === null ? 'Nicht gemeldet' : `${agent.progress}%` }}</div>
</div>
</div>
</div>
<!-- Live Thinking -->
<div v-if="agent.think" class="m-sec">
<h4>Live Thinking</h4>
<div class="m-think">{{ thinkDisplay }}<span class="caret"></span></div>
<div v-if="agent.statusDetail" class="m-sec">
<h4>Gemeldeter Status</h4>
<div class="m-think">{{ agent.statusDetail }}</div>
</div>
<div v-if="agent.activity.length" class="m-sec">
@@ -183,6 +161,7 @@ function avatarLabel() {
<button
v-for="m in agent.availableModels"
:key="m.id"
type="button"
:class="['m-model-btn', { active: m.alias === selectedModel }]"
@click="selectModel(m.alias)"
>{{ m.alias }}</button>
@@ -452,6 +431,17 @@ function avatarLabel() {
margin-top: 8px;
}
.m-telemetry-note {
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--accent-wash);
color: var(--tx-3);
font-family: var(--font-mono-v2);
font-size: 10.5px;
line-height: 1.5;
}
.m-pct {
font-family: 'JetBrains Mono', monospace;
font-size: 22px;
@@ -517,7 +507,7 @@ function avatarLabel() {
}
.m-think::before {
content: '▶ thinking';
content: '▶ gateway state';
position: absolute;
top: 10px;
right: 14px;
@@ -527,14 +517,6 @@ function avatarLabel() {
opacity: .7;
}
.caret::after {
content: '▍';
animation: blink 1s steps(1) infinite;
color: var(--st-think);
}
@keyframes blink { 50% { opacity: 0; } }
.m-activity {
display: flex;
flex-direction: column;
@@ -56,8 +56,8 @@ defineEmits<{
<div class="nc-task">{{ agent.task || 'Bereit · ' + agent.next }}</div>
<!-- Progress Bar -->
<div class="nc-bar">
<i :style="{ width: (agent.progress || 3) + '%' }"></i>
<div v-if="agent.progress !== null" class="nc-bar" aria-hidden="true">
<i :style="{ width: Math.min(100, Math.max(0, agent.progress)) + '%' }"></i>
</div>
<!-- Meta-Zeile -->
@@ -68,7 +68,11 @@ defineEmits<{
>
{{ agent.statusLabel }}
</span>
<span>{{ agent.task ? (agent.progress + '% · ' + agent.elapsed) : agent.model }}</span>
<span v-if="agent.task && agent.progress !== null">
{{ agent.progress }}%<template v-if="agent.elapsed"> · {{ agent.elapsed }}</template>
</span>
<span v-else-if="agent.task">Fortschritt nicht gemeldet</span>
<span v-else>{{ agent.model }}</span>
</div>
</div>
</div>
@@ -273,4 +277,63 @@ defineEmits<{
font-weight: 600;
}
@media (max-width: 680px) {
.node {
width: 118px;
}
.ncard {
padding: 8px;
border-radius: 10px;
}
.nc-top {
gap: 6px;
}
.nc-av {
width: 25px;
height: 25px;
border-radius: 7px;
font-size: 9px;
}
.nc-name {
font-size: 10.5px;
}
.nc-role {
display: none;
}
.dot {
width: 7px;
height: 7px;
}
.nc-task {
min-height: 0;
margin-top: 6px;
font-size: 9px;
line-height: 1.25;
white-space: nowrap;
text-overflow: ellipsis;
display: block;
}
.nc-bar {
height: 3px;
margin-top: 5px;
}
.nc-meta {
margin-top: 4px;
font-size: 8px;
}
.nc-meta > span:last-child {
display: none;
}
}
</style>
+151 -87
View File
@@ -1,24 +1,13 @@
<script setup lang="ts">
/**
* AlertBar — Status-Übersicht im V2 Dashboard
*
* Props:
* activeCount Agents mit status 'work'
* thinkCount Agents mit status 'think'
* idleCount Agents mit status 'idle'
* blockerCount Blocker-Anzahl
* todayCost Kosten heute (z.B. "$6.40")
* todayTokens Token heute (z.B. "282k")
*/
import { icons } from '../../../composables/icons'
import { CircleDollarSign } from '@lucide/vue'
defineProps<{
activeCount: number
thinkCount: number
idleCount: number
blockerCount: number
todayCost: string
todayTokens: string
todayCost: string | null
todayTokens: string | null
blockerLabel?: string
}>()
@@ -28,97 +17,113 @@ defineEmits<{
</script>
<template>
<div class="alertbar glass-panel">
<!-- Active (arbeitet) -->
<div class="seg">
<span class="dot work"></span>
<span class="seg-label">{{ activeCount }} arbeiten</span>
<section class="alertbar glass-panel" aria-label="Operationsstatus">
<div class="status-cluster">
<span class="seg" :aria-label="`${activeCount} Agents arbeiten`">
<span class="dot work" aria-hidden="true"></span>
<strong>{{ activeCount }}</strong>
<span class="seg-label">aktiv</span>
</span>
<span class="seg" :aria-label="`${thinkCount} Agents planen`">
<span class="dot think" aria-hidden="true"></span>
<strong>{{ thinkCount }}</strong>
<span class="seg-label">planen</span>
</span>
<span class="seg idle-seg" :aria-label="`${idleCount} Agents sind bereit`">
<span class="dot idle" aria-hidden="true"></span>
<strong>{{ idleCount }}</strong>
<span class="seg-label">bereit</span>
</span>
</div>
<!-- Think (plant) -->
<div class="seg">
<span class="dot think"></span>
<span class="seg-label">{{ thinkCount }} planen</span>
<div
v-if="todayCost || todayTokens"
class="cost-seg"
:aria-label="`Gemeldete Gateway-Telemetrie: ${todayCost || 'keine Kosten'}, ${todayTokens || 'keine'} Token`"
>
<CircleDollarSign class="seg-icon" :size="13" aria-hidden="true" />
<span class="today-label">Gemeldet</span>
<strong v-if="todayCost" class="cost-value">{{ todayCost }}</strong>
<span v-if="todayTokens" class="token-value"><template v-if="todayCost">· </template>{{ todayTokens }} Token</span>
</div>
<!-- Idle (bereit) -->
<div class="seg">
<span class="dot idle"></span>
<span class="seg-label">{{ idleCount }} bereit</span>
</div>
<!-- Separator -->
<div class="sep"></div>
<!-- Kosten heute -->
<div class="seg tx2">
<span class="seg-icon" v-html="icons.coin || ''"></span>
heute <span class="cost-value">{{ todayCost }}</span> · {{ todayTokens }}
</div>
<!-- Blocker Alert (rechts) -->
<button
v-if="blockerCount > 0"
class="blk"
type="button"
:aria-label="blockerLabel || `${blockerCount} Blocker`"
@click="$emit('blockerClick')"
>
<span class="dot block"></span>
{{ blockerLabel || `${blockerCount} Blocker` }}
<span class="dot block" aria-hidden="true"></span>
<span class="blocker-full">{{ blockerLabel || `${blockerCount} Blocker` }}</span>
<span class="blocker-short">{{ blockerCount }} Blocker</span>
</button>
</div>
</section>
</template>
<style scoped>
.alertbar {
min-height: 44px;
display: flex;
align-items: center;
gap: 16px;
padding: 6px 8px 6px 12px;
border-radius: var(--r);
flex: 0 0 auto;
overflow: hidden;
}
.status-cluster {
display: flex;
align-items: center;
gap: 14px;
padding: 11px 16px;
border-radius: var(--r);
flex-wrap: wrap;
min-width: 0;
}
.seg {
display: flex;
.seg,
.cost-seg {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12.5px;
font-weight: 600;
color: var(--tx);
gap: 6px;
white-space: nowrap;
}
.seg-label {
font-family: var(--font-body);
font-size: 11.5px;
color: var(--tx-2);
}
.seg-icon :deep(svg) {
width: 14px;
height: 14px;
flex: 0 0 auto;
color: var(--a-mid);
.seg strong {
color: var(--tx);
font-family: var(--font-mono-v2);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.tx2 .seg-icon :deep(svg) {
.seg-icon {
display: flex;
color: var(--tx-3);
}
.seg-icon :deep(svg) {
width: 13px;
height: 13px;
}
.dot {
width: 9px;
height: 9px;
width: 7px;
height: 7px;
border-radius: 50%;
flex: 0 0 auto;
}
.dot.work {
background: var(--st-work);
box-shadow: 0 0 0 0 rgba(61,220,151,.55);
animation: pulse-work 1.8s infinite;
}
.dot.think {
background: var(--st-think);
box-shadow: 0 0 0 0 rgba(52,214,245,.55);
animation: pulse-think 1.8s infinite;
}
@@ -128,19 +133,18 @@ defineEmits<{
.dot.block {
background: var(--st-block);
box-shadow: 0 0 0 0 rgba(251,113,133,.55);
animation: pulse-block 1.8s infinite;
}
.sep {
width: 1px;
height: 20px;
background: var(--line-2);
flex: 0 0 auto;
.cost-seg {
min-width: 0;
padding-left: 16px;
border-left: 1px solid var(--line-2);
}
.cost-value {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono-v2);
font-size: 11px;
font-variant-numeric: tabular-nums;
background: var(--grad);
-webkit-background-clip: text;
@@ -148,45 +152,105 @@ defineEmits<{
color: transparent;
}
.token-value {
color: var(--tx-3);
font-family: var(--font-mono-v2);
font-size: 10.5px;
}
.blk {
margin-left: auto;
max-width: 42%;
min-height: 32px;
display: inline-flex;
align-items: center;
gap: 9px;
padding: 6px 12px;
gap: 8px;
padding: 0 11px;
border-radius: 9px;
background: rgba(251,113,133,.12);
border: 1px solid rgba(251,113,133,.3);
font-size: 12.5px;
font-weight: 600;
background: var(--status-block-bg);
border: 1px solid var(--status-block-line);
color: #fda4b0;
cursor: pointer;
transition: background .15s;
font-family: 'Manrope', sans-serif;
transition: background .15s, border-color .15s;
font-family: var(--font-body);
font-size: 11.5px;
font-weight: 700;
white-space: nowrap;
}
.blk:hover {
background: rgba(251,113,133,.22);
background: rgba(251, 113, 133, .18);
border-color: rgba(251, 113, 133, .42);
}
@media (max-width: 767px) {
.blk:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.blocker-full {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.blocker-short {
display: none;
}
@media (max-width: 700px) {
.alertbar {
flex-wrap: wrap;
gap: 8px;
padding: 10px;
gap: 10px;
padding-left: 10px;
}
.seg {
flex: 0 0 calc(50% - 4px);
.status-cluster {
gap: 10px;
}
.sep {
.cost-seg {
margin-left: auto;
padding-left: 10px;
}
.today-label,
.token-value,
.blocker-full {
display: none;
}
.blocker-short {
display: inline;
}
.blk {
margin-left: 0;
max-width: none;
}
}
@media (max-width: 460px) {
.alertbar {
gap: 8px;
}
.status-cluster {
gap: 8px;
}
.idle-seg,
.cost-seg {
display: none;
}
.blk {
margin-left: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.dot {
animation: none;
}
}
</style>
@@ -8,13 +8,12 @@
*
* Emits:
* select Agent ausgewählt (id)
* add Neuen Agent hinzufügen
* updatePositions Positionsänderung
*/
import { computed, onMounted, onUnmounted, ref, nextTick, watch } from 'vue'
import { Network, RotateCcw } from '@lucide/vue'
import type { AgentNodeData } from '../../../composables/useFlowLayout'
import { autoLayout, buildEdges, curve } from '../../../composables/useFlowLayout'
import { icons } from '../../../composables/icons'
import AgentNode from './AgentNode.vue'
import { useFlowCanvasInteractions } from './useFlowCanvasInteractions'
@@ -26,7 +25,6 @@ const props = defineProps<{
const emit = defineEmits<{
select: [id: string]
add: []
resetLayout: []
updatePositions: [positions: Record<string, { x: number; y: number }>]
}>()
@@ -64,6 +62,7 @@ function isActive(status: string) {
function renderEdges() {
const flow = flowRef.value
if (!flow) return
const flowElement = flow
const fr = flow.getBoundingClientRect()
const svg = svgRef.value
@@ -75,7 +74,7 @@ function renderEdges() {
// Node centers in pixel coordinates
function center(id: string): { x: number; y: number } | null {
const el = flow.querySelector(`.node[data-id="${id}"]`) as HTMLElement | null
const el = flowElement.querySelector(`.node[data-id="${id}"]`) as HTMLElement | null
if (!el) return null
const nr = el.getBoundingClientRect()
return {
@@ -207,23 +206,24 @@ function handleReset() {
>
<!-- Header -->
<div class="flow-h">
<span class="header-icon" v-html="icons.flow || ''"></span>
<Network class="header-icon" :size="18" aria-hidden="true" />
<h3>Live-Orchestrierung</h3>
<span class="flow-count">{{ agentCount }} Agents</span>
<span class="flow-count" :aria-label="`${agentCount} Agents`">
<span>{{ agentCount }}</span>
<span class="count-label">Agents</span>
</span>
<button
class="reset-btn"
type="button"
title="Auto-Layout wiederherstellen"
aria-label="Auto-Layout wiederherstellen"
@click="handleReset"
>
<span class="btn-icon" v-html="icons.flow || ''"></span>
<RotateCcw class="btn-icon" :size="13" aria-hidden="true" />
<span class="reset-label">Reset</span>
</button>
<button class="add-btn" @click="emit('add')" title="Agent hinzufügen">
<span class="btn-icon" v-html="icons.plus || ''"></span>
<span class="add-label">Agent hinzufügen</span>
</button>
</div>
<!-- SVG Layer -->
@@ -282,13 +282,17 @@ function handleReset() {
color: var(--tx);
}
.header-icon :deep(svg) {
.header-icon {
width: 18px;
height: 18px;
color: var(--a-mid);
flex: 0 0 auto;
}
.flow-count {
display: inline-flex;
align-items: center;
gap: 4px;
font-family: 'JetBrains Mono', monospace;
font-size: 11.5px;
font-weight: 600;
@@ -300,6 +304,7 @@ function handleReset() {
}
.reset-btn {
margin-left: auto;
height: 30px;
padding: 0 11px;
border-radius: 9px;
@@ -321,39 +326,11 @@ function handleReset() {
color: var(--tx);
}
.reset-btn .btn-icon :deep(svg) {
.reset-btn .btn-icon {
width: 13px;
height: 13px;
}
.add-btn {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
height: 34px;
padding: 0 14px;
border-radius: 10px;
background: var(--grad);
border: none;
color: #fff;
font-family: 'Manrope', sans-serif;
font-size: 13px;
font-weight: 600;
cursor: pointer;
box-shadow: var(--glow-purple);
transition: filter 0.15s;
}
.add-btn:hover {
filter: brightness(1.1);
}
.add-btn .btn-icon :deep(svg) {
width: 15px;
height: 15px;
}
/* ── SVG Layer ────────────────────────────────── */
.edges {
position: absolute;
@@ -412,7 +389,27 @@ function handleReset() {
}
@media (max-width: 767px) {
.add-label {
.flow-h {
gap: 6px;
padding: 10px;
}
.flow-h h3 {
font-size: 12.5px;
white-space: nowrap;
}
.header-icon {
width: 16px;
height: 16px;
}
.flow-count {
padding: 3px 7px;
font-size: 10px;
}
.count-label {
display: none;
}
@@ -420,13 +417,6 @@ function handleReset() {
display: none;
}
.add-btn {
width: 34px;
padding: 0;
display: grid;
place-items: center;
}
.reset-btn {
width: 30px;
padding: 0;
+339 -121
View File
@@ -1,19 +1,26 @@
<script setup lang="ts">
import { ref, nextTick, watch } from 'vue'
import { icons } from '../../../composables/icons'
import { nextTick, onMounted, ref, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { Bot, ChevronRight, FileText, Link2, Send, Workflow, X } from '@lucide/vue'
import type { ChatMessage } from './types'
import type { MissionControlContext } from '../../../types/mission-control'
import OperationResultCard from '../../mission-control/OperationResultCard.vue'
const props = defineProps<{
messages: ChatMessage[]
isThinking: boolean
error?: string | null
context: MissionControlContext
}>()
const emit = defineEmits<{
send: [text: string]
close: []
}>()
const inputText = ref('')
const dialogEl = ref<HTMLDialogElement | null>(null)
const inputEl = ref<HTMLInputElement | null>(null)
const scrollEl = ref<HTMLElement | null>(null)
function handleSend() {
@@ -23,96 +30,198 @@ function handleSend() {
inputText.value = ''
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
handleSend()
}
}
function closeDialog() {
dialogEl.value?.close()
}
function onBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget) closeDialog()
}
watch(
() => props.messages.length,
() => {
nextTick(() => {
if (scrollEl.value) scrollEl.value.scrollTop = scrollEl.value.scrollHeight
})
}
},
)
onMounted(async () => {
if (dialogEl.value && !dialogEl.value.open) dialogEl.value.showModal()
await nextTick()
inputEl.value?.focus()
})
</script>
<template>
<section class="iris-panel">
<!-- Header -->
<div class="iris-head">
<div class="iris-av" v-html="icons.bot || ''"></div>
<div>
<div class="iris-name">Iris</div>
<div class="iris-sub">Chief of Staff · <span class="online">online</span></div>
</div>
<button class="expand-btn" type="button" v-html="icons.expand || ''"></button>
</div>
<dialog
id="iris-chat-dialog"
ref="dialogEl"
class="iris-dialog"
aria-labelledby="iris-chat-title"
aria-describedby="iris-chat-description"
@click="onBackdropClick"
@cancel.prevent="closeDialog"
@keydown.esc.prevent.stop="closeDialog"
@close="emit('close')"
>
<section class="iris-panel">
<header class="iris-head">
<div class="iris-av" aria-hidden="true"><Bot :size="19" /></div>
<div class="iris-identity">
<h2 id="iris-chat-title" class="iris-name">Iris Chat</h2>
<p id="iris-chat-description" class="iris-sub">
Chief of Staff · <span class="online">online</span>
</p>
</div>
<button
class="close-btn"
type="button"
aria-label="Iris Chat schließen"
@click="closeDialog"
>
<X :size="18" aria-hidden="true" />
</button>
</header>
<!-- Chat Scroll -->
<div ref="scrollEl" class="chat-scroll">
<div v-if="error" class="chat-msg-info error"> {{ error }}</div>
<div v-else-if="!messages.length && !isThinking" class="chat-msg-info">Noch keine Nachrichten.</div>
<div ref="scrollEl" class="chat-scroll" aria-live="polite">
<div v-if="error" class="chat-msg-info error">{{ error }}</div>
<div v-else-if="!messages.length && !isThinking" class="chat-msg-info">
Noch keine Nachrichten.
</div>
<div v-for="(msg, i) in messages" :key="i" class="chat-row">
<template v-if="msg.sender === 'iris'">
<div class="bubble iris">{{ msg.text }}</div>
<div v-if="msg.tool" class="tool">
<span v-html="icons.doc || ''"></span>{{ msg.tool }}
<div v-for="(msg, index) in messages" :key="index" class="chat-row">
<template v-if="msg.sender === 'iris'">
<div class="bubble iris">{{ msg.text }}</div>
<div v-if="msg.tool" class="tool">
<FileText :size="12" aria-hidden="true" />{{ msg.tool }}
</div>
<RouterLink
v-if="msg.runId && !msg.operation"
class="result-card"
:to="{ name: 'RunDetail', params: { id: msg.runId } }"
@click="closeDialog"
>
<Workflow :size="15" aria-hidden="true" />
<span>
<strong>OpenClaw-Run öffnen</strong>
<small>{{ msg.runState || 'Status wird synchronisiert' }}</small>
</span>
<ChevronRight :size="14" aria-hidden="true" />
</RouterLink>
<OperationResultCard
v-if="msg.operation"
:result="msg.operation"
:title="msg.operation.primaryRef?.type === 'agent-proposal'
? 'Agent-Vorschlag'
: 'OpenClaw-Run'"
@click="closeDialog"
/>
</template>
<div v-else class="bubble me">{{ msg.text }}</div>
</div>
<div v-if="isThinking" class="chat-row">
<div class="bubble iris" aria-label="Iris schreibt">
<span class="caret" aria-hidden="true"></span>
</div>
</template>
<div v-else class="bubble me">{{ msg.text }}</div>
</div>
</div>
<div v-if="isThinking" class="chat-row">
<div class="bubble iris"><span class="caret"></span></div>
</div>
</div>
<!-- Input -->
<div class="chat-in">
<input
v-model="inputText"
type="text"
placeholder="Nachricht an Iris…"
@keydown="onKeydown"
/>
<button class="send" type="button" @click="handleSend" v-html="icons.send || ''"></button>
</div>
</section>
<form class="chat-in" @submit.prevent="handleSend">
<div class="context-chip" :title="context.path">
<Link2 :size="12" aria-hidden="true" />
<span>{{ context.surface }}</span>
<code v-if="context.entityId">{{ context.entityId }}</code>
</div>
<input
ref="inputEl"
v-model="inputText"
type="text"
aria-label="Nachricht an Iris"
placeholder="Nachricht an Iris…"
@keydown="onKeydown"
/>
<button
class="send"
type="submit"
aria-label="Nachricht senden"
:disabled="!inputText.trim()"
>
<Send :size="17" aria-hidden="true" />
</button>
</form>
</section>
</dialog>
</template>
<style scoped>
.iris-dialog {
width: min(720px, calc(100vw - 32px));
height: min(720px, calc(100dvh - 48px));
max-width: none;
max-height: none;
margin: auto;
padding: 0;
overflow: visible;
border: 0;
background: transparent;
color: var(--tx);
}
.iris-dialog[open] {
animation: dialog-in .2s cubic-bezier(.2, .8, .3, 1);
}
.iris-dialog::backdrop {
background: rgba(5, 4, 16, .78);
backdrop-filter: blur(14px);
}
@keyframes dialog-in {
from { opacity: 0; transform: translateY(12px) scale(.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.iris-panel {
width: var(--rail-w, 360px);
flex: 0 0 var(--rail-w, 360px);
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
background: linear-gradient(180deg, rgba(20,17,48,.6), rgba(12,10,30,.6));
border: 1px solid var(--line);
border-radius: var(--r);
backdrop-filter: blur(12px);
overflow: hidden;
border: 1px solid var(--line-3);
border-radius: var(--r-lg);
background: linear-gradient(160deg, rgba(22, 18, 50, .98), rgba(10, 8, 28, .98));
box-shadow:
0 0 0 1px rgba(124, 108, 255, .12),
0 32px 80px -16px rgba(0, 0, 0, .82),
0 0 60px -12px rgba(124, 108, 255, .34);
backdrop-filter: blur(18px);
}
/* ── Header ─────────────────────────────────── */
.iris-head {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
gap: 12px;
min-height: 68px;
padding: 12px 14px 12px 16px;
border-bottom: 1px solid var(--line);
flex: 0 0 auto;
}
.iris-av {
width: 34px;
height: 34px;
border-radius: 10px;
width: 40px;
height: 40px;
border-radius: 11px;
background: var(--grad);
display: grid;
place-items: center;
@@ -121,74 +230,76 @@ watch(
}
.iris-av :deep(svg) {
width: 18px;
height: 18px;
color: #fff;
width: 19px;
height: 19px;
color: var(--tx-on-accent);
}
.iris-identity {
min-width: 0;
}
.iris-name {
font-family: 'Space Grotesk', sans-serif;
font-weight: 600;
font-size: 14.5px;
color: var(--tx);
margin: 0;
font-family: var(--font-display);
font-weight: 700;
font-size: 17px;
line-height: 1.2;
color: var(--tx);
}
.iris-sub {
margin: 2px 0 0;
font-size: 11px;
color: var(--tx-3);
margin-top: 1px;
}
.online {
color: var(--st-work);
}
.expand-btn {
.close-btn {
margin-left: auto;
width: 34px;
height: 34px;
border-radius: 9px;
border: none;
background: transparent;
width: 40px;
height: 40px;
border-radius: var(--r-sm);
border: 1px solid var(--line-2);
background: var(--accent-wash);
color: var(--tx-2);
cursor: pointer;
display: grid;
place-items: center;
transition: background .15s, color .15s;
transition: background .15s, border-color .15s, color .15s;
flex: 0 0 auto;
}
.expand-btn:hover {
background: rgba(124,108,255,.10);
.close-btn:hover {
border-color: var(--line-3);
background: var(--accent-wash-strong);
color: var(--tx);
}
.expand-btn :deep(svg) {
width: 16px;
height: 16px;
}
/* ── Messages ────────────────────────────────── */
.chat-scroll {
flex: 1;
overflow-y: auto;
padding: 16px;
padding: 20px;
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
scrollbar-width: thin;
scrollbar-color: var(--accent-scroll) transparent;
}
.chat-scroll::-webkit-scrollbar { width: 6px; }
.chat-scroll::-webkit-scrollbar { width: 7px; }
.chat-scroll::-webkit-scrollbar-thumb {
background: rgba(124,108,255,.22);
border-radius: 6px;
background: var(--accent-scroll);
border-radius: 7px;
}
.chat-scroll::-webkit-scrollbar-track { background: transparent; }
.chat-msg-info {
font-family: 'Manrope', sans-serif;
font-family: var(--font-body);
font-size: 12px;
color: var(--tx-3);
font-style: italic;
@@ -196,7 +307,10 @@ watch(
padding: 24px 0;
}
.chat-msg-info.error { color: #fda4b0; font-style: normal; }
.chat-msg-info.error {
color: #fda4b0;
font-style: normal;
}
.chat-row {
display: flex;
@@ -205,16 +319,16 @@ watch(
}
.bubble {
max-width: 84%;
max-width: 78%;
padding: 10px 13px;
border-radius: 14px;
font-family: 'Manrope', sans-serif;
font-family: var(--font-body);
font-size: 13px;
line-height: 1.5;
}
.bubble.iris {
background: rgba(124,108,255,.12);
background: rgba(124, 108, 255, .12);
border: 1px solid var(--line-2);
border-bottom-left-radius: 5px;
color: var(--tx);
@@ -222,7 +336,7 @@ watch(
.bubble.me {
background: var(--grad);
color: #fff;
color: var(--tx-on-accent);
border-bottom-right-radius: 5px;
margin-left: auto;
box-shadow: var(--glow-purple);
@@ -232,10 +346,10 @@ watch(
display: flex;
align-items: center;
gap: 6px;
font-family: 'JetBrains Mono', monospace;
padding-left: 4px;
font-family: var(--font-mono-v2);
font-size: 10px;
color: var(--st-think);
padding-left: 4px;
}
.tool :deep(svg) {
@@ -243,6 +357,45 @@ watch(
height: 12px;
}
.result-card {
width: min(78%, 420px);
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 9px;
padding: 10px 12px;
border: 1px solid var(--line-2);
border-radius: 11px;
background: var(--accent-wash);
color: var(--tx);
text-decoration: none;
}
.result-card:hover {
border-color: var(--line-3);
background: var(--accent-wash-strong);
}
.result-card strong,
.result-card small {
display: block;
}
.result-card strong {
font-size: 11.5px;
}
.result-card small {
margin-top: 2px;
color: var(--tx-3);
font: 9.5px var(--font-mono-v2);
}
.result-card:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.caret::after {
content: '▍';
animation: blink 1s steps(1) infinite;
@@ -251,53 +404,71 @@ watch(
@keyframes blink { 50% { opacity: 0; } }
@media (max-width: 767px) {
.iris-panel {
width: 100%;
flex: 0 0 auto;
max-height: 45vh;
}
.chat-scroll {
max-height: 30vh;
}
.expand-btn {
display: none;
}
}
/* ── Input ───────────────────────────────────── */
.chat-in {
padding: 12px;
padding: 14px;
border-top: 1px solid var(--line);
display: flex;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 9px;
align-items: center;
flex: 0 0 auto;
}
.context-chip {
max-width: 190px;
min-height: 32px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 9px;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 9px;
background: var(--accent-wash);
color: var(--tx-2);
font-family: var(--font-body);
font-size: 10.5px;
white-space: nowrap;
}
.context-chip span,
.context-chip code {
overflow: hidden;
text-overflow: ellipsis;
}
.context-chip code {
color: var(--tx-3);
font-family: var(--font-mono-v2);
font-size: 9.5px;
}
.chat-in input {
flex: 1;
height: 40px;
border-radius: 11px;
min-width: 0;
height: 44px;
border-radius: 12px;
border: 1px solid var(--line-2);
background: rgba(124,108,255,.06);
background: var(--field-surface);
color: var(--tx);
padding: 0 14px;
font-family: 'Manrope', sans-serif;
font-family: var(--font-body);
font-size: 13px;
outline: none;
transition: border-color .15s;
transition: border-color .15s, background .15s, box-shadow .15s;
}
.chat-in input::placeholder { color: var(--tx-3); }
.chat-in input:focus { border-color: var(--line-3); }
.chat-in input:focus {
border-color: var(--line-3);
background: var(--field-surface-focus);
box-shadow: var(--focus-ring);
}
.send {
width: 40px;
height: 40px;
border-radius: 11px;
width: 44px;
height: 44px;
border-radius: 12px;
border: none;
background: var(--grad);
display: grid;
@@ -305,14 +476,61 @@ watch(
cursor: pointer;
box-shadow: var(--glow-purple);
flex: 0 0 auto;
transition: filter .15s;
transition: filter .15s, opacity .15s;
}
.send:hover { filter: brightness(1.1); }
.send:hover:not(:disabled) { filter: brightness(1.1); }
.send:disabled {
cursor: not-allowed;
opacity: .42;
box-shadow: none;
}
.send :deep(svg) {
width: 17px;
height: 17px;
color: #fff;
color: var(--tx-on-accent);
}
.close-btn:focus-visible,
.send:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
@media (max-width: 767px) {
.iris-dialog {
width: calc(100vw - 16px);
height: calc(100dvh - 24px);
}
.iris-panel {
border-radius: var(--r);
}
.chat-scroll {
padding: 14px;
}
.bubble {
max-width: 88%;
}
.chat-in {
grid-template-columns: minmax(0, 1fr) auto;
}
.context-chip {
grid-column: 1 / -1;
max-width: 100%;
justify-self: start;
}
}
@media (prefers-reduced-motion: reduce) {
.iris-dialog[open],
.caret::after {
animation: none;
}
}
</style>
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { ListTodo } from '@lucide/vue'
import { RouterLink } from 'vue-router'
import type { TaskItem } from './types'
defineProps<{
@@ -7,159 +9,221 @@ defineProps<{
error?: string | null
}>()
function prioLabel(p: TaskItem['priority']): string {
return p === 'high' ? 'P0' : p === 'medium' ? 'P1' : 'P2'
function priorityLabel(priority: TaskItem['priority']): string {
return priority === 'high' ? 'P0' : priority === 'medium' ? 'P1' : 'P2'
}
function prioColor(p: TaskItem['priority']): string {
return p === 'high' ? '#fda4b0' : p === 'medium' ? '#fcd34d' : '#9db6ff'
function priorityColor(priority: TaskItem['priority']): string {
return priority === 'high' ? '#fda4b0' : priority === 'medium' ? '#fcd34d' : '#9db6ff'
}
function dotClass(s: TaskItem['status']): string {
return s === 'active' ? 'work' : s === 'blocked' ? 'block' : 'queue'
function dotClass(status: TaskItem['status']): string {
return status === 'active' ? 'work' : status === 'blocked' ? 'block' : 'queue'
}
function statusLabel(s: TaskItem['status']): string {
return s === 'active' ? 'Läuft' : s === 'blocked' ? 'Blocker' : 'Queue'
function statusLabel(status: TaskItem['status']): string {
return status === 'active' ? 'Läuft' : status === 'blocked' ? 'Blocker' : 'Queue'
}
function taskLabel(task: TaskItem): string {
return `${priorityLabel(task.priority)}, ${statusLabel(task.status)}: ${task.title}, ${task.agent}`
}
</script>
<template>
<div class="tstrip">
<template v-if="loading">
<div v-for="n in 4" :key="n" class="tcard skeleton"></div>
</template>
<section class="tstrip glass-panel" aria-label="Aktuelle Fokus-Tasks">
<div class="strip-label">
<ListTodo :size="14" aria-hidden="true" />
<span>Fokus</span>
</div>
<div v-else-if="error" class="tstrip-msg"> {{ error }}</div>
<div v-if="loading" class="task-list" aria-label="Tasks werden geladen">
<span v-for="index in 3" :key="index" class="tcard skeleton"></span>
</div>
<div v-else-if="error" class="tstrip-msg error">{{ error }}</div>
<div v-else-if="!tasks.length" class="tstrip-msg">Keine aktiven Tasks</div>
<template v-else>
<div
<div v-else class="task-list">
<RouterLink
v-for="task in tasks.slice(0, 4)"
:key="task.id"
:to="{ name: 'TaskDetail', params: { id: task.id } }"
class="tcard"
:class="{ block: task.status === 'blocked' }"
:aria-label="taskLabel(task)"
:title="taskLabel(task)"
>
<div class="tcard-row">
<span class="pr" :style="{ background: 'rgba(124,108,255,.14)', color: prioColor(task.priority) }">
{{ prioLabel(task.priority) }}
</span>
<span class="dot" :class="dotClass(task.status)"></span>
<span class="stl">{{ statusLabel(task.status) }}</span>
</div>
<div class="tt">{{ task.title }}</div>
<div class="ow">{{ task.agent }}</div>
</div>
</template>
</div>
<span
class="priority"
:style="{ color: priorityColor(task.priority) }"
>
{{ priorityLabel(task.priority) }}
</span>
<span class="dot" :class="dotClass(task.status)" aria-hidden="true"></span>
<span class="task-title">{{ task.title }}</span>
<span class="task-owner">{{ task.agent }}</span>
</RouterLink>
</div>
</section>
</template>
<style scoped>
.tstrip {
min-height: 44px;
display: flex;
align-items: center;
gap: 10px;
overflow: hidden;
padding: 5px 7px 5px 10px;
flex: 0 0 auto;
overflow: hidden;
}
.strip-label {
display: inline-flex;
align-items: center;
gap: 6px;
flex: 0 0 auto;
padding-right: 10px;
border-right: 1px solid var(--line-2);
color: var(--tx-3);
font-family: var(--font-body);
font-size: 10.5px;
font-weight: 700;
letter-spacing: .08em;
text-transform: uppercase;
}
.task-list {
min-width: 0;
flex: 1;
display: flex;
gap: 7px;
overflow: hidden;
}
.tcard {
flex: 1;
min-width: 0;
padding: 11px 13px;
border-radius: 12px;
background: var(--glass);
border: 1px solid var(--line);
flex: 1 1 0;
height: 32px;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 0 9px;
border-radius: 9px;
background: var(--accent-wash);
border: 1px solid var(--line);
color: var(--tx);
text-decoration: none;
}
.tcard:hover {
border-color: var(--line-3);
background: var(--accent-wash-strong);
}
.tcard.block {
border-color: rgba(251,113,133,.35);
background: rgba(251,113,133,.07);
border-color: var(--status-block-line);
background: var(--status-block-bg);
}
.tcard-row {
display: flex;
align-items: center;
gap: 7px;
}
.pr {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
font-weight: 600;
padding: 1px 6px;
border-radius: 5px;
.priority {
flex: 0 0 auto;
font-family: var(--font-mono-v2);
font-size: 9.5px;
font-weight: 700;
}
.dot {
width: 8px;
height: 8px;
width: 6px;
height: 6px;
border-radius: 50%;
flex: 0 0 auto;
}
.dot.work { background: var(--st-work); animation: pulse-work 1.8s infinite; }
.dot.work { background: var(--st-work); animation: pulse-work 1.8s infinite; }
.dot.queue { background: var(--st-queue); }
.dot.block { background: var(--st-block); animation: pulse-block 1.4s infinite; }
.dot.idle { background: var(--st-idle); }
.stl {
margin-left: auto;
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--tx-3);
}
.tt {
font-size: 12px;
font-weight: 600;
margin-top: 7px;
white-space: nowrap;
.task-title {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
color: var(--tx);
white-space: nowrap;
font-family: var(--font-body);
font-size: 11px;
font-weight: 650;
}
.ow {
font-size: 10.5px;
.task-owner {
max-width: 28%;
flex: 0 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--tx-3);
margin-top: 5px;
font-family: var(--font-mono-v2);
font-size: 9px;
}
.skeleton {
height: 78px;
background: var(--glass);
border-color: transparent;
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 0.5; }
50% { opacity: 0.8; }
0%, 100% { opacity: .42; }
50% { opacity: .78; }
}
.tstrip-msg {
font-family: 'Manrope', sans-serif;
font-size: 11px;
color: var(--tx-3);
padding: 12px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--tx-3);
font-family: var(--font-body);
font-size: 11px;
}
.tstrip-msg.error {
color: #fda4b0;
}
@media (max-width: 767px) {
.tstrip {
gap: 7px;
padding-left: 8px;
}
.strip-label span,
.task-owner {
display: none;
}
.strip-label {
padding-right: 7px;
}
.task-list {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.tstrip::-webkit-scrollbar {
.task-list::-webkit-scrollbar {
display: none;
}
.tcard {
flex: 0 0 200px;
flex: 0 0 min(210px, 72vw);
}
}
@media (prefers-reduced-motion: reduce) {
.dot,
.skeleton {
animation: none;
}
}
</style>
+14 -17
View File
@@ -1,12 +1,16 @@
/**
* Shared types for V2 Dashboard components
*/
import type { OperationResultDto } from '../../../api/contracts'
export interface ChatMessage {
sender: 'iris' | 'user'
text: string
ts: string
tool?: string
runId?: string | null
runState?: string | null
operation?: OperationResultDto | null
}
export interface TaskItem {
@@ -22,12 +26,6 @@ export interface TaskItem {
/* ── Agent Detail Modal Types ─────────────────── */
export interface ThinkingItem {
type: 'thought' | 'action' | 'result'
text: string
ts: string
}
export interface AgentActivityItem {
time: string
text: string
@@ -44,20 +42,19 @@ export interface AgentDetailData {
statusLabel: string
task: string | null
goal: string | null
progress: number
elapsed: string
next: string
tokens: string
cost: string
think: string | null
progress: number | null
elapsed: string | null
next: string | null
tokens: string | null
cost: string | null
statusDetail: string | null
md?: string
tokensToday: number
costToday: number
workload: number
uptime: string
tokensToday: number | null
costToday: number | null
workload: number | null
uptime: string | null
lastActive: string
activeTaskCount: number
thinking: ThinkingItem[]
activity: AgentActivityItem[]
availableModels: { id: string; alias: string }[]
}
+189 -42
View File
@@ -1,94 +1,241 @@
<script setup lang="ts">
import { Command, Search, CircleDot, Sparkles } from '@lucide/vue'
import { Menu, Search, CircleDot, Sparkles } from '@lucide/vue'
import { RouterLink } from 'vue-router'
import { nextTick, ref, watch } from 'vue'
defineProps<{
const props = withDefaults(defineProps<{
connected: boolean
}>()
commandOpen?: boolean
irisOpen?: boolean
canOpenIris?: boolean
}>(), {
commandOpen: false,
irisOpen: false,
canOpenIris: false,
})
defineEmits<{
toggleMobileNav: []
openCommand: []
openIris: []
}>()
const commandButton = ref<HTMLButtonElement | null>(null)
const irisButton = ref<HTMLButtonElement | null>(null)
watch(
() => props.commandOpen,
async (isOpen, wasOpen) => {
if (!isOpen && wasOpen) {
await nextTick()
if (!document.activeElement || document.activeElement === document.body) {
commandButton.value?.focus()
}
}
},
)
watch(
() => props.irisOpen,
async (isOpen, wasOpen) => {
if (!isOpen && wasOpen) {
await nextTick()
if (!document.activeElement || document.activeElement === document.body) {
irisButton.value?.focus()
}
}
},
)
</script>
<template>
<header class="topbar">
<button class="mobile-menu" @click="$emit('toggleMobileNav')">
<Command :size="19" />
<button
type="button"
class="mobile-menu"
aria-label="Toggle navigation"
@click="$emit('toggleMobileNav')"
>
<Menu :size="19" />
</button>
<div class="search">
<button
ref="commandButton"
type="button"
class="search"
aria-label="Mission Control durchsuchen"
aria-haspopup="dialog"
aria-controls="mission-command-dialog"
:aria-expanded="commandOpen"
@click="$emit('openCommand')"
>
<Search :size="16" />
<span>Search operations</span>
<kbd> K</kbd>
</div>
<kbd>Ctrl K</kbd>
</button>
<div class="spacer" aria-hidden="true"></div>
<div class="top-actions">
<span :class="['connection', connected ? 'live' : 'preview']">
<RouterLink
to="/runs"
:class="['connection', connected ? 'live' : 'preview']"
:aria-label="connected ? 'OpenClaw connected, open Run Control' : 'OpenClaw disconnected, open Run Control'"
>
<CircleDot :size="13" />
{{ connected ? 'Live' : 'Preview data' }}
</span>
<button class="ask"><Sparkles :size="15" /> Ask Iris</button>
{{ connected ? 'OpenClaw live' : 'OpenClaw offline' }}
</RouterLink>
<button
ref="irisButton"
class="ask"
type="button"
:disabled="!canOpenIris"
:aria-label="canOpenIris ? 'Open Iris chat' : 'Iris chat is available to owners only'"
:aria-haspopup="canOpenIris ? 'dialog' : undefined"
:aria-controls="canOpenIris ? 'iris-chat-dialog' : undefined"
:aria-expanded="canOpenIris ? irisOpen : false"
:title="canOpenIris ? undefined : 'Owner access required'"
@click="$emit('openIris')"
><Sparkles :size="15" /> <span class="ask-label">Ask Iris</span></button>
</div>
</header>
</template>
<style scoped>
.topbar {
height: var(--topbar-h);
flex: 0 0 var(--topbar-h);
display: flex;
align-items: center;
gap: 12px;
padding: 10px 20px;
border-bottom: 1px solid var(--nx-line, #1f2330);
background: var(--nx-panel, #11141b);
gap: 14px;
padding: 0 22px;
border-bottom: 1px solid var(--line);
background: color-mix(in srgb, var(--space-1) 50%, transparent);
backdrop-filter: blur(14px);
}
.mobile-menu { display: none; }
.search {
display: flex;
align-items: center;
gap: 8px;
gap: 10px;
flex: 1;
padding: 6px 12px;
border: 1px solid var(--nx-line, #1f2330);
border-radius: 7px;
color: var(--nx-text-dim, #6f7889);
font-size: 11px;
max-width: 560px;
height: 38px;
padding: 0 14px;
border: 1px solid var(--line);
border-radius: 11px;
background: color-mix(in srgb, var(--a-mid) 6%, transparent);
color: var(--tx-3);
font-size: 13.5px;
text-align: left;
cursor: pointer;
}
.search kbd {
margin-left: auto;
padding: 1px 4px;
border: 1px solid #2a2f3d;
border-radius: 4px;
font-size: 8px;
color: #4a5266;
padding: 2px 6px;
border: 1px solid var(--line-2);
border-radius: 6px;
background: color-mix(in srgb, var(--space-3) 64%, transparent);
color: var(--tx-3);
font-family: var(--font-mono-v2);
font-size: 11px;
}
.search:hover {
border-color: var(--line-2);
background: color-mix(in srgb, var(--a-mid) 10%, transparent);
}
.search:focus-visible,
.ask:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.top-actions {
display: flex;
align-items: center;
gap: 10px;
}
.spacer {
flex: 1;
}
.connection {
display: flex;
align-items: center;
gap: 5px;
font-size: 9px;
font-weight: 600;
padding: 4px 9px;
border-radius: 6px;
gap: 6px;
height: 28px;
padding: 0 11px;
border: 1px solid var(--line-2);
border-radius: 999px;
background: color-mix(in srgb, var(--a-mid) 7%, transparent);
color: var(--tx-2);
font-size: 11.5px;
font-weight: 650;
text-decoration: none;
}
.connection.live { color: #27ae60; background: rgba(39,174,96,.1); }
.connection.preview { color: #e67e22; background: rgba(230,126,34,.1); }
.connection.live { color: var(--st-work); }
.connection.preview { color: var(--st-queue); }
.ask {
display: flex;
align-items: center;
gap: 5px;
padding: 5px 10px;
justify-content: center;
gap: 8px;
min-height: 36px;
padding: 0 14px;
border: none;
border-radius: 6px;
background: var(--nx-accent, #7b6ef2);
color: #fff;
font-size: 10px;
border-radius: var(--r-sm);
background: var(--grad);
color: var(--tx-on-accent);
box-shadow: var(--glow-purple);
font-size: 13px;
font-weight: 650;
cursor: pointer;
transition: filter .16s ease;
}
.ask:hover:not(:disabled) {
filter: brightness(1.08);
}
.ask:disabled {
box-shadow: none;
}
@media (max-width: 860px) {
.mobile-menu { display: flex; align-items: center; justify-content: center; padding: 6px; border: 1px solid var(--nx-line, #1f2330); border-radius: 6px; background: transparent; color: var(--nx-accent, #7b6ef2); cursor: pointer; }
@media (max-width: 900px) {
.topbar {
padding: 0 14px;
}
.mobile-menu {
width: 34px;
height: 34px;
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid var(--line);
border-radius: 9px;
background: transparent;
color: var(--tx-2);
cursor: pointer;
}
.search {
max-width: none;
}
.search kbd,
.connection {
display: none;
}
.spacer {
display: none;
}
.ask {
width: 34px;
height: 34px;
flex: 0 0 auto;
padding: 0;
}
.ask :deep(svg) {
margin: 0;
}
.ask {
gap: 0;
}
.ask-label {
display: none;
}
}
</style>
+234 -101
View File
@@ -1,14 +1,14 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { computed } from 'vue'
import {
Activity, Bell, Bot, Boxes, Command, FileText,
LayoutDashboard, ListTodo, LogOut, MessageSquareText, Settings,
Shield, SlidersHorizontal, Sparkles, BookOpen,
AlertTriangle, Calendar,
LayoutDashboard, ListTodo, LogOut, Settings,
Shield, SlidersHorizontal, BookOpen,
AlertTriangle, Calendar, Workflow,
} from '@lucide/vue'
import { useRouter } from 'vue-router'
import { RouterLink, useRouter } from 'vue-router'
import { useAuthStore } from '../../stores/auth'
import { useNotificationStore } from '../../stores/notifications'
import { useNotificationSnapshot } from '../../api/notifications'
import { initials } from '../../utils/format'
const props = defineProps<{
@@ -24,31 +24,51 @@ const emit = defineEmits<{
const auth = useAuthStore()
const router = useRouter()
const notificationStore = useNotificationStore()
onMounted(() => {
notificationStore.startPolling()
})
const { query: notificationQuery } = useNotificationSnapshot({ limit: 1 })
const unreadNotifications = computed(() => notificationQuery.data.value?.unreadCount ?? 0)
const ownerInitials = computed(() =>
auth.user?.displayName ? initials(auth.user.displayName) : 'OW'
)
const navigation = [
{ label: 'Dashboard', icon: LayoutDashboard },
{ label: 'Memory', icon: FileText },
{ label: 'Docs', icon: BookOpen },
{ label: 'Security', icon: Shield },
{ label: 'Projects', icon: Boxes },
{ label: 'Task Board', icon: ListTodo },
{ label: 'Incidents', icon: AlertTriangle },
{ separator: true },
{ label: 'Notifications', icon: Bell },
{ label: 'Calendar', icon: Calendar },
{ label: 'Agents', icon: Bot },
{ label: 'Models', icon: SlidersHorizontal },
{ label: 'Activity', icon: Activity },
{ label: 'Mobile Chat', icon: MessageSquareText },
const navigationGroups = [
{
id: 'operations',
label: 'Operations',
items: [
{ label: 'Dashboard', route: '/dashboard', icon: LayoutDashboard },
{ label: 'Agents', route: '/agents', icon: Bot },
{ label: 'Projects', route: '/projects', icon: Boxes },
{ label: 'Task Board', route: '/tasks', icon: ListTodo },
{ label: 'Run Control', route: '/runs', icon: Workflow },
],
},
{
id: 'knowledge',
label: 'Knowledge',
items: [
{ label: 'Memory', route: '/memory', icon: FileText },
{ label: 'Docs', route: '/docs', icon: BookOpen },
],
},
{
id: 'infrastructure',
label: 'Infrastructure',
items: [
{ label: 'Models', route: '/models', icon: SlidersHorizontal },
{ label: 'Activity', route: '/activity', icon: Activity },
{ label: 'Calendar', route: '/calendar', icon: Calendar },
],
},
{
id: 'governance',
label: 'Governance',
items: [
{ label: 'Security', route: '/security', icon: Shield },
{ label: 'Incidents', route: '/incidents', icon: AlertTriangle },
{ label: 'Notifications', route: '/notifications', icon: Bell },
],
},
]
function onNavigate(label: string) {
@@ -62,7 +82,7 @@ async function logout() {
</script>
<template>
<aside :class="['sidebar', { open: mobileNavOpen }]">
<aside :class="['sidebar', { open: mobileNavOpen }]" aria-label="Nexus navigation">
<div class="brand">
<div class="brand-mark"><Command :size="18" /></div>
<div>
@@ -71,26 +91,42 @@ async function logout() {
</div>
</div>
<nav class="nav">
<template v-for="item in navigation" :key="item.label ?? 'sep'">
<div v-if="item.separator" class="nav-separator"></div>
<button
v-else
<nav class="nav" aria-label="Primary">
<div
v-for="group in navigationGroups"
:key="group.id"
class="nav-group"
role="group"
:aria-labelledby="`legacy-nav-${group.id}`"
>
<span :id="`legacy-nav-${group.id}`" class="nav-group-label">{{ group.label }}</span>
<RouterLink
v-for="item in group.items"
:key="item.label"
class="nav-link"
:to="item.route ?? '/dashboard'"
:class="{ active: activeView === item.label }"
:aria-current="activeView === item.label ? 'page' : undefined"
@click="onNavigate(item.label)"
>
<component :is="item.icon" :size="17" />
<span>{{ item.label }}</span>
<i v-if="item.label === 'Task Board'">{{ queuedTasks }}</i>
<i v-if="item.label === 'Incidents'">{{ incidents }}</i>
<i v-if="item.label === 'Notifications' && notificationStore.unreadCount > 0" class="badge-red">{{ notificationStore.unreadCount }}</i>
</button>
</template>
<i v-if="item.label === 'Notifications' && Number(unreadNotifications) > 0" class="badge-red">{{ unreadNotifications }}</i>
</RouterLink>
</div>
</nav>
<div class="sidebar-bottom">
<button :class="{ active: activeView === 'Settings' }" @click="onNavigate('Settings')"><Settings :size="17" /> Settings</button>
<button class="owner" type="button" title="Sign out" @click="logout">
<RouterLink
to="/settings"
class="sidebar-settings"
:class="{ active: activeView === 'Settings' }"
:aria-current="activeView === 'Settings' ? 'page' : undefined"
@click="onNavigate('Settings')"
><Settings :size="17" /> Settings</RouterLink>
<button class="owner" type="button" aria-label="Sign out" title="Sign out" @click="logout">
<div class="avatar">{{ ownerInitials }}</div>
<div><strong>{{ auth.user?.displayName ?? 'Owner' }}</strong><span>{{ auth.user?.role ?? 'owner' }}</span></div>
<LogOut :size="15" />
@@ -101,116 +137,213 @@ async function logout() {
<style scoped>
.sidebar {
width: 210px;
width: var(--sidebar-w);
flex: 0 0 var(--sidebar-w);
height: 100dvh;
display: flex;
flex-direction: column;
background: var(--panel, #11141b);
border-right: 1px solid var(--line, #1f2330);
position: relative;
z-index: 2;
background: linear-gradient(
180deg,
color-mix(in srgb, var(--space-2) 92%, transparent),
color-mix(in srgb, var(--space-0) 92%, transparent)
);
border-right: 1px solid var(--line);
backdrop-filter: blur(14px);
flex-shrink: 0;
padding: 0 8px;
padding: 0 12px;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
padding: 16px 10px 12px;
gap: 11px;
padding: 18px 6px 16px;
}
.brand-mark {
width: 30px;
height: 30px;
width: 38px;
height: 38px;
display: grid;
place-items: center;
border-radius: 7px;
background: var(--accent, #7b6ef2);
color: #fff;
border-radius: 11px;
background: var(--grad);
color: var(--tx-on-accent);
box-shadow: var(--glow-purple);
}
.brand div strong {
display: block;
font-family: var(--font-display);
font-size: 17px;
line-height: 1;
letter-spacing: .14em;
}
.brand div span {
display: block;
margin-top: 3px;
color: var(--tx-3);
font-size: 10.5px;
letter-spacing: .05em;
}
.brand div strong { display: block; font-size: 10px; letter-spacing: .08em; }
.brand div span { font-size: 8px; color: var(--text-dim, #6f7889); }
.nav {
flex: 1;
display: flex;
flex-direction: column;
gap: 1px;
padding: 4px 0;
gap: 0;
padding: 6px 0 12px;
overflow-y: auto;
}
.nav button {
.nav-group {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: 6px;
background: transparent;
color: #9ea5b3;
font-size: 10.5px;
text-align: left;
cursor: pointer;
transition: background .15s, color .15s;
flex-direction: column;
gap: 2px;
}
.nav button:hover { background: var(--accent-soft, rgba(123,110,242,.08)); color: #d8dbe3; }
.nav button.active { background: var(--accent-soft, rgba(123,110,242,.08)); color: var(--accent, #7b6ef2); font-weight: 600; }
.nav button i {
margin-left: auto;
background: var(--accent, #7b6ef2);
color: #fff;
font-style: normal;
font-size: 8px;
.nav-group-label {
display: block;
padding: 14px 10px 6px;
color: var(--tx-3);
font-family: var(--font-body);
font-size: 10px;
font-weight: 700;
padding: 1px 5px;
border-radius: 5px;
line-height: 1.4;
letter-spacing: .18em;
line-height: 1.2;
text-transform: uppercase;
}
.nav button i.badge-red {
background: #e16e75;
.nav-group:first-child .nav-group-label {
padding-top: 6px;
}
.nav-separator {
height: 1px;
margin: 6px 10px;
background: var(--nx-line, #1f2330);
.nav-link {
position: relative;
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-height: 38px;
padding: 8px 10px;
border: 1px solid transparent;
border-radius: 10px;
background: transparent;
color: var(--tx-2);
font-size: 12px;
text-align: left;
text-decoration: none;
cursor: pointer;
transition: background .15s, border-color .15s, color .15s;
}
.sidebar-bottom { padding: 8px 0; border-top: 1px solid var(--nx-line, #1f2330); }
.sidebar-bottom > button {
.nav-link:hover {
background: color-mix(in srgb, var(--a-mid) 8%, transparent);
color: var(--tx);
}
.nav-link.active {
border-color: var(--line-2);
background: var(--grad-soft);
color: var(--tx);
font-weight: 650;
}
.nav-link.active::before,
.sidebar-settings.active::before {
content: '';
position: absolute;
left: -13px;
width: 3px;
height: 22px;
border-radius: 0 999px 999px 0;
background: var(--grad);
box-shadow: var(--glow-purple);
}
.nav-link i {
margin-left: auto;
min-width: 22px;
background: color-mix(in srgb, var(--a-mid) 14%, transparent);
border: 1px solid var(--line);
color: var(--tx);
font-style: normal;
font-family: var(--font-mono-v2);
font-size: 11px;
font-weight: 700;
padding: 2px 6px;
border-radius: 999px;
line-height: 1.2;
text-align: center;
}
.nav-link i.badge-red {
border-color: var(--status-block-line);
background: var(--status-block-bg);
color: var(--st-block);
}
.sidebar-bottom {
padding: 10px 0 12px;
border-top: 1px solid var(--line);
}
.sidebar-settings,
.owner {
position: relative;
display: flex;
align-items: center;
gap: 9px;
width: 100%;
min-height: 38px;
padding: 8px 10px;
border: none;
border-radius: 6px;
border: 1px solid transparent;
border-radius: 10px;
background: transparent;
color: #9ea5b3;
font-size: 10.5px;
color: var(--tx-2);
font-size: 12px;
text-decoration: none;
cursor: pointer;
transition: background .15s, color .15s;
transition: background .15s, border-color .15s, color .15s;
}
.sidebar-settings:hover,
.owner:hover {
background: color-mix(in srgb, var(--a-mid) 8%, transparent);
color: var(--tx);
}
.sidebar-settings.active {
border-color: var(--line-2);
background: var(--grad-soft);
color: var(--tx);
font-weight: 650;
}
.sidebar-bottom > button:hover { background: var(--nx-accent-soft, rgba(123,110,242,.08)); color: #d8dbe3; }
.sidebar-bottom > button.active { background: var(--nx-accent-soft, rgba(123,110,242,.08)); color: var(--nx-accent, #7b6ef2); font-weight: 600; }
.owner {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
}
.owner div strong { display: block; font-size: 9px; }
.owner div span { font-size: 7.5px; color: var(--text-dim, #6f7889); text-transform: capitalize; }
.owner div strong { display: block; font-size: 12px; }
.owner div span {
display: block;
margin-top: 1px;
color: var(--tx-3);
font-size: 11px;
text-transform: capitalize;
}
.owner > svg:last-child { margin-left: auto; opacity: .4; transition: opacity .15s; }
.owner:hover > svg:last-child { opacity: 1; }
.avatar {
width: 26px;
height: 26px;
border-radius: 6px;
width: 34px;
height: 34px;
border-radius: 10px;
display: grid;
place-items: center;
background: var(--accent, #7b6ef2);
color: #fff;
font-size: 9px;
background: var(--grad-soft);
border: 1px solid var(--line-2);
color: var(--tx);
font-size: 12px;
font-weight: 700;
}
@media (max-width: 860px) {
.sidebar { position: fixed; inset: 0; z-index: 100; transform: translateX(-100%); transition: transform .25s; }
@media (max-width: 900px) {
.sidebar {
position: fixed;
inset: 0 auto 0 0;
left: 0;
z-index: 100;
width: min(280px, calc(100vw - 28px));
flex-basis: auto;
transform: translateX(-100%);
transition: transform .25s ease;
box-shadow: var(--panel-shadow);
}
.sidebar.open { transform: translateX(0); }
}
</style>
+9 -3
View File
@@ -1,15 +1,21 @@
<script setup lang="ts">
import type { NavItemDef } from '../../composables/icons'
import type { Component } from 'vue'
import NavItem from './NavItem.vue'
defineProps<{
label: string
items: NavItemDef[]
items: Array<{
icon: Component
label: string
route: string
count?: string
active?: boolean
}>
}>()
</script>
<template>
<div class="nav-group">
<div class="nav-group" role="group" :aria-label="label">
<div class="nav-group-label">{{ label }}</div>
<NavItem
v-for="item in items"
+24 -23
View File
@@ -1,46 +1,43 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { icons } from '../../composables/icons'
import { computed, type Component } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
const props = defineProps<{
icon: string
icon: Component
label: string
route?: string
count?: string
active?: boolean
}>()
const router = useRouter()
const route = useRoute()
const currentRoute = useRoute()
const isActive = computed(() => {
if (props.active) return true
if (props.route && route.path === props.route) return true
return false
if (props.route && currentRoute.path === props.route) return true
return Boolean(props.active)
})
function navigate() {
if (props.route) {
router.push(props.route)
}
}
</script>
<template>
<button
<component
:is="props.route ? RouterLink : 'button'"
:to="props.route || undefined"
:type="props.route ? undefined : 'button'"
:class="['nav-item', { active: isActive }]"
@click="navigate"
:aria-current="isActive ? 'page' : undefined"
>
<!-- Icon -->
<span class="nav-icon" v-html="icons[icon] || ''"></span>
<span class="nav-icon">
<component :is="icon" aria-hidden="true" />
</span>
<!-- Label -->
<span class="nav-label">{{ label }}</span>
<!-- Count badge -->
<span v-if="count !== undefined" class="count">{{ count }}</span>
</button>
</component>
</template>
<style scoped>
@@ -65,14 +62,18 @@ function navigate() {
}
.nav-item:hover {
background: rgba(124,108,255,.08);
background: color-mix(in srgb, var(--a-mid) 8%, transparent);
color: var(--tx);
}
.nav-item.active {
color: #fff;
background: linear-gradient(90deg, rgba(124,108,255,.22), rgba(124,108,255,.04));
box-shadow: inset 0 0 0 1px rgba(124,108,255,.25);
color: var(--tx-on-accent);
background: linear-gradient(
90deg,
color-mix(in srgb, var(--a-mid) 22%, transparent),
color-mix(in srgb, var(--a-mid) 4%, transparent)
);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--a-mid) 25%, transparent);
}
.nav-item.active::before {
@@ -118,7 +119,7 @@ function navigate() {
font-weight: 600;
padding: 1px 8px;
border-radius: 20px;
background: rgba(124,108,255,.16);
background: color-mix(in srgb, var(--a-mid) 16%, transparent);
color: var(--tx);
line-height: 1.4;
flex-shrink: 0;
+165 -42
View File
@@ -1,11 +1,27 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { computed, type Component } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import {
Activity,
AlertTriangle,
Bell,
BookOpen,
Bot,
Boxes,
Calendar,
ChevronLeft,
Command,
FileText,
LayoutDashboard,
ListTodo,
Settings,
Shield,
SlidersHorizontal,
Workflow,
} from '@lucide/vue'
import { useOpenClawOverviewQuery } from '../../api/openclawRuntime'
import { useNotificationSnapshot } from '../../api/notifications'
import { useAuthStore } from '../../stores/auth'
import { useAgentStore } from '../../stores/agents'
import { useTaskStore } from '../../stores/tasks'
import { navigation, icons } from '../../composables/icons'
import type { NavGroupDef } from '../../composables/icons'
defineProps<{
mobileOpen?: boolean
@@ -19,24 +35,80 @@ import { initials } from '../../utils/format'
const auth = useAuthStore()
const router = useRouter()
const agentStore = useAgentStore()
const taskStore = useTaskStore()
const overviewQuery = useOpenClawOverviewQuery()
const { query: notificationQuery } = useNotificationSnapshot({ limit: 1 })
interface NavItemDef {
icon: Component
label: string
route: string
count?: string
active?: boolean
}
interface NavGroupDef {
group: string
items: NavItemDef[]
}
const navigation: NavGroupDef[] = [
{
group: 'Operations',
items: [
{ icon: LayoutDashboard, label: 'Dashboard', route: '/dashboard' },
{ icon: Bot, label: 'Agenten', route: '/agents' },
{ icon: Boxes, label: 'Projects', route: '/projects' },
{ icon: ListTodo, label: 'Task Board', route: '/tasks' },
{ icon: Workflow, label: 'Run Control', route: '/runs' },
],
},
{
group: 'Knowledge',
items: [
{ icon: FileText, label: 'Memory', route: '/memory' },
{ icon: BookOpen, label: 'Docs & .md', route: '/docs' },
],
},
{
group: 'Infrastructure',
items: [
{ icon: SlidersHorizontal, label: 'Modelle', route: '/models' },
{ icon: Activity, label: 'Activity Log', route: '/activity' },
{ icon: Calendar, label: 'Calendar', route: '/calendar' },
],
},
{
group: 'Governance',
items: [
{ icon: Shield, label: 'Security', route: '/security' },
{ icon: AlertTriangle, label: 'Incidents', route: '/incidents' },
{ icon: Bell, label: 'Notifications', route: '/notifications' },
],
},
]
const ownerInitials = computed(() =>
auth.user?.displayName ? initials(auth.user.displayName) : 'OW'
)
function logout() {
auth.logout()
router.replace('/login')
async function logout() {
await auth.logout()
await router.replace('/login')
}
/**
* Dynamische Nav-Item-Counts aus den Stores.
* Überschreibt die hartcodierten `count`-Werte im navigation-Array.
* Counts come from the canonical Vue Query server-state boundary. This
* presentation island does not recreate a second agent or task store.
*/
const dynamicNavigation = computed<NavGroupDef[]>(() => {
// Deep-clone: Jede Gruppe und jedes Item neu erstellen
const overview = overviewQuery.data.value
const activeTasks = overview?.tasks.items.filter(task =>
!['completed', 'done', 'failed', 'cancelled'].includes(
task.status.toLocaleLowerCase(),
),
).length ?? 0
const unread = notificationQuery.data.value?.unreadCount
return navigation.map(group => ({
...group,
items: group.items.map(item => {
@@ -44,20 +116,13 @@ const dynamicNavigation = computed<NavGroupDef[]>(() => {
switch (item.label) {
case 'Agenten':
case 'Hosts · OpenClaw':
dynamicCount = String(agentStore.agentList.length)
dynamicCount = overview ? String(overview.agents.items.length) : undefined
break
case 'Task Board':
dynamicCount = String(taskStore.taskList.length)
dynamicCount = overview ? String(activeTasks) : undefined
break
case 'Kosten & Tokens':
dynamicCount = agentStore.todayCost
break
case 'Docs & .md':
dynamicCount = '0'
break
case 'Incidents':
dynamicCount = '0'
case 'Notifications':
dynamicCount = unread === undefined ? undefined : String(unread)
break
}
@@ -71,11 +136,18 @@ const dynamicNavigation = computed<NavGroupDef[]>(() => {
</script>
<template>
<aside :class="['sidebar', { open: mobileOpen }]">
<button class="sidebar-close" @click="$emit('close')" v-html="icons.chevron_left || ''"></button>
<aside :class="['sidebar', { open: mobileOpen }]" aria-label="Nexus navigation">
<button
type="button"
class="sidebar-close"
aria-label="Close navigation"
@click="$emit('close')"
>
<ChevronLeft :size="18" aria-hidden="true" />
</button>
<!-- Brand -->
<div class="side-top">
<div class="brand-mark" v-html="icons.command || ''"></div>
<div class="brand-mark"><Command :size="20" aria-hidden="true" /></div>
<div>
<div class="brand-name">NEXUS</div>
<div class="brand-sub">Mission Control</div>
@@ -83,7 +155,7 @@ const dynamicNavigation = computed<NavGroupDef[]>(() => {
</div>
<!-- Navigation -->
<nav class="nav">
<nav class="nav" aria-label="Primary">
<NavGroup
v-for="(group, idx) in dynamicNavigation"
:key="idx"
@@ -94,10 +166,16 @@ const dynamicNavigation = computed<NavGroupDef[]>(() => {
<!-- Footer -->
<div class="side-foot">
<div class="avatar">{{ ownerInitials }}</div>
<div class="owner-info">
<div class="owner-name">{{ auth.user?.displayName ?? 'Owner' }}</div>
<div class="owner-role">{{ auth.user?.role ?? 'Owner' }}</div>
<RouterLink to="/settings" class="settings-link" active-class="active">
<Settings :size="17" aria-hidden="true" />
<span>Settings</span>
</RouterLink>
<div class="owner-row">
<div class="avatar">{{ ownerInitials }}</div>
<div class="owner-info">
<div class="owner-name">{{ auth.user?.displayName ?? 'Owner' }}</div>
<div class="owner-role">{{ auth.user?.role ?? 'Owner' }}</div>
</div>
</div>
</div>
</aside>
@@ -110,7 +188,11 @@ const dynamicNavigation = computed<NavGroupDef[]>(() => {
height: 100vh;
display: flex;
flex-direction: column;
background: linear-gradient(180deg, rgba(14,12,32,.92), rgba(8,6,20,.92));
background: linear-gradient(
180deg,
color-mix(in srgb, var(--space-2) 92%, transparent),
color-mix(in srgb, var(--space-0) 92%, transparent)
);
border-right: 1px solid var(--line);
backdrop-filter: blur(14px);
padding: 0;
@@ -139,7 +221,7 @@ const dynamicNavigation = computed<NavGroupDef[]>(() => {
.brand-mark :deep(svg) {
width: 20px;
height: 20px;
color: #fff;
color: var(--tx-on-accent);
}
.brand-name {
@@ -170,14 +252,55 @@ const dynamicNavigation = computed<NavGroupDef[]>(() => {
padding: 12px;
border-top: 1px solid var(--line);
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
transition: background .15s;
flex-direction: column;
align-items: stretch;
gap: 6px;
}
.side-foot:hover {
background: rgba(124,108,255,.06);
.settings-link {
position: relative;
display: flex;
align-items: center;
gap: 11px;
min-height: 38px;
padding: 9px 11px;
border: 1px solid transparent;
border-radius: 10px;
color: var(--tx-2);
font-family: var(--font-body);
font-size: 13.5px;
font-weight: 500;
text-decoration: none;
transition: background .16s, border-color .16s, color .16s;
}
.settings-link:hover {
background: color-mix(in srgb, var(--a-mid) 6%, transparent);
color: var(--tx);
}
.settings-link.active {
border-color: var(--line-2);
background: var(--grad-soft);
color: var(--tx);
}
.settings-link.active::before {
content: '';
position: absolute;
left: -13px;
width: 3px;
height: 22px;
border-radius: 0 999px 999px 0;
background: var(--grad);
box-shadow: var(--glow-purple);
}
.owner-row {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 0 0;
}
.sidebar-close {
@@ -218,7 +341,7 @@ const dynamicNavigation = computed<NavGroupDef[]>(() => {
}
.sidebar-close:hover {
background: rgba(124,108,255,.1);
background: color-mix(in srgb, var(--a-mid) 10%, transparent);
color: var(--tx);
}
+122 -16
View File
@@ -1,40 +1,112 @@
<script setup lang="ts">
import { icons } from '../../composables/icons'
import { nextTick, ref, watch } from 'vue'
import { Menu, Search, Sparkles } from '@lucide/vue'
import { RouterLink } from 'vue-router'
defineProps<{
const props = withDefaults(defineProps<{
connected?: boolean
statusLabel?: string
}>()
irisChatOpen?: boolean
commandOpen?: boolean
canOpenIris?: boolean
}>(), {
connected: false,
statusLabel: undefined,
irisChatOpen: false,
commandOpen: false,
canOpenIris: false,
})
defineEmits<{
'toggle-sidebar': []
'open-command': []
'open-iris': []
}>()
const irisChatButton = ref<HTMLButtonElement | null>(null)
const commandButton = ref<HTMLButtonElement | null>(null)
watch(
() => props.irisChatOpen,
async (isOpen, wasOpen) => {
if (!isOpen && wasOpen) {
await nextTick()
if (!document.activeElement || document.activeElement === document.body) {
irisChatButton.value?.focus()
}
}
},
)
watch(
() => props.commandOpen,
async (isOpen, wasOpen) => {
if (!isOpen && wasOpen) {
await nextTick()
if (!document.activeElement || document.activeElement === document.body) {
commandButton.value?.focus()
}
}
},
)
</script>
<template>
<header class="topbar">
<!-- Hamburger (mobile only) -->
<button class="hamburger" @click="$emit('toggle-sidebar')" v-html="icons.list || ''"></button>
<button
class="hamburger"
type="button"
aria-label="Open navigation"
@click="$emit('toggle-sidebar')"
>
<Menu :size="20" aria-hidden="true" />
</button>
<!-- Search -->
<div class="search">
<span class="search-icon" v-html="icons.search || ''"></span>
<button
ref="commandButton"
class="search"
type="button"
aria-label="Mission Control durchsuchen"
aria-haspopup="dialog"
aria-controls="mission-command-dialog"
:aria-expanded="commandOpen"
@click="$emit('open-command')"
>
<Search class="search-icon" :size="16" aria-hidden="true" />
<span class="search-placeholder">Operationen, Agents oder Tasks suchen</span>
</div>
<kbd>Ctrl K</kbd>
</button>
<!-- Spacer -->
<div class="spacer"></div>
<!-- Status Pill -->
<span :class="['pill', connected ? 'live' : 'preview']">
<RouterLink
to="/runs"
:class="['pill', connected ? 'live' : 'preview']"
:aria-label="connected ? 'OpenClaw connected, open Run Control' : 'OpenClaw disconnected, open Run Control'"
>
<span class="status-dot" :class="connected ? 'on' : 'off'"></span>
{{ connected ? (statusLabel || 'OpenClaw verbunden') : 'Preview' }}
</span>
{{ connected ? (statusLabel || 'OpenClaw verbunden') : 'OpenClaw offline' }}
</RouterLink>
<!-- Ask Iris Button -->
<button class="btn btn-primary ask-iris-btn">
<span class="btn-icon" v-html="icons.spark || ''"></span>
<span class="ask-label">Ask Iris</span>
<button
ref="irisChatButton"
class="btn btn-primary ask-iris-btn"
type="button"
:disabled="!canOpenIris"
:aria-label="canOpenIris ? 'Iris Chat öffnen' : 'Iris Chat ist nur für Owner verfügbar'"
:aria-haspopup="canOpenIris ? 'dialog' : undefined"
:aria-controls="canOpenIris ? 'iris-chat-dialog' : undefined"
:aria-expanded="canOpenIris ? irisChatOpen : false"
:title="canOpenIris ? undefined : 'Nur Owner dürfen Iris verwenden'"
@click="$emit('open-iris')"
>
<span class="btn-icon"><Sparkles :size="15" aria-hidden="true" /></span>
<span class="ask-label">Iris Chat</span>
</button>
</header>
</template>
@@ -66,9 +138,11 @@ defineEmits<{
color: var(--tx-3);
font-size: 13.5px;
font-family: 'Manrope', sans-serif;
text-align: left;
cursor: pointer;
}
.search-icon :deep(svg) {
.search-icon {
width: 16px;
height: 16px;
flex: 0 0 auto;
@@ -97,6 +171,28 @@ defineEmits<{
border: 1px solid var(--line-2);
background: rgba(124,108,255,.07);
color: var(--tx-2);
text-decoration: none;
}
.search kbd {
margin-left: auto;
padding: 2px 6px;
border: 1px solid var(--line-2);
border-radius: 6px;
background: color-mix(in srgb, var(--space-3) 64%, transparent);
color: var(--tx-3);
font-family: var(--font-mono-v2);
font-size: 10px;
}
.search:hover {
border-color: var(--line-2);
background: rgba(124,108,255,.1);
}
.search:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.status-dot {
@@ -137,10 +233,16 @@ defineEmits<{
box-shadow: var(--glow-purple);
}
.btn-primary:hover {
.btn-primary:hover:not(:disabled) {
filter: brightness(1.08);
}
.btn:disabled {
cursor: not-allowed;
opacity: .48;
box-shadow: none;
}
.btn-icon :deep(svg) {
width: 15px;
height: 15px;
@@ -150,7 +252,7 @@ defineEmits<{
display: none;
}
@media (max-width: 767px) {
@media (max-width: 900px) {
.topbar {
padding: 0 14px;
}
@@ -160,6 +262,10 @@ defineEmits<{
max-width: none;
}
.search kbd {
display: none;
}
.hamburger {
display: flex;
align-items: center;
@@ -0,0 +1,556 @@
<script setup lang="ts">
import {
Activity,
Bot,
Boxes,
FileText,
LayoutDashboard,
ListTodo,
Play,
Search,
Settings,
Shield,
Sparkles,
Workflow,
} from '@lucide/vue'
import { computed, nextTick, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useOpenClawOverviewQuery } from '../../api/openclawRuntime'
import { useProjects } from '../../api/projects'
import { useTaskBoard } from '../../api/taskBoard'
import type { MissionControlContext } from '../../types/mission-control'
interface CommandEntry {
id: string
label: string
description: string
group: 'Actions' | 'Navigation' | 'Objects'
icon: unknown
route?: string | { path: string; query?: Record<string, string> }
keywords: string
action?: 'iris'
}
const props = defineProps<{
open: boolean
context: MissionControlContext
canOpenIris: boolean
canStartRun: boolean
}>()
const emit = defineEmits<{
close: []
askIris: []
}>()
const router = useRouter()
const overviewQuery = useOpenClawOverviewQuery()
const projects = useProjects()
const taskBoard = useTaskBoard(50)
const dialogEl = ref<HTMLDialogElement | null>(null)
const inputEl = ref<HTMLInputElement | null>(null)
const query = ref('')
const activeIndex = ref(0)
const navigationCommands: CommandEntry[] = [
{ id: 'nav-dashboard', label: 'Dashboard', description: 'Live orchestration workspace', group: 'Navigation', icon: LayoutDashboard, route: '/dashboard', keywords: 'home overview orchestration' },
{ id: 'nav-runs', label: 'Run Control', description: 'Runs, sessions and approvals', group: 'Navigation', icon: Workflow, route: '/runs', keywords: 'openclaw sessions approvals tasks' },
{ id: 'nav-tasks', label: 'Task Board', description: 'Nexus work queue', group: 'Navigation', icon: ListTodo, route: '/tasks', keywords: 'board work items' },
{ id: 'nav-agents', label: 'Agents', description: 'OpenClaw agent inventory', group: 'Navigation', icon: Bot, route: '/agents', keywords: 'workers openclaw' },
{ id: 'nav-projects', label: 'Projects', description: 'Mission scopes and delivery', group: 'Navigation', icon: Boxes, route: '/projects', keywords: 'workspaces delivery' },
{ id: 'nav-activity', label: 'Activity', description: 'Operational audit trail', group: 'Navigation', icon: Activity, route: '/activity', keywords: 'events audit logs' },
{ id: 'nav-docs', label: 'Docs', description: 'Operational knowledge', group: 'Navigation', icon: FileText, route: '/docs', keywords: 'documentation knowledge' },
{ id: 'nav-security', label: 'Security', description: 'Trust and approval controls', group: 'Navigation', icon: Shield, route: '/security', keywords: 'auth approvals governance' },
{ id: 'nav-settings', label: 'Settings', description: 'Nexus and OpenClaw configuration', group: 'Navigation', icon: Settings, route: '/settings', keywords: 'configuration gateway' },
]
const allCommands = computed<CommandEntry[]>(() => {
const contextLabel = props.context.entityType && props.context.entityId
? `${props.context.entityType} ${props.context.entityId}`
: props.context.surface
const entries: CommandEntry[] = []
if (props.canOpenIris) {
entries.push({
id: 'action-iris',
label: `Ask Iris about ${contextLabel}`,
description: `Send the current ${props.context.surface} context with your request`,
group: 'Actions',
icon: Sparkles,
action: 'iris',
keywords: `assistant iris ask ${props.context.routeName} ${props.context.entityId ?? ''}`,
})
}
if (props.canStartRun) {
entries.push({
id: 'action-start-run',
label: props.context.entityType && props.context.entityId
? `Start run for this ${props.context.entityType}`
: 'Start OpenClaw run',
description: 'Dispatch a durable, correlated agent run',
group: 'Actions',
icon: Play,
route: {
path: '/runs',
query: {
new: '1',
...(props.context.entityType === 'task' && props.context.entityId
? { taskId: props.context.entityId }
: {}),
...(props.context.entityType === 'project' && props.context.entityId
? { projectId: props.context.entityId }
: {}),
...(props.context.entityType === 'agent' && props.context.entityId
? { agentId: props.context.entityId }
: {}),
},
},
keywords: `start dispatch durable run ${props.context.entityType ?? ''} ${props.context.entityId ?? ''}`,
})
}
entries.push(...navigationCommands)
for (const project of projects.query.data.value ?? []) {
entries.push({
id: `project-${project.id}`,
label: project.name,
description: `Project · ${project.status}`,
group: 'Objects',
icon: Boxes,
route: `/projects/${encodeURIComponent(project.id)}`,
keywords: `project ${project.status}`,
})
}
const tasks = [
...taskBoard.board.value.offen,
...taskBoard.board.value.inProgress,
...taskBoard.board.value.review,
...taskBoard.board.value.blocked,
...taskBoard.board.value.done,
]
for (const task of tasks) {
entries.push({
id: `task-${task.id}`,
label: task.title,
description: `Task · ${task.state} · ${task.priority}`,
group: 'Objects',
icon: ListTodo,
route: `/tasks/${encodeURIComponent(task.id)}`,
keywords: `task ${task.state} ${task.priority}`,
})
}
for (const agent of overviewQuery.data.value?.agents.items ?? []) {
entries.push({
id: `agent-${agent.id}`,
label: agent.name,
description: `Agent · ${agent.status}${agent.model ? ` · ${agent.model}` : ''}`,
group: 'Objects',
icon: Bot,
route: `/agents/${encodeURIComponent(agent.id)}`,
keywords: `agent ${agent.id} ${agent.status} ${agent.model ?? ''}`,
})
}
for (const session of overviewQuery.data.value?.sessions.items ?? []) {
entries.push({
id: `session-${session.key}`,
label: session.title,
description: `Session · ${session.status} · ${session.agentId}`,
group: 'Objects',
icon: Workflow,
route: { path: '/runs', query: { session: session.key } },
keywords: `session run ${session.key} ${session.agentId} ${session.status}`,
})
}
return entries
})
const filteredCommands = computed(() => {
const terms = query.value
.trim()
.toLocaleLowerCase()
.split(/\s+/)
.filter(Boolean)
const matches = terms.length
? allCommands.value.filter(command => {
const haystack = `${command.label} ${command.description} ${command.keywords}`.toLocaleLowerCase()
return terms.every(term => haystack.includes(term))
})
: allCommands.value
return matches.slice(0, 14)
})
const groupedCommands = computed(() => {
const groups: Array<{ label: CommandEntry['group']; items: CommandEntry[] }> = []
for (const label of ['Actions', 'Navigation', 'Objects'] as const) {
const items = filteredCommands.value.filter(command => command.group === label)
if (items.length) groups.push({ label, items })
}
return groups
})
watch(
() => props.open,
async isOpen => {
if (isOpen) {
query.value = ''
activeIndex.value = 0
if (dialogEl.value && !dialogEl.value.open) dialogEl.value.showModal()
await nextTick()
inputEl.value?.focus()
} else if (dialogEl.value?.open) {
dialogEl.value.close()
}
},
{ immediate: true },
)
watch(filteredCommands, items => {
if (!items.length) activeIndex.value = 0
else activeIndex.value = Math.min(activeIndex.value, items.length - 1)
})
function closeDialog() {
dialogEl.value?.close()
}
function onDialogClose() {
emit('close')
}
function onBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget) closeDialog()
}
async function execute(command: CommandEntry) {
if (command.action === 'iris') {
if (!props.canOpenIris) return
emit('askIris')
return
}
if (command.id === 'action-start-run' && !props.canStartRun) return
if (command.route) {
await router.push(command.route)
emit('close')
}
}
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault()
closeDialog()
} else if (event.key === 'ArrowDown') {
event.preventDefault()
if (filteredCommands.value.length) {
activeIndex.value = (activeIndex.value + 1) % filteredCommands.value.length
}
} else if (event.key === 'ArrowUp') {
event.preventDefault()
if (filteredCommands.value.length) {
activeIndex.value = (activeIndex.value - 1 + filteredCommands.value.length) % filteredCommands.value.length
}
} else if (event.key === 'Enter') {
event.preventDefault()
const command = filteredCommands.value[activeIndex.value]
if (command) void execute(command)
}
}
function flatIndex(command: CommandEntry) {
return filteredCommands.value.findIndex(item => item.id === command.id)
}
</script>
<template>
<dialog
id="mission-command-dialog"
ref="dialogEl"
class="command-dialog"
aria-labelledby="command-dialog-title"
@click="onBackdropClick"
@cancel.prevent="closeDialog"
@close="onDialogClose"
>
<section class="command-panel">
<h2 id="command-dialog-title" class="sr-only">Mission Control commands</h2>
<label class="command-search">
<Search :size="18" aria-hidden="true" />
<input
ref="inputEl"
v-model="query"
type="search"
placeholder="Operationen, Agents, Tasks oder Projekte suchen…"
autocomplete="off"
aria-label="Mission Control durchsuchen"
:aria-activedescendant="filteredCommands[activeIndex] ? `command-${filteredCommands[activeIndex].id}` : undefined"
@keydown="onKeydown"
/>
<kbd>Esc</kbd>
</label>
<div class="command-results" role="listbox" aria-label="Commands">
<div v-if="!filteredCommands.length" class="command-empty">
Keine passende Operation gefunden.
</div>
<section v-for="group in groupedCommands" :key="group.label" class="command-group">
<h3>{{ group.label }}</h3>
<button
v-for="command in group.items"
:id="`command-${command.id}`"
:key="command.id"
type="button"
class="command-row"
:class="{ active: flatIndex(command) === activeIndex }"
role="option"
:aria-selected="flatIndex(command) === activeIndex"
@mouseenter="activeIndex = flatIndex(command)"
@click="execute(command)"
>
<span class="command-icon" aria-hidden="true">
<component :is="command.icon" :size="17" />
</span>
<span class="command-copy">
<strong>{{ command.label }}</strong>
<small>{{ command.description }}</small>
</span>
<span class="command-enter" aria-hidden="true"></span>
</button>
</section>
</div>
<footer class="command-footer">
<span><kbd></kbd><kbd></kbd> auswählen</span>
<span><kbd></kbd> öffnen</span>
<span class="command-context">{{ context.surface }}<template v-if="context.entityId"> · {{ context.entityId }}</template></span>
</footer>
</section>
</dialog>
</template>
<style scoped>
.command-dialog {
width: min(720px, calc(100vw - 32px));
max-width: none;
max-height: min(760px, calc(100dvh - 48px));
margin: 11vh auto auto;
padding: 0;
overflow: visible;
border: 0;
background: transparent;
color: var(--tx);
}
.command-dialog::backdrop {
background: color-mix(in srgb, var(--space-0) 78%, transparent);
backdrop-filter: blur(14px);
}
.command-panel {
overflow: hidden;
border: 1px solid var(--line-3);
border-radius: var(--r-lg);
background: linear-gradient(160deg, rgba(22, 18, 50, .98), rgba(10, 8, 28, .98));
box-shadow: var(--panel-shadow), 0 0 58px -16px rgba(124, 108, 255, .38);
backdrop-filter: blur(18px);
}
.command-search {
min-height: 62px;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
padding: 0 18px;
border-bottom: 1px solid var(--line);
color: var(--tx-3);
}
.command-search input {
min-width: 0;
height: 60px;
border: 0;
outline: 0;
background: transparent;
color: var(--tx);
font-family: var(--font-body);
font-size: 15px;
}
.command-search input::placeholder {
color: var(--tx-3);
}
kbd {
min-width: 24px;
min-height: 22px;
display: inline-grid;
place-items: center;
padding: 2px 6px;
border: 1px solid var(--line-2);
border-radius: 6px;
background: var(--accent-wash);
color: var(--tx-3);
font-family: var(--font-mono-v2);
font-size: 10px;
}
.command-results {
max-height: min(560px, calc(100dvh - 220px));
overflow-y: auto;
padding: 10px;
scrollbar-width: thin;
scrollbar-color: var(--accent-scroll) transparent;
}
.command-group + .command-group {
margin-top: 8px;
}
.command-group h3 {
margin: 0;
padding: 7px 9px 5px;
color: var(--tx-3);
font-family: var(--font-mono-v2);
font-size: 10px;
font-weight: 700;
letter-spacing: .13em;
text-transform: uppercase;
}
.command-row {
width: 100%;
min-height: 54px;
display: grid;
grid-template-columns: 36px minmax(0, 1fr) auto;
align-items: center;
gap: 11px;
padding: 7px 10px;
border: 1px solid transparent;
border-radius: var(--r-sm);
background: transparent;
color: var(--tx);
text-align: left;
cursor: pointer;
}
.command-row.active {
border-color: var(--line-2);
background: var(--grad-soft);
}
.command-icon {
width: 34px;
height: 34px;
display: grid;
place-items: center;
border: 1px solid var(--line);
border-radius: 9px;
background: var(--accent-wash);
color: var(--a-mid);
}
.command-copy {
min-width: 0;
display: grid;
gap: 2px;
}
.command-copy strong {
overflow: hidden;
color: var(--tx);
font-family: var(--font-body);
font-size: 12.5px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-copy small {
overflow: hidden;
color: var(--tx-3);
font-family: var(--font-body);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-enter {
color: var(--tx-3);
font-family: var(--font-mono-v2);
font-size: 13px;
opacity: 0;
}
.command-row.active .command-enter {
opacity: 1;
}
.command-empty {
padding: 36px 16px;
color: var(--tx-3);
font-size: 12px;
text-align: center;
}
.command-footer {
min-height: 40px;
display: flex;
align-items: center;
gap: 14px;
padding: 8px 12px;
border-top: 1px solid var(--line);
color: var(--tx-3);
font-size: 10px;
}
.command-footer span {
display: inline-flex;
align-items: center;
gap: 4px;
}
.command-context {
min-width: 0;
margin-left: auto;
overflow: hidden;
font-family: var(--font-mono-v2);
text-overflow: ellipsis;
white-space: nowrap;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 767px) {
.command-dialog {
width: calc(100vw - 16px);
margin-top: 8px;
}
.command-footer {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.command-dialog[open] {
animation: none;
}
}
</style>
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { ArrowUpRight } from '@lucide/vue'
import type { EntityRefDto } from '../../api/contracts'
import { entityTypeLabel, routeForEntity } from '../../utils/entityNavigation'
defineProps<{
entity: EntityRefDto
compact?: boolean
}>()
</script>
<template>
<RouterLink
class="entity-link"
:class="{ 'entity-link--compact': compact }"
:to="routeForEntity(entity)"
>
<span class="entity-link__type">{{ entityTypeLabel(entity.type) }}</span>
<strong>{{ entity.label || entity.id }}</strong>
<ArrowUpRight :size="13" aria-hidden="true" />
</RouterLink>
</template>
<style scoped>
.entity-link {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
padding: 8px 10px;
color: var(--tx);
text-decoration: none;
border: 1px solid var(--line);
border-radius: 10px;
background: color-mix(in srgb, var(--space-2) 62%, transparent);
}
.entity-link:hover,
.entity-link:focus-visible {
border-color: color-mix(in srgb, var(--a-mid) 45%, var(--line));
background: color-mix(in srgb, var(--a-mid) 10%, var(--space-2));
outline: none;
}
.entity-link strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
}
.entity-link__type {
color: var(--tx-3);
font: 10px/1.2 'JetBrains Mono', monospace;
text-transform: uppercase;
letter-spacing: .05em;
}
.entity-link--compact {
padding: 5px 8px;
}
</style>
@@ -0,0 +1,99 @@
<script setup lang="ts">
import { CheckCircle2, CircleAlert, LoaderCircle } from '@lucide/vue'
import type { OperationResultDto } from '../../api/contracts'
import EntityLink from './EntityLink.vue'
const props = defineProps<{
result: OperationResultDto
title?: string
}>()
const pendingStates = new Set(['queued', 'pending', 'awaiting_approval', 'provisioning'])
const failedStates = new Set([
'failed',
'rejected',
'error',
'invalid',
'forbidden',
'unavailable',
'unsupported',
'management_disabled',
'not_found',
'conflict',
'stale',
'idempotency_conflict',
'in_doubt',
'audit_unavailable',
'audit_pending',
])
</script>
<template>
<section class="operation-result nexus-panel" aria-live="polite">
<div class="operation-result__status">
<LoaderCircle v-if="pendingStates.has(props.result.status)" :size="18" class="spin" aria-hidden="true" />
<CircleAlert v-else-if="failedStates.has(props.result.status)" :size="18" aria-hidden="true" />
<CheckCircle2 v-else :size="18" aria-hidden="true" />
<div>
<strong>{{ title || 'Operation' }}</strong>
<span>{{ props.result.status }}</span>
</div>
</div>
<div v-if="props.result.primaryRef || props.result.affectedRefs.length" class="operation-result__refs">
<EntityLink v-if="props.result.primaryRef" :entity="props.result.primaryRef" />
<EntityLink
v-for="entity in props.result.affectedRefs.filter(item => item.id !== props.result.primaryRef?.id)"
:key="`${entity.type}:${entity.id}`"
:entity="entity"
compact
/>
</div>
<div class="operation-result__meta">
<span>Revision {{ props.result.revision }}</span>
<span v-if="props.result.traceId">Trace {{ props.result.traceId }}</span>
</div>
</section>
</template>
<style scoped>
.operation-result {
display: grid;
gap: 12px;
padding: 14px;
}
.operation-result__status {
display: flex;
align-items: center;
gap: 10px;
color: var(--a-cyan);
}
.operation-result__status div {
display: grid;
gap: 2px;
}
.operation-result__status strong {
color: var(--tx);
font: 700 14px/1.2 'Space Grotesk', sans-serif;
}
.operation-result__status span,
.operation-result__meta {
color: var(--tx-3);
font: 10px/1.4 'JetBrains Mono', monospace;
}
.operation-result__refs {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.operation-result__meta {
display: flex;
justify-content: space-between;
gap: 12px;
}
.spin {
animation: spin .9s linear infinite;
}
@keyframes spin {
to { transform: rotate(1turn); }
}
</style>
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { X } from '@lucide/vue'
import type { OperationResultDto } from '../../api/contracts'
import OperationResultCard from './OperationResultCard.vue'
defineProps<{
result: OperationResultDto
title: string
}>()
defineEmits<{
close: []
}>()
</script>
<template>
<aside class="operation-tray" aria-label="Letztes Operationsergebnis">
<button
type="button"
class="operation-tray__close"
aria-label="Operationsergebnis schließen"
@click="$emit('close')"
>
<X :size="15" aria-hidden="true" />
</button>
<OperationResultCard :result="result" :title="title" />
</aside>
</template>
<style scoped>
.operation-tray {
position: fixed;
z-index: 120;
right: 18px;
bottom: 18px;
width: min(430px, calc(100vw - 28px));
filter: drop-shadow(0 18px 36px color-mix(in srgb, var(--space-0) 68%, transparent));
}
.operation-tray__close {
position: absolute;
z-index: 1;
top: 8px;
right: 8px;
display: grid;
width: 28px;
height: 28px;
place-items: center;
border: 1px solid var(--line);
border-radius: 8px;
background: color-mix(in srgb, var(--space-2) 82%, transparent);
color: var(--tx-2);
}
.operation-tray__close:hover,
.operation-tray__close:focus-visible {
border-color: var(--line-2);
color: var(--tx);
outline: 2px solid color-mix(in srgb, var(--a-blue) 45%, transparent);
outline-offset: 2px;
}
@media (max-width: 768px) {
.operation-tray {
right: 14px;
bottom: 14px;
}
}
</style>
@@ -0,0 +1,720 @@
<script setup lang="ts">
import {
AlertTriangle,
CheckCircle2,
ChevronRight,
FileJson2,
Loader2,
RefreshCw,
Save,
Search,
ShieldCheck,
} from '@lucide/vue'
import { computed, onMounted, ref } from 'vue'
import { apiFetch } from '../../services/api'
import { createMutationRequestContext } from '../../services/mutationContext'
import { reportOperationEnvelope } from '../../services/operationResults'
interface SchemaChild {
key: string
path: string
type: unknown
required: boolean
hasChildren: boolean
reloadKind: string | null
hint: unknown
}
interface SchemaLookup {
path: string
schema: unknown
reloadKind: string | null
hint: unknown
children: SchemaChild[]
checkedAt: string
}
interface ConfigSnapshot {
exists: boolean
valid: boolean
hash: string | null
config: unknown
issues: unknown
warnings: unknown
checkedAt: string
}
interface ConfigPatchResult {
ok: boolean
state: string
message: string
snapshot: ConfigSnapshot
restart: unknown
verified: boolean
idempotencyKey: string
correlationId: string
completedAt: string
operation?: unknown
}
const schemaPath = ref('agents')
const replacePathsText = ref('')
const note = ref('')
const patchText = ref('{\n \n}')
const snapshot = ref<ConfigSnapshot | null>(null)
const schema = ref<SchemaLookup | null>(null)
const loading = ref(false)
const schemaLoading = ref(false)
const saving = ref(false)
const error = ref('')
const success = ref('')
const confirmed = ref(false)
const parsedPatch = computed<Record<string, unknown> | null>(() => {
try {
const value = JSON.parse(patchText.value) as unknown
return isPlainObject(value) ? value : null
} catch {
return null
}
})
const patchError = computed(() => {
if (!parsedPatch.value) return 'Der Patch muss ein gültiges JSON-Objekt sein.'
if (Object.keys(parsedPatch.value).length === 0) return 'Der Patch enthält keine Änderung.'
return ''
})
const selectedBefore = computed(() =>
readPath(snapshot.value?.config, schemaPath.value),
)
const selectedAfter = computed(() => {
if (!snapshot.value || !parsedPatch.value) return null
const merged = mergePatch(cloneJson(snapshot.value.config), parsedPatch.value)
return readPath(merged, schemaPath.value)
})
const replacePaths = computed(() =>
replacePathsText.value
.split(',')
.map(path => path.trim())
.filter(Boolean),
)
const canSave = computed(() =>
Boolean(snapshot.value?.hash)
&& !patchError.value
&& confirmed.value
&& !saving.value,
)
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function cloneJson<T>(value: T): T {
return value == null ? value : JSON.parse(JSON.stringify(value)) as T
}
function mergePatch(target: unknown, patch: unknown): unknown {
if (!isPlainObject(patch)) return cloneJson(patch)
const result: Record<string, unknown> = isPlainObject(target)
? { ...target }
: {}
for (const [key, value] of Object.entries(patch)) {
if (value === null) {
delete result[key]
} else {
result[key] = mergePatch(result[key], value)
}
}
return result
}
function readPath(root: unknown, path: string): unknown {
const segments = path
.replace(/\[(\d+)\]/g, '.$1')
.split(/[./]/)
.map(segment => segment.trim())
.filter(Boolean)
let current = root
for (const segment of segments) {
if (Array.isArray(current) && /^\d+$/.test(segment)) {
current = current[Number(segment)]
continue
}
if (!isPlainObject(current)) return null
current = current[segment]
}
return current ?? null
}
function formatJson(value: unknown) {
if (value == null) return 'Nicht vorhanden'
return JSON.stringify(value, null, 2)
}
async function readResponse<T>(response: Response, operationTitle?: string): Promise<T> {
const payload = await response.json().catch(() => null) as
| T
| { message?: string; detail?: string; title?: string }
| null
if (operationTitle) reportOperationEnvelope(payload, operationTitle)
if (!response.ok) {
const message = payload && typeof payload === 'object'
? ('message' in payload && payload.message)
|| ('detail' in payload && payload.detail)
|| ('title' in payload && payload.title)
: null
throw new Error(message || `OpenClaw-Anfrage ist mit HTTP ${response.status} fehlgeschlagen.`)
}
return payload as T
}
async function loadSnapshot() {
loading.value = true
error.value = ''
success.value = ''
try {
snapshot.value = await readResponse<ConfigSnapshot>(
await apiFetch('/api/v1/openclaw/config'),
)
} catch (cause) {
error.value = cause instanceof Error ? cause.message : 'Konfiguration konnte nicht geladen werden.'
} finally {
loading.value = false
}
}
async function loadSchema(path = schemaPath.value) {
const normalized = path.trim()
if (!normalized) {
error.value = 'Ein OpenClaw-Konfigurationspfad ist erforderlich.'
return
}
schemaLoading.value = true
error.value = ''
success.value = ''
try {
schema.value = await readResponse<SchemaLookup>(
await apiFetch(`/api/v1/openclaw/config/schema?path=${encodeURIComponent(normalized)}`),
)
schemaPath.value = schema.value.path
} catch (cause) {
error.value = cause instanceof Error ? cause.message : 'Schema konnte nicht geladen werden.'
} finally {
schemaLoading.value = false
}
}
async function savePatch() {
if (!canSave.value || !snapshot.value?.hash || !parsedPatch.value) return
saving.value = true
error.value = ''
success.value = ''
try {
const context = createMutationRequestContext('openclaw-config-patch')
const result = await readResponse<ConfigPatchResult>(
await apiFetch('/api/v1/openclaw/config', {
method: 'PATCH',
headers: context.headers,
body: JSON.stringify({
patch: parsedPatch.value,
baseHash: snapshot.value.hash,
replacePaths: replacePaths.value,
note: note.value.trim() || null,
}),
}),
'OpenClaw-Konfiguration aktualisiert',
)
snapshot.value = result.snapshot
success.value = result.verified
? `${result.message} Die Konfiguration wurde zurückgelesen und verifiziert.`
: result.message
confirmed.value = false
patchText.value = '{\n \n}'
replacePathsText.value = ''
note.value = ''
} catch (cause) {
error.value = cause instanceof Error ? cause.message : 'Konfigurationsänderung ist fehlgeschlagen.'
} finally {
saving.value = false
}
}
onMounted(async () => {
await Promise.all([loadSnapshot(), loadSchema()])
})
</script>
<template>
<section class="config-editor nexus-panel" aria-labelledby="openclaw-config-title">
<header class="config-editor__header">
<div>
<span class="config-editor__eyebrow">SCHEMA-CONTROLLED</span>
<h2 id="openclaw-config-title">
<FileJson2 :size="18" aria-hidden="true" />
OpenClaw-Konfiguration
</h2>
<p>
Nexus liest das Live-Schema und schreibt ausschließlich hash-geschützte Patches.
Provider-Secrets werden weder angezeigt noch als Klartext akzeptiert.
</p>
</div>
<button
type="button"
class="nexus-button"
:disabled="loading || saving"
@click="loadSnapshot"
>
<Loader2 v-if="loading" :size="14" class="spin" aria-hidden="true" />
<RefreshCw v-else :size="14" aria-hidden="true" />
Neu laden
</button>
</header>
<div v-if="loading && !snapshot" class="nexus-state nexus-state--loading" role="status">
<Loader2 :size="18" class="spin" aria-hidden="true" />
<p>OpenClaw-Konfiguration wird geladen</p>
</div>
<template v-else>
<div class="config-editor__facts" aria-label="Konfigurationsstatus">
<div>
<span>Basis-Hash</span>
<code>{{ snapshot?.hash || 'nicht verfügbar' }}</code>
</div>
<div>
<span>Validierung</span>
<strong :class="snapshot?.valid ? 'is-valid' : 'is-warning'">
{{ snapshot?.valid ? 'gültig' : 'Prüfung erforderlich' }}
</strong>
</div>
<div>
<span>Schema-Pfad</span>
<code>{{ schema?.path || schemaPath }}</code>
</div>
</div>
<form class="config-editor__schema-search" @submit.prevent="loadSchema()">
<label for="openclaw-schema-path">Konfigurationsbereich</label>
<div>
<Search :size="15" aria-hidden="true" />
<input
id="openclaw-schema-path"
v-model="schemaPath"
autocomplete="off"
spellcheck="false"
placeholder="z. B. agents, cron oder models"
/>
<button type="submit" class="nexus-button" :disabled="schemaLoading">
<Loader2 v-if="schemaLoading" :size="14" class="spin" aria-hidden="true" />
Schema laden
</button>
</div>
</form>
<nav
v-if="schema?.children.length"
class="config-editor__children"
aria-label="Untergeordnete Konfigurationsbereiche"
>
<button
v-for="child in schema.children"
:key="child.path"
type="button"
@click="loadSchema(child.path)"
>
<span>
<strong>{{ child.key }}</strong>
<small>{{ child.reloadKind || 'Live-Verhalten laut OpenClaw' }}</small>
</span>
<ChevronRight :size="14" aria-hidden="true" />
</button>
</nav>
<div class="config-editor__grid">
<section aria-labelledby="openclaw-patch-title">
<h3 id="openclaw-patch-title">JSON Merge Patch</h3>
<label for="openclaw-config-patch">Patch</label>
<textarea
id="openclaw-config-patch"
v-model="patchText"
rows="12"
spellcheck="false"
@input="confirmed = false"
/>
<p v-if="patchError" class="config-editor__validation">{{ patchError }}</p>
<label for="openclaw-replace-paths">Replace-Pfade <span>(optional, kommasepariert)</span></label>
<input
id="openclaw-replace-paths"
v-model="replacePathsText"
spellcheck="false"
placeholder="agents.list, models.providers"
@input="confirmed = false"
/>
<label for="openclaw-config-note">Audit-Notiz <span>(optional)</span></label>
<input
id="openclaw-config-note"
v-model="note"
maxlength="1000"
placeholder="Warum wird diese Änderung benötigt?"
/>
</section>
<section class="config-editor__diff" aria-labelledby="openclaw-diff-title">
<h3 id="openclaw-diff-title">Diff-Vorschau · {{ schemaPath }}</h3>
<div>
<span>Vorher</span>
<pre>{{ formatJson(selectedBefore) }}</pre>
</div>
<div>
<span>Nach Patch</span>
<pre>{{ formatJson(selectedAfter) }}</pre>
</div>
</section>
</div>
<div class="config-editor__guard">
<AlertTriangle :size="18" aria-hidden="true" />
<div>
<strong>OpenClaw bleibt die Konfigurationsautorität.</strong>
<p>
Bei einem abweichenden Basis-Hash bricht Nexus mit Konflikt ab. Ein Speichern bedeutet
nur geschrieben und zurückgelesen; eine Runtime-Aktivierung wird nicht erfunden.
</p>
<label>
<input v-model="confirmed" type="checkbox" />
Ich habe Patch und Vorschau geprüft.
</label>
</div>
</div>
<p v-if="error" class="config-editor__message config-editor__message--error" role="alert">
<AlertTriangle :size="15" aria-hidden="true" />
{{ error }}
</p>
<p v-if="success" class="config-editor__message config-editor__message--success" role="status">
<CheckCircle2 :size="15" aria-hidden="true" />
{{ success }}
</p>
<footer>
<span>
<ShieldCheck :size="15" aria-hidden="true" />
Owner · operator.admin · Idempotenz · Read-back
</span>
<button type="button" class="nexus-button nexus-button--primary" :disabled="!canSave" @click="savePatch">
<Loader2 v-if="saving" :size="14" class="spin" aria-hidden="true" />
<Save v-else :size="14" aria-hidden="true" />
Patch anwenden
</button>
</footer>
</template>
</section>
</template>
<style scoped>
.config-editor {
display: grid;
gap: 16px;
min-width: 0;
padding: 18px;
border-color: var(--line-2);
}
.config-editor__header,
.config-editor footer {
display: flex;
gap: 16px;
align-items: flex-start;
justify-content: space-between;
}
.config-editor__header h2,
.config-editor__grid h3 {
margin: 0;
color: var(--tx);
font-family: var(--font-display-v2);
}
.config-editor__header h2 {
display: flex;
gap: 8px;
align-items: center;
font-size: 17px;
}
.config-editor__header p,
.config-editor__guard p {
max-width: 650px;
margin: 5px 0 0;
color: var(--tx-2);
font-size: 12px;
line-height: 1.55;
}
.config-editor__eyebrow {
display: block;
margin-bottom: 5px;
color: var(--a-blue);
font-family: var(--font-mono-v2);
font-size: 10px;
letter-spacing: .14em;
}
.config-editor__facts {
display: grid;
grid-template-columns: 1.5fr .7fr 1fr;
gap: 8px;
}
.config-editor__facts > div {
display: grid;
gap: 5px;
min-width: 0;
padding: 10px;
border: 1px solid var(--line);
border-radius: var(--r-sm);
background: var(--accent-wash);
}
.config-editor__facts span,
.config-editor__diff span {
color: var(--tx-3);
font-family: var(--font-mono-v2);
font-size: 10px;
letter-spacing: .08em;
text-transform: uppercase;
}
.config-editor__facts code {
overflow: hidden;
color: var(--tx-2);
font-family: var(--font-mono-v2);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.is-valid { color: var(--st-work); }
.is-warning { color: var(--st-queue); }
.config-editor__schema-search {
display: grid;
gap: 6px;
}
.config-editor label {
color: var(--tx-2);
font-size: 11px;
font-weight: 700;
}
.config-editor label span {
color: var(--tx-3);
font-weight: 500;
}
.config-editor__schema-search > div {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
padding-left: 10px;
border: 1px solid var(--line);
border-radius: var(--r-sm);
background: var(--glass);
}
.config-editor input:not([type='checkbox']),
.config-editor textarea {
width: 100%;
min-width: 0;
border: 1px solid var(--line);
border-radius: var(--r-sm);
outline: 0;
background: var(--glass);
color: var(--tx);
font: 11px/1.55 var(--font-mono-v2);
}
.config-editor input:not([type='checkbox']) {
min-height: 38px;
padding: 8px 10px;
}
.config-editor__schema-search input {
border: 0;
background: transparent;
}
.config-editor textarea {
resize: vertical;
padding: 10px;
}
.config-editor input:focus-visible,
.config-editor textarea:focus-visible {
border-color: var(--a-blue);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--a-blue) 18%, transparent);
}
.config-editor__children {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
}
.config-editor__children button {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
min-width: 0;
padding: 9px 10px;
border: 1px solid var(--line);
border-radius: var(--r-sm);
background: var(--glass);
color: var(--tx);
text-align: left;
}
.config-editor__children button:hover {
border-color: var(--line-2);
background: var(--accent-wash);
}
.config-editor__children span {
display: grid;
min-width: 0;
}
.config-editor__children strong,
.config-editor__children small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.config-editor__children small {
margin-top: 2px;
color: var(--tx-3);
font: 10px var(--font-mono-v2);
}
.config-editor__grid {
display: grid;
grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr);
gap: 12px;
}
.config-editor__grid > section {
display: grid;
align-content: start;
gap: 7px;
min-width: 0;
padding: 12px;
border: 1px solid var(--line);
border-radius: var(--r-sm);
background: var(--glass);
}
.config-editor__grid h3 {
margin-bottom: 3px;
font-size: 13px;
}
.config-editor__validation {
margin: 0;
color: var(--st-block);
font-size: 11px;
}
.config-editor__diff > div {
display: grid;
gap: 5px;
min-width: 0;
}
.config-editor__diff pre {
overflow: auto;
max-height: 190px;
min-height: 64px;
margin: 0;
padding: 9px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--space-0);
color: var(--tx-2);
font: 10px/1.5 var(--font-mono-v2);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.config-editor__guard {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 10px;
padding: 12px;
border: 1px solid var(--status-queue-line);
border-radius: var(--r-sm);
background: var(--status-queue-bg);
color: var(--st-queue);
}
.config-editor__guard strong { color: var(--tx); }
.config-editor__guard label {
display: flex;
gap: 8px;
align-items: center;
margin-top: 9px;
color: var(--tx);
}
.config-editor__message {
display: flex;
gap: 7px;
align-items: center;
margin: 0;
padding: 9px 10px;
border-radius: var(--r-sm);
font-size: 11px;
}
.config-editor__message--error {
background: var(--status-block-bg);
color: var(--st-block);
}
.config-editor__message--success {
background: var(--status-work-bg);
color: var(--st-work);
}
.config-editor footer {
align-items: center;
}
.config-editor footer > span {
display: flex;
gap: 7px;
align-items: center;
color: var(--tx-3);
font: 10px var(--font-mono-v2);
}
@media (max-width: 768px) {
.config-editor__header,
.config-editor footer {
align-items: stretch;
flex-direction: column;
}
.config-editor__facts,
.config-editor__grid,
.config-editor__children {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
@@ -0,0 +1,155 @@
<script setup lang="ts">
import { AlertTriangle, RefreshCw, Workflow } from '@lucide/vue'
import { computed } from 'vue'
import { RouterLink } from 'vue-router'
import { useOpenClawOverviewQuery } from '../../api/openclawRuntime'
const overviewQuery = useOpenClawOverviewQuery()
const connection = computed(() => overviewQuery.data.value?.connection ?? null)
const errorMessage = computed(() => {
const error = overviewQuery.error.value
return error instanceof Error
? error.message
: error
? 'OpenClaw-Status nicht erreichbar'
: ''
})
const visible = computed(() =>
Boolean(errorMessage.value || (connection.value && !connection.value.connected)),
)
const title = computed(() => {
if (errorMessage.value) return 'OpenClaw-Status nicht erreichbar'
if (!connection.value?.configured) return 'OpenClaw ist nicht konfiguriert'
if (connection.value?.pairingRequired) return 'OpenClaw Device Pairing erforderlich'
if (connection.value.state === 'failed') return 'OpenClaw-Verbindung fehlgeschlagen'
return 'OpenClaw ist nicht verbunden'
})
</script>
<template>
<aside v-if="visible" class="openclaw-notice" role="status">
<span class="notice-icon"><AlertTriangle :size="16" aria-hidden="true" /></span>
<span class="notice-copy">
<strong>{{ title }}</strong>
<small>
{{ errorMessage || (connection?.pairingRequired && connection.pairingRequestId
? `Request-ID: ${connection.pairingRequestId}`
: connection?.recovery || connection?.message) }}
</small>
</span>
<RouterLink class="notice-link" to="/runs">
<Workflow :size="14" aria-hidden="true" />
Run Control
</RouterLink>
<button
type="button"
class="notice-refresh"
aria-label="OpenClaw status refresh"
:disabled="overviewQuery.isFetching.value"
@click="overviewQuery.refetch()"
>
<RefreshCw :size="14" :class="{ spin: overviewQuery.isFetching.value }" aria-hidden="true" />
</button>
</aside>
</template>
<style scoped>
.openclaw-notice {
min-height: 48px;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
align-items: center;
gap: 10px;
padding: 8px 11px;
border: 1px solid var(--status-queue-line);
border-radius: var(--r-sm);
background: var(--status-queue-bg);
color: var(--tx);
}
.notice-icon {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border-radius: 8px;
background: color-mix(in srgb, var(--st-queue) 12%, transparent);
color: var(--st-queue);
}
.notice-copy {
min-width: 0;
}
.notice-copy strong,
.notice-copy small {
display: block;
}
.notice-copy strong {
font-size: 12px;
}
.notice-copy small {
margin-top: 2px;
overflow: hidden;
color: var(--tx-2);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.notice-link,
.notice-refresh {
min-height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border: 1px solid var(--line-2);
border-radius: 8px;
background: color-mix(in srgb, var(--space-2) 62%, transparent);
color: var(--tx);
font-size: 11px;
font-weight: 700;
text-decoration: none;
}
.notice-link {
padding: 0 10px;
}
.notice-refresh {
width: 30px;
padding: 0;
cursor: pointer;
}
.notice-refresh:disabled {
opacity: .55;
}
.spin {
animation: notice-spin 1s linear infinite;
}
@keyframes notice-spin {
to { transform: rotate(360deg); }
}
@media (max-width: 700px) {
.openclaw-notice {
grid-template-columns: auto minmax(0, 1fr) auto;
}
.notice-link {
grid-column: 2;
justify-self: start;
}
.notice-refresh {
grid-column: 3;
grid-row: 1;
}
}
</style>
@@ -0,0 +1,527 @@
<script setup lang="ts">
import {
AlertTriangle,
Check,
CircleAlert,
CircleCheck,
Eye,
EyeOff,
Link2,
Loader2,
Radar,
RefreshCw,
ServerCog,
ShieldCheck,
Trash2,
} from '@lucide/vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useAuthStore } from '../../stores/auth'
import { useOpenClawSetupStore } from '../../stores/openclawSetup'
import type { OpenClawDiscoveryCandidate } from '../../types/openclaw-setup'
import OpenClawSetupWizard from './OpenClawSetupWizard.vue'
const auth = useAuthStore()
const setup = useOpenClawSetupStore()
const endpoint = ref('')
const source = ref('manual')
const tlsFingerprint = ref('')
const bootstrapToken = ref('')
const bootstrapSecretReference = ref('')
const showBootstrapToken = ref(false)
const includeMdns = ref(false)
const confirmRemoval = ref(false)
const endpointInput = ref<HTMLInputElement | null>(null)
const isOwner = computed(() => auth.user?.role === 'owner')
const status = computed(() => setup.status)
const canAttach = computed(() =>
isOwner.value
&& Boolean(setup.probeResult?.canAttach)
&& !setup.busy,
)
const wizardMethods = [
'wizard.start',
'wizard.next',
'wizard.status',
'wizard.cancel',
]
const wizardAvailable = computed(() =>
isOwner.value
&& Boolean(status.value?.managementEnabled)
&& Boolean(status.value?.grantedScopes.includes('operator.admin'))
&& wizardMethods.every(method => status.value?.advertisedMethods.includes(method)),
)
const wizardUnavailableReason = computed(() => {
if (!isOwner.value) return 'Nur der Nexus-Owner darf den offiziellen OpenClaw-Wizard bedienen.'
if (!status.value?.hasProfile) {
return 'Zuerst eine erreichbare OpenClaw-Instanz verbinden und deren Gateway-Vertrag prüfen.'
}
if (!status.value.managementEnabled) {
return 'Zuerst Read-only-Inventar übernehmen und OpenClaw-Verwaltung ausdrücklich freigeben.'
}
if (!status.value.grantedScopes.includes('operator.admin')) {
return 'Der Wizard benötigt ein ausdrücklich freigegebenes operator.admin Scope-Upgrade.'
}
const missing = wizardMethods.filter(method => !status.value?.advertisedMethods.includes(method))
if (missing.length) return `OpenClaw bietet noch nicht alle Wizard-Methoden an: ${missing.join(', ')}.`
return 'OpenClaw Wizard ist derzeit nicht verfügbar.'
})
const steps = computed(() => {
const adoption = status.value?.adoptionState ?? 'none'
const hasProfile = Boolean(status.value?.hasProfile)
const verified = ['verified', 'adopted'].includes(adoption)
const adopted = adoption === 'adopted'
return [
{ label: 'Erkennen', complete: Boolean(setup.probeResult || hasProfile) },
{ label: 'Read-only verbinden', complete: hasProfile },
{ label: 'Inventar prüfen', complete: verified },
{ label: 'Übernehmen', complete: adopted },
{ label: 'Verwalten', complete: Boolean(status.value?.managementEnabled) },
]
})
function chooseCandidate(candidate: OpenClawDiscoveryCandidate) {
endpoint.value = candidate.endpoint
source.value = candidate.source
tlsFingerprint.value = ''
setup.probeResult = null
void nextTick(() => endpointInput.value?.focus())
}
async function discover() {
await setup.discover(includeMdns.value).catch(() => undefined)
}
async function probe() {
if (!endpoint.value.trim()) return
await setup.probe(
endpoint.value.trim(),
tlsFingerprint.value.trim() || null,
).catch(() => undefined)
}
async function attach() {
if (!setup.probeResult) return
await setup.attach({
endpoint: setup.probeResult.endpoint,
discoverySource: source.value,
tlsCertificateFingerprint: tlsFingerprint.value.trim() || null,
bootstrapToken: bootstrapToken.value || null,
bootstrapSecretReference: bootstrapSecretReference.value.trim() || null,
}).catch(() => undefined)
bootstrapToken.value = ''
}
async function verify() {
await setup.verify().catch(() => undefined)
}
async function adopt() {
await setup.adopt().catch(() => undefined)
}
async function toggleManagement() {
await setup.setManagement(!status.value?.managementEnabled).catch(() => undefined)
}
async function removeConnection() {
if (!confirmRemoval.value || !status.value?.endpoint) return
const removed = await setup.remove(
status.value.endpoint,
status.value.deviceId,
).then(() => true).catch(() => false)
if (removed) {
confirmRemoval.value = false
endpoint.value = ''
source.value = 'manual'
}
}
watch(
() => setup.status?.endpoint,
value => {
if (value && !endpoint.value) endpoint.value = value
},
)
onMounted(async () => {
await setup.load()
if (setup.status?.endpoint) endpoint.value = setup.status.endpoint
})
</script>
<template>
<section class="setup-center nexus-panel" aria-labelledby="openclaw-setup-title">
<header class="setup-header">
<div class="setup-title">
<span class="setup-icon"><ServerCog :size="19" aria-hidden="true" /></span>
<div>
<span class="eyebrow">OPENCLAW CONTROL PLANE</span>
<h2 id="openclaw-setup-title">Attach &amp; Adopt</h2>
<p>
OpenClaw bleibt die Runtime-Autorität. Nexus übernimmt nur Verbindung,
Inventar, Richtlinien und Audit.
</p>
</div>
</div>
<button
type="button"
class="nexus-button"
:disabled="setup.busy"
@click="setup.load()"
>
<Loader2 v-if="setup.loading" :size="14" class="spin" aria-hidden="true" />
<RefreshCw v-else :size="14" aria-hidden="true" />
Status prüfen
</button>
</header>
<ol class="setup-steps" aria-label="OpenClaw Einrichtungsfortschritt">
<li v-for="(step, index) in steps" :key="step.label" :class="{ complete: step.complete }">
<span><Check v-if="step.complete" :size="12" aria-hidden="true" />{{ step.complete ? '' : index + 1 }}</span>
{{ step.label }}
</li>
</ol>
<div
v-if="status?.experimentalBlocked"
class="setup-alert setup-alert--warning"
role="status"
>
<AlertTriangle :size="18" aria-hidden="true" />
<div>
<strong>Externe Nexus-Client-ID noch nicht freigegeben</strong>
<p>
Nexus imitiert weder OpenClaw CLI noch Control UI. Discovery und Diagnose
bleiben nutzbar; Attach ist blockiert, bis der gepinnte Gateway-Vertrag
die Client-ID <code>nexus</code> offiziell akzeptiert.
</p>
</div>
</div>
<div v-if="setup.loading && !status" class="nexus-state nexus-state--loading" role="status">
<Loader2 :size="18" class="spin" aria-hidden="true" />
<p>OpenClaw-Setupstatus wird geladen</p>
</div>
<template v-else>
<section class="connection-summary" aria-labelledby="connection-summary-title">
<div class="section-heading">
<div>
<span class="eyebrow">CURRENT AUTHORITY</span>
<h3 id="connection-summary-title">Verbindungszustand</h3>
</div>
<span
class="nexus-status"
:class="status?.hasProfile ? 'nexus-status--success' : 'nexus-status--warning'"
>
<CircleCheck v-if="status?.hasProfile" :size="13" aria-hidden="true" />
<CircleAlert v-else :size="13" aria-hidden="true" />
{{ status?.adoptionState || 'Nicht konfiguriert' }}
</span>
</div>
<dl class="setup-facts">
<div><dt>Endpoint</dt><dd><code>{{ status?.endpoint || 'Noch nicht gewählt' }}</code></dd></div>
<div><dt>Version</dt><dd>{{ status?.gatewayVersion || 'Nicht geprüft' }}</dd></div>
<div><dt>Protokoll</dt><dd>{{ status?.protocolVersion ? `v${status.protocolVersion}` : 'Nicht geprüft' }}</dd></div>
<div><dt>Device</dt><dd><code>{{ status?.deviceId || 'Nicht gepaart' }}</code></dd></div>
<div><dt>Scopes</dt><dd>{{ status?.grantedScopes.length ?? 0 }} gewährt</dd></div>
<div><dt>Verwaltung</dt><dd>{{ status?.managementEnabled ? 'Freigegeben' : 'Read-only' }}</dd></div>
</dl>
<div v-if="status?.pairingRequired" class="pairing-proof" role="status">
<ShieldCheck :size="17" aria-hidden="true" />
<div>
<strong>Externe Pairing-Freigabe erforderlich</strong>
<p>Prüfe und genehmige exakt diese aktuelle Request-ID in Baos OpenClaw:</p>
<code>{{ status.pairingRequestId || 'Keine Request-ID gemeldet' }}</code>
</div>
</div>
</section>
<OpenClawSetupWizard
:available="wizardAvailable"
:unavailable-reason="wizardUnavailableReason"
/>
<section class="discovery-section" aria-labelledby="discovery-title">
<div class="section-heading">
<div>
<span class="eyebrow">READ-ONLY PREFLIGHT</span>
<h3 id="discovery-title">Gateway erkennen und prüfen</h3>
</div>
<button
type="button"
class="nexus-button"
:disabled="!isOwner || setup.busy"
@click="discover"
>
<Loader2 v-if="setup.action === 'discover'" :size="14" class="spin" aria-hidden="true" />
<Radar v-else :size="14" aria-hidden="true" />
Kandidaten erkennen
</button>
</div>
<label class="mdns-toggle">
<input v-model="includeMdns" type="checkbox" />
<span>Optional eine ausdrücklich gestartete mDNS-Suche einbeziehen</span>
</label>
<div v-if="setup.discovery?.candidates.length" class="candidate-list">
<button
v-for="candidate in setup.discovery.candidates"
:key="`${candidate.source}-${candidate.endpoint}`"
type="button"
class="candidate"
:disabled="!candidate.isValid"
@click="chooseCandidate(candidate)"
>
<span>
<strong>{{ candidate.source }}</strong>
<code>{{ candidate.endpoint }}</code>
</span>
<span>{{ candidate.requiresTlsFingerprint ? 'TLS-Pin erforderlich' : 'Interner Kandidat' }}</span>
</button>
</div>
<div class="probe-form">
<label>
<span>Gateway Endpoint</span>
<input
ref="endpointInput"
v-model="endpoint"
type="url"
inputmode="url"
autocomplete="url"
placeholder="ws://openclaw-gateway:18789"
/>
</label>
<label>
<span>TLS SHA-256 Fingerprint</span>
<input
v-model="tlsFingerprint"
type="text"
autocomplete="off"
placeholder="Nur für externe wss:// Endpoints"
/>
</label>
<button
type="button"
class="nexus-button nexus-button--primary"
:disabled="!isOwner || !endpoint.trim() || setup.busy"
@click="probe"
>
<Loader2 v-if="setup.action === 'probe'" :size="14" class="spin" aria-hidden="true" />
<Link2 v-else :size="14" aria-hidden="true" />
Read-only prüfen
</button>
</div>
<div v-if="setup.probeResult" class="probe-proof" aria-live="polite">
<div>
<span>Endpoint</span>
<code>{{ setup.probeResult.endpoint }}</code>
</div>
<div>
<span>Gateway</span>
<strong>{{ setup.probeResult.gatewayVersion || 'Nicht verbunden' }}</strong>
</div>
<div>
<span>Protocol</span>
<strong>{{ setup.probeResult.protocolVersion ? `v${setup.probeResult.protocolVersion}` : 'Nicht gemeldet' }}</strong>
</div>
<div>
<span>Least privilege</span>
<strong>{{ setup.probeResult.leastPrivilegeSatisfied ? 'Read-only' : 'Nicht bestätigt' }}</strong>
</div>
</div>
</section>
<section v-if="setup.probeResult || status?.hasProfile" class="adoption-section" aria-labelledby="adoption-title">
<div class="section-heading">
<div>
<span class="eyebrow">TRUST TRANSITION</span>
<h3 id="adoption-title">Verbinden und übernehmen</h3>
</div>
</div>
<div v-if="!status?.hasProfile" class="bootstrap-grid">
<label>
<span>Einmaliges Bootstrap-Token</span>
<span class="secret-input">
<input
v-model="bootstrapToken"
:type="showBootstrapToken ? 'text' : 'password'"
autocomplete="off"
placeholder="Wird nicht gespeichert"
/>
<button
type="button"
:aria-label="showBootstrapToken ? 'Bootstrap-Token verbergen' : 'Bootstrap-Token anzeigen'"
@click="showBootstrapToken = !showBootstrapToken"
>
<EyeOff v-if="showBootstrapToken" :size="15" aria-hidden="true" />
<Eye v-else :size="15" aria-hidden="true" />
</button>
</span>
</label>
<label>
<span>oder Server-SecretRef</span>
<input
v-model="bootstrapSecretReference"
type="text"
autocomplete="off"
placeholder="env:OPENCLAW_GATEWAY_TOKEN"
/>
</label>
<button
type="button"
class="nexus-button nexus-button--primary"
:disabled="!canAttach"
@click="attach"
>
<Loader2 v-if="setup.action === 'attach'" :size="14" class="spin" aria-hidden="true" />
<ShieldCheck v-else :size="14" aria-hidden="true" />
Read-only verbinden
</button>
</div>
<div v-else class="adoption-actions">
<button type="button" class="nexus-button" :disabled="setup.busy" @click="verify">
<Loader2 v-if="setup.action === 'verify'" :size="14" class="spin" aria-hidden="true" />
<RefreshCw v-else :size="14" aria-hidden="true" />
Inventar verifizieren
</button>
<button
type="button"
class="nexus-button nexus-button--primary"
:disabled="setup.busy || status?.adoptionState === 'adopted'"
@click="adopt"
>
<Check :size="14" aria-hidden="true" />
Read-only übernehmen
</button>
<button
type="button"
class="nexus-button"
:disabled="setup.busy || status?.adoptionState !== 'adopted'"
@click="toggleManagement"
>
<ShieldCheck :size="14" aria-hidden="true" />
{{ status?.managementEnabled ? 'Verwaltung sperren' : 'Verwaltung anfordern' }}
</button>
</div>
<div v-if="setup.inventory" class="inventory-grid" aria-label="OpenClaw Inventar">
<div><span>Agenten</span><strong>{{ setup.inventory.agentCount ?? '—' }}</strong></div>
<div><span>Agent-Dateien</span><strong>{{ setup.inventory.agentFileCount ?? '—' }}</strong></div>
<div><span>Cronjobs</span><strong>{{ setup.inventory.cronJobCount ?? '—' }}</strong></div>
<div><span>Modelle</span><strong>{{ setup.inventory.modelCount ?? '—' }}</strong></div>
<div><span>Channels</span><strong>{{ setup.inventory.channelCount ?? '—' }}</strong></div>
<div><span>Nodes</span><strong>{{ setup.inventory.nodeCount ?? '—' }}</strong></div>
</div>
</section>
<section v-if="status?.hasProfile" class="danger-zone" aria-labelledby="disconnect-title">
<div>
<span class="eyebrow">LOCAL RECOVERY</span>
<h3 id="disconnect-title">Nexus-Verbindung entfernen</h3>
<p>OpenClaw selbst, seine Agenten und Cronjobs werden dabei nicht gelöscht.</p>
</div>
<label>
<input v-model="confirmRemoval" type="checkbox" />
<span>Endpoint und lokale Profilbindung wirklich entfernen</span>
</label>
<button
type="button"
class="nexus-button setup-danger-button"
:disabled="!confirmRemoval || setup.busy"
@click="removeConnection"
>
<Trash2 :size="14" aria-hidden="true" />
Verbindung entfernen
</button>
</section>
</template>
<div v-if="setup.error" class="setup-alert setup-alert--error" role="alert">
<CircleAlert :size="18" aria-hidden="true" />
<div><strong>OpenClaw Setup fehlgeschlagen</strong><p>{{ setup.error }}</p></div>
</div>
<p class="nexus-visually-hidden" aria-live="polite">{{ setup.announcement }}</p>
</section>
</template>
<style scoped>
.setup-center { display: grid; gap: 16px; padding: 18px; }
.setup-header, .setup-title, .section-heading, .adoption-actions, .danger-zone {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.setup-title { align-items: flex-start; justify-content: flex-start; }
.setup-title h2, .section-heading h3, .danger-zone h3 { margin: 3px 0 0; font-family: var(--font-display); }
.setup-title h2 { font-size: 20px; }
.setup-title p, .danger-zone p { margin: 5px 0 0; color: var(--tx-3); line-height: 1.55; }
.setup-icon { width: 40px; height: 40px; display: grid; place-items: center; flex: 0 0 auto; border: 1px solid var(--line-2); border-radius: var(--r-sm); background: var(--grad-soft); color: var(--a-mid); }
.setup-steps { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 6px; margin: 0; padding: 0; list-style: none; }
.setup-steps li { display: flex; align-items: center; gap: 7px; min-height: 38px; padding: 7px 9px; border: 1px solid var(--line); border-radius: var(--r-sm); color: var(--tx-3); font-size: 11px; }
.setup-steps li span { width: 21px; height: 21px; display: grid; place-items: center; flex: 0 0 auto; border: 1px solid var(--line-2); border-radius: 50%; font-family: var(--font-mono-v2); }
.setup-steps li.complete { border-color: color-mix(in srgb, var(--st-work) 28%, var(--line)); color: var(--tx-2); }
.setup-steps li.complete span { background: var(--status-work-bg); color: var(--st-work); }
.connection-summary, .discovery-section, .adoption-section, .danger-zone { display: grid; gap: 13px; padding: 15px; border: 1px solid var(--line); border-radius: var(--r); background: var(--accent-wash); }
.section-heading h3, .danger-zone h3 { font-size: 15px; }
.setup-facts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin: 0; }
.setup-facts div, .probe-proof div, .inventory-grid div { min-width: 0; padding: 10px; border: 1px solid var(--line); border-radius: var(--r-sm); background: var(--glass); }
.setup-facts dt, .probe-proof span, .inventory-grid span { color: var(--tx-3); font-family: var(--font-mono-v2); font-size: 10px; text-transform: uppercase; }
.setup-facts dd { margin: 4px 0 0; overflow-wrap: anywhere; color: var(--tx-2); }
.setup-facts code, .probe-proof code { font-family: var(--font-mono-v2); font-size: 11px; overflow-wrap: anywhere; }
.setup-alert, .pairing-proof { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px; padding: 12px; border: 1px solid var(--line); border-radius: var(--r-sm); }
.setup-alert p, .pairing-proof p { margin: 3px 0 0; color: var(--tx-2); line-height: 1.5; }
.setup-alert--warning, .pairing-proof { border-color: var(--status-queue-line); background: var(--status-queue-bg); color: var(--st-queue); }
.setup-alert--error { border-color: var(--status-block-line); background: var(--status-block-bg); color: var(--st-block); }
.pairing-proof code { display: inline-block; margin-top: 6px; color: var(--tx); overflow-wrap: anywhere; }
.mdns-toggle, .danger-zone label { display: flex; align-items: center; gap: 9px; color: var(--tx-2); }
.candidate-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.candidate { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 52px; padding: 10px 12px; border: 1px solid var(--line); border-radius: var(--r-sm); background: var(--glass); color: var(--tx-2); text-align: left; cursor: pointer; }
.candidate:hover:not(:disabled) { border-color: var(--line-3); }
.candidate span:first-child { display: grid; gap: 3px; min-width: 0; }
.candidate code { overflow: hidden; color: var(--tx-3); font-family: var(--font-mono-v2); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.candidate > span:last-child { color: var(--tx-3); font-size: 10px; }
.probe-form, .bootstrap-grid { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) auto; gap: 10px; align-items: end; }
.probe-form label, .bootstrap-grid label { display: grid; gap: 6px; min-width: 0; color: var(--tx-3); font-size: 11px; }
.probe-form input, .bootstrap-grid input { width: 100%; min-width: 0; }
.probe-proof { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
.probe-proof div { display: grid; gap: 4px; }
.secret-input { display: flex; border: 1px solid var(--line); border-radius: var(--r-sm); background: var(--field-surface); }
.secret-input:focus-within { border-color: var(--a-blue); box-shadow: var(--focus-ring); }
.secret-input input { border: 0; background: transparent; box-shadow: none; }
.secret-input button { width: 38px; border: 0; background: transparent; color: var(--tx-3); cursor: pointer; }
.inventory-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 8px; }
.inventory-grid div { display: grid; gap: 4px; }
.inventory-grid strong { font-family: var(--font-display); font-size: 18px; }
.danger-zone { grid-template-columns: minmax(0, 1fr) auto auto; border-color: color-mix(in srgb, var(--st-block) 24%, var(--line)); }
.setup-danger-button { color: var(--st-block); }
.spin { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 1024px) {
.setup-steps { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.setup-facts { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.probe-form, .bootstrap-grid { grid-template-columns: 1fr 1fr; }
.probe-form .nexus-button, .bootstrap-grid .nexus-button { justify-self: start; }
.inventory-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.danger-zone { grid-template-columns: 1fr; align-items: start; }
.danger-zone .nexus-button { justify-self: start; }
}
@media (max-width: 680px) {
.setup-header, .section-heading, .adoption-actions { align-items: stretch; flex-direction: column; }
.setup-header .nexus-button, .section-heading .nexus-button, .adoption-actions .nexus-button { align-self: flex-start; }
.setup-steps, .candidate-list, .setup-facts, .probe-proof, .probe-form, .bootstrap-grid { grid-template-columns: 1fr; }
.inventory-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.candidate { align-items: flex-start; flex-direction: column; }
.candidate code { white-space: normal; overflow-wrap: anywhere; }
}
</style>
@@ -0,0 +1,904 @@
<script setup lang="ts">
import {
Check,
CircleAlert,
ClipboardCopy,
ExternalLink,
Loader2,
Play,
RefreshCw,
RotateCcw,
ShieldCheck,
XCircle,
} from '@lucide/vue'
import { computed, nextTick, ref, watch } from 'vue'
import { useOpenClawWizardStore } from '../../stores/openclawWizard'
import type {
OpenClawWizardMode,
OpenClawWizardStep,
} from '../../types/openclaw-wizard'
const props = withDefaults(defineProps<{
available?: boolean
unavailableReason?: string | null
}>(), {
available: false,
unavailableReason: null,
})
const wizard = useOpenClawWizardStore()
const mode = ref<OpenClawWizardMode>('local')
const startConfirmed = ref(false)
const textAnswer = ref('')
const selectedOptionIndex = ref<number | null>(null)
const selectedOptionIndices = ref<number[]>([])
const confirmAnswer = ref<boolean | null>(null)
const localError = ref('')
const copiedDeviceCode = ref(false)
const stepHeading = ref<HTMLElement | null>(null)
const errorAlert = ref<HTMLElement | null>(null)
const step = computed(() => wizard.step)
const result = computed(() => wizard.result)
const active = computed(() => wizard.active)
const combinedError = computed(() => localError.value || wizard.error)
const canStart = computed(() =>
props.available
&& startConfirmed.value
&& !wizard.busy,
)
const textAnswerBytes = computed(() => new TextEncoder().encode(textAnswer.value).byteLength)
const canSubmit = computed(() => {
const current = step.value
if (!current || current.sensitive || !current.canAnswer || wizard.busy) return false
switch (current.type) {
case 'select':
return selectedOptionIndex.value !== null
case 'text':
return textAnswerBytes.value <= 64 * 1024
case 'confirm':
return confirmAnswer.value !== null
case 'multiselect':
case 'note':
case 'progress':
case 'action':
return true
default:
return false
}
})
const continueLabel = computed(() => {
switch (step.value?.type) {
case 'progress':
return 'Fortschritt abrufen'
case 'action':
return 'Aktion fortsetzen'
default:
return 'Weiter'
}
})
const safeExternalUrl = computed(() => {
const raw = step.value?.externalUrl?.trim()
if (!raw || raw.length > 2048) return null
try {
const url = new URL(raw)
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) return null
return {
href: url.toString(),
label: `${url.origin}${url.pathname === '/' ? '' : url.pathname}`,
}
} catch {
return null
}
})
function valuesEqual(left: unknown, right: unknown): boolean {
if (Object.is(left, right)) return true
try {
return JSON.stringify(left) === JSON.stringify(right)
} catch {
return false
}
}
function resetAnswer(current: OpenClawWizardStep | null) {
textAnswer.value = ''
selectedOptionIndex.value = null
selectedOptionIndices.value = []
confirmAnswer.value = null
localError.value = ''
copiedDeviceCode.value = false
if (!current || current.sensitive) return
const initialValue = current.initialValue
if (current.type === 'text' && ['string', 'number'].includes(typeof initialValue)) {
textAnswer.value = String(initialValue)
} else if (current.type === 'confirm' && typeof initialValue === 'boolean') {
confirmAnswer.value = initialValue
} else if (current.type === 'select') {
const index = current.options.findIndex(option =>
valuesEqual(option.value, initialValue),
)
selectedOptionIndex.value = index >= 0 ? index : null
} else if (current.type === 'multiselect' && Array.isArray(initialValue)) {
selectedOptionIndices.value = current.options
.map((option, index) =>
initialValue.some((value: unknown) => valuesEqual(value, option.value))
? index
: -1,
)
.filter(index => index >= 0)
}
}
watch(
() => step.value?.id ?? null,
async () => {
resetAnswer(step.value)
await nextTick()
stepHeading.value?.focus()
},
{ immediate: true },
)
watch(combinedError, async value => {
if (!value) return
await nextTick()
errorAlert.value?.focus()
})
async function startWizard() {
if (!canStart.value) return
localError.value = ''
await wizard.start(mode.value).catch(() => undefined)
}
async function submitStep() {
const current = step.value
if (!current || !canSubmit.value) return
localError.value = ''
let hasAnswer = true
let value: unknown
switch (current.type) {
case 'select':
value = current.options[selectedOptionIndex.value!]?.value
break
case 'text':
if (textAnswerBytes.value > 64 * 1024) {
localError.value = 'Die Antwort darf höchstens 64 KiB groß sein.'
return
}
value = textAnswer.value
break
case 'confirm':
value = confirmAnswer.value
break
case 'multiselect':
value = selectedOptionIndices.value.map(index => current.options[index]?.value)
break
case 'note':
case 'progress':
case 'action':
hasAnswer = false
break
}
await wizard.next({
stepId: current.id,
value,
hasAnswer,
}).catch(() => undefined)
}
async function refreshWizard() {
localError.value = ''
await wizard.refresh().catch(() => undefined)
}
async function cancelWizard() {
localError.value = ''
await wizard.cancel().catch(() => undefined)
}
async function copyDeviceCode() {
const code = step.value?.deviceCode?.code
if (!code) return
try {
if (!navigator.clipboard || !window.isSecureContext) {
throw new Error('Clipboard API is unavailable.')
}
await navigator.clipboard.writeText(code)
copiedDeviceCode.value = true
wizard.announcement = 'Device-Code wurde in die Zwischenablage kopiert.'
} catch {
localError.value = 'Device-Code konnte nicht kopiert werden. Markiere den Code und kopiere ihn manuell.'
}
}
</script>
<template>
<section
class="wizard-panel"
aria-labelledby="openclaw-wizard-title"
:aria-busy="wizard.busy"
>
<header class="wizard-header">
<div class="wizard-title">
<span class="wizard-icon"><ShieldCheck :size="18" aria-hidden="true" /></span>
<div>
<span class="eyebrow">OFFICIAL OPENCLAW FLOW</span>
<h3 id="openclaw-wizard-title">Neues OpenClaw einrichten</h3>
<p>
Nexus rendert den offiziellen Gateway-Wizard live. OpenClaw führt die
Schritte aus; Provider-Secrets werden im Browser weder abgefragt noch gespeichert.
</p>
</div>
</div>
<span
class="nexus-status"
:class="available ? 'nexus-status--success' : 'nexus-status--warning'"
>
<Check v-if="available" :size="12" aria-hidden="true" />
<CircleAlert v-else :size="12" aria-hidden="true" />
{{ available ? 'Bereit' : 'Gesperrt' }}
</span>
</header>
<div class="wizard-boundary" role="note">
<strong>Ausführungsgrenze</strong>
<span>
Live-Gateway · kein Daemon-Install · keine Browser-Secrets · Sitzung nur im Arbeitsspeicher
</span>
</div>
<div
v-if="!available && !active"
class="wizard-notice wizard-notice--warning"
role="status"
>
<CircleAlert :size="17" aria-hidden="true" />
<div>
<strong>Wizard noch nicht verfügbar</strong>
<p>{{ unavailableReason || 'Gateway-Verbindung, Wizard-Methoden und operator.admin werden benötigt.' }}</p>
</div>
</div>
<form
v-if="!result"
class="wizard-start"
@submit.prevent="startWizard"
>
<fieldset :disabled="wizard.busy">
<legend>OpenClaw-Ziel</legend>
<label class="wizard-choice">
<input v-model="mode" type="radio" value="local" />
<span>
<strong>Lokal</strong>
<small>Gateway und Workspace auf demselben OpenClaw-Host einrichten.</small>
</span>
</label>
<label class="wizard-choice">
<input v-model="mode" type="radio" value="remote" />
<span>
<strong>Remote</strong>
<small>Eine erreichbare Remote-Konfiguration über den offiziellen Flow vorbereiten.</small>
</span>
</label>
</fieldset>
<label class="wizard-confirm">
<input v-model="startConfirmed" type="checkbox" />
<span>
Ich bestätige, dass der offizielle OpenClaw-Wizard Konfigurationen
verändern kann. Nexus installiert keine Pakete und startet keinen Daemon.
</span>
</label>
<button
type="submit"
class="nexus-button nexus-button--primary wizard-primary"
:disabled="!canStart"
>
<Loader2 v-if="wizard.action === 'start'" :size="14" class="spin" aria-hidden="true" />
<Play v-else :size="14" aria-hidden="true" />
Offiziellen Wizard starten
</button>
</form>
<template v-else>
<div class="wizard-session" aria-label="OpenClaw Wizard-Sitzung">
<span>
<strong>Status</strong>
<code>{{ result.status || result.state }}</code>
</span>
<span v-if="result.sessionId">
<strong>Session</strong>
<code>{{ result.sessionId }}</code>
</span>
</div>
<article v-if="active && step" class="wizard-step">
<header>
<span class="wizard-step-type">{{ step.type }}</span>
<h4 ref="stepHeading" tabindex="-1">
{{ step.title || 'OpenClaw Einrichtungsschritt' }}
</h4>
<p v-if="step.message">{{ step.message }}</p>
</header>
<a
v-if="safeExternalUrl"
class="wizard-external nexus-button"
:href="safeExternalUrl.href"
target="_blank"
rel="noopener noreferrer nofollow"
>
<ExternalLink :size="14" aria-hidden="true" />
Externe OpenClaw-Seite öffnen
<span>{{ safeExternalUrl.label }}</span>
</a>
<div v-if="step.deviceCode?.code" class="wizard-device" role="group" aria-label="OpenClaw Device-Code">
<div>
<span>DEVICE CODE</span>
<code tabindex="0">{{ step.deviceCode.code }}</code>
<small v-if="step.deviceCode.message">{{ step.deviceCode.message }}</small>
<small v-if="step.deviceCode.expiresInMinutes">
Gültig für ungefähr {{ step.deviceCode.expiresInMinutes }} Minuten.
</small>
</div>
<button
type="button"
class="nexus-button"
:aria-label="copiedDeviceCode ? 'Device-Code kopiert' : 'Device-Code kopieren'"
@click="copyDeviceCode"
>
<Check v-if="copiedDeviceCode" :size="14" aria-hidden="true" />
<ClipboardCopy v-else :size="14" aria-hidden="true" />
{{ copiedDeviceCode ? 'Kopiert' : 'Kopieren' }}
</button>
</div>
<div
v-if="step.sensitive || !step.canAnswer"
class="wizard-notice wizard-notice--blocked"
role="alert"
>
<ShieldCheck :size="18" aria-hidden="true" />
<div>
<strong>Serverseitiger Secret-Schritt</strong>
<p>
{{ step.blockedReason || 'Dieser Schritt kann aus Sicherheitsgründen nicht im Browser beantwortet werden.' }}
</p>
<p>Lege das Secret direkt in OpenClaw oder als serverseitigen SecretRef an.</p>
</div>
</div>
<fieldset
v-else-if="step.type === 'select'"
class="wizard-options"
:disabled="wizard.busy"
>
<legend>Eine Option auswählen</legend>
<label
v-for="(option, index) in step.options"
:key="`${step.id}-${index}`"
class="wizard-choice"
>
<input v-model="selectedOptionIndex" type="radio" :value="index" />
<span>
<strong>{{ option.label }}</strong>
<small v-if="option.hint">{{ option.hint }}</small>
</span>
</label>
</fieldset>
<label v-else-if="step.type === 'text'" class="wizard-text">
<span>Antwort</span>
<input
v-model="textAnswer"
type="text"
autocomplete="off"
:placeholder="step.placeholder || undefined"
:aria-describedby="textAnswerBytes > 64 * 1024 ? 'wizard-text-error' : undefined"
/>
<small
id="wizard-text-error"
:class="{ 'wizard-text-error': textAnswerBytes > 64 * 1024 }"
>
{{ textAnswerBytes.toLocaleString('de-DE') }} / 65.536 Bytes
</small>
</label>
<fieldset
v-else-if="step.type === 'confirm'"
class="wizard-options wizard-options--inline"
:disabled="wizard.busy"
>
<legend>Entscheidung bestätigen</legend>
<label class="wizard-choice">
<input v-model="confirmAnswer" type="radio" :value="true" />
<span><strong>Ja</strong></span>
</label>
<label class="wizard-choice">
<input v-model="confirmAnswer" type="radio" :value="false" />
<span><strong>Nein</strong></span>
</label>
</fieldset>
<fieldset
v-else-if="step.type === 'multiselect'"
class="wizard-options"
:disabled="wizard.busy"
>
<legend>Optionen auswählen</legend>
<label
v-for="(option, index) in step.options"
:key="`${step.id}-${index}`"
class="wizard-choice"
>
<input v-model="selectedOptionIndices" type="checkbox" :value="index" />
<span>
<strong>{{ option.label }}</strong>
<small v-if="option.hint">{{ option.hint }}</small>
</span>
</label>
</fieldset>
<div
v-else-if="step.type === 'progress'"
class="wizard-progress"
role="status"
>
<Loader2 :size="18" class="spin" aria-hidden="true" />
<span>OpenClaw führt den aktuellen Schritt aus.</span>
</div>
<div v-else-if="step.type === 'action'" class="wizard-action-note" role="note">
<strong>{{ step.executor === 'client' ? 'Aktion im Browser' : 'Aktion im Gateway' }}</strong>
<span>
{{ safeExternalUrl ? 'Führe die verlinkte Aktion aus und setze den Wizard danach fort.' : 'OpenClaw führt die Aktion beim Fortsetzen aus.' }}
</span>
</div>
<div class="wizard-actions">
<button
type="button"
class="nexus-button"
:disabled="wizard.busy"
@click="refreshWizard"
>
<Loader2 v-if="wizard.action === 'refresh'" :size="14" class="spin" aria-hidden="true" />
<RefreshCw v-else :size="14" aria-hidden="true" />
Status prüfen
</button>
<button
type="button"
class="nexus-button nexus-button--danger"
:disabled="wizard.busy"
@click="cancelWizard"
>
<Loader2 v-if="wizard.action === 'cancel'" :size="14" class="spin" aria-hidden="true" />
<XCircle v-else :size="14" aria-hidden="true" />
Abbrechen
</button>
<button
v-if="!step.sensitive && step.canAnswer"
type="button"
class="nexus-button nexus-button--primary wizard-primary"
:disabled="!canSubmit"
@click="submitStep"
>
<Loader2 v-if="wizard.action === 'next'" :size="14" class="spin" aria-hidden="true" />
<Check v-else :size="14" aria-hidden="true" />
{{ continueLabel }}
</button>
</div>
</article>
<div v-else-if="result.done" class="wizard-finished" role="status">
<Check v-if="result.status === 'done'" :size="20" aria-hidden="true" />
<XCircle v-else :size="20" aria-hidden="true" />
<div>
<strong>{{ result.status === 'done' ? 'OpenClaw-Wizard abgeschlossen' : 'OpenClaw-Wizard beendet' }}</strong>
<p>{{ result.error || result.message }}</p>
<p v-if="result.recovery">{{ result.recovery }}</p>
</div>
<button type="button" class="nexus-button" @click="wizard.clearCompleted()">
<RotateCcw :size="14" aria-hidden="true" />
Zur Startansicht
</button>
</div>
</template>
<div
v-if="combinedError"
ref="errorAlert"
class="wizard-notice wizard-notice--error"
role="alert"
tabindex="-1"
>
<CircleAlert :size="17" aria-hidden="true" />
<div>
<strong>Wizard-Aktion fehlgeschlagen</strong>
<p>{{ combinedError }}</p>
</div>
</div>
<p class="nexus-visually-hidden" aria-live="polite" aria-atomic="true">
{{ wizard.announcement }}
</p>
</section>
</template>
<style scoped>
.wizard-panel {
display: grid;
gap: 14px;
padding: 15px;
border: 1px solid var(--line);
border-radius: var(--r);
background: var(--accent-wash);
}
.wizard-header,
.wizard-title,
.wizard-actions,
.wizard-finished,
.wizard-device {
display: flex;
align-items: center;
gap: 12px;
}
.wizard-header {
align-items: flex-start;
justify-content: space-between;
}
.wizard-title {
align-items: flex-start;
min-width: 0;
}
.wizard-icon {
width: 38px;
height: 38px;
display: grid;
place-items: center;
flex: 0 0 38px;
border: 1px solid var(--line-2);
border-radius: var(--r-sm);
background: var(--grad-soft);
color: var(--a-mid);
}
.wizard-title h3,
.wizard-step h4 {
margin: 3px 0 0;
color: var(--tx);
font-family: var(--font-display);
}
.wizard-title h3 {
font-size: 15px;
}
.wizard-title p,
.wizard-step header p,
.wizard-notice p,
.wizard-finished p {
margin: 5px 0 0;
color: var(--tx-2);
line-height: 1.55;
}
.wizard-boundary,
.wizard-action-note,
.wizard-progress {
display: flex;
align-items: center;
gap: 10px;
min-height: 40px;
padding: 9px 11px;
border: 1px solid var(--line);
border-radius: var(--r-sm);
background: var(--glass);
color: var(--tx-2);
}
.wizard-boundary strong,
.wizard-step-type,
.wizard-session strong,
.wizard-device > div > span {
color: var(--tx-3);
font-family: var(--font-mono-v2);
font-size: 10px;
letter-spacing: .05em;
text-transform: uppercase;
}
.wizard-start,
.wizard-step,
.wizard-options {
display: grid;
gap: 12px;
}
.wizard-start fieldset,
.wizard-options {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.wizard-start legend,
.wizard-options legend,
.wizard-text > span {
margin-bottom: 7px;
color: var(--tx-3);
font-weight: 700;
}
.wizard-choice,
.wizard-confirm {
min-height: 44px;
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: var(--r-sm);
background: var(--glass);
color: var(--tx-2);
cursor: pointer;
}
.wizard-choice + .wizard-choice {
margin-top: 7px;
}
.wizard-choice:has(input:checked) {
border-color: color-mix(in srgb, var(--a-blue) 48%, var(--line));
background: var(--accent-wash-strong);
}
.wizard-choice input,
.wizard-confirm input {
margin: 3px 0 0;
flex: 0 0 auto;
}
.wizard-choice span {
display: grid;
gap: 3px;
}
.wizard-choice small,
.wizard-text small,
.wizard-device small {
color: var(--tx-3);
line-height: 1.45;
}
.wizard-primary {
justify-self: start;
min-height: 44px;
}
.wizard-session {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.wizard-session span {
min-width: 0;
display: grid;
gap: 4px;
padding: 9px 10px;
border: 1px solid var(--line);
border-radius: var(--r-sm);
background: var(--glass);
}
.wizard-session code {
overflow-wrap: anywhere;
color: var(--tx-2);
font-family: var(--font-mono-v2);
font-size: 11px;
}
.wizard-step {
padding: 14px;
border: 1px solid var(--line-2);
border-radius: var(--r);
background: var(--glass);
}
.wizard-step h4 {
font-size: 17px;
outline: none;
}
.wizard-step h4:focus-visible {
border-radius: 4px;
box-shadow: var(--focus-ring);
}
.wizard-external {
min-height: 44px;
justify-self: start;
max-width: 100%;
}
.wizard-external span {
max-width: 280px;
overflow: hidden;
color: var(--tx-3);
font-family: var(--font-mono-v2);
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.wizard-device {
justify-content: space-between;
padding: 12px;
border: 1px solid var(--status-queue-line);
border-radius: var(--r-sm);
background: var(--status-queue-bg);
}
.wizard-device > div {
min-width: 0;
display: grid;
gap: 5px;
}
.wizard-device code {
overflow-wrap: anywhere;
color: var(--tx);
font-family: var(--font-mono-v2);
font-size: 17px;
letter-spacing: .08em;
user-select: all;
}
.wizard-text {
display: grid;
gap: 6px;
}
.wizard-text input {
width: 100%;
min-height: 44px;
}
.wizard-text-error {
color: var(--st-block) !important;
}
.wizard-options--inline {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.wizard-options--inline legend {
grid-column: 1 / -1;
}
.wizard-options--inline .wizard-choice {
margin: 0;
}
.wizard-progress,
.wizard-action-note {
align-items: flex-start;
}
.wizard-action-note {
display: grid;
gap: 4px;
}
.wizard-actions {
justify-content: flex-end;
flex-wrap: wrap;
padding-top: 2px;
}
.wizard-actions .nexus-button {
min-height: 44px;
}
.wizard-notice {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 10px;
padding: 11px 12px;
border: 1px solid var(--line);
border-radius: var(--r-sm);
}
.wizard-notice--warning {
border-color: var(--status-queue-line);
background: var(--status-queue-bg);
color: var(--st-queue);
}
.wizard-notice--blocked,
.wizard-notice--error {
border-color: var(--status-block-line);
background: var(--status-block-bg);
color: var(--st-block);
}
.wizard-notice:focus {
outline: 2px solid var(--a-blue);
outline-offset: 2px;
}
.wizard-finished {
align-items: flex-start;
padding: 13px;
border: 1px solid var(--status-work-line);
border-radius: var(--r-sm);
background: var(--status-work-bg);
color: var(--st-work);
}
.wizard-finished > div {
min-width: 0;
flex: 1;
}
.spin {
animation: wizard-spin 1s linear infinite;
}
@keyframes wizard-spin {
to { transform: rotate(360deg); }
}
@media (max-width: 760px) {
.wizard-header,
.wizard-device,
.wizard-finished {
align-items: stretch;
flex-direction: column;
}
.wizard-header .nexus-status,
.wizard-device .nexus-button,
.wizard-finished .nexus-button {
align-self: flex-start;
}
.wizard-session,
.wizard-options--inline {
grid-template-columns: 1fr;
}
.wizard-actions {
align-items: stretch;
flex-direction: column-reverse;
}
.wizard-actions .nexus-button {
width: 100%;
}
.wizard-external span {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.spin {
animation: none;
}
}
</style>
+14 -5
View File
@@ -1,5 +1,12 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick, computed } from 'vue'
import {
ref,
onMounted,
onUnmounted,
nextTick,
computed,
type ComponentPublicInstance,
} from 'vue'
import AgentCard from './AgentCard.vue'
interface AgentData {
@@ -134,15 +141,17 @@ const pathElements = ref<Record<string, SVGPathElement | null>>({})
const pulseElements = ref<Record<string, SVGPathElement | null>>({})
const pulseOffsets = ref<Record<string, number>>({})
type TemplateRefValue = Element | ComponentPublicInstance | null
function storePathRef(id: string) {
return (el: SVGPathElement | null) => {
pathElements.value[id] = el
return (el: TemplateRefValue) => {
pathElements.value[id] = el instanceof SVGPathElement ? el : null
}
}
function storePulseRef(id: string) {
return (el: SVGPathElement | null) => {
pulseElements.value[id] = el
return (el: TemplateRefValue) => {
pulseElements.value[id] = el instanceof SVGPathElement ? el : null
}
}
+13 -13
View File
@@ -7,18 +7,18 @@ const { toasts, remove } = useToast()
const typeConfig: Record<string, { icon: any; color: string; bg: string }> = {
success: {
icon: CheckCircle,
color: '#22c55e',
bg: 'rgba(34, 197, 94, 0.10)',
color: 'var(--st-work)',
bg: 'color-mix(in srgb, var(--st-work) 10%, transparent)',
},
error: {
icon: XCircle,
color: '#ef4444',
bg: 'rgba(239, 68, 68, 0.10)',
color: 'var(--st-block)',
bg: 'color-mix(in srgb, var(--st-block) 10%, transparent)',
},
info: {
icon: Info,
color: '#3b82f6',
bg: 'rgba(59, 130, 246, 0.10)',
color: 'var(--a-blue)',
bg: 'color-mix(in srgb, var(--a-blue) 10%, transparent)',
},
}
</script>
@@ -40,7 +40,7 @@ const typeConfig: Record<string, { icon: any; color: string; bg: string }> = {
<component :is="typeConfig[toast.type].icon" :size="18" />
</div>
<span class="toast-message">{{ toast.message }}</span>
<button class="toast-close" @click="remove(toast.id)" aria-label="Dismiss">
<button type="button" class="toast-close" @click="remove(toast.id)" aria-label="Dismiss">
<X :size="14" />
</button>
</div>
@@ -69,14 +69,14 @@ const typeConfig: Record<string, { icon: any; color: string; bg: string }> = {
padding: 12px 14px 12px 12px;
border-radius: 10px;
border: 1px solid color-mix(in srgb, var(--toast-color) 25%, transparent);
background: rgba(17, 20, 27, 0.92);
background: color-mix(in srgb, var(--space-2) 92%, transparent);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.4),
0 8px 32px color-mix(in srgb, var(--space-0) 40%, transparent),
inset 0 1px 0 color-mix(in srgb, var(--toast-color) 12%, transparent);
pointer-events: auto;
color: #e8eaf0;
color: var(--tx);
font-size: 12.5px;
line-height: 1.4;
}
@@ -106,15 +106,15 @@ const typeConfig: Record<string, { icon: any; color: string; bg: string }> = {
border: none;
border-radius: 6px;
background: transparent;
color: #6b7385;
color: var(--tx-3);
cursor: pointer;
opacity: 0.5;
transition: all 0.15s;
}
.toast-close:hover {
opacity: 1;
background: rgba(255, 255, 255, 0.06);
color: #e8eaf0;
background: color-mix(in srgb, var(--tx) 6%, transparent);
color: var(--tx);
}
/* Transition animations */
+4 -86
View File
@@ -1,89 +1,7 @@
/**
* Inline SVG icons for Nexus V2
* All stroke-based, currentColor, viewBox 0 0 24 24
* The dashboard detail modal still renders this one inline SVG fragment.
* Navigation icons live in the active Lucide-based AppSidebar.
*/
export const icons: Record<string, string> = {
grid: `<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>`,
cpu: `<rect x="5" y="5" width="14" height="14" rx="2"/><rect x="9" y="9" width="6" height="6" rx="1"/><path d="M9 2v3M15 2v3M9 19v3M15 19v3M2 9h3M2 15h3M19 9h3M19 15h3"/>`,
list: `<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/>`,
flow: `<circle cx="6" cy="6" r="2.5"/><circle cx="18" cy="6" r="2.5"/><circle cx="12" cy="18" r="2.5"/><path d="M7.5 7.5 11 16M16.5 7.5 13 16"/>`,
brain: `<path d="M9 3a3 3 0 0 0-3 3 3 3 0 0 0-1 5.8A3 3 0 0 0 8 17a3 3 0 0 0 4 1 3 3 0 0 0 4-1 3 3 0 0 0 3-5.2A3 3 0 0 0 18 6a3 3 0 0 0-3-3 3 3 0 0 0-3 1.5A3 3 0 0 0 9 3Z"/>`,
doc: `<path d="M14 3v5h5M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>`,
search: `<circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/>`,
server: `<rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/>`,
model: `<path d="M12 2 3 7l9 5 9-5-9-5ZM3 12l9 5 9-5M3 17l9 5 9-5"/>`,
activity: `<path d="M3 12h4l3 8 4-16 3 8h4"/>`,
coin: `<circle cx="12" cy="12" r="9"/><path d="M12 7v10M9.5 9.5h4a1.5 1.5 0 0 1 0 3h-3a1.5 1.5 0 0 0 0 3h4"/>`,
shield: `<path d="M12 3 5 6v5c0 4 3 7 7 9 4-2 7-5 7-9V6z"/>`,
alert: `<path d="M12 3 2 20h20zM12 9v5M12 17h.01"/>`,
send: `<path d="M22 2 11 13M22 2 15 22l-4-9-9-4z"/>`,
spark: `<path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2.5 2.5M15.5 15.5 18 18M18 6l-2.5 2.5M8.5 15.5 6 18"/>`,
expand: `<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>`,
bot: `<rect x="4" y="7" width="16" height="12" rx="3"/><path d="M12 7V4M9 13h.01M15 13h.01M8 19v2M16 19v2"/>`,
clock: `<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>`,
target: `<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1.5"/>`,
arrow: `<path d="M5 12h14M13 6l6 6-6 6"/>`,
plus: `<path d="M12 5v14M5 12h14"/>`,
command: `<path d="M7 4a3 3 0 0 0-3 3v10a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V7a3 3 0 0 0-3-3H7z"/><path d="M12 8v8M8 12h8"/>`,
chevron_left: `<path d="m15 18-6-6 6-6"/>`,
chevron_right: `<path d="m9 18 6-6-6-6"/>`,
dots: `<circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/><circle cx="5" cy="12" r="1.5"/>`,
export const icons: Readonly<Record<'target', string>> = {
target: '<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1.5"/>',
}
export function svg(name: string, cls = ''): string {
const inner = icons[name]
if (!inner) return ''
return `<svg class="${cls}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${inner}</svg>`
}
export interface NavItemDef {
icon: string
label: string
route?: string
count?: string
active?: boolean
}
export interface NavGroupDef {
group: string
items: NavItemDef[]
}
/**
* Navigation structure matching NEXUS.nav from agents.js
*/
export const navigation: NavGroupDef[] = [
{
group: 'Operations',
items: [
{ icon: 'grid', label: 'Dashboard', route: '/dashboard', active: true },
{ icon: 'cpu', label: 'Agenten', route: '/agents' },
{ icon: 'list', label: 'Task Board', route: '/tasks' },
{ icon: 'flow', label: 'Orchestrierung', route: '/orchestration' },
],
},
{
group: 'Knowledge',
items: [
{ icon: 'brain', label: 'Memory', route: '/memory' },
{ icon: 'doc', label: 'Docs & .md', route: '/docs' },
{ icon: 'search', label: 'Research', route: '/research' },
],
},
{
group: 'Infrastructure',
items: [
{ icon: 'server', label: 'Hosts · OpenClaw', route: '/hosts' },
{ icon: 'model', label: 'Modelle', route: '/models' },
{ icon: 'activity', label: 'Activity Log', route: '/activity' },
],
},
{
group: 'Governance',
items: [
{ icon: 'coin', label: 'Kosten & Tokens', route: '/costs' },
{ icon: 'shield', label: 'Security', route: '/security' },
{ icon: 'alert', label: 'Incidents', route: '/incidents' },
],
},
]
+1 -25
View File
@@ -1,5 +1,4 @@
import { ref } from 'vue'
import { extraAgentPool } from './useFlowLayout'
import type { AgentNodeData } from './useFlowLayout'
interface FlowBoardAgentStore {
@@ -9,10 +8,6 @@ interface FlowBoardAgentStore {
selectAgent: (id: string | null) => void
}
interface FlowBoardChatStore {
sendMessage: (text: string) => void
}
const STORAGE_KEY = 'nexus-flow-positions'
function readStoredPositions() {
@@ -25,10 +20,9 @@ function readStoredPositions() {
}
}
export function useFlowBoardState(agentStore: FlowBoardAgentStore, chatStore: FlowBoardChatStore) {
export function useFlowBoardState(agentStore: FlowBoardAgentStore) {
const agentPositions = ref<Record<string, { x: number; y: number }>>(readStoredPositions())
const enteringIds = ref<string[]>([])
const localAgentPool = ref<AgentNodeData[]>([...extraAgentPool])
function selectAgent(id: string) {
agentStore.selectAgent(id)
@@ -44,18 +38,6 @@ export function useFlowBoardState(agentStore: FlowBoardAgentStore, chatStore: Fl
agentStore.changeModel(agentId, modelId)
}
function addAgent() {
const next = localAgentPool.value.shift()
if (!next) return
enteringIds.value = [...enteringIds.value, next.id]
agentStore.agents.push(next)
window.setTimeout(() => {
enteringIds.value = enteringIds.value.filter(id => id !== next.id)
}, 600)
}
function resetLayout() {
agentPositions.value = {}
if (typeof window !== 'undefined') window.localStorage.removeItem(STORAGE_KEY)
@@ -68,19 +50,13 @@ export function useFlowBoardState(agentStore: FlowBoardAgentStore, chatStore: Fl
}
}
function sendChatMessage(text: string) {
chatStore.sendMessage(text)
}
return {
addAgent,
agentPositions,
changeModel,
closeAgent,
enteringIds,
resetLayout,
selectAgent,
sendChatMessage,
updatePositions,
}
}
+7 -85
View File
@@ -21,12 +21,12 @@ export interface AgentNodeData {
statusLabel: string
task: string | null
goal: string | null
progress: number
elapsed: string
next: string
tokens: string
cost: string
think: string | null
progress: number | null
elapsed: string | null
next: string | null
tokens: string | null
cost: string | null
statusDetail: string | null
handoff?: string
from?: string
links?: string[]
@@ -52,7 +52,7 @@ export function autoLayout(agents: AgentNodeData[]): Record<string, Point> {
const maxPerRow = n <= 2 ? 2 : n <= 6 ? 3 : n <= 9 ? 3 : 4
const numRows = Math.ceil(n / maxPerRow)
const yStart = n <= 3 ? 58 : 30
const yStart = n <= 3 ? 58 : 40
const yEnd = 86
const yVals = numRows === 1
? [yStart]
@@ -116,81 +116,3 @@ export function curve(p1: Point, p2: Point): string {
const cy = my + (dx / len) * off
return `M${p1.x},${p1.y} Q${cx},${cy} ${p2.x},${p2.y}`
}
/**
* Extra agents that can be added to the FlowBoard dynamically
*/
export const extraAgentPool: AgentNodeData[] = [
{
id: 'qa',
name: 'QA Automator',
role: 'Test Automation',
roleBadge: 'badge-cyan',
avatar: 'QA',
status: 'idle',
statusLabel: 'Bereit',
task: 'End-to-End Tests schreiben',
goal: '100% Coverage für auth/',
progress: 0,
elapsed: '—',
next: 'Testplan erstellen',
model: 'Deepseek V4 Flash',
tokens: '0',
cost: '0.00',
think: null,
},
{
id: 'devops',
name: 'DevOps',
role: 'CI/CD Pipeline',
roleBadge: 'badge-amber',
avatar: 'DO',
status: 'idle',
statusLabel: 'Bereit',
task: 'GitHub Actions Workflow',
goal: 'Automatisches Deploy auf merge',
progress: 0,
elapsed: '—',
next: 'Pipeline konfigurieren',
model: 'Deepseek V4 Pro',
tokens: '0',
cost: '0.00',
think: null,
},
{
id: 'security',
name: 'Security Scanner',
role: 'Security Analysis',
roleBadge: 'badge-rose',
avatar: 'SC',
status: 'think',
statusLabel: 'Scannt',
task: 'Dependency-Audit durchführen',
goal: 'CVEs in api/ aufdecken',
progress: 18,
elapsed: '00:01:44',
next: 'Report an Iris',
model: 'Deepseek V4 Pro',
tokens: '9k',
cost: '0.18',
think: 'Analysiere package-lock.json auf bekannte Vulnerabilities…',
},
{
id: 'pm',
name: 'Project Manager',
role: 'Coordination',
roleBadge: 'badge-purple',
avatar: 'PM',
status: 'think',
statusLabel: 'Plant',
task: 'Sprint-Retrospektive vorbereiten',
goal: 'Blockers identifizieren',
progress: 35,
elapsed: '00:05:10',
next: 'Meeting-Summary an Team',
model: 'Deepseek V4 Flash',
tokens: '14k',
cost: '0.24',
think: 'Analysiere Velocity-Daten der letzten 3 Sprints…',
},
]
-95
View File
@@ -1,95 +0,0 @@
import type { AgentNodeData } from '../types/agentNode'
export const TASK_AGENT_OPTIONS = [
{ id: '', label: 'Nicht zugewiesen' },
{ id: 'bao', label: '👤 Bao' },
{ id: 'iris', label: '🤖 Iris' },
{ id: 'product-owner', label: '📋 Product Owner' },
{ id: 'programmer', label: '🛠 Programmer' },
{ id: 'programmer-fast', label: '⚡ Programmer Fast' },
{ id: 'reviewer', label: '🔎 Reviewer' },
{ id: 'architekt', label: '🏛 Architekt' },
{ id: 'researcher', label: '🔬 Researcher' },
{ id: 'executor', label: '🚀 Executor' },
] as const
export const TASK_AGENT_LABELS: Record<string, string> = Object.fromEntries(
TASK_AGENT_OPTIONS
.filter(option => option.id)
.map(option => [option.id, option.label])
) as Record<string, string>
export const EXTRA_AGENT_POOL: AgentNodeData[] = [
{
id: 'qa',
name: 'QA Automator',
role: 'Test Automation',
roleBadge: 'badge-cyan',
avatar: 'QA',
status: 'idle',
statusLabel: 'Bereit',
task: 'End-to-End Tests schreiben',
goal: '100% Coverage für auth/',
progress: 0,
elapsed: '—',
next: 'Testplan erstellen',
model: 'Deepseek V4 Flash',
tokens: '0',
cost: '0.00',
think: null,
},
{
id: 'devops',
name: 'DevOps',
role: 'CI/CD Pipeline',
roleBadge: 'badge-amber',
avatar: 'DO',
status: 'idle',
statusLabel: 'Bereit',
task: 'GitHub Actions Workflow',
goal: 'Automatisches Deploy auf merge',
progress: 0,
elapsed: '—',
next: 'Pipeline konfigurieren',
model: 'Deepseek V4 Pro',
tokens: '0',
cost: '0.00',
think: null,
},
{
id: 'security',
name: 'Security Scanner',
role: 'Security Analysis',
roleBadge: 'badge-rose',
avatar: 'SC',
status: 'think',
statusLabel: 'Scannt',
task: 'Dependency-Audit durchführen',
goal: 'CVEs in api/ aufdecken',
progress: 18,
elapsed: '00:01:44',
next: 'Report an Iris',
model: 'Deepseek V4 Pro',
tokens: '9k',
cost: '0.18',
think: 'Analysiere package-lock.json auf bekannte Vulnerabilities…',
},
{
id: 'pm',
name: 'Project Manager',
role: 'Coordination',
roleBadge: 'badge-purple',
avatar: 'PM',
status: 'think',
statusLabel: 'Plant',
task: 'Sprint-Retrospektive vorbereiten',
goal: 'Blockers identifizieren',
progress: 35,
elapsed: '00:05:10',
next: 'Meeting-Summary an Team',
model: 'Deepseek V4 Flash',
tokens: '14k',
cost: '0.24',
think: 'Analysiere Velocity-Daten der letzten 3 Sprints…',
},
]
+42 -14
View File
@@ -5,14 +5,29 @@
* Sidebar (248px) + Main (flex:1, flex-column)
* Mobile: Sidebar als Overlay mit Hamburger-Toggle
*/
import { ref } from 'vue'
import { RouterView } from 'vue-router'
import { useDashboardStore } from '../stores/dashboard'
import { computed, ref } from 'vue'
import { RouterView, useRoute } from 'vue-router'
import { useTaskBoard } from '../api/taskBoard'
import { useOpenClawOverviewQuery } from '../api/openclawRuntime'
import GalaxyBackground from '../components/background/GalaxyBackground.vue'
import Sidebar from '../components/layout/Sidebar.vue'
import AppSidebar from '../components/layout/AppSidebar.vue'
import Topbar from '../components/layout/Topbar.vue'
import { useAuthStore } from '../stores/auth'
import { useMissionControlUiStore } from '../stores/missionControlUi'
const dashboardStore = useDashboardStore()
const taskBoard = useTaskBoard(50)
const overviewQuery = useOpenClawOverviewQuery()
const missionControlUi = useMissionControlUiStore()
const auth = useAuthStore()
const route = useRoute()
const activeView = computed(() => String(route.name ?? 'Dashboard'))
const queuedTasks = computed(() =>
taskBoard.board.value.offen.length
+ taskBoard.board.value.inProgress.length
+ taskBoard.board.value.review.length
+ taskBoard.board.value.blocked.length,
)
/* ── Mobile Sidebar State ───────────────────────── */
const mobileMenuOpen = ref(false)
@@ -20,28 +35,39 @@ const mobileMenuOpen = ref(false)
function closeMobileMenu() {
mobileMenuOpen.value = false
}
</script>
<template>
<div class="nexus-layout">
<GalaxyBackground />
<Sidebar
:mobile-open="mobileMenuOpen"
@close="closeMobileMenu"
<AppSidebar
:active-view="activeView"
:mobile-nav-open="mobileMenuOpen"
:queued-tasks="queuedTasks"
:incidents="0"
@navigate="closeMobileMenu"
/>
<!-- Mobile Backdrop -->
<div
<button
v-if="mobileMenuOpen"
type="button"
class="mobile-backdrop"
aria-label="Close navigation"
@click="closeMobileMenu"
></div>
></button>
<main class="nexus-main">
<Topbar
:connected="dashboardStore.isGatewayConnected"
:status-label="dashboardStore.irisStatusLabel"
:connected="overviewQuery.data.value?.connection.connected ?? false"
:status-label="overviewQuery.data.value?.connection.gatewayVersion ? `OpenClaw ${overviewQuery.data.value.connection.gatewayVersion}` : 'OpenClaw status pending'"
:iris-chat-open="missionControlUi.irisOpen"
:command-open="missionControlUi.commandOpen"
:can-open-iris="auth.isOwner"
@toggle-sidebar="mobileMenuOpen = !mobileMenuOpen"
@open-command="missionControlUi.openCommand"
@open-iris="missionControlUi.openIris"
/>
<div class="nexus-content">
<RouterView />
@@ -78,7 +104,7 @@ function closeMobileMenu() {
display: none;
}
@media (max-width: 767px) {
@media (max-width: 900px) {
.nexus-main {
width: 100%;
}
@@ -88,7 +114,9 @@ function closeMobileMenu() {
position: fixed;
inset: 0;
z-index: 99;
background: rgba(0, 0, 0, 0.5);
padding: 0;
border: 0;
background: color-mix(in srgb, var(--space-0) 50%, transparent);
}
}
</style>
+9
View File
@@ -1,10 +1,15 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { VueQueryPlugin } from '@tanstack/vue-query'
import App from './App.vue'
import router from './router'
import { queryClient } from './api/queryClient'
import { useAuthStore } from './stores/auth'
import { startBrowserTelemetry } from './services/browserTelemetry'
import { startDomainEventSync } from './services/domainEvents'
import './assets/main.css'
import './assets/nexus-tokens.css'
import './assets/nexus-components.css'
const pinia = createPinia()
@@ -25,5 +30,9 @@ router.beforeEach(async to => {
createApp(App)
.use(pinia)
.use(VueQueryPlugin, { queryClient })
.use(router)
.mount('#app')
startBrowserTelemetry(router)
startDomainEventSync(router)
-127
View File
@@ -1,127 +0,0 @@
import type { AgentNodeData } from '../types/agentNode'
import type { AgentDetailData, ThinkingItem } from '../types/agentDetail'
import type { DashboardAgentDto, ModelDto } from '../services/agentService'
const STATUS_LABELS: Record<AgentNodeData['status'], string> = {
work: 'Arbeitet',
think: 'Plant',
idle: 'Bereit',
block: 'Blockiert',
}
interface CatalogEntry {
elapsed: string
think: string | null
next: string
}
const AGENT_CATALOG: Record<string, CatalogEntry> = {
iris: { elapsed: '--', think: null, next: 'Standby' },
'product-owner': { elapsed: '--', think: null, next: 'Standby' },
programmer: { elapsed: '--', think: null, next: 'Standby' },
'programmer-fast': { elapsed: '--', think: null, next: 'Standby' },
developer: { elapsed: '--', think: null, next: 'Standby' },
architekt: { elapsed: '--', think: null, next: 'Standby' },
reviewer: { elapsed: '--', think: null, next: 'Standby' },
executor: { elapsed: '--', think: null, next: 'Standby' },
researcher: { elapsed: '--', think: null, next: 'Standby' },
}
function resolveStatus(isActive: boolean, currentTask: string | null): AgentNodeData['status'] {
if (!isActive) return 'idle'
if (currentTask && currentTask !== 'Idle') return 'work'
return 'think'
}
function resolveAvatar(id: string, name: string): string {
if (id === 'iris') return 'IR'
if (id === 'product-owner') return 'PO'
if (id === 'programmer' || id === 'developer') return '</>'
if (id === 'programmer-fast') return 'PF'
return name.slice(0, 2).toUpperCase()
}
function buildThinkingItems(data: AgentNodeData): ThinkingItem[] {
if (!data.think) return []
const now = new Date()
const ts = (ago: number) => {
const d = new Date(now.getTime() - ago * 1000)
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
const sentences = data.think.split(/[.…!?]+/).filter(s => s.trim().length > 5)
if (sentences.length >= 2) {
const items: ThinkingItem[] = [
{ type: 'thought', text: sentences[0].trim() + '.', ts: ts(30) },
{ type: 'action', text: sentences[1].trim() + '…', ts: ts(18) },
]
const lastSentence = sentences.length >= 3
? sentences[sentences.length - 1].trim() + '.'
: 'Verarbeitung abgeschlossen.'
items.push({ type: 'result', text: lastSentence, ts: ts(3) })
return items
}
if (sentences.length === 1) {
return [
{ type: 'thought', text: sentences[0].trim(), ts: ts(15) },
{ type: 'action', text: 'Analysiere Daten und erstelle nächsten Schritt…', ts: ts(6) },
]
}
return [{ type: 'thought', text: data.think, ts: ts(10) }]
}
export function toAgentNode(dto: DashboardAgentDto): AgentNodeData {
const cat = AGENT_CATALOG[dto.id] ?? AGENT_CATALOG['reviewer']!
const status = resolveStatus(dto.isActive, dto.currentTask)
return {
id: dto.id,
name: dto.name,
role: dto.role,
model: dto.model,
avatar: resolveAvatar(dto.id, dto.name),
status,
statusLabel: STATUS_LABELS[status],
task: dto.currentTask,
goal: dto.goal ?? null,
progress: dto.progress ?? 0,
elapsed: cat.elapsed,
next: cat.next,
tokens: '0',
cost: '0.00',
think: cat.think,
}
}
export function toModelAlias(dtos: ModelDto[]): { id: string; alias: string }[] {
return dtos.map(m => ({ id: m.id, alias: m.name }))
}
export function toAgentDetail(
data: AgentNodeData,
models: { id: string; alias: string }[]
): AgentDetailData {
const tokenNum = parseFloat(data.tokens?.replace(/[^0-9.]/g, '') || '0')
const tokenMultiplier = data.tokens?.includes('M')
? 1_000_000
: data.tokens?.includes('k') ? 1_000 : 1
const tokensToday = Math.round(tokenNum * tokenMultiplier)
const matchingModel = models.find(m => m.id === data.model || m.alias === data.model)
const displayModel = matchingModel?.alias ?? data.model
return {
id: data.id,
name: data.name,
role: data.role,
model: displayModel,
status: data.status === 'block' ? 'idle' : data.status,
tokensToday,
costToday: parseFloat(data.cost || '0'),
workload: data.progress,
uptime: data.elapsed || '—',
lastActive: data.elapsed !== '—' ? 'Vor ' + data.elapsed : 'Nicht aktiv',
activeTaskCount: data.task ? 1 : 0,
thinking: buildThinkingItems(data),
availableModels: models,
}
}
-35
View File
@@ -1,35 +0,0 @@
import type { TaskItem } from '../types/task'
import type { TaskDto } from '../services/taskService'
function toPriority(raw: string): TaskItem['priority'] {
const p = raw.toLowerCase()
if (p === 'high' || p === 'critical' || p === 'urgent') return 'high'
if (p === 'low' || p === 'minor') return 'low'
return 'medium'
}
function toStatus(raw: string): TaskItem['status'] {
const s = raw.toLowerCase()
if (s === 'in progress' || s === 'active' || s === 'working') return 'active'
if (s === 'blocked' || s === 'block') return 'blocked'
return 'pending'
}
function toProgress(raw: string): number {
const s = raw.toLowerCase()
if (s === 'in progress' || s === 'active' || s === 'working') return 50
if (s === 'done') return 100
if (s === 'blocked') return 30
return 0
}
export function toTaskItem(dto: TaskDto): TaskItem {
return {
id: dto.id,
title: dto.title,
agent: dto.assignedTo ?? '—',
priority: toPriority(dto.priority),
status: toStatus(dto.state),
progress: toProgress(dto.state),
}
}
+36 -18
View File
@@ -1,19 +1,29 @@
import { createRouter, createWebHistory } from 'vue-router'
import LoginView from './views/LoginView.vue'
import ProjectDetailView from './views/ProjectDetailView.vue'
import SettingsView from './views/SettingsView.vue'
import MemoryView from './views/MemoryView.vue'
import DocsView from './views/DocsView.vue'
import AgentDetailView from './views/AgentDetailView.vue'
import AgentsIndexView from './views/AgentsIndexView.vue'
import SecurityView from './views/SecurityView.vue'
import IncidentsView from './views/IncidentsView.vue'
import CalendarView from './views/CalendarView.vue'
import NexusLayout from './layouts/NexusLayout.vue'
import FlowBoard from './views/Dashboard/FlowBoard.vue'
import TaskBoardView from './views/TaskBoardView.vue'
import TaskDetailView from './views/TaskDetailView.vue'
import NotificationsView from './views/NotificationsView.vue'
// Route-level splitting keeps Mission Control's first visible result small;
// data views and their editors are loaded only when the operator opens them.
const NexusLayout = () => import('./layouts/NexusLayout.vue')
const FlowBoard = () => import('./views/Dashboard/FlowBoard.vue')
const ProjectsIndexView = () => import('./views/ProjectsIndexView.vue')
const ProjectDetailView = () => import('./views/ProjectDetailView.vue')
const SettingsView = () => import('./views/SettingsView.vue')
const MemoryView = () => import('./views/MemoryView.vue')
const DocsView = () => import('./views/DocsView.vue')
const AgentDetailView = () => import('./views/AgentDetailView.vue')
const AgentsIndexView = () => import('./views/AgentsIndexView.vue')
const AgentCreateView = () => import('./views/AgentCreateView.vue')
const AgentProposalDetailView = () => import('./views/AgentProposalDetailView.vue')
const SecurityView = () => import('./views/SecurityView.vue')
const IncidentsView = () => import('./views/IncidentsView.vue')
const CalendarView = () => import('./views/CalendarView.vue')
const TaskBoardView = () => import('./views/TaskBoardView.vue')
const TaskDetailView = () => import('./views/TaskDetailView.vue')
const NotificationsView = () => import('./views/NotificationsView.vue')
const RunControlView = () => import('./views/RunControlView.vue')
const ModelsView = () => import('./views/ModelsView.vue')
const ActivityView = () => import('./views/ActivityView.vue')
const RunDetailView = () => import('./views/RunDetailView.vue')
const routes = [
{ path: '/login', name: 'Login', component: LoginView, meta: { public: true } },
@@ -30,18 +40,26 @@ const routes = [
{ path: '/memory', name: 'Memory', component: MemoryView, meta: { standalone: true } },
{ path: '/docs', name: 'Docs', component: DocsView, meta: { standalone: true } },
{ path: '/agents/new', name: 'AgentCreate', component: AgentCreateView, meta: { standalone: true } },
{
path: '/agents/proposals/:proposalId',
name: 'AgentProposalDetail',
component: AgentProposalDetailView,
meta: { standalone: true },
},
{ path: '/agents/:id', name: 'AgentDetail', component: AgentDetailView, meta: { standalone: true } },
{ path: '/security', name: 'Security', component: SecurityView, meta: { standalone: true } },
{ path: '/incidents', name: 'Incidents', component: IncidentsView, meta: { standalone: true } },
{ path: '/calendar', name: 'Calendar', component: CalendarView, meta: { standalone: true } },
{ path: '/projects', name: 'Projects', component: { template: '' } },
{ path: '/projects', name: 'Projects', component: ProjectsIndexView, meta: { standalone: true } },
{ path: '/projects/:id', name: 'ProjectDetail', component: ProjectDetailView, meta: { standalone: true } },
{ path: '/tasks', name: 'Task Board', component: TaskBoardView, meta: { standalone: true } },
{ path: '/tasks/:id', name: 'TaskDetail', component: TaskDetailView, meta: { standalone: true } },
{ path: '/agents', name: 'Agents', component: AgentsIndexView, meta: { standalone: true } },
{ path: '/models', name: 'Models', component: { template: '' } },
{ path: '/activity', name: 'Activity', component: { template: '' } },
{ path: '/chat', name: 'Mobile Chat', component: { template: '' } },
{ path: '/runs', name: 'Run Control', component: RunControlView, meta: { standalone: true } },
{ path: '/runs/:id', name: 'RunDetail', component: RunDetailView, meta: { standalone: true } },
{ path: '/models', name: 'Models', component: ModelsView, meta: { standalone: true } },
{ path: '/activity', name: 'Activity', component: ActivityView, meta: { standalone: true } },
{ path: '/notifications', name: 'Notifications', component: NotificationsView, meta: { standalone: true } },
{ path: '/settings', name: 'Settings', component: SettingsView, meta: { standalone: true } },
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' },
+9 -3
View File
@@ -5,10 +5,16 @@ export async function apiFetch(input: RequestInfo | URL, init: RequestInit = {})
if (!auth.initialized) await auth.initialize()
const send = () => {
const headers = new Headers(init.headers)
// openapi-fetch supplies a fully built Request object. Preserve its
// Content-Type, idempotency and correlation headers before applying
// explicit overrides and the current auth token.
const headers = new Headers(
typeof Request !== 'undefined' && input instanceof Request
? input.headers
: undefined,
)
new Headers(init.headers).forEach((value, key) => headers.set(key, value))
if (auth.accessToken) headers.set('Authorization', `Bearer ${auth.accessToken}`)
if (auth.isIris) headers.set('X-Agent-Id', 'iris')
else if (auth.isBao) headers.set('X-Agent-Id', 'bao')
// Set Content-Type for JSON body requests — needed because fetch() defaults
// to text/plain for string bodies, which ASP.NET rejects for [FromBody] binding.
if (typeof init.body === 'string' && !headers.has('Content-Type')) {
+125
View File
@@ -0,0 +1,125 @@
import type { Router } from 'vue-router'
import { onCLS, onFCP, onINP, onLCP, onTTFB, type Metric } from 'web-vitals'
import { apiFetch } from './api'
type BrowserMetricName =
| 'CLS'
| 'FCP'
| 'INP'
| 'LCP'
| 'TTFB'
| 'board_content_visible'
| 'board_delta_painted'
| 'mutation_confirmed'
| 'agent_proposal_readback'
export interface BrowserMetricEnvelope {
name: BrowserMetricName
value: number
rating: 'good' | 'needs-improvement' | 'poor' | 'custom'
routeName: string
buildVersion: string
navigationType: string
liveMode: 'live' | 'polling' | 'unknown'
correlationId: string | null
}
const STANDARD_METRICS = new Set<BrowserMetricName>(['CLS', 'FCP', 'INP', 'LCP', 'TTFB'])
let activeRouter: Router | null = null
let started = false
function safeRouteName(): string {
const name = activeRouter?.currentRoute.value.name
return typeof name === 'string' && /^[A-Za-z0-9 _-]{1,80}$/.test(name) ? name : 'unknown'
}
async function report(envelope: BrowserMetricEnvelope): Promise<void> {
if (safeRouteName() === 'Login') return
try {
await apiFetch('/api/v1/telemetry/browser', {
method: 'POST',
keepalive: true,
body: JSON.stringify(envelope),
})
} catch {
// Telemetry must never alter user-visible behavior.
}
}
function fromWebVital(metric: Metric): BrowserMetricEnvelope | null {
if (!STANDARD_METRICS.has(metric.name as BrowserMetricName)) return null
return {
name: metric.name as BrowserMetricName,
value: metric.value,
rating: metric.rating,
routeName: safeRouteName(),
buildVersion: String(import.meta.env.VITE_BUILD_SHA || 'development').slice(0, 80),
navigationType: String(metric.navigationType || 'navigate').slice(0, 40),
liveMode: 'unknown',
correlationId: null,
}
}
export function startBrowserTelemetry(router: Router): void {
activeRouter = router
if (started || typeof window === 'undefined') return
started = true
const callback = (metric: Metric) => {
const envelope = fromWebVital(metric)
if (envelope) void report(envelope)
}
onCLS(callback)
onFCP(callback)
onINP(callback)
onLCP(callback)
onTTFB(callback)
}
export async function reportCustomMetric(
name: Exclude<BrowserMetricName, 'CLS' | 'FCP' | 'INP' | 'LCP' | 'TTFB'>,
value: number,
options: {
liveMode?: BrowserMetricEnvelope['liveMode']
correlationId?: string | null
} = {},
): Promise<void> {
if (!Number.isFinite(value) || value < 0) return
await report({
name,
value,
rating: 'custom',
routeName: safeRouteName(),
buildVersion: String(import.meta.env.VITE_BUILD_SHA || 'development').slice(0, 80),
navigationType: 'spa',
liveMode: options.liveMode ?? 'unknown',
correlationId: options.correlationId?.slice(0, 128) ?? null,
})
}
export function markPerformance(name: string): void {
if (typeof performance === 'undefined') return
performance.mark(`nexus:${name}`)
}
export async function measurePerformance(
metricName: Parameters<typeof reportCustomMetric>[0],
startMark: string,
endMark: string,
options?: Parameters<typeof reportCustomMetric>[2],
): Promise<number | null> {
if (typeof performance === 'undefined') return null
const start = `nexus:${startMark}`
const end = `nexus:${endMark}`
try {
const measurement = performance.measure(`nexus:${metricName}`, start, end)
await reportCustomMetric(metricName, measurement.duration, options)
return measurement.duration
} catch {
return null
} finally {
performance.clearMarks(start)
performance.clearMarks(end)
}
}
+182
View File
@@ -0,0 +1,182 @@
import type { Router } from 'vue-router'
import { queryClient, queryKeys } from '../api/queryClient'
import type { DomainEventDto, EntityType } from '../api/contracts'
import {
reconcileTaskBoardCard,
removeTaskBoardCardDelta,
} from '../api/taskBoard'
import { sseHub, type ParsedSseEvent, type SseConnectionState } from './sseHub'
export type { SseConnectionState } from './sseHub'
// The outbox sequence is global. Consuming the complete content-minimized
// stream prevents legitimate events from filtered-out channels looking like
// sequence gaps in the browser.
const STREAM_URL = '/api/v1/events'
let unsubscribe: (() => void) | null = null
let lastSequence = 0
let state: SseConnectionState = 'closed'
const listeners = new Set<(next: SseConnectionState) => void>()
const pendingInvalidations = new Set<string>()
let flushScheduled = false
function scheduleInvalidation(domain: string): void {
pendingInvalidations.add(domain)
if (flushScheduled) return
flushScheduled = true
window.setTimeout(async () => {
flushScheduled = false
const domains = [...pendingInvalidations]
pendingInvalidations.clear()
await Promise.all(domains.map(domain =>
queryClient.invalidateQueries({
queryKey: domain === 'openclaw-overview'
? queryKeys.openClawOverview()
: [domain],
}),
))
}, 80)
}
function scheduleFullResync(): void {
for (const domain of ['tasks', 'openclaw', 'projects', 'activity', 'notifications', 'incidents']) {
scheduleInvalidation(domain)
}
}
export function invalidateOpenClawRunEventQueries(entityId?: string): Promise<void> {
return queryClient.invalidateQueries({
predicate: query => {
const key = query.queryKey
if (key[0] !== 'openclaw' || key[1] !== 'runs') return false
if (!entityId) return true
const isList = key.length === 2
|| (typeof key[2] === 'object' && key[2] !== null)
if (isList) return true
return (key[2] === 'detail' || key[2] === 'history')
&& key[3] === entityId
},
})
}
export function invalidateOpenClawCronEventQueries(entityId?: string): Promise<void> {
return queryClient.invalidateQueries({
predicate: query => {
const key = query.queryKey
if (key[0] !== 'openclaw' || key[1] !== 'cron') return false
if (!entityId) return true
const isList = key.length === 2
|| (typeof key[2] === 'object' && key[2] !== null)
if (isList) return true
return (key[2] === 'detail' && key[3] === entityId)
|| (key[2] === 'runs' && key[3] === entityId)
},
})
}
function applyEvent(raw: ParsedSseEvent): void {
if (raw.event === 'heartbeat') return
const event = raw.data as Partial<DomainEventDto>
if (!event || typeof event !== 'object') return
window.dispatchEvent(new CustomEvent('nexus:domain-event', { detail: event }))
if (event.eventType === 'resync_required') {
if (typeof event.sequence === 'number') lastSequence = event.sequence
scheduleFullResync()
return
}
if (typeof event.sequence === 'number') {
if (lastSequence > 0 && event.sequence > lastSequence + 1) {
// The outbox sequence is global, so the missing event may belong to any
// domain rather than to the entity carried by this first later event.
scheduleFullResync()
}
lastSequence = Math.max(lastSequence, event.sequence)
}
if (!event.entity) return
const entityType = event.entity.type as EntityType
switch (entityType) {
case 'task':
if (event.entity.id && event.entity.id !== '*') {
if (event.eventType === 'task.deleted') {
removeTaskBoardCardDelta(event.entity.id)
} else {
void reconcileTaskBoardCard(event.entity.id)
}
void queryClient.invalidateQueries({
queryKey: queryKeys.task(event.entity.id),
})
} else {
scheduleInvalidation('tasks')
}
scheduleInvalidation('openclaw-overview')
break
case 'agent':
case 'agent-proposal':
scheduleInvalidation('openclaw')
break
case 'run':
void invalidateOpenClawRunEventQueries(
event.entity.id && event.entity.id !== '*' ? event.entity.id : undefined,
)
scheduleInvalidation('openclaw-overview')
break
case 'cron':
void invalidateOpenClawCronEventQueries(
event.entity.id && event.entity.id !== '*' ? event.entity.id : undefined,
)
scheduleInvalidation('openclaw-overview')
break
case 'project':
scheduleInvalidation('projects')
break
case 'notification':
scheduleInvalidation('notifications')
break
case 'activity':
scheduleInvalidation('activity')
break
case 'incident':
scheduleInvalidation('incidents')
break
}
}
function setState(next: SseConnectionState): void {
state = next
for (const listener of listeners) listener(next)
}
function start(): void {
if (unsubscribe) return
unsubscribe = sseHub.subscribe(STREAM_URL, applyEvent, {
lastEventId: lastSequence > 0 ? String(lastSequence) : null,
onStateChange: setState,
})
}
function stop(): void {
unsubscribe?.()
unsubscribe = null
setState('closed')
}
export function startDomainEventSync(router: Router): void {
const syncForRoute = () => {
if (router.currentRoute.value.meta.public) stop()
else start()
}
void router.isReady().then(syncForRoute)
router.afterEach(syncForRoute)
}
export function subscribeDomainEventState(listener: (next: SseConnectionState) => void): () => void {
listeners.add(listener)
listener(state)
return () => listeners.delete(listener)
}
export function invalidateTaskBoard(): Promise<void> {
return queryClient.invalidateQueries({ queryKey: queryKeys.taskBoard(50) })
}
-94
View File
@@ -1,94 +0,0 @@
import { apiFetch } from './api'
export type LiveEventName = 'snapshot' | 'update' | 'heartbeat'
export type LiveMode = 'live' | 'polling'
export interface LiveCursorDto {
sequence: number
timestamp: string
mode: LiveMode
}
export interface LiveUpdateEnvelope {
type: string
timestamp: string
payload: unknown
sequence: number
channel: string
}
export interface DashboardLiveEventDto {
envelope: LiveUpdateEnvelope
cursor: LiveCursorDto
}
export interface OpenDashboardLiveStreamResult {
closed: Promise<void>
}
export async function openDashboardLiveStream(
onEvent: (event: LiveEventName, data: unknown) => void,
options: { forUser?: string; notificationLimit?: number; afterSequence?: number | null; signal?: AbortSignal } = {},
): Promise<OpenDashboardLiveStreamResult> {
const params = new URLSearchParams({
forUser: options.forUser ?? 'bao',
notificationLimit: String(options.notificationLimit ?? 50),
})
if (typeof options.afterSequence === 'number' && Number.isFinite(options.afterSequence) && options.afterSequence > 0) {
params.set('afterSequence', String(options.afterSequence))
}
const response = await apiFetch(`/api/dashboard/live?${params}`, {
method: 'GET',
headers: { Accept: 'text/event-stream', 'Cache-Control': 'no-cache' },
signal: options.signal,
})
if (!response.ok || !response.body) {
throw new Error(`Live stream unavailable: HTTP ${response.status}`)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
const flushBlock = (block: string) => {
const lines = block.split('\n')
let eventName: LiveEventName = 'update'
const dataLines: string[] = []
for (const rawLine of lines) {
const line = rawLine.trimEnd()
if (line.startsWith('event:')) eventName = line.slice(6).trim() as LiveEventName
if (line.startsWith('data:')) dataLines.push(line.slice(5).trim())
}
if (!dataLines.length) return
try {
onEvent(eventName, JSON.parse(dataLines.join('\n')))
} catch (error) {
console.warn('[live] failed to parse event payload', error)
}
}
const closed = (async () => {
while (true) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const parts = buffer.split('\n\n')
buffer = parts.pop() ?? ''
for (const part of parts) {
if (part.trim()) flushBlock(part)
}
}
if (buffer.trim()) {
flushBlock(buffer)
buffer = ''
}
})()
return { closed }
}
+36
View File
@@ -0,0 +1,36 @@
export interface MutationRequestContext {
idempotencyKey: string
correlationId: string
traceparent: string
headers: Headers
}
function randomHex(length: number): string {
const bytes = crypto.getRandomValues(new Uint8Array(Math.ceil(length / 2)))
return Array.from(bytes, value => value.toString(16).padStart(2, '0'))
.join('')
.slice(0, length)
}
export function createMutationRequestContext(
operation: string,
stableKey?: string,
): MutationRequestContext {
const correlationId = crypto.randomUUID()
const idempotencyKey = stableKey?.trim()
? `${operation}:${stableKey.trim()}`
: `${operation}:${crypto.randomUUID()}`
const traceparent = `00-${randomHex(32)}-${randomHex(16)}-01`
const headers = new Headers({
'Idempotency-Key': idempotencyKey,
'X-Correlation-ID': correlationId,
traceparent,
})
return {
idempotencyKey,
correlationId,
traceparent,
headers,
}
}
+33
View File
@@ -0,0 +1,33 @@
import type { components } from '../api/generated/schema'
import {
normalizeOperationResult,
type OperationResultDto,
} from '../api/contracts'
import { useMissionControlUiStore } from '../stores/missionControlUi'
type GeneratedOperationResult = components['schemas']['OperationResultDto']
export function reportOperationResult(
value: GeneratedOperationResult | OperationResultDto | null | undefined,
title = 'Operation',
): OperationResultDto | null {
const result = normalizeOperationResult(value as GeneratedOperationResult | null | undefined)
if (result) useMissionControlUiStore().showOperation(result, title)
return result
}
export function reportOperationEnvelope(
value: unknown,
title = 'Operation',
): OperationResultDto | null {
if (!value || typeof value !== 'object' || !('operation' in value)) return null
const operation = (value as { operation?: unknown }).operation
if (!operation || typeof operation !== 'object') return null
const candidate = operation as Partial<GeneratedOperationResult>
if (
typeof candidate.operationId !== 'string'
|| typeof candidate.status !== 'string'
|| !Array.isArray(candidate.affectedRefs)
) return null
return reportOperationResult(candidate as GeneratedOperationResult, title)
}
+229
View File
@@ -0,0 +1,229 @@
import { createParser, type EventSourceMessage } from 'eventsource-parser'
import { apiFetch } from './api'
export type SseConnectionState = 'connecting' | 'open' | 'reconnecting' | 'closed' | 'unsupported' | 'error'
export interface ParsedSseEvent<T = unknown> {
id: string | null
event: string
data: T
}
export interface SseSubscriptionOptions {
lastEventId?: string | null
onStateChange?: (state: SseConnectionState) => void
}
type Subscriber = {
onEvent: (event: ParsedSseEvent) => void
onStateChange?: (state: SseConnectionState) => void
}
type Connection = {
url: string
subscribers: Set<Subscriber>
controller: AbortController | null
loop: Promise<void> | null
lastEventId: string | null
retryMs: number
stopped: boolean
}
const MAX_BUFFER_SIZE = 256 * 1024
const MAX_BACKOFF_MS = 30_000
const HEARTBEAT_TIMEOUT_MS = 45_000
function delay(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(signal.reason)
return
}
const timer = globalThis.setTimeout(resolve, ms)
signal.addEventListener('abort', () => {
globalThis.clearTimeout(timer)
reject(signal.reason)
}, { once: true })
})
}
function parseData(raw: string): unknown {
try {
return JSON.parse(raw)
} catch {
return raw
}
}
export class AuthenticatedSseHub {
private readonly connections = new Map<string, Connection>()
subscribe(
url: string,
onEvent: (event: ParsedSseEvent) => void,
options: SseSubscriptionOptions = {},
): () => void {
let connection = this.connections.get(url)
if (!connection) {
connection = {
url,
subscribers: new Set(),
controller: null,
loop: null,
lastEventId: options.lastEventId ?? null,
retryMs: 1_000,
stopped: false,
}
this.connections.set(url, connection)
} else if (!connection.lastEventId && options.lastEventId) {
connection.lastEventId = options.lastEventId
}
const subscriber: Subscriber = { onEvent, onStateChange: options.onStateChange }
connection.subscribers.add(subscriber)
connection.stopped = false
if (!connection.loop) connection.loop = this.run(connection)
return () => {
connection?.subscribers.delete(subscriber)
if (connection && connection.subscribers.size === 0) {
connection.stopped = true
connection.controller?.abort('no-subscribers')
this.connections.delete(url)
}
}
}
closeAll(): void {
for (const connection of this.connections.values()) {
connection.stopped = true
connection.controller?.abort('hub-closed')
this.notify(connection, 'closed')
}
this.connections.clear()
}
private notify(connection: Connection, state: SseConnectionState): void {
for (const subscriber of connection.subscribers) subscriber.onStateChange?.(state)
}
private dispatch(connection: Connection, event: ParsedSseEvent): void {
queueMicrotask(() => {
for (const subscriber of connection.subscribers) subscriber.onEvent(event)
})
}
private async waitBeforeReconnect(
connection: Connection,
state: SseConnectionState,
): Promise<void> {
if (connection.stopped || connection.subscribers.size === 0) return
this.notify(connection, state)
const controller = new AbortController()
connection.controller = controller
const jitter = Math.round(connection.retryMs * (0.8 + Math.random() * 0.4))
await delay(jitter, controller.signal).catch(() => undefined)
if (!connection.stopped) {
connection.retryMs = Math.min(connection.retryMs * 2, MAX_BACKOFF_MS)
}
}
private async run(connection: Connection): Promise<void> {
try {
while (!connection.stopped && connection.subscribers.size > 0) {
const controller = new AbortController()
connection.controller = controller
this.notify(connection, connection.lastEventId ? 'reconnecting' : 'connecting')
try {
const headers = new Headers({
Accept: 'text/event-stream',
'Cache-Control': 'no-cache',
})
if (connection.lastEventId) headers.set('Last-Event-ID', connection.lastEventId)
const response = await apiFetch(connection.url, { headers, signal: controller.signal })
if (response.status === 404 || response.status === 501) {
this.notify(connection, 'unsupported')
await delay(60_000, controller.signal)
continue
}
if (!response.ok || !response.body) {
throw new Error(`SSE unavailable: HTTP ${response.status}`)
}
let parserFailure: Error | null = null
let serverDirectedRetry = false
const parser = createParser({
maxBufferSize: MAX_BUFFER_SIZE,
onRetry: retry => {
serverDirectedRetry = true
connection.retryMs = Math.min(Math.max(retry, 500), MAX_BACKOFF_MS)
},
onError: error => {
if (error.type === 'max-buffer-size-exceeded') {
parserFailure = error
controller.abort(error)
}
},
onEvent: (message: EventSourceMessage) => {
if (!serverDirectedRetry) connection.retryMs = 1_000
if (message.id) connection.lastEventId = message.id
this.dispatch(connection, {
id: message.id ?? null,
event: message.event || 'message',
data: parseData(message.data),
})
},
})
this.notify(connection, 'open')
const reader = response.body.getReader()
const decoder = new TextDecoder()
let heartbeatTimer: ReturnType<typeof globalThis.setTimeout> | null = null
const armHeartbeatTimeout = () => {
if (heartbeatTimer !== null) globalThis.clearTimeout(heartbeatTimer)
heartbeatTimer = globalThis.setTimeout(() => {
controller.abort(new Error('SSE heartbeat timeout'))
}, HEARTBEAT_TIMEOUT_MS)
}
armHeartbeatTimeout()
try {
while (!controller.signal.aborted) {
const { value, done } = await reader.read()
if (done) break
armHeartbeatTimeout()
parser.feed(decoder.decode(value, { stream: true }))
if (parserFailure) throw parserFailure
}
const tail = decoder.decode()
if (tail) parser.feed(tail)
parser.reset({ consume: true })
} finally {
if (heartbeatTimer !== null) globalThis.clearTimeout(heartbeatTimer)
reader.releaseLock()
}
// A clean EOF is still a disconnected live stream. Back off before
// reconnecting so a server that closes immediately cannot trigger a
// tight request loop.
if (!controller.signal.aborted) {
await this.waitBeforeReconnect(connection, 'reconnecting')
}
} catch (error) {
if (connection.stopped || controller.signal.reason === 'no-subscribers' || controller.signal.reason === 'hub-closed') break
await this.waitBeforeReconnect(connection, 'error')
}
}
} finally {
connection.controller = null
connection.loop = null
if (!connection.stopped && connection.subscribers.size > 0) {
connection.loop = this.run(connection)
}
}
}
}
export const sseHub = new AuthenticatedSseHub()
+58 -258
View File
@@ -1,158 +1,60 @@
/**
* Agent Store V2 Dashboard
* Dashboard-local agent UI state and agent mutations.
*
* Fetches agents from /api/dashboard/agents and available models
* from /api/dashboard/models. Enriches raw API data with catalog
* metadata (color, icon, description, hero) and maps into
* AgentNodeData (for FlowCanvas) and AgentDetail (for Modal).
*
* Auto-refresh: every 30 seconds.
* Canonical agent, model and runtime reads live in the OpenClaw Vue Query
* boundary. This store intentionally owns only modal selection and the
* model-change command used by the orchestration canvas.
*/
import { defineStore } from 'pinia'
import { apiFetch } from '../services/api'
import type { AgentNodeData } from '../composables/useFlowLayout'
import type { AgentActivityItem, AgentDetailData, ThinkingItem } from '../components/dashboard/v2/types'
import type { AgentDetailData } from '../components/dashboard/v2/types'
import type { OpenClawOperation } from '../types/openclaw'
import { createMutationRequestContext } from '../services/mutationContext'
import { reportOperationEnvelope } from '../services/operationResults'
import { invalidateOpenClawRuntime } from '../api/openclawRuntime'
/* ── API Response Shapes ──────────────────────────── */
interface DashboardAgentInfo {
id: string
name: string
role: string
model: string
isActive: boolean
currentTask: string | null
description?: string
tags?: string[]
progress?: number
workload?: number
goal?: string | null
roleBadge?: string
statusLabel?: string
elapsed?: string | null
think?: string | null
next?: string | null
}
interface ModelOption {
id: string
name: string
provider: string
}
interface AgentActivityEntry {
time: string
text: string
}
/* ── Status Mapping ───────────────────────────────── */
function mapStatus(isActive: boolean, currentTask: string | null): AgentNodeData['status'] {
if (!isActive) return 'idle'
if (currentTask && currentTask !== 'Idle') return 'work'
return 'think'
}
const STATUS_LABELS: Record<AgentNodeData['status'], string> = {
work: 'Arbeitet',
think: 'Plant',
idle: 'Bereit',
block: 'Blockiert',
}
function avatarFor(id: string, name: string): string {
if (id === 'iris') return 'IR'
if (id === 'programmer' || id === 'developer') return '</>'
return name.slice(0, 2).toUpperCase()
}
/* ── Enrich API Agent → AgentNodeData ─────────────── */
function enrichAgent(api: DashboardAgentInfo): AgentNodeData {
const status = mapStatus(api.isActive, api.currentTask)
return {
id: api.id,
name: api.name,
role: api.role,
roleBadge: api.roleBadge ?? 'badge-slate',
model: api.model,
avatar: avatarFor(api.id, api.name),
status,
statusLabel: api.statusLabel ?? STATUS_LABELS[status],
task: api.currentTask,
goal: api.goal ?? null,
progress: api.progress ?? 0,
elapsed: api.elapsed ?? '--',
next: api.next ?? 'Standby',
tokens: '0',
cost: '0.00',
think: api.think ?? null,
}
}
/* ── Build AgentDetail from AgentNodeData ─────────── */
function buildThinkingItems(data: AgentNodeData): ThinkingItem[] {
if (!data.think) return []
const now = new Date()
const ts = (ago: number) => {
const d = new Date(now.getTime() - ago * 1000)
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
const sentences = data.think.split(/[.…!?]+/).filter(s => s.trim().length > 5)
const items: ThinkingItem[] = []
if (sentences.length >= 2) {
items.push({ type: 'thought', text: sentences[0].trim() + '.', ts: ts(30) })
items.push({ type: 'action', text: sentences[1].trim() + '…', ts: ts(18) })
if (sentences.length >= 3) {
items.push({ type: 'result', text: sentences[sentences.length - 1].trim() + '.', ts: ts(3) })
} else {
items.push({ type: 'result', text: 'Verarbeitung abgeschlossen.', ts: ts(3) })
}
} else if (sentences.length === 1) {
items.push({ type: 'thought', text: sentences[0].trim(), ts: ts(15) })
items.push({ type: 'action', text: 'Analysiere Daten und erstelle nächsten Schritt…', ts: ts(6) })
} else {
items.push({ type: 'thought', text: data.think, ts: ts(10) })
}
return items
}
export function buildAgentDetail(data: AgentNodeData, models: { id: string; alias: string }[]): AgentDetailData {
const tokenNum = parseFloat(data.tokens?.replace(/[^0-9.]/g, '') || '0')
const tokenMultiplier = data.tokens?.includes('M') ? 1_000_000 : data.tokens?.includes('k') ? 1_000 : 1
const tokensToday = Math.round(tokenNum * tokenMultiplier)
const costNum = parseFloat(data.cost || '0')
const progress = data.progress || 0
// Map model ID to display name for the modal dropdown (which uses alias for comparison)
const matchingModel = models.find(m => m.id === data.model || m.alias === data.model)
const displayModel = matchingModel?.alias ?? data.model
export function buildAgentDetail(
data: AgentNodeData,
models: { id: string; alias: string }[],
): AgentDetailData {
const tokenNum = data.tokens ? parseFloat(data.tokens.replace(/[^0-9.]/g, '')) : Number.NaN
const tokenMultiplier = data.tokens?.includes('M')
? 1_000_000
: data.tokens?.includes('k')
? 1_000
: 1
const tokensToday = Number.isFinite(tokenNum)
? Math.round(tokenNum * tokenMultiplier)
: null
const costNum = data.cost ? parseFloat(data.cost) : Number.NaN
const matchingModel = models.find(model =>
model.id === data.model || model.alias === data.model,
)
return {
id: data.id,
name: data.name,
role: data.role,
roleBadge: data.roleBadge || 'badge-slate',
model: displayModel,
model: matchingModel?.alias ?? data.model,
status: data.status === 'block' ? 'idle' : data.status,
statusLabel: data.statusLabel,
task: data.task,
goal: data.goal,
progress,
elapsed: data.elapsed || '—',
next: data.next || '—',
tokens: data.tokens || '0',
cost: data.cost || '0.00',
think: data.think,
progress: data.progress,
elapsed: data.elapsed,
next: data.next,
tokens: data.tokens,
cost: data.cost,
statusDetail: data.statusDetail,
md: data.md,
tokensToday,
costToday: costNum,
workload: progress,
uptime: data.elapsed || '—',
lastActive: data.elapsed !== '—' ? 'Vor ' + data.elapsed : 'Nicht aktiv',
costToday: Number.isFinite(costNum) ? costNum : null,
workload: null,
uptime: data.elapsed,
lastActive: data.elapsed ? `Vor ${data.elapsed}` : 'Nicht gemeldet',
activeTaskCount: data.task ? 1 : 0,
thinking: buildThinkingItems(data),
activity: [],
availableModels: models,
}
@@ -160,141 +62,39 @@ export function buildAgentDetail(data: AgentNodeData, models: { id: string; alia
export const useAgentStore = defineStore('agents', {
state: () => ({
agents: [] as AgentNodeData[],
models: [] as { id: string; alias: string }[],
loading: false,
error: null as string | null,
selectedAgentId: null as string | null,
activityByAgentId: {} as Record<string, AgentActivityItem[]>,
refreshInterval: null as ReturnType<typeof setInterval> | null,
isConnected: false,
error: null as string | null,
}),
getters: {
/** AgentNodeData list for FlowCanvas */
agentList: (state) => state.agents,
/** Agent IDs in display order (Iris first) */
agentOrder: (state) => {
const ordered = state.agents.filter(a => a.id === 'iris')
state.agents.forEach(a => { if (a.id !== 'iris') ordered.push(a) })
return ordered.map(a => a.id)
},
/** Selected agent detail for modal */
selectedAgent(state): AgentDetailData | null {
if (!state.selectedAgentId) return null
const data = state.agents.find(a => a.id === state.selectedAgentId)
if (!data) return null
return {
...buildAgentDetail(data, state.models),
activity: state.activityByAgentId[data.id] ?? [],
}
},
/** Is the modal open? */
modalOpen: (state) => state.selectedAgentId !== null,
/* ── AlertBar Metrics ────────────────────────── */
activeCount: (state) => state.agents.filter(a => a.status === 'work').length,
thinkCount: (state) => state.agents.filter(a => a.status === 'think').length,
idleCount: (state) => state.agents.filter(a => a.status === 'idle').length,
blockerCount: (state) => state.agents.filter(a => a.status === 'block').length,
todayCost: (state) => {
const total = state.agents.reduce((s, a) => s + parseFloat(a.cost || '0'), 0)
return '$' + total.toFixed(2)
},
todayTokens: (state) => {
const total = state.agents.reduce((s, a) => {
const raw = a.tokens?.replace(/[^0-9.]/g, '') || '0'
const v = parseFloat(raw)
return Number.isFinite(v) ? s + v : s
}, 0)
return total >= 1000 ? Math.round(total / 1000) + 'k' : Math.round(total) + ''
},
},
actions: {
/* ── API: Fetch agents ──────────────────────── */
async fetchAgents() {
try {
const res = await apiFetch('/api/dashboard/agents')
if (!res.ok) { this.isConnected = false; return }
const data: DashboardAgentInfo[] = await res.json()
this.agents = data.map(enrichAgent)
this.isConnected = true
} catch (err) {
this.isConnected = false
console.warn('[AgentStore] fetchAgents failed', err)
}
},
/* ── API: Fetch available models ────────────── */
async fetchModels() {
try {
const res = await apiFetch('/api/dashboard/models')
if (!res.ok) return
const data: ModelOption[] = await res.json()
this.models = data.map(m => ({ id: m.id, alias: m.name }))
} catch (err) {
console.warn('[AgentStore] fetchModels failed', err)
}
},
/* ── API: Change agent model ────────────────── */
async changeModel(agentId: string, modelId: string) {
// Optimistic update
const agent = this.agents.find(a => a.id === agentId)
if (agent) agent.model = modelId
this.error = null
try {
await apiFetch(`/api/dashboard/agents/${encodeURIComponent(agentId)}/model`, {
method: 'PUT',
body: JSON.stringify({ model: modelId }),
const requestContext = createMutationRequestContext('openclaw-session-model')
const response = await apiFetch('/api/v1/openclaw/sessions/model', {
method: 'POST',
headers: requestContext.headers,
body: JSON.stringify({
sessionKey: `agent:${agentId}:main`,
model: modelId,
}),
})
} catch (err) {
console.warn('[AgentStore] changeModel failed', err)
// Refetch to revert on failure
await this.fetchAgents()
const result: OpenClawOperation<Record<string, unknown>> = await response.json()
reportOperationEnvelope(result, 'Agent-Modell aktualisiert')
if (!response.ok || !result.ok) {
throw new Error(result.recovery || result.message)
}
await invalidateOpenClawRuntime()
} catch (error) {
console.warn('[AgentStore] changeModel failed', error)
this.error = error instanceof Error
? error.message
: 'OpenClaw model update failed'
}
},
async fetchAgentActivity(agentId: string) {
try {
const res = await apiFetch(`/api/dashboard/agents/${encodeURIComponent(agentId)}/activity?limit=5`)
if (!res.ok) return
const data: AgentActivityEntry[] = await res.json()
this.activityByAgentId[agentId] = data.map(entry => ({
time: entry.time,
text: entry.text,
}))
} catch (err) {
console.warn('[AgentStore] fetchAgentActivity failed', err)
}
},
/* ── Selection ───────────────────────────────── */
selectAgent(id: string | null) {
this.selectedAgentId = id
if (id) void this.fetchAgentActivity(id)
},
/* ── Polling ─────────────────────────────────── */
startPolling() {
if (this.refreshInterval) return
this.fetchAgents()
this.fetchModels()
this.refreshInterval = setInterval(() => {
this.fetchAgents()
this.fetchModels()
}, 15000)
},
stopPolling() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval)
this.refreshInterval = null
}
},
},
})
+12 -2
View File
@@ -35,6 +35,7 @@ export const useAuthStore = defineStore('auth', {
}),
getters: {
isAuthenticated: state => Boolean(state.accessToken && state.user),
isOwner: state => state.user?.role.toLowerCase() === 'owner',
isRateLimited: state => state.remainingAttempts === 0 && state.retryAfterSeconds > 0,
/** Returns true if the current web-ui user is Iris (JWT user identity matches "iris"). */
isIris: state => {
@@ -65,7 +66,12 @@ export const useAuthStore = defineStore('auth', {
this.retryAfterSeconds = 0
},
async initialize() {
if (this.initialized) return this.isAuthenticated
// Router guards can overlap during the initial navigation. Reuse the
// active refresh instead of treating the not-yet-applied session as an
// unauthenticated result.
if (this.initialized) {
return refreshInFlight ?? this.isAuthenticated
}
this.initialized = true
return this.refresh()
},
@@ -110,7 +116,11 @@ export const useAuthStore = defineStore('auth', {
} else if (response.status === 401) {
this.remainingAttempts = remaining
this.retryAfterSeconds = retryAfter
throw new LoginError(body.message as string || 'Invalid email or password.', remaining, retryAfter)
throw new LoginError(
body.message as string || 'Invalid email or password.',
remaining ?? 4,
retryAfter,
)
}
} catch (error) {
if (error instanceof LoginError) throw error
+104 -35
View File
@@ -1,14 +1,21 @@
/**
* Chat Store V2 Dashboard
*
* Fetches chat messages from /api/dashboard/chat/messages and
* sends new messages via /api/dashboard/chat/send.
* Fetches the OpenClaw-backed session history and sends new messages through
* the authenticated Nexus -> OpenClaw -> model runtime path.
*
* Auto-refresh: every 10 seconds (incoming Iris messages).
*/
import { defineStore } from 'pinia'
import { apiFetch } from '../services/api'
import type { ChatMessage } from '../components/dashboard/v2/types'
import type { MissionControlContext } from '../types/mission-control'
import { useOpenClawStore } from './openclaw'
import { createMutationRequestContext } from '../services/mutationContext'
import type { DomainEventDto, OperationResultDto } from '../api/contracts'
let domainEventListener: ((event: Event) => void) | null = null
let eventRefreshTimer: number | null = null
/* ── API Response Shapes ──────────────────────────── */
@@ -18,10 +25,14 @@ interface MessageEntry {
timestamp: string
}
interface ChatResponse {
ok: boolean
reply: string | null
error: string | null
interface AgentChatResponse {
runtime: string
agentId: string
conversationId: string
content: string
runId?: string | null
state?: string
operation?: OperationResultDto | null
}
export const useChatStore = defineStore('chat', {
@@ -32,6 +43,7 @@ export const useChatStore = defineStore('chat', {
refreshInterval: null as ReturnType<typeof setInterval> | null,
/** Tracks last process timestamp to avoid duplicates */
lastProcessedTs: 0,
conversationId: localStorage.getItem('nexus-iris-conversation-id') ?? `nexus-${crypto.randomUUID()}`,
}),
getters: {
@@ -81,7 +93,7 @@ export const useChatStore = defineStore('chat', {
},
/* ── API: Send message ──────────────────────── */
async sendMessage(text: string) {
async sendMessage(text: string, context?: MissionControlContext) {
if (!text.trim()) return
const tsFormatted = new Date().toLocaleTimeString('de-DE', {
@@ -100,37 +112,42 @@ export const useChatStore = defineStore('chat', {
this.error = null
try {
const res = await apiFetch('/api/dashboard/chat/send', {
const requestContext = createMutationRequestContext('iris-chat')
const res = await apiFetch('/api/v1/chat', {
method: 'POST',
body: JSON.stringify({ message: text.trim() }),
headers: requestContext.headers,
body: JSON.stringify({
message: text.trim(),
conversationId: this.conversationId,
agentId: 'iris',
context: context ?? null,
}),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data: ChatResponse = await res.json()
if (data.ok && data.reply) {
this.messages.push({
sender: 'iris',
text: data.reply,
ts: new Date().toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
}),
})
} else if (data.error) {
this.messages.push({
sender: 'iris',
text: `⚠️ ${data.error}`,
ts: new Date().toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
}),
})
const data = await res.json().catch(() => null) as AgentChatResponse | { detail?: string } | null
if (!res.ok || !data || !('content' in data)) {
throw new Error(data && 'detail' in data && data.detail
? data.detail
: `OpenClaw chat returned HTTP ${res.status}`)
}
} catch (err) {
console.warn('[ChatStore] sendMessage failed', err)
this.conversationId = data.conversationId
localStorage.setItem('nexus-iris-conversation-id', data.conversationId)
this.messages.push({
sender: 'iris',
text: '⚠️ Connection error. Please try again.',
text: data.content,
ts: new Date().toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
}),
runId: data.runId ?? null,
runState: data.state ?? null,
operation: data.operation ?? null,
})
} catch (err) {
console.warn('[ChatStore] sendMessage failed', err)
this.error = err instanceof Error ? err.message : 'OpenClaw chat is unavailable.'
this.messages.push({
sender: 'iris',
text: this.error,
ts: new Date().toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
@@ -145,9 +162,53 @@ export const useChatStore = defineStore('chat', {
startPolling() {
if (this.refreshInterval) return
this.fetchHistory()
if (!domainEventListener) {
domainEventListener = (rawEvent: Event) => {
const detail = (rawEvent as CustomEvent<DomainEventDto>).detail
if (!detail) return
if (detail.entity?.type === 'agent-proposal') {
const operationId = `domain-event:${detail.sequence}`
if (this.messages.some(message =>
message.operation?.operationId === operationId
)) return
const payload = detail.payload && typeof detail.payload === 'object'
? detail.payload as { state?: unknown }
: null
const status = typeof payload?.state === 'string'
? payload.state
: 'updated'
this.messages.push({
sender: 'iris',
text: status === 'awaiting_approval'
? 'Iris hat einen Agent-Vorschlag zur Owner-Freigabe angelegt.'
: 'Ein Agent-Vorschlag wurde aktualisiert.',
ts: new Date(detail.occurredAt).toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
}),
operation: {
operationId,
status,
revision: detail.entityRevision,
primaryRef: detail.entity,
affectedRefs: [detail.entity],
traceId: null,
},
})
return
}
if (detail.entity?.type !== 'run') return
if (eventRefreshTimer !== null) window.clearTimeout(eventRefreshTimer)
eventRefreshTimer = window.setTimeout(() => {
eventRefreshTimer = null
void this.fetchHistory()
}, 400)
}
window.addEventListener('nexus:domain-event', domainEventListener)
}
this.refreshInterval = setInterval(() => {
this.fetchHistory()
}, 10000) // 10s for chat (more responsive)
if (useOpenClawStore().syncMode !== 'live') this.fetchHistory()
}, 60_000)
},
stopPolling() {
@@ -155,6 +216,14 @@ export const useChatStore = defineStore('chat', {
clearInterval(this.refreshInterval)
this.refreshInterval = null
}
if (domainEventListener) {
window.removeEventListener('nexus:domain-event', domainEventListener)
domainEventListener = null
}
if (eventRefreshTimer !== null) {
window.clearTimeout(eventRefreshTimer)
eventRefreshTimer = null
}
},
},
})
-110
View File
@@ -1,110 +0,0 @@
import { defineStore } from 'pinia'
import { apiFetch } from '../services/api'
interface DashboardStatusDto {
gatewayOk: boolean
irisStatus: string
activeAgents: number
pendingTasks: number
}
interface FeedEntryDto {
agent: string
action: string
timestamp: string
time: string
agentId?: string | null
type?: string | null
}
interface QueueItemDto {
id: string
name: string
status: string
priority: string
source: string
waitTime: string
}
export const useDashboardStore = defineStore('dashboard', {
state: () => ({
status: null as DashboardStatusDto | null,
operations: [] as FeedEntryDto[],
queue: [] as QueueItemDto[],
loading: false,
error: null as string | null,
refreshInterval: null as ReturnType<typeof setInterval> | null,
}),
getters: {
isGatewayConnected: state => state.status?.gatewayOk ?? false,
irisStatusLabel: state => state.status?.irisStatus ?? 'Offline',
},
actions: {
async fetchStatus() {
try {
const res = await apiFetch('/api/dashboard/status')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
this.status = await res.json()
} catch (err) {
console.warn('[DashboardStore] fetchStatus failed', err)
this.status = null
}
},
async fetchOperations() {
try {
const res = await apiFetch('/api/dashboard/operations?limit=20')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
this.operations = await res.json()
} catch (err) {
console.warn('[DashboardStore] fetchOperations failed', err)
this.operations = []
}
},
async fetchQueue() {
try {
const res = await apiFetch('/api/dashboard/queue')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
this.queue = await res.json()
} catch (err) {
console.warn('[DashboardStore] fetchQueue failed', err)
this.queue = []
}
},
async refresh() {
this.loading = true
try {
await Promise.all([
this.fetchStatus(),
this.fetchOperations(),
this.fetchQueue(),
])
this.error = null
} catch (err) {
console.warn('[DashboardStore] refresh failed', err)
this.error = 'Dashboard metadata could not be loaded'
} finally {
this.loading = false
}
},
startPolling() {
if (this.refreshInterval) return
this.refresh()
this.refreshInterval = setInterval(() => {
this.refresh()
}, 30000)
},
stopPolling() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval)
this.refreshInterval = null
}
},
},
})
-172
View File
@@ -1,172 +0,0 @@
import { defineStore } from 'pinia'
import { openDashboardLiveStream } from '../services/live'
import type { BoardGroup, DashboardTaskDto } from './tasks'
import type { NotificationItem } from './notifications'
import type { TaskItem } from '../components/dashboard/v2/types'
import { useTaskStore } from './tasks'
import { useNotificationStore } from './notifications'
import type { DashboardLiveEventDto, LiveCursorDto, LiveUpdateEnvelope } from '../services/live'
interface NotificationSnapshotDto {
notifications: NotificationItem[]
unreadCount: number
forUser: string
}
interface DashboardLiveSnapshotDto {
board: BoardGroup
notifications: NotificationSnapshotDto
cursor: LiveCursorDto
}
function isBoardGroup(value: unknown): value is BoardGroup {
const v = value as BoardGroup
return !!v && Array.isArray(v.offen) && Array.isArray(v.inProgress) && Array.isArray(v.review) && Array.isArray(v.blocked) && Array.isArray(v.done)
}
function mapTasks(board: BoardGroup): DashboardTaskDto[] {
return [...board.offen, ...board.inProgress, ...board.review, ...board.blocked, ...board.done]
}
function mapTaskStripItem(t: DashboardTaskDto): TaskItem {
return {
id: t.id,
title: t.title,
agent: t.assignedTo ?? '—',
priority: (['high', 'critical', 'urgent'].includes(t.priority.toLowerCase()) ? 'high' : ['low', 'minor'].includes(t.priority.toLowerCase()) ? 'low' : 'medium') as 'high' | 'medium' | 'low',
status: (t.state.toLowerCase() === 'blocked' ? 'blocked' : (['in progress', 'active', 'working'].includes(t.state.toLowerCase()) ? 'active' : 'pending')) as 'active' | 'blocked' | 'pending',
progress: t.state.toLowerCase() === 'done' ? 100 : t.state.toLowerCase() === 'blocked' ? 30 : (['in progress', 'active', 'working'].includes(t.state.toLowerCase()) ? 50 : 0),
detail: t.detail,
source: t.source,
}
}
export const useLiveSyncStore = defineStore('liveSync', {
state: () => ({
connected: false,
connecting: false,
lastEventAt: null as string | null,
lastHeartbeatAt: null as string | null,
error: null as string | null,
controller: null as AbortController | null,
reconnectTimer: null as ReturnType<typeof setTimeout> | null,
mode: 'polling' as 'polling' | 'live',
lastSequence: 0,
reconnectAttempts: 0,
}),
getters: {
liveIndicatorLabel: (state) => {
if (state.connecting) return 'Verbinde…'
if (state.connected) return `Live · #${state.lastSequence}`
return state.mode === 'polling' ? 'Polling' : 'Offline'
},
connectionHealth: (state) => {
if (state.connected) return 'healthy'
if (state.connecting) return 'connecting'
return 'degraded'
},
},
actions: {
async connect(forUser = 'bao') {
if (this.connecting || this.connected) return
this.connecting = true
this.error = null
this.controller = new AbortController()
const taskStore = useTaskStore()
const notificationStore = useNotificationStore()
try {
const stream = await openDashboardLiveStream((event, data) => {
this.lastEventAt = new Date().toISOString()
if (event === 'heartbeat') {
const cursor = data as LiveCursorDto
this.lastHeartbeatAt = cursor.timestamp
this.lastSequence = Math.max(this.lastSequence, cursor.sequence)
return
}
if (event === 'snapshot') {
const snapshot = data as DashboardLiveSnapshotDto
taskStore.board = snapshot.board
taskStore.tasks = mapTasks(snapshot.board).map(mapTaskStripItem)
notificationStore.notifications = snapshot.notifications.notifications
notificationStore.unreadCount = snapshot.notifications.unreadCount
this.lastSequence = snapshot.cursor.sequence
this.connected = true
this.mode = 'live'
this.reconnectAttempts = 0
taskStore.stopBoardPolling()
return
}
const eventDto = data as DashboardLiveEventDto
this.applyEnvelope(eventDto.envelope, forUser)
this.lastSequence = eventDto.cursor.sequence
this.connected = true
this.mode = 'live'
this.reconnectAttempts = 0
taskStore.stopBoardPolling()
}, { forUser, signal: this.controller.signal, afterSequence: this.lastSequence || null })
await stream.closed
} catch (error) {
if (this.controller?.signal.aborted) return
console.warn('[liveSync] stream failed, falling back to polling', error)
this.error = 'Live updates unavailable'
this.connected = false
this.mode = 'polling'
taskStore.startBoardPolling()
this.scheduleReconnect(forUser)
} finally {
this.connecting = false
if (!this.controller?.signal.aborted && !this.connected) {
this.mode = 'polling'
}
}
},
applyEnvelope(envelope: LiveUpdateEnvelope, forUser: string) {
const taskStore = useTaskStore()
const notificationStore = useNotificationStore()
if (envelope.type === 'tasks.board.snapshot' && isBoardGroup(envelope.payload)) {
taskStore.board = envelope.payload
taskStore.tasks = mapTasks(envelope.payload).map(mapTaskStripItem)
return
}
if (envelope.type === 'notifications.snapshot') {
const snapshot = envelope.payload as NotificationSnapshotDto
if (snapshot.forUser !== forUser) return
notificationStore.notifications = snapshot.notifications
notificationStore.unreadCount = snapshot.unreadCount
}
},
disconnect() {
this.controller?.abort()
this.controller = null
this.connected = false
this.connecting = false
this.mode = 'polling'
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
},
scheduleReconnect(forUser = 'bao') {
if (this.reconnectTimer) return
const delay = Math.min(30000, 5000 * Math.max(1, this.reconnectAttempts + 1))
this.reconnectAttempts += 1
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
this.connect(forUser)
}, delay)
},
},
})
-172
View File
@@ -1,172 +0,0 @@
import { defineStore } from 'pinia'
import { openDashboardLiveStream } from '../services/live'
import type { BoardGroup, DashboardTaskDto } from './tasks'
import type { NotificationItem } from './notifications'
import type { TaskItem } from '../components/dashboard/v2/types'
import { useTaskStore } from './tasks'
import { useNotificationStore } from './notifications'
import type { DashboardLiveEventDto, LiveCursorDto, LiveUpdateEnvelope } from '../services/live'
interface NotificationSnapshotDto {
notifications: NotificationItem[]
unreadCount: number
forUser: string
}
interface DashboardLiveSnapshotDto {
board: BoardGroup
notifications: NotificationSnapshotDto
cursor: LiveCursorDto
}
function isBoardGroup(value: unknown): value is BoardGroup {
const v = value as BoardGroup
return !!v && Array.isArray(v.offen) && Array.isArray(v.inProgress) && Array.isArray(v.review) && Array.isArray(v.blocked) && Array.isArray(v.done)
}
function mapTasks(board: BoardGroup): DashboardTaskDto[] {
return [...board.offen, ...board.inProgress, ...board.review, ...board.blocked, ...board.done]
}
function mapTaskStripItem(t: DashboardTaskDto): TaskItem {
return {
id: t.id,
title: t.title,
agent: t.assignedTo ?? '—',
priority: (['high', 'critical', 'urgent'].includes(t.priority.toLowerCase()) ? 'high' : ['low', 'minor'].includes(t.priority.toLowerCase()) ? 'low' : 'medium') as 'high' | 'medium' | 'low',
status: (t.state.toLowerCase() === 'blocked' ? 'blocked' : (['in progress', 'active', 'working'].includes(t.state.toLowerCase()) ? 'active' : 'pending')) as 'active' | 'blocked' | 'pending',
progress: t.state.toLowerCase() === 'done' ? 100 : t.state.toLowerCase() === 'blocked' ? 30 : (['in progress', 'active', 'working'].includes(t.state.toLowerCase()) ? 50 : 0),
detail: t.detail,
source: t.source,
}
}
export const useLiveSyncStore = defineStore('liveSync', {
state: () => ({
connected: false,
connecting: false,
lastEventAt: null as string | null,
lastHeartbeatAt: null as string | null,
error: null as string | null,
controller: null as AbortController | null,
reconnectTimer: null as ReturnType<typeof setTimeout> | null,
mode: 'polling' as 'polling' | 'live',
lastSequence: 0,
reconnectAttempts: 0,
}),
getters: {
liveIndicatorLabel: (state) => {
if (state.connecting) return 'Verbinde…'
if (state.connected) return `Live · #${state.lastSequence}`
return state.mode === 'polling' ? 'Polling' : 'Offline'
},
connectionHealth: (state) => {
if (state.connected) return 'healthy'
if (state.connecting) return 'connecting'
return 'degraded'
},
},
actions: {
async connect(forUser = 'bao') {
if (this.connecting || this.connected) return
this.connecting = true
this.error = null
this.controller = new AbortController()
const taskStore = useTaskStore()
const notificationStore = useNotificationStore()
try {
const stream = await openDashboardLiveStream((event, data) => {
this.lastEventAt = new Date().toISOString()
if (event === 'heartbeat') {
const cursor = data as LiveCursorDto
this.lastHeartbeatAt = cursor.timestamp
this.lastSequence = Math.max(this.lastSequence, cursor.sequence)
return
}
if (event === 'snapshot') {
const snapshot = data as DashboardLiveSnapshotDto
taskStore.board = snapshot.board
taskStore.tasks = mapTasks(snapshot.board).map(mapTaskStripItem)
notificationStore.notifications = snapshot.notifications.notifications
notificationStore.unreadCount = snapshot.notifications.unreadCount
this.lastSequence = snapshot.cursor.sequence
this.connected = true
this.mode = 'live'
this.reconnectAttempts = 0
taskStore.stopBoardPolling()
return
}
const eventDto = data as DashboardLiveEventDto
this.applyEnvelope(eventDto.envelope, forUser)
this.lastSequence = eventDto.cursor.sequence
this.connected = true
this.mode = 'live'
this.reconnectAttempts = 0
taskStore.stopBoardPolling()
}, { forUser, signal: this.controller.signal, afterSequence: this.lastSequence || null })
await stream.closed
} catch (error) {
if (this.controller?.signal.aborted) return
console.warn('[liveSync] stream failed, falling back to polling', error)
this.error = 'Live updates unavailable'
this.connected = false
this.mode = 'polling'
taskStore.startBoardPolling()
this.scheduleReconnect(forUser)
} finally {
this.connecting = false
if (!this.controller?.signal.aborted && !this.connected) {
this.mode = 'polling'
}
}
},
applyEnvelope(envelope: LiveUpdateEnvelope, forUser: string) {
const taskStore = useTaskStore()
const notificationStore = useNotificationStore()
if (envelope.type === 'tasks.board.snapshot' && isBoardGroup(envelope.payload)) {
taskStore.board = envelope.payload
taskStore.tasks = mapTasks(envelope.payload).map(mapTaskStripItem)
return
}
if (envelope.type === 'notifications.snapshot') {
const snapshot = envelope.payload as NotificationSnapshotDto
if (snapshot.forUser !== forUser) return
notificationStore.notifications = snapshot.notifications
notificationStore.unreadCount = snapshot.unreadCount
}
},
disconnect() {
this.controller?.abort()
this.controller = null
this.connected = false
this.connecting = false
this.mode = 'polling'
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
},
scheduleReconnect(forUser = 'bao') {
if (this.reconnectTimer) return
const delay = Math.min(30000, 5000 * Math.max(1, this.reconnectAttempts + 1))
this.reconnectAttempts += 1
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
this.connect(forUser)
}, delay)
},
},
})
+40
View File
@@ -0,0 +1,40 @@
import { defineStore } from 'pinia'
import type { OperationResultDto } from '../api/contracts'
import { useAuthStore } from './auth'
export const useMissionControlUiStore = defineStore('mission-control-ui', {
state: () => ({
commandOpen: false,
irisOpen: false,
operationResult: null as OperationResultDto | null,
operationTitle: 'Operation',
}),
actions: {
openCommand() {
this.irisOpen = false
this.commandOpen = true
},
closeCommand() {
this.commandOpen = false
},
openIris() {
if (!useAuthStore().isOwner) {
this.irisOpen = false
return
}
this.commandOpen = false
this.irisOpen = true
},
closeIris() {
this.irisOpen = false
},
showOperation(result: OperationResultDto, title = 'Operation') {
this.operationResult = result
this.operationTitle = title
},
dismissOperation() {
this.operationResult = null
this.operationTitle = 'Operation'
},
},
})
-122
View File
@@ -1,122 +0,0 @@
/**
* Notification Store Polls unread count and notifications from the API.
*/
import { defineStore } from 'pinia'
import { apiFetch } from '../services/api'
export interface NotificationItem {
id: string
type: string // "task_assigned", "task_review", "task_blocked"
title: string
message: string | null
forUser: string
taskId: string | null
isRead: boolean
createdAt: string
}
export interface UnreadCount {
count: number
}
export const useNotificationStore = defineStore('notifications', {
state: () => ({
notifications: [] as NotificationItem[],
unreadCount: 0,
loading: false,
error: null as string | null,
countRefreshInterval: null as ReturnType<typeof setInterval> | null,
listRefreshInterval: null as ReturnType<typeof setInterval> | null,
}),
actions: {
async fetchNotifications(forUser = 'bao', limit = 50, unreadOnly = false) {
this.loading = true
try {
const params = new URLSearchParams({ forUser, limit: String(limit), unreadOnly: String(unreadOnly) })
const res = await apiFetch(`/api/dashboard/notifications?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
this.notifications = await res.json()
this.error = null
} catch (err) {
console.warn('[NotificationStore] fetchNotifications failed', err)
this.error = 'Notifications could not be loaded'
} finally {
this.loading = false
}
},
async fetchUnreadCount(forUser = 'bao') {
try {
const params = new URLSearchParams({ forUser })
const res = await apiFetch(`/api/dashboard/notifications/unread-count?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data: UnreadCount = await res.json()
this.unreadCount = data.count
} catch (err) {
console.warn('[NotificationStore] fetchUnreadCount failed', err)
}
},
async markAsRead(id: string) {
try {
const res = await apiFetch(`/api/dashboard/notifications/${id}/read`, { method: 'PATCH' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
// Update local state
const n = this.notifications.find(n => n.id === id)
if (n) n.isRead = true
this.unreadCount = Math.max(0, this.unreadCount - 1)
} catch (err) {
console.warn('[NotificationStore] markAsRead failed', err)
}
},
async markAllAsRead(forUser = 'bao') {
try {
const params = new URLSearchParams({ forUser })
const res = await apiFetch(`/api/dashboard/notifications/read-all?${params}`, { method: 'PATCH' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
this.notifications.forEach(n => { n.isRead = true })
this.unreadCount = 0
} catch (err) {
console.warn('[NotificationStore] markAllAsRead failed', err)
}
},
startPolling(forUser = 'bao') {
if (!this.countRefreshInterval) {
this.fetchUnreadCount(forUser)
this.countRefreshInterval = setInterval(() => {
this.fetchUnreadCount(forUser)
}, 30000)
}
},
stopPolling() {
if (this.countRefreshInterval) {
clearInterval(this.countRefreshInterval)
this.countRefreshInterval = null
}
if (this.listRefreshInterval) {
clearInterval(this.listRefreshInterval)
this.listRefreshInterval = null
}
},
startListPolling(forUser = 'bao') {
if (!this.listRefreshInterval) {
this.fetchNotifications(forUser)
this.listRefreshInterval = setInterval(() => {
this.fetchNotifications(forUser)
}, 30000)
}
},
stopListPolling() {
if (this.listRefreshInterval) {
clearInterval(this.listRefreshInterval)
this.listRefreshInterval = null
}
},
},
})
+133
View File
@@ -0,0 +1,133 @@
/**
* OpenClaw command and connection-mode facade.
*
* Vue Query owns runtime reads. This active Pinia store remains because Iris
* polling and Run Control share connection mode, while privileged task,
* session and approval mutations need one common command boundary.
*/
import { defineStore } from 'pinia'
import { apiFetch } from '../services/api'
import { createMutationRequestContext } from '../services/mutationContext'
import { refreshOpenClawOverview } from '../api/openclawRuntime'
import { reportOperationEnvelope } from '../services/operationResults'
import type {
OpenClawApproval,
OpenClawOperation,
OpenClawTask,
} from '../types/openclaw'
interface OpenClawErrorPayload {
detail?: string
title?: string
message?: string
recovery?: string
state?: string
}
class OpenClawRequestError extends Error {
constructor(
message: string,
readonly status: number,
readonly state: string | null = null,
readonly recovery: string | null = null,
) {
super(message)
this.name = 'OpenClawRequestError'
}
}
async function readJson<T>(path: string, init?: RequestInit, operationTitle?: string): Promise<T> {
const response = await apiFetch(path, init)
const payload = await response.json().catch(() => null) as T | OpenClawErrorPayload | null
if (operationTitle) reportOperationEnvelope(payload, operationTitle)
if (!response.ok) {
const failure = payload as OpenClawErrorPayload | null
const message = failure?.detail
|| failure?.message
|| failure?.title
|| `Request failed with HTTP ${response.status}`
throw new OpenClawRequestError(
failure?.recovery ? `${message} ${failure.recovery}` : message,
response.status,
failure?.state ?? null,
failure?.recovery ?? null,
)
}
return payload as T
}
export const useOpenClawStore = defineStore('openclaw', {
state: () => ({
syncMode: 'stopped' as 'stopped' | 'connecting' | 'live' | 'polling',
}),
actions: {
setSyncMode(mode: 'stopped' | 'connecting' | 'live' | 'polling') {
this.syncMode = mode
},
async refreshOverview(_options: { quiet?: boolean } = {}) {
try {
return await refreshOpenClawOverview()
} catch (error) {
console.warn('[OpenClawStore] overview refresh failed', error)
return null
}
},
async cancelTask(
taskId: string,
reason = 'Stopped from Nexus Mission Control',
) {
const requestContext = createMutationRequestContext('openclaw-task-cancel')
const result = await readJson<OpenClawOperation<OpenClawTask>>(
`/api/v1/openclaw/tasks/${encodeURIComponent(taskId)}/cancel`,
{
method: 'POST',
headers: requestContext.headers,
body: JSON.stringify({ reason }),
},
'OpenClaw-Task abgebrochen',
)
if (!result.ok) throw new Error(result.recovery || result.message)
await this.refreshOverview({ quiet: true })
return result
},
async abortSession(sessionKey: string, runId?: string | null) {
const requestContext = createMutationRequestContext('openclaw-session-abort')
const result = await readJson<OpenClawOperation<Record<string, unknown>>>(
'/api/v1/openclaw/sessions/abort',
{
method: 'POST',
headers: requestContext.headers,
body: JSON.stringify({
sessionKey,
runId: runId || null,
clearQueued: true,
}),
},
'OpenClaw-Session gestoppt',
)
if (!result.ok) throw new Error(result.recovery || result.message)
await this.refreshOverview({ quiet: true })
return result
},
async resolveApproval(approval: OpenClawApproval, decision: string) {
const requestContext = createMutationRequestContext('openclaw-approval-resolve')
const result = await readJson<OpenClawOperation<OpenClawApproval>>(
`/api/v1/openclaw/approvals/${encodeURIComponent(approval.id)}/resolve`,
{
method: 'POST',
headers: requestContext.headers,
body: JSON.stringify({ kind: approval.kind, decision }),
},
'OpenClaw-Freigabe entschieden',
)
if (!result.ok) throw new Error(result.recovery || result.message)
await this.refreshOverview({ quiet: true })
return result
},
},
})
+233
View File
@@ -0,0 +1,233 @@
import { defineStore } from 'pinia'
import { apiFetch } from '../services/api'
import type {
OpenClawAdoptionInventory,
OpenClawDiscovery,
OpenClawProbe,
OpenClawSetupOperation,
OpenClawSetupStatus,
} from '../types/openclaw-setup'
async function readJson<T>(path: string, init?: RequestInit): Promise<T> {
const response = await apiFetch(path, init)
const payload = await response.json().catch(() => null) as
| T
| { detail?: string; message?: string; title?: string; recovery?: string }
| null
if (!response.ok) {
const failure = payload as {
detail?: string
message?: string
title?: string
recovery?: string
} | null
const message = failure?.detail
|| failure?.message
|| failure?.title
|| `OpenClaw setup request failed with HTTP ${response.status}`
throw new Error(
failure?.recovery ? `${message} ${failure.recovery}` : message,
)
}
return payload as T
}
export const useOpenClawSetupStore = defineStore('openclaw-setup', {
state: () => ({
status: null as OpenClawSetupStatus | null,
discovery: null as OpenClawDiscovery | null,
probeResult: null as OpenClawProbe | null,
inventory: null as OpenClawAdoptionInventory | null,
loading: false,
action: '' as '' | 'discover' | 'probe' | 'attach' | 'verify' | 'adopt' | 'management' | 'remove',
error: '',
announcement: '',
}),
getters: {
busy: state => state.loading || Boolean(state.action),
revision: state => state.status?.revision ?? null,
},
actions: {
async load() {
this.loading = true
this.error = ''
try {
this.status = await readJson<OpenClawSetupStatus>('/api/v1/openclaw/setup')
} catch (error) {
this.error = error instanceof Error ? error.message : 'OpenClaw setup status could not be loaded.'
} finally {
this.loading = false
}
},
async discover(includeMdns = false) {
this.action = 'discover'
this.error = ''
try {
this.discovery = await readJson<OpenClawDiscovery>('/api/v1/openclaw/setup/discover', {
method: 'POST',
body: JSON.stringify({ includeMdns }),
})
this.announcement = `${this.discovery.candidates.length} OpenClaw candidate(s) found.`
return this.discovery
} catch (error) {
this.error = error instanceof Error ? error.message : 'Discovery failed.'
throw error
} finally {
this.action = ''
}
},
async probe(endpoint: string, tlsCertificateFingerprint: string | null) {
this.action = 'probe'
this.error = ''
try {
const response = await apiFetch('/api/v1/openclaw/setup/probe', {
method: 'POST',
body: JSON.stringify({ endpoint, tlsCertificateFingerprint }),
})
const operation = await response.json().catch(() => null) as OpenClawSetupOperation<OpenClawProbe> | null
if (!operation) throw new Error(`OpenClaw probe failed with HTTP ${response.status}`)
if (operation.data) this.probeResult = operation.data
this.announcement = operation.message
if (!response.ok || !operation.ok) {
this.error = operation.recovery || operation.message
return operation.data
}
if (!operation.data) throw new Error('OpenClaw probe returned no evidence.')
return operation.data
} catch (error) {
this.error = error instanceof Error ? error.message : 'Probe failed.'
throw error
} finally {
this.action = ''
}
},
async attach(input: {
endpoint: string
discoverySource: string
tlsCertificateFingerprint: string | null
bootstrapToken: string | null
bootstrapSecretReference: string | null
}) {
this.action = 'attach'
this.error = ''
try {
const operation = await readJson<OpenClawSetupOperation<OpenClawSetupStatus>>(
'/api/v1/openclaw/setup/attach',
{ method: 'POST', body: JSON.stringify(input) },
)
if (!operation.ok) throw new Error(operation.recovery || operation.message)
if (operation.data) this.status = operation.data
this.announcement = operation.message
await this.load()
return operation
} catch (error) {
this.error = error instanceof Error ? error.message : 'Attach failed.'
throw error
} finally {
this.action = ''
}
},
async verify() {
if (this.revision === null) throw new Error('No setup revision is available.')
this.action = 'verify'
this.error = ''
try {
const operation = await readJson<OpenClawSetupOperation<OpenClawSetupStatus>>(
'/api/v1/openclaw/setup/verify',
{ method: 'POST', body: JSON.stringify({ expectedRevision: this.revision }) },
)
if (!operation.ok) throw new Error(operation.recovery || operation.message)
if (operation.data) this.status = operation.data
this.announcement = operation.message
await this.load()
return operation
} catch (error) {
this.error = error instanceof Error ? error.message : 'Verification failed.'
throw error
} finally {
this.action = ''
}
},
async adopt() {
if (this.revision === null) throw new Error('No setup revision is available.')
this.action = 'adopt'
this.error = ''
try {
const operation = await readJson<OpenClawSetupOperation<OpenClawAdoptionInventory>>(
'/api/v1/openclaw/setup/adopt',
{ method: 'POST', body: JSON.stringify({ expectedRevision: this.revision }) },
)
if (!operation.ok) throw new Error(operation.recovery || operation.message)
this.inventory = operation.data
this.announcement = operation.message
await this.load()
return operation
} catch (error) {
this.error = error instanceof Error ? error.message : 'Adoption failed.'
throw error
} finally {
this.action = ''
}
},
async setManagement(enabled: boolean) {
if (this.revision === null) throw new Error('No setup revision is available.')
this.action = 'management'
this.error = ''
try {
const operation = await readJson<OpenClawSetupOperation<OpenClawSetupStatus>>(
'/api/v1/openclaw/setup/management',
{
method: 'POST',
body: JSON.stringify({
enabled,
confirmed: true,
expectedRevision: this.revision,
}),
},
)
if (!operation.ok) throw new Error(operation.recovery || operation.message)
if (operation.data) this.status = operation.data
this.announcement = operation.message
await this.load()
return operation
} catch (error) {
this.error = error instanceof Error ? error.message : 'Management policy could not be changed.'
throw error
} finally {
this.action = ''
}
},
async remove(endpoint: string, deviceId: string | null) {
if (this.revision === null) throw new Error('No setup revision is available.')
this.action = 'remove'
this.error = ''
try {
const operation = await readJson<OpenClawSetupOperation<OpenClawSetupStatus>>(
'/api/v1/openclaw/setup/connection',
{
method: 'DELETE',
body: JSON.stringify({
endpoint,
deviceId,
expectedRevision: this.revision,
}),
},
)
if (!operation.ok) throw new Error(operation.recovery || operation.message)
this.announcement = operation.recovery
? `${operation.message} ${operation.recovery}`
: operation.message
this.discovery = null
this.probeResult = null
this.inventory = null
await this.load()
return operation
} catch (error) {
this.error = error instanceof Error ? error.message : 'Connection could not be removed.'
throw error
} finally {
this.action = ''
}
},
},
})
+201
View File
@@ -0,0 +1,201 @@
import { defineStore } from 'pinia'
import { apiFetch } from '../services/api'
import { createMutationRequestContext } from '../services/mutationContext'
import type {
OpenClawWizardAnswer,
OpenClawWizardMode,
OpenClawWizardResult,
} from '../types/openclaw-wizard'
interface ErrorPayload {
detail?: string
message?: string
recovery?: string
title?: string
}
async function readWizardResult(
path: string,
init?: RequestInit,
): Promise<OpenClawWizardResult> {
const response = await apiFetch(path, init)
const payload = await response.json().catch(() => null) as
| OpenClawWizardResult
| ErrorPayload
| null
if (!response.ok) {
const failure = payload as ErrorPayload | null
throw new Error(
failure?.recovery
|| failure?.detail
|| failure?.message
|| failure?.title
|| `OpenClaw wizard request failed with HTTP ${response.status}.`,
)
}
const result = payload as OpenClawWizardResult | null
if (!result || typeof result.ok !== 'boolean') {
throw new Error('OpenClaw wizard returned an invalid response.')
}
if (!result.ok) {
throw new Error(result.recovery || result.error || result.message)
}
return result
}
function mutationHeaders(operation: string): Headers {
return createMutationRequestContext(operation).headers
}
export const useOpenClawWizardStore = defineStore('openclaw-wizard', {
state: () => ({
result: null as OpenClawWizardResult | null,
action: '' as '' | 'start' | 'next' | 'refresh' | 'cancel',
error: '',
announcement: '',
}),
getters: {
busy: state => Boolean(state.action),
sessionId: state => state.result?.sessionId ?? null,
step: state => state.result?.step ?? null,
active: state => Boolean(
state.result?.sessionId
&& !state.result.done
&& state.result.status !== 'cancelled'
&& state.result.status !== 'error',
),
},
actions: {
async start(mode: OpenClawWizardMode) {
this.action = 'start'
this.error = ''
try {
const result = await readWizardResult(
'/api/v1/openclaw/setup/wizard/start',
{
method: 'POST',
headers: mutationHeaders('openclaw-wizard-start'),
body: JSON.stringify({ mode, confirmed: true }),
},
)
this.result = result
this.announcement = result.message
return result
} catch (error) {
this.error = error instanceof Error
? error.message
: 'OpenClaw wizard could not be started.'
throw error
} finally {
this.action = ''
}
},
async next(answer: OpenClawWizardAnswer) {
const sessionId = this.sessionId
const currentStep = this.step
if (!sessionId || !currentStep) {
throw new Error('No active OpenClaw wizard step is available.')
}
if (currentStep.sensitive || !currentStep.canAnswer) {
throw new Error(
currentStep.blockedReason
|| 'This OpenClaw step cannot be answered in the browser.',
)
}
if (answer.stepId !== currentStep.id) {
throw new Error('The answer no longer belongs to the current OpenClaw step.')
}
this.action = 'next'
this.error = ''
try {
const result = await readWizardResult(
'/api/v1/openclaw/setup/wizard/next',
{
method: 'POST',
headers: mutationHeaders('openclaw-wizard-next'),
body: JSON.stringify({
sessionId,
stepId: answer.stepId,
value: answer.value,
hasAnswer: answer.hasAnswer,
}),
},
)
this.result = result
this.announcement = result.message
return result
} catch (error) {
this.error = error instanceof Error
? error.message
: 'OpenClaw wizard could not continue.'
throw error
} finally {
this.action = ''
}
},
async refresh() {
const sessionId = this.sessionId
if (!sessionId) throw new Error('No OpenClaw wizard session is available.')
this.action = 'refresh'
this.error = ''
try {
const result = await readWizardResult(
`/api/v1/openclaw/setup/wizard/${encodeURIComponent(sessionId)}`,
)
const currentStep = this.result?.step ?? null
this.result = {
...result,
step: result.step ?? (result.done ? null : currentStep),
}
this.announcement = result.message
return this.result
} catch (error) {
this.error = error instanceof Error
? error.message
: 'OpenClaw wizard status could not be loaded.'
throw error
} finally {
this.action = ''
}
},
async cancel() {
const sessionId = this.sessionId
if (!sessionId) throw new Error('No OpenClaw wizard session is available.')
this.action = 'cancel'
this.error = ''
try {
const result = await readWizardResult(
`/api/v1/openclaw/setup/wizard/${encodeURIComponent(sessionId)}/cancel`,
{
method: 'POST',
headers: mutationHeaders('openclaw-wizard-cancel'),
},
)
this.result = result
this.announcement = result.message
return result
} catch (error) {
this.error = error instanceof Error
? error.message
: 'OpenClaw wizard could not be cancelled.'
throw error
} finally {
this.action = ''
}
},
clearCompleted() {
if (this.active) return
this.result = null
this.error = ''
this.announcement = ''
},
},
})
-197
View File
@@ -1,197 +0,0 @@
import { defineStore } from 'pinia'
import type { AgentInfo, OperationsSnapshot, RoutingTarget } from '../types'
import { apiFetch } from '../services/api'
export interface PendingApprovalTask {
id: string
title: string
state: string
priority: string
projectId?: string | null
updatedAt: string
}
const fallback: OperationsSnapshot = {
generatedAt: new Date().toISOString(),
runtime: { runtime: 'OpenClaw', status: 'Unknown', detail: 'Awaiting connection…' },
models: [],
metrics: { activeAgents: 0, queuedTasks: 0, successRate: 0, incidents: 0 },
projects: [],
tasks: [],
activity: [],
}
const fallbackRouting: RoutingTarget[] = []
export const useOperationsStore = defineStore('operations', {
state: () => ({
snapshot: fallback,
routing: fallbackRouting,
loading: false,
connected: false,
}),
actions: {
async fetchPendingApprovals(): Promise<PendingApprovalTask[]> {
const response = await apiFetch('/api/v1/tasks/pending-approval')
if (!response.ok) throw new Error('Pending approvals could not be loaded')
return await response.json()
},
async createProject(name: string) {
const response = await apiFetch('/api/v1/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
if (!response.ok) throw new Error('Project could not be created')
const project = await response.json()
this.snapshot.projects.unshift({
id: project.id,
name: project.name,
status: project.status,
progress: project.progress,
})
},
async createTask(title: string, priority: string) {
const response = await apiFetch('/api/v1/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, priority }),
})
if (!response.ok) throw new Error('Task could not be created')
const task = await response.json()
this.snapshot.tasks.unshift(task)
this.snapshot.metrics.queuedTasks += 1
},
async updateTaskState(id: string, state: string) {
const response = await apiFetch(`/api/v1/tasks/${id}/state`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ state }),
})
if (!response.ok) throw new Error('Task state could not be updated')
const updatedTask = await response.json()
const index = this.snapshot.tasks.findIndex(task => task.id === id)
if (index !== -1) this.snapshot.tasks[index] = updatedTask
this.snapshot.metrics.queuedTasks = this.snapshot.tasks.filter(task => task.state !== 'Done').length
this.snapshot.metrics.incidents = this.snapshot.tasks.filter(task => task.state === 'Blocked').length
const completed = this.snapshot.tasks.filter(task => task.state === 'Done').length
this.snapshot.metrics.successRate = this.snapshot.tasks.length
? Math.round((completed * 1000) / this.snapshot.tasks.length) / 10
: 100
},
async updateTask(id: string, data: { title?: string; priority?: string; projectId?: string | null }) {
const response = await apiFetch(`/api/v1/tasks/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) throw new Error('Task could not be updated')
const updatedTask = await response.json()
const index = this.snapshot.tasks.findIndex(task => task.id === id)
if (index !== -1) this.snapshot.tasks[index] = updatedTask
},
async updateProject(id: string, data: { name?: string; description?: string; status?: string }) {
const response = await apiFetch(`/api/v1/projects/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) throw new Error('Project could not be updated')
const updatedProject = await response.json()
const index = this.snapshot.projects.findIndex(p => p.id === id)
if (index !== -1) this.snapshot.projects[index] = {
id: updatedProject.id,
name: updatedProject.name,
status: updatedProject.status,
progress: updatedProject.progress,
}
},
async deleteTask(id: string) {
const response = await apiFetch(`/api/v1/tasks/${id}`, {
method: 'DELETE',
})
if (response.status === 403) {
const err = await response.json().catch(() => ({ detail: 'Task cannot be deleted in its current state.' }))
throw new Error(err.detail || 'Task cannot be deleted in its current state.')
}
if (!response.ok) throw new Error('Task could not be deleted')
this.snapshot.tasks = this.snapshot.tasks.filter(t => t.id !== id)
this.snapshot.metrics.queuedTasks = this.snapshot.tasks.filter(t => t.state !== 'Done').length
const completed = this.snapshot.tasks.filter(t => t.state === 'Done').length
this.snapshot.metrics.successRate = this.snapshot.tasks.length
? Math.round((completed * 1000) / this.snapshot.tasks.length) / 10
: 100
},
async deleteProject(id: string) {
const response = await apiFetch(`/api/v1/projects/${id}`, {
method: 'DELETE',
})
if (!response.ok) throw new Error('Project could not be deleted')
this.snapshot.projects = this.snapshot.projects.filter(p => p.id !== id)
},
async refresh() {
this.loading = true
try {
const [snapshotResponse, routingResponse] = await Promise.all([
apiFetch('/api/v1/operations/snapshot'),
apiFetch('/api/v1/routing'),
])
if (!snapshotResponse.ok || !routingResponse.ok) throw new Error('Nexus API unavailable')
this.snapshot = await snapshotResponse.json()
this.routing = await routingResponse.json()
this.connected = true
} catch {
this.connected = false
} finally {
this.loading = false
}
},
async fetchAgents(): Promise<AgentInfo[]> {
try {
const response = await apiFetch('/api/v1/agents')
if (!response.ok) throw new Error('Failed to fetch agents')
return await response.json()
} catch {
return []
}
},
async approveTask(id: string) {
const response = await apiFetch(`/api/v1/tasks/${id}/approve`, {
method: 'POST',
})
if (!response.ok) {
const err = await response.json().catch(() => ({ detail: 'Task could not be approved' }))
throw new Error(err.detail || 'Task could not be approved')
}
const index = this.snapshot.tasks.findIndex(task => task.id === id)
if (index !== -1) {
this.snapshot.tasks.splice(index, 1)
}
this.snapshot.metrics.queuedTasks = this.snapshot.tasks.filter(task => task.state !== 'Done').length
this.snapshot.metrics.incidents = this.snapshot.tasks.filter(task => task.state === 'Blocked').length
const completed = this.snapshot.tasks.filter(task => task.state === 'Done').length
this.snapshot.metrics.successRate = this.snapshot.tasks.length
? Math.round((completed * 1000) / this.snapshot.tasks.length) / 10
: 100
},
async rejectTask(id: string) {
const response = await apiFetch(`/api/v1/tasks/${id}/reject`, {
method: 'POST',
})
if (!response.ok) {
const err = await response.json().catch(() => ({ detail: 'Task could not be rejected' }))
throw new Error(err.detail || 'Task could not be rejected')
}
const index = this.snapshot.tasks.findIndex(task => task.id === id)
if (index !== -1) {
this.snapshot.tasks[index] = { ...this.snapshot.tasks[index], state: 'Backlog' }
}
this.snapshot.metrics.queuedTasks = this.snapshot.tasks.filter(task => task.state !== 'Done').length
this.snapshot.metrics.incidents = this.snapshot.tasks.filter(task => task.state === 'Blocked').length
const completed = this.snapshot.tasks.filter(task => task.state === 'Done').length
this.snapshot.metrics.successRate = this.snapshot.tasks.length
? Math.round((completed * 1000) / this.snapshot.tasks.length) / 10
: 100
},
},
})
-389
View File
@@ -1,389 +0,0 @@
/**
* Task Store V2 Dashboard + Task Board
*
* Fetches tasks from /api/dashboard/tasks and /api/dashboard/tasks/board
* and maps them into TaskItem[] format for the TaskStrip component.
*
* Board state: grouped by column (offen, inProgress, review, blocked, done)
* Auto-refresh: every 30 seconds.
*/
import { defineStore } from 'pinia'
import { apiFetch } from '../services/api'
import type { TaskItem } from '../components/dashboard/v2/types'
/* ── API Response Shapes ──────────────────────────── */
export interface DashboardTaskDto {
id: string
title: string
detail: string | null
source: string
state: string
priority: string
assignedTo: string | null
parentTaskId?: string | null
dueDate?: string | null
createdAt: string
updatedAt: string
isAgentTask?: boolean
expectedFrom?: string | null
lastActivityMessage?: string | null
lastActivityAt?: string | null
childTasks?: DashboardTaskDto[] | null
childTaskCount?: number
openChildTaskCount?: number
hasVisibleDelegation?: boolean
}
export interface BoardGroup {
offen: DashboardTaskDto[]
inProgress: DashboardTaskDto[]
review: DashboardTaskDto[]
blocked: DashboardTaskDto[]
done: DashboardTaskDto[]
}
export interface AgentWorkflowOverview {
waitingForBao: DashboardTaskDto[]
waitingForIris: DashboardTaskDto[]
waitingForOthers: DashboardTaskDto[]
staleTasks: DashboardTaskDto[]
staleThreshold: string
}
/* ── State Mapping ────────────────────────────────── */
function mapPriority(priority: string): TaskItem['priority'] {
const p = priority.toLowerCase()
if (p === 'high' || p === 'critical' || p === 'urgent') return 'high'
if (p === 'low' || p === 'minor') return 'low'
return 'medium'
}
function mapState(state: string): TaskItem['status'] {
const s = state.toLowerCase()
if (s === 'in progress' || s === 'active' || s === 'working') return 'active'
if (s === 'blocked' || s === 'block') return 'blocked'
return 'pending'
}
function mapProgress(state: string): number {
const s = state.toLowerCase()
if (s === 'in progress' || s === 'active' || s === 'working') return 50
if (s === 'done') return 100
if (s === 'blocked') return 30
return 0
}
function mapTask(t: DashboardTaskDto): TaskItem {
return {
id: t.id,
title: t.title,
agent: t.assignedTo ?? '—',
priority: mapPriority(t.priority),
status: mapState(t.state),
progress: mapProgress(t.state),
detail: t.detail,
source: t.source,
}
}
export const useTaskStore = defineStore('tasks', {
state: () => ({
tasks: [] as TaskItem[],
loading: false,
error: null as string | null,
refreshInterval: null as ReturnType<typeof setInterval> | null,
boardRefreshInterval: null as ReturnType<typeof setInterval> | null,
// Board state
board: {
offen: [] as DashboardTaskDto[],
inProgress: [] as DashboardTaskDto[],
review: [] as DashboardTaskDto[],
blocked: [] as DashboardTaskDto[],
done: [] as DashboardTaskDto[],
} as BoardGroup,
boardLoading: false,
boardError: null as string | null,
// Agent Workflow Overview (for Iris)
agentOverview: null as AgentWorkflowOverview | null,
agentOverviewLoading: false,
agentOverviewError: null as string | null,
}),
getters: {
taskList: (state) => state.tasks,
// Iris helpers
waitingForIrisTasks: (state) => state.agentOverview?.waitingForIris ?? [],
waitingForBaoTasks: (state) => state.agentOverview?.waitingForBao ?? [],
waitingForOthersTasks: (state) => state.agentOverview?.waitingForOthers ?? [],
staleTasksList: (state) => state.agentOverview?.staleTasks ?? [],
agentTaskCount: (state) => {
if (!state.agentOverview) return 0
return state.agentOverview.waitingForBao.length +
state.agentOverview.waitingForIris.length +
state.agentOverview.waitingForOthers.length +
state.agentOverview.staleTasks.length
},
},
actions: {
/* ── API: Fetch tasks (for TaskStrip) ─────────── */
async fetchTasks() {
this.loading = true
try {
const res = await apiFetch('/api/dashboard/tasks')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data: DashboardTaskDto[] = await res.json()
this.tasks = data.map(mapTask)
this.error = null
} catch (err) {
console.warn('[TaskStore] fetchTasks failed', err)
this.error = 'Tasks could not be loaded'
} finally {
this.loading = false
}
},
/* ── API: Fetch board (for TaskBoardView) ─────── */
async fetchBoard() {
this.boardLoading = true
try {
const res = await apiFetch('/api/dashboard/tasks/board')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data: BoardGroup = await res.json()
this.board = data
this.boardError = null
} catch (err) {
console.warn('[TaskStore] fetchBoard failed', err)
this.boardError = 'Board could not be loaded'
} finally {
this.boardLoading = false
}
},
/* ── API: Move task (Drag & Drop) ─────────────── */
async moveTask(id: string, newState: string) {
// Map board group key to canonical state string for the API payload
const canonicalMap: Record<string, string> = {
offen: 'Backlog',
inProgress: 'In progress',
review: 'Review',
blocked: 'Blocked',
done: 'Done',
}
// Save previous state for rollback
const prevBoard = JSON.parse(JSON.stringify(this.board)) as BoardGroup
// Optimistic: find the task in current board and move it
const findAndRemove = (arr: DashboardTaskDto[]): DashboardTaskDto | null => {
const idx = arr.findIndex(t => t.id === id)
if (idx === -1) return null
return arr.splice(idx, 1)[0]
}
const task =
findAndRemove(this.board.offen) ??
findAndRemove(this.board.inProgress) ??
findAndRemove(this.board.review) ??
findAndRemove(this.board.blocked) ??
findAndRemove(this.board.done)
if (task) {
const canonicalState = canonicalMap[newState] ?? newState
task.state = canonicalState
const targetKey = newState as keyof BoardGroup
if (this.board[targetKey]) {
this.board[targetKey].push(task)
}
}
// Actually call API with the board group key (backend handles mapping)
try {
const res = await apiFetch(`/api/dashboard/tasks/${id}/move`, {
method: 'PATCH',
body: JSON.stringify({ state: newState }),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
} catch (err) {
console.warn('[TaskStore] moveTask failed, rolling back', err)
this.board = prevBoard
}
},
/* ── API: Create task ─────────────────────────── */
async createTask(data: { title: string; detail?: string | null; priority?: string; assignedTo?: string }) {
try {
const res = await apiFetch('/api/dashboard/tasks', {
method: 'POST',
body: JSON.stringify({
title: data.title,
detail: data.detail ?? null,
priority: data.priority ?? 'Medium',
assignedTo: data.assignedTo ?? 'bao',
source: 'bao',
}),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
// Refresh board + task list
await this.fetchBoard()
await this.fetchTasks()
} catch (err) {
console.warn('[TaskStore] createTask failed', err)
throw err
}
},
/* ── API: Add task (for TaskStrip) ────────────── */
async addTask(title: string, detail?: string, priority?: string, assignedTo?: string) {
try {
const res = await apiFetch('/api/dashboard/tasks', {
method: 'POST',
body: JSON.stringify({
title,
detail: detail ?? null,
priority: priority ?? null,
assignedTo: assignedTo ?? null,
source: 'bao',
}),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
// Refresh task list
await this.fetchTasks()
} catch (err) {
console.warn('[TaskStore] addTask failed', err)
}
},
/* ── API: Update task ─────────────────────────── */
async updateTask(id: string, updates: { title?: string; detail?: string | null; priority?: string; assignedTo?: string | null; dueDate?: string | null }) {
try {
const res = await apiFetch(`/api/dashboard/tasks/${id}`, {
method: 'PUT',
body: JSON.stringify(updates),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
await this.fetchTasks()
await this.fetchBoard()
} catch (err) {
console.warn('[TaskStore] updateTask failed', err)
throw err
}
},
async fetchTaskChildren(id: string) {
try {
const res = await apiFetch(`/api/dashboard/tasks/${id}/children`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return await res.json() as DashboardTaskDto[]
} catch (err) {
console.warn('[TaskStore] fetchTaskChildren failed', err)
throw err
}
},
async fetchTaskActivity(id: string) {
try {
const res = await apiFetch(`/api/dashboard/tasks/${id}/activity`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return await res.json() as Array<{ id?: string; message?: string; type?: string; createdAt?: string; timestamp?: string }>
} catch (err) {
console.warn('[TaskStore] fetchTaskActivity failed', err)
throw err
}
},
/* ── API: Fetch agent workflow overview ──────── */
async fetchAgentOverview(staleHours = 2) {
this.agentOverviewLoading = true
try {
const res = await apiFetch(`/api/dashboard/tasks/agent-overview?staleHours=${staleHours}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data: AgentWorkflowOverview = await res.json()
this.agentOverview = data
this.agentOverviewError = null
} catch (err) {
console.warn('[TaskStore] fetchAgentOverview failed', err)
this.agentOverviewError = 'Agent overview could not be loaded'
} finally {
this.agentOverviewLoading = false
}
},
/* ── API: Create agent task ───────────────────── */
async createAgentTask(data: {
title: string
detail?: string | null
source?: string
priority?: string
assignedTo?: string
expectedFrom?: string
parentTaskId?: string | null
startsInProgress?: boolean
initialState?: string | null
}) {
try {
const res = await apiFetch('/api/dashboard/tasks/agent', {
method: 'POST',
body: JSON.stringify({
title: data.title,
detail: data.detail ?? null,
source: data.source ?? 'iris',
priority: data.priority ?? 'Medium',
assignedTo: data.assignedTo ?? null,
expectedFrom: data.expectedFrom ?? null,
parentTaskId: data.parentTaskId ?? null,
startsInProgress: data.startsInProgress ?? true,
initialState: data.initialState ?? null,
}),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
await this.fetchBoard()
await this.fetchAgentOverview()
return await res.json() as DashboardTaskDto
} catch (err) {
console.warn('[TaskStore] createAgentTask failed', err)
throw err
}
},
/* ── Polling ──────────────────────────────────── */
startPolling() {
if (this.refreshInterval) return
this.fetchTasks()
this.refreshInterval = setInterval(() => {
this.fetchTasks()
}, 30000)
},
stopPolling() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval)
this.refreshInterval = null
}
},
startBoardPolling(force = false) {
if (this.boardRefreshInterval && !force) return
if (this.boardRefreshInterval && force) {
clearInterval(this.boardRefreshInterval)
this.boardRefreshInterval = null
}
this.fetchBoard()
this.boardRefreshInterval = setInterval(() => {
this.fetchBoard()
}, 30000)
},
stopBoardPolling() {
if (this.boardRefreshInterval) {
clearInterval(this.boardRefreshInterval)
this.boardRefreshInterval = null
}
},
},
})
-67
View File
@@ -1,67 +0,0 @@
import type { AgentInfo } from './agent'
export interface RuntimeStatus {
runtime: string
status: 'Online' | 'Degraded' | 'Offline' | 'Unknown'
latency?: string
detail?: string
}
export interface ModelStatus {
provider: string
model: string
status: 'Online' | 'Degraded' | 'Offline' | 'Unknown'
isLocal: boolean
detail?: string
}
export interface ProjectHealth {
online: number
offline: number
degraded: number
unknown: number
}
export interface IncidentInfo {
taskId?: string
title?: string
since?: string
}
export interface RoutingTarget {
priority: number
provider: string
model: string
purpose: string
status: 'Online' | 'Degraded' | 'Offline' | 'Unknown'
detail: string
}
export type TaskState = 'Backlog' | 'In progress' | 'Review' | 'Blocked' | 'Done'
export const TASK_STATES: TaskState[] = ['Backlog', 'In progress', 'Review', 'Blocked', 'Done']
export interface OperationsSnapshot {
generatedAt: string
runtime: RuntimeStatus
models: ModelStatus[]
metrics: {
activeAgents: number
queuedTasks: number
successRate: number
incidents: number
runtimeHealthy?: boolean
lastIncident?: IncidentInfo
}
projectHealth?: ProjectHealth
agents?: AgentInfo[]
projects: Array<{ id: string; name: string; status: string; progress: number; updatedAt?: string }>
tasks: Array<{
id: string
title: string
state: TaskState
priority: string
projectId?: string | null
updatedAt: string
}>
activity: Array<{ id?: number; type: string; message: string; at: string }>
}
-1
View File
@@ -1,4 +1,3 @@
export * from './agent'
export * from './config'
export * from './dashboard'
export * from './project'
+9
View File
@@ -0,0 +1,9 @@
export type MissionControlEntityType = 'agent' | 'agent-proposal' | 'project' | 'task' | 'run'
export interface MissionControlContext {
routeName: string
path: string
surface: string
entityType: MissionControlEntityType | null
entityId: string | null
}
+84
View File
@@ -0,0 +1,84 @@
export interface OpenClawSetupStatus {
profileId: string
state: string
experimentalBlocked: boolean
hasProfile: boolean
endpoint: string | null
discoverySource: string | null
adoptionState: string
managementEnabled: boolean
requiredVersion: string | null
gatewayVersion: string | null
protocolVersion: number | null
deviceId: string | null
deviceTokenConfigured: boolean
pairingRequired: boolean
pairingRequestId: string | null
grantedScopes: string[]
advertisedMethods: string[]
capabilityHash: string | null
revision: number | null
lastProbedAt: string | null
lastVerifiedAt: string | null
adoptedAt: string | null
updatedAt: string | null
message: string | null
recovery: string | null
checkedAt: string
}
export interface OpenClawDiscoveryCandidate {
endpoint: string
source: string
isCurrentConnectorEndpoint: boolean
requiresTlsFingerprint: boolean
isValid: boolean
reason: string | null
}
export interface OpenClawDiscovery {
candidates: OpenClawDiscoveryCandidate[]
mdnsState: string
message: string | null
checkedAt: string
}
export interface OpenClawProbe {
endpoint: string
source: string
isCurrentConnectorEndpoint: boolean
connected: boolean
gatewayVersion: string | null
requiredVersion: string | null
versionMatches: boolean
protocolVersion: number | null
deviceId: string | null
pairingRequired: boolean
pairingRequestId: string | null
grantedScopes: string[]
advertisedMethods: string[]
capabilityHash: string
canAttach: boolean
leastPrivilegeSatisfied: boolean
checkedAt: string
}
export interface OpenClawAdoptionInventory {
agentCount: number | null
agentFileCount: number | null
cronJobCount: number | null
modelCount: number | null
channelCount: number | null
nodeCount: number | null
diagnostics: string[]
capturedAt: string
}
export interface OpenClawSetupOperation<T> {
ok: boolean
state: string
message: string
data: T | null
recovery: string | null
completedAt: string
}
+57
View File
@@ -0,0 +1,57 @@
export type OpenClawWizardStepType =
| 'note'
| 'select'
| 'text'
| 'confirm'
| 'multiselect'
| 'progress'
| 'action'
export interface OpenClawWizardOption {
value: unknown
label: string
hint: string | null
}
export interface OpenClawWizardDeviceCode {
code: string
expiresInMinutes: number | null
message: string | null
}
export interface OpenClawWizardStep {
id: string
type: OpenClawWizardStepType
title: string | null
message: string | null
options: OpenClawWizardOption[]
initialValue: unknown
placeholder: string | null
sensitive: boolean
executor: string | null
externalUrl: string | null
deviceCode: OpenClawWizardDeviceCode | null
canAnswer: boolean
blockedReason: string | null
}
export interface OpenClawWizardResult {
ok: boolean
state: string
message: string
sessionId: string | null
done: boolean
status: string | null
error: string | null
step: OpenClawWizardStep | null
recovery: string | null
completedAt: string
}
export type OpenClawWizardMode = 'local' | 'remote'
export interface OpenClawWizardAnswer {
stepId: string
value?: unknown
hasAnswer: boolean
}
+394
View File
@@ -0,0 +1,394 @@
import type { OperationResultDto } from '../api/contracts'
export type OpenClawSurfaceState =
| 'ready'
| 'connected'
| 'initializing'
| 'reconnecting'
| 'disconnected'
| 'degraded'
| 'failed'
| 'unsupported'
| 'forbidden'
| 'management_disabled'
| 'invalid'
| 'error'
export interface OpenClawConnection {
state: OpenClawSurfaceState
configured: boolean
credentialConfigured: boolean
connected: boolean
endpoint: string
gatewayVersion: string | null
requiredVersion: string | null
versionPinned: boolean
versionMatches: boolean
protocolVersion: number | null
grantedScopes: string[]
advertisedEvents: string[]
lastConnectedAt: string | null
lastEventAt: string | null
reconnectAttempts: number
message: string | null
recovery: string | null
checkedAt: string
deviceId: string | null
pairingRequired: boolean
pairingRequestId: string | null
}
export interface OpenClawCapability {
id: string
label: string
method: string
requiredScope: string
available: boolean
state: OpenClawSurfaceState
reason: string | null
}
export interface OpenClawCollection<T> {
state: OpenClawSurfaceState
items: T[]
nextCursor: string | null
message: string | null
recovery: string | null
checkedAt: string
}
export interface OpenClawOperation<T> {
ok: boolean
state: string
message: string
data: T | null
recovery: string | null
completedAt: string
operationId: string | null
correlationId: string | null
idempotencyKey: string | null
traceParent: string | null
actor: string | null
operation?: OperationResultDto | null
}
export interface OpenClawTask {
id: string
title: string
status: string
kind: string | null
runtime: string | null
agentId: string | null
sessionKey: string | null
runId: string | null
flowId: string | null
parentTaskId: string | null
createdAt: string | null
startedAt: string | null
updatedAt: string | null
finishedAt: string | null
progress: number | null
summary: string | null
error: string | null
canCancel: boolean
}
export interface OpenClawSession {
key: string
sessionId: string | null
agentId: string
title: string
status: string
kind: string | null
channel: string | null
model: string | null
provider: string | null
runId: string | null
updatedAt: string | null
inputTokens: number | null
outputTokens: number | null
totalTokens: number | null
canAbort: boolean
}
export interface OpenClawCronJob {
id: string
name: string
description: string | null
schedule: string
timeZone: string | null
enabled: boolean
status: string
agentId: string | null
sessionKey: string | null
nextRunAt: string | null
lastRunAt: string | null
lastRunStatus: string | null
lastError: string | null
canRun: boolean
resourceHash: string | null
}
export interface OpenClawCronSchedule {
kind: string
expression: string | null
timeZone: string | null
at: string | null
everyMs: number | null
anchorMs: number | null
staggerMs: number | null
command: string | null
workingDirectory: string | null
}
export interface OpenClawCronPayload {
kind: string
text: string | null
message: string | null
model: string | null
fallbacks: string[]
thinking: string | null
timeoutSeconds: number | null
allowUnsafeExternalContent: boolean | null
lightContext: boolean | null
toolsAllow: string[]
arguments: string[]
workingDirectory: string | null
environmentKeys: string[]
inputConfigured: boolean
noOutputTimeoutSeconds: number | null
outputMaxBytes: number | null
}
export interface OpenClawCronDestination {
channel: string | null
target: string | null
accountId: string | null
mode: string | null
}
export interface OpenClawCronDelivery {
mode: string
channel: string | null
target: string | null
threadId: string | null
accountId: string | null
bestEffort: boolean | null
completionDestination: OpenClawCronDestination | null
failureDestination: OpenClawCronDestination | null
}
export interface OpenClawCronTrigger {
script: string
once: boolean
}
export interface OpenClawCronFailureAlert {
after: number | null
channel: string | null
target: string | null
cooldownMs: number | null
includeSkipped: boolean | null
mode: string | null
accountId: string | null
}
export interface OpenClawCronJobDetail {
id: string
name: string
displayName: string | null
description: string | null
enabled: boolean
deleteAfterRun: boolean
agentId: string | null
sessionKey: string | null
sessionTarget: string
wakeMode: string
schedule: OpenClawCronSchedule
payload: OpenClawCronPayload
delivery: OpenClawCronDelivery | null
trigger: OpenClawCronTrigger | null
failureAlert: OpenClawCronFailureAlert | null
createdAt: string | null
updatedAt: string | null
nextRunAt: string | null
lastRunAt: string | null
lastRunStatus: string | null
lastError: string | null
resourceHash: string
canUpdate: boolean
canDelete: boolean
canRun: boolean
}
export interface OpenClawCronRunDiagnostic {
occurredAt: string | null
source: string
severity: string
message: string
toolName: string | null
exitCode: number | null
truncated: boolean
}
export interface OpenClawCronRun {
id: string
jobId: string
jobName: string | null
runId: string | null
status: string
action: string
summary: string | null
error: string | null
errorReason: string | null
deliveryStatus: string | null
deliveryError: string | null
delivered: boolean | null
triggerFired: boolean | null
diagnosticsSummary: string | null
diagnostics: OpenClawCronRunDiagnostic[]
sessionId: string | null
sessionKey: string | null
occurredAt: string | null
runAt: string | null
durationMs: number | null
nextRunAt: string | null
model: string | null
provider: string | null
inputTokens: number | null
outputTokens: number | null
totalTokens: number | null
}
export interface CreateOpenClawCronJobRequest {
name: string
schedule: Record<string, unknown>
sessionTarget: string
wakeMode: string
payload: Record<string, unknown>
description?: string | null
enabled?: boolean
agentId?: string | null
sessionKey?: string | null
deleteAfterRun?: boolean | null
delivery?: Record<string, unknown> | null
trigger?: Record<string, unknown> | null
failureAlert?: Record<string, unknown> | boolean | null
declarationKey?: string | null
displayName?: string | null
}
export interface PatchOpenClawCronJobRequest {
patch: Record<string, unknown>
expectedHash: string
}
export interface OpenClawCronRunEnqueue {
jobId: string
enqueued: boolean
runId: string | null
}
export interface OpenClawQueuedCronRun {
jobId: string
runId: string | null
enqueuedAt: string
}
export interface OpenClawActivity {
id: string
eventType: string
kind: string
action: string
status: string
message: string
severity: string | null
actor: string | null
agentId: string | null
sessionKey: string | null
runId: string | null
occurredAt: string | null
source: string
}
export interface OpenClawApproval {
id: string
kind: string
title: string
description: string | null
status: string
severity: string
command: string | null
workingDirectory: string | null
agentId: string | null
sessionKey: string | null
requestedAt: string | null
expiresAt: string | null
allowedDecisions: string[]
canResolve: boolean
}
export interface OpenClawModel {
id: string
name: string
provider: string
configured: boolean
available: boolean
contextWindow: number | null
reason: string | null
}
export interface OpenClawModelAuthExpiry {
at: string
remainingMs: number
label: string
}
export interface OpenClawModelAuthProfileSummary {
type: 'oauth' | 'token' | 'api_key' | 'unknown'
status: string
count: number
}
export interface OpenClawModelAuthApiKey {
source: 'config' | 'env'
envVar: string | null
}
export interface OpenClawModelAuthUsage {
summary: string | null
plan: string | null
}
export interface OpenClawModelAuthProvider {
provider: string
displayName: string
status: string
expiry: OpenClawModelAuthExpiry | null
profiles: OpenClawModelAuthProfileSummary[]
apiKey: OpenClawModelAuthApiKey | null
usage: OpenClawModelAuthUsage | null
}
export interface OpenClawAgent {
id: string
name: string
description: string | null
model: string | null
provider: string | null
workspace: string | null
status: string
}
export interface OpenClawOverview {
connection: OpenClawConnection
capabilities: OpenClawCapability[]
tasks: OpenClawCollection<OpenClawTask>
sessions: OpenClawCollection<OpenClawSession>
cronJobs: OpenClawCollection<OpenClawCronJob>
approvals: OpenClawCollection<OpenClawApproval>
activity: OpenClawCollection<OpenClawActivity>
models: OpenClawCollection<OpenClawModel>
agents: OpenClawCollection<OpenClawAgent>
generatedAt: string
}
+73
View File
@@ -0,0 +1,73 @@
import type { RouteLocationRaw } from 'vue-router'
import type { EntityRefDto, EntityType } from '../api/contracts'
export function routeForEntity(entity: EntityRefDto): RouteLocationRaw {
const id = entity.id
switch (entity.type) {
case 'activity':
return { name: 'Activity', query: { event: id } }
case 'agent':
return { name: 'AgentDetail', params: { id } }
case 'agent-proposal':
return { name: 'AgentProposalDetail', params: { proposalId: id } }
case 'project':
return { name: 'ProjectDetail', params: { id } }
case 'task':
return { name: 'TaskDetail', params: { id } }
case 'run':
return { name: 'RunDetail', params: { id } }
case 'cron':
return { name: 'Calendar', query: { job: id } }
case 'incident':
return { name: 'Incidents', query: { incident: id } }
case 'document':
return { name: 'Docs', query: { document: id } }
case 'notification':
return { name: 'Notifications', query: { notification: id } }
case 'task-board':
return { name: 'Task Board' }
case 'openclaw-task':
return { name: 'Run Control', query: { task: id } }
case 'session':
return { name: 'Run Control', query: { session: id } }
case 'approval':
return { name: 'Run Control', query: { approval: id } }
case 'config':
return { name: 'Settings', query: { section: 'openclaw-config' } }
case 'agent-file': {
const separator = id.indexOf('/')
const agentId = separator >= 0 ? id.slice(0, separator) : id
const fileName = separator >= 0 ? id.slice(separator + 1) : undefined
return {
name: 'AgentDetail',
params: { id: agentId },
query: fileName ? { file: fileName } : undefined,
}
}
case 'event-stream':
return { name: 'Settings', query: { section: 'openclaw-events' } }
}
}
export function entityTypeLabel(type: EntityType): string {
const labels: Record<EntityType, string> = {
activity: 'Aktivität',
agent: 'Agent',
'agent-proposal': 'Agent-Vorschlag',
project: 'Projekt',
task: 'Aufgabe',
run: 'Run',
cron: 'Cronjob',
incident: 'Incident',
document: 'Dokument',
notification: 'Benachrichtigung',
'task-board': 'Task Board',
'openclaw-task': 'OpenClaw-Task',
session: 'Session',
approval: 'Freigabe',
config: 'Konfiguration',
'agent-file': 'Agent-Datei',
'event-stream': 'Ereignisstream',
}
return labels[type]
}
@@ -0,0 +1,54 @@
import type { RouteLocationNormalizedLoaded } from 'vue-router'
import type {
MissionControlContext,
MissionControlEntityType,
} from '../types/mission-control'
const ROUTE_SURFACES: Record<string, string> = {
Dashboard: 'Dashboard',
Memory: 'Memory',
Docs: 'Docs',
AgentDetail: 'Agent detail',
AgentCreate: 'Agent proposal creation',
AgentProposalDetail: 'Agent proposal detail',
Agents: 'Agents',
Security: 'Security',
Incidents: 'Incidents',
Calendar: 'Calendar',
Projects: 'Projects',
ProjectDetail: 'Project detail',
'Task Board': 'Task Board',
TaskDetail: 'Task detail',
'Run Control': 'Run Control',
RunDetail: 'Run detail',
Models: 'Models',
Activity: 'Activity',
Notifications: 'Notifications',
Settings: 'Settings',
}
const DETAIL_ROUTE_TYPES: Record<string, MissionControlEntityType> = {
AgentDetail: 'agent',
AgentProposalDetail: 'agent-proposal',
ProjectDetail: 'project',
TaskDetail: 'task',
RunDetail: 'run',
}
export function buildMissionControlContext(
route: Pick<RouteLocationNormalizedLoaded, 'name' | 'path' | 'params'>,
): MissionControlContext {
const routeName = String(route.name ?? 'Unknown')
const rawId = routeName === 'AgentProposalDetail'
? route.params.proposalId
: route.params.id
const entityId = Array.isArray(rawId) ? rawId[0] : rawId
return {
routeName,
path: route.path,
surface: ROUTE_SURFACES[routeName] ?? routeName,
entityType: DETAIL_ROUTE_TYPES[routeName] ?? null,
entityId: typeof entityId === 'string' && entityId.trim() ? entityId.trim() : null,
}
}
+50
View File
@@ -0,0 +1,50 @@
export interface TaskAgentOption {
id: string
label: string
}
interface LiveAgentOptionSource {
id: string
name: string
}
/**
* OpenClaw supplies the live inventory. Nexus adds only local assignment
* concepts plus values already persisted on visible tasks, so a disconnected
* gateway never makes the current value disappear from a select.
*/
export function buildTaskAgentOptions(
liveAgents: readonly LiveAgentOptionSource[],
persistedValues: readonly (string | null | undefined)[] = [],
): TaskAgentOption[] {
const options = new Map<string, TaskAgentOption>()
const add = (id: string, label: string) => {
const normalizedId = id.trim()
const key = normalizedId.toLocaleLowerCase()
if (!options.has(key)) {
options.set(key, {
id: normalizedId,
label: label.trim() || normalizedId,
})
}
}
add('', 'Nicht zugewiesen')
add('bao', 'Bao')
for (const agent of liveAgents) add(agent.id, agent.name)
for (const value of persistedValues) {
if (value?.trim()) add(value, value)
}
return [...options.values()]
}
export function taskAgentLabel(
value: string | null | undefined,
options: readonly TaskAgentOption[],
): string {
if (!value) return ''
const normalized = value.toLocaleLowerCase()
return options.find(option => option.id.toLocaleLowerCase() === normalized)?.label
?? value
}
+415
View File
@@ -0,0 +1,415 @@
<script setup lang="ts">
import {
Activity,
Bot,
CircleAlert,
Clock3,
Filter,
Loader2,
RefreshCw,
Search,
ServerCog,
X,
} from '@lucide/vue'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import type { EntityRefDto } from '../api/contracts'
import { useActivity } from '../api/activity'
import { useOpenClawOverviewQuery } from '../api/openclawRuntime'
import EntityLink from '../components/mission-control/EntityLink.vue'
interface UnifiedActivity {
id: string
source: 'OpenClaw' | 'Nexus'
type: string
action: string
status: string
message: string
severity: string | null
actor: string | null
agentId: string | null
sessionKey: string | null
runId: string | null
occurredAt: string | null
entity: EntityRefDto | null
}
const overviewQuery = useOpenClawOverviewQuery()
const nexusActivity = useActivity()
const route = useRoute()
const query = ref('')
const sourceFilter = ref<'all' | 'OpenClaw' | 'Nexus'>('all')
const severityFilter = ref('all')
const selectedEvent = ref<UnifiedActivity | null>(null)
const requestedEventId = computed(() =>
typeof route.query.event === 'string' ? route.query.event : '',
)
const events = computed<UnifiedActivity[]>(() => {
const runtimeEvents = (overviewQuery.data.value?.activity.items ?? []).map(event => ({
id: `openclaw:${event.id}`,
source: 'OpenClaw' as const,
type: event.kind || event.eventType,
action: event.action,
status: event.status,
message: event.message,
severity: event.severity,
actor: event.actor,
agentId: event.agentId,
sessionKey: event.sessionKey,
runId: event.runId,
occurredAt: event.occurredAt,
entity: null,
}))
const nexusEvents = (nexusActivity.data.value?.items ?? []).map(event => ({
id: `nexus:${event.id}:${event.at}`,
source: 'Nexus' as const,
type: event.type,
action: event.type,
status: 'recorded',
message: event.message,
severity: null,
actor: null,
agentId: null,
sessionKey: null,
runId: null,
occurredAt: event.at,
entity: event.entity,
}))
return [...runtimeEvents, ...nexusEvents].sort((left, right) =>
timestamp(right.occurredAt) - timestamp(left.occurredAt),
)
})
const severities = computed(() =>
[...new Set(events.value.map(event => event.severity || event.status).filter(Boolean))]
.sort((left, right) => left.localeCompare(right)),
)
const filteredEvents = computed(() => {
const needle = query.value.trim().toLocaleLowerCase()
return events.value.filter(event => {
const matchesSource = sourceFilter.value === 'all' || event.source === sourceFilter.value
const tone = event.severity || event.status
const matchesSeverity = severityFilter.value === 'all' || tone === severityFilter.value
const matchesQuery = !needle || [
event.type,
event.action,
event.message,
event.actor,
event.agentId,
event.sessionKey,
event.runId,
].some(value => value?.toLocaleLowerCase().includes(needle))
return matchesSource && matchesSeverity && matchesQuery
})
})
const runtimeCollection = computed(() => overviewQuery.data.value?.activity ?? null)
const runtimeCount = computed(() => overviewQuery.data.value?.activity.items.length ?? 0)
const nexusCount = computed(() => nexusActivity.data.value?.totalCount ?? 0)
const failedCount = computed(() =>
events.value.filter(event => ['error', 'failed', 'critical', 'high'].includes(
(event.severity || event.status).toLocaleLowerCase(),
)).length,
)
function timestamp(value: string | null) {
if (!value) return 0
const parsed = Date.parse(value)
return Number.isNaN(parsed) ? 0 : parsed
}
function formatTime(value: string | null) {
if (!value) return 'No timestamp'
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) return value
return parsed.toLocaleString('de-DE', {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
function toneFor(event: UnifiedActivity) {
const value = (event.severity || event.status).toLocaleLowerCase()
if (['error', 'failed', 'critical', 'high', 'denied'].includes(value)) return 'danger'
if (['warning', 'warn', 'medium', 'pending', 'blocked'].includes(value)) return 'warning'
if (['success', 'succeeded', 'completed', 'resolved', 'ready'].includes(value)) return 'success'
return 'info'
}
function entityRefs(event: UnifiedActivity): EntityRefDto[] {
const refs: EntityRefDto[] = event.entity ? [event.entity] : []
if (event.agentId) refs.push({ type: 'agent', id: event.agentId, label: event.agentId })
if (event.runId) refs.push({ type: 'run', id: event.runId, label: event.runId })
return [...new Map(refs.map(entity => [`${entity.type}:${entity.id}`, entity])).values()]
}
async function refresh() {
await Promise.allSettled([
overviewQuery.refetch(),
nexusActivity.refetch(),
])
}
function closeDetails() {
selectedEvent.value = null
}
watch(
[requestedEventId, events],
async ([requested, items]) => {
if (!requested) return
const event = items.find(item =>
item.id === requested || item.id.startsWith(`nexus:${requested}:`),
)
if (!event) return
selectedEvent.value = event
await nextTick()
document.getElementById(`activity-${event.id}`)?.focus({ preventScroll: true })
},
{ immediate: true },
)
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') closeDetails()
}
onMounted(() => {
window.addEventListener('keydown', onKeydown)
})
onUnmounted(() => {
window.removeEventListener('keydown', onKeydown)
})
</script>
<template>
<div class="activity-page nexus-page">
<header class="nexus-page-header">
<div class="nexus-page-header__identity">
<div class="nexus-page-header__icon"><Activity :size="20" aria-hidden="true" /></div>
<div>
<span class="eyebrow">UNIFIED AUDIT FEED</span>
<h1>Activity</h1>
<p class="page-subtitle">OpenClaw runtime events and Nexus domain changes in one inspectable timeline.</p>
</div>
</div>
<button type="button" class="nexus-button" :disabled="overviewQuery.isFetching.value || nexusActivity.isFetching.value" @click="refresh">
<Loader2 v-if="overviewQuery.isFetching.value || nexusActivity.isFetching.value" :size="15" class="spin" aria-hidden="true" />
<RefreshCw v-else :size="15" aria-hidden="true" />
Refresh
</button>
</header>
<section class="activity-metrics" aria-label="Activity summary">
<article class="nexus-card metric-card">
<ServerCog :size="17" aria-hidden="true" />
<div><strong>{{ runtimeCount }}</strong><span>OpenClaw events</span></div>
</article>
<article class="nexus-card metric-card">
<Activity :size="17" aria-hidden="true" />
<div><strong>{{ nexusCount }}</strong><span>Nexus events</span></div>
</article>
<article class="nexus-card metric-card">
<CircleAlert :size="17" aria-hidden="true" />
<div><strong>{{ failedCount }}</strong><span>Attention needed</span></div>
</article>
</section>
<section
v-if="runtimeCollection && runtimeCollection.state !== 'ready'"
class="runtime-state nexus-state"
:class="{ 'nexus-state--error': runtimeCollection.state === 'disconnected' || runtimeCollection.state === 'error' }"
>
<ServerCog :size="18" aria-hidden="true" />
<div>
<h2>OpenClaw audit feed: {{ runtimeCollection.state }}</h2>
<p>{{ runtimeCollection.message || 'Runtime activity is not currently available.' }}</p>
<p v-if="runtimeCollection.recovery">{{ runtimeCollection.recovery }}</p>
</div>
<RouterLink class="nexus-button" to="/settings">Diagnostics</RouterLink>
</section>
<section class="activity-toolbar nexus-panel" aria-label="Activity filters">
<label class="activity-search">
<span class="nexus-visually-hidden">Search activity</span>
<Search :size="15" aria-hidden="true" />
<input v-model="query" type="search" placeholder="Search message, agent, session, or run" />
</label>
<label>
<Filter :size="14" aria-hidden="true" />
<span class="nexus-visually-hidden">Filter source</span>
<select v-model="sourceFilter">
<option value="all">All sources</option>
<option value="OpenClaw">OpenClaw</option>
<option value="Nexus">Nexus</option>
</select>
</label>
<label>
<span class="nexus-visually-hidden">Filter severity</span>
<select v-model="severityFilter">
<option value="all">All states</option>
<option v-for="severity in severities" :key="severity" :value="severity">{{ severity }}</option>
</select>
</label>
</section>
<div v-if="overviewQuery.isPending.value && !overviewQuery.data.value && nexusActivity.isLoading.value" class="nexus-state nexus-state--loading">
<Loader2 :size="18" class="spin" aria-hidden="true" />
<p>Loading activity sources</p>
</div>
<section v-else-if="filteredEvents.length" class="timeline" aria-label="Activity timeline">
<button
v-for="event in filteredEvents"
:id="`activity-${event.id}`"
:key="event.id"
type="button"
class="event-row nexus-card nexus-card--interactive"
@click="selectedEvent = event"
>
<span class="event-dot" :class="toneFor(event)" aria-hidden="true"></span>
<span class="event-main">
<span class="event-title">
<strong>{{ event.action }}</strong>
<span class="event-source">{{ event.source }}</span>
</span>
<span class="event-message">{{ event.message }}</span>
<span v-if="event.agentId || event.actor" class="event-actor">
<Bot :size="12" aria-hidden="true" />
{{ event.agentId || event.actor }}
</span>
</span>
<span class="event-meta">
<span class="nexus-status" :class="`nexus-status--${toneFor(event)}`">
{{ event.severity || event.status }}
</span>
<time :datetime="event.occurredAt || undefined">{{ formatTime(event.occurredAt) }}</time>
</span>
</button>
</section>
<div v-else class="nexus-state nexus-state--empty">
<Activity :size="18" aria-hidden="true" />
<h2>No matching activity</h2>
<p v-if="events.length">Adjust the search or source filters.</p>
<p v-else>No Nexus or OpenClaw events have been returned yet.</p>
</div>
<Teleport to="body">
<div v-if="selectedEvent" class="activity-dialog-overlay" @click.self="closeDetails">
<section
class="activity-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="activity-dialog-title"
>
<header>
<div>
<span class="eyebrow">{{ selectedEvent.source }} · {{ selectedEvent.type }}</span>
<h2 id="activity-dialog-title">{{ selectedEvent.action }}</h2>
</div>
<button type="button" aria-label="Close activity details" @click="closeDetails">
<X :size="18" aria-hidden="true" />
</button>
</header>
<p class="dialog-message">{{ selectedEvent.message }}</p>
<dl>
<div><dt>Status</dt><dd>{{ selectedEvent.severity || selectedEvent.status }}</dd></div>
<div><dt>Time</dt><dd>{{ formatTime(selectedEvent.occurredAt) }}</dd></div>
<div v-if="selectedEvent.actor"><dt>Actor</dt><dd>{{ selectedEvent.actor }}</dd></div>
<div v-if="selectedEvent.agentId"><dt>Agent</dt><dd>{{ selectedEvent.agentId }}</dd></div>
<div v-if="selectedEvent.sessionKey"><dt>Session</dt><dd><code>{{ selectedEvent.sessionKey }}</code></dd></div>
<div v-if="selectedEvent.runId"><dt>Run</dt><dd><code>{{ selectedEvent.runId }}</code></dd></div>
</dl>
<div v-if="entityRefs(selectedEvent).length" class="dialog-refs" aria-label="Related results">
<EntityLink
v-for="entity in entityRefs(selectedEvent)"
:key="`${entity.type}:${entity.id}`"
:entity="entity"
/>
</div>
<div class="dialog-actions">
<RouterLink
v-if="selectedEvent.source === 'OpenClaw'"
class="nexus-button"
to="/runs"
@click="closeDetails"
>Open Run Control</RouterLink>
<button type="button" class="nexus-button nexus-button--primary" @click="closeDetails">Done</button>
</div>
</section>
</div>
</Teleport>
</div>
</template>
<style scoped>
.activity-page { padding-bottom: 30px; }
.activity-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.metric-card { display: flex; align-items: center; gap: 12px; padding: 15px; color: var(--a-mid); }
.metric-card div { display: grid; gap: 2px; }
.metric-card strong { color: var(--tx); font-family: var(--font-display); font-size: 21px; }
.metric-card span { color: var(--tx-3); }
.runtime-state { grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; }
.runtime-state h2 { font-size: 15px; }
.activity-toolbar { display: flex; align-items: center; gap: 10px; padding: 12px; }
.activity-toolbar > label { display: flex; align-items: center; gap: 7px; color: var(--tx-3); }
.activity-search { flex: 1; min-width: 230px; padding-left: 11px; border: 1px solid var(--line); border-radius: var(--r-sm); background: var(--field-surface); }
.activity-search:focus-within { border-color: var(--a-blue); box-shadow: var(--focus-ring); }
.activity-search input { flex: 1; min-height: 38px; padding: 0 10px 0 0; border: 0; background: transparent; box-shadow: none; }
.activity-toolbar select { min-width: 135px; }
.timeline { display: grid; gap: 8px; }
.event-row { display: grid; grid-template-columns: 8px minmax(0, 1fr) auto; align-items: center; gap: 12px; width: 100%; padding: 13px 15px; color: inherit; text-align: left; cursor: pointer; }
.event-dot { width: 8px; height: 8px; border-radius: 999px; background: var(--a-blue); box-shadow: 0 0 12px color-mix(in srgb, var(--a-blue) 45%, transparent); }
.event-dot.success { background: var(--st-work); }
.event-dot.warning { background: var(--st-queue); }
.event-dot.danger { background: var(--st-block); }
.event-main { display: grid; gap: 5px; min-width: 0; }
.event-title { display: flex; align-items: center; gap: 8px; min-width: 0; }
.event-title strong { overflow: hidden; color: var(--tx); text-overflow: ellipsis; white-space: nowrap; }
.event-source { flex: 0 0 auto; padding: 2px 6px; border: 1px solid var(--line); border-radius: 999px; color: var(--tx-3); font-family: var(--font-mono-v2); font-size: 11px; }
.event-message { overflow: hidden; color: var(--tx-2); text-overflow: ellipsis; white-space: nowrap; }
.event-actor { display: inline-flex; align-items: center; gap: 5px; color: var(--tx-3); font-family: var(--font-mono-v2); font-size: 11px; }
.event-meta { display: grid; justify-items: end; gap: 7px; }
.event-meta time { color: var(--tx-3); font-family: var(--font-mono-v2); font-size: 11px; }
.event-meta .nexus-status { font-size: 11px; text-transform: capitalize; }
.activity-dialog-overlay { position: fixed; inset: 0; z-index: 300; display: grid; place-items: center; padding: 20px; background: color-mix(in srgb, var(--space-0) 78%, transparent); backdrop-filter: blur(12px); }
.activity-dialog { width: min(100%, 620px); max-height: calc(100dvh - 40px); overflow: auto; border: 1px solid var(--line-3); border-radius: var(--r-lg); background: color-mix(in srgb, var(--space-2) 94%, transparent); box-shadow: var(--panel-shadow); }
.activity-dialog > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 18px; border-bottom: 1px solid var(--line); }
.activity-dialog h2 { margin: 4px 0 0; font-family: var(--font-display); font-size: 20px; }
.activity-dialog header button { width: 36px; height: 36px; display: grid; place-items: center; border: 1px solid var(--line); border-radius: var(--r-sm); background: var(--accent-wash); color: var(--tx-2); cursor: pointer; }
.dialog-message { margin: 16px 18px 6px; color: var(--tx-2); line-height: 1.6; }
.activity-dialog dl { display: grid; margin: 0; padding: 8px 18px; }
.activity-dialog dl div { display: grid; grid-template-columns: 100px minmax(0, 1fr); gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--line); }
.activity-dialog dl div:last-child { border-bottom: 0; }
.activity-dialog dt { color: var(--tx-3); font-family: var(--font-mono-v2); font-size: 11px; text-transform: uppercase; }
.activity-dialog dd { margin: 0; color: var(--tx-2); overflow-wrap: anywhere; }
.activity-dialog code { font-family: var(--font-mono-v2); font-size: 11px; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 10px; padding: 15px 18px 18px; }
.dialog-refs { display: flex; flex-wrap: wrap; gap: 8px; padding: 10px 18px 0; }
.spin { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 760px) {
.activity-toolbar { align-items: stretch; flex-direction: column; }
.activity-toolbar > label { width: 100%; }
.activity-toolbar select { flex: 1; }
.runtime-state { grid-template-columns: auto minmax(0, 1fr); }
.runtime-state .nexus-button { grid-column: 1 / -1; justify-self: start; }
}
@media (max-width: 580px) {
.activity-metrics { grid-template-columns: 1fr; }
.event-row { grid-template-columns: 8px minmax(0, 1fr); }
.event-meta { grid-column: 2; grid-row: 2; justify-items: start; }
.event-message { white-space: normal; }
.activity-dialog dl div { grid-template-columns: 1fr; gap: 4px; }
}
</style>
+344
View File
@@ -0,0 +1,344 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import {
ArrowLeft,
Bot,
ChevronRight,
FileText,
Info,
LoaderCircle,
ShieldCheck,
Sparkles,
} from '@lucide/vue'
import {
useAgentCreateOptionsQuery,
useCreateAgentProposalMutation,
type CreateAgentProposalRequest,
} from '../api/agentProposals'
import { useAuthStore } from '../stores/auth'
const router = useRouter()
const auth = useAuthStore()
const isOwner = computed(() => auth.isOwner)
const optionsQuery = useAgentCreateOptionsQuery(isOwner)
const createMutation = useCreateAgentProposalMutation()
const form = reactive({
name: '',
role: '',
description: '',
model: '',
})
const files = reactive<Record<string, string>>({})
const selectedFile = ref('')
const selectedFileContent = computed({
get: () => selectedFile.value ? files[selectedFile.value] ?? '' : '',
set: value => {
if (selectedFile.value) files[selectedFile.value] = value
},
})
const formError = ref('')
const lastRequestSignature = ref('')
const lastClientRequestId = ref('')
const options = computed(() => optionsQuery.data.value ?? null)
const modelOptions = computed(() => options.value?.models.filter(model => model.available) ?? [])
const canSubmit = computed(() => Boolean(options.value?.canSubmitProposal))
const configuredFileCount = computed(() => Object.values(files).filter(value => value.trim()).length)
watch(
() => options.value?.standardFiles,
standardFiles => {
if (!standardFiles?.length) return
for (const fileName of standardFiles) {
if (!(fileName in files)) files[fileName] = ''
}
if (!selectedFile.value || !standardFiles.includes(selectedFile.value)) {
selectedFile.value = standardFiles[0]
}
},
{ immediate: true },
)
function buildRequest(): CreateAgentProposalRequest {
const configuredFiles = Object.fromEntries(
Object.entries(files).filter(([, content]) => content.trim().length > 0),
)
return {
name: form.name.trim(),
role: form.role.trim() || null,
description: form.description.trim() || null,
model: form.model || null,
files: Object.keys(configuredFiles).length ? configuredFiles : null,
}
}
async function submitProposal() {
formError.value = ''
if (!form.name.trim()) {
formError.value = 'Bitte gib dem Agenten einen Namen.'
return
}
if (!canSubmit.value) {
formError.value = options.value?.reason || 'Agent-Vorschläge sind derzeit nicht verfügbar.'
return
}
const request = buildRequest()
const signature = JSON.stringify(request)
if (signature !== lastRequestSignature.value) {
lastRequestSignature.value = signature
lastClientRequestId.value = crypto.randomUUID()
}
try {
const operation = await createMutation.mutateAsync({
...request,
clientRequestId: lastClientRequestId.value,
})
if (!operation.ok || !operation.proposal) {
formError.value = operation.recovery || operation.message
return
}
await router.push({
name: 'AgentProposalDetail',
params: { proposalId: operation.proposal.id },
})
} catch (error) {
formError.value = error instanceof Error ? error.message : 'Agent-Vorschlag konnte nicht erstellt werden.'
}
}
</script>
<template>
<div class="agent-create-page nexus-page nexus-page--reading">
<header class="nexus-page-header">
<div class="nexus-page-header__identity">
<RouterLink class="back-link" to="/agents" aria-label="Zurück zu Agents">
<ArrowLeft :size="17" aria-hidden="true" />
</RouterLink>
<div class="nexus-page-header__icon">
<Sparkles :size="21" aria-hidden="true" />
</div>
<div>
<h1>Agent vorschlagen</h1>
<p class="page-subtitle">Konfiguration vorbereiten und anschließend bewusst freigeben.</p>
</div>
</div>
<span class="nexus-status nexus-status--warning">
<ShieldCheck :size="14" aria-hidden="true" />
Owner-Approval
</span>
</header>
<div v-if="!isOwner" class="nexus-state nexus-state--error" role="alert">
<strong>Owner-Berechtigung erforderlich</strong>
<span>Agent-Vorschläge enthalten OpenClaw-Konfiguration und sind deshalb ausschließlich für Owner sichtbar.</span>
<RouterLink class="nexus-button" to="/agents">Zur Agentenübersicht</RouterLink>
</div>
<div v-else-if="optionsQuery.isPending.value" class="nexus-state nexus-state--loading" role="status">
<LoaderCircle class="spin" :size="18" aria-hidden="true" />
OpenClaw-Optionen werden geprüft
</div>
<div v-else-if="optionsQuery.isError.value" class="nexus-state nexus-state--error" role="alert">
<strong>Optionen nicht verfügbar</strong>
<span>{{ optionsQuery.error.value?.message || 'Die Create-Options konnten nicht geladen werden.' }}</span>
<button type="button" class="nexus-button" @click="optionsQuery.refetch()">Erneut prüfen</button>
</div>
<template v-else-if="options">
<section
class="availability-panel nexus-panel"
:class="{ 'availability-panel--blocked': !options.canSubmitProposal }"
:aria-label="options.canSubmitProposal ? 'Proposal-Erstellung verfügbar' : 'Proposal-Erstellung blockiert'"
>
<Info :size="18" aria-hidden="true" />
<div>
<strong>
{{ options.canSubmitProposal ? 'Vorschlag kann erstellt werden' : 'Erstellung derzeit blockiert' }}
</strong>
<p>
<template v-if="options.canSubmitProposal && options.canProvision">
Nach deiner Freigabe kann Nexus den Agenten über OpenClaw provisionieren.
</template>
<template v-else-if="options.canSubmitProposal">
Der Vorschlag kann gespeichert werden; die Provisionierung bleibt blockiert, bis die Runtime-Bedingungen erfüllt sind.
</template>
<template v-else>
{{ options.reason || 'Die erforderlichen Management- oder Runtime-Bedingungen fehlen.' }}
</template>
</p>
</div>
<span class="nexus-mono">{{ options.state }}</span>
</section>
<form class="proposal-form" novalidate @submit.prevent="submitProposal">
<section class="form-panel nexus-panel">
<div class="section-heading">
<span class="section-icon"><Bot :size="18" aria-hidden="true" /></span>
<div>
<h2>Identität und Auftrag</h2>
<p>Diese Angaben werden im Review vollständig angezeigt.</p>
</div>
</div>
<div class="field-grid">
<label class="field">
<span>Name <em>Pflichtfeld</em></span>
<input
v-model="form.name"
class="nexus-field"
name="agent-name"
autocomplete="off"
maxlength="80"
required
placeholder="z. B. Release Coordinator"
/>
</label>
<label class="field">
<span>Rolle</span>
<input
v-model="form.role"
class="nexus-field"
name="agent-role"
autocomplete="off"
maxlength="120"
placeholder="z. B. Delivery Operations"
/>
</label>
</div>
<label class="field">
<span>Zweck und Verantwortungsbereich</span>
<textarea
v-model="form.description"
class="nexus-field"
name="agent-description"
rows="5"
maxlength="1600"
placeholder="Welche Ergebnisse soll der Agent liefern, und wo liegen seine Grenzen?"
></textarea>
</label>
<label class="field">
<span>OpenClaw-Modell</span>
<select v-model="form.model" class="nexus-field" name="agent-model">
<option value="">OpenClaw-Standard verwenden</option>
<option v-for="model in modelOptions" :key="model.id" :value="model.id">
{{ model.name }} · {{ model.provider }}
</option>
</select>
</label>
</section>
<section v-if="options.standardFiles.length" class="form-panel nexus-panel">
<div class="section-heading">
<span class="section-icon"><FileText :size="18" aria-hidden="true" /></span>
<div>
<h2>Initiale Agent-Dateien</h2>
<p>Optional. Nur von OpenClaw erlaubte Standarddateien werden übertragen.</p>
</div>
<span class="configured-count nexus-badge">{{ configuredFileCount }} konfiguriert</span>
</div>
<div class="file-editor">
<label class="field file-picker">
<span>Datei</span>
<select v-model="selectedFile" class="nexus-field">
<option v-for="fileName in options.standardFiles" :key="fileName" :value="fileName">
{{ fileName }}
</option>
</select>
</label>
<label class="field">
<span>{{ selectedFile }} Inhalt</span>
<textarea
v-model="selectedFileContent"
class="nexus-field file-content"
rows="12"
maxlength="30000"
:placeholder="`${selectedFile} leer lassen, um OpenClaws Standard zu verwenden.`"
></textarea>
</label>
</div>
</section>
<section class="review-panel nexus-panel" aria-labelledby="proposal-review-title">
<div>
<span class="review-eyebrow">Review</span>
<h2 id="proposal-review-title">{{ form.name.trim() || 'Noch kein Name' }}</h2>
<p>{{ form.role.trim() || 'Keine Rolle angegeben' }}</p>
</div>
<dl>
<div><dt>Modell</dt><dd>{{ form.model || 'OpenClaw-Standard' }}</dd></div>
<div><dt>Dateien</dt><dd>{{ configuredFileCount }}</dd></div>
<div><dt>Nächster Schritt</dt><dd>Owner-Freigabe</dd></div>
</dl>
</section>
<div v-if="formError" class="nexus-state nexus-state--error" role="alert">
{{ formError }}
</div>
<footer class="form-actions">
<RouterLink class="nexus-button" to="/agents">Abbrechen</RouterLink>
<button
type="submit"
class="nexus-button nexus-button--primary"
:disabled="createMutation.isPending.value || !canSubmit"
>
<LoaderCircle v-if="createMutation.isPending.value" class="spin" :size="16" aria-hidden="true" />
<ChevronRight v-else :size="16" aria-hidden="true" />
{{ createMutation.isPending.value ? 'Wird angelegt…' : 'Vorschlag anlegen' }}
</button>
</footer>
</form>
</template>
</div>
</template>
<style scoped>
.agent-create-page { padding-bottom: 40px; }
.back-link { width: 42px; height: 42px; padding: 0; flex: 0 0 42px; text-decoration: none; }
.availability-panel { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: start; gap: 12px; border-color: var(--status-work-line); }
.availability-panel > svg { color: var(--st-work); margin-top: 2px; }
.availability-panel--blocked { border-color: var(--status-block-line); background: linear-gradient(135deg, var(--status-block-bg), var(--glass)); }
.availability-panel--blocked > svg { color: var(--st-block); }
.availability-panel strong { color: var(--tx); }
.availability-panel p { margin: 4px 0 0; color: var(--tx-2); line-height: 1.55; }
.availability-panel > .nexus-mono { color: var(--tx-3); }
.proposal-form { display: grid; gap: 16px; }
.form-panel { display: grid; gap: 18px; }
.section-heading { display: flex; align-items: center; gap: 12px; padding-bottom: 14px; border-bottom: 1px solid var(--line); }
.section-heading h2, .review-panel h2 { margin: 0; font-size: 16px; }
.section-heading p { margin: 3px 0 0; color: var(--tx-2); line-height: 1.45; }
.section-icon { width: 38px; height: 38px; display: grid; place-items: center; flex: 0 0 auto; border: 1px solid var(--line-2); border-radius: var(--r-sm); background: var(--grad-soft); color: var(--a-mid); }
.configured-count { margin-left: auto; }
.field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.field { display: grid; gap: 7px; color: var(--tx-2); font-weight: 650; }
.field > span { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.field em { color: var(--a-mid); font-family: var(--font-mono-v2); font-size: 10px; font-style: normal; text-transform: uppercase; }
.nexus-field { width: 100%; padding: 10px 12px; }
textarea.nexus-field { line-height: 1.55; resize: vertical; }
.file-editor { display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: 14px; align-items: start; }
.file-content { min-height: 250px; font-family: var(--font-mono-v2); font-size: 12px; }
.review-panel { display: grid; grid-template-columns: minmax(0, 1fr) minmax(260px, .7fr); gap: 20px; align-items: center; }
.review-eyebrow { display: block; margin-bottom: 6px; color: var(--a-mid); font-family: var(--font-mono-v2); font-size: 10px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; }
.review-panel p { margin: 5px 0 0; color: var(--tx-2); }
.review-panel dl { display: grid; gap: 8px; margin: 0; }
.review-panel dl div { display: flex; justify-content: space-between; gap: 18px; padding-bottom: 8px; border-bottom: 1px solid var(--line); }
.review-panel dl div:last-child { padding-bottom: 0; border-bottom: 0; }
.review-panel dt { color: var(--tx-3); }
.review-panel dd { margin: 0; color: var(--tx); font-family: var(--font-mono-v2); text-align: right; overflow-wrap: anywhere; }
.form-actions { display: flex; justify-content: flex-end; gap: 10px; }
.form-actions a { text-decoration: none; }
.spin { animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 720px) {
.field-grid, .file-editor, .review-panel { grid-template-columns: minmax(0, 1fr); }
.availability-panel { grid-template-columns: auto minmax(0, 1fr); }
.availability-panel > .nexus-mono { grid-column: 2; }
.configured-count { margin-left: 0; }
}
</style>
+266 -294
View File
@@ -1,39 +1,37 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { onMounted, onUnmounted, ref, computed, watch } from 'vue'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Bot, Loader2, AlertCircle, Activity, RefreshCw } from '@lucide/vue'
import { apiFetch } from '../services/api'
import type { AgentDetail } from '../types'
import {
applyAgentFileWrite,
useAgentActivityQuery,
useAgentDetailQuery,
useAgentFileQuery,
useAgentFilesQuery,
useAgentSummaryQuery,
type AgentActivityDto,
type AgentFileDto,
type AgentFileWriteDto,
} from '../api/agentDetail'
import { queryClient, queryKeys } from '../api/queryClient'
import ConfigTabs from '../components/config/ConfigTabs.vue'
import ConfigEditor from '../components/config/ConfigEditor.vue'
import { openDashboardLiveStream } from '../services/live'
import AgentWorkspaceBrowser from '../components/config/AgentWorkspaceBrowser.vue'
import StandingOrdersEditor from '../components/config/StandingOrdersEditor.vue'
import { subscribeDomainEventState } from '../services/domainEvents'
import { createMutationRequestContext } from '../services/mutationContext'
import { useAuthStore } from '../stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const agent = ref<AgentDetail | null>(null)
const loading = ref(false)
const error = ref('')
const configFiles = ref<ConfigFileInfo[]>([])
const activeTab = ref(0)
const configsLoading = ref(false)
const configsError = ref('')
const activityItems = ref<AgentActivityItem[]>([])
const activityLoading = ref(false)
const activityError = ref('')
const summaryLoading = ref(false)
const summaryError = ref('')
const summary = ref<AgentSummary | null>(null)
const liveConnected = ref(false)
const liveUnavailable = ref(false)
let liveAbort: AbortController | null = null
let activityReloadTimer: ReturnType<typeof setTimeout> | null = null
let liveReconnectTimer: ReturnType<typeof setTimeout> | null = null
let liveStreamStopped = false
let lastLiveSequence = 0
const initLoading = ref(true)
let unsubscribeDomainState: (() => void) | null = null
interface EditorState {
content: string
@@ -45,56 +43,8 @@ interface EditorState {
backupStatus: string
reloadStatus: string
reloadMessage: string
}
interface ConfigFileInfo {
fileName: string
size: number
modifiedAt: string
}
interface ConfigFileDetail extends ConfigFileInfo {
content: string
}
interface AgentActivityItem {
id: number | null
type: string
message: string
at: string
source: string
relativeTime?: string | null
}
interface AgentSummary {
now: AgentSummaryItem
today: AgentSummaryItem
generatedAt: string
}
interface AgentSummaryItem {
text: string
source: string
timestamp?: string | null
}
interface SaveConfigResult {
fileName: string
size: number
modifiedAt: string
validation: {
status: string
fileKind: string
errors: string[]
}
backup: {
status: string
backupCreated: boolean
}
reloadCheck: {
status: string
message: string
}
contentHash: string
verified: boolean
}
const editorState = ref<EditorState>({
@@ -107,11 +57,32 @@ const editorState = ref<EditorState>({
backupStatus: 'not_applicable',
reloadStatus: 'not_supported',
reloadMessage: '',
contentHash: '',
verified: false,
})
const agentId = route.params.id as string
const orderedTabs = ['IDENTITY.md', 'SOUL.md', 'AGENTS.md', 'TOOLS.md', 'HEARTBEAT.md', 'USER.md']
const canConfigure = computed(() => auth.user?.role === 'owner')
const agentQuery = useAgentDetailQuery(agentId)
const activityQuery = useAgentActivityQuery(agentId)
const summaryQuery = useAgentSummaryQuery(agentId)
const filesQuery = useAgentFilesQuery(agentId, canConfigure)
const agent = computed(() => agentQuery.data.value ?? null)
const activityItems = computed(() => activityQuery.data.value ?? [])
const summary = computed(() => summaryQuery.data.value ?? null)
const configFiles = computed(() => filesQuery.data.value?.files ?? [])
const loading = computed(() => agentQuery.isPending.value)
const activityLoading = computed(() => activityQuery.isFetching.value)
const summaryLoading = computed(() => summaryQuery.isFetching.value)
const error = computed(() =>
agentQuery.error.value instanceof Error ? agentQuery.error.value.message : '',
)
const activityError = computed(() =>
activityQuery.error.value instanceof Error ? activityQuery.error.value.message : '',
)
const summaryError = computed(() =>
summaryQuery.error.value instanceof Error ? summaryQuery.error.value.message : '',
)
const currentFile = computed(() => {
if (!configFiles.value.length) return null
@@ -120,8 +91,36 @@ const currentFile = computed(() => {
})
const activeTabFileName = computed(() => {
return orderedTabs[activeTab.value] || null
return currentFile.value?.name || null
})
const requestedFileName = computed(() =>
typeof route.query.file === 'string' ? route.query.file : '',
)
const fileQuery = useAgentFileQuery(
agentId,
computed(() => activeTabFileName.value ?? ''),
canConfigure,
)
const configsLoading = computed(() =>
canConfigure.value
&& (
filesQuery.isPending.value
|| (Boolean(activeTabFileName.value) && fileQuery.isFetching.value)
),
)
const configsError = computed(() => {
if (!canConfigure.value) {
return 'Nur Owner dürfen sensible OpenClaw-Agentdateien lesen und bearbeiten.'
}
const cause = fileQuery.error.value ?? filesQuery.error.value
return cause instanceof Error ? cause.message : ''
})
const initLoading = computed(() =>
loading.value
|| activityQuery.isPending.value
|| summaryQuery.isPending.value
|| (canConfigure.value && filesQuery.isPending.value),
)
const fallbackName = computed(() => {
return agentId.charAt(0).toUpperCase() + agentId.slice(1)
@@ -143,10 +142,10 @@ function formatModifiedAt(dateStr: string): string {
const statusColor = (status: string): string => {
switch (status) {
case 'Online': return '#51d49a'
case 'Degraded': return '#e5b05e'
case 'Offline': return '#e16e75'
default: return '#7e8799'
case 'Online': return 'var(--st-work)'
case 'Degraded': return 'var(--st-queue)'
case 'Offline': return 'var(--st-block)'
default: return 'var(--st-idle)'
}
}
@@ -159,7 +158,7 @@ function formatLastSeen(dateStr?: string): string {
})
}
function formatActivityTime(item: AgentActivityItem): string {
function formatActivityTime(item: AgentActivityDto): string {
if (item.relativeTime) return item.relativeTime
const d = new Date(item.at)
return d.toLocaleDateString('de-DE', {
@@ -175,54 +174,14 @@ function activityTypeLabel(type: string): string {
return 'Activity'
}
async function loadAgent() {
loading.value = true
error.value = ''
try {
const response = await apiFetch(`/api/v1/agents/${agentId}`)
if (!response.ok) throw new Error(`Agent "${agentId}" not found`)
agent.value = await response.json()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load agent data'
} finally {
loading.value = false
}
}
async function loadActivity() {
activityLoading.value = true
activityError.value = ''
try {
const response = await apiFetch(`/api/v1/agents/${agentId}/activity`)
if (!response.ok) throw new Error('Failed to load activity')
activityItems.value = await response.json()
} catch (e) {
activityError.value = e instanceof Error ? e.message : 'Failed to load activity'
} finally {
activityLoading.value = false
}
}
async function loadSummary() {
summaryLoading.value = true
summaryError.value = ''
try {
const response = await apiFetch(`/api/v1/agents/${agentId}/summary`)
if (!response.ok) throw new Error('Failed to load summary')
summary.value = await response.json()
} catch (e) {
summaryError.value = e instanceof Error ? e.message : 'Failed to load summary'
} finally {
summaryLoading.value = false
}
}
function scheduleActivityReload() {
if (activityReloadTimer) return
activityReloadTimer = setTimeout(async () => {
activityReloadTimer = null
await loadActivity()
await loadSummary()
await Promise.allSettled([
queryClient.invalidateQueries({ queryKey: queryKeys.agentActivity(agentId) }),
queryClient.invalidateQueries({ queryKey: queryKeys.agentSummary(agentId) }),
])
}, 250)
}
@@ -245,113 +204,80 @@ function summarySourceLabel(source: string): string {
}
}
function scheduleStreamReconnect() {
if (liveStreamStopped || liveReconnectTimer) return
liveReconnectTimer = setTimeout(() => {
liveReconnectTimer = null
void connectActivityStream()
}, 1500)
function onDomainEvent(event: Event) {
const detail = (event as CustomEvent<{
eventType?: string
entity?: { type?: string }
payload?: { agentIds?: unknown[] }
}>).detail
if (detail?.eventType !== 'activity.created' || detail.entity?.type !== 'activity') return
const agentIds = Array.isArray(detail.payload?.agentIds)
? detail.payload.agentIds.map(id => String(id).toLowerCase())
: []
if (agentIds.includes(agentId.toLowerCase())) scheduleActivityReload()
}
async function connectActivityStream() {
liveAbort?.abort()
liveAbort = new AbortController()
try {
const stream = await openDashboardLiveStream((event, data) => {
const cursor = (data as any)?.cursor
if (typeof cursor?.sequence === 'number') lastLiveSequence = cursor.sequence
if (event === 'snapshot') {
liveConnected.value = true
liveUnavailable.value = false
return
}
if (event !== 'update') return
const envelope = (data as any)?.envelope
if (envelope?.type !== 'activity.created') return
const agentIds = Array.isArray(envelope?.payload?.agentIds)
? envelope.payload.agentIds.map((id: unknown) => String(id).toLowerCase())
: []
if (agentIds.includes(agentId.toLowerCase())) {
scheduleActivityReload()
}
}, { signal: liveAbort.signal, afterSequence: lastLiveSequence || null })
await stream.closed
if (!liveStreamStopped) {
liveConnected.value = false
scheduleStreamReconnect()
}
} catch {
liveConnected.value = false
liveUnavailable.value = true
if (!liveStreamStopped) scheduleStreamReconnect()
function hydrateEditor(data: AgentFileDto) {
if (editorState.value.dirty || editorState.value.saving) return
editorState.value = {
content: data.content ?? '',
savedContent: data.content ?? '',
saving: false,
dirty: false,
saveStatus: 'idle',
saveMessage: data.missing ? 'Datei fehlt noch und wird beim ersten Speichern angelegt.' : '',
backupStatus: 'not_applicable',
reloadStatus: 'not_supported',
reloadMessage: 'Nexus meldet erst nach erfolgreichem OpenClaw-Read-back „verifiziert“.',
contentHash: data.contentHash,
verified: false,
}
}
async function loadConfigFiles() {
configsLoading.value = true
configsError.value = ''
try {
const response = await apiFetch(`/api/v1/agents/${agentId}/config`)
if (!response.ok) throw new Error('Failed to load config files')
configFiles.value = await response.json()
if (configFiles.value.length > 0) {
const fileName = configFiles.value[0].fileName
const tabIndex = orderedTabs.indexOf(fileName)
activeTab.value = tabIndex >= 0 ? tabIndex : 0
}
if (configFiles.value.length > 0) {
await loadFileContent(configFiles.value[0].fileName)
}
} catch (e) {
configsError.value = e instanceof Error ? e.message : 'Failed to load config files'
} finally {
configsLoading.value = false
}
function refreshActivity(): void {
void Promise.allSettled([
activityQuery.refetch(),
summaryQuery.refetch(),
])
}
async function loadFileContent(fileName: string) {
try {
const response = await apiFetch(`/api/v1/agents/${agentId}/config/${encodeURIComponent(fileName)}`)
if (!response.ok) throw new Error(`Failed to load ${fileName}`)
const data: ConfigFileDetail = await response.json()
editorState.value = {
content: data.content,
savedContent: data.content,
saving: false,
dirty: false,
saveStatus: 'idle',
saveMessage: '',
backupStatus: 'not_applicable',
reloadStatus: 'not_supported',
reloadMessage: '',
}
} catch (e) {
editorState.value = {
content: '',
savedContent: '',
saving: false,
dirty: false,
saveStatus: 'error',
saveMessage: e instanceof Error ? e.message : `Failed to load ${fileName}`,
backupStatus: 'not_applicable',
reloadStatus: 'not_supported',
reloadMessage: '',
}
}
}
watch(
() => fileQuery.data.value,
data => {
if (data) hydrateEditor(data)
},
{ immediate: true },
)
watch(
() => configFiles.value.length,
length => {
if (!length || activeTab.value >= length) activeTab.value = 0
},
{ immediate: true },
)
watch(
[configFiles, requestedFileName],
([files, requested]) => {
if (!requested || editorState.value.dirty) return
const index = files.findIndex(file => file.name === requested)
if (index >= 0) activeTab.value = index
},
{ immediate: true },
)
async function switchTab(index: number) {
if (activeTab.value === index) return
if (editorState.value.dirty &&
!window.confirm('Ungespeicherte Änderungen verwerfen und eine andere Datei öffnen?')) {
return
}
activeTab.value = index
const fileName = orderedTabs[index]
if (!fileName) return
await loadFileContent(fileName)
const fileName = configFiles.value[index]?.name
if (fileName && route.query.file !== fileName) {
await router.replace({ query: { ...route.query, file: fileName } })
}
}
function onContentChange(value: string) {
@@ -369,36 +295,57 @@ async function saveFile() {
editorState.value.backupStatus = 'not_applicable'
editorState.value.reloadStatus = 'not_supported'
editorState.value.reloadMessage = ''
editorState.value.verified = false
try {
const response = await apiFetch(`/api/v1/agents/${agentId}/config/${encodeURIComponent(fileName)}`, {
const requestContext = createMutationRequestContext(
'openclaw-agent-file-set',
`${agentId}:${fileName}:${editorState.value.contentHash}`,
)
const response = await apiFetch(
`/api/v1/openclaw/agents/${encodeURIComponent(agentId)}/files/${encodeURIComponent(fileName)}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: editorState.value.content }),
headers: requestContext.headers,
body: JSON.stringify({
content: editorState.value.content,
expectedHash: editorState.value.contentHash,
}),
})
if (!response.ok) {
const err = await response.json().catch(() => ({}))
const problem = err as { error?: string; errors?: Record<string, string[]> }
const detail = problem.error
const problem = err as {
error?: string
message?: string
errors?: Record<string, string[]>
currentHash?: string
}
const detail = problem.message
|| problem.error
|| Object.values(problem.errors ?? {}).flat().join(' ')
|| 'Failed to save file'
if (response.status === 409 && problem.currentHash) {
editorState.value.reloadMessage = 'OpenClaw enthält eine neuere Version. Lade die Datei neu, bevor du erneut speicherst.'
}
throw new Error(detail)
}
const result: SaveConfigResult = await response.json()
const result: AgentFileWriteDto = await response.json()
editorState.value.content = result.file.content ?? editorState.value.content
editorState.value.savedContent = editorState.value.content
editorState.value.dirty = false
editorState.value.saveStatus = 'saved'
editorState.value.saveMessage = `Gespeichert · Backup ${result.backup.status}`
editorState.value.backupStatus = result.backup.status
editorState.value.reloadStatus = result.reloadCheck.status
editorState.value.reloadMessage = result.reloadCheck.message
editorState.value.saveMessage = result.message
editorState.value.backupStatus = 'not_applicable'
editorState.value.reloadStatus = result.verified ? 'verified' : 'not_supported'
editorState.value.reloadMessage = result.verified
? 'OpenClaw hat den gespeicherten Inhalt identisch zurückgegeben.'
: 'Der Runtime-Reload wurde nicht bestätigt.'
editorState.value.contentHash = result.file.contentHash
editorState.value.verified = result.verified
const idx = configFiles.value.findIndex(f => f.fileName === fileName)
if (idx >= 0) {
configFiles.value[idx] = { ...configFiles.value[idx], size: result.size, modifiedAt: result.modifiedAt }
}
await applyAgentFileWrite(queryClient, agentId, result)
setTimeout(() => {
if (editorState.value.saveStatus === 'saved') {
@@ -411,38 +358,47 @@ async function saveFile() {
editorState.value.saveMessage = e instanceof Error ? e.message : 'Failed to save file'
editorState.value.backupStatus = 'not_applicable'
editorState.value.reloadStatus = 'not_supported'
editorState.value.verified = false
} finally {
editorState.value.saving = false
}
}
onMounted(async () => {
liveStreamStopped = false
initLoading.value = true
await Promise.allSettled([
loadAgent(),
loadConfigFiles(),
loadActivity(),
loadSummary(),
])
connectActivityStream()
initLoading.value = false
onMounted(() => {
window.addEventListener('nexus:domain-event', onDomainEvent)
unsubscribeDomainState = subscribeDomainEventState(state => {
liveConnected.value = state === 'open'
liveUnavailable.value = state === 'unsupported' || state === 'error'
})
})
function onBeforeUnload(event: BeforeUnloadEvent) {
if (!editorState.value.dirty) return
event.preventDefault()
event.returnValue = ''
}
window.addEventListener('beforeunload', onBeforeUnload)
onBeforeRouteLeave(() => {
if (!editorState.value.dirty) return true
return window.confirm('Ungespeicherte Agent-Konfiguration verwerfen und die Seite verlassen?')
})
onUnmounted(() => {
liveStreamStopped = true
liveAbort?.abort()
liveAbort = null
window.removeEventListener('nexus:domain-event', onDomainEvent)
unsubscribeDomainState?.()
unsubscribeDomainState = null
if (activityReloadTimer) clearTimeout(activityReloadTimer)
if (liveReconnectTimer) clearTimeout(liveReconnectTimer)
window.removeEventListener('beforeunload', onBeforeUnload)
})
</script>
<template>
<div class="detail-page">
<button class="back-link" @click="router.push('/team')">
<div class="detail-page nexus-page">
<button type="button" class="back-link" @click="router.push('/agents')">
<ArrowLeft :size="14" />
Zurück zum Team
Zurück zu Agents
</button>
<div v-if="initLoading" class="status-message">
@@ -482,13 +438,19 @@ onUnmounted(() => {
<section class="thinking-section">
<header class="section-head">
<div>
<span class="eyebrow">LIVE</span>
<h2>Thinking <span :class="['live-dot', { on: liveConnected }]"></span></h2>
<span class="eyebrow">AUTHORITATIVE ACTIVITY</span>
<h2>Agent events <span :class="['live-dot', { on: liveConnected }]"></span></h2>
<p class="section-note">
Nexus activity streams live. Gateway session history remains read-only fallback and refreshes when related Nexus events arrive or on manual reload.
</p>
</div>
<button class="icon-button" :disabled="activityLoading || summaryLoading" @click="Promise.allSettled([loadActivity(), loadSummary()])">
<button
type="button"
class="icon-button"
aria-label="Agent activity and summary refresh"
:disabled="activityLoading || summaryLoading"
@click="refreshActivity"
>
<RefreshCw :size="14" :class="{ spin: activityLoading }" />
</button>
</header>
@@ -572,15 +534,15 @@ onUnmounted(() => {
<template v-else>
<ConfigTabs
:tabs="orderedTabs"
:tabs="configFiles.map(file => file.name)"
:active-tab="activeTab"
@switch-tab="switchTab"
/>
<ConfigEditor
:file-name="activeTabFileName"
:file-size="currentFile ? formatFileSize(currentFile.size) : ''"
:file-modified="currentFile ? formatModifiedAt(currentFile.modifiedAt) : ''"
:file-size="currentFile?.size != null ? formatFileSize(Number(currentFile.size)) : 'Noch nicht angelegt'"
:file-modified="currentFile?.updatedAt ? formatModifiedAt(currentFile.updatedAt) : 'Keine Änderung gemeldet'"
:content="editorState.content"
:dirty="editorState.dirty"
:saving="editorState.saving"
@@ -589,9 +551,19 @@ onUnmounted(() => {
:backup-status="editorState.backupStatus"
:reload-status="editorState.reloadStatus"
:reload-message="editorState.reloadMessage"
:content-hash="editorState.contentHash"
:verified="editorState.verified"
:read-only="!canConfigure"
@update-content="onContentChange"
@save="saveFile"
/>
<StandingOrdersEditor
v-if="activeTabFileName === 'AGENTS.md'"
:content="editorState.content"
:read-only="!canConfigure"
@apply="onContentChange"
/>
<AgentWorkspaceBrowser v-if="canConfigure" :agent-id="agentId" />
</template>
</div>
</template>
@@ -612,15 +584,15 @@ onUnmounted(() => {
border: 1px solid var(--line);
border-radius: 7px;
background: var(--panel);
color: #7e8799;
color: var(--tx-3);
font-size: 10.5px;
cursor: pointer;
margin-bottom: 20px;
transition: border-color 0.15s, color 0.15s;
}
.back-link:hover {
border-color: #443d7c;
color: #d8dbe3;
border-color: var(--line-3);
color: var(--tx);
}
.status-message {
@@ -629,11 +601,11 @@ onUnmounted(() => {
justify-content: center;
gap: 8px;
padding: 48px;
color: #7e8799;
color: var(--tx-3);
font-size: 12px;
}
.status-message.error {
color: #e16e75;
color: var(--st-block);
}
.status-message.compact {
padding: 20px;
@@ -659,16 +631,16 @@ onUnmounted(() => {
display: grid;
place-items: center;
border-radius: 12px;
background: rgba(139,124,246,.1);
color: #8b7cf6;
background: color-mix(in srgb, var(--a-mid) 10%, transparent);
color: var(--a-mid);
flex-shrink: 0;
}
.agent-avatar.iris { background: rgba(139,124,246,.15); color: #8b7cf6; }
.agent-avatar.programmer { background: rgba(77,140,246,.15); color: #4d8cf6; }
.agent-avatar.architekt { background: rgba(77,168,246,.15); color: #4da8f6; }
.agent-avatar.reviewer { background: rgba(246,168,77,.15); color: #f6a84d; }
.agent-avatar.researcher { background: rgba(139,77,246,.15); color: #8b4df6; }
.agent-avatar.executor { background: rgba(77,246,212,.15); color: #4df6d4; }
.agent-avatar.iris { background: color-mix(in srgb, var(--a-mid) 15%, transparent); color: var(--a-mid); }
.agent-avatar.programmer { background: color-mix(in srgb, var(--a-blue) 15%, transparent); color: var(--a-blue); }
.agent-avatar.architekt { background: color-mix(in srgb, var(--a-blue) 15%, transparent); color: var(--a-blue); }
.agent-avatar.reviewer { background: color-mix(in srgb, var(--st-review) 15%, transparent); color: var(--st-review); }
.agent-avatar.researcher { background: color-mix(in srgb, var(--a-mid) 15%, transparent); color: var(--a-mid); }
.agent-avatar.executor { background: color-mix(in srgb, var(--st-think) 15%, transparent); color: var(--st-think); }
.agent-header-info {
flex: 1;
@@ -678,7 +650,7 @@ onUnmounted(() => {
font-size: 8.5px;
font-weight: 700;
letter-spacing: .12em;
color: var(--accent, #7b6ef2);
color: var(--a-mid);
text-transform: uppercase;
margin-bottom: 2px;
}
@@ -686,7 +658,7 @@ onUnmounted(() => {
margin: 0 0 4px;
font-size: 20px;
font-weight: 600;
color: #e8eaf0;
color: var(--tx);
}
.agent-status-row {
display: flex;
@@ -702,14 +674,14 @@ onUnmounted(() => {
}
.status-label {
font-size: 11px;
color: #7e8799;
color: var(--tx-3);
display: inline-flex;
align-items: center;
gap: 4px;
}
.status-label.muted { color: #6b7385; }
.status-label.muted { color: var(--tx-3); }
.status-label.mono { font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; }
.status-sep { color: #3d4152; font-size: 11px; }
.status-sep { color: var(--line-3); font-size: 11px; }
.thinking-section {
border: 1px solid var(--line);
@@ -731,11 +703,11 @@ onUnmounted(() => {
font-size: 8.5px;
font-weight: 700;
letter-spacing: .12em;
color: var(--accent, #7b6ef2);
color: var(--a-mid);
}
.section-head h2 {
margin: 2px 0 0;
color: #e8eaf0;
color: var(--tx);
font-size: 13px;
font-weight: 600;
display: inline-flex;
@@ -744,7 +716,7 @@ onUnmounted(() => {
}
.section-note {
margin: 6px 0 0;
color: #6f788b;
color: var(--tx-3);
font-size: 10px;
line-height: 1.45;
max-width: 560px;
@@ -753,18 +725,18 @@ onUnmounted(() => {
width: 6px;
height: 6px;
border-radius: 999px;
background: #6b7385;
background: var(--tx-3);
}
.live-dot.on {
background: #51d49a;
background: var(--st-work);
}
.icon-button {
width: 30px;
height: 30px;
border: 1px solid var(--line);
border-radius: 7px;
background: rgba(255,255,255,.03);
color: #9ba3b5;
background: color-mix(in srgb, var(--tx) 3%, transparent);
color: var(--tx-2);
display: grid;
place-items: center;
cursor: pointer;
@@ -780,7 +752,7 @@ onUnmounted(() => {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
background: rgba(255,255,255,.05);
background: color-mix(in srgb, var(--tx) 5%, transparent);
border-bottom: 1px solid var(--line);
}
.summary-row > div {
@@ -790,13 +762,13 @@ onUnmounted(() => {
.summary-card small {
display: block;
margin-top: 7px;
color: #6f788b;
color: var(--tx-3);
font-size: 9.5px;
line-height: 1.4;
}
.summary-row span {
display: block;
color: #6f788b;
color: var(--tx-3);
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
@@ -804,14 +776,14 @@ onUnmounted(() => {
}
.summary-row p {
margin: 0;
color: #cbd0dc;
color: var(--tx);
font-size: 11px;
line-height: 1.5;
overflow-wrap: anywhere;
}
.thinking-item {
padding: 12px 16px;
border-bottom: 1px solid rgba(255,255,255,.05);
border-bottom: 1px solid color-mix(in srgb, var(--tx) 5%, transparent);
}
.thinking-item:last-child {
border-bottom: 0;
@@ -821,20 +793,20 @@ onUnmounted(() => {
align-items: center;
gap: 8px;
margin-bottom: 6px;
color: #6f788b;
color: var(--tx-3);
font-size: 10px;
}
.type-pill {
padding: 2px 6px;
border-radius: 999px;
border: 1px solid rgba(123,110,242,.24);
color: #aaa1ff;
background: rgba(123,110,242,.08);
border: 1px solid color-mix(in srgb, var(--a-mid) 24%, transparent);
color: var(--tx-2);
background: color-mix(in srgb, var(--a-mid) 8%, transparent);
font-size: 9px;
}
.thinking-item p {
margin: 0;
color: #cbd0dc;
color: var(--tx);
font-size: 11px;
line-height: 1.55;
overflow-wrap: anywhere;
@@ -0,0 +1,466 @@
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
import { useRoute } from 'vue-router'
import {
AlertTriangle,
ArrowLeft,
Bot,
Check,
Clock3,
FileText,
LoaderCircle,
RefreshCw,
RotateCcw,
ShieldCheck,
X,
} from '@lucide/vue'
import {
canApproveAgentProposal,
canRejectAgentProposal,
canRetryAgentProposal,
getAgentProposalStatusMeta,
useAgentProposalQuery,
useApproveAgentProposalMutation,
useRejectAgentProposalMutation,
useRetryAgentProposalMutation,
} from '../api/agentProposals'
import { useAuthStore } from '../stores/auth'
import {
normalizeOperationResult,
type OperationResultDto,
} from '../api/contracts'
import OperationResultCard from '../components/mission-control/OperationResultCard.vue'
type ActionMode = 'approve' | 'reject' | 'retry'
const route = useRoute()
const auth = useAuthStore()
const isOwner = computed(() => auth.isOwner)
const proposalId = computed(() => String(route.params.proposalId ?? ''))
const proposalQuery = useAgentProposalQuery(proposalId, isOwner)
const approveMutation = useApproveAgentProposalMutation()
const rejectMutation = useRejectAgentProposalMutation()
const retryMutation = useRetryAgentProposalMutation()
const actionMode = ref<ActionMode | null>(null)
const actionReason = ref('')
const actionRequestId = ref('')
const actionError = ref('')
const operationMessage = ref('')
const operationResult = ref<OperationResultDto | null>(null)
const actionDialog = ref<HTMLElement | null>(null)
const actionDialogTitle = ref<HTMLElement | null>(null)
let actionTrigger: HTMLElement | null = null
const proposal = computed(() => proposalQuery.data.value ?? null)
const statusMeta = computed(() => getAgentProposalStatusMeta(proposal.value?.status ?? ''))
const actionPending = computed(() =>
approveMutation.isPending.value || rejectMutation.isPending.value || retryMutation.isPending.value,
)
const actionHeading = computed(() => {
if (actionMode.value === 'approve') return 'Agent-Provisionierung freigeben'
if (actionMode.value === 'reject') return 'Vorschlag ablehnen'
return 'Provisionierung erneut versuchen'
})
const actionCopy = computed(() => {
if (actionMode.value === 'approve') {
return 'Nexus darf danach genau diesen Proposal-Stand an OpenClaw übergeben.'
}
if (actionMode.value === 'reject') {
return 'Der Vorschlag bleibt als Audit-Eintrag erhalten und wird nicht provisioniert.'
}
return 'Nexus startet einen neuen kontrollierten Versuch und prüft das OpenClaw-Inventar erneut.'
})
function formatDate(value: string | null | undefined): string {
if (!value) return '—'
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) return value
return new Intl.DateTimeFormat('de-DE', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(parsed)
}
function openAction(mode: ActionMode) {
actionTrigger = document.activeElement instanceof HTMLElement ? document.activeElement : null
actionMode.value = mode
actionReason.value = ''
actionError.value = ''
actionRequestId.value = crypto.randomUUID()
void nextTick(() => actionDialogTitle.value?.focus())
}
function closeAction(force = false) {
if (actionPending.value && !force) return
actionMode.value = null
actionReason.value = ''
actionError.value = ''
actionRequestId.value = ''
void nextTick(() => {
if (actionTrigger?.isConnected) actionTrigger.focus()
actionTrigger = null
})
}
function onDialogKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault()
closeAction()
return
}
if (event.key !== 'Tab' || !actionDialog.value) return
const focusable = [...actionDialog.value.querySelectorAll<HTMLElement>(
'button:not(:disabled), textarea:not(:disabled), input:not(:disabled), select:not(:disabled), a[href]',
)]
if (!focusable.length) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
}
}
async function confirmAction() {
if (!proposal.value || !actionMode.value) return
if (actionMode.value === 'reject' && !actionReason.value.trim()) {
actionError.value = 'Bitte dokumentiere den Grund für die Ablehnung.'
return
}
actionError.value = ''
operationMessage.value = ''
operationResult.value = null
const input = {
proposalId: proposal.value.id,
expectedRevision: proposal.value.revision,
reason: actionReason.value.trim() || null,
clientRequestId: actionRequestId.value,
}
try {
const operation = actionMode.value === 'approve'
? await approveMutation.mutateAsync(input)
: actionMode.value === 'reject'
? await rejectMutation.mutateAsync(input)
: await retryMutation.mutateAsync(input)
if (!operation.ok) {
actionError.value = operation.recovery || operation.message
return
}
operationMessage.value = operation.message
operationResult.value = normalizeOperationResult(operation.operation)
closeAction(true)
await proposalQuery.refetch()
} catch (error) {
actionError.value = error instanceof Error ? error.message : 'Aktion konnte nicht abgeschlossen werden.'
}
}
</script>
<template>
<div class="proposal-detail-page nexus-page">
<header class="nexus-page-header">
<div class="nexus-page-header__identity">
<RouterLink class="back-link" to="/agents" aria-label="Zurück zu Agents">
<ArrowLeft :size="17" aria-hidden="true" />
</RouterLink>
<div class="nexus-page-header__icon">
<ShieldCheck :size="21" aria-hidden="true" />
</div>
<div>
<h1>{{ proposal?.requestedName || 'Agent-Vorschlag' }}</h1>
<p class="page-subtitle">Proposal {{ proposalId }}</p>
</div>
</div>
<button
type="button"
class="nexus-button"
:disabled="proposalQuery.isFetching.value"
@click="proposalQuery.refetch()"
>
<RefreshCw :class="{ spin: proposalQuery.isFetching.value }" :size="15" aria-hidden="true" />
Aktualisieren
</button>
</header>
<div v-if="!isOwner" class="nexus-state nexus-state--error" role="alert">
<strong>Owner-Berechtigung erforderlich</strong>
<span>Agent-Vorschläge und Provisionierungsaktionen sind ausschließlich für Owner sichtbar.</span>
<RouterLink class="nexus-button" to="/agents">Zur Agentenübersicht</RouterLink>
</div>
<div v-else-if="proposalQuery.isPending.value" class="nexus-state nexus-state--loading" role="status">
<LoaderCircle class="spin" :size="18" aria-hidden="true" />
Vorschlag wird geladen
</div>
<div v-else-if="proposalQuery.isError.value" class="nexus-state nexus-state--error" role="alert">
<strong>Vorschlag nicht verfügbar</strong>
<span>{{ proposalQuery.error.value?.message || 'Der Proposal-Vertrag konnte nicht gelesen werden.' }}</span>
<RouterLink class="nexus-button" to="/agents">Zur Agentenübersicht</RouterLink>
</div>
<template v-else-if="proposal">
<section class="status-hero nexus-panel" :class="`status-hero--${statusMeta.tone}`">
<span class="status-icon">
<Check v-if="proposal.status === 'ready'" :size="22" aria-hidden="true" />
<Clock3 v-else-if="proposal.status === 'awaiting_approval' || proposal.status === 'provisioning'" :size="22" aria-hidden="true" />
<AlertTriangle v-else-if="proposal.status === 'failed' || proposal.status === 'partial' || proposal.status === 'in_doubt'" :size="22" aria-hidden="true" />
<Bot v-else :size="22" aria-hidden="true" />
</span>
<div>
<span class="status-eyebrow">{{ statusMeta.label }}</span>
<h2>{{ proposal.requestedName }}</h2>
<p>{{ statusMeta.description }}</p>
</div>
<span class="revision nexus-mono">rev {{ proposal.revision }}</span>
</section>
<div v-if="operationMessage" class="nexus-state operation-success" role="status">
<Check :size="17" aria-hidden="true" />
{{ operationMessage }}
</div>
<OperationResultCard
v-if="operationResult"
:result="operationResult"
title="Agent-Proposal"
/>
<div class="detail-layout">
<main class="detail-main nexus-stack">
<section class="nexus-panel detail-section">
<div class="section-heading">
<div>
<span class="section-eyebrow">Konfiguration</span>
<h2>Vorgeschlagener Agent</h2>
</div>
<RouterLink
v-if="proposal.openClawAgentId"
class="nexus-button"
:to="`/agents/${encodeURIComponent(proposal.openClawAgentId)}`"
>
Agent öffnen
</RouterLink>
</div>
<dl class="fact-grid">
<div><dt>Name</dt><dd>{{ proposal.requestedName }}</dd></div>
<div><dt>Agent-ID</dt><dd class="nexus-mono">{{ proposal.requestedAgentId }}</dd></div>
<div><dt>Rolle</dt><dd>{{ proposal.role || 'Nicht angegeben' }}</dd></div>
<div><dt>Modell</dt><dd class="nexus-mono">{{ proposal.model || 'OpenClaw-Standard' }}</dd></div>
<div class="fact-wide"><dt>Zweck</dt><dd>{{ proposal.description || 'Kein zusätzlicher Zweck angegeben.' }}</dd></div>
<div class="fact-wide"><dt>Verwalteter Workspace</dt><dd class="nexus-mono">{{ proposal.workspace }}</dd></div>
</dl>
</section>
<section class="nexus-panel detail-section">
<div class="section-heading">
<div>
<span class="section-eyebrow">OpenClaw-Dateien</span>
<h2>{{ proposal.files.length }} Initialdateien</h2>
</div>
<FileText :size="19" aria-hidden="true" />
</div>
<div v-if="proposal.files.length" class="file-list">
<details v-for="file in proposal.files" :key="file.name" class="file-item">
<summary>
<span>{{ file.name }}</span>
<span class="nexus-mono">{{ file.size }} B · {{ file.contentHash.slice(0, 12) }}</span>
</summary>
<pre v-if="file.content">{{ file.content }}</pre>
<p v-else>Der Dateiinhalt ist in dieser Ansicht nicht freigegeben.</p>
</details>
</div>
<div v-else class="inline-empty">
OpenClaw verwendet seine Standarddateien; der Proposal enthält keine eigenen Inhalte.
</div>
</section>
<section v-if="proposal.error" class="nexus-state nexus-state--error" role="alert">
<strong>{{ proposal.error.code }}</strong>
<span>{{ proposal.error.message }}</span>
<span v-if="proposal.error.recovery">{{ proposal.error.recovery }}</span>
</section>
<section v-if="proposal.rejectionReason" class="nexus-panel rejection-panel">
<span class="section-eyebrow">Ablehnungsgrund</span>
<p>{{ proposal.rejectionReason }}</p>
</section>
</main>
<aside class="detail-sidebar nexus-stack">
<section class="nexus-panel">
<span class="section-eyebrow">Audit</span>
<dl class="audit-list">
<div><dt>Quelle</dt><dd>{{ proposal.source }}</dd></div>
<div><dt>Angefragt von</dt><dd>{{ proposal.requestedBy }}</dd></div>
<div><dt>Erstellt</dt><dd>{{ formatDate(proposal.createdAt) }}</dd></div>
<div><dt>Aktualisiert</dt><dd>{{ formatDate(proposal.updatedAt) }}</dd></div>
<div v-if="proposal.approvedBy"><dt>Freigegeben von</dt><dd>{{ proposal.approvedBy }}</dd></div>
<div v-if="proposal.rejectedBy"><dt>Abgelehnt von</dt><dd>{{ proposal.rejectedBy }}</dd></div>
<div v-if="proposal.completedAt"><dt>Abgeschlossen</dt><dd>{{ formatDate(proposal.completedAt) }}</dd></div>
</dl>
</section>
<section
v-if="canApproveAgentProposal(proposal) || canRejectAgentProposal(proposal) || canRetryAgentProposal(proposal)"
class="nexus-panel action-panel"
>
<span class="section-eyebrow">Nächste Aktion</span>
<p>Jede Mutation wird mit Proposal-Revision, Idempotenz- und Korrelationsdaten gesendet.</p>
<button
v-if="canApproveAgentProposal(proposal)"
type="button"
class="nexus-button nexus-button--primary"
@click="openAction('approve')"
>
<Check :size="16" aria-hidden="true" />
Freigeben
</button>
<button
v-if="canRejectAgentProposal(proposal)"
type="button"
class="nexus-button nexus-button--danger"
@click="openAction('reject')"
>
<X :size="16" aria-hidden="true" />
Ablehnen
</button>
<button
v-if="canRetryAgentProposal(proposal)"
type="button"
class="nexus-button"
@click="openAction('retry')"
>
<RotateCcw :size="16" aria-hidden="true" />
{{ proposal.status === 'in_doubt' ? 'Read-only abgleichen' : 'Erneut versuchen' }}
</button>
</section>
</aside>
</div>
<div
v-if="actionMode"
class="action-overlay"
role="presentation"
@mousedown.self="closeAction()"
@keydown="onDialogKeydown"
>
<section
ref="actionDialog"
class="action-dialog nexus-panel"
role="dialog"
aria-modal="true"
aria-labelledby="proposal-action-title"
>
<div class="section-heading">
<div>
<span class="section-eyebrow">Bestätigung</span>
<h2 id="proposal-action-title" ref="actionDialogTitle" tabindex="-1">{{ actionHeading }}</h2>
</div>
<button
type="button"
class="dialog-close"
aria-label="Dialog schließen"
:disabled="actionPending"
@click="() => closeAction()"
>
<X :size="18" aria-hidden="true" />
</button>
</div>
<p>{{ actionCopy }}</p>
<label class="action-reason">
<span>{{ actionMode === 'reject' ? 'Begründung (Pflichtfeld)' : 'Notiz (optional)' }}</span>
<textarea
v-model="actionReason"
class="nexus-field"
rows="4"
maxlength="1000"
:required="actionMode === 'reject'"
></textarea>
</label>
<div v-if="actionError" class="nexus-state nexus-state--error" role="alert">{{ actionError }}</div>
<div class="dialog-actions">
<button type="button" class="nexus-button" :disabled="actionPending" @click="() => closeAction()">
Abbrechen
</button>
<button
type="button"
class="nexus-button"
:class="actionMode === 'reject' ? 'nexus-button--danger' : 'nexus-button--primary'"
:disabled="actionPending"
@click="confirmAction"
>
<LoaderCircle v-if="actionPending" class="spin" :size="16" aria-hidden="true" />
<Check v-else-if="actionMode === 'approve'" :size="16" aria-hidden="true" />
<X v-else-if="actionMode === 'reject'" :size="16" aria-hidden="true" />
<RotateCcw v-else :size="16" aria-hidden="true" />
Bestätigen
</button>
</div>
</section>
</div>
</template>
</div>
</template>
<style scoped>
.proposal-detail-page { padding-bottom: 40px; }
.back-link { width: 42px; height: 42px; padding: 0; flex: 0 0 42px; text-decoration: none; }
.status-hero { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 16px; }
.status-hero--success { border-color: var(--status-work-line); }
.status-hero--info { border-color: var(--status-think-line); }
.status-hero--warning { border-color: var(--status-queue-line); }
.status-hero--danger { border-color: var(--status-block-line); }
.status-icon { width: 48px; height: 48px; display: grid; place-items: center; border: 1px solid var(--line-2); border-radius: var(--r-sm); background: var(--grad-soft); color: var(--a-mid); }
.status-hero--success .status-icon { color: var(--st-work); background: var(--status-work-bg); border-color: var(--status-work-line); }
.status-hero--warning .status-icon { color: var(--st-queue); background: var(--status-queue-bg); border-color: var(--status-queue-line); }
.status-hero--danger .status-icon { color: var(--st-block); background: var(--status-block-bg); border-color: var(--status-block-line); }
.status-eyebrow, .section-eyebrow { display: block; margin-bottom: 5px; color: var(--a-mid); font-family: var(--font-mono-v2); font-size: 10px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
.status-hero h2, .section-heading h2 { margin: 0; font-size: 17px; }
.status-hero p { margin: 4px 0 0; color: var(--tx-2); line-height: 1.5; }
.revision { color: var(--tx-3); }
.operation-success { grid-template-columns: auto 1fr; align-items: center; border-color: var(--status-work-line); background: var(--status-work-bg); color: var(--st-work); }
.detail-layout { display: grid; grid-template-columns: minmax(0, 1fr) 310px; gap: 16px; align-items: start; }
.detail-section { display: grid; gap: 16px; }
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-bottom: 14px; border-bottom: 1px solid var(--line); }
.section-heading a { text-decoration: none; }
.fact-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 18px; margin: 0; }
.fact-grid > div, .audit-list > div { display: grid; gap: 5px; padding: 12px 0; border-bottom: 1px solid var(--line); }
.fact-grid > div:nth-last-child(-n + 2) { border-bottom: 0; }
.fact-grid .fact-wide { grid-column: 1 / -1; }
.fact-grid dt, .audit-list dt { color: var(--tx-3); font-size: 11px; }
.fact-grid dd, .audit-list dd { margin: 0; color: var(--tx); line-height: 1.55; overflow-wrap: anywhere; }
.file-list { display: grid; gap: 9px; }
.file-item { border: 1px solid var(--line); border-radius: var(--r-sm); background: color-mix(in srgb, var(--space-2) 60%, transparent); overflow: hidden; }
.file-item summary { display: flex; justify-content: space-between; gap: 12px; padding: 12px 14px; color: var(--tx); cursor: pointer; }
.file-item summary span:last-child { color: var(--tx-3); }
.file-item pre { max-height: 360px; margin: 0; padding: 14px; overflow: auto; border-top: 1px solid var(--line); color: var(--tx-2); font: 12px/1.6 var(--font-mono-v2); white-space: pre-wrap; overflow-wrap: anywhere; }
.file-item p, .inline-empty { margin: 0; padding: 14px; color: var(--tx-2); line-height: 1.5; }
.rejection-panel p { margin: 0; color: var(--tx-2); line-height: 1.6; }
.audit-list { margin: 0; }
.audit-list > div:last-child { border-bottom: 0; }
.action-panel { display: grid; gap: 10px; }
.action-panel p { margin: 0 0 4px; color: var(--tx-2); line-height: 1.55; }
.action-panel .nexus-button { width: 100%; }
.action-overlay { position: fixed; inset: 0; z-index: 120; display: grid; place-items: center; padding: 20px; background: color-mix(in srgb, var(--space-0) 78%, transparent); backdrop-filter: blur(12px); }
.action-dialog { width: min(100%, 540px); display: grid; gap: 16px; box-shadow: 0 28px 90px rgba(3, 2, 16, .52); }
.action-dialog > p { margin: 0; color: var(--tx-2); line-height: 1.6; }
.dialog-close { width: 38px; height: 38px; display: grid; place-items: center; border: 1px solid var(--line); border-radius: var(--r-sm); background: var(--accent-wash); color: var(--tx-2); cursor: pointer; }
.action-reason { display: grid; gap: 7px; color: var(--tx-2); font-weight: 650; }
.action-reason textarea { width: 100%; padding: 10px 12px; resize: vertical; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 10px; }
.spin { animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 900px) {
.detail-layout { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 620px) {
.status-hero { grid-template-columns: auto minmax(0, 1fr); }
.revision { grid-column: 2; }
.fact-grid { grid-template-columns: minmax(0, 1fr); }
.fact-grid .fact-wide { grid-column: auto; }
.file-item summary { display: grid; }
}
</style>
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More