dsh-agent-office 4.2.0 → 4.6.0

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/panel-src/main.js CHANGED
@@ -13,6 +13,7 @@ import faceDizzy from './assets/exp-dizzy.png'
13
13
  import faceSurprised from './assets/exp-surprised.png'
14
14
  import faceThinking from './assets/exp-thinking.png'
15
15
  import faceLoved from './assets/exp-loved.png'
16
+ import faceSlits from './assets/exp-slits.png'
16
17
 
17
18
  const API = '/agent-office/api/state'
18
19
  const FLOOR_GAP = 4.0
@@ -285,7 +286,7 @@ function applyThemeFrame() {
285
286
  for (const material of cityMats) material.color.copy(NIGHT_THEME.city).lerp(DAY_THEME.city, mix)
286
287
  const neonMaterial = neonSign?.material
287
288
  if (neonMaterial) neonMaterial.opacity = NIGHT_THEME.neon + (DAY_THEME.neon - NIGHT_THEME.neon) * mix
288
- for (const item of themeLights) item.light.intensity = item.night + (item.day - item.night) * mix
289
+ for (const item of [...themeLights, ...annexLights]) item.light.intensity = item.night + (item.day - item.night) * mix
289
290
  }
290
291
 
291
292
  function syncThemeByClock(immediate = false) {
@@ -350,6 +351,7 @@ const FACE_SRC = {
350
351
  surprised: faceSurprised,
351
352
  thinking: faceThinking,
352
353
  loved: faceLoved,
354
+ slits: faceSlits,
353
355
  }
354
356
  const faceTextures = {}
355
357
  const faceLoader = new THREE.TextureLoader()
@@ -359,13 +361,15 @@ for (const [name, src] of Object.entries(FACE_SRC)) {
359
361
  faceTextures[name] = texture
360
362
  }
361
363
  const CUSTOM_FACE_KEY = 'agent-office-custom-face'
364
+ const GLASS_KEY = 'agent-office-ui-opacity'
362
365
  let customFaceTexture = null
366
+ let customFaceData = ''
363
367
  const FACE_FOR_STATUS = {
364
368
  working: 'happy',
365
369
  need_me: 'surprised',
366
370
  stuck: 'dizzy',
367
371
  done: 'loved',
368
- resting: 'sleepy',
372
+ resting: 'slits',
369
373
  }
370
374
  const zTexture = (() => {
371
375
  const canvas = document.createElement('canvas')
@@ -384,6 +388,63 @@ const zTexture = (() => {
384
388
  return texture
385
389
  })()
386
390
 
391
+
392
+ const sweatTexture = (() => {
393
+ const canvas = document.createElement('canvas')
394
+ canvas.width = 64
395
+ canvas.height = 96
396
+ const ctx = canvas.getContext('2d')
397
+ ctx.clearRect(0, 0, 64, 96)
398
+ ctx.fillStyle = '#bfeaff'
399
+ ctx.beginPath()
400
+ ctx.moveTo(32, 8)
401
+ ctx.bezierCurveTo(46, 30, 55, 48, 55, 62)
402
+ ctx.bezierCurveTo(55, 79, 45, 89, 32, 89)
403
+ ctx.bezierCurveTo(19, 89, 9, 79, 9, 62)
404
+ ctx.bezierCurveTo(9, 48, 18, 30, 32, 8)
405
+ ctx.fill()
406
+ ctx.fillStyle = 'rgba(255,255,255,.85)'
407
+ ctx.beginPath()
408
+ ctx.ellipse(23, 58, 5, 9, -0.35, 0, Math.PI * 2)
409
+ ctx.fill()
410
+ const texture = new THREE.CanvasTexture(canvas)
411
+ texture.colorSpace = THREE.SRGBColorSpace
412
+ return texture
413
+ })()
414
+
415
+ const scribbleTexture = (() => {
416
+ const canvas = document.createElement('canvas')
417
+ canvas.width = 256
418
+ canvas.height = 160
419
+ const ctx = canvas.getContext('2d')
420
+ ctx.clearRect(0, 0, 256, 160)
421
+ const strokes = [
422
+ [[34, 92], [64, 34], [92, 112], [124, 40], [154, 108], [186, 48], [224, 96]],
423
+ [[48, 54], [92, 78], [60, 126], [126, 92], [172, 124], [208, 66]],
424
+ [[78, 24], [104, 66], [144, 26], [168, 74], [208, 32]],
425
+ ]
426
+ ctx.lineCap = 'round'
427
+ ctx.lineJoin = 'round'
428
+ for (const pass of [{ color: 'rgba(255,255,255,.18)', width: 13 }, { color: '#0b0d10', width: 8 }]) {
429
+ ctx.strokeStyle = pass.color
430
+ ctx.lineWidth = pass.width
431
+ for (const points of strokes) {
432
+ ctx.beginPath()
433
+ ctx.moveTo(points[0][0], points[0][1])
434
+ for (let i = 1; i < points.length - 1; i += 2) {
435
+ const [cx, cy] = points[i]
436
+ const [x, y] = points[i + 1]
437
+ ctx.quadraticCurveTo(cx, cy, x, y)
438
+ }
439
+ ctx.stroke()
440
+ }
441
+ }
442
+ const texture = new THREE.CanvasTexture(canvas)
443
+ texture.colorSpace = THREE.SRGBColorSpace
444
+ return texture
445
+ })()
446
+ const OVERLOAD_COLOR = new THREE.Color(0xff5a5a)
447
+
387
448
  const floorTexture = (() => {
388
449
  const canvas = document.createElement('canvas')
389
450
  canvas.width = canvas.height = 256
@@ -414,7 +475,7 @@ function disposeGroup(group) {
414
475
  if (object.material) {
415
476
  const materials = Array.isArray(object.material) ? object.material : [object.material]
416
477
  for (const material of materials) {
417
- if (material.map && material.map !== glowTexture && material.map !== shadowTexture && material.map !== floorTexture) material.map.dispose()
478
+ if (material.map && material.map !== glowTexture && material.map !== shadowTexture && material.map !== floorTexture && material.map !== sweatTexture && material.map !== scribbleTexture) material.map.dispose()
418
479
  material.dispose()
419
480
  }
420
481
  }
@@ -456,6 +517,25 @@ function robotFor(agent, index = 0, sitY = 0) {
456
517
  face.position.y = baseY
457
518
  group.add(face)
458
519
 
520
+ // 正常工作:大汗淋漓;长时间工作:红温 + 头顶乱线
521
+ const sweat = []
522
+ for (let i = 0; i < 4; i++) {
523
+ const mat = new THREE.SpriteMaterial({ map: sweatTexture, transparent: true, opacity: 0, depthWrite: false, toneMapped: false, color: 0xbfeaff })
524
+ const drop = new THREE.Sprite(mat)
525
+ drop.scale.set(0.095, 0.145, 1)
526
+ drop.visible = false
527
+ group.add(drop)
528
+ sweat.push({ sprite: drop, phase: i / 4, x: i % 2 === 0 ? -0.27 : 0.27, drift: i < 2 ? -0.04 : 0.04 })
529
+ }
530
+ const scribbleGlow = new THREE.Sprite(new THREE.SpriteMaterial({ map: glowTexture, color: 0xff4a4a, transparent: true, opacity: 0, depthWrite: false, blending: THREE.AdditiveBlending }))
531
+ scribbleGlow.scale.set(1.0, 0.62, 1)
532
+ scribbleGlow.position.set(0, baseY + 1.55, 0.03)
533
+ group.add(scribbleGlow)
534
+ const scribble = new THREE.Sprite(new THREE.SpriteMaterial({ map: scribbleTexture, transparent: true, opacity: 0, depthWrite: false, toneMapped: false }))
535
+ scribble.scale.set(0.84, 0.5, 1)
536
+ scribble.position.set(0, baseY + 1.55, 0.04)
537
+ group.add(scribble)
538
+
459
539
  // 猫咪上下浮动的 GSAP 循环(用统一相位对象驱动,tick 里再乘幅度)
460
540
  const motion = { bob: 0.5 }
461
541
  if (!reduceMotion) {
@@ -567,7 +647,7 @@ function robotFor(agent, index = 0, sitY = 0) {
567
647
  group.userData = {
568
648
  agentId: agent.id,
569
649
  index,
570
- parts: { face, faceMat, ring, glow, badge, label, zzz, ripples, motion, seatShadow, shadow, tint, baseY },
650
+ parts: { face, faceMat, ring, glow, badge, label, zzz, ripples, motion, seatShadow, shadow, tint, baseY, sweat, scribble, scribbleGlow },
571
651
  }
572
652
  return group
573
653
  }
@@ -1099,7 +1179,7 @@ function buildFloor(team, index, teamAgents) {
1099
1179
 
1100
1180
  // 彩色货架(参考原版的彩色格子)
1101
1181
  const shelf = new THREE.Group()
1102
- shelf.position.set(-W / 2 + 0.26, 0, -0.4)
1182
+ shelf.position.set(-W / 2 + 0.38, 0, -0.4)
1103
1183
  shelf.rotation.y = Math.PI / 2
1104
1184
  const shelfMat = new THREE.MeshStandardMaterial({ color: 0x1b2430, roughness: 0.7, metalness: 0.3 })
1105
1185
  for (const y of [0.5, 1.15, 1.8]) {
@@ -1215,6 +1295,10 @@ function applyCustomFaceToAll() {
1215
1295
  parts.faceMat.color.set(customFaceTexture ? 0xffffff : parts.tint ?? 0xffffff)
1216
1296
  parts.faceMat.needsUpdate = true
1217
1297
  }
1298
+ if (selectedId !== null) {
1299
+ const entry = agentsById.get(selectedId)
1300
+ if (entry) renderCard(entry.agent)
1301
+ }
1218
1302
  }
1219
1303
 
1220
1304
  function loadCustomFace() {
@@ -1222,9 +1306,11 @@ function loadCustomFace() {
1222
1306
  try { data = localStorage.getItem(CUSTOM_FACE_KEY) ?? '' } catch {}
1223
1307
  if (!data) {
1224
1308
  customFaceTexture = null
1309
+ customFaceData = ''
1225
1310
  applyCustomFaceToAll()
1226
1311
  return
1227
1312
  }
1313
+ customFaceData = data
1228
1314
  faceLoader.load(data, texture => {
1229
1315
  texture.colorSpace = THREE.SRGBColorSpace
1230
1316
  customFaceTexture = texture
@@ -1232,8 +1318,20 @@ function loadCustomFace() {
1232
1318
  })
1233
1319
  }
1234
1320
 
1321
+ function applyGlassSetting() {
1322
+ let raw = ''
1323
+ try { raw = localStorage.getItem(GLASS_KEY) ?? '' } catch {}
1324
+ const value = Number.parseFloat(raw)
1325
+ if (Number.isFinite(value)) {
1326
+ document.documentElement.style.setProperty('--glass-alpha', String(Math.min(0.98, Math.max(0.5, value / 100))))
1327
+ } else {
1328
+ document.documentElement.style.removeProperty('--glass-alpha')
1329
+ }
1330
+ }
1331
+
1235
1332
  window.addEventListener('storage', event => {
1236
1333
  if (event.key === CUSTOM_FACE_KEY) loadCustomFace()
1334
+ if (event.key === GLASS_KEY) applyGlassSetting()
1237
1335
  })
1238
1336
  let usage = { tokens_week: 0, tokens_today: 0 }
1239
1337
  let selectedId = null
@@ -1258,8 +1356,8 @@ function globalPose(floorsCount) {
1258
1356
  const height = Math.max(0, (floorsCount - 1) * FLOOR_GAP)
1259
1357
  const centerX = ((floorsCount - 1) * OFFSET_X) / 2
1260
1358
  const centerZ = ((floorsCount - 1) * OFFSET_Z) / 2
1261
- const dist = (13.5 + floorsCount * 2.5) * viewScale()
1262
- const target = new THREE.Vector3(centerX, height / 2 + 1.2, centerZ)
1359
+ const dist = (19 + floorsCount * 3) * viewScale()
1360
+ const target = new THREE.Vector3(centerX + 3.6, height / 2 + 1.0, centerZ + 0.8)
1263
1361
  return {
1264
1362
  target,
1265
1363
  position: new THREE.Vector3(centerX + dist * 0.42, target.y + dist * 0.82, centerZ + dist * 0.9),
@@ -1302,6 +1400,8 @@ function setFloorFocus(index) {
1302
1400
  for (const child of floor.children) child.robot.userData.parts.label.visible = visible
1303
1401
  }
1304
1402
  neonSign.visible = !focused
1403
+ if (annex) annex.visible = !focused
1404
+ if (mcpGroup) mcpGroup.visible = !focused
1305
1405
  const chip = $('#focusChip')
1306
1406
  if (focused) {
1307
1407
  const floor = floors.find(item => item.index === index)
@@ -1409,7 +1509,7 @@ function renderChecks() {
1409
1509
  ]
1410
1510
  const done = checks.filter(item => item.ok).length
1411
1511
  $('#checkScore').textContent = `${done}/${checks.length}`
1412
- $('#checkBody').innerHTML = checks.map(item => `<div class="check ${item.ok ? 'ok' : ''}"><i>✓</i><span>${item.text}</span></div>`).join('')
1512
+ $('#checkBody').innerHTML = checks.map(item => `<div class="check ${item.ok ? 'ok' : ''}"><i><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3.5 8.5l3 3 6-6"/></svg></i><span>${item.text}</span></div>`).join('')
1413
1513
  }
1414
1514
 
1415
1515
  function renderMeter() {
@@ -1432,31 +1532,36 @@ function renderFeed() {
1432
1532
  }).join('')
1433
1533
  }
1434
1534
 
1535
+ let cardHideTimer = null
1536
+
1435
1537
  function renderCard(agent) {
1436
1538
  const card = $('#card')
1437
1539
  if (agent === null) {
1438
- card.classList.remove('show')
1540
+ if (!card.classList.contains('show') || card.classList.contains('is-out')) return
1541
+ card.classList.add('is-out')
1542
+ clearTimeout(cardHideTimer)
1543
+ cardHideTimer = setTimeout(() => card.classList.remove('show', 'is-out'), 160)
1439
1544
  return
1440
1545
  }
1546
+ clearTimeout(cardHideTimer)
1547
+ card.classList.remove('is-out')
1441
1548
  card.classList.add('show')
1442
1549
  const status = STATUS[agent.status] ?? STATUS.resting
1443
1550
  const floor = floors.find(item => item.team === agent.team)
1444
1551
  $('#cardTeam').textContent = floor === undefined ? agent.team : `楼层 ${floor.index + 1} · ${agent.team}`
1445
1552
  const glyphEl = $('#cardGlyph')
1446
1553
  glyphEl.textContent = ''
1447
- glyphEl.style.backgroundImage = `url(${faceDefault})`
1554
+ glyphEl.style.backgroundImage = `url(${customFaceData || faceDefault})`
1448
1555
  glyphEl.style.backgroundSize = '82% 82%'
1449
1556
  glyphEl.style.backgroundRepeat = 'no-repeat'
1450
1557
  glyphEl.style.backgroundPosition = 'center'
1451
- glyphEl.style.backgroundColor = `${agent.color}22`
1452
- glyphEl.style.border = `1px solid ${agent.color}66`
1558
+ glyphEl.style.backgroundColor = customFaceData ? 'transparent' : `${agent.color}22`
1559
+ glyphEl.style.border = customFaceData ? '1px solid var(--line-2)' : `1px solid ${agent.color}66`
1453
1560
  $('#cardName').textContent = agent.name
1454
1561
  $('#cardSub').textContent = agent.schedule
1455
1562
  const statusEl = $('#cardStatus')
1456
1563
  statusEl.textContent = `● ${status.label}`
1457
- statusEl.style.color = status.color
1458
- statusEl.style.borderColor = status.color + '66'
1459
- statusEl.style.background = status.color + '18'
1564
+ statusEl.dataset.status = agent.status
1460
1565
  $('#cardRuns').textContent = String(agent.runs_today ?? 0)
1461
1566
  $('#cardShare').textContent = `${Math.round(agent.usage_pct || 0)}%`
1462
1567
  $('#cardTokens').textContent = fmt(agent.tokens_today || 0)
@@ -1482,6 +1587,351 @@ function teamList(list) {
1482
1587
  return teams.slice(0, 4)
1483
1588
  }
1484
1589
 
1590
+
1591
+ /* ------------------------------------------------------------------ *
1592
+ * 右侧 Annex:教室布局(讲台 + 通用/备考/题库三席)
1593
+ * ------------------------------------------------------------------ */
1594
+ const ANNEX_X = 9.6
1595
+ const ANNEX_Z = 0.3
1596
+ const ANNEX_W = 7.2
1597
+ const ANNEX_D = 6.0
1598
+ let annex = null
1599
+ let annexLights = []
1600
+ let mcpCurve = null
1601
+ let mcpBeads = []
1602
+ let mcpDashes = []
1603
+ let mcpGroup = null
1604
+ let mcpSign = null
1605
+ let sciFiIcons = []
1606
+
1607
+ function buildAnnex() {
1608
+ annex = new THREE.Group()
1609
+ annex.position.set(ANNEX_X, 0, ANNEX_Z)
1610
+ annex.scale.setScalar(0.84)
1611
+ scene.add(annex)
1612
+
1613
+ const wood = new THREE.MeshStandardMaterial({ color: 0x2a2119, roughness: 0.72, metalness: 0.2 })
1614
+ const dark = new THREE.MeshStandardMaterial({ color: 0x17212b, roughness: 0.5, metalness: 0.5 })
1615
+ const metal = new THREE.MeshStandardMaterial({ color: 0x334654, roughness: 0.35, metalness: 0.75 })
1616
+ const glass = new THREE.MeshStandardMaterial({ color: 0x8fc8ff, transparent: true, opacity: 0.06, roughness: 0.08, metalness: 0.1, depthWrite: false, side: THREE.DoubleSide })
1617
+ const cyan = new THREE.MeshBasicMaterial({ color: 0x7fd7ff, toneMapped: false })
1618
+ const warm = new THREE.MeshBasicMaterial({ color: 0xffcf9a, toneMapped: false })
1619
+ const box = (w, h, d, mat, x, y, z, parent = annex) => {
1620
+ const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), mat)
1621
+ mesh.position.set(x, y, z)
1622
+ parent.add(mesh)
1623
+ return mesh
1624
+ }
1625
+
1626
+ // 地板 / 边线
1627
+ box(ANNEX_W, 0.3, ANNEX_D, wood, 0, -0.15, 0)
1628
+ const top = new THREE.Mesh(new THREE.PlaneGeometry(ANNEX_W - 0.18, ANNEX_D - 0.18), new THREE.MeshStandardMaterial({ map: floorTexture, roughness: 0.8, metalness: 0.25 }))
1629
+ top.rotation.x = -Math.PI / 2
1630
+ top.position.y = 0.012
1631
+ annex.add(top)
1632
+ const edge = new THREE.LineSegments(
1633
+ new THREE.EdgesGeometry(new THREE.BoxGeometry(ANNEX_W + 0.04, 0.34, ANNEX_D + 0.04)),
1634
+ new THREE.LineBasicMaterial({ color: 0x8fd4ff, transparent: true, opacity: 0.75 }),
1635
+ )
1636
+ annex.add(edge)
1637
+
1638
+ // 玻璃外墙(正面留空)
1639
+ box(ANNEX_W, 2.35, 0.05, glass, 0, 1.18, -ANNEX_D / 2 + 0.08)
1640
+ box(0.05, 2.35, ANNEX_D, glass, -ANNEX_W / 2 + 0.08, 1.18, 0)
1641
+ box(0.05, 2.35, ANNEX_D, glass, ANNEX_W / 2 - 0.08, 1.18, 0)
1642
+ for (const [x, z] of [[-ANNEX_W / 2 + 0.08, -ANNEX_D / 2 + 0.08], [ANNEX_W / 2 - 0.08, -ANNEX_D / 2 + 0.08], [-ANNEX_W / 2 + 0.08, ANNEX_D / 2 - 0.08], [ANNEX_W / 2 - 0.08, ANNEX_D / 2 - 0.08]]) {
1643
+ box(0.09, 2.45, 0.09, dark, x, 1.22, z)
1644
+ }
1645
+ box(ANNEX_W, 0.06, 0.06, metal, 0, 2.38, -ANNEX_D / 2 + 0.08)
1646
+ box(0.06, 0.06, ANNEX_D, metal, -ANNEX_W / 2 + 0.08, 2.38, 0)
1647
+ box(0.06, 0.06, ANNEX_D, metal, ANNEX_W / 2 - 0.08, 2.38, 0)
1648
+ box(ANNEX_W - 1.0, 0.025, 0.02, cyan, 0, 2.31, -ANNEX_D / 2 + 0.16)
1649
+
1650
+ // 教室布局:讲台在后墙,三张工位面朝讲台
1651
+ // 1) 通用工位
1652
+ function addDesk(x, z, matTop = wood) {
1653
+ const g = new THREE.Group()
1654
+ g.position.set(x, 0, z)
1655
+ box(1.16, 0.055, 0.62, matTop, 0, 0.72, 0, g)
1656
+ for (const [lx, lz] of [[-0.5, -0.24], [0.5, -0.24], [-0.5, 0.24], [0.5, 0.24]]) {
1657
+ box(0.055, 0.7, 0.055, dark, lx, 0.36, lz, g)
1658
+ }
1659
+ box(0.62, 0.4, 0.045, dark, 0, 0.95, -0.18, g)
1660
+ const screen = new THREE.Mesh(new THREE.PlaneGeometry(0.54, 0.3), cyan)
1661
+ screen.position.set(0, 0.95, -0.155)
1662
+ g.add(screen)
1663
+ box(0.5, 0.055, 0.06, warm, 0, 0.76, 0.3, g)
1664
+ // 椅子(面向讲台 -z)
1665
+ box(0.42, 0.06, 0.4, dark, 0, 0.45, 0.55, g)
1666
+ box(0.42, 0.5, 0.06, dark, 0, 0.7, 0.75, g)
1667
+ const stem = new THREE.Mesh(new THREE.CylinderGeometry(0.025, 0.025, 0.42, 8), metal)
1668
+ stem.position.set(0, 0.22, 0.55)
1669
+ g.add(stem)
1670
+ const base = new THREE.Mesh(new THREE.CylinderGeometry(0.23, 0.23, 0.03, 16), dark)
1671
+ base.position.set(0, 0.02, 0.55)
1672
+ g.add(base)
1673
+ annex.add(g)
1674
+ }
1675
+ addDesk(-2.35, 0.85)
1676
+
1677
+ // 2) 备考隔间(产品里的学习空间)
1678
+ const booth = new THREE.Group()
1679
+ booth.position.set(0, 0, 0.85)
1680
+ box(1.55, 1.78, 0.04, glass, 0, 0.9, -0.66, booth)
1681
+ box(0.04, 1.78, 1.28, glass, -0.76, 0.9, 0, booth)
1682
+ box(0.04, 1.78, 1.28, glass, 0.76, 0.9, 0, booth)
1683
+ box(1.58, 0.06, 0.06, metal, 0, 1.79, -0.66, booth)
1684
+ box(0.06, 0.06, 1.32, metal, -0.76, 1.79, 0, booth)
1685
+ box(0.06, 0.06, 1.32, metal, 0.76, 1.79, 0, booth)
1686
+ box(0.74, 0.055, 0.44, wood, 0, 0.7, -0.2, booth)
1687
+ box(0.56, 0.34, 0.04, dark, 0, 0.93, -0.4, booth)
1688
+ const boothScreen = new THREE.Mesh(new THREE.PlaneGeometry(0.48, 0.25), cyan)
1689
+ boothScreen.position.set(0, 0.93, -0.375)
1690
+ booth.add(boothScreen)
1691
+ box(0.42, 0.06, 0.38, dark, 0, 0.44, 0.2, booth)
1692
+ box(0.42, 0.5, 0.06, dark, 0, 0.69, 0.4, booth)
1693
+ const boothLamp = new THREE.Mesh(new THREE.SphereGeometry(0.05, 10, 10), warm)
1694
+ boothLamp.position.set(0.5, 1.34, -0.32)
1695
+ booth.add(boothLamp)
1696
+ annex.add(booth)
1697
+
1698
+ // 3) 题库工位:科幻桌面 + 浮动 OCR / 数据库 / 试卷投影
1699
+ const sci = new THREE.Group()
1700
+ sci.position.set(2.35, 0, 0.85)
1701
+ box(1.16, 0.07, 0.62, metal, 0, 0.72, 0, sci)
1702
+ for (const [lx, lz] of [[-0.5, -0.24], [0.5, -0.24], [-0.5, 0.24], [0.5, 0.24]]) {
1703
+ box(0.05, 0.7, 0.05, dark, lx, 0.36, lz, sci)
1704
+ }
1705
+ // 全息桌屏
1706
+ const holoCanvas = document.createElement('canvas')
1707
+ holoCanvas.width = 256
1708
+ holoCanvas.height = 128
1709
+ const hctx = holoCanvas.getContext('2d')
1710
+ hctx.clearRect(0, 0, 256, 128)
1711
+ hctx.strokeStyle = 'rgba(110,220,255,.7)'
1712
+ hctx.lineWidth = 2
1713
+ for (let x = 0; x <= 256; x += 24) {
1714
+ hctx.beginPath(); hctx.moveTo(x, 0); hctx.lineTo(x, 128); hctx.stroke()
1715
+ }
1716
+ for (let y = 0; y <= 128; y += 24) {
1717
+ hctx.beginPath(); hctx.moveTo(0, y); hctx.lineTo(256, y); hctx.stroke()
1718
+ }
1719
+ hctx.fillStyle = 'rgba(120,230,255,.16)'
1720
+ hctx.fillRect(16, 22, 224, 20)
1721
+ hctx.fillRect(16, 62, 150, 10)
1722
+ hctx.fillRect(16, 86, 190, 10)
1723
+ const holoTexture = new THREE.CanvasTexture(holoCanvas)
1724
+ holoTexture.colorSpace = THREE.SRGBColorSpace
1725
+ const holo = new THREE.Mesh(new THREE.PlaneGeometry(0.86, 0.42), new THREE.MeshBasicMaterial({ map: holoTexture, transparent: true, opacity: 0.55, depthWrite: false, toneMapped: false, side: THREE.DoubleSide }))
1726
+ holo.position.set(0, 1.08, -0.08)
1727
+ holo.rotation.x = -0.18
1728
+ sci.add(holo)
1729
+ box(0.5, 0.055, 0.06, cyan, 0, 0.76, 0.3, sci)
1730
+ // 椅子
1731
+ box(0.42, 0.06, 0.4, dark, 0, 0.45, 0.55, sci)
1732
+ box(0.42, 0.5, 0.06, dark, 0, 0.7, 0.75, sci)
1733
+ const sciStem = new THREE.Mesh(new THREE.CylinderGeometry(0.025, 0.025, 0.42, 8), metal)
1734
+ sciStem.position.set(0, 0.22, 0.55)
1735
+ sci.add(sciStem)
1736
+ const sciBase = new THREE.Mesh(new THREE.CylinderGeometry(0.23, 0.23, 0.03, 16), dark)
1737
+ sciBase.position.set(0, 0.02, 0.55)
1738
+ sci.add(sciBase)
1739
+ annex.add(sci)
1740
+ const halo = new THREE.Mesh(
1741
+ new THREE.RingGeometry(0.4, 0.44, 48),
1742
+ new THREE.MeshBasicMaterial({ color: 0x6fdcff, transparent: true, opacity: 0.35, side: THREE.DoubleSide, depthWrite: false, blending: THREE.AdditiveBlending }),
1743
+ )
1744
+ halo.rotation.x = -Math.PI / 2
1745
+ halo.position.set(2.35, 1.58, 0.98)
1746
+ annex.add(halo)
1747
+
1748
+ // 浮动图标:OCR 相机 / 数据库 / 试卷(billboard,远看也清楚)
1749
+ const iconCanvas = (draw) => {
1750
+ const canvas = document.createElement('canvas')
1751
+ canvas.width = canvas.height = 160
1752
+ const ctx = canvas.getContext('2d')
1753
+ ctx.clearRect(0, 0, 160, 160)
1754
+ ctx.strokeStyle = '#9fe7ff'
1755
+ ctx.fillStyle = '#9fe7ff'
1756
+ ctx.lineWidth = 7
1757
+ ctx.lineCap = 'round'
1758
+ ctx.lineJoin = 'round'
1759
+ ctx.shadowColor = 'rgba(80,200,255,.9)'
1760
+ ctx.shadowBlur = 10
1761
+ draw(ctx)
1762
+ const texture = new THREE.CanvasTexture(canvas)
1763
+ texture.colorSpace = THREE.SRGBColorSpace
1764
+ return texture
1765
+ }
1766
+ const iconSprite = (texture, x, y, z, scale = 0.4) => {
1767
+ const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: texture, transparent: true, depthWrite: false, toneMapped: false }))
1768
+ sprite.scale.set(scale, scale, 1)
1769
+ sprite.position.set(2.35 + x, y, 0.85 + z)
1770
+ annex.add(sprite)
1771
+ sprite.userData.baseY = y
1772
+ sprite.userData.phase = sciFiIcons.length * 0.9
1773
+ sciFiIcons.push(sprite)
1774
+ return sprite
1775
+ }
1776
+
1777
+ const ocrTexture = iconCanvas(ctx => {
1778
+ ctx.beginPath(); ctx.roundRect(32, 44, 78, 58, 10); ctx.stroke()
1779
+ ctx.beginPath(); ctx.arc(73, 73, 17, 0, Math.PI * 2); ctx.stroke()
1780
+ ctx.beginPath(); ctx.arc(73, 73, 6, 0, Math.PI * 2); ctx.fill()
1781
+ ctx.fillRect(42, 34, 22, 10)
1782
+ ctx.font = '700 15px ui-monospace, Menlo, monospace'
1783
+ ctx.textAlign = 'center'
1784
+ ctx.fillText('OCR', 80, 128)
1785
+ })
1786
+ const dbTexture = iconCanvas(ctx => {
1787
+ for (let i = 0; i < 3; i++) {
1788
+ const y = 48 + i * 24
1789
+ ctx.beginPath(); ctx.ellipse(80, y, 28, 9, 0, 0, Math.PI * 2); ctx.stroke()
1790
+ ctx.beginPath(); ctx.moveTo(52, y); ctx.lineTo(52, y + 22); ctx.stroke()
1791
+ ctx.beginPath(); ctx.moveTo(108, y); ctx.lineTo(108, y + 22); ctx.stroke()
1792
+ ctx.beginPath(); ctx.ellipse(80, y + 22, 28, 9, 0, 0, Math.PI * 2); ctx.stroke()
1793
+ }
1794
+ ctx.font = '700 15px ui-monospace, Menlo, monospace'
1795
+ ctx.textAlign = 'center'
1796
+ ctx.fillText('DB', 80, 140)
1797
+ })
1798
+ const paperTexture = iconCanvas(ctx => {
1799
+ ctx.beginPath(); ctx.roundRect(46, 28, 68, 92, 5); ctx.stroke()
1800
+ ctx.beginPath(); ctx.moveTo(98, 28); ctx.lineTo(114, 44); ctx.lineTo(98, 44); ctx.closePath(); ctx.stroke()
1801
+ for (let i = 0; i < 5; i++) {
1802
+ ctx.beginPath(); ctx.moveTo(60, 54 + i * 13); ctx.lineTo(100, 54 + i * 13); ctx.stroke()
1803
+ }
1804
+ ctx.font = '700 15px ui-monospace, Menlo, monospace'
1805
+ ctx.textAlign = 'center'
1806
+ ctx.fillText('EXAM', 80, 142)
1807
+ })
1808
+
1809
+ iconSprite(ocrTexture, -0.5, 2.08, 0.12, 0.48)
1810
+ iconSprite(dbTexture, 0, 2.2, 0.12, 0.48)
1811
+ iconSprite(paperTexture, 0.5, 2.08, 0.12, 0.48)
1812
+
1813
+ // 讲台(教室最前面,背靠后墙,面向三张工位)
1814
+ const podium = new THREE.Group()
1815
+ podium.position.set(0, 0, -1.95)
1816
+ box(1.35, 0.14, 0.95, dark, 0, 0.07, 0, podium)
1817
+ const lectern = box(0.7, 0.98, 0.5, wood, 0, 0.56, 0, podium)
1818
+ lectern.rotation.x = -0.04
1819
+ const topPlate = box(0.86, 0.06, 0.58, metal, 0, 1.08, 0, podium)
1820
+ topPlate.rotation.x = -0.16
1821
+ const panel = new THREE.Mesh(new THREE.PlaneGeometry(0.46, 0.26), cyan)
1822
+ panel.position.set(0, 0.64, 0.256)
1823
+ podium.add(panel)
1824
+ const mic = new THREE.Mesh(new THREE.CylinderGeometry(0.014, 0.014, 0.36, 6), metal)
1825
+ mic.position.set(0.2, 1.22, 0.02)
1826
+ mic.rotation.z = -0.22
1827
+ podium.add(mic)
1828
+ const micHead = new THREE.Mesh(new THREE.SphereGeometry(0.04, 10, 10), cyan)
1829
+ micHead.position.set(0.24, 1.4, 0.02)
1830
+ podium.add(micHead)
1831
+ annex.add(podium)
1832
+ // 讲台后的黑板/示教板
1833
+ box(3.3, 1.25, 0.04, dark, 0, 1.55, -2.86)
1834
+ box(3.1, 0.03, 0.05, cyan, 0, 2.18, -2.83)
1835
+
1836
+ // 室内灯
1837
+ const lightWarm = new THREE.PointLight(0xffd8b0, 2.6, 11, 2)
1838
+ lightWarm.position.set(0, 2.15, 0.4)
1839
+ annex.add(lightWarm)
1840
+ annexLights.push({ light: lightWarm, night: 2.6, day: 1.1 })
1841
+ const lightCool = new THREE.PointLight(0x8fc8ff, 1.8, 10, 2)
1842
+ lightCool.position.set(0, 2.3, -1.8)
1843
+ annex.add(lightCool)
1844
+ annexLights.push({ light: lightCool, night: 1.8, day: 0.8 })
1845
+
1846
+ // 区域标签
1847
+ const tag = (text, x, y, z) => {
1848
+ const el = document.createElement('div')
1849
+ el.className = 'officeTag'
1850
+ el.textContent = text
1851
+ const obj = new CSS2DObject(el)
1852
+ obj.position.set(x, y, z)
1853
+ annex.add(obj)
1854
+ }
1855
+ tag('讲台', 0, 1.9, -1.95)
1856
+ tag('通用', -2.35, 1.45, 0.85)
1857
+ tag('备考', 0, 2.0, 0.85)
1858
+ tag('题库', 2.35, 1.27, 0.85)
1859
+
1860
+ // MCP 虚线 + 流光 + 暂未接通标牌
1861
+ mcpGroup = new THREE.Group()
1862
+ scene.add(mcpGroup)
1863
+ mcpCurve = new THREE.CatmullRomCurve3([
1864
+ new THREE.Vector3(4.55, 2.55, 0.35),
1865
+ new THREE.Vector3(5.15, 3.35, 0.55),
1866
+ new THREE.Vector3(5.8, 3.05, 0.8),
1867
+ new THREE.Vector3(6.58, 2.3, 1.14),
1868
+ ])
1869
+ const pts = mcpCurve.getPoints(90)
1870
+ const cableGeo = new THREE.BufferGeometry().setFromPoints(pts)
1871
+ const cable = new THREE.Line(cableGeo, new THREE.LineDashedMaterial({
1872
+ color: 0x2f7fff, dashSize: 0.2, gapSize: 0.16, transparent: true, opacity: 0.9,
1873
+ }))
1874
+ cable.computeLineDistances()
1875
+ mcpGroup.add(cable)
1876
+
1877
+ // 沿曲线流动的短划,形成可见的“流光”
1878
+ const dashGeo = new THREE.BoxGeometry(0.18, 0.05, 0.05)
1879
+ const dashMat = new THREE.MeshBasicMaterial({ color: 0x2f7fff, transparent: true, opacity: 0.95, depthWrite: false, toneMapped: false })
1880
+ for (let i = 0; i < 8; i++) {
1881
+ const dash = new THREE.Mesh(dashGeo, dashMat)
1882
+ mcpGroup.add(dash)
1883
+ mcpDashes.push({ mesh: dash, phase: i / 8 })
1884
+ }
1885
+
1886
+ const beadGeo = new THREE.SphereGeometry(0.042, 8, 8)
1887
+ const beadMat = new THREE.MeshBasicMaterial({ color: 0x66b8ff, transparent: true, opacity: 0.95, depthWrite: false, toneMapped: false })
1888
+ for (let i = 0; i < 9; i++) {
1889
+ const bead = new THREE.Mesh(beadGeo, beadMat)
1890
+ mcpGroup.add(bead)
1891
+ mcpBeads.push({ mesh: bead, phase: i / 9 })
1892
+ }
1893
+
1894
+ const signCanvas = document.createElement('canvas')
1895
+ signCanvas.width = 512
1896
+ signCanvas.height = 160
1897
+ const sctx = signCanvas.getContext('2d')
1898
+ sctx.clearRect(0, 0, 512, 160)
1899
+ sctx.fillStyle = 'rgba(5,16,28,.88)'
1900
+ sctx.beginPath()
1901
+ sctx.roundRect(6, 6, 500, 148, 24)
1902
+ sctx.fill()
1903
+ sctx.strokeStyle = 'rgba(84,168,255,.55)'
1904
+ sctx.lineWidth = 2
1905
+ sctx.stroke()
1906
+ sctx.strokeStyle = '#54a8ff'
1907
+ sctx.lineWidth = 13
1908
+ sctx.lineCap = 'round'
1909
+ const drawX = (cx, cy, size) => {
1910
+ sctx.beginPath(); sctx.moveTo(cx - size, cy - size); sctx.lineTo(cx + size, cy + size); sctx.stroke()
1911
+ sctx.beginPath(); sctx.moveTo(cx + size, cy - size); sctx.lineTo(cx - size, cy + size); sctx.stroke()
1912
+ }
1913
+ drawX(58, 70, 19)
1914
+ drawX(454, 70, 19)
1915
+ sctx.fillStyle = '#d8efff'
1916
+ sctx.textAlign = 'center'
1917
+ sctx.font = '700 58px ui-monospace, Menlo, monospace'
1918
+ sctx.fillText('MCP', 256, 89)
1919
+ sctx.fillStyle = 'rgba(120,190,255,.82)'
1920
+ sctx.font = '500 22px ui-monospace, Menlo, monospace'
1921
+ sctx.fillText('暂未接通', 256, 128)
1922
+ const signTex = new THREE.CanvasTexture(signCanvas)
1923
+ signTex.colorSpace = THREE.SRGBColorSpace
1924
+ mcpSign = new THREE.Sprite(new THREE.SpriteMaterial({ map: signTex, transparent: true, depthWrite: false, toneMapped: false }))
1925
+ mcpSign.position.set(5.6, 4.55, 1.18)
1926
+ mcpSign.scale.set(3.35, 1.05, 1)
1927
+ mcpGroup.add(mcpSign)
1928
+
1929
+ const signGlow = new THREE.Sprite(new THREE.SpriteMaterial({ map: glowTexture, color: 0x3f9dff, transparent: true, opacity: 0.24, blending: THREE.AdditiveBlending, depthWrite: false }))
1930
+ signGlow.position.copy(mcpSign.position)
1931
+ signGlow.scale.set(4.4, 1.8, 1)
1932
+ mcpGroup.add(signGlow)
1933
+ }
1934
+
1485
1935
  function rebuild(list) {
1486
1936
  for (const [, entry] of agentsById) {
1487
1937
  const motion = entry.robot?.userData?.parts?.motion
@@ -1510,6 +1960,8 @@ function rebuild(list) {
1510
1960
  neonSign.position.set((teams.length - 1) * OFFSET_X * 0.5, height + 5.2, (teams.length - 1) * OFFSET_Z * 0.5)
1511
1961
  selectedId = null
1512
1962
  focusFloor = null
1963
+ if (annex) annex.visible = true
1964
+ if (mcpGroup) mcpGroup.visible = true
1513
1965
  $('#focusChip').hidden = true
1514
1966
  const pose = globalPose(teams.length)
1515
1967
  controls.target.copy(pose.target)
@@ -1615,6 +2067,28 @@ function tick() {
1615
2067
  const t = clock.getElapsedTime()
1616
2068
  requestAnimationFrame(tick)
1617
2069
  applyThemeFrame()
2070
+ if (mcpCurve && mcpBeads.length > 0) {
2071
+ const flow = clock.getElapsedTime()
2072
+ for (const bead of mcpBeads) {
2073
+ const p = mcpCurve.getPointAt((flow * 0.11 + bead.phase) % 1)
2074
+ bead.mesh.position.copy(p)
2075
+ bead.mesh.scale.setScalar(0.75 + 0.35 * Math.sin(flow * 5 + bead.phase * 18))
2076
+ }
2077
+ const axis = new THREE.Vector3(1, 0, 0)
2078
+ const point = new THREE.Vector3()
2079
+ const tangent = new THREE.Vector3()
2080
+ for (const dash of mcpDashes) {
2081
+ const t = (flow * 0.075 + dash.phase) % 1
2082
+ mcpCurve.getPointAt(t, point)
2083
+ mcpCurve.getTangentAt(t, tangent)
2084
+ dash.mesh.position.copy(point)
2085
+ dash.mesh.quaternion.setFromUnitVectors(axis, tangent.normalize())
2086
+ }
2087
+ if (mcpSign) mcpSign.position.y = 4.55 + Math.sin(flow * 0.9) * 0.04
2088
+ for (const icon of sciFiIcons) {
2089
+ icon.position.y = icon.userData.baseY + Math.sin(flow * 2.2 + icon.userData.phase) * 0.04
2090
+ }
2091
+ }
1618
2092
 
1619
2093
  const focusedTeam = focusFloor === null ? null : floors.find(floor => floor.index === focusFloor)?.team
1620
2094
  for (const [id, entry] of agentsById) {
@@ -1628,6 +2102,13 @@ function tick() {
1628
2102
  const working = status === 'working'
1629
2103
  const sleeping = status === 'resting'
1630
2104
  const phase = (id.charCodeAt(2) || 0) * 0.7
2105
+ const nowMs = Date.now()
2106
+ if (working) {
2107
+ if (!entry.workingSince) entry.workingSince = nowMs
2108
+ } else {
2109
+ entry.workingSince = 0
2110
+ }
2111
+ const overloaded = working && entry.workingSince > 0 && nowMs - entry.workingSince > LOAD_WARN_MS
1631
2112
  const amp = working ? 0.09 : 0.055
1632
2113
  const bob = reduceMotion ? 0 : (parts.motion.bob * 2 - 1) * amp
1633
2114
  parts.face.position.y = parts.baseY + bob
@@ -1638,7 +2119,7 @@ function tick() {
1638
2119
  }
1639
2120
 
1640
2121
  // 表情只随状态切换;有自定义猫咪图片时优先使用
1641
- const expr = FACE_FOR_STATUS[status] ?? 'default'
2122
+ const expr = overloaded ? 'dizzy' : (FACE_FOR_STATUS[status] ?? 'default')
1642
2123
  if (customFaceTexture) {
1643
2124
  if (parts.faceMat.map !== customFaceTexture) {
1644
2125
  parts.faceMat.map = customFaceTexture
@@ -1650,18 +2131,41 @@ function tick() {
1650
2131
  parts.faceMat.map = faceTextures[expr]
1651
2132
  parts.faceMat.needsUpdate = true
1652
2133
  }
1653
- parts.faceMat.color.copy(parts.tint)
2134
+ parts.faceMat.color.copy(parts.tint).lerp(OVERLOAD_COLOR, overloaded ? 0.55 : 0)
2135
+ }
2136
+
2137
+ // 正常工作:汗滴;长时间工作:红温乱线
2138
+ const sweaty = working && !overloaded
2139
+ for (const drop of parts.sweat) {
2140
+ drop.sprite.visible = sweaty
2141
+ if (!sweaty) continue
2142
+ const p = (t * 0.55 + drop.phase) % 1
2143
+ drop.sprite.position.set(drop.x + Math.sin(t * 2 + drop.phase * 8) * drop.drift, parts.baseY + 0.26 - p * 0.34, 0.12)
2144
+ drop.sprite.material.opacity = Math.sin(p * Math.PI) * 0.92
2145
+ drop.sprite.scale.set(0.085 + p * 0.03, 0.13 + p * 0.05, 1)
2146
+ }
2147
+ parts.scribble.visible = overloaded
2148
+ parts.scribbleGlow.visible = overloaded
2149
+ if (overloaded) {
2150
+ const jitter = Math.sin(t * 17 + phase) * 0.012
2151
+ parts.scribble.position.set(jitter, parts.baseY + 1.55 + Math.sin(t * 3.2 + phase) * 0.03, 0.04)
2152
+ parts.scribble.rotation = Math.sin(t * 6 + phase) * 0.08
2153
+ parts.scribble.material.opacity = 0.96
2154
+ parts.scribbleGlow.position.copy(parts.scribble.position)
2155
+ parts.scribbleGlow.position.z = 0.02
2156
+ parts.scribbleGlow.material.opacity = 0.26 + Math.sin(t * 8 + phase) * 0.08
1654
2157
  }
1655
2158
 
1656
2159
  // 呼吸/浮动
1657
2160
  const breathe = sleeping ? 1 + Math.sin(t * 1.5 + phase) * 0.02 : 1 + Math.sin(t * 2.2 + phase) * 0.012
1658
2161
  parts.face.scale.set(0.6 * breathe, 0.6 * breathe, 1)
1659
2162
 
1660
- const statusColor = STATUS[status]?.color ?? STATUS.resting.color
2163
+ const statusColor = overloaded ? '#ff5a5a' : (STATUS[status]?.color ?? STATUS.resting.color)
1661
2164
  parts.ring.material.color.set(statusColor)
1662
2165
  parts.glow.material.color.set(statusColor)
1663
2166
  parts.ring.rotation.z = status === 'stuck' ? t * 2.6 : 0
1664
- const ringPulse = working ? 1 + Math.sin(t * 3.4 + phase) * 0.08
2167
+ const ringPulse = overloaded ? 1 + Math.sin(t * 6.5 + phase) * 0.12
2168
+ : working ? 1 + Math.sin(t * 3.4 + phase) * 0.08
1665
2169
  : status === 'need_me' ? 1 + Math.sin(t * 5 + phase) * 0.1 : 1
1666
2170
  parts.ring.scale.setScalar(ringPulse * (id === selectedId ? 1.24 : 1))
1667
2171
  for (let k = 0; k < parts.ripples.length; k++) {
@@ -1673,7 +2177,8 @@ function tick() {
1673
2177
  ripple.scale.setScalar(1 + p * 1.1)
1674
2178
  ripple.material.opacity = (1 - p) * 0.3
1675
2179
  }
1676
- parts.glow.material.opacity = working ? 0.34 + Math.sin(t * 3.4 + phase) * 0.1
2180
+ parts.glow.material.opacity = overloaded ? 0.38 + Math.sin(t * 6.5 + phase) * 0.08
2181
+ : working ? 0.34 + Math.sin(t * 3.4 + phase) * 0.1
1677
2182
  : status === 'need_me' ? 0.32 : status === 'stuck' ? 0.32 : 0.14
1678
2183
 
1679
2184
  // 空闲睡觉:飘 Z
@@ -1855,6 +2360,8 @@ window.__NS = {
1855
2360
  rebuild([])
1856
2361
  $('#offline').classList.add('show')
1857
2362
  $('#offline').textContent = '正在读取本机会话…'
2363
+ buildAnnex()
2364
+ applyGlassSetting()
1858
2365
  loadCustomFace()
1859
2366
  poll()
1860
2367
  setInterval(poll, 2000)