@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
FunctionControlcho 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-uiImport
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ả ConversationComponent và HistoryComponent đề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-uiTruy cập: http://localhost:4500/components-ai-assistant-ui
