npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@libs-ui/components-ai-assistant-ui

v0.2.357-2

Published

> Bộ component UI hoàn chỉnh để xây dựng giao diện chatbot AI với hỗ trợ SSE streaming, lịch sử hội thoại và ô nhập liệu thông minh.

Readme

@libs-ui/components-ai-assistant-ui

Bộ component UI hoàn chỉnh để xây dựng giao diện chatbot AI với hỗ trợ SSE streaming, lịch sử hội thoại và ô nhập liệu thông minh.

Giới thiệu

@libs-ui/components-ai-assistant-ui cung cấp ba component độc lập để lắp ráp giao diện trợ lý AI: khung hội thoại với virtual scroll và streaming real-time (ConversationComponent), danh sách lịch sử chat có phân trang và tìm kiếm (HistoryComponent), và ô nhập tin nhắn với nút gửi/dừng (MessageInputComponent). Các component được thiết kế theo kiến trúc mở — consumer tự inject service AI và implement hàm stream, lib không ràng buộc backend cụ thể.

Tính năng

  • Streaming SSE real-time với AbortController để dừng giữa chừng
  • Virtual scroll hiệu năng cao cho danh sách tin nhắn và lịch sử thread
  • Tự động tải thêm lịch sử khi scroll lên đầu (infinite scroll)
  • Hiển thị trạng thái tin nhắn: thinking, streaming, success, error, stop
  • Hiển thị nguồn tài liệu tham khảo (sources) và câu hỏi gợi ý (suggestedQuestions)
  • Custom SSE parser — override parseChunk để tương thích mọi backend
  • Màn hình chào mừng (welcome screen) tùy biến khi chưa có tin nhắn
  • Tích hợp i18n qua @ngx-translate/core
  • API FunctionControl cho phép component cha điều khiển trực tiếp: gửi, dừng, reload, lấy messages

Khi nào sử dụng

  • Xây dựng giao diện chatbot AI tích hợp trong ứng dụng (side panel, full-page, modal)
  • Cần hiển thị lịch sử các cuộc hội thoại với khả năng chuyển đổi giữa các thread
  • Streaming câu trả lời từ AI theo từng chunk SSE thay vì chờ toàn bộ response
  • Tích hợp với bất kỳ AI backend nào thông qua hàm stream tùy chỉnh

Cài đặt

npm install @libs-ui/components-ai-assistant-ui

Import

import {
  LibsUiComponentsAIAssistantUiConversationComponent,
  LibsUiComponentsAIAssistantUiHistoryComponent,
  LibsUiComponentsAIAssistantUiMessageInputComponent,
} from '@libs-ui/components-ai-assistant-ui';

// Types & Interfaces
import {
  T_conversation_stream_config,
  T_conversation_send_trigger,
  T_conversation_parsed_chunk,
  I_conversation_msg_converter,
  I_ai_assistant_ui_conversation_function_control,
  T_message,
  T_message_source,
  E_message_role,
  E_message_status,
  T_thread_history,
  I_emit_delete,
  I_emit_rename,
  assistantName,
} from '@libs-ui/components-ai-assistant-ui';

Ví dụ sử dụng

Ví dụ 1 — Giao diện chat đầy đủ (History + Conversation + MessageInput)

// my-chat.component.ts
import { ChangeDetectionStrategy, Component, inject, signal, viewChild } from '@angular/core';
import {
  LibsUiComponentsAIAssistantUiConversationComponent,
  LibsUiComponentsAIAssistantUiHistoryComponent,
  LibsUiComponentsAIAssistantUiMessageInputComponent,
  T_conversation_stream_config,
  T_conversation_send_trigger,
  T_message,
  T_thread_history,
} from '@libs-ui/components-ai-assistant-ui';
import { Observable } from 'rxjs';

