@conscience-technology/ctds 0.1.11 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/components/AIMessage/AIMessage.module.css +12 -4
- package/assets/components/AIMessage/AIMessage.tsx +3 -1
- package/assets/components/AttachmentThumb/AttachmentThumb.module.css +25 -4
- package/assets/components/MessageSources/MessageSources.module.css +3 -3
- package/assets/components/MessageSources/MessageSources.tsx +1 -5
- package/assets/components/MessageState/MessageState.module.css +66 -0
- package/assets/components/MessageState/MessageState.tsx +107 -0
- package/assets/components/MessageState/index.ts +2 -0
- package/assets/components/Spinner/LottiePlayer.tsx +8 -2
- package/assets/components/Spinner/Spinner.module.css +0 -1
- package/assets/components/Spinner/lottieColor.ts +17 -21
- package/assets/components/_data.ts +2 -1
- package/assets/prompts/components/message-sources.md +2 -2
- package/assets/prompts/components/message-state.md +53 -0
- package/package.json +1 -1
|
@@ -22,16 +22,24 @@
|
|
|
22
22
|
min-width: 0;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
/* isGenerating — 한 글자씩 통통 wave */
|
|
25
|
+
/* isGenerating — 한 글자씩 통통 wave. Spinner+텍스트 세로 중앙 정렬. */
|
|
26
26
|
.root[data-generating="true"] .body {
|
|
27
27
|
color: var(--fg-muted);
|
|
28
28
|
font-weight: var(--font-weight-medium);
|
|
29
|
+
display: inline-flex;
|
|
30
|
+
align-items: center;
|
|
31
|
+
gap: var(--space-2);
|
|
32
|
+
line-height: 1.4;
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
.generatingSpinner {
|
|
32
|
-
color: var(--
|
|
33
|
-
|
|
34
|
-
|
|
36
|
+
color: var(--primary);
|
|
37
|
+
flex: 0 0 auto;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.generatingText {
|
|
41
|
+
display: inline-flex;
|
|
42
|
+
align-items: center;
|
|
35
43
|
}
|
|
36
44
|
|
|
37
45
|
.bounceChar {
|
|
@@ -85,7 +85,9 @@ export function AIMessage({
|
|
|
85
85
|
{isGenerating ? (
|
|
86
86
|
<>
|
|
87
87
|
<Spinner variant="lottie-dots" size="sm" className={styles.generatingSpinner} />
|
|
88
|
-
{
|
|
88
|
+
<span className={styles.generatingText}>
|
|
89
|
+
{typeof children === "string" ? renderBouncingText(children) : children}
|
|
90
|
+
</span>
|
|
89
91
|
</>
|
|
90
92
|
) : (
|
|
91
93
|
children
|
|
@@ -53,15 +53,36 @@
|
|
|
53
53
|
height: calc(var(--at-size) * 0.60);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
/* Loading —
|
|
57
|
-
Spinner
|
|
56
|
+
/* Loading — bg-muted ↔ 더 어두운 톤 사이를 미묘하게 흐르는 그라데이션 + 중앙 흰색 lottie spinner.
|
|
57
|
+
Spinner colorBind가 .loading의 color(=fixed-white)를 상속받아 흰색으로 recolor된다. */
|
|
58
58
|
.loading {
|
|
59
|
-
background:
|
|
59
|
+
background: var(--bg-muted);
|
|
60
60
|
color: var(--fixed-white);
|
|
61
61
|
overflow: hidden;
|
|
62
62
|
}
|
|
63
|
-
|
|
63
|
+
.loading::before {
|
|
64
|
+
content: "";
|
|
65
|
+
position: absolute;
|
|
66
|
+
inset: 0;
|
|
67
|
+
background: linear-gradient(
|
|
68
|
+
120deg,
|
|
69
|
+
var(--bg-muted) 0%,
|
|
70
|
+
color-mix(in oklch, var(--fg) 18%, var(--bg-muted)) 50%,
|
|
71
|
+
var(--bg-muted) 100%
|
|
72
|
+
);
|
|
73
|
+
background-size: 220% 220%;
|
|
74
|
+
border-radius: inherit;
|
|
75
|
+
animation: at-shimmer calc(var(--duration-slower) * 6) var(--ease-in-out) infinite;
|
|
76
|
+
}
|
|
77
|
+
@keyframes at-shimmer {
|
|
78
|
+
0% { background-position: 0% 50%; }
|
|
79
|
+
50% { background-position: 100% 50%; }
|
|
80
|
+
100% { background-position: 0% 50%; }
|
|
81
|
+
}
|
|
82
|
+
/* Spinner는 pulse 위 레이어로 중앙 고정, wide 로티도 꽉 차 보이게 scale-up */
|
|
64
83
|
.loading > span[role="status"] {
|
|
84
|
+
position: relative;
|
|
85
|
+
z-index: var(--z-raise);
|
|
65
86
|
transform: scale(1.6);
|
|
66
87
|
transform-origin: center;
|
|
67
88
|
}
|
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
.root {
|
|
5
5
|
display: inline-flex;
|
|
6
6
|
align-items: center;
|
|
7
|
-
gap: var(--space-
|
|
8
|
-
padding: var(--space-
|
|
7
|
+
gap: var(--space-1_5);
|
|
8
|
+
padding: var(--space-1_5) var(--space-3) var(--space-1_5) var(--space-2);
|
|
9
9
|
border: 0;
|
|
10
10
|
background: transparent;
|
|
11
11
|
color: var(--fg-muted);
|
|
12
12
|
border-radius: var(--radius-full);
|
|
13
13
|
cursor: pointer;
|
|
14
|
-
font-size: var(--text-
|
|
14
|
+
font-size: var(--text-sm);
|
|
15
15
|
line-height: 1;
|
|
16
16
|
transition: var(--transition-colors);
|
|
17
17
|
}
|
|
@@ -32,7 +32,6 @@ export function MessageSources({
|
|
|
32
32
|
...rest
|
|
33
33
|
}: MessageSourcesProps) {
|
|
34
34
|
const visible = sources.slice(0, maxFavicons);
|
|
35
|
-
const extra = sources.length - visible.length;
|
|
36
35
|
|
|
37
36
|
const cls = [styles.root, className].filter(Boolean).join(" ");
|
|
38
37
|
|
|
@@ -55,10 +54,7 @@ export function MessageSources({
|
|
|
55
54
|
</span>
|
|
56
55
|
))}
|
|
57
56
|
</span>
|
|
58
|
-
<span className={styles.label}>
|
|
59
|
-
{label}
|
|
60
|
-
{extra > 0 ? <span className={styles.extra}> +{extra}</span> : null}
|
|
61
|
-
</span>
|
|
57
|
+
<span className={styles.label}>{label}</span>
|
|
62
58
|
</button>
|
|
63
59
|
);
|
|
64
60
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/* MessageState — AI 응답 중 "뭘 하고 있는지" 라인.
|
|
2
|
+
Lottie/Spinner 리더 + 통통거리는 라벨. done=true면 ✓ 로 전환, 라벨 정적. */
|
|
3
|
+
|
|
4
|
+
.root {
|
|
5
|
+
display: inline-flex;
|
|
6
|
+
align-items: center;
|
|
7
|
+
gap: var(--space-2);
|
|
8
|
+
color: var(--fg-muted);
|
|
9
|
+
font-size: var(--text-sm);
|
|
10
|
+
line-height: 1.4;
|
|
11
|
+
font-weight: var(--font-weight-medium);
|
|
12
|
+
}
|
|
13
|
+
.root[data-done] {
|
|
14
|
+
color: var(--fg);
|
|
15
|
+
font-weight: var(--font-weight-regular);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.leadIcon {
|
|
19
|
+
display: inline-flex;
|
|
20
|
+
align-items: center;
|
|
21
|
+
justify-content: center;
|
|
22
|
+
width: var(--space-4);
|
|
23
|
+
height: var(--space-4);
|
|
24
|
+
color: var(--primary);
|
|
25
|
+
flex: 0 0 auto;
|
|
26
|
+
}
|
|
27
|
+
.root[data-done] .leadIcon {
|
|
28
|
+
color: var(--success);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.leadSpinner {
|
|
32
|
+
color: var(--primary);
|
|
33
|
+
flex: 0 0 auto;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* 통통거리는 라벨 (AIMessage의 generating 패턴과 동일 — 중복 정의) */
|
|
37
|
+
.label {
|
|
38
|
+
display: inline-flex;
|
|
39
|
+
align-items: center;
|
|
40
|
+
}
|
|
41
|
+
.bounceChar {
|
|
42
|
+
display: inline-block;
|
|
43
|
+
animation: msgstate-char-bounce calc(var(--duration-slower) * 5) var(--ease-in-out) infinite;
|
|
44
|
+
}
|
|
45
|
+
.bounceSpace {
|
|
46
|
+
display: inline-block;
|
|
47
|
+
white-space: pre;
|
|
48
|
+
}
|
|
49
|
+
@keyframes msgstate-char-bounce {
|
|
50
|
+
0%, 70%, 100% {
|
|
51
|
+
transform: translateY(0);
|
|
52
|
+
opacity: 0.6;
|
|
53
|
+
}
|
|
54
|
+
35% {
|
|
55
|
+
transform: translateY(calc(-1.5px * var(--motion-distance-scale))); /* lint-hardcoded: ignore */
|
|
56
|
+
opacity: 1;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* done일 땐 애니 정지 (bounce span도 opacity 1 static) */
|
|
61
|
+
.root[data-done] .bounceChar,
|
|
62
|
+
.root[data-done] .bounceSpace {
|
|
63
|
+
animation: none;
|
|
64
|
+
opacity: 1;
|
|
65
|
+
transform: none;
|
|
66
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { HTMLAttributes, ReactNode } from "react";
|
|
4
|
+
import {
|
|
5
|
+
MagnifyingGlass as PhSearch,
|
|
6
|
+
PencilSimple as PhPencil,
|
|
7
|
+
Image as PhImage,
|
|
8
|
+
VideoCamera as PhVideo,
|
|
9
|
+
File as PhFile,
|
|
10
|
+
Check as PhCheck,
|
|
11
|
+
} from "@phosphor-icons/react";
|
|
12
|
+
import { Spinner } from "@/components/ctds/Spinner";
|
|
13
|
+
import styles from "./MessageState.module.css";
|
|
14
|
+
|
|
15
|
+
export type MessageStateKind =
|
|
16
|
+
| "generating"
|
|
17
|
+
| "searching"
|
|
18
|
+
| "memory"
|
|
19
|
+
| "image"
|
|
20
|
+
| "video"
|
|
21
|
+
| "file"
|
|
22
|
+
| "custom";
|
|
23
|
+
|
|
24
|
+
export interface MessageStateProps extends HTMLAttributes<HTMLDivElement> {
|
|
25
|
+
kind?: MessageStateKind;
|
|
26
|
+
/** 라벨 override. kind별 기본 라벨이 있지만 커스터마이즈 가능. */
|
|
27
|
+
message?: ReactNode;
|
|
28
|
+
/** 완료 상태 — true면 spinner 자리에 ✓, 라벨 통통거림 멈춤. */
|
|
29
|
+
done?: boolean;
|
|
30
|
+
/** 커스텀 아이콘 — kind="custom"이거나 override 하고 싶을 때. */
|
|
31
|
+
icon?: ReactNode;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const DEFAULT_MESSAGE: Record<MessageStateKind, string> = {
|
|
35
|
+
generating: "응답 생성 중…",
|
|
36
|
+
searching: "Searching the web…",
|
|
37
|
+
memory: "Updated saved memory",
|
|
38
|
+
image: "Looking at the image…",
|
|
39
|
+
video: "Reviewing the video…",
|
|
40
|
+
file: "Processing file…",
|
|
41
|
+
custom: "",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const KIND_ICON: Record<MessageStateKind, React.ComponentType<any> | null> = {
|
|
45
|
+
generating: null, // Spinner만
|
|
46
|
+
searching: PhSearch,
|
|
47
|
+
memory: PhPencil,
|
|
48
|
+
image: PhImage,
|
|
49
|
+
video: PhVideo,
|
|
50
|
+
file: PhFile,
|
|
51
|
+
custom: null,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function renderBouncingText(text: string) {
|
|
55
|
+
const chars = Array.from(text);
|
|
56
|
+
return chars.map((ch, i) => {
|
|
57
|
+
if (ch === " ") {
|
|
58
|
+
return (
|
|
59
|
+
<span key={i} className={styles.bounceSpace}>
|
|
60
|
+
{" "}
|
|
61
|
+
</span>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return (
|
|
65
|
+
<span
|
|
66
|
+
key={i}
|
|
67
|
+
className={styles.bounceChar}
|
|
68
|
+
style={{ animationDelay: `calc(${i} * var(--duration-base))` }}
|
|
69
|
+
>
|
|
70
|
+
{ch}
|
|
71
|
+
</span>
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function MessageState({
|
|
77
|
+
kind = "generating",
|
|
78
|
+
message,
|
|
79
|
+
done = false,
|
|
80
|
+
icon,
|
|
81
|
+
className,
|
|
82
|
+
...rest
|
|
83
|
+
}: MessageStateProps) {
|
|
84
|
+
const label = message ?? DEFAULT_MESSAGE[kind];
|
|
85
|
+
const KindIcon = KIND_ICON[kind];
|
|
86
|
+
|
|
87
|
+
const cls = [styles.root, className].filter(Boolean).join(" ");
|
|
88
|
+
|
|
89
|
+
const leadIcon = done ? (
|
|
90
|
+
<PhCheck className={styles.leadIcon} weight="bold" aria-hidden />
|
|
91
|
+
) : icon ? (
|
|
92
|
+
<span className={styles.leadIcon}>{icon}</span>
|
|
93
|
+
) : KindIcon ? (
|
|
94
|
+
<KindIcon className={styles.leadIcon} weight="regular" aria-hidden />
|
|
95
|
+
) : (
|
|
96
|
+
<Spinner variant="lottie-dots" size="sm" className={styles.leadSpinner} />
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div className={cls} data-done={done || undefined} {...rest}>
|
|
101
|
+
{leadIcon}
|
|
102
|
+
<span className={styles.label}>
|
|
103
|
+
{done || typeof label !== "string" ? label : renderBouncingText(label)}
|
|
104
|
+
</span>
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -53,8 +53,14 @@ export function LottiePlayer({ name, className }: LottiePlayerProps) {
|
|
|
53
53
|
// 바깥 요소의 computed color를 읽어 재색상화
|
|
54
54
|
const el = containerRef.current;
|
|
55
55
|
const css = el ? getComputedStyle(el).color : "rgb(0,0,0)";
|
|
56
|
-
const rgb = parseCssColor(css)
|
|
57
|
-
|
|
56
|
+
const rgb = parseCssColor(css);
|
|
57
|
+
// eslint-disable-next-line no-console
|
|
58
|
+
console.log(
|
|
59
|
+
`[LottiePlayer] name=${name} | computed.color="${css}" | parsed=${
|
|
60
|
+
rgb ? `[${rgb.map((v) => (v * 255).toFixed(0)).join(",")}]` : "NULL(fallback to black)"
|
|
61
|
+
}`,
|
|
62
|
+
);
|
|
63
|
+
setData(recolorLottie(raw, rgb ?? [0, 0, 0]));
|
|
58
64
|
});
|
|
59
65
|
return () => {
|
|
60
66
|
cancelled = true;
|
|
@@ -9,38 +9,34 @@
|
|
|
9
9
|
export type Rgb = [number, number, number];
|
|
10
10
|
|
|
11
11
|
/** CSS 색 문자열 → [r,g,b] 0~1 정규화. 서버에서는 null 반환.
|
|
12
|
-
* oklch()
|
|
13
|
-
* 브라우저가
|
|
12
|
+
* getComputedStyle이 oklch()를 그대로 반환하는 브라우저가 많아, Canvas 2D fillStyle에
|
|
13
|
+
* 던져 브라우저가 sRGB hex로 정규화해주는 트릭을 사용. Canvas가 못 받으면 "#000"이 유지되므로
|
|
14
|
+
* rgb() 폴백 파싱. */
|
|
14
15
|
export function parseCssColor(css: string): Rgb | null {
|
|
15
16
|
if (typeof document === "undefined") return null;
|
|
16
17
|
const canvas = document.createElement("canvas");
|
|
17
|
-
canvas.width = 1;
|
|
18
|
-
canvas.height = 1;
|
|
18
|
+
canvas.width = canvas.height = 1;
|
|
19
19
|
const ctx = canvas.getContext("2d");
|
|
20
20
|
if (!ctx) return null;
|
|
21
|
+
// 센티넬로 "#000"을 먼저 넣고 입력 시도 — Canvas가 입력을 못 받으면 "#000"이 그대로 유지됨.
|
|
21
22
|
ctx.fillStyle = "#000";
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
let hex = /^#([0-9a-f]{6})$/i.exec(computed);
|
|
27
|
-
if (!hex) {
|
|
28
|
-
const el = document.createElement("div");
|
|
29
|
-
el.style.color = css;
|
|
30
|
-
document.body.appendChild(el);
|
|
31
|
-
ctx.fillStyle = "#000";
|
|
32
|
-
ctx.fillStyle = getComputedStyle(el).color;
|
|
33
|
-
document.body.removeChild(el);
|
|
34
|
-
hex = /^#([0-9a-f]{6})$/i.exec(ctx.fillStyle as string);
|
|
23
|
+
try {
|
|
24
|
+
ctx.fillStyle = css;
|
|
25
|
+
} catch {
|
|
26
|
+
// 일부 브라우저는 invalid color 시 예외 throw
|
|
35
27
|
}
|
|
28
|
+
const normalized = ctx.fillStyle as string;
|
|
29
|
+
const hex = /^#([0-9a-f]{6})$/i.exec(normalized);
|
|
36
30
|
if (hex) {
|
|
37
31
|
const n = parseInt(hex[1], 16);
|
|
38
32
|
return [((n >> 16) & 0xff) / 255, ((n >> 8) & 0xff) / 255, (n & 0xff) / 255];
|
|
39
33
|
}
|
|
40
|
-
//
|
|
41
|
-
const m =
|
|
42
|
-
if (
|
|
43
|
-
|
|
34
|
+
// Canvas가 못 받으면 rgba()/rgb() 문자열일 수 있음 → 숫자 파싱
|
|
35
|
+
const m = normalized.match(/-?\d+(\.\d+)?/g);
|
|
36
|
+
if (m && m.length >= 3 && !normalized.startsWith("oklch") && !normalized.startsWith("color(")) {
|
|
37
|
+
return [+m[0] / 255, +m[1] / 255, +m[2] / 255];
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
44
40
|
}
|
|
45
41
|
|
|
46
42
|
/** 색 자리를 target으로 치환. colorBind=true 전용 — 단색 로티 가정.
|
|
@@ -606,6 +606,7 @@ export const componentSlugs: Record<string, string> = {
|
|
|
606
606
|
"Message Quote": "chat/message-quote",
|
|
607
607
|
"Message Sources": "chat/message-sources",
|
|
608
608
|
"Message Table": "chat/message-table",
|
|
609
|
+
"Message State": "chat/message-state",
|
|
609
610
|
};
|
|
610
611
|
|
|
611
612
|
/** 작업중 — 사이드바에서 접근은 가능하지만 "작업중" 뱃지로 표시해 사용 자제 유도. */
|
|
@@ -672,7 +673,7 @@ export const components: { id: string; title: string; items: string[] }[] = [
|
|
|
672
673
|
items: [
|
|
673
674
|
"File Icon", "Attachment Thumb", "Attachment Chip", "Quotation Bar", "Chat Input",
|
|
674
675
|
"Bubble", "Message Actions", "User Message", "AI Message",
|
|
675
|
-
"Code Block", "Message Quote", "Message Sources", "Message Table",
|
|
676
|
+
"Code Block", "Message Quote", "Message Sources", "Message Table", "Message State",
|
|
676
677
|
"Chat Sidebar",
|
|
677
678
|
],
|
|
678
679
|
},
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# Message Sources
|
|
2
2
|
|
|
3
3
|
### 원칙
|
|
4
|
-
AIMessage의 **하단 액션바 옆에 inline으로 붙는
|
|
4
|
+
AIMessage의 **하단 액션바 옆에 inline으로 붙는 chip**. 겹친 favicon stack + 라벨("Sources"). 블록이 아니라 액션 액세서리 — 실제론 [[components/ai-message]]의 `sources` prop으로 전달해 자동 배치. 전체 소스 리스트는 chip 클릭 시 popover로 (소비처가 조립).
|
|
5
5
|
|
|
6
6
|
### Do
|
|
7
7
|
- `<AIMessage sources={...}>` prop으로 전달. standalone으로 쓸 일은 거의 없음.
|
|
8
|
-
- `maxFavicons`로 겹쳐 보여줄 favicon 개수 조정 (기본 3).
|
|
8
|
+
- `maxFavicons`로 겹쳐 보여줄 favicon 개수 조정 (기본 3). 넘어도 라벨은 그대로 — 숫자 노출 안 함(라벨만).
|
|
9
9
|
- 각 소스에 `icon` 넘겨 favicon을 명시, 없으면 중성 원형 placeholder.
|
|
10
10
|
- 클릭 시 소비처에서 popover(소스 전체 리스트) 열기 — 디자인 시스템 범위 밖.
|
|
11
11
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Message State
|
|
2
|
+
|
|
3
|
+
### 원칙
|
|
4
|
+
AI 응답 중간에 쌓이는 **"뭘 하고 있는지"** 라인. Lottie-ish Spinner 또는 kind별 아이콘 + 통통거리는 라벨. `done=true`면 ✓ 체크 + 라벨 정적. [[components/ai-message]] 안에서 children으로 여러 개 쌓아 "작업 플로우" 전달.
|
|
5
|
+
|
|
6
|
+
### Do
|
|
7
|
+
- `kind`로 상태 분류: generating / searching / memory / image / video / file / custom.
|
|
8
|
+
- 완료되면 **동일 state를 done=true로 유지** — 사라지면 흐름이 안 보임.
|
|
9
|
+
- 라벨 override는 `message` prop. kind는 아이콘만 쓰고 커스텀 라벨 가능.
|
|
10
|
+
- AIMessage 안의 children에 여러 MessageState를 쌓고, 끝나면 아래에 실제 응답 본문.
|
|
11
|
+
|
|
12
|
+
### Don't
|
|
13
|
+
- MessageState를 bubble로 감싸지 않는다 — 플랫 라인.
|
|
14
|
+
- Spinner와 kind 아이콘을 동시에 띄우지 않는다 — 하나만 (generating=spinner, 그 외=kind icon).
|
|
15
|
+
- done 상태에서 라벨 통통거리면 안 됨 — 애니 정지.
|
|
16
|
+
- 메시지 완료 후에도 MessageState 계속 노출하지 말고 **접거나 하나로 요약** (예: “3 steps completed ✓”).
|
|
17
|
+
|
|
18
|
+
### Fallback
|
|
19
|
+
kind 애매하면 `generating`, message 애매하면 kind별 기본.
|
|
20
|
+
|
|
21
|
+
### 관련
|
|
22
|
+
- [[components/ai-message]] — 상위 소비처 (`isGenerating`은 전체 메시지가 생성 중일 때, MessageState는 **메시지 안의 세부 작업**)
|
|
23
|
+
- [[components/spinner]] — lottie-dots variant 재사용
|
|
24
|
+
|
|
25
|
+
### Props 시그니처
|
|
26
|
+
```tsx
|
|
27
|
+
<MessageState
|
|
28
|
+
kind="generating | searching | memory | image | video | file | custom"
|
|
29
|
+
message={ReactNode} // kind별 기본 라벨 override
|
|
30
|
+
done={boolean} // true → ✓ + 애니 정지
|
|
31
|
+
icon={ReactNode} // kind="custom" 또는 override
|
|
32
|
+
/>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Kind 매트릭스
|
|
36
|
+
| kind | 아이콘 | 기본 라벨 |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| generating | Spinner (lottie-dots) | 응답 생성 중… |
|
|
39
|
+
| searching | MagnifyingGlass | Searching the web… |
|
|
40
|
+
| memory | PencilSimple | Updated saved memory |
|
|
41
|
+
| image | Image | Looking at the image… |
|
|
42
|
+
| video | VideoCamera | Reviewing the video… |
|
|
43
|
+
| file | File | Processing file… |
|
|
44
|
+
| custom | `icon` prop | `message` prop |
|
|
45
|
+
|
|
46
|
+
### 애니메이션
|
|
47
|
+
- 라벨 각 글자 wave bounce — `duration-slower × 5` 주기, `duration-base` 간격으로 index 지연
|
|
48
|
+
- done=true → `animation: none; opacity: 1; transform: none` 로 정지
|
|
49
|
+
- 리더 아이콘: active primary 색, done success 색
|
|
50
|
+
|
|
51
|
+
### 레이아웃
|
|
52
|
+
- `inline-flex`, gap `space-2`, `text-sm`, line-height 1.4
|
|
53
|
+
- 리더 `space-4` 정사각 (아이콘·spinner 둘 다)
|