| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152 |
- import {
- ChangeDetectionStrategy,
- Component,
- computed,
- effect,
- ElementRef,
- inject,
- signal,
- viewChild,
- } from '@angular/core';
- import { ActivatedRoute, Router } from '@angular/router';
- import { UiAlert, UiAvatar, UiButton, UiEmpty, UiSkeleton } from '../../../ui';
- import { MessageBubbleComponent } from '../../../components/domain/message-bubble/message-bubble';
- import { FilePreviewComponent } from '../../../components/preview/file-preview/file-preview';
- import { ImageLightboxComponent } from '../../../components/preview/image-lightbox/image-lightbox';
- import { previewKindOf } from '../../../components/preview/file-kind';
- import { Agent, Message } from '../../../core/api/models';
- import { ChatService } from '../../../core/chat.service';
- import { crossOwnerWarning, detectCrossOwner } from '../../../core/cross-owner';
- import { detectHijack, hijackHint, repliesAfter } from '../../../core/hijack-detect';
- import { DeviceService } from '../../../core/device.service';
- import { fmtPercent, fmtTokens, usageOf } from '../../../core/format';
- import { GroupsService } from '../../../core/groups.service';
- import { GroupRolesService } from '../../../core/group-roles.service';
- import { LiveService } from '../../../core/live.service';
- import { DeliverablesService } from '../../../core/deliverables.service';
- import { Deliverable } from '../../../core/api/models';
- import { Material, fromAttachment, fromDeliverable, mergeMaterials } from '../../../core/materials';
- import { ARTIFACT_HINT, buildContext, looksLikeResume } from '../../../core/group-context';
- import { availabilityOf } from '../../../core/target-availability';
- import { leaderHealth } from '../../../core/leader-health';
- import { MemberTaskStatus, memberTaskStatus } from '../../../core/member-task-timeout';
- import { findLastDispatch } from '../../../core/dispatch-check';
- import { diagnoseLeader } from '../../../core/leader-membership';
- import { LEADER_PROMPT, MEMBER_PROMPT } from '../../../core/workgroup-brief';
- import { applyMention, filterMentionCandidates, parseMentionQuery } from '../../../core/mention-autocomplete';
- import { MatrixClientService } from '../../../core/matrix-client.service';
- import { SessionService } from '../../../core/session.service';
- import { FmodeApi } from '../../../core/api/fmode-api';
- /**
- * 桌面·群聊(多数字生命协作)
- *
- * ## 与单聊页的区别
- *
- * | | 单聊 `chat.ts` | 群聊(本页) |
- * |---|---|---|
- * | 消息归属 | `me` / `agent` 两态 | **每个 agent 都能发言**,要显示是谁 |
- * | 右栏 | 单容器运行状态 + 成果 | **成员列表**(含各自状态) |
- * | 输入 | 直接发 | 可 **@ 单个 / @ 多个** |
- *
- * ## 实测依据(2026-10-21 P0)
- *
- * - 群里 @ 单个 agent:3 秒响应
- * - 群里 @ 多个 agent:两个都响应,互不干扰
- * - **非主人发言会被拒绝**:容器只认自己的 owner
- * → 所以群成员必须是同一主人(或已授权的成员)
- */
- @Component({
- selector: 'app-dk-group-page',
- changeDetection: ChangeDetectionStrategy.OnPush,
- imports: [UiAlert, UiAvatar, UiButton, UiEmpty, UiSkeleton, MessageBubbleComponent, FilePreviewComponent, ImageLightboxComponent],
- template: `
- <div class="group-layout">
- <!-- 群列表 -->
- <aside class="col list-col" aria-label="群聊列表">
- <header class="col-head">
- <h2>工作组</h2>
- <div class="head-actions">
- <span class="count">{{ groups.rooms().length }}</span>
- <!-- 仅公司管理员可创建(云函数会再次校验,这里只是体验) -->
- <ui-button
- variant="ghost"
- shape="circle"
- size="sm"
- icon="fa-solid fa-plus"
- ariaLabel="新建工作组"
- (click)="openCreate()"
- />
- </div>
- </header>
- <div class="list-scroll">
- @if (groups.rooms().length === 0) {
- <ui-empty
- title="还没有工作组"
- description="在工作组里可以由多名数字生命协作。需要公司管理员创建。"
- />
- } @else {
- @for (r of groups.rooms(); track r.roomId) {
- <button
- type="button"
- class="group-row"
- [class.active]="r.roomId === roomId()"
- (click)="open(r.roomId)"
- >
- <span class="group-icon" aria-hidden="true">
- <i class="fa-solid fa-users"></i>
- </span>
- <span class="group-meta">
- <strong class="ellipsis">{{ r.name }}</strong>
- <small class="ellipsis">{{ memberNames(r.roomId) }}</small>
- </span>
- @if (r.memberIds.length > 2) {
- <span class="badge">{{ r.memberIds.length - 1 }}</span>
- }
- </button>
- }
- }
- </div>
- </aside>
- <!-- 群会话 -->
- <section class="col main-col" aria-label="群消息">
- @if (!roomId()) {
- <div class="center-fill">
- <ui-empty title="选择一个工作组" description="群内可以 @ 一个或多个数字生命。" />
- </div>
- } @else {
- <header class="chat-head">
- <span class="group-icon lg" aria-hidden="true">
- <i class="fa-solid fa-users"></i>
- </span>
- <div class="chat-title">
- <strong>{{ currentRoom()?.name }}</strong>
- <small class="ellipsis">
- {{ members().length }} 名数字生命
- @if (leaderName(); as ln) {
- · 组长 {{ ln }}
- }
- </small>
- </div>
- <ui-button
- variant="ghost"
- shape="circle"
- icon="fa-solid fa-user-group"
- ariaLabel="成员"
- [class.on]="membersOpen()"
- (click)="membersOpen.set(!membersOpen())"
- />
- </header>
- <!--
- 抢答提示:@ 了 A 却是 B 回的。
- 实测(2026-10-28)@ lj-test 派活、agent-lj 执行 ——
- 对方 REQUIRE_MENTION=false,群里任何消息都处理。
- -->
- @if (hijackHintText(); as hint) {
- <div class="banner-hold">
- <ui-alert type="warning" title="回应的不是被 @ 的对象">
- <p class="loop-detail">{{ hint }}</p>
- <ui-button variant="outline" size="sm" (click)="dismissHijack()">知道了</ui-button>
- </ui-alert>
- </div>
- }
- <!--
- 回环提示暂时移除(2026-11-09)。
- 原实现注入的是单聊 Guard,读的是当前单聊会话的消息,不是群消息流。
- 结果是群聊页显示的是「当前单聊会话」的判定,两端都串门。
- 群聊回环的正确数据源是群消息流,需单独设计后再接入,
- 不在这里复用单聊守卫。
- -->
- <div class="messages" #msgList (scroll)="onScroll()">
- @if (loading()) {
- <div class="skeleton-hold">
- @for (i of [1, 2, 3]; track i) {
- <ui-skeleton variant="rect" height="56px" />
- }
- </div>
- } @else if (messages().length === 0) {
- <ui-empty title="工作组还没有消息" description="在下方 @ 一名或多名数字生命开始协作。" />
- } @else {
- <div class="history-hold">
- @if (paginating()) {
- <ui-skeleton variant="rect" height="32px" />
- } @else if (hasMore()) {
- <ui-button variant="ghost" size="sm" (click)="loadEarlier()">加载更早</ui-button>
- } @else {
- <span class="history-end">已到最早</span>
- }
- </div>
- @for (m of messages(); track m.id) {
- <app-message-bubble
- [message]="m"
- [own]="m.sender === 'me'"
- [showSender]="true"
- [nameOf]="mxName"
- (preview)="openFile($event)"
- />
- }
- }
- </div>
- <!-- @ 选择器 -->
- @if (mentionOpen()) {
- <div class="mention-bar">
- <span class="mention-hint">选择要 @ 的成员(可多选):</span>
- <div class="mention-chips">
- @for (a of members(); track a.id) {
- <button
- type="button"
- class="mention-chip"
- [class.on]="mentionTargets().has(a.id)"
- (click)="toggleMention(a)"
- >
- {{ a.name }}
- </button>
- }
- </div>
- </div>
- }
- <div class="composer-hold">
- @if (mentionSuggestions().length > 0) {
- <div class="mention-suggest" role="listbox" aria-label="@ 候选">
- @for (a of mentionSuggestions(); track a.id) {
- <button type="button" class="suggest-item" role="option" (click)="pickMention(a)">
- <strong>{{ a.name }}</strong>
- @if (roomId() && roles.dutyOf()(roomId()!, a.id); as duty) {
- <small>{{ duty }}</small>
- }
- </button>
- }
- </div>
- }
- @if (leaderMembership().warn) {
- <div class="target-warn critical">
- <i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>
- <span>{{ leaderMembership().message }}</span>
- </div>
- } @else if (leaderStatus().warn) {
- <div class="target-warn" [class.critical]="leaderStatus().suggestReplace">
- <i class="fa-solid" [class]="leaderStatus().suggestReplace ? 'fa-triangle-exclamation' : 'fa-hourglass-half'" aria-hidden="true"></i>
- <span>{{ leaderStatus().message }}</span>
- @if (leaderStatus().suggestReplace) {
- <button type="button" class="warn-action" (click)="membersOpen.set(true)">更换组长</button>
- }
- </div>
- } @else if (dispatchIssue(); as issue) {
- <div class="target-warn critical">
- <i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>
- <span>派给 {{ issue.member }} 的活没送到:{{ issue.detail }}</span>
- </div>
- } @else if (memberStatus().state === 'stuck') {
- <div class="target-warn critical">
- <i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>
- <span>{{ memberStatus().hint }}</span>
- </div>
- } @else if (targetStatus().warn) {
- <div class="target-warn">
- <i class="fa-solid fa-hourglass-half" aria-hidden="true"></i>
- {{ targetStatus().hint }}
- </div>
- }
- @if (uploading()) {
- <div class="mention-preview">
- <i class="fa-solid fa-cloud-arrow-up" aria-hidden="true"></i>
- 正在上传 {{ uploadPct() }}%
- </div>
- }
- @if (mentionTargets().size > 0) {
- <div class="mention-preview">
- <i class="fa-solid fa-at" aria-hidden="true"></i>
- {{ mentionLabel() }}
- <button type="button" class="clear" (click)="clearMention()" aria-label="取消 @">
- <i class="fa-solid fa-xmark" aria-hidden="true"></i>
- </button>
- </div>
- }
- <div class="composer-row">
- <ui-button
- variant="ghost"
- shape="circle"
- icon="fa-solid fa-at"
- ariaLabel="@ 成员"
- (click)="mentionOpen.set(!mentionOpen())"
- />
- <!-- 群内附件:与 DM 用同一条上传链路(预签名直传 OBS) -->
- <ui-button
- variant="ghost"
- shape="circle"
- icon="fa-solid fa-paperclip"
- ariaLabel="上传文件"
- [disabled]="uploading()"
- (click)="fileInput.click()"
- />
- <input
- #fileInput
- type="file"
- multiple
- hidden
- (change)="onFiles($event)"
- />
- <textarea
- #input
- class="input"
- rows="1"
- [placeholder]="targetHint()"
- [value]="draft()"
- (input)="onInput($event)"
- (keydown)="onKeydown($event)"
- ></textarea>
- <ui-button
- variant="primary"
- shape="circle"
- icon="fa-solid fa-paper-plane"
- ariaLabel="发送"
- [disabled]="!draft().trim() || sending()"
- (click)="send()"
- />
- </div>
- <!--
- 发送目标必须明确。
- 不 @ 任何人时,群里 REQUIRE_MENTION=false 的容器会全部响应 ——
- 目标失控。所以在这里挡住,并给出一键选择。
- -->
- @if (sendBlocked()) {
- <div class="target-required" role="alert">
- <i class="fa-solid fa-circle-exclamation" aria-hidden="true"></i>
- <span>请先选择要让谁做事(点下方 @ 或直接点名字)—— 群里有多个容器,不指定对象会导致谁都在响应。</span>
- <div class="target-quick">
- @if (leaderAgent(); as la) {
- <ui-button variant="outline" size="sm" (click)="quickMention(la)">
- 交给组长 {{ la.name }}
- </ui-button>
- }
- <ui-button variant="ghost" size="sm" (click)="sendBlocked.set(false)">知道了</ui-button>
- </div>
- </div>
- }
- </div>
- }
- </section>
- <!-- 新建工作组 -->
- @if (creating()) {
- <div class="role-editor" role="dialog" aria-modal="true" aria-label="新建工作组">
- <div class="role-card">
- <h3>新建工作组</h3>
- <label class="role-field">
- <span>组名</span>
- <input
- type="text"
- [value]="newName()"
- (input)="newName.set($any($event.target).value)"
- placeholder="如:登录页开发"
- maxlength="40"
- />
- </label>
- <label class="role-field">
- <span>目标(选填)</span>
- <input
- type="text"
- [value]="newGoal()"
- (input)="newGoal.set($any($event.target).value)"
- placeholder="一句话说明这个组要做什么"
- maxlength="80"
- />
- </label>
- <div class="role-field">
- <span>选择成员(可多选)</span>
- <div class="mention-chips">
- @for (a of candidateAgents(); track a.id) {
- <button
- type="button"
- class="mention-chip"
- [class.on]="newMembers().has(a.id)"
- (click)="toggleNewMember(a)"
- >
- {{ a.name }}
- </button>
- }
- </div>
- </div>
- @if (createError()) {
- <p class="role-hint err">{{ createError() }}</p>
- }
- <!-- 建组前就提醒:混入别人的容器会导致组长指挥不动它 -->
- @if (newCrossOwnerHint(); as hint) {
- <p class="role-hint warn-text">
- <i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>
- {{ hint }}
- </p>
- }
- <p class="role-hint">
- 创建后你和所选数字生命会进入同一个群,可以 @ 一个或多个协作。
- </p>
- <div class="role-actions">
- <ui-button variant="ghost" size="sm" (click)="creating.set(false)">取消</ui-button>
- <ui-button
- variant="primary"
- size="sm"
- [disabled]="!newName().trim() || newMembers().size === 0 || creatingBusy()"
- (click)="submitCreate()"
- >
- {{ creatingBusy() ? '创建中…' : '创建' }}
- </ui-button>
- </div>
- </div>
- </div>
- }
- <!-- 角色编辑 -->
- @if (editing()) {
- <div class="role-editor" role="dialog" aria-modal="true" aria-label="编辑角色">
- <div class="role-card">
- <h3>编辑 {{ editing()!.name }} 的角色</h3>
- <label class="role-field">
- <span>职责</span>
- <input
- type="text"
- [value]="dutyDraft()"
- (input)="dutyDraft.set($any($event.target).value)"
- placeholder="如:前端 / 后端 / 设计"
- maxlength="40"
- />
- </label>
- <label class="role-check">
- <input
- type="checkbox"
- [checked]="isLeader(editing()!.id)"
- (change)="toggleLeader(editing()!.id)"
- />
- <span>设为组长(会替换原组长)</span>
- </label>
- <p class="role-hint">
- 组长负责汇总方案、对外沟通;组员负责各自部分。用户据此知道该找谁。
- </p>
- <div class="role-actions">
- <ui-button variant="ghost" size="sm" (click)="editing.set(null)">取消</ui-button>
- <ui-button variant="primary" size="sm" [disabled]="savingRole()" (click)="saveRole()">
- {{ savingRole() ? '保存中…' : '保存' }}
- </ui-button>
- </div>
- </div>
- </div>
- }
- <!-- 成员栏 -->
- @if (roomId() && membersOpen()) {
- <aside class="col members-col" aria-label="群成员">
- <header class="col-head">
- <h2>成员与资料</h2>
- <ui-button
- variant="ghost"
- shape="circle"
- icon="fa-solid fa-xmark"
- ariaLabel="收起"
- (click)="membersOpen.set(false)"
- />
- </header>
- <div class="member-list">
- <!--
- 跨主人协作提示。
- 容器只认自己的主人:组里混了别人的容器时,组长 @ 它不会执行。
- 这个限制 App 改不了(需容器侧实现授权表),但必须提前说清楚,
- 别让用户派活失败后才发现。
- -->
- @if (crossOwnerHint(); as hint) {
- <div class="cross-owner-note" role="note">
- <i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>
- <span>{{ hint }}</span>
- </div>
- }
- @for (a of membersSorted(); track a.id) {
- <button
- type="button"
- class="member-row"
- [class.is-leader]="isLeader(a.id)"
- (click)="openRoleEditor(a)"
- [attr.aria-label]="'编辑 ' + a.name + ' 的角色'"
- >
- <ui-avatar [name]="a.name" [size]="'sm'" />
- <span class="member-meta">
- <span class="member-line">
- <strong class="ellipsis">{{ a.name }}</strong>
- @if (isLeader(a.id)) {
- <span class="role-tag leader">组长</span>
- } @else if (roles.dutyOf()(roomId()!, a.id)) {
- <span class="role-tag">组员</span>
- }
- @if (!isLeader(a.id) && !isAuthorized(a.id)) {
- <span class="role-tag warn" title="未授权:组长 @ 它不会执行">未授权</span>
- }
- </span>
- <!--
- 状态行:优先显示「现在在干什么」。
- 🔴 只看 status 会误报 —— 容器上报 running 但 currentTask 为空是常态,
- 直接显示「执行中」会让用户以为它在干活(截图实测)。
- -->
- <small class="ellipsis" [title]="statusLineOf(a)">{{ statusLineOf(a) }}</small>
- </span>
- <span class="dot" [class.busy]="isReallyBusy(a)" aria-hidden="true"></span>
- </button>
- }
- @if (members().length === 0) {
- <p class="empty-hint">该群的数字生命尚未绑定到平台账号。</p>
- }
- <!--
- 运行状态(与单聊右侧同一结构,用户要求两边样式统一)。
- 显示组成员的实时指标;组长单独一行说明角色。
- -->
- @if (leaderAgent(); as la) {
- <div class="material-head">
- <h3>运行状态 · {{ la.name }}</h3>
- </div>
- <dl class="kv">
- <div>
- <dt>当前任务</dt>
- <dd>{{ statusLineOf(la) }}</dd>
- </div>
- <div><dt>CPU</dt><dd>{{ pct(la.host?.cpuPercent) }}</dd></div>
- <div><dt>内存</dt><dd>{{ pct(la.host?.memPercent) }}</dd></div>
- <div><dt>Token 用量</dt><dd>{{ tokens(la) }}</dd></div>
- </dl>
- }
- <!--
- 资料区(问题 5):这个群产出的文件都在这里,固定可滚动。
- 与「成果」页同源(DeliverablesService),只是按本组成员过滤。
- -->
- <div class="material-head">
- <h3>本群资料 <span class="count">{{ groupMaterials().length }}</span></h3>
- </div>
- @if (groupMaterials().length === 0) {
- <p class="empty-hint">还没有产出资料。</p>
- } @else {
- @for (d of groupMaterials(); track d.id) {
- <button type="button" class="material-row" (click)="openGroupMaterial(d)">
- <span class="file-icon" aria-hidden="true">
- <i class="fa-solid" [class]="materialIcon(d)"></i>
- </span>
- <span class="rail-meta">
- <!--
- 标题和文件名常常相同(如都是 index.html)→ 只显示一次。
- 副标题只放「能补充信息」的内容:来源标签或不同的文件名。
- -->
- <strong class="ellipsis">{{ d.title }}</strong>
- <small class="ellipsis">
- @if (d.filename && d.filename !== d.title) {
- {{ d.filename }}
- } @else if (d.source === 'attachment') {
- 来自群聊
- } @else {
- 来自成果
- }
- </small>
- </span>
- </button>
- }
- }
- </div>
- </aside>
- }
- <!-- 图片灯箱(2026-10-30):图片不进预览弹窗 -->
- <app-image-lightbox
- [open]="!!lightboxFile()"
- [url]="lightboxFile()?.url ?? ''"
- [name]="lightboxFile()?.name ?? ''"
- [size]="lightboxFile()?.size ?? 0"
- (closed)="lightboxFile.set(null)"
- />
- <!-- 附件预览:与 DM 共用同一个预览组件 -->
- <app-file-preview
- [open]="!!previewFile()"
- [url]="previewFile()?.url ?? ''"
- [name]="previewFile()?.name ?? ''"
- [mimeType]="previewFile()?.mimeType ?? ''"
- (closed)="previewFile.set(null)"
- />
- </div>
- `,
- styles: [
- `
- :host {
- display: block;
- height: 100%;
- min-height: 0;
- overflow: hidden;
- }
- .group-layout {
- display: grid;
- grid-template-columns: var(--desk-list-w) minmax(0, 1fr);
- height: 100%;
- min-height: 0;
- }
- .group-layout:has(> .members-col) {
- grid-template-columns: var(--desk-list-w) minmax(0, 1fr) 260px;
- }
- .col {
- min-width: 0;
- min-height: 0;
- display: flex;
- flex-direction: column;
- background: var(--surface);
- }
- .list-col {
- background: var(--surface-2);
- border-right: 1px solid var(--edge);
- overflow: hidden;
- }
- .members-col {
- background: var(--rail-surface);
- border-left: 1px solid var(--edge);
- }
- .col-head {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: var(--sp-2);
- padding: var(--sp-4);
- border-bottom: 1px solid var(--edge);
- }
- .col-head h2 {
- margin: 0;
- font-size: var(--fs-15);
- color: var(--ink);
- }
- .count {
- font-size: var(--fs-11);
- color: var(--muted);
- }
- .list-scroll {
- flex: 1;
- min-height: 0;
- overflow-y: auto;
- padding: var(--sp-2);
- }
- .group-row {
- display: flex;
- align-items: center;
- gap: var(--sp-3);
- width: 100%;
- min-height: 56px;
- padding: var(--sp-2) var(--sp-3);
- border: 0;
- border-radius: var(--r-md);
- background: transparent;
- text-align: left;
- cursor: pointer;
- }
- .group-row:hover {
- background: var(--surface-hover);
- }
- .group-row.active {
- background: var(--surface);
- }
- .group-icon {
- display: grid;
- place-items: center;
- width: 32px;
- height: 32px;
- flex-shrink: 0;
- border-radius: var(--r-sm);
- background: var(--primary-subtle);
- color: var(--primary);
- }
- .group-icon.lg {
- width: 40px;
- height: 40px;
- }
- .group-meta {
- display: grid;
- gap: 2px;
- flex: 1;
- min-width: 0;
- }
- .group-meta strong {
- font-size: var(--fs-15);
- color: var(--ink);
- }
- .group-meta small {
- font-size: var(--fs-12);
- color: var(--muted);
- }
- .badge {
- min-width: 18px;
- padding: 0 5px;
- border-radius: var(--r-tag);
- background: var(--surface-active);
- color: var(--ink-2);
- font-size: var(--fs-11);
- line-height: 18px;
- text-align: center;
- }
- .center-fill {
- flex: 1;
- display: grid;
- place-content: center;
- }
- .chat-head {
- display: flex;
- align-items: center;
- gap: var(--sp-3);
- padding: var(--sp-3) var(--sp-4);
- border-bottom: 1px solid var(--edge);
- }
- .chat-title {
- display: grid;
- gap: 2px;
- flex: 1;
- min-width: 0;
- }
- .chat-title strong {
- font-size: var(--fs-15);
- color: var(--ink);
- }
- .chat-title small {
- font-size: var(--fs-12);
- color: var(--muted);
- }
- .banner-hold {
- padding: var(--sp-3) var(--sp-4) 0;
- }
- .loop-detail {
- margin: 0 0 var(--sp-2);
- font-size: var(--fs-12);
- color: var(--ink-2);
- }
- .messages {
- flex: 1;
- min-height: 0;
- overflow-y: auto;
- padding: var(--sp-4);
- display: grid;
- gap: var(--sp-3);
- align-content: start;
- }
- .history-hold {
- display: flex;
- justify-content: center;
- padding: var(--sp-1) 0;
- }
- .history-end {
- font-size: var(--fs-12);
- color: var(--muted);
- }
- .skeleton-hold {
- display: grid;
- gap: var(--sp-2);
- }
- .mention-bar {
- padding: var(--sp-2) var(--sp-4);
- border-top: 1px solid var(--edge);
- background: var(--surface-2);
- }
- .mention-hint {
- font-size: var(--fs-12);
- color: var(--muted);
- }
- .mention-chips {
- display: flex;
- flex-wrap: wrap;
- gap: var(--sp-2);
- margin-top: var(--sp-2);
- }
- .mention-chip {
- min-height: 28px;
- padding: 0 var(--sp-3);
- border: 1px solid var(--edge);
- border-radius: var(--r-tag);
- background: var(--surface);
- color: var(--ink-2);
- font-size: var(--fs-12);
- cursor: pointer;
- }
- .mention-chip.on {
- border-color: var(--primary);
- background: var(--primary-subtle);
- color: var(--primary);
- }
- .composer-hold {
- border-top: 1px solid var(--edge);
- padding: var(--sp-3) var(--sp-4);
- }
- .target-warn.critical {
- background: var(--danger-subtle);
- color: var(--danger);
- }
- .warn-action {
- margin-left: auto;
- flex-shrink: 0;
- min-height: 24px;
- padding: 0 var(--sp-2);
- border: 1px solid currentColor;
- border-radius: var(--r-sm);
- background: transparent;
- color: inherit;
- font-size: var(--fs-11);
- cursor: pointer;
- }
- .mention-suggest {
- display: grid;
- gap: 2px;
- margin-bottom: var(--sp-2);
- padding: var(--sp-1);
- border: 1px solid var(--edge);
- border-radius: var(--r-md);
- background: var(--surface);
- box-shadow: var(--shadow-pop);
- }
- .suggest-item {
- display: flex;
- align-items: baseline;
- gap: var(--sp-2);
- width: 100%;
- min-height: 32px;
- padding: 0 var(--sp-2);
- border: 0;
- border-radius: var(--r-sm);
- background: transparent;
- color: var(--ink);
- text-align: left;
- cursor: pointer;
- }
- .suggest-item:hover {
- background: var(--surface-hover);
- }
- .suggest-item strong {
- font-size: var(--fs-13);
- }
- .suggest-item small {
- font-size: var(--fs-11);
- color: var(--muted);
- }
- .head-actions {
- display: flex;
- align-items: center;
- gap: var(--sp-1);
- }
- .role-hint.err {
- color: var(--danger);
- }
- .role-hint.warn-text {
- display: flex;
- gap: var(--sp-2);
- align-items: flex-start;
- color: var(--warning);
- line-height: 1.5;
- }
- .role-hint.warn-text i {
- margin-top: 2px;
- flex: none;
- }
- .target-warn {
- display: flex;
- align-items: center;
- gap: var(--sp-2);
- margin-bottom: var(--sp-2);
- padding: var(--sp-2) var(--sp-3);
- border-radius: var(--r-sm);
- background: var(--warning-subtle);
- color: var(--warning);
- font-size: var(--fs-12);
- line-height: 1.5;
- }
- .ctx-toggle {
- display: inline-flex;
- align-items: center;
- gap: var(--sp-1);
- margin-bottom: var(--sp-2);
- font-size: var(--fs-12);
- color: var(--muted);
- cursor: pointer;
- }
- .mention-preview {
- display: inline-flex;
- align-items: center;
- gap: var(--sp-1);
- padding: 2px var(--sp-2);
- margin-bottom: var(--sp-2);
- border-radius: var(--r-tag);
- background: var(--primary-subtle);
- color: var(--primary);
- font-size: var(--fs-12);
- }
- .mention-preview .clear {
- border: 0;
- background: transparent;
- color: inherit;
- cursor: pointer;
- }
- .composer-row {
- display: flex;
- align-items: flex-end;
- gap: var(--sp-2);
- }
- .input {
- flex: 1;
- min-height: 40px;
- max-height: 160px;
- padding: var(--sp-2) var(--sp-3);
- border: 1px solid var(--edge);
- border-radius: var(--r-md);
- background: var(--surface);
- color: var(--ink);
- font: inherit;
- resize: none;
- }
- /* 跨主人协作提示(不是错误,是「提前说明」,所以用 warning 而非 danger) */
- /* 发送目标必选提示 */
- .target-required {
- display: grid;
- gap: var(--sp-2);
- margin: var(--sp-2) var(--sp-4) 0;
- padding: var(--sp-2) var(--sp-3);
- border: 1px solid var(--warning);
- border-radius: var(--r-md);
- background: var(--warning-subtle);
- font-size: var(--fs-12);
- line-height: 1.5;
- color: var(--ink-2);
- }
- .target-required > i {
- display: none;
- }
- .target-required span {
- display: flex;
- gap: var(--sp-2);
- }
- .target-quick {
- display: flex;
- gap: var(--sp-2);
- flex-wrap: wrap;
- }
- .cross-owner-note {
- display: flex;
- gap: var(--sp-2);
- align-items: flex-start;
- margin-bottom: var(--sp-3);
- padding: var(--sp-2) var(--sp-3);
- border: 1px solid var(--warning);
- border-radius: var(--r-md);
- background: var(--warning-subtle);
- font-size: var(--fs-12);
- line-height: 1.5;
- color: var(--ink-2);
- }
- .cross-owner-note i {
- margin-top: 2px;
- flex: none;
- color: var(--warning);
- }
- .cross-owner-note span {
- min-width: 0;
- word-break: break-word;
- }
- .member-list {
- flex: 1;
- min-height: 0;
- overflow-y: auto;
- padding: var(--sp-3);
- display: grid; gap: var(--sp-2);
- align-content: start;
- }
- .member-line {
- display: flex;
- align-items: center;
- gap: var(--sp-1);
- min-width: 0;
- }
- .role-tag {
- flex-shrink: 0;
- padding: 0 5px;
- border-radius: var(--r-tag);
- background: var(--surface-hover);
- color: var(--muted);
- font-size: var(--fs-11);
- line-height: 16px;
- }
- .role-tag.warn {
- background: var(--warning-subtle);
- color: var(--warning);
- }
- .role-tag.leader {
- background: var(--primary-subtle);
- color: var(--primary);
- font-weight: 600;
- }
- /* 组长置顶并加左侧色条,用户一眼知道找谁 */
- .role-editor {
- position: fixed;
- inset: 0;
- display: grid;
- place-items: center;
- background: var(--overlay);
- z-index: var(--z-modal);
- padding: var(--sp-4);
- }
- .role-card {
- width: min(400px, 92vw);
- padding: var(--sp-5);
- border-radius: var(--r-lg);
- background: var(--surface);
- box-shadow: var(--shadow-overlay);
- display: grid;
- gap: var(--sp-3);
- }
- .role-card h3 {
- margin: 0;
- font-size: var(--fs-17);
- color: var(--ink);
- }
- .role-field {
- display: grid;
- gap: var(--sp-1);
- font-size: var(--fs-13);
- color: var(--ink-2);
- }
- .role-field input {
- min-height: var(--touch-min);
- padding: 0 var(--sp-3);
- border: 1px solid var(--edge);
- border-radius: var(--r-md);
- background: var(--surface);
- color: var(--ink);
- font: inherit;
- }
- .role-check {
- display: flex;
- align-items: center;
- gap: var(--sp-2);
- font-size: var(--fs-13);
- color: var(--ink-2);
- }
- .role-hint {
- margin: 0;
- font-size: var(--fs-12);
- color: var(--muted);
- line-height: 1.6;
- }
- .role-actions {
- display: flex;
- justify-content: flex-end;
- gap: var(--sp-2);
- }
- .kv {
- margin: 0 0 var(--sp-3);
- display: grid;
- gap: var(--sp-2);
- }
- .kv > div {
- display: flex;
- justify-content: space-between;
- gap: var(--sp-3);
- font-size: var(--fs-12);
- }
- .kv dt {
- color: var(--muted);
- flex-shrink: 0;
- }
- .kv dd {
- margin: 0;
- color: var(--ink);
- text-align: right;
- min-width: 0;
- overflow-wrap: anywhere;
- }
- .material-head {
- margin-top: var(--sp-3);
- padding-top: var(--sp-3);
- border-top: 1px solid var(--edge);
- }
- .material-head h3 {
- margin: 0 0 var(--sp-2);
- font-size: var(--fs-12);
- color: var(--ink-2);
- }
- .material-row {
- display: flex;
- align-items: center;
- gap: var(--sp-2);
- width: 100%;
- min-height: 44px;
- padding: var(--sp-2);
- margin-bottom: var(--sp-2);
- border: 1px solid var(--edge);
- border-radius: var(--r-md);
- background: var(--surface);
- text-align: left;
- cursor: pointer;
- font: inherit;
- }
- .material-row:hover {
- background: var(--surface-hover);
- }
- .member-row {
- width: 100%;
- border: 0;
- text-align: left;
- cursor: pointer;
- font: inherit;
- }
- .member-row:hover {
- background: var(--surface-hover);
- }
- .member-row.is-leader {
- border-left: 3px solid var(--primary);
- }
- .member-row {
- display: flex;
- align-items: center;
- gap: var(--sp-2);
- padding: var(--sp-2);
- border-radius: var(--r-md);
- background: var(--surface);
- }
- .member-meta {
- display: grid;
- gap: 1px;
- flex: 1;
- min-width: 0;
- }
- .member-meta strong {
- font-size: var(--fs-13);
- color: var(--ink);
- }
- .member-meta small {
- font-size: var(--fs-11);
- color: var(--muted);
- }
- .dot {
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: var(--edge-strong);
- flex-shrink: 0;
- }
- .dot.busy {
- background: var(--success);
- animation: pulse-dot 1.4s var(--ease-standard) infinite;
- }
- @keyframes pulse-dot {
- 0%,
- 100% {
- opacity: 1;
- }
- 50% {
- opacity: 0.35;
- }
- }
- @media (prefers-reduced-motion: reduce) {
- .dot.busy {
- animation: none;
- }
- }
- .empty-hint {
- margin: 0;
- font-size: var(--fs-12);
- color: var(--muted);
- }
- .ellipsis {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- `,
- ],
- })
- export class DkGroupPage {
- readonly groups = inject(GroupsService);
- readonly roles = inject(GroupRolesService);
- private readonly deliverables = inject(DeliverablesService);
- private readonly live = inject(LiveService);
- private readonly chat = inject(ChatService);
- private readonly mx = inject(MatrixClientService);
- private readonly session = inject(SessionService);
- private readonly api = inject(FmodeApi);
- private readonly route = inject(ActivatedRoute);
- private readonly router = inject(Router);
- private readonly device = inject(DeviceService);
- readonly roomId = signal<string | null>(null);
- readonly messages = signal<Message[]>([]);
- readonly loading = signal(false);
- readonly sending = signal(false);
- readonly draft = signal('');
- /**
- * 发送被拦下(没选 @ 对象)。
- *
- * 群里有多个容器时,不指定对象等于「谁都可能响应」——
- * 实测量产 容器 `REQUIRE_MENTION=false` 时所有容器都会处理。
- * 所以**必须先选人**,这里负责给出清楚的理由。
- */
- readonly sendBlocked = signal(false);
- /**
- * 上一次派活的抢答提示(null = 没有)。
- *
- * 实测:@ lj-test 派活,执行的是 agent-lj(对方 REQUIRE_MENTION=false)。
- * 不提示的话用户会以为「组员干了活」,实际组内根本没有分工。
- */
- readonly hijackHintText = signal<string | null>(null);
- /** 上一次 @ 的目标与时间(用于检测回复归属) */
- private lastDispatch: { expected: string[]; at: number } | null = null;
- readonly membersOpen = signal(true);
- readonly mentionOpen = signal(false);
- /** 打字时的 @ 补全状态 */
- private readonly mentionQuery = signal({ active: false, query: '', start: -1 });
- /**
- * 发送时是否附带最近对话。
- *
- * 🔴 2026-10-27 改为**默认关闭**。
- *
- * 🔴 2026-10-27 改回**默认开启**。
- *
- * 实测(用户反馈):关掉后容器完全没有上下文 ——
- * 你问「做个 Excel 表格」→ 它反问一堆 → 你说「随机即可」→ 它答「不知道你在说什么」。
- *
- * 组长制并不能替代上下文:组长也是容器,同样没有跨消息记忆。
- * 所以**始终带上最近对话**(2026-11-01 用户要求删掉开关):
- * 关掉后的表现是「答非所问」,等于坏掉,没有保留成选项的意义。
- */
- readonly attachContext = true;
- readonly mentionTargets = signal<Set<string>>(new Set());
- /** 上滚加载历史中(防重入) */
- readonly paginating = signal(false);
- /** 还有更早的历史 */
- readonly hasMore = signal(false);
- private readonly messagesEl = viewChild<ElementRef<HTMLElement>>('msgList');
- readonly currentRoom = computed(() =>
- this.groups.rooms().find((r) => r.roomId === this.roomId()),
- );
- /** 群内 agent 成员(已映射回平台 Agent,能显示状态) */
- readonly members = computed(() => {
- const id = this.roomId();
- return id ? this.groups.membersOf()(id) : [];
- });
- /**
- * 跨主人协作风险。
- *
- * 容器**只认自己的主人** —— 组里混了别人的容器时,组长 @ 它不会执行。
- * App 改不了这个限制(需容器侧实现授权表),但必须**提前说清楚**,
- * 别让用户派活失败后才发现。
- */
- readonly crossOwner = computed(() =>
- detectCrossOwner(
- this.members().map((a) => ({ id: a.id, name: a.name, ownerId: a.owner?.id })),
- this.session.me()?.id,
- ),
- );
- /** 跨主人提示文案(无风险时 null) */
- readonly crossOwnerHint = computed(() => crossOwnerWarning(this.crossOwner()));
- /** 建组弹窗里的跨主人提示(按已选成员实时算) */
- readonly newCrossOwnerHint = computed(() => {
- const picked = this.candidateAgents().filter((a) => this.newMembers().has(a.id));
- return crossOwnerWarning(
- detectCrossOwner(
- picked.map((a) => ({ id: a.id, name: a.name, ownerId: a.owner?.id })),
- this.session.me()?.id,
- ),
- );
- });
- /**
- * 被 @ 成员的可用性。
- *
- * 实测:容器忙时消息会排队(6/6 探针在空闲时全成功)。
- * 这里不是要拦截发送,是**告诉用户等一会儿**。
- */
- /**
- * 组长健康度。
- *
- * 组长制下所有需求都走组长,掉线会让整组停摆 —— 必须让用户知道。
- */
- /**
- * 组长派活后,组员是否卡住了。
- *
- * 只做提示,不自动重发 —— 重发可能让组员做两遍。
- */
- readonly memberStatus = computed<MemberTaskStatus>(() => {
- const rid = this.roomId();
- if (!rid) return { state: 'idle', waitedMs: 0, hint: '' };
- const leaderMx = this.roles.leaderOf()(rid)?.channels?.matrixConfig?.userId;
- if (!leaderMx) return { state: 'idle', waitedMs: 0, hint: '' };
- // 检查每个组员,取最严重的
- let worst: MemberTaskStatus = { state: 'idle', waitedMs: 0, hint: '' };
- for (const a of this.members()) {
- if (a.id === this.roles.leaderOf()(rid)?.id) continue;
- const mx = a.channels?.matrixConfig?.userId;
- if (!mx) continue;
- const st = memberTaskStatus(this.messages(), leaderMx, mx, Date.now(), a.name);
- if (st.state === 'stuck') return st;
- if (st.state === 'slow') worst = st;
- }
- return worst;
- });
- /**
- * 派活是否真的送到了组员手上。
- *
- * 实测坑:组长说「派活消息已发到群里」,但那条消息只在文字里写了 @名字,
- * 没有 `m.mentions` 结构 → 组员不认为被叫到 → 40 分钟都没开工。
- */
- readonly dispatchIssue = computed(() => {
- const rid = this.roomId();
- if (!rid) return null;
- const leader = this.roles.leaderOf()(rid);
- const leaderMx = leader?.channels?.matrixConfig?.userId;
- if (!leaderMx) return null;
- for (const a of this.members()) {
- if (a.id === leader?.id) continue;
- const mx = a.channels?.matrixConfig?.userId;
- if (!mx) continue;
- const { msg, check } = findLastDispatch(this.messages(), leaderMx, mx);
- // 只有「派了但没送到」且组员确实没回时才提示
- if (!msg || !check.needsResend) continue;
- const st = memberTaskStatus(this.messages(), leaderMx, mx, Date.now(), a.name);
- if (st.state === 'stuck' || st.state === 'slow') {
- return { member: a.name, detail: check.detail };
- }
- }
- return null;
- });
- readonly leaderStatus = computed(() => {
- const rid = this.roomId();
- if (!rid) return leaderHealth(undefined);
- return leaderHealth(this.roles.leaderOf()(rid));
- });
- /**
- * 组长是否真的在群里。
- *
- * 🔴 实测:组长 `lj` 报告「派活已发到群里」,但查群成员发现它压根不在 ——
- * 它在自己的私聊房间里自言自语,40 分钟组员没开工。
- * 现有健康检查只看 status,发现不了这个问题。
- */
- /** 实时成员(SDK 缓存可能是旧的) */
- private readonly liveMemberIds = signal<string[]>([]);
- /** 用户是否主动上翻离开了底部 */
- private userScrolledAway = false;
- /**
- * 刷新实时成员。
- *
- * 🔴 不能只在进群时拉一次 —— 成员会变(组长被踢、新成员加入),
- * 拉一次的话状态会一直停在旧值(实测:组长已重新入群,App 仍提示不在)。
- */
- private async refreshMembers(roomId: string): Promise<void> {
- const ids = await this.mx.fetchRoomMembers(roomId);
- if (ids.length === 0 || this.roomId() !== roomId) return;
- this.liveMemberIds.set(ids);
- // 🔴 同时回写群列表,让 members() 也拿到实时成员
- // (此前只更新 liveMemberIds,members() 仍读 SDK 缓存的旧值 → 只显示 1 人)
- this.groups.patchRoomMembers(roomId, ids);
- }
- readonly leaderMembership = computed(() => {
- const rid = this.roomId();
- if (!rid) return diagnoseLeader(undefined, [], 0);
- const room = this.groups.rooms().find((r) => r.roomId === rid);
- // 优先用实时拉到的成员,回落到列表里的(可能是旧的)
- const memberIds = this.liveMemberIds().length > 0 ? this.liveMemberIds() : (room?.memberIds ?? []);
- const leader = this.roles.leaderOf()(rid);
- const leaderMx = leader?.channels?.matrixConfig?.userId;
- const spoke = leaderMx
- ? this.messages().filter((m) => m.senderId === leaderMx).length
- : 0;
- return diagnoseLeader(leader, memberIds, spoke);
- });
- readonly targetStatus = computed(() => {
- const ids = this.mentionTargets();
- const picked = this.members().filter((a) => ids.has(a.id));
- return availabilityOf(picked);
- });
- readonly mentionLabel = computed(() => {
- const ids = this.mentionTargets();
- return this.members()
- .filter((a) => ids.has(a.id))
- .map((a) => '@' + a.name)
- .join(' ');
- });
- constructor() {
- this.route.paramMap.subscribe((p) => {
- const id = p.get('id');
- if (id) void this.open(id);
- else {
- const first = this.groups.rooms()[0];
- if (first) void this.open(first.roomId);
- }
- });
- // 同步到新消息时重读群会话 + 刷新成员
- effect(() => {
- this.mx.storeVersion();
- const rid = this.roomId();
- if (!rid) return;
- this.loadMessages(rid);
- void this.refreshMembers(rid);
- });
- /*
- * 消息变了就贴底。
- *
- * 🔴 不能只在 `open()` 里滚一次 —— 进群那一刻消息往往还没渲染出来,
- * 此时 `scrollToBottom` 量到的高度是 0,等于没滚
- * (实测:进群后距底 6275px)。
- * 改为监听消息列表:尾部变化就滚,且与单聊同样的三段式。
- */
- effect(() => {
- const list = this.messages();
- const tail = list.length ? list[list.length - 1].id : '';
- if (tail === this.lastTailId) return;
- this.lastTailId = tail;
- if (!tail) return;
- /*
- * 🔴 必须等 DOM 真正渲染完再滚。
- * 实测:effect 里同步调用时元素已存在但 `scrollTop` 被后续渲染重置回 0,
- * 结果是「滚了等于没滚」(距底仍 6275px)。
- * 改用 `afterNextRender` 的等价写法:等两帧 + 补一次异步内容。
- */
- queueMicrotask(() => {
- requestAnimationFrame(() => {
- this.scrollToBottom();
- requestAnimationFrame(() => {
- this.scrollToBottom();
- setTimeout(() => this.scrollToBottom(), 150);
- });
- });
- });
- });
- }
- /** 上一次渲染的最后一条消息 id(用于区分尾部新增与头部插入) */
- private lastTailId = '';
- async open(roomId: string): Promise<void> {
- this.roomId.set(roomId);
- this.groups.enter(roomId);
- this.mentionTargets.set(new Set());
- this.loading.set(true);
- // 路由同步(可直接分享链接)
- void this.router.navigateByUrl(`/${this.device.mode()}/groups/${encodeURIComponent(roomId)}`, {
- replaceUrl: this.route.snapshot.paramMap.get('id') === roomId,
- });
- this.loadMessages(roomId);
- this.hasMore.set(this.mx.paginationToken(roomId) != null);
- // 实时拉成员,避免用 SDK 缓存误判「组长在不在群」
- void this.refreshMembers(roomId);
- this.loading.set(false);
- this.stickToBottom();
- }
- /** Matrix ID → 展示名(群聊气泡里把 mentions 显示成人能读的名字) */
- readonly mxName = (mxId: string): string => {
- for (const a of this.live.agents()) {
- if (a.channels?.matrixConfig?.userId === mxId) return a.name;
- }
- return mxId.replace(/^@/, '').split(':')[0];
- };
- // ---------- 新建工作组 ----------
- readonly creating = signal(false);
- readonly newName = signal('');
- readonly newGoal = signal('');
- readonly newMembers = signal<Set<string>>(new Set());
- readonly creatingBusy = signal(false);
- readonly createError = signal('');
- /** 候选人:已接入 Matrix 的容器(同公司限制由服务端再校验) */
- readonly candidateAgents = computed(() =>
- this.live.agents().filter((a) => a.matrixReady),
- );
- openCreate(): void {
- this.creating.set(true);
- this.newName.set('');
- this.newGoal.set('');
- this.newMembers.set(new Set());
- this.createError.set('');
- }
- toggleNewMember(a: Agent): void {
- this.newMembers.update((s) => {
- const next = new Set(s);
- if (next.has(a.id)) next.delete(a.id);
- else next.add(a.id);
- return next;
- });
- }
- async submitCreate(): Promise<void> {
- const name = this.newName().trim();
- const ids = [...this.newMembers()];
- if (!name || ids.length === 0 || this.creatingBusy()) return;
- this.creatingBusy.set(true);
- this.createError.set('');
- try {
- const { roomId } = await this.api.createWorkGroup({
- name,
- goal: this.newGoal().trim(),
- memberAgentIds: ids,
- });
- this.creating.set(false);
- // 建好后立刻刷新群列表并进入
- await new Promise((r) => setTimeout(r, 1500)); // 等成员 join
- await this.groups.refreshFromServer();
- await this.open(roomId);
- /*
- * 🔴 建组后自动下发角色提示词。
- *
- * 实测:组长制能跑通,靠的是给它发过职责指令。
- * 新容器不知道规则就不会统筹 —— 不自动下发的话,用户还得手动复制一遍。
- *
- * 第一个成员默认当组长(可在成员栏改),其余是组员。
- */
- await this.briefRoles(roomId, ids);
- } catch (e) {
- // 服务端会给出明确原因(非管理员 / 跨公司等),如实展示
- this.createError.set(e instanceof Error ? e.message : '创建失败');
- } finally {
- this.creatingBusy.set(false);
- }
- }
- /**
- * 建组后给容器下发角色提示词。
- *
- * 失败不阻塞建组(提示词只是「告知」,不影响群本身可用)。
- */
- private async briefRoles(roomId: string, ids: string[]): Promise<void> {
- /*
- * 🔴 不能用 `this.members()` —— 它依赖 Matrix 房间的成员同步,
- * 刚建的群还没同步完,拿到的是空数组(实测:提示词一条没发出去)。
- * 直接用建组时选中的 ids 去平台数据里取。
- */
- const all = ids
- .map((id) => this.live.agentById(id))
- .filter((a): a is Agent => !!a);
- const leaderId = ids[0];
- const leader = all.find((a) => a.id === leaderId);
- const memberMs = all
- .filter((a) => a.id !== leaderId)
- .map((a) => a.channels?.matrixConfig?.userId)
- .filter((x): x is string => !!x);
- const leaderMx = leader?.channels?.matrixConfig?.userId;
- try {
- // 组长:下发组长职责
- if (leaderMx) {
- await this.mx.sendMention(roomId, leaderMx, LEADER_PROMPT, leader?.name);
- // 同时记下组长(成员栏显示「组长」标记)
- await this.roles.save(roomId, { ...this.roles.rolesOf()(roomId), leader: leaderId, version: 1 });
- }
- // 组员:下发组员职责
- if (memberMs.length > 0) {
- await this.mx.sendMentionMany(
- roomId,
- memberMs,
- MEMBER_PROMPT,
- all.filter((a) => a.id !== leaderId).map((a) => a.name),
- );
- }
- } catch {
- // 提示词下发失败不影响群可用,用户可手动发送
- }
- }
- /**
- * 群聊是否附带最近对话 —— **已固定为总是带上**(2026-11-01 用户要求删掉该开关)。
- *
- * 原先是设置页的一个开关(localStorage `fmode_group_attach_context`)。
- * 保留这个常量是为了不动 `buildContext(..., { enabled })` 的调用签名;
- * 关掉后的表现是「容器答非所问」,等于功能坏掉,不值得作为选项保留。
- */
- /** 正在编辑角色的成员 */
- readonly editing = signal<Agent | null>(null);
- readonly dutyDraft = signal('');
- readonly savingRole = signal(false);
- openRoleEditor(a: Agent): void {
- this.editing.set(a);
- const rid = this.roomId();
- this.dutyDraft.set(rid ? this.roles.dutyOf()(rid, a.id) : '');
- }
- toggleLeader(agentId: string): void {
- const rid = this.roomId();
- if (!rid) return;
- // 再点一次取消组长(原组长置空)
- const cur = this.isLeader(agentId);
- void this.roles.save(rid, {
- ...this.roles.rolesOf()(rid),
- leader: cur ? undefined : agentId,
- version: 1,
- });
- }
- async saveRole(): Promise<void> {
- const rid = this.roomId();
- const a = this.editing();
- if (!rid || !a || this.savingRole()) return;
- this.savingRole.set(true);
- try {
- await this.roles.setDuty(rid, a.id, this.dutyDraft().trim());
- this.editing.set(null);
- } finally {
- this.savingRole.set(false);
- }
- }
- /** @ 补全候选(边打边筛) */
- readonly mentionSuggestions = computed(() => {
- const q = this.mentionQuery();
- if (!q.active) return [];
- return filterMentionCandidates(
- this.members(),
- q.query,
- (a) => a.name,
- (a) => (this.roomId() ? this.roles.dutyOf()(this.roomId()!, a.id) : ''),
- ).slice(0, 6);
- });
- /** 选中候选:把 @片段 换成完整名字 */
- pickMention(a: Agent): void {
- const input = document.querySelector<HTMLTextAreaElement>('.input');
- const text = this.draft();
- const cursor = input?.selectionStart ?? text.length;
- const r = applyMention(text, this.mentionQuery(), a.name, cursor);
- this.draft.set(r.text);
- this.mentionQuery.set({ active: false, query: '', start: -1 });
- // 同时把它加进「被 @ 的成员」,发送时才带 mention 结构
- if (!this.mentionTargets().has(a.id)) {
- this.mentionTargets.update((s) => new Set(s).add(a.id));
- }
- // 恢复焦点与光标
- queueMicrotask(() => {
- if (!input) return;
- input.value = r.text;
- input.focus();
- input.setSelectionRange(r.cursor, r.cursor);
- });
- }
- /** 组长置顶 */
- readonly membersSorted = computed(() => {
- const rid = this.roomId();
- const list = this.members();
- if (!rid) return list;
- const leader = this.roles.leaderOf()(rid)?.id;
- return [...list].sort((a, b) => Number(b.id === leader) - Number(a.id === leader));
- });
- leaderName(): string {
- const rid = this.roomId();
- return rid ? (this.roles.leaderOf()(rid)?.name ?? '') : '';
- }
- /**
- * 该成员是否已授权接受组长指令。
- *
- * 容器默认只认自己的主人;跨主人的组员没授权时,组长 @ 它也不会执行。
- */
- isAuthorized(agentId: string): boolean {
- const rid = this.roomId();
- if (!rid) return true;
- const roles = this.roles.rolesOf()(rid);
- // 没设置 authorized 字段时视为「同主人」,不误报
- if (!roles.authorized) return true;
- return roles.authorized.includes(agentId);
- }
- /**
- * 成员「现在在干什么」。
- *
- * 🔴 只看 status 会误报:容器上报 running 但 currentTask 为空是常态。
- * 有具体任务才显示「执行中 · <任务>」,否则按职责/空闲显示。
- */
- statusLineOf(a: Agent): string {
- if (a.status === 'offline' || a.status === 'stopped') return '离线';
- if (a.status === 'error') return '异常';
- const task = a.currentTask?.trim();
- if (a.status === 'running' && task) return `执行中 · ${task}`;
- const rid = this.roomId();
- const duty = rid ? this.roles.dutyOf()(rid, a.id) : '';
- return duty ? `空闲 · ${duty}` : '空闲';
- }
- /**
- * 本群产出的资料。
- *
- * 口径:本群成员的成果(按 agentId 过滤已加载的成果列表)。
- * 与「成果」页同源,不另发请求。
- */
- /**
- * 本群资料 —— **两个来源合并**。
- *
- * 🔴 只用「成果」会让资料区空着(实测):
- * 容器常在群里直接发文件(m.file),那是聊天附件,不是成果。
- * 两种都要收,并按 URL 去重。
- */
- readonly groupMaterials = computed<Material[]>(() => {
- const ids = new Set(this.members().map((a) => a.id));
- const rid = this.roomId();
- const leader = rid ? this.roles.leaderOf()(rid) : undefined;
- if (leader) ids.add(leader.id);
- // 来源 ①:成果
- const fromDeliverables = this.deliverables
- .all()
- .filter((d) => ids.has(d.agentId))
- .map(fromDeliverable)
- .filter((x): x is Material => !!x);
- // 来源 ②:本群的聊天附件
- const fromMessages = this.messages()
- .map(fromAttachment)
- .filter((x): x is Material => !!x);
- return mergeMaterials([...fromDeliverables, ...fromMessages]);
- });
- /** 组长 Agent(运行状态块用;与单聊右侧同结构) */
- readonly leaderAgent = computed<Agent | undefined>(() => {
- const rid = this.roomId();
- return rid ? this.roles.leaderOf()(rid) : undefined;
- });
- readonly pct = (v?: number) => fmtPercent(v);
- readonly tokens = (a: { usage?: { totalTokens?: number } }) => fmtTokens(usageOf(a as never).totalTokens);
- openGroupMaterial(m: Material): void {
- this.openFile({ url: m.url, name: m.filename || m.title, mimeType: '' });
- }
- materialIcon(d: Material): string {
- const t = (d.fileType || '').toUpperCase();
- if (t === 'HTML') return 'fa-file-code';
- if (t === 'PNG' || t === 'JPG' || t === 'JPEG') return 'fa-file-image';
- if (t === 'PDF') return 'fa-file-pdf';
- if (t === 'CSV' || t === 'XLSX') return 'fa-file-csv';
- return 'fa-file-lines';
- }
- /** 是否真的在忙(有任务才算,避免误报) */
- isReallyBusy(a: Agent): boolean {
- return a.status === 'running' && !!a.currentTask?.trim();
- }
- isLeader(agentId: string): boolean {
- const rid = this.roomId();
- return rid ? this.roles.leaderOf()(rid)?.id === agentId : false;
- }
- memberNames(roomId: string): string {
- const list = this.groups.membersOf()(roomId);
- return list.length ? list.map((a) => a.name).join('、') : '暂无数字生命';
- }
- /** 我自己的 Matrix ID —— 从会话服务取,不依赖 chat.provisionInfo */
- private readonly myMxId = computed(() => {
- // session.me() 的 id 是平台 _User.objectId(小写后即 Matrix 本地部分)
- const id = this.session.me()?.id;
- return id ? `@${id.toLowerCase()}:fmode.cn` : '';
- });
- private loadMessages(roomId: string): void {
- /*
- * 🔴 不能用 `chat.provisionInfo.userMxId` —— 那是**打开 DM 后**才有值的,
- * 群聊页从没打开过 DM → 恒为 undefined → `my` 是空串 →
- * 所有消息都被判成 agent(实测:自己发的消息显示在左边、还带着名字)。
- * 改为从 session.me() 推导,不依赖 chat 的状态。
- */
- const my = this.chat.provisionInfo?.userMxId || this.myMxId();
- const raw = this.mx.timelineToMessages(roomId);
- /*
- * 🔴 发送者名字优先用**平台数据**(agent 的 botName),不用 Matrix 的 displayName。
- *
- * 实测:容器账号没有设置 Matrix displayName,`getMember().name` 为空,
- * 于是回落到 Matrix ID(`k0nrevcfik` / `zgdcmgnqcd`)——
- * 用户看到的是一串技术 ID,完全不知道是谁。
- * 平台侧有 `botName`(`lj` / `lj-test`),这才是有意义的名字。
- */
- const nameByMx = new Map<string, string>();
- for (const a of this.live.agents()) {
- const mx = a.channels?.matrixConfig?.userId;
- if (mx) nameByMx.set(mx, a.name);
- }
- /*
- * 自己的名字:用平台昵称(`用户9836`),不用 Matrix 的 displayName。
- * Matrix 里那个名字可能是账号创建时随手设的(实测带着 emoji 尾巴),
- * 在群里显示出来很奇怪。
- */
- if (my) nameByMx.set(my, this.session.me()?.name ?? '我');
- this.messages.set(
- raw
- .filter((m) => !!m.body?.trim())
- .map((m) => {
- // 文件卡片是 `m.text` + JSON body,解析出来才能渲染成附件而不是一坨 JSON
- // 两种附件形态都要认:
- // ① 我们的约定:m.text + JSON body
- // ② Matrix 原生:m.file / m.image + mxc://(实测容器用的是这个)
- const file = MatrixClientService.parseFileCard(m.body) ?? (m.media
- ? { name: m.media.name, size: m.media.size, mimeType: m.media.mimeType, url: m.media.url }
- : null);
- return {
- id: m.eventId,
- conversationId: roomId,
- sender: m.sender === my ? ('me' as const) : ('agent' as const),
- senderId: m.sender,
- senderName: nameByMx.get(m.sender) ?? m.senderName ?? m.sender,
- kind: m.kind,
- text: file ? undefined : m.body,
- file: file ? { kind: 'file' as const, ...file } : undefined,
- ts: new Date(m.ts).toISOString(),
- state: 'sent' as const,
- mentions: m.mentions,
- };
- }),
- );
- this.checkHijack(my);
- }
- /**
- * 检查上一轮派活是否被「抢答」。
- *
- * 实测(2026-10-28):@ lj-test 派活,执行的是 agent-lj ——
- * 对方 `MATRIX_REQUIRE_MENTION=false`,群里任何消息都处理。
- * 不提示的话用户会误以为「组员干了活」。
- *
- * 只在**派活后的一段时间内**检查,过期自动清除,避免旧提示常驻。
- */
- private checkHijack(myMxId: string): void {
- const d = this.lastDispatch;
- if (!d) return;
- // 派活超过 5 分钟还没有结论 → 不再提示(用户已经看到结果了)
- if (Date.now() - d.at > 5 * 60_000) {
- this.hijackHintText.set(null);
- this.lastDispatch = null;
- return;
- }
- const all = this.messages();
- const report = detectHijack(d.expected, repliesAfter(all, d.at, myMxId));
- if (!report.hasHijack) {
- this.hijackHintText.set(null);
- return;
- }
- const nameOf = (id: string) =>
- this.live.agents().find((a) => a.channels?.matrixConfig?.userId === id)?.name ?? id;
- this.hijackHintText.set(hijackHint(report, nameOf));
- }
- /** 关闭抢答提示 */
- dismissHijack(): void {
- this.hijackHintText.set(null);
- this.lastDispatch = null;
- }
- /** 输入框占位文案:始终提示「要先选人」 */
- targetHint(): string {
- const n = this.mentionTargets().size;
- if (n > 0) {
- const names = this.members()
- .filter((a) => this.mentionTargets().has(a.id))
- .map((a) => a.name)
- .join('、');
- return `发给 ${names},输入内容回车发送`;
- }
- return '先点下方 @ 选择让谁做事,再输入内容';
- }
- /** 一键 @ 某人(用于「交给组长」快捷操作) */
- quickMention(a: Agent): void {
- this.mentionTargets.update((s) => new Set(s).add(a.id));
- this.sendBlocked.set(false);
- }
- toggleMention(a: Agent): void {
- this.mentionTargets.update((s) => {
- const next = new Set(s);
- if (next.has(a.id)) next.delete(a.id);
- else next.add(a.id);
- return next;
- });
- }
- clearMention(): void {
- this.mentionTargets.set(new Set());
- }
- onInput(e: Event): void {
- const el = e.target as HTMLTextAreaElement;
- this.draft.set(el.value);
- el.style.height = 'auto';
- el.style.height = Math.min(el.scrollHeight, 160) + 'px';
- // 边打边筛:根据光标位置的 @ 片段更新候选
- this.mentionQuery.set(parseMentionQuery(el.value, el.selectionStart ?? el.value.length));
- }
- onKeydown(e: KeyboardEvent): void {
- // 候选列表打开时,回车优先「选中候选」而不是发送
- if (e.key === 'Enter' && !e.shiftKey && this.mentionSuggestions().length > 0) {
- e.preventDefault();
- this.pickMention(this.mentionSuggestions()[0]);
- return;
- }
- if (e.key === 'Escape') {
- this.mentionQuery.set({ active: false, query: '', start: -1 });
- return;
- }
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault();
- void this.send();
- }
- }
- /** 消息附件预览(与 DM 共用同一个预览器) */
- readonly previewFile = signal<{ url: string; name: string; mimeType: string } | null>(null);
- /** 图片单独走灯箱(2026-10-30:图片不进预览弹窗) */
- readonly lightboxFile = signal<{ url: string; name: string; size: number } | null>(null);
- openFile(f: { url: string; name: string; mimeType: string; size?: number }): void {
- // 图片不进预览弹窗,直接打开灯箱(2026-10-30)
- if (previewKindOf(f.name || f.url, f.mimeType) === 'image') {
- this.lightboxFile.set({ url: f.url, name: f.name, size: f.size ?? 0 });
- return;
- }
- this.previewFile.set(f);
- }
- readonly uploading = signal(false);
- readonly uploadPct = signal(0);
- /**
- * 群内上传文件。
- *
- * 与 DM 共用 `FmodeApi.uploadFile`(预签名直传 OBS),
- * 只是发送到**群房间**而不是 DM 房间。
- */
- async onFiles(e: Event): Promise<void> {
- const input = e.target as HTMLInputElement;
- const files = Array.from(input.files ?? []);
- input.value = ''; // 允许重复选同一文件
- const rid = this.roomId();
- if (!rid || files.length === 0 || this.uploading()) return;
- this.uploading.set(true);
- this.uploadPct.set(0);
- try {
- for (const f of files) {
- const { publicUrl } = await this.api.uploadFile(f, (pct) => this.uploadPct.set(pct));
- await this.mx.sendFileCard(rid, {
- name: f.name,
- size: f.size,
- mimeType: f.type || 'application/octet-stream',
- url: publicUrl,
- });
- }
- this.loadMessages(rid);
- this.stickToBottom();
- } catch (err) {
- // 上传失败要让用户知道,不静默
- this.uploadError.set(err instanceof Error ? err.message : '上传失败');
- } finally {
- this.uploading.set(false);
- this.uploadPct.set(0);
- }
- }
- readonly uploadError = signal('');
- async send(): Promise<void> {
- const text = this.draft().trim();
- const rid = this.roomId();
- if (!text || !rid || this.sending()) return;
- /*
- * 🔴 **不再默认发给组长**(2026-10-28 用户裁决)。
- *
- * 原来的「没选人 → 自动发组长」有两个问题:
- * ① 用户以为自己在跟某个人说话,实际发给了组长 → 语义不清
- * ② 群里容器 `REQUIRE_MENTION=false` 时,**不 @ 任何人的消息所有容器都会处理**
- * → 目标完全失控(实测:默认发给组长,结果别的容器也抢答)
- *
- * 现在:必须明确选人。没选就提示,不发出去。
- */
- const targets = this.members().filter((a) => this.mentionTargets().has(a.id));
- if (targets.length === 0) {
- this.sendBlocked.set(true);
- return;
- }
- this.sendBlocked.set(false);
- this.sending.set(true);
- try {
- /*
- * 上下文**只给组长**(2026-10-28 实测结论)。
- *
- * 容器的 `messages` 表里只有「用户消息 + 自己的回复」——
- * **看不到其他容器说的话**(lj-test 库中 user 角色全部来自用户本人)。
- * 所以「同事说了什么」必须由 App 补。但补给谁很关键:
- *
- * · 组长:要理解前因后果、综合各容器反馈、决定下一步 → **需要**
- * · 组员:只需要知道「我被派了什么活」→ **不需要**,塞别人的对话只是噪音
- *
- * 这和一个真实项目的分工一致:成员看任务单,不看会议纪要。
- */
- /*
- * 兜底:群没配组长时,至少给**唯一目标**带上上下文 ——
- * 否则这个群彻底失去「让容器知道前因」的能力,
- * 比「只给组员多了点噪音」糟糕得多。
- */
- const leaderNow = rid ? this.roles.leaderOf()(rid) : undefined;
- const toLeader = leaderNow
- ? targets.length === 1 && targets[0].id === leaderNow.id
- : targets.length <= 1;
- const ctx = toLeader
- ? buildContext(this.messages(), { enabled: this.attachContext })
- : '';
- /*
- * 「继续刚才的活」这类指令:追加一句「先查产物」提醒。
- * 实测:容器查工作区能恢复状态,查会话记录则失败。
- */
- const hint = looksLikeResume(text) ? ARTIFACT_HINT + '\n\n' : '';
- const payload = ctx ? ctx + hint + text : hint + text;
- /*
- * 记下本轮 @ 的目标 —— 用于随后检测「抢答」:
- * 实测 @ lj-test 派活,执行的是 agent-lj(对方 REQUIRE_MENTION=false),
- * 不检测的话用户会误以为组员干了活。
- */
- const expectedMx = targets
- .map((a) => a.channels?.matrixConfig?.userId ?? '')
- .filter(Boolean);
- this.lastDispatch = expectedMx.length > 0
- ? { expected: expectedMx, at: Date.now() }
- : null;
- this.hijackHintText.set(null);
- if (targets.length === 0) {
- await this.mx.sendText(rid, payload);
- } else {
- await this.mx.sendMentionMany(
- rid,
- expectedMx,
- payload,
- targets.map((a) => a.name),
- );
- }
- this.draft.set('');
- this.clearMention();
- const input = document.querySelector<HTMLTextAreaElement>('.input');
- if (input) {
- input.value = '';
- input.style.height = 'auto';
- }
- this.loadMessages(rid);
- this.stickToBottom();
- } finally {
- this.sending.set(false);
- }
- }
- /**
- * 滚到顶部附近 → 往前翻历史,并**保持视觉位置不动**。
- *
- * 与 DM 页同一套做法(那边踩过坑):
- * 补偿必须在**渲染完成后**量 `scrollHeight`,`queueMicrotask` 时新内容
- * 还没进 DOM,量到的是旧高度,补偿量算成 0 → 视口被顶飞。
- */
- onScroll(): void {
- const el = this.messagesEl()?.nativeElement;
- if (!el) return;
- // 记住「用户是否主动上翻」,避免新消息把人拽回底部
- const gap = el.scrollHeight - el.scrollTop - el.clientHeight;
- this.userScrolledAway = gap > 160;
- if (el.scrollTop > 80) return;
- if (this.paginating() || !this.hasMore()) return;
- void this.loadEarlier();
- }
- async loadEarlier(): Promise<void> {
- const el = this.messagesEl()?.nativeElement;
- const rid = this.roomId();
- if (!el || !rid) return;
- const beforeHeight = el.scrollHeight;
- const beforeTop = el.scrollTop;
- this.paginating.set(true);
- try {
- const grew = await this.mx.paginateBack(rid, 30);
- if (!grew) {
- this.hasMore.set(false);
- return;
- }
- this.loadMessages(rid);
- // 两帧后再量:一帧可能还没完成 markdown 渲染
- requestAnimationFrame(() => {
- requestAnimationFrame(() => {
- const added = el.scrollHeight - beforeHeight;
- if (added > 0) el.scrollTop = beforeTop + added;
- });
- });
- this.hasMore.set(this.mx.paginationToken(rid) != null);
- } finally {
- this.paginating.set(false);
- }
- }
- /**
- * 贴底 —— 与单聊**同一套逻辑**(用户要求两边一致)。
- *
- * 🔴 只滚一次不够:消息进 DOM 时 markdown 还没渲染完,
- * `scrollHeight` 偏小 → 滚完仍差一截,用户看到的是中间某处。
- * 做法:立即滚 + rAF 再滚 + 120ms 补一次(异步内容会继续长高)。
- */
- private stickToBottom(): void {
- // 用户主动上翻看历史时不要把他拽回来
- if (this.userScrolledAway) return;
- this.scrollToBottom();
- requestAnimationFrame(() => {
- this.scrollToBottom();
- setTimeout(() => this.scrollToBottom(), 120);
- });
- }
- private scrollToBottom(): void {
- const el = this.messagesEl()?.nativeElement;
- if (el) el.scrollTop = el.scrollHeight;
- }
- }
|