replace_portraits.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. # -*- coding: utf-8 -*-
  2. new_portraits = ''' async function renderPortraitsPage() {
  3. els.pageTitle.textContent = '画像与标签看板';
  4. const page = document.createElement('div');
  5. page.className = 'page';
  6. page.innerHTML = `
  7. <div class="dashboard-grid" id="pt-dashboard">
  8. <div class="kpi-row" id="pt-kpi-row"></div>
  9. <div class="chart-card third">
  10. <h3 class="chart-title"><span>画像覆盖率</span></h3>
  11. <div class="chart-container small" id="pt-coverage-chart"></div>
  12. </div>
  13. <div class="chart-card third">
  14. <h3 class="chart-title"><span>画像来源分布</span></h3>
  15. <div class="chart-container small" id="pt-source-chart"></div>
  16. </div>
  17. <div class="chart-card third">
  18. <h3 class="chart-title"><span>Top 标签</span></h3>
  19. <div class="chart-container small" id="pt-tags-chart"></div>
  20. </div>
  21. <div class="chart-card full">
  22. <h3 class="chart-title"><span>近7日画像生成趋势</span></h3>
  23. <div class="chart-container" id="pt-trend-chart"></div>
  24. </div>
  25. <div class="operation-toolbar">
  26. <div class="toolbar-left">
  27. <button class="btn" id="btn-pt-batch" data-job="batch-portraits">批量生成画像</button>
  28. <button class="btn btn-secondary" id="btn-pt-tag">为客户打标签</button>
  29. <button class="btn btn-secondary" id="btn-pt-export">导出全部画像</button>
  30. </div>
  31. <div class="toolbar-right">
  32. <button class="btn btn-sm btn-secondary" id="btn-pt-refresh">刷新</button>
  33. <button class="btn btn-sm btn-secondary" id="btn-pt-rebuild">重建索引</button>
  34. </div>
  35. </div>
  36. <div class="data-table-card">
  37. <div class="card-header">
  38. <div>
  39. <h3 class="card-title">客户画像列表</h3>
  40. <div class="card-subtitle">支持按画像状态、来源、标签筛选</div>
  41. </div>
  42. </div>
  43. <div class="filter-bar" style="margin-bottom:16px">
  44. <input id="pt-filter-keyword" type="text" placeholder="搜索 externalUserId / 姓名" style="width:220px" />
  45. <select id="pt-filter-source">
  46. <option value="">全部来源</option>
  47. <option value="keyword">关键词</option>
  48. <option value="agent">AI</option>
  49. </select>
  50. <select id="pt-filter-tag">
  51. <option value="">全部标签</option>
  52. </select>
  53. </div>
  54. <div id="pt-portrait-table-wrap"></div>
  55. <div id="pt-pagination"></div>
  56. </div>
  57. </div>
  58. `;
  59. els.content.appendChild(page);
  60. await loadPortraitsDashboard(page);
  61. dashboardRefreshTimer = setInterval(() => {
  62. if (state.page === 'portraits') loadPortraitsDashboard(page, true);
  63. }, 30000);
  64. page.querySelector('#btn-pt-refresh').addEventListener('click', () => loadPortraitsDashboard(page));
  65. page.querySelector('#btn-pt-rebuild').addEventListener('click', async () => {
  66. try {
  67. await api('POST', '/api/dashboard/rebuild');
  68. toast('索引已重建');
  69. loadPortraitsDashboard(page);
  70. } catch (err) {
  71. toast(err.message || '重建失败', 'error');
  72. }
  73. });
  74. page.querySelector('#pt-filter-keyword').addEventListener('input', () => loadPortraitsTable(page));
  75. page.querySelector('#pt-filter-source').addEventListener('change', () => loadPortraitsTable(page));
  76. page.querySelector('#pt-filter-tag').addEventListener('change', () => loadPortraitsTable(page));
  77. page.querySelector('#btn-pt-batch').addEventListener('click', () => openBatchPortraitModal(page));
  78. page.querySelector('#btn-pt-tag').addEventListener('click', () => openTagModal(page));
  79. page.querySelector('#btn-pt-export').addEventListener('click', () => {
  80. startJob('export-portraits', '/api/portraits/export', {}, (result) => {
  81. if (result.files && result.files[0]) {
  82. toast('导出成功,点击下载', 'success');
  83. window.open(`/api/outputs?path=${encodeURIComponent(result.files[0])}`, '_blank');
  84. }
  85. });
  86. });
  87. }
  88. async function loadPortraitsDashboard(page, silent = false) {
  89. if (!silent) page.querySelector('#pt-portrait-table-wrap').innerHTML = '<div class="skeleton" style="height:200px"></div>';
  90. try {
  91. const [summaryRes, portraitsRes, tagsRes, customersRes] = await Promise.all([
  92. api('GET', '/api/dashboard/summary'),
  93. api('GET', '/api/dashboard/portraits'),
  94. api('GET', '/api/dashboard/tags'),
  95. api('GET', '/api/dashboard/customers')
  96. ]);
  97. const summary = summaryRes.data || {};
  98. const portraits = portraitsRes.data?.portraits || {};
  99. const tags = tagsRes.data?.allTags || [];
  100. const customers = customersRes.data?.customers || {};
  101. renderPortraitsKpi(page, summary);
  102. renderPortraitsCharts(page, summary, portraits, tags);
  103. renderPortraitsTagFilter(page, tags);
  104. state.portraits.allPortraits = portraits;
  105. state.portraits.allCustomers = customers;
  106. loadPortraitsTable(page);
  107. } catch (err) {
  108. if (!silent) toast(err.message || '加载看板失败', 'error');
  109. }
  110. }
  111. function renderPortraitsKpi(page, summary) {
  112. const wrap = page.querySelector('#pt-kpi-row');
  113. const p = summary.portraits || {};
  114. const t = summary.tags || {};
  115. const kpi = [
  116. { label: '已生成画像', value: formatNumber(p.total) },
  117. { label: '画像覆盖率', value: (p.coverage || 0) + '%', sub: '有画像客户占比' },
  118. { label: '本地标签', value: formatNumber(t.total), sub: '去重后标签数' },
  119. { label: '人均标签', value: t.avgPerCustomer || 0, sub: '每个客户平均标签' },
  120. { label: 'AI 画像', value: formatNumber(p.bySource?.agent || 0), sub: 'Agent 分析生成' },
  121. { label: '关键词画像', value: formatNumber(p.bySource?.keyword || 0), sub: '关键词匹配生成' }
  122. ];
  123. wrap.innerHTML = renderKpiRow(kpi);
  124. }
  125. function renderPortraitsCharts(page, summary, portraits, tags) {
  126. const coverage = summary.portraits?.coverage || 0;
  127. initEChart('pt-coverage-chart', {
  128. series: [{
  129. type: 'gauge',
  130. startAngle: 180,
  131. endAngle: 0,
  132. min: 0,
  133. max: 100,
  134. progress: { show: true, width: 12 },
  135. axisLine: { lineStyle: { width: 12 } },
  136. axisTick: { show: false },
  137. splitLine: { show: false },
  138. axisLabel: { show: false },
  139. pointer: { show: false },
  140. anchor: { show: false },
  141. title: { show: false },
  142. detail: {
  143. valueAnimation: true,
  144. fontSize: 28,
  145. fontWeight: 800,
  146. color: '#1f1f1f',
  147. formatter: '{value}%',
  148. offsetCenter: [0, '0%']
  149. },
  150. data: [{ value: coverage }]
  151. }]
  152. });
  153. const bySource = summary.portraits?.bySource || {};
  154. initEChart('pt-source-chart', {
  155. tooltip: { trigger: 'item' },
  156. legend: { bottom: 0 },
  157. series: [{
  158. type: 'pie',
  159. radius: ['40%', '65%'],
  160. center: ['50%', '45%'],
  161. data: [
  162. { name: '关键词', value: bySource.keyword || 0, itemStyle: { color: '#fa8c16' } },
  163. { name: 'AI', value: bySource.agent || 0, itemStyle: { color: '#1890ff' } },
  164. { name: '其他', value: bySource.other || 0, itemStyle: { color: '#8c8c8c' } }
  165. ]
  166. }]
  167. });
  168. const tagCounts = {};
  169. for (const p of Object.values(portraits)) {
  170. // 标签统计需要从 customers 中读取
  171. }
  172. const topTags = tags.slice(0, 10).map(t => ({ name: t, value: Object.values(state.portraits.allCustomers || {}).filter(c => Array.isArray(c.tags) && c.tags.includes(t)).length }));
  173. initEChart('pt-tags-chart', {
  174. tooltip: { trigger: 'axis' },
  175. xAxis: { type: 'value' },
  176. yAxis: { type: 'category', data: topTags.map(t => t.name).reverse() },
  177. series: [{ type: 'bar', data: topTags.map(t => t.value).reverse(), itemStyle: { color: '#fa8c16' } }]
  178. });
  179. const days = last7Days();
  180. const portraitItems = Object.values(portraits);
  181. const counts = countByDay(portraitItems, 'updatedAt');
  182. initEChart('pt-trend-chart', {
  183. tooltip: { trigger: 'axis' },
  184. xAxis: { type: 'category', data: days.map(d => formatDate(d)) },
  185. yAxis: { type: 'value' },
  186. series: [{ name: '画像生成', type: 'line', smooth: true, data: counts, itemStyle: { color: '#fa8c16' }, areaStyle: { color: 'rgba(250,140,22,0.15)' } }]
  187. });
  188. }
  189. function renderPortraitsTagFilter(page, tags) {
  190. const select = page.querySelector('#pt-filter-tag');
  191. const current = select.value;
  192. select.innerHTML = '<option value="">全部标签</option>' + tags.map(t => `<option value="${escapeHtml(t)}">${escapeHtml(t)}</option>`).join('');
  193. select.value = current;
  194. }
  195. function loadPortraitsTable(page) {
  196. const wrap = page.querySelector('#pt-portrait-table-wrap');
  197. const portraits = state.portraits.allPortraits || {};
  198. const customers = state.portraits.allCustomers || {};
  199. const keyword = page.querySelector('#pt-filter-keyword').value.trim().toLowerCase();
  200. const source = page.querySelector('#pt-filter-source').value;
  201. const tag = page.querySelector('#pt-filter-tag').value;
  202. let items = Object.values(portraits).map(p => {
  203. const customer = Object.values(customers).find(c => c.externalUserId === p.externalUserId) || {};
  204. return { ...p, name: customer.name, phone: customer.phone, tags: customer.tags || [] };
  205. });
  206. let filtered = items.filter(p => {
  207. if (keyword) {
  208. const text = `${p.externalUserId || ''} ${p.name || ''} ${p.phone || ''}`.toLowerCase();
  209. if (!text.includes(keyword)) return false;
  210. }
  211. if (source && p.source !== source) return false;
  212. if (tag && !p.tags.includes(tag)) return false;
  213. return true;
  214. });
  215. const pageSize = 20;
  216. const currentPage = state.portraits.tablePage || 1;
  217. const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
  218. if (currentPage > totalPages) state.portraits.tablePage = totalPages;
  219. const start = (state.portraits.tablePage - 1) * pageSize;
  220. const pageItems = filtered.slice(start, start + pageSize);
  221. wrap.innerHTML = `
  222. <div class="table-wrap">
  223. <table>
  224. <thead>
  225. <tr>
  226. <th>externalUserId</th>
  227. <th>姓名</th>
  228. <th>标签</th>
  229. <th>画像字段</th>
  230. <th>来源</th>
  231. <th>消息数</th>
  232. <th>更新时间</th>
  233. <th>操作</th>
  234. </tr>
  235. </thead>
  236. <tbody>
  237. ${pageItems.length ? pageItems.map(p => `
  238. <tr>
  239. <td style="font-family:monospace;font-size:12px">${escapeHtml(p.externalUserId || '-')}</td>
  240. <td>${escapeHtml(p.name || '-')}</td>
  241. <td>${p.tags.length ? p.tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('') : '-'}</td>
  242. <td>${formatNumber(p.fields?.length || 0)} 个字段</td>
  243. <td>${p.source === 'keyword' ? '<span class="badge badge-warning">关键词</span>' : p.source === 'agent' ? '<span class="badge badge-info">AI</span>' : '<span class="badge badge-default">未知</span>'}</td>
  244. <td>${formatNumber(p.messageCount)}</td>
  245. <td>${formatDateTime(p.updatedAt)}</td>
  246. <td>
  247. <button class="btn btn-sm btn-secondary" onclick="window.dashboardPortraitDetail('${escapeHtml(p.externalUserId)}')">查看</button>
  248. </td>
  249. </tr>
  250. `).join('') : '<tr><td colspan="8" style="text-align:center;color:var(--text-muted);padding:40px">暂无画像数据</td></tr>'}
  251. </tbody>
  252. </table>
  253. </div>
  254. `;
  255. const pagination = page.querySelector('#pt-pagination');
  256. pagination.innerHTML = renderPagination(state.portraits.tablePage, filtered.length, pageSize);
  257. pagination.querySelectorAll('button').forEach(btn => {
  258. btn.addEventListener('click', () => {
  259. state.portraits.tablePage = Number(btn.dataset.page);
  260. loadPortraitsTable(page);
  261. });
  262. });
  263. }
  264. function openBatchPortraitModal(page) {
  265. const body = `
  266. <div class="form-group" style="margin-bottom:16px">
  267. <label>externalUserIds(每行一个)</label>
  268. <textarea id="batch-portrait-ids" rows="6" placeholder="externalUserId1\nexternalUserId2"></textarea>
  269. </div>
  270. <div class="form-group">
  271. <label>生成模式</label>
  272. <select id="batch-portrait-mode">
  273. <option value="keyword">关键词模式(直接保存)</option>
  274. <option value="ai">AI 模式(生成 context 供分析)</option>
  275. </select>
  276. </div>
  277. `;
  278. openModal('批量生成画像', body, `
  279. <button class="btn btn-secondary" onclick="window.dashboardCloseModal()">取消</button>
  280. <button class="btn" id="btn-confirm-batch-portrait">生成</button>
  281. `);
  282. document.getElementById('btn-confirm-batch-portrait').addEventListener('click', () => {
  283. const ids = document.getElementById('batch-portrait-ids').value.split(/\n|\r/).map(s => s.trim()).filter(Boolean);
  284. const mode = document.getElementById('batch-portrait-mode').value;
  285. if (!ids.length) {
  286. toast('请输入 externalUserIds', 'warning');
  287. return;
  288. }
  289. closeModal();
  290. startJob('batch-portraits', '/api/portraits/batch', { externalUserIds: ids, aiMode: mode, guid: getGuid() }, () => loadPortraitsDashboard(page));
  291. });
  292. }
  293. function openTagModal(page) {
  294. const body = `
  295. <div class="form-group" style="margin-bottom:16px">
  296. <label>externalUserId</label>
  297. <input id="tag-external-id" type="text" placeholder="客户 externalUserId" />
  298. </div>
  299. <div class="form-group" style="margin-bottom:16px">
  300. <label>标签(逗号分隔)</label>
  301. <input id="tag-input" type="text" placeholder="高意向,学区房" />
  302. </div>
  303. <div class="form-group">
  304. <label>操作</label>
  305. <select id="tag-action">
  306. <option value="add">添加标签</option>
  307. <option value="remove">移除标签</option>
  308. </select>
  309. </div>
  310. `;
  311. openModal('为客户打标签', body, `
  312. <button class="btn btn-secondary" onclick="window.dashboardCloseModal()">取消</button>
  313. <button class="btn" id="btn-confirm-tag">确定</button>
  314. `);
  315. document.getElementById('btn-confirm-tag').addEventListener('click', async () => {
  316. const externalUserId = document.getElementById('tag-external-id').value.trim();
  317. const tags = document.getElementById('tag-input').value.split(/[,,]/).map(s => s.trim()).filter(Boolean);
  318. const action = document.getElementById('tag-action').value;
  319. if (!externalUserId || !tags.length) {
  320. toast('请输入 externalUserId 和标签', 'warning');
  321. return;
  322. }
  323. try {
  324. await api('POST', `/api/tags/${action}`, { externalUserId, tags });
  325. toast('标签已更新');
  326. closeModal();
  327. loadPortraitsDashboard(page);
  328. } catch (err) {
  329. toast(err.message || '更新失败', 'error');
  330. }
  331. });
  332. }
  333. window.dashboardPortraitDetail = async (externalUserId) => {
  334. if (!externalUserId) return;
  335. try {
  336. const res = await api('POST', '/api/customer-ops/customer-profile', { externalUserId, guid: getGuid() });
  337. const data = res.data || {};
  338. const portrait = data.portrait;
  339. openModal('画像详情', `
  340. <div style="font-size:13px;color:var(--text-secondary);margin-bottom:12px">
  341. <div><strong>externalUserId:</strong>${escapeHtml(externalUserId)}</div>
  342. </div>
  343. <div class="detail-panel">${escapeHtml(JSON.stringify(portrait || {}, null, 2))}</div>
  344. `, `<button class="btn btn-secondary" onclick="window.dashboardCloseModal()">关闭</button>`);
  345. } catch (err) {
  346. toast(err.message || '查询失败', 'error');
  347. }
  348. };
  349. '''
  350. with open('d:/caidawork/openclaw-voc-skill/claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js', 'r', encoding='utf-8') as f:
  351. lines = f.readlines()
  352. start = 1784
  353. end = 2228
  354. new_lines = lines[:start] + [new_portraits + '\n'] + lines[end:]
  355. with open('d:/caidawork/openclaw-voc-skill/claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js', 'w', encoding='utf-8') as f:
  356. f.writelines(new_lines)
  357. print('renderPortraitsPage replaced successfully')