mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 09:18:26 +00:00
Compare commits
65 Commits
split/plan
...
nathaniel/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c66b22392 | ||
|
|
7655e58d20 | ||
|
|
91e0ebeba0 | ||
|
|
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 |
211
browser_tests/tests/agent/agentPanel.spec.ts
Normal file
211
browser_tests/tests/agent/agentPanel.spec.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
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'
|
||||
|
||||
/**
|
||||
* In-App Agent panel e2e coverage (FE-1187).
|
||||
*
|
||||
* The panel is a CLOUD-ONLY workbench extension: `src/extensions/core/index.ts`
|
||||
* imports `agentPanel.ts` only under `if (isCloud)`, and `isCloud` is the
|
||||
* build-time `__DISTRIBUTION__ === 'cloud'` define. It therefore exists only in
|
||||
* the cloud build, which the `cloud` Playwright project runs against
|
||||
* (`frontend-dist-cloud` in CI; `playwright.config.ts` gives that project
|
||||
* `grep: /@cloud/` while every other project greps it out). These specs are
|
||||
* tagged `@cloud` so they run there and nowhere else. The `comfyPageFixture`
|
||||
* itself already installs the cloud Firebase-auth mock for any `@cloud` test and
|
||||
* boots the app, so tests only arrange agent mocks and act.
|
||||
*
|
||||
* The panel docks on the right and opens from the top-bar "Ask Comfy Agent"
|
||||
* button (not a sidebar tab). It is gated FAIL-CLOSED by the PostHog flag
|
||||
* `agent-in-app-experience`: `agentPanel.ts` reads `posthog.isFeatureEnabled`
|
||||
* and only exposes that button once it is `true`. PostHog only initializes
|
||||
* when `/api/features` supplies a `posthog_project_token`, so the mocks seed both
|
||||
* the token and the flag (through PostHog `bootstrap.featureFlags`, which resolves
|
||||
* the read synchronously with no `/decide` network call — see `agentPanelMocks.ts`).
|
||||
* `agentFlagEnabled` (default true) is a fixture option a test flips off to model
|
||||
* the fail-closed default.
|
||||
*
|
||||
* REST is mocked via `page.route` (POST messages -> 202, GET draft snapshot) and
|
||||
* server->client WS frames are injected with the repo's `webSocketFixture`
|
||||
* (`context.routeWebSocket(/\/ws/)`): `ws.send(JSON.stringify({type, data}))`
|
||||
* pushes a frame onto the app's shared `/ws`, which is exactly where the panel's
|
||||
* reconnecting event source listens.
|
||||
*/
|
||||
|
||||
type AgentFixtures = {
|
||||
agentFlagEnabled: boolean
|
||||
postedMessages: string[]
|
||||
}
|
||||
|
||||
// Override the `page` fixture to install the agent boot + REST mocks, the same
|
||||
// pattern as workspaceSwitcher.spec.ts: `comfyPage` depends on `page`, so the
|
||||
// override is guaranteed to finish before `comfyPage` navigates and boots the
|
||||
// app. A sibling fixture carries no such ordering, so its mocks can land after
|
||||
// boot and the real server answers /api/features (no PostHog token, gate stays
|
||||
// closed).
|
||||
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)
|
||||
|
||||
// The panel opens from the top-bar action button, exposed with aria-label = tooltip.
|
||||
const OPEN_AGENT_LABEL = enMessages.agent.askComfyAgent
|
||||
|
||||
// Push one agent WS event onto the app's /ws in the ComfyUI envelope shape.
|
||||
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)
|
||||
|
||||
// Fail-closed: with the PostHog flag false the top-bar button is never
|
||||
// exposed, so the panel cannot be opened.
|
||||
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
|
||||
}) => {
|
||||
// Boot + open + full turn can exceed the cloud project's 15s budget on a
|
||||
// loaded CI runner; same widening precedent as versionMismatchWarnings:91.
|
||||
test.setTimeout(30_000)
|
||||
|
||||
const page = comfyPage.page
|
||||
|
||||
// Flag on: the top-bar button is exposed. Open the panel.
|
||||
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()
|
||||
|
||||
// Empty state: greeting + question and the five suggested-prompt chips. The greeting
|
||||
// personalizes to the account's first name, so match the stable prefix, not a fixed name.
|
||||
await expect(panel.getByText(/^Hello/)).toBeVisible()
|
||||
await expect(panel.getByText('What do you want to make?')).toBeVisible()
|
||||
// Sourced from the bundled locale so the spec cannot drift from the rendered prompts.
|
||||
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' })
|
||||
|
||||
// Clicking a suggested prompt INSERTS it into the composer, it does not 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)
|
||||
|
||||
// Submitting sends the composed message: POST /api/agent/threads/new/messages.
|
||||
const ws = await getWebSocket()
|
||||
await sendButton.click()
|
||||
await expect.poll(() => postedMessages.length).toBeGreaterThanOrEqual(1)
|
||||
expect(postedMessages[0]).toContain(firstPrompt)
|
||||
// The composer clears once the send is accepted.
|
||||
await expect(composer).toHaveValue('')
|
||||
|
||||
// agent_thinking -> the "Thinking..." status is visible.
|
||||
pushEvent(ws, THINKING_EVENT)
|
||||
await expect(panel.getByText('Thinking...')).toBeVisible()
|
||||
|
||||
// agent_tool_call (ok) -> a tool-call group card appears ("Ran 1 tool call").
|
||||
pushEvent(ws, TOOL_CALL_EVENT)
|
||||
await expect(panel.getByText('Ran 1 tool call')).toBeVisible()
|
||||
|
||||
// agent_message_delta with markdown -> rendered agent message; the **bold**
|
||||
// run renders as <strong>.
|
||||
pushEvent(ws, MESSAGE_DELTA_EVENT)
|
||||
await expect(
|
||||
panel.locator('strong', { hasText: 'fully ready' })
|
||||
).toBeVisible()
|
||||
// Text arriving clears the thinking chip.
|
||||
await expect(panel.getByText('Thinking...')).toBeHidden()
|
||||
|
||||
// agent_message_done -> the turn settles: the primary button returns to Send
|
||||
// (it is Stop while streaming).
|
||||
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
|
||||
}) => {
|
||||
// Boot + open + full turn can exceed the cloud project's 15s budget on a
|
||||
// loaded CI runner; same widening precedent as versionMismatchWarnings:91.
|
||||
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()
|
||||
|
||||
// The draft store only adopts a draft_patch whose workflow_id matches the
|
||||
// server's workflow. The server returns that id in the message ack, and the
|
||||
// panel binds the draft store to it. So send once to establish the bind before
|
||||
// pushing the patch. A draft_patch is otherwise NEVER turn-filtered.
|
||||
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 })
|
||||
|
||||
// The two-node draft graph is validated and handed to app.loadGraphData, so
|
||||
// the canvas graph must reflect it.
|
||||
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'])
|
||||
)
|
||||
})
|
||||
})
|
||||
291
browser_tests/tests/agent/agentPanelMocks.ts
Normal file
291
browser_tests/tests/agent/agentPanelMocks.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
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'
|
||||
|
||||
/**
|
||||
* Typed mocks for the In-App Agent panel e2e spec. Every REST body and WS frame
|
||||
* is annotated with the shared agent contract types
|
||||
* (`@/workbench/extensions/agent/schemas/agentApiSchema`) and the workflow schema,
|
||||
* so a wire-shape drift is a compile error here, not a flaky runtime failure. The
|
||||
* payload values are copied from the captured fixtures under
|
||||
* `src/workbench/extensions/agent/schemas/__fixtures__/agent/`.
|
||||
*/
|
||||
|
||||
// The turn everything in scenario 2 & 3 is addressed by. The panel adopts this
|
||||
// server-minted message_id (from the POST 202 ack) as the TurnId that keys the
|
||||
// assistant message the WS frames stream into.
|
||||
const THREAD_ID = 'd4c016c4-3b8c-44cf-97de-1ae27e43e718'
|
||||
const TURN_ID = '3818ba00-d772-4a3f-98c1-9312725b577d'
|
||||
const WORKFLOW_ID = 'a81718a4-02ae-41e6-ae85-c33b7bb880f6'
|
||||
|
||||
// The 202 body the panel's postMessage('new') expects: {thread_id, message_id}
|
||||
// plus the captured additive workflow_id (the schema is passthrough).
|
||||
const TURN_ACCEPTED: AgentTurnAccepted = {
|
||||
message_id: TURN_ID,
|
||||
thread_id: THREAD_ID,
|
||||
workflow_id: WORKFLOW_ID
|
||||
}
|
||||
|
||||
/**
|
||||
* A minimal schema-valid v0.4 workflow. `validateComfyWorkflow` first parses a
|
||||
* top-level `version: number` (the captured draft graphs omit it, so they would
|
||||
* be rejected before ever reaching the canvas); this shape carries `version`,
|
||||
* `last_node_id`, `last_link_id`, `nodes` and `links` so it survives validation
|
||||
* and `app.loadGraphData` actually applies it. Two nodes so the assertion on the
|
||||
* canvas node count is unambiguous.
|
||||
*/
|
||||
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: []
|
||||
}
|
||||
|
||||
// The GET /api/agent/draft snapshot body: {content, version}. content rides the
|
||||
// wire as an opaque object; the panel re-validates it before loading.
|
||||
const DRAFT_SNAPSHOT: AgentDraftSnapshot = {
|
||||
content: DRAFT_GRAPH as unknown as Record<string, unknown>,
|
||||
version: 24
|
||||
}
|
||||
|
||||
// A draft_patch WS event whose content is the minimal valid graph above. The panel
|
||||
// adopts monotonically, so the version must exceed any snapshot already adopted.
|
||||
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
|
||||
}
|
||||
|
||||
// The chat-turn WS frames for scenario 2, in arrival order. Each rides the ComfyUI
|
||||
// envelope {type, data}; values copied from ws-turn-ask-run.jsonl but retargeted to
|
||||
// TURN_ID so they land on the turn the POST ack opened.
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Markdown delta: the **bold** run renders as <strong> through the sanitizing
|
||||
// markdown pipeline, which the spec asserts on.
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `/api/features` payload that boots the cloud telemetry provider with PostHog AND
|
||||
* turns the agent gate on. PostHog only initializes when a `posthog_project_token`
|
||||
* is present, and the panel's fail-closed gate reads `posthog.isFeatureEnabled`;
|
||||
* seeding the flag through PostHog's `bootstrap.featureFlags` makes that read
|
||||
* resolve `true` synchronously at init with no `/decide` round-trip, so the gate is
|
||||
* deterministic. Omit `agentFlag` (or pass false) to model the fail-closed default.
|
||||
*/
|
||||
function agentFeatures(agentFlag: boolean): RemoteConfig {
|
||||
return {
|
||||
team_workspaces_enabled: true,
|
||||
posthog_project_token: 'phc_e2e_agent_panel',
|
||||
posthog_config: {
|
||||
// Never let posthog-js reach its flags endpoint in the test env; bootstrap
|
||||
// alone supplies the flag value the gate reads.
|
||||
advanced_disable_flags: true,
|
||||
bootstrap: {
|
||||
featureFlags: { 'agent-in-app-experience': agentFlag }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boots the cloud app against fully mocked boot + agent REST endpoints. Mirrors
|
||||
* `CloudWorkspaceMockHelper.mockBoot`, adding the agent's own REST surface
|
||||
* (`postMessage`, `getDraft`). Records POST bodies into the caller's
|
||||
* `postedMessages` so the spec can assert the composed message reached the wire.
|
||||
* Auth (Firebase) is mocked by the caller via `CloudAuthHelper` before navigation.
|
||||
*/
|
||||
export async function mockAgentBoot(
|
||||
page: Page,
|
||||
{
|
||||
agentFlag,
|
||||
postedMessages
|
||||
}: { agentFlag: boolean; postedMessages: string[] }
|
||||
): Promise<void> {
|
||||
// Mark the coach as already seen: its full-viewport scrim otherwise blocks
|
||||
// every click on the freshly-opened panel (fresh storage in CI).
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('Comfy.AgentPanel.onboarded', 'true')
|
||||
})
|
||||
|
||||
// Billing + assets answer real requests in CI; unmocked they reject and their
|
||||
// error toasts stack over the docked panel, swallowing clicks.
|
||||
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' }
|
||||
})
|
||||
)
|
||||
)
|
||||
// TutorialCompleted marks the user as returning so the new-user Templates dialog
|
||||
// never auto-opens over the sidebar rail; errors tab off suppresses a 401 toast.
|
||||
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' }))
|
||||
)
|
||||
// Must satisfy WorkspaceTokenResponseSchema (workspaceAuthStore.ts): a bare
|
||||
// {token} fails validation, and the auth recovery/retry loop then toasts
|
||||
// over the docked panel, swallowing clicks.
|
||||
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'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// POST /api/agent/threads/new/messages -> 202 {thread_id, message_id, workflow_id}.
|
||||
// The server owns the workflow and returns its id in this ack; the panel binds the
|
||||
// draft store to it and then only adopts draft_patch events whose workflow_id
|
||||
// matches. Record the body so the spec can assert the composed text was sent, not
|
||||
// just that a request fired. GET on the same path (history hydration) is empty.
|
||||
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))
|
||||
)
|
||||
}
|
||||
88
pnpm-lock.yaml
generated
88
pnpm-lock.yaml
generated
@@ -3461,32 +3461,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':
|
||||
@@ -7918,8 +7918,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:
|
||||
@@ -8884,8 +8884,8 @@ packages:
|
||||
vue-component-type-helpers@3.3.2:
|
||||
resolution: {integrity: sha512-l4Z2Y34m7nFMlx8vrslJaVtXxUpzgDMSESC7TakG/c5kwjYT/do+E0NcT2/vWDzaoIhsShg/2OKwX7Q4nbzC0g==}
|
||||
|
||||
vue-component-type-helpers@3.3.5:
|
||||
resolution: {integrity: sha512-Fe1jyPJoUGpJOYKOri44jduR7My4yYINOMJISuMAbmrs+L5LbIDUc8NTWZYY3EJLK0yPLuCmcd5zoCsE4k2/KA==}
|
||||
vue-component-type-helpers@3.3.7:
|
||||
resolution: {integrity: sha512-Skkhw9agYSgsWqv7bxSOGJZa9SaiJbZVGdXuFWnrzKaQYHnw9qbjD630rw6RyMqDbp54nfLCLw5SZA55if7JLg==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
@@ -9331,7 +9331,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:
|
||||
@@ -9365,7 +9365,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
|
||||
|
||||
@@ -11528,40 +11528,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
|
||||
@@ -11668,7 +11668,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.5
|
||||
vue-component-type-helpers: 3.3.7
|
||||
|
||||
'@swc/helpers@0.5.21':
|
||||
dependencies:
|
||||
@@ -12979,7 +12979,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
|
||||
@@ -16996,14 +16996,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
|
||||
|
||||
@@ -18138,7 +18138,7 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@3.3.2: {}
|
||||
|
||||
vue-component-type-helpers@3.3.5: {}
|
||||
vue-component-type-helpers@3.3.7: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.34(typescript@5.9.3)):
|
||||
dependencies:
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
@import '@comfyorg/design-system/css/style.css';
|
||||
/* In-App Agent panel (FE-1187) theme: aliases agent-* tokens onto host semantic
|
||||
variables. Imported after the design-system so those host variables exist. */
|
||||
@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,159 @@
|
||||
<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" />
|
||||
|
||||
<!-- Left column: workflow tabs + canvas/panels. The agent dock is a sibling so it
|
||||
spans the full viewport height beside the tab bar. -->
|
||||
<div
|
||||
class="pointer-events-none flex flex-1 overflow-hidden"
|
||||
:class="{
|
||||
'flex-row': sidebarLocation === 'left',
|
||||
'flex-row-reverse': sidebarLocation === 'right'
|
||||
}"
|
||||
class="pointer-events-none flex min-w-0 flex-1 flex-col overflow-hidden"
|
||||
>
|
||||
<div class="side-toolbar-container">
|
||||
<slot name="side-toolbar" />
|
||||
</div>
|
||||
<slot name="workflow-tabs" />
|
||||
|
||||
<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"
|
||||
<div
|
||||
class="pointer-events-none flex flex-1 overflow-hidden"
|
||||
:class="{
|
||||
'flex-row': sidebarLocation === 'left',
|
||||
'flex-row-reverse': sidebarLocation === 'right'
|
||||
}"
|
||||
>
|
||||
<!-- 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
|
||||
<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"
|
||||
>
|
||||
<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'
|
||||
)
|
||||
<!-- 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
|
||||
"
|
||||
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>
|
||||
<slot
|
||||
v-if="sidebarLocation === 'left' && sidebarPanelVisible"
|
||||
name="side-bar-panel"
|
||||
/>
|
||||
<slot
|
||||
v-else-if="sidebarLocation === 'right'"
|
||||
name="right-side-panel"
|
||||
/>
|
||||
</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'
|
||||
<!-- 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'
|
||||
)
|
||||
: '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>
|
||||
"
|
||||
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>
|
||||
|
||||
<!-- Right column: the agent dock, full viewport height, pixel width with a drag
|
||||
handle on its left edge (420-960px; the panel header icon jumps between the
|
||||
extremes). -->
|
||||
<div
|
||||
v-if="agentPanelDocked"
|
||||
class="pointer-events-auto relative h-full shrink-0 overflow-hidden"
|
||||
:style="{ width: `${agentPanelWidth}px` }"
|
||||
>
|
||||
<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 +168,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 +190,37 @@ 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)
|
||||
// The agent dock renders only while its flag gate is on and the user opened it.
|
||||
const agentPanelDocked = computed(
|
||||
() => agentPanelEnabled.value && agentPanelOpen.value
|
||||
)
|
||||
|
||||
// Drag-resize for the agent dock: capture the pointer on the handle and track the delta;
|
||||
// the store clamps into its [min, max] range.
|
||||
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 +363,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,12 @@ import { forEachNode } from '@/utils/graphTraversalUtil'
|
||||
import SelectionRectangle from './SelectionRectangle.vue'
|
||||
import { useUrlActionLoaders } from '@/composables/useUrlActionLoaders'
|
||||
|
||||
// Async so the flag-gated agent subtree stays out of the GraphCanvas chunk;
|
||||
// it loads on first dock (the slot renders only while the panel is open).
|
||||
const AgentPanelRoot = defineAsyncComponent(
|
||||
() => import('@/workbench/extensions/agent/AgentPanelRoot.vue')
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const emit = defineEmits<{
|
||||
ready: []
|
||||
|
||||
@@ -150,6 +150,24 @@ 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'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Agent failures have no catalog remediation copy; the raw details are the
|
||||
// only actionable signal, so they surface on the card.
|
||||
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) => {
|
||||
|
||||
@@ -62,6 +62,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.',
|
||||
@@ -621,6 +625,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()
|
||||
|
||||
@@ -45,6 +45,13 @@ import {
|
||||
|
||||
const PROMPT_CARD_ID = '__prompt__'
|
||||
|
||||
// Agent prompt errors carry the actionable failure text in `details`; host
|
||||
// prompt errors keep details off the card (their catalog copy is the message).
|
||||
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__'
|
||||
|
||||
@@ -358,6 +365,9 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
errors: [
|
||||
{
|
||||
message: error.message,
|
||||
...(AGENT_PROMPT_ERROR_TYPES.has(error.type)
|
||||
? { details: error.details }
|
||||
: {}),
|
||||
...resolvedDisplay
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h, reactive } from 'vue'
|
||||
import { defineComponent, h, reactive, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
@@ -16,6 +16,8 @@ const distribution = vi.hoisted(() => ({
|
||||
|
||||
const tabBarLayout = vi.hoisted(() => ({ value: 'Default' }))
|
||||
|
||||
const agentPanel = vi.hoisted(() => ({ enabled: false, toggle: vi.fn() }))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return distribution.isCloud
|
||||
@@ -79,6 +81,16 @@ vi.mock('@/stores/workspaceStore', () => ({
|
||||
useWorkspaceStore: () => ({ shiftDown: false })
|
||||
}))
|
||||
|
||||
// Ref-backed so the component's storeToRefs picks the fields up like a real store.
|
||||
vi.mock('@/workbench/extensions/agent/stores/agent/agentPanelStore', () => ({
|
||||
useAgentPanelStore: () =>
|
||||
reactive({
|
||||
isOpen: ref(false),
|
||||
enabled: ref(agentPanel.enabled),
|
||||
toggle: agentPanel.toggle
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/mouseDownUtil', () => ({
|
||||
whileMouseDown: vi.fn()
|
||||
}))
|
||||
@@ -188,3 +200,26 @@ describe('WorkflowTabs feedback button', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorkflowTabs agent entry button', () => {
|
||||
beforeEach(() => {
|
||||
distribution.isCloud = false
|
||||
distribution.isDesktop = false
|
||||
distribution.isNightly = false
|
||||
tabBarLayout.value = 'Default'
|
||||
agentPanel.enabled = true
|
||||
agentPanel.toggle.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
agentPanel.enabled = false
|
||||
})
|
||||
|
||||
it('renders the Ask Comfy Agent CTA and toggles the panel on click', async () => {
|
||||
const { user } = renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Ask Comfy Agent' }))
|
||||
|
||||
expect(agentPanel.toggle).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
89
src/extensions/core/agentPanel.test.ts
Normal file
89
src/extensions/core/agentPanel.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
|
||||
// Per-test mock state, hoisted so the vi.mock factories below may close over it.
|
||||
// The extension's only job is the fail-closed flag gate: it flips the panel store's
|
||||
// `enabled` and closes an open panel when the flag turns off (the tab-bar button and the
|
||||
// dock both key off the store). The posthog mock exposes isFeatureEnabled (whose return
|
||||
// the gate reads) and onFeatureFlags (which captures the reload listener so the test can
|
||||
// drive flag transitions).
|
||||
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])
|
||||
// setup fires setupFlagGate, which awaits the (mocked) posthog-js dynamic import before
|
||||
// it captures the onFeatureFlags listener and runs the initial sync(). Flush macrotasks
|
||||
// until that listener is registered so tests can drive flag transitions through it.
|
||||
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).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
39
src/extensions/core/agentPanel.ts
Normal file
39
src/extensions/core/agentPanel.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { createPostHogFlagSource } from '@/workbench/extensions/agent/composables/agent/useAgentFeatureGate'
|
||||
import { useAgentPanelStore } from '@/workbench/extensions/agent/stores/agent/agentPanelStore'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
|
||||
/**
|
||||
* In-App Agent panel (FE-1187): the panel docks as a full-viewport-height right column
|
||||
* (the splitter overlay's agent-panel slot) and opens from the workflow-tab-bar "Ask
|
||||
* Comfy Agent" button. Gated by the PostHog flag `agent-in-app-experience`, fail-closed:
|
||||
* the button is absent and the panel unreachable until the flag evaluates true, and both
|
||||
* hide again (closing an open panel) if the flag turns off.
|
||||
*/
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.AgentPanel',
|
||||
setup() {
|
||||
const agentPanelStore = useAgentPanelStore()
|
||||
|
||||
async function setupFlagGate(): Promise<void> {
|
||||
// posthog-js is initialized (or not) by the telemetry provider; an
|
||||
// uninitialized client answers isFeatureEnabled with undefined, which the
|
||||
// flag source maps to false, so the gate stays closed by default.
|
||||
const posthog = (await import('posthog-js')).default
|
||||
const source = createPostHogFlagSource(posthog)
|
||||
const sync = (): void => {
|
||||
// The dev server shows the panel without the PostHog flag (which is scoped to a cloud
|
||||
// project the dev build may not read); test and production builds still gate on it.
|
||||
const forceInDev = import.meta.env.MODE === 'development'
|
||||
const enabled = forceInDev || source.isEnabled()
|
||||
agentPanelStore.enabled = enabled
|
||||
// Fail-closed: a flag flip-off also closes an open panel.
|
||||
if (!enabled) agentPanelStore.close('flag_disabled')
|
||||
}
|
||||
source.onChange?.(sync)
|
||||
sync()
|
||||
}
|
||||
|
||||
void setupFlagGate()
|
||||
}
|
||||
})
|
||||
@@ -37,6 +37,7 @@ if (isCloud) {
|
||||
await import('./cloudRemoteConfig')
|
||||
await import('./cloudBadges')
|
||||
await import('./cloudSessionCookie')
|
||||
await import('./agentPanel')
|
||||
}
|
||||
|
||||
// Feedback button for cloud and nightly builds
|
||||
|
||||
@@ -4316,6 +4316,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4496,5 +4504,70 @@
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
AuthMetadata,
|
||||
BeginCheckoutMetadata,
|
||||
DefaultViewSetMetadata,
|
||||
@@ -283,6 +290,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))
|
||||
}
|
||||
|
||||
@@ -11,6 +11,13 @@ import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
import type {
|
||||
AddCreditsClickMetadata,
|
||||
AgentEntryButtonClickedMetadata,
|
||||
AgentMessageFeedbackMetadata,
|
||||
AgentMessageSentMetadata,
|
||||
AgentNodeTaggedMetadata,
|
||||
AgentPanelClosedMetadata,
|
||||
AgentPanelOpenedMetadata,
|
||||
AgentWorkflowAppliedMetadata,
|
||||
AuthMetadata,
|
||||
BeginCheckoutMetadata,
|
||||
DefaultViewSetMetadata,
|
||||
@@ -564,6 +571,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,
|
||||
|
||||
@@ -375,6 +375,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'
|
||||
}
|
||||
|
||||
/**
|
||||
* Help center opened metadata
|
||||
*/
|
||||
@@ -615,6 +660,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
|
||||
}
|
||||
@@ -717,6 +775,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
|
||||
@@ -766,6 +835,13 @@ export type TelemetryEventProperties =
|
||||
| HelpCenterOpenedMetadata
|
||||
| HelpResourceClickedMetadata
|
||||
| HelpCenterClosedMetadata
|
||||
| AgentMessageFeedbackMetadata
|
||||
| AgentPanelOpenedMetadata
|
||||
| AgentPanelClosedMetadata
|
||||
| AgentEntryButtonClickedMetadata
|
||||
| AgentMessageSentMetadata
|
||||
| AgentNodeTaggedMetadata
|
||||
| AgentWorkflowAppliedMetadata
|
||||
| WorkflowCreatedMetadata
|
||||
| EnterLinearMetadata
|
||||
| ShareFlowMetadata
|
||||
|
||||
1561
src/workbench/extensions/agent/AgentPanelRoot.test.ts
Normal file
1561
src/workbench/extensions/agent/AgentPanelRoot.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
545
src/workbench/extensions/agent/AgentPanelRoot.vue
Normal file
545
src/workbench/extensions/agent/AgentPanelRoot.vue
Normal file
@@ -0,0 +1,545 @@
|
||||
<script setup lang="ts">
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
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'
|
||||
// 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/safetyTypes'
|
||||
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 { AgentThreadSummary } from './schemas/agentApiSchema'
|
||||
import type { ChatSession } from './stores/agent/agentChatHistoryStore'
|
||||
import type { WorkflowTurnContext } from './composables/agent/useAgentSession'
|
||||
import { useAgentSession } from './composables/agent/useAgentSession'
|
||||
import { useDraftCanvasApply } from './composables/agent/useDraftCanvasApply'
|
||||
import { useAgentDraftStore } from './stores/agent/agentDraftStore'
|
||||
import { useAgentWorkflowTabBindingStore } from './stores/agent/agentWorkflowTabBindingStore'
|
||||
import { buildTranscriptMarkdown } from './services/agent/agentTranscript'
|
||||
import { createAgentRestClient } from './services/agent/agentRestClient'
|
||||
import type { WorkflowUpload } 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()
|
||||
|
||||
// Rides the api's own typed /ws dispatch (which survives socket reconnects), so the
|
||||
// panel is not left deaf after a reconnect and each frame is JSON-parsed only once.
|
||||
const events = createAgentEventSource(api)
|
||||
|
||||
const workflowStore = useWorkflowStore()
|
||||
const bindingStore = useAgentWorkflowTabBindingStore()
|
||||
const draftStore = useAgentDraftStore()
|
||||
const agentPanelStore = useAgentPanelStore()
|
||||
|
||||
// B7 @-tagging: selected nodes stage removable chips; consume() on send clears
|
||||
// them and the same selection does not re-tag until it changes.
|
||||
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
|
||||
})
|
||||
|
||||
// The graph the canvas is showing (root or an open subgraph); the deprecated
|
||||
// app.graph getter only ever returns root, which misses subgraph-inner nodes.
|
||||
function viewedGraphNodes() {
|
||||
return app.canvas?.graph?.nodes ?? app.graph?.nodes ?? []
|
||||
}
|
||||
|
||||
// The @ picker lists the viewed graph's nodes, computed on open (not watched).
|
||||
function mentionableNodes(): SelectedNode[] {
|
||||
return viewedGraphNodes().map((node) => ({
|
||||
id: String(node.id),
|
||||
title: node.title || node.type
|
||||
}))
|
||||
}
|
||||
|
||||
// A bound tab resolves to its id; a cloud-saved tab offers its persisted uuid
|
||||
// speculatively (the server 403s foreign ids and the send retries without).
|
||||
function activeWorkflowTurnContext(): WorkflowTurnContext | undefined {
|
||||
const active = workflowStore.activeWorkflow
|
||||
if (!active) return undefined
|
||||
const bound = bindingStore.workflowIdFor(active.path)
|
||||
if (bound !== undefined)
|
||||
return { id: bound, speculative: false, tabPath: active.path }
|
||||
const savedId = active.activeState?.id
|
||||
return typeof savedId === 'string'
|
||||
? { id: savedId, speculative: true, tabPath: active.path }
|
||||
: undefined
|
||||
}
|
||||
|
||||
// B17/Jo QA: the panel names the tab the agent acts on.
|
||||
const activeTab = computed<ActiveTab | null>(() => {
|
||||
const active = workflowStore.activeWorkflow
|
||||
return active ? { name: active.filename } : null
|
||||
})
|
||||
|
||||
// The turn runs against the canvas as of the send: upload the serialized
|
||||
// graph when it changed since the last upload so the server can seed the
|
||||
// thread's draft from it (cloud postMessage.workflow contract; servers
|
||||
// without the field ignore it). Oversized graphs are skipped, matching the
|
||||
// server's 413 limit.
|
||||
const MAX_UPLOAD_CHARS = 2_000_000
|
||||
let lastSentGraph: string | null = null
|
||||
let snapshotTabPath: string | null = null
|
||||
|
||||
function takeWorkflowSnapshot(): WorkflowUpload | undefined {
|
||||
const graph = app.graph?.serialize()
|
||||
if (!graph) return undefined
|
||||
const serialized = JSON.stringify(graph)
|
||||
if (serialized === lastSentGraph || serialized.length > MAX_UPLOAD_CHARS)
|
||||
return undefined
|
||||
lastSentGraph = serialized
|
||||
snapshotTabPath = workflowStore.activeWorkflow?.path ?? null
|
||||
return { graph, last_seen_version: draftStore.version }
|
||||
}
|
||||
|
||||
// A fresh thread gets a fresh draft on the server: re-upload on next send.
|
||||
function resetSnapshotGuard(): void {
|
||||
lastSentGraph = null
|
||||
snapshotTabPath = null
|
||||
}
|
||||
|
||||
// Bind when the server confirmed the id we sent for that tab, or when the
|
||||
// send uploaded this tab's canvas: the draft then mirrors the tab, so even a
|
||||
// freshly minted id applies in place instead of opening a new tab.
|
||||
function onWorkflowAdopted(
|
||||
workflowId: string,
|
||||
sent: WorkflowTurnContext | undefined,
|
||||
uploaded: boolean
|
||||
): void {
|
||||
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,
|
||||
snapshot: takeWorkflowSnapshot
|
||||
}
|
||||
})
|
||||
|
||||
// Every agent error goes through the ONE existing host error modal — no
|
||||
// bespoke toasts.
|
||||
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
|
||||
}
|
||||
)
|
||||
|
||||
// The draft rides the wire untyped; validate through the host schema. A failed
|
||||
// apply is a workflow error, surfaced once per failure streak.
|
||||
let draftRejectionNotified = false
|
||||
|
||||
function surfaceDraftApplyFailure(details: string): void {
|
||||
console.warn(details)
|
||||
if (draftRejectionNotified) return
|
||||
draftRejectionNotified = true
|
||||
surfaceAgentError('agent_draft_apply_failed', details)
|
||||
}
|
||||
|
||||
// B16 graph write: patches route to their workflow_id's bound tab — in place when
|
||||
// active, lazily on refocus, via the conflict dialog when the user edited the tab.
|
||||
const conflictOpen = ref(false)
|
||||
// 'Keep mine' parks the draft until the user's next turn re-arms applies.
|
||||
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)
|
||||
return path === undefined ? null : workflowStore.getWorkflowByPath(path)
|
||||
}
|
||||
|
||||
// Agent writes autosave: re-baseline the tracker to the canvas as loaded (a
|
||||
// minted tab's stored baseline carries an id the canvas never adopts, so the
|
||||
// next capture would flip isModified and every following patch would raise
|
||||
// the conflict dialog). A manual edit after this re-arms the dialog as before.
|
||||
// The serialized canvas re-enters through the schema to type it honestly.
|
||||
async function autosaveAppliedDraft(tab: ComfyWorkflow): Promise<void> {
|
||||
const canvasState = app.graph?.serialize()
|
||||
const baseline = canvasState ? await validateComfyWorkflow(canvasState) : null
|
||||
if (baseline) tab.changeTracker?.reset(baseline)
|
||||
tab.isModified = false
|
||||
}
|
||||
|
||||
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
|
||||
// A user tab-switch mid-load must not misbind: identify the minted tab by
|
||||
// diffing the open set rather than trusting post-await focus.
|
||||
const openBefore = new Set(workflowStore.openWorkflows.map((w) => w.path))
|
||||
try {
|
||||
await app.loadGraphData(workflow, true, true, tab)
|
||||
draftRejectionNotified = false
|
||||
lastApplied = { workflowId, version }
|
||||
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(opened)
|
||||
}
|
||||
return
|
||||
}
|
||||
await autosaveAppliedDraft(tab)
|
||||
} catch (error) {
|
||||
surfaceDraftApplyFailure(
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function applyDraft(): Promise<void> {
|
||||
// Patches stream faster than loadGraphData settles; serialize applies and
|
||||
// coalesce whatever arrived meanwhile into one trailing re-run.
|
||||
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
|
||||
// An agent draft with no nodes is noise (the server's freshly minted
|
||||
// draft starts empty): applying it would blank a bound tab or conjure an
|
||||
// empty one. Park until a patch carries actual nodes.
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useDraftCanvasApply(() => void applyDraft())
|
||||
// A patch parked while its tab was backgrounded applies when the tab refocuses.
|
||||
watch(
|
||||
() => workflowStore.activeWorkflow?.path,
|
||||
() => void applyDraft()
|
||||
)
|
||||
// A rebind opens a new epoch: guards scoped to the old workflow must not leak
|
||||
// into it (a parked 'mine', a stale lastApplied, an open dialog).
|
||||
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') {
|
||||
// Decided: this draft version stays off the canvas; only a NEWER agent
|
||||
// edit asks again.
|
||||
lastApplied = { workflowId, version }
|
||||
return
|
||||
}
|
||||
// 'agent' overwrites the bound tab; 'newtab' leaves it with the user's state
|
||||
// and rebinds the workflow to a fresh tab, where future patches follow.
|
||||
void loadDraft(
|
||||
workflowId,
|
||||
version,
|
||||
content,
|
||||
choice === 'agent' ? boundTabFor(workflowId) : null
|
||||
)
|
||||
}
|
||||
|
||||
start()
|
||||
onBeforeUnmount(() => {
|
||||
// Cancel any in-flight turn so it does not keep generating and billing while the panel
|
||||
// is closed; unsubscribe only after the cancel settles so its ack is not torn down.
|
||||
void stopTurn().finally(stop)
|
||||
})
|
||||
|
||||
const history = useAgentChatHistoryStore()
|
||||
|
||||
const { copy } = useClipboard({ legacy: true })
|
||||
|
||||
// A null vote is a retraction of a prior thumb and is forwarded so the eval pipeline
|
||||
// records it rather than dropping it.
|
||||
function onFeedback(turnId: string, vote: 'up' | 'down' | null): void {
|
||||
useTelemetry()?.trackAgentMessageFeedback({
|
||||
message_id: turnId,
|
||||
vote,
|
||||
workflow_id: draftStore.workflowId
|
||||
})
|
||||
}
|
||||
|
||||
// title is "" until the server names the thread, so the row falls back to the preview
|
||||
// (the first prompt).
|
||||
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()
|
||||
}
|
||||
|
||||
// Only the active in-memory conversation has a transcript; a non-active id has nothing
|
||||
// to serialize and gets an info toast instead.
|
||||
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 {
|
||||
// A new turn re-arms applies AND replays the draft a 'Keep mine' parked —
|
||||
// its version may never advance, so the version watch alone cannot re-drive.
|
||||
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) => {
|
||||
// A failed send consumed the snapshot guard without reaching the
|
||||
// server; drop it so the next send re-uploads.
|
||||
if (!ok) resetSnapshotGuard()
|
||||
})
|
||||
}
|
||||
|
||||
function onStop(): void {
|
||||
void stopTurn()
|
||||
}
|
||||
|
||||
function onNewChat(): void {
|
||||
resetSnapshotGuard()
|
||||
newChat()
|
||||
}
|
||||
|
||||
// uploadImage answers {name, subfolder, type}; the send path forwards `name` as the
|
||||
// LoadImage-style ref.
|
||||
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 {
|
||||
useTelemetry()?.trackAgentNodeTagged({ source: 'mention_picker' })
|
||||
addSelectionTag(node)
|
||||
}
|
||||
|
||||
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))
|
||||
// Clear so re-picking the same file fires change again.
|
||||
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).
|
||||
134
src/workbench/extensions/agent/agentTheme.css
Normal file
134
src/workbench/extensions/agent/agentTheme.css
Normal file
@@ -0,0 +1,134 @@
|
||||
/* In-App Agent panel (FE-1187) theme, owned by the feature (not the design-system
|
||||
master). The agent components render on agent-* utility classes (bg-agent-surface,
|
||||
text-agent-fg, border-agent-border, ...); this file routes those tokens onto host
|
||||
semantic variables so the panel tracks host theme flips (light mode included)
|
||||
automatically. @theme inline substitutes the var reference into the generated
|
||||
utilities, which is what makes the flip work without touching any component.
|
||||
|
||||
The panel's Figma frames are dark-only; light-mode rendering follows the host theme
|
||||
here, an intentional divergence deferred to the FE-1187 design-alignment pass. */
|
||||
@theme inline {
|
||||
/* agent token -> host semantic token
|
||||
Aliases tuned to the design prototype's token choices (visual reference). */
|
||||
/* surface -> base background (#171718 dark) */
|
||||
--color-agent-surface: var(--color-base-background);
|
||||
/* surface-raised -> secondary background (composer, chips, bubbles) */
|
||||
--color-agent-surface-raised: var(--color-secondary-background);
|
||||
/* surface-hover -> secondary background hover */
|
||||
--color-agent-surface-hover: var(--color-secondary-background-hover);
|
||||
/* fg -> base foreground */
|
||||
--color-agent-fg: var(--color-base-foreground);
|
||||
/* fg-muted -> muted foreground */
|
||||
--color-agent-fg-muted: var(--color-muted-foreground);
|
||||
/* fg-subtle: the reference uses muted-foreground for every secondary text run */
|
||||
--color-agent-fg-subtle: var(--color-muted-foreground);
|
||||
/* border -> node component border (header/session dividers) */
|
||||
--color-agent-border: var(--color-component-node-border);
|
||||
/* border-strong -> default border (composer resting border) */
|
||||
--color-agent-border-strong: var(--color-border-default);
|
||||
/* accent -> primary background */
|
||||
--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;
|
||||
/* danger -> destructive background */
|
||||
--color-agent-danger: var(--color-destructive-background);
|
||||
/* success -> success background */
|
||||
--color-agent-success: var(--color-success-background);
|
||||
/* pill: subtle chip surface with no defensible host pairing distinct from
|
||||
surface-raised; kept literal pending the FE-1187 design pass. */
|
||||
--color-agent-pill: #303036;
|
||||
|
||||
/* Radius is not theme-flipped and the host exposes no matching --radius-* step, so
|
||||
the agent radius stays a literal here. */
|
||||
--radius-agent: 0.75rem;
|
||||
}
|
||||
|
||||
/* Structural (theme-independent) collapsible animation for the agent tool
|
||||
disclosures, driven by the --reka-collapsible-content-height var Reka sets on
|
||||
the content element. Namespaced agent-* so this globally-imported sheet cannot
|
||||
retarget the host's animate-collapsible-* utilities (undefined on main) in the
|
||||
sharing dialogs. */
|
||||
@theme {
|
||||
--animate-agent-collapsible-down: agent-collapsible-down 200ms ease-out;
|
||||
--animate-agent-collapsible-up: agent-collapsible-up 200ms ease-out;
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shimmering "Thinking…" text (design reference): a light band sweeps across the muted
|
||||
label while the agent reasons. Falls back to the muted color under reduced-motion. */
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -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,18 @@
|
||||
<script setup lang="ts">
|
||||
export interface ActiveTab {
|
||||
name: string
|
||||
}
|
||||
|
||||
// Shows which workflow the agent is acting on. Hidden when there is no active tab.
|
||||
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>
|
||||
181
src/workbench/extensions/agent/components/agent/AgentPanel.vue
Normal file
181
src/workbench/extensions/agent/components/agent/AgentPanel.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<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/safetyTypes'
|
||||
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 SessionBar from './SessionBar.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]
|
||||
}>()
|
||||
|
||||
// In-panel Chat History screen (B12): the session bar opens it, the back arrow / picking a
|
||||
// chat / starting a new chat closes it. Rendered inline instead of a teleported drawer.
|
||||
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()
|
||||
|
||||
// Session bar title (B4): the first user prompt titles the session; a fresh session reads
|
||||
// "Untitled" (SessionBar's fallback), per the DES-455 chat-history flow.
|
||||
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
|
||||
})
|
||||
|
||||
// The root wires the file picker + upload lifecycle and drives the composer's
|
||||
// staged chips through these, without the panel owning the upload path.
|
||||
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>
|
||||
<SessionBar :title="sessionTitle" @open-history="onOpenHistory" />
|
||||
|
||||
<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,107 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type {
|
||||
ChatSession,
|
||||
HistoryGroups
|
||||
} from '../../stores/agent/agentChatHistoryStore'
|
||||
|
||||
// In-panel Chat History screen (Figma B12): replaces the conversation within the panel,
|
||||
// with a "<- Chat History" back affordance. Sessions are grouped by recency; a row loads
|
||||
// its chat, and a hover reveals copy / delete. Rendered inline (not a teleported drawer)
|
||||
// so it never fights the docked panel's stacking context.
|
||||
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)
|
||||
})
|
||||
})
|
||||
195
src/workbench/extensions/agent/components/agent/Composer.vue
Normal file
195
src/workbench/extensions/agent/components/agent/Composer.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<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'
|
||||
import SelectionTagChip from './composer/SelectionTagChip.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]
|
||||
}>()
|
||||
|
||||
// Snapshotted when the @ menu opens; the graph is not watched.
|
||||
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')
|
||||
})
|
||||
|
||||
// Enter sends; Shift+Enter inserts a newline (default textarea behavior). An Enter that
|
||||
// commits an IME composition (CJK input) must NOT send the half-composed text.
|
||||
function onEnter(event: KeyboardEvent): void {
|
||||
if (event.isComposing || event.shiftKey) return
|
||||
event.preventDefault()
|
||||
composer.submit()
|
||||
}
|
||||
|
||||
// Parent (asset tray / file picker) stages attachments through this.
|
||||
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">
|
||||
<SelectionTagChip
|
||||
v-for="tag in selectionTags"
|
||||
:key="tag.id"
|
||||
:title="tag.title"
|
||||
@remove="emit('removeTag', tag.id)"
|
||||
/>
|
||||
</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,130 @@
|
||||
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'
|
||||
|
||||
// Capture the IntersectionObserver callback useIntersectionObserver registers so the test
|
||||
// can drive the "at bottom" signal directly — happy-dom has no real observer, and the pill
|
||||
// only shows once the sentinel scrolls out of view.
|
||||
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'))
|
||||
// The thinking chip shows before any text streams.
|
||||
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()
|
||||
// The markdown bold survives sanitization as a real <strong> element.
|
||||
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] }
|
||||
})
|
||||
|
||||
// At bottom initially: no pill.
|
||||
expect(screen.queryByText('Latest')).not.toBeInTheDocument()
|
||||
|
||||
// Drive the sentinel out of view -> the pill appears.
|
||||
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,87 @@
|
||||
<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()
|
||||
|
||||
// Keep the newest turn in view as content streams in, but ONLY when the user is already at
|
||||
// the bottom — if they scrolled up to re-read, a streaming delta must not yank them back.
|
||||
// The bottom sentinel's visibility is the "at bottom" signal.
|
||||
const bottom = ref<HTMLElement>()
|
||||
const atBottom = ref(true)
|
||||
|
||||
useIntersectionObserver(bottom, ([entry]) => {
|
||||
atBottom.value = entry?.isIntersecting ?? true
|
||||
})
|
||||
|
||||
function scrollToLatest(): void {
|
||||
bottom.value?.scrollIntoView({ block: 'end' })
|
||||
}
|
||||
|
||||
// Only the LAST entry can grow while a turn streams (earlier ones are settled), so this
|
||||
// signal replaces a deep watch that re-walked every entry per token.
|
||||
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,58 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SuggestedPromptChip from './composer/SuggestedPromptChip.vue'
|
||||
|
||||
const { userName } = defineProps<{ userName?: string }>()
|
||||
const emit = defineEmits<{ insert: [text: string] }>()
|
||||
|
||||
const { t, tm } = useI18n()
|
||||
|
||||
const prompts = computed(() => tm('agent.suggestedPrompts') as string[])
|
||||
|
||||
// Leading icon per suggested prompt (Figma B5), in the same order as agent.suggestedPrompts.
|
||||
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">
|
||||
<SuggestedPromptChip
|
||||
v-for="(prompt, index) in prompts"
|
||||
:key="index"
|
||||
:text="prompt"
|
||||
:icon="promptIcons[index] ?? 'icon-[lucide--sparkles]'"
|
||||
@insert="emit('insert', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import { 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'
|
||||
|
||||
// Host-DOM anchored: the step points at a host selector (e.g. '#graph-canvas'). Where the
|
||||
// anchor is absent (the standalone harness) the coach simply shows nothing — it must NOT
|
||||
// finish, which would burn the persist-once flag before the user ever saw the step.
|
||||
const { step, storageKey } = defineProps<{
|
||||
step: CoachStep
|
||||
storageKey?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { active, finish } = useOnboarding(storageKey)
|
||||
|
||||
const { width, height } = useWindowSize()
|
||||
const CARD_W = 256
|
||||
const CARD_H = 160
|
||||
const MARGIN = 8
|
||||
|
||||
const cardStyle = ref<Record<string, string> | null>(null)
|
||||
|
||||
// flush:'post' so the effect runs AFTER render — a synchronous pre-mount pass would miss
|
||||
// every app-rendered anchor. A missing anchor leaves the card hidden without consuming the
|
||||
// step. NOTE(follow-up): re-check via MutationObserver to skip a step whose anchor never
|
||||
// appears rather than blocking on it.
|
||||
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" 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,83 @@
|
||||
<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'
|
||||
|
||||
// Header per the design reference: title + neutral ALPHA badge (no logo mark) + the
|
||||
// active tab name, then new chat, width toggle (420 <-> 960 dock), close. History is
|
||||
// reached from the session bar (B4), not the header.
|
||||
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,27 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// Session / title bar (Figma B4): the row under the header showing the current chat title
|
||||
// (or "Untitled" for a fresh session). The compact pill is a button that opens Chat
|
||||
// History; its tooltip reads "Show chat history" to name the action.
|
||||
const { title } = defineProps<{ title?: string }>()
|
||||
const emit = defineEmits<{ openHistory: [] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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="emit('openHistory')"
|
||||
>
|
||||
<span class="icon-[lucide--align-justify] size-3.5 shrink-0" />
|
||||
<span class="max-w-56 truncate">{{
|
||||
title || t('agent.newChatTitle')
|
||||
}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</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,27 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { title } = defineProps<{
|
||||
title: string
|
||||
}>()
|
||||
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.5 text-xs"
|
||||
>
|
||||
<span class="text-agent-fg-subtle icon-[lucide--at-sign] size-3.5" />
|
||||
<span class="max-w-40 truncate">{{ 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('remove')"
|
||||
>
|
||||
<span class="icon-[lucide--x] size-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
// Click INSERTS the prompt into the composer (never sends) per the locked Figma decision.
|
||||
// Per the design reference: a rounded-full pill (leading icon + label) that stacks
|
||||
// full-width in narrow panels and wraps centered from 460px container width up.
|
||||
const { text, icon } = defineProps<{ text: string; icon: string }>()
|
||||
const emit = defineEmits<{ insert: [text: string] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
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', text)"
|
||||
>
|
||||
<span
|
||||
:class="cn('text-agent-fg-muted size-3 shrink-0', icon)"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="truncate">{{ text }}</span>
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,107 @@
|
||||
<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 ThinkingStatus from './ThinkingStatus.vue'
|
||||
import ToolCallGroup from './ToolCallGroup.vue'
|
||||
|
||||
const { message } = defineProps<{ message: AssistantMessage }>()
|
||||
const emit = defineEmits<{ feedback: [vote: 'up' | 'down' | null] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Adjacent tool parts render as one grouped card; text and notices stay as their
|
||||
// own rows, preserving the transport's interleaved order.
|
||||
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">
|
||||
<ThinkingStatus
|
||||
v-if="message.thinking || (message.streaming && !message.parts.length)"
|
||||
/>
|
||||
|
||||
<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,51 @@
|
||||
<script setup lang="ts">
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
// Spec (B10): framed block with a header (type icon + language label + copy
|
||||
// button) over a monospace body. Syntax highlighting is intentionally absent.
|
||||
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,106 @@
|
||||
// @vitest-environment jsdom
|
||||
// DOMPurify's sanitize walks a real DOM tree; happy-dom drops the sanitized inner
|
||||
// tree when re-serializing DOMPurify's <template> round-trip, so this XSS-guard suite
|
||||
// runs against jsdom, the reference DOM DOMPurify is tested against upstream.
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { i18n } from '@/i18n'
|
||||
|
||||
import MarkdownStream from './MarkdownStream.vue'
|
||||
|
||||
// Pins the agent-reply rendering contract (markdown in, safe HTML out) as it flows
|
||||
// through the shared platform renderer; the renderer itself has its own tests.
|
||||
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] }
|
||||
})
|
||||
// The whole inner fence is ONE literal code body, not a nested block.
|
||||
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' }
|
||||
})
|
||||
// Rendered as an inline <code> span inside prose, not a framed block.
|
||||
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,88 @@
|
||||
<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
|
||||
|
||||
// Fenced code blocks are pulled out via marked's lexer (which handles 4+
|
||||
// backtick fences and inline backtick spans correctly) so they render as
|
||||
// framed CodeBlocks with copy chrome; the prose between them goes through the
|
||||
// sanitizing markdown renderer. Indented code stays prose so it keeps its
|
||||
// inline rendering rather than being promoted to a framed block.
|
||||
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
|
||||
})
|
||||
|
||||
// Prose styles read from the deployed prototype's .agent-markdown rules.
|
||||
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,70 @@
|
||||
<script setup lang="ts">
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
// Thumbs are mutually exclusive; clicking the active one clears it. Feedback is emitted
|
||||
// so the container can forward it to the real submit_feedback tool.
|
||||
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,14 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// Plain "Thinking..." per the locked Figma decision (the monolith's rotating status
|
||||
// words are dropped).
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div 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>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { PartState } from '../../../services/agent/agentMessageParts'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
// One tool row (or a run of identical calls collapsed with a count). `ok` is undefined
|
||||
// while the call is still running.
|
||||
const {
|
||||
name,
|
||||
state,
|
||||
ok,
|
||||
count = 1
|
||||
} = defineProps<{
|
||||
name: string
|
||||
state: PartState
|
||||
ok?: boolean
|
||||
count?: number
|
||||
}>()
|
||||
|
||||
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="truncate font-mono text-xs">{{ name }}</span>
|
||||
<span v-if="count > 1" class="text-agent-fg-subtle text-xs"
|
||||
>×{{ count }}</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,96 @@
|
||||
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('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] }
|
||||
})
|
||||
|
||||
// Every call settled clean: the rows collapse behind the header.
|
||||
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,101 @@
|
||||
<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()
|
||||
|
||||
// Consecutive identical tool names collapse into one row with a count (same-tool xN).
|
||||
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)
|
||||
)
|
||||
|
||||
// Live while running or if anything failed; collapse once every call settled clean. Drive
|
||||
// open BOTH ways off the source: a later tool re-entering the group (running again) or a
|
||||
// tool failing must re-open it, not just the first clean settle. Manual re-open of a
|
||||
// settled clean group still sticks because the source does not change then.
|
||||
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,62 @@
|
||||
<script setup lang="ts">
|
||||
import type { UserAttachment } from '../../../stores/agent/agentConversationStore'
|
||||
|
||||
// User message per the design reference: a right-aligned pill, no avatar, with
|
||||
// sent attachments (B11 thumbnail grid) and @-tags kept visible above the text.
|
||||
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,113 @@
|
||||
<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'
|
||||
import type { ConflictChoice } from './safetyTypes'
|
||||
|
||||
// DES-502 card: Cancel/✕ defer the decision, "Keep my changes" keeps this draft
|
||||
// off the canvas, "Accept agent changes" applies it (dropdown: into a new tab).
|
||||
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,3 @@
|
||||
// 'cancel' defers the decision (the next turn re-asks); the other three act on
|
||||
// the pending draft, per DES-502.
|
||||
export type ConflictChoice = 'agent' | 'mine' | 'newtab' | 'cancel'
|
||||
@@ -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,26 @@
|
||||
export const AGENT_PANEL_FLAG = 'agent-in-app-experience'
|
||||
|
||||
export interface AgentFlagSource {
|
||||
// Must return false while flags are unknown/not yet loaded (fail closed).
|
||||
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 {
|
||||
// undefined = flags not loaded -> fail closed.
|
||||
isEnabled: () => posthog.isFeatureEnabled(flag) === true,
|
||||
onChange: (listener) => {
|
||||
const unsubscribe = posthog.onFeatureFlags(listener)
|
||||
return typeof unsubscribe === 'function' ? unsubscribe : () => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,757 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type {
|
||||
AgentCancelAccepted,
|
||||
AgentDraftSnapshot,
|
||||
AgentMessages,
|
||||
AgentThreadSummary,
|
||||
AgentTurnAccepted,
|
||||
UploadImageResult
|
||||
} from '../../schemas/agentApiSchema'
|
||||
import { zAgentWsEvent } from '../../schemas/agentApiSchema'
|
||||
import { AgentApiError } from '../../services/agent/agentRestClient'
|
||||
import type {
|
||||
AgentRestClient,
|
||||
PostMessageInput
|
||||
} from '../../services/agent/agentRestClient'
|
||||
import { useAgentConversationStore } from '../../stores/agent/agentConversationStore'
|
||||
import { useAgentDraftStore } from '../../stores/agent/agentDraftStore'
|
||||
|
||||
import type { AgentEventSource } from './useAgentSession'
|
||||
import { useAgentSession } from './useAgentSession'
|
||||
|
||||
// A fully typed fake REST client: each method is a vi.fn the test arranges per scenario.
|
||||
function fakeRest(overrides: Partial<AgentRestClient> = {}): AgentRestClient {
|
||||
const base: AgentRestClient = {
|
||||
postMessage: vi.fn(
|
||||
async (): Promise<AgentTurnAccepted> => ({
|
||||
thread_id: 'th-1',
|
||||
message_id: 'msg-1',
|
||||
workflow_id: 'wf-1'
|
||||
})
|
||||
),
|
||||
getMessages: vi.fn(async (): Promise<AgentMessages> => []),
|
||||
listThreads: vi.fn(async (): Promise<AgentThreadSummary[]> => []),
|
||||
cancelMessage: vi.fn(
|
||||
async (): Promise<AgentCancelAccepted> => ({ status: 'cancelling' })
|
||||
),
|
||||
getDraft: vi.fn(
|
||||
async (): Promise<AgentDraftSnapshot> => ({ content: {}, version: 1 })
|
||||
),
|
||||
uploadImage: vi.fn(
|
||||
async (): Promise<UploadImageResult> => ({
|
||||
name: 'n',
|
||||
subfolder: '',
|
||||
type: 'input'
|
||||
})
|
||||
)
|
||||
}
|
||||
return { ...base, ...overrides }
|
||||
}
|
||||
|
||||
// A fake event source that captures the session's listeners so the test can push frames
|
||||
// and toggle liveness by hand.
|
||||
function fakeEvents() {
|
||||
let listener: ((raw: unknown) => void) | undefined
|
||||
let statusListener: ((live: boolean) => void) | undefined
|
||||
const source: AgentEventSource = {
|
||||
subscribe(fn) {
|
||||
listener = fn
|
||||
return () => {
|
||||
listener = undefined
|
||||
}
|
||||
},
|
||||
onStatus(fn) {
|
||||
statusListener = fn
|
||||
return () => {
|
||||
statusListener = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
source,
|
||||
emit: (raw: unknown) => listener?.(raw),
|
||||
status: (live: boolean) => statusListener?.(live)
|
||||
}
|
||||
}
|
||||
|
||||
const wire = (raw: unknown): unknown => zAgentWsEvent.parse(raw)
|
||||
const thinking = (id: string, delta: string) =>
|
||||
wire({
|
||||
type: 'agent_thinking',
|
||||
data: { delta, message_id: id, thread_id: 'th-1' }
|
||||
})
|
||||
const delta = (id: string, text: string) =>
|
||||
wire({
|
||||
type: 'agent_message_delta',
|
||||
data: { delta: text, message_id: id, thread_id: 'th-1' }
|
||||
})
|
||||
const done = (id: string) =>
|
||||
wire({
|
||||
type: 'agent_message_done',
|
||||
data: { message_id: id, thread_id: 'th-1', usage: null }
|
||||
})
|
||||
const draftPatch = (workflowId: string, version: number) =>
|
||||
wire({
|
||||
type: 'draft_patch',
|
||||
data: {
|
||||
base_version: version - 1,
|
||||
version,
|
||||
content: { n: 1 },
|
||||
workflow_id: workflowId
|
||||
}
|
||||
})
|
||||
const draftVersion = (workflowId: string, version: number) =>
|
||||
wire({ type: 'draft_version', data: { version, workflow_id: workflowId } })
|
||||
|
||||
describe('useAgentSession (v1 composition root)', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('(a) posts to new, adopts ids, records the user turn, and renders a settled reply', async () => {
|
||||
const rest = fakeRest()
|
||||
const { source, emit } = fakeEvents()
|
||||
const session = useAgentSession({ rest, events: source })
|
||||
session.start()
|
||||
|
||||
await session.sendMessage('make me a cat')
|
||||
|
||||
expect(rest.postMessage).toHaveBeenCalledWith('new', {
|
||||
content: 'make me a cat',
|
||||
selection: undefined,
|
||||
attachments: undefined
|
||||
})
|
||||
expect(session.threadId.value).toBe('th-1')
|
||||
|
||||
emit(thinking('msg-1', 'planning'))
|
||||
emit(delta('msg-1', 'A cat.'))
|
||||
emit(done('msg-1'))
|
||||
|
||||
const roles = session.entries.value.map((e) => e.role)
|
||||
expect(roles).toEqual(['user', 'assistant'])
|
||||
const assistant = session.entries.value[1]
|
||||
expect(assistant).toMatchObject({
|
||||
role: 'assistant',
|
||||
streaming: false
|
||||
})
|
||||
expect(session.isStreaming.value).toBe(false)
|
||||
})
|
||||
|
||||
it('(b) a second send posts to the adopted threadId, not new', async () => {
|
||||
const postMessage = vi
|
||||
.fn<
|
||||
(threadId: string, req: PostMessageInput) => Promise<AgentTurnAccepted>
|
||||
>()
|
||||
.mockResolvedValueOnce({ thread_id: 'th-9', message_id: 'msg-1' })
|
||||
.mockResolvedValueOnce({ thread_id: 'th-9', message_id: 'msg-2' })
|
||||
const rest = fakeRest({ postMessage })
|
||||
const { source } = fakeEvents()
|
||||
const session = useAgentSession({ rest, events: source })
|
||||
session.start()
|
||||
|
||||
await session.sendMessage('first')
|
||||
await session.sendMessage('second')
|
||||
|
||||
expect(postMessage.mock.calls[0][0]).toBe('new')
|
||||
expect(postMessage.mock.calls[1][0]).toBe('th-9')
|
||||
})
|
||||
|
||||
it('(b2) a remounted session continues the persisted thread, not a new one', async () => {
|
||||
const postMessage = vi
|
||||
.fn<
|
||||
(threadId: string, req: PostMessageInput) => Promise<AgentTurnAccepted>
|
||||
>()
|
||||
.mockResolvedValueOnce({ thread_id: 'th-9', message_id: 'msg-1' })
|
||||
.mockResolvedValueOnce({ thread_id: 'th-9', message_id: 'msg-2' })
|
||||
const rest = fakeRest({ postMessage })
|
||||
|
||||
// First mount adopts the thread, then unmounts (the panel closes).
|
||||
const first = useAgentSession({ rest, events: fakeEvents().source })
|
||||
first.start()
|
||||
await first.sendMessage('first')
|
||||
first.stop()
|
||||
|
||||
// A remount is a fresh composable over the same pinia: it must read the persisted thread
|
||||
// and post there, not split the transcript across a second 'new' thread.
|
||||
const second = useAgentSession({ rest, events: fakeEvents().source })
|
||||
second.start()
|
||||
await second.sendMessage('second')
|
||||
|
||||
expect(postMessage.mock.calls[0][0]).toBe('new')
|
||||
expect(postMessage.mock.calls[1][0]).toBe('th-9')
|
||||
})
|
||||
|
||||
it('(c) a postMessage AgentApiError surfaces inline only (no toast) and opens no live turn', async () => {
|
||||
const postMessage = vi
|
||||
.fn<
|
||||
(threadId: string, req: PostMessageInput) => Promise<AgentTurnAccepted>
|
||||
>()
|
||||
.mockRejectedValue(new AgentApiError('server exploded', 500, undefined))
|
||||
const rest = fakeRest({ postMessage })
|
||||
const { source } = fakeEvents()
|
||||
const session = useAgentSession({ rest, events: source })
|
||||
session.start()
|
||||
|
||||
const ok = await session.sendMessage('boom')
|
||||
expect(ok).toBe(false)
|
||||
|
||||
// A send failure already has an inline conversation row, so it must NOT also raise a
|
||||
// session notice (host toast) — that double-surfaces and, top-right, collides with the
|
||||
// docked panel. The failed send renders as a settled exchange (user + error notice),
|
||||
// not a live turn: no spinner is left running.
|
||||
expect(session.notices.value).toHaveLength(0)
|
||||
expect(session.entries.value.map((e) => e.role)).toEqual([
|
||||
'user',
|
||||
'assistant'
|
||||
])
|
||||
expect(session.isStreaming.value).toBe(false)
|
||||
})
|
||||
|
||||
it('(d) stopTurn cancels the active turn; a 409 is swallowed and the socket settles it', async () => {
|
||||
const cancelMessage = vi
|
||||
.fn<
|
||||
(threadId: string, messageId: string) => Promise<AgentCancelAccepted>
|
||||
>()
|
||||
.mockRejectedValue(new AgentApiError('already done', 409, undefined))
|
||||
const rest = fakeRest({ cancelMessage })
|
||||
const { source, emit } = fakeEvents()
|
||||
const session = useAgentSession({ rest, events: source })
|
||||
session.start()
|
||||
|
||||
await session.sendMessage('go')
|
||||
emit(delta('msg-1', 'working'))
|
||||
expect(session.isStreaming.value).toBe(true)
|
||||
|
||||
await session.stopTurn()
|
||||
expect(cancelMessage).toHaveBeenCalledWith('th-1', 'msg-1')
|
||||
// A 409 is benign — no notice, and no local abort (the socket settles the turn).
|
||||
expect(session.notices.value).toHaveLength(0)
|
||||
expect(session.isStreaming.value).toBe(true)
|
||||
|
||||
emit(delta('msg-1', ' Stopped at your request.'))
|
||||
emit(done('msg-1'))
|
||||
expect(session.isStreaming.value).toBe(false)
|
||||
})
|
||||
|
||||
it('(d2) stopTurn rejecting with a network TypeError surfaces a notice, not an unhandled rejection', async () => {
|
||||
// Mirrors resyncDraft (n): newChat/onStop void stopTurn(), so a non-AgentApiError
|
||||
// rethrow would escape as an unhandled rejection. The catch must pushError instead;
|
||||
// the notice is the proof the error was surfaced rather than swallowed or escaped.
|
||||
const cancelMessage = vi
|
||||
.fn<
|
||||
(threadId: string, messageId: string) => Promise<AgentCancelAccepted>
|
||||
>()
|
||||
.mockRejectedValue(new TypeError('fetch failed'))
|
||||
const rest = fakeRest({ cancelMessage })
|
||||
const { source, emit } = fakeEvents()
|
||||
const session = useAgentSession({ rest, events: source })
|
||||
session.start()
|
||||
|
||||
await session.sendMessage('go')
|
||||
emit(delta('msg-1', 'working'))
|
||||
|
||||
await session.stopTurn()
|
||||
|
||||
expect(cancelMessage).toHaveBeenCalledWith('th-1', 'msg-1')
|
||||
expect(session.notices.value).toEqual([
|
||||
{ level: 'error', text: 'fetch failed' }
|
||||
])
|
||||
})
|
||||
|
||||
it('(e) foreign chat events are ignored, but a mid-turn draft_patch still adopts', async () => {
|
||||
const rest = fakeRest()
|
||||
const { source, emit } = fakeEvents()
|
||||
const session = useAgentSession({ rest, events: source })
|
||||
session.start()
|
||||
|
||||
// The send's ack binds the draft store to the server's workflow (wf-1).
|
||||
await session.sendMessage('hi')
|
||||
emit(delta('msg-1', 'kept'))
|
||||
// A chat event for a different turn is dropped.
|
||||
emit(delta('msg-OTHER', 'DROP'))
|
||||
// A draft_patch arriving during this turn still adopts (draft is not turn-filtered).
|
||||
emit(draftPatch('wf-1', 5))
|
||||
|
||||
const assistant = session.entries.value.at(-1)
|
||||
const textPart =
|
||||
assistant?.role === 'assistant'
|
||||
? assistant.parts.find((p) => p.type === 'text')
|
||||
: undefined
|
||||
expect(textPart).toMatchObject({ text: 'kept' })
|
||||
const draft = useAgentDraftStore()
|
||||
expect(draft.version).toBe(5)
|
||||
expect(draft.content).toEqual({ n: 1 })
|
||||
})
|
||||
|
||||
it('(f) draft_version ahead triggers exactly one single-flight resync', async () => {
|
||||
let resolveDraft: ((snapshot: AgentDraftSnapshot) => void) | undefined
|
||||
const getDraft = vi.fn<(workflowId: string) => Promise<AgentDraftSnapshot>>(
|
||||
() =>
|
||||
new Promise<AgentDraftSnapshot>((resolve) => {
|
||||
resolveDraft = resolve
|
||||
})
|
||||
)
|
||||
const rest = fakeRest({ getDraft })
|
||||
const { source, emit } = fakeEvents()
|
||||
const session = useAgentSession({ rest, events: source })
|
||||
session.start()
|
||||
// The server-owned workflow id is adopted from a message ack; simulate that binding.
|
||||
const draft = useAgentDraftStore()
|
||||
draft.bind('wf-1')
|
||||
|
||||
// Two 'behind' heartbeats before the fetch resolves collapse to ONE getDraft.
|
||||
emit(draftVersion('wf-1', 9))
|
||||
emit(draftVersion('wf-1', 10))
|
||||
expect(getDraft).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolveDraft?.({ content: { adopted: true }, version: 10 })
|
||||
await Promise.resolve()
|
||||
expect(draft.version).toBe(10)
|
||||
expect(draft.content).toEqual({ adopted: true })
|
||||
})
|
||||
|
||||
it('(g) onStatus(false) aborts the active turn; onStatus(true) resyncs the draft', async () => {
|
||||
const rest = fakeRest()
|
||||
const { source, emit, status } = fakeEvents()
|
||||
const session = useAgentSession({ rest, events: source })
|
||||
session.start()
|
||||
|
||||
// The send's ack binds the draft store; no resync fires until a reconnect.
|
||||
await session.sendMessage('go')
|
||||
emit(delta('msg-1', 'partial'))
|
||||
expect(session.isStreaming.value).toBe(true)
|
||||
expect(rest.getDraft).not.toHaveBeenCalled()
|
||||
|
||||
status(false)
|
||||
expect(session.isStreaming.value).toBe(false)
|
||||
|
||||
status(true)
|
||||
expect(rest.getDraft).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('(h) attachments pass through to the postMessage wire body', async () => {
|
||||
const rest = fakeRest()
|
||||
const { source } = fakeEvents()
|
||||
const session = useAgentSession({ rest, events: source })
|
||||
session.start()
|
||||
|
||||
await session.sendMessage('with files', [
|
||||
{ ref: 'upload_a.png', name: 'a.png', previewUrl: 'blob:a' },
|
||||
{ ref: 'upload_b.png', name: 'b.png' }
|
||||
])
|
||||
|
||||
// Only the server refs ride the wire; name/preview stay on the transcript.
|
||||
expect(rest.postMessage).toHaveBeenCalledWith('new', {
|
||||
content: 'with files',
|
||||
selection: undefined,
|
||||
attachments: ['upload_a.png', 'upload_b.png']
|
||||
})
|
||||
})
|
||||
|
||||
it('(h2) tags ride as node_ids on the POST selection', async () => {
|
||||
const rest = fakeRest()
|
||||
const session = useAgentSession({ rest, events: fakeEvents().source })
|
||||
session.start()
|
||||
await session.sendMessage('explain', undefined, [
|
||||
{ id: '5', title: 'K' },
|
||||
{ id: '6', title: 'Decode' }
|
||||
])
|
||||
const body = vi.mocked(rest.postMessage).mock.calls[0][1]
|
||||
expect(body.selection).toEqual({ node_ids: ['5', '6'] })
|
||||
})
|
||||
|
||||
it('(h4) attaches the workflow snapshot and reports the upload to adopted', async () => {
|
||||
const rest = fakeRest()
|
||||
const adopted = vi.fn()
|
||||
const session = useAgentSession({
|
||||
rest,
|
||||
events: fakeEvents().source,
|
||||
workflow: {
|
||||
current: () => undefined,
|
||||
adopted,
|
||||
snapshot: () => ({
|
||||
graph: { nodes: [{ id: 1 }] },
|
||||
last_seen_version: null
|
||||
})
|
||||
}
|
||||
})
|
||||
session.start()
|
||||
await session.sendMessage('hi')
|
||||
expect(vi.mocked(rest.postMessage).mock.calls[0][1]).toMatchObject({
|
||||
workflow: { graph: { nodes: [{ id: 1 }] }, last_seen_version: null }
|
||||
})
|
||||
expect(adopted).toHaveBeenCalledWith('wf-1', undefined, true)
|
||||
})
|
||||
|
||||
it("(i2) loadThread drops the previous thread's draft binding", async () => {
|
||||
const rest = fakeRest()
|
||||
const { source } = fakeEvents()
|
||||
const session = useAgentSession({ rest, events: source })
|
||||
session.start()
|
||||
|
||||
await session.sendMessage('bind me')
|
||||
expect(useAgentDraftStore().workflowId).toBe('wf-1')
|
||||
|
||||
// A late patch for the abandoned thread must find nothing to land on.
|
||||
await session.loadThread('th-2')
|
||||
expect(useAgentDraftStore().workflowId).toBeNull()
|
||||
})
|
||||
|
||||
it('(j) a rebind during resyncDraft does not adopt the stale workflow draft', async () => {
|
||||
let resolveDraft: ((snapshot: AgentDraftSnapshot) => void) | undefined
|
||||
const getDraft = vi.fn<(workflowId: string) => Promise<AgentDraftSnapshot>>(
|
||||
() =>
|
||||
new Promise<AgentDraftSnapshot>((resolve) => {
|
||||
resolveDraft = resolve
|
||||
})
|
||||
)
|
||||
const rest = fakeRest({ getDraft })
|
||||
const { source, status } = fakeEvents()
|
||||
const session = useAgentSession({ rest, events: source })
|
||||
session.start()
|
||||
|
||||
// Bind wf-1 (as a message ack would) and let a reconnect start its resync.
|
||||
const draft = useAgentDraftStore()
|
||||
draft.bind('wf-1')
|
||||
status(true)
|
||||
expect(getDraft).toHaveBeenCalledWith('wf-1')
|
||||
|
||||
// The bound workflow changes before the in-flight fetch resolves.
|
||||
draft.bind('wf-2')
|
||||
|
||||
// The stale snapshot for wf-1 arrives; it must NOT be adopted onto wf-2.
|
||||
resolveDraft?.({ content: { stale: true }, version: 99 })
|
||||
await Promise.resolve()
|
||||
|
||||
expect(draft.workflowId).toBe('wf-2')
|
||||
expect(draft.content).toBeNull()
|
||||
expect(draft.version).toBeNull()
|
||||
})
|
||||
|
||||
it('(k) a failed POST records the user text plus a settled error reply and returns false', async () => {
|
||||
const postMessage = vi
|
||||
.fn<
|
||||
(threadId: string, req: PostMessageInput) => Promise<AgentTurnAccepted>
|
||||
>()
|
||||
.mockRejectedValue(new AgentApiError('server exploded', 500, undefined))
|
||||
const rest = fakeRest({ postMessage })
|
||||
const session = useAgentSession({ rest, events: fakeEvents().source })
|
||||
session.start()
|
||||
|
||||
const ok = await session.sendMessage('boom')
|
||||
expect(ok).toBe(false)
|
||||
|
||||
const entries = session.entries.value
|
||||
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: 'Message failed to send: server exploded'
|
||||
}
|
||||
])
|
||||
}
|
||||
expect(session.isStreaming.value).toBe(false)
|
||||
})
|
||||
|
||||
it('(l) newChat cancels the active turn exactly once with the right ids', async () => {
|
||||
const cancelMessage = vi.fn<
|
||||
(threadId: string, messageId: string) => Promise<AgentCancelAccepted>
|
||||
>(async () => ({ status: 'cancelling' }))
|
||||
const rest = fakeRest({ cancelMessage })
|
||||
const { source, emit } = fakeEvents()
|
||||
const session = useAgentSession({ rest, events: source })
|
||||
session.start()
|
||||
|
||||
await session.sendMessage('go')
|
||||
emit(delta('msg-1', 'working'))
|
||||
expect(session.isStreaming.value).toBe(true)
|
||||
|
||||
session.newChat()
|
||||
|
||||
expect(cancelMessage).toHaveBeenCalledTimes(1)
|
||||
expect(cancelMessage).toHaveBeenCalledWith('th-1', 'msg-1')
|
||||
expect(session.entries.value).toHaveLength(0)
|
||||
expect(session.threadId.value).toBeNull()
|
||||
})
|
||||
|
||||
it('(m) a second send while the first POST is pending posts once and records a busy notice', async () => {
|
||||
let resolvePost: ((ack: AgentTurnAccepted) => void) | undefined
|
||||
const postMessage = vi
|
||||
.fn<
|
||||
(threadId: string, req: PostMessageInput) => Promise<AgentTurnAccepted>
|
||||
>()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<AgentTurnAccepted>((resolve) => {
|
||||
resolvePost = resolve
|
||||
})
|
||||
)
|
||||
const rest = fakeRest({ postMessage })
|
||||
const session = useAgentSession({ rest, events: fakeEvents().source })
|
||||
session.start()
|
||||
|
||||
const first = session.sendMessage('first')
|
||||
const second = await session.sendMessage('second')
|
||||
expect(second).toBe(false)
|
||||
// Only the first send reached the wire.
|
||||
expect(postMessage).toHaveBeenCalledTimes(1)
|
||||
|
||||
// The dropped second send is recorded as a failed exchange, not swallowed.
|
||||
const busyNotice = session.entries.value.find(
|
||||
(e) =>
|
||||
e.role === 'assistant' &&
|
||||
e.parts.some(
|
||||
(p) =>
|
||||
p.type === 'notice' && p.text === 'A message is already being sent'
|
||||
)
|
||||
)
|
||||
expect(busyNotice).toBeDefined()
|
||||
|
||||
resolvePost?.({ thread_id: 'th-1', message_id: 'msg-1' })
|
||||
await first
|
||||
})
|
||||
|
||||
it('(n) a getDraft rejecting with a network TypeError surfaces a notice, not an unhandled rejection', async () => {
|
||||
// onStatus(true) voids resyncDraft(), so a non-AgentApiError rethrow would escape as an
|
||||
// unhandled rejection. The catch must instead pushError. The notice is the proof:
|
||||
// the old rethrow path pushed NO notice (the error escaped), so its presence pins
|
||||
// both the surfacing and the absence of an escaping rejection.
|
||||
const getDraft = vi
|
||||
.fn<(workflowId: string) => Promise<AgentDraftSnapshot>>()
|
||||
.mockRejectedValue(new TypeError('fetch failed'))
|
||||
const { source, status } = fakeEvents()
|
||||
const session = useAgentSession({
|
||||
rest: fakeRest({ getDraft }),
|
||||
events: source
|
||||
})
|
||||
session.start()
|
||||
|
||||
useAgentDraftStore().bind('wf-1')
|
||||
status(true)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(session.notices.value).toEqual([
|
||||
{ level: 'error', text: 'fetch failed' }
|
||||
])
|
||||
})
|
||||
|
||||
it('(o) a malformed done for the active turn settles it; a foreign malformed done does not', async () => {
|
||||
const rest = fakeRest()
|
||||
const { source, emit } = fakeEvents()
|
||||
const session = useAgentSession({ rest, events: source })
|
||||
session.start()
|
||||
|
||||
await session.sendMessage('go')
|
||||
emit(delta('msg-1', 'partial'))
|
||||
expect(session.isStreaming.value).toBe(true)
|
||||
|
||||
// A malformed done for a FOREIGN, readable message_id must not abort our turn.
|
||||
emit({ type: 'agent_message_done', data: { message_id: 'msg-OTHER' } })
|
||||
expect(session.isStreaming.value).toBe(true)
|
||||
|
||||
// A malformed done for the ACTIVE turn settles it (no hung spinner).
|
||||
emit({ type: 'agent_message_done', data: { message_id: 'msg-1' } })
|
||||
expect(session.isStreaming.value).toBe(false)
|
||||
})
|
||||
|
||||
it('(p) non-object and foreign host frames are dropped silently mid-turn', async () => {
|
||||
const rest = fakeRest()
|
||||
const { source, emit } = fakeEvents()
|
||||
const session = useAgentSession({ rest, events: source })
|
||||
session.start()
|
||||
|
||||
await session.sendMessage('go')
|
||||
emit(delta('msg-1', 'working'))
|
||||
expect(session.isStreaming.value).toBe(true)
|
||||
|
||||
// Both ride the shared /ws: a garbage frame and a real host status frame.
|
||||
emit('not an object')
|
||||
emit({ type: 'status', data: { sid: 1 } })
|
||||
|
||||
expect(session.isStreaming.value).toBe(true)
|
||||
expect(session.entries.value.map((e) => e.role)).toEqual([
|
||||
'user',
|
||||
'assistant'
|
||||
])
|
||||
expect(session.notices.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('(i) a 404 draft resync is benign; a 403 pushes an error notice', async () => {
|
||||
const draft = useAgentDraftStore()
|
||||
|
||||
const getDraft404 = vi
|
||||
.fn<(workflowId: string) => Promise<AgentDraftSnapshot>>()
|
||||
.mockRejectedValue(new AgentApiError('not found', 404, undefined))
|
||||
const events404 = fakeEvents()
|
||||
const session404 = useAgentSession({
|
||||
rest: fakeRest({ getDraft: getDraft404 }),
|
||||
events: events404.source
|
||||
})
|
||||
session404.start()
|
||||
draft.bind('wf-1')
|
||||
events404.status(true)
|
||||
await Promise.resolve()
|
||||
expect(session404.notices.value).toHaveLength(0)
|
||||
|
||||
const getDraft403 = vi
|
||||
.fn<(workflowId: string) => Promise<AgentDraftSnapshot>>()
|
||||
.mockRejectedValue(new AgentApiError('forbidden', 403, undefined))
|
||||
const events403 = fakeEvents()
|
||||
const session403 = useAgentSession({
|
||||
rest: fakeRest({ getDraft: getDraft403 }),
|
||||
events: events403.source
|
||||
})
|
||||
session403.start()
|
||||
events403.status(true)
|
||||
await Promise.resolve()
|
||||
expect(session403.notices.value).toEqual([
|
||||
{ level: 'error', text: 'forbidden' }
|
||||
])
|
||||
})
|
||||
})
|
||||
describe('thread resume (B17)', () => {
|
||||
const HISTORY: AgentMessages = [
|
||||
{
|
||||
id: 'row-1',
|
||||
thread_id: 'th-9',
|
||||
seq: 0,
|
||||
role: 'user',
|
||||
status: 'complete',
|
||||
turn_id: 'turn-1',
|
||||
content: { text: 'build a duck' }
|
||||
},
|
||||
{
|
||||
id: 'row-2',
|
||||
thread_id: 'th-9',
|
||||
seq: 1,
|
||||
role: 'assistant',
|
||||
status: 'complete',
|
||||
turn_id: 'turn-1',
|
||||
content: { text: 'Duck workflow ready.' }
|
||||
}
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('restores the persisted thread and hydrates its transcript on start', async () => {
|
||||
localStorage.setItem('Comfy.Agent.ThreadId', 'th-9')
|
||||
const getMessages = vi.fn(async (): Promise<AgentMessages> => HISTORY)
|
||||
const session = useAgentSession({
|
||||
rest: fakeRest({ getMessages }),
|
||||
events: fakeEvents().source
|
||||
})
|
||||
session.start()
|
||||
await vi.waitFor(() => expect(getMessages).toHaveBeenCalledWith('th-9'))
|
||||
await vi.waitFor(() => expect(session.entries.value).toHaveLength(2))
|
||||
|
||||
const [user, assistant] = session.entries.value
|
||||
expect(user).toMatchObject({ role: 'user', text: 'build a duck' })
|
||||
expect(assistant).toMatchObject({ role: 'assistant', streaming: false })
|
||||
expect(session.threadId.value).toBe('th-9')
|
||||
// The resumed transcript is settled: nothing streams.
|
||||
expect(session.isStreaming.value).toBe(false)
|
||||
})
|
||||
|
||||
it('forgets a stale persisted thread on 404 without surfacing an error', async () => {
|
||||
localStorage.setItem('Comfy.Agent.ThreadId', 'th-gone')
|
||||
const getMessages = vi.fn(async (): Promise<AgentMessages> => {
|
||||
throw new AgentApiError('not found', 404, null)
|
||||
})
|
||||
const session = useAgentSession({
|
||||
rest: fakeRest({ getMessages }),
|
||||
events: fakeEvents().source
|
||||
})
|
||||
session.start()
|
||||
await vi.waitFor(() =>
|
||||
expect(localStorage.getItem('Comfy.Agent.ThreadId')).toBeNull()
|
||||
)
|
||||
expect(session.threadId.value).toBeNull()
|
||||
expect(session.entries.value).toHaveLength(0)
|
||||
expect(session.notices.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('persists the thread on send and clears it on newChat', async () => {
|
||||
const session = useAgentSession({
|
||||
rest: fakeRest(),
|
||||
events: fakeEvents().source
|
||||
})
|
||||
session.start()
|
||||
await session.sendMessage('hello')
|
||||
expect(localStorage.getItem('Comfy.Agent.ThreadId')).toBe('th-1')
|
||||
|
||||
session.newChat()
|
||||
expect(localStorage.getItem('Comfy.Agent.ThreadId')).toBeNull()
|
||||
expect(useAgentConversationStore().threadId).toBeNull()
|
||||
})
|
||||
|
||||
it('does not clobber an in-memory conversation on panel reopen', async () => {
|
||||
const getMessages = vi.fn(async (): Promise<AgentMessages> => HISTORY)
|
||||
const rest = fakeRest({ getMessages })
|
||||
const first = useAgentSession({ rest, events: fakeEvents().source })
|
||||
first.start()
|
||||
await first.sendMessage('live message')
|
||||
first.stop()
|
||||
|
||||
// Reopen within the same page session: the store still holds the live conversation.
|
||||
const second = useAgentSession({ rest, events: fakeEvents().source })
|
||||
second.start()
|
||||
expect(getMessages).not.toHaveBeenCalled()
|
||||
expect(
|
||||
second.entries.value.some(
|
||||
(entry) => entry.role === 'user' && entry.text === 'live message'
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('loadThread adopts, persists and hydrates a chat picked from history', async () => {
|
||||
const getMessages = vi.fn(async (): Promise<AgentMessages> => HISTORY)
|
||||
const session = useAgentSession({
|
||||
rest: fakeRest({ getMessages }),
|
||||
events: fakeEvents().source
|
||||
})
|
||||
session.start()
|
||||
|
||||
await session.loadThread('th-9')
|
||||
|
||||
expect(getMessages).toHaveBeenCalledWith('th-9')
|
||||
expect(session.threadId.value).toBe('th-9')
|
||||
expect(localStorage.getItem('Comfy.Agent.ThreadId')).toBe('th-9')
|
||||
await vi.waitFor(() => expect(session.entries.value).toHaveLength(2))
|
||||
expect(session.entries.value[0]).toMatchObject({
|
||||
role: 'user',
|
||||
text: 'build a duck'
|
||||
})
|
||||
})
|
||||
|
||||
it('listThreads returns the REST client thread list', async () => {
|
||||
const listThreads = vi.fn(
|
||||
async (): Promise<AgentThreadSummary[]> => [
|
||||
{
|
||||
id: 'th-9',
|
||||
title: 'build a duck',
|
||||
updated_at: '2026-07-07T00:00:00Z'
|
||||
}
|
||||
]
|
||||
)
|
||||
const session = useAgentSession({
|
||||
rest: fakeRest({ listThreads }),
|
||||
events: fakeEvents().source
|
||||
})
|
||||
const threads = await session.listThreads()
|
||||
expect(threads).toHaveLength(1)
|
||||
expect(threads[0]).toMatchObject({ id: 'th-9', title: 'build a duck' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,337 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { i18n } from '@/i18n'
|
||||
import type { TurnId } from '../../schemas/agentApiSchema'
|
||||
import { isAgentEvent, parseAgentWsEvent } from '../../schemas/agentApiSchema'
|
||||
import { AgentApiError } from '../../services/agent/agentRestClient'
|
||||
import type {
|
||||
AgentRestClient,
|
||||
WorkflowUpload
|
||||
} from '../../services/agent/agentRestClient'
|
||||
import { useAgentConversationStore } from '../../stores/agent/agentConversationStore'
|
||||
import { useAgentDraftStore } from '../../stores/agent/agentDraftStore'
|
||||
|
||||
// Headless composition root. Turns are addressed by the server-minted assistant
|
||||
// message_id from the send ack; the panel never mints turn ids.
|
||||
|
||||
export interface AgentEventSource {
|
||||
subscribe(listener: (raw: unknown) => void): () => void
|
||||
onStatus?(listener: (live: boolean) => void): () => void
|
||||
}
|
||||
|
||||
// Only errors surface out of the session today; widen the union when the wire
|
||||
// grows a non-error notice.
|
||||
export interface SessionNotice {
|
||||
level: 'error'
|
||||
text: string
|
||||
}
|
||||
|
||||
// A settled upload: the server ref rides the POST, name + preview stay behind
|
||||
// on the transcript's user entry.
|
||||
interface SentAttachment {
|
||||
ref: string
|
||||
name: string
|
||||
previewUrl?: string
|
||||
}
|
||||
|
||||
// A consumed @-tag: the node id rides the POST selection, the title stays
|
||||
// behind on the transcript's user entry. The id resolves against the graph
|
||||
// uploaded with the same POST (workflow.graph).
|
||||
interface SentTag {
|
||||
id: string
|
||||
title: string
|
||||
}
|
||||
|
||||
// The active tab resolved for a turn. `speculative` marks an id the server has
|
||||
// not confirmed owning; a 403 on it retries the send once without the id.
|
||||
export interface WorkflowTurnContext {
|
||||
id: string
|
||||
speculative: boolean
|
||||
tabPath: string
|
||||
}
|
||||
|
||||
export interface AgentSessionDeps {
|
||||
rest: AgentRestClient
|
||||
events: AgentEventSource
|
||||
workflow?: {
|
||||
current(): WorkflowTurnContext | undefined
|
||||
adopted(
|
||||
workflowId: string,
|
||||
sent: WorkflowTurnContext | undefined,
|
||||
uploaded: boolean
|
||||
): void
|
||||
// The canvas snapshot to seed the server draft with, or undefined when
|
||||
// unchanged since the last upload.
|
||||
snapshot?(): WorkflowUpload | undefined
|
||||
}
|
||||
}
|
||||
|
||||
const THREAD_STORAGE_KEY = 'Comfy.Agent.ThreadId'
|
||||
|
||||
export function useAgentSession(deps: AgentSessionDeps) {
|
||||
const { rest, events, workflow } = deps
|
||||
|
||||
const conversationStore = useAgentConversationStore()
|
||||
const draftStore = useAgentDraftStore()
|
||||
|
||||
const notices = ref<SessionNotice[]>([])
|
||||
let resyncing = false
|
||||
// The 202 ack races the socket frames; a second send in that window must not open a
|
||||
// second wire turn.
|
||||
const sending = ref(false)
|
||||
|
||||
// Local-only turn ids for failed sends, which have no server-minted message_id.
|
||||
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)
|
||||
// A rebind during the fetch must not adopt the stale workflow's draft.
|
||||
if (draftStore.workflowId === id) draftStore.adoptSnapshot(snapshot)
|
||||
} catch (error) {
|
||||
if (error instanceof AgentApiError) {
|
||||
if (error.status === 404) return
|
||||
pushError(error.message)
|
||||
return
|
||||
}
|
||||
// Callers void this promise; surface instead of an unhandled rejection.
|
||||
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)
|
||||
// A same-page reopen already has the conversation in the store; only a fresh page
|
||||
// restores the persisted thread.
|
||||
if (
|
||||
conversationStore.threadId === null &&
|
||||
conversationStore.messages.length === 0
|
||||
) {
|
||||
const stored = localStorage.getItem(THREAD_STORAGE_KEY)
|
||||
if (stored !== null) {
|
||||
conversationStore.setThreadId(stored)
|
||||
void hydrateFromServer(stored)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A 404 means the thread expired or was deleted: forget the stale id, not an error.
|
||||
async function hydrateFromServer(threadId: string): Promise<void> {
|
||||
try {
|
||||
const history = await rest.getMessages(threadId)
|
||||
// newChat may have replaced the thread while the fetch was in flight.
|
||||
if (conversationStore.threadId === threadId)
|
||||
conversationStore.hydrate(history)
|
||||
} catch (error) {
|
||||
if (error instanceof AgentApiError && error.status === 404) {
|
||||
if (conversationStore.threadId === threadId)
|
||||
conversationStore.setThreadId(null)
|
||||
localStorage.removeItem(THREAD_STORAGE_KEY)
|
||||
return
|
||||
}
|
||||
pushError(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
unsubscribe?.()
|
||||
unsubscribeStatus?.()
|
||||
unsubscribe = null
|
||||
unsubscribeStatus = null
|
||||
}
|
||||
|
||||
// False on any failure; hosts may restore the composer draft from it.
|
||||
async function sendMessage(
|
||||
text: string,
|
||||
attachments?: SentAttachment[],
|
||||
tags?: SentTag[]
|
||||
): Promise<boolean> {
|
||||
if (sending.value) {
|
||||
// The composer already cleared its draft; a silently dropped send loses the text.
|
||||
conversationStore.recordFailedSend(
|
||||
nextLocalErrorId(),
|
||||
text,
|
||||
i18n.global.t('agent.sendBusy')
|
||||
)
|
||||
return false
|
||||
}
|
||||
sending.value = true
|
||||
const wfContext = workflow?.current()
|
||||
const upload = workflow?.snapshot?.()
|
||||
const input = {
|
||||
content: text,
|
||||
selection:
|
||||
tags !== undefined && tags.length > 0
|
||||
? { node_ids: tags.map((tag) => tag.id) }
|
||||
: undefined,
|
||||
attachments: attachments?.map((attachment) => attachment.ref),
|
||||
workflow: upload
|
||||
}
|
||||
async function postTurn(threadId: string) {
|
||||
try {
|
||||
return await rest.postMessage(
|
||||
threadId,
|
||||
wfContext ? { ...input, workflowId: wfContext.id } : input
|
||||
)
|
||||
} catch (error) {
|
||||
const speculativeDenied =
|
||||
wfContext?.speculative === true &&
|
||||
error instanceof AgentApiError &&
|
||||
error.status === 403
|
||||
if (!speculativeDenied) throw error
|
||||
return await rest.postMessage(threadId, input)
|
||||
}
|
||||
}
|
||||
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, upload !== undefined)
|
||||
}
|
||||
// The ONE branding seam: the server-minted message_id becomes the TurnId.
|
||||
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)
|
||||
// Inline row only: a toast would double-surface this and overlap the docked panel.
|
||||
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) {
|
||||
// 409 = already settled server-side; the socket still delivers the terminal
|
||||
// delta + done, so no local abort.
|
||||
if (error.status === 409) return
|
||||
pushError(error.message)
|
||||
return
|
||||
}
|
||||
pushError(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
function newChat(): void {
|
||||
// Cancel first: an abandoned turn keeps generating and billing. stopTurn reads its
|
||||
// ids synchronously, so the reset below cannot race it.
|
||||
void stopTurn()
|
||||
conversationStore.reset()
|
||||
// A late patch or resync for the abandoned thread's workflow must not land here.
|
||||
draftStore.reset()
|
||||
localStorage.removeItem(THREAD_STORAGE_KEY)
|
||||
}
|
||||
|
||||
function listThreads() {
|
||||
return rest.listThreads()
|
||||
}
|
||||
|
||||
async function loadThread(threadId: string): Promise<void> {
|
||||
void stopTurn()
|
||||
// The switched-to thread's workflow binds on its next ack; drop the old one.
|
||||
draftStore.reset()
|
||||
conversationStore.setThreadId(threadId)
|
||||
localStorage.setItem(THREAD_STORAGE_KEY, threadId)
|
||||
await hydrateFromServer(threadId)
|
||||
}
|
||||
|
||||
function onRaw(raw: unknown): void {
|
||||
if (typeof raw !== 'object' || raw === null) return
|
||||
const type = (raw as { type?: unknown }).type
|
||||
// The event source subscribes by agent type, but gate again before paying for a
|
||||
// zod parse so a mis-wired source cannot feed foreign frames into the turn logic.
|
||||
if (typeof type !== 'string' || !isAgentEvent(type)) return
|
||||
const parsed = parseAgentWsEvent(raw)
|
||||
if (!parsed.success) {
|
||||
// A malformed done for OUR turn must still settle it (or the spinner hangs);
|
||||
// a readable FOREIGN message_id must not abort our turn.
|
||||
const messageId = (raw as { data?: { message_id?: unknown } }).data
|
||||
?.message_id
|
||||
if (
|
||||
type === 'agent_message_done' &&
|
||||
(typeof messageId !== 'string' ||
|
||||
messageId === conversationStore.activeTurnId)
|
||||
) {
|
||||
conversationStore.abortActiveTurn()
|
||||
pushError(i18n.global.t('agent.malformedEvent'))
|
||||
}
|
||||
console.warn('[agent] dropping malformed agent event', parsed.error)
|
||||
return
|
||||
}
|
||||
const event = parsed.data
|
||||
switch (event.type) {
|
||||
case 'draft_patch':
|
||||
// Drafts are shared workflow state, never turn-filtered.
|
||||
draftStore.applyPatch(event.data)
|
||||
return
|
||||
case 'draft_version':
|
||||
if (draftStore.checkHeartbeat(event.data) === 'behind')
|
||||
void resyncDraft()
|
||||
return
|
||||
default:
|
||||
conversationStore.ingest(event)
|
||||
}
|
||||
}
|
||||
|
||||
function onStatus(live: boolean): void {
|
||||
// Drop: settle the turn so no spinner hangs. Recover: resync missed draft patches.
|
||||
if (live) void resyncDraft()
|
||||
else conversationStore.abortActiveTurn()
|
||||
}
|
||||
|
||||
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,123 @@
|
||||
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
|
||||
}
|
||||
|
||||
// A live chip registry mirroring what the composer does with the callbacks, so
|
||||
// each test asserts the states a user would actually see.
|
||||
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()
|
||||
// The full message pins the {name}/{limit} i18n interpolation; the count
|
||||
// pins one error per rejected file.
|
||||
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)])
|
||||
|
||||
// Visible before the upload finishes: name + preview, marked uploading.
|
||||
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,66 @@
|
||||
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) {
|
||||
// Each file stages an uploading chip immediately (local object-URL preview),
|
||||
// then settles in place: the upload's ref on success, removal + error on failure.
|
||||
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> {
|
||||
// preventDefault before any await — a default drop navigates the tab away.
|
||||
event.preventDefault()
|
||||
const files = event.dataTransfer?.files
|
||||
return files && files.length > 0
|
||||
? addFiles(Array.from(files))
|
||||
: Promise.resolve()
|
||||
}
|
||||
|
||||
return { addFiles, onDrop }
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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
|
||||
// watch re-evaluates on the reactive isLive change
|
||||
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([])
|
||||
|
||||
// Same selection stays empty (already consumed) even after a reactive tick.
|
||||
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,66 @@
|
||||
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[]>([])
|
||||
// consumedSig: the same selection must not re-tag every message. stagedSig: a watcher
|
||||
// re-fire with no real change must not resurrect tags the user dismissed via remove().
|
||||
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)
|
||||
}
|
||||
|
||||
// Manual staging from the @ picker; a later canvas-selection change still
|
||||
// replaces the whole set (last gesture wins).
|
||||
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,135 @@
|
||||
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')
|
||||
|
||||
// A submitted preview lives on in the transcript; submit must not touch it.
|
||||
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()
|
||||
// The draft is preserved on stop (only a real send clears it).
|
||||
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,76 @@
|
||||
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[]>([])
|
||||
|
||||
// In-flight uploads block the send: their refs do not exist server-side yet,
|
||||
// so sending now would silently drop what the user can see attached.
|
||||
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)
|
||||
// A dismissed chip's local preview has no other consumer (sent ones live on
|
||||
// in the transcript); release the object URL's image buffer.
|
||||
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,82 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import type { DraftPatchData } from '../../schemas/agentApiSchema'
|
||||
import { useAgentDraftStore } from '../../stores/agent/agentDraftStore'
|
||||
|
||||
import { useDraftCanvasApply } from './useDraftCanvasApply'
|
||||
|
||||
const WF = 'wf-1'
|
||||
|
||||
function patch(version: number): DraftPatchData {
|
||||
return {
|
||||
workflow_id: WF,
|
||||
base_version: version - 1,
|
||||
version,
|
||||
content: { nodes: [{ id: version }] }
|
||||
}
|
||||
}
|
||||
|
||||
describe('useDraftCanvasApply', () => {
|
||||
beforeEach(() => setActivePinia(createPinia()))
|
||||
|
||||
it('applies each newly adopted draft version with its content', async () => {
|
||||
const store = useAgentDraftStore()
|
||||
store.bind(WF)
|
||||
const apply = vi.fn()
|
||||
useDraftCanvasApply(apply)
|
||||
|
||||
store.adoptSnapshot({ content: { nodes: [] }, version: 5 })
|
||||
await nextTick()
|
||||
store.applyPatch(patch(6))
|
||||
await nextTick()
|
||||
|
||||
expect(apply.mock.calls).toEqual([
|
||||
[{ nodes: [] }],
|
||||
[{ nodes: [{ id: 6 }] }]
|
||||
])
|
||||
})
|
||||
|
||||
it('does not fire for rejected (stale or foreign) patches', async () => {
|
||||
const store = useAgentDraftStore()
|
||||
store.bind(WF)
|
||||
store.adoptSnapshot({ content: {}, version: 9 })
|
||||
const apply = vi.fn()
|
||||
useDraftCanvasApply(apply)
|
||||
|
||||
store.applyPatch(patch(9))
|
||||
store.applyPatch({ ...patch(10), workflow_id: 'other' })
|
||||
await nextTick()
|
||||
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not fire with null content when the store resets or rebinds', async () => {
|
||||
const store = useAgentDraftStore()
|
||||
store.bind(WF)
|
||||
store.adoptSnapshot({ content: {}, version: 3 })
|
||||
const apply = vi.fn()
|
||||
useDraftCanvasApply(apply)
|
||||
|
||||
store.reset()
|
||||
await nextTick()
|
||||
store.bind('wf-2')
|
||||
await nextTick()
|
||||
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops applying after the returned stop handle runs', async () => {
|
||||
const store = useAgentDraftStore()
|
||||
store.bind(WF)
|
||||
const apply = vi.fn()
|
||||
const stop = useDraftCanvasApply(apply)
|
||||
|
||||
stop()
|
||||
store.applyPatch(patch(1))
|
||||
await nextTick()
|
||||
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { useAgentDraftStore } from '../../stores/agent/agentDraftStore'
|
||||
|
||||
/**
|
||||
* useDraftCanvasApply: the canvas half of the draft pipeline. Whenever a newer draft
|
||||
* version lands in the store (live draft_patch or reconnect resync), the full graph is
|
||||
* handed to the injected apply callback. The HOST injects the actual canvas load
|
||||
* (e.g. app.loadGraphData); the panel itself has no canvas. Returns the watch stop
|
||||
* handle so the host can tear down with its mount.
|
||||
*/
|
||||
export function useDraftCanvasApply(
|
||||
apply: (content: Record<string, unknown>) => void
|
||||
): () => void {
|
||||
const draftStore = useAgentDraftStore()
|
||||
return watch(
|
||||
() => draftStore.version,
|
||||
(version) => {
|
||||
if (version === null || draftStore.content === null) return
|
||||
apply(draftStore.content)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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)
|
||||
|
||||
// useStorage flushes the write asynchronously; let it land before a fresh instance
|
||||
// reads the persisted flag and confirms it does not re-run.
|
||||
await nextTick()
|
||||
const second = useOnboarding(KEY)
|
||||
expect(second.active.value).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
export interface CoachStep {
|
||||
// A host DOM selector the spotlight anchors to (e.g. '#graph-canvas'). When the
|
||||
// target is missing the coachmark stays hidden rather than pointing at nothing.
|
||||
target: string
|
||||
title: string
|
||||
body: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Onboarding coachmark state: persist-once (shown a single time per browser). Host DOM
|
||||
* anchoring lives in the OnboardingCoach component; this only owns whether the single
|
||||
* coachmark should show at all.
|
||||
*/
|
||||
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,13 @@
|
||||
{"capturedAt":1783366986602,"frame":{"data":{"delta":"I'll set the positive prompt to your red fox in the snow, then run it.","message_id":"0a0b096e-8169-4655-83ab-784558030c17","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718"},"type":"agent_thinking"}}
|
||||
{"capturedAt":1783366988747,"frame":{"data":{"args":["workflow","slots","workflow.json"],"message_id":"0a0b096e-8169-4655-83ab-784558030c17","status":"ok","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718","tool_name":"list_slots"},"type":"agent_tool_call"}}
|
||||
{"capturedAt":1783366991756,"frame":{"data":{"args":["workflow","ls-nodes","workflow.json"],"message_id":"0a0b096e-8169-4655-83ab-784558030c17","status":"ok","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718","tool_name":"ls_nodes"},"type":"agent_tool_call"}}
|
||||
{"capturedAt":1783366994714,"frame":{"data":{"delta":"There are two prompt nodes — let me confirm which is the positive one by checking the sampler's wiring.","message_id":"0a0b096e-8169-4655-83ab-784558030c17","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718"},"type":"agent_thinking"}}
|
||||
{"capturedAt":1783366997022,"frame":{"data":{"args":["nodes","show","KSampler"],"message_id":"0a0b096e-8169-4655-83ab-784558030c17","status":"ok","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718","tool_name":"show_node"},"type":"agent_tool_call"}}
|
||||
{"capturedAt":1783367000894,"frame":{"data":{"delta":"The positive prompt is node 3610737701189037 (the one I set earlier as the positive). Let me update it to the red fox and set a batch size of 1, then run.","message_id":"0a0b096e-8169-4655-83ab-784558030c17","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718"},"type":"agent_thinking"}}
|
||||
{"capturedAt":1783367002946,"frame":{"data":{"base_version":26,"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 red fox sitting in fresh snow, winter forest, highly detailed fur, 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":"0a0b096e-8169-4655-83ab-784558030c17","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718","version":27,"workflow_id":"a81718a4-02ae-41e6-ae85-c33b7bb880f6"},"type":"draft_patch"}}
|
||||
{"capturedAt":1783367002948,"frame":{"data":{"args":["workflow","set-widget","workflow.json","--actor","MjVkuhzz6vSuA19LWfBa9lMQdKv2","--base-version","26","--where","cloud","--","3610737701189037.text","a red fox sitting in fresh snow, winter forest, highly detailed fur, sharp focus, vibrant colors"],"message_id":"0a0b096e-8169-4655-83ab-784558030c17","status":"ok","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718","tool_name":"set_widget"},"type":"agent_tool_call"}}
|
||||
{"capturedAt":1783367004628,"frame":{"data":{"args":["workflow","set-widget","workflow.json","--actor","MjVkuhzz6vSuA19LWfBa9lMQdKv2","--base-version","27","--where","cloud","--","3095725229108875.batch_size","1"],"message_id":"0a0b096e-8169-4655-83ab-784558030c17","status":"ok","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718","tool_name":"set_widget"},"type":"agent_tool_call"}}
|
||||
{"capturedAt":1783367007855,"frame":{"data":{"delta":"Prompt set to the red fox in the snow. Generating your image now — this usually takes a moment.","message_id":"0a0b096e-8169-4655-83ab-784558030c17","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718"},"type":"agent_thinking"}}
|
||||
{"capturedAt":1783367011395,"frame":{"data":{"version":27,"workflow_id":"a81718a4-02ae-41e6-ae85-c33b7bb880f6"},"type":"draft_version"}}
|
||||
{"capturedAt":1783367012307,"frame":{"data":{"delta":"The graph is fully ready to go — prompt set to *\"a red fox sitting in fresh snow, winter forest, highly detailed fur, sharp focus, vibrant colors,\"* one image, 12 steps, CFG 6.5, on SDXL. But the run needs your approval before it can start (running spends credits), and I don't have a way to confirm that on my end.\n\nCould you approve/confirm the run so I can kick it off? Once you do, I'll generate the image and send you the result.","message_id":"0a0b096e-8169-4655-83ab-784558030c17","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718"},"type":"agent_message_delta"}}
|
||||
{"capturedAt":1783367012316,"frame":{"data":{"message_id":"0a0b096e-8169-4655-83ab-784558030c17","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":53394,"input_tokens":11662,"output_tokens":731,"total_tokens":12393}},"type":"agent_message_done"}}
|
||||
@@ -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,7 @@
|
||||
{"capturedAt":1783367023398,"frame":{"data":{"version":27,"workflow_id":"a81718a4-02ae-41e6-ae85-c33b7bb880f6"},"type":"draft_version"}}
|
||||
{"capturedAt":1783367035407,"frame":{"data":{"version":27,"workflow_id":"a81718a4-02ae-41e6-ae85-c33b7bb880f6"},"type":"draft_version"}}
|
||||
{"capturedAt":1783367047474,"frame":{"data":{"version":27,"workflow_id":"a81718a4-02ae-41e6-ae85-c33b7bb880f6"},"type":"draft_version"}}
|
||||
{"capturedAt":1783367059396,"frame":{"data":{"version":27,"workflow_id":"a81718a4-02ae-41e6-ae85-c33b7bb880f6"},"type":"draft_version"}}
|
||||
{"capturedAt":1783367073388,"frame":{"data":{"delta":"Generating your red fox image now — this usually takes a short moment.","message_id":"162f34e8-a64c-40ff-bb8f-382c5ff9bf1b","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718"},"type":"agent_thinking"}}
|
||||
{"capturedAt":1783367080091,"frame":{"data":{"delta":"I hit a wall on my side: even with your go-ahead, I don't have an approval channel to actually start the run from here — the system is blocking it and asking that the run be triggered directly rather than by me.\n\nEverything is built and ready — your red fox prompt, 12 steps, CFG 6.5, one image, SDXL. If there's a **Run** control on your end for this workflow, go ahead and trigger it and it'll generate right away. If you're expecting me to run it and it keeps refusing, this is likely a permissions setting on the account that needs to be enabled before I can launch runs on your behalf.\n\nSorry for the snag — the workflow itself is complete and correct, so the moment the run can be triggered you'll get your image.","message_id":"162f34e8-a64c-40ff-bb8f-382c5ff9bf1b","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718"},"type":"agent_message_delta"}}
|
||||
{"capturedAt":1783367080100,"frame":{"data":{"message_id":"162f34e8-a64c-40ff-bb8f-382c5ff9bf1b","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":17798,"input_tokens":1846,"output_tokens":318,"total_tokens":2164}},"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"}}
|
||||
173
src/workbench/extensions/agent/schemas/agentApiSchema.test.ts
Normal file
173
src/workbench/extensions/agent/schemas/agentApiSchema.test.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
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'
|
||||
|
||||
// Fixtures are read at test time via Vite's raw glob (the bundler-native equivalent
|
||||
// of fs.readFileSync here, since @types/node is not wired into this tsconfig). Each
|
||||
// .jsonl file lands as its raw text, split into lines below.
|
||||
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 six agent event types', () => {
|
||||
expect([...AGENT_WS_EVENT_TYPES].sort()).toEqual(
|
||||
[
|
||||
'agent_message_delta',
|
||||
'agent_message_done',
|
||||
'agent_thinking',
|
||||
'agent_tool_call',
|
||||
'draft_patch',
|
||||
'draft_version'
|
||||
].sort()
|
||||
)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
198
src/workbench/extensions/agent/schemas/agentApiSchema.ts
Normal file
198
src/workbench/extensions/agent/schemas/agentApiSchema.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
// Wire contract for the In-App Agent REST + WebSocket backend, validated
|
||||
// field-by-field against live frames in __fixtures__/agent/.
|
||||
|
||||
// Wire fields stay plain z.string(); the session layer brands ids as it adopts them,
|
||||
// so a raw frame parses without a branding cast.
|
||||
const zTurnId = z.string().brand<'TurnId'>()
|
||||
export type TurnId = z.infer<typeof zTurnId>
|
||||
|
||||
// workflow_id is optional only because the openapi marks it so; every observed ack
|
||||
// carries it.
|
||||
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>
|
||||
|
||||
// content is omitted while a message is still streaming, so it stays optional.
|
||||
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>
|
||||
|
||||
// title is "" until the server names the thread; preview carries the first prompt.
|
||||
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 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>
|
||||
|
||||
// Plain-string error shape only; ingest-raised errors use a richer {error:{message,type}}
|
||||
// shape that callers tolerate but do not model.
|
||||
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>
|
||||
|
||||
// Each event rides the standard ComfyUI envelope {type, data}.
|
||||
|
||||
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()
|
||||
|
||||
// content is the full UI-format graph object (not a diff); base_version anchors it.
|
||||
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(),
|
||||
// The wire carries token usage (null on a cancelled turn) but the panel does not
|
||||
// render it, so its inner shape stays unvalidated.
|
||||
usage: z.unknown().nullish()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
// Heartbeat; carries no message_id/thread_id.
|
||||
const zDraftVersionData = z
|
||||
.object({
|
||||
version: z.number().int(),
|
||||
workflow_id: z.string()
|
||||
})
|
||||
.passthrough()
|
||||
export type DraftVersionData = z.infer<typeof zDraftVersionData>
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
export const zAgentWsEvent = z.discriminatedUnion('type', [
|
||||
zAgentThinkingEvent,
|
||||
zAgentToolCallEvent,
|
||||
zDraftPatchEvent,
|
||||
zAgentMessageDeltaEvent,
|
||||
zAgentMessageDoneEvent,
|
||||
zDraftVersionEvent
|
||||
])
|
||||
export type AgentWsEvent = z.infer<typeof zAgentWsEvent>
|
||||
|
||||
// Host frames (e.g. {type:"status"}) ride the same /ws; isAgentEvent sorts agent events
|
||||
// from foreign frames on the type string without zod-parsing them.
|
||||
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'
|
||||
])
|
||||
|
||||
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,102 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { AGENT_WS_EVENT_TYPES } from '../../schemas/agentApiSchema'
|
||||
|
||||
import type { AgentEventHost } from './agentEventSource'
|
||||
import { createAgentEventSource } from './agentEventSource'
|
||||
|
||||
// A minimal stand-in for the api singleton: registered custom types receive the
|
||||
// frame's data as a CustomEvent detail (exactly what api.ts dispatches after its
|
||||
// single JSON.parse), and reconnect liveness rides 'reconnecting'/'reconnected'.
|
||||
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)
|
||||
|
||||
// Registration is what keeps api.ts from throwing 'Unknown message type'
|
||||
// once per agent type; the schema's list is the authoritative set.
|
||||
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,58 @@
|
||||
import type { AgentEventSource } from '../../composables/agent/useAgentSession'
|
||||
import { AGENT_WS_EVENT_TYPES } from '../../schemas/agentApiSchema'
|
||||
|
||||
// The surface of the host api singleton this adapter binds to. The api JSON-parses
|
||||
// every /ws frame ONCE and dispatches frames whose type was registered (via
|
||||
// addCustomEventListener) as CustomEvents on itself — listeners survive socket
|
||||
// reconnects because they bind to the api EventTarget, not the socket. Registering
|
||||
// the agent types also stops the api's once-per-type "Unknown message type" throw.
|
||||
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 // WebSocket.OPEN
|
||||
|
||||
export function createAgentEventSource(host: AgentEventHost): AgentEventSource {
|
||||
return {
|
||||
subscribe(listener) {
|
||||
// The api dispatches only the frame's data as the event detail; rebuild the
|
||||
// {type, data} envelope the zod event schema validates.
|
||||
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)
|
||||
// A socket already open before (re)binding must report live once, else the
|
||||
// session's open-triggered draft resync never fires for it.
|
||||
if (host.socket?.readyState === OPEN) listener(true)
|
||||
return () => {
|
||||
host.removeEventListener('reconnecting', onDown)
|
||||
host.removeEventListener('reconnected', onUp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
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'
|
||||
|
||||
// Fixtures are read at test time via Vite's raw glob (node:fs types are not wired
|
||||
// into this tsconfig), mirroring agentApiSchema.test.ts.
|
||||
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
|
||||
}
|
||||
|
||||
// Parse every line, keep only chat events for the given message_id, in file order.
|
||||
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
|
||||
|
||||
// The transport emits a fresh snapshot per applied event; the last snapshot is the
|
||||
// settled message under test.
|
||||
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
|
||||
}
|
||||
|
||||
// Typed builders keep the unit cases free of `as` casts on inline literals.
|
||||
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'
|
||||
)
|
||||
// The captured reply is the single message_delta, asserted against the fixture
|
||||
// rather than hardcoded so a re-capture keeps the test honest.
|
||||
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' })
|
||||
// Tools come before the text: the last part is the reply.
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
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'
|
||||
}
|
||||
>
|
||||
|
||||
// settle closes the turn for both endings: the server's done event and a local
|
||||
// abort (socket drop / superseded turn) — the settled message looks the same.
|
||||
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
|
||||
// v1 tool events carry no id; callId is synthesized by arrival order within the turn.
|
||||
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 {
|
||||
switch (event.type) {
|
||||
case 'agent_thinking':
|
||||
// v1 thinking is never persisted server-side; storing it locally would diverge
|
||||
// from GET /messages on reload, so raise the transient chip only, no stored part.
|
||||
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 emits its own snapshot; return so this event does not emit again below.
|
||||
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,52 @@
|
||||
import type { TurnId } from '../../schemas/agentApiSchema'
|
||||
|
||||
// Part field shapes mirror ai's TextUIPart so a later swap to ai's own message
|
||||
// model is mechanical.
|
||||
export type PartState = 'streaming' | 'done'
|
||||
|
||||
export interface TextPart {
|
||||
type: 'text'
|
||||
text: string
|
||||
state: PartState
|
||||
}
|
||||
|
||||
// callId is synthesized in arrival order; the wire carries no tool id.
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Fresh reference per emit: Vue's set trap skips same-reference (Object.is) and deep
|
||||
// in-place mutations, so the shallow clone is what makes streaming deltas propagate.
|
||||
export function snapshotMessage(message: AssistantMessage): AssistantMessage {
|
||||
return { ...message, parts: [...message.parts] }
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// The client rides the host transport (auth headers, 401 remint, Comfy-User all
|
||||
// live in api.fetchApi); tests assert the route + init handed to that seam and
|
||||
// drive responses back through it.
|
||||
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('{}')
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
// passthrough keeps the additive workflow_id key
|
||||
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 () => {
|
||||
// content + version are required by zAgentDraftSnapshot; a wrong shape must throw.
|
||||
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)
|
||||
})
|
||||
})
|
||||
184
src/workbench/extensions/agent/services/agent/agentRestClient.ts
Normal file
184
src/workbench/extensions/agent/services/agent/agentRestClient.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import type { z } from 'zod'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
import {
|
||||
zAgentCancelAccepted,
|
||||
zAgentDraftSnapshot,
|
||||
zAgentError,
|
||||
zAgentMessages,
|
||||
zAgentThreads,
|
||||
zAgentTurnAccepted,
|
||||
zUploadImageResult
|
||||
} from '../../schemas/agentApiSchema'
|
||||
import type {
|
||||
AgentCancelAccepted,
|
||||
AgentDraftSnapshot,
|
||||
AgentMessages,
|
||||
AgentThreadSummary,
|
||||
AgentTurnAccepted,
|
||||
UploadImageResult
|
||||
} from '../../schemas/agentApiSchema'
|
||||
|
||||
// body is the parsed error payload, or undefined when empty or not JSON.
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// The client's canvas, uploaded with a turn so the server seeds the thread's
|
||||
// draft from it before the agent runs. last_seen_version null force-seeds; a
|
||||
// non-null value is the draft version the client last received, so the server
|
||||
// can reject an upload built on a draft the agent has since moved past.
|
||||
export interface WorkflowUpload {
|
||||
graph: unknown
|
||||
last_seen_version: number | null
|
||||
}
|
||||
|
||||
export interface PostMessageInput {
|
||||
content: string
|
||||
workflowId?: string
|
||||
selection?: Record<string, unknown>
|
||||
attachments?: string[]
|
||||
workflow?: WorkflowUpload
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// Rides api.fetchApi so agent calls share the host transport: auth headers
|
||||
// (Firebase/workspace), the 401 remint retry, and the Comfy-User header.
|
||||
// A schema violation throws zod's error uncaught here, by design (anti-drift seam).
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// threadId 'new' opens a thread as part of posting the first message.
|
||||
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.selection !== undefined) body.selection = req.selection
|
||||
if (req.attachments !== undefined) body.attachments = req.attachments
|
||||
if (req.workflow !== undefined) body.workflow = req.workflow
|
||||
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 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
|
||||
)
|
||||
}
|
||||
|
||||
// No Content-Type header is set so the browser writes the multipart boundary itself.
|
||||
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,
|
||||
cancelMessage,
|
||||
getDraft,
|
||||
uploadImage
|
||||
}
|
||||
}
|
||||
|
||||
export type AgentRestClient = ReturnType<typeof createAgentRestClient>
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { TurnId } from '../../schemas/agentApiSchema'
|
||||
import type { AssistantMessage } from './agentMessageParts'
|
||||
import type { ConversationEntry } from '../../stores/agent/agentConversationStore'
|
||||
|
||||
import { buildTranscriptMarkdown } from './agentTranscript'
|
||||
|
||||
describe('buildTranscriptMarkdown', () => {
|
||||
it('renders a user line and an assistant line from a turn', () => {
|
||||
const assistant: AssistantMessage = {
|
||||
id: 'm-1' as TurnId,
|
||||
role: 'assistant',
|
||||
parts: [
|
||||
{ type: 'text', text: 'Here is ', state: 'done' },
|
||||
{ type: 'tool', callId: 'tool_0', name: 'add_node', state: 'done' },
|
||||
{ type: 'text', text: 'a cat.', state: 'done' }
|
||||
],
|
||||
streaming: false,
|
||||
thinking: false
|
||||
}
|
||||
const entries: ConversationEntry[] = [
|
||||
{ id: 'm-1' as TurnId, role: 'user', text: 'make a cat' },
|
||||
assistant
|
||||
]
|
||||
|
||||
const md = buildTranscriptMarkdown(entries)
|
||||
|
||||
expect(md).toContain('**You:** make a cat')
|
||||
// Text parts concatenate; the tool part contributes no plain-markdown body.
|
||||
expect(md).toContain('**Agent:** Here is a cat.')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ConversationEntry } from '../../stores/agent/agentConversationStore'
|
||||
|
||||
export 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')
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ChatSession } from './agentChatHistoryStore'
|
||||
import {
|
||||
groupSessionsByRecency,
|
||||
useAgentChatHistoryStore
|
||||
} from './agentChatHistoryStore'
|
||||
|
||||
// A fixed reference moment: 2026-03-15 12:00 local.
|
||||
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', () => {
|
||||
// NOW is 02:30 just after the US spring-forward (2026-03-08 02:00 -> 03:00), so today
|
||||
// is a 23h day. A session at 23:30 the evening before must land in Yesterday. This pins
|
||||
// the startYesterday = startOfLocalDay(startToday - 1) intent: a naive startToday - 24h
|
||||
// drifts the yesterday boundary off local midnight around a DST transition. Constructed
|
||||
// with a local Date; in a non-DST test TZ it degrades to a plain day-boundary check,
|
||||
// which is still the correct bucket.
|
||||
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 { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
export interface ChatSession {
|
||||
id: string
|
||||
title: string
|
||||
// epoch ms of the last activity in the session
|
||||
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)
|
||||
// Local-day start, not startToday - 24h, so a DST 23h/25h day doesn't misbucket yesterday.
|
||||
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 grouped = computed(() =>
|
||||
groupSessionsByRecency(sessions.value, activeId.value, Date.now())
|
||||
)
|
||||
|
||||
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,219 @@
|
||||
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'
|
||||
|
||||
// Schema-honest event builders (the v1 analog of the old frame() helper): a typo in a
|
||||
// test event surfaces as a parse error here, not as a silently-dropped event. Only chat
|
||||
// events are built, so each parse result is narrowed to AgentChatEvent.
|
||||
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()
|
||||
|
||||
// The fresh-reference-per-emit is what trips the watcher; an in-place mutation of
|
||||
// the same object reference would NOT (Object.is on the slot stays equal).
|
||||
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)
|
||||
// The settled message itself carries streaming=false (the transport's in-place
|
||||
// abort), not merely the null-active fallback of isStreaming.
|
||||
expect(store.messages[0].streaming).toBe(false)
|
||||
// History is preserved (the partial message stays), unlike reset().
|
||||
expect(store.messages).toHaveLength(1)
|
||||
// A second abort is a no-op.
|
||||
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'))
|
||||
|
||||
// An event from a different (stale/foreign) turn must not bleed in. Every v1 chat
|
||||
// event carries data.message_id, so there is no absent-id case to test.
|
||||
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)
|
||||
// The prior turn is settled (no lingering spinner), the new one is live.
|
||||
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' }
|
||||
])
|
||||
}
|
||||
// No transport opened, no active turn touched.
|
||||
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')
|
||||
|
||||
// The live turn is untouched: still active and streaming.
|
||||
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,226 @@
|
||||
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'
|
||||
|
||||
// What a sent attachment leaves behind in the transcript: the upload's display
|
||||
// name plus a local preview (object URL); the server ref is not re-fetchable yet.
|
||||
export interface UserAttachment {
|
||||
name: string
|
||||
previewUrl?: string
|
||||
}
|
||||
|
||||
// Recorded at send time because the event stream is assistant-only; paired to its
|
||||
// assistant turn by the shared TurnId (the server-minted message_id).
|
||||
interface UserEntry {
|
||||
id: TurnId
|
||||
role: 'user'
|
||||
text: string
|
||||
attachments?: UserAttachment[]
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
export type ConversationEntry = UserEntry | AssistantMessage
|
||||
|
||||
export const useAgentConversationStore = defineStore(
|
||||
'agentConversation',
|
||||
() => {
|
||||
const messages = ref<AssistantMessage[]>([])
|
||||
const activeTurnId = ref<TurnId | null>(null)
|
||||
// Held here (not the session composable) so it survives a panel remount: a reopened
|
||||
// panel with a null thread would post to 'new' and split the transcript across two
|
||||
// server threads.
|
||||
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
|
||||
// Reactive so status/isStreaming re-derive the instant a turn opens or settles; a plain
|
||||
// closure int leaves those computeds stale until the next messages mutation.
|
||||
const activeIndex = ref(-1)
|
||||
|
||||
function replaceActive(message: AssistantMessage): void {
|
||||
if (activeIndex.value >= 0) messages.value[activeIndex.value] = 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
|
||||
}
|
||||
|
||||
// Opens no transport and leaves activeTurnId/activeIndex untouched, so any in-flight
|
||||
// turn is unaffected.
|
||||
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)
|
||||
}
|
||||
|
||||
// Abort any prior in-flight turn first so a dropped reply can't leave two turns live.
|
||||
function startTurn(turnId: TurnId): void {
|
||||
if (transport) abortActiveTurn()
|
||||
const message = createAssistantMessage(turnId)
|
||||
activeIndex.value = messages.value.push(message) - 1
|
||||
activeTurnId.value = turnId
|
||||
transport = createAgentEventTransport(message, replaceActive)
|
||||
}
|
||||
|
||||
// The store owns turn filtering: events for a foreign turn are dropped here. Every v1
|
||||
// chat event carries data.message_id, so there is no absent-id case to guard.
|
||||
function ingest(event: AgentChatEvent): void {
|
||||
if (!transport) return
|
||||
if (event.data.message_id !== activeTurnId.value) return
|
||||
if (event.type === 'agent_message_done') {
|
||||
transport.settle()
|
||||
clearActive()
|
||||
return
|
||||
}
|
||||
transport.ingest(event)
|
||||
}
|
||||
|
||||
// Settle the in-flight turn WITHOUT a done (socket drop mid-turn): close open blocks and
|
||||
// clear transport/activeTurnId so no spinner is stuck forever.
|
||||
function abortActiveTurn(): void {
|
||||
if (!transport) return
|
||||
transport.settle()
|
||||
clearActive()
|
||||
}
|
||||
|
||||
function clearActive(): void {
|
||||
transport = null
|
||||
activeIndex.value = -1
|
||||
activeTurnId.value = null
|
||||
}
|
||||
|
||||
// Dropped transcript previews are object URLs pinning image buffers;
|
||||
// release them or every sent image outlives its conversation.
|
||||
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()
|
||||
}
|
||||
|
||||
// Rows pair by turn_id and order by seq. A turn whose assistant row is missing
|
||||
// (interrupted before the reply persisted) still gets a settled placeholder so its user
|
||||
// prompt renders.
|
||||
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
|
||||
// The history endpoint does not return attachment refs or tags yet;
|
||||
// hydrated turns render text-only until the BE persists them.
|
||||
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,
|
||||
reset,
|
||||
hydrate
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,221 @@
|
||||
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'
|
||||
|
||||
// Minimal full-graph content mirroring draft_patch.content in ws-turn-edit.jsonl:
|
||||
// a single-node snapshot whose lone widget value carries the version's edit.
|
||||
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)
|
||||
|
||||
// base_version 26 would gap the 25 we hold; content is a full snapshot, so the
|
||||
// monotonic jump is still adopted without a refetch.
|
||||
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,64 @@
|
||||
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)
|
||||
|
||||
// Switching workflows clears content/version so a prior workflow's draft can't bleed in.
|
||||
function bind(id: string): void {
|
||||
if (id === workflowId.value) return
|
||||
workflowId.value = id
|
||||
content.value = null
|
||||
version.value = null
|
||||
}
|
||||
|
||||
// draft_patch.content is the FULL graph snapshot at that version, NOT a diff, so a
|
||||
// base_version gap does not force a mid-stream refetch; monotonic adoption suffices.
|
||||
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'
|
||||
}
|
||||
|
||||
// Equal-version re-adopt is a harmless idempotent refresh; an older snapshot is ignored.
|
||||
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,62 @@
|
||||
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('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,63 @@
|
||||
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', () => {
|
||||
// Mirrors the PostHog flag gate (fail-closed): host renders the panel only while on.
|
||||
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' })
|
||||
}
|
||||
|
||||
// Idempotent: the flag gate re-syncs close() on every flag evaluation, and a
|
||||
// no-op close must not emit a phantom closed event or corrupt the duration.
|
||||
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,35 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { useAgentWorkflowTabBindingStore } from './agentWorkflowTabBindingStore'
|
||||
|
||||
describe('agentWorkflowTabBindingStore', () => {
|
||||
beforeEach(() => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
// 1:1 workflow_id ↔ tab-path binding: sends resolve the active tab to its id,
|
||||
// patches resolve back to their tab, so sequential agent edits land in ONE tab.
|
||||
export const useAgentWorkflowTabBindingStore = defineStore(
|
||||
'agentWorkflowTabBinding',
|
||||
() => {
|
||||
const tabByWorkflow = reactive(new Map<string, string>())
|
||||
|
||||
function bind(workflowId: string, tabPath: string): void {
|
||||
for (const [boundId, boundPath] of tabByWorkflow) {
|
||||
if (boundPath === tabPath) tabByWorkflow.delete(boundId)
|
||||
}
|
||||
tabByWorkflow.set(workflowId, tabPath)
|
||||
}
|
||||
|
||||
function tabPathFor(workflowId: string): string | undefined {
|
||||
return tabByWorkflow.get(workflowId)
|
||||
}
|
||||
|
||||
function workflowIdFor(tabPath: string): string | undefined {
|
||||
for (const [workflowId, boundPath] of tabByWorkflow) {
|
||||
if (boundPath === tabPath) return workflowId
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
return { bind, tabPathFor, workflowIdFor }
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user