chore: import clean Agent Desk snapshot (6/7)
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import type { AIWorkflowDefinition, AIWorkflowNodeSpec } from "@/lib/api/admin"
|
||||
|
||||
const MESSAGE_SOURCE = "agent-desk"
|
||||
const EMPTY_NODE_SPECS: AIWorkflowNodeSpec[] = []
|
||||
|
||||
function definitionsEqual(
|
||||
left: AIWorkflowDefinition,
|
||||
right: AIWorkflowDefinition
|
||||
) {
|
||||
return JSON.stringify(left) === JSON.stringify(right)
|
||||
}
|
||||
|
||||
type EditorMessage =
|
||||
| {
|
||||
source: typeof MESSAGE_SOURCE
|
||||
type: "workflow:ready"
|
||||
}
|
||||
| {
|
||||
source: typeof MESSAGE_SOURCE
|
||||
type: "workflow:change"
|
||||
document: AIWorkflowDefinition
|
||||
}
|
||||
|
||||
export function OfficialWorkflowEditor({
|
||||
documentKey,
|
||||
definition,
|
||||
nodeSpecs = EMPTY_NODE_SPECS,
|
||||
onDefinitionChange,
|
||||
readonly = false,
|
||||
}: {
|
||||
documentKey: string
|
||||
definition: AIWorkflowDefinition
|
||||
nodeSpecs?: AIWorkflowNodeSpec[]
|
||||
onDefinitionChange: (definition: AIWorkflowDefinition) => void
|
||||
readonly?: boolean
|
||||
}) {
|
||||
const t = useI18n()
|
||||
const frameRef = useRef<HTMLIFrameElement>(null)
|
||||
const definitionRef = useRef(definition)
|
||||
|
||||
useEffect(() => {
|
||||
definitionRef.current = definition
|
||||
}, [definition])
|
||||
|
||||
const loadDocument = useCallback(() => {
|
||||
frameRef.current?.contentWindow?.postMessage(
|
||||
{
|
||||
source: MESSAGE_SOURCE,
|
||||
type: "workflow:load",
|
||||
documentKey,
|
||||
document: definitionRef.current,
|
||||
nodeSpecs,
|
||||
readonly,
|
||||
},
|
||||
window.location.origin
|
||||
)
|
||||
}, [documentKey, nodeSpecs, readonly])
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent<EditorMessage>) => {
|
||||
if (
|
||||
event.origin !== window.location.origin ||
|
||||
event.source !== frameRef.current?.contentWindow ||
|
||||
event.data?.source !== MESSAGE_SOURCE
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (event.data.type === "workflow:ready") {
|
||||
loadDocument()
|
||||
} else if (event.data.type === "workflow:change") {
|
||||
if (definitionsEqual(event.data.document, definitionRef.current)) {
|
||||
return
|
||||
}
|
||||
definitionRef.current = event.data.document
|
||||
onDefinitionChange(event.data.document)
|
||||
}
|
||||
}
|
||||
window.addEventListener("message", handleMessage)
|
||||
return () => window.removeEventListener("message", handleMessage)
|
||||
}, [loadDocument, onDefinitionChange])
|
||||
|
||||
useEffect(() => {
|
||||
loadDocument()
|
||||
}, [loadDocument])
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={frameRef}
|
||||
src="/flowgram-editor/index.html"
|
||||
title={t("aiWorkflow.editorTitle")}
|
||||
className="h-full min-h-[560px] w-full border-0 bg-white"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { ProjectDialog } from "@/components/project-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import {
|
||||
fetchAIWorkflowUsage,
|
||||
type AIWorkflow,
|
||||
type AIWorkflowUsage,
|
||||
} from "@/lib/api/admin"
|
||||
|
||||
export function WorkflowUsageDialog({
|
||||
workflow,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
workflow: AIWorkflow | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
const t = useI18n()
|
||||
const [usage, setUsage] = useState<AIWorkflowUsage[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!workflow || !open) return
|
||||
setLoading(true)
|
||||
try {
|
||||
setUsage((await fetchAIWorkflowUsage(workflow.id)) ?? [])
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("aiWorkflow.loadFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [open, t, workflow])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
return (
|
||||
<ProjectDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("aiWorkflow.usageTitle")}
|
||||
description={workflow?.name}
|
||||
size="lg"
|
||||
bodyClassName="min-h-[360px]"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex min-h-64 items-center justify-center text-sm text-muted-foreground">
|
||||
{t("aiWorkflow.loadingUsage")}
|
||||
</div>
|
||||
) : usage.length ? (
|
||||
<div className="space-y-3">
|
||||
{usage.map((item) => (
|
||||
<div
|
||||
key={`${item.aiAgentId}-${item.workflowVersionId}`}
|
||||
className="flex items-center justify-between rounded-md border p-4"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">{item.aiAgentName}</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
{t("aiWorkflow.fixedBoundVersion", { version: String(item.workflowVersion) })}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={item.enabled ? "secondary" : "outline"}>
|
||||
{item.enabled ? t("aiWorkflow.enabledStatus") : t("aiWorkflow.disabledStatus")}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-64 items-center justify-center text-sm text-muted-foreground">
|
||||
{t("aiWorkflow.notUsedByAnyAgent")}
|
||||
</div>
|
||||
)}
|
||||
</ProjectDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { ProjectDialog } from "@/components/project-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import {
|
||||
fetchAIWorkflowVersions,
|
||||
restoreAIWorkflowVersion,
|
||||
type AIWorkflow,
|
||||
type AIWorkflowVersion,
|
||||
} from "@/lib/api/admin"
|
||||
import { formatDateTime } from "@/lib/utils"
|
||||
|
||||
export function WorkflowVersionsDialog({
|
||||
workflow,
|
||||
open,
|
||||
onOpenChange,
|
||||
onRestored,
|
||||
}: {
|
||||
workflow: AIWorkflow | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onRestored: () => void
|
||||
}) {
|
||||
const t = useI18n()
|
||||
const [versions, setVersions] = useState<AIWorkflowVersion[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [restoringId, setRestoringId] = useState<number | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!workflow || !open) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const page = await fetchAIWorkflowVersions({
|
||||
workflowId: workflow.id,
|
||||
limit: 50,
|
||||
})
|
||||
setVersions(page.results ?? [])
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("aiWorkflow.loadFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [open, t, workflow])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
async function restore(version: AIWorkflowVersion) {
|
||||
if (!workflow || restoringId !== null) return
|
||||
setRestoringId(version.id)
|
||||
try {
|
||||
await restoreAIWorkflowVersion(workflow.id, version.id)
|
||||
onRestored()
|
||||
toast.success(t("aiWorkflow.restoredDraftSuccess", { version: String(version.version) }))
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("aiWorkflow.restoreFailed"))
|
||||
} finally {
|
||||
setRestoringId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ProjectDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("aiWorkflow.versionsTitle")}
|
||||
description={workflow?.name}
|
||||
size="lg"
|
||||
bodyClassName="min-h-[360px]"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex min-h-64 items-center justify-center text-sm text-muted-foreground">
|
||||
{t("aiWorkflow.loadingVersions")}
|
||||
</div>
|
||||
) : versions.length ? (
|
||||
<div className="space-y-3">
|
||||
{versions.map((version) => (
|
||||
<div
|
||||
key={version.id}
|
||||
className="flex items-center gap-4 rounded-md border p-4"
|
||||
>
|
||||
<Badge>v{version.version}</Badge>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium">
|
||||
{formatDateTime(version.publishedAt || version.createdAt)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{t("aiWorkflow.publisher", { name: version.publishedByName || "-" })}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={restoringId !== null}
|
||||
onClick={() => void restore(version)}
|
||||
>
|
||||
{restoringId === version.id ? t("aiWorkflow.restoring") : t("aiWorkflow.restoreToDraft")}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-64 items-center justify-center text-sm text-muted-foreground">
|
||||
{t("aiWorkflow.noVersionsYet")}
|
||||
</div>
|
||||
)}
|
||||
</ProjectDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
|
||||
import { PencilIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { OptionCombobox } from "@/components/option-combobox"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import {
|
||||
createAIWorkflow,
|
||||
fetchAIWorkflow,
|
||||
fetchAIWorkflowDefaultDefinition,
|
||||
fetchAIWorkflowNodeSpecs,
|
||||
fetchAIWorkflowTemplates,
|
||||
publishAIWorkflow,
|
||||
updateAIWorkflow,
|
||||
type AIWorkflow,
|
||||
type AIWorkflowDefinition,
|
||||
type AIWorkflowNodeSpec,
|
||||
type AIWorkflowTemplate,
|
||||
} from "@/lib/api/admin"
|
||||
|
||||
import { OfficialWorkflowEditor } from "./official-workflow-editor"
|
||||
|
||||
const emptyDefinition: AIWorkflowDefinition = {
|
||||
schemaVersion: 2,
|
||||
nodes: [
|
||||
{
|
||||
id: "start_1",
|
||||
type: "start",
|
||||
meta: { position: { x: 0, y: 80 } },
|
||||
data: { title: "Start", config: {}, inputsValues: {} },
|
||||
},
|
||||
{
|
||||
id: "end_1",
|
||||
type: "end",
|
||||
meta: { position: { x: 260, y: 80 } },
|
||||
data: { title: "End", config: {}, inputsValues: {} },
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
sourceNodeID: "start_1",
|
||||
targetNodeID: "end_1",
|
||||
sourcePortID: "edge_start_end",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
type WorkflowWorkbenchProps = {
|
||||
workflowID?: number
|
||||
onSaved?: () => void
|
||||
}
|
||||
|
||||
export function WorkflowWorkbench({
|
||||
workflowID,
|
||||
onSaved,
|
||||
}: WorkflowWorkbenchProps) {
|
||||
const t = useI18n()
|
||||
const [active, setActive] = useState<AIWorkflow | null>(null)
|
||||
const [name, setName] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [definition, setDefinition] =
|
||||
useState<AIWorkflowDefinition>(emptyDefinition)
|
||||
const [nodeSpecs, setNodeSpecs] = useState<AIWorkflowNodeSpec[]>([])
|
||||
const [templates, setTemplates] = useState<AIWorkflowTemplate[]>([])
|
||||
const [selectedTemplate, setSelectedTemplate] = useState("")
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [editingField, setEditingField] = useState<
|
||||
"name" | "description" | null
|
||||
>(null)
|
||||
const editSnapshotRef = useRef({ value: "", dirty: false })
|
||||
const cancellingEditRef = useRef(false)
|
||||
const savingRef = useRef(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoaded(false)
|
||||
const [specs, availableTemplates] = await Promise.all([
|
||||
fetchAIWorkflowNodeSpecs(),
|
||||
fetchAIWorkflowTemplates(),
|
||||
])
|
||||
setNodeSpecs(specs)
|
||||
setTemplates(availableTemplates)
|
||||
if (!workflowID) {
|
||||
setDefinition(await fetchAIWorkflowDefaultDefinition())
|
||||
setLoaded(true)
|
||||
return
|
||||
}
|
||||
|
||||
const item = await fetchAIWorkflow(workflowID)
|
||||
setActive(item)
|
||||
setName(item.name)
|
||||
setDescription(item.description)
|
||||
setDefinition(item.draftDefinition)
|
||||
setDirty(false)
|
||||
setLoaded(true)
|
||||
}, [workflowID])
|
||||
|
||||
const handleDefinitionChange = useCallback(
|
||||
(next: AIWorkflowDefinition) => {
|
||||
setDefinition(next)
|
||||
setDirty(true)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
void load().catch((error) =>
|
||||
toast.error(error instanceof Error ? error.message : t("aiWorkflow.loadFailed"))
|
||||
)
|
||||
}, [load, t])
|
||||
|
||||
function beginEditing(field: "name" | "description") {
|
||||
cancellingEditRef.current = false
|
||||
editSnapshotRef.current = {
|
||||
value: field === "name" ? name : description,
|
||||
dirty,
|
||||
}
|
||||
setEditingField(field)
|
||||
}
|
||||
|
||||
async function finishEditing(field: "name" | "description") {
|
||||
if (cancellingEditRef.current) {
|
||||
cancellingEditRef.current = false
|
||||
return
|
||||
}
|
||||
setEditingField(null)
|
||||
const currentValue = field === "name" ? name.trim() : description.trim()
|
||||
if (currentValue === editSnapshotRef.current.value) {
|
||||
setDirty(editSnapshotRef.current.dirty)
|
||||
return
|
||||
}
|
||||
if (field === "name" && !currentValue) {
|
||||
setName(editSnapshotRef.current.value)
|
||||
setDirty(editSnapshotRef.current.dirty)
|
||||
toast.error(t("aiWorkflow.nameRequired"))
|
||||
return
|
||||
}
|
||||
|
||||
const nextName = field === "name" ? currentValue : name.trim()
|
||||
const nextDescription =
|
||||
field === "description" ? currentValue : description.trim()
|
||||
if (savingRef.current) return
|
||||
savingRef.current = true
|
||||
setSaving(true)
|
||||
try {
|
||||
if (active) {
|
||||
await updateAIWorkflow({
|
||||
id: active.id,
|
||||
name: nextName,
|
||||
description: nextDescription,
|
||||
definition: active.draftDefinition,
|
||||
})
|
||||
setActive((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
name: nextName,
|
||||
description: nextDescription,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
: current
|
||||
)
|
||||
} else {
|
||||
const created = await createAIWorkflow({
|
||||
name: nextName,
|
||||
description: nextDescription,
|
||||
definition,
|
||||
})
|
||||
setActive(created)
|
||||
}
|
||||
setName(nextName)
|
||||
setDescription(nextDescription)
|
||||
setDirty(active ? editSnapshotRef.current.dirty : false)
|
||||
onSaved?.()
|
||||
toast.success(field === "name" ? t("aiWorkflow.nameSaved") : t("aiWorkflow.descriptionSaved"))
|
||||
} catch (error) {
|
||||
setDirty(true)
|
||||
toast.error(error instanceof Error ? error.message : t("aiWorkflow.saveFailed"))
|
||||
} finally {
|
||||
savingRef.current = false
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEditing(field: "name" | "description") {
|
||||
cancellingEditRef.current = true
|
||||
if (field === "name") {
|
||||
setName(editSnapshotRef.current.value)
|
||||
} else {
|
||||
setDescription(editSnapshotRef.current.value)
|
||||
}
|
||||
setDirty(editSnapshotRef.current.dirty)
|
||||
setEditingField(null)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (savingRef.current) return
|
||||
if (!name.trim()) {
|
||||
toast.error(t("aiWorkflow.nameRequired"))
|
||||
return
|
||||
}
|
||||
const savedName = name.trim()
|
||||
const savedDescription = description.trim()
|
||||
savingRef.current = true
|
||||
setSaving(true)
|
||||
try {
|
||||
if (active) {
|
||||
await updateAIWorkflow({
|
||||
id: active.id,
|
||||
name: savedName,
|
||||
description: savedDescription,
|
||||
definition,
|
||||
})
|
||||
setActive((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
name: savedName,
|
||||
description: savedDescription,
|
||||
draftDefinition: definition,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
: current
|
||||
)
|
||||
setName(savedName)
|
||||
setDescription(savedDescription)
|
||||
setDirty(false)
|
||||
} else {
|
||||
const created = await createAIWorkflow({
|
||||
name: savedName,
|
||||
description: savedDescription,
|
||||
definition,
|
||||
})
|
||||
setActive(created)
|
||||
setName(created.name)
|
||||
setDescription(created.description)
|
||||
setDirty(false)
|
||||
}
|
||||
onSaved?.()
|
||||
toast.success(t("aiWorkflow.savedSuccess"))
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("aiWorkflow.saveFailed"))
|
||||
} finally {
|
||||
savingRef.current = false
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
if (savingRef.current) return
|
||||
if (!active) {
|
||||
toast.error(t("aiWorkflow.saveFirst"))
|
||||
return
|
||||
}
|
||||
savingRef.current = true
|
||||
setSaving(true)
|
||||
try {
|
||||
const version = await publishAIWorkflow(active.id, definition)
|
||||
setActive((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
draftDefinition: definition,
|
||||
publishedVersionId: version.id,
|
||||
updatedAt: version.updatedAt,
|
||||
}
|
||||
: current
|
||||
)
|
||||
setDirty(false)
|
||||
onSaved?.()
|
||||
toast.success(t("aiWorkflow.publishedVersionSuccess", { version: String(version.version) }))
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("aiWorkflow.publishFailed"))
|
||||
} finally {
|
||||
savingRef.current = false
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-background">
|
||||
<header className="shrink-0 border-b px-4 py-1.5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{editingField === "name" ? (
|
||||
<Input
|
||||
autoFocus
|
||||
aria-label={t("aiWorkflow.nameLabel")}
|
||||
value={name}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
onChange={(event) => {
|
||||
setName(event.target.value)
|
||||
setDirty(true)
|
||||
}}
|
||||
onBlur={() => void finishEditing("name")}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
event.currentTarget.blur()
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
cancelEditing("name")
|
||||
}
|
||||
}}
|
||||
className="h-7 min-w-48 max-w-xl flex-1 px-2 text-lg font-semibold"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Workflow Name"
|
||||
onClick={() => beginEditing("name")}
|
||||
className="group flex h-7 min-w-0 max-w-xl items-center gap-1.5 rounded-md px-1 text-left text-lg font-semibold hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<span className="truncate">{name || t("aiWorkflow.createTitle")}</span>
|
||||
<PencilIcon className="size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100" />
|
||||
</button>
|
||||
)}
|
||||
<Badge
|
||||
variant={active?.publishedVersionId ? "secondary" : "outline"}
|
||||
>
|
||||
{active?.publishedVersionId ? t("aiWorkflow.published") : t("aiWorkflow.draft")}
|
||||
</Badge>
|
||||
{dirty ? (
|
||||
<span className="text-xs text-amber-600">{t("supportHelpWorkbench.unsaved")}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{editingField === "description" ? (
|
||||
<Input
|
||||
autoFocus
|
||||
aria-label="Workflow Description"
|
||||
value={description}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
onChange={(event) => {
|
||||
setDescription(event.target.value)
|
||||
setDirty(true)
|
||||
}}
|
||||
onBlur={() => void finishEditing("description")}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
event.currentTarget.blur()
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
cancelEditing("description")
|
||||
}
|
||||
}}
|
||||
placeholder="Workflow description..."
|
||||
className="mt-0.5 h-6 max-w-2xl px-1 text-sm font-normal"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Workflow Description"
|
||||
onClick={() => beginEditing("description")}
|
||||
className="group mt-0.5 flex h-6 max-w-2xl items-center gap-1.5 rounded-md px-1 text-left text-sm text-muted-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<span className="truncate">
|
||||
{description || t("aiWorkflow.noDescription")}
|
||||
</span>
|
||||
<PencilIcon className="size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
{!active ? (
|
||||
<OptionCombobox
|
||||
value={selectedTemplate}
|
||||
placeholder="Template"
|
||||
searchPlaceholder="Search template..."
|
||||
emptyText="No templates"
|
||||
triggerClassName="w-48"
|
||||
options={templates.map((template) => ({
|
||||
value: template.code,
|
||||
label: template.name,
|
||||
description: template.description,
|
||||
}))}
|
||||
onChange={(code) => {
|
||||
const template = templates.find((item) => item.code === code)
|
||||
if (!template) return
|
||||
setSelectedTemplate(code)
|
||||
setDefinition(structuredClone(template.definition))
|
||||
setName(template.name)
|
||||
setDescription(template.description)
|
||||
setDirty(true)
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={saving}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={saving || !active}
|
||||
onClick={() => void publish()}
|
||||
>
|
||||
{t("aiAgent.publishAgent")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="w-8 shrink-0" aria-hidden="true" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
{loaded ? (
|
||||
<OfficialWorkflowEditor
|
||||
documentKey={
|
||||
workflowID
|
||||
? `workflow-${workflowID}`
|
||||
: `new-${selectedTemplate || "blank"}`
|
||||
}
|
||||
definition={definition}
|
||||
nodeSpecs={nodeSpecs}
|
||||
onDefinitionChange={handleDefinitionChange}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
{t("aiWorkflow.loadingNodeCapabilities")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
168
web/app/(dashboard)/dashboard/ai-workflows/page.tsx
Normal file
168
web/app/(dashboard)/dashboard/ai-workflows/page.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
import {
|
||||
GitBranchIcon,
|
||||
HistoryIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import { DashboardCrudPage } from "@/components/dashboard/crud"
|
||||
import { ProjectDialog } from "@/components/project-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
deleteAIWorkflow,
|
||||
fetchAIWorkflows,
|
||||
type AIWorkflow,
|
||||
type CreateAIWorkflowPayload,
|
||||
} from "@/lib/api/admin"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { formatDateTime } from "@/lib/utils"
|
||||
|
||||
import { WorkflowUsageDialog } from "./_components/workflow-usage-dialog"
|
||||
import { WorkflowVersionsDialog } from "./_components/workflow-versions-dialog"
|
||||
import { WorkflowWorkbench } from "./_components/workflow-workbench"
|
||||
|
||||
export default function DashboardAIWorkflowsPage() {
|
||||
const t = useI18n()
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
const [versionsWorkflow, setVersionsWorkflow] =
|
||||
useState<AIWorkflow | null>(null)
|
||||
const [usageWorkflow, setUsageWorkflow] = useState<AIWorkflow | null>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardCrudPage<AIWorkflow, CreateAIWorkflowPayload>
|
||||
filters={[
|
||||
{
|
||||
name: "name",
|
||||
label: t("aiWorkflow.filterName"),
|
||||
placeholder: t("aiWorkflow.searchName"),
|
||||
defaultValue: "",
|
||||
trim: true,
|
||||
className: "w-full sm:w-72",
|
||||
},
|
||||
]}
|
||||
columns={[
|
||||
{
|
||||
key: "workflow",
|
||||
label: t("aiWorkflow.columnWorkflow"),
|
||||
render: (item) => (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex size-8 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||
<GitBranchIcon className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">{item.name}</div>
|
||||
<div className="mt-1 line-clamp-2 text-sm text-muted-foreground">
|
||||
{item.description || t("aiWorkflow.noDescription")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: t("aiWorkflow.columnStatus"),
|
||||
render: (item) => (
|
||||
<Badge
|
||||
variant={item.publishedVersionId ? "secondary" : "outline"}
|
||||
>
|
||||
{item.publishedVersionId ? t("aiWorkflow.published") : t("aiWorkflow.draft")}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "version",
|
||||
label: t("aiWorkflow.currentVersion"),
|
||||
render: (item) =>
|
||||
item.publishedVersionId
|
||||
? t("aiWorkflow.publishedVersion", { version: item.publishedVersionId })
|
||||
: "-",
|
||||
},
|
||||
{
|
||||
key: "updatedAt",
|
||||
label: t("common.actions"),
|
||||
render: (item) => formatDateTime(item.updatedAt),
|
||||
},
|
||||
]}
|
||||
fetchList={(query) =>
|
||||
fetchAIWorkflows({
|
||||
name: typeof query.name === "string" ? query.name : undefined,
|
||||
page: Number(query.page),
|
||||
limit: Number(query.limit),
|
||||
})
|
||||
}
|
||||
getItemId={(item) => item.id}
|
||||
createItem={async () => undefined}
|
||||
updateItem={async () => undefined}
|
||||
deleteItem={(item) => deleteAIWorkflow(item.id)}
|
||||
rowActions={[
|
||||
{
|
||||
key: "versions",
|
||||
label: t("aiWorkflow.versionHistory"),
|
||||
icon: <HistoryIcon />,
|
||||
run: ({ item }) => setVersionsWorkflow(item),
|
||||
},
|
||||
{
|
||||
key: "usage",
|
||||
label: t("aiWorkflow.usage"),
|
||||
icon: <UsersIcon />,
|
||||
run: ({ item }) => setUsageWorkflow(item),
|
||||
},
|
||||
]}
|
||||
reloadKey={reloadKey}
|
||||
renderEditDialog={({ open, item, itemId, onOpenChange }) => (
|
||||
<ProjectDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={item ? t("aiWorkflow.editTitle") : t("aiWorkflow.createTitle")}
|
||||
defaultFullscreen
|
||||
bodyScrollable={false}
|
||||
headerClassName="sr-only"
|
||||
>
|
||||
<WorkflowWorkbench
|
||||
workflowID={itemId ?? undefined}
|
||||
onSaved={() => setReloadKey((value) => value + 1)}
|
||||
/>
|
||||
</ProjectDialog>
|
||||
)}
|
||||
labels={{
|
||||
refresh: t("aiWorkflow.refresh"),
|
||||
create: t("aiWorkflow.create"),
|
||||
query: t("aiWorkflow.query"),
|
||||
loading: t("aiWorkflow.loading"),
|
||||
empty: t("aiWorkflow.empty"),
|
||||
actions: t("aiWorkflow.actions"),
|
||||
edit: t("aiWorkflow.edit"),
|
||||
delete: t("aiWorkflow.delete"),
|
||||
processing: t("aiWorkflow.processing"),
|
||||
moreActions: (item) => t("aiWorkflow.moreActions", { name: item.name }),
|
||||
loadFailed: t("aiWorkflow.loadFailed"),
|
||||
saveFailed: t("aiWorkflow.saveFailed"),
|
||||
deleteFailed: t("aiWorkflow.deleteFailed"),
|
||||
created: (payload) => t("aiWorkflow.created", { name: payload.name }),
|
||||
updated: (item) => t("aiWorkflow.updated", { name: item.name }),
|
||||
deleted: (item) => t("aiWorkflow.deleted", { name: item.name }),
|
||||
}}
|
||||
/>
|
||||
|
||||
<WorkflowVersionsDialog
|
||||
workflow={versionsWorkflow}
|
||||
open={versionsWorkflow !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setVersionsWorkflow(null)
|
||||
}}
|
||||
onRestored={() => setReloadKey((value) => value + 1)}
|
||||
/>
|
||||
<WorkflowUsageDialog
|
||||
workflow={usageWorkflow}
|
||||
open={usageWorkflow !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setUsageWorkflow(null)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user