PlaygroundView.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. <script setup lang="ts">
  2. import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
  3. import { useRoute, useRouter } from 'vue-router'
  4. import { AgentChat, useChatSession, type ChatSession } from '@agent-server/chat-ui'
  5. import type { ProfileSummary } from '@agent-server/client'
  6. import type { PermissionMode, ProviderStatusDto, SessionDetailDto, SessionDto } from '@agent-server/shared'
  7. import DebugPanel from '../components/DebugPanel.vue'
  8. import SidePane from '../components/SidePane.vue'
  9. import { client, errorMessage } from '../lib/api'
  10. import { PERMISSION_LABELS, relativeTime, toast } from '../lib/ui'
  11. const route = useRoute()
  12. const router = useRouter()
  13. const sessionId = computed(() => (route.params.sessionId as string | undefined) || null)
  14. const sessions = ref<SessionDto[]>([])
  15. const profiles = ref<ProfileSummary[]>([])
  16. const providers = ref<ProviderStatusDto[]>([])
  17. const detail = ref<SessionDetailDto | null>(null)
  18. const chat = shallowRef<ChatSession | null>(null)
  19. const showDebug = ref(true)
  20. const debugTab = ref<'events' | 'files' | 'session'>('events')
  21. const preview = ref<{ fileId: string; path: string; mime: string; size: number } | null>(null)
  22. const editingTitle = ref(false)
  23. const titleDraft = ref('')
  24. const showPrompt = ref(false)
  25. const promptDraft = ref('')
  26. const started = computed(() => (chat.value?.state.items.length ?? 0) > 0)
  27. const idle = computed(() => chat.value?.state.runState === 'idle')
  28. const models = computed(() => providers.value.find(p => p.id === detail.value?.provider)?.models ?? [])
  29. async function loadSessions() {
  30. try {
  31. sessions.value = (await client.sessions.list({ limit: 100 })).items
  32. } catch (err) {
  33. toast(errorMessage(err), 'error')
  34. }
  35. }
  36. async function loadDetail() {
  37. if (!sessionId.value) return
  38. detail.value = await client.sessions.get(sessionId.value)
  39. }
  40. watch(sessionId, async id => {
  41. chat.value?.dispose()
  42. chat.value = null
  43. detail.value = null
  44. preview.value = null
  45. if (!id) return
  46. try {
  47. await loadDetail()
  48. chat.value = useChatSession(client, id)
  49. } catch (err) {
  50. toast(errorMessage(err), 'error')
  51. void router.replace('/playground')
  52. }
  53. }, { immediate: true })
  54. // 运行状态变化时刷新列表和详情(标题、更新时间)
  55. watch(() => chat.value?.state.runState, (s, prev) => {
  56. if (s !== prev) {
  57. void loadSessions()
  58. void loadDetail()
  59. }
  60. })
  61. async function newSession(profileId?: string) {
  62. try {
  63. const s = await client.sessions.create({ profileId })
  64. sessions.value.unshift(s)
  65. await router.push(`/playground/${s.id}`)
  66. } catch (err) {
  67. toast(errorMessage(err), 'error')
  68. }
  69. }
  70. /** 空值表示恢复为跟随 Profile */
  71. function setMode(v: string) {
  72. void update({ permissionMode: v ? (v as PermissionMode) : null })
  73. }
  74. async function update(patch: { model?: string; permissionMode?: PermissionMode | null; profileId?: string; systemPromptAppend?: string; title?: string }) {
  75. if (!detail.value) return
  76. try {
  77. await client.sessions.update(detail.value.id, patch)
  78. await loadDetail()
  79. void loadSessions()
  80. } catch (err) {
  81. toast(errorMessage(err), 'error')
  82. }
  83. }
  84. async function removeSession(s: SessionDto) {
  85. if (!confirm(`删除会话「${s.title || '新会话'}」?工作区文件也会一起删除。`)) return
  86. try {
  87. await client.sessions.remove(s.id)
  88. sessions.value = sessions.value.filter(x => x.id !== s.id)
  89. if (sessionId.value === s.id) await router.push('/playground')
  90. } catch (err) {
  91. toast(errorMessage(err), 'error')
  92. }
  93. }
  94. function startEditTitle() {
  95. titleDraft.value = detail.value?.title ?? ''
  96. editingTitle.value = true
  97. }
  98. async function saveTitle() {
  99. editingTitle.value = false
  100. if (detail.value && titleDraft.value.trim() !== detail.value.title) await update({ title: titleDraft.value.trim() })
  101. }
  102. function openPrompt() {
  103. promptDraft.value = detail.value?.systemPromptAppend ?? ''
  104. showPrompt.value = true
  105. }
  106. function openFile(f: { fileId: string; path: string; mime: string; size: number }) {
  107. showDebug.value = true
  108. debugTab.value = 'files'
  109. preview.value = f
  110. }
  111. let timer: ReturnType<typeof setInterval> | undefined
  112. onMounted(async () => {
  113. void loadSessions()
  114. ;[profiles.value, providers.value] = await Promise.all([client.profiles().catch(() => []), client.providers().catch(() => [])])
  115. timer = setInterval(loadSessions, 15000)
  116. })
  117. onBeforeUnmount(() => {
  118. clearInterval(timer)
  119. chat.value?.dispose()
  120. })
  121. </script>
  122. <template>
  123. <SidePane title="Playground">
  124. <template #actions>
  125. <button type="button" class="icon-btn" title="新会话(默认 Profile)" @click="newSession()"><span class="codicon codicon-add" /></button>
  126. <button type="button" class="icon-btn" title="刷新" @click="loadSessions"><span class="codicon codicon-refresh" /></button>
  127. </template>
  128. <template #side>
  129. <div v-if="!sessions.length" class="px-5 py-3 hint">还没有会话,点击 + 新建</div>
  130. <div
  131. v-for="s in sessions" :key="s.id"
  132. class="group flex items-center gap-2 h-[44px] pl-5 pr-2 cursor-pointer hover:bg-hover"
  133. :class="{ '!bg-active': s.id === sessionId }" @click="router.push(`/playground/${s.id}`)"
  134. >
  135. <div class="flex-1 min-w-0">
  136. <div class="truncate">{{ s.title || '新会话' }}</div>
  137. <div class="text-[11px] text-fg-muted truncate">{{ s.model }} · {{ relativeTime(s.updatedAt) }}</div>
  138. </div>
  139. <span v-if="s.status !== 'idle'" class="w-2 h-2 rounded-full flex-none" :class="s.status === 'waiting_input' ? 'bg-warning' : 'bg-accent'" />
  140. <button type="button" class="icon-btn opacity-0 group-hover:opacity-100" title="删除" @click.stop="removeSession(s)">
  141. <span class="codicon codicon-trash" />
  142. </button>
  143. </div>
  144. </template>
  145. <!-- 未选择会话 -->
  146. <div v-if="!sessionId" class="flex-1 flex flex-col items-center justify-center gap-4 p-8 bg-bg-side">
  147. <span class="codicon codicon-comment-discussion text-[40px] text-fg-muted" />
  148. <div class="text-[15px] font-600">在 Playground 中验证 Agent</div>
  149. <div class="hint max-w-110 text-center">使用与业务平台完全相同的 /v1 接口。选择一个 Profile 开始新会话,对话中可以随时切换模型和权限模式。</div>
  150. <div class="flex flex-col gap-1.5 w-80">
  151. <button
  152. v-for="p in profiles" :key="p.id" type="button"
  153. 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"
  154. @click="newSession(p.id)"
  155. >
  156. <span class="codicon codicon-hubot text-accent" />
  157. <span class="flex-1 min-w-0">
  158. <span class="block font-500">{{ p.name }}<span v-if="p.isDefault" class="hint"> · 默认</span></span>
  159. <span class="block hint truncate">{{ p.provider }} / {{ p.model }} · {{ PERMISSION_LABELS[p.permissionMode] }}</span>
  160. </span>
  161. </button>
  162. </div>
  163. </div>
  164. <template v-else>
  165. <header class="h-[35px] flex-none flex items-center gap-2 px-4 border-b border-b-solid border-border bg-bg-side">
  166. <input
  167. v-if="editingTitle" v-model="titleDraft" class="field h-6 max-w-100" aria-label="会话标题" autofocus
  168. @keydown.enter="saveTitle" @keydown.esc="editingTitle = false" @blur="saveTitle"
  169. >
  170. <button v-else type="button" class="border-none bg-transparent p-0 cursor-text font-500 truncate text-fg" title="点击修改标题" @click="startEditTitle">
  171. {{ detail?.title || '新会话' }}
  172. </button>
  173. <span class="flex-1" />
  174. <button type="button" class="icon-btn" title="会话追加提示词" @click="openPrompt"><span class="codicon codicon-note" /></button>
  175. <button type="button" class="icon-btn" :class="{ '!text-fg': showDebug }" title="调试面板" @click="showDebug = !showDebug">
  176. <span class="codicon codicon-layout-sidebar-right" />
  177. </button>
  178. </header>
  179. <div class="flex-1 min-h-0 flex">
  180. <div class="flex-1 min-w-0">
  181. <AgentChat v-if="chat" :key="sessionId!" :client="client" :session="chat" @open-file="openFile">
  182. <template #toolbar>
  183. <template v-if="detail">
  184. <label v-if="!started" class="pill" title="Profile">
  185. <span class="codicon codicon-hubot text-[12px]" />
  186. <select class="pill-select" :value="detail.profileId ?? ''" aria-label="Profile" @change="update({ profileId: ($event.target as HTMLSelectElement).value })">
  187. <option v-for="p in profiles" :key="p.id" :value="p.id">{{ p.name }}</option>
  188. </select>
  189. </label>
  190. <label class="pill" title="模型">
  191. <span class="text-fg-muted">{{ detail.provider }}</span>
  192. <select class="pill-select" :value="detail.model" :disabled="!idle" aria-label="模型" @change="update({ model: ($event.target as HTMLSelectElement).value })">
  193. <option v-for="m in (models.includes(detail.model) ? models : [detail.model, ...models])" :key="m" :value="m">{{ m }}</option>
  194. </select>
  195. </label>
  196. <label class="pill" title="权限模式">
  197. <span class="codicon text-[12px]" :class="detail.permissionMode === 'auto' ? 'codicon-unlock' : detail.permissionMode === 'readonly' ? 'codicon-eye' : 'codicon-shield'" />
  198. <select class="pill-select" :value="detail.permissionModeOverridden ? detail.permissionMode : ''" :disabled="!idle" aria-label="权限模式" @change="setMode(($event.target as HTMLSelectElement).value)">
  199. <option value="">跟随 Profile{{ detail.permissionModeOverridden ? '' : `(${PERMISSION_LABELS[detail.permissionMode]})` }}</option>
  200. <option v-for="(label, k) in PERMISSION_LABELS" :key="k" :value="k">{{ label }}(仅本会话)</option>
  201. </select>
  202. </label>
  203. <!-- Codex 插件与应用连接器只在 Playground 自己创建的会话中开放(ADR-0018) -->
  204. <span v-if="detail.provider === 'codex' && detail.source === 'admin'" class="pill" title="本会话可使用已安装的 Codex 插件与应用连接器(使用登录账号的授权);经 API 创建的会话不会开放">
  205. <span class="codicon codicon-extensions text-[12px]" />插件已开放
  206. </span>
  207. </template>
  208. </template>
  209. </AgentChat>
  210. </div>
  211. <div v-if="showDebug && chat" class="w-[380px] flex-none min-h-0">
  212. <DebugPanel v-model:tab="debugTab" v-model:preview="preview" :state="chat.state" :session="detail" />
  213. </div>
  214. </div>
  215. </template>
  216. <!-- 会话追加提示词 -->
  217. <div v-if="showPrompt" class="fixed inset-0 z-40 flex items-center justify-center bg-[var(--app-overlay)]" @click.self="showPrompt = false">
  218. <div class="w-[560px] card bg-input-alt p-4 flex flex-col gap-3 shadow-xl">
  219. <div class="font-600">会话追加提示词</div>
  220. <div class="hint">追加在 Profile 提示词之后,只对当前会话生效,下一轮对话起生效。</div>
  221. <textarea v-model="promptDraft" class="field-area h-48" aria-label="追加提示词" />
  222. <div class="flex justify-end gap-2">
  223. <button type="button" class="btn-secondary" @click="showPrompt = false">取消</button>
  224. <button type="button" class="btn-primary" :disabled="!idle" @click="update({ systemPromptAppend: promptDraft }); showPrompt = false">保存</button>
  225. </div>
  226. </div>
  227. </div>
  228. </SidePane>
  229. </template>
  230. <style scoped>
  231. .pill-select {
  232. border: none;
  233. background: transparent;
  234. outline: none;
  235. cursor: pointer;
  236. padding: 0;
  237. font-size: 12px;
  238. max-width: 140px;
  239. }
  240. .pill-select option { background: var(--app-input-secondary-background); color: var(--app-primary-foreground); }
  241. .pill-select:disabled { cursor: not-allowed; opacity: 0.6; }
  242. </style>