dsh-erpbot 0.1.0 → 0.1.1

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/client.js CHANGED
@@ -207,24 +207,27 @@ window.__ModuleLoader__.load({
207
207
  }
208
208
  /* Only the primary icon group gives way in a short box; the settings/
209
209
  language group at the bottom is always kept on screen instead of being
210
- pushed out of the panel. */
211
- .erp-app .t1-rail > .rail-group:first-of-type{
210
+ pushed out of the panel.
211
+
212
+ NOTE: this must NOT be ":first-of-type". The rail now opens with the brand
213
+ avatar slot, which is also a <div>, so ":first-of-type" would match the
214
+ brand instead of the button group and the scroll guard would silently
215
+ switch off — visible only as the button group pushing the wordmark out of
216
+ a short panel. */
217
+ .erp-app .t1-rail > .rail-group{
212
218
  flex:0 1 auto;min-height:0;overflow-y:auto;overflow-x:hidden;
213
219
  }
214
- .erp-app .t1-rail > .rail-group:first-of-type::-webkit-scrollbar{width:0;height:0}
220
+ .erp-app .t1-rail > .rail-group::-webkit-scrollbar{width:0;height:0}
215
221
  /* The bottom lockup (erpBot wordmark + 中/EN switch) is never squeezed out:
216
222
  it keeps its natural height while only the icon group above may scroll. */
217
223
  .erp-app .t1-rail .rail-foot{flex:none}
218
224
  .erp-app .rail-spacer{flex:1 1 auto;min-height:0}
219
- .erp-app .t1-rail::after{
220
- content:"";position:absolute;right:0;top:16px;bottom:24px;width:1px;
221
- pointer-events:none;
222
- background:linear-gradient(180deg,
223
- rgba(198,203,225,0) 0px,
224
- rgba(198,203,225,.85) 22px,
225
- rgba(198,203,225,.85) calc(100% - 34px),
226
- rgba(198,203,225,0) 100%);
227
- }
225
+ /* The vertical hairline between the rail and the content column is gone: the
226
+ rail already reads as its own column through the inset icon card and the
227
+ whitespace gutter, so the line was a redundant second edge. Kept as an
228
+ explicit display:none rather than deleting the rule, so the dark override
229
+ below stays paired with it. */
230
+ .erp-app .t1-rail::after{display:none}
228
231
  .erp-app .rail-brand{
229
232
  width:92px;flex:none;display:flex;align-items:center;justify-content:center;gap:5px;
230
233
  white-space:nowrap;height:58px;
@@ -274,27 +277,6 @@ window.__ModuleLoader__.load({
274
277
  box-shadow:0 10px 18px -12px rgba(21,25,51,.95);
275
278
  }
276
279
 
277
- /* rail-top brand tile: the B mark, spanning the full width of the nav rail so
278
- the brand block is exactly as wide as the icon column below it. */
279
- .erp-app .rail-logo{
280
- width:100%;flex:none;display:flex;align-items:center;justify-content:center;
281
- margin-bottom:6px;
282
- }
283
- /* The B tile fills the rail's width: the brand block and the icon column below
284
- share the same edges, so the mark reads as the header of the nav column. */
285
- .erp-app .rail-logo .rail-mark{
286
- width:100%;height:52px;flex:none;
287
- display:grid;place-items:center;
288
- border-radius:16px;
289
- background:linear-gradient(140deg,#3f2fbe,#7c5cff 52%,#c48bfb);
290
- color:#fff;font-size:26px;font-weight:800;line-height:1;letter-spacing:.02em;
291
- box-shadow:0 10px 22px -12px rgba(94,72,232,.72);
292
- }
293
- .erp-app[data-theme='dark'] .rail-logo .rail-mark{
294
- background:linear-gradient(140deg,#4a37d6,#8a68ff 52%,#cfa2ff);
295
- box-shadow:0 10px 22px -12px rgba(0,0,0,.8);
296
- }
297
-
298
280
  /* language switch: a two-segment pill (中 | EN) under the erpBot wordmark.
299
281
  Both languages stay visible, the active segment is lit, and the inactive one
300
282
  only reacts to hover. */
@@ -401,6 +383,13 @@ window.__ModuleLoader__.load({
401
383
 
402
384
  /* ---------------- stat cards ---------------- */
403
385
  .erp-app .t1-stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:13px}
386
+ .erp-app .mcp-bar{display:flex;align-items:center;gap:5px;padding:0 4px 9px;font-size:11px;color:var(--erp-dim)}
387
+ .erp-app .mcp-bar-label{opacity:.72}
388
+ .erp-app .mcp-dot{display:inline-flex;align-items:center}
389
+ .erp-app .mcp-dot .bot-dot{width:6px;height:6px;border-radius:50%;flex:none}
390
+ .erp-app .mcp-dot.ok .bot-dot{background:#4dba6a}
391
+ .erp-app .mcp-dot.warn .bot-dot{background:#eb943b}
392
+ .erp-app .mcp-dot.off .bot-dot{background:var(--erp-dim);opacity:.4}
404
393
  .erp-app .stat{padding:13px 15px 11px;display:flex;flex-direction:column;gap:8px}
405
394
  .erp-app .stat-top{display:flex;align-items:flex-start;justify-content:space-between;gap:6px}
406
395
  .erp-app .stat-label{font-size:11.4px;font-weight:600;color:#8c93ac;letter-spacing:.005em}
@@ -704,10 +693,16 @@ window.__ModuleLoader__.load({
704
693
  inline-size container above), which is exactly the area the host hands us.
705
694
  Width and height both get handled: narrow boxes reflow the grid, short boxes
706
695
  shrink the fixed-height chart so the page still fits. */
707
- @container erp (min-width:1181px){
708
- /* Wide enough that the whole board is one screen: let the two content rows
696
+ @container erp (min-width:901px){
697
+ /* Wide enough that the board is still one screen: let the two content rows
709
698
  share the leftover height instead of sitting at their natural size, so a
710
- tall panel fills up and a short one compresses rather than being clipped. */
699
+ tall panel fills up and a short one compresses rather than being clipped.
700
+
701
+ The floor used to be 1181px. Between 901 and 1180 the panel did NOT stack
702
+ (row 2 and row 3 were still side by side) yet the rows were plain "auto",
703
+ so any spare height just collected as dead space at the bottom of the
704
+ column — 191px of it at 1100x900. The split the rows need is the 900px
705
+ breakpoint below, not 1181, so the range moves with it. */
711
706
  .erp-app .t1-main{
712
707
  grid-template-rows:auto minmax(var(--erp-row2-min),1.42fr) minmax(var(--erp-row3-min),1fr);
713
708
  }
@@ -909,8 +904,10 @@ window.__ModuleLoader__.load({
909
904
  .erp-app .rail-foot{gap:5px}
910
905
  .erp-app .rail-lang{padding:1px}
911
906
  .erp-app .rail-lang .lang-seg{min-width:21px;height:16px;padding:0 4px;font-size:8.2px}
912
- .erp-app .rail-logo{margin-bottom:4px}
913
- .erp-app .rail-logo .rail-mark{height:28px;font-size:16px;border-radius:11px}
907
+ /* short box: the avatar trades its breathing room for the icon column, but
908
+ keeps the ring — that ring is the only thing separating brand from a
909
+ navigation button, so it is the last thing to give up. */
910
+ .erp-app .rail-brand-slot{margin:0 0 6px}
914
911
  }
915
912
  /* The card art is the one element that cannot simply shrink: below a certain
916
913
  height the 1.62:1 ratio squeezes it into a sliver too small to read. So when
@@ -946,16 +943,17 @@ window.__ModuleLoader__.load({
946
943
  }
947
944
  .erp-app[data-theme='dark'] .erp-board{
948
945
  background:#12141f;
946
+ /* the avatar's seam and dot halo read this, so it must follow the surface */
947
+ --erp-board-bg:#12141f;
949
948
  }
950
949
  .erp-app[data-theme='dark'] .erp-board::before{display:none}
951
- .erp-app[data-theme='dark'] .t1-rail::after{
952
- background:linear-gradient(180deg,rgba(70,78,110,0) 0,rgba(70,78,110,.8) 22px,rgba(70,78,110,.8) calc(100% - 34px),rgba(70,78,110,0) 100%);
953
- }
950
+ /* paired with the light rule above: the divider is off in both themes */
951
+ .erp-app[data-theme='dark'] .t1-rail::after{display:none}
954
952
  .erp-app[data-theme='dark'] .rail-group{background:#171a2c;border-color:#242840;box-shadow:0 12px 26px -22px rgba(0,0,0,.8)}
955
953
  .erp-app[data-theme='dark'] .rb-name{color:#eef0fa}
956
954
  .erp-app[data-theme='dark'] .rail-btn{background:#1d2136;border-color:#2a2f4a;color:#98a0bd}
957
955
  .erp-app[data-theme='dark'] .rail-btn:hover{background:#242944;color:#eef0fa;border-color:#3a4166}
958
- .erp-app[data-theme='dark'] .rail-btn.active{background:#6a54f5;border-color:#6a54f5;color:#fff;box-shadow:0 10px 18px -12px rgba(106,84,245,.9)}
956
+ .erp-app[data-theme='dark'] .rail-btn.active{background:#5b3cf0;border-color:#5b3cf0;color:#fff;box-shadow:0 10px 18px -12px rgba(91,60,240,.9)}
959
957
  .erp-app[data-theme='dark'] .brand-name{color:#f0f2fb}
960
958
  .erp-app[data-theme='dark'] .t1-greet b{color:#f0f2fb}
961
959
  .erp-app[data-theme='dark'] .t1-greet small{color:#8b93b0}
@@ -1314,9 +1312,18 @@ window.__ModuleLoader__.load({
1314
1312
  .erp-app .visa.focus .mini-donut-center{
1315
1313
  position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;
1316
1314
  justify-content:center;gap:2px;text-align:center;pointer-events:none;
1315
+ /* The centre is inset to the RING's inner diameter, not the ring's box: the
1316
+ stroke is 6/44 of the viewBox, so the usable hole is about 68% wide. Text
1317
+ that fills the whole box visually touches the arcs. */
1318
+ padding:0 16%;
1317
1319
  }
1318
1320
  .erp-app .visa.focus .mini-donut-center b{
1319
1321
  font-size:clamp(13px,4cqi,18px);font-weight:800;letter-spacing:-.03em;font-variant-numeric:tabular-nums;
1322
+ /* The hole is roughly half the ring's outer width; anything wider spills
1323
+ across both arcs. compactMoney already shortens the figure, and this is
1324
+ the belt-and-braces so an unexpectedly long string shrinks to fit instead
1325
+ of overflowing. */
1326
+ max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
1320
1327
  }
1321
1328
  .erp-app .visa.focus .mini-donut-center span{
1322
1329
  font-size:clamp(7px,2cqi,9px);opacity:.68;font-weight:600;
@@ -1350,16 +1357,33 @@ window.__ModuleLoader__.load({
1350
1357
  flex:1 1 0;min-width:0;max-width:none;justify-content:center;
1351
1358
  }
1352
1359
 
1353
- /* ---------------- donut row: content-sized group, bounded legend column ---------------- */
1360
+ /* ---------------- donut row: full-width ring + legend pair ----------------
1361
+ This row used to inherit max-width:46% from the generic .erp-app .donut
1362
+ rule (line ~441), which was written for the expense card where the ring
1363
+ shares its row with a legend that has its own column. Here it is the ONLY
1364
+ thing in the middle lane, so capping it at 46% left the row 121-218px wide
1365
+ while the card was 304-514px: the ring ate 96px and the legend got 0-30px,
1366
+ i.e. every label ellipsised away. The override below restores the full lane. */
1354
1367
  .erp-app .visa.focus .visa-chart.donut{
1355
- width:100%;justify-content:center;
1356
- gap:clamp(24px,5cqi,40px);
1368
+ width:100%;max-width:none;justify-content:center;
1369
+ gap:clamp(20px,4cqi,34px);
1370
+ /* The two floating arrows are positioned against the CARD (right:-13px), not
1371
+ against this row, so the row has to reserve their lane itself or the
1372
+ legend runs underneath the next arrow. The button is 26px and 13px of it
1373
+ hangs outside the card, so 30px of inset keeps ring and legend clear. */
1374
+ padding:0 30px;
1375
+ box-sizing:border-box;
1357
1376
  }
1358
1377
  .erp-app .visa.focus .mini-donut{
1359
- width:clamp(88px,30cqi,158px);height:auto;max-height:100%;aspect-ratio:1/1;flex:none;
1378
+ width:clamp(96px,32cqi,168px);height:auto;max-height:100%;aspect-ratio:1/1;flex:none;
1360
1379
  }
1380
+ /* The legend used to be width-limited to 26cqi, which on a narrow card left it
1381
+ with 9-27px — narrower than its own text, so every row ellipsised to nothing.
1382
+ It now takes the room the ring does not need, with a floor that keeps the
1383
+ label readable and lets the row wrap rather than crush. */
1361
1384
  .erp-app .visa.focus .mini-legend{
1362
- flex:0 1 auto;width:clamp(150px,26cqi,196px);min-width:0;max-width:none;justify-content:center;
1385
+ flex:1 1 auto;width:auto;min-width:96px;max-width:min(230px,42cqi);
1386
+ justify-content:center;
1363
1387
  }
1364
1388
 
1365
1389
  /* ---------------- card anatomy: header / middle / footer ---------------- */
@@ -1618,6 +1642,12 @@ window.__ModuleLoader__.load({
1618
1642
 
1619
1643
  /* ---------------- nested pages: four KPIs + one section workbench ---------------- */
1620
1644
  .erp-app .t1-main[data-shell='true']{grid-template-rows:auto minmax(0,1fr)}
1645
+ /* The team page has no KPI row: it is ONE card that must fill the whole column.
1646
+ It sets data-shell="team" (see shell/index.js), but only [data-shell='true']
1647
+ had a rule here, so the team card fell back to content height and left a
1648
+ large empty band under it. A single minmax row gives it the full height, and
1649
+ align-content must stop being "start" or the row keeps its content height. */
1650
+ .erp-app .t1-main[data-shell='team']{grid-template-rows:minmax(0,1fr);align-content:stretch}
1621
1651
  .erp-app .workbench{display:flex;flex-direction:row;min-height:0;overflow:hidden;padding:0}
1622
1652
  .erp-app .wb-nav{flex:none;width:200px;border-right:1px solid #f2f3fa;padding:12px 10px;display:flex;flex-direction:column;gap:2px;overflow-y:auto}
1623
1653
  .erp-app .wb-hd{font-size:9.6px;font-weight:700;letter-spacing:.05em;color:#a6acc2;padding:2px 8px 8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
@@ -2034,6 +2064,9 @@ window.__ModuleLoader__.load({
2034
2064
  .erp-app .bot-avatar{position:relative;width:40px;height:40px;border-radius:13px;flex:none;display:grid;place-items:center;background:linear-gradient(135deg,#3f2fbe,#7c5cff 62%,#c48bfb);color:#fff;font-weight:800;box-shadow:0 8px 18px -8px rgba(91,70,224,.6);transition:transform .2s,box-shadow .2s}
2035
2065
  .erp-app .bot-avatar:hover{transform:translateY(-1px);box-shadow:0 10px 22px -8px rgba(91,70,224,.72)}
2036
2066
  .erp-app .bot-avatar-t{font-size:14px;letter-spacing:.5px}
2067
+ /* Team avatar uses the users glyph instead of a Latin initial; the shared
2068
+ gradient stays so the team reads as a fifth member of the same family. */
2069
+ .erp-app .bot-avatar.team-avatar svg{width:18px;height:18px}
2037
2070
  .erp-app .bot-avatar-state{position:absolute;right:-1px;bottom:-1px;width:10px;height:10px;border-radius:50%;background:#c3c7d6;border:2px solid #fff}
2038
2071
  .erp-app .bot-avatar-state.live{background:#22b07d}
2039
2072
  .erp-app .bot-avatar-state.warn{background:#e8a13c}
@@ -2057,10 +2090,35 @@ window.__ModuleLoader__.load({
2057
2090
  .erp-app .bot-act.on{background:#efecff;color:#5b46e0}
2058
2091
  .erp-app .bot-act svg{width:15px;height:15px}
2059
2092
  .erp-app .bot-body{position:relative;flex:1;min-height:0;display:flex;flex-direction:column;padding:0}
2093
+ /* Blank session: the official surface (BotChatSurface) stays mounted so the
2094
+ composer — the only input — remains pinned to the bottom. The welcome block
2095
+ is lifted out of the flex flow with absolute positioning, overlaying the
2096
+ empty message area ABOVE the composer instead of splitting the pane in half
2097
+ (which pushed the composer to the middle). */
2098
+ .erp-app .bot-body.is-blank>.bot-welcome{
2099
+ position:absolute;inset:0;padding:16px var(--erp-gutter) 120px;
2100
+ pointer-events:none;z-index:1;
2101
+ }
2102
+ .erp-app .bot-body.is-blank .bot-chips{pointer-events:auto}
2060
2103
  .erp-app .bot-conversation{flex:1;min-height:0;display:flex;flex-direction:column;overflow:hidden}
2061
2104
  .erp-app .bot-conversation>*{flex:1;min-height:0}
2062
- .erp-app .bot-welcome{flex:none;text-align:center;padding:16px var(--erp-gutter) 6px;order:-1}
2063
- .erp-app .bot-orb{width:40px;height:40px;margin:0 auto 6px}
2105
+ /* Welcome state, centred in the empty conversation area.
2106
+ It used to sit at the very top: .bot-welcome is a sibling of the chat
2107
+ surface inside .bot-body (a column), and both were flex:1 with the
2108
+ welcome block ordered first, so when the session is blank the greeting
2109
+ hugged the header instead of the middle of the pane. Growing it and
2110
+ centring on the cross+main axis puts the orb, name and greeting where an
2111
+ empty conversation expects them — and it collapses back to flex:none as
2112
+ soon as the surface renders real messages. */
2113
+ .erp-app .bot-welcome{
2114
+ /* When the session is blank the chat surface contributes nothing, so the
2115
+ welcome block must take the whole body and centre inside it. It is the
2116
+ only child that grows; the surface stays at content height. */
2117
+ flex:1 1 auto;min-height:0;display:flex;flex-direction:column;
2118
+ align-items:center;justify-content:center;
2119
+ text-align:center;padding:16px var(--erp-gutter);
2120
+ }
2121
+ .erp-app .bot-orb{width:40px;height:40px;margin:0 0 10px;flex:none}
2064
2122
  .erp-app .bot-welcome b{display:block;font-size:15px;color:#1a1d38}
2065
2123
  .erp-app .bot-welcome-sub{display:block;margin:6px auto 0;max-width:560px;font-size:11.5px;color:#8b90a6;line-height:1.65}
2066
2124
  .erp-app .bot-chips{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;margin-top:12px}
@@ -2093,11 +2151,47 @@ window.__ModuleLoader__.load({
2093
2151
  .erp-app .bot-tab.on{color:#4a35d0;font-weight:700;border-bottom-color:#6a54f5}
2094
2152
  .erp-app .bot-dr-bd{flex:1;min-height:0;overflow:auto;padding:14px var(--erp-gutter)}
2095
2153
  .erp-app .bot-dr-ft{flex:none;display:flex;align-items:center;gap:8px;padding:10px 16px;border-top:1px solid #eef0f8;font-size:10.5px;color:#9aa0b4}
2154
+ /* Write-back conflict strip — sits above the footer so it cannot be scrolled
2155
+ out of view while the user is deciding what to do. */
2156
+ .erp-app .bot-conflict{flex:none;padding:9px 16px;background:#fff8ec;border-top:1px solid #f4e4c8}
2157
+ .erp-app .bot-conflict .tx{font-size:10.5px;line-height:1.6;color:#8a5a12}
2158
+ .erp-app .bot-conflict .acts{display:flex;gap:6px;margin-top:7px}
2159
+ .erp-app .bot-conflict button{border-radius:7px;padding:4px 10px;font-size:10px;font-weight:650;cursor:pointer}
2160
+ .erp-app .bot-conflict .p{background:#b5741a;border:0;color:#fff}
2161
+ .erp-app .bot-conflict .p:hover{filter:brightness(1.08)}
2162
+ .erp-app .bot-conflict .g{background:#fff;border:1px solid #e6d4b4;color:#8a5a12}
2163
+ .erp-app .bot-conflict .g:hover{background:#fdf6ea}
2164
+ .erp-app[data-theme='dark'] .bot-conflict{background:#2b2317;border-top-color:#3d3120}
2165
+ .erp-app[data-theme='dark'] .bot-conflict .tx{color:#e0b878}
2166
+ .erp-app[data-theme='dark'] .bot-conflict .p{background:#c8871f;color:#171a2c}
2167
+ .erp-app[data-theme='dark'] .bot-conflict .g{background:#1d2136;border-color:#3d3120;color:#e0b878}
2168
+ .erp-app[data-theme='dark'] .bot-conflict .g:hover{background:#2b2317}
2096
2169
  .erp-app .bot-link{margin-left:auto;color:#5b46e0;font-size:11px}
2097
2170
  .erp-app .bot-idcard{display:flex;align-items:center;gap:10px;padding:10px;margin-bottom:12px;border:1px solid #efebff;border-radius:12px;background:linear-gradient(135deg,#f8f5ff,#fdfbff)}
2098
- .erp-app .bot-idcard-ava{width:34px;height:34px;border-radius:11px;background:linear-gradient(135deg,#3f2fbe,#9b86ff);color:#fff;display:grid;place-items:center;font-weight:800;font-size:13px;flex:none}
2171
+ /* Identity disc. The glyph used to be a CJK single character (税/账/司/军) set at
2172
+ 13px inside 34px, which at this size smears into an unreadable blob — CJK
2173
+ strokes need roughly 20px+ to stay distinct, and there is no room for that
2174
+ here. It is now the bot's Latin initial: one thick stroke, crisp at any size,
2175
+ and it matches the brand avatar's monogram language. */
2176
+ .erp-app .bot-idcard-ava{
2177
+ width:34px;height:34px;border-radius:50%;flex:none;
2178
+ display:grid;place-items:center;color:#fff;
2179
+ font-weight:800;font-size:15px;line-height:1;letter-spacing:0;
2180
+ background:linear-gradient(135deg,#3f2fbe,#7c5cff 58%,#a06fe8);
2181
+ box-shadow:0 6px 14px -8px rgba(91,70,224,.72);
2182
+ }
2183
+ /* Per-bot accent. The accent field is set on every bot in bot-data (aurora-v1..v4) but
2184
+ had no matching rule, so all four identity cards rendered the same gradient. */
2185
+ .erp-app .bot-idcard-ava.aurora-v1{background:linear-gradient(135deg,#2e2a8f,#6a54f5 58%,#a99bff)}
2186
+ .erp-app .bot-idcard-ava.aurora-v2{background:linear-gradient(135deg,#123f6b,#2f8fd0 58%,#7fd3f2)}
2187
+ .erp-app .bot-idcard-ava.aurora-v3{background:linear-gradient(135deg,#5a2a6e,#a24fc4 58%,#e0a3f2)}
2188
+ .erp-app .bot-idcard-ava.aurora-v4{background:linear-gradient(135deg,#2f5a3e,#3f9a6a 58%,#8fd9b0)}
2099
2189
  .erp-app .bot-idcard b{display:block;font-size:12px;color:#1a1d38}
2100
- .erp-app .bot-idcard span{display:block;font-size:10.5px;color:#8b90a6;margin-top:2px}
2190
+ /* Scoped to the text column: as .bot-idcard span this rule also matched the
2191
+ avatar disc (same element type, higher specificity than .bot-idcard-ava),
2192
+ forcing its glyph to 10.5px and adding margin-top:2px — which is why the
2193
+ initial looked like a smeared blob and sat off-centre. */
2194
+ .erp-app .bot-idcard > div span{display:block;font-size:10.5px;color:#8b90a6;margin-top:2px}
2101
2195
  .erp-app .bot-field{margin-bottom:11px}
2102
2196
  .erp-app .bot-field>label{display:block;font-size:10.5px;color:#8b90a6;margin-bottom:4px}
2103
2197
  .erp-app .bot-ro{background:#fafafd;border:1px solid #f0f0f7;border-radius:9px;padding:8px 10px;color:#4a4f66;font-size:11px;line-height:1.6;white-space:pre-wrap}
@@ -2105,6 +2199,155 @@ window.__ModuleLoader__.load({
2105
2199
  .erp-app .bot-input:focus{border-color:#c9bcff;box-shadow:0 0 0 3px rgba(106,84,245,.08)}
2106
2200
  .erp-app .bot-row{display:flex;gap:10px}
2107
2201
  .erp-app .bot-row .bot-field{flex:1;min-width:0}
2202
+
2203
+ /* ================= Bot résumé pane =================
2204
+ The drawer stopped being a flat field list: it now reads AGENTS.md and renders
2205
+ a résumé — qualification blocks drawn from sections 3/5/9, a coverage radar
2206
+ computed from structural counts, and fields that can be edited in place.
2207
+ Everything here is scoped under .bot-sec / .bot-qual / .bot-radar so it cannot
2208
+ leak into other bot-page surfaces. */
2209
+
2210
+ /* sync badge in the identity card */
2211
+ .erp-app .bot-sync{
2212
+ flex:none;display:inline-flex;align-items:center;gap:4px;height:19px;padding:0 7px;
2213
+ border-radius:6px;font-size:9px;font-weight:700;white-space:nowrap;
2214
+ }
2215
+ .erp-app .bot-sync.ok{background:rgba(34,176,125,.14);color:#1a8f66}
2216
+ .erp-app .bot-sync.dirty{background:rgba(240,160,42,.16);color:#a2680f}
2217
+ .erp-app .bot-sync i{width:5px;height:5px;border-radius:50%;background:currentColor;flex:none}
2218
+ .erp-app[data-theme='dark'] .bot-sync.ok{background:rgba(61,220,151,.14);color:#3ddc97}
2219
+ .erp-app[data-theme='dark'] .bot-sync.dirty{background:rgba(240,160,42,.16);color:#f0a02a}
2220
+
2221
+ /* section header with a rule that fills the remaining width */
2222
+ .erp-app .bot-sec-hd{
2223
+ display:flex;align-items:center;gap:7px;margin-bottom:8px;
2224
+ font-size:9.5px;font-weight:700;letter-spacing:.09em;text-transform:uppercase;color:#a6acc2;
2225
+ }
2226
+ .erp-app .bot-sec-hd .rule{flex:1;height:1px;background:#eef0f8}
2227
+ .erp-app .bot-sec-hd .cnt{
2228
+ order:2;letter-spacing:0;text-transform:none;font-size:9px;font-weight:600;color:#b0b6c9;
2229
+ }
2230
+ .erp-app[data-theme='dark'] .bot-sec-hd{color:#767e9c}
2231
+ .erp-app[data-theme='dark'] .bot-sec-hd .rule{background:#242840}
2232
+ .erp-app[data-theme='dark'] .bot-sec-hd .cnt{color:#6a7290}
2233
+
2234
+ /* qualification blocks (AGENTS.md sections 3 / 5 / 9) */
2235
+ .erp-app .bot-qual{border:1px solid #eef0f8;border-radius:11px;background:#fafafd;overflow:hidden}
2236
+ .erp-app .bot-qual+.bot-qual{margin-top:7px}
2237
+ .erp-app .bot-qual>summary{
2238
+ display:flex;align-items:center;gap:8px;padding:9px 11px;cursor:pointer;list-style:none;
2239
+ font-size:11px;font-weight:650;color:#3c4157;
2240
+ }
2241
+ .erp-app .bot-qual>summary::-webkit-details-marker{display:none}
2242
+ .erp-app .bot-qual>summary:hover{color:#1a1d38}
2243
+ .erp-app .bot-qual .ico{
2244
+ width:16px;height:16px;border-radius:5px;display:grid;place-items:center;flex:none;
2245
+ background:#efecff;color:#5b46e0;font-size:9px;font-weight:800;
2246
+ }
2247
+ .erp-app .bot-qual .chev{margin-left:auto;color:#a6acc2;font-size:9px;transition:transform .18s}
2248
+ .erp-app .bot-qual[open] .chev{transform:rotate(90deg)}
2249
+ .erp-app .bot-qual .bd{padding:0 11px 11px;font-size:10.5px;line-height:1.75;color:#5f647d;white-space:pre-wrap}
2250
+ .erp-app[data-theme='dark'] .bot-qual{background:#1a1d2e;border-color:#242840}
2251
+ .erp-app[data-theme='dark'] .bot-qual>summary{color:#c9cee6}
2252
+ .erp-app[data-theme='dark'] .bot-qual>summary:hover{color:#eef0fa}
2253
+ .erp-app[data-theme='dark'] .bot-qual .ico{background:#2a2350;color:#c4b6ff}
2254
+ .erp-app[data-theme='dark'] .bot-qual .bd{color:#9aa1bd}
2255
+
2256
+ /* coverage radar */
2257
+ .erp-app .bot-radar{border:1px solid #eef0f8;border-radius:11px;background:#fafafd;padding:11px 11px 8px}
2258
+ .erp-app .bot-radar-top{display:flex;align-items:baseline;gap:8px;margin-bottom:3px}
2259
+ .erp-app .bot-radar-top b{font-size:11px;font-weight:700;color:#1a1d38}
2260
+ .erp-app .bot-radar-top .note{margin-left:auto;font-size:9px;color:#b0b6c9}
2261
+ .erp-app .bot-radar-wrap{display:flex;align-items:center;gap:10px}
2262
+ .erp-app .bot-radar-wrap svg{display:block;flex:none}
2263
+ .erp-app .bot-legend{flex:1;min-width:0;display:flex;flex-direction:column;gap:5px}
2264
+ .erp-app .bot-legend .rl{
2265
+ display:flex;align-items:center;gap:7px;font-size:10.5px;cursor:pointer;
2266
+ border-radius:6px;padding:2px 3px;margin:-2px -3px;
2267
+ }
2268
+ .erp-app .bot-legend .rl:hover,.erp-app .bot-legend .rl.on{background:#f1f2f9}
2269
+ .erp-app .bot-legend .rl .dot{width:5px;height:5px;border-radius:50%;background:#6a54f5;flex:none}
2270
+ .erp-app .bot-legend .rl .n{flex:1;min-width:0;color:#5f647d;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
2271
+ .erp-app .bot-legend .rl .v{font-weight:750;color:#1a1d38;font-variant-numeric:tabular-nums}
2272
+ .erp-app .bot-legend .rl .bar{width:30px;height:3px;border-radius:2px;background:#eceef7;overflow:hidden;flex:none}
2273
+ .erp-app .bot-legend .rl .bar i{display:block;height:100%;background:linear-gradient(90deg,#6a54f5,#a98bfb)}
2274
+ .erp-app .bot-evidence{
2275
+ margin-top:6px;padding:8px 9px;border-radius:8px;background:#f4f5fb;border:1px solid #eef0f8;
2276
+ font-size:9.8px;line-height:1.7;color:#767e9c;
2277
+ }
2278
+ .erp-app .bot-evidence b{color:#4a4f66;font-weight:650}
2279
+ .erp-app .radar-fill{fill:rgba(106,84,245,.13);stroke:#6a54f5;stroke-width:1.5;stroke-linejoin:round}
2280
+ .erp-app .radar-grid{fill:none;stroke:#e4e7f3;stroke-width:1}
2281
+ .erp-app .radar-spoke{stroke:#e4e7f3;stroke-width:1}
2282
+ .erp-app .radar-node{fill:#6a54f5;stroke:#fafafd;stroke-width:1.4}
2283
+ .erp-app .radar-cap{font-size:8px;fill:#a6acc2;font-weight:600}
2284
+ .erp-app[data-theme='dark'] .bot-radar{background:#1a1d2e;border-color:#242840}
2285
+ .erp-app[data-theme='dark'] .bot-radar-top b{color:#eef0fa}
2286
+ .erp-app[data-theme='dark'] .bot-radar-top .note{color:#6a7290}
2287
+ .erp-app[data-theme='dark'] .bot-legend .rl:hover,.erp-app[data-theme='dark'] .bot-legend .rl.on{background:#20243a}
2288
+ .erp-app[data-theme='dark'] .bot-legend .rl .n{color:#9aa1bd}
2289
+ .erp-app[data-theme='dark'] .bot-legend .rl .v{color:#eef0fa}
2290
+ .erp-app[data-theme='dark'] .bot-legend .rl .bar{background:#2a2f4a}
2291
+ .erp-app[data-theme='dark'] .bot-evidence{background:#20243a;border-color:#2a2f4a;color:#8b93b0}
2292
+ .erp-app[data-theme='dark'] .bot-evidence b{color:#c9cee6}
2293
+ .erp-app[data-theme='dark'] .radar-grid,.erp-app[data-theme='dark'] .radar-spoke{stroke:#2a2f4a}
2294
+ .erp-app[data-theme='dark'] .radar-node{stroke:#1a1d2e}
2295
+ .erp-app[data-theme='dark'] .radar-cap{fill:#767e9c}
2296
+
2297
+ /* editable identity rows */
2298
+ .erp-app .bot-editable{border:1px solid #eef0f8;border-radius:11px;background:#fafafd;overflow:hidden}
2299
+ .erp-app .bot-erow{display:flex;align-items:flex-start;gap:8px;padding:9px 11px;border-bottom:1px solid #f2f3fa}
2300
+ .erp-app .bot-erow:last-child{border-bottom:0}
2301
+ .erp-app .bot-erow .lab{width:62px;flex:none;font-size:9.5px;color:#a6acc2;font-weight:650;padding-top:1px}
2302
+ .erp-app .bot-erow .val{flex:1;min-width:0;font-size:10.5px;line-height:1.7;color:#4a4f66;white-space:pre-wrap}
2303
+ .erp-app .bot-erow .ed{
2304
+ flex:none;border:0;background:none;color:#6a54f5;cursor:pointer;
2305
+ font-size:9.5px;font-weight:650;padding:0;
2306
+ }
2307
+ .erp-app .bot-erow .ed:hover{text-decoration:underline}
2308
+ .erp-app .bot-erow.edit-on{background:#f4f5fb;display:block}
2309
+ .erp-app .bot-erow textarea{
2310
+ width:100%;min-height:60px;padding:7px 8px;border-radius:8px;border:1px solid #e0e3ef;
2311
+ background:#fff;color:#3c4157;font:inherit;font-size:10.5px;line-height:1.7;resize:vertical;
2312
+ }
2313
+ .erp-app .bot-erow textarea:focus{outline:none;border-color:#c9bcff;box-shadow:0 0 0 3px rgba(106,84,245,.08)}
2314
+ .erp-app .bot-eacts{display:flex;gap:6px;margin-top:6px}
2315
+ .erp-app .bot-eacts .p{
2316
+ border:0;border-radius:7px;padding:5px 11px;font-size:10px;font-weight:650;cursor:pointer;
2317
+ background:#6a54f5;color:#fff;
2318
+ }
2319
+ .erp-app .bot-eacts .p:hover{filter:brightness(1.08)}
2320
+ .erp-app .bot-eacts .g{
2321
+ border:1px solid #e0e3ef;border-radius:7px;padding:5px 11px;font-size:10px;font-weight:650;
2322
+ cursor:pointer;background:#fff;color:#5f647d;
2323
+ }
2324
+ .erp-app .bot-eacts .g:hover{color:#1a1d38}
2325
+ .erp-app[data-theme='dark'] .bot-editable{background:#1a1d2e;border-color:#242840}
2326
+ .erp-app[data-theme='dark'] .bot-erow{border-bottom-color:#242840}
2327
+ .erp-app[data-theme='dark'] .bot-erow .lab{color:#6a7290}
2328
+ .erp-app[data-theme='dark'] .bot-erow .val{color:#b6bdcf}
2329
+ .erp-app[data-theme='dark'] .bot-erow.edit-on{background:#20243a}
2330
+ .erp-app[data-theme='dark'] .bot-erow textarea{background:#141726;border-color:#2f3550;color:#e7e9f6}
2331
+ .erp-app[data-theme='dark'] .bot-eacts .g{background:#20243a;border-color:#2f3550;color:#9aa1bd}
2332
+ .erp-app[data-theme='dark'] .bot-eacts .g:hover{color:#eef0fa}
2333
+
2334
+ /* red-line list (append-only) */
2335
+ .erp-app .bot-reds{border:1px solid #eef0f8;border-radius:11px;background:#fafafd;overflow:hidden}
2336
+ .erp-app .bot-reds ul{margin:0;padding:10px 11px 4px 26px;font-size:10.5px;line-height:1.9;color:#4a4f66}
2337
+ .erp-app .bot-reds li::marker{color:#e25563}
2338
+ .erp-app .bot-reds .ft{padding:0 11px 11px}
2339
+ .erp-app .bot-tags{display:flex;flex-wrap:wrap;gap:5px;margin-top:7px}
2340
+ .erp-app .bot-tags .tag{
2341
+ border:1px dashed #dfe2ee;background:none;color:#8b90a6;border-radius:6px;
2342
+ padding:3px 7px;font-size:9px;cursor:pointer;
2343
+ }
2344
+ .erp-app .bot-tags .tag:hover{border-color:#6a54f5;color:#5b46e0;background:#f6f3ff}
2345
+ .erp-app .bot-tags .tag:disabled{opacity:.45;cursor:not-allowed;border-style:solid;color:#22b07d;border-color:#c9ead9}
2346
+ .erp-app[data-theme='dark'] .bot-reds{background:#1a1d2e;border-color:#242840}
2347
+ .erp-app[data-theme='dark'] .bot-reds ul{color:#b6bdcf}
2348
+ .erp-app[data-theme='dark'] .bot-tags .tag{border-color:#2f3550;color:#8b93b0}
2349
+ .erp-app[data-theme='dark'] .bot-tags .tag:hover{background:#2a2350;border-color:#6a54f5;color:#c4b6ff}
2350
+ .erp-app[data-theme='dark'] .bot-tags .tag:disabled{color:#3ddc97;border-color:#1d4a38}
2108
2351
  .erp-app .bot-hint{font-size:10.5px;color:#9aa0b4;line-height:1.6;margin-top:6px}
2109
2352
  .erp-app .bot-skills-hd{display:flex;align-items:center;font-size:11px;color:#8b90a6;margin-bottom:6px}
2110
2353
  .erp-app .bot-skills-src{margin-left:auto;color:#9aa0b4;font-size:10px}
@@ -2222,15 +2465,18 @@ window.__ModuleLoader__.load({
2222
2465
  @container erp (max-width:560px){
2223
2466
  .erp-app .bot-role,.erp-app .bot-welcome-sub{display:none}
2224
2467
  .erp-app .bot-avatar{width:34px;height:34px;border-radius:11px}
2225
- .erp-app .bot-welcome{padding:12px var(--erp-gutter) 4px}
2468
+ .erp-app .bot-welcome{padding:12px var(--erp-gutter)}
2226
2469
  }
2227
2470
  @container board (max-height:560px){
2228
- .erp-app .bot-welcome{padding:10px var(--erp-gutter) 2px}
2471
+ .erp-app .bot-welcome{padding:10px var(--erp-gutter)}
2229
2472
  .erp-app .bot-orb{width:30px;height:30px;margin-bottom:4px}
2230
2473
  .erp-app .bot-welcome-sub{display:none}
2231
2474
  }
2232
2475
 
2233
2476
  .erp-app[data-theme='dark'] .bot-hd,.erp-app[data-theme='dark'] .bot-dr-hd,.erp-app[data-theme='dark'] .bot-tabs,.erp-app[data-theme='dark'] .bot-dr-ft{border-color:#242840}
2477
+ .erp-app[data-theme='dark'] .bot-tab{color:#8b93b0}
2478
+ .erp-app[data-theme='dark'] .bot-tab:hover{color:#c9cee6}
2479
+ .erp-app[data-theme='dark'] .bot-tab.on{color:#c4b6ff;border-bottom-color:#8c72ff}
2234
2480
  .erp-app[data-theme='dark'] .bot-name b,.erp-app[data-theme='dark'] .bot-welcome b,.erp-app[data-theme='dark'] .bot-idcard b,.erp-app[data-theme='dark'] .bot-dr-hd b,.erp-app[data-theme='dark'] .bot-cv-hd b,.erp-app[data-theme='dark'] .bot-cv-chip b,.erp-app[data-theme='dark'] .bot-skill-top b,.erp-app[data-theme='dark'] .bot-rel b{color:#eef0fa}
2235
2481
  .erp-app[data-theme='dark'] .bot-role,.erp-app[data-theme='dark'] .bot-session-title,.erp-app[data-theme='dark'] .bot-welcome-sub,.erp-app[data-theme='dark'] .bot-hint,.erp-app[data-theme='dark'] .bot-empty{color:#9aa0b4}
2236
2482
  .erp-app[data-theme='dark'] .bot-avatar-state{border-color:#171a2c}
@@ -2278,6 +2524,15 @@ window.__ModuleLoader__.load({
2278
2524
  .erp-app[data-theme='dark'] .rail-hub.on .rail-hub-ico,.erp-app[data-theme='dark'] .rail-base.on .rail-base-ico{background:#2a2350;border-color:#3b3470;color:#c4b6ff}
2279
2525
  .erp-app[data-theme='dark'] .t1-rail.open .rail-bot .rail-label,.erp-app[data-theme='dark'] .t1-rail.open .rail-base .rail-label{color:#e6e8f5}
2280
2526
  .erp-app[data-theme='dark'] .rail-divider{background:#2a2e48}
2527
+ /* The separator has to lift, not darken, on the dark card: a dimmer line would
2528
+ disappear into #171a2c, so it uses white at low alpha like the other edges. */
2529
+ .erp-app[data-theme='dark'] .rail-sep{
2530
+ background:linear-gradient(90deg,
2531
+ rgba(255,255,255,0) 0%,
2532
+ rgba(255,255,255,.22) 16%,
2533
+ rgba(255,255,255,.22) 84%,
2534
+ rgba(255,255,255,0) 100%);
2535
+ }
2281
2536
  .erp-app[data-theme='dark'] .t1-rail.open .rail-bot.on{background:#1d2136}
2282
2537
  .erp-app[data-theme='dark'] .rail-bot-dot{border-color:#171a2c}
2283
2538
  .erp-app[data-theme='dark'] .rail-me-txt b{color:#eef0fa}
@@ -2294,6 +2549,10 @@ window.__ModuleLoader__.load({
2294
2549
  --erp-rail-btn:38px; /* icon button = brand tile footprint */
2295
2550
  --erp-rail-pad:7px; /* white group's inner padding */
2296
2551
  --erp-rail-gutter:8px; /* whitespace outside the group, both sides */
2552
+ /* The board's own surface colour, named so the brand avatar's seam and its
2553
+ status-dot halo can punch back to the page instead of hard-coding white
2554
+ (which shows as a bright ring in dark mode). Overridden in the dark block. */
2555
+ --erp-board-bg:#f6f7fb;
2297
2556
  --erp-rail-w:calc(var(--erp-rail-btn) + var(--erp-rail-pad) * 2 + 2px + var(--erp-rail-gutter) * 2);
2298
2557
  grid-template-columns:var(--erp-rail-w) minmax(0,1fr);
2299
2558
  }
@@ -2302,29 +2561,72 @@ window.__ModuleLoader__.load({
2302
2561
  padding:12px var(--erp-rail-gutter) 18px;overflow:hidden;
2303
2562
  background:transparent;box-shadow:none;border-right:0;border-radius:0;transition:none;
2304
2563
  }
2305
- .erp-app .t1-rail::after{
2306
- display:block;content:"";position:absolute;right:0;top:24px;bottom:24px;width:1px;
2307
- background:linear-gradient(180deg,
2308
- rgba(198,203,225,0) 0px,
2309
- rgba(198,203,225,.85) 22px,
2310
- rgba(198,203,225,.85) calc(100% - 34px),
2311
- rgba(198,203,225,0) 100%);
2312
- }
2313
- /* The brand tile is the group's first item: same footprint as one icon button,
2314
- so it sits in the same column as the page icons instead of hovering above
2315
- the card. */
2316
- .erp-app .rail-logo{width:100%;flex:none;display:flex;align-items:center;justify-content:center;margin:0}
2317
- .erp-app .rail-logo .rail-mark{
2318
- width:100%;height:var(--erp-rail-btn);flex:none;
2319
- display:grid;place-items:center;border-radius:12px;
2564
+ /* paired with the rule at the top of the file: the divider is off */
2565
+ .erp-app .t1-rail::after{display:none}
2566
+ /* ---------------- brand avatar (rail head) ----------------
2567
+ Three nested discs: ring → board-coloured seam → gradient face. The brand
2568
+ and the active page both used to be full-bleed saturated blocks, so they
2569
+ fought for the same focal slot; now the brand owns a structural signature
2570
+ (a ring) that no navigation button has, and colour only has to carry theme.
2571
+
2572
+ Sized off --erp-rail-btn so the footprint (46px) always fits the rail's
2573
+ 54px content box and rescales with the existing breakpoints. */
2574
+ .erp-app .rail-brand-slot{
2575
+ flex:none;width:100%;display:flex;justify-content:center;
2576
+ margin:2px 0 10px;
2577
+ }
2578
+ .erp-app .brand-av{
2579
+ --brand-ring:2px;
2580
+ --brand-gap:2px;
2581
+ position:relative;display:grid;place-items:center;
2582
+ width:calc(var(--erp-rail-btn) + (var(--brand-ring) + var(--brand-gap)) * 2);
2583
+ height:calc(var(--erp-rail-btn) + (var(--brand-ring) + var(--brand-gap)) * 2);
2584
+ border-radius:50%;
2320
2585
  background:linear-gradient(140deg,#3f2fbe,#7c5cff 52%,#c48bfb);
2321
- color:#fff;font-size:calc(var(--erp-rail-btn) * .47);font-weight:800;line-height:1;
2322
- letter-spacing:.02em;box-shadow:0 8px 18px -12px rgba(94,72,232,.8);
2323
- }
2324
- .erp-app[data-theme='dark'] .rail-logo .rail-mark{
2325
- background:linear-gradient(140deg,#4a37d6,#8a68ff 52%,#cfa2ff);
2326
- box-shadow:0 8px 18px -12px rgba(0,0,0,.8);
2586
+ box-shadow:0 8px 18px -12px rgba(94,72,232,.8);
2587
+ cursor:pointer;transition:transform .2s,box-shadow .2s,filter .2s;
2588
+ }
2589
+ .erp-app .brand-av:hover{transform:scale(1.04);filter:brightness(1.06)}
2590
+ .erp-app .brand-av:focus-visible{
2591
+ outline:none;
2592
+ box-shadow:0 0 0 2px var(--erp-board-bg),0 0 0 4px #7c5cff;
2593
+ }
2594
+ .erp-app .brand-av-gap{
2595
+ width:calc(100% - var(--brand-ring) * 2);
2596
+ height:calc(100% - var(--brand-ring) * 2);
2597
+ border-radius:50%;background:var(--erp-board-bg);
2598
+ display:grid;place-items:center;
2327
2599
  }
2600
+ /* The face's light end stops at #a06fe8 rather than #c48bfb: white on
2601
+ #c48bfb is only about 2.5:1, below even the 3:1 large-text floor. */
2602
+ .erp-app .brand-av-face{
2603
+ width:calc(100% - var(--brand-gap) * 2);
2604
+ height:calc(100% - var(--brand-gap) * 2);
2605
+ border-radius:50%;display:grid;place-items:center;color:#fff;
2606
+ background:linear-gradient(140deg,#241a7a,#5b3cf0 50%,#a06fe8);
2607
+ font-size:calc(var(--erp-rail-btn) * .58);font-weight:800;line-height:1;
2608
+ letter-spacing:.02em;
2609
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.26),inset 0 -8px 14px rgba(16,18,40,.18);
2610
+ }
2611
+ /* The dot is anchored on the ring at 45 degrees, not on the box corner.
2612
+ Geometry: outer radius R = btn/2 + (ring + gap) = btn/2 + 4. The ring's
2613
+ midline is one half-ring inside that, R - ring/2 = btn/2 + 3, and the dot's
2614
+ CENTRE must land there along the diagonal, i.e. (btn/2 + 3)/sqrt(2) per axis.
2615
+ Converting centre to box edge collapses to 0.0415*btn + 1.88px for
2616
+ ring = gap = 2. Written proportionally instead of a fixed 4px so the dot
2617
+ stays on the ring at 38 / 34 / 30 — a hard-coded inset drifts inward and
2618
+ ends up half on the face at btn 30. */
2619
+ .erp-app .brand-dot{
2620
+ position:absolute;
2621
+ width:calc(var(--erp-rail-btn) * .21);
2622
+ height:calc(var(--erp-rail-btn) * .21);
2623
+ right:calc(var(--erp-rail-btn) * .0415 + 1.88px);
2624
+ bottom:calc(var(--erp-rail-btn) * .0415 + 1.88px);
2625
+ border-radius:50%;
2626
+ border:2px solid var(--erp-board-bg);
2627
+ background:#22c55e;
2628
+ }
2629
+ .erp-app .brand-dot.busy{background:#f0a02a}
2328
2630
  /* Foot lockup and brand tile share the group's width, so the column reads as
2329
2631
  one continuous stack: B tile → icon group → wordmark → 中/EN pill. */
2330
2632
  .erp-app .rail-brand{width:100%;height:36px;display:flex;align-items:center;justify-content:center;gap:0;padding:0}
@@ -2341,13 +2643,26 @@ window.__ModuleLoader__.load({
2341
2643
  dead white gutter. */
2342
2644
  .erp-app .rail-group{width:100%;display:flex;flex-direction:column;align-items:center;gap:7px;padding:var(--erp-rail-pad);border-radius:24px;background:#fff;border:1px solid #f1f2f9;box-shadow:0 12px 26px -22px rgba(24,30,66,.7),0 1px 2px rgba(24,30,66,.035)}
2343
2645
  .erp-app .rail-group + .rail-group{margin-top:12px}
2344
- .erp-app .t1-rail > .rail-group:first-of-type{flex:0 1 auto;min-height:0;overflow-y:auto;overflow-x:hidden}
2345
- .erp-app .t1-rail > .rail-group:first-of-type::-webkit-scrollbar{width:0;height:0}
2646
+ .erp-app .t1-rail > .rail-group{flex:0 1 auto;min-height:0;overflow-y:auto;overflow-x:hidden}
2647
+ .erp-app .t1-rail > .rail-group::-webkit-scrollbar{width:0;height:0}
2346
2648
  .erp-app .rail-foot{width:100%;flex:none;display:flex;flex-direction:column;align-items:center;gap:7px;margin-bottom:2px}
2347
2649
  .erp-app .rail-spacer{flex:1 1 auto;min-height:0}
2348
2650
  .erp-app .rail-btn{width:var(--erp-rail-btn);height:var(--erp-rail-btn);border-radius:50%;display:grid;place-items:center;background:#f3f4f9;border:1px solid #e9ebf3;color:#828aa3}
2349
2651
  .erp-app .rail-btn.active{background:#151933;color:#fff;border-color:#151933;box-shadow:0 10px 20px -10px rgba(21,25,51,.6)}
2350
2652
  .erp-app .rail-btn svg{width:18px;height:18px}
2653
+ /* Group separator inside the icon card: a short hairline that marks where the
2654
+ business pages end and the infrastructure page (底座) begins. It is inset
2655
+ rather than full width so it reads as a grouping mark, not as a card edge,
2656
+ and it borrows the group's own padding so it never touches the card sides. */
2657
+ .erp-app .rail-sep{
2658
+ display:block;flex:none;width:calc(100% - 12px);
2659
+ height:1px;margin:6px 0;border-radius:1px;
2660
+ background:linear-gradient(90deg,
2661
+ rgba(122,134,166,0) 0%,
2662
+ rgba(122,134,166,.72) 16%,
2663
+ rgba(122,134,166,.72) 84%,
2664
+ rgba(122,134,166,0) 100%);
2665
+ }
2351
2666
  /* The language pill fills the rail as well: its two segments split the width
2352
2667
  evenly, so it no longer sits narrower than the blocks stacked above it. */
2353
2668
  .erp-app .rail-lang{display:flex;width:100%;justify-content:center;gap:2px;padding:2px}
@@ -2360,14 +2675,17 @@ window.__ModuleLoader__.load({
2360
2675
  @container erp (max-width:900px){
2361
2676
  .erp-app .erp-board{--erp-rail-btn:34px}
2362
2677
  .erp-app .t1-rail{padding-bottom:12px}
2363
- .erp-app .rail-logo .rail-mark{border-radius:11px}
2678
+ /* Below this size the monogram stops counting as WCAG large text, so the 3:1
2679
+ large-text floor becomes a 4.5:1 body-text floor: flatten the light end of
2680
+ the face gradient to keep white legible instead of just shrinking it. */
2681
+ .erp-app .brand-av-face{background:linear-gradient(140deg,#241a7a,#4d31d8 60%,#7d54e0)}
2364
2682
  .erp-app .rail-btn svg{width:16px;height:16px}
2365
2683
  .erp-app .rail-lang .lang-seg{font-size:8.4px}
2366
2684
  .erp-app .rail-greet{display:none}
2367
2685
  }
2368
2686
  @container erp (max-width:560px){
2369
2687
  .erp-app .erp-board{--erp-rail-btn:30px;--erp-rail-pad:6px}
2370
- .erp-app .rail-logo .rail-mark{border-radius:10px}
2688
+ .erp-app .brand-av{--brand-ring:1.5px;--brand-gap:1.5px}
2371
2689
  .erp-app .rail-group{border-radius:20px}
2372
2690
  }`
2373
2691
  /* =====================================================================
@@ -2462,30 +2780,30 @@ window.__ModuleLoader__.load({
2462
2780
  titleKey: 'ov.blocks',
2463
2781
  items: [
2464
2782
  {
2465
- key: 'nav.cai', value: '1,284', subKey: 'focus.cai.sub', timeKey: 'focus.time.past', pct: 67,
2466
- page: 'cai', icon: 'doc', unitKey: 'blocks.unit.vouchers',
2783
+ key: 'nav.finance', value: '1,284', subKey: 'focus.finance.sub', timeKey: 'focus.time.past', pct: 67,
2784
+ page: 'finance', icon: 'doc', unitKey: 'blocks.unit.vouchers',
2467
2785
  period: '2026-11',
2468
2786
  chart: { type: 'bars', data: [58, 72, 64, 80, 70, 86] },
2469
2787
  cells: [
2470
- { k: 'cai.diff', v: '0.00' },
2471
- { k: 'cai.pending', v: '37' },
2472
- { k: 'cai.reportsDone', v: '2/3' },
2788
+ { k: 'finance.diff', v: '0.00' },
2789
+ { k: 'finance.pending', v: '37' },
2790
+ { k: 'finance.reportsDone', v: '2/3' },
2473
2791
  ],
2474
2792
  },
2475
2793
  {
2476
- key: 'nav.zi', value: '¥8,450,000.00', subKey: 'focus.zi.sub', timeKey: 'focus.time.now', pct: 82,
2477
- page: 'zi', icon: 'wallet',
2794
+ key: 'nav.funds', value: '¥8,450,000.00', subKey: 'focus.funds.sub', timeKey: 'focus.time.now', pct: 82,
2795
+ page: 'funds', icon: 'wallet',
2478
2796
  period: '2026-11',
2479
2797
  chart: { type: 'line', data: [52, 61, 58, 70, 66, 78] },
2480
2798
  cells: [
2481
2799
  { k: 'focus.cash', v: '¥7,100,000.00' },
2482
- { k: 'zi.unreconciled', v: '3' },
2483
- { k: 'zi.plan', v: '¥2,480,000.00' },
2800
+ { k: 'funds.unreconciled', v: '3' },
2801
+ { k: 'funds.plan', v: '¥2,480,000.00' },
2484
2802
  ],
2485
2803
  },
2486
2804
  {
2487
- key: 'nav.tui', value: '64%', subKey: 'focus.tui.sub', timeKey: 'focus.time.future', pct: 64,
2488
- page: 'tui', icon: 'chart',
2805
+ key: 'nav.forecast', value: '64%', subKey: 'focus.forecast.sub', timeKey: 'focus.time.future', pct: 64,
2806
+ page: 'forecast', icon: 'chart',
2489
2807
  period: '2026-11',
2490
2808
  chart: {
2491
2809
  type: 'donut',
@@ -2500,19 +2818,19 @@ window.__ModuleLoader__.load({
2500
2818
  },
2501
2819
  cells: [
2502
2820
  { k: 'focus.budget', v: '64%' },
2503
- { k: 'tui.forecast', v: '¥2,480,000.00' },
2504
- { k: 'tui.variance', v: '+0.5%' },
2821
+ { k: 'forecast.revenue', v: '¥2,480,000.00' },
2822
+ { k: 'forecast.variance', v: '+0.5%' },
2505
2823
  ],
2506
2824
  },
2507
2825
  {
2508
- key: 'nav.shui', value: '¥86,400.00', subKey: 'focus.shui.sub', pct: 72,
2509
- page: 'shui', icon: 'list',
2826
+ key: 'nav.tax', value: '¥86,400.00', subKey: 'focus.tax.sub', pct: 72,
2827
+ page: 'tax', icon: 'list',
2510
2828
  period: '2026-10',
2511
2829
  chart: { type: 'bars', data: [62, 70, 66, 78, 72, 84] },
2512
2830
  cells: [
2513
- { k: 'shui.output', v: '¥150,800.00' },
2514
- { k: 'shui.input', v: '¥64,400.00' },
2515
- { k: 'shui.filing', v: '2/3' },
2831
+ { k: 'tax.output', v: '¥150,800.00' },
2832
+ { k: 'tax.input', v: '¥64,400.00' },
2833
+ { k: 'tax.filing', v: '2/3' },
2516
2834
  ],
2517
2835
  },
2518
2836
  ],
@@ -2642,245 +2960,12 @@ window.__ModuleLoader__.load({
2642
2960
  }
2643
2961
  },
2644
2962
  ],
2645
- sections: [
2646
- {
2647
- icon: 'swap',
2648
- label: { zh: 'MCP 通道与服务', en: 'MCP channels and services' },
2649
- kind: 'table',
2650
- pill: { zh: '6 条 · 48 工具', en: '6 channels · 48 tools' },
2651
- summary: [{ label: { zh: '已连接', en: 'Linked' }, value: { zh: '5', en: '5' }, tone: 'ok' }, { label: { zh: '部分授权', en: 'Partial auth' }, value: { zh: '1', en: '1' }, tone: 'warn' }, { label: { zh: '启用 / 注册工具', en: 'Tools enabled / registered' }, value: { zh: '39 / 48', en: '39 / 48' } }],
2652
- data: {
2653
- columns: [{ label: { zh: '通道', en: 'Channel' } }, { label: { zh: '类型', en: 'Type' } }, { label: { zh: '协议与鉴权', en: 'Protocol and auth' } }, { label: { zh: '工具(启用 / 注册)', en: 'Tools (enabled / registered)' }, align: 'right' }, { label: { zh: '可用率', en: 'Uptime' }, align: 'right' }, { label: { zh: 'P95', en: 'P95' }, align: 'right' }, { label: { zh: '最近调用', en: 'Last call' }, align: 'right' }, { label: { zh: '状态', en: 'Status' } }],
2654
- rows: [
2655
- [{ zh: '用友 U8', en: 'Yonyou U8' }, { zh: 'ERP 适配', en: 'ERP adapter' }, { zh: 'MCP over stdio · 服务账号 + 密钥', en: 'MCP over stdio · service account and key' }, { zh: '14 / 18', en: '14 / 18' }, { zh: '100.0%', en: '100.0%', tone: 'ok' }, '2.1s', '23:12', { zh: '已连接', en: 'Linked', tone: 'ok' }],
2656
- [{ zh: '金蝶云星空', en: 'Kingdee Cloud' }, { zh: 'ERP 适配', en: 'ERP adapter' }, { zh: 'MCP over HTTP · OAuth2', en: 'MCP over HTTP · OAuth2' }, { zh: '9 / 12', en: '9 / 12' }, { zh: '100.0%', en: '100.0%', tone: 'ok' }, '1.9s', '18:55', { zh: '已连接', en: 'Linked', tone: 'ok' }],
2657
- [{ zh: '数电发票', en: 'E-invoice' }, { zh: '票税平台', en: 'Tax platform' }, { zh: 'MCP over HTTPS · 服务账号 + 证书', en: 'MCP over HTTPS · service account and certificate' }, { zh: '6 / 6', en: '6 / 6' }, { zh: '100.0%', en: '100.0%', tone: 'ok' }, '1.6s', '23:05', { zh: '已连接', en: 'Linked', tone: 'ok' }],
2658
- [{ zh: '电子税务局', en: 'E-tax bureau' }, { zh: '票税平台', en: 'Tax platform' }, { zh: 'MCP over HTTPS · 证书 + 身份快照', en: 'MCP over HTTPS · certificate and identity snapshot' }, { zh: '4 / 4', en: '4 / 4' }, { zh: '100.0%', en: '100.0%', tone: 'ok' }, '2.6s', '22:40', { zh: '已连接', en: 'Linked', tone: 'ok' }],
2659
- [{ zh: '影像 OCR', en: 'Imaging OCR' }, { zh: '文档识别', en: 'Document reading' }, { zh: 'MCP over HTTP · 服务账号', en: 'MCP over HTTP · service account' }, { zh: '3 / 3', en: '3 / 3' }, { zh: '99.9%', en: '99.9%', tone: 'warn' }, '5.0s', '18:40', { zh: '已连接', en: 'Linked', tone: 'ok' }],
2660
- [{ zh: '银行直连', en: 'Bank link' }, { zh: '银行直连', en: 'Bank link' }, { zh: 'MCP over HTTPS · 证书 + 双人授权', en: 'MCP over HTTPS · certificate and dual approval' }, { zh: '3 / 5', en: '3 / 5' }, { zh: '99.7%', en: '99.7%', tone: 'warn' }, '2.4s', '21:30', { zh: '部分授权', en: 'Partial auth', tone: 'warn' }],
2661
- ],
2662
- total: [{ zh: '合计', en: 'Total' }, { zh: '4 类', en: '4 types' }, '—', { zh: '39 / 48', en: '39 / 48' }, { zh: '99.6%', en: '99.6%' }, '—', { zh: '最近 23:12', en: 'Last 23:12' }, { zh: '5 已连接 / 1 部分授权', en: '5 linked / 1 partial' }]
2663
- },
2664
- alt: {
2665
- kind: 'table',
2666
- label: { zh: '可联通资产', en: 'Connected assets' },
2667
- data: {
2668
- columns: [{ label: { zh: '资产类型', en: 'Asset type' } }, { label: { zh: '资产', en: 'Asset' } }, { label: { zh: '所属通道', en: 'Channel' } }, { label: { zh: '取数内容', en: 'Data scope' } }, { label: { zh: '状态', en: 'Status' } }],
2669
- rows: [
2670
- [{ zh: '账套', en: 'Ledger' }, { zh: 'U8 2026 年度账', en: 'U8 FY2026 ledger' }, { zh: '用友 U8', en: 'Yonyou U8' }, { zh: '凭证 / 账簿 / 报表', en: 'Vouchers, ledgers, reports' }, { zh: '已联通', en: 'Linked', tone: 'ok' }],
2671
- [{ zh: '账套', en: 'Ledger' }, { zh: '金蝶 2026 账套', en: 'Kingdee FY2026 ledger' }, { zh: '金蝶云星空', en: 'Kingdee Cloud' }, { zh: '采购 / 库存 / 应收', en: 'Purchasing, stock, AR' }, { zh: '已联通', en: 'Linked', tone: 'ok' }],
2672
- [{ zh: '账户', en: 'Account' }, { zh: '工行 4821', en: 'ICBC 4821' }, { zh: '银行直连', en: 'Bank link' }, { zh: '余额与流水', en: 'Balances and statements' }, { zh: '已联通', en: 'Linked', tone: 'ok' }],
2673
- [{ zh: '账户', en: 'Account' }, { zh: '工行 7356', en: 'ICBC 7356' }, { zh: '银行直连', en: 'Bank link' }, { zh: '余额与流水', en: 'Balances and statements' }, { zh: '已联通', en: 'Linked', tone: 'ok' }],
2674
- [{ zh: '账户', en: 'Account' }, { zh: '招行 2093', en: 'CMB 2093' }, { zh: '银行直连', en: 'Bank link' }, { zh: '余额与流水', en: 'Balances and statements' }, { zh: '已联通', en: 'Linked', tone: 'ok' }],
2675
- [{ zh: '发票', en: 'Invoice' }, { zh: '数电销项', en: 'E-invoice output' }, { zh: '数电发票', en: 'E-invoice' }, { zh: '销项票面', en: 'Output invoice face' }, { zh: '已联通', en: 'Linked', tone: 'ok' }],
2676
- [{ zh: '发票', en: 'Invoice' }, { zh: '数电进项', en: 'E-invoice input' }, { zh: '数电发票', en: 'E-invoice' }, { zh: '进项与勾选', en: 'Input and selection' }, { zh: '已联通', en: 'Linked', tone: 'ok' }],
2677
- [{ zh: '费控', en: 'Expense control' }, { zh: '费控系统', en: 'Expense system' }, { zh: '费控(文件)', en: 'Expense files' }, { zh: '报销单与预算', en: 'Claims and budget' }, { zh: '已联通', en: 'Linked', tone: 'ok' }]
2678
- ]
2679
- }
2680
- },
2681
- foot: {
2682
- zh: '通道 6 = U8 1 + 金蝶 1 + 数电发票 1 + 电子税务局 1 + 影像 OCR 1 + 银行直连 1;已连接 5 + 部分授权 1(建行 2 户待授权);工具 48 = 18+12+6+4+3+5,启用 39 / 策略禁用 7 / 待授权 2(48 = 39 + 7 + 2);越权拦截 3 均留回执;alt 8 项资产 = 账套 2 + 账户 3 + 发票 2 + 费控 1(未联通 3 账户 = 建行 2 户 + 中行 1 户);可用率 / P95 取近 30 天:可用率 = (720 − 2.9) ÷ 720 = 717.1 ÷ 720 = 99.5972% → 99.6%,6 行不可用分钟 110 + 30 + 8 + 8 + 9 + 9 = 174 分钟 = 2.9h(去重前窗口,平台按去重后 2.9h 计);配置 CFG-2026-11-08(生效 11-08);口径 C-2026-11-01',
2683
- en: '6 channels = U8 1 + Kingdee 1 + e-invoice 1 + e-tax bureau 1 + imaging OCR 1 + bank link 1; 5 linked plus 1 partial (2 CCB accounts pending auth); tools 48 = 18+12+6+4+3+5 with 39 enabled, 7 policy-disabled and 2 pending auth (48 = 39 + 7 + 2); the 3 blocked calls each leave a receipt; 8 assets = 2 ledgers + 3 accounts + 2 invoice sets + 1 expense system (3 accounts not linked = 2 CCB + 1 BOC); uptime and P95 over 30 days: uptime = (720 - 2.9) / 720 = 717.1 / 720 = 99.5972% to 99.6%, and the six down-minute figures 110 + 30 + 8 + 8 + 9 + 9 sum to 174 minutes = 2.9h as the pre-dedup window while the platform counts 2.9h after dedup; config CFG-2026-11-08 effective Nov 8; caliber C-2026-11-01'
2684
- }
2685
- },
2686
- {
2687
- icon: 'spark',
2688
- label: { zh: '技能与场景', en: 'Skills and scenarios' },
2689
- kind: 'table',
2690
- pill: { zh: 'S3 · 12 技能 / 5 场景', en: 'S3 · 12 skills / 5 scenarios' },
2691
- summary: [{ label: { zh: '已启用', en: 'Enabled' }, value: { zh: '9', en: '9' }, tone: 'ok' }, { label: { zh: '待升级', en: 'To upgrade' }, value: { zh: '3', en: '3' }, tone: 'warn' }, { label: { zh: '调用 7 天', en: 'Calls 7 days' }, value: { zh: '1,284', en: '1,284' } }],
2692
- data: {
2693
- columns: [{ label: { zh: '技能', en: 'Skill' } }, { label: { zh: '版本', en: 'Version' } }, { label: { zh: '启用范围', en: 'Scope' } }, { label: { zh: '依赖通道', en: 'Channels' } }, { label: { zh: '状态', en: 'Status' } }, { label: { zh: '近 7 天调用', en: 'Calls 7 days' }, align: 'right' }],
2694
- rows: [
2695
- [{ zh: '凭证识别录入', en: 'Voucher capture' }, 'v3.2', { zh: '财', en: 'Books' }, { zh: 'U8 + 影像 OCR', en: 'U8 and OCR' }, { zh: '启用', en: 'Enabled', tone: 'ok' }, '186'],
2696
- [{ zh: '智能对账', en: 'Smart reconciliation' }, 'v2.4', { zh: '资', en: 'Cash' }, { zh: '银行直连 + U8', en: 'Bank link and U8' }, { zh: '启用', en: 'Enabled', tone: 'ok' }, '142'],
2697
- [{ zh: '账龄分析', en: 'AR aging' }, 'v2.0', { zh: '财 + 总览', en: 'Books and overview' }, { zh: 'U8', en: 'U8' }, { zh: '启用', en: 'Enabled', tone: 'ok' }, '98'],
2698
- [{ zh: '现金流预测', en: 'Cash forecast' }, 'v2.1', { zh: '资 + 推', en: 'Cash and plan' }, { zh: 'U8 + 银行直连', en: 'U8 and bank link' }, { zh: '灰度', en: 'Gray', tone: 'warn' }, '36'],
2699
- [{ zh: '月末关账助手', en: 'Month-end close' }, 'v3.0', { zh: '财', en: 'Books' }, { zh: 'U8', en: 'U8' }, { zh: '启用', en: 'Enabled', tone: 'ok' }, '12'],
2700
- [{ zh: '税务申报助手', en: 'Tax filing' }, 'v1.6', { zh: '税', en: 'Tax' }, { zh: '电子税务局 + 数电', en: 'Tax bureau and e-invoice' }, { zh: '灰度', en: 'Gray', tone: 'warn' }, '8'],
2701
- [{ zh: '发票查验', en: 'Invoice checks' }, 'v1.2', { zh: '税', en: 'Tax' }, { zh: '数电发票', en: 'E-invoice' }, { zh: '启用', en: 'Enabled', tone: 'ok' }, '62'],
2702
- [{ zh: '旧银企 RPA', en: 'Legacy bank RPA' }, 'v0.9', { zh: '—', en: '—' }, { zh: '银行直连', en: 'Bank link' }, { zh: '停用', en: 'Off', tone: 'off' }, '0']
2703
- ],
2704
- total: [{ zh: '合计', en: 'Total' }, { zh: 'S3 生效', en: 'S3 active' }, '—', '—', { zh: '9 启用 / 2 灰度 / 1 停用', en: '9 on / 2 gray / 1 off' }, '1,284']
2705
- },
2706
- alt: {
2707
- kind: 'list',
2708
- label: { zh: '场景与运行', en: 'Scenarios and runs' },
2709
- data: {
2710
- rows: [
2711
- { name: { zh: '月末关账助手', en: 'Month-end close' }, desc: { zh: '运行中 · 每月 · 最近 11-11 09:00 · 技能 月末关账助手 v3.0', en: 'Running · monthly · last Nov 11 09:00 · skill v3.0' }, badge: { zh: '运行中', en: 'Running' }, tone: 'ok', meta: { zh: '财', en: 'Books' } },
2712
- { name: { zh: '资金日报', en: 'Daily cash brief' }, desc: { zh: '运行中 · 每日 4 次(08:00 / 12:00 / 16:00 / 20:00)· 最近 11-11 20:00 · 技能 管报生成 v1.4', en: 'Running · 4 times daily (08:00, 12:00, 16:00, 20:00) · last Nov 11 20:00 · skill v1.4' }, badge: { zh: '运行中', en: 'Running' }, tone: 'ok', meta: { zh: '资 · 总览', en: 'Cash, overview' } },
2713
- { name: { zh: '应收催收', en: 'AR collection' }, desc: { zh: '运行中 · 每周一 · 11-09 · 技能 账龄分析 v2.0', en: 'Running · weekly on Monday · Nov 9 · skill v2.0' }, badge: { zh: '运行中', en: 'Running' }, tone: 'ok', meta: { zh: '财', en: 'Books' } },
2714
- { name: { zh: '预算偏差预警', en: 'Budget variance alert' }, desc: { zh: '待配置 · 触发未设 · 技能 差异归因 v1.1', en: 'To configure · trigger missing · skill v1.1' }, badge: { zh: '待配置', en: 'To configure' }, tone: 'warn', meta: { zh: '推', en: 'Plan' } },
2715
- { name: { zh: '税务申报助手', en: 'Tax filing agent' }, desc: { zh: '停用 · 11-05 起 · 技能 税务申报助手 v1.6(灰度)· 停用只停触发,按需调用仍入回执', en: 'Off · since Nov 5 · skill v1.6 (gray); off stops triggers only, on-demand calls still leave receipts' }, badge: { zh: '停用', en: 'Off' }, tone: 'off', meta: { zh: '税', en: 'Tax' } }
2716
- ]
2717
- }
2718
- },
2719
- foot: {
2720
- zh: '12 技能另 4 项(预算编制 v2.2、管报生成 v1.4、差异归因 v1.1、主数据匹配 v1.0,均启用)合计调用 740,全量 1,284;策略包 = 步骤 / 工具选择 / 口径选择 / 降级 / 复核触发;待升级 3 为版本维度,与灰度 2 不互算;技能会改变助手执行方案,必须可版本、可回滚、可审计;场景侧 5 = 运行中 3 + 待配置 1 + 停用 1;场景 = 技能 + 触发 + 适用页面 / 角色;近 7 天 30 次 = 资金日报 4 × 7 + 应收催收 11-09 + 月末关账 11-11;全量运行记录属本页,推页只展示产出本页结果的运行(R-1111-06)',
2721
- en: 'The other 4 skills (budget v2.2, management report v1.4, variance v1.1, master data match v1.0, all enabled) account for 740 calls, 1,284 in total; a policy pack holds steps, tool choice, caliber choice, fallback and review triggers; 3 pending upgrades is a version measure and does not combine with 2 gray; skill changes alter the agent plan and must be versioned, rollbackable and auditable; on the scenario side: 5 = 3 running + 1 to configure + 1 off; a scenario is a skill plus a trigger plus pages and roles; 30 runs in 7 days = 4 x 7 daily briefs + one collection run on Nov 9 + one close run on Nov 11; the full run log lives here, while the planning page shows only runs that produced its results (R-1111-06)'
2722
- }
2723
- },
2724
- {
2725
- icon: 'doc',
2726
- label: { zh: '数据源', en: 'Sources' },
2727
- kind: 'table',
2728
- pill: { zh: '12 源', en: '12 sources' },
2729
- summary: [{ label: { zh: '已接入', en: 'Connected' }, value: { zh: '12', en: '12' }, tone: 'ok' }, { label: { zh: '权威 / 参考', en: 'Primary and reference' }, value: { zh: '9 / 3', en: '9 / 3' } }, { label: { zh: '待处理接入', en: 'Pending auth or manual' }, value: { zh: '建行 2 户 · 中行 1 户', en: 'CCB 2, BOC 1' }, tone: 'warn' }],
2730
- data: {
2731
- columns: [{ label: { zh: '数据源', en: 'Source' } }, { label: { zh: '类型', en: 'Type' } }, { label: { zh: '接入方式', en: 'Ingest' } }, { label: { zh: '最近批次', en: 'Last batch' } }, { label: { zh: '水位', en: 'Watermark' } }, { label: { zh: '状态', en: 'Status' } }],
2732
- rows: [
2733
- [{ zh: '用友 U8', en: 'Yonyou U8' }, { zh: '核算', en: 'Books' }, { zh: '只读接口 · 增量', en: 'Read-only API, incremental' }, 'B-20261111-109', '2026-11-11 23:10', { zh: '已接入', en: 'Connected', tone: 'ok' }],
2734
- [{ zh: '金蝶云星空', en: 'Kingdee Cloud' }, { zh: '核算', en: 'Books' }, { zh: '只读接口 · 增量', en: 'Read-only API, incremental' }, 'B-20261111-108', '2026-11-11 23:10', { zh: '已接入', en: 'Connected', tone: 'ok' }],
2735
- [{ zh: '工行', en: 'ICBC' }, { zh: '银行', en: 'Bank' }, { zh: '银企直连', en: 'Direct bank link' }, 'B-20261111-107', '2026-11-11 23:10', { zh: '已接入', en: 'Connected', tone: 'ok' }],
2736
- [{ zh: '招行', en: 'CMB' }, { zh: '银行', en: 'Bank' }, { zh: '银企直连', en: 'Direct bank link' }, 'B-20261111-107', '2026-11-11 23:10', { zh: '已接入', en: 'Connected', tone: 'ok' }],
2737
- [{ zh: '建行 2 户', en: 'CCB, 2 accounts' }, { zh: '银行', en: 'Bank' }, { zh: '银企待授权', en: 'Bank link pending auth' }, 'B-20261110-101', '2026-11-10', { zh: '待授权', en: 'Auth pending', tone: 'warn' }],
2738
- [{ zh: '中行 1 户', en: 'BOC, 1 account' }, { zh: '银行', en: 'Bank' }, { zh: '手工导入', en: 'Manual import' }, 'B-20261110-102', '2026-11-10', { zh: '手工接入', en: 'Manual access', tone: 'warn' }],
2739
- [{ zh: '票税平台 2 源', en: 'Tax platform, 2 sources' }, { zh: '票税', en: 'Tax' }, { zh: '平台接口', en: 'Platform API' }, 'B-20261111-109', '2026-11-11 23:10', { zh: '已接入', en: 'Connected', tone: 'ok' }],
2740
- [{ zh: '业务文件 4 源', en: 'Business files, 4 sources' }, { zh: '业务', en: 'Business' }, { zh: '文件与接口', en: 'Files and API' }, 'B-20261111-106', '2026-11-11 23:10', { zh: '已接入', en: 'Connected', tone: 'ok' }]
2741
- ],
2742
- total: [{ zh: '合计', en: 'Total' }, { zh: '12 源', en: '12 sources' }, { zh: '权威 9 / 参考 3', en: '9 primary, 3 reference' }, { zh: '最近 B-20261111-109', en: 'Last B-20261111-109' }, { zh: '2026-11-11 23:10', en: '2026-11-11 23:10' }, { zh: '建行 2 户待授权 · 中行 1 户手工', en: 'CCB 2 pending auth, BOC 1 manual' }]
2743
- },
2744
- foot: { zh: '12 源 = 1+1+1+1+1+1+2+4;权威 9 / 参考 3;建行 2 户待授权、中行 1 户手工导入(已接入 · 手工非自动);业务文件 4 源中影像 OCR 近 24h 1 批超时(见『质量与对账』),接入状态仍为已接入', en: '12 sources = 1+1+1+1+1+1+2+4; 9 primary, 3 reference; CCB 2 accounts pending auth, BOC 1 manual import (connected, manual not automatic); imaging OCR in business files had 1 timeout in 24h (see Quality and recon), access status stays connected' }
2745
- },
2746
- {
2747
- icon: 'pulse',
2748
- label: { zh: '蒸馏批次', en: 'Pipelines' },
2749
- kind: 'chart',
2750
- pill: { zh: '868 批 / 7 天', en: '868 batches / 7 days' },
2751
- summary: [{ label: { zh: '近 7 天批次', en: 'Batches, 7 days' }, value: { zh: '868', en: '868' }, tone: 'ok' }, { label: { zh: '成功', en: 'Succeeded' }, value: { zh: '861', en: '861' }, tone: 'ok' }, { label: { zh: '失败', en: 'Failed' }, value: { zh: '7', en: '7' }, tone: 'warn' }],
2752
- data: {
2753
- mode: 'line',
2754
- series: [120, 126, 122, 128, 124, 122, 126],
2755
- labels: [{ zh: '11-05', en: 'Nov 5' }, { zh: '11-06', en: 'Nov 6' }, { zh: '11-07', en: 'Nov 7' }, { zh: '11-08', en: 'Nov 8' }, { zh: '11-09', en: 'Nov 9' }, { zh: '11-10', en: 'Nov 10' }, { zh: '11-11', en: 'Nov 11' }],
2756
- caption: { zh: '每日批次;滑动 24h 124 批(成功 123 / 失败 1);日历日 11-11 126 批;全量批次 11-09 02:00', en: 'Daily batches; rolling 24h 124 (123 succeeded, 1 failed); calendar day Nov 11 126; full batch at 02:00 on Nov 9' }
2757
- },
2758
- alt: {
2759
- kind: 'table',
2760
- label: { zh: '逐日明细', en: 'Daily detail' },
2761
- data: {
2762
- columns: [{ label: { zh: '日期', en: 'Date' } }, { label: { zh: '批次', en: 'Batches' }, align: 'right' }, { label: { zh: '成功', en: 'Succeeded' }, align: 'right' }, { label: { zh: '失败', en: 'Failed' }, align: 'right' }, { label: { zh: '成功率', en: 'Pass rate' }, align: 'right' }, { label: { zh: '回放', en: 'Replay' } }],
2763
- rows: [
2764
- ['2026-11-05', '120', '120', '0', '100.0%', { zh: '未回放', en: 'Not replayed' }],
2765
- ['2026-11-06', '126', '125', '1', '99.2%', { zh: '未回放', en: 'Not replayed' }],
2766
- ['2026-11-07', '122', '120', '2', '98.4%', { zh: '已回放', en: 'Replayed' }],
2767
- ['2026-11-08', '128', '127', '1', '99.2%', { zh: '未回放', en: 'Not replayed' }],
2768
- ['2026-11-09', '124', '123', '1', '99.2%', { zh: '未回放', en: 'Not replayed' }],
2769
- ['2026-11-10', '122', '121', '1', '99.2%', { zh: '未回放', en: 'Not replayed' }],
2770
- ['2026-11-11', '126', '125', '1', '99.2%', { zh: '未回放', en: 'Not replayed' }]
2771
- ],
2772
- total: [{ zh: '合计', en: 'Total' }, '868', '861', '7', '99.2%', { zh: '已回放 1 批', en: '1 replayed' }]
2773
- }
2774
- },
2775
- foot: { zh: '近 7 天 868 批 = 成功 861 + 失败 7(99.2%);日均 124 批;滑动 24h 124 批(成功 123 / 失败 1,影像 OCR 超时),日历日 11-11 126 批;全量批次 11-09 02:00;批次只增不改,回放不覆盖原批次;同步延迟 4 分钟 / SLA 15 分钟(B-20261111-109 完成 23:14 − 水位 23:10,余量 11 分钟,最慢源=银行流水)', en: '7 days: 868 batches = 861 succeeded + 7 failed (99.2%); 124 per day on average; rolling 24h 124 (123 succeeded, 1 imaging OCR timeout), calendar day Nov 11 126; full batch at 02:00 on Nov 9; batches are append-only and replay never overwrites; sync lag 4 minutes against a 15-minute SLA (batch B-20261111-109 done 23:14 minus watermark 23:10, 11 minutes headroom, slowest source is bank statements)' }
2776
- },
2777
- {
2778
- icon: 'users',
2779
- label: { zh: '主数据映射', en: 'Master data' },
2780
- kind: 'table',
2781
- pill: { zh: '7 项待复核', en: '7 to review' },
2782
- summary: [{ label: { zh: '实体条数', en: 'Entities' }, value: { zh: '500', en: '500' } }, { label: { zh: '待复核', en: 'To review' }, value: { zh: '7', en: '7' }, tone: 'warn' }, { label: { zh: '未映射', en: 'Unmapped' }, value: { zh: '3', en: '3' }, tone: 'warn' }],
2783
- data: {
2784
- columns: [{ label: { zh: '实体', en: 'Entity' } }, { label: { zh: '条数', en: 'Records' }, align: 'right' }, { label: { zh: '待复核', en: 'To review' }, align: 'right' }, { label: { zh: '原因', en: 'Reason' } }, { label: { zh: '状态', en: 'Status' } }],
2785
- rows: [
2786
- [{ zh: '客商', en: 'Partners' }, '386', '3', { zh: '未映射 2 · 新维度 1', en: '2 unmapped · 1 new' }, { zh: '待复核', en: 'To review', tone: 'warn' }],
2787
- [{ zh: '科目', en: 'Accounts' }, '86', '1', { zh: '生效日冲突 1', en: '1 date conflict' }, { zh: '待复核', en: 'To review', tone: 'warn' }],
2788
- [{ zh: '成本中心', en: 'Cost centres' }, '12', '1', { zh: '生效日冲突 1', en: '1 date conflict' }, { zh: '待复核', en: 'To review', tone: 'warn' }],
2789
- [{ zh: '利润中心', en: 'Profit centres' }, '6', '1', { zh: '新维度 1', en: '1 new dimension' }, { zh: '待复核', en: 'To review', tone: 'warn' }],
2790
- [{ zh: '业务条线', en: 'Business lines' }, '4', '0', { zh: '—', en: 'None' }, { zh: '正常', en: 'Clear', tone: 'ok' }],
2791
- [{ zh: '银行账户', en: 'Bank accounts' }, '6', '1', { zh: '未映射 1', en: '1 unmapped' }, { zh: '待复核', en: 'To review', tone: 'warn' }]
2792
- ],
2793
- total: [{ zh: '合计', en: 'Total' }, '500', '7', { zh: '未映射 3 · 生效日冲突 2 · 新维度 2', en: '3 unmapped · 2 conflicts · 2 new' }, { zh: '1 ok / 5 warn', en: '1 ok / 5 warn' }]
2794
- },
2795
- foot: { zh: '待复核 7 = 未映射 3 + 生效日冲突 2 + 新维度 2;来源=ERP 蒸馏镜像,本页只做映射与复核,不新增、不改写;科目 86 与财页全量 86 科目同数,银行账户 6 与资页 6 账户同数', en: '7 to review = 3 unmapped + 2 date conflicts + 2 new dimensions; source is the ERP mirror, this page only maps and reviews, never creates or rewrites; 86 accounts match the books page and 6 bank accounts match the funds page' }
2796
- },
2797
- {
2798
- icon: 'grid',
2799
- label: { zh: '指标与口径', en: 'Metrics and caliber' },
2800
- kind: 'bars',
2801
- pill: { zh: '86 指标', en: '86 metrics' },
2802
- summary: [{ label: { zh: '可用指标', en: 'Usable' }, value: { zh: '86', en: '86' }, tone: 'ok' }, { label: { zh: '口径版本', en: 'Caliber' }, value: { zh: 'C-2026-11-01', en: 'C-2026-11-01' } }, { label: { zh: '六域', en: 'Domains' }, value: { zh: '6', en: '6' }, tone: 'ok' }],
2803
- data: { items: [{ label: { zh: '财', en: 'Finance' }, valueText: '22 项', pct: 25.6 }, { label: { zh: '资', en: 'Funds' }, valueText: '16 项', pct: 18.6 }, { label: { zh: '推', en: 'Forecast' }, valueText: '18 项', pct: 20.9 }, { label: { zh: '税', en: 'Tax' }, valueText: '14 项', pct: 16.3 }, { label: { zh: '数', en: 'Data' }, valueText: '10 项', pct: 11.6 }, { label: { zh: '总览', en: 'Overview' }, valueText: '6 项', pct: 7 }] },
2804
- alt: {
2805
- kind: 'list',
2806
- label: { zh: '六域清单', en: 'Six domains' },
2807
- data: {
2808
- rows: [
2809
- { name: { zh: '财 · 22 项', en: 'Finance, 22' }, desc: { zh: '已发布 · 口径 C-2026-11-01 · 血缘 3 层', en: 'Published · caliber C-2026-11-01 · 3 lineage levels' }, badge: { zh: '生效', en: 'Active' }, tone: 'ok', meta: { zh: '22 项', en: '22' } },
2810
- { name: { zh: '资 · 16 项', en: 'Funds, 16' }, desc: { zh: '已发布 · 口径 C-2026-11-01 · 血缘 3 层', en: 'Published · caliber C-2026-11-01 · 3 lineage levels' }, badge: { zh: '生效', en: 'Active' }, tone: 'ok', meta: { zh: '16 项', en: '16' } },
2811
- { name: { zh: '推 · 18 项', en: 'Forecast, 18' }, desc: { zh: '已发布 + 灰度 · 随假设 A3 重算', en: 'Published plus preview · recomputed with assumption A3' }, badge: { zh: '生效', en: 'Active' }, tone: 'ok', meta: { zh: '18 项', en: '18' } },
2812
- { name: { zh: '税 · 14 项', en: 'Tax, 14' }, desc: { zh: '已发布 · 口径 C-2026-11-01 · 预计口径单列', en: 'Published · caliber C-2026-11-01 · estimate basis kept apart' }, badge: { zh: '生效', en: 'Active' }, tone: 'ok', meta: { zh: '14 项', en: '14' } },
2813
- { name: { zh: '数 · 10 项', en: 'Data, 10' }, desc: { zh: '10 项可用 · 口径 C-2026-11-01 · 血缘 3 层', en: '10 usable · caliber C-2026-11-01 · 3 lineage levels' }, badge: { zh: '生效', en: 'Active' }, tone: 'ok', meta: { zh: '10 项', en: '10' } },
2814
- { name: { zh: '总览 · 6 项', en: 'Overview, 6' }, desc: { zh: '已发布 · 引用四页口径 · 血缘 3 层', en: 'Published · reuses four page calibers · 3 lineage levels' }, badge: { zh: '生效', en: 'Active' }, tone: 'ok', meta: { zh: '6 项', en: '6' } }
2815
- ]
2816
- }
2817
- },
2818
- foot: {
2819
- zh: '指标 86 = 财 22 + 资 16 + 推 18 + 税 14 + 数 10 + 总览 6(六域可用口径);总 102 = 已发布 78 + 灰度 8 + 停用 4 + 草稿 12(四态只在 KPI 2 弹层,不按域拆);口径版本 C-2026-11-01(11-01 发布并生效),版本链 C-2026-10-01(已作废,保留引用)→ C-2026-11-01 →(计划)C-2026-12-01;决策表与费用标准随版本发布、带生效日',
2820
- en: '86 metrics = finance 22 + funds 16 + forecast 18 + tax 14 + data 10 + overview 6 on a usable basis; 102 total = 78 published + 8 preview + 4 retired + 12 draft, shown only in the KPI 2 modal, never split by domain; caliber C-2026-11-01 released and effective on Nov 1; chain C-2026-10-01 superseded but kept, C-2026-11-01 effective, C-2026-12-01 planned for Dec 1; decision tables ship with effective dates'
2821
- }
2822
- },
2823
- {
2824
- icon: 'eye',
2825
- label: { zh: '质量与对账', en: 'Quality and recon' },
2826
- kind: 'checks',
2827
- pill: { zh: '3 待处理', en: '3 open' },
2828
- summary: [{ label: { zh: '规则', en: 'Rules' }, value: { zh: '28', en: '28' } }, { label: { zh: '近 24h 命中', en: 'Hits in 24h' }, value: { zh: '11', en: '11' }, tone: 'warn' }, { label: { zh: '待处理', en: 'Open' }, value: { zh: '3', en: '3' }, tone: 'warn' }],
2829
- data: {
2830
- items: [
2831
- { name: { zh: '凭证借贷平衡', en: 'Voucher debit-credit balance' }, desc: { zh: '1,284 张 · 借贷差 ¥0.00 · 2026-11 期间', en: '1,284 vouchers · gap ¥0.00 · Nov 2026' }, state: 'ok', meta: { zh: '通过', en: 'Passed' } },
2832
- { name: { zh: '科目余额六栏平衡', en: 'Six-column trial balance' }, desc: { zh: '借 = 贷 = ¥29,915,200.00 · 试算差 ¥0.00', en: 'Debits equal credits at ¥29,915,200.00 · gap ¥0.00' }, state: 'ok', meta: { zh: '通过', en: 'Passed' } },
2833
- { name: { zh: '银行余额对账', en: 'Bank balance reconciliation' }, desc: { zh: '10-31 同期 · 银行侧 ¥8,550,000.00 vs 账面 ¥8,450,000.00 · 未达 3 笔 ¥100,000.00(62,000 + 35,000 + 3,000)· 调节后 ¥0.00', en: 'Oct 31 basis · bank side ¥8,550,000.00 vs books ¥8,450,000.00 · 3 unreconciled items ¥100,000.00 (62,000 + 35,000 + 3,000) · nil after adjusting' }, state: 'warn', meta: { zh: '差异待核', en: 'Gap open' } },
2834
- { name: { zh: '销项票面 vs 申报', en: 'Output invoices vs filing' }, desc: { zh: '4 张 · 销项 ¥150,800.00 · 票面与申报一致', en: '4 invoices · output VAT ¥150,800.00 · invoice and filing agree' }, state: 'ok', meta: { zh: '通过', en: 'Passed' } },
2835
- { name: { zh: '主数据唯一性', en: 'Master data uniqueness' }, desc: { zh: '客商 386 · 重复 0 · 主键去重校验', en: '386 partners · 0 duplicates · key uniqueness check' }, state: 'ok', meta: { zh: '通过', en: 'Passed' } },
2836
- { name: { zh: '批次完整性', en: 'Batch completeness' }, desc: { zh: 'B-109 分区 124/124 · 全量校验 11-09 通过', en: 'B-109 partitions 124 of 124 · full check passed Nov 9' }, state: 'ok', meta: { zh: '通过', en: 'Passed' } },
2837
- { name: { zh: '影像 OCR 识别率', en: 'Imaging OCR recognition' }, desc: { zh: '98.7% · 阈值 99% · 准确性规则 · 低于阈值 0.3 个百分点', en: '98.7% · threshold 99% · accuracy rule · 0.3 points below' }, state: 'warn', meta: { zh: '待处理', en: 'Open' } },
2838
- { name: { zh: '接入及时性', en: 'Ingest timeliness' }, desc: { zh: '建行 2 户 + 中行 1 户 T-1 未更新 · 涉及建行、中行 2 行 / 3 源', en: 'CCB 2 accounts plus BOC 1 not refreshed for T-1 · 2 rows and 3 sources' }, state: 'warn', meta: { zh: '待处理', en: 'Open' } }
2839
- ]
2840
- },
2841
- foot: { zh: '规则 28 = 完整性 8 + 一致性 7 + 准确性 6 + 及时性 4 + 唯一性 3;近 24h 命中 11 = 已处置 8 + 待处理 3;待处理 3 = 影像 1 + 及时性 1 + 在途差异 1,规则类为准确性 1(影像 OCR)+ 一致性 1(银行未达)+ 及时性 1(T-1 未更新)', en: '28 rules = 8 completeness + 7 consistency + 6 accuracy + 4 timeliness + 3 uniqueness; 11 hits in 24h = 8 handled + 3 open; 3 open = imaging 1 + timeliness 1 + in-transit gap 1, by rule class accuracy 1 (imaging OCR) + consistency 1 (bank gap) + timeliness 1 (T-1)' }
2842
- },
2843
- {
2844
- icon: 'bell',
2845
- label: { zh: '回执与审计', en: 'Receipts and audit' },
2846
- kind: 'table',
2847
- pill: { zh: '1,284 次 / 7 天', en: '1,284 calls / 7 days' },
2848
- summary: [{ label: { zh: '近 7 天调用', en: 'Calls 7 days' }, value: { zh: '1,284', en: '1,284' } }, { label: { zh: '失败', en: 'Failures' }, value: { zh: '12', en: '12' }, tone: 'warn' }, { label: { zh: '审计断链', en: 'Chain breaks' }, value: { zh: '0', en: '0' }, tone: 'ok' }],
2849
- data: {
2850
- columns: [{ label: { zh: '时间', en: 'Time' }, align: 'right' }, { label: { zh: '场景或技能', en: 'Scenario or skill' } }, { label: { zh: '通道工具', en: 'Channel and tool' } }, { label: { zh: '结果', en: 'Result' } }, { label: { zh: '耗时', en: 'Latency' }, align: 'right' }, { label: { zh: '回执号', en: 'Receipt' } }],
2851
- rows: [
2852
- ['11-11 23:12', { zh: '月末关账助手 · 场景触发', en: 'Month-end close · trigger' }, { zh: 'U8 · 凭证检索', en: 'U8 · voucher search' }, { zh: '成功', en: 'Success', tone: 'ok' }, '820ms', 'RC-1111-2312-183'],
2853
- ['11-11 23:05', { zh: '发票查验 v1.2 · 按需', en: 'Invoice checks v1.2 · on demand' }, { zh: '数电发票 · 查验', en: 'E-invoice · verify' }, { zh: '成功', en: 'Success', tone: 'ok' }, '640ms', 'RC-1111-2305-179'],
2854
- ['11-11 22:40', { zh: '税务申报助手(灰度)· 按需', en: 'Tax filing (gray) · on demand' }, { zh: '电子税务局 · 申报预检', en: 'Tax bureau · pre-check' }, { zh: '成功', en: 'Success', tone: 'ok' }, '1.2s', 'RC-1111-2240-168'],
2855
- ['11-11 21:30', { zh: '智能对账 · 按需', en: 'Smart reconciliation · on demand' }, { zh: '银行直连 · 流水拉取', en: 'Bank link · statements' }, { zh: '成功', en: 'Success', tone: 'ok' }, '2.1s', 'RC-1111-2130-151'],
2856
- ['11-11 20:15', { zh: '未知调用方 · 按需', en: 'Unknown caller · on demand' }, { zh: '银行直连 · 付款接口', en: 'Bank link · payment API' }, { zh: '拦截', en: 'Blocked', tone: 'warn' }, '0ms', 'RC-1111-2015-142'],
2857
- ['11-11 18:40', { zh: '凭证识别录入 v3.2 · 按需', en: 'Voucher capture v3.2 · on demand' }, { zh: '影像 OCR · 识别', en: 'OCR · reading' }, { zh: '失败(超时)', en: 'Failed (timeout)', tone: 'warn' }, '5.0s', 'RC-1111-1840-121'],
2858
- ['11-11 17:20', { zh: '账龄分析 v2.0 · 按需', en: 'AR aging v2.0 · on demand' }, { zh: 'U8 · 凭证检索', en: 'U8 · voucher search' }, { zh: '成功', en: 'Success', tone: 'ok' }, '900ms', 'RC-1111-1720-112'],
2859
- ['11-11 06:30', { zh: '滚动预测 R-1111-06 · 场景触发', en: 'Rolling forecast R-1111-06 · trigger' }, { zh: 'U8 + 银行直连 · 现金流预测 v2.1', en: 'U8 and bank link · cash forecast v2.1' }, { zh: '成功', en: 'Success', tone: 'ok' }, '1.8s', 'RC-1111-0630-012']
2860
- ]
2861
- },
2862
- alt: {
2863
- kind: 'list',
2864
- label: { zh: '导出与回执', en: 'Exports and receipts' },
2865
- data: {
2866
- rows: [
2867
- { name: { zh: '资产负债表', en: 'Balance sheet' }, desc: { zh: '2026-10 期次 · 水印五件齐全 · 导出人 王静 · 11-11 09:20', en: 'Oct 2026 · all five watermarks · exported by Wang Jing · Nov 11 09:20' }, badge: { zh: '已导出', en: 'Exported' }, tone: 'ok', meta: { zh: '业务单号 EX-0912', en: 'Export No. EX-0912' } },
2868
- { name: { zh: '利润表', en: 'Income statement' }, desc: { zh: '2026-10 期次 · 水印五件齐全 · 导出人 王静 · 11-11 09:20', en: 'Oct 2026 · all five watermarks · exported by Wang Jing · Nov 11 09:20' }, badge: { zh: '已导出', en: 'Exported' }, tone: 'ok', meta: { zh: '业务单号 EX-0913', en: 'Export No. EX-0913' } },
2869
- { name: { zh: '现金流量表', en: 'Cash-flow statement' }, desc: { zh: '2026-10 期次 · 底稿待复核 · 水印标待复核 · 导出人 王静', en: 'Oct 2026 · workpaper pending review · watermark marked draft · exported by Wang Jing' }, badge: { zh: '待复核版', en: 'Draft version' }, tone: 'warn', meta: { zh: '业务单号 EX-0914', en: 'Export No. EX-0914' } },
2870
- { name: { zh: '资金日报', en: 'Daily cash brief' }, desc: { zh: '2026-11-10 · 水印五件齐全 · 导出人 陈可 · 11-11 08:10', en: 'Nov 10 2026 · all five watermarks · exported by Chen Ke · Nov 11 08:10' }, badge: { zh: '已导出', en: 'Exported' }, tone: 'ok', meta: { zh: '业务单号 EX-0915', en: 'Export No. EX-0915' } },
2871
- { name: { zh: '税务申报表', en: 'Tax return' }, desc: { zh: '2026-10 期次 · 待报送草稿 · 水印五件齐全 · 导出人 李珂', en: 'Oct 2026 · draft before filing · all five watermarks · exported by Li Ke' }, badge: { zh: '草稿导出', en: 'Draft export' }, tone: 'warn', meta: { zh: '业务单号 EX-0916', en: 'Export No. EX-0916' } }
2872
- ]
2873
- }
2874
- },
2875
- foot: {
2876
- zh: '近 7 天 1,284 = 成功 1,269 + 失败 12 + 越权拦截 3,审计断链 0;失败 12 = 超时 9 + 鉴权 2 + 限流 1;RC 号 = RC-{MMDD}-{HHMM}-{三位当日流水},当日 183 条;拦截不算失败;R-1111-06 属运行编号、RC 属回执编号,不同域;导出侧 5 = 财报导出 3 + 资金日报 1 + 税务申报表 1,失败 0,最近 11-11 09:20;水印五件 = 口径版本 + 数据时点 + 血缘短链 + 导出人 + 导出时间;业务单号 EX-… 为导出凭据号,RC-… 在审计链,两套编号不互称;导出发起 / 审批 / 下载不在本页,本页只读',
2877
- en: '7-day calls 1,284 = 1,269 succeeded + 12 failed + 3 blocked, with 0 chain breaks; 12 failures = 9 timeouts + 2 auth + 1 rate limit; RC numbers follow RC-{MMDD}-{HHMM}-{daily serial} with 183 that day; a blocked call is not a failure; R-1111-06 is a run number and RC is a receipt number, different domains; exports: 5 = 3 statement exports + 1 daily cash brief + 1 tax return with 0 failures, latest at 09:20 on Nov 11; five watermarks = caliber version + data time + lineage link + exporter + export time; EX- numbers are export references and RC- numbers live in the audit chain, the two are never interchanged; export initiation, approval and download happen outside this page, which stays read only'
2878
- }
2879
- },
2880
- ],
2881
2963
  },
2882
- cai: {
2883
- botId: 'cai',
2964
+ team: {
2965
+ title: { zh: '团队模式', en: 'Team mode' },
2966
+ },
2967
+ finance: {
2968
+ botId: 'finance',
2884
2969
  title: { zh: '财 · 过去', en: 'Books · Past' },
2885
2970
  kpis: [
2886
2971
  {
@@ -3121,8 +3206,8 @@ window.__ModuleLoader__.load({
3121
3206
  },
3122
3207
  ],
3123
3208
  },
3124
- zi: {
3125
- botId: 'zi',
3209
+ funds: {
3210
+ botId: 'funds',
3126
3211
  title: { zh: '资 · 现在', en: 'Funds · Now' },
3127
3212
  kpis: [
3128
3213
  {
@@ -3312,8 +3397,8 @@ window.__ModuleLoader__.load({
3312
3397
  },
3313
3398
  ],
3314
3399
  },
3315
- tui: {
3316
- botId: 'tui',
3400
+ forecast: {
3401
+ botId: 'forecast',
3317
3402
  title: { zh: '推 · 未来', en: 'Forecast · Future' },
3318
3403
  kpis: [
3319
3404
  {
@@ -3567,8 +3652,8 @@ window.__ModuleLoader__.load({
3567
3652
  },
3568
3653
  ],
3569
3654
  },
3570
- shui: {
3571
- botId: 'shui',
3655
+ tax: {
3656
+ botId: 'tax',
3572
3657
  title: { zh: '税 · 税务', en: 'Tax · Filing' },
3573
3658
  kpis: [
3574
3659
  {
@@ -3778,18 +3863,18 @@ window.__ModuleLoader__.load({
3778
3863
  * 纯数据片段:无函数、无 h(、无 React;由 workbench / entry 通过 deps 注入。
3779
3864
  * ===================================================================== */
3780
3865
 
3781
- const BOT_IDS = ['cai', 'zi', 'tui', 'shui']
3866
+ const BOT_IDS = ['finance', 'funds', 'forecast', 'tax']
3782
3867
 
3783
3868
  const SKILL_IDS = {
3784
- cai: ['vouchers', 'ledgers', 'ar-aging', 'payables', 'fixed-assets', 'close', 'statements', 'workpapers'],
3785
- zi: ['accounts', 'payments', 'recon', 'plan', 'restricted', 'bills'],
3786
- tui: ['budget', 'forecast', 'scenario', 'assumption', 'allocation', 'mgmt-report', 'variance', 'cash-plan'],
3787
- shui: ['output-invoice', 'input-verify', 'invoice-check', 'file', 'pay', 'burden'],
3869
+ finance: ['vouchers', 'ledgers', 'ar-aging', 'payables', 'fixed-assets', 'close', 'statements', 'workpapers'],
3870
+ funds: ['accounts', 'payments', 'recon', 'plan', 'restricted', 'bills'],
3871
+ forecast: ['budget', 'forecast', 'scenario', 'assumption', 'allocation', 'mgmt-report', 'variance', 'cash-plan'],
3872
+ tax: ['output-invoice', 'input-verify', 'invoice-check', 'file', 'pay', 'burden'],
3788
3873
  }
3789
3874
 
3790
3875
  const BOTS = {
3791
- cai: {
3792
- id: 'cai',
3876
+ finance: {
3877
+ id: 'finance',
3793
3878
  name: { zh: '账房', en: 'The Ledger' },
3794
3879
  short: { zh: '账', en: 'L' },
3795
3880
  role: { zh: '把账记对、把账关好、把证据留全', en: 'Keep the books correct, close them cleanly, keep the evidence complete' },
@@ -3821,8 +3906,8 @@ window.__ModuleLoader__.load({
3821
3906
  en: 'For questions about whether the books balance, where the close stands or whether statements are ready: give the conclusion first, then the definition and the evidence path (statements, workpapers, ledgers, vouchers). Name the document or step behind every gap.',
3822
3907
  },
3823
3908
  },
3824
- zi: {
3825
- id: 'zi',
3909
+ funds: {
3910
+ id: 'funds',
3826
3911
  name: { zh: '司库', en: 'The Treasurer' },
3827
3912
  short: { zh: '司', en: 'T' },
3828
3913
  role: { zh: '守住可支付能力与安全垫', en: 'Guard the payment capacity and the safety buffer' },
@@ -3854,8 +3939,8 @@ window.__ModuleLoader__.load({
3854
3939
  en: 'For questions about how much cash is available, whether payments are covered or where the gap is: give the available balance and the buffer verdict first, then the timestamp, restrictions and unreconciled items. Flag payment dependencies when they matter.',
3855
3940
  },
3856
3941
  },
3857
- tui: {
3858
- id: 'tui',
3942
+ forecast: {
3943
+ id: 'forecast',
3859
3944
  name: { zh: '军师', en: 'The Planner' },
3860
3945
  short: { zh: '军', en: 'P' },
3861
3946
  role: { zh: '把不确定性与假设讲清楚', en: 'Make uncertainty and assumptions explicit' },
@@ -3887,8 +3972,8 @@ window.__ModuleLoader__.load({
3887
3972
  en: 'For questions about budget headroom, the next three months or what an assumption change does: give the range and the assumption version first, then the conclusion and sensitivities. Keep forecast and commitment separate.',
3888
3973
  },
3889
3974
  },
3890
- shui: {
3891
- id: 'shui',
3975
+ tax: {
3976
+ id: 'tax',
3892
3977
  name: { zh: '税官', en: 'The Tax Officer' },
3893
3978
  short: { zh: '税', en: 'X' },
3894
3979
  role: { zh: '不错报、不漏报、红线可见', en: 'No wrong filing, no missed filing, red lines visible' },
@@ -3921,13 +4006,27 @@ window.__ModuleLoader__.load({
3921
4006
  },
3922
4007
  },
3923
4008
  }
3924
- /** Side navigation: 总览 + 四个业务 Bot + 底座(MCP 与数据合一)。 */
4009
+
4010
+ /** Skill dependency declarations: skill name → prerequisite skill names.
4011
+ * When a user adds a skill, the UI warns if any prerequisite is missing.
4012
+ * These are only the cross-skill deps that matter at binding time; the
4013
+ * skill's own SKILL.md is the authority for runtime requirements. */
4014
+ const SKILL_DEPS = {
4015
+ /* DSH standard skills that need MCP channels or other skills. */
4016
+ 'intelligent-recon': ['bank-mcp-channel'],
4017
+ 'invoice-check': ['tax-system-channel'],
4018
+ 'tax-filing-assistant': ['tax-system-channel'],
4019
+ 'bank-rpa': ['bank-mcp-channel'],
4020
+ }
4021
+ /** Side navigation: 总览 + 四个业务 Bot + 团队模式 + 底座(MCP 与数据合一)。
4022
+ 团队模式排在底座之前:底座是「基础设施」类条目,放最后收尾更像栏目边界。 */
3925
4023
  const NAV = [
3926
4024
  { id: 'overview', icon: 'grid', key: 'nav.overview' },
3927
- { id: 'cai', icon: 'doc', key: 'nav.cai' },
3928
- { id: 'zi', icon: 'wallet', key: 'nav.zi' },
3929
- { id: 'tui', icon: 'chart', key: 'nav.tui' },
3930
- { id: 'shui', icon: 'list', key: 'nav.shui' },
4025
+ { id: 'finance', icon: 'doc', key: 'nav.finance' },
4026
+ { id: 'funds', icon: 'wallet', key: 'nav.funds' },
4027
+ { id: 'forecast', icon: 'chart', key: 'nav.forecast' },
4028
+ { id: 'tax', icon: 'list', key: 'nav.tax' },
4029
+ { id: 'team', icon: 'users', key: 'nav.team' },
3931
4030
  { id: 'base', icon: 'layers', key: 'nav.base' },
3932
4031
  ]
3933
4032
  /** Normalized label of a KPI (inline literal or dictionary key). */
@@ -4406,6 +4505,15 @@ window.__ModuleLoader__.load({
4406
4505
  'ai.expand': '展开助手',
4407
4506
  'ai.prompt.insights': '总结一下本月账务的关键异常和待办。',
4408
4507
  'ai.prompt.forecast': '按当前数据预测未来 3 个月现金流,并给出风险点。',
4508
+ 'team.title': '团队协作',
4509
+ 'team.role': '账房 · 司库 · 军师 · 税官',
4510
+ 'team.sub': '共享工作区 · 任务板 · 多 Bot 协作',
4511
+ 'team.avatar': '团队',
4512
+ 'team.session': '团队会话',
4513
+ 'team.greeting': '我是财务团队。月末关账、资金对账、税务申报、现金流预测都可以交给团队;先告诉我这次要做什么?',
4514
+ 'team.chip.close': '做一次月末关账',
4515
+ 'team.chip.recon': '对一下银行账户',
4516
+ 'team.chip.tax': '核一下本期要交的税',
4409
4517
  'ai.prompt.reports': '生成本月经营简报:收入、支出、利润和异常项。',
4410
4518
  'aging.title': '应收账款账龄',
4411
4519
  'aging.totalLabel': '应收合计',
@@ -4427,11 +4535,12 @@ window.__ModuleLoader__.load({
4427
4535
  'metric.recent': '近 10 个时点',
4428
4536
  'metric.empty': '暂无明细',
4429
4537
  'nav.overview': '总览',
4430
- 'nav.cai': '财 · 过去',
4431
- 'nav.zi': '资 · 现在',
4432
- 'nav.tui': '推 · 未来',
4433
- 'nav.shui': '税 · 税务',
4538
+ 'nav.finance': '财 · 过去',
4539
+ 'nav.funds': '资 · 现在',
4540
+ 'nav.forecast': '推 · 未来',
4541
+ 'nav.tax': '税 · 税务',
4434
4542
  'nav.base': '底座 · MCP 与数据',
4543
+ 'nav.team': '团队模式',
4435
4544
  'theme.toDark': '切换到深色',
4436
4545
  'theme.toLight': '切换到浅色',
4437
4546
  'list.empty': '暂无数据',
@@ -4461,15 +4570,15 @@ window.__ModuleLoader__.load({
4461
4570
  'feed.approvalSub': '18 分钟前',
4462
4571
  'feed.alert': '数据源预警',
4463
4572
  'feed.alertSub': '1 小时前',
4464
- 'cai.vouchers': '本期凭证数',
4465
- 'cai.vouchersSub': '环比 +6.2%',
4466
- 'cai.diff': '借贷差异',
4467
- 'cai.diffSub': '差 0 笔',
4468
- 'cai.pending': '待处理单据',
4469
- 'cai.pendingSub': '较昨日 -12',
4470
- 'cai.close': '期末进度',
4471
- 'cai.closeSub': '已完成 8 / 12',
4472
- 'cai.books': '账务',
4573
+ 'finance.vouchers': '本期凭证数',
4574
+ 'finance.vouchersSub': '环比 +6.2%',
4575
+ 'finance.diff': '借贷差异',
4576
+ 'finance.diffSub': '差 0 笔',
4577
+ 'finance.pending': '待处理单据',
4578
+ 'finance.pendingSub': '较昨日 -12',
4579
+ 'finance.close': '期末进度',
4580
+ 'finance.closeSub': '已完成 8 / 12',
4581
+ 'finance.books': '账务',
4473
4582
  'books.period': '期间与关账',
4474
4583
  'books.periodSub': '2026-11 · 未锁账',
4475
4584
  'books.vouchers': '凭证与分录',
@@ -4478,7 +4587,7 @@ window.__ModuleLoader__.load({
4478
4587
  'books.ledgerSub': '明细账 / 总账',
4479
4588
  'books.snapshot': '期末镜像',
4480
4589
  'books.snapshotSub': 'ERP 关账状态',
4481
- 'cai.reports': '报表',
4590
+ 'finance.reports': '报表',
4482
4591
  'reports.statutory': '法定报表',
4483
4592
  'reports.statutorySub': '资产负债表 / 利润表 / 现金流量表',
4484
4593
  'reports.consol': '合并(占位)',
@@ -4487,29 +4596,29 @@ window.__ModuleLoader__.load({
4487
4596
  'reports.taxSub': '进项 / 销项',
4488
4597
  'reports.workpaper': '底稿与回执',
4489
4598
  'reports.workpaperSub': '取数链 / 凭据',
4490
- 'cai.closeMirror': '期末镜像',
4599
+ 'finance.closeMirror': '期末镜像',
4491
4600
  'close.step1': '关账步骤',
4492
4601
  'close.step1Sub': 'ERP 推进中',
4493
4602
  'close.step2': '锁账',
4494
4603
  'close.step2Sub': '未锁定',
4495
4604
  'close.step3': '重跑',
4496
4605
  'close.step3Sub': '本架构不发起',
4497
- 'cai.check': '核对异常',
4606
+ 'finance.check': '核对异常',
4498
4607
  'check.recon': '勾稽差异',
4499
4608
  'check.reconSub': '本期',
4500
4609
  'check.cross': '跨表差异',
4501
4610
  'check.crossSub': '待处理',
4502
4611
  'check.miss': '漏账断言',
4503
4612
  'check.missSub': '本期',
4504
- 'cai.datasets': '数据集目录',
4613
+ 'finance.datasets': '数据集目录',
4505
4614
  'ds.voucher': '凭证与分录',
4506
4615
  'ds.voucherSub': '只读',
4507
4616
  'ds.ledger': '账簿',
4508
4617
  'ds.ledgerSub': '只读',
4509
4618
  'ds.docs': '单据(报销 / 发票 / 合同)',
4510
4619
  'ds.docsSub': '只读',
4511
- 'cai.reportsDone': '报表完成',
4512
- 'cai.reportsDoneSub': '已出 3 / 4 张',
4620
+ 'finance.reportsDone': '报表完成',
4621
+ 'finance.reportsDoneSub': '已出 3 / 4 张',
4513
4622
  'progress.done': '已完成',
4514
4623
  'bars.debit': '借方',
4515
4624
  'bars.credit': '贷方',
@@ -4528,17 +4637,17 @@ window.__ModuleLoader__.load({
4528
4637
  'focus.time.past': '过去 · 已发生',
4529
4638
  'focus.time.now': '现在 · 在途',
4530
4639
  'focus.time.future': '未来 · 假设',
4531
- 'focus.cai.sub': '本期凭证与期末进度',
4532
- 'focus.zi.sub': '总余额与未达项',
4533
- 'focus.tui.sub': '预算使用与预测',
4534
- 'focus.shui.sub': '票税与申报进度',
4535
- 'zi.unreconciled': '未达项',
4536
- 'zi.plan': '90 天计划',
4537
- 'tui.forecast': '预测收入',
4538
- 'tui.variance': '与上版差异',
4539
- 'shui.output': '销项税额',
4540
- 'shui.input': '进项税额',
4541
- 'shui.filing': '申报进度',
4640
+ 'focus.finance.sub': '本期凭证与期末进度',
4641
+ 'focus.funds.sub': '总余额与未达项',
4642
+ 'focus.forecast.sub': '预算使用与预测',
4643
+ 'focus.tax.sub': '票税与申报进度',
4644
+ 'funds.unreconciled': '未达项',
4645
+ 'funds.plan': '90 天计划',
4646
+ 'forecast.revenue': '预测收入',
4647
+ 'forecast.variance': '与上版差异',
4648
+ 'tax.output': '销项税额',
4649
+ 'tax.input': '进项税额',
4650
+ 'tax.filing': '申报进度',
4542
4651
  'blocks.paused': '已暂停',
4543
4652
  'blocks.open': '打开板块',
4544
4653
  'blocks.unit.vouchers': '凭证',
@@ -4659,6 +4768,15 @@ window.__ModuleLoader__.load({
4659
4768
  'ai.expand': 'Expand assistant',
4660
4769
  'ai.prompt.insights': 'Summarize this month’s key exceptions and open items.',
4661
4770
  'ai.prompt.forecast': 'Forecast the next three months of cash flow from the current data and list the risks.',
4771
+ 'team.title': 'Team',
4772
+ 'team.role': 'Ledger · Treasurer · Planner · Tax Officer',
4773
+ 'team.sub': 'Shared workspace · task board · multi-bot',
4774
+ 'team.avatar': 'Team',
4775
+ 'team.session': 'Team session',
4776
+ 'team.greeting': 'I am the finance team. Month-end close, bank reconciliation, tax filing, cash-flow forecast — tell me what to do first.',
4777
+ 'team.chip.close': 'Run a month-end close',
4778
+ 'team.chip.recon': 'Reconcile the bank accounts',
4779
+ 'team.chip.tax': 'Check the tax due this period',
4662
4780
  'ai.prompt.reports': 'Draft this month’s operating brief: income, expenses, profit, and exceptions.',
4663
4781
  'aging.title': 'AR aging',
4664
4782
  'aging.totalLabel': 'Outstanding',
@@ -4680,11 +4798,12 @@ window.__ModuleLoader__.load({
4680
4798
  'metric.recent': 'Last 10 points',
4681
4799
  'metric.empty': 'No details yet',
4682
4800
  'nav.overview': 'Overview',
4683
- 'nav.cai': 'Books · Past',
4684
- 'nav.zi': 'Funds · Now',
4685
- 'nav.tui': 'Forecast · Future',
4686
- 'nav.shui': 'Tax · Filing',
4801
+ 'nav.finance': 'Books · Past',
4802
+ 'nav.funds': 'Funds · Now',
4803
+ 'nav.forecast': 'Forecast · Future',
4804
+ 'nav.tax': 'Tax · Filing',
4687
4805
  'nav.base': 'Base · MCP and Data',
4806
+ 'nav.team': 'Team mode',
4688
4807
  'theme.toDark': 'Switch to dark',
4689
4808
  'theme.toLight': 'Switch to light',
4690
4809
  'list.empty': 'No data',
@@ -4714,15 +4833,15 @@ window.__ModuleLoader__.load({
4714
4833
  'feed.approvalSub': '18 minutes ago',
4715
4834
  'feed.alert': 'Source alert',
4716
4835
  'feed.alertSub': '1 hour ago',
4717
- 'cai.vouchers': 'Vouchers',
4718
- 'cai.vouchersSub': '+6.2% MoM',
4719
- 'cai.diff': 'Debit-credit diff',
4720
- 'cai.diffSub': '0 lines off',
4721
- 'cai.pending': 'Pending documents',
4722
- 'cai.pendingSub': '-12 vs yesterday',
4723
- 'cai.close': 'Period close',
4724
- 'cai.closeSub': '8 of 12 done',
4725
- 'cai.books': 'Books',
4836
+ 'finance.vouchers': 'Vouchers',
4837
+ 'finance.vouchersSub': '+6.2% MoM',
4838
+ 'finance.diff': 'Debit-credit diff',
4839
+ 'finance.diffSub': '0 lines off',
4840
+ 'finance.pending': 'Pending documents',
4841
+ 'finance.pendingSub': '-12 vs yesterday',
4842
+ 'finance.close': 'Period close',
4843
+ 'finance.closeSub': '8 of 12 done',
4844
+ 'finance.books': 'Books',
4726
4845
  'books.period': 'Period & close',
4727
4846
  'books.periodSub': '2026-11 · open',
4728
4847
  'books.vouchers': 'Vouchers & lines',
@@ -4731,7 +4850,7 @@ window.__ModuleLoader__.load({
4731
4850
  'books.ledgerSub': 'Detail / general',
4732
4851
  'books.snapshot': 'Close mirror',
4733
4852
  'books.snapshotSub': 'ERP close status',
4734
- 'cai.reports': 'Reports',
4853
+ 'finance.reports': 'Reports',
4735
4854
  'reports.statutory': 'Statutory reports',
4736
4855
  'reports.statutorySub': 'BS / IS / CF',
4737
4856
  'reports.consol': 'Consolidation (placeholder)',
@@ -4740,29 +4859,29 @@ window.__ModuleLoader__.load({
4740
4859
  'reports.taxSub': 'Input / output VAT',
4741
4860
  'reports.workpaper': 'Workpapers & receipts',
4742
4861
  'reports.workpaperSub': 'Lineage / credentials',
4743
- 'cai.closeMirror': 'Close mirror',
4862
+ 'finance.closeMirror': 'Close mirror',
4744
4863
  'close.step1': 'Close steps',
4745
4864
  'close.step1Sub': 'ERP in progress',
4746
4865
  'close.step2': 'Lock',
4747
4866
  'close.step2Sub': 'Not locked',
4748
4867
  'close.step3': 'Rerun',
4749
4868
  'close.step3Sub': 'Not initiated here',
4750
- 'cai.check': 'Check exceptions',
4869
+ 'finance.check': 'Check exceptions',
4751
4870
  'check.recon': 'Reconciliation diff',
4752
4871
  'check.reconSub': 'This period',
4753
4872
  'check.cross': 'Cross-table diff',
4754
4873
  'check.crossSub': 'Open',
4755
4874
  'check.miss': 'Missing-posting assertion',
4756
4875
  'check.missSub': 'This period',
4757
- 'cai.datasets': 'Datasets',
4876
+ 'finance.datasets': 'Datasets',
4758
4877
  'ds.voucher': 'Vouchers & lines',
4759
4878
  'ds.voucherSub': 'Read-only',
4760
4879
  'ds.ledger': 'Ledgers',
4761
4880
  'ds.ledgerSub': 'Read-only',
4762
4881
  'ds.docs': 'Documents (expense / invoice / contract)',
4763
4882
  'ds.docsSub': 'Read-only',
4764
- 'cai.reportsDone': 'Reports done',
4765
- 'cai.reportsDoneSub': '3 of 4 issued',
4883
+ 'finance.reportsDone': 'Reports done',
4884
+ 'finance.reportsDoneSub': '3 of 4 issued',
4766
4885
  'progress.done': 'completed',
4767
4886
  'bars.debit': 'Debit',
4768
4887
  'bars.credit': 'Credit',
@@ -4781,17 +4900,17 @@ window.__ModuleLoader__.load({
4781
4900
  'focus.time.past': 'Past · booked',
4782
4901
  'focus.time.now': 'Now · in flight',
4783
4902
  'focus.time.future': 'Future · assumed',
4784
- 'focus.cai.sub': 'Vouchers & close progress',
4785
- 'focus.zi.sub': 'Balance & open items',
4786
- 'focus.tui.sub': 'Budget usage & forecast',
4787
- 'focus.shui.sub': 'VAT & filing progress',
4788
- 'zi.unreconciled': 'Open items',
4789
- 'zi.plan': '90-day plan',
4790
- 'tui.forecast': 'Forecast revenue',
4791
- 'tui.variance': 'Variance vs prior',
4792
- 'shui.output': 'Output VAT',
4793
- 'shui.input': 'Input VAT',
4794
- 'shui.filing': 'Filing progress',
4903
+ 'focus.finance.sub': 'Vouchers & close progress',
4904
+ 'focus.funds.sub': 'Balance & open items',
4905
+ 'focus.forecast.sub': 'Budget usage & forecast',
4906
+ 'focus.tax.sub': 'VAT & filing progress',
4907
+ 'funds.unreconciled': 'Open items',
4908
+ 'funds.plan': '90-day plan',
4909
+ 'forecast.revenue': 'Forecast revenue',
4910
+ 'forecast.variance': 'Variance vs prior',
4911
+ 'tax.output': 'Output VAT',
4912
+ 'tax.input': 'Input VAT',
4913
+ 'tax.filing': 'Filing progress',
4795
4914
  'blocks.paused': 'Paused',
4796
4915
  'blocks.open': 'Open block',
4797
4916
  'blocks.unit.vouchers': 'vouchers',
@@ -5122,6 +5241,10 @@ window.__ModuleLoader__.load({
5122
5241
  chatBots.forEach((id) => { chatStates[id] = emptyState() })
5123
5242
  const chatSubs = new Set()
5124
5243
  let chatCtx = null
5244
+ /** The `remote.agentTeams` client namespace, injected by mount.js so the
5245
+ * team page can enable Team mode without resolving the `remote` service
5246
+ * (which erpbot does not inject). */
5247
+ let teamRemote = null
5125
5248
 
5126
5249
  /** Resolve one client service lazily (sessions / workspaces / uiWorkspace). */
5127
5250
  function assistantService(name) {
@@ -5133,6 +5256,11 @@ window.__ModuleLoader__.load({
5133
5256
  }
5134
5257
  }
5135
5258
 
5259
+ /** Wire the `remote.agentTeams` namespace for enabling Team mode. */
5260
+ function setTeamRemote(agentTeams) {
5261
+ teamRemote = agentTeams || null
5262
+ }
5263
+
5136
5264
  /** Notify every React subscriber with the fresh map. */
5137
5265
  function notifyChat() {
5138
5266
  chatSubs.forEach((fn) => {
@@ -5172,6 +5300,29 @@ window.__ModuleLoader__.load({
5172
5300
  return snap || emptyState()
5173
5301
  }
5174
5302
 
5303
+ /**
5304
+ * Aggregate activity across every bot, as one boolean. The rail's brand
5305
+ * avatar shows a single status dot, so it needs "is anything running"
5306
+ * rather than four separate tones; calling useChatState per bot inside a
5307
+ * loop would break the rules of hooks.
5308
+ */
5309
+ function readChatBusy() {
5310
+ return chatBots.some((id) => {
5311
+ const state = chatStates[id]
5312
+ return !!(state && (state.pending || state.running))
5313
+ })
5314
+ }
5315
+
5316
+ /** React binding for the aggregate activity flag. */
5317
+ function useChatBusy() {
5318
+ const [busy, setBusy] = React.useState(() => readChatBusy())
5319
+ React.useEffect(() => {
5320
+ setBusy(readChatBusy())
5321
+ return subscribeChat(() => setBusy(readChatBusy()))
5322
+ }, [])
5323
+ return busy
5324
+ }
5325
+
5175
5326
  /** Status tone used by the header dot. */
5176
5327
  function chatTone(state) {
5177
5328
  if (!state) return ''
@@ -5182,10 +5333,23 @@ window.__ModuleLoader__.load({
5182
5333
  return ''
5183
5334
  }
5184
5335
 
5185
- /** Read the persisted session id of one bot. */
5336
+ /** Bot ids used before the pinyin → English rename, for storage migration. */
5337
+ const LEGACY_BOT_IDS = { finance: 'cai', funds: 'zi', forecast: 'tui', tax: 'shui' }
5338
+
5339
+ /** Read the persisted session id of one bot, migrating a pre-rename key once. */
5186
5340
  function readSessionId(botId) {
5187
5341
  try {
5188
- return window.localStorage.getItem(SESSION_PREFIX + botId) || null
5342
+ const current = window.localStorage.getItem(SESSION_PREFIX + botId)
5343
+ if (current) return current
5344
+ /* The rename changed the key suffix; carry the legacy id over so an
5345
+ existing conversation is not silently replaced by a fresh one. */
5346
+ const legacy = LEGACY_BOT_IDS[botId]
5347
+ if (!legacy) return null
5348
+ const old = window.localStorage.getItem(SESSION_PREFIX + legacy)
5349
+ if (!old) return null
5350
+ window.localStorage.setItem(SESSION_PREFIX + botId, old)
5351
+ window.localStorage.removeItem(SESSION_PREFIX + legacy)
5352
+ return old
5189
5353
  } catch (error) {
5190
5354
  return null
5191
5355
  }
@@ -5303,6 +5467,55 @@ window.__ModuleLoader__.load({
5303
5467
  return rows.slice(0, LIST_LIMIT)
5304
5468
  }
5305
5469
 
5470
+ /**
5471
+ * The resident Session instance's own blank bit — the earliest truthful
5472
+ * signal that a conversation left its blank state.
5473
+ *
5474
+ * The list snapshot only flips `blank` once the `sessionListMetadata`
5475
+ * projection folds the first `turn/start`, which trails the accepted
5476
+ * submission by a tick. A retained Session flips its own `blankBit`
5477
+ * synchronously inside `acceptPrompt`, so reading the instance closes
5478
+ * that gap: the welcome block disappears the moment the composer accepts
5479
+ * the message instead of when the projection catches up.
5480
+ * @param sessionId - Session to inspect.
5481
+ * @returns the blank bit, or undefined when no resident instance is held.
5482
+ */
5483
+ function residentBlank(sessionId) {
5484
+ if (sessionId === null) return undefined
5485
+ try {
5486
+ const sessions = assistantService('sessions')
5487
+ if (sessions === null || typeof sessions.binding !== 'function') return undefined
5488
+ const binding = sessions.binding(sessionId)
5489
+ if (binding === undefined || binding === null) return undefined
5490
+ const session = binding.session
5491
+ if (session === undefined || session === null || typeof session.getSnapshot !== 'function') return undefined
5492
+ const snapshot = session.getSnapshot()
5493
+ if (snapshot === undefined || snapshot === null) return undefined
5494
+ /* A running/attempted session is never blank even when the projection
5495
+ has not folded its first turn/start yet. */
5496
+ if (snapshot.running === true || snapshot.promptAttempted === true || snapshot.awaitingFirstTurn === true) return false
5497
+ if (typeof snapshot.blank === 'boolean') return snapshot.blank
5498
+ } catch (error) {
5499
+ return undefined
5500
+ }
5501
+ return undefined
5502
+ }
5503
+
5504
+ /**
5505
+ * Blank state for one session: the resident instance first, the list row
5506
+ * second, the caller's cached value last.
5507
+ * @param sessionId - Session to inspect.
5508
+ * @param row - matching session-list row, when the list knows the Session.
5509
+ * @param fallback - value to keep when neither source is available.
5510
+ * @returns the resolved blank bit.
5511
+ */
5512
+ function resolveBlank(sessionId, row, fallback) {
5513
+ const resident = residentBlank(sessionId)
5514
+ if (resident !== undefined) return resident
5515
+ if (row !== undefined) return row.blank === true
5516
+ return fallback
5517
+ }
5518
+
5306
5519
  /** Recompute one bot's session list and current status. */
5307
5520
  function refreshChat(botId) {
5308
5521
  const state = chatStates[botId]
@@ -5313,13 +5526,14 @@ window.__ModuleLoader__.load({
5313
5526
  ? assistantService('sessions').list.getSnapshot()
5314
5527
  : null
5315
5528
  const row = catalog !== null && state.sessionId !== null ? catalog.byId[state.sessionId] : undefined
5529
+ const blank = resolveBlank(state.sessionId, row, state.blank)
5316
5530
  patchChat(botId, {
5317
5531
  sessions: rows,
5318
5532
  running: current ? current.running : false,
5319
5533
  pending: current ? current.pending : false,
5320
5534
  unread: current ? current.unread : false,
5321
- blank: row !== undefined ? row.blank === true : state.blank,
5322
- fresh: state.fresh && row !== undefined && row.blank !== true ? false : state.fresh,
5535
+ blank,
5536
+ fresh: state.fresh && blank === false ? false : state.fresh,
5323
5537
  })
5324
5538
  }
5325
5539
 
@@ -5433,69 +5647,307 @@ window.__ModuleLoader__.load({
5433
5647
  }
5434
5648
  }
5435
5649
 
5436
- /** Open / close one bot's session list. */
5437
- function toggleChatList(botId) {
5438
- const state = chatStates[botId] || emptyState()
5439
- patchChat(botId, { listOpen: !state.listOpen })
5650
+ /** Write a prompt into the team composer's draft (best effort). */
5651
+ function prefillTeamChat(text) {
5652
+ try {
5653
+ if (teamState.sessionId === null || text === '') return
5654
+ const sessions = assistantService('sessions')
5655
+ if (sessions === null) return
5656
+ const binding = sessions.binding(teamState.sessionId)
5657
+ const scoped = sessions.scope(teamState.sessionId)
5658
+ const conversation = scoped === undefined ? undefined : scoped.get('conversation')
5659
+ if (binding === undefined || conversation === undefined) return
5660
+ conversation.input.requestDraftInitialization(binding, { prompt: text, whenDraftExists: 'replace' })
5661
+ } catch (error) {
5662
+ console.warn('[dsh-erpbot] team prefill failed', error)
5663
+ }
5440
5664
  }
5441
5665
 
5442
- /** Scan the workspace store and restore each bot's state (no creation). */
5443
- function primeChats() {
5444
- const workspaces = assistantService('workspaces')
5445
- if (workspaces === null) return
5446
- chatBots.forEach((botId) => {
5447
- const found = findWorkspace(botId)
5448
- if (found === undefined) return
5449
- const state = chatStates[botId] || emptyState()
5450
- if (state.workspaceId !== found.workspaceId) {
5451
- patchChat(botId, { workspaceId: found.workspaceId, workspacePath: found.path || '' })
5452
- }
5453
- refreshChat(botId)
5666
+ /* =====================================================================
5667
+ * TEAM CHAT — the "团队模式" page embeds one root session whose Team mode
5668
+ * is enabled. This is the same embedding trick as a single bot's chat
5669
+ * (conversation.content), except the session is created in the shared team
5670
+ * workspace and then remote.agentTeams.setEnabled turns on the board, so
5671
+ * the official member tree + task board slots render inside this session.
5672
+ * ===================================================================== */
5673
+
5674
+ /** Team chat state, independent of the four single-bot states. */
5675
+ let teamState = emptyState()
5676
+
5677
+ /** Read the team session id, migrating nothing (team had no pinyin id). */
5678
+ function readTeamSessionId() {
5679
+ try {
5680
+ return window.localStorage.getItem('dsh-erpbot.team.session.v1') || null
5681
+ } catch (error) {
5682
+ return null
5683
+ }
5684
+ }
5685
+
5686
+ /** Persist (or clear) the team session id. */
5687
+ function writeTeamSessionId(id) {
5688
+ try {
5689
+ if (id) window.localStorage.setItem('dsh-erpbot.team.session.v1', id)
5690
+ else window.localStorage.removeItem('dsh-erpbot.team.session.v1')
5691
+ } catch (error) {
5692
+ /* storage may be unavailable */
5693
+ }
5694
+ }
5695
+
5696
+ /** Create or reuse the shared team workspace via the host route. */
5697
+ async function ensureTeamWorkspace() {
5698
+ const response = await fetch('/erp/team/workspace', {
5699
+ method: 'POST',
5700
+ headers: { 'content-type': 'application/json' },
5701
+ body: '{}',
5454
5702
  })
5703
+ const data = await response.json().catch(() => null)
5704
+ if (!response.ok || data === null || data.ok !== true) {
5705
+ throw new Error(data !== null && data.error ? data.error.message : 'team workspace unavailable')
5706
+ }
5707
+ return { workspaceId: data.workspaceId, path: data.path || '' }
5455
5708
  }
5456
5709
 
5457
- /** Last-resort path when the embedded occurrence cannot be rendered. */
5458
- function openOfficialConversation(botId) {
5710
+ /** Ensure the team root session exists, is retained, and has Team mode on. */
5711
+ async function ensureTeamChat() {
5712
+ if (teamState.phase === 'ready' && teamState.reference !== null) return teamState.reference
5713
+ if (teamState.phase === 'preparing') return null
5714
+ if (assistantCtxMissing()) {
5715
+ teamState = Object.assign({}, teamState, { phase: 'unsupported', error: 'chat services unavailable' })
5716
+ notifyTeam()
5717
+ return null
5718
+ }
5719
+ teamState = Object.assign({}, teamState, { phase: 'preparing', error: null })
5720
+ notifyTeam()
5459
5721
  try {
5460
- const navigation = assistantService('uiWorkspace')
5461
- const state = chatStates[botId] || emptyState()
5462
- if (navigation !== null) {
5463
- if (state.sessionId !== null) navigation.openSession(state.sessionId)
5464
- else navigation.startSession(undefined, { prompt: t('ai.prompt.insights') })
5722
+ const workspace = await ensureTeamWorkspace()
5723
+ const sessions = assistantService('sessions')
5724
+ const stored = readTeamSessionId()
5725
+ let id = null
5726
+ if (stored !== null) {
5727
+ const catalog = sessions.list.getSnapshot()
5728
+ const row = catalog.byId[stored]
5729
+ if (row !== undefined && row.origin === undefined) id = stored
5730
+ }
5731
+ if (id === null) {
5732
+ id = await sessions.create({ workspaceId: workspace.workspaceId })
5733
+ writeTeamSessionId(id)
5734
+ }
5735
+ /* Retain FIRST: activating the session makes it a live root Agent,
5736
+ which `remote.agentTeams.setEnabled` requires (its host half calls
5737
+ `subagents.rootOf(agent)`, which rejects a not-yet-live session). */
5738
+ const reference = sessions.retain(id, { source: 'erpbot-team' })
5739
+ /* Now enable Team mode on this live root session so the official
5740
+ member tree + task board slots render. */
5741
+ try {
5742
+ if (teamRemote !== null && typeof teamRemote.setEnabled === 'function') {
5743
+ const response = await teamRemote.setEnabled(id, true)
5744
+ if (response && response.ok === false) {
5745
+ console.warn('[dsh-erpbot] team mode enable rejected:', response.error)
5746
+ }
5747
+ } else {
5748
+ console.warn('[dsh-erpbot] team remote unavailable, team mode not enabled')
5749
+ }
5750
+ } catch (error) {
5751
+ console.warn('[dsh-erpbot] team mode enable failed', error)
5465
5752
  }
5753
+ teamState = Object.assign({}, teamState, {
5754
+ phase: 'ready',
5755
+ workspaceId: workspace.workspaceId,
5756
+ workspacePath: workspace.path,
5757
+ sessionId: id,
5758
+ reference,
5759
+ error: null,
5760
+ })
5761
+ notifyTeam()
5762
+ return reference
5466
5763
  } catch (error) {
5467
- console.warn('[dsh-erpbot] official conversation fallback failed', error)
5764
+ teamState = Object.assign({}, teamState, { phase: 'error', error: String((error && error.message) || error) })
5765
+ notifyTeam()
5766
+ return null
5468
5767
  }
5469
5768
  }
5470
5769
 
5471
- /** Fixed Chat view for the embedded occurrence. */
5472
- function BotFixedChat(props) {
5473
- return props.renderSlot('conversation.session', { view: 'chat' })
5770
+ /** Notify every React subscriber of the team state change. */
5771
+ function notifyTeam() {
5772
+ notifyChat()
5474
5773
  }
5475
- const BOT_FACTORY_OPTIONS = { slots: { views: BotFixedChat } }
5476
5774
 
5477
- /**
5478
- * The official conversation for one bot (or its degraded state).
5479
- * @param props - `{ botId, kit }`.
5480
- * @returns the embedded conversation element.
5481
- */
5482
- function BotChatSurface(props) {
5483
- const botId = props.botId
5484
- const kit = props.kit || {}
5485
- const state = useChatState(botId)
5775
+ /** React binding for the team chat state. */
5776
+ function useTeamChat() {
5777
+ const [snap, setSnap] = React.useState(teamState)
5778
+ React.useEffect(() => {
5779
+ setSnap(teamState)
5780
+ const off = subscribeChat(() => setSnap(teamState))
5781
+ return () => off()
5782
+ }, [])
5783
+ return snap || emptyState()
5784
+ }
5486
5785
 
5487
- if (state.phase === 'idle' || state.phase === 'preparing') {
5488
- return h('div', { className: 'assist-note bot-note' }, t('ai.preparing'))
5489
- }
5490
- if (state.phase === 'error') {
5491
- return h('div', { className: 'assist-note bot-note' },
5492
- h('span', null, t('ai.error') + ' - ' + String(state.error || '')),
5493
- h('button', {
5494
- className: 'assist-link',
5495
- type: 'button',
5496
- onClick: () => {
5497
- patchChat(botId, { phase: 'idle', error: null })
5498
- ensureChat(botId)
5786
+ /** Rows of the team workspace's sessions (current blank included). */
5787
+ function teamSessionRows() {
5788
+ const workspaces = assistantService('workspaces')
5789
+ const sessions = assistantService('sessions')
5790
+ if (workspaces === null || sessions === null || teamState.workspaceId === null) return []
5791
+ const catalog = sessions.list.getSnapshot()
5792
+ const workspace = (workspaces.list.getSnapshot().items || [])
5793
+ .find((item) => item.workspaceId === teamState.workspaceId)
5794
+ if (workspace === undefined) return []
5795
+ const uiSession = assistantService('uiSession')
5796
+ const statuses = uiSession !== null ? uiSession.sessionStatus.getSnapshot() : null
5797
+ const rows = []
5798
+ for (const id of workspace.sessionIds) {
5799
+ const row = catalog.byId[id]
5800
+ if (row === undefined) continue
5801
+ if (row.blank === true && id !== teamState.sessionId) continue
5802
+ const status = statuses !== null && statuses !== undefined ? statuses.get(id) : undefined
5803
+ rows.push({
5804
+ id,
5805
+ title: row.title || row.displayTitle || t('ai.blankChat'),
5806
+ at: row.updatedAt || 0,
5807
+ blank: row.blank === true,
5808
+ running: status !== undefined ? status.running === true : row.running === true,
5809
+ pending: status !== undefined && status.pendingInteraction !== undefined && status.pendingInteraction !== null,
5810
+ unread: status !== undefined && status.completionUnread === true,
5811
+ })
5812
+ }
5813
+ rows.sort((a, b) => b.at - a.at)
5814
+ return rows.slice(0, LIST_LIMIT)
5815
+ }
5816
+
5817
+ /** Refresh the team session's status, sessions list + current session. */
5818
+ function refreshTeamChat() {
5819
+ if (assistantCtxMissing() || teamState.workspaceId === null) return
5820
+ const sessions = assistantService('sessions')
5821
+ const rows = teamSessionRows()
5822
+ const current = rows.find((row) => row.id === teamState.sessionId)
5823
+ const catalog = sessions !== null ? sessions.list.getSnapshot() : null
5824
+ const row = catalog !== null && teamState.sessionId !== null ? catalog.byId[teamState.sessionId] : undefined
5825
+ const blank = resolveBlank(teamState.sessionId, row, teamState.blank)
5826
+ teamState = Object.assign({}, teamState, {
5827
+ sessions: rows,
5828
+ running: current ? current.running : false,
5829
+ pending: current ? current.pending : false,
5830
+ unread: current ? current.unread : false,
5831
+ blank,
5832
+ fresh: teamState.fresh && blank === false ? false : teamState.fresh,
5833
+ })
5834
+ notifyTeam()
5835
+ }
5836
+
5837
+ /** Switch the team page to another session of its workspace. */
5838
+ function switchTeamSession(id) {
5839
+ const sessions = assistantService('sessions')
5840
+ if (sessions === null) return
5841
+ try {
5842
+ const reference = sessions.retain(id, { source: 'erpbot-team' })
5843
+ const previous = teamState.reference
5844
+ teamState = Object.assign({}, teamState, { sessionId: id, reference, error: null, fresh: false })
5845
+ writeTeamSessionId(id)
5846
+ if (previous !== null && previous !== reference) {
5847
+ setTimeout(() => { try { previous.release() } catch (error) { /* released */ } }, 0)
5848
+ }
5849
+ refreshTeamChat()
5850
+ } catch (error) {
5851
+ teamState = Object.assign({}, teamState, { error: String((error && error.message) || error) })
5852
+ notifyTeam()
5853
+ }
5854
+ }
5855
+
5856
+ /** Create a fresh team root session, enable Team mode, switch to it. */
5857
+ async function newTeamSession() {
5858
+ if (teamState.phase !== 'ready') return
5859
+ const sessions = assistantService('sessions')
5860
+ if (sessions === null) return
5861
+ try {
5862
+ const id = await sessions.create({ workspaceId: teamState.workspaceId })
5863
+ /* Retain first so the session is a live root, then enable Team mode. */
5864
+ const previous = teamState.reference
5865
+ const reference = sessions.retain(id, { source: 'erpbot-team' })
5866
+ try {
5867
+ if (teamRemote !== null && typeof teamRemote.setEnabled === 'function') {
5868
+ const response = await teamRemote.setEnabled(id, true)
5869
+ if (response && response.ok === false) {
5870
+ console.warn('[dsh-erpbot] team mode enable rejected:', response.error)
5871
+ }
5872
+ }
5873
+ } catch (error) {
5874
+ console.warn('[dsh-erpbot] team mode enable failed', error)
5875
+ }
5876
+ teamState = Object.assign({}, teamState, { sessionId: id, reference, error: null, blank: true, fresh: true })
5877
+ writeTeamSessionId(id)
5878
+ if (previous !== null && previous !== reference) {
5879
+ setTimeout(() => { try { previous.release() } catch (error) { /* released */ } }, 0)
5880
+ }
5881
+ refreshTeamChat()
5882
+ } catch (error) {
5883
+ teamState = Object.assign({}, teamState, { error: String((error && error.message) || error) })
5884
+ notifyTeam()
5885
+ }
5886
+ }
5887
+
5888
+ /** Open / close one bot's session list. */
5889
+ function toggleChatList(botId) {
5890
+ const state = chatStates[botId] || emptyState()
5891
+ patchChat(botId, { listOpen: !state.listOpen })
5892
+ }
5893
+
5894
+ /** Scan the workspace store and restore each bot's state (no creation). */
5895
+ function primeChats() {
5896
+ const workspaces = assistantService('workspaces')
5897
+ if (workspaces === null) return
5898
+ chatBots.forEach((botId) => {
5899
+ const found = findWorkspace(botId)
5900
+ if (found === undefined) return
5901
+ const state = chatStates[botId] || emptyState()
5902
+ if (state.workspaceId !== found.workspaceId) {
5903
+ patchChat(botId, { workspaceId: found.workspaceId, workspacePath: found.path || '' })
5904
+ }
5905
+ refreshChat(botId)
5906
+ })
5907
+ }
5908
+
5909
+ /** Last-resort path when the embedded occurrence cannot be rendered. */
5910
+ function openOfficialConversation(botId) {
5911
+ try {
5912
+ const navigation = assistantService('uiWorkspace')
5913
+ const state = chatStates[botId] || emptyState()
5914
+ if (navigation !== null) {
5915
+ if (state.sessionId !== null) navigation.openSession(state.sessionId)
5916
+ else navigation.startSession(undefined, { prompt: t('ai.prompt.insights') })
5917
+ }
5918
+ } catch (error) {
5919
+ console.warn('[dsh-erpbot] official conversation fallback failed', error)
5920
+ }
5921
+ }
5922
+
5923
+ /** Fixed Chat view for the embedded occurrence. */
5924
+ function BotFixedChat(props) {
5925
+ return props.renderSlot('conversation.session', { view: 'chat' })
5926
+ }
5927
+ const BOT_FACTORY_OPTIONS = { slots: { views: BotFixedChat } }
5928
+
5929
+ /**
5930
+ * The official conversation for one bot (or its degraded state).
5931
+ * @param props - `{ botId, kit }`.
5932
+ * @returns the embedded conversation element.
5933
+ */
5934
+ function BotChatSurface(props) {
5935
+ const botId = props.botId
5936
+ const kit = props.kit || {}
5937
+ const state = useChatState(botId)
5938
+
5939
+ if (state.phase === 'idle' || state.phase === 'preparing') {
5940
+ return h('div', { className: 'assist-note bot-note' }, t('ai.preparing'))
5941
+ }
5942
+ if (state.phase === 'error') {
5943
+ return h('div', { className: 'assist-note bot-note' },
5944
+ h('span', null, t('ai.error') + ' - ' + String(state.error || '')),
5945
+ h('button', {
5946
+ className: 'assist-link',
5947
+ type: 'button',
5948
+ onClick: () => {
5949
+ patchChat(botId, { phase: 'idle', error: null })
5950
+ ensureChat(botId)
5499
5951
  },
5500
5952
  }, t('ai.retry')))
5501
5953
  }
@@ -5524,18 +5976,70 @@ window.__ModuleLoader__.load({
5524
5976
  chatCtx = ctx
5525
5977
  }
5526
5978
 
5979
+ /**
5980
+ * The team root session's embedded conversation. Identical to
5981
+ * BotChatSurface but bound to the team state (a shared workspace session
5982
+ * with Team mode on), so the official member tree + task board render.
5983
+ * @param props - `{ kit }`.
5984
+ * @returns the embedded conversation element.
5985
+ */
5986
+ function BotTeamSurface(props) {
5987
+ const kit = props.kit || {}
5988
+ const state = useTeamChat()
5989
+
5990
+ if (state.phase === 'idle' || state.phase === 'preparing') {
5991
+ return h('div', { className: 'assist-note bot-note' }, t('ai.preparing'))
5992
+ }
5993
+ if (state.phase === 'error') {
5994
+ return h('div', { className: 'assist-note bot-note' },
5995
+ h('span', null, t('ai.error') + ' - ' + String(state.error || '')),
5996
+ h('button', {
5997
+ className: 'assist-link',
5998
+ type: 'button',
5999
+ onClick: () => {
6000
+ teamState = Object.assign({}, teamState, { phase: 'idle', error: null })
6001
+ notifyTeam()
6002
+ ensureTeamChat()
6003
+ },
6004
+ }, t('ai.retry')))
6005
+ }
6006
+ if (state.phase === 'unsupported' || state.reference === null
6007
+ || typeof kit.SessionProvider !== 'function' || typeof kit.renderFactorySlot !== 'function') {
6008
+ return h('div', { className: 'assist-note bot-note' },
6009
+ h('span', null, t('ai.fallback')))
6010
+ }
6011
+
6012
+ return h('div', { className: 'bot-conversation team-conversation', 'data-bot': 'team' },
6013
+ h(kit.SessionProvider, { session: state.reference },
6014
+ kit.renderFactorySlot('conversation.content', {
6015
+ variant: 'embedded',
6016
+ phase: 'active',
6017
+ hero: false,
6018
+ }, BOT_FACTORY_OPTIONS)))
6019
+ }
6020
+
5527
6021
  return {
5528
6022
  chatTone,
5529
6023
  useChatState,
6024
+ useChatBusy,
6025
+ readChatBusy,
5530
6026
  ensureChat,
6027
+ ensureTeamChat,
6028
+ useTeamChat,
5531
6029
  refreshChat,
6030
+ refreshTeamChat,
5532
6031
  switchChatSession,
5533
6032
  newChatSession,
6033
+ newTeamSession,
6034
+ switchTeamSession,
5534
6035
  prefillChat,
6036
+ prefillTeamChat,
5535
6037
  toggleChatList,
5536
6038
  primeChats,
5537
6039
  setAssistantCtx,
6040
+ setTeamRemote,
5538
6041
  BotChatSurface,
6042
+ BotTeamSurface,
5539
6043
  assistantTime,
5540
6044
  chatSessions: (botId) => (chatStates[botId] || emptyState()).sessions,
5541
6045
  chatState: (botId) => chatStates[botId] || emptyState(),
@@ -5548,11 +6052,16 @@ window.__ModuleLoader__.load({
5548
6052
  * ===================================================================== */
5549
6053
 
5550
6054
  function createCards(deps) {
5551
- const { React, h, t, metricLabel, metricSub, PAGES, Icon, Spark, drawTrend } = deps
6055
+ const { React, h, t, compactMoney, readLang, metricLabel, metricSub, PAGES, Icon, Spark, drawTrend } = deps
5552
6056
 
5553
6057
  /* Legacy deep links: the data and platform pages were merged into one
5554
- base page, so `#/erp/shu` and `#/erp/guan` now land on it. */
5555
- const HASH_ALIAS = { shu: 'base', guan: 'base' }
6058
+ base page, so `#/erp/shu` and `#/erp/guan` now land on it. The four bot
6059
+ pages were renamed from pinyin ids to English ones, so the old hashes
6060
+ still resolve instead of falling back to the overview. */
6061
+ const HASH_ALIAS = {
6062
+ shu: 'base', guan: 'base',
6063
+ cai: 'finance', zi: 'funds', tui: 'forecast', shui: 'tax',
6064
+ }
5556
6065
 
5557
6066
  /** Read the page id out of `#/erp/<id>`; unknown ids fall back to overview. */
5558
6067
  function readHashPage() {
@@ -5848,6 +6357,12 @@ window.__ModuleLoader__.load({
5848
6357
  /**
5849
6358
  * Tiny chart drawn on the block card: bars for 账, a line for 资, a ring for
5850
6359
  * 推. White with opacities so it reads on every card tint.
6360
+ *
6361
+ * The centre figure is formatted for the ring, not printed in full. The
6362
+ * ring's hole is only about half its width, so "¥12,000,000.00" at full
6363
+ * length was wider than the hole it sits in and spilled across both arcs.
6364
+ * compactMoney scales it to "¥1,200万"; the exact figure still appears in
6365
+ * the card's own cells row and in the detail modal, so nothing is lost.
5851
6366
  */
5852
6367
  function MiniChart(props) {
5853
6368
  const chart = props.chart || {}
@@ -5873,6 +6388,9 @@ window.__ModuleLoader__.load({
5873
6388
  h('i', { style: { background: 'rgba(255,255,255,' + slice.tone + ')' } }),
5874
6389
  h('span', null, slice.key ? t(slice.key) : ''),
5875
6390
  h('b', null, slice.v + '%')))
6391
+ const centre = chart.total
6392
+ ? (compactMoney ? compactMoney(chart.total, readLang ? readLang() : 'zh') : chart.total)
6393
+ : ''
5876
6394
  return h('div', { className: 'visa-chart donut' },
5877
6395
  h('div', { className: 'mini-donut' },
5878
6396
  h('svg', { viewBox: '0 0 44 44', 'aria-hidden': 'true' },
@@ -5883,7 +6401,7 @@ window.__ModuleLoader__.load({
5883
6401
  }),
5884
6402
  arcs)),
5885
6403
  h('div', { className: 'mini-donut-center' },
5886
- h('b', null, chart.total || ''),
6404
+ h('b', null, centre),
5887
6405
  chart.totalLabelKey ? h('span', null, t(chart.totalLabelKey)) : null)),
5888
6406
  h('ul', { className: 'mini-legend' }, legend))
5889
6407
  }
@@ -6026,7 +6544,7 @@ window.__ModuleLoader__.load({
6026
6544
  * ===================================================================== */
6027
6545
 
6028
6546
  function createDashboard(deps) {
6029
- const { React, h, t, L, readLang, setLang, LANGS, SHORT_LOCALE, LANG_BADGE, useChatState, chatTone, drawOverview, CARDS, CARD_AUTO_MS, DONUT_ARCS, EXPENSE_LEGEND, TRANSACTIONS, nextUid, NAV, Icon, Spark, TxTile } = deps
6547
+ const { React, h, t, L, readLang, setLang, LANGS, SHORT_LOCALE, LANG_BADGE, useChatState, useChatBusy, chatTone, drawOverview, CARDS, CARD_AUTO_MS, DONUT_ARCS, EXPENSE_LEGEND, TRANSACTIONS, nextUid, NAV, Icon, Spark, TxTile } = deps
6030
6548
 
6031
6549
  /* =====================================================================
6032
6550
  * SECTIONS
@@ -6085,46 +6603,86 @@ window.__ModuleLoader__.load({
6085
6603
 
6086
6604
  const RAIL_LABEL = {
6087
6605
  overview: { zh: '总览', en: 'Overview' },
6088
- cai: { zh: '账房 · 财', en: 'The Ledger · Books' },
6089
- zi: { zh: '司库 · 资', en: 'The Treasurer · Funds' },
6090
- tui: { zh: '军师 · 推', en: 'The Planner · Forecast' },
6091
- shui: { zh: '税官 · 税', en: 'The Tax Officer · Tax' },
6606
+ finance: { zh: '账房 · 财', en: 'The Ledger · Books' },
6607
+ funds: { zh: '司库 · 资', en: 'The Treasurer · Funds' },
6608
+ forecast: { zh: '军师 · 推', en: 'The Planner · Forecast' },
6609
+ tax: { zh: '税官 · 税', en: 'The Tax Officer · Tax' },
6610
+ team: { zh: '团队模式', en: 'Team mode' },
6092
6611
  base: { zh: '底座 · MCP 与数据', en: 'Base · MCP and Data' },
6093
6612
  }
6094
6613
 
6095
6614
  /**
6096
- * Rail-top brand tile: the erpBot B mark, rendered as the first item
6097
- * inside the icon group so the brand reads as the head of the same
6098
- * column as the page icons.
6099
- * @returns the brand tile.
6615
+ * Rail-top brand avatar: the erpBot identity lockup at the head of the
6616
+ * nav column. Three nested discs — a gradient ring, a board-coloured gap
6617
+ * and the gradient face — so the brand separates from the flat single-
6618
+ * layer buttons below it by STRUCTURE rather than by being a second
6619
+ * saturated block that competes with the active page.
6620
+ *
6621
+ * The whole thing is sized off `--erp-rail-btn`, so it stays inside the
6622
+ * rail's existing content width and rescales at every breakpoint without
6623
+ * a single extra media query. Footprint 46px against the 54px content
6624
+ * box: 4px of air on each side.
6625
+ *
6626
+ * The status dot aggregates the four bots into one boolean via
6627
+ * `useChatBusy` (amber while any bot is mid-flight, green else), so the
6628
+ * avatar never needs a per-bot hook loop. Decorative for assistive tech —
6629
+ * the button label already names the tile.
6100
6630
  */
6101
- function RailMark() {
6102
- return h('span', { className: 'rail-mark', 'aria-hidden': 'true' }, 'B')
6631
+ function BrandAvatar(props) {
6632
+ const busy = useChatBusy()
6633
+ const tone = busy ? 'busy' : 'on'
6634
+ const label = t('brand.name')
6635
+ const goHome = L(RAIL_LABEL.overview)
6636
+ return h('div', { className: 'rail-brand-slot' },
6637
+ h('button', {
6638
+ type: 'button',
6639
+ className: 'brand-av',
6640
+ title: label + ' · ' + goHome,
6641
+ 'aria-label': label + ' · ' + goHome,
6642
+ onClick: () => props.onNav('overview'),
6643
+ },
6644
+ h('span', { className: 'brand-av-gap' },
6645
+ h('span', { className: 'brand-av-face', 'aria-hidden': 'true' }, 'B')),
6646
+ h('i', { className: 'brand-dot ' + tone, 'aria-hidden': 'true' })))
6103
6647
  }
6104
6648
 
6105
6649
  /**
6106
- * Left rail: the erpBot mark at the top-left corner, the grouped page
6650
+ * Left rail: the brand avatar at the top-left corner, the grouped page
6107
6651
  * icons, then the bottom lockup — the erpBot wordmark with the 中 / EN
6108
6652
  * switch directly beneath it, so the language lives at the panel's
6109
6653
  * bottom-left corner.
6654
+ *
6655
+ * The avatar sits OUTSIDE `.rail-group` on purpose. Inside it, the brand
6656
+ * was the group's first item: same white card, same rhythm as the page
6657
+ * buttons, which made it read as "the 0th navigation item" rather than
6658
+ * as the product's identity. Pulling it out is what fixes the semantics;
6659
+ * the ring is what makes the two read as different things.
6110
6660
  */
6111
6661
  function Rail(props) {
6112
6662
  const navActive = props.page
6113
- const primary = NAV.map((item) => h('button', {
6114
- key: item.id,
6115
- type: 'button',
6116
- className: 'rail-btn' + (navActive === item.id ? ' active' : ''),
6117
- title: L(RAIL_LABEL[item.id] || { zh: item.id, en: item.id }),
6118
- 'aria-label': L(RAIL_LABEL[item.id] || { zh: item.id, en: item.id }),
6119
- 'aria-current': navActive === item.id ? 'page' : 'false',
6120
- onClick: () => props.onNav(item.id),
6121
- }, h(Icon, { name: item.icon })))
6663
+ /* `base` opens the infrastructure group, so a hairline separates it from
6664
+ the business pages above (总览 + 四个 Bot + 团队). Without it the last
6665
+ two entries read as one flat list and the difference in kind between
6666
+ "a working page" and "the MCP/data foundation" is invisible. */
6667
+ const primary = []
6668
+ NAV.forEach((item) => {
6669
+ if (item.id === 'base') {
6670
+ primary.push(h('span', { className: 'rail-sep', key: 'sep-base', 'aria-hidden': 'true' }))
6671
+ }
6672
+ primary.push(h('button', {
6673
+ key: item.id,
6674
+ type: 'button',
6675
+ className: 'rail-btn' + (navActive === item.id ? ' active' : ''),
6676
+ title: L(RAIL_LABEL[item.id] || { zh: item.id, en: item.id }),
6677
+ 'aria-label': L(RAIL_LABEL[item.id] || { zh: item.id, en: item.id }),
6678
+ 'aria-current': navActive === item.id ? 'page' : 'false',
6679
+ onClick: () => props.onNav(item.id),
6680
+ }, h(Icon, { name: item.icon })))
6681
+ })
6122
6682
 
6123
6683
  return h('aside', { className: 't1-rail' },
6124
- h('div', { className: 'rail-group' },
6125
- h('div', { className: 'rail-logo', title: 'erpBot', 'aria-label': 'erpBot' },
6126
- h(RailMark)),
6127
- primary),
6684
+ h(BrandAvatar, { onNav: props.onNav }),
6685
+ h('div', { className: 'rail-group' }, primary),
6128
6686
  h('div', { className: 'rail-spacer' }),
6129
6687
  h('div', { className: 'rail-foot' },
6130
6688
  h('div', { className: 'rail-brand' },
@@ -6361,7 +6919,7 @@ window.__ModuleLoader__.load({
6361
6919
  * ===================================================================== */
6362
6920
 
6363
6921
  function createWorkbench(deps) {
6364
- const { React, h, t, L, readLang, KpiFigure, compactMoney, metricLabel, metricSub, moneyAmount, Icon, Spark, PAGES, BOTS, useChatState, chatTone, ensureChat, refreshChat, switchChatSession, newChatSession, prefillChat, BotChatSurface } = deps
6922
+ const { React, h, t, L, readLang, KpiFigure, compactMoney, metricLabel, metricSub, moneyAmount, Icon, Spark, PAGES, BOTS, SKILL_DEPS, useChatState, chatTone, ensureChat, ensureTeamChat, useTeamChat, refreshChat, refreshTeamChat, switchChatSession, newChatSession, newTeamSession, switchTeamSession, prefillChat, prefillTeamChat, BotChatSurface, BotTeamSurface } = deps
6365
6923
 
6366
6924
  /** KPI card for the nested-page shell (labels are inline localized). */
6367
6925
  function ShellKpi(props) {
@@ -6406,7 +6964,7 @@ window.__ModuleLoader__.load({
6406
6964
  }
6407
6965
 
6408
6966
  function PaneEmpty(section) {
6409
- return h('div', { className: 'pg-empty' },
6967
+ return h('div', { className: 'wb-empty' },
6410
6968
  h('span', { className: 'orb orb-md', 'aria-hidden': 'true' }),
6411
6969
  h('b', null, L(section.label)),
6412
6970
  h('span', null, L({ zh: '该模块内容待接入', en: 'This module is not wired yet' })))
@@ -6730,6 +7288,288 @@ window.__ModuleLoader__.load({
6730
7288
  ]
6731
7289
  }
6732
7290
 
7291
+ /* =====================================================================
7292
+ * BASE SHELL — 底座 · MCP 与数据(精简两栏):左侧 MCP 清单,右侧接口清单。
7293
+ * 数据来自 Host 桥接的 ctx.mcpManager(/erp/mcp/list + /erp/mcp/probe),
7294
+ * 不含任何业务数据、不含 KPI 行、不含 8 清单项——本页只回答「连了哪些
7295
+ * MCP、每个 MCP 有哪些接口」。
7296
+ * ===================================================================== */
7297
+
7298
+ /** Map a manager status state to a dot tone and a human label. */
7299
+ function mcpStatusTone(st) {
7300
+ if (!st) return 'off'
7301
+ if (st.state === 'connected') return 'ok'
7302
+ if (st.state === 'connecting' || st.state === 'reconnecting') return 'warn'
7303
+ return 'off'
7304
+ }
7305
+ function mcpStatusText(st) {
7306
+ if (!st) return ''
7307
+ switch (st.state) {
7308
+ case 'connected': return L({ zh: '已连接', en: 'Connected' })
7309
+ case 'connecting': return L({ zh: '连接中', en: 'Connecting' })
7310
+ case 'reconnecting': return L({ zh: '重连中', en: 'Reconnecting' })
7311
+ case 'waiting-credentials': return L({ zh: '待凭据', en: 'Waiting credentials' })
7312
+ case 'disabled': return L({ zh: '已停用', en: 'Disabled' })
7313
+ case 'failed': return L({ zh: '失败', en: 'Failed' })
7314
+ case 'closed': return L({ zh: '已关闭', en: 'Closed' })
7315
+ default: return ''
7316
+ }
7317
+ }
7318
+
7319
+ /** Base page: live MCP fleet list (left) and per-server tool inventory (right). */
7320
+ function BaseShell(props) {
7321
+ const cfg = props.cfg || {}
7322
+ const [servers, setServers] = React.useState([])
7323
+ const [activeName, setActiveName] = React.useState('')
7324
+ const [listError, setListError] = React.useState('')
7325
+ const [probe, setProbe] = React.useState(null)
7326
+
7327
+ /* Load the live MCP fleet once. */
7328
+ React.useEffect(() => {
7329
+ let alive = true
7330
+ hostJson('/erp/mcp/list').then((data) => {
7331
+ if (!alive) return
7332
+ if (data !== null && data.ok === true && Array.isArray(data.servers)) {
7333
+ setServers(data.servers)
7334
+ setListError('')
7335
+ if (data.servers.length === 0) {
7336
+ setActiveName('')
7337
+ } else if (!data.servers.some((s) => s.name === activeName)) {
7338
+ setActiveName(data.servers[0].name)
7339
+ }
7340
+ } else {
7341
+ setListError(L({ zh: '读不到 MCP 清单', en: 'MCP server list unavailable' }))
7342
+ }
7343
+ })
7344
+ return () => { alive = false }
7345
+ }, [])
7346
+
7347
+ /* Probe the selected server for its tool list. */
7348
+ React.useEffect(() => {
7349
+ if (activeName === '') { setProbe(null); return }
7350
+ let alive = true
7351
+ setProbe({ loading: true })
7352
+ hostJson('/erp/mcp/probe?name=' + encodeURIComponent(activeName)).then((data) => {
7353
+ if (!alive) return
7354
+ if (data !== null && data.ok === true) {
7355
+ setProbe({ loading: false, result: data.probe })
7356
+ } else {
7357
+ setProbe({ loading: false, error: data !== null && data.error ? data.error.message : 'probe failed' })
7358
+ }
7359
+ })
7360
+ return () => { alive = false }
7361
+ }, [activeName])
7362
+
7363
+ const active = servers.find((s) => s.name === activeName) || null
7364
+ const activeStatus = active ? active.status : null
7365
+ const toolCount = activeStatus && activeStatus.state === 'connected' ? activeStatus.toolCount : 0
7366
+
7367
+ const listItems = servers.length
7368
+ ? servers.map((s) => {
7369
+ const tone = mcpStatusTone(s.status)
7370
+ const tc = s.status && s.status.state === 'connected' ? s.status.toolCount : null
7371
+ return h('button', {
7372
+ key: s.name,
7373
+ type: 'button',
7374
+ className: 'wb-item' + (s.name === activeName ? ' on' : ''),
7375
+ onClick: () => setActiveName(s.name),
7376
+ },
7377
+ h('i', { className: 'mcp-dot ' + tone, 'aria-hidden': 'true' }),
7378
+ h('span', null, s.name),
7379
+ tc !== null ? h('span', { className: 'wb-pill-sm' }, String(tc)) : null)
7380
+ })
7381
+ : null
7382
+
7383
+ /** Right pane: the selected server's tool inventory. */
7384
+ function toolsPane() {
7385
+ if (listError) {
7386
+ return h('div', { className: 'wb-empty' },
7387
+ h('span', { className: 'orb orb-md', 'aria-hidden': 'true' }),
7388
+ h('b', null, listError))
7389
+ }
7390
+ if (servers.length === 0) {
7391
+ return h('div', { className: 'wb-empty' },
7392
+ h('span', { className: 'orb orb-md', 'aria-hidden': 'true' }),
7393
+ h('b', null, L({ zh: '尚未配置 MCP', en: 'No MCP configured' })),
7394
+ h('span', null, L({ zh: '在 DSH 设置里添加 MCP 服务器后,这里会列出通道与接口。', en: 'Add an MCP server in DSH settings; channels and tools appear here.' })))
7395
+ }
7396
+ if (!active) return null
7397
+ if (probe && probe.loading) {
7398
+ return h('div', { className: 'wb-empty' },
7399
+ h('span', { className: 'orb orb-md', 'aria-hidden': 'true' }),
7400
+ h('b', null, L({ zh: '读取接口清单…', en: 'Reading tools…' })))
7401
+ }
7402
+ if (probe && probe.error) {
7403
+ return h('div', { className: 'wb-empty' },
7404
+ h('span', { className: 'orb orb-md', 'aria-hidden': 'true' }),
7405
+ h('b', null, L({ zh: '接口清单读取失败', en: 'Tool list failed' })),
7406
+ h('span', null, String(probe.error)))
7407
+ }
7408
+ const result = probe && probe.result ? probe.result : null
7409
+ const tools = result && result.ok === true ? result.toolNames : []
7410
+ if (tools.length === 0) {
7411
+ return h('div', { className: 'wb-empty' },
7412
+ h('span', { className: 'orb orb-md', 'aria-hidden': 'true' }),
7413
+ h('b', null, L({ zh: '无接口', en: 'No tools' })),
7414
+ h('span', null, L({ zh: '该 MCP 未声明任何工具。', en: 'This MCP server declares no tools.' })))
7415
+ }
7416
+ return h('div', { className: 'wb-list' },
7417
+ tools.map((tn) => h('div', { className: 'wb-row', key: tn },
7418
+ h('span', { className: 'wb-row-ico' }, h(Icon, { name: 'spark' })),
7419
+ h('div', { className: 'wb-row-main' },
7420
+ h('div', { className: 'wb-row-top' }, h('b', null, tn))),
7421
+ h('span', { className: 'wb-row-meta' }, 'mcp__' + activeName + '__' + tn))))
7422
+ }
7423
+
7424
+ const header = h('div', { className: 'wb-main-hd' },
7425
+ h('b', null, activeName || L({ zh: 'MCP', en: 'MCP' })),
7426
+ activeStatus && mcpStatusText(activeStatus) ? h('span', { className: 'wb-pill' }, mcpStatusText(activeStatus)) : null,
7427
+ toolCount > 0 ? h('span', { className: 'wb-pill' }, String(toolCount) + ' ' + L({ zh: '个接口', en: 'tools' })) : null,
7428
+ active && active.status && active.status.state === 'connected' && active.status.connectedAt
7429
+ ? h('span', { className: 'wb-pill' }, L({ zh: '连接于', en: 'since' }) + ' ' + new Date(active.status.connectedAt).toLocaleString())
7430
+ : null)
7431
+
7432
+ /* Four KPI cards: the first ("MCP channels") reflects the live fleet;
7433
+ the other three keep their static finance caliber. */
7434
+ function kpiCards() {
7435
+ const base = cfg.kpis || []
7436
+ if (!base.length) return null
7437
+ const total = servers.length
7438
+ const connected = servers.filter((s) => s.status && s.status.state === 'connected').length
7439
+ const first = Object.assign({}, base[0], {
7440
+ value: String(total),
7441
+ cardSub: { zh: total + ' 条 · 已连接 ' + connected, en: total + ' channels · ' + connected + ' connected' },
7442
+ parts: [
7443
+ { label: { zh: '已连接', en: 'Connected' }, value: String(connected) },
7444
+ { label: { zh: '其它', en: 'Other' }, value: String(total - connected) },
7445
+ ],
7446
+ })
7447
+ return [first].concat(base.slice(1)).map((k, i) => h(PageKpi, { key: i, k, onOpen: props.onOpen }))
7448
+ }
7449
+
7450
+ return [
7451
+ h('div', { className: 't1-stats', key: 'kpis' }, kpiCards()),
7452
+ h('div', { className: 'card workbench pg-workbench', key: 'workbench' },
7453
+ h('aside', { className: 'wb-nav' },
7454
+ h('div', { className: 'wb-hd' }, L({ zh: 'MCP 清单', en: 'MCP servers' })),
7455
+ listItems),
7456
+ h('section', { className: 'wb-main pg-pane' },
7457
+ header,
7458
+ h('div', { className: 'pg-body' }, toolsPane()))),
7459
+ ]
7460
+ }
7461
+
7462
+ /* =====================================================================
7463
+ * TEAM SHELL — 团队模式页:一个嵌入了「开了 Team 模式」的根会话的页面。
7464
+ * 会话正文、成员树、任务板全部由 DSH 官方 slot 渲染(同 chart 嫁接原理),
7465
+ * 本组件只负责创建会话 + 开 team + 挂官方 conversation.content。
7466
+ * ===================================================================== */
7467
+ function TeamShell(props) {
7468
+ const team = useTeamChat()
7469
+ const [drawer, setDrawer] = React.useState('')
7470
+
7471
+ React.useEffect(() => {
7472
+ if (team.phase === 'idle' || team.phase === 'error') ensureTeamChat()
7473
+ }, [team.phase])
7474
+
7475
+ React.useEffect(() => {
7476
+ refreshTeamChat()
7477
+ }, [team.sessionId])
7478
+
7479
+ const tone = chatTone(team)
7480
+ const statusText = L(TONE_LABEL[tone] || TONE_LABEL[''])
7481
+ const currentSession = team.sessions.find((row) => row.id === team.sessionId)
7482
+ const sessionTitle = currentSession ? currentSession.title : t('team.session')
7483
+ const blankChat = team.phase === 'ready' && (team.blank || team.fresh)
7484
+
7485
+ /** Ask a guided team question (prefills the composer). */
7486
+ function askTeam(textKey) {
7487
+ prefillTeamChat(t(textKey))
7488
+ }
7489
+
7490
+ /** History pane: sessions of the team workspace. */
7491
+ function teamHistoryPane() {
7492
+ return team.sessions.length
7493
+ ? team.sessions.map((row) => h('button', {
7494
+ key: row.id,
7495
+ type: 'button',
7496
+ className: 'bot-hist-row' + (row.id === team.sessionId ? ' on' : ''),
7497
+ onClick: () => {
7498
+ switchTeamSession(row.id)
7499
+ setDrawer('')
7500
+ },
7501
+ },
7502
+ h('span', { className: 'bot-hist-title' }, row.blank ? t('ai.blankChat') : row.title),
7503
+ h('span', { className: 'bot-hist-meta' },
7504
+ h('i', { className: 'bot-dot ' + (row.running ? 'live' : row.pending ? 'warn' : row.unread ? 'unread' : '') }),
7505
+ h('span', null, row.id === team.sessionId ? L({ zh: '当前', en: 'Current' }) : ''))))
7506
+ : h('div', { className: 'bot-empty' }, t('ai.noHistory'))
7507
+ }
7508
+
7509
+ return h('div', { className: 'card bot-card team-card' + (drawer ? ' has-drawer' : ''), key: 'team' },
7510
+ h('div', { className: 'bot-main team-main' },
7511
+ h('div', { className: 'bot-hd' },
7512
+ h('button', {
7513
+ className: 'bot-avatar team-avatar',
7514
+ type: 'button',
7515
+ title: t('team.title'),
7516
+ 'aria-label': t('team.title'),
7517
+ },
7518
+ h(Icon, { name: 'users' }),
7519
+ h('i', { className: 'bot-avatar-state ' + tone, 'aria-hidden': 'true' })),
7520
+ h('div', { className: 'bot-meta' },
7521
+ h('div', { className: 'bot-name' },
7522
+ h('b', null, t('team.title')),
7523
+ h('span', { className: 'bot-role' }, t('team.role'))),
7524
+ h('div', { className: 'bot-sub' },
7525
+ h('i', { className: 'bot-dot ' + tone }),
7526
+ h('span', null, statusText),
7527
+ h('span', { className: 'bot-sep' }, '·'),
7528
+ h('span', { className: 'bot-session-title' }, sessionTitle))),
7529
+ h('div', { className: 'bot-grow' }),
7530
+ h('div', { className: 'bot-hd-acts' },
7531
+ h('button', {
7532
+ className: 'bot-act',
7533
+ type: 'button',
7534
+ title: t('ai.newChat'),
7535
+ 'aria-label': t('ai.newChat'),
7536
+ onClick: () => newTeamSession(),
7537
+ }, h(Icon, { name: 'plus' })),
7538
+ h('button', {
7539
+ className: 'bot-act' + (drawer === 'history' ? ' on' : ''),
7540
+ type: 'button',
7541
+ title: t('ai.history'),
7542
+ 'aria-label': t('ai.history'),
7543
+ onClick: () => setDrawer(drawer === 'history' ? '' : 'history'),
7544
+ }, h(Icon, { name: 'list' })))),
7545
+ h('div', { className: 'bot-body team-body' + (blankChat ? ' is-blank' : '') },
7546
+ /* The official surface is ALWAYS mounted, exactly as on the Bot
7547
+ page: its composer is the only input, so unmounting it in a
7548
+ blank session left the team page with no place to type. The
7549
+ welcome block is an overlay on the empty message area, not a
7550
+ replacement for the surface. */
7551
+ h(BotTeamSurface, { kit: props.kit }),
7552
+ blankChat
7553
+ ? h('div', { className: 'bot-welcome' },
7554
+ h('span', { className: 'orb orb-md bot-orb' }),
7555
+ h('b', null, t('team.title')),
7556
+ h('span', { className: 'bot-welcome-sub' }, t('team.greeting')),
7557
+ h('div', { className: 'bot-chips' },
7558
+ [t('team.chip.close'), t('team.chip.recon'), t('team.chip.tax')].map((label) =>
7559
+ h('button', {
7560
+ key: label,
7561
+ type: 'button',
7562
+ className: 'bot-chip',
7563
+ onClick: () => askTeam(
7564
+ label === t('team.chip.close') ? 'team.chip.close'
7565
+ : label === t('team.chip.recon') ? 'team.chip.recon' : 'team.chip.tax'),
7566
+ }, label))))
7567
+ : null)),
7568
+ h('aside', { className: 'bot-drawer history' + (drawer === 'history' ? ' on' : '') },
7569
+ drawerHead(L({ zh: '团队会话', en: 'Team conversations' }), t('team.title'), () => setDrawer('')),
7570
+ h('div', { className: 'bot-dr-bd' }, teamHistoryPane())))
7571
+ }
7572
+
6733
7573
  /* =====================================================================
6734
7574
  * BOT SHELL — 对话主导的 Bot 页:KPI 行 + Chat 卡 + 头像配置抽屉 + 画布。
6735
7575
  * 会话由 assistant 板块(DSH 官方会话嵌入)驱动;本板块只做呈现与配置。
@@ -6748,10 +7588,28 @@ window.__ModuleLoader__.load({
6748
7588
  return 'dsh-erpbot.bot.config.v1.' + botId
6749
7589
  }
6750
7590
 
7591
+ /** Bot ids used before the pinyin → English rename, for storage migration. */
7592
+ const LEGACY_BOT_IDS = { finance: 'cai', funds: 'zi', forecast: 'tui', tax: 'shui' }
7593
+
7594
+ /** Carry one legacy localStorage value to its renamed key, once. */
7595
+ function migrateKey(prefix, botId) {
7596
+ const legacy = LEGACY_BOT_IDS[botId]
7597
+ if (!legacy) return null
7598
+ try {
7599
+ const old = window.localStorage.getItem(prefix + legacy)
7600
+ if (!old) return null
7601
+ window.localStorage.setItem(prefix + botId, old)
7602
+ window.localStorage.removeItem(prefix + legacy)
7603
+ return old
7604
+ } catch (error) {
7605
+ return null
7606
+ }
7607
+ }
7608
+
6751
7609
  /** Read the local mirror of one bot's config: added DSH skill names. */
6752
7610
  function readBotConfig(botId) {
6753
7611
  try {
6754
- const raw = window.localStorage.getItem(botConfigKey(botId))
7612
+ const raw = window.localStorage.getItem(botConfigKey(botId)) || migrateKey('dsh-erpbot.bot.config.v1.', botId)
6755
7613
  if (!raw) return { bot: botId, skills: [], updatedAt: null }
6756
7614
  const parsed = JSON.parse(raw)
6757
7615
  return {
@@ -6778,51 +7636,166 @@ window.__ModuleLoader__.load({
6778
7636
  * Build the AGENTS.md written into the bot workspace. It carries the bot
6779
7637
  * identity, its scene prompt and the DSH skills the user added, so every
6780
7638
  * conversation in that workspace starts from the same context.
7639
+ *
7640
+ * Schema v3 (erp.bot.agents.v3) — 9 sections per bot-agents-spec.md.
7641
+ *
6781
7642
  * @param meta - bot identity from bot-data.
6782
7643
  * @param added - DSH skill names added to this bot.
6783
7644
  * @param prefs - user preferences (reply tone, period).
6784
7645
  * @returns markdown text.
6785
7646
  */
7647
+
7648
+ /* Cross-boundary routing table — one bot forwards a topic to the other
7649
+ three. Single source of truth for both the generated AGENTS.md section
7650
+ 6 and the config drawer's "越界转介" block. */
7651
+ const TONE_LABELS = {
7652
+ rigorous: { zh: '严谨 · 审计', en: 'Rigorous · audit' },
7653
+ concise: { zh: '简洁 · 结论先行', en: 'Concise · conclusion first' },
7654
+ warm: { zh: '亲和 · 解释型', en: 'Warm · explanatory' },
7655
+ }
7656
+
7657
+ /**
7658
+ * Shared drawer chrome, used by three drawers (bot config, bot history,
7659
+ * team history) that used to spell out the same header by hand.
7660
+ *
7661
+ * It lives at FACTORY scope, not inside a component: it is called from
7662
+ * both BotShell and TeamShell, and when it was defined inside BotShell
7663
+ * only, TeamShell threw "drawerHead is not defined" at render time — a
7664
+ * blank panel that every static check happily passed. `onClose` is passed
7665
+ * in because each shell owns its own drawer state.
7666
+ */
7667
+ function drawerHead(title, sub, onClose) {
7668
+ return h('div', { className: 'bot-dr-hd' },
7669
+ h('b', null, title),
7670
+ h('span', { className: 'bot-dr-sub' }, sub),
7671
+ h('button', {
7672
+ className: 'bot-x',
7673
+ type: 'button',
7674
+ title: L({ zh: '收起', en: 'Collapse' }),
7675
+ 'aria-label': L({ zh: '收起', en: 'Collapse' }),
7676
+ onClick: onClose,
7677
+ }, h(Icon, { name: 'close' })))
7678
+ }
7679
+
7680
+ const ROUTING = {
7681
+ finance: [
7682
+ { zh: '钱够不够、资金缺口 → 司库(funds)', en: 'Cash position, funding gaps → Treasurer (funds)' },
7683
+ { zh: '预算与预测 → 军师(forecast)', en: 'Budget and forecast → Planner (forecast)' },
7684
+ { zh: '税务与申报 → 税官(tax)', en: 'Tax and filing → Tax Officer (tax)' },
7685
+ ],
7686
+ funds: [
7687
+ { zh: '账务处理与凭证 → 账房(finance)', en: 'Bookkeeping and vouchers → Ledger (finance)' },
7688
+ { zh: '资金计划与预测 → 军师(forecast)', en: 'Cash plan and forecast → Planner (forecast)' },
7689
+ { zh: '涉税事项 → 税官(tax)', en: 'Tax matters → Tax Officer (tax)' },
7690
+ ],
7691
+ forecast: [
7692
+ { zh: '已发生事实与账务 → 账房(finance)', en: 'Actuals and bookkeeping → Ledger (finance)' },
7693
+ { zh: '当前资金与收付 → 司库(funds)', en: 'Current cash and payments → Treasurer (funds)' },
7694
+ { zh: '税务申报 → 税官(tax)', en: 'Tax filing → Tax Officer (tax)' },
7695
+ ],
7696
+ tax: [
7697
+ { zh: '账务处理与凭证 → 账房(finance)', en: 'Bookkeeping and vouchers → Ledger (finance)' },
7698
+ { zh: '资金与收付 → 司库(funds)', en: 'Cash and payments → Treasurer (funds)' },
7699
+ { zh: '预算与预测 → 军师(forecast)', en: 'Budget and forecast → Planner (forecast)' },
7700
+ ],
7701
+ }
7702
+
6786
7703
  function buildAgentsDoc(meta, added, prefs) {
6787
- const prefsBody = [
6788
- '- 回复语气:' + (prefs.replyTone || 'rigorous'),
6789
- '- 默认时间口径:' + (prefs.periodWindow || '(按问题判断)'),
6790
- ].join('\n')
6791
- const rulesBody = [
6792
- '- 只回答本领域问题,越界时指向对应 Bot。',
6793
- '- 数字必须能回答口径、时点与数据集;不引入本文件之外的新数。',
6794
- '- 不直写 ERP;受控写经 MCP 并留回执。',
6795
- ].join('\n')
6796
7704
  const tick = String.fromCharCode(96)
7705
+ const h2 = '##'
7706
+
7707
+ /* 8. User preferences — map raw ids to human-readable labels. */
7708
+ const toneValue = TONE_LABELS[prefs.replyTone] || { zh: prefs.replyTone || '严谨 · 审计', en: prefs.replyTone || 'Rigorous · audit' }
7709
+ const periodValue = prefs.periodWindow || '(按问题判断)'
7710
+
7711
+ /* Cross-boundary routing lives at the factory scope (see ROUTING above),
7712
+ shared by both the generated file and the config drawer. */
7713
+ const botRoute = ROUTING[meta.id] || []
7714
+ const routeLines = botRoute.map(function(r) { return '- ' + r.zh }).join('\n')
7715
+ const routeLinesEn = botRoute.map(function(r) { return '- ' + r.en }).join('\n')
7716
+
7717
+ /* Skill list. */
6797
7718
  const skillLines = added.length
6798
- ? added.map((name) => '- ' + tick + name + tick).join('\n')
7719
+ ? added.map(function(name) { return '- ' + tick + name + tick }).join('\n')
6799
7720
  : '(暂未添加技能。用户可在 Bot 配置 > 技能里从 DSH 技能库添加。)'
7721
+
6800
7722
  return [
6801
- '# erpBot · ' + (meta.name ? meta.name.zh : '') + '(' + (meta.id || '') + ')',
7723
+ '<!-- schema: erp.bot.agents.v3 -->',
7724
+ '',
7725
+ /* Everything between these two markers is rewritten on save. Anything
7726
+ the user adds OUTSIDE them survives — that is the whole point of
7727
+ the managed region (see the parser block above). */
7728
+ '<!-- BEGIN managed:erpbot v3 -->',
7729
+ '',
7730
+ '# erpBot ' + String.fromCharCode(183) + ' ' + (meta.name ? meta.name.zh : '') + '(' + (meta.id || '') + ')',
7731
+ '',
7732
+ '> ' + (meta.role ? meta.role.zh : '') + ' | ' + (meta.role ? meta.role.en : ''),
6802
7733
  '',
6803
- '## 身份',
6804
- '- 角色:' + (meta.role ? meta.role.zh : ''),
6805
- '- 职责:' + (meta.duties ? meta.duties.zh : ''),
6806
- '- 语气:' + (meta.tone ? meta.tone.zh : ''),
6807
- '- 边界:' + (meta.boundaries ? meta.boundaries.zh : ''),
7734
+ h2 + ' 1. 身份(Identity)',
6808
7735
  '',
6809
- '## 场景提示',
7736
+ '- 角色(Role):' + (meta.role ? meta.role.zh : ''),
7737
+ '- 职责(Duties):' + (meta.duties ? meta.duties.zh : ''),
7738
+ '- 语气(Tone):' + (meta.tone ? meta.tone.zh : ''),
7739
+ '- 边界(Boundaries):' + (meta.boundaries ? meta.boundaries.zh : ''),
7740
+ meta.notApplicable && meta.notApplicable.zh
7741
+ ? '- 不适用(Not applicable):' + meta.notApplicable.zh + '(越界请转介,不要硬答)'
7742
+ : '',
7743
+ '',
7744
+ h2 + ' 2. 回答顺序(Scene prompt)',
6810
7745
  '',
6811
7746
  meta.scene ? meta.scene.zh : '',
6812
7747
  '',
6813
- '## 已添加到本 Bot 的 DSH 技能',
7748
+ h2 + ' 3. 数字可信(Digital trust — 硬要求)',
7749
+ '',
7750
+ '- 口径(Definition):每个数字必须能说出计算定义与生效版本。',
7751
+ '- 时点(Timestamp):业务发生时点、数据观测时点、财务确认时点分别说明。版本指认用「口径版本 + 生效时点」,不得用「当前」当版本指认。',
7752
+ '- 数据集(Dataset):说明数字来自哪份只读数据集。页面与 Bot 都不直连 ERP,不在前端重算业务数。',
7753
+ '- 证据(Evidence):结论必须能给出凭据 / 回执编号,或明确写「无依据」。无依据即不下结论。',
7754
+ '',
7755
+ h2 + ' 4. 红线(Red lines)',
7756
+ '',
7757
+ '**不做(结构上无通道)**:不直写 ERP、不代为审批 / 签批 / 申报、不勾选确认、不改口径、不覆盖历史版本。',
7758
+ '**只能走通道**:反写仅经受控写通道并留回执;调账由待办发起;改口径走版本变更流程。',
7759
+ '违反以上任一条时:拒绝执行 + 说明该走哪条通道 + 指出谁能批。',
7760
+ '',
7761
+ h2 + ' 5. 不确定怎么办(Suspension discipline)',
7762
+ '',
7763
+ '判不准 / 版本指认失败 / 口径冲突 / 同一动作重复到达但载荷不同 → 一律挂起转人工裁定:',
7764
+ '- 写明挂起点、缺什么、谁能定。',
7765
+ '- 不猜测、不用默认值填空、不静默重试。',
7766
+ '',
7767
+ h2 + ' 6. 越界转介(Cross-boundary routing)',
7768
+ '',
7769
+ routeLines || '(无转介配置)',
7770
+ '',
7771
+ h2 + ' 7. 已绑定 DSH 技能(Bound skills — 优先检索)',
6814
7772
  '',
6815
7773
  skillLines,
6816
7774
  '',
6817
- '以上技能来自 DSH 标准技能库(~/.dsh/skills),按各技能 SKILL.md 使用;不要自行发明技能名。',
7775
+ '规则:',
7776
+ '1. 回答前,先判断任务是否命中以上技能;命中则加载对应 skill 并按其 SKILL.md 执行。',
7777
+ '2. 未命中时,才回退到本会话全局 skill 目录(<available_skills>)检索。',
7778
+ '3. 不得自行发明技能名;加载技能不改变身份与口径。',
6818
7779
  '',
6819
- '## 用户偏好',
7780
+ h2 + ' 8. 用户偏好(Preferences)',
6820
7781
  '',
6821
- prefsBody,
7782
+ '- 回复语气:' + toneValue.zh,
7783
+ '- 默认时间口径:' + periodValue,
6822
7784
  '',
6823
- '## 约束',
7785
+ h2 + ' 9. 交付前自检(Self-check before delivery)',
6824
7786
  '',
6825
- rulesBody,
7787
+ '- [ ] 每个数字都说了口径与时点?',
7788
+ '- [ ] 每个结论都有依据或明确写了「无依据」?',
7789
+ '- [ ] 有没有越过红线、有没有替人批?',
7790
+ '- [ ] 拿不准的是否已挂起而不是猜?',
7791
+ '- [ ] 是否越界到别的 Bot 的领域?',
7792
+ '',
7793
+ '---',
7794
+ '',
7795
+ '> 本文件由 dsh-erpbot 自动生成(schema: erp.bot.agents.v3)。托管区由面板重写,标记外的内容保留。',
7796
+ '> 中文为主,字段有对应英文。',
7797
+ '',
7798
+ '<!-- END managed:erpbot v3 -->',
6826
7799
  '',
6827
7800
  ].join('\n')
6828
7801
  }
@@ -6853,6 +7826,289 @@ window.__ModuleLoader__.load({
6853
7826
  return { ok: true, config: data.config, agentsPath: data.agentsPath }
6854
7827
  }
6855
7828
 
7829
+ /* =====================================================================
7830
+ * AGENTS.md PARSER — the config drawer reads the FILE, not bot-data.
7831
+ *
7832
+ * Why this exists: every field the drawer used to show came from the
7833
+ * static BOTS constant, so a bot whose AGENTS.md had been edited still
7834
+ * displayed its factory definition. The host already returns the file
7835
+ * text (`data.agents`); the client only used it to decide whether to
7836
+ * migrate old schemas, never to render. That made the panel a spec sheet
7837
+ * instead of a résumé of the bot that actually exists.
7838
+ *
7839
+ * Managed region: content between
7840
+ * <!-- BEGIN managed:erpbot vN --> ... <!-- END managed:erpbot vN -->
7841
+ * is rewritten on save. Everything outside is the user's own text and is
7842
+ * preserved verbatim — that is what makes hand-editing safe.
7843
+ * ===================================================================== */
7844
+
7845
+ const MANAGED_BEGIN = /<!--\s*BEGIN managed:[^>]*-->/i
7846
+ const MANAGED_END = /<!--\s*END managed:[^>]*-->/i
7847
+
7848
+ /** Split a file into pre / begin / managed / end / post. */
7849
+ function splitManagedDoc(text) {
7850
+ const src = typeof text === 'string' ? text : ''
7851
+ const b = MANAGED_BEGIN.exec(src)
7852
+ const e = MANAGED_END.exec(src)
7853
+ if (b === null || e === null || e.index <= b.index) {
7854
+ /* No markers yet (legacy file): treat the whole thing as managed. */
7855
+ return { pre: '', begin: '', managed: src, end: '', post: '', marked: false }
7856
+ }
7857
+ return {
7858
+ pre: src.slice(0, b.index),
7859
+ begin: b[0],
7860
+ managed: src.slice(b.index + b[0].length, e.index),
7861
+ end: e[0],
7862
+ post: src.slice(e.index + e[0].length),
7863
+ marked: true,
7864
+ }
7865
+ }
7866
+
7867
+ /** Parse `## N. Title` sections out of the managed region. */
7868
+ function parseAgentsSections(managed) {
7869
+ const out = []
7870
+ let cur = null
7871
+ for (const line of String(managed).split('\n')) {
7872
+ const m = /^##\s+(\d+)\.\s*(.+?)\s*$/.exec(line)
7873
+ if (m !== null) {
7874
+ cur = { num: Number(m[1]), title: m[2], lines: [] }
7875
+ out.push(cur)
7876
+ continue
7877
+ }
7878
+ if (cur !== null) cur.lines.push(line)
7879
+ }
7880
+ for (const s of out) s.text = s.lines.join('\n').replace(/^\n+|\s+$/g, '')
7881
+ return out
7882
+ }
7883
+
7884
+ /**
7885
+ * Parse one `- 键(Key):值` line out of the identity section.
7886
+ * The section is bilingual (中文 label + Latin key in parens); accept both.
7887
+ */
7888
+ function parseIdentityFields(section) {
7889
+ const map = {}
7890
+ if (!section) return map
7891
+ for (const raw of section.text.split('\n')) {
7892
+ const line = raw.trim()
7893
+ const m = /^-\s*([^((]+?)(?:[((][^))]*[))])?\s*[::]\s*(.*)$/.exec(line)
7894
+ if (m !== null) map[m[1].trim()] = m[2].trim()
7895
+ }
7896
+ return map
7897
+ }
7898
+
7899
+ /** `- \`skill-name\`` lines under section 7. */
7900
+ function parseSkillLines(section) {
7901
+ const out = []
7902
+ if (!section) return out
7903
+ for (const raw of section.text.split('\n')) {
7904
+ const m = /^-\s*`([^`]+)`\s*$/.exec(raw.trim())
7905
+ if (m !== null) out.push(m[1])
7906
+ }
7907
+ return out
7908
+ }
7909
+
7910
+ /** Bullet count in a section (section 6 routing, etc). */
7911
+ function countBullets(section) {
7912
+ if (!section) return 0
7913
+ return section.text.split('\n').filter(function (l) {
7914
+ return /^\s*-\s+\S/.test(l)
7915
+ }).length
7916
+ }
7917
+
7918
+ /** The "不做(…)" list under section 4, split on the enumeration comma. */
7919
+ function parseRedLines(section) {
7920
+ if (!section) return []
7921
+ const m = /\*\*不做[^*]*\*\*\s*[::]\s*([^\n]+)/.exec(section.text)
7922
+ if (m === null) return []
7923
+ return m[1].split('、').map(function (x) { return x.trim() }).filter(function (x) { return x !== '' })
7924
+ }
7925
+
7926
+ /** `- 回复语气:…` / `- 默认时间口径:…` under section 8. */
7927
+ function parsePreferences(section) {
7928
+ const out = { replyTone: '', periodWindow: '' }
7929
+ if (!section) return out
7930
+ for (const raw of section.text.split('\n')) {
7931
+ const line = raw.trim()
7932
+ const t = /^-\s*回复语气\s*[::]\s*(.*)$/.exec(line)
7933
+ if (t !== null) out.replyTone = t[1].trim()
7934
+ const p = /^-\s*默认时间口径\s*[::]\s*(.*)$/.exec(line)
7935
+ if (p !== null) out.periodWindow = p[1].trim()
7936
+ }
7937
+ return out
7938
+ }
7939
+
7940
+ /**
7941
+ * Whole-file parse into the shape the drawer renders.
7942
+ *
7943
+ * Coverage metrics are deliberately STRUCTURAL — bullet counts and
7944
+ * present/absent flags — never punctuation-sliced prose. An earlier
7945
+ * prototype counted duties by splitting the Chinese text on 「;」, so
7946
+ * merely rewording a field moved the number; a résumé metric that jumps
7947
+ * when the user rephrases a sentence is worse than no metric.
7948
+ */
7949
+ function parseAgentsDoc(text) {
7950
+ const doc = splitManagedDoc(text)
7951
+ const sections = parseAgentsSections(doc.managed)
7952
+ const by = {}
7953
+ for (const s of sections) by[s.num] = s
7954
+ const identity = parseIdentityFields(by[1])
7955
+ const skills = parseSkillLines(by[7])
7956
+ const redLines = parseRedLines(by[4])
7957
+ const prefs = parsePreferences(by[8])
7958
+ const notApplicable = (identity['不适用'] || '').split(';')
7959
+ .map(function (x) { return x.trim() }).filter(function (x) { return x !== '' })
7960
+ const hasScene = !!(by[2] && by[2].text.trim())
7961
+ return {
7962
+ doc: doc,
7963
+ sections: sections,
7964
+ by: by,
7965
+ identity: identity,
7966
+ skills: skills,
7967
+ redLines: redLines,
7968
+ prefs: prefs,
7969
+ notApplicable: notApplicable,
7970
+ scene: hasScene ? (by[2].text || '') : '',
7971
+ /* Section 3 / 5 / 9 are the credibility trio. */
7972
+ trust: by[3] ? (by[3].text || '') : '',
7973
+ suspension: by[5] ? (by[5].text || '') : '',
7974
+ selfCheck: by[9] ? (by[9].text || '') : '',
7975
+ coverage: {
7976
+ skills: skills.length,
7977
+ routes: countBullets(by[6]),
7978
+ redLines: redLines.length,
7979
+ scene: hasScene ? 1 : 0,
7980
+ notApplicable: notApplicable.length,
7981
+ },
7982
+ }
7983
+ }
7984
+
7985
+ /** Rewrite one `## N.` section body inside the managed region. */
7986
+ function replaceAgentsSection(text, num, newBody) {
7987
+ const doc = splitManagedDoc(text)
7988
+ const lines = doc.managed.split('\n')
7989
+ let start = -1
7990
+ let end = lines.length
7991
+ for (let i = 0; i < lines.length; i += 1) {
7992
+ const m = /^##\s+(\d+)\.\s/.exec(lines[i])
7993
+ if (m === null) continue
7994
+ if (start === -1 && Number(m[1]) === num) { start = i; continue }
7995
+ if (start !== -1) { end = i; break }
7996
+ }
7997
+ if (start === -1) return text
7998
+ const tail = lines.slice(end)
7999
+ while (tail.length > 0 && tail[0].trim() === '') tail.shift()
8000
+ const rebuilt = lines.slice(0, start + 1)
8001
+ .concat([''], newBody.split('\n'), [''])
8002
+ .concat(tail)
8003
+ return doc.pre + doc.begin + rebuilt.join('\n') + doc.end + doc.post
8004
+ }
8005
+
8006
+ /**
8007
+ * Replace one `- 标签(Key):值` line inside section 1.
8008
+ * Returns null when the line is absent — the caller reports that instead
8009
+ * of silently regenerating the file (which would drop other edits).
8010
+ */
8011
+ function replaceIdentityLine(text, label, value) {
8012
+ const doc = splitManagedDoc(text)
8013
+ const sec = parseAgentsSections(doc.managed).filter(function (s) { return s.num === 1 })[0]
8014
+ if (!sec) return null
8015
+ const re = new RegExp('^(-\\s*' + label + '(?:[((][^))]*[))])?\\s*[::]\\s*)(.*)$', 'm')
8016
+ if (!re.test(sec.text)) return null
8017
+ const body = sec.text.replace(re, function (_all, head) { return head + value })
8018
+ return replaceAgentsSection(text, 1, body)
8019
+ }
8020
+
8021
+ /**
8022
+ * Append one entry to the section 4 「不做」 list.
8023
+ * Append-only by design: existing entries are never removed, because the
8024
+ * red lines are the reason the bot is trustworthy — a boundary the user
8025
+ * can quietly widen is no boundary at all at audit time.
8026
+ */
8027
+ function appendRedLineToDoc(text, item) {
8028
+ const doc = splitManagedDoc(text)
8029
+ const sec = parseAgentsSections(doc.managed).filter(function (s) { return s.num === 4 })[0]
8030
+ if (!sec) return null
8031
+ const existing = parseRedLines(sec)
8032
+ if (existing.indexOf(item) >= 0) return text
8033
+ const re = /(\*\*不做[^*]*\*\*\s*[::]\s*)([^\n]+)/
8034
+ if (!re.test(sec.text)) return null
8035
+ const body = sec.text.replace(re, function (_all, head, list) {
8036
+ return head + list.replace(/\s+$/, '') + '、' + item
8037
+ })
8038
+ return replaceAgentsSection(text, 4, body)
8039
+ }
8040
+
8041
+ /* ---------------------------------------------------------------------
8042
+ * Section 7 / 8 bodies — the ONLY two sections the skill list and the
8043
+ * preference fields own. Written here once so the patch path and the
8044
+ * full generator cannot drift apart.
8045
+ * ------------------------------------------------------------------- */
8046
+
8047
+ /** Body of section 7 for a given skill list. */
8048
+ function section7Body(skillNames) {
8049
+ const tick = String.fromCharCode(96)
8050
+ const list = skillNames.length
8051
+ ? skillNames.map(function (name) { return '- ' + tick + name + tick }).join('\n')
8052
+ : '(暂未添加技能。用户可在 Bot 配置 > 技能里从 DSH 技能库添加。)'
8053
+ return [
8054
+ list,
8055
+ '',
8056
+ '规则:',
8057
+ '1. 回答前,先判断任务是否命中以上技能;命中则加载对应 skill 并按其 SKILL.md 执行。',
8058
+ '2. 未命中时,才回退到本会话全局 skill 目录(<available_skills>)检索。',
8059
+ '3. 不得自行发明技能名;加载技能不改变身份与口径。',
8060
+ ].join('\n')
8061
+ }
8062
+
8063
+ /** Body of section 8 for given preferences. */
8064
+ function section8Body(prefs) {
8065
+ const tone = TONE_LABELS[prefs.replyTone] || { zh: prefs.replyTone || '严谨 · 审计' }
8066
+ return [
8067
+ '- 回复语气:' + tone.zh,
8068
+ '- 默认时间口径:' + (prefs.periodWindow || '(按问题判断)'),
8069
+ ].join('\n')
8070
+ }
8071
+
8072
+ /**
8073
+ * Whether a write must stop because the disk copy drifted from what we
8074
+ * last read or wrote.
8075
+ *
8076
+ * `baseline === null` means the first load has not answered yet, so there
8077
+ * is nothing to compare against and the write proceeds. `disk === next`
8078
+ * means the file already holds exactly what we are about to write (for
8079
+ * example a retry), which is not a conflict.
8080
+ */
8081
+ function isDiskConflict(disk, baseline, next) {
8082
+ if (baseline === null) return false
8083
+ return disk !== baseline && disk !== next
8084
+ }
8085
+
8086
+ /**
8087
+ * Produce the next file content for a skills / preferences change.
8088
+ *
8089
+ * THE FILE IS THE SOURCE OF TRUTH. This used to regenerate the whole
8090
+ * document from the factory `meta.*` constants, which meant any save that
8091
+ * did not carry an explicit override (adding a skill, changing the reply
8092
+ * tone) silently reverted section 1 to the factory wording — throwing
8093
+ * away whatever the user had edited a moment earlier, while the drawer
8094
+ * kept showing the edited text because its in-memory copy was not
8095
+ * refreshed. Patching only the two owned sections removes that whole
8096
+ * class of bug: everything else in the file, edited or hand-written, is
8097
+ * preserved byte for byte.
8098
+ *
8099
+ * `buildAgentsDoc` is used only when the bot has no file yet.
8100
+ */
8101
+ function nextAgentsContent(currentFile, factoryMeta, skillNames, prefs) {
8102
+ if (typeof currentFile !== 'string' || currentFile === '') {
8103
+ return buildAgentsDoc(factoryMeta, skillNames, prefs)
8104
+ }
8105
+ const by = parseAgentsDoc(currentFile).by
8106
+ let text = currentFile
8107
+ if (by[7]) text = replaceAgentsSection(text, 7, section7Body(skillNames))
8108
+ if (by[8]) text = replaceAgentsSection(text, 8, section8Body(prefs || {}))
8109
+ return text
8110
+ }
8111
+
6856
8112
 
6857
8113
  /**
6858
8114
  * Bot page shell: 4 KPI cards + one chat card. The chat body is the
@@ -6865,7 +8121,7 @@ window.__ModuleLoader__.load({
6865
8121
  */
6866
8122
  function BotShell(props) {
6867
8123
  const cfg = props.cfg || {}
6868
- const botId = cfg.botId || props.page || 'cai'
8124
+ const botId = cfg.botId || props.page || 'finance'
6869
8125
  const meta = BOTS[botId] || {}
6870
8126
  const chat = useChatState(botId)
6871
8127
  const [drawer, setDrawer] = React.useState('')
@@ -6874,9 +8130,53 @@ window.__ModuleLoader__.load({
6874
8130
  const [library, setLibrary] = React.useState([])
6875
8131
  const [libraryError, setLibraryError] = React.useState('')
6876
8132
  const [saving, setSaving] = React.useState('')
8133
+ /* The AGENTS.md text as it exists on disk. Empty until the host answers;
8134
+ the drawer falls back to the factory definition only in that window,
8135
+ so the panel never shows stale file content as if it were current. */
8136
+ const [agentsFile, setAgentsFile] = React.useState('')
8137
+ /* The file text as last read from / written to disk. `null` means the
8138
+ baseline is not established yet (first load in flight), so a write is
8139
+ not treated as a conflict. */
8140
+ const [savedAgents, setSavedAgents] = React.useState(null)
8141
+ /* Non-null when the disk copy drifted from our baseline: the pending
8142
+ write is parked until the user picks overwrite or reload. */
8143
+ const [conflict, setConflict] = React.useState(null)
8144
+ /* Which coverage dimension is expanded (-1 = none). */
8145
+ const [radarPick, setRadarPick] = React.useState(-1)
8146
+ /* Which identity field is being edited ('' = none) and its draft text. */
8147
+ const [editField, setEditField] = React.useState('')
8148
+ const [draft, setDraft] = React.useState('')
8149
+ /* Active group of the config drawer. The pane is ~1490px tall inside a
8150
+ 714px viewport (2.1 folds) when stacked linearly; grouping into tabs
8151
+ keeps each group within one screen. */
8152
+ const [paneTab, setPaneTab] = React.useState('overview')
8153
+
8154
+ /* ── Blank-area click collapses the drawer ──────────────────────────
8155
+ Clicking the chat column's empty space should close the right
8156
+ drawer, the same gesture every chat app uses. But the click must not
8157
+ fire when it lands on anything interactive or on a text selection:
8158
+ the composer, buttons, links and the message surface all swallow it.
8159
+ And when an identity field is mid-edit, the click is ignored and the
8160
+ user is pointed at the unsaved draft instead of losing it. */
8161
+ React.useEffect(() => {
8162
+ if (!drawer) return undefined
8163
+ const onBlank = function (event) {
8164
+ if (editField !== '' || picker) return
8165
+ const node = event.target
8166
+ if (node === null || typeof node.closest !== 'function') return
8167
+ /* Anything the user can act on (or read text from) owns its click. */
8168
+ if (node.closest('button, a, input, textarea, select, label, .bot-chip, .chip, [contenteditable="true"]')) return
8169
+ const selection = window.getSelection ? window.getSelection() : null
8170
+ if (selection !== null && String(selection) !== '') return
8171
+ setDrawer('')
8172
+ }
8173
+ document.addEventListener('mousedown', onBlank)
8174
+ return function () { document.removeEventListener('mousedown', onBlank) }
8175
+ }, [drawer, editField, picker])
6877
8176
  const [memory, setMemory] = React.useState(() => {
6878
8177
  try {
6879
8178
  const raw = window.localStorage.getItem('dsh-erpbot.bot.prefs.v1.' + botId)
8179
+ || migrateKey('dsh-erpbot.bot.prefs.v1.', botId)
6880
8180
  return raw ? JSON.parse(raw) : { replyTone: 'rigorous', periodWindow: '' }
6881
8181
  } catch (error) {
6882
8182
  return { replyTone: 'rigorous', periodWindow: '' }
@@ -6894,6 +8194,30 @@ window.__ModuleLoader__.load({
6894
8194
  if (!alive || data === null || data.ok !== true) return
6895
8195
  if (data.config !== undefined && data.config !== null) setConfig(data.config)
6896
8196
  mirrorBotConfig(botId, data.config || { bot: botId, skills: [], updatedAt: null })
8197
+ /* Keep the file text: the identity pane renders from it rather than
8198
+ from the static BOTS constant. Empty string means "no file yet".
8199
+ Establishing the baseline here is what lets a later save tell
8200
+ "nobody touched the file" apart from "someone edited it". */
8201
+ if (typeof data.agents === 'string') {
8202
+ setAgentsFile(data.agents)
8203
+ setSavedAgents(data.agents)
8204
+ } else {
8205
+ /* No file on this bot yet: an empty baseline means the first save
8206
+ is free to create one. */
8207
+ setSavedAgents('')
8208
+ }
8209
+ /* Auto-migrate AGENTS.md from an older schema (no v3 header) to v3. */
8210
+ if (data.agents && data.agents.length > 0 && data.agents.indexOf('<!-- schema: erp.bot.agents.v3 -->') < 0) {
8211
+ const skills = data.config && Array.isArray(data.config.skills) ? data.config.skills : []
8212
+ const agents = buildAgentsDoc(
8213
+ { id: botId, name: meta.name, role: meta.role, duties: meta.duties, tone: meta.tone, boundaries: meta.boundaries, notApplicable: meta.notApplicable, scene: meta.scene },
8214
+ skills,
8215
+ memory,
8216
+ )
8217
+ saveBotConfig(botId, skills, agents)
8218
+ setAgentsFile(agents)
8219
+ setSavedAgents(agents)
8220
+ }
6897
8221
  })
6898
8222
  hostJson('/erp/skills').then((data) => {
6899
8223
  if (!alive) return
@@ -6925,21 +8249,81 @@ window.__ModuleLoader__.load({
6925
8249
  }
6926
8250
 
6927
8251
  /** Save config + AGENTS.md to the workspace. */
6928
- function persist(skillNames, prefs) {
6929
- const agents = buildAgentsDoc(
6930
- { id: botId, name: meta.name, role: meta.role, duties: meta.duties, tone: meta.tone, boundaries: meta.boundaries, scene: meta.scene },
6931
- skillNames,
6932
- prefs || memory,
6933
- )
8252
+ /**
8253
+ * Persist config + AGENTS.md.
8254
+ *
8255
+ * `agentsOverride`, when given, is written verbatim — used by the
8256
+ * identity-field editor and the red-line appender, which already hold
8257
+ * the exact new file text.
8258
+ *
8259
+ * Without an override the file is patched, not regenerated (see
8260
+ * nextAgentsContent): the skills and preferences sections are rewritten
8261
+ * and everything else is left alone.
8262
+ *
8263
+ * WRITE-BACK PROTECTION: before writing, the file is re-read from the
8264
+ * host. If the copy on disk no longer matches the one we last read or
8265
+ * wrote, somebody edited AGENTS.md outside this drawer (an editor, or a
8266
+ * second window). We then stop and surface a conflict instead of
8267
+ * silently discarding their work.
8268
+ */
8269
+ function persist(skillNames, prefs, agentsOverride, force) {
8270
+ const factoryMeta = {
8271
+ id: botId, name: meta.name, role: meta.role, duties: meta.duties,
8272
+ tone: meta.tone, boundaries: meta.boundaries,
8273
+ notApplicable: meta.notApplicable, scene: meta.scene,
8274
+ }
8275
+ const next = (typeof agentsOverride === 'string' && agentsOverride !== '')
8276
+ ? agentsOverride
8277
+ : nextAgentsContent(agentsFile, factoryMeta, skillNames, prefs || memory)
8278
+
6934
8279
  setSaving(L({ zh: '保存中…', en: 'Saving…' }))
6935
- saveBotConfig(botId, skillNames, agents).then((result) => {
6936
- if (result.ok === true) {
6937
- setConfig(result.config || { bot: botId, skills: skillNames, updatedAt: new Date().toISOString() })
6938
- setSaving(L({ zh: '已保存到工作区', en: 'Saved to workspace' }))
6939
- } else {
6940
- setSaving(L({ zh: '保存失败:', en: 'Save failed: ' }) + String(result.error || ''))
8280
+
8281
+ const write = function () {
8282
+ saveBotConfig(botId, skillNames, next).then(function (result) {
8283
+ if (result.ok === true) {
8284
+ setConfig(result.config || { bot: botId, skills: skillNames, updatedAt: new Date().toISOString() })
8285
+ /* Keep the in-memory copy equal to what is now on disk. Without
8286
+ this the drawer keeps rendering the pre-save text and the two
8287
+ silently diverge. */
8288
+ setAgentsFile(next)
8289
+ setSavedAgents(next)
8290
+ setConflict(null)
8291
+ setSaving(L({ zh: '已保存到工作区', en: 'Saved to workspace' }))
8292
+ } else {
8293
+ setSaving(L({ zh: '保存失败:', en: 'Save failed: ' }) + String(result.error || ''))
8294
+ }
8295
+ })
8296
+ }
8297
+
8298
+ if (force === true) { write(); return }
8299
+
8300
+ hostJson('/erp/bot?bot=' + botId).then(function (fresh) {
8301
+ const disk = fresh !== null && typeof fresh.agents === 'string' ? fresh.agents : ''
8302
+ if (isDiskConflict(disk, savedAgents, next)) {
8303
+ /* Someone else changed the file. Park the pending write. */
8304
+ setConflict({ disk: disk, pending: next, skills: skillNames })
8305
+ setSaving(L({ zh: '磁盘上的文件已被修改', en: 'The file changed on disk' }))
8306
+ return
6941
8307
  }
6942
- })
8308
+ write()
8309
+ }).catch(function () { write() })
8310
+ }
8311
+
8312
+ /** Conflict resolution: overwrite the disk copy with the panel's text. */
8313
+ function conflictOverwrite() {
8314
+ const pending = conflict ? conflict.pending : null
8315
+ if (pending === null) return
8316
+ persist(config.skills || [], memory, pending, true)
8317
+ }
8318
+
8319
+ /** Conflict resolution: drop the panel's copy and adopt the disk text. */
8320
+ function conflictReload() {
8321
+ const disk = conflict ? conflict.disk : null
8322
+ if (typeof disk !== 'string') return
8323
+ setAgentsFile(disk)
8324
+ setSavedAgents(disk)
8325
+ setConflict(null)
8326
+ setSaving(L({ zh: '已重载磁盘版本', en: 'Reloaded from disk' }))
6943
8327
  }
6944
8328
 
6945
8329
  /** Add / remove one DSH skill and persist. */
@@ -6963,6 +8347,9 @@ window.__ModuleLoader__.load({
6963
8347
  const currentSession = chat.sessions.find((row) => row.id === chat.sessionId)
6964
8348
  const sessionTitle = currentSession ? currentSession.title : t('ai.blankChat')
6965
8349
  const addedSkills = Array.isArray(config.skills) ? config.skills : []
8350
+ /* Blank/fresh conversation: the welcome lockup replaces the chat surface
8351
+ instead of stacking above it, so it can own the pane and centre. */
8352
+ const blankChat = chat.phase === 'ready' && (chat.blank || chat.fresh)
6966
8353
 
6967
8354
  /** Read-only definition row inside the identity pane. */
6968
8355
  function identityField(label, value) {
@@ -6971,24 +8358,108 @@ window.__ModuleLoader__.load({
6971
8358
  h('div', { className: 'bot-ro' }, value))
6972
8359
  }
6973
8360
 
8361
+ /* The four identity fields the drawer can edit. `zh` is the label as it
8362
+ appears in AGENTS.md section 1 — the parser keys off that exact string,
8363
+ so it must not be translated here. */
8364
+ const EDIT_FIELDS = [
8365
+ { zh: '职责', en: 'Duties', meta: null },
8366
+ { zh: '语气', en: 'Tone', meta: null },
8367
+ { zh: '边界', en: 'Boundaries', meta: null },
8368
+ { zh: '不适用', en: 'Not applicable', meta: null },
8369
+ ]
8370
+
8371
+ /**
8372
+ * One editable identity row: label, current value, and an 编辑 button.
8373
+ * In edit mode the value becomes a textarea with 保存/取消. Saving
8374
+ * rewrites ONLY that field's line inside section 1 of the managed
8375
+ * region, so a hand-written paragraph elsewhere in the file survives.
8376
+ */
8377
+ function editableRow(def, currentValue) {
8378
+ const on = editField === def.zh
8379
+ if (!on) {
8380
+ return h('div', { className: 'bot-erow', key: def.zh },
8381
+ h('span', { className: 'lab' }, L({ zh: def.zh, en: def.en })),
8382
+ h('span', { className: 'val' }, currentValue || L({ zh: '(未设置)', en: '(not set)' })),
8383
+ h('button', {
8384
+ className: 'ed',
8385
+ type: 'button',
8386
+ onClick: function () { setEditField(def.zh); setDraft(currentValue || '') },
8387
+ }, L({ zh: '编辑', en: 'Edit' })))
8388
+ }
8389
+ return h('div', { className: 'bot-erow edit-on', key: def.zh },
8390
+ h('span', { className: 'lab' }, L({ zh: def.zh, en: def.en })),
8391
+ h('textarea', {
8392
+ value: draft,
8393
+ onChange: function (e) { setDraft(e.target.value) },
8394
+ }),
8395
+ h('div', { className: 'bot-eacts' },
8396
+ h('button', {
8397
+ className: 'p',
8398
+ type: 'button',
8399
+ onClick: function () { commitField(def.zh, draft) },
8400
+ }, L({ zh: '保存到文件', en: 'Save to file' })),
8401
+ h('button', {
8402
+ className: 'g',
8403
+ type: 'button',
8404
+ onClick: function () { setEditField(''); setDraft('') },
8405
+ }, L({ zh: '取消', en: 'Cancel' }))))
8406
+ }
8407
+
8408
+ /**
8409
+ * Replace one identity line in section 1 and persist.
8410
+ * The line format is `- 职责(Duties):...`; we keep the label prefix and
8411
+ * swap only the value so the bilingual key survives.
8412
+ */
8413
+ function commitField(label, value) {
8414
+ const next = replaceIdentityLine(agentsFile, label, value)
8415
+ if (next === null) {
8416
+ setEditField('')
8417
+ setSaving(L({ zh: '这一行在文件里找不到,请手动编辑 AGENTS.md', en: 'That line is missing from the file; edit AGENTS.md directly' }))
8418
+ return
8419
+ }
8420
+ setAgentsFile(next)
8421
+ setEditField('')
8422
+ setDraft('')
8423
+ persist(Array.isArray(config.skills) ? config.skills : [], memory, next)
8424
+ }
8425
+
8426
+ /** Append one red line to section 4 and persist (append-only by design). */
8427
+ function appendRedLine(item) {
8428
+ const next = appendRedLineToDoc(agentsFile, item)
8429
+ if (next === null || next === agentsFile) return
8430
+ setAgentsFile(next)
8431
+ persist(Array.isArray(config.skills) ? config.skills : [], memory, next)
8432
+ }
8433
+
6974
8434
  /** One bound skill as a small square card. */
6975
8435
  function skillCard(name) {
6976
8436
  const missing = library.length > 0 && library.map((s) => s.name).indexOf(name) < 0
6977
8437
  const info = library.find((s) => s.name === name)
8438
+ /* Check if all prerequisites are loaded. */
8439
+ const reqs = SKILL_DEPS[name]
8440
+ const unmet = reqs ? reqs.filter(function(r) { return addedSkills.indexOf(r) < 0 }) : []
8441
+ const depsWarn = unmet.length
8442
+ ? L({ zh: '缺少依赖:' + unmet.join('、'), en: 'Missing deps: ' + unmet.join(', ') })
8443
+ : ''
6978
8444
  return h('div', {
6979
- className: 'bot-sk' + (missing ? ' is-missing' : ''),
8445
+ className: 'bot-sk' + (missing ? ' is-missing' : '') + (unmet.length ? ' has-warn' : ''),
6980
8446
  key: 'sk-' + name,
6981
- title: info ? info.description : L({ zh: '本机已无此技能', en: 'No longer available on this machine' }),
8447
+ title: (depsWarn || (info ? info.description : L({ zh: '本机已无此技能', en: 'No longer available on this machine' }))),
6982
8448
  },
6983
8449
  h('span', { className: 'bot-sk-mark' }, String(name).slice(0, 1).toUpperCase()),
6984
8450
  h('span', { className: 'bot-sk-name' }, name),
8451
+ unmet.length ? h('span', {
8452
+ className: 'bot-sk-warn',
8453
+ title: depsWarn,
8454
+ 'aria-label': depsWarn,
8455
+ }, '!') : null,
6985
8456
  h('button', {
6986
8457
  className: 'bot-sk-x',
6987
8458
  type: 'button',
6988
8459
  title: L({ zh: '从本 Bot 移除', en: 'Remove from this bot' }),
6989
8460
  'aria-label': L({ zh: '从本 Bot 移除', en: 'Remove from this bot' }),
6990
8461
  onClick: () => toggleSkill(name),
6991
- }, '×'))
8462
+ }, '\u00d7'))
6992
8463
  }
6993
8464
 
6994
8465
  /** The "+" tile opens the DSH skill picker. */
@@ -7027,30 +8498,292 @@ window.__ModuleLoader__.load({
7027
8498
  h(Icon, { name: 'plus', className: 'bot-pick-plus' })))
7028
8499
  }
7029
8500
 
7030
- /** Identity pane: role definition, preferences and the bound skill cards. */
8501
+ /* ===================================================================
8502
+ * Groups of the config drawer. Kept at shell scope so both the tab bar
8503
+ * and the body render from the same list. */
8504
+ const PANE_TABS = [
8505
+ { id: 'overview', label: L({ zh: '概览', en: 'Overview' }) },
8506
+ { id: 'definition', label: L({ zh: '定义', en: 'Definition' }) },
8507
+ { id: 'capability', label: L({ zh: '能力', en: 'Capability' }) },
8508
+ ]
8509
+
8510
+ /** Tab bar of the config drawer. */
8511
+ function identityTabs() {
8512
+ return h('div', {
8513
+ className: 'bot-tabs',
8514
+ role: 'tablist',
8515
+ 'aria-label': L({ zh: '配置分组', en: 'Settings groups' }),
8516
+ }, PANE_TABS.map(function (tab) {
8517
+ const on = paneTab === tab.id
8518
+ return h('button', {
8519
+ className: 'bot-tab' + (on ? ' on' : ''),
8520
+ key: tab.id,
8521
+ type: 'button',
8522
+ role: 'tab',
8523
+ 'aria-selected': on ? 'true' : 'false',
8524
+ onClick: function () { setPaneTab(tab.id) },
8525
+ }, tab.label)
8526
+ }))
8527
+ }
8528
+
8529
+ /* ===================================================================
8530
+ * Identity pane — the bot's résumé, rendered from AGENTS.md.
8531
+ *
8532
+ * Everything shown here is read out of the file (via parseAgentsDoc)
8533
+ * and falls back to the factory definition only while the host has
8534
+ * not answered. Sections are built as named constants and returned
8535
+ * as a flat array: deep h() nesting is where unbalanced parentheses
8536
+ * hide, so this function stays shallow on purpose.
8537
+ * ===================================================================== */
7031
8538
  function identityPane() {
7032
- return [
7033
- h('div', { className: 'bot-idcard', key: 'idcard' },
7034
- h('span', { className: 'bot-idcard-ava ' + (meta.accent || '') }, L(meta.short || { zh: 'B', en: 'B' })),
7035
- h('div', null,
7036
- h('b', null, L(meta.name || {}) + ' · ' + L({ zh: '身份', en: 'Identity' })),
7037
- h('span', null, L(meta.role || {})))),
7038
- identityField(L({ zh: '职责(只读)', en: 'Duties (read-only)' }), L(meta.duties || {})),
7039
- identityField(L({ zh: '语气(只读)', en: 'Tone (read-only)' }), L(meta.tone || {})),
7040
- identityField(L({ zh: '边界(只读)', en: 'Boundaries (read-only)' }), L(meta.boundaries || {})),
7041
- h('div', { className: 'bot-field', key: 'skills' },
7042
- h('label', null, L({ zh: '专属技能', en: 'Bound skills' })),
7043
- h('div', { className: 'bot-sk-grid' },
7044
- addedSkills.map(skillCard),
7045
- skillAddTile())),
8539
+ const route = ROUTING[botId] || []
8540
+ const parsed = agentsFile ? parseAgentsDoc(agentsFile) : null
8541
+
8542
+ const fileField = function (label, factoryValue) {
8543
+ const fromFile = parsed && parsed.identity ? parsed.identity[label] : ''
8544
+ return fromFile || L(factoryValue || {})
8545
+ }
8546
+ const fileScene = parsed && parsed.scene ? parsed.scene : L(meta.scene || {})
8547
+ const fileSkills = parsed && parsed.skills.length ? parsed.skills : null
8548
+
8549
+ /* ── Coverage metrics: structural counts only ──
8550
+ Every value is a bullet count or a present/absent flag read from
8551
+ the parsed file. Nothing is sliced out of prose: an earlier
8552
+ prototype counted duties by splitting on punctuation and the
8553
+ number jumped whenever the wording changed. */
8554
+ const cov = parsed ? parsed.coverage : {
8555
+ skills: (fileSkills || addedSkills).length,
8556
+ routes: route.length,
8557
+ redLines: 0,
8558
+ scene: fileScene ? 1 : 0,
8559
+ notApplicable: 0,
8560
+ }
8561
+ const DIMS = [
8562
+ { key: L({ zh: '技能覆盖', en: 'Skills' }), raw: cov.skills, max: 10,
8563
+ why: L({ zh: '第 7 段里形如「- 名称」的技能行数。命中即加载对应 SKILL.md,未命中回退全局目录。',
8564
+ en: 'Skill lines in section 7. A hit loads that SKILL.md; a miss falls back to the global catalog.' }) },
8565
+ { key: L({ zh: '转介协作', en: 'Routing' }), raw: cov.routes, max: 3,
8566
+ why: L({ zh: '第 6 段里以「-」开头的出口行数。越界时按此转介,不硬答。',
8567
+ en: 'Routing bullets in section 6. Out-of-scope questions are forwarded, never answered blind.' }) },
8568
+ { key: L({ zh: '约束严谨', en: 'Red lines' }), raw: cov.redLines, max: 10,
8569
+ why: L({ zh: '第 4 段「不做」清单按「、」拆出的条目数。',
8570
+ en: 'Entries in the section 4 "does not do" list.' }) },
8571
+ { key: L({ zh: '场景明确', en: 'Scene' }), raw: cov.scene, max: 1,
8572
+ why: L({ zh: '第 2 段是否有正文。有正文等于回答顺序固定,不是每次现编。',
8573
+ en: 'Whether section 2 has a body. Present means a fixed answer order.' }) },
8574
+ { key: L({ zh: '边界声明', en: 'Scope' }), raw: cov.notApplicable, max: 3,
8575
+ why: L({ zh: '第 1 段「不适用」字段里的分号条数。写明越界范围,比空着更可信。',
8576
+ en: 'Stated not-applicable items. A stated boundary is more trustworthy than a blank.' }) },
8577
+ ]
8578
+
8579
+ /* ── Radar geometry ── label ring at R, data ring at DATA of it. ── */
8580
+ const CX = 76, CY = 76, R = 54, DATA = 0.74
8581
+ const ang = function (i) { return (Math.PI * 2 * i) / DIMS.length - Math.PI / 2 }
8582
+ const pt = function (i, k) {
8583
+ return [CX + Math.cos(ang(i)) * R * k, CY + Math.sin(ang(i)) * R * k]
8584
+ }
8585
+ const ratios = DIMS.map(function (d) { return Math.max(d.raw / d.max, 0.06) })
8586
+
8587
+ const radarEls = []
8588
+ const rings = [1 / 3, 2 / 3, 1]
8589
+ for (let g = 0; g < rings.length; g += 1) {
8590
+ const gridPts = DIMS.map(function (d, i) { return pt(i, DATA * rings[g]).join(',') }).join(' ')
8591
+ radarEls.push(h('polygon', { key: 'grid' + g, points: gridPts, className: 'radar-grid' }))
8592
+ }
8593
+ DIMS.forEach(function (d, i) {
8594
+ const tip = pt(i, 1)
8595
+ radarEls.push(h('line', {
8596
+ key: 'spoke' + i, x1: CX, y1: CY, x2: tip[0], y2: tip[1], className: 'radar-spoke',
8597
+ }))
8598
+ })
8599
+ const areaPts = DIMS.map(function (d, i) { return pt(i, ratios[i] * DATA).join(',') }).join(' ')
8600
+ radarEls.push(h('polygon', { key: 'area', points: areaPts, className: 'radar-fill' }))
8601
+ DIMS.forEach(function (d, i) {
8602
+ const node = pt(i, ratios[i] * DATA)
8603
+ radarEls.push(h('circle', {
8604
+ key: 'node' + i, cx: node[0], cy: node[1], r: 2.3, className: 'radar-node',
8605
+ }))
8606
+ })
8607
+ DIMS.forEach(function (d, i) {
8608
+ const cap = pt(i, 1.02)
8609
+ const cosA = Math.cos(ang(i))
8610
+ const sinA = Math.sin(ang(i))
8611
+ const anchor = Math.abs(cosA) < 0.3 ? 'middle' : (cosA > 0 ? 'start' : 'end')
8612
+ const dy = sinA < -0.5 ? -3 : (sinA > 0.5 ? 9 : 3.4)
8613
+ radarEls.push(h('text', {
8614
+ key: 'cap' + i, x: cap[0], y: cap[1] + dy,
8615
+ className: 'radar-cap', textAnchor: anchor,
8616
+ }, d.key))
8617
+ })
8618
+
8619
+ const legendRows = DIMS.map(function (d, i) {
8620
+ return h('div', {
8621
+ className: 'rl' + (radarPick === i ? ' on' : ''),
8622
+ key: 'dim' + i,
8623
+ role: 'button',
8624
+ tabIndex: 0,
8625
+ title: d.why,
8626
+ onClick: function () { setRadarPick(radarPick === i ? -1 : i) },
8627
+ onKeyDown: function (e) {
8628
+ if (e.key === 'Enter' || e.key === ' ') {
8629
+ e.preventDefault()
8630
+ setRadarPick(radarPick === i ? -1 : i)
8631
+ }
8632
+ },
8633
+ },
8634
+ h('span', { className: 'dot' }),
8635
+ h('span', { className: 'n' }, d.key),
8636
+ h('span', { className: 'v' }, String(d.raw)),
8637
+ h('span', { className: 'bar' },
8638
+ h('i', { style: { width: Math.round((d.raw / d.max) * 100) + '%' } })))
8639
+ })
8640
+
8641
+ const evidence = radarPick >= 0 && DIMS[radarPick]
8642
+ ? h('span', null,
8643
+ h('b', null, DIMS[radarPick].key + ' · ' + String(DIMS[radarPick].raw)),
8644
+ ' — ' + DIMS[radarPick].why)
8645
+ : h('span', null, L({
8646
+ zh: '点左侧任一维度,这里说明这个数字从文件的哪一段、按什么规则数出来。',
8647
+ en: 'Pick a dimension to see which section the number is counted from and how.',
8648
+ }))
8649
+
8650
+ /* ── Qualification: sections 3 / 5 / 9, previously invisible. ── */
8651
+ const QUAL_SECS = [
8652
+ { n: 3, label: L({ zh: '数字可信', en: 'Digital trust' }), body: parsed ? parsed.trust : '' },
8653
+ { n: 5, label: L({ zh: '不确定怎么办', en: 'When unsure' }), body: parsed ? parsed.suspension : '' },
8654
+ { n: 9, label: L({ zh: '交付前自检', en: 'Self-check' }), body: parsed ? parsed.selfCheck : '' },
8655
+ ]
8656
+ const QUAL = QUAL_SECS
8657
+ .filter(function (q) { return q.body !== '' })
8658
+ .map(function (q) {
8659
+ return h('details', { className: 'bot-qual', key: 'q' + q.n },
8660
+ h('summary', null,
8661
+ h('span', { className: 'ico' }, String(q.n)),
8662
+ q.label,
8663
+ h('span', { className: 'chev' }, '\u25b8')),
8664
+ h('div', { className: 'bd' }, q.body))
8665
+ })
8666
+
8667
+ /* ── Red lines: append-only by design. ── */
8668
+ const reds = parsed ? parsed.redLines : []
8669
+ const RED_PRESETS = [
8670
+ L({ zh: '不承诺受理时限', en: 'No service-level promise' }),
8671
+ L({ zh: '不代选会计政策', en: 'No accounting-policy choice' }),
8672
+ L({ zh: '不对外披露明细', en: 'No external disclosure of detail' }),
8673
+ ]
8674
+ const redTags = RED_PRESETS.map(function (p, i) {
8675
+ const used = reds.indexOf(p) >= 0
8676
+ return h('button', {
8677
+ className: 'tag',
8678
+ key: 'rp' + i,
8679
+ type: 'button',
8680
+ disabled: used,
8681
+ onClick: function () { appendRedLine(p) },
8682
+ }, (used ? '\u2713 ' : '+ ') + p)
8683
+ })
8684
+ const redList = h('ul', null, reds.length
8685
+ ? reds.map(function (r, i) { return h('li', { key: 'r' + i }, r) })
8686
+ : [h('li', { key: 'r-empty' },
8687
+ L({ zh: '(文件里还没有红线清单)', en: '(no red-line list in the file yet)' }))])
8688
+
8689
+ /* ── Section header helper: title + rule + optional counter. ── */
8690
+ const secHd = function (title, cnt) {
8691
+ return h('div', { className: 'bot-sec-hd' },
8692
+ title,
8693
+ h('span', { className: 'rule', 'aria-hidden': 'true' }),
8694
+ cnt ? h('span', { className: 'cnt' }, cnt) : null)
8695
+ }
8696
+
8697
+ /* ── One section per résumé block, kept shallow. ── */
8698
+ const idcardEl = h('div', { className: 'bot-idcard', key: 'idcard' },
8699
+ h('span', { className: 'bot-idcard-ava ' + (meta.accent || '') },
8700
+ (meta.short && meta.short.en) || (meta.id || 'B').charAt(0).toUpperCase()),
8701
+ h('div', { style: { flex: '1', minWidth: '0' } },
8702
+ h('b', null, L(meta.name || {})),
8703
+ h('span', null, L(meta.role || {}))),
8704
+ /* Honest sync state: this only says whether the file was read,
8705
+ never claims freshness it cannot verify. */
8706
+ h('span', { className: 'bot-sync ' + (agentsFile ? 'ok' : 'dirty') },
8707
+ h('i', { 'aria-hidden': 'true' }),
8708
+ agentsFile
8709
+ ? L({ zh: '已读文件', en: 'From file' })
8710
+ : L({ zh: '厂值', en: 'Defaults' })))
8711
+
8712
+ const qualSec = QUAL.length
8713
+ ? h('div', { className: 'bot-sec', key: 'sec-qual' },
8714
+ secHd(L({ zh: '资质', en: 'Qualification' }),
8715
+ L({ zh: '来自 AGENTS.md', en: 'From AGENTS.md' })),
8716
+ h('div', null, QUAL))
8717
+ : null
8718
+
8719
+ const radarSec = h('div', { className: 'bot-sec', key: 'sec-radar' },
8720
+ secHd(L({ zh: '能力覆盖面', en: 'Coverage' }),
8721
+ L({ zh: '可数事实 · 非评分', en: 'Counted facts, not a score' })),
8722
+ h('div', { className: 'bot-radar' },
8723
+ h('div', { className: 'bot-radar-top' },
8724
+ h('b', null, L({ zh: '这个 Bot 能覆盖到哪', en: 'What this bot covers' })),
8725
+ h('span', { className: 'note' }, L({ zh: '点一行看依据', en: 'Click a row for its basis' }))),
8726
+ h('div', { className: 'bot-radar-wrap' },
8727
+ h('svg', {
8728
+ width: 152, height: 152, viewBox: '0 0 152 152',
8729
+ role: 'img', 'aria-label': L({ zh: '能力覆盖面雷达图', en: 'Coverage radar' }),
8730
+ }, radarEls),
8731
+ h('div', { className: 'bot-legend' }, legendRows)),
8732
+ h('div', { className: 'bot-evidence' }, evidence)))
8733
+
8734
+ const identitySec = h('div', { className: 'bot-sec', key: 'sec-who' },
8735
+ secHd(L({ zh: '身份定义', en: 'Identity' }),
8736
+ L({ zh: '可编辑 · 第 1 段', en: 'Editable · section 1' })),
8737
+ h('div', { className: 'bot-editable' },
8738
+ EDIT_FIELDS.map(function (def) {
8739
+ return editableRow(def, fileField(def.zh, def.meta))
8740
+ })))
8741
+
8742
+ const redSec = h('div', { className: 'bot-sec', key: 'sec-red' },
8743
+ secHd(L({ zh: '红线', en: 'Red lines' }),
8744
+ L({ zh: '可追加 · 不可删', en: 'Append only' })),
8745
+ h('div', { className: 'bot-reds' },
8746
+ redList,
8747
+ h('div', { className: 'ft' },
8748
+ h('div', { className: 'bot-tags' }, redTags),
8749
+ h('div', { className: 'bot-hint' }, L({
8750
+ zh: '红线只能追加。已有的「不做」条目不可删除 —— 它们是这个 Bot 可信的全部理由。',
8751
+ en: 'Red lines are append-only. Existing entries cannot be removed — they are why this bot is trustworthy.',
8752
+ })))))
8753
+
8754
+ const howSec = h('div', { className: 'bot-sec', key: 'sec-how' },
8755
+ secHd(L({ zh: '我怎么做', en: 'How I answer' }), null),
8756
+ fileScene
8757
+ ? identityField(L({ zh: '回答顺序', en: 'Scene prompt' }), fileScene)
8758
+ : null,
8759
+ route.length
8760
+ ? h('div', { className: 'bot-field', key: 'route' },
8761
+ h('label', null, L({ zh: '越界转介', en: 'Cross-boundary routing' })),
8762
+ h('div', { className: 'bot-ro' },
8763
+ route.map(function (r, i) {
8764
+ return h('div', { className: 'bot-route', key: i },
8765
+ h('span', { className: 'bot-route-txt' }, L(r)))
8766
+ })))
8767
+ : null)
8768
+
8769
+ const skillsSec = h('div', { className: 'bot-sec', key: 'sec-skills' },
8770
+ secHd(L({ zh: '我会什么 · 优先检索', en: 'Bound skills · searched first' }), null),
8771
+ h('div', { className: 'bot-sk-grid' },
8772
+ /* Skills come from the FILE when present, so edits saved by the
8773
+ drawer are reflected on the next read. */
8774
+ (fileSkills || addedSkills).map(skillCard),
8775
+ skillAddTile()),
7046
8776
  picker
7047
8777
  ? h('div', { className: 'bot-pick-list', key: 'picker' }, skillPicker())
7048
8778
  : null,
7049
8779
  h('div', { className: 'bot-hint', key: 'skills-hint' }, L({
7050
- zh: '技能来自 DSH 标准技能库(~/.dsh/skills)。绑定的技能会写进本 Bot 的 AGENTS.md,对话时自动带入。',
7051
- en: 'Skills come from the DSH skill library (~/.dsh/skills). Bound skills are written into this bot AGENTS.md and preloaded into every conversation.',
7052
- })),
7053
- h('div', { className: 'bot-row', key: 'row-tone' },
8780
+ zh: '先在这份清单里检索,命中即用;未命中再回退全局 DSH 技能目录。技能写入 AGENTS.md 第 7 段。',
8781
+ en: 'Search this list first; fall back to the global DSH skill catalog on a miss. Written into AGENTS.md section 7.',
8782
+ })))
8783
+
8784
+ const prefsSec = h('div', { className: 'bot-sec', key: 'sec-prefs' },
8785
+ secHd(L({ zh: '偏好', en: 'Preferences' }), null),
8786
+ h('div', { className: 'bot-row' },
7054
8787
  h('div', { className: 'bot-field' },
7055
8788
  h('label', null, L({ zh: '回复语气', en: 'Reply tone' })),
7056
8789
  h('select', {
@@ -7069,13 +8802,26 @@ window.__ModuleLoader__.load({
7069
8802
  value: memory.periodWindow || '',
7070
8803
  placeholder: '2026-11',
7071
8804
  onChange: (event) => updatePreference('periodWindow', event.target.value),
7072
- }))),
7073
- h('div', { className: 'bot-hint', key: 'hint' }, L({
7074
- zh: '身份、技能与偏好写入该 Bot 工作区的 AGENTS.md,每次对话自动带入。职责、语气与边界只读。',
7075
- en: 'Identity, skills and preferences are written to the bot workspace AGENTS.md and preloaded into every conversation. Duties, tone and boundaries are read-only.',
7076
- })),
7077
- h('div', { className: 'bot-hint', key: 'saving' }, saving),
7078
- ]
8805
+ }))))
8806
+
8807
+ /* ── Grouping ──────────────────────────────────────────────────
8808
+ Stacked linearly this pane measured 1490px inside a 714px
8809
+ viewport — 2.1 folds of scrolling for one bot's settings. The
8810
+ three groups each fit one screen:
8811
+ 概览 ≈664px 身份卡 + 资质(3/5/9) + 能力雷达 — 只读,先看懂
8812
+ 定义 ≈548px 身份定义 + 我怎么做 + 偏好 — 叙述类,可编辑
8813
+ 能力 ≈261px 技能 + 红线 — 清单类,可增减
8814
+ Overview is deliberately read-only: a user opening settings wants
8815
+ to know what the bot IS before touching anything. */
8816
+ const groups = {
8817
+ overview: [idcardEl, qualSec, radarSec],
8818
+ definition: [identitySec, howSec, prefsSec],
8819
+ capability: [skillsSec, redSec],
8820
+ }
8821
+ const body = groups[paneTab] || groups.overview
8822
+ return body.concat([
8823
+ h('div', { className: 'bot-hint', key: 'hint-saving' }, saving),
8824
+ ])
7079
8825
  }
7080
8826
 
7081
8827
  /** History pane: sessions of this bot workspace only. */
@@ -7116,7 +8862,8 @@ window.__ModuleLoader__.load({
7116
8862
  setDrawer(drawer === 'config' ? '' : 'config')
7117
8863
  },
7118
8864
  },
7119
- h('span', { className: 'bot-avatar-t' }, L(meta.short || { zh: 'B', en: 'B' })),
8865
+ h('span', { className: 'bot-avatar-t' },
8866
+ (meta.short && meta.short.en) || (meta.id || 'B').charAt(0).toUpperCase()),
7120
8867
  h('i', { className: 'bot-avatar-state ' + tone, 'aria-hidden': 'true' })),
7121
8868
  h('div', { className: 'bot-meta' },
7122
8869
  h('div', { className: 'bot-name' },
@@ -7143,8 +8890,13 @@ window.__ModuleLoader__.load({
7143
8890
  'aria-label': t('ai.history'),
7144
8891
  onClick: () => setDrawer(drawer === 'history' ? '' : 'history'),
7145
8892
  }, h(Icon, { name: 'list' })))),
7146
- h('div', { className: 'bot-body' },
7147
- (chat.phase === 'ready' && (chat.blank || chat.fresh))
8893
+ h('div', { className: 'bot-body' + (blankChat ? ' is-blank' : '') },
8894
+ /* The official surface is ALWAYS mounted: its composer is the
8895
+ only input, so hiding it in a blank session left the bot page
8896
+ with no way to type. The welcome block overlays the empty
8897
+ message area instead of replacing the surface. */
8898
+ h(BotChatSurface, { botId, kit: props.kit }),
8899
+ blankChat
7148
8900
  ? h('div', { className: 'bot-welcome' },
7149
8901
  h('span', { className: 'orb orb-md bot-orb' }),
7150
8902
  h('b', null, L(meta.name || {})),
@@ -7157,23 +8909,41 @@ window.__ModuleLoader__.load({
7157
8909
  className: 'bot-chip',
7158
8910
  onClick: () => askAbout(name),
7159
8911
  }, name))))
7160
- : null,
7161
- h(BotChatSurface, { botId, kit: props.kit }))),
8912
+ : null)),
7162
8913
  h('aside', { className: 'bot-drawer config' + (drawer === 'config' ? ' on' : '') },
7163
- h('div', { className: 'bot-dr-hd' },
7164
- h('b', null, L({ zh: 'Bot 配置', en: 'Bot settings' })),
7165
- h('span', { className: 'bot-dr-sub' }, L(meta.name || {}) + ' · ' + (meta.version || '')),
7166
- h('button', { className: 'bot-x', type: 'button', onClick: () => setDrawer('') }, h(Icon, { name: 'close' }))),
7167
- h('div', { className: 'bot-dr-bd' }, identityPane()),
8914
+ drawerHead(L({ zh: 'Bot 配置', en: 'Bot settings' }),
8915
+ L(meta.name || {}) + ' · ' + (meta.version || ''), () => setDrawer('')),
8916
+ identityTabs(),
8917
+ /* key={paneTab} remounts the scroll box on every switch, so a
8918
+ group always opens at its top instead of inheriting the
8919
+ previous group's scroll offset. */
8920
+ h('div', { className: 'bot-dr-bd', key: 'bd-' + paneTab }, identityPane()),
8921
+ /* Write-back conflict strip: the disk copy changed under us, so
8922
+ the pending write is parked until the user chooses. */
8923
+ conflict
8924
+ ? h('div', { className: 'bot-conflict', role: 'alert' },
8925
+ h('div', { className: 'tx' }, L({
8926
+ zh: 'AGENTS.md 在磁盘上已被修改,保存会覆盖它。',
8927
+ en: 'AGENTS.md changed on disk; saving would overwrite it.',
8928
+ })),
8929
+ h('div', { className: 'acts' },
8930
+ h('button', {
8931
+ className: 'p',
8932
+ type: 'button',
8933
+ onClick: conflictOverwrite,
8934
+ }, L({ zh: '用面板内容覆盖', en: 'Overwrite with mine' })),
8935
+ h('button', {
8936
+ className: 'g',
8937
+ type: 'button',
8938
+ onClick: conflictReload,
8939
+ }, L({ zh: '改为加载磁盘版本', en: 'Reload from disk' }))))
8940
+ : null,
7168
8941
  h('div', { className: 'bot-dr-ft' },
7169
8942
  h('span', null, L({ zh: '写入 Bot 工作区', en: 'Saved to the bot workspace' })),
7170
8943
  h('button', { className: 'bot-link', type: 'button', onClick: () => setDrawer('') }, L({ zh: '关闭', en: 'Close' })))),
7171
8944
  h('aside', { className: 'bot-drawer history' + (drawer === 'history' ? ' on' : '') },
7172
- h('div', { className: 'bot-dr-hd' },
7173
- h('b', null, L({ zh: '会话', en: 'Conversations' })),
7174
- h('span', { className: 'bot-dr-sub' }, L(meta.name || {})),
7175
- h('button', { className: 'bot-x', type: 'button', onClick: () => setDrawer('') }, h(Icon, { name: 'close' }))),
7176
- h('div', { className: 'bot-dr-bd' }, historyPane()))),
8945
+ drawerHead(L({ zh: '会话', en: 'Conversations' }), L(meta.name || {}), () => setDrawer('')),
8946
+ h('div', { className: 'bot-dr-bd' }, historyPane()))),
7177
8947
  ]
7178
8948
  }
7179
8949
 
@@ -7182,7 +8952,7 @@ window.__ModuleLoader__.load({
7182
8952
  return renderPane(normalizeSection(section))
7183
8953
  }
7184
8954
 
7185
- return { SectionShell, BotShell, PageKpi, renderSection }
8955
+ return { SectionShell, BotShell, BaseShell, TeamShell, PageKpi, renderSection }
7186
8956
  }
7187
8957
  /* =====================================================================
7188
8958
  * SHELL — 面板根板块:ErpPanel(路由 + 布局)+ ErpNavIcon(左栏图标)。
@@ -7191,7 +8961,7 @@ window.__ModuleLoader__.load({
7191
8961
  * ===================================================================== */
7192
8962
 
7193
8963
  function createShell(deps) {
7194
- const { React, h, readLang, subscribeLang, useHostTheme, Rail, CardView, KpiCard, TodoPage, readHashPage, writeHashPage, SectionShell, BotShell, MetricDetailModal, PAGES, ICONS } = deps
8964
+ const { React, h, readLang, subscribeLang, useHostTheme, Rail, CardView, KpiCard, TodoPage, readHashPage, writeHashPage, SectionShell, BotShell, BaseShell, TeamShell, MetricDetailModal, PAGES, ICONS } = deps
7195
8965
 
7196
8966
 
7197
8967
 
@@ -7221,36 +8991,51 @@ window.__ModuleLoader__.load({
7221
8991
  const cfg = PAGES[page] || PAGES.overview
7222
8992
  const revision = activeLang + ':' + theme
7223
8993
  const [openMetric, setOpenMetric] = React.useState(null)
7224
- const body = cfg.sections
7225
- ? (cfg.botId
7226
- ? h(BotShell, { key: page, cfg, page, onOpen: setOpenMetric, kit: props })
7227
- : h(SectionShell, { key: page, cfg, onOpen: setOpenMetric }))
7228
- : cfg.todo
7229
- ? h(TodoPage)
7230
- : [
7231
- h('div', { className: 't1-stats', key: 'kpis' },
7232
- cfg.kpis.map((card, i) => h(KpiCard, { key: i, card, onOpen: setOpenMetric }))),
7233
- h('div', { className: 't1-row2', key: 'core' },
7234
- cfg.core.map((card, i) => h(CardView, { key: i, card, theme, revision, onNav: go }))),
7235
- h('div', { className: 't1-row3', key: 'sub' },
7236
- cfg.sub.map((card, i) => h(CardView, { key: i, card, theme, revision }))),
7237
- ]
8994
+ const body = page === 'team'
8995
+ ? h(TeamShell, { key: page, kit: props })
8996
+ : page === 'base'
8997
+ ? h(BaseShell, { key: page, cfg, onOpen: setOpenMetric })
8998
+ : cfg.sections
8999
+ ? (cfg.botId
9000
+ ? h(BotShell, { key: page, cfg, page, onOpen: setOpenMetric, kit: props })
9001
+ : h(SectionShell, { key: page, cfg, pageId: page, onOpen: setOpenMetric }))
9002
+ : cfg.todo
9003
+ ? h(TodoPage)
9004
+ : [
9005
+ h('div', { className: 't1-stats', key: 'kpis' },
9006
+ cfg.kpis.map((card, i) => h(KpiCard, { key: i, card, onOpen: setOpenMetric }))),
9007
+ h('div', { className: 't1-row2', key: 'core' },
9008
+ cfg.core.map((card, i) => h(CardView, { key: i, card, theme, revision, onNav: go }))),
9009
+ h('div', { className: 't1-row3', key: 'sub' },
9010
+ cfg.sub.map((card, i) => h(CardView, { key: i, card, theme, revision }))),
9011
+ ]
7238
9012
 
7239
9013
  return h('div', { className: 'erp-app', 'data-theme': theme },
7240
9014
  h('div', { className: 'erp-board' },
7241
9015
  h(Rail, { lang: activeLang, page, onNav: go }),
7242
9016
  h('div', {
7243
9017
  className: 't1-main',
7244
- 'data-shell': cfg.sections ? 'true' : 'false',
9018
+ 'data-shell': page === 'team' ? 'team' : (page === 'base' || cfg.sections) ? 'true' : 'false',
7245
9019
  }, body)),
7246
9020
  h(MetricDetailModal, { metric: openMetric, onClose: () => setOpenMetric(null), onGo: go }))
7247
9021
  }
7248
9022
 
7249
9023
 
7250
9024
  /**
7251
- * Left-nav row glyph: the erpBot mark — a rounded gradient tile with a
7252
- * lowercase e. DSH hands `{size, active}`; the tile keeps its gradient in
7253
- * both states so the brand stays recognizable.
9025
+ * Left-nav row glyph: the erpBot mark.
9026
+ *
9027
+ * Two layers only — a gradient disc and a thin bright rim. The earlier
9028
+ * version stacked four (ring + cut-out seam + face + letter B), which is
9029
+ * more information than the 18px box the host allocates can carry: at
9030
+ * `sidebar.panellist` size the seam collapsed and the letter smeared, so
9031
+ * the mark read as a small muddy blob next to the host's crisp line icons.
9032
+ * Dropping to two layers means every pixel carries the brand colour
9033
+ * instead of fighting three other edges.
9034
+ *
9035
+ * It stays a filled disc rather than a line icon on purpose: the sidebar
9036
+ * lists "插件 / 更多" as thin outlines, and a solid disc is what marks this
9037
+ * row as an app rather than another menu entry. The rim is what keeps that
9038
+ * disc from looking like a flat dot.
7254
9039
  * @param props - `{size, active}`.
7255
9040
  * @returns the inline `<svg>`.
7256
9041
  */
@@ -7264,21 +9049,30 @@ window.__ModuleLoader__.load({
7264
9049
  style: { display: 'block', flex: 'none' },
7265
9050
  },
7266
9051
  h('defs', { key: 'd' },
7267
- h('linearGradient', { id: 'erp-nav-mark', x1: 2, y1: 22, x2: 22, y2: 2, gradientUnits: 'userSpaceOnUse' },
7268
- h('stop', { key: 's0', stopColor: '#3f2fbe' }),
7269
- h('stop', { key: 's1', offset: '.52', stopColor: '#7c5cff' }),
7270
- h('stop', { key: 's2', offset: '1', stopColor: '#c48bfb' }))),
7271
- h('rect', { key: 'r', width: 24, height: 24, rx: 7.4, fill: 'url(#erp-nav-mark)' }),
7272
- h('text', {
9052
+ h('linearGradient', {
9053
+ id: 'erp-nav-face', x1: 3, y1: 21, x2: 21, y2: 3, gradientUnits: 'userSpaceOnUse',
9054
+ },
9055
+ h('stop', { key: 'f0', stopColor: '#3a2bb4' }),
9056
+ h('stop', { key: 'f1', offset: '.52', stopColor: '#6d4cf5' }),
9057
+ h('stop', { key: 'f2', offset: '1', stopColor: '#a98bfb' }))),
9058
+ h('circle', { key: 'face', cx: 12, cy: 12, r: 11, fill: 'url(#erp-nav-face)' }),
9059
+ /* Rim sits on the disc's own edge so the bounding box stays 24/24;
9060
+ .62 alpha is the point where it still reads against the disc's
9061
+ light end (#a98bfb) instead of dissolving into it. */
9062
+ h('circle', {
9063
+ key: 'rim', cx: 12, cy: 12, r: 11, fill: 'none',
9064
+ stroke: 'rgba(255,255,255,.62)', strokeWidth: 1.2,
9065
+ }),
9066
+ /* One glyph, no seam, no mask: a "B" spelled as two counters would
9067
+ smear at 18px, so the mark is a single thick stem plus a bowl —
9068
+ the smallest shape that still reads as an initial rather than as a
9069
+ status dot. */
9070
+ h('path', {
7273
9071
  key: 'b',
7274
- x: 12,
7275
- y: 17,
7276
- textAnchor: 'middle',
7277
- fontFamily: '"Segoe UI", Arial, sans-serif',
7278
- fontSize: 13,
7279
- fontWeight: 800,
7280
- fill: '#fff',
7281
- }, 'B'))
9072
+ d: 'M9.6 7.1v9.8M9.6 7.1h2.9a2.5 2.5 0 0 1 0 5H9.6M9.6 12.1h3.3a2.45 2.45 0 0 1 0 4.8H9.6',
9073
+ fill: 'none', stroke: '#fff', strokeWidth: 1.85,
9074
+ strokeLinecap: 'round', strokeLinejoin: 'round',
9075
+ }))
7282
9076
  }
7283
9077
 
7284
9078
 
@@ -7287,26 +9081,26 @@ window.__ModuleLoader__.load({
7287
9081
  /* =====================================================================
7288
9082
  * ENTRY — 唯一接线点。按拓扑序实例化 7 个板块工厂,把符号解构到 factory
7289
9083
  * 作用域。bot-data 片段在此之前已声明 BOT_IDS / BOTS / PLATFORM_SKILLS /
7290
- * SKILL_IDS,由 deps 显式注入到需要的工厂。
9084
+ * SKILL_IDS / SKILL_DEPS,由 deps 显式注入到需要的工厂。
7291
9085
  * ===================================================================== */
7292
9086
 
7293
9087
  const i18n = createI18n()
7294
9088
  const chart = createChart({ SVG_NS, t: i18n.t })
7295
9089
 
7296
9090
  const assistant = createAssistant({ React, h, t: i18n.t, readLang: i18n.readLang, chatBots: BOT_IDS })
7297
- const cards = createCards({ React, h, t: i18n.t, metricLabel, metricSub, PAGES, Icon, Spark, drawTrend: chart.drawTrend })
7298
- const dashboard = createDashboard({ React, h, t: i18n.t, L: i18n.L, readLang: i18n.readLang, setLang: i18n.setLang, LANGS: i18n.LANGS, SHORT_LOCALE: i18n.SHORT_LOCALE, LANG_BADGE: i18n.LANG_BADGE, useChatState: assistant.useChatState, chatTone: assistant.chatTone, drawOverview: chart.drawOverview, CARDS, CARD_AUTO_MS, DONUT_ARCS, EXPENSE_LEGEND, TRANSACTIONS, nextUid, NAV, Icon, Spark, TxTile })
7299
- const workbench = createWorkbench({ React, h, t: i18n.t, L: i18n.L, readLang: i18n.readLang, KpiFigure, compactMoney, metricLabel, metricSub, moneyAmount, Icon, Spark, PAGES, BOTS, useChatState: assistant.useChatState, chatTone: assistant.chatTone, ensureChat: assistant.ensureChat, refreshChat: assistant.refreshChat, switchChatSession: assistant.switchChatSession, newChatSession: assistant.newChatSession, prefillChat: assistant.prefillChat, BotChatSurface: assistant.BotChatSurface })
7300
- const shell = createShell({ React, h, readLang: i18n.readLang, subscribeLang: i18n.subscribeLang, useHostTheme, Rail: dashboard.Rail, CardView: cards.CardView, KpiCard: cards.KpiCard, TodoPage: cards.TodoPage, readHashPage: cards.readHashPage, writeHashPage: cards.writeHashPage, SectionShell: workbench.SectionShell, BotShell: workbench.BotShell, MetricDetailModal, PAGES, ICONS })
9091
+ const cards = createCards({ React, h, t: i18n.t, readLang: i18n.readLang, compactMoney, metricLabel, metricSub, PAGES, Icon, Spark, drawTrend: chart.drawTrend })
9092
+ const dashboard = createDashboard({ React, h, t: i18n.t, L: i18n.L, readLang: i18n.readLang, setLang: i18n.setLang, LANGS: i18n.LANGS, SHORT_LOCALE: i18n.SHORT_LOCALE, LANG_BADGE: i18n.LANG_BADGE, useChatState: assistant.useChatState, useChatBusy: assistant.useChatBusy, chatTone: assistant.chatTone, drawOverview: chart.drawOverview, CARDS, CARD_AUTO_MS, DONUT_ARCS, EXPENSE_LEGEND, TRANSACTIONS, nextUid, NAV, Icon, Spark, TxTile })
9093
+ const workbench = createWorkbench({ React, h, t: i18n.t, L: i18n.L, readLang: i18n.readLang, KpiFigure, compactMoney, metricLabel, metricSub, moneyAmount, Icon, Spark, PAGES, BOTS, SKILL_DEPS, useChatState: assistant.useChatState, chatTone: assistant.chatTone, ensureChat: assistant.ensureChat, ensureTeamChat: assistant.ensureTeamChat, useTeamChat: assistant.useTeamChat, refreshChat: assistant.refreshChat, refreshTeamChat: assistant.refreshTeamChat, switchChatSession: assistant.switchChatSession, newChatSession: assistant.newChatSession, newTeamSession: assistant.newTeamSession, switchTeamSession: assistant.switchTeamSession, prefillChat: assistant.prefillChat, prefillTeamChat: assistant.prefillTeamChat, BotChatSurface: assistant.BotChatSurface, BotTeamSurface: assistant.BotTeamSurface })
9094
+ const shell = createShell({ React, h, readLang: i18n.readLang, subscribeLang: i18n.subscribeLang, useHostTheme, Rail: dashboard.Rail, CardView: cards.CardView, KpiCard: cards.KpiCard, TodoPage: cards.TodoPage, readHashPage: cards.readHashPage, writeHashPage: cards.writeHashPage, SectionShell: workbench.SectionShell, BotShell: workbench.BotShell, BaseShell: workbench.BaseShell, TeamShell: workbench.TeamShell, MetricDetailModal, PAGES, ICONS })
7301
9095
 
7302
9096
  // 摊回 factory 作用域(mount.js 与横切小片仍按顶层符号名引用)
7303
9097
  const { ZH, EN, t, L, setLang, subscribeLang, readLang } = i18n
7304
9098
  const { mk, smooth, INCOME, EXPENSE, HILITE, drawTrend, drawOverview } = chart
7305
9099
  const { ErpPanel, ErpNavIcon } = shell
7306
- const { ensureChat, refreshChat, primeChats, setAssistantCtx, useChatState, chatTone, BotChatSurface, newChatSession, switchChatSession, prefillChat } = assistant
9100
+ const { ensureChat, refreshChat, refreshTeamChat, primeChats, setAssistantCtx, setTeamRemote, useChatState, chatTone, BotChatSurface, newChatSession, switchChatSession, prefillChat } = assistant
7307
9101
  const { KpiCard, CardView, TodoPage, readHashPage, writeHashPage } = cards
7308
9102
  const { TopBar, Rail } = dashboard
7309
- const { SectionShell, BotShell } = workbench
9103
+ const { SectionShell, BotShell, BaseShell, TeamShell } = workbench
7310
9104
  /* =====================================================================
7311
9105
  * MOUNT
7312
9106
  * ===================================================================== */
@@ -7371,7 +9165,13 @@ window.__ModuleLoader__.load({
7371
9165
  const sessions = scoped.get('sessions')
7372
9166
  const workspaces = scoped.get('workspaces')
7373
9167
  scoped.effect(() => {
7374
- const refreshAll = () => BOT_IDS.forEach((botId) => refreshChat(botId))
9168
+ /* The team page shares the same stores, so its state must ride the
9169
+ same refresh: without it `team.blank` kept the value it had when
9170
+ the session was created, and the welcome overlay never left. */
9171
+ const refreshAll = () => {
9172
+ BOT_IDS.forEach((botId) => refreshChat(botId))
9173
+ refreshTeamChat()
9174
+ }
7375
9175
  const offSessions = sessions.list.subscribe(refreshAll)
7376
9176
  const offWorkspaces = workspaces.list.subscribe(() => {
7377
9177
  primeChats()
@@ -7387,10 +9187,21 @@ window.__ModuleLoader__.load({
7387
9187
  ctx.inject(['uiSession'], (scoped) => {
7388
9188
  const uiSession = scoped.get('uiSession')
7389
9189
  scoped.effect(() => {
7390
- const off = uiSession.sessionStatus.subscribe(() => BOT_IDS.forEach((botId) => refreshChat(botId)))
9190
+ const off = uiSession.sessionStatus.subscribe(() => {
9191
+ BOT_IDS.forEach((botId) => refreshChat(botId))
9192
+ refreshTeamChat()
9193
+ })
7391
9194
  return () => off()
7392
9195
  }, 'dsh-erpbot: chat status subscription')
7393
9196
  })
9197
+ /* The team page enables Team mode on its root session through the
9198
+ official `remote.agentTeams` namespace. erpbot does not inject the
9199
+ whole `remote` service, so this narrow injection hands assistant the
9200
+ one namespace it needs (same shape DSH's own agent-team UI uses). */
9201
+ ctx.inject(['remote', 'remote.agentTeams'], (scoped) => {
9202
+ const agentTeams = scoped.remote !== undefined ? scoped.remote.agentTeams : null
9203
+ if (agentTeams !== undefined && agentTeams !== null) setTeamRemote(agentTeams)
9204
+ })
7394
9205
  } catch (error) {
7395
9206
  console.error('[dsh-erpbot] chat services unavailable', error)
7396
9207
  }