feat: add verified manual pipeline migration for plugin runners

This commit is contained in:
RockChinQ
2026-09-16 18:13:11 +00:00
parent 273b1ea3cf
commit 8c119bc4b6
97 changed files with 16662 additions and 123 deletions
+14 -2
View File
@@ -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) {
+34 -6
View File
@@ -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;
}
+31 -6
View File
@@ -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[];
}
+19 -2
View File
@@ -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> {
+4
View File
@@ -1,4 +1,7 @@
import pipelineMigration from './pipeline-migration/en-US';
const enUS = {
pipelineMigration,
sidebar: {
home: 'Home',
extensions: 'Extensions',
@@ -260,6 +263,7 @@ const enUS = {
reasoningAbility: 'Reasoning',
reasoningLevel: 'Reasoning level',
reasoningLevels: {
useModelSetting: 'Use model setting',
providerDefault: 'Provider default',
disabled: 'Off',
enabled: 'On',
+4
View File
@@ -1,4 +1,7 @@
import pipelineMigration from './pipeline-migration/es-ES';
const esES = {
pipelineMigration,
sidebar: {
home: 'Inicio',
extensions: 'Extensiones',
@@ -261,6 +264,7 @@ const esES = {
reasoningAbility: 'Razonamiento',
reasoningLevel: 'Nivel de razonamiento',
reasoningLevels: {
useModelSetting: 'Usar ajuste del modelo',
providerDefault: 'Predeterminado del proveedor',
disabled: 'Desactivado',
enabled: 'Activado',
+4
View File
@@ -1,4 +1,7 @@
import pipelineMigration from './pipeline-migration/ja-JP';
const jaJP = {
pipelineMigration,
sidebar: {
home: 'ホーム',
extensions: '拡張機能',
@@ -265,6 +268,7 @@ const jaJP = {
reasoningAbility: '推論',
reasoningLevel: '推論レベル',
reasoningLevels: {
useModelSetting: 'モデル設定を使用',
providerDefault: 'Provider デフォルト',
disabled: 'オフ',
enabled: 'オン',
@@ -0,0 +1,88 @@
export default {
activationRetryHint:
'After checking the runtime, refresh, select this pipeline and confirm to retry activation only. Its saved configuration will not be migrated again.',
notices: {
legacyArchive:
'The active configuration will contain only the selected runner. All original runner settings, including inactive ones, are kept in the migration backup.',
contextDefaults:
'History will use the new context budget and summarization defaults instead of a fixed round limit.',
modelReasoning:
'Per-model reasoning settings are preserved and applied by the host.',
serialTools: 'Tool calls remain sequential after migration.',
retrievalDefaults:
'Retrieval uses the new top-k and result limits. Review them after migration.',
boxReset:
'Existing Box session state is not transferred; a new isolated session will be created.',
persistentHistory:
'New conversations use persistent, isolated history. Existing remote history is not imported.',
tweaksDefault: 'Langflow tweaks default to an empty object.',
timeoutDefault: 'Dify uses a 30-second request timeout by default.',
historyReset:
'Existing conversation history and session identifiers are not transferred. New conversations start after migration.',
sessionTitle: 'New WeKnora sessions use titles generated by the plugin.',
aliasRepaired: 'The old field name is mapped to its supported replacement.',
nullDefault: 'This empty value uses the new plugin default.',
outputPolicy:
'The existing reasoning-output visibility setting is preserved.',
filteredVariables:
'Only supported variables are transferred; reserved variables are supplied by the conversation context.',
identityPreserved: 'The provider user identity keeps its legacy source.',
newDefaults:
'New plugin options use their documented defaults; existing supported values are retained.',
externalState:
'Complete or cancel pending remote interactions before migration; their live state is not transferred.',
pluginVersion:
'Install the required plugin version, then refresh. Older versions are not compatible with this migration.',
schemaChanged:
'The installed runner configuration does not match the migration target. Check the plugin version and refresh.',
runnerExcluded:
'This pipeline excludes the required runner plugin. Update its extension settings first.',
boxScope:
'A custom Box session template cannot be migrated safely. Remove it or review the isolation requirements first.',
pendingInteraction:
'A conversation is waiting for input. Complete or cancel it before migration.',
},
title: 'Pipeline migration',
description:
'Nothing changes until you select pipelines and explicitly confirm. Migration preserves pipeline identity and unrelated settings.',
detected: '{{count}} pipelines need migration review.',
review: 'Review migration',
readOnly: 'Only workspace managers can migrate pipelines.',
previewError: 'Preview unavailable. Refresh to check again.',
submitting: 'Submitting selected pipelines…',
running:
'Migration is running. Closing this dialog does not cancel the task.',
finished: 'Task finished. Check each pipeline result below.',
failed: 'Task failed. Check each pipeline result below.',
lost: 'Task observation lost. Its outcome is unknown; refresh the preview before any further action.',
requestError:
'Request not completed. Refresh the preview before selecting again.',
warningFallback: 'Review this setting before migration.',
blockerFallback:
'This setting or runtime condition cannot be migrated safely. Resolve it and refresh the preview.',
changedFields: 'Changed fields',
activationHint:
'Configuration was saved, but activation is pending. Ask a workspace administrator to check runtime availability, then refresh. Do not rerun migration blindly.',
pluginHint:
'Install or enable the required runner plugin through Extensions / Extension Market. Review your workspace extension quota there; this assistant never installs plugins. Return here and refresh the preview.',
extensions: 'Open Extensions',
results: 'Per-pipeline results',
selection: '{{count}} selected (maximum 50)',
confirm: 'I confirm migration of the selected pipelines.',
refresh: 'Refresh preview',
execute: 'Migrate selected',
legacyGate:
'Legacy configuration is read-only until migration. Save and debug are unavailable to prevent implicit conversion.',
states: {
ready: 'Ready',
needs_plugin: 'Plugin required',
blocked: 'Blocked',
already_current: 'Already current',
not_legacy: 'Not legacy',
activation_pending: 'Activation pending',
pending: 'Pending',
migrated: 'Migrated',
stale: 'Stale preview',
failed: 'Failed',
},
};
@@ -0,0 +1,93 @@
export default {
activationRetryHint:
'Tras comprobar el entorno, actualiza, selecciona esta canalización y confirma para reintentar solo la activación. No se volverá a migrar la configuración guardada.',
notices: {
legacyArchive:
'La configuración activa contendrá solo el Runner seleccionado. Todos los ajustes anteriores, incluidos los inactivos, se conservan en la copia de seguridad de migración.',
contextDefaults:
'El historial usará el nuevo presupuesto de contexto y los valores de resumen, en lugar de un límite fijo de turnos.',
modelReasoning:
'Se conservan los ajustes de razonamiento por modelo y el host los aplica.',
serialTools: 'Las herramientas seguirán ejecutándose de forma secuencial.',
retrievalDefaults:
'La recuperación usará los nuevos límites de top-k y longitud. Revísalos después de migrar.',
boxReset:
'No se transfiere el estado de Box; se creará una nueva sesión aislada.',
persistentHistory:
'Las nuevas conversaciones tendrán un historial persistente y aislado. No se importa el historial remoto anterior.',
tweaksDefault:
'Langflow tweaks usa un objeto vacío de forma predeterminada.',
timeoutDefault:
'Dify usa un tiempo de espera de 30 segundos de forma predeterminada.',
historyReset:
'No se transfieren el historial ni los identificadores de sesión. Después de migrar se iniciarán conversaciones nuevas.',
sessionTitle:
'Las nuevas sesiones de WeKnora usan títulos generados por el plugin.',
aliasRepaired:
'El nombre antiguo del campo se sustituye por el nombre admitido.',
nullDefault:
'Este valor vacío usará el valor predeterminado del nuevo plugin.',
outputPolicy:
'Se conserva la visibilidad actual del contenido de razonamiento.',
filteredVariables:
'Solo se transfieren variables admitidas; el contexto de la conversación proporciona las variables reservadas.',
identityPreserved:
'La identidad de usuario del proveedor conserva su origen anterior.',
newDefaults:
'Las opciones nuevas usan los valores predeterminados documentados; se conservan los valores anteriores admitidos.',
externalState:
'Completa o cancela las interacciones remotas pendientes antes de migrar; su estado activo no se transfiere.',
pluginVersion:
'Instala la versión requerida del plugin y actualiza. Las versiones anteriores no admiten esta migración.',
schemaChanged:
'La configuración del Runner instalado no coincide con el destino. Revisa la versión del plugin y actualiza.',
runnerExcluded:
'Esta canalización excluye el plugin Runner necesario. Ajusta primero la configuración de extensiones.',
boxScope:
'La plantilla personalizada de sesión de Box no se puede migrar de forma segura. Elimínala o revisa los requisitos de aislamiento.',
pendingInteraction:
'Hay una conversación esperando una respuesta. Complétala o cancélala antes de migrar.',
},
title: 'Migración de pipelines',
description:
'No se cambia nada hasta seleccionar pipelines y confirmar explícitamente. Se conservan su identidad y los ajustes no relacionados.',
detected: '{{count}} pipelines requieren revisión.',
review: 'Revisar migración',
readOnly: 'Solo los gestores del espacio pueden migrar pipelines.',
previewError: 'Vista previa no disponible. Actualícela.',
submitting: 'Enviando pipelines seleccionados…',
running: 'Migración en curso. Cerrar el diálogo no cancela la tarea.',
finished: 'Tarea finalizada. Revise el resultado de cada pipeline.',
failed: 'La tarea falló. Revise el resultado de cada pipeline.',
lost: 'Se perdió el seguimiento de la tarea; se desconoce el resultado. Actualice la vista previa antes de actuar.',
requestError:
'Solicitud no completada. Actualice la vista previa y vuelva a seleccionar.',
warningFallback: 'Revise este ajuste antes de migrar.',
blockerFallback:
'Este ajuste o estado de ejecución no se puede migrar de forma segura. Resuélvalo y actualice la vista previa.',
changedFields: 'Campos modificados',
activationHint:
'Configuración guardada, pero activación pendiente. Pida al administrador que compruebe el entorno y actualice. No repita la migración a ciegas.',
pluginHint:
'Instale o active el plugin requerido desde Extensiones / Mercado de extensiones y compruebe la cuota del espacio. Este asistente nunca instala plugins. Al volver, actualice la vista previa.',
extensions: 'Abrir Extensiones',
results: 'Resultados por pipeline',
selection: '{{count}} seleccionados (máximo 50)',
confirm: 'Confirmo la migración de los pipelines seleccionados.',
refresh: 'Actualizar vista previa',
execute: 'Migrar seleccionados',
legacyGate:
'La configuración antigua es de solo lectura hasta la migración. Guardar y depurar no están disponibles para evitar una conversión implícita.',
states: {
ready: 'Listo',
needs_plugin: 'Plugin requerido',
blocked: 'Bloqueado',
already_current: 'Ya actualizado',
not_legacy: 'No es antiguo',
activation_pending: 'Activación pendiente',
pending: 'Pendiente',
migrated: 'Migrado',
stale: 'Vista previa caducada',
failed: 'Fallido',
},
};
@@ -0,0 +1,87 @@
export default {
activationRetryHint:
'実行環境を確認して再読み込みし、このパイプラインを選択して確定すると、有効化のみを再試行します。保存済み設定は再移行しません。',
notices: {
legacyArchive:
'有効な設定には選択した Runner のみを残します。未使用のものを含むすべての旧 Runner 設定は移行バックアップに保存します。',
contextDefaults:
'履歴には固定の往復数制限ではなく、新しいコンテキスト予算と要約の既定値を適用します。',
modelReasoning: 'モデル別の推論設定を保持し、ホスト側で適用します。',
serialTools: '移行後もツール呼び出しは順番に実行します。',
retrievalDefaults:
'検索には新しい top-k と結果長の既定値を適用します。移行後に確認してください。',
boxReset:
'既存の Box セッション状態は引き継がず、新しい分離セッションを作成します。',
persistentHistory:
'新しい会話の履歴は分離して永続化します。既存のリモート履歴は取り込みません。',
tweaksDefault: 'Langflow tweaks の既定値は空のオブジェクトです。',
timeoutDefault: 'Dify のリクエストタイムアウトは既定で 30 秒です。',
historyReset:
'既存の会話履歴とセッション ID は引き継ぎません。移行後は新しい会話を開始します。',
sessionTitle:
'新しい WeKnora セッションにはプラグインが生成したタイトルを使用します。',
aliasRepaired: '旧フィールド名を現在サポートされている名前に置き換えます。',
nullDefault: 'この空の値には新しいプラグインの既定値を使用します。',
outputPolicy: '推論内容の表示設定は保持します。',
filteredVariables:
'対応する変数のみ移行します。予約変数は会話コンテキストから提供します。',
identityPreserved:
'サービス側のユーザー識別子には従来と同じ情報源を使用します。',
newDefaults:
'追加された設定には文書化された既定値を使い、対応する既存の値は保持します。',
externalState:
'移行前に待機中のリモート操作を完了またはキャンセルしてください。実行状態は引き継ぎません。',
pluginVersion:
'必要なバージョンのプラグインをインストールして再読み込みしてください。旧バージョンは移行に対応していません。',
schemaChanged:
'インストール済み Runner の設定形式が移行先と一致しません。プラグインのバージョンを確認してください。',
runnerExcluded:
'このパイプラインでは必要な Runner プラグインが除外されています。拡張機能の設定を変更してください。',
boxScope:
'独自の Box セッションテンプレートは安全に移行できません。削除するか、分離要件を確認してください。',
pendingInteraction:
'入力待ちの会話があります。完了またはキャンセルしてから移行してください。',
},
title: 'パイプライン移行',
description:
'パイプラインを選択し、明示的に確認するまで変更されません。識別情報と無関係な設定は保持されます。',
detected: '{{count}} 件のパイプラインに移行確認が必要です。',
review: '移行を確認',
readOnly: '移行できるのはワークスペースの管理権限を持つユーザーのみです。',
previewError: 'プレビューを取得できません。更新してください。',
submitting: '選択したパイプラインを送信中…',
running: '移行中です。ダイアログを閉じてもタスクは停止しません。',
finished: 'タスクが終了しました。各パイプラインの結果を確認してください。',
failed: 'タスクが失敗しました。各パイプラインの結果を確認してください。',
lost: 'タスクの追跡が失われ、結果は不明です。次の操作前にプレビューを更新してください。',
requestError:
'リクエストを完了できませんでした。プレビューを更新して再選択してください。',
warningFallback: '移行前にこの設定を確認してください。',
blockerFallback:
'この設定または実行状態は安全に移行できません。解決してプレビューを更新してください。',
changedFields: '変更フィールド',
activationHint:
'設定は保存されましたが、有効化待ちです。管理者に実行環境の確認を依頼してから更新してください。移行を無条件に再実行しないでください。',
pluginHint:
'「拡張機能 / 拡張機能マーケット」で必要なランナープラグインをインストールまたは有効化し、ワークスペースの上限を確認してください。このアシスタントはインストールしません。戻った後にプレビューを更新してください。',
extensions: '拡張機能を開く',
results: 'パイプラインごとの結果',
selection: '{{count}} 件選択(最大 50 件)',
confirm: '選択したパイプラインの移行を確認します。',
refresh: 'プレビューを更新',
execute: '選択項目を移行',
legacyGate:
'旧設定は移行まで読み取り専用です。暗黙の変換を防ぐため、保存とデバッグは使用できません。',
states: {
ready: '移行可能',
needs_plugin: 'プラグインが必要',
blocked: '移行不可',
already_current: '移行済みの形式',
not_legacy: '旧形式ではない',
activation_pending: '有効化待ち',
pending: '待機中',
migrated: '移行済み',
stale: 'プレビューが古い',
failed: '失敗',
},
};
@@ -0,0 +1,90 @@
export default {
activationRetryHint:
'Проверьте среду выполнения, обновите список, выберите конвейер и подтвердите повторную активацию. Сохранённая конфигурация не будет мигрировать повторно.',
notices: {
legacyArchive:
'В активной конфигурации останется только выбранный Runner. Все прежние настройки, включая неиспользуемые, сохраняются в резервной копии миграции.',
contextDefaults:
'Вместо фиксированного числа раундов история будет использовать новый бюджет контекста и настройки суммаризации.',
modelReasoning:
'Настройки рассуждений для каждой модели сохраняются и применяются хостом.',
serialTools: 'Вызовы инструментов останутся последовательными.',
retrievalDefaults:
'Для поиска применяются новые значения top-k и ограничения длины результатов. Проверьте их после миграции.',
boxReset:
'Состояние сеанса Box не переносится; будет создан новый изолированный сеанс.',
persistentHistory:
'Новые беседы используют постоянную изолированную историю. Прежняя удалённая история не импортируется.',
tweaksDefault:
'Для Langflow tweaks по умолчанию используется пустой объект.',
timeoutDefault: 'Тайм-аут запроса Dify по умолчанию составляет 30 секунд.',
historyReset:
'История и идентификаторы сеансов не переносятся. После миграции начнутся новые беседы.',
sessionTitle:
'Новые сеансы WeKnora используют заголовки, созданные плагином.',
aliasRepaired: 'Старое имя поля заменяется поддерживаемым именем.',
nullDefault:
'Для этого пустого значения применяется значение нового плагина по умолчанию.',
outputPolicy: 'Текущая настройка видимости рассуждений сохраняется.',
filteredVariables:
'Переносятся только поддерживаемые переменные; зарезервированные переменные задаются контекстом беседы.',
identityPreserved:
'Источник пользовательского идентификатора у провайдера остаётся прежним.',
newDefaults:
'Новые параметры используют документированные значения по умолчанию; поддерживаемые прежние значения сохраняются.',
externalState:
'До миграции завершите или отмените ожидающие удалённые взаимодействия: их текущее состояние не переносится.',
pluginVersion:
'Установите требуемую версию плагина и обновите список. Старые версии не поддерживают эту миграцию.',
schemaChanged:
'Схема установленного Runner не соответствует целевой. Проверьте версию плагина и обновите список.',
runnerExcluded:
'В этом конвейере исключён нужный плагин Runner. Сначала измените настройки расширений.',
boxScope:
'Пользовательский шаблон сеанса Box нельзя безопасно перенести. Удалите его или проверьте требования изоляции.',
pendingInteraction:
'Беседа ожидает ввода. Завершите или отмените её перед миграцией.',
},
title: 'Миграция конвейеров',
description:
'Изменения выполняются только после выбора конвейеров и явного подтверждения. Идентификаторы и прочие настройки сохраняются.',
detected: '{{count}} конвейеров требуют проверки миграции.',
review: 'Проверить миграцию',
readOnly:
'Миграция доступна только пользователям с правами управления пространством.',
previewError: 'Предпросмотр недоступен. Обновите его.',
submitting: 'Отправка выбранных конвейеров…',
running: 'Выполняется миграция. Закрытие окна не отменяет задачу.',
finished: 'Задача завершена. Проверьте результат каждого конвейера.',
failed: 'Задача завершилась ошибкой. Проверьте результат каждого конвейера.',
lost: 'Наблюдение за задачей потеряно; результат неизвестен. Обновите предпросмотр перед дальнейшими действиями.',
requestError: 'Запрос не завершён. Обновите предпросмотр и выберите заново.',
warningFallback: 'Проверьте эту настройку перед миграцией.',
blockerFallback:
'Эту настройку или состояние среды нельзя безопасно перенести. Устраните проблему и обновите предпросмотр.',
changedFields: 'Изменяемые поля',
activationHint:
'Настройки сохранены, но активация ожидается. Попросите администратора проверить среду и обновите предпросмотр. Не повторяйте миграцию вслепую.',
pluginHint:
'Установите или включите нужный плагин через «Расширения / Маркет» и проверьте квоту пространства. Этот помощник не устанавливает плагины. Вернувшись, обновите предпросмотр.',
extensions: 'Открыть расширения',
results: 'Результаты по конвейерам',
selection: 'Выбрано {{count}} (максимум 50)',
confirm: 'Подтверждаю миграцию выбранных конвейеров.',
refresh: 'Обновить предпросмотр',
execute: 'Перенести выбранные',
legacyGate:
'Старая конфигурация доступна только для чтения до миграции. Сохранение и отладка отключены во избежание неявного преобразования.',
states: {
ready: 'Готов',
needs_plugin: 'Нужен плагин',
blocked: 'Заблокирован',
already_current: 'Уже актуален',
not_legacy: 'Не устаревший',
activation_pending: 'Ожидает активации',
pending: 'Ожидает',
migrated: 'Перенесён',
stale: 'Предпросмотр устарел',
failed: 'Ошибка',
},
};
@@ -0,0 +1,83 @@
export default {
activationRetryHint:
'หลังตรวจสอบสภาพแวดล้อมการทำงาน ให้รีเฟรช เลือกไปป์ไลน์นี้และยืนยันเพื่อลองเปิดใช้งานอีกครั้งเท่านั้น การตั้งค่าที่บันทึกไว้จะไม่ถูกย้ายซ้ำ',
notices: {
legacyArchive:
'การตั้งค่าที่ใช้งานจะเหลือเฉพาะ Runner ที่เลือก การตั้งค่า Runner เดิมทั้งหมด รวมถึงส่วนที่ไม่ได้ใช้งาน จะถูกเก็บไว้ในข้อมูลสำรองการย้าย',
contextDefaults:
'ประวัติจะใช้งบประมาณบริบทและค่าการสรุปใหม่ แทนการจำกัดจำนวนรอบแบบตายตัว',
modelReasoning:
'การตั้งค่าการให้เหตุผลของแต่ละโมเดลจะถูกเก็บไว้และใช้โดยโฮสต์',
serialTools: 'หลังย้ายข้อมูล เครื่องมือยังคงทำงานตามลำดับ',
retrievalDefaults:
'การค้นคืนจะใช้ค่า top-k และขีดจำกัดความยาวผลลัพธ์ใหม่ โปรดตรวจสอบหลังย้ายข้อมูล',
boxReset: 'สถานะเซสชัน Box เดิมจะไม่ถูกย้าย ระบบจะสร้างเซสชันแยกใหม่',
persistentHistory:
'บทสนทนาใหม่จะมีประวัติถาวรที่แยกจากกัน โดยไม่นำเข้าประวัติระยะไกลเดิม',
tweaksDefault: 'Langflow tweaks จะใช้วัตถุว่างเป็นค่าเริ่มต้น',
timeoutDefault: 'Dify ใช้เวลารอคำขอเริ่มต้น 30 วินาที',
historyReset:
'ประวัติและรหัสเซสชันเดิมจะไม่ถูกย้าย ระบบจะเริ่มบทสนทนาใหม่หลังย้ายข้อมูล',
sessionTitle: 'เซสชัน WeKnora ใหม่จะใช้ชื่อที่ปลั๊กอินสร้าง',
aliasRepaired: 'ชื่อฟิลด์เดิมจะเปลี่ยนเป็นชื่อที่รองรับในปัจจุบัน',
nullDefault: 'ค่าว่างนี้จะใช้ค่าเริ่มต้นของปลั๊กอินใหม่',
outputPolicy: 'การตั้งค่าการแสดงเนื้อหาการให้เหตุผลเดิมจะถูกเก็บไว้',
filteredVariables:
'ย้ายเฉพาะตัวแปรที่รองรับ ส่วนตัวแปรสงวนจะมาจากบริบทการสนทนา',
identityPreserved: 'ข้อมูลระบุตัวผู้ใช้ของผู้ให้บริการยังใช้แหล่งเดิม',
newDefaults: 'ตัวเลือกใหม่ใช้ค่าเริ่มต้นตามเอกสาร และคงค่าเดิมที่รองรับไว้',
externalState:
'โปรดดำเนินการหรือยกเลิกการโต้ตอบระยะไกลที่ค้างอยู่ก่อนย้ายข้อมูล สถานะที่กำลังทำงานจะไม่ถูกย้าย',
pluginVersion:
'ติดตั้งปลั๊กอินเวอร์ชันที่กำหนดแล้วรีเฟรช เวอร์ชันเก่าไม่รองรับการย้ายข้อมูลนี้',
schemaChanged:
'รูปแบบการตั้งค่า Runner ที่ติดตั้งไม่ตรงกับเป้าหมาย โปรดตรวจสอบเวอร์ชันปลั๊กอินแล้วรีเฟรช',
runnerExcluded:
'ไปป์ไลน์นี้ยกเว้นปลั๊กอิน Runner ที่จำเป็น โปรดแก้ไขการตั้งค่าส่วนขยายก่อน',
boxScope:
'ไม่สามารถย้ายเทมเพลตเซสชัน Box แบบกำหนดเองได้อย่างปลอดภัย โปรดลบหรือทบทวนข้อกำหนดการแยกเซสชัน',
pendingInteraction:
'มีบทสนทนาที่รอข้อมูลอยู่ โปรดดำเนินการให้เสร็จหรือยกเลิกก่อนย้ายข้อมูล',
},
title: 'ย้ายไปป์ไลน์',
description:
'จะไม่มีการเปลี่ยนแปลงจนกว่าคุณจะเลือกไปป์ไลน์และยืนยันอย่างชัดเจน โดยคงตัวตนและการตั้งค่าอื่นไว้',
detected: 'มี {{count}} ไปป์ไลน์ที่ต้องตรวจสอบการย้าย',
review: 'ตรวจสอบการย้าย',
readOnly: 'เฉพาะผู้มีสิทธิ์จัดการพื้นที่ทำงานเท่านั้นที่ย้ายไปป์ไลน์ได้',
previewError: 'ไม่สามารถดูตัวอย่างได้ โปรดรีเฟรช',
submitting: 'กำลังส่งไปป์ไลน์ที่เลือก…',
running: 'กำลังย้าย การปิดหน้าต่างไม่ยกเลิกงาน',
finished: 'งานสิ้นสุดแล้ว โปรดตรวจสอบผลลัพธ์ของแต่ละไปป์ไลน์',
failed: 'งานล้มเหลว โปรดตรวจสอบผลลัพธ์ของแต่ละไปป์ไลน์',
lost: 'ไม่สามารถติดตามงานได้ ไม่ทราบผลลัพธ์ โปรดรีเฟรชตัวอย่างก่อนดำเนินการต่อ',
requestError: 'คำขอไม่เสร็จสมบูรณ์ โปรดรีเฟรชตัวอย่างและเลือกใหม่',
warningFallback: 'ตรวจสอบการตั้งค่านี้ก่อนย้าย',
blockerFallback:
'ไม่สามารถย้ายการตั้งค่าหรือสถานะรันนี้ได้อย่างปลอดภัย โปรดแก้ไขแล้วรีเฟรชตัวอย่าง',
changedFields: 'ฟิลด์ที่เปลี่ยน',
activationHint:
'บันทึกการตั้งค่าแล้วแต่รอเปิดใช้งาน โปรดให้ผู้ดูแลตรวจสอบสภาพแวดล้อมแล้วรีเฟรช อย่าย้ายซ้ำโดยไม่ตรวจสอบ',
pluginHint:
'ติดตั้งหรือเปิดใช้ปลั๊กอินรันเนอร์ผ่านส่วนขยาย / ตลาดส่วนขยาย และตรวจสอบโควตาพื้นที่ทำงานด้วยตนเอง ผู้ช่วยนี้ไม่ติดตั้งปลั๊กอิน เมื่อกลับมาโปรดรีเฟรชตัวอย่าง',
extensions: 'เปิดส่วนขยาย',
results: 'ผลลัพธ์แต่ละไปป์ไลน์',
selection: 'เลือก {{count}} รายการ (สูงสุด 50)',
confirm: 'ฉันยืนยันการย้ายไปป์ไลน์ที่เลือก',
refresh: 'รีเฟรชตัวอย่าง',
execute: 'ย้ายรายการที่เลือก',
legacyGate:
'การตั้งค่าเดิมอ่านได้อย่างเดียวจนกว่าจะย้าย ปิดการบันทึกและดีบักเพื่อป้องกันการแปลงโดยไม่ตั้งใจ',
states: {
ready: 'พร้อม',
needs_plugin: 'ต้องการปลั๊กอิน',
blocked: 'ถูกบล็อก',
already_current: 'เป็นรุ่นใหม่แล้ว',
not_legacy: 'ไม่ใช่รุ่นเก่า',
activation_pending: 'รอเปิดใช้งาน',
pending: 'รอดำเนินการ',
migrated: 'ย้ายแล้ว',
stale: 'ตัวอย่างล้าสมัย',
failed: 'ล้มเหลว',
},
};
@@ -0,0 +1,87 @@
export default {
activationRetryHint:
'Sau khi kiểm tra môi trường chạy, làm mới, chọn pipeline này và xác nhận để chỉ thử kích hoạt lại. Cấu hình đã lưu sẽ không được di chuyển lần nữa.',
notices: {
legacyArchive:
'Cấu hình hoạt động chỉ giữ Runner đã chọn. Tất cả thiết lập Runner cũ, kể cả các thiết lập không dùng, được giữ trong bản sao lưu di chuyển.',
contextDefaults:
'Lịch sử sẽ dùng ngân sách ngữ cảnh và thiết lập tóm tắt mới thay vì giới hạn số lượt cố định.',
modelReasoning:
'Thiết lập suy luận của từng mô hình được giữ lại và do máy chủ áp dụng.',
serialTools: 'Các công cụ vẫn được gọi lần lượt sau khi di chuyển.',
retrievalDefaults:
'Truy xuất dùng giới hạn top-k và độ dài kết quả mới. Hãy kiểm tra sau khi di chuyển.',
boxReset:
'Trạng thái phiên Box hiện tại không được chuyển; một phiên cách ly mới sẽ được tạo.',
persistentHistory:
'Hội thoại mới dùng lịch sử bền vững và riêng biệt. Lịch sử từ xa cũ không được nhập.',
tweaksDefault: 'Langflow tweaks mặc định là một đối tượng rỗng.',
timeoutDefault: 'Dify mặc định có thời gian chờ yêu cầu là 30 giây.',
historyReset:
'Lịch sử và mã phiên cũ không được chuyển. Hội thoại mới sẽ bắt đầu sau khi di chuyển.',
sessionTitle: 'Phiên WeKnora mới dùng tiêu đề do plugin tạo.',
aliasRepaired: 'Tên trường cũ được ánh xạ sang tên đang được hỗ trợ.',
nullDefault: 'Giá trị trống này sẽ dùng giá trị mặc định của plugin mới.',
outputPolicy: 'Thiết lập hiển thị nội dung suy luận hiện tại được giữ lại.',
filteredVariables:
'Chỉ chuyển các biến được hỗ trợ; biến dành riêng do ngữ cảnh hội thoại cung cấp.',
identityPreserved:
'Danh tính người dùng phía nhà cung cấp giữ nguyên nguồn cũ.',
newDefaults:
'Tùy chọn mới dùng giá trị mặc định đã mô tả; các giá trị cũ được hỗ trợ vẫn được giữ.',
externalState:
'Hoàn tất hoặc hủy các tương tác từ xa đang chờ trước khi di chuyển; trạng thái đang chạy không được chuyển.',
pluginVersion:
'Cài phiên bản plugin được yêu cầu rồi làm mới. Phiên bản cũ không hỗ trợ lần di chuyển này.',
schemaChanged:
'Cấu hình Runner đã cài không khớp với đích di chuyển. Kiểm tra phiên bản plugin rồi làm mới.',
runnerExcluded:
'Pipeline này loại trừ plugin Runner cần thiết. Hãy chỉnh thiết lập tiện ích trước.',
boxScope:
'Mẫu phiên Box tùy chỉnh không thể di chuyển an toàn. Hãy xóa mẫu hoặc kiểm tra yêu cầu cách ly.',
pendingInteraction:
'Có hội thoại đang chờ nhập liệu. Hãy hoàn tất hoặc hủy trước khi di chuyển.',
},
title: 'Di chuyển pipeline',
description:
'Chỉ thay đổi sau khi bạn chọn pipeline và xác nhận rõ ràng. Danh tính và các cài đặt không liên quan được giữ nguyên.',
detected: '{{count}} pipeline cần xem xét di chuyển.',
review: 'Xem xét di chuyển',
readOnly:
'Chỉ người có quyền quản lý không gian mới được di chuyển pipeline.',
previewError: 'Không lấy được bản xem trước. Hãy làm mới.',
submitting: 'Đang gửi các pipeline đã chọn…',
running: 'Đang di chuyển. Đóng hộp thoại không hủy tác vụ.',
finished: 'Tác vụ đã kết thúc. Hãy kiểm tra kết quả từng pipeline.',
failed: 'Tác vụ thất bại. Hãy kiểm tra kết quả từng pipeline.',
lost: 'Mất theo dõi tác vụ; chưa biết kết quả. Hãy làm mới bản xem trước trước khi thao tác tiếp.',
requestError: 'Yêu cầu chưa hoàn tất. Làm mới bản xem trước rồi chọn lại.',
warningFallback: 'Kiểm tra cài đặt này trước khi di chuyển.',
blockerFallback:
'Không thể di chuyển an toàn cài đặt hoặc trạng thái chạy này. Hãy xử lý rồi làm mới bản xem trước.',
changedFields: 'Trường thay đổi',
activationHint:
'Cấu hình đã lưu nhưng đang chờ kích hoạt. Nhờ quản trị viên kiểm tra môi trường rồi làm mới. Không chạy lại di chuyển một cách mù quáng.',
pluginHint:
'Cài hoặc bật plugin runner cần thiết qua Tiện ích / Chợ tiện ích và kiểm tra hạn mức không gian. Trợ lý này không cài plugin. Khi quay lại, hãy làm mới bản xem trước.',
extensions: 'Mở Tiện ích',
results: 'Kết quả từng pipeline',
selection: 'Đã chọn {{count}} (tối đa 50)',
confirm: 'Tôi xác nhận di chuyển các pipeline đã chọn.',
refresh: 'Làm mới bản xem trước',
execute: 'Di chuyển đã chọn',
legacyGate:
'Cấu hình cũ chỉ đọc cho đến khi di chuyển. Lưu và gỡ lỗi bị tắt để tránh chuyển đổi ngầm.',
states: {
ready: 'Sẵn sàng',
needs_plugin: 'Cần plugin',
blocked: 'Bị chặn',
already_current: 'Đã cập nhật',
not_legacy: 'Không phải bản cũ',
activation_pending: 'Chờ kích hoạt',
pending: 'Đang chờ',
migrated: 'Đã di chuyển',
stale: 'Bản xem trước đã cũ',
failed: 'Thất bại',
},
};
@@ -0,0 +1,72 @@
export default {
activationRetryHint:
'检查运行环境后刷新,选中此流水线并确认,即可仅重试激活,不会再次迁移已保存的配置。',
notices: {
legacyArchive:
'活动配置仅保留所选 Runner;全部旧 Runner 配置(包括未启用的配置)保存在迁移备份中。',
contextDefaults:
'历史上下文将采用新的容量预算与摘要默认值,不再按固定轮数截断。',
modelReasoning: '各模型的推理设置会保留,并由主程序应用。',
serialTools: '迁移后工具调用仍按顺序执行。',
retrievalDefaults: '检索采用新的 top-k 与结果长度默认值,请在迁移后检查。',
boxReset: '现有 Box 会话状态不迁移,后续将创建新的隔离会话。',
persistentHistory:
'新会话采用持久化、相互隔离的历史记录,不导入原有远端历史。',
tweaksDefault: 'Langflow tweaks 默认设为空对象。',
timeoutDefault: 'Dify 请求超时默认设为 30 秒。',
historyReset: '原有会话历史和会话标识不迁移,迁移后开始新会话。',
sessionTitle: '新的 WeKnora 会话使用插件生成的标题。',
aliasRepaired: '旧字段名称将映射为当前支持的名称。',
nullDefault: '此空值将使用新插件的默认值。',
outputPolicy: '现有推理内容显示设置会保留。',
filteredVariables: '仅迁移支持的变量;保留变量由会话上下文提供。',
identityPreserved: '服务端用户身份沿用旧配置的来源。',
newDefaults: '新增插件选项采用已说明的默认值,原有受支持配置会保留。',
externalState: '迁移前请完成或取消等待中的远端交互,其运行状态不会转移。',
pluginVersion: '请安装所需插件版本后刷新。旧版本不支持此次迁移。',
schemaChanged:
'已安装 Runner 的配置格式与迁移目标不一致,请检查插件版本后刷新。',
runnerExcluded: '此流水线排除了所需 Runner 插件,请先调整扩展设置。',
boxScope: '自定义 Box 会话模板无法安全迁移,请先移除或检查隔离要求。',
pendingInteraction: '有会话正在等待输入,请完成或取消后再迁移。',
},
title: '流水线迁移',
description:
'仅在选择流水线并明确确认后才会更改配置。迁移保留流水线身份及无关设置。',
detected: '{{count}} 条流水线需要检查迁移。',
review: '检查迁移',
readOnly: '只有工作空间管理者可以迁移流水线。',
previewError: '无法获取预览,请刷新重试。',
submitting: '正在提交所选流水线…',
running: '正在迁移。关闭对话框不会取消任务。',
finished: '任务已结束,请逐条检查下方结果。',
failed: '任务失败,请逐条检查下方结果。',
lost: '已失去任务状态连接,结果未知。执行其他操作前请刷新预览。',
requestError: '请求未完成。请刷新预览后重新选择。',
warningFallback: '迁移前请检查此设置。',
blockerFallback: '此设置或运行状态无法安全迁移。请处理后刷新预览。',
changedFields: '变更字段',
activationHint:
'配置已保存,但尚未激活。请工作空间管理员检查运行环境后刷新,不要盲目重复迁移。',
pluginHint:
'请通过「扩展 / 扩展市场」安装或启用所需的运行器插件,并自行检查工作空间扩展配额。本助手不会安装插件。返回此处后请刷新预览。',
extensions: '打开扩展',
results: '逐条迁移结果',
selection: '已选择 {{count}} 条(最多 50 条)',
confirm: '我确认迁移所选流水线。',
refresh: '刷新预览',
execute: '迁移所选流水线',
legacyGate: '旧版配置在迁移前保持只读。为避免隐式转换,已禁用保存和调试。',
states: {
ready: '可迁移',
needs_plugin: '需要插件',
blocked: '已阻止',
already_current: '已是新版',
not_legacy: '非旧版',
activation_pending: '等待激活',
pending: '等待中',
migrated: '已迁移',
stale: '预览已过期',
failed: '失败',
},
};
@@ -0,0 +1,72 @@
export default {
activationRetryHint:
'檢查執行環境後重新整理,選取此流水線並確認,即可只重試啟用,不會再次遷移已儲存的設定。',
notices: {
legacyArchive:
'作用中的設定僅保留所選 Runner;全部舊 Runner 設定(包括未啟用的設定)保存在遷移備份中。',
contextDefaults:
'歷史上下文將採用新的容量預算與摘要預設值,不再按固定輪數截斷。',
modelReasoning: '各模型的推理設定會保留,並由主程式套用。',
serialTools: '遷移後工具呼叫仍依序執行。',
retrievalDefaults: '檢索採用新的 top-k 與結果長度預設值,請在遷移後檢查。',
boxReset: '現有 Box 工作階段狀態不遷移,後續將建立新的隔離工作階段。',
persistentHistory:
'新對話採用持久化、相互隔離的歷史記錄,不匯入原有遠端歷史。',
tweaksDefault: 'Langflow tweaks 預設為空物件。',
timeoutDefault: 'Dify 請求逾時預設為 30 秒。',
historyReset: '原有對話歷史與工作階段識別碼不遷移,遷移後開始新對話。',
sessionTitle: '新的 WeKnora 對話使用外掛產生的標題。',
aliasRepaired: '舊欄位名稱將對應至目前支援的名稱。',
nullDefault: '此空值將使用新外掛的預設值。',
outputPolicy: '現有推理內容顯示設定會保留。',
filteredVariables: '僅遷移支援的變數;保留變數由對話上下文提供。',
identityPreserved: '服務端使用者身分沿用舊設定的來源。',
newDefaults: '新增外掛選項採用已說明的預設值,原有受支援設定會保留。',
externalState: '遷移前請完成或取消等待中的遠端互動,其執行狀態不會轉移。',
pluginVersion: '請安裝所需外掛版本後重新整理。舊版本不支援此次遷移。',
schemaChanged:
'已安裝 Runner 的設定格式與遷移目標不一致,請檢查外掛版本後重新整理。',
runnerExcluded: '此流水線排除了所需 Runner 外掛,請先調整擴充設定。',
boxScope: '自訂 Box 工作階段範本無法安全遷移,請先移除或檢查隔離要求。',
pendingInteraction: '有對話正在等待輸入,請完成或取消後再遷移。',
},
title: '流水線遷移',
description:
'僅在選擇流水線並明確確認後才會變更設定。遷移保留流水線身分及無關設定。',
detected: '{{count}} 條流水線需要檢查遷移。',
review: '檢查遷移',
readOnly: '只有工作空間管理者可以遷移流水線。',
previewError: '無法取得預覽,請重新整理。',
submitting: '正在提交所選流水線…',
running: '正在遷移。關閉對話框不會取消任務。',
finished: '任務已結束,請逐條檢查下方結果。',
failed: '任務失敗,請逐條檢查下方結果。',
lost: '已失去任務狀態連線,結果未知。執行其他操作前請重新整理預覽。',
requestError: '請求未完成。請重新整理預覽後重新選擇。',
warningFallback: '遷移前請檢查此設定。',
blockerFallback: '此設定或執行狀態無法安全遷移。請處理後重新整理預覽。',
changedFields: '變更欄位',
activationHint:
'設定已儲存,但尚未啟用。請工作空間管理員檢查執行環境後重新整理,不要盲目重複遷移。',
pluginHint:
'請透過「擴充功能 / 擴充功能市場」安裝或啟用所需執行器外掛,並自行檢查工作空間配額。本助手不會安裝外掛。返回後請重新整理預覽。',
extensions: '開啟擴充功能',
results: '逐條遷移結果',
selection: '已選擇 {{count}} 條(最多 50 條)',
confirm: '我確認遷移所選流水線。',
refresh: '重新整理預覽',
execute: '遷移所選流水線',
legacyGate: '舊版設定在遷移前保持唯讀。為避免隱式轉換,已停用儲存和除錯。',
states: {
ready: '可遷移',
needs_plugin: '需要外掛',
blocked: '已阻止',
already_current: '已是新版',
not_legacy: '非舊版',
activation_pending: '等待啟用',
pending: '等待中',
migrated: '已遷移',
stale: '預覽已過期',
failed: '失敗',
},
};
+4
View File
@@ -1,4 +1,7 @@
import pipelineMigration from './pipeline-migration/ru-RU';
const ruRU = {
pipelineMigration,
sidebar: {
home: 'Главная',
extensions: 'Расширения',
@@ -257,6 +260,7 @@ const ruRU = {
reasoningAbility: 'Рассуждение',
reasoningLevel: 'Уровень рассуждений',
reasoningLevels: {
useModelSetting: 'Настройка модели',
providerDefault: 'По умолчанию провайдера',
disabled: 'Выключено',
enabled: 'Включено',
+4
View File
@@ -1,4 +1,7 @@
import pipelineMigration from './pipeline-migration/th-TH';
const thTH = {
pipelineMigration,
sidebar: {
home: 'หน้าแรก',
extensions: 'ส่วนขยาย',
@@ -252,6 +255,7 @@ const thTH = {
reasoningAbility: 'ความสามารถในการให้เหตุผล',
reasoningLevel: 'ระดับการให้เหตุผล',
reasoningLevels: {
useModelSetting: 'ใช้การตั้งค่าโมเดล',
providerDefault: 'ค่าเริ่มต้นของผู้ให้บริการ',
disabled: 'ปิด',
enabled: 'เปิด',
+4
View File
@@ -1,4 +1,7 @@
import pipelineMigration from './pipeline-migration/vi-VN';
const viVN = {
pipelineMigration,
sidebar: {
home: 'Trang chủ',
extensions: 'Tiện ích mở rộng',
@@ -256,6 +259,7 @@ const viVN = {
reasoningAbility: 'Khả năng suy luận',
reasoningLevel: 'Mức độ suy luận',
reasoningLevels: {
useModelSetting: 'Dùng cài đặt mô hình',
providerDefault: 'Mặc định của nhà cung cấp',
disabled: 'Tắt',
enabled: 'Bật',
+4
View File
@@ -1,4 +1,7 @@
import pipelineMigration from './pipeline-migration/zh-Hans';
const zhHans = {
pipelineMigration,
sidebar: {
home: '首页',
extensions: '扩展',
@@ -250,6 +253,7 @@ const zhHans = {
reasoningAbility: '思考能力',
reasoningLevel: '思考档位',
reasoningLevels: {
useModelSetting: '使用模型设置',
providerDefault: 'Provider 默认',
disabled: '关闭',
enabled: '开启',
+4
View File
@@ -1,4 +1,7 @@
import pipelineMigration from './pipeline-migration/zh-Hant';
const zhHant = {
pipelineMigration,
sidebar: {
home: '首頁',
extensions: '擴展',
@@ -244,6 +247,7 @@ const zhHant = {
reasoningAbility: '思考能力',
reasoningLevel: '思考等級',
reasoningLevels: {
useModelSetting: '使用模型設定',
providerDefault: '供應商預設',
disabled: '關閉',
enabled: '開啟',