@Component({
  selector: 'app-my-chat',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [
    LibsUiComponentsAIAssistantUiConversationComponent,
    LibsUiComponentsAIAssistantUiHistoryComponent,
    LibsUiComponentsAIAssistantUiMessageInputComponent,
  ],
  template: `
    <div class="h-full flex gap-4">
      <!-- Lịch sử chat -->
      <div class="w-[320px] h-full">
        <libs_ui-components-ai_assistant_ui-history
          [fieldKey]="'id'"
          [historyConfig]="historyConfig"
          (outSelectThread)="handlerSelectThread($event)"
          (outDeleteThread)="handlerDeleteThread($event)"
          (outNewThread)="handlerNewThread()" />
      </div>

      <!-- Khung hội thoại + ô nhập -->
      <div class="flex-1 h-full flex flex-col">
        <div class="flex-1 min-h-0">
          <libs_ui-components-ai_assistant_ui-conversation
            #conversationRef
            [streamConfig]="streamConfig"
            [sendTrigger]="sendTrigger()"
            [welcomeMessage]="'i18n_welcome_message'"
            (outStreamingChange)="handlerStreamingChange($event)"
            (outRetry)="handlerRetry()" />
        </div>
        <div class="px-4 pb-4">
          <libs_ui-components-ai_assistant_ui-message_input
            [isStreaming]="isStreaming()"
            (outSendMessage)="handlerSend($event)"
            (outCancel)="handlerStop($event)" />
        </div>
      </div>
    </div>
  `,
})
export class MyChatComponent {
  private readonly conversationRef = viewChild<LibsUiComponentsAIAssistantUiConversationComponent>('conversationRef');

  protected readonly sendTrigger = signal<T_conversation_send_trigger | undefined>(undefined);
  protected readonly isStreaming = signal<boolean>(false);

  // Implement streamFn để gọi API AI của bạn
  protected readonly streamConfig: T_conversation_stream_config = {
    streamFn: (content: string, abortSignal?: AbortSignal): Observable<string> => {
      return this.aiService.sendMessage(content, abortSignal);
    },
  };

  protected readonly historyConfig = {
    type: 'text' as const,
    httpRequestData: signal({
      serviceClass: AiThreadService,
      functionName: 'getThreads',
      argumentsValue: [],
    }),
  };

  private readonly aiService = inject(AiStreamService);

  protected handlerSend(event: { content: string }) {
    this.sendTrigger.set(undefined);
    setTimeout(() => this.sendTrigger.set({ content: event.content }), 0);
  }

  protected async handlerStop(event: { recoveredContent: string }) {
    await this.conversationRef()?.FunctionControl().stop();
  }

  protected async handlerRetry() {
    const messages = await this.conversationRef()?.FunctionControl().getMessages();
    const lastUser = messages?.slice().reverse().find((m: T_message) => m.role === 'user');
    if (lastUser) {
      this.handlerSend({ content: lastUser.content });
    }
  }

  protected handlerStreamingChange(isStreaming: boolean) {
    this.isStreaming.set(isStreaming);
  }

  protected async handlerSelectThread(thread: T_thread_history) {
    await this.conversationRef()?.FunctionControl().reloadHistory();
  }

  protected handlerDeleteThread(event: unknown) {
    // Xử lý xóa thread
  }

  protected handlerNewThread() {
    // Tạo mới thread
  }
}

Ví dụ 2 — ConversationComponent với custom SSE parser

// my-ai-chat.component.ts
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import {
  LibsUiComponentsAIAssistantUiConversationComponent,
  LibsUiComponentsAIAssistantUiMessageInputComponent,
  T_conversation_stream_config,
  T_conversation_parsed_chunk,
  T_conversation_send_trigger,
} from '@libs-ui/components-ai-assistant-ui';
import { Observable } from 'rxjs';

