Files
Thing/src/modules/terminal/components/AiPanel.vue
T
2026-09-18 18:28:13 +08:00

212 lines
7.4 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>