DreamConsole.vue
11.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
<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>