@Component({
  selector: 'app-my-ai-chat',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [
    LibsUiComponentsAIAssistantUiConversationComponent,
    LibsUiComponentsAIAssistantUiMessageInputComponent,
  ],
  template: `
    <div class="h-[600px] flex flex-col">
      <div class="flex-1 min-h-0">
        <libs_ui-components-ai_assistant_ui-conversation
          [streamConfig]="streamConfig"
          [sendTrigger]="sendTrigger()"
          [welcomeMessage]="'i18n_ver1_ready_for_your_query'"
          [welcomeClassInclude]="'gap-[16px]'"
          (outStreamingChange)="handlerStreamingChange($event)"
          (outSendDone)="handlerSendDone($event)" />
      </div>
      <div class="px-[16px] pb-[16px]">
        <libs_ui-components-ai_assistant_ui-message_input
          [isStreaming]="isStreaming()"
          [placeholder]="'Nhập câu hỏi của bạn...'"
          (outSendMessage)="handlerSend($event)"
          (outCancel)="handlerStop($event)" />
      </div>
    </div>
  `,
})
export class MyAiChatComponent {
  protected readonly sendTrigger = signal<T_conversation_send_trigger | undefined>(undefined);
  protected readonly isStreaming = signal<boolean>(false);

  // Custom parser cho backend không dùng format ai-core chuẩn
  private customParser = (raw: string): T_conversation_parsed_chunk => {
    const result: T_conversation_parsed_chunk = {};
    try {
      const data = JSON.parse(raw.replace('data: ', ''));
      if (data.choices?.[0]?.delta?.content) {
        result.content = data.choices[0].delta.content;
      }
    } catch {
      // bỏ qua chunk không hợp lệ
    }
    return result;
  };

  protected readonly streamConfig: T_conversation_stream_config = {
    streamFn: (content: string, abortSignal?: AbortSignal): Observable<string> => {
      return new Observable((observer) => {
        fetch('/api/ai/stream', {
          method: 'POST',
          body: JSON.stringify({ message: content }),
          signal: abortSignal,
        }).then(async (res) => {
          const reader = res.body!.getReader();
          const decoder = new TextDecoder();
          while (true) {
            const { done, value } = await reader.read();
            if (done) break;
            observer.next(decoder.decode(value));
          }
          observer.complete();
        }).catch((err) => observer.error(err));
      });
    },
    parseChunk: this.customParser,
  };

  protected handlerSend(event: { content: string }) {
    this.sendTrigger.set(undefined);
    setTimeout(() => this.sendTrigger.set({ content: event.content }), 0);
  }

  protected handlerStop(_event: { recoveredContent: string }) {
    // Conversation tự xử lý abort qua FunctionControl().stop()
  }

  protected handlerStreamingChange(isStreaming: boolean) {
    this.isStreaming.set(isStreaming);
  }

  protected handlerSendDone(message: unknown) {
    console.log('Message completed:', message);
  }
}

Ví dụ 3 — HistoryComponent độc lập

// chat-sidebar.component.ts
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import {
  LibsUiComponentsAIAssistantUiHistoryComponent,
  I_emit_delete,
  I_emit_rename,
  T_thread_history,
} from '@libs-ui/components-ai-assistant-ui';

@Component({
  selector: 'app-chat-sidebar',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [LibsUiComponentsAIAssistantUiHistoryComponent],
  template: `
    <div class="w-[280px] h-full">
      <libs_ui-components-ai_assistant_ui-history
        [fieldKey]="'id'"
        [historyConfig]="historyConfig"
        [isSearchOnline]="true"
        [activeThreadId]="activeThreadId()"
        (outSelectThread)="handlerSelectThread($event)"
        (outDeleteThread)="handlerDeleteThread($event)"
        (outRenameThread)="handlerRenameThread($event)"
        (outNewThread)="handlerNewThread()"
        (outLoading)="handlerLoading($event)" />
    </div>
  `,
})
export class ChatSidebarComponent {
  protected readonly activeThreadId = signal<string | undefined>(undefined);

