| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384 |
- import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core';
- import { Router } from '@angular/router';
- import { UiAvatar, UiButton, UiModal } from '../../../ui';
- import { DeviceService } from '../../../core/device.service';
- import { LiveService } from '../../../core/live.service';
- import { NewSessionModalService } from './new-session-modal.service';
- import { WorkspaceService } from '../../../core/workspace.service';
- import { GroupChatService } from '../../../core/group-chat.service';
- import { Agent, SemanticTone } from '../../../core/api/models';
- import { agentLayers } from '../../../core/agent-state';
- /**
- * 「新建对话」模态框(全局单例,挂在外壳上)
- *
- * 流程:选容器 → 在该容器下新建一条**空对话** → 落进「日常对话」→ 跳过去。
- *
- * 2026-10-30 用户要求:所有新建入口统一成一个按钮,且新建的对话**默认落在日常对话**。
- */
- @Component({
- selector: 'app-new-session-modal',
- changeDetection: ChangeDetectionStrategy.OnPush,
- imports: [UiModal, UiButton, UiAvatar],
- template: `
- <ui-modal [open]="service.isOpen()" title="新建对话" size="sm" (openChange)="$event || close()">
- <p class="note">
- 选择要对话的容器。新建的对话会存放在
- 「{{ targetName() }}」里。
- </p>
- <!-- 分组:单聊(默认)/ 群聊 —— 与移动端同一套语义(2026-11-05) -->
- <div class="mode-tabs" role="tablist" aria-label="会话类型">
- <button
- type="button"
- role="tab"
- [class.on]="mode() === 'dm'"
- [attr.aria-selected]="mode() === 'dm'"
- (click)="mode.set('dm')"
- >
- 单聊
- </button>
- <button
- type="button"
- role="tab"
- [class.on]="mode() === 'group'"
- [attr.aria-selected]="mode() === 'group'"
- (click)="mode.set('group')"
- >
- 群聊
- </button>
- </div>
- <div class="pick-list" role="listbox" aria-label="选择容器">
- @for (a of agents(); track a.id) {
- <button
- type="button"
- class="pick-row"
- [class.pick-row--checked]="isChecked(a)"
- [attr.aria-selected]="mode() === 'group' ? isChecked(a) : false"
- (click)="mode() === 'group' ? toggleMember(a) : createIn(a.id)"
- >
- @if (mode() === 'group') {
- <span class="pick-check" [class.on]="isChecked(a)" aria-hidden="true">
- @if (isChecked(a)) {
- <i class="fa-solid fa-check"></i>
- }
- </span>
- }
- <ui-avatar [name]="a.name" [size]="'sm'" />
- <span class="pick-text">
- <span class="pick-name ellipsis">{{ a.name }}</span>
- <span class="pick-sub ellipsis">{{ a.specialty || '数字生命' }}</span>
- </span>
- <!--
- 容器状态(2026-11-08 用户要求「PC 新建会话也像移动端一样显示状态」)。
- 与全站同一口径 resolveAgentState(判心跳、防上报器自指、判可用性)。
- 颜色按真实语义 —— 不一律涂红,否则「未接入」和「异常」分不清。
- -->
- <span class="pick-state" [attr.data-tone]="stateOf(a).tone">
- <i class="dot" aria-hidden="true"></i>{{ stateOf(a).text }}
- </span>
- <i class="fa-solid fa-chevron-right pick-arrow" aria-hidden="true"></i>
- </button>
- } @empty {
- <p class="pick-empty">没有可用的容器(需先在平台完成接入)。</p>
- }
- </div>
- @if (mode() === 'group') {
- @if (groupError()) {
- <p class="pick-empty" role="alert">{{ groupError() }}</p>
- }
- <p class="note note--muted">
- 已选 {{ selected().length }} 个容器(至少 2 个)。群聊里每个容器在自己的独立会话中工作,彼此看不到对方的发言。
- </p>
- }
- <div footer class="acts">
- <ui-button variant="ghost" (click)="close()">取消</ui-button>
- @if (mode() === 'group') {
- <ui-button
- variant="primary"
- [disabled]="selected().length < 2"
- (click)="createGroup()"
- >
- 创建群聊({{ selected().length }})
- </ui-button>
- }
- </div>
- </ui-modal>
- `,
- styles: `
- :host {
- display: contents;
- }
- .note {
- margin: 0 0 var(--sp-3);
- font-size: var(--fs-13);
- color: var(--muted);
- line-height: 1.7;
- }
- /*
- * 固定高度 + 内部滚动(2026-11-03 用户要求)。
- * 超出部分在列表内滚动选择,弹窗整体高度保持不变 ——
- * 否则容器多时弹窗会撑到屏幕外,「取消」按钮点不到。
- */
- /* 单聊 / 群聊 分组 */
- .mode-tabs {
- display: flex;
- gap: var(--sp-1);
- padding: var(--sp-1);
- margin-bottom: var(--sp-3);
- border: 1px solid var(--edge);
- border-radius: var(--r-tag);
- background: var(--surface-2);
- }
- .mode-tabs button {
- flex: 1;
- height: 34px;
- border: 0;
- border-radius: var(--r-tag);
- background: none;
- color: var(--muted);
- font: inherit;
- font-size: var(--fs-13);
- cursor: pointer;
- }
- .mode-tabs button.on {
- background: var(--surface);
- color: var(--ink);
- font-weight: 600;
- box-shadow: var(--shadow-soft);
- }
- .pick-check {
- display: grid;
- place-items: center;
- flex: none;
- width: 18px;
- height: 18px;
- border: 1.5px solid var(--edge-strong);
- border-radius: var(--r-sm);
- color: var(--primary-content);
- font-size: var(--fs-9);
- }
- .pick-check.on {
- border-color: var(--primary);
- background: var(--primary);
- }
- .pick-row--checked {
- background: var(--primary-subtle);
- }
- .note--muted {
- margin-top: var(--sp-3);
- color: var(--muted);
- font-size: var(--fs-12);
- }
- .pick-list {
- display: grid;
- gap: var(--sp-1);
- max-height: 360px;
- overflow-y: auto;
- overscroll-behavior: contain;
- scrollbar-width: thin;
- /* 底部渐隐提示「还有内容」。
- * currentColor 在此处是不透明前景(mask 只取 alpha),等价于原先的 #000,
- * 且不必写死色值。 */
- mask-image: linear-gradient(to bottom, currentColor calc(100% - 24px), transparent);
- padding-bottom: var(--sp-4);
- }
- .pick-row {
- display: flex;
- align-items: center;
- gap: var(--sp-3);
- min-height: var(--touch-min);
- padding: var(--sp-2) var(--sp-3);
- border: 1px solid var(--edge);
- border-radius: var(--r-md);
- background: var(--surface);
- text-align: left;
- cursor: pointer;
- transition: background-color var(--motion-fast) var(--ease-standard);
- }
- .pick-row:hover {
- background: var(--surface-hover);
- border-color: var(--edge-strong);
- }
- .pick-text {
- display: grid;
- flex: 1;
- min-width: 0;
- line-height: 1.3;
- }
- .pick-name {
- font-size: var(--fs-13);
- font-weight: 600;
- color: var(--ink);
- }
- .pick-sub {
- font-size: var(--fs-11);
- color: var(--muted);
- }
- .pick-arrow {
- flex-shrink: 0;
- font-size: var(--fs-11);
- color: var(--faint);
- }
- /*
- * 容器状态(2026-11-08):圆点 + 文案,颜色走语义 token。
- * 只用颜色不够(WCAG):始终带文字,颜色只是加强。
- */
- .pick-state {
- flex-shrink: 0;
- display: inline-flex;
- align-items: center;
- gap: var(--sp-1);
- font-size: var(--fs-11);
- color: var(--muted);
- white-space: nowrap;
- }
- .pick-state .dot {
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: currentColor;
- }
- .pick-state[data-tone='success'] {
- color: var(--success);
- }
- .pick-state[data-tone='warning'] {
- color: var(--warning);
- }
- .pick-state[data-tone='danger'] {
- color: var(--danger);
- }
- .pick-state[data-tone='info'] {
- color: var(--primary);
- }
- .pick-state[data-tone='neutral'] {
- color: var(--muted);
- }
- .pick-empty {
- margin: 0;
- padding: var(--sp-4);
- font-size: var(--fs-13);
- color: var(--muted);
- text-align: center;
- }
- .acts {
- display: flex;
- justify-content: flex-end;
- }
- `
- })
- export class NewSessionModalComponent {
- protected readonly service = inject(NewSessionModalService);
- private readonly live = inject(LiveService);
- private readonly router = inject(Router);
- private readonly device = inject(DeviceService);
- private readonly workspaces = inject(WorkspaceService);
- private readonly groupChat = inject(GroupChatService);
- /** 会话类型:单聊 / 群聊(2026-11-05) */
- protected readonly mode = signal<'dm' | 'group'>('dm');
- /** 群聊已选成员 */
- protected readonly selected = signal<string[]>([]);
- protected readonly groupError = signal<string | null>(null);
- constructor() {
- /*
- * 打开时套用调用方预设的标签(2026-11-06)。
- * 左栏「群聊」分组头的「+」直接把用户送到群聊标签,
- * 不必先进单聊再手动切换。打开期间用户自己切标签不受影响 ——
- * 这里只在 isOpen 变 true 的那一刻同步一次。
- */
- effect(() => {
- if (!this.service.isOpen()) return;
- this.mode.set(this.service.mode());
- this.selected.set([]);
- this.groupError.set(null);
- });
- }
- /** 可选容器:已接入 Matrix 的数字生命(与旧实现同源) */
- /** 目标工作区名(模态框文案) */
- protected readonly targetName = computed(() => {
- const id = this.service.workspaceId();
- if (!id) return '日常对话';
- return this.workspaces.byId(id)?.name ?? '日常对话';
- });
- protected readonly agents = computed(() =>
- this.live
- .agents()
- .filter((a) => a.matrixReady)
- .sort((x, y) => x.name.localeCompare(y.name, 'zh') || x.id.localeCompare(y.id)),
- );
- /**
- * 容器**是否在线**(2026-11-09 用户裁决)。
- *
- * 🔴 用户原话:「新建对话这里只需要显示容器是否在线就行,不需要显示容器的状态,
- * 容器的状态是在会话页面显示的」。
- *
- * 所以这里只回答一个问题:**能不能跟它说话**。
- * 判据用三层模型:心跳活着(`alive`)+ 通道通(`link === 'ready'`)。
- * 不再输出「正在执行 / 空闲」——那是会话页的职责。
- */
- protected stateOf(a: Agent): { text: string; tone: SemanticTone } {
- const l = agentLayers(a);
- const online = l.alive !== 'dead' && l.alive !== 'stale' && l.link === 'ready';
- return online
- ? { text: '在线', tone: 'success' }
- : { text: '离线', tone: 'danger' };
- }
- protected createIn(agentId: string): void {
- const target = this.service.workspaceId();
- this.close();
- /*
- * 🔴 **不在这里创建会话**(2026-11-01 用户裁决:首次发消息才建会话)。
- *
- * 旧实现会 `sessions.create()` 一条空会话再跳转 —— 用户选完容器却没发消息,
- * 就留下一条 0 条消息的「新对话」,反复几次列表就堆满空壳。
- * 现在只是跳到「该容器、待建」的地址,输入第一条消息时才真正建会话。
- *
- * 工作区归属随查询参数带过去,由聊天页在会话建出来之后落地。
- */
- const qs = target ? `?ws=${encodeURIComponent(target)}` : '';
- void this.router.navigateByUrl(`/${this.device.mode()}/chat/${agentId}/new${qs}`);
- }
- protected isChecked(a: Agent): boolean {
- return this.selected().includes(a.id);
- }
- protected toggleMember(a: Agent): void {
- this.groupError.set(null);
- this.selected.update((cur) =>
- cur.includes(a.id) ? cur.filter((x) => x !== a.id) : [...cur, a.id],
- );
- }
- /** 创建群聊:建群 → 关弹窗 → 进群聊页 */
- protected createGroup(): void {
- const ids = this.selected();
- if (ids.length < 2) {
- this.groupError.set('群聊至少需要选择 2 个容器');
- return;
- }
- try {
- const ws = this.service.workspaceId() ?? null;
- const s = this.groupChat.createGroup(ids, ws);
- this.selected.set([]);
- this.mode.set('dm');
- this.close();
- void this.router.navigateByUrl(`/${this.device.mode()}/group/${encodeURIComponent(s.id)}`);
- } catch (e) {
- this.groupError.set(e instanceof Error ? e.message : '创建群聊失败');
- }
- }
- protected close(): void {
- this.service.close();
- }
- }
|