Skip to content

Global Chat ​

@jennifersoft/apm-components의 Global Chat(AI 채팅) 패널 문서입니다. 이 문서는 @jennifersoft/apm-components@1.4.7 기준으로 정리했습니다.

설계 원칙 ​

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) ──▶ Bridge

GlobalChatPanel ​

패널 전체를 그리는 최상위 컴포넌트입니다. 보통 이것만 사용합니다.

Props ​

Prop타입기본값설명
viewGlobalChatPanelView— (필수)패널 전체 화면 모델
historyBackLabelstringundefined모바일 기록 화면의 뒤로가기 라벨
submitShortcutbooleantrueEnter 전송 단축키 사용 여부
transactionLinksInteractivebooleanfalse응답 본문의 TXID 토큰을 클릭 가능하게 함 (데스크톱 전용)

transactionLinksInteractive는 데스크톱 호스트만 켭니다. 모바일에서는 X-View 팝업을 열 수 없으므로 꺼둔 채로 텍스트만 표시합니다.

이벤트 ​

전송·입력

이벤트페이로드설명
update:draftdraft: string입력창 내용 변경
submitdraft: string질문 전송
stop—응답 생성 중단
remove-contextcontextId: string첨부 context 제거

패널 제어

이벤트페이로드설명
close—패널 닫기
new-chat—새 대화 시작
open-more—더보기 메뉴 열기
select-more-menuitemId: string더보기 항목 선택

지난 대화(History)

이벤트페이로드
open-history / close-history—
select-historychatId: string
update:history-searchsearch: string
open-history-item-menuchatId: string
close-history-item-menu—
rename-history-itemchatId: string
delete-history-itemchatId: string
update:history-renaming-titletitle: string
submit-history-renamechatId: string
cancel-history-rename—

메시지

이벤트페이로드설명
toggle-thinkingmessageIdThinking 패널 펼침 전환
toggle-thinking-message-api-callmessageId, apiCallId체크리스트에 연결되지 않은 API 호출 토글
toggle-thinking-api-callmessageId, checklistId, itemId, apiCallId체크리스트 항목의 API 호출 토글
edit-questionmessageId, text질문 수정 저장
retry-messagemessageId답변 재생성
select-message-versionmessageId, direction: 'prev' | 'next'버전 페이징
copy-messagemessageId, text질문/답변 복사
copy-codemessageId, code코드 블록 복사
open-transactiontransactionId: stringTXID 링크 클릭

기본 사용법 ​

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를 하나의 배열에 섞어 담는 이유는 수신 순서를 보존하기 위해서입니다. 두 종류를 별도 필드로 나누면 어느 쪽이 먼저 왔는지 잃어버립니다.

elapsedTimeLabel은 헤더에 노출되는 경과 시간 문자열(예: 4초, 경과 시간 10초)이며, 숫자가 변경될 때 자릿수별 슬롯머신 롤링 트랜지션 애니메이션이 자동으로 적용됩니다. 체크리스트의 항목에 apiCalls가 있을 경우, 항목의 제목 위치에 ChatThinkingApiCall이 직접 배치되어 인자 목록을 아코디언 카드 형태로 확인할 수 있습니다.

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타입기본값
viewGlobalChatComposerView— (필수)
submitShortcutbooleantrue
typescript
emit('update:draft', draft: string)
emit('submit', draft: string)
emit('stop')
emit('remove-context', contextId: string)
emit('open-context', preview: GlobalChatContextPreviewView)

ChatMessageList ​

Prop타입
messagesreadonly GlobalChatMessageView[]
actionLabelsGlobalChatMessageActionLabels
transactionLinksInteractiveboolean?
transactionContextGlobalChatTransactionContext?

GlobalChatPanel의 메시지 관련 이벤트를 그대로 emit합니다.

ChatUserMessage ​

Prop타입
messageGlobalChatMessageView
actionLabelsGlobalChatMessageActionLabels
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타입
messageGlobalChatMessageView
actionLabelsGlobalChatMessageActionLabels
transactionLinksInteractiveboolean?
transactionContextGlobalChatTransactionContext?
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타입설명
blocksreadonly GlobalChatResponseBlockView[]필수
emptyMessagestring?Bridge가 명시한 경우에만 표시
canRetryboolean?
retryLabelstring?
copyCodeLabelstring?있을 때만 코드 복사 버튼 노출
transactionLinksInteractiveboolean?
typescript
emit('retry')
emit('open-transaction', transactionId: string)

ChatResponseText / ChatResponseCode / ChatResponseTable ​

컴포넌트Props
ChatResponseTexttext: string, copyCodeLabel?: string, transactionLinksInteractive?: boolean
ChatResponseCodelanguage: string | null, code: string, copyLabel?: string
ChatResponseTabletable: ResponseMarkdownTable, transactionLinksInteractive?: boolean