  protected readonly historyConfig = {
    type: 'text' as const,
    httpRequestData: signal({
      serviceClass: AiThreadService,
      functionName: 'getThreads',
      argumentsValue: [],
    }),
  };

  protected handlerSelectThread(thread: T_thread_history) {
    this.activeThreadId.set(thread.id || thread._id);
  }

  protected handlerDeleteThread(event: I_emit_delete) {
    // event.removeDialog?.() — gọi để đóng dialog xác nhận
    console.log('Delete thread:', event.id);
  }

  protected handlerRenameThread(event: I_emit_rename) {
    // event.revertName() — gọi khi đổi tên thất bại để hoàn tác
    console.log('Rename thread:', event.id, event.newName);
  }

  protected handlerNewThread() {
    this.activeThreadId.set(undefined);
  }

  protected handlerLoading(isLoading: boolean) {
    console.log('History loading:', isLoading);
  }
}

Ví dụ 4 — MessageInputComponent độc lập

// message-box.component.ts
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import {
  LibsUiComponentsAIAssistantUiMessageInputComponent,
  T_content_to_send,
  T_recover_message,
} from '@libs-ui/components-ai-assistant-ui';

@Component({
  selector: 'app-message-box',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [LibsUiComponentsAIAssistantUiMessageInputComponent],
  template: `
    <libs_ui-components-ai_assistant_ui-message_input
      [isStreaming]="isStreaming()"
      [disable]="isDisabled()"
      [placeholder]="'Hỏi M-Copilot bất cứ điều gì...'"
      [iconClass]="customIconClass"
      (outSendMessage)="handlerSendMessage($event)"
      (outCancel)="handlerCancel($event)" />
  `,
})
export class MessageBoxComponent {
  protected readonly isStreaming = signal<boolean>(false);
  protected readonly isDisabled = signal<boolean>(false);

  protected readonly customIconClass = {
    send: 'libs-ui-icon-send-solid',
    stop: 'libs-ui-icon-stop-solid',
  };

  protected handlerSendMessage(event: T_content_to_send) {
    event.stopPropagation?.();
    console.log('Message to send:', event.content);
    this.isStreaming.set(true);
  }

  protected handlerCancel(event: T_recover_message) {
    event.stopPropagation?.();
    console.log('Recovered content:', event.recoveredContent);
    this.isStreaming.set(false);
  }
}

ConversationComponent

Selector: libs_ui-components-ai_assistant_ui-conversation

@Input()

| Input | Type | Default | Mô tả | Ví dụ | |---|---|---|---|---| | iconAssistantClass | string | '' | Class icon hiển thị bên cạnh bubble tin nhắn của assistant | iconAssistantClass="libs-ui-icon-robot" | | messagesConfig | IListConfigItem \| undefined | undefined | Config gọi API lấy lịch sử tin nhắn. Dùng pattern IListConfigItem của @libs-ui/components-list | [messagesConfig]="messagesConfig" | | msgConverter | I_conversation_msg_converter<any> \| undefined | undefined | Adapter chuyển đổi raw API response thành T_message nội bộ. Dùng khi backend trả format khác | [msgConverter]="myConverter" | | sendTrigger | T_conversation_send_trigger \| undefined | undefined | Signal trigger gửi tin nhắn từ bên ngoài (từ component cha). Reset về undefined rồi set lại để trigger | [sendTrigger]="sendTrigger()" | | streamConfig | T_conversation_stream_config \| undefined | undefined | Config hàm stream SSE — bắt buộc để gửi và nhận tin nhắn real-time | [streamConfig]="streamConfig" | | welcomeClassInclude | string | '' | Class CSS bổ sung cho container màn hình chào mừng | welcomeClassInclude="gap-[24px]" | | welcomeIcon | TemplateRef<unknown> \| undefined | undefined | TemplateRef icon tùy chỉnh cho màn hình chào mừng. Mặc định hiển thị icon ai-interaction | [welcomeIcon]="myIconTpl" | | welcomeMessage | string \| undefined | undefined | Key i18n hoặc chuỗi hiển thị trên màn hình chào mừng. Mặc định dùng key i18n_ver1_ready_for_your_query | welcomeMessage="i18n_welcome" |

