DreamConsole.vue 11.9 KB
<template>
  <div class="dream-page">
    <AppNav />
    <div class="dream-layout">
      <header class="page-header">
        <div>
          <h1>🌙 Dream 经验汇流</h1>
          <p class="subtitle">定时把所有 agent 在所有公司的记忆提炼成不含公司信息的经验,跨公司沉淀</p>
        </div>
        <div class="header-actions">
          <button class="btn-refresh" @click="loadAgents">🔄 刷新</button>
          <button class="btn-sweep" :disabled="sweepStatus.sweeping" @click="triggerSweep">
            {{ sweepStatus.sweeping ? '⏳ 全量扫描中…' : '🌙 全量扫描' }}
          </button>
        </div>
      </header>

      <div v-if="sweepStatus.startedAt" class="sweep-banner" :class="{ running: sweepStatus.sweeping }">
        <span v-if="sweepStatus.sweeping">上一轮 {{ formatTime(sweepStatus.startedAt) }} 起跑,串行扫描中,可能要几分钟到几十分钟——刷新本页不会打断它。</span>
        <span v-else-if="sweepStatus.error">上一轮 {{ formatTime(sweepStatus.startedAt) }} 扫描失败:{{ sweepStatus.error }}</span>
        <span v-else-if="sweepStatus.results">
          上一轮 {{ formatTime(sweepStatus.finishedAt) }} 完成:{{ sweepStatus.results.length }} 个本体,
          {{ sweepStatus.results.filter(r => !r.skipped && !r.error).length }} 个有新原料,
          沉淀 {{ sweepStatus.results.reduce((n, r) => n + (r.accepted || 0), 0) }} 条。
        </span>
      </div>

      <div v-if="loading" class="loading-state">⏳ 加载中…</div>
      <div v-else-if="agents.length === 0" class="empty-state">没有可 dream 的本体</div>

      <div v-else class="agents-table">
        <div class="table-header">
          <span>本体</span>
          <span>公司数</span>
          <span>上次跑</span>
          <span>沉淀 / 拒绝</span>
          <span>操作</span>
        </div>
        <div v-for="a in agents" :key="a.agentId" class="table-row">
          <span class="col-agent">{{ a.agentId }}</span>
          <span>{{ a.companies }}</span>
          <span class="col-time">{{ a.lastRunAt ? formatTime(a.lastRunAt) : '从未' }}</span>
          <span>
            <span class="badge accepted">{{ a.lastAccepted }}</span>
            <span class="badge rejected">{{ a.lastRejected }}</span>
          </span>
          <span class="col-actions">
            <button class="btn-action" @click="viewDetail(a.agentId)">👁 详情</button>
            <button class="btn-action" :disabled="runningAgent === a.agentId" @click="runOne(a.agentId, false)">
              {{ runningAgent === a.agentId ? '跑中…' : '▶ 跑一次' }}
            </button>
            <button class="btn-action" :disabled="runningAgent === a.agentId" @click="runOne(a.agentId, true)" title="只看有没有新原料,不起 LLM 调用">
              dry-run
            </button>
          </span>
        </div>
      </div>

      <!-- 详情弹窗 -->
      <div v-if="detail" class="modal-overlay" @click="detail = null">
        <div class="modal-content" @click.stop>
          <div class="modal-header">
            <h2>{{ detail.agentId }}</h2>
            <button class="btn-close" @click="detail = null">✕</button>
          </div>
          <div class="modal-body">
            <section class="detail-section">
              <h4>状态</h4>
              <pre class="state-pre">{{ JSON.stringify(detail.state, null, 2) }}</pre>
            </section>

            <section class="detail-section">
              <h4>定义文件能不能写</h4>
              <div class="def-list">
                <div v-for="d in detail.definitions" :key="d.file" class="def-row">
                  <span class="def-file">{{ d.file }}</span>
                  <span class="badge" :class="d.allowed ? 'accepted' : 'rejected'">
                    {{ d.allowed ? '可写' : '不可写' }}
                  </span>
                  <span class="def-reason">{{ d.reason || '' }}</span>
                </div>
              </div>
            </section>

            <section class="detail-section">
              <h4>已沉淀经验(结论与原料原文并排,抽查用)</h4>
              <div v-if="detail.experience.length === 0" class="empty-inline">还没有沉淀过</div>
              <div v-for="(e, i) in detail.experience" :key="i" class="exp-card">
                <div class="exp-title">{{ e.title }}</div>
                <div v-for="(s, j) in e.sources" :key="j" class="exp-source">
                  <span class="exp-source-id">{{ s.source }}</span>
                  <span v-if="s.text !== null" class="exp-source-text">{{ s.text }}</span>
                  <span v-else class="exp-source-dangling">⚠️ 指针悬空(原料被清理过 / 出处编造)</span>
                </div>
              </div>
            </section>
          </div>
        </div>
      </div>

      <!-- 手动跑结果弹窗 -->
      <div v-if="runResult" class="modal-overlay" @click="runResult = null">
        <div class="modal-content" @click.stop>
          <div class="modal-header">
            <h2>{{ runResult.agentId }} 跑一次的结果</h2>
            <button class="btn-close" @click="runResult = null">✕</button>
          </div>
          <div class="modal-body">
            <pre class="state-pre">{{ JSON.stringify(runResult, null, 2) }}</pre>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import AppNav from '@/components/AppNav.vue'

