终端模块初版

This commit is contained in:
zhongluofeng
2026-09-18 18:28:13 +08:00
parent f6c1cc250e
commit b018abd922
63 changed files with 26695 additions and 185 deletions
+211
View File
@@ -0,0 +1,211 @@
<script setup lang="ts">
/**
* AI 命令助手面板(P2)。
*
* # 引擎来源
*
* 复用**翻译模块**的 AI 引擎配置(Base URL / 模型 / 密钥)——用户配置一份 API
* 即可在两处使用。没有可用引擎时展示引导文案而不是让用户点了「生成」才报错。
*
* # 交互与片段库同一套安全语义
*
* 建议默认「填入命令行」(用户自己按回车),「直接执行」是显式第二动作。
* 理由与 SnippetPanel 一致:命令被自动执行与等待用户确认,在心理上完全不同;
* 而且模型的建议未经本地验证,用户应当有机会先看一眼再回车。
*
* # 上下文
*
* 「带上下文」开关取终端当前**选中文本**(用户选中一段报错再点生成,
* 模型能理解「接着这个修」)。没有选区时不传上下文——把整个屏幕
* 内容都塞给模型既稀释意图又增加 token 费用。
*/
import { computed, ref, watch } from 'vue'
import { Sparkles, Square, TerminalSquare } from '@lucide/vue'
import { toast } from 'vue-sonner'
import { useTerminalStore } from '@/stores/terminalStore'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { createLogger } from '@/lib/logger'
import type { AiEngineOption, CommandSuggestion } from '@/types/terminal'
const logger = createLogger('terminal')
const store = useTerminalStore()
const props = defineProps<{
open: boolean
/** 当前会话(填入/执行的目标;null 时只能看不能填) */
sessionId: string | null
/** 取终端上下文(选中文本等),由 TerminalModule 提供 */
getContext: () => string
}>()
const emit = defineEmits<{ (e: 'update:open', v: boolean): void }>()
const engines = ref<AiEngineOption[]>([])
const engineId = ref('')
const intent = ref('')
const useContext = ref(false)
const loadingEngines = ref(false)
const generating = ref(false)
const suggestions = ref<CommandSuggestion[]>([])
watch(
() => props.open,
async v => {
if (!v) return
suggestions.value = []
loadingEngines.value = true
try {
engines.value = await store.aiEngines()
// 默认选第一个(后端已按优先级排序);保留用户上次的选择
if (engineId.value && engines.value.some(e => e.id === engineId.value)) {
// keep
} else {
engineId.value = engines.value[0]?.id ?? ''
}
} catch (e) {
logger.error(`加载 AI 引擎列表失败:${String(e)}`)
toast.error(`加载引擎列表失败:${String(e)}`)
} finally {
loadingEngines.value = false
}
}
)
const canGenerate = computed(
() => !!engineId.value && intent.value.trim().length > 0 && !generating.value
)
async function generate() {
if (!canGenerate.value) return
generating.value = true
try {
const ctx = useContext.value ? props.getContext() : ''
suggestions.value = await store.aiSuggest(engineId.value, intent.value, ctx)
if (suggestions.value.length === 0) {
toast.info('模型没有给出建议,试着换个描述')
}
} catch (e) {
toast.error(`生成失败:${String(e)}`)
} finally {
generating.value = false
}
}
/**
* 填入 / 执行。
*
* 两条路径都把命令字节写进 PTY stdin;区别只是要不要带回车(0x0D)。
* 「填入」让用户保留最后的确认权——模型的建议可能差一个参数。
*/
async function deliver(cmd: string, execute: boolean) {
if (!props.sessionId) {
toast.error('当前没有可写入的会话')
return
}
try {
const payload = execute ? `${cmd}\r` : cmd
await store.write(props.sessionId, new TextEncoder().encode(payload))
if (!execute) emit('update:open', false) // 填入后回到终端看命令
} catch (e) {
toast.error(`写入终端失败:${String(e)}`)
}
}
/** 引擎展示名(含模型,方便多引擎用户区分) */
function engineLabel(e: AiEngineOption): string {
return e.model ? `${e.name}${e.model}` : e.name
}
</script>
<template>
<Dialog :open="open" @update:open="v => emit('update:open', v)">
<DialogContent class="max-w-lg">
<DialogHeader>
<DialogTitle class="flex items-center gap-2 text-base">
<Sparkles class="size-4" />AI 命令助手
</DialogTitle>
<DialogDescription class="text-xs">
复用翻译设置里的 AI 引擎建议默认只填入命令行由你确认后执行
</DialogDescription>
</DialogHeader>
<!-- 引擎选择 -->
<div class="space-y-1.5">
<Label class="text-xs">引擎</Label>
<p v-if="loadingEngines" class="text-xs text-muted-foreground">加载中</p>
<template v-else-if="engines.length > 0">
<Select v-model="engineId">
<SelectTrigger class="h-8 text-xs">
<SelectValue placeholder="选择引擎" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="e in engines" :key="e.id" :value="e.id">
{{ engineLabel(e) }}
</SelectItem>
</SelectContent>
</Select>
</template>
<p v-else class="text-xs text-muted-foreground">
还没有可用的 AI 引擎请到
<span class="text-foreground font-medium">翻译模块 设置 引擎</span>
配置一个DeepSeek / OpenAI / Ollama OpenAI 兼容服务均可配置后回到这里刷新
</p>
</div>
<!-- 意图 -->
<div class="space-y-1.5">
<Label class="text-xs">你想做什么</Label>
<Input
v-model="intent"
placeholder="例如:找出占用磁盘最大的 10 个目录"
class="h-8 text-sm"
@keydown.enter="generate"
/>
<label class="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer select-none">
<input v-model="useContext" type="checkbox" class="accent-primary" />
带上终端选中的文本作为上下文
</label>
</div>
<Button size="sm" class="w-full gap-1.5 text-xs" :disabled="!canGenerate" @click="generate">
<Sparkles class="size-3.5" />{{ generating ? '生成中…' : '生成建议' }}
</Button>
<!-- 建议 -->
<div v-if="suggestions.length > 0" class="space-y-1.5">
<div
v-for="(s, i) in suggestions"
:key="i"
class="rounded border border-border px-2.5 py-1.5 space-y-1"
>
<p class="text-xs font-mono break-all">{{ s.command }}</p>
<p class="text-[11px] text-muted-foreground">{{ s.description }}</p>
<div class="flex gap-1.5 justify-end">
<Button variant="outline" size="sm" class="h-6 gap-1 px-2 text-[11px]" :disabled="!sessionId" @click="deliver(s.command, false)">
<TerminalSquare class="size-3" />填入命令行
</Button>
<Button size="sm" class="h-6 gap-1 px-2 text-[11px]" :disabled="!sessionId" @click="deliver(s.command, true)">
<Square class="size-3" />直接执行
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
</template>