diff --git a/frontend/server/migration/contracts.py b/frontend/server/migration/contracts.py index 97e22d5bb..5b5cd5c0d 100644 --- a/frontend/server/migration/contracts.py +++ b/frontend/server/migration/contracts.py @@ -23,6 +23,7 @@ from .models import ( MIGRATION_FRAMEWORKS, STRUCTURED_MIGRATION_FRAMEWORKS, + is_valid_model_id, is_valid_structured_entry, ) @@ -178,6 +179,7 @@ def validate_migration_request( "session_ttl_seconds", "created_at", }, + optional={"model_id"}, ) if ( value.get("schema_version") != 1 @@ -192,6 +194,8 @@ def validate_migration_request( ): raise MigrationContractError("invalid source file name") _text(value.get("instruction"), maximum=_MAX_TEXT_LENGTH) + if "model_id" in value and not is_valid_model_id(value.get("model_id")): + raise MigrationContractError("invalid model id") created_at = value.get("created_at") if isinstance(created_at, str): _timestamp_text(created_at) diff --git a/frontend/server/migration/gateway.py b/frontend/server/migration/gateway.py index d78c98e0a..7e64328e2 100644 --- a/frontend/server/migration/gateway.py +++ b/frontend/server/migration/gateway.py @@ -25,6 +25,7 @@ import requests from agentkit.sdk.tools import types as tools_types +from agentkit.toolkit.cli.sandbox.env_config import build_exec_session_envs from agentkit.toolkit.cli.sandbox.sandbox_client import ( SANDBOX_FILE_DOWNLOAD_ROUTE, build_bash_exec_url, @@ -60,6 +61,11 @@ "start_analysis": ANALYSIS_START_MARKER, "start_migration": MIGRATION_START_MARKER, } +_SESSION_CREDENTIAL_ENV_KEYS = { + "ANTHROPIC_AUTH_TOKEN", + "CODEX_API_KEY", + "OPENCODE_API_KEY", +} _RELEASED_SESSION_STATUSES = { "createfailed", "deleted", @@ -126,6 +132,7 @@ def create_session( creator_name: str, display_name: str, ttl_seconds: int, + model_id: str | None = None, ) -> MigrationSandboxSession: ... def list_sessions(self, owner_id: str) -> list[MigrationSandboxSession]: ... @@ -506,6 +513,7 @@ def create_session( creator_name: str, display_name: str, ttl_seconds: int, + model_id: str | None = None, ) -> MigrationSandboxSession: _, region = self._get_tool() existing = self._list_region( @@ -539,6 +547,25 @@ def create_session( username=owner_id, creator_name=creator_name, ) + if model_id: + model_provider, model_base_url = _sandbox_model_config( + cloud_provider_from_env() + ) + session_envs = build_exec_session_envs( + model_name=model_id, + model_provider=model_provider, + model_base_url=model_base_url, + model_provider_was_provided=True, + model_base_url_was_provided=True, + include_codex_config=True, + ) + safe_session_envs = [ + item + for item in session_envs or [] + if item.key not in _SESSION_CREDENTIAL_ENV_KEYS + ] + if safe_session_envs: + request = request.model_copy(update={"envs": safe_session_envs}) try: response = self._client(region).create_session(request) except Exception as error: diff --git a/frontend/server/migration/models.py b/frontend/server/migration/models.py index 1adfb5a7d..a0a46dd40 100644 --- a/frontend/server/migration/models.py +++ b/frontend/server/migration/models.py @@ -47,6 +47,7 @@ _SOURCE_FILE_NAME_RE = re.compile(r"^[^/\\\x00-\x1f]{1,255}\.zip$", re.IGNORECASE) _APP_NAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$") _TASK_ID_RE = re.compile(r"^migration-v1-[0-9a-f]{32}$") +_MODEL_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$") STRUCTURED_ENTRY_PATTERN = ( r"^[A-Za-z0-9_./-]+\.(?:py|json)(?::[A-Za-z_][A-Za-z0-9_]*)?$" ) @@ -71,6 +72,7 @@ class CreateMigrationTaskBody(BaseModel): task_id: str | None = Field(default=None, alias="taskId", max_length=45) source_file_name: str = Field(alias="sourceFileName", min_length=1, max_length=255) instruction: str = Field(default="", max_length=20_000) + model_id: str | None = Field(default=None, alias="modelId", max_length=128) model_config = {"populate_by_name": True, "extra": "forbid"} @@ -79,13 +81,20 @@ def normalize(self) -> CreateMigrationTaskBody: self.task_id = (self.task_id or "").strip() or None self.source_file_name = self.source_file_name.strip() self.instruction = self.instruction.strip() + self.model_id = (self.model_id or "").strip() or None if self.task_id is not None and not _TASK_ID_RE.fullmatch(self.task_id): raise ValueError("迁移会话 ID 无效") if not _SOURCE_FILE_NAME_RE.fullmatch(self.source_file_name): raise ValueError("请选择名称有效的 ZIP 文件") + if self.model_id is not None and not _MODEL_ID_RE.fullmatch(self.model_id): + raise ValueError("模型 ID 格式无效") return self +def is_valid_model_id(value: object) -> bool: + return isinstance(value, str) and _MODEL_ID_RE.fullmatch(value) is not None + + class ConfirmMigrationBody(BaseModel): framework: MigrationFramework entry: str | None = Field(default=None, max_length=512) diff --git a/frontend/server/migration/service.py b/frontend/server/migration/service.py index a4fced80b..80aab02de 100644 --- a/frontend/server/migration/service.py +++ b/frontend/server/migration/service.py @@ -963,6 +963,7 @@ def _accept_request_command(candidate_path: str, expected_sha256: str) -> str: "task_id", "source_file_name", "instruction", + "model_id", "session_ttl_seconds", ) @@ -2425,6 +2426,8 @@ def create_task( "instruction": body.instruction, "session_ttl_seconds": MIGRATION_SESSION_TTL_SECONDS, } + if body.model_id: + request["model_id"] = body.model_id try: session = self._gateway.create_session( task_id=task_id, @@ -2432,6 +2435,7 @@ def create_task( creator_name=creator_name, display_name="存量迁移", ttl_seconds=MIGRATION_SESSION_TTL_SECONDS, + model_id=body.model_id, ) self._validate_session_timing(session) existing_request = self._read_json( @@ -2596,6 +2600,7 @@ def _validate_request( if ( existing.get("source_file_name") != expected["source_file_name"] or existing.get("instruction") != expected["instruction"] + or existing.get("model_id") != expected.get("model_id") or existing.get("session_ttl_seconds") != expected["session_ttl_seconds"] ): raise MigrationError( @@ -2786,6 +2791,8 @@ def _task_payload( "canStop": state in _STOPPABLE_STATES, "artifact": artifact_status, } + if request.get("model_id"): + payload["modelId"] = str(request["model_id"]) if analysis is not None: payload["analysis"] = analysis payload["analysisRef"] = { diff --git a/frontend/src/adk/migrations.ts b/frontend/src/adk/migrations.ts index fe47abbb8..3fd8d6272 100644 --- a/frontend/src/adk/migrations.ts +++ b/frontend/src/adk/migrations.ts @@ -36,6 +36,10 @@ export type MigrationTaskState = export interface MigrationCapabilities { enabled: boolean; reason: string; + model?: { + configured: boolean; + id: string; + }; maxUploadBytes: number; sessionTtlSeconds: number; frameworks: MigrationFramework[]; @@ -87,6 +91,7 @@ export interface MigrationTask { message: string; sourceFileName: string; instruction: string; + modelId?: string; createdAt: string | number; expiresAt: string; sessionTtlSeconds: number; @@ -432,6 +437,9 @@ function normalizeTask(value: unknown): MigrationTask { deployReady: artifact.deployReady === true, }, }; + if (typeof task.modelId === "string" && task.modelId.trim()) { + normalized.modelId = task.modelId; + } if (task.analysis !== undefined) normalized.analysis = normalizeAnalysis(task.analysis); if (task.analysisRef !== undefined) { const reference = record(task.analysisRef, "分析结果引用"); @@ -809,13 +817,21 @@ export async function getMigrationCapabilities( ) { throw new Error("迁移能力格式错误。"); } - return { + const capability: MigrationCapabilities = { enabled: body.enabled, reason: body.reason, maxUploadBytes: body.maxUploadBytes, sessionTtlSeconds: body.sessionTtlSeconds, frameworks: body.frameworks.map((item) => framework(item, "迁移框架")), }; + if (body.model !== undefined) { + const model = record(body.model, "迁移模型能力"); + if (typeof model.configured !== "boolean" || typeof model.id !== "string") { + throw new Error("迁移模型能力格式错误。"); + } + capability.model = { configured: model.configured, id: model.id }; + } + return capability; } export async function listMigrationTasks( @@ -833,6 +849,7 @@ export async function createMigrationTask(args: { taskId: string; sourceFileName: string; instruction: string; + modelId?: string; signal?: AbortSignal; }): Promise { return normalizeTask( @@ -846,6 +863,7 @@ export async function createMigrationTask(args: { taskId: args.taskId, sourceFileName: args.sourceFileName, instruction: args.instruction, + ...(args.modelId ? { modelId: args.modelId } : {}), }), signal: args.signal, }, diff --git a/frontend/src/migrations/MigrationWorkspace.css b/frontend/src/migrations/MigrationWorkspace.css index e463bb2b8..1eeb80525 100644 --- a/frontend/src/migrations/MigrationWorkspace.css +++ b/frontend/src/migrations/MigrationWorkspace.css @@ -1145,6 +1145,25 @@ justify-content: space-between; } +.migration-composer__tools { + min-width: 0; + display: flex; + align-items: center; + gap: 4px; +} + +.migration-composer__model-select, +.migration-composer__model-select .new-chat-compact-select { + width: 220px; + max-width: 220px; +} + +.migration-composer__model-select .new-chat-compact-select__menu { + top: auto; + bottom: calc(100% + 6px); + width: min(320px, calc(100vw - 32px)); +} + .migration-attach-button { min-height: 36px; display: inline-flex; diff --git a/frontend/src/migrations/MigrationWorkspace.tsx b/frontend/src/migrations/MigrationWorkspace.tsx index e33c12f01..3359d4f7e 100644 --- a/frontend/src/migrations/MigrationWorkspace.tsx +++ b/frontend/src/migrations/MigrationWorkspace.tsx @@ -29,7 +29,9 @@ import { } from "../adk/migrations"; import { deployAgentkitProject, + listModelOptions, type DeployStage, + type ModelOption, } from "../adk/client"; import { defaultCloudRegion, @@ -233,6 +235,10 @@ function sourceStem(name: string): string { return name.replace(/\.zip$/i, ""); } +function isSelectableMigrationModel(model: ModelOption): boolean { + return model.available || model.lifecycleStatus === "Retiring"; +} + function defaultAppName(name: string): string { const value = sourceStem(name) .toLowerCase() @@ -638,6 +644,11 @@ export function MigrationWorkspace({ const [tasks, setTasks] = useState([]); const [selectedTaskId, setSelectedTaskId] = useState(""); const [sourceFile, setSourceFile] = useState(null); + const [models, setModels] = useState([]); + const [selectedModelId, setSelectedModelId] = useState(""); + const [modelsLoading, setModelsLoading] = useState(false); + const [modelsError, setModelsError] = useState(""); + const [modelsReloadKey, setModelsReloadKey] = useState(0); const [dragging, setDragging] = useState(false); const [loading, setLoading] = useState(true); const [action, setAction] = useState< @@ -668,6 +679,38 @@ export function MigrationWorkspace({ Record >({}); const task = selectedTask(tasks, selectedTaskId); + const selectableModels = useMemo( + () => models.filter(isSelectableMigrationModel), + [models], + ); + const composerModelId = task?.modelId || selectedModelId; + const modelSelectOptions = useMemo(() => { + const options = selectableModels.map((model) => ({ + value: model.id, + label: model.displayName, + description: [ + model.id, + model.vendorName, + model.lifecycleStatus === "Retiring" ? "即将下线" : "", + ] + .filter(Boolean) + .join(" · "), + })); + const fallbackId = ( + task?.modelId || + selectedModelId || + capability?.model?.id || + "" + ).trim(); + if (fallbackId && !options.some((option) => option.value === fallbackId)) { + options.unshift({ + value: fallbackId, + label: fallbackId, + description: "当前默认模型", + }); + } + return options; + }, [capability?.model?.id, selectableModels, selectedModelId, task?.modelId]); const createElapsedSeconds = createStartedAt ? Math.max(0, Math.floor((now - createStartedAt) / 1_000)) : 0; @@ -749,6 +792,38 @@ export function MigrationWorkspace({ return () => controller.abort(); }, []); + useEffect(() => { + const controller = new AbortController(); + setModelsLoading(true); + setModelsError(""); + void listModelOptions({ + signal: controller.signal, + refresh: modelsReloadKey > 0, + }) + .then((response) => { + if (controller.signal.aborted) return; + setModels(response.models); + }) + .catch((cause: unknown) => { + if (!controller.signal.aborted) { + setModelsError( + cause instanceof Error ? cause.message : "加载模型列表失败", + ); + } + }) + .finally(() => { + if (!controller.signal.aborted) setModelsLoading(false); + }); + return () => controller.abort(); + }, [cloudProvider, modelsReloadKey]); + + useEffect(() => { + if (!capability || selectedModelId) return; + const defaultModelId = + capability?.model?.id.trim() || selectableModels[0]?.id || ""; + if (defaultModelId) setSelectedModelId(defaultModelId); + }, [capability, selectableModels, selectedModelId]); + useEffect( () => () => { transferAbortRef.current?.abort(); @@ -986,6 +1061,7 @@ export function MigrationWorkspace({ taskId: createdTaskId, sourceFileName: sourceFile.name, instruction: "", + modelId: selectedModelId || undefined, signal: controller.signal, }); if (!isCurrent()) return; @@ -1189,6 +1265,9 @@ export function MigrationWorkspace({ setArtifactErrorRetryable(false); setDeploymentOpen(false); setStopConfirmOpen(false); + setSelectedModelId( + capability?.model?.id.trim() || selectableModels[0]?.id || "", + ); } const deploymentProject: AgentProject | null = artifact @@ -1870,15 +1949,34 @@ export function MigrationWorkspace({ )}
- +
+ +
+ + setModelsReloadKey((current) => current + 1) + } + /> +
+