const agents = ref([])
const loading = ref(true)
const detail = ref(null)
const runResult = ref(null)
const runningAgent = ref(null)
const sweepStatus = ref({ sweeping: false, startedAt: null, finishedAt: null, results: null, error: null })
let pollTimer = null

async function loadAgents() {
  loading.value = true
  try {
    const res = await fetch('/api/dream/agents')
    const data = await res.json()
    agents.value = data.agents || []
  } catch (e) {
    console.error('加载 dream 本体列表失败', e)
  } finally {
    loading.value = false
  }
}

async function loadSweepStatus() {
  try {
    const res = await fetch('/api/dream/sweep/status')
    sweepStatus.value = await res.json()
  } catch (e) {
    console.error('加载 sweep 状态失败', e)
  }
}

async function triggerSweep() {
  try {
    const res = await fetch('/api/dream/sweep', { method: 'POST' })
    if (res.status === 409) { alert('上一轮扫描还没跑完'); return }
    await loadSweepStatus()
  } catch (e) {
    alert('触发失败: ' + e.message)
  }
}

async function viewDetail(agentId) {
  try {
    const res = await fetch(`/api/dream/agents/${agentId}`)
    detail.value = await res.json()
  } catch (e) {
    alert('加载详情失败: ' + e.message)
  }
}

async function runOne(agentId, dryRun) {
  runningAgent.value = agentId
  try {
    const res = await fetch(`/api/dream/agents/${agentId}/run`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ dryRun }),
    })
    runResult.value = await res.json()
    await loadAgents()
  } catch (e) {
    alert('跑失败: ' + e.message)
  } finally {
    runningAgent.value = null
  }
}

function formatTime(iso) {
  if (!iso) return '-'
  return new Date(iso).toLocaleString('zh-CN')
}

onMounted(() => {
  loadAgents()
  loadSweepStatus()
  // 全量扫描可能跑几十分钟,轮询一下让「还在跑」不需要手动刷新才能看到
  pollTimer = setInterval(() => { if (sweepStatus.value.sweeping) loadSweepStatus() }, 15000)
})
onUnmounted(() => { if (pollTimer) clearInterval(pollTimer) })
</script>

<style scoped>
.dream-page { min-height: 100vh; background: var(--bg-primary); color: var(--text-primary); }
.dream-layout { max-width: 1100px; margin: 0 auto; padding: 32px 40px; }

.page-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 20px; }
.page-header h1 { font-size: 24px; font-weight: 700; margin: 0 0 6px; }
.subtitle { color: var(--text-secondary); font-size: 13px; margin: 0; }
.header-actions { display: flex; gap: 10px; }

