groups.ts 73 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152
  1. import {
  2. ChangeDetectionStrategy,
  3. Component,
  4. computed,
  5. effect,
  6. ElementRef,
  7. inject,
  8. signal,
  9. viewChild,
  10. } from '@angular/core';
  11. import { ActivatedRoute, Router } from '@angular/router';
  12. import { UiAlert, UiAvatar, UiButton, UiEmpty, UiSkeleton } from '../../../ui';
  13. import { MessageBubbleComponent } from '../../../components/domain/message-bubble/message-bubble';
  14. import { FilePreviewComponent } from '../../../components/preview/file-preview/file-preview';
  15. import { ImageLightboxComponent } from '../../../components/preview/image-lightbox/image-lightbox';
  16. import { previewKindOf } from '../../../components/preview/file-kind';
  17. import { Agent, Message } from '../../../core/api/models';
  18. import { ChatService } from '../../../core/chat.service';
  19. import { crossOwnerWarning, detectCrossOwner } from '../../../core/cross-owner';
  20. import { detectHijack, hijackHint, repliesAfter } from '../../../core/hijack-detect';
  21. import { DeviceService } from '../../../core/device.service';
  22. import { fmtPercent, fmtTokens, usageOf } from '../../../core/format';
  23. import { GroupsService } from '../../../core/groups.service';
  24. import { GroupRolesService } from '../../../core/group-roles.service';
  25. import { LiveService } from '../../../core/live.service';
  26. import { DeliverablesService } from '../../../core/deliverables.service';
  27. import { Deliverable } from '../../../core/api/models';
  28. import { Material, fromAttachment, fromDeliverable, mergeMaterials } from '../../../core/materials';
  29. import { ARTIFACT_HINT, buildContext, looksLikeResume } from '../../../core/group-context';
  30. import { availabilityOf } from '../../../core/target-availability';
  31. import { leaderHealth } from '../../../core/leader-health';
  32. import { MemberTaskStatus, memberTaskStatus } from '../../../core/member-task-timeout';
  33. import { findLastDispatch } from '../../../core/dispatch-check';
  34. import { diagnoseLeader } from '../../../core/leader-membership';
  35. import { LEADER_PROMPT, MEMBER_PROMPT } from '../../../core/workgroup-brief';
  36. import { applyMention, filterMentionCandidates, parseMentionQuery } from '../../../core/mention-autocomplete';
  37. import { MatrixClientService } from '../../../core/matrix-client.service';
  38. import { SessionService } from '../../../core/session.service';
  39. import { FmodeApi } from '../../../core/api/fmode-api';
  40. /**
  41. * 桌面·群聊(多数字生命协作)
  42. *
  43. * ## 与单聊页的区别
  44. *
  45. * | | 单聊 `chat.ts` | 群聊(本页) |
  46. * |---|---|---|
  47. * | 消息归属 | `me` / `agent` 两态 | **每个 agent 都能发言**,要显示是谁 |
  48. * | 右栏 | 单容器运行状态 + 成果 | **成员列表**(含各自状态) |
  49. * | 输入 | 直接发 | 可 **@ 单个 / @ 多个** |
  50. *
  51. * ## 实测依据(2026-10-21 P0)
  52. *
  53. * - 群里 @ 单个 agent:3 秒响应
  54. * - 群里 @ 多个 agent:两个都响应,互不干扰
  55. * - **非主人发言会被拒绝**:容器只认自己的 owner
  56. * → 所以群成员必须是同一主人(或已授权的成员)
  57. */
  58. @Component({
  59. selector: 'app-dk-group-page',
  60. changeDetection: ChangeDetectionStrategy.OnPush,
  61. imports: [UiAlert, UiAvatar, UiButton, UiEmpty, UiSkeleton, MessageBubbleComponent, FilePreviewComponent, ImageLightboxComponent],
  62. template: `
  63. <div class="group-layout">
  64. <!-- 群列表 -->
  65. <aside class="col list-col" aria-label="群聊列表">
  66. <header class="col-head">
  67. <h2>工作组</h2>
  68. <div class="head-actions">
  69. <span class="count">{{ groups.rooms().length }}</span>
  70. <!-- 仅公司管理员可创建(云函数会再次校验,这里只是体验) -->
  71. <ui-button
  72. variant="ghost"
  73. shape="circle"
  74. size="sm"
  75. icon="fa-solid fa-plus"
  76. ariaLabel="新建工作组"
  77. (click)="openCreate()"
  78. />
  79. </div>
  80. </header>
  81. <div class="list-scroll">
  82. @if (groups.rooms().length === 0) {
  83. <ui-empty
  84. title="还没有工作组"
  85. description="在工作组里可以由多名数字生命协作。需要公司管理员创建。"
  86. />
  87. } @else {
  88. @for (r of groups.rooms(); track r.roomId) {
  89. <button
  90. type="button"
  91. class="group-row"
  92. [class.active]="r.roomId === roomId()"
  93. (click)="open(r.roomId)"
  94. >
  95. <span class="group-icon" aria-hidden="true">
  96. <i class="fa-solid fa-users"></i>
  97. </span>
  98. <span class="group-meta">
  99. <strong class="ellipsis">{{ r.name }}</strong>
  100. <small class="ellipsis">{{ memberNames(r.roomId) }}</small>
  101. </span>
  102. @if (r.memberIds.length > 2) {
  103. <span class="badge">{{ r.memberIds.length - 1 }}</span>
  104. }
  105. </button>
  106. }
  107. }
  108. </div>
  109. </aside>
  110. <!-- 群会话 -->
  111. <section class="col main-col" aria-label="群消息">
  112. @if (!roomId()) {
  113. <div class="center-fill">
  114. <ui-empty title="选择一个工作组" description="群内可以 @ 一个或多个数字生命。" />
  115. </div>
  116. } @else {
  117. <header class="chat-head">
  118. <span class="group-icon lg" aria-hidden="true">
  119. <i class="fa-solid fa-users"></i>
  120. </span>
  121. <div class="chat-title">
  122. <strong>{{ currentRoom()?.name }}</strong>
  123. <small class="ellipsis">
  124. {{ members().length }} 名数字生命
  125. @if (leaderName(); as ln) {
  126. · 组长 {{ ln }}
  127. }
  128. </small>
  129. </div>
  130. <ui-button
  131. variant="ghost"
  132. shape="circle"
  133. icon="fa-solid fa-user-group"
  134. ariaLabel="成员"
  135. [class.on]="membersOpen()"
  136. (click)="membersOpen.set(!membersOpen())"
  137. />
  138. </header>
  139. <!--
  140. 抢答提示:@ 了 A 却是 B 回的。
  141. 实测(2026-10-28)@ lj-test 派活、agent-lj 执行 ——
  142. 对方 REQUIRE_MENTION=false,群里任何消息都处理。
  143. -->
  144. @if (hijackHintText(); as hint) {
  145. <div class="banner-hold">
  146. <ui-alert type="warning" title="回应的不是被 @ 的对象">
  147. <p class="loop-detail">{{ hint }}</p>
  148. <ui-button variant="outline" size="sm" (click)="dismissHijack()">知道了</ui-button>
  149. </ui-alert>
  150. </div>
  151. }
  152. <!--
  153. 回环提示暂时移除(2026-11-09)。
  154. 原实现注入的是单聊 Guard,读的是当前单聊会话的消息,不是群消息流。
  155. 结果是群聊页显示的是「当前单聊会话」的判定,两端都串门。
  156. 群聊回环的正确数据源是群消息流,需单独设计后再接入,
  157. 不在这里复用单聊守卫。
  158. -->
  159. <div class="messages" #msgList (scroll)="onScroll()">
  160. @if (loading()) {
  161. <div class="skeleton-hold">
  162. @for (i of [1, 2, 3]; track i) {
  163. <ui-skeleton variant="rect" height="56px" />
  164. }
  165. </div>
  166. } @else if (messages().length === 0) {
  167. <ui-empty title="工作组还没有消息" description="在下方 @ 一名或多名数字生命开始协作。" />
  168. } @else {
  169. <div class="history-hold">
  170. @if (paginating()) {
  171. <ui-skeleton variant="rect" height="32px" />
  172. } @else if (hasMore()) {
  173. <ui-button variant="ghost" size="sm" (click)="loadEarlier()">加载更早</ui-button>
  174. } @else {
  175. <span class="history-end">已到最早</span>
  176. }
  177. </div>
  178. @for (m of messages(); track m.id) {
  179. <app-message-bubble
  180. [message]="m"
  181. [own]="m.sender === 'me'"
  182. [showSender]="true"
  183. [nameOf]="mxName"
  184. (preview)="openFile($event)"
  185. />
  186. }
  187. }
  188. </div>
  189. <!-- @ 选择器 -->
  190. @if (mentionOpen()) {
  191. <div class="mention-bar">
  192. <span class="mention-hint">选择要 @ 的成员(可多选):</span>
  193. <div class="mention-chips">
  194. @for (a of members(); track a.id) {
  195. <button
  196. type="button"
  197. class="mention-chip"
  198. [class.on]="mentionTargets().has(a.id)"
  199. (click)="toggleMention(a)"
  200. >
  201. {{ a.name }}
  202. </button>
  203. }
  204. </div>
  205. </div>
  206. }
  207. <div class="composer-hold">
  208. @if (mentionSuggestions().length > 0) {
  209. <div class="mention-suggest" role="listbox" aria-label="@ 候选">
  210. @for (a of mentionSuggestions(); track a.id) {
  211. <button type="button" class="suggest-item" role="option" (click)="pickMention(a)">
  212. <strong>{{ a.name }}</strong>
  213. @if (roomId() && roles.dutyOf()(roomId()!, a.id); as duty) {
  214. <small>{{ duty }}</small>
  215. }
  216. </button>
  217. }
  218. </div>
  219. }
  220. @if (leaderMembership().warn) {
  221. <div class="target-warn critical">
  222. <i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>
  223. <span>{{ leaderMembership().message }}</span>
  224. </div>
  225. } @else if (leaderStatus().warn) {
  226. <div class="target-warn" [class.critical]="leaderStatus().suggestReplace">
  227. <i class="fa-solid" [class]="leaderStatus().suggestReplace ? 'fa-triangle-exclamation' : 'fa-hourglass-half'" aria-hidden="true"></i>
  228. <span>{{ leaderStatus().message }}</span>
  229. @if (leaderStatus().suggestReplace) {
  230. <button type="button" class="warn-action" (click)="membersOpen.set(true)">更换组长</button>
  231. }
  232. </div>
  233. } @else if (dispatchIssue(); as issue) {
  234. <div class="target-warn critical">
  235. <i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>
  236. <span>派给 {{ issue.member }} 的活没送到:{{ issue.detail }}</span>
  237. </div>
  238. } @else if (memberStatus().state === 'stuck') {
  239. <div class="target-warn critical">
  240. <i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>
  241. <span>{{ memberStatus().hint }}</span>
  242. </div>
  243. } @else if (targetStatus().warn) {
  244. <div class="target-warn">
  245. <i class="fa-solid fa-hourglass-half" aria-hidden="true"></i>
  246. {{ targetStatus().hint }}
  247. </div>
  248. }
  249. @if (uploading()) {
  250. <div class="mention-preview">
  251. <i class="fa-solid fa-cloud-arrow-up" aria-hidden="true"></i>
  252. 正在上传 {{ uploadPct() }}%
  253. </div>
  254. }
  255. @if (mentionTargets().size > 0) {
  256. <div class="mention-preview">
  257. <i class="fa-solid fa-at" aria-hidden="true"></i>
  258. {{ mentionLabel() }}
  259. <button type="button" class="clear" (click)="clearMention()" aria-label="取消 @">
  260. <i class="fa-solid fa-xmark" aria-hidden="true"></i>
  261. </button>
  262. </div>
  263. }
  264. <div class="composer-row">
  265. <ui-button
  266. variant="ghost"
  267. shape="circle"
  268. icon="fa-solid fa-at"
  269. ariaLabel="@ 成员"
  270. (click)="mentionOpen.set(!mentionOpen())"
  271. />
  272. <!-- 群内附件:与 DM 用同一条上传链路(预签名直传 OBS) -->
  273. <ui-button
  274. variant="ghost"
  275. shape="circle"
  276. icon="fa-solid fa-paperclip"
  277. ariaLabel="上传文件"
  278. [disabled]="uploading()"
  279. (click)="fileInput.click()"
  280. />
  281. <input
  282. #fileInput
  283. type="file"
  284. multiple
  285. hidden
  286. (change)="onFiles($event)"
  287. />
  288. <textarea
  289. #input
  290. class="input"
  291. rows="1"
  292. [placeholder]="targetHint()"
  293. [value]="draft()"
  294. (input)="onInput($event)"
  295. (keydown)="onKeydown($event)"
  296. ></textarea>
  297. <ui-button
  298. variant="primary"
  299. shape="circle"
  300. icon="fa-solid fa-paper-plane"
  301. ariaLabel="发送"
  302. [disabled]="!draft().trim() || sending()"
  303. (click)="send()"
  304. />
  305. </div>
  306. <!--
  307. 发送目标必须明确。
  308. 不 @ 任何人时,群里 REQUIRE_MENTION=false 的容器会全部响应 ——
  309. 目标失控。所以在这里挡住,并给出一键选择。
  310. -->
  311. @if (sendBlocked()) {
  312. <div class="target-required" role="alert">
  313. <i class="fa-solid fa-circle-exclamation" aria-hidden="true"></i>
  314. <span>请先选择要让谁做事(点下方 @ 或直接点名字)—— 群里有多个容器,不指定对象会导致谁都在响应。</span>
  315. <div class="target-quick">
  316. @if (leaderAgent(); as la) {
  317. <ui-button variant="outline" size="sm" (click)="quickMention(la)">
  318. 交给组长 {{ la.name }}
  319. </ui-button>
  320. }
  321. <ui-button variant="ghost" size="sm" (click)="sendBlocked.set(false)">知道了</ui-button>
  322. </div>
  323. </div>
  324. }
  325. </div>
  326. }
  327. </section>
  328. <!-- 新建工作组 -->
  329. @if (creating()) {
  330. <div class="role-editor" role="dialog" aria-modal="true" aria-label="新建工作组">
  331. <div class="role-card">
  332. <h3>新建工作组</h3>
  333. <label class="role-field">
  334. <span>组名</span>
  335. <input
  336. type="text"
  337. [value]="newName()"
  338. (input)="newName.set($any($event.target).value)"
  339. placeholder="如:登录页开发"
  340. maxlength="40"
  341. />
  342. </label>
  343. <label class="role-field">
  344. <span>目标(选填)</span>
  345. <input
  346. type="text"
  347. [value]="newGoal()"
  348. (input)="newGoal.set($any($event.target).value)"
  349. placeholder="一句话说明这个组要做什么"
  350. maxlength="80"
  351. />
  352. </label>
  353. <div class="role-field">
  354. <span>选择成员(可多选)</span>
  355. <div class="mention-chips">
  356. @for (a of candidateAgents(); track a.id) {
  357. <button
  358. type="button"
  359. class="mention-chip"
  360. [class.on]="newMembers().has(a.id)"
  361. (click)="toggleNewMember(a)"
  362. >
  363. {{ a.name }}
  364. </button>
  365. }
  366. </div>
  367. </div>
  368. @if (createError()) {
  369. <p class="role-hint err">{{ createError() }}</p>
  370. }
  371. <!-- 建组前就提醒:混入别人的容器会导致组长指挥不动它 -->
  372. @if (newCrossOwnerHint(); as hint) {
  373. <p class="role-hint warn-text">
  374. <i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>
  375. {{ hint }}
  376. </p>
  377. }
  378. <p class="role-hint">
  379. 创建后你和所选数字生命会进入同一个群,可以 @ 一个或多个协作。
  380. </p>
  381. <div class="role-actions">
  382. <ui-button variant="ghost" size="sm" (click)="creating.set(false)">取消</ui-button>
  383. <ui-button
  384. variant="primary"
  385. size="sm"
  386. [disabled]="!newName().trim() || newMembers().size === 0 || creatingBusy()"
  387. (click)="submitCreate()"
  388. >
  389. {{ creatingBusy() ? '创建中…' : '创建' }}
  390. </ui-button>
  391. </div>
  392. </div>
  393. </div>
  394. }
  395. <!-- 角色编辑 -->
  396. @if (editing()) {
  397. <div class="role-editor" role="dialog" aria-modal="true" aria-label="编辑角色">
  398. <div class="role-card">
  399. <h3>编辑 {{ editing()!.name }} 的角色</h3>
  400. <label class="role-field">
  401. <span>职责</span>
  402. <input
  403. type="text"
  404. [value]="dutyDraft()"
  405. (input)="dutyDraft.set($any($event.target).value)"
  406. placeholder="如:前端 / 后端 / 设计"
  407. maxlength="40"
  408. />
  409. </label>
  410. <label class="role-check">
  411. <input
  412. type="checkbox"
  413. [checked]="isLeader(editing()!.id)"
  414. (change)="toggleLeader(editing()!.id)"
  415. />
  416. <span>设为组长(会替换原组长)</span>
  417. </label>
  418. <p class="role-hint">
  419. 组长负责汇总方案、对外沟通;组员负责各自部分。用户据此知道该找谁。
  420. </p>
  421. <div class="role-actions">
  422. <ui-button variant="ghost" size="sm" (click)="editing.set(null)">取消</ui-button>
  423. <ui-button variant="primary" size="sm" [disabled]="savingRole()" (click)="saveRole()">
  424. {{ savingRole() ? '保存中…' : '保存' }}
  425. </ui-button>
  426. </div>
  427. </div>
  428. </div>
  429. }
  430. <!-- 成员栏 -->
  431. @if (roomId() && membersOpen()) {
  432. <aside class="col members-col" aria-label="群成员">
  433. <header class="col-head">
  434. <h2>成员与资料</h2>
  435. <ui-button
  436. variant="ghost"
  437. shape="circle"
  438. icon="fa-solid fa-xmark"
  439. ariaLabel="收起"
  440. (click)="membersOpen.set(false)"
  441. />
  442. </header>
  443. <div class="member-list">
  444. <!--
  445. 跨主人协作提示。
  446. 容器只认自己的主人:组里混了别人的容器时,组长 @ 它不会执行。
  447. 这个限制 App 改不了(需容器侧实现授权表),但必须提前说清楚,
  448. 别让用户派活失败后才发现。
  449. -->
  450. @if (crossOwnerHint(); as hint) {
  451. <div class="cross-owner-note" role="note">
  452. <i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>
  453. <span>{{ hint }}</span>
  454. </div>
  455. }
  456. @for (a of membersSorted(); track a.id) {
  457. <button
  458. type="button"
  459. class="member-row"
  460. [class.is-leader]="isLeader(a.id)"
  461. (click)="openRoleEditor(a)"
  462. [attr.aria-label]="'编辑 ' + a.name + ' 的角色'"
  463. >
  464. <ui-avatar [name]="a.name" [size]="'sm'" />
  465. <span class="member-meta">
  466. <span class="member-line">
  467. <strong class="ellipsis">{{ a.name }}</strong>
  468. @if (isLeader(a.id)) {
  469. <span class="role-tag leader">组长</span>
  470. } @else if (roles.dutyOf()(roomId()!, a.id)) {
  471. <span class="role-tag">组员</span>
  472. }
  473. @if (!isLeader(a.id) && !isAuthorized(a.id)) {
  474. <span class="role-tag warn" title="未授权:组长 @ 它不会执行">未授权</span>
  475. }
  476. </span>
  477. <!--
  478. 状态行:优先显示「现在在干什么」。
  479. 🔴 只看 status 会误报 —— 容器上报 running 但 currentTask 为空是常态,
  480. 直接显示「执行中」会让用户以为它在干活(截图实测)。
  481. -->
  482. <small class="ellipsis" [title]="statusLineOf(a)">{{ statusLineOf(a) }}</small>
  483. </span>
  484. <span class="dot" [class.busy]="isReallyBusy(a)" aria-hidden="true"></span>
  485. </button>
  486. }
  487. @if (members().length === 0) {
  488. <p class="empty-hint">该群的数字生命尚未绑定到平台账号。</p>
  489. }
  490. <!--
  491. 运行状态(与单聊右侧同一结构,用户要求两边样式统一)。
  492. 显示组成员的实时指标;组长单独一行说明角色。
  493. -->
  494. @if (leaderAgent(); as la) {
  495. <div class="material-head">
  496. <h3>运行状态 · {{ la.name }}</h3>
  497. </div>
  498. <dl class="kv">
  499. <div>
  500. <dt>当前任务</dt>
  501. <dd>{{ statusLineOf(la) }}</dd>
  502. </div>
  503. <div><dt>CPU</dt><dd>{{ pct(la.host?.cpuPercent) }}</dd></div>
  504. <div><dt>内存</dt><dd>{{ pct(la.host?.memPercent) }}</dd></div>
  505. <div><dt>Token 用量</dt><dd>{{ tokens(la) }}</dd></div>
  506. </dl>
  507. }
  508. <!--
  509. 资料区(问题 5):这个群产出的文件都在这里,固定可滚动。
  510. 与「成果」页同源(DeliverablesService),只是按本组成员过滤。
  511. -->
  512. <div class="material-head">
  513. <h3>本群资料 <span class="count">{{ groupMaterials().length }}</span></h3>
  514. </div>
  515. @if (groupMaterials().length === 0) {
  516. <p class="empty-hint">还没有产出资料。</p>
  517. } @else {
  518. @for (d of groupMaterials(); track d.id) {
  519. <button type="button" class="material-row" (click)="openGroupMaterial(d)">
  520. <span class="file-icon" aria-hidden="true">
  521. <i class="fa-solid" [class]="materialIcon(d)"></i>
  522. </span>
  523. <span class="rail-meta">
  524. <!--
  525. 标题和文件名常常相同(如都是 index.html)→ 只显示一次。
  526. 副标题只放「能补充信息」的内容:来源标签或不同的文件名。
  527. -->
  528. <strong class="ellipsis">{{ d.title }}</strong>
  529. <small class="ellipsis">
  530. @if (d.filename && d.filename !== d.title) {
  531. {{ d.filename }}
  532. } @else if (d.source === 'attachment') {
  533. 来自群聊
  534. } @else {
  535. 来自成果
  536. }
  537. </small>
  538. </span>
  539. </button>
  540. }
  541. }
  542. </div>
  543. </aside>
  544. }
  545. <!-- 图片灯箱(2026-10-30):图片不进预览弹窗 -->
  546. <app-image-lightbox
  547. [open]="!!lightboxFile()"
  548. [url]="lightboxFile()?.url ?? ''"
  549. [name]="lightboxFile()?.name ?? ''"
  550. [size]="lightboxFile()?.size ?? 0"
  551. (closed)="lightboxFile.set(null)"
  552. />
  553. <!-- 附件预览:与 DM 共用同一个预览组件 -->
  554. <app-file-preview
  555. [open]="!!previewFile()"
  556. [url]="previewFile()?.url ?? ''"
  557. [name]="previewFile()?.name ?? ''"
  558. [mimeType]="previewFile()?.mimeType ?? ''"
  559. (closed)="previewFile.set(null)"
  560. />
  561. </div>
  562. `,
  563. styles: [
  564. `
  565. :host {
  566. display: block;
  567. height: 100%;
  568. min-height: 0;
  569. overflow: hidden;
  570. }
  571. .group-layout {
  572. display: grid;
  573. grid-template-columns: var(--desk-list-w) minmax(0, 1fr);
  574. height: 100%;
  575. min-height: 0;
  576. }
  577. .group-layout:has(> .members-col) {
  578. grid-template-columns: var(--desk-list-w) minmax(0, 1fr) 260px;
  579. }
  580. .col {
  581. min-width: 0;
  582. min-height: 0;
  583. display: flex;
  584. flex-direction: column;
  585. background: var(--surface);
  586. }
  587. .list-col {
  588. background: var(--surface-2);
  589. border-right: 1px solid var(--edge);
  590. overflow: hidden;
  591. }
  592. .members-col {
  593. background: var(--rail-surface);
  594. border-left: 1px solid var(--edge);
  595. }
  596. .col-head {
  597. display: flex;
  598. align-items: center;
  599. justify-content: space-between;
  600. gap: var(--sp-2);
  601. padding: var(--sp-4);
  602. border-bottom: 1px solid var(--edge);
  603. }
  604. .col-head h2 {
  605. margin: 0;
  606. font-size: var(--fs-15);
  607. color: var(--ink);
  608. }
  609. .count {
  610. font-size: var(--fs-11);
  611. color: var(--muted);
  612. }
  613. .list-scroll {
  614. flex: 1;
  615. min-height: 0;
  616. overflow-y: auto;
  617. padding: var(--sp-2);
  618. }
  619. .group-row {
  620. display: flex;
  621. align-items: center;
  622. gap: var(--sp-3);
  623. width: 100%;
  624. min-height: 56px;
  625. padding: var(--sp-2) var(--sp-3);
  626. border: 0;
  627. border-radius: var(--r-md);
  628. background: transparent;
  629. text-align: left;
  630. cursor: pointer;
  631. }
  632. .group-row:hover {
  633. background: var(--surface-hover);
  634. }
  635. .group-row.active {
  636. background: var(--surface);
  637. }
  638. .group-icon {
  639. display: grid;
  640. place-items: center;
  641. width: 32px;
  642. height: 32px;
  643. flex-shrink: 0;
  644. border-radius: var(--r-sm);
  645. background: var(--primary-subtle);
  646. color: var(--primary);
  647. }
  648. .group-icon.lg {
  649. width: 40px;
  650. height: 40px;
  651. }
  652. .group-meta {
  653. display: grid;
  654. gap: 2px;
  655. flex: 1;
  656. min-width: 0;
  657. }
  658. .group-meta strong {
  659. font-size: var(--fs-15);
  660. color: var(--ink);
  661. }
  662. .group-meta small {
  663. font-size: var(--fs-12);
  664. color: var(--muted);
  665. }
  666. .badge {
  667. min-width: 18px;
  668. padding: 0 5px;
  669. border-radius: var(--r-tag);
  670. background: var(--surface-active);
  671. color: var(--ink-2);
  672. font-size: var(--fs-11);
  673. line-height: 18px;
  674. text-align: center;
  675. }
  676. .center-fill {
  677. flex: 1;
  678. display: grid;
  679. place-content: center;
  680. }
  681. .chat-head {
  682. display: flex;
  683. align-items: center;
  684. gap: var(--sp-3);
  685. padding: var(--sp-3) var(--sp-4);
  686. border-bottom: 1px solid var(--edge);
  687. }
  688. .chat-title {
  689. display: grid;
  690. gap: 2px;
  691. flex: 1;
  692. min-width: 0;
  693. }
  694. .chat-title strong {
  695. font-size: var(--fs-15);
  696. color: var(--ink);
  697. }
  698. .chat-title small {
  699. font-size: var(--fs-12);
  700. color: var(--muted);
  701. }
  702. .banner-hold {
  703. padding: var(--sp-3) var(--sp-4) 0;
  704. }
  705. .loop-detail {
  706. margin: 0 0 var(--sp-2);
  707. font-size: var(--fs-12);
  708. color: var(--ink-2);
  709. }
  710. .messages {
  711. flex: 1;
  712. min-height: 0;
  713. overflow-y: auto;
  714. padding: var(--sp-4);
  715. display: grid;
  716. gap: var(--sp-3);
  717. align-content: start;
  718. }
  719. .history-hold {
  720. display: flex;
  721. justify-content: center;
  722. padding: var(--sp-1) 0;
  723. }
  724. .history-end {
  725. font-size: var(--fs-12);
  726. color: var(--muted);
  727. }
  728. .skeleton-hold {
  729. display: grid;
  730. gap: var(--sp-2);
  731. }
  732. .mention-bar {
  733. padding: var(--sp-2) var(--sp-4);
  734. border-top: 1px solid var(--edge);
  735. background: var(--surface-2);
  736. }
  737. .mention-hint {
  738. font-size: var(--fs-12);
  739. color: var(--muted);
  740. }
  741. .mention-chips {
  742. display: flex;
  743. flex-wrap: wrap;
  744. gap: var(--sp-2);
  745. margin-top: var(--sp-2);
  746. }
  747. .mention-chip {
  748. min-height: 28px;
  749. padding: 0 var(--sp-3);
  750. border: 1px solid var(--edge);
  751. border-radius: var(--r-tag);
  752. background: var(--surface);
  753. color: var(--ink-2);
  754. font-size: var(--fs-12);
  755. cursor: pointer;
  756. }
  757. .mention-chip.on {
  758. border-color: var(--primary);
  759. background: var(--primary-subtle);
  760. color: var(--primary);
  761. }
  762. .composer-hold {
  763. border-top: 1px solid var(--edge);
  764. padding: var(--sp-3) var(--sp-4);
  765. }
  766. .target-warn.critical {
  767. background: var(--danger-subtle);
  768. color: var(--danger);
  769. }
  770. .warn-action {
  771. margin-left: auto;
  772. flex-shrink: 0;
  773. min-height: 24px;
  774. padding: 0 var(--sp-2);
  775. border: 1px solid currentColor;
  776. border-radius: var(--r-sm);
  777. background: transparent;
  778. color: inherit;
  779. font-size: var(--fs-11);
  780. cursor: pointer;
  781. }
  782. .mention-suggest {
  783. display: grid;
  784. gap: 2px;
  785. margin-bottom: var(--sp-2);
  786. padding: var(--sp-1);
  787. border: 1px solid var(--edge);
  788. border-radius: var(--r-md);
  789. background: var(--surface);
  790. box-shadow: var(--shadow-pop);
  791. }
  792. .suggest-item {
  793. display: flex;
  794. align-items: baseline;
  795. gap: var(--sp-2);
  796. width: 100%;
  797. min-height: 32px;
  798. padding: 0 var(--sp-2);
  799. border: 0;
  800. border-radius: var(--r-sm);
  801. background: transparent;
  802. color: var(--ink);
  803. text-align: left;
  804. cursor: pointer;
  805. }
  806. .suggest-item:hover {
  807. background: var(--surface-hover);
  808. }
  809. .suggest-item strong {
  810. font-size: var(--fs-13);
  811. }
  812. .suggest-item small {
  813. font-size: var(--fs-11);
  814. color: var(--muted);
  815. }
  816. .head-actions {
  817. display: flex;
  818. align-items: center;
  819. gap: var(--sp-1);
  820. }
  821. .role-hint.err {
  822. color: var(--danger);
  823. }
  824. .role-hint.warn-text {
  825. display: flex;
  826. gap: var(--sp-2);
  827. align-items: flex-start;
  828. color: var(--warning);
  829. line-height: 1.5;
  830. }
  831. .role-hint.warn-text i {
  832. margin-top: 2px;
  833. flex: none;
  834. }
  835. .target-warn {
  836. display: flex;
  837. align-items: center;
  838. gap: var(--sp-2);
  839. margin-bottom: var(--sp-2);
  840. padding: var(--sp-2) var(--sp-3);
  841. border-radius: var(--r-sm);
  842. background: var(--warning-subtle);
  843. color: var(--warning);
  844. font-size: var(--fs-12);
  845. line-height: 1.5;
  846. }
  847. .ctx-toggle {
  848. display: inline-flex;
  849. align-items: center;
  850. gap: var(--sp-1);
  851. margin-bottom: var(--sp-2);
  852. font-size: var(--fs-12);
  853. color: var(--muted);
  854. cursor: pointer;
  855. }
  856. .mention-preview {
  857. display: inline-flex;
  858. align-items: center;
  859. gap: var(--sp-1);
  860. padding: 2px var(--sp-2);
  861. margin-bottom: var(--sp-2);
  862. border-radius: var(--r-tag);
  863. background: var(--primary-subtle);
  864. color: var(--primary);
  865. font-size: var(--fs-12);
  866. }
  867. .mention-preview .clear {
  868. border: 0;
  869. background: transparent;
  870. color: inherit;
  871. cursor: pointer;
  872. }
  873. .composer-row {
  874. display: flex;
  875. align-items: flex-end;
  876. gap: var(--sp-2);
  877. }
  878. .input {
  879. flex: 1;
  880. min-height: 40px;
  881. max-height: 160px;
  882. padding: var(--sp-2) var(--sp-3);
  883. border: 1px solid var(--edge);
  884. border-radius: var(--r-md);
  885. background: var(--surface);
  886. color: var(--ink);
  887. font: inherit;
  888. resize: none;
  889. }
  890. /* 跨主人协作提示(不是错误,是「提前说明」,所以用 warning 而非 danger) */
  891. /* 发送目标必选提示 */
  892. .target-required {
  893. display: grid;
  894. gap: var(--sp-2);
  895. margin: var(--sp-2) var(--sp-4) 0;
  896. padding: var(--sp-2) var(--sp-3);
  897. border: 1px solid var(--warning);
  898. border-radius: var(--r-md);
  899. background: var(--warning-subtle);
  900. font-size: var(--fs-12);
  901. line-height: 1.5;
  902. color: var(--ink-2);
  903. }
  904. .target-required > i {
  905. display: none;
  906. }
  907. .target-required span {
  908. display: flex;
  909. gap: var(--sp-2);
  910. }
  911. .target-quick {
  912. display: flex;
  913. gap: var(--sp-2);
  914. flex-wrap: wrap;
  915. }
  916. .cross-owner-note {
  917. display: flex;
  918. gap: var(--sp-2);
  919. align-items: flex-start;
  920. margin-bottom: var(--sp-3);
  921. padding: var(--sp-2) var(--sp-3);
  922. border: 1px solid var(--warning);
  923. border-radius: var(--r-md);
  924. background: var(--warning-subtle);
  925. font-size: var(--fs-12);
  926. line-height: 1.5;
  927. color: var(--ink-2);
  928. }
  929. .cross-owner-note i {
  930. margin-top: 2px;
  931. flex: none;
  932. color: var(--warning);
  933. }
  934. .cross-owner-note span {
  935. min-width: 0;
  936. word-break: break-word;
  937. }
  938. .member-list {
  939. flex: 1;
  940. min-height: 0;
  941. overflow-y: auto;
  942. padding: var(--sp-3);
  943. display: grid; gap: var(--sp-2);
  944. align-content: start;
  945. }
  946. .member-line {
  947. display: flex;
  948. align-items: center;
  949. gap: var(--sp-1);
  950. min-width: 0;
  951. }
  952. .role-tag {
  953. flex-shrink: 0;
  954. padding: 0 5px;
  955. border-radius: var(--r-tag);
  956. background: var(--surface-hover);
  957. color: var(--muted);
  958. font-size: var(--fs-11);
  959. line-height: 16px;
  960. }
  961. .role-tag.warn {
  962. background: var(--warning-subtle);
  963. color: var(--warning);
  964. }
  965. .role-tag.leader {
  966. background: var(--primary-subtle);
  967. color: var(--primary);
  968. font-weight: 600;
  969. }
  970. /* 组长置顶并加左侧色条,用户一眼知道找谁 */
  971. .role-editor {
  972. position: fixed;
  973. inset: 0;
  974. display: grid;
  975. place-items: center;
  976. background: var(--overlay);
  977. z-index: var(--z-modal);
  978. padding: var(--sp-4);
  979. }
  980. .role-card {
  981. width: min(400px, 92vw);
  982. padding: var(--sp-5);
  983. border-radius: var(--r-lg);
  984. background: var(--surface);
  985. box-shadow: var(--shadow-overlay);
  986. display: grid;
  987. gap: var(--sp-3);
  988. }
  989. .role-card h3 {
  990. margin: 0;
  991. font-size: var(--fs-17);
  992. color: var(--ink);
  993. }
  994. .role-field {
  995. display: grid;
  996. gap: var(--sp-1);
  997. font-size: var(--fs-13);
  998. color: var(--ink-2);
  999. }
  1000. .role-field input {
  1001. min-height: var(--touch-min);
  1002. padding: 0 var(--sp-3);
  1003. border: 1px solid var(--edge);
  1004. border-radius: var(--r-md);
  1005. background: var(--surface);
  1006. color: var(--ink);
  1007. font: inherit;
  1008. }
  1009. .role-check {
  1010. display: flex;
  1011. align-items: center;
  1012. gap: var(--sp-2);
  1013. font-size: var(--fs-13);
  1014. color: var(--ink-2);
  1015. }
  1016. .role-hint {
  1017. margin: 0;
  1018. font-size: var(--fs-12);
  1019. color: var(--muted);
  1020. line-height: 1.6;
  1021. }
  1022. .role-actions {
  1023. display: flex;
  1024. justify-content: flex-end;
  1025. gap: var(--sp-2);
  1026. }
  1027. .kv {
  1028. margin: 0 0 var(--sp-3);
  1029. display: grid;
  1030. gap: var(--sp-2);
  1031. }
  1032. .kv > div {
  1033. display: flex;
  1034. justify-content: space-between;
  1035. gap: var(--sp-3);
  1036. font-size: var(--fs-12);
  1037. }
  1038. .kv dt {
  1039. color: var(--muted);
  1040. flex-shrink: 0;
  1041. }
  1042. .kv dd {
  1043. margin: 0;
  1044. color: var(--ink);
  1045. text-align: right;
  1046. min-width: 0;
  1047. overflow-wrap: anywhere;
  1048. }
  1049. .material-head {
  1050. margin-top: var(--sp-3);
  1051. padding-top: var(--sp-3);
  1052. border-top: 1px solid var(--edge);
  1053. }
  1054. .material-head h3 {
  1055. margin: 0 0 var(--sp-2);
  1056. font-size: var(--fs-12);
  1057. color: var(--ink-2);
  1058. }
  1059. .material-row {
  1060. display: flex;
  1061. align-items: center;
  1062. gap: var(--sp-2);
  1063. width: 100%;
  1064. min-height: 44px;
  1065. padding: var(--sp-2);
  1066. margin-bottom: var(--sp-2);
  1067. border: 1px solid var(--edge);
  1068. border-radius: var(--r-md);
  1069. background: var(--surface);
  1070. text-align: left;
  1071. cursor: pointer;
  1072. font: inherit;
  1073. }
  1074. .material-row:hover {
  1075. background: var(--surface-hover);
  1076. }
  1077. .member-row {
  1078. width: 100%;
  1079. border: 0;
  1080. text-align: left;
  1081. cursor: pointer;
  1082. font: inherit;
  1083. }
  1084. .member-row:hover {
  1085. background: var(--surface-hover);
  1086. }
  1087. .member-row.is-leader {
  1088. border-left: 3px solid var(--primary);
  1089. }
  1090. .member-row {
  1091. display: flex;
  1092. align-items: center;
  1093. gap: var(--sp-2);
  1094. padding: var(--sp-2);
  1095. border-radius: var(--r-md);
  1096. background: var(--surface);
  1097. }
  1098. .member-meta {
  1099. display: grid;
  1100. gap: 1px;
  1101. flex: 1;
  1102. min-width: 0;
  1103. }
  1104. .member-meta strong {
  1105. font-size: var(--fs-13);
  1106. color: var(--ink);
  1107. }
  1108. .member-meta small {
  1109. font-size: var(--fs-11);
  1110. color: var(--muted);
  1111. }
  1112. .dot {
  1113. width: 6px;
  1114. height: 6px;
  1115. border-radius: 50%;
  1116. background: var(--edge-strong);
  1117. flex-shrink: 0;
  1118. }
  1119. .dot.busy {
  1120. background: var(--success);
  1121. animation: pulse-dot 1.4s var(--ease-standard) infinite;
  1122. }
  1123. @keyframes pulse-dot {
  1124. 0%,
  1125. 100% {
  1126. opacity: 1;
  1127. }
  1128. 50% {
  1129. opacity: 0.35;
  1130. }
  1131. }
  1132. @media (prefers-reduced-motion: reduce) {
  1133. .dot.busy {
  1134. animation: none;
  1135. }
  1136. }
  1137. .empty-hint {
  1138. margin: 0;
  1139. font-size: var(--fs-12);
  1140. color: var(--muted);
  1141. }
  1142. .ellipsis {
  1143. overflow: hidden;
  1144. text-overflow: ellipsis;
  1145. white-space: nowrap;
  1146. }
  1147. `,
  1148. ],
  1149. })
  1150. export class DkGroupPage {
  1151. readonly groups = inject(GroupsService);
  1152. readonly roles = inject(GroupRolesService);
  1153. private readonly deliverables = inject(DeliverablesService);
  1154. private readonly live = inject(LiveService);
  1155. private readonly chat = inject(ChatService);
  1156. private readonly mx = inject(MatrixClientService);
  1157. private readonly session = inject(SessionService);
  1158. private readonly api = inject(FmodeApi);
  1159. private readonly route = inject(ActivatedRoute);
  1160. private readonly router = inject(Router);
  1161. private readonly device = inject(DeviceService);
  1162. readonly roomId = signal<string | null>(null);
  1163. readonly messages = signal<Message[]>([]);
  1164. readonly loading = signal(false);
  1165. readonly sending = signal(false);
  1166. readonly draft = signal('');
  1167. /**
  1168. * 发送被拦下(没选 @ 对象)。
  1169. *
  1170. * 群里有多个容器时,不指定对象等于「谁都可能响应」——
  1171. * 实测量产 容器 `REQUIRE_MENTION=false` 时所有容器都会处理。
  1172. * 所以**必须先选人**,这里负责给出清楚的理由。
  1173. */
  1174. readonly sendBlocked = signal(false);
  1175. /**
  1176. * 上一次派活的抢答提示(null = 没有)。
  1177. *
  1178. * 实测:@ lj-test 派活,执行的是 agent-lj(对方 REQUIRE_MENTION=false)。
  1179. * 不提示的话用户会以为「组员干了活」,实际组内根本没有分工。
  1180. */
  1181. readonly hijackHintText = signal<string | null>(null);
  1182. /** 上一次 @ 的目标与时间(用于检测回复归属) */
  1183. private lastDispatch: { expected: string[]; at: number } | null = null;
  1184. readonly membersOpen = signal(true);
  1185. readonly mentionOpen = signal(false);
  1186. /** 打字时的 @ 补全状态 */
  1187. private readonly mentionQuery = signal({ active: false, query: '', start: -1 });
  1188. /**
  1189. * 发送时是否附带最近对话。
  1190. *
  1191. * 🔴 2026-10-27 改为**默认关闭**。
  1192. *
  1193. * 🔴 2026-10-27 改回**默认开启**。
  1194. *
  1195. * 实测(用户反馈):关掉后容器完全没有上下文 ——
  1196. * 你问「做个 Excel 表格」→ 它反问一堆 → 你说「随机即可」→ 它答「不知道你在说什么」。
  1197. *
  1198. * 组长制并不能替代上下文:组长也是容器,同样没有跨消息记忆。
  1199. * 所以**始终带上最近对话**(2026-11-01 用户要求删掉开关):
  1200. * 关掉后的表现是「答非所问」,等于坏掉,没有保留成选项的意义。
  1201. */
  1202. readonly attachContext = true;
  1203. readonly mentionTargets = signal<Set<string>>(new Set());
  1204. /** 上滚加载历史中(防重入) */
  1205. readonly paginating = signal(false);
  1206. /** 还有更早的历史 */
  1207. readonly hasMore = signal(false);
  1208. private readonly messagesEl = viewChild<ElementRef<HTMLElement>>('msgList');
  1209. readonly currentRoom = computed(() =>
  1210. this.groups.rooms().find((r) => r.roomId === this.roomId()),
  1211. );
  1212. /** 群内 agent 成员(已映射回平台 Agent,能显示状态) */
  1213. readonly members = computed(() => {
  1214. const id = this.roomId();
  1215. return id ? this.groups.membersOf()(id) : [];
  1216. });
  1217. /**
  1218. * 跨主人协作风险。
  1219. *
  1220. * 容器**只认自己的主人** —— 组里混了别人的容器时,组长 @ 它不会执行。
  1221. * App 改不了这个限制(需容器侧实现授权表),但必须**提前说清楚**,
  1222. * 别让用户派活失败后才发现。
  1223. */
  1224. readonly crossOwner = computed(() =>
  1225. detectCrossOwner(
  1226. this.members().map((a) => ({ id: a.id, name: a.name, ownerId: a.owner?.id })),
  1227. this.session.me()?.id,
  1228. ),
  1229. );
  1230. /** 跨主人提示文案(无风险时 null) */
  1231. readonly crossOwnerHint = computed(() => crossOwnerWarning(this.crossOwner()));
  1232. /** 建组弹窗里的跨主人提示(按已选成员实时算) */
  1233. readonly newCrossOwnerHint = computed(() => {
  1234. const picked = this.candidateAgents().filter((a) => this.newMembers().has(a.id));
  1235. return crossOwnerWarning(
  1236. detectCrossOwner(
  1237. picked.map((a) => ({ id: a.id, name: a.name, ownerId: a.owner?.id })),
  1238. this.session.me()?.id,
  1239. ),
  1240. );
  1241. });
  1242. /**
  1243. * 被 @ 成员的可用性。
  1244. *
  1245. * 实测:容器忙时消息会排队(6/6 探针在空闲时全成功)。
  1246. * 这里不是要拦截发送,是**告诉用户等一会儿**。
  1247. */
  1248. /**
  1249. * 组长健康度。
  1250. *
  1251. * 组长制下所有需求都走组长,掉线会让整组停摆 —— 必须让用户知道。
  1252. */
  1253. /**
  1254. * 组长派活后,组员是否卡住了。
  1255. *
  1256. * 只做提示,不自动重发 —— 重发可能让组员做两遍。
  1257. */
  1258. readonly memberStatus = computed<MemberTaskStatus>(() => {
  1259. const rid = this.roomId();
  1260. if (!rid) return { state: 'idle', waitedMs: 0, hint: '' };
  1261. const leaderMx = this.roles.leaderOf()(rid)?.channels?.matrixConfig?.userId;
  1262. if (!leaderMx) return { state: 'idle', waitedMs: 0, hint: '' };
  1263. // 检查每个组员,取最严重的
  1264. let worst: MemberTaskStatus = { state: 'idle', waitedMs: 0, hint: '' };
  1265. for (const a of this.members()) {
  1266. if (a.id === this.roles.leaderOf()(rid)?.id) continue;
  1267. const mx = a.channels?.matrixConfig?.userId;
  1268. if (!mx) continue;
  1269. const st = memberTaskStatus(this.messages(), leaderMx, mx, Date.now(), a.name);
  1270. if (st.state === 'stuck') return st;
  1271. if (st.state === 'slow') worst = st;
  1272. }
  1273. return worst;
  1274. });
  1275. /**
  1276. * 派活是否真的送到了组员手上。
  1277. *
  1278. * 实测坑:组长说「派活消息已发到群里」,但那条消息只在文字里写了 @名字,
  1279. * 没有 `m.mentions` 结构 → 组员不认为被叫到 → 40 分钟都没开工。
  1280. */
  1281. readonly dispatchIssue = computed(() => {
  1282. const rid = this.roomId();
  1283. if (!rid) return null;
  1284. const leader = this.roles.leaderOf()(rid);
  1285. const leaderMx = leader?.channels?.matrixConfig?.userId;
  1286. if (!leaderMx) return null;
  1287. for (const a of this.members()) {
  1288. if (a.id === leader?.id) continue;
  1289. const mx = a.channels?.matrixConfig?.userId;
  1290. if (!mx) continue;
  1291. const { msg, check } = findLastDispatch(this.messages(), leaderMx, mx);
  1292. // 只有「派了但没送到」且组员确实没回时才提示
  1293. if (!msg || !check.needsResend) continue;
  1294. const st = memberTaskStatus(this.messages(), leaderMx, mx, Date.now(), a.name);
  1295. if (st.state === 'stuck' || st.state === 'slow') {
  1296. return { member: a.name, detail: check.detail };
  1297. }
  1298. }
  1299. return null;
  1300. });
  1301. readonly leaderStatus = computed(() => {
  1302. const rid = this.roomId();
  1303. if (!rid) return leaderHealth(undefined);
  1304. return leaderHealth(this.roles.leaderOf()(rid));
  1305. });
  1306. /**
  1307. * 组长是否真的在群里。
  1308. *
  1309. * 🔴 实测:组长 `lj` 报告「派活已发到群里」,但查群成员发现它压根不在 ——
  1310. * 它在自己的私聊房间里自言自语,40 分钟组员没开工。
  1311. * 现有健康检查只看 status,发现不了这个问题。
  1312. */
  1313. /** 实时成员(SDK 缓存可能是旧的) */
  1314. private readonly liveMemberIds = signal<string[]>([]);
  1315. /** 用户是否主动上翻离开了底部 */
  1316. private userScrolledAway = false;
  1317. /**
  1318. * 刷新实时成员。
  1319. *
  1320. * 🔴 不能只在进群时拉一次 —— 成员会变(组长被踢、新成员加入),
  1321. * 拉一次的话状态会一直停在旧值(实测:组长已重新入群,App 仍提示不在)。
  1322. */
  1323. private async refreshMembers(roomId: string): Promise<void> {
  1324. const ids = await this.mx.fetchRoomMembers(roomId);
  1325. if (ids.length === 0 || this.roomId() !== roomId) return;
  1326. this.liveMemberIds.set(ids);
  1327. // 🔴 同时回写群列表,让 members() 也拿到实时成员
  1328. // (此前只更新 liveMemberIds,members() 仍读 SDK 缓存的旧值 → 只显示 1 人)
  1329. this.groups.patchRoomMembers(roomId, ids);
  1330. }
  1331. readonly leaderMembership = computed(() => {
  1332. const rid = this.roomId();
  1333. if (!rid) return diagnoseLeader(undefined, [], 0);
  1334. const room = this.groups.rooms().find((r) => r.roomId === rid);
  1335. // 优先用实时拉到的成员,回落到列表里的(可能是旧的)
  1336. const memberIds = this.liveMemberIds().length > 0 ? this.liveMemberIds() : (room?.memberIds ?? []);
  1337. const leader = this.roles.leaderOf()(rid);
  1338. const leaderMx = leader?.channels?.matrixConfig?.userId;
  1339. const spoke = leaderMx
  1340. ? this.messages().filter((m) => m.senderId === leaderMx).length
  1341. : 0;
  1342. return diagnoseLeader(leader, memberIds, spoke);
  1343. });
  1344. readonly targetStatus = computed(() => {
  1345. const ids = this.mentionTargets();
  1346. const picked = this.members().filter((a) => ids.has(a.id));
  1347. return availabilityOf(picked);
  1348. });
  1349. readonly mentionLabel = computed(() => {
  1350. const ids = this.mentionTargets();
  1351. return this.members()
  1352. .filter((a) => ids.has(a.id))
  1353. .map((a) => '@' + a.name)
  1354. .join(' ');
  1355. });
  1356. constructor() {
  1357. this.route.paramMap.subscribe((p) => {
  1358. const id = p.get('id');
  1359. if (id) void this.open(id);
  1360. else {
  1361. const first = this.groups.rooms()[0];
  1362. if (first) void this.open(first.roomId);
  1363. }
  1364. });
  1365. // 同步到新消息时重读群会话 + 刷新成员
  1366. effect(() => {
  1367. this.mx.storeVersion();
  1368. const rid = this.roomId();
  1369. if (!rid) return;
  1370. this.loadMessages(rid);
  1371. void this.refreshMembers(rid);
  1372. });
  1373. /*
  1374. * 消息变了就贴底。
  1375. *
  1376. * 🔴 不能只在 `open()` 里滚一次 —— 进群那一刻消息往往还没渲染出来,
  1377. * 此时 `scrollToBottom` 量到的高度是 0,等于没滚
  1378. * (实测:进群后距底 6275px)。
  1379. * 改为监听消息列表:尾部变化就滚,且与单聊同样的三段式。
  1380. */
  1381. effect(() => {
  1382. const list = this.messages();
  1383. const tail = list.length ? list[list.length - 1].id : '';
  1384. if (tail === this.lastTailId) return;
  1385. this.lastTailId = tail;
  1386. if (!tail) return;
  1387. /*
  1388. * 🔴 必须等 DOM 真正渲染完再滚。
  1389. * 实测:effect 里同步调用时元素已存在但 `scrollTop` 被后续渲染重置回 0,
  1390. * 结果是「滚了等于没滚」(距底仍 6275px)。
  1391. * 改用 `afterNextRender` 的等价写法:等两帧 + 补一次异步内容。
  1392. */
  1393. queueMicrotask(() => {
  1394. requestAnimationFrame(() => {
  1395. this.scrollToBottom();
  1396. requestAnimationFrame(() => {
  1397. this.scrollToBottom();
  1398. setTimeout(() => this.scrollToBottom(), 150);
  1399. });
  1400. });
  1401. });
  1402. });
  1403. }
  1404. /** 上一次渲染的最后一条消息 id(用于区分尾部新增与头部插入) */
  1405. private lastTailId = '';
  1406. async open(roomId: string): Promise<void> {
  1407. this.roomId.set(roomId);
  1408. this.groups.enter(roomId);
  1409. this.mentionTargets.set(new Set());
  1410. this.loading.set(true);
  1411. // 路由同步(可直接分享链接)
  1412. void this.router.navigateByUrl(`/${this.device.mode()}/groups/${encodeURIComponent(roomId)}`, {
  1413. replaceUrl: this.route.snapshot.paramMap.get('id') === roomId,
  1414. });
  1415. this.loadMessages(roomId);
  1416. this.hasMore.set(this.mx.paginationToken(roomId) != null);
  1417. // 实时拉成员,避免用 SDK 缓存误判「组长在不在群」
  1418. void this.refreshMembers(roomId);
  1419. this.loading.set(false);
  1420. this.stickToBottom();
  1421. }
  1422. /** Matrix ID → 展示名(群聊气泡里把 mentions 显示成人能读的名字) */
  1423. readonly mxName = (mxId: string): string => {
  1424. for (const a of this.live.agents()) {
  1425. if (a.channels?.matrixConfig?.userId === mxId) return a.name;
  1426. }
  1427. return mxId.replace(/^@/, '').split(':')[0];
  1428. };
  1429. // ---------- 新建工作组 ----------
  1430. readonly creating = signal(false);
  1431. readonly newName = signal('');
  1432. readonly newGoal = signal('');
  1433. readonly newMembers = signal<Set<string>>(new Set());
  1434. readonly creatingBusy = signal(false);
  1435. readonly createError = signal('');
  1436. /** 候选人:已接入 Matrix 的容器(同公司限制由服务端再校验) */
  1437. readonly candidateAgents = computed(() =>
  1438. this.live.agents().filter((a) => a.matrixReady),
  1439. );
  1440. openCreate(): void {
  1441. this.creating.set(true);
  1442. this.newName.set('');
  1443. this.newGoal.set('');
  1444. this.newMembers.set(new Set());
  1445. this.createError.set('');
  1446. }
  1447. toggleNewMember(a: Agent): void {
  1448. this.newMembers.update((s) => {
  1449. const next = new Set(s);
  1450. if (next.has(a.id)) next.delete(a.id);
  1451. else next.add(a.id);
  1452. return next;
  1453. });
  1454. }
  1455. async submitCreate(): Promise<void> {
  1456. const name = this.newName().trim();
  1457. const ids = [...this.newMembers()];
  1458. if (!name || ids.length === 0 || this.creatingBusy()) return;
  1459. this.creatingBusy.set(true);
  1460. this.createError.set('');
  1461. try {
  1462. const { roomId } = await this.api.createWorkGroup({
  1463. name,
  1464. goal: this.newGoal().trim(),
  1465. memberAgentIds: ids,
  1466. });
  1467. this.creating.set(false);
  1468. // 建好后立刻刷新群列表并进入
  1469. await new Promise((r) => setTimeout(r, 1500)); // 等成员 join
  1470. await this.groups.refreshFromServer();
  1471. await this.open(roomId);
  1472. /*
  1473. * 🔴 建组后自动下发角色提示词。
  1474. *
  1475. * 实测:组长制能跑通,靠的是给它发过职责指令。
  1476. * 新容器不知道规则就不会统筹 —— 不自动下发的话,用户还得手动复制一遍。
  1477. *
  1478. * 第一个成员默认当组长(可在成员栏改),其余是组员。
  1479. */
  1480. await this.briefRoles(roomId, ids);
  1481. } catch (e) {
  1482. // 服务端会给出明确原因(非管理员 / 跨公司等),如实展示
  1483. this.createError.set(e instanceof Error ? e.message : '创建失败');
  1484. } finally {
  1485. this.creatingBusy.set(false);
  1486. }
  1487. }
  1488. /**
  1489. * 建组后给容器下发角色提示词。
  1490. *
  1491. * 失败不阻塞建组(提示词只是「告知」,不影响群本身可用)。
  1492. */
  1493. private async briefRoles(roomId: string, ids: string[]): Promise<void> {
  1494. /*
  1495. * 🔴 不能用 `this.members()` —— 它依赖 Matrix 房间的成员同步,
  1496. * 刚建的群还没同步完,拿到的是空数组(实测:提示词一条没发出去)。
  1497. * 直接用建组时选中的 ids 去平台数据里取。
  1498. */
  1499. const all = ids
  1500. .map((id) => this.live.agentById(id))
  1501. .filter((a): a is Agent => !!a);
  1502. const leaderId = ids[0];
  1503. const leader = all.find((a) => a.id === leaderId);
  1504. const memberMs = all
  1505. .filter((a) => a.id !== leaderId)
  1506. .map((a) => a.channels?.matrixConfig?.userId)
  1507. .filter((x): x is string => !!x);
  1508. const leaderMx = leader?.channels?.matrixConfig?.userId;
  1509. try {
  1510. // 组长:下发组长职责
  1511. if (leaderMx) {
  1512. await this.mx.sendMention(roomId, leaderMx, LEADER_PROMPT, leader?.name);
  1513. // 同时记下组长(成员栏显示「组长」标记)
  1514. await this.roles.save(roomId, { ...this.roles.rolesOf()(roomId), leader: leaderId, version: 1 });
  1515. }
  1516. // 组员:下发组员职责
  1517. if (memberMs.length > 0) {
  1518. await this.mx.sendMentionMany(
  1519. roomId,
  1520. memberMs,
  1521. MEMBER_PROMPT,
  1522. all.filter((a) => a.id !== leaderId).map((a) => a.name),
  1523. );
  1524. }
  1525. } catch {
  1526. // 提示词下发失败不影响群可用,用户可手动发送
  1527. }
  1528. }
  1529. /**
  1530. * 群聊是否附带最近对话 —— **已固定为总是带上**(2026-11-01 用户要求删掉该开关)。
  1531. *
  1532. * 原先是设置页的一个开关(localStorage `fmode_group_attach_context`)。
  1533. * 保留这个常量是为了不动 `buildContext(..., { enabled })` 的调用签名;
  1534. * 关掉后的表现是「容器答非所问」,等于功能坏掉,不值得作为选项保留。
  1535. */
  1536. /** 正在编辑角色的成员 */
  1537. readonly editing = signal<Agent | null>(null);
  1538. readonly dutyDraft = signal('');
  1539. readonly savingRole = signal(false);
  1540. openRoleEditor(a: Agent): void {
  1541. this.editing.set(a);
  1542. const rid = this.roomId();
  1543. this.dutyDraft.set(rid ? this.roles.dutyOf()(rid, a.id) : '');
  1544. }
  1545. toggleLeader(agentId: string): void {
  1546. const rid = this.roomId();
  1547. if (!rid) return;
  1548. // 再点一次取消组长(原组长置空)
  1549. const cur = this.isLeader(agentId);
  1550. void this.roles.save(rid, {
  1551. ...this.roles.rolesOf()(rid),
  1552. leader: cur ? undefined : agentId,
  1553. version: 1,
  1554. });
  1555. }
  1556. async saveRole(): Promise<void> {
  1557. const rid = this.roomId();
  1558. const a = this.editing();
  1559. if (!rid || !a || this.savingRole()) return;
  1560. this.savingRole.set(true);
  1561. try {
  1562. await this.roles.setDuty(rid, a.id, this.dutyDraft().trim());
  1563. this.editing.set(null);
  1564. } finally {
  1565. this.savingRole.set(false);
  1566. }
  1567. }
  1568. /** @ 补全候选(边打边筛) */
  1569. readonly mentionSuggestions = computed(() => {
  1570. const q = this.mentionQuery();
  1571. if (!q.active) return [];
  1572. return filterMentionCandidates(
  1573. this.members(),
  1574. q.query,
  1575. (a) => a.name,
  1576. (a) => (this.roomId() ? this.roles.dutyOf()(this.roomId()!, a.id) : ''),
  1577. ).slice(0, 6);
  1578. });
  1579. /** 选中候选:把 @片段 换成完整名字 */
  1580. pickMention(a: Agent): void {
  1581. const input = document.querySelector<HTMLTextAreaElement>('.input');
  1582. const text = this.draft();
  1583. const cursor = input?.selectionStart ?? text.length;
  1584. const r = applyMention(text, this.mentionQuery(), a.name, cursor);
  1585. this.draft.set(r.text);
  1586. this.mentionQuery.set({ active: false, query: '', start: -1 });
  1587. // 同时把它加进「被 @ 的成员」,发送时才带 mention 结构
  1588. if (!this.mentionTargets().has(a.id)) {
  1589. this.mentionTargets.update((s) => new Set(s).add(a.id));
  1590. }
  1591. // 恢复焦点与光标
  1592. queueMicrotask(() => {
  1593. if (!input) return;
  1594. input.value = r.text;
  1595. input.focus();
  1596. input.setSelectionRange(r.cursor, r.cursor);
  1597. });
  1598. }
  1599. /** 组长置顶 */
  1600. readonly membersSorted = computed(() => {
  1601. const rid = this.roomId();
  1602. const list = this.members();
  1603. if (!rid) return list;
  1604. const leader = this.roles.leaderOf()(rid)?.id;
  1605. return [...list].sort((a, b) => Number(b.id === leader) - Number(a.id === leader));
  1606. });
  1607. leaderName(): string {
  1608. const rid = this.roomId();
  1609. return rid ? (this.roles.leaderOf()(rid)?.name ?? '') : '';
  1610. }
  1611. /**
  1612. * 该成员是否已授权接受组长指令。
  1613. *
  1614. * 容器默认只认自己的主人;跨主人的组员没授权时,组长 @ 它也不会执行。
  1615. */
  1616. isAuthorized(agentId: string): boolean {
  1617. const rid = this.roomId();
  1618. if (!rid) return true;
  1619. const roles = this.roles.rolesOf()(rid);
  1620. // 没设置 authorized 字段时视为「同主人」,不误报
  1621. if (!roles.authorized) return true;
  1622. return roles.authorized.includes(agentId);
  1623. }
  1624. /**
  1625. * 成员「现在在干什么」。
  1626. *
  1627. * 🔴 只看 status 会误报:容器上报 running 但 currentTask 为空是常态。
  1628. * 有具体任务才显示「执行中 · <任务>」,否则按职责/空闲显示。
  1629. */
  1630. statusLineOf(a: Agent): string {
  1631. if (a.status === 'offline' || a.status === 'stopped') return '离线';
  1632. if (a.status === 'error') return '异常';
  1633. const task = a.currentTask?.trim();
  1634. if (a.status === 'running' && task) return `执行中 · ${task}`;
  1635. const rid = this.roomId();
  1636. const duty = rid ? this.roles.dutyOf()(rid, a.id) : '';
  1637. return duty ? `空闲 · ${duty}` : '空闲';
  1638. }
  1639. /**
  1640. * 本群产出的资料。
  1641. *
  1642. * 口径:本群成员的成果(按 agentId 过滤已加载的成果列表)。
  1643. * 与「成果」页同源,不另发请求。
  1644. */
  1645. /**
  1646. * 本群资料 —— **两个来源合并**。
  1647. *
  1648. * 🔴 只用「成果」会让资料区空着(实测):
  1649. * 容器常在群里直接发文件(m.file),那是聊天附件,不是成果。
  1650. * 两种都要收,并按 URL 去重。
  1651. */
  1652. readonly groupMaterials = computed<Material[]>(() => {
  1653. const ids = new Set(this.members().map((a) => a.id));
  1654. const rid = this.roomId();
  1655. const leader = rid ? this.roles.leaderOf()(rid) : undefined;
  1656. if (leader) ids.add(leader.id);
  1657. // 来源 ①:成果
  1658. const fromDeliverables = this.deliverables
  1659. .all()
  1660. .filter((d) => ids.has(d.agentId))
  1661. .map(fromDeliverable)
  1662. .filter((x): x is Material => !!x);
  1663. // 来源 ②:本群的聊天附件
  1664. const fromMessages = this.messages()
  1665. .map(fromAttachment)
  1666. .filter((x): x is Material => !!x);
  1667. return mergeMaterials([...fromDeliverables, ...fromMessages]);
  1668. });
  1669. /** 组长 Agent(运行状态块用;与单聊右侧同结构) */
  1670. readonly leaderAgent = computed<Agent | undefined>(() => {
  1671. const rid = this.roomId();
  1672. return rid ? this.roles.leaderOf()(rid) : undefined;
  1673. });
  1674. readonly pct = (v?: number) => fmtPercent(v);
  1675. readonly tokens = (a: { usage?: { totalTokens?: number } }) => fmtTokens(usageOf(a as never).totalTokens);
  1676. openGroupMaterial(m: Material): void {
  1677. this.openFile({ url: m.url, name: m.filename || m.title, mimeType: '' });
  1678. }
  1679. materialIcon(d: Material): string {
  1680. const t = (d.fileType || '').toUpperCase();
  1681. if (t === 'HTML') return 'fa-file-code';
  1682. if (t === 'PNG' || t === 'JPG' || t === 'JPEG') return 'fa-file-image';
  1683. if (t === 'PDF') return 'fa-file-pdf';
  1684. if (t === 'CSV' || t === 'XLSX') return 'fa-file-csv';
  1685. return 'fa-file-lines';
  1686. }
  1687. /** 是否真的在忙(有任务才算,避免误报) */
  1688. isReallyBusy(a: Agent): boolean {
  1689. return a.status === 'running' && !!a.currentTask?.trim();
  1690. }
  1691. isLeader(agentId: string): boolean {
  1692. const rid = this.roomId();
  1693. return rid ? this.roles.leaderOf()(rid)?.id === agentId : false;
  1694. }
  1695. memberNames(roomId: string): string {
  1696. const list = this.groups.membersOf()(roomId);
  1697. return list.length ? list.map((a) => a.name).join('、') : '暂无数字生命';
  1698. }
  1699. /** 我自己的 Matrix ID —— 从会话服务取,不依赖 chat.provisionInfo */
  1700. private readonly myMxId = computed(() => {
  1701. // session.me() 的 id 是平台 _User.objectId(小写后即 Matrix 本地部分)
  1702. const id = this.session.me()?.id;
  1703. return id ? `@${id.toLowerCase()}:fmode.cn` : '';
  1704. });
  1705. private loadMessages(roomId: string): void {
  1706. /*
  1707. * 🔴 不能用 `chat.provisionInfo.userMxId` —— 那是**打开 DM 后**才有值的,
  1708. * 群聊页从没打开过 DM → 恒为 undefined → `my` 是空串 →
  1709. * 所有消息都被判成 agent(实测:自己发的消息显示在左边、还带着名字)。
  1710. * 改为从 session.me() 推导,不依赖 chat 的状态。
  1711. */
  1712. const my = this.chat.provisionInfo?.userMxId || this.myMxId();
  1713. const raw = this.mx.timelineToMessages(roomId);
  1714. /*
  1715. * 🔴 发送者名字优先用**平台数据**(agent 的 botName),不用 Matrix 的 displayName。
  1716. *
  1717. * 实测:容器账号没有设置 Matrix displayName,`getMember().name` 为空,
  1718. * 于是回落到 Matrix ID(`k0nrevcfik` / `zgdcmgnqcd`)——
  1719. * 用户看到的是一串技术 ID,完全不知道是谁。
  1720. * 平台侧有 `botName`(`lj` / `lj-test`),这才是有意义的名字。
  1721. */
  1722. const nameByMx = new Map<string, string>();
  1723. for (const a of this.live.agents()) {
  1724. const mx = a.channels?.matrixConfig?.userId;
  1725. if (mx) nameByMx.set(mx, a.name);
  1726. }
  1727. /*
  1728. * 自己的名字:用平台昵称(`用户9836`),不用 Matrix 的 displayName。
  1729. * Matrix 里那个名字可能是账号创建时随手设的(实测带着 emoji 尾巴),
  1730. * 在群里显示出来很奇怪。
  1731. */
  1732. if (my) nameByMx.set(my, this.session.me()?.name ?? '我');
  1733. this.messages.set(
  1734. raw
  1735. .filter((m) => !!m.body?.trim())
  1736. .map((m) => {
  1737. // 文件卡片是 `m.text` + JSON body,解析出来才能渲染成附件而不是一坨 JSON
  1738. // 两种附件形态都要认:
  1739. // ① 我们的约定:m.text + JSON body
  1740. // ② Matrix 原生:m.file / m.image + mxc://(实测容器用的是这个)
  1741. const file = MatrixClientService.parseFileCard(m.body) ?? (m.media
  1742. ? { name: m.media.name, size: m.media.size, mimeType: m.media.mimeType, url: m.media.url }
  1743. : null);
  1744. return {
  1745. id: m.eventId,
  1746. conversationId: roomId,
  1747. sender: m.sender === my ? ('me' as const) : ('agent' as const),
  1748. senderId: m.sender,
  1749. senderName: nameByMx.get(m.sender) ?? m.senderName ?? m.sender,
  1750. kind: m.kind,
  1751. text: file ? undefined : m.body,
  1752. file: file ? { kind: 'file' as const, ...file } : undefined,
  1753. ts: new Date(m.ts).toISOString(),
  1754. state: 'sent' as const,
  1755. mentions: m.mentions,
  1756. };
  1757. }),
  1758. );
  1759. this.checkHijack(my);
  1760. }
  1761. /**
  1762. * 检查上一轮派活是否被「抢答」。
  1763. *
  1764. * 实测(2026-10-28):@ lj-test 派活,执行的是 agent-lj ——
  1765. * 对方 `MATRIX_REQUIRE_MENTION=false`,群里任何消息都处理。
  1766. * 不提示的话用户会误以为「组员干了活」。
  1767. *
  1768. * 只在**派活后的一段时间内**检查,过期自动清除,避免旧提示常驻。
  1769. */
  1770. private checkHijack(myMxId: string): void {
  1771. const d = this.lastDispatch;
  1772. if (!d) return;
  1773. // 派活超过 5 分钟还没有结论 → 不再提示(用户已经看到结果了)
  1774. if (Date.now() - d.at > 5 * 60_000) {
  1775. this.hijackHintText.set(null);
  1776. this.lastDispatch = null;
  1777. return;
  1778. }
  1779. const all = this.messages();
  1780. const report = detectHijack(d.expected, repliesAfter(all, d.at, myMxId));
  1781. if (!report.hasHijack) {
  1782. this.hijackHintText.set(null);
  1783. return;
  1784. }
  1785. const nameOf = (id: string) =>
  1786. this.live.agents().find((a) => a.channels?.matrixConfig?.userId === id)?.name ?? id;
  1787. this.hijackHintText.set(hijackHint(report, nameOf));
  1788. }
  1789. /** 关闭抢答提示 */
  1790. dismissHijack(): void {
  1791. this.hijackHintText.set(null);
  1792. this.lastDispatch = null;
  1793. }
  1794. /** 输入框占位文案:始终提示「要先选人」 */
  1795. targetHint(): string {
  1796. const n = this.mentionTargets().size;
  1797. if (n > 0) {
  1798. const names = this.members()
  1799. .filter((a) => this.mentionTargets().has(a.id))
  1800. .map((a) => a.name)
  1801. .join('、');
  1802. return `发给 ${names},输入内容回车发送`;
  1803. }
  1804. return '先点下方 @ 选择让谁做事,再输入内容';
  1805. }
  1806. /** 一键 @ 某人(用于「交给组长」快捷操作) */
  1807. quickMention(a: Agent): void {
  1808. this.mentionTargets.update((s) => new Set(s).add(a.id));
  1809. this.sendBlocked.set(false);
  1810. }
  1811. toggleMention(a: Agent): void {
  1812. this.mentionTargets.update((s) => {
  1813. const next = new Set(s);
  1814. if (next.has(a.id)) next.delete(a.id);
  1815. else next.add(a.id);
  1816. return next;
  1817. });
  1818. }
  1819. clearMention(): void {
  1820. this.mentionTargets.set(new Set());
  1821. }
  1822. onInput(e: Event): void {
  1823. const el = e.target as HTMLTextAreaElement;
  1824. this.draft.set(el.value);
  1825. el.style.height = 'auto';
  1826. el.style.height = Math.min(el.scrollHeight, 160) + 'px';
  1827. // 边打边筛:根据光标位置的 @ 片段更新候选
  1828. this.mentionQuery.set(parseMentionQuery(el.value, el.selectionStart ?? el.value.length));
  1829. }
  1830. onKeydown(e: KeyboardEvent): void {
  1831. // 候选列表打开时,回车优先「选中候选」而不是发送
  1832. if (e.key === 'Enter' && !e.shiftKey && this.mentionSuggestions().length > 0) {
  1833. e.preventDefault();
  1834. this.pickMention(this.mentionSuggestions()[0]);
  1835. return;
  1836. }
  1837. if (e.key === 'Escape') {
  1838. this.mentionQuery.set({ active: false, query: '', start: -1 });
  1839. return;
  1840. }
  1841. if (e.key === 'Enter' && !e.shiftKey) {
  1842. e.preventDefault();
  1843. void this.send();
  1844. }
  1845. }
  1846. /** 消息附件预览(与 DM 共用同一个预览器) */
  1847. readonly previewFile = signal<{ url: string; name: string; mimeType: string } | null>(null);
  1848. /** 图片单独走灯箱(2026-10-30:图片不进预览弹窗) */
  1849. readonly lightboxFile = signal<{ url: string; name: string; size: number } | null>(null);
  1850. openFile(f: { url: string; name: string; mimeType: string; size?: number }): void {
  1851. // 图片不进预览弹窗,直接打开灯箱(2026-10-30)
  1852. if (previewKindOf(f.name || f.url, f.mimeType) === 'image') {
  1853. this.lightboxFile.set({ url: f.url, name: f.name, size: f.size ?? 0 });
  1854. return;
  1855. }
  1856. this.previewFile.set(f);
  1857. }
  1858. readonly uploading = signal(false);
  1859. readonly uploadPct = signal(0);
  1860. /**
  1861. * 群内上传文件。
  1862. *
  1863. * 与 DM 共用 `FmodeApi.uploadFile`(预签名直传 OBS),
  1864. * 只是发送到**群房间**而不是 DM 房间。
  1865. */
  1866. async onFiles(e: Event): Promise<void> {
  1867. const input = e.target as HTMLInputElement;
  1868. const files = Array.from(input.files ?? []);
  1869. input.value = ''; // 允许重复选同一文件
  1870. const rid = this.roomId();
  1871. if (!rid || files.length === 0 || this.uploading()) return;
  1872. this.uploading.set(true);
  1873. this.uploadPct.set(0);
  1874. try {
  1875. for (const f of files) {
  1876. const { publicUrl } = await this.api.uploadFile(f, (pct) => this.uploadPct.set(pct));
  1877. await this.mx.sendFileCard(rid, {
  1878. name: f.name,
  1879. size: f.size,
  1880. mimeType: f.type || 'application/octet-stream',
  1881. url: publicUrl,
  1882. });
  1883. }
  1884. this.loadMessages(rid);
  1885. this.stickToBottom();
  1886. } catch (err) {
  1887. // 上传失败要让用户知道,不静默
  1888. this.uploadError.set(err instanceof Error ? err.message : '上传失败');
  1889. } finally {
  1890. this.uploading.set(false);
  1891. this.uploadPct.set(0);
  1892. }
  1893. }
  1894. readonly uploadError = signal('');
  1895. async send(): Promise<void> {
  1896. const text = this.draft().trim();
  1897. const rid = this.roomId();
  1898. if (!text || !rid || this.sending()) return;
  1899. /*
  1900. * 🔴 **不再默认发给组长**(2026-10-28 用户裁决)。
  1901. *
  1902. * 原来的「没选人 → 自动发组长」有两个问题:
  1903. * ① 用户以为自己在跟某个人说话,实际发给了组长 → 语义不清
  1904. * ② 群里容器 `REQUIRE_MENTION=false` 时,**不 @ 任何人的消息所有容器都会处理**
  1905. * → 目标完全失控(实测:默认发给组长,结果别的容器也抢答)
  1906. *
  1907. * 现在:必须明确选人。没选就提示,不发出去。
  1908. */
  1909. const targets = this.members().filter((a) => this.mentionTargets().has(a.id));
  1910. if (targets.length === 0) {
  1911. this.sendBlocked.set(true);
  1912. return;
  1913. }
  1914. this.sendBlocked.set(false);
  1915. this.sending.set(true);
  1916. try {
  1917. /*
  1918. * 上下文**只给组长**(2026-10-28 实测结论)。
  1919. *
  1920. * 容器的 `messages` 表里只有「用户消息 + 自己的回复」——
  1921. * **看不到其他容器说的话**(lj-test 库中 user 角色全部来自用户本人)。
  1922. * 所以「同事说了什么」必须由 App 补。但补给谁很关键:
  1923. *
  1924. * · 组长:要理解前因后果、综合各容器反馈、决定下一步 → **需要**
  1925. * · 组员:只需要知道「我被派了什么活」→ **不需要**,塞别人的对话只是噪音
  1926. *
  1927. * 这和一个真实项目的分工一致:成员看任务单,不看会议纪要。
  1928. */
  1929. /*
  1930. * 兜底:群没配组长时,至少给**唯一目标**带上上下文 ——
  1931. * 否则这个群彻底失去「让容器知道前因」的能力,
  1932. * 比「只给组员多了点噪音」糟糕得多。
  1933. */
  1934. const leaderNow = rid ? this.roles.leaderOf()(rid) : undefined;
  1935. const toLeader = leaderNow
  1936. ? targets.length === 1 && targets[0].id === leaderNow.id
  1937. : targets.length <= 1;
  1938. const ctx = toLeader
  1939. ? buildContext(this.messages(), { enabled: this.attachContext })
  1940. : '';
  1941. /*
  1942. * 「继续刚才的活」这类指令:追加一句「先查产物」提醒。
  1943. * 实测:容器查工作区能恢复状态,查会话记录则失败。
  1944. */
  1945. const hint = looksLikeResume(text) ? ARTIFACT_HINT + '\n\n' : '';
  1946. const payload = ctx ? ctx + hint + text : hint + text;
  1947. /*
  1948. * 记下本轮 @ 的目标 —— 用于随后检测「抢答」:
  1949. * 实测 @ lj-test 派活,执行的是 agent-lj(对方 REQUIRE_MENTION=false),
  1950. * 不检测的话用户会误以为组员干了活。
  1951. */
  1952. const expectedMx = targets
  1953. .map((a) => a.channels?.matrixConfig?.userId ?? '')
  1954. .filter(Boolean);
  1955. this.lastDispatch = expectedMx.length > 0
  1956. ? { expected: expectedMx, at: Date.now() }
  1957. : null;
  1958. this.hijackHintText.set(null);
  1959. if (targets.length === 0) {
  1960. await this.mx.sendText(rid, payload);
  1961. } else {
  1962. await this.mx.sendMentionMany(
  1963. rid,
  1964. expectedMx,
  1965. payload,
  1966. targets.map((a) => a.name),
  1967. );
  1968. }
  1969. this.draft.set('');
  1970. this.clearMention();
  1971. const input = document.querySelector<HTMLTextAreaElement>('.input');
  1972. if (input) {
  1973. input.value = '';
  1974. input.style.height = 'auto';
  1975. }
  1976. this.loadMessages(rid);
  1977. this.stickToBottom();
  1978. } finally {
  1979. this.sending.set(false);
  1980. }
  1981. }
  1982. /**
  1983. * 滚到顶部附近 → 往前翻历史,并**保持视觉位置不动**。
  1984. *
  1985. * 与 DM 页同一套做法(那边踩过坑):
  1986. * 补偿必须在**渲染完成后**量 `scrollHeight`,`queueMicrotask` 时新内容
  1987. * 还没进 DOM,量到的是旧高度,补偿量算成 0 → 视口被顶飞。
  1988. */
  1989. onScroll(): void {
  1990. const el = this.messagesEl()?.nativeElement;
  1991. if (!el) return;
  1992. // 记住「用户是否主动上翻」,避免新消息把人拽回底部
  1993. const gap = el.scrollHeight - el.scrollTop - el.clientHeight;
  1994. this.userScrolledAway = gap > 160;
  1995. if (el.scrollTop > 80) return;
  1996. if (this.paginating() || !this.hasMore()) return;
  1997. void this.loadEarlier();
  1998. }
  1999. async loadEarlier(): Promise<void> {
  2000. const el = this.messagesEl()?.nativeElement;
  2001. const rid = this.roomId();
  2002. if (!el || !rid) return;
  2003. const beforeHeight = el.scrollHeight;
  2004. const beforeTop = el.scrollTop;
  2005. this.paginating.set(true);
  2006. try {
  2007. const grew = await this.mx.paginateBack(rid, 30);
  2008. if (!grew) {
  2009. this.hasMore.set(false);
  2010. return;
  2011. }
  2012. this.loadMessages(rid);
  2013. // 两帧后再量:一帧可能还没完成 markdown 渲染
  2014. requestAnimationFrame(() => {
  2015. requestAnimationFrame(() => {
  2016. const added = el.scrollHeight - beforeHeight;
  2017. if (added > 0) el.scrollTop = beforeTop + added;
  2018. });
  2019. });
  2020. this.hasMore.set(this.mx.paginationToken(rid) != null);
  2021. } finally {
  2022. this.paginating.set(false);
  2023. }
  2024. }
  2025. /**
  2026. * 贴底 —— 与单聊**同一套逻辑**(用户要求两边一致)。
  2027. *
  2028. * 🔴 只滚一次不够:消息进 DOM 时 markdown 还没渲染完,
  2029. * `scrollHeight` 偏小 → 滚完仍差一截,用户看到的是中间某处。
  2030. * 做法:立即滚 + rAF 再滚 + 120ms 补一次(异步内容会继续长高)。
  2031. */
  2032. private stickToBottom(): void {
  2033. // 用户主动上翻看历史时不要把他拽回来
  2034. if (this.userScrolledAway) return;
  2035. this.scrollToBottom();
  2036. requestAnimationFrame(() => {
  2037. this.scrollToBottom();
  2038. setTimeout(() => this.scrollToBottom(), 120);
  2039. });
  2040. }
  2041. private scrollToBottom(): void {
  2042. const el = this.messagesEl()?.nativeElement;
  2043. if (el) el.scrollTop = el.scrollHeight;
  2044. }
  2045. }