mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-07-29 07:31:45 +08:00
357 lines
13 KiB
Vue
357 lines
13 KiB
Vue
<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>
|