Compare commits
4 Commits
deploy/dev
...
feat/workf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3681a3796 | ||
|
|
84ed12ba3b | ||
|
|
b9bf6d01e0 | ||
|
|
08387f43e3 |
188
web/app/(commonLayout)/workflow-copilot-debug/page.tsx
Normal file
188
web/app/(commonLayout)/workflow-copilot-debug/page.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { COPILOT_ACTION_KINDS } from '@/app/components/workflow-copilot/types'
|
||||
import { useCopilotSession } from '@/app/components/workflow-copilot/use-copilot-session'
|
||||
import { API_PREFIX } from '@/config'
|
||||
|
||||
// Throwaway developer tool for driving the workflow-copilot Fix backend by hand.
|
||||
// No i18n, no design polish, no reusable UI components on purpose.
|
||||
// Session/stream request logic lives in `app/components/workflow-copilot/use-copilot-session.ts`,
|
||||
// shared with the in-editor Workflow Copilot panel (see `app/components/workflow/panel/workflow-copilot-panel`).
|
||||
|
||||
export default function WorkflowCopilotDebugPage() {
|
||||
const [baseUrl, setBaseUrl] = useState(API_PREFIX)
|
||||
const [appId, setAppId] = useState('')
|
||||
const [failedRunId, setFailedRunId] = useState('')
|
||||
const [workspaceId, setWorkspaceId] = useState('')
|
||||
const [messageText, setMessageText] = useState('')
|
||||
|
||||
const { view, lastRaw, lastError, progressLog, startFix, refresh, runAction, sendMessage } =
|
||||
useCopilotSession({ baseUrl, workspaceId })
|
||||
|
||||
const handleStartFix = async () => {
|
||||
await startFix(appId, failedRunId)
|
||||
}
|
||||
|
||||
const handleGet = async () => {
|
||||
await refresh()
|
||||
}
|
||||
|
||||
const handleAction = async (kind: (typeof COPILOT_ACTION_KINDS)[number]) => {
|
||||
await runAction(kind, kind === 'provide_testdata' ? { mode: 'mock' } : {})
|
||||
}
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
const ok = await sendMessage(messageText)
|
||||
if (ok) setMessageText('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, fontFamily: 'monospace', maxWidth: 960 }}>
|
||||
<h1>Workflow Copilot Debug</h1>
|
||||
<p>Throwaway developer tool for driving the copilot Fix backend by hand.</p>
|
||||
|
||||
<fieldset style={{ marginBottom: 16 }}>
|
||||
<legend>Connection</legend>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, maxWidth: 480 }}>
|
||||
<label>
|
||||
Base URL
|
||||
<input
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
App ID
|
||||
<input
|
||||
value={appId}
|
||||
onChange={(e) => setAppId(e.target.value)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Failed Run ID
|
||||
<input
|
||||
value={failedRunId}
|
||||
onChange={(e) => setFailedRunId(e.target.value)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Workspace ID
|
||||
<input
|
||||
value={workspaceId}
|
||||
onChange={(e) => setWorkspaceId(e.target.value)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleStartFix()
|
||||
}}
|
||||
>
|
||||
Start Fix
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleGet()
|
||||
}}
|
||||
style={{ marginLeft: 8 }}
|
||||
>
|
||||
Refresh (GET)
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset style={{ marginBottom: 16 }}>
|
||||
<legend>Actions</legend>
|
||||
<div>
|
||||
{COPILOT_ACTION_KINDS.map((kind) => (
|
||||
<button
|
||||
key={kind}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleAction(kind)
|
||||
}}
|
||||
style={{ marginRight: 8, marginBottom: 8 }}
|
||||
>
|
||||
{kind}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, maxWidth: 480 }}>
|
||||
<input
|
||||
value={messageText}
|
||||
onChange={(e) => setMessageText(e.target.value)}
|
||||
placeholder="message text"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleSendMessage()
|
||||
}}
|
||||
>
|
||||
Send Message
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{lastError && <div style={{ color: 'red', marginBottom: 16 }}>Error: {lastError}</div>}
|
||||
|
||||
<fieldset style={{ marginBottom: 16 }}>
|
||||
<legend>Status</legend>
|
||||
{view ? (
|
||||
<ul>
|
||||
<li>SessionID: {view.SessionID}</li>
|
||||
<li>State: {view.State}</li>
|
||||
<li>Version: {view.Version}</li>
|
||||
<li>CanvasReadOnly: {String(view.CanvasReadOnly)}</li>
|
||||
<li>RunStatus: {view.RunStatus}</li>
|
||||
<li>Interrupted: {String(view.Interrupted)}</li>
|
||||
</ul>
|
||||
) : (
|
||||
<p>No session yet.</p>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
<fieldset style={{ marginBottom: 16 }}>
|
||||
<legend>Conversation</legend>
|
||||
<ul>
|
||||
{(view?.Conversation ?? []).map((entry) => (
|
||||
<li key={entry.Seq}>
|
||||
{entry.Seq}
|
||||
{' · '}
|
||||
{entry.Kind}
|
||||
{' · '}
|
||||
{JSON.stringify(entry.Payload)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</fieldset>
|
||||
|
||||
<fieldset style={{ marginBottom: 16 }}>
|
||||
<legend>Progress Log (stream)</legend>
|
||||
<ul>
|
||||
{progressLog.map((entry) => (
|
||||
<li key={entry.id}>
|
||||
{entry.event}
|
||||
{' · '}
|
||||
{JSON.stringify(entry.data)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Last Raw Response</legend>
|
||||
<pre>{JSON.stringify(lastRaw, null, 2)}</pre>
|
||||
</fieldset>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -40,6 +40,9 @@ const WorkflowHeader = () => {
|
||||
middle: <FeaturesTrigger />,
|
||||
chatVariableTrigger: <ChatVariableTrigger />,
|
||||
},
|
||||
controls: {
|
||||
showWorkflowCopilotButton: true,
|
||||
},
|
||||
runAndHistoryProps: {
|
||||
showRunButton: !isChatMode,
|
||||
showPreviewButton: isChatMode,
|
||||
|
||||
12
web/app/components/workflow-copilot/README.md
Normal file
12
web/app/components/workflow-copilot/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Workflow Copilot
|
||||
|
||||
Shared session/stream logic for the enterprise workflow-copilot Fix API, consumed by
|
||||
both the standalone debug page and the in-editor Workflow Copilot panel.
|
||||
|
||||
## Internal Modules
|
||||
|
||||
None.
|
||||
|
||||
## External Modules
|
||||
|
||||
- `config`: `CSRF_COOKIE_NAME`, `CSRF_HEADER_NAME` for authenticated console requests.
|
||||
86
web/app/components/workflow-copilot/types.ts
Normal file
86
web/app/components/workflow-copilot/types.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
// Contracts for the enterprise `workflow-copilot` Fix session API. There is no
|
||||
// generated contract for this endpoint yet (enterprise-only, still under active
|
||||
// design), so these mirror the backend's JSON responses by hand until one exists.
|
||||
|
||||
export type SessionView = {
|
||||
SessionID: string
|
||||
AppID: string
|
||||
Version: number
|
||||
State: string
|
||||
CanvasReadOnly: boolean
|
||||
RunStatus: string
|
||||
Interrupted: boolean
|
||||
Conversation: {
|
||||
Seq: number
|
||||
Kind: string
|
||||
Payload: Record<string, unknown>
|
||||
AtVersion: number
|
||||
}[]
|
||||
}
|
||||
|
||||
export type ProgressEntry = {
|
||||
id: number
|
||||
event: string
|
||||
data: unknown
|
||||
}
|
||||
|
||||
// Mirrors the backend's `bizcopilot.ChecklistError` JSON shape exactly (see
|
||||
// `pkg/enterprise/biz/copilot/models.go`). Used both to seed a checklist-fix
|
||||
// session (`checklist_errors`) and to report the still-failing items on a
|
||||
// `recheck` action (`remaining`).
|
||||
export type ChecklistErrorPayload = {
|
||||
node_id: string
|
||||
node_type: string
|
||||
title: string
|
||||
messages: string[]
|
||||
unconnected: boolean
|
||||
plugin_missing: boolean
|
||||
}
|
||||
|
||||
// The state at which the backend has applied a checklist repair to the draft
|
||||
// and is waiting for the frontend to re-run its client-side checklist and
|
||||
// report the result via a `recheck` action. Mirrors `bizcopilot.ChecklistAwaitRecheck`.
|
||||
export const CHECKLIST_AWAIT_RECHECK_STATE = 'checklist.await_recheck'
|
||||
|
||||
// Fire-and-forget actions rendered as generic buttons (no computed payload
|
||||
// beyond the odd `provide_testdata` special-case in the panel).
|
||||
export const COPILOT_MANUAL_ACTION_KINDS = [
|
||||
'approve_repair',
|
||||
'run_verify',
|
||||
'provide_testdata',
|
||||
'publish',
|
||||
'undo',
|
||||
're_fix',
|
||||
] as const
|
||||
|
||||
// `recheck` carries a computed `{ passed, remaining }` payload (see
|
||||
// `ChecklistErrorPayload`) built from the current client-side checklist, so
|
||||
// it is driven by dedicated panel logic rather than a generic button.
|
||||
export const COPILOT_ACTION_KINDS = [...COPILOT_MANUAL_ACTION_KINDS, 'recheck'] as const
|
||||
|
||||
export type CopilotActionKind = (typeof COPILOT_ACTION_KINDS)[number]
|
||||
|
||||
export function isSessionView(value: unknown): value is SessionView {
|
||||
if (typeof value !== 'object' || value === null) return false
|
||||
return 'SessionID' in value && 'Version' in value
|
||||
}
|
||||
|
||||
// Parses one `event: <kind>\ndata: <json>` SSE frame (already split on the `\n\n` delimiter).
|
||||
export function parseSSEFrame(frame: string): { event: string; data: unknown } | null {
|
||||
const lines = frame.split('\n')
|
||||
let event = 'message'
|
||||
const dataLines: string[] = []
|
||||
lines.forEach((line) => {
|
||||
if (line.startsWith('event:')) event = line.slice(6).trim()
|
||||
else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim())
|
||||
})
|
||||
if (dataLines.length === 0) return null
|
||||
const raw = dataLines.join('\n')
|
||||
let data: unknown = raw
|
||||
try {
|
||||
data = JSON.parse(raw)
|
||||
} catch {
|
||||
// not JSON, keep the raw string
|
||||
}
|
||||
return { event, data }
|
||||
}
|
||||
253
web/app/components/workflow-copilot/use-copilot-session.ts
Normal file
253
web/app/components/workflow-copilot/use-copilot-session.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
'use client'
|
||||
|
||||
import type { ChecklistErrorPayload, CopilotActionKind, ProgressEntry, SessionView } from './types'
|
||||
import Cookies from 'js-cookie'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { CSRF_COOKIE_NAME, CSRF_HEADER_NAME } from '@/config'
|
||||
import { isSessionView, parseSSEFrame } from './types'
|
||||
|
||||
// What seeds a new Fix session: either a failed workflow run (existing
|
||||
// run-fix flow) or a set of client-detected pre-publish checklist errors
|
||||
// (checklist-fix flow). Exactly one of the two is sent to the backend.
|
||||
type CreateSessionTarget = { failedRunId: string } | { checklistErrors: ChecklistErrorPayload[] }
|
||||
|
||||
export type UseCopilotSessionParams = {
|
||||
baseUrl: string
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
export type UseCopilotSessionResult = {
|
||||
view: SessionView | null
|
||||
lastRaw: unknown
|
||||
lastError: string
|
||||
progressLog: ProgressEntry[]
|
||||
/** Creates a new Fix session for `failedRunId` and starts streaming its progress. */
|
||||
startFix: (appId: string, failedRunId: string) => Promise<boolean>
|
||||
/** Creates a new checklist-fix session for `checklistErrors` and starts streaming its progress. */
|
||||
startChecklistFix: (appId: string, checklistErrors: ChecklistErrorPayload[]) => Promise<boolean>
|
||||
/** Re-fetches the current session (GET). No-op if no session has been created yet. */
|
||||
refresh: () => Promise<boolean>
|
||||
/** Posts an action (`approve_repair`, `run_verify`, ...) against the current session. */
|
||||
runAction: (kind: CopilotActionKind, payload?: Record<string, unknown>) => Promise<boolean>
|
||||
/** Posts a free-text message against the current session. */
|
||||
sendMessage: (text: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
function useCopilotApi(baseUrl: string, workspaceId: string) {
|
||||
// Memoized so the returned methods stay referentially stable across renders
|
||||
// unless the connection inputs actually change (also what earns the `use` prefix).
|
||||
return useMemo(() => {
|
||||
const root = `${baseUrl.replace(/\/$/, '')}/enterprise/workflow-copilot`
|
||||
|
||||
const headers = (json: boolean, csrf: boolean): Record<string, string> => {
|
||||
const h: Record<string, string> = {}
|
||||
if (json) h['Content-Type'] = 'application/json'
|
||||
if (csrf) h[CSRF_HEADER_NAME] = Cookies.get(CSRF_COOKIE_NAME()) || ''
|
||||
if (workspaceId) h['X-Workspace-Id'] = workspaceId
|
||||
return h
|
||||
}
|
||||
|
||||
const create = (appId: string, target: CreateSessionTarget) =>
|
||||
fetch(`${root}/sessions`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: headers(true, true),
|
||||
body: JSON.stringify({
|
||||
app_id: appId,
|
||||
...('failedRunId' in target
|
||||
? { failed_run_id: target.failedRunId }
|
||||
: { checklist_errors: target.checklistErrors }),
|
||||
}),
|
||||
})
|
||||
|
||||
const get = (id: string) =>
|
||||
fetch(`${root}/sessions/${id}`, {
|
||||
credentials: 'include',
|
||||
headers: headers(false, false),
|
||||
})
|
||||
|
||||
const action = (
|
||||
id: string,
|
||||
kind: string,
|
||||
baseVersion: number,
|
||||
payload: Record<string, unknown> = {},
|
||||
) =>
|
||||
fetch(`${root}/sessions/${id}/actions`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: headers(true, true),
|
||||
body: JSON.stringify({ kind, payload, base_version: baseVersion }),
|
||||
})
|
||||
|
||||
const message = (id: string, text: string, baseVersion: number) =>
|
||||
fetch(`${root}/sessions/${id}/messages`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: headers(true, true),
|
||||
body: JSON.stringify({ text, base_version: baseVersion }),
|
||||
})
|
||||
|
||||
const streamURL = (id: string) => `${root}/sessions/${id}/stream`
|
||||
|
||||
return { create, get, action, message, streamURL }
|
||||
}, [baseUrl, workspaceId])
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the workflow-copilot Fix session lifecycle: creating a session, streaming
|
||||
* its SSE progress log, posting actions/messages, and refreshing session state.
|
||||
* Shared by the standalone `/workflow-copilot-debug` page and the in-editor
|
||||
* Workflow Copilot panel so both drive the exact same request/stream logic.
|
||||
*/
|
||||
export function useCopilotSession({
|
||||
baseUrl,
|
||||
workspaceId,
|
||||
}: UseCopilotSessionParams): UseCopilotSessionResult {
|
||||
const [view, setView] = useState<SessionView | null>(null)
|
||||
const [lastRaw, setLastRaw] = useState<unknown>(null)
|
||||
const [lastError, setLastError] = useState('')
|
||||
const [progressLog, setProgressLog] = useState<ProgressEntry[]>([])
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const progressIdRef = useRef(0)
|
||||
|
||||
const api = useCopilotApi(baseUrl, workspaceId)
|
||||
|
||||
// Cancel the in-flight stream when the owning component unmounts.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortRef.current?.abort()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const pushProgress = useCallback((event: string, data: unknown) => {
|
||||
progressIdRef.current += 1
|
||||
setProgressLog((prev) => [...prev, { id: progressIdRef.current, event, data }])
|
||||
}, [])
|
||||
|
||||
const applyResponse = useCallback(async (res: Response): Promise<unknown> => {
|
||||
const text = await res.text()
|
||||
let parsed: unknown = text
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
} catch {
|
||||
// leave as raw text; still shown in the "Last Raw Response" panel
|
||||
}
|
||||
setLastRaw(parsed)
|
||||
setLastError(res.ok ? '' : `HTTP ${res.status}`)
|
||||
if (isSessionView(parsed)) setView(parsed)
|
||||
return parsed
|
||||
}, [])
|
||||
|
||||
const startStream = useCallback(
|
||||
(sessionId: string) => {
|
||||
abortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
fetch(api.streamURL(sessionId), { credentials: 'include', signal: controller.signal })
|
||||
.then(async (res) => {
|
||||
const reader = res.body?.getReader()
|
||||
if (!reader) return
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
let done = false
|
||||
while (!done) {
|
||||
const result = await reader.read()
|
||||
done = result.done
|
||||
if (result.value) buffer += decoder.decode(result.value, { stream: true })
|
||||
const frames = buffer.split('\n\n')
|
||||
buffer = frames.pop() ?? ''
|
||||
frames.forEach((frame) => {
|
||||
const parsed = parseSSEFrame(frame)
|
||||
if (parsed) pushProgress(parsed.event, parsed.data)
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (controller.signal.aborted) return
|
||||
pushProgress('error', String(err))
|
||||
})
|
||||
},
|
||||
[api, pushProgress],
|
||||
)
|
||||
|
||||
const beginSession = useCallback(
|
||||
async (appId: string, target: CreateSessionTarget) => {
|
||||
try {
|
||||
const res = await api.create(appId, target)
|
||||
const parsed = await applyResponse(res)
|
||||
if (isSessionView(parsed)) startStream(parsed.SessionID)
|
||||
return true
|
||||
} catch (err) {
|
||||
setLastError(String(err))
|
||||
return false
|
||||
}
|
||||
},
|
||||
[api, applyResponse, startStream],
|
||||
)
|
||||
|
||||
const startFix = useCallback(
|
||||
(appId: string, failedRunId: string) => beginSession(appId, { failedRunId }),
|
||||
[beginSession],
|
||||
)
|
||||
|
||||
const startChecklistFix = useCallback(
|
||||
(appId: string, checklistErrors: ChecklistErrorPayload[]) =>
|
||||
beginSession(appId, { checklistErrors }),
|
||||
[beginSession],
|
||||
)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!view) return false
|
||||
try {
|
||||
const res = await api.get(view.SessionID)
|
||||
await applyResponse(res)
|
||||
return true
|
||||
} catch (err) {
|
||||
setLastError(String(err))
|
||||
return false
|
||||
}
|
||||
}, [api, applyResponse, view])
|
||||
|
||||
const runAction = useCallback(
|
||||
async (kind: CopilotActionKind, payload: Record<string, unknown> = {}) => {
|
||||
if (!view) return false
|
||||
try {
|
||||
const res = await api.action(view.SessionID, kind, view.Version, payload)
|
||||
await applyResponse(res)
|
||||
return true
|
||||
} catch (err) {
|
||||
setLastError(String(err))
|
||||
return false
|
||||
}
|
||||
},
|
||||
[api, applyResponse, view],
|
||||
)
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (text: string) => {
|
||||
if (!view) return false
|
||||
try {
|
||||
const res = await api.message(view.SessionID, text, view.Version)
|
||||
await applyResponse(res)
|
||||
return true
|
||||
} catch (err) {
|
||||
setLastError(String(err))
|
||||
return false
|
||||
}
|
||||
},
|
||||
[api, applyResponse, view],
|
||||
)
|
||||
|
||||
return {
|
||||
view,
|
||||
lastRaw,
|
||||
lastError,
|
||||
progressLog,
|
||||
startFix,
|
||||
startChecklistFix,
|
||||
refresh,
|
||||
runAction,
|
||||
sendMessage,
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import OnlineUsers from './online-users'
|
||||
import RunAndHistory from './run-and-history'
|
||||
import ScrollToSelectedNodeButton from './scroll-to-selected-node-button'
|
||||
import { VersionHistoryButton } from './version-history-button'
|
||||
import WorkflowCopilotButton from './workflow-copilot-button'
|
||||
|
||||
export type HeaderInNormalProps = {
|
||||
components?: {
|
||||
@@ -27,6 +28,7 @@ export type HeaderInNormalProps = {
|
||||
controls?: {
|
||||
showEnvButton?: boolean
|
||||
showGlobalVariableButton?: boolean
|
||||
showWorkflowCopilotButton?: boolean
|
||||
}
|
||||
runAndHistoryProps?: RunAndHistoryProps
|
||||
}
|
||||
@@ -41,12 +43,14 @@ const HeaderInNormal = ({ components, controls, runAndHistoryProps }: HeaderInNo
|
||||
const setShowVariableInspectPanel = useStore((s) => s.setShowVariableInspectPanel)
|
||||
const setShowChatVariablePanel = useStore((s) => s.setShowChatVariablePanel)
|
||||
const setShowGlobalVariablePanel = useStore((s) => s.setShowGlobalVariablePanel)
|
||||
const setShowWorkflowCopilotPanel = useStore((s) => s.setShowWorkflowCopilotPanel)
|
||||
const nodes = useNodes<StartNodeType>()
|
||||
const selectedNode = nodes.find((node) => node.data.selected)
|
||||
const { handleBackupDraft } = useWorkflowRun()
|
||||
const { closeAllInputFieldPanels } = useInputFieldPanel()
|
||||
const showEnvButton = controls?.showEnvButton !== false
|
||||
const showGlobalVariableButton = controls?.showGlobalVariableButton !== false
|
||||
const showWorkflowCopilotButton = controls?.showWorkflowCopilotButton === true
|
||||
const showContextButtons =
|
||||
!!components?.chatVariableTrigger || showEnvButton || showGlobalVariableButton
|
||||
|
||||
@@ -61,6 +65,7 @@ const HeaderInNormal = ({ components, controls, runAndHistoryProps }: HeaderInNo
|
||||
setShowVariableInspectPanel(false)
|
||||
setShowChatVariablePanel(false)
|
||||
setShowGlobalVariablePanel(false)
|
||||
setShowWorkflowCopilotPanel(false)
|
||||
closeAllInputFieldPanels()
|
||||
}, [
|
||||
workflowStore,
|
||||
@@ -73,6 +78,7 @@ const HeaderInNormal = ({ components, controls, runAndHistoryProps }: HeaderInNo
|
||||
setShowVariableInspectPanel,
|
||||
setShowChatVariablePanel,
|
||||
setShowGlobalVariablePanel,
|
||||
setShowWorkflowCopilotPanel,
|
||||
closeAllInputFieldPanels,
|
||||
])
|
||||
|
||||
@@ -87,6 +93,7 @@ const HeaderInNormal = ({ components, controls, runAndHistoryProps }: HeaderInNo
|
||||
{components?.left}
|
||||
<Divider type="vertical" className="mx-auto h-3.5" />
|
||||
<RunAndHistory {...runAndHistoryProps} />
|
||||
{showWorkflowCopilotButton && <WorkflowCopilotButton disabled={nodesReadOnly} />}
|
||||
{showContextButtons && (
|
||||
<div className="shrink-0 cursor-pointer rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs backdrop-blur-[10px]">
|
||||
{components?.chatVariableTrigger}
|
||||
|
||||
@@ -132,6 +132,7 @@ const ViewHistory = ({ withText, onClearLogAndMessageModal, historyUrl }: ViewHi
|
||||
historyWorkflowData: item,
|
||||
showInputsPanel: false,
|
||||
showEnvPanel: false,
|
||||
showWorkflowCopilotPanel: false,
|
||||
})
|
||||
closeAllInputFieldPanels()
|
||||
handleBackupDraft()
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
|
||||
const WorkflowCopilotButton = ({ disabled }: { disabled: boolean }) => {
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const showWorkflowCopilotPanel = useStore((s) => s.showWorkflowCopilotPanel)
|
||||
const setShowWorkflowCopilotPanel = useStore((s) => s.setShowWorkflowCopilotPanel)
|
||||
const label = t(($) => $['copilot.buttonTooltip'], { ns: 'workflow' })
|
||||
|
||||
const handleClick = () => {
|
||||
setShowWorkflowCopilotPanel(!showWorkflowCopilotPanel)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label={label}
|
||||
aria-pressed={showWorkflowCopilotPanel}
|
||||
className={cn(
|
||||
'rounded-lg border border-transparent p-2',
|
||||
theme === 'dark' &&
|
||||
showWorkflowCopilotPanel &&
|
||||
'border-black/5 bg-white/10 backdrop-blur-xs',
|
||||
)}
|
||||
variant="ghost"
|
||||
disabled={disabled}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-ri-magic-line size-4 text-components-button-secondary-text"
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(WorkflowCopilotButton)
|
||||
@@ -16,6 +16,13 @@ const VersionHistoryPanel = dynamic(
|
||||
},
|
||||
)
|
||||
|
||||
const WorkflowCopilotPanel = dynamic(
|
||||
() => import('@/app/components/workflow/panel/workflow-copilot-panel'),
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
)
|
||||
|
||||
export type PanelProps = {
|
||||
components?: {
|
||||
left?: React.ReactNode
|
||||
@@ -96,6 +103,7 @@ const Panel: FC<PanelProps> = ({ components, versionHistoryPanelProps }) => {
|
||||
const showEnvPanel = useStore((s) => s.showEnvPanel)
|
||||
const isRestoring = useStore((s) => s.isRestoring)
|
||||
const showWorkflowVersionHistoryPanel = useStore((s) => s.showWorkflowVersionHistoryPanel)
|
||||
const showWorkflowCopilotPanel = useStore((s) => s.showWorkflowCopilotPanel)
|
||||
|
||||
// widths used for adaptive layout
|
||||
const workflowCanvasWidth = useStore((s) => s.workflowCanvasWidth)
|
||||
@@ -127,23 +135,34 @@ const Panel: FC<PanelProps> = ({ components, versionHistoryPanelProps }) => {
|
||||
const otherPanelRef = useResizeObserver(setOtherPanelWidth)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rightPanelRef}
|
||||
data-workflow-right-panel
|
||||
tabIndex={-1}
|
||||
className={cn('absolute top-14 right-0 bottom-1 z-10 flex outline-hidden')}
|
||||
key={`${isRestoring}`}
|
||||
>
|
||||
{components?.left}
|
||||
{!!selectedNode && <NodePanel {...selectedNode} />}
|
||||
<div className="relative" ref={otherPanelRef}>
|
||||
{components?.right}
|
||||
{showWorkflowVersionHistoryPanel && versionHistoryPanelProps && (
|
||||
<VersionHistoryPanel {...versionHistoryPanelProps} />
|
||||
)}
|
||||
{showEnvPanel && <EnvPanel />}
|
||||
<>
|
||||
{showWorkflowCopilotPanel && (
|
||||
<div
|
||||
data-workflow-left-panel
|
||||
tabIndex={-1}
|
||||
className={cn('absolute top-14 left-0 bottom-1 z-10 flex outline-hidden')}
|
||||
>
|
||||
<WorkflowCopilotPanel />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={rightPanelRef}
|
||||
data-workflow-right-panel
|
||||
tabIndex={-1}
|
||||
className={cn('absolute top-14 right-0 bottom-1 z-10 flex outline-hidden')}
|
||||
key={`${isRestoring}`}
|
||||
>
|
||||
{components?.left}
|
||||
{!!selectedNode && <NodePanel {...selectedNode} />}
|
||||
<div className="relative" ref={otherPanelRef}>
|
||||
{components?.right}
|
||||
{showWorkflowVersionHistoryPanel && versionHistoryPanelProps && (
|
||||
<VersionHistoryPanel {...versionHistoryPanelProps} />
|
||||
)}
|
||||
{showEnvPanel && <EnvPanel />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ChecklistItem } from '@/app/components/workflow/hooks/use-checklist'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export type ChecklistToFixProps = {
|
||||
fixableItems: ChecklistItem[]
|
||||
unfixableItems: ChecklistItem[]
|
||||
canStartChecklistFix: boolean
|
||||
isStarting: boolean
|
||||
onStartChecklistFix: () => void
|
||||
}
|
||||
|
||||
// Sibling entry point to `RunToFix`: offers to start a checklist-fix session
|
||||
// for the pre-publish checklist issues `useChecklist` currently reports on
|
||||
// this app's draft, alongside the existing failed-run entry.
|
||||
function ChecklistToFix({
|
||||
fixableItems,
|
||||
unfixableItems,
|
||||
canStartChecklistFix,
|
||||
isStarting,
|
||||
onStartChecklistFix,
|
||||
}: ChecklistToFixProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-3 px-4 py-3">
|
||||
<div className="system-sm-semibold-uppercase text-text-secondary">
|
||||
{t(($) => $['copilot.checklistFixTitle'], { ns: 'workflow' })}
|
||||
</div>
|
||||
<div className="rounded-lg border border-components-panel-border bg-components-panel-bg px-3 py-2">
|
||||
<div className="system-sm-regular text-text-secondary">
|
||||
{t(($) => $['copilot.checklistFixSummary'], {
|
||||
ns: 'workflow',
|
||||
count: fixableItems.length,
|
||||
})}
|
||||
</div>
|
||||
{unfixableItems.length > 0 && (
|
||||
<div className="mt-1 system-xs-regular text-text-quaternary">
|
||||
{t(($) => $['copilot.checklistUnfixableNote'], {
|
||||
ns: 'workflow',
|
||||
count: unfixableItems.length,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
disabled={!canStartChecklistFix || isStarting}
|
||||
loading={isStarting}
|
||||
onClick={onStartChecklistFix}
|
||||
>
|
||||
{isStarting
|
||||
? t(($) => $['copilot.starting'], { ns: 'workflow' })
|
||||
: t(($) => $['copilot.checklistFixAction'], {
|
||||
ns: 'workflow',
|
||||
count: fixableItems.length,
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ChecklistToFix)
|
||||
@@ -0,0 +1,209 @@
|
||||
import type {
|
||||
CopilotActionKind,
|
||||
ProgressEntry,
|
||||
SessionView,
|
||||
} from '@/app/components/workflow-copilot/types'
|
||||
import type { ChecklistItem } from '@/app/components/workflow/hooks/use-checklist'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
CHECKLIST_AWAIT_RECHECK_STATE,
|
||||
COPILOT_MANUAL_ACTION_KINDS,
|
||||
} from '@/app/components/workflow-copilot/types'
|
||||
|
||||
function useActionLabels(): Record<CopilotActionKind, string> {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return {
|
||||
approve_repair: t(($) => $['copilot.actionApproveRepair'], { ns: 'workflow' }),
|
||||
run_verify: t(($) => $['copilot.actionRunVerify'], { ns: 'workflow' }),
|
||||
provide_testdata: t(($) => $['copilot.actionProvideTestdata'], { ns: 'workflow' }),
|
||||
publish: t(($) => $['copilot.actionPublish'], { ns: 'workflow' }),
|
||||
undo: t(($) => $['copilot.actionUndo'], { ns: 'workflow' }),
|
||||
re_fix: t(($) => $['copilot.actionReFix'], { ns: 'workflow' }),
|
||||
recheck: t(($) => $['copilot.checklistRecheckAction'], { ns: 'workflow' }),
|
||||
}
|
||||
}
|
||||
|
||||
function SessionStatus({ view }: { view: SessionView }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-x-2 gap-y-1 rounded-lg border border-components-panel-border bg-components-panel-bg px-3 py-2 system-xs-regular text-text-secondary">
|
||||
<div className="text-text-tertiary">
|
||||
{t(($) => $['copilot.sessionIdLabel'], { ns: 'workflow' })}
|
||||
</div>
|
||||
<div className="truncate text-right">{view.SessionID}</div>
|
||||
<div className="text-text-tertiary">
|
||||
{t(($) => $['copilot.sessionStateLabel'], { ns: 'workflow' })}
|
||||
</div>
|
||||
<div className="truncate text-right">{view.State}</div>
|
||||
<div className="text-text-tertiary">
|
||||
{t(($) => $['copilot.sessionVersionLabel'], { ns: 'workflow' })}
|
||||
</div>
|
||||
<div className="truncate text-right">{view.Version}</div>
|
||||
<div className="text-text-tertiary">
|
||||
{t(($) => $['copilot.sessionRunStatusLabel'], { ns: 'workflow' })}
|
||||
</div>
|
||||
<div className="truncate text-right">{view.RunStatus}</div>
|
||||
<div className="text-text-tertiary">
|
||||
{t(($) => $['copilot.sessionCanvasReadOnlyLabel'], { ns: 'workflow' })}
|
||||
</div>
|
||||
<div className="truncate text-right">{String(view.CanvasReadOnly)}</div>
|
||||
<div className="text-text-tertiary">
|
||||
{t(($) => $['copilot.sessionInterruptedLabel'], { ns: 'workflow' })}
|
||||
</div>
|
||||
<div className="truncate text-right">{String(view.Interrupted)}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressLog({ entries }: { entries: ProgressEntry[] }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!entries.length) {
|
||||
return (
|
||||
<div className="system-xs-regular text-text-quaternary">
|
||||
{t(($) => $['copilot.progressEmpty'], { ns: 'workflow' })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex max-h-60 flex-col gap-y-1 overflow-y-auto rounded-lg border border-components-panel-border bg-components-panel-bg px-3 py-2">
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.id} className="system-xs-regular text-text-secondary">
|
||||
<span className="text-text-accent">{entry.event}</span>
|
||||
{' · '}
|
||||
<span className="wrap-break-word">{JSON.stringify(entry.data)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChecklistRecheck({
|
||||
fixableItems,
|
||||
unfixableItems,
|
||||
onRecheck,
|
||||
isRefreshingDraft,
|
||||
}: {
|
||||
fixableItems: ChecklistItem[]
|
||||
unfixableItems: ChecklistItem[]
|
||||
onRecheck: () => void
|
||||
isRefreshingDraft: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2 rounded-lg border border-components-panel-border bg-components-panel-bg px-3 py-2">
|
||||
<div className="system-sm-semibold-uppercase text-text-secondary">
|
||||
{t(($) => $['copilot.checklistRecheckTitle'], { ns: 'workflow' })}
|
||||
</div>
|
||||
<div className="system-xs-regular text-text-tertiary">
|
||||
{t(($) => $['copilot.checklistRecheckDescription'], { ns: 'workflow' })}
|
||||
</div>
|
||||
{unfixableItems.length > 0 && (
|
||||
<div className="system-xs-regular text-text-quaternary">
|
||||
{t(($) => $['copilot.checklistUnfixableNote'], {
|
||||
ns: 'workflow',
|
||||
count: unfixableItems.length,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
disabled={isRefreshingDraft}
|
||||
loading={isRefreshingDraft}
|
||||
onClick={onRecheck}
|
||||
>
|
||||
{t(($) => $['copilot.checklistRecheckAction'], { ns: 'workflow' })}
|
||||
{fixableItems.length > 0 ? ` (${fixableItems.length})` : ''}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type CopilotSessionViewProps = {
|
||||
view: SessionView
|
||||
progressLog: ProgressEntry[]
|
||||
onRunAction: (kind: CopilotActionKind) => void
|
||||
messageText: string
|
||||
onMessageTextChange: (value: string) => void
|
||||
onSendMessage: () => void
|
||||
fixableChecklistItems: ChecklistItem[]
|
||||
unfixableChecklistItems: ChecklistItem[]
|
||||
onRecheck: () => void
|
||||
isRefreshingDraftForRecheck: boolean
|
||||
}
|
||||
|
||||
function CopilotSessionView({
|
||||
view,
|
||||
progressLog,
|
||||
onRunAction,
|
||||
messageText,
|
||||
onMessageTextChange,
|
||||
onSendMessage,
|
||||
fixableChecklistItems,
|
||||
unfixableChecklistItems,
|
||||
onRecheck,
|
||||
isRefreshingDraftForRecheck,
|
||||
}: CopilotSessionViewProps) {
|
||||
const { t } = useTranslation()
|
||||
const actionLabels = useActionLabels()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-3 px-4 py-3">
|
||||
<SessionStatus view={view} />
|
||||
|
||||
{view.State === CHECKLIST_AWAIT_RECHECK_STATE && (
|
||||
<ChecklistRecheck
|
||||
fixableItems={fixableChecklistItems}
|
||||
unfixableItems={unfixableChecklistItems}
|
||||
onRecheck={onRecheck}
|
||||
isRefreshingDraft={isRefreshingDraftForRecheck}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<div className="system-sm-semibold-uppercase text-text-secondary">
|
||||
{t(($) => $['copilot.progressTitle'], { ns: 'workflow' })}
|
||||
</div>
|
||||
<ProgressLog entries={progressLog} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<div className="system-sm-semibold-uppercase text-text-secondary">
|
||||
{t(($) => $['copilot.actionsTitle'], { ns: 'workflow' })}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{COPILOT_MANUAL_ACTION_KINDS.map((kind) => (
|
||||
<Button key={kind} size="small" onClick={() => onRunAction(kind)}>
|
||||
{actionLabels[kind]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Input
|
||||
size="small"
|
||||
value={messageText}
|
||||
onChange={(e) => onMessageTextChange(e.target.value)}
|
||||
placeholder={t(($) => $['copilot.messagePlaceholder'], { ns: 'workflow' })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && messageText.trim()) onSendMessage()
|
||||
}}
|
||||
/>
|
||||
<Button size="small" disabled={!messageText.trim()} onClick={onSendMessage}>
|
||||
{t(($) => $['copilot.messageSend'], { ns: 'workflow' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(CopilotSessionView)
|
||||
@@ -0,0 +1,238 @@
|
||||
import type { ChecklistErrorPayload } from '@/app/components/workflow-copilot/types'
|
||||
import type { ChecklistItem } from '@/app/components/workflow/hooks/use-checklist'
|
||||
import type { CommonEdgeType } from '@/app/components/workflow/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEdges } from 'reactflow'
|
||||
import { CHECKLIST_AWAIT_RECHECK_STATE } from '@/app/components/workflow-copilot/types'
|
||||
import { useCopilotSession } from '@/app/components/workflow-copilot/use-copilot-session'
|
||||
import { useHooksStore } from '@/app/components/workflow/hooks-store/store'
|
||||
import { useChecklist } from '@/app/components/workflow/hooks/use-checklist'
|
||||
import { useIsChatMode } from '@/app/components/workflow/hooks/use-workflow'
|
||||
import { useWorkflowRefreshDraft } from '@/app/components/workflow/hooks/use-workflow-refresh-draft'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import { API_PREFIX } from '@/config'
|
||||
import { currentWorkspaceAtom } from '@/context/workspace-state'
|
||||
import { useWorkflowRunHistory } from '@/service/use-workflow'
|
||||
import ChecklistToFix from './checklist-to-fix'
|
||||
import CopilotSessionView from './copilot-session-view'
|
||||
import RunToFix from './run-to-fix'
|
||||
|
||||
// Maps a client-side `ChecklistItem` onto the backend's `ChecklistError` JSON
|
||||
// shape, used both to seed a checklist-fix session and to report the
|
||||
// still-failing items on a `recheck` action.
|
||||
const toChecklistErrorPayload = (item: ChecklistItem): ChecklistErrorPayload => ({
|
||||
node_id: item.id,
|
||||
node_type: String(item.type),
|
||||
title: item.title,
|
||||
messages: item.errorMessages,
|
||||
unconnected: !!item.unConnected,
|
||||
plugin_missing: !!item.isPluginMissing,
|
||||
})
|
||||
|
||||
function WorkflowCopilotPanel() {
|
||||
const { t } = useTranslation()
|
||||
const appId = useStore((s) => s.appId)
|
||||
const setShowWorkflowCopilotPanel = useStore((s) => s.setShowWorkflowCopilotPanel)
|
||||
const isChatMode = useIsChatMode()
|
||||
const currentWorkspace = useAtomValue(currentWorkspaceAtom)
|
||||
|
||||
const historyUrl = appId
|
||||
? isChatMode
|
||||
? `/apps/${appId}/advanced-chat/workflow-runs`
|
||||
: `/apps/${appId}/workflow-runs`
|
||||
: undefined
|
||||
const { data: runHistoryData, isLoading: isRunHistoryLoading } = useWorkflowRunHistory(
|
||||
historyUrl,
|
||||
!!historyUrl,
|
||||
)
|
||||
// TODO: the run history endpoint does not support server-side status filtering yet,
|
||||
// so we scan the most recent page for the latest failed run client-side.
|
||||
const latestFailedRun = useMemo(
|
||||
() => runHistoryData?.data.find((run) => run.status === WorkflowRunningStatus.Failed),
|
||||
[runHistoryData],
|
||||
)
|
||||
|
||||
const [manualRunId, setManualRunId] = useState('')
|
||||
const [isStarting, setIsStarting] = useState(false)
|
||||
const [isStartingChecklistFix, setIsStartingChecklistFix] = useState(false)
|
||||
const [messageText, setMessageText] = useState('')
|
||||
|
||||
// The pre-publish checklist is computed client-side from the current draft
|
||||
// canvas, exactly like the header checklist popover and the pre-run
|
||||
// validation gate — it stays reactive to canvas edits made both by the
|
||||
// user and (once landed) by the copilot's own applied repairs.
|
||||
const nodes = useNodes()
|
||||
const edges = useEdges<CommonEdgeType>()
|
||||
const flowType = useHooksStore((s) => s.configsMap?.flowType)
|
||||
const checklistItems = useChecklist(nodes, edges, { flowType })
|
||||
// Config errors (`errorMessages`) are addressable via the backend's
|
||||
// `set_node_config` repair; unconnected nodes and missing plugins are
|
||||
// structural and not auto-fixable in v1 (see `bizcopilot.ChecklistError`
|
||||
// doc comment), so they're surfaced but excluded from the fixable count
|
||||
// even when they also carry a message (e.g. "plugin not installed").
|
||||
const { fixableChecklistItems, unfixableChecklistItems } = useMemo(() => {
|
||||
const fixable: ChecklistItem[] = []
|
||||
const unfixable: ChecklistItem[] = []
|
||||
checklistItems.forEach((item) => {
|
||||
if (item.unConnected || item.isPluginMissing) unfixable.push(item)
|
||||
else if (item.errorMessages.length > 0) fixable.push(item)
|
||||
})
|
||||
return { fixableChecklistItems: fixable, unfixableChecklistItems: unfixable }
|
||||
}, [checklistItems])
|
||||
|
||||
const { view, lastError, progressLog, startFix, startChecklistFix, runAction, sendMessage } =
|
||||
useCopilotSession({
|
||||
baseUrl: API_PREFIX,
|
||||
workspaceId: currentWorkspace.id,
|
||||
})
|
||||
|
||||
const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft()
|
||||
const isSyncingWorkflowDraft = useStore((s) => s.isSyncingWorkflowDraft)
|
||||
|
||||
// The copilot applies checklist repairs to the *server* draft (via the
|
||||
// backend adapter), not the local canvas, so `nodes`/`useChecklist` above
|
||||
// stay on the stale pre-fix graph until something pulls the draft back in.
|
||||
// When the session hands control back for a re-check
|
||||
// (`checklist.await_recheck`), refresh the draft into the canvas once per
|
||||
// entry into that state, so the re-check reflects the copilot's applied
|
||||
// fix. This can't clobber unsaved user edits: the canvas is read-only for
|
||||
// the duration of the session, and the editor's own autosave only pushes
|
||||
// local changes to the draft, never pulls.
|
||||
useEffect(() => {
|
||||
if (view?.State === CHECKLIST_AWAIT_RECHECK_STATE) handleRefreshWorkflowDraft()
|
||||
}, [view?.State, handleRefreshWorkflowDraft])
|
||||
|
||||
const resolvedRunId = manualRunId.trim() || latestFailedRun?.id || ''
|
||||
const canStartFix = !!appId && !!currentWorkspace.id && !!resolvedRunId
|
||||
const canStartChecklistFix = !!appId && !!currentWorkspace.id && fixableChecklistItems.length > 0
|
||||
|
||||
const handleStartFix = useCallback(() => {
|
||||
if (!canStartFix) return
|
||||
setIsStarting(true)
|
||||
void startFix(appId, resolvedRunId).finally(() => setIsStarting(false))
|
||||
}, [appId, canStartFix, resolvedRunId, startFix])
|
||||
|
||||
const handleStartChecklistFix = useCallback(() => {
|
||||
if (!canStartChecklistFix || !appId) return
|
||||
setIsStartingChecklistFix(true)
|
||||
void startChecklistFix(appId, checklistItems.map(toChecklistErrorPayload)).finally(() =>
|
||||
setIsStartingChecklistFix(false),
|
||||
)
|
||||
}, [appId, canStartChecklistFix, checklistItems, startChecklistFix])
|
||||
|
||||
const handleRunAction = useCallback(
|
||||
(kind: Parameters<typeof runAction>[0]) => {
|
||||
void runAction(kind, kind === 'provide_testdata' ? { mode: 'mock' } : {})
|
||||
},
|
||||
[runAction],
|
||||
)
|
||||
|
||||
// Drives `checklist.await_recheck`: report the *current* client-side
|
||||
// checklist back to the session. `passed` only reflects fixable (config)
|
||||
// items — unfixable structural ones can't be resolved by this loop, so
|
||||
// they mustn't block it from handing off to the publish/keep/re-fix
|
||||
// decision gate. All still-outstanding items (fixable and unfixable) are
|
||||
// still sent in `remaining` so the session's record stays accurate.
|
||||
const handleRecheck = useCallback(() => {
|
||||
void runAction('recheck', {
|
||||
passed: fixableChecklistItems.length === 0,
|
||||
remaining: checklistItems.map(toChecklistErrorPayload),
|
||||
})
|
||||
}, [checklistItems, fixableChecklistItems, runAction])
|
||||
|
||||
const handleSendMessage = useCallback(() => {
|
||||
if (!messageText.trim()) return
|
||||
void sendMessage(messageText).then((ok) => {
|
||||
if (ok) setMessageText('')
|
||||
})
|
||||
}, [messageText, sendMessage])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex h-full w-[420px] flex-col rounded-r-2xl border border-components-panel-border bg-components-panel-bg-alt',
|
||||
)}
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-between p-4 pb-0 system-xl-semibold text-text-primary">
|
||||
{t(($) => $['copilot.panelTitle'], { ns: 'workflow' })}
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
|
||||
className="flex size-6 cursor-pointer items-center justify-center"
|
||||
onClick={() => setShowWorkflowCopilotPanel(false)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-line size-4 text-text-tertiary" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 px-4 py-1 system-sm-regular text-text-tertiary">
|
||||
{t(($) => $['copilot.panelDescription'], { ns: 'workflow' })}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 px-4 py-2 system-xs-regular text-text-tertiary">
|
||||
<div>
|
||||
{t(($) => $['copilot.appLabel'], { ns: 'workflow' })}: {appId || '—'}
|
||||
</div>
|
||||
<div>
|
||||
{t(($) => $['copilot.workspaceLabel'], { ns: 'workflow' })}:{' '}
|
||||
{currentWorkspace.name || currentWorkspace.id || '—'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grow overflow-y-auto rounded-b-2xl">
|
||||
{!view && (
|
||||
<>
|
||||
<RunToFix
|
||||
isRunHistoryLoading={isRunHistoryLoading}
|
||||
latestFailedRun={latestFailedRun}
|
||||
manualRunId={manualRunId}
|
||||
onManualRunIdChange={setManualRunId}
|
||||
canStartFix={canStartFix}
|
||||
isStarting={isStarting}
|
||||
onStartFix={handleStartFix}
|
||||
/>
|
||||
{checklistItems.length > 0 && (
|
||||
<>
|
||||
<div className="mx-4 border-t border-components-panel-border" />
|
||||
<ChecklistToFix
|
||||
fixableItems={fixableChecklistItems}
|
||||
unfixableItems={unfixableChecklistItems}
|
||||
canStartChecklistFix={canStartChecklistFix}
|
||||
isStarting={isStartingChecklistFix}
|
||||
onStartChecklistFix={handleStartChecklistFix}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{view && (
|
||||
<CopilotSessionView
|
||||
view={view}
|
||||
progressLog={progressLog}
|
||||
onRunAction={handleRunAction}
|
||||
messageText={messageText}
|
||||
onMessageTextChange={setMessageText}
|
||||
onSendMessage={handleSendMessage}
|
||||
fixableChecklistItems={fixableChecklistItems}
|
||||
unfixableChecklistItems={unfixableChecklistItems}
|
||||
onRecheck={handleRecheck}
|
||||
isRefreshingDraftForRecheck={isSyncingWorkflowDraft}
|
||||
/>
|
||||
)}
|
||||
{lastError && (
|
||||
<div className="px-4 pb-3 system-xs-regular text-text-destructive">
|
||||
{t(($) => $['copilot.errorPrefix'], { ns: 'workflow' })}: {lastError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(WorkflowCopilotPanel)
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { WorkflowRunHistory } from '@/types/workflow'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
|
||||
export type RunToFixProps = {
|
||||
isRunHistoryLoading: boolean
|
||||
latestFailedRun?: WorkflowRunHistory
|
||||
manualRunId: string
|
||||
onManualRunIdChange: (value: string) => void
|
||||
canStartFix: boolean
|
||||
isStarting: boolean
|
||||
onStartFix: () => void
|
||||
}
|
||||
|
||||
function RunToFix({
|
||||
isRunHistoryLoading,
|
||||
latestFailedRun,
|
||||
manualRunId,
|
||||
onManualRunIdChange,
|
||||
canStartFix,
|
||||
isStarting,
|
||||
onStartFix,
|
||||
}: RunToFixProps) {
|
||||
const { t } = useTranslation()
|
||||
const { formatTimeFromNow } = useFormatTimeFromNow()
|
||||
const usesAutoDetectedRun = !manualRunId.trim() && !!latestFailedRun
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-3 px-4 py-3">
|
||||
<div className="system-sm-semibold-uppercase text-text-secondary">
|
||||
{t(($) => $['copilot.runToFixTitle'], { ns: 'workflow' })}
|
||||
</div>
|
||||
<div className="rounded-lg border border-components-panel-border bg-components-panel-bg px-3 py-2">
|
||||
<div className="system-xs-medium text-text-tertiary">
|
||||
{t(($) => $['copilot.runToFixAutoLabel'], { ns: 'workflow' })}
|
||||
</div>
|
||||
{isRunHistoryLoading && (
|
||||
<div className="system-sm-regular mt-1 text-text-quaternary">
|
||||
{t(($) => $['copilot.runToFixAutoLoading'], { ns: 'workflow' })}
|
||||
</div>
|
||||
)}
|
||||
{!isRunHistoryLoading && latestFailedRun && (
|
||||
<div className="mt-1 flex items-center justify-between gap-x-2">
|
||||
<div className="min-w-0">
|
||||
<div className="system-sm-medium truncate text-text-primary">
|
||||
{latestFailedRun.id}
|
||||
</div>
|
||||
<div className="system-xs-regular text-text-tertiary">
|
||||
{formatTimeFromNow((latestFailedRun.finished_at || latestFailedRun.created_at) * 1000)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isRunHistoryLoading && !latestFailedRun && (
|
||||
<div className="system-sm-regular mt-1 text-text-quaternary">
|
||||
{t(($) => $['copilot.runToFixAutoEmpty'], { ns: 'workflow' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 system-xs-medium text-text-tertiary">
|
||||
{t(($) => $['copilot.runToFixManualLabel'], { ns: 'workflow' })}
|
||||
</div>
|
||||
<Input
|
||||
size="small"
|
||||
value={manualRunId}
|
||||
onChange={(e) => onManualRunIdChange(e.target.value)}
|
||||
placeholder={t(($) => $['copilot.runToFixManualPlaceholder'], { ns: 'workflow' })}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
disabled={!canStartFix || isStarting}
|
||||
loading={isStarting}
|
||||
onClick={onStartFix}
|
||||
>
|
||||
{isStarting
|
||||
? t(($) => $['copilot.starting'], { ns: 'workflow' })
|
||||
: usesAutoDetectedRun
|
||||
? t(($) => $['copilot.useThisRun'], { ns: 'workflow' })
|
||||
: t(($) => $['copilot.startFix'], { ns: 'workflow' })}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(RunToFix)
|
||||
@@ -31,6 +31,8 @@ export type PanelSliceShape = {
|
||||
setInitShowLastRunTab: (initShowLastRunTab: boolean) => void
|
||||
activeCommentId?: string | null
|
||||
setActiveCommentId: (commentId: string | null) => void
|
||||
showWorkflowCopilotPanel: boolean
|
||||
setShowWorkflowCopilotPanel: (showWorkflowCopilotPanel: boolean) => void
|
||||
}
|
||||
|
||||
export const createPanelSlice: StateCreator<PanelSliceShape> = (set) => ({
|
||||
@@ -62,4 +64,7 @@ export const createPanelSlice: StateCreator<PanelSliceShape> = (set) => ({
|
||||
setInitShowLastRunTab: (initShowLastRunTab) => set(() => ({ initShowLastRunTab })),
|
||||
activeCommentId: null,
|
||||
setActiveCommentId: (commentId: string | null) => set(() => ({ activeCommentId: commentId })),
|
||||
showWorkflowCopilotPanel: false,
|
||||
setShowWorkflowCopilotPanel: (showWorkflowCopilotPanel) =>
|
||||
set(() => ({ showWorkflowCopilotPanel })),
|
||||
})
|
||||
|
||||
@@ -126,6 +126,9 @@ type PluralBaseResources = {
|
||||
workflow: {
|
||||
'changeHistory.stepBackward': string
|
||||
'changeHistory.stepForward': string
|
||||
'copilot.checklistFixAction': string
|
||||
'copilot.checklistFixSummary': string
|
||||
'copilot.checklistUnfixableNote': string
|
||||
'nodes.iteration.error': string
|
||||
'nodes.iteration.iteration': string
|
||||
'nodes.loop.error': string
|
||||
|
||||
@@ -281,6 +281,50 @@
|
||||
"common.workflowProcessRunning": "Workflow Process running",
|
||||
"common.workflowProcessSucceeded": "Workflow Process succeeded",
|
||||
"common.workflowSaveStatus": "Workflow save status",
|
||||
"copilot.actionApproveRepair": "Approve repair",
|
||||
"copilot.actionProvideTestdata": "Provide mock test data",
|
||||
"copilot.actionPublish": "Publish",
|
||||
"copilot.actionReFix": "Re-fix",
|
||||
"copilot.actionRunVerify": "Run verify",
|
||||
"copilot.actionUndo": "Undo",
|
||||
"copilot.actionsTitle": "Actions",
|
||||
"copilot.appLabel": "App",
|
||||
"copilot.buttonTooltip": "Workflow Copilot",
|
||||
"copilot.checklistFixAction_one": "Fix {{count}} checklist issue",
|
||||
"copilot.checklistFixAction_other": "Fix {{count}} checklist issues",
|
||||
"copilot.checklistFixSummary_one": "{{count}} configuration issue can be auto-fixed",
|
||||
"copilot.checklistFixSummary_other": "{{count}} configuration issues can be auto-fixed",
|
||||
"copilot.checklistFixTitle": "Checklist issues",
|
||||
"copilot.checklistRecheckAction": "Recheck checklist",
|
||||
"copilot.checklistRecheckDescription": "The proposed fix was applied to the draft. Recheck the checklist to confirm it resolved the issue.",
|
||||
"copilot.checklistRecheckTitle": "Checklist recheck",
|
||||
"copilot.checklistUnfixableNote_one": "{{count}} issue can't be auto-fixed yet — resolve it manually before publishing",
|
||||
"copilot.checklistUnfixableNote_other": "{{count}} issues can't be auto-fixed yet — resolve them manually before publishing",
|
||||
"copilot.contextMissing": "Open this panel from an app's workflow editor to use Workflow Copilot.",
|
||||
"copilot.errorPrefix": "Error",
|
||||
"copilot.messagePlaceholder": "Send a message to the copilot",
|
||||
"copilot.messageSend": "Send",
|
||||
"copilot.panelDescription": "Diagnose and repair a failed workflow run with AI-assisted fixes.",
|
||||
"copilot.panelTitle": "Workflow Copilot",
|
||||
"copilot.progressEmpty": "Progress will appear here once a fix starts.",
|
||||
"copilot.progressTitle": "Progress",
|
||||
"copilot.runToFixAutoEmpty": "No failed run found yet. Enter a run ID manually.",
|
||||
"copilot.runToFixAutoLabel": "Latest failed run",
|
||||
"copilot.runToFixAutoLoading": "Looking for the latest failed run…",
|
||||
"copilot.runToFixManualLabel": "Or enter a run ID",
|
||||
"copilot.runToFixManualPlaceholder": "Workflow run ID",
|
||||
"copilot.runToFixTitle": "Run to fix",
|
||||
"copilot.sessionCanvasReadOnlyLabel": "Canvas read-only",
|
||||
"copilot.sessionEmpty": "No active fix session yet.",
|
||||
"copilot.sessionIdLabel": "Session",
|
||||
"copilot.sessionInterruptedLabel": "Interrupted",
|
||||
"copilot.sessionRunStatusLabel": "Run status",
|
||||
"copilot.sessionStateLabel": "State",
|
||||
"copilot.sessionVersionLabel": "Version",
|
||||
"copilot.startFix": "Start Fix",
|
||||
"copilot.starting": "Starting…",
|
||||
"copilot.useThisRun": "Fix this run",
|
||||
"copilot.workspaceLabel": "Workspace",
|
||||
"customWebhook": "Custom Webhook",
|
||||
"debug.copyLastRun": "Copy Last Run",
|
||||
"debug.copyLastRunError": "Failed to copy last run inputs",
|
||||
|
||||
Reference in New Issue
Block a user