AgentList.vue 9.74 KB
<template>
  <div class="agent-list-page">
    <AppNav />
    <div class="agent-list-layout">

      <!-- 第1列: Agent 列表 -->
      <aside class="col col-agents">
        <div class="col-header">🏢 Agents <span class="badge">{{ agents.length }}</span></div>
        <div class="col-scroll">
          <div
            v-for="a in agents" :key="a.id"
            class="agent-item" :class="{ active: selectedAgent === a.id }"
            @click="selectAgent(a.id)"
          >
            <span class="agent-item-emoji">{{ a.emoji || '🤖' }}</span>
            <div class="agent-item-info">
              <div class="agent-item-name">{{ a.name }}</div>
              <div class="agent-item-type">{{ a.type }}</div>
            </div>
          </div>
        </div>
      </aside>

      <!-- 第2列: Agent 详情 -->
      <section class="col col-detail">
        <div class="col-header">📋 详情</div>
        <div class="col-scroll" v-if="detail">
          <div class="detail-hero">
            <span class="detail-emoji">{{ detail.emoji || '🤖' }}</span>
            <div>
              <h2 class="detail-name">{{ detail.name }}</h2>
              <div class="detail-meta">
                <span class="detail-type-badge">{{ detail.type }}</span>
                <span class="detail-status" :class="detail.status">{{ detail.status }}</span>
              </div>
            </div>
          </div>

          <div class="detail-section">
            <div class="detail-label">ID</div>
            <div class="detail-value mono">{{ detail.id }}</div>
          </div>

          <div class="detail-section">
            <div class="detail-label">问候语</div>
            <div class="detail-value">{{ detail.greeting || '(无)' }}</div>
          </div>

          <div class="detail-section">
            <div class="detail-label">Bootstrap 文件</div>
            <div class="detail-files">
              <span v-for="f in detail.bootstrapFiles" :key="f" class="file-badge" @click="previewFile(f)">{{ f }}</span>
            </div>
          </div>

          <div class="detail-section">
            <div class="detail-label">统计</div>
            <div class="detail-stats">
              <div class="stat-item"><span class="stat-num">{{ fileTree?.totalFiles || 0 }}</span> 文件</div>
              <div class="stat-item"><span class="stat-num">{{ convCount }}</span> 对话</div>
            </div>
          </div>

          <div class="detail-actions">
            <button class="action-btn" @click="$router.push(`/agent/${selectedAgent}`)">💬 对话</button>
          </div>
        </div>
        <div v-else class="col-empty">← 选择一个 Agent</div>
      </section>

      <!-- 第3列: 文件树 -->
      <section class="col col-tree">
        <div class="col-header">📁 文件 <span v-if="fileTree" class="badge">{{ fileTree.totalFiles }}</span></div>
        <div class="col-scroll" v-if="fileTree">
          <div class="file-tree">
            <template v-for="node in fileTree.tree" :key="node.path">
              <FileNode :node="node" :depth="0" :selected="selectedFile" @select="previewFile" />
            </template>
          </div>
        </div>
        <div v-else class="col-empty">← 选择 Agent 以查看文件</div>
      </section>

      <!-- 第4列: 文件预览 -->
      <section class="col col-preview">
        <div class="col-header">👁️ {{ selectedFile || '预览' }}</div>
        <div class="col-scroll">
          <div v-if="previewContent !== null" class="preview-body">
            <pre class="preview-code">{{ previewContent }}</pre>
          </div>
          <div v-else class="col-empty">← 点击文件以预览</div>
        </div>
      </section>

    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AppNav from '@/components/AppNav.vue'
import FileNode from '@/components/FileNode.vue'

const route = useRoute()
const router = useRouter()

const agents = ref([])
const selectedAgent = ref(route.params.agentId || null)
const detail = ref(null)
const fileTree = ref(null)
const selectedFile = ref(null)
const previewContent = ref(null)
const convCount = ref(0)

async function loadAgents() {
  try {
    const r = await fetch('/api/agent/list')
    if (r.ok) agents.value = await r.json()
  } catch (e) { console.error(e) }
}

async function selectAgent(id) {
  selectedAgent.value = id
  selectedFile.value = null
  previewContent.value = null
  router.replace({ params: { agentId: id } })

  // Load detail
  try {
    const r = await fetch(`/api/agent/${id}/info`)
    if (r.ok) detail.value = await r.json()
  } catch {}

  // Load file tree
  try {
    const r = await fetch(`/api/agent/${id}/files`)
    if (r.ok) fileTree.value = await r.json()
  } catch {}

  // Count conversations
  try {
    const r = await fetch(`/api/agent/${id}/conversations`)
    if (r.ok) {
      const convs = await r.json()
      convCount.value = convs.length
    }
  } catch {}
}

async function previewFile(filePath) {
  selectedFile.value = filePath
  try {
    const r = await fetch(`/api/agent/${selectedAgent.value}/file/${filePath}`)
    if (r.ok) {
      const data = await r.json()
      previewContent.value = data.content
    }
  } catch { previewContent.value = '(加载失败)' }
}

onMounted(async () => {
  await loadAgents()
  if (selectedAgent.value) {
    await selectAgent(selectedAgent.value)
  }
})
</script>

