@dijkspicy/opencode-stats-reporter 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/contrib/opencode-session-reporter/README.md +4 -2
- package/contrib/opencode-session-reporter/package.json +1 -1
- package/contrib/opencode-session-reporter/reporter.test.ts +186 -2
- package/contrib/opencode-session-reporter/session-usage-reporter.ts +76 -21
- package/package.json +1 -1
- package/panel/css/style.css +81 -2
- package/panel/index.html +11 -11
- package/panel/js/api.js +4 -1
- package/panel/js/sessions.js +226 -30
- package/src/server.js +11 -2
- package/src/session-api.js +17 -0
- package/src/store.js +113 -14
|
@@ -65,7 +65,7 @@ config.json 不存在、JSON 非法、字段缺失或 URL 非法时一律静默
|
|
|
65
65
|
{
|
|
66
66
|
"schema_version": 1,
|
|
67
67
|
"install_id": "0b3f7e6a-....",
|
|
68
|
-
"reporter": { "client": "opencode", "version": "0.1.
|
|
68
|
+
"reporter": { "client": "opencode", "version": "0.1.6" },
|
|
69
69
|
"sessions": [
|
|
70
70
|
{
|
|
71
71
|
"session_id": "ses_...",
|
|
@@ -81,13 +81,15 @@ config.json 不存在、JSON 非法、字段缺失或 URL 非法时一律静默
|
|
|
81
81
|
"session_created_at": 1759000000000,
|
|
82
82
|
"session_title": "每30s自动刷新功能",
|
|
83
83
|
"session_directory": "/opt/projects/BlueRegionUsage",
|
|
84
|
+
"parent_session_id": "ses_...",
|
|
85
|
+
"root_session_id": "ses_...",
|
|
84
86
|
"last_activity_at": 1759003600123
|
|
85
87
|
}
|
|
86
88
|
]
|
|
87
89
|
}
|
|
88
90
|
```
|
|
89
91
|
|
|
90
|
-
`model` 取该会话最后一条已完成助手消息的 `providerID/modelID`;`agent` 取其 `mode`(无则为 `null`)。`session_title` / `session_directory`
|
|
92
|
+
`model` 取该会话最后一条已完成助手消息的 `providerID/modelID`;`agent` 取其 `mode`(无则为 `null`)。`session_title` / `session_directory` / `parent_session_id`(取自 `session.get` 的 `parentID`)复用**同一次** `session.get` 调用(不新增 API 请求),取不到时为 `null`(不是空字符串)。`root_session_id` 为沿 `parentID` 回溯到的顶层祖先(深度上限 20、防环;祖先已删除/报错时以最近一次验证成功的会话为根;自身即顶层时等于 `session_id`)。载荷中**不存在** consumer/identity 类字段。
|
|
91
93
|
|
|
92
94
|
## 测试
|
|
93
95
|
|
|
@@ -78,7 +78,7 @@ const makeClient = (
|
|
|
78
78
|
messages: Record<string, UsageMessage[]>,
|
|
79
79
|
children: Record<string, string[]> = {},
|
|
80
80
|
/** session.get 的返回元数据;不传则 client 不含 session.get */
|
|
81
|
-
meta?: { created?: number | null; title?: string | null; directory?: string | null; throws?: boolean },
|
|
81
|
+
meta?: { created?: number | null; title?: string | null; directory?: string | null; parentID?: string | null; throws?: boolean },
|
|
82
82
|
): ReporterClient => ({
|
|
83
83
|
session: {
|
|
84
84
|
messages: async (args: { path: { id: string } }) => ({ data: messages[args.path.id] ?? [] }),
|
|
@@ -92,7 +92,12 @@ const makeClient = (
|
|
|
92
92
|
throw new Error("boom: session.get failed")
|
|
93
93
|
}
|
|
94
94
|
: async () => ({
|
|
95
|
-
data: {
|
|
95
|
+
data: {
|
|
96
|
+
time: { created: meta.created ?? 1_700_000_000_000 },
|
|
97
|
+
title: meta.title ?? null,
|
|
98
|
+
directory: meta.directory ?? null,
|
|
99
|
+
parentID: meta.parentID ?? null,
|
|
100
|
+
},
|
|
96
101
|
}),
|
|
97
102
|
}
|
|
98
103
|
: {}),
|
|
@@ -137,6 +142,8 @@ const snap = (sessionID: string, over: Partial<SessionSnapshot> = {}): SessionSn
|
|
|
137
142
|
session_created_at: null,
|
|
138
143
|
session_title: null,
|
|
139
144
|
session_directory: null,
|
|
145
|
+
parent_session_id: null,
|
|
146
|
+
root_session_id: null,
|
|
140
147
|
last_activity_at: 1_000 + snapSeq,
|
|
141
148
|
...over,
|
|
142
149
|
}
|
|
@@ -149,7 +156,9 @@ const SESSION_KEYS = [
|
|
|
149
156
|
"last_activity_at",
|
|
150
157
|
"message_count",
|
|
151
158
|
"model",
|
|
159
|
+
"parent_session_id",
|
|
152
160
|
"revision",
|
|
161
|
+
"root_session_id",
|
|
153
162
|
"session_created_at",
|
|
154
163
|
"session_directory",
|
|
155
164
|
"session_id",
|
|
@@ -554,6 +563,181 @@ await runTest("session.get 不存在 → 三个元数据字段均为 null", asyn
|
|
|
554
563
|
assertEqual(snapshot!.session_created_at, null, "created → null")
|
|
555
564
|
})
|
|
556
565
|
|
|
566
|
+
/** 辅助:用给定 client 采集,返回指定会话快照的 root_session_id */
|
|
567
|
+
const collectAndRootOf = async (client: ReporterClient, trigger: string, want?: string): Promise<string | null> => {
|
|
568
|
+
const { core } = makeCore(client, { endpoint: "http://e" })
|
|
569
|
+
await core.collectSession(trigger)
|
|
570
|
+
const snap = core.queue.drainAll().find((s) => s.session_id === (want ?? trigger))
|
|
571
|
+
return snap ? snap.root_session_id : null
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
await runTest("session.get 返回 parentID → 快照/载荷带上 parent_session_id;顶层会话 root 等于自身", async () => {
|
|
575
|
+
// 顶层会话:无 parentID
|
|
576
|
+
const topClient = makeClient(
|
|
577
|
+
{ ses_top: [assistantMessage()] },
|
|
578
|
+
{},
|
|
579
|
+
{ parentID: null, title: "顶层任务", directory: "/opt/x" },
|
|
580
|
+
)
|
|
581
|
+
const { core } = makeCore(topClient, { endpoint: "http://e" })
|
|
582
|
+
await core.collectSession("ses_top")
|
|
583
|
+
const topSnap = core.queue.drainAll().find((s) => s.session_id === "ses_top")
|
|
584
|
+
assert(!!topSnap, "应产出快照")
|
|
585
|
+
assertEqual(topSnap!.parent_session_id, null, "顶层无父")
|
|
586
|
+
assertEqual(topSnap!.root_session_id, "ses_top", "自身即顶层 → root 等于 session_id")
|
|
587
|
+
|
|
588
|
+
// 带父会话的子会话 → 载荷验证
|
|
589
|
+
const childClient = makeClient({ ses_c: [assistantMessage()] }, { ses_p: ["ses_c"] }, { parentID: "ses_p" })
|
|
590
|
+
const { fetchFn, calls } = makeFetch("ok")
|
|
591
|
+
const q = createQueue(50)
|
|
592
|
+
const core2 = makeCore(childClient, { endpoint: "http://e", queue: q, fetchFn }).core
|
|
593
|
+
await core2.collectSession("ses_c")
|
|
594
|
+
const childSnap = q.drainAll().find((s) => s.session_id === "ses_c")
|
|
595
|
+
assert(!!childSnap, "子会话应产出快照")
|
|
596
|
+
assertEqual(childSnap!.parent_session_id, "ses_p", "快照 parent_session_id")
|
|
597
|
+
assertEqual(childSnap!.root_session_id, "ses_p", "root 解析为已验证的顶层")
|
|
598
|
+
|
|
599
|
+
q.push(childSnap!)
|
|
600
|
+
const core3 = makeCore(makeClient({}), { endpoint: "http://e", queue: q, fetchFn }).core
|
|
601
|
+
await core3.flushOnce()
|
|
602
|
+
const body = JSON.parse(calls[0]!.init.body ?? "{}")
|
|
603
|
+
assertEqual(body.sessions[0].parent_session_id, "ses_p", "载荷 parent_session_id")
|
|
604
|
+
assertEqual(body.sessions[0].root_session_id, "ses_p", "载荷 root_session_id")
|
|
605
|
+
})
|
|
606
|
+
|
|
607
|
+
await runTest("子会话触发采集时能上溯到根:root_session_id 等于顶层 id", async () => {
|
|
608
|
+
// 结构:ses_top(顶层) → ses_mid → ses_leaf
|
|
609
|
+
const client = makeClient(
|
|
610
|
+
{ ses_top: [assistantMessage()], ses_mid: [assistantMessage()], ses_leaf: [assistantMessage()] },
|
|
611
|
+
{ ses_top: ["ses_mid"], ses_mid: ["ses_leaf"] },
|
|
612
|
+
)
|
|
613
|
+
const getCalls: string[] = []
|
|
614
|
+
client.session.get = (async (args: { path: { id: string } }) => {
|
|
615
|
+
getCalls.push(args.path.id)
|
|
616
|
+
const parentID = args.path.id === "ses_top" ? null : args.path.id === "ses_mid" ? "ses_top" : "ses_mid"
|
|
617
|
+
return { data: { time: { created: 1 }, title: `t-${args.path.id}`, directory: "/d", parentID } }
|
|
618
|
+
}) as typeof client.session.get
|
|
619
|
+
|
|
620
|
+
const { core } = makeCore(client, { endpoint: "http://e" })
|
|
621
|
+
await core.collectSession("ses_leaf") // 由最深层子会话触发
|
|
622
|
+
|
|
623
|
+
const drained = core.queue.drainAll()
|
|
624
|
+
const leaf = drained.find((s) => s.session_id === "ses_leaf")
|
|
625
|
+
const mid = drained.find((s) => s.session_id === "ses_mid")
|
|
626
|
+
const top = drained.find((s) => s.session_id === "ses_top")
|
|
627
|
+
assert(!!leaf && !!mid && !!top, "从根开始应连带采集整棵树")
|
|
628
|
+
assertEqual(leaf!.root_session_id, "ses_top", "leaf root")
|
|
629
|
+
assertEqual(leaf!.parent_session_id, "ses_mid", "leaf parent")
|
|
630
|
+
assertEqual(mid!.root_session_id, "ses_top", "mid root")
|
|
631
|
+
assertEqual(top!.root_session_id, "ses_top", "top root 等于自身")
|
|
632
|
+
assertEqual(top!.parent_session_id, null, "top 无父")
|
|
633
|
+
assert(getCalls.includes("ses_top") && getCalls.includes("ses_mid") && getCalls.includes("ses_leaf"), "回溯与采集覆盖全链")
|
|
634
|
+
})
|
|
635
|
+
|
|
636
|
+
await runTest("同一子会话由不同入口触发,root_session_id 一致", async () => {
|
|
637
|
+
const parentOf = (id: string) => (id === "ses_top" ? null : "ses_top")
|
|
638
|
+
const buildClient = () => {
|
|
639
|
+
const client = makeClient(
|
|
640
|
+
{ ses_top: [assistantMessage()], ses_child: [assistantMessage()] },
|
|
641
|
+
{ ses_top: ["ses_child"] },
|
|
642
|
+
)
|
|
643
|
+
client.session.get = (async (args: { path: { id: string } }) => ({
|
|
644
|
+
data: { time: { created: 1 }, title: null, directory: null, parentID: parentOf(args.path.id) },
|
|
645
|
+
})) as typeof client.session.get
|
|
646
|
+
return client
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// 入口一:子会话自身的空闲信号;入口二:顶层会话的空闲信号
|
|
650
|
+
const rootFromChild = await collectAndRootOf(buildClient(), "ses_child", "ses_child")
|
|
651
|
+
const rootFromTop = await collectAndRootOf(buildClient(), "ses_top", "ses_child")
|
|
652
|
+
assertEqual(rootFromChild, "ses_top", "子会话入口 → root=顶层")
|
|
653
|
+
assertEqual(rootFromTop, "ses_top", "顶层入口 → 子会话 root=顶层")
|
|
654
|
+
assertEqual(rootFromChild, rootFromTop, "两个入口解析出的 root 一致")
|
|
655
|
+
})
|
|
656
|
+
|
|
657
|
+
await runTest("祖先缺失(get 抛错)→ 以最近验证成功的会话为根,不崩溃不丢快照", async () => {
|
|
658
|
+
// 链:ses_child → ses_ghost(已删除,get 抛错)
|
|
659
|
+
const client = makeClient({ ses_child: [assistantMessage()] })
|
|
660
|
+
client.session.get = (async (args: { path: { id: string } }) => {
|
|
661
|
+
if (args.path.id === "ses_ghost") throw new Error("deleted session")
|
|
662
|
+
return {
|
|
663
|
+
data: {
|
|
664
|
+
time: { created: 1 },
|
|
665
|
+
title: null,
|
|
666
|
+
directory: null,
|
|
667
|
+
parentID: args.path.id === "ses_child" ? "ses_ghost" : null,
|
|
668
|
+
},
|
|
669
|
+
}
|
|
670
|
+
}) as typeof client.session.get
|
|
671
|
+
|
|
672
|
+
const { core } = makeCore(client, { endpoint: "http://e" })
|
|
673
|
+
await core.collectSession("ses_child")
|
|
674
|
+
const child = core.queue.drainAll().find((s) => s.session_id === "ses_child")
|
|
675
|
+
assert(!!child, "主流程不崩溃,快照仍产生")
|
|
676
|
+
assertEqual(child!.root_session_id, "ses_child", "以最近验证成功的会话为根")
|
|
677
|
+
assertEqual(child!.parent_session_id, "ses_ghost", "直接父保留(即使其已删除)")
|
|
678
|
+
})
|
|
679
|
+
|
|
680
|
+
await runTest("血缘完全不可解析(get 全抛错)→ 血缘为 null,退化为从传入 id 采集", async () => {
|
|
681
|
+
const client = makeClient({ ses_x: [assistantMessage()] }, {}, { throws: true })
|
|
682
|
+
const { core } = makeCore(client, { endpoint: "http://e" })
|
|
683
|
+
await core.collectSession("ses_x")
|
|
684
|
+
const snap = core.queue.drainAll().find((s) => s.session_id === "ses_x")
|
|
685
|
+
assert(!!snap, "不崩溃,快照仍产生")
|
|
686
|
+
assertEqual(snap!.root_session_id, null, "root 不可知 → null")
|
|
687
|
+
assertEqual(snap!.parent_session_id, null, "parent 不可知 → null")
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
await runTest("祖先已删除(get 返回 data 缺失)→ 回溯就地停止,root 为最近验证成功的会话", async () => {
|
|
691
|
+
// 链:ses_leaf → ses_mid(已删除,get 返回 data: undefined) → ses_root(实际不可达)
|
|
692
|
+
const client = makeClient({ ses_leaf: [assistantMessage()] })
|
|
693
|
+
client.session.get = (async (args: { path: { id: string } }) => {
|
|
694
|
+
if (args.path.id === "ses_mid") return { data: undefined }
|
|
695
|
+
if (args.path.id === "ses_leaf")
|
|
696
|
+
return { data: { time: { created: 1 }, title: null, directory: null, parentID: "ses_mid" } }
|
|
697
|
+
return { data: { time: { created: 1 }, title: null, directory: null, parentID: null } }
|
|
698
|
+
}) as typeof client.session.get
|
|
699
|
+
|
|
700
|
+
const { core } = makeCore(client, { endpoint: "http://e" })
|
|
701
|
+
await core.collectSession("ses_leaf")
|
|
702
|
+
const leaf = core.queue.drainAll().find((s) => s.session_id === "ses_leaf")
|
|
703
|
+
assert(!!leaf, "主流程不崩溃,快照仍产生")
|
|
704
|
+
assertEqual(leaf!.root_session_id, "ses_leaf", "root 应为最近真正取到元数据的 leaf")
|
|
705
|
+
assert(leaf!.root_session_id !== "ses_mid", "root 不得指向已删除的幽灵会话")
|
|
706
|
+
assertEqual(leaf!.parent_session_id, "ses_mid", "直接父保留(即使其已删除)")
|
|
707
|
+
})
|
|
708
|
+
|
|
709
|
+
await runTest("起始节点自身已删除(data 缺失)→ 血缘 null,退化为从传入 id 采集", async () => {
|
|
710
|
+
const client = makeClient({ ses_ghost: [assistantMessage()] })
|
|
711
|
+
client.session.get = (async () => ({ data: undefined })) as typeof client.session.get
|
|
712
|
+
|
|
713
|
+
const { core } = makeCore(client, { endpoint: "http://e" })
|
|
714
|
+
await core.collectSession("ses_ghost")
|
|
715
|
+
const snap = core.queue.drainAll().find((s) => s.session_id === "ses_ghost")
|
|
716
|
+
assert(!!snap, "不崩溃;messages 仍可用时快照照常产出")
|
|
717
|
+
assertEqual(snap!.root_session_id, null, "血缘不可知 → null")
|
|
718
|
+
assertEqual(snap!.parent_session_id, null, "parent 不可知 → null")
|
|
719
|
+
assertEqual(snap!.session_title, null, "title 不可知 → null")
|
|
720
|
+
assertEqual(snap!.session_directory, null, "directory 不可知 → null")
|
|
721
|
+
})
|
|
722
|
+
|
|
723
|
+
await runTest("存在但字段缺失(无 parentID/time)→ 视为存在的顶层,不误判为已删除", async () => {
|
|
724
|
+
const client = makeClient({ ses_min: [assistantMessage()] })
|
|
725
|
+
client.session.get = (async (args: { path: { id: string } }) => {
|
|
726
|
+
if (args.path.id === "ses_min") return { data: { id: "ses_min", title: "T", directory: "/d" } }
|
|
727
|
+
return { data: undefined }
|
|
728
|
+
}) as typeof client.session.get
|
|
729
|
+
|
|
730
|
+
const { core } = makeCore(client, { endpoint: "http://e" })
|
|
731
|
+
await core.collectSession("ses_min")
|
|
732
|
+
const snap = core.queue.drainAll().find((s) => s.session_id === "ses_min")
|
|
733
|
+
assert(!!snap, "应产出快照")
|
|
734
|
+
assertEqual(snap!.root_session_id, "ses_min", "存在的顶层 → root 等于自身")
|
|
735
|
+
assertEqual(snap!.parent_session_id, null, "无 parentID → null")
|
|
736
|
+
assertEqual(snap!.session_title, "T", "title 正常带上")
|
|
737
|
+
assertEqual(snap!.session_directory, "/d", "directory 正常带上")
|
|
738
|
+
assertEqual(snap!.session_created_at, null, "无 time → created 为 null")
|
|
739
|
+
})
|
|
740
|
+
|
|
557
741
|
// ---------------------------------------------------------------------------
|
|
558
742
|
// 汇总
|
|
559
743
|
// ---------------------------------------------------------------------------
|
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
* - 在会话空闲(bus 事件 `session.idle`,或 `session.status` 且 `status.type === "idle"`)时,
|
|
6
6
|
* 读取该会话及其全部后代子会话的「已完成」助手消息 token 用量,按会话各自聚合为快照。
|
|
7
7
|
* - 父会话快照绝不并入子会话用量;每个会话(含子代理会话)都是独立记录。
|
|
8
|
+
* - 血缘字段:parent_session_id = 直接父会话(session.get 的 parentID);root_session_id = 沿
|
|
9
|
+
* parentID 回溯到的顶层祖先(自身即顶层时等于 session_id)。祖先查不到(已删除/报错)时
|
|
10
|
+
* 就地停止,以最近一次验证成功的会话为根;完全不可解析时血缘为 null。
|
|
8
11
|
* - token 口径原样分列上报(input / output / reasoning / cache.read / cache.write),
|
|
9
12
|
* 不在客户端做任何相加或换算(`output` 不含 reasoning,`input` 不含 cache.read)。
|
|
10
13
|
* - hook 只触发后台采集并入队,绝不等待网络;由后台 flush 统一 POST 到解析出的上报端点
|
|
@@ -43,7 +46,7 @@ import { dirname, join } from "node:path"
|
|
|
43
46
|
// ---------------------------------------------------------------------------
|
|
44
47
|
|
|
45
48
|
const SCHEMA_VERSION = 1
|
|
46
|
-
const PLUGIN_VERSION = "0.1.
|
|
49
|
+
const PLUGIN_VERSION = "0.1.6"
|
|
47
50
|
/** 队列容量:最多积压 500 条会话快照,溢出丢最旧 */
|
|
48
51
|
const QUEUE_CAP = 500
|
|
49
52
|
/** 单次上报请求超时(AbortSignal.timeout) */
|
|
@@ -57,6 +60,8 @@ const FLUSH_DEBOUNCE_MS = 250
|
|
|
57
60
|
const DISPOSE_BUDGET_MS = 3_000
|
|
58
61
|
/** 子会话递归深度上限(防御环/异常数据) */
|
|
59
62
|
const MAX_SESSION_DEPTH = 32
|
|
63
|
+
/** 血缘回溯(向上找顶层祖先)深度上限(防御环/异常数据) */
|
|
64
|
+
const MAX_ANCESTOR_DEPTH = 20
|
|
60
65
|
|
|
61
66
|
const INSTALL_ID_STATE_FILE = () => join(homedir(), ".local", "state", "opencode-session-reporter", "install_id")
|
|
62
67
|
|
|
@@ -113,6 +118,8 @@ export type SessionSnapshot = {
|
|
|
113
118
|
session_created_at: number | null
|
|
114
119
|
session_title: string | null
|
|
115
120
|
session_directory: string | null
|
|
121
|
+
parent_session_id: string | null
|
|
122
|
+
root_session_id: string | null
|
|
116
123
|
last_activity_at: number | null
|
|
117
124
|
}
|
|
118
125
|
|
|
@@ -148,6 +155,14 @@ export type ReporterClient = {
|
|
|
148
155
|
}
|
|
149
156
|
}
|
|
150
157
|
|
|
158
|
+
/** 一次 session.get 能取到的会话元数据;null 表示本次调用失败 / 不可用(get 缺失、抛错、会话已删除) */
|
|
159
|
+
export type SessionMeta = {
|
|
160
|
+
created: number | null
|
|
161
|
+
title: string | null
|
|
162
|
+
directory: string | null
|
|
163
|
+
parentID: string | null
|
|
164
|
+
}
|
|
165
|
+
|
|
151
166
|
/** 可注入的定时器(测试用手动调度器替换,避免真实 setTimeout) */
|
|
152
167
|
export type ReporterScheduler = (fn: () => void, delayMs: number) => () => void
|
|
153
168
|
|
|
@@ -260,6 +275,8 @@ const aggregateSessionImpl = (sessionID: string, messages: readonly UsageMessage
|
|
|
260
275
|
session_created_at: null, // 由采集层补充
|
|
261
276
|
session_title: null, // 由采集层补充(session.get)
|
|
262
277
|
session_directory: null, // 由采集层补充(session.get)
|
|
278
|
+
parent_session_id: null, // 由采集层补充(session.get 的 parentID)
|
|
279
|
+
root_session_id: null, // 由采集层补充(血缘回溯解析出的顶层祖先)
|
|
263
280
|
last_activity_at: lastActivity,
|
|
264
281
|
}
|
|
265
282
|
}
|
|
@@ -517,52 +534,61 @@ const createReporterCoreImpl = (options: ReporterCoreOptions): ReporterCore => {
|
|
|
517
534
|
return ids
|
|
518
535
|
}
|
|
519
536
|
|
|
520
|
-
const
|
|
521
|
-
sessionID: string,
|
|
522
|
-
): Promise<{ created: number | null; title: string | null; directory: string | null }> => {
|
|
537
|
+
const fetchSessionMeta = async (sessionID: string): Promise<SessionMeta | null> => {
|
|
523
538
|
try {
|
|
524
539
|
const getSession = options.client.session.get
|
|
525
|
-
if (typeof getSession !== "function") return
|
|
540
|
+
if (typeof getSession !== "function") return null
|
|
526
541
|
const response = await getSession.call(options.client.session, { path: { id: sessionID }, query: query() })
|
|
527
542
|
const data = (
|
|
528
543
|
response as
|
|
529
|
-
| { data?: { time?: { created?: unknown }; title?: unknown; directory?: unknown } }
|
|
544
|
+
| { data?: { time?: { created?: unknown }; title?: unknown; directory?: unknown; parentID?: unknown } }
|
|
530
545
|
| undefined
|
|
531
546
|
)?.data
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
547
|
+
// 「取不到会话」(data 缺失 / null / 非对象,典型为会话已删除)必须与
|
|
548
|
+
// 「取到了但某些字段缺失」区分开:前者返回 null,血缘回溯据此就地停止,
|
|
549
|
+
// 不得把该会话当作已验证的祖先(否则 root 会指向幽灵会话)。
|
|
550
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) return null
|
|
551
|
+
const created = data.time?.created
|
|
552
|
+
const parentID = data.parentID
|
|
535
553
|
return {
|
|
536
554
|
created: typeof created === "number" ? created : null,
|
|
537
|
-
title: typeof title === "string" && title ? title : null,
|
|
538
|
-
directory: typeof directory === "string" && directory ? directory : null,
|
|
555
|
+
title: typeof data.title === "string" && data.title ? data.title : null,
|
|
556
|
+
directory: typeof data.directory === "string" && data.directory ? data.directory : null,
|
|
557
|
+
parentID: typeof parentID === "string" && parentID ? parentID : null,
|
|
539
558
|
}
|
|
540
559
|
} catch {
|
|
541
|
-
// session.get 抛错 /
|
|
542
|
-
return
|
|
560
|
+
// session.get 抛错 / 形状异常:元数据不可用(null),绝不影响采集主流程
|
|
561
|
+
return null
|
|
543
562
|
}
|
|
544
563
|
}
|
|
545
564
|
|
|
546
|
-
/**
|
|
565
|
+
/**
|
|
566
|
+
* 递归聚合会话及其后代;每个会话独立成快照,父快照不含子用量。
|
|
567
|
+
* rootSessionID 为血缘回溯解析出的顶层祖先(不可解析时为 null);metaGet 为单次采集运行内的
|
|
568
|
+
* 元数据缓存读取器(血缘回溯与递归采集共用同一次 session.get 结果,不新增 API 请求)。
|
|
569
|
+
*/
|
|
547
570
|
const collectRecursive = async (
|
|
548
571
|
sessionID: string,
|
|
549
572
|
out: SessionSnapshot[],
|
|
550
573
|
visited: Set<string>,
|
|
551
574
|
depth: number,
|
|
575
|
+
rootSessionID: string | null,
|
|
576
|
+
metaGet: (id: string) => Promise<SessionMeta | null>,
|
|
552
577
|
): Promise<void> => {
|
|
553
578
|
if (!sessionID || visited.has(sessionID) || depth > MAX_SESSION_DEPTH || visited.size >= QUEUE_CAP) return
|
|
554
579
|
visited.add(sessionID)
|
|
555
580
|
const snapshot = aggregateSessionImpl(sessionID, await readMessages(sessionID))
|
|
556
581
|
if (snapshot) {
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
snapshot.
|
|
560
|
-
snapshot.
|
|
561
|
-
snapshot.
|
|
582
|
+
const meta = await metaGet(sessionID)
|
|
583
|
+
snapshot.session_created_at = meta?.created ?? null
|
|
584
|
+
snapshot.session_title = meta?.title ?? null
|
|
585
|
+
snapshot.session_directory = meta?.directory ?? null
|
|
586
|
+
snapshot.parent_session_id = meta?.parentID ?? null
|
|
587
|
+
snapshot.root_session_id = rootSessionID
|
|
562
588
|
out.push(snapshot)
|
|
563
589
|
}
|
|
564
590
|
for (const childID of await readChildIDs(sessionID)) {
|
|
565
|
-
await collectRecursive(childID, out, visited, depth + 1)
|
|
591
|
+
await collectRecursive(childID, out, visited, depth + 1, rootSessionID, metaGet)
|
|
566
592
|
}
|
|
567
593
|
}
|
|
568
594
|
|
|
@@ -571,8 +597,37 @@ const createReporterCoreImpl = (options: ReporterCoreOptions): ReporterCore => {
|
|
|
571
597
|
if (inFlight.has(sessionID)) return // 同一会话采集中:跳过,下次空闲信号再采
|
|
572
598
|
inFlight.add(sessionID)
|
|
573
599
|
try {
|
|
600
|
+
// 单次采集运行内的元数据缓存:血缘回溯与递归采集共用同一次 session.get 结果。
|
|
601
|
+
// 顶层会话的常见路径(get 一次、无 parent)不产生任何额外 API 请求。
|
|
602
|
+
const metaCache = new Map<string, SessionMeta | null>()
|
|
603
|
+
const metaGet = async (id: string): Promise<SessionMeta | null> => {
|
|
604
|
+
if (metaCache.has(id)) return metaCache.get(id) ?? null
|
|
605
|
+
const meta = await fetchSessionMeta(id)
|
|
606
|
+
metaCache.set(id, meta)
|
|
607
|
+
return meta
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// 1) 血缘回溯:从触发会话沿 parentID 向上找顶层祖先。
|
|
611
|
+
// 边界:深度上限 MAX_ANCESTOR_DEPTH、visitedAncestors 防环(含自父);
|
|
612
|
+
// 祖先查不到(已删除 / get 抛错 / 不可用)就地停止,以「最近一次验证成功的会话」为根;
|
|
613
|
+
// 起始节点自身查不到 → 根不可知(null),退化为从传入 id 开始采集。
|
|
614
|
+
const visitedAncestors = new Set<string>([sessionID])
|
|
615
|
+
let current = sessionID
|
|
616
|
+
let verifiedRoot: string | null = null
|
|
617
|
+
for (let depth = 0; depth < MAX_ANCESTOR_DEPTH; depth++) {
|
|
618
|
+
const meta = await metaGet(current)
|
|
619
|
+
if (!meta) break // 查不到:就地停止
|
|
620
|
+
verifiedRoot = current
|
|
621
|
+
const parentID = meta.parentID
|
|
622
|
+
if (!parentID || visitedAncestors.has(parentID)) break // 到顶 / 环 / 自父
|
|
623
|
+
visitedAncestors.add(parentID)
|
|
624
|
+
current = parentID
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// 2) 从根开始递归采集(同一次空闲信号能连带刷新所有兄弟会话)
|
|
628
|
+
const startID = verifiedRoot ?? sessionID
|
|
574
629
|
const out: SessionSnapshot[] = []
|
|
575
|
-
await collectRecursive(
|
|
630
|
+
await collectRecursive(startID, out, new Set(), 0, verifiedRoot, metaGet)
|
|
576
631
|
for (const snapshot of out) queue.push(snapshot)
|
|
577
632
|
if (out.length > 0) scheduleFlush()
|
|
578
633
|
} catch (error) {
|
package/package.json
CHANGED
package/panel/css/style.css
CHANGED
|
@@ -537,9 +537,9 @@ body {
|
|
|
537
537
|
white-space: nowrap;
|
|
538
538
|
vertical-align: bottom;
|
|
539
539
|
font-weight: 600;
|
|
540
|
-
cursor: pointer;
|
|
541
540
|
}
|
|
542
|
-
.sess-title
|
|
541
|
+
.sess-title.is-link { cursor: pointer; }
|
|
542
|
+
.sess-title.is-link:hover { color: var(--accent-hover); text-decoration: underline; }
|
|
543
543
|
.sess-sub-line {
|
|
544
544
|
display: flex;
|
|
545
545
|
align-items: center;
|
|
@@ -562,6 +562,85 @@ body {
|
|
|
562
562
|
.sess-dir { max-width: 120px; }
|
|
563
563
|
}
|
|
564
564
|
|
|
565
|
+
/* 任务行:箭头展开控件 + 「任务」徽标 + 会话数徽标;token 列为任务合计,模型/代理/消息数留 '--' */
|
|
566
|
+
#session-table tr.sess-task > td { padding-top: 14px; }
|
|
567
|
+
tr.sess-task .sess-title-line { display: flex; align-items: center; gap: 6px; }
|
|
568
|
+
.sess-task-badge {
|
|
569
|
+
flex: none;
|
|
570
|
+
padding: 0 7px;
|
|
571
|
+
border: 1px solid var(--border);
|
|
572
|
+
border-radius: 999px;
|
|
573
|
+
color: var(--text-secondary);
|
|
574
|
+
font-size: 10px;
|
|
575
|
+
font-weight: 600;
|
|
576
|
+
line-height: 16px;
|
|
577
|
+
}
|
|
578
|
+
.sess-count-chip {
|
|
579
|
+
flex: none;
|
|
580
|
+
padding: 0 7px;
|
|
581
|
+
border: 1px solid var(--border);
|
|
582
|
+
border-radius: 999px;
|
|
583
|
+
background: var(--bg);
|
|
584
|
+
color: var(--text-secondary);
|
|
585
|
+
font-size: 10px;
|
|
586
|
+
line-height: 16px;
|
|
587
|
+
font-variant-numeric: tabular-nums;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/* 展开/收起箭头:独立点击区,与标题(进详情)互不误触;加载中转圈 */
|
|
591
|
+
.sess-expand {
|
|
592
|
+
flex: none;
|
|
593
|
+
width: 18px;
|
|
594
|
+
height: 18px;
|
|
595
|
+
padding: 0;
|
|
596
|
+
border: none;
|
|
597
|
+
background: transparent;
|
|
598
|
+
display: inline-flex;
|
|
599
|
+
align-items: center;
|
|
600
|
+
justify-content: center;
|
|
601
|
+
cursor: pointer;
|
|
602
|
+
}
|
|
603
|
+
.sess-expand:focus-visible { outline: 1px solid var(--accent); }
|
|
604
|
+
.sess-expand .tri {
|
|
605
|
+
width: 0;
|
|
606
|
+
height: 0;
|
|
607
|
+
border-left: 5px solid var(--text-secondary);
|
|
608
|
+
border-top: 4px solid transparent;
|
|
609
|
+
border-bottom: 4px solid transparent;
|
|
610
|
+
transition: transform .15s;
|
|
611
|
+
}
|
|
612
|
+
.sess-expand:hover .tri { border-left-color: var(--accent); }
|
|
613
|
+
.sess-expand .tri.is-open { transform: rotate(90deg); }
|
|
614
|
+
.sess-expand .spin {
|
|
615
|
+
width: 10px;
|
|
616
|
+
height: 10px;
|
|
617
|
+
border: 2px solid var(--border);
|
|
618
|
+
border-top-color: var(--accent);
|
|
619
|
+
border-radius: 50%;
|
|
620
|
+
animation: sess-spin .8s linear infinite;
|
|
621
|
+
}
|
|
622
|
+
@keyframes sess-spin { to { transform: rotate(360deg); } }
|
|
623
|
+
|
|
624
|
+
/* 任务展开区的加载 / 错误通栏行:与子会话行同缩进 */
|
|
625
|
+
#session-table tr.sess-expand-state > td {
|
|
626
|
+
font-size: 12px;
|
|
627
|
+
color: var(--text-secondary);
|
|
628
|
+
padding-left: 30px;
|
|
629
|
+
}
|
|
630
|
+
#session-table tr.sess-expand-state > td .btn { margin-left: 10px; }
|
|
631
|
+
#session-table tbody tr.sess-expand-state:hover { background: transparent; }
|
|
632
|
+
|
|
633
|
+
#session-table tr.sess-child .sess-cell { position: relative; padding-left: 30px; }
|
|
634
|
+
#session-table tr.sess-child .sess-cell::before {
|
|
635
|
+
content: "";
|
|
636
|
+
position: absolute;
|
|
637
|
+
left: 14px;
|
|
638
|
+
top: 0;
|
|
639
|
+
bottom: 0;
|
|
640
|
+
width: 2px;
|
|
641
|
+
background: var(--border);
|
|
642
|
+
}
|
|
643
|
+
|
|
565
644
|
/* 会话 ID:默认弱化(有标题时),等宽头尾截断,完整值在 title,点击进详情 */
|
|
566
645
|
.sess-id-btn {
|
|
567
646
|
display: inline-block;
|
package/panel/index.html
CHANGED
|
@@ -280,7 +280,7 @@
|
|
|
280
280
|
<!-- 会话列表 -->
|
|
281
281
|
<section id="card-session-list" class="card">
|
|
282
282
|
<div class="table-head">
|
|
283
|
-
<h2 class="card-title"
|
|
283
|
+
<h2 class="card-title">任务列表 <span class="src-badge" title="数据来自客户端上报,可能延迟或遗漏">客户端上报</span></h2>
|
|
284
284
|
<div class="sess-filter">
|
|
285
285
|
<label class="sess-filter-item">模型
|
|
286
286
|
<select id="sess-filter-model" aria-label="按模型筛选">
|
|
@@ -296,16 +296,16 @@
|
|
|
296
296
|
<table id="session-table" class="session-table">
|
|
297
297
|
<thead>
|
|
298
298
|
<tr>
|
|
299
|
-
<th title="
|
|
300
|
-
<th>模型</th>
|
|
301
|
-
<th>代理</th>
|
|
302
|
-
<th class="num" title="输入总量 = 输入 + 缓存读 +
|
|
303
|
-
<th class="num" title="输出总量 = 输出 +
|
|
304
|
-
<th class="num">推理</th>
|
|
305
|
-
<th class="num">缓存读</th>
|
|
306
|
-
<th class="num">缓存写</th>
|
|
307
|
-
<th class="num">消息数</th>
|
|
308
|
-
<th>最后活动</th>
|
|
299
|
+
<th title="每行一个顶层任务(token 为任务合计);点箭头展开/收起子会话,点标题查看根会话详情">任务</th>
|
|
300
|
+
<th title="会话级属性;任务行不显示,展开子会话查看">模型</th>
|
|
301
|
+
<th title="会话级属性;任务行不显示,展开子会话查看">代理</th>
|
|
302
|
+
<th class="num" title="输入总量 = 输入 + 缓存读 + 缓存写;任务行 = 该任务全部会话合计">输入总量</th>
|
|
303
|
+
<th class="num" title="输出总量 = 输出 + 推理;任务行 = 该任务全部会话合计">输出总量</th>
|
|
304
|
+
<th class="num" title="任务行 = 该任务全部会话合计">推理</th>
|
|
305
|
+
<th class="num" title="任务行 = 该任务全部会话合计">缓存读</th>
|
|
306
|
+
<th class="num" title="任务行 = 该任务全部会话合计">缓存写</th>
|
|
307
|
+
<th class="num" title="会话级属性;任务行不聚合消息数">消息数</th>
|
|
308
|
+
<th title="任务行 = 该任务最近一次会话活动">最后活动</th>
|
|
309
309
|
</tr>
|
|
310
310
|
</thead>
|
|
311
311
|
<tbody id="session-tbody"></tbody>
|
package/panel/js/api.js
CHANGED
|
@@ -89,7 +89,9 @@
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
// ---- 会话用量接口(自建会话服务,经同一网关地址访问;数据为客户端上报口径,非网关权威) ----
|
|
92
|
-
/**
|
|
92
|
+
/** 任务聚合:/v1/tasks,按顶层会话聚合的 token 合计 + 会话数(服务端口径),window/model/agent/limit/cursor */
|
|
93
|
+
function tasks(cfg, params) { return get(cfg, '/v1/tasks', params || {}); }
|
|
94
|
+
/** 会话列表:window / model / agent / root_session_id / install_id / limit / cursor(游标分页) */
|
|
93
95
|
function sessions(cfg, params) { return get(cfg, '/v1/sessions', params || {}); }
|
|
94
96
|
/** 单会话详情:不存在或不属于当前消费者时返回 404 */
|
|
95
97
|
function sessionDetail(cfg, id) { return get(cfg, '/v1/sessions/' + encodeURIComponent(id)); }
|
|
@@ -144,6 +146,7 @@
|
|
|
144
146
|
byModel: byModel,
|
|
145
147
|
byProvider: byProvider,
|
|
146
148
|
performance: performance,
|
|
149
|
+
tasks: tasks,
|
|
147
150
|
sessions: sessions,
|
|
148
151
|
sessionDetail: sessionDetail,
|
|
149
152
|
sessionsSummary: sessionsSummary,
|
package/panel/js/sessions.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* 数据源:/v1/sessions
|
|
2
|
+
* 会话视图:以顶层任务为主维度的 token 用量
|
|
3
|
+
* 数据源:/v1/tasks(任务级聚合,含 token 合计与会话数)+ /v1/sessions?root_session_id=(展开时按需拉取子会话)
|
|
4
|
+
* 均为客户端上报口径,可能延迟/遗漏,非网关权威数据
|
|
4
5
|
* token 口径(已实测验证):输入总量 = 输入 + 缓存读 + 缓存写;输出总量 = 输出 + 推理(推理单列,不并入输出)
|
|
5
|
-
*
|
|
6
|
+
* 任务行直接展示服务端 tokens_input_total / tokens_output_total(与子会话行同口径),客户端不做页内相加
|
|
7
|
+
* 结构:来源声明 → 用量对账(网关权威总量 − 会话上报合计 = 未归属用量)→ 任务列表(子会话默认收起、点击展开 + 窗口/模型/代理筛选 + 游标分页)→ 会话详情
|
|
6
8
|
* 由 app.js 初始化:BRU.sessions.init({ getCfg, getPeriod, getModelCatalog, onUnauthorized }),并经 BRU.sessions.load() 触发拉取
|
|
7
9
|
*/
|
|
8
10
|
(function () {
|
|
@@ -20,11 +22,13 @@
|
|
|
20
22
|
var st = {
|
|
21
23
|
seq: 0, // 请求代次:响应到达时核对,过期响应(切窗/切筛选后)直接丢弃
|
|
22
24
|
period: 'today',
|
|
23
|
-
|
|
24
|
-
|
|
25
|
+
tasks: [], // 已加载任务(/v1/tasks 分页累积,服务端按任务级 last_activity_at 倒序)
|
|
26
|
+
taskCursor: null, // 下一页游标(null = 没有更多)
|
|
27
|
+
expanded: {}, // root_session_id -> { status: 'loading'|'open'|'closed'|'error', rows: []|null, error, pending }
|
|
28
|
+
// 子会话拉取后缓存于 rows:重复展开不再请求;切窗/切筛选(load)时整体重置
|
|
25
29
|
filters: { model: '', agent: '' },
|
|
26
|
-
modelSet: {}, // 模型筛选项(模型目录 +
|
|
27
|
-
agentSet: {}, //
|
|
30
|
+
modelSet: {}, // 模型筛选项(模型目录 + 已展开子会话模型)
|
|
31
|
+
agentSet: {}, // 代理筛选项(已展开子会话代理)
|
|
28
32
|
selectedId: null // 当前打开详情的会话 ID
|
|
29
33
|
};
|
|
30
34
|
|
|
@@ -77,7 +81,7 @@
|
|
|
77
81
|
}
|
|
78
82
|
|
|
79
83
|
// ---------- 加载编排 ----------
|
|
80
|
-
/**
|
|
84
|
+
/** 整页加载:任务列表重置到第一页 + 对账聚合;seq 保证切窗/切筛选后的旧响应不回写;展开态与子会话缓存一并重置 */
|
|
81
85
|
function load() {
|
|
82
86
|
var cfg = deps && deps.getCfg();
|
|
83
87
|
if (!cfg) return Promise.resolve();
|
|
@@ -85,34 +89,34 @@
|
|
|
85
89
|
st.period = deps.getPeriod();
|
|
86
90
|
setCardState(el.coverageCard, 'loading');
|
|
87
91
|
setCardState(el.listCard, 'loading');
|
|
88
|
-
st.
|
|
89
|
-
st.
|
|
92
|
+
st.tasks = [];
|
|
93
|
+
st.taskCursor = null;
|
|
94
|
+
st.expanded = {};
|
|
90
95
|
el.tbody.innerHTML = '';
|
|
91
96
|
el.count.textContent = '';
|
|
92
97
|
hide(el.btnMore);
|
|
93
98
|
closeDetail();
|
|
94
99
|
var w = st.period;
|
|
95
100
|
return Promise.all([
|
|
96
|
-
|
|
101
|
+
fetchTasks(cfg, w, null, true, seq),
|
|
97
102
|
fetchCoverage(cfg, w, seq)
|
|
98
103
|
]);
|
|
99
104
|
}
|
|
100
105
|
|
|
101
|
-
/**
|
|
102
|
-
function
|
|
106
|
+
/** 拉取一页任务:isReset=true 首页(覆盖),false 追加(加载更多);失败走卡片错误层 + 重试 */
|
|
107
|
+
function fetchTasks(cfg, window_, cursor, isReset, seq) {
|
|
103
108
|
var params = { window: window_, limit: PAGE_SIZE };
|
|
104
109
|
if (st.filters.model) params.model = st.filters.model;
|
|
105
110
|
if (st.filters.agent) params.agent = st.filters.agent;
|
|
106
111
|
if (cursor) params.cursor = cursor;
|
|
107
|
-
return API.
|
|
112
|
+
return API.tasks(cfg, params).then(function (res) {
|
|
108
113
|
if (seq !== st.seq) return;
|
|
109
114
|
var rows = (res && res.data) || [];
|
|
110
|
-
st.
|
|
111
|
-
st.
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if (st.nextCursor) show(el.btnMore); else hide(el.btnMore);
|
|
115
|
+
st.tasks = isReset ? rows : st.tasks.concat(rows);
|
|
116
|
+
st.taskCursor = (res && res.next_cursor) || null;
|
|
117
|
+
renderTasks();
|
|
118
|
+
el.count.textContent = '已加载 ' + st.tasks.length + ' 个任务' + (st.taskCursor ? '(还有更多)' : '');
|
|
119
|
+
if (st.taskCursor) show(el.btnMore); else hide(el.btnMore);
|
|
116
120
|
if (isReset && !rows.length) {
|
|
117
121
|
setCardState(el.listCard, 'empty', null, null,
|
|
118
122
|
(st.filters.model || st.filters.agent) ? '当前筛选条件下暂无会话上报' : null);
|
|
@@ -124,7 +128,7 @@
|
|
|
124
128
|
if (seq !== st.seq) return;
|
|
125
129
|
if (err && err.code === 'unauthorized') { deps.onUnauthorized(UNAUTH_MSG); return; }
|
|
126
130
|
setCardState(el.listCard, 'error', (err && err.message) || '加载失败', function () {
|
|
127
|
-
|
|
131
|
+
fetchTasks(deps.getCfg(), deps.getPeriod(), cursor, isReset, st.seq);
|
|
128
132
|
});
|
|
129
133
|
});
|
|
130
134
|
}
|
|
@@ -189,11 +193,189 @@
|
|
|
189
193
|
el.covNote.textContent = note;
|
|
190
194
|
}
|
|
191
195
|
|
|
192
|
-
// ----------
|
|
193
|
-
|
|
196
|
+
// ---------- 任务列表 ----------
|
|
197
|
+
/**
|
|
198
|
+
* 默认只渲染任务行(/v1/tasks,服务端已按任务级 last_activity_at 倒序,客户端不再重排)。
|
|
199
|
+
* 子会话默认收起:展开态的任务行下方按需插入「加载中 / 错误可重试 / 子会话行」。
|
|
200
|
+
* root_session_id 为空的任务(旧数据无血缘)按单会话任务降级:无展开控件、标题缺失时降级 '--'。
|
|
201
|
+
*/
|
|
202
|
+
function renderTasks() {
|
|
194
203
|
var tbody = el.tbody;
|
|
195
204
|
tbody.innerHTML = '';
|
|
196
|
-
st.
|
|
205
|
+
st.tasks.forEach(function (t) {
|
|
206
|
+
var rootId = nonEmpty(t.root_session_id);
|
|
207
|
+
tbody.appendChild(taskRow(t, rootId));
|
|
208
|
+
var s = rootId ? st.expanded[rootId] : null;
|
|
209
|
+
if (!s) return;
|
|
210
|
+
if (s.status === 'loading') tbody.appendChild(stateRow('正在加载子会话…'));
|
|
211
|
+
else if (s.status === 'error') tbody.appendChild(stateRow('子会话加载失败:' + (s.error || '加载失败'), rootId));
|
|
212
|
+
else if (s.status === 'open') (s.rows || []).forEach(function (r) { tbody.appendChild(sessionRow(r)); });
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** 展开/收起:已拉取的子会话缓存复用(重复展开不再请求);加载中点击 = 收起并丢弃未返回的结果 */
|
|
217
|
+
function toggleTask(rootId) {
|
|
218
|
+
var s = st.expanded[rootId];
|
|
219
|
+
if (s && s.status === 'open') { s.status = 'closed'; renderTasks(); return; }
|
|
220
|
+
if (s && s.status === 'loading') { s.pending = false; s.status = 'closed'; renderTasks(); return; }
|
|
221
|
+
if (s && s.rows) { s.status = 'open'; renderTasks(); return; } // 收起后再展开:用缓存
|
|
222
|
+
st.expanded[rootId] = { status: 'loading', rows: null, pending: true };
|
|
223
|
+
renderTasks();
|
|
224
|
+
fetchChildren(rootId);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** 拉取单个任务的全部会话(根自身 + 全部子会话,limit=200 上限);pending 防止已收起后迟到响应回写 */
|
|
228
|
+
function fetchChildren(rootId) {
|
|
229
|
+
var cfg = deps.getCfg();
|
|
230
|
+
if (!cfg) return;
|
|
231
|
+
var seq = st.seq;
|
|
232
|
+
API.sessions(cfg, { root_session_id: rootId, limit: 200 }).then(function (res) {
|
|
233
|
+
if (seq !== st.seq) return;
|
|
234
|
+
var s = st.expanded[rootId];
|
|
235
|
+
if (!s || !s.pending) return;
|
|
236
|
+
s.rows = (res && res.data) || [];
|
|
237
|
+
s.status = 'open';
|
|
238
|
+
s.pending = false;
|
|
239
|
+
absorbOptions(s.rows); // 子会话的模型/代理补进筛选项
|
|
240
|
+
renderTasks();
|
|
241
|
+
}, function (err) {
|
|
242
|
+
if (seq !== st.seq) return;
|
|
243
|
+
var s = st.expanded[rootId];
|
|
244
|
+
if (!s || !s.pending) return;
|
|
245
|
+
s.pending = false;
|
|
246
|
+
if (err && err.code === 'unauthorized') { deps.onUnauthorized(UNAUTH_MSG); return; }
|
|
247
|
+
s.status = 'error';
|
|
248
|
+
s.error = (err && err.message) || '加载失败';
|
|
249
|
+
renderTasks();
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** 展开控件:箭头按钮,独立于标题的点击区;状态(开/合/加载中)每次渲染时按 st 重建 */
|
|
254
|
+
function expandButton(rootId) {
|
|
255
|
+
var b = document.createElement('button');
|
|
256
|
+
b.type = 'button';
|
|
257
|
+
b.className = 'sess-expand';
|
|
258
|
+
var tri = document.createElement('span');
|
|
259
|
+
var s = st.expanded[rootId];
|
|
260
|
+
if (s && s.status === 'open') { tri.className = 'tri is-open'; b.title = '收起子会话'; b.setAttribute('aria-label', '收起子会话'); }
|
|
261
|
+
else if (s && s.status === 'loading') { tri.className = 'spin'; b.title = '正在加载子会话'; b.setAttribute('aria-label', '正在加载子会话'); }
|
|
262
|
+
else { tri.className = 'tri'; b.title = '展开子会话'; b.setAttribute('aria-label', '展开子会话'); }
|
|
263
|
+
b.appendChild(tri);
|
|
264
|
+
b.addEventListener('click', function () { toggleTask(rootId); });
|
|
265
|
+
return b;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** 任务行展开区的状态行:colspan 通栏;error 态带重试按钮 */
|
|
269
|
+
function stateRow(text, errorRootId) {
|
|
270
|
+
var tr = document.createElement('tr');
|
|
271
|
+
tr.className = 'sess-expand-state';
|
|
272
|
+
var c = document.createElement('td');
|
|
273
|
+
c.colSpan = 10;
|
|
274
|
+
c.textContent = text;
|
|
275
|
+
if (errorRootId) {
|
|
276
|
+
var btn = document.createElement('button');
|
|
277
|
+
btn.className = 'btn btn-sm';
|
|
278
|
+
btn.textContent = '重试';
|
|
279
|
+
btn.addEventListener('click', function () {
|
|
280
|
+
st.expanded[errorRootId] = { status: 'loading', rows: null, pending: true };
|
|
281
|
+
renderTasks();
|
|
282
|
+
fetchChildren(errorRootId);
|
|
283
|
+
});
|
|
284
|
+
c.appendChild(btn);
|
|
285
|
+
}
|
|
286
|
+
tr.appendChild(c);
|
|
287
|
+
return tr;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* 任务行:与子会话行共用同一套列(token 各列同口径),仅「模型 / 代理 / 消息数」为会话级属性,
|
|
292
|
+
* 任务行显示 '--' 并在悬浮说明,不用某个子会话的值冒充任务值。
|
|
293
|
+
* token 合计取服务端 tokens_input_total / tokens_output_total(契约与客户端派生公式一致),缺失时才退回客户端派生。
|
|
294
|
+
*/
|
|
295
|
+
function taskRow(t, rootId) {
|
|
296
|
+
var tr = document.createElement('tr');
|
|
297
|
+
tr.className = 'sess-task';
|
|
298
|
+
var rawTitle = nonEmpty(t.session_title);
|
|
299
|
+
var dir = nonEmpty(t.session_directory);
|
|
300
|
+
|
|
301
|
+
var cell = document.createElement('td');
|
|
302
|
+
cell.className = 'sess-cell';
|
|
303
|
+
|
|
304
|
+
var line1 = document.createElement('div');
|
|
305
|
+
line1.className = 'sess-title-line';
|
|
306
|
+
var cnt = Number(t.session_count);
|
|
307
|
+
var hasCount = isFinite(cnt) && cnt > 0;
|
|
308
|
+
// 单会话任务(session_count<=1)没有可展开的子会话,不提供展开控件,避免展开后空组
|
|
309
|
+
var expandable = rootId && (!hasCount || cnt > 1);
|
|
310
|
+
if (expandable) line1.appendChild(expandButton(rootId));
|
|
311
|
+
|
|
312
|
+
var badge = document.createElement('span');
|
|
313
|
+
badge.className = 'sess-task-badge';
|
|
314
|
+
badge.textContent = '任务';
|
|
315
|
+
line1.appendChild(badge);
|
|
316
|
+
|
|
317
|
+
var titleEl = document.createElement('span');
|
|
318
|
+
titleEl.className = 'sess-title' + (rootId ? ' is-link' : '');
|
|
319
|
+
titleEl.textContent = rawTitle || (rootId ? shortId(rootId) : '--');
|
|
320
|
+
if (rootId) {
|
|
321
|
+
titleEl.title = (rawTitle || rootId) + '\n点击查看根会话详情';
|
|
322
|
+
titleEl.addEventListener('click', function () { openDetail(rootId); });
|
|
323
|
+
} else if (rawTitle) {
|
|
324
|
+
titleEl.title = rawTitle;
|
|
325
|
+
}
|
|
326
|
+
line1.appendChild(titleEl);
|
|
327
|
+
|
|
328
|
+
if (hasCount) {
|
|
329
|
+
var chip = document.createElement('span');
|
|
330
|
+
chip.className = 'sess-count-chip';
|
|
331
|
+
chip.textContent = F.fullNumber(cnt) + ' 个会话';
|
|
332
|
+
chip.title = '该任务共 ' + F.fullNumber(cnt) + ' 个会话(含顶层会话与子会话)';
|
|
333
|
+
line1.appendChild(chip);
|
|
334
|
+
}
|
|
335
|
+
cell.appendChild(line1);
|
|
336
|
+
|
|
337
|
+
var line2 = document.createElement('div');
|
|
338
|
+
line2.className = 'sess-sub-line';
|
|
339
|
+
if (dir) {
|
|
340
|
+
var d = document.createElement('span');
|
|
341
|
+
d.className = 'sess-dir mono';
|
|
342
|
+
d.textContent = shortDir(dir);
|
|
343
|
+
d.title = '目录:' + dir;
|
|
344
|
+
line2.appendChild(d);
|
|
345
|
+
}
|
|
346
|
+
if (rootId) {
|
|
347
|
+
if (dir) {
|
|
348
|
+
var sep = document.createElement('span');
|
|
349
|
+
sep.className = 'sess-sub-sep';
|
|
350
|
+
sep.textContent = '·';
|
|
351
|
+
line2.appendChild(sep);
|
|
352
|
+
}
|
|
353
|
+
line2.appendChild(idButton(rootId, !rawTitle));
|
|
354
|
+
}
|
|
355
|
+
if (line2.childNodes.length) cell.appendChild(line2);
|
|
356
|
+
|
|
357
|
+
tr.appendChild(cell);
|
|
358
|
+
|
|
359
|
+
tr.appendChild(td('--', null, '任务行不显示会话级模型,展开查看各子会话'));
|
|
360
|
+
tr.appendChild(td('--', null, '任务行不显示会话级代理,展开查看各子会话'));
|
|
361
|
+
tr.appendChild(td(F.fullNumber(taskTotal(t, 'tokens_input_total', inputTotal)), 'num', '输入总量 = 输入 + 缓存读 + 缓存写'));
|
|
362
|
+
tr.appendChild(td(F.fullNumber(taskTotal(t, 'tokens_output_total', outputTotal)), 'num', '输出总量 = 输出 + 推理'));
|
|
363
|
+
tr.appendChild(td(F.fullNumber(t.tokens_reasoning), 'num'));
|
|
364
|
+
tr.appendChild(td(F.fullNumber(t.tokens_cache_read), 'num'));
|
|
365
|
+
tr.appendChild(td(F.fullNumber(t.tokens_cache_write), 'num'));
|
|
366
|
+
tr.appendChild(td('--', null, '任务级不聚合消息数,展开查看各子会话'));
|
|
367
|
+
var lastFull = F.dateTime(t.last_activity_at);
|
|
368
|
+
var firstFull = F.dateTime(t.first_activity_at);
|
|
369
|
+
var tip = lastFull === '--' ? '' : '最后活动:' + lastFull;
|
|
370
|
+
if (tip && firstFull !== '--') tip += '\n首次活动:' + firstFull;
|
|
371
|
+
tr.appendChild(td(F.shortDateTime(t.last_activity_at), null, tip));
|
|
372
|
+
return tr;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** 任务级合计:优先服务端聚合字段,缺失时退回与子会话行相同的客户端派生口径 */
|
|
376
|
+
function taskTotal(t, serverField, derive) {
|
|
377
|
+
var v = t[serverField];
|
|
378
|
+
return (typeof v === 'number' && isFinite(v)) ? v : derive(t);
|
|
197
379
|
}
|
|
198
380
|
|
|
199
381
|
/** 空值守卫:非字符串或空白串返回 null,避免把 null/undefined/空串渲染到页面 */
|
|
@@ -214,6 +396,16 @@
|
|
|
214
396
|
return '…/' + parts.slice(-2).join('/');
|
|
215
397
|
}
|
|
216
398
|
|
|
399
|
+
/**
|
|
400
|
+
* 子代理会话标题尾部「 (@xxx subagent)」与「代理」列信息重复,展示时去掉;完整原标题始终保留在悬浮。
|
|
401
|
+
* 仅在后缀中的代理名与该行 agent 字段一致时才去(不一致说明后缀另有含义,原样保留)。
|
|
402
|
+
*/
|
|
403
|
+
function displayTitle(title, agent) {
|
|
404
|
+
var m = title.match(/\s*\(@([^\s()]+) subagent\)$/);
|
|
405
|
+
if (m && agent && m[1] === agent) return title.slice(0, m.index).replace(/\s+$/, '');
|
|
406
|
+
return title;
|
|
407
|
+
}
|
|
408
|
+
|
|
217
409
|
/** 会话 ID 按钮:等宽展示(头尾截断),完整值放 title,点击进详情;primary = 无标题时作为降级主标识 */
|
|
218
410
|
function idButton(sid, primary) {
|
|
219
411
|
var b = document.createElement('button');
|
|
@@ -226,12 +418,14 @@
|
|
|
226
418
|
}
|
|
227
419
|
|
|
228
420
|
/**
|
|
229
|
-
*
|
|
421
|
+
* 子会话行(任务展开后插入):两行结构
|
|
230
422
|
* 主行:会话标题(加粗);无标题时降级为会话 ID(等宽、高亮、可点)
|
|
231
423
|
* 次行:项目目录(末两级压缩,悬浮显全路径)· 会话 ID(弱化;ID 已作主标识时不重复)
|
|
424
|
+
* token 各列与任务行同列同口径(会话级分量)。
|
|
232
425
|
*/
|
|
233
426
|
function sessionRow(r) {
|
|
234
427
|
var tr = document.createElement('tr');
|
|
428
|
+
tr.className = 'sess-child';
|
|
235
429
|
var title = nonEmpty(r.session_title);
|
|
236
430
|
var dir = nonEmpty(r.session_directory);
|
|
237
431
|
var sid = nonEmpty(r.session_id) || '--';
|
|
@@ -243,8 +437,8 @@
|
|
|
243
437
|
line1.className = 'sess-title-line';
|
|
244
438
|
if (title) {
|
|
245
439
|
var t = document.createElement('span');
|
|
246
|
-
t.className = 'sess-title';
|
|
247
|
-
t.textContent = title;
|
|
440
|
+
t.className = 'sess-title is-link';
|
|
441
|
+
t.textContent = displayTitle(title, nonEmpty(r.agent));
|
|
248
442
|
t.title = title + '\n点击查看详情';
|
|
249
443
|
t.addEventListener('click', function () { openDetail(sid); });
|
|
250
444
|
line1.appendChild(t);
|
|
@@ -381,6 +575,8 @@
|
|
|
381
575
|
body.appendChild(group('会话信息', [
|
|
382
576
|
item('会话标题', nonEmpty(d.session_title) || '--'),
|
|
383
577
|
item('项目目录', nonEmpty(d.session_directory) || '--', true),
|
|
578
|
+
item('根会话', nonEmpty(d.root_session_id) || '--', true),
|
|
579
|
+
item('父会话', nonEmpty(d.parent_session_id) || '--', true),
|
|
384
580
|
item('模型', d.model || '--'),
|
|
385
581
|
item('代理', d.agent || '--'),
|
|
386
582
|
item('安装标识', d.install_id || '--', true),
|
|
@@ -450,11 +646,11 @@
|
|
|
450
646
|
};
|
|
451
647
|
|
|
452
648
|
el.btnMore.addEventListener('click', function () {
|
|
453
|
-
if (!st.
|
|
649
|
+
if (!st.taskCursor) return;
|
|
454
650
|
el.btnMore.disabled = true;
|
|
455
651
|
el.btnMore.textContent = '加载中…';
|
|
456
|
-
//
|
|
457
|
-
|
|
652
|
+
// fetchTasks 内部消化错误,此处仅恢复按钮状态
|
|
653
|
+
fetchTasks(deps.getCfg(), deps.getPeriod(), st.taskCursor, false, st.seq)
|
|
458
654
|
.then(function () {
|
|
459
655
|
el.btnMore.disabled = false;
|
|
460
656
|
el.btnMore.textContent = '加载更多';
|
package/src/server.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import http from "node:http"
|
|
2
2
|
import fs from "node:fs"
|
|
3
3
|
import path from "node:path"
|
|
4
|
-
import { ApiError, report, listSessions, sessionDetail, sessionsSummary } from "./session-api.js"
|
|
4
|
+
import { ApiError, report, listSessions, sessionDetail, sessionsSummary, listTasks } from "./session-api.js"
|
|
5
5
|
import { createProxy } from "./proxy.js"
|
|
6
6
|
import { createIdentityResolver } from "./identity.js"
|
|
7
7
|
|
|
@@ -102,7 +102,11 @@ export function createServer({ config, store, panelDir, mode = "local", consumer
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
// 会话类 → 本地存储;托管模式需网关注入身份,本地模式归入单一本地用户
|
|
105
|
-
const isSessionRoute =
|
|
105
|
+
const isSessionRoute =
|
|
106
|
+
p === "/v1/session-usage/report" ||
|
|
107
|
+
p === "/v1/sessions" ||
|
|
108
|
+
p === "/v1/tasks" ||
|
|
109
|
+
p.startsWith("/v1/sessions/")
|
|
106
110
|
if (isSessionRoute) {
|
|
107
111
|
const consumer = resolveConsumer(req)
|
|
108
112
|
if (!consumer) {
|
|
@@ -132,6 +136,11 @@ export function createServer({ config, store, panelDir, mode = "local", consumer
|
|
|
132
136
|
sendJson(res, 200, sessionsSummary(store, consumer, url.searchParams))
|
|
133
137
|
return
|
|
134
138
|
}
|
|
139
|
+
// /v1/tasks 必须先于任何 :id 形式的捕获
|
|
140
|
+
if (p === "/v1/tasks" && req.method === "GET") {
|
|
141
|
+
sendJson(res, 200, listTasks(store, consumer, url.searchParams))
|
|
142
|
+
return
|
|
143
|
+
}
|
|
135
144
|
if (p.startsWith("/v1/sessions/") && req.method === "GET") {
|
|
136
145
|
const id = decodeURIComponent(p.slice("/v1/sessions/".length))
|
|
137
146
|
if (!id || id.includes("/")) {
|
package/src/session-api.js
CHANGED
|
@@ -73,6 +73,23 @@ export function listSessions(store, consumer, params) {
|
|
|
73
73
|
model: params.get("model") || undefined,
|
|
74
74
|
agent: params.get("agent") || undefined,
|
|
75
75
|
installId: params.get("install_id") || undefined,
|
|
76
|
+
rootSessionId: params.get("root_session_id") || undefined,
|
|
77
|
+
limit,
|
|
78
|
+
cursor: cursorRaw ? decodeCursor(cursorRaw) : null,
|
|
79
|
+
})
|
|
80
|
+
return { object: "list", window: window || null, as_of: iso(), data, next_cursor: nextCursor ? encodeCursor(nextCursor) : null }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** GET /v1/tasks —— 顶层任务聚合(分组键 COALESCE(root_session_id, session_id)) */
|
|
84
|
+
export function listTasks(store, consumer, params) {
|
|
85
|
+
const window = params.get("window") || undefined
|
|
86
|
+
if (window && !WINDOWS.includes(window)) throw new ApiError(400, "window 必须是: today, week, month", "invalid_window")
|
|
87
|
+
const limit = boundedInt(params.get("limit"), "limit", 50, 1, 200)
|
|
88
|
+
const cursorRaw = params.get("cursor")
|
|
89
|
+
const { data, nextCursor } = store.listTasks(consumer, {
|
|
90
|
+
window,
|
|
91
|
+
model: params.get("model") || undefined,
|
|
92
|
+
agent: params.get("agent") || undefined,
|
|
76
93
|
limit,
|
|
77
94
|
cursor: cursorRaw ? decodeCursor(cursorRaw) : null,
|
|
78
95
|
})
|
package/src/store.js
CHANGED
|
@@ -31,6 +31,8 @@ CREATE TABLE IF NOT EXISTS session_usage (
|
|
|
31
31
|
last_reported_at INTEGER NOT NULL,
|
|
32
32
|
session_title TEXT,
|
|
33
33
|
session_directory TEXT,
|
|
34
|
+
parent_session_id TEXT,
|
|
35
|
+
root_session_id TEXT,
|
|
34
36
|
PRIMARY KEY (consumer_id, install_id, session_id)
|
|
35
37
|
);
|
|
36
38
|
CREATE INDEX IF NOT EXISTS idx_su_consumer_activity ON session_usage(consumer_id, last_activity_at DESC);
|
|
@@ -93,6 +95,8 @@ export function sanitizeRecord(raw) {
|
|
|
93
95
|
client_session_header: str(raw.client_session_header),
|
|
94
96
|
session_title: str(raw.session_title, 300),
|
|
95
97
|
session_directory: str(raw.session_directory, 512),
|
|
98
|
+
parent_session_id: str(raw.parent_session_id, 200),
|
|
99
|
+
root_session_id: str(raw.root_session_id, 200),
|
|
96
100
|
}
|
|
97
101
|
}
|
|
98
102
|
|
|
@@ -125,7 +129,12 @@ export class Store {
|
|
|
125
129
|
const existing = new Set(
|
|
126
130
|
this.db.prepare("PRAGMA table_info(session_usage)").all().map((r) => r.name),
|
|
127
131
|
)
|
|
128
|
-
for (const [name, type] of [
|
|
132
|
+
for (const [name, type] of [
|
|
133
|
+
["session_title", "TEXT"],
|
|
134
|
+
["session_directory", "TEXT"],
|
|
135
|
+
["parent_session_id", "TEXT"],
|
|
136
|
+
["root_session_id", "TEXT"],
|
|
137
|
+
]) {
|
|
129
138
|
if (!existing.has(name)) {
|
|
130
139
|
this.db.exec(`ALTER TABLE session_usage ADD COLUMN ${name} ${type}`)
|
|
131
140
|
}
|
|
@@ -149,22 +158,22 @@ export class Store {
|
|
|
149
158
|
let accepted = 0
|
|
150
159
|
let skipped = 0
|
|
151
160
|
const find = this.db.prepare(
|
|
152
|
-
"SELECT revision, last_activity_at, session_title, session_directory FROM session_usage WHERE consumer_id=? AND install_id=? AND session_id=?",
|
|
161
|
+
"SELECT revision, last_activity_at, session_title, session_directory, parent_session_id, root_session_id FROM session_usage WHERE consumer_id=? AND install_id=? AND session_id=?",
|
|
153
162
|
)
|
|
154
163
|
const backfill = this.db.prepare(
|
|
155
|
-
"UPDATE session_usage SET session_title=?, session_directory=?, last_reported_at=? WHERE consumer_id=? AND install_id=? AND session_id=?",
|
|
164
|
+
"UPDATE session_usage SET session_title=?, session_directory=?, parent_session_id=?, root_session_id=?, last_reported_at=? WHERE consumer_id=? AND install_id=? AND session_id=?",
|
|
156
165
|
)
|
|
157
166
|
const insert = this.db.prepare(`INSERT INTO session_usage
|
|
158
167
|
(consumer_id, install_id, session_id, source, schema_version, reporter_version, client_session_header,
|
|
159
168
|
model, agent, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
|
|
160
169
|
cost, message_count, revision, session_created_at, last_activity_at, client_reported_at,
|
|
161
|
-
first_seen_at, last_reported_at, session_title, session_directory)
|
|
162
|
-
VALUES (
|
|
170
|
+
first_seen_at, last_reported_at, session_title, session_directory, parent_session_id, root_session_id)
|
|
171
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
|
163
172
|
const update = this.db.prepare(`UPDATE session_usage SET
|
|
164
173
|
source=?, schema_version=?, reporter_version=?, client_session_header=?, model=?, agent=?,
|
|
165
174
|
tokens_input=?, tokens_output=?, tokens_reasoning=?, tokens_cache_read=?, tokens_cache_write=?,
|
|
166
175
|
cost=?, message_count=?, revision=?, session_created_at=?, last_activity_at=?, client_reported_at=?,
|
|
167
|
-
last_reported_at=?, session_title=?, session_directory=?
|
|
176
|
+
last_reported_at=?, session_title=?, session_directory=?, parent_session_id=?, root_session_id=?
|
|
168
177
|
WHERE consumer_id=? AND install_id=? AND session_id=?`)
|
|
169
178
|
|
|
170
179
|
for (const r of records) {
|
|
@@ -176,7 +185,7 @@ export class Store {
|
|
|
176
185
|
consumerId, r.install_id, r.session_id, source, schemaVersion, reporterVersion, r.client_session_header,
|
|
177
186
|
r.model, r.agent, r.tokens_input, r.tokens_output, r.tokens_reasoning, r.tokens_cache_read, r.tokens_cache_write,
|
|
178
187
|
r.cost, r.message_count, r.revision, r.session_created_at, r.last_activity_at, r.client_reported_at, ts, ts,
|
|
179
|
-
r.session_title, r.session_directory,
|
|
188
|
+
r.session_title, r.session_directory, r.parent_session_id, r.root_session_id,
|
|
180
189
|
)
|
|
181
190
|
} else if (r.revision > prev.revision || (r.revision === prev.revision && r.last_activity_at > prev.last_activity_at)) {
|
|
182
191
|
status = "upserted"
|
|
@@ -184,19 +193,25 @@ export class Store {
|
|
|
184
193
|
source, schemaVersion, reporterVersion, r.client_session_header, r.model, r.agent,
|
|
185
194
|
r.tokens_input, r.tokens_output, r.tokens_reasoning, r.tokens_cache_read, r.tokens_cache_write,
|
|
186
195
|
r.cost, r.message_count, r.revision, r.session_created_at, r.last_activity_at, r.client_reported_at, ts,
|
|
187
|
-
r.session_title, r.session_directory,
|
|
196
|
+
r.session_title, r.session_directory, r.parent_session_id, r.root_session_id,
|
|
188
197
|
consumerId, r.install_id, r.session_id,
|
|
189
198
|
)
|
|
190
199
|
} else if (r.revision === prev.revision && r.last_activity_at === prev.last_activity_at) {
|
|
191
|
-
// 单调守卫内(revision
|
|
192
|
-
// 只写 session_title / session_directory /
|
|
193
|
-
// revision、last_activity_at、source、reporter_version 等计量与守卫字段。
|
|
200
|
+
// 单调守卫内(revision 与活动时间均未推进):仅当元数据(含血缘)变化时做「窄更新」回填,
|
|
201
|
+
// 只写 session_title / session_directory / parent_session_id / root_session_id / last_reported_at,
|
|
202
|
+
// 不触碰 token 类字段、revision、last_activity_at、source、reporter_version 等计量与守卫字段。
|
|
194
203
|
const metaChanged =
|
|
195
204
|
(r.session_title ?? null) !== (prev.session_title ?? null) ||
|
|
196
|
-
(r.session_directory ?? null) !== (prev.session_directory ?? null)
|
|
205
|
+
(r.session_directory ?? null) !== (prev.session_directory ?? null) ||
|
|
206
|
+
(r.parent_session_id ?? null) !== (prev.parent_session_id ?? null) ||
|
|
207
|
+
(r.root_session_id ?? null) !== (prev.root_session_id ?? null)
|
|
197
208
|
if (metaChanged) {
|
|
198
209
|
status = "backfilled"
|
|
199
|
-
backfill.run(
|
|
210
|
+
backfill.run(
|
|
211
|
+
r.session_title ?? null, r.session_directory ?? null,
|
|
212
|
+
r.parent_session_id ?? null, r.root_session_id ?? null,
|
|
213
|
+
ts, consumerId, r.install_id, r.session_id,
|
|
214
|
+
)
|
|
200
215
|
} else {
|
|
201
216
|
status = "unchanged"
|
|
202
217
|
}
|
|
@@ -210,7 +225,7 @@ export class Store {
|
|
|
210
225
|
return { accepted, skipped, results }
|
|
211
226
|
}
|
|
212
227
|
|
|
213
|
-
listSessions(consumerId, { window, model, agent, installId, limit = 50, cursor = null } = {}) {
|
|
228
|
+
listSessions(consumerId, { window, model, agent, installId, rootSessionId, limit = 50, cursor = null } = {}) {
|
|
214
229
|
const where = ["consumer_id=?"]
|
|
215
230
|
const args = [consumerId]
|
|
216
231
|
const start = windowStartMs(window)
|
|
@@ -230,6 +245,16 @@ export class Store {
|
|
|
230
245
|
where.push("install_id=?")
|
|
231
246
|
args.push(installId)
|
|
232
247
|
}
|
|
248
|
+
if (rootSessionId) {
|
|
249
|
+
// 顶层任务标识的定义 = COALESCE(root_session_id, session_id)(与 listTasks 的分组表达式严格一致)。
|
|
250
|
+
// 这里写成语义等价的 OR 展开:root 非空 → root_session_id = X;root 为 NULL(旧数据/孤儿)→
|
|
251
|
+
// 回退到 session_id = X。若直接写 COALESCE(...) = ? 则该谓词不可走索引;OR 展开后
|
|
252
|
+
// 第二个分支可用 idx_su_consumer_session(consumer_id, session_id),第一个分支
|
|
253
|
+
// (root_session_id = X)无专用索引,但 consumer_id 前置条件仍可借
|
|
254
|
+
// idx_su_consumer_activity 收敛范围——当前单机个人数据量(千级行)下成本可忽略。
|
|
255
|
+
where.push("(root_session_id=? OR (root_session_id IS NULL AND session_id=?))")
|
|
256
|
+
args.push(rootSessionId, rootSessionId)
|
|
257
|
+
}
|
|
233
258
|
if (cursor) {
|
|
234
259
|
where.push("(last_activity_at < ? OR (last_activity_at = ? AND session_id < ?))")
|
|
235
260
|
args.push(cursor[0], cursor[0], cursor[1])
|
|
@@ -242,6 +267,80 @@ export class Store {
|
|
|
242
267
|
return { data, nextCursor }
|
|
243
268
|
}
|
|
244
269
|
|
|
270
|
+
/**
|
|
271
|
+
* 顶层任务聚合:按 COALESCE(root_session_id, session_id) 分组(无血缘的行各自成任务)。
|
|
272
|
+
*
|
|
273
|
+
* 语义选择:window / model / agent 过滤施加在「会话行」上(先过滤、后聚合)——
|
|
274
|
+
* 任务内只计入匹配的会话行,任务级 last_activity_at 为组内匹配行的 MAX。
|
|
275
|
+
*
|
|
276
|
+
* 根会话元数据(title/directory)用「相关子查询 + LIMIT 1」取标量:同一 consumer 下同一
|
|
277
|
+
* session_id 可能来自多个 install_id,JOIN 会造成行数膨胀;标量子查询每组恰好贡献一个值。
|
|
278
|
+
* 子查询额外要求 r.root_session_id = 根 id(即该行确实是根行),孤儿组(无血缘/根行缺失)
|
|
279
|
+
* 因此得到 null,与契约一致。
|
|
280
|
+
*
|
|
281
|
+
* 游标 [last_activity_at, root_session_id] 作用在聚合结果之后(外层 WHERE),排序键为
|
|
282
|
+
* 任务级 MAX(last_activity_at) DESC, root_session_id DESC。
|
|
283
|
+
*/
|
|
284
|
+
listTasks(consumerId, { window, model, agent, limit = 50, cursor = null } = {}) {
|
|
285
|
+
const where = ["consumer_id=?"]
|
|
286
|
+
const args = [consumerId]
|
|
287
|
+
const start = windowStartMs(window)
|
|
288
|
+
if (start !== null) {
|
|
289
|
+
where.push("last_activity_at>=?")
|
|
290
|
+
args.push(start)
|
|
291
|
+
}
|
|
292
|
+
if (model) {
|
|
293
|
+
where.push("model=?")
|
|
294
|
+
args.push(model)
|
|
295
|
+
}
|
|
296
|
+
if (agent) {
|
|
297
|
+
where.push("agent=?")
|
|
298
|
+
args.push(agent)
|
|
299
|
+
}
|
|
300
|
+
const cursorWhere = []
|
|
301
|
+
const cursorArgs = []
|
|
302
|
+
if (cursor) {
|
|
303
|
+
cursorWhere.push("(t.last_activity_at < ? OR (t.last_activity_at = ? AND t.root_session_id < ?))")
|
|
304
|
+
cursorArgs.push(cursor[0], cursor[0], cursor[1])
|
|
305
|
+
}
|
|
306
|
+
const sql = `SELECT
|
|
307
|
+
t.root_session_id,
|
|
308
|
+
(SELECT r.session_title FROM session_usage r
|
|
309
|
+
WHERE r.consumer_id = t.consumer_id AND r.session_id = t.root_session_id AND r.root_session_id = t.root_session_id
|
|
310
|
+
ORDER BY r.last_reported_at DESC LIMIT 1) AS session_title,
|
|
311
|
+
(SELECT r.session_directory FROM session_usage r
|
|
312
|
+
WHERE r.consumer_id = t.consumer_id AND r.session_id = t.root_session_id AND r.root_session_id = t.root_session_id
|
|
313
|
+
ORDER BY r.last_reported_at DESC LIMIT 1) AS session_directory,
|
|
314
|
+
t.session_count, t.tokens_input, t.tokens_output, t.tokens_reasoning,
|
|
315
|
+
t.tokens_cache_read, t.tokens_cache_write,
|
|
316
|
+
t.first_activity_at, t.last_activity_at
|
|
317
|
+
FROM (
|
|
318
|
+
SELECT consumer_id,
|
|
319
|
+
COALESCE(root_session_id, session_id) AS root_session_id,
|
|
320
|
+
COUNT(*) AS session_count,
|
|
321
|
+
SUM(tokens_input) AS tokens_input,
|
|
322
|
+
SUM(tokens_output) AS tokens_output,
|
|
323
|
+
SUM(tokens_reasoning) AS tokens_reasoning,
|
|
324
|
+
SUM(tokens_cache_read) AS tokens_cache_read,
|
|
325
|
+
SUM(tokens_cache_write) AS tokens_cache_write,
|
|
326
|
+
MIN(last_activity_at) AS first_activity_at,
|
|
327
|
+
MAX(last_activity_at) AS last_activity_at
|
|
328
|
+
FROM session_usage
|
|
329
|
+
WHERE ${where.join(" AND ")}
|
|
330
|
+
GROUP BY consumer_id, COALESCE(root_session_id, session_id)
|
|
331
|
+
) AS t
|
|
332
|
+
${cursorWhere.length ? "WHERE " + cursorWhere.join(" AND ") : ""}
|
|
333
|
+
ORDER BY t.last_activity_at DESC, t.root_session_id DESC
|
|
334
|
+
LIMIT ?`
|
|
335
|
+
const rows = this.db.prepare(sql).all(...args, ...cursorArgs, limit + 1)
|
|
336
|
+
const hasNext = rows.length > limit
|
|
337
|
+
const data = rows.slice(0, limit).map(withDerived)
|
|
338
|
+
const nextCursor = hasNext && data.length
|
|
339
|
+
? [data[data.length - 1].last_activity_at, data[data.length - 1].root_session_id]
|
|
340
|
+
: null
|
|
341
|
+
return { data, nextCursor }
|
|
342
|
+
}
|
|
343
|
+
|
|
245
344
|
getSession(consumerId, sessionId, installId) {
|
|
246
345
|
const where = ["consumer_id=?", "session_id=?"]
|
|
247
346
|
const args = [consumerId, sessionId]
|