new-session-modal.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  1. import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core';
  2. import { Router } from '@angular/router';
  3. import { UiAvatar, UiButton, UiModal } from '../../../ui';
  4. import { DeviceService } from '../../../core/device.service';
  5. import { LiveService } from '../../../core/live.service';
  6. import { NewSessionModalService } from './new-session-modal.service';
  7. import { WorkspaceService } from '../../../core/workspace.service';
  8. import { GroupChatService } from '../../../core/group-chat.service';
  9. import { Agent, SemanticTone } from '../../../core/api/models';
  10. import { agentLayers } from '../../../core/agent-state';
  11. /**
  12. * 「新建对话」模态框(全局单例,挂在外壳上)
  13. *
  14. * 流程:选容器 → 在该容器下新建一条**空对话** → 落进「日常对话」→ 跳过去。
  15. *
  16. * 2026-10-30 用户要求:所有新建入口统一成一个按钮,且新建的对话**默认落在日常对话**。
  17. */
  18. @Component({
  19. selector: 'app-new-session-modal',
  20. changeDetection: ChangeDetectionStrategy.OnPush,
  21. imports: [UiModal, UiButton, UiAvatar],
  22. template: `
  23. <ui-modal [open]="service.isOpen()" title="新建对话" size="sm" (openChange)="$event || close()">
  24. <p class="note">
  25. 选择要对话的容器。新建的对话会存放在
  26. 「{{ targetName() }}」里。
  27. </p>
  28. <!-- 分组:单聊(默认)/ 群聊 —— 与移动端同一套语义(2026-11-05) -->
  29. <div class="mode-tabs" role="tablist" aria-label="会话类型">
  30. <button
  31. type="button"
  32. role="tab"
  33. [class.on]="mode() === 'dm'"
  34. [attr.aria-selected]="mode() === 'dm'"
  35. (click)="mode.set('dm')"
  36. >
  37. 单聊
  38. </button>
  39. <button
  40. type="button"
  41. role="tab"
  42. [class.on]="mode() === 'group'"
  43. [attr.aria-selected]="mode() === 'group'"
  44. (click)="mode.set('group')"
  45. >
  46. 群聊
  47. </button>
  48. </div>
  49. <div class="pick-list" role="listbox" aria-label="选择容器">
  50. @for (a of agents(); track a.id) {
  51. <button
  52. type="button"
  53. class="pick-row"
  54. [class.pick-row--checked]="isChecked(a)"
  55. [attr.aria-selected]="mode() === 'group' ? isChecked(a) : false"
  56. (click)="mode() === 'group' ? toggleMember(a) : createIn(a.id)"
  57. >
  58. @if (mode() === 'group') {
  59. <span class="pick-check" [class.on]="isChecked(a)" aria-hidden="true">
  60. @if (isChecked(a)) {
  61. <i class="fa-solid fa-check"></i>
  62. }
  63. </span>
  64. }
  65. <ui-avatar [name]="a.name" [size]="'sm'" />
  66. <span class="pick-text">
  67. <span class="pick-name ellipsis">{{ a.name }}</span>
  68. <span class="pick-sub ellipsis">{{ a.specialty || '数字生命' }}</span>
  69. </span>
  70. <!--
  71. 容器状态(2026-11-08 用户要求「PC 新建会话也像移动端一样显示状态」)。
  72. 与全站同一口径 resolveAgentState(判心跳、防上报器自指、判可用性)。
  73. 颜色按真实语义 —— 不一律涂红,否则「未接入」和「异常」分不清。
  74. -->
  75. <span class="pick-state" [attr.data-tone]="stateOf(a).tone">
  76. <i class="dot" aria-hidden="true"></i>{{ stateOf(a).text }}
  77. </span>
  78. <i class="fa-solid fa-chevron-right pick-arrow" aria-hidden="true"></i>
  79. </button>
  80. } @empty {
  81. <p class="pick-empty">没有可用的容器(需先在平台完成接入)。</p>
  82. }
  83. </div>
  84. @if (mode() === 'group') {
  85. @if (groupError()) {
  86. <p class="pick-empty" role="alert">{{ groupError() }}</p>
  87. }
  88. <p class="note note--muted">
  89. 已选 {{ selected().length }} 个容器(至少 2 个)。群聊里每个容器在自己的独立会话中工作,彼此看不到对方的发言。
  90. </p>
  91. }
  92. <div footer class="acts">
  93. <ui-button variant="ghost" (click)="close()">取消</ui-button>
  94. @if (mode() === 'group') {
  95. <ui-button
  96. variant="primary"
  97. [disabled]="selected().length < 2"
  98. (click)="createGroup()"
  99. >
  100. 创建群聊({{ selected().length }})
  101. </ui-button>
  102. }
  103. </div>
  104. </ui-modal>
  105. `,
  106. styles: `
  107. :host {
  108. display: contents;
  109. }
  110. .note {
  111. margin: 0 0 var(--sp-3);
  112. font-size: var(--fs-13);
  113. color: var(--muted);
  114. line-height: 1.7;
  115. }
  116. /*
  117. * 固定高度 + 内部滚动(2026-11-03 用户要求)。
  118. * 超出部分在列表内滚动选择,弹窗整体高度保持不变 ——
  119. * 否则容器多时弹窗会撑到屏幕外,「取消」按钮点不到。
  120. */
  121. /* 单聊 / 群聊 分组 */
  122. .mode-tabs {
  123. display: flex;
  124. gap: var(--sp-1);
  125. padding: var(--sp-1);
  126. margin-bottom: var(--sp-3);
  127. border: 1px solid var(--edge);
  128. border-radius: var(--r-tag);
  129. background: var(--surface-2);
  130. }
  131. .mode-tabs button {
  132. flex: 1;
  133. height: 34px;
  134. border: 0;
  135. border-radius: var(--r-tag);
  136. background: none;
  137. color: var(--muted);
  138. font: inherit;
  139. font-size: var(--fs-13);
  140. cursor: pointer;
  141. }
  142. .mode-tabs button.on {
  143. background: var(--surface);
  144. color: var(--ink);
  145. font-weight: 600;
  146. box-shadow: var(--shadow-soft);
  147. }
  148. .pick-check {
  149. display: grid;
  150. place-items: center;
  151. flex: none;
  152. width: 18px;
  153. height: 18px;
  154. border: 1.5px solid var(--edge-strong);
  155. border-radius: var(--r-sm);
  156. color: var(--primary-content);
  157. font-size: var(--fs-9);
  158. }
  159. .pick-check.on {
  160. border-color: var(--primary);
  161. background: var(--primary);
  162. }
  163. .pick-row--checked {
  164. background: var(--primary-subtle);
  165. }
  166. .note--muted {
  167. margin-top: var(--sp-3);
  168. color: var(--muted);
  169. font-size: var(--fs-12);
  170. }
  171. .pick-list {
  172. display: grid;
  173. gap: var(--sp-1);
  174. max-height: 360px;
  175. overflow-y: auto;
  176. overscroll-behavior: contain;
  177. scrollbar-width: thin;
  178. /* 底部渐隐提示「还有内容」。
  179. * currentColor 在此处是不透明前景(mask 只取 alpha),等价于原先的 #000,
  180. * 且不必写死色值。 */
  181. mask-image: linear-gradient(to bottom, currentColor calc(100% - 24px), transparent);
  182. padding-bottom: var(--sp-4);
  183. }
  184. .pick-row {
  185. display: flex;
  186. align-items: center;
  187. gap: var(--sp-3);
  188. min-height: var(--touch-min);
  189. padding: var(--sp-2) var(--sp-3);
  190. border: 1px solid var(--edge);
  191. border-radius: var(--r-md);
  192. background: var(--surface);
  193. text-align: left;
  194. cursor: pointer;
  195. transition: background-color var(--motion-fast) var(--ease-standard);
  196. }
  197. .pick-row:hover {
  198. background: var(--surface-hover);
  199. border-color: var(--edge-strong);
  200. }
  201. .pick-text {
  202. display: grid;
  203. flex: 1;
  204. min-width: 0;
  205. line-height: 1.3;
  206. }
  207. .pick-name {
  208. font-size: var(--fs-13);
  209. font-weight: 600;
  210. color: var(--ink);
  211. }
  212. .pick-sub {
  213. font-size: var(--fs-11);
  214. color: var(--muted);
  215. }
  216. .pick-arrow {
  217. flex-shrink: 0;
  218. font-size: var(--fs-11);
  219. color: var(--faint);
  220. }
  221. /*
  222. * 容器状态(2026-11-08):圆点 + 文案,颜色走语义 token。
  223. * 只用颜色不够(WCAG):始终带文字,颜色只是加强。
  224. */
  225. .pick-state {
  226. flex-shrink: 0;
  227. display: inline-flex;
  228. align-items: center;
  229. gap: var(--sp-1);
  230. font-size: var(--fs-11);
  231. color: var(--muted);
  232. white-space: nowrap;
  233. }
  234. .pick-state .dot {
  235. width: 6px;
  236. height: 6px;
  237. border-radius: 50%;
  238. background: currentColor;
  239. }
  240. .pick-state[data-tone='success'] {
  241. color: var(--success);
  242. }
  243. .pick-state[data-tone='warning'] {
  244. color: var(--warning);
  245. }
  246. .pick-state[data-tone='danger'] {
  247. color: var(--danger);
  248. }
  249. .pick-state[data-tone='info'] {
  250. color: var(--primary);
  251. }
  252. .pick-state[data-tone='neutral'] {
  253. color: var(--muted);
  254. }
  255. .pick-empty {
  256. margin: 0;
  257. padding: var(--sp-4);
  258. font-size: var(--fs-13);
  259. color: var(--muted);
  260. text-align: center;
  261. }
  262. .acts {
  263. display: flex;
  264. justify-content: flex-end;
  265. }
  266. `
  267. })
  268. export class NewSessionModalComponent {
  269. protected readonly service = inject(NewSessionModalService);
  270. private readonly live = inject(LiveService);
  271. private readonly router = inject(Router);
  272. private readonly device = inject(DeviceService);
  273. private readonly workspaces = inject(WorkspaceService);
  274. private readonly groupChat = inject(GroupChatService);
  275. /** 会话类型:单聊 / 群聊(2026-11-05) */
  276. protected readonly mode = signal<'dm' | 'group'>('dm');
  277. /** 群聊已选成员 */
  278. protected readonly selected = signal<string[]>([]);
  279. protected readonly groupError = signal<string | null>(null);
  280. constructor() {
  281. /*
  282. * 打开时套用调用方预设的标签(2026-11-06)。
  283. * 左栏「群聊」分组头的「+」直接把用户送到群聊标签,
  284. * 不必先进单聊再手动切换。打开期间用户自己切标签不受影响 ——
  285. * 这里只在 isOpen 变 true 的那一刻同步一次。
  286. */
  287. effect(() => {
  288. if (!this.service.isOpen()) return;
  289. this.mode.set(this.service.mode());
  290. this.selected.set([]);
  291. this.groupError.set(null);
  292. });
  293. }
  294. /** 可选容器:已接入 Matrix 的数字生命(与旧实现同源) */
  295. /** 目标工作区名(模态框文案) */
  296. protected readonly targetName = computed(() => {
  297. const id = this.service.workspaceId();
  298. if (!id) return '日常对话';
  299. return this.workspaces.byId(id)?.name ?? '日常对话';
  300. });
  301. protected readonly agents = computed(() =>
  302. this.live
  303. .agents()
  304. .filter((a) => a.matrixReady)
  305. .sort((x, y) => x.name.localeCompare(y.name, 'zh') || x.id.localeCompare(y.id)),
  306. );
  307. /**
  308. * 容器**是否在线**(2026-11-09 用户裁决)。
  309. *
  310. * 🔴 用户原话:「新建对话这里只需要显示容器是否在线就行,不需要显示容器的状态,
  311. * 容器的状态是在会话页面显示的」。
  312. *
  313. * 所以这里只回答一个问题:**能不能跟它说话**。
  314. * 判据用三层模型:心跳活着(`alive`)+ 通道通(`link === 'ready'`)。
  315. * 不再输出「正在执行 / 空闲」——那是会话页的职责。
  316. */
  317. protected stateOf(a: Agent): { text: string; tone: SemanticTone } {
  318. const l = agentLayers(a);
  319. const online = l.alive !== 'dead' && l.alive !== 'stale' && l.link === 'ready';
  320. return online
  321. ? { text: '在线', tone: 'success' }
  322. : { text: '离线', tone: 'danger' };
  323. }
  324. protected createIn(agentId: string): void {
  325. const target = this.service.workspaceId();
  326. this.close();
  327. /*
  328. * 🔴 **不在这里创建会话**(2026-11-01 用户裁决:首次发消息才建会话)。
  329. *
  330. * 旧实现会 `sessions.create()` 一条空会话再跳转 —— 用户选完容器却没发消息,
  331. * 就留下一条 0 条消息的「新对话」,反复几次列表就堆满空壳。
  332. * 现在只是跳到「该容器、待建」的地址,输入第一条消息时才真正建会话。
  333. *
  334. * 工作区归属随查询参数带过去,由聊天页在会话建出来之后落地。
  335. */
  336. const qs = target ? `?ws=${encodeURIComponent(target)}` : '';
  337. void this.router.navigateByUrl(`/${this.device.mode()}/chat/${agentId}/new${qs}`);
  338. }
  339. protected isChecked(a: Agent): boolean {
  340. return this.selected().includes(a.id);
  341. }
  342. protected toggleMember(a: Agent): void {
  343. this.groupError.set(null);
  344. this.selected.update((cur) =>
  345. cur.includes(a.id) ? cur.filter((x) => x !== a.id) : [...cur, a.id],
  346. );
  347. }
  348. /** 创建群聊:建群 → 关弹窗 → 进群聊页 */
  349. protected createGroup(): void {
  350. const ids = this.selected();
  351. if (ids.length < 2) {
  352. this.groupError.set('群聊至少需要选择 2 个容器');
  353. return;
  354. }
  355. try {
  356. const ws = this.service.workspaceId() ?? null;
  357. const s = this.groupChat.createGroup(ids, ws);
  358. this.selected.set([]);
  359. this.mode.set('dm');
  360. this.close();
  361. void this.router.navigateByUrl(`/${this.device.mode()}/group/${encodeURIComponent(s.id)}`);
  362. } catch (e) {
  363. this.groupError.set(e instanceof Error ? e.message : '创建群聊失败');
  364. }
  365. }
  366. protected close(): void {
  367. this.service.close();
  368. }
  369. }