Skip to content

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-coreBridge입니다. Bridge가 새 view를 만들어 내려줘야 화면이 갱신됩니다.

IMPORTANT

view.composer.draft를 그대로 두고 update:draft만 받으면 입력창에 글자가 써지지 않습니다. 반드시 Bridge(또는 호스트 상태)에 반영해 새 view로 되돌려줘야 합니다.

화면 모델의 정본은 apm-corepanel-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;

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

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 표는 ChatResponseContenttext 블록을 파싱해 내부적으로 렌더하므로, 호출부는 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

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

ChatThinkingApiCall

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/coreuseClipboardlegacy: 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를 열 수 있는 데스크톱 호스트에서만 켭니다.