@Output()

| Output | Type | Mô tả | Handler TS | Binding HTML | |---|---|---|---|---| | (outError) | { messageId: string; error: unknown } | Phát khi stream gặp lỗi, kèm id tin nhắn bị lỗi | handlerError(e: { messageId: string; error: unknown }): void { e.stopPropagation?.(); console.error(e); } | (outError)="handlerError($event)" | | (outLoadingHistory) | boolean | Phát khi trạng thái loading lịch sử thay đổi | handlerLoadingHistory(e: boolean): void { this.isLoadingHistory.set(e); } | (outLoadingHistory)="handlerLoadingHistory($event)" | | (outRetry) | void | Phát khi user click nút retry trên tin nhắn lỗi | handlerRetry(): void { /* gửi lại tin nhắn cuối */ } | (outRetry)="handlerRetry()" | | (outSendDone) | T_message | Phát khi stream hoàn thành, kèm tin nhắn cuối cùng của assistant | handlerSendDone(e: T_message): void { this.lastMessage.set(e); } | (outSendDone)="handlerSendDone($event)" | | (outStreamingChange) | boolean | Phát khi trạng thái streaming thay đổi (bắt đầu/kết thúc) | handlerStreamingChange(e: boolean): void { this.isStreaming.set(e); } | (outStreamingChange)="handlerStreamingChange($event)" |

FunctionControl API

Truy cập qua viewChild + FunctionControl():

private readonly conversationRef = viewChild<LibsUiComponentsAIAssistantUiConversationComponent>('conversationRef');

// Gửi tin nhắn programmatically
await this.conversationRef()?.FunctionControl().send({ content: 'Hello AI' });

// Dừng stream đang chạy
await this.conversationRef()?.FunctionControl().stop();

// Reload lịch sử tin nhắn
await this.conversationRef()?.FunctionControl().reloadHistory();

// Lấy danh sách tin nhắn hiện tại
const messages = await this.conversationRef()?.FunctionControl().getMessages();

HistoryComponent

Selector: libs_ui-components-ai_assistant_ui-history

@Input()

| Input | Type | Default | Mô tả | Ví dụ | |---|---|---|---|---| | activeThreadId | string \| undefined | undefined | ID thread đang được chọn, dùng để highlight trong danh sách | [activeThreadId]="activeThreadId()" | | customDeleteThread | boolean | false | Nếu true, component không hiển thị dialog xác nhận xóa nội bộ mà emit outDeleteThread để cha tự xử lý | [customDeleteThread]="true" | | fieldKey | string | '_id' | Tên field dùng làm key định danh duy nhất cho mỗi thread trong data API | [fieldKey]="'id'" | | historyConfig | IListConfigItem \| undefined | undefined | Config gọi API lấy danh sách thread. Bắt buộc để hiển thị lịch sử | [historyConfig]="historyConfig" | | isSearchOnline | boolean | true | Nếu true, tìm kiếm gọi lại API với keyword. Nếu false, lọc offline trên data đã tải | [isSearchOnline]="false" | | templateRefNotSearchNoData | TemplateRef<TYPE_TEMPLATE_REF> \| undefined | undefined | Template hiển thị khi danh sách rỗng và không có từ khóa tìm kiếm | [templateRefNotSearchNoData]="emptyTpl" | | templateRefSearchNoData | TemplateRef<TYPE_TEMPLATE_REF> \| undefined | undefined | Template hiển thị khi tìm kiếm không có kết quả | [templateRefSearchNoData]="noResultTpl" | | zIndex | number \| undefined | undefined | z-index cho các popup/dropdown bên trong component | [zIndex]="1050" |

