feat: release OpsXShell 2.1.0 native Tauri edition
This commit is contained in:
1 parent
e9a0d21758
commit
a3dba0c6ed
311 files changed
+31748
-14897
No files matched your search
@@ -0,0 +1,313 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Check, Clipboard, CornerDownLeft, Loader2, Play, Server, ShieldAlert, Sparkles, X } from 'lucide-react'
|
||||
import type { AiAssistantAction } from '../../../shared/types/ai.types'
|
||||
import { useAiProviderStore } from '../../stores/aiProviderStore'
|
||||
import { extractBashBlocks, singleLineTerminalInsert, terminalContextLineCount } from '../../utils/aiResponse'
|
||||
import { writeClipboardText } from '../../platform/clipboard'
|
||||
import { useConfirmDialog } from '../common/ConfirmDialog'
|
||||
|
||||
export interface TerminalAiContext {
|
||||
selection: string
|
||||
viewport: string
|
||||
}
|
||||
|
||||
interface AiTerminalPanelProps {
|
||||
capture: () => TerminalAiContext
|
||||
insert: (command: string) => void
|
||||
onClose: () => void
|
||||
connectionId?: string | null
|
||||
targetLabel?: string
|
||||
isLocal?: boolean
|
||||
}
|
||||
|
||||
const ACTIONS: Array<{ id: AiAssistantAction; label: string }> = [
|
||||
{ id: 'explain', label: '解释' },
|
||||
{ id: 'command', label: '生成命令' },
|
||||
{ id: 'diagnose', label: '排障' },
|
||||
{ id: 'execute', label: '执行任务' },
|
||||
]
|
||||
|
||||
function commandMayChangeSystem(command: string): boolean {
|
||||
return /(^|[;&|]\s*)(rm|mv|cp|chmod|chown|kill|pkill|reboot|shutdown|mkfs|dd|systemctl|service|apt|yum|dnf|apk|brew)\b|\bsed\s+-i\b|(^|[^<])>{1,2}[^=]|\bkubectl\s+(delete|apply|patch|replace|scale|rollout)\b/i.test(command)
|
||||
}
|
||||
|
||||
export default function AiTerminalPanel({
|
||||
capture,
|
||||
insert,
|
||||
onClose,
|
||||
connectionId = null,
|
||||
targetLabel = '当前 SSH 会话',
|
||||
isLocal = false,
|
||||
}: AiTerminalPanelProps) {
|
||||
const store = useAiProviderStore()
|
||||
const [action, setAction] = useState<AiAssistantAction>('diagnose')
|
||||
const [prompt, setPrompt] = useState('')
|
||||
const [includeViewport, setIncludeViewport] = useState(false)
|
||||
const [result, setResult] = useState('')
|
||||
const [contextNote, setContextNote] = useState('不会自动发送终端内容')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [toolOutput, setToolOutput] = useState('')
|
||||
const [pendingCommand, setPendingCommand] = useState('')
|
||||
const [executing, setExecuting] = useState(false)
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog()
|
||||
|
||||
useEffect(() => {
|
||||
void store.load()
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const provider = store.providers.find((item) => item.id === store.activeProviderId && item.enabled)
|
||||
const model = provider?.models.find((item) => item.id === provider.defaultModelId)
|
||||
const blocks = useMemo(() => extractBashBlocks(result), [result])
|
||||
const insertable = blocks.length === 1 ? singleLineTerminalInsert(blocks[0]) : null
|
||||
|
||||
const submit = async () => {
|
||||
setError('')
|
||||
setResult('')
|
||||
if (!provider || !model) {
|
||||
setError('请先在“设置 → 大模型”添加 Provider 并选择默认模型')
|
||||
return
|
||||
}
|
||||
if (!provider.hasApiKey && provider.authStyle !== 'none') {
|
||||
setError(`Provider「${provider.name}」尚未保存 API Key`)
|
||||
return
|
||||
}
|
||||
if (!prompt.trim()) {
|
||||
setError('请输入问题或任务')
|
||||
return
|
||||
}
|
||||
if (action === 'execute' && (!connectionId || isLocal)) {
|
||||
setError('ssh-exec 只作用于当前已连接的远程 SSH 会话')
|
||||
return
|
||||
}
|
||||
const captured = capture()
|
||||
const selection = captured.selection.trim()
|
||||
const context = selection || (includeViewport ? captured.viewport.trim() : '')
|
||||
setContextNote(
|
||||
selection
|
||||
? `已发送选中内容 · ${terminalContextLineCount(context)} 行`
|
||||
: context
|
||||
? `已发送当前屏幕 · ${terminalContextLineCount(context)} 行`
|
||||
: '本次未发送终端内容'
|
||||
)
|
||||
setLoading(true)
|
||||
setToolOutput('')
|
||||
setPendingCommand('')
|
||||
try {
|
||||
const executionScope = action === 'execute'
|
||||
? `\n\n执行目标:当前 SSH 会话「${targetLabel}」。只生成远程 shell 内要执行的命令。远程已有的 kubectl、helm、docker 等工具可像普通命令一样使用。`
|
||||
: ''
|
||||
const response = await window.opsxApi.aiComplete({
|
||||
provider,
|
||||
model,
|
||||
action,
|
||||
prompt: `${prompt.trim()}${executionScope}`,
|
||||
context,
|
||||
})
|
||||
if (!response.success || !response.text) throw new Error(response.error || '模型未返回内容')
|
||||
setResult(response.text)
|
||||
if (action === 'execute') {
|
||||
const commands = extractBashBlocks(response.text)
|
||||
const command = commands.length === 1 ? singleLineTerminalInsert(commands[0]) : null
|
||||
if (!command) {
|
||||
setError('模型没有返回唯一的单行命令,已拒绝进入执行流程')
|
||||
} else {
|
||||
setPendingCommand(command)
|
||||
}
|
||||
}
|
||||
if (response.redactedCount) setContextNote((current) => `${current} · 本地脱敏 ${response.redactedCount} 处`)
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : String(caught))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const executeOnce = async () => {
|
||||
if (!provider || !model || !pendingCommand || action !== 'execute') return
|
||||
const target = `SSH · ${targetLabel}`
|
||||
const highRisk = commandMayChangeSystem(pendingCommand)
|
||||
const approved = await confirm({
|
||||
title: highRisk ? '确认执行可能修改系统的命令' : '确认执行一次',
|
||||
message: `目标:${target}\n\n命令:${pendingCommand}\n\n该操作只执行一次,不会保存到命令历史。`,
|
||||
danger: highRisk,
|
||||
confirmText: highRisk ? '确认风险并执行' : '执行一次',
|
||||
})
|
||||
if (!approved) return
|
||||
|
||||
setExecuting(true)
|
||||
setError('')
|
||||
setToolOutput('')
|
||||
try {
|
||||
const response = await window.opsxApi.sshExec(connectionId || '', pendingCommand)
|
||||
if (!response.success) throw new Error(response.error || '工具执行失败')
|
||||
const output = response.result?.trim() || '命令执行成功,无输出'
|
||||
setToolOutput(output)
|
||||
const executedCommand = pendingCommand
|
||||
setPendingCommand('')
|
||||
|
||||
const analysis = await window.opsxApi.aiComplete({
|
||||
provider,
|
||||
model,
|
||||
action: 'diagnose',
|
||||
prompt: `用户原任务:${prompt.trim()}\n已通过 ssh-exec 执行一次命令。根据真实结果给出简短结论;不要声称执行了其他操作。`,
|
||||
context: `$ ${executedCommand}\n${output}`,
|
||||
})
|
||||
if (analysis.success && analysis.text) setResult(analysis.text)
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : String(caught))
|
||||
} finally {
|
||||
setExecuting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copyResult = async () => {
|
||||
await writeClipboardText(result)
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 1200)
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="border-dark-700/50 bg-dark-850 flex w-[370px] flex-shrink-0 flex-col border-l">
|
||||
<header className="border-dark-700/50 flex items-center gap-2 border-b px-3 py-2.5">
|
||||
<Sparkles className="h-4 w-4 text-accent-blue" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-content-primary">终端助手</div>
|
||||
<div className="truncate text-[10px] text-txt-faint">
|
||||
{provider && model ? `${provider.name} · ${model.label}` : '尚未配置模型'}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" title="关闭" onClick={onClose} className="rounded p-1 text-txt-muted hover:bg-dark-700">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 space-y-3 overflow-y-auto p-3">
|
||||
<div className="bg-dark-900/60 flex gap-1 rounded-lg p-1">
|
||||
{ACTIONS.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAction(item.id)
|
||||
setResult('')
|
||||
setToolOutput('')
|
||||
setPendingCommand('')
|
||||
setError('')
|
||||
}}
|
||||
className={`flex-1 rounded-md px-2 py-1.5 text-xs ${action === item.id ? 'bg-accent-blue/15 text-accent-blue' : 'text-txt-muted hover:text-content-secondary'}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{action === 'execute' && (
|
||||
<div className="border-dark-700/40 bg-dark-900/30 space-y-2 rounded-lg border p-2.5">
|
||||
<div className="flex items-center gap-1.5 truncate text-[11px] text-content-secondary">
|
||||
<Server className="h-3.5 w-3.5 flex-shrink-0" /> 目标:{targetLabel}
|
||||
</div>
|
||||
<p className="text-[10px] leading-relaxed text-txt-faint">AI 只准备一条远程命令;固定复用当前 SSH 连接,每次真实执行都需要你确认。</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
rows={4}
|
||||
placeholder="例如:分析这段报错,并给出最少的只读检查命令"
|
||||
className="ui-input-sm resize-none leading-relaxed"
|
||||
/>
|
||||
|
||||
<label className="border-dark-700/40 bg-dark-900/30 flex items-start gap-2 rounded-lg border p-2.5 text-xs text-txt-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeViewport}
|
||||
onChange={(event) => setIncludeViewport(event.target.checked)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-content-secondary">无选区时附带当前屏幕</span>
|
||||
<span className="mt-0.5 block text-[10px] text-txt-faint">
|
||||
有选中内容时始终只发送选区;默认不读取屏幕。
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void submit()}
|
||||
disabled={loading}
|
||||
className="ui-btn-primary flex w-full items-center justify-center gap-1.5"
|
||||
>
|
||||
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}{' '}
|
||||
{loading ? '处理中...' : action === 'execute' ? '准备执行方案' : '生成建议'}
|
||||
</button>
|
||||
<div className="text-[10px] text-txt-faint">{contextNote}</div>
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-lg border border-red-500/25 bg-red-500/10 px-3 py-2 text-xs text-red-300"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="space-y-2">
|
||||
<div className="border-dark-700/50 bg-dark-900/55 whitespace-pre-wrap break-words rounded-lg border p-3 font-mono text-xs leading-relaxed text-content-secondary">
|
||||
{result}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyResult()}
|
||||
className="ui-btn-secondary flex items-center gap-1.5"
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Clipboard className="h-3.5 w-3.5" />}{' '}
|
||||
{copied ? '已复制' : '复制结果'}
|
||||
</button>
|
||||
{action !== 'execute' && insertable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insert(insertable)}
|
||||
className="ui-btn-primary flex items-center gap-1.5"
|
||||
>
|
||||
<CornerDownLeft className="h-3.5 w-3.5" />
|
||||
填入终端
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{action !== 'execute' && blocks.length > 0 && !insertable && (
|
||||
<p className="text-[10px] text-amber-300">多行脚本仅允许复制,不提供填入按钮。</p>
|
||||
)}
|
||||
{action === 'execute' && pendingCommand && (
|
||||
<div className={`rounded-lg border p-2.5 ${commandMayChangeSystem(pendingCommand) ? 'border-amber-500/30 bg-amber-500/10' : 'border-dark-700/50 bg-dark-900/40'}`}>
|
||||
<div className="mb-1 text-[10px] text-txt-faint">待执行 · SSH {targetLabel}</div>
|
||||
<code className="block break-all text-xs text-content-secondary">{pendingCommand}</code>
|
||||
<button type="button" onClick={() => void executeOnce()} disabled={executing} className="ui-btn-primary mt-2 flex items-center gap-1.5">
|
||||
{executing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
|
||||
{executing ? '执行并分析中...' : '执行一次'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toolOutput && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-[10px] text-emerald-400">真实工具输出</div>
|
||||
<pre className="border-dark-700/50 bg-black/25 max-h-52 overflow-auto whitespace-pre-wrap break-words rounded-lg border p-3 text-[11px] text-content-secondary">{toolOutput}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="border-dark-700/40 flex items-start gap-2 border-t px-3 py-2 text-[10px] leading-relaxed text-txt-faint">
|
||||
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
|
||||
ssh-exec 只复用当前连接;每次执行均需确认,关闭面板即清空内容,不保存命令历史。
|
||||
</footer>
|
||||
{ConfirmDialogComponent}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user