diff --git a/i18n/locales/en.json b/i18n/locales/en.json index e8e9651e..b21016a0 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -159,11 +159,48 @@ "PrimaryUser": "Primary", "RemoveShareUser": "Remove user", "BatchCommand": "Batch", - "AIEmptyTitle": "AI assistant", - "AIEmptyDescription": "Ask questions and get ops suggestions or command help.", - "AIInputPlaceholder": "Describe your task or the command you want to run…", + "AIUnavailableTitle": "AI is unavailable for this terminal", + "AIUnavailableDescription": "Select a connected Koko terminal with Terminal AI enabled.", + "AIHeaderDescription": "Uses the active terminal context", + "AIEmptyTitle": "Ready to assist this terminal", + "AIEmptyDescription": "Ask for analysis or have Terminal AI plan and execute a task.", + "AIInputPlaceholder": "Ask about this terminal or describe a task…", "AISend": "Send", - "AIPlaceholderReply": "AI integration is coming soon. Use the batch command panel for now.", + "AIYou": "You", + "AIApprovalAll": "Approve every command", + "AIApprovalRisk2": "Approve risk 2 and above", + "AIApprovalRisk3": "Approve risk 3 and above", + "AIApprovalRisk4": "Approve risk 4 only", + "AIModeAuto": "AI chooses", + "AIModePty": "Current PTY only", + "AIModeBackground": "Background only", + "AIExecutionPlan": "Execution plan", + "AIStep": "Step {count}", + "AICommand": "Command", + "AIRisk": "Risk {level}", + "AIAutoApproved": "Auto approved", + "AICurrentPty": "Current PTY", + "AIBackgroundExecution": "Background", + "AIApprove": "Approve", + "AIReject": "Reject", + "AIExecutionResult": "Execution result", + "AICommandAcl": "Command ACL", + "AIInterrupt": "Interrupt task", + "AISendFailed": "Failed to send the Terminal AI message", + "AIPolicyFailed": "Failed to update the Terminal AI policy", + "AIApprovalFailed": "Failed to submit the Terminal AI decision", + "AIInterruptFailed": "Failed to interrupt the Terminal AI task", + "AIStatusApproved": "Approved", + "AIStatusAutoApproved": "Auto approved", + "AIStatusAwaitingApproval": "Awaiting approval", + "AIStatusCompleted": "Completed", + "AIStatusFailed": "Failed", + "AIStatusRunning": "Running", + "AIStatusInterrupted": "Interrupted", + "AIStatusPending": "Pending", + "AIStatusRejected": "Rejected", + "AIStatusReviewing": "Reviewing", + "AIStatusSkipped": "Skipped", "SFTPSearchPlaceholder": "Search assets for file transfer…", "SFTPEmptyTitle": "Choose an SFTP asset", "SFTPEmptyDescription": "Search for an asset or reuse the active terminal host.", @@ -527,6 +564,7 @@ "removedFromShare": "Removed from shared session", "websocketClosed": "WebSocket closed", "websocketConnectionClosed": "WebSocket connection is closed. Refresh the page.", + "invalidMessage": "Received an invalid terminal message", "shareLink": "Link", "verificationCode": "Code", "shareLinkCopied": "Share link copied" diff --git a/i18n/locales/zh.json b/i18n/locales/zh.json index 602247ef..45d1209b 100644 --- a/i18n/locales/zh.json +++ b/i18n/locales/zh.json @@ -159,11 +159,48 @@ "PrimaryUser": "主用户", "RemoveShareUser": "移除用户", "BatchCommand": "批量命令", - "AIEmptyTitle": "AI 助手", - "AIEmptyDescription": "输入问题,获取运维建议与命令辅助。", - "AIInputPlaceholder": "描述你的问题或想执行的运维任务…", + "AIUnavailableTitle": "当前终端不可使用 AI", + "AIUnavailableDescription": "请选择一个已连接且已启用终端 AI 的 Koko 终端。", + "AIHeaderDescription": "基于当前激活终端的上下文", + "AIEmptyTitle": "准备协助当前终端", + "AIEmptyDescription": "可以分析终端问题,也可以规划并执行操作。", + "AIInputPlaceholder": "询问当前终端或描述要完成的任务…", "AISend": "发送", - "AIPlaceholderReply": "AI 能力接入中。当前可先使用批量命令面板。", + "AIYou": "你", + "AIApprovalAll": "全部命令审批", + "AIApprovalRisk2": "风险 2 及以上审批", + "AIApprovalRisk3": "风险 3 及以上审批", + "AIApprovalRisk4": "仅风险 4 审批", + "AIModeAuto": "AI 自动选择", + "AIModePty": "仅当前 PTY", + "AIModeBackground": "仅后台执行", + "AIExecutionPlan": "执行计划", + "AIStep": "步骤 {count}", + "AICommand": "命令", + "AIRisk": "风险 {level}", + "AIAutoApproved": "自动放行", + "AICurrentPty": "当前 PTY", + "AIBackgroundExecution": "后台执行", + "AIApprove": "批准", + "AIReject": "拒绝", + "AIExecutionResult": "执行结果", + "AICommandAcl": "命令 ACL", + "AIInterrupt": "中断任务", + "AISendFailed": "发送终端 AI 消息失败", + "AIPolicyFailed": "更新终端 AI 策略失败", + "AIApprovalFailed": "提交终端 AI 审批失败", + "AIInterruptFailed": "中断终端 AI 任务失败", + "AIStatusApproved": "已批准", + "AIStatusAutoApproved": "自动批准", + "AIStatusAwaitingApproval": "等待审批", + "AIStatusCompleted": "完成", + "AIStatusFailed": "失败", + "AIStatusRunning": "执行中", + "AIStatusInterrupted": "已中断", + "AIStatusPending": "待执行", + "AIStatusRejected": "已拒绝", + "AIStatusReviewing": "分析结果", + "AIStatusSkipped": "未执行", "SFTPSearchPlaceholder": "搜索要传输文件的资产…", "SFTPEmptyTitle": "选择 SFTP 资产", "SFTPEmptyDescription": "搜索资产,或复用当前活动终端对应主机。", @@ -530,6 +567,7 @@ "removedFromShare": "已从共享会话中移除", "websocketClosed": "WebSocket 已关闭", "websocketConnectionClosed": "WebSocket 连接已关闭,请刷新页面", + "invalidMessage": "收到无效的终端消息", "shareLink": "链接", "verificationCode": "验证码", "shareLinkCopied": "分享链接已复制" diff --git a/nuxt.config.ts b/nuxt.config.ts index 5f259e37..98b0c8e8 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -111,14 +111,6 @@ export default defineNuxtConfig({ rewrite: (path) => path.replace(/^\/luna/, ""), configure: bindProxyErrorHandler("luna-koko-ws") }, - "/luna/koko/": { - target: kokoTarget, - secure: false, - ws: true, - changeOrigin: true, - rewrite: (path) => path.replace(/^\/luna/, ""), - configure: bindProxyErrorHandler("luna-koko-http") - }, "/koko/ws/": { target: kokoTarget.replace(/^http/i, "ws"), secure: false, diff --git a/package.json b/package.json index df2b68bf..3f1f27bd 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "author": "JumpServer", "license": "MIT", "engines": { - "node": ">=20.19" + "node": ">=22" }, "scripts": { "dev": "nuxt dev", @@ -40,6 +40,7 @@ "reset": "rm -rf node_modules .nuxt dist && cd src-tauri && cargo clean" }, "dependencies": { + "@ai-sdk/vue": "4.0.44", "@codemirror/commands": "^6.10.4", "@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-css": "^6.3.1", @@ -85,11 +86,14 @@ "@xterm/xterm": "^6.0.0", "ag-grid-community": "^36.0.0", "ag-grid-vue3": "^36.0.0", + "ai": "7.0.44", "clipboard-polyfill": "^4.1.1", "codemirror": "^6.0.2", + "dompurify": "3.4.12", "fflate": "^0.8.2", "guacamole-common-js-jumpserver": "1.1.0-c", "js-untar": "^2.0.0", + "marked": "17.0.6", "mitt": "^3.0.1", "nuxt": "^4.4.6", "pinia": "^3.0.4", diff --git a/packages/koko/app/composables/terminal/envelope.ts b/packages/koko/app/composables/terminal/envelope.ts new file mode 100644 index 00000000..097e7b06 --- /dev/null +++ b/packages/koko/app/composables/terminal/envelope.ts @@ -0,0 +1,90 @@ +export const ENVELOPE_VERSION = 0x01; +export const ENVELOPE_TERMINAL_INPUT = 0x01; +export const ENVELOPE_TERMINAL_OUTPUT = 0x02; +export const ENVELOPE_TERMINAL_COMMAND = 0x03; +export const ENVELOPE_ERROR = 0x04; +export const ENVELOPE_CHAT = 0x05; +export const ENVELOPE_TERMINAL_CREATE = 0x06; +export const ENVELOPE_TERMINAL_CLOSE = 0x07; + +const HEADER_SIZE = 6; +const MAX_PAYLOAD_SIZE = 10 * 1024 * 1024; +const encoder = new TextEncoder(); +const decoder = new TextDecoder("utf-8", { fatal: true }); + +export interface TerminalEnvelope { + type: number; + payload: Uint8Array; +} + +export interface TerminalCommandEnvelope { + terminalId?: number; + command: string; + params?: Record; + requestId?: string; + timestamp?: number; +} + +export function buildEnvelope(type: number, payload: Uint8Array) { + if (payload.byteLength > MAX_PAYLOAD_SIZE) { + throw new Error("WebSocket envelope payload is too large"); + } + + const frame = new Uint8Array(HEADER_SIZE + payload.byteLength); + const view = new DataView(frame.buffer); + frame[0] = ENVELOPE_VERSION; + frame[1] = type; + view.setUint32(2, payload.byteLength, false); + frame.set(payload, HEADER_SIZE); + return frame; +} + +export function parseEnvelope(value: ArrayBuffer | Uint8Array): TerminalEnvelope { + const frame = value instanceof Uint8Array ? value : new Uint8Array(value); + if (frame.byteLength < HEADER_SIZE || frame[0] !== ENVELOPE_VERSION) { + throw new Error("Invalid WebSocket envelope"); + } + + const length = new DataView(frame.buffer, frame.byteOffset, frame.byteLength).getUint32(2, false); + if (length > MAX_PAYLOAD_SIZE || frame.byteLength !== HEADER_SIZE + length) { + throw new Error("Invalid WebSocket envelope length"); + } + + return { type: frame[1]!, payload: frame.slice(HEADER_SIZE) }; +} + +export function buildJSONEnvelope(type: number, value: unknown) { + return buildEnvelope(type, encoder.encode(JSON.stringify(value))); +} + +export function parseJSONPayload(payload: Uint8Array): T { + return JSON.parse(decoder.decode(payload)) as T; +} + +export function buildTerminalInput(terminalId: string | number, value: string | Uint8Array) { + const normalizedTerminalId = Number(terminalId); + if (!Number.isInteger(normalizedTerminalId) || normalizedTerminalId <= 0) { + throw new Error("A valid terminalId is required"); + } + + const data = typeof value === "string" ? encoder.encode(value) : value; + const payload = new Uint8Array(4 + data.byteLength); + new DataView(payload.buffer).setUint32(0, normalizedTerminalId, false); + payload.set(data, 4); + return buildEnvelope(ENVELOPE_TERMINAL_INPUT, payload); +} + +export function parseTerminalPayload(payload: Uint8Array) { + if (payload.byteLength < 4) { + throw new Error("Terminal envelope payload is too short"); + } + + return { + terminalId: new DataView(payload.buffer, payload.byteOffset, payload.byteLength).getUint32(0, false), + data: payload.slice(4) + }; +} + +export function createRequestId(prefix = "request") { + return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} diff --git a/packages/koko/app/composables/terminal/protocol.ts b/packages/koko/app/composables/terminal/protocol.ts index 5ce33440..365ded76 100644 --- a/packages/koko/app/composables/terminal/protocol.ts +++ b/packages/koko/app/composables/terminal/protocol.ts @@ -16,10 +16,12 @@ export enum TerminalWebSocketProtocol { export interface TerminalIncomingMessage { id: string; - type: MESSAGE_TYPE; + type: string; data?: string; err?: string; raw?: string | number[]; + terminalId?: number; + requestId?: string; } const messageTypes = new Set(Object.values(MESSAGE_TYPE)); @@ -32,13 +34,15 @@ export function parseTerminalIncomingMessage(raw: unknown): TerminalIncomingMess if (!raw || typeof raw !== "object") return null; const message = raw as Record; - if (typeof message.id !== "string" || !isTerminalMessageType(message.type)) return null; + if (typeof message.type !== "string" || !message.type) return null; return { - id: message.id, + id: typeof message.id === "string" ? message.id : "", type: message.type, data: typeof message.data === "string" ? message.data : undefined, err: typeof message.err === "string" ? message.err : undefined, - raw: typeof message.raw === "string" || Array.isArray(message.raw) ? message.raw : undefined + raw: typeof message.raw === "string" || Array.isArray(message.raw) ? message.raw : undefined, + terminalId: typeof message.terminalId === "number" ? message.terminalId : undefined, + requestId: typeof message.requestId === "string" ? message.requestId : undefined }; } diff --git a/packages/koko/app/composables/terminal/terminal.browser.spec.ts b/packages/koko/app/composables/terminal/terminal.browser.spec.ts index 4484f5d6..b563b997 100644 --- a/packages/koko/app/composables/terminal/terminal.browser.spec.ts +++ b/packages/koko/app/composables/terminal/terminal.browser.spec.ts @@ -1,21 +1,40 @@ import { MESSAGE_TYPE } from "@jumpserver/connectors-core"; - import { afterEach, expect, it, vi } from "vitest"; +import { computed } from "vue"; +import { + buildJSONEnvelope, + buildTerminalInput, + ENVELOPE_CHAT, + ENVELOPE_TERMINAL_COMMAND, + parseEnvelope, + parseJSONPayload, + parseTerminalPayload +} from "./envelope"; import { parseTerminalIncomingMessage } from "./protocol"; +import { + getKokoTerminalAiSession, + handleKokoTerminalAiMessage, + isKokoTerminalAiInputLocked, + registerKokoTerminalAiSession, + sendKokoTerminalAiControl, + unregisterKokoTerminalAiSession +} from "./useTerminalAiSessions"; import { useKokoTerminalMessageHandler } from "./useTerminalMessageHandler"; import { saveZmodemPacketsToDisk, sendZmodemFiles } from "./zmodemBrowser"; it("parses terminal wire messages only when they match the protocol", () => { - expect(parseTerminalIncomingMessage({ id: "terminal-1", type: MESSAGE_TYPE.CONNECT, data: "{}" })).toEqual({ + expect(parseTerminalIncomingMessage({ id: "terminal-1", type: MESSAGE_TYPE.CONNECT, data: "{}" })).toMatchObject({ id: "terminal-1", type: MESSAGE_TYPE.CONNECT, - data: "{}", - err: undefined, - raw: undefined + data: "{}" + }); + expect(parseTerminalIncomingMessage({ id: "terminal-1", type: "created", terminalId: 7 })).toMatchObject({ + type: "created", + terminalId: 7 }); - expect(parseTerminalIncomingMessage({ id: "terminal-1", type: "UNKNOWN" })).toBeNull(); - expect(parseTerminalIncomingMessage({ type: MESSAGE_TYPE.CONNECT })).toBeNull(); + expect(parseTerminalIncomingMessage({ type: "" })).toBeNull(); + expect(parseTerminalIncomingMessage({})).toBeNull(); }); it("dispatches parsed terminal messages and ignores malformed payloads", () => { @@ -30,6 +49,214 @@ it("dispatches parsed terminal messages and ignores malformed payloads", () => { expect(received).toEqual(["terminal-1"]); }); +it("encodes and decodes terminal envelopes", () => { + const command = buildJSONEnvelope(ENVELOPE_TERMINAL_COMMAND, { + terminalId: 7, + command: MESSAGE_TYPE.PING + }); + const commandEnvelope = parseEnvelope(command); + + expect(commandEnvelope.type).toBe(ENVELOPE_TERMINAL_COMMAND); + expect(parseJSONPayload(commandEnvelope.payload)).toEqual({ + terminalId: 7, + command: MESSAGE_TYPE.PING + }); + + const inputEnvelope = parseEnvelope(buildTerminalInput(7, "whoami")); + expect(parseTerminalPayload(inputEnvelope.payload)).toMatchObject({ terminalId: 7 }); + expect(new TextDecoder().decode(parseTerminalPayload(inputEnvelope.payload).data)).toBe("whoami"); +}); + +it("keeps terminal AI state isolated by active pane", async () => { + const send = vi.fn(); + const socket = { readyState: WebSocket.OPEN, send } as unknown as WebSocket; + registerKokoTerminalAiSession("pane-1", socket, "11"); + registerKokoTerminalAiSession("pane-2", socket, "22"); + + handleKokoTerminalAiMessage("pane-1", { + id: "capability-1", + role: "assistant", + metadata: { terminalId: 11 }, + parts: [ + { + type: "data-capability", + data: { enabled: true, backgroundExec: true, approvalThreshold: 3, executionMode: "auto" } + } + ] + }); + handleKokoTerminalAiMessage("pane-2", { + id: "lock-2", + role: "assistant", + metadata: { terminalId: 22 }, + parts: [{ type: "data-input-lock", data: { locked: true } }] + }); + handleKokoTerminalAiMessage("pane-1", { + id: "wrong-terminal", + role: "assistant", + metadata: { terminalId: 22 }, + parts: [{ type: "text", text: "ignored" }] + }); + handleKokoTerminalAiMessage("pane-1", null); + handleKokoTerminalAiMessage("pane-1", { + id: "malformed", + role: "assistant", + parts: [null] + }); + + expect(getKokoTerminalAiSession("pane-1")).toMatchObject({ + enabled: true, + backgroundExec: true, + approvalThreshold: 3 + }); + expect(getKokoTerminalAiSession("pane-1")?.chat.messages.value).toHaveLength(0); + expect(isKokoTerminalAiInputLocked("pane-1")).toBe(false); + expect(isKokoTerminalAiInputLocked("pane-2")).toBe(true); + + const message = { + id: "question-1", + role: "user" as const, + metadata: { terminalId: 11 }, + parts: [{ type: "text" as const, text: "status" }] + }; + sendKokoTerminalAiControl("pane-1", message); + + const sentEnvelope = parseEnvelope(send.mock.calls[0]![0]); + expect(sentEnvelope.type).toBe(ENVELOPE_CHAT); + expect(parseJSONPayload(sentEnvelope.payload)).toEqual(message); + + const session = getKokoTerminalAiSession("pane-1")!; + const renderedMessages = computed(() => [...session.chat.messages.value]); + const renderedMessageCount = computed(() => renderedMessages.value.length); + expect(renderedMessageCount.value).toBe(0); + const response = session.chat.sendMessage({ + text: "stream status", + metadata: { terminalId: 11 } + }); + await Promise.resolve(); + await Promise.resolve(); + expect(parseJSONPayload(parseEnvelope(send.mock.calls[1]![0]).payload)).toMatchObject({ + role: "user", + metadata: { terminalId: 11 }, + parts: [{ type: "text", text: "stream status" }] + }); + handleKokoTerminalAiMessage("pane-1", { + id: "plan-message-1", + role: "assistant", + metadata: { terminalId: 11, stage: "process" }, + parts: [ + { + type: "data-plan", + data: { + id: "plan-1", + summary: "Inspect status", + steps: [{ id: "step-1", title: "Check", objective: "Read status", status: "pending" }] + } + } + ] + }); + handleKokoTerminalAiMessage("pane-1", { + id: "answer-1", + role: "assistant", + metadata: { terminalId: 11, stage: "final" }, + parts: [{ type: "text", text: "ready", state: "done" }] + }); + handleKokoTerminalAiMessage("pane-1", { + id: "progress-idle", + role: "assistant", + metadata: { terminalId: 11 }, + parts: [{ type: "data-progress", data: { state: "idle", text: "" } }] + }); + await response; + + expect(session.chat.status.value).toBe("ready"); + expect(session.chat.messages.value).toHaveLength(2); + expect(renderedMessageCount.value).toBe(2); + expect(session.chat.messages.value[0]).toMatchObject({ + role: "user", + parts: [{ type: "text", text: "stream status" }] + }); + expect(session.chat.messages.value[1]).toMatchObject({ + id: "plan-message-1", + role: "assistant", + parts: [ + { type: "data-plan", data: { id: "plan-1", summary: "Inspect status" } }, + { type: "text", text: "ready", state: "done" } + ] + }); + + unregisterKokoTerminalAiSession("pane-1", socket); + unregisterKokoTerminalAiSession("pane-2", socket); +}); + +it("keeps an AI capability that arrives before terminal creation", () => { + const socket = { readyState: WebSocket.OPEN, send: vi.fn() } as unknown as WebSocket; + registerKokoTerminalAiSession("pane-early", socket, ""); + + handleKokoTerminalAiMessage("pane-early", { + id: "capability-early", + role: "assistant", + metadata: { terminalId: 7 }, + parts: [{ type: "data-capability", data: { enabled: true } }] + }); + registerKokoTerminalAiSession("pane-early", socket, "7"); + + expect(getKokoTerminalAiSession("pane-early")).toMatchObject({ + enabled: true, + terminalId: "7" + }); + + unregisterKokoTerminalAiSession("pane-early", socket); +}); + +it("surfaces Terminal AI stream failures through the AI SDK chat state", async () => { + const send = vi.fn(); + const socket = { readyState: WebSocket.OPEN, send } as unknown as WebSocket; + const session = registerKokoTerminalAiSession("pane-error", socket, "9")!; + handleKokoTerminalAiMessage("pane-error", { + id: "capability-error", + role: "assistant", + metadata: { terminalId: 9 }, + parts: [{ type: "data-capability", data: { enabled: true } }] + }); + + const response = session.chat.sendMessage({ + text: "fail", + metadata: { terminalId: 9 } + }); + await Promise.resolve(); + await Promise.resolve(); + handleKokoTerminalAiMessage("pane-error", { + id: "error-1", + role: "assistant", + metadata: { terminalId: 9, stage: "final" }, + parts: [{ type: "data-error", data: { message: "model unavailable" } }] + }); + await response; + + expect(session.chat.status.value).toBe("error"); + expect(session.errorText).toBe("model unavailable"); + session.chat.clearError(); + expect(session.chat.status.value).toBe("ready"); + + const retry = session.chat.sendMessage({ + text: "retry", + metadata: { terminalId: 9 } + }); + await Promise.resolve(); + await Promise.resolve(); + expect(send).toHaveBeenCalledTimes(2); + handleKokoTerminalAiMessage("pane-error", { + id: "progress-retry-idle", + role: "assistant", + metadata: { terminalId: 9 }, + parts: [{ type: "data-progress", data: { state: "idle", text: "" } }] + }); + await retry; + expect(session.chat.status.value).toBe("ready"); + + unregisterKokoTerminalAiSession("pane-error", socket); +}); + afterEach(() => { vi.restoreAllMocks(); }); diff --git a/packages/koko/app/composables/terminal/useTerminalAiSessions.ts b/packages/koko/app/composables/terminal/useTerminalAiSessions.ts new file mode 100644 index 00000000..8d13c784 --- /dev/null +++ b/packages/koko/app/composables/terminal/useTerminalAiSessions.ts @@ -0,0 +1,329 @@ +import type { UseChatHelpers } from "@ai-sdk/vue"; +import type { ChatTransport, UIMessage, UIMessageChunk } from "ai"; +import type { EffectScope } from "vue"; +import { useChat } from "@ai-sdk/vue"; +import { effectScope, markRaw, reactive, shallowReactive } from "vue"; +import { buildJSONEnvelope, ENVELOPE_CHAT } from "#koko/composables/terminal/envelope"; + +export type TerminalAiEventData = Record; +export type TerminalAiChatMessage = UIMessage>; + +export interface KokoTerminalAiSession { + paneId: string; + socket: WebSocket | null; + terminalId: string; + chat: UseChatHelpers; + enabled: boolean; + backgroundExec: boolean; + backgroundReason: string; + approvalThreshold: number; + executionMode: string; + inputLocked: boolean; + draft: string; + runtimeStatus: string; + errorText: string; + decisions: Set; + executionOverrides: Map; + expansionOverrides: Map; +} + +interface ActiveChatResponse { + controller: ReadableStreamDefaultController; + started: boolean; + abortSignal?: AbortSignal; + abortHandler?: () => void; +} + +class KokoTerminalAiChatTransport implements ChatTransport { + private activeResponse: ActiveChatResponse | null = null; + + constructor( + private readonly socket: WebSocket, + private readonly terminalId: () => string, + private readonly enabled: () => boolean + ) {} + + sendMessages: ChatTransport["sendMessages"] = async ({ messages, abortSignal }) => { + if (this.activeResponse) throw new Error("A Terminal AI response is already active"); + if (!this.enabled() || this.socket.readyState !== WebSocket.OPEN) { + throw new Error("Terminal AI is not available for the active terminal"); + } + + const message = messages.at(-1); + if (!message || message.role !== "user") throw new Error("Terminal AI requires a user message"); + + const stream = new ReadableStream({ + start: (controller) => { + this.activeResponse = { controller, started: false, abortSignal }; + }, + cancel: () => this.clearActiveResponse() + }); + + const abortHandler = () => this.finish(); + if (abortSignal) { + this.activeResponse!.abortHandler = abortHandler; + abortSignal.addEventListener("abort", abortHandler, { once: true }); + } + + try { + this.socket.send( + buildJSONEnvelope(ENVELOPE_CHAT, { + ...message, + metadata: { + ...message.metadata, + terminalId: Number(this.terminalId()) + } + }) + ); + } catch (error) { + this.fail(error instanceof Error ? error : new Error("Failed to send Terminal AI message")); + throw error; + } + + return stream; + }; + + reconnectToStream: ChatTransport["reconnectToStream"] = async () => null; + + receive(message: TerminalAiChatMessage) { + const response = this.activeResponse; + if (!response || message.role !== "assistant") return false; + + if (!response.started) { + response.controller.enqueue({ + type: "start", + messageId: message.id, + messageMetadata: message.metadata + }); + response.started = true; + } else if (message.metadata) { + response.controller.enqueue({ + type: "message-metadata", + messageMetadata: message.metadata + }); + } + + for (const [index, part] of message.parts.entries()) { + const id = `${message.id}-${index}`; + if (part.type === "text") { + response.controller.enqueue({ type: "text-start", id }); + response.controller.enqueue({ type: "text-delta", id, delta: part.text }); + response.controller.enqueue({ type: "text-end", id }); + continue; + } + + if (!part.type.startsWith("data-") || !("data" in part)) continue; + if (part.type === "data-error") { + const data = part.data as TerminalAiEventData; + response.controller.enqueue({ + type: "error", + errorText: String(data.message || "Terminal AI failed") + }); + response.controller.close(); + this.clearActiveResponse(); + return true; + } + + response.controller.enqueue({ + type: part.type, + id, + data: part.data + }); + } + + return true; + } + + finish() { + const response = this.activeResponse; + if (!response) return; + if (response.started) response.controller.enqueue({ type: "finish", finishReason: "stop" }); + response.controller.close(); + this.clearActiveResponse(); + } + + disconnect() { + this.finish(); + } + + private fail(error: Error) { + const response = this.activeResponse; + if (!response) return; + response.controller.error(error); + this.clearActiveResponse(); + } + + private clearActiveResponse() { + const response = this.activeResponse; + if (response?.abortSignal && response.abortHandler) { + response.abortSignal.removeEventListener("abort", response.abortHandler); + } + this.activeResponse = null; + } +} + +const sessions = shallowReactive(new Map()); +const transports = new WeakMap(); +const chatScopes = new WeakMap(); + +function createSession(paneId: string, socket: WebSocket, terminalId: string): KokoTerminalAiSession { + let session: KokoTerminalAiSession; + const transport = markRaw( + new KokoTerminalAiChatTransport( + socket, + () => session.terminalId, + () => session.enabled + ) + ); + const chatScope = effectScope(true); + const chat = markRaw( + chatScope.run(() => + useChat({ + id: paneId, + transport, + generateId: () => createTerminalAiMessageId("message"), + onError: (error) => { + session.errorText = error.message; + } + }) + )! + ); + + session = reactive({ + paneId, + socket: markRaw(socket), + terminalId, + chat, + enabled: false, + backgroundExec: false, + backgroundReason: "", + approvalThreshold: 2, + executionMode: "auto", + inputLocked: false, + draft: "", + runtimeStatus: "", + errorText: "", + decisions: new Set(), + executionOverrides: new Map(), + expansionOverrides: new Map() + }) as KokoTerminalAiSession; + transports.set(session, transport); + chatScopes.set(session, chatScope); + return session; +} + +function partData(message: TerminalAiChatMessage, type: string) { + const part = message.parts.find((candidate) => candidate.type === type); + return part && "data" in part ? (part.data as TerminalAiEventData) : undefined; +} + +function isTerminalAiChatMessage(message: unknown): message is TerminalAiChatMessage { + if (!message || typeof message !== "object") return false; + const value = message as Record; + if (typeof value.id !== "string" || (value.role !== "user" && value.role !== "assistant")) return false; + if (value.metadata !== undefined && (!value.metadata || typeof value.metadata !== "object")) return false; + if (!Array.isArray(value.parts)) return false; + + return value.parts.every((part) => { + if (!part || typeof part !== "object") return false; + const candidate = part as Record; + if (candidate.type === "text") return typeof candidate.text === "string"; + return typeof candidate.type === "string" && candidate.type.startsWith("data-") && "data" in candidate; + }); +} + +export function registerKokoTerminalAiSession(paneId: string, socket: WebSocket, terminalId: string) { + if (!paneId) return null; + + const existing = sessions.get(paneId); + if (existing?.socket === socket) { + if (terminalId) existing.terminalId = terminalId; + return existing; + } + if (existing) { + transports.get(existing)?.disconnect(); + chatScopes.get(existing)?.stop(); + } + + const session = createSession(paneId, socket, terminalId); + sessions.set(paneId, session); + return session; +} + +export function unregisterKokoTerminalAiSession(paneId: string, socket?: WebSocket | null) { + const session = sessions.get(paneId); + if (!session || (socket && session.socket !== socket)) return; + transports.get(session)?.disconnect(); + chatScopes.get(session)?.stop(); + transports.delete(session); + chatScopes.delete(session); + sessions.delete(paneId); +} + +export function getKokoTerminalAiSession(paneId: string) { + return sessions.get(paneId) || null; +} + +export function isKokoTerminalAiInputLocked(paneId: string) { + return Boolean(sessions.get(paneId)?.inputLocked); +} + +export function handleKokoTerminalAiMessage(paneId: string, message: unknown) { + const session = sessions.get(paneId); + if (!session || !isTerminalAiChatMessage(message)) return; + + const messageTerminalId = Number(message.metadata?.terminalId) || 0; + if (messageTerminalId && session.terminalId && messageTerminalId !== Number(session.terminalId)) return; + if (messageTerminalId && !session.terminalId) session.terminalId = String(messageTerminalId); + + const capability = partData(message, "data-capability"); + if (capability) { + session.enabled = Boolean(capability.enabled); + session.backgroundExec = Boolean(capability.backgroundExec); + session.backgroundReason = String(capability.backgroundReason || ""); + session.approvalThreshold = Number(capability.approvalThreshold) || 2; + session.executionMode = String(capability.executionMode || "auto"); + return; + } + + const inputLock = partData(message, "data-input-lock"); + if (inputLock) { + session.inputLocked = Boolean(inputLock.locked); + return; + } + + const progress = partData(message, "data-progress"); + if (progress) { + session.runtimeStatus = String(progress.text || ""); + if (String(progress.state || "") === "idle") transports.get(session)?.finish(); + return; + } + + const policy = partData(message, "data-policy"); + if (policy) { + session.approvalThreshold = Number(policy.approvalThreshold) || session.approvalThreshold; + session.executionMode = String(policy.executionMode || session.executionMode); + return; + } + + const runtimeError = partData(message, "data-error"); + if (runtimeError) session.errorText = String(runtimeError.message || "Terminal AI failed"); + + if (!transports.get(session)?.receive(message)) { + session.chat.messages.value = [...session.chat.messages.value, message]; + } +} + +export function sendKokoTerminalAiControl(paneId: string, message: TerminalAiChatMessage) { + const session = sessions.get(paneId); + const socket = session?.socket; + if (!session?.enabled || !socket || socket.readyState !== WebSocket.OPEN) { + throw new Error("Terminal AI is not available for the active terminal"); + } + + socket.send(buildJSONEnvelope(ENVELOPE_CHAT, message)); +} + +export function createTerminalAiMessageId(prefix: string) { + return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} diff --git a/packages/koko/app/composables/terminal/useTerminalBinaryHandler.ts b/packages/koko/app/composables/terminal/useTerminalBinaryHandler.ts index 6c7a2237..ba2ea318 100644 --- a/packages/koko/app/composables/terminal/useTerminalBinaryHandler.ts +++ b/packages/koko/app/composables/terminal/useTerminalBinaryHandler.ts @@ -10,12 +10,13 @@ export function useKokoTerminalBinaryHandler(options: { addErrorToast: ReturnType["addErrorToast"]; t: ReturnType["t"]; }) { - const handleBinaryMessage = (payload: ArrayBufferLike) => { + const handleBinaryMessage = (payload: ArrayBufferLike | Uint8Array) => { if (!options.terminalRef.value || !options.sentryRef.value) return; + const bytes = payload instanceof Uint8Array ? Uint8Array.from(payload) : new Uint8Array(payload); if (options.zmodemTransferStatus.value) { try { - options.sentryRef.value.consume(payload); + options.sentryRef.value.consume(bytes.buffer); } catch { if (options.sentryRef.value.get_confirmed_session()) { options.sentryRef.value.get_confirmed_session()?.abort(); @@ -29,7 +30,7 @@ export function useKokoTerminalBinaryHandler(options: { true, false, options.terminalRef.value, - payload, + bytes, options.addErrorToast, options.t("koko.errors.zmodemBlocked") ); diff --git a/packages/koko/app/composables/terminal/useTerminalInput.ts b/packages/koko/app/composables/terminal/useTerminalInput.ts index 9a520b8b..a3fb5c06 100644 --- a/packages/koko/app/composables/terminal/useTerminalInput.ts +++ b/packages/koko/app/composables/terminal/useTerminalInput.ts @@ -1,5 +1,6 @@ import type { Terminal } from "@xterm/xterm"; import type { Ref } from "vue"; +import type { ILunaConfig } from "#koko/types"; import { FORMATTER_MESSAGE_TYPE, HOST_MESSAGE_TYPE } from "@jumpserver/connectors-core"; @@ -18,9 +19,10 @@ export function useKokoTerminalInput(options: { fit: () => void; isSocketClosing: (socket: WebSocket) => boolean; quickPaste: () => string; - getTerminalConfig: () => Record; + getTerminalConfig: () => Partial; onResize: (size: { cols: number; rows: number }) => void; onHostKey: (key: string) => void; + inputLocked: () => boolean; addErrorToast: (options: { title: string }) => void; translate: (key: string) => string; sendHostEvent: (event: string, data: unknown) => void; @@ -49,7 +51,7 @@ export function useKokoTerminalInput(options: { text = options.selectionText.value; } const socket = options.socket.value; - if (!text || !socket || options.isSocketClosing(socket)) { + if (!text || !socket || options.inputLocked() || options.isSocketClosing(socket)) { if (socket && options.isSocketClosing(socket)) { options.addErrorToast({ title: options.translate("koko.terminal.websocketConnectionClosed") }); } @@ -87,10 +89,14 @@ export function useKokoTerminalInput(options: { terminal.onData((data) => { const socket = options.socket.value; - if (!socket || options.isSocketClosing(socket)) return; + if (!socket || options.inputLocked() || options.isSocketClosing(socket)) return; options.lastSendTime.value = new Date(); socket.send( - formatMessage("", FORMATTER_MESSAGE_TYPE.TERMINAL_DATA, preprocessInput(data, options.getTerminalConfig)) + formatMessage( + options.terminalId.value, + FORMATTER_MESSAGE_TYPE.TERMINAL_DATA, + preprocessInput(data, options.getTerminalConfig()) + ) ); options.sendToHost(HOST_MESSAGE_TYPE.INPUT_ACTIVE, ""); }); diff --git a/packages/koko/app/composables/terminal/useTerminalMessageHandler.ts b/packages/koko/app/composables/terminal/useTerminalMessageHandler.ts index ab0e76aa..ab806138 100644 --- a/packages/koko/app/composables/terminal/useTerminalMessageHandler.ts +++ b/packages/koko/app/composables/terminal/useTerminalMessageHandler.ts @@ -1,23 +1,40 @@ +import type { HostBridge } from "@jumpserver/connectors-core"; import type { useKokoHostAdapter } from "@jumpserver/koko/host"; import type { Terminal } from "@xterm/xterm"; import type { ComputedRef, Ref } from "vue"; import type { useKokoConnectionStore } from "#koko/stores/connection"; import type { useKokoTerminalSettingsStore } from "#koko/stores/terminalSettings"; -import type { OnlineUser, SettingConfig, ShareUserOptions } from "#koko/types"; +import type { OnlineUser, SettingConfig, ShareUserOptions, TerminalSessionInfo } from "#koko/types"; +import type { TerminalCommandEnvelope } from "./envelope"; import type { TerminalIncomingMessage } from "./protocol"; -import { - FORMATTER_MESSAGE_TYPE, - HOST_MESSAGE_TYPE, - MESSAGE_TYPE, - ZMODEM_ACTION_TYPE -} from "@jumpserver/connectors-core"; +import type { TerminalAiChatMessage } from "./useTerminalAiSessions"; +import { HOST_MESSAGE_TYPE, MESSAGE_TYPE, ZMODEM_ACTION_TYPE } from "@jumpserver/connectors-core"; import { terminalTheme } from "../../utils/terminalTheme"; -import { formatMessage, updateIcon } from "../../utils/terminalUtils"; +import { updateIcon } from "../../utils/terminalUtils"; +import { + buildJSONEnvelope, + createRequestId, + ENVELOPE_CHAT, + ENVELOPE_ERROR, + ENVELOPE_TERMINAL_CLOSE, + ENVELOPE_TERMINAL_COMMAND, + ENVELOPE_TERMINAL_CREATE, + ENVELOPE_TERMINAL_OUTPUT, + parseEnvelope, + parseJSONPayload, + parseTerminalPayload +} from "./envelope"; import { parseTerminalIncomingMessage } from "./protocol"; -export type TerminalMessageHandlers = Partial void>>; +export type TerminalMessageHandlers = Partial void>>; -export function useKokoTerminalMessageHandler(handlers: TerminalMessageHandlers) { +export function useKokoTerminalMessageHandler( + handlers: TerminalMessageHandlers, + options?: { + onTerminalOutput: (terminalId: number, data: Uint8Array) => void; + onChat: (message: TerminalAiChatMessage) => void; + } +) { function handleRawMessage(raw: string) { let parsed: unknown; @@ -32,7 +49,61 @@ export function useKokoTerminalMessageHandler(handlers: TerminalMessageHandlers) handlers[message.type]?.(message); } - return { handleRawMessage }; + function dispatch(raw: unknown) { + const message = parseTerminalIncomingMessage(raw); + if (message) handlers[message.type]?.(message); + } + + function handleEnvelopeMessage(raw: ArrayBuffer | Uint8Array) { + const frame = parseEnvelope(raw); + switch (frame.type) { + case ENVELOPE_TERMINAL_OUTPUT: { + const payload = parseTerminalPayload(frame.payload); + options?.onTerminalOutput(payload.terminalId, payload.data); + break; + } + case ENVELOPE_TERMINAL_COMMAND: { + const command = parseJSONPayload(frame.payload); + const params = command.params || {}; + dispatch({ + ...params, + type: command.command, + terminalId: command.terminalId || Number(params.terminalId) || 0, + requestId: command.requestId || "" + }); + break; + } + case ENVELOPE_ERROR: { + const error = parseJSONPayload>(frame.payload); + dispatch({ + id: "", + type: MESSAGE_TYPE.ERROR, + err: String(error.message || "Terminal error"), + terminalId: Number(error.terminalId) || 0, + requestId: String(error.requestId || "") + }); + break; + } + case ENVELOPE_TERMINAL_CLOSE: { + const closed = parseJSONPayload>(frame.payload); + dispatch({ + id: "", + type: MESSAGE_TYPE.CLOSE, + data: String(closed.reason || ""), + terminalId: Number(closed.terminalId) || 0, + requestId: String(closed.requestId || "") + }); + break; + } + case ENVELOPE_CHAT: + options?.onChat(parseJSONPayload(frame.payload)); + break; + default: + throw new Error(`Unsupported terminal envelope type: ${frame.type}`); + } + } + + return { handleRawMessage, handleEnvelopeMessage }; } export function createKokoTerminalMessageHandlers(options: { @@ -58,13 +129,10 @@ export function createKokoTerminalMessageHandlers(options: { connectionStore: ReturnType; terminalSettingsStore: ReturnType; hostAdapter: ReturnType; - hostBridge: { - once: (event: HOST_MESSAGE_TYPE, handler: (message: Record) => void) => void; - sendHost: (event: HOST_MESSAGE_TYPE, data: unknown) => void; - }; + hostBridge: HostBridge; sendHostEvent: (event: HOST_MESSAGE_TYPE, data: unknown) => void; emitTerminalConnect: (id: string) => void; - emitTerminalSession: (payload: unknown) => void; + emitTerminalSession: (payload: TerminalSessionInfo) => void; showInfoOnce: (content: string) => void; onConnected: (terminalId: string, socket: WebSocket, terminal: Terminal) => void; }) { @@ -108,15 +176,6 @@ export function createKokoTerminalMessageHandlers(options: { const terminal = options.terminalRef.value; if (!socket || !terminal) return; - options.terminalId.value = message.id; - options.emitTerminalConnect(options.terminalId.value); - options.connectionStore.setConnectionState({ - socket, - terminal, - terminalId: message.id - }); - options.onConnected(message.id, socket, terminal); - const info = parseJson<{ setting: Partial; asset?: { name?: string } }>(message.data, { setting: {} }); @@ -125,17 +184,28 @@ export function createKokoTerminalMessageHandlers(options: { updateIcon(info.setting); socket.send( - formatMessage( - options.terminalId.value, - FORMATTER_MESSAGE_TYPE.TERMINAL_INIT, - JSON.stringify({ + buildJSONEnvelope(ENVELOPE_TERMINAL_CREATE, { + requestId: createRequestId("primary"), + params: { + type: "primary", cols: terminal.cols, rows: terminal.rows, code: options.connectionStore.shareCode - }) - ) + } + }) ); }, + created: (message) => { + const socket = options.socketRef.value; + const terminal = options.terminalRef.value; + const terminalId = String(message.terminalId || ""); + if (!socket || !terminal || !terminalId) return; + + options.terminalId.value = terminalId; + options.emitTerminalConnect(terminalId); + options.connectionStore.setConnectionState({ socket, terminal, terminalId }); + options.onConnected(terminalId, socket, terminal); + }, [MESSAGE_TYPE.TERMINAL_ERROR]: (message) => { options.terminalRef.value?.write(message.err || ""); }, @@ -168,7 +238,7 @@ export function createKokoTerminalMessageHandlers(options: { ctrlCAsCtrlZ?: boolean; themeName?: string; }>(message.data, { session: { id: "" } }); - options.emitTerminalSession(sessionInfo); + options.emitTerminalSession(sessionInfo as TerminalSessionInfo); const tabId = options.sessionCtxRef.value?.tabId; if (tabId) { @@ -205,8 +275,14 @@ export function createKokoTerminalMessageHandlers(options: { options.sessionId.value = sessionInfo.session.id; options.connectionStore.updateConnectionState({ sessionId: sessionInfo.session.id }); - options.terminalSettingsStore.setDefaultTerminalConfig("theme", effectiveThemeName || sessionInfo.themeName); - options.terminalSettingsStore.setDefaultTerminalConfig("themeName", effectiveThemeName || sessionInfo.themeName); + options.terminalSettingsStore.setDefaultTerminalConfig( + "theme", + effectiveThemeName || sessionInfo.themeName || "" + ); + options.terminalSettingsStore.setDefaultTerminalConfig( + "themeName", + effectiveThemeName || sessionInfo.themeName || "" + ); }, [MESSAGE_TYPE.TERMINAL_SHARE_JOIN]: (message) => { const payload = parseJson(message.data, {} as OnlineUser); diff --git a/packages/koko/app/composables/terminal/useTerminalSocket.ts b/packages/koko/app/composables/terminal/useTerminalSocket.ts index 3a2e116e..b5ba11ea 100644 --- a/packages/koko/app/composables/terminal/useTerminalSocket.ts +++ b/packages/koko/app/composables/terminal/useTerminalSocket.ts @@ -8,6 +8,12 @@ import { FitAddon } from "@xterm/addon-fit"; import { SearchAddon } from "@xterm/addon-search"; import { WebglAddon } from "@xterm/addon-webgl"; import { Terminal } from "@xterm/xterm"; +import { + handleKokoTerminalAiMessage, + isKokoTerminalAiInputLocked, + registerKokoTerminalAiSession, + unregisterKokoTerminalAiSession +} from "#koko/composables/terminal/useTerminalAiSessions"; import { useKokoTerminalBinaryHandler } from "#koko/composables/terminal/useTerminalBinaryHandler"; import { useKokoTerminalEvents } from "#koko/composables/terminal/useTerminalEvents"; import { useKokoTerminalHeartbeat } from "#koko/composables/terminal/useTerminalHeartbeat"; @@ -116,10 +122,14 @@ export const useKokoTerminalSocket = () => { lastSendTime, fit: () => fitAddon?.fit(), isSocketClosing, - quickPaste: () => terminalSettingsStore.quickPaste, - getTerminalConfig: terminalSettingsStore.getConfig, + quickPaste: () => terminalSettingsStore.quickPaste || "0", + getTerminalConfig: () => terminalSettingsStore.getConfig, onResize: debouncedResize, onHostKey: debouncedSendHostKey, + inputLocked: () => { + const paneId = unref(sessionCtxRef)?.tabId || ""; + return Boolean(paneId && isKokoTerminalAiInputLocked(paneId)); + }, addErrorToast, translate: t, sendHostEvent, @@ -162,15 +172,34 @@ export const useKokoTerminalSocket = () => { showInfoOnce, onConnected: (id, socket) => { const tabId = unref(sessionCtxRef)?.tabId; - if (tabId) registerKokoTerminalSession(tabId, { socket, terminalId: id }); + if (tabId) { + registerKokoTerminalSession(tabId, { socket, terminalId: id }); + registerKokoTerminalAiSession(tabId, socket, id); + } + } + }); + const messageHandler = useKokoTerminalMessageHandler(terminalMessageHandlers, { + onTerminalOutput: (messageTerminalId, data) => { + if (messageTerminalId === Number(terminalId.value)) { + binaryHandler.handleBinaryMessage(data); + } + }, + onChat: (message) => { + const tabId = unref(sessionCtxRef)?.tabId; + if (tabId) handleKokoTerminalAiMessage(tabId, message); } }); - const messageHandler = useKokoTerminalMessageHandler(terminalMessageHandlers); const listenSocketEvent = () => { if (!socketRef.value) return; - sentryRef.value = createSentry(terminalRef.value!, socketRef.value!, lastSendTime); + const paneId = unref(sessionCtxRef)?.tabId; + if (paneId) registerKokoTerminalAiSession(paneId, socketRef.value, ""); + + sentryRef.value = createSentry(terminalRef.value!, socketRef.value!, terminalId, lastSendTime, () => { + const paneId = unref(sessionCtxRef)?.tabId || ""; + return !paneId || !isKokoTerminalAiInputLocked(paneId); + }); socketRef.value.onopen = heartbeat.start; @@ -184,8 +213,14 @@ export const useKokoTerminalSocket = () => { socketRef.value.onmessage = async (message: MessageEvent) => { await new Promise((resolve) => setTimeout(resolve, 1)); lastReceiveTime.value = new Date(); - if (typeof message.data === "object") binaryHandler.handleBinaryMessage(message.data as ArrayBufferLike); - else messageHandler.handleRawMessage(message.data); + try { + if (message.data instanceof ArrayBuffer) messageHandler.handleEnvelopeMessage(message.data); + else if (typeof message.data === "string") messageHandler.handleRawMessage(message.data); + } catch (error) { + addErrorToast({ + title: error instanceof Error ? error.message : t("koko.terminal.invalidMessage") + }); + } }; }; @@ -268,6 +303,7 @@ export const useKokoTerminalSocket = () => { const tabId = unref(sessionCtxRef)?.tabId; if (tabId) { unregisterKokoTerminalSession(tabId); + unregisterKokoTerminalAiSession(tabId, socketRef.value); hostAdapter.clearSessionDetails(tabId); } transport.close(); diff --git a/packages/koko/app/composables/terminal/useZmodem.ts b/packages/koko/app/composables/terminal/useZmodem.ts index 33947731..66cce6a3 100644 --- a/packages/koko/app/composables/terminal/useZmodem.ts +++ b/packages/koko/app/composables/terminal/useZmodem.ts @@ -57,11 +57,19 @@ export const useKokoZmodem = () => { }); }; - const createSentry = (terminal: Terminal, socket: WebSocket, lastSendTime: Ref) => { + const createSentry = ( + terminal: Terminal, + socket: WebSocket, + terminalId: Ref, + lastSendTime: Ref, + canSend: () => boolean + ) => { const sentry = createKokoZmodemSentry({ terminal, socket, + terminalId, lastSendTime, + canSend, onWriteFailure: () => addErrorToast({ title: t("koko.terminal.writeFailed") }), shouldWriteToTerminal: () => !sentryRef.value?.get_confirmed_session(), onDetect: (detection) => { diff --git a/packages/koko/app/composables/terminal/useZmodemSentry.ts b/packages/koko/app/composables/terminal/useZmodemSentry.ts index d8a51983..e64201b7 100644 --- a/packages/koko/app/composables/terminal/useZmodemSentry.ts +++ b/packages/koko/app/composables/terminal/useZmodemSentry.ts @@ -2,11 +2,14 @@ import type { Terminal } from "@xterm/xterm"; import type { Ref } from "vue"; import type { KokoZmodemDetection } from "./zmodemTypes"; import Zmodem from "zmodem-ts"; +import { buildTerminalInput } from "./envelope"; export function createKokoZmodemSentry(options: { terminal: Terminal; socket: WebSocket; + terminalId: Ref; lastSendTime: Ref; + canSend: () => boolean; onDetect: (detection: KokoZmodemDetection) => void; onWriteFailure: () => void; shouldWriteToTerminal: () => boolean; @@ -21,14 +24,15 @@ export function createKokoZmodemSentry(options: { } }, sender: (octets: Uint8Array) => { + if (!options.canSend() || !options.terminalId.value) return; options.lastSendTime.value = new Date(); try { - options.socket.send(new Uint8Array(octets)); + options.socket.send(buildTerminalInput(options.terminalId.value, new Uint8Array(octets))); } catch { options.onWriteFailure(); } }, on_retract: () => {}, - on_detect: (detection) => options.onDetect(detection as KokoZmodemDetection) + on_detect: (detection: KokoZmodemDetection) => options.onDetect(detection) }); } diff --git a/packages/koko/app/composables/useTerminalSessionRegistry.ts b/packages/koko/app/composables/useTerminalSessionRegistry.ts index 3603a7a3..91031936 100644 --- a/packages/koko/app/composables/useTerminalSessionRegistry.ts +++ b/packages/koko/app/composables/useTerminalSessionRegistry.ts @@ -1,4 +1,5 @@ import { FORMATTER_MESSAGE_TYPE } from "@jumpserver/connectors-core"; +import { isKokoTerminalAiInputLocked } from "#koko/composables/terminal/useTerminalAiSessions"; import { formatMessage } from "#koko/utils/terminalUtils"; interface TerminalSession { @@ -30,6 +31,8 @@ export function unregisterLocalShellTerminalSession(tabId: string) { } export function sendKokoTerminalData(tabId: string, data: string) { + if (isKokoTerminalAiInputLocked(tabId)) return false; + const session = sessions.get(tabId); if (session?.socket.readyState === WebSocket.OPEN) { session.socket.send(formatMessage(session.terminalId, FORMATTER_MESSAGE_TYPE.TERMINAL_DATA, data)); diff --git a/packages/koko/app/context/terminalContext.ts b/packages/koko/app/context/terminalContext.ts index 8d1a681e..73de0dd9 100644 --- a/packages/koko/app/context/terminalContext.ts +++ b/packages/koko/app/context/terminalContext.ts @@ -4,10 +4,16 @@ import type { InjectionKey } from "vue"; import type { TerminalMittEvent } from "#koko/composables/terminal/protocol"; import type { TerminalSessionInfo } from "#koko/types"; -import { createHostBridge, FORMATTER_MESSAGE_TYPE, HOST_MESSAGE_TYPE } from "@jumpserver/connectors-core"; +import { + connectorSessionKey, + createHostBridge, + FORMATTER_MESSAGE_TYPE, + HOST_MESSAGE_TYPE +} from "@jumpserver/connectors-core"; import mitt from "mitt"; import { inject, nextTick } from "vue"; import { TerminalEventType } from "#koko/composables/terminal/protocol"; +import { isKokoTerminalAiInputLocked } from "#koko/composables/terminal/useTerminalAiSessions"; import { useKokoConnectionStore } from "#koko/stores/connection"; import mittBus from "#koko/utils/mittBus"; import { terminalTheme } from "#koko/utils/terminalTheme"; @@ -35,6 +41,7 @@ export const createKokoTerminalContext = (): TerminalContext => { const eventBus = mitt(); const hostBridge = createHostBridge(); const connectionStore = useKokoConnectionStore(); + const sessionCtxRef = inject(connectorSessionKey, null); let unbindPostMessage: (() => void) | undefined; const sendHostEvent = (event: string, data: unknown) => { @@ -73,7 +80,15 @@ export const createKokoTerminalContext = (): TerminalContext => { const handleHostCommand = (data: unknown) => { const socket = connectionStore.socket; const terminalId = connectionStore.terminalId; - if (!socket || !terminalId || socket.readyState !== WebSocket.OPEN) return; + const paneId = unref(sessionCtxRef)?.tabId || ""; + if ( + !socket || + !terminalId || + socket.readyState !== WebSocket.OPEN || + (paneId && isKokoTerminalAiInputLocked(paneId)) + ) { + return; + } socket.send(formatMessage(terminalId, FORMATTER_MESSAGE_TYPE.TERMINAL_DATA, String(data ?? ""))); }; diff --git a/packages/koko/app/utils/terminalUtils.ts b/packages/koko/app/utils/terminalUtils.ts index 7d16b137..77935a73 100644 --- a/packages/koko/app/utils/terminalUtils.ts +++ b/packages/koko/app/utils/terminalUtils.ts @@ -1,10 +1,27 @@ import type { Terminal } from "@xterm/xterm"; import type { ILunaConfig } from "#koko/types"; -import { formatMessage } from "@jumpserver/connectors-core"; +import { buildJSONEnvelope, buildTerminalInput, ENVELOPE_TERMINAL_COMMAND } from "#koko/composables/terminal/envelope"; import { AsciiBackspace, AsciiCtrlC, AsciiCtrlZ, AsciiDel } from "#koko/utils/config"; -export { formatMessage }; +export function formatMessage(id: string | number, type: string, data: unknown) { + const terminalId = Number(id) || 0; + if (type === "TERMINAL_DATA") { + return buildTerminalInput(terminalId, typeof data === "string" ? data : new Uint8Array(data as ArrayBuffer)); + } + + return buildJSONEnvelope(ENVELOPE_TERMINAL_COMMAND, { + terminalId, + command: type, + params: { + id: String(id || ""), + type, + data, + terminalId + }, + timestamp: Date.now() + }); +} export function writeBufferToTerminal( enableZmodem: boolean, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf19b1d6..e74e7186 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@ai-sdk/vue': + specifier: 4.0.44 + version: 4.0.44(vue@3.5.40(typescript@6.0.3))(zod@4.4.3) '@codemirror/commands': specifier: ^6.10.4 version: 6.10.4 @@ -143,12 +146,18 @@ importers: ag-grid-vue3: specifier: ^36.0.0 version: 36.0.2(vue@3.5.40(typescript@6.0.3)) + ai: + specifier: 7.0.44 + version: 7.0.44(zod@4.4.3) clipboard-polyfill: specifier: ^4.1.1 version: 4.1.1 codemirror: specifier: ^6.0.2 version: 6.0.2 + dompurify: + specifier: 3.4.12 + version: 3.4.12 fflate: specifier: ^0.8.2 version: 0.8.3 @@ -158,6 +167,9 @@ importers: js-untar: specifier: ^2.0.0 version: 2.0.0 + marked: + specifier: 17.0.6 + version: 17.0.6 mitt: specifier: ^3.0.1 version: 3.0.1 @@ -336,6 +348,28 @@ importers: packages: + '@ai-sdk/gateway@4.0.33': + resolution: {integrity: sha512-YFkZhWH1ksIoSO2b0S/nAboiYBYRhkrmbC/6vOBsKZz6Y0FfBBaIjWBu3lW2n2x7cXEH776RUKGOiEzJYmu+Pw==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider-utils@5.0.16': + resolution: {integrity: sha512-LWDrmYWkZoAJwMbToUglpR6AmgvtnNtCCGeU9MKlXiSV3Yiu4u73/pVA18sQAAe3kZtCRBY9P31lAfnPekWWyg==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider@4.0.4': + resolution: {integrity: sha512-tbHKNLirllUNF3ZlkCsXnwab2ZV1Sl4b1H/Cp9ruCce15IBmskE8Gwkk0yo9xDWY+jho2of7lVXtwSsyrq7cwQ==} + engines: {node: '>=22'} + + '@ai-sdk/vue@4.0.44': + resolution: {integrity: sha512-FNbXhgV7LNgX2tuZtJe796vrjvZsFrSJg2Nk14l9eq/IWkxQVQ83zshvOb9w4qDHcdVr+bBKnhpnlcxvotddUQ==} + engines: {node: '>=22'} + peerDependencies: + vue: ^3.3.4 + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -3209,6 +3243,9 @@ packages: '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -3448,6 +3485,10 @@ packages: engines: {node: '>=20'} hasBin: true + '@vercel/oidc@3.2.0': + resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} + engines: {node: '>= 20'} + '@vitejs/devtools-kit@0.4.9': resolution: {integrity: sha512-uI1Gk4rfc6qCAK+5m5o3d5HCNVPpnGYi+RlGnLuWn6j9XNcvuP1MiZrgEMyXvpbQHEN3N4mcfUswIPc8XnBvsg==} peerDependencies: @@ -3683,6 +3724,9 @@ packages: peerDependencies: vue: ^3.5.0 + '@workflow/serde@4.1.0': + resolution: {integrity: sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==} + '@xterm/addon-fit@0.11.0': resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==} @@ -3736,6 +3780,12 @@ packages: resolution: {integrity: sha512-k2/tQ1+8Zf50dEUJWklUP80LcE/+Ph+OJ6cf2Ff2fD/c/TtCe6ofnCoNMz9UnyxOQYlaAALZtEWETzn+1JjfHg==} engines: {node: '>= 14'} + ai@7.0.44: + resolution: {integrity: sha512-OMJ8v3ZzMIt90wIZRluD8c/VgvmX8SvAajGNYEbCeCAiCbr62yhCgXC7MvZRrQ0pOTfLZRohztUVrt5A08gKNg==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} @@ -4276,6 +4326,9 @@ packages: resolution: {integrity: sha512-pr8ToPIuwBonzUy42STpc5Cf0m69zsQ7gtCLLvKrTbhVRnRohT2pLiJmGp3PAh16nDVWpYpcRpdjuk1vFmnQUg==} engines: {node: '>= 4'} + dompurify@3.4.12: + resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==} + domutils@3.0.1: resolution: {integrity: sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q==} @@ -4688,6 +4741,10 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + execa@8.0.1: resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} engines: {node: '>=16.17'} @@ -5168,6 +5225,9 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -6631,6 +6691,11 @@ packages: engines: {node: '>=16'} hasBin: true + swrv@1.2.0: + resolution: {integrity: sha512-lH/g4UcNyj+7lzK4eRGT4C68Q4EhQ6JtM9otPRIASfhhzfLWtbZPHcMuhuba7S9YVYuxkMUGImwMyGpfbkH07A==} + peerDependencies: + vue: '>=3.2.26 < 4' + synckit@0.11.13: resolution: {integrity: sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==} engines: {node: ^14.18.0 || >=16.0.0} @@ -6806,6 +6871,10 @@ packages: undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + engines: {node: '>=20.18.1'} + undici@8.9.0: resolution: {integrity: sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==} engines: {node: '>=22.19.0'} @@ -7420,6 +7489,35 @@ packages: snapshots: + '@ai-sdk/gateway@4.0.33(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 4.0.4 + '@ai-sdk/provider-utils': 5.0.16(zod@4.4.3) + '@vercel/oidc': 3.2.0 + zod: 4.4.3 + + '@ai-sdk/provider-utils@5.0.16(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 4.0.4 + '@standard-schema/spec': 1.1.0 + '@workflow/serde': 4.1.0 + eventsource-parser: 3.1.0 + undici: 7.29.0 + zod: 4.4.3 + + '@ai-sdk/provider@4.0.4': + dependencies: + json-schema: 0.4.0 + + '@ai-sdk/vue@4.0.44(vue@3.5.40(typescript@6.0.3))(zod@4.4.3)': + dependencies: + '@ai-sdk/provider-utils': 5.0.16(zod@4.4.3) + ai: 7.0.44(zod@4.4.3) + swrv: 1.2.0(vue@3.5.40(typescript@6.0.3)) + vue: 3.5.40(typescript@6.0.3) + transitivePeerDependencies: + - zod + '@alloc/quick-lru@5.2.0': {} '@antfu/eslint-config@9.2.0(@typescript-eslint/typescript-estree@8.65.0(typescript@6.0.3))(@typescript-eslint/utils@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3))(@vue/compiler-sfc@3.5.40)(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.10)': @@ -10648,6 +10746,9 @@ snapshots: '@types/resolve@1.20.2': {} + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@3.0.3': {} '@types/web-bluetooth@0.0.20': {} @@ -10942,6 +11043,8 @@ snapshots: - rollup - supports-color + '@vercel/oidc@3.2.0': {} + '@vitejs/devtools-kit@0.4.9(srvx@0.11.22)(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0))': dependencies: '@devframes/hub': 0.7.14(devframe@0.7.14(srvx@0.11.22)(typescript@6.0.3)) @@ -11285,6 +11388,8 @@ snapshots: dependencies: vue: 3.5.40(typescript@6.0.3) + '@workflow/serde@4.1.0': {} + '@xterm/addon-fit@0.11.0': {} '@xterm/addon-search@0.16.0': {} @@ -11329,6 +11434,13 @@ snapshots: transitivePeerDependencies: - supports-color + ai@7.0.44(zod@4.4.3): + dependencies: + '@ai-sdk/gateway': 4.0.33(zod@4.4.3) + '@ai-sdk/provider': 4.0.4 + '@ai-sdk/provider-utils': 5.0.16(zod@4.4.3) + zod: 4.4.3 + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -11860,6 +11972,10 @@ snapshots: dependencies: domelementtype: 2.3.0 + dompurify@3.4.12: + optionalDependencies: + '@types/trusted-types': 2.0.7 + domutils@3.0.1: dependencies: dom-serializer: 2.0.0 @@ -12399,6 +12515,8 @@ snapshots: events@3.3.0: {} + eventsource-parser@3.1.0: {} + execa@8.0.1: dependencies: cross-spawn: 7.0.6 @@ -12854,6 +12972,8 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema@0.4.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@2.2.3: {} @@ -15088,6 +15208,10 @@ snapshots: picocolors: 1.1.1 sax: 1.6.1 + swrv@1.2.0(vue@3.5.40(typescript@6.0.3)): + dependencies: + vue: 3.5.40(typescript@6.0.3) + synckit@0.11.13: dependencies: '@pkgr/core': 0.3.6 @@ -15257,6 +15381,8 @@ snapshots: undici-types@8.3.0: {} + undici@7.29.0: {} + undici@8.9.0: {} unenv@2.0.0-rc.24: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ec3259d3..11011650 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,9 @@ + +minimumReleaseAgeExclude: + - "@ai-sdk/gateway@4.0.33" + - "@ai-sdk/provider-utils@5.0.16" + - "@ai-sdk/vue@4.0.44" + - ai@7.0.44 shellEmulator: true packages: @@ -9,3 +15,4 @@ allowBuilds: esbuild: true unrs-resolver: true vue-demi: true + diff --git a/ui/components/RightPanel/aiPanel.vue b/ui/components/RightPanel/aiPanel.vue index 8b7b4b96..1f89aabf 100644 --- a/ui/components/RightPanel/aiPanel.vue +++ b/ui/components/RightPanel/aiPanel.vue @@ -1,87 +1,673 @@ + + diff --git a/ui/components/SideBar/profile.vue b/ui/components/SideBar/profile.vue index 34683c19..30d205c6 100644 --- a/ui/components/SideBar/profile.vue +++ b/ui/components/SideBar/profile.vue @@ -33,7 +33,6 @@ const isTopbar = computed(() => props.placement === "topbar"); const recentSiteLimit = 5; -const toast = useToast(); const { addErrorToast } = useErrorToast(); const appConfig = useAppConfig(); const localePath = useLocalePath(); @@ -602,14 +601,7 @@ const handleConfirm = async () => { } if (!isTauriRuntime()) { - toast.add({ - title: t("Login.LoginFailed"), - description: "Web 模式暂未接入登录流程。", - color: "warning", - icon: "i-lucide-circle-alert", - progress: true, - duration: 3000 - }); + redirectToWebLogin(); return; } @@ -785,6 +777,7 @@ onBeforeUnmount(() => { (null); +const aiOpen = ref(true); provide(connectorSessionKey, sessionContext); @@ -17,6 +19,7 @@ const tokenId = computed(() => String(route.query.token || "")); onMounted(() => { if (!tokenId.value) return; + const paneId = `standalone:${globalThis.crypto?.randomUUID?.() || Date.now()}`; sessionContext.value = { component: "koko", tokenId: tokenId.value, @@ -25,14 +28,44 @@ onMounted(() => { terminalThemeName: colorMode.value === "dark" ? "OneHalfDark" : "OneHalfLight", colorMode: colorMode.value, themeType: colorMode.value === "dark" ? "darkGary" : "default", - disableAutoHash: String(route.query.disableautohash || "false") + disableAutoHash: String(route.query.disableautohash || "false"), + tabId: paneId }; + activePaneId.value = paneId; +}); + +onBeforeUnmount(() => { + if (activePaneId.value === sessionContext.value?.tabId) { + activePaneId.value = ""; + } });