Files
One-KVM/web/src/components/ComputerUseSheet.vue
2026-06-15 23:36:17 +08:00

357 lines
13 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">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { Bot, ChevronDown, Image, KeyRound, Play, Square } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import { computerUseApi, type ComputerUseAction, type ComputerUseConfig, type ComputerUseSession } from '@/api'
import type { ComputerUseTimelineItem } from '@/types/computerUseTimeline'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import { Tabs, TabsContent } from '@/components/ui/tabs'
const props = defineProps<{
open: boolean
connected: boolean
wsError: string | null
session: ComputerUseSession | null
timeline: ComputerUseTimelineItem[]
}>()
const emit = defineEmits<{
(e: 'update:open', value: boolean): void
(e: 'start', prompt: string): void
(e: 'stop'): void
(e: 'clear'): void
}>()
const config = ref<ComputerUseConfig | null>(null)
const prompt = ref('')
const apiKey = ref('')
const savingConfig = ref(false)
const starting = ref(false)
const activeTab = ref('chat')
const messagesRef = ref<HTMLDivElement | null>(null)
const defaultModel = computed({
get: () => config.value?.model ?? 'gpt-5.5',
set: (value: string) => {
if (config.value) config.value.model = value
},
})
const defaultBaseUrl = computed({
get: () => config.value?.base_url ?? 'https://api.openai.com/v1/responses',
set: (value: string) => {
if (config.value) config.value.base_url = value
},
})
const defaultMaxSteps = computed({
get: () => String(config.value?.max_steps ?? 30),
set: (value: string) => {
if (config.value) config.value.max_steps = Number(value) || 30
},
})
const defaultTimeoutSeconds = computed({
get: () => String(config.value?.timeout_seconds ?? 600),
set: (value: string) => {
if (config.value) config.value.timeout_seconds = Number(value) || 600
},
})
const status = computed(() => props.session?.status ?? 'idle')
const isRunning = computed(() => ['waiting_screenshot', 'thinking', 'executing'].includes(status.value))
const canStart = computed(() => !!config.value?.enabled && !!config.value?.api_key_configured && prompt.value.trim().length > 0 && !isRunning.value)
const showWelcome = computed(() => props.timeline.length === 0 && !props.session?.last_error && !props.session?.final_message)
const statusLabel = computed(() => {
switch (status.value) {
case 'waiting_screenshot': return '截屏中'
case 'thinking': return '思考中'
case 'executing': return '执行中'
case 'completed': return '已完成'
case 'failed': return '失败'
case 'stopped': return '已停止'
default: return '空闲'
}
})
async function loadConfig() {
config.value = await computerUseApi.config()
}
async function saveConfig() {
savingConfig.value = true
try {
config.value = await computerUseApi.updateConfig({
enabled: config.value?.enabled ?? true,
base_url: config.value?.base_url || 'https://api.openai.com/v1/responses',
model: config.value?.model || 'gpt-5.5',
max_steps: config.value?.max_steps || 30,
timeout_seconds: config.value?.timeout_seconds || 600,
openai_api_key: apiKey.value.trim() || undefined,
})
apiKey.value = ''
toast.success('Computer Use 配置已保存')
} finally {
savingConfig.value = false
}
}
async function clearApiKey() {
savingConfig.value = true
try {
config.value = await computerUseApi.updateConfig({
clear_openai_api_key: true,
})
apiKey.value = ''
toast.success('OpenAI API Key 已清除')
} finally {
savingConfig.value = false
}
}
async function start() {
if (!canStart.value) return
const text = prompt.value.trim()
starting.value = true
try {
emit('start', text)
prompt.value = ''
} finally {
starting.value = false
}
}
function formatAction(action: ComputerUseAction): string {
switch (action.type) {
case 'click':
return `点击 (${action.x}, ${action.y}) ${action.button ?? 'left'}`
case 'double_click':
return `双击 (${action.x}, ${action.y}) ${action.button ?? 'left'}`
case 'move':
return `移动到 (${action.x}, ${action.y})`
case 'drag':
return `拖拽 ${action.path.length} 个点`
case 'scroll':
return `滚动 (${action.x}, ${action.y}) dx=${action.dx ?? 0} dy=${action.dy ?? 0}`
case 'type':
return `输入 ${action.text.length} 字符`
case 'keypress':
return `按键 ${action.keys.join('+')}`
case 'wait':
return `等待 ${action.ms}ms`
case 'screenshot':
return '请求截图'
}
}
function scrollToBottom() {
nextTick(() => {
const el = messagesRef.value
if (!el) return
el.scrollTop = el.scrollHeight
})
}
watch(() => props.timeline.length, scrollToBottom)
watch(() => props.open, (open) => {
if (open) scrollToBottom()
})
onMounted(loadConfig)
</script>
<template>
<aside
v-show="open"
class="absolute inset-y-0 right-0 z-30 h-full min-h-0 w-[min(100%,420px)] border-l bg-background/98 shadow-xl backdrop-blur md:relative md:z-auto md:w-[420px] xl:w-[460px]"
>
<div class="flex h-full min-h-0 flex-col">
<div class="flex h-12 shrink-0 items-center justify-between border-b px-3">
<div class="flex min-w-0 items-center gap-2">
<Bot class="h-5 w-5 shrink-0" />
<div class="min-w-0">
<div class="truncate text-sm font-semibold">Computer Use</div>
<div class="truncate text-[11px] text-muted-foreground">
WebSocket {{ connected ? '已连接' : '未连接' }}
<span v-if="wsError"> · {{ wsError }}</span>
</div>
</div>
</div>
<div class="flex items-center gap-1.5">
<Badge :variant="status === 'failed' ? 'destructive' : 'secondary'">
{{ statusLabel }}
</Badge>
<Button variant="ghost" size="icon" class="h-8 w-8" @click="emit('update:open', false)">
<ChevronDown class="h-4 w-4 rotate-90" />
</Button>
</div>
</div>
<Tabs v-model="activeTab" class="flex min-h-0 flex-1 flex-col">
<div class="px-3 py-2">
<div class="grid grid-cols-2 rounded-md bg-muted p-1">
<button
type="button"
:class="[
'rounded-sm px-3 py-1.5 text-sm font-medium transition-colors',
activeTab === 'chat' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
]"
@click="activeTab = 'chat'"
>
对话
</button>
<button
type="button"
:class="[
'rounded-sm px-3 py-1.5 text-sm font-medium transition-colors',
activeTab === 'settings' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
]"
@click="activeTab = 'settings'"
>
设置
</button>
</div>
</div>
<TabsContent value="chat" class="m-0 flex min-h-0 flex-1 flex-col data-[state=inactive]:hidden">
<div ref="messagesRef" class="min-h-0 flex-1 space-y-3 overflow-y-auto p-3">
<div v-if="showWelcome" class="rounded-md border border-dashed p-4 text-center text-xs text-muted-foreground">
发送任务后这里会显示对话截图和坐标操作
</div>
<template v-for="item in timeline" :key="item.id">
<div v-if="item.type === 'user'" class="flex justify-end">
<div class="max-w-[86%] rounded-md bg-primary px-3 py-2 text-sm text-primary-foreground">
{{ item.text }}
</div>
</div>
<div v-else-if="item.type === 'assistant'" class="flex justify-start">
<div class="max-w-[86%] rounded-md border bg-muted/50 px-3 py-2 text-sm">
{{ item.text }}
</div>
</div>
<div v-else-if="item.type === 'screenshot'" class="rounded-md border bg-card p-2">
<div class="mb-2 flex items-center justify-between text-xs text-muted-foreground">
<span class="inline-flex items-center gap-1.5"><Image class="h-3.5 w-3.5" />截图</span>
<span>{{ item.screenshot.width }}x{{ item.screenshot.height }}</span>
</div>
<div
class="w-full overflow-hidden rounded-sm bg-black"
:style="{ aspectRatio: `${item.screenshot.width} / ${item.screenshot.height}` }"
>
<img :src="item.screenshot.data_url" class="h-full w-full object-cover" alt="Computer Use screenshot" />
</div>
</div>
<div v-else-if="item.type === 'actions_executed'" class="rounded-md border bg-emerald-50 p-2 text-emerald-950 dark:bg-emerald-950/20 dark:text-emerald-100">
<div class="mb-2 text-xs font-medium">已执行</div>
<div class="space-y-1">
<div v-for="(action, index) in item.actions" :key="index" class="rounded-sm bg-background/60 px-2 py-1.5 text-xs">
{{ formatAction(action) }}
</div>
</div>
</div>
<div v-else-if="item.type === 'error'" class="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{{ item.text }}
</div>
<div v-else class="text-center text-xs text-muted-foreground">
{{ item.text }}
</div>
</template>
</div>
<div class="shrink-0 border-t p-3">
<Textarea
v-model="prompt"
rows="3"
placeholder="继续输入任务或追问"
:disabled="isRunning"
@keydown.meta.enter.prevent="start"
@keydown.ctrl.enter.prevent="start"
/>
<div class="mt-2 flex gap-2">
<Button class="flex-1 gap-2" :disabled="!canStart || starting" @click="start">
<Play class="h-4 w-4" />
发送
</Button>
<Button variant="outline" class="gap-2" :disabled="!isRunning" @click="emit('stop')">
<Square class="h-4 w-4" />
停止
</Button>
<Button variant="ghost" size="sm" :disabled="isRunning || timeline.length === 0" @click="emit('clear')">
清空
</Button>
</div>
<p v-if="!config?.api_key_configured" class="mt-2 text-xs text-muted-foreground">
需要先在设置里保存 OpenAI API Key
</p>
</div>
</TabsContent>
<TabsContent value="settings" class="m-0 min-h-0 flex-1 overflow-y-auto p-3 data-[state=inactive]:hidden">
<div class="space-y-4">
<div class="flex items-center justify-between rounded-md border p-3">
<div>
<div class="text-sm font-medium">启用 AI 操作</div>
<div class="text-xs text-muted-foreground">配置保存后立即生效</div>
</div>
<Switch
:model-value="config?.enabled ?? false"
@update:model-value="(value) => { if (config) config.enabled = value }"
/>
</div>
<div class="space-y-3 rounded-md border p-3">
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1">
<Label class="text-xs">模型</Label>
<Input v-model="defaultModel" :disabled="!config" placeholder="gpt-5.5" />
</div>
<div class="space-y-1">
<Label class="text-xs">最大步数</Label>
<Input v-model="defaultMaxSteps" type="number" min="1" max="100" />
</div>
</div>
<div class="space-y-1">
<Label class="text-xs">超时秒数</Label>
<Input v-model="defaultTimeoutSeconds" type="number" min="30" max="3600" />
</div>
<div class="space-y-1">
<Label class="text-xs">API URL</Label>
<Input v-model="defaultBaseUrl" :disabled="!config" placeholder="https://api.openai.com/v1/responses" />
</div>
<div class="space-y-1">
<Label class="text-xs flex items-center gap-1">
<KeyRound class="h-3.5 w-3.5" />
OpenAI API Key
</Label>
<Input
v-model="apiKey"
type="password"
autocomplete="off"
:placeholder="config?.api_key_configured ? `已配置:${config.api_key_source}` : 'sk-...'"
/>
</div>
<div class="grid grid-cols-2 gap-2">
<Button size="sm" :disabled="savingConfig || !config" @click="saveConfig">
保存配置
</Button>
<Button size="sm" variant="outline" :disabled="savingConfig || !config?.api_key_configured" @click="clearApiKey">
清除 Key
</Button>
</div>
</div>
</div>
</TabsContent>
</Tabs>
</div>
</aside>
</template>