@Output()

| Output | Type | Mô tả | Handler TS | Binding HTML | |---|---|---|---|---| | (outDeleteThread) | I_emit_delete | Phát khi user xác nhận xóa thread, kèm id và callback đóng dialog | handlerDeleteThread(e: I_emit_delete): void { e.stopPropagation?.(); this.threadService.delete(e.id); } | (outDeleteThread)="handlerDeleteThread($event)" | | (outLoading) | boolean | Phát khi trạng thái loading danh sách thay đổi | handlerLoading(e: boolean): void { this.isLoading.set(e); } | (outLoading)="handlerLoading($event)" | | (outNewThread) | void | Phát khi user click nút tạo mới thread | handlerNewThread(): void { this.activeThreadId.set(undefined); } | (outNewThread)="handlerNewThread()" | | (outRenameThread) | I_emit_rename | Phát khi user hoàn tất đổi tên thread, kèm oldName, newName và callback hoàn tác | handlerRenameThread(e: I_emit_rename): void { e.stopPropagation?.(); this.threadService.rename(e.id, e.newName).catch(() => e.revertName()); } | (outRenameThread)="handlerRenameThread($event)" | | (outSelectThread) | T_thread_history | Phát khi user click chọn một thread | handlerSelectThread(e: T_thread_history): void { e.stopPropagation?.(); this.activeThreadId.set(e.id); } | (outSelectThread)="handlerSelectThread($event)" |


MessageInputComponent

Selector: libs_ui-components-ai_assistant_ui-message_input

@Input()

| Input | Type | Default | Mô tả | Ví dụ | |---|---|---|---|---| | disable | boolean | false | Vô hiệu hóa toàn bộ input và nút gửi | [disable]="isDisabled()" | | iconClass | { send: string; stop: string } | { send: 'libs-ui-icon-send-solid', stop: 'libs-ui-icon-stop-solid' } | Class icon cho nút gửi và nút dừng | [iconClass]="{ send: 'libs-ui-icon-send-solid', stop: 'libs-ui-icon-stop-solid' }" | | isStreaming | boolean | false | Khi true, ẩn nút gửi và hiển thị nút dừng stream | [isStreaming]="isStreaming()" | | placeholder | string | '' | Placeholder cho textarea. Mặc định dùng key i18n i18n_ver1_ask_mcopilot | placeholder="Nhập câu hỏi của bạn..." |

@Output()

| Output | Type | Mô tả | Handler TS | Binding HTML | |---|---|---|---|---| | (outCancel) | T_recover_message | Phát khi user click nút dừng. Kèm nội dung tin nhắn cuối để phục hồi vào input | handlerCancel(e: T_recover_message): void { e.stopPropagation?.(); this.isStreaming.set(false); } | (outCancel)="handlerCancel($event)" | | (outSendMessage) | T_content_to_send | Phát khi user click nút gửi hoặc nhấn Enter (không phải Shift+Enter) | handlerSendMessage(e: T_content_to_send): void { e.stopPropagation?.(); this.sendTrigger.set({ content: e.content }); } | (outSendMessage)="handlerSendMessage($event)" |


Types & Interfaces

import {
  // Stream configuration
  T_conversation_stream_config,
  T_conversation_parsed_chunk,
  T_conversation_send_trigger,
  I_conversation_msg_converter,

  // Message
  T_message,
  T_message_source,
  E_message_role,
  E_message_status,

  // History
  T_thread_history,
  I_emit_delete,
  I_emit_rename,

  // FunctionControl
  I_ai_assistant_ui_conversation_function_control,
} from '@libs-ui/components-ai-assistant-ui';
// Cấu hình stream SSE
type T_conversation_stream_config = {
  // Bắt buộc: hàm gọi stream, emit raw SSE text chunks
  streamFn: (content: string, abortSignal?: AbortSignal) => Observable<string>;
  // Tùy chọn: override parser nếu backend không dùng format ai-core
  parseChunk?: (raw: string) => T_conversation_parsed_chunk;
};

