diff --git a/.env.development b/.env.development
index f5de60af9..81cc43ce1 100644
--- a/.env.development
+++ b/.env.development
@@ -3,6 +3,9 @@
JMS_KOKO_DEV_URL=http://localhost:5050
+# Platform AI runs as an independent Core service (defaults to localhost:8088).
+# JMS_AI_DEV_URL=http://localhost:8088
+
# Lion 默认复用 JMS_KOKO_DEV_URL;仅兼容独立调试时覆盖
# JMS_LION_DEV_URL=http://localhost:5050
# JMS_CHEN_DEV_URL=http://localhost:8082
diff --git a/electron/auth.mjs b/electron/auth.mjs
index b3b780fe7..6ffe2d5bf 100644
--- a/electron/auth.mjs
+++ b/electron/auth.mjs
@@ -20,6 +20,33 @@ function endpoint(site, endpointPath) {
return `${site.replace(/\/+$/, "")}${endpointPath}`;
}
+function requestSite(session, request) {
+ if (request.service !== "chat-ai") return session.origin;
+
+ const configured = String(process.env.JMS_AI_DESKTOP_URL || process.env.JMS_AI_DEV_URL || "").trim();
+ if (configured) {
+ const parsed = new URL(configured);
+ if (!["http:", "https:"].includes(parsed.protocol) || !parsed.hostname || parsed.username || parsed.password) {
+ throw new Error("Chat AI endpoint must be an HTTP/HTTPS URL without embedded credentials");
+ }
+ return configured.replace(/\/+$/, "");
+ }
+
+ if (process.env.JMS_ELECTRON_DEV === "1") {
+ const rendererUrl = String(process.env.JMS_ELECTRON_RENDERER_URL || "").trim();
+ if (rendererUrl) {
+ const renderer = new URL(rendererUrl);
+ if (["http:", "https:"].includes(renderer.protocol) && renderer.hostname) return renderer.origin;
+ }
+ const site = new URL(session.origin);
+ if (["localhost", "127.0.0.1", "::1"].includes(site.hostname)) {
+ site.port = "8088";
+ return site.origin;
+ }
+ }
+ return session.origin;
+}
+
function timezoneOffset() {
const totalMinutes = -new Date().getTimezoneOffset();
const sign = totalMinutes >= 0 ? "+" : "-";
@@ -292,7 +319,7 @@ export class DesktopAuthService {
const session = this.currentSession();
const bearer = await this.freshToken(session.origin, session.sessionKey, session.bearerToken);
session.bearerToken = bearer;
- const url = new URL(endpoint(session.origin, request.path));
+ const url = new URL(endpoint(requestSite(session, request), request.path));
for (const [key, value] of Object.entries(request.query || {})) {
if (value === undefined || value === null) continue;
if (Array.isArray(value)) value.forEach((item) => url.searchParams.append(key, String(item)));
@@ -320,6 +347,48 @@ export class DesktopAuthService {
return JSON.parse(text);
}
+ async apiStreamRequest(request, { signal, onChunk } = {}) {
+ const session = this.currentSession();
+ const bearer = await this.freshToken(session.origin, session.sessionKey, session.bearerToken);
+ session.bearerToken = bearer;
+ const url = new URL(endpoint(requestSite(session, request), request.path));
+ for (const [key, value] of Object.entries(request.query || {})) {
+ if (value === undefined || value === null) continue;
+ if (Array.isArray(value)) value.forEach((item) => url.searchParams.append(key, String(item)));
+ else url.searchParams.set(key, typeof value === "object" ? JSON.stringify(value) : String(value));
+ }
+ const headers = {
+ Accept: "text/event-stream",
+ "X-TZ": timezoneOffset(),
+ Referer: url.origin,
+ Authorization: `Bearer ${bearer}`
+ };
+ const orgId = request.orgId || session.orgId;
+ if (orgId) headers["X-JMS-ORG"] = orgId;
+ const hasBody = request.body !== undefined && request.body !== null;
+ if (hasBody) headers["Content-Type"] = "application/json";
+ const response = await net.fetch(url.toString(), {
+ method: request.method,
+ headers,
+ body: hasBody ? JSON.stringify(request.body) : undefined,
+ signal
+ });
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(`api stream request failed: status=${response.status}, body=${text}`);
+ }
+ if (!response.body) throw new Error("api stream response body is unavailable");
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder("utf-8");
+ while (true) {
+ const { value, done } = await reader.read();
+ const chunk = decoder.decode(value || new Uint8Array(), { stream: !done });
+ if (chunk) onChunk?.(chunk);
+ if (done) break;
+ }
+ }
+
async createKokoConnectTicket({ baseUrl, tokenId }) {
const session = this.currentSession();
const bearer = await this.freshToken(session.origin, session.sessionKey, session.bearerToken);
diff --git a/electron/main.mjs b/electron/main.mjs
index 5a8b1b438..eb44a23ef 100644
--- a/electron/main.mjs
+++ b/electron/main.mjs
@@ -56,6 +56,7 @@ const windows = new Map();
const subscriptions = new Map();
const stores = new Map();
const localShellSessions = new Map();
+const apiStreams = new Map();
const webProxyViews = new Map();
const allowedChenOrigins = new Set();
const allowedKokoOrigins = new Set();
@@ -251,6 +252,44 @@ function emitDesktopEvent(name, payload, targetLabel) {
}
}
+function startApiStream(event, win, args) {
+ const streamId = String(args.streamId || "").trim();
+ if (!streamId) throw new Error("api stream id is required");
+ if (apiStreams.has(streamId)) throw new Error("api stream already exists");
+
+ const controller = new AbortController();
+ const owner = { controller, webContentsId: event.sender.id };
+ apiStreams.set(streamId, owner);
+ const label = labelForWindow(win);
+ void authService
+ .apiStreamRequest(args.request, {
+ signal: controller.signal,
+ onChunk: (chunk) => emitDesktopEvent("api-stream", { streamId, type: "chunk", chunk }, label)
+ })
+ .then(() => emitDesktopEvent("api-stream", { streamId, type: "done" }, label))
+ .catch((error) => {
+ if (controller.signal.aborted) return;
+ emitDesktopEvent(
+ "api-stream",
+ { streamId, type: "error", error: error instanceof Error ? error.message : String(error) },
+ label
+ );
+ })
+ .finally(() => {
+ if (apiStreams.get(streamId) === owner) apiStreams.delete(streamId);
+ });
+ return null;
+}
+
+function cancelApiStream(event, args) {
+ const streamId = String(args.streamId || "").trim();
+ const stream = apiStreams.get(streamId);
+ if (!stream || stream.webContentsId !== event.sender.id) return false;
+ apiStreams.delete(streamId);
+ stream.controller.abort();
+ return true;
+}
+
function shellCommand() {
if (process.platform === "win32") {
const shell = process.env.ComSpec || "powershell.exe";
@@ -700,6 +739,11 @@ function createWindow(label = "main", options = {}) {
localShellSessions.delete(sessionId);
session.process.kill();
}
+ for (const [streamId, stream] of apiStreams) {
+ if (stream.webContentsId !== windowWebContentsId) continue;
+ apiStreams.delete(streamId);
+ stream.controller.abort();
+ }
for (const [viewLabel, managed] of webProxyViews) {
if (managed.hostWebContentsId !== windowWebContentsId) continue;
webProxyViews.delete(viewLabel);
@@ -1152,6 +1196,8 @@ async function handleInvoke(event, request) {
if (command === "auth_cancel") return authService.cancelAuth();
if (command === "bootstrap_auth_session") return authService.bootstrapAuthSession(args);
if (command === "api_request") return authService.apiRequest(args.request);
+ if (command === "api_stream_start") return startApiStream(event, win, args);
+ if (command === "api_stream_cancel") return cancelApiStream(event, args);
if (command === "resolve_chen_endpoint") return resolveChenEndpoint();
if (command === "resolve_koko_endpoint") return resolveKokoEndpoint();
if (command === "create_koko_connect_ticket") return authService.createKokoConnectTicket(args);
diff --git a/i18n/locales/en.json b/i18n/locales/en.json
index 5e502e94e..415f43378 100644
--- a/i18n/locales/en.json
+++ b/i18n/locales/en.json
@@ -253,6 +253,43 @@
"Control": "Control",
"Files": "Files",
"AI": "AI",
+ "PlatformAIName": "JumpServer AI",
+ "PlatformAIHistory": "Conversation history",
+ "PlatformAINewChat": "New chat",
+ "PlatformAINoHistory": "No conversation history",
+ "PlatformAIUntitledConversation": "Untitled conversation",
+ "PlatformAIRename": "Rename conversation",
+ "PlatformAIDelete": "Delete conversation",
+ "PlatformAIDeleteDescription": "Delete “{title}”? This conversation cannot be recovered.",
+ "PlatformAIUnavailableTitle": "Platform AI is unavailable",
+ "PlatformAIUnavailableDescription": "Platform AI must be enabled in Core and allowed for your account. Your organization scope is preserved.",
+ "PlatformAIRetry": "Try again",
+ "PlatformAIWelcomeTitle": "How can I help?",
+ "PlatformAIInputPlaceholder": "Ask about JumpServer or describe an administrative task…",
+ "PlatformAIScopeNotice": "Platform and current organization",
+ "PlatformAIDisclaimer": "AI can make mistakes. Review API operations before approval.",
+ "PlatformAIWorking": "Working…",
+ "PlatformAIStop": "Stop",
+ "PlatformAIActivity": "Activity ({count})",
+ "PlatformAISearchingAPI": "Finding an authorized Core API",
+ "PlatformAIFoundAPI": "Found {count} API operations",
+ "PlatformAIWebSearch": "Web search",
+ "PlatformAICoreAPI": "Core API operation",
+ "PlatformAIFailed": "Platform AI failed",
+ "PlatformAIApprovalTitle": "Approve this platform operation?",
+ "PlatformAIApprovalDescription": "The assistant is requesting a write operation in the current JumpServer permission and organization scope.",
+ "PlatformAIApprovalRecovery": "This approval was opened in another panel session. Cancel the task to continue safely.",
+ "PlatformAIRunContinuing": "This task is still running on the server",
+ "PlatformAIAssistantGeneral": "General assistant",
+ "PlatformAIAssistantGeneralDescription": "Answers product questions and helps you use JumpServer safely.",
+ "PlatformAIAssistantManagement": "Management assistant",
+ "PlatformAIAssistantManagementDescription": "Helps inspect settings, users, permissions, and platform state.",
+ "PlatformAIAssistantAsset": "Asset assistant",
+ "PlatformAIAssistantAssetDescription": "Helps find and understand assets, nodes, accounts, and authorization.",
+ "PlatformAIAssistantAudit": "Session audit assistant",
+ "PlatformAIAssistantAuditDescription": "Helps investigate sessions, logins, commands, and audit records.",
+ "PlatformAIAssistantOps": "Operations assistant",
+ "PlatformAIAssistantOpsDescription": "Helps inspect jobs, components, and operational health.",
"SFTP": "Files",
"SFTPTooltip": "Current session files (lightweight)",
"Clipboard": "Clipboard",
diff --git a/i18n/locales/zh.json b/i18n/locales/zh.json
index 2515d9d7c..c7be8d8b8 100644
--- a/i18n/locales/zh.json
+++ b/i18n/locales/zh.json
@@ -253,6 +253,43 @@
"Control": "控制",
"Files": "文件",
"AI": "AI",
+ "PlatformAIName": "JumpServer AI",
+ "PlatformAIHistory": "历史会话",
+ "PlatformAINewChat": "新会话",
+ "PlatformAINoHistory": "暂无历史会话",
+ "PlatformAIUntitledConversation": "未命名会话",
+ "PlatformAIRename": "重命名会话",
+ "PlatformAIDelete": "删除会话",
+ "PlatformAIDeleteDescription": "确定删除“{title}”吗?删除后无法恢复。",
+ "PlatformAIUnavailableTitle": "平台 AI 当前不可用",
+ "PlatformAIUnavailableDescription": "需要在 Core 中启用平台 AI,并为当前账号授予使用权限;请求会保持当前组织范围。",
+ "PlatformAIRetry": "重试",
+ "PlatformAIWelcomeTitle": "有什么可以帮你?",
+ "PlatformAIInputPlaceholder": "询问 JumpServer,或描述一个平台管理任务…",
+ "PlatformAIScopeNotice": "平台与当前组织范围",
+ "PlatformAIDisclaimer": "AI 可能出错,批准前请核对 API 操作。",
+ "PlatformAIWorking": "正在处理…",
+ "PlatformAIStop": "停止",
+ "PlatformAIActivity": "执行动态({count})",
+ "PlatformAISearchingAPI": "正在查找已授权的 Core API",
+ "PlatformAIFoundAPI": "找到 {count} 个 API 操作",
+ "PlatformAIWebSearch": "网页搜索",
+ "PlatformAICoreAPI": "Core API 操作",
+ "PlatformAIFailed": "平台 AI 执行失败",
+ "PlatformAIApprovalTitle": "批准此平台操作?",
+ "PlatformAIApprovalDescription": "助手正在请求写操作,实际权限与数据范围仍受当前 JumpServer 账号和组织限制。",
+ "PlatformAIApprovalRecovery": "该审批来自之前的面板会话。为保证安全,请取消任务后再继续。",
+ "PlatformAIRunContinuing": "任务仍在服务端运行",
+ "PlatformAIAssistantGeneral": "通用助手",
+ "PlatformAIAssistantGeneralDescription": "回答产品问题,帮助你安全使用 JumpServer。",
+ "PlatformAIAssistantManagement": "管理助手",
+ "PlatformAIAssistantManagementDescription": "协助查看设置、用户、权限和平台状态。",
+ "PlatformAIAssistantAsset": "资产助手",
+ "PlatformAIAssistantAssetDescription": "协助查找和理解资产、节点、账号与授权。",
+ "PlatformAIAssistantAudit": "会话审计助手",
+ "PlatformAIAssistantAuditDescription": "协助调查会话、登录、命令和审计记录。",
+ "PlatformAIAssistantOps": "运维助手",
+ "PlatformAIAssistantOpsDescription": "协助查看任务、组件和运行健康状况。",
"SFTP": "文件",
"SFTPTooltip": "当前会话文件(轻量)",
"Clipboard": "剪贴板",
diff --git a/nuxt.config.ts b/nuxt.config.ts
index 2b15782ec..9bcba8201 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -1,4 +1,5 @@
const jumpServerTarget = process.env.JMS_CORE_DEV_URL || "http://localhost:8080";
+const chatAiTarget = process.env.JMS_AI_DEV_URL || "http://localhost:8088";
const kokoTarget = process.env.JMS_KOKO_DEV_URL || "http://localhost:5050";
// JMS_LION_DEV_URL remains a compatibility override; Lion is served by Koko by default.
const lionTarget = process.env.JMS_LION_DEV_URL || kokoTarget;
@@ -118,6 +119,12 @@ export default defineNuxtConfig({
port: Number(process.env.JMS_HMR_PORT || 3001)
},
proxy: {
+ "/api/v1/chat-ai/": {
+ target: chatAiTarget,
+ secure: false,
+ changeOrigin: true,
+ configure: configureHttpProxy("chat-ai", chatAiTarget)
+ },
"/luna/koko/ws/": {
target: kokoTarget.replace(/^http/i, "ws"),
secure: false,
diff --git a/ui/components/Header/ActionButtons.vue b/ui/components/Header/ActionButtons.vue
index 9aa1f308d..90162e55d 100644
--- a/ui/components/Header/ActionButtons.vue
+++ b/ui/components/Header/ActionButtons.vue
@@ -3,13 +3,13 @@ import Profile from "~/components/SideBar/profile.vue";
const { t } = useI18n();
const { activeWorkspaceMode } = useWorkspaceMode();
-const { activeTab: rightPanelTab, open: rightPanelOpen, toggle: toggleRightPanel } = useRightPanel();
+const { open: rightPanelOpen, toggle: toggleRightPanel } = useRightPanel();
const { open: aiPanelOpen, toggleAi } = useAiPanel();
const showRightPanelButton = computed(() => activeWorkspaceMode.value !== "files");
const aiButtonLabel = computed(() => t(aiPanelOpen.value ? "RightPanel.AIClose" : "RightPanel.AIOpen"));
const handleToggleAi = () => {
- toggleAi(rightPanelOpen.value && rightPanelTab.value === "sftp" ? "sftp" : "workspace");
+ toggleAi();
};
@@ -18,6 +18,7 @@ const handleToggleAi = () => {
+import WorkspaceAiPanel from "./aiPanel.vue";
+import PlatformAiPanel from "./PlatformAiPanel.vue";
+
const emit = defineEmits<{ close: [] }>();
const { t } = useI18n();
const isNarrowScreen = useMediaQuery("(max-width: 767px)");
+const { activeWorkspaceMode } = useWorkspaceMode();
+const { activePaneId, activeTab } = useWorkspaceTabs();
+const { activeTab: rightPanelTab, open: rightPanelOpen } = useRightPanel();
+const { mode, setSource, workspaceFocused } = useAiPanel();
+const activeSurface = computed(() => {
+ const tab = activeTab.value;
+ return tab?.panes.find((pane) => pane.id === activePaneId.value) || tab;
+});
+
+watchEffect(() => {
+ setSource(
+ resolveAiPanelSource({
+ workspaceMode: activeWorkspaceMode.value,
+ surfaceStatus: activeSurface.value?.status,
+ surfaceAssetId: activeSurface.value?.assetId,
+ standaloneWorkspace: !activeTab.value && Boolean(activePaneId.value),
+ workspaceFocused: workspaceFocused.value,
+ rightPanelOpen: rightPanelOpen.value,
+ rightPanelTab: rightPanelTab.value
+ })
+ );
+});
-
+