mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-24 09:25:21 +08:00
feat: add verified manual pipeline migration for plugin runners
This commit is contained in:
@@ -32,7 +32,13 @@ import AgentFormComponent, {
|
||||
RunnerStatus,
|
||||
} from './components/AgentFormComponent';
|
||||
|
||||
export default function AgentDetailContent({ id }: { id: string }) {
|
||||
export default function AgentDetailContent({
|
||||
id,
|
||||
pipelineRevision,
|
||||
}: {
|
||||
id: string;
|
||||
pipelineRevision?: string;
|
||||
}) {
|
||||
const isCreateMode = id === 'new';
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
@@ -130,7 +136,13 @@ export default function AgentDetailContent({ id }: { id: string }) {
|
||||
if (loading || !agent) return <EntityLoadState />;
|
||||
|
||||
if (agent.kind === 'pipeline') {
|
||||
return <PipelineDetailContent id={id} routeBase="/home/agents" />;
|
||||
return (
|
||||
<PipelineDetailContent
|
||||
key={pipelineRevision}
|
||||
id={id}
|
||||
routeBase="/home/agents"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function saveBasicInfo(values: EntityBasicInfoValues) {
|
||||
|
||||
@@ -1,19 +1,47 @@
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import PipelineMigration, {
|
||||
migrationWorkspaceKey,
|
||||
} from '@/app/home/pipelines/PipelineMigration';
|
||||
import AgentDetailContent from './AgentDetailContent';
|
||||
|
||||
export default function AgentsPage() {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const detailId = searchParams.get('id');
|
||||
|
||||
if (detailId) {
|
||||
return <AgentDetailContent key={detailId} id={detailId} />;
|
||||
}
|
||||
const workspace = useCurrentWorkspace();
|
||||
const { refreshPipelines } = useSidebarData();
|
||||
const [revision, setRevision] = useState(0);
|
||||
const scopeKey = migrationWorkspaceKey(workspace);
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<p>{t('agents.selectFromSidebar')}</p>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{workspace && workspace.permissions.includes('resource.view') && (
|
||||
<PipelineMigration
|
||||
key={scopeKey}
|
||||
workspace={workspace}
|
||||
onComplete={() => {
|
||||
void refreshPipelines();
|
||||
setRevision((current) => current + 1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-h-0">
|
||||
{detailId ? (
|
||||
<AgentDetailContent
|
||||
key={detailId}
|
||||
id={detailId}
|
||||
pipelineRevision={`${scopeKey}:${revision}`}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<p>{t('agents.selectFromSidebar')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import { useForm } from 'react-hook-form';
|
||||
import type { ControllerRenderProps } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { isJsonValue, isPromptValue } from './StructuredFieldValue';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
Form,
|
||||
@@ -133,13 +134,10 @@ function getValueSchema(spec: DynamicFormValueSpec) {
|
||||
fallbacks: z.array(z.string()),
|
||||
reasoning: z.record(z.string()),
|
||||
});
|
||||
case DynamicFormItemType.JSON:
|
||||
return z.custom(isJsonValue);
|
||||
case DynamicFormItemType.PROMPT_EDITOR:
|
||||
return z.array(
|
||||
z.object({
|
||||
content: z.string(),
|
||||
role: z.string(),
|
||||
}),
|
||||
);
|
||||
return z.custom(isPromptValue);
|
||||
default:
|
||||
return z.string();
|
||||
}
|
||||
@@ -656,6 +654,7 @@ export default function DynamicFormComponent({
|
||||
};
|
||||
const fieldKey = config.id || config.name || `field-${index}`;
|
||||
|
||||
let isHiddenByCondition = false;
|
||||
if (config.show_if) {
|
||||
const dependValue = resolveShowIfValue(
|
||||
config.show_if.field,
|
||||
@@ -668,23 +667,32 @@ export default function DynamicFormComponent({
|
||||
config.show_if.operator === 'eq' &&
|
||||
dependValue !== config.show_if.value
|
||||
) {
|
||||
return null;
|
||||
isHiddenByCondition = true;
|
||||
}
|
||||
if (
|
||||
config.show_if.operator === 'neq' &&
|
||||
dependValue === config.show_if.value
|
||||
) {
|
||||
return null;
|
||||
isHiddenByCondition = true;
|
||||
}
|
||||
if (
|
||||
config.show_if.operator === 'in' &&
|
||||
Array.isArray(config.show_if.value) &&
|
||||
!config.show_if.value.includes(dependValue)
|
||||
) {
|
||||
return null;
|
||||
isHiddenByCondition = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Keep structured drafts mounted across conditional hiding so invalid
|
||||
// JSON cannot disappear from validation when Advanced Settings closes.
|
||||
if (
|
||||
isHiddenByCondition &&
|
||||
normalizedConfig.type !== DynamicFormItemType.JSON &&
|
||||
normalizedConfig.type !== DynamicFormItemType.PROMPT_EDITOR
|
||||
)
|
||||
return null;
|
||||
|
||||
// Keep locked fields visible and resolve only the applicable reason.
|
||||
const { isDisabledByCondition, disabledTooltip: tooltip } =
|
||||
resolveDisabledState(
|
||||
@@ -957,7 +965,10 @@ export default function DynamicFormComponent({
|
||||
setFormValue,
|
||||
});
|
||||
return (
|
||||
<FormItem className="min-w-0">
|
||||
<FormItem
|
||||
className={cn('min-w-0', isHiddenByCondition && 'hidden')}
|
||||
hidden={isHiddenByCondition}
|
||||
>
|
||||
<FormLabel className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="min-w-0 break-words">
|
||||
{extractI18nObject(config.label)}{' '}
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
IDynamicFormItemSchema,
|
||||
IFileConfig,
|
||||
} from '@/app/infra/entities/form/dynamic';
|
||||
import StructuredFieldEditor from './StructuredFieldEditor';
|
||||
import { isSimplePrompt } from './StructuredFieldValue';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
@@ -490,14 +492,7 @@ export default function DynamicFormItemComponent({
|
||||
);
|
||||
|
||||
case DynamicFormItemType.JSON:
|
||||
return (
|
||||
<Textarea
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
className="min-h-[200px] font-mono text-sm"
|
||||
placeholder='{"key": "value"}'
|
||||
/>
|
||||
);
|
||||
return <StructuredFieldEditor field={field} />;
|
||||
|
||||
case DynamicFormItemType.BOOLEAN:
|
||||
return (
|
||||
@@ -1261,11 +1256,11 @@ export default function DynamicFormItemComponent({
|
||||
|
||||
const updateModelReasoning = (
|
||||
modelUuid: string,
|
||||
level: ReasoningLevel,
|
||||
level: ReasoningLevel | undefined,
|
||||
) => {
|
||||
if (!modelUuid) return;
|
||||
const updated = { ...modelValue.reasoning };
|
||||
if (level === 'provider_default') {
|
||||
if (level === undefined) {
|
||||
delete updated[modelUuid];
|
||||
} else {
|
||||
updated[modelUuid] = level;
|
||||
@@ -1311,6 +1306,8 @@ export default function DynamicFormItemComponent({
|
||||
<ReasoningLevelPicker
|
||||
value={currentLevel}
|
||||
levels={levels}
|
||||
inherited={!Object.hasOwn(modelValue.reasoning, modelUuid)}
|
||||
onInherit={() => updateModelReasoning(modelUuid, undefined)}
|
||||
onChange={(level) => updateModelReasoning(modelUuid, level)}
|
||||
/>
|
||||
);
|
||||
@@ -1872,6 +1869,9 @@ export default function DynamicFormItemComponent({
|
||||
);
|
||||
|
||||
case DynamicFormItemType.PROMPT_EDITOR: {
|
||||
if (!isSimplePrompt(field.value)) {
|
||||
return <StructuredFieldEditor field={field} prompt />;
|
||||
}
|
||||
// Guard: field.value may be undefined when the form resets or
|
||||
// initialValues haven't propagated yet. Fall back to a default
|
||||
// single system-prompt entry to prevent the .map() crash.
|
||||
|
||||
@@ -56,7 +56,6 @@ export function parseDynamicFormItemType(value: string): DynamicFormItemType {
|
||||
[DynamicFormItemType.SELECT_KNOWLEDGE_BASES]:
|
||||
DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR,
|
||||
[DynamicFormItemType.NUMBER]: DynamicFormItemType.FLOAT,
|
||||
[DynamicFormItemType.JSON]: DynamicFormItemType.TEXT,
|
||||
};
|
||||
if (value in typeMap) {
|
||||
return typeMap[value];
|
||||
|
||||
@@ -80,6 +80,7 @@ export function normalizeDynamicFormFieldValue(
|
||||
}
|
||||
|
||||
const reasoningLevels = new Set([
|
||||
'provider_default',
|
||||
'disabled',
|
||||
'enabled',
|
||||
'minimal',
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { ControllerRenderProps } from 'react-hook-form';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { parseStructuredDraft } from './StructuredFieldValue';
|
||||
|
||||
/** Draft text stays local: only explicitly parsed edits enter persisted state. */
|
||||
export default function StructuredFieldEditor({
|
||||
field,
|
||||
prompt = false,
|
||||
}: {
|
||||
field: ControllerRenderProps<any, any>;
|
||||
prompt?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const form = useFormContext();
|
||||
const [draft, setDraft] = useState(
|
||||
() => JSON.stringify(field.value, null, 2) ?? '',
|
||||
);
|
||||
const [invalid, setInvalid] = useState(false);
|
||||
const emitted = useRef(field.value);
|
||||
const input = useRef<HTMLTextAreaElement | null>(null);
|
||||
const error = t('agents.debugData.invalidField', {
|
||||
field: `${field.name} (JSON)`,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (JSON.stringify(field.value) !== JSON.stringify(emitted.current)) {
|
||||
emitted.current = field.value;
|
||||
setDraft(JSON.stringify(field.value, null, 2) ?? '');
|
||||
setInvalid(false);
|
||||
input.current?.setCustomValidity('');
|
||||
}
|
||||
}, [field.value]);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
ref={(element) => {
|
||||
input.current = element;
|
||||
field.ref(element);
|
||||
}}
|
||||
name={field.name}
|
||||
aria-label={`${field.name} (JSON)`}
|
||||
data-testid={`structured-editor-${field.name}`}
|
||||
aria-invalid={invalid}
|
||||
className="min-h-48 font-mono"
|
||||
value={draft}
|
||||
onBlur={field.onBlur}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value;
|
||||
setDraft(next);
|
||||
try {
|
||||
const value = parseStructuredDraft(next, prompt);
|
||||
event.target.setCustomValidity('');
|
||||
setInvalid(false);
|
||||
form.clearErrors(field.name);
|
||||
emitted.current = value;
|
||||
field.onChange(value);
|
||||
} catch {
|
||||
event.target.setCustomValidity(error);
|
||||
setInvalid(true);
|
||||
form.setError(field.name, { type: 'json', message: error });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{invalid && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
export type JsonValue =
|
||||
| null
|
||||
| boolean
|
||||
| number
|
||||
| string
|
||||
| JsonValue[]
|
||||
| { [key: string]: JsonValue };
|
||||
export type PromptMessage = {
|
||||
role: string;
|
||||
content?: string | null | JsonValue[];
|
||||
[key: string]: JsonValue | undefined;
|
||||
};
|
||||
|
||||
export function isJsonValue(value: unknown): value is JsonValue {
|
||||
if (value === null || typeof value === 'string' || typeof value === 'boolean')
|
||||
return true;
|
||||
if (typeof value === 'number') return Number.isFinite(value);
|
||||
if (Array.isArray(value)) return value.every(isJsonValue);
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
Object.values(value).every(isJsonValue)
|
||||
);
|
||||
}
|
||||
export function isPromptValue(value: unknown): value is PromptMessage[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.every(
|
||||
(item) =>
|
||||
item !== null &&
|
||||
typeof item === 'object' &&
|
||||
!Array.isArray(item) &&
|
||||
typeof item.role === 'string' &&
|
||||
(!Object.prototype.hasOwnProperty.call(item, 'content') ||
|
||||
typeof item.content === 'string' ||
|
||||
item.content === null ||
|
||||
Array.isArray(item.content)) &&
|
||||
isJsonValue(item),
|
||||
)
|
||||
);
|
||||
}
|
||||
export function parseStructuredDraft(
|
||||
draft: string,
|
||||
prompt: boolean,
|
||||
): JsonValue {
|
||||
const value: unknown = JSON.parse(draft);
|
||||
if (!(prompt ? isPromptValue(value) : isJsonValue(value)))
|
||||
throw new TypeError('JSON');
|
||||
return value as JsonValue;
|
||||
}
|
||||
export function isSimplePrompt(
|
||||
value: unknown,
|
||||
): value is { role: string; content: string }[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
value.every(
|
||||
(item, index) =>
|
||||
item &&
|
||||
typeof item.content === 'string' &&
|
||||
(index === 0
|
||||
? item.role === 'system'
|
||||
: ['user', 'assistant'].includes(item.role)) &&
|
||||
Object.keys(item).every((key) => key === 'role' || key === 'content'),
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,8 @@ interface ReasoningLevelPickerProps {
|
||||
value: ReasoningLevel;
|
||||
levels: ReasoningLevel[];
|
||||
disabled?: boolean;
|
||||
inherited?: boolean;
|
||||
onInherit?: () => void;
|
||||
onChange: (value: ReasoningLevel) => void;
|
||||
}
|
||||
|
||||
@@ -44,6 +46,8 @@ export default function ReasoningLevelPicker({
|
||||
value,
|
||||
levels,
|
||||
disabled = false,
|
||||
inherited = false,
|
||||
onInherit,
|
||||
onChange,
|
||||
}: ReasoningLevelPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -52,8 +56,14 @@ export default function ReasoningLevelPicker({
|
||||
const safeValue: ReasoningLevel = safeLevels.includes(value)
|
||||
? value
|
||||
: safeLevels[0];
|
||||
const currentLabel = t(REASONING_LEVEL_LABEL_KEYS[safeValue]);
|
||||
const isExplicit = safeValue !== 'provider_default';
|
||||
const isInherited = Boolean(onInherit && inherited);
|
||||
const currentLabel = t(
|
||||
isInherited
|
||||
? 'models.reasoningLevels.useModelSetting'
|
||||
: REASONING_LEVEL_LABEL_KEYS[safeValue],
|
||||
);
|
||||
const isExplicit =
|
||||
!isInherited && (Boolean(onInherit) || safeValue !== 'provider_default');
|
||||
const currentIndex = Math.max(0, safeLevels.indexOf(safeValue));
|
||||
|
||||
return (
|
||||
@@ -63,7 +73,7 @@ export default function ReasoningLevelPicker({
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled || safeLevels.length <= 1}
|
||||
disabled={disabled || (safeLevels.length <= 1 && !onInherit)}
|
||||
aria-label={`${t('models.reasoningLevel')}: ${currentLabel}`}
|
||||
className="h-9 w-9 shrink-0 gap-1.5 px-2.5 text-xs font-normal sm:w-auto sm:max-w-36"
|
||||
>
|
||||
@@ -77,6 +87,30 @@ export default function ReasoningLevelPicker({
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-[272px] p-4">
|
||||
{onInherit && (
|
||||
<div className="mb-3 flex flex-col gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-pressed={isInherited}
|
||||
onClick={onInherit}
|
||||
>
|
||||
{t('models.reasoningLevels.useModelSetting')}
|
||||
</Button>
|
||||
{safeLevels.includes('provider_default') && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-pressed={!isInherited && safeValue === 'provider_default'}
|
||||
onClick={() => onChange('provider_default')}
|
||||
>
|
||||
{t(REASONING_LEVEL_LABEL_KEYS.provider_default)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex h-5 items-center gap-0.5 text-sm text-muted-foreground">
|
||||
<span>{currentLabel}</span>
|
||||
<ChevronRight className="size-3.5" />
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import EntityLoadState from '@/components/EntityLoadState';
|
||||
import { isCurrentPipelineConfig } from './pipeline-config-safety';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import PipelineFormComponent, {
|
||||
PipelineFormHandle,
|
||||
} from '@/app/home/pipelines/components/pipeline-form/PipelineFormComponent';
|
||||
@@ -150,6 +152,25 @@ export default function PipelineDetailContent({
|
||||
);
|
||||
if (!pipelineDetails) return <EntityLoadState />;
|
||||
|
||||
// Never hydrate plugin defaults or mount debug autosave for legacy bindings.
|
||||
if (!isCurrentPipelineConfig(pipelineDetails.config)) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{pipelineDetails.emoji || '⚙️'} {pipelineDetails.name}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{pipelineDetails.description}
|
||||
</p>
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{t('pipelineMigration.legacyGate')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Edit Mode ====================
|
||||
const pipelineName =
|
||||
pipelineDetails?.name ||
|
||||
@@ -178,6 +199,7 @@ export default function PipelineDetailContent({
|
||||
<fieldset className="contents" disabled={!canManage}>
|
||||
<PipelineFormComponent
|
||||
ref={pipelineFormRef}
|
||||
onLegacyPipeline={setPipelineDetails}
|
||||
pipelineId={id}
|
||||
isEditMode={true}
|
||||
disableForm={!canManage}
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { getCurrentWorkspaceSnapshot } from '@/app/infra/http/currentWorkspaceStore';
|
||||
import { migrationIssueKey } from './pipeline-migration-issues';
|
||||
import type { CurrentWorkspace } from '@/app/infra/entities/workspace';
|
||||
import type {
|
||||
PipelineMigrationIssue,
|
||||
PipelineMigrationItem,
|
||||
PipelineMigrationPreview,
|
||||
PipelineMigrationResult,
|
||||
} from '@/app/infra/entities/api/pipeline-migration';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
|
||||
export function migrationWorkspaceKey(workspace: CurrentWorkspace | null) {
|
||||
return workspace
|
||||
? `${workspace.workspace.instance_uuid}:${workspace.workspace.uuid}:${workspace.placement_generation}:${workspace.permissions.join(',')}`
|
||||
: '';
|
||||
}
|
||||
|
||||
function safeField(field?: string | null) {
|
||||
return field && field.length <= 180 && /^[a-zA-Z0-9_.\-[\]]+$/.test(field)
|
||||
? field
|
||||
: null;
|
||||
}
|
||||
|
||||
const resultStates = new Set([
|
||||
'pending',
|
||||
'migrated',
|
||||
'already_current',
|
||||
'blocked',
|
||||
'stale',
|
||||
'failed',
|
||||
'activation_pending',
|
||||
]);
|
||||
type Status =
|
||||
| 'idle'
|
||||
| 'submitting'
|
||||
| 'running'
|
||||
| 'finished'
|
||||
| 'failed'
|
||||
| 'lost'
|
||||
| 'requestError';
|
||||
|
||||
export default function PipelineMigration({
|
||||
workspace,
|
||||
onComplete,
|
||||
}: {
|
||||
workspace: CurrentWorkspace;
|
||||
onComplete: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const scopeKey = migrationWorkspaceKey(workspace);
|
||||
const canManage = workspace.permissions.includes('resource.manage');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [preview, setPreview] = useState<PipelineMigrationPreview | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [previewError, setPreviewError] = useState(false);
|
||||
const [valid, setValid] = useState(false);
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [status, setStatus] = useState<Status>('idle');
|
||||
const [results, setResults] = useState<PipelineMigrationResult[]>([]);
|
||||
const active = useRef(false);
|
||||
const submitting = useRef(false);
|
||||
const previewGeneration = useRef(0);
|
||||
const controller = useRef<AbortController | null>(null);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const onCompleteRef = useRef(onComplete);
|
||||
onCompleteRef.current = onComplete;
|
||||
const isCurrent = useCallback(
|
||||
() =>
|
||||
active.current &&
|
||||
migrationWorkspaceKey(getCurrentWorkspaceSnapshot()) === scopeKey,
|
||||
[scopeKey],
|
||||
);
|
||||
|
||||
const refresh = useCallback(
|
||||
async (allowSelection = true) => {
|
||||
if (!isCurrent()) return;
|
||||
const generation = ++previewGeneration.current;
|
||||
setSelected([]);
|
||||
setConfirmed(false);
|
||||
setValid(false);
|
||||
setLoading(true);
|
||||
setPreviewError(false);
|
||||
try {
|
||||
const response = await httpClient.getPipelineMigrationPreview({
|
||||
signal: controller.current?.signal,
|
||||
});
|
||||
if (!isCurrent() || generation !== previewGeneration.current) return;
|
||||
if (
|
||||
response.workspace_uuid !== workspace.workspace.uuid ||
|
||||
!Array.isArray(response.items) ||
|
||||
response.total !== response.items.length
|
||||
)
|
||||
throw new Error('Invalid preview');
|
||||
setPreview(response);
|
||||
setValid(allowSelection);
|
||||
} catch {
|
||||
if (isCurrent() && generation === previewGeneration.current) {
|
||||
setPreviewError(true);
|
||||
setPreview(null);
|
||||
}
|
||||
} finally {
|
||||
if (isCurrent() && generation === previewGeneration.current)
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[isCurrent, workspace.workspace.uuid],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
active.current = true;
|
||||
controller.current = new AbortController();
|
||||
void refresh();
|
||||
const onFocus = () => {
|
||||
if (!submitting.current) void refresh();
|
||||
};
|
||||
window.addEventListener('focus', onFocus);
|
||||
return () => {
|
||||
active.current = false;
|
||||
controller.current?.abort();
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
window.removeEventListener('focus', onFocus);
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
const busy = status === 'submitting' || status === 'running';
|
||||
const eligible = (item: PipelineMigrationItem) =>
|
||||
['ready', 'activation_pending'].includes(item.state) &&
|
||||
!!item.preview_token;
|
||||
const rows = preview?.items ?? [];
|
||||
const count = rows.filter(
|
||||
(item) => !['already_current', 'not_legacy'].includes(item.state),
|
||||
).length;
|
||||
|
||||
function renderIssue(issue: PipelineMigrationIssue, warning = false) {
|
||||
// Unknown server codes use localized fallbacks, never raw upstream messages.
|
||||
const key = migrationIssueKey(issue.code);
|
||||
const fallback = t(
|
||||
warning
|
||||
? 'pipelineMigration.warningFallback'
|
||||
: 'pipelineMigration.blockerFallback',
|
||||
);
|
||||
const message = key
|
||||
? t(`pipelineMigration.notices.${key}`, { defaultValue: fallback })
|
||||
: fallback;
|
||||
const field = safeField(issue.field);
|
||||
return (
|
||||
<span>
|
||||
{message}
|
||||
{field && (
|
||||
<>
|
||||
{' '}
|
||||
— <code className="text-xs">{field}</code>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
async function execute() {
|
||||
if (
|
||||
submitting.current ||
|
||||
!isCurrent() ||
|
||||
!canManage ||
|
||||
!valid ||
|
||||
loading ||
|
||||
!confirmed ||
|
||||
selected.length === 0 ||
|
||||
selected.length > 50
|
||||
)
|
||||
return;
|
||||
const items = rows
|
||||
.filter((item) => selected.includes(item.pipeline_uuid) && eligible(item))
|
||||
.map((item) => ({
|
||||
pipeline_uuid: item.pipeline_uuid,
|
||||
preview_token: item.preview_token!,
|
||||
}));
|
||||
if (items.length !== selected.length) return;
|
||||
submitting.current = true;
|
||||
++previewGeneration.current;
|
||||
setStatus('submitting');
|
||||
setValid(false);
|
||||
setConfirmed(false);
|
||||
setResults(
|
||||
items.map((item) => ({
|
||||
pipeline_uuid: item.pipeline_uuid,
|
||||
state: 'pending',
|
||||
code: null,
|
||||
})),
|
||||
);
|
||||
|
||||
const loseObservation = () => {
|
||||
if (!isCurrent()) return;
|
||||
submitting.current = false;
|
||||
setStatus('lost');
|
||||
void refresh(false);
|
||||
onCompleteRef.current();
|
||||
};
|
||||
try {
|
||||
const { task_id } = await httpClient.executePipelineMigration(
|
||||
{ confirmed: true, items },
|
||||
{ signal: controller.current?.signal },
|
||||
);
|
||||
if (!isCurrent()) return;
|
||||
setStatus('running');
|
||||
const poll = async () => {
|
||||
if (!isCurrent()) return;
|
||||
try {
|
||||
const task = await httpClient.getAsyncTask(task_id, {
|
||||
signal: controller.current?.signal,
|
||||
});
|
||||
if (!isCurrent()) return;
|
||||
const metadata = task.task_context?.metadata;
|
||||
if (
|
||||
task.id !== task_id ||
|
||||
metadata?.kind !== 'pipeline_migration' ||
|
||||
!Array.isArray(metadata.results)
|
||||
) {
|
||||
loseObservation();
|
||||
return;
|
||||
}
|
||||
const outcome = metadata.results as PipelineMigrationResult[];
|
||||
if (
|
||||
outcome.some(
|
||||
(item) =>
|
||||
!item ||
|
||||
!resultStates.has(item.state) ||
|
||||
!items.some(
|
||||
(submitted) => submitted.pipeline_uuid === item.pipeline_uuid,
|
||||
),
|
||||
) ||
|
||||
(task.runtime.done && outcome.length !== items.length) ||
|
||||
new Set(outcome.map((item) => item.pipeline_uuid)).size !==
|
||||
outcome.length
|
||||
) {
|
||||
loseObservation();
|
||||
return;
|
||||
}
|
||||
const scopedResults = items.map(
|
||||
(item) =>
|
||||
outcome.find(
|
||||
(result) => result.pipeline_uuid === item.pipeline_uuid,
|
||||
) ?? {
|
||||
pipeline_uuid: item.pipeline_uuid,
|
||||
state: 'pending' as const,
|
||||
code: null,
|
||||
},
|
||||
);
|
||||
setResults(scopedResults);
|
||||
if (task.runtime.done) {
|
||||
if (
|
||||
!task.runtime.exception &&
|
||||
scopedResults.some((item) => item.state === 'pending')
|
||||
) {
|
||||
loseObservation();
|
||||
return;
|
||||
}
|
||||
submitting.current = false;
|
||||
setStatus(task.runtime.exception ? 'failed' : 'finished');
|
||||
void refresh(false);
|
||||
onCompleteRef.current();
|
||||
} else {
|
||||
timer.current = setTimeout(() => {
|
||||
void poll();
|
||||
}, 1000);
|
||||
}
|
||||
} catch {
|
||||
loseObservation();
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
} catch (error) {
|
||||
if (!isCurrent()) return;
|
||||
const code =
|
||||
typeof error === 'object' && error && 'code' in error
|
||||
? error.code
|
||||
: undefined;
|
||||
if (code === -1 || code === -2 || code === undefined) {
|
||||
loseObservation();
|
||||
return;
|
||||
}
|
||||
submitting.current = false;
|
||||
setStatus('requestError');
|
||||
setSelected([]);
|
||||
}
|
||||
}
|
||||
|
||||
function changeOpen(next: boolean) {
|
||||
setOpen(next);
|
||||
if (!submitting.current) {
|
||||
setSelected([]);
|
||||
setConfirmed(false);
|
||||
if (next) void refresh(status === 'idle');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{(count > 0 || previewError || results.length > 0) && (
|
||||
<Alert className="mb-4 shrink-0">
|
||||
<AlertTitle>{t('pipelineMigration.title')}</AlertTitle>
|
||||
<AlertDescription className="flex items-center justify-between gap-3">
|
||||
<span>
|
||||
{previewError
|
||||
? t('pipelineMigration.previewError')
|
||||
: t('pipelineMigration.detected', { count })}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => changeOpen(true)}
|
||||
>
|
||||
{t('pipelineMigration.review')}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Dialog open={open} onOpenChange={changeOpen}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('pipelineMigration.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('pipelineMigration.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{!canManage && (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{t('pipelineMigration.readOnly')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{previewError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{t('pipelineMigration.previewError')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{status !== 'idle' && (
|
||||
<Alert
|
||||
variant={
|
||||
status === 'failed' || status === 'requestError'
|
||||
? 'destructive'
|
||||
: 'default'
|
||||
}
|
||||
>
|
||||
<AlertDescription role="status">
|
||||
{t(`pipelineMigration.${status}`)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<ScrollArea className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="space-y-3 pr-3">
|
||||
{rows.map((item) => (
|
||||
<div
|
||||
key={item.pipeline_uuid}
|
||||
className="rounded-md border p-3 space-y-2"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
aria-label={item.name}
|
||||
checked={selected.includes(item.pipeline_uuid)}
|
||||
disabled={
|
||||
!canManage ||
|
||||
busy ||
|
||||
loading ||
|
||||
!valid ||
|
||||
!eligible(item) ||
|
||||
(selected.length >= 50 &&
|
||||
!selected.includes(item.pipeline_uuid))
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
setConfirmed(false);
|
||||
setSelected((current) =>
|
||||
checked
|
||||
? [...current, item.pipeline_uuid]
|
||||
: current.filter((id) => id !== item.pipeline_uuid),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p className="font-medium text-sm break-words">
|
||||
{item.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground break-words">
|
||||
{item.legacy_runner ?? '—'} →{' '}
|
||||
{item.target_plugin?.name ?? '—'}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
{t(`pipelineMigration.states.${item.state}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
{item.blockers.map((issue, index) => (
|
||||
<p className="text-sm text-destructive" key={`b-${index}`}>
|
||||
{renderIssue(issue)}
|
||||
</p>
|
||||
))}
|
||||
{item.warnings.map((issue, index) => (
|
||||
<p
|
||||
className="text-sm text-muted-foreground"
|
||||
key={`w-${index}`}
|
||||
>
|
||||
{renderIssue(issue, true)}
|
||||
</p>
|
||||
))}
|
||||
{item.changed_paths.filter((path) => safeField(path)).length >
|
||||
0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('pipelineMigration.changedFields')}:{' '}
|
||||
{item.changed_paths
|
||||
.filter((path) => safeField(path))
|
||||
.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{item.state === 'activation_pending' && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
item.preview_token
|
||||
? 'pipelineMigration.activationRetryHint'
|
||||
: 'pipelineMigration.activationHint',
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{rows.some((item) => item.state === 'needs_plugin') && (
|
||||
<Alert>
|
||||
<AlertDescription className="space-y-2">
|
||||
<p>{t('pipelineMigration.pluginHint')}</p>
|
||||
<Button variant="link" asChild className="h-auto p-0">
|
||||
<a
|
||||
href="/home/extensions"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t('pipelineMigration.extensions')}
|
||||
</a>
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{results.length > 0 && (
|
||||
<div
|
||||
className="space-y-2"
|
||||
aria-label={t('pipelineMigration.results')}
|
||||
>
|
||||
<h3 className="text-sm font-medium">
|
||||
{t('pipelineMigration.results')}
|
||||
</h3>
|
||||
{results.map((result) => (
|
||||
<div
|
||||
className="rounded-md border p-3 text-sm"
|
||||
key={result.pipeline_uuid}
|
||||
data-testid={`migration-result-${result.pipeline_uuid}`}
|
||||
>
|
||||
<p>
|
||||
{rows.find(
|
||||
(item) => item.pipeline_uuid === result.pipeline_uuid,
|
||||
)?.name ?? result.pipeline_uuid}{' '}
|
||||
— {t(`pipelineMigration.states.${result.state}`)}
|
||||
</p>
|
||||
{result.code && (
|
||||
<p className="text-muted-foreground">
|
||||
{renderIssue({ code: result.code })}
|
||||
</p>
|
||||
)}
|
||||
{result.state === 'activation_pending' && (
|
||||
<p>{t('pipelineMigration.activationHint')}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('pipelineMigration.selection', { count: selected.length })}
|
||||
</p>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={confirmed}
|
||||
onCheckedChange={(checked) => setConfirmed(checked === true)}
|
||||
disabled={
|
||||
!canManage || busy || loading || !valid || !selected.length
|
||||
}
|
||||
/>
|
||||
{t('pipelineMigration.confirm')}
|
||||
</label>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => changeOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy || loading}
|
||||
onClick={() => {
|
||||
setStatus('idle');
|
||||
void refresh();
|
||||
}}
|
||||
>
|
||||
{t('pipelineMigration.refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
!canManage ||
|
||||
busy ||
|
||||
loading ||
|
||||
!valid ||
|
||||
!selected.length ||
|
||||
!confirmed
|
||||
}
|
||||
onClick={() => {
|
||||
void execute();
|
||||
}}
|
||||
>
|
||||
{t('pipelineMigration.execute')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import EntityLoadState from '@/components/EntityLoadState';
|
||||
import { preserveRunnerConfig } from './RunnerConfigPreservation';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { isCurrentPipelineConfig } from '../../pipeline-config-safety';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
@@ -67,6 +71,35 @@ import {
|
||||
type InstalledRunner,
|
||||
} from '@/app/home/agents/runner-marketplace';
|
||||
|
||||
/** A mount-scoped editing session: normalized defaults are UI state, not edits. */
|
||||
function PersistedRunnerForm(
|
||||
props: ComponentProps<typeof DynamicFormComponent>,
|
||||
) {
|
||||
const initialValues = useRef(props.initialValues);
|
||||
const previous = useRef<Record<string, unknown> | undefined>(undefined);
|
||||
const current = useRef(props.initialValues || {});
|
||||
current.current = props.initialValues || {};
|
||||
return (
|
||||
<DynamicFormComponent
|
||||
{...props}
|
||||
initialValues={initialValues.current}
|
||||
onSubmit={(values) => {
|
||||
const emitted = values as Record<string, unknown>;
|
||||
const next = preserveRunnerConfig(
|
||||
current.current,
|
||||
previous.current,
|
||||
emitted,
|
||||
);
|
||||
previous.current = structuredClone(emitted);
|
||||
if (JSON.stringify(next) !== JSON.stringify(current.current)) {
|
||||
current.current = next;
|
||||
props.onSubmit?.(next);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface PipelineFormComponentProps {
|
||||
pipelineId?: string;
|
||||
isEditMode: boolean;
|
||||
@@ -78,6 +111,7 @@ interface PipelineFormComponentProps {
|
||||
onCancel?: () => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
onSavingChange?: (saving: boolean) => void;
|
||||
onLegacyPipeline?: (pipeline: Pipeline) => void;
|
||||
}
|
||||
|
||||
export interface PipelineFormHandle {
|
||||
@@ -103,6 +137,7 @@ const PipelineFormComponent = forwardRef<
|
||||
onCancel,
|
||||
onDirtyChange,
|
||||
onSavingChange,
|
||||
onLegacyPipeline,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
@@ -111,7 +146,10 @@ const PipelineFormComponent = forwardRef<
|
||||
const [showCopyConfirm, setShowCopyConfirm] = useState(false);
|
||||
const [isDefaultPipeline, setIsDefaultPipeline] = useState<boolean>(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const pipelineFormElement = useRef<HTMLFormElement>(null);
|
||||
const isSavingRef = useRef(false);
|
||||
const legacyConfigRef = useRef(false);
|
||||
const [legacyConfig, setLegacyConfig] = useState(false);
|
||||
|
||||
const formSchema = isEditMode
|
||||
? z.object({
|
||||
@@ -291,6 +329,12 @@ const PipelineFormComponent = forwardRef<
|
||||
.getPipeline(pipelineId || '')
|
||||
.then((resp: GetPipelineResponseData) => {
|
||||
if (cancelled) return;
|
||||
if (!isCurrentPipelineConfig(resp.pipeline.config)) {
|
||||
legacyConfigRef.current = true;
|
||||
setLegacyConfig(true);
|
||||
onLegacyPipeline?.(resp.pipeline);
|
||||
return;
|
||||
}
|
||||
setIsDefaultPipeline(resp.pipeline.is_default ?? false);
|
||||
|
||||
const loadedValues = {
|
||||
@@ -316,7 +360,7 @@ const PipelineFormComponent = forwardRef<
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [form, isEditMode, pipelineId, loadAttempt]);
|
||||
}, [form, isEditMode, pipelineId, loadAttempt, onLegacyPipeline]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -404,7 +448,15 @@ const PipelineFormComponent = forwardRef<
|
||||
}
|
||||
|
||||
async function handleModify(values: FormValues): Promise<boolean> {
|
||||
if (isSavingRef.current) return false;
|
||||
// Imperative saves bypass native form submission validation. In particular,
|
||||
// never save an old structured value while its visible JSON draft is invalid.
|
||||
if (
|
||||
pipelineFormElement.current &&
|
||||
!pipelineFormElement.current.checkValidity()
|
||||
)
|
||||
return false;
|
||||
if (isSavingRef.current || legacyConfigRef.current || !pipelineLoaded)
|
||||
return false;
|
||||
const submittedSnapshot = JSON.stringify(values);
|
||||
const realConfig = {
|
||||
ai: values.ai,
|
||||
@@ -468,6 +520,7 @@ const PipelineFormComponent = forwardRef<
|
||||
}
|
||||
},
|
||||
async save() {
|
||||
if (legacyConfigRef.current || !pipelineLoaded) return false;
|
||||
if (!hasUnsavedChangesRef.current) return true;
|
||||
if (isSavingRef.current || !isEditMode) return false;
|
||||
const valid = await form.trigger();
|
||||
@@ -488,7 +541,9 @@ const PipelineFormComponent = forwardRef<
|
||||
values: object,
|
||||
) {
|
||||
const stageKey = `${String(formName)}.${stageName}`;
|
||||
const isFirstEmission = !initializedStagesRef.current.has(stageKey);
|
||||
const isFirstEmission =
|
||||
!initializedStagesRef.current.has(stageKey) &&
|
||||
!(formName === 'output' && stageName === 'misc');
|
||||
|
||||
const currentValues =
|
||||
(form.getValues(formName) as Record<string, unknown>) || {};
|
||||
@@ -524,6 +579,29 @@ const PipelineFormComponent = forwardRef<
|
||||
}
|
||||
|
||||
form.setValue(formName, nextValues);
|
||||
if (formName === 'output' && stageName === 'misc') {
|
||||
const removeThink = (values as Record<string, unknown>)['remove-think'];
|
||||
const previousThink = (
|
||||
currentValues.misc as Record<string, unknown> | undefined
|
||||
)?.['remove-think'];
|
||||
const runnerId = form.getValues('ai.runner.id') as string;
|
||||
const supported = aiConfigTabSchema?.stages.some(
|
||||
(stage) =>
|
||||
stage.name === runnerId &&
|
||||
stage.config.some((item) => item.name === 'remove-think'),
|
||||
);
|
||||
if (
|
||||
supported &&
|
||||
typeof removeThink === 'boolean' &&
|
||||
removeThink !== previousThink
|
||||
) {
|
||||
const configs = form.getValues('ai.runner_config') || {};
|
||||
form.setValue('ai.runner_config', {
|
||||
...configs,
|
||||
[runnerId]: { ...configs[runnerId], 'remove-think': removeThink },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isFirstEmission) {
|
||||
initializedStagesRef.current.add(stageKey);
|
||||
@@ -539,22 +617,23 @@ const PipelineFormComponent = forwardRef<
|
||||
}
|
||||
|
||||
function handleRunnerConfigEmit(stageName: string, values: object) {
|
||||
const stageKey = `ai.runner_config.${stageName}`;
|
||||
const isFirstEmission = !initializedStagesRef.current.has(stageKey);
|
||||
|
||||
const currentRunnerConfigs =
|
||||
(form.getValues('ai.runner_config') as Record<string, unknown>) || {};
|
||||
(form.getValues('ai.runner_config') as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>) || {};
|
||||
const removeThink = (values as Record<string, unknown>)['remove-think'];
|
||||
const previousThink = currentRunnerConfigs[stageName]?.['remove-think'];
|
||||
form.setValue('ai.runner_config', {
|
||||
...currentRunnerConfigs,
|
||||
[stageName]: values,
|
||||
});
|
||||
|
||||
if (isFirstEmission) {
|
||||
initializedStagesRef.current.add(stageKey);
|
||||
const currentSnapshot = JSON.stringify(form.getValues());
|
||||
if (savedSnapshotRef.current === '' || !hasUnsavedChangesRef.current) {
|
||||
savedSnapshotRef.current = currentSnapshot;
|
||||
}
|
||||
if (typeof removeThink === 'boolean' && removeThink !== previousThink) {
|
||||
const output = form.getValues('output') || {};
|
||||
form.setValue('output', {
|
||||
...output,
|
||||
misc: { ...output.misc, 'remove-think': removeThink },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -631,7 +710,7 @@ const PipelineFormComponent = forwardRef<
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<DynamicFormComponent
|
||||
<PersistedRunnerForm
|
||||
itemConfigList={stage.config}
|
||||
initialValues={stageInitialValues}
|
||||
systemContext={dynamicFormSystemContext}
|
||||
@@ -645,6 +724,10 @@ const PipelineFormComponent = forwardRef<
|
||||
}
|
||||
}
|
||||
|
||||
const StageForm =
|
||||
formName === 'output' && stage.name === 'misc'
|
||||
? PersistedRunnerForm
|
||||
: DynamicFormComponent;
|
||||
const stageInitialValues: Record<string, any> =
|
||||
(form.watch(formName) as Record<string, any>)?.[stage.name] || {};
|
||||
|
||||
@@ -659,7 +742,7 @@ const PipelineFormComponent = forwardRef<
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<DynamicFormComponent
|
||||
<StageForm
|
||||
itemConfigList={stage.config}
|
||||
initialValues={stageInitialValues}
|
||||
systemContext={dynamicFormSystemContext}
|
||||
@@ -711,6 +794,12 @@ const PipelineFormComponent = forwardRef<
|
||||
}
|
||||
};
|
||||
|
||||
if (legacyConfig)
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>{t('pipelineMigration.legacyGate')}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
if (loadFailed)
|
||||
return (
|
||||
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
|
||||
@@ -723,6 +812,7 @@ const PipelineFormComponent = forwardRef<
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="pipeline-form"
|
||||
ref={pipelineFormElement}
|
||||
onSubmit={form.handleSubmit(handleFormSubmit)}
|
||||
className="h-full flex flex-col flex-1 min-h-0 mb-2"
|
||||
>
|
||||
@@ -920,8 +1010,11 @@ const PipelineFormComponent = forwardRef<
|
||||
{/* Dynamic config sections (edit mode only) */}
|
||||
{isEditMode && (
|
||||
<>
|
||||
{activeSection === 'ai' && aiConfigTabSchema && (
|
||||
<div className="space-y-6">
|
||||
{aiConfigTabSchema && (
|
||||
<div
|
||||
className="space-y-6"
|
||||
hidden={activeSection !== 'ai'}
|
||||
>
|
||||
{aiConfigTabSchema.stages.map((stage) =>
|
||||
renderDynamicForms(stage, 'ai'),
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
type Values = Record<string, unknown>;
|
||||
const isRecord = (value: unknown): value is Values =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
/** Apply only edits relative to the mounted form's normalized emission.
|
||||
* The raw persisted object remains authoritative for unedited values, including
|
||||
* fields not represented by the schema, nulls, omitted defaults and whitespace.
|
||||
*/
|
||||
export function preserveRunnerConfig(
|
||||
current: Values,
|
||||
previous: Values | undefined,
|
||||
emitted: Values,
|
||||
): Values {
|
||||
if (!previous) return current;
|
||||
const result = { ...current };
|
||||
for (const key of new Set([
|
||||
...Object.keys(previous),
|
||||
...Object.keys(emitted),
|
||||
])) {
|
||||
if (JSON.stringify(previous[key]) === JSON.stringify(emitted[key]))
|
||||
continue;
|
||||
if (!Object.prototype.hasOwnProperty.call(emitted, key)) {
|
||||
delete result[key];
|
||||
} else if (isRecord(previous[key]) && isRecord(emitted[key])) {
|
||||
result[key] = preserveRunnerConfig(
|
||||
isRecord(current[key]) ? current[key] : {},
|
||||
previous[key],
|
||||
emitted[key],
|
||||
);
|
||||
} else {
|
||||
result[key] = emitted[key];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1,19 +1,44 @@
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import PipelineDetailContent from './PipelineDetailContent';
|
||||
import PipelineMigration, { migrationWorkspaceKey } from './PipelineMigration';
|
||||
|
||||
export default function PipelineConfigPage() {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const detailId = searchParams.get('id');
|
||||
|
||||
if (detailId) {
|
||||
return <PipelineDetailContent key={detailId} id={detailId} />;
|
||||
}
|
||||
const workspace = useCurrentWorkspace();
|
||||
const { refreshPipelines } = useSidebarData();
|
||||
const [revision, setRevision] = useState(0);
|
||||
const scopeKey = migrationWorkspaceKey(workspace);
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<p>{t('pipelines.selectFromSidebar')}</p>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{workspace && workspace.permissions.includes('resource.view') && (
|
||||
<PipelineMigration
|
||||
key={scopeKey}
|
||||
workspace={workspace}
|
||||
onComplete={() => {
|
||||
void refreshPipelines();
|
||||
setRevision((current) => current + 1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-h-0">
|
||||
{detailId ? (
|
||||
<PipelineDetailContent
|
||||
key={`${scopeKey}:${detailId}:${revision}`}
|
||||
id={detailId}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<p>{t('pipelines.selectFromSidebar')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Only unambiguous current containers may hydrate editor defaults. */
|
||||
export function isCurrentPipelineConfig(config: unknown): boolean {
|
||||
if (!record(config) || !record(config.ai) || !record(config.ai.runner))
|
||||
return false;
|
||||
const ai = config.ai;
|
||||
const runner = ai.runner as Record<string, unknown>;
|
||||
if (
|
||||
Object.keys(ai).some((key) => !['runner', 'runner_config'].includes(key)) ||
|
||||
Object.keys(runner).some((key) => !['id', 'expire-time'].includes(key)) ||
|
||||
typeof runner.id !== 'string'
|
||||
)
|
||||
return false;
|
||||
|
||||
// The canonical current default has no selected runner yet. Do not confuse
|
||||
// it with a legacy selector or an orphaned configuration needing review.
|
||||
if (runner.id === '')
|
||||
return (
|
||||
record(ai.runner_config) && Object.keys(ai.runner_config).length === 0
|
||||
);
|
||||
|
||||
return runner.id.startsWith('plugin:');
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
const issueKeys: Record<string, string> = {
|
||||
'local.context_defaults': 'contextDefaults',
|
||||
'local.model_reasoning': 'modelReasoning',
|
||||
'local.serial_tools_preserved': 'serialTools',
|
||||
'local.retrieval_defaults': 'retrievalDefaults',
|
||||
'local.box_state_reset': 'boxReset',
|
||||
'coze.persistent_history': 'persistentHistory',
|
||||
'langflow.persistent_history': 'persistentHistory',
|
||||
'langflow.tweaks_default': 'tweaksDefault',
|
||||
'dify.timeout_default': 'timeoutDefault',
|
||||
'n8n.session_ids_reset': 'historyReset',
|
||||
'weknora.session_title_changed': 'sessionTitle',
|
||||
'migration.alias_repaired': 'aliasRepaired',
|
||||
'migration.null_default': 'nullDefault',
|
||||
'migration.output_policy_copied': 'outputPolicy',
|
||||
'migration.filtered_variables': 'filteredVariables',
|
||||
'migration.history_reset': 'historyReset',
|
||||
'migration.identity_preserved': 'identityPreserved',
|
||||
'migration.new_defaults': 'newDefaults',
|
||||
'migration.legacy_sections_archived': 'legacyArchive',
|
||||
'external.state_validation_required': 'externalState',
|
||||
'runtime.plugin_version_mismatch': 'pluginVersion',
|
||||
'runtime.schema_invalid': 'schemaChanged',
|
||||
'runtime.schema_missing': 'schemaChanged',
|
||||
'extensions.runner_excluded': 'runnerExcluded',
|
||||
'local.box_scope': 'boxScope',
|
||||
'runtime.pending_interaction': 'pendingInteraction',
|
||||
};
|
||||
|
||||
/** Only locally reviewed codes may select localized copy. */
|
||||
export function migrationIssueKey(code: string): string | undefined {
|
||||
return Object.prototype.hasOwnProperty.call(issueKeys, code)
|
||||
? issueKeys[code]
|
||||
: undefined;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
export type PipelineMigrationState =
|
||||
| 'ready'
|
||||
| 'needs_plugin'
|
||||
| 'blocked'
|
||||
| 'already_current'
|
||||
| 'not_legacy'
|
||||
| 'activation_pending';
|
||||
|
||||
export interface PipelineMigrationIssue {
|
||||
code: string;
|
||||
field?: string | null;
|
||||
}
|
||||
|
||||
export interface PipelineMigrationItem {
|
||||
pipeline_uuid: string;
|
||||
name: string;
|
||||
state: PipelineMigrationState;
|
||||
legacy_runner: string | null;
|
||||
target_runner_id: string | null;
|
||||
target_plugin: { author: string; name: string; version: string } | null;
|
||||
changed_paths: string[];
|
||||
blockers: PipelineMigrationIssue[];
|
||||
warnings: PipelineMigrationIssue[];
|
||||
preview_token: string | null;
|
||||
}
|
||||
|
||||
export interface PipelineMigrationPreview {
|
||||
planner_version: string;
|
||||
workspace_uuid: string;
|
||||
items: PipelineMigrationItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface PipelineMigrationRequest {
|
||||
confirmed: true;
|
||||
items: { pipeline_uuid: string; preview_token: string }[];
|
||||
}
|
||||
|
||||
export interface PipelineMigrationResult {
|
||||
pipeline_uuid: string;
|
||||
state:
|
||||
| 'pending'
|
||||
| 'migrated'
|
||||
| 'already_current'
|
||||
| 'blocked'
|
||||
| 'stale'
|
||||
| 'failed'
|
||||
| 'activation_pending';
|
||||
code: string | null;
|
||||
}
|
||||
|
||||
export interface PipelineMigrationTaskMetadata {
|
||||
kind: 'pipeline_migration';
|
||||
results: PipelineMigrationResult[];
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import { BaseHttpClient, type RequestConfig } from './BaseHttpClient';
|
||||
import type {
|
||||
PipelineMigrationPreview,
|
||||
PipelineMigrationRequest,
|
||||
} from '@/app/infra/entities/api/pipeline-migration';
|
||||
import type { DebugExecutionEvent } from '@/app/infra/entities/api/agent-debug';
|
||||
import type {
|
||||
CodexAuthStatus,
|
||||
@@ -441,6 +445,19 @@ export class BackendClient extends BaseHttpClient {
|
||||
return this.get(`/api/v1/pipelines/${uuid}`);
|
||||
}
|
||||
|
||||
public getPipelineMigrationPreview(
|
||||
config?: RequestConfig,
|
||||
): Promise<PipelineMigrationPreview> {
|
||||
return this.get('/api/v1/pipelines/_/migration/preview', undefined, config);
|
||||
}
|
||||
|
||||
public executePipelineMigration(
|
||||
body: PipelineMigrationRequest,
|
||||
config?: RequestConfig,
|
||||
): Promise<AsyncTaskCreatedResp> {
|
||||
return this.post('/api/v1/pipelines/_/migration/execute', body, config);
|
||||
}
|
||||
|
||||
public createPipeline(pipeline: Pipeline): Promise<{
|
||||
uuid: string;
|
||||
}> {
|
||||
@@ -1344,8 +1361,8 @@ export class BackendClient extends BaseHttpClient {
|
||||
return this.get(`/api/v1/system/tasks${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
public getAsyncTask(id: number): Promise<AsyncTask> {
|
||||
return this.get(`/api/v1/system/tasks/${id}`);
|
||||
public getAsyncTask(id: number, config?: RequestConfig): Promise<AsyncTask> {
|
||||
return this.get(`/api/v1/system/tasks/${id}`, undefined, config);
|
||||
}
|
||||
|
||||
public getPluginSystemStatus(): Promise<ApiRespPluginSystemStatus> {
|
||||
|
||||
Reference in New Issue
Block a user