copyLabel / copyCodeLabel을 주지 않으면 복사 액션이 아예 렌더되지 않습니다. 버튼만 숨기는 것이 아니라 라벨 부재를 "기능 미제공"으로 해석합니다.

WARNING

ResponseMarkdownTable은 패키지 밖으로 export되지 않는 내부 타입입니다. 따라서 ChatResponseTable을 외부에서 직접 사용하기는 어렵습니다. Markdown 표는 ChatResponseContent가 text 블록을 파싱해 내부적으로 렌더하므로, 호출부는 ChatResponseContent(또는 GlobalChatPanel)를 쓰면 됩니다.

ChatThinkingPanel ​

Prop타입
viewGlobalChatThinkingView
typescript
emit('toggle')
emit('toggle-api-call', checklistId: string, itemId: string, apiCallId: string)
emit('toggle-message-api-call', apiCallId: string)

ChatThinkingChecklist ​

체크리스트 항목을 표시합니다. 항목에 apiCalls가 있을 경우(item.apiCalls.length > 0), 기존 텍스트 타이틀 대신 ChatThinkingApiCall 컴포넌트가 해당 라인의 제목 위치에 직접 렌더링되며 item.apiCallLabel이 버튼 라벨로 사용됩니다.

Prop타입
viewGlobalChatThinkingChecklistView
typescript
emit('toggle-api-call', checklistId: string, itemId: string, apiCallId: string)

ChatThinkingApiCall ​

체크리스트 항목에 API 호출이 있을 때 타이틀 위치에 표시되거나, 독립적인 API 호출 내역을 표시하는 컴포넌트입니다. 펼침 시 상단에 API 호출 라벨(i18n.globalChatApiCallGroup, 기본값 'API 호출')과 함께 테두리 카드(border: 1px solid var(--gray-200)) 형태로 인자 목록이 표시됩니다.

Prop타입설명
viewsreadonly GlobalChatThinkingApiCallView[]필수
labelstring?토글 라벨
typescript
emit('toggle', apiCallId: string)

ChatThinkingIndicator ​

Prop타입기본값설명
labelstringundefined없으면 아이콘만 노출
sizenumber20

ChatHistoryPanel ​

Prop타입설명
viewGlobalChatHistoryView필수
backLabelstring?모바일 전체 폭 화면의 뒤로가기
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타입기본값
viewGlobalChatContextPreviewView— (필수)
removablebooleanfalse
removeLabelstring | nullnull
typescript
emit('remove', contextId: string)
emit('open', preview: GlobalChatContextPreviewView)  // id가 아니라 카드 자체를 넘긴다

open이 id가 아닌 객체 전체를 넘기는 이유는, composer와 지난 질문이 같은 context.md id를 공유할 수 있어 id만으로는 어느 카드인지 되찾을 수 없기 때문입니다.

ChatContextLightbox ​

Prop타입
viewGlobalChatContextPreviewView
closeLabelstring
typescript
emit('close')

ChatEmptyState ​

Prop타입
viewGlobalChatEmptyStateView
typescript
interface GlobalChatEmptyStateView {
    greetingLines: readonly string[];
    descriptionLines: readonly string[];
}

줄 나눔은 Bridge가 정한 배열 그대로 지킵니다. 컴포넌트가 재배치하지 않습니다.

ChatMessageVersionPager ​

Prop타입
currentnumber
totalnumber
labelsPick<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('복사했습니다.');
}

Chat Markdown Demo ​

apm-components의 /chat-markdown 데모는 ChatResponseContent와 GlobalChatResponseBlockView를 사용해 Markdown 응답을 보여줍니다. 폭을 320px(모바일), 560px(데스크톱), 전체 폭으로 바꾸며 같은 고정 Markdown fixture의 표·코드·텍스트 렌더링을 비교합니다.

데모의 문장은 패키지에 포함된 예시 데이터이며 서버 응답이 아닙니다. 제품에서는 호스트가 응답 블록과 Markdown 원문을 구성해 전달합니다.

주의사항 ​

  • view를 부분 변경(mutate)하지 마세요. 새 객체로 교체해야 갱신이 감지됩니다.
  • 라벨을 비우면 해당 기능이 사라집니다. copyCodeLabel, removeLabel, retryLabel이 그렇습니다.
  • emptyResponseMessage 없이 빈 blocks를 내려주면 아무것도 표시되지 않습니다(스트리밍 중으로 간주).
  • 에러 블록의 message에는 서버 원문 payload를 넣지 마세요. 사용자 노출 문구만 담습니다.
  • transactionLinksInteractive는 X-View를 열 수 있는 데스크톱 호스트에서만 켭니다.