Appearance
Global Chat
@jennifersoft/apm-components의 Global Chat(AI 채팅) 패널 문서입니다. 이 문서는 @jennifersoft/apm-components@1.4.1 기준으로 정리했습니다.
설계 원칙
Global Chat은 완전 제어(fully controlled) 컴포넌트입니다.
- 입력은
view: GlobalChatPanelView하나입니다. - 컴포넌트는 상태를 소유하지 않습니다. 펼침·편집중·메뉴 열림까지 전부
view안에 있습니다. - 사용자의 모든 행동은 의도(intent)만 emit하고, 화면은 바뀌지 않습니다.
- 상태를 바꾸는 주체는
@jennifersoft/apm-core의 Bridge입니다. Bridge가 새view를 만들어 내려줘야 화면이 갱신됩니다.
IMPORTANT
view.composer.draft를 그대로 두고 update:draft만 받으면 입력창에 글자가 써지지 않습니다. 반드시 Bridge(또는 호스트 상태)에 반영해 새 view로 되돌려줘야 합니다.
화면 모델의 정본은 apm-core의 panel-view.ts이며, apm-components는 타입을 재수출만 합니다.
인터랙티브 데모
TIP
responding을 켜면 composer의 trailing action이 전송에서 중단으로 바뀝니다. 질문을 실제로 전송해도 2초 뒤 자동으로 풀리도록 데모를 구성했습니다.
ChatEmptyState
ChatThinkingIndicator
label을 주지 않으면 아이콘만 표시됩니다.
ChatMessageVersionPager
useGlobalChatClipboard
Host (client-v2 / mobile)
└── Bridge (apm-core) 상태 소유 · WebSocket · 스트리밍
└── view: GlobalChatPanelView
└── GlobalChatPanel (apm-components) 렌더링만
└── emit(intent) ──▶ BridgeGlobalChatPanel
패널 전체를 그리는 최상위 컴포넌트입니다. 보통 이것만 사용합니다.
Props
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
view | GlobalChatPanelView | — (필수) | 패널 전체 화면 모델 |
historyBackLabel | string | undefined | 모바일 기록 화면의 뒤로가기 라벨 |
submitShortcut | boolean | true | Enter 전송 단축키 사용 여부 |
transactionLinksInteractive | boolean | false | 응답 본문의 TXID 토큰을 클릭 가능하게 함 (데스크톱 전용) |
transactionLinksInteractive는 데스크톱 호스트만 켭니다. 모바일에서는 X-View 팝업을 열 수 없으므로 꺼둔 채로 텍스트만 표시합니다.
이벤트
전송·입력
| 이벤트 | 페이로드 | 설명 |
|---|---|---|
update:draft | draft: string | 입력창 내용 변경 |
submit | draft: string | 질문 전송 |
stop | — | 응답 생성 중단 |
remove-context | contextId: string | 첨부 context 제거 |
패널 제어
| 이벤트 | 페이로드 | 설명 |
|---|---|---|
close | — | 패널 닫기 |
new-chat | — | 새 대화 시작 |
open-more | — | 더보기 메뉴 열기 |
select-more-menu | itemId: string | 더보기 항목 선택 |
지난 대화(History)
| 이벤트 | 페이로드 |
|---|---|
open-history / close-history | — |
select-history | chatId: string |
update:history-search | search: string |
open-history-item-menu | chatId: string |
close-history-item-menu | — |
rename-history-item | chatId: string |
delete-history-item | chatId: string |
update:history-renaming-title | title: string |
submit-history-rename | chatId: string |
cancel-history-rename | — |
메시지
| 이벤트 | 페이로드 | 설명 |
|---|---|---|
toggle-thinking | messageId | Thinking 패널 펼침 전환 |
toggle-thinking-message-api-call | messageId, apiCallId | 체크리스트에 연결되지 않은 API 호출 토글 |
toggle-thinking-api-call | messageId, checklistId, itemId, apiCallId | 체크리스트 항목의 API 호출 토글 |
edit-question | messageId, text | 질문 수정 저장 |
retry-message | messageId | 답변 재생성 |
select-message-version | messageId, direction: 'prev' | 'next' | 버전 페이징 |
copy-message | messageId, text | 질문/답변 복사 |
copy-code | messageId, code | 코드 블록 복사 |
open-transaction | transactionId: string | TXID 링크 클릭 |
기본 사용법
vue
<script setup lang="ts">
import { ref } from 'vue';
import { GlobalChatPanel } from '@jennifersoft/apm-components';
import type { GlobalChatPanelView } from '@jennifersoft/apm-components';
const view = ref<GlobalChatPanelView>(createInitialView());
function onUpdateDraft(draft: string) {
// 상태는 호스트가 소유한다. view를 새로 만들어 되돌려준다.
view.value = {
...view.value,
composer: { ...view.value.composer, draft, canSubmit: draft.length > 0 },
};
}
function onSubmit(draft: string) {
bridge.sendQuestion(draft);
}
</script>
<template>
<GlobalChatPanel
:view="view"
transaction-links-interactive
@update:draft="onUpdateDraft"
@submit="onSubmit"
@stop="bridge.stop()"
@new-chat="bridge.newChat()"
@open-history="bridge.openHistory()"
@retry-message="bridge.retry($event)"
@open-transaction="openXView"
@close="isOpen = false"
/>
</template>화면 모델
GlobalChatPanelView
typescript
interface GlobalChatPanelView {
title: string;
newChatLabel: string;
historyLabel: string;
moreLabel: string;
closeLabel: string;
messageActionLabels: GlobalChatMessageActionLabels;
messages: readonly GlobalChatMessageView[];
/** TXID 링크가 사용할 X-View 조회 정보 */
transactionContext?: GlobalChatTransactionContext;
composer: GlobalChatComposerView;
/** 기록 화면을 연 동안에만 채운다 */
history?: GlobalChatHistoryView;
/** 더보기 dropdown */
moreMenu?: GlobalChatMoreMenuView;
/** 대화가 없을 때 목록 대신 노출 */
emptyState?: GlobalChatEmptyStateView;
}모든 라벨이 view에 들어 있는 이유는 다국어를 호스트가 소유하기 때문입니다. 컴포넌트 안에는 하드코딩된 사용자 문구가 없습니다.
GlobalChatMessageView
typescript
interface GlobalChatMessageView {
id: string;
role: GlobalChatMessageRole; // 'user' | 'assistant'
authorLabel: string;
/** user 질문 본문. assistant는 blocks를 사용한다 */
text: string;
/** assistant 응답을 수신 순서대로 담는다. user는 빈 배열 */
blocks: readonly GlobalChatResponseBlockView[];
/** 이 질문이 실제 전송한 context. 상속 질문은 빈 배열 */
contextPreviews: readonly GlobalChatContextPreviewView[];
thinking?: GlobalChatThinkingView;
/** Bridge가 "응답이 비었다"고 판단한 경우에만 제공 */
emptyResponseMessage?: string;
versionInfo?: { current: number; total: number };
editable?: boolean;
canRetry?: boolean;
responding?: boolean;
}WARNING
blocks가 비었다고 컴포넌트가 "빈 응답"으로 판단하지 않습니다. 스트리밍 초기의 빈 blocks와 구분할 수 없기 때문입니다. 빈 응답 안내를 띄우려면 Bridge가 emptyResponseMessage를 명시해야 합니다.
응답 블록
typescript
const GLOBAL_CHAT_RESPONSE_BLOCK_KIND = {
TEXT: 'text',
CHART: 'chart',
ERROR: 'error',
} as const;
type GlobalChatResponseBlockView =
| { id: string; kind: 'text'; text: string } // 이름 치환 완료된 Markdown
| { id: string; kind: 'chart'; label: string } // 차트 접근성 이름
| { id: string; kind: 'error'; message: string }; // 원문 payload 노출 금지text 블록은 Markdown 표와 코드 블록을 포함할 수 있고, ChatResponseContent가 파싱해 표는 ChatResponseTable, 코드는 ChatResponseCode로 렌더합니다.
Thinking
typescript
interface GlobalChatThinkingView {
statusLabel: string;
toggleLabel: string;
elapsedTimeLabel: string | null;
active: boolean;
expanded: boolean;
/** text와 checklist가 수신 순서대로 섞인 본문 */
blocks: readonly GlobalChatThinkingBlockView[];
indicator: string | null;
/** checklist에 연결되지 않은 message-level 활동 */
apiCalls: readonly GlobalChatThinkingApiCallView[];
}
const GLOBAL_CHAT_THINKING_CHECKLIST_STATUS = {
PENDING: 'pending',
IN_PROGRESS: 'in-progress',
COMPLETED: 'completed',
FAILED: 'failed',
STOPPED: 'stopped',
} as const;blocks가 text와 checklist를 하나의 배열에 섞어 담는 이유는 수신 순서를 보존하기 위해서입니다. 두 종류를 별도 필드로 나누면 어느 쪽이 먼저 왔는지 잃어버립니다.
Composer
typescript
interface GlobalChatComposerView {
statusMessage?: string; // context 미준비 안내
disabled?: boolean; // 로컬 LLM은 첫 질문 후 새 채팅까지 잠금
draft: string;
inputLabel: string;
placeholder: string;
submitLabel: string;
canSubmit: boolean;
contextPreviews: readonly GlobalChatContextPreviewView[];
removeContextLabel: string;
suggestedQuestionsLabel: string;
suggestedQuestions: readonly GlobalChatSuggestedQuestionView[];
suggestedQuestionsLoading?: boolean;
/** true면 trailing action이 stop 의도로 바뀐다 */
responding: boolean;
stopLabel: string;
/** 초기 화면은 'stack', 대화 중에는 'rail'. 생략 시 'rail' */
suggestedQuestionsLayout?: 'rail' | 'stack';
}Context Preview
typescript
const GLOBAL_CHAT_CONTEXT_PREVIEW_KIND = {
IMAGE: 'image',
FILE: 'file',
MARKDOWN: 'markdown',
} as const;
interface GlobalChatContextPreviewView {
id: string;
kind: GlobalChatContextPreviewKind;
label: string;
thumbnailUrl: string | null; // image일 때만
imageDisplay?: GlobalChatContextImageDisplay;
badgeLabel: string | null; // file일 때 확장자 배지
/** 라이트박스 본문. 없으면 라이트박스를 열지 않는다 */
contentText?: string | null;
}하위 컴포넌트
패널을 직접 조립할 때만 사용합니다. 일반적인 화면은 GlobalChatPanel로 충분합니다.
ChatComposer
| Prop | 타입 | 기본값 |
|---|---|---|
view | GlobalChatComposerView | — (필수) |
submitShortcut | boolean | true |
typescript
emit('update:draft', draft: string)
emit('submit', draft: string)
emit('stop')
emit('remove-context', contextId: string)
emit('open-context', preview: GlobalChatContextPreviewView)ChatMessageList
| Prop | 타입 |
|---|---|
messages | readonly GlobalChatMessageView[] |
actionLabels | GlobalChatMessageActionLabels |
transactionLinksInteractive | boolean? |
transactionContext | GlobalChatTransactionContext? |
GlobalChatPanel의 메시지 관련 이벤트를 그대로 emit합니다.
ChatUserMessage
| Prop | 타입 |
|---|---|
message | GlobalChatMessageView |
actionLabels | GlobalChatMessageActionLabels |
typescript
emit('edit', messageId: string, text: string)
emit('select-version', messageId: string, direction: 'prev' | 'next')
emit('copy', messageId: string, text: string)
emit('open-context', preview: GlobalChatContextPreviewView)ChatAssistantMessage
| Prop | 타입 |
|---|---|
message | GlobalChatMessageView |
actionLabels | GlobalChatMessageActionLabels |
transactionLinksInteractive | boolean? |
transactionContext | GlobalChatTransactionContext? |
typescript
emit('toggle-thinking', messageId)
emit('toggle-thinking-message-api-call', messageId, apiCallId)
emit('toggle-thinking-api-call', messageId, checklistId, itemId, apiCallId)
emit('select-version', messageId, direction)
emit('retry', messageId)
emit('copy', messageId, text)
emit('copy-code', messageId, code)
emit('open-transaction', transactionId)ChatResponseContent
응답 블록 배열을 실제 본문으로 렌더합니다.
| Prop | 타입 | 설명 |
|---|---|---|
blocks | readonly GlobalChatResponseBlockView[] | 필수 |
emptyMessage | string? | Bridge가 명시한 경우에만 표시 |
canRetry | boolean? | |
retryLabel | string? | |
copyCodeLabel | string? | 있을 때만 코드 복사 버튼 노출 |
transactionLinksInteractive | boolean? |
typescript
emit('retry')
emit('open-transaction', transactionId: string)ChatResponseText / ChatResponseCode / ChatResponseTable
| 컴포넌트 | Props |
|---|---|
ChatResponseText | text: string, copyCodeLabel?: string, transactionLinksInteractive?: boolean |
ChatResponseCode | language: string | null, code: string, copyLabel?: string |
ChatResponseTable | table: ResponseMarkdownTable, transactionLinksInteractive?: boolean |
copyLabel / copyCodeLabel을 주지 않으면 복사 액션이 아예 렌더되지 않습니다. 버튼만 숨기는 것이 아니라 라벨 부재를 "기능 미제공"으로 해석합니다.
WARNING
ResponseMarkdownTable은 패키지 밖으로 export되지 않는 내부 타입입니다. 따라서 ChatResponseTable을 외부에서 직접 사용하기는 어렵습니다. Markdown 표는 ChatResponseContent가 text 블록을 파싱해 내부적으로 렌더하므로, 호출부는 ChatResponseContent(또는 GlobalChatPanel)를 쓰면 됩니다.
ChatThinkingPanel
| Prop | 타입 |
|---|---|
view | GlobalChatThinkingView |
typescript
emit('toggle')
emit('toggle-api-call', checklistId: string, itemId: string, apiCallId: string)
emit('toggle-message-api-call', apiCallId: string)ChatThinkingChecklist
| Prop | 타입 |
|---|---|
view | GlobalChatThinkingChecklistView |
typescript
emit('toggle-api-call', checklistId: string, itemId: string, apiCallId: string)ChatThinkingApiCall
| Prop | 타입 | 설명 |
|---|---|---|
views | readonly GlobalChatThinkingApiCallView[] | 필수 |
label | string? | 토글 라벨 |
typescript
emit('toggle', apiCallId: string)ChatThinkingIndicator
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
label | string | undefined | 없으면 아이콘만 노출 |
size | number | 20 |
ChatHistoryPanel
| Prop | 타입 | 설명 |
|---|---|---|
view | GlobalChatHistoryView | 필수 |
backLabel | string? | 모바일 전체 폭 화면의 뒤로가기 |
typescript
emit('close')
emit('update:search', search: string)
emit('select', chatId: string)
emit('open-item-menu', chatId: string)
emit('close-item-menu')
emit('rename', chatId: string)
emit('delete', chatId: string)
emit('update:renaming-title', title: string)
emit('submit-rename', chatId: string)
emit('cancel-rename')검색 대상은 제목뿐입니다. 본문 full-text 검색은 제공하지 않습니다.
ChatContextPreview
| Prop | 타입 | 기본값 |
|---|---|---|
view | GlobalChatContextPreviewView | — (필수) |
removable | boolean | false |
removeLabel | string | null | null |
typescript
emit('remove', contextId: string)
emit('open', preview: GlobalChatContextPreviewView) // id가 아니라 카드 자체를 넘긴다open이 id가 아닌 객체 전체를 넘기는 이유는, composer와 지난 질문이 같은 context.md id를 공유할 수 있어 id만으로는 어느 카드인지 되찾을 수 없기 때문입니다.
ChatContextLightbox
| Prop | 타입 |
|---|---|
view | GlobalChatContextPreviewView |
closeLabel | string |
typescript
emit('close')ChatEmptyState
| Prop | 타입 |
|---|---|
view | GlobalChatEmptyStateView |
typescript
interface GlobalChatEmptyStateView {
greetingLines: readonly string[];
descriptionLines: readonly string[];
}줄 나눔은 Bridge가 정한 배열 그대로 지킵니다. 컴포넌트가 재배치하지 않습니다.
ChatMessageVersionPager
| Prop | 타입 |
|---|---|
current | number |
total | number |
labels | Pick<GlobalChatMessageActionLabels, 'versionPager' | 'previousVersion' | 'nextVersion'> |
typescript
emit('select', direction: 'prev' | 'next')useGlobalChatClipboard
데스크톱과 모바일의 복사 동작을 통일하는 composable입니다. @vueuse/core의 useClipboard를 legacy: true로 감싸 비보안 컨텍스트에서도 동작합니다.
typescript
import { useGlobalChatClipboard } from '@jennifersoft/apm-components';
const { copyText } = useGlobalChatClipboard();
async function onCopy(messageId: string, text: string) {
const ok = await copyText(text); // 예외를 던지지 않고 boolean 반환
if (ok) showToast('복사했습니다.');
}주의사항
- view를 부분 변경(mutate)하지 마세요. 새 객체로 교체해야 갱신이 감지됩니다.
- 라벨을 비우면 해당 기능이 사라집니다.
copyCodeLabel,removeLabel,retryLabel이 그렇습니다. emptyResponseMessage없이 빈blocks를 내려주면 아무것도 표시되지 않습니다(스트리밍 중으로 간주).- 에러 블록의
message에는 서버 원문 payload를 넣지 마세요. 사용자 노출 문구만 담습니다. transactionLinksInteractive는 X-View를 열 수 있는 데스크톱 호스트에서만 켭니다.