R64-R70: 工作台迭代—视频解析执行状态跨刷新/找不到MCP根因修复/全量排查需MCP功能挂connector/刷新功能强制重新获取/自定义选题创作改名原创选题创作+系列UI微调(cdp-verify回归脚本)
This commit is contained in:
1 parent
3ebdbe0a11
commit
b3e719f385
17 files changed
+745
-65
No files matched your search
@@ -0,0 +1,44 @@
|
||||
// 09-04 R64.2 验证:账号列表「刷新」(更新)按钮去文字留图标
|
||||
const CDP = 9223;
|
||||
const list = await fetch(`http://localhost:${CDP}/json`).then((r) => r.json());
|
||||
const page = list.find((t) => t.type === 'page' && (t.url || '').includes('8900')) || list.find((t) => t.type === 'page');
|
||||
if (!page) { console.log('ERR: no page'); process.exit(1); }
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||
let id = 0; const pending = new Map();
|
||||
const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); });
|
||||
ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(m.error.message)) : p.res(m.result); } };
|
||||
await new Promise((r) => (ws.onopen = r));
|
||||
const ev = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 300) }; return r.result.value; };
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
await send('Page.enable'); await send('Runtime.enable'); await send('Network.enable');
|
||||
await send('Network.setCacheDisabled', { cacheDisabled: true });
|
||||
await send('Emulation.setDeviceMetricsOverride', { width: 860, height: 1000, deviceScaleFactor: 1, mobile: false });
|
||||
const waitFor = async (expr, ms = 15000) => { const t0 = Date.now(); while (Date.now() - t0 < ms) { if (await ev(expr)) return true; await sleep(300); } return false; };
|
||||
const fail = (m) => { console.log('FAIL@' + m); process.exit(1); };
|
||||
const fs = await import('node:fs');
|
||||
|
||||
await send('Page.navigate', { url: `http://127.0.0.1:8900/?t=${Date.now()}#/accounts` });
|
||||
if (!(await waitFor(`location.hash === '#/accounts' && !!document.querySelector('#accUpdate') && [...document.scripts].some((s) => (s.src || '').includes('data-pages.js?v=20260904n'))`, 15000))) fail('accounts route');
|
||||
await sleep(500);
|
||||
|
||||
const r = await ev(`(() => {
|
||||
const b = document.querySelector('#accUpdate');
|
||||
if (!b) return { err: 'no #accUpdate' };
|
||||
const text = (b.textContent || '').trim();
|
||||
const svg = b.querySelector('svg');
|
||||
const rect = b.getBoundingClientRect();
|
||||
return { text, hasSvg: !!svg, svgViewBox: svg ? svg.getAttribute('viewBox') : null, w: Math.round(rect.width), h: Math.round(rect.height), title: b.getAttribute('title') };
|
||||
})()`);
|
||||
console.log(JSON.stringify(r, null, 2));
|
||||
|
||||
await ev(`document.querySelector('#accUpdate').scrollIntoView({ block: 'center' })`); await sleep(200);
|
||||
// 截图按钮所在 toolbar
|
||||
const rect = await ev(`(() => { const el = document.querySelector('.acc-toolbar'); const r = el.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; })()`);
|
||||
if (rect && !rect.__err) {
|
||||
const shot = await send('Page.captureScreenshot', { format: 'png', clip: { x: Math.max(0, rect.x - 4), y: Math.max(0, rect.y - 4), width: Math.min(rect.w + 8, 1200), height: Math.min(rect.h + 8, 800), scale: 1 } });
|
||||
fs.writeFileSync(`tmp/r64_acc_toolbar.png`, Buffer.from(shot.data, 'base64'));
|
||||
}
|
||||
const ok = r && !r.__err && r.text === '' && r.hasSvg;
|
||||
console.log(ok ? '\n=== PASS ===' : '\n=== CHECK ===');
|
||||
ws.close();
|
||||
process.exit(ok ? 0 : 1);
|
||||
@@ -0,0 +1,53 @@
|
||||
// 09-04 R64.3 验证:AI 脚本弹窗「删除」按钮去文字留图标
|
||||
const CDP = 9223;
|
||||
const list = await fetch(`http://localhost:${CDP}/json`).then((r) => r.json());
|
||||
const page = list.find((t) => t.type === 'page' && (t.url || '').includes('8900')) || list.find((t) => t.type === 'page');
|
||||
if (!page) { console.log('ERR: no page'); process.exit(1); }
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||
let id = 0; const pending = new Map();
|
||||
const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); });
|
||||
ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(m.error.message)) : p.res(m.result); } };
|
||||
await new Promise((r) => (ws.onopen = r));
|
||||
const ev = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 300) }; return r.result.value; };
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
await send('Page.enable'); await send('Runtime.enable'); await send('Network.enable');
|
||||
await send('Network.setCacheDisabled', { cacheDisabled: true });
|
||||
await send('Emulation.setDeviceMetricsOverride', { width: 860, height: 1000, deviceScaleFactor: 1, mobile: false });
|
||||
const waitFor = async (expr, ms = 15000) => { const t0 = Date.now(); while (Date.now() - t0 < ms) { if (await ev(expr)) return true; await sleep(300); } return false; };
|
||||
const fail = (m) => { console.log('FAIL@' + m); process.exit(1); };
|
||||
const fs = await import('node:fs');
|
||||
|
||||
await send('Page.navigate', { url: `http://127.0.0.1:8900/?t=${Date.now()}#/rewrites` });
|
||||
if (!(await waitFor(`location.hash === '#/rewrites' && !!document.querySelector('#view .rw-tabs') && [...document.scripts].some((s) => (s.src || '').includes('data-pages.js?v=20260904o'))`, 15000))) fail('rewrites route');
|
||||
await sleep(600);
|
||||
|
||||
// 打开弹窗:优先 ref tab 行 data-detail,其次切 custom tab 行 data-cview
|
||||
let opened = await ev(`(() => { const b = document.querySelector('[data-detail], [data-cview], [data-rewrite]'); if (!b) return false; b.click(); return true; })()`);
|
||||
if (!opened) {
|
||||
// 尝试切到 custom tab
|
||||
await ev(`[...document.querySelectorAll('#view .rw-tabs [data-tab]')].find((t) => t.textContent.includes('自定义选题'))?.click()`);
|
||||
await sleep(800);
|
||||
opened = await ev(`(() => { const b = document.querySelector('[data-cview]'); if (!b) return false; b.click(); return true; })()`);
|
||||
}
|
||||
if (!opened) { console.log('SKIP: no script row to open'); process.exit(2); }
|
||||
if (!(await waitFor(`!!document.querySelector('.rw-modal')`, 8000))) fail('modal open');
|
||||
await sleep(700);
|
||||
|
||||
const r = await ev(`(() => {
|
||||
const m = document.querySelector('.rw-modal');
|
||||
if (!m) return { err: 'no .rw-modal' };
|
||||
const d = m.querySelector('[data-del-script]');
|
||||
if (!d) return { err: 'no [data-del-script]' };
|
||||
return { text: (d.textContent || '').trim(), hasSvg: !!d.querySelector('svg'), title: d.getAttribute('title'), footHtml: (m.querySelector('.rw-sec-foot')?.textContent || '').trim().slice(0, 40) };
|
||||
})()`);
|
||||
console.log(JSON.stringify(r, null, 2));
|
||||
|
||||
const rect = await ev(`(() => { const el = document.querySelector('[data-del-script]'); const r = el.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; })()`);
|
||||
if (rect && !rect.__err) {
|
||||
const shot = await send('Page.captureScreenshot', { format: 'png', clip: { x: Math.max(0, rect.x - 140), y: Math.max(0, rect.y - 30), width: 300, height: rect.h + 60, scale: 1 } });
|
||||
fs.writeFileSync(`tmp/r64_del_btn.png`, Buffer.from(shot.data, 'base64'));
|
||||
}
|
||||
const ok = r && !r.__err && r.text === '' && r.hasSvg;
|
||||
console.log(ok ? '\n=== PASS ===' : '\n=== CHECK ===');
|
||||
ws.close();
|
||||
process.exit(ok ? 0 : 1);
|
||||
@@ -0,0 +1,81 @@
|
||||
// 09-04 R64 验证:内容定位首板块与头部大标题框间距加大(y 起点 22→34)
|
||||
// 断言:① 头部框(y30 h170)底 → 第一张整宽米色卡顶 gap = 64(旧值 52)② 「内容定位」标题存在 ③ 原 R59 断言保持(白底/gap≥20/不越界/含内容结构)
|
||||
const CDP = 9223;
|
||||
const list = await fetch(`http://localhost:${CDP}/json`).then((r) => r.json());
|
||||
const page = list.find((t) => t.type === 'page' && (t.url || '').includes('8900')) || list.find((t) => t.type === 'page');
|
||||
if (!page) { console.log('ERR: no page'); process.exit(1); }
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||
let id = 0; const pending = new Map();
|
||||
const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); });
|
||||
ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(m.error.message)) : p.res(m.result); } };
|
||||
await new Promise((r) => (ws.onopen = r));
|
||||
const ev = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 300) }; return r.result.value; };
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
await send('Page.enable'); await send('Runtime.enable'); await send('Network.enable');
|
||||
await send('Network.setCacheDisabled', { cacheDisabled: true });
|
||||
await send('Emulation.setDeviceMetricsOverride', { width: 860, height: 1000, deviceScaleFactor: 1, mobile: false });
|
||||
const waitFor = async (expr, ms = 15000) => { const t0 = Date.now(); while (Date.now() - t0 < ms) { if (await ev(expr)) return true; await sleep(300); } return false; };
|
||||
const fail = (m) => { console.log('FAIL@' + m); process.exit(1); };
|
||||
const fs = await import('node:fs');
|
||||
|
||||
const AUDIT = `(() => {
|
||||
const svg = document.querySelector('.vk-view svg');
|
||||
if (!svg) return { err: 'no svg in .vk-view' };
|
||||
const vbH = svg.viewBox.baseVal.height;
|
||||
const rects = [...svg.querySelectorAll('rect')].map((r) => ({ x: Math.round(r.x.baseVal.value * 10) / 10, y: Math.round(r.y.baseVal.value * 10) / 10, w: Math.round(r.width.baseVal.value), h: Math.round(r.height.baseVal.value), fill: r.getAttribute('fill') }));
|
||||
const texts = [...svg.querySelectorAll('text')].map((t) => ({ x: +t.getAttribute('x'), y: +t.getAttribute('y'), size: +t.getAttribute('font-size'), wt: t.getAttribute('font-weight'), txt: (t.textContent || '').slice(0, 42) }));
|
||||
const head = rects.find((r) => r.w === 520 && r.h === 170); /* 头部大标题框 */
|
||||
const headBottom = head ? head.y + head.h : null;
|
||||
const cards = rects.filter((r) => (r.fill || '').startsWith('#F') && r.w > 550 && r.h > 40); /* 整宽内容卡(w>550 排除 520 头部标题框) */
|
||||
const firstCard = cards[0];
|
||||
const titleYs = texts.filter((t) => t.txt.startsWith('▍')).map((t) => ({ t: t.txt.slice(1), y: t.y }));
|
||||
const firstTitle = titleYs[0]; /* 应为「内容定位」 */
|
||||
const audits = cards.map((r) => {
|
||||
const inT = texts.filter((t) => t.y > r.y + 2 && t.y < r.y + r.h && t.x >= r.x - 1 && t.x <= r.x + r.w + 1).sort((a, b) => a.y - b.y);
|
||||
const first = inT[0];
|
||||
return { cardTop: r.y, h: r.h, firstTxt: first ? first.txt : '', gap: first ? Math.round((first.y - r.y) * 10) / 10 : null };
|
||||
});
|
||||
const structCard = cards[1];
|
||||
const cardByTitle = {};
|
||||
cards.forEach((r) => { const t = titleYs.find((x) => x.y < r.y && r.y - x.y < 60); if (t) cardByTitle[t.t] = r; });
|
||||
const secCard = cardByTitle['内容结构'];
|
||||
const secBoard = secCard ? texts.filter((t) => t.y > secCard.y && t.y < secCard.y + secCard.h && (t.wt || '') === '700' && t.x > secCard.x && t.x < secCard.x + 120).map((t) => t.txt) : [];
|
||||
const bgStops = [...svg.querySelectorAll('#bgGrad stop')].map((s) => s.getAttribute('stop-color'));
|
||||
const maxTextY = Math.max(...texts.map((t) => t.y));
|
||||
return { vbH, headBottom, firstCardTop: firstCard ? firstCard.y : null, headGap: (headBottom != null && firstCard) ? Math.round((firstCard.y - headBottom) * 10) / 10 : null, firstTitle: firstTitle ? { t: firstTitle.t, y: firstTitle.y, dt: Math.round((firstTitle.y - headBottom) * 10) / 10 } : null, titles: titleYs.map((x) => x.t), audits, secBoard, bgStops, over: maxTextY > vbH - 4 };
|
||||
})()`;
|
||||
|
||||
const VIDS = [93, 190];
|
||||
const out = {};
|
||||
for (const id of VIDS) {
|
||||
await send('Page.navigate', { url: `http://127.0.0.1:8900/?t=${Date.now()}#/video/${id}` });
|
||||
if (!(await waitFor(`location.hash === '#/video/${id}' && !!document.querySelector('#view .video-grid') && [...document.scripts].some((s) => (s.src || '').includes('data-pages.js?v=20260904m'))`, 15000))) fail(`v${id} route`);
|
||||
await sleep(400);
|
||||
await ev(`[...document.querySelectorAll('#view .tabs .tab')].find((t) => t.textContent.trim() === '视频拆解').click()`);
|
||||
const hasCard = await waitFor(`!!document.querySelector('#view .vk-view svg')`, 12000);
|
||||
if (!hasCard) { out['v' + id] = { __err: 'no keycard svg' }; continue; }
|
||||
await sleep(500);
|
||||
const audit = await ev(AUDIT);
|
||||
out['v' + id] = audit;
|
||||
await ev(`document.querySelector('.vk-view svg').scrollIntoView({ block: 'start' })`); await sleep(250);
|
||||
const rect = await ev(`(() => { const r = document.querySelector('.vk-view svg').getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; })()`);
|
||||
if (rect && !rect.__err && rect.w > 100) {
|
||||
const shot = await send('Page.captureScreenshot', { format: 'png', clip: { x: Math.max(0, rect.x - 4), y: Math.max(0, rect.y - 4), width: Math.min(rect.w + 8, 1200), height: Math.min(rect.h + 8, 4000), scale: 1 } });
|
||||
fs.writeFileSync(`tmp/r64_gap_v${id}.png`, Buffer.from(shot.data, 'base64'));
|
||||
out['v' + id].shot = `tmp/r64_gap_v${id}.png`;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(out, null, 1));
|
||||
const v93 = out.v93, v190 = out.v190;
|
||||
const ok = v93 && !v93.__err && v190 && !v190.__err
|
||||
&& v93.headGap === 64 && v190.headGap === 64
|
||||
&& v93.firstTitle && v93.firstTitle.t === '内容定位' && v190.firstTitle && v190.firstTitle.t === '内容定位'
|
||||
&& v93.bgStops && v93.bgStops.every((c) => c === '#FFFFFF') && v190.bgStops && v190.bgStops.every((c) => c === '#FFFFFF')
|
||||
&& v93.titles.includes('内容结构') && v190.titles.includes('内容结构')
|
||||
&& v93.secBoard && v93.secBoard.some((b) => b.includes('开场钩子')) && v190.secBoard && v190.secBoard.some((b) => b.includes('开场钩子'))
|
||||
&& v93.audits.every((a) => a.gap >= 20) && v190.audits.every((a) => a.gap >= 20)
|
||||
&& !v93.over && !v190.over;
|
||||
console.log(ok ? '\n=== PASS ===' : '\n=== CHECK ===');
|
||||
ws.close();
|
||||
process.exit(ok ? 0 : 1);
|
||||
@@ -0,0 +1,61 @@
|
||||
// 09-04 R65 验证:视频解析任务「执行中」状态跨刷新保持 + 终态自动恢复
|
||||
// 模拟:先写入 wk_video_task_93(不存在的 taskId)→ 打开视频详情 → 应显示「执行中」
|
||||
// → watch 轮询 /api/run/status → notfound → 清理 localStorage + load 恢复可点按钮
|
||||
const CDP = 9223;
|
||||
const list = await fetch(`http://localhost:${CDP}/json`).then((r) => r.json());
|
||||
const page = list.find((t) => t.type === 'page' && (t.url || '').includes('8900')) || list.find((t) => t.type === 'page');
|
||||
if (!page) { console.log('ERR: no page'); process.exit(1); }
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||
let id = 0; const pending = new Map();
|
||||
const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); });
|
||||
ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(m.error.message)) : p.res(m.result); } };
|
||||
await new Promise((r) => (ws.onopen = r));
|
||||
const ev = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 300) }; return r.result.value; };
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
await send('Page.enable'); await send('Runtime.enable'); await send('Network.enable');
|
||||
await send('Network.setCacheDisabled', { cacheDisabled: true });
|
||||
await send('Emulation.setDeviceMetricsOverride', { width: 860, height: 1000, deviceScaleFactor: 1, mobile: false });
|
||||
const waitFor = async (expr, ms = 15000) => { const t0 = Date.now(); while (Date.now() - t0 < ms) { if (await ev(expr)) return true; await sleep(300); } return false; };
|
||||
const fail = (m) => { console.log('FAIL@' + m); process.exit(1); };
|
||||
const fs = await import('node:fs');
|
||||
|
||||
// 1) 注入伪造执行中记录后打开视频 93
|
||||
await ev(`localStorage.setItem('wk_video_task_93', JSON.stringify({ taskId: 'nonexist-test-1', ts: Date.now() }))`);
|
||||
await send('Page.navigate', { url: `http://127.0.0.1:8900/?t=${Date.now()}#/video/93` });
|
||||
if (!(await waitFor(`location.hash === '#/video/93' && !!document.querySelector('#view .video-grid') && [...document.scripts].some((s) => (s.src || '').includes('data-pages.js?v=20260904p'))`, 15000))) fail('v93 route');
|
||||
await sleep(600);
|
||||
|
||||
// 2) 断言:出现「执行中」禁用按钮
|
||||
const running1 = await ev(`(() => {
|
||||
const b = document.querySelector('.tab-tools .btn-busy, .empty-state .btn-busy');
|
||||
const rec = localStorage.getItem('wk_video_task_93');
|
||||
return { busy: !!b, txt: b ? (b.textContent || '').trim() : null, disabled: b ? b.disabled : null, recKept: !!rec, v93Running: location.hash };
|
||||
})()`);
|
||||
console.log('STEP1 running state:', JSON.stringify(running1));
|
||||
|
||||
// 3) 等 watch 轮询(间隔 5s)走到 notfound → 清理 + load 恢复
|
||||
const recovered = await waitFor(`!localStorage.getItem('wk_video_task_93') && !!document.querySelector('[data-parse]') && !document.querySelector('.tab-tools .btn-busy, .empty-state .btn-busy')`, 25000);
|
||||
console.log('STEP2 recovered:', recovered);
|
||||
const state2 = await ev(`(() => {
|
||||
const b = document.querySelector('[data-parse]');
|
||||
return { parseBtn: b ? (b.textContent || '').trim() : null, disabled: b ? b.disabled : null, rec: localStorage.getItem('wk_video_task_93') };
|
||||
})()`);
|
||||
console.log('STEP2 final:', JSON.stringify(state2));
|
||||
|
||||
// 4) 清理测试残留并截图执行中形态(重注入后快速抓拍)
|
||||
await ev(`localStorage.setItem('wk_video_task_93', JSON.stringify({ taskId: 'nonexist-test-2', ts: Date.now() }))`);
|
||||
await send('Page.navigate', { url: `http://127.0.0.1:8900/?t=${Date.now()}#/video/93` });
|
||||
await waitFor(`!!document.querySelector('.tab-tools .btn-busy, .empty-state .btn-busy')`, 10000);
|
||||
await sleep(400);
|
||||
const rect = await ev(`(() => { const el = document.querySelector('.tab-tools .btn-busy, .empty-state .btn-busy'); if (!el) return null; const r = el.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; })()`);
|
||||
if (rect) {
|
||||
const shot = await send('Page.captureScreenshot', { format: 'png', clip: { x: Math.max(0, rect.x - 160), y: Math.max(0, rect.y - 12), width: rect.w + 300, height: rect.h + 24, scale: 1 } });
|
||||
fs.writeFileSync('tmp/r65_running_btn.png', Buffer.from(shot.data, 'base64'));
|
||||
}
|
||||
// 等 notfound 清理
|
||||
await waitFor(`!localStorage.getItem('wk_video_task_93')`, 20000);
|
||||
|
||||
const ok = running1 && running1.busy && running1.txt && running1.txt.includes('执行中') && running1.disabled === true && recovered && state2 && state2.rec === null && state2.parseBtn;
|
||||
console.log(ok ? '\n=== PASS ===' : '\n=== CHECK ===');
|
||||
ws.close();
|
||||
process.exit(ok ? 0 : 1);
|
||||
@@ -0,0 +1,37 @@
|
||||
// R67 冒烟:新版本资源加载 + MYAI_CONNECTORS 可达 + 无 console error
|
||||
const CDP = 9223;
|
||||
const list = await fetch(`http://localhost:${CDP}/json`).then((r) => r.json());
|
||||
const page = list.find((t) => t.type === 'page' && (t.url || '').includes('8900')) || list.find((t) => t.type === 'page');
|
||||
if (!page) { console.log('ERR: no page'); process.exit(1); }
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||
let id = 0; const pending = new Map();
|
||||
const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); });
|
||||
ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(m.error.message)) : p.res(m.result); } };
|
||||
await new Promise((r) => (ws.onopen = r));
|
||||
const ev = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 200) }; return r.result.value; };
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
await send('Page.enable'); await send('Runtime.enable'); await send('Network.enable');
|
||||
await send('Network.setCacheDisabled', { cacheDisabled: true });
|
||||
await send('Emulation.setDeviceMetricsOverride', { width: 860, height: 1000, deviceScaleFactor: 1, mobile: false });
|
||||
const waitFor = async (expr, ms = 15000) => { const t0 = Date.now(); while (Date.now() - t0 < ms) { if (await ev(expr)) return true; await sleep(300); } return false; };
|
||||
|
||||
const errors = [];
|
||||
const onExc = (e) => { try { const m = JSON.parse(e.data); if (m.method === 'Runtime.exceptionThrown') errors.push((m.params.exceptionDetails?.exception?.description || m.params.exceptionDetails?.text || '').slice(0, 200)); } catch (x) {} };
|
||||
ws.onmessage = ((orig) => (e) => { onExc(e); orig(e); })(ws.onmessage);
|
||||
|
||||
await send('Page.navigate', { url: `http://127.0.0.1:8900/?t=${Date.now()}#/accounts` });
|
||||
if (!(await waitFor(`location.hash === '#/accounts' && !!document.querySelector('#accUpdate') && [...document.scripts].some((s) => (s.src || '').includes('app.js?v=20260904i')) && [...document.scripts].some((s) => (s.src || '').includes('data-pages.js?v=20260904r'))`, 15000))) { console.log('FAIL: version resources not loaded'); process.exit(1); }
|
||||
await sleep(600);
|
||||
const r = await ev(`(() => {
|
||||
// 常量经全局词法环境可达(跨 script);模拟一次 executeTask 入参构造不抛错
|
||||
let connOk = false, fnOk = false;
|
||||
try { connOk = (typeof MYAI_CONNECTORS !== 'undefined') && Array.isArray(MYAI_CONNECTORS) && MYAI_CONNECTORS[0] === 'custom-mcp:myai-mcp-production'; } catch (e) {}
|
||||
try { fnOk = typeof executeTask === 'function' && typeof confirmVideoParse === 'function'; } catch (e) {}
|
||||
return { connOk, fnOk, title: document.title, hasUpdate: !!document.querySelector('#accUpdate') };
|
||||
})()`);
|
||||
console.log(JSON.stringify(r, null, 2));
|
||||
console.log('console/page errors:', errors.length ? JSON.stringify(errors.slice(0, 3)) : 'none');
|
||||
const ok = r && !r.__err && r.connOk && r.fnOk && errors.length === 0;
|
||||
console.log(ok ? '\n=== PASS ===' : '\n=== CHECK ===');
|
||||
ws.close();
|
||||
process.exit(ok ? 0 : 1);
|
||||
@@ -0,0 +1,62 @@
|
||||
// R68 验证:账号信息 / 视频信息属性内容宽度 180px
|
||||
const CDP = 9223;
|
||||
const list = await fetch(`http://localhost:${CDP}/json`).then((r) => r.json());
|
||||
const page = list.find((t) => t.type === 'page' && (t.url || '').includes('8900')) || list.find((t) => t.type === 'page');
|
||||
if (!page) { console.log('ERR: no page'); process.exit(1); }
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||
let id = 0; const pending = new Map();
|
||||
const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); });
|
||||
ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(m.error.message)) : p.res(m.result); } };
|
||||
await new Promise((r) => (ws.onopen = r));
|
||||
const ev = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 200) }; return r.result.value; };
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
await send('Page.enable'); await send('Runtime.enable'); await send('Network.enable');
|
||||
await send('Network.setCacheDisabled', { cacheDisabled: true });
|
||||
await send('Emulation.setDeviceMetricsOverride', { width: 900, height: 1100, deviceScaleFactor: 1, mobile: false });
|
||||
const waitFor = async (expr, ms = 15000) => { const t0 = Date.now(); while (Date.now() - t0 < ms) { if (await ev(expr)) return true; await sleep(300); } return false; };
|
||||
const fs = await import('node:fs');
|
||||
|
||||
const out = {};
|
||||
// 1) 视频详情
|
||||
await send('Page.navigate', { url: `http://127.0.0.1:8900/?t=${Date.now()}#/video/93` });
|
||||
if (!(await waitFor(`location.hash === '#/video/93' && !!document.querySelector('#view .video-grid') && [...document.styleSheets].some((s) => (s.href || '').includes('style.css?v=20260904i'))`, 15000))) { console.log('FAIL video route'); process.exit(1); }
|
||||
await sleep(500);
|
||||
const vw = await ev(`(() => {
|
||||
const items = [...document.querySelectorAll('.video-info .video-grid .info-item:not(.info-wide)')];
|
||||
if (!items.length) return { err: 'no items' };
|
||||
const widths = items.slice(0, 4).map((el) => Math.round(el.getBoundingClientRect().width));
|
||||
const body = document.querySelector('.detail-body') || document.querySelector('main');
|
||||
const over = items.some((el) => el.getBoundingClientRect().right > (body?.getBoundingClientRect().right || 9999));
|
||||
return { widths, over, n: items.length };
|
||||
})()`);
|
||||
out.video = vw;
|
||||
|
||||
// 2) 账号详情(取第一个账号)
|
||||
const accId = await ev(`fetch('/api/dsh/accounts?page=1&pageSize=1').then(r=>r.json()).then(d => d.items && d.items[0] ? d.items[0].id : null).catch(()=>null)`);
|
||||
if (accId) {
|
||||
await send('Page.navigate', { url: `http://127.0.0.1:8900/?t=${Date.now()}#/account/${accId}` });
|
||||
if (!(await waitFor(`location.hash === '#/account/${accId}' && !!document.querySelector('.account-info .account-grid')`, 15000))) { console.log('FAIL account route'); process.exit(1); }
|
||||
await sleep(500);
|
||||
const aw = await ev(`(() => {
|
||||
const items = [...document.querySelectorAll('.account-info .account-grid .info-item')];
|
||||
if (!items.length) return { err: 'no items' };
|
||||
const widths = items.slice(0, 4).map((el) => Math.round(el.getBoundingClientRect().width));
|
||||
const body = document.querySelector('.detail-body') || document.querySelector('main');
|
||||
const over = items.some((el) => el.getBoundingClientRect().right > (body?.getBoundingClientRect().right || 9999));
|
||||
return { widths, over, n: items.length, acc: '#' + location.hash };
|
||||
})()`);
|
||||
out.account = aw;
|
||||
// 截图账号信息卡
|
||||
const rect = await ev(`(() => { const el = document.querySelector('.account-info'); if (!el) return null; const r = el.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; })()`);
|
||||
if (rect) {
|
||||
const shot = await send('Page.captureScreenshot', { format: 'png', clip: { x: Math.max(0, rect.x - 6), y: Math.max(0, rect.y - 6), width: Math.min(rect.w + 12, 1200), height: Math.min(rect.h + 12, 900), scale: 1 } });
|
||||
fs.writeFileSync('tmp/r68_account_info.png', Buffer.from(shot.data, 'base64'));
|
||||
}
|
||||
} else { out.account = { err: 'no account in db' }; }
|
||||
|
||||
console.log(JSON.stringify(out, null, 1));
|
||||
const ok = out.video && !out.video.err && out.video.widths.length && out.video.widths.every((w) => w === 180) && !out.video.over
|
||||
&& out.account && !out.account.err && out.account.widths.length && out.account.widths.every((w) => w === 180) && !out.account.over;
|
||||
console.log(ok ? '\n=== PASS ===' : '\n=== CHECK ===');
|
||||
ws.close();
|
||||
process.exit(ok ? 0 : 1);
|
||||
@@ -0,0 +1,70 @@
|
||||
// R68b 验证:视频关键信息卡 板块标题→内容卡间距 30→22(标题与卡更靠近),原断言保持
|
||||
const CDP = 9223;
|
||||
const list = await fetch(`http://localhost:${CDP}/json`).then((r) => r.json());
|
||||
const page = list.find((t) => t.type === 'page' && (t.url || '').includes('8900')) || list.find((t) => t.type === 'page');
|
||||
if (!page) { console.log('ERR: no page'); process.exit(1); }
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||
let id = 0; const pending = new Map();
|
||||
const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); });
|
||||
ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(m.error.message)) : p.res(m.result); } };
|
||||
await new Promise((r) => (ws.onopen = r));
|
||||
const ev = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 200) }; return r.result.value; };
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
await send('Page.enable'); await send('Runtime.enable'); await send('Network.enable');
|
||||
await send('Network.setCacheDisabled', { cacheDisabled: true });
|
||||
await send('Emulation.setDeviceMetricsOverride', { width: 860, height: 1000, deviceScaleFactor: 1, mobile: false });
|
||||
const waitFor = async (expr, ms = 15000) => { const t0 = Date.now(); while (Date.now() - t0 < ms) { if (await ev(expr)) return true; await sleep(300); } return false; };
|
||||
const fail = (m) => { console.log('FAIL@' + m); process.exit(1); };
|
||||
const fs = await import('node:fs');
|
||||
|
||||
const AUDIT = `(() => {
|
||||
const svg = document.querySelector('.vk-view svg');
|
||||
if (!svg) return { err: 'no svg' };
|
||||
const vbH = svg.viewBox.baseVal.height;
|
||||
const rects = [...svg.querySelectorAll('rect')].map((r) => ({ x: Math.round(r.x.baseVal.value * 10) / 10, y: Math.round(r.y.baseVal.value * 10) / 10, w: Math.round(r.width.baseVal.value), h: Math.round(r.height.baseVal.value), fill: r.getAttribute('fill') }));
|
||||
const texts = [...svg.querySelectorAll('text')].map((t) => ({ x: +t.getAttribute('x'), y: +t.getAttribute('y'), size: +t.getAttribute('font-size'), wt: t.getAttribute('font-weight'), txt: (t.textContent || '').slice(0, 42) }));
|
||||
const cards = rects.filter((r) => (r.fill || '').startsWith('#F') && r.w > 550 && r.h > 40);
|
||||
const titleYs = texts.filter((t) => t.txt.startsWith('▍')).map((t) => ({ t: t.txt.slice(1), y: t.y }));
|
||||
const cardByTitle = {};
|
||||
cards.forEach((r) => { const t = titleYs.find((x) => x.y < r.y && r.y - x.y < 60); if (t) cardByTitle[t.t] = r; });
|
||||
const gaps = Object.entries(cardByTitle).map(([t, r]) => ({ t, gap: Math.round((r.y - titleYs.find((x) => x.t === t).y) * 10) / 10 }));
|
||||
const audits = cards.map((r) => {
|
||||
const inT = texts.filter((t) => t.y > r.y + 2 && t.y < r.y + r.h && t.x >= r.x - 1 && t.x <= r.x + r.w + 1).sort((a, b) => a.y - b.y);
|
||||
const first = inT[0];
|
||||
return { firstTxt: first ? first.txt : '', gap: first ? Math.round((first.y - r.y) * 10) / 10 : null };
|
||||
});
|
||||
const secCard = cardByTitle['内容结构'];
|
||||
const secBoard = secCard ? texts.filter((t) => t.y > secCard.y && t.y < secCard.y + secCard.h && (t.wt || '') === '700' && t.x > secCard.x && t.x < secCard.x + 120).map((t) => t.txt) : [];
|
||||
const bgStops = [...svg.querySelectorAll('#bgGrad stop')].map((s) => s.getAttribute('stop-color'));
|
||||
const maxTextY = Math.max(...texts.map((t) => t.y));
|
||||
return { vbH, gaps, audits, secBoard, bgStops, over: maxTextY > vbH - 4 };
|
||||
})()`;
|
||||
|
||||
const VIDS = [93, 190];
|
||||
const out = {};
|
||||
for (const id of VIDS) {
|
||||
await send('Page.navigate', { url: `http://127.0.0.1:8900/?t=${Date.now()}#/video/${id}` });
|
||||
if (!(await waitFor(`location.hash === '#/video/${id}' && !!document.querySelector('#view .video-grid') && [...document.scripts].some((s) => (s.src || '').includes('data-pages.js?v=20260904s'))`, 15000))) fail(`v${id} route`);
|
||||
await sleep(400);
|
||||
await ev(`[...document.querySelectorAll('#view .tabs .tab')].find((t) => t.textContent.trim() === '视频拆解').click()`);
|
||||
if (!(await waitFor(`!!document.querySelector('#view .vk-view svg')`, 12000))) { out['v' + id] = { __err: 'no svg' }; continue; }
|
||||
await sleep(500);
|
||||
out['v' + id] = await ev(AUDIT);
|
||||
await ev(`document.querySelector('.vk-view svg').scrollIntoView({ block: 'start' })`); await sleep(250);
|
||||
const rect = await ev(`(() => { const r = document.querySelector('.vk-view svg').getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; })()`);
|
||||
if (rect && !rect.__err) {
|
||||
const shot = await send('Page.captureScreenshot', { format: 'png', clip: { x: Math.max(0, rect.x - 4), y: Math.max(0, rect.y - 4), width: Math.min(rect.w + 8, 1200), height: Math.min(rect.h + 8, 2600), scale: 1 } });
|
||||
fs.writeFileSync(`tmp/r68b_v${id}.png`, Buffer.from(shot.data, 'base64'));
|
||||
}
|
||||
}
|
||||
console.log(JSON.stringify(out, null, 1));
|
||||
const v93 = out.v93, v190 = out.v190;
|
||||
const ok = v93 && !v93.__err && v190 && !v190.__err
|
||||
&& v93.gaps.length && v93.gaps.every((g) => g.gap === 22) && v190.gaps.length && v190.gaps.every((g) => g.gap === 22)
|
||||
&& v93.secBoard.some((b) => b.includes('开场钩子')) && v190.secBoard.some((b) => b.includes('开场钩子'))
|
||||
&& v93.bgStops.every((c) => c === '#FFFFFF') && v190.bgStops.every((c) => c === '#FFFFFF')
|
||||
&& v93.audits.every((a) => a.gap >= 20) && v190.audits.every((a) => a.gap >= 20)
|
||||
&& !v93.over && !v190.over;
|
||||
console.log(ok ? '\n=== PASS ===' : '\n=== CHECK ===');
|
||||
ws.close();
|
||||
process.exit(ok ? 0 : 1);
|
||||
@@ -0,0 +1,140 @@
|
||||
// R69 验证:三类「刷新」任务的 prompt 均注入「强制重新获取并覆盖」语义
|
||||
// ①视频详情 刷新 → prompt 含 VIDEO_REFRESH_OVERRIDE 覆盖刷新段(禁判断/禁复用旧结果/防截断覆盖)
|
||||
// ②榜单 冷却窗口内「仍要更新」(force) → prompt 追加 覆盖刷新段(忽略幂等跳过)
|
||||
// ③榜单 常规「确认更新」(非 force) → prompt 不含 force 覆盖段(幂等铁律保留)
|
||||
// 实现:页面内拦截 fetch——捕获 POST /api/run 与 /api/dsh/ranking-update 的 body,伪造响应使流程走完但不真建任务
|
||||
const CDP = 9223;
|
||||
const list = await fetch(`http://localhost:${CDP}/json`).then((r) => r.json());
|
||||
const page = list.find((t) => t.type === 'page' && (t.url || '').includes('8900')) || list.find((t) => t.type === 'page');
|
||||
if (!page) { console.log('ERR: no page'); process.exit(1); }
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||
let id = 0; const pending = new Map();
|
||||
const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); });
|
||||
ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(m.error.message)) : p.res(m.result); } };
|
||||
await new Promise((r) => (ws.onopen = r));
|
||||
const ev = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 300) }; return r.result.value; };
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
await send('Page.enable'); await send('Runtime.enable'); await send('Network.enable');
|
||||
await send('Network.setCacheDisabled', { cacheDisabled: true });
|
||||
await send('Emulation.setDeviceMetricsOverride', { width: 900, height: 1100, deviceScaleFactor: 1, mobile: false });
|
||||
const waitFor = async (expr, ms = 15000) => { const t0 = Date.now(); while (Date.now() - t0 < ms) { if (await ev(expr)) return true; await sleep(300); } return false; };
|
||||
const fail = (m) => { console.log('FAIL@' + m); process.exit(1); };
|
||||
|
||||
const V = '20260904t';
|
||||
const loaded = `[...document.scripts].some((s) => (s.src || '').includes('data-pages.js?v=${V}')) && typeof VIDEO_REFRESH_OVERRIDE === 'string'`;
|
||||
|
||||
// ---- 注入 fetch 拦截:捕获 run/ranking-update 请求体,伪造响应(不真建任务)----
|
||||
const INJECT = `(() => {
|
||||
window.__r69 = { runs: [], rankPost: [], videoPrompt: null, rankGetMock: 1 };
|
||||
const realFetch = window.fetch.bind(window);
|
||||
const json = (obj, ok = true) => ({ ok, json: async () => obj });
|
||||
window.fetch = async (url, opts) => {
|
||||
const u = String(url); const m = (opts && opts.method) || 'GET';
|
||||
if (u.includes('/api/run/status')) return json({ ok: true, id: 'probe', state: 'notfound' });
|
||||
if (u.endsWith('/api/run') && m.toUpperCase() === 'POST') {
|
||||
let body = ''; try { body = JSON.parse((opts.body) || '{}'); } catch (e) {}
|
||||
if (body && body.prompt) window.__r69.videoPrompt = body.prompt;
|
||||
if (body && body.name && String(body.name).startsWith('更新账号·')) window.__r69.accPrompt = body.prompt;
|
||||
window.__r69.runs.push({ name: body.name || '', skills: body.skills || [], connectors: body.connectorIds || null, prompt: (body.prompt || '').slice(0, 2000) });
|
||||
return json({ ok: true, id: 'probe-run' });
|
||||
}
|
||||
if (u.endsWith('/api/dsh/ranking-update') && m.toUpperCase() === 'GET') {
|
||||
const recent = window.__r69.rankGetMock === 1;
|
||||
return json({ ok: true, exists: recent, files: [], lastUpdateAt: recent ? Date.now() - 60000 : null, lastUpdateDate: recent ? '2026-09-04' : '', lastUpdateAgeMin: recent ? 1 : null, cooldownMin: 30, task: null });
|
||||
}
|
||||
if (u.endsWith('/api/dsh/ranking-update') && m.toUpperCase() === 'POST') {
|
||||
let body = {}; try { body = JSON.parse((opts.body) || '{}'); } catch (e) {}
|
||||
window.__r69.rankPost.push({ force: !!body.force, prompt: (body.prompt || '').slice(0, 2000) });
|
||||
return json({ ok: true, id: 'probe-rank' });
|
||||
}
|
||||
return realFetch(url, opts);
|
||||
};
|
||||
return 'injected';
|
||||
})()`;
|
||||
|
||||
// ---- ① 视频详情 刷新 prompt ----
|
||||
await send('Page.navigate', { url: `http://127.0.0.1:8900/?t=${Date.now()}#/video/93` });
|
||||
if (!(await waitFor(`location.hash === '#/video/93' && !!document.querySelector('#view .video-grid') && ${loaded}`, 15000))) fail('video93 route+load');
|
||||
await sleep(300);
|
||||
await ev(`localStorage.removeItem('wk_video_task_93')`); // 清残留执行中记录,确保刷新按钮可点
|
||||
await ev(INJECT);
|
||||
await ev(`[...document.querySelectorAll('#view .tabs .tab')].find((t) => t.textContent.trim() === '视频拆解').click()`);
|
||||
if (!(await waitFor(`!!document.querySelector('#view [data-parse="refresh"]')`, 10000))) fail('v93 refresh btn');
|
||||
await ev(`document.querySelector('#view [data-parse="refresh"]').click()`);
|
||||
if (!(await waitFor(`!!document.querySelector('.modal-mask [data-ok]')`, 8000))) fail('refresh modal');
|
||||
await ev(`document.querySelector('.modal-mask [data-ok]').click()`);
|
||||
if (!(await waitFor(`!!window.__r69.videoPrompt`, 8000))) fail('video prompt captured');
|
||||
const vp = await ev(`window.__r69.videoPrompt`);
|
||||
const vOk = typeof vp === 'string' && vp.includes('【覆盖刷新模式(用户点击「刷新」触发,非首次解析)】')
|
||||
&& vp.includes('强制重新获取数据并覆盖') && vp.includes('禁止判断「是否需更新」')
|
||||
&& vp.includes('严禁用不完整/截断结果覆盖旧的完整版本') && vp.includes('POST /api/import/account')
|
||||
&& vp.includes('mcn-dou-analysis');
|
||||
console.log('[1] video refresh prompt = SKILL_HINT + override:', vOk ? 'PASS' : 'FAIL');
|
||||
if (!vOk) { console.log(String(vp).slice(0, 900)); process.exit(1); }
|
||||
// 同时验证 connectors 挂载未回退
|
||||
const vc = await ev(`window.__r69.runs[0] ? JSON.stringify(window.__r69.runs[0].connectors) : 'none'`);
|
||||
console.log('[1b] video refresh connectors:', vc === '["custom-mcp:myai-mcp-production"]' ? 'PASS' : 'FAIL ' + vc);
|
||||
|
||||
// ---- ② 榜单更新 prompt:force(冷却窗口内「仍要更新」) ----
|
||||
await send('Page.navigate', { url: `http://127.0.0.1:8900/?t=${Date.now()}#/ranking` });
|
||||
if (!(await waitFor(`location.hash.startsWith('#/ranking') && ${loaded}`, 15000))) fail('ranking route');
|
||||
await sleep(400);
|
||||
await ev(INJECT); // 重置捕获(rankGetMock 默认 1 = 近期已更新 → 冷却 → force 分支)
|
||||
await ev(`(() => { const b = document.querySelector('#rankUpdate'); if (!b) return 'no-btn'; b.click(); return 'ok'; })()`);
|
||||
if (!(await waitFor(`!!document.querySelector('.modal-mask [data-ok]')`, 8000))) fail('rank modal');
|
||||
const okTxt1 = await ev(`(document.querySelector('.modal-mask [data-ok]') || {}).textContent`);
|
||||
if (!String(okTxt1).includes('仍要更新')) { console.log('FAIL: expected force btn (仍要更新), got', JSON.stringify(okTxt1)); process.exit(1); }
|
||||
console.log('[2] cooldown → 仍要更新 (force):', 'PASS');
|
||||
await ev(`document.querySelector('.modal-mask [data-ok]').click()`);
|
||||
if (!(await waitFor(`window.__r69.rankPost.length >= 1`, 8000))) fail('rank POST captured');
|
||||
await sleep(200);
|
||||
const rp1 = await ev(`window.__r69.rankPost[0]`);
|
||||
const fOk = rp1 && rp1.force === true && typeof rp1.prompt === 'string'
|
||||
&& rp1.prompt.includes('【覆盖刷新模式(09-04 R69:用户已确认强制刷新)】')
|
||||
&& rp1.prompt.includes('重新调用 API 抓取最新数据并覆盖写入同名文件');
|
||||
console.log('[3] force refresh prompt override:', fOk ? 'PASS' : 'FAIL');
|
||||
if (!fOk) { console.log(JSON.stringify(rp1)); process.exit(1); }
|
||||
|
||||
// ---- ③ 榜单更新 prompt:非 force(无近期落盘 → 「确认更新」)----
|
||||
await ev(`window.__r69.rankGetMock = 0; window.__r69.rankPost = [];`); // 下次 GET=无近期落盘 → 非冷却 → 普通更新
|
||||
// ② 完成触发 router() 整页重建:重试点击直到弹窗出现(新 #rankUpdate 渲染绑定完成)
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < 12000) {
|
||||
const r = await ev(`(() => { const b = document.querySelector('#rankUpdate'); if (!b) return 'no-btn'; b.click(); return 'ok'; })()`);
|
||||
if (await ev(`!!document.querySelector('.modal-mask [data-ok]')`)) break;
|
||||
await sleep(400);
|
||||
}
|
||||
if (!(await ev(`!!document.querySelector('.modal-mask [data-ok]')`))) fail('rank modal2');
|
||||
const okTxt2 = await ev(`(document.querySelector('.modal-mask [data-ok]') || {}).textContent`);
|
||||
if (!String(okTxt2).includes('确认更新')) { console.log('FAIL: expected 确认更新, got', JSON.stringify(okTxt2)); process.exit(1); }
|
||||
await ev(`document.querySelector('.modal-mask [data-ok]').click()`);
|
||||
if (!(await waitFor(`window.__r69.rankPost.length >= 1`, 8000))) fail('rank normal POST captured');
|
||||
await sleep(200);
|
||||
const rp2 = await ev(`window.__r69.rankPost[0]`);
|
||||
const nOk = rp2 && rp2.force === false && typeof rp2.prompt === 'string'
|
||||
&& !rp2.prompt.includes('用户已确认强制刷新') && rp2.prompt.includes('查询幂等铁律');
|
||||
console.log('[4] normal refresh keeps idempotency (no force override):', nOk ? 'PASS' : 'FAIL');
|
||||
if (!nOk) { console.log(JSON.stringify(rp2)); process.exit(1); }
|
||||
|
||||
// ---- ④ 账号列表「更新关注账号」 prompt:含 覆盖刷新模式 ----
|
||||
await send('Page.navigate', { url: `http://127.0.0.1:8900/?t=${Date.now()}#/accounts` });
|
||||
if (!(await waitFor(`location.hash.startsWith('#/accounts') && !!document.querySelector('#view #accUpdate') && ${loaded}`, 15000))) fail('accounts route');
|
||||
await sleep(400);
|
||||
await ev(INJECT); // 重置捕获(accPrompt 按 name 前缀 更新账号· 捕获)
|
||||
await ev(`document.querySelector('#view #accUpdate').click()`);
|
||||
if (!(await waitFor(`!!document.querySelector('.modal-mask h3') && (document.querySelector('.modal-mask h3').textContent || '').includes('更新关注账号')`, 8000))) fail('acc modal');
|
||||
await ev(`document.querySelector('.modal-mask [data-ok]').click()`);
|
||||
if (!(await waitFor(`!!window.__r69.accPrompt`, 10000))) fail('acc prompt captured');
|
||||
const ap = await ev(`window.__r69.accPrompt`);
|
||||
const aOk = typeof ap === 'string' && ap.includes('【覆盖刷新模式(09-04 R69)】')
|
||||
&& ap.includes('强制重新抓取并覆盖') && ap.includes('不要因库中已有该账号记录')
|
||||
&& ap.includes('覆盖更新该账号登记数据与视频列表记录');
|
||||
console.log('[5] account refresh prompt override:', aOk ? 'PASS' : 'FAIL');
|
||||
if (!aOk) { console.log(String(ap).slice(0, 900)); process.exit(1); }
|
||||
|
||||
console.log('\n=== PASS ===');
|
||||
// 清理:整页重载丢弃 fetch 拦截(页面若留覆盖会 mock 掉真实任务提交),回到首页
|
||||
await send('Page.navigate', { url: `http://127.0.0.1:8900/?t=${Date.now()}` });
|
||||
await waitFor(`location.hash === '' || location.hash === '#/'`, 8000);
|
||||
ws.close();
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,58 @@
|
||||
// R70 验证:AI写脚本页「自定义选题创作」→「原创选题创作」+ 空态句末「→ 去账号列表」点击跳转 #/accounts
|
||||
const CDP = 9223;
|
||||
const list = await fetch(`http://localhost:${CDP}/json`).then((r) => r.json());
|
||||
const page = list.find((t) => t.type === 'page' && (t.url || '').includes('8900')) || list.find((t) => t.type === 'page');
|
||||
if (!page) { console.log('ERR: no page'); process.exit(1); }
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||
let id = 0; const pending = new Map();
|
||||
const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); });
|
||||
ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(m.error.message)) : p.res(m.result); } };
|
||||
await new Promise((r) => (ws.onopen = r));
|
||||
const ev = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 300) }; return r.result.value; };
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
await send('Page.enable'); await send('Runtime.enable'); await send('Network.enable');
|
||||
await send('Network.setCacheDisabled', { cacheDisabled: true });
|
||||
const waitFor = async (expr, ms = 15000) => { const t0 = Date.now(); while (Date.now() - t0 < ms) { if (await ev(expr)) return true; await sleep(300); } return false; };
|
||||
const fail = (m) => { console.log('FAIL@' + m); process.exit(1); };
|
||||
|
||||
await send('Page.navigate', { url: `http://127.0.0.1:8900/?t=${Date.now()}#/rewrites` });
|
||||
if (!(await waitFor(`location.hash === '#/rewrites' && !!document.querySelector('#view .rw-tabs') && [...document.scripts].some((s) => (s.src || '').includes('data-pages.js?v=20260904u'))`, 15000))) fail('rewrites route+load');
|
||||
await sleep(400);
|
||||
|
||||
// ① tab 显示名
|
||||
const tabNames = await ev(`[...document.querySelectorAll('#view .rw-tabs [data-tab]')].map((b) => b.textContent.replace(/\\d+$/, '').trim())`);
|
||||
console.log('[1] tabs:', JSON.stringify(tabNames));
|
||||
if (!tabNames.some((t) => t === '原创选题创作')) { console.log('FAIL: tab 未改名'); process.exit(1); }
|
||||
if (tabNames.some((t) => t.includes('自定义选题'))) { console.log('FAIL: 旧名残留'); process.exit(1); }
|
||||
console.log('[1] tab 改名 原创选题创作: PASS');
|
||||
|
||||
// ② 切到 custom tab 看空态 + 箭头
|
||||
await ev(`document.querySelector('#view .rw-tabs [data-tab="custom"]').click()`);
|
||||
if (!(await waitFor(`document.querySelector('#view .rw-tabs [data-tab="custom"]').classList.contains('tab-active')`, 8000))) fail('custom tab active');
|
||||
await sleep(600);
|
||||
const emptyState = await ev(`(() => {
|
||||
const td = document.querySelector('#view .rw-tabs [data-tab="custom"]').closest('.page').querySelector('.empty');
|
||||
if (!td) return 'NO-EMPTY';
|
||||
const a = td.querySelector('a.link[href="#/accounts"]');
|
||||
return { txt: td.textContent.trim().slice(0, 120), hasArrow: !!a, href: a ? a.getAttribute('href') : '', linkText: a ? a.textContent.trim() : '' };
|
||||
})()`);
|
||||
console.log('[2] custom 空态:', JSON.stringify(emptyState));
|
||||
if (!emptyState || emptyState === 'NO-EMPTY') {
|
||||
// 有数据时不显示空态——查 note 文案确认命名 + 从 note 验证即可
|
||||
const note = await ev(`document.querySelector('#view .rw-note').textContent`);
|
||||
console.log('[2b] custom note:', note.slice(0, 100));
|
||||
if (!note.includes('原创选题创作')) { console.log('FAIL: note 未改名'); process.exit(1); }
|
||||
console.log('[2b] note 改名: PASS (custom 有数据,无空态,跳转逻辑待空态场景)');
|
||||
} else {
|
||||
if (!emptyState.txt.includes('原创选题脚本')) { console.log('FAIL: 空态文案未改', emptyState.txt); process.exit(1); }
|
||||
if (!emptyState.hasArrow || emptyState.href !== '#/accounts' || !emptyState.linkText.includes('去账号列表')) { console.log('FAIL: 箭头跳转缺失'); process.exit(1); }
|
||||
console.log('[2] 空态文案 + → 去账号列表链接: PASS');
|
||||
// ③ 点击跳转
|
||||
await ev(`document.querySelector('#view .empty a.link[href="#/accounts"]').click()`);
|
||||
if (!(await waitFor(`location.hash === '#/accounts' && !!document.querySelector('#view #accUpdate')`, 10000))) fail('jump to accounts');
|
||||
console.log('[3] 点击 → 跳转账号列表: PASS');
|
||||
}
|
||||
|
||||
console.log('\n=== PASS ===');
|
||||
ws.close();
|
||||
process.exit(0);
|
||||
@@ -527,8 +527,8 @@ function getRanking({ board = 'week', date = '', category = '', sortBy = '', ord
|
||||
return { dates, categories, category, date: selDate, total, page: Math.max(1, Number(page) || 1), pageSize: limit, items: pageItems };
|
||||
}
|
||||
|
||||
/* ================= AI写脚本:自定义选题(无视频载体)================= */
|
||||
/* 09-02 v25:账号列表「AI创作」(自定义选题,无原视频)入库的脚本 = video_id 空。此处逐条列出该类脚本 */
|
||||
/* ================= AI写脚本:原创选题(无视频载体)================= */
|
||||
/* 09-02 v25:账号列表「AI创作」(原创选题,无原视频)入库的脚本 = video_id 空。此处逐条列出该类脚本 */
|
||||
function listCustomScripts({ page = 1, pageSize = 20, search = '', days = '', account = '' } = {}) {
|
||||
const d = getDb();
|
||||
if (!d) return { total: 0, page: 1, pageSize, items: [] };
|
||||
@@ -550,16 +550,16 @@ function listCustomScripts({ page = 1, pageSize = 20, search = '', days = '', ac
|
||||
return { total, page: Math.max(1, Number(page) || 1), pageSize: limit, items };
|
||||
}
|
||||
|
||||
/* 自定义选题脚本全文(查看弹窗用,避免列表全量携带大文本) */
|
||||
/* 原创选题脚本全文(查看弹窗用,避免列表全量携带大文本) */
|
||||
function getCustomScript(id) {
|
||||
const d = getDb(); if (!d) throw new Error('数据库不可用');
|
||||
const row = d.prepare(`SELECT r.id, r.video_id, r.account_id, r.title, r.topic, r.script_text, r.status, r.created_time, r.updated_time, h.account_name
|
||||
FROM rewrite_log r LEFT JOIN hot_accounts h ON h.id=r.account_id WHERE r.id=?`).get(Number(id));
|
||||
if (!row || row.video_id) throw new Error('脚本不存在或非自定义选题脚本');
|
||||
if (!row || row.video_id) throw new Error('脚本不存在或非原创选题脚本');
|
||||
return row;
|
||||
}
|
||||
|
||||
/* 写库:AI创作任务完成后回调保存脚本(自定义选题 videoId 省略/0 → video_id=NULL;参考选题传 videoId)
|
||||
/* 写库:AI创作任务完成后回调保存脚本(原创选题 videoId 省略/0 → video_id=NULL;参考选题传 videoId)
|
||||
可写连接单独打开(与只读 getDb 隔离),与 setFavorite 同模式 */
|
||||
function saveScript({ videoId, awemeId, accountId, title, scriptText }) {
|
||||
if (!fs.existsSync(DSH_DB)) throw new Error('数据库不可用');
|
||||
@@ -577,7 +577,7 @@ function saveScript({ videoId, awemeId, accountId, title, scriptText }) {
|
||||
}
|
||||
}
|
||||
|
||||
/* 09-02 v25:AI写脚本 三 tab 徽标计数——按 有原视频(参考选题)/无原视频(自定义选题) 拆分;
|
||||
/* 09-02 v25:AI写脚本 三 tab 徽标计数——按 有原视频(参考选题)/无原视频(原创选题) 拆分;
|
||||
口径与账号列表「AI脚本」列一致(status=done 且脚本非空);account 传账号名时按账号统计 */
|
||||
function getRewriteStats(account = '') {
|
||||
const d = getDb();
|
||||
|
||||
@@ -26,6 +26,11 @@ const EXT_ICON = { md: 'file-text', json: 'file-code', xlsx: 'table', csv: 'tabl
|
||||
定位方式跨环境:优先用 Skill 工具按技能名加载;技能位于用户技能目录 ~/.workbuddy/skills/短视频工作台/subskills/mcn-dou-analysis/(部署到用户环境时同此标准位),不写死机器绝对路径 */
|
||||
const SKILL_HINT_ACCOUNT = '请调用 Skill 工具加载「mcn-dou-analysis」技能(技能根目录 = 用户技能目录 ~/.workbuddy/skills/短视频工作台/subskills/mcn-dou-analysis/,先读取其 SKILL.md 及 references 规范;若 Skill 工具无法加载,请直接读取该目录下的 SKILL.md 文件)。严格按技能工作流执行(获取账号信息 → 视频列表抓取 → 登记达人账号表 15 列规范),遵守技能红线:TOP6 筛选、≥60 条停止、账号身份标识(sec_uid/抖音号/地址)必须由浏览器获取、禁止从数据库回填。';
|
||||
|
||||
/* 09-04 R67:业务任务统一挂载的连接器 configId(myai-mcp-production 自定义 MCP)。
|
||||
宿主 register(connectorIds) 只注入列出的连接器;凡任务可能调用 MCP(视频解析/对标拆解/功能一兜底数据源等)必须挂载,
|
||||
否则后台会话无 MCP 工具(「找不到 MCP」根因,见 R66)。复盘/分镜/榜单等纯脚本任务不挂。 */
|
||||
const MYAI_CONNECTORS = ['custom-mcp:myai-mcp-production'];
|
||||
|
||||
// 任务完成后自动刷新当前数据页(无需手动刷新即可看到最新数据)
|
||||
function refreshCurrentPage() {
|
||||
const view = $('#view');
|
||||
@@ -36,6 +41,8 @@ function refreshCurrentPage() {
|
||||
}
|
||||
|
||||
// 提交任务 + 按钮执行中状态 + 轮询恢复(供 confirmSaveAccount/confirmRun 确认后调用)
|
||||
// opts.onSubmit(taskId):提交成功拿到任务 id 后回调(调用方可持久化「执行中」状态,供页面刷新后恢复,见视频解析)
|
||||
// opts.onFinish(s):轮询结束(done/error/notfound/review/超时 s=null)后回调,restore 之前调用
|
||||
async function executeTask(opts) {
|
||||
const btn = opts.btn;
|
||||
const oldText = btn ? btn.innerHTML : '';
|
||||
@@ -43,15 +50,19 @@ async function executeTask(opts) {
|
||||
if (btn) { btn.disabled = true; btn.innerHTML = '执行中…'; btn.classList.add('btn-busy'); }
|
||||
let taskId = null;
|
||||
let finished = false;
|
||||
let s = null;
|
||||
let ended = false;
|
||||
const finish = (st) => { if (ended) return; ended = true; if (opts.onFinish) { try { opts.onFinish(st); } catch (e) {} } }; /* 终态清理须在 refresh 之前,避免刷新渲染读到陈旧执行中记录 */
|
||||
try {
|
||||
const d = await api('/api/run', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt: opts.prompt, name: opts.label || '工作台任务', skills: opts.skills }),
|
||||
body: JSON.stringify({ prompt: opts.prompt, name: opts.label || '工作台任务', skills: opts.skills, connectorIds: opts.connectorIds }),
|
||||
});
|
||||
if (d.error) throw new Error(d.error);
|
||||
taskId = d.id;
|
||||
showToast('✅ 任务已提交,请在左侧会话栏查看执行');
|
||||
if (opts.onSubmit) { try { opts.onSubmit(taskId); } catch (e) {} }
|
||||
} catch (e) {
|
||||
showToast('❌ 任务提交失败: ' + e.message);
|
||||
restore();
|
||||
@@ -61,15 +72,15 @@ async function executeTask(opts) {
|
||||
const deadline = Date.now() + 30 * 60 * 1000;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
let s = null;
|
||||
try { s = await api('/api/run/status?id=' + encodeURIComponent(taskId)); } catch (e) { continue; }
|
||||
if (!s || s.state === 'notfound') { finished = true; break; }
|
||||
if (s.state === 'done') { finished = true; showToast('✅ 任务执行完成,页面已自动刷新'); refreshCurrentPage(); break; }
|
||||
if (s.state === 'error') { finished = true; showToast('❌ 任务执行失败' + (s.error ? ':' + s.error : '')); break; }
|
||||
if (s.state === 'review') { finished = true; showToast('⏸ 任务待确认,请到左侧会话栏确认执行'); break; }
|
||||
if (!s || s.state === 'notfound') { finished = true; finish(s || null); break; }
|
||||
if (s.state === 'done') { finished = true; finish(s); showToast('✅ 任务执行完成,页面已自动刷新'); refreshCurrentPage(); break; }
|
||||
if (s.state === 'error') { finished = true; finish(s); showToast('❌ 任务执行失败' + (s.error ? ':' + s.error : '')); break; }
|
||||
if (s.state === 'review') { finished = true; finish(s); showToast('⏸ 任务待确认,请到左侧会话栏确认执行'); break; }
|
||||
// queued / pending / running → 继续等待
|
||||
}
|
||||
restore();
|
||||
if (!ended) finish(null);
|
||||
if (!finished) showToast('⏳ 任务仍在执行,请到左侧会话栏查看进度');
|
||||
}
|
||||
|
||||
@@ -105,6 +116,7 @@ function confirmSaveAccount(name, url, btn) {
|
||||
label: '保存指令',
|
||||
btn,
|
||||
skills: ['mcn-dou-analysis'],
|
||||
connectorIds: MYAI_CONNECTORS, /* R67:功能一浏览器不可用时 MCP 账号库兜底 */
|
||||
});
|
||||
}
|
||||
|
||||
@@ -139,7 +151,7 @@ function openImportAccounts(btn) {
|
||||
const parts = [name ? `账号名称「${name}」` : '', url ? `账号地址「${url}」` : ''].filter(Boolean).join(',');
|
||||
const prompt = `${SKILL_HINT_ACCOUNT}\n请帮我把抖音账号 ${parts} 添加:通过抖音网页获取账号信息与视频列表,写入账号列表并保存到本地`;
|
||||
close();
|
||||
executeTask({ prompt, label: '添加指令', btn, skills: ['mcn-dou-analysis'] });
|
||||
executeTask({ prompt, label: '添加指令', btn, skills: ['mcn-dou-analysis'], connectorIds: MYAI_CONNECTORS });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ const SKILL_HINT_REVIEW = '请调用 Skill 工具加载「脚本复盘」技能
|
||||
const SKILL_HINT_VIDEO_PARSE = '请调用 Skill 工具加载「mcn-dou-analysis」技能(技能根目录 = 用户技能目录 ~/.workbuddy/skills/短视频工作台/subskills/mcn-dou-analysis/,先读取其 SKILL.md 及 references 规范;若 Skill 工具无法加载,请直接读取该目录下的 SKILL.md 文件)。本任务为技能功能五「解析视频」:对单条抖音视频做 MCP 内容解析。按 references/feature/05_解析视频.md 流程执行:① 调用 MCP 工具 upload_douyin_video 提交视频链接(share_text=该视频抖音链接)获取 detailId;② 等待解析完成(新视频约 3-5 分钟,用 sleep 等待后查询,content 已出而 analysis 未出时稍后重试);③ 用 short_video_detail 查询解析结果 content(视频脚本/分镜头表)与 analysis(视频拆解/结构化JSON);④ 按 references/解析结果格式规范.md 用 scripts/json_tool.py 将 content.json 与 analysis.json 保存到产出目录对应视频文件夹;⑤ 解析结果入库:工作台环境(本机 http://localhost:8900)产物落盘后调用 POST /api/import/account(body {"account":"{达人昵称}"})同步入库,接口不可用则跳过不影响保存。完成后汇报 detailId、content/analysis 是否齐备与保存路径。技能边界:只做该单视频解析,不抓取账号信息、不提炼账号设定、不创作/改写脚本。';
|
||||
/* 09-02 v25:AI创作任务完成后的写库回调指令——否则脚本只落会话/产出目录,工作台「AI写脚本」列表看不到。
|
||||
参考选题创作(有原视频,如视频行 AI创作):传 videoId/awemeId → rewrite_log.video_id 关联原视频;
|
||||
自定义选题创作(无原视频,如账号列表 AI创作 弹窗):不传 videoId → video_id 留空,归「自定义选题创作」tab。
|
||||
原创选题创作(无原视频,如账号列表 AI创作 弹窗):不传 videoId → video_id 留空,归「原创选题创作」tab。
|
||||
保存走本地服务 /api/dsh/script-save:server 自行解析 mcn-plugin.db 路径(跨开发机/dsh 环境最可靠),勿让 agent 猜库路径 */
|
||||
/* 榜单数据更新技能声明(09-03 新增 09-04 四榜扩展:热点数据页「更新榜单」按钮 → 后台任务按 mcn-data-insight 落盘规范抓取四榜最新一期。
|
||||
范围与调用次数已在点击确认弹窗中向用户明示并获授权(账号日榜 全品类 1 次 + 账号周榜 5 赛道 + 视频热榜/点赞榜,约 8 次红狐 API);
|
||||
@@ -40,7 +40,7 @@ const saveHintOf = (ctx) => {
|
||||
lines.push(` "scriptText": "(完整脚本全文)"`);
|
||||
return `\n\n【任务完成后必须执行】将完整脚本保存到工作台数据库(工作台与本任务同机运行,服务地址 http://localhost:8900):\n`
|
||||
+ `1. 用 fetch 发起 POST http://localhost:8900/api/dsh/script-save;body 用 JSON.stringify 构造(scriptText 为完整脚本全文,注意转义,不要手拼 JSON):\n{\n${lines.join('\n')}\n}\n`
|
||||
+ (ref ? '(参考选题创作:脚本归入「参考选题创作」列表,关联该原视频)\n' : '(自定义选题创作:不传 videoId,脚本归入「自定义选题创作」列表)\n')
|
||||
+ (ref ? '(参考选题创作:脚本归入「参考选题创作」列表,关联该原视频)\n' : '(原创选题创作:不传 videoId,脚本归入「原创选题创作」列表)\n')
|
||||
+ `2. 响应含 ok:true 即已入库;失败重试一次,仍失败则在回复中说明原因。\n完成后回复:脚本标题 + 保存结果。`;
|
||||
};
|
||||
|
||||
@@ -502,7 +502,7 @@ async function confirmAiScript(accountName, btn) {
|
||||
rm();
|
||||
const prompt = `${SKILL_HINT_CREATE}\n${buildCreatePrompt(accountName, w, personaBlock(), pHas, attach.text)}${saveHintOf({ accountId: accId })}`;
|
||||
close();
|
||||
executeTask({ prompt, label: '创作指令', btn, skills: ['短视频工作台'] });
|
||||
executeTask({ prompt, label: '创作指令', btn, skills: ['短视频工作台'], connectorIds: MYAI_CONNECTORS });
|
||||
});
|
||||
document.body.appendChild(ck);
|
||||
});
|
||||
@@ -709,7 +709,7 @@ async function updateRankingData(view, btn) {
|
||||
mask.innerHTML = `
|
||||
<div class="modal" style="max-width:560px">
|
||||
<h3>${svgIcon('refresh', 18)} 更新榜单数据</h3>
|
||||
<p class="dim">将重新抓取热点数据四榜最新一期并落盘:账号日榜(全品类单次,不传赛道)+ 账号周榜(5 赛道)+ 视频热榜 + 点赞榜,约 8 次红狐 API 调用(幂等铁律:同榜名+赛道+数据日期已存在则跳过)。后台任务执行需数分钟,完成后自动刷新榜单。</p>
|
||||
<p class="dim">将重新抓取热点数据四榜最新一期并落盘:账号日榜(全品类单次,不传赛道)+ 账号周榜(5 赛道)+ 视频热榜 + 点赞榜,约 8 次红狐 API 调用${inCooldown ? '(强制刷新:同名文件已存在也重新抓取并覆盖写入)' : '(幂等铁律:同榜名+赛道+数据日期已存在则跳过)'}。后台任务执行需数分钟,完成后自动刷新榜单。</p>
|
||||
${st.lastUpdateAt ? `<div class="confirm-target">上次更新:${esc(st.lastUpdateDate || '—')} ${esc(timeTxt)}(${esc(ageTxt() || '')})</div>` : (st.exists === false ? '<div class="confirm-target">本地暂无榜单数据文件,本次为首次抓取</div>' : '')}
|
||||
${inCooldown ? `<p style="color:#b7791f;font-size:14px;margin:8px 0 0">⚠️ ${st.lastUpdateAgeMin} 分钟前刚更新过(防重复窗口 ${st.cooldownMin} 分钟),数据仍较新;确需强制刷新请点「仍要更新」。</p>` : ''}
|
||||
<div class="modal-actions">
|
||||
@@ -727,11 +727,15 @@ async function updateRankingData(view, btn) {
|
||||
const restore = () => { if (btn) { btn.disabled = false; btn.title = oldTitle; btn.classList.remove('btn-busy'); } };
|
||||
if (btn) { btn.disabled = true; btn.title = '榜单更新中…(完成后自动刷新)'; btn.classList.add('btn-busy'); } // 纯图标按钮:不动 innerHTML,图标经 .btn-busy .ic 旋转
|
||||
try {
|
||||
// 3) 提交(后端二次防重;冷却窗口内确认 = 传 force)
|
||||
// 3) 提交(后端二次防重;冷却窗口内确认 = 传 force)。R69:force 时在 prompt 内追加覆盖语义——
|
||||
// 否则 AI 仍按「幂等铁律」跳过同名同日期文件,强制刷新变空跑(重抓已存在文件=用户主动要求覆盖)
|
||||
const rankPrompt = SKILL_HINT_RANKING + (inCooldown
|
||||
? '\n【覆盖刷新模式(09-04 R69:用户已确认强制刷新)】本次为强制刷新:即使目标榜名+赛道+数据日期同名文件已存在,也要重新调用 API 抓取最新数据并覆盖写入同名文件,禁止因文件已存在而跳过 API 调用。'
|
||||
: '');
|
||||
const r = await api('/api/dsh/ranking-update', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt: SKILL_HINT_RANKING, force: !!inCooldown }),
|
||||
body: JSON.stringify({ prompt: rankPrompt, force: !!inCooldown }),
|
||||
});
|
||||
if (!r || r.ok === false) throw new Error((r && r.error) || '任务提交失败');
|
||||
showToast('✅ 榜单更新任务已提交,请到左侧会话栏查看执行');
|
||||
@@ -800,7 +804,7 @@ async function renderAccounts(view) {
|
||||
<select id="accTrack" class="input-select"><option value="">全部赛道</option>${tracks.map((t) => `<option value="${esc(t)}" ${t === state.track ? 'selected' : ''}>${esc(t)}</option>`).join('')}</select>
|
||||
<input id="accSearch" class="input" placeholder="搜索账号名 / 内容 / 赛道" value="${esc(state.search)}">
|
||||
<button class="btn" id="accImport" title="添加账号数据(抖音账号信息 / 账号地址)">${svgIcon('user-plus', 15, 'currentColor')} 添加</button>
|
||||
<button class="btn" id="accUpdate" title="重新抓取所有已关注账号(⭐)的最新账号数据与视频列表(后台任务逐个执行)">${svgIcon('globe', 15, 'currentColor')} 更新</button>
|
||||
<button class="btn" id="accUpdate" title="重新抓取所有已关注账号(⭐)的最新账号数据与视频列表(后台任务逐个执行)">${svgIcon('globe', 15, 'currentColor')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
@@ -885,7 +889,7 @@ function updateFollowedAccounts(btn) {
|
||||
for (let i = 0; i < names.length; i++) {
|
||||
const name = names[i];
|
||||
if (btn) btn.innerHTML = `更新中 ${i + 1}/${names.length}…`;
|
||||
const prompt = `${SKILL_HINT_ACCOUNT}\n请更新抖音账号「${name}」的数据:该账号已在本工作台账号库中登记,属数据更新场景(非新增)。通过抖音网页重新获取该账号最新的账号信息(粉丝/获赞/作品数/简介等)与最新视频列表,并覆盖更新该账号的登记数据与视频列表记录。账号身份标识(sec_uid/抖音号/抖音地址)一律以本次浏览器抓取到的为准,禁止沿用库中旧值回填。`;
|
||||
const prompt = `${SKILL_HINT_ACCOUNT}\n请更新抖音账号「${name}」的数据:该账号已在本工作台账号库中登记,属数据更新场景(非新增)。通过抖音网页重新获取该账号最新的账号信息(粉丝/获赞/作品数/简介等)与最新视频列表,并覆盖更新该账号的登记数据与视频列表记录。账号身份标识(sec_uid/抖音号/抖音地址)一律以本次浏览器抓取到的为准,禁止沿用库中旧值回填。\n【覆盖刷新模式(09-04 R69)】本次是用户主动「更新」操作 = 强制重新抓取并覆盖:不要因库中已有该账号记录而判断「数据已较新/无需更新」而跳过或缩短流程,禁止复用旧的抓取结果——必须重新走完整抓取流程,用本次结果整体覆盖更新该账号登记数据与视频列表记录。`;
|
||||
try {
|
||||
const r = await api('/api/run', {
|
||||
method: 'POST',
|
||||
@@ -1100,6 +1104,7 @@ async function renderAccountDetail(view, id) {
|
||||
prompt: basePrompt + saveHintOf({ videoId: r.id, awemeId: r.aweme, accountId: id }),
|
||||
label: '创作指令',
|
||||
skills: ['短视频工作台'],
|
||||
connectorIds: MYAI_CONNECTORS, /* R67: 未解析视频先 MCP 解析分支 */
|
||||
btn: b,
|
||||
});
|
||||
}));
|
||||
@@ -1111,6 +1116,7 @@ async function renderAccountDetail(view, id) {
|
||||
prompt: `${SKILL_HINT_EXTRACT}\n请为达人「${acc.account_name}」提炼账号设定(生成人设卡)。`,
|
||||
label: '提炼指令',
|
||||
skills: ['mcn-dou-analysis'],
|
||||
connectorIds: MYAI_CONNECTORS, /* R67:功能三缺存量解析时会现场 MCP 解析 */
|
||||
btn: exBtn,
|
||||
}));
|
||||
// 09-04:数据分析 tab 空态「账号数据分析」按钮 → mcn-dou-analysis 功能四(近4个月数据 逐条拆解+汇总分析)
|
||||
@@ -1122,6 +1128,7 @@ async function renderAccountDetail(view, id) {
|
||||
prompt: `${SKILL_HINT_ANALYZE}\n请为达人「${acc.account_name}」进行账号数据分析(功能四)。`,
|
||||
label: '分析指令',
|
||||
skills: ['mcn-dou-analysis'],
|
||||
connectorIds: MYAI_CONNECTORS, /* R67:功能四需补齐无解析视频的 MCP 解析 */
|
||||
btn: anaBtn,
|
||||
}));
|
||||
view._reload = load; // 静默自动刷新(保留当前 tab/翻页)
|
||||
@@ -1318,14 +1325,14 @@ function renderVideoKeySvg(analysis, vt, acc) {
|
||||
T(340, headY0 + 162, `${acc ? acc + ' · ' : ''}赛道:${D.track || '未知'}`, 9.5, P.headDim, null, 'middle');
|
||||
|
||||
/* —— 逐模块排版 —— */
|
||||
let y = headY0 + headH + 22; /* 头部框下起点 */
|
||||
let y = headY0 + headH + 34; /* 头部框下起点(09-04:首板块与头部大标题框间距加大 22→34,避免「内容定位」标题贴框底) */
|
||||
const vPadLR = 16, vPadT = 15, vPadB = 14; /* 卡内左右/上/下留白(F6:文字禁止贴靠卡边) */
|
||||
const insetTop = (s) => Math.round(s * 0.9); /* 字形顶 → 基线(中文 glyph 顶在基线之上约 0.9em) */
|
||||
const kvU = (size) => X((CW - vPadLR * 2 - 6) / (size + 1.1)); /* 卡内正文每行最多全角字数 */
|
||||
D.mods.forEach((mod, mi) => {
|
||||
if (mi > 0) y += 14;
|
||||
T(LX, y, '▍' + mod.title, 14, P.secTitle, 700);
|
||||
y += 30;
|
||||
y += 22; /* 板块标题 → 内容卡顶(09-04:30→22,标题与卡更靠近) */
|
||||
if (mod.kind === 'kv') {
|
||||
/* kv 卡:label 独占行(主题粗体)→ 值折行 ≤2 行;游标自卡顶 vPadT 起步 → 首行文字顶与卡顶留白 vPadT */
|
||||
const rows = mod.rows.filter(([, v]) => String(v).trim());
|
||||
@@ -1420,6 +1427,7 @@ async function renderVideoDetail(view, id) {
|
||||
const videoId = Number(id);
|
||||
let tab = 'script';
|
||||
let vkMode = 'card'; /* R59:拆解 tab 默认展示「视频关键信息卡」,可切换「拆解全文」文案版 */
|
||||
let watchStarted = false; /* R65:本页实例已挂恢复轮询(刷新后执行中状态接管),避免重复轮询 */
|
||||
const d = await api('/api/dsh/video-detail?videoId=' + videoId).catch((e) => ({ ok: false, error: e.message }));
|
||||
if (!d.ok) { view.innerHTML = `<div class="page"><div class="page-toolbar">${toolbarBack()}</div><div class="empty">${esc(d.error || '视频不存在')}</div></div>`; bindBack(view); return; }
|
||||
const v = d.video, analysis = d.analysis, parse = d.parse || {};
|
||||
@@ -1431,8 +1439,12 @@ async function renderVideoDetail(view, id) {
|
||||
const scriptMd = parse.content || null;
|
||||
const deconstructMd = parse.analysis ? analysisToMd(parse.analysis) : null;
|
||||
const keyCardHtml = renderVideoKeySvg(parse.analysis, v.video_title, v.account_name); /* R59:关键信息卡(SVG 生成器,拆解维度自动抽取,无则空 → 纯全文) */
|
||||
/* R65:该视频存在未完成解析任务(页面刷新后恢复)→ 按钮渲染「执行中」禁用,轮询到终态才恢复 */
|
||||
const runningRec = readVideoTaskRec(videoId);
|
||||
/* 解析/刷新按钮(09-03 R28):未解析=空态「解析」;已解析=内容右上角「刷新」(同 F5 解析任务,幂等覆盖) */
|
||||
const parseBtn = (mode) => `<button class="btn btn-sm" data-parse="${mode}" title="${mode === 'refresh' ? '重新解析该视频(覆盖更新视频脚本与视频拆解)' : '对该视频做 MCP 解析(产出视频脚本与视频拆解)'}">${svgIcon(mode === 'refresh' ? 'refresh' : 'zap', 14, 'currentColor')} ${mode === 'refresh' ? '刷新' : '解析'}</button>`;
|
||||
const parseBtn = (mode) => runningRec
|
||||
? `<button class="btn btn-sm btn-busy" disabled title="解析任务执行中…(约需 3-5 分钟,完成后自动刷新本页;也可到左侧会话栏查看进度)">${svgIcon('refresh', 14, 'currentColor')} 执行中</button>`
|
||||
: `<button class="btn btn-sm" data-parse="${mode}" title="${mode === 'refresh' ? '重新解析该视频(覆盖更新视频脚本与视频拆解)' : '对该视频做 MCP 解析(产出视频脚本与视频拆解)'}">${svgIcon(mode === 'refresh' ? 'refresh' : 'zap', 14, 'currentColor')} ${mode === 'refresh' ? '刷新' : '解析'}</button>`;
|
||||
const emptyAct = (icon, title, desc) => `<div class="empty-state"><div class="empty-icon">${svgIcon(icon, 40)}</div><div class="empty-title">${title}</div><div class="empty-desc">${desc}</div><div style="margin-top:12px">${parseBtn('parse')}</div></div>`;
|
||||
const tabBtns = [['script', '视频脚本'], ['deconstruct', '视频拆解']].map(([k, label]) => `<button class="tab ${tab === k ? 'tab-active' : ''}" data-tab="${k}">${label}</button>`).join('');
|
||||
/* R59:拆解 tab 内容区 = 关键信息卡(默认)/ 拆解全文 切换视图(同账号设定卡片交互:卡/文切换钮,无 AI、纯前端抽取) */
|
||||
@@ -1482,12 +1494,49 @@ async function renderVideoDetail(view, id) {
|
||||
if (tv) tv.classList.toggle('vs-hidden', vkMode !== 'text');
|
||||
}));
|
||||
view._reload = load; // 静默自动刷新(保留当前 tab)
|
||||
/* R65:页面刷新后接管未完成任务状态——按钮已渲染执行中,这里挂终态轮询(每页实例只挂一次),终态后清理记录并 load 恢复按钮 */
|
||||
if (runningRec && !watchStarted) { watchStarted = true; watchVideoParseTask(videoId, runningRec.taskId, load); }
|
||||
}
|
||||
load();
|
||||
}
|
||||
|
||||
/* 视频详情「解析/刷新」确认弹窗(09-03 R28):走 confirmRun + executeTask 单任务链路,
|
||||
prompt=技能功能五 F5(SKILL_HINT_VIDEO_PARSE),任务完成 executeTask 自动刷新本页显示新内容 */
|
||||
prompt=技能功能五 F5(SKILL_HINT_VIDEO_PARSE),任务完成 executeTask 自动刷新本页显示新内容。
|
||||
09-04 R65:任务 id 写 localStorage(wk_video_task_<videoId>)——页面刷新后按钮仍显示「执行中」并恢复轮询,任务真正结束才恢复可点 */
|
||||
const VIDEO_TASK_KEY = (videoId) => 'wk_video_task_' + videoId;
|
||||
function readVideoTaskRec(videoId) {
|
||||
try {
|
||||
const r = JSON.parse(localStorage.getItem(VIDEO_TASK_KEY(videoId)) || 'null');
|
||||
if (r && r.taskId && Date.now() - (r.ts || 0) < 35 * 60 * 1000) return r; /* 超 35 分钟视为陈旧,清理 */
|
||||
if (r) localStorage.removeItem(VIDEO_TASK_KEY(videoId));
|
||||
} catch (e) {}
|
||||
return null;
|
||||
}
|
||||
/* 刷新后恢复轮询:跟随该视频解析任务直到终态 → 清理记录 → 提示并刷新本页按钮态 */
|
||||
async function watchVideoParseTask(videoId, taskId, reload) {
|
||||
let s = null;
|
||||
const deadline = Date.now() + 35 * 60 * 1000;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
try { s = await api('/api/run/status?id=' + encodeURIComponent(taskId)); } catch (e) { continue; }
|
||||
if (!s || s.state === 'notfound' || s.state === 'done' || s.state === 'error' || s.state === 'review') break;
|
||||
}
|
||||
try { localStorage.removeItem(VIDEO_TASK_KEY(videoId)); } catch (e) {}
|
||||
if (s && s.state === 'done') showToast('✅ 视频解析完成,已自动刷新本页');
|
||||
else if (s && s.state === 'error') showToast('❌ 视频解析失败' + (s.error ? ':' + s.error : '') + '(任务已结束,可重新点击解析)');
|
||||
else if (s && s.state === 'review') showToast('⏸ 任务待确认,请到左侧会话栏确认执行');
|
||||
else if (!s) showToast('⏳ 任务仍在执行,请到左侧会话栏查看进度');
|
||||
if (typeof reload === 'function') reload();
|
||||
}
|
||||
/* 09-04 R69:视频「刷新」= 强制重新获取覆盖(追加进刷新 prompt 的覆盖语义段)。
|
||||
旧实现刷新与解析共用同一条 prompt(SKILL_HINT_VIDEO_PARSE 无覆盖语义)——模型可能"先判断是否要更新"、
|
||||
复用旧记录、甚至用截断/不完整结果覆盖旧完整版本(已发生一次质量事故)。刷新模式必须追加本段:
|
||||
明确 重新抓取 + 整体覆盖 + 防截断兜底 */
|
||||
const VIDEO_REFRESH_OVERRIDE = '\n\n【覆盖刷新模式(用户点击「刷新」触发,非首次解析)】该视频已有历史解析记录,本次操作语义 = 强制重新获取数据并覆盖:\n'
|
||||
+ '1. 禁止判断「是否需更新」、禁止复用/保留旧解析结果——必须重新走完整解析流程(重新 upload_douyin_video 提交链接 → 等待 → short_video_detail 重新查询 content 与 analysis);\n'
|
||||
+ '2. 用本次全新抓取结果整体覆盖:content.json(视频脚本)与 analysis.json(视频拆解)均覆盖写入旧文件/旧记录,不保留、不合并任何旧版本内容;\n'
|
||||
+ '3. 完整性兜底:若查询结果疑似截断/字段缺失/解析未完成,先等待重试直到拿到完整结果再覆盖保存——严禁用不完整/截断结果覆盖旧的完整版本;\n'
|
||||
+ '4. 覆盖保存后照常调用 POST /api/import/account(body {"account":"{达人昵称}"})覆盖入库。';
|
||||
function confirmVideoParse(btn, v, mode) {
|
||||
const isRefresh = mode === 'refresh';
|
||||
confirmRun({
|
||||
@@ -1497,19 +1546,22 @@ function confirmVideoParse(btn, v, mode) {
|
||||
: '将对该视频做 MCP 内容解析,产出「视频脚本」(分镜头表)与「视频拆解」(结构化分析),约需 3-5 分钟,完成后自动刷新。',
|
||||
target: cleanTitle(v.video_title) || '视频',
|
||||
url: v.video_url || '',
|
||||
prompt: `${SKILL_HINT_VIDEO_PARSE}\n待解析视频链接:${v.video_url || ''}\n视频标题:${v.video_title || ''}\n所属达人:${v.account_name || ''}`,
|
||||
prompt: `${SKILL_HINT_VIDEO_PARSE}\n待解析视频链接:${v.video_url || ''}\n视频标题:${v.video_title || ''}\n所属达人:${v.account_name || ''}${isRefresh ? VIDEO_REFRESH_OVERRIDE : ''}`,
|
||||
label: '解析指令',
|
||||
btn,
|
||||
skills: ['mcn-dou-analysis'],
|
||||
connectorIds: MYAI_CONNECTORS, /* R66/R67:任务会话按 configId 注入连接器,否则后台会话无 MCP 工具 */
|
||||
onSubmit: (taskId) => { try { localStorage.setItem(VIDEO_TASK_KEY(v.id), JSON.stringify({ taskId, ts: Date.now() })); } catch (e) {} },
|
||||
onFinish: () => { try { localStorage.removeItem(VIDEO_TASK_KEY(v.id)); } catch (e) {} },
|
||||
});
|
||||
}
|
||||
|
||||
/* ================= AI写脚本 ================= */
|
||||
async function renderRewrites(view, initAccount) { /* v22:initAccount=账号列表 AI脚本列跳转进入,预筛该账号;09-02 v25:AI写脚本=三类脚本中心(参考选题创作/自定义选题创作/ABC融合创作) */
|
||||
async function renderRewrites(view, initAccount) { /* v22:initAccount=账号列表 AI脚本列跳转进入,预筛该账号;09-02 v25:AI写脚本=三类脚本中心(参考选题创作/原创选题创作/ABC融合创作) */
|
||||
try { initAccount = decodeURIComponent(initAccount || ''); } catch (e) { initAccount = ''; } /* hash 路径段 param 未解码,中文账号名需还原(v22 fix) */
|
||||
/* tab 说明:
|
||||
ref 参考选题创作——以视频行为选题来源(有原视频):基于「原视频」创作,脚本保存后挂在该视频下(rewrite_log.video_id>0)
|
||||
custom 自定义选题创作——无原视频:账号列表/详情「AI创作」弹窗等触发,video_id 留空,脚本保存后自动入库归入本列表
|
||||
custom 原创选题创作——无原视频:账号列表/详情「AI创作」弹窗等触发,video_id 留空,脚本保存后自动入库归入本列表
|
||||
abc ABC 融合创作——将同账号多条视频的爆款元素融合成一条新脚本(功能待实现,占位) */
|
||||
const state = {
|
||||
tab: 'ref',
|
||||
@@ -1538,8 +1590,8 @@ async function renderRewrites(view, initAccount) { /* v22:initAccount=账号
|
||||
: '参考选题创作:以库中视频为选题来源(关注账号优先 · 按发布时间倒序),行尾「AI创作」= 基于该视频创作新脚本(完成后自动保存并挂到该视频下)')
|
||||
: (state.tab === 'custom'
|
||||
? (state.scope
|
||||
? `账号「${esc(state.scope)}」· 自定义选题创作:无原视频的脚本(来自账号列表「AI创作」弹窗等),保存后自动入库显示在这里`
|
||||
: '自定义选题创作:无原视频的脚本(来自账号列表 / 账号详情「AI创作」弹窗等),保存后自动入库显示在这里')
|
||||
? `账号「${esc(state.scope)}」· 原创选题创作:无原视频的脚本(来自账号列表「AI创作」弹窗等),保存后自动入库显示在这里`
|
||||
: '原创选题创作:无原视频的脚本(来自账号列表 / 账号详情「AI创作」弹窗等),保存后自动入库显示在这里')
|
||||
: 'ABC 融合创作:将同账号多条视频的爆款元素融合成一条全新脚本。功能开发中,敬请期待。');
|
||||
/* —— ref tab:视频行(参考选题载体) —— */
|
||||
const refRows = items.map((v) => `<tr>
|
||||
@@ -1585,7 +1637,7 @@ async function renderRewrites(view, initAccount) { /* v22:initAccount=账号
|
||||
} else if (state.tab === 'custom') {
|
||||
bodyHtml = `<div class="table-wrap"><table class="table">
|
||||
<thead><tr><th>账号</th><th>脚本标题</th><th>字数</th><th>创建时间</th><th class="th-act">操作</th></tr></thead>
|
||||
<tbody>${customRows || `<tr><td colspan="5" class="empty">暂无自定义选题脚本${state.scope ? '(该账号暂无无视频脚本;有原视频的脚本请切到「参考选题创作」tab 查看)' : '——可从账号列表「AI创作」弹窗创作,任务完成自动入库后显示在这里'}</td></tr>`}</tbody>
|
||||
<tbody>${customRows || `<tr><td colspan="5" class="empty">暂无原创选题脚本${state.scope ? '(该账号暂无无视频脚本;有原视频的脚本请切到「参考选题创作」tab 查看)' : '——可从账号列表「AI创作」弹窗创作,任务完成自动入库后显示在这里 <a class="link" href="#/accounts" title="前往账号列表,用「AI创作」弹窗创作原创选题脚本">→ 去账号列表</a>'}</td></tr>`}</tbody>
|
||||
</table></div>`;
|
||||
} else {
|
||||
bodyHtml = `<div class="empty-state">
|
||||
@@ -1606,7 +1658,7 @@ async function renderRewrites(view, initAccount) { /* v22:initAccount=账号
|
||||
</div>
|
||||
<div class="rw-tabs">
|
||||
<button class="${tabCls('ref')}" data-tab="ref" title="有原视频:以视频行为选题来源创作,脚本保存后挂在该视频下">参考选题创作<span class="rw-tab-badge">${state.stats.videoScripts}</span></button>
|
||||
<button class="${tabCls('custom')}" data-tab="custom" title="无原视频:账号列表「AI创作」弹窗等创作,脚本保存后自动入库">自定义选题创作<span class="rw-tab-badge">${state.stats.customScripts}</span></button>
|
||||
<button class="${tabCls('custom')}" data-tab="custom" title="无原视频:账号列表「AI创作」弹窗等创作,脚本保存后自动入库">原创选题创作<span class="rw-tab-badge">${state.stats.customScripts}</span></button>
|
||||
<button class="${tabCls('abc')}" data-tab="abc" title="多视频爆款元素融合创作(开发中)">ABC融合创作</button>
|
||||
<div class="toolbar-right" style="margin-left:auto">${toolbarCtrl}</div>
|
||||
</div>
|
||||
@@ -1640,7 +1692,7 @@ async function renderRewrites(view, initAccount) { /* v22:initAccount=账号
|
||||
if (days) days.addEventListener('change', (e) => { state.custom.days = e.target.value; state.custom.page = 1; load(); });
|
||||
const clear = view.querySelector('[data-scope-clear]');
|
||||
if (clear) clear.addEventListener('click', () => { state.scope = ''; state.ref.search = ''; state.ref.page = 1; state.custom.page = 1; reloadAll(); });
|
||||
/* R58:自定义选题「查看」并入统一 AI 脚本弹窗(openScriptModal rewriteId 分支单条展示),获得删除按钮/诊断面板能力;原 openCustomScriptView 简单弹窗移除 */
|
||||
/* R58:原创选题「查看」并入统一 AI 脚本弹窗(openScriptModal rewriteId 分支单条展示),获得删除按钮/诊断面板能力;原 openCustomScriptView 简单弹窗移除 */
|
||||
view.querySelectorAll('[data-cview]').forEach((b) => b.addEventListener('click', () => openScriptModal({ rewriteId: Number(b.dataset.cview) })));
|
||||
}
|
||||
}
|
||||
@@ -1677,6 +1729,7 @@ async function renderRewrites(view, initAccount) { /* v22:initAccount=账号
|
||||
: `${SKILL_HINT_CREATE}\n请为达人「${r.name}」创作一条全新的短视频故事脚本。${personaBlock}${noPersonaNote}\n该视频尚未解析,请先解析这个抖音视频并做拆解分析(${r.url ? '视频地址:' + r.url + '\n' : ''}视频标题:《${r.title}》),再基于账号设定与提炼出的选题,创作完整故事脚本。${saveHint}`,
|
||||
label: '创作指令',
|
||||
skills: ['短视频工作台'],
|
||||
connectorIds: MYAI_CONNECTORS, /* R67:未解析视频先 MCP 解析分支需要 myai */
|
||||
btn: b,
|
||||
});
|
||||
}
|
||||
@@ -1688,7 +1741,7 @@ async function renderRewrites(view, initAccount) { /* v22:initAccount=账号
|
||||
load();
|
||||
}
|
||||
|
||||
/* 启动:拉取徽标计数;账号级进入(账号列表「AI脚本」列)时若该账号存在自定义选题脚本 → 默认落在「自定义选题创作」tab(否则「参考选题创作」) */
|
||||
/* 启动:拉取徽标计数;账号级进入(账号列表「AI脚本」列)时若该账号存在原创选题脚本 → 默认落在「原创选题创作」tab(否则「参考选题创作」) */
|
||||
{
|
||||
const st = await api('/api/dsh/rewrite-stats?account=' + encodeURIComponent(state.scope)).catch(() => ({ videoScripts: 0, customScripts: 0 }));
|
||||
state.stats = st;
|
||||
@@ -1829,8 +1882,8 @@ async function renderReviews(view) {
|
||||
布局同 AI写脚本查看弹窗(rw-stack 横向列):源脚本(可折叠,有原视频时) | AI脚本(版本切换) | 右侧列
|
||||
右侧列头部 tab:脚本诊断(默认) | 分镜提示词
|
||||
—— 诊断内容 = 当前版本 AI 脚本最新复盘(script_review type=ai 优先;compare 取 AI 视角总分并带源对比分)
|
||||
—— 分镜内容 = 该视频下 storyboard_log 中属当前版本的记录 + 生成/重新生成入口(仅参考选题脚本;自定义选题无视频载体不支持)
|
||||
数据源:/api/dsh/video-script + /api/dsh/video-detail(参考选题)/ /api/dsh/custom-script(自定义选题)
|
||||
—— 分镜内容 = 该视频下 storyboard_log 中属当前版本的记录 + 生成/重新生成入口(仅参考选题脚本;原创选题无视频载体不支持)
|
||||
—— 数据源:/api/dsh/video-script + /api/dsh/video-detail(参考选题)/ /api/dsh/custom-script(原创选题)
|
||||
+ /api/dsh/review-full?rewriteId=(当前版本诊断)+ /api/dsh/storyboards?videoId=(分镜) */
|
||||
const DIAG_DIMS = ['创意看点', '设定承接', '框架节奏', '爆款开场', '叙事逻辑', '极致事件', '格式规范性', '台词口语化', '内容合规', '教程完整链', '其他'];
|
||||
/* R46/R47 维度术语改名(09-03):钩子设置→爆款开场、共鸣和共情→创意和爆点(R46,排设定承接前)→创意看点(R47)。
|
||||
@@ -1899,7 +1952,7 @@ async function openScriptModal({ rewriteId, videoId } = {}) {
|
||||
rewrites = (vs && vs.rewrites) || [];
|
||||
source = (vs && vs.source) || null;
|
||||
if (vd && vd.video) { metaTitle = vd.video.video_title || ''; metaAcc = vd.video.account_name || ''; }
|
||||
} else if (rewriteId) { /* 自定义选题(video_id 空):单条脚本;custom-script 拒绝则回退全量表定位(该脚本实际挂在视频下) */
|
||||
} else if (rewriteId) { /* 原创选题(video_id 空):单条脚本;custom-script 拒绝则回退全量表定位(该脚本实际挂在视频下) */
|
||||
const cs = await api('/api/dsh/custom-script?id=' + rewriteId).catch(() => ({ ok: false, error: 1 }));
|
||||
if (cs && !cs.error && cs.script) {
|
||||
rewrites = [{ id: cs.script.id, script_text: cs.script.script_text, created_time: cs.script.created_time }];
|
||||
@@ -1936,7 +1989,7 @@ async function openScriptModal({ rewriteId, videoId } = {}) {
|
||||
confirmRun({ title: hasSource ? '复盘评分' : 'AI脚本复盘', desc, prompt, label: '复盘指令', skills: ['脚本复盘'], btn });
|
||||
} finally { delete btn.dataset.lock; }
|
||||
};
|
||||
/* 生成 / 重新生成分镜(仅参考选题脚本;自定义选题无视频载体不提供) */
|
||||
/* 生成 / 重新生成分镜(仅参考选题脚本;原创选题无视频载体不提供) */
|
||||
const fireGenSb = (btn, regenerate) => {
|
||||
confirmRun({
|
||||
title: regenerate ? '重新生成分镜' : '生成分镜',
|
||||
@@ -1997,7 +2050,7 @@ async function openScriptModal({ rewriteId, videoId } = {}) {
|
||||
const aiSecHtml = (c) => `<div class="rw-sec-head">AI脚本${srcToggleHtml()}${sideToggleHtml()}</div><div class="rw-sec-body md-body">${renderMarkdown(c.script_text || '(暂无内容)')}</div>
|
||||
<div class="rw-sec-foot">${delScriptBtnHtml(c)}${rewrites.length > 1 ? verCtrlInner('v', verIdx, rewrites.length, '版本', c.created_time) : ''}</div>`;
|
||||
/* R58:删除当前脚本(左下角浅红按钮)——同时删除其诊断复盘与分镜提示词;确认后执行 API,多版本就地移除当前版并刷新,单版本关弹窗并重建页面 */
|
||||
const delScriptBtnHtml = (c) => `<button class="btn btn-sm btn-del" data-del-script title="删除当前脚本(同时删除其诊断复盘与分镜提示词数据,不可恢复)">${svgIcon('trash', 13, 'currentColor')} 删除</button>`;
|
||||
const delScriptBtnHtml = (c) => `<button class="btn btn-sm btn-del" data-del-script title="删除当前脚本(同时删除其诊断复盘与分镜提示词数据,不可恢复)">${svgIcon('trash', 13, 'currentColor')}</button>`;
|
||||
const fireDel = async (btn) => {
|
||||
if (btn.dataset.lock) return; btn.dataset.lock = '1';
|
||||
try {
|
||||
@@ -2059,7 +2112,7 @@ async function openScriptModal({ rewriteId, videoId } = {}) {
|
||||
sideBody = '' /* R51:空态提示清空——不再显示「(暂无分镜提示词)」 */;
|
||||
sideFoot = `<div class="rw-sec-foot"><button class="btn btn-sm btn-view" data-gen-sb>${svgIcon('zap', 14, 'currentColor')} 生成分镜</button></div>`;
|
||||
} else {
|
||||
sideBody = '<div class="diag-empty">该脚本为自定义选题(无关联视频),暂不支持分镜提示词</div>';
|
||||
sideBody = '<div class="diag-empty">该脚本为原创选题(无关联视频),暂不支持分镜提示词</div>';
|
||||
sideFoot = '';
|
||||
}
|
||||
return { sideBody, sideFoot };
|
||||
|
||||
@@ -117,9 +117,9 @@
|
||||
},
|
||||
{
|
||||
key: 'rewrites', icon: 'pen', title: 'AI写脚本', route: '#/rewrites',
|
||||
desc: '脚本中心:参考选题创作(有原视频)/ 自定义选题创作(无原视频)/ ABC 融合创作',
|
||||
desc: '脚本中心:参考选题创作(有原视频)/ 原创选题创作(无原视频)/ ABC 融合创作',
|
||||
steps: [
|
||||
{ sel: '.rw-tabs [data-tab="custom"]', mode: 'click', title: '自定义选题创作', text: 'AI写脚本 分三种创作入口,先看「自定义选题创作」:无原视频的脚本(来自账号列表/详情 AI创作弹窗)都收在这里,带徽标计数。' + '\n' + '👉 点「自定义选题创作」tab。' },
|
||||
{ sel: '.rw-tabs [data-tab="custom"]', mode: 'click', title: '原创选题创作', text: 'AI写脚本 分三种创作入口,先看「原创选题创作」:无原视频的脚本(来自账号列表/详情 AI创作弹窗)都收在这里,带徽标计数。' + '\n' + '👉 点「原创选题创作」tab。' },
|
||||
{ sel: '.rw-tabs [data-tab="ref"]', mode: 'click', title: '参考选题创作', text: '「参考选题创作」:以库中视频为选题来源(关注账号优先、按发布时间倒序),每行视频可「AI创作」新脚本,完成后自动挂到该视频下。' + '\n' + '👉 点回「参考选题创作」。' },
|
||||
{ sel: '#rwSearch', mode: 'input', emptyOk: true, title: '搜索视频', text: '按视频标题 / 账号名搜索候选视频,配时间范围与「已解析/未解析」状态过滤。' + '\n' + '👉 输入关键词回车试试,或直接下一步。' },
|
||||
{ sel: '.rw-tabs [data-tab="abc"]', mode: 'info', title: 'ABC 融合创作', text: '「ABC融合创作」:将同账号多条视频的爆款元素融合成一条全新脚本(功能开发中,暂为占位说明页)。' },
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>短视频工作台</title>
|
||||
<link rel="stylesheet" href="style.css?v=20260904h">
|
||||
<link rel="stylesheet" href="style.css?v=20260904i">
|
||||
<script src="vendor/echarts.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -41,8 +41,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js?v=20260904f"></script>
|
||||
<script src="help-guide.js?v=20260904d"></script>
|
||||
<script src="data-pages.js?v=20260904l"></script>
|
||||
<script src="app.js?v=20260904i"></script>
|
||||
<script src="help-guide.js?v=20260904e"></script>
|
||||
<script src="data-pages.js?v=20260904u"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -520,8 +520,8 @@ td.td-title { max-width: 240px; }
|
||||
把 4 列轨道撑成等宽 232.5px、每格右侧大片留白——故将其移出共享网格(见 .account-info > .info-item),8→7 字段列轨贴内容收缩,
|
||||
值与值之间视觉距离恢复为「内容宽+80px」,与视频详情属性区观感一致 */
|
||||
.account-info .account-grid { display: grid; grid-template-columns: repeat(4, minmax(min-content, max-content)); grid-template-areas: 'a b c .' 'd e f g'; gap: 10px 0; justify-content: start; }
|
||||
.account-info .account-grid .info-item { width: 140px; }
|
||||
/* 账号信息属性内容统一宽度(09-03 R48 终版):所有属性值固定 width 140px,四列严格等宽;超宽单行省略号 + hover 看全量;
|
||||
.account-info .account-grid .info-item { width: 180px; }
|
||||
/* 账号信息属性内容统一宽度(09-03 R48 终版,09-04 宽度 140→180):所有属性值固定 width 180px,四列严格等宽;超宽单行省略号 + hover 看全量;
|
||||
横向间隔 0(用户定:内容统一宽度即可,不要其他间隔) */
|
||||
.account-info .account-grid .info-item .info-value { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
/* 账号定位整行(R45):作为网格容器的直接子项平级输出,占满卡片宽度,与网格保持纵向间距 */
|
||||
@@ -535,10 +535,10 @@ td.td-title { max-width: 240px; }
|
||||
/* 视频详情信息卡:标题独立一行(大字号),下方按行分组 */
|
||||
/* 视频详情字段对齐(09-02 终版):8 字段同处一个 grid 容器(2 行 × 4 列),列轨道跨行共享——第 i 列严格对齐(分享↔时长、收藏↔发布时间);列宽贴合内容(min-content~max-content,非均分);横向间隔 80px 与账号信息一致。之前用两个独立 .info-row 容器导致跨行不对齐 */
|
||||
.video-info .video-grid { display: grid; grid-template-columns: repeat(4, minmax(min-content, max-content)); gap: 10px 0; justify-content: start; }
|
||||
.video-info .video-grid .info-item { width: 140px; }
|
||||
.video-info .video-grid .info-item { width: 180px; }
|
||||
.video-info .video-grid .info-item.info-wide { grid-column: 1 / -1; width: auto; }
|
||||
/* 视频详情属性内容统一宽度(09-03 R48):与账号详情同款——8 字段固定 width 140px、横向间隔 0;超宽单行省略号 + hover 看全量;
|
||||
标签(info-wide)占整行不受限宽保持换行;原视频ID vid-limited 独立 max-width 220 已被统一 140 覆盖,移除冗余规则 */
|
||||
/* 视频详情属性内容统一宽度(09-03 R48,09-04 宽度 140→180):与账号详情同款——8 字段固定 width 180px、横向间隔 0;超宽单行省略号 + hover 看全量;
|
||||
标签(info-wide)占整行不受限宽保持换行;原视频ID vid-limited 独立 max-width 220 已被统一 180 覆盖,移除冗余规则 */
|
||||
.video-info .video-grid .info-item:not(.info-wide) .info-value { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* ================= R59 视频关键信息卡(拆解 tab 默认视图,卡/文切换) ================= */
|
||||
@@ -694,7 +694,7 @@ td.td-title { max-width: 240px; }
|
||||
.cb-cell { width: 36px; text-align: center; }
|
||||
.cb-cell input { width: 15px; height: 15px; cursor: pointer; vertical-align: middle; accent-color: var(--primary); }
|
||||
|
||||
/* ---- AI写脚本 三 tab(09-02 v25:参考选题创作 / 自定义选题创作 / ABC融合创作) ---- */
|
||||
/* ---- AI写脚本 三 tab(09-02 v25:参考选题创作 / 原创选题创作 / ABC融合创作) ---- */
|
||||
/* 标题行(返回|标题)下方独立一行:左=tab 切换,右=搜索/时间/状态筛选(tab 与筛选同一行,均不与标题/返回同行) */
|
||||
.rw-tabs { display: flex; align-items: center; gap: 2px; border-bottom: 1px solid var(--border); margin: -12px 0 12px; padding: 0 4px; }
|
||||
.rw-tabs .tab { font-size: 15px; padding: 9px 14px; }
|
||||
@@ -711,7 +711,7 @@ td.td-title { max-width: 240px; }
|
||||
.rw-chip > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rw-chip button { border: none; background: none; color: inherit; cursor: pointer; font-size: 15px; line-height: 1; padding: 0 3px; border-radius: 50%; }
|
||||
.rw-chip button:hover { opacity: .7; }
|
||||
/* 自定义选题脚本 查看弹窗 */
|
||||
/* 原创选题脚本 查看弹窗 */
|
||||
.cw-modal { width: min(980px, calc(100% - 80px)); height: calc(100vh - 120px); display: flex; flex-direction: column; overflow: hidden; padding: 0; }
|
||||
.cw-modal .modal-title-row { flex: none; padding: 14px 18px; border-bottom: 1px solid var(--border); margin: 0; }
|
||||
.cw-meta { flex: none; padding: 8px 18px; border-bottom: 1px dashed var(--border); color: var(--dim); font-size: 13px; }
|
||||
|
||||
@@ -288,7 +288,10 @@ function wbDb() {
|
||||
|
||||
// 创建一次性后台任务(08-31 实测约定:scheduled_at 与 next_run_at 均取 +5s 未来,调度器按 next_run_at 扫描拾取;
|
||||
// 模型跟随用户最近活跃手动会话选择,兜底 'auto')——逻辑与 /api/run 完全一致,供更新榜单等新入口复用
|
||||
function createOnceAutomation(name, prompt, skillsArr) {
|
||||
// 09-04 connectorIdsArr:任务会话要用的「用户连接器」configId 列表(如 ['custom-mcp:myai-mcp-production'])——
|
||||
// 宿主会话 register(connectorIds) 只注入列出的连接器,空数组=不注入任何连接器(后台任务「找不到 MCP」根因),
|
||||
// 依赖 MCP 的任务(视频解析等)必须显式挂载;纯脚本类任务(榜单更新)保持 [] 即可
|
||||
function createOnceAutomation(name, prompt, skillsArr, connectorIdsArr) {
|
||||
const db = wbDb();
|
||||
try {
|
||||
const now = Date.now();
|
||||
@@ -304,7 +307,7 @@ function createOnceAutomation(name, prompt, skillsArr) {
|
||||
const modelId = (modelRow && modelRow.model) ? modelRow.model : 'auto';
|
||||
db.prepare(`INSERT INTO automations (id,name,prompt,status,schedule_type,scheduled_at,next_run_at,rrule,cwds,created_at,updated_at,skills_json,connector_ids_json,model_id,permission_mode,owner_user_id,owner_status)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||
.run(id, name, prompt, 'ACTIVE', 'once', scheduledAt, nextRunAt, '', JSON.stringify([cwd]), now, now, JSON.stringify(Array.isArray(skillsArr) ? skillsArr : []), '[]', modelId, 'fullAccess', ownerUserId, 'confirmed');
|
||||
.run(id, name, prompt, 'ACTIVE', 'once', scheduledAt, nextRunAt, '', JSON.stringify([cwd]), now, now, JSON.stringify(Array.isArray(skillsArr) ? skillsArr : []), JSON.stringify(Array.isArray(connectorIdsArr) ? connectorIdsArr : []), modelId, 'fullAccess', ownerUserId, 'confirmed');
|
||||
return { id };
|
||||
} finally {
|
||||
db.close();
|
||||
@@ -553,20 +556,20 @@ const server = http.createServer((req, res) => {
|
||||
if (dshGuard()) return;
|
||||
return dshOk(200, dsh.listRewrites(qInt('videoId', 0)));
|
||||
}
|
||||
if (p === '/api/dsh/custom-scripts') { /* 09-02 v25:自定义选题(无视频载体)脚本列表 */
|
||||
if (p === '/api/dsh/custom-scripts') { /* 09-02 v25:原创选题(无视频载体)脚本列表 */
|
||||
if (dshGuard()) return;
|
||||
return dshOk(200, dsh.listCustomScripts({ page: qInt('page', 1), pageSize: qInt('pageSize', 20), search: String(q.get('search') || ''), days: String(q.get('days') || ''), account: String(q.get('account') || '') }));
|
||||
}
|
||||
if (p === '/api/dsh/rewrite-stats') { /* 09-02 v25:AI写脚本 tab 徽标计数(参考选题/自定义选题),可传 account 按账号统计 */
|
||||
if (p === '/api/dsh/rewrite-stats') { /* 09-02 v25:AI写脚本 tab 徽标计数(参考选题/原创选题),可传 account 按账号统计 */
|
||||
if (dshGuard()) return;
|
||||
return dshOk(200, dsh.getRewriteStats(String(q.get('account') || '')));
|
||||
}
|
||||
if (p === '/api/dsh/custom-script') { /* 自定义选题脚本全文(查看弹窗) */
|
||||
if (p === '/api/dsh/custom-script') { /* 原创选题脚本全文(查看弹窗) */
|
||||
if (dshGuard()) return;
|
||||
try { return dshOk(200, { script: dsh.getCustomScript(qInt('id', 0)) }); }
|
||||
catch (e) { return sendErr(404, e.message); }
|
||||
}
|
||||
if (p === '/api/dsh/script-save' && req.method === 'POST') { /* AI创作任务完成回调写库(参考选题传 videoId;自定义选题省略→video_id 空) */
|
||||
if (p === '/api/dsh/script-save' && req.method === 'POST') { /* AI创作任务完成回调写库(参考选题传 videoId;原创选题省略→video_id 空) */
|
||||
if (dshGuard()) return;
|
||||
let body = '';
|
||||
req.on('data', (c) => { body += c; if (body.length > 2e6) req.destroy(); });
|
||||
@@ -654,8 +657,14 @@ const server = http.createServer((req, res) => {
|
||||
req.on('data', (c) => { body += c; if (body.length > 1e6) req.destroy(); });
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const { prompt, name, skills } = JSON.parse(body || '{}');
|
||||
const { prompt, name, skills, connectorIds } = JSON.parse(body || '{}');
|
||||
if (!prompt || typeof prompt !== 'string' || !prompt.trim()) return sendErr(400, 'prompt 不能为空');
|
||||
// 09-04 连接器级挂载:connector_ids_json 填「用户连接器」configId 数组(如 custom-mcp:myai-mcp-production)。
|
||||
// 宿主会话 register(connectorIds) 白名单注入;空数组=不注入任何连接器 → 依赖 MCP 的任务会「找不到 MCP」。
|
||||
// 仅放行合法 configId 形态(connector:/custom-mcp:/裸名),其余丢弃
|
||||
const connArr = Array.isArray(connectorIds)
|
||||
? connectorIds.filter((c) => typeof c === 'string' && /^[A-Za-z0-9:_-]{1,120}$/.test(c.trim())).map((c) => c.trim())
|
||||
: [];
|
||||
// 09-01 参数级技能挂载:skills_json 是 automations 表原生字段(default '[]'),
|
||||
// 客户端调度器创建会话时按该字段挂载技能;前端传技能名数组(如 ['短视频工作台'])即完成任务级技能绑定
|
||||
// 09-03 模型跟随用户选择:原 model_id 钉死 'deepseek-v4-flash'(绕过用户模型选择,全任务跑 flash)。
|
||||
@@ -696,7 +705,7 @@ const server = http.createServer((req, res) => {
|
||||
const modelId = (modelRow && modelRow.model) ? modelRow.model : 'auto';
|
||||
db.prepare(`INSERT INTO automations (id,name,prompt,status,schedule_type,scheduled_at,next_run_at,rrule,cwds,created_at,updated_at,skills_json,connector_ids_json,model_id,permission_mode,owner_user_id,owner_status)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||
.run(id, name || '工作台任务', finalPrompt, 'ACTIVE', 'once', scheduledAt, nextRunAt, '', JSON.stringify([cwd]), now, now, JSON.stringify(skillsArr), '[]', modelId, 'fullAccess', ownerUserId, 'confirmed');
|
||||
.run(id, name || '工作台任务', finalPrompt, 'ACTIVE', 'once', scheduledAt, nextRunAt, '', JSON.stringify([cwd]), now, now, JSON.stringify(skillsArr), JSON.stringify(connArr), modelId, 'fullAccess', ownerUserId, 'confirmed');
|
||||
db.close();
|
||||
log('已提交 AI 任务: ' + id + (name ? ' (' + name + ')' : ''));
|
||||
return sendJSON(200, { ok: true, id, message: '任务已提交,请在左侧会话栏查看执行' });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@echo off
|
||||
rem MCN工作台 启动脚本(Windows 双击运行)
|
||||
rem 优先使用 WorkBuddy managed node,回退系统 node
|
||||
rem MCN工作台 启动脚本(Windows 双击运行)
|
||||
rem 优先使用 WorkBuddy managed node,回退系统 node
|
||||
|
||||
setlocal
|
||||
cd /d "%~dp0"
|
||||
@@ -14,12 +14,12 @@ if %errorlevel%==0 (
|
||||
goto run
|
||||
)
|
||||
|
||||
echo [错误] 未找到 Node.js,请安装 Node 或配置 managed node 路径
|
||||
echo [错误] 未找到 Node.js,请安装 Node 或配置 managed node 路径
|
||||
pause
|
||||
exit /b 1
|
||||
|
||||
:run
|
||||
echo 正在启动 MCN工作台...
|
||||
echo 正在启动 MCN工作台...
|
||||
start "" "%NODE_EXE%" server.js 8900
|
||||
timeout /t 1 /nobreak >nul
|
||||
start http://localhost:8900
|
||||
|
||||
Reference in new issue
Block a user