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 + }) + ); +});