212 lines
7.4 KiB
Vue
212 lines
7.4 KiB
Vue
<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>
|