V3.5 精细化运营升级:用量统计/接口库/团队/对话/工作目录/流式超时
1. 精细化统计:cost_records 新增 calls/cached_tokens/latency_ms/first_token_ms;
用量明细报表(项目×智能体矩阵) + 成本报表细化(输入/输出/缓存命中/调用次数)
2. 从参考项目中新建:内置3个测试项目(文案/Python/调研),一键复制目标+任务
3. 大模型接口库(llm_endpoints):专门配置接口(地址/密钥/模型/定价),
计费支持按token(逐模型)与按调用次数;创建AI Worker直接选用;
AI Worker团队(worker_teams):打包Worker,对话/建项目可直接选团队
4. 对话导航融合仪表盘:可选大模型/AI Worker/团队,默认主力AI Worker(⭐可设),SSE流式
5. 系统工作目录:默认data/workspace可改绝对路径;项目与多Agent协作均在其下
建唯一工作目录;手动输入目录已存在则列出信息并需手动确认
6. 任务执行超时改为流式单token返回超时+首字延迟超时,设置页可配;
所有模型输出SSE按token接收
This commit is contained in:
+752
-63
@@ -214,18 +214,280 @@ function router() {
|
||||
}
|
||||
window.addEventListener('hashchange', router);
|
||||
|
||||
/* ---------- 仪表盘 ---------- */
|
||||
/* ---------- 💬 对话(融合在仪表盘顶部)V3.5 ---------- */
|
||||
let chatCtx = {options: null, sessions: [], sid: null, ttype: 'worker', tid: null, model: '', busy: false, msgs: []};
|
||||
|
||||
function chatTargetLabel() {
|
||||
const c = chatCtx;
|
||||
if (c.ttype === 'model') {
|
||||
const e = (c.options?.endpoints || []).find(x => String(x.id) === String(c.tid));
|
||||
return '接口「' + (e?.name || '#' + c.tid) + '」/' + (c.model || '模型');
|
||||
}
|
||||
if (c.ttype === 'team') {
|
||||
const t = (c.options?.teams || []).find(x => String(x.id) === String(c.tid));
|
||||
return '团队「' + (t?.name || '#' + c.tid) + '」';
|
||||
}
|
||||
const w = (c.options?.workers || []).find(x => String(x.id) === String(c.tid));
|
||||
return (w?.name || 'AI Worker') + (c.tid === c.options?.main_worker_id ? ' ⭐主力' : '');
|
||||
}
|
||||
|
||||
function chatPanelHtml() {
|
||||
const c = chatCtx;
|
||||
const eps = (c.options?.endpoints || []).map(e => `<option value="${e.id}" data-models="${esc(JSON.stringify(e.models || []))}">${esc(e.name)}</option>`).join('') || '<option value="">(无可用接口)</option>';
|
||||
const ws = (c.options?.workers || []).map(w => `<option value="${w.id}" ${String(w.id) === String(c.tid) ? 'selected' : ''}>${esc(w.name)}${String(w.id) === c.options?.main_worker_id ? ' ⭐主力' : ''}</option>`).join('') || '<option value="">(无 Worker)</option>';
|
||||
const ts = (c.options?.teams || []).map(t => `<option value="${t.id}">${esc(t.name)}(${t.worker_count} 人)</option>`).join('') || '<option value="">(无团队)</option>';
|
||||
const sessOpts = (c.sessions || []).map(s => `<option value="${s.id}" ${String(s.id) === String(c.sid) ? 'selected' : ''}>${esc(s.title || ('会话#' + s.id))}</option>`).join('');
|
||||
return `
|
||||
<div class="card chat-card">
|
||||
<div class="chat-top">
|
||||
<div class="chat-title">💬 对话 <small style="color:var(--muted)">可选 大模型 / AI Worker / 团队 · 输出按 token 流式 · 默认主力 AI Worker</small></div>
|
||||
<div class="chat-sess">
|
||||
<select id="chat-session" onchange="chatSwitchSession()" title="切换历史会话"><option value="">— 新会话 —</option>${sessOpts}</select>
|
||||
<button class="btn sm" onclick="chatNewSession()" title="新建会话">+</button>
|
||||
${c.sid ? `<button class="btn sm danger" onclick="chatDelSession()" title="删除当前会话">🗑</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-target">
|
||||
<select id="chat-ttype" onchange="chatChangeType()">
|
||||
<option value="worker" ${c.ttype === 'worker' ? 'selected' : ''}>🤖 AI Worker</option>
|
||||
<option value="model" ${c.ttype === 'model' ? 'selected' : ''}>🔌 大模型接口</option>
|
||||
<option value="team" ${c.ttype === 'team' ? 'selected' : ''}>👥 团队</option>
|
||||
</select>
|
||||
<select id="chat-worker" style="${c.ttype === 'worker' ? '' : 'display:none'}" onchange="chatCtx.tid=Number(this.value);chatRenderTarget()">${ws}</select>
|
||||
<select id="chat-endpoint" style="${c.ttype === 'model' ? '' : 'display:none'}" onchange="chatEndpointChange()">${eps}</select>
|
||||
<select id="chat-model" style="${c.ttype === 'model' ? '' : 'display:none'}"></select>
|
||||
<select id="chat-team" style="${c.ttype === 'team' ? '' : 'display:none'}" onchange="chatCtx.tid=Number(this.value);chatRenderTarget()">${ts}</select>
|
||||
<span class="chat-target-label" id="chat-target-label">${esc(chatTargetLabel())}</span>
|
||||
</div>
|
||||
<div class="chat-body" id="chat-body"><div class="empty">👆 选择对话目标,开始输入吧(新会话需先发送第一条消息)</div></div>
|
||||
<div class="chat-input">
|
||||
<textarea id="chat-input" rows="2" placeholder="输入你的问题,Enter 发送 / Shift+Enter 换行" onkeydown="chatKeydown(event)"></textarea>
|
||||
<button class="btn primary" id="chat-send" onclick="chatSend()">发送 🚀</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function chatRenderTarget() {
|
||||
const c = chatCtx;
|
||||
const lbl = $('#chat-target-label');
|
||||
if (lbl) lbl.textContent = chatTargetLabel();
|
||||
}
|
||||
|
||||
async function chatInit() {
|
||||
try {
|
||||
const [opt, sess] = await Promise.all([api('/api/chat/options'), api('/api/chat/sessions')]);
|
||||
chatCtx.options = opt.data;
|
||||
chatCtx.sessions = sess.data;
|
||||
// 默认:主力 AI Worker
|
||||
if (!chatCtx.tid) {
|
||||
chatCtx.ttype = 'worker';
|
||||
chatCtx.tid = chatCtx.options.main_worker_id || (chatCtx.options.workers[0]?.id) || null;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function chatNewSession() {
|
||||
if (!chatCtx.tid) return toast('请先选择对话目标', 'err');
|
||||
try {
|
||||
const r = await api('/api/chat/sessions', {method: 'POST', body: {
|
||||
target_type: chatCtx.ttype, target_id: chatCtx.tid,
|
||||
model: chatCtx.ttype === 'model' ? chatCtx.model : ''
|
||||
}});
|
||||
chatCtx.sid = r.id;
|
||||
chatCtx.msgs = [];
|
||||
const sess = (await api('/api/chat/sessions')).data;
|
||||
chatCtx.sessions = sess;
|
||||
chatRenderMessages();
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
}
|
||||
|
||||
async function chatSwitchSession() {
|
||||
const v = Number($('#chat-session').value || 0);
|
||||
if (!v) { chatCtx.sid = null; chatCtx.msgs = []; chatRenderMessages(); return; }
|
||||
try {
|
||||
const d = (await api(`/api/chat/sessions/${v}`)).data;
|
||||
chatCtx.sid = v;
|
||||
chatCtx.ttype = d.session.target_type;
|
||||
chatCtx.tid = d.session.target_id;
|
||||
chatCtx.model = d.session.model || '';
|
||||
chatCtx.msgs = d.messages;
|
||||
// 同步选择器
|
||||
const tt = $('#chat-ttype'); if (tt) tt.value = d.session.target_type;
|
||||
chatSyncSelectors();
|
||||
chatRenderMessages();
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
}
|
||||
|
||||
async function chatDelSession() {
|
||||
if (!chatCtx.sid || !confirm('删除该会话及全部消息?')) return;
|
||||
await api(`/api/chat/sessions/${chatCtx.sid}`, {method: 'DELETE'});
|
||||
chatCtx.sid = null; chatCtx.msgs = [];
|
||||
chatCtx.sessions = (await api('/api/chat/sessions')).data;
|
||||
chatRenderMessages();
|
||||
}
|
||||
|
||||
function chatChangeType() {
|
||||
chatCtx.ttype = $('#chat-ttype').value;
|
||||
chatSyncSelectors();
|
||||
}
|
||||
|
||||
function chatSyncSelectors() {
|
||||
const c = chatCtx;
|
||||
const tt = $('#chat-ttype');
|
||||
$('#chat-worker').style.display = c.ttype === 'worker' ? '' : 'none';
|
||||
$('#chat-endpoint').style.display = c.ttype === 'model' ? '' : 'none';
|
||||
$('#chat-model').style.display = c.ttype === 'model' ? '' : 'none';
|
||||
$('#chat-team').style.display = c.ttype === 'team' ? '' : 'none';
|
||||
if (c.ttype === 'worker') {
|
||||
const w = $('#chat-worker');
|
||||
w.value = c.tid || '';
|
||||
if (!w.value && c.options?.workers.length) { w.value = c.options.main_worker_id || c.options.workers[0].id; c.tid = Number(w.value); }
|
||||
} else if (c.ttype === 'team') {
|
||||
const t = $('#chat-team');
|
||||
if (!t.value && c.options?.teams.length) { t.value = c.options.teams[0].id; c.tid = Number(t.value); }
|
||||
} else if (c.ttype === 'model') {
|
||||
const e = $('#chat-endpoint');
|
||||
if (!e.value && c.options?.endpoints.length) { e.value = c.options.endpoints[0].id; }
|
||||
chatEndpointChange();
|
||||
}
|
||||
chatRenderTarget();
|
||||
}
|
||||
|
||||
function chatEndpointChange() {
|
||||
const sel = $('#chat-endpoint');
|
||||
const opt = sel.selectedOptions[0];
|
||||
chatCtx.tid = Number(sel.value || 0);
|
||||
let models = [];
|
||||
try { models = JSON.parse(opt?.dataset?.models || '[]'); } catch (e) {}
|
||||
const m = $('#chat-model');
|
||||
m.innerHTML = models.map(x => `<option value="${esc(x)}" ${x === chatCtx.model ? 'selected' : ''}>${esc(x)}</option>`).join('');
|
||||
chatCtx.model = m.value || models[0] || '';
|
||||
chatRenderTarget();
|
||||
}
|
||||
|
||||
function chatRenderMessages() {
|
||||
const body = $('#chat-body');
|
||||
if (!body) return;
|
||||
const c = chatCtx;
|
||||
if (!c.sid) { body.innerHTML = '<div class="empty">👆 选择对话目标,点「发送」自动创建新会话</div>'; return; }
|
||||
if (!c.msgs.length) { body.innerHTML = '<div class="empty">新会话已建立,开始提问吧 ✍️</div>'; return; }
|
||||
body.innerHTML = c.msgs.map(m => {
|
||||
if (m.role === 'user') return `<div class="chat-msg user"><div class="chat-bubble user">${esc(m.content)}</div></div>`;
|
||||
if (m.error) return `<div class="chat-msg ai"><div class="chat-bubble ai err"><b style="color:var(--danger)">❌ 出错了:</b>${esc(m.error)}</div>${m.content ? `<div class="chat-bubble ai">${esc(m.content)}</div>` : ''}</div>`;
|
||||
return `<div class="chat-msg ai"><div class="chat-bubble ai">${esc(m.content)}</div>` +
|
||||
`<div class="chat-usage">⚡ ${m.total_tokens ?? (m.prompt_tokens + m.completion_tokens)} tokens · 输入 ${m.prompt_tokens} / 输出 ${m.completion_tokens}${m.cached_tokens ? ' / 缓存命中 ' + m.cached_tokens : ''} · ${fmtMoney(m.cost)} · 首字 ${m.first_token_ms || '—'}ms · ${m.latency_ms || '—'}ms · ${esc(m.model || '')}</div></div>`;
|
||||
}).join('');
|
||||
body.scrollTop = body.scrollHeight;
|
||||
}
|
||||
|
||||
function chatKeydown(e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); chatSend(); }
|
||||
}
|
||||
|
||||
async function chatSend() {
|
||||
const input = $('#chat-input');
|
||||
const content = (input.value || '').trim();
|
||||
if (!content || chatCtx.busy) return;
|
||||
if (!chatCtx.tid) return toast('请先选择对话目标', 'err');
|
||||
chatCtx.busy = true;
|
||||
const sendBtn = $('#chat-send'); if (sendBtn) sendBtn.disabled = true;
|
||||
// 无会话则先创建
|
||||
if (!chatCtx.sid) {
|
||||
try {
|
||||
const r = await api('/api/chat/sessions', {method: 'POST', body: {
|
||||
target_type: chatCtx.ttype, target_id: chatCtx.tid,
|
||||
model: chatCtx.ttype === 'model' ? chatCtx.model : ''
|
||||
}});
|
||||
chatCtx.sid = r.id;
|
||||
chatCtx.msgs = [];
|
||||
chatCtx.sessions = (await api('/api/chat/sessions')).data;
|
||||
} catch (e) { chatCtx.busy = false; if (sendBtn) sendBtn.disabled = false; return toast(e.message, 'err'); }
|
||||
}
|
||||
// 渲染用户消息 + AI 占位
|
||||
const body = $('#chat-body');
|
||||
const bubble = document.createElement('div');
|
||||
const msgWrap = document.createElement('div');
|
||||
msgWrap.className = 'chat-msg ai';
|
||||
bubble.className = 'chat-bubble ai';
|
||||
bubble.textContent = '';
|
||||
msgWrap.appendChild(bubble);
|
||||
const usage = document.createElement('div');
|
||||
usage.className = 'chat-usage';
|
||||
usage.textContent = '⏳ 等待首字…';
|
||||
msgWrap.appendChild(usage);
|
||||
const userWrap = document.createElement('div');
|
||||
userWrap.className = 'chat-msg user';
|
||||
const userBubble = document.createElement('div');
|
||||
userBubble.className = 'chat-bubble user';
|
||||
userBubble.textContent = content;
|
||||
userWrap.appendChild(userBubble);
|
||||
if (chatCtx.msgs.length === 0 && body) body.innerHTML = '';
|
||||
body.appendChild(userWrap);
|
||||
body.appendChild(msgWrap);
|
||||
body.scrollTop = body.scrollHeight;
|
||||
input.value = '';
|
||||
let acc = '';
|
||||
try {
|
||||
const res = await fetch(`/api/chat/sessions/${chatCtx.sid}/messages`, {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({content})
|
||||
});
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
const reader = res.body.getReader();
|
||||
const dec = new TextDecoder();
|
||||
let buf = '';
|
||||
let done_usage = null;
|
||||
while (true) {
|
||||
const {done, value} = await reader.read();
|
||||
if (done) break;
|
||||
buf += dec.decode(value, {stream: true});
|
||||
let idx;
|
||||
while ((idx = buf.indexOf('\n\n')) >= 0) {
|
||||
const chunk = buf.slice(0, idx);
|
||||
buf = buf.slice(idx + 2);
|
||||
const line = chunk.split('\n').find(l => l.startsWith('data: '));
|
||||
if (!line) continue;
|
||||
let evt;
|
||||
try { evt = JSON.parse(line.slice(6)); } catch (e) { continue; }
|
||||
if (evt.type === 'delta') {
|
||||
acc += evt.content;
|
||||
bubble.textContent = acc;
|
||||
body.scrollTop = body.scrollHeight;
|
||||
} else if (evt.type === 'done') {
|
||||
done_usage = evt.usage;
|
||||
} else if (evt.type === 'error') {
|
||||
throw new Error(evt.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (done_usage) {
|
||||
usage.textContent = `⚡ ${done_usage.total_tokens} tokens · 输入 ${done_usage.prompt_tokens} / 输出 ${done_usage.completion_tokens}${done_usage.cached_tokens ? ' / 缓存命中 ' + done_usage.cached_tokens : ''} · ${fmtMoney(done_usage.cost)} · 首字 ${done_usage.first_token_ms || '—'}ms · 总 ${done_usage.elapsed_ms}ms · ${esc(done_usage.model)}`;
|
||||
}
|
||||
// 刷新会话消息列表(含用户消息 + 助手消息)
|
||||
const d = (await api(`/api/chat/sessions/${chatCtx.sid}`)).data;
|
||||
chatCtx.msgs = d.messages;
|
||||
} catch (e) {
|
||||
usage.textContent = '❌ ' + e.message;
|
||||
} finally {
|
||||
chatCtx.busy = false;
|
||||
if (sendBtn) sendBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 仪表盘(含对话) ---------- */
|
||||
async function pageDashboard() {
|
||||
await chatInit();
|
||||
const s = (await api('/api/stats')).data;
|
||||
const total = Object.values(s.by_status).reduce((a, b) => a + b, 0);
|
||||
const pct = n => total ? Math.round(n / total * 100) : 0;
|
||||
$('#main').innerHTML = `
|
||||
<h1 class="page-title">仪表盘<small>AI 虚拟团队运营总览</small></h1>
|
||||
${chatPanelHtml()}
|
||||
<h1 class="page-title" style="margin-top:20px">运营总览<small>项目 · 任务 · AI Worker · 用量</small></h1>
|
||||
<div class="grid g4">
|
||||
<div class="stat"><div class="lbl">项目</div><div class="num">${s.projects}</div><div class="sub">进行中 ${s.by_status.done ?? 0} 个已完成任务</div></div>
|
||||
<div class="stat"><div class="lbl">任务总数</div><div class="num">${s.tasks}</div><div class="sub">完成率 ${pct(s.by_status.done ?? 0)}% · 待审核 ${s.by_status.review ?? 0}</div></div>
|
||||
<div class="stat"><div class="lbl">AI Worker</div><div class="num">${s.workers}</div><div class="sub">虚拟员工档案数</div></div>
|
||||
<div class="stat"><div class="lbl">累计成本</div><div class="num">${fmtMoney(s.total_cost)}</div><div class="sub">${(s.total_tokens/1e6).toFixed(2)}M tokens 消耗</div></div>
|
||||
<div class="stat"><div class="lbl">累计成本</div><div class="num">${fmtMoney(s.total_cost)}</div><div class="sub">${(s.total_tokens/1e6).toFixed(2)}M tokens · ${s.total_calls ?? 0} 次调用 · 缓存命中 ${(s.total_cached ?? 0)/1e6 >= 0.001 ? (s.total_cached/1e6).toFixed(2) + 'M' : (s.total_cached ?? 0)} tokens</div></div>
|
||||
</div>
|
||||
<div class="grid g3" style="margin-top:14px">
|
||||
<div class="card">
|
||||
@@ -234,6 +496,7 @@ async function pageDashboard() {
|
||||
<dt>一次通过率</dt><dd><b style="color:var(--accent2)">${s.one_pass_rate ?? '—'}%</b>(验收一次通过)</dd>
|
||||
<dt>返工次数</dt><dd>${s.rework_count} 次打回</dd>
|
||||
<dt>执行失败</dt><dd>${s.by_status.failed ?? 0} 个任务</dd>
|
||||
<dt>平均首字延迟</dt><dd class="mono">${s.avg_first_token_ms ? s.avg_first_token_ms + ' ms' : '—'}</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
@@ -271,7 +534,10 @@ async function pageProjects() {
|
||||
const statusCls = s => s === 'active' ? 'running' : s === 'review' ? 'review' : s === 'done' ? 'done' : s === 'archived' ? 'cancelled' : 'todo';
|
||||
$('#main').innerHTML = `
|
||||
<h1 class="page-title">项目<small>立项 → 规划 → 执行 → 验收 → 归档</small></h1>
|
||||
<div class="toolbar"><button class="btn primary" onclick="openProjectModal()">+ 新建项目</button></div>
|
||||
<div class="toolbar">
|
||||
<button class="btn primary" onclick="openProjectModal()">+ 新建项目</button>
|
||||
<button class="btn" onclick="openRefProjectModal()">📋 从参考项目中新建</button>
|
||||
</div>
|
||||
<div class="filter-bar">
|
||||
<div class="fgroup"><span class="flbl">状态</span>
|
||||
<button class="fbtn ${projFilter === 'all' ? 'on' : ''}" onclick="projFilter='all';pageProjects()">全部 ${list.length}</button>
|
||||
@@ -323,6 +589,7 @@ async function delProject(pid) {
|
||||
function openProjectModal(p = {}) {
|
||||
const usersP = api('/api/users');
|
||||
const workersP = api('/api/workers');
|
||||
const teamsP = api('/api/teams');
|
||||
openModal(`
|
||||
<h3>${p.id ? '编辑项目' : '新建项目'}<button class="close" data-close>×</button></h3>
|
||||
<label>项目名称 *</label><input id="pm-name" value="${esc(p.name || '')}" placeholder="例如:营销内容批量生产">
|
||||
@@ -345,10 +612,21 @@ function openProjectModal(p = {}) {
|
||||
</select></div>
|
||||
</div>
|
||||
<label>交付说明</label><input id="pm-dnote" value="${esc(p.deliver_note || '')}" placeholder="给送达者的交付说明(可选)">
|
||||
<div class="sec-divider">🗂️ 工作目录(V3.5)<small style="color:var(--muted);font-weight:400">默认系统工作目录下 project_<id>(自动无重复);可手动指定</small></div>
|
||||
<div class="row" style="align-items:end">
|
||||
<div style="flex:3"><label>自定义工作目录(相对系统工作目录名,或绝对路径;留空=自动)</label>
|
||||
<input id="pm-wsdir" value="${esc(p.workspace_dir || '')}" placeholder="如:my_project_a / /data/custom_dir"></div>
|
||||
<button class="btn" onclick="probeWsDir()" type="button">🔍 检查</button>
|
||||
</div>
|
||||
<div id="pm-wsinfo" style="display:none;margin-top:8px"></div>
|
||||
<label style="display:none;align-items:center;gap:6px;font-weight:normal" id="pm-wsconfirm-wrap"><input type="checkbox" id="pm-wsconfirm"> 目录已存在,我确认继续使用该目录</label>
|
||||
<div class="sec-divider">🤖 AI 虚拟团队(创建后自动开工)</div>
|
||||
<label>👔 AI 主管(管理者)* <small style="color:var(--muted)">负责拆解任务、派活、监控,默认「AI主管」</small></label>
|
||||
<select id="pm-mgr"><option value="">加载 Worker 中…</option></select>
|
||||
<label>👷 干活团队(AI 主管支配的 Worker,可多选)* <small style="color:var(--muted)">默认 AI牛 / AI马</small></label>
|
||||
<div class="row" style="align-items:center">
|
||||
<select id="pm-team-preset" style="max-width:260px" onchange="applyTeamPreset()"><option value="">📋 快捷选择团队(可选)</option></select>
|
||||
</div>
|
||||
<div id="pm-team" class="pick-list"><div class="empty" style="padding:8px">加载 Worker 中…</div></div>
|
||||
<div class="row">
|
||||
<label style="display:flex;align-items:center;gap:6px;font-weight:normal"><input type="checkbox" id="pm-auto-review"> 任务完成后需人工审核(默认不勾选 = AI 主管全自动跑到完工)</label>
|
||||
@@ -376,7 +654,7 @@ function openProjectModal(p = {}) {
|
||||
curMgr = (m || ws[0]).id;
|
||||
}
|
||||
$('#pm-mgr').innerHTML = '<option value="">— 请选择 AI 主管 —</option>' +
|
||||
ws.map(w => `<option value="${w.id}" ${String(w.id) === String(curMgr) ? 'selected' : ''}>${esc(w.name)}(${esc(w.provider)}/${esc(w.model)})</option>`).join('');
|
||||
ws.map(w => `<option value="${w.id}" ${String(w.id) === String(curMgr) ? 'selected' : ''}>${esc(w.name)}(${esc(w.provider)}/${esc(w.model)})${w.is_main ? ' ⭐主力' : ''}</option>`).join('');
|
||||
const curTeam = new Set((p.team_workers || []).map(w => w.id));
|
||||
let defaults = ws.filter(w => /牛|马/.test(w.name) && String(w.id) !== String(curMgr));
|
||||
if (!defaults.length) defaults = ws.filter(w => String(w.id) !== String(curMgr));
|
||||
@@ -384,6 +662,43 @@ function openProjectModal(p = {}) {
|
||||
<label class="pick-item"><input type="checkbox" value="${w.id}" ${curTeam.has(w.id) || (!p.id && defaults.includes(w)) ? 'checked' : ''}>
|
||||
<span>${esc(w.name)}</span><small>${esc(w.provider)}/${esc(w.model)}</small></label>`).join('');
|
||||
}).catch(() => {});
|
||||
teamsP.then(r => {
|
||||
const sel = $('#pm-team-preset');
|
||||
if (!sel) return;
|
||||
sel.innerHTML = '<option value="">📋 快捷选择团队(可选)</option>' +
|
||||
r.data.map(t => `<option value="${t.id}">👥 ${esc(t.name)}(${(t.enabled_workers || []).length} 人)</option>`).join('');
|
||||
}).catch(() => {});
|
||||
window.applyTeamPreset = () => {
|
||||
const tid = Number($('#pm-team-preset').value || 0);
|
||||
if (!tid) return;
|
||||
teamsP.then(r => {
|
||||
const t = r.data.find(x => x.id === tid);
|
||||
if (!t) return;
|
||||
const ids = new Set((t.enabled_workers || t.workers || []).map(w => w.id));
|
||||
$$('#pm-team input').forEach(cb => cb.checked = ids.has(Number(cb.value)));
|
||||
toast(`已按团队「${t.name}」勾选干活 Worker`, 'ok');
|
||||
});
|
||||
};
|
||||
window.probeWsDir = async () => {
|
||||
const path = $('#pm-wsdir').value.trim();
|
||||
const box = $('#pm-wsinfo');
|
||||
if (!path) { box.style.display = 'none'; return; }
|
||||
box.style.display = 'block';
|
||||
box.innerHTML = '<div class="tab-note"><span class="spin"></span> 检查中…</div>';
|
||||
try {
|
||||
const r = await api('/api/workspace/probe?path=' + encodeURIComponent(path));
|
||||
if (r.data.exists) {
|
||||
const d = r.data;
|
||||
box.innerHTML = `<div class="alert-row unread" style="margin:0"><div class="t">⚠️ 目录已存在:${esc(d.path)}</div>
|
||||
<div class="d">文件 ${d.files} 个 · 大小 ${(d.size/1024/1024).toFixed(2)} MB${d.newest_mtime ? ' · 最近修改 ' + fmtTime(d.newest_mtime) : ''}</div>
|
||||
<div class="d" style="max-height:120px;overflow:auto">${(d.sample || []).map(s => '<div>· ' + esc(s) + '</div>').join('')}</div></div>`;
|
||||
$('#pm-wsconfirm-wrap').style.display = 'flex';
|
||||
} else {
|
||||
box.innerHTML = `<div class="tab-note" style="color:var(--accent2)">✅ 目录不存在:${esc(r.data.path)},创建时将自动新建</div>`;
|
||||
$('#pm-wsconfirm-wrap').style.display = 'none';
|
||||
}
|
||||
} catch (e) { box.innerHTML = `<div class="err">${esc(e.message)}</div>`; }
|
||||
};
|
||||
$('#pm-save').addEventListener('click', async () => {
|
||||
const body = {
|
||||
name: $('#pm-name').value.trim(), objective: $('#pm-objective').value,
|
||||
@@ -394,8 +709,11 @@ function openProjectModal(p = {}) {
|
||||
deliver_note: $('#pm-dnote').value.trim(),
|
||||
manager_worker_id: Number($('#pm-mgr').value || 0),
|
||||
team_worker_ids: [...$$('#pm-team input:checked')].map(i => Number(i.value)),
|
||||
workspace_dir: $('#pm-wsdir').value.trim(),
|
||||
confirm_existing_dir: !!$('#pm-wsconfirm')?.checked,
|
||||
auto_review: !!$('#pm-auto-review').checked
|
||||
};
|
||||
if (p.from_reference) body.from_reference = p.from_reference;
|
||||
if (!body.name) return toast('请填写项目名称', 'err');
|
||||
if (!body.deliver_user_id) return toast('必填:选择送达者用户', 'err');
|
||||
if (!body.manager_worker_id) return toast('必填:选择 AI 主管 Worker', 'err');
|
||||
@@ -406,10 +724,45 @@ function openProjectModal(p = {}) {
|
||||
toast('已保存(新主管/团队将在下次开工时生效)', 'ok'); closeModal(); router();
|
||||
} else {
|
||||
const r = await api('/api/projects', {method: 'POST', body});
|
||||
toast('项目已创建,AI 主管已开工 🚀', 'ok'); closeModal();
|
||||
toast(`项目已创建,AI 主管已开工 🚀${r.copied_tasks ? `(从参考项目复制 ${r.copied_tasks} 个任务)` : ''}`, 'ok'); closeModal();
|
||||
location.hash = `#/project/${r.id}`;
|
||||
}
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
} catch (e) {
|
||||
if (e.message && e.message.includes('need_confirm') === false) toast(e.message, 'err');
|
||||
else if (e.message) toast(e.message, 'err');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* 从参考项目中新建(V3.5) */
|
||||
async function openRefProjectModal() {
|
||||
const r = await api('/api/reference_projects');
|
||||
const refs = r.data || [];
|
||||
if (!refs.length) { toast('暂无参考项目', 'err'); return; }
|
||||
openModal(`
|
||||
<h3>📋 从参考项目中新建<button class="close" data-close>×</button></h3>
|
||||
<div class="tab-note">选择下面的内置简单测试项目作为参考,将复制其目标与任务列表,快速生成一个新项目(仍可继续编辑)。</div>
|
||||
${refs.map(x => `
|
||||
<div class="card ref-card" style="margin-bottom:10px;cursor:pointer" onclick="openProjectModalFromRef(${x.id})">
|
||||
<div style="font-weight:600">${esc(x.name)} <span class="tag">${x.tasks.length} 个任务</span></div>
|
||||
<div style="color:var(--muted);font-size:12px;margin-top:4px">🎯 ${esc(x.objective)}</div>
|
||||
<div style="color:var(--muted);font-size:12px;margin-top:4px">✅ 验收标准:${esc(x.acceptance_criteria)}</div>
|
||||
<div style="color:var(--accent);font-size:12px;margin-top:6px">点击 → 以此参考新建项目</div>
|
||||
</div>`).join('')}
|
||||
<button class="btn" data-close>取消</button>`);
|
||||
}
|
||||
|
||||
async function openProjectModalFromRef(refId) {
|
||||
const d = (await api('/api/reference_projects')).data;
|
||||
const ref = d.find(x => x.id === refId);
|
||||
if (!ref) return;
|
||||
closeModal();
|
||||
openProjectModal({
|
||||
from_reference: ref.id,
|
||||
name: ref.name.replace('(参考)', '(副本)'),
|
||||
objective: ref.objective,
|
||||
acceptance_criteria: ref.acceptance_criteria,
|
||||
description: ref.description || ''
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1691,37 +2044,62 @@ async function deliverAction(action) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Worker 管理 ---------- */
|
||||
async function pageWorkers() {
|
||||
const [ws, ps] = await Promise.all([api('/api/workers'), api('/api/providers')]);
|
||||
/* ---------- Worker 管理(V3.5:AI Worker / 大模型接口库 / 团队) ---------- */
|
||||
let workersTab = 'list';
|
||||
|
||||
async function pageWorkers(args = []) {
|
||||
workersTab = args[0] || 'list';
|
||||
const [ws, ps, eps, teams] = await Promise.all([api('/api/workers'), api('/api/providers'), api('/api/endpoints'), api('/api/teams')]);
|
||||
const canManage = CURRENT_ROLE === 'admin';
|
||||
$('#main').innerHTML = `
|
||||
<h1 class="page-title">AI Worker 管理<small>模型 + 角色提示词 + 工具权限 + 成本上限 = 虚拟员工档案</small></h1>
|
||||
${canManage ? `<div class="toolbar"><button class="btn primary" onclick="openWorkerModal(null, ${JSON.stringify(ps.data).replace(/"/g,'"')})">+ 注册 Worker</button></div>` : '<div class="toolbar"><span class="tab-note">👁️ 仅显示你有权限查看的 Worker(由管理员授权)</span></div>'}
|
||||
<table><thead><tr><th>ID</th><th>名称</th><th>模型</th><th>角色提示词</th><th>温度/上限</th><th>本月成本</th><th>成本上限</th><th>状态</th><th>权限</th><th>操作</th></tr></thead><tbody>
|
||||
${ws.data.map(w => `
|
||||
const tabBar = `
|
||||
<div class="tabs">
|
||||
<a class="${workersTab === 'list' ? 'active' : ''}" onclick="workersTab='list';pageWorkers()">🧑💻 AI Worker</a>
|
||||
<a class="${workersTab === 'endpoints' ? 'active' : ''}" onclick="workersTab='endpoints';pageWorkers()">🔌 大模型接口库</a>
|
||||
<a class="${workersTab === 'teams' ? 'active' : ''}" onclick="workersTab='teams';pageWorkers()">👥 团队</a>
|
||||
</div>`;
|
||||
$('#main').innerHTML = `<h1 class="page-title">AI Worker<small>模型接口 + 角色提示词 + 工具权限 + 成本上限 = 虚拟员工档案</small></h1>${tabBar}<div id="workers-body"></div>`;
|
||||
if (workersTab === 'endpoints') renderEndpoints(eps.data, canManage);
|
||||
else if (workersTab === 'teams') renderTeams(teams.data, ws.data, canManage);
|
||||
else renderWorkersList(ws.data, ps.data, eps.data, canManage);
|
||||
}
|
||||
|
||||
function renderWorkersList(ws, ps, eps, canManage) {
|
||||
const epName = eid => { const e = eps.find(x => x.id === eid); return e ? e.name : '—'; };
|
||||
$('#workers-body').innerHTML = `
|
||||
${canManage ? `<div class="toolbar"><button class="btn primary" onclick="openWorkerModal(null, ${JSON.stringify(ps).replace(/"/g,'"')}, ${JSON.stringify(eps).replace(/"/g,'"')})">+ 注册 Worker</button></div>` : '<div class="toolbar"><span class="tab-note">👁️ 仅显示你有权限查看的 Worker(由管理员授权)</span></div>'}
|
||||
<table><thead><tr><th>ID</th><th>名称</th><th>接口库</th><th>模型</th><th>角色提示词</th><th>温度/上限</th><th>本月成本</th><th>状态</th><th>权限</th><th>操作</th></tr></thead><tbody>
|
||||
${ws.map(w => `
|
||||
<tr>
|
||||
<td class="mono">#${w.id}</td>
|
||||
<td><b>${esc(w.name)}</b><br><span style="color:var(--muted);font-size:12px">${esc(w.description || '')}</span></td>
|
||||
<td><b>${esc(w.name)}</b>${w.is_main ? ' <span class="badge st-done">⭐主力</span>' : ''}<br><span style="color:var(--muted);font-size:12px">${esc(w.description || '')}</span></td>
|
||||
<td><span class="pill">${esc(epName(w.endpoint_id))}</span></td>
|
||||
<td><span class="pill">${esc(w.provider)}</span> <span class="pill">${esc(w.model)}</span></td>
|
||||
<td style="max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(w.system_prompt)}">${esc((w.system_prompt || '—').slice(0, 40))}</td>
|
||||
<td style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(w.system_prompt)}">${esc((w.system_prompt || '—').slice(0, 36))}</td>
|
||||
<td class="mono">${w.temperature} / ${w.max_tokens}</td>
|
||||
<td class="mono">${fmtMoney(w.month_cost)}</td>
|
||||
<td class="mono">${w.task_cost_limit ? '单任务 ¥' + w.task_cost_limit : '—'}<br>${w.monthly_cost_limit ? '月 ¥' + w.monthly_cost_limit : ''}</td>
|
||||
<td><span class="badge ${w.status === 'enabled' ? 'st-done' : 'st-cancelled'}">${w.status === 'enabled' ? '启用' : '停用'}</span></td>
|
||||
<td><span class="tag">${{view:'查看',use:'使用',manage:'管理',admin:'管理员'}[w.perm] || w.perm}</span></td>
|
||||
<td><button class="btn sm" onclick="testWorker(${w.id})">测试</button>
|
||||
<button class="btn sm" onclick="visionTest(${w.id})">🖼️ 视觉测试</button>
|
||||
${canManage ? `<button class="btn sm" onclick="openWorkerModal(${JSON.stringify(w).replace(/"/g,'"')}, ${JSON.stringify(ps.data).replace(/"/g,'"')})">编辑</button>
|
||||
<button class="btn sm" onclick="visionTest(${w.id})">🖼️ 视觉</button>
|
||||
${canManage && !w.is_main ? `<button class="btn sm" onclick="setMainWorker(${w.id})">⭐ 设为主力</button>` : ''}
|
||||
${canManage ? `<button class="btn sm" onclick="openWorkerModal(${JSON.stringify(w).replace(/"/g,'"')}, ${JSON.stringify(ps).replace(/"/g,'"')}, ${JSON.stringify(eps).replace(/"/g,'"')})">编辑</button>
|
||||
<button class="btn sm danger" onclick="delWorker(${w.id})">删除</button>` : ''}</td>
|
||||
</tr>`).join('') || '<tr><td colspan="10" class="empty">还没有 Worker,注册第一个虚拟员工吧</td></tr>'}
|
||||
</tr>`).join('') || '<tr><td colspan="10" class="empty">还没有 Worker,先在「🔌 大模型接口库」确认接口,再注册第一个虚拟员工吧</td></tr>'}
|
||||
</tbody></table>`;
|
||||
}
|
||||
|
||||
async function setMainWorker(wid) {
|
||||
try {
|
||||
await api(`/api/workers/${wid}/main`, {method: 'POST'});
|
||||
toast('⭐ 已设置为主力 AI Worker(对话默认使用)', 'ok');
|
||||
router();
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
}
|
||||
|
||||
async function testWorker(wid) {
|
||||
toast('正在测试连通性…');
|
||||
toast('正在测试连通性(流式)…');
|
||||
try { const r = await api(`/api/workers/${wid}/test`, {method:'POST'});
|
||||
toast(`✅ 连通正常:${r.data.latency_ms}ms,回复「${r.data.reply}」,成本 ¥${r.data.cost}`, 'ok'); }
|
||||
toast(`✅ 连通正常:${r.data.latency_ms}ms(首字 ${r.data.first_token_ms ?? '—'}ms),回复「${r.data.reply}」,成本 ¥${r.data.cost}`, 'ok'); }
|
||||
catch (e) { toast('❌ ' + e.message, 'err'); }
|
||||
}
|
||||
|
||||
@@ -1752,19 +2130,32 @@ async function delWorker(wid) {
|
||||
toast('已删除', 'ok'); router();
|
||||
}
|
||||
|
||||
function openWorkerModal(w, providers) {
|
||||
function openWorkerModal(w, providers, eps) {
|
||||
w = w || {};
|
||||
const sel = (providerId) => providers.find(p => p.id === providerId);
|
||||
providers = providers || [];
|
||||
eps = eps || [];
|
||||
const curEp = eps.find(e => String(e.id) === String(w.endpoint_id));
|
||||
openModal(`
|
||||
<h3>${w.id ? '编辑 Worker' : '注册 Worker'}<button class="close" data-close>×</button></h3>
|
||||
<label>Worker 名称 *</label><input id="wm-name" value="${esc(w.name || '')}" placeholder="例如:文案小助手">
|
||||
<label>模型供应商</label><select id="wm-provider" onchange="onProviderChange()">
|
||||
${providers.map(p => `<option value="${p.id}" ${w.provider === p.id ? 'selected' : ''}>${esc(p.name)}${p.has_key ? '' : '(未配 Key)'}</option>`).join('')}
|
||||
<label>🔌 大模型接口(V3.5:从接口库中选择,价格/密钥统一在接口库管理)*</label>
|
||||
<select id="wm-endpoint" onchange="onEndpointChange()">
|
||||
<option value="">— 选择大模型接口 —</option>
|
||||
${eps.map(e => `<option value="${e.id}" data-models="${esc(JSON.stringify(e.models || []))}" data-pricing="${esc(JSON.stringify(e.pricing || {}))}" data-billing="${e.billing}" data-price="${e.price_per_call}" ${String(e.id) === String(w.endpoint_id) ? 'selected' : ''}>${esc(e.name)}(${e.billing === 'call' ? '按次 ¥' + e.price_per_call + '/次' : '按token计费'})</option>`).join('')}
|
||||
${!eps.length ? '<option value="" disabled>请先在大模型接口库添加接口</option>' : ''}
|
||||
</select>
|
||||
<label>模型(来自接口库,或填自定义模型名)</label>
|
||||
<select id="wm-model">
|
||||
${(curEp?.models || []).map(m => `<option value="${esc(m)}" ${m === w.model ? 'selected' : ''}>${esc(m)}</option>`).join('') || '<option value="">(该接口未配置模型列表)</option>'}
|
||||
${w.model && !(curEp?.models || []).includes(w.model) ? `<option value="${esc(w.model)}" selected>${esc(w.model)}(自定义)</option>` : ''}
|
||||
</select>
|
||||
<label>模型</label><input id="wm-model" value="${esc(w.model || '')}" placeholder="如 doubao-seed-evolving / deepseek-chat">
|
||||
<div class="row">
|
||||
<div><label>自定义 Base URL(留空用默认)</label><input id="wm-base" value="${esc(w.base_url || '')}" placeholder="${esc(sel(w.provider)?.base_url || '')}"></div>
|
||||
<div><label>自定义 API Key(留空用全局)</label><input id="wm-key" value="${esc(w.api_key || '')}"></div>
|
||||
<div><label>或自定义模型名(填了优先用这个,如 vllm 上的自定义模型)</label><input id="wm-model-custom" value="" placeholder="留空则用上面选择的模型"></div>
|
||||
</div>
|
||||
<div class="tab-note" id="wm-price-info" style="margin-top:4px">${w.id && w.endpoint_id ? '计价由接口库「' + esc(curEp?.name || '') + '」统一管理' : ''}</div>
|
||||
<div class="row">
|
||||
<div><label>自定义 Base URL(覆盖接口库,留空用接口库)</label><input id="wm-base" value="${esc(w.base_url || '')}"></div>
|
||||
<div><label>自定义 API Key(覆盖接口库,留空用接口库)</label><input id="wm-key" value="${esc(w.api_key || '')}"></div>
|
||||
</div>
|
||||
<label>角色提示词(System Prompt)</label><textarea id="wm-prompt" placeholder="你是资深文案专家,擅长小红书种草文案…">${esc(w.system_prompt || '')}</textarea>
|
||||
<label>角色描述</label><input id="wm-desc" value="${esc(w.description || '')}" placeholder="一句话描述这个虚拟员工的职责">
|
||||
@@ -1785,27 +2176,45 @@ function openWorkerModal(w, providers) {
|
||||
${w.id ? `<button class="btn" onclick="testWorker(${w.id})">测试连通</button>` : ''}
|
||||
<button class="btn primary" id="wm-save">保存</button>
|
||||
</div>`);
|
||||
window.onProviderChange = () => {
|
||||
const p = providers.find(x => x.id === $('#wm-provider').value);
|
||||
if (!p) return;
|
||||
const base = $('#wm-base');
|
||||
if (!base.value || base.placeholder === base.value) base.value = p.base_url;
|
||||
base.placeholder = p.base_url;
|
||||
const model = $('#wm-model');
|
||||
if (p.models.length && !model.value) model.value = p.models[0];
|
||||
window.onEndpointChange = () => {
|
||||
const sel = $('#wm-endpoint');
|
||||
const opt = sel.selectedOptions[0];
|
||||
let models = [];
|
||||
try { models = JSON.parse(opt?.dataset?.models || '[]'); } catch (e) {}
|
||||
const m = $('#wm-model');
|
||||
m.innerHTML = models.map(x => `<option value="${esc(x)}">${esc(x)}</option>`).join('') || '<option value="">(该接口未配置模型列表)</option>';
|
||||
const info = $('#wm-price-info');
|
||||
if (opt) {
|
||||
let pricing = {};
|
||||
try { pricing = JSON.parse(opt.dataset.pricing || '{}'); } catch (e) {}
|
||||
const first = m.value || models[0] || '';
|
||||
const p = pricing[first];
|
||||
info.textContent = opt.dataset.billing === 'call'
|
||||
? '💰 按调用次数计费:¥' + opt.dataset.price + ' / 次'
|
||||
: (p ? `💰 计价:输入 ¥${p.input}/1M · 输出 ¥${p.output}/1M tokens` : '💰 由接口库统一计价');
|
||||
} else info.textContent = '';
|
||||
};
|
||||
$('#wm-save').addEventListener('click', async () => {
|
||||
const endpoint_id = Number($('#wm-endpoint').value || 0);
|
||||
const customModel = $('#wm-model-custom')?.value.trim() || '';
|
||||
const body = {
|
||||
name: $('#wm-name').value.trim(), provider: $('#wm-provider').value,
|
||||
model: $('#wm-model').value.trim(), base_url: $('#wm-base').value.trim(),
|
||||
api_key: $('#wm-key').value.trim(), system_prompt: $('#wm-prompt').value,
|
||||
description: $('#wm-desc').value.trim(), temperature: parseFloat($('#wm-temp').value || 0.7),
|
||||
name: $('#wm-name').value.trim(),
|
||||
endpoint_id: endpoint_id || null,
|
||||
provider: endpoint_id ? (eps.find(e => e.id === endpoint_id)?.provider || 'custom') : (w.provider || 'custom'),
|
||||
model: customModel || $('#wm-model').value.trim(),
|
||||
base_url: $('#wm-base').value.trim(),
|
||||
api_key: $('#wm-key').value.trim(),
|
||||
system_prompt: $('#wm-prompt').value,
|
||||
description: $('#wm-desc').value.trim(),
|
||||
temperature: parseFloat($('#wm-temp').value || 0.7),
|
||||
max_tokens: parseInt($('#wm-max').value || 2000),
|
||||
task_cost_limit: parseFloat($('#wm-tasklimit').value || 0),
|
||||
monthly_cost_limit: parseFloat($('#wm-monthlimit').value || 0),
|
||||
status: $('#wm-status').value
|
||||
};
|
||||
if (!body.name || !body.provider || !body.model) return toast('名称/供应商/模型必填', 'err');
|
||||
if (!body.name) return toast('请填写 Worker 名称', 'err');
|
||||
if (!endpoint_id) return toast('必选:大模型接口(先在大模型接口库配置)', 'err');
|
||||
if (!body.model) return toast('请选择模型', 'err');
|
||||
try {
|
||||
await api(w.id ? `/api/workers/${w.id}` : '/api/workers', {method: w.id ? 'PUT' : 'POST', body});
|
||||
toast('已保存', 'ok'); closeModal(); router();
|
||||
@@ -1813,19 +2222,185 @@ function openWorkerModal(w, providers) {
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- 成本报表 ---------- */
|
||||
/* ---------- 🔌 大模型接口库(V3.5) ---------- */
|
||||
function renderEndpoints(eps, canManage) {
|
||||
$('#workers-body').innerHTML = `
|
||||
${canManage ? `<div class="toolbar"><button class="btn primary" onclick="openEndpointModal()">+ 添加大模型接口</button>
|
||||
<span class="pager-tip">专门配置大模型接口(地址/密钥/模型/定价),创建 AI Worker 时直接选用;支持按 token 或按调用次数计费</span></div>` : ''}
|
||||
<table><thead><tr><th>ID</th><th>名称</th><th>提供商</th><th>Base URL</th><th>模型</th><th>计费方式</th><th>价格</th><th>状态</th><th>关联 Worker</th><th>操作</th></tr></thead><tbody>
|
||||
${eps.map(e => {
|
||||
const billingTxt = e.billing === 'call' ? `按次 ¥${e.price_per_call}/次` : '按 token';
|
||||
return `<tr>
|
||||
<td class="mono">#${e.id}</td>
|
||||
<td><b>${esc(e.name)}</b><br><span style="color:var(--muted);font-size:12px">${esc(e.description || '')}</span></td>
|
||||
<td><span class="pill">${esc(e.provider)}</span></td>
|
||||
<td class="mono" style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(e.base_url)}">${esc(e.base_url)}</td>
|
||||
<td>${(e.models || []).slice(0, 3).map(m => `<span class="pill">${esc(m)}</span>`).join('')}${(e.models || []).length > 3 ? `<span class="tag">+${e.models.length - 3}</span>` : ''}</td>
|
||||
<td><span class="badge ${e.billing === 'call' ? 'st-review' : 'st-done'}">${billingTxt}</span></td>
|
||||
<td style="font-size:12px;color:var(--muted)">${e.billing === 'call' ? '' : `输入 ¥${e.input_price}/1M · 输出 ¥${e.output_price}/1M`}</td>
|
||||
<td><span class="badge ${e.status === 'enabled' ? 'st-done' : 'st-cancelled'}">${e.status === 'enabled' ? '启用' : '停用'}</span></td>
|
||||
<td>${e.worker_count ? `<span class="pill">${e.worker_count} 个</span>` : '—'}</td>
|
||||
<td><button class="btn sm" onclick="testEndpoint(${e.id})">测试</button>
|
||||
${canManage ? `<button class="btn sm" onclick="openEndpointModal(${JSON.stringify(e).replace(/"/g,'"')})">编辑</button>
|
||||
<button class="btn sm danger" onclick="delEndpoint(${e.id})">删除</button>` : ''}</td>
|
||||
</tr>`;
|
||||
}).join('') || '<tr><td colspan="10" class="empty">还没有大模型接口,点右上角添加(或系统已从 config.py 自动导入)</td></tr>'}
|
||||
</tbody></table>`;
|
||||
}
|
||||
|
||||
function openEndpointModal(e = {}) {
|
||||
const models = e.models || [];
|
||||
const pricing = e.pricing || {};
|
||||
openModal(`
|
||||
<h3>${e.id ? '编辑大模型接口' : '添加大模型接口'}<button class="close" data-close>×</button></h3>
|
||||
<label>接口名称 *</label><input id="ep-name" value="${esc(e.name || '')}" placeholder="如:DeepSeek 官方">
|
||||
<div class="row">
|
||||
<div><label>提供商类型</label><select id="ep-provider">
|
||||
${['doubao','deepseek','autodl','qwen','openai','vllm','custom'].map(p => `<option value="${p}" ${(e.provider || 'custom') === p ? 'selected' : ''}>${p}</option>`).join('')}
|
||||
</select></div>
|
||||
<div><label>计费方式 *</label><select id="ep-billing" onchange="toggleBilling()">
|
||||
<option value="token" ${(e.billing || 'token') === 'token' ? 'selected' : ''}>按 token 数计费</option>
|
||||
<option value="call" ${e.billing === 'call' ? 'selected' : ''}>按调用次数计费</option>
|
||||
</select></div>
|
||||
</div>
|
||||
<label>Base URL *</label><input id="ep-base" value="${esc(e.base_url || '')}" placeholder="https://api.deepseek.com 或 .../v1">
|
||||
<label>API Key</label><input id="ep-key" value="${esc(e.api_key || '')}" placeholder="留空则该接口无 Key">
|
||||
<label>模型列表(每行一个,创建 Worker 时从这里选择)</label>
|
||||
<textarea id="ep-models" rows="4" placeholder="deepseek-chat\ndeepseek-v4-flash">${esc(models.join('\n'))}</textarea>
|
||||
<div id="ep-token-pricing">
|
||||
<label>逐模型定价(元/1M tokens,格式:模型名 输入价 输出价,每行一个)</label>
|
||||
<textarea id="ep-pricing" rows="3" placeholder="deepseek-chat 2 8">${esc(Object.entries(pricing).map(([m, p]) => `${m} ${p.input} ${p.output}`).join('\n'))}</textarea>
|
||||
<div class="row">
|
||||
<div><label>兜底输入价(元/1M)</label><input id="ep-in" type="number" step="0.01" value="${e.input_price || 2}"></div>
|
||||
<div><label>兜底输出价(元/1M)</label><input id="ep-out" type="number" step="0.01" value="${e.output_price || 8}"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ep-call-pricing" style="display:none">
|
||||
<label>单次调用价格(元/次)</label><input id="ep-percall" type="number" step="0.0001" value="${e.price_per_call || 0}">
|
||||
</div>
|
||||
<label>描述</label><input id="ep-desc" value="${esc(e.description || '')}">
|
||||
<label>状态</label><select id="ep-status">
|
||||
<option value="enabled" ${e.status !== 'disabled' ? 'selected' : ''}>启用</option>
|
||||
<option value="disabled" ${e.status === 'disabled' ? 'selected' : ''}>停用</option>
|
||||
</select>
|
||||
<div class="modal-actions">
|
||||
<button class="btn" data-close>取消</button>
|
||||
${e.id ? `<button class="btn" onclick="testEndpoint(${e.id})">测试连通</button>` : ''}
|
||||
<button class="btn primary" id="ep-save">保存</button>
|
||||
</div>`);
|
||||
window.toggleBilling = () => {
|
||||
const call = $('#ep-billing').value === 'call';
|
||||
$('#ep-token-pricing').style.display = call ? 'none' : '';
|
||||
$('#ep-call-pricing').style.display = call ? '' : 'none';
|
||||
};
|
||||
toggleBilling();
|
||||
$('#ep-save').addEventListener('click', async () => {
|
||||
const models = $('#ep-models').value.split(/\n/).map(s => s.trim()).filter(Boolean);
|
||||
const pricing = {};
|
||||
$('#ep-pricing').value.split(/\n/).forEach(l => {
|
||||
const parts = l.trim().split(/\s+/);
|
||||
if (parts.length >= 3 && !isNaN(parseFloat(parts[1]))) pricing[parts[0]] = {input: parseFloat(parts[1]), output: parseFloat(parts[2])};
|
||||
});
|
||||
const body = {
|
||||
name: $('#ep-name').value.trim(), provider: $('#ep-provider').value,
|
||||
base_url: $('#ep-base').value.trim(), api_key: $('#ep-key').value.trim(),
|
||||
models, pricing, billing: $('#ep-billing').value,
|
||||
input_price: parseFloat($('#ep-in').value || 0), output_price: parseFloat($('#ep-out').value || 0),
|
||||
price_per_call: parseFloat($('#ep-percall').value || 0),
|
||||
description: $('#ep-desc').value.trim(), status: $('#ep-status').value
|
||||
};
|
||||
if (!body.name || !body.base_url) return toast('名称 / Base URL 必填', 'err');
|
||||
if (!models.length) return toast('请填写至少一个模型', 'err');
|
||||
try {
|
||||
await api(e.id ? `/api/endpoints/${e.id}` : '/api/endpoints', {method: e.id ? 'PUT' : 'POST', body});
|
||||
toast('已保存', 'ok'); closeModal(); router();
|
||||
} catch (err) { toast(err.message, 'err'); }
|
||||
});
|
||||
}
|
||||
|
||||
async function testEndpoint(eid) {
|
||||
toast('正在测试连通性…');
|
||||
try {
|
||||
const r = await api(`/api/endpoints/${eid}/test`, {method:'POST'});
|
||||
toast(`✅ 连通正常:${r.data.latency_ms}ms(首字 ${r.data.first_token_ms ?? '—'}ms),回复「${r.data.reply}」,成本 ¥${r.data.cost}`, 'ok');
|
||||
} catch (e) { toast('❌ ' + e.message, 'err'); }
|
||||
}
|
||||
|
||||
async function delEndpoint(eid) {
|
||||
if (!confirm('删除该大模型接口?绑定它的 Worker 仍保留但计价回退默认价。')) return;
|
||||
try {
|
||||
await api(`/api/endpoints/${eid}`, {method: 'DELETE'});
|
||||
toast('已删除', 'ok'); router();
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
}
|
||||
|
||||
/* ---------- 👥 团队(V3.5) ---------- */
|
||||
function renderTeams(teams, ws, canManage) {
|
||||
$('#workers-body').innerHTML = `
|
||||
${canManage ? `<div class="toolbar"><button class="btn primary" onclick="openTeamModal()">+ 创建团队</button>
|
||||
<span class="pager-tip">团队 = 一组 AI Worker 的集合,可在「对话」中选择团队对话,或在创建项目时一键勾选为干活团队</span></div>` : ''}
|
||||
<table><thead><tr><th>ID</th><th>团队名称</th><th>说明</th><th>成员 Worker</th><th>可用成员</th><th>操作</th></tr></thead><tbody>
|
||||
${teams.map(t => `
|
||||
<tr>
|
||||
<td class="mono">#${t.id}</td>
|
||||
<td><b>${esc(t.name)}</b></td>
|
||||
<td style="max-width:200px;color:var(--muted)">${esc(t.description || '—')}</td>
|
||||
<td>${(t.workers || []).map(w => `<span class="pill">${esc(w.name)}${w.status !== 'enabled' ? ' 🚫' : ''}</span>`).join(' ') || '—'}</td>
|
||||
<td><span class="badge st-done">${(t.enabled_workers || []).length} 人可用</span></td>
|
||||
<td>${canManage ? `<button class="btn sm" onclick="openTeamModal(${JSON.stringify(t).replace(/"/g,'"')}, ${JSON.stringify(ws).replace(/"/g,'"')})">编辑</button>
|
||||
<button class="btn sm danger" onclick="delTeam(${t.id})">删除</button>` : `<button class="btn sm" onclick="openTeamModal(${JSON.stringify(t).replace(/"/g,'"')}, ${JSON.stringify(ws).replace(/"/g,'"')})">查看</button>`}</td>
|
||||
</tr>`).join('') || '<tr><td colspan="6" class="empty">还没有团队,创建一个把常用 Worker 打包吧</td></tr>'}
|
||||
</tbody></table>`;
|
||||
}
|
||||
|
||||
function openTeamModal(t = {}, allWorkers = []) {
|
||||
const memberSet = new Set(t.worker_ids || (t.workers || []).map(w => w.id));
|
||||
openModal(`
|
||||
<h3>${t.id ? '编辑团队' : '创建团队'}<button class="close" data-close>×</button></h3>
|
||||
<label>团队名称 *</label><input id="tm-name" value="${esc(t.name || '')}" placeholder="如:内容创作组">
|
||||
<label>说明</label><input id="tm-desc" value="${esc(t.description || '')}">
|
||||
<label>成员 AI Worker(可多选)*</label>
|
||||
<div class="check-group">
|
||||
${(allWorkers.length ? allWorkers : (t.workers || [])).map(w => `<label><input type="checkbox" value="${w.id}" ${memberSet.has(w.id) ? 'checked' : ''}> ${esc(w.name)}${w.status !== 'enabled' ? '(停用)' : ''} <small style="color:var(--muted)">${esc(w.model)}</small></label>`).join('') || '<span style="color:var(--muted)">请先注册 Worker</span>'}
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn" data-close>取消</button>
|
||||
<button class="btn primary" id="tm-save">保存</button>
|
||||
</div>`);
|
||||
$('#tm-save').addEventListener('click', async () => {
|
||||
const worker_ids = [...$$('#modal-box .check-group input:checked')].map(x => Number(x.value));
|
||||
if (!$('#tm-name').value.trim()) return toast('请填写团队名称', 'err');
|
||||
if (!worker_ids.length) return toast('至少选择 1 个成员', 'err');
|
||||
const body = {name: $('#tm-name').value.trim(), description: $('#tm-desc').value.trim(), worker_ids};
|
||||
try {
|
||||
await api(t.id ? `/api/teams/${t.id}` : '/api/teams', {method: t.id ? 'PUT' : 'POST', body});
|
||||
toast('已保存', 'ok'); closeModal(); router();
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
});
|
||||
}
|
||||
|
||||
async function delTeam(tid) {
|
||||
if (!confirm('删除该团队?(成员 Worker 不受影响)')) return;
|
||||
await api(`/api/teams/${tid}`, {method: 'DELETE'});
|
||||
toast('已删除', 'ok'); router();
|
||||
}
|
||||
|
||||
/* ---------- 成本报表 / 用量明细 ---------- */
|
||||
async function pageReports() {
|
||||
const group = location.hash.includes('group=') ? location.hash.split('group=')[1] : 'project';
|
||||
if (group === 'usage') return pageUsageReport();
|
||||
const r = (await api('/api/reports/cost?group=' + group)).data;
|
||||
const s = (await api('/api/stats')).data;
|
||||
const head = {project:['项目','调用次数','Tokens','成本'], worker:['Worker','模型','调用次数','Tokens','成本'], model:['供应商/模型','调用次数','Tokens','成本']}[group];
|
||||
const head = {project:['项目','调用次数','输入tokens','输出tokens','缓存命中','总Tokens','成本'], worker:['Worker','模型','调用次数','输入tokens','输出tokens','缓存命中','总Tokens','成本'], model:['供应商/模型','调用次数','输入tokens','输出tokens','缓存命中','总Tokens','成本']}[group];
|
||||
const fmtT = n => (n / 1e6).toFixed(3) + 'M';
|
||||
$('#main').innerHTML = `
|
||||
<h1 class="page-title">成本报表<small>按项目 × 任务 × Worker × 模型 多维核算</small></h1>
|
||||
<h1 class="page-title">成本报表<small>按项目 × 任务 × Worker × 模型 多维核算(含输入/输出/缓存命中 token 与调用次数)</small></h1>
|
||||
<div class="toolbar">
|
||||
<a class="btn ${group==='project'?'primary':''}" href="#/reports/group=project">按项目</a>
|
||||
<a class="btn ${group==='worker'?'primary':''}" href="#/reports/group=worker">按 Worker</a>
|
||||
<a class="btn ${group==='model'?'primary':''}" href="#/reports/group=model">按模型</a>
|
||||
<span style="margin-left:auto" class="pager-tip">累计成本 ${fmtMoney(s.total_cost)} · ${(s.total_tokens/1e6).toFixed(2)}M tokens</span>
|
||||
<a class="btn ${group==='usage'?'primary':''}" href="#/reports/group=usage">📊 用量明细(项目×智能体)</a>
|
||||
<span style="margin-left:auto" class="pager-tip">累计成本 ${fmtMoney(s.total_cost)} · ${(s.total_tokens/1e6).toFixed(2)}M tokens · ${s.total_calls ?? 0} 次调用 · 缓存命中 ${(s.total_cached ?? 0)/1e6 >= 0.001 ? (s.total_cached/1e6).toFixed(2)+'M' : (s.total_cached ?? 0)} tokens</span>
|
||||
</div>
|
||||
<div class="card"><table><thead><tr>${head.map(h => `<th>${h}</th>`).join('')}</tr></thead><tbody>
|
||||
${r.map(x => {
|
||||
@@ -1834,12 +2409,58 @@ async function pageReports() {
|
||||
return `<tr>${group === 'project' ? `<td><b>${esc(x.project_name || '—')}</b></td>` : group === 'worker' ?
|
||||
`<td><b>${esc(x.worker_name || '—')}</b></td><td><span class="pill">${esc(x.provider || '')}</span> <span class="pill">${esc(x.model || '')}</span></td>` :
|
||||
`<td><span class="pill">${esc(x.provider || '')}</span> <span class="pill">${esc(x.model || '')}</span></td>`}
|
||||
<td>${x.runs}</td><td class="mono">${(tokens/1e6).toFixed(3)}M</td>
|
||||
<td>${x.runs}</td>
|
||||
<td class="mono">${fmtT(x.prompt_tokens || 0)}</td>
|
||||
<td class="mono">${fmtT(x.completion_tokens || 0)}</td>
|
||||
<td class="mono" style="color:${(x.cached_tokens || 0) ? 'var(--accent2)' : 'var(--muted)'}">${fmtT(x.cached_tokens || 0)}</td>
|
||||
<td class="mono">${fmtT(tokens)}</td>
|
||||
<td class="mono">${fmtMoney(cost)}<div class="bar"><div style="width:${cost/maxCost*100}%"></div></div></td></tr>`;
|
||||
}).join('') || '<tr><td colspan="5" class="empty">暂无成本数据,先派几个任务吧</td></tr>'}
|
||||
}).join('') || '<tr><td colspan="8" class="empty">暂无成本数据,先派几个任务吧</td></tr>'}
|
||||
</tbody></table></div>`;
|
||||
}
|
||||
|
||||
async function pageUsageReport() {
|
||||
const d = (await api('/api/reports/usage')).data;
|
||||
const fmtT = n => (n / 1e6).toFixed(3) + 'M';
|
||||
const s = (await api('/api/stats')).data;
|
||||
const cols = (r) => `
|
||||
<td>${r.calls}</td>
|
||||
<td class="mono">${fmtT(r.prompt_tokens || 0)}</td>
|
||||
<td class="mono">${fmtT(r.completion_tokens || 0)}</td>
|
||||
<td class="mono" style="color:${(r.cached_tokens || 0) ? 'var(--accent2)' : 'var(--muted)'}">${fmtT(r.cached_tokens || 0)}</td>
|
||||
<td class="mono">${fmtT(r.total_tokens || 0)}</td>
|
||||
<td class="mono">${fmtMoney(r.cost || 0)}</td>`;
|
||||
$('#main').innerHTML = `
|
||||
<h1 class="page-title">用量明细<small>每个项目 × 每个智能体:调用次数 / 输入 / 输出 / 缓存命中 token / 成本</small></h1>
|
||||
<div class="toolbar">
|
||||
<a class="btn" href="#/reports/group=project">按项目</a>
|
||||
<a class="btn" href="#/reports/group=worker">按 Worker</a>
|
||||
<a class="btn" href="#/reports/group=model">按模型</a>
|
||||
<a class="btn primary" href="#/reports/group=usage">📊 用量明细(项目×智能体)</a>
|
||||
<span style="margin-left:auto" class="pager-tip">累计 ${s.total_calls ?? 0} 次调用 · 缓存命中 ${(s.total_cached ?? 0)/1e6 >= 0.001 ? (s.total_cached/1e6).toFixed(2)+'M' : (s.total_cached ?? 0)} tokens</span>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="sec-title" style="margin-top:0">📊 项目 × 智能体 明细矩阵</div>
|
||||
<table><thead><tr><th>项目</th><th>智能体</th><th>模型</th><th>调用次数</th><th>输入tokens</th><th>输出tokens</th><th>缓存命中</th><th>总Tokens</th><th>成本</th></tr></thead><tbody>
|
||||
${d.matrix.map(r => `<tr><td>${esc(r.project_name)}</td><td><b>${esc(r.worker_name)}</b></td><td><span class="pill">${esc(r.model || '')}</span></td>${cols(r)}</tr>`).join('') || '<tr><td colspan="9" class="empty">暂无用量数据</td></tr>'}
|
||||
</tbody></table>
|
||||
</div>
|
||||
<div class="grid g2" style="margin-top:14px">
|
||||
<div class="card">
|
||||
<div class="sec-title" style="margin-top:0">按项目汇总</div>
|
||||
<table><thead><tr><th>项目</th><th>调用</th><th>输入</th><th>输出</th><th>缓存</th><th>总Tokens</th><th>成本</th></tr></thead><tbody>
|
||||
${d.by_project.map(r => `<tr><td>${esc(r.project_name)}</td>${cols(r)}</tr>`).join('') || '<tr><td colspan="7" class="empty">—</td></tr>'}
|
||||
</tbody></table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="sec-title" style="margin-top:0">按智能体汇总</div>
|
||||
<table><thead><tr><th>智能体</th><th>调用</th><th>输入</th><th>输出</th><th>缓存</th><th>总Tokens</th><th>成本</th></tr></thead><tbody>
|
||||
${d.by_worker.map(r => `<tr><td>${esc(r.worker_name)}</td>${cols(r)}</tr>`).join('') || '<tr><td colspan="7" class="empty">—</td></tr>'}
|
||||
</tbody></table>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ---------- 运行日志 ---------- */
|
||||
let logsState = {page: 1, pageSize: 50, level: '', q: ''};
|
||||
window.logsState = logsState; // 供分页组件通过 window[stateVar] 读写
|
||||
@@ -2026,11 +2647,14 @@ async function delToken(id) {
|
||||
toast('已删除', 'ok'); pageApiTokens();
|
||||
}
|
||||
|
||||
/* ---------- 通知设置 ---------- */
|
||||
/* ---------- 通知设置(V3.5:含执行超时 / 系统工作目录 / 主力 Worker) ---------- */
|
||||
async function pageSettings() {
|
||||
const [chans, evs, st] = await Promise.all([api('/api/channels'), api('/api/events'), api('/api/settings')]);
|
||||
const [chans, evs, st, workers, ws] = await Promise.all([
|
||||
api('/api/channels'), api('/api/events'), api('/api/settings'),
|
||||
api('/api/workers'), api('/api/settings/workspace')
|
||||
]);
|
||||
$('#main').innerHTML = `
|
||||
<h1 class="page-title">通知与设置<small>飞书 / 企微群机器人 · 邮件 · 预算告警阈值</small></h1>
|
||||
<h1 class="page-title">通知与设置<small>飞书 / 企微群机器人 · 邮件 · 预算告警 · 执行超时 · 系统工作目录 · 主力 Worker</small></h1>
|
||||
<div class="toolbar"><button class="btn primary" onclick="openChannelModal(null, ${JSON.stringify(evs.data)})">+ 添加通知渠道</button></div>
|
||||
<div class="grid g2">
|
||||
<div class="card">
|
||||
@@ -2042,26 +2666,91 @@ async function pageSettings() {
|
||||
<td>${(c.events || []).map(e => `<span class="pill">${(evs.data.find(x => x.id === e) || {}).name || e}</span>`).join('') || '—'}</td>
|
||||
<td><span class="badge ${c.enabled ? 'st-done' : 'st-cancelled'}">${c.enabled ? '启用' : '停用'}</span></td>
|
||||
<td><button class="btn sm" onclick="testChannel(${c.id})">测试</button>
|
||||
<button class="btn sm" onclick="openChannelModal(${JSON.stringify(c).replace(/"/g,'"')}, ${JSON.stringify(evs.data)})">编辑</button>
|
||||
<button class="btn sm" onclick="openChannelModal(${JSON.stringify(c).replace(/"/g,'"')}, ${JSON.stringify(evs.data)}) ">编辑</button>
|
||||
<button class="btn sm danger" onclick="delChannel(${c.id})">删除</button></td>
|
||||
</tr>`).join('') || '<tr><td colspan="5" class="empty">暂无渠道,添加飞书/企微机器人后任务事件自动推送</td></tr>'}
|
||||
</tbody></table>
|
||||
<div class="tab-note" style="margin-top:10px">💡 飞书群机器人:群设置 → 群机器人 → 添加自定义机器人,复制 Webhook 地址(以 https://open.feishu.cn/open-apis/bot/v2/hook/ 开头)。企微同理(https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=…)。</div>
|
||||
<div class="tab-note" style="margin-top:10px">💡 飞书群机器人:群设置 → 群机器人 → 添加自定义机器人,复制 Webhook 地址。企微同理。</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="sec-title" style="margin-top:0">预算告警</div>
|
||||
<label>项目预算使用率告警阈值(0~1,达到即告警)</label>
|
||||
<div class="row"><input id="st-ratio" type="number" step="0.05" min="0.1" max="1" value="${st.data.budget_alert_ratio}">
|
||||
<button class="btn primary" onclick="saveSettings()">保存</button></div>
|
||||
<div class="tab-note" style="margin-top:12px">告警会写入告警中心并推送到订阅了「预算告警」事件的渠道。</div>
|
||||
<div class="sec-title">邮件通知</div>
|
||||
<div class="tab-note">${st.data.email_configured ? '✅ 已配置 SMTP(config.py EMAIL)' : '⚠️ 未配置 SMTP(config.py 的 EMAIL 填写 host/user/password 后重启生效)'}</div>
|
||||
<div class="sec-title" style="margin-top:0">⏱️ 执行超时(V3.5 流式)</div>
|
||||
<div class="tab-note">所有模型输出按 token 流式接收:<b>单 token 返回超时</b> = 相邻两个 token 数据块的最大间隔;<b>首字延迟超时</b> = 请求发出后首块数据的最长等待。命中即中断并标记失败。</div>
|
||||
<div class="row">
|
||||
<div><label>单 token 返回超时(秒)</label><input id="st-token" type="number" min="3" value="${st.data.token_timeout}"></div>
|
||||
<div><label>首字延迟超时(秒)</label><input id="st-first" type="number" min="3" value="${st.data.first_token_timeout}"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div><label>整体兜底超时(秒)</label><input id="st-req" type="number" min="10" value="${st.data.request_timeout}"></div>
|
||||
<div><label>预算告警阈值(0~1)</label><input id="st-ratio" type="number" step="0.05" min="0.1" max="1" value="${st.data.budget_alert_ratio}"></div>
|
||||
</div>
|
||||
<button class="btn primary" onclick="saveSettings()">保存超时/预算设置</button>
|
||||
<div class="sec-title">⭐ 主力 AI Worker(对话默认使用)</div>
|
||||
<select id="st-main">
|
||||
${workers.data.filter(w => w.status === 'enabled').map(w => `<option value="${w.id}" ${String(w.id) === String(st.data.main_worker_id) ? 'selected' : ''}>${esc(w.name)}(${esc(w.model)})</option>`).join('') || '<option value="">(无 Worker)</option>'}
|
||||
</select>
|
||||
<button class="btn" onclick="saveMainWorker()">设为主力 Worker</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="margin-top:14px">
|
||||
<div class="sec-title" style="margin-top:0">🗂️ 系统工作目录(V3.5)</div>
|
||||
<div class="tab-note">所有项目与多 Agent 协作都在此目录下新建独立工作目录(自动无重复)。默认 <code>${esc(ws.data.default_root)}</code>;可手动改成任意绝对路径——不存在则自动创建,已存在会列出目录信息并需手动确认。</div>
|
||||
<div class="row" style="align-items:end">
|
||||
<div style="flex:3"><label>当前系统工作目录</label><input id="st-wsroot" value="${esc(ws.data.root)}"></div>
|
||||
<button class="btn" onclick="probeWsRoot()">🔍 检查</button>
|
||||
<button class="btn primary" onclick="saveWsRoot()">切换并应用</button>
|
||||
</div>
|
||||
<div id="st-wsinfo" style="margin-top:10px">${ws.data.info ? wsRootInfoHtml(ws.data.info) : '<div class="tab-note">✅ 目录已就绪(不存在时会自动创建)</div>'}</div>
|
||||
<label style="display:none;align-items:center;gap:6px;font-weight:normal" id="st-wsconfirm-wrap"><input type="checkbox" id="st-wsconfirm"> 目录已存在,我确认继续使用</label>
|
||||
<div class="sec-title" style="margin-top:12px">邮件通知</div>
|
||||
<div class="tab-note">${st.data.email_configured ? '✅ 已配置 SMTP(config.py EMAIL)' : '⚠️ 未配置 SMTP(config.py 的 EMAIL 填写 host/user/password 后重启生效)'}</div>
|
||||
</div>`;
|
||||
// 主力 Worker
|
||||
window.saveMainWorker = async () => {
|
||||
try {
|
||||
await api('/api/settings', {method:'PUT', body:{main_worker_id: Number($('#st-main').value)}});
|
||||
toast('⭐ 主力 AI Worker 已更新', 'ok');
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
};
|
||||
window.probeWsRoot = async () => {
|
||||
const path = $('#st-wsroot').value.trim();
|
||||
const box = $('#st-wsinfo');
|
||||
if (!path) return;
|
||||
box.innerHTML = '<div class="tab-note"><span class="spin"></span> 检查中…</div>';
|
||||
try {
|
||||
const r = await api('/api/workspace/probe?path=' + encodeURIComponent(path));
|
||||
if (r.data.exists) {
|
||||
box.innerHTML = wsRootInfoHtml(r.data);
|
||||
$('#st-wsconfirm-wrap').style.display = 'flex';
|
||||
} else {
|
||||
box.innerHTML = `<div class="tab-note" style="color:var(--accent2)">✅ 目录不存在:${esc(r.data.path)},切换时将自动创建</div>`;
|
||||
$('#st-wsconfirm-wrap').style.display = 'none';
|
||||
}
|
||||
} catch (e) { box.innerHTML = `<div class="err">${esc(e.message)}</div>`; }
|
||||
};
|
||||
window.saveWsRoot = async () => {
|
||||
const path = $('#st-wsroot').value.trim();
|
||||
if (!path) return toast('请输入目录路径', 'err');
|
||||
try {
|
||||
const r = await api('/api/settings/workspace', {method:'POST', body:{path, confirm_existing: !!$('#st-wsconfirm')?.checked}});
|
||||
toast('✅ 系统工作目录已切换:' + r.root, 'ok');
|
||||
pageSettings();
|
||||
} catch (e) { toast(e.message, 'err'); pageSettings(); }
|
||||
};
|
||||
}
|
||||
|
||||
function wsRootInfoHtml(info) {
|
||||
return `<div class="alert-row unread" style="margin:0"><div class="t">📂 ${esc(info.path)}</div>
|
||||
<div class="d">文件 ${info.files} 个 · 大小 ${(info.size/1024/1024).toFixed(2)} MB${info.newest_mtime ? ' · 最近修改 ' + fmtTime(info.newest_mtime) : ''}</div>
|
||||
<div class="d" style="max-height:120px;overflow:auto">${(info.sample || []).map(s => '<div>· ' + esc(s) + '</div>').join('') || ''}</div></div>`;
|
||||
}
|
||||
async function saveSettings() {
|
||||
await api('/api/settings', {method:'PUT', body:{budget_alert_ratio: parseFloat($('#st-ratio').value)}});
|
||||
toast('已保存', 'ok');
|
||||
await api('/api/settings', {method:'PUT', body:{
|
||||
budget_alert_ratio: parseFloat($('#st-ratio').value),
|
||||
token_timeout: parseFloat($('#st-token').value),
|
||||
first_token_timeout: parseFloat($('#st-first').value),
|
||||
request_timeout: parseFloat($('#st-req').value)
|
||||
}});
|
||||
toast('已保存(新超时设置立即生效)', 'ok');
|
||||
}
|
||||
async function testChannel(id) {
|
||||
toast('正在发送测试消息…');
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
<aside id="sidebar">
|
||||
<div class="logo">🤖 <span>AI Worker</span><small>项目管理平台</small></div>
|
||||
<nav>
|
||||
<a href="#/dashboard" data-route="dashboard" data-perm="dashboard.view">📊 仪表盘</a>
|
||||
<a href="#/dashboard" data-route="dashboard" data-perm="dashboard.view" class="nav-chat">💬 对话 · 仪表盘</a>
|
||||
<a href="#/projects" data-route="projects" data-perm="project.view">📁 项目</a>
|
||||
<a href="#/workers" data-route="workers" data-perm="worker.view">🧑💻 AI Worker</a>
|
||||
<a href="#/agents" data-route="agents" data-perm="agent.view">🤝 多 Agent 协作</a>
|
||||
|
||||
@@ -14,6 +14,8 @@ nav{padding:12px 0;flex:1}
|
||||
nav a{display:block;padding:11px 20px;color:var(--muted);text-decoration:none;border-left:3px solid transparent}
|
||||
nav a:hover{color:var(--text);background:var(--panel2)}
|
||||
nav a.active{color:var(--accent);border-left-color:var(--accent);background:var(--panel2)}
|
||||
nav a.nav-chat{color:var(--accent2);font-weight:600;border-left-color:var(--accent2)}
|
||||
nav a.nav-chat:hover{background:var(--panel2)}
|
||||
.sidebar-foot{padding:14px 18px;border-top:1px solid var(--border);font-size:12px}
|
||||
.conn.ok{color:var(--accent2)} .conn.err{color:var(--danger)}
|
||||
.logout{color:var(--muted);text-decoration:none;display:block;margin-top:6px}
|
||||
@@ -272,3 +274,26 @@ code{background:var(--panel2);border:1px solid var(--border);border-radius:6px;p
|
||||
.al-task{cursor:pointer}
|
||||
.al-task:hover .al-msg{color:var(--accent)}
|
||||
.al-go{color:var(--accent);font-size:11px;margin-left:6px;border:1px solid var(--border);border-radius:4px;padding:0 5px}
|
||||
|
||||
/* ---------- V3.5 对话 ---------- */
|
||||
.chat-card{display:flex;flex-direction:column;max-height:560px;padding:14px}
|
||||
.chat-top{display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px}
|
||||
.chat-title{font-size:16px;font-weight:700}
|
||||
.chat-sess{display:flex;gap:6px;align-items:center}
|
||||
.chat-sess select{width:auto;min-width:160px}
|
||||
.chat-target{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:10px}
|
||||
.chat-target select{width:auto;max-width:240px}
|
||||
.chat-target-label{font-size:12px;color:var(--accent);font-weight:600}
|
||||
.chat-body{flex:1;min-height:180px;max-height:330px;overflow-y:auto;background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:12px}
|
||||
.chat-msg{display:flex;margin-bottom:10px}
|
||||
.chat-msg.user{justify-content:flex-end}
|
||||
.chat-msg.ai{justify-content:flex-start;flex-direction:column;align-items:flex-start}
|
||||
.chat-bubble{max-width:78%;padding:9px 13px;border-radius:12px;font-size:13px;line-height:1.65;white-space:pre-wrap;word-break:break-word}
|
||||
.chat-bubble.user{background:var(--accent);color:#fff;border-bottom-right-radius:3px}
|
||||
.chat-bubble.ai{background:var(--panel2);border:1px solid var(--border);border-bottom-left-radius:3px}
|
||||
.chat-bubble.ai.err{border-color:var(--danger)}
|
||||
.chat-usage{font-size:11px;color:var(--muted);margin-top:4px;font-family:ui-monospace,Consolas,monospace}
|
||||
.chat-input{display:flex;gap:10px;margin-top:10px;align-items:flex-end}
|
||||
.chat-input textarea{flex:1;resize:vertical}
|
||||
.ref-card{border-left:3px solid var(--accent)}
|
||||
.ref-card:hover{border-color:var(--accent)}
|
||||
Reference in New Issue
Block a user