You've already forked agentic-coding-workflow
TTY
This commit is contained in:
@@ -21,6 +21,8 @@
|
||||
"packageManager": "pnpm@10.32.1",
|
||||
"dependencies": {
|
||||
"@vicons/ionicons5": "^0.13.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"markdown-it": "^14.1.1",
|
||||
"naive-ui": "^2.44.1",
|
||||
"pinia": "^3.0.4",
|
||||
|
||||
Generated
+16
@@ -11,6 +11,12 @@ importers:
|
||||
'@vicons/ionicons5':
|
||||
specifier: ^0.13.0
|
||||
version: 0.13.0
|
||||
'@xterm/addon-fit':
|
||||
specifier: ^0.11.0
|
||||
version: 0.11.0
|
||||
'@xterm/xterm':
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
markdown-it:
|
||||
specifier: ^14.1.1
|
||||
version: 14.1.1
|
||||
@@ -644,6 +650,12 @@ packages:
|
||||
'@vue/server-renderer':
|
||||
optional: true
|
||||
|
||||
'@xterm/addon-fit@0.11.0':
|
||||
resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
|
||||
|
||||
'@xterm/xterm@6.0.0':
|
||||
resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==}
|
||||
|
||||
abbrev@2.0.0:
|
||||
resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==}
|
||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
||||
@@ -2290,6 +2302,10 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@vue/server-renderer': 3.5.33(vue@3.5.33(typescript@6.0.3))
|
||||
|
||||
'@xterm/addon-fit@0.11.0': {}
|
||||
|
||||
'@xterm/xterm@6.0.0': {}
|
||||
|
||||
abbrev@2.0.0: {}
|
||||
|
||||
acorn-jsx@5.3.2(acorn@8.16.0):
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
<RouterLink :to="{ name: 'admin-acp-agent-kinds' }" class="text-sm hover:underline">
|
||||
ACP 客户端
|
||||
</RouterLink>
|
||||
<RouterLink :to="{ name: 'admin-terminal' }" class="text-sm hover:underline">
|
||||
终端
|
||||
</RouterLink>
|
||||
</template>
|
||||
</nav>
|
||||
<span v-else class="flex-1" />
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// useTerminalSocket 封装管理员终端的双向 WebSocket,结构照搬 useRunStream:
|
||||
// - connect + 自动重连(指数退避 1s/3s,最多 2 次)
|
||||
// - server→client:二进制帧 = shell 输出,回调喂给 xterm
|
||||
// - client→server:二进制帧 = stdin 原始字节;文本帧 JSON = resize 控制
|
||||
// 浏览器原生 WS 不支持自定义 header,token 经 ?token= 传递(同 run/acp WS 约定)。
|
||||
import { ref, onScopeDispose, type Ref } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
export type TerminalStatus = 'idle' | 'connecting' | 'open' | 'closed' | 'error'
|
||||
|
||||
export interface UseTerminalSocketOptions {
|
||||
// onData 接收 shell 输出(已解码为字符串)。
|
||||
onData: (text: string) => void
|
||||
// 首次连接的初始窗口尺寸。
|
||||
initialRows?: number
|
||||
initialCols?: number
|
||||
}
|
||||
|
||||
export interface UseTerminalSocketReturn {
|
||||
status: Ref<TerminalStatus>
|
||||
sendInput: (data: string) => void
|
||||
sendResize: (rows: number, cols: number) => void
|
||||
reconnect: () => void
|
||||
close: () => void
|
||||
}
|
||||
|
||||
function wsURL(rows: number, cols: number, token: string): string {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const host = window.location.host
|
||||
return (
|
||||
`${proto}://${host}/api/v1/admin/terminal/stream` +
|
||||
`?rows=${rows}&cols=${cols}&token=${encodeURIComponent(token)}`
|
||||
)
|
||||
}
|
||||
|
||||
export function useTerminalSocket(opts: UseTerminalSocketOptions): UseTerminalSocketReturn {
|
||||
const status = ref<TerminalStatus>('idle')
|
||||
const rows = ref(opts.initialRows ?? 24)
|
||||
const cols = ref(opts.initialCols ?? 80)
|
||||
|
||||
let ws: WebSocket | null = null
|
||||
let retried = 0
|
||||
let manualClose = false
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const auth = useAuthStore()
|
||||
const decoder = new TextDecoder()
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
function connect() {
|
||||
if (ws || manualClose) return
|
||||
status.value = 'connecting'
|
||||
const token = auth.token ?? ''
|
||||
const sock = new WebSocket(wsURL(rows.value, cols.value, token))
|
||||
sock.binaryType = 'arraybuffer'
|
||||
ws = sock
|
||||
|
||||
sock.onopen = () => {
|
||||
status.value = 'open'
|
||||
retried = 0
|
||||
}
|
||||
|
||||
sock.onmessage = (ev: MessageEvent) => {
|
||||
if (ev.data instanceof ArrayBuffer) {
|
||||
opts.onData(decoder.decode(new Uint8Array(ev.data)))
|
||||
} else if (typeof ev.data === 'string') {
|
||||
// 兼容性兜底:服务端目前只发二进制帧。
|
||||
opts.onData(ev.data)
|
||||
}
|
||||
}
|
||||
|
||||
sock.onerror = () => {
|
||||
status.value = 'error'
|
||||
}
|
||||
|
||||
sock.onclose = () => {
|
||||
ws = null
|
||||
if (manualClose) {
|
||||
status.value = 'closed'
|
||||
return
|
||||
}
|
||||
if (retried < 2) {
|
||||
const delay = retried === 0 ? 1000 : 3000
|
||||
retried += 1
|
||||
reconnectTimer = setTimeout(connect, delay)
|
||||
} else {
|
||||
status.value = 'closed'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sendInput(data: string) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||
ws.send(encoder.encode(data))
|
||||
}
|
||||
|
||||
function sendResize(r: number, c: number) {
|
||||
rows.value = r
|
||||
cols.value = c
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||
ws.send(JSON.stringify({ type: 'resize', rows: r, cols: c }))
|
||||
}
|
||||
|
||||
function reconnect() {
|
||||
if (ws) ws.close()
|
||||
retried = 0
|
||||
manualClose = false
|
||||
connect()
|
||||
}
|
||||
|
||||
function close() {
|
||||
manualClose = true
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
if (ws) ws.close()
|
||||
status.value = 'closed'
|
||||
}
|
||||
|
||||
connect()
|
||||
onScopeDispose(close)
|
||||
|
||||
return { status, sendInput, sendResize, reconnect, close }
|
||||
}
|
||||
@@ -175,6 +175,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/admin/AgentKindEditView.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: '/admin/terminal',
|
||||
name: 'admin-terminal',
|
||||
component: () => import('@/views/admin/AdminTerminalView.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
// 兜底:匹配不到的路径(如空参数的 /p/x/w/、手输错误 URL)统一回首页,避免白屏
|
||||
path: '/:pathMatch(.*)*',
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<AppShell>
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-lg font-semibold">
|
||||
终端
|
||||
</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<NTag
|
||||
:type="statusType"
|
||||
size="small"
|
||||
round
|
||||
>
|
||||
{{ statusLabel }}
|
||||
</NTag>
|
||||
<NButton
|
||||
size="small"
|
||||
:disabled="sock.status.value === 'connecting' || sock.status.value === 'open'"
|
||||
@click="sock.reconnect()"
|
||||
>
|
||||
重连
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NAlert
|
||||
type="warning"
|
||||
:show-icon="true"
|
||||
title="管理员终端"
|
||||
>
|
||||
命令在 server 宿主进程(部署时即 runtime 容器)内执行,权限等同容器内 shell。
|
||||
安装 ACP 客户端示例:<code>npm i -g @anthropic-ai/claude-code</code>,
|
||||
安装完成后将 <code>binary_path</code> 填入对应的 ACP 客户端配置。
|
||||
</NAlert>
|
||||
|
||||
<div
|
||||
ref="containerEl"
|
||||
class="terminal-host"
|
||||
/>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { NAlert, NButton, NTag } from 'naive-ui'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import { useTerminalSocket } from '@/composables/useTerminalSocket'
|
||||
|
||||
const containerEl = ref<HTMLElement | null>(null)
|
||||
|
||||
const term = new Terminal({
|
||||
fontFamily: 'Menlo, Consolas, "DejaVu Sans Mono", monospace',
|
||||
fontSize: 13,
|
||||
cursorBlink: true,
|
||||
convertEol: false,
|
||||
scrollback: 5000,
|
||||
})
|
||||
const fitAddon = new FitAddon()
|
||||
term.loadAddon(fitAddon)
|
||||
|
||||
// 在 setup 同步阶段建立连接(onScopeDispose 才能正确挂到组件 scope)。
|
||||
// term.write 在 open() 前会被 xterm 内部缓冲,待挂载后渲染。
|
||||
const sock = useTerminalSocket({
|
||||
onData: (text) => term.write(text),
|
||||
initialRows: term.rows,
|
||||
initialCols: term.cols,
|
||||
})
|
||||
term.onData((d) => sock.sendInput(d))
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
function fitAndReport() {
|
||||
if (!containerEl.value) return
|
||||
try {
|
||||
fitAddon.fit()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
sock.sendResize(term.rows, term.cols)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!containerEl.value) return
|
||||
term.open(containerEl.value)
|
||||
fitAndReport()
|
||||
resizeObserver = new ResizeObserver(() => fitAndReport())
|
||||
resizeObserver.observe(containerEl.value)
|
||||
term.focus()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
sock.close()
|
||||
term.dispose()
|
||||
})
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
switch (sock.status.value) {
|
||||
case 'connecting':
|
||||
return '连接中'
|
||||
case 'open':
|
||||
return '已连接'
|
||||
case 'error':
|
||||
return '错误'
|
||||
case 'closed':
|
||||
return '已断开'
|
||||
default:
|
||||
return '空闲'
|
||||
}
|
||||
})
|
||||
|
||||
const statusType = computed<'success' | 'warning' | 'error' | 'default'>(() => {
|
||||
switch (sock.status.value) {
|
||||
case 'open':
|
||||
return 'success'
|
||||
case 'connecting':
|
||||
return 'warning'
|
||||
case 'error':
|
||||
return 'error'
|
||||
default:
|
||||
return 'default'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.terminal-host {
|
||||
width: 100%;
|
||||
height: calc(100vh - 220px);
|
||||
min-height: 320px;
|
||||
background: #000;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user