feat: ship agent-first mission control v0.2.57
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
Generated
+309
-8
@@ -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
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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'
|
||||
+8487
File diff suppressed because it is too large
Load Diff
@@ -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)),
|
||||
})
|
||||
}
|
||||
@@ -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)),
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -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)),
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }[]
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 & 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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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…',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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…',
|
||||
},
|
||||
]
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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' },
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) })
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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)
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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)
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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'
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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 = ''
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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 = ''
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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,4 +1,3 @@
|
||||
export * from './agent'
|
||||
export * from './config'
|
||||
export * from './dashboard'
|
||||
export * from './project'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user