// Kết quả parse mỗi SSE chunk
type T_conversation_parsed_chunk = {
  content?: string;             // nội dung text delta
  source?: T_message_source;    // tài liệu tham khảo
  suggestedQuestions?: string[]; // câu hỏi gợi ý
};

// Trigger gửi tin nhắn từ component cha
type T_conversation_send_trigger = {
  content: string;
  noStream?: boolean; // true → POST thuần, không stream
};

// Adapter chuyển đổi raw API → T_message
interface I_conversation_msg_converter<TRaw = Record<string, unknown>> {
  toMessage: (raw: TRaw) => T_message;
}

// Tin nhắn nội bộ
type T_message = {
  id: string;
  role: T_message_role;       // 'user' | 'assistant'
  content: string;
  attachments?: File[];
  status: T_message_status;   // 'sending' | 'success' | 'error' | 'streaming' | 'thinking' | 'stop'
  sources?: Array<T_message_source>;
  suggestedQuestions?: string[];
};

// Nguồn tài liệu tham khảo
type T_message_source = {
  index: number;
  title: string;
  url: string;
  score: number;
  updated_at: string;
  file_type: string;
};

// Thread lịch sử
type T_thread_history = {
  _id: string;
  id: string;
  name?: string;
  title?: string;
};

// Event xóa thread
interface I_emit_delete {
  id: string;
  name: string;
  removeDialog?: () => Promise<void>; // gọi để đóng dialog xác nhận
}

// Event đổi tên thread
interface I_emit_rename {
  id: string;
  oldName: string;
  newName: string;
  revertName: () => void; // gọi để hoàn tác khi đổi tên thất bại
}

// FunctionControl của ConversationComponent
interface I_ai_assistant_ui_conversation_function_control {
  send: (trigger: T_conversation_send_trigger) => Promise<void>;
  reloadHistory: () => Promise<void>;
  stop: () => Promise<void>;
  getMessages: () => Promise<Array<T_message>>;
}

Lưu ý quan trọng

⚠️ Reset sendTrigger trước khi set lại: Khi gửi cùng một nội dung hai lần liên tiếp, cần reset về undefined trước, sau đó set lại trong setTimeout(0) để Angular's signal system phát hiện sự thay đổi.

// Đúng
protected handlerSend(event: { content: string }) {
  this.sendTrigger.set(undefined);
  setTimeout(() => this.sendTrigger.set({ content: event.content }), 0);
}

⚠️ streamConfig bắt buộc để gửi tin nhắn: Nếu không truyền streamConfig vào ConversationComponent, component sẽ không xử lý sendTrigger — không có lỗi compile nhưng tin nhắn không được gửi.

⚠️ FunctionControl().stop() vs outCancel: stop() từ FunctionControl hủy AbortController và cập nhật trạng thái tin nhắn thành stop. outCancel từ MessageInputComponent chỉ phục hồi nội dung vào ô nhập — cần gọi cả hai khi xử lý nút dừng.

⚠️ I_emit_rename.revertName(): Bắt buộc gọi event.revertName() trong handler outRenameThread nếu API đổi tên thất bại, để hoàn tác tên hiển thị trong UI.

⚠️ fieldKey của HistoryComponent: Mặc định là '_id'. Nếu API trả id thay vì _id, phải truyền [fieldKey]="'id'" để tránh lỗi key tracking trong virtual scroll.

⚠️ Virtual scroll yêu cầu chiều cao cố định: Cả ConversationComponentHistoryComponent đều dùng virtual scroll. Container cha phải có chiều cao xác định (không phải auto) — dùng h-full, h-[600px] hoặc tương đương.


Demo

npx nx serve core-ui

Truy cập: http://localhost:4500/components-ai-assistant-ui