| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262 |
- <script setup lang="ts">
- import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
- import { useRoute, useRouter } from 'vue-router'
- import { AgentChat, useChatSession, type ChatSession } from '@agent-server/chat-ui'
- import type { ProfileSummary } from '@agent-server/client'
- import type { PermissionMode, ProviderStatusDto, SessionDetailDto, SessionDto } from '@agent-server/shared'
- import DebugPanel from '../components/DebugPanel.vue'
- import SidePane from '../components/SidePane.vue'
- import { client, errorMessage } from '../lib/api'
- import { PERMISSION_LABELS, relativeTime, toast } from '../lib/ui'
- const route = useRoute()
- const router = useRouter()
- const sessionId = computed(() => (route.params.sessionId as string | undefined) || null)
- const sessions = ref<SessionDto[]>([])
- const profiles = ref<ProfileSummary[]>([])
- const providers = ref<ProviderStatusDto[]>([])
- const detail = ref<SessionDetailDto | null>(null)
- const chat = shallowRef<ChatSession | null>(null)
- const showDebug = ref(true)
- const debugTab = ref<'events' | 'files' | 'session'>('events')
- const preview = ref<{ fileId: string; path: string; mime: string; size: number } | null>(null)
- const editingTitle = ref(false)
- const titleDraft = ref('')
- const showPrompt = ref(false)
- const promptDraft = ref('')
- const started = computed(() => (chat.value?.state.items.length ?? 0) > 0)
- const idle = computed(() => chat.value?.state.runState === 'idle')
- const models = computed(() => providers.value.find(p => p.id === detail.value?.provider)?.models ?? [])
- async function loadSessions() {
- try {
- sessions.value = (await client.sessions.list({ limit: 100 })).items
- } catch (err) {
- toast(errorMessage(err), 'error')
- }
- }
- async function loadDetail() {
- if (!sessionId.value) return
- detail.value = await client.sessions.get(sessionId.value)
- }
- watch(sessionId, async id => {
- chat.value?.dispose()
- chat.value = null
- detail.value = null
- preview.value = null
- if (!id) return
- try {
- await loadDetail()
- chat.value = useChatSession(client, id)
- } catch (err) {
- toast(errorMessage(err), 'error')
- void router.replace('/playground')
- }
- }, { immediate: true })
- // 运行状态变化时刷新列表和详情(标题、更新时间)
- watch(() => chat.value?.state.runState, (s, prev) => {
- if (s !== prev) {
- void loadSessions()
- void loadDetail()
- }
- })
- async function newSession(profileId?: string) {
- try {
- const s = await client.sessions.create({ profileId })
- sessions.value.unshift(s)
- await router.push(`/playground/${s.id}`)
- } catch (err) {
- toast(errorMessage(err), 'error')
- }
- }
- /** 空值表示恢复为跟随 Profile */
- function setMode(v: string) {
- void update({ permissionMode: v ? (v as PermissionMode) : null })
- }
- async function update(patch: { model?: string; permissionMode?: PermissionMode | null; profileId?: string; systemPromptAppend?: string; title?: string }) {
- if (!detail.value) return
- try {
- await client.sessions.update(detail.value.id, patch)
- await loadDetail()
- void loadSessions()
- } catch (err) {
- toast(errorMessage(err), 'error')
- }
- }
- async function removeSession(s: SessionDto) {
- if (!confirm(`删除会话「${s.title || '新会话'}」?工作区文件也会一起删除。`)) return
- try {
- await client.sessions.remove(s.id)
- sessions.value = sessions.value.filter(x => x.id !== s.id)
- if (sessionId.value === s.id) await router.push('/playground')
- } catch (err) {
- toast(errorMessage(err), 'error')
- }
- }
- function startEditTitle() {
- titleDraft.value = detail.value?.title ?? ''
- editingTitle.value = true
- }
- async function saveTitle() {
- editingTitle.value = false
- if (detail.value && titleDraft.value.trim() !== detail.value.title) await update({ title: titleDraft.value.trim() })
- }
- function openPrompt() {
- promptDraft.value = detail.value?.systemPromptAppend ?? ''
- showPrompt.value = true
- }
- function openFile(f: { fileId: string; path: string; mime: string; size: number }) {
- showDebug.value = true
- debugTab.value = 'files'
- preview.value = f
- }
- let timer: ReturnType<typeof setInterval> | undefined
- onMounted(async () => {
- void loadSessions()
- ;[profiles.value, providers.value] = await Promise.all([client.profiles().catch(() => []), client.providers().catch(() => [])])
- timer = setInterval(loadSessions, 15000)
- })
- onBeforeUnmount(() => {
- clearInterval(timer)
- chat.value?.dispose()
- })
- </script>
- <template>
- <SidePane title="Playground">
- <template #actions>
- <button type="button" class="icon-btn" title="新会话(默认 Profile)" @click="newSession()"><span class="codicon codicon-add" /></button>
- <button type="button" class="icon-btn" title="刷新" @click="loadSessions"><span class="codicon codicon-refresh" /></button>
- </template>
- <template #side>
- <div v-if="!sessions.length" class="px-5 py-3 hint">还没有会话,点击 + 新建</div>
- <div
- v-for="s in sessions" :key="s.id"
- class="group flex items-center gap-2 h-[44px] pl-5 pr-2 cursor-pointer hover:bg-hover"
- :class="{ '!bg-active': s.id === sessionId }" @click="router.push(`/playground/${s.id}`)"
- >
- <div class="flex-1 min-w-0">
- <div class="truncate">{{ s.title || '新会话' }}</div>
- <div class="text-[11px] text-fg-muted truncate">{{ s.model }} · {{ relativeTime(s.updatedAt) }}</div>
- </div>
- <span v-if="s.status !== 'idle'" class="w-2 h-2 rounded-full flex-none" :class="s.status === 'waiting_input' ? 'bg-warning' : 'bg-accent'" />
- <button type="button" class="icon-btn opacity-0 group-hover:opacity-100" title="删除" @click.stop="removeSession(s)">
- <span class="codicon codicon-trash" />
- </button>
- </div>
- </template>
- <!-- 未选择会话 -->
- <div v-if="!sessionId" class="flex-1 flex flex-col items-center justify-center gap-4 p-8 bg-bg-side">
- <span class="codicon codicon-comment-discussion text-[40px] text-fg-muted" />
- <div class="text-[15px] font-600">在 Playground 中验证 Agent</div>
- <div class="hint max-w-110 text-center">使用与业务平台完全相同的 /v1 接口。选择一个 Profile 开始新会话,对话中可以随时切换模型和权限模式。</div>
- <div class="flex flex-col gap-1.5 w-80">
- <button
- v-for="p in profiles" :key="p.id" type="button"
- class="flex items-center gap-2 px-3 py-2 rounded-md border border-solid border-input bg-input-alt cursor-pointer text-left hover:border-active"
- @click="newSession(p.id)"
- >
- <span class="codicon codicon-hubot text-accent" />
- <span class="flex-1 min-w-0">
- <span class="block font-500">{{ p.name }}<span v-if="p.isDefault" class="hint"> · 默认</span></span>
- <span class="block hint truncate">{{ p.provider }} / {{ p.model }} · {{ PERMISSION_LABELS[p.permissionMode] }}</span>
- </span>
- </button>
- </div>
- </div>
- <template v-else>
- <header class="h-[35px] flex-none flex items-center gap-2 px-4 border-b border-b-solid border-border bg-bg-side">
- <input
- v-if="editingTitle" v-model="titleDraft" class="field h-6 max-w-100" aria-label="会话标题" autofocus
- @keydown.enter="saveTitle" @keydown.esc="editingTitle = false" @blur="saveTitle"
- >
- <button v-else type="button" class="border-none bg-transparent p-0 cursor-text font-500 truncate text-fg" title="点击修改标题" @click="startEditTitle">
- {{ detail?.title || '新会话' }}
- </button>
- <span class="flex-1" />
- <button type="button" class="icon-btn" title="会话追加提示词" @click="openPrompt"><span class="codicon codicon-note" /></button>
- <button type="button" class="icon-btn" :class="{ '!text-fg': showDebug }" title="调试面板" @click="showDebug = !showDebug">
- <span class="codicon codicon-layout-sidebar-right" />
- </button>
- </header>
- <div class="flex-1 min-h-0 flex">
- <div class="flex-1 min-w-0">
- <AgentChat v-if="chat" :key="sessionId!" :client="client" :session="chat" @open-file="openFile">
- <template #toolbar>
- <template v-if="detail">
- <label v-if="!started" class="pill" title="Profile">
- <span class="codicon codicon-hubot text-[12px]" />
- <select class="pill-select" :value="detail.profileId ?? ''" aria-label="Profile" @change="update({ profileId: ($event.target as HTMLSelectElement).value })">
- <option v-for="p in profiles" :key="p.id" :value="p.id">{{ p.name }}</option>
- </select>
- </label>
- <label class="pill" title="模型">
- <span class="text-fg-muted">{{ detail.provider }}</span>
- <select class="pill-select" :value="detail.model" :disabled="!idle" aria-label="模型" @change="update({ model: ($event.target as HTMLSelectElement).value })">
- <option v-for="m in (models.includes(detail.model) ? models : [detail.model, ...models])" :key="m" :value="m">{{ m }}</option>
- </select>
- </label>
- <label class="pill" title="权限模式">
- <span class="codicon text-[12px]" :class="detail.permissionMode === 'auto' ? 'codicon-unlock' : detail.permissionMode === 'readonly' ? 'codicon-eye' : 'codicon-shield'" />
- <select class="pill-select" :value="detail.permissionModeOverridden ? detail.permissionMode : ''" :disabled="!idle" aria-label="权限模式" @change="setMode(($event.target as HTMLSelectElement).value)">
- <option value="">跟随 Profile{{ detail.permissionModeOverridden ? '' : `(${PERMISSION_LABELS[detail.permissionMode]})` }}</option>
- <option v-for="(label, k) in PERMISSION_LABELS" :key="k" :value="k">{{ label }}(仅本会话)</option>
- </select>
- </label>
- <!-- Codex 插件与应用连接器只在 Playground 自己创建的会话中开放(ADR-0018) -->
- <span v-if="detail.provider === 'codex' && detail.source === 'admin'" class="pill" title="本会话可使用已安装的 Codex 插件与应用连接器(使用登录账号的授权);经 API 创建的会话不会开放">
- <span class="codicon codicon-extensions text-[12px]" />插件已开放
- </span>
- </template>
- </template>
- </AgentChat>
- </div>
- <div v-if="showDebug && chat" class="w-[380px] flex-none min-h-0">
- <DebugPanel v-model:tab="debugTab" v-model:preview="preview" :state="chat.state" :session="detail" />
- </div>
- </div>
- </template>
- <!-- 会话追加提示词 -->
- <div v-if="showPrompt" class="fixed inset-0 z-40 flex items-center justify-center bg-[var(--app-overlay)]" @click.self="showPrompt = false">
- <div class="w-[560px] card bg-input-alt p-4 flex flex-col gap-3 shadow-xl">
- <div class="font-600">会话追加提示词</div>
- <div class="hint">追加在 Profile 提示词之后,只对当前会话生效,下一轮对话起生效。</div>
- <textarea v-model="promptDraft" class="field-area h-48" aria-label="追加提示词" />
- <div class="flex justify-end gap-2">
- <button type="button" class="btn-secondary" @click="showPrompt = false">取消</button>
- <button type="button" class="btn-primary" :disabled="!idle" @click="update({ systemPromptAppend: promptDraft }); showPrompt = false">保存</button>
- </div>
- </div>
- </div>
- </SidePane>
- </template>
- <style scoped>
- .pill-select {
- border: none;
- background: transparent;
- outline: none;
- cursor: pointer;
- padding: 0;
- font-size: 12px;
- max-width: 140px;
- }
- .pill-select option { background: var(--app-input-secondary-background); color: var(--app-primary-foreground); }
- .pill-select:disabled { cursor: not-allowed; opacity: 0.6; }
- </style>
|