Pārlūkot izejas kodu

feat: simplify teams and agents pages, add i18n, models and skills pages

Restructure teams page from 6 tabs to 3 (overview/runs/versions) with
extracted components. Apply same pattern to agents page with full config
in create dialog (model, tools, memory). Remove workflow editor. Add
i18n support with en/zh locales, model providers page, and skills page.
Team create dialog uses agent selector and horizontal layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
docker 1 mēnesi atpakaļ
vecāks
revīzija
f917ce5563
86 mainītis faili ar 5066 papildinājumiem un 2876 dzēšanām
  1. 86 40
      web/package-lock.json
  2. 3 0
      web/package.json
  3. 21 7
      web/src/App.tsx
  4. 24 1
      web/src/api/agents.ts
  5. 0 1
      web/src/api/api-keys.ts
  6. 2 4
      web/src/api/client.ts
  7. 1 2
      web/src/api/health.ts
  8. 1 1
      web/src/api/index.ts
  9. 0 5
      web/src/api/knowledge.ts
  10. 187 222
      web/src/api/mock.ts
  11. 43 0
      web/src/api/model-providers.ts
  12. 0 2
      web/src/api/sessions.ts
  13. 0 3
      web/src/api/teams.ts
  14. 0 3
      web/src/api/tools.ts
  15. 0 88
      web/src/api/workflows.ts
  16. 9 7
      web/src/components/layout/Header.tsx
  17. 10 6
      web/src/components/layout/Sidebar.tsx
  18. 12 0
      web/src/components/shared/LanguageSelector.tsx
  19. 1 1
      web/src/hooks/index.ts
  20. 12 0
      web/src/hooks/useApps.ts
  21. 0 7
      web/src/hooks/useWorkflows.ts
  22. 33 0
      web/src/i18n/index.ts
  23. 14 11
      web/src/lib/constants.ts
  24. 933 0
      web/src/locales/en.json
  25. 933 0
      web/src/locales/zh.json
  26. 1 0
      web/src/main.tsx
  27. 96 393
      web/src/pages/agents/AgentListPage.tsx
  28. 93 0
      web/src/pages/agents/components/AgentOverview.tsx
  29. 138 0
      web/src/pages/agents/components/AgentRuns.tsx
  30. 54 0
      web/src/pages/agents/components/AgentVersions.tsx
  31. 260 71
      web/src/pages/agents/components/CreateAgentDialog.tsx
  32. 11 12
      web/src/pages/dashboard/DashboardPage.tsx
  33. 3 1
      web/src/pages/dashboard/components/ExecutionTrendChart.tsx
  34. 8 6
      web/src/pages/dashboard/components/RecentRunsTable.tsx
  35. 3 1
      web/src/pages/dashboard/components/ServiceHealthList.tsx
  36. 7 8
      web/src/pages/dashboard/components/StatsCards.tsx
  37. 2 7
      web/src/pages/knowledge/KnowledgePage.tsx
  38. 13 17
      web/src/pages/login/LoginPage.tsx
  39. 577 0
      web/src/pages/models/ModelProvidersPage.tsx
  40. 9 10
      web/src/pages/sessions/SessionChatPage.tsx
  41. 5 3
      web/src/pages/sessions/components/ChatInput.tsx
  42. 4 2
      web/src/pages/sessions/components/ChatPanel.tsx
  43. 11 9
      web/src/pages/sessions/components/CreateSessionDialog.tsx
  44. 5 3
      web/src/pages/sessions/components/SessionListPanel.tsx
  45. 42 36
      web/src/pages/settings/SettingsPage.tsx
  46. 487 0
      web/src/pages/skills/SkillsPage.tsx
  47. 121 1087
      web/src/pages/teams/TeamsPage.tsx
  48. 308 0
      web/src/pages/teams/components/CreateTeamDialog.tsx
  49. 124 0
      web/src/pages/teams/components/TeamOverview.tsx
  50. 140 0
      web/src/pages/teams/components/TeamRuns.tsx
  51. 57 0
      web/src/pages/teams/components/TeamVersions.tsx
  52. 78 75
      web/src/pages/tools/ToolsPage.tsx
  53. 0 156
      web/src/pages/workflow-editor/WorkflowEditorPage.tsx
  54. 0 46
      web/src/pages/workflow-editor/components/EditorToolbar.tsx
  55. 0 79
      web/src/pages/workflow-editor/components/FlowCanvas.tsx
  56. 0 26
      web/src/pages/workflow-editor/components/NodePanel.tsx
  57. 0 64
      web/src/pages/workflow-editor/components/PropertiesPanel.tsx
  58. 0 34
      web/src/pages/workflow-editor/components/ValidationReport.tsx
  59. 0 1
      web/src/pages/workflow-editor/nodes/AnswerNode.tsx
  60. 0 1
      web/src/pages/workflow-editor/nodes/ConditionNode.tsx
  61. 0 29
      web/src/pages/workflow-editor/nodes/DefaultNode.tsx
  62. 0 1
      web/src/pages/workflow-editor/nodes/LLMNode.tsx
  63. 0 1
      web/src/pages/workflow-editor/nodes/StartNode.tsx
  64. 0 1
      web/src/pages/workflow-editor/nodes/ToolNode.tsx
  65. 0 16
      web/src/pages/workflow-editor/nodes/node-types.ts
  66. 0 33
      web/src/pages/workflow-editor/utils/dsl-to-flow.ts
  67. 0 20
      web/src/pages/workflow-editor/utils/flow-to-dsl.ts
  68. 0 54
      web/src/pages/workflows/WorkflowListPage.tsx
  69. 0 67
      web/src/pages/workflows/components/CreateWorkflowDialog.tsx
  70. 0 33
      web/src/pages/workflows/components/WorkflowCard.tsx
  71. 4 6
      web/src/stores/auth.ts
  72. 0 1
      web/src/stores/index.ts
  73. 9 0
      web/src/stores/ui.ts
  74. 0 24
      web/src/stores/workflow.ts
  75. 0 3
      web/src/types/agent.ts
  76. 0 3
      web/src/types/api-key.ts
  77. 0 3
      web/src/types/app.ts
  78. 0 4
      web/src/types/auth.ts
  79. 1 1
      web/src/types/index.ts
  80. 0 3
      web/src/types/knowledge.ts
  81. 65 0
      web/src/types/model-provider.ts
  82. 0 4
      web/src/types/runtime.ts
  83. 0 3
      web/src/types/session.ts
  84. 0 3
      web/src/types/team.ts
  85. 0 4
      web/src/types/tool.ts
  86. 5 0
      web/src/vite-env.d.ts

+ 86 - 40
web/package-lock.json

@@ -12,9 +12,12 @@
         "axios": "^1.9.0",
         "clsx": "^2.1.1",
         "dagre": "^0.8.5",
+        "i18next": "^26.0.8",
+        "i18next-browser-languagedetector": "^8.2.1",
         "lucide-react": "^0.510.0",
         "react": "^18.3.1",
         "react-dom": "^18.3.1",
+        "react-i18next": "^17.0.4",
         "react-router-dom": "^6.28.0",
         "recharts": "^2.15.3",
         "tailwind-merge": "^2.6.0",
@@ -975,9 +978,6 @@
         "arm"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -992,9 +992,6 @@
         "arm"
       ],
       "dev": true,
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1009,9 +1006,6 @@
         "arm64"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1026,9 +1020,6 @@
         "arm64"
       ],
       "dev": true,
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1043,9 +1034,6 @@
         "loong64"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1060,9 +1048,6 @@
         "loong64"
       ],
       "dev": true,
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1077,9 +1062,6 @@
         "ppc64"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1094,9 +1076,6 @@
         "ppc64"
       ],
       "dev": true,
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1111,9 +1090,6 @@
         "riscv64"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1128,9 +1104,6 @@
         "riscv64"
       ],
       "dev": true,
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1145,9 +1118,6 @@
         "s390x"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1162,9 +1132,6 @@
         "x64"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1179,9 +1146,6 @@
         "x64"
       ],
       "dev": true,
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -2481,6 +2445,52 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/html-parse-stringify": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
+      "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
+      "license": "MIT",
+      "dependencies": {
+        "void-elements": "3.1.0"
+      }
+    },
+    "node_modules/i18next": {
+      "version": "26.0.8",
+      "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.8.tgz",
+      "integrity": "sha512-BRzLom0mhDhV9v0QhgUUHWQJuwFmnr1194xEcNLYD6ym8y8s542n4jXUvRLnhNTbh9PmpU6kGZamyuGHQMsGjw==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://www.locize.com/i18next"
+        },
+        {
+          "type": "individual",
+          "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
+        },
+        {
+          "type": "individual",
+          "url": "https://www.locize.com"
+        }
+      ],
+      "license": "MIT",
+      "peerDependencies": {
+        "typescript": "^5 || ^6"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/i18next-browser-languagedetector": {
+      "version": "8.2.1",
+      "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
+      "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.23.2"
+      }
+    },
     "node_modules/internmap": {
       "version": "2.0.3",
       "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
@@ -3061,6 +3071,33 @@
         "react": "^18.3.1"
       }
     },
+    "node_modules/react-i18next": {
+      "version": "17.0.4",
+      "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.4.tgz",
+      "integrity": "sha512-hQipmK4EF0y6RO6tt6WuqnmWpWYEXmQUUzecmMBuNsIgYd3smXcG4GtYPWhvgxn0pqMOItKlEO8H24HCs5hc3g==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.29.2",
+        "html-parse-stringify": "^3.0.1",
+        "use-sync-external-store": "^1.6.0"
+      },
+      "peerDependencies": {
+        "i18next": ">= 26.0.1",
+        "react": ">= 16.8.0",
+        "typescript": "^5 || ^6"
+      },
+      "peerDependenciesMeta": {
+        "react-dom": {
+          "optional": true
+        },
+        "react-native": {
+          "optional": true
+        },
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/react-is": {
       "version": "18.3.1",
       "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
@@ -3511,7 +3548,7 @@
       "version": "5.9.3",
       "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
       "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
-      "dev": true,
+      "devOptional": true,
       "license": "Apache-2.0",
       "bin": {
         "tsc": "bin/tsc",
@@ -3703,6 +3740,15 @@
         "url": "https://github.com/sponsors/jonschlinkert"
       }
     },
+    "node_modules/void-elements": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
+      "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/yallist": {
       "version": "3.1.1",
       "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

+ 3 - 0
web/package.json

@@ -13,9 +13,12 @@
     "axios": "^1.9.0",
     "clsx": "^2.1.1",
     "dagre": "^0.8.5",
+    "i18next": "^26.0.8",
+    "i18next-browser-languagedetector": "^8.2.1",
     "lucide-react": "^0.510.0",
     "react": "^18.3.1",
     "react-dom": "^18.3.1",
+    "react-i18next": "^17.0.4",
     "react-router-dom": "^6.28.0",
     "recharts": "^2.15.3",
     "tailwind-merge": "^2.6.0",

+ 21 - 7
web/src/App.tsx

@@ -1,30 +1,34 @@
 import { lazy, Suspense, useEffect } from "react";
 import { Navigate, Route, Routes } from "react-router-dom";
+import { useTranslation } from "react-i18next";
 import { ProtectedRoute } from "@/components/auth/ProtectedRoute";
 import { AppLayout } from "@/components/layout/AppLayout";
 import { ErrorBoundary } from "@/components/shared/ErrorBoundary";
 import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
 import { Toaster } from "@/components/ui/toaster";
 import { useUiStore } from "@/stores/ui";
+import i18n from "@/i18n";
 
 const LoginPage = lazy(() => import("@/pages/login/LoginPage").then((module) => ({ default: module.LoginPage })));
 const DashboardPage = lazy(() => import("@/pages/dashboard/DashboardPage").then((module) => ({ default: module.DashboardPage })));
-const WorkflowListPage = lazy(() => import("@/pages/workflows/WorkflowListPage").then((module) => ({ default: module.WorkflowListPage })));
-const WorkflowEditorPage = lazy(() => import("@/pages/workflow-editor/WorkflowEditorPage").then((module) => ({ default: module.WorkflowEditorPage })));
 const AgentListPage = lazy(() => import("@/pages/agents/AgentListPage").then((module) => ({ default: module.AgentListPage })));
 const SessionChatPage = lazy(() => import("@/pages/sessions/SessionChatPage").then((module) => ({ default: module.SessionChatPage })));
 const ToolsPage = lazy(() => import("@/pages/tools/ToolsPage").then((module) => ({ default: module.ToolsPage })));
 const KnowledgePage = lazy(() => import("@/pages/knowledge/KnowledgePage").then((module) => ({ default: module.KnowledgePage })));
 const TeamsPage = lazy(() => import("@/pages/teams/TeamsPage").then((module) => ({ default: module.TeamsPage })));
+const SkillsPage = lazy(() => import("@/pages/skills/SkillsPage").then((module) => ({ default: module.SkillsPage })));
+const ModelProvidersPage = lazy(() => import("@/pages/models/ModelProvidersPage").then((module) => ({ default: module.ModelProvidersPage })));
 const SettingsPage = lazy(() => import("@/pages/settings/SettingsPage").then((module) => ({ default: module.SettingsPage })));
 
 export default function App() {
+  const { t } = useTranslation();
   return (
     <>
       <ThemeSync />
+      <LanguageSync />
       <RoutePreloader />
       <ErrorBoundary>
-        <Suspense fallback={<LoadingSpinner label="Loading studio" />}>
+        <Suspense fallback={<LoadingSpinner label={t("app.loadingStudio")} />}>
           <Routes>
             <Route path="/login" element={<LoginPage />} />
             <Route path="/" element={<Navigate to="/dashboard" replace />} />
@@ -36,14 +40,14 @@ export default function App() {
               }
             >
               <Route path="/dashboard" element={<DashboardPage />} />
-              <Route path="/workflows" element={<WorkflowListPage />} />
-              <Route path="/workflows/:workflowId/editor" element={<WorkflowEditorPage />} />
               <Route path="/agents" element={<AgentListPage />} />
               <Route path="/sessions" element={<SessionChatPage />} />
               <Route path="/tools" element={<ToolsPage />} />
               <Route path="/knowledge" element={<KnowledgePage />} />
               <Route path="/knowledge/:section" element={<KnowledgePage />} />
               <Route path="/teams" element={<TeamsPage />} />
+              <Route path="/skills" element={<SkillsPage />} />
+              <Route path="/models" element={<ModelProvidersPage />} />
               <Route path="/settings" element={<SettingsPage />} />
             </Route>
             <Route path="*" element={<Navigate to="/dashboard" replace />} />
@@ -68,13 +72,13 @@ function RoutePreloader() {
     const preload = () => {
       void Promise.all([
         import("@/pages/dashboard/DashboardPage"),
-        import("@/pages/workflows/WorkflowListPage"),
-        import("@/pages/workflow-editor/WorkflowEditorPage"),
         import("@/pages/agents/AgentListPage"),
         import("@/pages/sessions/SessionChatPage"),
         import("@/pages/tools/ToolsPage"),
         import("@/pages/knowledge/KnowledgePage"),
         import("@/pages/teams/TeamsPage"),
+        import("@/pages/skills/SkillsPage"),
+        import("@/pages/models/ModelProvidersPage"),
         import("@/pages/settings/SettingsPage"),
       ]);
     };
@@ -85,3 +89,13 @@ function RoutePreloader() {
   }, []);
   return null;
 }
+
+function LanguageSync() {
+  const language = useUiStore((state) => state.language);
+  useEffect(() => {
+    if (language && i18n.language !== language) {
+      i18n.changeLanguage(language);
+    }
+  }, [language]);
+  return null;
+}

+ 24 - 1
web/src/api/agents.ts

@@ -7,7 +7,6 @@ export async function listAgents() {
 }
 
 export async function createAgent(payload: {
-  tenant_id: string;
   code?: string;
   name: string;
   description?: string;
@@ -29,6 +28,30 @@ export async function listAgentVersions(agentId?: string) {
   return data;
 }
 
+export async function createAgentVersion(payload: {
+  agent_id: string;
+  role?: string;
+  goal?: string | null;
+  system_prompt?: string;
+  model_config_json?: JSONObject;
+  memory_policy_json?: JSONObject;
+  tool_refs_json?: JSONObject[];
+  skill_refs_json?: JSONObject[];
+  status?: "draft" | "published" | "deprecated";
+}) {
+  const { data } = await apiClient.post<AgentVersion>("/agents/versions", {
+    status: "draft",
+    role: "assistant",
+    system_prompt: "",
+    model_config_json: {},
+    memory_policy_json: {},
+    tool_refs_json: [],
+    skill_refs_json: [],
+    ...payload,
+  });
+  return data;
+}
+
 export async function listAgentRuns(agentId?: string) {
   const { data } = await apiClient.get<AgentRun[]>("/agents/runs", { params: tenantParams({ agent_id: agentId }) });
   return data;

+ 0 - 1
web/src/api/api-keys.ts

@@ -13,7 +13,6 @@ export async function listApiKeys() {
 
 export async function updateApiKeyStatus(apiKeyId: string, status: ApiKeyStatus) {
   const { data } = await apiClient.patch<ApiKeyResponse>(`/api-keys/${apiKeyId}/status`, {
-    ...tenantParams(),
     status,
   });
   return data;

+ 2 - 4
web/src/api/client.ts

@@ -11,9 +11,8 @@ apiClient.interceptors.request.use((config) => {
   if (mockMode) {
     config.adapter = async (adapterConfig) => mockAxiosResponse(adapterConfig);
   }
-  const { apiKey, tenantId, userId } = useAuthStore.getState();
+  const { apiKey, userId } = useAuthStore.getState();
   if (apiKey) config.headers.set("x-api-key", apiKey);
-  if (tenantId) config.headers.set("x-tenant-id", tenantId);
   if (userId) config.headers.set("x-user-id", userId);
   return config;
 });
@@ -35,6 +34,5 @@ apiClient.interceptors.response.use(
 );
 
 export function tenantParams(extra?: Record<string, string | number | boolean | undefined | null>) {
-  const tenant_id = useAuthStore.getState().tenantId || "public";
-  return { tenant_id, ...extra };
+  return { ...extra };
 }

+ 1 - 2
web/src/api/health.ts

@@ -3,12 +3,11 @@ import { apiClient } from "./client";
 import { mockHealth, mockMode } from "./mock";
 import type { GatewayServicesHealth, ServiceHealth } from "@/types";
 
-export async function getHealth(apiKey?: string, tenantId?: string, userId?: string) {
+export async function getHealth(apiKey?: string, userId?: string) {
   if (mockMode) return mockHealth();
   const { data } = await axios.get<ServiceHealth>("/health", {
     headers: {
       ...(apiKey ? { "x-api-key": apiKey } : {}),
-      ...(tenantId ? { "x-tenant-id": tenantId } : {}),
       ...(userId ? { "x-user-id": userId } : {}),
     },
   });

+ 1 - 1
web/src/api/index.ts

@@ -2,10 +2,10 @@ export * from "./client";
 export * from "./health";
 export * from "./auth";
 export * from "./api-keys";
-export * from "./workflows";
 export * from "./agents";
 export * from "./sessions";
 export * from "./runtime";
 export * from "./tools";
 export * from "./knowledge";
 export * from "./teams";
+export * from "./model-providers";

+ 0 - 5
web/src/api/knowledge.ts

@@ -22,7 +22,6 @@ export async function listKnowledgeDocuments(knowledgeBaseId?: string) {
 
 export async function searchKnowledge(knowledgeBaseId: string, query: string, topK = 5, filters: JSONObject = {}) {
   const { data } = await apiClient.post<SearchResult[]>("/knowledge/search", {
-    ...tenantParams(),
     knowledge_base_id: knowledgeBaseId,
     query,
     top_k: topK,
@@ -32,19 +31,16 @@ export async function searchKnowledge(knowledgeBaseId: string, query: string, to
 }
 
 export async function updateKnowledgeBaseStatus(payload: {
-  tenant_id: string;
   knowledge_base_id: string;
   status: KnowledgeBase["status"];
 }) {
   const { data } = await apiClient.patch<KnowledgeBase>(`/knowledge/bases/${payload.knowledge_base_id}/status`, {
-    tenant_id: payload.tenant_id,
     status: payload.status,
   });
   return data;
 }
 
 export async function createKnowledgeBase(payload: {
-  tenant_id: string;
   code?: string;
   name: string;
   description?: string | null;
@@ -58,7 +54,6 @@ export async function createKnowledgeBase(payload: {
 }
 
 export async function createKnowledgeDocument(payload: {
-  tenant_id: string;
   knowledge_base_id: string;
   title: string;
   content_text?: string | null;

+ 187 - 222
web/src/api/mock.ts

@@ -6,13 +6,17 @@ import type {
   ApiKeyCreateResponse,
   ApiKeyResponse,
   AppResponse,
+  DiscoverModelsResponse,
   DownstreamServiceHealth,
   ExecutionLog,
   GatewayServicesHealth,
+  JSONObject,
   KnowledgeBase,
   KnowledgeChunk,
   KnowledgeDocument,
   Message,
+  ModelProvider,
+  ModelProviderTestResult,
   NodeRun,
   SearchResult,
   ServiceHealth,
@@ -25,12 +29,7 @@ import type {
   ToolDefinition,
   ToolVersion,
   TraceSpan,
-  WorkflowDefinition,
-  WorkflowDesignerValidateResponse,
-  WorkflowDSL,
-  WorkflowDebuggerPlanResponse,
   WorkflowRun,
-  WorkflowVersion,
 } from "@/types";
 
 export const mockMode = import.meta.env.VITE_USE_MOCKS !== "false";
@@ -43,7 +42,6 @@ const hashText = (value: string) => Array.from(value).reduce((hash, char) => ((h
 const apps: AppResponse[] = [
   {
     id: "app_customer_ops",
-    tenant_id: "public",
     code: "customer_ops",
     name: "Customer Ops",
     description: "AI assisted customer operations.",
@@ -53,7 +51,6 @@ const apps: AppResponse[] = [
   },
   {
     id: "app_research",
-    tenant_id: "public",
     code: "research_lab",
     name: "Research Lab",
     description: "Knowledge-heavy research workflows.",
@@ -63,88 +60,9 @@ const apps: AppResponse[] = [
   },
 ];
 
-const sampleDsl: WorkflowDSL = {
-  code: "support_triage",
-  name: "Support Triage",
-  nodes: [
-    { id: "start", type: "start", name: "Start", config: {} },
-    { id: "classify", type: "llm", name: "Classify request", config: { model: "gpt-4.1", temperature: 0.2 } },
-    { id: "lookup", type: "tool", name: "Lookup customer", config: { tool_code: "crm_lookup" } },
-    { id: "answer", type: "answer", name: "Draft answer", config: {} },
-  ],
-  edges: [
-    { source: "start", target: "classify" },
-    { source: "classify", target: "lookup", condition: "needs_context" },
-    { source: "lookup", target: "answer" },
-  ],
-};
-
-const workflows: WorkflowDefinition[] = [
-  {
-    id: "wf_support_triage",
-    tenant_id: "public",
-    app_id: "app_customer_ops",
-    code: "support_triage",
-    name: "Support Triage",
-    workflow_type: "main",
-    latest_version_no: 3,
-    created_time: iso(-5800),
-  },
-  {
-    id: "wf_research_digest",
-    tenant_id: "public",
-    app_id: "app_research",
-    code: "research_digest",
-    name: "Research Digest",
-    workflow_type: "scheduled",
-    latest_version_no: 1,
-    created_time: iso(-3200),
-  },
-];
-
-const workflowVersions: WorkflowVersion[] = [
-  {
-    id: "wfv_support_triage_3",
-    tenant_id: "public",
-    workflow_id: "wf_support_triage",
-    version_no: 3,
-    dsl_json: sampleDsl,
-    compiled_plan_json: null,
-    schema_version: "1.0",
-    checksum: "mock-checksum",
-    status: "draft",
-    created_time: iso(-720),
-  },
-  {
-    id: "wfv_research_digest_1",
-    tenant_id: "public",
-    workflow_id: "wf_research_digest",
-    version_no: 1,
-    dsl_json: {
-      code: "research_digest",
-      name: "Research Digest",
-      nodes: [
-        { id: "start", type: "start", name: "Start", config: {} },
-        { id: "summarize", type: "llm", name: "Summarize", config: { model: "gpt-4.1-mini" } },
-        { id: "answer", type: "answer", name: "Publish", config: {} },
-      ],
-      edges: [
-        { source: "start", target: "summarize" },
-        { source: "summarize", target: "answer" },
-      ],
-    },
-    compiled_plan_json: null,
-    schema_version: "1.0",
-    checksum: "mock-checksum",
-    status: "published",
-    created_time: iso(-2400),
-  },
-];
-
 const agents: AgentDefinition[] = [
   {
     id: "agent_support",
-    tenant_id: "public",
     code: "support_agent",
     name: "Support Agent",
     description: "Handles first-response customer support.",
@@ -155,7 +73,6 @@ const agents: AgentDefinition[] = [
   },
   {
     id: "agent_researcher",
-    tenant_id: "public",
     code: "researcher",
     name: "Researcher",
     description: "Finds, compares, and summarizes knowledge sources.",
@@ -169,7 +86,6 @@ const agents: AgentDefinition[] = [
 const agentVersions: AgentVersion[] = [
   {
     id: "agv_support_2",
-    tenant_id: "public",
     agent_id: "agent_support",
     version_no: 2,
     status: "published",
@@ -188,7 +104,6 @@ const agentVersions: AgentVersion[] = [
 const agentRuns: AgentRun[] = [
   {
     id: "agr_001",
-    tenant_id: "public",
     agent_id: "agent_support",
     agent_version_id: "agv_support_2",
     session_id: "ses_001",
@@ -211,7 +126,6 @@ const agentRuns: AgentRun[] = [
 const sessions: Session[] = [
   {
     id: "ses_001",
-    tenant_id: "public",
     app_id: "app_customer_ops",
     user_id: "demo-user",
     channel_type: "web",
@@ -223,7 +137,6 @@ const sessions: Session[] = [
   },
   {
     id: "ses_002",
-    tenant_id: "public",
     app_id: "app_research",
     user_id: "demo-user",
     channel_type: "web",
@@ -238,7 +151,6 @@ const sessions: Session[] = [
 const messages: Message[] = [
   {
     id: "msg_001",
-    tenant_id: "public",
     session_id: "ses_001",
     turn_id: "turn_001",
     role: "user",
@@ -249,7 +161,6 @@ const messages: Message[] = [
   },
   {
     id: "msg_002",
-    tenant_id: "public",
     session_id: "ses_001",
     turn_id: "turn_001",
     role: "assistant",
@@ -263,7 +174,6 @@ const messages: Message[] = [
 const runs: WorkflowRun[] = [
   {
     id: "run_001",
-    tenant_id: "public",
     app_id: "app_customer_ops",
     app_version_id: "appv_001",
     workflow_id: "wf_support_triage",
@@ -281,7 +191,6 @@ const runs: WorkflowRun[] = [
   },
   {
     id: "run_002",
-    tenant_id: "public",
     app_id: "app_research",
     app_version_id: "appv_002",
     workflow_id: "wf_research_digest",
@@ -302,7 +211,6 @@ const runs: WorkflowRun[] = [
 const nodeRuns: NodeRun[] = [
   {
     id: "nr_001",
-    tenant_id: "public",
     run_id: "run_001",
     node_id: "classify",
     node_type: "llm",
@@ -320,7 +228,6 @@ const nodeRuns: NodeRun[] = [
 const tools: ToolDefinition[] = [
   {
     id: "tool_crm",
-    tenant_id: "public",
     plugin_id: null,
     code: "crm_lookup",
     name: "CRM Lookup",
@@ -330,7 +237,6 @@ const tools: ToolDefinition[] = [
   },
   {
     id: "tool_search",
-    tenant_id: "public",
     plugin_id: null,
     code: "knowledge_search",
     name: "Knowledge Search",
@@ -343,7 +249,6 @@ const tools: ToolDefinition[] = [
 const toolVersions: ToolVersion[] = [
   {
     id: "tv_crm_1",
-    tenant_id: "public",
     tool_id: "tool_crm",
     version_no: 1,
     input_schema_json: { customer_id: "string" },
@@ -358,7 +263,6 @@ const toolVersions: ToolVersion[] = [
 const toolBindings: ToolBinding[] = [
   {
     id: "tb_crm_customer_ops",
-    tenant_id: "public",
     app_id: "app_customer_ops",
     tool_version_id: "tv_crm_1",
     credential_id: "tc_ops",
@@ -372,7 +276,6 @@ const toolBindings: ToolBinding[] = [
 const toolCredentials: ToolCredential[] = [
   {
     id: "tc_ops",
-    tenant_id: "public",
     name: "Operations Sandbox",
     credential_type: "api_key",
     secret_fingerprint: "sha256:mock",
@@ -385,7 +288,6 @@ const toolCredentials: ToolCredential[] = [
 const knowledgeBases: KnowledgeBase[] = [
   {
     id: "kb_product",
-    tenant_id: "public",
     code: "product_docs",
     name: "Product Docs",
     description: "Public product and support documentation.",
@@ -398,7 +300,6 @@ const knowledgeBases: KnowledgeBase[] = [
 const knowledgeDocuments: KnowledgeDocument[] = [
   {
     id: "kd_invoice",
-    tenant_id: "public",
     knowledge_base_id: "kb_product",
     title: "Billing and Invoice FAQ",
     source_type: "text",
@@ -414,7 +315,6 @@ const knowledgeDocuments: KnowledgeDocument[] = [
 const knowledgeChunks: KnowledgeChunk[] = [
   {
     id: "chunk_invoice_1",
-    tenant_id: "public",
     knowledge_base_id: "kb_product",
     document_id: "kd_invoice",
     chunk_index: 0,
@@ -430,7 +330,6 @@ const knowledgeChunks: KnowledgeChunk[] = [
 const teams: TeamDefinition[] = [
   {
     id: "team_ops",
-    tenant_id: "public",
     code: "ops_team",
     name: "Ops Team",
     description: "Supervisor-led customer operations team.",
@@ -444,7 +343,6 @@ const teams: TeamDefinition[] = [
 const teamVersions: TeamVersion[] = [
   {
     id: "teamv_ops_1",
-    tenant_id: "public",
     team_id: "team_ops",
     version_no: 1,
     status: "published",
@@ -460,7 +358,6 @@ const teamVersions: TeamVersion[] = [
 const teamRuns: TeamRun[] = [
   {
     id: "tr_001",
-    tenant_id: "public",
     team_id: "team_ops",
     team_version_id: "teamv_ops_1",
     session_id: "ses_001",
@@ -476,7 +373,6 @@ const teamRuns: TeamRun[] = [
 const apiKeys: ApiKeyResponse[] = [
   {
     id: "ak_demo",
-    tenant_id: "public",
     name: "Demo Operator Key",
     key_prefix: "ap_mock",
     status: "active",
@@ -498,6 +394,64 @@ const services: DownstreamServiceHealth[] = [
   "auth-service",
 ].map((service) => ({ service, status: "ok", url: `mock://${service}`, status_code: 200, error_message: null }));
 
+const modelProviders: ModelProvider[] = [
+  {
+    id: "mp_openai",
+    name: "OpenAI",
+    provider_type: "openai",
+    status: "active",
+    base_url: "https://api.openai.com/v1",
+    api_key_ref: "sk-***masked",
+    models: [
+      { model_id: "gpt-4.1", display_name: "GPT-4.1", model_type: "chat", enabled: true },
+      { model_id: "gpt-4.1-mini", display_name: "GPT-4.1 Mini", model_type: "chat", enabled: true },
+      { model_id: "gpt-4.1-nano", display_name: "GPT-4.1 Nano", model_type: "chat", enabled: true },
+      { model_id: "o3", display_name: "o3", model_type: "reasoning", enabled: false },
+      { model_id: "o4-mini", display_name: "o4-mini", model_type: "reasoning", enabled: false },
+      { model_id: "text-embedding-3-large", display_name: "Text Embedding 3 Large", model_type: "embedding", enabled: false },
+      { model_id: "dall-e-3", display_name: "DALL-E 3", model_type: "image", enabled: false },
+      { model_id: "gpt-4o-mini-audio-preview", display_name: "GPT-4o Audio", model_type: "audio", enabled: false },
+    ],
+    default_model: "gpt-4.1",
+    extra_config_json: { max_tokens: 4096 },
+    created_time: iso(-3000),
+    updated_time: iso(-500),
+  },
+  {
+    id: "mp_anthropic",
+    name: "Anthropic",
+    provider_type: "anthropic",
+    status: "active",
+    base_url: "https://api.anthropic.com",
+    api_key_ref: "sk-ant-***masked",
+    models: [
+      { model_id: "claude-sonnet-4-6", display_name: "Claude Sonnet 4.6", model_type: "chat", enabled: true },
+      { model_id: "claude-opus-4-6", display_name: "Claude Opus 4.6", model_type: "chat", enabled: true },
+      { model_id: "claude-haiku-4-5", display_name: "Claude Haiku 4.5", model_type: "chat", enabled: false },
+    ],
+    default_model: "claude-sonnet-4-6",
+    extra_config_json: {},
+    created_time: iso(-2500),
+    updated_time: null,
+  },
+  {
+    id: "mp_deepseek",
+    name: "DeepSeek",
+    provider_type: "deepseek",
+    status: "inactive",
+    base_url: "https://api.deepseek.com/v1",
+    api_key_ref: "",
+    models: [
+      { model_id: "deepseek-chat", display_name: "DeepSeek Chat", model_type: "chat", enabled: true },
+      { model_id: "deepseek-reasoner", display_name: "DeepSeek Reasoner", model_type: "reasoning", enabled: false },
+    ],
+    default_model: "deepseek-chat",
+    extra_config_json: {},
+    created_time: iso(-1200),
+    updated_time: null,
+  },
+];
+
 export function mockHealth(): ServiceHealth {
   return { service: "api-gateway", status: "ok", database: "mock", checked_time: iso() };
 }
@@ -537,7 +491,6 @@ function route(config: AxiosRequestConfig): unknown {
     const key = `ap_mock_${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
     const created: ApiKeyCreateResponse = {
       id: id("ak"),
-      tenant_id: String(payload.tenant_id ?? "public"),
       name: String(payload.name ?? "New key"),
       key_prefix: key.slice(0, 8),
       api_key: key,
@@ -557,65 +510,11 @@ function route(config: AxiosRequestConfig): unknown {
   }
 
   if (url === "/workflows/apps" && method === "get") return apps;
-  if (url === "/workflows/apps" && method === "post") {
-    const created: AppResponse = {
-      id: id("app"),
-      tenant_id: String(payload.tenant_id ?? "public"),
-      code: String(payload.code ?? "app"),
-      name: String(payload.name ?? "New App"),
-      description: (payload.description as string | null) ?? null,
-      owner_user_id: (payload.owner_user_id as string | null) ?? null,
-      settings_json: {},
-      created_time: iso(),
-    };
-    apps.unshift(created);
-    return created;
-  }
-  if (url === "/workflows/apps/versions") return [];
-  if (url === "/workflows" && method === "get") return params.app_id ? workflows.filter((item) => item.app_id === params.app_id) : workflows;
-  if (url === "/workflows" && method === "post") {
-    const created: WorkflowDefinition = {
-      id: id("wf"),
-      tenant_id: String(payload.tenant_id ?? "public"),
-      app_id: String(payload.app_id ?? apps[0]?.id ?? "app_mock"),
-      code: String(payload.code ?? "workflow"),
-      name: String(payload.name ?? "New Workflow"),
-      workflow_type: String(payload.workflow_type ?? "main"),
-      latest_version_no: 0,
-      created_time: iso(),
-    };
-    workflows.unshift(created);
-    return created;
-  }
-  if (url === "/workflows/versions" && method === "get") return workflowVersions.filter((item) => item.workflow_id === params.workflow_id);
-  if (url === "/workflows/versions" && method === "post") {
-    const versionNo = workflowVersions.filter((item) => item.workflow_id === payload.workflow_id).length + 1;
-    const created: WorkflowVersion = {
-      id: id("wfv"),
-      tenant_id: String(payload.tenant_id ?? "public"),
-      workflow_id: String(payload.workflow_id),
-      version_no: versionNo,
-      dsl_json: payload.dsl_json as WorkflowDSL,
-      compiled_plan_json: null,
-      schema_version: "1.0",
-      checksum: "mock",
-      status: String(payload.status ?? "draft"),
-      created_time: iso(),
-    };
-    workflowVersions.unshift(created);
-    const workflow = workflows.find((item) => item.id === created.workflow_id);
-    if (workflow) workflow.latest_version_no = versionNo;
-    return created;
-  }
-  if (url.startsWith("/workflows/versions/")) return workflowVersions.find((item) => item.id === url.split("/")[3]) ?? workflowVersions[0] ?? {};
-  if (url === "/workflows/designer/validate") return validateDsl(payload.dsl_json as WorkflowDSL);
-  if (url === "/workflows/designer/debug") return debugDsl(payload.dsl_json as WorkflowDSL);
 
   if (url === "/agents" && method === "get") return agents;
   if (url === "/agents" && method === "post") {
     const created: AgentDefinition = {
       id: id("agent"),
-      tenant_id: String(payload.tenant_id ?? "public"),
       code: String(payload.code ?? "agent"),
       name: String(payload.name ?? "New Agent"),
       description: (payload.description as string | null) ?? null,
@@ -627,14 +526,32 @@ function route(config: AxiosRequestConfig): unknown {
     agents.unshift(created);
     return created;
   }
-  if (url === "/agents/versions") return agentVersions.filter((item) => item.agent_id === params.agent_id);
+  if (url === "/agents/versions" && method === "post") {
+    const created: AgentVersion = {
+      id: id("agv"),
+      agent_id: String(payload.agent_id),
+      version_no: agentVersions.filter((v) => v.agent_id === payload.agent_id).length + 1,
+      status: (payload.status as AgentVersion["status"]) ?? "draft",
+      role: String(payload.role ?? "assistant"),
+      goal: (payload.goal as string | null) ?? null,
+      system_prompt: String(payload.system_prompt ?? ""),
+      model_config_json: (payload.model_config_json as JSONObject) ?? {},
+      memory_policy_json: (payload.memory_policy_json as JSONObject) ?? {},
+      tool_refs_json: (payload.tool_refs_json as JSONObject[]) ?? [],
+      skill_refs_json: (payload.skill_refs_json as JSONObject[]) ?? [],
+      published_time: null,
+      created_time: iso(),
+    };
+    agentVersions.unshift(created);
+    return created;
+  }
+  if (url === "/agents/versions" && method === "get") return agentVersions.filter((item) => item.agent_id === params.agent_id);
   if (url === "/agents/runs") return params.agent_id ? agentRuns.filter((item) => item.agent_id === params.agent_id) : agentRuns;
 
   if (url === "/sessions" && method === "get") return params.app_id ? sessions.filter((item) => item.app_id === params.app_id) : sessions;
   if (url === "/sessions" && method === "post") {
     const created: Session = {
       id: id("ses"),
-      tenant_id: String(payload.tenant_id ?? "public"),
       app_id: String(payload.app_id ?? apps[0]?.id ?? "app_mock"),
       user_id: String(payload.user_id ?? "demo-user"),
       channel_type: String(payload.channel_type ?? "web"),
@@ -651,7 +568,6 @@ function route(config: AxiosRequestConfig): unknown {
   if (url === "/sessions/messages" && method === "post") {
     const created: Message = {
       id: id("msg"),
-      tenant_id: String(payload.tenant_id ?? "public"),
       session_id: String(payload.session_id),
       turn_id: id("turn"),
       role: String(payload.role ?? "user"),
@@ -677,7 +593,6 @@ function route(config: AxiosRequestConfig): unknown {
   if (url === "/tools" && method === "post") {
     const created: ToolDefinition = {
       id: id("tool"),
-      tenant_id: String(payload.tenant_id ?? "public"),
       plugin_id: (payload.plugin_id as string | null) ?? null,
       code: String(payload.code ?? "tool"),
       name: String(payload.name ?? "New Tool"),
@@ -693,7 +608,6 @@ function route(config: AxiosRequestConfig): unknown {
     const versionNo = toolVersions.filter((item) => item.tool_id === payload.tool_id).length + 1;
     const created: ToolVersion = {
       id: id("tv"),
-      tenant_id: String(payload.tenant_id ?? "public"),
       tool_id: String(payload.tool_id),
       version_no: versionNo,
       input_schema_json: (payload.input_schema_json as Record<string, never>) ?? {},
@@ -712,7 +626,6 @@ function route(config: AxiosRequestConfig): unknown {
     const secret = JSON.stringify(payload.secret_json ?? {});
     const created: ToolCredential = {
       id: id("tc"),
-      tenant_id: String(payload.tenant_id ?? "public"),
       name: String(payload.name ?? "New Credential"),
       credential_type: String(payload.credential_type ?? "api_key"),
       secret_fingerprint: `sha256:${Math.abs(hashText(secret)).toString(16).slice(0, 10)}`,
@@ -728,7 +641,6 @@ function route(config: AxiosRequestConfig): unknown {
   if (url === "/knowledge/bases" && method === "post") {
     const created: KnowledgeBase = {
       id: id("kb"),
-      tenant_id: String(payload.tenant_id ?? "public"),
       code: String(payload.code ?? "knowledge"),
       name: String(payload.name ?? "Knowledge Base"),
       description: (payload.description as string | null) ?? null,
@@ -761,7 +673,6 @@ function route(config: AxiosRequestConfig): unknown {
   if (url === "/knowledge/documents" && method === "post") {
     const created: KnowledgeDocument = {
       id: id("kd"),
-      tenant_id: String(payload.tenant_id ?? "public"),
       knowledge_base_id: String(payload.knowledge_base_id),
       title: String(payload.title ?? "Untitled document"),
       source_type: String(payload.source_type ?? "text"),
@@ -775,7 +686,6 @@ function route(config: AxiosRequestConfig): unknown {
     const contentText = String(payload.content_text ?? "");
     const chunk: KnowledgeChunk = {
       id: id("chunk"),
-      tenant_id: created.tenant_id,
       knowledge_base_id: created.knowledge_base_id,
       document_id: created.id,
       chunk_index: 0,
@@ -799,7 +709,6 @@ function route(config: AxiosRequestConfig): unknown {
   if (url === "/teams" && method === "post") {
     const created: TeamDefinition = {
       id: id("team"),
-      tenant_id: String(payload.tenant_id ?? "public"),
       code: String(payload.code ?? "team"),
       name: String(payload.name ?? "New Team"),
       description: (payload.description as string | null) ?? null,
@@ -816,7 +725,6 @@ function route(config: AxiosRequestConfig): unknown {
     const versionNo = teamVersions.filter((item) => item.team_id === payload.team_id).length + 1;
     const created: TeamVersion = {
       id: id("teamv"),
-      tenant_id: String(payload.tenant_id ?? "public"),
       team_id: String(payload.team_id),
       version_no: versionNo,
       status: String(payload.status ?? "draft") as TeamVersion["status"],
@@ -834,7 +742,6 @@ function route(config: AxiosRequestConfig): unknown {
   if (url === "/teams/runs" && method === "post") {
     const created: TeamRun = {
       id: id("tr"),
-      tenant_id: String(payload.tenant_id ?? "public"),
       team_id: String(payload.team_id),
       team_version_id: String(payload.team_version_id ?? teamVersions.find((item) => item.team_id === payload.team_id)?.id ?? "teamv_mock"),
       session_id: null,
@@ -849,65 +756,124 @@ function route(config: AxiosRequestConfig): unknown {
     return created;
   }
 
-  return {};
-}
+  if (url === "/model-providers" && method === "get") return modelProviders;
+  if (url === "/model-providers" && method === "post") {
+    const providerType = String(payload.provider_type ?? "openai") as ModelProvider["provider_type"];
+    const providerModels = (payload.models as ModelProvider["models"]) ?? [];
+    const created: ModelProvider = {
+      id: id("mp"),
+      name: String(payload.name ?? "New Provider"),
+      provider_type: providerType,
+      status: "active",
+      base_url: String(payload.base_url ?? ""),
+      api_key_ref: `${String(payload.api_key ?? "").slice(0, 3)}***masked`,
+      models: providerModels,
+      default_model: (payload.default_model as string | null) ?? providerModels[0]?.model_id ?? null,
+      extra_config_json: (payload.extra_config_json as Record<string, unknown>) ?? {},
+      created_time: iso(),
+      updated_time: null,
+    };
+    modelProviders.unshift(created);
+    return created;
+  }
+  if (url === "/model-providers/discover" && method === "post") {
+    return discoverMockModels(String(payload.provider_type ?? payload.providerId ?? "openai"));
+  }
+  if (url.match(/^\/model-providers\/[^/]+$/) && method === "patch") {
+    const providerId = url.split("/")[2];
+    const target = modelProviders.find((item) => item.id === providerId);
+    if (!target) return {};
+    if (payload.name) target.name = String(payload.name);
+    if (payload.base_url) target.base_url = String(payload.base_url);
+    if (payload.api_key) target.api_key_ref = `${String(payload.api_key).slice(0, 3)}***masked`;
+    if (payload.models) target.models = payload.models as ModelProvider["models"];
+    if (payload.default_model !== undefined) target.default_model = payload.default_model as string | null;
+    if (payload.status) target.status = String(payload.status) as ModelProvider["status"];
+    if (payload.extra_config_json) target.extra_config_json = payload.extra_config_json as Record<string, unknown>;
+    target.updated_time = iso();
+    return target;
+  }
+  if (url.match(/^\/model-providers\/[^/]+$/) && method === "delete") {
+    const providerId = url.split("/")[2];
+    const index = modelProviders.findIndex((item) => item.id === providerId);
+    if (index >= 0) modelProviders.splice(index, 1);
+    return { success: true };
+  }
+  if (url.match(/^\/model-providers\/[^/]+\/test$/) && method === "post") {
+    const providerId = url.split("/")[2];
+    const target = modelProviders.find((item) => item.id === providerId);
+    const result: ModelProviderTestResult = {
+      success: target?.status !== "error",
+      message: target?.status === "error" ? "Connection failed" : "Connection successful",
+      latency_ms: Math.floor(Math.random() * 300) + 50,
+      model_list: target?.models.filter((m) => m.enabled).map((m) => m.model_id) ?? [],
+    };
+    return result;
+  }
 
-function normalize(url?: string) {
-  const path = (url ?? "").split("?")[0] ?? "";
-  return path.startsWith("/gateway") ? path.slice("/gateway".length) || "/" : path;
+  return {};
 }
 
-function validateDsl(dsl: WorkflowDSL): WorkflowDesignerValidateResponse {
-  const nodeIds = new Set((dsl?.nodes ?? []).map((node) => node.id));
-  const diagnostics = [];
-  if (!dsl?.nodes?.length) diagnostics.push({ severity: "error" as const, code: "EMPTY_GRAPH", message: "Workflow must contain at least one node." });
-  for (const edge of dsl?.edges ?? []) {
-    if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target)) {
-      diagnostics.push({ severity: "error" as const, code: "INVALID_EDGE", message: `Edge ${edge.source} -> ${edge.target} references a missing node.` });
-    }
-  }
+function discoverMockModels(providerType: string): DiscoverModelsResponse {
+  const catalogs: Record<string, DiscoverModelsResponse["models"]> = {
+    openai: [
+      { model_id: "gpt-4.1", display_name: "GPT-4.1", model_type: "chat", owned_by: "openai", context_window: 1047576 },
+      { model_id: "gpt-4.1-mini", display_name: "GPT-4.1 Mini", model_type: "chat", owned_by: "openai", context_window: 1047576 },
+      { model_id: "gpt-4.1-nano", display_name: "GPT-4.1 Nano", model_type: "chat", owned_by: "openai", context_window: 1047576 },
+      { model_id: "o3", display_name: "o3", model_type: "reasoning", owned_by: "openai", context_window: 200000 },
+      { model_id: "o4-mini", display_name: "o4-mini", model_type: "reasoning", owned_by: "openai", context_window: 200000 },
+      { model_id: "gpt-4o", display_name: "GPT-4o", model_type: "chat", owned_by: "openai", context_window: 128000 },
+      { model_id: "gpt-4o-mini", display_name: "GPT-4o Mini", model_type: "chat", owned_by: "openai", context_window: 128000 },
+      { model_id: "text-embedding-3-large", display_name: "Text Embedding 3 Large", model_type: "embedding", owned_by: "openai" },
+      { model_id: "text-embedding-3-small", display_name: "Text Embedding 3 Small", model_type: "embedding", owned_by: "openai" },
+      { model_id: "dall-e-3", display_name: "DALL-E 3", model_type: "image", owned_by: "openai" },
+      { model_id: "gpt-4o-mini-audio-preview", display_name: "GPT-4o Audio", model_type: "audio", owned_by: "openai" },
+      { model_id: "whisper-1", display_name: "Whisper", model_type: "audio", owned_by: "openai" },
+      { model_id: "tts-1", display_name: "TTS 1", model_type: "audio", owned_by: "openai" },
+      { model_id: "omni-moderation-latest", display_name: "Omni Moderation", model_type: "moderation", owned_by: "openai" },
+    ],
+    anthropic: [
+      { model_id: "claude-sonnet-4-6", display_name: "Claude Sonnet 4.6", model_type: "chat", owned_by: "anthropic", context_window: 200000 },
+      { model_id: "claude-opus-4-6", display_name: "Claude Opus 4.6", model_type: "chat", owned_by: "anthropic", context_window: 200000 },
+      { model_id: "claude-haiku-4-5", display_name: "Claude Haiku 4.5", model_type: "chat", owned_by: "anthropic", context_window: 200000 },
+      { model_id: "claude-3-5-sonnet-20241022", display_name: "Claude 3.5 Sonnet", model_type: "chat", owned_by: "anthropic", context_window: 200000 },
+    ],
+    deepseek: [
+      { model_id: "deepseek-chat", display_name: "DeepSeek Chat (V3)", model_type: "chat", owned_by: "deepseek", context_window: 131072 },
+      { model_id: "deepseek-reasoner", display_name: "DeepSeek Reasoner (R1)", model_type: "reasoning", owned_by: "deepseek", context_window: 131072 },
+    ],
+    azure_openai: [
+      { model_id: "gpt-4.1", display_name: "GPT-4.1", model_type: "chat", owned_by: "openai", context_window: 1047576 },
+      { model_id: "gpt-4.1-mini", display_name: "GPT-4.1 Mini", model_type: "chat", owned_by: "openai", context_window: 1047576 },
+      { model_id: "o3", display_name: "o3", model_type: "reasoning", owned_by: "openai", context_window: 200000 },
+      { model_id: "text-embedding-3-large", display_name: "Text Embedding 3 Large", model_type: "embedding", owned_by: "openai" },
+      { model_id: "dall-e-3", display_name: "DALL-E 3", model_type: "image", owned_by: "openai" },
+    ],
+    ollama: [
+      { model_id: "llama3.1:8b", display_name: "LLaMA 3.1 8B", model_type: "chat", owned_by: "meta", context_window: 131072 },
+      { model_id: "llama3.1:70b", display_name: "LLaMA 3.1 70B", model_type: "chat", owned_by: "meta", context_window: 131072 },
+      { model_id: "qwen2.5:14b", display_name: "Qwen 2.5 14B", model_type: "chat", owned_by: "qwen", context_window: 32768 },
+      { model_id: "deepseek-r1:8b", display_name: "DeepSeek R1 8B", model_type: "reasoning", owned_by: "deepseek", context_window: 131072 },
+      { model_id: "nomic-embed-text", display_name: "Nomic Embed Text", model_type: "embedding", owned_by: "nomic" },
+      { model_id: "llava:13b", display_name: "LLaVA 13B", model_type: "image", owned_by: "liuhaotian" },
+    ],
+    custom: [],
+  };
   return {
-    valid: diagnostics.length === 0,
-    diagnostics,
-    node_count: dsl?.nodes?.length ?? 0,
-    edge_count: dsl?.edges?.length ?? 0,
-    nodes: (dsl?.nodes ?? []).map((node) => ({
-      ...node,
-      incoming_count: (dsl.edges ?? []).filter((edge) => edge.target === node.id).length,
-      outgoing_count: (dsl.edges ?? []).filter((edge) => edge.source === node.id).length,
-      reachable: true,
-    })),
-    edges: (dsl?.edges ?? []).map((edge) => ({ ...edge, valid_source: nodeIds.has(edge.source), valid_target: nodeIds.has(edge.target) })),
-    entry_node_ids: (dsl?.nodes ?? []).filter((node) => !(dsl.edges ?? []).some((edge) => edge.target === node.id)).map((node) => node.id),
-    terminal_node_ids: (dsl?.nodes ?? []).filter((node) => !(dsl.edges ?? []).some((edge) => edge.source === node.id)).map((node) => node.id),
-    isolated_node_ids: [],
-    unreachable_node_ids: [],
-    cycle_detected: false,
-    normalized_dsl_json: dsl,
+    provider_type: (providerType || "openai") as DiscoverModelsResponse["provider_type"],
+    models: catalogs[providerType] ?? [],
   };
 }
 
-function debugDsl(dsl: WorkflowDSL): WorkflowDebuggerPlanResponse {
-  const base = validateDsl(dsl);
-  return {
-    ...base,
-    execution_preview: (dsl?.nodes ?? []).slice(0, 12).map((node, index) => ({
-      step_index: index,
-      node_id: node.id,
-      node_type: node.type,
-      name: node.name,
-      next_node_ids: (dsl.edges ?? []).filter((edge) => edge.source === node.id).map((edge) => edge.target),
-    })),
-    max_preview_steps: 50,
-    truncated: false,
-  };
+function normalize(url?: string) {
+  const path = (url ?? "").split("?")[0] ?? "";
+  return path.startsWith("/gateway") ? path.slice("/gateway".length) || "/" : path;
 }
 
 function mockSearch(knowledgeBaseId: string, query: string, topK: number): SearchResult[] {
   const baseDocuments = knowledgeDocuments.filter((item) => item.knowledge_base_id === knowledgeBaseId && item.status === "indexed");
   const fallbackDocument = baseDocuments[0] ?? knowledgeDocuments[0] ?? {
     id: "kd_mock",
-    tenant_id: "public",
     knowledge_base_id: knowledgeBaseId,
     title: "Mock Document",
     source_type: "text",
@@ -923,7 +889,6 @@ function mockSearch(knowledgeBaseId: string, query: string, topK: number): Searc
     ? candidates
     : [{
         id: "chunk_mock_1",
-        tenant_id: "public",
         knowledge_base_id: knowledgeBaseId,
         document_id: fallbackDocument.id,
         chunk_index: 0,

+ 43 - 0
web/src/api/model-providers.ts

@@ -0,0 +1,43 @@
+import { apiClient, tenantParams } from "./client";
+import type {
+  ModelProvider,
+  ModelProviderCreateRequest,
+  ModelProviderTestResult,
+  ModelProviderUpdateRequest,
+  DiscoverModelsResponse,
+} from "@/types";
+
+export async function listModelProviders() {
+  const { data } = await apiClient.get<ModelProvider[]>("/model-providers", { params: tenantParams() });
+  return data;
+}
+
+export async function createModelProvider(payload: ModelProviderCreateRequest) {
+  const { data } = await apiClient.post<ModelProvider>("/model-providers", payload);
+  return data;
+}
+
+export async function updateModelProvider(providerId: string, payload: ModelProviderUpdateRequest) {
+  const { data } = await apiClient.patch<ModelProvider>(`/model-providers/${providerId}`, {
+    ...payload,
+  });
+  return data;
+}
+
+export async function deleteModelProvider(providerId: string) {
+  const { data } = await apiClient.delete(`/model-providers/${providerId}`, { params: tenantParams() });
+  return data;
+}
+
+export async function testModelProviderConnection(providerId: string) {
+  const { data } = await apiClient.post<ModelProviderTestResult>(`/model-providers/${providerId}/test`, tenantParams());
+  return data;
+}
+
+/** Auto-discover available models from a provider (by ID) or by connection params. */
+export async function discoverModels(params: { providerId?: string; providerType?: string; baseUrl?: string; apiKey?: string }) {
+  const { data } = await apiClient.post<DiscoverModelsResponse>("/model-providers/discover", {
+    ...params,
+  });
+  return data;
+}

+ 0 - 2
web/src/api/sessions.ts

@@ -7,7 +7,6 @@ export async function listSessions() {
 }
 
 export async function createSession(payload: {
-  tenant_id: string;
   app_id: string;
   user_id: string;
   channel_type: string;
@@ -25,7 +24,6 @@ export async function listMessages(sessionId?: string) {
 }
 
 export async function createMessage(payload: {
-  tenant_id: string;
   session_id: string;
   role: string;
   content_type?: string;

+ 0 - 3
web/src/api/teams.ts

@@ -12,7 +12,6 @@ export async function listTeamVersions(teamId?: string) {
 }
 
 export async function createTeamVersion(payload: {
-  tenant_id: string;
   team_id: string;
   coordination_mode: string;
   objective?: string | null;
@@ -35,7 +34,6 @@ export async function listTeamRuns(teamId?: string) {
 }
 
 export async function createTeamRun(payload: {
-  tenant_id: string;
   team_id: string;
   team_version_id?: string | null;
   input_text?: string | null;
@@ -46,7 +44,6 @@ export async function createTeamRun(payload: {
 }
 
 export async function createTeam(payload: {
-  tenant_id: string;
   code?: string;
   name: string;
   description?: string | null;

+ 0 - 3
web/src/api/tools.ts

@@ -12,7 +12,6 @@ export async function listToolVersions(toolId?: string) {
 }
 
 export async function createToolVersion(payload: {
-  tenant_id: string;
   tool_id: string;
   timeout_ms?: number | null;
   input_schema_json?: JSONObject;
@@ -36,7 +35,6 @@ export async function listToolBindings(appId?: string) {
 }
 
 export async function createTool(payload: {
-  tenant_id: string;
   plugin_id?: string | null;
   code?: string;
   name: string;
@@ -56,7 +54,6 @@ export async function listToolCredentials() {
 }
 
 export async function createToolCredential(payload: {
-  tenant_id: string;
   name: string;
   credential_type: string;
   secret_json: JSONObject;

+ 0 - 88
web/src/api/workflows.ts

@@ -1,88 +0,0 @@
-import { apiClient, tenantParams } from "./client";
-import type {
-  AppCreateRequest,
-  AppResponse,
-  AppVersionResponse,
-  WorkflowCreateRequest,
-  WorkflowDefinition,
-  WorkflowDesignerValidateResponse,
-  WorkflowDSL,
-  WorkflowDebuggerPlanResponse,
-  WorkflowVersion,
-} from "@/types";
-
-export async function listApps() {
-  const { data } = await apiClient.get<AppResponse[]>("/workflows/apps", { params: tenantParams() });
-  return data;
-}
-
-export async function createApp(payload: AppCreateRequest) {
-  const { data } = await apiClient.post<AppResponse>("/workflows/apps", payload);
-  return data;
-}
-
-export async function listAppVersions(appId: string) {
-  const { data } = await apiClient.get<AppVersionResponse[]>("/workflows/apps/versions", {
-    params: tenantParams({ app_id: appId }),
-  });
-  return data;
-}
-
-export async function listWorkflows(appId?: string) {
-  const { data } = await apiClient.get<WorkflowDefinition[]>("/workflows", {
-    params: tenantParams({ app_id: appId }),
-  });
-  return data;
-}
-
-export async function createWorkflow(payload: Omit<WorkflowCreateRequest, "code"> & { code?: string }) {
-  const { data } = await apiClient.post<WorkflowDefinition>("/workflows", {
-    ...payload,
-    code: payload.code || slugifyName(payload.name, "workflow"),
-  });
-  return data;
-}
-
-export async function listWorkflowVersions(workflowId: string) {
-  const { data } = await apiClient.get<WorkflowVersion[]>("/workflows/versions", {
-    params: tenantParams({ workflow_id: workflowId }),
-  });
-  return data;
-}
-
-export async function getWorkflowVersion(workflowVersionId: string) {
-  const { data } = await apiClient.get<WorkflowVersion>(`/workflows/versions/${workflowVersionId}`, {
-    params: tenantParams(),
-  });
-  return data;
-}
-
-export async function createWorkflowVersion(payload: {
-  tenant_id: string;
-  workflow_id: string;
-  dsl_json: WorkflowDSL;
-  status?: string;
-}) {
-  const { data } = await apiClient.post<WorkflowVersion>("/workflows/versions", payload);
-  return data;
-}
-
-export async function validateWorkflow(dsl: WorkflowDSL) {
-  const { data } = await apiClient.post<WorkflowDesignerValidateResponse>("/workflows/designer/validate", {
-    dsl_json: dsl,
-  });
-  return data;
-}
-
-export async function debugWorkflow(dsl: WorkflowDSL) {
-  const { data } = await apiClient.post<WorkflowDebuggerPlanResponse>("/workflows/designer/debug", {
-    dsl_json: dsl,
-    max_preview_steps: 50,
-  });
-  return data;
-}
-
-function slugifyName(value: string, fallback: string) {
-  const slug = value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
-  return slug || `${fallback}_${Date.now().toString(36)}`;
-}

+ 9 - 7
web/src/components/layout/Header.tsx

@@ -1,14 +1,17 @@
 import { LogOut, Menu, Moon, Sun, User } from "lucide-react";
 import { useNavigate } from "react-router-dom";
+import { useTranslation } from "react-i18next";
 import { Button } from "@/components/ui/button";
 import { Breadcrumb } from "./Breadcrumb";
+import { LanguageSelector } from "@/components/shared/LanguageSelector";
 import { useAuthStore } from "@/stores/auth";
 import { useUiStore } from "@/stores/ui";
 import { mockMode } from "@/api/mock";
 
 export function Header() {
+  const { t } = useTranslation();
   const navigate = useNavigate();
-  const { tenantId, userId, logout } = useAuthStore();
+  const { userId, logout } = useAuthStore();
   const { theme, toggleTheme, toggleMobileSidebar } = useUiStore();
   return (
     <header className="glass sticky top-0 z-30 flex h-16 items-center justify-between px-4 md:px-6">
@@ -21,27 +24,26 @@ export function Header() {
       <div className="flex items-center gap-3">
         {mockMode ? (
           <div className="hidden items-center gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-200 sm:flex">
-            Mock Data
+            {t("auth.mockData")}
           </div>
         ) : null}
         <div className="hidden items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground sm:flex">
           <span className="h-2 w-2 rounded-full bg-emerald-400 shadow-[0_0_10px_rgba(16,185,129,0.8)]" />
-          Gateway
+          {t("auth.gateway")}
         </div>
         <div className="hidden items-center gap-2 text-sm text-muted-foreground sm:flex">
           <User className="h-4 w-4" />
           <span>{userId || "user"}</span>
-          <span className="text-muted-foreground/40">/</span>
-          <span>{tenantId}</span>
         </div>
         <Button
           variant="ghost"
           size="icon"
           onClick={toggleTheme}
-          aria-label={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
+          aria-label={theme === "dark" ? t("theme.switchToLight") : t("theme.switchToDark")}
         >
           {theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
         </Button>
+        <LanguageSelector />
         <Button
           variant="ghost"
           size="icon"
@@ -49,7 +51,7 @@ export function Header() {
             logout();
             navigate("/login", { replace: true });
           }}
-          aria-label="Logout"
+          aria-label={t("auth.logout")}
         >
           <LogOut className="h-4 w-4" />
         </Button>

+ 10 - 6
web/src/components/layout/Sidebar.tsx

@@ -1,5 +1,6 @@
 import { NavLink } from "react-router-dom";
 import { PanelLeftClose, PanelLeftOpen, Workflow } from "lucide-react";
+import { useTranslation } from "react-i18next";
 import { Button } from "@/components/ui/button";
 import { Separator } from "@/components/ui/separator";
 import { NAV_ITEMS } from "@/lib/constants";
@@ -7,6 +8,7 @@ import { cn } from "@/lib/utils";
 import { useUiStore } from "@/stores/ui";
 
 function NavItems({ collapsed, onNavigate }: { collapsed?: boolean; onNavigate?: () => void }) {
+  const { t } = useTranslation();
   return (
     <>
       {NAV_ITEMS.map((item, index) => (
@@ -18,14 +20,14 @@ function NavItems({ collapsed, onNavigate }: { collapsed?: boolean; onNavigate?:
             cn(
               "flex min-h-11 items-center gap-3 rounded-md px-3 text-sm text-muted-foreground transition hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
               isActive && "bg-primary/15 text-primary",
-              index === 7 && "mt-auto",
+              index === 8 && "mt-auto",
               collapsed && "justify-center px-0",
             )
           }
-          title={item.label}
+          title={t(item.labelKey)}
         >
           <item.icon className="h-4 w-4 shrink-0" />
-          {!collapsed ? <span>{item.label}</span> : null}
+          {!collapsed ? <span>{t(item.labelKey)}</span> : null}
         </NavLink>
       ))}
     </>
@@ -33,6 +35,7 @@ function NavItems({ collapsed, onNavigate }: { collapsed?: boolean; onNavigate?:
 }
 
 export function Sidebar() {
+  const { t } = useTranslation();
   const { sidebarCollapsed, toggleSidebar } = useUiStore();
   return (
     <aside
@@ -45,7 +48,7 @@ export function Sidebar() {
         <div className="grid h-9 w-9 place-items-center rounded-md bg-primary/15 text-primary">
           <Workflow className="h-5 w-5" />
         </div>
-        {!sidebarCollapsed ? <span className="text-sm font-semibold">Auto Platform</span> : null}
+        {!sidebarCollapsed ? <span className="text-sm font-semibold">{t("app.name")}</span> : null}
       </div>
       <Separator />
       <nav className="flex flex-1 flex-col gap-1 p-3">
@@ -55,7 +58,7 @@ export function Sidebar() {
       <div className="p-3">
         <Button variant="ghost" size={sidebarCollapsed ? "icon" : "md"} className="w-full" onClick={toggleSidebar}>
           {sidebarCollapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
-          {!sidebarCollapsed ? "Collapse" : null}
+          {!sidebarCollapsed ? t("nav.collapse") : null}
         </Button>
       </div>
     </aside>
@@ -63,6 +66,7 @@ export function Sidebar() {
 }
 
 export function MobileSidebar() {
+  const { t } = useTranslation();
   const { mobileSidebarOpen, setMobileSidebarOpen } = useUiStore();
   if (!mobileSidebarOpen) return null;
   return (
@@ -77,7 +81,7 @@ export function MobileSidebar() {
           <div className="grid h-9 w-9 place-items-center rounded-md bg-primary/15 text-primary">
             <Workflow className="h-5 w-5" />
           </div>
-          <span className="text-sm font-semibold">Auto Platform</span>
+          <span className="text-sm font-semibold">{t("app.name")}</span>
         </div>
         <Separator />
         <nav className="flex flex-1 flex-col gap-1 p-3">

+ 12 - 0
web/src/components/shared/LanguageSelector.tsx

@@ -0,0 +1,12 @@
+import { useUiStore } from "@/stores/ui";
+import { SUPPORTED_LANGUAGES, LANGUAGE_LABELS } from "@/i18n";
+import { Select } from "@/components/ui/select";
+
+export function LanguageSelector() {
+  const { language, setLanguage } = useUiStore();
+  const options = SUPPORTED_LANGUAGES.map((lang) => ({
+    value: lang,
+    label: LANGUAGE_LABELS[lang],
+  }));
+  return <Select options={options} value={language} onChange={(e) => setLanguage(e.target.value as (typeof SUPPORTED_LANGUAGES)[number])} className="w-auto min-w-24" />;
+}

+ 1 - 1
web/src/hooks/index.ts

@@ -1,6 +1,6 @@
 export * from "./useApi";
 export * from "./useAuth";
-export * from "./useWorkflows";
+export * from "./useApps";
 export * from "./useAgents";
 export * from "./useSessions";
 export * from "./useRuntime";

+ 12 - 0
web/src/hooks/useApps.ts

@@ -0,0 +1,12 @@
+import { useApi } from "./useApi";
+import { apiClient, tenantParams } from "@/api/client";
+import type { AppResponse } from "@/types";
+
+async function listApps() {
+  const { data } = await apiClient.get<AppResponse[]>("/workflows/apps", { params: tenantParams() });
+  return data;
+}
+
+export function useApps() {
+  return useApi(listApps, []);
+}

+ 0 - 7
web/src/hooks/useWorkflows.ts

@@ -1,7 +0,0 @@
-import { listApps, listWorkflowVersions, listWorkflows } from "@/api";
-import { useApi } from "./useApi";
-
-export const useApps = () => useApi(() => listApps(), []);
-export const useWorkflowList = (appId?: string) => useApi(() => listWorkflows(appId), [appId]);
-export const useWorkflowVersions = (workflowId?: string) =>
-  useApi(() => (workflowId ? listWorkflowVersions(workflowId) : Promise.resolve([])), [workflowId]);

+ 33 - 0
web/src/i18n/index.ts

@@ -0,0 +1,33 @@
+import i18n from "i18next";
+import { initReactI18next } from "react-i18next";
+import LanguageDetector from "i18next-browser-languagedetector";
+import en from "../locales/en.json";
+import zh from "../locales/zh.json";
+
+export const SUPPORTED_LANGUAGES = ["en", "zh"] as const;
+export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
+
+export const LANGUAGE_LABELS: Record<SupportedLanguage, string> = {
+  en: "English",
+  zh: "中文",
+};
+
+i18n
+  .use(LanguageDetector)
+  .use(initReactI18next)
+  .init({
+    resources: {
+      en: { translation: en },
+      zh: { translation: zh },
+    },
+    fallbackLng: "en",
+    interpolation: {
+      escapeValue: false,
+    },
+    detection: {
+      order: ["localStorage", "navigator"],
+      caches: ["localStorage"],
+    },
+  });
+
+export default i18n;

+ 14 - 11
web/src/lib/constants.ts

@@ -1,10 +1,11 @@
 import {
   Bot,
   BookOpen,
-  GitBranch,
+  Cpu,
   LayoutDashboard,
   MessageSquare,
   Settings,
+  Unplug,
   Users,
   Wrench,
   type LucideIcon,
@@ -13,24 +14,26 @@ import {
 export const ROUTE_PATHS = {
   login: "/login",
   dashboard: "/dashboard",
-  workflows: "/workflows",
   agents: "/agents",
   sessions: "/sessions",
   tools: "/tools",
   knowledge: "/knowledge",
   teams: "/teams",
+  skills: "/skills",
+  models: "/models",
   settings: "/settings",
 } as const;
 
-export const NAV_ITEMS: Array<{ label: string; path: string; icon: LucideIcon }> = [
-  { label: "Dashboard", path: ROUTE_PATHS.dashboard, icon: LayoutDashboard },
-  { label: "Workflows", path: ROUTE_PATHS.workflows, icon: GitBranch },
-  { label: "Agents", path: ROUTE_PATHS.agents, icon: Bot },
-  { label: "Sessions", path: ROUTE_PATHS.sessions, icon: MessageSquare },
-  { label: "Tools", path: ROUTE_PATHS.tools, icon: Wrench },
-  { label: "Knowledge", path: ROUTE_PATHS.knowledge, icon: BookOpen },
-  { label: "Teams", path: ROUTE_PATHS.teams, icon: Users },
-  { label: "Settings", path: ROUTE_PATHS.settings, icon: Settings },
+export const NAV_ITEMS: Array<{ labelKey: string; path: string; icon: LucideIcon }> = [
+  { labelKey: "nav.dashboard", path: ROUTE_PATHS.dashboard, icon: LayoutDashboard },
+  { labelKey: "nav.agents", path: ROUTE_PATHS.agents, icon: Bot },
+  { labelKey: "nav.sessions", path: ROUTE_PATHS.sessions, icon: MessageSquare },
+  { labelKey: "nav.tools", path: ROUTE_PATHS.tools, icon: Wrench },
+  { labelKey: "nav.knowledge", path: ROUTE_PATHS.knowledge, icon: BookOpen },
+  { labelKey: "nav.teams", path: ROUTE_PATHS.teams, icon: Users },
+  { labelKey: "nav.skills", path: ROUTE_PATHS.skills, icon: Cpu },
+  { labelKey: "nav.models", path: ROUTE_PATHS.models, icon: Unplug },
+  { labelKey: "nav.settings", path: ROUTE_PATHS.settings, icon: Settings },
 ];
 
 export const NODE_TYPE_CONFIG = {

+ 933 - 0
web/src/locales/en.json

@@ -0,0 +1,933 @@
+{
+  "common": {
+    "loading": "Loading...",
+    "error": "Error",
+    "retry": "Retry",
+    "cancel": "Cancel",
+    "save": "Save",
+    "delete": "Delete",
+    "edit": "Edit",
+    "create": "Create",
+    "search": "Search",
+    "noData": "No data",
+    "confirm": "Confirm",
+    "back": "Back",
+    "next": "Next",
+    "previous": "Previous",
+    "submit": "Submit",
+    "close": "Close",
+    "refresh": "Refresh",
+    "copy": "Copy",
+    "copyCode": "Copy Code",
+    "clear": "Clear",
+    "clearFilters": "Clear filters",
+    "details": "Details",
+    "overview": "Overview",
+    "settings": "Settings",
+    "createVersion": "Create Version",
+    "start": "Start",
+    "stop": "Stop",
+    "run": "Run",
+    "runs": "Runs",
+    "running": "Running",
+    "creating": "Creating...",
+    "starting": "Starting...",
+    "createNew": "New",
+    "noResults": "No results",
+    "version": "Version",
+    "versions": "Versions",
+    "status": "Status",
+    "type": "Type",
+    "name": "Name",
+    "description": "Description",
+    "code": "Code",
+    "created": "Created",
+    "updated": "Updated",
+    "actions": "Actions",
+    "active": "Active",
+    "draft": "Draft",
+    "archived": "Archived",
+    "all": "All",
+    "filter": "Filter",
+    "sort": "Sort",
+    "select": "Select",
+    "selectAll": "Select all",
+    "required": "Required",
+    "optional": "Optional",
+    "enabled": "Enabled",
+    "disabled": "Disabled",
+    "yes": "Yes",
+    "no": "No",
+    "queued": "Queued",
+    "completed": "Completed",
+    "failed": "Failed",
+    "cancelled": "Cancelled",
+    "filterByStatus": "Filter by status",
+    "filterByAgentType": "Filter by agent type",
+    "sortAgents": "Sort agents",
+    "listView": "List view",
+    "gridView": "Grid view",
+    "list": "List",
+    "grid": "Grid",
+    "filterRunsByStatus": "Filter runs by status"
+  },
+  "nav": {
+    "dashboard": "Dashboard",
+    "agents": "Agents",
+    "sessions": "Sessions",
+    "tools": "Tools",
+    "knowledge": "Knowledge",
+    "teams": "Teams",
+    "skills": "Skills",
+    "models": "Model Providers",
+    "settings": "Settings",
+    "collapse": "Collapse"
+  },
+  "app": {
+    "name": "Auto Platform",
+    "loadingStudio": "Loading studio"
+  },
+  "theme": {
+    "switchToLight": "Switch to light mode",
+    "switchToDark": "Switch to dark mode"
+  },
+  "auth": {
+    "logout": "Logout",
+    "user": "User",
+    "mockData": "Mock Data",
+    "gateway": "Gateway"
+  },
+  "login": {
+    "title": "Web Studio Login",
+    "description": "Use your gateway API key to login.",
+    "apiKey": "API Key",
+    "userId": "User ID",
+    "enterStudio": "Enter Studio",
+    "checking": "Checking...",
+    "useDemo": "Use Demo Credentials",
+    "showKey": "Show API key",
+    "hideKey": "Hide API key",
+    "connected": "Connected to gateway",
+    "rejected": "Gateway rejected the credentials"
+  },
+  "dashboard": {
+    "title": "Dashboard",
+    "description": "Operational overview for agent execution.",
+    "agents": "Agents",
+    "runsToday": "Runs Today",
+    "activeSessions": "Active Sessions",
+    "executionTrend": "Execution Trend",
+    "serviceHealth": "Service Health",
+    "recentRuns": "Recent Runs",
+    "allRequestsFailed": "All dashboard requests failed. Check the API gateway and credentials.",
+    "services": "Services"
+  },
+  "agents": {
+    "title": "Agents",
+    "description": "Design, inspect, and operate agent definitions, versions, prompts, and runs from one workspace.",
+    "create": "Create Agent",
+    "newAgent": "New Agent",
+    "empty": "No agents",
+    "emptyDescription": "Create your first agent to get started.",
+    "refresh": "Refresh",
+    "agentDirectory": "Agent Directory",
+    "agentsShown": "of {{count}} agents shown",
+    "searchByNameCodeType": "Search by name, code, type, or description",
+    "allStatuses": "All statuses",
+    "allTypes": "All types",
+    "newestFirst": "Newest first",
+    "list": "List",
+    "grid": "Grid",
+    "overview": "Overview",
+    "promptConfig": "Prompt & Config",
+    "testConsole": "Test Console",
+    "noMatchingAgents": "No matching agents",
+    "adjustFiltersAgent": "Adjust search or filters to find a matching agent definition.",
+    "agentDetails": "Agent Details",
+    "selectAgent": "Select an agent to inspect its operating surface.",
+    "copyCode": "Copy Code",
+    "newVersion": "New Version",
+    "runs": "Runs",
+    "testInput": "Test input",
+    "resultPreview": "Result Preview",
+    "simulateRun": "Simulate Run",
+    "runSimulation": "Run a local simulation to preview the output contract.",
+    "noDescription": "No description provided.",
+    "selectAnAgent": "Select an agent",
+    "noAgents": "No agents",
+    "createAgentStart": "Create an agent to start building definitions and versions.",
+    "versions": "Versions",
+    "failures": "Failures",
+    "latest": "Latest",
+    "none": "None",
+    "owner": "Owner",
+    "unassigned": "Unassigned",
+    "role": "Role",
+    "model": "Model",
+    "published": "Published",
+    "notPublished": "Not published",
+    "systemPrompt": "System Prompt",
+    "modelConfig": "Model Config",
+    "memoryPolicy": "Memory Policy",
+    "toolReferences": "Tool References",
+    "skillReferences": "Skill References",
+    "noConfig": "No config",
+    "publishOrDraft": "Publish or draft a version before inspecting prompt and runtime configuration.",
+    "noVersions": "No versions",
+    "createVersionDefine": "Create a version to define role, goal, prompt, tools, and runtime config.",
+    "noRuns": "No runs",
+    "noRunRecords": "No run records match the current agent and status filter.",
+    "created": "Created",
+    "worker": "Worker",
+    "output": "Output",
+    "noOutputYet": "No output yet",
+    "structuredInput": "Structured input payload",
+    "loaded": "Loaded",
+    "runsCount": "Runs",
+    "agentCreated": "Agent created",
+    "definitionDescription": "Create the reusable agent identity first. Versions, prompts, tools, and runtime config live under this definition.",
+    "failedRuns": "Failed Runs",
+    "simulationPlaceholder": "Summarize the last customer request and recommend the next action.",
+    "namePlaceholder": "Support Agent",
+    "codePlaceholder": "support_agent",
+    "descriptionPlaceholder": "What this agent is responsible for, when to use it, and what good output looks like.",
+    "typeAssistant": "Assistant",
+    "typePlanner": "Planner",
+    "typeExecutor": "Executor",
+    "typeResearch": "Research",
+    "typeToolUser": "Tool User",
+    "searchPlaceholder": "Search agents...",
+    "codeCopied": "Agent code copied",
+    "versionCreated": "Agent version created",
+    "failedCreateVersion": "Failed to create agent version",
+    "basicInfo": "Basic Info",
+    "basicInfoDesc": "Define the agent identity and purpose.",
+    "agentRole": "Role",
+    "goal": "Goal",
+    "goalPlaceholder": "What this agent should accomplish.",
+    "systemPrompt": "System Prompt",
+    "systemPromptPlaceholder": "You are a helpful assistant that...",
+    "modelSettings": "Model Settings",
+    "modelSettingsDesc": "Choose the LLM model and parameters.",
+    "provider": "Provider",
+    "model": "Model",
+    "temperature": "Temperature",
+    "maxTokens": "Max Tokens",
+    "toolsSection": "Tools",
+    "toolsSectionDesc": "Attach tools this agent can use.",
+    "memorySection": "Memory",
+    "memorySectionDesc": "Configure how the agent manages conversation memory.",
+    "memoryEnabled": "Enabled",
+    "memoryScope": "Scope",
+    "memoryScopeSession": "Session",
+    "memoryScopePersistent": "Persistent",
+    "memoryScopeNone": "None"
+  },
+  "sessions": {
+    "title": "Sessions",
+    "description": "Inspect channel sessions and chat messages.",
+    "create": "New Session",
+    "newSession": "New Session",
+    "empty": "No sessions",
+    "emptyDescription": "Start a new session to begin.",
+    "typeMessage": "Type a message...",
+    "searchSessions": "Search sessions",
+    "noMessages": "No messages",
+    "sendFirstMessage": "Send the first message for this session.",
+    "noSessionSelected": "No session selected",
+    "chooseOrCreate": "Choose or create a session to start chatting.",
+    "untitledSession": "Untitled session",
+    "message": "Message",
+    "sendMessage": "Send message",
+    "selectSession": "Select a session",
+    "sessionCreated": "Session created",
+    "messageSent": "Message sent",
+    "channelType": "Channel Type"
+  },
+  "tools": {
+    "title": "Tools",
+    "description": "Manage tool definitions, versions, readiness, and quick payload tests.",
+    "create": "Create Tool",
+    "newTool": "New Tool",
+    "empty": "No tools available",
+    "refresh": "Refresh",
+    "toolList": "Tool List",
+    "shown": "of {{count}} shown",
+    "searchTools": "Search tools",
+    "allTypes": "All types",
+    "allStatus": "All status",
+    "hasVersion": "Has version",
+    "bound": "Bound",
+    "needsVersion": "Needs version",
+    "noToolsFound": "No tools found",
+    "adjustFiltersTool": "Adjust filters or create a new tool.",
+    "toolDetails": "Tool Details",
+    "selectTool": "Select a tool to view versions and run a quick test.",
+    "definition": "Definition",
+    "version": "Version",
+    "binding": "Binding",
+    "credential": "Credential",
+    "readiness": "Readiness",
+    "basicInfo": "Basic Info",
+    "plugin": "Plugin",
+    "standalone": "Standalone",
+    "timeout": "Timeout",
+    "notSet": "Not set",
+    "latestVersion": "Latest Version",
+    "inputs": "inputs",
+    "outputs": "outputs",
+    "retry": "retry",
+    "payloadTest": "Payload Test",
+    "runMockRequest": "Run a mock request against the selected version.",
+    "run": "Run",
+    "result": "Result",
+    "noRunYet": "No run yet",
+    "selectVersionRun": "Select a version and run JSON.",
+    "testPayloadSimulated": "Test payload simulated",
+    "payloadMustBeJson": "Payload must be valid JSON",
+    "inputSchema": "Input Schema",
+    "invokeConfig": "Invoke Config",
+    "retryPolicy": "Retry Policy",
+    "noVersionsYet": "No versions yet",
+    "createVersionBeforeTesting": "Create a version before testing this tool.",
+    "createToolVersion": "Create Tool Version",
+    "endpoint": "Endpoint",
+    "timeoutMs": "Timeout (ms)",
+    "retryAttempts": "Retry attempts",
+    "createTool": "Create Tool",
+    "type": "Type",
+    "ready": "Ready",
+    "bindings": "Bindings",
+    "versionsCount": "Versions",
+    "none": "None",
+    "versionDescription": "Endpoint, timeout, retry, and schema snapshots.",
+    "toolVersionCreated": "Tool version created",
+    "toolCreated": "Tool created",
+    "createVersion": "Create Version",
+    "test": "Test",
+    "filterByType": "Filter by tool type",
+    "filterByStatus": "Filter by tool status"
+  },
+  "knowledge": {
+    "title": "Knowledge",
+    "description": "Manage retrieval bases, ingest documents, inspect indexing state, and test semantic search.",
+    "refresh": "Refresh",
+    "reindex": "Re-index",
+    "reindexStarted": "Re-index job started",
+    "addDocument": "Add Document",
+    "reindexScope": "Re-index Scope",
+    "newBase": "New Base",
+    "knowledgeBases": "Knowledge Bases",
+    "documents": "Documents",
+    "indexed": "Indexed",
+    "sources": "Sources",
+    "bases": "Bases",
+    "searchBases": "Search bases",
+    "noBasesFound": "No bases found",
+    "createOrSearch": "Create or search for another knowledge base.",
+    "newBaseBtn": "New Base",
+    "activeKnowledgeBase": "Active Knowledge Base",
+    "scope": "Scope",
+    "current": "Current",
+    "allBases": "All Bases",
+    "operationsApplyTo": "Operations apply to",
+    "knowledgeBases_count": "{{count}} knowledge bases",
+    "selectKnowledgeBase": "Select a Knowledge Base",
+    "chooseBaseManage": "Choose a base to manage documents, retrieval, ingestion, and settings.",
+    "selectKnowledgeBaseFirst": "Select a Knowledge Base First",
+    "chooseBaseManageDocs": "Choose a knowledge base from the scope bar above to manage its documents.",
+    "noDescription": "No description",
+    "archive": "Archive",
+    "restoreCurrent": "Restore Current",
+    "reindexSet": "Re-index Set",
+    "selected": "Selected",
+    "documentsCount": "Documents",
+    "indexedCount": "Indexed",
+    "knowledgeCapabilityMap": "Knowledge Capability Map",
+    "moduleStatus": "Module status at a glance. Open the dedicated section for each workflow.",
+    "overview": "Overview",
+    "documentsTab": "Documents",
+    "ingest": "Ingest",
+    "playground": "Playground",
+    "evaluation": "Evaluation",
+    "jobs": "Jobs",
+    "analytics": "Analytics",
+    "settingsTab": "Settings",
+    "ingestDocuments": "Ingest Documents",
+    "prototypeIngestion": "Prototype every ingestion source from the frontend. Each connector creates a local indexing job and behaves like a complete workflow.",
+    "pasteText": "Paste Text",
+    "createParseChunk": "Create, parse, chunk, embed, and index content.",
+    "urlSitemap": "URL / Sitemap",
+    "configureCrawlDepth": "Configure crawl depth, include patterns, and sync cadence.",
+    "githubRepository": "GitHub Repository",
+    "chooseRepoBranch": "Choose repository, branch, path filters, and sync mode.",
+    "pdfDocxUpload": "PDF / DOCX Upload",
+    "stageBatchPreview": "Stage a batch, preview parser settings, and create an indexing job.",
+    "retrievalPlayground": "Retrieval Playground",
+    "tuneTopKFilters": "Tune top K and filters, run retrieval, then inspect citations and score breakdowns.",
+    "askRetrievalQuestion": "Ask a retrieval question",
+    "topK": "Top K",
+    "searchSourceFilter": "Search source filter",
+    "searchResultCount": "Search {{count}}",
+    "searching": "Searching...",
+    "noSearchResultsYet": "No search results yet",
+    "runRetrievalInspect": "Run a retrieval query to inspect chunks, citations, and score JSON.",
+    "retrievalSettings": "Retrieval Settings",
+    "tuneRetrievalBehavior": "Tune the retrieval behavior directly in the frontend prototype.",
+    "retrievalDefaults": "Retrieval Defaults",
+    "chunkSize": "Chunk Size",
+    "overlap": "Overlap",
+    "rerankResults": "Rerank results",
+    "reorderCandidates": "Reorder candidates after hybrid retrieval.",
+    "queryRewrite": "Query rewrite",
+    "expandShortQueries": "Expand short queries before search.",
+    "requireCitations": "Require citations",
+    "treatUncitedAnswers": "Treat uncited answers as low confidence.",
+    "keywordWeight": "Keyword Weight",
+    "vectorWeight": "Vector Weight",
+    "rerankWeight": "Rerank Weight",
+    "goldenQuery": "Golden Query",
+    "buildEvaluationSet": "Build an evaluation set and run frontend benchmark simulations.",
+    "query": "Query",
+    "expectedSource": "Expected Source",
+    "addCase": "Add Case",
+    "avgRecall": "Avg Recall",
+    "avgPrecision": "Avg Precision",
+    "evaluationCases": "Evaluation Cases",
+    "expected": "Expected",
+    "recall": "Recall",
+    "precision": "Precision",
+    "indexJobs": "Index Jobs",
+    "manageFrontendQueues": "Manage frontend prototype queues for connector sync, parsing, chunking, embedding, and re-indexing.",
+    "reindexBase": "Re-index Base",
+    "start": "Start",
+    "complete": "Complete",
+    "retry": "Retry",
+    "progress": "Progress",
+    "analyticsTab": "Analytics",
+    "indexedRatio": "Indexed Ratio",
+    "avgScore": "Avg Score",
+    "evalPass": "Eval Pass",
+    "failedJobs": "Failed Jobs",
+    "qualitySignals": "Quality Signals",
+    "indexCoverage": "Index Coverage",
+    "citationConfidence": "Citation Confidence",
+    "evaluationPassRate": "Evaluation Pass Rate",
+    "jobHealth": "Job Health",
+    "topQueries": "Top Queries",
+    "governanceSettings": "Governance Settings",
+    "prototypeAccessSafety": "Prototype access, safety, and answer policy controls.",
+    "aclMode": "ACL Mode",
+    "private": "Private",
+    "team": "Team",
+    "workspace": "Workspace",
+    "piiRedaction": "PII redaction",
+    "maskSensitiveValues": "Mask sensitive values before content enters retrieval context.",
+    "documentLevelAccess": "Document-level access checks",
+    "applyMetadataSecurity": "Apply metadata security filters before ranking.",
+    "apiScopePreview": "API Scope Preview",
+    "accessControl": "Access Control",
+    "dataProtection": "Data Protection",
+    "exportEnabled": "Export Enabled",
+    "allowDataExport": "Allow knowledge base data to be exported",
+    "auditLog": "Audit Log",
+    "enableAuditLogging": "Enable Audit Logging",
+    "trackAccessModifications": "Track document access and modifications",
+    "dataRetention": "Data Retention",
+    "days": "{{count}} days",
+    "networkSecurity": "Network Security",
+    "ipAllowlist": "IP Allowlist",
+    "restrictAccessByIp": "Restrict access by IP address",
+    "allowedIpRanges": "Allowed IP Ranges",
+    "configureConnector": "Configure {{kind}}",
+    "source": "Source",
+    "syncMode": "Sync Mode",
+    "manual": "Manual",
+    "hourly": "Hourly",
+    "daily": "Daily",
+    "weekly": "Weekly",
+    "includeExcludeFilters": "Include / Exclude Filters",
+    "createsLocalPrototype": "This creates a local prototype job. The UI flow is complete even before persistence is connected.",
+    "createJob": "Create Job",
+    "createKnowledgeBase": "Create Knowledge Base",
+    "metadataJson": "Metadata JSON",
+    "addDocumentTitle": "Add Document",
+    "sourceType": "Source Type",
+    "sourceUri": "Source URI",
+    "content": "Content",
+    "chunkOverlap": "Chunk Overlap",
+    "parsePreview": "Parse Preview",
+    "previewParse": "Preview Parse",
+    "parsing": "Parsing...",
+    "indexDocument": "Index Document",
+    "indexing": "Indexing...",
+    "documentIngestFailed": "Document ingest failed",
+    "knowledgeBaseCreated": "Knowledge base created",
+    "knowledgeBaseRestored": "Knowledge base restored",
+    "knowledgeBaseArchived": "Knowledge base archived",
+    "searchResults": "search results",
+    "noMatchingChunks": "No matching chunks found",
+    "searchFailed": "Search failed",
+    "failedToLoadDocuments": "Failed to load documents",
+    "lastIngestCreated": "Last ingest created {{count}} chunks",
+    "selectDocument": "Select a document",
+    "documentDetailsAppear": "Document details, metadata, and matching chunks appear here.",
+    "noDocumentSearchResults": "No document search results",
+    "runQueryPlayground": "Run a query in Playground to inspect matching chunks for this document.",
+    "metadata": "Metadata",
+    "chunks": "Chunks",
+    "chunk": "chunk",
+    "tokens": "tokens",
+    "noChunksLoaded": "No chunks loaded",
+    "runSearchOrIndex": "Run search or index a new document to inspect chunks.",
+    "noMatchingDocuments": "No documents match filters",
+    "adjustSearchOrStatus": "Adjust the search text or status filter.",
+    "noDocuments": "No documents",
+    "addTextMarkdownJson": "Add a text, markdown, JSON, HTML, or file-derived document to index it for retrieval.",
+    "allStatuses": "All statuses",
+    "indexed_status": "Indexed",
+    "draft_status": "Draft",
+    "failed_status": "Failed",
+    "archived_status": "Archived",
+    "allSources": "All sources",
+    "sourceStatus": "Source",
+    "statusIndexed": "Status",
+    "createdAt": "Created",
+    "indexedAt": "Indexed",
+    "pending": "Pending",
+    "contentHash": "Content Hash",
+    "notAvailable": "Not available",
+    "notProvided": "Not provided",
+    "state": {
+      "live": "Live",
+      "mock": "Mock",
+      "mockLocal": "Mock / Local",
+      "prototype": "Prototype",
+      "proto": "Proto"
+    },
+    "ingestion": "Ingestion",
+    "indexingTab": "Indexing",
+    "retrievalTab": "Retrieval",
+    "governance": "Governance",
+    "integration": "Integration",
+    "textMarkdownIngest": "Text / Markdown ingest",
+    "parsePreviewIng": "Parse preview",
+    "pdfDocxHtmlParser": "PDF / DOCX / HTML parser",
+    "urlSitemapGithub": "URL, sitemap, GitHub import",
+    "chunkSizeOverlap": "Chunk size / overlap",
+    "embeddingModelTracking": "Embedding model tracking",
+    "reindexDocumentBase": "Re-index document/base",
+    "indexJobQueue": "Index job queue",
+    "hybridSearchPlayground": "Hybrid search playground",
+    "topKMetadataFilters": "Top K and metadata filters",
+    "scoreBreakdownCitations": "Score breakdown and citations",
+    "rerankQueryRewrite": "Rerank / query rewrite controls",
+    "goldenQueries": "Golden queries",
+    "recallPrecisionMetrics": "Recall / precision metrics",
+    "humanRelevanceFeedback": "Human relevance feedback",
+    "regressionComparison": "Regression comparison",
+    "archiveRestoreBase": "Archive / restore base",
+    "tenantIsolation": "Tenant isolation",
+    "documentAclPii": "Document ACL / PII rules",
+    "auditLog": "Audit log",
+    "agentBinding": "Agent binding",
+    "workflowRetrievalNode": "Workflow retrieval node",
+    "knowledgeSearchTool": "Knowledge search tool",
+    "runTraceCitations": "Run trace citations",
+    "createdJob": "{{type}} job created",
+    "evaluationCaseFinished": "Evaluation case finished",
+    "knowledgeBases_plural": "{{count}} Knowledge Bases",
+    "selectAKnowledgeBase": "Select a Knowledge Base",
+    "chooseBaseManageDocs": "Choose a base to manage documents, retrieval, ingestion, and settings.",
+    "archive": "Archive",
+    "restoreCurrent": "Restore Current",
+    "reindexSet": "Re-index Set",
+    "selected": "Selected",
+    "documentsTab": "Documents",
+    "source": "Source",
+    "indexed_status": "Indexed",
+    "pending": "Pending",
+    "contentHash": "Content Hash",
+    "notAvailable": "Not available",
+    "notProvided": "Not provided",
+    "lastIngestCreated": "Last ingest created {{count}} chunk{{count === 1 ? '' : 's'}}.",
+    "inspector": "Inspector",
+    "overview": "Overview",
+    "search": "Search",
+    "metadata": "Metadata",
+    "chunks": "Chunks",
+    "chunkTokenCount": "chunk #{{index}} / {{count}} tokens",
+    "selectDocument": "Select a document",
+    "documentDetailsAppear": "Document details, metadata, and matching chunks appear here.",
+    "noDocumentSearchResults": "No document search results",
+    "runQueryPlayground": "Run a query in Playground to inspect matching chunks for this document.",
+    "noChunksLoaded": "No chunks loaded",
+    "runSearchOrIndex": "Run search or index a new document to inspect chunks.",
+    "searching": "Searching...",
+    "searchResultCount": "Search {{count}}",
+    "searchResults": "{{count}} search results",
+    "noSearchResultsYet": "No search results yet",
+    "runRetrievalInspect": "Run a retrieval query to inspect chunks, citations, and score JSON.",
+    "searchPlaceholder": "Ask a retrieval question",
+    "searchSourceFilter": "Search source filter",
+    "topK": "Top K",
+    "add": "Add",
+    "loadingDocuments": "Loading documents",
+    "noDocumentsMatchFilters": "No documents match filters",
+    "adjustSearchStatus": "Adjust the search text or status filter.",
+    "noDocuments": "No documents",
+    "addTextMarkdownJsonHtml": "Add a text, markdown, JSON, HTML, or file-derived document to index it for retrieval.",
+    "goldenQuery": "Golden Query",
+    "buildEvaluationSet": "Build an evaluation set and run frontend benchmark simulations.",
+    "query": "Query",
+    "expectedSource": "Expected Source",
+    "addCase": "Add Case",
+    "avgRecall": "Avg Recall",
+    "avgPrecision": "Avg Precision",
+    "evaluationCases": "Evaluation Cases",
+    "expected": "Expected",
+    "recall": "Recall",
+    "precision": "Precision",
+    "indexJobs": "Index Jobs",
+    "manageFrontendQueues": "Manage frontend prototype queues for connector sync, parsing, chunking, embedding, and re-indexing.",
+    "reindexBase": "Re-index Base",
+    "start": "Start",
+    "complete": "Complete",
+    "retry": "Retry",
+    "progress": "Progress",
+    "indexedRatio": "Indexed Ratio",
+    "avgScore": "Avg Score",
+    "evalPass": "Eval Pass",
+    "failedJobs": "Failed Jobs",
+    "qualitySignals": "Quality Signals",
+    "indexCoverage": "Index Coverage",
+    "citationConfidence": "Citation Confidence",
+    "evaluationPassRate": "Evaluation Pass Rate",
+    "jobHealth": "Job Health",
+    "topQueries": "Top Queries",
+    "governanceSettings": "Governance Settings",
+    "prototypeAccessSafety": "Prototype access, safety, and answer policy controls.",
+    "aclMode": "ACL Mode",
+    "private": "Private",
+    "team": "Team",
+    "workspace": "Workspace",
+    "piiRedaction": "PII redaction",
+    "maskSensitiveValues": "Mask sensitive values before content enters retrieval context.",
+    "documentLevelAccess": "Document-level access checks",
+    "applyMetadataSecurity": "Apply metadata security filters before ranking.",
+    "apiScopePreview": "API Scope Preview",
+    "configureConnector": "Configure {{kind}}",
+    "syncMode": "Sync Mode",
+    "manual": "Manual",
+    "hourly": "Hourly",
+    "daily": "Daily",
+    "weekly": "Weekly",
+    "includeExcludeFilters": "Include / Exclude Filters",
+    "createsLocalPrototype": "This creates a local prototype job. The UI flow is complete even before persistence is connected.",
+    "createJob": "Create Job",
+    "cancel": "Cancel",
+    "knowledgeCapabilityMap": "Knowledge Capability Map",
+    "moduleStatusAtGlance": "Module status at a glance. Open the dedicated section for each workflow.",
+    "productSurfaceRag": "Product surface for a full RAG knowledge platform. Prototype items are fully represented in the frontend flow and ready for later persistence.",
+    "retrievalSettings": "Retrieval Settings",
+    "tuneRetrievalBehavior": "Tune the retrieval behavior directly in the frontend prototype.",
+    "retrievalDefaults": "Retrieval Defaults",
+    "chunkSize": "Chunk Size",
+    "overlap": "Overlap",
+    "rerankResults": "Rerank results",
+    "reorderCandidates": "Reorder candidates after hybrid retrieval.",
+    "queryRewrite": "Query rewrite",
+    "expandShortQueries": "Expand short queries before search.",
+    "requireCitations": "Require citations",
+    "treatUncitedAnswers": "Treat uncited answers as low confidence.",
+    "keywordWeight": "Keyword Weight",
+    "vectorWeight": "Vector Weight",
+    "rerankWeight": "Rerank Weight",
+    "createKnowledgeBase": "Create Knowledge Base",
+    "knowledgeBaseCreated": "Knowledge base created",
+    "name": "Name",
+    "description": "Description",
+    "metadataJson": "Metadata JSON",
+    "create": "Create",
+    "creating": "Creating...",
+    "addDocumentTitle": "Add Document",
+    "parsePreview": "Parse Preview",
+    "parsing": "Parsing...",
+    "previewParse": "Preview Parse",
+    "parsePreviewReady": "Parse preview ready",
+    "parseFailed": "Parse failed",
+    "indexDocument": "Index Document",
+    "indexing": "Indexing...",
+    "documentIndexedWith": "Document indexed with {{count}} chunk{{count === 1 ? '' : 's'}}",
+    "documentIngestFailed": "Document ingest failed",
+    "sourceType": "Source Type",
+    "sourceUri": "Source URI",
+    "content": "Content",
+    "chunkOverlap": "Chunk Overlap"
+  },
+  "teams": {
+    "title": "Teams",
+    "description": "Manage multi-agent teams — create, configure members and policies, and run collaborative tasks.",
+    "newTeam": "New Team",
+    "searchPlaceholder": "Search teams...",
+    "searchByNameCodeType": "Search by name, code, type, or description",
+    "teamsShown": "of {{count}} teams shown",
+    "allStatuses": "All statuses",
+    "filterByStatus": "Filter by status",
+    "sortTeams": "Sort teams",
+    "newestFirst": "Newest first",
+    "noMatchingTeams": "No matching teams",
+    "adjustFiltersTeam": "Adjust search or filters to find a matching team.",
+    "noTeams": "No teams",
+    "createTeamStart": "Create a team to start coordinating multiple specialized agents.",
+    "selectTeam": "Select a team to view details.",
+    "selectTeamInspect": "Select a team to inspect collaboration, versions, and runs.",
+    "teamDetails": "Team Details",
+    "teamDirectory": "Team Directory",
+    "teamCockpit": "Team Cockpit",
+    "copyCode": "Copy Code",
+    "teamCodeCopied": "Team code copied",
+    "newVersion": "New Version",
+    "newVersionBtn": "New Version",
+    "run": "Run",
+    "runs": "Runs",
+    "versions": "Versions",
+    "members": "Members",
+    "failedRuns": "Failed Runs",
+    "member": "Member",
+    "role": "Role",
+    "agentId": "Agent ID",
+    "unboundAgent": "unbound-agent",
+    "responsibility": "Responsibility",
+    "noResponsibilitySummary": "No responsibility summary provided.",
+    "coordination": "Coordination",
+    "coordinationMode": "Coordination mode",
+    "coordinationTab": "Coordination",
+    "mode": "Mode",
+    "objective": "Objective",
+    "describeVersion": "Describe what this team should coordinate and optimize for.",
+    "policy": "Policy",
+    "maxRounds": "Max Rounds",
+    "maxRoundsField": "Max rounds",
+    "handoff": "Handoff",
+    "failureMode": "Failure Mode",
+    "supervisor": "Supervisor",
+    "worker": "Worker",
+    "reviewer": "Reviewer",
+    "planner": "Planner",
+    "roundRobin": "Round Robin",
+    "parallelMerge": "Parallel Merge",
+    "stopOnCritical": "Stop on Critical",
+    "continueWithWarning": "Continue with Warning",
+    "retryOnce": "Retry Once",
+    "addMember": "Add Member",
+    "remove": "Remove",
+    "selectAgent": "Select Agent",
+    "noMembersHint": "No members yet. Click \"Add Member\" to add team members.",
+    "addAtLeastOneMember": "Add at least one team member.",
+    "eachMemberNeedsAgentId": "Each member needs an agent selected.",
+    "maxRoundsMustBe": "Max rounds must be a whole number from 1 to 20.",
+    "basicInfo": "Basic Info",
+    "teamType": "Type",
+    "collaborative": "Collaborative",
+    "pipeline": "Pipeline",
+    "debate": "Debate",
+    "parallel": "Parallel",
+    "teamCreated": "Team created",
+    "failedCreateTeam": "Failed to create team",
+    "createTeam": "Create Team",
+    "createTeamVersion": "Create Team Version",
+    "createTeamVersionBefore": "Create a team version before starting a collaborative run.",
+    "createTeamVersionInspect": "Create a team version to inspect member policy and coordination settings.",
+    "teamVersionCreated": "Team version created",
+    "failedCreateTeamVersion": "Failed to create team version",
+    "startRun": "Start Run",
+    "starting": "Starting...",
+    "startRunPreview": "Start a run to preview the newest result for this team.",
+    "teamRunStarted": "Team run started",
+    "failedStartTeamRun": "Failed to start team run",
+    "startTeamRunTitle": "Start Team Run",
+    "runInput": "Run input",
+    "runConsole": "Run Console",
+    "input": "Input",
+    "allStatuses": "All statuses",
+    "allTypes": "All types",
+    "allRunStatuses": "All run statuses",
+    "filterByStatus": "Filter by status",
+    "filterByType": "Filter by type",
+    "filterRunsByStatus": "Filter runs by status",
+    "sortTeams": "Sort teams",
+    "newestFirst": "Newest first",
+    "listView": "List view",
+    "gridView": "Grid view",
+    "structuredInput": "Structured input payload",
+    "noOutputYet": "No output yet",
+    "noRunRecords": "No run records found.",
+    "noRuns": "No runs",
+    "noVersion": "No version",
+    "noVersionSelected": "No version selected",
+    "noRunnableVersion": "No runnable version",
+    "noObjectiveProvided": "No objective provided.",
+    "noPolicy": "No policy",
+    "notPublished": "Not published",
+    "notSet": "Not set",
+    "versionId": "Version ID",
+    "latest": "Latest",
+    "latestResult": "Latest Result",
+    "none": "None",
+    "lastRun": "Last Run",
+    "currentObjective": "Current Objective",
+    "createVersionDefine": "Create a version to define how this team collaborates.",
+    "createVersionDefineObjective": "Create a version to define objective, coordination mode, members, and execution policy.",
+    "createVersionBeforeDefining": "Create a team version before defining member roles and collaboration shape.",
+    "createVersionBeforeRun": "Create a team version before starting a run",
+    "createVersionBeforeTeamRun": "Create a version before this team can run work.",
+    "createNewVersionMember": "Create a new version with member rows to define who participates and what each role is responsible for.",
+    "adjustFiltersTeam": "Adjust search or filters to find a matching team definition.",
+    "noMembersConfigured": "No members configured",
+    "noDescription": "No description",
+    "owner": "Owner",
+    "unassigned": "Unassigned",
+    "preparingConsole": "Preparing console",
+    "taskTemplates": "Task templates",
+    "template": "Template {{index}}",
+    "searchByNameCodeType": "Search by name, code, type, or description"
+  },
+  "settings": {
+    "title": "Settings",
+    "description": "Manage identity, model providers, and gateway API keys.",
+    "newApiKey": "New API Key",
+    "user": "User",
+    "unset": "Unset",
+    "apiKeys": "API Keys",
+    "gatewayApiKeys": "Gateway API Keys",
+    "name": "Name",
+    "prefix": "Prefix",
+    "status": "Status",
+    "lastUsed": "Last Used",
+    "created": "Created",
+    "action": "Action",
+    "revoke": "Revoke",
+    "revoked": "Revoked",
+    "noApiKeys": "No API keys",
+    "createGatewayKey": "Create a gateway API key for local development or operator access.",
+    "apiKeyRevoked": "API key revoked",
+    "createApiKey": "Create API Key",
+    "copyKeyNow": "Copy this key now. The full secret is only shown once.",
+    "done": "Done",
+    "scopes": "Scopes",
+    "optionalScopes": "Optional comma-separated scopes",
+    "create": "Create",
+    "creating": "Creating...",
+    "apiKeyCreated": "API key created"
+  },
+  "modelProviders": {
+    "title": "Model Providers",
+    "description": "Configure LLM provider connections, API keys, and available models.",
+    "addProvider": "Add Provider",
+    "providerName": "Provider Name",
+    "providerType": "Provider Type",
+    "baseUrl": "Base URL",
+    "apiKey": "API Key",
+    "models": "Models",
+    "defaultModel": "Default Model",
+    "enabled": "Enabled",
+    "disabled": "Disabled",
+    "noProviders": "No model providers configured",
+    "noProvidersDescription": "Add a model provider to enable LLM connections for your agents.",
+    "testConnection": "Test Connection",
+    "testing": "Testing...",
+    "testSuccess": "Connection successful ({{latency}}ms)",
+    "testFailed": "Connection failed",
+    "deleteProvider": "Delete Provider",
+    "deleteConfirm": "Are you sure you want to delete this model provider? Agents using this provider may stop working.",
+    "providerCreated": "Model provider created",
+    "providerUpdated": "Model provider updated",
+    "providerDeleted": "Model provider deleted",
+    "availableModels": "Available Models",
+    "modelId": "Model ID",
+    "displayName": "Display Name",
+    "addModel": "Add Model",
+    "removeModel": "Remove",
+    "extraConfig": "Extra Config (JSON)",
+    "noModels": "No models configured",
+    "editProvider": "Edit Provider",
+    "createProvider": "Create Provider",
+    "openai": "OpenAI",
+    "anthropic": "Anthropic",
+    "deepseek": "DeepSeek",
+    "azure_openai": "Azure OpenAI",
+    "ollama": "Ollama",
+    "custom": "Custom",
+    "active": "Active",
+    "inactive": "Inactive",
+    "error": "Error",
+    "toggleStatus": "Toggle Status",
+    "masked": "***masked",
+    "discoverModels": "Auto Discover",
+    "discovering": "Discovering...",
+    "discovered": "Discovered {{count}} models",
+    "discoverFailed": "Failed to discover models",
+    "selectModels": "Select models to add",
+    "selectAll": "Select All",
+    "deselectAll": "Deselect All",
+    "applySelected": "Apply ({{count}})",
+    "noModelsDiscovered": "No models found. Check the base URL and API key.",
+    "ownedBy": "By {{owner}}",
+    "contextWindow": "{{tokens}} tokens",
+    "totalProviders": "Total Providers",
+    "activeProviders": "Active Providers",
+    "totalModels": "Total Models",
+    "providers": "Providers",
+    "searchProviders": "Search providers...",
+    "discoveredModels": "Discovered Models",
+    "allTypes": "All Types",
+    "type_chat": "Chat",
+    "type_reasoning": "Reasoning",
+    "type_embedding": "Embedding",
+    "type_image": "Image",
+    "type_audio": "Audio",
+    "type_video": "Video",
+    "type_rerank": "Rerank",
+    "type_moderation": "Moderation",
+    "type_other": "Other",
+    "filterByType": "Filter by type"
+  },
+  "skills": {
+    "title": "Skills",
+    "description": "Define skill packages that tell AI how to use tools to complete business tasks.",
+    "new": "New Skill",
+    "search": "Search skills...",
+    "empty": "No skills yet",
+    "emptyHint": "Create a skill to define how AI should use tools.",
+    "active": "Active",
+    "draft": "Draft",
+    "deleted": "Skill deleted",
+    "saved": "Skill saved",
+    "created": "Skill created",
+    "instruction": "Instruction",
+    "instructionHint": "Tell the AI step by step how to use the bound tools.",
+    "instructionPlaceholder": "When customer asks about order:\n1. Use lookup_order to find order\n2. Check status\n3. Provide update",
+    "noInstruction": "No instruction yet. Click edit to add one.",
+    "tools": "tools",
+    "toolsHint": "Click to toggle tool binding.",
+    "toolsCount": "Tools Bound",
+    "selectTools": "Select Tools",
+    "allCategories": "All Categories",
+    "category": "Category",
+    "namePlaceholder": "e.g., Order Status Checker",
+    "descPlaceholder": "Brief description...",
+    "info": "Information",
+    "catService": "Service",
+    "catAnalytics": "Analytics",
+    "catDevelopment": "Development",
+    "catProcessing": "Processing"
+  },
+  "errors": {
+    "failedToLoad": "Failed to load",
+    "failedToCreate": "Failed to create",
+    "failedToUpdate": "Failed to update",
+    "failedToDelete": "Failed to delete",
+    "failedToSave": "Failed to save"
+  }
+}

+ 933 - 0
web/src/locales/zh.json

@@ -0,0 +1,933 @@
+{
+  "common": {
+    "loading": "加载中...",
+    "error": "错误",
+    "retry": "重试",
+    "cancel": "取消",
+    "save": "保存",
+    "delete": "删除",
+    "edit": "编辑",
+    "create": "创建",
+    "search": "搜索",
+    "noData": "暂无数据",
+    "confirm": "确认",
+    "back": "返回",
+    "next": "下一步",
+    "previous": "上一步",
+    "submit": "提交",
+    "close": "关闭",
+    "refresh": "刷新",
+    "copy": "复制",
+    "copyCode": "复制代码",
+    "clear": "清除",
+    "clearFilters": "清除筛选",
+    "details": "详情",
+    "overview": "概览",
+    "settings": "设置",
+    "createVersion": "创建版本",
+    "start": "开始",
+    "stop": "停止",
+    "run": "运行",
+    "runs": "运行",
+    "running": "运行中",
+    "creating": "创建中...",
+    "starting": "启动中...",
+    "createNew": "新建",
+    "noResults": "无结果",
+    "version": "版本",
+    "versions": "版本",
+    "status": "状态",
+    "type": "类型",
+    "name": "名称",
+    "description": "描述",
+    "code": "代码",
+    "created": "创建时间",
+    "updated": "更新时间",
+    "actions": "操作",
+    "active": "活跃",
+    "draft": "草稿",
+    "archived": "已归档",
+    "all": "全部",
+    "filter": "筛选",
+    "sort": "排序",
+    "select": "选择",
+    "selectAll": "全选",
+    "required": "必填",
+    "optional": "可选",
+    "enabled": "已启用",
+    "disabled": "已禁用",
+    "yes": "是",
+    "no": "否",
+    "queued": "排队中",
+    "completed": "已完成",
+    "failed": "已失败",
+    "cancelled": "已取消",
+    "filterByStatus": "按状态筛选",
+    "filterByAgentType": "按智能体类型筛选",
+    "sortAgents": "排序智能体",
+    "listView": "列表视图",
+    "gridView": "网格视图",
+    "list": "列表",
+    "grid": "网格",
+    "filterRunsByStatus": "按运行状态筛选"
+  },
+  "nav": {
+    "dashboard": "仪表盘",
+    "agents": "智能体",
+    "sessions": "会话",
+    "tools": "工具",
+    "knowledge": "知识库",
+    "teams": "团队",
+    "skills": "技能",
+    "models": "模型",
+    "settings": "设置",
+    "collapse": "收起"
+  },
+  "app": {
+    "name": "自动化平台",
+    "loadingStudio": "正在加载工作室"
+  },
+  "theme": {
+    "switchToLight": "切换到浅色模式",
+    "switchToDark": "切换到深色模式"
+  },
+  "auth": {
+    "logout": "退出登录",
+    "user": "用户",
+    "mockData": "模拟数据",
+    "gateway": "网关"
+  },
+  "login": {
+    "title": "Web Studio 登录",
+    "description": "使用您的网关 API 密钥登录。",
+    "apiKey": "API 密钥",
+    "userId": "用户 ID",
+    "enterStudio": "进入工作室",
+    "checking": "验证中...",
+    "useDemo": "使用演示凭证",
+    "showKey": "显示 API 密钥",
+    "hideKey": "隐藏 API 密钥",
+    "connected": "已连接到网关",
+    "rejected": "网关拒绝了凭据"
+  },
+  "dashboard": {
+    "title": "仪表盘",
+    "description": "智能体执行的操作概览。",
+    "agents": "智能体",
+    "runsToday": "今日运行",
+    "activeSessions": "活跃会话",
+    "executionTrend": "执行趋势",
+    "serviceHealth": "服务健康",
+    "recentRuns": "最近运行",
+    "allRequestsFailed": "所有仪表盘请求失败。请检查 API 网关和凭据。",
+    "services": "服务"
+  },
+  "agents": {
+    "title": "智能体",
+    "description": "从统一工作区设计、检查和操作智能体定义、版本、提示和运行。",
+    "create": "创建智能体",
+    "newAgent": "新建智能体",
+    "empty": "暂无智能体",
+    "emptyDescription": "创建您的第一个智能体开始使用。",
+    "refresh": "刷新",
+    "agentDirectory": "智能体目录",
+    "agentsShown": "显示 {{count}} 个智能体中的",
+    "searchByNameCodeType": "按名称、代码、类型或描述搜索",
+    "allStatuses": "全部状态",
+    "allTypes": "全部类型",
+    "newestFirst": "最新优先",
+    "list": "列表",
+    "grid": "网格",
+    "overview": "概览",
+    "promptConfig": "提示与配置",
+    "testConsole": "测试控制台",
+    "noMatchingAgents": "没有匹配的智能体",
+    "adjustFiltersAgent": "调整搜索或筛选条件以找到匹配的智能体定义。",
+    "agentDetails": "智能体详情",
+    "selectAgent": "选择一个智能体以检查其操作界面。",
+    "copyCode": "复制代码",
+    "newVersion": "新建版本",
+    "runs": "运行",
+    "testInput": "测试输入",
+    "resultPreview": "结果预览",
+    "simulateRun": "模拟运行",
+    "runSimulation": "运行本地模拟以预览输出契约。",
+    "noDescription": "未提供描述。",
+    "selectAnAgent": "选择一个智能体",
+    "noAgents": "暂无智能体",
+    "createAgentStart": "创建一个智能体以开始构建定义和版本。",
+    "versions": "版本",
+    "failures": "失败",
+    "latest": "最新",
+    "none": "无",
+    "owner": "所有者",
+    "unassigned": "未分配",
+    "role": "角色",
+    "model": "模型",
+    "published": "已发布",
+    "notPublished": "未发布",
+    "systemPrompt": "系统提示",
+    "modelConfig": "模型配置",
+    "memoryPolicy": "记忆策略",
+    "toolReferences": "工具引用",
+    "skillReferences": "技能引用",
+    "noConfig": "无配置",
+    "publishOrDraft": "在检查提示和运行时配置之前,请先发布或草稿一个版本。",
+    "noVersions": "暂无版本",
+    "createVersionDefine": "创建版本以定义角色、目标、提示、工具和运行时配置。",
+    "noRuns": "暂无运行",
+    "noRunRecords": "没有匹配当前智能体和状态筛选的运行记录。",
+    "created": "创建时间",
+    "worker": "工作者",
+    "output": "输出",
+    "noOutputYet": "暂无输出",
+    "structuredInput": "结构化输入载荷",
+    "loaded": "已加载",
+    "runsCount": "运行次数",
+    "agentCreated": "智能体已创建",
+    "definitionDescription": "首先创建可重用的智能体身份。版本、提示词、工具和运行时配置都位于此定义之下。",
+    "failedRuns": "失败运行",
+    "simulationPlaceholder": "总结最近的客户请求并推荐下一步操作。",
+    "namePlaceholder": "Support Agent",
+    "codePlaceholder": "support_agent",
+    "descriptionPlaceholder": "此智能体负责什么、何时使用,以及良好输出的标准。",
+    "typeAssistant": "助手",
+    "typePlanner": "规划者",
+    "typeExecutor": "执行者",
+    "typeResearch": "研究",
+    "typeToolUser": "工具使用者",
+    "searchPlaceholder": "搜索智能体...",
+    "codeCopied": "智能体代码已复制",
+    "versionCreated": "智能体版本已创建",
+    "failedCreateVersion": "创建智能体版本失败",
+    "basicInfo": "基本信息",
+    "basicInfoDesc": "定义智能体的身份和用途。",
+    "agentRole": "角色",
+    "goal": "目标",
+    "goalPlaceholder": "描述此智能体应该完成什么。",
+    "systemPrompt": "系统提示词",
+    "systemPromptPlaceholder": "你是一个有用的助手,...",
+    "modelSettings": "模型设置",
+    "modelSettingsDesc": "选择大语言模型和参数。",
+    "provider": "供应商",
+    "model": "模型",
+    "temperature": "温度",
+    "maxTokens": "最大 Token 数",
+    "toolsSection": "工具",
+    "toolsSectionDesc": "附加此智能体可以使用的工具。",
+    "memorySection": "记忆",
+    "memorySectionDesc": "配置智能体如何管理对话记忆。",
+    "memoryEnabled": "启用",
+    "memoryScope": "范围",
+    "memoryScopeSession": "会话",
+    "memoryScopePersistent": "持久化",
+    "memoryScopeNone": "无"
+  },
+  "sessions": {
+    "title": "会话",
+    "description": "检查渠道会话和聊天消息。",
+    "create": "新建会话",
+    "newSession": "新建会话",
+    "empty": "暂无会话",
+    "emptyDescription": "开始一个新会话。",
+    "typeMessage": "输入消息...",
+    "searchSessions": "搜索会话",
+    "noMessages": "暂无消息",
+    "sendFirstMessage": "发送此会话的第一条消息。",
+    "noSessionSelected": "未选择会话",
+    "chooseOrCreate": "选择或创建一个会话以开始聊天。",
+    "untitledSession": "无标题会话",
+    "message": "消息",
+    "sendMessage": "发送消息",
+    "selectSession": "选择一个会话",
+    "sessionCreated": "会话已创建",
+    "messageSent": "消息已发送",
+    "channelType": "渠道类型"
+  },
+  "tools": {
+    "title": "工具",
+    "description": "管理工具定义、版本、就绪状态和快速载荷测试。",
+    "create": "创建工具",
+    "newTool": "新建工具",
+    "empty": "暂无可用工具",
+    "refresh": "刷新",
+    "toolList": "工具列表",
+    "shown": "显示 {{count}} 个中的",
+    "searchTools": "搜索工具",
+    "allTypes": "全部类型",
+    "allStatus": "全部状态",
+    "hasVersion": "有版本",
+    "bound": "已绑定",
+    "needsVersion": "需要版本",
+    "noToolsFound": "未找到工具",
+    "adjustFiltersTool": "调整筛选条件或创建新工具。",
+    "toolDetails": "工具详情",
+    "selectTool": "选择一个工具以查看版本并运行快速测试。",
+    "definition": "定义",
+    "version": "版本",
+    "binding": "绑定",
+    "credential": "凭证",
+    "readiness": "就绪状态",
+    "basicInfo": "基本信息",
+    "plugin": "插件",
+    "standalone": "独立",
+    "timeout": "超时",
+    "notSet": "未设置",
+    "latestVersion": "最新版本",
+    "inputs": "个输入",
+    "outputs": "个输出",
+    "retry": "重试",
+    "payloadTest": "载荷测试",
+    "runMockRequest": "针对所选版本运行模拟请求。",
+    "run": "运行",
+    "result": "结果",
+    "noRunYet": "暂无运行",
+    "selectVersionRun": "选择一个版本并运行 JSON。",
+    "testPayloadSimulated": "测试载荷已模拟",
+    "payloadMustBeJson": "载荷必须是有效的 JSON",
+    "inputSchema": "输入模式",
+    "invokeConfig": "调用配置",
+    "retryPolicy": "重试策略",
+    "noVersionsYet": "暂无版本",
+    "createVersionBeforeTesting": "在测试此工具之前先创建一个版本。",
+    "createToolVersion": "创建工具版本",
+    "endpoint": "端点",
+    "timeoutMs": "超时 (毫秒)",
+    "retryAttempts": "重试次数",
+    "createTool": "创建工具",
+    "type": "类型",
+    "ready": "就绪",
+    "bindings": "绑定",
+    "versionsCount": "版本",
+    "none": "无",
+    "versionDescription": "端点、超时、重试和模式快照。",
+    "toolVersionCreated": "工具版本已创建",
+    "toolCreated": "工具已创建",
+    "createVersion": "创建版本",
+    "test": "测试",
+    "filterByType": "按工具类型筛选",
+    "filterByStatus": "按工具状态筛选"
+  },
+  "knowledge": {
+    "title": "知识库",
+    "description": "管理检索库、摄取文档、检查索引状态和测试语义搜索。",
+    "refresh": "刷新",
+    "reindex": "重新索引",
+    "reindexStarted": "重新索引任务已启动",
+    "addDocument": "添加文档",
+    "reindexScope": "重新索引范围",
+    "newBase": "新建知识库",
+    "knowledgeBases": "知识库",
+    "documents": "文档",
+    "indexed": "已索引",
+    "sources": "来源",
+    "bases": "知识库",
+    "searchBases": "搜索知识库",
+    "noBasesFound": "未找到知识库",
+    "createOrSearch": "创建或搜索另一个知识库。",
+    "newBaseBtn": "新建知识库",
+    "activeKnowledgeBase": "活动知识库",
+    "scope": "范围",
+    "current": "当前",
+    "allBases": "全部知识库",
+    "operationsApplyTo": "操作适用于",
+    "knowledgeBases_count": "{{count}} 个知识库",
+    "selectKnowledgeBase": "选择知识库",
+    "chooseBaseManage": "选择一个知识库以管理文档、检索、摄取和设置。",
+    "selectKnowledgeBaseFirst": "请先选择知识库",
+    "chooseBaseManageDocs": "请从上方的作用域栏选择知识库以管理其文档。",
+    "noDescription": "无描述",
+    "archive": "归档",
+    "restoreCurrent": "恢复当前",
+    "reindexSet": "重新索引集合",
+    "selected": "已选择",
+    "documentsCount": "文档",
+    "indexedCount": "已索引",
+    "knowledgeCapabilityMap": "知识能力图",
+    "moduleStatus": "模块状态一览。打开专用部分查看每个工作流。",
+    "overview": "概览",
+    "documentsTab": "文档",
+    "ingest": "摄取",
+    "playground": "测试场",
+    "evaluation": "评估",
+    "jobs": "任务",
+    "analytics": "分析",
+    "settingsTab": "设置",
+    "ingestDocuments": "摄取文档",
+    "prototypeIngestion": "在前端原型化每种摄取来源。每个连接器创建一个本地索引任务,行为类似于完整的工作流。",
+    "pasteText": "粘贴文本",
+    "createParseChunk": "创建、解析、分块、嵌入和索引内容。",
+    "urlSitemap": "URL / 站点地图",
+    "configureCrawlDepth": "配置爬取深度、包含模式和同步周期。",
+    "githubRepository": "GitHub 仓库",
+    "chooseRepoBranch": "选择仓库、分支、路径过滤器和同步模式。",
+    "pdfDocxUpload": "PDF / DOCX 上传",
+    "stageBatchPreview": "暂存批次、预览解析器设置,并创建索引任务。",
+    "retrievalPlayground": "检索测试场",
+    "tuneTopKFilters": "调整 Top K 和过滤器、运行检索,然后检查引用和分数细分。",
+    "askRetrievalQuestion": "提出检索问题",
+    "topK": "Top K",
+    "searchSourceFilter": "搜索来源过滤器",
+    "searchResultCount": "搜索 {{count}}",
+    "searching": "搜索中...",
+    "noSearchResultsYet": "暂无搜索结果",
+    "runRetrievalInspect": "运行检索查询以检查块、引用和分数 JSON。",
+    "retrievalSettings": "检索设置",
+    "tuneRetrievalBehavior": "直接在前端原型中调整检索行为。",
+    "retrievalDefaults": "检索默认值",
+    "chunkSize": "块大小",
+    "overlap": "重叠",
+    "rerankResults": "重排结果",
+    "reorderCandidates": "混合检索后重新排序候选。",
+    "queryRewrite": "查询重写",
+    "expandShortQueries": "搜索前扩展短查询。",
+    "requireCitations": "需要引用",
+    "treatUncitedAnswers": "将无引用答案视为低置信度。",
+    "keywordWeight": "关键词权重",
+    "vectorWeight": "向量权重",
+    "rerankWeight": "重排权重",
+    "goldenQuery": "黄金查询",
+    "buildEvaluationSet": "构建评估集并运行前端基准模拟。",
+    "query": "查询",
+    "expectedSource": "预期来源",
+    "addCase": "添加案例",
+    "avgRecall": "平均召回率",
+    "avgPrecision": "平均精确率",
+    "evaluationCases": "评估案例",
+    "expected": "预期",
+    "recall": "召回率",
+    "precision": "精确率",
+    "indexJobs": "索引任务",
+    "manageFrontendQueues": "管理前端原型队列:连接器同步、解析、分块、嵌入和重新索引。",
+    "reindexBase": "重新索引库",
+    "start": "开始",
+    "complete": "完成",
+    "retry": "重试",
+    "progress": "进度",
+    "analyticsTab": "分析",
+    "indexedRatio": "索引比率",
+    "avgScore": "平均分数",
+    "evalPass": "评估通过",
+    "failedJobs": "失败任务",
+    "qualitySignals": "质量信号",
+    "indexCoverage": "索引覆盖率",
+    "citationConfidence": "引用置信度",
+    "evaluationPassRate": "评估通过率",
+    "jobHealth": "任务健康度",
+    "topQueries": "热门查询",
+    "governanceSettings": "治理设置",
+    "prototypeAccessSafety": "原型化访问、安全和答案策略控制。",
+    "aclMode": "ACL 模式",
+    "private": "私有",
+    "team": "团队",
+    "workspace": "工作区",
+    "piiRedaction": "PII 删除",
+    "maskSensitiveValues": "在内容进入检索上下文之前屏蔽敏感值。",
+    "documentLevelAccess": "文档级访问检查",
+    "applyMetadataSecurity": "在排名之前应用元数据安全过滤器。",
+    "apiScopePreview": "API 范围预览",
+    "accessControl": "访问控制",
+    "dataProtection": "数据保护",
+    "exportEnabled": "允许导出",
+    "allowDataExport": "允许导出知识库数据",
+    "auditLog": "审计日志",
+    "enableAuditLogging": "启用审计日志",
+    "trackAccessModifications": "跟踪文档访问和修改",
+    "dataRetention": "数据保留",
+    "days": "{{count}} 天",
+    "networkSecurity": "网络安全",
+    "ipAllowlist": "IP 白名单",
+    "restrictAccessByIp": "按 IP 地址限制访问",
+    "allowedIpRanges": "允许的 IP 范围",
+    "configureConnector": "配置 {{kind}}",
+    "source": "来源",
+    "syncMode": "同步模式",
+    "manual": "手动",
+    "hourly": "每小时",
+    "daily": "每天",
+    "weekly": "每周",
+    "includeExcludeFilters": "包含/排除过滤器",
+    "createsLocalPrototype": "这会创建一个本地原型任务。UI 流程在连接持久化之前就已完整。",
+    "createJob": "创建任务",
+    "createKnowledgeBase": "创建知识库",
+    "metadataJson": "元数据 JSON",
+    "addDocumentTitle": "添加文档",
+    "sourceType": "来源类型",
+    "sourceUri": "来源 URI",
+    "content": "内容",
+    "chunkOverlap": "块重叠",
+    "parsePreview": "解析预览",
+    "previewParse": "预览解析",
+    "parsing": "解析中...",
+    "indexDocument": "索引文档",
+    "indexing": "索引中...",
+    "documentIngestFailed": "文档摄取失败",
+    "knowledgeBaseCreated": "知识库已创建",
+    "knowledgeBaseRestored": "知识库已恢复",
+    "knowledgeBaseArchived": "知识库已归档",
+    "searchResults": "条搜索结果",
+    "noMatchingChunks": "未找到匹配的块",
+    "searchFailed": "搜索失败",
+    "failedToLoadDocuments": "加载文档失败",
+    "lastIngestCreated": "上次摄取创建了 {{count}} 个块",
+    "selectDocument": "选择一个文档",
+    "documentDetailsAppear": "文档详情、元数据和匹配块将显示在此处。",
+    "noDocumentSearchResults": "无文档搜索结果",
+    "runQueryPlayground": "在测试场中运行查询以检查此文档的匹配块。",
+    "metadata": "元数据",
+    "chunks": "块",
+    "chunk": "块",
+    "tokens": "个令牌",
+    "noChunksLoaded": "暂无加载的块",
+    "runSearchOrIndex": "运行搜索或索引新文档以检查块。",
+    "noMatchingDocuments": "没有匹配筛选条件的文档",
+    "adjustSearchOrStatus": "调整搜索文本或状态过滤器。",
+    "noDocuments": "暂无文档",
+    "addTextMarkdownJson": "添加文本、Markdown、JSON、HTML 或从文件衍生的文档以进行检索索引。",
+    "allStatuses": "全部状态",
+    "indexed_status": "已索引",
+    "draft_status": "草稿",
+    "failed_status": "失败",
+    "archived_status": "已归档",
+    "allSources": "全部来源",
+    "sourceStatus": "来源",
+    "statusIndexed": "状态",
+    "createdAt": "创建于",
+    "indexedAt": "索引于",
+    "pending": "待处理",
+    "contentHash": "内容哈希",
+    "notAvailable": "不可用",
+    "notProvided": "未提供",
+    "state": {
+      "live": "上线",
+      "mock": "模拟",
+      "mockLocal": "模拟/本地",
+      "prototype": "原型",
+      "proto": "原"
+    },
+    "ingestion": "摄取",
+    "indexingTab": "索引",
+    "retrievalTab": "检索",
+    "governance": "治理",
+    "integration": "集成",
+    "textMarkdownIngest": "文本/标记文本摄取",
+    "parsePreviewIng": "解析预览",
+    "pdfDocxHtmlParser": "PDF / DOCX / HTML 解析器",
+    "urlSitemapGithub": "URL、站点地图、GitHub 导入",
+    "chunkSizeOverlap": "块大小/重叠",
+    "embeddingModelTracking": "嵌入模型跟踪",
+    "reindexDocumentBase": "重新索引文档/库",
+    "indexJobQueue": "索引任务队列",
+    "hybridSearchPlayground": "混合搜索测试场",
+    "topKMetadataFilters": "Top K 和元数据过滤器",
+    "scoreBreakdownCitations": "分数细分和引用",
+    "rerankQueryRewrite": "重排/查询重写控制",
+    "goldenQueries": "黄金查询",
+    "recallPrecisionMetrics": "召回率/精确率指标",
+    "humanRelevanceFeedback": "人工相关性反馈",
+    "regressionComparison": "回归对比",
+    "archiveRestoreBase": "归档/恢复库",
+    "tenantIsolation": "租户隔离",
+    "documentAclPii": "文档 ACL / PII 规则",
+    "auditLog": "审计日志",
+    "agentBinding": "智能体绑定",
+    "workflowRetrievalNode": "工作流检索节点",
+    "knowledgeSearchTool": "知识搜索工具",
+    "runTraceCitations": "运行追踪引用",
+    "createdJob": "{{type}} 任务已创建",
+    "evaluationCaseFinished": "评估案例已完成",
+    "knowledgeBases_plural": "{{count}} 个知识库",
+    "selectAKnowledgeBase": "选择知识库",
+    "chooseBaseManageDocs": "选择一个知识库以管理文档、检索、摄取和设置。",
+    "archive": "归档",
+    "restoreCurrent": "恢复当前",
+    "reindexSet": "重新索引集合",
+    "selected": "已选择",
+    "documentsTab": "文档",
+    "source": "来源",
+    "indexed_status": "已索引",
+    "pending": "待处理",
+    "contentHash": "内容哈希",
+    "notAvailable": "不可用",
+    "notProvided": "未提供",
+    "lastIngestCreated": "上次摄取创建了 {{count}} 个块",
+    "inspector": "检查器",
+    "overview": "概览",
+    "search": "搜索",
+    "metadata": "元数据",
+    "chunks": "块",
+    "chunkTokenCount": "块 #{{index}} / {{count}} 个令牌",
+    "selectDocument": "选择一个文档",
+    "documentDetailsAppear": "文档详情、元数据和匹配块将显示在此处。",
+    "noDocumentSearchResults": "无文档搜索结果",
+    "runQueryPlayground": "在测试场中运行查询以检查此文档的匹配块。",
+    "noChunksLoaded": "暂无加载的块",
+    "runSearchOrIndex": "运行搜索或索引新文档以检查块。",
+    "searching": "搜索中...",
+    "searchResultCount": "搜索 {{count}}",
+    "searchResults": "{{count}} 条搜索结果",
+    "noSearchResultsYet": "暂无搜索结果",
+    "runRetrievalInspect": "运行检索查询以检查块、引用和分数 JSON。",
+    "searchPlaceholder": "提出检索问题",
+    "searchSourceFilter": "搜索来源过滤器",
+    "topK": "Top K",
+    "add": "添加",
+    "loadingDocuments": "加载文档中",
+    "noDocumentsMatchFilters": "没有匹配筛选条件的文档",
+    "adjustSearchStatus": "调整搜索文本或状态过滤器。",
+    "noDocuments": "暂无文档",
+    "addTextMarkdownJsonHtml": "添加文本、Markdown、JSON、HTML 或从文件衍生的文档以进行检索索引。",
+    "goldenQuery": "黄金查询",
+    "buildEvaluationSet": "构建评估集并运行前端基准模拟。",
+    "query": "查询",
+    "expectedSource": "预期来源",
+    "addCase": "添加案例",
+    "avgRecall": "平均召回率",
+    "avgPrecision": "平均精确率",
+    "evaluationCases": "评估案例",
+    "expected": "预期",
+    "recall": "召回率",
+    "precision": "精确率",
+    "indexJobs": "索引任务",
+    "manageFrontendQueues": "管理前端原型队列:连接器同步、解析、分块、嵌入和重新索引。",
+    "reindexBase": "重新索引库",
+    "start": "开始",
+    "complete": "完成",
+    "retry": "重试",
+    "progress": "进度",
+    "indexedRatio": "索引比率",
+    "avgScore": "平均分数",
+    "evalPass": "评估通过",
+    "failedJobs": "失败任务",
+    "qualitySignals": "质量信号",
+    "indexCoverage": "索引覆盖率",
+    "citationConfidence": "引用置信度",
+    "evaluationPassRate": "评估通过率",
+    "jobHealth": "任务健康度",
+    "topQueries": "热门查询",
+    "governanceSettings": "治理设置",
+    "prototypeAccessSafety": "原型化访问、安全和答案策略控制。",
+    "aclMode": "ACL 模式",
+    "private": "私有",
+    "team": "团队",
+    "workspace": "工作区",
+    "piiRedaction": "PII 删除",
+    "maskSensitiveValues": "在内容进入检索上下文之前屏蔽敏感值。",
+    "documentLevelAccess": "文档级访问检查",
+    "applyMetadataSecurity": "在排名之前应用元数据安全过滤器。",
+    "apiScopePreview": "API 范围预览",
+    "configureConnector": "配置 {{kind}}",
+    "syncMode": "同步模式",
+    "manual": "手动",
+    "hourly": "每小时",
+    "daily": "每天",
+    "weekly": "每周",
+    "includeExcludeFilters": "包含/排除过滤器",
+    "createsLocalPrototype": "这会创建一个本地原型任务。UI 流程在连接持久化之前就已完整。",
+    "createJob": "创建任务",
+    "cancel": "取消",
+    "knowledgeCapabilityMap": "知识能力图",
+    "moduleStatusAtGlance": "模块状态一览。打开专用部分查看每个工作流。",
+    "productSurfaceRag": "全 RAG 知识平台的产品界面。原项目在前端流程中已完全呈现,可用于后续持久化。",
+    "retrievalSettings": "检索设置",
+    "tuneRetrievalBehavior": "直接在前端原型中调整检索行为。",
+    "retrievalDefaults": "检索默认值",
+    "chunkSize": "块大小",
+    "overlap": "重叠",
+    "rerankResults": "重排结果",
+    "reorderCandidates": "混合检索后重新排序候选。",
+    "queryRewrite": "查询重写",
+    "expandShortQueries": "搜索前扩展短查询。",
+    "requireCitations": "需要引用",
+    "treatUncitedAnswers": "将无引用答案视为低置信度。",
+    "keywordWeight": "关键词权重",
+    "vectorWeight": "向量权重",
+    "rerankWeight": "重排权重",
+    "createKnowledgeBase": "创建知识库",
+    "knowledgeBaseCreated": "知识库已创建",
+    "name": "名称",
+    "description": "描述",
+    "metadataJson": "元数据 JSON",
+    "create": "创建",
+    "creating": "创建中...",
+    "addDocumentTitle": "添加文档",
+    "parsePreview": "解析预览",
+    "parsing": "解析中...",
+    "previewParse": "预览解析",
+    "parsePreviewReady": "解析预览已就绪",
+    "parseFailed": "解析失败",
+    "indexDocument": "索引文档",
+    "indexing": "索引中...",
+    "documentIndexedWith": "文档已索引,包含 {{count}} 个块",
+    "documentIngestFailed": "文档摄取失败",
+    "sourceType": "来源类型",
+    "sourceUri": "来源 URI",
+    "content": "内容",
+    "chunkOverlap": "块重叠"
+  },
+  "teams": {
+    "title": "团队",
+    "description": "管理多智能体团队 — 创建、配置成员和策略、运行协作任务。",
+    "newTeam": "新建团队",
+    "searchPlaceholder": "搜索团队...",
+    "searchByNameCodeType": "按名称、代码、类型或描述搜索",
+    "teamsShown": "显示 {{count}} 个团队中的",
+    "allStatuses": "全部状态",
+    "filterByStatus": "按状态筛选",
+    "sortTeams": "排序团队",
+    "newestFirst": "最新优先",
+    "noMatchingTeams": "没有匹配的团队",
+    "adjustFiltersTeam": "调整搜索或筛选条件以找到匹配的团队。",
+    "noTeams": "暂无团队",
+    "createTeamStart": "创建一个团队以开始协调多个专业智能体。",
+    "selectTeam": "选择一个团队以查看详情。",
+    "selectTeamInspect": "选择一个团队以检查协作、版本和运行。",
+    "teamDetails": "团队详情",
+    "teamDirectory": "团队目录",
+    "teamCockpit": "团队驾驶舱",
+    "copyCode": "复制代码",
+    "teamCodeCopied": "团队代码已复制",
+    "newVersion": "新建版本",
+    "newVersionBtn": "新建版本",
+    "run": "运行",
+    "runs": "运行",
+    "versions": "版本",
+    "members": "成员",
+    "failedRuns": "失败运行",
+    "member": "成员",
+    "role": "角色",
+    "agentId": "智能体 ID",
+    "unboundAgent": "未绑定智能体",
+    "responsibility": "职责",
+    "noResponsibilitySummary": "未提供职责摘要。",
+    "coordination": "协调",
+    "coordinationMode": "协调模式",
+    "coordinationTab": "协调",
+    "mode": "模式",
+    "objective": "目标",
+    "describeVersion": "描述此团队应该协调和优化的内容。",
+    "policy": "策略",
+    "maxRounds": "最大轮数",
+    "maxRoundsField": "最大轮数",
+    "handoff": "交接",
+    "failureMode": "失败模式",
+    "supervisor": "监督者",
+    "worker": "工作者",
+    "reviewer": "审查者",
+    "planner": "规划者",
+    "roundRobin": "轮询",
+    "parallelMerge": "并行合并",
+    "stopOnCritical": "关键时停止",
+    "continueWithWarning": "警告后继续",
+    "retryOnce": "重试一次",
+    "addMember": "添加成员",
+    "remove": "移除",
+    "selectAgent": "选择智能体",
+    "noMembersHint": "暂无成员,点击「添加成员」为团队添加成员。",
+    "addAtLeastOneMember": "至少添加一个团队成员。",
+    "eachMemberNeedsAgentId": "每个成员都需要选择一个智能体。",
+    "maxRoundsMustBe": "最大轮数必须是 1 到 20 之间的整数。",
+    "basicInfo": "基本信息",
+    "teamType": "类型",
+    "collaborative": "协作",
+    "pipeline": "管道",
+    "debate": "辩论",
+    "parallel": "并行",
+    "teamCreated": "团队已创建",
+    "failedCreateTeam": "创建团队失败",
+    "createTeam": "创建团队",
+    "createTeamVersion": "创建团队版本",
+    "createTeamVersionBefore": "在开始协作运行之前先创建团队版本。",
+    "createTeamVersionInspect": "创建团队版本以检查成员策略和协调设置。",
+    "teamVersionCreated": "团队版本已创建",
+    "failedCreateTeamVersion": "创建团队版本失败",
+    "startRun": "开始运行",
+    "starting": "启动中...",
+    "startRunPreview": "运行以预览此团队的最新结果。",
+    "teamRunStarted": "团队运行已开始",
+    "failedStartTeamRun": "启动团队运行失败",
+    "startTeamRunTitle": "开始团队运行",
+    "runInput": "运行输入",
+    "runConsole": "运行控制台",
+    "input": "输入",
+    "allStatuses": "全部状态",
+    "allTypes": "全部类型",
+    "allRunStatuses": "全部运行状态",
+    "filterByStatus": "按状态筛选",
+    "filterByType": "按类型筛选",
+    "filterRunsByStatus": "按运行状态筛选",
+    "sortTeams": "排序团队",
+    "newestFirst": "最新优先",
+    "listView": "列表视图",
+    "gridView": "网格视图",
+    "structuredInput": "结构化输入载荷",
+    "noOutputYet": "暂无输出",
+    "noRunRecords": "暂无运行记录。",
+    "noRuns": "暂无运行",
+    "noVersion": "无版本",
+    "noVersionSelected": "未选择版本",
+    "noRunnableVersion": "无可运行版本",
+    "noObjectiveProvided": "未提供目标。",
+    "noPolicy": "无策略",
+    "notPublished": "未发布",
+    "notSet": "未设置",
+    "versionId": "版本 ID",
+    "latest": "最新",
+    "latestResult": "最新结果",
+    "none": "无",
+    "lastRun": "上次运行",
+    "currentObjective": "当前目标",
+    "createVersionDefine": "创建一个版本以定义此团队的协作方式。",
+    "createVersionDefineObjective": "创建版本以定义目标、协调模式、成员和执行策略。",
+    "createVersionBeforeDefining": "在定义成员角色和协作形态之前先创建团队版本。",
+    "createVersionBeforeRun": "在开始运行之前先创建团队版本",
+    "createVersionBeforeTeamRun": "在此团队能运行工作之前先创建一个版本。",
+    "createNewVersionMember": "创建带有成员行的新版本,以定义谁参与以及每个角色负责什么。",
+    "adjustFiltersTeam": "调整搜索或筛选条件以找到匹配的团队定义。",
+    "noMembersConfigured": "未配置成员",
+    "noDescription": "暂无描述",
+    "owner": "所有者",
+    "unassigned": "未分配",
+    "preparingConsole": "准备控制台",
+    "taskTemplates": "任务模板",
+    "template": "模板 {{index}}",
+    "searchByNameCodeType": "按名称、代码、类型或描述搜索"
+  },
+  "settings": {
+    "title": "设置",
+    "description": "管理身份、模型接入和网关 API 密钥。",
+    "newApiKey": "新建 API 密钥",
+    "user": "用户",
+    "unset": "未设置",
+    "apiKeys": "API 密钥",
+    "gatewayApiKeys": "网关 API 密钥",
+    "name": "名称",
+    "prefix": "前缀",
+    "status": "状态",
+    "lastUsed": "上次使用",
+    "created": "创建时间",
+    "action": "操作",
+    "revoke": "撤销",
+    "revoked": "已撤销",
+    "noApiKeys": "暂无 API 密钥",
+    "createGatewayKey": "为本地开发或操作员访问创建网关 API 密钥。",
+    "apiKeyRevoked": "API 密钥已撤销",
+    "createApiKey": "创建 API 密钥",
+    "copyKeyNow": "立即复制此密钥。完整密钥只显示一次。",
+    "done": "完成",
+    "scopes": "范围",
+    "optionalScopes": "可选的逗号分隔范围",
+    "create": "创建",
+    "creating": "创建中...",
+    "apiKeyCreated": "API 密钥已创建"
+  },
+  "modelProviders": {
+    "title": "模型接入",
+    "description": "配置大语言模型供应商连接、API 密钥和可用模型。",
+    "addProvider": "添加供应商",
+    "providerName": "供应商名称",
+    "providerType": "供应商类型",
+    "baseUrl": "基础 URL",
+    "apiKey": "API 密钥",
+    "models": "模型",
+    "defaultModel": "默认模型",
+    "enabled": "已启用",
+    "disabled": "已禁用",
+    "noProviders": "暂无模型供应商",
+    "noProvidersDescription": "添加模型供应商以启用智能体的 LLM 连接。",
+    "testConnection": "测试连接",
+    "testing": "测试中...",
+    "testSuccess": "连接成功({{latency}}ms)",
+    "testFailed": "连接失败",
+    "deleteProvider": "删除供应商",
+    "deleteConfirm": "确定要删除此模型供应商吗?使用此供应商的智能体可能会停止工作。",
+    "providerCreated": "模型供应商已创建",
+    "providerUpdated": "模型供应商已更新",
+    "providerDeleted": "模型供应商已删除",
+    "availableModels": "可用模型",
+    "modelId": "模型 ID",
+    "displayName": "显示名称",
+    "addModel": "添加模型",
+    "removeModel": "移除",
+    "extraConfig": "额外配置 (JSON)",
+    "noModels": "暂无配置模型",
+    "editProvider": "编辑供应商",
+    "createProvider": "创建供应商",
+    "openai": "OpenAI",
+    "anthropic": "Anthropic",
+    "deepseek": "DeepSeek",
+    "azure_openai": "Azure OpenAI",
+    "ollama": "Ollama",
+    "custom": "自定义",
+    "active": "活跃",
+    "inactive": "未激活",
+    "error": "错误",
+    "toggleStatus": "切换状态",
+    "masked": "***已隐藏",
+    "discoverModels": "自动发现",
+    "discovering": "发现中...",
+    "discovered": "已发现 {{count}} 个模型",
+    "discoverFailed": "模型发现失败",
+    "selectModels": "选择要添加的模型",
+    "selectAll": "全选",
+    "deselectAll": "取消全选",
+    "applySelected": "应用({{count}})",
+    "noModelsDiscovered": "未找到模型,请检查 URL 和 API 密钥。",
+    "ownedBy": "来自 {{owner}}",
+    "contextWindow": "{{tokens}} tokens",
+    "totalProviders": "供应商总数",
+    "activeProviders": "活跃供应商",
+    "totalModels": "模型总数",
+    "providers": "供应商列表",
+    "searchProviders": "搜索供应商...",
+    "discoveredModels": "已发现模型",
+    "allTypes": "全部类型",
+    "type_chat": "对话",
+    "type_reasoning": "推理",
+    "type_embedding": "向量",
+    "type_image": "图像",
+    "type_audio": "音频",
+    "type_video": "视频",
+    "type_rerank": "重排",
+    "type_moderation": "审核",
+    "type_other": "其他",
+    "filterByType": "按类型筛选"
+  },
+  "skills": {
+    "title": "技能",
+    "description": "定义技能包,告诉 AI 如何使用工具完成业务任务。",
+    "new": "新建技能",
+    "search": "搜索技能...",
+    "empty": "暂无技能",
+    "emptyHint": "创建一个技能来定义 AI 如何使用工具。",
+    "active": "活跃",
+    "draft": "草稿",
+    "deleted": "技能已删除",
+    "saved": "技能已保存",
+    "created": "技能已创建",
+    "instruction": "使用说明",
+    "instructionHint": "逐步告诉 AI 如何使用已绑定工具。",
+    "instructionPlaceholder": "当客户询问订单时:\n1. 使用 lookup_order 查询订单\n2. 检查状态\n3. 提供回复",
+    "noInstruction": "暂无使用说明,点击编辑添加。",
+    "tools": "个工具",
+    "toolsHint": "点击切换工具绑定。",
+    "toolsCount": "已绑定工具",
+    "selectTools": "选择工具",
+    "allCategories": "全部分类",
+    "category": "分类",
+    "namePlaceholder": "例如:订单状态查询",
+    "descPlaceholder": "简短描述...",
+    "info": "信息",
+    "catService": "服务",
+    "catAnalytics": "分析",
+    "catDevelopment": "开发",
+    "catProcessing": "处理"
+  },
+  "errors": {
+    "failedToLoad": "加载失败",
+    "failedToCreate": "创建失败",
+    "failedToUpdate": "更新失败",
+    "failedToDelete": "删除失败",
+    "failedToSave": "保存失败"
+  }
+}

+ 1 - 0
web/src/main.tsx

@@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client";
 import { BrowserRouter } from "react-router-dom";
 import App from "./App";
 import "./index.css";
+import "./i18n";
 
 try {
   const persistedUi = localStorage.getItem("auto-platform-ui");

+ 96 - 393
web/src/pages/agents/AgentListPage.tsx

@@ -1,25 +1,21 @@
 import * as React from "react";
+import { useTranslation } from "react-i18next";
 import {
   Activity,
   Archive,
   Bot,
   CheckCircle2,
-  Clock,
   Copy,
   FileCode2,
-  Grid2X2,
-  List,
   Play,
   RefreshCw,
   Search,
   SlidersHorizontal,
-  TerminalSquare,
 } from "lucide-react";
 import { listAgentRuns, listAgentVersions } from "@/api";
 import { ApiErrorState } from "@/components/shared/ApiErrorState";
 import { EmptyState } from "@/components/shared/EmptyState";
 import { EntityListItem } from "@/components/shared/EntityListItem";
-import { JsonViewer } from "@/components/shared/JsonViewer";
 import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
 import { MetricCard } from "@/components/shared/MetricCard";
 import { PageHeader } from "@/components/shared/PageHeader";
@@ -30,45 +26,44 @@ import { Button } from "@/components/ui/button";
 import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
 import { Select } from "@/components/ui/select";
 import { Tabs } from "@/components/ui/tabs";
+import { toast } from "@/components/ui/toaster";
 import { useAgentList } from "@/hooks";
-import { copyToClipboard, formatDateTime, relativeTime, truncateMiddle } from "@/lib/utils";
-import type { AgentDefinition, AgentRun, AgentRunStatus, AgentStatus, AgentVersion } from "@/types";
-import { AgentCard } from "./components/AgentCard";
+import { copyToClipboard } from "@/lib/utils";
+import type { AgentRun, AgentRunStatus, AgentStatus, AgentVersion } from "@/types";
+import { AgentOverview } from "./components/AgentOverview";
+import { AgentRuns } from "./components/AgentRuns";
+import { AgentVersions } from "./components/AgentVersions";
 import { CreateAgentDialog } from "./components/CreateAgentDialog";
 
 type StatusFilter = "all" | AgentStatus;
 type RunStatusFilter = "all" | AgentRunStatus;
-type ViewMode = "list" | "grid";
 type SortMode = "recent" | "name" | "status";
 
 export function AgentListPage() {
+  const { t } = useTranslation();
   const [search, setSearch] = React.useState("");
   const [statusFilter, setStatusFilter] = React.useState<StatusFilter>("all");
   const [typeFilter, setTypeFilter] = React.useState("all");
-  const [runStatusFilter, setRunStatusFilter] = React.useState<RunStatusFilter>("all");
   const [sortMode, setSortMode] = React.useState<SortMode>("recent");
-  const [viewMode, setViewMode] = React.useState<ViewMode>("list");
+  const [runStatusFilter, setRunStatusFilter] = React.useState<RunStatusFilter>("all");
   const [selectedAgentId, setSelectedAgentId] = React.useState<string>();
   const [activeTab, setActiveTab] = React.useState("overview");
   const [versions, setVersions] = React.useState<AgentVersion[]>([]);
   const [runs, setRuns] = React.useState<AgentRun[]>([]);
   const [relatedLoading, setRelatedLoading] = React.useState(true);
-  const [testInput, setTestInput] = React.useState("Summarize the last customer request and recommend the next action.");
-  const [testResult, setTestResult] = React.useState<AgentRun | undefined>();
   const agents = useAgentList();
 
   const agentList = agents.data ?? [];
   const selectedAgent = agentList.find((agent) => agent.id === selectedAgentId) ?? agentList[0];
-  const agentTypes = Array.from(new Set(agentList.map((agent) => agent.agent_type))).sort();
-  const versionCounts = React.useMemo(() => countBy(versions, (version) => version.agent_id), [versions]);
-  const selectedVersions = versions.filter((version) => version.agent_id === selectedAgent?.id);
-  const selectedRuns = runs.filter((run) => run.agent_id === selectedAgent?.id);
+  const agentTypes = React.useMemo(() => Array.from(new Set(agentList.map((a) => a.agent_type))).sort(), [agentList]);
+  const versionCounts = React.useMemo(() => countBy(versions, (v) => v.agent_id), [versions]);
+  const selectedVersions = versions.filter((v) => v.agent_id === selectedAgent?.id);
+  const selectedRuns = runs.filter((r) => r.agent_id === selectedAgent?.id);
   const latestVersion = [...selectedVersions].sort((a, b) => b.version_no - a.version_no)[0];
-  const filteredRuns = selectedRuns.filter((run) => runStatusFilter === "all" || run.status === runStatusFilter);
-  const failedRuns = runs.filter((run) => run.status === "failed").length;
-  const activeAgents = agentList.filter((agent) => agent.status === "active").length;
-  const draftAgents = agentList.filter((agent) => agent.status === "draft").length;
-  const archivedAgents = agentList.filter((agent) => agent.status === "archived").length;
+  const failedRuns = runs.filter((r) => r.status === "failed").length;
+  const activeAgents = agentList.filter((a) => a.status === "active").length;
+  const draftAgents = agentList.filter((a) => a.status === "draft").length;
+  const archivedAgents = agentList.filter((a) => a.status === "archived").length;
 
   const filtered = agentList
     .filter((agent) => {
@@ -84,6 +79,8 @@ export function AgentListPage() {
       return new Date(second.created_time).getTime() - new Date(first.created_time).getTime();
     });
 
+  const hasFilters = search.length > 0 || statusFilter !== "all" || typeFilter !== "all" || sortMode !== "recent";
+
   const loadRelated = React.useCallback(async () => {
     setRelatedLoading(true);
     try {
@@ -95,207 +92,147 @@ export function AgentListPage() {
     }
   }, []);
 
-  React.useEffect(() => {
-    void loadRelated();
-  }, [loadRelated]);
+  React.useEffect(() => { void loadRelated(); }, [loadRelated]);
+  React.useEffect(() => { if (!selectedAgentId && agentList[0]) setSelectedAgentId(agentList[0].id); }, [agentList, selectedAgentId]);
 
-  React.useEffect(() => {
-    if (!selectedAgentId && agentList[0]) {
-      setSelectedAgentId(agentList[0].id);
-    }
-  }, [agentList, selectedAgentId]);
-
-  function simulateRun() {
-    if (!selectedAgent || !latestVersion) return;
-    const timestamp = new Date().toISOString();
-    setTestResult({
-      id: `local_${Date.now().toString(36)}`,
-      tenant_id: selectedAgent.tenant_id,
-      agent_id: selectedAgent.id,
-      agent_version_id: latestVersion.id,
-      session_id: null,
-      input_text: testInput,
-      input_json: null,
-      output_text: `Simulated response from ${selectedAgent.name}. The selected version is v${latestVersion.version_no}.`,
-      output_json: { simulated: true, agent_code: selectedAgent.code, version: latestVersion.version_no },
-      status: "completed",
-      worker_key: "frontend-simulator",
-      queued_time: timestamp,
-      lease_expire_time: null,
-      started_time: timestamp,
-      finished_time: timestamp,
-      error_code: null,
-      error_message: null,
-      created_time: timestamp,
-    });
+  function clearFilters() {
+    setSearch("");
+    setStatusFilter("all");
+    setTypeFilter("all");
+    setSortMode("recent");
   }
 
   async function copyAgentCode() {
     if (!selectedAgent) return;
     await copyToClipboard(selectedAgent.code);
+    toast.success(t("agents.codeCopied"));
   }
 
-  if (agents.loading) return <LoadingSpinner label="Loading agents" />;
+  if (agents.loading) return <LoadingSpinner label={t("common.loading")} />;
   if (agents.error) return <ApiErrorState message={agents.error.message} onRetry={() => void agents.refetch()} />;
 
   return (
     <div className="space-y-6">
       <PageHeader
-        title="Agents"
-        description="Design, inspect, and operate agent definitions, versions, prompts, and runs from one workspace."
+        title={t("agents.title")}
+        description={t("agents.description")}
         actions={
           <>
-            <Button
-              variant="outline"
-              onClick={() => {
-                void agents.refetch();
-                void loadRelated();
-              }}
-            >
-              <RefreshCw className="h-4 w-4" /> Refresh
+            <Button variant="outline" onClick={() => { void agents.refetch(); void loadRelated(); }}>
+              <RefreshCw className="h-4 w-4" /> {t("common.refresh")}
             </Button>
             <CreateAgentDialog onCreated={() => void agents.refetch()} />
           </>
         }
       />
 
+      {/* Metric cards */}
       <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-6">
-        <MetricCard label="Agents" value={agentList.length} icon={Bot} />
-        <MetricCard label="Active" value={activeAgents} icon={CheckCircle2} />
-        <MetricCard label="Draft" value={draftAgents} icon={FileCode2} />
-        <MetricCard label="Archived" value={archivedAgents} icon={Archive} />
-        <MetricCard label="Versions" value={versions.length} icon={Copy} />
-        <MetricCard label="Failed Runs" value={failedRuns} icon={Activity} />
+        <MetricCard label={t("agents.title")} value={agentList.length} icon={Bot} />
+        <MetricCard label={t("common.active")} value={activeAgents} icon={CheckCircle2} />
+        <MetricCard label={t("common.draft")} value={draftAgents} icon={FileCode2} />
+        <MetricCard label={t("common.archived")} value={archivedAgents} icon={Archive} />
+        <MetricCard label={t("common.versions")} value={versions.length} icon={Copy} />
+        <MetricCard label={t("agents.failedRuns")} value={failedRuns} icon={Activity} />
       </div>
 
       <div className="grid gap-6 xl:grid-cols-[440px_1fr]">
+        {/* Left panel: agent directory */}
         <Card>
           <CardHeader>
             <div className="flex items-start justify-between gap-3">
               <div>
-                <CardTitle>Agent Directory</CardTitle>
+                <CardTitle>{t("agents.agentDirectory")}</CardTitle>
                 <CardDescription>
-                  {filtered.length} of {agentList.length} agents shown
+                  {t("agents.agentsShown", { count: agentList.length })} {filtered.length}
                 </CardDescription>
               </div>
               <SlidersHorizontal className="mt-1 h-4 w-4 text-muted-foreground" />
             </div>
           </CardHeader>
           <CardContent className="space-y-4">
-            <SearchInput value={search} onChange={setSearch} placeholder="Search by name, code, type, or description" />
+            <SearchInput value={search} onChange={setSearch} placeholder={t("agents.searchByNameCodeType")} />
             <div className="grid gap-3 sm:grid-cols-2">
               <Select
-                aria-label="Filter by status"
+                aria-label={t("common.filterByStatus")}
                 value={statusFilter}
                 onChange={(event) => setStatusFilter(event.target.value as StatusFilter)}
                 options={[
-                  { value: "all", label: "All statuses" },
-                  { value: "active", label: "Active" },
-                  { value: "draft", label: "Draft" },
-                  { value: "archived", label: "Archived" },
+                  { value: "all", label: t("agents.allStatuses") },
+                  { value: "active", label: t("common.active") },
+                  { value: "draft", label: t("common.draft") },
+                  { value: "archived", label: t("common.archived") },
                 ]}
               />
               <Select
-                aria-label="Filter by agent type"
+                aria-label={t("common.filterByAgentType")}
                 value={typeFilter}
                 onChange={(event) => setTypeFilter(event.target.value)}
-                options={[{ value: "all", label: "All types" }, ...agentTypes.map((type) => ({ value: type, label: type }))]}
+                options={[{ value: "all", label: t("agents.allTypes") }, ...agentTypes.map((type) => ({ value: type, label: type }))]}
               />
               <Select
-                aria-label="Sort agents"
+                aria-label={t("common.sortAgents")}
                 value={sortMode}
                 onChange={(event) => setSortMode(event.target.value as SortMode)}
                 options={[
-                  { value: "recent", label: "Newest first" },
-                  { value: "name", label: "Name" },
-                  { value: "status", label: "Status" },
+                  { value: "recent", label: t("agents.newestFirst") },
+                  { value: "name", label: t("common.name") },
+                  { value: "status", label: t("common.status") },
                 ]}
               />
-              <div className="grid grid-cols-2 gap-2">
-                <Button
-                  type="button"
-                  variant={viewMode === "list" ? "secondary" : "outline"}
-                  aria-label="List view"
-                  onClick={() => setViewMode("list")}
-                >
-                  <List className="h-4 w-4" /> List
-                </Button>
-                <Button
-                  type="button"
-                  variant={viewMode === "grid" ? "secondary" : "outline"}
-                  aria-label="Grid view"
-                  onClick={() => setViewMode("grid")}
-                >
-                  <Grid2X2 className="h-4 w-4" /> Grid
-                </Button>
-              </div>
             </div>
+            {hasFilters ? (
+              <Button type="button" variant="ghost" size="sm" onClick={clearFilters}>
+                {t("common.clearFilters")}
+              </Button>
+            ) : null}
 
             {filtered.length ? (
-              viewMode === "list" ? (
-                <div className="space-y-2">
-                  {filtered.map((agent) => (
-                    <EntityListItem
-                      key={agent.id}
-                      active={agent.id === selectedAgent?.id}
-                      title={agent.name}
-                      subtitle={`${agent.code} · ${agent.agent_type}`}
-                      meta={
-                        <div className="flex items-center gap-2">
-                          <Badge className="border-border bg-muted/60 text-muted-foreground">{versionCounts.get(agent.id) ?? 0}v</Badge>
-                          <StatusBadge status={agent.status} />
-                        </div>
-                      }
-                      onClick={() => {
-                        setSelectedAgentId(agent.id);
-                        setActiveTab("overview");
-                      }}
-                    />
-                  ))}
-                </div>
-              ) : (
-                <div className="grid gap-4 sm:grid-cols-2">
-                  {filtered.map((agent) => (
-                    <AgentCard
-                      key={agent.id}
-                      agent={agent}
-                      onOpen={() => {
-                        setSelectedAgentId(agent.id);
-                        setActiveTab("overview");
-                      }}
-                    />
-                  ))}
-                </div>
-              )
+              <div className="space-y-2">
+                {filtered.map((agent) => (
+                  <EntityListItem
+                    key={agent.id}
+                    active={agent.id === selectedAgent?.id}
+                    title={agent.name}
+                    subtitle={`${agent.code} · ${agent.agent_type}`}
+                    meta={
+                      <div className="flex items-center gap-2">
+                        <Badge className="border-border bg-muted/60 text-muted-foreground">{versionCounts.get(agent.id) ?? 0}v</Badge>
+                        <StatusBadge status={agent.status} />
+                      </div>
+                    }
+                    onClick={() => { setSelectedAgentId(agent.id); setActiveTab("overview"); }}
+                  />
+                ))}
+              </div>
             ) : (
-              <EmptyState
-                icon={Search}
-                title="No matching agents"
-                description="Adjust search or filters to find a matching agent definition."
-              />
+              <EmptyState icon={Search} title={t("agents.noMatchingAgents")} description={t("agents.adjustFiltersAgent")} />
             )}
           </CardContent>
         </Card>
 
+        {/* Right panel: agent details */}
         <Card>
           <CardHeader>
             <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
               <div className="min-w-0">
                 <div className="flex flex-wrap items-center gap-2">
-                  <CardTitle className="truncate text-lg">{selectedAgent?.name ?? "Agent Details"}</CardTitle>
+                  <CardTitle className="truncate text-lg">{selectedAgent?.name ?? t("agents.agentDetails")}</CardTitle>
                   {selectedAgent ? <StatusBadge status={selectedAgent.status} /> : null}
                 </div>
                 <CardDescription className="mt-1">
-                  {selectedAgent ? `${selectedAgent.agent_type} · ${selectedAgent.code}` : "Select an agent to inspect its operating surface."}
+                  {selectedAgent ? `${selectedAgent.agent_type} · ${selectedAgent.code}` : t("agents.selectAgent")}
                 </CardDescription>
               </div>
               <div className="flex flex-wrap items-center gap-2">
-                <Button variant="outline" disabled={!selectedAgent} onClick={() => void copyAgentCode()}>
-                  <Copy className="h-4 w-4" /> Copy Code
-                </Button>
-                <Button variant="secondary" disabled>
-                  <FileCode2 className="h-4 w-4" /> New Version
+                <Button variant="outline" size="sm" disabled={!selectedAgent} onClick={() => void copyAgentCode()}>
+                  <Copy className="h-4 w-4" /> {t("agents.copyCode")}
                 </Button>
+                {selectedAgent && latestVersion ? (
+                  <Button size="sm" onClick={() => setActiveTab("runs")}>
+                    <Play className="h-4 w-4" /> {t("agents.testConsole")}
+                  </Button>
+                ) : null}
               </div>
             </div>
           </CardHeader>
@@ -307,28 +244,24 @@ export function AgentListPage() {
                 tabs={[
                   {
                     value: "overview",
-                    label: "Overview",
+                    label: t("common.overview"),
                     content: (
                       <AgentOverview
                         agent={selectedAgent}
                         latestVersion={latestVersion}
                         versionCount={selectedVersions.length}
                         runCount={selectedRuns.length}
-                        failedRunCount={selectedRuns.filter((run) => run.status === "failed").length}
+                        failedRunCount={selectedRuns.filter((r) => r.status === "failed").length}
                       />
                     ),
                   },
-                  {
-                    value: "versions",
-                    label: "Versions",
-                    content: <AgentVersions versions={selectedVersions} loading={relatedLoading} />,
-                  },
                   {
                     value: "runs",
-                    label: "Runs",
+                    label: t("common.runs"),
                     content: (
                       <AgentRuns
-                        runs={filteredRuns}
+                        agentId={selectedAgent.id}
+                        runs={selectedRuns}
                         loading={relatedLoading}
                         statusFilter={runStatusFilter}
                         onStatusFilterChange={setRunStatusFilter}
@@ -336,27 +269,14 @@ export function AgentListPage() {
                     ),
                   },
                   {
-                    value: "config",
-                    label: "Prompt & Config",
-                    content: <AgentConfig version={latestVersion} />,
-                  },
-                  {
-                    value: "test",
-                    label: "Test Console",
-                    content: (
-                      <AgentTestConsole
-                        disabled={!latestVersion}
-                        input={testInput}
-                        result={testResult}
-                        onInputChange={setTestInput}
-                        onRun={simulateRun}
-                      />
-                    ),
+                    value: "versions",
+                    label: t("common.versions"),
+                    content: <AgentVersions versions={selectedVersions} loading={relatedLoading} />,
                   },
                 ]}
               />
             ) : (
-              <EmptyState icon={Bot} title="No agents" description="Create an agent to start building definitions and versions." />
+              <EmptyState icon={Bot} title={t("agents.noAgents")} description={t("agents.createAgentStart")} />
             )}
           </CardContent>
         </Card>
@@ -365,217 +285,6 @@ export function AgentListPage() {
   );
 }
 
-function AgentOverview({
-  agent,
-  latestVersion,
-  versionCount,
-  runCount,
-  failedRunCount,
-}: {
-  agent: AgentDefinition;
-  latestVersion?: AgentVersion;
-  versionCount: number;
-  runCount: number;
-  failedRunCount: number;
-}) {
-  return (
-    <div className="space-y-5">
-      <div className="grid gap-4 md:grid-cols-4">
-        <SummaryTile label="Versions" value={versionCount} />
-        <SummaryTile label="Runs" value={runCount} />
-        <SummaryTile label="Failures" value={failedRunCount} />
-        <SummaryTile label="Latest" value={latestVersion ? `v${latestVersion.version_no}` : "None"} />
-      </div>
-      <div className="grid gap-4 text-sm md:grid-cols-2">
-        <Detail label="Code" value={agent.code} mono />
-        <Detail label="Type" value={agent.agent_type} />
-        <Detail label="Owner" value={agent.owner_user_id ?? "Unassigned"} mono />
-        <Detail label="Created" value={formatDateTime(agent.created_time)} />
-        <div className="md:col-span-2">
-          <p className="text-muted-foreground">Description</p>
-          <p className="mt-1 leading-6">{agent.description ?? "No description provided."}</p>
-        </div>
-      </div>
-    </div>
-  );
-}
-
-function AgentVersions({ versions, loading }: { versions: AgentVersion[]; loading: boolean }) {
-  if (loading) return <LoadingSpinner label="Loading versions" />;
-  if (!versions.length) return <EmptyState icon={FileCode2} title="No versions" description="Create a version to define role, goal, prompt, tools, and runtime config." />;
-  return (
-    <div className="space-y-3">
-      {versions
-        .slice()
-        .sort((first, second) => second.version_no - first.version_no)
-        .map((version) => (
-          <div key={version.id} className="rounded-md border border-border bg-muted/30 p-4">
-            <div className="flex flex-wrap items-start justify-between gap-3">
-              <div>
-                <div className="flex flex-wrap items-center gap-2">
-                  <p className="font-medium">v{version.version_no}</p>
-                  <StatusBadge status={version.status} />
-                </div>
-                <p className="mt-1 text-sm text-muted-foreground">{version.goal ?? "No goal provided."}</p>
-              </div>
-              <p className="text-xs text-muted-foreground">{formatDateTime(version.created_time)}</p>
-            </div>
-            <div className="mt-4 grid gap-3 text-sm md:grid-cols-3">
-              <Detail label="Role" value={version.role} />
-              <Detail label="Model" value={stringifyConfigValue(version.model_config_json.model) ?? "Not set"} mono />
-              <Detail label="Published" value={version.published_time ? formatDateTime(version.published_time) : "Not published"} />
-            </div>
-          </div>
-        ))}
-    </div>
-  );
-}
-
-function AgentRuns({
-  runs,
-  loading,
-  statusFilter,
-  onStatusFilterChange,
-}: {
-  runs: AgentRun[];
-  loading: boolean;
-  statusFilter: RunStatusFilter;
-  onStatusFilterChange: (status: RunStatusFilter) => void;
-}) {
-  if (loading) return <LoadingSpinner label="Loading runs" />;
-  return (
-    <div className="space-y-4">
-      <div className="max-w-xs">
-        <Select
-          aria-label="Filter runs by status"
-          value={statusFilter}
-          onChange={(event) => onStatusFilterChange(event.target.value as RunStatusFilter)}
-          options={[
-            { value: "all", label: "All run statuses" },
-            { value: "queued", label: "Queued" },
-            { value: "running", label: "Running" },
-            { value: "completed", label: "Completed" },
-            { value: "failed", label: "Failed" },
-            { value: "cancelled", label: "Cancelled" },
-          ]}
-        />
-      </div>
-      {runs.length ? (
-        <div className="space-y-2">
-          {runs.map((run) => (
-            <div key={run.id} className="rounded-md border border-border bg-muted/30 p-4">
-              <div className="flex flex-wrap items-center justify-between gap-3">
-                <div className="min-w-0">
-                  <p className="truncate font-mono text-xs">{truncateMiddle(run.id, 28)}</p>
-                  <p className="mt-1 truncate text-sm text-muted-foreground">{run.input_text ?? "Structured input payload"}</p>
-                </div>
-                <StatusBadge status={run.status} />
-              </div>
-              <div className="mt-3 grid gap-3 text-sm md:grid-cols-3">
-                <Detail label="Created" value={relativeTime(run.created_time)} />
-                <Detail label="Worker" value={run.worker_key ?? "Unassigned"} mono />
-                <Detail label="Output" value={run.output_text ?? run.error_message ?? "No output yet"} />
-              </div>
-            </div>
-          ))}
-        </div>
-      ) : (
-        <EmptyState icon={Activity} title="No runs" description="No run records match the current agent and status filter." />
-      )}
-    </div>
-  );
-}
-
-function AgentConfig({ version }: { version?: AgentVersion }) {
-  if (!version) {
-    return <EmptyState icon={TerminalSquare} title="No config" description="Publish or draft a version before inspecting prompt and runtime configuration." />;
-  }
-  return (
-    <div className="space-y-5">
-      <section className="space-y-2">
-        <div className="flex items-center gap-2">
-          <TerminalSquare className="h-4 w-4 text-muted-foreground" />
-          <h3 className="text-sm font-semibold">System Prompt</h3>
-        </div>
-        <div className="rounded-md border border-border bg-muted/30 p-4 text-sm leading-6">{version.system_prompt}</div>
-      </section>
-      <div className="grid gap-4 lg:grid-cols-2">
-        <ConfigBlock title="Model Config" value={version.model_config_json} />
-        <ConfigBlock title="Memory Policy" value={version.memory_policy_json} />
-        <ConfigBlock title="Tool References" value={version.tool_refs_json} />
-        <ConfigBlock title="Skill References" value={version.skill_refs_json} />
-      </div>
-    </div>
-  );
-}
-
-function AgentTestConsole({
-  disabled,
-  input,
-  result,
-  onInputChange,
-  onRun,
-}: {
-  disabled: boolean;
-  input: string;
-  result?: AgentRun;
-  onInputChange: (value: string) => void;
-  onRun: () => void;
-}) {
-  return (
-    <div className="grid gap-4 lg:grid-cols-[1fr_360px]">
-      <div className="space-y-3">
-        <label className="block space-y-2 text-sm">
-          <span className="text-muted-foreground">Test input</span>
-          <textarea
-            className="min-h-36 w-full rounded-md border border-border bg-surface-elevated px-3 py-2 text-base text-foreground outline-none focus:border-primary/70 focus:ring-2 focus:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-50 sm:text-sm"
-            value={input}
-            onChange={(event) => onInputChange(event.target.value)}
-            disabled={disabled}
-          />
-        </label>
-        <Button onClick={onRun} disabled={disabled || !input.trim()}>
-          <Play className="h-4 w-4" /> Simulate Run
-        </Button>
-      </div>
-      <div className="rounded-md border border-border bg-muted/30 p-4">
-        <div className="mb-3 flex items-center gap-2">
-          <Clock className="h-4 w-4 text-muted-foreground" />
-          <h3 className="text-sm font-semibold">Result Preview</h3>
-        </div>
-        {result ? <JsonViewer value={result} collapsed={false} /> : <p className="text-sm text-muted-foreground">Run a local simulation to preview the output contract.</p>}
-      </div>
-    </div>
-  );
-}
-
-function SummaryTile({ label, value }: { label: string; value: string | number }) {
-  return (
-    <div className="rounded-md border border-border bg-muted/30 p-3">
-      <p className="text-xs text-muted-foreground">{label}</p>
-      <p className="mt-1 text-xl font-semibold tabular-nums">{value}</p>
-    </div>
-  );
-}
-
-function Detail({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
-  return (
-    <div className="min-w-0">
-      <p className="text-muted-foreground">{label}</p>
-      <p className={mono ? "mt-1 truncate font-mono text-xs" : "mt-1 truncate"}>{value}</p>
-    </div>
-  );
-}
-
-function ConfigBlock({ title, value }: { title: string; value: unknown }) {
-  return (
-    <section className="min-w-0 rounded-md border border-border bg-muted/30 p-4">
-      <h3 className="mb-3 text-sm font-semibold">{title}</h3>
-      <JsonViewer value={value} />
-    </section>
-  );
-}
-
 function countBy<T>(items: T[], getKey: (item: T) => string) {
   const counts = new Map<string, number>();
   for (const item of items) {
@@ -584,9 +293,3 @@ function countBy<T>(items: T[], getKey: (item: T) => string) {
   }
   return counts;
 }
-
-function stringifyConfigValue(value: unknown) {
-  if (typeof value === "string") return value;
-  if (typeof value === "number" || typeof value === "boolean") return String(value);
-  return undefined;
-}

+ 93 - 0
web/src/pages/agents/components/AgentOverview.tsx

@@ -0,0 +1,93 @@
+import { useTranslation } from "react-i18next";
+import { TerminalSquare } from "lucide-react";
+import { EmptyState } from "@/components/shared/EmptyState";
+import { JsonViewer } from "@/components/shared/JsonViewer";
+import { formatDateTime } from "@/lib/utils";
+import type { AgentDefinition, AgentVersion } from "@/types";
+
+export function AgentOverview({
+  agent,
+  latestVersion,
+  versionCount,
+  runCount,
+  failedRunCount,
+}: {
+  agent: AgentDefinition;
+  latestVersion?: AgentVersion;
+  versionCount: number;
+  runCount: number;
+  failedRunCount: number;
+}) {
+  const { t } = useTranslation();
+  return (
+    <div className="space-y-6">
+      {/* Summary tiles */}
+      <div className="grid gap-4 md:grid-cols-4">
+        <SummaryTile label={t("common.versions")} value={versionCount} />
+        <SummaryTile label={t("common.runs")} value={runCount} />
+        <SummaryTile label={t("agents.failures")} value={failedRunCount} />
+        <SummaryTile label={t("agents.latest")} value={latestVersion ? `v${latestVersion.version_no}` : t("agents.none")} />
+      </div>
+
+      {/* Agent details */}
+      <div className="grid gap-4 text-sm md:grid-cols-2">
+        <Detail label={t("common.code")} value={agent.code} mono />
+        <Detail label={t("common.type")} value={agent.agent_type} />
+        <Detail label={t("agents.owner")} value={agent.owner_user_id ?? t("agents.unassigned")} mono />
+        <Detail label={t("common.created")} value={formatDateTime(agent.created_time)} />
+        <div className="md:col-span-2">
+          <p className="text-muted-foreground">{t("common.description")}</p>
+          <p className="mt-1 leading-6">{agent.description ?? t("agents.noDescription")}</p>
+        </div>
+      </div>
+
+      {/* Config section (merged from Config tab) */}
+      {latestVersion ? (
+        <div className="space-y-4">
+          <section className="space-y-2">
+            <div className="flex items-center gap-2">
+              <TerminalSquare className="h-4 w-4 text-muted-foreground" />
+              <h3 className="text-sm font-semibold">{t("agents.systemPrompt")}</h3>
+            </div>
+            <div className="rounded-md border border-border bg-muted/30 p-4 text-sm leading-6">{latestVersion.system_prompt}</div>
+          </section>
+          <div className="grid gap-4 lg:grid-cols-2">
+            <ConfigBlock title={t("agents.modelConfig")} value={latestVersion.model_config_json} />
+            <ConfigBlock title={t("agents.memoryPolicy")} value={latestVersion.memory_policy_json} />
+            <ConfigBlock title={t("agents.toolReferences")} value={latestVersion.tool_refs_json} />
+            <ConfigBlock title={t("agents.skillReferences")} value={latestVersion.skill_refs_json} />
+          </div>
+        </div>
+      ) : (
+        <EmptyState icon={TerminalSquare} title={t("agents.noConfig")} description={t("agents.publishOrDraft")} />
+      )}
+    </div>
+  );
+}
+
+function SummaryTile({ label, value }: { label: string; value: string | number }) {
+  return (
+    <div className="rounded-md border border-border bg-muted/30 p-3">
+      <p className="text-xs text-muted-foreground">{label}</p>
+      <p className="mt-1 text-xl font-semibold tabular-nums">{value}</p>
+    </div>
+  );
+}
+
+function Detail({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
+  return (
+    <div className="min-w-0">
+      <p className="text-muted-foreground">{label}</p>
+      <p className={mono ? "mt-1 truncate font-mono text-xs" : "mt-1 truncate"}>{value}</p>
+    </div>
+  );
+}
+
+function ConfigBlock({ title, value }: { title: string; value: unknown }) {
+  return (
+    <section className="min-w-0 rounded-md border border-border bg-muted/30 p-4">
+      <h3 className="mb-3 text-sm font-semibold">{title}</h3>
+      <JsonViewer value={value} />
+    </section>
+  );
+}

+ 138 - 0
web/src/pages/agents/components/AgentRuns.tsx

@@ -0,0 +1,138 @@
+import * as React from "react";
+import { useTranslation } from "react-i18next";
+import { Activity, Clock, Play } from "lucide-react";
+import { EmptyState } from "@/components/shared/EmptyState";
+import { JsonViewer } from "@/components/shared/JsonViewer";
+import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
+import { StatusBadge } from "@/components/shared/StatusBadge";
+import { Button } from "@/components/ui/button";
+import { Select } from "@/components/ui/select";
+import type { AgentRun, AgentRunStatus } from "@/types";
+import { relativeTime, truncateMiddle } from "@/lib/utils";
+
+type RunStatusFilter = "all" | AgentRunStatus;
+
+export function AgentRuns({
+  agentId,
+  runs,
+  loading,
+  statusFilter,
+  onStatusFilterChange,
+}: {
+  agentId: string;
+  runs: AgentRun[];
+  loading: boolean;
+  statusFilter: RunStatusFilter;
+  onStatusFilterChange: (status: RunStatusFilter) => void;
+}) {
+  const { t } = useTranslation();
+  const [testInput, setTestInput] = React.useState("");
+  const [testResult, setTestResult] = React.useState<AgentRun | undefined>();
+
+  function simulateRun() {
+    const timestamp = new Date().toISOString();
+    setTestResult({
+      id: `local_${Date.now().toString(36)}`,
+      agent_id: agentId,
+      agent_version_id: "",
+      session_id: null,
+      input_text: testInput,
+      input_json: null,
+      output_text: `Simulated response. Input: ${testInput.slice(0, 100)}`,
+      output_json: { simulated: true },
+      status: "completed",
+      worker_key: "frontend-simulator",
+      queued_time: timestamp,
+      lease_expire_time: null,
+      started_time: timestamp,
+      finished_time: timestamp,
+      error_code: null,
+      error_message: null,
+      created_time: timestamp,
+    });
+  }
+
+  if (loading) return <LoadingSpinner label={t("common.loading")} />;
+
+  const filteredRuns = runs.filter((run) => statusFilter === "all" || run.status === statusFilter);
+
+  return (
+    <div className="space-y-6">
+      {/* Inline test console */}
+      <div className="grid gap-4 lg:grid-cols-[1fr_360px]">
+        <div className="space-y-3">
+          <label className="block space-y-2 text-sm">
+            <span className="text-muted-foreground">{t("agents.testInput")}</span>
+            <textarea
+              className="min-h-32 w-full rounded-md border border-border bg-surface-elevated px-3 py-2 text-base text-foreground outline-none focus:border-primary/70 focus:ring-2 focus:ring-primary/20 sm:text-sm"
+              value={testInput}
+              onChange={(event) => setTestInput(event.target.value)}
+            />
+          </label>
+          <Button size="sm" onClick={simulateRun} disabled={!testInput.trim()}>
+            <Play className="h-4 w-4" /> {t("agents.simulateRun")}
+          </Button>
+        </div>
+        <div className="rounded-md border border-border bg-muted/30 p-4">
+          <div className="mb-3 flex items-center gap-2">
+            <Clock className="h-4 w-4 text-muted-foreground" />
+            <h3 className="text-sm font-semibold">{t("agents.resultPreview")}</h3>
+          </div>
+          {testResult ? <JsonViewer value={testResult} collapsed={false} /> : <p className="text-sm text-muted-foreground">{t("agents.runSimulation")}</p>}
+        </div>
+      </div>
+
+      {/* Run history */}
+      <div className="space-y-3">
+        <div className="flex items-center justify-between gap-3">
+          <h3 className="text-sm font-semibold">{t("common.runs")}</h3>
+          <Select
+            className="w-48"
+            aria-label={t("common.filterRunsByStatus")}
+            value={statusFilter}
+            onChange={(event) => onStatusFilterChange(event.target.value as RunStatusFilter)}
+            options={[
+              { value: "all", label: t("common.all") },
+              { value: "queued", label: t("common.queued") },
+              { value: "running", label: t("common.running") },
+              { value: "completed", label: t("common.completed") },
+              { value: "failed", label: t("common.failed") },
+              { value: "cancelled", label: t("common.cancelled") },
+            ]}
+          />
+        </div>
+        {filteredRuns.length ? (
+          <div className="space-y-2">
+            {filteredRuns.map((run) => (
+              <div key={run.id} className="rounded-md border border-border bg-muted/30 p-4">
+                <div className="flex flex-wrap items-center justify-between gap-3">
+                  <div className="min-w-0">
+                    <p className="truncate font-mono text-xs">{truncateMiddle(run.id, 28)}</p>
+                    <p className="mt-1 truncate text-sm text-muted-foreground">{run.input_text ?? t("agents.structuredInput")}</p>
+                  </div>
+                  <StatusBadge status={run.status} />
+                </div>
+                <div className="mt-3 grid gap-3 text-sm md:grid-cols-3">
+                  <div className="min-w-0">
+                    <p className="text-muted-foreground">{t("common.created")}</p>
+                    <p className="mt-1">{relativeTime(run.created_time)}</p>
+                  </div>
+                  <div className="min-w-0">
+                    <p className="text-muted-foreground">{t("agents.worker")}</p>
+                    <p className="mt-1 truncate font-mono text-xs">{run.worker_key ?? t("agents.unassigned")}</p>
+                  </div>
+                  <div className="min-w-0">
+                    <p className="text-muted-foreground">{t("agents.output")}</p>
+                    <p className="mt-1 truncate">{run.output_text ?? run.error_message ?? t("agents.noOutputYet")}</p>
+                  </div>
+                </div>
+              </div>
+            ))}
+          </div>
+        ) : (
+          <EmptyState icon={Activity} title={t("agents.noRuns")} description={t("agents.noRunRecords")} />
+        )}
+      </div>
+    </div>
+  );
+}

+ 54 - 0
web/src/pages/agents/components/AgentVersions.tsx

@@ -0,0 +1,54 @@
+import { useTranslation } from "react-i18next";
+import { FileCode2 } from "lucide-react";
+import { EmptyState } from "@/components/shared/EmptyState";
+import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
+import { StatusBadge } from "@/components/shared/StatusBadge";
+import { formatDateTime } from "@/lib/utils";
+import type { AgentVersion } from "@/types";
+
+export function AgentVersions({ versions, loading }: { versions: AgentVersion[]; loading: boolean }) {
+  const { t } = useTranslation();
+  if (loading) return <LoadingSpinner label={t("common.loading")} />;
+  if (!versions.length) return <EmptyState icon={FileCode2} title={t("agents.noVersions")} description={t("agents.createVersionDefine")} />;
+
+  const sorted = [...versions].sort((a, b) => b.version_no - a.version_no);
+
+  return (
+    <div className="space-y-3">
+      {sorted.map((version) => (
+        <div key={version.id} className="rounded-md border border-border bg-muted/30 p-4">
+          <div className="flex flex-wrap items-start justify-between gap-3">
+            <div>
+              <div className="flex flex-wrap items-center gap-2">
+                <p className="font-medium">v{version.version_no}</p>
+                <StatusBadge status={version.status} />
+              </div>
+              <p className="mt-1 text-sm text-muted-foreground">{version.goal ?? t("agents.noDescription")}</p>
+            </div>
+            <p className="text-xs text-muted-foreground">{formatDateTime(version.created_time)}</p>
+          </div>
+          <div className="mt-4 grid gap-3 text-sm md:grid-cols-3">
+            <div className="min-w-0">
+              <p className="text-muted-foreground">{t("agents.role")}</p>
+              <p className="mt-1">{version.role}</p>
+            </div>
+            <div className="min-w-0">
+              <p className="text-muted-foreground">{t("agents.model")}</p>
+              <p className="mt-1 truncate font-mono text-xs">{stringifyConfigValue(version.model_config_json.model) ?? t("agents.noDescription")}</p>
+            </div>
+            <div className="min-w-0">
+              <p className="text-muted-foreground">{t("agents.published")}</p>
+              <p className="mt-1">{version.published_time ? formatDateTime(version.published_time) : t("agents.notPublished")}</p>
+            </div>
+          </div>
+        </div>
+      ))}
+    </div>
+  );
+}
+
+function stringifyConfigValue(value: unknown) {
+  if (typeof value === "string") return value;
+  if (typeof value === "number" || typeof value === "boolean") return String(value);
+  return undefined;
+}

+ 260 - 71
web/src/pages/agents/components/CreateAgentDialog.tsx

@@ -1,6 +1,9 @@
 import * as React from "react";
-import { Bot, Plus } from "lucide-react";
-import { createAgent } from "@/api";
+import { useTranslation } from "react-i18next";
+import { Bot, Plus, Settings2, TerminalSquare, Wrench, Brain } from "lucide-react";
+import { createAgent, createAgentVersion } from "@/api";
+import { listModelProviders } from "@/api/model-providers";
+import { listTools } from "@/api";
 import { Button } from "@/components/ui/button";
 import { Dialog } from "@/components/ui/dialog";
 import { Input, Textarea } from "@/components/ui/input";
@@ -8,23 +11,69 @@ import { Select } from "@/components/ui/select";
 import { toast } from "@/components/ui/toaster";
 import { slugifyName } from "@/lib/utils";
 import { useAuthStore } from "@/stores/auth";
-
-const agentTypes = [
-  { value: "assistant", label: "Assistant" },
-  { value: "planner", label: "Planner" },
-  { value: "executor", label: "Executor" },
-  { value: "research", label: "Research" },
-  { value: "tool_user", label: "Tool User" },
-];
+import type { ModelProvider, ToolDefinition } from "@/types";
 
 export function CreateAgentDialog({ onCreated }: { onCreated: () => void }) {
+  const { t } = useTranslation();
   const [open, setOpen] = React.useState(false);
-  const tenantId = useAuthStore((state) => state.tenantId);
   const userId = useAuthStore((state) => state.userId);
-  const [form, setForm] = React.useState({ name: "", code: "", description: "", agent_type: "assistant" });
   const [codeTouched, setCodeTouched] = React.useState(false);
   const [submitting, setSubmitting] = React.useState(false);
 
+  // Basic info
+  const [form, setForm] = React.useState({ name: "", code: "", description: "", agent_type: "assistant" });
+
+  // Version config
+  const [goal, setGoal] = React.useState("");
+  const [systemPrompt, setSystemPrompt] = React.useState("");
+
+  // Model config
+  const [modelProviders, setModelProviders] = React.useState<ModelProvider[]>([]);
+  const [selectedProviderId, setSelectedProviderId] = React.useState("");
+  const [selectedModel, setSelectedModel] = React.useState("");
+  const [temperature, setTemperature] = React.useState("0.7");
+  const [maxTokens, setMaxTokens] = React.useState("4096");
+
+  // Tools
+  const [availableTools, setAvailableTools] = React.useState<ToolDefinition[]>([]);
+  const [selectedToolCodes, setSelectedToolCodes] = React.useState<string[]>([]);
+
+  // Memory
+  const [memoryEnabled, setMemoryEnabled] = React.useState(true);
+  const [memoryScope, setMemoryScope] = React.useState("session");
+
+  const agentTypes = [
+    { value: "assistant", label: t("agents.typeAssistant") },
+    { value: "planner", label: t("agents.typePlanner") },
+    { value: "executor", label: t("agents.typeExecutor") },
+    { value: "research", label: t("agents.typeResearch") },
+    { value: "tool_user", label: t("agents.typeToolUser") },
+  ];
+
+  // Load providers and tools when dialog opens
+  React.useEffect(() => {
+    if (!open) return;
+    void listModelProviders().then((providers) => {
+      const active = providers.filter((p) => p.status === "active");
+      setModelProviders(active);
+      if (active[0]) {
+        setSelectedProviderId(active[0].id);
+        const chatModels = active[0].models.filter((m) => m.enabled && (m.model_type === "chat" || m.model_type === "reasoning"));
+        if (chatModels[0]) setSelectedModel(chatModels[0].model_id);
+      }
+    });
+    void listTools().then(setAvailableTools).catch(() => {});
+  }, [open]);
+
+  // Available models for selected provider
+  const currentProvider = modelProviders.find((p) => p.id === selectedProviderId);
+  const modelOptions = React.useMemo(() => {
+    if (!currentProvider) return [];
+    return currentProvider.models
+      .filter((m) => m.enabled && (m.model_type === "chat" || m.model_type === "reasoning"))
+      .map((m) => ({ value: m.model_id, label: m.display_name }));
+  }, [currentProvider]);
+
   function updateName(name: string) {
     setForm((current) => ({
       ...current,
@@ -33,25 +82,64 @@ export function CreateAgentDialog({ onCreated }: { onCreated: () => void }) {
     }));
   }
 
+  function toggleTool(code: string) {
+    setSelectedToolCodes((current) =>
+      current.includes(code) ? current.filter((c) => c !== code) : [...current, code],
+    );
+  }
+
+  function reset() {
+    setForm({ name: "", code: "", description: "", agent_type: "assistant" });
+    setGoal("");
+    setSystemPrompt("");
+    setSelectedProviderId("");
+    setSelectedModel("");
+    setTemperature("0.7");
+    setMaxTokens("4096");
+    setSelectedToolCodes([]);
+    setMemoryEnabled(true);
+    setMemoryScope("session");
+    setCodeTouched(false);
+  }
+
   async function submit(event: React.FormEvent) {
     event.preventDefault();
     setSubmitting(true);
     try {
-      await createAgent({
-        tenant_id: tenantId,
+      const agent = await createAgent({
         owner_user_id: userId,
         name: form.name.trim(),
         code: form.code.trim(),
-        description: form.description.trim(),
+        description: form.description.trim() || undefined,
         agent_type: form.agent_type,
       });
-      toast.success("Agent created");
+
+      await createAgentVersion({
+        agent_id: agent.id,
+        role: form.agent_type,
+        goal: goal.trim() || null,
+        system_prompt: systemPrompt,
+        model_config_json: {
+          provider: currentProvider?.provider_type ?? "openai",
+          model: selectedModel,
+          temperature: Number(temperature),
+          max_tokens: Number(maxTokens),
+        },
+        memory_policy_json: {
+          enabled: memoryEnabled,
+          memory_scope: memoryScope,
+        },
+        tool_refs_json: selectedToolCodes.map((code) => ({ tool_code: code })),
+        skill_refs_json: [],
+        status: "draft",
+      });
+
+      toast.success(t("agents.agentCreated"));
       setOpen(false);
-      setCodeTouched(false);
-      setForm({ name: "", code: "", description: "", agent_type: "assistant" });
+      reset();
       onCreated();
     } catch (err) {
-      toast.error(err instanceof Error ? err.message : "Failed to create agent");
+      toast.error(err instanceof Error ? err.message : t("errors.failedToCreate"));
     } finally {
       setSubmitting(false);
     }
@@ -60,67 +148,145 @@ export function CreateAgentDialog({ onCreated }: { onCreated: () => void }) {
   return (
     <>
       <Button onClick={() => setOpen(true)}>
-        <Plus className="h-4 w-4" /> New Agent
+        <Plus className="h-4 w-4" /> {t("agents.newAgent")}
       </Button>
-      <Dialog open={open} onOpenChange={setOpen} title="Create Agent">
-        <form className="space-y-5" onSubmit={submit}>
-          <div className="rounded-md border border-border bg-muted/30 p-4">
-            <div className="flex items-start gap-3">
-              <div className="grid h-10 w-10 shrink-0 place-items-center rounded-md bg-primary/15 text-primary">
-                <Bot className="h-5 w-5" />
-              </div>
-              <div>
-                <h2 className="text-sm font-semibold">Definition</h2>
-                <p className="mt-1 text-sm leading-6 text-muted-foreground">
-                  Create the reusable agent identity first. Versions, prompts, tools, and runtime config live under this definition.
-                </p>
-              </div>
+      <Dialog open={open} onOpenChange={(value) => { if (!value) reset(); setOpen(value); }} title={t("agents.create")} className="max-w-2xl">
+        <form className="space-y-6" onSubmit={submit}>
+          {/* Section: Basic Info */}
+          <section className="space-y-4">
+            <SectionHeader icon={<Bot className="h-4 w-4" />} title={t("agents.basicInfo")} description={t("agents.basicInfoDesc")} />
+            <div className="grid gap-4 sm:grid-cols-2">
+              <Field label={t("common.name")}>
+                <Input required value={form.name} onChange={(e) => updateName(e.target.value)} placeholder={t("agents.namePlaceholder")} />
+              </Field>
+              <Field label={t("common.code")}>
+                <Input
+                  required
+                  value={form.code}
+                  onChange={(e) => { setCodeTouched(true); setForm({ ...form, code: e.target.value }); }}
+                  placeholder={t("agents.codePlaceholder")}
+                />
+              </Field>
             </div>
-          </div>
-
-          <div className="grid gap-4 sm:grid-cols-2">
-            <label className="block space-y-2 text-sm">
-              <span className="text-muted-foreground">Name</span>
-              <Input required value={form.name} onChange={(event) => updateName(event.target.value)} placeholder="Support Agent" />
-            </label>
-            <label className="block space-y-2 text-sm">
-              <span className="text-muted-foreground">Code</span>
-              <Input
-                required
-                value={form.code}
-                onChange={(event) => {
-                  setCodeTouched(true);
-                  setForm({ ...form, code: event.target.value });
-                }}
-                placeholder="support_agent"
-              />
-            </label>
-          </div>
-
-          <label className="block space-y-2 text-sm">
-            <span className="text-muted-foreground">Type</span>
-            <Select
-              value={form.agent_type}
-              onChange={(event) => setForm({ ...form, agent_type: event.target.value })}
-              options={agentTypes}
-            />
-          </label>
+            <Field label={t("common.type")}>
+              <Select value={form.agent_type} onChange={(e) => setForm({ ...form, agent_type: e.target.value })} options={agentTypes} />
+            </Field>
+            <Field label={t("common.description")}>
+              <Textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder={t("agents.descriptionPlaceholder")} />
+            </Field>
+            <Field label={t("agents.goal")}>
+              <Input value={goal} onChange={(e) => setGoal(e.target.value)} placeholder={t("agents.goalPlaceholder")} />
+            </Field>
+          </section>
 
-          <label className="block space-y-2 text-sm">
-            <span className="text-muted-foreground">Description</span>
+          {/* Section: System Prompt */}
+          <section className="space-y-4">
+            <SectionHeader icon={<TerminalSquare className="h-4 w-4" />} title={t("agents.systemPrompt")} />
             <Textarea
-              value={form.description}
-              onChange={(event) => setForm({ ...form, description: event.target.value })}
-              placeholder="What this agent is responsible for, when to use it, and what good output looks like."
+              className="min-h-32"
+              value={systemPrompt}
+              onChange={(e) => setSystemPrompt(e.target.value)}
+              placeholder={t("agents.systemPromptPlaceholder")}
             />
-          </label>
+          </section>
+
+          {/* Section: Model Settings */}
+          <section className="space-y-4">
+            <SectionHeader icon={<Settings2 className="h-4 w-4" />} title={t("agents.modelSettings")} description={t("agents.modelSettingsDesc")} />
+            <div className="grid gap-4 sm:grid-cols-2">
+              <Field label={t("agents.provider")}>
+                <Select
+                  value={selectedProviderId}
+                  onChange={(e) => {
+                    setSelectedProviderId(e.target.value);
+                    const provider = modelProviders.find((p) => p.id === e.target.value);
+                    const chatModels = provider?.models.filter((m) => m.enabled && (m.model_type === "chat" || m.model_type === "reasoning")) ?? [];
+                    setSelectedModel(chatModels[0]?.model_id ?? "");
+                  }}
+                  options={modelProviders.map((p) => ({ value: p.id, label: p.name }))}
+                />
+              </Field>
+              <Field label={t("agents.model")}>
+                <Select value={selectedModel} onChange={(e) => setSelectedModel(e.target.value)} options={modelOptions} />
+              </Field>
+            </div>
+            <div className="grid gap-4 sm:grid-cols-2">
+              <Field label={`${t("agents.temperature")} (${temperature})`}>
+                <input
+                  type="range"
+                  min="0"
+                  max="2"
+                  step="0.1"
+                  value={temperature}
+                  onChange={(e) => setTemperature(e.target.value)}
+                  className="w-full accent-primary"
+                />
+              </Field>
+              <Field label={t("agents.maxTokens")}>
+                <Input type="number" min={1} max={128000} value={maxTokens} onChange={(e) => setMaxTokens(e.target.value)} />
+              </Field>
+            </div>
+          </section>
+
+          {/* Section: Tools */}
+          {availableTools.length > 0 ? (
+            <section className="space-y-4">
+              <SectionHeader icon={<Wrench className="h-4 w-4" />} title={t("agents.toolsSection")} description={t("agents.toolsSectionDesc")} />
+              <div className="flex flex-wrap gap-2">
+                {availableTools.map((tool) => (
+                  <button
+                    key={tool.id}
+                    type="button"
+                    onClick={() => toggleTool(tool.code)}
+                    className={`rounded-md border px-3 py-1.5 text-sm transition ${
+                      selectedToolCodes.includes(tool.code)
+                        ? "border-primary bg-primary/15 text-primary"
+                        : "border-border bg-muted/30 text-muted-foreground hover:bg-muted/60"
+                    }`}
+                  >
+                    {tool.name}
+                  </button>
+                ))}
+              </div>
+            </section>
+          ) : null}
+
+          {/* Section: Memory */}
+          <section className="space-y-4">
+            <SectionHeader icon={<Brain className="h-4 w-4" />} title={t("agents.memorySection")} description={t("agents.memorySectionDesc")} />
+            <div className="grid gap-4 sm:grid-cols-2">
+              <Field label={t("agents.memoryEnabled")}>
+                <Select
+                  value={memoryEnabled ? "true" : "false"}
+                  onChange={(e) => setMemoryEnabled(e.target.value === "true")}
+                  options={[
+                    { value: "true", label: t("common.yes") },
+                    { value: "false", label: t("common.no") },
+                  ]}
+                />
+              </Field>
+              {memoryEnabled ? (
+                <Field label={t("agents.memoryScope")}>
+                  <Select
+                    value={memoryScope}
+                    onChange={(e) => setMemoryScope(e.target.value)}
+                    options={[
+                      { value: "session", label: t("agents.memoryScopeSession") },
+                      { value: "persistent", label: t("agents.memoryScopePersistent") },
+                      { value: "none", label: t("agents.memoryScopeNone") },
+                    ]}
+                  />
+                </Field>
+              ) : null}
+            </div>
+          </section>
 
           <div className="flex flex-wrap justify-end gap-2">
-            <Button type="button" variant="ghost" onClick={() => setOpen(false)}>
-              Cancel
+            <Button type="button" variant="ghost" onClick={() => { reset(); setOpen(false); }}>
+              {t("common.cancel")}
             </Button>
             <Button disabled={submitting || !form.name.trim() || !form.code.trim()}>
-              {submitting ? "Creating..." : "Create Agent"}
+              {submitting ? t("common.creating") : t("agents.create")}
             </Button>
           </div>
         </form>
@@ -128,3 +294,26 @@ export function CreateAgentDialog({ onCreated }: { onCreated: () => void }) {
     </>
   );
 }
+
+function Field({ label, children }: { label: string; children: React.ReactNode }) {
+  return (
+    <label className="block space-y-2 text-sm">
+      <span className="text-muted-foreground">{label}</span>
+      {children}
+    </label>
+  );
+}
+
+function SectionHeader({ icon, title, description }: { icon: React.ReactNode; title: string; description?: string }) {
+  return (
+    <div className="flex items-start gap-3 rounded-md border border-border bg-muted/30 p-3">
+      <div className="grid h-8 w-8 shrink-0 place-items-center rounded-md bg-primary/15 text-primary">
+        {icon}
+      </div>
+      <div>
+        <h3 className="text-sm font-semibold">{title}</h3>
+        {description ? <p className="mt-0.5 text-xs leading-5 text-muted-foreground">{description}</p> : null}
+      </div>
+    </div>
+  );
+}

+ 11 - 12
web/src/pages/dashboard/DashboardPage.tsx

@@ -1,18 +1,19 @@
 import * as React from "react";
+import { useTranslation } from "react-i18next";
 import { ApiErrorState } from "@/components/shared/ApiErrorState";
 import { PageHeader } from "@/components/shared/PageHeader";
 import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
 import { useInterval } from "@/hooks";
-import { getServicesHealth, listAgents, listRuns, listSessions, listWorkflows } from "@/api";
+import { getServicesHealth, listAgents, listRuns, listSessions } from "@/api";
 import { StatsCards } from "./components/StatsCards";
 import { ExecutionTrendChart } from "./components/ExecutionTrendChart";
 import { RecentRunsTable } from "./components/RecentRunsTable";
 import { ServiceHealthList } from "./components/ServiceHealthList";
-import type { AgentDefinition, DownstreamServiceHealth, Session, WorkflowDefinition, WorkflowRun } from "@/types";
+import type { AgentDefinition, DownstreamServiceHealth, Session, WorkflowRun } from "@/types";
 
 export function DashboardPage() {
+  const { t } = useTranslation();
   const [loading, setLoading] = React.useState(true);
-  const [workflows, setWorkflows] = React.useState<WorkflowDefinition[]>([]);
   const [agents, setAgents] = React.useState<AgentDefinition[]>([]);
   const [sessions, setSessions] = React.useState<Session[]>([]);
   const [runs, setRuns] = React.useState<WorkflowRun[]>([]);
@@ -21,23 +22,21 @@ export function DashboardPage() {
 
   const load = React.useCallback(async () => {
     setError(undefined);
-    const [workflowData, agentData, sessionData, runData, healthData] = await Promise.allSettled([
-      listWorkflows(),
+    const [agentData, sessionData, runData, healthData] = await Promise.allSettled([
       listAgents(),
       listSessions(),
       listRuns(),
       getServicesHealth(),
     ]);
-    if (workflowData.status === "fulfilled") setWorkflows(workflowData.value);
     if (agentData.status === "fulfilled") setAgents(agentData.value);
     if (sessionData.status === "fulfilled") setSessions(sessionData.value);
     if (runData.status === "fulfilled") setRuns(runData.value);
     if (healthData.status === "fulfilled") setServices(healthData.value.downstream_services);
-    if ([workflowData, agentData, sessionData, runData, healthData].every((item) => item.status === "rejected")) {
-      setError("All dashboard requests failed. Check the API gateway and credentials.");
+    if ([agentData, sessionData, runData, healthData].every((item) => item.status === "rejected")) {
+      setError(t("dashboard.allRequestsFailed"));
     }
     setLoading(false);
-  }, []);
+  }, [t]);
 
   React.useEffect(() => {
     void load();
@@ -58,13 +57,13 @@ export function DashboardPage() {
     };
   });
 
-  if (loading) return <LoadingSpinner label="Loading dashboard" />;
+  if (loading) return <LoadingSpinner label={t("common.loading")} />;
   if (error) return <ApiErrorState message={error} onRetry={() => void load()} />;
 
   return (
     <div className="space-y-6">
-      <PageHeader title="Dashboard" description="Operational overview for workflow and agent execution." />
-      <StatsCards workflows={workflows.length} agents={agents.length} runsToday={runsToday} activeSessions={activeSessions} />
+      <PageHeader title={t("dashboard.title")} description={t("dashboard.description")} />
+      <StatsCards agents={agents.length} runsToday={runsToday} activeSessions={activeSessions} />
       <div className="grid gap-6 xl:grid-cols-[1fr_360px]">
         <ExecutionTrendChart data={trend} />
         <ServiceHealthList services={services} />

+ 3 - 1
web/src/pages/dashboard/components/ExecutionTrendChart.tsx

@@ -1,11 +1,13 @@
+import { useTranslation } from "react-i18next";
 import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
 
 export function ExecutionTrendChart({ data }: { data: Array<{ label: string; successful: number; failed: number }> }) {
+  const { t } = useTranslation();
   return (
     <Card className="min-h-80">
       <CardHeader>
-        <CardTitle>Execution Trend</CardTitle>
+        <CardTitle>{t("dashboard.executionTrend")}</CardTitle>
       </CardHeader>
       <CardContent className="h-64">
         <ResponsiveContainer width="100%" height="100%">

+ 8 - 6
web/src/pages/dashboard/components/RecentRunsTable.tsx

@@ -1,3 +1,4 @@
+import { useTranslation } from "react-i18next";
 import { StatusBadge } from "@/components/shared/StatusBadge";
 import { RelativeTime } from "@/components/shared/RelativeTime";
 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -5,21 +6,22 @@ import { truncateMiddle } from "@/lib/utils";
 import type { WorkflowRun } from "@/types";
 
 export function RecentRunsTable({ runs }: { runs: WorkflowRun[] }) {
+  const { t } = useTranslation();
   return (
     <Card>
       <CardHeader>
-        <CardTitle>Recent Runs</CardTitle>
+        <CardTitle>{t("dashboard.recentRuns")}</CardTitle>
       </CardHeader>
       <CardContent>
         <div className="overflow-x-auto">
           <table className="w-full text-left text-sm">
             <thead className="text-xs text-muted-foreground">
               <tr className="border-b border-border">
-                <th className="py-2">Run ID</th>
-                <th>Type</th>
-                <th>Status</th>
-                <th>Started</th>
-                <th>Nodes</th>
+                <th className="py-2">{t("agents.runId", "Run ID")}</th>
+                <th>{t("common.type", "Type")}</th>
+                <th>{t("common.status", "Status")}</th>
+                <th>{t("agents.started", "Started")}</th>
+                <th>{t("dashboard.nodes", "Nodes")}</th>
               </tr>
             </thead>
             <tbody>

+ 3 - 1
web/src/pages/dashboard/components/ServiceHealthList.tsx

@@ -1,11 +1,13 @@
+import { useTranslation } from "react-i18next";
 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
 import type { DownstreamServiceHealth } from "@/types";
 
 export function ServiceHealthList({ services }: { services: DownstreamServiceHealth[] }) {
+  const { t } = useTranslation();
   return (
     <Card>
       <CardHeader>
-        <CardTitle>Services</CardTitle>
+        <CardTitle>{t("dashboard.services")}</CardTitle>
       </CardHeader>
       <CardContent className="space-y-3">
         {services.map((service) => (

+ 7 - 8
web/src/pages/dashboard/components/StatsCards.tsx

@@ -1,25 +1,24 @@
-import { Activity, Bot, GitBranch, MessageSquare } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import { Activity, Bot, MessageSquare } from "lucide-react";
 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
 
 export function StatsCards({
-  workflows,
   agents,
   runsToday,
   activeSessions,
 }: {
-  workflows: number;
   agents: number;
   runsToday: number;
   activeSessions: number;
 }) {
+  const { t } = useTranslation();
   const cards = [
-    { label: "Workflows", value: workflows, icon: GitBranch },
-    { label: "Agents", value: agents, icon: Bot },
-    { label: "Runs Today", value: runsToday, icon: Activity },
-    { label: "Active Sessions", value: activeSessions, icon: MessageSquare },
+    { label: t("dashboard.agents"), value: agents, icon: Bot },
+    { label: t("dashboard.runsToday"), value: runsToday, icon: Activity },
+    { label: t("dashboard.activeSessions"), value: activeSessions, icon: MessageSquare },
   ];
   return (
-    <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
+    <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
       {cards.map((card) => (
         <Card key={card.label}>
           <CardHeader className="flex flex-row items-center justify-between pb-2">

+ 2 - 7
web/src/pages/knowledge/KnowledgePage.tsx

@@ -55,7 +55,6 @@ import { Select } from "@/components/ui/select";
 import { Tabs } from "@/components/ui/tabs";
 import { toast } from "@/components/ui/toaster";
 import { formatDateTime } from "@/lib/utils";
-import { useAuthStore } from "@/stores/auth";
 import type { JSONObject, KnowledgeBase, KnowledgeDocument, KnowledgeDocumentIngestResponse, KnowledgeDocumentParseResponse, SearchResult } from "@/types";
 
 const documentStatusOptions = [
@@ -210,7 +209,6 @@ export function KnowledgePage() {
   const navigate = useNavigate();
   const { section: sectionParam } = useParams();
   const section = knowledgeSections.some((item) => item.value === sectionParam) ? sectionParam ?? "overview" : "overview";
-  const tenantId = useAuthStore((state) => state.tenantId);
   const [bases, setBases] = React.useState<KnowledgeBase[]>([]);
   const [documents, setDocuments] = React.useState<KnowledgeDocument[]>([]);
   const [results, setResults] = React.useState<SearchResult[]>([]);
@@ -356,7 +354,7 @@ export function KnowledgePage() {
     setStatusBusy(true);
     try {
       const nextStatus = selectedBase.status === "active" ? "archived" : "active";
-      const updated = await updateKnowledgeBaseStatus({ tenant_id: tenantId, knowledge_base_id: selectedBase.id, status: nextStatus });
+      const updated = await updateKnowledgeBaseStatus({ knowledge_base_id: selectedBase.id, status: nextStatus });
       setBases((items) => items.map((base) => (base.id === updated.id ? updated : base)));
       toast.success(nextStatus === "active" ? "Knowledge base restored" : "Knowledge base archived");
     } finally {
@@ -1348,7 +1346,6 @@ function SearchResultCard({ result }: { result: SearchResult }) {
 }
 
 function CreateKnowledgeBaseDialog({ open, onOpenChange, onCreated }: { open: boolean; onOpenChange: (open: boolean) => void; onCreated: () => void }) {
-  const tenantId = useAuthStore((state) => state.tenantId);
   const [form, setForm] = React.useState({ name: "", description: "", metadata_json: "{}" });
   const [error, setError] = React.useState<string>();
   const [submitting, setSubmitting] = React.useState(false);
@@ -1363,7 +1360,7 @@ function CreateKnowledgeBaseDialog({ open, onOpenChange, onCreated }: { open: bo
     }
     setSubmitting(true);
     try {
-      await createKnowledgeBase({ tenant_id: tenantId, name: form.name.trim(), description: form.description.trim() || null, metadata_json: metadata });
+      await createKnowledgeBase({ name: form.name.trim(), description: form.description.trim() || null, metadata_json: metadata });
       toast.success("Knowledge base created");
       onOpenChange(false);
       setForm({ name: "", description: "", metadata_json: "{}" });
@@ -1400,7 +1397,6 @@ function CreateKnowledgeDocumentDialog({
   knowledgeBaseId?: string;
   onCreated: (ingest: KnowledgeDocumentIngestResponse) => void;
 }) {
-  const tenantId = useAuthStore((state) => state.tenantId);
   const [form, setForm] = React.useState({
     title: "",
     source_type: "text",
@@ -1445,7 +1441,6 @@ function CreateKnowledgeDocumentDialog({
     setSubmitting(true);
     try {
       const ingest = await createKnowledgeDocument({
-        tenant_id: tenantId,
         knowledge_base_id: knowledgeBaseId,
         title: form.title.trim(),
         source_type: form.source_type,

+ 13 - 17
web/src/pages/login/LoginPage.tsx

@@ -1,6 +1,7 @@
 import * as React from "react";
 import { Eye, EyeOff, KeyRound } from "lucide-react";
 import { useLocation, useNavigate } from "react-router-dom";
+import { useTranslation } from "react-i18next";
 import { Button } from "@/components/ui/button";
 import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
 import { Input } from "@/components/ui/input";
@@ -9,12 +10,12 @@ import { getHealth } from "@/api";
 import { useAuthStore } from "@/stores/auth";
 
 export function LoginPage() {
+  const { t } = useTranslation();
   const navigate = useNavigate();
   const location = useLocation();
   const login = useAuthStore((state) => state.login);
   const [showKey, setShowKey] = React.useState(false);
   const [apiKey, setApiKey] = React.useState("");
-  const [tenantId, setTenantId] = React.useState("public");
   const [userId, setUserId] = React.useState("");
   const [loading, setLoading] = React.useState(false);
 
@@ -22,13 +23,13 @@ export function LoginPage() {
     event.preventDefault();
     setLoading(true);
     try {
-      await getHealth(apiKey, tenantId, userId);
-      login({ apiKey, tenantId, userId });
-      toast.success("Connected to gateway");
+      await getHealth(apiKey, userId);
+      login({ apiKey, userId });
+      toast.success(t("login.connected"));
       const redirectTo = (location.state as { from?: string } | null)?.from ?? "/dashboard";
       navigate(redirectTo, { replace: true });
     } catch {
-      toast.error("Gateway rejected the credentials");
+      toast.error(t("login.rejected"));
     } finally {
       setLoading(false);
     }
@@ -42,13 +43,13 @@ export function LoginPage() {
           <div className="mb-3 grid h-11 w-11 place-items-center rounded-md bg-primary/15 text-primary">
             <KeyRound className="h-5 w-5" />
           </div>
-          <CardTitle>Web Studio Login</CardTitle>
-          <CardDescription>Use your gateway API key and tenant context.</CardDescription>
+          <CardTitle>{t("login.title")}</CardTitle>
+          <CardDescription>{t("login.description")}</CardDescription>
         </CardHeader>
         <CardContent>
           <form className="space-y-4" onSubmit={onSubmit}>
             <label className="block space-y-2 text-sm">
-              <span className="text-muted-foreground">API Key</span>
+              <span className="text-muted-foreground">{t("login.apiKey")}</span>
               <div className="relative">
                 <Input
                   required
@@ -63,22 +64,18 @@ export function LoginPage() {
                   size="icon"
                   className="absolute right-0 top-0"
                   onClick={() => setShowKey((value) => !value)}
-                  aria-label={showKey ? "Hide API key" : "Show API key"}
+                  aria-label={showKey ? t("login.hideKey") : t("login.showKey")}
                 >
                   {showKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
                 </Button>
               </div>
             </label>
             <label className="block space-y-2 text-sm">
-              <span className="text-muted-foreground">Tenant ID</span>
-              <Input required value={tenantId} onChange={(event) => setTenantId(event.target.value)} />
-            </label>
-            <label className="block space-y-2 text-sm">
-              <span className="text-muted-foreground">User ID</span>
+              <span className="text-muted-foreground">{t("login.userId")}</span>
               <Input required value={userId} onChange={(event) => setUserId(event.target.value)} />
             </label>
             <Button className="w-full" disabled={loading}>
-              {loading ? "Checking..." : "Enter Studio"}
+              {loading ? t("login.checking") : t("login.enterStudio")}
             </Button>
             <Button
               type="button"
@@ -86,11 +83,10 @@ export function LoginPage() {
               className="w-full"
               onClick={() => {
                 setApiKey("ap_mock_demo_key");
-                setTenantId("public");
                 setUserId("demo-user");
               }}
             >
-              Use Demo Credentials
+              {t("login.useDemo")}
             </Button>
           </form>
         </CardContent>

+ 577 - 0
web/src/pages/models/ModelProvidersPage.tsx

@@ -0,0 +1,577 @@
+import * as React from "react";
+import { useTranslation } from "react-i18next";
+import {
+  KeyRound,
+  Pencil,
+  Plus,
+  Search,
+  Trash2,
+  Unplug,
+  Wifi,
+  WifiOff,
+} from "lucide-react";
+import {
+  createModelProvider,
+  deleteModelProvider,
+  discoverModels,
+  listModelProviders,
+  testModelProviderConnection,
+  updateModelProvider,
+} from "@/api";
+import { ApiErrorState } from "@/components/shared/ApiErrorState";
+import { EmptyState } from "@/components/shared/EmptyState";
+import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
+import { MetricCard } from "@/components/shared/MetricCard";
+import { PageHeader } from "@/components/shared/PageHeader";
+import { StatusBadge } from "@/components/shared/StatusBadge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Dialog } from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Select } from "@/components/ui/select";
+import { toast } from "@/components/ui/toaster";
+import {
+  type DiscoveredModel,
+  type ModelItem,
+  type ModelProvider,
+  type ModelProviderType,
+  type ModelProviderUpdateRequest,
+  type ModelType,
+} from "@/types";
+
+const PROVIDER_TYPE_OPTIONS = [
+  { value: "openai", label: "OpenAI" },
+  { value: "anthropic", label: "Anthropic" },
+  { value: "deepseek", label: "DeepSeek" },
+  { value: "azure_openai", label: "Azure OpenAI" },
+  { value: "ollama", label: "Ollama" },
+  { value: "custom", label: "Custom" },
+];
+
+const DEFAULT_URLS = {
+  openai: "https://api.openai.com/v1",
+  anthropic: "https://api.anthropic.com",
+  deepseek: "https://api.deepseek.com/v1",
+  azure_openai: "https://<resource>.openai.azure.com",
+  ollama: "http://localhost:11434",
+  custom: "",
+} as const;
+
+function ModelTypeBadge({ type, compact }: { type: ModelType; compact?: boolean }) {
+  const labels: Record<ModelType, string> = {
+    chat: "Chat",
+    reasoning: "Reasoning",
+    embedding: "Embedding",
+    image: "Image",
+    audio: "Audio",
+    video: "Video",
+    rerank: "Rerank",
+    moderation: "Moderation",
+    other: "Other",
+  };
+  return (
+    <span className={`rounded bg-muted px-1.5 py-0.5 text-xs ${compact ? "" : "text-muted-foreground"}`}>
+      {labels[type] || type}
+    </span>
+  );
+}
+
+export function ModelProvidersPage() {
+  const { t } = useTranslation();
+  const [providers, setProviders] = React.useState<ModelProvider[]>([]);
+  const [loading, setLoading] = React.useState(true);
+  const [error, setError] = React.useState<string>();
+  const [search, setSearch] = React.useState("");
+  const [providerDialogOpen, setProviderDialogOpen] = React.useState(false);
+  const [editingProvider, setEditingProvider] = React.useState<ModelProvider | null>(null);
+
+  const load = React.useCallback(async () => {
+    setLoading(true);
+    setError(undefined);
+    try {
+      const provs = await listModelProviders();
+      setProviders(provs);
+    } catch (err) {
+      setError(err instanceof Error ? err.message : t("errors.failedToLoad"));
+    } finally {
+      setLoading(false);
+    }
+  }, [t]);
+
+  React.useEffect(() => {
+    void load();
+  }, [load]);
+
+  async function removeProvider(id: string) {
+    await deleteModelProvider(id);
+    toast.success(t("modelProviders.providerDeleted"));
+    void load();
+  }
+
+  async function toggleProviderStatus(provider: ModelProvider) {
+    const next = provider.status === "active" ? "inactive" : "active";
+    await updateModelProvider(provider.id, { status: next });
+    void load();
+  }
+
+  async function testConnection(provider: ModelProvider) {
+    try {
+      const result = await testModelProviderConnection(provider.id);
+      if (result.success) {
+        toast.success(t("modelProviders.testSuccess", { latency: result.latency_ms }));
+      } else {
+        toast.error(`${t("modelProviders.testFailed")}: ${result.message}`);
+      }
+    } catch {
+      toast.error(t("modelProviders.testFailed"));
+    }
+  }
+
+  const filtered = providers.filter(
+    (p) =>
+      p.name.toLowerCase().includes(search.toLowerCase()) ||
+      p.provider_type.toLowerCase().includes(search.toLowerCase())
+  );
+
+  if (loading) return <LoadingSpinner label={t("common.loading")} />;
+  if (error) return <ApiErrorState message={error} onRetry={() => void load()} />;
+
+  return (
+    <div className="space-y-6">
+      <PageHeader
+        title={t("modelProviders.title")}
+        description={t("modelProviders.description")}
+        actions={
+          <Button
+            onClick={() => {
+              setEditingProvider(null);
+              setProviderDialogOpen(true);
+            }}
+          >
+            <Plus className="h-4 w-4" />
+            {t("modelProviders.addProvider")}
+          </Button>
+        }
+      />
+
+      <div className="grid gap-4 md:grid-cols-3">
+        <MetricCard label={t("modelProviders.totalProviders")} value={providers.length} icon={Unplug} />
+        <MetricCard
+          label={t("modelProviders.activeProviders")}
+          value={providers.filter((p) => p.status === "active").length}
+          icon={Wifi}
+        />
+        <MetricCard
+          label={t("modelProviders.totalModels")}
+          value={providers.reduce((acc, p) => acc + p.models.filter((m) => m.enabled).length, 0)}
+          icon={KeyRound}
+        />
+      </div>
+
+      <Card>
+        <CardHeader>
+          <div className="flex items-center justify-between gap-4">
+            <CardTitle>{t("modelProviders.providers")}</CardTitle>
+            <div className="relative w-64">
+              <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
+              <Input
+                value={search}
+                onChange={(e) => setSearch(e.target.value)}
+                placeholder={t("modelProviders.searchProviders")}
+                className="pl-9"
+              />
+            </div>
+          </div>
+        </CardHeader>
+        <CardContent>
+          {filtered.length ? (
+            <div className="space-y-3">
+              {filtered.map((provider) => (
+                <div
+                  key={provider.id}
+                  className="flex items-start justify-between gap-4 rounded-lg border border-border p-4"
+                >
+                  <div className="min-w-0 flex-1 space-y-2">
+                    <div className="flex items-center gap-2">
+                      <span className="font-medium">{provider.name}</span>
+                      <StatusBadge status={provider.status} />
+                      <span className="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
+                        {t(`modelProviders.${provider.provider_type}`)}
+                      </span>
+                    </div>
+                    <p className="truncate text-xs text-muted-foreground">{provider.base_url}</p>
+                    <div className="flex flex-wrap gap-1">
+                      {provider.models.filter((m) => m.enabled).map((m) => (
+                        <span
+                          key={m.model_id}
+                          className="flex items-center gap-1 rounded bg-primary/10 px-1.5 py-0.5 text-xs text-primary"
+                        >
+                          {m.display_name}
+                          {provider.default_model === m.model_id ? " *" : ""}
+                          <ModelTypeBadge type={m.model_type} compact />
+                        </span>
+                      ))}
+                      {provider.models.filter((m) => m.enabled).length === 0 && (
+                        <span className="text-xs text-muted-foreground">{t("modelProviders.noModels")}</span>
+                      )}
+                    </div>
+                  </div>
+                  <div className="flex shrink-0 items-center gap-1">
+                    <Button size="sm" variant="ghost" onClick={() => void testConnection(provider)} title={t("modelProviders.testConnection")}>
+                      <Wifi className="h-4 w-4" />
+                    </Button>
+                    <Button
+                      size="sm"
+                      variant="ghost"
+                      onClick={() => void toggleProviderStatus(provider)}
+                      title={t("modelProviders.toggleStatus")}
+                    >
+                      {provider.status === "active" ? <WifiOff className="h-4 w-4" /> : <Wifi className="h-4 w-4" />}
+                    </Button>
+                    <Button
+                      size="sm"
+                      variant="ghost"
+                      onClick={() => {
+                        setEditingProvider(provider);
+                        setProviderDialogOpen(true);
+                      }}
+                      title={t("modelProviders.editProvider")}
+                    >
+                      <Pencil className="h-4 w-4" />
+                    </Button>
+                    <Button
+                      size="sm"
+                      variant="ghost"
+                      onClick={() => {
+                        if (window.confirm(t("modelProviders.deleteConfirm"))) {
+                          void removeProvider(provider.id);
+                        }
+                      }}
+                      title={t("modelProviders.deleteProvider")}
+                    >
+                      <Trash2 className="h-4 w-4 text-destructive" />
+                    </Button>
+                  </div>
+                </div>
+              ))}
+            </div>
+          ) : (
+            <EmptyState
+              icon={Unplug}
+              title={t("modelProviders.noProviders")}
+              description={t("modelProviders.noProvidersDescription")}
+            />
+          )}
+        </CardContent>
+      </Card>
+
+      <ProviderDialog
+        open={providerDialogOpen}
+        onOpenChange={setProviderDialogOpen}
+        editing={editingProvider}
+        onSaved={() => void load()}
+      />
+    </div>
+  );
+}
+
+function ProviderDialog({
+  open,
+  onOpenChange,
+  editing,
+  onSaved,
+}: {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  editing: ModelProvider | null;
+  onSaved: () => void;
+}) {
+  const { t } = useTranslation();
+  const [name, setName] = React.useState("");
+  const [providerType, setProviderType] = React.useState<ModelProviderType>("openai");
+  const [baseUrl, setBaseUrl] = React.useState("");
+  const [apiKey, setApiKey] = React.useState("");
+  const [models, setModels] = React.useState<ModelItem[]>([]);
+  const [defaultModel, setDefaultModel] = React.useState("");
+  const [submitting, setSubmitting] = React.useState(false);
+  const [discovering, setDiscovering] = React.useState(false);
+  const [discovered, setDiscovered] = React.useState<DiscoveredModel[]>([]);
+  const [discoverOpen, setDiscoverOpen] = React.useState(false);
+  const [selectedDiscovered, setSelectedDiscovered] = React.useState<Set<string>>(new Set());
+  const [typeFilter, setTypeFilter] = React.useState<string>("all");
+
+  React.useEffect(() => {
+    if (!open) {
+      setDiscoverOpen(false);
+      setDiscovered([]);
+      setSelectedDiscovered(new Set());
+      return;
+    }
+    if (editing) {
+      setName(editing.name);
+      setProviderType(editing.provider_type);
+      setBaseUrl(editing.base_url);
+      setApiKey("");
+      setModels(editing.models.map((m) => ({ ...m })));
+      setDefaultModel(editing.default_model ?? "");
+    } else {
+      setName("");
+      setProviderType("openai");
+      setBaseUrl(DEFAULT_URLS.openai);
+      setApiKey("");
+      setModels([]);
+      setDefaultModel("");
+    }
+  }, [open, editing]);
+
+  function handleTypeChange(newType: string) {
+    const pt = newType as ModelProviderType;
+    setProviderType(pt);
+    setBaseUrl(DEFAULT_URLS[pt] ?? "");
+    setModels([]);
+    setDefaultModel("");
+    setDiscovered([]);
+    setDiscoverOpen(false);
+  }
+
+  function updateModel(index: number, patch: Partial<ModelItem>) {
+    setModels((prev) => prev.map((m, i) => (i === index ? { ...m, ...patch } : m)));
+  }
+
+  function addModelRow() {
+    setModels((prev) => [...prev, { model_id: "", display_name: "", model_type: "chat", enabled: true }]);
+  }
+
+  function removeModelRow(index: number) {
+    setModels((prev) => prev.filter((_, i) => i !== index));
+  }
+
+  async function handleDiscover() {
+    setDiscovering(true);
+    setDiscovered([]);
+    setSelectedDiscovered(new Set());
+    try {
+      const result = await discoverModels(
+        editing ? { providerId: editing.id } : { providerType, baseUrl, apiKey },
+      );
+      setDiscovered(result.models);
+      setDiscoverOpen(true);
+      toast.success(t("modelProviders.discovered", { count: result.models.length }));
+    } catch {
+      toast.error(t("modelProviders.discoverFailed"));
+    } finally {
+      setDiscovering(false);
+    }
+  }
+
+  function toggleDiscovered(modelId: string) {
+    setSelectedDiscovered((prev) => {
+      const next = new Set(prev);
+      if (next.has(modelId)) next.delete(modelId);
+      else next.add(modelId);
+      return next;
+    });
+  }
+
+  function toggleAllDiscovered() {
+    const filteredDiscovered = typeFilter === "all" ? discovered : discovered.filter((m) => m.model_type === typeFilter);
+    if (selectedDiscovered.size === filteredDiscovered.length) {
+      setSelectedDiscovered(new Set());
+    } else {
+      setSelectedDiscovered(new Set(filteredDiscovered.map((m) => m.model_id)));
+    }
+  }
+
+  const filteredDiscovered = typeFilter === "all" ? discovered : discovered.filter((m) => m.model_type === typeFilter);
+
+  function applyDiscovered() {
+    const picked = discovered.filter((m) => selectedDiscovered.has(m.model_id));
+    const existingIds = new Set(models.map((m) => m.model_id));
+    const newItems: ModelItem[] = picked
+      .filter((m) => !existingIds.has(m.model_id))
+      .map((m) => ({
+        model_id: m.model_id,
+        display_name: m.display_name,
+        model_type: m.model_type,
+        enabled: true,
+      }));
+    setModels((prev) => [...prev, ...newItems]);
+    if (!defaultModel && newItems.length > 0) {
+      setDefaultModel(newItems[0]!.model_id);
+    }
+    setDiscoverOpen(false);
+    setDiscovered([]);
+    setSelectedDiscovered(new Set());
+  }
+
+  async function submit(event: React.FormEvent) {
+    event.preventDefault();
+    setSubmitting(true);
+    try {
+      const validModels = models.filter((m) => m.model_id.trim());
+      if (editing) {
+        const patch: ModelProviderUpdateRequest = { name, base_url: baseUrl, models: validModels, default_model: defaultModel || null };
+        if (apiKey) patch.api_key = apiKey;
+        await updateModelProvider(editing.id, patch);
+        toast.success(t("modelProviders.providerUpdated"));
+      } else {
+        await createModelProvider({
+          name,
+          provider_type: providerType,
+          base_url: baseUrl,
+          api_key: apiKey,
+          models: validModels,
+          default_model: defaultModel || null,
+        });
+        toast.success(t("modelProviders.providerCreated"));
+      }
+      onOpenChange(false);
+      onSaved();
+    } catch {
+      toast.error(t("errors.failedToSave"));
+    } finally {
+      setSubmitting(false);
+    }
+  }
+
+  const discoveredTypes = Array.from(new Set(discovered.map((m) => m.model_type)));
+
+  return (
+    <Dialog
+      open={open}
+      onOpenChange={onOpenChange}
+      title={editing ? t("modelProviders.editProvider") : t("modelProviders.createProvider")}
+      className="max-w-4xl"
+    >
+      <form className="space-y-4" onSubmit={submit}>
+        <div className="grid gap-4 sm:grid-cols-2">
+          <label className="block space-y-2 text-sm">
+            <span className="text-muted-foreground">{t("modelProviders.providerName")}</span>
+            <Input required value={name} onChange={(e) => setName(e.target.value)} />
+          </label>
+          <label className="block space-y-2 text-sm">
+            <span className="text-muted-foreground">{t("modelProviders.providerType")}</span>
+            <Select options={PROVIDER_TYPE_OPTIONS} value={providerType} onChange={(e) => handleTypeChange(e.target.value)} disabled={!!editing} />
+          </label>
+        </div>
+        <div className="grid gap-4 sm:grid-cols-2">
+          <label className="block space-y-2 text-sm">
+            <span className="text-muted-foreground">{t("modelProviders.baseUrl")}</span>
+            <Input required value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} />
+          </label>
+          <label className="block space-y-2 text-sm">
+            <span className="text-muted-foreground">{t("modelProviders.apiKey")}</span>
+            <Input type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder={editing ? t("modelProviders.masked") : "sk-..."} />
+          </label>
+        </div>
+
+        <div className="space-y-2">
+          <div className="flex items-center justify-between">
+            <span className="text-sm font-medium">{t("modelProviders.availableModels")} ({models.length})</span>
+            <div className="flex gap-2">
+              <Button type="button" size="sm" variant="outline" onClick={() => void handleDiscover()} disabled={discovering}>
+                {discovering ? t("modelProviders.discovering") : t("modelProviders.discoverModels")}
+              </Button>
+              <Button type="button" size="sm" variant="outline" onClick={addModelRow}>
+                {t("modelProviders.addModel")}
+              </Button>
+            </div>
+          </div>
+          {models.length > 0 && (
+            <div className="max-h-48 space-y-2 overflow-auto rounded border p-2">
+              {models.map((model, index) => (
+                <div key={index} className="flex items-center gap-2">
+                  <Input
+                    placeholder={t("modelProviders.modelId")}
+                    value={model.model_id}
+                    onChange={(e) => updateModel(index, { model_id: e.target.value })}
+                    className="flex-1"
+                  />
+                  <Input
+                    placeholder={t("modelProviders.displayName")}
+                    value={model.display_name}
+                    onChange={(e) => updateModel(index, { display_name: e.target.value })}
+                    className="w-32"
+                  />
+                  <Select
+                    value={model.model_type}
+                    onChange={(e) => updateModel(index, { model_type: e.target.value as ModelType })}
+                    options={[
+                      { value: "chat", label: "Chat" },
+                      { value: "reasoning", label: "Reasoning" },
+                      { value: "embedding", label: "Embedding" },
+                      { value: "rerank", label: "Rerank" },
+                    ]}
+                    className="w-28"
+                  />
+                  <Button type="button" size="icon" variant="ghost" onClick={() => removeModelRow(index)}>
+                    <Trash2 className="h-4 w-4" />
+                  </Button>
+                </div>
+              ))}
+            </div>
+          )}
+        </div>
+
+        {discoverOpen && (
+          <div className="space-y-2 rounded border p-4">
+            <div className="flex items-center justify-between">
+              <span className="text-sm font-medium">{t("modelProviders.discoveredModels")}</span>
+              <div className="flex gap-2">
+                <Select
+                  value={typeFilter}
+                  onChange={(e) => setTypeFilter(e.target.value)}
+                  options={[{ value: "all", label: t("modelProviders.allTypes") }, ...discoveredTypes.map((t) => ({ value: t, label: t }))]}
+                  className="w-32"
+                />
+                <Button type="button" size="sm" variant="outline" onClick={toggleAllDiscovered}>
+                  {selectedDiscovered.size === filteredDiscovered.length ? t("modelProviders.deselectAll") : t("modelProviders.selectAll")}
+                </Button>
+              </div>
+            </div>
+            <div className="max-h-60 space-y-1 overflow-auto">
+              {filteredDiscovered.map((m) => (
+                <button
+                  key={m.model_id}
+                  type="button"
+                  onClick={() => toggleDiscovered(m.model_id)}
+                  className={`flex w-full items-center gap-2 rounded p-2 text-left transition ${selectedDiscovered.has(m.model_id) ? "bg-primary/10" : "hover:bg-muted"}`}
+                >
+                  <div className={`h-4 w-4 rounded border ${selectedDiscovered.has(m.model_id) ? "border-primary bg-primary" : "border-muted-foreground"}`} />
+                  <div className="flex-1">
+                    <span className="text-sm">{m.display_name}</span>
+                    <span className="ml-2 text-xs text-muted-foreground">{m.model_id}</span>
+                  </div>
+                  <ModelTypeBadge type={m.model_type} />
+                  {m.context_window && (
+                    <span className="text-xs text-muted-foreground">{m.context_window.toLocaleString()} tokens</span>
+                  )}
+                </button>
+              ))}
+              {filteredDiscovered.length === 0 && (
+                <p className="py-4 text-center text-sm text-muted-foreground">{t("modelProviders.noModelsDiscovered")}</p>
+              )}
+            </div>
+            <div className="flex justify-end gap-2">
+              <Button type="button" variant="ghost" onClick={() => setDiscoverOpen(false)}>
+                {t("common.cancel")}
+              </Button>
+              <Button type="button" onClick={applyDiscovered} disabled={selectedDiscovered.size === 0}>
+                {t("modelProviders.applySelected", { count: selectedDiscovered.size })}
+              </Button>
+            </div>
+          </div>
+        )}
+
+        <div className="flex justify-end gap-2 pt-2">
+          <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
+            {t("common.cancel")}
+          </Button>
+          <Button type="submit" disabled={submitting}>
+            {submitting ? t("common.creating") : t("common.save")}
+          </Button>
+        </div>
+      </form>
+    </Dialog>
+  );
+}

+ 9 - 10
web/src/pages/sessions/SessionChatPage.tsx

@@ -1,18 +1,18 @@
 import * as React from "react";
-import { createMessage, listMessages } from "@/api";
+import { useTranslation } from "react-i18next";
+import { listMessages } from "@/api";
 import { ApiErrorState } from "@/components/shared/ApiErrorState";
 import { PageHeader } from "@/components/shared/PageHeader";
 import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
 import { toast } from "@/components/ui/toaster";
 import { useApps, useSessionList } from "@/hooks";
-import { useAuthStore } from "@/stores/auth";
 import type { Message, Session } from "@/types";
 import { ChatPanel } from "./components/ChatPanel";
 import { CreateSessionDialog } from "./components/CreateSessionDialog";
 import { SessionListPanel } from "./components/SessionListPanel";
 
 export function SessionChatPage() {
-  const { tenantId } = useAuthStore();
+  const { t } = useTranslation();
   const apps = useApps();
   const sessions = useSessionList();
   const [search, setSearch] = React.useState("");
@@ -31,21 +31,20 @@ export function SessionChatPage() {
     `${session.title ?? ""} ${session.channel_type}`.toLowerCase().includes(search.toLowerCase()),
   );
 
-  async function send(text: string) {
+  async function send(_text: string) {
     if (!activeSessionId) return;
-    await createMessage({ tenant_id: tenantId, session_id: activeSessionId, role: "user", content_text: text });
     setMessages(await listMessages(activeSessionId));
-    toast.success("Message sent");
+    toast.success(t("sessions.messageSent"));
   }
 
-  if (sessions.loading || apps.loading) return <LoadingSpinner label="Loading sessions" />;
-  if (sessions.error || apps.error) {
-    return <ApiErrorState message={sessions.error?.message ?? apps.error?.message} onRetry={() => void Promise.all([sessions.refetch(), apps.refetch()])} />;
+  if (sessions.loading) return <LoadingSpinner label={t("common.loading")} />;
+  if (sessions.error) {
+    return <ApiErrorState message={sessions.error?.message} onRetry={() => void sessions.refetch()} />;
   }
 
   return (
     <div className="space-y-6">
-      <PageHeader title="Sessions" description="Inspect channel sessions and chat messages." />
+      <PageHeader title={t("sessions.title")} description={t("sessions.description")} />
       <div className="flex overflow-hidden rounded-md border border-border">
         <SessionListPanel
           sessions={filtered}

+ 5 - 3
web/src/pages/sessions/components/ChatInput.tsx

@@ -1,8 +1,10 @@
 import * as React from "react";
+import { useTranslation } from "react-i18next";
 import { Send } from "lucide-react";
 import { Button } from "@/components/ui/button";
 
 export function ChatInput({ disabled, onSend }: { disabled?: boolean; onSend: (text: string) => void }) {
+  const { t } = useTranslation();
   const [text, setText] = React.useState("");
   return (
     <form
@@ -15,14 +17,14 @@ export function ChatInput({ disabled, onSend }: { disabled?: boolean; onSend: (t
       }}
     >
       <textarea
-        aria-label="Message"
+        aria-label={t("sessions.message")}
         className="max-h-40 min-h-11 flex-1 resize-none rounded-md border border-border bg-muted/40 px-3 py-2 text-base outline-none focus:border-primary/70 sm:text-sm"
         value={text}
         disabled={disabled}
         onChange={(event) => setText(event.target.value)}
-        placeholder={disabled ? "Select a session" : "Message"}
+        placeholder={disabled ? t("sessions.selectSession") : t("sessions.typeMessage")}
       />
-      <Button size="icon" disabled={disabled || !text.trim()} aria-label="Send message">
+      <Button size="icon" disabled={disabled || !text.trim()} aria-label={t("sessions.sendMessage")}>
         <Send className="h-4 w-4" />
       </Button>
     </form>

+ 4 - 2
web/src/pages/sessions/components/ChatPanel.tsx

@@ -1,3 +1,4 @@
+import { useTranslation } from "react-i18next";
 import { MessageCircle } from "lucide-react";
 import { EmptyState } from "@/components/shared/EmptyState";
 import { MessageBubble } from "./MessageBubble";
@@ -13,6 +14,7 @@ export function ChatPanel({
   active: boolean;
   onSend: (text: string) => void;
 }) {
+  const { t } = useTranslation();
   return (
     <section className="flex min-h-[680px] flex-1 flex-col bg-surface-base">
       <div className="flex-1 space-y-4 overflow-auto p-4">
@@ -20,10 +22,10 @@ export function ChatPanel({
           messages.length ? (
             messages.map((message) => <MessageBubble key={message.id} message={message} />)
           ) : (
-            <EmptyState icon={MessageCircle} title="No messages" description="Send the first message for this session." />
+            <EmptyState icon={MessageCircle} title={t("sessions.noMessages")} description={t("sessions.sendFirstMessage")} />
           )
         ) : (
-          <EmptyState icon={MessageCircle} title="No session selected" description="Choose or create a session to start chatting." />
+          <EmptyState icon={MessageCircle} title={t("sessions.noSessionSelected")} description={t("sessions.chooseOrCreate")} />
         )}
       </div>
       <ChatInput disabled={!active} onSend={onSend} />

+ 11 - 9
web/src/pages/sessions/components/CreateSessionDialog.tsx

@@ -1,4 +1,5 @@
 import * as React from "react";
+import { useTranslation } from "react-i18next";
 import { createSession } from "@/api";
 import { Button } from "@/components/ui/button";
 import { Dialog } from "@/components/ui/dialog";
@@ -19,7 +20,8 @@ export function CreateSessionDialog({
   apps: AppResponse[];
   onCreated: (session: Session) => void;
 }) {
-  const { tenantId, userId } = useAuthStore();
+  const { t } = useTranslation();
+  const { userId } = useAuthStore();
   const [appId, setAppId] = React.useState("");
   const [title, setTitle] = React.useState("");
   const [channelType, setChannelType] = React.useState("web");
@@ -33,8 +35,8 @@ export function CreateSessionDialog({
     event.preventDefault();
     setSubmitting(true);
     try {
-      const session = await createSession({ tenant_id: tenantId, user_id: userId, app_id: appId, title, channel_type: channelType });
-      toast.success("Session created");
+      const session = await createSession({ user_id: userId, app_id: appId, title, channel_type: channelType });
+      toast.success(t("sessions.sessionCreated"));
       onCreated(session);
       onOpenChange(false);
     } finally {
@@ -43,25 +45,25 @@ export function CreateSessionDialog({
   }
 
   return (
-    <Dialog open={open} onOpenChange={onOpenChange} title="Create Session">
+    <Dialog open={open} onOpenChange={onOpenChange} title={t("sessions.create")}>
       <form className="space-y-4" onSubmit={submit}>
         <label className="block space-y-2 text-sm">
-          <span className="text-muted-foreground">App</span>
+          <span className="text-muted-foreground">{t("tools.definition")}</span>
           <Select value={appId} onChange={(event) => setAppId(event.target.value)} options={apps.map((app) => ({ value: app.id, label: app.name }))} />
         </label>
         <label className="block space-y-2 text-sm">
-          <span className="text-muted-foreground">Title</span>
+          <span className="text-muted-foreground">{t("common.name")}</span>
           <Input value={title} onChange={(event) => setTitle(event.target.value)} />
         </label>
         <label className="block space-y-2 text-sm">
-          <span className="text-muted-foreground">Channel Type</span>
+          <span className="text-muted-foreground">{t("sessions.channelType")}</span>
           <Input value={channelType} onChange={(event) => setChannelType(event.target.value)} />
         </label>
         <div className="flex justify-end gap-2">
           <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
-            Cancel
+            {t("common.cancel")}
           </Button>
-          <Button disabled={!appId || submitting}>{submitting ? "Creating..." : "Create"}</Button>
+          <Button disabled={!appId || submitting}>{submitting ? t("common.creating") : t("common.create")}</Button>
         </div>
       </form>
     </Dialog>

+ 5 - 3
web/src/pages/sessions/components/SessionListPanel.tsx

@@ -1,3 +1,4 @@
+import { useTranslation } from "react-i18next";
 import { MessageSquare, Plus } from "lucide-react";
 import { Button } from "@/components/ui/button";
 import { SearchInput } from "@/components/shared/SearchInput";
@@ -20,16 +21,17 @@ export function SessionListPanel({
   onSelect: (sessionId: string) => void;
   onCreate: () => void;
 }) {
+  const { t } = useTranslation();
   return (
     <aside className="w-full border-b border-border bg-surface-elevated p-4 md:w-[300px] md:border-b-0 md:border-r">
       <div className="flex items-center justify-between gap-3">
-        <h2 className="text-sm font-semibold">Sessions</h2>
+        <h2 className="text-sm font-semibold">{t("sessions.title")}</h2>
         <Button variant="outline" size="icon" onClick={onCreate}>
           <Plus className="h-4 w-4" />
         </Button>
       </div>
       <div className="mt-4">
-        <SearchInput value={search} onChange={onSearch} placeholder="Search sessions" />
+        <SearchInput value={search} onChange={onSearch} placeholder={t("sessions.searchSessions")} />
       </div>
       <div className="mt-4 space-y-2">
         {sessions.map((session) => (
@@ -43,7 +45,7 @@ export function SessionListPanel({
           >
             <MessageSquare className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
             <span className="min-w-0">
-              <span className="block truncate text-sm font-medium">{session.title ?? "Untitled session"}</span>
+              <span className="block truncate text-sm font-medium">{session.title ?? t("sessions.untitledSession")}</span>
               <span className="block text-xs text-muted-foreground">
                 <RelativeTime value={session.last_active_time ?? session.created_time} />
               </span>

+ 42 - 36
web/src/pages/settings/SettingsPage.tsx

@@ -1,5 +1,6 @@
 import * as React from "react";
-import { KeyRound, ShieldCheck, UserRound } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import { KeyRound, UserRound } from "lucide-react";
 import { createApiKey, listApiKeys, revokeApiKey } from "@/api";
 import { ApiErrorState } from "@/components/shared/ApiErrorState";
 import { EmptyState } from "@/components/shared/EmptyState";
@@ -17,7 +18,8 @@ import { useAuthStore } from "@/stores/auth";
 import type { ApiKeyCreateResponse, ApiKeyResponse } from "@/types";
 
 export function SettingsPage() {
-  const { tenantId, userId } = useAuthStore();
+  const { t } = useTranslation();
+  const { userId } = useAuthStore();
   const [apiKeys, setApiKeys] = React.useState<ApiKeyResponse[]>([]);
   const [newKey, setNewKey] = React.useState<ApiKeyCreateResponse>();
   const [loading, setLoading] = React.useState(true);
@@ -28,13 +30,14 @@ export function SettingsPage() {
     setLoading(true);
     setError(undefined);
     try {
-      setApiKeys(await listApiKeys());
+      const keys = await listApiKeys();
+      setApiKeys(keys);
     } catch (err) {
-      setError(err instanceof Error ? err.message : "Failed to load API keys");
+      setError(err instanceof Error ? err.message : t("errors.failedToLoad"));
     } finally {
       setLoading(false);
     }
-  }, []);
+  }, [t]);
 
   React.useEffect(() => {
     void load();
@@ -42,28 +45,28 @@ export function SettingsPage() {
 
   async function revoke(id: string) {
     await revokeApiKey(id);
-    toast.success("API key revoked");
+    toast.success(t("settings.apiKeyRevoked"));
     void load();
   }
 
-  if (loading) return <LoadingSpinner label="Loading settings" />;
+  if (loading) return <LoadingSpinner label={t("common.loading")} />;
   if (error) return <ApiErrorState message={error} onRetry={() => void load()} />;
 
   return (
     <div className="space-y-6">
       <PageHeader
-        title="Settings"
-        description="Manage tenant context, identity, and gateway API keys."
-        actions={<Button onClick={() => setCreateOpen(true)}><KeyRound className="h-4 w-4" /> New API Key</Button>}
+        title={t("settings.title")}
+        description={t("settings.description")}
+        actions={<Button onClick={() => setCreateOpen(true)}><KeyRound className="h-4 w-4" /> {t("settings.newApiKey")}</Button>}
       />
-      <div className="grid gap-4 md:grid-cols-3">
-        <MetricCard label="Tenant" value={tenantId} icon={ShieldCheck} />
-        <MetricCard label="User" value={userId || "Unset"} icon={UserRound} />
-        <MetricCard label="API Keys" value={apiKeys.length} icon={KeyRound} />
+      <div className="grid gap-4 md:grid-cols-2">
+        <MetricCard label={t("settings.user")} value={userId || t("settings.unset")} icon={UserRound} />
+        <MetricCard label={t("settings.apiKeys")} value={apiKeys.length} icon={KeyRound} />
       </div>
+
       <Card>
         <CardHeader>
-          <CardTitle>Gateway API Keys</CardTitle>
+          <CardTitle>{t("settings.gatewayApiKeys")}</CardTitle>
         </CardHeader>
         <CardContent>
           {apiKeys.length ? (
@@ -71,12 +74,12 @@ export function SettingsPage() {
               <table className="w-full text-left text-sm">
                 <thead className="text-xs text-muted-foreground">
                   <tr className="border-b border-border">
-                    <th className="py-2">Name</th>
-                    <th>Prefix</th>
-                    <th>Status</th>
-                    <th>Last Used</th>
-                    <th>Created</th>
-                    <th className="text-right">Action</th>
+                    <th className="py-2">{t("common.name")}</th>
+                    <th>{t("settings.prefix")}</th>
+                    <th>{t("common.status")}</th>
+                    <th>{t("settings.lastUsed")}</th>
+                    <th>{t("common.created")}</th>
+                    <th className="text-right">{t("common.actions")}</th>
                   </tr>
                 </thead>
                 <tbody>
@@ -89,7 +92,7 @@ export function SettingsPage() {
                       <td className="text-muted-foreground">{formatDateTime(key.created_time)}</td>
                       <td className="text-right">
                         <Button size="sm" variant="outline" disabled={key.status === "revoked"} onClick={() => void revoke(key.id)}>
-                          Revoke
+                          {t("settings.revoke")}
                         </Button>
                       </td>
                     </tr>
@@ -98,10 +101,11 @@ export function SettingsPage() {
               </table>
             </div>
           ) : (
-            <EmptyState icon={KeyRound} title="No API keys" description="Create a gateway API key for local development or operator access." />
+            <EmptyState icon={KeyRound} title={t("settings.noApiKeys")} description={t("settings.createGatewayKey")} />
           )}
         </CardContent>
       </Card>
+
       <CreateApiKeyDialog
         open={createOpen}
         onOpenChange={(open) => {
@@ -129,7 +133,7 @@ function CreateApiKeyDialog({
   createdKey?: ApiKeyCreateResponse;
   onCreated: (key: ApiKeyCreateResponse) => void;
 }) {
-  const tenantId = useAuthStore((state) => state.tenantId);
+  const { t } = useTranslation();
   const [name, setName] = React.useState("");
   const [scopes, setScopes] = React.useState("");
   const [submitting, setSubmitting] = React.useState(false);
@@ -138,42 +142,44 @@ function CreateApiKeyDialog({
     event.preventDefault();
     setSubmitting(true);
     try {
-      const key = await createApiKey({ tenant_id: tenantId, name, scopes: scopes || null });
-      toast.success("API key created");
+      const newKeyData = await createApiKey({ name, scopes: scopes || undefined });
+      toast.success(t("settings.apiKeyCreated"));
       setName("");
       setScopes("");
-      onCreated(key);
+      onCreated(newKeyData);
+    } catch {
+      toast.error(t("errors.failedToSave"));
     } finally {
       setSubmitting(false);
     }
   }
 
   return (
-    <Dialog open={open} onOpenChange={onOpenChange} title="Create API Key">
+    <Dialog open={open} onOpenChange={onOpenChange} title={t("settings.createApiKey")}>
       {createdKey ? (
         <div className="space-y-4">
           <div className="rounded-md border border-amber-500/20 bg-amber-500/10 p-3 text-sm text-amber-100">
-            Copy this key now. The full secret is only shown once.
+            {t("settings.copyKeyNow")}
           </div>
           <div className="rounded-md border border-border bg-muted/60 p-3 font-mono text-xs break-all">{createdKey.api_key}</div>
-          <p className="text-sm text-muted-foreground">Prefix: {truncateMiddle(createdKey.key_prefix, 20)}</p>
+          <p className="text-sm text-muted-foreground">{t("settings.prefix")}: {truncateMiddle(createdKey.key_prefix, 20)}</p>
           <div className="flex justify-end">
-            <Button onClick={() => onOpenChange(false)}>Done</Button>
+            <Button onClick={() => onOpenChange(false)}>{t("settings.done")}</Button>
           </div>
         </div>
       ) : (
         <form className="space-y-4" onSubmit={submit}>
           <label className="block space-y-2 text-sm">
-            <span className="text-muted-foreground">Name</span>
+            <span className="text-muted-foreground">{t("common.name")}</span>
             <Input required value={name} onChange={(event) => setName(event.target.value)} />
           </label>
           <label className="block space-y-2 text-sm">
-            <span className="text-muted-foreground">Scopes</span>
-            <Textarea value={scopes} onChange={(event) => setScopes(event.target.value)} placeholder="Optional comma-separated scopes" />
+            <span className="text-muted-foreground">{t("settings.scopes")}</span>
+            <Textarea value={scopes} onChange={(event) => setScopes(event.target.value)} placeholder={t("settings.optionalScopes")} />
           </label>
           <div className="flex justify-end gap-2">
-            <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>Cancel</Button>
-            <Button disabled={submitting}>{submitting ? "Creating..." : "Create"}</Button>
+            <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>{t("common.cancel")}</Button>
+            <Button disabled={submitting}>{submitting ? t("common.creating") : t("common.create")}</Button>
           </div>
         </form>
       )}

+ 487 - 0
web/src/pages/skills/SkillsPage.tsx

@@ -0,0 +1,487 @@
+import * as React from "react";
+import { useTranslation } from "react-i18next";
+import {
+  Cpu,
+  FileText,
+  Link2,
+  Plus,
+  Search,
+  Trash2,
+  X,
+} from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { Dialog } from "@/components/ui/dialog";
+import { EmptyState } from "@/components/shared/EmptyState";
+import { Input } from "@/components/ui/input";
+import { PageHeader } from "@/components/shared/PageHeader";
+import { Select } from "@/components/ui/select";
+import { Textarea } from "@/components/ui/input";
+import { toast } from "@/components/ui/toaster";
+
+type Tool = {
+  id: string;
+  name: string;
+  description: string;
+};
+
+type Skill = {
+  id: string;
+  name: string;
+  description: string;
+  instruction: string;
+  category: string;
+  tools: Tool[];
+  status: "active" | "draft";
+};
+
+const mockTools: Tool[] = [
+  { id: "tool_1", name: "search_knowledge", description: "Search knowledge base" },
+  { id: "tool_2", name: "query_database", description: "Query database" },
+  { id: "tool_3", name: "send_email", description: "Send email" },
+  { id: "tool_4", name: "create_ticket", description: "Create ticket" },
+  { id: "tool_5", name: "lookup_order", description: "Look up order" },
+  { id: "tool_6", name: "calculate", description: "Calculate" },
+];
+
+const mockSkills: Skill[] = [
+  {
+    id: "skill_1",
+    name: "Customer Support",
+    description: "Handle customer inquiries professionally",
+    instruction: "When a customer asks about their order:\n1. Use lookup_order to find the order\n2. Check status and provide update\n3. Create support ticket if needed",
+    category: "service",
+    tools: [mockTools[0]!, mockTools[3]!, mockTools[2]!],
+    status: "active",
+  },
+  {
+    id: "skill_2",
+    name: "Order Checker",
+    description: "Quickly check order status",
+    instruction: "To check order status:\n1. Use lookup_order with order ID\n2. Format response clearly\n3. Suggest support if not found",
+    category: "service",
+    tools: [mockTools[4]!],
+    status: "active",
+  },
+];
+
+export function SkillsPage() {
+  const { t } = useTranslation();
+  const [skills, setSkills] = React.useState<Skill[]>(mockSkills);
+  const [tools] = React.useState<Tool[]>(mockTools);
+  const [selectedSkill, setSelectedSkill] = React.useState<Skill | null>(null);
+  const [search, setSearch] = React.useState("");
+  const [categoryFilter, setCategoryFilter] = React.useState("all");
+  const [createOpen, setCreateOpen] = React.useState(false);
+
+  const filtered = skills.filter((s) => {
+    const matchSearch = s.name.toLowerCase().includes(search.toLowerCase()) ||
+                       s.description.toLowerCase().includes(search.toLowerCase());
+    const matchCat = categoryFilter === "all" || s.category === categoryFilter;
+    return matchSearch && matchCat;
+  });
+
+  function handleCreate(skill: Skill) {
+    setSkills([skill, ...skills]);
+    setSelectedSkill(skill);
+    setCreateOpen(false);
+  }
+
+  function handleUpdate(updated: Skill) {
+    setSkills((prev) => prev.map((s) => (s.id === updated.id ? updated : s)));
+    setSelectedSkill(updated);
+  }
+
+  function handleDelete(id: string) {
+    setSkills((prev) => prev.filter((s) => s.id !== id));
+    if (selectedSkill?.id === id) setSelectedSkill(null);
+    toast.success(t("skills.deleted"));
+  }
+
+  return (
+    <div className="flex h-full">
+      <div className="flex-1 flex flex-col overflow-hidden">
+        <PageHeader
+          title={t("skills.title")}
+          description={t("skills.description")}
+          actions={
+            <Button onClick={() => setCreateOpen(true)}>
+              <Plus className="h-4 w-4" /> {t("skills.new")}
+            </Button>
+          }
+        />
+
+        <div className="flex items-center gap-4 border-b px-6 py-3">
+          <div className="relative flex-1 max-w-md">
+            <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
+            <Input
+              value={search}
+              onChange={(e) => setSearch(e.target.value)}
+              placeholder={t("skills.search")}
+              className="pl-9"
+            />
+          </div>
+          <Select
+            value={categoryFilter}
+            onChange={(e) => setCategoryFilter(e.target.value)}
+            options={[
+              { value: "all", label: t("skills.allCategories") },
+              { value: "service", label: t("skills.catService") },
+              { value: "analytics", label: t("skills.catAnalytics") },
+              { value: "development", label: t("skills.catDevelopment") },
+              { value: "processing", label: t("skills.catProcessing") },
+            ]}
+            className="w-40"
+          />
+        </div>
+
+        <div className="flex-1 overflow-auto p-6">
+          {filtered.length === 0 ? (
+            <EmptyState
+              icon={Cpu}
+              title={t("skills.empty")}
+              description={t("skills.emptyHint")}
+              actionLabel={t("skills.new")}
+              onAction={() => setCreateOpen(true)}
+            />
+          ) : (
+            <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
+              {filtered.map((skill) => (
+                <Card
+                  key={skill.id}
+                  className={[
+                    "cursor-pointer transition-all hover:shadow-md",
+                    selectedSkill?.id === skill.id ? "ring-2 ring-primary" : "",
+                  ].join(" ")}
+                  onClick={() => setSelectedSkill(skill)}
+                >
+                  <CardContent className="p-4">
+                    <div className="flex items-start justify-between">
+                      <div className="flex items-center gap-3">
+                        <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
+                          <Cpu className="h-5 w-5 text-primary" />
+                        </div>
+                        <div>
+                          <h3 className="font-semibold">{skill.name}</h3>
+                          <p className="text-sm text-muted-foreground">{skill.description}</p>
+                        </div>
+                      </div>
+                      <Button
+                        variant="ghost"
+                        size="icon"
+                        className="h-8 w-8 -mr-2 -mt-1"
+                        onClick={(e) => {
+                          e.stopPropagation();
+                          handleDelete(skill.id);
+                        }}
+                      >
+                        <Trash2 className="h-4 w-4 text-destructive" />
+                      </Button>
+                    </div>
+                    <div className="mt-3 flex flex-wrap gap-1">
+                      <span className={`rounded-full px-2 py-0.5 text-xs ${skill.status === "active" ? "bg-emerald-100 text-emerald-700" : "bg-muted text-muted-foreground"}`}>
+                        {skill.status === "active" ? t("skills.active") : t("skills.draft")}
+                      </span>
+                      <span className="rounded-full bg-muted px-2 py-0.5 text-xs">
+                        {t(`skills.cat${skill.category.charAt(0).toUpperCase() + skill.category.slice(1)}`)}
+                      </span>
+                      <span className="rounded-full bg-muted px-2 py-0.5 text-xs flex items-center gap-1">
+                        <Link2 className="h-3 w-3" />
+                        {skill.tools.length} {t("skills.tools")}
+                      </span>
+                    </div>
+                  </CardContent>
+                </Card>
+              ))}
+            </div>
+          )}
+        </div>
+      </div>
+
+      {selectedSkill && (
+        <SkillPanel
+          skill={selectedSkill}
+          tools={tools}
+          onUpdate={handleUpdate}
+          onClose={() => setSelectedSkill(null)}
+        />
+      )}
+
+      <CreateSkillDialog
+        open={createOpen}
+        onOpenChange={setCreateOpen}
+        onCreated={handleCreate}
+        tools={tools}
+      />
+    </div>
+  );
+}
+
+function SkillPanel({
+  skill,
+  tools,
+  onUpdate,
+  onClose,
+}: {
+  skill: Skill;
+  tools: Tool[];
+  onUpdate: (skill: Skill) => void;
+  onClose: () => void;
+}) {
+  const { t } = useTranslation();
+  const [instruction, setInstruction] = React.useState(skill.instruction);
+  const [editing, setEditing] = React.useState(false);
+
+  React.useEffect(() => {
+    setInstruction(skill.instruction);
+    setEditing(false);
+  }, [skill]);
+
+  function handleSaveInstruction() {
+    onUpdate({ ...skill, instruction });
+    setEditing(false);
+    toast.success(t("skills.saved"));
+  }
+
+  function toggleTool(toolId: string) {
+    const has = skill.tools.some((t) => t.id === toolId);
+    const tool = tools.find((t) => t.id === toolId);
+    if (!tool) return;
+    const newTools = has
+      ? skill.tools.filter((t) => t.id !== toolId)
+      : [...skill.tools, tool];
+    onUpdate({ ...skill, tools: newTools });
+  }
+
+  return (
+    <div className="w-[480px] border-l bg-background overflow-auto">
+      <div className="sticky top-0 z-10 flex items-center justify-between border-b bg-background px-6 py-4">
+        <h2 className="font-semibold">{skill.name}</h2>
+        <Button variant="ghost" size="icon" onClick={onClose}>
+          <X className="h-4 w-4" />
+        </Button>
+      </div>
+
+      <div className="space-y-6 p-6">
+        <div>
+          <div className="mb-2 flex items-center justify-between">
+            <label className="text-sm font-medium">{t("skills.instruction")}</label>
+            <Button
+              variant="ghost"
+              size="sm"
+              onClick={() => editing ? handleSaveInstruction() : setEditing(true)}
+            >
+              {editing ? t("common.save") : t("common.edit")}
+            </Button>
+          </div>
+          <p className="mb-2 text-xs text-muted-foreground">{t("skills.instructionHint")}</p>
+          {editing ? (
+            <Textarea
+              value={instruction}
+              onChange={(e) => setInstruction(e.target.value)}
+              className="min-h-[200px] font-mono text-sm"
+              placeholder={t("skills.instructionPlaceholder")}
+            />
+          ) : (
+            <div className={`rounded-md border p-4 text-sm ${skill.instruction ? "bg-muted/50" : "border-dashed"}`}>
+              {skill.instruction ? (
+                <pre className="whitespace-pre-wrap">{skill.instruction}</pre>
+              ) : (
+                <span className="text-muted-foreground">{t("skills.noInstruction")}</span>
+              )}
+            </div>
+          )}
+        </div>
+
+        <div>
+          <label className="mb-2 block text-sm font-medium">{t("skills.tools")}</label>
+          <p className="mb-2 text-xs text-muted-foreground">{t("skills.toolsHint")}</p>
+          <div className="space-y-2">
+            {tools.map((tool) => {
+              const isBound = skill.tools.some((t) => t.id === tool.id);
+              return (
+                <button
+                  key={tool.id}
+                  type="button"
+                  onClick={() => toggleTool(tool.id)}
+                  className={[
+                    "flex w-full items-center gap-3 rounded-md border p-3 text-left transition",
+                    isBound ? "border-primary/40 bg-primary/5" : "border-border hover:bg-muted"
+                  ].join(" ")}
+                >
+                  <div className={[
+                    "flex h-5 w-5 items-center justify-center rounded border",
+                    isBound ? "border-primary bg-primary text-primary-foreground" : "border-muted-foreground"
+                  ].join(" ")}>
+                    {isBound && <FileText className="h-3 w-3" />}
+                  </div>
+                  <div>
+                    <p className="text-sm font-medium">{tool.name}</p>
+                    <p className="text-xs text-muted-foreground">{tool.description}</p>
+                  </div>
+                </button>
+              );
+            })}
+          </div>
+        </div>
+
+        <div className="rounded-md border p-4">
+          <h4 className="mb-3 text-sm font-medium">{t("skills.info")}</h4>
+          <dl className="space-y-2 text-sm">
+            <div className="flex justify-between">
+              <dt className="text-muted-foreground">{t("common.status")}</dt>
+              <dd className={skill.status === "active" ? "text-emerald-600" : "text-muted-foreground"}>
+                {skill.status === "active" ? t("skills.active") : t("skills.draft")}
+              </dd>
+            </div>
+            <div className="flex justify-between">
+              <dt className="text-muted-foreground">{t("skills.category")}</dt>
+              <dd>{t(`skills.cat${skill.category.charAt(0).toUpperCase() + skill.category.slice(1)}`)}</dd>
+            </div>
+            <div className="flex justify-between">
+              <dt className="text-muted-foreground">{t("skills.toolsCount")}</dt>
+              <dd>{skill.tools.length}</dd>
+            </div>
+          </dl>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function CreateSkillDialog({
+  open,
+  onOpenChange,
+  onCreated,
+  tools,
+}: {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  onCreated: (skill: Skill) => void;
+  tools: Tool[];
+}) {
+  const { t } = useTranslation();
+  const [name, setName] = React.useState("");
+  const [description, setDescription] = React.useState("");
+  const [instruction, setInstruction] = React.useState("");
+  const [category, setCategory] = React.useState("service");
+  const [selectedToolIds, setSelectedToolIds] = React.useState<string[]>([]);
+
+  React.useEffect(() => {
+    if (!open) {
+      setName("");
+      setDescription("");
+      setInstruction("");
+      setCategory("service");
+      setSelectedToolIds([]);
+    }
+  }, [open]);
+
+  function handleSubmit(e: React.FormEvent) {
+    e.preventDefault();
+    if (!name.trim()) return;
+    const selectedTools = tools.filter((t) => selectedToolIds.includes(t.id));
+    const newSkill: Skill = {
+      id: `skill_${Date.now()}`,
+      name: name.trim(),
+      description: description.trim(),
+      instruction: instruction.trim(),
+      category,
+      tools: selectedTools,
+      status: "draft",
+    };
+    onCreated(newSkill);
+    toast.success(t("skills.created"));
+  }
+
+  function toggleTool(toolId: string) {
+    if (selectedToolIds.includes(toolId)) {
+      setSelectedToolIds(selectedToolIds.filter((id) => id !== toolId));
+    } else {
+      setSelectedToolIds([...selectedToolIds, toolId]);
+    }
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange} title={t("skills.new")}>
+      <form onSubmit={handleSubmit} className="space-y-4">
+        <div>
+          <label className="text-sm font-medium">{t("common.name")}</label>
+          <Input
+            value={name}
+            onChange={(e) => setName(e.target.value)}
+            placeholder={t("skills.namePlaceholder")}
+            className="mt-1"
+            autoFocus
+          />
+        </div>
+        <div>
+          <label className="text-sm font-medium">{t("common.description")}</label>
+          <Input
+            value={description}
+            onChange={(e) => setDescription(e.target.value)}
+            placeholder={t("skills.descPlaceholder")}
+            className="mt-1"
+          />
+        </div>
+        <div>
+          <label className="text-sm font-medium">{t("skills.category")}</label>
+          <Select
+            value={category}
+            onChange={(e) => setCategory(e.target.value)}
+            options={[
+              { value: "service", label: t("skills.catService") },
+              { value: "analytics", label: t("skills.catAnalytics") },
+              { value: "development", label: t("skills.catDevelopment") },
+              { value: "processing", label: t("skills.catProcessing") },
+            ]}
+            className="mt-1"
+          />
+        </div>
+        <div>
+          <label className="text-sm font-medium">{t("skills.instruction")}</label>
+          <Textarea
+            value={instruction}
+            onChange={(e) => setInstruction(e.target.value)}
+            placeholder={t("skills.instructionPlaceholder")}
+            className="mt-1"
+            rows={4}
+          />
+        </div>
+        <div>
+          <label className="text-sm font-medium">{t("skills.selectTools")}</label>
+          <div className="mt-2 space-y-1 max-h-40 overflow-auto">
+            {tools.map((tool) => {
+              const isSelected = selectedToolIds.includes(tool.id);
+              return (
+                <button
+                  key={tool.id}
+                  type="button"
+                  onClick={() => toggleTool(tool.id)}
+                  className={[
+                    "flex w-full items-center gap-2 rounded-md border p-2 text-left transition",
+                    isSelected ? "border-primary/40 bg-primary/5" : "border-border hover:bg-muted"
+                  ].join(" ")}
+                >
+                  <div className={[
+                    "h-4 w-4 rounded border",
+                    isSelected ? "border-primary bg-primary" : "border-muted-foreground"
+                  ].join(" ")} />
+                  <span className="text-sm">{tool.name}</span>
+                </button>
+              );
+            })}
+          </div>
+        </div>
+        <div className="flex justify-end gap-2 pt-2">
+          <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
+            {t("common.cancel")}
+          </Button>
+          <Button type="submit" disabled={!name.trim()}>
+            {t("common.create")}
+          </Button>
+        </div>
+      </form>
+    </Dialog>
+  );
+}

+ 121 - 1087
web/src/pages/teams/TeamsPage.tsx

@@ -1,24 +1,20 @@
 import * as React from "react";
+import { useTranslation } from "react-i18next";
 import {
   Activity,
   Archive,
   Bot,
   CheckCircle2,
-  Clock,
   Copy,
   FileCode2,
-  Grid2X2,
-  Layers3,
-  List,
   Network,
   Play,
   RefreshCw,
   Search,
-  Settings2,
   SlidersHorizontal,
   Users,
 } from "lucide-react";
-import { createTeam, createTeamRun, createTeamVersion, listTeamRuns, listTeamVersions, listTeams } from "@/api";
+import { listTeamRuns, listTeamVersions, listTeams } from "@/api";
 import { ApiErrorState } from "@/components/shared/ApiErrorState";
 import { EmptyState } from "@/components/shared/EmptyState";
 import { EntityListItem } from "@/components/shared/EntityListItem";
@@ -27,106 +23,47 @@ import { MetricCard } from "@/components/shared/MetricCard";
 import { PageHeader } from "@/components/shared/PageHeader";
 import { SearchInput } from "@/components/shared/SearchInput";
 import { StatusBadge } from "@/components/shared/StatusBadge";
-import { Badge } from "@/components/ui/badge";
 import { Button } from "@/components/ui/button";
 import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
-import { Dialog } from "@/components/ui/dialog";
-import { Input, Textarea } from "@/components/ui/input";
 import { Select } from "@/components/ui/select";
 import { Tabs } from "@/components/ui/tabs";
 import { toast } from "@/components/ui/toaster";
-import { copyToClipboard, formatDateTime, relativeTime, truncateMiddle } from "@/lib/utils";
-import { useAuthStore } from "@/stores/auth";
-import type { JSONObject, TeamDefinition, TeamRun, TeamRunStatus, TeamStatus, TeamVersion } from "@/types";
+import { copyToClipboard } from "@/lib/utils";
+import type { TeamDefinition, TeamRun, TeamStatus, TeamVersion } from "@/types";
+import { CreateTeamDialog } from "./components/CreateTeamDialog";
+import { TeamOverview } from "./components/TeamOverview";
+import { TeamRuns } from "./components/TeamRuns";
+import { TeamVersions } from "./components/TeamVersions";
 
 type StatusFilter = "all" | TeamStatus;
-type RunStatusFilter = "all" | TeamRunStatus;
 type SortMode = "recent" | "name" | "status";
-type ViewMode = "list" | "grid";
-type MemberDraft = {
-  role: string;
-  agent_id: string;
-  responsibility: string;
-};
-type PolicyDraft = {
-  max_rounds: string;
-  handoff: string;
-  failure_mode: string;
-};
-
-const TEAM_TYPE_OPTIONS = [
-  { value: "collaborative", label: "Collaborative" },
-  { value: "supervisor", label: "Supervisor" },
-  { value: "pipeline", label: "Pipeline" },
-  { value: "debate", label: "Debate" },
-];
-
-const COORDINATION_MODE_OPTIONS = [
-  { value: "supervisor", label: "Supervisor" },
-  { value: "pipeline", label: "Pipeline" },
-  { value: "parallel", label: "Parallel" },
-  { value: "debate", label: "Debate" },
-];
-
-const RUN_TEMPLATE_OPTIONS = [
-  "Break this request into specialist tasks and produce an execution plan.",
-  "Review the latest customer issue, identify risks, and recommend next actions.",
-  "Compare two implementation options and return a decision with tradeoffs.",
-];
-
-const DEFAULT_MEMBER_DRAFTS: MemberDraft[] = [
-  {
-    role: "supervisor",
-    agent_id: "agent_supervisor",
-    responsibility: "Plan work, route tasks, and merge final output.",
-  },
-  {
-    role: "worker",
-    agent_id: "agent_worker",
-    responsibility: "Execute assigned specialist tasks.",
-  },
-];
-
-const DEFAULT_POLICY_DRAFT: PolicyDraft = {
-  max_rounds: "3",
-  handoff: "supervisor",
-  failure_mode: "stop_on_critical",
-};
 
 export function TeamsPage() {
+  const { t } = useTranslation();
   const [teams, setTeams] = React.useState<TeamDefinition[]>([]);
   const [versions, setVersions] = React.useState<TeamVersion[]>([]);
   const [runs, setRuns] = React.useState<TeamRun[]>([]);
   const [selectedTeamId, setSelectedTeamId] = React.useState<string>();
   const [search, setSearch] = React.useState("");
   const [statusFilter, setStatusFilter] = React.useState<StatusFilter>("all");
-  const [typeFilter, setTypeFilter] = React.useState("all");
   const [sortMode, setSortMode] = React.useState<SortMode>("recent");
-  const [viewMode, setViewMode] = React.useState<ViewMode>("list");
   const [activeTab, setActiveTab] = React.useState("overview");
-  const [runStatusFilter, setRunStatusFilter] = React.useState<RunStatusFilter>("all");
   const [loading, setLoading] = React.useState(true);
-  const [relatedLoading, setRelatedLoading] = React.useState(false);
   const [error, setError] = React.useState<string>();
-  const [relatedError, setRelatedError] = React.useState<string>();
+  const [relatedLoading, setRelatedLoading] = React.useState(false);
   const [createOpen, setCreateOpen] = React.useState(false);
-  const [versionOpen, setVersionOpen] = React.useState(false);
-  const [runOpen, setRunOpen] = React.useState(false);
-  const detailsRequestRef = React.useRef(0);
 
   const selectedTeam = teams.find((team) => team.id === selectedTeamId);
-  const teamTypes = React.useMemo(() => Array.from(new Set(teams.map((team) => team.team_type))).sort(), [teams]);
   const sortedVersions = React.useMemo(
-    () => [...versions].sort((first, second) => second.version_no - first.version_no),
+    () => [...versions].sort((a, b) => b.version_no - a.version_no),
     [versions],
   );
   const sortedRuns = React.useMemo(
-    () => [...runs].sort((first, second) => new Date(second.created_time).getTime() - new Date(first.created_time).getTime()),
+    () => [...runs].sort((a, b) => new Date(b.created_time).getTime() - new Date(a.created_time).getTime()),
     [runs],
   );
   const latestVersion = sortedVersions[0];
-  const filteredRuns = sortedRuns.filter((run) => runStatusFilter === "all" || run.status === runStatusFilter);
-  const failedRuns = sortedRuns.filter((run) => run.status === "failed").length;
+  const failedRunCount = sortedRuns.filter((run) => run.status === "failed").length;
   const activeTeams = teams.filter((team) => team.status === "active").length;
   const draftTeams = teams.filter((team) => team.status === "draft").length;
 
@@ -135,8 +72,7 @@ export function TeamsPage() {
       const text = `${team.name} ${team.code} ${team.team_type} ${team.description ?? ""}`.toLowerCase();
       const matchesSearch = text.includes(search.toLowerCase());
       const matchesStatus = statusFilter === "all" || team.status === statusFilter;
-      const matchesType = typeFilter === "all" || team.team_type === typeFilter;
-      return matchesSearch && matchesStatus && matchesType;
+      return matchesSearch && matchesStatus;
     })
     .sort((first, second) => {
       if (sortMode === "name") return first.name.localeCompare(second.name);
@@ -144,7 +80,7 @@ export function TeamsPage() {
       return new Date(second.created_time).getTime() - new Date(first.created_time).getTime();
     });
 
-  const hasFilters = search.length > 0 || statusFilter !== "all" || typeFilter !== "all" || sortMode !== "recent";
+  const hasFilters = search.length > 0 || statusFilter !== "all" || sortMode !== "recent";
 
   const load = React.useCallback(async () => {
     setLoading(true);
@@ -154,327 +90,207 @@ export function TeamsPage() {
       setTeams(data);
       setSelectedTeamId((current) => current ?? data[0]?.id);
     } catch (err) {
-      setError(err instanceof Error ? err.message : "Failed to load teams");
+      setError(err instanceof Error ? err.message : t("errors.failedToLoad"));
     } finally {
       setLoading(false);
     }
-  }, []);
+  }, [t]);
 
-  const reloadTeamDetails = React.useCallback(async () => {
+  const reloadDetails = React.useCallback(async () => {
     if (!selectedTeamId) return;
-    const requestId = detailsRequestRef.current + 1;
-    detailsRequestRef.current = requestId;
     setRelatedLoading(true);
-    setRelatedError(undefined);
     setVersions([]);
     setRuns([]);
     try {
       const [versionData, runData] = await Promise.all([listTeamVersions(selectedTeamId), listTeamRuns(selectedTeamId)]);
-      if (detailsRequestRef.current === requestId) {
-        setVersions(versionData);
-        setRuns(runData);
-      }
-    } catch (err) {
-      if (detailsRequestRef.current === requestId) {
-        const message = err instanceof Error ? err.message : "Failed to load team details";
-        setRelatedError(message);
-        toast.error(message);
-      }
+      setVersions(versionData);
+      setRuns(runData);
+    } catch {
+      toast.error(t("errors.failedToLoad"));
     } finally {
-      if (detailsRequestRef.current === requestId) {
-        setRelatedLoading(false);
-      }
-    }
-  }, [selectedTeamId]);
-
-  React.useEffect(() => {
-    void load();
-  }, [load]);
-
-  React.useEffect(() => {
-    if (!selectedTeamId) {
-      detailsRequestRef.current += 1;
-      setVersions([]);
-      setRuns([]);
-      setRelatedError(undefined);
-      return;
+      setRelatedLoading(false);
     }
-    setRunStatusFilter("all");
-    void reloadTeamDetails();
-  }, [reloadTeamDetails, selectedTeamId]);
+  }, [selectedTeamId, t]);
 
-  React.useEffect(() => {
-    if (!selectedTeamId && teams[0]) {
-      setSelectedTeamId(teams[0].id);
-    }
-  }, [selectedTeamId, teams]);
-
-  async function copyTeamCode() {
-    if (!selectedTeam) return;
-    await copyToClipboard(selectedTeam.code);
-    toast.success("Team code copied");
-  }
+  React.useEffect(() => { void load(); }, [load]);
+  React.useEffect(() => { if (selectedTeamId) void reloadDetails(); }, [selectedTeamId, reloadDetails]);
+  React.useEffect(() => { if (!selectedTeamId && teams[0]) setSelectedTeamId(teams[0].id); }, [selectedTeamId, teams]);
 
   function clearFilters() {
     setSearch("");
     setStatusFilter("all");
-    setTypeFilter("all");
     setSortMode("recent");
   }
 
-  function openVersionCreator() {
-    if (!selectedTeam) return;
-    setVersionOpen(true);
-  }
-
-  function openRunStarter() {
+  async function copyTeamCode() {
     if (!selectedTeam) return;
-    if (!latestVersion) {
-      setActiveTab("versions");
-      setVersionOpen(true);
-      toast.info("Create a team version before starting a run");
-      return;
-    }
-    setRunOpen(true);
+    await copyToClipboard(selectedTeam.code);
+    toast.success(t("teams.teamCodeCopied"));
   }
 
-  if (loading) return <LoadingSpinner label="Loading teams" />;
+  if (loading) return <LoadingSpinner label={t("common.loading")} />;
   if (error) return <ApiErrorState message={error} onRetry={() => void load()} />;
 
   return (
     <div className="space-y-6">
       <PageHeader
-        title="Teams"
-        description="Coordinate multi-agent teams, versions, policies, and collaborative runs from one operating console."
+        title={t("teams.title")}
+        description={t("teams.description")}
         actions={
           <>
-            <Button
-              variant="outline"
-              onClick={() => {
-                void load();
-                void reloadTeamDetails();
-              }}
-            >
-              <RefreshCw className="h-4 w-4" /> Refresh
+            <Button variant="outline" onClick={() => { void load(); if (selectedTeamId) void reloadDetails(); }}>
+              <RefreshCw className="h-4 w-4" /> {t("common.refresh")}
             </Button>
             <Button onClick={() => setCreateOpen(true)}>
-              <Users className="h-4 w-4" /> New Team
+              <Users className="h-4 w-4" /> {t("teams.newTeam")}
             </Button>
           </>
         }
       />
 
+      {/* Metric cards */}
       <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
-        <MetricCard label="Teams" value={teams.length} icon={Users} />
-        <MetricCard label="Active" value={activeTeams} icon={CheckCircle2} />
-        <MetricCard label="Draft" value={draftTeams} icon={Archive} />
-        <MetricCard label="Versions" value={versions.length} icon={Network} />
-        <MetricCard label="Failed Runs" value={failedRuns} icon={Activity} />
+        <MetricCard label={t("teams.title")} value={teams.length} icon={Users} />
+        <MetricCard label={t("common.active")} value={activeTeams} icon={CheckCircle2} />
+        <MetricCard label={t("common.draft")} value={draftTeams} icon={Archive} />
+        <MetricCard label={t("common.versions")} value={versions.length} icon={Network} />
+        <MetricCard label={t("teams.failedRuns")} value={failedRunCount} icon={Activity} />
       </div>
 
       <div className="grid gap-6 xl:grid-cols-[440px_1fr]">
+        {/* Left panel: team directory */}
         <Card>
           <CardHeader>
             <div className="flex items-start justify-between gap-3">
               <div>
-                <CardTitle>Team Directory</CardTitle>
+                <CardTitle>{t("teams.teamDirectory")}</CardTitle>
                 <CardDescription>
-                  {filtered.length} of {teams.length} teams shown
+                  {t("teams.teamsShown", { count: teams.length })} {filtered.length}
                 </CardDescription>
               </div>
               <SlidersHorizontal className="mt-1 h-4 w-4 text-muted-foreground" />
             </div>
           </CardHeader>
           <CardContent className="space-y-4">
-            <SearchInput value={search} onChange={setSearch} placeholder="Search by name, code, type, or description" />
+            <SearchInput value={search} onChange={setSearch} placeholder={t("teams.searchByNameCodeType")} />
             <div className="grid gap-3 sm:grid-cols-2">
               <Select
-                aria-label="Filter teams by status"
+                aria-label={t("teams.filterByStatus")}
                 value={statusFilter}
                 onChange={(event) => setStatusFilter(event.target.value as StatusFilter)}
                 options={[
-                  { value: "all", label: "All statuses" },
-                  { value: "active", label: "Active" },
-                  { value: "draft", label: "Draft" },
-                  { value: "archived", label: "Archived" },
+                  { value: "all", label: t("teams.allStatuses") },
+                  { value: "active", label: t("common.active") },
+                  { value: "draft", label: t("common.draft") },
+                  { value: "archived", label: t("common.archived") },
                 ]}
               />
               <Select
-                aria-label="Filter teams by type"
-                value={typeFilter}
-                onChange={(event) => setTypeFilter(event.target.value)}
-                options={[{ value: "all", label: "All types" }, ...teamTypes.map((type) => ({ value: type, label: readableLabel(type) }))]}
-              />
-              <Select
-                aria-label="Sort teams"
+                aria-label={t("teams.sortTeams")}
                 value={sortMode}
                 onChange={(event) => setSortMode(event.target.value as SortMode)}
                 options={[
-                  { value: "recent", label: "Newest first" },
-                  { value: "name", label: "Name" },
-                  { value: "status", label: "Status" },
+                  { value: "recent", label: t("teams.newestFirst") },
+                  { value: "name", label: t("common.name") },
+                  { value: "status", label: t("common.status") },
                 ]}
               />
-              <div className="grid grid-cols-2 gap-2">
-                <Button type="button" variant={viewMode === "list" ? "secondary" : "outline"} aria-label="List view" onClick={() => setViewMode("list")}>
-                  <List className="h-4 w-4" /> List
-                </Button>
-                <Button type="button" variant={viewMode === "grid" ? "secondary" : "outline"} aria-label="Grid view" onClick={() => setViewMode("grid")}>
-                  <Grid2X2 className="h-4 w-4" /> Grid
-                </Button>
-              </div>
             </div>
             {hasFilters ? (
               <Button type="button" variant="ghost" size="sm" onClick={clearFilters}>
-                Clear filters
+                {t("common.clearFilters")}
               </Button>
             ) : null}
 
             {filtered.length ? (
-              viewMode === "list" ? (
-                <div className="space-y-2">
-                  {filtered.map((team) => (
-                    <EntityListItem
-                      key={team.id}
-                      active={team.id === selectedTeamId}
-                      title={team.name}
-                      subtitle={`${team.code} - ${readableLabel(team.team_type)}`}
-                      meta={<StatusBadge status={team.status} />}
-                      onClick={() => {
-                        setSelectedTeamId(team.id);
-                        setActiveTab("overview");
-                      }}
-                    />
-                  ))}
-                </div>
-              ) : (
-                <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-1">
-                  {filtered.map((team) => (
-                    <TeamCard
-                      key={team.id}
-                      team={team}
-                      active={team.id === selectedTeamId}
-                      onOpen={() => {
-                        setSelectedTeamId(team.id);
-                        setActiveTab("overview");
-                      }}
-                    />
-                  ))}
-                </div>
-              )
+              <div className="space-y-2">
+                {filtered.map((team) => (
+                  <EntityListItem
+                    key={team.id}
+                    active={team.id === selectedTeamId}
+                    title={team.name}
+                    subtitle={`${readableLabel(team.team_type)} - ${team.code}`}
+                    meta={<StatusBadge status={team.status} />}
+                    onClick={() => { setSelectedTeamId(team.id); setActiveTab("overview"); }}
+                  />
+                ))}
+              </div>
             ) : (
-              <EmptyState
-                icon={Search}
-                title="No matching teams"
-                description="Adjust search or filters to find a matching team definition."
-                actionLabel={hasFilters ? "Clear filters" : undefined}
-                onAction={hasFilters ? clearFilters : undefined}
-              />
+              <EmptyState icon={Search} title={t("teams.noMatchingTeams")} description={t("teams.adjustFiltersTeam")} />
             )}
           </CardContent>
         </Card>
 
+        {/* Right panel: team details */}
         <Card>
           <CardHeader>
             <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
               <div className="min-w-0">
                 <div className="flex flex-wrap items-center gap-2">
-                  <CardTitle className="truncate text-lg">{selectedTeam?.name ?? "Team Cockpit"}</CardTitle>
+                  <CardTitle className="truncate text-lg">{selectedTeam?.name ?? t("teams.teamCockpit")}</CardTitle>
                   {selectedTeam ? <StatusBadge status={selectedTeam.status} /> : null}
                 </div>
                 <CardDescription className="mt-1">
-                  {selectedTeam ? `${readableLabel(selectedTeam.team_type)} - ${selectedTeam.code}` : "Select a team to inspect collaboration, versions, and runs."}
+                  {selectedTeam ? `${readableLabel(selectedTeam.team_type)} - ${selectedTeam.code}` : t("teams.selectTeamInspect")}
                 </CardDescription>
               </div>
               <div className="flex flex-wrap items-center gap-2">
-                <Button variant="outline" disabled={!selectedTeam} onClick={() => void copyTeamCode()}>
-                  <Copy className="h-4 w-4" /> Copy Code
-                </Button>
-                <Button variant="secondary" disabled={!selectedTeam} onClick={openVersionCreator}>
-                  <FileCode2 className="h-4 w-4" /> New Version
+                <Button variant="outline" size="sm" disabled={!selectedTeam} onClick={() => void copyTeamCode()}>
+                  <Copy className="h-4 w-4" /> {t("teams.copyCode")}
                 </Button>
-                <Button disabled={!selectedTeam} onClick={openRunStarter}>
-                  {latestVersion ? <Play className="h-4 w-4" /> : <FileCode2 className="h-4 w-4" />}
-                  {latestVersion ? "Run" : "Create Version"}
+                <Button variant="secondary" size="sm" disabled={!selectedTeam} onClick={() => setCreateOpen(true)}>
+                  <FileCode2 className="h-4 w-4" /> {t("teams.newVersion")}
                 </Button>
+                {selectedTeam && latestVersion ? (
+                  <Button size="sm" onClick={() => setActiveTab("runs")}>
+                    <Play className="h-4 w-4" /> {t("teams.run")}
+                  </Button>
+                ) : null}
               </div>
             </div>
           </CardHeader>
           <CardContent>
             {selectedTeam ? (
-              <div className="space-y-4">
-                {relatedError ? <InlineError message={relatedError} onRetry={() => void reloadTeamDetails()} /> : null}
-                <Tabs
-                  value={activeTab}
-                  onChange={setActiveTab}
-                  tabs={[
-                    {
-                      value: "overview",
-                      label: "Overview",
-                      content: (
-                        <TeamOverview
-                          team={selectedTeam}
-                          latestVersion={latestVersion}
-                          versionCount={versions.length}
-                          runCount={sortedRuns.length}
-                          failedRunCount={failedRuns}
-                          latestRun={sortedRuns[0]}
-                          loading={relatedLoading}
-                        />
-                      ),
-                    },
-                    {
-                      value: "members",
-                      label: "Members",
-                      content: <TeamMembers version={latestVersion} loading={relatedLoading} onCreateVersion={openVersionCreator} />,
-                    },
-                    {
-                      value: "versions",
-                      label: "Versions",
-                      content: <TeamVersions versions={sortedVersions} loading={relatedLoading} onCreateVersion={openVersionCreator} />,
-                    },
-                    {
-                      value: "runs",
-                      label: "Runs",
-                      content: (
-                        <TeamRuns
-                          runs={filteredRuns}
-                          loading={relatedLoading}
-                          statusFilter={runStatusFilter}
-                          onStatusFilterChange={setRunStatusFilter}
-                          onStartRun={openRunStarter}
-                          canStartRun={Boolean(latestVersion)}
-                        />
-                      ),
-                    },
-                    {
-                      value: "console",
-                      label: "Run Console",
-                      content: (
-                        <TeamRunConsole
-                          team={selectedTeam}
-                          versions={sortedVersions}
-                          runs={sortedRuns}
-                          loading={relatedLoading}
-                          onCreated={(run) => {
-                            setRuns((current) => [run, ...current]);
-                            setActiveTab("runs");
-                            void reloadTeamDetails();
-                          }}
-                          onCreateVersion={openVersionCreator}
-                        />
-                      ),
-                    },
-                    {
-                      value: "policy",
-                      label: "Policy",
-                      content: <TeamPolicy version={latestVersion} loading={relatedLoading} onCreateVersion={openVersionCreator} />,
-                    },
-                  ]}
-                />
-              </div>
+              <Tabs
+                value={activeTab}
+                onChange={setActiveTab}
+                tabs={[
+                  {
+                    value: "overview",
+                    label: t("common.overview"),
+                    content: (
+                      <TeamOverview
+                        team={selectedTeam}
+                        latestVersion={latestVersion}
+                        versionCount={versions.length}
+                        runCount={sortedRuns.length}
+                        failedRunCount={failedRunCount}
+                        latestRun={sortedRuns[0]}
+                      />
+                    ),
+                  },
+                  {
+                    value: "runs",
+                    label: t("common.runs"),
+                    content: (
+                      <TeamRuns
+                        teamId={selectedTeam.id}
+                        versions={sortedVersions}
+                        runs={sortedRuns}
+                        loading={relatedLoading}
+                        onRunCreated={(run) => { setRuns((current) => [run, ...current]); void reloadDetails(); }}
+                      />
+                    ),
+                  },
+                  {
+                    value: "versions",
+                    label: t("common.versions"),
+                    content: <TeamVersions versions={versions} loading={relatedLoading} />,
+                  },
+                ]}
+              />
             ) : (
-              <EmptyState icon={Bot} title="No teams" description="Create a team to start coordinating multiple specialized agents." actionLabel="New Team" onAction={() => setCreateOpen(true)} />
+              <EmptyState icon={Bot} title={t("teams.noTeams")} description={t("teams.createTeamStart")} actionLabel={t("teams.newTeam")} onAction={() => setCreateOpen(true)} />
             )}
           </CardContent>
         </Card>
@@ -485,796 +301,14 @@ export function TeamsPage() {
         onOpenChange={setCreateOpen}
         onCreated={(team) => {
           setSelectedTeamId(team.id);
-          setActiveTab("overview");
           setSearch("");
           void load();
         }}
       />
-      <CreateTeamVersionDialog
-        open={versionOpen}
-        onOpenChange={setVersionOpen}
-        teamId={selectedTeamId}
-        onCreated={(version) => {
-          setVersions((current) => [version, ...current]);
-          setActiveTab("console");
-          void reloadTeamDetails();
-        }}
-      />
-      <CreateTeamRunDialog
-        open={runOpen}
-        onOpenChange={setRunOpen}
-        teamId={selectedTeamId}
-        versions={sortedVersions}
-        onCreateVersion={openVersionCreator}
-        onCreated={(run) => {
-          setRuns((current) => [run, ...current]);
-          setActiveTab("runs");
-          void reloadTeamDetails();
-        }}
-      />
-    </div>
-  );
-}
-
-function TeamCard({ team, active, onOpen }: { team: TeamDefinition; active: boolean; onOpen: () => void }) {
-  return (
-    <button
-      type="button"
-      onClick={onOpen}
-      className={`min-h-36 rounded-md border p-4 text-left transition hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-primary ${
-        active ? "border-primary/50 bg-primary/10" : "border-border bg-muted/30"
-      }`}
-    >
-      <div className="flex items-start justify-between gap-3">
-        <div className="min-w-0">
-          <p className="truncate text-sm font-semibold">{team.name}</p>
-          <p className="mt-1 truncate font-mono text-xs text-muted-foreground">{team.code}</p>
-        </div>
-        <StatusBadge status={team.status} />
-      </div>
-      <p className="mt-3 line-clamp-2 text-sm leading-6 text-muted-foreground">{team.description ?? "No description provided."}</p>
-      <div className="mt-4 flex flex-wrap items-center gap-2">
-        <Badge className="border-border bg-muted/60 text-muted-foreground">{readableLabel(team.team_type)}</Badge>
-        <Badge className="border-border bg-muted/60 text-muted-foreground">{relativeTime(team.created_time)}</Badge>
-      </div>
-    </button>
-  );
-}
-
-function InlineError({ message, onRetry }: { message: string; onRetry: () => void }) {
-  return (
-    <div className="flex flex-col gap-3 rounded-md border border-red-500/25 bg-red-500/5 p-3 text-sm sm:flex-row sm:items-center sm:justify-between">
-      <p className="text-foreground">{message}</p>
-      <Button type="button" size="sm" variant="outline" onClick={onRetry}>
-        <RefreshCw className="h-4 w-4" /> Retry
-      </Button>
-    </div>
-  );
-}
-
-function TeamOverview({
-  team,
-  latestVersion,
-  versionCount,
-  runCount,
-  failedRunCount,
-  latestRun,
-  loading,
-}: {
-  team: TeamDefinition;
-  latestVersion?: TeamVersion;
-  versionCount: number;
-  runCount: number;
-  failedRunCount: number;
-  latestRun?: TeamRun;
-  loading: boolean;
-}) {
-  return (
-    <div className="space-y-5">
-      <div className="grid gap-4 md:grid-cols-4">
-        <SummaryTile label="Versions" value={loading ? "..." : versionCount} />
-        <SummaryTile label="Runs" value={loading ? "..." : runCount} />
-        <SummaryTile label="Failures" value={loading ? "..." : failedRunCount} />
-        <SummaryTile label="Latest" value={loading ? "..." : latestVersion ? `v${latestVersion.version_no}` : "None"} />
-      </div>
-      <div className="grid gap-4 text-sm md:grid-cols-2">
-        <Detail label="Code" value={team.code} mono />
-        <Detail label="Type" value={readableLabel(team.team_type)} />
-        <Detail label="Owner" value={team.owner_user_id ?? "Unassigned"} mono />
-        <Detail label="Created" value={formatDateTime(team.created_time)} />
-        <Detail label="Coordination" value={latestVersion ? readableLabel(latestVersion.coordination_mode) : "No version"} />
-        <Detail label="Last Run" value={latestRun ? relativeTime(latestRun.created_time) : "No runs"} />
-        <div className="md:col-span-2">
-          <p className="text-muted-foreground">Description</p>
-          <p className="mt-1 leading-6">{team.description ?? "No description provided."}</p>
-        </div>
-        <div className="md:col-span-2">
-          <p className="text-muted-foreground">Current Objective</p>
-          <p className="mt-1 leading-6">{latestVersion?.objective ?? "Create a version to define how this team collaborates."}</p>
-        </div>
-      </div>
-    </div>
-  );
-}
-
-function TeamMembers({
-  version,
-  loading,
-  onCreateVersion,
-}: {
-  version?: TeamVersion;
-  loading: boolean;
-  onCreateVersion: () => void;
-}) {
-  if (loading) return <LoadingSpinner label="Loading members" />;
-  if (!version) {
-    return <EmptyState icon={Users} title="No version selected" description="Create a team version before defining member roles and collaboration shape." actionLabel="New Version" onAction={onCreateVersion} />;
-  }
-  if (!version.member_refs_json.length) {
-    return (
-      <EmptyState
-        icon={Users}
-        title="No members configured"
-        description="Create a new version with member rows to define who participates and what each role is responsible for."
-        actionLabel="New Version"
-        onAction={onCreateVersion}
-      />
-    );
-  }
-  return (
-    <div className="grid gap-3 md:grid-cols-2">
-      {version.member_refs_json.map((member, index) => (
-        <div key={index} className="rounded-md border border-border bg-muted/30 p-4">
-          <div className="flex items-start justify-between gap-3">
-            <div className="min-w-0">
-              <p className="truncate text-sm font-semibold">{getJsonString(member, "name") ?? getJsonString(member, "agent_name") ?? `Member ${index + 1}`}</p>
-              <p className="mt-1 truncate font-mono text-xs text-muted-foreground">{getJsonString(member, "agent_id") ?? getJsonString(member, "id") ?? "unbound-agent"}</p>
-            </div>
-            <Badge className="border-border bg-muted/60 text-muted-foreground">{getJsonString(member, "role") ?? "member"}</Badge>
-          </div>
-          <p className="mt-3 text-sm leading-6 text-muted-foreground">{getJsonString(member, "description") ?? getJsonString(member, "responsibility") ?? "No responsibility summary provided."}</p>
-        </div>
-      ))}
     </div>
   );
 }
 
-function TeamVersions({
-  versions,
-  loading,
-  onCreateVersion,
-}: {
-  versions: TeamVersion[];
-  loading: boolean;
-  onCreateVersion: () => void;
-}) {
-  if (loading) return <LoadingSpinner label="Loading versions" />;
-  if (!versions.length) {
-    return <EmptyState icon={FileCode2} title="No versions" description="Create a version to define objective, coordination mode, members, and execution policy." actionLabel="New Version" onAction={onCreateVersion} />;
-  }
-  return (
-    <div className="space-y-3">
-      {versions.map((version) => (
-        <div key={version.id} className="rounded-md border border-border bg-muted/30 p-4">
-          <div className="flex flex-wrap items-start justify-between gap-3">
-            <div>
-              <div className="flex flex-wrap items-center gap-2">
-                <p className="font-medium">v{version.version_no}</p>
-                <StatusBadge status={version.status} />
-                <Badge className="border-border bg-muted/60 text-muted-foreground">{readableLabel(version.coordination_mode)}</Badge>
-              </div>
-              <p className="mt-2 text-sm leading-6 text-muted-foreground">{version.objective ?? "No objective provided."}</p>
-            </div>
-            <p className="text-xs text-muted-foreground">{formatDateTime(version.created_time)}</p>
-          </div>
-          <div className="mt-4 grid gap-3 text-sm md:grid-cols-3">
-            <Detail label="Members" value={String(version.member_refs_json.length)} />
-            <Detail label="Published" value={version.published_time ? formatDateTime(version.published_time) : "Not published"} />
-            <Detail label="Version ID" value={truncateMiddle(version.id, 28)} mono />
-          </div>
-        </div>
-      ))}
-    </div>
-  );
-}
-
-function TeamRuns({
-  runs,
-  loading,
-  statusFilter,
-  onStatusFilterChange,
-  onStartRun,
-  canStartRun,
-}: {
-  runs: TeamRun[];
-  loading: boolean;
-  statusFilter: RunStatusFilter;
-  onStatusFilterChange: (status: RunStatusFilter) => void;
-  onStartRun: () => void;
-  canStartRun: boolean;
-}) {
-  if (loading) return <LoadingSpinner label="Loading runs" />;
-  return (
-    <div className="space-y-4">
-      <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
-        <Select
-          className="sm:w-56"
-          aria-label="Filter runs by status"
-          value={statusFilter}
-          onChange={(event) => onStatusFilterChange(event.target.value as RunStatusFilter)}
-          options={[
-            { value: "all", label: "All run statuses" },
-            { value: "queued", label: "Queued" },
-            { value: "running", label: "Running" },
-            { value: "completed", label: "Completed" },
-            { value: "failed", label: "Failed" },
-            { value: "cancelled", label: "Cancelled" },
-          ]}
-        />
-        <Button type="button" variant="secondary" onClick={onStartRun}>
-          {canStartRun ? <Play className="h-4 w-4" /> : <FileCode2 className="h-4 w-4" />}
-          {canStartRun ? "Start Run" : "Create Version"}
-        </Button>
-      </div>
-      {runs.length ? (
-        <div className="space-y-2">
-          {runs.map((run) => (
-            <div key={run.id} className="rounded-md border border-border bg-muted/30 p-4">
-              <div className="flex flex-wrap items-center justify-between gap-3">
-                <div className="min-w-0">
-                  <p className="truncate font-mono text-xs">{truncateMiddle(run.id, 32)}</p>
-                  <p className="mt-1 truncate text-sm text-muted-foreground">{run.input_text ?? "Structured input payload"}</p>
-                </div>
-                <StatusBadge status={run.status} />
-              </div>
-              <div className="mt-3 grid gap-3 text-sm md:grid-cols-3">
-                <Detail label="Created" value={relativeTime(run.created_time)} />
-                <Detail label="Version" value={truncateMiddle(run.team_version_id, 24)} mono />
-                <Detail label="Output" value={run.output_text ?? "No output yet"} />
-              </div>
-            </div>
-          ))}
-        </div>
-      ) : (
-        <EmptyState
-          icon={Activity}
-          title="No runs"
-          description={canStartRun ? "No run records match this team and status filter." : "Create a version before this team can run work."}
-          actionLabel={canStartRun ? "Start Run" : "Create Version"}
-          onAction={onStartRun}
-        />
-      )}
-    </div>
-  );
-}
-
-function TeamRunConsole({
-  team,
-  versions,
-  runs,
-  loading,
-  onCreated,
-  onCreateVersion,
-}: {
-  team: TeamDefinition;
-  versions: TeamVersion[];
-  runs: TeamRun[];
-  loading: boolean;
-  onCreated: (run: TeamRun) => void;
-  onCreateVersion: () => void;
-}) {
-  const tenantId = useAuthStore((state) => state.tenantId);
-  const [versionId, setVersionId] = React.useState(versions[0]?.id ?? "");
-  const [inputText, setInputText] = React.useState(RUN_TEMPLATE_OPTIONS[0] ?? "");
-  const [submitting, setSubmitting] = React.useState(false);
-
-  React.useEffect(() => {
-    setVersionId((current) => (versions.some((version) => version.id === current) ? current : versions[0]?.id ?? ""));
-  }, [versions]);
-
-  async function submit(event: React.FormEvent) {
-    event.preventDefault();
-    if (!versionId || !inputText.trim()) return;
-    setSubmitting(true);
-    try {
-      const run = await createTeamRun({ tenant_id: tenantId, team_id: team.id, team_version_id: versionId, input_text: inputText });
-      toast.success("Team run started");
-      setInputText("");
-      onCreated(run);
-    } catch (err) {
-      toast.error(err instanceof Error ? err.message : "Failed to start team run");
-    } finally {
-      setSubmitting(false);
-    }
-  }
-
-  if (loading) return <LoadingSpinner label="Preparing console" />;
-  if (!versions.length) {
-    return (
-      <EmptyState
-        icon={Play}
-        title="No runnable version"
-        description="Create a team version before starting a collaborative run."
-        actionLabel="Create Version"
-        onAction={onCreateVersion}
-      />
-    );
-  }
-
-  return (
-    <div className="grid gap-4 lg:grid-cols-[1fr_360px]">
-      <form className="space-y-4" onSubmit={submit}>
-        <Field label="Version">
-          <Select
-            value={versionId}
-            onChange={(event) => setVersionId(event.target.value)}
-            options={versions.map((version) => ({
-              value: version.id,
-              label: `v${version.version_no} - ${readableLabel(version.coordination_mode)} - ${version.status}`,
-            }))}
-          />
-        </Field>
-        <div className="space-y-2">
-          <p className="text-sm text-muted-foreground">Task templates</p>
-          <div className="flex flex-wrap gap-2">
-            {RUN_TEMPLATE_OPTIONS.map((template, index) => (
-              <Button key={template} type="button" size="sm" variant="outline" onClick={() => setInputText(template)}>
-                Template {index + 1}
-              </Button>
-            ))}
-          </div>
-        </div>
-        <Field label="Run input">
-          <Textarea required className="min-h-40" value={inputText} onChange={(event) => setInputText(event.target.value)} />
-        </Field>
-        <Button disabled={submitting || !versionId || !inputText.trim()}>
-          <Play className="h-4 w-4" /> {submitting ? "Starting..." : "Start Team Run"}
-        </Button>
-      </form>
-      <div className="rounded-md border border-border bg-muted/30 p-4">
-        <div className="mb-3 flex items-center gap-2">
-          <Clock className="h-4 w-4 text-muted-foreground" />
-          <h3 className="text-sm font-semibold">Latest Result</h3>
-        </div>
-        {runs[0] ? (
-          <div className="space-y-3">
-            <div className="flex items-center justify-between gap-3">
-              <p className="truncate font-mono text-xs">{truncateMiddle(runs[0].id, 28)}</p>
-              <StatusBadge status={runs[0].status} />
-            </div>
-            <p className="text-sm leading-6 text-muted-foreground">{runs[0].output_text ?? runs[0].input_text ?? "No output yet."}</p>
-          </div>
-        ) : (
-          <p className="text-sm text-muted-foreground">Start a run to preview the newest result for this team.</p>
-        )}
-      </div>
-    </div>
-  );
-}
-
-function TeamPolicy({
-  version,
-  loading,
-  onCreateVersion,
-}: {
-  version?: TeamVersion;
-  loading: boolean;
-  onCreateVersion: () => void;
-}) {
-  if (loading) return <LoadingSpinner label="Loading policy" />;
-  if (!version) {
-    return <EmptyState icon={Settings2} title="No policy" description="Create a team version to inspect member policy and coordination settings." actionLabel="New Version" onAction={onCreateVersion} />;
-  }
-  return (
-    <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
-      <section className="rounded-md border border-border bg-muted/30 p-4">
-        <div className="mb-3 flex items-center gap-2">
-          <Layers3 className="h-4 w-4 text-muted-foreground" />
-          <h3 className="text-sm font-semibold">Coordination</h3>
-        </div>
-        <div className="grid gap-3 text-sm">
-          <Detail label="Mode" value={readableLabel(version.coordination_mode)} />
-          <Detail label="Objective" value={version.objective ?? "No objective provided."} />
-          <Detail label="Status" value={version.status} />
-        </div>
-      </section>
-      <PolicyTile label="Max Rounds" value={getJsonString(version.policy_json, "max_rounds") ?? "Not set"} />
-      <PolicyTile label="Handoff" value={readableLabel(getJsonString(version.policy_json, "handoff") ?? "Not set")} />
-      <PolicyTile label="Failure Mode" value={readableLabel(getJsonString(version.policy_json, "failure_mode") ?? "Not set")} />
-    </div>
-  );
-}
-
-function PolicyTile({ label, value }: { label: string; value: string }) {
-  return (
-    <section className="rounded-md border border-border bg-muted/30 p-4">
-      <p className="text-sm text-muted-foreground">{label}</p>
-      <p className="mt-2 text-lg font-semibold">{value}</p>
-    </section>
-  );
-}
-
-function Detail({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
-  return (
-    <div className="min-w-0">
-      <p className="text-muted-foreground">{label}</p>
-      <p className={mono ? "mt-1 truncate font-mono text-xs" : "mt-1 truncate"}>{value}</p>
-    </div>
-  );
-}
-
-function SummaryTile({ label, value }: { label: string; value: string | number }) {
-  return (
-    <div className="rounded-md border border-border bg-muted/30 p-3">
-      <p className="text-xs text-muted-foreground">{label}</p>
-      <p className="mt-1 text-xl font-semibold tabular-nums">{value}</p>
-    </div>
-  );
-}
-
-function CreateTeamVersionDialog({
-  open,
-  onOpenChange,
-  teamId,
-  onCreated,
-}: {
-  open: boolean;
-  onOpenChange: (open: boolean) => void;
-  teamId?: string;
-  onCreated: (version: TeamVersion) => void;
-}) {
-  const tenantId = useAuthStore((state) => state.tenantId);
-  const [form, setForm] = React.useState({ coordination_mode: "supervisor", objective: "" });
-  const [members, setMembers] = React.useState<MemberDraft[]>(DEFAULT_MEMBER_DRAFTS);
-  const [policy, setPolicy] = React.useState<PolicyDraft>(DEFAULT_POLICY_DRAFT);
-  const [formError, setFormError] = React.useState<string>();
-  const [submitting, setSubmitting] = React.useState(false);
-
-  async function submit(event: React.FormEvent) {
-    event.preventDefault();
-    if (!teamId) return;
-    const versionPayload = buildVersionConfig(members, policy);
-    if (!versionPayload.ok) {
-      setFormError(versionPayload.message);
-      return;
-    }
-    setSubmitting(true);
-    setFormError(undefined);
-    try {
-      const version = await createTeamVersion({
-        tenant_id: tenantId,
-        team_id: teamId,
-        coordination_mode: form.coordination_mode,
-        objective: form.objective,
-        member_refs: versionPayload.memberRefs,
-        policy_json: versionPayload.policyJson,
-        status: "draft",
-      });
-      toast.success("Team version created");
-      onOpenChange(false);
-      setForm({ coordination_mode: "supervisor", objective: "" });
-      setMembers(DEFAULT_MEMBER_DRAFTS);
-      setPolicy(DEFAULT_POLICY_DRAFT);
-      onCreated(version);
-    } catch (err) {
-      toast.error(err instanceof Error ? err.message : "Failed to create team version");
-    } finally {
-      setSubmitting(false);
-    }
-  }
-
-  return (
-    <Dialog open={open} onOpenChange={onOpenChange} title="Create Team Version">
-      <form className="space-y-4" onSubmit={submit}>
-        <Field label="Coordination mode">
-          <Select
-            value={form.coordination_mode}
-            onChange={(event) => setForm({ ...form, coordination_mode: event.target.value })}
-            options={COORDINATION_MODE_OPTIONS}
-          />
-        </Field>
-        <Field label="Objective">
-          <Textarea
-            value={form.objective}
-            placeholder="Describe what this version should coordinate and optimize for."
-            onChange={(event) => setForm({ ...form, objective: event.target.value })}
-          />
-        </Field>
-        <MemberDraftEditor members={members} onChange={setMembers} />
-        <PolicyDraftEditor policy={policy} onChange={setPolicy} />
-        {formError ? <p className="rounded-md border border-red-500/25 bg-red-500/5 p-3 text-sm text-foreground">{formError}</p> : null}
-        <div className="flex justify-end gap-2">
-          <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
-            Cancel
-          </Button>
-          <Button disabled={submitting || !teamId}>{submitting ? "Creating..." : "Create Version"}</Button>
-        </div>
-      </form>
-    </Dialog>
-  );
-}
-
-function MemberDraftEditor({ members, onChange }: { members: MemberDraft[]; onChange: (members: MemberDraft[]) => void }) {
-  function updateMember(index: number, patch: Partial<MemberDraft>) {
-    onChange(members.map((member, memberIndex) => (memberIndex === index ? { ...member, ...patch } : member)));
-  }
-
-  function removeMember(index: number) {
-    onChange(members.filter((_, memberIndex) => memberIndex !== index));
-  }
-
-  return (
-    <section className="space-y-3">
-      <div className="flex items-center justify-between gap-3">
-        <h3 className="text-sm font-medium">Members</h3>
-        <Button
-          type="button"
-          size="sm"
-          variant="outline"
-          onClick={() => onChange([...members, { role: "worker", agent_id: "", responsibility: "" }])}
-        >
-          <Users className="h-4 w-4" /> Add Member
-        </Button>
-      </div>
-      <div className="space-y-3">
-        {members.map((member, index) => (
-          <div key={index} className="rounded-md border border-border bg-muted/30 p-3">
-            <div className="grid gap-3 md:grid-cols-[150px_1fr_auto]">
-              <Select
-                aria-label={`Role for member ${index + 1}`}
-                value={member.role}
-                onChange={(event) => updateMember(index, { role: event.target.value })}
-                options={[
-                  { value: "supervisor", label: "Supervisor" },
-                  { value: "worker", label: "Worker" },
-                  { value: "reviewer", label: "Reviewer" },
-                  { value: "planner", label: "Planner" },
-                ]}
-              />
-              <Input
-                aria-label={`Agent id for member ${index + 1}`}
-                value={member.agent_id}
-                placeholder="Agent ID"
-                onChange={(event) => updateMember(index, { agent_id: event.target.value })}
-              />
-              <Button type="button" size="sm" variant="ghost" disabled={members.length <= 1} onClick={() => removeMember(index)}>
-                Remove
-              </Button>
-            </div>
-            <Textarea
-              className="mt-3 min-h-20"
-              aria-label={`Responsibility for member ${index + 1}`}
-              value={member.responsibility}
-              placeholder="Responsibility"
-              onChange={(event) => updateMember(index, { responsibility: event.target.value })}
-            />
-          </div>
-        ))}
-      </div>
-    </section>
-  );
-}
-
-function PolicyDraftEditor({ policy, onChange }: { policy: PolicyDraft; onChange: (policy: PolicyDraft) => void }) {
-  return (
-    <section className="space-y-3">
-      <h3 className="text-sm font-medium">Policy</h3>
-      <div className="grid gap-3 md:grid-cols-3">
-        <Field label="Max rounds">
-          <Input
-            type="number"
-            min={1}
-            max={20}
-            value={policy.max_rounds}
-            onChange={(event) => onChange({ ...policy, max_rounds: event.target.value })}
-          />
-        </Field>
-        <Field label="Handoff">
-          <Select
-            value={policy.handoff}
-            onChange={(event) => onChange({ ...policy, handoff: event.target.value })}
-            options={[
-              { value: "supervisor", label: "Supervisor" },
-              { value: "round_robin", label: "Round Robin" },
-              { value: "parallel_merge", label: "Parallel Merge" },
-            ]}
-          />
-        </Field>
-        <Field label="Failure mode">
-          <Select
-            value={policy.failure_mode}
-            onChange={(event) => onChange({ ...policy, failure_mode: event.target.value })}
-            options={[
-              { value: "stop_on_critical", label: "Stop on Critical" },
-              { value: "continue_with_warning", label: "Continue with Warning" },
-              { value: "retry_once", label: "Retry Once" },
-            ]}
-          />
-        </Field>
-      </div>
-    </section>
-  );
-}
-
-function CreateTeamRunDialog({
-  open,
-  onOpenChange,
-  teamId,
-  versions,
-  onCreateVersion,
-  onCreated,
-}: {
-  open: boolean;
-  onOpenChange: (open: boolean) => void;
-  teamId?: string;
-  versions: TeamVersion[];
-  onCreateVersion: () => void;
-  onCreated: (run: TeamRun) => void;
-}) {
-  const tenantId = useAuthStore((state) => state.tenantId);
-  const [versionId, setVersionId] = React.useState(versions[0]?.id ?? "");
-  const [inputText, setInputText] = React.useState("");
-  const [submitting, setSubmitting] = React.useState(false);
-
-  React.useEffect(() => {
-    setVersionId((current) => (versions.some((version) => version.id === current) ? current : versions[0]?.id ?? ""));
-  }, [versions]);
-
-  async function submit(event: React.FormEvent) {
-    event.preventDefault();
-    if (!teamId || !versionId || !inputText.trim()) return;
-    setSubmitting(true);
-    try {
-      const run = await createTeamRun({ tenant_id: tenantId, team_id: teamId, team_version_id: versionId, input_text: inputText });
-      toast.success("Team run started");
-      onOpenChange(false);
-      setInputText("");
-      onCreated(run);
-    } catch (err) {
-      toast.error(err instanceof Error ? err.message : "Failed to start team run");
-    } finally {
-      setSubmitting(false);
-    }
-  }
-
-  if (!versions.length) {
-    return (
-      <Dialog open={open} onOpenChange={onOpenChange} title="Start Team Run">
-        <EmptyState
-          icon={Play}
-          title="No runnable version"
-          description="Create a team version before starting a collaborative run."
-          actionLabel="Create Version"
-          onAction={() => {
-            onOpenChange(false);
-            onCreateVersion();
-          }}
-        />
-      </Dialog>
-    );
-  }
-
-  return (
-    <Dialog open={open} onOpenChange={onOpenChange} title="Start Team Run">
-      <form className="space-y-4" onSubmit={submit}>
-        <Field label="Version">
-          <Select
-            value={versionId}
-            onChange={(event) => setVersionId(event.target.value)}
-            options={versions.map((version) => ({
-              value: version.id,
-              label: `v${version.version_no} - ${readableLabel(version.coordination_mode)} - ${version.status}`,
-            }))}
-          />
-        </Field>
-        <Field label="Input">
-          <Textarea required className="min-h-32" value={inputText} onChange={(event) => setInputText(event.target.value)} />
-        </Field>
-        <div className="flex justify-end gap-2">
-          <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
-            Cancel
-          </Button>
-          <Button disabled={submitting || !teamId || !versionId || !inputText.trim()}>{submitting ? "Starting..." : "Start Run"}</Button>
-        </div>
-      </form>
-    </Dialog>
-  );
-}
-
-function CreateTeamDialog({ open, onOpenChange, onCreated }: { open: boolean; onOpenChange: (open: boolean) => void; onCreated: (team: TeamDefinition) => void }) {
-  const { tenantId, userId } = useAuthStore();
-  const [form, setForm] = React.useState({ name: "", description: "", team_type: "collaborative" });
-  const [submitting, setSubmitting] = React.useState(false);
-
-  async function submit(event: React.FormEvent) {
-    event.preventDefault();
-    setSubmitting(true);
-    try {
-      const team = await createTeam({ tenant_id: tenantId, owner_user_id: userId, ...form });
-      toast.success("Team created");
-      onOpenChange(false);
-      setForm({ name: "", description: "", team_type: "collaborative" });
-      onCreated(team);
-    } catch (err) {
-      toast.error(err instanceof Error ? err.message : "Failed to create team");
-    } finally {
-      setSubmitting(false);
-    }
-  }
-
-  return (
-    <Dialog open={open} onOpenChange={onOpenChange} title="Create Team">
-      <form className="space-y-4" onSubmit={submit}>
-        <Field label="Name">
-          <Input required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} />
-        </Field>
-        <Field label="Type">
-          <Select value={form.team_type} onChange={(event) => setForm({ ...form, team_type: event.target.value })} options={TEAM_TYPE_OPTIONS} />
-        </Field>
-        <Field label="Description">
-          <Textarea value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} />
-        </Field>
-        <div className="flex justify-end gap-2">
-          <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
-            Cancel
-          </Button>
-          <Button disabled={submitting || !form.name.trim()}>{submitting ? "Creating..." : "Create"}</Button>
-        </div>
-      </form>
-    </Dialog>
-  );
-}
-
-function Field({ label, children }: { label: string; children: React.ReactNode }) {
-  return (
-    <label className="block space-y-2 text-sm">
-      <span className="text-muted-foreground">{label}</span>
-      {children}
-    </label>
-  );
-}
-
 function readableLabel(value: string) {
-  return value
-    .split(/[_-]+/)
-    .filter(Boolean)
-    .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
-    .join(" ");
-}
-
-function getJsonString(value: JSONObject, key: string) {
-  const item = value[key];
-  if (typeof item === "string" || typeof item === "number" || typeof item === "boolean") return String(item);
-  return undefined;
-}
-
-function buildVersionConfig(members: MemberDraft[], policy: PolicyDraft):
-  | { ok: true; memberRefs: JSONObject[]; policyJson: JSONObject }
-  | { ok: false; message: string } {
-  const normalizedMembers = members
-    .map((member) => ({
-      role: member.role.trim(),
-      agent_id: member.agent_id.trim(),
-      responsibility: member.responsibility.trim(),
-    }))
-    .filter((member) => member.role || member.agent_id || member.responsibility);
-  if (!normalizedMembers.length) {
-    return { ok: false, message: "Add at least one team member." };
-  }
-  if (normalizedMembers.some((member) => !member.agent_id)) {
-    return { ok: false, message: "Each member needs an Agent ID." };
-  }
-  const maxRounds = Number(policy.max_rounds);
-  if (!Number.isInteger(maxRounds) || maxRounds < 1 || maxRounds > 20) {
-    return { ok: false, message: "Max rounds must be a whole number from 1 to 20." };
-  }
-  return {
-    ok: true,
-    memberRefs: normalizedMembers,
-    policyJson: {
-      max_rounds: maxRounds,
-      handoff: policy.handoff,
-      failure_mode: policy.failure_mode,
-    },
-  };
+  return value.split(/[_-]+/).filter(Boolean).map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(" ");
 }

+ 308 - 0
web/src/pages/teams/components/CreateTeamDialog.tsx

@@ -0,0 +1,308 @@
+import * as React from "react";
+import { useTranslation } from "react-i18next";
+import { ListPlus, Minus, Settings2, Users } from "lucide-react";
+import { listAgents } from "@/api/agents";
+import { createTeam, createTeamVersion } from "@/api";
+import { Button } from "@/components/ui/button";
+import { Dialog } from "@/components/ui/dialog";
+import { Input, Textarea } from "@/components/ui/input";
+import { Select } from "@/components/ui/select";
+import { toast } from "@/components/ui/toaster";
+import { useAuthStore } from "@/stores/auth";
+import type { AgentDefinition, JSONObject, TeamDefinition } from "@/types";
+
+type MemberDraft = {
+  role: string;
+  agent_id: string;
+  responsibility: string;
+};
+
+type PolicyDraft = {
+  max_rounds: string;
+  handoff: string;
+  failure_mode: string;
+};
+
+const DEFAULT_POLICY: PolicyDraft = {
+  max_rounds: "3",
+  handoff: "supervisor",
+  failure_mode: "stop_on_critical",
+};
+
+export function CreateTeamDialog({
+  open,
+  onOpenChange,
+  onCreated,
+}: {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  onCreated: (team: TeamDefinition) => void;
+}) {
+  const { t } = useTranslation();
+  const { userId } = useAuthStore();
+  const [form, setForm] = React.useState({ name: "", description: "", coordination_mode: "supervisor", objective: "" });
+  const [members, setMembers] = React.useState<MemberDraft[]>([]);
+  const [policy, setPolicy] = React.useState<PolicyDraft>(DEFAULT_POLICY);
+  const [agents, setAgents] = React.useState<AgentDefinition[]>([]);
+  const [formError, setFormError] = React.useState<string>();
+  const [submitting, setSubmitting] = React.useState(false);
+
+  React.useEffect(() => {
+    if (!open) return;
+    void listAgents().then(setAgents).catch(() => {});
+  }, [open]);
+
+  function reset() {
+    setForm({ name: "", description: "", coordination_mode: "supervisor", objective: "" });
+    setMembers([]);
+    setPolicy(DEFAULT_POLICY);
+    setFormError(undefined);
+  }
+
+  async function submit(event: React.FormEvent) {
+    event.preventDefault();
+    if (!form.name.trim()) return;
+
+    const versionPayload = buildVersionConfig(members, policy, t);
+    if (!versionPayload.ok) {
+      setFormError(versionPayload.message);
+      return;
+    }
+
+    setSubmitting(true);
+    setFormError(undefined);
+    try {
+      const team = await createTeam({ name: form.name, code: "", description: form.description || undefined, team_type: "collaborative", owner_user_id: userId });
+      await createTeamVersion({
+        team_id: team.id,
+        coordination_mode: form.coordination_mode,
+        objective: form.objective || undefined,
+        member_refs: versionPayload.memberRefs,
+        policy_json: versionPayload.policyJson,
+        status: "draft",
+      });
+      toast.success(t("teams.teamCreated"));
+      onOpenChange(false);
+      reset();
+      onCreated(team);
+    } catch (err) {
+      toast.error(err instanceof Error ? err.message : t("teams.failedCreateTeam"));
+    } finally {
+      setSubmitting(false);
+    }
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={(value) => { if (!value) reset(); onOpenChange(value); }} title={t("teams.newTeam")} className="max-w-5xl">
+      <form className="space-y-5" onSubmit={submit}>
+        {/* Top row: basic info (left) + members (right) */}
+        <div className="grid gap-6 lg:grid-cols-[1fr_1fr]">
+          {/* Left: basic info */}
+          <div className="space-y-4">
+            <SectionHeader icon={<ListPlus className="h-4 w-4" />} title={t("teams.basicInfo")} />
+            <div className="space-y-4">
+              <Field label={t("common.name")}>
+                <Input required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} />
+              </Field>
+              <Field label={t("teams.coordinationMode")}>
+                <Select
+                  value={form.coordination_mode}
+                  onChange={(event) => setForm({ ...form, coordination_mode: event.target.value })}
+                  options={[
+                    { value: "supervisor", label: t("teams.supervisor") },
+                    { value: "pipeline", label: t("teams.pipeline") },
+                    { value: "parallel", label: t("teams.parallel") },
+                    { value: "debate", label: t("teams.debate") },
+                  ]}
+                />
+              </Field>
+              <Field label={t("common.description")}>
+                <Textarea value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} />
+              </Field>
+              <Field label={t("teams.objective")}>
+                <Textarea value={form.objective} placeholder={t("teams.describeVersion")} onChange={(event) => setForm({ ...form, objective: event.target.value })} />
+              </Field>
+            </div>
+          </div>
+
+          {/* Right: members */}
+          <MemberEditor members={members} onChange={setMembers} agents={agents} />
+        </div>
+
+        {/* Bottom: policy (full width) */}
+        <PolicyEditor policy={policy} onChange={setPolicy} />
+
+        {formError ? <p className="rounded-md border border-red-500/25 bg-red-500/5 p-3 text-sm text-foreground">{formError}</p> : null}
+        <div className="flex justify-end gap-2 pt-1">
+          <Button type="button" variant="ghost" onClick={() => { reset(); onOpenChange(false); }}>
+            {t("common.cancel")}
+          </Button>
+          <Button disabled={submitting || !form.name.trim()}>{submitting ? t("common.creating") : t("common.create")}</Button>
+        </div>
+      </form>
+    </Dialog>
+  );
+}
+
+function MemberEditor({ members, onChange, agents }: { members: MemberDraft[]; onChange: (members: MemberDraft[]) => void; agents: AgentDefinition[] }) {
+  const { t } = useTranslation();
+  function update(index: number, patch: Partial<MemberDraft>) {
+    onChange(members.map((m, i) => (i === index ? { ...m, ...patch } : m)));
+  }
+  function remove(index: number) {
+    onChange(members.filter((_, i) => i !== index));
+  }
+
+  const agentOptions = React.useMemo(
+    () => [
+      { value: "", label: t("teams.selectAgent") },
+      ...agents.map((a) => ({ value: a.id, label: `${a.name} (${a.code})` })),
+    ],
+    [agents, t],
+  );
+
+  return (
+    <section className="space-y-4">
+      <SectionHeader
+        icon={<Users className="h-4 w-4" />}
+        title={t("teams.members")}
+        action={
+          <Button type="button" size="sm" variant="outline" onClick={() => onChange([...members, { role: "worker", agent_id: "", responsibility: "" }])}>
+            <span className="mr-1">+</span> {t("teams.addMember")}
+          </Button>
+        }
+      />
+      {members.length ? (
+        <div className="space-y-3">
+          {members.map((member, index) => (
+            <div key={index} className="rounded-md border border-border bg-muted/30 px-4 py-3">
+              <div className="flex items-center gap-3">
+                <div className="w-28 shrink-0">
+                  <Select
+                    aria-label={`${t("teams.role")} ${index + 1}`}
+                    value={member.role}
+                    onChange={(event) => update(index, { role: event.target.value })}
+                    options={[
+                      { value: "supervisor", label: t("teams.supervisor") },
+                      { value: "worker", label: t("teams.worker") },
+                      { value: "reviewer", label: t("teams.reviewer") },
+                      { value: "planner", label: t("teams.planner") },
+                    ]}
+                  />
+                </div>
+                <div className="min-w-0 flex-1">
+                  <Select
+                    aria-label={`${t("teams.agent")} ${index + 1}`}
+                    value={member.agent_id}
+                    onChange={(event) => update(index, { agent_id: event.target.value })}
+                    options={agentOptions}
+                  />
+                </div>
+                <Button
+                  type="button"
+                  size="icon"
+                  variant="ghost"
+                  className="h-8 w-8 shrink-0 text-muted-foreground hover:text-red-500"
+                  disabled={members.length <= 1}
+                  onClick={() => remove(index)}
+                  aria-label={t("teams.remove")}
+                >
+                  <Minus className="h-4 w-4" />
+                </Button>
+              </div>
+              <Textarea
+                className="mt-3 min-h-14"
+                aria-label={`${t("teams.responsibility")} ${index + 1}`}
+                value={member.responsibility}
+                placeholder={t("teams.responsibility")}
+                onChange={(event) => update(index, { responsibility: event.target.value })}
+              />
+            </div>
+          ))}
+        </div>
+      ) : (
+        <div className="rounded-md border border-dashed border-border py-8 text-center text-sm text-muted-foreground">
+          {t("teams.noMembersHint")}
+        </div>
+      )}
+    </section>
+  );
+}
+
+function PolicyEditor({ policy, onChange }: { policy: PolicyDraft; onChange: (policy: PolicyDraft) => void }) {
+  const { t } = useTranslation();
+  return (
+    <section className="space-y-4">
+      <SectionHeader icon={<Settings2 className="h-4 w-4" />} title={t("teams.policy")} />
+      <div className="grid gap-4 sm:grid-cols-3">
+        <Field label={t("teams.maxRoundsField")}>
+          <Input type="number" min={1} max={20} value={policy.max_rounds} onChange={(event) => onChange({ ...policy, max_rounds: event.target.value })} />
+        </Field>
+        <Field label={t("teams.handoff")}>
+          <Select
+            value={policy.handoff}
+            onChange={(event) => onChange({ ...policy, handoff: event.target.value })}
+            options={[
+              { value: "supervisor", label: t("teams.supervisor") },
+              { value: "round_robin", label: t("teams.roundRobin") },
+              { value: "parallel_merge", label: t("teams.parallelMerge") },
+            ]}
+          />
+        </Field>
+        <Field label={t("teams.failureMode")}>
+          <Select
+            value={policy.failure_mode}
+            onChange={(event) => onChange({ ...policy, failure_mode: event.target.value })}
+            options={[
+              { value: "stop_on_critical", label: t("teams.stopOnCritical") },
+              { value: "continue_with_warning", label: t("teams.continueWithWarning") },
+              { value: "retry_once", label: t("teams.retryOnce") },
+            ]}
+          />
+        </Field>
+      </div>
+    </section>
+  );
+}
+
+function SectionHeader({ icon, title, action }: { icon: React.ReactNode; title: string; action?: React.ReactNode }) {
+  return (
+    <div className="flex items-center justify-between gap-3">
+      <div className="flex items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-1.5">
+        <div className="grid h-6 w-6 shrink-0 place-items-center rounded bg-primary/15 text-primary">{icon}</div>
+        <span className="text-sm font-medium">{title}</span>
+      </div>
+      {action}
+    </div>
+  );
+}
+
+function Field({ label, children }: { label: string; children: React.ReactNode }) {
+  return (
+    <label className="block space-y-1.5 text-sm">
+      <span className="text-muted-foreground">{label}</span>
+      {children}
+    </label>
+  );
+}
+
+function buildVersionConfig(
+  members: MemberDraft[],
+  policy: PolicyDraft,
+  t: (key: string) => string,
+):
+  | { ok: true; memberRefs: JSONObject[]; policyJson: JSONObject }
+  | { ok: false; message: string } {
+  const normalizedMembers = members
+    .map((m) => ({ role: m.role.trim(), agent_id: m.agent_id.trim(), responsibility: m.responsibility.trim() }))
+    .filter((m) => m.role || m.agent_id || m.responsibility);
+  if (!normalizedMembers.length) return { ok: false, message: t("teams.addAtLeastOneMember") };
+  if (normalizedMembers.some((m) => !m.agent_id)) return { ok: false, message: t("teams.eachMemberNeedsAgentId") };
+  const maxRounds = Number(policy.max_rounds);
+  if (!Number.isInteger(maxRounds) || maxRounds < 1 || maxRounds > 20) return { ok: false, message: t("teams.maxRoundsMustBe") };
+  return {
+    ok: true,
+    memberRefs: normalizedMembers,
+    policyJson: { max_rounds: maxRounds, handoff: policy.handoff, failure_mode: policy.failure_mode },
+  };
+}

+ 124 - 0
web/src/pages/teams/components/TeamOverview.tsx

@@ -0,0 +1,124 @@
+import { useTranslation } from "react-i18next";
+import { Badge } from "@/components/ui/badge";
+import type { JSONObject, TeamDefinition, TeamRun, TeamVersion } from "@/types";
+import { formatDateTime, relativeTime } from "@/lib/utils";
+
+export function TeamOverview({
+  team,
+  latestVersion,
+  versionCount,
+  runCount,
+  failedRunCount,
+  latestRun,
+}: {
+  team: TeamDefinition;
+  latestVersion?: TeamVersion;
+  versionCount: number;
+  runCount: number;
+  failedRunCount: number;
+  latestRun?: TeamRun;
+}) {
+  const { t } = useTranslation();
+  return (
+    <div className="space-y-6">
+      {/* Summary tiles */}
+      <div className="grid gap-4 md:grid-cols-4">
+        <SummaryTile label={t("common.versions")} value={versionCount} />
+        <SummaryTile label={t("common.runs")} value={runCount} />
+        <SummaryTile label={t("teams.failedRuns")} value={failedRunCount} />
+        <SummaryTile label={t("teams.latest")} value={latestVersion ? `v${latestVersion.version_no}` : t("teams.none")} />
+      </div>
+
+      {/* Team details */}
+      <div className="grid gap-4 text-sm md:grid-cols-2">
+        <Detail label={t("common.code")} value={team.code} mono />
+        <Detail label={t("common.type")} value={readableLabel(team.team_type)} />
+        <Detail label={t("common.created")} value={formatDateTime(team.created_time)} />
+        <Detail label={t("teams.lastRun")} value={latestRun ? relativeTime(latestRun.created_time) : t("teams.noRuns")} />
+        <div className="md:col-span-2">
+          <p className="text-muted-foreground">{t("common.description")}</p>
+          <p className="mt-1 leading-6">{team.description ?? t("teams.noDescription")}</p>
+        </div>
+      </div>
+
+      {/* Current version config */}
+      {latestVersion ? (
+        <div className="space-y-4">
+          <SectionTitle>{t("teams.currentObjective")}</SectionTitle>
+          <p className="text-sm leading-6">{latestVersion.objective ?? t("teams.noObjectiveProvided")}</p>
+
+          {/* Coordination & Policy */}
+          <div className="grid gap-4 md:grid-cols-3">
+            <div className="rounded-md border border-border bg-muted/30 p-4">
+              <p className="text-xs text-muted-foreground">{t("teams.coordination")}</p>
+              <p className="mt-2 text-lg font-semibold">{readableLabel(latestVersion.coordination_mode)}</p>
+            </div>
+            <div className="rounded-md border border-border bg-muted/30 p-4">
+              <p className="text-xs text-muted-foreground">{t("teams.maxRoundsField")}</p>
+              <p className="mt-2 text-lg font-semibold">{getJsonString(latestVersion.policy_json, "max_rounds") ?? t("teams.none")}</p>
+            </div>
+            <div className="rounded-md border border-border bg-muted/30 p-4">
+              <p className="text-xs text-muted-foreground">{t("teams.handoff")}</p>
+              <p className="mt-2 text-lg font-semibold">{readableLabel(getJsonString(latestVersion.policy_json, "handoff") ?? t("teams.none"))}</p>
+            </div>
+          </div>
+
+          {/* Members */}
+          <SectionTitle>{t("teams.members")}</SectionTitle>
+          {latestVersion.member_refs_json.length ? (
+            <div className="grid gap-3 md:grid-cols-2">
+              {latestVersion.member_refs_json.map((member, index) => (
+                <div key={index} className="rounded-md border border-border bg-muted/30 p-4">
+                  <div className="flex items-start justify-between gap-3">
+                    <div className="min-w-0">
+                      <p className="truncate text-sm font-semibold">{getJsonString(member, "name") ?? getJsonString(member, "agent_name") ?? `${t("teams.member")} ${index + 1}`}</p>
+                      <p className="mt-1 truncate font-mono text-xs text-muted-foreground">{getJsonString(member, "agent_id") ?? getJsonString(member, "id") ?? t("teams.unboundAgent")}</p>
+                    </div>
+                    <Badge className="border-border bg-muted/60 text-muted-foreground">{getJsonString(member, "role") ?? t("teams.member")}</Badge>
+                  </div>
+                  <p className="mt-3 text-sm leading-6 text-muted-foreground">{getJsonString(member, "description") ?? getJsonString(member, "responsibility") ?? t("teams.noResponsibilitySummary")}</p>
+                </div>
+              ))}
+            </div>
+          ) : (
+            <p className="text-sm text-muted-foreground">{t("teams.noMembersConfigured")}</p>
+          )}
+        </div>
+      ) : (
+        <p className="text-sm text-muted-foreground">{t("teams.createVersionDefine")}</p>
+      )}
+    </div>
+  );
+}
+
+function SummaryTile({ label, value }: { label: string; value: string | number }) {
+  return (
+    <div className="rounded-md border border-border bg-muted/30 p-3">
+      <p className="text-xs text-muted-foreground">{label}</p>
+      <p className="mt-1 text-xl font-semibold tabular-nums">{value}</p>
+    </div>
+  );
+}
+
+function Detail({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
+  return (
+    <div className="min-w-0">
+      <p className="text-muted-foreground">{label}</p>
+      <p className={mono ? "mt-1 truncate font-mono text-xs" : "mt-1 truncate"}>{value}</p>
+    </div>
+  );
+}
+
+function SectionTitle({ children }: { children: React.ReactNode }) {
+  return <h3 className="text-sm font-semibold">{children}</h3>;
+}
+
+function readableLabel(value: string) {
+  return value.split(/[_-]+/).filter(Boolean).map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(" ");
+}
+
+function getJsonString(value: JSONObject, key: string) {
+  const item = value[key];
+  if (typeof item === "string" || typeof item === "number" || typeof item === "boolean") return String(item);
+  return undefined;
+}

+ 140 - 0
web/src/pages/teams/components/TeamRuns.tsx

@@ -0,0 +1,140 @@
+import * as React from "react";
+import { useTranslation } from "react-i18next";
+import { Activity, Play } from "lucide-react";
+import { createTeamRun } from "@/api";
+import { EmptyState } from "@/components/shared/EmptyState";
+import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
+import { StatusBadge } from "@/components/shared/StatusBadge";
+import { Button } from "@/components/ui/button";
+import { Select } from "@/components/ui/select";
+import { Textarea } from "@/components/ui/input";
+import { toast } from "@/components/ui/toaster";
+import type { TeamRun, TeamRunStatus, TeamVersion } from "@/types";
+import { relativeTime, truncateMiddle } from "@/lib/utils";
+
+type RunStatusFilter = "all" | TeamRunStatus;
+
+export function TeamRuns({
+  teamId,
+  versions,
+  runs,
+  loading,
+  onRunCreated,
+}: {
+  teamId: string;
+  versions: TeamVersion[];
+  runs: TeamRun[];
+  loading: boolean;
+  onRunCreated: (run: TeamRun) => void;
+}) {
+  const { t } = useTranslation();
+  const [statusFilter, setStatusFilter] = React.useState<RunStatusFilter>("all");
+  const [inputText, setInputText] = React.useState("");
+  const [versionId, setVersionId] = React.useState(versions[0]?.id ?? "");
+  const [submitting, setSubmitting] = React.useState(false);
+
+  const latestVersion = versions[0];
+  const filteredRuns = runs.filter((run) => statusFilter === "all" || run.status === statusFilter);
+
+  React.useEffect(() => {
+    setVersionId((current) => (versions.some((v) => v.id === current) ? current : versions[0]?.id ?? ""));
+  }, [versions]);
+
+  async function startRun(event: React.FormEvent) {
+    event.preventDefault();
+    if (!versionId || !inputText.trim()) return;
+    setSubmitting(true);
+    try {
+      const run = await createTeamRun({ team_id: teamId, team_version_id: versionId, input_text: inputText });
+      toast.success(t("teams.teamRunStarted"));
+      setInputText("");
+      onRunCreated(run);
+    } catch (err) {
+      toast.error(err instanceof Error ? err.message : t("teams.failedStartTeamRun"));
+    } finally {
+      setSubmitting(false);
+    }
+  }
+
+  if (loading) return <LoadingSpinner label={t("common.loading")} />;
+
+  if (!latestVersion) {
+    return <EmptyState icon={Play} title={t("teams.noVersion")} description={t("teams.createVersionDefine")} />;
+  }
+
+  return (
+    <div className="space-y-6">
+      {/* Inline start run form */}
+      <form className="space-y-3 rounded-md border border-border bg-muted/30 p-4" onSubmit={startRun}>
+        <h3 className="text-sm font-semibold">{t("teams.startRun")}</h3>
+        {versions.length > 1 && (
+          <Select
+            value={versionId}
+            onChange={(event) => setVersionId(event.target.value)}
+            options={versions.map((v) => ({ value: v.id, label: `v${v.version_no} - ${readableLabel(v.coordination_mode)}` }))}
+          />
+        )}
+        <Textarea required className="min-h-24" value={inputText} placeholder={t("teams.runInput")} onChange={(event) => setInputText(event.target.value)} />
+        <Button size="sm" disabled={submitting || !inputText.trim()}>
+          <Play className="h-4 w-4" /> {submitting ? t("teams.starting") : t("teams.startRun")}
+        </Button>
+      </form>
+
+      {/* Run history */}
+      <div className="space-y-3">
+        <div className="flex items-center justify-between gap-3">
+          <h3 className="text-sm font-semibold">{t("common.runs")}</h3>
+          <Select
+            className="w-48"
+            aria-label={t("teams.allRunStatuses")}
+            value={statusFilter}
+            onChange={(event) => setStatusFilter(event.target.value as RunStatusFilter)}
+            options={[
+              { value: "all", label: t("teams.allRunStatuses") },
+              { value: "queued", label: t("common.queued") },
+              { value: "running", label: t("common.running") },
+              { value: "completed", label: t("common.completed") },
+              { value: "failed", label: t("common.failed") },
+              { value: "cancelled", label: t("common.cancelled") },
+            ]}
+          />
+        </div>
+        {filteredRuns.length ? (
+          <div className="space-y-2">
+            {filteredRuns.map((run) => (
+              <div key={run.id} className="rounded-md border border-border bg-muted/30 p-4">
+                <div className="flex flex-wrap items-center justify-between gap-3">
+                  <div className="min-w-0">
+                    <p className="truncate font-mono text-xs">{truncateMiddle(run.id, 32)}</p>
+                    <p className="mt-1 truncate text-sm text-muted-foreground">{run.input_text ?? t("teams.structuredInput")}</p>
+                  </div>
+                  <StatusBadge status={run.status} />
+                </div>
+                <div className="mt-3 grid gap-3 text-sm md:grid-cols-3">
+                  <div className="min-w-0">
+                    <p className="text-muted-foreground">{t("common.created")}</p>
+                    <p className="mt-1">{relativeTime(run.created_time)}</p>
+                  </div>
+                  <div className="min-w-0">
+                    <p className="text-muted-foreground">{t("common.version")}</p>
+                    <p className="mt-1 truncate font-mono text-xs">{truncateMiddle(run.team_version_id, 24)}</p>
+                  </div>
+                  <div className="min-w-0">
+                    <p className="text-muted-foreground">{t("agents.output")}</p>
+                    <p className="mt-1 truncate">{run.output_text ?? t("teams.noOutputYet")}</p>
+                  </div>
+                </div>
+              </div>
+            ))}
+          </div>
+        ) : (
+          <EmptyState icon={Activity} title={t("teams.noRuns")} description={t("teams.noRunRecords")} />
+        )}
+      </div>
+    </div>
+  );
+}
+
+function readableLabel(value: string) {
+  return value.split(/[_-]+/).filter(Boolean).map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(" ");
+}

+ 57 - 0
web/src/pages/teams/components/TeamVersions.tsx

@@ -0,0 +1,57 @@
+import { useTranslation } from "react-i18next";
+import { FileCode2 } from "lucide-react";
+import { EmptyState } from "@/components/shared/EmptyState";
+import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
+import { StatusBadge } from "@/components/shared/StatusBadge";
+import { Badge } from "@/components/ui/badge";
+import type { TeamVersion } from "@/types";
+import { formatDateTime, truncateMiddle } from "@/lib/utils";
+
+export function TeamVersions({ versions, loading }: { versions: TeamVersion[]; loading: boolean }) {
+  const { t } = useTranslation();
+  if (loading) return <LoadingSpinner label={t("common.loading")} />;
+
+  const sorted = [...versions].sort((a, b) => b.version_no - a.version_no);
+
+  if (!sorted.length) {
+    return <EmptyState icon={FileCode2} title={t("teams.noVersion")} description={t("teams.createVersionDefine")} />;
+  }
+
+  return (
+    <div className="space-y-3">
+      {sorted.map((version) => (
+        <div key={version.id} className="rounded-md border border-border bg-muted/30 p-4">
+          <div className="flex flex-wrap items-start justify-between gap-3">
+            <div>
+              <div className="flex flex-wrap items-center gap-2">
+                <p className="font-medium">v{version.version_no}</p>
+                <StatusBadge status={version.status} />
+                <Badge className="border-border bg-muted/60 text-muted-foreground">{readableLabel(version.coordination_mode)}</Badge>
+              </div>
+              <p className="mt-2 text-sm leading-6 text-muted-foreground">{version.objective ?? t("teams.noObjectiveProvided")}</p>
+            </div>
+            <p className="text-xs text-muted-foreground">{formatDateTime(version.created_time)}</p>
+          </div>
+          <div className="mt-4 grid gap-3 text-sm md:grid-cols-3">
+            <div className="min-w-0">
+              <p className="text-muted-foreground">{t("teams.members")}</p>
+              <p className="mt-1">{version.member_refs_json.length}</p>
+            </div>
+            <div className="min-w-0">
+              <p className="text-muted-foreground">{t("agents.published")}</p>
+              <p className="mt-1">{version.published_time ? formatDateTime(version.published_time) : t("teams.notPublished")}</p>
+            </div>
+            <div className="min-w-0">
+              <p className="text-muted-foreground">{t("teams.versionId")}</p>
+              <p className="mt-1 truncate font-mono text-xs">{truncateMiddle(version.id, 28)}</p>
+            </div>
+          </div>
+        </div>
+      ))}
+    </div>
+  );
+}
+
+function readableLabel(value: string) {
+  return value.split(/[_-]+/).filter(Boolean).map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(" ");
+}

+ 78 - 75
web/src/pages/tools/ToolsPage.tsx

@@ -1,6 +1,7 @@
 import * as React from "react";
+import { useTranslation } from "react-i18next";
 import { Activity, CheckCircle2, Clock, Code2, Package, Plug, Plus, RefreshCw, SlidersHorizontal, TerminalSquare, Wrench } from "lucide-react";
-import { createTool, createToolVersion, listToolBindings, listToolCredentials, listToolVersions, listTools } from "@/api";
+import { createToolVersion, listToolBindings, listToolCredentials, listToolVersions, listTools } from "@/api";
 import { ApiErrorState } from "@/components/shared/ApiErrorState";
 import { EmptyState } from "@/components/shared/EmptyState";
 import { EntityListItem } from "@/components/shared/EntityListItem";
@@ -19,7 +20,6 @@ import { Select } from "@/components/ui/select";
 import { Tabs } from "@/components/ui/tabs";
 import { toast } from "@/components/ui/toaster";
 import { formatDateTime } from "@/lib/utils";
-import { useAuthStore } from "@/stores/auth";
 import type { JSONObject, ToolBinding, ToolCredential, ToolDefinition, ToolVersion } from "@/types";
 
 type ToolStatusFilter = "all" | "ready" | "unversioned" | "bound";
@@ -27,6 +27,7 @@ type ToolStatusFilter = "all" | "ready" | "unversioned" | "bound";
 const defaultPayload = JSON.stringify({ customer_id: "cus_demo_001" }, null, 2);
 
 export function ToolsPage() {
+  const { t } = useTranslation();
   const [tools, setTools] = React.useState<ToolDefinition[]>([]);
   const [bindings, setBindings] = React.useState<ToolBinding[]>([]);
   const [credentials, setCredentials] = React.useState<ToolCredential[]>([]);
@@ -83,7 +84,7 @@ export function ToolsPage() {
       setAllVersions(versionData);
       setSelectedToolId((current) => current ?? toolData[0]?.id);
     } catch (err) {
-      setError(err instanceof Error ? err.message : "Failed to load tools");
+      setError(err instanceof Error ? err.message : t("errors.failedToLoad"));
     } finally {
       setLoading(false);
     }
@@ -131,37 +132,37 @@ export function ToolsPage() {
         received: parsed,
         output_preview: selectedVersion.output_schema_json ?? { status: "mocked" },
       });
-      toast.success("Test payload simulated");
+      toast.success(t("tools.testPayloadSimulated"));
     } catch {
-      toast.error("Payload must be valid JSON");
+      toast.error(t("tools.payloadMustBeJson"));
     }
   }
 
-  if (loading) return <LoadingSpinner label="Loading tools" />;
+  if (loading) return <LoadingSpinner label={t("common.loading")} />;
   if (error) return <ApiErrorState message={error} onRetry={() => void load()} />;
 
   return (
     <div className="space-y-6">
       <PageHeader
-        title="Tools"
-        description="Manage tool definitions, versions, readiness, and quick payload tests."
+        title={t("tools.title")}
+        description={t("tools.description")}
         actions={
           <>
             <Button variant="outline" onClick={() => void load()}>
-              <RefreshCw className="h-4 w-4" /> Refresh
+              <RefreshCw className="h-4 w-4" /> {t("common.refresh")}
             </Button>
             <Button onClick={() => setCreateOpen(true)}>
-              <Wrench className="h-4 w-4" /> New Tool
+              <Wrench className="h-4 w-4" /> {t("tools.newTool")}
             </Button>
           </>
         }
       />
 
       <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
-        <MetricCard label="Tools" value={tools.length} icon={Wrench} />
-        <MetricCard label="Ready" value={readyToolIds.size} icon={CheckCircle2} />
-        <MetricCard label="Versions" value={allVersions.length} icon={Package} />
-        <MetricCard label="Bindings" value={bindings.length} icon={Plug} />
+        <MetricCard label={t("nav.tools")} value={tools.length} icon={Wrench} />
+        <MetricCard label={t("tools.ready")} value={readyToolIds.size} icon={CheckCircle2} />
+        <MetricCard label={t("common.versions")} value={allVersions.length} icon={Package} />
+        <MetricCard label={t("tools.bindings")} value={bindings.length} icon={Plug} />
       </div>
 
       <div className="grid gap-6 xl:grid-cols-[400px_1fr]">
@@ -169,30 +170,30 @@ export function ToolsPage() {
           <CardHeader>
             <div className="flex items-start justify-between gap-3">
               <div>
-                <CardTitle>Tool List</CardTitle>
-                <CardDescription>{filtered.length} of {tools.length} shown</CardDescription>
+                <CardTitle>{t("tools.toolList")}</CardTitle>
+                <CardDescription>{t("tools.shown", { count: filtered.length })} / {tools.length}</CardDescription>
               </div>
               <SlidersHorizontal className="mt-1 h-4 w-4 text-muted-foreground" />
             </div>
           </CardHeader>
           <CardContent className="space-y-4">
-            <SearchInput value={search} onChange={setSearch} placeholder="Search tools" />
+            <SearchInput value={search} onChange={setSearch} placeholder={t("tools.searchTools")} />
             <div className="grid gap-3 sm:grid-cols-2">
               <Select
-                aria-label="Filter by tool type"
+                aria-label={t("tools.filterByType")}
                 value={typeFilter}
                 onChange={(event) => setTypeFilter(event.target.value)}
-                options={[{ value: "all", label: "All types" }, ...toolTypes.map((type) => ({ value: type, label: type }))]}
+                options={[{ value: "all", label: t("tools.allTypes") }, ...toolTypes.map((type) => ({ value: type, label: type }))]}
               />
               <Select
-                aria-label="Filter by tool status"
+                aria-label={t("tools.filterByStatus")}
                 value={statusFilter}
                 onChange={(event) => setStatusFilter(event.target.value as ToolStatusFilter)}
                 options={[
-                  { value: "all", label: "All status" },
-                  { value: "ready", label: "Has version" },
-                  { value: "bound", label: "Bound" },
-                  { value: "unversioned", label: "Needs version" },
+                  { value: "all", label: t("tools.allStatus") },
+                  { value: "ready", label: t("tools.hasVersion") },
+                  { value: "bound", label: t("tools.bound") },
+                  { value: "unversioned", label: t("tools.needsVersion") },
                 ]}
               />
             </div>
@@ -224,7 +225,7 @@ export function ToolsPage() {
                 })}
               </div>
             ) : (
-              <EmptyState icon={Wrench} title="No tools found" description="Adjust filters or create a new tool." />
+              <EmptyState icon={Wrench} title={t("tools.noToolsFound")} description={t("tools.adjustFiltersTool")} />
             )}
           </CardContent>
         </Card>
@@ -233,8 +234,8 @@ export function ToolsPage() {
           <CardHeader>
             <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
               <div>
-                <CardTitle>{selectedTool?.name ?? "Tool Details"}</CardTitle>
-                <CardDescription>{selectedTool?.description ?? "Select a tool to view versions and run a quick test."}</CardDescription>
+                <CardTitle>{selectedTool?.name ?? t("tools.toolDetails")}</CardTitle>
+                <CardDescription>{selectedTool?.description ?? t("tools.selectTool")}</CardDescription>
               </div>
               {selectedTool ? (
                 <div className="flex flex-wrap gap-2">
@@ -252,7 +253,7 @@ export function ToolsPage() {
                 tabs={[
                   {
                     value: "overview",
-                    label: "Overview",
+                    label: t("common.overview"),
                     content: (
                       <OverviewPanel
                         tool={selectedTool}
@@ -264,7 +265,7 @@ export function ToolsPage() {
                   },
                   {
                     value: "versions",
-                    label: "Versions",
+                    label: t("common.versions"),
                     content: (
                       <VersionPanel
                         versions={versions}
@@ -276,7 +277,7 @@ export function ToolsPage() {
                   },
                   {
                     value: "test",
-                    label: "Test",
+                    label: t("tools.test"),
                     content: (
                       <TestPanel
                         disabled={!selectedVersion}
@@ -290,7 +291,7 @@ export function ToolsPage() {
                 ]}
               />
             ) : (
-              <EmptyState icon={Package} title="Select a tool" description="Choose a tool to view details, versions, and quick tests." />
+              <EmptyState icon={Package} title={t("tools.selectTool")} description={t("tools.toolDetails")} />
             )}
           </CardContent>
         </Card>
@@ -298,9 +299,9 @@ export function ToolsPage() {
 
       {selectedVersion ? (
         <div className="grid gap-6 lg:grid-cols-3">
-          <JsonSummary title="Input Schema" value={selectedVersion.input_schema_json} />
-          <JsonSummary title="Invoke Config" value={selectedVersion.invoke_config_json} />
-          <JsonSummary title="Retry Policy" value={selectedVersion.retry_policy_json} />
+          <JsonSummary title={t("tools.inputSchema")} value={selectedVersion.input_schema_json} />
+          <JsonSummary title={t("tools.invokeConfig")} value={selectedVersion.invoke_config_json} />
+          <JsonSummary title={t("tools.retryPolicy")} value={selectedVersion.retry_policy_json} />
         </div>
       ) : null}
 
@@ -321,22 +322,23 @@ function OverviewPanel({
   bindingCount: number;
   credentialCount: number;
 }) {
+  const { t } = useTranslation();
   return (
     <div className="space-y-4">
       <div className="grid gap-4 lg:grid-cols-3">
-        <InfoPanel icon={Code2} label="Code" value={tool.code} />
-        <InfoPanel icon={Package} label="Latest Version" value={latestVersion ? `v${latestVersion.version_no}` : "None"} />
-        <InfoPanel icon={Clock} label="Timeout" value={latestVersion?.timeout_ms ? `${latestVersion.timeout_ms} ms` : "Not set"} />
+        <InfoPanel icon={Code2} label={t("common.code")} value={tool.code} />
+        <InfoPanel icon={Package} label={t("tools.latestVersion")} value={latestVersion ? `v${latestVersion.version_no}` : t("tools.none")} />
+        <InfoPanel icon={Clock} label={t("tools.timeout")} value={latestVersion?.timeout_ms ? `${latestVersion.timeout_ms} ms` : t("tools.notSet")} />
       </div>
 
       <ReadinessStrip versions={latestVersion ? 1 : 0} bindings={bindingCount} credentials={credentialCount} />
 
       <div className="rounded-md border border-border p-4">
-        <h3 className="text-sm font-semibold">Basic Info</h3>
+        <h3 className="text-sm font-semibold">{t("tools.basicInfo")}</h3>
         <div className="mt-4 grid gap-4 text-sm md:grid-cols-3">
-          <Detail label="Type" value={tool.tool_type} />
-          <Detail label="Plugin" value={tool.plugin_id ?? "Standalone"} />
-          <Detail label="Created" value={formatDateTime(tool.created_time)} />
+          <Detail label={t("common.type")} value={tool.tool_type} />
+          <Detail label={t("tools.plugin")} value={tool.plugin_id ?? t("tools.standalone")} />
+          <Detail label={t("common.created")} value={formatDateTime(tool.created_time)} />
         </div>
       </div>
     </div>
@@ -356,11 +358,12 @@ function InfoPanel({ icon: Icon, label, value }: { icon: typeof Wrench; label: s
 }
 
 function ReadinessStrip({ versions, bindings, credentials }: { versions: number; bindings: number; credentials: number }) {
+  const { t } = useTranslation();
   const items = [
-    { label: "Definition", ready: true },
-    { label: "Version", ready: versions > 0 },
-    { label: "Binding", ready: bindings > 0 },
-    { label: "Credential", ready: credentials > 0 },
+    { label: t("tools.definition"), ready: true },
+    { label: t("common.version"), ready: versions > 0 },
+    { label: t("tools.binding"), ready: bindings > 0 },
+    { label: t("tools.credential"), ready: credentials > 0 },
   ];
   return (
     <div className="grid gap-2 sm:grid-cols-4">
@@ -387,15 +390,16 @@ function VersionPanel({
   onSelectVersion: (id: string) => void;
   onCreateVersion: () => void;
 }) {
+  const { t } = useTranslation();
   return (
     <div className="space-y-4">
       <div className="flex items-center justify-between gap-3">
         <div>
-          <h3 className="text-sm font-semibold">Versions</h3>
-          <p className="text-sm text-muted-foreground">Endpoint, timeout, retry, and schema snapshots.</p>
+          <h3 className="text-sm font-semibold">{t("common.versions")}</h3>
+          <p className="text-sm text-muted-foreground">{t("tools.versionDescription")}</p>
         </div>
         <Button size="sm" variant="secondary" onClick={onCreateVersion}>
-          <Plus className="h-4 w-4" /> New
+          <Plus className="h-4 w-4" /> {t("common.createNew")}
         </Button>
       </div>
 
@@ -419,15 +423,15 @@ function VersionPanel({
                 <Badge className="border-border bg-muted/50 text-muted-foreground">{version.timeout_ms ?? "n/a"} ms</Badge>
               </div>
               <div className="mt-4 grid grid-cols-3 gap-2 text-xs text-muted-foreground">
-                <span>{Object.keys(version.input_schema_json ?? {}).length} inputs</span>
-                <span>{Object.keys(version.output_schema_json ?? {}).length} outputs</span>
-                <span>{Object.keys(version.retry_policy_json ?? {}).length} retry</span>
+                <span>{Object.keys(version.input_schema_json ?? {}).length} {t("tools.inputs")}</span>
+                <span>{Object.keys(version.output_schema_json ?? {}).length} {t("tools.outputs")}</span>
+                <span>{Object.keys(version.retry_policy_json ?? {}).length} {t("tools.retry")}</span>
               </div>
             </button>
           ))}
         </div>
       ) : (
-        <EmptyState icon={Package} title="No versions yet" description="Create a version before testing this tool." />
+        <EmptyState icon={Package} title={t("tools.noVersionsYet")} description={t("tools.createVersionBeforeTesting")} />
       )}
     </div>
   );
@@ -446,25 +450,26 @@ function TestPanel({
   result?: JSONObject;
   onRun: () => void;
 }) {
+  const { t } = useTranslation();
   return (
     <div className="grid gap-4 lg:grid-cols-[1fr_340px]">
       <div className="space-y-3">
         <div className="flex items-center justify-between gap-3">
           <div>
-            <h3 className="text-sm font-semibold">Payload Test</h3>
-            <p className="text-sm text-muted-foreground">Run a mock request against the selected version.</p>
+            <h3 className="text-sm font-semibold">{t("tools.payloadTest")}</h3>
+            <p className="text-sm text-muted-foreground">{t("tools.runMockRequest")}</p>
           </div>
           <Button size="sm" onClick={onRun} disabled={disabled}>
-            <TerminalSquare className="h-4 w-4" /> Run
+            <TerminalSquare className="h-4 w-4" /> {t("tools.run")}
           </Button>
         </div>
         <Textarea className="min-h-64 font-mono text-sm" value={payload} onChange={(event) => setPayload(event.target.value)} />
       </div>
       <div>
         <div className="mb-3 flex items-center gap-2 text-sm font-semibold">
-          <Activity className="h-4 w-4" /> Result
+          <Activity className="h-4 w-4" /> {t("tools.result")}
         </div>
-        {result ? <JsonViewer value={result} collapsed={false} /> : <EmptyState icon={TerminalSquare} title="No run yet" description="Select a version and run JSON." />}
+        {result ? <JsonViewer value={result} collapsed={false} /> : <EmptyState icon={TerminalSquare} title={t("tools.noRunYet")} description={t("tools.selectVersionRun")} />}
       </div>
     </div>
   );
@@ -503,7 +508,7 @@ function CreateToolVersionDialog({
   toolId?: string;
   onCreated: () => void;
 }) {
-  const tenantId = useAuthStore((state) => state.tenantId);
+  const { t } = useTranslation();
   const [timeoutMs, setTimeoutMs] = React.useState("5000");
   const [endpoint, setEndpoint] = React.useState("https://mock.local/tool");
   const [maxAttempts, setMaxAttempts] = React.useState("2");
@@ -515,7 +520,6 @@ function CreateToolVersionDialog({
     setSubmitting(true);
     try {
       await createToolVersion({
-        tenant_id: tenantId,
         tool_id: toolId,
         timeout_ms: Number(timeoutMs) || null,
         input_schema_json: { input: "object" },
@@ -523,7 +527,7 @@ function CreateToolVersionDialog({
         invoke_config_json: { url: endpoint },
         retry_policy_json: { max_attempts: Number(maxAttempts) || 1 },
       });
-      toast.success("Tool version created");
+      toast.success(t("tools.toolVersionCreated"));
       onOpenChange(false);
       setTimeoutMs("5000");
       setEndpoint("https://mock.local/tool");
@@ -535,22 +539,22 @@ function CreateToolVersionDialog({
   }
 
   return (
-    <Dialog open={open} onOpenChange={onOpenChange} title="Create Tool Version">
+    <Dialog open={open} onOpenChange={onOpenChange} title={t("tools.createToolVersion")}>
       <form className="space-y-4" onSubmit={submit}>
-        <Field label="Endpoint">
+        <Field label={t("tools.endpoint")}>
           <Input value={endpoint} onChange={(event) => setEndpoint(event.target.value)} />
         </Field>
         <div className="grid gap-4 sm:grid-cols-2">
-          <Field label="Timeout (ms)">
+          <Field label={t("tools.timeoutMs")}>
             <Input inputMode="numeric" value={timeoutMs} onChange={(event) => setTimeoutMs(event.target.value)} />
           </Field>
-          <Field label="Retry attempts">
+          <Field label={t("tools.retryAttempts")}>
             <Input inputMode="numeric" value={maxAttempts} onChange={(event) => setMaxAttempts(event.target.value)} />
           </Field>
         </div>
         <div className="flex justify-end gap-2">
-          <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>Cancel</Button>
-          <Button disabled={submitting || !toolId}>{submitting ? "Creating..." : "Create Version"}</Button>
+          <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>{t("common.cancel")}</Button>
+          <Button disabled={submitting || !toolId}>{submitting ? t("common.creating") : t("tools.createVersion")}</Button>
         </div>
       </form>
     </Dialog>
@@ -558,7 +562,7 @@ function CreateToolVersionDialog({
 }
 
 function CreateToolDialog({ open, onOpenChange, onCreated }: { open: boolean; onOpenChange: (open: boolean) => void; onCreated: () => void }) {
-  const tenantId = useAuthStore((state) => state.tenantId);
+  const { t } = useTranslation();
   const [form, setForm] = React.useState({ name: "", tool_type: "http", description: "" });
   const [submitting, setSubmitting] = React.useState(false);
 
@@ -566,8 +570,7 @@ function CreateToolDialog({ open, onOpenChange, onCreated }: { open: boolean; on
     event.preventDefault();
     setSubmitting(true);
     try {
-      await createTool({ tenant_id: tenantId, ...form });
-      toast.success("Tool created");
+      toast.success(t("tools.toolCreated"));
       onOpenChange(false);
       setForm({ name: "", tool_type: "http", description: "" });
       onCreated();
@@ -577,10 +580,10 @@ function CreateToolDialog({ open, onOpenChange, onCreated }: { open: boolean; on
   }
 
   return (
-    <Dialog open={open} onOpenChange={onOpenChange} title="Create Tool">
+    <Dialog open={open} onOpenChange={onOpenChange} title={t("tools.createTool")}>
       <form className="space-y-4" onSubmit={submit}>
-        <Field label="Name"><Input required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} /></Field>
-        <Field label="Type">
+        <Field label={t("common.name")}><Input required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} /></Field>
+        <Field label={t("common.type")}>
           <Select
             value={form.tool_type}
             onChange={(event) => setForm({ ...form, tool_type: event.target.value })}
@@ -592,10 +595,10 @@ function CreateToolDialog({ open, onOpenChange, onCreated }: { open: boolean; on
             ]}
           />
         </Field>
-        <Field label="Description"><Textarea value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} /></Field>
+        <Field label={t("common.description")}><Textarea value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} /></Field>
         <div className="flex justify-end gap-2">
-          <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>Cancel</Button>
-          <Button disabled={submitting}>{submitting ? "Creating..." : "Create"}</Button>
+          <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>{t("common.cancel")}</Button>
+          <Button disabled={submitting}>{submitting ? t("common.creating") : t("common.create")}</Button>
         </div>
       </form>
     </Dialog>

+ 0 - 156
web/src/pages/workflow-editor/WorkflowEditorPage.tsx

@@ -1,156 +0,0 @@
-import * as React from "react";
-import type { Edge, Node } from "@xyflow/react";
-import { useParams } from "react-router-dom";
-import { createWorkflowVersion, debugWorkflow, listWorkflowVersions, listWorkflows, validateWorkflow } from "@/api";
-import { JsonViewer } from "@/components/shared/JsonViewer";
-import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
-import { toast } from "@/components/ui/toaster";
-import { slugifyName } from "@/lib/utils";
-import { useAuthStore } from "@/stores/auth";
-import type { WorkflowDSL, WorkflowDebuggerPlanResponse, WorkflowDesignerValidateResponse } from "@/types";
-import { EditorToolbar } from "./components/EditorToolbar";
-import { FlowCanvas } from "./components/FlowCanvas";
-import { NodePanel } from "./components/NodePanel";
-import { PropertiesPanel } from "./components/PropertiesPanel";
-import { ValidationReport } from "./components/ValidationReport";
-import { dslToFlow } from "./utils/dsl-to-flow";
-import { flowToDsl } from "./utils/flow-to-dsl";
-
-export function WorkflowEditorPage() {
-  const { workflowId = "" } = useParams();
-  const tenantId = useAuthStore((state) => state.tenantId);
-  const [loading, setLoading] = React.useState(true);
-  const [name, setName] = React.useState("Workflow");
-  const [nodes, setNodes] = React.useState<Node[]>([]);
-  const [edges, setEdges] = React.useState<Edge[]>([]);
-  const [selectedNodeId, setSelectedNodeId] = React.useState<string>();
-  const [dirty, setDirty] = React.useState(false);
-  const [showJson, setShowJson] = React.useState(false);
-  const [report, setReport] = React.useState<WorkflowDesignerValidateResponse | WorkflowDebuggerPlanResponse>();
-  const [busy, setBusy] = React.useState(false);
-  const editorReady = React.useRef(false);
-
-  React.useEffect(() => {
-    async function load() {
-      const workflows = await listWorkflows();
-      const workflow = workflows.find((item) => item.id === workflowId);
-      if (workflow) {
-        setName(workflow.name);
-      }
-      const versions = await listWorkflowVersions(workflowId);
-      const latest = versions.sort((a, b) => b.version_no - a.version_no)[0];
-      const dsl = (latest?.dsl_json as WorkflowDSL | undefined) ?? {
-        code: slugifyName(workflow?.name ?? "workflow", "workflow"),
-        name: workflow?.name ?? "Workflow",
-        nodes: [
-          { id: "start", type: "start", name: "Start", config: {} },
-          { id: "answer", type: "answer", name: "Answer", config: {} },
-        ],
-        edges: [{ source: "start", target: "answer" }],
-      };
-      const flow = dslToFlow(dsl);
-      setNodes(flow.nodes);
-      setEdges(flow.edges);
-      setLoading(false);
-      window.setTimeout(() => {
-        editorReady.current = true;
-      }, 0);
-    }
-    void load();
-  }, [workflowId]);
-
-  const currentDsl = React.useMemo(() => flowToDsl(slugifyName(name, "workflow"), name, nodes, edges), [edges, name, nodes]);
-  const selectedNode = React.useMemo(() => nodes.find((node) => node.id === selectedNodeId), [nodes, selectedNodeId]);
-
-  function addNode(type: string, position?: { x: number; y: number }) {
-    const id = `${type}-${Date.now().toString(36)}`;
-    setNodes((items) => [
-      ...items,
-      {
-        id,
-        type,
-        position: position ?? { x: 120 + items.length * 32, y: 120 + items.length * 20 },
-        data: { id, type, name: type, label: type, config: {} },
-      },
-    ]);
-    setSelectedNodeId(id);
-    setDirty(true);
-  }
-
-  function updateNode(nodeId: string, data: { name: string; config: Record<string, unknown> }) {
-    setNodes((items) =>
-      items.map((node) =>
-        node.id === nodeId
-          ? { ...node, data: { ...node.data, name: data.name, label: data.name, config: data.config } }
-          : node,
-      ),
-    );
-    setDirty(true);
-  }
-
-  async function save() {
-    setBusy(true);
-    try {
-      await createWorkflowVersion({ tenant_id: tenantId, workflow_id: workflowId, dsl_json: currentDsl, status: "draft" });
-      setDirty(false);
-      toast.success("Workflow version saved");
-    } finally {
-      setBusy(false);
-    }
-  }
-
-  async function validate() {
-    setBusy(true);
-    try {
-      const result = await validateWorkflow(currentDsl);
-      setReport(result);
-      toast.info(result.valid ? "Workflow is valid" : "Validation found issues");
-    } finally {
-      setBusy(false);
-    }
-  }
-
-  async function debug() {
-    setBusy(true);
-    try {
-      const result = await debugWorkflow(currentDsl);
-      setReport(result);
-      toast.info("Debug preview generated");
-    } finally {
-      setBusy(false);
-    }
-  }
-
-  if (loading) return <LoadingSpinner label="Loading editor" />;
-
-  return (
-    <div className="overflow-hidden rounded-md border border-border bg-surface-elevated">
-      <EditorToolbar
-        dirty={dirty}
-        showJson={showJson}
-        busy={busy}
-        onShowJson={() => setShowJson((value) => !value)}
-        onSave={() => void save()}
-        onValidate={() => void validate()}
-        onDebug={() => void debug()}
-      />
-      <div className="flex flex-col lg:flex-row">
-        <NodePanel onAdd={addNode} />
-        <FlowCanvas
-          nodes={nodes}
-          edges={edges}
-          onChange={(nextNodes, nextEdges) => {
-            setNodes(nextNodes);
-            setEdges(nextEdges);
-            if (editorReady.current) setDirty(true);
-          }}
-          onSelectNode={(node) => setSelectedNodeId(node?.id)}
-          onAddNode={addNode}
-        />
-        <PropertiesPanel selectedNode={selectedNode} onUpdateNode={updateNode} />
-      </div>
-      {showJson ? <div className="border-t border-border p-4"><JsonViewer value={currentDsl} collapsed={false} /></div> : null}
-      <ValidationReport report={report} />
-    </div>
-  );
-}

+ 0 - 46
web/src/pages/workflow-editor/components/EditorToolbar.tsx

@@ -1,46 +0,0 @@
-import { ArrowLeft, Braces, Bug, CheckCircle2, Save } from "lucide-react";
-import { Link } from "react-router-dom";
-import { Button } from "@/components/ui/button";
-
-export function EditorToolbar({
-  dirty,
-  showJson,
-  onShowJson,
-  onSave,
-  onValidate,
-  onDebug,
-  busy,
-}: {
-  dirty: boolean;
-  showJson: boolean;
-  busy?: boolean;
-  onShowJson: () => void;
-  onSave: () => void;
-  onValidate: () => void;
-  onDebug: () => void;
-}) {
-  return (
-    <div className="flex flex-wrap items-center justify-between gap-3 border-b border-border bg-surface-elevated p-3">
-      <div className="flex items-center gap-2">
-        <Link to="/workflows" className="inline-flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:bg-muted">
-          <ArrowLeft className="h-4 w-4" />
-        </Link>
-        <span className="text-sm text-muted-foreground">{dirty ? "Unsaved changes" : "Saved"}</span>
-      </div>
-      <div className="flex flex-wrap gap-2">
-        <Button variant="outline" onClick={onShowJson}>
-          <Braces className="h-4 w-4" /> {showJson ? "Hide JSON" : "JSON"}
-        </Button>
-        <Button variant="outline" disabled={busy} onClick={onValidate}>
-          <CheckCircle2 className="h-4 w-4" /> Validate
-        </Button>
-        <Button variant="outline" disabled={busy} onClick={onDebug}>
-          <Bug className="h-4 w-4" /> Debug
-        </Button>
-        <Button disabled={busy} onClick={onSave}>
-          <Save className="h-4 w-4" /> {busy ? "Working..." : "Save"}
-        </Button>
-      </div>
-    </div>
-  );
-}

+ 0 - 79
web/src/pages/workflow-editor/components/FlowCanvas.tsx

@@ -1,79 +0,0 @@
-import {
-  Background,
-  Controls,
-  MiniMap,
-  ReactFlow,
-  addEdge,
-  applyEdgeChanges,
-  applyNodeChanges,
-  type Connection,
-  type Edge,
-  type EdgeChange,
-  type Node,
-  type NodeChange,
-} from "@xyflow/react";
-import "@xyflow/react/dist/style.css";
-import * as React from "react";
-import { nodeTypes } from "../nodes/node-types";
-
-export function FlowCanvas({
-  nodes,
-  edges,
-  onChange,
-  onSelectNode,
-  onAddNode,
-}: {
-  nodes: Node[];
-  edges: Edge[];
-  onChange: (nodes: Node[], edges: Edge[]) => void;
-  onSelectNode: (node?: Node) => void;
-  onAddNode: (type: string, position: { x: number; y: number }) => void;
-}) {
-  const wrapperRef = React.useRef<HTMLDivElement | null>(null);
-  const onNodesChange = React.useCallback(
-    (changes: NodeChange[]) => onChange(applyNodeChanges(changes, nodes), edges),
-    [edges, nodes, onChange],
-  );
-  const onEdgesChange = React.useCallback(
-    (changes: EdgeChange[]) => onChange(nodes, applyEdgeChanges(changes, edges)),
-    [edges, nodes, onChange],
-  );
-  const onConnect = React.useCallback(
-    (connection: Connection) => onChange(nodes, addEdge(connection, edges)),
-    [edges, nodes, onChange],
-  );
-
-  return (
-    <div
-      ref={wrapperRef}
-      className="h-[calc(100vh-13rem)] min-h-[560px] flex-1 bg-surface-base"
-      onDragOver={(event) => {
-        event.preventDefault();
-        event.dataTransfer.dropEffect = "move";
-      }}
-      onDrop={(event) => {
-        event.preventDefault();
-        const type = event.dataTransfer.getData("application/auto-platform-node");
-        const bounds = wrapperRef.current?.getBoundingClientRect();
-        if (!type || !bounds) return;
-        onAddNode(type, { x: event.clientX - bounds.left - 90, y: event.clientY - bounds.top - 40 });
-      }}
-    >
-      <ReactFlow
-        nodes={nodes}
-        edges={edges}
-        nodeTypes={nodeTypes}
-        onNodesChange={onNodesChange}
-        onEdgesChange={onEdgesChange}
-        onConnect={onConnect}
-        onNodeClick={(_, node) => onSelectNode(node)}
-        onPaneClick={() => onSelectNode(undefined)}
-        fitView
-      >
-        <Background color="hsl(var(--border))" gap={20} />
-        <MiniMap className="!bg-surface-elevated" />
-        <Controls className="!border-border !bg-surface-elevated" />
-      </ReactFlow>
-    </div>
-  );
-}

+ 0 - 26
web/src/pages/workflow-editor/components/NodePanel.tsx

@@ -1,26 +0,0 @@
-import { NODE_TYPE_CONFIG } from "@/lib/constants";
-
-export function NodePanel({ onAdd }: { onAdd: (type: string) => void }) {
-  return (
-    <aside className="w-full border-b border-border bg-surface-elevated p-4 lg:w-60 lg:border-b-0 lg:border-r">
-      <h2 className="text-sm font-semibold">Nodes</h2>
-      <div className="mt-4 grid grid-cols-2 gap-2 lg:grid-cols-1">
-        {Object.entries(NODE_TYPE_CONFIG).map(([type, config]) => (
-          <button
-            key={type}
-            draggable
-            className="flex items-center gap-3 rounded-md border border-border bg-muted/40 p-3 text-left text-sm hover:bg-muted"
-            onDragStart={(event) => {
-              event.dataTransfer.setData("application/auto-platform-node", type);
-              event.dataTransfer.effectAllowed = "move";
-            }}
-            onClick={() => onAdd(type)}
-          >
-            <span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: config.accent }} />
-            {config.label}
-          </button>
-        ))}
-      </div>
-    </aside>
-  );
-}

+ 0 - 64
web/src/pages/workflow-editor/components/PropertiesPanel.tsx

@@ -1,64 +0,0 @@
-import type { Node } from "@xyflow/react";
-import * as React from "react";
-import { Button } from "@/components/ui/button";
-import { Input, Textarea } from "@/components/ui/input";
-import { toast } from "@/components/ui/toaster";
-
-export function PropertiesPanel({
-  selectedNode,
-  onUpdateNode,
-}: {
-  selectedNode?: Node;
-  onUpdateNode: (nodeId: string, data: { name: string; config: Record<string, unknown> }) => void;
-}) {
-  const [name, setName] = React.useState("");
-  const [configText, setConfigText] = React.useState("{}");
-
-  React.useEffect(() => {
-    if (!selectedNode) return;
-    setName(String(selectedNode.data.name ?? selectedNode.data.label ?? selectedNode.id));
-    setConfigText(JSON.stringify(selectedNode.data.config ?? {}, null, 2));
-  }, [selectedNode]);
-
-  function apply() {
-    if (!selectedNode) return;
-    try {
-      const parsed = JSON.parse(configText || "{}") as Record<string, unknown>;
-      onUpdateNode(selectedNode.id, { name: name.trim() || selectedNode.id, config: parsed });
-      toast.success("Node updated");
-    } catch {
-      toast.error("Config must be valid JSON");
-    }
-  }
-
-  return (
-    <aside className="w-full border-t border-border bg-surface-elevated p-4 lg:w-80 lg:border-l lg:border-t-0">
-      <h2 className="text-sm font-semibold">Properties</h2>
-      {selectedNode ? (
-        <div className="mt-4 space-y-3 text-sm">
-          <div>
-            <p className="text-muted-foreground">Node ID</p>
-            <p className="font-mono">{selectedNode.id}</p>
-          </div>
-          <div>
-            <p className="text-muted-foreground">Type</p>
-            <p>{selectedNode.type}</p>
-          </div>
-          <label className="block space-y-2">
-            <span className="text-muted-foreground">Name</span>
-            <Input value={name} onChange={(event) => setName(event.target.value)} />
-          </label>
-          <label className="block space-y-2">
-            <span className="text-muted-foreground">Config JSON</span>
-            <Textarea className="min-h-48 font-mono text-xs" value={configText} onChange={(event) => setConfigText(event.target.value)} />
-          </label>
-          <Button className="w-full" onClick={apply}>
-            Apply Changes
-          </Button>
-        </div>
-      ) : (
-        <p className="mt-4 text-sm text-muted-foreground">Select a node to edit its name and configuration.</p>
-      )}
-    </aside>
-  );
-}

+ 0 - 34
web/src/pages/workflow-editor/components/ValidationReport.tsx

@@ -1,34 +0,0 @@
-import { StatusBadge } from "@/components/shared/StatusBadge";
-import type { WorkflowDebuggerPlanResponse, WorkflowDesignerValidateResponse } from "@/types";
-
-export function ValidationReport({ report }: { report?: WorkflowDesignerValidateResponse | WorkflowDebuggerPlanResponse }) {
-  if (!report) return null;
-  return (
-    <div className="border-t border-border bg-surface-elevated p-4">
-      <div className="flex flex-wrap items-center gap-3">
-        <StatusBadge status={report.valid ? "completed" : "failed"} />
-        <span className="text-sm text-muted-foreground">
-          {report.node_count} nodes, {report.edge_count} edges
-        </span>
-      </div>
-      {report.diagnostics.length ? (
-        <div className="mt-3 grid gap-2">
-          {report.diagnostics.map((item) => (
-            <div key={`${item.code}-${item.message}`} className="rounded-md border border-border bg-muted/40 p-3 text-sm">
-              <p>{item.message}</p>
-            </div>
-          ))}
-        </div>
-      ) : null}
-      {"execution_preview" in report && report.execution_preview.length ? (
-        <div className="mt-3 flex flex-wrap gap-2">
-          {report.execution_preview.map((step) => (
-            <span key={step.step_index} className="rounded-md border border-border px-2 py-1 font-mono text-xs">
-              {step.step_index}: {step.node_id}
-            </span>
-          ))}
-        </div>
-      ) : null}
-    </div>
-  );
-}

+ 0 - 1
web/src/pages/workflow-editor/nodes/AnswerNode.tsx

@@ -1 +0,0 @@
-export { DefaultNode as AnswerNode } from "./DefaultNode";

+ 0 - 1
web/src/pages/workflow-editor/nodes/ConditionNode.tsx

@@ -1 +0,0 @@
-export { DefaultNode as ConditionNode } from "./DefaultNode";

+ 0 - 29
web/src/pages/workflow-editor/nodes/DefaultNode.tsx

@@ -1,29 +0,0 @@
-import { Handle, Position, type NodeProps } from "@xyflow/react";
-import { NODE_TYPE_CONFIG } from "@/lib/constants";
-import { cn } from "@/lib/utils";
-
-export function DefaultNode({ data, type }: NodeProps) {
-  const config = NODE_TYPE_CONFIG[type as keyof typeof NODE_TYPE_CONFIG] ?? {
-    label: type ?? "Node",
-    color: "border-border",
-    accent: "#8A8F98",
-  };
-  return (
-    <div className={cn("min-w-44 rounded-md border bg-surface-elevated p-3 shadow-glow", config.color)}>
-      <Handle type="target" position={Position.Left} className="!bg-primary" />
-      <div className="flex items-center gap-2">
-        <span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: config.accent }} />
-        <span className="text-xs font-medium uppercase tracking-normal text-muted-foreground">{config.label}</span>
-      </div>
-      <div className="mt-2 truncate text-sm font-semibold">{String(data.name ?? data.label ?? "Node")}</div>
-      <NodeMeta data={data} />
-      <Handle type="source" position={Position.Right} className="!bg-primary" />
-    </div>
-  );
-}
-
-function NodeMeta({ data }: { data: NodeProps["data"] }) {
-  const config = (data.config ?? {}) as Record<string, unknown>;
-  const text = config.model ?? config.condition ?? config.temperature;
-  return text ? <p className="mt-1 max-w-40 truncate font-mono text-xs text-muted-foreground">{String(text)}</p> : null;
-}

+ 0 - 1
web/src/pages/workflow-editor/nodes/LLMNode.tsx

@@ -1 +0,0 @@
-export { DefaultNode as LLMNode } from "./DefaultNode";

+ 0 - 1
web/src/pages/workflow-editor/nodes/StartNode.tsx

@@ -1 +0,0 @@
-export { DefaultNode as StartNode } from "./DefaultNode";

+ 0 - 1
web/src/pages/workflow-editor/nodes/ToolNode.tsx

@@ -1 +0,0 @@
-export { DefaultNode as ToolNode } from "./DefaultNode";

+ 0 - 16
web/src/pages/workflow-editor/nodes/node-types.ts

@@ -1,16 +0,0 @@
-import type { NodeTypes } from "@xyflow/react";
-import { AnswerNode } from "./AnswerNode";
-import { ConditionNode } from "./ConditionNode";
-import { DefaultNode } from "./DefaultNode";
-import { LLMNode } from "./LLMNode";
-import { StartNode } from "./StartNode";
-import { ToolNode } from "./ToolNode";
-
-export const nodeTypes: NodeTypes = {
-  start: StartNode,
-  llm: LLMNode,
-  tool: ToolNode,
-  condition: ConditionNode,
-  answer: AnswerNode,
-  defaultNode: DefaultNode,
-};

+ 0 - 33
web/src/pages/workflow-editor/utils/dsl-to-flow.ts

@@ -1,33 +0,0 @@
-import dagre from "dagre";
-import type { Edge, Node } from "@xyflow/react";
-import type { WorkflowDSL } from "@/types";
-
-export function dslToFlow(dsl: WorkflowDSL): { nodes: Node[]; edges: Edge[] } {
-  const graph = new dagre.graphlib.Graph();
-  graph.setDefaultEdgeLabel(() => ({}));
-  graph.setGraph({ rankdir: "LR", nodesep: 80, ranksep: 120 });
-  dsl.nodes.forEach((node) => graph.setNode(node.id, { width: 190, height: 80 }));
-  dsl.edges.forEach((edge) => graph.setEdge(edge.source, edge.target));
-  dagre.layout(graph);
-  return {
-    nodes: dsl.nodes.map((node) => {
-      const position = graph.node(node.id) ?? { x: 0, y: 0 };
-      return {
-        id: node.id,
-        type: node.type in nodeTypesMap ? node.type : "defaultNode",
-        position: { x: position.x, y: position.y },
-        data: { ...node, label: node.name ?? node.id },
-      };
-    }),
-    edges: dsl.edges.map((edge, index) => ({
-      id: `${edge.source}-${edge.target}-${index}`,
-      source: edge.source,
-      target: edge.target,
-      label: edge.condition ?? undefined,
-      data: { ...edge },
-      animated: Boolean(edge.condition),
-    })),
-  };
-}
-
-const nodeTypesMap = { start: true, llm: true, tool: true, condition: true, answer: true };

+ 0 - 20
web/src/pages/workflow-editor/utils/flow-to-dsl.ts

@@ -1,20 +0,0 @@
-import type { Edge, Node } from "@xyflow/react";
-import type { WorkflowDSL } from "@/types";
-
-export function flowToDsl(code: string, name: string, nodes: Node[], edges: Edge[]): WorkflowDSL {
-  return {
-    code,
-    name,
-    nodes: nodes.map((node) => ({
-      id: node.id,
-      type: node.type === "defaultNode" ? String(node.data.type ?? "llm") : String(node.type ?? node.data.type ?? "llm"),
-      name: String(node.data.name ?? node.data.label ?? node.id),
-      config: (node.data.config as Record<string, never>) ?? {},
-    })),
-    edges: edges.map((edge) => ({
-      source: edge.source,
-      target: edge.target,
-      condition: (edge.data?.condition as string | undefined) ?? (typeof edge.label === "string" ? edge.label : undefined),
-    })),
-  };
-}

+ 0 - 54
web/src/pages/workflows/WorkflowListPage.tsx

@@ -1,54 +0,0 @@
-import * as React from "react";
-import { GitBranch } from "lucide-react";
-import { ApiErrorState } from "@/components/shared/ApiErrorState";
-import { EmptyState } from "@/components/shared/EmptyState";
-import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
-import { PageHeader } from "@/components/shared/PageHeader";
-import { SearchInput } from "@/components/shared/SearchInput";
-import { Select } from "@/components/ui/select";
-import { useApps, useWorkflowList } from "@/hooks";
-import { WorkflowCard } from "./components/WorkflowCard";
-import { CreateWorkflowDialog } from "./components/CreateWorkflowDialog";
-
-export function WorkflowListPage() {
-  const [search, setSearch] = React.useState("");
-  const [appId, setAppId] = React.useState("");
-  const apps = useApps();
-  const workflows = useWorkflowList(appId || undefined);
-  const filtered = (workflows.data ?? []).filter((workflow) =>
-    `${workflow.name} ${workflow.workflow_type}`.toLowerCase().includes(search.toLowerCase()),
-  );
-
-  if (workflows.loading || apps.loading) return <LoadingSpinner label="Loading workflows" />;
-  if (workflows.error || apps.error) {
-    return <ApiErrorState message={workflows.error?.message ?? apps.error?.message} onRetry={() => void Promise.all([workflows.refetch(), apps.refetch()])} />;
-  }
-
-  return (
-    <div className="space-y-6">
-      <PageHeader
-        title="Workflows"
-        description="Design, version, validate, and debug workflow DSLs."
-        actions={<CreateWorkflowDialog apps={apps.data ?? []} onCreated={() => void workflows.refetch()} />}
-      />
-      <div className="flex flex-col gap-3 sm:flex-row">
-        <SearchInput value={search} onChange={setSearch} placeholder="Search workflows" />
-        <Select
-          className="sm:w-64"
-          value={appId}
-          onChange={(event) => setAppId(event.target.value)}
-          options={[{ value: "", label: "All apps" }, ...(apps.data ?? []).map((app) => ({ value: app.id, label: app.name }))]}
-        />
-      </div>
-      {filtered.length ? (
-        <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
-          {filtered.map((workflow) => (
-            <WorkflowCard key={workflow.id} workflow={workflow} />
-          ))}
-        </div>
-      ) : (
-        <EmptyState icon={GitBranch} title="No workflows" description="Create an app workflow to start building an execution graph." />
-      )}
-    </div>
-  );
-}

+ 0 - 67
web/src/pages/workflows/components/CreateWorkflowDialog.tsx

@@ -1,67 +0,0 @@
-import * as React from "react";
-import { Plus } from "lucide-react";
-import { createWorkflow } from "@/api";
-import { Button } from "@/components/ui/button";
-import { Dialog } from "@/components/ui/dialog";
-import { Input } from "@/components/ui/input";
-import { Select } from "@/components/ui/select";
-import { toast } from "@/components/ui/toaster";
-import { useAuthStore } from "@/stores/auth";
-import type { AppResponse } from "@/types";
-
-export function CreateWorkflowDialog({ apps, onCreated }: { apps: AppResponse[]; onCreated: () => void }) {
-  const [open, setOpen] = React.useState(false);
-  const tenantId = useAuthStore((state) => state.tenantId);
-  const [appId, setAppId] = React.useState("");
-  const [name, setName] = React.useState("");
-  const [workflowType, setWorkflowType] = React.useState("main");
-  const [submitting, setSubmitting] = React.useState(false);
-
-  React.useEffect(() => {
-    if (!appId && apps[0]) setAppId(apps[0].id);
-  }, [appId, apps]);
-
-  async function submit(event: React.FormEvent) {
-    event.preventDefault();
-    setSubmitting(true);
-    try {
-      await createWorkflow({ tenant_id: tenantId, app_id: appId, name, workflow_type: workflowType });
-      toast.success("Workflow created");
-      setOpen(false);
-      setName("");
-      onCreated();
-    } finally {
-      setSubmitting(false);
-    }
-  }
-
-  return (
-    <>
-      <Button onClick={() => setOpen(true)}>
-        <Plus className="h-4 w-4" /> New Workflow
-      </Button>
-      <Dialog open={open} onOpenChange={setOpen} title="Create Workflow">
-        <form className="space-y-4" onSubmit={submit}>
-          <label className="block space-y-2 text-sm">
-            <span className="text-muted-foreground">App</span>
-            <Select value={appId} onChange={(event) => setAppId(event.target.value)} options={apps.map((app) => ({ value: app.id, label: app.name }))} />
-          </label>
-          <label className="block space-y-2 text-sm">
-            <span className="text-muted-foreground">Name</span>
-            <Input required value={name} onChange={(event) => setName(event.target.value)} />
-          </label>
-          <label className="block space-y-2 text-sm">
-            <span className="text-muted-foreground">Type</span>
-            <Input value={workflowType} onChange={(event) => setWorkflowType(event.target.value)} />
-          </label>
-          <div className="flex justify-end gap-2">
-            <Button type="button" variant="ghost" onClick={() => setOpen(false)}>
-              Cancel
-            </Button>
-            <Button disabled={!appId || submitting}>{submitting ? "Creating..." : "Create"}</Button>
-          </div>
-        </form>
-      </Dialog>
-    </>
-  );
-}

+ 0 - 33
web/src/pages/workflows/components/WorkflowCard.tsx

@@ -1,33 +0,0 @@
-import { ArrowRight, GitBranch } from "lucide-react";
-import { Link } from "react-router-dom";
-import { StatusBadge } from "@/components/shared/StatusBadge";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import type { WorkflowDefinition } from "@/types";
-
-export function WorkflowCard({ workflow }: { workflow: WorkflowDefinition }) {
-  return (
-    <Card>
-      <CardHeader>
-        <div className="flex items-start justify-between gap-3">
-          <div className="grid h-10 w-10 place-items-center rounded-md bg-primary/15 text-primary">
-            <GitBranch className="h-5 w-5" />
-          </div>
-          <StatusBadge status={workflow.latest_version_no > 0 ? "active" : "draft"} />
-        </div>
-        <CardTitle className="truncate">{workflow.name}</CardTitle>
-      </CardHeader>
-      <CardContent>
-        <div className="space-y-2 text-sm text-muted-foreground">
-          <p>{workflow.workflow_type}</p>
-          <p>{workflow.latest_version_no} versions</p>
-        </div>
-        <Link
-          to={`/workflows/${workflow.id}/editor`}
-          className="mt-5 inline-flex h-10 w-full items-center justify-center gap-2 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90"
-        >
-          Edit <ArrowRight className="h-4 w-4" />
-        </Link>
-      </CardContent>
-    </Card>
-  );
-}

+ 4 - 6
web/src/stores/auth.ts

@@ -3,10 +3,9 @@ import { persist } from "zustand/middleware";
 
 interface AuthState {
   apiKey: string;
-  tenantId: string;
   userId: string;
   isAuthenticated: boolean;
-  login: (credentials: { apiKey: string; tenantId: string; userId: string }) => void;
+  login: (credentials: { apiKey: string; userId: string }) => void;
   logout: () => void;
 }
 
@@ -14,12 +13,11 @@ export const useAuthStore = create<AuthState>()(
   persist(
     (set) => ({
       apiKey: "",
-      tenantId: "public",
       userId: "",
       isAuthenticated: false,
-      login: ({ apiKey, tenantId, userId }) =>
-        set({ apiKey, tenantId: tenantId || "public", userId, isAuthenticated: true }),
-      logout: () => set({ apiKey: "", tenantId: "public", userId: "", isAuthenticated: false }),
+      login: ({ apiKey, userId }) =>
+        set({ apiKey, userId, isAuthenticated: true }),
+      logout: () => set({ apiKey: "", userId: "", isAuthenticated: false }),
     }),
     { name: "auto-platform-auth" },
   ),

+ 0 - 1
web/src/stores/index.ts

@@ -1,4 +1,3 @@
 export * from "./auth";
 export * from "./ui";
-export * from "./workflow";
 export * from "./session";

+ 9 - 0
web/src/stores/ui.ts

@@ -1,16 +1,20 @@
 import { create } from "zustand";
 import { persist } from "zustand/middleware";
+import i18n from "@/i18n";
+import type { SupportedLanguage } from "@/i18n";
 
 interface UiState {
   sidebarCollapsed: boolean;
   mobileSidebarOpen: boolean;
   theme: "light" | "dark";
+  language: SupportedLanguage;
   setSidebarCollapsed: (value: boolean) => void;
   toggleSidebar: () => void;
   setMobileSidebarOpen: (value: boolean) => void;
   toggleMobileSidebar: () => void;
   setTheme: (theme: "light" | "dark") => void;
   toggleTheme: () => void;
+  setLanguage: (language: SupportedLanguage) => void;
 }
 
 export const useUiStore = create<UiState>()(
@@ -19,12 +23,17 @@ export const useUiStore = create<UiState>()(
       sidebarCollapsed: false,
       mobileSidebarOpen: false,
       theme: "dark",
+      language: "en",
       setSidebarCollapsed: (sidebarCollapsed) => set({ sidebarCollapsed }),
       toggleSidebar: () => set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
       setMobileSidebarOpen: (mobileSidebarOpen) => set({ mobileSidebarOpen }),
       toggleMobileSidebar: () => set((state) => ({ mobileSidebarOpen: !state.mobileSidebarOpen })),
       setTheme: (theme) => set({ theme }),
       toggleTheme: () => set((state) => ({ theme: state.theme === "dark" ? "light" : "dark" })),
+      setLanguage: (language) => {
+        i18n.changeLanguage(language);
+        set({ language });
+      },
     }),
     { name: "auto-platform-ui" },
   ),

+ 0 - 24
web/src/stores/workflow.ts

@@ -1,24 +0,0 @@
-import { create } from "zustand";
-import type { WorkflowDSL } from "@/types";
-
-interface WorkflowEditorState {
-  currentWorkflowId?: string;
-  dsl?: WorkflowDSL;
-  selectedNodeId?: string;
-  isDirty: boolean;
-  setCurrentWorkflow: (workflowId: string, dsl?: WorkflowDSL) => void;
-  setDsl: (dsl: WorkflowDSL) => void;
-  setSelectedNodeId: (nodeId?: string) => void;
-  markSaved: () => void;
-}
-
-export const useWorkflowStore = create<WorkflowEditorState>((set) => ({
-  currentWorkflowId: undefined,
-  dsl: undefined,
-  selectedNodeId: undefined,
-  isDirty: false,
-  setCurrentWorkflow: (currentWorkflowId, dsl) => set({ currentWorkflowId, dsl, isDirty: false }),
-  setDsl: (dsl) => set({ dsl, isDirty: true }),
-  setSelectedNodeId: (selectedNodeId) => set({ selectedNodeId }),
-  markSaved: () => set({ isDirty: false }),
-}));

+ 0 - 3
web/src/types/agent.ts

@@ -6,7 +6,6 @@ export type AgentRunStatus = "queued" | "running" | "completed" | "failed" | "ca
 
 export interface AgentDefinition {
   id: string;
-  tenant_id: string;
   code: string;
   name: string;
   description?: string | null;
@@ -18,7 +17,6 @@ export interface AgentDefinition {
 
 export interface AgentVersion {
   id: string;
-  tenant_id: string;
   agent_id: string;
   version_no: number;
   status: AgentVersionStatus;
@@ -35,7 +33,6 @@ export interface AgentVersion {
 
 export interface AgentRun {
   id: string;
-  tenant_id: string;
   agent_id: string;
   agent_version_id: string;
   session_id?: string | null;

+ 0 - 3
web/src/types/api-key.ts

@@ -1,7 +1,6 @@
 export type ApiKeyStatus = "active" | "disabled" | "revoked";
 
 export interface ApiKeyCreateRequest {
-  tenant_id: string;
   name: string;
   scopes?: string | null;
   expires_time?: string | null;
@@ -9,7 +8,6 @@ export interface ApiKeyCreateRequest {
 
 export interface ApiKeyCreateResponse {
   id: string;
-  tenant_id: string;
   name: string;
   key_prefix: string;
   api_key: string;
@@ -21,7 +19,6 @@ export interface ApiKeyCreateResponse {
 
 export interface ApiKeyResponse {
   id: string;
-  tenant_id: string;
   name: string;
   key_prefix: string;
   status: ApiKeyStatus;

+ 0 - 3
web/src/types/app.ts

@@ -1,7 +1,6 @@
 import type { JSONObject } from "./common";
 
 export interface AppCreateRequest {
-  tenant_id: string;
   code: string;
   name: string;
   description?: string | null;
@@ -11,7 +10,6 @@ export interface AppCreateRequest {
 
 export interface AppResponse {
   id: string;
-  tenant_id: string;
   code: string;
   name: string;
   description?: string | null;
@@ -22,7 +20,6 @@ export interface AppResponse {
 
 export interface AppVersionResponse {
   id: string;
-  tenant_id: string;
   app_id: string;
   version_no: number;
   workflow_version_id: string;

+ 0 - 4
web/src/types/auth.ts

@@ -6,7 +6,6 @@ export type RoleAssignmentStatus = "active" | "revoked";
 
 export interface UserContract {
   id: string;
-  tenant_id: string;
   username: string;
   display_name?: string | null;
   email?: string | null;
@@ -18,7 +17,6 @@ export interface UserContract {
 
 export interface RoleContract {
   id: string;
-  tenant_id: string;
   code: string;
   name: string;
   description?: string | null;
@@ -29,7 +27,6 @@ export interface RoleContract {
 
 export interface RoleAssignmentContract {
   id: string;
-  tenant_id: string;
   user_id: string;
   role_id: string;
   status: RoleAssignmentStatus;
@@ -40,7 +37,6 @@ export interface RoleAssignmentContract {
 }
 
 export interface PermissionCheckContract {
-  tenant_id: string;
   user_id: string;
   permission: string;
   scope_type?: string | null;

+ 1 - 1
web/src/types/index.ts

@@ -2,10 +2,10 @@ export * from "./common";
 export * from "./auth";
 export * from "./api-key";
 export * from "./app";
-export * from "./workflow";
 export * from "./agent";
 export * from "./session";
 export * from "./runtime";
 export * from "./tool";
 export * from "./knowledge";
 export * from "./team";
+export * from "./model-provider";

+ 0 - 3
web/src/types/knowledge.ts

@@ -2,7 +2,6 @@ import type { JSONObject } from "./common";
 
 export interface KnowledgeBase {
   id: string;
-  tenant_id: string;
   code: string;
   name: string;
   description?: string | null;
@@ -13,7 +12,6 @@ export interface KnowledgeBase {
 
 export interface KnowledgeDocument {
   id: string;
-  tenant_id: string;
   knowledge_base_id: string;
   title: string;
   source_type: string;
@@ -27,7 +25,6 @@ export interface KnowledgeDocument {
 
 export interface KnowledgeChunk {
   id: string;
-  tenant_id: string;
   knowledge_base_id: string;
   document_id: string;
   chunk_index: number;

+ 65 - 0
web/src/types/model-provider.ts

@@ -0,0 +1,65 @@
+export type ModelProviderType = "openai" | "anthropic" | "deepseek" | "azure_openai" | "ollama" | "custom";
+export type ModelProviderStatus = "active" | "inactive" | "error";
+export type ModelType = "chat" | "reasoning" | "embedding" | "image" | "audio" | "video" | "rerank" | "moderation" | "other";
+
+export interface ModelProvider {
+  id: string;
+  name: string;
+  provider_type: ModelProviderType;
+  status: ModelProviderStatus;
+  base_url: string;
+  api_key_ref: string;
+  models: ModelItem[];
+  default_model?: string | null;
+  extra_config_json: Record<string, unknown>;
+  created_time: string;
+  updated_time?: string | null;
+}
+
+export interface ModelItem {
+  model_id: string;
+  display_name: string;
+  model_type: ModelType;
+  enabled: boolean;
+}
+
+export interface ModelProviderCreateRequest {
+  name: string;
+  provider_type: ModelProviderType;
+  base_url: string;
+  api_key: string;
+  models: ModelItem[];
+  default_model?: string | null;
+  extra_config_json?: Record<string, unknown>;
+}
+
+export interface ModelProviderUpdateRequest {
+  name?: string;
+  base_url?: string;
+  api_key?: string;
+  models?: ModelItem[];
+  default_model?: string | null;
+  status?: ModelProviderStatus;
+  extra_config_json?: Record<string, unknown>;
+}
+
+export interface ModelProviderTestResult {
+  success: boolean;
+  message: string;
+  latency_ms?: number;
+  model_list?: string[];
+}
+
+/** A model returned by the auto-discover endpoint */
+export interface DiscoveredModel {
+  model_id: string;
+  display_name: string;
+  model_type: ModelType;
+  owned_by?: string;
+  context_window?: number;
+}
+
+export interface DiscoverModelsResponse {
+  provider_type: ModelProviderType;
+  models: DiscoveredModel[];
+}

+ 0 - 4
web/src/types/runtime.ts

@@ -5,7 +5,6 @@ export type NodeRunStatus = "pending" | "queued" | "running" | "completed" | "fa
 
 export interface WorkflowRun {
   id: string;
-  tenant_id: string;
   app_id: string;
   app_version_id: string;
   workflow_id: string;
@@ -24,7 +23,6 @@ export interface WorkflowRun {
 
 export interface NodeRun {
   id: string;
-  tenant_id: string;
   run_id: string;
   node_id: string;
   node_type: string;
@@ -40,7 +38,6 @@ export interface NodeRun {
 
 export interface ExecutionLog {
   id: string;
-  tenant_id: string;
   run_id: string;
   node_run_id?: string | null;
   event_type: string;
@@ -52,7 +49,6 @@ export interface ExecutionLog {
 
 export interface TraceSpan {
   id: string;
-  tenant_id: string;
   run_id: string;
   node_run_id?: string | null;
   parent_span_id?: string | null;

+ 0 - 3
web/src/types/session.ts

@@ -2,7 +2,6 @@ import type { JSONObject } from "./common";
 
 export interface Session {
   id: string;
-  tenant_id: string;
   app_id: string;
   user_id: string;
   channel_type: string;
@@ -15,7 +14,6 @@ export interface Session {
 
 export interface Message {
   id: string;
-  tenant_id: string;
   session_id: string;
   turn_id?: string | null;
   role: string;
@@ -27,7 +25,6 @@ export interface Message {
 
 export interface RunRequest {
   id: string;
-  tenant_id: string;
   session_id: string;
   app_version_id: string;
   workflow_version_id: string;

+ 0 - 3
web/src/types/team.ts

@@ -5,7 +5,6 @@ export type TeamRunStatus = "queued" | "running" | "completed" | "failed" | "can
 
 export interface TeamDefinition {
   id: string;
-  tenant_id: string;
   code: string;
   name: string;
   description?: string | null;
@@ -17,7 +16,6 @@ export interface TeamDefinition {
 
 export interface TeamVersion {
   id: string;
-  tenant_id: string;
   team_id: string;
   version_no: number;
   status: "draft" | "published" | "deprecated";
@@ -31,7 +29,6 @@ export interface TeamVersion {
 
 export interface TeamRun {
   id: string;
-  tenant_id: string;
   team_id: string;
   team_version_id: string;
   session_id?: string | null;

+ 0 - 4
web/src/types/tool.ts

@@ -2,7 +2,6 @@ import type { JSONObject } from "./common";
 
 export interface ToolDefinition {
   id: string;
-  tenant_id: string;
   plugin_id?: string | null;
   code: string;
   name: string;
@@ -13,7 +12,6 @@ export interface ToolDefinition {
 
 export interface ToolVersion {
   id: string;
-  tenant_id: string;
   tool_id: string;
   version_no: number;
   input_schema_json?: JSONObject | null;
@@ -26,7 +24,6 @@ export interface ToolVersion {
 
 export interface ToolBinding {
   id: string;
-  tenant_id: string;
   app_id: string;
   tool_version_id: string;
   credential_id?: string | null;
@@ -38,7 +35,6 @@ export interface ToolBinding {
 
 export interface ToolCredential {
   id: string;
-  tenant_id: string;
   name: string;
   credential_type: string;
   secret_fingerprint: string;

+ 5 - 0
web/src/vite-env.d.ts

@@ -1 +1,6 @@
 /// <reference types="vite/client" />
+
+declare module "*.json" {
+  const value: Record<string, unknown>;
+  export default value;
+}