<!-- FileNode 子组件 (递归文件树) -->
<script>
// 注册为全局组件的备选方案: 在单独文件中
</script>

<style scoped>
.agent-list-page {
  height: 100vh; display: flex; flex-direction: column;
  background: linear-gradient(145deg, #0a0e1a 0%, #111827 50%, #0d1321 100%);
  color: #e5e7eb; font-family: 'Inter', -apple-system, sans-serif;
}
.agent-list-layout { flex: 1; display: flex; overflow: hidden; }

.col {
  display: flex; flex-direction: column; overflow: hidden;
  border-right: 1px solid rgba(255,255,255,0.06);
}
.col:last-child { border-right: none; }
.col-agents { width: 180px; min-width: 160px; background: rgba(0,0,0,0.2); }
.col-detail { width: 260px; min-width: 220px; background: rgba(255,255,255,0.02); }
.col-tree { width: 240px; min-width: 200px; background: rgba(0,0,0,0.1); }
.col-preview { flex: 1; min-width: 300px; background: rgba(0,0,0,0.15); }

.col-header {
  padding: 10px 12px; font-size: 12px; font-weight: 700; color: rgba(255,255,255,0.5);
  border-bottom: 1px solid rgba(255,255,255,0.06);
  display: flex; align-items: center; gap: 6px;
  letter-spacing: 0.3px;
}
.col-scroll { flex: 1; overflow-y: auto; padding: 8px; }
.col-empty { flex: 1; display: flex; align-items: center; justify-content: center; color: rgba(255,255,255,0.15); font-size: 12px; }

.badge {
  background: rgba(16,185,129,0.15); color: #34d399;
  padding: 1px 7px; border-radius: 10px; font-size: 10px; font-weight: 600;
}

/* Agent 列表 */
.agent-item {
  display: flex; align-items: center; gap: 8px;
  padding: 8px 10px; border-radius: 8px; cursor: pointer;
  transition: all 0.15s; margin-bottom: 2px; border: 1px solid transparent;
}
.agent-item:hover { background: rgba(16,185,129,0.04); }
.agent-item.active { background: rgba(16,185,129,0.1); border-color: rgba(16,185,129,0.25); }
.agent-item-emoji { font-size: 20px; flex-shrink: 0; }
.agent-item-info { min-width: 0; }
.agent-item-name { font-size: 12px; font-weight: 600; color: rgba(255,255,255,0.8); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.agent-item-type { font-size: 10px; color: rgba(255,255,255,0.25); }

/* 详情区 */
.detail-hero {
  display: flex; align-items: center; gap: 12px;
  padding: 16px 8px; margin-bottom: 12px;
  border-bottom: 1px solid rgba(255,255,255,0.04);
}
.detail-emoji { font-size: 36px; filter: drop-shadow(0 4px 12px rgba(16,185,129,0.3)); }
.detail-name { font-size: 16px; font-weight: 700; margin: 0 0 4px; color: #e5e7eb; }
.detail-meta { display: flex; gap: 6px; align-items: center; }
.detail-type-badge {
  font-size: 10px; padding: 2px 8px; border-radius: 6px;
  background: rgba(139,92,246,0.15); color: #a78bfa; font-weight: 600;
}
.detail-status { font-size: 10px; color: rgba(255,255,255,0.3); }
.detail-status.ready { color: #10b981; }

.detail-section { margin-bottom: 12px; }
.detail-label { font-size: 10px; font-weight: 600; color: rgba(255,255,255,0.35); margin-bottom: 3px; text-transform: uppercase; letter-spacing: 0.5px; }
.detail-value { font-size: 12px; color: rgba(255,255,255,0.7); line-height: 1.5; }
.detail-value.mono { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: rgba(255,255,255,0.5); }
.detail-files { display: flex; flex-wrap: wrap; gap: 4px; }
.file-badge {
  font-size: 10px; padding: 2px 8px; border-radius: 4px;
  background: rgba(255,255,255,0.04); color: rgba(255,255,255,0.5);
  border: 1px solid rgba(255,255,255,0.06); cursor: pointer; transition: all 0.15s;
}
.file-badge:hover { background: rgba(16,185,129,0.1); color: #34d399; border-color: rgba(16,185,129,0.2); }

.detail-stats { display: flex; gap: 16px; }
.stat-item { font-size: 12px; color: rgba(255,255,255,0.5); }
.stat-num { font-weight: 700; color: #34d399; font-size: 16px; margin-right: 4px; }

.detail-actions { margin-top: 16px; display: flex; gap: 8px; }
.action-btn {
  padding: 6px 14px; border-radius: 8px; border: 1px solid rgba(16,185,129,0.2);
  background: rgba(16,185,129,0.08); color: #34d399; font-size: 12px;
  cursor: pointer; transition: all 0.2s; font-weight: 500;
}
.action-btn:hover { background: rgba(16,185,129,0.15); transform: translateY(-1px); }

/* 文件树 */
.file-tree { font-size: 11px; }

/* 预览 */
.preview-body { padding: 4px; }
.preview-code {
  font-family: 'JetBrains Mono', 'Menlo', monospace;
  font-size: 11px; line-height: 1.6; color: rgba(255,255,255,0.7);
  white-space: pre-wrap; word-break: break-all; margin: 0;
}
</style>