feat: delegate sandbox policy to runners and simplify pipeline migration

This commit is contained in:
RockChinQ
2026-09-17 23:05:27 +08:00
parent 8c119bc4b6
commit b264d46d77
57 changed files with 2111 additions and 1751 deletions
@@ -231,7 +231,7 @@ export default function PluginProcessorBindings({
return (
<Card
key={binding.processor_uuid}
className="gap-0 rounded-lg py-0 shadow-none hover:bg-accent"
className="gap-0 rounded-lg py-0 shadow-none"
>
<CardContent className="flex items-center gap-3 p-3">
<span
@@ -4,6 +4,7 @@ import {
IFileConfig,
} from '@/app/infra/entities/form/dynamic';
import StructuredFieldEditor from './StructuredFieldEditor';
import PresetSelect from './PresetSelect';
import { isSimplePrompt } from './StructuredFieldValue';
import { Input } from '@/components/ui/input';
import {
@@ -544,6 +545,9 @@ export default function DynamicFormItemComponent({
);
case DynamicFormItemType.SELECT:
if (config.allow_custom) {
return <PresetSelect config={config} field={field} />;
}
const selectedOption = config.options?.find(
(option) => option.name === field.value,
);
@@ -16,6 +16,7 @@ export class DynamicFormItemConfig implements IDynamicFormItemSchema {
type: DynamicFormItemType;
description?: I18nObject;
options?: IDynamicFormItemOption[];
allow_custom?: boolean;
show_if?: IShowIfCondition;
login_platform?: string;
url?: string;
@@ -32,6 +33,7 @@ export class DynamicFormItemConfig implements IDynamicFormItemSchema {
this.type = params.type;
this.description = params.description;
this.options = params.options;
this.allow_custom = params.allow_custom;
this.show_if = params.show_if;
this.login_platform = params.login_platform;
this.url = params.url;
@@ -0,0 +1,77 @@
import { useState } from 'react';
import { ControllerRenderProps } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
export default function PresetSelect({
config,
field,
}: {
config: IDynamicFormItemSchema;
field: ControllerRenderProps;
}) {
const { t } = useTranslation();
const options = config.options?.filter((option) => option.name.trim()) ?? [];
const presetIndex = options.findIndex(
(option) => option.name === field.value,
);
// Selecting custom keeps the existing template until the user edits it.
const [editingValue, setEditingValue] = useState<string | null>(null);
const custom = presetIndex < 0 || editingValue === field.value;
return (
<div className="w-full max-w-md space-y-2">
<Select
value={custom ? 'custom' : `preset-${presetIndex}`}
onValueChange={(value) => {
if (value === 'custom') {
setEditingValue(field.value ?? '');
} else {
setEditingValue(null);
field.onChange(options[Number(value.slice(7))].name);
}
}}
>
<SelectTrigger
className="w-full"
onBlur={field.onBlur}
disabled={field.disabled}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((option, index) => (
<SelectItem
key={option.name}
value={`preset-${index}`}
description={option.name}
>
{extractI18nObject(option.label)}
</SelectItem>
))}
<SelectItem value="custom">{t('common.customValue')}</SelectItem>
</SelectContent>
</Select>
{custom && (
<Input
{...field}
value={field.value ?? ''}
aria-label={`${extractI18nObject(config.label)} — ${t('common.customValue')}`}
onChange={(event) => {
setEditingValue(event.target.value);
field.onChange(event.target.value);
}}
/>
)}
</div>
);
}
+217 -240
View File
@@ -1,19 +1,18 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AlertTriangle, ChevronDown, Loader2 } from 'lucide-react';
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,
@@ -22,6 +21,11 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { ScrollArea } from '@/components/ui/scroll-area';
export function migrationWorkspaceKey(workspace: CurrentWorkspace | null) {
@@ -30,12 +34,6 @@ export function migrationWorkspaceKey(workspace: CurrentWorkspace | null) {
: '';
}
function safeField(field?: string | null) {
return field && field.length <= 180 && /^[a-zA-Z0-9_.\-[\]]+$/.test(field)
? field
: null;
}
const resultStates = new Set([
'pending',
'migrated',
@@ -69,8 +67,8 @@ export default function PipelineMigration({
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 [phase, setPhase] = useState('migrating');
const [dataOnly, setDataOnly] = useState(false);
const [status, setStatus] = useState<Status>('idle');
const [results, setResults] = useState<PipelineMigrationResult[]>([]);
const active = useRef(false);
@@ -91,8 +89,6 @@ export default function PipelineMigration({
async (allowSelection = true) => {
if (!isCurrent()) return;
const generation = ++previewGeneration.current;
setSelected([]);
setConfirmed(false);
setValid(false);
setLoading(true);
setPreviewError(false);
@@ -139,9 +135,6 @@ export default function PipelineMigration({
}, [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),
@@ -149,6 +142,8 @@ export default function PipelineMigration({
function renderIssue(issue: PipelineMigrationIssue, warning = false) {
// Unknown server codes use localized fallbacks, never raw upstream messages.
if (issue.code === 'plugin_install_failed')
return <span>{t('pipelineMigration.installFailed')}</span>;
const key = migrationIssueKey(issue.code);
const fallback = t(
warning
@@ -158,44 +153,28 @@ export default function PipelineMigration({
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>
);
return <span>{message}</span>;
}
async function execute() {
async function execute(installPlugins: boolean) {
if (
submitting.current ||
!isCurrent() ||
!canManage ||
!valid ||
loading ||
!confirmed ||
selected.length === 0 ||
selected.length > 50
count === 0
)
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;
let items = rows.filter(
(item) => !['already_current', 'not_legacy'].includes(item.state),
);
setDataOnly(!installPlugins);
setPhase(installPlugins ? 'installing' : 'migrating');
submitting.current = true;
++previewGeneration.current;
setStatus('submitting');
setValid(false);
setConfirmed(false);
setResults(
items.map((item) => ({
pipeline_uuid: item.pipeline_uuid,
@@ -212,11 +191,37 @@ export default function PipelineMigration({
onCompleteRef.current();
};
try {
const { task_id } = await httpClient.executePipelineMigration(
{ confirmed: true, items },
{ signal: controller.current?.signal },
);
const { task_id, pipeline_uuids } =
await httpClient.executePipelineMigration(
{ confirmed: true, all: true, install_plugins: installPlugins },
{ signal: controller.current?.signal },
);
if (!isCurrent()) return;
if (
!Array.isArray(pipeline_uuids) ||
!pipeline_uuids.length ||
pipeline_uuids.some((id) => typeof id !== 'string' || !id) ||
new Set(pipeline_uuids).size !== pipeline_uuids.length
) {
loseObservation();
return;
}
// The server captures the complete workspace set at admission.
items = pipeline_uuids.map(
(id) =>
rows.find((row) => row.pipeline_uuid === id) ?? {
pipeline_uuid: id,
name: id,
state: 'ready' as const,
legacy_runner: null,
target_runner_id: null,
target_plugin: null,
changed_paths: [],
warnings: [],
blockers: [],
preview_token: null,
},
);
setStatus('running');
const poll = async () => {
if (!isCurrent()) return;
@@ -262,6 +267,8 @@ export default function PipelineMigration({
},
);
setResults(scopedResults);
if (metadata.phase === 'installing' || metadata.phase === 'migrating')
setPhase(metadata.phase);
if (task.runtime.done) {
if (
!task.runtime.exception &&
@@ -272,7 +279,7 @@ export default function PipelineMigration({
}
submitting.current = false;
setStatus(task.runtime.exception ? 'failed' : 'finished');
void refresh(false);
void refresh(!task.runtime.exception);
onCompleteRef.current();
} else {
timer.current = setTimeout(() => {
@@ -296,15 +303,12 @@ export default function PipelineMigration({
}
submitting.current = false;
setStatus('requestError');
setSelected([]);
}
}
function changeOpen(next: boolean) {
setOpen(next);
if (!submitting.current) {
setSelected([]);
setConfirmed(false);
if (next) void refresh(status === 'idle');
}
}
@@ -312,9 +316,10 @@ export default function PipelineMigration({
return (
<>
{(count > 0 || previewError || results.length > 0) && (
<Alert className="mb-4 shrink-0">
<Alert className="mb-4 shrink-0 border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-200">
<AlertTriangle aria-hidden="true" />
<AlertTitle>{t('pipelineMigration.title')}</AlertTitle>
<AlertDescription className="flex items-center justify-between gap-3">
<AlertDescription className="flex items-center justify-between gap-3 text-amber-800 dark:text-amber-200">
<span>
{previewError
? t('pipelineMigration.previewError')
@@ -331,206 +336,178 @@ export default function PipelineMigration({
</Alert>
)}
<Dialog open={open} onOpenChange={changeOpen}>
<DialogContent className="sm:max-w-2xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogContent className="flex max-h-[90dvh] flex-col overflow-hidden sm:max-w-md">
<DialogHeader className="shrink-0 pr-5">
<DialogTitle>{t('pipelineMigration.title')}</DialogTitle>
<DialogDescription>
{t('pipelineMigration.description')}
{t('pipelineMigration.autoDescription')}
</DialogDescription>
</DialogHeader>
{!canManage && (
<Alert>
<AlertDescription>
<div className="min-h-0 space-y-3 overflow-y-auto py-2">
{!canManage && (
<p className="text-sm text-muted-foreground">
{t('pipelineMigration.readOnly')}
</AlertDescription>
</Alert>
)}
{previewError && (
<Alert variant="destructive">
<AlertDescription>
</p>
)}
{previewError && (
<p className="text-sm text-destructive">
{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>
</p>
)}
{busy ? (
<div className="flex items-center gap-2 text-sm" role="status">
<Loader2 className="size-4 shrink-0 animate-spin" />
{t(`pipelineMigration.${phase}`)}
</div>
) : status !== 'idle' ? (
<div className="space-y-2" role="status">
<p className="text-sm">{t(`pipelineMigration.${status}`)}</p>
{results.length > 0 && (
<p className="text-sm text-muted-foreground">
{t('pipelineMigration.summary', {
migrated: results.filter((r) =>
['migrated', 'already_current'].includes(r.state),
).length,
remaining: results.filter(
(r) =>
!['migrated', 'already_current'].includes(r.state),
).length,
})}
</p>
)}
{dataOnly && results.some((r) => r.state === 'migrated') && (
<p className="text-sm text-muted-foreground">
{t('pipelineMigration.dataOnlyHint')}
</p>
)}
</div>
) : (
<p className="text-sm text-muted-foreground">
{t('pipelineMigration.detected', { count })}
</p>
)}
{!busy && (count > 0 || results.length > 0) && (
<Collapsible>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="sm"
className="group px-0 text-muted-foreground hover:bg-transparent"
>
<ChevronDown className="size-4 group-data-[state=open]:rotate-180" />
{t('pipelineMigration.viewPipelines')}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<ScrollArea
className="h-[min(35dvh,16rem)]"
data-testid="migration-scroll-area"
>
<div className="space-y-3 pr-3">
{rows
.filter(
(item) =>
!['already_current', 'not_legacy'].includes(
item.state,
) ||
results.some(
(r) => r.pipeline_uuid === item.pipeline_uuid,
),
)
.map((item) => {
const result = results.find(
(r) => r.pipeline_uuid === item.pipeline_uuid,
);
return (
<div
key={item.pipeline_uuid}
className="space-y-1 border-b pb-3 text-sm"
data-testid={`migration-result-${item.pipeline_uuid}`}
>
<div className="flex items-start justify-between gap-2">
<span className="min-w-0 break-words font-medium">
{item.name}
</span>
<Badge variant="secondary" className="shrink-0">
{t(
`pipelineMigration.states.${result?.state ?? item.state}`,
)}
</Badge>
</div>
{item.target_plugin && (
<p className="text-xs text-muted-foreground">
{item.target_plugin.name}
</p>
)}
{result?.code && result.code !== 'data_only' ? (
<p className="text-xs text-destructive">
{renderIssue({ code: result.code })}
</p>
) : (
!result &&
item.blockers
.filter(
(b) =>
![
'plugin_missing',
'plugin_disabled',
].includes(b.code),
)
.map((issue, index) => (
<p
key={index}
className="text-xs text-destructive"
>
{renderIssue(issue)}
</p>
))
)}
</div>
);
})}
</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')}
</ScrollArea>
</CollapsibleContent>
</Collapsible>
)}
</div>
<DialogFooter className="shrink-0 flex-col gap-2 sm:flex-col">
{!busy && (
<>
<Button
disabled={!canManage || loading || !valid || !count}
className="w-full"
onClick={() => void execute(true)}
>
<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>
{t('pipelineMigration.autoInstall')}
</Button>
<Button
variant="outline"
disabled={!canManage || loading || !valid || !count}
className="w-full"
onClick={() => void execute(false)}
>
{t('pipelineMigration.dataOnly')}
</Button>
<p className="text-center text-xs text-muted-foreground">
{t('pipelineMigration.dataOnlyHint')}
</p>
{(status !== 'idle' || previewError) && (
<Button
variant="outline"
disabled={loading}
onClick={() => {
setStatus('idle');
void refresh();
}}
>
{t('pipelineMigration.refresh')}
</Button>
)}
</>
)}
<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')}
{t('common.close')}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,4 +1,6 @@
const issueKeys: Record<string, string> = {
plugin_missing: 'pluginRequired',
plugin_disabled: 'pluginRequired',
'local.context_defaults': 'contextDefaults',
'local.model_reasoning': 'modelReasoning',
'local.serial_tools_preserved': 'serialTools',
@@ -31,10 +31,12 @@ export interface PipelineMigrationPreview {
total: number;
}
export interface PipelineMigrationRequest {
confirmed: true;
items: { pipeline_uuid: string; preview_token: string }[];
}
export type PipelineMigrationRequest =
| { confirmed: true; all: true; install_plugins: boolean }
| {
confirmed: true;
items: { pipeline_uuid: string; preview_token: string }[];
};
export interface PipelineMigrationResult {
pipeline_uuid: string;
@@ -51,5 +53,6 @@ export interface PipelineMigrationResult {
export interface PipelineMigrationTaskMetadata {
kind: 'pipeline_migration';
phase?: 'installing' | 'migrating' | 'finished';
results: PipelineMigrationResult[];
}
@@ -27,6 +27,8 @@ export interface IDynamicFormItemSchema {
type: DynamicFormItemType;
description?: I18nObject;
options?: IDynamicFormItemOption[];
/** Allow an editable value in addition to the declared select options. */
allow_custom?: boolean;
/** When the condition matches, the field is rendered. Same evaluator as
* ``disable_if`` — supports the ``__system.*`` namespace via
* ``DynamicFormComponent.systemContext``. */
+1 -1
View File
@@ -454,7 +454,7 @@ export class BackendClient extends BaseHttpClient {
public executePipelineMigration(
body: PipelineMigrationRequest,
config?: RequestConfig,
): Promise<AsyncTaskCreatedResp> {
): Promise<AsyncTaskCreatedResp & { pipeline_uuids?: string[] }> {
return this.post('/api/v1/pipelines/_/migration/execute', body, config);
}
+1
View File
@@ -17,6 +17,7 @@ const enUS = {
editionCloud: 'Cloud',
},
common: {
customValue: 'Custom',
loadFailed: 'Failed to load. Please try again.',
login: 'Login',
logout: 'Logout',
+1
View File
@@ -18,6 +18,7 @@ const esES = {
editionCloud: 'Cloud',
},
common: {
customValue: 'Personalizado',
login: 'Iniciar sesión',
logout: 'Cerrar sesión',
accountOptions: 'Configuración',
+1
View File
@@ -17,6 +17,7 @@ const jaJP = {
editionCloud: 'Cloud',
},
common: {
customValue: 'カスタム',
loadFailed: '読み込みに失敗しました。再試行してください。',
login: 'ログイン',
logout: 'ログアウト',
@@ -1,7 +1,23 @@
export default {
autoDescription:
'Legacy runners are now plugins. Migrate all pipelines while keeping your settings. Original configurations are backed up; conversations start fresh.',
viewPipelines: 'View pipelines',
autoInstall: 'Install plugins and migrate',
dataOnly: 'Migrate data only',
dataOnlyHint:
'For offline or private networks. Install the corresponding runner plugins yourself after migration.',
installing: 'Installing required plugins…',
migrating: 'Migrating pipelines…',
summary: '{{migrated}} migrated; {{remaining}} need attention.',
installFailed:
'Plugin installation failed. Check your network and extension quota, then retry, or migrate data only.',
activationRetryHint:
'After checking the runtime, refresh, select this pipeline and confirm to retry activation only. Its saved configuration will not be migrated again.',
details: 'Migration details',
notices: {
pluginRequired:
'Install or enable the runner plugin shown above, then refresh the preview.',
legacyArchive:
'The active configuration will contain only the selected runner. All original runner settings, including inactive ones, are kept in the migration backup.',
contextDefaults:
@@ -44,7 +60,7 @@ export default {
},
title: 'Pipeline migration',
description:
'Nothing changes until you select pipelines and explicitly confirm. Migration preserves pipeline identity and unrelated settings.',
'Select pipelines to migrate. Original settings are backed up; conversations start fresh after migration.',
detected: '{{count}} pipelines need migration review.',
review: 'Review migration',
readOnly: 'Only workspace managers can migrate pipelines.',
@@ -64,7 +80,7 @@ export default {
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.',
'Missing a plugin? Install or enable its runner in Extensions, then refresh the preview.',
extensions: 'Open Extensions',
results: 'Per-pipeline results',
selection: '{{count}} selected (maximum 50)',
@@ -1,7 +1,23 @@
export default {
autoDescription:
'Los ejecutores antiguos ahora son plugins. Migra todos los pipelines conservando sus ajustes. Se guardará una copia de la configuración y las conversaciones empezarán de nuevo.',
viewPipelines: 'Ver pipelines',
autoInstall: 'Instalar plugins y migrar',
dataOnly: 'Migrar solo los datos',
dataOnlyHint:
'Para redes privadas o sin conexión. Instala los plugins de ejecución correspondientes después de la migración.',
installing: 'Instalando los plugins necesarios…',
migrating: 'Migrando pipelines…',
summary: '{{migrated}} migrados; {{remaining}} requieren atención.',
installFailed:
'No se pudieron instalar los plugins. Comprueba la red y la cuota de extensiones y reintenta, o migra solo los datos.',
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.',
details: 'Detalles de migración',
notices: {
pluginRequired:
'Instala o activa el plugin de ejecución indicado arriba y actualiza la vista previa.',
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:
@@ -50,7 +66,7 @@ export default {
},
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.',
'Selecciona los pipelines que deseas migrar. Se guardará una copia de la configuración y las conversaciones empezarán de nuevo.',
detected: '{{count}} pipelines requieren revisión.',
review: 'Revisar migración',
readOnly: 'Solo los gestores del espacio pueden migrar pipelines.',
@@ -69,7 +85,7 @@ export default {
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.',
'¿Falta un plugin? Instala o activa su ejecutor en Extensiones y actualiza la vista previa.',
extensions: 'Abrir Extensiones',
results: 'Resultados por pipeline',
selection: '{{count}} seleccionados (máximo 50)',
@@ -1,7 +1,23 @@
export default {
autoDescription:
'従来の実行方式はプラグインになりました。設定を保持して全パイプラインを移行します。元の設定はバックアップされ、会話は新しく始まります。',
viewPipelines: 'パイプラインを表示',
autoInstall: 'プラグインを自動インストールして移行',
dataOnly: 'データのみ移行',
dataOnlyHint:
'オフラインやイントラネット向けです。移行後、対応するランナープラグインを手動でインストールしてください。',
installing: '必要なプラグインをインストール中…',
migrating: 'パイプラインを移行中…',
summary: '{{migrated}} 件移行済み、{{remaining}} 件の確認が必要です。',
installFailed:
'プラグインのインストールに失敗しました。ネットワークと拡張機能の上限を確認して再試行するか、データのみ移行してください。',
activationRetryHint:
'実行環境を確認して再読み込みし、このパイプラインを選択して確定すると、有効化のみを再試行します。保存済み設定は再移行しません。',
details: '移行の詳細',
notices: {
pluginRequired:
'上記のランナープラグインをインストールまたは有効化し、プレビューを更新してください。',
legacyArchive:
'有効な設定には選択した Runner のみを残します。未使用のものを含むすべての旧 Runner 設定は移行バックアップに保存します。',
contextDefaults:
@@ -44,7 +60,7 @@ export default {
},
title: 'パイプライン移行',
description:
'パイプラインを選択し、明示的に確認するまで変更されません。識別情報と無関係な設定は保持されます。',
'移行するパイプラインを選択してください。元の設定はバックアップされ、移行後は新しい会話を開始します。',
detected: '{{count}} 件のパイプラインに移行確認が必要です。',
review: '移行を確認',
readOnly: '移行できるのはワークスペースの管理権限を持つユーザーのみです。',
@@ -63,7 +79,7 @@ export default {
activationHint:
'設定は保存されましたが、有効化待ちです。管理者に実行環境の確認を依頼してから更新してください。移行を無条件に再実行しないでください。',
pluginHint:
'「拡張機能 / 拡張機能マーケット」で必要なランナープラグインをインストールまたは有効化し、ワークスペースの上限を確認してください。このアシスタントはインストールしません。戻った後にプレビューを更新してください。',
'プラグインが不足している場合は、拡張機能でランナーをインストールまたは有効化し、プレビューを更新してください。',
extensions: '拡張機能を開く',
results: 'パイプラインごとの結果',
selection: '{{count}} 件選択(最大 50 件)',
@@ -1,7 +1,23 @@
export default {
autoDescription:
'Прежние исполнители стали плагинами. Все конвейеры будут перенесены с сохранением настроек. Исходные настройки будут скопированы, а разговоры начнутся заново.',
viewPipelines: 'Посмотреть конвейеры',
autoInstall: 'Установить плагины и перенести',
dataOnly: 'Перенести только данные',
dataOnlyHint:
'Для закрытых сетей и работы без интернета. После переноса установите соответствующие плагины исполнителей вручную.',
installing: 'Установка необходимых плагинов…',
migrating: 'Перенос конвейеров…',
summary: 'Перенесено: {{migrated}}; требуют внимания: {{remaining}}.',
installFailed:
'Не удалось установить плагины. Проверьте сеть и квоту расширений и повторите попытку либо перенесите только данные.',
activationRetryHint:
'Проверьте среду выполнения, обновите список, выберите конвейер и подтвердите повторную активацию. Сохранённая конфигурация не будет мигрировать повторно.',
details: 'Подробности переноса',
notices: {
pluginRequired:
'Установите или включите указанный выше плагин исполнителя, затем обновите предпросмотр.',
legacyArchive:
'В активной конфигурации останется только выбранный Runner. Все прежние настройки, включая неиспользуемые, сохраняются в резервной копии миграции.',
contextDefaults:
@@ -47,7 +63,7 @@ export default {
},
title: 'Миграция конвейеров',
description:
'Изменения выполняются только после выбора конвейеров и явного подтверждения. Идентификаторы и прочие настройки сохраняются.',
'Выберите конвейеры для переноса. Исходные настройки сохранятся в резервной копии, а разговоры начнутся заново.',
detected: '{{count}} конвейеров требуют проверки миграции.',
review: 'Проверить миграцию',
readOnly:
@@ -66,7 +82,7 @@ export default {
activationHint:
'Настройки сохранены, но активация ожидается. Попросите администратора проверить среду и обновите предпросмотр. Не повторяйте миграцию вслепую.',
pluginHint:
'Установите или включите нужный плагин через «Расширения / Маркет» и проверьте квоту пространства. Этот помощник не устанавливает плагины. Вернувшись, обновите предпросмотр.',
'Не хватает плагина? Установите или включите его в расширениях, затем обновите предпросмотр.',
extensions: 'Открыть расширения',
results: 'Результаты по конвейерам',
selection: 'Выбрано {{count}} (максимум 50)',
@@ -1,7 +1,23 @@
export default {
autoDescription:
'รันเนอร์เดิมเปลี่ยนเป็นปลั๊กอินแล้ว ย้ายไปป์ไลน์ทั้งหมดโดยคงการตั้งค่าเดิม ระบบจะสำรองการตั้งค่าและเริ่มการสนทนาใหม่',
viewPipelines: 'ดูไปป์ไลน์',
autoInstall: 'ติดตั้งปลั๊กอินและย้ายข้อมูล',
dataOnly: 'ย้ายเฉพาะข้อมูล',
dataOnlyHint:
'สำหรับเครือข่ายภายในหรือออฟไลน์ โปรดติดตั้งปลั๊กอินรันเนอร์ที่เกี่ยวข้องด้วยตนเองหลังย้ายข้อมูล',
installing: 'กำลังติดตั้งปลั๊กอินที่จำเป็น…',
migrating: 'กำลังย้ายไปป์ไลน์…',
summary: 'ย้ายแล้ว {{migrated}} รายการ ต้องตรวจสอบ {{remaining}} รายการ',
installFailed:
'ติดตั้งปลั๊กอินไม่สำเร็จ ตรวจสอบเครือข่ายและโควตาส่วนขยายแล้วลองใหม่ หรือย้ายเฉพาะข้อมูล',
activationRetryHint:
'หลังตรวจสอบสภาพแวดล้อมการทำงาน ให้รีเฟรช เลือกไปป์ไลน์นี้และยืนยันเพื่อลองเปิดใช้งานอีกครั้งเท่านั้น การตั้งค่าที่บันทึกไว้จะไม่ถูกย้ายซ้ำ',
details: 'รายละเอียดการย้าย',
notices: {
pluginRequired:
'ติดตั้งหรือเปิดใช้งานปลั๊กอินรันเนอร์ที่แสดงด้านบน แล้วรีเฟรชตัวอย่าง',
legacyArchive:
'การตั้งค่าที่ใช้งานจะเหลือเฉพาะ Runner ที่เลือก การตั้งค่า Runner เดิมทั้งหมด รวมถึงส่วนที่ไม่ได้ใช้งาน จะถูกเก็บไว้ในข้อมูลสำรองการย้าย',
contextDefaults:
@@ -41,7 +57,7 @@ export default {
},
title: 'ย้ายไปป์ไลน์',
description:
'จะไม่มีการเปลี่ยนแปลงจนกว่าคุณจะเลือกไปป์ไลน์และยืนยันอย่างชัดเจน โดยคงตัวตนและการตั้งค่าอื่นไว้',
'เลือกไปป์ไลน์ที่จะย้าย ระบบจะสำรองการตั้งค่าเดิมและเริ่มการสนทนาใหม่หลังย้าย',
detected: 'มี {{count}} ไปป์ไลน์ที่ต้องตรวจสอบการย้าย',
review: 'ตรวจสอบการย้าย',
readOnly: 'เฉพาะผู้มีสิทธิ์จัดการพื้นที่ทำงานเท่านั้นที่ย้ายไปป์ไลน์ได้',
@@ -59,7 +75,7 @@ export default {
activationHint:
'บันทึกการตั้งค่าแล้วแต่รอเปิดใช้งาน โปรดให้ผู้ดูแลตรวจสอบสภาพแวดล้อมแล้วรีเฟรช อย่าย้ายซ้ำโดยไม่ตรวจสอบ',
pluginHint:
'ติดตั้งหรือเปิดใช้ปลั๊กอินรันเนอร์ผ่านส่วนขยาย / ตลาดส่วนขยาย และตรวจสอบโควตาพื้นที่ทำงานด้วยตนเอง ผู้ช่วยนี้ไม่ติดตั้งปลั๊กอิน เมื่อกลับมาโปรดรีเฟรชตัวอย่าง',
'ขาดปลั๊กอิน? ติดตั้งหรือเปิดใช้งานรันเนอร์ในส่วนขยาย แล้วรีเฟรชตัวอย่าง',
extensions: 'เปิดส่วนขยาย',
results: 'ผลลัพธ์แต่ละไปป์ไลน์',
selection: 'เลือก {{count}} รายการ (สูงสุด 50)',
@@ -1,7 +1,23 @@
export default {
autoDescription:
'Các runner cũ đã chuyển thành plugin. Chuyển đổi tất cả pipeline và giữ lại thiết lập. Cấu hình cũ sẽ được sao lưu; hội thoại sẽ bắt đầu lại.',
viewPipelines: 'Xem pipeline',
autoInstall: 'Cài plugin và chuyển đổi',
dataOnly: 'Chỉ chuyển đổi dữ liệu',
dataOnlyHint:
'Dành cho mạng nội bộ hoặc ngoại tuyến. Tự cài các plugin runner tương ứng sau khi chuyển đổi.',
installing: 'Đang cài plugin cần thiết…',
migrating: 'Đang chuyển đổi pipeline…',
summary: 'Đã chuyển {{migrated}}; {{remaining}} cần xử lý.',
installFailed:
'Cài plugin thất bại. Kiểm tra mạng và hạn mức tiện ích rồi thử lại, hoặc chỉ chuyển đổi dữ liệu.',
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.',
details: 'Chi tiết chuyển đổi',
notices: {
pluginRequired:
'Cài đặt hoặc bật plugin runner ở trên, rồi làm mới bản xem trước.',
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:
@@ -44,7 +60,7 @@ export default {
},
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.',
'Chọn các pipeline cần chuyển đổi. Cấu hình cũ sẽ được sao lưu; các cuộc trò chuyện sẽ bắt đầu lại.',
detected: '{{count}} pipeline cần xem xét di chuyển.',
review: 'Xem xét di chuyển',
readOnly:
@@ -63,7 +79,7 @@ export default {
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.',
'Thiếu plugin? Cài đặt hoặc bật runner trong Tiện ích mở rộng, rồi 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)',
@@ -1,7 +1,20 @@
export default {
autoDescription:
'旧版运行方式已改为插件。迁移全部流水线并保留现有设置,原配置会自动备份;迁移后开始新会话。',
viewPipelines: '查看流水线',
autoInstall: '自动安装插件并迁移',
dataOnly: '仅迁移数据',
dataOnlyHint: '适合离线或内网环境,迁移后请自行安装对应的运行器插件。',
installing: '正在安装所需插件…',
migrating: '正在迁移流水线…',
summary: '已迁移 {{migrated}} 条,{{remaining}} 条需要处理。',
installFailed: '插件安装失败,请检查网络和扩展配额后重试,或选择仅迁移数据。',
activationRetryHint:
'检查运行环境后刷新,选中此流水线并确认,即可仅重试激活,不会再次迁移已保存的配置。',
details: '迁移详情',
notices: {
pluginRequired: '请先安装或启用上方所示的运行器插件,再刷新预览。',
legacyArchive:
'活动配置仅保留所选 Runner;全部旧 Runner 配置(包括未启用的配置)保存在迁移备份中。',
contextDefaults:
@@ -31,8 +44,7 @@ export default {
pendingInteraction: '有会话正在等待输入,请完成或取消后再迁移。',
},
title: '流水线迁移',
description:
'仅在选择流水线并明确确认后才会更改配置。迁移保留流水线身份及无关设置。',
description: '选择要迁移的流水线。原配置会备份,迁移后将开始新会话。',
detected: '{{count}} 条流水线需要检查迁移。',
review: '检查迁移',
readOnly: '只有工作空间管理者可以迁移流水线。',
@@ -48,8 +60,7 @@ export default {
changedFields: '变更字段',
activationHint:
'配置已保存,但尚未激活。请工作空间管理员检查运行环境后刷新,不要盲目重复迁移。',
pluginHint:
'请通过「扩展 / 扩展市场」安装或启用所需的运行器插件,并自行检查工作空间扩展配额。本助手不会安装插件。返回此处后请刷新预览。',
pluginHint: '缺少插件?前往扩展安装或启用对应运行器,然后刷新预览。',
extensions: '打开扩展',
results: '逐条迁移结果',
selection: '已选择 {{count}} 条(最多 50 条)',
@@ -1,7 +1,20 @@
export default {
autoDescription:
'舊版執行方式已改為外掛。遷移全部流水線並保留現有設定,原設定會自動備份;遷移後開始新對話。',
viewPipelines: '查看流水線',
autoInstall: '自動安裝外掛並遷移',
dataOnly: '僅遷移資料',
dataOnlyHint: '適合離線或內網環境,遷移後請自行安裝對應的執行器外掛。',
installing: '正在安裝所需外掛…',
migrating: '正在遷移流水線…',
summary: '已遷移 {{migrated}} 條,{{remaining}} 條需要處理。',
installFailed: '外掛安裝失敗,請檢查網路和擴充配額後重試,或選擇僅遷移資料。',
activationRetryHint:
'檢查執行環境後重新整理,選取此流水線並確認,即可只重試啟用,不會再次遷移已儲存的設定。',
details: '遷移詳情',
notices: {
pluginRequired: '請先安裝或啟用上方所示的執行器外掛,再重新整理預覽。',
legacyArchive:
'作用中的設定僅保留所選 Runner;全部舊 Runner 設定(包括未啟用的設定)保存在遷移備份中。',
contextDefaults:
@@ -31,8 +44,7 @@ export default {
pendingInteraction: '有對話正在等待輸入,請完成或取消後再遷移。',
},
title: '流水線遷移',
description:
'僅在選擇流水線並明確確認後才會變更設定。遷移保留流水線身分及無關設定。',
description: '選擇要遷移的流水線。原設定會備份,遷移後將開始新對話。',
detected: '{{count}} 條流水線需要檢查遷移。',
review: '檢查遷移',
readOnly: '只有工作空間管理者可以遷移流水線。',
@@ -48,8 +60,7 @@ export default {
changedFields: '變更欄位',
activationHint:
'設定已儲存,但尚未啟用。請工作空間管理員檢查執行環境後重新整理,不要盲目重複遷移。',
pluginHint:
'請透過「擴充功能 / 擴充功能市場」安裝或啟用所需執行器外掛,並自行檢查工作空間配額。本助手不會安裝外掛。返回後請重新整理預覽。',
pluginHint: '缺少外掛?前往擴充功能安裝或啟用對應執行器,再重新整理預覽。',
extensions: '開啟擴充功能',
results: '逐條遷移結果',
selection: '已選擇 {{count}} 條(最多 50 條)',
+1
View File
@@ -18,6 +18,7 @@ const ruRU = {
editionCloud: 'Cloud',
},
common: {
customValue: 'Свой вариант',
login: 'Войти',
logout: 'Выйти',
accountOptions: 'Настройки',
+1
View File
@@ -17,6 +17,7 @@ const thTH = {
editionCloud: 'Cloud',
},
common: {
customValue: 'กำหนดเอง',
login: 'เข้าสู่ระบบ',
logout: 'ออกจากระบบ',
accountOptions: 'การตั้งค่า',
+1
View File
@@ -18,6 +18,7 @@ const viVN = {
editionCloud: 'Cloud',
},
common: {
customValue: 'Tùy chỉnh',
login: 'Đăng nhập',
logout: 'Đăng xuất',
accountOptions: 'Cài đặt',
+1
View File
@@ -17,6 +17,7 @@ const zhHans = {
editionCloud: 'Cloud',
},
common: {
customValue: '自定义',
loadFailed: '加载失败,请重试。',
login: '登录',
logout: '退出登录',
+1
View File
@@ -17,6 +17,7 @@ const zhHant = {
editionCloud: 'Cloud',
},
common: {
customValue: '自訂',
login: '登入',
logout: '登出',
accountOptions: '系統設定',