Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.development
Original file line number Diff line number Diff line change
Expand Up @@ -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
71 changes: 70 additions & 1 deletion electron/auth.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,33 @@
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(/\/+$/, "");

Check warning on line 32 in electron/auth.mjs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Simplify this regular expression to reduce its runtime, as it has super-linear performance due to backtracking.

See more on https://sonarcloud.io/project/issues?id=jumpserver_luna&issues=AaBHmHR6JV0savksxfpE&open=AaBHmHR6JV0savksxfpE&pullRequest=1607
}

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 ? "+" : "-";
Expand Down Expand Up @@ -292,7 +319,7 @@
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)));
Expand Down Expand Up @@ -320,6 +347,48 @@
return JSON.parse(text);
}

async apiStreamRequest(request, { signal, onChunk } = {}) {

Check failure on line 350 in electron/auth.mjs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 20 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=jumpserver_luna&issues=AaBHmHR6JV0savksxfpF&open=AaBHmHR6JV0savksxfpF&pullRequest=1607
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(), {

Check warning on line 370 in electron/auth.mjs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Change this code to not construct the URL from user-controlled data.

See more on https://sonarcloud.io/project/issues?id=jumpserver_luna&issues=AaBHmHR6JV0savksxfpG&open=AaBHmHR6JV0savksxfpG&pullRequest=1607
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);
Expand Down
46 changes: 46 additions & 0 deletions electron/main.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
37 changes: 37 additions & 0 deletions i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
37 changes: 37 additions & 0 deletions i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "剪贴板",
Expand Down
7 changes: 7 additions & 0 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions ui/components/Header/ActionButtons.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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();
};
</script>

Expand All @@ -18,6 +18,7 @@ const handleToggleAi = () => {
<div class="flex items-center gap-1 px-2">
<UTooltip arrow :text="aiButtonLabel">
<UButton
data-ai-context="preserve"
icon="i-lucide-sparkles"
:aria-label="aiButtonLabel"
:aria-pressed="aiPanelOpen"
Expand Down
35 changes: 31 additions & 4 deletions ui/components/RightPanel/AiOverlayPanel.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,36 @@
<script setup lang="ts">
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
})
);
});
</script>

<template>
<div class="pointer-events-none absolute inset-0 z-50">
<div data-ai-context="preserve" class="pointer-events-none absolute inset-0 z-50">
<button
v-if="isNarrowScreen"
type="button"
Expand All @@ -17,9 +42,9 @@ const isNarrowScreen = useMediaQuery("(max-width: 767px)");
<aside
class="pointer-events-auto absolute inset-y-0 right-0 flex w-[min(380px,calc(100vw-3rem))] min-h-0 flex-col border-l border-[var(--app-border)] bg-[var(--app-panel-bg)] text-[var(--app-fg)] shadow-2xl"
>
<div class="flex h-10 shrink-0 items-center gap-2 border-b border-[var(--app-border)] px-3">
<div class="flex h-10 shrink-0 items-center gap-2 border-b border-[var(--app-border)] px-2">
<UIcon name="i-lucide-sparkles" class="size-4 text-primary" />
<span class="min-w-0 flex-1 truncate text-sm font-semibold">{{ t("RightPanel.AI") }}</span>
<span class="min-w-0 flex-1 truncate text-sm font-medium">{{ t("RightPanel.AI") }}</span>
<UButton
icon="i-lucide-x"
:aria-label="t('RightPanel.AIClose')"
Expand All @@ -31,7 +56,9 @@ const isNarrowScreen = useMediaQuery("(max-width: 767px)");
</div>

<div class="min-h-0 flex-1 overflow-hidden">
<RightPanelAiPanel />
<KeepAlive>
<component :is="mode === 'platform' ? PlatformAiPanel : WorkspaceAiPanel" />
</KeepAlive>
</div>
</aside>
</div>
Expand Down
Loading
Loading