codebee 0.1.46 → 0.1.48
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/CHANGELOG.md +395 -388
- package/app/core/flows.py +12 -3
- package/app/core/hooks.py +156 -0
- package/app/core/pipeline.py +149 -4
- package/app/core/planner.py +82 -11
- package/app/core/store.py +68 -1
- package/app/core/usage.py +290 -286
- package/app/core/volumes.py +353 -0
- package/app/core/zentao.py +9 -4
- package/app/main.py +38 -0
- package/app/ui/app.js +405 -16
- package/app/ui/i18n.js +26 -0
- package/app/ui/index.html +72 -0
- package/app/ui/style.css +160 -0
- package/package.json +2 -1
- package/tools/wx_reader.py +261 -0
package/app/ui/index.html
CHANGED
|
@@ -115,6 +115,10 @@
|
|
|
115
115
|
<span class="kbd" aria-hidden="true">Ctrl K</span>
|
|
116
116
|
</button>
|
|
117
117
|
<div class="side-quick">
|
|
118
|
+
<button class="qitem" id="btn-q-overview" title="概览" data-i18n-title="概览">
|
|
119
|
+
<svg class="ico" aria-hidden="true"><use href="#i-gauge"></use></svg>
|
|
120
|
+
<span data-i18n="概览">概览</span>
|
|
121
|
+
</button>
|
|
118
122
|
<button class="qitem" id="btn-q-automation" title="自动化" data-i18n-title="自动化">
|
|
119
123
|
<svg class="ico" aria-hidden="true"><use href="#i-calendar-days"></use></svg>
|
|
120
124
|
<span data-i18n="自动化">自动化</span>
|
|
@@ -174,10 +178,13 @@
|
|
|
174
178
|
<button id="btn-set-back" class="set-back" data-i18n="返回 CodeBee">
|
|
175
179
|
<svg class="ico" aria-hidden="true"><use href="#i-arrow-left"></use></svg>返回 CodeBee
|
|
176
180
|
</button>
|
|
181
|
+
<div data-i18n="工作台" class="set-group">工作台</div>
|
|
182
|
+
<button class="set-item" data-sub="overview" data-i18n="概览"><svg class="ico" aria-hidden="true"><use href="#i-gauge"></use></svg>概览</button>
|
|
177
183
|
<div data-i18n="编排" class="set-group">编排</div>
|
|
178
184
|
<button class="set-item active" data-sub="tasks" data-i18n="任务"><svg class="ico" aria-hidden="true"><use href="#i-tasks"></use></svg>任务</button>
|
|
179
185
|
<button class="set-item" data-sub="runs" data-i18n="运行记录"><svg class="ico" aria-hidden="true"><use href="#i-history"></use></svg>运行记录</button>
|
|
180
186
|
<button class="set-item" data-sub="automation" data-i18n="自动化"><svg class="ico" aria-hidden="true"><use href="#i-calendar-days"></use></svg>自动化</button>
|
|
187
|
+
<button class="set-item" data-sub="hooks" data-i18n="钩子"><svg class="ico" aria-hidden="true"><use href="#i-link"></use></svg>钩子</button>
|
|
181
188
|
<button class="set-item" data-sub="__wxdigest" data-i18n="微信群聊汇报"><svg class="ico" aria-hidden="true"><use href="#i-bee"></use></svg>微信群聊汇报</button>
|
|
182
189
|
<button class="set-item" data-sub="zentao" data-i18n="禅道"><svg class="ico" aria-hidden="true"><use href="#i-search"></use></svg>禅道</button>
|
|
183
190
|
<button class="set-item" data-sub="usage" data-i18n="用量统计"><svg class="ico" aria-hidden="true"><use href="#i-chart"></use></svg>用量统计</button>
|
|
@@ -243,6 +250,53 @@
|
|
|
243
250
|
<main>
|
|
244
251
|
<!-- ================= 设置页(导航在左栏,内容为居中窄栏) ================= -->
|
|
245
252
|
<section id="page-settings" class="page">
|
|
253
|
+
<!-- 概览首页:日期 → 一句话概览 → KPI 卡 → 趋势 + 快捷入口 → 最近运行。
|
|
254
|
+
全页数据来自真实台账(/api/usage)与运行记录(/api/state),无占位假数。 -->
|
|
255
|
+
<div id="sub-overview" class="subpage hidden">
|
|
256
|
+
<div class="ov-head">
|
|
257
|
+
<div class="ov-head-text">
|
|
258
|
+
<div id="ov-date" class="ov-eyebrow"></div>
|
|
259
|
+
<h1 id="ov-title" class="ov-title" data-i18n="概览">概览</h1>
|
|
260
|
+
<p id="ov-sub" class="ov-sub"></p>
|
|
261
|
+
</div>
|
|
262
|
+
<div class="ov-head-act">
|
|
263
|
+
<button class="ghost" onclick="switchTab('runs')">
|
|
264
|
+
<svg class="ico" aria-hidden="true"><use href="#i-history"></use></svg><span data-i18n="运行记录">运行记录</span>
|
|
265
|
+
</button>
|
|
266
|
+
<button class="primary" onclick="exitSettings()">
|
|
267
|
+
<svg class="ico" aria-hidden="true"><use href="#i-plus"></use></svg><span data-i18n="新建任务">新建任务</span>
|
|
268
|
+
</button>
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
<div id="ov-kpis" class="ov-kpis"></div>
|
|
272
|
+
<div class="ov-row">
|
|
273
|
+
<div class="panel ov-trend">
|
|
274
|
+
<div class="panel-head">
|
|
275
|
+
<h2 data-i18n="用量趋势">用量趋势</h2>
|
|
276
|
+
<div class="actions" id="ov-ranges">
|
|
277
|
+
<div class="seg" role="group" aria-label="时间范围" data-i18n-aria="时间范围">
|
|
278
|
+
<button class="seg-btn active" data-days="7" data-i18n="近 7 天" onclick="setOvDays(7)">近 7 天</button>
|
|
279
|
+
<button class="seg-btn" data-days="30" data-i18n="近 30 天" onclick="setOvDays(30)">近 30 天</button>
|
|
280
|
+
</div>
|
|
281
|
+
</div>
|
|
282
|
+
</div>
|
|
283
|
+
<div id="ov-trend-body" class="ov-trend-body"></div>
|
|
284
|
+
</div>
|
|
285
|
+
<div class="panel ov-quick">
|
|
286
|
+
<div class="panel-head"><h2 data-i18n="快捷入口">快捷入口</h2></div>
|
|
287
|
+
<div id="ov-quick-body" class="ov-quick-body"></div>
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
<div class="panel wide ov-recent">
|
|
291
|
+
<div class="panel-head">
|
|
292
|
+
<h2 data-i18n="最近运行">最近运行</h2>
|
|
293
|
+
<div class="actions">
|
|
294
|
+
<button class="ghost small" onclick="switchTab('runs')" data-i18n="查看全部">查看全部</button>
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
297
|
+
<div id="ov-recent-body" class="ov-recent-body"></div>
|
|
298
|
+
</div>
|
|
299
|
+
</div>
|
|
246
300
|
<div id="sub-tasks" class="subpage">
|
|
247
301
|
<div class="panel wide composer">
|
|
248
302
|
<div class="hero-row">
|
|
@@ -595,6 +649,24 @@
|
|
|
595
649
|
</div>
|
|
596
650
|
</div>
|
|
597
651
|
|
|
652
|
+
<!-- 钩子:任务生命周期事件挂本机命令(stdin JSON / stdout 注入) -->
|
|
653
|
+
<div id="sub-hooks" class="subpage hidden">
|
|
654
|
+
<div class="panel wide">
|
|
655
|
+
<div class="panel-head">
|
|
656
|
+
<h2 data-i18n="钩子">钩子</h2>
|
|
657
|
+
<div class="actions">
|
|
658
|
+
<button id="btn-hooks-add" class="primary small" title="加一条钩子" data-i18n-title="加一条钩子"><span data-i18n="新建钩子">新建钩子</span></button>
|
|
659
|
+
</div>
|
|
660
|
+
</div>
|
|
661
|
+
<p class="hint" data-i18n="在任务生命周期的关键点执行本机命令:命令从标准输入收到 JSON(事件与任务上下文),标准输出回 {"inject": "文本"} 即把文本注入提示词(run_end 的输出被忽略,适合回写知识库)。命令按空格分词执行,不支持管道;失败或超时自动跳过,绝不挡主流程。">在任务生命周期的关键点执行本机命令:命令从标准输入收到 JSON(事件与任务上下文),标准输出回 {"inject": "文本"} 即把文本注入提示词(run_end 的输出被忽略,适合回写知识库)。命令按空格分词执行,不支持管道;失败或超时自动跳过,绝不挡主流程。</p>
|
|
662
|
+
<div id="hooks-list" class="cards"></div>
|
|
663
|
+
<div id="hooks-empty" class="empty hidden" data-i18n="还没有钩子——点「新建钩子」在任务开始 / 消息送达 / 运行结束时挂你自己的脚本。">还没有钩子——点「新建钩子」在任务开始 / 消息送达 / 运行结束时挂你自己的脚本。</div>
|
|
664
|
+
<div class="actions" style="margin-top:10px">
|
|
665
|
+
<button id="btn-hooks-save" class="primary small" title="保存全部钩子" data-i18n-title="保存全部钩子"><span data-i18n="保存钩子">保存钩子</span></button>
|
|
666
|
+
</div>
|
|
667
|
+
</div>
|
|
668
|
+
</div>
|
|
669
|
+
|
|
598
670
|
<!-- 禅道:Bug 自动扫描修复 -->
|
|
599
671
|
<div id="sub-zentao" class="subpage hidden">
|
|
600
672
|
<div class="panel wide">
|
package/app/ui/style.css
CHANGED
|
@@ -5708,3 +5708,163 @@ button.request-busy > * { opacity: .18; }
|
|
|
5708
5708
|
padding: 1px 7px; border-radius: 6px; }
|
|
5709
5709
|
.chat-act:hover { background: color-mix(in srgb, var(--text) 8%, transparent);
|
|
5710
5710
|
color: var(--text); }
|
|
5711
|
+
|
|
5712
|
+
/* 屏蔽顶部滑动加载线(用户拍板 2026-09-22):后台/长请求(如禅道内网不可达
|
|
5713
|
+
15-30s)会让线一直滑,观感差;操作反馈由「操作状态」中心承担,不丢信息 */
|
|
5714
|
+
#request-progress { display: none !important; }
|
|
5715
|
+
|
|
5716
|
+
/* 钩子管理卡片(设置 → 钩子,2026-09-22):名称/事件/开关一行,命令整行,
|
|
5717
|
+
* 底部超时+测试。mono 字体命令输入便于读路径 */
|
|
5718
|
+
.hook-card .hook-head { display: flex; align-items: center; gap: 10px; }
|
|
5719
|
+
.hook-card .hook-head .hook-name { flex: 1 1 140px; min-width: 0; }
|
|
5720
|
+
.hook-card .hook-head .hook-event { flex: none; }
|
|
5721
|
+
.hook-card .hook-cmd { width: 100%; margin-top: 10px; font-family: var(--mono); font-size: 12px; }
|
|
5722
|
+
.hook-card .hook-foot { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
|
|
5723
|
+
.hook-card .hook-timeout { width: 64px; }
|
|
5724
|
+
.hook-card .hook-test-out { flex: 1 1 auto; min-width: 0; overflow: hidden;
|
|
5725
|
+
text-overflow: ellipsis; white-space: nowrap; }
|
|
5726
|
+
.hook-card .switch { display: inline-flex; align-items: center; cursor: pointer; }
|
|
5727
|
+
.hook-card .switch input { appearance: none; width: 34px; height: 19px; border-radius: 999px;
|
|
5728
|
+
background: var(--border, rgba(128,128,128,.4)); position: relative; cursor: pointer;
|
|
5729
|
+
transition: background .15s; margin: 0; }
|
|
5730
|
+
.hook-card .switch input:checked { background: var(--accent); }
|
|
5731
|
+
.hook-card .switch input::after { content: ""; position: absolute; top: 2px; left: 2px;
|
|
5732
|
+
width: 15px; height: 15px; border-radius: 50%; background: #fff; transition: left .15s; }
|
|
5733
|
+
.hook-card .switch input:checked::after { left: 17px; }
|
|
5734
|
+
|
|
5735
|
+
/* ==========================================================================
|
|
5736
|
+
概览首页 + KPI 卡三件套(2026-09-22)
|
|
5737
|
+
配色一律走变量(--accent/--ok/--warn/--bad/--accent2),所以 7 套皮肤
|
|
5738
|
+
与日间/夜间都自动成立,无需为皮肤另写规则。
|
|
5739
|
+
========================================================================== */
|
|
5740
|
+
|
|
5741
|
+
/* ---------- KPI 卡:图标块 / 环比徽章 / 迷你走势 ---------- */
|
|
5742
|
+
/* .kpi 定位上下文:走势图绝对定位钉在右下角 */
|
|
5743
|
+
.kpi { position: relative; }
|
|
5744
|
+
.kpi.has-top { padding-top: 11px; }
|
|
5745
|
+
.kpi-top {
|
|
5746
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
5747
|
+
gap: 8px; margin-bottom: 9px;
|
|
5748
|
+
}
|
|
5749
|
+
/* 图标独立成块(淡彩底 + 本色图标),不再是标签行里的装饰性小图标 */
|
|
5750
|
+
.kpi-ico {
|
|
5751
|
+
width: 26px; height: 26px; flex: none; border-radius: 8px;
|
|
5752
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
5753
|
+
}
|
|
5754
|
+
.kpi-ico .ico { width: 15px; height: 15px; stroke-width: 2; }
|
|
5755
|
+
.kpi-ico.t-accent { color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, transparent); }
|
|
5756
|
+
.kpi-ico.t-ok { color: var(--ok); background: color-mix(in srgb, var(--ok) 14%, transparent); }
|
|
5757
|
+
.kpi-ico.t-warn { color: var(--warn); background: color-mix(in srgb, var(--warn) 14%, transparent); }
|
|
5758
|
+
.kpi-ico.t-bad { color: var(--bad); background: color-mix(in srgb, var(--bad) 14%, transparent); }
|
|
5759
|
+
.kpi-ico.t-calm { color: var(--accent2); background: color-mix(in srgb, var(--accent2) 14%, transparent); }
|
|
5760
|
+
|
|
5761
|
+
.kpi-delta {
|
|
5762
|
+
flex: none; font-size: 11px; font-weight: 600; white-space: nowrap;
|
|
5763
|
+
padding: 2px 8px; border-radius: 999px;
|
|
5764
|
+
}
|
|
5765
|
+
.kpi-delta.good { color: var(--ok); background: color-mix(in srgb, var(--ok) 14%, transparent); }
|
|
5766
|
+
.kpi-delta.bad { color: var(--bad); background: color-mix(in srgb, var(--bad) 14%, transparent); }
|
|
5767
|
+
.kpi-delta.flat { color: var(--muted); background: color-mix(in srgb, var(--muted) 14%, transparent); }
|
|
5768
|
+
|
|
5769
|
+
/* 走势图钉在右下;副行文字给它让位(.kpi-s 本就 nowrap + 省略号) */
|
|
5770
|
+
.kpi.has-spark { padding-bottom: 34px; }
|
|
5771
|
+
.kpi.has-spark .kpi-s { padding-right: 80px; }
|
|
5772
|
+
.kpi-spark { position: absolute; right: 10px; bottom: 8px; width: 72px; height: 24px; }
|
|
5773
|
+
.kspark { display: block; width: 100%; height: 100%; overflow: visible; }
|
|
5774
|
+
.kspark .ks-line { fill: none; stroke-width: 1.6; stroke-linejoin: round; stroke-linecap: round; }
|
|
5775
|
+
.kspark .ks-area { stroke: none; opacity: .16; }
|
|
5776
|
+
.kspark.t-accent .ks-line { stroke: var(--accent); }
|
|
5777
|
+
.kspark.t-accent .ks-area { fill: var(--accent); }
|
|
5778
|
+
.kspark.t-ok .ks-line { stroke: var(--ok); }
|
|
5779
|
+
.kspark.t-ok .ks-area { fill: var(--ok); }
|
|
5780
|
+
.kspark.t-warn .ks-line { stroke: var(--warn); }
|
|
5781
|
+
.kspark.t-warn .ks-area { fill: var(--warn); }
|
|
5782
|
+
.kspark.t-bad .ks-line { stroke: var(--bad); }
|
|
5783
|
+
.kspark.t-bad .ks-area { fill: var(--bad); }
|
|
5784
|
+
.kspark.t-calm .ks-line { stroke: var(--accent2); }
|
|
5785
|
+
.kspark.t-calm .ks-area { fill: var(--accent2); }
|
|
5786
|
+
|
|
5787
|
+
/* ---------- 概览页版式 ---------- */
|
|
5788
|
+
/* #sub-overview 本身就是 .subpage(纵向 flex + 20px 间距),
|
|
5789
|
+
所以下列各块不再自带外边距,避免出现双倍留白 */
|
|
5790
|
+
.ov-head {
|
|
5791
|
+
display: flex; align-items: flex-end; justify-content: space-between;
|
|
5792
|
+
gap: 16px; flex-wrap: wrap;
|
|
5793
|
+
}
|
|
5794
|
+
.ov-eyebrow { color: var(--muted); font-size: 12px; margin-bottom: 6px; }
|
|
5795
|
+
.ov-title { margin: 0; font-size: 26px; font-weight: 700; letter-spacing: -.02em; }
|
|
5796
|
+
.ov-sub { margin: 9px 0 0; color: var(--muted); font-size: 13px; }
|
|
5797
|
+
.ov-head-act { display: flex; gap: 8px; flex: none; }
|
|
5798
|
+
.ov-head-act button { display: inline-flex; align-items: center; gap: 6px; }
|
|
5799
|
+
.ov-head-act .ico { width: 14px; height: 14px; }
|
|
5800
|
+
|
|
5801
|
+
.ov-kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
|
5802
|
+
.ov-row {
|
|
5803
|
+
display: grid; gap: 12px; align-items: start;
|
|
5804
|
+
grid-template-columns: minmax(0, 1.62fr) minmax(0, 1fr);
|
|
5805
|
+
}
|
|
5806
|
+
|
|
5807
|
+
/* ---------- 快捷入口:图标块 + 主副标题 + 右上箭头 ---------- */
|
|
5808
|
+
.ov-quick-body { display: flex; flex-direction: column; gap: 2px; }
|
|
5809
|
+
.ov-q {
|
|
5810
|
+
display: flex; align-items: center; gap: 10px; width: 100%;
|
|
5811
|
+
text-align: left; background: none; border: none; cursor: pointer;
|
|
5812
|
+
color: var(--text); font-family: inherit; padding: 8px 9px; border-radius: 10px;
|
|
5813
|
+
transition: background-color .15s;
|
|
5814
|
+
}
|
|
5815
|
+
.ov-q:hover { background: var(--panel2); }
|
|
5816
|
+
.ov-q-ico {
|
|
5817
|
+
width: 28px; height: 28px; flex: none; border-radius: 9px;
|
|
5818
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
5819
|
+
color: var(--accent); background: color-mix(in srgb, var(--accent) 13%, transparent);
|
|
5820
|
+
}
|
|
5821
|
+
.ov-q-ico .ico { width: 15px; height: 15px; stroke-width: 2; }
|
|
5822
|
+
.ov-q-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1 1 auto; }
|
|
5823
|
+
.ov-q-text b { font-size: 13px; font-weight: 600; }
|
|
5824
|
+
.ov-q-text i {
|
|
5825
|
+
font-size: 11.5px; font-style: normal; color: var(--muted);
|
|
5826
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
5827
|
+
}
|
|
5828
|
+
/* ↗ 复用 i-arrow-up 旋转 45°,不再单加一个精灵图标 */
|
|
5829
|
+
.ov-q-go { flex: none; display: inline-flex; color: var(--muted); opacity: .5; transform: rotate(45deg); }
|
|
5830
|
+
.ov-q:hover .ov-q-go { opacity: .9; }
|
|
5831
|
+
/* 四行各自分色:一列同色图标太平 */
|
|
5832
|
+
.ov-q:nth-child(2) .ov-q-ico { color: var(--accent2); background: color-mix(in srgb, var(--accent2) 13%, transparent); }
|
|
5833
|
+
.ov-q:nth-child(3) .ov-q-ico { color: var(--ok); background: color-mix(in srgb, var(--ok) 13%, transparent); }
|
|
5834
|
+
.ov-q:nth-child(4) .ov-q-ico { color: var(--warn); background: color-mix(in srgb, var(--warn) 13%, transparent); }
|
|
5835
|
+
|
|
5836
|
+
/* ---------- 最近运行:发丝线分隔的行 ---------- */
|
|
5837
|
+
.ov-recent-body { display: flex; flex-direction: column; }
|
|
5838
|
+
.ov-recent .panel-head { margin-bottom: 8px; }
|
|
5839
|
+
.ov-run {
|
|
5840
|
+
display: flex; align-items: center; gap: 12px; width: 100%;
|
|
5841
|
+
text-align: left; background: none; border: none; cursor: pointer;
|
|
5842
|
+
color: var(--text); font-family: inherit; padding: 11px 8px;
|
|
5843
|
+
border-top: 1px solid var(--border); transition: background-color .15s;
|
|
5844
|
+
}
|
|
5845
|
+
.ov-run:first-child { border-top: none; }
|
|
5846
|
+
.ov-run:hover { background: var(--panel2); }
|
|
5847
|
+
.ov-run-ico { flex: none; width: 16px; display: inline-flex; align-items: center; justify-content: center; }
|
|
5848
|
+
.ov-run-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
|
5849
|
+
.ov-run-main b { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
5850
|
+
.ov-run-main i {
|
|
5851
|
+
font-size: 11.5px; font-style: normal; color: var(--muted);
|
|
5852
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
5853
|
+
}
|
|
5854
|
+
.ov-run-meta { flex: none; display: flex; align-items: center; gap: 10px; }
|
|
5855
|
+
.ov-run-time { min-width: 54px; text-align: right; color: var(--muted); font-size: 11.5px; white-space: nowrap; }
|
|
5856
|
+
|
|
5857
|
+
/* ---------- 窄屏:KPI 两列 → 一列,趋势与快捷入口改纵向 ---------- */
|
|
5858
|
+
@media (max-width: 1080px) {
|
|
5859
|
+
.ov-kpis { grid-template-columns: repeat(2, 1fr); }
|
|
5860
|
+
.ov-row { grid-template-columns: 1fr; }
|
|
5861
|
+
}
|
|
5862
|
+
@media (max-width: 640px) {
|
|
5863
|
+
.ov-kpis { grid-template-columns: 1fr; }
|
|
5864
|
+
.ov-head { flex-direction: column; align-items: flex-start; }
|
|
5865
|
+
/* 窄屏放不下并排:走势图落到卡片底部通栏,副行不再让位 */
|
|
5866
|
+
.kpi-spark { position: static; width: 100%; margin-top: 8px; }
|
|
5867
|
+
.kpi.has-spark { padding-bottom: 12px; }
|
|
5868
|
+
.kpi.has-spark .kpi-s { padding-right: 0; }
|
|
5869
|
+
}
|
|
5870
|
+
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codebee",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.48",
|
|
4
4
|
"description": "CodeBee · 多智能体编排台 — 统一编排本机 AI 编码 CLI(Codex / Claude Code / QwenCode / OpenCode / Aider…):目标→拆解→路由→执行→验证→跨厂商评审→修复→报告。纯 Python 标准库实现,本地 Web UI。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"app",
|
|
11
11
|
"bin",
|
|
12
|
+
"tools/wx_reader.py",
|
|
12
13
|
"CHANGELOG.md",
|
|
13
14
|
"README.md",
|
|
14
15
|
"LICENSE"
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""微信读取 sidecar:由 64 位 Python ≥3.9(配 wechatauto-replica)运行,
|
|
3
|
+
把本机微信 4.x 群消息增量拉成本仓库已有的「导出 txt」格式。
|
|
4
|
+
|
|
5
|
+
为什么是独立进程:wechatauto 依赖链(winsdk 等)需要 64 位 Python ≥3.9,
|
|
6
|
+
而 CodeBee 服务钉在 3.8-32。sidecar 通过文件与主程序解耦——拉到的新消息
|
|
7
|
+
按「2024-01-01 12:34:56 昵称」行格式追加进监控文件夹,wxdigest 现有的
|
|
8
|
+
解析/增量游标/摘要/桌宠提醒整条链路零改动复用。
|
|
9
|
+
|
|
10
|
+
用法(两个模式,互斥):
|
|
11
|
+
列群(供前端选择):
|
|
12
|
+
python wx_reader.py --list-groups --result OUT.json
|
|
13
|
+
拉增量(每次扫描前调用):
|
|
14
|
+
python wx_reader.py --pull --out DIR --state STATE.json \
|
|
15
|
+
--groups-file GROUPS.json --result OUT.json
|
|
16
|
+
GROUPS.json = [{"username": "...@chatroom", "name": "群名"}, ...]
|
|
17
|
+
|
|
18
|
+
只读保证:只调用 get_groups/get_new_messages/get_group_members/get_self_info,
|
|
19
|
+
不调用任何发送/删除接口。
|
|
20
|
+
结果 JSON 写 --result 文件(stdout 被库日志污染,不作为契约)。
|
|
21
|
+
语法保持 3.8 兼容(测试解释器要 import 本文件的纯函数)。
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import sys
|
|
30
|
+
import time
|
|
31
|
+
|
|
32
|
+
# 群消息 content 自带「<wxid>: 前缀」,剥掉后用成员表翻译成昵称
|
|
33
|
+
_PREFIX_RE = re.compile(r"^([A-Za-z0-9_+@\-.]{3,}):\s*\n?")
|
|
34
|
+
_ILLEGAL_FN = re.compile(r'[\\/:*?"<>|]')
|
|
35
|
+
|
|
36
|
+
SELF_NAME = "我"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def sanitize_filename(name):
|
|
40
|
+
"""群名 → 合法文件名(Windows 非法字符换下划线;空名回落 username)。"""
|
|
41
|
+
out = _ILLEGAL_FN.sub("_", str(name or "").strip())
|
|
42
|
+
return out[:80] or "unnamed"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def strip_sender_prefix(content, sender_username):
|
|
46
|
+
"""群消息正文剥掉自带的「wxid: 」前缀(前缀与发送者一致才可信地剥)。"""
|
|
47
|
+
text = str(content or "")
|
|
48
|
+
m = _PREFIX_RE.match(text)
|
|
49
|
+
if m and (not sender_username or m.group(1) == sender_username):
|
|
50
|
+
return text[m.end():].strip()
|
|
51
|
+
return text.strip()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def member_map(members):
|
|
55
|
+
"""get_group_members 结果 → {username: 显示名}。备注优先(微信 UI 同款)。"""
|
|
56
|
+
out = {}
|
|
57
|
+
for m in members or []:
|
|
58
|
+
if not isinstance(m, dict):
|
|
59
|
+
continue
|
|
60
|
+
uid = m.get("username") or ""
|
|
61
|
+
if not uid:
|
|
62
|
+
continue
|
|
63
|
+
out[uid] = m.get("remark") or m.get("nick_name") or uid
|
|
64
|
+
return out
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def format_line(ts_epoch, sender_name, text):
|
|
68
|
+
"""一条消息 → 导出格式行(时间戳行与正文之间不留空行,与现有解析器对齐)。"""
|
|
69
|
+
stamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ts_epoch or 0))
|
|
70
|
+
return "%s %s\n%s\n" % (stamp, sender_name or "?", str(text or "").strip())
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def type_placeholder(mtype):
|
|
74
|
+
"""非文本消息 → 占位符(摘要时让模型知道有图/语音存在)。None=跳过不入正文。"""
|
|
75
|
+
t = str(mtype or "")
|
|
76
|
+
if "文本" in t:
|
|
77
|
+
return None # 文本走正文,不是占位符
|
|
78
|
+
for key, ph in (("图片", "[图片]"), ("语音", "[语音]"), ("视频", "[视频]"),
|
|
79
|
+
("文件", "[文件]"), ("链接", "[链接]"), ("名片", "[名片]"),
|
|
80
|
+
("位置", "[位置]"), ("动画表情", "[表情]"), ("系统", None)):
|
|
81
|
+
if key in t:
|
|
82
|
+
return ph
|
|
83
|
+
return "[%s]" % t if t and len(t) <= 8 else None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _quiet_lib_logs():
|
|
87
|
+
try:
|
|
88
|
+
import logging
|
|
89
|
+
logging.getLogger("wechatauto").setLevel(logging.WARNING)
|
|
90
|
+
except Exception:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _write_result(path, obj):
|
|
95
|
+
tmp = str(path) + ".tmp"
|
|
96
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
97
|
+
json.dump(obj, f, ensure_ascii=False)
|
|
98
|
+
os.replace(tmp, path)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _load_json(path, default):
|
|
102
|
+
try:
|
|
103
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
104
|
+
return json.load(f)
|
|
105
|
+
except Exception:
|
|
106
|
+
return default
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def load_state(path):
|
|
110
|
+
return _load_json(path, {}) or {}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def save_state(path, state):
|
|
114
|
+
tmp = str(path) + ".tmp"
|
|
115
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
116
|
+
json.dump(state, f, ensure_ascii=False)
|
|
117
|
+
os.replace(tmp, path)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def cmd_list_groups(result_path):
|
|
121
|
+
_quiet_lib_logs()
|
|
122
|
+
import wechatauto
|
|
123
|
+
db = wechatauto.WeChatDB()
|
|
124
|
+
groups, seen = [], set()
|
|
125
|
+
for g in db.get_groups():
|
|
126
|
+
if isinstance(g, dict) and g.get("username"):
|
|
127
|
+
groups.append({"username": g["username"],
|
|
128
|
+
"name": g.get("name") or g["username"],
|
|
129
|
+
"member_count": str(g.get("member_count") or "")})
|
|
130
|
+
seen.add(g["username"])
|
|
131
|
+
# 新群兜底(2026-09-21 用户实测「缺一个新建的群」):刚建的群可能还没
|
|
132
|
+
# 落进 chat_room/contact,但只要有过消息就在会话列表里——合并补进,
|
|
133
|
+
# 昵称未同步时先显示 wxid,微信同步后「刷新群列表」即见真名。
|
|
134
|
+
try:
|
|
135
|
+
for s in db.get_sessions(200):
|
|
136
|
+
u = str((s or {}).get("username") or "")
|
|
137
|
+
if u.endswith("@chatroom") and u not in seen:
|
|
138
|
+
groups.append({"username": u,
|
|
139
|
+
"name": db.get_nickname(u) or u,
|
|
140
|
+
"member_count": ""})
|
|
141
|
+
seen.add(u)
|
|
142
|
+
except Exception:
|
|
143
|
+
pass # 会话库不可用不影响主清单
|
|
144
|
+
_write_result(result_path, {"ok": True, "groups": groups, "count": len(groups)})
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def cmd_pull(out_dir, state_path, groups_file, result_path):
|
|
148
|
+
_quiet_lib_logs()
|
|
149
|
+
import wechatauto
|
|
150
|
+
sel = _load_json(groups_file, [])
|
|
151
|
+
sel = [g for g in (sel or []) if isinstance(g, dict) and g.get("username")]
|
|
152
|
+
state = load_state(state_path)
|
|
153
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
154
|
+
db = wechatauto.WeChatDB()
|
|
155
|
+
try:
|
|
156
|
+
self_wxid = db.wxid
|
|
157
|
+
except Exception:
|
|
158
|
+
self_wxid = ""
|
|
159
|
+
pulled, errors = {}, []
|
|
160
|
+
for g in sel:
|
|
161
|
+
username, name = g["username"], g.get("name") or g["username"]
|
|
162
|
+
since = 0
|
|
163
|
+
try:
|
|
164
|
+
since = int((state.get(username) or {}).get("since_seq") or 0)
|
|
165
|
+
except (TypeError, ValueError):
|
|
166
|
+
since = 0
|
|
167
|
+
try:
|
|
168
|
+
msgs = db.get_new_messages(username, since_seq=since, limit=500)
|
|
169
|
+
except Exception as e:
|
|
170
|
+
errors.append("%s: %s" % (name, e))
|
|
171
|
+
continue
|
|
172
|
+
if not msgs:
|
|
173
|
+
pulled[username] = 0
|
|
174
|
+
continue
|
|
175
|
+
members = {}
|
|
176
|
+
try:
|
|
177
|
+
members = member_map(db.get_group_members(username))
|
|
178
|
+
except Exception:
|
|
179
|
+
members = {}
|
|
180
|
+
# 昵称缓存:退群成员不在成员表里,靠 get_nickname 兜底一次后永久缓存
|
|
181
|
+
gstate = state.get(username) or {}
|
|
182
|
+
names = dict(gstate.get("names") or {})
|
|
183
|
+
|
|
184
|
+
def resolve(uid):
|
|
185
|
+
if not uid:
|
|
186
|
+
return "?"
|
|
187
|
+
if self_wxid and uid == self_wxid:
|
|
188
|
+
return SELF_NAME
|
|
189
|
+
if uid in members:
|
|
190
|
+
return members[uid]
|
|
191
|
+
if uid in names:
|
|
192
|
+
return names[uid]
|
|
193
|
+
try:
|
|
194
|
+
nick = str(db.get_nickname(uid) or "").strip()
|
|
195
|
+
except Exception:
|
|
196
|
+
nick = ""
|
|
197
|
+
name2 = nick or uid
|
|
198
|
+
if len(names) < 5000: # 缓存上限防无限膨胀
|
|
199
|
+
names[uid] = name2
|
|
200
|
+
return name2
|
|
201
|
+
|
|
202
|
+
fname = os.path.join(out_dir, sanitize_filename(name) + ".txt")
|
|
203
|
+
header = "【%s】\n" % name
|
|
204
|
+
need_header = not os.path.exists(fname)
|
|
205
|
+
lines = []
|
|
206
|
+
max_seq = since
|
|
207
|
+
for m in msgs:
|
|
208
|
+
seq = m.get("sort_seq") or 0
|
|
209
|
+
if seq <= since:
|
|
210
|
+
continue
|
|
211
|
+
max_seq = max(max_seq, seq)
|
|
212
|
+
uid = m.get("sender_username") or ""
|
|
213
|
+
who = resolve(uid)
|
|
214
|
+
mtype = m.get("type")
|
|
215
|
+
text = None
|
|
216
|
+
if "文本" in str(mtype or ""):
|
|
217
|
+
text = strip_sender_prefix(m.get("content"), uid)
|
|
218
|
+
else:
|
|
219
|
+
text = type_placeholder(mtype)
|
|
220
|
+
if not text:
|
|
221
|
+
continue
|
|
222
|
+
lines.append(format_line(m.get("create_time"), who, text))
|
|
223
|
+
if lines:
|
|
224
|
+
with open(fname, "a", encoding="utf-8") as f:
|
|
225
|
+
if need_header:
|
|
226
|
+
f.write(header)
|
|
227
|
+
f.write("\n".join(lines) + "\n")
|
|
228
|
+
state[username] = {"name": name, "since_seq": max_seq,
|
|
229
|
+
"names": names, "t": time.time()}
|
|
230
|
+
pulled[username] = len(lines)
|
|
231
|
+
save_state(state_path, state)
|
|
232
|
+
_write_result(result_path, {"ok": not errors, "pulled": pulled,
|
|
233
|
+
"errors": errors, "count": sum(pulled.values())})
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def main():
|
|
237
|
+
ap = argparse.ArgumentParser(description="CodeBee 微信群消息只读 sidecar")
|
|
238
|
+
ap.add_argument("--list-groups", action="store_true")
|
|
239
|
+
ap.add_argument("--pull", action="store_true")
|
|
240
|
+
ap.add_argument("--out", default="")
|
|
241
|
+
ap.add_argument("--state", default="")
|
|
242
|
+
ap.add_argument("--groups-file", default="")
|
|
243
|
+
ap.add_argument("--result", required=True, help="结果 JSON 落点(stdout 不可靠)")
|
|
244
|
+
args = ap.parse_args()
|
|
245
|
+
try:
|
|
246
|
+
if args.list_groups:
|
|
247
|
+
cmd_list_groups(args.result)
|
|
248
|
+
elif args.pull:
|
|
249
|
+
cmd_pull(args.out, args.state, args.groups_file, args.result)
|
|
250
|
+
else:
|
|
251
|
+
ap.error("需要 --list-groups 或 --pull")
|
|
252
|
+
except Exception as e:
|
|
253
|
+
# 顶层异常也落 result:微信未登录/DB 锁住等真实原因必须透传给主程序
|
|
254
|
+
_write_result(args.result, {"ok": False, "errors": [str(e)],
|
|
255
|
+
"groups": [], "pulled": {}, "count": 0})
|
|
256
|
+
return 1
|
|
257
|
+
return 0
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
if __name__ == "__main__":
|
|
261
|
+
sys.exit(main())
|