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: `
@if (!roomId()) {
} @else {
{{ currentRoom()?.name }} {{ members().length }} 名数字生命 @if (leaderName(); as ln) { · 组长 {{ ln }} }
@if (hijackHintText(); as hint) { }
@if (loading()) {
@for (i of [1, 2, 3]; track i) { }
} @else if (messages().length === 0) { } @else {
@if (paginating()) { } @else if (hasMore()) { 加载更早 } @else { 已到最早 }
@for (m of messages(); track m.id) { } }
@if (mentionOpen()) {
选择要 @ 的成员(可多选):
@for (a of members(); track a.id) { }
}
@if (mentionSuggestions().length > 0) {
@for (a of mentionSuggestions(); track a.id) { }
} @if (leaderMembership().warn) {
{{ leaderMembership().message }}
} @else if (leaderStatus().warn) {
{{ leaderStatus().message }} @if (leaderStatus().suggestReplace) { }
} @else if (dispatchIssue(); as issue) {
派给 {{ issue.member }} 的活没送到:{{ issue.detail }}
} @else if (memberStatus().state === 'stuck') {
{{ memberStatus().hint }}
} @else if (targetStatus().warn) {
{{ targetStatus().hint }}
} @if (uploading()) {
正在上传 {{ uploadPct() }}%
} @if (mentionTargets().size > 0) {
{{ mentionLabel() }}
}
@if (sendBlocked()) { }
}
@if (creating()) { } @if (editing()) { } @if (roomId() && membersOpen()) { }
`, 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(null); readonly messages = signal([]); 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(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>(new Set()); /** 上滚加载历史中(防重入) */ readonly paginating = signal(false); /** 还有更早的历史 */ readonly hasMore = signal(false); private readonly messagesEl = viewChild>('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(() => { 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([]); /** 用户是否主动上翻离开了底部 */ private userScrolledAway = false; /** * 刷新实时成员。 * * 🔴 不能只在进群时拉一次 —— 成员会变(组长被踢、新成员加入), * 拉一次的话状态会一直停在旧值(实测:组长已重新入群,App 仍提示不在)。 */ private async refreshMembers(roomId: string): Promise { 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 { 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>(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 { 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 { /* * 🔴 不能用 `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(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 { 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('.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(() => { 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(() => { 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(); 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 { 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 { 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('.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 { 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; } }