| <script lang="ts"> |
| import { onMount, onDestroy, getContext } from 'svelte'; |
| import { Terminal } from '@xterm/xterm'; |
| import { FitAddon } from '@xterm/addon-fit'; |
| import { WebLinksAddon } from '@xterm/addon-web-links'; |
| import '@xterm/xterm/css/xterm.css'; |
|
|
| import { terminalServers, settings, selectedTerminalId, user } from '$lib/stores'; |
| import { WEBUI_API_BASE_URL } from '$lib/constants'; |
| import Tooltip from '$lib/components/common/Tooltip.svelte'; |
|
|
| const i18n = getContext('i18n'); |
|
|
| export let overlay = false; |
| export let chatId: string | null = null; |
|
|
| let terminalEl: HTMLDivElement; |
| let term: Terminal | null = null; |
| let fitAddon: FitAddon | null = null; |
| let ws: WebSocket | null = null; |
| export let connected = false; |
| export let connecting = false; |
| let resizeObserver: ResizeObserver | null = null; |
| let pingInterval: ReturnType<typeof setInterval> | null = null; |
|
|
| |
| const getTerminalInfo = (): { serverId: string; baseUrl: string } | null => { |
| |
| const systemTerminals = ($terminalServers ?? []).filter((t: any) => t.id); |
| const systemMatch = systemTerminals.find((t: any) => t.id === $selectedTerminalId); |
| if (systemMatch) { |
| |
| return { serverId: systemMatch.id, baseUrl: WEBUI_API_BASE_URL }; |
| } |
|
|
| |
| const directTerminals = ($settings?.terminalServers ?? []).filter((s: any) => s.url); |
| const directMatch = directTerminals.find((s: any) => s.url === $selectedTerminalId); |
| if (directMatch) { |
| |
| return { serverId: '__direct__', baseUrl: directMatch.url }; |
| } |
|
|
| return null; |
| }; |
|
|
| const connect = async () => { |
| if (ws) disconnect(); |
|
|
| const info = getTerminalInfo(); |
| if (!info) return; |
|
|
| connecting = true; |
|
|
| const token = localStorage.getItem('token') ?? ''; |
|
|
| try { |
| let sessionId: string; |
| let wsUrl: string; |
| let authToken: string; |
|
|
| if (info.serverId === '__direct__') { |
| |
| const base = info.baseUrl.replace(/\/$/, ''); |
| const directTerminals = ($settings?.terminalServers ?? []).filter((s: any) => s.url); |
| const directMatch = directTerminals.find((s: any) => s.url === $selectedTerminalId); |
| const apiKey = directMatch?.key ?? ''; |
| authToken = apiKey; |
|
|
| |
| const createHeaders: Record<string, string> = { Authorization: `Bearer ${apiKey}` }; |
| if (chatId) createHeaders['X-Session-Id'] = chatId; |
| const res = await fetch(`${base}/api/terminals`, { |
| method: 'POST', |
| headers: createHeaders |
| }); |
| if (!res.ok) throw new Error(`Failed to create session: ${res.status}`); |
| const session = await res.json(); |
| sessionId = session.id; |
|
|
| const wsBase = base.replace(/^https:/, 'wss:').replace(/^http:/, 'ws:'); |
| wsUrl = `${wsBase}/api/terminals/${sessionId}`; |
| } else { |
| |
| const base = info.baseUrl.replace(/\/$/, ''); |
| authToken = token; |
|
|
| |
| const proxyHeaders: Record<string, string> = { Authorization: `Bearer ${token}` }; |
| if (chatId) proxyHeaders['X-Session-Id'] = chatId; |
| const res = await fetch(`${base}/terminals/${info.serverId}/api/terminals`, { |
| method: 'POST', |
| headers: proxyHeaders |
| }); |
| if (!res.ok) throw new Error(`Failed to create session: ${res.status}`); |
| const session = await res.json(); |
| sessionId = session.id; |
|
|
| const wsBase = base.replace(/^https:/, 'wss:').replace(/^http:/, 'ws:'); |
| wsUrl = `${wsBase}/terminals/${info.serverId}/api/terminals/${sessionId}`; |
| } |
|
|
| ws = new WebSocket(wsUrl); |
| ws.binaryType = 'arraybuffer'; |
|
|
| ws.onopen = () => { |
| |
| if (ws) { |
| ws.send(JSON.stringify({ type: 'auth', token: authToken })); |
| } |
| connected = true; |
| connecting = false; |
| |
| term?.focus(); |
| |
| if (term && ws) { |
| ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows })); |
| } |
| |
| if (pingInterval) clearInterval(pingInterval); |
| pingInterval = setInterval(() => { |
| if (ws && ws.readyState === WebSocket.OPEN) { |
| ws.send(JSON.stringify({ type: 'ping' })); |
| } |
| }, 25000); |
| }; |
|
|
| ws.onmessage = (event) => { |
| if (term) { |
| if (event.data instanceof ArrayBuffer) { |
| term.write(new Uint8Array(event.data)); |
| } else { |
| term.write(event.data); |
| } |
| } |
| }; |
|
|
| ws.onclose = () => { |
| connected = false; |
| connecting = false; |
| if (term) { |
| term.write('\r\n\x1b[90m[Connection closed]\x1b[0m\r\n'); |
| } |
| }; |
|
|
| ws.onerror = () => { |
| connected = false; |
| connecting = false; |
| }; |
| } catch (err) { |
| connecting = false; |
| if (term) { |
| term.write(`\r\n\x1b[31m[Error: ${err}]\x1b[0m\r\n`); |
| } |
| } |
| }; |
|
|
| const disconnect = () => { |
| if (pingInterval) { |
| clearInterval(pingInterval); |
| pingInterval = null; |
| } |
| if (ws) { |
| ws.close(); |
| ws = null; |
| } |
| connected = false; |
| connecting = false; |
| }; |
|
|
| const initTerminal = () => { |
| if (!terminalEl || term) return; |
|
|
| term = new Terminal({ |
| cursorBlink: true, |
| fontSize: 13, |
| fontFamily: |
| "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, 'Courier New', monospace", |
| theme: { |
| background: '#000000', |
| foreground: '#c0c0c0', |
| cursor: '#ffffff', |
| cursorAccent: '#000000', |
| selectionBackground: '#444444', |
| selectionForeground: '#ffffff', |
| black: '#000000', |
| red: '#cd0000', |
| green: '#00cd00', |
| yellow: '#cdcd00', |
| blue: '#0000ee', |
| magenta: '#cd00cd', |
| cyan: '#00cdcd', |
| white: '#e5e5e5', |
| brightBlack: '#7f7f7f', |
| brightRed: '#ff0000', |
| brightGreen: '#00ff00', |
| brightYellow: '#ffff00', |
| brightBlue: '#5c5cff', |
| brightMagenta: '#ff00ff', |
| brightCyan: '#00ffff', |
| brightWhite: '#ffffff' |
| }, |
| allowProposedApi: true, |
| scrollback: 5000 |
| }); |
|
|
| fitAddon = new FitAddon(); |
| term.loadAddon(fitAddon); |
| term.loadAddon(new WebLinksAddon()); |
|
|
| term.open(terminalEl); |
|
|
| |
| requestAnimationFrame(() => { |
| fitAddon?.fit(); |
| }); |
|
|
| |
| term.onData((data) => { |
| if (ws && ws.readyState === WebSocket.OPEN) { |
| ws.send(new TextEncoder().encode(data)); |
| } |
| }); |
|
|
| |
| term.onBinary((data) => { |
| if (ws && ws.readyState === WebSocket.OPEN) { |
| const buffer = new Uint8Array(data.length); |
| for (let i = 0; i < data.length; i++) { |
| buffer[i] = data.charCodeAt(i) & 0xff; |
| } |
| ws.send(buffer); |
| } |
| }); |
|
|
| |
| |
| term.attachCustomKeyEventHandler(() => true); |
|
|
| |
| term.onResize(({ cols, rows }) => { |
| if (ws && ws.readyState === WebSocket.OPEN) { |
| ws.send(JSON.stringify({ type: 'resize', cols, rows })); |
| } |
| }); |
|
|
| |
| resizeObserver = new ResizeObserver(() => { |
| requestAnimationFrame(() => { |
| fitAddon?.fit(); |
| }); |
| }); |
| resizeObserver.observe(terminalEl); |
|
|
| |
| |
| |
| |
| }; |
|
|
| |
| $: if ($selectedTerminalId !== undefined && term) { |
| |
| disconnect(); |
| term.clear(); |
| if ($selectedTerminalId) { |
| connect(); |
| } |
| } |
|
|
| onMount(() => { |
| initTerminal(); |
| }); |
|
|
| onDestroy(() => { |
| disconnect(); |
| resizeObserver?.disconnect(); |
| term?.dispose(); |
| term = null; |
| fitAddon = null; |
| }); |
| </script> |
|
|
| <div class="h-full min-h-0 relative"> |
| <div bind:this={terminalEl} class="absolute inset-0 px-0.5" class:pointer-events-none={overlay} /> |
| </div> |
|
|