终端模块初版
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user