mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 01:07:56 +00:00
Compare commits
82 Commits
jaeone/err
...
nathaniel/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
512f380daa | ||
|
|
b3f200ae24 | ||
|
|
48ca60215a | ||
|
|
8aec372a25 | ||
|
|
122ce0f073 | ||
|
|
22043354c0 | ||
|
|
39cc78727d | ||
|
|
7a05020f55 | ||
|
|
dc1174e0e1 | ||
|
|
ac0dead740 | ||
|
|
a7fffdcb4b | ||
|
|
bca7995b4c | ||
|
|
41bd6a8406 | ||
|
|
ccf4642882 | ||
|
|
9d30613860 | ||
|
|
5727717ef3 | ||
|
|
2c66b22392 | ||
|
|
7655e58d20 | ||
|
|
91e0ebeba0 | ||
|
|
f62d7af7c5 | ||
|
|
8c8b78def4 | ||
|
|
bddb1a22cb | ||
|
|
ac572f3937 | ||
|
|
9e74c435a0 | ||
|
|
fe35a35d02 | ||
|
|
3eb30788f0 | ||
|
|
f37a4dd022 | ||
|
|
a2e870ee6c | ||
|
|
4dabd238c1 | ||
|
|
a5020b0b69 | ||
|
|
c4fc77b514 | ||
|
|
497c8e18e6 | ||
|
|
8aba29b72f | ||
|
|
b7c4256653 | ||
|
|
b6326c3e52 | ||
|
|
0157adeb51 | ||
|
|
260b7d1a8f | ||
|
|
f4fc05f35a | ||
|
|
e1a13ae9c2 | ||
|
|
9e9f63ad1a | ||
|
|
3048178d1b | ||
|
|
939529a109 | ||
|
|
fe466d8d88 | ||
|
|
5b11576778 | ||
|
|
b2fe62d5b4 | ||
|
|
a505890f5a | ||
|
|
fe0cf34dd1 | ||
|
|
5022ee8979 | ||
|
|
a02279ec92 | ||
|
|
517d6d3df8 | ||
|
|
128e8f7bd7 | ||
|
|
63690cca50 | ||
|
|
887e3a587a | ||
|
|
37a5554962 | ||
|
|
a0983fd2a4 | ||
|
|
375e808973 | ||
|
|
5ee3576062 | ||
|
|
7aee27ebfb | ||
|
|
30ef8b3b8c | ||
|
|
c931e75550 | ||
|
|
749e473192 | ||
|
|
d5aae1a24c | ||
|
|
b83df7704e | ||
|
|
944ff2c009 | ||
|
|
17bf3643e9 | ||
|
|
37632ad3b8 | ||
|
|
ad150c88b3 | ||
|
|
88cdef79f5 | ||
|
|
500a6d9c00 | ||
|
|
028b15c3c9 | ||
|
|
442b49b3e5 | ||
|
|
50d510f344 | ||
|
|
2675779e20 | ||
|
|
084fe439c0 | ||
|
|
42a7681a3a | ||
|
|
fd83e792b4 | ||
|
|
4a6a877823 | ||
|
|
f414df4d14 | ||
|
|
b82d7d5c3a | ||
|
|
083cb95f1f | ||
|
|
c11f28f363 | ||
|
|
3d1c489b34 |
@@ -41,6 +41,9 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
|
||||
# Enable PostHog debug logging in the browser console.
|
||||
# VITE_POSTHOG_DEBUG=true
|
||||
|
||||
# Send local dev PostHog events to the staging project (cloud mode only).
|
||||
# VITE_POSTHOG_PROJECT_TOKEN=
|
||||
|
||||
# Override staging comfy-api / comfy-platform base URLs.
|
||||
# VITE_STAGING_API_BASE_URL=https://stagingapi.comfy.org
|
||||
# VITE_STAGING_PLATFORM_BASE_URL=https://stagingplatform.comfy.org
|
||||
|
||||
147
browser_tests/tests/agent/agentPanel.spec.ts
Normal file
147
browser_tests/tests/agent/agentPanel.spec.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import type { WebSocketRoute } from '@playwright/test'
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
import type { AgentWsEvent } from '@/workbench/extensions/agent/schemas/agentApiSchema'
|
||||
|
||||
import {
|
||||
DRAFT_PATCH,
|
||||
MESSAGE_DELTA_EVENT,
|
||||
MESSAGE_DONE_EVENT,
|
||||
THINKING_EVENT,
|
||||
TOOL_CALL_EVENT,
|
||||
mockAgentBoot
|
||||
} from '@e2e/tests/agent/agentPanelMocks'
|
||||
|
||||
type AgentFixtures = {
|
||||
agentFlagEnabled: boolean
|
||||
postedMessages: string[]
|
||||
}
|
||||
|
||||
const agentCloudFixture = comfyPageFixture.extend<AgentFixtures>({
|
||||
agentFlagEnabled: [true, { option: true }],
|
||||
postedMessages: async ({}, use) => {
|
||||
await use([])
|
||||
},
|
||||
page: async ({ page, agentFlagEnabled, postedMessages }, use) => {
|
||||
await mockAgentBoot(page, { agentFlag: agentFlagEnabled, postedMessages })
|
||||
await use(page)
|
||||
}
|
||||
})
|
||||
|
||||
const test = mergeTests(agentCloudFixture, webSocketFixture)
|
||||
|
||||
const OPEN_AGENT_LABEL = enMessages.agent.askComfyAgent
|
||||
|
||||
function pushEvent(ws: WebSocketRoute, event: AgentWsEvent): void {
|
||||
ws.send(JSON.stringify(event))
|
||||
}
|
||||
|
||||
test.describe('In-App Agent panel', { tag: '@cloud' }, () => {
|
||||
test.describe('flag off', () => {
|
||||
test.use({ agentFlagEnabled: false })
|
||||
|
||||
test('does not expose the Ask Comfy Agent button', async ({
|
||||
comfyPage,
|
||||
postedMessages
|
||||
}) => {
|
||||
expect(postedMessages).toHaveLength(0)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByRole('button', { name: OPEN_AGENT_LABEL })
|
||||
).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test('shows the greeting, inserts a suggested prompt, and completes a chat turn', async ({
|
||||
comfyPage,
|
||||
postedMessages,
|
||||
getWebSocket
|
||||
}) => {
|
||||
test.setTimeout(30_000)
|
||||
|
||||
const page = comfyPage.page
|
||||
|
||||
const openButton = page.getByRole('button', { name: OPEN_AGENT_LABEL })
|
||||
await expect(openButton).toBeVisible()
|
||||
await openButton.click()
|
||||
|
||||
const panel = page.locator('#agent-panel-root')
|
||||
await expect(panel).toBeVisible()
|
||||
|
||||
await expect(panel.getByText(/^Hello/)).toBeVisible()
|
||||
await expect(panel.getByText('What do you want to make?')).toBeVisible()
|
||||
const firstPrompt = enMessages.agent.suggestedPrompts[0]
|
||||
const promptChip = panel.getByRole('button', { name: firstPrompt })
|
||||
await expect(promptChip).toBeVisible()
|
||||
|
||||
const composer = panel.getByPlaceholder(enMessages.agent.placeholder)
|
||||
const sendButton = panel.getByRole('button', { name: 'Send' })
|
||||
|
||||
await expect(composer).toHaveValue('')
|
||||
await promptChip.click()
|
||||
await expect(composer).toHaveValue(firstPrompt)
|
||||
expect(
|
||||
postedMessages,
|
||||
'inserting a prompt must not POST a message'
|
||||
).toHaveLength(0)
|
||||
|
||||
const ws = await getWebSocket()
|
||||
await sendButton.click()
|
||||
await expect.poll(() => postedMessages.length).toBeGreaterThanOrEqual(1)
|
||||
expect(postedMessages[0]).toContain(firstPrompt)
|
||||
await expect(composer).toHaveValue('')
|
||||
|
||||
pushEvent(ws, THINKING_EVENT)
|
||||
await expect(panel.getByText('Thinking...')).toBeVisible()
|
||||
|
||||
pushEvent(ws, TOOL_CALL_EVENT)
|
||||
await expect(panel.getByText('Ran 1 tool call')).toBeVisible()
|
||||
|
||||
pushEvent(ws, MESSAGE_DELTA_EVENT)
|
||||
await expect(
|
||||
panel.locator('strong', { hasText: 'fully ready' })
|
||||
).toBeVisible()
|
||||
await expect(panel.getByText('Thinking...')).toBeHidden()
|
||||
|
||||
pushEvent(ws, MESSAGE_DONE_EVENT)
|
||||
await expect(panel.getByRole('button', { name: 'Send' })).toBeVisible()
|
||||
await expect(panel.getByRole('button', { name: 'Stop' })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('applies a draft_patch graph to the canvas', async ({
|
||||
comfyPage,
|
||||
postedMessages,
|
||||
getWebSocket
|
||||
}) => {
|
||||
test.setTimeout(30_000)
|
||||
|
||||
const page = comfyPage.page
|
||||
const panel = page.locator('#agent-panel-root')
|
||||
|
||||
const openButton = page.getByRole('button', { name: OPEN_AGENT_LABEL })
|
||||
await expect(openButton).toBeVisible()
|
||||
await openButton.click()
|
||||
await expect(panel).toBeVisible()
|
||||
|
||||
await panel.getByPlaceholder(enMessages.agent.placeholder).fill('Build it')
|
||||
await panel.getByRole('button', { name: 'Send' }).click()
|
||||
await expect.poll(() => postedMessages.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
const ws = await getWebSocket()
|
||||
pushEvent(ws, { type: 'draft_patch', data: DRAFT_PATCH })
|
||||
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.app!.graph!.nodes.length))
|
||||
.toBe(2)
|
||||
const nodeTypes = await page.evaluate(() =>
|
||||
window.app!.graph!.nodes.map((n) => n.type)
|
||||
)
|
||||
expect(nodeTypes).toEqual(
|
||||
expect.arrayContaining(['CheckpointLoaderSimple', 'SaveImage'])
|
||||
)
|
||||
})
|
||||
})
|
||||
229
browser_tests/tests/agent/agentPanelMocks.ts
Normal file
229
browser_tests/tests/agent/agentPanelMocks.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import type {
|
||||
AgentDraftSnapshot,
|
||||
AgentTurnAccepted,
|
||||
AgentWsEvent,
|
||||
DraftPatchData
|
||||
} from '@/workbench/extensions/agent/schemas/agentApiSchema'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import { mockBilling } from '@e2e/fixtures/utils/cloudBillingMocks'
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
|
||||
const THREAD_ID = 'd4c016c4-3b8c-44cf-97de-1ae27e43e718'
|
||||
const TURN_ID = '3818ba00-d772-4a3f-98c1-9312725b577d'
|
||||
const WORKFLOW_ID = 'a81718a4-02ae-41e6-ae85-c33b7bb880f6'
|
||||
|
||||
const TURN_ACCEPTED: AgentTurnAccepted = {
|
||||
message_id: TURN_ID,
|
||||
thread_id: THREAD_ID,
|
||||
workflow_id: WORKFLOW_ID
|
||||
}
|
||||
|
||||
const DRAFT_GRAPH: ComfyWorkflowJSON = {
|
||||
version: 0.4,
|
||||
last_node_id: 2,
|
||||
last_link_id: 0,
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'CheckpointLoaderSimple',
|
||||
pos: [100, 300],
|
||||
size: [210, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ name: 'MODEL', type: 'MODEL', links: [] },
|
||||
{ name: 'CLIP', type: 'CLIP', links: [] },
|
||||
{ name: 'VAE', type: 'VAE', links: [] }
|
||||
],
|
||||
properties: {},
|
||||
widgets_values: ['sd_xl_base_1.0.safetensors']
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'SaveImage',
|
||||
pos: [1400, 300],
|
||||
size: [210, 100],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0,
|
||||
inputs: [{ name: 'images', type: 'IMAGE', link: null }],
|
||||
outputs: [],
|
||||
properties: {},
|
||||
widgets_values: ['ComfyUI']
|
||||
}
|
||||
],
|
||||
links: []
|
||||
}
|
||||
|
||||
const DRAFT_SNAPSHOT: AgentDraftSnapshot = {
|
||||
content: DRAFT_GRAPH as unknown as Record<string, unknown>,
|
||||
version: 24
|
||||
}
|
||||
|
||||
export const DRAFT_PATCH: DraftPatchData = {
|
||||
base_version: 24,
|
||||
version: 25,
|
||||
content: DRAFT_GRAPH as unknown as Record<string, unknown>,
|
||||
workflow_id: WORKFLOW_ID,
|
||||
message_id: TURN_ID,
|
||||
thread_id: THREAD_ID
|
||||
}
|
||||
|
||||
export const THINKING_EVENT: AgentWsEvent = {
|
||||
type: 'agent_thinking',
|
||||
data: {
|
||||
delta: "I'll set the positive prompt to your red fox scene.",
|
||||
message_id: TURN_ID,
|
||||
thread_id: THREAD_ID
|
||||
}
|
||||
}
|
||||
|
||||
export const TOOL_CALL_EVENT: AgentWsEvent = {
|
||||
type: 'agent_tool_call',
|
||||
data: {
|
||||
tool_name: 'set_widget',
|
||||
status: 'ok',
|
||||
args: ['workflow', 'set-widget', 'workflow.json'],
|
||||
message_id: TURN_ID,
|
||||
thread_id: THREAD_ID
|
||||
}
|
||||
}
|
||||
|
||||
const MESSAGE_DELTA_TEXT =
|
||||
'The graph is **fully ready** to go — prompt set to the red fox in the snow.'
|
||||
|
||||
export const MESSAGE_DELTA_EVENT: AgentWsEvent = {
|
||||
type: 'agent_message_delta',
|
||||
data: {
|
||||
delta: MESSAGE_DELTA_TEXT,
|
||||
message_id: TURN_ID,
|
||||
thread_id: THREAD_ID
|
||||
}
|
||||
}
|
||||
|
||||
export const MESSAGE_DONE_EVENT: AgentWsEvent = {
|
||||
type: 'agent_message_done',
|
||||
data: {
|
||||
message_id: TURN_ID,
|
||||
thread_id: THREAD_ID,
|
||||
usage: {
|
||||
input_tokens: 4493,
|
||||
output_tokens: 425,
|
||||
total_tokens: 12393,
|
||||
cache_read_input_tokens: 35596,
|
||||
cache_creation_input_tokens: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function agentFeatures(agentFlag: boolean): RemoteConfig {
|
||||
return {
|
||||
team_workspaces_enabled: true,
|
||||
posthog_project_token: 'phc_e2e_agent_panel',
|
||||
posthog_config: {
|
||||
advanced_disable_flags: true,
|
||||
bootstrap: {
|
||||
featureFlags: { 'agent-in-app-experience': agentFlag }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function mockAgentBoot(
|
||||
page: Page,
|
||||
{
|
||||
agentFlag,
|
||||
postedMessages
|
||||
}: { agentFlag: boolean; postedMessages: string[] }
|
||||
): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('Comfy.AgentPanel.onboarded', 'true')
|
||||
})
|
||||
|
||||
await mockBilling(page)
|
||||
await page.route('**/api/assets**', (r) =>
|
||||
r.fulfill(jsonRoute({ assets: [] }))
|
||||
)
|
||||
|
||||
await page.route('**/api/features', (r) =>
|
||||
r.fulfill(jsonRoute(agentFeatures(agentFlag)))
|
||||
)
|
||||
await page.route('**/api/system_stats', (r) =>
|
||||
r.fulfill(jsonRoute(mockSystemStats))
|
||||
)
|
||||
await page.route('**/api/users', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
storage: 'server',
|
||||
migrated: true,
|
||||
users: { 'test-user-e2e': 'E2E Test User' }
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/api/settings', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
'Comfy.TutorialCompleted': true,
|
||||
'Comfy.RightSidePanel.ShowErrorsTab': false
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/api/auth/token', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
token: 'mock-workspace-token',
|
||||
expires_at: '2100-01-01T00:00:00.000Z',
|
||||
workspace: { id: 'ws-personal', name: 'Personal', type: 'personal' },
|
||||
role: 'owner',
|
||||
permissions: ['owner:*']
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/workspaces', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
workspaces: [
|
||||
{
|
||||
id: 'ws-personal',
|
||||
name: 'Personal',
|
||||
type: 'personal',
|
||||
role: 'owner'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
await page.route('**/api/agent/threads/*/messages', (route: Route) => {
|
||||
const request = route.request()
|
||||
if (request.method() === 'POST') {
|
||||
postedMessages.push(request.postData() ?? '')
|
||||
return route.fulfill({
|
||||
status: 202,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(TURN_ACCEPTED)
|
||||
})
|
||||
}
|
||||
return route.fulfill(jsonRoute([]))
|
||||
})
|
||||
|
||||
await page.route('**/api/agent/draft**', (r) =>
|
||||
r.fulfill(jsonRoute(DRAFT_SNAPSHOT))
|
||||
)
|
||||
}
|
||||
276
pnpm-lock.yaml
generated
276
pnpm-lock.yaml
generated
@@ -167,7 +167,7 @@ catalogs:
|
||||
version: 2.0.1
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.3
|
||||
version: 6.0.7
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^4.0.16
|
||||
version: 4.0.16
|
||||
@@ -380,7 +380,7 @@ catalogs:
|
||||
version: 3.2.2
|
||||
vite-plugin-vue-devtools:
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.5
|
||||
version: 8.1.2
|
||||
vitest:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.8
|
||||
@@ -389,7 +389,7 @@ catalogs:
|
||||
version: 3.5.34
|
||||
vue-component-type-helpers:
|
||||
specifier: ^3.2.1
|
||||
version: 3.3.2
|
||||
version: 3.3.7
|
||||
vue-eslint-parser:
|
||||
specifier: ^10.4.0
|
||||
version: 10.4.0
|
||||
@@ -717,7 +717,7 @@ importers:
|
||||
version: 0.184.1
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: 'catalog:'
|
||||
version: 6.0.3(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
|
||||
version: 6.0.7(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
|
||||
'@vitest/coverage-v8':
|
||||
specifier: 'catalog:'
|
||||
version: 4.0.16(vitest@4.1.8)
|
||||
@@ -861,13 +861,13 @@ importers:
|
||||
version: 3.2.2(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
|
||||
vite-plugin-vue-devtools:
|
||||
specifier: 'catalog:'
|
||||
version: 8.0.5(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
|
||||
version: 8.1.2(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
|
||||
vitest:
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/coverage-v8@4.0.16)(@vitest/ui@4.0.16)(happy-dom@20.9.0)(jsdom@27.4.0)(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
|
||||
vue-component-type-helpers:
|
||||
specifier: 'catalog:'
|
||||
version: 3.3.2
|
||||
version: 3.3.7
|
||||
vue-eslint-parser:
|
||||
specifier: 'catalog:'
|
||||
version: 10.4.0(eslint@10.4.0(jiti@2.6.1))
|
||||
@@ -925,7 +925,7 @@ importers:
|
||||
version: 4.3.0(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: 'catalog:'
|
||||
version: 6.0.3(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
|
||||
version: 6.0.7(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
|
||||
dotenv:
|
||||
specifier: 'catalog:'
|
||||
version: 16.6.1
|
||||
@@ -943,7 +943,7 @@ importers:
|
||||
version: 3.2.2(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
|
||||
vite-plugin-vue-devtools:
|
||||
specifier: 'catalog:'
|
||||
version: 8.0.5(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
|
||||
version: 8.1.2(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
|
||||
vue-tsc:
|
||||
specifier: 'catalog:'
|
||||
version: 3.2.5(typescript@5.9.3)
|
||||
@@ -1037,7 +1037,7 @@ importers:
|
||||
version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/coverage-v8@4.0.16(vitest@4.1.8))(@vitest/ui@4.0.16(vitest@4.1.8))(happy-dom@20.9.0)(jsdom@27.4.0)(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
|
||||
vue-component-type-helpers:
|
||||
specifier: 'catalog:'
|
||||
version: 3.3.2
|
||||
version: 3.3.7
|
||||
|
||||
packages/comfyui-desktop-bridge-types: {}
|
||||
|
||||
@@ -3305,9 +3305,6 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-beta.53':
|
||||
resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==}
|
||||
|
||||
'@rolldown/pluginutils@1.0.1':
|
||||
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
|
||||
|
||||
@@ -3467,32 +3464,32 @@ packages:
|
||||
pinia:
|
||||
optional: true
|
||||
|
||||
'@shikijs/core@4.1.0':
|
||||
resolution: {integrity: sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ==}
|
||||
'@shikijs/core@4.3.1':
|
||||
resolution: {integrity: sha512-ANMDxuaPsNMdDC1m4vfvhlDmJweMwkE5XitTwrq2rWHx5jM+dlm4MmHt2PP6t0uejfR77SuhrhJ0zEijIF/uhA==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/engine-javascript@4.1.0':
|
||||
resolution: {integrity: sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ==}
|
||||
'@shikijs/engine-javascript@4.3.1':
|
||||
resolution: {integrity: sha512-JBItcnPuYq7jVJdZo/vMj94r+szT7XEjHFX+mvFDGSEIbVAXAGyHAHzhbWzpGOwYidCZrErJLLgn2PVeiokHnQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/engine-oniguruma@4.1.0':
|
||||
resolution: {integrity: sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg==}
|
||||
'@shikijs/engine-oniguruma@4.3.1':
|
||||
resolution: {integrity: sha512-OXyNMzg0pews+msMj4cHeqT4xiYKKvbnn6VbdAXxfoFl3SSx4fJTc8FadECuc5/H9p3BzhNAoAUXKwAu9rWYhg==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/langs@4.1.0':
|
||||
resolution: {integrity: sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg==}
|
||||
'@shikijs/langs@4.3.1':
|
||||
resolution: {integrity: sha512-m0l9nsDqgBHvbZbk7A0/kXz/impK3uB/c6rAn6Gpg/uPtdZRQ+alsN/17MU5thb68XTj/4DxkZAotrM0GGSpDQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/primitive@4.1.0':
|
||||
resolution: {integrity: sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw==}
|
||||
'@shikijs/primitive@4.3.1':
|
||||
resolution: {integrity: sha512-CXQRQOYy1leqQ8ceTeJdmXv/bsUY++6QyLpXJ94LZAAYj5X2SKRdc5ipguv4NPyGVKItB2PPwUpRNe0Sjh5S1A==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/themes@4.1.0':
|
||||
resolution: {integrity: sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw==}
|
||||
'@shikijs/themes@4.3.1':
|
||||
resolution: {integrity: sha512-dgpoJ4WqNi2yTmizQHBJ5zcX6j2lE6icN/0yt4l1kkf16jrY/pwPLoTb1ETsWMz0OBLf9ZNvwmxft+cH+N9qSA==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/types@4.1.0':
|
||||
resolution: {integrity: sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA==}
|
||||
'@shikijs/types@4.3.1':
|
||||
resolution: {integrity: sha512-CHFxE0jztBIZRHH6gxXE7DXUCFXjReEGxZ/j0rfSLGKZuwp2xBYycEP14875DSa9KLL/6700oxIq6oO6ef9K2g==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/vscode-textmate@10.0.2':
|
||||
@@ -4206,13 +4203,6 @@ packages:
|
||||
vite: ^8.0.13
|
||||
vue: ^3.0.0
|
||||
|
||||
'@vitejs/plugin-vue@6.0.3':
|
||||
resolution: {integrity: sha512-TlGPkLFLVOY3T7fZrwdvKpjprR3s4fxRln0ORDo1VQ7HHyxJwTlrjKU3kpVWTlaAjIEuCTokmjkZnr8Tpc925w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
peerDependencies:
|
||||
vite: ^8.0.13
|
||||
vue: ^3.2.25
|
||||
|
||||
'@vitejs/plugin-vue@6.0.7':
|
||||
resolution: {integrity: sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -4369,11 +4359,6 @@ packages:
|
||||
'@vue/devtools-api@7.7.9':
|
||||
resolution: {integrity: sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==}
|
||||
|
||||
'@vue/devtools-core@8.0.5':
|
||||
resolution: {integrity: sha512-dpCw8nl0GDBuiL9SaY0mtDxoGIEmU38w+TQiYEPOLhW03VDC0lfNMYXS/qhl4I0YlysGp04NLY4UNn6xgD0VIQ==}
|
||||
peerDependencies:
|
||||
vue: ^3.0.0
|
||||
|
||||
'@vue/devtools-core@8.1.2':
|
||||
resolution: {integrity: sha512-ZGGyaSBP4/+bN2Nd9ZHNYAVDRIzMw1rv2RyXWtyZlo6mQal+IDmTvKY4V+DjAEBhaXt30mHmsgYp1yXJ/2tIWg==}
|
||||
peerDependencies:
|
||||
@@ -4382,18 +4367,12 @@ packages:
|
||||
'@vue/devtools-kit@7.7.9':
|
||||
resolution: {integrity: sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==}
|
||||
|
||||
'@vue/devtools-kit@8.0.5':
|
||||
resolution: {integrity: sha512-q2VV6x1U3KJMTQPUlRMyWEKVbcHuxhqJdSr6Jtjz5uAThAIrfJ6WVZdGZm5cuO63ZnSUz0RCsVwiUUb0mDV0Yg==}
|
||||
|
||||
'@vue/devtools-kit@8.1.2':
|
||||
resolution: {integrity: sha512-f75/upc+GCyjXErpgPGz4582ujS0L/adAltGy+tqXMGUJpgAcfGr6CxnnhpZY8BHuMYt6KpbF8uaFrrQG66rGQ==}
|
||||
|
||||
'@vue/devtools-shared@7.7.9':
|
||||
resolution: {integrity: sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==}
|
||||
|
||||
'@vue/devtools-shared@8.0.5':
|
||||
resolution: {integrity: sha512-bRLn6/spxpmgLk+iwOrR29KrYnJjG9DGpHGkDFG82UM21ZpJ39ztUT9OXX3g+usW7/b2z+h46I9ZiYyB07XMXg==}
|
||||
|
||||
'@vue/devtools-shared@8.1.2':
|
||||
resolution: {integrity: sha512-X9RyVFYAdkBe4IUf5v48TxBF/6QPmF8CmWrDAjXzfUHrgQ/HGfTC1A6TqgXqZ03ye66l3AD51BAGD69IvKM9sw==}
|
||||
|
||||
@@ -6709,10 +6688,6 @@ packages:
|
||||
lru-cache@10.4.3:
|
||||
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
||||
|
||||
lru-cache@11.2.6:
|
||||
resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
lru-cache@11.5.1:
|
||||
resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==}
|
||||
engines: {node: 20 || >=22}
|
||||
@@ -6746,9 +6721,6 @@ packages:
|
||||
resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
magicast@0.5.1:
|
||||
resolution: {integrity: sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==}
|
||||
|
||||
magicast@0.5.3:
|
||||
resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==}
|
||||
|
||||
@@ -7072,11 +7044,6 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
nanoid@5.1.5:
|
||||
resolution: {integrity: sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==}
|
||||
engines: {node: ^18 || >=20}
|
||||
hasBin: true
|
||||
|
||||
napi-postinstall@0.3.3:
|
||||
resolution: {integrity: sha512-uTp172LLXSxuSYHv/kou+f6KW3SMppU9ivthaVTXian9sOt3XM/zHYHpRZiLgQoxeWfYUnslNWQHF1+G71xcow==}
|
||||
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
||||
@@ -7927,8 +7894,8 @@ packages:
|
||||
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
shiki@4.1.0:
|
||||
resolution: {integrity: sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q==}
|
||||
shiki@4.3.1:
|
||||
resolution: {integrity: sha512-oR+qDVi2OjX1tmDpyv+3KviX01KzO6Af+0NNnKnsp9491UEGz2YpxTuJboS/6VhYpTdqzmuJBuiTlrAWWJAssw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
side-channel-list@1.0.0:
|
||||
@@ -8253,10 +8220,6 @@ packages:
|
||||
resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
tinyrainbow@3.0.3:
|
||||
resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
tinyrainbow@3.1.0:
|
||||
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
@@ -8669,23 +8632,12 @@ packages:
|
||||
'@nuxt/kit':
|
||||
optional: true
|
||||
|
||||
vite-plugin-vue-devtools@8.0.5:
|
||||
resolution: {integrity: sha512-p619BlKFOqQXJ6uDWS1vUPQzuJOD6xJTfftj57JXBGoBD/yeQCowR7pnWcr/FEX4/HVkFbreI6w2uuGBmQOh6A==}
|
||||
engines: {node: '>=v14.21.3'}
|
||||
peerDependencies:
|
||||
vite: ^8.0.13
|
||||
|
||||
vite-plugin-vue-devtools@8.1.2:
|
||||
resolution: {integrity: sha512-gt5h1CNryR9Hy0tvhSbqY3j0F7aj0pGxBxWLa1lXSiZVkhdWDf0vbCOZyjh8ivFGE6FDHTGy3zkcZGlMZdVHig==}
|
||||
engines: {node: '>=v14.21.3'}
|
||||
peerDependencies:
|
||||
vite: ^8.0.13
|
||||
|
||||
vite-plugin-vue-inspector@5.3.2:
|
||||
resolution: {integrity: sha512-YvEKooQcSiBTAs0DoYLfefNja9bLgkFM7NI2b07bE2SruuvX0MEa9cMaxjKVMkeCp5Nz9FRIdcN1rOdFVBeL6Q==}
|
||||
peerDependencies:
|
||||
vite: ^8.0.13
|
||||
|
||||
vite-plugin-vue-inspector@6.0.0:
|
||||
resolution: {integrity: sha512-OpyITJLgZNibxlrik1EmRtvXHDjLRxNPsWkGFTERZs2LgMEdG4W0WoFt5GIgp3a3jRou+eJR8U1zOBk/XQgEbw==}
|
||||
peerDependencies:
|
||||
@@ -8890,11 +8842,8 @@ packages:
|
||||
vue-component-type-helpers@2.2.12:
|
||||
resolution: {integrity: sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==}
|
||||
|
||||
vue-component-type-helpers@3.3.2:
|
||||
resolution: {integrity: sha512-l4Z2Y34m7nFMlx8vrslJaVtXxUpzgDMSESC7TakG/c5kwjYT/do+E0NcT2/vWDzaoIhsShg/2OKwX7Q4nbzC0g==}
|
||||
|
||||
vue-component-type-helpers@3.3.6:
|
||||
resolution: {integrity: sha512-FkljacAwJ9BUoSUdpFe3VDy0sGigNlTH9+2zcXUWmZOjN8swiCkl3t48wOJun0OsUd2cEIda1l04tsxMiKIIrQ==}
|
||||
vue-component-type-helpers@3.3.7:
|
||||
resolution: {integrity: sha512-Skkhw9agYSgsWqv7bxSOGJZa9SaiJbZVGdXuFWnrzKaQYHnw9qbjD630rw6RyMqDbp54nfLCLw5SZA55if7JLg==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
@@ -9340,7 +9289,7 @@ snapshots:
|
||||
'@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-tokenizer': 3.0.4
|
||||
lru-cache: 11.2.6
|
||||
lru-cache: 11.5.1
|
||||
|
||||
'@asamuzakjp/dom-selector@6.7.6':
|
||||
dependencies:
|
||||
@@ -9348,7 +9297,7 @@ snapshots:
|
||||
bidi-js: 1.0.3
|
||||
css-tree: 3.1.0
|
||||
is-potential-custom-element-name: 1.0.1
|
||||
lru-cache: 11.2.6
|
||||
lru-cache: 11.5.1
|
||||
|
||||
'@asamuzakjp/nwsapi@2.3.9': {}
|
||||
|
||||
@@ -9374,7 +9323,7 @@ snapshots:
|
||||
js-yaml: 4.1.1
|
||||
picomatch: 4.0.4
|
||||
retext-smartypants: 6.2.0
|
||||
shiki: 4.1.0
|
||||
shiki: 4.3.1
|
||||
smol-toml: 1.6.1
|
||||
unified: 11.0.5
|
||||
|
||||
@@ -11362,8 +11311,6 @@ snapshots:
|
||||
'@rolldown/binding-win32-x64-msvc@1.0.1':
|
||||
optional: true
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-beta.53': {}
|
||||
|
||||
'@rolldown/pluginutils@1.0.1': {}
|
||||
|
||||
'@rollup/pluginutils@4.2.1':
|
||||
@@ -11537,40 +11484,40 @@ snapshots:
|
||||
optionalDependencies:
|
||||
pinia: 3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3))
|
||||
|
||||
'@shikijs/core@4.1.0':
|
||||
'@shikijs/core@4.3.1':
|
||||
dependencies:
|
||||
'@shikijs/primitive': 4.1.0
|
||||
'@shikijs/types': 4.1.0
|
||||
'@shikijs/primitive': 4.3.1
|
||||
'@shikijs/types': 4.3.1
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
hast-util-to-html: 9.0.5
|
||||
|
||||
'@shikijs/engine-javascript@4.1.0':
|
||||
'@shikijs/engine-javascript@4.3.1':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.1.0
|
||||
'@shikijs/types': 4.3.1
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
oniguruma-to-es: 4.3.6
|
||||
|
||||
'@shikijs/engine-oniguruma@4.1.0':
|
||||
'@shikijs/engine-oniguruma@4.3.1':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.1.0
|
||||
'@shikijs/types': 4.3.1
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
|
||||
'@shikijs/langs@4.1.0':
|
||||
'@shikijs/langs@4.3.1':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.1.0
|
||||
'@shikijs/types': 4.3.1
|
||||
|
||||
'@shikijs/primitive@4.1.0':
|
||||
'@shikijs/primitive@4.3.1':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.1.0
|
||||
'@shikijs/types': 4.3.1
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
'@shikijs/themes@4.1.0':
|
||||
'@shikijs/themes@4.3.1':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.1.0
|
||||
'@shikijs/types': 4.3.1
|
||||
|
||||
'@shikijs/types@4.1.0':
|
||||
'@shikijs/types@4.3.1':
|
||||
dependencies:
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
@@ -11677,7 +11624,7 @@ snapshots:
|
||||
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
type-fest: 2.19.0
|
||||
vue: 3.5.34(typescript@5.9.3)
|
||||
vue-component-type-helpers: 3.3.6
|
||||
vue-component-type-helpers: 3.3.7
|
||||
|
||||
'@swc/helpers@0.5.21':
|
||||
dependencies:
|
||||
@@ -12303,18 +12250,12 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@vitejs/plugin-vue@6.0.3(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))':
|
||||
'@vitejs/plugin-vue@6.0.7(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.0-beta.53
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
vite: 8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
|
||||
vue: 3.5.34(typescript@5.9.3)
|
||||
|
||||
'@vitejs/plugin-vue@6.0.3(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.0-beta.53
|
||||
vite: 8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
|
||||
vue: 3.5.34(typescript@5.9.3)
|
||||
|
||||
'@vitejs/plugin-vue@6.0.7(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
@@ -12330,10 +12271,10 @@ snapshots:
|
||||
istanbul-lib-report: 3.0.1
|
||||
istanbul-lib-source-maps: 5.0.6
|
||||
istanbul-reports: 3.2.0
|
||||
magicast: 0.5.1
|
||||
magicast: 0.5.3
|
||||
obug: 2.1.1
|
||||
std-env: 3.10.0
|
||||
tinyrainbow: 3.0.3
|
||||
tinyrainbow: 3.1.0
|
||||
vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/coverage-v8@4.0.16)(@vitest/ui@4.0.16)(happy-dom@20.9.0)(jsdom@27.4.0)(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -12377,7 +12318,7 @@ snapshots:
|
||||
|
||||
'@vitest/pretty-format@4.0.16':
|
||||
dependencies:
|
||||
tinyrainbow: 3.0.3
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/pretty-format@4.1.8':
|
||||
dependencies:
|
||||
@@ -12409,7 +12350,7 @@ snapshots:
|
||||
pathe: 2.0.3
|
||||
sirv: 3.0.2
|
||||
tinyglobby: 0.2.16
|
||||
tinyrainbow: 3.0.3
|
||||
tinyrainbow: 3.1.0
|
||||
vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/coverage-v8@4.0.16)(@vitest/ui@4.0.16)(happy-dom@20.9.0)(jsdom@27.4.0)(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
|
||||
|
||||
'@vitest/utils@3.2.4':
|
||||
@@ -12421,7 +12362,7 @@ snapshots:
|
||||
'@vitest/utils@4.0.16':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.0.16
|
||||
tinyrainbow: 3.0.3
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/utils@4.1.8':
|
||||
dependencies:
|
||||
@@ -12590,30 +12531,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@vue/devtools-kit': 7.7.9
|
||||
|
||||
'@vue/devtools-core@8.0.5(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@vue/devtools-kit': 8.0.5
|
||||
'@vue/devtools-shared': 8.0.5
|
||||
mitt: 3.0.1
|
||||
nanoid: 5.1.5
|
||||
pathe: 2.0.3
|
||||
vite-hot-client: 2.1.0(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
|
||||
vue: 3.5.34(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- vite
|
||||
|
||||
'@vue/devtools-core@8.0.5(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@vue/devtools-kit': 8.0.5
|
||||
'@vue/devtools-shared': 8.0.5
|
||||
mitt: 3.0.1
|
||||
nanoid: 5.1.5
|
||||
pathe: 2.0.3
|
||||
vite-hot-client: 2.1.0(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
|
||||
vue: 3.5.34(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- vite
|
||||
|
||||
'@vue/devtools-core@8.1.2(vue@3.5.34(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@vue/devtools-kit': 8.1.2
|
||||
@@ -12630,16 +12547,6 @@ snapshots:
|
||||
speakingurl: 14.0.1
|
||||
superjson: 2.2.2
|
||||
|
||||
'@vue/devtools-kit@8.0.5':
|
||||
dependencies:
|
||||
'@vue/devtools-shared': 8.0.5
|
||||
birpc: 2.9.0
|
||||
hookable: 5.5.3
|
||||
mitt: 3.0.1
|
||||
perfect-debounce: 2.0.0
|
||||
speakingurl: 14.0.1
|
||||
superjson: 2.2.2
|
||||
|
||||
'@vue/devtools-kit@8.1.2':
|
||||
dependencies:
|
||||
'@vue/devtools-shared': 8.1.2
|
||||
@@ -12651,10 +12558,6 @@ snapshots:
|
||||
dependencies:
|
||||
rfdc: 1.4.1
|
||||
|
||||
'@vue/devtools-shared@8.0.5':
|
||||
dependencies:
|
||||
rfdc: 1.4.1
|
||||
|
||||
'@vue/devtools-shared@8.1.2': {}
|
||||
|
||||
'@vue/language-core@2.2.0(typescript@5.9.3)':
|
||||
@@ -12988,7 +12891,7 @@ snapshots:
|
||||
picomatch: 4.0.4
|
||||
rehype: 13.0.2
|
||||
semver: 7.7.4
|
||||
shiki: 4.1.0
|
||||
shiki: 4.3.1
|
||||
smol-toml: 1.6.1
|
||||
svgo: 4.0.1
|
||||
tinyclip: 0.1.13
|
||||
@@ -15229,8 +15132,6 @@ snapshots:
|
||||
|
||||
lru-cache@10.4.3: {}
|
||||
|
||||
lru-cache@11.2.6: {}
|
||||
|
||||
lru-cache@11.5.1: {}
|
||||
|
||||
lru-cache@5.1.1:
|
||||
@@ -15259,12 +15160,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
magicast@0.5.1:
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.3
|
||||
'@babel/types': 7.29.0
|
||||
source-map-js: 1.2.1
|
||||
|
||||
magicast@0.5.3:
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.3
|
||||
@@ -15873,8 +15768,6 @@ snapshots:
|
||||
|
||||
nanoid@3.3.12: {}
|
||||
|
||||
nanoid@5.1.5: {}
|
||||
|
||||
napi-postinstall@0.3.3: {}
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
@@ -16227,7 +16120,7 @@ snapshots:
|
||||
|
||||
path-scurry@2.0.2:
|
||||
dependencies:
|
||||
lru-cache: 11.2.6
|
||||
lru-cache: 11.5.1
|
||||
minipass: 7.1.3
|
||||
|
||||
path-type@4.0.0: {}
|
||||
@@ -17007,14 +16900,14 @@ snapshots:
|
||||
|
||||
shebang-regex@3.0.0: {}
|
||||
|
||||
shiki@4.1.0:
|
||||
shiki@4.3.1:
|
||||
dependencies:
|
||||
'@shikijs/core': 4.1.0
|
||||
'@shikijs/engine-javascript': 4.1.0
|
||||
'@shikijs/engine-oniguruma': 4.1.0
|
||||
'@shikijs/langs': 4.1.0
|
||||
'@shikijs/themes': 4.1.0
|
||||
'@shikijs/types': 4.1.0
|
||||
'@shikijs/core': 4.3.1
|
||||
'@shikijs/engine-javascript': 4.3.1
|
||||
'@shikijs/engine-oniguruma': 4.3.1
|
||||
'@shikijs/langs': 4.3.1
|
||||
'@shikijs/themes': 4.3.1
|
||||
'@shikijs/types': 4.3.1
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
@@ -17380,8 +17273,6 @@ snapshots:
|
||||
|
||||
tinyrainbow@2.0.0: {}
|
||||
|
||||
tinyrainbow@3.0.3: {}
|
||||
|
||||
tinyrainbow@3.1.0: {}
|
||||
|
||||
tinyspy@4.0.4: {}
|
||||
@@ -17850,29 +17741,15 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
vite-plugin-vue-devtools@8.0.5(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)):
|
||||
vite-plugin-vue-devtools@8.1.2(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@vue/devtools-core': 8.0.5(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
|
||||
'@vue/devtools-kit': 8.0.5
|
||||
'@vue/devtools-shared': 8.0.5
|
||||
'@vue/devtools-core': 8.1.2(vue@3.5.34(typescript@5.9.3))
|
||||
'@vue/devtools-kit': 8.1.2
|
||||
'@vue/devtools-shared': 8.1.2
|
||||
sirv: 3.0.2
|
||||
vite: 8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
|
||||
vite-plugin-inspect: 11.3.3(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
|
||||
vite-plugin-vue-inspector: 5.3.2(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
|
||||
transitivePeerDependencies:
|
||||
- '@nuxt/kit'
|
||||
- supports-color
|
||||
- vue
|
||||
|
||||
vite-plugin-vue-devtools@8.0.5(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@vue/devtools-core': 8.0.5(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
|
||||
'@vue/devtools-kit': 8.0.5
|
||||
'@vue/devtools-shared': 8.0.5
|
||||
sirv: 3.0.2
|
||||
vite: 8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
|
||||
vite-plugin-inspect: 11.3.3(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
|
||||
vite-plugin-vue-inspector: 5.3.2(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
|
||||
vite-plugin-vue-inspector: 6.0.0(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
|
||||
transitivePeerDependencies:
|
||||
- '@nuxt/kit'
|
||||
- supports-color
|
||||
@@ -17892,7 +17769,7 @@ snapshots:
|
||||
- supports-color
|
||||
- vue
|
||||
|
||||
vite-plugin-vue-inspector@5.3.2(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
|
||||
vite-plugin-vue-inspector@6.0.0(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0)
|
||||
@@ -17907,21 +17784,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
vite-plugin-vue-inspector@5.3.2(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0)
|
||||
'@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0)
|
||||
'@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0)
|
||||
'@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0)
|
||||
'@vue/babel-plugin-jsx': 1.4.0(@babel/core@7.29.0)
|
||||
'@vue/compiler-dom': 3.5.34
|
||||
kolorist: 1.8.0
|
||||
magic-string: 0.30.21
|
||||
vite: 8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
vite-plugin-vue-inspector@6.0.0(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
@@ -18147,9 +18009,7 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@2.2.12: {}
|
||||
|
||||
vue-component-type-helpers@3.3.2: {}
|
||||
|
||||
vue-component-type-helpers@3.3.6: {}
|
||||
vue-component-type-helpers@3.3.7: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.34(typescript@5.9.3)):
|
||||
dependencies:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@import '@comfyorg/design-system/css/style.css';
|
||||
@import '../../workbench/extensions/agent/agentTheme.css';
|
||||
|
||||
/* Use 0.001ms instead of 0s so transitionend/animationend events still fire
|
||||
and JS listeners aren't broken. */
|
||||
|
||||
@@ -1,133 +1,154 @@
|
||||
<template>
|
||||
<div
|
||||
class="pointer-events-none absolute top-0 left-0 z-999 flex size-full flex-col"
|
||||
class="pointer-events-none absolute top-0 left-0 z-999 flex size-full flex-row"
|
||||
>
|
||||
<slot name="workflow-tabs" />
|
||||
<div
|
||||
class="pointer-events-none flex min-w-0 flex-1 flex-col overflow-hidden"
|
||||
>
|
||||
<slot name="workflow-tabs" />
|
||||
|
||||
<div
|
||||
class="pointer-events-none flex flex-1 overflow-hidden"
|
||||
:class="{
|
||||
'flex-row': sidebarLocation === 'left',
|
||||
'flex-row-reverse': sidebarLocation === 'right'
|
||||
}"
|
||||
>
|
||||
<div class="side-toolbar-container">
|
||||
<slot name="side-toolbar" />
|
||||
</div>
|
||||
|
||||
<Splitter
|
||||
:key="splitterRefreshKey"
|
||||
class="pointer-events-none flex-1 overflow-hidden border-none bg-transparent"
|
||||
:state-key="
|
||||
isSelectMode
|
||||
? sidebarLocation === 'left'
|
||||
? 'builder-splitter'
|
||||
: 'builder-splitter-right'
|
||||
: sidebarStateKey
|
||||
"
|
||||
state-storage="local"
|
||||
@resizestart="onResizestart"
|
||||
@resizeend="normalizeSavedSizes"
|
||||
>
|
||||
<!-- First panel: sidebar when left, properties when right -->
|
||||
<SplitterPanel
|
||||
v-if="firstPanelVisible"
|
||||
:class="
|
||||
sidebarLocation === 'left'
|
||||
? cn(
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden',
|
||||
sidebarPanelVisible && 'min-w-78'
|
||||
)
|
||||
: 'pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden'
|
||||
"
|
||||
:min-size="
|
||||
sidebarLocation === 'left' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:style="firstPanelStyle"
|
||||
:role="sidebarLocation === 'left' ? 'complementary' : undefined"
|
||||
:aria-label="
|
||||
sidebarLocation === 'left' ? t('sideToolbar.sidebar') : undefined
|
||||
"
|
||||
>
|
||||
<slot
|
||||
v-if="sidebarLocation === 'left' && sidebarPanelVisible"
|
||||
name="side-bar-panel"
|
||||
/>
|
||||
<slot
|
||||
v-else-if="sidebarLocation === 'right'"
|
||||
name="right-side-panel"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
|
||||
<!-- Main panel (always present) -->
|
||||
<SplitterPanel :size="centerPanelDefaultSize" class="flex flex-col">
|
||||
<slot name="topmenu" :sidebar-panel-visible />
|
||||
|
||||
<Splitter
|
||||
class="splitter-overlay-bottom pointer-events-none mx-1 mb-1 flex-1 border-none bg-transparent"
|
||||
layout="vertical"
|
||||
:pt:gutter="
|
||||
cn(
|
||||
'rounded-t-lg',
|
||||
!(bottomPanelVisible && !focusMode) && 'hidden'
|
||||
)
|
||||
"
|
||||
state-key="bottom-panel-splitter"
|
||||
state-storage="local"
|
||||
@resizestart="onResizestart"
|
||||
>
|
||||
<SplitterPanel
|
||||
class="graph-canvas-panel relative overflow-visible"
|
||||
>
|
||||
<slot name="graph-canvas-panel" />
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
v-show="bottomPanelVisible && !focusMode"
|
||||
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg focus-visible:outline-hidden"
|
||||
>
|
||||
<slot name="bottom-panel" />
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</SplitterPanel>
|
||||
|
||||
<!-- Last panel: properties when left, sidebar when right -->
|
||||
<SplitterPanel
|
||||
v-if="lastPanelVisible"
|
||||
:class="
|
||||
sidebarLocation === 'right'
|
||||
? cn(
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden',
|
||||
sidebarPanelVisible && 'min-w-78'
|
||||
)
|
||||
: 'pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden'
|
||||
"
|
||||
:min-size="
|
||||
sidebarLocation === 'right' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:style="lastPanelStyle"
|
||||
:role="sidebarLocation === 'right' ? 'complementary' : undefined"
|
||||
:aria-label="
|
||||
sidebarLocation === 'right' ? t('sideToolbar.sidebar') : undefined
|
||||
"
|
||||
>
|
||||
<slot v-if="sidebarLocation === 'left'" name="right-side-panel" />
|
||||
<slot
|
||||
v-else-if="sidebarLocation === 'right' && sidebarPanelVisible"
|
||||
name="side-bar-panel"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="pointer-events-none flex flex-1 overflow-hidden"
|
||||
:class="{
|
||||
'flex-row': sidebarLocation === 'left',
|
||||
'flex-row-reverse': sidebarLocation === 'right'
|
||||
}"
|
||||
v-if="agentPanelDocked"
|
||||
class="pointer-events-auto relative h-full shrink-0 overflow-hidden"
|
||||
:style="{ width: `${agentPanelWidth}px` }"
|
||||
>
|
||||
<div class="side-toolbar-container">
|
||||
<slot name="side-toolbar" />
|
||||
</div>
|
||||
|
||||
<Splitter
|
||||
:key="splitterRefreshKey"
|
||||
class="pointer-events-none flex-1 overflow-hidden border-none bg-transparent"
|
||||
:state-key="
|
||||
isSelectMode
|
||||
? sidebarLocation === 'left'
|
||||
? 'builder-splitter'
|
||||
: 'builder-splitter-right'
|
||||
: sidebarStateKey
|
||||
"
|
||||
state-storage="local"
|
||||
@resizestart="onResizestart"
|
||||
@resizeend="normalizeSavedSizes"
|
||||
>
|
||||
<!-- First panel: sidebar when left, properties when right -->
|
||||
<SplitterPanel
|
||||
v-if="firstPanelVisible"
|
||||
:class="
|
||||
sidebarLocation === 'left'
|
||||
? cn(
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden',
|
||||
sidebarPanelVisible && 'min-w-78'
|
||||
)
|
||||
: 'pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden'
|
||||
"
|
||||
:min-size="
|
||||
sidebarLocation === 'left' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:style="firstPanelStyle"
|
||||
:role="sidebarLocation === 'left' ? 'complementary' : undefined"
|
||||
:aria-label="
|
||||
sidebarLocation === 'left' ? t('sideToolbar.sidebar') : undefined
|
||||
"
|
||||
>
|
||||
<slot
|
||||
v-if="sidebarLocation === 'left' && sidebarPanelVisible"
|
||||
name="side-bar-panel"
|
||||
/>
|
||||
<slot
|
||||
v-else-if="sidebarLocation === 'right'"
|
||||
name="right-side-panel"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
|
||||
<!-- Main panel (always present) -->
|
||||
<SplitterPanel :size="centerPanelDefaultSize" class="flex flex-col">
|
||||
<slot name="topmenu" :sidebar-panel-visible />
|
||||
|
||||
<Splitter
|
||||
class="splitter-overlay-bottom pointer-events-none mx-1 mb-1 flex-1 border-none bg-transparent"
|
||||
layout="vertical"
|
||||
:pt:gutter="
|
||||
cn(
|
||||
'rounded-t-lg',
|
||||
!(bottomPanelVisible && !focusMode) && 'hidden'
|
||||
)
|
||||
"
|
||||
state-key="bottom-panel-splitter"
|
||||
state-storage="local"
|
||||
@resizestart="onResizestart"
|
||||
>
|
||||
<SplitterPanel class="graph-canvas-panel relative overflow-visible">
|
||||
<slot name="graph-canvas-panel" />
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
v-show="bottomPanelVisible && !focusMode"
|
||||
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg focus-visible:outline-hidden"
|
||||
>
|
||||
<slot name="bottom-panel" />
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</SplitterPanel>
|
||||
|
||||
<!-- Last panel: properties when left, sidebar when right -->
|
||||
<SplitterPanel
|
||||
v-if="lastPanelVisible"
|
||||
:class="
|
||||
sidebarLocation === 'right'
|
||||
? cn(
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden',
|
||||
sidebarPanelVisible && 'min-w-78'
|
||||
)
|
||||
: 'pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden'
|
||||
"
|
||||
:min-size="
|
||||
sidebarLocation === 'right' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:style="lastPanelStyle"
|
||||
:role="sidebarLocation === 'right' ? 'complementary' : undefined"
|
||||
:aria-label="
|
||||
sidebarLocation === 'right' ? t('sideToolbar.sidebar') : undefined
|
||||
"
|
||||
>
|
||||
<slot v-if="sidebarLocation === 'left'" name="right-side-panel" />
|
||||
<slot
|
||||
v-else-if="sidebarLocation === 'right' && sidebarPanelVisible"
|
||||
name="side-bar-panel"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
<div
|
||||
class="agent-resize-handle absolute top-0 left-0 z-10 h-full w-[5px] cursor-col-resize"
|
||||
:data-resizing="isAgentResizing"
|
||||
@pointerdown="onAgentResizeStart"
|
||||
@lostpointercapture="isAgentResizing = false"
|
||||
/>
|
||||
<slot name="agent-panel" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Splitter from 'primevue/splitter'
|
||||
import type { SplitterResizeStartEvent } from 'primevue/splitter'
|
||||
import SplitterPanel from 'primevue/splitterpanel'
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
@@ -142,11 +163,13 @@ import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { useAgentPanelStore } from '@/workbench/extensions/agent/stores/agent/agentPanelStore'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const settingStore = useSettingStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
const agentPanelStore = useAgentPanelStore()
|
||||
const { t } = useI18n()
|
||||
const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
settingStore.get('Comfy.Sidebar.Location')
|
||||
@@ -162,6 +185,33 @@ const { isSelectMode, isBuilderMode } = useAppMode()
|
||||
const { activeSidebarTabId, activeSidebarTab } = storeToRefs(sidebarTabStore)
|
||||
const { bottomPanelVisible } = storeToRefs(useBottomPanelStore())
|
||||
const { isOpen: rightSidePanelVisible } = storeToRefs(rightSidePanelStore)
|
||||
const {
|
||||
isOpen: agentPanelOpen,
|
||||
enabled: agentPanelEnabled,
|
||||
width: agentPanelWidth
|
||||
} = storeToRefs(agentPanelStore)
|
||||
const agentPanelDocked = computed(
|
||||
() => agentPanelEnabled.value && agentPanelOpen.value
|
||||
)
|
||||
const isAgentResizing = ref(false)
|
||||
let agentResizeStartX = 0
|
||||
let agentResizeStartWidth = 0
|
||||
|
||||
function onAgentResizeStart(e: PointerEvent): void {
|
||||
isAgentResizing.value = true
|
||||
agentResizeStartX = e.clientX
|
||||
agentResizeStartWidth = agentPanelStore.width
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
useEventListener(document, 'pointermove', (e: PointerEvent) => {
|
||||
if (!isAgentResizing.value) return
|
||||
agentPanelStore.setWidth(
|
||||
agentResizeStartWidth + (agentResizeStartX - e.clientX)
|
||||
)
|
||||
})
|
||||
|
||||
const showOffsideSplitter = computed(
|
||||
() => rightSidePanelVisible.value || isSelectMode.value
|
||||
)
|
||||
@@ -304,4 +354,10 @@ const lastPanelStyle = computed(() => {
|
||||
.splitter-overlay-bottom :deep(.p-splitter-gutter) {
|
||||
transform: translateY(5px);
|
||||
}
|
||||
|
||||
.agent-resize-handle:hover,
|
||||
.agent-resize-handle[data-resizing='true'] {
|
||||
transition: background-color 0.2s ease 300ms;
|
||||
background-color: var(--p-primary-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -38,6 +38,15 @@
|
||||
<AppBuilder v-if="isBuilderMode" />
|
||||
<NodePropertiesPanel v-else />
|
||||
</template>
|
||||
<template #agent-panel>
|
||||
<div class="size-full p-2">
|
||||
<div
|
||||
class="size-full overflow-hidden rounded-lg border border-(--interface-stroke)"
|
||||
>
|
||||
<AgentPanelRoot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #graph-canvas-panel>
|
||||
<GraphCanvasMenu
|
||||
v-if="canvasMenuEnabled && !isBuilderMode"
|
||||
@@ -111,6 +120,7 @@
|
||||
import { until, useEventListener } from '@vueuse/core'
|
||||
import {
|
||||
computed,
|
||||
defineAsyncComponent,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
@@ -197,6 +207,10 @@ import { forEachNode } from '@/utils/graphTraversalUtil'
|
||||
import SelectionRectangle from './SelectionRectangle.vue'
|
||||
import { useUrlActionLoaders } from '@/composables/useUrlActionLoaders'
|
||||
|
||||
const AgentPanelRoot = defineAsyncComponent(
|
||||
() => import('@/workbench/extensions/agent/AgentPanelRoot.vue')
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const emit = defineEmits<{
|
||||
ready: []
|
||||
|
||||
@@ -150,6 +150,22 @@ describe('TabErrors.vue', () => {
|
||||
expect(screen.queryByText('Error details')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('passes raw details through to the card for agent prompt errors only', () => {
|
||||
renderComponent({
|
||||
executionError: {
|
||||
lastPromptError: {
|
||||
type: 'agent_api_failed',
|
||||
message: 'Comfy Agent hit a server error.',
|
||||
details: 'HTTP 500 from /api/agent/threads'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.getByText('HTTP 500 from /api/agent/threads')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders node validation errors grouped by catalog copy', async () => {
|
||||
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
|
||||
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) => {
|
||||
|
||||
@@ -65,6 +65,10 @@ vi.mock('@/i18n', () => {
|
||||
'Prompt has no outputs',
|
||||
'errorCatalog.promptErrors.prompt_no_outputs.desc':
|
||||
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.',
|
||||
'errorCatalog.promptErrors.agent_draft_apply_failed.title':
|
||||
'An error was found',
|
||||
'errorCatalog.promptErrors.agent_draft_apply_failed.desc':
|
||||
"Couldn't apply the agent's draft to the canvas.",
|
||||
'errorCatalog.runtimeErrors.execution_failed.title': 'Execution failed',
|
||||
'errorCatalog.runtimeErrors.execution_failed.message':
|
||||
'Node threw an error during execution.',
|
||||
@@ -674,6 +678,25 @@ describe('useErrorGroups', () => {
|
||||
expect(promptGroup).toBeDefined()
|
||||
})
|
||||
|
||||
it('carries prompt error details onto the card item', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastPromptError = {
|
||||
type: 'agent_draft_apply_failed',
|
||||
message: "Couldn't apply the agent's draft to the canvas",
|
||||
details: 'Validation error: Required at "version"'
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
const promptGroup = groups.allErrorGroups.value.find(
|
||||
(g) => g.type === 'execution' && g.displayTitle === 'An error was found'
|
||||
)
|
||||
const details =
|
||||
promptGroup && 'cards' in promptGroup
|
||||
? promptGroup.cards[0]?.errors[0]?.details
|
||||
: undefined
|
||||
expect(details).toBe('Validation error: Required at "version"')
|
||||
})
|
||||
|
||||
it('includes prompt error when a node is selected', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
@@ -46,6 +46,11 @@ import {
|
||||
|
||||
const PROMPT_CARD_ID = '__prompt__'
|
||||
|
||||
const AGENT_PROMPT_ERROR_TYPES = new Set([
|
||||
'agent_api_failed',
|
||||
'agent_draft_apply_failed'
|
||||
])
|
||||
|
||||
/** Sentinel: distinguishes "fetch in-flight" from "fetch done, pack not found (null)". */
|
||||
const RESOLVING = '__RESOLVING__'
|
||||
|
||||
@@ -372,6 +377,9 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
errors: [
|
||||
{
|
||||
message: error.message,
|
||||
...(AGENT_PROMPT_ERROR_TYPES.has(error.type)
|
||||
? { details: error.details }
|
||||
: {}),
|
||||
...resolvedDisplay
|
||||
}
|
||||
]
|
||||
|
||||
@@ -79,6 +79,34 @@ vi.mock('@/stores/workspaceStore', () => ({
|
||||
useWorkspaceStore: () => ({ shiftDown: false })
|
||||
}))
|
||||
|
||||
const agentPanelHolder = vi.hoisted(() => ({
|
||||
store: null as unknown as {
|
||||
isOpen: { value: boolean }
|
||||
enabled: { value: boolean }
|
||||
toggle: ReturnType<typeof vi.fn>
|
||||
}
|
||||
}))
|
||||
vi.mock(
|
||||
'@/workbench/extensions/agent/stores/agent/agentPanelStore',
|
||||
async () => {
|
||||
const { ref } = await import('vue')
|
||||
agentPanelHolder.store = {
|
||||
isOpen: ref(false),
|
||||
enabled: ref(false),
|
||||
toggle: vi.fn(() => {
|
||||
agentPanelHolder.store.isOpen.value =
|
||||
!agentPanelHolder.store.isOpen.value
|
||||
})
|
||||
}
|
||||
return { useAgentPanelStore: () => agentPanelHolder.store }
|
||||
}
|
||||
)
|
||||
|
||||
const trackAgentEntryButtonClicked = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackAgentEntryButtonClicked })
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/mouseDownUtil', () => ({
|
||||
whileMouseDown: vi.fn()
|
||||
}))
|
||||
@@ -131,6 +159,42 @@ function renderComponent() {
|
||||
return { user, ...result }
|
||||
}
|
||||
|
||||
describe('WorkflowTabs agent entry button', () => {
|
||||
beforeEach(() => {
|
||||
tabBarLayout.value = 'Integrated'
|
||||
agentPanelHolder.store.enabled.value = true
|
||||
agentPanelHolder.store.isOpen.value = false
|
||||
trackAgentEntryButtonClicked.mockClear()
|
||||
agentPanelHolder.store.toggle.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
tabBarLayout.value = 'Legacy'
|
||||
agentPanelHolder.store.enabled.value = false
|
||||
agentPanelHolder.store.isOpen.value = false
|
||||
})
|
||||
|
||||
it('reports the entry click with the state the click produces', async () => {
|
||||
const { user } = renderComponent()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: enMessages.agent.askComfyAgent })
|
||||
)
|
||||
expect(trackAgentEntryButtonClicked).toHaveBeenCalledWith({
|
||||
resulting_state: 'opened'
|
||||
})
|
||||
expect(agentPanelHolder.store.toggle).toHaveBeenCalledTimes(1)
|
||||
|
||||
agentPanelHolder.store.isOpen.value = true
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: enMessages.agent.askComfyAgent })
|
||||
)
|
||||
expect(trackAgentEntryButtonClicked).toHaveBeenLastCalledWith({
|
||||
resulting_state: 'closed'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorkflowTabs feedback button', () => {
|
||||
let openSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
|
||||
@@ -84,6 +84,24 @@
|
||||
data-testid="integrated-tab-bar-actions"
|
||||
class="ml-auto flex shrink-0 items-center gap-2 px-2"
|
||||
>
|
||||
<Button
|
||||
v-if="agentPanelEnabled"
|
||||
variant="link"
|
||||
size="sm"
|
||||
:class="
|
||||
cn(
|
||||
'no-drag shrink-0 border border-solid text-base-foreground',
|
||||
isAgentPanelOpen
|
||||
? 'border-plum-500 bg-plum-600/20'
|
||||
: 'border-plum-600 bg-ink-700 hover:border-plum-500'
|
||||
)
|
||||
"
|
||||
:aria-label="$t('agent.askComfyAgent')"
|
||||
@click="onAgentEntryClick"
|
||||
>
|
||||
<i class="icon-[comfy--comfy-c] size-3 text-brand-yellow" />
|
||||
<span>{{ $t('agent.askComfyAgent') }}</span>
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isCloud || isNightly"
|
||||
v-tooltip="{ value: $t('actionbar.feedbackTooltip'), showDelay: 300 }"
|
||||
@@ -106,7 +124,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useScroll } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import ScrollPanel from 'primevue/scrollpanel'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import { computed, nextTick, onUpdated, ref, watch } from 'vue'
|
||||
@@ -126,6 +146,8 @@ import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workfl
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useAgentPanelStore } from '@/workbench/extensions/agent/stores/agent/agentPanelStore'
|
||||
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
|
||||
import { whileMouseDown } from '@/utils/mouseDownUtil'
|
||||
|
||||
@@ -145,6 +167,16 @@ const workspaceStore = useWorkspaceStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const commandStore = useCommandStore()
|
||||
const agentPanelStore = useAgentPanelStore()
|
||||
const { isOpen: isAgentPanelOpen, enabled: agentPanelEnabled } =
|
||||
storeToRefs(agentPanelStore)
|
||||
|
||||
function onAgentEntryClick(): void {
|
||||
useTelemetry()?.trackAgentEntryButtonClicked({
|
||||
resulting_state: isAgentPanelOpen.value ? 'closed' : 'opened'
|
||||
})
|
||||
agentPanelStore.toggle()
|
||||
}
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
|
||||
// Dismiss a tab's terminal status badge once it has been viewed
|
||||
|
||||
80
src/extensions/core/agentPanel.test.ts
Normal file
80
src/extensions/core/agentPanel.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
capturedExtensions: [] as ComfyExtension[],
|
||||
agentStore: { enabled: false, close: vi.fn() },
|
||||
flagEnabled: undefined as boolean | undefined,
|
||||
flagListener: null as (() => void) | null
|
||||
}))
|
||||
|
||||
vi.mock('@/services/extensionService', () => ({
|
||||
useExtensionService: () => ({
|
||||
registerExtension: (ext: ComfyExtension) => {
|
||||
mocks.capturedExtensions.push(ext)
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/workbench/extensions/agent/stores/agent/agentPanelStore', () => ({
|
||||
useAgentPanelStore: () => mocks.agentStore
|
||||
}))
|
||||
|
||||
vi.mock('posthog-js', () => ({
|
||||
default: {
|
||||
isFeatureEnabled: () => mocks.flagEnabled,
|
||||
onFeatureFlags: (listener: () => void) => {
|
||||
mocks.flagListener = listener
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const flush = (): Promise<void> =>
|
||||
new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
async function loadEntryAndSetup(): Promise<void> {
|
||||
await import('./agentPanel')
|
||||
const ext = mocks.capturedExtensions.find(
|
||||
(e) => e.name === 'Comfy.AgentPanel'
|
||||
)
|
||||
expect(ext).toBeDefined()
|
||||
ext!.setup!({} as Parameters<NonNullable<ComfyExtension['setup']>>[0])
|
||||
for (let i = 0; i < 20 && mocks.flagListener === null; i++) await flush()
|
||||
expect(mocks.flagListener).toBeTypeOf('function')
|
||||
}
|
||||
|
||||
describe('AgentPanel extension flag gate', () => {
|
||||
beforeEach(() => {
|
||||
mocks.capturedExtensions.length = 0
|
||||
mocks.agentStore.close.mockClear()
|
||||
mocks.agentStore.enabled = false
|
||||
mocks.flagEnabled = undefined
|
||||
mocks.flagListener = null
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('leaves the panel disabled while the flag is undefined', async () => {
|
||||
await loadEntryAndSetup()
|
||||
expect(mocks.agentStore.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('enables the panel when the flag turns true', async () => {
|
||||
await loadEntryAndSetup()
|
||||
mocks.flagEnabled = true
|
||||
mocks.flagListener!()
|
||||
expect(mocks.agentStore.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('disables and closes the panel when the flag flips back to false', async () => {
|
||||
await loadEntryAndSetup()
|
||||
mocks.flagEnabled = true
|
||||
mocks.flagListener!()
|
||||
mocks.flagEnabled = false
|
||||
mocks.flagListener!()
|
||||
|
||||
expect(mocks.agentStore.enabled).toBe(false)
|
||||
expect(mocks.agentStore.close).toHaveBeenCalledWith('flag_disabled')
|
||||
})
|
||||
})
|
||||
25
src/extensions/core/agentPanel.ts
Normal file
25
src/extensions/core/agentPanel.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { createPostHogFlagSource } from '@/workbench/extensions/agent/composables/agent/useAgentFeatureGate'
|
||||
import { useAgentPanelStore } from '@/workbench/extensions/agent/stores/agent/agentPanelStore'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.AgentPanel',
|
||||
setup() {
|
||||
const agentPanelStore = useAgentPanelStore()
|
||||
|
||||
async function setupFlagGate(): Promise<void> {
|
||||
const posthog = (await import('posthog-js')).default
|
||||
const source = createPostHogFlagSource(posthog)
|
||||
const sync = (): void => {
|
||||
const forceInDev = import.meta.env.MODE === 'development'
|
||||
const enabled = forceInDev || source.isEnabled()
|
||||
agentPanelStore.enabled = enabled
|
||||
if (!enabled) agentPanelStore.close('flag_disabled')
|
||||
}
|
||||
source.onChange?.(sync)
|
||||
sync()
|
||||
}
|
||||
|
||||
void setupFlagGate()
|
||||
}
|
||||
})
|
||||
@@ -38,6 +38,7 @@ if (isCloud) {
|
||||
await import('./cloudRemoteConfig')
|
||||
await import('./cloudBadges')
|
||||
await import('./cloudSessionCookie')
|
||||
await import('./agentPanel')
|
||||
}
|
||||
|
||||
// Feedback button for cloud and nightly builds
|
||||
|
||||
@@ -4356,6 +4356,14 @@
|
||||
"prompt_outputs_failed_validation": {
|
||||
"title": "Prompt validation failed",
|
||||
"desc": "The workflow has invalid node inputs. Fix the highlighted nodes before running it again."
|
||||
},
|
||||
"agent_draft_apply_failed": {
|
||||
"title": "An error was found",
|
||||
"desc": "Couldn't apply the agent's draft to the canvas."
|
||||
},
|
||||
"agent_api_failed": {
|
||||
"title": "An error was found",
|
||||
"desc": "Comfy Agent hit a server error."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4547,5 +4555,74 @@
|
||||
"training": "Training…",
|
||||
"processingVideo": "Processing video…",
|
||||
"running": "Running…"
|
||||
},
|
||||
"agent": {
|
||||
"title": "Comfy Agent",
|
||||
"askComfyAgent": "Ask Comfy Agent",
|
||||
"caption": "The agent edits your workflow as a draft. Only you can run it.",
|
||||
"latest": "Latest",
|
||||
"copyUnavailable": "Only the current conversation can be copied in this version",
|
||||
"alpha": "ALPHA",
|
||||
"newChat": "New chat",
|
||||
"newChatTitle": "Untitled",
|
||||
"maximize": "Maximize panel",
|
||||
"minimize": "Minimize panel",
|
||||
"close": "Close",
|
||||
"history": "Chat history",
|
||||
"showChatHistory": "Show chat history",
|
||||
"backToChat": "Back to chat",
|
||||
"historyCurrent": "Current",
|
||||
"historyToday": "Today",
|
||||
"historyYesterday": "Yesterday",
|
||||
"historyEarlier": "Earlier",
|
||||
"historyEmpty": "No conversations yet",
|
||||
"copyMarkdown": "Copy as markdown",
|
||||
"delete": "Delete",
|
||||
"untitledChat": "Untitled",
|
||||
"skip": "Skip",
|
||||
"gotIt": "Got it",
|
||||
"greeting": "Hello {name},",
|
||||
"greetingQuestion": "What do you want to make?",
|
||||
"placeholder": "Ask Comfy Agent…",
|
||||
"suggestedPrompts": [
|
||||
"Generate a yellow duck with a hockey mask",
|
||||
"List my saved workflows",
|
||||
"Find the best workflow for skin upscaling",
|
||||
"Explain the selected node",
|
||||
"Build a workflow for image to video with 3 models"
|
||||
],
|
||||
"attach": "Attach a file",
|
||||
"attachmentTooLarge": "{name} is larger than {limit}",
|
||||
"attachmentUploadFailed": "{name} could not be uploaded",
|
||||
"mention": "Mention a node",
|
||||
"modelAuto": "Auto",
|
||||
"send": "Send",
|
||||
"stop": "Stop",
|
||||
"thinking": "Thinking...",
|
||||
"helpful": "Helpful",
|
||||
"notHelpful": "Not helpful",
|
||||
"toggleRaw": "Toggle raw markdown",
|
||||
"ranToolCalls": "Ran {count} tool call | Ran {count} tool calls",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied!",
|
||||
"remove": "Remove",
|
||||
"uploading": "Uploading",
|
||||
"noNodesToMention": "No nodes in this workflow",
|
||||
"friend": "there",
|
||||
"conflictTitle": "Agent changes are ready",
|
||||
"conflictBody": "You edited this workflow while Comfy Agent changed the same graph. Choose what should happen in this tab.",
|
||||
"keepMine": "Keep my changes",
|
||||
"acceptAgent": "Accept agent changes",
|
||||
"moreApplyOptions": "More apply options",
|
||||
"openNewTab": "Open in new tab",
|
||||
"sendFailed": "Message failed to send",
|
||||
"sendBusy": "A message is already being sent",
|
||||
"malformedEvent": "The agent sent an event this panel could not read",
|
||||
"coachTitle": "Meet the agent",
|
||||
"coachBody": "Ask it to build or edit graphs.",
|
||||
"toolOpenedNewTab": "Opened a new tab",
|
||||
"toolSwitchedTabs": "Switched tabs",
|
||||
"toolSavedPreference": "Saved a preference",
|
||||
"toolForgotPreference": "Forgot a preference"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -555,6 +555,23 @@ describe('errorMessageResolver', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves the agent draft-apply failure to overlay copy', () => {
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'prompt',
|
||||
isCloud: true,
|
||||
error: {
|
||||
type: 'agent_draft_apply_failed',
|
||||
message: "Couldn't apply the agent's draft to the canvas",
|
||||
details: 'Validation error: Required at "version"'
|
||||
}
|
||||
})
|
||||
).toEqual({
|
||||
displayTitle: 'An error was found',
|
||||
displayMessage: "Couldn't apply the agent's draft to the canvas."
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves server_error prompt copy by environment', () => {
|
||||
const error = {
|
||||
type: 'server_error',
|
||||
|
||||
@@ -13,7 +13,8 @@ const KNOWN_PROMPT_ERROR_TYPES = new Set([
|
||||
'no_prompt',
|
||||
'server_error',
|
||||
'missing_node_type',
|
||||
'prompt_outputs_failed_validation'
|
||||
'prompt_outputs_failed_validation',
|
||||
'agent_draft_apply_failed'
|
||||
])
|
||||
|
||||
function getPromptExceptionMessage(
|
||||
|
||||
@@ -2,6 +2,13 @@ import type { AuditLog } from '@/services/customerEventsService'
|
||||
|
||||
import type {
|
||||
AddCreditsClickMetadata,
|
||||
AgentEntryButtonClickedMetadata,
|
||||
AgentMessageFeedbackMetadata,
|
||||
AgentMessageSentMetadata,
|
||||
AgentNodeTaggedMetadata,
|
||||
AgentPanelClosedMetadata,
|
||||
AgentPanelOpenedMetadata,
|
||||
AgentWorkflowAppliedMetadata,
|
||||
AuthErrorMetadata,
|
||||
AuthMetadata,
|
||||
BeginCheckoutMetadata,
|
||||
@@ -288,6 +295,46 @@ export class TelemetryRegistry implements TelemetryDispatcher {
|
||||
this.dispatch((provider) => provider.trackUiButtonClicked?.(metadata))
|
||||
}
|
||||
|
||||
trackAgentMessageFeedback(metadata: AgentMessageFeedbackMetadata): void {
|
||||
this.dispatch((provider) => provider.trackAgentMessageFeedback?.(metadata))
|
||||
}
|
||||
|
||||
trackAgentPanelOpened(metadata: AgentPanelOpenedMetadata): void {
|
||||
this.dispatch((provider) => provider.trackAgentPanelOpened?.(metadata))
|
||||
}
|
||||
|
||||
trackAgentPanelClosed(metadata: AgentPanelClosedMetadata): void {
|
||||
this.dispatch((provider) => provider.trackAgentPanelClosed?.(metadata))
|
||||
}
|
||||
|
||||
trackAgentEntryButtonClicked(
|
||||
metadata: AgentEntryButtonClickedMetadata
|
||||
): void {
|
||||
this.dispatch((provider) =>
|
||||
provider.trackAgentEntryButtonClicked?.(metadata)
|
||||
)
|
||||
}
|
||||
|
||||
trackAgentCloseButtonClicked(): void {
|
||||
this.dispatch((provider) => provider.trackAgentCloseButtonClicked?.())
|
||||
}
|
||||
|
||||
trackAgentMessageSent(metadata: AgentMessageSentMetadata): void {
|
||||
this.dispatch((provider) => provider.trackAgentMessageSent?.(metadata))
|
||||
}
|
||||
|
||||
trackAgentNodeTagged(metadata: AgentNodeTaggedMetadata): void {
|
||||
this.dispatch((provider) => provider.trackAgentNodeTagged?.(metadata))
|
||||
}
|
||||
|
||||
trackAgentAttachButtonClicked(): void {
|
||||
this.dispatch((provider) => provider.trackAgentAttachButtonClicked?.())
|
||||
}
|
||||
|
||||
trackAgentWorkflowApplied(metadata: AgentWorkflowAppliedMetadata): void {
|
||||
this.dispatch((provider) => provider.trackAgentWorkflowApplied?.(metadata))
|
||||
}
|
||||
|
||||
trackPageView(pageName: string, properties?: PageViewMetadata): void {
|
||||
this.dispatch((provider) => provider.trackPageView?.(pageName, properties))
|
||||
}
|
||||
|
||||
@@ -135,6 +135,29 @@ describe('PostHogTelemetryProvider', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it("lets the server's person_profiles win over the client default", async () => {
|
||||
hoisted.refs.remoteConfig.value = {
|
||||
posthog_config: { person_profiles: 'always' }
|
||||
}
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockInit).toHaveBeenCalledWith(
|
||||
'phc_test_token',
|
||||
expect.objectContaining({ person_profiles: 'always' })
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults person_profiles to identified_only when the server omits it', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockInit).toHaveBeenCalledWith(
|
||||
'phc_test_token',
|
||||
expect.objectContaining({ person_profiles: 'identified_only' })
|
||||
)
|
||||
})
|
||||
|
||||
it('registers onUserResolved callback after init', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
@@ -870,12 +893,11 @@ describe('PostHogTelemetryProvider', () => {
|
||||
expect(result.$set_once).toHaveProperty('plan', 'free')
|
||||
})
|
||||
|
||||
it('remoteConfig.posthog_config cannot override before_send or person_profiles', async () => {
|
||||
it('remoteConfig.posthog_config cannot override before_send (PII stripping)', async () => {
|
||||
const remoteBefore_send = vi.fn()
|
||||
hoisted.refs.remoteConfig.value = {
|
||||
posthog_config: {
|
||||
before_send: remoteBefore_send,
|
||||
person_profiles: 'always'
|
||||
before_send: remoteBefore_send
|
||||
}
|
||||
}
|
||||
|
||||
@@ -885,7 +907,6 @@ describe('PostHogTelemetryProvider', () => {
|
||||
const initConfig = hoisted.mockInit.mock.calls[0][1]
|
||||
|
||||
expect(initConfig.before_send).not.toBe(remoteBefore_send)
|
||||
expect(initConfig.person_profiles).toBe('identified_only')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,13 @@ import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
import type {
|
||||
AddCreditsClickMetadata,
|
||||
AgentEntryButtonClickedMetadata,
|
||||
AgentMessageFeedbackMetadata,
|
||||
AgentMessageSentMetadata,
|
||||
AgentNodeTaggedMetadata,
|
||||
AgentPanelClosedMetadata,
|
||||
AgentPanelOpenedMetadata,
|
||||
AgentWorkflowAppliedMetadata,
|
||||
AuthErrorMetadata,
|
||||
AuthMetadata,
|
||||
BeginCheckoutMetadata,
|
||||
@@ -119,7 +126,9 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const apiKey = window.__CONFIG__?.posthog_project_token
|
||||
const apiKey =
|
||||
window.__CONFIG__?.posthog_project_token ??
|
||||
import.meta.env.VITE_POSTHOG_PROJECT_TOKEN
|
||||
if (apiKey) {
|
||||
try {
|
||||
void import('posthog-js')
|
||||
@@ -135,8 +144,8 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
capture_pageleave: false,
|
||||
persistence: 'localStorage+cookie',
|
||||
debug: import.meta.env.VITE_POSTHOG_DEBUG === 'true',
|
||||
...serverConfig,
|
||||
person_profiles: 'identified_only',
|
||||
...serverConfig,
|
||||
// cookie_domain omitted: posthog-js sets a first-party cross-subdomain cookie
|
||||
// automatically when persistence includes 'cookie' (the default).
|
||||
// Explicit override interacts badly with posthog-js#3578 where reset() fails
|
||||
@@ -569,6 +578,44 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.UI_BUTTON_CLICKED, metadata)
|
||||
}
|
||||
|
||||
trackAgentMessageFeedback(metadata: AgentMessageFeedbackMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.AGENT_MESSAGE_FEEDBACK, metadata)
|
||||
}
|
||||
|
||||
trackAgentPanelOpened(metadata: AgentPanelOpenedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.AGENT_PANEL_OPENED, metadata)
|
||||
}
|
||||
|
||||
trackAgentPanelClosed(metadata: AgentPanelClosedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.AGENT_PANEL_CLOSED, metadata)
|
||||
}
|
||||
|
||||
trackAgentEntryButtonClicked(
|
||||
metadata: AgentEntryButtonClickedMetadata
|
||||
): void {
|
||||
this.trackEvent(TelemetryEvents.AGENT_ENTRY_BUTTON_CLICKED, metadata)
|
||||
}
|
||||
|
||||
trackAgentCloseButtonClicked(): void {
|
||||
this.trackEvent(TelemetryEvents.AGENT_CLOSE_BUTTON_CLICKED)
|
||||
}
|
||||
|
||||
trackAgentMessageSent(metadata: AgentMessageSentMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.AGENT_MESSAGE_SENT, metadata)
|
||||
}
|
||||
|
||||
trackAgentNodeTagged(metadata: AgentNodeTaggedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.AGENT_NODE_TAGGED, metadata)
|
||||
}
|
||||
|
||||
trackAgentAttachButtonClicked(): void {
|
||||
this.trackEvent(TelemetryEvents.AGENT_ATTACH_BUTTON_CLICKED)
|
||||
}
|
||||
|
||||
trackAgentWorkflowApplied(metadata: AgentWorkflowAppliedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.AGENT_WORKFLOW_APPLIED, metadata)
|
||||
}
|
||||
|
||||
trackPageView(pageName: string, properties?: PageViewMetadata): void {
|
||||
this.captureRaw(TelemetryEvents.PAGE_VIEW, {
|
||||
page_name: pageName,
|
||||
|
||||
@@ -401,6 +401,51 @@ export interface UiButtonClickMetadata {
|
||||
element_group: string
|
||||
}
|
||||
|
||||
/**
|
||||
* In-App Agent message rating metadata (PM-98). `vote` is null when the user retracts a
|
||||
* prior thumb, which the eval pipeline records as a retraction rather than dropping.
|
||||
*/
|
||||
export interface AgentMessageFeedbackMetadata extends Record<string, unknown> {
|
||||
message_id: string
|
||||
vote: 'up' | 'down' | null
|
||||
workflow_id: string | null
|
||||
}
|
||||
|
||||
export type AgentPanelCloseSource =
|
||||
| 'topbar_button'
|
||||
| 'close_button'
|
||||
| 'flag_disabled'
|
||||
|
||||
export interface AgentPanelOpenedMetadata extends Record<string, unknown> {
|
||||
source: 'topbar_button'
|
||||
}
|
||||
|
||||
export interface AgentPanelClosedMetadata extends Record<string, unknown> {
|
||||
source: AgentPanelCloseSource
|
||||
open_duration_ms: number | null
|
||||
}
|
||||
|
||||
export interface AgentEntryButtonClickedMetadata extends Record<
|
||||
string,
|
||||
unknown
|
||||
> {
|
||||
resulting_state: 'opened' | 'closed'
|
||||
}
|
||||
|
||||
export interface AgentMessageSentMetadata extends Record<string, unknown> {
|
||||
attachment_count: number
|
||||
node_tag_count: number
|
||||
}
|
||||
|
||||
export interface AgentNodeTaggedMetadata extends Record<string, unknown> {
|
||||
source: 'mention_picker'
|
||||
}
|
||||
|
||||
export interface AgentWorkflowAppliedMetadata extends Record<string, unknown> {
|
||||
workflow_id: string
|
||||
target: 'new_tab' | 'existing_tab' | 'active_tab_open' | 'active_tab_switch'
|
||||
}
|
||||
|
||||
/**
|
||||
* Help center opened metadata
|
||||
*/
|
||||
@@ -642,6 +687,19 @@ export interface TelemetryProvider {
|
||||
// Generic UI button click events
|
||||
trackUiButtonClicked?(metadata: UiButtonClickMetadata): void
|
||||
|
||||
// In-App Agent message rating (PM-98)
|
||||
trackAgentMessageFeedback?(metadata: AgentMessageFeedbackMetadata): void
|
||||
|
||||
// In-App Agent panel engagement (FE-1187)
|
||||
trackAgentPanelOpened?(metadata: AgentPanelOpenedMetadata): void
|
||||
trackAgentPanelClosed?(metadata: AgentPanelClosedMetadata): void
|
||||
trackAgentEntryButtonClicked?(metadata: AgentEntryButtonClickedMetadata): void
|
||||
trackAgentCloseButtonClicked?(): void
|
||||
trackAgentMessageSent?(metadata: AgentMessageSentMetadata): void
|
||||
trackAgentNodeTagged?(metadata: AgentNodeTaggedMetadata): void
|
||||
trackAgentAttachButtonClicked?(): void
|
||||
trackAgentWorkflowApplied?(metadata: AgentWorkflowAppliedMetadata): void
|
||||
|
||||
// Page view tracking
|
||||
trackPageView?(pageName: string, properties?: PageViewMetadata): void
|
||||
}
|
||||
@@ -745,6 +803,17 @@ export const TelemetryEvents = {
|
||||
// Generic UI Button Click
|
||||
UI_BUTTON_CLICKED: 'app:ui_button_clicked',
|
||||
|
||||
// In-App Agent
|
||||
AGENT_MESSAGE_FEEDBACK: 'app:agent_message_feedback',
|
||||
AGENT_PANEL_OPENED: 'app:agent_panel_opened',
|
||||
AGENT_PANEL_CLOSED: 'app:agent_panel_closed',
|
||||
AGENT_ENTRY_BUTTON_CLICKED: 'app:agent_entry_button_clicked',
|
||||
AGENT_CLOSE_BUTTON_CLICKED: 'app:agent_close_button_clicked',
|
||||
AGENT_MESSAGE_SENT: 'app:agent_message_sent',
|
||||
AGENT_NODE_TAGGED: 'app:agent_node_tagged',
|
||||
AGENT_ATTACH_BUTTON_CLICKED: 'app:agent_attach_button_clicked',
|
||||
AGENT_WORKFLOW_APPLIED: 'app:agent_workflow_applied',
|
||||
|
||||
// Page View
|
||||
PAGE_VIEW: 'app:page_view'
|
||||
} as const
|
||||
@@ -795,6 +864,13 @@ export type TelemetryEventProperties =
|
||||
| HelpCenterOpenedMetadata
|
||||
| HelpResourceClickedMetadata
|
||||
| HelpCenterClosedMetadata
|
||||
| AgentMessageFeedbackMetadata
|
||||
| AgentPanelOpenedMetadata
|
||||
| AgentPanelClosedMetadata
|
||||
| AgentEntryButtonClickedMetadata
|
||||
| AgentMessageSentMetadata
|
||||
| AgentNodeTaggedMetadata
|
||||
| AgentWorkflowAppliedMetadata
|
||||
| WorkflowCreatedMetadata
|
||||
| EnterLinearMetadata
|
||||
| ShareFlowMetadata
|
||||
|
||||
2819
src/workbench/extensions/agent/AgentPanelRoot.test.ts
Normal file
2819
src/workbench/extensions/agent/AgentPanelRoot.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
752
src/workbench/extensions/agent/AgentPanelRoot.vue
Normal file
752
src/workbench/extensions/agent/AgentPanelRoot.vue
Normal file
@@ -0,0 +1,752 @@
|
||||
<script setup lang="ts">
|
||||
import './agentPanel.css'
|
||||
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { validateComfyWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { appendWorkflowJsonExt } from '@/utils/formatUtil'
|
||||
// eslint-disable-next-line import-x/no-restricted-paths
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
import AgentPanel from './components/agent/AgentPanel.vue'
|
||||
import OnboardingCoach from './components/agent/OnboardingCoach.vue'
|
||||
import type { ConflictChoice } from './components/agent/safety/ConflictDialog.vue'
|
||||
import { useAttachment } from './composables/agent/useAttachment'
|
||||
import type { ActiveTab } from './components/agent/ActiveTabStrip.vue'
|
||||
import type { SelectedNode } from './composables/agent/useCanvasSelection'
|
||||
import { useCanvasSelection } from './composables/agent/useCanvasSelection'
|
||||
import type { CoachStep } from './composables/agent/useOnboarding'
|
||||
import type { ComposerAttachment } from './composables/agent/useComposer'
|
||||
import type {
|
||||
AgentActiveTabData,
|
||||
AgentDraftSnapshot,
|
||||
AgentThreadSummary
|
||||
} from './schemas/agentApiSchema'
|
||||
import type { ChatSession } from './stores/agent/agentChatHistoryStore'
|
||||
import type { ConversationEntry } from './stores/agent/agentConversationStore'
|
||||
import type { WorkflowTurnContext } from './composables/agent/useAgentSession'
|
||||
import { useAgentSession } from './composables/agent/useAgentSession'
|
||||
import { useAgentDraftStore } from './stores/agent/agentDraftStore'
|
||||
import { useAgentWorkflowTabBindingStore } from './stores/agent/agentWorkflowTabBindingStore'
|
||||
import {
|
||||
AgentApiError,
|
||||
createAgentRestClient
|
||||
} from './services/agent/agentRestClient'
|
||||
import type {
|
||||
DraftUpload,
|
||||
OpenTabsSnapshot
|
||||
} from './services/agent/agentRestClient'
|
||||
import { createAgentEventSource } from './services/agent/agentEventSource'
|
||||
import { useAgentChatHistoryStore } from './stores/agent/agentChatHistoryStore'
|
||||
import { useAgentPanelStore } from './stores/agent/agentPanelStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToastStore()
|
||||
|
||||
const { userDisplayName } = useCurrentUser()
|
||||
const userName = computed(
|
||||
() => userDisplayName.value?.trim().split(/\s+/)[0] || undefined
|
||||
)
|
||||
|
||||
const rest = createAgentRestClient()
|
||||
|
||||
const events = createAgentEventSource(api)
|
||||
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const bindingStore = useAgentWorkflowTabBindingStore()
|
||||
const draftStore = useAgentDraftStore()
|
||||
const agentPanelStore = useAgentPanelStore()
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const selectedNodes = computed<SelectedNode[]>(() =>
|
||||
canvasStore.selectedItems.filter(isLGraphNode).map((node) => ({
|
||||
id: String(node.id),
|
||||
title: node.title || node.type
|
||||
}))
|
||||
)
|
||||
const {
|
||||
staged: selectionTags,
|
||||
consume: consumeSelection,
|
||||
remove: removeSelectionTag,
|
||||
add: addSelectionTag
|
||||
} = useCanvasSelection({
|
||||
selection: selectedNodes,
|
||||
isLive: () => agentPanelStore.isOpen
|
||||
})
|
||||
|
||||
function viewedGraphNodes() {
|
||||
return app.canvas?.graph?.nodes ?? app.graph?.nodes ?? []
|
||||
}
|
||||
|
||||
function mentionableNodes(): SelectedNode[] {
|
||||
return viewedGraphNodes().map((node) => ({
|
||||
id: String(node.id),
|
||||
title: node.title || node.type
|
||||
}))
|
||||
}
|
||||
|
||||
let cloudIdsByName = new Map<string, string>()
|
||||
|
||||
async function refreshCloudWorkflowIds(): Promise<void> {
|
||||
try {
|
||||
const workflows = await rest.listCloudWorkflows()
|
||||
const nameCounts = new Map<string, number>()
|
||||
for (const { name } of workflows) {
|
||||
if (name !== undefined)
|
||||
nameCounts.set(name, (nameCounts.get(name) ?? 0) + 1)
|
||||
}
|
||||
cloudIdsByName = new Map(
|
||||
workflows.flatMap(({ id, name }) =>
|
||||
name !== undefined && nameCounts.get(name) === 1
|
||||
? [[name, id] as const]
|
||||
: []
|
||||
)
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn('[agent] could not refresh cloud workflow ids', error)
|
||||
}
|
||||
}
|
||||
|
||||
function openSavedTabsNamed(filename: string): ComfyWorkflow[] {
|
||||
return workflowStore.openWorkflows.filter(
|
||||
(tab) => !tab.isTemporary && tab.filename === filename
|
||||
)
|
||||
}
|
||||
|
||||
function cloudIdFor(tab: ComfyWorkflow): string | undefined {
|
||||
const saved =
|
||||
!tab.isTemporary && openSavedTabsNamed(tab.filename).length === 1
|
||||
? cloudIdsByName.get(tab.filename)
|
||||
: undefined
|
||||
return saved ?? bindingStore.workflowIdFor(tab.path)
|
||||
}
|
||||
|
||||
let lastKnownGraph: { serialized: string; workflowId: string } | null = null
|
||||
|
||||
function reclaimMovedBinding(activePath: string): string | undefined {
|
||||
if (lastKnownGraph === null) return undefined
|
||||
const graph = app.graph?.serialize()
|
||||
if (!graph || JSON.stringify(graph) !== lastKnownGraph.serialized)
|
||||
return undefined
|
||||
const { workflowId } = lastKnownGraph
|
||||
bindingStore.bind(workflowId, activePath)
|
||||
lastKnownGraph = null
|
||||
return workflowId
|
||||
}
|
||||
|
||||
function activeWorkflowTurnContext(): WorkflowTurnContext | undefined {
|
||||
const active = workflowStore.activeWorkflow
|
||||
if (!active) return undefined
|
||||
const bound = cloudIdFor(active) ?? reclaimMovedBinding(active.path)
|
||||
return bound === undefined ? undefined : { id: bound, tabPath: active.path }
|
||||
}
|
||||
|
||||
const activeTab = computed<ActiveTab | null>(() => {
|
||||
const active = workflowStore.activeWorkflow
|
||||
return active ? { name: active.filename } : null
|
||||
})
|
||||
|
||||
let lastSentGraph: string | null = null
|
||||
let snapshotTabPath: string | null = null
|
||||
|
||||
function takeWorkflowSnapshot(): DraftUpload | undefined {
|
||||
const graph = app.graph?.serialize()
|
||||
if (!graph?.nodes?.length) return undefined
|
||||
const serialized = JSON.stringify(graph)
|
||||
const activePath = workflowStore.activeWorkflow?.path ?? null
|
||||
if (serialized === lastSentGraph && activePath === snapshotTabPath)
|
||||
return undefined
|
||||
lastSentGraph = serialized
|
||||
snapshotTabPath = activePath
|
||||
return { content: graph, version: draftStore.version }
|
||||
}
|
||||
|
||||
function resetSnapshotGuard(): void {
|
||||
lastSentGraph = null
|
||||
snapshotTabPath = null
|
||||
lastKnownGraph = null
|
||||
}
|
||||
|
||||
function openTabsSnapshot(): OpenTabsSnapshot | undefined {
|
||||
const openTabs = workflowStore.openWorkflows.flatMap((tab) => {
|
||||
const workflowId = cloudIdFor(tab)
|
||||
return workflowId === undefined
|
||||
? []
|
||||
: [{ workflow_id: workflowId, name: tab.filename }]
|
||||
})
|
||||
if (openTabs.length === 0) return undefined
|
||||
const active = workflowStore.activeWorkflow
|
||||
return {
|
||||
open_tabs: openTabs,
|
||||
current_tab: active ? cloudIdFor(active) : undefined
|
||||
}
|
||||
}
|
||||
|
||||
function onWorkflowAdopted(
|
||||
workflowId: string,
|
||||
sent: WorkflowTurnContext | undefined,
|
||||
uploaded: boolean
|
||||
): void {
|
||||
if (uploaded && lastSentGraph !== null)
|
||||
lastKnownGraph = { serialized: lastSentGraph, workflowId }
|
||||
if (sent !== undefined && sent.id === workflowId) {
|
||||
bindingStore.bind(workflowId, sent.tabPath)
|
||||
return
|
||||
}
|
||||
if (uploaded && snapshotTabPath !== null)
|
||||
bindingStore.bind(workflowId, snapshotTabPath)
|
||||
}
|
||||
|
||||
const {
|
||||
sendMessage,
|
||||
stopTurn,
|
||||
newChat,
|
||||
start,
|
||||
stop,
|
||||
entries,
|
||||
isStreaming,
|
||||
status,
|
||||
notices,
|
||||
threadId,
|
||||
listThreads,
|
||||
loadThread
|
||||
} = useAgentSession({
|
||||
rest,
|
||||
events,
|
||||
workflow: {
|
||||
current: activeWorkflowTurnContext,
|
||||
adopted: onWorkflowAdopted,
|
||||
prepare: refreshCloudWorkflowIds,
|
||||
snapshot: takeWorkflowSnapshot,
|
||||
uploadSkipped: resetSnapshotGuard,
|
||||
tabs: openTabsSnapshot,
|
||||
activeTab: enqueueActiveTab
|
||||
}
|
||||
})
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
|
||||
function surfaceAgentError(
|
||||
type: 'agent_api_failed' | 'agent_draft_apply_failed',
|
||||
details: string
|
||||
): void {
|
||||
executionErrorStore.lastPromptError = {
|
||||
type,
|
||||
message: t(`errorCatalog.promptErrors.${type}.desc`),
|
||||
details
|
||||
}
|
||||
executionErrorStore.showErrorOverlay()
|
||||
}
|
||||
|
||||
let noticesSeen = 0
|
||||
watch(
|
||||
() => notices.value.length,
|
||||
(length) => {
|
||||
for (const notice of notices.value.slice(noticesSeen))
|
||||
surfaceAgentError('agent_api_failed', notice.text)
|
||||
noticesSeen = length
|
||||
}
|
||||
)
|
||||
|
||||
let draftRejectionNotified = false
|
||||
|
||||
function surfaceDraftApplyFailure(details: string): void {
|
||||
console.warn(details)
|
||||
if (draftRejectionNotified) return
|
||||
draftRejectionNotified = true
|
||||
surfaceAgentError('agent_draft_apply_failed', details)
|
||||
}
|
||||
|
||||
const conflictOpen = ref(false)
|
||||
let applySuppressed = false
|
||||
let lastApplied: { workflowId: string; version: number } | null = null
|
||||
let applying = false
|
||||
let reapplyQueued = false
|
||||
|
||||
function boundTabFor(workflowId: string): ComfyWorkflow | null {
|
||||
const path = bindingStore.tabPathFor(workflowId)
|
||||
const bound =
|
||||
path === undefined ? null : workflowStore.getWorkflowByPath(path)
|
||||
if (bound) return bound
|
||||
for (const [name, id] of cloudIdsByName) {
|
||||
if (id !== workflowId) continue
|
||||
const matches = openSavedTabsNamed(name)
|
||||
return matches.length === 1 ? matches[0] : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function unusedFilenameFor(tab: ComfyWorkflow): string {
|
||||
const takenByOther = (filename: string) => {
|
||||
const path =
|
||||
tab.directory +
|
||||
'/' +
|
||||
appendWorkflowJsonExt(filename, tab.initialMode === 'app')
|
||||
return path !== tab.path && workflowStore.getWorkflowByPath(path) !== null
|
||||
}
|
||||
if (!takenByOther(tab.filename)) return tab.filename
|
||||
let counter = 2
|
||||
while (takenByOther(`${tab.filename} (${counter})`)) counter++
|
||||
return `${tab.filename} (${counter})`
|
||||
}
|
||||
|
||||
async function autosaveAppliedDraft(
|
||||
workflowId: string,
|
||||
tab: ComfyWorkflow
|
||||
): Promise<void> {
|
||||
try {
|
||||
const saved = tab.isTemporary
|
||||
? await workflowService.saveWorkflowAs(tab, {
|
||||
filename: unusedFilenameFor(tab)
|
||||
})
|
||||
: await workflowService.saveWorkflow(tab)
|
||||
if (!saved) console.error(`Agent draft autosave failed for ${tab.path}`)
|
||||
} catch (error) {
|
||||
console.error(`Agent draft autosave failed for ${tab.path}:`, error)
|
||||
} finally {
|
||||
bindingStore.bind(workflowId, tab.path)
|
||||
}
|
||||
}
|
||||
|
||||
let activeTabGeneration = 0
|
||||
let activeTabChain: Promise<void> = Promise.resolve()
|
||||
const lastRenderedVersions = new Map<string, number>()
|
||||
|
||||
function enqueueActiveTab(data: AgentActiveTabData): void {
|
||||
const generation = ++activeTabGeneration
|
||||
activeTabChain = activeTabChain.then(() => onAgentActiveTab(data, generation))
|
||||
}
|
||||
|
||||
function agentTabFilename(name: string | undefined): string | undefined {
|
||||
const cleaned = [
|
||||
...(name ?? '')
|
||||
.replace(/[/\\\p{Cc}]/gu, '-')
|
||||
.replace(/\.json$/i, '')
|
||||
.trim()
|
||||
.replace(/^\.+/, '')
|
||||
]
|
||||
.slice(0, 80)
|
||||
.join('')
|
||||
.replace(/^[\s.]+/u, '')
|
||||
.trim()
|
||||
return cleaned.length === 0 ? undefined : `${cleaned}.json`
|
||||
}
|
||||
|
||||
async function fetchDraftSnapshot(
|
||||
workflowId: string
|
||||
): Promise<AgentDraftSnapshot | null> {
|
||||
try {
|
||||
return await rest.getDraft(workflowId)
|
||||
} catch (error) {
|
||||
if (error instanceof AgentApiError && error.status === 404) return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function recordRenderedVersion(nextWorkflowId: string): void {
|
||||
const leaving = draftStore.workflowId
|
||||
if (leaving === null || leaving === nextWorkflowId) return
|
||||
if (lastApplied?.workflowId === leaving)
|
||||
lastRenderedVersions.set(leaving, lastApplied.version)
|
||||
else lastRenderedVersions.delete(leaving)
|
||||
}
|
||||
|
||||
async function adoptDraftBase(
|
||||
workflowId: string,
|
||||
snapshot: AgentDraftSnapshot,
|
||||
armVersion: number = snapshot.version
|
||||
): Promise<void> {
|
||||
draftStore.bind(workflowId)
|
||||
await nextTick()
|
||||
if (
|
||||
!(
|
||||
lastApplied?.workflowId === workflowId && lastApplied.version > armVersion
|
||||
)
|
||||
)
|
||||
lastApplied = { workflowId, version: armVersion }
|
||||
draftStore.adoptSnapshot(snapshot)
|
||||
}
|
||||
|
||||
async function onAgentActiveTab(
|
||||
data: AgentActiveTabData,
|
||||
generation: number
|
||||
): Promise<void> {
|
||||
const stale = () => generation !== activeTabGeneration
|
||||
if (stale()) return
|
||||
try {
|
||||
recordRenderedVersion(data.workflow_id)
|
||||
const bound = boundTabFor(data.workflow_id)
|
||||
if (bound) {
|
||||
const alreadyCurrent =
|
||||
draftStore.workflowId === data.workflow_id &&
|
||||
draftStore.version !== null
|
||||
await workflowService.openWorkflow(bound)
|
||||
if (stale()) return
|
||||
draftStore.bind(data.workflow_id)
|
||||
const snapshot = await fetchDraftSnapshot(data.workflow_id)
|
||||
if (stale()) return
|
||||
if (
|
||||
snapshot !== null &&
|
||||
!(alreadyCurrent && (draftStore.version ?? -1) >= snapshot.version)
|
||||
)
|
||||
await adoptDraftBase(
|
||||
data.workflow_id,
|
||||
snapshot,
|
||||
lastRenderedVersions.get(data.workflow_id) ?? -1
|
||||
)
|
||||
useTelemetry()?.trackAgentWorkflowApplied({
|
||||
workflow_id: data.workflow_id,
|
||||
target: 'active_tab_switch'
|
||||
})
|
||||
return
|
||||
}
|
||||
const snapshot = await fetchDraftSnapshot(data.workflow_id)
|
||||
if (stale()) return
|
||||
let validationError = ''
|
||||
const workflow =
|
||||
snapshot === null
|
||||
? null
|
||||
: await validateComfyWorkflow(snapshot.content, (error) => {
|
||||
validationError = error
|
||||
})
|
||||
if (stale()) return
|
||||
if (snapshot !== null && !workflow) {
|
||||
surfaceDraftApplyFailure(validationError)
|
||||
draftStore.bind(data.workflow_id)
|
||||
return
|
||||
}
|
||||
const tab = workflowStore.createTemporary(
|
||||
agentTabFilename(data.name),
|
||||
workflow ?? undefined
|
||||
)
|
||||
await workflowService.openWorkflow(tab)
|
||||
if (stale()) return
|
||||
await autosaveAppliedDraft(data.workflow_id, tab)
|
||||
if (stale()) return
|
||||
if (snapshot === null) draftStore.bind(data.workflow_id)
|
||||
else await adoptDraftBase(data.workflow_id, snapshot)
|
||||
useTelemetry()?.trackAgentWorkflowApplied({
|
||||
workflow_id: data.workflow_id,
|
||||
target: 'active_tab_open'
|
||||
})
|
||||
} catch (error) {
|
||||
if (stale()) return
|
||||
draftStore.bind(data.workflow_id)
|
||||
surfaceAgentError(
|
||||
'agent_api_failed',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDraft(
|
||||
workflowId: string,
|
||||
version: number,
|
||||
content: Record<string, unknown>,
|
||||
tab: ComfyWorkflow | null
|
||||
): Promise<void> {
|
||||
const workflow = await validateComfyWorkflow(content, (error) => {
|
||||
surfaceDraftApplyFailure(error)
|
||||
})
|
||||
if (!workflow) return
|
||||
const openBefore = new Set(workflowStore.openWorkflows.map((w) => w.path))
|
||||
try {
|
||||
await app.loadGraphData(workflow, true, true, tab)
|
||||
draftRejectionNotified = false
|
||||
lastApplied = { workflowId, version }
|
||||
const rendered = app.graph?.serialize()
|
||||
if (rendered)
|
||||
lastKnownGraph = { serialized: JSON.stringify(rendered), workflowId }
|
||||
useTelemetry()?.trackAgentWorkflowApplied({
|
||||
workflow_id: workflowId,
|
||||
target: tab === null ? 'new_tab' : 'existing_tab'
|
||||
})
|
||||
if (tab === null) {
|
||||
const opened = workflowStore.openWorkflows.find(
|
||||
(w) => !openBefore.has(w.path)
|
||||
)
|
||||
if (opened) {
|
||||
bindingStore.bind(workflowId, opened.path)
|
||||
await autosaveAppliedDraft(workflowId, opened)
|
||||
}
|
||||
return
|
||||
}
|
||||
await autosaveAppliedDraft(workflowId, tab)
|
||||
} catch (error) {
|
||||
surfaceDraftApplyFailure(
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function applyDraft(): Promise<void> {
|
||||
if (applying) {
|
||||
reapplyQueued = true
|
||||
return
|
||||
}
|
||||
applying = true
|
||||
try {
|
||||
const workflowId = draftStore.workflowId
|
||||
const version = draftStore.version
|
||||
const content = draftStore.content
|
||||
if (workflowId === null || version === null || content === null) return
|
||||
if (applySuppressed) return
|
||||
if (
|
||||
lastApplied !== null &&
|
||||
lastApplied.workflowId === workflowId &&
|
||||
lastApplied.version >= version
|
||||
)
|
||||
return
|
||||
const nodes = (content as { nodes?: unknown }).nodes
|
||||
if (!Array.isArray(nodes) || nodes.length === 0) return
|
||||
const boundTab = boundTabFor(workflowId)
|
||||
if (boundTab) {
|
||||
if (workflowStore.activeWorkflow?.path !== boundTab.path) return
|
||||
if (boundTab.isModified) {
|
||||
conflictOpen.value = true
|
||||
return
|
||||
}
|
||||
await loadDraft(workflowId, version, content, boundTab)
|
||||
return
|
||||
}
|
||||
await loadDraft(workflowId, version, content, null)
|
||||
} finally {
|
||||
applying = false
|
||||
if (reapplyQueued) {
|
||||
reapplyQueued = false
|
||||
void applyDraft()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => draftStore.version,
|
||||
(version) => {
|
||||
if (version === null || draftStore.content === null) return
|
||||
void applyDraft()
|
||||
}
|
||||
)
|
||||
watch(
|
||||
() => workflowStore.activeWorkflow?.path,
|
||||
() => void applyDraft()
|
||||
)
|
||||
watch(
|
||||
() => draftStore.workflowId,
|
||||
() => {
|
||||
lastApplied = null
|
||||
applySuppressed = false
|
||||
conflictOpen.value = false
|
||||
}
|
||||
)
|
||||
|
||||
function onResolveConflict(choice: ConflictChoice): void {
|
||||
conflictOpen.value = false
|
||||
const workflowId = draftStore.workflowId
|
||||
const version = draftStore.version
|
||||
const content = draftStore.content
|
||||
if (workflowId === null || version === null || content === null) return
|
||||
if (choice === 'cancel') {
|
||||
applySuppressed = true
|
||||
return
|
||||
}
|
||||
if (choice === 'mine') {
|
||||
lastApplied = { workflowId, version }
|
||||
return
|
||||
}
|
||||
void loadDraft(
|
||||
workflowId,
|
||||
version,
|
||||
content,
|
||||
choice === 'agent' ? boundTabFor(workflowId) : null
|
||||
)
|
||||
}
|
||||
|
||||
start()
|
||||
void refreshCloudWorkflowIds()
|
||||
onBeforeUnmount(stop)
|
||||
|
||||
const history = useAgentChatHistoryStore()
|
||||
|
||||
const { copy } = useClipboard({ legacy: true })
|
||||
|
||||
function onFeedback(turnId: string, vote: 'up' | 'down' | null): void {
|
||||
useTelemetry()?.trackAgentMessageFeedback({
|
||||
message_id: turnId,
|
||||
vote,
|
||||
workflow_id: draftStore.workflowId
|
||||
})
|
||||
}
|
||||
|
||||
function toChatSession(thread: AgentThreadSummary): ChatSession {
|
||||
const stamp = thread.last_message_at ?? thread.updated_at ?? thread.created_at
|
||||
const updatedAt = stamp ? Date.parse(stamp) : Date.now()
|
||||
return {
|
||||
id: thread.id,
|
||||
title: thread.title || thread.preview || t('agent.untitledChat'),
|
||||
updatedAt: Number.isNaN(updatedAt) ? Date.now() : updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshHistory(): Promise<void> {
|
||||
try {
|
||||
history.replaceAll((await listThreads()).map(toChatSession))
|
||||
} catch (error) {
|
||||
surfaceAgentError(
|
||||
'agent_api_failed',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
watch(threadId, (id) => history.setActive(id), { immediate: true })
|
||||
|
||||
void refreshHistory()
|
||||
|
||||
async function onSelectHistory(id: string): Promise<void> {
|
||||
resetSnapshotGuard()
|
||||
await loadThread(id)
|
||||
void refreshHistory()
|
||||
}
|
||||
|
||||
function buildTranscriptMarkdown(entries: ConversationEntry[]): string {
|
||||
return entries
|
||||
.map((entry) => {
|
||||
if (entry.role === 'user') return `**You:** ${entry.text}`
|
||||
const text = entry.parts
|
||||
.filter((part) => part.type === 'text')
|
||||
.map((part) => part.text)
|
||||
.join('')
|
||||
return `**Agent:** ${text}`
|
||||
})
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
function onCopyMarkdown(id: string): void {
|
||||
if (id === history.activeId) void copy(buildTranscriptMarkdown(entries.value))
|
||||
else toast.add({ severity: 'info', summary: t('agent.copyUnavailable') })
|
||||
}
|
||||
|
||||
const coachStep: CoachStep = {
|
||||
target: '#agent-panel-root',
|
||||
title: t('agent.coachTitle'),
|
||||
body: t('agent.coachBody')
|
||||
}
|
||||
|
||||
function onSend(text: string, attachments: ComposerAttachment[]): void {
|
||||
applySuppressed = false
|
||||
void applyDraft()
|
||||
const nodeTags = consumeSelection()
|
||||
useTelemetry()?.trackAgentMessageSent({
|
||||
attachment_count: attachments.length,
|
||||
node_tag_count: nodeTags.length
|
||||
})
|
||||
void sendMessage(text, attachments, nodeTags).then((ok) => {
|
||||
if (!ok) resetSnapshotGuard()
|
||||
})
|
||||
}
|
||||
|
||||
function onStop(): void {
|
||||
void stopTurn()
|
||||
}
|
||||
|
||||
function onNewChat(): void {
|
||||
resetSnapshotGuard()
|
||||
newChat()
|
||||
}
|
||||
|
||||
const panelRef = ref<InstanceType<typeof AgentPanel>>()
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
|
||||
const attachment = useAttachment({
|
||||
upload: async (file) => ({
|
||||
ref: (await rest.uploadImage(file, file.name)).name
|
||||
}),
|
||||
onError: (message) => surfaceAgentError('agent_api_failed', message),
|
||||
stage: (staged) => panelRef.value?.addAttachment(staged),
|
||||
update: (id, patch) => panelRef.value?.updateAttachment(id, patch),
|
||||
remove: (id) => panelRef.value?.removeAttachment(id)
|
||||
})
|
||||
|
||||
function onAttach(): void {
|
||||
useTelemetry()?.trackAgentAttachButtonClicked()
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
function onMentionPick(node: SelectedNode): void {
|
||||
const stagedBefore = selectionTags.value.length
|
||||
addSelectionTag(node)
|
||||
if (selectionTags.value.length > stagedBefore)
|
||||
useTelemetry()?.trackAgentNodeTagged({ source: 'mention_picker' })
|
||||
}
|
||||
|
||||
function onClosePanel(): void {
|
||||
useTelemetry()?.trackAgentCloseButtonClicked()
|
||||
agentPanelStore.close('close_button')
|
||||
}
|
||||
|
||||
async function onFilesPicked(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = input.files
|
||||
if (files && files.length > 0) await attachment.addFiles(Array.from(files))
|
||||
input.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="agent-panel-root" class="size-full">
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
data-testid="agent-file-input"
|
||||
@change="onFilesPicked"
|
||||
/>
|
||||
<AgentPanel
|
||||
ref="panelRef"
|
||||
:entries
|
||||
:user-name="userName"
|
||||
:streaming="isStreaming"
|
||||
:submitting="status === 'thinking'"
|
||||
:can-attach="true"
|
||||
:is-maximized="agentPanelStore.isMaximized"
|
||||
:history-groups="history.grouped"
|
||||
:selection-tags="selectionTags"
|
||||
:active-tab="activeTab"
|
||||
:conflict-open="conflictOpen"
|
||||
:get-mention-nodes="mentionableNodes"
|
||||
@send="onSend"
|
||||
@stop="onStop"
|
||||
@attach="onAttach"
|
||||
@remove-tag="removeSelectionTag"
|
||||
@mention-pick="onMentionPick"
|
||||
@resolve-conflict="onResolveConflict"
|
||||
@feedback="onFeedback"
|
||||
@new-chat="onNewChat"
|
||||
@toggle-size="agentPanelStore.toggleMaximize()"
|
||||
@close="onClosePanel"
|
||||
@open-history="refreshHistory()"
|
||||
@select-history="onSelectHistory"
|
||||
@delete-history="history.remove($event)"
|
||||
@copy-history="onCopyMarkdown"
|
||||
/>
|
||||
<OnboardingCoach
|
||||
:step="coachStep"
|
||||
storage-key="Comfy.AgentPanel.onboarded"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
7
src/workbench/extensions/agent/README.md
Normal file
7
src/workbench/extensions/agent/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# In-App Agent panel (FE-1187)
|
||||
|
||||
The In-App Agent panel is a manager-pattern workbench extension. The panel lives
|
||||
entirely in this subtree and renders in a flag-gated right dock registered by
|
||||
`src/extensions/core/agentPanel.ts`, so it shares the host pinia and vue-i18n
|
||||
instances and wires every host dependency itself (REST client, `/ws` event source,
|
||||
draft-to-canvas seam).
|
||||
76
src/workbench/extensions/agent/agentPanel.css
Normal file
76
src/workbench/extensions/agent/agentPanel.css
Normal file
@@ -0,0 +1,76 @@
|
||||
@keyframes agent-collapsible-down {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--reka-collapsible-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-collapsible-up {
|
||||
from {
|
||||
height: var(--reka-collapsible-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-shimmer {
|
||||
from {
|
||||
background-position: 150% center;
|
||||
}
|
||||
to {
|
||||
background-position: -50% center;
|
||||
}
|
||||
}
|
||||
|
||||
.agent-shimmer-text {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-muted-foreground) 35%,
|
||||
var(--color-base-foreground) 50%,
|
||||
var(--color-muted-foreground) 65%
|
||||
);
|
||||
background-size: 200% auto;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: agent-shimmer 1.8s linear infinite;
|
||||
}
|
||||
|
||||
.disable-animations .agent-shimmer-text {
|
||||
animation: none;
|
||||
color: var(--color-muted-foreground);
|
||||
-webkit-text-fill-color: currentcolor;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.agent-shimmer-text {
|
||||
animation: none;
|
||||
color: var(--color-muted-foreground);
|
||||
-webkit-text-fill-color: currentcolor;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tailwind Preflight is disabled host-wide (PrimeVue + litegraph coexistence), so raw form
|
||||
elements in the panel inherit UA chrome: a <button> gets the buttonface fill + 2px outset
|
||||
border, a <textarea>/<input> gets its own border, font and padding. Re-apply Preflight's
|
||||
normalization, scoped to the panel root and its teleported reka surfaces (dialogs,
|
||||
dropdowns, drawer carry .agent-scope). :where() holds the selectors at zero specificity so
|
||||
each element's own bg-* / border utilities still win. */
|
||||
@layer base {
|
||||
:where(#agent-panel-root, .agent-scope)
|
||||
:where(button, [type='button'], [type='reset'], [type='submit']) {
|
||||
appearance: none;
|
||||
background-color: transparent;
|
||||
border: 0 solid;
|
||||
}
|
||||
|
||||
:where(#agent-panel-root, .agent-scope) :where(textarea, input, select) {
|
||||
appearance: none;
|
||||
border: 0 solid;
|
||||
background-color: transparent;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
24
src/workbench/extensions/agent/agentTheme.css
Normal file
24
src/workbench/extensions/agent/agentTheme.css
Normal file
@@ -0,0 +1,24 @@
|
||||
@theme inline {
|
||||
--color-agent-surface: var(--color-base-background);
|
||||
--color-agent-surface-raised: var(--color-secondary-background);
|
||||
--color-agent-surface-hover: var(--color-secondary-background-hover);
|
||||
--color-agent-fg: var(--color-base-foreground);
|
||||
--color-agent-fg-muted: var(--color-muted-foreground);
|
||||
--color-agent-fg-subtle: var(--color-muted-foreground);
|
||||
--color-agent-border: var(--color-component-node-border);
|
||||
--color-agent-border-strong: var(--color-border-default);
|
||||
--color-agent-accent: var(--color-primary-background);
|
||||
/* accent-fg: text on the always-blue accent stays white in both themes; no host
|
||||
on-primary token flips correctly here. Design-review item for FE-1187. */
|
||||
--color-agent-accent-fg: #ffffff;
|
||||
--color-agent-danger: var(--color-destructive-background);
|
||||
--color-agent-success: var(--color-success-background);
|
||||
--color-agent-pill: #303036;
|
||||
|
||||
--radius-agent: 0.75rem;
|
||||
}
|
||||
|
||||
@theme {
|
||||
--animate-agent-collapsible-down: agent-collapsible-down 200ms ease-out;
|
||||
--animate-agent-collapsible-up: agent-collapsible-up 200ms ease-out;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import ActiveTabStrip from './ActiveTabStrip.vue'
|
||||
|
||||
describe('ActiveTabStrip', () => {
|
||||
it('shows the tab name, and nothing when there is no tab', async () => {
|
||||
const { rerender } = render(ActiveTabStrip, {
|
||||
props: { tab: { name: 'portrait.json' } }
|
||||
})
|
||||
expect(screen.getByText('portrait.json')).not.toBeNull()
|
||||
|
||||
await rerender({ tab: null })
|
||||
expect(screen.queryByText('portrait.json')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
export interface ActiveTab {
|
||||
name: string
|
||||
}
|
||||
|
||||
const { tab } = defineProps<{ tab: ActiveTab | null }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
v-if="tab"
|
||||
class="text-agent-fg-muted flex min-w-0 items-center gap-1 text-xs"
|
||||
>
|
||||
<span class="icon-[lucide--panels-top-left] size-3.5 shrink-0" />
|
||||
<span class="truncate">{{ tab.name }}</span>
|
||||
</span>
|
||||
</template>
|
||||
189
src/workbench/extensions/agent/components/agent/AgentPanel.vue
Normal file
189
src/workbench/extensions/agent/components/agent/AgentPanel.vue
Normal file
@@ -0,0 +1,189 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { ActiveTab } from './ActiveTabStrip.vue'
|
||||
import type { ComposerAttachment } from '../../composables/agent/useComposer'
|
||||
import type { SelectedNode } from '../../composables/agent/useCanvasSelection'
|
||||
import type { ConflictChoice } from './safety/ConflictDialog.vue'
|
||||
import type { ConversationEntry } from '../../stores/agent/agentConversationStore'
|
||||
import type { HistoryGroups } from '../../stores/agent/agentChatHistoryStore'
|
||||
|
||||
import ChatHistoryScreen from './ChatHistoryScreen.vue'
|
||||
import Composer from './Composer.vue'
|
||||
import ConversationView from './ConversationView.vue'
|
||||
import EmptyState from './EmptyState.vue'
|
||||
import PanelHeader from './PanelHeader.vue'
|
||||
import ConflictDialog from './safety/ConflictDialog.vue'
|
||||
|
||||
const {
|
||||
entries,
|
||||
userName,
|
||||
streaming = false,
|
||||
submitting = false,
|
||||
conflictOpen = false,
|
||||
canAttach = false,
|
||||
isMaximized = false,
|
||||
selectionTags = [],
|
||||
activeTab = null,
|
||||
getMentionNodes = () => [],
|
||||
historyGroups
|
||||
} = defineProps<{
|
||||
entries: ConversationEntry[]
|
||||
userName?: string
|
||||
streaming?: boolean
|
||||
submitting?: boolean
|
||||
conflictOpen?: boolean
|
||||
canAttach?: boolean
|
||||
isMaximized?: boolean
|
||||
selectionTags?: SelectedNode[]
|
||||
activeTab?: ActiveTab | null
|
||||
getMentionNodes?: () => SelectedNode[]
|
||||
historyGroups: HistoryGroups
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
send: [text: string, attachments: ComposerAttachment[]]
|
||||
stop: []
|
||||
attach: []
|
||||
removeTag: [id: string]
|
||||
mentionPick: [node: SelectedNode]
|
||||
feedback: [turnId: string, vote: 'up' | 'down' | null]
|
||||
resolveConflict: [choice: ConflictChoice]
|
||||
newChat: []
|
||||
toggleSize: []
|
||||
close: []
|
||||
openHistory: []
|
||||
selectHistory: [id: string]
|
||||
deleteHistory: [id: string]
|
||||
copyHistory: [id: string]
|
||||
}>()
|
||||
|
||||
const showHistory = ref(false)
|
||||
|
||||
function onNewChat(): void {
|
||||
showHistory.value = false
|
||||
emit('newChat')
|
||||
}
|
||||
function onOpenHistory(): void {
|
||||
showHistory.value = true
|
||||
emit('openHistory')
|
||||
}
|
||||
function onSelectHistory(id: string): void {
|
||||
showHistory.value = false
|
||||
emit('selectHistory', id)
|
||||
}
|
||||
|
||||
const composerRef = ref<InstanceType<typeof Composer>>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const sessionTitle = computed(() => {
|
||||
const firstUser = entries.find(
|
||||
(entry): entry is Extract<ConversationEntry, { role: 'user' }> =>
|
||||
entry.role === 'user'
|
||||
)
|
||||
return firstUser?.text.trim().slice(0, 60) || undefined
|
||||
})
|
||||
|
||||
function addAttachment(attachment: ComposerAttachment): void {
|
||||
composerRef.value?.addAttachment(attachment)
|
||||
}
|
||||
|
||||
function updateAttachment(
|
||||
id: string,
|
||||
patch: Partial<ComposerAttachment>
|
||||
): void {
|
||||
composerRef.value?.updateAttachment(id, patch)
|
||||
}
|
||||
|
||||
function removeAttachment(id: string): void {
|
||||
composerRef.value?.removeAttachment(id)
|
||||
}
|
||||
|
||||
defineExpose({ addAttachment, updateAttachment, removeAttachment })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="bg-agent-surface text-agent-fg @container flex h-full flex-col overflow-hidden"
|
||||
>
|
||||
<PanelHeader
|
||||
:is-maximized="isMaximized"
|
||||
:active-tab="activeTab"
|
||||
@new-chat="onNewChat"
|
||||
@toggle-size="emit('toggleSize')"
|
||||
@close="emit('close')"
|
||||
/>
|
||||
|
||||
<template v-if="showHistory">
|
||||
<ChatHistoryScreen
|
||||
:groups="historyGroups"
|
||||
class="min-h-0 flex-1"
|
||||
@back="showHistory = false"
|
||||
@select="onSelectHistory"
|
||||
@delete="emit('deleteHistory', $event)"
|
||||
@copy-markdown="emit('copyHistory', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex shrink-0 items-center px-2 py-1.5">
|
||||
<button
|
||||
v-tooltip.bottom="{
|
||||
value: t('agent.showChatHistory'),
|
||||
showDelay: 500
|
||||
}"
|
||||
type="button"
|
||||
class="text-agent-fg-muted hover:bg-agent-surface-hover flex h-6 cursor-pointer items-center gap-1 rounded-sm px-2 text-xs transition-colors"
|
||||
@click="onOpenHistory"
|
||||
>
|
||||
<span class="icon-[lucide--align-justify] size-3.5 shrink-0" />
|
||||
<span class="max-w-56 truncate">{{
|
||||
sessionTitle || t('agent.newChatTitle')
|
||||
}}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1">
|
||||
<EmptyState
|
||||
v-if="!entries.length"
|
||||
:user-name="userName"
|
||||
@insert="composerRef?.insert($event)"
|
||||
/>
|
||||
<ConversationView
|
||||
v-else
|
||||
:entries="entries"
|
||||
@feedback="(id, vote) => emit('feedback', id, vote)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="!showHistory">
|
||||
<footer class="shrink-0 p-4">
|
||||
<div class="mx-auto flex w-full max-w-[640px] flex-col gap-2.5">
|
||||
<Composer
|
||||
ref="composerRef"
|
||||
:streaming="streaming"
|
||||
:submitting="submitting"
|
||||
:can-attach="canAttach"
|
||||
:selection-tags="selectionTags"
|
||||
:get-mention-nodes="getMentionNodes"
|
||||
@send="(text, attachments) => emit('send', text, attachments)"
|
||||
@stop="emit('stop')"
|
||||
@attach="emit('attach')"
|
||||
@remove-tag="emit('removeTag', $event)"
|
||||
@mention-pick="emit('mentionPick', $event)"
|
||||
/>
|
||||
<p class="text-agent-fg-muted my-0 text-center text-xs">
|
||||
{{ t('agent.caption') }}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<ConflictDialog
|
||||
:open="conflictOpen"
|
||||
@resolve="emit('resolveConflict', $event)"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type {
|
||||
ChatSession,
|
||||
HistoryGroups
|
||||
} from '../../stores/agent/agentChatHistoryStore'
|
||||
|
||||
const { groups } = defineProps<{ groups: HistoryGroups }>()
|
||||
const emit = defineEmits<{
|
||||
back: []
|
||||
select: [id: string]
|
||||
delete: [id: string]
|
||||
copyMarkdown: [id: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const sections = computed(() =>
|
||||
(
|
||||
[
|
||||
['current', t('agent.historyCurrent'), groups.current],
|
||||
['today', t('agent.historyToday'), groups.today],
|
||||
['yesterday', t('agent.historyYesterday'), groups.yesterday],
|
||||
['earlier', t('agent.historyEarlier'), groups.earlier]
|
||||
] as const
|
||||
).filter(([, , items]) => items.length > 0)
|
||||
)
|
||||
|
||||
const isEmpty = computed(() => sections.value.length === 0)
|
||||
|
||||
function pick(session: ChatSession): void {
|
||||
emit('select', session.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col overflow-hidden">
|
||||
<div class="flex shrink-0 items-center px-2 py-1.5">
|
||||
<button
|
||||
v-tooltip.bottom="{ value: t('agent.backToChat'), showDelay: 500 }"
|
||||
type="button"
|
||||
class="text-agent-fg-muted hover:bg-agent-surface-hover hover:text-agent-fg flex h-6 cursor-pointer items-center gap-1 rounded-sm px-2 text-xs transition-colors"
|
||||
@click="emit('back')"
|
||||
>
|
||||
<span class="icon-[lucide--arrow-left] size-3.5 shrink-0" />
|
||||
<span>{{ t('agent.history') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
<p
|
||||
v-if="isEmpty"
|
||||
class="text-agent-fg-muted px-2 py-8 text-center text-sm"
|
||||
>
|
||||
{{ t('agent.historyEmpty') }}
|
||||
</p>
|
||||
|
||||
<section v-for="[key, label, items] in sections" :key="key" class="mb-3">
|
||||
<p class="text-agent-fg-muted my-0 px-2 py-1 text-xs font-medium">
|
||||
{{ label }}
|
||||
</p>
|
||||
<div
|
||||
v-for="session in items"
|
||||
:key="session.id"
|
||||
class="group hover:bg-agent-surface-hover flex items-center gap-2 rounded-md px-2 py-1.5"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="text-agent-fg flex min-w-0 flex-1 cursor-pointer items-center gap-2 text-left text-sm"
|
||||
@click="pick(session)"
|
||||
>
|
||||
<span
|
||||
class="text-agent-fg-muted icon-[lucide--circle-check] size-3.5 shrink-0"
|
||||
/>
|
||||
<span class="truncate">{{
|
||||
session.title || t('agent.untitledChat')
|
||||
}}</span>
|
||||
</button>
|
||||
<div class="hidden shrink-0 items-center gap-0.5 group-hover:flex">
|
||||
<button
|
||||
type="button"
|
||||
class="text-agent-fg-muted hover:bg-agent-surface-hover hover:text-agent-fg flex cursor-pointer items-center justify-center rounded-sm p-0.5 transition-colors"
|
||||
:aria-label="t('agent.copyMarkdown')"
|
||||
@click="emit('copyMarkdown', session.id)"
|
||||
>
|
||||
<span class="icon-[lucide--copy] size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-agent-fg-muted hover:text-agent-danger hover:bg-agent-surface-hover flex cursor-pointer items-center justify-center rounded-sm p-0.5 transition-colors"
|
||||
:aria-label="t('agent.delete')"
|
||||
@click="emit('delete', session.id)"
|
||||
>
|
||||
<span class="icon-[lucide--trash-2] size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,59 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
|
||||
import { i18n } from '@/i18n'
|
||||
|
||||
import Composer from './Composer.vue'
|
||||
|
||||
function mount(props: ComponentProps<typeof Composer> = {}) {
|
||||
return render(Composer, { props, global: { plugins: [i18n] } })
|
||||
}
|
||||
|
||||
describe('Composer', () => {
|
||||
it('disables send when empty and enables once text is typed', async () => {
|
||||
mount()
|
||||
const send = screen.getByRole('button', { name: 'Send' })
|
||||
expect(send).toBeDisabled()
|
||||
await userEvent.type(screen.getByRole('textbox'), 'hello')
|
||||
expect(send).toBeEnabled()
|
||||
})
|
||||
|
||||
it('emits send with the trimmed text and clears the draft', async () => {
|
||||
const { emitted } = mount()
|
||||
const box = screen.getByRole('textbox')
|
||||
await userEvent.type(box, ' make art ')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Send' }))
|
||||
expect(emitted().send[0]).toEqual(['make art', []])
|
||||
expect((box as HTMLTextAreaElement).value).toBe('')
|
||||
})
|
||||
|
||||
it('sends on Enter but not on Shift+Enter', async () => {
|
||||
const { emitted } = mount()
|
||||
const box = screen.getByRole('textbox')
|
||||
await userEvent.type(box, 'one{Shift>}{Enter}{/Shift}two')
|
||||
expect(emitted().send).toBeUndefined()
|
||||
await userEvent.type(box, '{Enter}')
|
||||
expect(emitted().send).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows Stop while streaming and emits stop instead of send', async () => {
|
||||
const { emitted } = mount({ streaming: true })
|
||||
const stop = screen.getByRole('button', { name: 'Stop' })
|
||||
await userEvent.click(stop)
|
||||
expect(emitted().stop).toHaveLength(1)
|
||||
expect(emitted().send).toBeUndefined()
|
||||
})
|
||||
|
||||
it('hides the paperclip by default', () => {
|
||||
mount()
|
||||
expect(screen.queryByRole('button', { name: 'Attach a file' })).toBeNull()
|
||||
})
|
||||
|
||||
it('emits attach when the paperclip is clicked and canAttach is set', async () => {
|
||||
const { emitted } = mount({ canAttach: true })
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Attach a file' }))
|
||||
expect(emitted().attach).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
200
src/workbench/extensions/agent/components/agent/Composer.vue
Normal file
200
src/workbench/extensions/agent/components/agent/Composer.vue
Normal file
@@ -0,0 +1,200 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuTrigger
|
||||
} from 'reka-ui'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Textarea from '@/components/ui/textarea/Textarea.vue'
|
||||
import type { ComposerAttachment } from '../../composables/agent/useComposer'
|
||||
import { useComposer } from '../../composables/agent/useComposer'
|
||||
import type { SelectedNode } from '../../composables/agent/useCanvasSelection'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import AttachmentChip from './composer/AttachmentChip.vue'
|
||||
|
||||
const {
|
||||
streaming = false,
|
||||
submitting = false,
|
||||
canAttach = false,
|
||||
selectionTags = [],
|
||||
getMentionNodes = () => []
|
||||
} = defineProps<{
|
||||
streaming?: boolean
|
||||
submitting?: boolean
|
||||
canAttach?: boolean
|
||||
selectionTags?: SelectedNode[]
|
||||
getMentionNodes?: () => SelectedNode[]
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
send: [text: string, attachments: ComposerAttachment[]]
|
||||
stop: []
|
||||
attach: []
|
||||
removeTag: [id: string]
|
||||
mentionPick: [node: SelectedNode]
|
||||
}>()
|
||||
|
||||
const mentionNodes = ref<SelectedNode[]>([])
|
||||
function onMentionOpen(open: boolean): void {
|
||||
if (open) mentionNodes.value = getMentionNodes()
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const composer = useComposer({
|
||||
onSend: (text, attachments) => emit('send', text, attachments),
|
||||
isStreaming: () => streaming,
|
||||
onStop: () => emit('stop')
|
||||
})
|
||||
|
||||
function onEnter(event: KeyboardEvent): void {
|
||||
if (event.isComposing || event.shiftKey) return
|
||||
event.preventDefault()
|
||||
composer.submit()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
insert: composer.insert,
|
||||
addAttachment: composer.addAttachment,
|
||||
updateAttachment: composer.updateAttachment,
|
||||
removeAttachment: composer.removeAttachment
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="border-agent-border-strong bg-agent-surface-raised focus-within:border-agent-fg-muted flex flex-col rounded-2xl border transition-colors"
|
||||
>
|
||||
<div v-if="selectionTags.length" class="flex flex-wrap gap-1.5 px-4 pt-3">
|
||||
<span
|
||||
v-for="tag in selectionTags"
|
||||
:key="tag.id"
|
||||
class="rounded-agent bg-agent-pill text-agent-fg inline-flex items-center gap-1.5 py-1 pr-2 pl-1.5 text-xs"
|
||||
>
|
||||
<span class="text-agent-fg-subtle icon-[lucide--at-sign] size-3.5" />
|
||||
<span class="max-w-40 truncate">{{ tag.title }}</span>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('agent.remove')"
|
||||
class="text-agent-fg-muted hover:bg-agent-surface-hover hover:text-agent-fg -my-1 -mr-1 flex size-5 cursor-pointer items-center justify-center rounded-full transition-colors"
|
||||
@click="emit('removeTag', tag.id)"
|
||||
>
|
||||
<span class="icon-[lucide--x] size-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="composer.attachments.value.length"
|
||||
class="flex flex-wrap gap-1.5 px-4 pt-3"
|
||||
>
|
||||
<AttachmentChip
|
||||
v-for="item in composer.attachments.value"
|
||||
:key="item.id"
|
||||
:name="item.name"
|
||||
:preview-url="item.previewUrl"
|
||||
:uploading="item.uploading"
|
||||
@remove="composer.removeAttachment(item.id)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
v-model="composer.draft.value"
|
||||
:placeholder="t('agent.placeholder')"
|
||||
rows="1"
|
||||
class="max-h-48 min-h-20 resize-none rounded-xl bg-transparent px-4 py-3 focus-visible:ring-0"
|
||||
@keydown.enter="onEnter"
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-between px-3 py-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
v-if="canAttach"
|
||||
type="button"
|
||||
:aria-label="t('agent.attach')"
|
||||
class="rounded-agent text-agent-fg-muted hover:bg-agent-surface-hover hover:text-agent-fg flex size-8 cursor-pointer items-center justify-center transition-colors"
|
||||
@click="emit('attach')"
|
||||
>
|
||||
<span class="icon-[lucide--paperclip] size-4" />
|
||||
</button>
|
||||
<DropdownMenuRoot @update:open="onMentionOpen">
|
||||
<DropdownMenuTrigger
|
||||
:aria-label="t('agent.mention')"
|
||||
class="rounded-agent text-agent-fg-muted hover:bg-agent-surface-hover hover:text-agent-fg flex size-8 cursor-pointer items-center justify-center transition-colors"
|
||||
>
|
||||
<span class="icon-[lucide--at-sign] size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="start"
|
||||
:side-offset="4"
|
||||
class="rounded-agent border-agent-border bg-agent-surface-raised z-1100 max-h-64 w-64 overflow-y-auto border p-1 shadow-lg"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
v-for="node in mentionNodes"
|
||||
:key="node.id"
|
||||
class="text-agent-fg data-highlighted:bg-agent-surface-hover rounded-agent flex cursor-pointer items-center gap-1.5 px-2 py-1.5 text-xs outline-none"
|
||||
@select="emit('mentionPick', node)"
|
||||
>
|
||||
<span class="truncate">{{ node.title }}</span>
|
||||
<span class="text-agent-fg-subtle ml-auto shrink-0">
|
||||
#{{ node.id }}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
v-if="!mentionNodes.length"
|
||||
disabled
|
||||
class="text-agent-fg-subtle px-2 py-1.5 text-xs"
|
||||
>
|
||||
{{ t('agent.noNodesToMention') }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="text-agent-fg-muted hover:bg-agent-surface-hover flex h-8 cursor-pointer items-center gap-1 rounded-sm px-2 text-xs transition-colors"
|
||||
:aria-label="t('agent.modelAuto')"
|
||||
>
|
||||
<span>{{ t('agent.modelAuto') }}</span>
|
||||
<span class="icon-[lucide--chevron-down] size-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="streaming ? t('agent.stop') : t('agent.send')"
|
||||
:disabled="!streaming && !composer.canSend.value"
|
||||
:class="
|
||||
cn(
|
||||
'flex size-8 items-center justify-center rounded-xl transition-colors',
|
||||
streaming
|
||||
? 'bg-agent-surface-hover text-agent-fg hover:bg-agent-border cursor-pointer'
|
||||
: 'bg-agent-fg text-agent-surface hover:bg-agent-fg/90 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50'
|
||||
)
|
||||
"
|
||||
@click="composer.submit"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'size-4',
|
||||
submitting
|
||||
? 'icon-[lucide--loader-circle] animate-spin'
|
||||
: streaming
|
||||
? 'icon-[lucide--square]'
|
||||
: 'icon-[lucide--arrow-up]'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,123 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { defineComponent } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type * as VueUse from '@vueuse/core'
|
||||
|
||||
const intersectionCallbacks = vi.hoisted(
|
||||
() => [] as ((entries: { isIntersecting: boolean }[]) => void)[]
|
||||
)
|
||||
vi.mock('@vueuse/core', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof VueUse>()),
|
||||
useIntersectionObserver: (
|
||||
_target: unknown,
|
||||
callback: (entries: { isIntersecting: boolean }[]) => void
|
||||
) => {
|
||||
intersectionCallbacks.push(callback)
|
||||
return { stop: () => {} }
|
||||
}
|
||||
}))
|
||||
|
||||
import { i18n } from '@/i18n'
|
||||
import type { TurnId } from '../../schemas/agentApiSchema'
|
||||
import { zAgentWsEvent } from '../../schemas/agentApiSchema'
|
||||
import type { AgentChatEvent } from '../../services/agent/agentEventTransport'
|
||||
import type { AssistantMessage } from '../../services/agent/agentMessageParts'
|
||||
import { useAgentConversationStore } from '../../stores/agent/agentConversationStore'
|
||||
|
||||
import ConversationView from './ConversationView.vue'
|
||||
|
||||
const T = 'msg-1' as TurnId
|
||||
const chat = (raw: unknown): AgentChatEvent =>
|
||||
zAgentWsEvent.parse(raw) as AgentChatEvent
|
||||
const thinking = (id: string, delta: string) =>
|
||||
chat({
|
||||
type: 'agent_thinking',
|
||||
data: { delta, message_id: id, thread_id: 'th' }
|
||||
})
|
||||
const delta = (id: string, text: string) =>
|
||||
chat({
|
||||
type: 'agent_message_delta',
|
||||
data: { delta: text, message_id: id, thread_id: 'th' }
|
||||
})
|
||||
const toolCall = (id: string, name: string, status: string) =>
|
||||
chat({
|
||||
type: 'agent_tool_call',
|
||||
data: { tool_name: name, status, args: [], message_id: id, thread_id: 'th' }
|
||||
})
|
||||
const done = (id: string) =>
|
||||
chat({
|
||||
type: 'agent_message_done',
|
||||
data: { message_id: id, thread_id: 'th', usage: null }
|
||||
})
|
||||
|
||||
const Harness = defineComponent({
|
||||
components: { ConversationView },
|
||||
setup() {
|
||||
const store = useAgentConversationStore()
|
||||
return { store }
|
||||
},
|
||||
template: `<ConversationView :entries="store.entries" user-name="Ada" />`
|
||||
})
|
||||
|
||||
function mountHarness() {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const utils = render(Harness, { global: { plugins: [pinia, i18n] } })
|
||||
return { store: useAgentConversationStore(), ...utils }
|
||||
}
|
||||
|
||||
describe('ConversationView', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
intersectionCallbacks.length = 0
|
||||
})
|
||||
|
||||
it('wire-driven v1 turn renders user pill, spinner, reasoning-free text, tool group', async () => {
|
||||
const { store } = mountHarness()
|
||||
store.recordUser(T, 'make a cat')
|
||||
store.startTurn(T)
|
||||
|
||||
store.ingest(thinking('msg-1', 'pondering'))
|
||||
expect(await screen.findByText('Thinking...')).toBeInTheDocument()
|
||||
|
||||
store.ingest(delta('msg-1', 'Here is a **cat**'))
|
||||
store.ingest(toolCall('msg-1', 'add_node', 'ok'))
|
||||
store.ingest(done('msg-1'))
|
||||
|
||||
expect(await screen.findByText('make a cat')).toBeInTheDocument()
|
||||
expect(screen.getByText('Ran 1 tool call')).toBeInTheDocument()
|
||||
expect(screen.getByText('cat', { selector: 'strong' })).toBeInTheDocument()
|
||||
expect(store.entries.at(-1)).toMatchObject({
|
||||
role: 'assistant',
|
||||
streaming: false
|
||||
})
|
||||
})
|
||||
|
||||
it('shows a scroll-to-latest pill when scrolled up and returns to bottom on click', async () => {
|
||||
const assistant: AssistantMessage = {
|
||||
id: 'msg-1' as TurnId,
|
||||
role: 'assistant',
|
||||
parts: [{ type: 'text', text: 'hello', state: 'done' }],
|
||||
streaming: false,
|
||||
thinking: false
|
||||
}
|
||||
const scrollIntoView = vi.fn()
|
||||
Element.prototype.scrollIntoView = scrollIntoView
|
||||
|
||||
render(ConversationView, {
|
||||
props: { entries: [assistant] },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
|
||||
expect(screen.queryByText('Latest')).not.toBeInTheDocument()
|
||||
|
||||
for (const cb of intersectionCallbacks) cb([{ isIntersecting: false }])
|
||||
const pill = await screen.findByRole('button', { name: 'Latest' })
|
||||
|
||||
await userEvent.click(pill)
|
||||
expect(scrollIntoView).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { useIntersectionObserver } from '@vueuse/core'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { ConversationEntry } from '../../stores/agent/agentConversationStore'
|
||||
|
||||
import AgentMessage from './message/AgentMessage.vue'
|
||||
import UserMessage from './message/UserMessage.vue'
|
||||
|
||||
const { entries } = defineProps<{
|
||||
entries: ConversationEntry[]
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
feedback: [turnId: string, vote: 'up' | 'down' | null]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const bottom = ref<HTMLElement>()
|
||||
const atBottom = ref(true)
|
||||
|
||||
useIntersectionObserver(bottom, ([entry]) => {
|
||||
atBottom.value = entry?.isIntersecting ?? true
|
||||
})
|
||||
|
||||
function scrollToLatest(): void {
|
||||
bottom.value?.scrollIntoView({ block: 'end' })
|
||||
}
|
||||
|
||||
const latestContentSignal = computed(() => {
|
||||
const last = entries.at(-1)
|
||||
if (!last) return '0'
|
||||
const size = 'parts' in last ? JSON.stringify(last.parts).length : 0
|
||||
return `${entries.length}:${size}`
|
||||
})
|
||||
|
||||
watch(
|
||||
latestContentSignal,
|
||||
async () => {
|
||||
if (!atBottom.value) return
|
||||
await nextTick()
|
||||
scrollToLatest()
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative h-full">
|
||||
<div class="h-full overflow-y-auto">
|
||||
<div class="mx-auto max-w-[640px] p-4">
|
||||
<div class="flex flex-col gap-4">
|
||||
<template v-for="entry in entries" :key="`${entry.role}-${entry.id}`">
|
||||
<UserMessage
|
||||
v-if="entry.role === 'user'"
|
||||
:text="entry.text"
|
||||
:attachments="entry.attachments"
|
||||
:tags="entry.tags"
|
||||
/>
|
||||
<AgentMessage
|
||||
v-else
|
||||
:message="entry"
|
||||
@feedback="emit('feedback', entry.id, $event)"
|
||||
/>
|
||||
</template>
|
||||
<div ref="bottom" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="!atBottom"
|
||||
type="button"
|
||||
class="rounded-agent border-agent-border bg-agent-surface-raised text-agent-fg-muted hover:text-agent-fg absolute bottom-3 left-1/2 flex -translate-x-1/2 items-center gap-1 border px-3 py-1.5 text-xs shadow-md transition-colors"
|
||||
@click="scrollToLatest"
|
||||
>
|
||||
<span class="icon-[lucide--arrow-down] size-3.5" />
|
||||
{{ t('agent.latest') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { userName } = defineProps<{ userName?: string }>()
|
||||
const emit = defineEmits<{ insert: [text: string] }>()
|
||||
|
||||
const { t, tm } = useI18n()
|
||||
|
||||
const prompts = computed(() => tm('agent.suggestedPrompts') as string[])
|
||||
|
||||
const promptIcons = [
|
||||
'icon-[lucide--lightbulb]',
|
||||
'icon-[lucide--list]',
|
||||
'icon-[lucide--search]',
|
||||
'icon-[lucide--message-circle-warning]',
|
||||
'icon-[lucide--workflow]'
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col p-4">
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col items-center justify-center gap-4 p-6 text-center"
|
||||
>
|
||||
<div
|
||||
class="mb-2 flex size-12 items-center justify-center rounded-xl border border-plum-600 bg-ink-700"
|
||||
>
|
||||
<span
|
||||
class="icon-[comfy--comfy-c] size-6 text-brand-yellow drop-shadow-[0_0_12px_currentColor]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="text-agent-fg flex max-w-sm flex-col items-center gap-2 text-base/snug font-semibold tracking-tight @min-[570px]:text-2xl/snug"
|
||||
>
|
||||
<p class="my-0">
|
||||
{{ t('agent.greeting', { name: userName ?? t('agent.friend') }) }}
|
||||
</p>
|
||||
<p class="my-0">
|
||||
{{ t('agent.greetingQuestion') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap gap-2 @min-[460px]:justify-center">
|
||||
<button
|
||||
v-for="(prompt, index) in prompts"
|
||||
:key="index"
|
||||
type="button"
|
||||
class="bg-agent-surface-raised text-agent-fg hover:bg-agent-surface-hover focus-visible:ring-agent-accent flex h-8 w-full cursor-pointer items-center justify-start gap-2 rounded-full px-3 text-sm whitespace-nowrap transition-colors focus-visible:ring-2 focus-visible:outline-none @min-[460px]:w-auto"
|
||||
@click="emit('insert', prompt)"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'text-agent-fg-muted size-3 shrink-0',
|
||||
promptIcons[index] ?? 'icon-[lucide--sparkles]'
|
||||
)
|
||||
"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="truncate">{{ prompt }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { onKeyStroke, useWindowSize } from '@vueuse/core'
|
||||
import { ref, watchEffect } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { CoachStep } from '../../composables/agent/useOnboarding'
|
||||
import { useOnboarding } from '../../composables/agent/useOnboarding'
|
||||
|
||||
const { step, storageKey } = defineProps<{
|
||||
step: CoachStep
|
||||
storageKey?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { active, finish } = useOnboarding(storageKey)
|
||||
|
||||
onKeyStroke('Escape', () => {
|
||||
if (active.value) finish()
|
||||
})
|
||||
|
||||
const { width, height } = useWindowSize()
|
||||
const CARD_W = 256
|
||||
const CARD_H = 160
|
||||
const MARGIN = 8
|
||||
|
||||
const cardStyle = ref<Record<string, string> | null>(null)
|
||||
|
||||
watchEffect(
|
||||
() => {
|
||||
cardStyle.value = null
|
||||
if (!active.value) return
|
||||
const target = document.querySelector(step.target)
|
||||
if (!target) return
|
||||
const rect = target.getBoundingClientRect()
|
||||
const left = Math.min(
|
||||
Math.max(MARGIN, rect.left),
|
||||
Math.max(MARGIN, width.value - CARD_W - MARGIN)
|
||||
)
|
||||
const top = Math.min(
|
||||
Math.max(MARGIN, rect.bottom + MARGIN),
|
||||
Math.max(MARGIN, height.value - CARD_H - MARGIN)
|
||||
)
|
||||
cardStyle.value = { top: `${top}px`, left: `${left}px` }
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="active && cardStyle"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="step.title"
|
||||
class="fixed inset-0 z-50"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/40" />
|
||||
<div
|
||||
:style="cardStyle"
|
||||
class="rounded-agent border-agent-border bg-agent-surface-raised text-agent-fg absolute w-64 border p-3 shadow-xl"
|
||||
>
|
||||
<p class="text-sm font-semibold">{{ step.title }}</p>
|
||||
<p class="text-agent-fg-muted mt-1 text-xs">{{ step.body }}</p>
|
||||
<div class="mt-3 flex justify-end gap-2">
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="md"
|
||||
class="hover:text-agent-fg focus-visible:ring-agent-accent rounded-xl px-3 text-sm focus-visible:ring-2"
|
||||
@click="finish"
|
||||
>{{ t('agent.skip') }}</Button
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
class="text-agent-accent-fg hover:bg-agent-accent/90 focus-visible:ring-agent-accent rounded-xl px-3 text-sm focus-visible:ring-2"
|
||||
@click="finish"
|
||||
>
|
||||
{{ t('agent.gotIt') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { ActiveTab } from './ActiveTabStrip.vue'
|
||||
import ActiveTabStrip from './ActiveTabStrip.vue'
|
||||
|
||||
const { isMaximized = false, activeTab = null } = defineProps<{
|
||||
isMaximized?: boolean
|
||||
activeTab?: ActiveTab | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
newChat: []
|
||||
toggleSize: []
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const sizeToggleIcon = computed(() =>
|
||||
isMaximized ? 'icon-[lucide--minimize-2]' : 'icon-[lucide--maximize-2]'
|
||||
)
|
||||
const sizeToggleLabel = computed(() =>
|
||||
isMaximized ? t('agent.minimize') : t('agent.maximize')
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header
|
||||
class="border-agent-border flex h-12 shrink-0 items-center gap-2 border-b px-4"
|
||||
>
|
||||
<h1 class="text-agent-fg my-0 text-sm font-normal whitespace-nowrap">
|
||||
{{ t('agent.title') }}
|
||||
</h1>
|
||||
<span
|
||||
class="border-agent-border-strong text-agent-fg-muted shrink-0 rounded-full border px-2 py-0.5 text-xs"
|
||||
>
|
||||
{{ t('agent.alpha') }}
|
||||
</span>
|
||||
|
||||
<ActiveTabStrip :tab="activeTab" />
|
||||
|
||||
<div class="ml-auto flex items-center gap-1">
|
||||
<Button
|
||||
v-tooltip.bottom="{ value: t('agent.newChat'), showDelay: 300 }"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
class="hover:text-agent-fg focus-visible:ring-agent-accent rounded-xl focus-visible:ring-2"
|
||||
:aria-label="t('agent.newChat')"
|
||||
@click="emit('newChat')"
|
||||
>
|
||||
<span class="icon-[lucide--message-circle-plus] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.bottom="{ value: sizeToggleLabel, showDelay: 300 }"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
class="hover:text-agent-fg focus-visible:ring-agent-accent rounded-xl focus-visible:ring-2"
|
||||
:aria-label="sizeToggleLabel"
|
||||
@click="emit('toggleSize')"
|
||||
>
|
||||
<span :class="cn(sizeToggleIcon, 'size-4')" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.bottom="{ value: t('agent.close'), showDelay: 300 }"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
class="hover:text-agent-fg focus-visible:ring-agent-accent rounded-xl focus-visible:ring-2"
|
||||
:aria-label="t('agent.close')"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<span class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const {
|
||||
name,
|
||||
previewUrl,
|
||||
uploading = false
|
||||
} = defineProps<{
|
||||
name: string
|
||||
previewUrl?: string
|
||||
uploading?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{ remove: [] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="rounded-agent bg-agent-pill text-agent-fg inline-flex items-center gap-1.5 py-1 pr-2 pl-1 text-xs"
|
||||
>
|
||||
<span
|
||||
v-if="uploading"
|
||||
:aria-label="t('agent.uploading')"
|
||||
class="text-agent-fg-subtle icon-[lucide--loader-circle] size-4 animate-spin"
|
||||
/>
|
||||
<img
|
||||
v-else-if="previewUrl"
|
||||
:src="previewUrl"
|
||||
:alt="name"
|
||||
class="size-5 rounded-sm object-cover"
|
||||
/>
|
||||
<span v-else class="text-agent-fg-subtle icon-[lucide--paperclip] size-4" />
|
||||
<span class="max-w-32 truncate">{{ name }}</span>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('agent.remove')"
|
||||
class="text-agent-fg-muted hover:bg-agent-surface-hover hover:text-agent-fg -my-1 -mr-1 flex size-5 cursor-pointer items-center justify-center rounded-full transition-colors"
|
||||
@click="emit('remove')"
|
||||
>
|
||||
<span class="icon-[lucide--x] size-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type {
|
||||
AssistantMessage,
|
||||
NoticePart,
|
||||
TextPart,
|
||||
ToolPart
|
||||
} from '../../../services/agent/agentMessageParts'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import MarkdownStream from './MarkdownStream.vue'
|
||||
import MessageFeedback from './MessageFeedback.vue'
|
||||
import ToolCallGroup from './ToolCallGroup.vue'
|
||||
|
||||
const { message } = defineProps<{ message: AssistantMessage }>()
|
||||
const emit = defineEmits<{ feedback: [vote: 'up' | 'down' | null] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
type Group =
|
||||
| { kind: 'text'; part: TextPart }
|
||||
| { kind: 'notice'; part: NoticePart }
|
||||
| { kind: 'tools'; parts: ToolPart[] }
|
||||
|
||||
const groups = computed<Group[]>(() => {
|
||||
const out: Group[] = []
|
||||
for (const part of message.parts) {
|
||||
const prev = out.at(-1)
|
||||
if (part.type === 'tool') {
|
||||
if (prev?.kind === 'tools') prev.parts.push(part)
|
||||
else out.push({ kind: 'tools', parts: [part] })
|
||||
} else if (part.type === 'text') {
|
||||
out.push({ kind: 'text', part })
|
||||
} else {
|
||||
out.push({ kind: 'notice', part })
|
||||
}
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const plainText = computed(() =>
|
||||
message.parts
|
||||
.filter((part): part is TextPart => part.type === 'text')
|
||||
.map((part) => part.text)
|
||||
.join('\n\n')
|
||||
)
|
||||
|
||||
const showActions = computed(
|
||||
() => !message.streaming && plainText.value.length > 0
|
||||
)
|
||||
|
||||
const raw = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-1.5">
|
||||
<div
|
||||
v-if="message.thinking || (message.streaming && !message.parts.length)"
|
||||
class="text-agent-fg-muted flex items-center gap-1.5 py-1 text-sm"
|
||||
>
|
||||
<span class="icon-[lucide--brain] size-3.5 shrink-0" />
|
||||
<span class="agent-shimmer-text">{{ t('agent.thinking') }}</span>
|
||||
</div>
|
||||
|
||||
<template v-for="(group, index) in groups" :key="index">
|
||||
<MarkdownStream
|
||||
v-if="group.kind === 'text'"
|
||||
:text="group.part.text"
|
||||
:raw="raw"
|
||||
/>
|
||||
<ToolCallGroup v-else-if="group.kind === 'tools'" :tools="group.parts" />
|
||||
<div
|
||||
v-else
|
||||
:class="
|
||||
cn(
|
||||
'rounded-agent flex items-start gap-2 border px-3 py-2 text-sm',
|
||||
group.part.level === 'error'
|
||||
? 'border-agent-danger/40 text-agent-danger'
|
||||
: 'border-agent-border text-agent-fg-muted'
|
||||
)
|
||||
"
|
||||
>
|
||||
<span class="mt-0.5 icon-[lucide--triangle-alert] size-4 shrink-0" />
|
||||
<span>{{ group.part.text }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="showActions" class="flex items-center gap-1">
|
||||
<MessageFeedback :text="plainText" @feedback="emit('feedback', $event)" />
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('agent.toggleRaw')"
|
||||
:aria-pressed="raw"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-agent text-agent-fg-subtle hover:bg-agent-surface-hover hover:text-agent-fg flex size-7 items-center justify-center transition-colors',
|
||||
raw && 'text-agent-fg'
|
||||
)
|
||||
"
|
||||
@click="raw = !raw"
|
||||
>
|
||||
<span class="icon-[lucide--code] size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { code, lang = 'text' } = defineProps<{
|
||||
code: string
|
||||
lang?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { copy, copied } = useClipboard({ copiedDuring: 2000, legacy: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="group border-agent-border-strong relative my-2 overflow-hidden rounded-md border"
|
||||
>
|
||||
<div
|
||||
class="border-agent-border-strong bg-agent-surface-hover flex items-center justify-between border-b px-3 py-1.5"
|
||||
>
|
||||
<span
|
||||
class="text-agent-fg-subtle flex items-center gap-1.5 font-mono text-xs"
|
||||
>
|
||||
<span class="icon-[lucide--file-code] size-3.5" />
|
||||
<span class="text-agent-fg font-medium">{{ lang }}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-agent-fg-subtle hover:bg-agent-surface hover:text-agent-fg border-agent-border-strong flex items-center gap-1 rounded-sm border px-2 py-0.5 font-mono text-xs transition-colors"
|
||||
@click="copy(code)"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'size-3.5',
|
||||
copied ? 'icon-[lucide--check]' : 'icon-[lucide--copy]'
|
||||
)
|
||||
"
|
||||
/>
|
||||
{{ copied ? t('agent.copied') : t('agent.copy') }}
|
||||
</button>
|
||||
</div>
|
||||
<pre
|
||||
class="text-agent-fg overflow-x-auto p-4 font-mono text-sm"
|
||||
><code>{{ code }}</code></pre>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,99 @@
|
||||
// @vitest-environment jsdom
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { i18n } from '@/i18n'
|
||||
|
||||
import MarkdownStream from './MarkdownStream.vue'
|
||||
|
||||
describe('MarkdownStream', () => {
|
||||
it('renders markdown prose', () => {
|
||||
render(MarkdownStream, { props: { text: '**bold**' } })
|
||||
expect(screen.getByText('bold', { selector: 'strong' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('strips a script tag (XSS guard)', () => {
|
||||
const { html } = render(MarkdownStream, {
|
||||
props: { text: 'hi <script>alert(1)</script> there' }
|
||||
})
|
||||
expect(html()).not.toContain('<script')
|
||||
expect(screen.getByText(/hi/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens links in a new tab with rel=noopener', () => {
|
||||
render(MarkdownStream, { props: { text: '[docs](https://example.com)' } })
|
||||
const link = screen.getByRole('link', { name: 'docs' })
|
||||
expect(link).toHaveAttribute('target', '_blank')
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
|
||||
it('drops a javascript: url', () => {
|
||||
const { html } = render(MarkdownStream, {
|
||||
props: { text: '[x](javascript:alert(1))' }
|
||||
})
|
||||
expect(html()).not.toContain('javascript:')
|
||||
})
|
||||
|
||||
it('renders a fenced block as a framed code block with its language and a copy button', () => {
|
||||
render(MarkdownStream, {
|
||||
props: { text: 'before\n```python\nprint("hi")\n```\nafter' },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
expect(screen.getByText('python')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('print("hi")', { selector: 'code' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /copy/i })).toBeInTheDocument()
|
||||
expect(screen.getByText('before')).toBeInTheDocument()
|
||||
expect(screen.getByText('after')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles a 4-backtick fence containing a 3-backtick fence', () => {
|
||||
render(MarkdownStream, {
|
||||
props: { text: '````md\n```js\ncode\n```\n````' },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
expect(screen.getByText(/```js/, { selector: 'code' })).toBeInTheDocument()
|
||||
expect(screen.getByText('md')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('leaves an inline triple-backtick span mid-sentence as prose', () => {
|
||||
render(MarkdownStream, {
|
||||
props: { text: 'use ```npm i``` to install' }
|
||||
})
|
||||
expect(screen.getByText('npm i', { selector: 'code' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /copy/i })).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps a 4-space-indented block as prose-rendered code, not a framed block', () => {
|
||||
render(MarkdownStream, {
|
||||
props: { text: 'steps:\n\n npm install\n\ndone' },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
expect(
|
||||
screen.getByText('npm install', { selector: 'code' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /copy/i })).toBeNull()
|
||||
})
|
||||
|
||||
it('labels a fence by the first word of its info string', () => {
|
||||
render(MarkdownStream, {
|
||||
props: { text: '```python title=x\nprint("hi")\n```' },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
expect(screen.getByText('python')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/title=x/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('labels a bare fence with no language as text', () => {
|
||||
render(MarkdownStream, {
|
||||
props: { text: '```\nplain body\n```' },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
expect(screen.getByText('text')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('plain body', { selector: 'code' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /copy/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { marked } from 'marked'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
|
||||
|
||||
import CodeBlock from './CodeBlock.vue'
|
||||
|
||||
const { text, raw = false } = defineProps<{
|
||||
text: string
|
||||
raw?: boolean
|
||||
}>()
|
||||
|
||||
interface ProseSegment {
|
||||
type: 'prose'
|
||||
html: string
|
||||
}
|
||||
interface CodeSegment {
|
||||
type: 'code'
|
||||
code: string
|
||||
lang: string
|
||||
}
|
||||
type Segment = ProseSegment | CodeSegment
|
||||
|
||||
const segments = computed<Segment[]>(() => {
|
||||
const out: Segment[] = []
|
||||
let prose = ''
|
||||
const flushProse = () => {
|
||||
if (!prose) return
|
||||
out.push({ type: 'prose', html: renderMarkdownToHtml(prose) })
|
||||
prose = ''
|
||||
}
|
||||
for (const token of marked.lexer(text)) {
|
||||
if (token.type === 'code' && token.codeBlockStyle !== 'indented') {
|
||||
flushProse()
|
||||
out.push({
|
||||
type: 'code',
|
||||
code: token.text,
|
||||
lang: token.lang?.split(/\s+/)[0] || 'text'
|
||||
})
|
||||
} else {
|
||||
prose += token.raw
|
||||
}
|
||||
}
|
||||
flushProse()
|
||||
return out
|
||||
})
|
||||
|
||||
const proseClass = cn(
|
||||
'text-agent-fg text-sm/relaxed',
|
||||
'[&_a]:text-agent-accent [&_a]:cursor-pointer [&_a]:underline',
|
||||
'[&_p]:my-0 [&_p]:pt-1 [&_strong]:font-semibold',
|
||||
'[&_h1]:mt-0 [&_h1]:pt-4 [&_h1]:pb-2 [&_h1]:text-2xl [&_h1]:font-semibold',
|
||||
'[&_h2]:pt-3.5 [&_h2]:pb-1.5 [&_h2]:text-base [&_h2]:font-semibold [&_h3]:pt-2 [&_h3]:font-semibold',
|
||||
'[&_ol]:my-0 [&_ol]:list-decimal [&_ol]:pt-1 [&_ol]:pb-2 [&_ol]:pl-5',
|
||||
'[&_ul]:my-0 [&_ul]:list-disc [&_ul]:pt-1 [&_ul]:pb-2 [&_ul]:pl-5',
|
||||
'[&_:not(pre)>code]:bg-agent-surface-hover [&_:not(pre)>code]:border-agent-border-strong [&_:not(pre)>code]:rounded-sm [&_:not(pre)>code]:border [&_:not(pre)>code]:px-1.5 [&_:not(pre)>code]:py-0.5 [&_:not(pre)>code]:text-[0.875em]',
|
||||
'[&_blockquote]:border-agent-border-strong [&_blockquote]:text-agent-fg-muted [&_blockquote]:my-2 [&_blockquote]:border-l-[3px] [&_blockquote]:py-1.5 [&_blockquote]:pl-3.5',
|
||||
'[&_table]:bg-agent-surface-raised [&_table]:my-2 [&_table]:w-full [&_table]:border-collapse [&_table]:overflow-hidden [&_table]:rounded-lg',
|
||||
'[&_th]:border-agent-border-strong [&_th]:bg-agent-surface-hover [&_th]:border-b [&_th]:px-4 [&_th]:py-2.5 [&_th]:text-left [&_th]:font-semibold',
|
||||
'[&_td]:border-agent-border-strong [&_td]:border-b [&_td]:px-4 [&_td]:py-2.5'
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<pre
|
||||
v-if="raw"
|
||||
class="rounded-agent bg-agent-surface text-agent-fg overflow-x-auto p-3 text-xs whitespace-pre-wrap"
|
||||
>{{ text }}</pre
|
||||
>
|
||||
<div v-else>
|
||||
<template v-for="(segment, index) in segments" :key="index">
|
||||
<CodeBlock
|
||||
v-if="segment.type === 'code'"
|
||||
:code="segment.code"
|
||||
:lang="segment.lang"
|
||||
/>
|
||||
<div v-else :class="proseClass" v-html="segment.html" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { text } = defineProps<{ text: string }>()
|
||||
const emit = defineEmits<{ feedback: [vote: 'up' | 'down' | null] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { copy, copied } = useClipboard({ copiedDuring: 2000, legacy: true })
|
||||
|
||||
const vote = ref<'up' | 'down' | null>(null)
|
||||
|
||||
function setVote(next: 'up' | 'down'): void {
|
||||
vote.value = vote.value === next ? null : next
|
||||
emit('feedback', vote.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-agent-fg-subtle flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('agent.helpful')"
|
||||
:aria-pressed="vote === 'up'"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-agent hover:bg-agent-surface-hover hover:text-agent-fg flex size-6 items-center justify-center transition-colors',
|
||||
vote === 'up' && 'text-agent-accent'
|
||||
)
|
||||
"
|
||||
@click="setVote('up')"
|
||||
>
|
||||
<span class="icon-[lucide--thumbs-up] size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('agent.notHelpful')"
|
||||
:aria-pressed="vote === 'down'"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-agent hover:bg-agent-surface-hover hover:text-agent-fg flex size-6 items-center justify-center transition-colors',
|
||||
vote === 'down' && 'text-agent-danger'
|
||||
)
|
||||
"
|
||||
@click="setVote('down')"
|
||||
>
|
||||
<span class="icon-[lucide--thumbs-down] size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="copied ? t('agent.copied') : t('agent.copy')"
|
||||
class="rounded-agent hover:bg-agent-surface-hover hover:text-agent-fg flex size-6 items-center justify-center transition-colors"
|
||||
@click="copy(text)"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'size-3.5',
|
||||
copied ? 'icon-[lucide--check]' : 'icon-[lucide--copy]'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { PartState } from '../../../services/agent/agentMessageParts'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const {
|
||||
name,
|
||||
state,
|
||||
ok,
|
||||
count = 1
|
||||
} = defineProps<{
|
||||
name: string
|
||||
state: PartState
|
||||
ok?: boolean
|
||||
count?: number
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const FRIENDLY_TOOL_KEYS: Record<string, string> = {
|
||||
new_tab: 'agent.toolOpenedNewTab',
|
||||
switch_tab: 'agent.toolSwitchedTabs',
|
||||
remember: 'agent.toolSavedPreference',
|
||||
forget: 'agent.toolForgotPreference'
|
||||
}
|
||||
|
||||
const friendlyKey = computed(() =>
|
||||
Object.hasOwn(FRIENDLY_TOOL_KEYS, name) ? FRIENDLY_TOOL_KEYS[name] : undefined
|
||||
)
|
||||
|
||||
const label = computed(() =>
|
||||
friendlyKey.value === undefined ? name : t(friendlyKey.value)
|
||||
)
|
||||
|
||||
const glyph = computed(() => {
|
||||
if (state === 'streaming') return 'animate-spin icon-[lucide--loader-circle]'
|
||||
return ok === false
|
||||
? 'icon-[lucide--circle-x]'
|
||||
: 'icon-[lucide--circle-check]'
|
||||
})
|
||||
|
||||
const glyphColor = computed(() => {
|
||||
if (state === 'streaming') return 'text-agent-fg-subtle'
|
||||
return ok === false ? 'text-agent-danger' : 'text-agent-success'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-agent-fg flex items-center gap-2 px-3 py-1.5 text-sm">
|
||||
<span :class="cn('size-4 shrink-0', glyph, glyphColor)" />
|
||||
<span
|
||||
:class="cn('truncate text-xs', friendlyKey === undefined && 'font-mono')"
|
||||
>{{ label }}</span
|
||||
>
|
||||
<span v-if="count > 1" class="text-agent-fg-subtle text-xs"
|
||||
>×{{ count }}</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,118 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { i18n } from '@/i18n'
|
||||
import type { TurnId } from '../../../schemas/agentApiSchema'
|
||||
import type {
|
||||
AssistantMessage,
|
||||
ToolPart
|
||||
} from '../../../services/agent/agentMessageParts'
|
||||
|
||||
import AgentMessage from './AgentMessage.vue'
|
||||
import ToolCallGroup from './ToolCallGroup.vue'
|
||||
|
||||
function tool(
|
||||
callId: string,
|
||||
name: string,
|
||||
state: ToolPart['state'],
|
||||
ok?: boolean
|
||||
): ToolPart {
|
||||
return { type: 'tool', callId, name, state, ok }
|
||||
}
|
||||
|
||||
describe('ToolCallGroup', () => {
|
||||
it('maps tab tools to friendly labels and renders unknown tools by raw name', () => {
|
||||
render(ToolCallGroup, {
|
||||
props: {
|
||||
tools: [
|
||||
tool('c1', 'new_tab', 'done', true),
|
||||
tool('c2', 'switch_tab', 'done', true),
|
||||
tool('c3', 'add_node', 'streaming'),
|
||||
tool('c4', 'constructor', 'done', true),
|
||||
tool('c5', 'remember', 'done', true),
|
||||
tool('c6', 'forget', 'done', true)
|
||||
]
|
||||
},
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
|
||||
expect(screen.getByText('Opened a new tab')).toBeInTheDocument()
|
||||
expect(screen.getByText('Switched tabs')).toBeInTheDocument()
|
||||
expect(screen.getByText('add_node')).toBeInTheDocument()
|
||||
expect(screen.getByText('constructor')).toBeInTheDocument()
|
||||
expect(screen.getByText('Saved a preference')).toBeInTheDocument()
|
||||
expect(screen.getByText('Forgot a preference')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders open with the row visible while a call streams', () => {
|
||||
render(ToolCallGroup, {
|
||||
props: { tools: [tool('c1', 'add_node', 'streaming')] },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
|
||||
expect(screen.getByText('Ran 1 tool call')).toBeInTheDocument()
|
||||
expect(screen.getByText('add_node')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('stays open and folds a same-name re-run into the counted row', () => {
|
||||
render(ToolCallGroup, {
|
||||
props: {
|
||||
tools: [
|
||||
tool('c1', 'add_node', 'done', true),
|
||||
tool('c2', 'add_node', 'streaming')
|
||||
]
|
||||
},
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
|
||||
expect(screen.getByText('Ran 2 tool calls')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('add_node')).toHaveLength(1)
|
||||
expect(screen.getByText('×2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reopens on a failure and folds it into the counted row', async () => {
|
||||
const { rerender } = render(ToolCallGroup, {
|
||||
props: { tools: [tool('c1', 'add_node', 'done', true)] },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
|
||||
expect(screen.queryByText('add_node')).not.toBeInTheDocument()
|
||||
|
||||
await rerender({
|
||||
tools: [
|
||||
tool('c1', 'add_node', 'done', true),
|
||||
tool('c2', 'add_node', 'done', false)
|
||||
]
|
||||
})
|
||||
|
||||
expect(await screen.findByText('add_node')).toBeInTheDocument()
|
||||
expect(screen.getByText('×2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentMessage tool grouping', () => {
|
||||
it('groups adjacent tool parts into one pluralized card that opens on click', async () => {
|
||||
const message: AssistantMessage = {
|
||||
id: 'msg-1' as TurnId,
|
||||
role: 'assistant',
|
||||
parts: [
|
||||
tool('c1', 'add_node', 'done', true),
|
||||
tool('c2', 'add_node', 'done', true)
|
||||
],
|
||||
streaming: false,
|
||||
thinking: false
|
||||
}
|
||||
render(AgentMessage, { props: { message }, global: { plugins: [i18n] } })
|
||||
|
||||
expect(screen.getByText('Ran 2 tool calls')).toBeInTheDocument()
|
||||
expect(screen.queryByText('add_node')).not.toBeInTheDocument()
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: /ran 2 tool calls/i })
|
||||
)
|
||||
|
||||
expect(await screen.findAllByText('add_node')).toHaveLength(1)
|
||||
expect(screen.getByText('×2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CollapsibleContent,
|
||||
CollapsibleRoot,
|
||||
CollapsibleTrigger
|
||||
} from 'reka-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { ToolPart } from '../../../services/agent/agentMessageParts'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import ToolCallCard from './ToolCallCard.vue'
|
||||
|
||||
const { tools } = defineProps<{ tools: ToolPart[] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Row {
|
||||
name: string
|
||||
state: ToolPart['state']
|
||||
ok?: boolean
|
||||
count: number
|
||||
}
|
||||
const rows = computed<Row[]>(() => {
|
||||
const out: Row[] = []
|
||||
for (const tool of tools) {
|
||||
const prev = out.at(-1)
|
||||
if (prev && prev.name === tool.name) {
|
||||
prev.count += 1
|
||||
if (tool.state === 'streaming') prev.state = 'streaming'
|
||||
if (tool.ok === false) prev.ok = false
|
||||
} else {
|
||||
out.push({
|
||||
name: tool.name,
|
||||
state: tool.state,
|
||||
ok: tool.ok,
|
||||
count: 1
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const running = computed(() => tools.some((tool) => tool.state === 'streaming'))
|
||||
const failed = computed(() =>
|
||||
tools.some((tool) => tool.state === 'done' && tool.ok === false)
|
||||
)
|
||||
|
||||
const open = ref(true)
|
||||
watch(
|
||||
() => running.value || failed.value,
|
||||
(stayOpen) => {
|
||||
open.value = stayOpen
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CollapsibleRoot v-model:open="open">
|
||||
<CollapsibleTrigger
|
||||
class="group text-agent-fg-muted hover:bg-agent-surface-hover hover:text-agent-fg flex h-8 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-sm transition-colors"
|
||||
>
|
||||
<span
|
||||
v-if="running"
|
||||
class="text-agent-fg-subtle icon-[lucide--loader-circle] size-4 shrink-0 animate-spin"
|
||||
/>
|
||||
<span
|
||||
v-else-if="failed"
|
||||
class="text-agent-danger icon-[lucide--circle-x] size-4 shrink-0"
|
||||
/>
|
||||
<span v-else class="icon-[lucide--wrench] size-4 shrink-0" />
|
||||
<span class="flex-1 text-left">{{
|
||||
t('agent.ranToolCalls', tools.length)
|
||||
}}</span>
|
||||
<span
|
||||
class="icon-[lucide--chevron-down] size-4 shrink-0 transition-transform group-data-[state=open]:rotate-180"
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent
|
||||
class="data-[state=closed]:animate-agent-collapsible-up data-[state=open]:animate-agent-collapsible-down overflow-hidden"
|
||||
>
|
||||
<div :class="cn('flex flex-col gap-0.5 pt-1 pl-2')">
|
||||
<ToolCallCard
|
||||
v-for="(row, index) in rows"
|
||||
:key="index"
|
||||
:name="row.name"
|
||||
:state="row.state"
|
||||
:ok="row.ok"
|
||||
:count="row.count"
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</CollapsibleRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import UserMessage from './UserMessage.vue'
|
||||
|
||||
describe('UserMessage', () => {
|
||||
it('renders a caption-only placeholder tile for a preview-less attachment', () => {
|
||||
render(UserMessage, {
|
||||
props: { text: '', attachments: [{ name: 'clip.bin' }] }
|
||||
})
|
||||
|
||||
expect(screen.getByText('clip.bin')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders thumbnails for previewable attachments above the text pill', () => {
|
||||
render(UserMessage, {
|
||||
props: {
|
||||
text: 'use these',
|
||||
attachments: [
|
||||
{ name: 'a.png', previewUrl: 'blob:a' },
|
||||
{ name: 'b.png', previewUrl: 'blob:b' }
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByAltText('a.png')).toBeInTheDocument()
|
||||
expect(screen.getByAltText('b.png')).toBeInTheDocument()
|
||||
expect(screen.getByText('use these')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import type { UserAttachment } from '../../../stores/agent/agentConversationStore'
|
||||
|
||||
const {
|
||||
text,
|
||||
attachments = [],
|
||||
tags = []
|
||||
} = defineProps<{
|
||||
text: string
|
||||
attachments?: UserAttachment[]
|
||||
tags?: string[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-end gap-1.5">
|
||||
<div v-if="tags.length" class="flex flex-wrap justify-end gap-1">
|
||||
<span
|
||||
v-for="(tag, index) in tags"
|
||||
:key="`${tag}:${index}`"
|
||||
class="rounded-agent bg-agent-pill text-agent-fg-muted inline-flex items-center gap-1 px-1.5 py-0.5 text-xs"
|
||||
>
|
||||
<span class="icon-[lucide--at-sign] size-3" />
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="attachments.length"
|
||||
class="grid w-56 max-w-full grid-cols-2 gap-1.5"
|
||||
>
|
||||
<figure
|
||||
v-for="(item, index) in attachments"
|
||||
:key="`${item.name}:${index}`"
|
||||
class="m-0"
|
||||
>
|
||||
<img
|
||||
v-if="item.previewUrl"
|
||||
:src="item.previewUrl"
|
||||
:alt="item.name"
|
||||
class="aspect-square w-full rounded-lg object-cover"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="bg-agent-surface-raised flex aspect-square w-full items-center justify-center rounded-lg"
|
||||
>
|
||||
<span class="text-agent-fg-subtle icon-[lucide--image] size-6" />
|
||||
</div>
|
||||
<figcaption class="text-agent-fg-muted mt-0.5 truncate text-xs">
|
||||
{{ item.name }}
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
<div
|
||||
v-if="text"
|
||||
class="bg-agent-surface-raised text-agent-fg w-fit max-w-full rounded-lg px-4 py-3 text-xs whitespace-pre-wrap"
|
||||
>
|
||||
{{ text }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogRoot,
|
||||
DialogTitle,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuTrigger
|
||||
} from 'reka-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
export type ConflictChoice = 'agent' | 'mine' | 'newtab' | 'cancel'
|
||||
|
||||
const { open } = defineProps<{ open: boolean }>()
|
||||
const emit = defineEmits<{ resolve: [choice: ConflictChoice] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function choose(choice: ConflictChoice): void {
|
||||
emit('resolve', choice)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot :open="open">
|
||||
<DialogPortal>
|
||||
<DialogOverlay class="fixed inset-0 z-50 bg-black/60" />
|
||||
<DialogContent
|
||||
class="agent-scope rounded-agent border-agent-border bg-agent-surface-raised text-agent-fg fixed top-1/2 left-1/2 z-50 w-full max-w-md -translate-1/2 space-y-3 border p-5 shadow-xl focus:outline-none"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<DialogTitle class="text-agent-fg my-0 text-sm font-semibold">
|
||||
{{ t('agent.conflictTitle') }}
|
||||
</DialogTitle>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('g.close')"
|
||||
class="text-agent-fg-subtle hover:text-agent-fg flex size-5 cursor-pointer items-center justify-center"
|
||||
@click="choose('cancel')"
|
||||
>
|
||||
<span class="icon-[lucide--x] size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<DialogDescription class="text-agent-fg-muted my-0 text-xs">
|
||||
{{ t('agent.conflictBody') }}
|
||||
</DialogDescription>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="md"
|
||||
class="hover:text-agent-fg focus-visible:ring-agent-accent rounded-xl px-3 text-sm focus-visible:ring-2"
|
||||
@click="choose('cancel')"
|
||||
>
|
||||
{{ t('g.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="md"
|
||||
class="border-agent-border focus-visible:ring-agent-accent rounded-xl border border-solid px-3 text-sm focus-visible:ring-2"
|
||||
@click="choose('mine')"
|
||||
>
|
||||
{{ t('agent.keepMine') }}
|
||||
</Button>
|
||||
<div class="flex">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
class="text-agent-accent-fg hover:bg-agent-accent/90 focus-visible:ring-agent-accent rounded-l-xl rounded-r-none px-3 text-sm focus-visible:ring-2"
|
||||
@click="choose('agent')"
|
||||
>
|
||||
{{ t('agent.acceptAgent') }}
|
||||
</Button>
|
||||
<DropdownMenuRoot>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
class="border-agent-surface/30 text-agent-accent-fg hover:bg-agent-accent/90 focus-visible:ring-agent-accent w-6 rounded-l-none rounded-r-xl border-l border-solid px-0 text-sm focus-visible:ring-2"
|
||||
:aria-label="t('agent.moreApplyOptions')"
|
||||
>
|
||||
<span class="icon-[lucide--chevron-down] size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
align="end"
|
||||
:side-offset="4"
|
||||
class="rounded-agent border-agent-border bg-agent-surface-raised z-1100 border p-1 shadow-lg"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
class="text-agent-fg data-highlighted:bg-agent-surface-hover rounded-agent cursor-pointer px-2 py-1.5 text-xs outline-none"
|
||||
@select="choose('newtab')"
|
||||
>
|
||||
{{ t('agent.openNewTab') }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
AGENT_PANEL_FLAG,
|
||||
createPostHogFlagSource
|
||||
} from './useAgentFeatureGate'
|
||||
import type { PostHogLike } from './useAgentFeatureGate'
|
||||
|
||||
function fakePostHog(initial: boolean | undefined): {
|
||||
posthog: PostHogLike
|
||||
setFlag: (value: boolean | undefined) => void
|
||||
} {
|
||||
let value = initial
|
||||
let listener: (() => void) | undefined
|
||||
return {
|
||||
posthog: {
|
||||
isFeatureEnabled: () => value,
|
||||
onFeatureFlags: (fn) => {
|
||||
listener = fn
|
||||
return () => {
|
||||
listener = undefined
|
||||
}
|
||||
}
|
||||
},
|
||||
setFlag: (next) => {
|
||||
value = next
|
||||
listener?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('createPostHogFlagSource', () => {
|
||||
it('maps an unloaded posthog flag (undefined) to false (fail closed)', () => {
|
||||
const { posthog } = fakePostHog(undefined)
|
||||
expect(createPostHogFlagSource(posthog).isEnabled()).toBe(false)
|
||||
})
|
||||
|
||||
it('notifies onChange listeners and reflects flag flips', () => {
|
||||
const { posthog, setFlag } = fakePostHog(undefined)
|
||||
const source = createPostHogFlagSource(posthog)
|
||||
const onChange = vi.fn()
|
||||
source.onChange?.(onChange)
|
||||
|
||||
setFlag(true)
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
expect(source.isEnabled()).toBe(true)
|
||||
|
||||
setFlag(false)
|
||||
expect(source.isEnabled()).toBe(false)
|
||||
})
|
||||
|
||||
it('unsubscribing stops further notifications', () => {
|
||||
const { posthog, setFlag } = fakePostHog(undefined)
|
||||
const source = createPostHogFlagSource(posthog)
|
||||
const onChange = vi.fn()
|
||||
const unsubscribe = source.onChange?.(onChange)
|
||||
|
||||
unsubscribe?.()
|
||||
setFlag(true)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('tolerates a void-returning onFeatureFlags', () => {
|
||||
const posthog: PostHogLike = {
|
||||
isFeatureEnabled: () => true,
|
||||
onFeatureFlags: vi.fn()
|
||||
}
|
||||
const source = createPostHogFlagSource(posthog)
|
||||
const unsubscribe = source.onChange?.(() => {})
|
||||
expect(source.isEnabled()).toBe(true)
|
||||
expect(() => unsubscribe?.()).not.toThrow()
|
||||
})
|
||||
|
||||
it('queries the agent flag by default', () => {
|
||||
const isFeatureEnabled = vi.fn(() => true)
|
||||
const source = createPostHogFlagSource({
|
||||
isFeatureEnabled,
|
||||
onFeatureFlags: () => {}
|
||||
})
|
||||
expect(source.isEnabled()).toBe(true)
|
||||
expect(isFeatureEnabled).toHaveBeenCalledWith(AGENT_PANEL_FLAG)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
export const AGENT_PANEL_FLAG = 'agent-in-app-experience'
|
||||
|
||||
export interface AgentFlagSource {
|
||||
isEnabled(): boolean
|
||||
onChange?(listener: () => void): () => void
|
||||
}
|
||||
|
||||
export interface PostHogLike {
|
||||
isFeatureEnabled(flag: string): boolean | undefined
|
||||
onFeatureFlags(listener: () => void): (() => void) | void
|
||||
}
|
||||
|
||||
export function createPostHogFlagSource(
|
||||
posthog: PostHogLike,
|
||||
flag: string = AGENT_PANEL_FLAG
|
||||
): AgentFlagSource {
|
||||
return {
|
||||
isEnabled: () => posthog.isFeatureEnabled(flag) === true,
|
||||
onChange: (listener) => {
|
||||
const unsubscribe = posthog.onFeatureFlags(listener)
|
||||
return typeof unsubscribe === 'function' ? unsubscribe : () => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,371 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { i18n } from '@/i18n'
|
||||
import type { AgentActiveTabData, TurnId } from '../../schemas/agentApiSchema'
|
||||
import { isAgentEvent, parseAgentWsEvent } from '../../schemas/agentApiSchema'
|
||||
import { AgentApiError } from '../../services/agent/agentRestClient'
|
||||
import type {
|
||||
AgentRestClient,
|
||||
DraftUpload,
|
||||
OpenTabsSnapshot
|
||||
} from '../../services/agent/agentRestClient'
|
||||
import { useAgentConversationStore } from '../../stores/agent/agentConversationStore'
|
||||
import { useAgentDraftStore } from '../../stores/agent/agentDraftStore'
|
||||
|
||||
export interface AgentEventSource {
|
||||
subscribe(listener: (raw: unknown) => void): () => void
|
||||
onStatus?(listener: (live: boolean) => void): () => void
|
||||
}
|
||||
|
||||
export interface SessionNotice {
|
||||
level: 'error'
|
||||
text: string
|
||||
}
|
||||
|
||||
interface SentAttachment {
|
||||
ref: string
|
||||
name: string
|
||||
previewUrl?: string
|
||||
}
|
||||
|
||||
interface SentTag {
|
||||
id: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export interface WorkflowTurnContext {
|
||||
id: string
|
||||
tabPath: string
|
||||
}
|
||||
|
||||
export interface AgentSessionDeps {
|
||||
rest: AgentRestClient
|
||||
events: AgentEventSource
|
||||
workflow?: {
|
||||
current(): WorkflowTurnContext | undefined
|
||||
adopted(
|
||||
workflowId: string,
|
||||
sent: WorkflowTurnContext | undefined,
|
||||
uploaded: boolean
|
||||
): void
|
||||
prepare?(): Promise<void>
|
||||
snapshot?(): DraftUpload | undefined
|
||||
uploadSkipped?(): void
|
||||
tabs?(): OpenTabsSnapshot | undefined
|
||||
activeTab?(data: AgentActiveTabData): void
|
||||
}
|
||||
}
|
||||
|
||||
const THREAD_STORAGE_KEY = 'Comfy.Agent.ThreadId'
|
||||
const PREPARE_TIMEOUT_MS = 3000
|
||||
|
||||
export function useAgentSession(deps: AgentSessionDeps) {
|
||||
const { rest, events, workflow } = deps
|
||||
|
||||
const conversationStore = useAgentConversationStore()
|
||||
const draftStore = useAgentDraftStore()
|
||||
|
||||
const notices = ref<SessionNotice[]>([])
|
||||
let resyncing = false
|
||||
const sending = ref(false)
|
||||
|
||||
let localErrorCount = 0
|
||||
function nextLocalErrorId(): TurnId {
|
||||
return `local-error-${++localErrorCount}` as TurnId
|
||||
}
|
||||
|
||||
let unsubscribe: (() => void) | null = null
|
||||
let unsubscribeStatus: (() => void) | null = null
|
||||
|
||||
function pushError(text: string): void {
|
||||
notices.value.push({ level: 'error', text })
|
||||
}
|
||||
|
||||
async function resyncDraft(): Promise<void> {
|
||||
const id = draftStore.workflowId
|
||||
if (id === null || resyncing) return
|
||||
resyncing = true
|
||||
try {
|
||||
const snapshot = await rest.getDraft(id)
|
||||
if (draftStore.workflowId === id) draftStore.adoptSnapshot(snapshot)
|
||||
} catch (error) {
|
||||
if (error instanceof AgentApiError) {
|
||||
if (error.status === 404) return
|
||||
pushError(error.message)
|
||||
return
|
||||
}
|
||||
pushError(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
resyncing = false
|
||||
}
|
||||
}
|
||||
|
||||
function start(): void {
|
||||
unsubscribe = events.subscribe(onRaw)
|
||||
if (events.onStatus) unsubscribeStatus = events.onStatus(onStatus)
|
||||
const surviving = conversationStore.threadId
|
||||
if (surviving !== null) {
|
||||
const generation = ++loadGeneration
|
||||
void hydrateFromServer(surviving, () => generation === loadGeneration)
|
||||
return
|
||||
}
|
||||
if (conversationStore.messages.length === 0) {
|
||||
const stored = localStorage.getItem(THREAD_STORAGE_KEY)
|
||||
if (stored !== null) {
|
||||
const generation = ++loadGeneration
|
||||
conversationStore.setThreadId(stored)
|
||||
void hydrateFromServer(stored, () => generation === loadGeneration)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function hydrateFromServer(
|
||||
threadId: string,
|
||||
isCurrent: () => boolean = () => true
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const history = await rest.getMessages(threadId)
|
||||
if (conversationStore.threadId !== threadId || !isCurrent()) return false
|
||||
conversationStore.hydrate(history)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (!isCurrent()) return false
|
||||
if (error instanceof AgentApiError && error.status === 404) {
|
||||
if (conversationStore.threadId === threadId)
|
||||
conversationStore.setThreadId(null)
|
||||
localStorage.removeItem(THREAD_STORAGE_KEY)
|
||||
return false
|
||||
}
|
||||
pushError(error instanceof Error ? error.message : String(error))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
unsubscribe?.()
|
||||
unsubscribeStatus?.()
|
||||
unsubscribe = null
|
||||
unsubscribeStatus = null
|
||||
conversationStore.abortActiveTurn()
|
||||
conversationStore.dropBackgroundTurns()
|
||||
}
|
||||
|
||||
async function sendMessage(
|
||||
text: string,
|
||||
attachments?: SentAttachment[],
|
||||
tags?: SentTag[]
|
||||
): Promise<boolean> {
|
||||
if (sending.value) {
|
||||
conversationStore.recordFailedSend(
|
||||
nextLocalErrorId(),
|
||||
text,
|
||||
i18n.global.t('agent.sendBusy')
|
||||
)
|
||||
return false
|
||||
}
|
||||
sending.value = true
|
||||
if (workflow?.prepare)
|
||||
await Promise.race([
|
||||
workflow.prepare().catch(() => undefined),
|
||||
new Promise<void>((resolve) => setTimeout(resolve, PREPARE_TIMEOUT_MS))
|
||||
])
|
||||
const wfContext = workflow?.current()
|
||||
const upload = workflow?.snapshot?.()
|
||||
const tabs = workflow?.tabs?.()
|
||||
function buildInput(draft: DraftUpload | undefined) {
|
||||
return {
|
||||
content: text,
|
||||
tabs,
|
||||
selection:
|
||||
tags !== undefined && tags.length > 0
|
||||
? { node_ids: tags.map((tag) => tag.id) }
|
||||
: undefined,
|
||||
attachments: attachments?.map((attachment) => attachment.ref),
|
||||
draft
|
||||
}
|
||||
}
|
||||
async function post(threadId: string, draft: DraftUpload | undefined) {
|
||||
const input = buildInput(draft)
|
||||
return rest.postMessage(
|
||||
threadId,
|
||||
wfContext ? { ...input, workflowId: wfContext.id } : input
|
||||
)
|
||||
}
|
||||
let uploaded = upload !== undefined
|
||||
async function postTurn(threadId: string) {
|
||||
try {
|
||||
return await post(threadId, upload)
|
||||
} catch (error) {
|
||||
if (!(error instanceof AgentApiError)) throw error
|
||||
const serverVersion = (error.body as { version?: unknown } | null)
|
||||
?.version
|
||||
if (
|
||||
error.status === 409 &&
|
||||
upload !== undefined &&
|
||||
typeof serverVersion === 'number'
|
||||
) {
|
||||
return await post(threadId, { ...upload, version: serverVersion })
|
||||
}
|
||||
if (upload !== undefined && error.status >= 500) {
|
||||
console.warn(
|
||||
'[agent] draft upload rejected by the server, sending without it',
|
||||
error.message
|
||||
)
|
||||
const ack = await post(threadId, undefined)
|
||||
uploaded = false
|
||||
workflow?.uploadSkipped?.()
|
||||
return ack
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
try {
|
||||
const ack = await postTurn(conversationStore.threadId ?? 'new')
|
||||
conversationStore.setThreadId(ack.thread_id)
|
||||
localStorage.setItem(THREAD_STORAGE_KEY, ack.thread_id)
|
||||
if (ack.workflow_id !== undefined) {
|
||||
draftStore.bind(ack.workflow_id)
|
||||
workflow?.adopted(ack.workflow_id, wfContext, uploaded)
|
||||
}
|
||||
const turnId = ack.message_id as TurnId
|
||||
conversationStore.recordUser(
|
||||
turnId,
|
||||
text,
|
||||
attachments?.map(({ name, previewUrl }) => ({ name, previewUrl })),
|
||||
tags?.map((tag) => tag.title)
|
||||
)
|
||||
conversationStore.startTurn(turnId)
|
||||
return true
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof AgentApiError
|
||||
? error.message
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: String(error)
|
||||
conversationStore.recordFailedSend(
|
||||
nextLocalErrorId(),
|
||||
text,
|
||||
`${i18n.global.t('agent.sendFailed')}: ${message}`
|
||||
)
|
||||
return false
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function stopTurn(): Promise<void> {
|
||||
const threadId = conversationStore.threadId
|
||||
const turnId = conversationStore.activeTurnId
|
||||
if (threadId === null || turnId === null) return
|
||||
try {
|
||||
await rest.cancelMessage(threadId, turnId)
|
||||
} catch (error) {
|
||||
if (error instanceof AgentApiError) {
|
||||
if (error.status === 409) return
|
||||
pushError(error.message)
|
||||
return
|
||||
}
|
||||
pushError(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
let loadGeneration = 0
|
||||
|
||||
function newChat(): void {
|
||||
loadGeneration++
|
||||
conversationStore.stashActiveTurn()
|
||||
conversationStore.reset()
|
||||
draftStore.reset()
|
||||
localStorage.removeItem(THREAD_STORAGE_KEY)
|
||||
}
|
||||
|
||||
function listThreads() {
|
||||
return rest.listThreads()
|
||||
}
|
||||
|
||||
async function loadThread(threadId: string): Promise<void> {
|
||||
const generation = ++loadGeneration
|
||||
conversationStore.stashActiveTurn()
|
||||
draftStore.reset()
|
||||
conversationStore.setThreadId(threadId)
|
||||
localStorage.setItem(THREAD_STORAGE_KEY, threadId)
|
||||
const hydrated = await hydrateFromServer(
|
||||
threadId,
|
||||
() => generation === loadGeneration
|
||||
)
|
||||
if (hydrated && generation === loadGeneration)
|
||||
conversationStore.resumeBackgroundTurn()
|
||||
}
|
||||
|
||||
function onRaw(raw: unknown): void {
|
||||
if (typeof raw !== 'object' || raw === null) return
|
||||
const type = (raw as { type?: unknown }).type
|
||||
if (typeof type !== 'string' || !isAgentEvent(type)) return
|
||||
const parsed = parseAgentWsEvent(raw)
|
||||
if (!parsed.success) {
|
||||
const messageId = (raw as { data?: { message_id?: unknown } }).data
|
||||
?.message_id
|
||||
if (type === 'agent_message_done') {
|
||||
if (
|
||||
typeof messageId !== 'string' ||
|
||||
messageId === conversationStore.activeTurnId
|
||||
) {
|
||||
conversationStore.abortActiveTurn()
|
||||
pushError(i18n.global.t('agent.malformedEvent'))
|
||||
} else {
|
||||
conversationStore.settleBackgroundTurn(messageId)
|
||||
}
|
||||
}
|
||||
console.warn('[agent] dropping malformed agent event', parsed.error)
|
||||
return
|
||||
}
|
||||
const event = parsed.data
|
||||
switch (event.type) {
|
||||
case 'draft_patch':
|
||||
if (
|
||||
event.data.thread_id === undefined ||
|
||||
event.data.thread_id === conversationStore.threadId
|
||||
)
|
||||
draftStore.applyPatch(event.data)
|
||||
return
|
||||
case 'draft_version':
|
||||
if (draftStore.checkHeartbeat(event.data) === 'behind')
|
||||
void resyncDraft()
|
||||
return
|
||||
case 'agent_active_tab':
|
||||
if (
|
||||
event.data.thread_id === undefined ||
|
||||
event.data.thread_id === conversationStore.threadId
|
||||
)
|
||||
workflow?.activeTab?.(event.data)
|
||||
return
|
||||
default:
|
||||
conversationStore.ingest(event)
|
||||
}
|
||||
}
|
||||
|
||||
function onStatus(live: boolean): void {
|
||||
if (live) {
|
||||
void resyncDraft()
|
||||
return
|
||||
}
|
||||
conversationStore.abortActiveTurn()
|
||||
conversationStore.dropBackgroundTurns()
|
||||
}
|
||||
|
||||
return {
|
||||
start,
|
||||
stop,
|
||||
sendMessage,
|
||||
stopTurn,
|
||||
newChat,
|
||||
listThreads,
|
||||
loadThread,
|
||||
entries: computed(() => conversationStore.entries),
|
||||
status: computed(() => conversationStore.status),
|
||||
isStreaming: computed(() => conversationStore.isStreaming),
|
||||
notices: computed(() => notices.value),
|
||||
threadId: computed(() => conversationStore.threadId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ComposerAttachment } from './useComposer'
|
||||
import { MAX_ATTACHMENT_BYTES, useAttachment } from './useAttachment'
|
||||
|
||||
function fileOfSize(name: string, size: number): File {
|
||||
const file = new File(['x'], name, { type: 'image/png' })
|
||||
Object.defineProperty(file, 'size', { value: size })
|
||||
return file
|
||||
}
|
||||
|
||||
function chipRegistry() {
|
||||
const chips: ComposerAttachment[] = []
|
||||
return {
|
||||
chips,
|
||||
stage: (attachment: ComposerAttachment) => chips.push(attachment),
|
||||
update: (id: string, patch: Partial<ComposerAttachment>) => {
|
||||
const index = chips.findIndex((chip) => chip.id === id)
|
||||
if (index >= 0) chips[index] = { ...chips[index], ...patch }
|
||||
},
|
||||
remove: (id: string) => {
|
||||
const index = chips.findIndex((chip) => chip.id === id)
|
||||
if (index >= 0) chips.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('useAttachment', () => {
|
||||
it('rejects files over 20MB before staging or uploading', async () => {
|
||||
const upload = vi.fn()
|
||||
const onError = vi.fn()
|
||||
const registry = chipRegistry()
|
||||
const { addFiles } = useAttachment({ upload, onError, ...registry })
|
||||
|
||||
await addFiles([fileOfSize('huge.png', MAX_ATTACHMENT_BYTES + 1)])
|
||||
|
||||
expect(registry.chips).toEqual([])
|
||||
expect(upload).not.toHaveBeenCalled()
|
||||
expect(onError).toHaveBeenCalledOnce()
|
||||
expect(onError).toHaveBeenCalledWith('huge.png is larger than 20MB')
|
||||
})
|
||||
|
||||
it('stages an uploading chip immediately, then settles it with the server ref', async () => {
|
||||
let resolveUpload: (result: { ref: string }) => void = () => {}
|
||||
const upload = vi.fn(
|
||||
() =>
|
||||
new Promise<{ ref: string }>((resolve) => {
|
||||
resolveUpload = resolve
|
||||
})
|
||||
)
|
||||
const registry = chipRegistry()
|
||||
const { addFiles } = useAttachment({ upload, ...registry })
|
||||
|
||||
const batch = addFiles([fileOfSize('cat.png', 1024)])
|
||||
|
||||
expect(registry.chips).toHaveLength(1)
|
||||
expect(registry.chips[0]).toMatchObject({
|
||||
name: 'cat.png',
|
||||
ref: '',
|
||||
uploading: true
|
||||
})
|
||||
expect(registry.chips[0].previewUrl).toBeTruthy()
|
||||
|
||||
resolveUpload({ ref: 'uploaded_cat.png' })
|
||||
await batch
|
||||
expect(registry.chips[0]).toMatchObject({
|
||||
ref: 'uploaded_cat.png',
|
||||
uploading: false
|
||||
})
|
||||
})
|
||||
|
||||
it('removes the chip and surfaces the error when the upload fails', async () => {
|
||||
const upload = vi.fn().mockRejectedValue(new Error('network down'))
|
||||
const onError = vi.fn()
|
||||
const registry = chipRegistry()
|
||||
const { addFiles } = useAttachment({ upload, onError, ...registry })
|
||||
|
||||
await addFiles([fileOfSize('cat.png', 1024)])
|
||||
|
||||
expect(registry.chips).toEqual([])
|
||||
expect(onError).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps earlier settled chips and continues the batch when one upload fails', async () => {
|
||||
const upload = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ref: 'a.png' })
|
||||
.mockRejectedValueOnce(new Error('boom'))
|
||||
.mockResolvedValueOnce({ ref: 'c.png' })
|
||||
const onError = vi.fn()
|
||||
const registry = chipRegistry()
|
||||
const { addFiles } = useAttachment({ upload, onError, ...registry })
|
||||
|
||||
await addFiles([
|
||||
fileOfSize('a.png', 10),
|
||||
fileOfSize('b.png', 10),
|
||||
fileOfSize('c.png', 10)
|
||||
])
|
||||
|
||||
expect(registry.chips.map((chip) => chip.ref)).toEqual(['a.png', 'c.png'])
|
||||
expect(registry.chips.every((chip) => chip.uploading === false)).toBe(true)
|
||||
expect(onError).toHaveBeenCalledWith('b.png could not be uploaded')
|
||||
})
|
||||
|
||||
it('calls preventDefault before doing any work on drop', async () => {
|
||||
const upload = vi.fn().mockResolvedValue({ ref: 'r' })
|
||||
const registry = chipRegistry()
|
||||
const { onDrop } = useAttachment({ upload, ...registry })
|
||||
const preventDefault = vi.fn()
|
||||
const event = {
|
||||
preventDefault,
|
||||
dataTransfer: { files: [] as unknown as FileList }
|
||||
} as unknown as DragEvent
|
||||
|
||||
await onDrop(event)
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import { i18n } from '@/i18n'
|
||||
import type { ComposerAttachment } from './useComposer'
|
||||
|
||||
export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
|
||||
const MAX_ATTACHMENT_LABEL = `${MAX_ATTACHMENT_BYTES / 1024 / 1024}MB`
|
||||
|
||||
interface UploadResult {
|
||||
ref: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
export interface UseAttachmentOptions {
|
||||
upload: (file: File) => Promise<UploadResult>
|
||||
onError?: (message: string) => void
|
||||
stage: (attachment: ComposerAttachment) => void
|
||||
update: (id: string, patch: Partial<ComposerAttachment>) => void
|
||||
remove: (id: string) => void
|
||||
}
|
||||
|
||||
let stagedCount = 0
|
||||
|
||||
export function useAttachment(options: UseAttachmentOptions) {
|
||||
async function addFiles(files: Iterable<File>): Promise<void> {
|
||||
for (const file of files) {
|
||||
if (file.size > MAX_ATTACHMENT_BYTES) {
|
||||
options.onError?.(
|
||||
i18n.global.t('agent.attachmentTooLarge', {
|
||||
name: file.name,
|
||||
limit: MAX_ATTACHMENT_LABEL
|
||||
})
|
||||
)
|
||||
continue
|
||||
}
|
||||
const id = `upload-${++stagedCount}:${file.name}`
|
||||
options.stage({
|
||||
id,
|
||||
name: file.name,
|
||||
ref: '',
|
||||
previewUrl: URL.createObjectURL(file),
|
||||
uploading: true
|
||||
})
|
||||
try {
|
||||
const result = await options.upload(file)
|
||||
options.update(id, { ref: result.ref, uploading: false })
|
||||
} catch {
|
||||
options.onError?.(
|
||||
i18n.global.t('agent.attachmentUploadFailed', { name: file.name })
|
||||
)
|
||||
options.remove(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent): Promise<void> {
|
||||
event.preventDefault()
|
||||
const files = event.dataTransfer?.files
|
||||
return files && files.length > 0
|
||||
? addFiles(Array.from(files))
|
||||
: Promise.resolve()
|
||||
}
|
||||
|
||||
return { addFiles, onDrop }
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ref } from 'vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { SelectedNode } from './useCanvasSelection'
|
||||
import { useCanvasSelection } from './useCanvasSelection'
|
||||
|
||||
const nodeA: SelectedNode = { id: '1', title: 'Load Checkpoint' }
|
||||
const nodeB: SelectedNode = { id: '2', title: 'KSampler' }
|
||||
|
||||
describe('useCanvasSelection', () => {
|
||||
it('stages the current selection only while live', () => {
|
||||
const selection = ref<SelectedNode[]>([nodeA])
|
||||
const isLive = ref(false)
|
||||
const { staged } = useCanvasSelection({ selection, isLive })
|
||||
expect(staged.value).toEqual([])
|
||||
|
||||
isLive.value = true
|
||||
expect(staged.value).toEqual([nodeA])
|
||||
})
|
||||
|
||||
it('clears on submit and does not re-stage the same selection', async () => {
|
||||
const selection = ref<SelectedNode[]>([nodeA])
|
||||
const { staged, consume } = useCanvasSelection({
|
||||
selection,
|
||||
isLive: ref(true)
|
||||
})
|
||||
expect(staged.value).toEqual([nodeA])
|
||||
|
||||
expect(consume()).toEqual([nodeA])
|
||||
expect(staged.value).toEqual([])
|
||||
|
||||
selection.value = [nodeA]
|
||||
await Promise.resolve()
|
||||
expect(staged.value).toEqual([])
|
||||
})
|
||||
|
||||
it('re-stages when the selection changes', () => {
|
||||
const selection = ref<SelectedNode[]>([nodeA])
|
||||
const { staged, consume } = useCanvasSelection({
|
||||
selection,
|
||||
isLive: ref(true)
|
||||
})
|
||||
consume()
|
||||
selection.value = [nodeA, nodeB]
|
||||
expect(staged.value).toEqual([nodeA, nodeB])
|
||||
})
|
||||
|
||||
it('drops a tag on remove but keeps the rest', () => {
|
||||
const selection = ref<SelectedNode[]>([nodeA, nodeB])
|
||||
const { staged, remove } = useCanvasSelection({
|
||||
selection,
|
||||
isLive: ref(true)
|
||||
})
|
||||
remove('1')
|
||||
expect(staged.value).toEqual([nodeB])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { ref, toValue, watch } from 'vue'
|
||||
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
export interface SelectedNode {
|
||||
id: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export interface UseCanvasSelectionOptions {
|
||||
selection: MaybeRefOrGetter<SelectedNode[]>
|
||||
isLive: MaybeRefOrGetter<boolean>
|
||||
}
|
||||
|
||||
function signature(nodes: SelectedNode[]): string {
|
||||
return nodes
|
||||
.map((node) => node.id)
|
||||
.sort()
|
||||
.join(',')
|
||||
}
|
||||
|
||||
export function useCanvasSelection(options: UseCanvasSelectionOptions) {
|
||||
const staged = ref<SelectedNode[]>([])
|
||||
const consumedSig = ref<string | null>(null)
|
||||
const stagedSig = ref<string | null>(null)
|
||||
|
||||
watch(
|
||||
() => (toValue(options.isLive) ? toValue(options.selection) : []),
|
||||
(nodes) => {
|
||||
if (nodes.length === 0) {
|
||||
staged.value = []
|
||||
consumedSig.value = null
|
||||
stagedSig.value = null
|
||||
return
|
||||
}
|
||||
const sig = signature(nodes)
|
||||
if (sig === consumedSig.value || sig === stagedSig.value) return
|
||||
consumedSig.value = null
|
||||
stagedSig.value = sig
|
||||
staged.value = [...nodes]
|
||||
},
|
||||
{ immediate: true, deep: true, flush: 'sync' }
|
||||
)
|
||||
|
||||
function consume(): SelectedNode[] {
|
||||
const tags = staged.value
|
||||
consumedSig.value = signature(toValue(options.selection))
|
||||
staged.value = []
|
||||
return tags
|
||||
}
|
||||
|
||||
function remove(id: string): void {
|
||||
staged.value = staged.value.filter((node) => node.id !== id)
|
||||
}
|
||||
|
||||
function add(node: SelectedNode): void {
|
||||
if (staged.value.some((tag) => tag.id === node.id)) return
|
||||
staged.value = [...staged.value, node]
|
||||
}
|
||||
|
||||
return { staged, consume, remove, add }
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ComposerAttachment } from './useComposer'
|
||||
import { useComposer } from './useComposer'
|
||||
|
||||
function setup(streaming = false) {
|
||||
const onSend =
|
||||
vi.fn<(text: string, attachments: ComposerAttachment[]) => void>()
|
||||
const onStop = vi.fn()
|
||||
const composer = useComposer({
|
||||
onSend,
|
||||
onStop,
|
||||
isStreaming: () => streaming
|
||||
})
|
||||
return { composer, onSend, onStop }
|
||||
}
|
||||
|
||||
describe('useComposer', () => {
|
||||
it('submit trims the draft, sends it, and clears draft + attachments', () => {
|
||||
const { composer, onSend } = setup()
|
||||
const attachment: ComposerAttachment = {
|
||||
id: 'a1',
|
||||
name: 'cat.png',
|
||||
ref: 'uploaded_cat.png'
|
||||
}
|
||||
composer.draft.value = ' make a cat '
|
||||
composer.addAttachment(attachment)
|
||||
|
||||
composer.submit()
|
||||
|
||||
expect(onSend).toHaveBeenCalledWith('make a cat', [attachment])
|
||||
expect(composer.draft.value).toBe('')
|
||||
expect(composer.attachments.value).toEqual([])
|
||||
})
|
||||
|
||||
it('blocks send while any attachment is uploading, unblocks on settle', () => {
|
||||
const { composer, onSend } = setup()
|
||||
composer.draft.value = 'wire it in'
|
||||
composer.addAttachment({
|
||||
id: 'u1',
|
||||
name: 'cat.png',
|
||||
ref: '',
|
||||
uploading: true
|
||||
})
|
||||
|
||||
expect(composer.canSend.value).toBe(false)
|
||||
composer.submit()
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
|
||||
composer.updateAttachment('u1', {
|
||||
ref: 'uploaded_cat.png',
|
||||
uploading: false
|
||||
})
|
||||
expect(composer.canSend.value).toBe(true)
|
||||
})
|
||||
|
||||
it('revokes a dismissed blob preview but never a submitted one', () => {
|
||||
const revoke = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
const { composer } = setup()
|
||||
composer.addAttachment({
|
||||
id: 'a1',
|
||||
name: 'a.png',
|
||||
ref: 'r1',
|
||||
previewUrl: 'blob:a'
|
||||
})
|
||||
composer.removeAttachment('a1')
|
||||
expect(revoke).toHaveBeenCalledWith('blob:a')
|
||||
|
||||
revoke.mockClear()
|
||||
composer.draft.value = 'send it'
|
||||
composer.addAttachment({
|
||||
id: 'a2',
|
||||
name: 'b.png',
|
||||
ref: 'r2',
|
||||
previewUrl: 'blob:b'
|
||||
})
|
||||
composer.submit()
|
||||
expect(revoke).not.toHaveBeenCalled()
|
||||
revoke.mockRestore()
|
||||
})
|
||||
|
||||
it('allows an attachment-only send with empty text', () => {
|
||||
const { composer, onSend } = setup()
|
||||
composer.addAttachment({ id: 'a1', name: 'cat.png', ref: 'r' })
|
||||
|
||||
expect(composer.canSend.value).toBe(true)
|
||||
composer.submit()
|
||||
|
||||
expect(onSend).toHaveBeenCalledWith('', [
|
||||
{ id: 'a1', name: 'cat.png', ref: 'r' }
|
||||
])
|
||||
})
|
||||
|
||||
it('does not send when there is neither text nor an attachment', () => {
|
||||
const { composer, onSend } = setup()
|
||||
composer.draft.value = ' '
|
||||
|
||||
expect(composer.canSend.value).toBe(false)
|
||||
composer.submit()
|
||||
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes submit to stop while streaming, without sending', () => {
|
||||
const { composer, onSend, onStop } = setup(true)
|
||||
composer.draft.value = 'ignored while streaming'
|
||||
|
||||
composer.submit()
|
||||
|
||||
expect(onStop).toHaveBeenCalledOnce()
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
expect(composer.draft.value).toBe('ignored while streaming')
|
||||
})
|
||||
|
||||
it('insert appends to the draft without sending', () => {
|
||||
const { composer, onSend } = setup()
|
||||
composer.insert('first')
|
||||
composer.insert('second')
|
||||
|
||||
expect(composer.draft.value).toBe('first second')
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removeAttachment drops the matching staged attachment', () => {
|
||||
const { composer } = setup()
|
||||
composer.addAttachment({ id: 'a1', name: 'a.png', ref: 'ra' })
|
||||
composer.addAttachment({ id: 'a2', name: 'b.png', ref: 'rb' })
|
||||
|
||||
composer.removeAttachment('a1')
|
||||
|
||||
expect(composer.attachments.value.map((a) => a.id)).toEqual(['a2'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
export interface ComposerAttachment {
|
||||
id: string
|
||||
name: string
|
||||
ref: string
|
||||
previewUrl?: string
|
||||
uploading?: boolean
|
||||
}
|
||||
|
||||
export interface UseComposerOptions {
|
||||
onSend: (text: string, attachments: ComposerAttachment[]) => void
|
||||
isStreaming: () => boolean
|
||||
onStop: () => void
|
||||
}
|
||||
|
||||
export function useComposer(options: UseComposerOptions) {
|
||||
const draft = ref('')
|
||||
const attachments = ref<ComposerAttachment[]>([])
|
||||
|
||||
const canSend = computed(
|
||||
() =>
|
||||
(draft.value.trim().length > 0 || attachments.value.length > 0) &&
|
||||
!attachments.value.some((item) => item.uploading)
|
||||
)
|
||||
|
||||
function submit(): void {
|
||||
if (options.isStreaming()) {
|
||||
options.onStop()
|
||||
return
|
||||
}
|
||||
if (!canSend.value) return
|
||||
options.onSend(draft.value.trim(), attachments.value)
|
||||
draft.value = ''
|
||||
attachments.value = []
|
||||
}
|
||||
|
||||
function insert(text: string): void {
|
||||
draft.value = draft.value ? `${draft.value} ${text}` : text
|
||||
}
|
||||
|
||||
function addAttachment(attachment: ComposerAttachment): void {
|
||||
attachments.value = [...attachments.value, attachment]
|
||||
}
|
||||
|
||||
function updateAttachment(
|
||||
id: string,
|
||||
patch: Partial<ComposerAttachment>
|
||||
): void {
|
||||
attachments.value = attachments.value.map((item) =>
|
||||
item.id === id ? { ...item, ...patch } : item
|
||||
)
|
||||
}
|
||||
|
||||
function removeAttachment(id: string): void {
|
||||
const removed = attachments.value.find((item) => item.id === id)
|
||||
if (removed?.previewUrl?.startsWith('blob:'))
|
||||
URL.revokeObjectURL(removed.previewUrl)
|
||||
attachments.value = attachments.value.filter((item) => item.id !== id)
|
||||
}
|
||||
|
||||
return {
|
||||
draft,
|
||||
attachments,
|
||||
canSend,
|
||||
submit,
|
||||
insert,
|
||||
addAttachment,
|
||||
updateAttachment,
|
||||
removeAttachment
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { nextTick } from 'vue'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { useOnboarding } from './useOnboarding'
|
||||
|
||||
const KEY = 'test.onboarded'
|
||||
|
||||
describe('useOnboarding', () => {
|
||||
beforeEach(() => window.localStorage.clear())
|
||||
|
||||
it('is active until finished, then persists as seen', async () => {
|
||||
const first = useOnboarding(KEY)
|
||||
expect(first.active.value).toBe(true)
|
||||
|
||||
first.finish()
|
||||
expect(first.active.value).toBe(false)
|
||||
|
||||
await nextTick()
|
||||
const second = useOnboarding(KEY)
|
||||
expect(second.active.value).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
export interface CoachStep {
|
||||
target: string
|
||||
title: string
|
||||
body: string
|
||||
}
|
||||
|
||||
export function useOnboarding(storageKey = 'Comfy.AgentPanel.onboarded') {
|
||||
const seen = useStorage(storageKey, false)
|
||||
|
||||
const active = computed(() => !seen.value)
|
||||
|
||||
function finish(): void {
|
||||
seen.value = true
|
||||
}
|
||||
|
||||
return { active, finish }
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
|
||||
{"capturedAt":1783367145226,"frame":{"data":{"delta":"Let me pull the current node list and every settable widget value so I can walk through them precisely.","message_id":"5d7c81a9-31f5-42f8-81c0-7525473da046","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718"},"type":"agent_thinking"}}
|
||||
{"capturedAt":1783367145346,"frame":{"data":{"delta":"Stopped at your request.","message_id":"5d7c81a9-31f5-42f8-81c0-7525473da046","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718"},"type":"agent_message_delta"}}
|
||||
{"capturedAt":1783367145351,"frame":{"data":{"message_id":"5d7c81a9-31f5-42f8-81c0-7525473da046","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718","usage":null},"type":"agent_message_done"}}
|
||||
@@ -0,0 +1,20 @@
|
||||
{"capturedAt":1783366827411,"frame":{"type":"status","data":{"status":{"exec_info":{"queue_remaining":0}},"sid":"c3bf440f-8131-481d-b46d-fffbf799d950"}}}
|
||||
{"capturedAt":1783366831396,"frame":{"data":{"version":24,"workflow_id":"a81718a4-02ae-41e6-ae85-c33b7bb880f6"},"type":"draft_version"}}
|
||||
{"capturedAt":1783366843413,"frame":{"data":{"version":24,"workflow_id":"a81718a4-02ae-41e6-ae85-c33b7bb880f6"},"type":"draft_version"}}
|
||||
{"capturedAt":1783366855397,"frame":{"data":{"version":24,"workflow_id":"a81718a4-02ae-41e6-ae85-c33b7bb880f6"},"type":"draft_version"}}
|
||||
{"capturedAt":1783366867397,"frame":{"data":{"version":24,"workflow_id":"a81718a4-02ae-41e6-ae85-c33b7bb880f6"},"type":"draft_version"}}
|
||||
{"capturedAt":1783366879318,"frame":{"data":{"delta":"I'll find the exact widget addresses first, then edit those two values in place.","message_id":"172a6ede-7ab7-4b01-83b6-5b15f66dee4b","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718"},"type":"agent_thinking"}}
|
||||
{"capturedAt":1783366881383,"frame":{"data":{"args":["workflow","slots","workflow.json"],"message_id":"172a6ede-7ab7-4b01-83b6-5b15f66dee4b","status":"ok","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718","tool_name":"list_slots"},"type":"agent_tool_call"}}
|
||||
{"capturedAt":1783366883967,"frame":{"data":{"delta":"Let me get the full slot list to find the exact node addresses.","message_id":"172a6ede-7ab7-4b01-83b6-5b15f66dee4b","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718"},"type":"agent_thinking"}}
|
||||
{"capturedAt":1783366884944,"frame":{"data":{"args":["workflow","ls-nodes","workflow.json"],"message_id":"172a6ede-7ab7-4b01-83b6-5b15f66dee4b","status":"ok","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718","tool_name":"ls_nodes"},"type":"agent_tool_call"}}
|
||||
{"capturedAt":1783366889500,"frame":{"data":{"base_version":24,"content":{"last_node_id":4415264681630046,"links":[[3157652320648586,2356360821950521,1,3610737701189037,0,"CLIP"],[2980960744320733,2356360821950521,1,4415264681630046,0,"CLIP"],[3311070635787049,2356360821950521,0,1906165638342264,0,"MODEL"],[2849829752322859,3610737701189037,0,1906165638342264,1,"CONDITIONING"],[302636556962155,4415264681630046,0,1906165638342264,2,"CONDITIONING"],[2921696816813485,3095725229108875,0,1906165638342264,3,"LATENT"],[4443285092645398,1906165638342264,0,4241707905201179,0,"LATENT"],[3478849875736289,2356360821950521,2,4241707905201179,1,"VAE"],[2519317245573755,4241707905201179,0,28442142756119,0,"IMAGE"]],"nodes":[{"flags":{},"id":2356360821950521,"inputs":[],"mode":0,"order":0,"outputs":[{"links":[3311070635787049],"name":"MODEL","type":"MODEL"},{"links":[3157652320648586,2980960744320733],"name":"CLIP","type":"CLIP"},{"links":[3478849875736289],"name":"VAE","type":"VAE"}],"pos":[100,300],"properties":{},"size":[210,100],"type":"CheckpointLoaderSimple","widgets_values":["sd_xl_base_1.0.safetensors"]},{"flags":{},"id":3610737701189037,"inputs":[{"link":3157652320648586,"name":"clip","type":"CLIP"}],"mode":0,"order":0,"outputs":[{"links":[2849829752322859],"name":"CONDITIONING","type":"CONDITIONING"}],"pos":[450,150],"properties":{},"size":[210,100],"type":"CLIPTextEncode","widgets_values":["a scenic mountain landscape at sunset, highly detailed, sharp focus, vibrant colors"]},{"flags":{},"id":4415264681630046,"inputs":[{"link":2980960744320733,"name":"clip","type":"CLIP"}],"mode":0,"order":0,"outputs":[{"links":[302636556962155],"name":"CONDITIONING","type":"CONDITIONING"}],"pos":[450,400],"properties":{},"size":[210,100],"type":"CLIPTextEncode","widgets_values":["blurry, low quality, distorted, watermark, text"]},{"flags":{},"id":3095725229108875,"inputs":[],"mode":0,"order":0,"outputs":[{"links":[2921696816813485],"name":"LATENT","type":"LATENT"}],"pos":[450,600],"properties":{},"size":[210,100],"type":"EmptyLatentImage","widgets_values":[1024,1024,1]},{"flags":{},"id":1906165638342264,"inputs":[{"link":3311070635787049,"name":"model","type":"MODEL"},{"link":2849829752322859,"name":"positive","type":"CONDITIONING"},{"link":302636556962155,"name":"negative","type":"CONDITIONING"},{"link":2921696816813485,"name":"latent_image","type":"LATENT"}],"mode":0,"order":0,"outputs":[{"links":[4443285092645398],"name":"LATENT","type":"LATENT"}],"pos":[800,300],"properties":{},"size":[210,100],"type":"KSampler","widgets_values":[0,"fixed",12,7,"euler","normal",1]},{"flags":{},"id":4241707905201179,"inputs":[{"link":4443285092645398,"name":"samples","type":"LATENT"},{"link":3478849875736289,"name":"vae","type":"VAE"}],"mode":0,"order":0,"outputs":[{"links":[2519317245573755],"name":"IMAGE","type":"IMAGE"}],"pos":[1150,300],"properties":{},"size":[210,100],"type":"VAEDecode","widgets_values":[]},{"flags":{},"id":28442142756119,"inputs":[{"link":2519317245573755,"name":"images","type":"IMAGE"}],"mode":0,"order":0,"outputs":[{"links":[],"name":"images","type":"IMAGE"}],"pos":[1400,300],"properties":{},"size":[210,100],"type":"SaveImage","widgets_values":["ComfyUI"]}]},"message_id":"172a6ede-7ab7-4b01-83b6-5b15f66dee4b","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718","version":25,"workflow_id":"a81718a4-02ae-41e6-ae85-c33b7bb880f6"},"type":"draft_patch"}}
|
||||
{"capturedAt":1783366889506,"frame":{"data":{"args":["workflow","set-widget","workflow.json","--actor","MjVkuhzz6vSuA19LWfBa9lMQdKv2","--base-version","24","--where","cloud","--","1906165638342264.steps","12"],"message_id":"172a6ede-7ab7-4b01-83b6-5b15f66dee4b","status":"ok","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718","tool_name":"set_widget"},"type":"agent_tool_call"}}
|
||||
{"capturedAt":1783366891336,"frame":{"data":{"base_version":25,"content":{"last_node_id":4415264681630046,"links":[[3157652320648586,2356360821950521,1,3610737701189037,0,"CLIP"],[2980960744320733,2356360821950521,1,4415264681630046,0,"CLIP"],[3311070635787049,2356360821950521,0,1906165638342264,0,"MODEL"],[2849829752322859,3610737701189037,0,1906165638342264,1,"CONDITIONING"],[302636556962155,4415264681630046,0,1906165638342264,2,"CONDITIONING"],[2921696816813485,3095725229108875,0,1906165638342264,3,"LATENT"],[4443285092645398,1906165638342264,0,4241707905201179,0,"LATENT"],[3478849875736289,2356360821950521,2,4241707905201179,1,"VAE"],[2519317245573755,4241707905201179,0,28442142756119,0,"IMAGE"]],"nodes":[{"flags":{},"id":2356360821950521,"inputs":[],"mode":0,"order":0,"outputs":[{"links":[3311070635787049],"name":"MODEL","type":"MODEL"},{"links":[3157652320648586,2980960744320733],"name":"CLIP","type":"CLIP"},{"links":[3478849875736289],"name":"VAE","type":"VAE"}],"pos":[100,300],"properties":{},"size":[210,100],"type":"CheckpointLoaderSimple","widgets_values":["sd_xl_base_1.0.safetensors"]},{"flags":{},"id":3610737701189037,"inputs":[{"link":3157652320648586,"name":"clip","type":"CLIP"}],"mode":0,"order":0,"outputs":[{"links":[2849829752322859],"name":"CONDITIONING","type":"CONDITIONING"}],"pos":[450,150],"properties":{},"size":[210,100],"type":"CLIPTextEncode","widgets_values":["a scenic mountain landscape at sunset, highly detailed, sharp focus, vibrant colors"]},{"flags":{},"id":4415264681630046,"inputs":[{"link":2980960744320733,"name":"clip","type":"CLIP"}],"mode":0,"order":0,"outputs":[{"links":[302636556962155],"name":"CONDITIONING","type":"CONDITIONING"}],"pos":[450,400],"properties":{},"size":[210,100],"type":"CLIPTextEncode","widgets_values":["blurry, low quality, distorted, watermark, text"]},{"flags":{},"id":3095725229108875,"inputs":[],"mode":0,"order":0,"outputs":[{"links":[2921696816813485],"name":"LATENT","type":"LATENT"}],"pos":[450,600],"properties":{},"size":[210,100],"type":"EmptyLatentImage","widgets_values":[1024,1024,1]},{"flags":{},"id":1906165638342264,"inputs":[{"link":3311070635787049,"name":"model","type":"MODEL"},{"link":2849829752322859,"name":"positive","type":"CONDITIONING"},{"link":302636556962155,"name":"negative","type":"CONDITIONING"},{"link":2921696816813485,"name":"latent_image","type":"LATENT"}],"mode":0,"order":0,"outputs":[{"links":[4443285092645398],"name":"LATENT","type":"LATENT"}],"pos":[800,300],"properties":{},"size":[210,100],"type":"KSampler","widgets_values":[0,"fixed",12,6.5,"euler","normal",1]},{"flags":{},"id":4241707905201179,"inputs":[{"link":4443285092645398,"name":"samples","type":"LATENT"},{"link":3478849875736289,"name":"vae","type":"VAE"}],"mode":0,"order":0,"outputs":[{"links":[2519317245573755],"name":"IMAGE","type":"IMAGE"}],"pos":[1150,300],"properties":{},"size":[210,100],"type":"VAEDecode","widgets_values":[]},{"flags":{},"id":28442142756119,"inputs":[{"link":2519317245573755,"name":"images","type":"IMAGE"}],"mode":0,"order":0,"outputs":[{"links":[],"name":"images","type":"IMAGE"}],"pos":[1400,300],"properties":{},"size":[210,100],"type":"SaveImage","widgets_values":["ComfyUI"]}]},"message_id":"172a6ede-7ab7-4b01-83b6-5b15f66dee4b","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718","version":26,"workflow_id":"a81718a4-02ae-41e6-ae85-c33b7bb880f6"},"type":"draft_patch"}}
|
||||
{"capturedAt":1783366891339,"frame":{"data":{"args":["workflow","set-widget","workflow.json","--actor","MjVkuhzz6vSuA19LWfBa9lMQdKv2","--base-version","25","--where","cloud","--","1906165638342264.cfg","6.5"],"message_id":"172a6ede-7ab7-4b01-83b6-5b15f66dee4b","status":"ok","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718","tool_name":"set_widget"},"type":"agent_tool_call"}}
|
||||
{"capturedAt":1783366891396,"frame":{"data":{"version":26,"workflow_id":"a81718a4-02ae-41e6-ae85-c33b7bb880f6"},"type":"draft_version"}}
|
||||
{"capturedAt":1783366894953,"frame":{"data":{"delta":"Done — both values updated in place, nothing else touched:\n\n- **Steps:** 20 → **12**\n- **Guidance (CFG):** 7 → **6.5**\n\nThe rest of the graph is unchanged, and I didn't run anything. Let me know when you'd like to generate.","message_id":"172a6ede-7ab7-4b01-83b6-5b15f66dee4b","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718"},"type":"agent_message_delta"}}
|
||||
{"capturedAt":1783366894960,"frame":{"data":{"message_id":"172a6ede-7ab7-4b01-83b6-5b15f66dee4b","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":35596,"input_tokens":4493,"output_tokens":425,"total_tokens":4918}},"type":"agent_message_done"}}
|
||||
{"capturedAt":1783366903400,"frame":{"data":{"version":26,"workflow_id":"a81718a4-02ae-41e6-ae85-c33b7bb880f6"},"type":"draft_version"}}
|
||||
{"capturedAt":1783366915398,"frame":{"data":{"version":26,"workflow_id":"a81718a4-02ae-41e6-ae85-c33b7bb880f6"},"type":"draft_version"}}
|
||||
{"capturedAt":1783366927396,"frame":{"data":{"version":26,"workflow_id":"a81718a4-02ae-41e6-ae85-c33b7bb880f6"},"type":"draft_version"}}
|
||||
{"capturedAt":1783366939396,"frame":{"data":{"version":26,"workflow_id":"a81718a4-02ae-41e6-ae85-c33b7bb880f6"},"type":"draft_version"}}
|
||||
185
src/workbench/extensions/agent/schemas/agentApiSchema.test.ts
Normal file
185
src/workbench/extensions/agent/schemas/agentApiSchema.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
AGENT_WS_EVENT_TYPES,
|
||||
isAgentEvent,
|
||||
parseAgentWsEvent,
|
||||
zAgentCancelAccepted,
|
||||
zAgentDraftSnapshot,
|
||||
zAgentError,
|
||||
zAgentMessage,
|
||||
zAgentMessages,
|
||||
zAgentTurnAccepted,
|
||||
zAgentWsEvent
|
||||
} from './agentApiSchema'
|
||||
import type { ZodTypeAny } from 'zod'
|
||||
|
||||
const fixtureText = import.meta.glob('./__fixtures__/agent/*.jsonl', {
|
||||
query: '?raw',
|
||||
import: 'default',
|
||||
eager: true
|
||||
}) as Record<string, string>
|
||||
|
||||
function jsonlLines(path: string): unknown[] {
|
||||
return fixtureText[path]
|
||||
.split('\n')
|
||||
.filter((line) => line.trim().length > 0)
|
||||
.map((line) => JSON.parse(line) as unknown)
|
||||
}
|
||||
|
||||
const wsPaths = Object.keys(fixtureText).filter((path) => path.includes('/ws-'))
|
||||
const restPath = './__fixtures__/agent/rest-responses.jsonl'
|
||||
|
||||
interface WsLine {
|
||||
frame: { type: string }
|
||||
}
|
||||
|
||||
interface RestLine {
|
||||
op: string
|
||||
status: number
|
||||
body: unknown
|
||||
}
|
||||
|
||||
describe('agentApiSchema fixture gate', () => {
|
||||
describe('ws frames: every line is a valid agent event or a recognized-foreign frame', () => {
|
||||
it.for(wsPaths)('%s', (path) => {
|
||||
const lines = jsonlLines(path) as WsLine[]
|
||||
lines.forEach((line, index) => {
|
||||
const { frame } = line
|
||||
if (isAgentEvent(frame.type)) {
|
||||
const result = zAgentWsEvent.safeParse(frame)
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`${path} line ${index} (${frame.type}) failed: ${result.error.message}`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
expect(
|
||||
zAgentWsEvent.safeParse(frame).success,
|
||||
`${path} line ${index} (${frame.type}) must stay foreign to the agent union`
|
||||
).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('rest responses: each line parses through its op/status schema', () => {
|
||||
const restLines = jsonlLines(restPath) as RestLine[]
|
||||
|
||||
it.for(restLines.map((line, index) => [index, line] as const))(
|
||||
'line %i',
|
||||
([index, line]) => {
|
||||
const schema = restSchemaFor(line)
|
||||
const result = schema.safeParse(line.body)
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`rest line ${index} (op=${line.op} status=${line.status}) failed: ${result.error.message}`
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function restSchemaFor(line: RestLine): ZodTypeAny {
|
||||
if (line.status >= 400) return zAgentError
|
||||
if (line.op.startsWith('postMessage')) return zAgentTurnAccepted
|
||||
if (line.op.startsWith('getMessages')) return zAgentMessages
|
||||
if (line.op.startsWith('getDraft')) return zAgentDraftSnapshot
|
||||
if (line.op.startsWith('cancelMessage')) return zAgentCancelAccepted
|
||||
throw new Error(`no schema mapped for op=${line.op} status=${line.status}`)
|
||||
}
|
||||
|
||||
describe('agentApiSchema contract subtleties', () => {
|
||||
const doneBase = {
|
||||
type: 'agent_message_done',
|
||||
data: { message_id: 'm1', thread_id: 't1' }
|
||||
}
|
||||
|
||||
it('accepts agent_message_done with usage null (cancelled turn)', () => {
|
||||
expect(
|
||||
zAgentWsEvent.safeParse({
|
||||
...doneBase,
|
||||
data: { ...doneBase.data, usage: null }
|
||||
}).success
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects draft_patch missing base_version', () => {
|
||||
expect(
|
||||
zAgentWsEvent.safeParse({
|
||||
type: 'draft_patch',
|
||||
data: { version: 2, content: {}, workflow_id: 'w1' }
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an unknown event type in the union while isAgentEvent stays false', () => {
|
||||
expect(zAgentWsEvent.safeParse({ type: 'status', data: {} }).success).toBe(
|
||||
false
|
||||
)
|
||||
expect(isAgentEvent('status')).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts extra additive keys in event data', () => {
|
||||
const parsed = parseAgentWsEvent({
|
||||
type: 'draft_version',
|
||||
data: { version: 5, workflow_id: 'w1', future_field: true }
|
||||
})
|
||||
expect(parsed.success).toBe(true)
|
||||
})
|
||||
|
||||
it('exposes exactly the seven agent event types', () => {
|
||||
expect([...AGENT_WS_EVENT_TYPES].sort()).toEqual(
|
||||
[
|
||||
'agent_active_tab',
|
||||
'agent_message_delta',
|
||||
'agent_message_done',
|
||||
'agent_thinking',
|
||||
'agent_tool_call',
|
||||
'draft_patch',
|
||||
'draft_version'
|
||||
].sort()
|
||||
)
|
||||
})
|
||||
|
||||
it('parses agent_active_tab with an optional name and rejects a missing workflow_id', () => {
|
||||
const parsed = zAgentWsEvent.safeParse({
|
||||
type: 'agent_active_tab',
|
||||
data: { workflow_id: 'wf-1', thread_id: 'th-1' }
|
||||
})
|
||||
expect(parsed.success).toBe(true)
|
||||
expect(
|
||||
zAgentWsEvent.safeParse({
|
||||
type: 'agent_active_tab',
|
||||
data: { thread_id: 'th-1' }
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts an AgentMessage with status interrupted', () => {
|
||||
expect(
|
||||
zAgentMessage.safeParse({
|
||||
id: 'm1',
|
||||
thread_id: 't1',
|
||||
seq: 3,
|
||||
role: 'assistant',
|
||||
status: 'interrupted',
|
||||
turn_id: 'x1'
|
||||
}).success
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts an AgentMessage with content omitted', () => {
|
||||
expect(
|
||||
zAgentMessage.safeParse({
|
||||
id: 'm1',
|
||||
thread_id: 't1',
|
||||
seq: 0,
|
||||
role: 'user',
|
||||
status: 'complete',
|
||||
turn_id: 'x1'
|
||||
}).success
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
208
src/workbench/extensions/agent/schemas/agentApiSchema.ts
Normal file
208
src/workbench/extensions/agent/schemas/agentApiSchema.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { zWorkflowListResponse } from '@comfyorg/ingest-types/zod'
|
||||
import { z } from 'zod'
|
||||
|
||||
const zTurnId = z.string().brand<'TurnId'>()
|
||||
export type TurnId = z.infer<typeof zTurnId>
|
||||
|
||||
export const zAgentTurnAccepted = z
|
||||
.object({
|
||||
thread_id: z.string(),
|
||||
message_id: z.string(),
|
||||
workflow_id: z.string().optional()
|
||||
})
|
||||
.passthrough()
|
||||
export type AgentTurnAccepted = z.infer<typeof zAgentTurnAccepted>
|
||||
|
||||
export const zAgentMessage = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
thread_id: z.string(),
|
||||
seq: z.number().int(),
|
||||
role: z.enum(['user', 'assistant', 'tool', 'system']),
|
||||
status: z.enum(['streaming', 'complete', 'error', 'interrupted']),
|
||||
turn_id: z.string(),
|
||||
content: z.record(z.string(), z.unknown()).optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
export const zAgentMessages = z.array(zAgentMessage)
|
||||
export type AgentMessages = z.infer<typeof zAgentMessages>
|
||||
|
||||
const zAgentThreadSummary = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
preview: z.string().optional(),
|
||||
last_message_at: z.string().optional(),
|
||||
updated_at: z.string().optional(),
|
||||
created_at: z.string().optional()
|
||||
})
|
||||
.passthrough()
|
||||
export type AgentThreadSummary = z.infer<typeof zAgentThreadSummary>
|
||||
|
||||
export const zAgentThreads = z
|
||||
.object({ threads: z.array(zAgentThreadSummary) })
|
||||
.passthrough()
|
||||
|
||||
export const zCloudWorkflowIndex = zWorkflowListResponse
|
||||
.pick({ pagination: true })
|
||||
.extend({
|
||||
data: z.array(
|
||||
z.object({ id: z.string(), name: z.string().optional() }).passthrough()
|
||||
)
|
||||
})
|
||||
export type CloudWorkflowEntry = z.infer<
|
||||
typeof zCloudWorkflowIndex
|
||||
>['data'][number]
|
||||
|
||||
export const zAgentCancelAccepted = z.object({
|
||||
status: z.literal('cancelling')
|
||||
})
|
||||
export type AgentCancelAccepted = z.infer<typeof zAgentCancelAccepted>
|
||||
|
||||
export const zAgentDraftSnapshot = z.object({
|
||||
content: z.record(z.string(), z.unknown()),
|
||||
version: z.number().int()
|
||||
})
|
||||
export type AgentDraftSnapshot = z.infer<typeof zAgentDraftSnapshot>
|
||||
|
||||
export const zAgentError = z.object({
|
||||
error: z.string()
|
||||
})
|
||||
|
||||
export const zUploadImageResult = z.object({
|
||||
name: z.string(),
|
||||
subfolder: z.string(),
|
||||
type: z.string()
|
||||
})
|
||||
export type UploadImageResult = z.infer<typeof zUploadImageResult>
|
||||
|
||||
const zAgentThinkingData = z
|
||||
.object({
|
||||
delta: z.string(),
|
||||
message_id: z.string(),
|
||||
thread_id: z.string()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const zAgentToolCallData = z
|
||||
.object({
|
||||
tool_name: z.string(),
|
||||
status: z.string(),
|
||||
args: z.array(z.string()),
|
||||
message_id: z.string(),
|
||||
thread_id: z.string()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const zDraftPatchData = z
|
||||
.object({
|
||||
base_version: z.number().int(),
|
||||
version: z.number().int(),
|
||||
content: z.record(z.string(), z.unknown()),
|
||||
message_id: z.string().optional(),
|
||||
thread_id: z.string().optional(),
|
||||
workflow_id: z.string()
|
||||
})
|
||||
.passthrough()
|
||||
export type DraftPatchData = z.infer<typeof zDraftPatchData>
|
||||
|
||||
const zAgentMessageDeltaData = z
|
||||
.object({
|
||||
delta: z.string(),
|
||||
message_id: z.string(),
|
||||
thread_id: z.string()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const zAgentMessageDoneData = z
|
||||
.object({
|
||||
message_id: z.string(),
|
||||
thread_id: z.string(),
|
||||
usage: z.unknown().nullish()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const zDraftVersionData = z
|
||||
.object({
|
||||
version: z.number().int(),
|
||||
workflow_id: z.string()
|
||||
})
|
||||
.passthrough()
|
||||
export type DraftVersionData = z.infer<typeof zDraftVersionData>
|
||||
|
||||
const zAgentActiveTabData = z
|
||||
.object({
|
||||
workflow_id: z.string(),
|
||||
name: z.string().optional(),
|
||||
thread_id: z.string().optional(),
|
||||
message_id: z.string().optional()
|
||||
})
|
||||
.passthrough()
|
||||
export type AgentActiveTabData = z.infer<typeof zAgentActiveTabData>
|
||||
|
||||
const zAgentThinkingEvent = z.object({
|
||||
type: z.literal('agent_thinking'),
|
||||
data: zAgentThinkingData
|
||||
})
|
||||
|
||||
const zAgentToolCallEvent = z.object({
|
||||
type: z.literal('agent_tool_call'),
|
||||
data: zAgentToolCallData
|
||||
})
|
||||
|
||||
const zDraftPatchEvent = z.object({
|
||||
type: z.literal('draft_patch'),
|
||||
data: zDraftPatchData
|
||||
})
|
||||
|
||||
const zAgentMessageDeltaEvent = z.object({
|
||||
type: z.literal('agent_message_delta'),
|
||||
data: zAgentMessageDeltaData
|
||||
})
|
||||
|
||||
const zAgentMessageDoneEvent = z.object({
|
||||
type: z.literal('agent_message_done'),
|
||||
data: zAgentMessageDoneData
|
||||
})
|
||||
|
||||
const zDraftVersionEvent = z.object({
|
||||
type: z.literal('draft_version'),
|
||||
data: zDraftVersionData
|
||||
})
|
||||
|
||||
const zAgentActiveTabEvent = z.object({
|
||||
type: z.literal('agent_active_tab'),
|
||||
data: zAgentActiveTabData
|
||||
})
|
||||
|
||||
export const zAgentWsEvent = z.discriminatedUnion('type', [
|
||||
zAgentThinkingEvent,
|
||||
zAgentToolCallEvent,
|
||||
zDraftPatchEvent,
|
||||
zAgentMessageDeltaEvent,
|
||||
zAgentMessageDoneEvent,
|
||||
zDraftVersionEvent,
|
||||
zAgentActiveTabEvent
|
||||
])
|
||||
export type AgentWsEvent = z.infer<typeof zAgentWsEvent>
|
||||
|
||||
export const AGENT_WS_EVENT_TYPES: ReadonlySet<string> = new Set([
|
||||
'agent_thinking',
|
||||
'agent_tool_call',
|
||||
'draft_patch',
|
||||
'agent_message_delta',
|
||||
'agent_message_done',
|
||||
'draft_version',
|
||||
'agent_active_tab'
|
||||
])
|
||||
|
||||
export function isAgentEvent(type: string): boolean {
|
||||
return AGENT_WS_EVENT_TYPES.has(type)
|
||||
}
|
||||
|
||||
export function parseAgentWsEvent(
|
||||
value: unknown
|
||||
): z.SafeParseReturnType<unknown, AgentWsEvent> {
|
||||
return zAgentWsEvent.safeParse(value)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { AGENT_WS_EVENT_TYPES } from '../../schemas/agentApiSchema'
|
||||
|
||||
import type { AgentEventHost } from './agentEventSource'
|
||||
import { createAgentEventSource } from './agentEventSource'
|
||||
|
||||
function fakeHost(readyState?: number) {
|
||||
const target = new EventTarget()
|
||||
const registered = new Set<string>()
|
||||
const host: AgentEventHost = {
|
||||
socket: readyState === undefined ? null : { readyState },
|
||||
addCustomEventListener(type, listener) {
|
||||
registered.add(type)
|
||||
target.addEventListener(type, listener as EventListener)
|
||||
},
|
||||
removeCustomEventListener(type, listener) {
|
||||
registered.delete(type)
|
||||
target.removeEventListener(type, listener as EventListener)
|
||||
},
|
||||
addEventListener(type, listener) {
|
||||
target.addEventListener(type, listener)
|
||||
},
|
||||
removeEventListener(type, listener) {
|
||||
target.removeEventListener(type, listener)
|
||||
}
|
||||
}
|
||||
const emit = (type: string, data?: unknown): void => {
|
||||
target.dispatchEvent(new CustomEvent(type, { detail: data }))
|
||||
}
|
||||
return { host, emit, registered }
|
||||
}
|
||||
|
||||
describe('createAgentEventSource', () => {
|
||||
it('registers every agent event type and delivers frames as {type, data}', () => {
|
||||
const { host, emit, registered } = fakeHost()
|
||||
const seen = vi.fn()
|
||||
|
||||
createAgentEventSource(host).subscribe(seen)
|
||||
|
||||
expect(registered).toEqual(new Set(AGENT_WS_EVENT_TYPES))
|
||||
|
||||
emit('agent_message_delta', { delta: 'hi' })
|
||||
expect(seen).toHaveBeenCalledWith({
|
||||
type: 'agent_message_delta',
|
||||
data: { delta: 'hi' }
|
||||
})
|
||||
})
|
||||
|
||||
it('stops delivering after unsubscribe', () => {
|
||||
const { host, emit, registered } = fakeHost()
|
||||
const seen = vi.fn()
|
||||
|
||||
const unsubscribe = createAgentEventSource(host).subscribe(seen)
|
||||
unsubscribe()
|
||||
|
||||
emit('agent_message_delta', { delta: 'late' })
|
||||
expect(seen).not.toHaveBeenCalled()
|
||||
expect(registered.size).toBe(0)
|
||||
})
|
||||
|
||||
it('maps reconnecting/reconnected to liveness', () => {
|
||||
const { host, emit } = fakeHost()
|
||||
const status = vi.fn()
|
||||
|
||||
createAgentEventSource(host).onStatus?.(status)
|
||||
expect(status).not.toHaveBeenCalled()
|
||||
|
||||
emit('reconnecting')
|
||||
expect(status).toHaveBeenLastCalledWith(false)
|
||||
|
||||
emit('reconnected')
|
||||
expect(status).toHaveBeenLastCalledWith(true)
|
||||
})
|
||||
|
||||
it('reports live once when the socket is already open at bind time', () => {
|
||||
const { host } = fakeHost(WebSocket.OPEN)
|
||||
const status = vi.fn()
|
||||
|
||||
createAgentEventSource(host).onStatus?.(status)
|
||||
|
||||
expect(status).toHaveBeenCalledTimes(1)
|
||||
expect(status).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('stays quiet for a connecting socket and after status unsubscribe', () => {
|
||||
const { host, emit } = fakeHost(WebSocket.CONNECTING)
|
||||
const status = vi.fn()
|
||||
|
||||
const unsubscribe = createAgentEventSource(host).onStatus?.(status)
|
||||
expect(status).not.toHaveBeenCalled()
|
||||
|
||||
unsubscribe?.()
|
||||
emit('reconnected')
|
||||
expect(status).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { AgentEventSource } from '../../composables/agent/useAgentSession'
|
||||
import { AGENT_WS_EVENT_TYPES } from '../../schemas/agentApiSchema'
|
||||
|
||||
export interface AgentEventHost {
|
||||
socket: { readyState: number } | null
|
||||
addCustomEventListener(
|
||||
type: string,
|
||||
listener: (event: CustomEvent<unknown>) => void
|
||||
): void
|
||||
removeCustomEventListener(
|
||||
type: string,
|
||||
listener: (event: CustomEvent<unknown>) => void
|
||||
): void
|
||||
addEventListener(
|
||||
type: 'reconnecting' | 'reconnected',
|
||||
listener: () => void
|
||||
): void
|
||||
removeEventListener(
|
||||
type: 'reconnecting' | 'reconnected',
|
||||
listener: () => void
|
||||
): void
|
||||
}
|
||||
|
||||
const OPEN = 1
|
||||
|
||||
export function createAgentEventSource(host: AgentEventHost): AgentEventSource {
|
||||
return {
|
||||
subscribe(listener) {
|
||||
const unbinders = [...AGENT_WS_EVENT_TYPES].map((type) => {
|
||||
const onEvent = (event: CustomEvent<unknown>): void =>
|
||||
listener({ type, data: event.detail })
|
||||
host.addCustomEventListener(type, onEvent)
|
||||
return () => host.removeCustomEventListener(type, onEvent)
|
||||
})
|
||||
return () => unbinders.forEach((unbind) => unbind())
|
||||
},
|
||||
onStatus(listener) {
|
||||
const onDown = (): void => listener(false)
|
||||
const onUp = (): void => listener(true)
|
||||
host.addEventListener('reconnecting', onDown)
|
||||
host.addEventListener('reconnected', onUp)
|
||||
if (host.socket?.readyState === OPEN) listener(true)
|
||||
return () => {
|
||||
host.removeEventListener('reconnecting', onDown)
|
||||
host.removeEventListener('reconnected', onUp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { AgentWsEvent, TurnId } from '../../schemas/agentApiSchema'
|
||||
import { zAgentWsEvent } from '../../schemas/agentApiSchema'
|
||||
|
||||
import type { AgentChatEvent } from './agentEventTransport'
|
||||
import { createAgentEventTransport } from './agentEventTransport'
|
||||
import type { AssistantMessage, TextPart, ToolPart } from './agentMessageParts'
|
||||
import { createAssistantMessage } from './agentMessageParts'
|
||||
|
||||
const fixtureText = import.meta.glob(
|
||||
'../../schemas/__fixtures__/agent/*.jsonl',
|
||||
{ query: '?raw', import: 'default', eager: true }
|
||||
) as Record<string, string>
|
||||
|
||||
function fixtureFor(name: string): string {
|
||||
const path = Object.keys(fixtureText).find((p) => p.endsWith(`/${name}`))
|
||||
if (!path) throw new Error(`fixture not found: ${name}`)
|
||||
return fixtureText[path]
|
||||
}
|
||||
|
||||
interface WsLine {
|
||||
frame: unknown
|
||||
}
|
||||
|
||||
function chatEventsFor(fixture: string, messageId: string): AgentChatEvent[] {
|
||||
const events: AgentChatEvent[] = []
|
||||
for (const line of fixtureFor(fixture).split('\n')) {
|
||||
if (!line.trim()) continue
|
||||
const { frame } = JSON.parse(line) as WsLine
|
||||
const parsed = zAgentWsEvent.safeParse(frame)
|
||||
if (!parsed.success) continue
|
||||
const event = parsed.data
|
||||
if (!isChatEvent(event)) continue
|
||||
if (event.data.message_id !== messageId) continue
|
||||
events.push(event)
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
function isChatEvent(event: AgentWsEvent): event is AgentChatEvent {
|
||||
return (
|
||||
event.type === 'agent_thinking' ||
|
||||
event.type === 'agent_tool_call' ||
|
||||
event.type === 'agent_message_delta' ||
|
||||
event.type === 'agent_message_done'
|
||||
)
|
||||
}
|
||||
|
||||
const T = 't1' as TurnId
|
||||
|
||||
function drive(events: AgentChatEvent[]): AssistantMessage {
|
||||
const message = createAssistantMessage(T)
|
||||
const emit = vi.fn<(m: AssistantMessage) => void>()
|
||||
const transport = createAgentEventTransport(message, emit)
|
||||
for (const event of events) transport.ingest(event)
|
||||
return emit.mock.calls.at(-1)?.[0] ?? message
|
||||
}
|
||||
|
||||
function thinking(delta: string): AgentChatEvent {
|
||||
return {
|
||||
type: 'agent_thinking',
|
||||
data: { delta, message_id: 'm', thread_id: 't' }
|
||||
}
|
||||
}
|
||||
|
||||
function toolCall(tool_name: string, status: string): AgentChatEvent {
|
||||
return {
|
||||
type: 'agent_tool_call',
|
||||
data: { tool_name, status, args: [], message_id: 'm', thread_id: 't' }
|
||||
}
|
||||
}
|
||||
|
||||
function delta(text: string): AgentChatEvent {
|
||||
return {
|
||||
type: 'agent_message_delta',
|
||||
data: { delta: text, message_id: 'm', thread_id: 't' }
|
||||
}
|
||||
}
|
||||
|
||||
const parts = (m: AssistantMessage) => m.parts
|
||||
const toolParts = (m: AssistantMessage): ToolPart[] =>
|
||||
m.parts.filter((p): p is ToolPart => p.type === 'tool')
|
||||
const textParts = (m: AssistantMessage): TextPart[] =>
|
||||
m.parts.filter((p): p is TextPart => p.type === 'text')
|
||||
|
||||
describe('agentEventTransport fixture replay', () => {
|
||||
it('ws-turn-edit: four settled tools then the reply text', () => {
|
||||
const events = chatEventsFor(
|
||||
'ws-turn-edit.jsonl',
|
||||
'172a6ede-7ab7-4b01-83b6-5b15f66dee4b'
|
||||
)
|
||||
const replyText = events
|
||||
.filter((e) => e.type === 'agent_message_delta')
|
||||
.map((e) => (e.type === 'agent_message_delta' ? e.data.delta : ''))
|
||||
.join('')
|
||||
|
||||
const message = drive(events)
|
||||
|
||||
expect(
|
||||
toolParts(message).map((p) => ({
|
||||
name: p.name,
|
||||
ok: p.ok,
|
||||
state: p.state
|
||||
}))
|
||||
).toEqual([
|
||||
{ name: 'list_slots', ok: true, state: 'done' },
|
||||
{ name: 'ls_nodes', ok: true, state: 'done' },
|
||||
{ name: 'set_widget', ok: true, state: 'done' },
|
||||
{ name: 'set_widget', ok: true, state: 'done' }
|
||||
])
|
||||
const texts = textParts(message)
|
||||
expect(texts).toHaveLength(1)
|
||||
expect(texts[0]).toMatchObject({ text: replyText, state: 'done' })
|
||||
expect(parts(message).at(-1)).toBe(texts[0])
|
||||
expect(message.streaming).toBe(false)
|
||||
expect(message.thinking).toBe(false)
|
||||
})
|
||||
|
||||
it('ws-turn-cancelled: one reply text part, settled', () => {
|
||||
const events = chatEventsFor(
|
||||
'ws-turn-cancelled.jsonl',
|
||||
'5d7c81a9-31f5-42f8-81c0-7525473da046'
|
||||
)
|
||||
const message = drive(events)
|
||||
|
||||
const texts = textParts(message)
|
||||
expect(texts).toHaveLength(1)
|
||||
expect(texts[0]).toMatchObject({
|
||||
text: 'Stopped at your request.',
|
||||
state: 'done'
|
||||
})
|
||||
expect(toolParts(message)).toHaveLength(0)
|
||||
expect(message.streaming).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentEventTransport thinking chip', () => {
|
||||
it('thinking before any text flips the chip and creates no part', () => {
|
||||
const message = drive([thinking('planning')])
|
||||
expect(message.thinking).toBe(true)
|
||||
expect(message.parts).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('thinking after text does not re-flip the chip', () => {
|
||||
const message = drive([delta('hello'), thinking('second thought')])
|
||||
expect(message.thinking).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentEventTransport text and tool parts', () => {
|
||||
it('two deltas append into one text part', () => {
|
||||
const message = drive([delta('foo '), delta('bar')])
|
||||
const texts = textParts(message)
|
||||
expect(texts).toHaveLength(1)
|
||||
expect(texts[0].text).toBe('foo bar')
|
||||
})
|
||||
|
||||
it('a delta with no prior thinking opens a text part directly', () => {
|
||||
const message = drive([delta('hi')])
|
||||
expect(textParts(message)).toHaveLength(1)
|
||||
expect(textParts(message)[0].text).toBe('hi')
|
||||
})
|
||||
|
||||
it('delta -> tool -> delta yields text, tool, text as three parts', () => {
|
||||
const message = drive([
|
||||
delta('before'),
|
||||
toolCall('run', 'ok'),
|
||||
delta('after')
|
||||
])
|
||||
expect(parts(message).map((p) => p.type)).toEqual(['text', 'tool', 'text'])
|
||||
expect(textParts(message).map((p) => p.text)).toEqual(['before', 'after'])
|
||||
})
|
||||
|
||||
it('a tool with status error settles ok false, state done', () => {
|
||||
const message = drive([toolCall('run', 'error')])
|
||||
expect(toolParts(message)[0]).toMatchObject({
|
||||
state: 'done',
|
||||
ok: false
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentEventTransport settle lifecycle', () => {
|
||||
it('settle mid-stream closes the open text part and clears streaming', () => {
|
||||
const message = createAssistantMessage(T)
|
||||
const emit = vi.fn<(m: AssistantMessage) => void>()
|
||||
const transport = createAgentEventTransport(message, emit)
|
||||
transport.ingest(delta('partial'))
|
||||
transport.settle()
|
||||
const final = emit.mock.calls.at(-1)?.[0] ?? message
|
||||
expect(textParts(final)[0]).toMatchObject({
|
||||
text: 'partial',
|
||||
state: 'done'
|
||||
})
|
||||
expect(final.streaming).toBe(false)
|
||||
expect(final.thinking).toBe(false)
|
||||
})
|
||||
|
||||
it('a second settle is a no-op after the first', () => {
|
||||
const message = createAssistantMessage(T)
|
||||
const emit = vi.fn<(m: AssistantMessage) => void>()
|
||||
const transport = createAgentEventTransport(message, emit)
|
||||
transport.settle()
|
||||
const callsAfterFirst = emit.mock.calls.length
|
||||
transport.settle()
|
||||
expect(emit.mock.calls.length).toBe(callsAfterFirst)
|
||||
})
|
||||
|
||||
it('events arriving after settle are ignored', () => {
|
||||
const message = createAssistantMessage(T)
|
||||
const emit = vi.fn<(m: AssistantMessage) => void>()
|
||||
const transport = createAgentEventTransport(message, emit)
|
||||
transport.ingest(delta('final'))
|
||||
transport.settle()
|
||||
const callsAfterSettle = emit.mock.calls.length
|
||||
|
||||
transport.ingest(delta(' late'))
|
||||
transport.ingest(toolCall('late_tool', 'ok'))
|
||||
|
||||
expect(emit.mock.calls.length).toBe(callsAfterSettle)
|
||||
expect(textParts(message)[0]).toMatchObject({
|
||||
text: 'final',
|
||||
state: 'done'
|
||||
})
|
||||
expect(toolParts(message)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { AgentWsEvent } from '../../schemas/agentApiSchema'
|
||||
|
||||
import type { AssistantMessage, TextPart, ToolPart } from './agentMessageParts'
|
||||
import { snapshotMessage } from './agentMessageParts'
|
||||
|
||||
export type AgentChatEvent = Extract<
|
||||
AgentWsEvent,
|
||||
{
|
||||
type:
|
||||
| 'agent_thinking'
|
||||
| 'agent_tool_call'
|
||||
| 'agent_message_delta'
|
||||
| 'agent_message_done'
|
||||
}
|
||||
>
|
||||
|
||||
export interface AgentEventTransport {
|
||||
ingest: (event: AgentChatEvent) => void
|
||||
settle: () => void
|
||||
}
|
||||
|
||||
export function createAgentEventTransport(
|
||||
message: AssistantMessage,
|
||||
emit: (m: AssistantMessage) => void
|
||||
): AgentEventTransport {
|
||||
let openText: TextPart | null = null
|
||||
let gotText = false
|
||||
let toolCount = 0
|
||||
let settled = false
|
||||
|
||||
function closeOpenText(): void {
|
||||
if (openText) {
|
||||
openText.state = 'done'
|
||||
openText = null
|
||||
}
|
||||
}
|
||||
|
||||
function openNewText(): TextPart {
|
||||
const part: TextPart = { type: 'text', text: '', state: 'streaming' }
|
||||
message.parts.push(part)
|
||||
openText = part
|
||||
return part
|
||||
}
|
||||
|
||||
function ingest(event: AgentChatEvent): void {
|
||||
if (settled) return
|
||||
switch (event.type) {
|
||||
case 'agent_thinking':
|
||||
if (!gotText) message.thinking = true
|
||||
break
|
||||
case 'agent_tool_call': {
|
||||
closeOpenText()
|
||||
const part: ToolPart = {
|
||||
type: 'tool',
|
||||
callId: `tool_${toolCount++}`,
|
||||
name: event.data.tool_name,
|
||||
state: 'done',
|
||||
ok: event.data.status === 'ok'
|
||||
}
|
||||
message.parts.push(part)
|
||||
break
|
||||
}
|
||||
case 'agent_message_delta':
|
||||
message.thinking = false
|
||||
gotText = true
|
||||
;(openText ?? openNewText()).text += event.data.delta
|
||||
break
|
||||
case 'agent_message_done':
|
||||
settle()
|
||||
return
|
||||
}
|
||||
emit(snapshotMessage(message))
|
||||
}
|
||||
|
||||
function settle(): void {
|
||||
if (settled) return
|
||||
settled = true
|
||||
closeOpenText()
|
||||
message.thinking = false
|
||||
message.streaming = false
|
||||
emit(snapshotMessage(message))
|
||||
}
|
||||
|
||||
return { ingest, settle }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { TurnId } from '../../schemas/agentApiSchema'
|
||||
|
||||
export type PartState = 'streaming' | 'done'
|
||||
|
||||
export interface TextPart {
|
||||
type: 'text'
|
||||
text: string
|
||||
state: PartState
|
||||
}
|
||||
|
||||
export interface ToolPart {
|
||||
type: 'tool'
|
||||
callId: string
|
||||
name: string
|
||||
state: PartState
|
||||
ok?: boolean
|
||||
}
|
||||
|
||||
export interface NoticePart {
|
||||
type: 'notice'
|
||||
level: 'info' | 'warning' | 'error'
|
||||
text: string
|
||||
}
|
||||
|
||||
type MessagePart = TextPart | ToolPart | NoticePart
|
||||
|
||||
export interface AssistantMessage {
|
||||
id: TurnId
|
||||
role: 'assistant'
|
||||
parts: MessagePart[]
|
||||
streaming: boolean
|
||||
thinking: boolean
|
||||
}
|
||||
|
||||
export function createAssistantMessage(id: TurnId): AssistantMessage {
|
||||
return {
|
||||
id,
|
||||
role: 'assistant',
|
||||
parts: [],
|
||||
streaming: true,
|
||||
thinking: false
|
||||
}
|
||||
}
|
||||
|
||||
export function snapshotMessage(message: AssistantMessage): AssistantMessage {
|
||||
return { ...message, parts: [...message.parts] }
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const fetchApi = vi.hoisted(() =>
|
||||
vi.fn<(route: string, init?: RequestInit) => Promise<Response>>()
|
||||
)
|
||||
vi.mock('@/scripts/api', () => ({ api: { fetchApi } }))
|
||||
|
||||
import { AgentApiError, createAgentRestClient } from './agentRestClient'
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
function respond(response: Response) {
|
||||
fetchApi.mockResolvedValueOnce(response)
|
||||
}
|
||||
|
||||
function lastCall(): { route: string; init: RequestInit } {
|
||||
const [route, init] = fetchApi.mock.calls.at(-1)!
|
||||
return { route, init: init ?? {} }
|
||||
}
|
||||
|
||||
function contentType(init: RequestInit): string | undefined {
|
||||
return (init.headers as Record<string, string> | undefined)?.['Content-Type']
|
||||
}
|
||||
|
||||
const makeClient = createAgentRestClient
|
||||
|
||||
const turnAccepted = {
|
||||
message_id: 'm1',
|
||||
thread_id: 't1',
|
||||
workflow_id: 'w1'
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchApi.mockReset()
|
||||
})
|
||||
|
||||
describe('agentRestClient route + method', () => {
|
||||
it('postMessage targets the literal "new" thread path to open a thread', async () => {
|
||||
respond(jsonResponse(202, turnAccepted))
|
||||
await makeClient().postMessage('new', { content: 'hi' })
|
||||
|
||||
const { route, init } = lastCall()
|
||||
expect(route).toBe('/agent/threads/new/messages')
|
||||
expect(init.method).toBe('POST')
|
||||
})
|
||||
|
||||
it('getMessages GETs the thread messages path', async () => {
|
||||
respond(jsonResponse(200, []))
|
||||
await makeClient().getMessages('t7')
|
||||
|
||||
const { route, init } = lastCall()
|
||||
expect(route).toBe('/agent/threads/t7/messages')
|
||||
expect(init.method).toBe('GET')
|
||||
})
|
||||
|
||||
it('cancelMessage POSTs the cancel path with an empty JSON body', async () => {
|
||||
respond(jsonResponse(202, { status: 'cancelling' }))
|
||||
await makeClient().cancelMessage('t7', 'm3')
|
||||
|
||||
const { route, init } = lastCall()
|
||||
expect(route).toBe('/agent/threads/t7/messages/m3/cancel')
|
||||
expect(init.method).toBe('POST')
|
||||
expect(init.body).toBe('{}')
|
||||
})
|
||||
|
||||
it('listCloudWorkflows GETs the paginated workflows path until has_more is false', async () => {
|
||||
const page = (data: unknown[], hasMore: boolean) =>
|
||||
jsonResponse(200, {
|
||||
data,
|
||||
pagination: {
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
total: data.length,
|
||||
has_more: hasMore
|
||||
}
|
||||
})
|
||||
respond(page([{ id: 'wf-1', name: 'one' }], true))
|
||||
respond(page([{ id: 'wf-2', name: 'two' }], false))
|
||||
|
||||
const workflows = await makeClient().listCloudWorkflows()
|
||||
|
||||
expect(fetchApi.mock.calls[0][0]).toBe('/workflows?limit=100&offset=0')
|
||||
expect(fetchApi.mock.calls[1][0]).toBe('/workflows?limit=100&offset=100')
|
||||
expect(workflows.map((w) => w.id)).toEqual(['wf-1', 'wf-2'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDraft query encoding', () => {
|
||||
it('encodes a workflow id containing a space', async () => {
|
||||
respond(jsonResponse(200, { content: {}, version: 1 }))
|
||||
await makeClient().getDraft('my workflow')
|
||||
|
||||
expect(lastCall().route).toBe('/agent/draft?workflow_id=my%20workflow')
|
||||
})
|
||||
|
||||
it('encodes a workflow id containing a slash', async () => {
|
||||
respond(jsonResponse(200, { content: {}, version: 1 }))
|
||||
await makeClient().getDraft('a/b')
|
||||
|
||||
expect(lastCall().route).toBe('/agent/draft?workflow_id=a%2Fb')
|
||||
})
|
||||
})
|
||||
|
||||
describe('postMessage wire body', () => {
|
||||
it('uses snake_case workflow_id and includes only the keys provided', async () => {
|
||||
respond(jsonResponse(202, turnAccepted))
|
||||
await makeClient().postMessage('t1', {
|
||||
content: 'build it',
|
||||
workflowId: 'wf-9',
|
||||
selection: { nodeId: 3 },
|
||||
attachments: ['a1']
|
||||
})
|
||||
|
||||
const { init } = lastCall()
|
||||
const parsed = JSON.parse(init.body as string) as Record<string, unknown>
|
||||
expect(parsed).toEqual({
|
||||
content: 'build it',
|
||||
workflow_id: 'wf-9',
|
||||
selection: { nodeId: 3 },
|
||||
attachments: ['a1']
|
||||
})
|
||||
expect(contentType(init)).toBe('application/json')
|
||||
})
|
||||
|
||||
it('omits absent optionals rather than sending them as undefined keys', async () => {
|
||||
respond(jsonResponse(202, turnAccepted))
|
||||
await makeClient().postMessage('t1', { content: 'just text' })
|
||||
|
||||
const parsed = JSON.parse(lastCall().init.body as string) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
expect(Object.keys(parsed)).toEqual(['content'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('uploadImage multipart', () => {
|
||||
it('posts FormData with the blob appended under "image" with the filename, no manual Content-Type', async () => {
|
||||
respond(jsonResponse(200, { name: 'x.png', subfolder: '', type: 'input' }))
|
||||
const appendSpy = vi.spyOn(FormData.prototype, 'append')
|
||||
const blob = new Blob(['bytes'], { type: 'image/png' })
|
||||
await makeClient().uploadImage(blob, 'x.png')
|
||||
|
||||
const { route, init } = lastCall()
|
||||
expect(route).toBe('/upload/image')
|
||||
expect(init.method).toBe('POST')
|
||||
expect(init.body).toBeInstanceOf(FormData)
|
||||
expect(appendSpy).toHaveBeenCalledWith('image', blob, 'x.png')
|
||||
expect(contentType(init)).toBeUndefined()
|
||||
appendSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('success response parsing', () => {
|
||||
it('parses the postMessage 202 through zAgentTurnAccepted, keeping extra workflow_id', async () => {
|
||||
respond(jsonResponse(202, turnAccepted))
|
||||
|
||||
const result = await makeClient().postMessage('t1', { content: 'hi' })
|
||||
|
||||
expect(result.message_id).toBe('m1')
|
||||
expect(result.thread_id).toBe('t1')
|
||||
expect((result as Record<string, unknown>).workflow_id).toBe('w1')
|
||||
})
|
||||
|
||||
it('parses a getDraft 200 snapshot', async () => {
|
||||
respond(jsonResponse(200, { content: { nodes: [] }, version: 24 }))
|
||||
|
||||
const result = await makeClient().getDraft('wf-1')
|
||||
|
||||
expect(result.version).toBe(24)
|
||||
expect(result.content).toEqual({ nodes: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('error mapping', () => {
|
||||
it('maps a plain-string error body to its message with the status and parsed body', async () => {
|
||||
respond(jsonResponse(409, { error: 'turn is not running' }))
|
||||
|
||||
const error = await makeClient()
|
||||
.cancelMessage('t1', 'm1')
|
||||
.catch((e: unknown) => e)
|
||||
|
||||
expect(error).toBeInstanceOf(AgentApiError)
|
||||
const apiError = error as AgentApiError
|
||||
expect(apiError.message).toBe('turn is not running')
|
||||
expect(apiError.status).toBe(409)
|
||||
expect(apiError.body).toEqual({ error: 'turn is not running' })
|
||||
})
|
||||
|
||||
it('reads the ingest-shaped {error:{message,type}} nested message', async () => {
|
||||
respond(
|
||||
jsonResponse(403, {
|
||||
error: { message: 'access denied', type: 'forbidden' }
|
||||
})
|
||||
)
|
||||
|
||||
const error = await makeClient()
|
||||
.getDraft('wf-x')
|
||||
.catch((e: unknown) => e)
|
||||
|
||||
expect((error as AgentApiError).message).toBe('access denied')
|
||||
expect((error as AgentApiError).status).toBe(403)
|
||||
})
|
||||
|
||||
it('falls back to statusText and undefined body for a non-JSON error response', async () => {
|
||||
respond(
|
||||
new Response('gateway boom', { status: 502, statusText: 'Bad Gateway' })
|
||||
)
|
||||
|
||||
const error = await makeClient()
|
||||
.getMessages('t1')
|
||||
.catch((e: unknown) => e)
|
||||
|
||||
const apiError = error as AgentApiError
|
||||
expect(apiError.message).toBe('Bad Gateway')
|
||||
expect(apiError.status).toBe(502)
|
||||
expect(apiError.body).toBeUndefined()
|
||||
})
|
||||
|
||||
it('throws zod when a success body violates the response schema (anti-drift)', async () => {
|
||||
respond(jsonResponse(200, { wrong: 'shape' }))
|
||||
|
||||
const error = await makeClient()
|
||||
.getDraft('wf-1')
|
||||
.catch((e: unknown) => e)
|
||||
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect(error).not.toBeInstanceOf(AgentApiError)
|
||||
})
|
||||
})
|
||||
216
src/workbench/extensions/agent/services/agent/agentRestClient.ts
Normal file
216
src/workbench/extensions/agent/services/agent/agentRestClient.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import type { z } from 'zod'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
import {
|
||||
zAgentCancelAccepted,
|
||||
zAgentDraftSnapshot,
|
||||
zAgentError,
|
||||
zAgentMessages,
|
||||
zAgentThreads,
|
||||
zAgentTurnAccepted,
|
||||
zCloudWorkflowIndex,
|
||||
zUploadImageResult
|
||||
} from '../../schemas/agentApiSchema'
|
||||
import type {
|
||||
AgentCancelAccepted,
|
||||
AgentDraftSnapshot,
|
||||
AgentMessages,
|
||||
AgentThreadSummary,
|
||||
AgentTurnAccepted,
|
||||
CloudWorkflowEntry,
|
||||
UploadImageResult
|
||||
} from '../../schemas/agentApiSchema'
|
||||
|
||||
const CLOUD_WORKFLOW_PAGE_SIZE = 100
|
||||
const CLOUD_WORKFLOW_MAX_PAGES = 5
|
||||
|
||||
export class AgentApiError extends Error {
|
||||
readonly status: number
|
||||
readonly body: unknown
|
||||
|
||||
constructor(message: string, status: number, body: unknown) {
|
||||
super(message)
|
||||
this.name = 'AgentApiError'
|
||||
this.status = status
|
||||
this.body = body
|
||||
}
|
||||
}
|
||||
|
||||
export interface DraftUpload {
|
||||
content: unknown
|
||||
version: number | null
|
||||
}
|
||||
|
||||
interface OpenTabEntry {
|
||||
workflow_id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface OpenTabsSnapshot {
|
||||
open_tabs: OpenTabEntry[]
|
||||
current_tab?: string
|
||||
}
|
||||
|
||||
export interface PostMessageInput {
|
||||
content: string
|
||||
workflowId?: string
|
||||
selection?: Record<string, unknown>
|
||||
attachments?: string[]
|
||||
draft?: DraftUpload
|
||||
tabs?: OpenTabsSnapshot
|
||||
}
|
||||
|
||||
interface IngestErrorBody {
|
||||
error: { message: string }
|
||||
}
|
||||
|
||||
function isIngestErrorBody(body: unknown): body is IngestErrorBody {
|
||||
if (typeof body !== 'object' || body === null) return false
|
||||
const { error } = body as { error?: unknown }
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
typeof (error as { message?: unknown }).message === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
export function createAgentRestClient() {
|
||||
async function toApiError(response: Response): Promise<AgentApiError> {
|
||||
const text = await response.text()
|
||||
let body: unknown
|
||||
try {
|
||||
body = text.length > 0 ? JSON.parse(text) : undefined
|
||||
} catch {
|
||||
body = undefined
|
||||
}
|
||||
const plain = zAgentError.safeParse(body)
|
||||
const message = plain.success
|
||||
? plain.data.error
|
||||
: isIngestErrorBody(body)
|
||||
? body.error.message
|
||||
: response.statusText
|
||||
return new AgentApiError(message, response.status, body)
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
route: string,
|
||||
init: RequestInit,
|
||||
schema: z.ZodType<T>
|
||||
): Promise<T> {
|
||||
const response = await api.fetchApi(route, init)
|
||||
if (!response.ok) throw await toApiError(response)
|
||||
return schema.parse(await response.json())
|
||||
}
|
||||
|
||||
function jsonInit(method: string, body: unknown): RequestInit {
|
||||
return {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
}
|
||||
}
|
||||
|
||||
async function postMessage(
|
||||
threadId: string,
|
||||
req: PostMessageInput
|
||||
): Promise<AgentTurnAccepted> {
|
||||
const body: Record<string, unknown> = { content: req.content }
|
||||
if (req.workflowId !== undefined) body.workflow_id = req.workflowId
|
||||
if (req.tabs !== undefined) {
|
||||
body.open_tabs = req.tabs.open_tabs
|
||||
if (req.tabs.current_tab !== undefined)
|
||||
body.current_tab = req.tabs.current_tab
|
||||
}
|
||||
if (req.selection !== undefined) body.selection = req.selection
|
||||
if (req.attachments !== undefined) body.attachments = req.attachments
|
||||
if (req.draft !== undefined) body.draft = req.draft
|
||||
return request(
|
||||
`/agent/threads/${threadId}/messages`,
|
||||
jsonInit('POST', body),
|
||||
zAgentTurnAccepted
|
||||
)
|
||||
}
|
||||
|
||||
async function getMessages(threadId: string): Promise<AgentMessages> {
|
||||
return request(
|
||||
`/agent/threads/${threadId}/messages`,
|
||||
{ method: 'GET' },
|
||||
zAgentMessages
|
||||
)
|
||||
}
|
||||
|
||||
async function listThreads(): Promise<AgentThreadSummary[]> {
|
||||
const page = await request(
|
||||
'/agent/threads',
|
||||
{ method: 'GET' },
|
||||
zAgentThreads
|
||||
)
|
||||
return page.threads
|
||||
}
|
||||
|
||||
async function listCloudWorkflows(): Promise<CloudWorkflowEntry[]> {
|
||||
const entries: CloudWorkflowEntry[] = []
|
||||
let hasMore = false
|
||||
for (let page = 0; page < CLOUD_WORKFLOW_MAX_PAGES; page++) {
|
||||
const result = await request(
|
||||
`/workflows?limit=${CLOUD_WORKFLOW_PAGE_SIZE}&offset=${page * CLOUD_WORKFLOW_PAGE_SIZE}`,
|
||||
{ method: 'GET' },
|
||||
zCloudWorkflowIndex
|
||||
)
|
||||
entries.push(...result.data)
|
||||
hasMore = result.pagination.has_more
|
||||
if (!hasMore) break
|
||||
}
|
||||
if (hasMore)
|
||||
console.warn(
|
||||
`[agent] cloud workflow index truncated at ${entries.length} entries`
|
||||
)
|
||||
return entries
|
||||
}
|
||||
|
||||
async function cancelMessage(
|
||||
threadId: string,
|
||||
messageId: string
|
||||
): Promise<AgentCancelAccepted> {
|
||||
return request(
|
||||
`/agent/threads/${threadId}/messages/${messageId}/cancel`,
|
||||
jsonInit('POST', {}),
|
||||
zAgentCancelAccepted
|
||||
)
|
||||
}
|
||||
|
||||
async function getDraft(workflowId: string): Promise<AgentDraftSnapshot> {
|
||||
const query = encodeURIComponent(workflowId)
|
||||
return request(
|
||||
`/agent/draft?workflow_id=${query}`,
|
||||
{ method: 'GET' },
|
||||
zAgentDraftSnapshot
|
||||
)
|
||||
}
|
||||
|
||||
async function uploadImage(
|
||||
image: Blob,
|
||||
filename: string
|
||||
): Promise<UploadImageResult> {
|
||||
const form = new FormData()
|
||||
form.append('image', image, filename)
|
||||
return request(
|
||||
'/upload/image',
|
||||
{ method: 'POST', body: form },
|
||||
zUploadImageResult
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
postMessage,
|
||||
getMessages,
|
||||
listThreads,
|
||||
listCloudWorkflows,
|
||||
cancelMessage,
|
||||
getDraft,
|
||||
uploadImage
|
||||
}
|
||||
}
|
||||
|
||||
export type AgentRestClient = ReturnType<typeof createAgentRestClient>
|
||||
@@ -0,0 +1,81 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ChatSession } from './agentChatHistoryStore'
|
||||
import {
|
||||
groupSessionsByRecency,
|
||||
useAgentChatHistoryStore
|
||||
} from './agentChatHistoryStore'
|
||||
|
||||
const NOW = new Date(2026, 2, 15, 12, 0, 0).getTime()
|
||||
const DAY = 86_400_000
|
||||
|
||||
const session = (id: string, updatedAt: number): ChatSession => ({
|
||||
id,
|
||||
title: id,
|
||||
updatedAt
|
||||
})
|
||||
|
||||
describe('groupSessionsByRecency', () => {
|
||||
it('buckets by recency, newest first, with the active session as Current', () => {
|
||||
const sessions = [
|
||||
session('now', NOW - 1_000),
|
||||
session('active', NOW - 5 * DAY),
|
||||
session('earlyToday', NOW - 6 * 3_600_000),
|
||||
session('yesterday', NOW - DAY),
|
||||
session('lastWeek', NOW - 4 * DAY)
|
||||
]
|
||||
const groups = groupSessionsByRecency(sessions, 'active', NOW)
|
||||
|
||||
expect(groups.current.map((s) => s.id)).toEqual(['active'])
|
||||
expect(groups.today.map((s) => s.id)).toEqual(['now', 'earlyToday'])
|
||||
expect(groups.yesterday.map((s) => s.id)).toEqual(['yesterday'])
|
||||
expect(groups.earlier.map((s) => s.id)).toEqual(['lastWeek'])
|
||||
})
|
||||
|
||||
it('places everything in earlier when nothing is recent and none is active', () => {
|
||||
const groups = groupSessionsByRecency(
|
||||
[session('old', NOW - 30 * DAY)],
|
||||
null,
|
||||
NOW
|
||||
)
|
||||
expect(groups.current).toHaveLength(0)
|
||||
expect(groups.earlier.map((s) => s.id)).toEqual(['old'])
|
||||
})
|
||||
|
||||
it('buckets the prior evening as yesterday across a spring-forward midnight', () => {
|
||||
const now = new Date(2026, 2, 8, 2, 30).getTime()
|
||||
const priorEvening = new Date(2026, 2, 7, 23, 30).getTime()
|
||||
const groups = groupSessionsByRecency(
|
||||
[session('priorEvening', priorEvening)],
|
||||
null,
|
||||
now
|
||||
)
|
||||
|
||||
expect(groups.yesterday.map((s) => s.id)).toEqual(['priorEvening'])
|
||||
expect(groups.earlier).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useAgentChatHistoryStore', () => {
|
||||
beforeEach(() => setActivePinia(createPinia()))
|
||||
|
||||
it('clears the active id when the active session is removed', () => {
|
||||
const store = useAgentChatHistoryStore()
|
||||
store.replaceAll([session('a', 1)])
|
||||
store.setActive('a')
|
||||
store.remove('a')
|
||||
|
||||
expect(store.activeId).toBeNull()
|
||||
expect(store.sessions).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps the active id when a different session is removed', () => {
|
||||
const store = useAgentChatHistoryStore()
|
||||
store.replaceAll([session('a', 1), session('b', 2)])
|
||||
store.setActive('a')
|
||||
store.remove('b')
|
||||
|
||||
expect(store.activeId).toBe('a')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useTimestamp } from '@vueuse/core'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
export interface ChatSession {
|
||||
id: string
|
||||
title: string
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface HistoryGroups {
|
||||
current: ChatSession[]
|
||||
today: ChatSession[]
|
||||
yesterday: ChatSession[]
|
||||
earlier: ChatSession[]
|
||||
}
|
||||
|
||||
function startOfLocalDay(now: number): number {
|
||||
const date = new Date(now)
|
||||
date.setHours(0, 0, 0, 0)
|
||||
return date.getTime()
|
||||
}
|
||||
|
||||
export function groupSessionsByRecency(
|
||||
sessions: ChatSession[],
|
||||
activeId: string | null,
|
||||
now: number
|
||||
): HistoryGroups {
|
||||
const startToday = startOfLocalDay(now)
|
||||
const startYesterday = startOfLocalDay(startToday - 1)
|
||||
const groups: HistoryGroups = {
|
||||
current: [],
|
||||
today: [],
|
||||
yesterday: [],
|
||||
earlier: []
|
||||
}
|
||||
const ordered = [...sessions].sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
for (const session of ordered) {
|
||||
if (session.id === activeId) groups.current.push(session)
|
||||
else if (session.updatedAt >= startToday) groups.today.push(session)
|
||||
else if (session.updatedAt >= startYesterday) groups.yesterday.push(session)
|
||||
else groups.earlier.push(session)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
export const useAgentChatHistoryStore = defineStore('agentChatHistory', () => {
|
||||
const sessions = ref<ChatSession[]>([])
|
||||
const activeId = ref<string | null>(null)
|
||||
const now = useTimestamp({ interval: 60_000 })
|
||||
|
||||
const grouped = computed(() =>
|
||||
groupSessionsByRecency(sessions.value, activeId.value, now.value)
|
||||
)
|
||||
|
||||
function remove(id: string): void {
|
||||
sessions.value = sessions.value.filter((item) => item.id !== id)
|
||||
if (activeId.value === id) activeId.value = null
|
||||
}
|
||||
|
||||
function replaceAll(next: ChatSession[]): void {
|
||||
sessions.value = next
|
||||
}
|
||||
|
||||
function setActive(id: string | null): void {
|
||||
activeId.value = id
|
||||
}
|
||||
|
||||
return {
|
||||
sessions,
|
||||
activeId,
|
||||
grouped,
|
||||
remove,
|
||||
replaceAll,
|
||||
setActive
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { TurnId } from '../../schemas/agentApiSchema'
|
||||
import { zAgentWsEvent } from '../../schemas/agentApiSchema'
|
||||
import type { AgentChatEvent } from '../../services/agent/agentEventTransport'
|
||||
import { useAgentConversationStore } from './agentConversationStore'
|
||||
|
||||
const done = (id: string): AgentChatEvent =>
|
||||
zAgentWsEvent.parse({
|
||||
type: 'agent_message_done',
|
||||
data: { message_id: id, thread_id: 'th', usage: null }
|
||||
}) as AgentChatEvent
|
||||
|
||||
const T1 = 't1' as TurnId
|
||||
const T2 = 't2' as TurnId
|
||||
|
||||
describe('agentConversationStore entries (user + assistant interleave)', () => {
|
||||
beforeEach(() => setActivePinia(createPinia()))
|
||||
|
||||
it('pairs each recorded user prompt before its assistant turn, in order', () => {
|
||||
const store = useAgentConversationStore()
|
||||
store.recordUser(T1, 'first prompt')
|
||||
store.startTurn(T1)
|
||||
store.ingest(done('t1'))
|
||||
store.recordUser(T2, 'second prompt')
|
||||
store.startTurn(T2)
|
||||
|
||||
const roles = store.entries.map((e) => e.role)
|
||||
expect(roles).toEqual(['user', 'assistant', 'user', 'assistant'])
|
||||
const first = store.entries[0]
|
||||
expect(first.role === 'user' && first.text).toBe('first prompt')
|
||||
})
|
||||
|
||||
it('renders an assistant turn with no recorded prompt as a bare assistant entry', () => {
|
||||
const store = useAgentConversationStore()
|
||||
store.startTurn(T1)
|
||||
expect(store.entries.map((e) => e.role)).toEqual(['assistant'])
|
||||
})
|
||||
|
||||
it('reset clears recorded user prompts too', () => {
|
||||
const store = useAgentConversationStore()
|
||||
store.recordUser(T1, 'gone')
|
||||
store.startTurn(T1)
|
||||
store.reset()
|
||||
expect(store.entries).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,205 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, watch } from 'vue'
|
||||
|
||||
import type { TurnId } from '../../schemas/agentApiSchema'
|
||||
import { zAgentWsEvent } from '../../schemas/agentApiSchema'
|
||||
import type { AgentChatEvent } from '../../services/agent/agentEventTransport'
|
||||
|
||||
import { useAgentConversationStore } from './agentConversationStore'
|
||||
|
||||
const chat = (raw: unknown): AgentChatEvent =>
|
||||
zAgentWsEvent.parse(raw) as AgentChatEvent
|
||||
const thinking = (id: string, delta: string): AgentChatEvent =>
|
||||
chat({
|
||||
type: 'agent_thinking',
|
||||
data: { delta, message_id: id, thread_id: 'th' }
|
||||
})
|
||||
const delta = (id: string, text: string): AgentChatEvent =>
|
||||
chat({
|
||||
type: 'agent_message_delta',
|
||||
data: { delta: text, message_id: id, thread_id: 'th' }
|
||||
})
|
||||
const toolCall = (id: string, name: string, status: string): AgentChatEvent =>
|
||||
chat({
|
||||
type: 'agent_tool_call',
|
||||
data: { tool_name: name, status, args: [], message_id: id, thread_id: 'th' }
|
||||
})
|
||||
const done = (id: string): AgentChatEvent =>
|
||||
chat({
|
||||
type: 'agent_message_done',
|
||||
data: { message_id: id, thread_id: 'th', usage: null }
|
||||
})
|
||||
|
||||
const T1 = 't1' as TurnId
|
||||
const T2 = 't2' as TurnId
|
||||
|
||||
describe('useAgentConversationStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('(M1) fires a deep watch on messages when a MID-turn delta event lands', async () => {
|
||||
const store = useAgentConversationStore()
|
||||
const spy = vi.fn()
|
||||
watch(() => store.messages, spy, { deep: true })
|
||||
|
||||
store.startTurn(T1)
|
||||
await nextTick()
|
||||
spy.mockClear()
|
||||
|
||||
store.ingest(delta('t1', 'streaming delta'))
|
||||
await nextTick()
|
||||
|
||||
expect(spy).toHaveBeenCalled()
|
||||
expect(store.messages[0].parts.map((p) => p.type)).toEqual(['text'])
|
||||
})
|
||||
|
||||
it('(M2) isStreaming is false after abortActiveTurn() with no done', () => {
|
||||
const store = useAgentConversationStore()
|
||||
store.startTurn(T1)
|
||||
store.ingest(delta('t1', 'half a th'))
|
||||
expect(store.isStreaming).toBe(true)
|
||||
|
||||
store.abortActiveTurn()
|
||||
|
||||
expect(store.isStreaming).toBe(false)
|
||||
expect(store.messages[0].streaming).toBe(false)
|
||||
expect(store.messages).toHaveLength(1)
|
||||
store.abortActiveTurn()
|
||||
expect(store.messages).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('settles the turn on done and reports idle', () => {
|
||||
const store = useAgentConversationStore()
|
||||
store.startTurn(T1)
|
||||
store.ingest(delta('t1', 'answer'))
|
||||
store.ingest(done('t1'))
|
||||
|
||||
expect(store.isStreaming).toBe(false)
|
||||
expect(store.status).toBe('idle')
|
||||
expect(store.activeTurnId).toBeNull()
|
||||
})
|
||||
|
||||
it('reports thinking vs streaming status', () => {
|
||||
const store = useAgentConversationStore()
|
||||
store.startTurn(T1)
|
||||
store.ingest(thinking('t1', 'planning'))
|
||||
expect(store.status).toBe('thinking')
|
||||
store.ingest(delta('t1', 'go'))
|
||||
expect(store.status).toBe('streaming')
|
||||
})
|
||||
|
||||
it('drops events for a foreign message_id (store owns turn filtering)', () => {
|
||||
const store = useAgentConversationStore()
|
||||
store.startTurn(T1)
|
||||
store.ingest(delta('t1', 'keep'))
|
||||
|
||||
store.ingest(delta('t2', 'DROP ME'))
|
||||
|
||||
const parts = store.messages[0].parts
|
||||
expect(parts).toHaveLength(1)
|
||||
expect(parts[0]).toMatchObject({ type: 'text', text: 'keep' })
|
||||
})
|
||||
|
||||
it('starting a new turn aborts a prior in-flight turn', () => {
|
||||
const store = useAgentConversationStore()
|
||||
store.startTurn(T1)
|
||||
store.ingest(delta('t1', 'unfinished'))
|
||||
|
||||
store.startTurn(T2)
|
||||
|
||||
expect(store.messages).toHaveLength(2)
|
||||
expect(store.messages[0].streaming).toBe(false)
|
||||
expect(store.messages[1].streaming).toBe(true)
|
||||
expect(store.activeTurnId).toBe(T2)
|
||||
})
|
||||
|
||||
it('ignores ingest with no active turn', () => {
|
||||
const store = useAgentConversationStore()
|
||||
store.ingest(delta('t1', 'orphan'))
|
||||
expect(store.messages).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('folds a tool_call into the active turn', () => {
|
||||
const store = useAgentConversationStore()
|
||||
store.startTurn(T1)
|
||||
store.ingest(toolCall('t1', 'add_node', 'ok'))
|
||||
expect(store.messages[0].parts[0]).toMatchObject({
|
||||
type: 'tool',
|
||||
name: 'add_node',
|
||||
ok: true
|
||||
})
|
||||
})
|
||||
|
||||
it('recordFailedSend renders [user, assistant(notice)] and leaves the turn idle', () => {
|
||||
const store = useAgentConversationStore()
|
||||
store.recordFailedSend('local-error-1' as TurnId, 'boom', 'send failed')
|
||||
|
||||
const entries = store.entries
|
||||
expect(entries.map((e) => e.role)).toEqual(['user', 'assistant'])
|
||||
expect(entries[0]).toMatchObject({ role: 'user', text: 'boom' })
|
||||
const assistant = entries[1]
|
||||
expect(assistant.role).toBe('assistant')
|
||||
if (assistant.role === 'assistant') {
|
||||
expect(assistant.streaming).toBe(false)
|
||||
expect(assistant.parts).toEqual([
|
||||
{ type: 'notice', level: 'error', text: 'send failed' }
|
||||
])
|
||||
}
|
||||
expect(store.activeTurnId).toBeNull()
|
||||
expect(store.isStreaming).toBe(false)
|
||||
})
|
||||
|
||||
it('recordFailedSend does not disturb an already-active turn', () => {
|
||||
const store = useAgentConversationStore()
|
||||
store.startTurn(T1)
|
||||
store.ingest(delta('t1', 'live'))
|
||||
|
||||
store.recordFailedSend('local-error-1' as TurnId, 'oops', 'send failed')
|
||||
|
||||
expect(store.activeTurnId).toBe(T1)
|
||||
expect(store.isStreaming).toBe(true)
|
||||
})
|
||||
|
||||
it('reset wipes the whole conversation, distinct from abortActiveTurn', () => {
|
||||
const store = useAgentConversationStore()
|
||||
store.startTurn(T1)
|
||||
store.ingest(delta('t1', 'gone'))
|
||||
store.reset()
|
||||
expect(store.messages).toHaveLength(0)
|
||||
expect(store.activeTurnId).toBeNull()
|
||||
expect(store.isStreaming).toBe(false)
|
||||
})
|
||||
|
||||
it('holds the thread id and clears it on reset', () => {
|
||||
const store = useAgentConversationStore()
|
||||
store.setThreadId('th-7')
|
||||
expect(store.threadId).toBe('th-7')
|
||||
store.reset()
|
||||
expect(store.threadId).toBeNull()
|
||||
})
|
||||
|
||||
it('revokes transcript blob previews on reset and on hydrate', () => {
|
||||
const revoke = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
const store = useAgentConversationStore()
|
||||
store.startTurn(T1)
|
||||
store.recordUser(T1, 'with picture', [
|
||||
{ name: 'a.png', previewUrl: 'blob:a' }
|
||||
])
|
||||
store.reset()
|
||||
expect(revoke).toHaveBeenCalledWith('blob:a')
|
||||
|
||||
revoke.mockClear()
|
||||
store.startTurn(T2)
|
||||
store.recordUser(T2, 'again', [{ name: 'b.png', previewUrl: 'blob:b' }])
|
||||
store.hydrate([])
|
||||
expect(revoke).toHaveBeenCalledWith('blob:b')
|
||||
expect(
|
||||
store.entries.every(
|
||||
(entry) => entry.role !== 'user' || entry.attachments === undefined
|
||||
)
|
||||
).toBe(true)
|
||||
revoke.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,296 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { AgentMessages, TurnId } from '../../schemas/agentApiSchema'
|
||||
import type {
|
||||
AgentChatEvent,
|
||||
AgentEventTransport
|
||||
} from '../../services/agent/agentEventTransport'
|
||||
import { createAgentEventTransport } from '../../services/agent/agentEventTransport'
|
||||
import type { AssistantMessage } from '../../services/agent/agentMessageParts'
|
||||
import { createAssistantMessage } from '../../services/agent/agentMessageParts'
|
||||
|
||||
export type ConversationStatus = 'idle' | 'thinking' | 'streaming'
|
||||
|
||||
export interface UserAttachment {
|
||||
name: string
|
||||
previewUrl?: string
|
||||
}
|
||||
|
||||
interface UserEntry {
|
||||
id: TurnId
|
||||
role: 'user'
|
||||
text: string
|
||||
attachments?: UserAttachment[]
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
export type ConversationEntry = UserEntry | AssistantMessage
|
||||
|
||||
interface BackgroundTurn {
|
||||
turnId: TurnId
|
||||
message: AssistantMessage
|
||||
transport: AgentEventTransport
|
||||
userText: string | undefined
|
||||
settled: boolean
|
||||
}
|
||||
|
||||
export const useAgentConversationStore = defineStore(
|
||||
'agentConversation',
|
||||
() => {
|
||||
const messages = ref<AssistantMessage[]>([])
|
||||
const activeTurnId = ref<TurnId | null>(null)
|
||||
const threadId = ref<string | null>(null)
|
||||
const userTexts = ref(new Map<TurnId, string>())
|
||||
const userAttachments = ref(new Map<TurnId, UserAttachment[]>())
|
||||
const userTags = ref(new Map<TurnId, string[]>())
|
||||
|
||||
let transport: AgentEventTransport | null = null
|
||||
let liveMessage: AssistantMessage | null = null
|
||||
const backgroundTurns = new Map<string, BackgroundTurn>()
|
||||
const activeIndex = ref(-1)
|
||||
|
||||
function replaceActive(message: AssistantMessage): void {
|
||||
const index = activeIndex.value
|
||||
if (index >= 0 && messages.value[index]?.id === message.id)
|
||||
messages.value[index] = message
|
||||
}
|
||||
|
||||
function recordUser(
|
||||
turnId: TurnId,
|
||||
text: string,
|
||||
attachments?: UserAttachment[],
|
||||
tags?: string[]
|
||||
): void {
|
||||
userTexts.value.set(turnId, text)
|
||||
if (attachments !== undefined && attachments.length > 0)
|
||||
userAttachments.value.set(turnId, attachments)
|
||||
if (tags !== undefined && tags.length > 0)
|
||||
userTags.value.set(turnId, tags)
|
||||
}
|
||||
|
||||
function setThreadId(id: string | null): void {
|
||||
threadId.value = id
|
||||
}
|
||||
|
||||
function recordFailedSend(
|
||||
turnId: TurnId,
|
||||
text: string,
|
||||
noticeText: string
|
||||
): void {
|
||||
userTexts.value.set(turnId, text)
|
||||
const message = createAssistantMessage(turnId)
|
||||
message.streaming = false
|
||||
message.parts = [{ type: 'notice', level: 'error', text: noticeText }]
|
||||
messages.value.push(message)
|
||||
}
|
||||
|
||||
function startTurn(turnId: TurnId): void {
|
||||
if (transport) abortActiveTurn()
|
||||
const message = createAssistantMessage(turnId)
|
||||
liveMessage = message
|
||||
activeIndex.value = messages.value.push(message) - 1
|
||||
activeTurnId.value = turnId
|
||||
transport = createAgentEventTransport(message, replaceActive)
|
||||
}
|
||||
|
||||
function ingest(event: AgentChatEvent): void {
|
||||
if (transport && event.data.message_id === activeTurnId.value) {
|
||||
if (event.type === 'agent_message_done') {
|
||||
transport.settle()
|
||||
clearActive()
|
||||
return
|
||||
}
|
||||
transport.ingest(event)
|
||||
return
|
||||
}
|
||||
const entry = backgroundTurns.get(event.data.thread_id)
|
||||
if (!entry || entry.turnId !== event.data.message_id) return
|
||||
if (event.type === 'agent_message_done') {
|
||||
entry.transport.settle()
|
||||
entry.settled = true
|
||||
return
|
||||
}
|
||||
entry.transport.ingest(event)
|
||||
}
|
||||
|
||||
function abortActiveTurn(): void {
|
||||
if (!transport) return
|
||||
transport.settle()
|
||||
clearActive()
|
||||
}
|
||||
|
||||
function stashActiveTurn(): void {
|
||||
if (!transport || liveMessage === null) return
|
||||
if (threadId.value === null || activeTurnId.value === null) {
|
||||
abortActiveTurn()
|
||||
return
|
||||
}
|
||||
backgroundTurns.set(threadId.value, {
|
||||
turnId: activeTurnId.value,
|
||||
message: liveMessage,
|
||||
transport,
|
||||
userText: userTexts.value.get(activeTurnId.value),
|
||||
settled: false
|
||||
})
|
||||
clearActive()
|
||||
}
|
||||
|
||||
function resumeBackgroundTurn(): void {
|
||||
if (threadId.value === null) return
|
||||
const entry = backgroundTurns.get(threadId.value)
|
||||
if (!entry) return
|
||||
backgroundTurns.delete(threadId.value)
|
||||
const kept = messages.value.filter((m) => m.id !== entry.turnId)
|
||||
const last = kept.at(-1)
|
||||
let poppedHydratedCopy = false
|
||||
if (
|
||||
kept.length === messages.value.length &&
|
||||
last &&
|
||||
entry.userText !== undefined &&
|
||||
userTexts.value.get(last.id) === entry.userText
|
||||
) {
|
||||
kept.pop()
|
||||
userTexts.value.delete(last.id)
|
||||
poppedHydratedCopy = true
|
||||
}
|
||||
if (entry.settled && !poppedHydratedCopy) {
|
||||
if (entry.userText === undefined) return
|
||||
const hydratedElsewhere = [...userTexts.value.values()].includes(
|
||||
entry.userText
|
||||
)
|
||||
if (hydratedElsewhere) return
|
||||
}
|
||||
if (entry.userText !== undefined && !userTexts.value.has(entry.turnId))
|
||||
userTexts.value.set(entry.turnId, entry.userText)
|
||||
const index = kept.push(entry.message) - 1
|
||||
messages.value = kept
|
||||
if (entry.settled) return
|
||||
activeIndex.value = index
|
||||
activeTurnId.value = entry.turnId
|
||||
transport = entry.transport
|
||||
liveMessage = entry.message
|
||||
}
|
||||
|
||||
function settleBackgroundTurn(turnId: string): void {
|
||||
for (const [key, entry] of backgroundTurns) {
|
||||
if (entry.turnId !== turnId) continue
|
||||
entry.transport.settle()
|
||||
backgroundTurns.delete(key)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function dropBackgroundTurns(): void {
|
||||
for (const entry of backgroundTurns.values()) entry.transport.settle()
|
||||
backgroundTurns.clear()
|
||||
}
|
||||
|
||||
function clearActive(): void {
|
||||
transport = null
|
||||
liveMessage = null
|
||||
activeIndex.value = -1
|
||||
activeTurnId.value = null
|
||||
}
|
||||
|
||||
function dropAttachmentPreviews(): void {
|
||||
for (const attachments of userAttachments.value.values()) {
|
||||
for (const { previewUrl } of attachments) {
|
||||
if (previewUrl?.startsWith('blob:')) URL.revokeObjectURL(previewUrl)
|
||||
}
|
||||
}
|
||||
userAttachments.value = new Map()
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
messages.value = []
|
||||
userTexts.value = new Map()
|
||||
userTags.value = new Map()
|
||||
dropAttachmentPreviews()
|
||||
threadId.value = null
|
||||
clearActive()
|
||||
}
|
||||
|
||||
function hydrate(history: AgentMessages): void {
|
||||
clearActive()
|
||||
const texts = new Map<TurnId, string>()
|
||||
const assistants = new Map<TurnId, AssistantMessage>()
|
||||
const turnOrder: TurnId[] = []
|
||||
for (const row of [...history].sort((a, b) => a.seq - b.seq)) {
|
||||
const turnId = row.turn_id as TurnId
|
||||
if (!turnOrder.includes(turnId)) turnOrder.push(turnId)
|
||||
const text =
|
||||
typeof row.content?.text === 'string' ? row.content.text : ''
|
||||
if (row.role === 'user') texts.set(turnId, text)
|
||||
if (row.role === 'assistant') {
|
||||
const message =
|
||||
assistants.get(turnId) ?? createAssistantMessage(turnId)
|
||||
message.streaming = false
|
||||
if (text)
|
||||
message.parts = [
|
||||
...message.parts,
|
||||
{ type: 'text', text, state: 'done' }
|
||||
]
|
||||
assistants.set(turnId, message)
|
||||
}
|
||||
}
|
||||
messages.value = turnOrder.map((turnId) => {
|
||||
const message = assistants.get(turnId) ?? createAssistantMessage(turnId)
|
||||
message.streaming = false
|
||||
return message
|
||||
})
|
||||
userTexts.value = texts
|
||||
userTags.value = new Map()
|
||||
dropAttachmentPreviews()
|
||||
}
|
||||
|
||||
const entries = computed<ConversationEntry[]>(() =>
|
||||
messages.value.flatMap((message) => {
|
||||
const text = userTexts.value.get(message.id)
|
||||
return text === undefined
|
||||
? [message]
|
||||
: [
|
||||
{
|
||||
id: message.id,
|
||||
role: 'user',
|
||||
text,
|
||||
attachments: userAttachments.value.get(message.id),
|
||||
tags: userTags.value.get(message.id)
|
||||
},
|
||||
message
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
const activeMessage = computed(() =>
|
||||
activeIndex.value >= 0 ? messages.value[activeIndex.value] : null
|
||||
)
|
||||
const isStreaming = computed(() => activeMessage.value?.streaming ?? false)
|
||||
const status = computed<ConversationStatus>(() => {
|
||||
const message = activeMessage.value
|
||||
if (!message?.streaming) return 'idle'
|
||||
return message.thinking ? 'thinking' : 'streaming'
|
||||
})
|
||||
|
||||
return {
|
||||
messages,
|
||||
entries,
|
||||
activeTurnId,
|
||||
threadId,
|
||||
isStreaming,
|
||||
status,
|
||||
recordUser,
|
||||
setThreadId,
|
||||
recordFailedSend,
|
||||
startTurn,
|
||||
ingest,
|
||||
abortActiveTurn,
|
||||
stashActiveTurn,
|
||||
resumeBackgroundTurn,
|
||||
settleBackgroundTurn,
|
||||
dropBackgroundTurns,
|
||||
reset,
|
||||
hydrate
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,217 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type {
|
||||
AgentDraftSnapshot,
|
||||
DraftPatchData,
|
||||
DraftVersionData
|
||||
} from '../../schemas/agentApiSchema'
|
||||
|
||||
import { useAgentDraftStore } from './agentDraftStore'
|
||||
|
||||
const WORKFLOW = 'a81718a4-02ae-41e6-ae85-c33b7bb880f6'
|
||||
const OTHER_WORKFLOW = 'b90000a0-0000-0000-0000-000000000000'
|
||||
|
||||
function graphAt(cfg: number): Record<string, unknown> {
|
||||
return {
|
||||
last_node_id: 1,
|
||||
links: [],
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'KSampler',
|
||||
widgets_values: [0, 'fixed', 12, cfg, 'euler', 'normal', 1]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function patch(
|
||||
version: number,
|
||||
baseVersion: number,
|
||||
workflow_id = WORKFLOW
|
||||
): DraftPatchData {
|
||||
return {
|
||||
base_version: baseVersion,
|
||||
version,
|
||||
content: graphAt(version),
|
||||
workflow_id
|
||||
}
|
||||
}
|
||||
|
||||
function heartbeat(version: number, workflow_id = WORKFLOW): DraftVersionData {
|
||||
return { version, workflow_id }
|
||||
}
|
||||
|
||||
function snapshot(version: number): AgentDraftSnapshot {
|
||||
return { content: graphAt(version), version }
|
||||
}
|
||||
|
||||
describe('useAgentDraftStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
describe('applyPatch monotonic adoption', () => {
|
||||
it('adopts increasing versions 24 -> 25 -> 26 (base_version gap tolerated)', () => {
|
||||
const store = useAgentDraftStore()
|
||||
store.bind(WORKFLOW)
|
||||
store.adoptSnapshot(snapshot(24))
|
||||
|
||||
expect(store.applyPatch(patch(25, 24))).toBe(true)
|
||||
expect(store.version).toBe(25)
|
||||
|
||||
expect(store.applyPatch(patch(27, 26))).toBe(true)
|
||||
expect(store.version).toBe(27)
|
||||
expect(store.content).toEqual(graphAt(27))
|
||||
})
|
||||
|
||||
it('adopts the first patch when unversioned but bound', () => {
|
||||
const store = useAgentDraftStore()
|
||||
store.bind(WORKFLOW)
|
||||
expect(store.version).toBeNull()
|
||||
expect(store.applyPatch(patch(25, 24))).toBe(true)
|
||||
expect(store.version).toBe(25)
|
||||
expect(store.content).toEqual(graphAt(25))
|
||||
})
|
||||
|
||||
it('ignores a duplicate version (same version re-sent)', () => {
|
||||
const store = useAgentDraftStore()
|
||||
store.bind(WORKFLOW)
|
||||
store.applyPatch(patch(25, 24))
|
||||
expect(store.applyPatch(patch(25, 24))).toBe(false)
|
||||
expect(store.version).toBe(25)
|
||||
expect(store.content).toEqual(graphAt(25))
|
||||
})
|
||||
|
||||
it('ignores a lower version (stale patch)', () => {
|
||||
const store = useAgentDraftStore()
|
||||
store.bind(WORKFLOW)
|
||||
store.applyPatch(patch(26, 25))
|
||||
expect(store.applyPatch(patch(25, 24))).toBe(false)
|
||||
expect(store.version).toBe(26)
|
||||
expect(store.content).toEqual(graphAt(26))
|
||||
})
|
||||
|
||||
it('ignores a foreign workflow_id and does not mutate state', () => {
|
||||
const store = useAgentDraftStore()
|
||||
store.bind(WORKFLOW)
|
||||
store.applyPatch(patch(25, 24))
|
||||
expect(store.applyPatch(patch(26, 25, OTHER_WORKFLOW))).toBe(false)
|
||||
expect(store.version).toBe(25)
|
||||
expect(store.content).toEqual(graphAt(25))
|
||||
})
|
||||
|
||||
it('ignores every patch while unbound', () => {
|
||||
const store = useAgentDraftStore()
|
||||
expect(store.applyPatch(patch(25, 24))).toBe(false)
|
||||
expect(store.content).toBeNull()
|
||||
expect(store.version).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('bind', () => {
|
||||
it('clears content/version when binding a different workflow', () => {
|
||||
const store = useAgentDraftStore()
|
||||
store.bind(WORKFLOW)
|
||||
store.applyPatch(patch(25, 24))
|
||||
expect(store.content).toEqual(graphAt(25))
|
||||
|
||||
store.bind(OTHER_WORKFLOW)
|
||||
expect(store.workflowId).toBe(OTHER_WORKFLOW)
|
||||
expect(store.content).toBeNull()
|
||||
expect(store.version).toBeNull()
|
||||
})
|
||||
|
||||
it('re-binding the same workflow preserves state', () => {
|
||||
const store = useAgentDraftStore()
|
||||
store.bind(WORKFLOW)
|
||||
store.applyPatch(patch(25, 24))
|
||||
store.bind(WORKFLOW)
|
||||
expect(store.version).toBe(25)
|
||||
expect(store.content).toEqual(graphAt(25))
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkHeartbeat', () => {
|
||||
it('is foreign when unbound', () => {
|
||||
const store = useAgentDraftStore()
|
||||
expect(store.checkHeartbeat(heartbeat(24))).toBe('foreign')
|
||||
})
|
||||
|
||||
it('is foreign when the workflow mismatches', () => {
|
||||
const store = useAgentDraftStore()
|
||||
store.bind(WORKFLOW)
|
||||
expect(store.checkHeartbeat(heartbeat(24, OTHER_WORKFLOW))).toBe(
|
||||
'foreign'
|
||||
)
|
||||
})
|
||||
|
||||
it('is behind when we hold no version yet', () => {
|
||||
const store = useAgentDraftStore()
|
||||
store.bind(WORKFLOW)
|
||||
expect(store.checkHeartbeat(heartbeat(24))).toBe('behind')
|
||||
})
|
||||
|
||||
it('is behind when the server is ahead', () => {
|
||||
const store = useAgentDraftStore()
|
||||
store.bind(WORKFLOW)
|
||||
store.applyPatch(patch(25, 24))
|
||||
expect(store.checkHeartbeat(heartbeat(26))).toBe('behind')
|
||||
})
|
||||
|
||||
it('is in-sync at the same version', () => {
|
||||
const store = useAgentDraftStore()
|
||||
store.bind(WORKFLOW)
|
||||
store.applyPatch(patch(26, 25))
|
||||
expect(store.checkHeartbeat(heartbeat(26))).toBe('in-sync')
|
||||
})
|
||||
|
||||
it('is in-sync when the heartbeat trails our version', () => {
|
||||
const store = useAgentDraftStore()
|
||||
store.bind(WORKFLOW)
|
||||
store.applyPatch(patch(26, 25))
|
||||
expect(store.checkHeartbeat(heartbeat(24))).toBe('in-sync')
|
||||
})
|
||||
})
|
||||
|
||||
describe('adoptSnapshot', () => {
|
||||
it('adopts onto an empty store', () => {
|
||||
const store = useAgentDraftStore()
|
||||
store.bind(WORKFLOW)
|
||||
store.adoptSnapshot(snapshot(24))
|
||||
expect(store.version).toBe(24)
|
||||
expect(store.content).toEqual(graphAt(24))
|
||||
})
|
||||
|
||||
it('re-adopts at an equal version (idempotent refresh)', () => {
|
||||
const store = useAgentDraftStore()
|
||||
store.bind(WORKFLOW)
|
||||
store.adoptSnapshot(snapshot(25))
|
||||
store.adoptSnapshot(snapshot(25))
|
||||
expect(store.version).toBe(25)
|
||||
expect(store.content).toEqual(graphAt(25))
|
||||
})
|
||||
|
||||
it('ignores an older snapshot', () => {
|
||||
const store = useAgentDraftStore()
|
||||
store.bind(WORKFLOW)
|
||||
store.adoptSnapshot(snapshot(26))
|
||||
store.adoptSnapshot(snapshot(24))
|
||||
expect(store.version).toBe(26)
|
||||
expect(store.content).toEqual(graphAt(26))
|
||||
})
|
||||
})
|
||||
|
||||
describe('reset', () => {
|
||||
it('clears all three refs', () => {
|
||||
const store = useAgentDraftStore()
|
||||
store.bind(WORKFLOW)
|
||||
store.applyPatch(patch(25, 24))
|
||||
store.reset()
|
||||
expect(store.workflowId).toBeNull()
|
||||
expect(store.content).toBeNull()
|
||||
expect(store.version).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type {
|
||||
AgentDraftSnapshot,
|
||||
DraftPatchData,
|
||||
DraftVersionData
|
||||
} from '../../schemas/agentApiSchema'
|
||||
|
||||
export type HeartbeatState = 'in-sync' | 'behind' | 'foreign'
|
||||
|
||||
export const useAgentDraftStore = defineStore('agentDraft', () => {
|
||||
const workflowId = ref<string | null>(null)
|
||||
const content = ref<Record<string, unknown> | null>(null)
|
||||
const version = ref<number | null>(null)
|
||||
|
||||
function bind(id: string): void {
|
||||
if (id === workflowId.value) return
|
||||
workflowId.value = id
|
||||
content.value = null
|
||||
version.value = null
|
||||
}
|
||||
|
||||
function applyPatch(data: DraftPatchData): boolean {
|
||||
if (data.workflow_id !== workflowId.value) return false
|
||||
if (version.value !== null && data.version <= version.value) return false
|
||||
content.value = data.content
|
||||
version.value = data.version
|
||||
return true
|
||||
}
|
||||
|
||||
function checkHeartbeat(data: DraftVersionData): HeartbeatState {
|
||||
if (data.workflow_id !== workflowId.value) return 'foreign'
|
||||
if (version.value === null || version.value < data.version) return 'behind'
|
||||
return 'in-sync'
|
||||
}
|
||||
|
||||
function adoptSnapshot(snapshot: AgentDraftSnapshot): void {
|
||||
if (version.value !== null && snapshot.version < version.value) return
|
||||
content.value = snapshot.content
|
||||
version.value = snapshot.version
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
workflowId.value = null
|
||||
content.value = null
|
||||
version.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
workflowId,
|
||||
content,
|
||||
version,
|
||||
bind,
|
||||
applyPatch,
|
||||
checkHeartbeat,
|
||||
adoptSnapshot,
|
||||
reset
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const telemetry = vi.hoisted(() => ({
|
||||
trackAgentPanelOpened: vi.fn(),
|
||||
trackAgentPanelClosed: vi.fn()
|
||||
}))
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => telemetry
|
||||
}))
|
||||
|
||||
import { useAgentPanelStore } from './agentPanelStore'
|
||||
|
||||
describe('agentPanelStore engagement telemetry', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('emits opened on toggle-open and closed with the open duration', () => {
|
||||
const store = useAgentPanelStore()
|
||||
|
||||
store.toggle()
|
||||
expect(store.isOpen).toBe(true)
|
||||
expect(telemetry.trackAgentPanelOpened).toHaveBeenCalledWith({
|
||||
source: 'topbar_button'
|
||||
})
|
||||
|
||||
vi.advanceTimersByTime(5000)
|
||||
store.close('close_button')
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(telemetry.trackAgentPanelClosed).toHaveBeenCalledWith({
|
||||
source: 'close_button',
|
||||
open_duration_ms: 5000
|
||||
})
|
||||
})
|
||||
|
||||
it('toggling an open panel closes it attributed to the topbar button', () => {
|
||||
const store = useAgentPanelStore()
|
||||
|
||||
store.toggle()
|
||||
vi.advanceTimersByTime(250)
|
||||
store.toggle()
|
||||
expect(telemetry.trackAgentPanelClosed).toHaveBeenCalledWith({
|
||||
source: 'topbar_button',
|
||||
open_duration_ms: 250
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a null duration when the panel was opened by a direct state write', () => {
|
||||
const store = useAgentPanelStore()
|
||||
|
||||
store.isOpen = true
|
||||
store.close('close_button')
|
||||
expect(telemetry.trackAgentPanelClosed).toHaveBeenCalledWith({
|
||||
source: 'close_button',
|
||||
open_duration_ms: null
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores a close while already closed so flag re-syncs emit nothing', () => {
|
||||
const store = useAgentPanelStore()
|
||||
|
||||
store.close('flag_disabled')
|
||||
store.close('flag_disabled')
|
||||
expect(telemetry.trackAgentPanelClosed).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { AgentPanelCloseSource } from '@/platform/telemetry/types'
|
||||
|
||||
const PANEL_MIN_WIDTH = 420
|
||||
const PANEL_MAX_WIDTH = 960
|
||||
|
||||
export const useAgentPanelStore = defineStore('agentPanel', () => {
|
||||
const enabled = ref(false)
|
||||
const isOpen = ref(false)
|
||||
const width = ref(PANEL_MIN_WIDTH)
|
||||
|
||||
let openedAt: number | null = null
|
||||
|
||||
const isMaximized = computed(() => width.value === PANEL_MAX_WIDTH)
|
||||
|
||||
function open(): void {
|
||||
if (isOpen.value) return
|
||||
isOpen.value = true
|
||||
openedAt = Date.now()
|
||||
useTelemetry()?.trackAgentPanelOpened({ source: 'topbar_button' })
|
||||
}
|
||||
|
||||
function close(source: AgentPanelCloseSource): void {
|
||||
if (!isOpen.value) return
|
||||
isOpen.value = false
|
||||
const openDurationMs = openedAt === null ? null : Date.now() - openedAt
|
||||
openedAt = null
|
||||
useTelemetry()?.trackAgentPanelClosed({
|
||||
source,
|
||||
open_duration_ms: openDurationMs
|
||||
})
|
||||
}
|
||||
|
||||
function toggle(): void {
|
||||
if (isOpen.value) close('topbar_button')
|
||||
else open()
|
||||
}
|
||||
|
||||
function setWidth(px: number): void {
|
||||
width.value = Math.min(PANEL_MAX_WIDTH, Math.max(PANEL_MIN_WIDTH, px))
|
||||
}
|
||||
|
||||
function toggleMaximize(): void {
|
||||
setWidth(isMaximized.value ? PANEL_MIN_WIDTH : PANEL_MAX_WIDTH)
|
||||
}
|
||||
|
||||
return {
|
||||
enabled,
|
||||
isOpen,
|
||||
width,
|
||||
isMaximized,
|
||||
toggle,
|
||||
close,
|
||||
setWidth,
|
||||
toggleMaximize
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { useAgentWorkflowTabBindingStore } from './agentWorkflowTabBindingStore'
|
||||
|
||||
describe('agentWorkflowTabBindingStore', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('resolves both directions after a bind', () => {
|
||||
const store = useAgentWorkflowTabBindingStore()
|
||||
store.bind('wf-1', 'workflows/a.json')
|
||||
expect(store.tabPathFor('wf-1')).toBe('workflows/a.json')
|
||||
expect(store.workflowIdFor('workflows/a.json')).toBe('wf-1')
|
||||
})
|
||||
|
||||
it('rebinding a workflow to a new tab releases the old tab', () => {
|
||||
const store = useAgentWorkflowTabBindingStore()
|
||||
store.bind('wf-1', 'workflows/a.json')
|
||||
store.bind('wf-1', 'workflows/b.json')
|
||||
expect(store.tabPathFor('wf-1')).toBe('workflows/b.json')
|
||||
expect(store.workflowIdFor('workflows/a.json')).toBeUndefined()
|
||||
expect(store.workflowIdFor('workflows/b.json')).toBe('wf-1')
|
||||
})
|
||||
|
||||
it('binding another workflow to an occupied tab steals it', () => {
|
||||
const store = useAgentWorkflowTabBindingStore()
|
||||
store.bind('wf-1', 'workflows/a.json')
|
||||
store.bind('wf-2', 'workflows/a.json')
|
||||
expect(store.workflowIdFor('workflows/a.json')).toBe('wf-2')
|
||||
expect(store.tabPathFor('wf-1')).toBeUndefined()
|
||||
expect(store.tabPathFor('wf-2')).toBe('workflows/a.json')
|
||||
})
|
||||
|
||||
it('bindings survive a reload', async () => {
|
||||
useAgentWorkflowTabBindingStore().bind('wf-1', 'workflows/a.json')
|
||||
await nextTick()
|
||||
|
||||
setActivePinia(createPinia())
|
||||
const reloaded = useAgentWorkflowTabBindingStore()
|
||||
|
||||
expect(reloaded.tabPathFor('wf-1')).toBe('workflows/a.json')
|
||||
expect(reloaded.workflowIdFor('workflows/a.json')).toBe('wf-1')
|
||||
})
|
||||
|
||||
it('does not resolve prototype-inherited names as bindings', () => {
|
||||
const store = useAgentWorkflowTabBindingStore()
|
||||
expect(store.tabPathFor('constructor')).toBeUndefined()
|
||||
expect(store.workflowIdFor('workflows/missing.json')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
const STORAGE_KEY = 'Comfy.Agent.WorkflowTabBindings'
|
||||
|
||||
export const useAgentWorkflowTabBindingStore = defineStore(
|
||||
'agentWorkflowTabBinding',
|
||||
() => {
|
||||
const tabByWorkflow = useLocalStorage<Record<string, string>>(
|
||||
STORAGE_KEY,
|
||||
{}
|
||||
)
|
||||
|
||||
function bind(workflowId: string, tabPath: string): void {
|
||||
const next = Object.fromEntries(
|
||||
Object.entries(tabByWorkflow.value).filter(
|
||||
([, boundPath]) => boundPath !== tabPath
|
||||
)
|
||||
)
|
||||
next[workflowId] = tabPath
|
||||
tabByWorkflow.value = next
|
||||
}
|
||||
|
||||
function tabPathFor(workflowId: string): string | undefined {
|
||||
return Object.hasOwn(tabByWorkflow.value, workflowId)
|
||||
? tabByWorkflow.value[workflowId]
|
||||
: undefined
|
||||
}
|
||||
|
||||
function workflowIdFor(tabPath: string): string | undefined {
|
||||
for (const [workflowId, boundPath] of Object.entries(
|
||||
tabByWorkflow.value
|
||||
)) {
|
||||
if (boundPath === tabPath) return workflowId
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
return { bind, tabPathFor, workflowIdFor }
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user