.btn-refresh, .btn-sweep {
  padding: 8px 16px; border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 600;
  border: 1px solid var(--border-primary); background: var(--bg-surface); color: var(--text-secondary);
}
.btn-sweep { background: var(--accent); border-color: transparent; color: white; }
.btn-sweep:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-refresh:hover { background: var(--bg-surface-hover); }

.sweep-banner {
  padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; font-size: 13px;
  background: var(--accent-bg); color: var(--text-secondary); border: 1px solid var(--border-active);
}
.sweep-banner.running { color: var(--accent-light); }

.loading-state, .empty-state, .empty-inline { text-align: center; padding: 40px; color: var(--text-muted); }
.empty-inline { padding: 12px; text-align: left; font-style: italic; }

.agents-table {
  background: var(--bg-surface); border: 1px solid var(--border-primary); border-radius: 12px; overflow: hidden;
}
.table-header, .table-row {
  display: grid; grid-template-columns: 1.4fr 0.6fr 1fr 1fr 1.6fr;
  gap: 12px; padding: 12px 16px; align-items: center;
}
.table-header { font-size: 12px; font-weight: 600; color: var(--text-muted); border-bottom: 1px solid var(--border-primary); }
.table-row { border-bottom: 1px solid var(--border-primary); font-size: 13px; }
.table-row:last-child { border-bottom: none; }
.col-agent { font-weight: 600; }
.col-time { color: var(--text-secondary); }
.col-actions { display: flex; gap: 6px; flex-wrap: wrap; }

.badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 700; margin-right: 6px; }
.badge.accepted { background: var(--accent-bg); color: var(--accent-light); }
.badge.rejected { background: rgba(248, 81, 73, 0.12); color: #f85149; }

.btn-action {
  padding: 5px 10px; border-radius: 6px; border: 1px solid var(--border-primary);
  background: var(--bg-surface); color: var(--text-primary); font-size: 12px; cursor: pointer;
}
.btn-action:hover { background: var(--bg-surface-hover); }
.btn-action:disabled { opacity: 0.5; cursor: not-allowed; }

/* Modal */
.modal-overlay {
  position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center;
  justify-content: center; z-index: 1000; backdrop-filter: blur(4px);
}
.modal-content {
  background: var(--bg-secondary); border: 1px solid var(--border-primary); border-radius: 12px;
  width: 640px; max-width: 92vw; max-height: 84vh; overflow-y: auto;
}
.modal-header {
  display: flex; justify-content: space-between; align-items: center; padding: 16px 20px;
  border-bottom: 1px solid var(--border-primary); position: sticky; top: 0; background: var(--bg-secondary);
}
.modal-header h2 { margin: 0; font-size: 16px; font-family: monospace; }
.btn-close { background: none; border: none; font-size: 16px; color: var(--text-secondary); cursor: pointer; }
.modal-body { padding: 20px; }

.detail-section { margin-bottom: 20px; }
.detail-section h4 { font-size: 13px; color: var(--text-secondary); margin: 0 0 8px; }
.state-pre {
  background: var(--bg-primary); padding: 12px; border-radius: 8px; font-size: 12px;
  overflow-x: auto; max-height: 200px; overflow-y: auto;
}

.def-list { display: flex; flex-direction: column; gap: 6px; }
.def-row { display: flex; align-items: center; gap: 10px; font-size: 12px; }
.def-file { font-family: monospace; min-width: 80px; }
.def-reason { color: var(--text-muted); }

.exp-card {
  background: var(--bg-primary); border: 1px solid var(--border-primary); border-radius: 8px;
  padding: 12px; margin-bottom: 10px;
}
.exp-title { font-weight: 700; font-size: 13px; margin-bottom: 6px; }
.exp-source { font-size: 12px; margin-bottom: 4px; padding-left: 8px; border-left: 2px solid var(--border-active); }
.exp-source-id { color: var(--text-muted); font-family: monospace; margin-right: 8px; }
.exp-source-text { color: var(--text-secondary); }
.exp-source-dangling { color: #f85149; }
</style>