feat: add app management, local inference controls, and task statistics

This commit is contained in:
mesalogo
2026-09-18 08:20:45 +08:00
parent 6dd6297414
commit bbae893c45
103 changed files with 7962 additions and 1490 deletions
+9
View File
@@ -9,13 +9,22 @@
3. [Scheduled Task PRD](./scheduled-task-prd.md):时间或事件触发的 Task。
4. [Goal Task PRD](./goal-task-prd.md):围绕可验证结果有界推进的 Task。
5. [Job 与 Subjob PRD](./job-and-subjob-prd.md):Task 内部串行、并行和委派执行。
6. [技术设计](./technical-design.md):统计查询契约、请求证据与不完整数据边界,以及立即创建的事务和入队规则。
正式功能范围固定为当前项目:顶部显示当前会话消息总数和 Agent 累计回复时间,下方列出本项目全部会话的任务。创建默认立即执行并关联当前会话,可选择本项目其他会话、新会话或定时周期;产品与验收规则见 Task Center PRD 和 Scheduled Task PRD。
## 阅读顺序
[当前会话统计 Demo](./hierarchy-demo.html) 用于评审任务中心中的紧凑统计区:上方显示当前会话的消息数与 Agent 累计回复时长,下方管理当前项目全部会话的任务及用时。切换会话只更新会话统计,任务范围固定为当前项目,没有全局或所有项目入口。支持状态筛选、定位所属会话,以及暂停、继续和取消任务的模拟操作。新建任务支持填写内容、关联本项目已有或新建会话、立即或定时执行;仅在页面内模拟,刷新后恢复样例。
配套 `hierarchy-demo.css` 与 `hierarchy-demo.js` 仅服务于该静态原型;使用演示数据,未接入产品。
项目任务标题右侧依次为 `+` 新建按钮与筛选图标,状态筛选默认收起;应用筛选后图标高亮并显示当前筛选条件。
时间为固定样例,Demo 从每次回复的起止秒数计算。正式功能按请求有效区间累计,包含轮内工具和审批等待、不计轮间空闲;任务用时按 `schedule_runs` 关联请求汇总,不按父任务生命周期计算。缺失历史有提示,远程缺少可靠证据时不推算用时,数据来源与边界以技术设计为准。
先阅读统一领域模型。其他功能文档不得重新定义 Task、Conversation、Job、Run 或 Subagent。
若实现与文档出现冲突,应先修正统一模型,再同步功能 PRD。
## 相关功能
- [本机专用推理架构与任务控制评估](../../architecture/local-inference-task-control.md)记录 ASR、OCR、embedding 的任务与服务状态、执行所有权、取消、超时、清理及观测待办;推理请求不自动等同于本目录的产品 Task,候选迁移尚未采用。
- [直连模型 Agent 能力](../direct-model-agent/README.md) 使用本目录定义的 Subagent 与
Job/Subjob 语义,为直连模型提供单层编程委派,但不重新定义产品对象层级。
@@ -0,0 +1,42 @@
:root {
color-scheme: light;
--surface-canvas: #e8f0f5; --surface-raised: #fff; --surface-subtle: #f4f8fb;
--text-primary: #17263a; --text-secondary: #4f6278; --border-default: #cedce7;
--accent: #086fc7; --accent-subtle: #e8f4ff; --success: #15725d; --warning: #865200;
--font-family-ui: "Inter Variable", "PingFang SC", "Microsoft YaHei UI", "Noto Sans SC Variable", system-ui, sans-serif;
--radius-control: 10px; --radius-card: 14px;
}
[data-theme="dark"] { color-scheme: dark; --surface-canvas: #07131f; --surface-raised: #102335; --surface-subtle: #162c40; --text-primary: #edf7ff; --text-secondary: #b5c6d5; --border-default: #29445a; --accent: #72c5ff; --accent-subtle: #123b5b; --success: #73d9bb; --warning: #f2bd66; }
* { box-sizing: border-box; }
body { margin: 0; padding: 24px; background: var(--surface-subtle); color: var(--text-primary); font: 13px/1.65 var(--font-family-ui); }
button, select { font: inherit; color: inherit; }
button { cursor: pointer; border: 1px solid transparent; border-radius: var(--radius-control); padding: 8px 12px; background: transparent; }
button:hover { background: var(--accent-subtle); }
button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
select { width: 100%; min-width: 0; padding: 8px; border: 1px solid var(--border-default); border-radius: var(--radius-control); background: var(--surface-raised); }
h1, h2, h3, p { margin: 0; } h1 { font-size: 24px; font-weight: 600; line-height: 1.4; } h2 { font-size: 14px; } h3 { font-size: 13px; }
.demo-bar, .demo-footer { max-width: 1500px; margin: auto; display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.demo-bar { margin-bottom: 16px; }.demo-bar strong { font-weight: 600; }.demo-bar span { margin-left: 16px; color: var(--text-secondary); font-size: 12px; }
#theme { border-color: var(--border-default); }
.workspace { max-width: 1500px; margin: auto; display: grid; grid-template-columns: 240px minmax(0, 1fr) 328px; height: calc(100dvh - 132px); min-height: 640px; border: 1px solid var(--border-default); border-radius: var(--radius-card); overflow: hidden; background: var(--surface-raised); }
.sidebar { display: flex; flex-direction: column; padding: 24px 16px; background: var(--surface-canvas); border-right: 1px solid var(--border-default); overflow: auto; }
.brand { display: flex; align-items: center; gap: 12px; margin-bottom: 32px; font-size: 16px; }.brand-mark { display: grid; place-items: center; width: 32px; height: 32px; border-radius: var(--radius-control); color: var(--surface-raised); background: var(--text-primary); font-weight: 700; }
label, .eyebrow { font-size: 11px; color: var(--text-secondary); }label { display: block; margin-bottom: 8px; }
.muted, .metadata, .sidebar-note, .task-footnote { color: var(--text-secondary); font-size: 12px; }
#project-meta { margin: 8px 0 32px; }.section-heading { display: flex; justify-content: space-between; align-items: center; gap: 8px; }.section-heading > span { color: var(--text-secondary); font-size: 11px; }
#sessions { margin-top: 12px; display: grid; gap: 8px; }#sessions button { text-align: left; width: 100%; padding: 12px; }#sessions button[aria-current="true"] { color: var(--accent); background: var(--surface-raised); border-color: var(--border-default); }
#sessions small { display: block; color: var(--text-secondary); margin-top: 4px; font-size: 11px; }.sidebar-note { margin-top: auto; padding-top: 32px; line-height: 1.9; }
main { display: flex; flex-direction: column; min-width: 0; min-height: 0; }.conversation-header { padding: 24px 32px; border-bottom: 1px solid var(--border-default); }.eyebrow { margin-bottom: 8px; }.metadata { display: flex; flex-wrap: wrap; gap: 4px 16px; margin-top: 12px; }
.messages { flex: 1; min-height: 0; overflow: auto; padding: 32px; }.message { max-width: 720px; margin: 0 auto 32px; }.message-author { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; font-size: 12px; font-weight: 600; }.message-author time { margin-left: auto; color: var(--text-secondary); font-size: 11px; font-weight: 400; }.message p { font-size: 14px; line-height: 1.9; white-space: pre-line; }.message.user p { background: var(--surface-subtle); padding: 16px; border-radius: var(--radius-card); }
.linked-task { display: block; margin-top: 16px; text-align: left; width: 100%; border-color: var(--border-default); color: var(--accent); }.linked-task small { display: block; color: var(--text-secondary); margin-top: 4px; }
.composer { padding: 16px 32px 24px; }.composer-box { padding: 16px; border: 1px solid var(--border-default); border-radius: var(--radius-card); color: var(--text-secondary); }.composer-box div { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; margin-top: 24px; font-size: 11px; }.composer > p { margin-top: 8px; font-size: 11px; color: var(--text-secondary); }
.tasks { border-left: 1px solid var(--border-default); background: var(--surface-subtle); padding: 24px 16px; overflow: auto; }.tasks header { margin-bottom: 24px; }.tasks header p { margin-top: 4px; }.badge { border: 1px solid var(--border-default); border-radius: var(--radius-control); padding: 0 8px; }.scope-label { margin: 8px 0 16px; color: var(--text-secondary); font-size: 11px; overflow-wrap: anywhere; }
.filters { display: flex; gap: 4px; border-bottom: 1px solid var(--border-default); padding-bottom: 12px; margin-bottom: 16px; }.filters button { padding: 4px 12px; font-size: 12px; }.filters [aria-pressed="true"] { color: var(--accent); background: var(--accent-subtle); border-color: var(--accent); }
.task-card { background: var(--surface-raised); border: 1px solid var(--border-default); border-radius: var(--radius-card); padding: 16px; margin-bottom: 12px; }.task-card .status { flex-shrink: 0; font-size: 11px; color: var(--accent); }.task-card .status.done { color: var(--success); }.task-card p { color: var(--text-secondary); font-size: 11px; margin-top: 8px; }.empty { padding: 24px 8px; color: var(--text-secondary); text-align: center; }.demo-footer { color: var(--text-secondary); margin-top: 12px; font-size: 11px; }
.session-stats { padding-bottom: 24px; border-bottom: 1px solid var(--border-default); }.session-stats h3 { font-weight: 500; }.stat-grid { display: grid; grid-template-columns: 1fr 1fr; margin: 24px 0 12px; gap: 16px; }.stat-grid dt { font-size: 12px; color: var(--text-secondary); }.stat-grid dd { margin: 4px 0 0; font-size: 20px; font-weight: 600; font-variant-numeric: tabular-nums; }.session-stats details { margin-top: 16px; font-size: 11px; color: var(--text-secondary); }.session-stats summary { cursor: pointer; }.session-stats details p { margin-top: 8px; }.task-heading { margin: 24px 0 12px; }.task-duration { display: flex; justify-content: space-between; margin-top: 16px; font-size: 12px; }.task-duration span { color: var(--text-secondary); }.task-duration strong { font-weight: 500; }
.task-session { padding: 4px 0; margin-top: 4px; text-align: left; color: var(--accent); font-size: 11px; }.task-actions { display: flex; gap: 8px; margin-top: 12px; }.task-actions button { border-color: var(--border-default); padding: 4px 12px; font-size: 12px; }
.task-toolbar { display: flex; gap: 4px; }.task-toolbar button { display: grid; place-items: center; width: 32px; height: 32px; padding: 4px; color: var(--text-secondary); }#new-task { font-size: 22px; line-height: 1; }.task-toolbar svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linejoin: round; }.task-toolbar .is-active { background: var(--accent-subtle); color: var(--accent); border-color: var(--accent); }#task-filter { margin-bottom: 16px; }#task-count { font-size: 11px; font-weight: 400; color: var(--text-secondary); }
#task-form { padding: 16px; margin-bottom: 24px; border: 1px solid var(--border-default); border-radius: var(--radius-card); background: var(--surface-raised); }#task-form label { margin-top: 12px; }#task-form textarea, #task-form input { width: 100%; min-width: 0; padding: 8px; border: 1px solid var(--border-default); border-radius: var(--radius-control); background: var(--surface-raised); color: var(--text-primary); font: inherit; }#task-form textarea { resize: vertical; }#task-form p { margin-top: 12px; }#task-form button[type="submit"] { color: var(--accent); border-color: var(--accent); }textarea:focus-visible, input:focus-visible, summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }.task-card h3, .task-session, #sessions button { overflow-wrap: anywhere; }
@media (max-width: 1100px) { .workspace { grid-template-columns: 200px minmax(0, 1fr) 280px; }.conversation-header, .messages { padding: 24px; }.composer { padding: 16px 24px; } }
@media (max-width: 850px) { body { padding: 16px; }.workspace { height: auto; grid-template-columns: 200px minmax(0, 1fr); }.tasks { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--border-default); }#task-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }.task-card { margin: 0; }.messages { min-height: 280px; }.demo-footer > span:last-child { display: none; } }
@media (max-width: 560px) { body { padding: 8px; }.demo-bar { align-items: start; }.demo-bar span { display: block; margin-left: 0; }.workspace { display: flex; flex-direction: column; }.sidebar { border-right: 0; border-bottom: 1px solid var(--border-default); padding: 16px; }.brand, .sidebar-note { display: none; }#project-meta { margin: 8px 0 16px; }#sessions { grid-template-columns: repeat(2, minmax(0, 1fr)); }#sessions button { padding: 8px; }.conversation-header, .messages { padding: 24px 16px; }.composer { padding: 16px; }#task-list { grid-template-columns: 1fr; }.demo-footer { padding: 0 8px; } }
@@ -0,0 +1,52 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GoodBuddy · 当前会话统计</title>
<link rel="stylesheet" href="hierarchy-demo.css">
<script src="hierarchy-demo.js" defer></script>
</head>
<body>
<header class="demo-bar"><div><strong>GoodBuddy / 会话统计 Demo</strong><span>演示数据 · 时间为固定样例</span></div><button id="theme" aria-pressed="false">深色模式</button></header>
<div class="workspace">
<aside class="sidebar" aria-label="项目与会话">
<div class="brand"><span class="brand-mark">G</span><strong>GoodBuddy</strong></div>
<div class="eyebrow">当前项目</div>
<strong>GoodBuddy 产品设计</strong>
<p id="project-meta" class="muted">工作目录 · goodbuddy</p>
<div class="section-heading"><h2>会话</h2><span id="session-count"></span></div>
<nav id="sessions" aria-label="当前项目的会话"></nav>
</aside>
<main>
<header class="conversation-header"><div class="eyebrow">GoodBuddy 产品设计</div><h1 id="conversation-title"></h1></header>
<section class="messages" id="messages" aria-label="会话内容"></section>
<div class="composer"><div class="composer-box">继续交流,或交代下一件事…<div><span>Execute · OpenCode</span><span>输入区仅作布局展示</span></div></div></div>
</main>
<aside class="tasks" aria-label="任务中心">
<header><h2>任务中心</h2><p class="muted">当前项目 · GoodBuddy 产品设计</p></header>
<section class="session-stats" aria-label="当前会话统计" aria-live="polite">
<div class="eyebrow">当前会话</div><h3 id="stats-title"></h3>
<dl class="stat-grid"><div><dt>Agent 累计回复时长</dt><dd id="duration"></dd></div><div><dt>消息数</dt><dd id="message-count"></dd></div></dl>
<p id="message-breakdown" class="muted"></p>
<details><summary>时间说明</summary><p id="time-description"></p><p>累计每次 Agent 回复的结束时间减去开始时间,不包含回复之间的空闲时间。任务用时单独展示,包含任务中的等待时间。</p></details>
</section>
<div class="section-heading task-heading"><h2>项目任务 <span id="task-count"></span></h2><div class="task-toolbar"><button id="new-task" aria-label="新建任务" title="新建任务" aria-expanded="false" aria-controls="task-form">+</button><button id="filter-toggle" aria-label="筛选任务" title="筛选任务" aria-expanded="false" aria-controls="task-filter"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5h16l-6 7v6l-4 2v-8z"/></svg></button></div></div>
<form id="task-form" hidden>
<h3>新建任务</h3>
<label for="task-content">任务内容</label><textarea id="task-content" rows="3" required maxlength="500" placeholder="描述要完成的工作"></textarea>
<label for="task-conversation">关联会话</label><select id="task-conversation"></select>
<label for="task-timing">执行时间</label><select id="task-timing"><option value="now">立即执行</option><option value="scheduled">定时执行</option></select>
<div id="schedule-field" hidden><label for="task-date">计划时间</label><input id="task-date" type="datetime-local"></div>
<p class="muted">仅在此页面模拟创建,刷新后恢复样例。</p>
<div class="task-actions"><button type="submit">创建任务</button><button id="close-form" type="button">取消</button></div>
</form>
<div id="task-filter" hidden><label for="task-status">任务状态</label>
<select id="task-status"><option value="all">全部状态</option><option value="进行中">进行中</option><option value="待执行">待执行</option><option value="已暂停">已暂停</option><option value="ended">已结束</option></select></div>
<p id="active-filter" class="scope-label" hidden></p>
<div id="task-list" aria-live="polite"></div>
</aside>
</div>
<footer class="demo-footer"><span>切换会话更新统计;项目任务保持可见,可暂停、继续或取消。</span><span>范围固定为当前项目</span></footer>
</body>
</html>
@@ -0,0 +1,140 @@
const conversations = [
{ id: 'layout', title: '会话统计信息设计', userCount: 12, assistantCount: 12, start: '14:05', end: '14:32', replies: [[0, 12], [50, 74], [120, 136], [220, 240], [300, 332], [440, 458], [590, 615], [720, 750], [890, 930], [1100, 1145], [1350, 1378], [1580, 1620]], user: '任务中心可以增加当前会话、任务的时长和消息数,信息可能没多少。', reply: '右侧累计每次 Agent 从开始回复到结束回复的时间,不计两次回复之间的空闲。下方管理当前项目的全部任务,也可以直接新建任务。\n\n切换会话时只更新会话统计,项目任务列表保留。', tasks: [
{ title: '制作统计布局 Demo', status: '进行中', duration: '6 分钟', time: '14:26 开始', active: true },
{ title: '核对统计字段', status: '已完成', duration: '3 分钟', time: '14:21 至 14:24', active: false }
] },
{ id: 'review', title: '导航方案评审', userCount: 5, assistantCount: 7, start: '10:18', end: '11:46', replies: [[0, 30], [300, 350], [700, 740], [1400, 1470], [2200, 2260], [3500, 3580], [5230, 5280]], user: '整理一下导航方案中的问题,形成一份评审清单。', reply: '评审清单已整理完成,包括项目入口、会话导航和任务状态展示。', tasks: [
{ title: '整理导航评审清单', status: '已完成', duration: '8 分钟', time: '11:38 至 11:46', active: false },
{ title: '补充导航交互说明', status: '已暂停', duration: '2 分钟', time: '11:48 开始', active: true }
] },
{ id: 'ideas', title: '随手聊聊产品想法', userCount: 3, assistantCount: 3, start: '16:20', end: '16:38', replies: [[0, 8], [400, 411], [1071, 1080]], user: '消息数量应该算条数,还是算对话轮数?', reply: '显示消息条数更直观。用户发言和助手回复分别计数,工具调用不单独算成聊天消息。', tasks: [] }
]
let conversationId = 'layout'
const $ = (id) => document.getElementById(id)
const escape = (value) => String(value).replace(/[&<>"']/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char])
function render() {
const conversation = conversations.find((item) => item.id === conversationId)
$('session-count').textContent = `${conversations.length} 个`
$('sessions').innerHTML = conversations.map((item) => `<button data-conversation="${item.id}" aria-current="${item.id === conversationId}">${escape(item.title)}<small>${item.userCount + item.assistantCount} 条消息</small></button>`).join('')
$('conversation-title').textContent = conversation.title
$('stats-title').textContent = conversation.title
// Demo intervals are reply start/end offsets in seconds, including idle gaps.
const replySeconds = conversation.replies.reduce((sum, [start, end]) => sum + end - start, 0)
$('duration').textContent = replySeconds >= 60 ? `${Math.floor(replySeconds / 60)} 分 ${replySeconds % 60} 秒` : `${replySeconds} 秒`
$('message-count').textContent = `${conversation.userCount + conversation.assistantCount} 条`
$('message-breakdown').textContent = `你 ${conversation.userCount} 条 · 助手 ${conversation.assistantCount} 条`
$('time-description').textContent = `本会话 ${conversation.replies.length} 次已结束回复,累计 ${replySeconds} 秒。演示按每次回复的起止时间计算。`
$('messages').innerHTML = conversation.userCount ? `<article class="message user"><div class="message-author">你 <time>${conversation.start}</time></div><p>${escape(conversation.user)}</p></article><article class="message"><div class="message-author">GoodBuddy <time>${conversation.end}</time></div><p>${escape(conversation.reply)}</p></article>` : '<p class="empty">任务已关联此会话,尚无消息。Demo 不执行真实 Agent。</p>'
const projectTasks = conversations.flatMap((session) => session.tasks.map((task, index) => ({ task, session, index })))
const status = $('task-status').value
const filtered = status !== 'all'
const filterLabel = $('task-status').selectedOptions[0].textContent
$('filter-toggle').classList.toggle('is-active', filtered)
$('filter-toggle').setAttribute('aria-label', filtered ? `筛选任务:${filterLabel}` : '筛选任务')
$('filter-toggle').title = filtered ? `筛选任务:${filterLabel}` : '筛选任务'
$('active-filter').hidden = !filtered
$('active-filter').textContent = `筛选:${filterLabel}`
const visibleTasks = projectTasks.filter(({ task }) => status === 'all' || (status === 'ended' ? !task.active : task.status === status))
$('task-count').textContent = `${visibleTasks.length} / ${projectTasks.length} 项`
$('task-list').innerHTML = visibleTasks.length ? visibleTasks.map(({ task, session, index }) => `<article class="task-card" data-session="${session.id}" data-index="${index}"><div class="section-heading"><h3>${escape(task.title)}</h3><span class="status ${task.active ? '' : 'done'}">${task.status}</span></div><button class="task-session" data-open="${session.id}">${session.id === conversationId ? '当前会话' : '会话'} · ${escape(session.title)}</button><div class="task-duration"><span>${task.active ? '已用时' : '总用时'}</span><strong>${task.duration}</strong></div><p>${escape(task.time)}</p>${task.active ? `<div class="task-actions"><button data-action="toggle">${task.status === '已暂停' ? '继续' : '暂停'}</button><button data-action="cancel">取消任务</button></div>` : ''}</article>`).join('') : '<p class="empty">当前项目暂无此状态的任务</p>'
}
$('task-status').addEventListener('change', render)
$('filter-toggle').addEventListener('click', () => {
const expanded = $('task-filter').hidden
$('task-filter').hidden = !expanded
$('filter-toggle').setAttribute('aria-expanded', String(expanded))
if (expanded) $('task-status').focus()
})
$('task-filter').addEventListener('keydown', (event) => {
if (event.key !== 'Escape') return
$('task-filter').hidden = true
$('filter-toggle').setAttribute('aria-expanded', 'false')
$('filter-toggle').focus()
})
$('task-list').addEventListener('click', (event) => {
const button = event.target.closest('button')
if (!button) return
if (button.dataset.open) {
conversationId = button.dataset.open
render()
$('conversation-title').tabIndex = -1
$('conversation-title').focus()
return
}
const card = button.closest('[data-session]')
const task = conversations.find((session) => session.id === card.dataset.session).tasks[Number(card.dataset.index)]
if (button.dataset.action === 'cancel') {
task.status = '已取消'
task.active = false
} else {
if (task.status === '已暂停') {
task.status = task.resumeStatus || '进行中'
} else {
task.resumeStatus = task.status
task.status = '已暂停'
}
}
const sessionId = card.dataset.session
const index = card.dataset.index
render()
const next = $('task-list').querySelector(`[data-session="${sessionId}"][data-index="${index}"] button`)
;(next || ($('task-filter').hidden ? $('filter-toggle') : $('task-status'))).focus()
})
$('sessions').addEventListener('click', (event) => {
const button = event.target.closest('[data-conversation]')
if (!button) return
conversationId = button.dataset.conversation
render()
$('sessions').querySelector(`[data-conversation="${conversationId}"]`).focus()
})
$('theme').addEventListener('click', () => {
const dark = document.documentElement.dataset.theme !== 'dark'
document.documentElement.dataset.theme = dark ? 'dark' : 'light'
$('theme').setAttribute('aria-pressed', String(dark))
$('theme').textContent = dark ? '浅色模式' : '深色模式'
})
$('new-task').addEventListener('click', () => {
$('task-form').hidden = false
$('new-task').setAttribute('aria-expanded', 'true')
$('task-conversation').innerHTML = conversations.map((session) => `<option value="${session.id}">${escape(session.title)}</option>`).join('') + '<option value="new">新建会话</option>'
$('task-conversation').value = conversationId
$('task-content').focus()
})
$('close-form').addEventListener('click', () => {
$('task-form').hidden = true
$('new-task').setAttribute('aria-expanded', 'false')
$('new-task').focus()
})
$('task-timing').addEventListener('change', () => {
const scheduled = $('task-timing').value === 'scheduled'
$('schedule-field').hidden = !scheduled
$('task-date').required = scheduled
$('task-date').disabled = !scheduled
})
$('task-content').addEventListener('input', () => $('task-content').setCustomValidity(''))
$('task-date').addEventListener('input', () => $('task-date').setCustomValidity(''))
$('task-form').addEventListener('submit', (event) => {
event.preventDefault()
const title = $('task-content').value.trim()
$('task-content').setCustomValidity(title ? '' : '请输入任务内容')
const scheduled = $('task-timing').value === 'scheduled'
$('task-date').setCustomValidity(scheduled && !(new Date($('task-date').value).getTime() > Date.now()) ? '请选择未来的时间' : '')
if (!$('task-form').reportValidity()) return
let session = conversations.find((item) => item.id === $('task-conversation').value)
if (!session) {
session = { id: `new-${conversations.length}`, title: title.slice(0, 24), userCount: 0, assistantCount: 0, replies: [], tasks: [] }
conversations.push(session)
}
session.tasks.unshift({ title, status: scheduled ? '待执行' : '进行中', duration: '0 秒', time: scheduled ? `计划于 ${$('task-date').value.replace('T', ' ')}` : '刚刚创建 · 模拟执行', active: true })
$('task-status').value = 'all'
$('task-form').reset()
$('schedule-field').hidden = true
$('task-date').required = false
$('task-date').disabled = true
$('task-form').hidden = true
$('new-task').setAttribute('aria-expanded', 'false')
render()
const created = $('task-list').querySelector(`[data-session="${session.id}"][data-index="0"] .task-session`)
created.focus()
})
render()
@@ -5,8 +5,8 @@
| 项目 | 内容 |
| --- | --- |
| 状态 | 首期稳定 Task 生命周期、创建体验与 Conversation 输入仲裁已实现;高级触发和执行治理待实施 |
| 版本 | 0.8 |
| 日期 | 2026-09-10 |
| 版本 | 0.9 |
| 日期 | 2026-09-17 |
| 依赖 | [Task 与 Job 统一领域模型](./task-and-job-model.md) |
| 相关架构 | [自动化平台总体设计](../../architecture/automation-platform-architecture.md) |
@@ -21,7 +21,7 @@ Scheduled Task 是带时间或事件触发器的 Task。每个 Scheduled Task
创建 Scheduled Task 时:
1. 用户选择关联当前 Conversation 或创建新 Conversation。
1. 默认关联当前 Conversation,也可选择本项目其他可用 Conversation 或创建新 Conversation。
2. 系统创建一个 Task,并保存稳定 `conversationId` 和 Schedule/Trigger Binding。
3. 每次触发在同一 Task 内创建新的 Job 和 Run。
4. 面向用户的文本进展和结果写回关联 Conversation,并标明 Task 来源。
@@ -34,11 +34,11 @@ Scheduled Task 是带时间或事件触发器的 Task。每个 Scheduled Task
GoodBuddy 当前已实现首期统一生命周期:
- 创建 Modal 可以关联当前 Conversation 或原子创建新 Conversation,不修改当前
- 创建 Modal 可以关联当前或本项目其他可用 Conversation,或原子创建新 Conversation,不修改已有
Conversation 的标题和既有消息。
- 每个 Schedule 绑定一个稳定产品级 Task 和 Conversation;重复触发复用同一身份,不再
为每次触发创建新的顶层 Task。
- 创建表单只设置任务要求、关联会话和时间;工作模式在关联会话中设置,未设置时使用项目默认值。
- 创建表单设置任务要求、可选名称、关联会话和执行时间,默认立即执行;工作模式在关联会话中设置,未设置时使用项目默认值。
- 单次、每日和每周计划支持暂停、恢复、立即运行和应用重启恢复。
- 到期和手动运行先进入关联 Conversation 的持久输入队列,与回复期间继续发送的普通消息
顺序仲裁;默认不打断当前回复,也不与其并发写入时间线。
@@ -58,7 +58,7 @@ Job/Subjob/Run 的统一持久化抽象。当前每日和每周按既有 UTC 间
- 支持单次、每日、每周、每月、工作日和受限 Cron。
- 支持 Task 完成、失败、Conversation 完成等内部事件触发。
- 创建时明确选择当前或新 Conversation。
- 创建时明确选择当前、本项目其他可用或新 Conversation。
- 使用关联会话当前的工作模式;新会话使用普通项目默认值。
- 在实际发送时解析上下文和执行配置,与手动发送共用执行流程。
- 提供时区、错过执行、幂等、租约、重试、恢复、取消、预算和审计。
@@ -82,10 +82,10 @@ Task Center 和 Conversation 操作都可以提供“新建定制任务”,但
```text
新建定制任务
创建一个可以按计划自动运行,并持续记录在会话中的任务
创建一个立即或按计划运行,并持续记录在会话中的任务
任务名称 *
[ 每周项目总结 ]
任务名称(可选)
[ 留空时从任务内容生成 ]
任务要求 *
[ 总结本周完成和失败的工作,并列出下周优先事项。 ]
@@ -97,11 +97,13 @@ Task Center 和 Conversation 操作都可以提供“新建定制任务”,但
○ 新建会话
为任务创建一条新会话,默认标题为任务名称
运行频率
[ 单次 ] [ 每日 ] [ 每周 ] [ 每月 ] [ 工作日 ] [ Cron ]
○ 本项目其他会话
[ 选择会话 ▾ ]
首次运行 [ 2026-08-21 ] [ 17:00 ]
时区 [ Asia/Shanghai ▾ ]
执行时间
◉ 立即执行 ○ 定时执行
定时选项 [ 单次 ] [ 每日 ] [ 每周 ]
首次运行 [ 日期 ] [ 时间 ](仅定时执行时显示)
关联项目
GoodBuddy Desktop · 项目工作目录
@@ -112,14 +114,18 @@ GoodBuddy Desktop · 项目工作目录
### 5.1 Conversation 选择
- 从当前聊天发起时默认选择当前 Conversation。
- 从 Task Center 发起时默认选择新 Conversation。
- 从当前聊天或 Task Center 发起时均默认选择当前 Conversation。
- 可选本项目其他可用 Conversation 或新 Conversation;当前会话不可用时明确提示用户选择,不隐藏目标。
- 当前选择必须持续可见,不能根据入口静默决定后隐藏。
- 关联当前 Conversation 不修改其标题、既有消息和普通聊天能力。
- 当前 Conversation 已有关联 Task 时,显示 Task 数量和共享上下文说明。
- 新 Conversation 默认使用 Task 名称作为标题,用户可以单独修改。
- 远程通道、归档、正在删除或 Project 不匹配的 Conversation 不可选择,并显示原因。
任务内容必填。名称可留空,使用压缩连续空白后的内容前 120 个字符生成。默认立即执行;
选择定时后支持单次、每日和每周,首次运行时间必须在未来。每月、工作日、Cron 和 IANA
时区仍属于后续能力,不显示为本次已支持选项。
### 5.2 创建摘要
提交前显示确定性摘要:
@@ -133,8 +139,13 @@ GoodBuddy Desktop · 项目工作目录
✓ 独立交付物保存到成果
```
摘要随目标会话和执行时间更新,默认立即执行时明确显示“创建后进入会话队列”。
创建 Task、可选新 Conversation、关联关系和 Schedule Binding 必须在 Main 中原子提交。
失败时保持 Modal 和用户输入,不只显示短暂通知。提交期间锁定重复操作。
立即创建同时提交一次执行及其队列项,只入队一次,遵循同会话串行规则;自动定时触发不得
再添加一次执行。事务失败保留 Modal 和输入;提交期间锁定重复操作。创建成功后的刷新失败
只提示刷新错误,不重新创建;后续执行失败显示在已创建任务中。
数据库事务与队列实现见[技术设计](./technical-design.md#custom-task-creation),其中 schedule run
与 queue 和创建记录在同一事务内写入,前端创建后不再调用 `runNow`。
### 5.3 Modal 行为与无障碍
@@ -346,7 +357,8 @@ Task Center 显示 Scheduled Task 的范围、关联 Conversation、状态、最
## 16. 验收标准
- [x] 创建 Scheduled Task 可以选择当前或新 Conversation。
- [ ] 创建默认立即执行并关联当前 Conversation,可选本项目其他可用会话或新会话;名称可留空从内容生成。
- [ ] 立即创建原子提交且只入队一次,定时器不重复触发;入队失败回滚创建,刷新或执行失败不重复创建。
- [x] 关联当前 Conversation 不修改其标题、类型或既有消息。
- [x] 一条 Conversation 可以在左侧展开一个或多个 Task。
- [x] 工作模式使用关联会话当前设置,未设置时使用项目默认值。
@@ -369,3 +381,5 @@ Task Center 显示 Scheduled Task 的范围、关联 Conversation、状态、最
- [x] 文本结果只写入 Conversation,独立交付物才进入成果。
- [ ] Task Center 和桌面通知可以打开正确 Conversation 并定位 Task。
- [ ] 应用重启不自动重放结果未知的副作用。
本次新增或变更的创建验收项未在本轮文档同步中执行验证,保留未勾选状态。
+31 -19
View File
@@ -5,22 +5,20 @@
| 项目 | 内容 |
| --- | --- |
| 状态 | Scheduled Task 与项目范围跟随已实现;Goal/Event Task 与完整操作待实施 |
| 版本 | 0.4 |
| 日期 | 2026-09-10 |
| 版本 | 0.5 |
| 日期 | 2026-09-17 |
| 依赖 | [Task 与 Job 统一领域模型](./task-and-job-model.md) |
| 界面归属 | [通用助手工作栏与执行空间](../assistant-workbar/prd.md) |
## 1. 产品定义
Task Center 是工作栏中的单实例 Task 索引,默认跟随当前项目。它不是 Automation Center,
也不复制 Conversation 内容。点击条目打开其关联 Conversation,并定位或展开对应 Task。
Task Center 是工作栏中的单实例 Task 索引,范围固定为当前项目。顶部显示当前会话统计,
下方管理本项目全部会话的任务。点击条目打开其关联 Conversation,并定位或展开对应 Task。
每个 Task 只关联一条 Conversation;一条 Conversation 可以关联零个、一个或多个 Task。
Conversation 不因为关联 Task 而改变对象类型。
“单实例”和“项目范围”是两个独立属性:应用内只有一个 Task Center Tab,但该 Tab 默认展示
当前项目的 Task。用户可以在同一 Tab 内切换到“所有项目”,不能为每个项目创建
独立 Task Center 实例。
应用内只有一个 Task Center Tab,不提供全局或所有项目入口,也不为每个项目创建独立实例。
## 2. 收录边界
@@ -45,7 +43,7 @@ Job/Subjob/Run 树、独立详情或路由。
每条 Task 至少显示:
- 名称和所属项目;无项目时标注“未绑定项目”。
- 名称和所属项目。
- 关联 Conversation 标题。
- Task 类型和触发来源。
- Ask / Execute 模式。
@@ -54,25 +52,32 @@ Job/Subjob/Run 树、独立详情或路由。
- 最近活动时间。
- 等待审批、失败或需要关注状态。
- 下次计划时间(如适用)。
- 累计执行时间:汇总该任务历次执行请求的有效区间,不使用父 Task 从创建到结束的生命周期。
Task 行只显示聚合后的用户状态,不要求用户理解内部 Job/Run。
## 4. 范围
范围控件提供:
FR-TC1:当前项目是唯一范围,列表只显示 `projectId` 匹配的 Task。切换项目刷新列表;
同一项目内切换会话只更新顶部统计,不按当前会话筛掉项目任务。没有活动项目时提示选择项目。
历史范围偏好不恢复全局或所有项目视图;范围收窄不删除历史任务或修改其项目绑定。
- `当前项目`:默认值,跟随活动项目变化,只显示 `projectId` 匹配的 Task。
- `所有项目`:显示当前用户可见的项目 Task 和没有 `projectId` 的历史 Task,后者标注“未绑定项目”。
### 4.1 当前会话统计
没有活动项目时,“当前项目”显示明确空状态,并允许切换到“所有项目”;系统不
自动改变用户已经选择的范围。范围模式作为工作栏公开偏好持久化,但“当前项目”模式不保存
具体项目 ID;重启后继续跟随届时的活动项目。旧布局保存的 `global` 范围在 UI 恢复时映射为
`all-projects`,后续保存使用新值;不修改任务数据或后端项目绑定规则。
FR-TC2:顶部紧凑展示当前会话消息总数与 Agent 累计回复时间。累计时间按每轮请求的有效
执行区间相加,包含轮内工具执行和审批等待,不计轮间空闲;不重复累加子专家用时。
缺失历史或时间证据不完整时提示“部分历史用时不可用”,已知用时不能表示为完整总量。
远程执行缺少可靠时间证据时显示不可用或不完整,不用事件接收时间虚构远程用时。
没有当前会话时提示选择会话,项目任务列表仍保留。
FR-TC3:任务累计执行时间按其历次执行请求汇总,周期触发仍显示在同一任务内。
未执行与缺失执行证据必须区分;具体关联和统计边界见[技术设计](./technical-design.md)。
## 5. 交互
- 点击条目打开关联 Conversation,并定位到该 Task。
- 状态筛选仅保留进行中、暂停、已结束,默认选择进行中。
- FR-TC4:项目任务标题右侧依次放置“+”新建按钮和筛选图标。状态筛选默认收起、默认全部,
展开后提供全部、进行中、暂停、已结束;收起不清除已选条件,非全部时图标高亮并显示筛选条件。
- 进行中包含 `idle`、`queued`、`running`、`waiting_approval`、`failed` 和 `interrupted`;
暂停对应 `paused`,已结束包含 `completed` 和 `cancelled`。分类只影响列表筛选,
不改变任务状态语义;任务行保留具体状态、错误信息和原有操作。
@@ -83,6 +88,8 @@ Task 行只显示聚合后的用户状态,不要求用户理解内部 Job/Run
- 审批徽标统计当前项目范围内全部待审批请求,不受状态筛选影响。
- 支持立即运行、暂停、恢复、取消、编辑和删除。
- 后台变化更新状态和徽标,不自动抢占当前页面。
- FR-TC5:创建默认立即执行并关联当前会话,也可选择本项目其他可用会话或新建会话,
改用单次定时、每日或每周计划。内容必填,名称可留空并从内容生成,详见[创建规则](./scheduled-task-prd.md#5-创建入口与-modal)。
- 立即运行、重试和恢复在 UI 上都是 Task 操作,内部 Job/Run 不单独显示。
- 完整消息留在 Conversation;工具、审批和错误可以在活动或 Runtime 中按 Task 查看;
独立交付物在成果中查看。
@@ -145,6 +152,11 @@ Task 行只显示聚合后的用户状态,不要求用户理解内部 Job/Run
- [x] 普通模型请求和工具调用不会误显示为 Task。
- [ ] 删除 Task 默认保留 Conversation 和既有消息。
- [x] Smart Heartbeat 不进入 Task Center。
- [x] Task Center 默认只显示当前项目的 Task,切换项目后在同一 Tab 更新列表。
- [x] 仅提供“当前项目”和“所有项目”;后者保留无项目 Task,并标注“未绑定项目”。
- [x] 范围切换不创建第二个 Task Center Tab;任务中心保持默认存在且不可关闭。
- [ ] FR-TC1:仅显示当前项目任务,切换会话不缩小列表;无项目时显示选择项目提示。
- [ ] FR-TC2:消息总数与累计回复时间随当前会话更新;轮内工具和审批计时,轮间空闲不计时,历史缺失及远程证据不足有提示。
- [ ] FR-TC3:多次执行汇总到同一任务,不按父 Task 生命周期计算用时。
- [ ] FR-TC4:标题右侧先新建、后筛选;筛选默认收起且为全部,收起后保留所选条件。
- [ ] FR-TC5:创建默认立即执行和当前会话,可选择本项目其他会话、新会话及定时周期,空名称从内容生成。
- [x] 任务中心保持单实例、默认存在且不可关闭。
本次新增或变更的验收项未在本轮文档同步中执行验证,保留未勾选状态。
@@ -0,0 +1,149 @@
# Execution Statistics Query
## Contract
`window.goodbuddy.tasks.getExecutionStats(input)` invokes `tasks:execution-stats`.
`ExecutionStatsInput` accepts exactly one UUID scope: `{ conversationId }` or
`{ projectId }`. Main validates the scope and trusted sender. The database returns:
```ts
interface ExecutionStats {
durationMs: number
requestCount: number
incompleteRequestCount: number
activeRequestCount: number
asOf: number // epoch milliseconds at query time
taskDurations: Array<{
id: string
durationMs: number
incompleteRequestCount: number
}>
}
```
The project scope sums request evidence across its conversations and returns card
totals in `taskDurations`. A scheduled execution uses `requestId = schedule_runs.id`;
join its `schedule_id` to the stable task's `schedule_id` to aggregate repeated runs.
Request creation also saves that proven relation in `parent_task_id`. Before plan
deletion cascades its run records, bind older requests that still lack this link.
Statistics can then use the retained link to a visible `origin = schedule` parent,
even after the parent's `schedule_id` has been cleared. A shared conversation alone
does not establish ownership. Other
identified requests retain their own task ID. Visible tasks with no executions
have a zero entry. Conversation queries return an empty `taskDurations` array.
Message counts used to detect missing history stay in Main; no bodies are returned.
## Evidence And Boundaries
- Query all retained scoped task records, including hidden chat requests, without
the visible-task list's pagination. Require a persisted message `request_id`
link, a reply event (`text`, `reasoning`, `tool`, `done`, `error`, `subagent`,
`generated-image`, or `question`) associated by `task_events.task_id`, a schedule
run association, or a current reply lease. Local message snapshots do not persist
request links, so local replies normally use event membership and kind. Do not
parse each stream/tool payload merely to repeat its owning request ID.
- Runtime status events also prove a reply when `requestId` matches the owning
task, `type` is `status`, and `message` is a string. Maintenance status events
such as `{ status: 'running' }` do not. Status-only interrupted replies retain
their last evidenced interval just like text/tool replies.
- Exclude stable schedule parents (`schedule_id` is set) and expert tasks
(`origin = subagent`). Scheduled reply requests remain eligible. Nested expert
execution is already inside the parent's elapsed interval and is not added again.
- Maintenance-only records do not establish reply identity. This excludes manual
context compression, heartbeat summaries, and unexecuted suggestions. A record
with no reply identity evidence cannot be classified; it is not counted.
- Sum intervals from `started_at` or later `running` status events to terminal
events/statuses. Pause/queue closes an interval; a later run starts another.
Repeated approval/running transitions do not reset the start. Tools and approval
waits inside a reply are included; idle gaps between replies/runs are excluded.
- `interrupted` may be written at application restart. Count only through the
preceding evidence, mark the request incomplete, and never use its recovery
`completed_at` as an execution endpoint. Missing or invalid timing also marks
the request incomplete; unknown time is not replaced with zero-length certainty.
- A non-aborted reply lease confirms current execution: an open local interval
with a reliable start contributes through `asOf` and is not marked incomplete
just because it is active. Manual context compression leases are excluded.
Without a live lease, stop at the latest evidence and mark an open interval
incomplete. Renderer must requery rather than extrapolate beyond `asOf`.
- Remote-recoverable records, tasks in persisted SSH project execution spaces,
and events with remote operation provenance are
incomplete and contribute no duration: their receipt timestamps cannot establish
remote execution intervals, particularly after replay. No Runtime, Agent, or
remote persistence behavior changes are required by this read-only query.
The SSH project check applies before the first provenance event, including
channel/delegation requests with `remote_recoverable = false`, so an unknown
remote duration is not first reported as complete local execution time.
- Completed, failed, or running schedule runs with no request history count as
incomplete, including in their stable task card. Pending runs do not count.
- Former schedule parents are never execution clocks, including after deletion.
A removed plan without retained execution ownership is reported as incomplete,
not a complete zero; its former lifetime cannot reconstruct missing runs.
- Retained top-level assistant messages establish missing-history evidence.
Explicit missing request links count as incomplete; unlinked snapshots use a
count comparison within each conversation/task, after covering linked replies.
Missing schedule runs already counted above cover their messages, avoiding a
second missing count. Expert-task messages and nested subagent content are not
counted again. A wholly missing reply contributes zero known duration and one
incomplete request, not a complete zero.
- Exclude the first local assistant message only when it has no request/task link
and exactly matches a shipped Chinese/English default greeting. Persisted
greetings have no separate marker. Other assistant-only history and identical
text later in the conversation remain eligible; do not discard all messages
before the first user turn.
- These counts are a lower bound when local snapshots have no request links;
fully deleted history cannot be reconstructed. `requestCount` includes replies
inferred from missing-history evidence. Concurrent top-level requests may
overlap in wall-clock time; their durations are summed independently.
The query materializes scoped task evidence once, then returns only status and
terminal events. Only status payloads use JSON extraction. Request membership and
remote provenance inspect scalar event columns; last-event and pre-interruption
timestamps use `(task_id, id)` index seeks. This preserves a tool/delta event as
the last reliable boundary without loading its body. Grouped assistant-message
counts still read task metadata from message rows rather than from every event.
It adds no tables, columns, migration, or persisted timing totals. Stable task
lifecycle timestamps are never duration endpoints.
## Validation Coverage
The focused execution-statistics tests cover hidden requests, scope isolation,
approval waits, repeated intervals, restart interruption, remote replay, missing
start times, and history beyond visible-task pagination. IPC tests cover sender
and scope validation; preload tests cover typed forwarding. Renderer integration
must supply `getExecutionStats` in its `DesktopApi.tasks` mocks.
The large-payload regression inspects the production query plan, measures paired
conversation/project polling on 3,000 events with approximately 94 MiB of payload,
and replaces stream/tool bodies with invalid JSON to prove they are not parsed.
Run it with `npx vitest run src/main/assistant/assistant-execution-stats.test.ts -t seeks --reporter=verbose --disableConsoleIntercept`
to print timings. These are synthetic warm-query measurements, not a guarantee
for arbitrarily large histories. Scalar event filtering and per-message metadata
work still grow with retained history; if that becomes measurable in deployment,
prefer polling only during active execution and refreshing on history/task changes.
## Custom Task Creation
The dialog defaults to the current conversation and immediate execution. Content
is required; an empty name uses the first 120 characters of whitespace-normalized
content. App supplies other local conversations in the current project and also
allows creating a new conversation. Scheduled creation retains once/daily/weekly
recurrence and requires a future timestamp, checked by both App and Main IPC.
`ScheduleCreateInput.runImmediately` is an optional creation command, not a stored
schedule field. It is valid only for `recurrence: 'once'`. `createSchedule` creates
the conversation (if needed), task, disabled schedule, pending schedule run and
conversation queue item in one SQLite transaction. Queue insertion failure rolls
back all of them. Disabling automatic scheduling prevents timer ticks from adding
another occurrence; pending/running queue state still determines task status.
The create IPC handler publishes the queue change and wakes the existing
conversation queue pump. App does not follow creation with `runNow`. Execution
uses the existing readiness and conversation serialization rules. A runtime
failure belongs to the created task; it does not reject creation or create another
task. Refresh failures after a successful creation are notifications, not a reason
to retry creation. No schema migration or preload changes are required.
`schedule-creation.test.ts` verifies transaction rollback and timer non-duplication
with real SQLite. IPC tests exercise that database through the production queue
and ordinary run handler with a controlled runtime, including failed execution.
App creation tests cover destination selection, time validation, and failure handling.