feat(workflow): show durable run records in Chat
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
.root {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.runHeader {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.runHeader:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.runLeading {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.runTitle {
|
||||
overflow: hidden;
|
||||
flex: none;
|
||||
max-width: 42%;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 14px;
|
||||
font-weight: 510;
|
||||
line-height: 24px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.runSummary {
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.statusTail {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
height: 20px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
font-size: 11px;
|
||||
font-weight: 510;
|
||||
line-height: 16px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.phaseHeader {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 32px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.phaseHeader:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: -2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.phaseLeading {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.phaseTitle {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.phaseCount {
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.phaseStatus {
|
||||
overflow: hidden;
|
||||
flex: none;
|
||||
width: 132px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.separator {
|
||||
flex: none;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
border-radius: 50%;
|
||||
background: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.phaseList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
padding: 4px 0 0 16px;
|
||||
}
|
||||
|
||||
.phase {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.members {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
padding: 0 0 0 16px;
|
||||
}
|
||||
|
||||
.memberRow,
|
||||
.memberButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 24px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.memberButton {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.memberButton .memberLabel {
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
text-decoration: underline;
|
||||
text-underline-position: from-font;
|
||||
}
|
||||
|
||||
.dotSlot {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 24px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.memberButton:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.memberButton:focus-visible .memberLabelWrap {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.memberLabelWrap {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 24px;
|
||||
align-items: center;
|
||||
padding: 0 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.memberLabel {
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.memberStatus {
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
width: 64px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.phaseList,
|
||||
.members {
|
||||
padding-left: 12px;
|
||||
}
|
||||
}
|
||||
235
packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx
Normal file
235
packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import { useMemo, useState, type KeyboardEvent } from 'react'
|
||||
import {
|
||||
IconChevronDownOutline14, IconChevronRightOutline14, StateDot, type StateDotState,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { WorkflowRunKey } from './locales.ts'
|
||||
import type {
|
||||
WorkflowRunMemberData, WorkflowRunPhaseData, WorkflowRunStatus,
|
||||
} from './workflow-definition.ts'
|
||||
import css from './WorkflowRunPanel.module.css'
|
||||
|
||||
/** Navigation action injected from the plugin's own SessionsService access. */
|
||||
export interface WorkflowRunInjected {
|
||||
readonly openSession: (id: SessionId) => void
|
||||
}
|
||||
|
||||
/** Complete keyed Chat renderer props. */
|
||||
export type WorkflowRunPanelProps =
|
||||
PropsRuntime<'conversation.chat.node', 'workflow-run'>
|
||||
& PropsLocale<'workflowRun'>
|
||||
& WorkflowRunInjected
|
||||
|
||||
const STATUS_KEYS = {
|
||||
running: 'status.running',
|
||||
completed: 'status.completed',
|
||||
failed: 'status.failed',
|
||||
cancelled: 'status.cancelled',
|
||||
interrupted: 'status.interrupted',
|
||||
} as const satisfies Record<WorkflowRunStatus, WorkflowRunKey>
|
||||
|
||||
function dotState(status: WorkflowRunStatus): StateDotState {
|
||||
switch (status) {
|
||||
case 'running': return 'ongoing'
|
||||
case 'completed': return 'done'
|
||||
case 'failed': return 'error'
|
||||
case 'cancelled':
|
||||
case 'interrupted': return 'warning'
|
||||
/* v8 ignore next -- WorkflowRunStatus is closed and every variant is handled above. */
|
||||
default: return status satisfies never
|
||||
}
|
||||
}
|
||||
|
||||
function readablePhase(phase: string | null, t: WorkflowRunPanelProps['t']): string {
|
||||
if (phase === null) return t('phase.unassigned')
|
||||
return phase === '' ? t('phase.empty') : phase
|
||||
}
|
||||
|
||||
function readableMember(label: string, t: WorkflowRunPanelProps['t']): string {
|
||||
return label === '' ? t('member.empty') : label
|
||||
}
|
||||
|
||||
function statusCount(
|
||||
status: WorkflowRunStatus,
|
||||
count: number,
|
||||
t: WorkflowRunPanelProps['t'],
|
||||
): string {
|
||||
return t(`statusCount.${status}`, { count })
|
||||
}
|
||||
|
||||
function phaseStatusSummary(members: readonly WorkflowRunMemberData[], t: WorkflowRunPanelProps['t']): string {
|
||||
const counts = new Map<WorkflowRunStatus, number>()
|
||||
for (const member of members) counts.set(member.status, (counts.get(member.status) ?? 0) + 1)
|
||||
const count = (status: WorkflowRunStatus): number => counts.get(status) ?? 0
|
||||
const active = (['running', 'failed', 'cancelled', 'interrupted'] as const)
|
||||
.filter(status => count(status) > 0)
|
||||
if (active.length === 0) return statusCount('completed', count('completed'), t)
|
||||
const visible = active.includes('interrupted') && count('completed') > 0
|
||||
? ['completed' as const, ...active]
|
||||
: active
|
||||
return visible.map(status => statusCount(status, count(status), t)).join(' · ')
|
||||
}
|
||||
|
||||
function handleDisclosureKey(event: KeyboardEvent<HTMLDivElement>, onToggle: () => void): void {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||
event.preventDefault()
|
||||
onToggle()
|
||||
}
|
||||
|
||||
function RunHeader({ count, name, onToggle, open, status, t }: {
|
||||
readonly count: number
|
||||
readonly name: string
|
||||
readonly onToggle: () => void
|
||||
readonly open: boolean
|
||||
readonly status: WorkflowRunStatus
|
||||
readonly t: WorkflowRunPanelProps['t']
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={css.runHeader}
|
||||
data-run-header
|
||||
data-status={status}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={open}
|
||||
onClick={onToggle}
|
||||
onKeyDown={(event) => { handleDisclosureKey(event, onToggle) }}
|
||||
>
|
||||
<span className={css.runLeading}>
|
||||
{open ? <IconChevronDownOutline14 /> : <IconChevronRightOutline14 />}
|
||||
</span>
|
||||
<span className={css.runTitle}>{t('run.title', { name })}</span>
|
||||
<span className={css.separator} aria-hidden />
|
||||
<span className={css.runSummary}>{t('run.members', { count })}</span>
|
||||
<span className={css.statusTail} data-run-status-tail data-status={status}>
|
||||
<StateDot state={dotState(status)} />
|
||||
<span>{t(STATUS_KEYS[status])}</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MemberRow({ member, navigable, openSession, t }: {
|
||||
readonly member: WorkflowRunMemberData
|
||||
readonly navigable: boolean
|
||||
readonly openSession: WorkflowRunInjected['openSession']
|
||||
readonly t: WorkflowRunPanelProps['t']
|
||||
}) {
|
||||
const name = readableMember(member.label, t)
|
||||
const content = (
|
||||
<>
|
||||
<span className={css.dotSlot}><StateDot state={dotState(member.status)} /></span>
|
||||
<span className={css.memberLabelWrap} data-member-label-wrap><span className={css.memberLabel} data-member-label>{name}</span></span>
|
||||
<span className={css.memberStatus} data-member-status-text>{t(STATUS_KEYS[member.status])}</span>
|
||||
</>
|
||||
)
|
||||
if (!navigable) {
|
||||
return <div className={css.memberRow} data-member-status={member.status}>{content}</div>
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={css.memberButton}
|
||||
data-member-status={member.status}
|
||||
aria-label={t('member.open', { name })}
|
||||
onClick={() => { openSession(member.childId) }}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function PhaseSection({ phase, navigable, openSession, t }: {
|
||||
readonly phase: WorkflowRunPhaseData
|
||||
readonly navigable: ReadonlySet<SessionId>
|
||||
readonly openSession: WorkflowRunInjected['openSession']
|
||||
readonly t: WorkflowRunPanelProps['t']
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const toggle = (): void => { setOpen(value => !value) }
|
||||
return (
|
||||
<div className={css.phase} data-phase-key={phase.key} data-phase-status={phase.status}>
|
||||
<div
|
||||
className={css.phaseHeader}
|
||||
data-phase-header
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={open}
|
||||
onClick={toggle}
|
||||
onKeyDown={(event) => { handleDisclosureKey(event, toggle) }}
|
||||
>
|
||||
<span className={css.phaseLeading}>
|
||||
{open ? <IconChevronDownOutline14 /> : <IconChevronRightOutline14 />}
|
||||
</span>
|
||||
<span className={css.phaseTitle}>{readablePhase(phase.phase, t)}</span>
|
||||
<span className={css.separator} aria-hidden />
|
||||
<span className={css.phaseCount} data-phase-count>{t('run.members', { count: phase.members.length })}</span>
|
||||
<span className={css.phaseStatus} data-phase-status-text>{phaseStatusSummary(phase.members, t)}</span>
|
||||
</div>
|
||||
{open && (
|
||||
<div className={css.members}>
|
||||
{phase.members.map(member => (
|
||||
<MemberRow
|
||||
key={member.seq}
|
||||
member={member}
|
||||
navigable={navigable.has(member.childId)}
|
||||
openSession={openSession}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Render one durable workflow run with independent run and phase disclosure. */
|
||||
export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t }: WorkflowRunPanelProps) {
|
||||
const [open, setOpen] = useState(() => node.data.status === 'running')
|
||||
const sessions = useSessions(value => value)
|
||||
const navigable = useMemo(() => {
|
||||
const ordinary = new Set(sessions.ids)
|
||||
const result = new Set<SessionId>()
|
||||
for (const phase of node.data.phases) {
|
||||
for (const member of phase.members) {
|
||||
const summary = sessions.byId[member.childId]
|
||||
if (member.status === 'running'
|
||||
&& ordinary.has(member.childId)
|
||||
&& summary?.origin === 'subagent'
|
||||
&& summary.parentId === sessionId
|
||||
&& summary.running) {
|
||||
result.add(member.childId)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}, [node.data.phases, sessionId, sessions])
|
||||
return (
|
||||
<section className={css.root} data-workflow-run data-run-status={node.data.status}>
|
||||
<RunHeader
|
||||
count={node.data.memberCount}
|
||||
name={node.data.name}
|
||||
open={open}
|
||||
status={node.data.status}
|
||||
t={t}
|
||||
onToggle={() => { setOpen(value => !value) }}
|
||||
/>
|
||||
{open && (
|
||||
<div className={css.phaseList}>
|
||||
{node.data.phases.length === 0
|
||||
? <span className={css.empty}>{t('run.empty')}</span>
|
||||
: node.data.phases.map(phase => (
|
||||
<PhaseSection
|
||||
key={phase.key}
|
||||
phase={phase}
|
||||
navigable={navigable}
|
||||
openSession={openSession}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
38
packages/client/ui-workflow-run/src/client/index.ts
Normal file
38
packages/client/ui-workflow-run/src/client/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/** Browser plugin for durable workflow-run Conversation Nodes. */
|
||||
|
||||
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { WorkflowRunPanel, type WorkflowRunInjected } from './WorkflowRunPanel.tsx'
|
||||
import { en, NS, type WorkflowRunKey, zh } from './locales.ts'
|
||||
import { workflowRunDefinition } from './workflow-definition.ts'
|
||||
|
||||
export type { WorkflowRunInjected, WorkflowRunPanelProps } from './WorkflowRunPanel.tsx'
|
||||
export type {
|
||||
WorkflowRunChatData, WorkflowRunMemberData, WorkflowRunPhaseData, WorkflowRunStatus,
|
||||
} from './workflow-definition.ts'
|
||||
export type { WorkflowRunKey } from './locales.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** Durable workflow-run node copy. */
|
||||
workflowRun: WorkflowRunKey
|
||||
}
|
||||
}
|
||||
|
||||
/** Required services for Definition, keyed renderer, navigation, and copy. */
|
||||
export const inject = ['conversationEvents', 'slots', 'sessions', 'locale']
|
||||
|
||||
/** Register the workflow Definition, dictionary, and keyed Chat renderer. */
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.conversationEvents.register(workflowRunDefinition)
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-workflow-run: dictionaries')
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
|
||||
name: 'conversation.chat.node',
|
||||
key: 'workflow-run',
|
||||
locale: NS,
|
||||
inject: (): WorkflowRunInjected => ({
|
||||
openSession: (id: SessionId) => { ctx.sessions.open(id) },
|
||||
}),
|
||||
}, WorkflowRunPanel))
|
||||
}
|
||||
49
packages/client/ui-workflow-run/src/client/locales.ts
Normal file
49
packages/client/ui-workflow-run/src/client/locales.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/** `workflowRun` namespace dictionaries. */
|
||||
|
||||
/** Dictionary namespace owned by this plugin. */
|
||||
export const NS = 'workflowRun'
|
||||
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
export const zh = {
|
||||
'run.title': '{name}',
|
||||
'run.members': '{count} 个成员',
|
||||
'run.empty': '没有启动成员',
|
||||
'phase.unassigned': '未分阶段',
|
||||
'phase.empty': '空阶段名',
|
||||
'statusCount.running': '运行中 {count}',
|
||||
'statusCount.completed': '已完成 {count}',
|
||||
'statusCount.failed': '失败 {count}',
|
||||
'statusCount.cancelled': '已取消 {count}',
|
||||
'statusCount.interrupted': '已中断 {count}',
|
||||
'member.empty': '空成员名',
|
||||
'member.open': '打开 {name}',
|
||||
'status.running': '运行中',
|
||||
'status.completed': '已完成',
|
||||
'status.failed': '失败',
|
||||
'status.cancelled': '已取消',
|
||||
'status.interrupted': '已中断',
|
||||
}
|
||||
|
||||
/** English dictionary (same key set). */
|
||||
export const en: Record<WorkflowRunKey, string> = {
|
||||
'run.title': '{name}',
|
||||
'run.members': '{count} members',
|
||||
'run.empty': 'No members started',
|
||||
'phase.unassigned': 'Unphased',
|
||||
'phase.empty': 'Empty phase name',
|
||||
'statusCount.running': 'Running {count}',
|
||||
'statusCount.completed': 'Completed {count}',
|
||||
'statusCount.failed': 'Failed {count}',
|
||||
'statusCount.cancelled': 'Cancelled {count}',
|
||||
'statusCount.interrupted': 'Interrupted {count}',
|
||||
'member.empty': 'Empty member name',
|
||||
'member.open': 'Open {name}',
|
||||
'status.running': 'Running',
|
||||
'status.completed': 'Completed',
|
||||
'status.failed': 'Failed',
|
||||
'status.cancelled': 'Cancelled',
|
||||
'status.interrupted': 'Interrupted',
|
||||
}
|
||||
|
||||
/** Union of this namespace's dictionary keys. */
|
||||
export type WorkflowRunKey = keyof typeof zh
|
||||
@@ -0,0 +1,200 @@
|
||||
import type {
|
||||
ChatConversationViewNode, ConversationLocation, ConversationNodeContext,
|
||||
ConversationNodeDefinition,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
ToolWorkflowAgentEndData, ToolWorkflowAgentStartData,
|
||||
} from '@deepseek-ai/dsh-tool-workflow/types'
|
||||
import type { WorkflowAgentOutcome, WorkflowStopReason } from '@deepseek-ai/dsh-workflow/types'
|
||||
|
||||
/** Status shown for a workflow, phase, or member. */
|
||||
export type WorkflowRunStatus = 'running' | 'completed' | 'failed' | 'cancelled' | 'interrupted'
|
||||
|
||||
/** Final renderer data for one member. */
|
||||
export interface WorkflowRunMemberData {
|
||||
readonly seq: number
|
||||
readonly label: string
|
||||
readonly childId: SessionId
|
||||
readonly status: WorkflowRunStatus
|
||||
}
|
||||
|
||||
/** Final renderer data for one exact phase identity. */
|
||||
export interface WorkflowRunPhaseData {
|
||||
readonly key: string
|
||||
/** `null` is the absent field; the empty string remains a distinct identity. */
|
||||
readonly phase: string | null
|
||||
readonly status: WorkflowRunStatus
|
||||
readonly members: readonly WorkflowRunMemberData[]
|
||||
}
|
||||
|
||||
/** Final keyed Chat payload for one workflow run. */
|
||||
export interface WorkflowRunChatData {
|
||||
readonly name: string
|
||||
readonly status: WorkflowRunStatus
|
||||
readonly memberCount: number
|
||||
readonly phases: readonly WorkflowRunPhaseData[]
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
interface ChatNodeDataMap {
|
||||
/** Durable top-level workflow run and all members that actually started. */
|
||||
'workflow-run': WorkflowRunChatData
|
||||
}
|
||||
}
|
||||
|
||||
interface WorkflowMemberState extends ToolWorkflowAgentStartData {
|
||||
readonly outcome?: WorkflowAgentOutcome
|
||||
}
|
||||
|
||||
interface WorkflowState {
|
||||
readonly name: string
|
||||
readonly stopReason?: WorkflowStopReason
|
||||
readonly members: readonly WorkflowMemberState[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a collision-free phase key preserving absent versus empty identity.
|
||||
* @param phase - exact phase string, or null for an omitted field.
|
||||
* @returns the stable renderer key for that phase identity.
|
||||
*/
|
||||
export function workflowPhaseKey(phase: string | null): string {
|
||||
return phase === null ? 'missing' : `value:${phase.length}:${phase}`
|
||||
}
|
||||
|
||||
function statusFromStopReason(stopReason: WorkflowStopReason): WorkflowRunStatus {
|
||||
switch (stopReason) {
|
||||
case 'completed': return 'completed'
|
||||
case 'cancelled': return 'cancelled'
|
||||
case 'error': return 'failed'
|
||||
/* v8 ignore next -- WorkflowStopReason is closed and every variant is handled above. */
|
||||
default: return stopReason satisfies never
|
||||
}
|
||||
}
|
||||
|
||||
function statusFromOutcome(outcome: WorkflowAgentOutcome): WorkflowRunStatus {
|
||||
switch (outcome) {
|
||||
case 'completed': return 'completed'
|
||||
case 'cancelled': return 'cancelled'
|
||||
case 'failed': return 'failed'
|
||||
/* v8 ignore next -- WorkflowAgentOutcome is closed and every variant is handled above. */
|
||||
default: return outcome satisfies never
|
||||
}
|
||||
}
|
||||
|
||||
function locationClosed(location: ConversationLocation | undefined): boolean {
|
||||
if (location === undefined) return false
|
||||
if (location.kind === 'step') {
|
||||
return location.step.status === 'closed' || location.turn.status === 'closed'
|
||||
}
|
||||
return location.kind === 'turn' && location.turn.status === 'closed'
|
||||
}
|
||||
|
||||
function aggregateStatus(members: readonly WorkflowRunMemberData[]): WorkflowRunStatus {
|
||||
if (members.some(member => member.status === 'running')) return 'running'
|
||||
if (members.some(member => member.status === 'failed')) return 'failed'
|
||||
if (members.some(member => member.status === 'cancelled')) return 'cancelled'
|
||||
if (members.some(member => member.status === 'interrupted')) return 'interrupted'
|
||||
return 'completed'
|
||||
}
|
||||
|
||||
function projectWorkflow(
|
||||
context: ConversationNodeContext<WorkflowState>,
|
||||
): WorkflowRunChatData | undefined {
|
||||
const state = context.state
|
||||
if (state === undefined) return undefined
|
||||
const interrupted = state.stopReason === undefined
|
||||
&& locationClosed(context.start?.location ?? context.matches[0]?.location)
|
||||
const phases = new Map<string, { phase: string | null; members: WorkflowRunMemberData[] }>()
|
||||
for (const member of state.members) {
|
||||
const phase = member.phase === undefined ? null : member.phase
|
||||
const key = workflowPhaseKey(phase)
|
||||
let group = phases.get(key)
|
||||
if (group === undefined) {
|
||||
group = { phase, members: [] }
|
||||
phases.set(key, group)
|
||||
}
|
||||
group.members.push({
|
||||
seq: member.seq,
|
||||
label: member.label,
|
||||
childId: member.childId,
|
||||
status: member.outcome === undefined
|
||||
? interrupted ? 'interrupted' : 'running'
|
||||
: statusFromOutcome(member.outcome),
|
||||
})
|
||||
}
|
||||
const projectedPhases = [...phases].map(([key, phase]) => ({
|
||||
key,
|
||||
phase: phase.phase,
|
||||
status: aggregateStatus(phase.members),
|
||||
members: phase.members,
|
||||
}))
|
||||
return {
|
||||
name: state.name,
|
||||
status: state.stopReason === undefined
|
||||
? interrupted ? 'interrupted' : 'running'
|
||||
: statusFromStopReason(state.stopReason),
|
||||
memberCount: state.members.length,
|
||||
phases: projectedPhases,
|
||||
}
|
||||
}
|
||||
|
||||
function updateAgentStart(state: WorkflowState, data: ToolWorkflowAgentStartData): WorkflowState {
|
||||
return { ...state, members: [...state.members, data] }
|
||||
}
|
||||
|
||||
function updateAgentEnd(state: WorkflowState, data: ToolWorkflowAgentEndData): WorkflowState {
|
||||
return {
|
||||
...state,
|
||||
members: state.members.map(member => member.seq === data.seq
|
||||
? { ...member, outcome: data.outcome }
|
||||
: member),
|
||||
}
|
||||
}
|
||||
|
||||
/** Durable workflow event family folded into one keyed Chat node. */
|
||||
export const workflowRunDefinition: ConversationNodeDefinition<WorkflowState> = {
|
||||
kind: 'workflow-run',
|
||||
match: (event) => {
|
||||
if (event.type === 'tool-workflow/run-start') return { id: String(event.data.runId), role: 'start' }
|
||||
if (event.type === 'tool-workflow/agent-start'
|
||||
|| event.type === 'tool-workflow/agent-end'
|
||||
|| event.type === 'tool-workflow/run-end') {
|
||||
return { id: String(event.data.runId), role: 'update' }
|
||||
}
|
||||
return null
|
||||
},
|
||||
start: (_context, match) => {
|
||||
if (match.event.type !== 'tool-workflow/run-start') {
|
||||
throw new Error('workflow-run start requires tool-workflow/run-start')
|
||||
}
|
||||
return { name: match.event.data.name, members: [] }
|
||||
},
|
||||
update: (context, match) => {
|
||||
if (match.event.type === 'tool-workflow/agent-start') {
|
||||
return updateAgentStart(context.state, match.event.data)
|
||||
}
|
||||
if (match.event.type === 'tool-workflow/agent-end') {
|
||||
return updateAgentEnd(context.state, match.event.data)
|
||||
}
|
||||
if (match.event.type === 'tool-workflow/run-end') {
|
||||
return { ...context.state, stopReason: match.event.data.stopReason }
|
||||
}
|
||||
return context.state
|
||||
},
|
||||
buildViewNode: (context, target): ChatConversationViewNode | null => {
|
||||
if (target !== 'chat') return null
|
||||
const data = projectWorkflow(context)
|
||||
if (data === undefined || context.start === undefined) return null
|
||||
return {
|
||||
key: context.key,
|
||||
kind: 'workflow-run',
|
||||
id: context.id,
|
||||
target: 'chat',
|
||||
anchorSeq: context.start.event.seq,
|
||||
location: context.start.location,
|
||||
visibility: 'visible',
|
||||
data,
|
||||
}
|
||||
},
|
||||
}
|
||||
6
packages/client/ui-workflow-run/src/css-modules.d.ts
vendored
Normal file
6
packages/client/ui-workflow-run/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
4
packages/client/ui-workflow-run/src/index.ts
Normal file
4
packages/client/ui-workflow-run/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/** Durable workflow-run UI plugin, node half. */
|
||||
|
||||
/** Host plugin body; the feature is entirely browser-side. */
|
||||
export function apply(): void {}
|
||||
24
packages/client/ui-workflow-run/src/invariant.ts
Normal file
24
packages/client/ui-workflow-run/src/invariant.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/** Package-owned invariant companion for the workflow-run UI plugin. */
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-workflow-run'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-ui-workflow-run-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the browser plugin contributes one effect-owned
|
||||
* Conversation Definition, keyed renderer, and dictionary; tests prove their
|
||||
* disposal and the Host tool package owns the durable event invariant.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/** Register this package's invariant companion. */
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
Reference in New Issue
Block a user