@astro-minimax/ai 0.2.0
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/README.md +223 -0
- package/dist/cache/global-cache.d.ts +31 -0
- package/dist/cache/global-cache.d.ts.map +1 -0
- package/dist/cache/global-cache.js +141 -0
- package/dist/cache/index.d.ts +8 -0
- package/dist/cache/index.d.ts.map +1 -0
- package/dist/cache/index.js +62 -0
- package/dist/cache/kv-adapter.d.ts +21 -0
- package/dist/cache/kv-adapter.d.ts.map +1 -0
- package/dist/cache/kv-adapter.js +102 -0
- package/dist/cache/memory-adapter.d.ts +24 -0
- package/dist/cache/memory-adapter.d.ts.map +1 -0
- package/dist/cache/memory-adapter.js +95 -0
- package/dist/cache/response-cache.d.ts +45 -0
- package/dist/cache/response-cache.d.ts.map +1 -0
- package/dist/cache/response-cache.js +85 -0
- package/dist/cache/types.d.ts +118 -0
- package/dist/cache/types.d.ts.map +1 -0
- package/dist/cache/types.js +16 -0
- package/dist/data/index.d.ts +3 -0
- package/dist/data/index.d.ts.map +1 -0
- package/dist/data/index.js +1 -0
- package/dist/data/metadata-loader.d.ts +37 -0
- package/dist/data/metadata-loader.d.ts.map +1 -0
- package/dist/data/metadata-loader.js +54 -0
- package/dist/data/types.d.ts +51 -0
- package/dist/data/types.d.ts.map +1 -0
- package/dist/data/types.js +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/intelligence/citation-guard.d.ts +24 -0
- package/dist/intelligence/citation-guard.d.ts.map +1 -0
- package/dist/intelligence/citation-guard.js +82 -0
- package/dist/intelligence/evidence-analysis.d.ts +29 -0
- package/dist/intelligence/evidence-analysis.d.ts.map +1 -0
- package/dist/intelligence/evidence-analysis.js +88 -0
- package/dist/intelligence/index.d.ts +6 -0
- package/dist/intelligence/index.d.ts.map +1 -0
- package/dist/intelligence/index.js +4 -0
- package/dist/intelligence/intent-detect.d.ts +29 -0
- package/dist/intelligence/intent-detect.d.ts.map +1 -0
- package/dist/intelligence/intent-detect.js +64 -0
- package/dist/intelligence/keyword-extract.d.ts +31 -0
- package/dist/intelligence/keyword-extract.d.ts.map +1 -0
- package/dist/intelligence/keyword-extract.js +114 -0
- package/dist/intelligence/types.d.ts +27 -0
- package/dist/intelligence/types.d.ts.map +1 -0
- package/dist/intelligence/types.js +1 -0
- package/dist/middleware/index.d.ts +3 -0
- package/dist/middleware/index.d.ts.map +1 -0
- package/dist/middleware/index.js +1 -0
- package/dist/middleware/rate-limiter.d.ts +26 -0
- package/dist/middleware/rate-limiter.d.ts.map +1 -0
- package/dist/middleware/rate-limiter.js +129 -0
- package/dist/prompt/dynamic-layer.d.ts +7 -0
- package/dist/prompt/dynamic-layer.d.ts.map +1 -0
- package/dist/prompt/dynamic-layer.js +40 -0
- package/dist/prompt/index.d.ts +6 -0
- package/dist/prompt/index.d.ts.map +1 -0
- package/dist/prompt/index.js +4 -0
- package/dist/prompt/prompt-builder.d.ts +11 -0
- package/dist/prompt/prompt-builder.d.ts.map +1 -0
- package/dist/prompt/prompt-builder.js +19 -0
- package/dist/prompt/semi-static-layer.d.ts +7 -0
- package/dist/prompt/semi-static-layer.d.ts.map +1 -0
- package/dist/prompt/semi-static-layer.js +32 -0
- package/dist/prompt/static-layer.d.ts +3 -0
- package/dist/prompt/static-layer.d.ts.map +1 -0
- package/dist/prompt/static-layer.js +78 -0
- package/dist/prompt/types.d.ts +25 -0
- package/dist/prompt/types.d.ts.map +1 -0
- package/dist/prompt/types.js +1 -0
- package/dist/provider-manager/base.d.ts +26 -0
- package/dist/provider-manager/base.d.ts.map +1 -0
- package/dist/provider-manager/base.js +47 -0
- package/dist/provider-manager/config.d.ts +7 -0
- package/dist/provider-manager/config.d.ts.map +1 -0
- package/dist/provider-manager/config.js +134 -0
- package/dist/provider-manager/index.d.ts +8 -0
- package/dist/provider-manager/index.d.ts.map +1 -0
- package/dist/provider-manager/index.js +6 -0
- package/dist/provider-manager/manager.d.ts +18 -0
- package/dist/provider-manager/manager.d.ts.map +1 -0
- package/dist/provider-manager/manager.js +121 -0
- package/dist/provider-manager/mock.d.ts +18 -0
- package/dist/provider-manager/mock.d.ts.map +1 -0
- package/dist/provider-manager/mock.js +56 -0
- package/dist/provider-manager/openai.d.ts +20 -0
- package/dist/provider-manager/openai.d.ts.map +1 -0
- package/dist/provider-manager/openai.js +83 -0
- package/dist/provider-manager/types.d.ts +217 -0
- package/dist/provider-manager/types.d.ts.map +1 -0
- package/dist/provider-manager/types.js +6 -0
- package/dist/provider-manager/workers.d.ts +20 -0
- package/dist/provider-manager/workers.d.ts.map +1 -0
- package/dist/provider-manager/workers.js +74 -0
- package/dist/providers/index.d.ts +2 -0
- package/dist/providers/index.d.ts.map +1 -0
- package/dist/providers/index.js +1 -0
- package/dist/providers/mock.d.ts +14 -0
- package/dist/providers/mock.d.ts.map +1 -0
- package/dist/providers/mock.js +234 -0
- package/dist/search/index.d.ts +5 -0
- package/dist/search/index.d.ts.map +1 -0
- package/dist/search/index.js +3 -0
- package/dist/search/search-api.d.ts +28 -0
- package/dist/search/search-api.d.ts.map +1 -0
- package/dist/search/search-api.js +110 -0
- package/dist/search/search-index.d.ts +6 -0
- package/dist/search/search-index.d.ts.map +1 -0
- package/dist/search/search-index.js +22 -0
- package/dist/search/search-utils.d.ts +43 -0
- package/dist/search/search-utils.d.ts.map +1 -0
- package/dist/search/search-utils.js +114 -0
- package/dist/search/session-cache.d.ts +19 -0
- package/dist/search/session-cache.d.ts.map +1 -0
- package/dist/search/session-cache.js +92 -0
- package/dist/search/types.d.ts +41 -0
- package/dist/search/types.d.ts.map +1 -0
- package/dist/search/types.js +1 -0
- package/dist/server/chat-handler.d.ts +3 -0
- package/dist/server/chat-handler.d.ts.map +1 -0
- package/dist/server/chat-handler.js +750 -0
- package/dist/server/dev-server.d.ts +18 -0
- package/dist/server/dev-server.d.ts.map +1 -0
- package/dist/server/dev-server.js +294 -0
- package/dist/server/errors.d.ts +17 -0
- package/dist/server/errors.d.ts.map +1 -0
- package/dist/server/errors.js +41 -0
- package/dist/server/index.d.ts +8 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +5 -0
- package/dist/server/metadata-init.d.ts +11 -0
- package/dist/server/metadata-init.d.ts.map +1 -0
- package/dist/server/metadata-init.js +45 -0
- package/dist/server/notify.d.ts +25 -0
- package/dist/server/notify.d.ts.map +1 -0
- package/dist/server/notify.js +62 -0
- package/dist/server/types.d.ts +56 -0
- package/dist/server/types.d.ts.map +1 -0
- package/dist/server/types.js +13 -0
- package/dist/stream/index.d.ts +3 -0
- package/dist/stream/index.d.ts.map +1 -0
- package/dist/stream/index.js +2 -0
- package/dist/stream/mock-stream.d.ts +12 -0
- package/dist/stream/mock-stream.d.ts.map +1 -0
- package/dist/stream/mock-stream.js +27 -0
- package/dist/stream/response.d.ts +10 -0
- package/dist/stream/response.d.ts.map +1 -0
- package/dist/stream/response.js +22 -0
- package/dist/utils/i18n.d.ts +18 -0
- package/dist/utils/i18n.d.ts.map +1 -0
- package/dist/utils/i18n.js +148 -0
- package/package.json +93 -0
- package/src/components/AIChatContainer.tsx +30 -0
- package/src/components/AIChatWidget.astro +30 -0
- package/src/components/ChatPanel.tsx +865 -0
- package/src/styles/source.css +2 -0
|
@@ -0,0 +1,865 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
|
2
|
+
import { useChat } from '@ai-sdk/react';
|
|
3
|
+
import { DefaultChatTransport } from 'ai';
|
|
4
|
+
import type { UIMessage } from 'ai';
|
|
5
|
+
import { getMockResponse, createMockStream } from '../providers/mock.js';
|
|
6
|
+
import type { ArticleChatContext, ChatStatusData } from '../server/types.js';
|
|
7
|
+
import { isChatStatusData } from '../server/types.js';
|
|
8
|
+
import { t, getLang } from '../utils/i18n.js';
|
|
9
|
+
|
|
10
|
+
export interface AIChatConfig {
|
|
11
|
+
enabled?: boolean;
|
|
12
|
+
mockMode?: boolean;
|
|
13
|
+
apiEndpoint?: string;
|
|
14
|
+
welcomeMessage?: string;
|
|
15
|
+
placeholder?: string;
|
|
16
|
+
authorName?: string;
|
|
17
|
+
lang?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface ChatPanelProps {
|
|
21
|
+
open: boolean;
|
|
22
|
+
onClose: () => void;
|
|
23
|
+
config: AIChatConfig;
|
|
24
|
+
articleContext?: ArticleChatContext;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const MIN_SEND_INTERVAL_MS = 500;
|
|
28
|
+
|
|
29
|
+
// ── Typewriter Effect Hook ─────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
const TYPEWRITER_SPEED_MS = 25;
|
|
32
|
+
const TYPEWRITER_BATCH_SIZE = 1;
|
|
33
|
+
|
|
34
|
+
function useTypewriter(fullText: string, isStreaming: boolean): string {
|
|
35
|
+
const [displayedLength, setDisplayedLength] = useState(0);
|
|
36
|
+
const prevFullTextRef = useRef(fullText);
|
|
37
|
+
const prevStreamingRef = useRef(isStreaming);
|
|
38
|
+
const animationRef = useRef<number | null>(null);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (fullText !== prevFullTextRef.current && !fullText.startsWith(prevFullTextRef.current)) {
|
|
42
|
+
setDisplayedLength(0);
|
|
43
|
+
}
|
|
44
|
+
prevFullTextRef.current = fullText;
|
|
45
|
+
}, [fullText]);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!isStreaming && prevStreamingRef.current) {
|
|
49
|
+
setDisplayedLength(fullText.length);
|
|
50
|
+
if (animationRef.current) {
|
|
51
|
+
cancelAnimationFrame(animationRef.current);
|
|
52
|
+
animationRef.current = null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
prevStreamingRef.current = isStreaming;
|
|
56
|
+
}, [isStreaming, fullText.length]);
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (!isStreaming) return;
|
|
60
|
+
|
|
61
|
+
let lastTime = performance.now();
|
|
62
|
+
|
|
63
|
+
const animate = (currentTime: number) => {
|
|
64
|
+
const elapsed = currentTime - lastTime;
|
|
65
|
+
|
|
66
|
+
if (elapsed >= TYPEWRITER_SPEED_MS) {
|
|
67
|
+
setDisplayedLength(prev => {
|
|
68
|
+
const targetLength = fullText.length;
|
|
69
|
+
if (prev >= targetLength) return prev;
|
|
70
|
+
const behind = targetLength - prev;
|
|
71
|
+
const speed = behind > 20 ? Math.min(behind, 5) : TYPEWRITER_BATCH_SIZE;
|
|
72
|
+
return Math.min(prev + speed, targetLength);
|
|
73
|
+
});
|
|
74
|
+
lastTime = currentTime;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
animationRef.current = requestAnimationFrame(animate);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
animationRef.current = requestAnimationFrame(animate);
|
|
81
|
+
|
|
82
|
+
return () => {
|
|
83
|
+
if (animationRef.current) {
|
|
84
|
+
cancelAnimationFrame(animationRef.current);
|
|
85
|
+
animationRef.current = null;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}, [isStreaming, fullText]);
|
|
89
|
+
|
|
90
|
+
return fullText.slice(0, displayedLength) || (isStreaming ? '' : fullText);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function generateSessionId(articleContext?: ArticleChatContext): string {
|
|
94
|
+
if (articleContext?.slug) return `article:${articleContext.slug}`;
|
|
95
|
+
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID();
|
|
96
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function getTextFromMessage(message: UIMessage): string {
|
|
100
|
+
const parts = message.parts ?? [];
|
|
101
|
+
return Array.isArray(parts)
|
|
102
|
+
? parts
|
|
103
|
+
.filter((p): p is { type: 'text'; text: string } => p.type === 'text')
|
|
104
|
+
.map(p => p.text)
|
|
105
|
+
.join('')
|
|
106
|
+
: '';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Quick Prompts ─────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
const QUICK_PROMPTS_ZH = [
|
|
112
|
+
t('ai.prompt.techStack', 'zh'),
|
|
113
|
+
t('ai.prompt.recommend', 'zh'),
|
|
114
|
+
t('ai.prompt.build', 'zh'),
|
|
115
|
+
];
|
|
116
|
+
const QUICK_PROMPTS_EN = [
|
|
117
|
+
t('ai.prompt.techStack', 'en'),
|
|
118
|
+
t('ai.prompt.recommend', 'en'),
|
|
119
|
+
t('ai.prompt.build', 'en'),
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
function getQuickPrompts(lang: string, articleContext?: ArticleChatContext): string[] {
|
|
123
|
+
const l = getLang(lang);
|
|
124
|
+
if (!articleContext) return l === 'zh' ? QUICK_PROMPTS_ZH : QUICK_PROMPTS_EN;
|
|
125
|
+
|
|
126
|
+
if (l === 'zh') {
|
|
127
|
+
const prompts = [t('ai.prompt.summarize', 'zh', { title: articleContext.title })];
|
|
128
|
+
if (articleContext.keyPoints?.length) {
|
|
129
|
+
prompts.push(t('ai.prompt.explain', 'zh', { point: articleContext.keyPoints[0] }));
|
|
130
|
+
}
|
|
131
|
+
prompts.push(t('ai.prompt.related', 'zh'));
|
|
132
|
+
return prompts;
|
|
133
|
+
}
|
|
134
|
+
const prompts = [t('ai.prompt.summarize', 'en', { title: articleContext.title })];
|
|
135
|
+
if (articleContext.keyPoints?.length) {
|
|
136
|
+
prompts.push(t('ai.prompt.explain', 'en', { point: articleContext.keyPoints[0] }));
|
|
137
|
+
}
|
|
138
|
+
prompts.push(t('ai.prompt.related', 'en'));
|
|
139
|
+
return prompts;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── Welcome Message ───────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
function buildWelcomeMessage(config: AIChatConfig, articleContext?: ArticleChatContext): UIMessage {
|
|
145
|
+
const lang = getLang(config.lang);
|
|
146
|
+
|
|
147
|
+
let text: string;
|
|
148
|
+
if (articleContext) {
|
|
149
|
+
text = config.welcomeMessage ?? t('ai.welcome.reading', lang, { title: articleContext.title });
|
|
150
|
+
} else {
|
|
151
|
+
text = config.welcomeMessage ?? t('ai.welcome.canHelp', lang);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
id: 'welcome',
|
|
156
|
+
role: 'assistant' as const,
|
|
157
|
+
parts: [{ type: 'text' as const, text }],
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Error Helpers ─────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
function parseErrorMessage(error: Error, lang: string = 'zh'): string {
|
|
164
|
+
const l = getLang(lang);
|
|
165
|
+
try {
|
|
166
|
+
const parsed = JSON.parse(error.message);
|
|
167
|
+
if (parsed?.error) return parsed.error;
|
|
168
|
+
} catch { /* not JSON */ }
|
|
169
|
+
const msg = error.message;
|
|
170
|
+
if (msg.includes('Failed to fetch') || msg.includes('NetworkError')) return t('ai.error.network', l);
|
|
171
|
+
if (msg.includes('aborted')) return t('ai.error.aborted', l);
|
|
172
|
+
if (msg.includes('429') || msg.includes('rate')) return t('ai.error.rateLimit', l);
|
|
173
|
+
if (msg.includes('503') || msg.includes('unavailable')) return t('ai.error.unavailable', l);
|
|
174
|
+
return t('ai.error.generic', l);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function isRetryable(error: Error): boolean {
|
|
178
|
+
try {
|
|
179
|
+
const parsed = JSON.parse(error.message);
|
|
180
|
+
if (typeof parsed?.retryable === 'boolean') return parsed.retryable;
|
|
181
|
+
} catch { /* not JSON */ }
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── Rich Text Rendering ───────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
type InlinePart =
|
|
188
|
+
| { type: 'text'; text: string }
|
|
189
|
+
| { type: 'link'; label: string; url: string }
|
|
190
|
+
| { type: 'bold'; text: string }
|
|
191
|
+
| { type: 'code'; text: string };
|
|
192
|
+
|
|
193
|
+
function parseInlineMarkdown(text: string): InlinePart[] {
|
|
194
|
+
const parts: InlinePart[] = [];
|
|
195
|
+
const re = /\[([^\]]+)\]\(([^)]+)\)|\*\*([^*]+)\*\*|`([^`]+)`/g;
|
|
196
|
+
let lastIndex = 0;
|
|
197
|
+
let match: RegExpExecArray | null;
|
|
198
|
+
while ((match = re.exec(text)) !== null) {
|
|
199
|
+
if (match.index > lastIndex) {
|
|
200
|
+
parts.push({ type: 'text', text: text.slice(lastIndex, match.index) });
|
|
201
|
+
}
|
|
202
|
+
if (match[1] && match[2]) parts.push({ type: 'link', label: match[1], url: match[2] });
|
|
203
|
+
else if (match[3]) parts.push({ type: 'bold', text: match[3] });
|
|
204
|
+
else if (match[4]) parts.push({ type: 'code', text: match[4] });
|
|
205
|
+
lastIndex = match.index + match[0].length;
|
|
206
|
+
}
|
|
207
|
+
if (lastIndex < text.length) parts.push({ type: 'text', text: text.slice(lastIndex) });
|
|
208
|
+
return parts;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function InlineRichText({ text }: { text: string }) {
|
|
212
|
+
const parts = useMemo(() => parseInlineMarkdown(text), [text]);
|
|
213
|
+
return (
|
|
214
|
+
<span>
|
|
215
|
+
{parts.map((p, i) => {
|
|
216
|
+
if (p.type === 'link') {
|
|
217
|
+
const isExternal = p.url.startsWith('http');
|
|
218
|
+
return (
|
|
219
|
+
<a key={i} href={p.url}
|
|
220
|
+
class="inline-flex items-center gap-0.5 font-medium text-accent underline decoration-accent/30 underline-offset-2 transition-colors hover:decoration-accent"
|
|
221
|
+
target={isExternal ? '_blank' : undefined} rel={isExternal ? 'noopener noreferrer' : undefined}>
|
|
222
|
+
{p.label}
|
|
223
|
+
{isExternal && <ExternalLinkIcon />}
|
|
224
|
+
</a>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
if (p.type === 'bold') return <strong key={i} class="font-semibold">{p.text}</strong>;
|
|
228
|
+
if (p.type === 'code') return <code key={i} class="rounded bg-muted/60 px-1 py-0.5 text-[13px] font-mono">{p.text}</code>;
|
|
229
|
+
return <span key={i}>{p.text}</span>;
|
|
230
|
+
})}
|
|
231
|
+
</span>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ── Block-level Markdown Rendering ────────────────────────────
|
|
236
|
+
|
|
237
|
+
interface BlockNode {
|
|
238
|
+
type: 'paragraph' | 'code-block' | 'blockquote' | 'list';
|
|
239
|
+
content: string;
|
|
240
|
+
lang?: string;
|
|
241
|
+
ordered?: boolean;
|
|
242
|
+
items?: string[];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function parseBlocks(text: string): BlockNode[] {
|
|
246
|
+
const lines = text.split('\n');
|
|
247
|
+
const blocks: BlockNode[] = [];
|
|
248
|
+
let i = 0;
|
|
249
|
+
|
|
250
|
+
while (i < lines.length) {
|
|
251
|
+
const line = lines[i];
|
|
252
|
+
|
|
253
|
+
// Fenced code block
|
|
254
|
+
if (line.startsWith('```')) {
|
|
255
|
+
const lang = line.slice(3).trim();
|
|
256
|
+
const codeLines: string[] = [];
|
|
257
|
+
i++;
|
|
258
|
+
while (i < lines.length && !lines[i].startsWith('```')) {
|
|
259
|
+
codeLines.push(lines[i]);
|
|
260
|
+
i++;
|
|
261
|
+
}
|
|
262
|
+
if (i < lines.length) i++; // skip closing ```
|
|
263
|
+
blocks.push({ type: 'code-block', content: codeLines.join('\n'), lang: lang || undefined });
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Blockquote (consecutive > lines)
|
|
268
|
+
if (line.startsWith('> ') || line === '>') {
|
|
269
|
+
const quoteLines: string[] = [];
|
|
270
|
+
while (i < lines.length && (lines[i].startsWith('> ') || lines[i] === '>')) {
|
|
271
|
+
quoteLines.push(lines[i].replace(/^>\s?/, ''));
|
|
272
|
+
i++;
|
|
273
|
+
}
|
|
274
|
+
blocks.push({ type: 'blockquote', content: quoteLines.join('\n') });
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Unordered list (- or *)
|
|
279
|
+
if (/^[-*]\s/.test(line)) {
|
|
280
|
+
const items: string[] = [];
|
|
281
|
+
while (i < lines.length && /^[-*]\s/.test(lines[i])) {
|
|
282
|
+
items.push(lines[i].replace(/^[-*]\s/, ''));
|
|
283
|
+
i++;
|
|
284
|
+
}
|
|
285
|
+
blocks.push({ type: 'list', content: '', ordered: false, items });
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Ordered list (1. 2. etc)
|
|
290
|
+
if (/^\d+\.\s/.test(line)) {
|
|
291
|
+
const items: string[] = [];
|
|
292
|
+
while (i < lines.length && /^\d+\.\s/.test(lines[i])) {
|
|
293
|
+
items.push(lines[i].replace(/^\d+\.\s/, ''));
|
|
294
|
+
i++;
|
|
295
|
+
}
|
|
296
|
+
blocks.push({ type: 'list', content: '', ordered: true, items });
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Empty line - skip
|
|
301
|
+
if (!line.trim()) {
|
|
302
|
+
i++;
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Regular paragraph (collect consecutive non-special lines)
|
|
307
|
+
const paraLines: string[] = [];
|
|
308
|
+
while (i < lines.length && lines[i].trim() && !lines[i].startsWith('```') &&
|
|
309
|
+
!lines[i].startsWith('> ') && lines[i] !== '>' &&
|
|
310
|
+
!/^[-*]\s/.test(lines[i]) && !/^\d+\.\s/.test(lines[i])) {
|
|
311
|
+
paraLines.push(lines[i]);
|
|
312
|
+
i++;
|
|
313
|
+
}
|
|
314
|
+
if (paraLines.length) {
|
|
315
|
+
blocks.push({ type: 'paragraph', content: paraLines.join('\n') });
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return blocks;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function RichText({ text }: { text: string }) {
|
|
323
|
+
const blocks = useMemo(() => parseBlocks(text), [text]);
|
|
324
|
+
return (
|
|
325
|
+
<div class="space-y-2">
|
|
326
|
+
{blocks.map((block, i) => {
|
|
327
|
+
switch (block.type) {
|
|
328
|
+
case 'code-block':
|
|
329
|
+
return (
|
|
330
|
+
<pre key={i} class="overflow-x-auto rounded-md bg-muted/60 px-3 py-2 text-[12px] leading-relaxed font-mono">
|
|
331
|
+
<code>{block.content}</code>
|
|
332
|
+
</pre>
|
|
333
|
+
);
|
|
334
|
+
case 'blockquote':
|
|
335
|
+
return (
|
|
336
|
+
<blockquote key={i} class="border-l-2 border-accent/40 pl-3 text-foreground-soft italic">
|
|
337
|
+
<InlineRichText text={block.content} />
|
|
338
|
+
</blockquote>
|
|
339
|
+
);
|
|
340
|
+
case 'list':
|
|
341
|
+
if (block.ordered) {
|
|
342
|
+
return (
|
|
343
|
+
<ol key={i} class="list-decimal space-y-0.5 pl-5">
|
|
344
|
+
{block.items?.map((item, j) => (
|
|
345
|
+
<li key={j}><InlineRichText text={item} /></li>
|
|
346
|
+
))}
|
|
347
|
+
</ol>
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
return (
|
|
351
|
+
<ul key={i} class="list-disc space-y-0.5 pl-5">
|
|
352
|
+
{block.items?.map((item, j) => (
|
|
353
|
+
<li key={j}><InlineRichText text={item} /></li>
|
|
354
|
+
))}
|
|
355
|
+
</ul>
|
|
356
|
+
);
|
|
357
|
+
case 'paragraph':
|
|
358
|
+
default:
|
|
359
|
+
return (
|
|
360
|
+
<p key={i} class="whitespace-pre-wrap"><InlineRichText text={block.content} /></p>
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
})}
|
|
364
|
+
</div>
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ── Reasoning Collapse Component ──────────────────────────────
|
|
369
|
+
|
|
370
|
+
function ReasoningBlock({ text, isStreaming, lang = 'zh' }: { text: string; isStreaming?: boolean; lang?: string }) {
|
|
371
|
+
const isEmpty = text.length === 0;
|
|
372
|
+
const l = getLang(lang);
|
|
373
|
+
return (
|
|
374
|
+
<details class="group rounded-lg border border-border/50 bg-muted/30 overflow-hidden" open={isStreaming || !isEmpty}>
|
|
375
|
+
<summary class="flex cursor-pointer items-center gap-1.5 px-2.5 py-1.5 text-[11px] font-medium text-foreground-soft transition-colors hover:bg-muted/50 hover:text-foreground">
|
|
376
|
+
<svg class="size-3.5 transition-transform group-open:rotate-90" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
377
|
+
<path d="m9 18 6-6-6-6"/>
|
|
378
|
+
</svg>
|
|
379
|
+
<span class="flex items-center gap-1">
|
|
380
|
+
{isStreaming ? (
|
|
381
|
+
<svg class="size-3 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
382
|
+
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
|
383
|
+
</svg>
|
|
384
|
+
) : (
|
|
385
|
+
<svg class="size-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
386
|
+
<circle cx="12" cy="12" r="10"/>
|
|
387
|
+
<path d="M12 16v-4"/>
|
|
388
|
+
<path d="M12 8h.01"/>
|
|
389
|
+
</svg>
|
|
390
|
+
)}
|
|
391
|
+
{isStreaming && isEmpty ? t('ai.reasoning.thinking', l) : isStreaming ? t('ai.reasoning.thinking', l).replace('...', '') : t('ai.reasoning.viewReasoning', l)}
|
|
392
|
+
</span>
|
|
393
|
+
</summary>
|
|
394
|
+
<div class="border-t border-border/30 bg-background/50 px-2.5 py-2">
|
|
395
|
+
{isEmpty && isStreaming ? (
|
|
396
|
+
<div class="flex items-center gap-2 text-[11px] text-foreground-soft">
|
|
397
|
+
<span class="inline-flex gap-1">
|
|
398
|
+
<span class="size-1.5 animate-bounce rounded-full bg-foreground-soft [animation-delay:0ms]" />
|
|
399
|
+
<span class="size-1.5 animate-bounce rounded-full bg-foreground-soft [animation-delay:150ms]" />
|
|
400
|
+
<span class="size-1.5 animate-bounce rounded-full bg-foreground-soft [animation-delay:300ms]" />
|
|
401
|
+
</span>
|
|
402
|
+
<span>{t('ai.reasoning.waiting', l)}</span>
|
|
403
|
+
</div>
|
|
404
|
+
) : (
|
|
405
|
+
<pre class="whitespace-pre-wrap text-[11px] leading-relaxed text-foreground-soft font-mono">{text}</pre>
|
|
406
|
+
)}
|
|
407
|
+
</div>
|
|
408
|
+
</details>
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ── Message Rendering (parts-based) ──────────────────────────
|
|
413
|
+
|
|
414
|
+
type ReasoningPart = { type: 'reasoning'; text: string; state?: 'streaming' | 'done' };
|
|
415
|
+
|
|
416
|
+
function AssistantMessage({ message, isStreaming, lang = 'zh' }: { message: UIMessage; isStreaming?: boolean; lang?: string }) {
|
|
417
|
+
const fullText = getTextFromMessage(message);
|
|
418
|
+
const displayedText = useTypewriter(fullText, isStreaming ?? false);
|
|
419
|
+
|
|
420
|
+
const reasoningParts = message.parts.filter((p): p is ReasoningPart => p.type === 'reasoning');
|
|
421
|
+
const reasoningFullText = reasoningParts.map(p => p.text).join('');
|
|
422
|
+
const reasoningDisplayed = useTypewriter(reasoningFullText, isStreaming ?? false);
|
|
423
|
+
const hasReasoning = reasoningFullText.length > 0;
|
|
424
|
+
|
|
425
|
+
const isWaitingForContent = isStreaming && !fullText && !reasoningFullText;
|
|
426
|
+
|
|
427
|
+
const sources = message.parts.filter(p => p.type === 'source-url' || p.type === 'source-document');
|
|
428
|
+
|
|
429
|
+
if (isWaitingForContent) {
|
|
430
|
+
return (
|
|
431
|
+
<div class="space-y-1.5">
|
|
432
|
+
<ReasoningBlock text="" isStreaming={true} lang={lang} />
|
|
433
|
+
</div>
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (!fullText && !hasReasoning) return null;
|
|
438
|
+
|
|
439
|
+
return (
|
|
440
|
+
<div class="space-y-1.5">
|
|
441
|
+
{hasReasoning && (
|
|
442
|
+
<ReasoningBlock text={reasoningDisplayed} isStreaming={isStreaming} lang={lang} />
|
|
443
|
+
)}
|
|
444
|
+
{displayedText && <RichText text={displayedText} />}
|
|
445
|
+
{!isStreaming && sources.length > 0 && (
|
|
446
|
+
<div class="mt-2 flex flex-wrap gap-1.5">
|
|
447
|
+
{sources.map((s, i) => {
|
|
448
|
+
const part = s as { url?: string; title?: string };
|
|
449
|
+
return (
|
|
450
|
+
<a key={i} href={part.url ?? '#'}
|
|
451
|
+
class="inline-flex items-center gap-1 rounded-md border border-border bg-muted/30 px-2 py-0.5 text-[11px] text-foreground-soft transition-colors hover:border-accent/40 hover:text-foreground"
|
|
452
|
+
target="_blank" rel="noopener noreferrer">
|
|
453
|
+
<svg class="size-2.5 opacity-50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
454
|
+
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
|
455
|
+
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
|
456
|
+
</svg>
|
|
457
|
+
{part.title ?? 'Source'}
|
|
458
|
+
</a>
|
|
459
|
+
);
|
|
460
|
+
})}
|
|
461
|
+
</div>
|
|
462
|
+
)}
|
|
463
|
+
</div>
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ── Small Components ──────────────────────────────────────────
|
|
468
|
+
|
|
469
|
+
function ExternalLinkIcon() {
|
|
470
|
+
return (
|
|
471
|
+
<svg class="inline-block size-3 shrink-0 opacity-50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
472
|
+
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /><polyline points="15 3 21 3 21 9" /><line x1="10" y1="14" x2="21" y2="3" />
|
|
473
|
+
</svg>
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function BotAvatar() {
|
|
478
|
+
return (
|
|
479
|
+
<div class="flex size-5.5 shrink-0 items-center justify-center rounded-full bg-accent/15 mt-0.5">
|
|
480
|
+
<BotIcon class="size-3 text-accent" />
|
|
481
|
+
</div>
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function BotIcon({ class: cls }: { class?: string }) {
|
|
486
|
+
return (
|
|
487
|
+
<svg class={cls} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
488
|
+
<path d="M12 8V4H8" /><rect width="16" height="12" x="4" y="8" rx="2" />
|
|
489
|
+
<path d="M2 14h2" /><path d="M20 14h2" /><path d="M15 13v2" /><path d="M9 13v2" />
|
|
490
|
+
</svg>
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function TypingDots({ statusMessage }: { statusMessage?: string }) {
|
|
495
|
+
return (
|
|
496
|
+
<div class="flex items-center gap-2">
|
|
497
|
+
<span class="inline-flex gap-1">
|
|
498
|
+
<span class="size-1.5 animate-bounce rounded-full bg-foreground-soft [animation-delay:0ms]" />
|
|
499
|
+
<span class="size-1.5 animate-bounce rounded-full bg-foreground-soft [animation-delay:150ms]" />
|
|
500
|
+
<span class="size-1.5 animate-bounce rounded-full bg-foreground-soft [animation-delay:300ms]" />
|
|
501
|
+
</span>
|
|
502
|
+
{statusMessage && (
|
|
503
|
+
<span class="text-[11px] text-foreground-soft">{statusMessage}</span>
|
|
504
|
+
)}
|
|
505
|
+
</div>
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// ── Mock Mode Chat ────────────────────────────────────────────
|
|
510
|
+
|
|
511
|
+
interface MockMessage {
|
|
512
|
+
id: string;
|
|
513
|
+
role: 'user' | 'assistant';
|
|
514
|
+
text: string;
|
|
515
|
+
streaming?: boolean;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function useMockChat(lang: string) {
|
|
519
|
+
const [messages, setMessages] = useState<MockMessage[]>([]);
|
|
520
|
+
const [isStreaming, setIsStreaming] = useState(false);
|
|
521
|
+
|
|
522
|
+
const sendMessage = useCallback(async (text: string) => {
|
|
523
|
+
const userMsg: MockMessage = { id: `u-${Date.now()}`, role: 'user', text };
|
|
524
|
+
const assistantId = `a-${Date.now()}`;
|
|
525
|
+
const assistantMsg: MockMessage = { id: assistantId, role: 'assistant', text: '', streaming: true };
|
|
526
|
+
setMessages(prev => [...prev, userMsg, assistantMsg]);
|
|
527
|
+
setIsStreaming(true);
|
|
528
|
+
|
|
529
|
+
const stream = createMockStream(getMockResponse(text, lang));
|
|
530
|
+
const reader = stream.getReader();
|
|
531
|
+
let accumulated = '';
|
|
532
|
+
try {
|
|
533
|
+
while (true) {
|
|
534
|
+
const { done, value } = await reader.read();
|
|
535
|
+
if (done) break;
|
|
536
|
+
accumulated += value;
|
|
537
|
+
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, text: accumulated } : m));
|
|
538
|
+
}
|
|
539
|
+
} finally {
|
|
540
|
+
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, streaming: false } : m));
|
|
541
|
+
setIsStreaming(false);
|
|
542
|
+
}
|
|
543
|
+
}, [lang]);
|
|
544
|
+
|
|
545
|
+
const clear = useCallback(() => setMessages([]), []);
|
|
546
|
+
|
|
547
|
+
return { messages, isStreaming, sendMessage, clear };
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// ── Main ChatPanel ────────────────────────────────────────────
|
|
551
|
+
|
|
552
|
+
export function ChatPanel({ open, onClose, config, articleContext }: ChatPanelProps) {
|
|
553
|
+
const isMockMode = config.mockMode || !config.apiEndpoint;
|
|
554
|
+
const lang = getLang(config.lang);
|
|
555
|
+
const placeholder = config.placeholder ?? t('ai.placeholder', lang);
|
|
556
|
+
|
|
557
|
+
const sessionId = useMemo(() => generateSessionId(articleContext), [articleContext]);
|
|
558
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
559
|
+
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
560
|
+
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
561
|
+
const lastSendRef = useRef(0);
|
|
562
|
+
const [inputValue, setInputValue] = useState('');
|
|
563
|
+
const [cooldown, setCooldown] = useState(false);
|
|
564
|
+
const [statusMessage, setStatusMessage] = useState<string>();
|
|
565
|
+
|
|
566
|
+
const quickPrompts = useMemo(() => getQuickPrompts(lang, articleContext), [lang, articleContext]);
|
|
567
|
+
const welcomeMessage = useMemo(() => buildWelcomeMessage(config, articleContext), [config, articleContext]);
|
|
568
|
+
|
|
569
|
+
// ── Live Mode (useChat) ─────────────────────────────────────
|
|
570
|
+
|
|
571
|
+
const transport = useMemo(() => new DefaultChatTransport({
|
|
572
|
+
api: config.apiEndpoint ?? '/api/chat',
|
|
573
|
+
prepareSendMessagesRequest: ({ id, messages: msgs }) => ({
|
|
574
|
+
headers: { 'x-session-id': sessionId },
|
|
575
|
+
body: {
|
|
576
|
+
id, messages: msgs,
|
|
577
|
+
lang,
|
|
578
|
+
context: articleContext
|
|
579
|
+
? { scope: 'article' as const, article: articleContext }
|
|
580
|
+
: { scope: 'global' as const },
|
|
581
|
+
},
|
|
582
|
+
}),
|
|
583
|
+
}), [config.apiEndpoint, sessionId, articleContext, lang]);
|
|
584
|
+
|
|
585
|
+
const {
|
|
586
|
+
messages: liveMessages,
|
|
587
|
+
sendMessage: liveSendMessage,
|
|
588
|
+
setMessages: liveSetMessages,
|
|
589
|
+
regenerate,
|
|
590
|
+
status: liveStatus,
|
|
591
|
+
error: liveError,
|
|
592
|
+
} = useChat({
|
|
593
|
+
transport,
|
|
594
|
+
onError: (err) => {
|
|
595
|
+
console.error('[ChatPanel] Chat error:', err.message);
|
|
596
|
+
},
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
useEffect(() => {
|
|
600
|
+
if (liveMessages.length === 0) {
|
|
601
|
+
liveSetMessages([welcomeMessage]);
|
|
602
|
+
}
|
|
603
|
+
}, []);
|
|
604
|
+
|
|
605
|
+
// ── Mock Mode ───────────────────────────────────────────────
|
|
606
|
+
|
|
607
|
+
const mockChat = useMockChat(lang);
|
|
608
|
+
|
|
609
|
+
// ── Unified State ───────────────────────────────────────────
|
|
610
|
+
|
|
611
|
+
const isStreaming = isMockMode ? mockChat.isStreaming : (liveStatus === 'streaming' || liveStatus === 'submitted');
|
|
612
|
+
const error = isMockMode ? null : liveError;
|
|
613
|
+
|
|
614
|
+
useEffect(() => {
|
|
615
|
+
if (isMockMode || !liveMessages.length) return;
|
|
616
|
+
for (let i = liveMessages.length - 1; i >= 0; i--) {
|
|
617
|
+
const msg = liveMessages[i];
|
|
618
|
+
if (msg.role === 'assistant' && isChatStatusData(msg.metadata)) {
|
|
619
|
+
setStatusMessage((msg.metadata as ChatStatusData).message);
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
setStatusMessage(undefined);
|
|
624
|
+
}, [liveMessages, isMockMode]);
|
|
625
|
+
|
|
626
|
+
// ── Scroll & Focus ──────────────────────────────────────────
|
|
627
|
+
|
|
628
|
+
useEffect(() => {
|
|
629
|
+
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
630
|
+
}, [liveMessages, mockChat.messages]);
|
|
631
|
+
|
|
632
|
+
useEffect(() => {
|
|
633
|
+
if (open) setTimeout(() => inputRef.current?.focus(), 150);
|
|
634
|
+
}, [open]);
|
|
635
|
+
|
|
636
|
+
const autoResize = useCallback(() => {
|
|
637
|
+
const el = inputRef.current;
|
|
638
|
+
if (!el) return;
|
|
639
|
+
el.style.height = 'auto';
|
|
640
|
+
el.style.height = Math.min(el.scrollHeight, 96) + 'px';
|
|
641
|
+
}, []);
|
|
642
|
+
|
|
643
|
+
// ── Send Logic ──────────────────────────────────────────────
|
|
644
|
+
|
|
645
|
+
const doSend = useCallback((text: string) => {
|
|
646
|
+
const trimmed = text.trim();
|
|
647
|
+
if (!trimmed || isStreaming || cooldown) return;
|
|
648
|
+
const now = Date.now();
|
|
649
|
+
if (now - lastSendRef.current < MIN_SEND_INTERVAL_MS) return;
|
|
650
|
+
lastSendRef.current = now;
|
|
651
|
+
setCooldown(true);
|
|
652
|
+
setTimeout(() => setCooldown(false), MIN_SEND_INTERVAL_MS);
|
|
653
|
+
setInputValue('');
|
|
654
|
+
if (inputRef.current) inputRef.current.style.height = 'auto';
|
|
655
|
+
|
|
656
|
+
if (isMockMode) {
|
|
657
|
+
mockChat.sendMessage(trimmed);
|
|
658
|
+
} else {
|
|
659
|
+
liveSendMessage({ text: trimmed });
|
|
660
|
+
}
|
|
661
|
+
}, [isStreaming, cooldown, isMockMode, mockChat, liveSendMessage]);
|
|
662
|
+
|
|
663
|
+
const handleSend = useCallback(() => doSend(inputValue), [doSend, inputValue]);
|
|
664
|
+
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
|
665
|
+
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); }
|
|
666
|
+
}, [handleSend]);
|
|
667
|
+
|
|
668
|
+
const handleClear = useCallback(() => {
|
|
669
|
+
if (isMockMode) {
|
|
670
|
+
mockChat.clear();
|
|
671
|
+
} else {
|
|
672
|
+
liveSetMessages([welcomeMessage]);
|
|
673
|
+
}
|
|
674
|
+
setInputValue('');
|
|
675
|
+
setStatusMessage(undefined);
|
|
676
|
+
}, [isMockMode, mockChat, liveSetMessages, welcomeMessage]);
|
|
677
|
+
|
|
678
|
+
if (!open) return null;
|
|
679
|
+
|
|
680
|
+
// ── Render Messages ─────────────────────────────────────────
|
|
681
|
+
|
|
682
|
+
const renderMockMessages = () => (
|
|
683
|
+
<>
|
|
684
|
+
{/* Welcome */}
|
|
685
|
+
{mockChat.messages.length === 0 && (
|
|
686
|
+
<div class="space-y-3">
|
|
687
|
+
<div class="flex items-start gap-2.5">
|
|
688
|
+
<BotAvatar />
|
|
689
|
+
<p class="min-w-0 flex-1 pt-0.5 text-[13px] leading-relaxed text-foreground">
|
|
690
|
+
{getTextFromMessage(welcomeMessage)}
|
|
691
|
+
</p>
|
|
692
|
+
</div>
|
|
693
|
+
<div class="flex flex-wrap gap-1.5 pl-8">
|
|
694
|
+
{quickPrompts.map(q => (
|
|
695
|
+
<button key={q} type="button" onClick={() => doSend(q)}
|
|
696
|
+
class="rounded-lg border border-border bg-muted/30 px-2.5 py-1 text-[12px] text-foreground-soft transition-colors hover:border-accent/40 hover:bg-accent/10 hover:text-foreground"
|
|
697
|
+
>{q}</button>
|
|
698
|
+
))}
|
|
699
|
+
</div>
|
|
700
|
+
</div>
|
|
701
|
+
)}
|
|
702
|
+
{mockChat.messages.map(msg => (
|
|
703
|
+
<div key={msg.id} class={msg.role === 'user' ? 'flex justify-end' : 'flex items-start gap-2.5'}>
|
|
704
|
+
{msg.role === 'assistant' && <BotAvatar />}
|
|
705
|
+
<div class={msg.role === 'user'
|
|
706
|
+
? 'max-w-[82%] rounded-2xl rounded-br-md bg-accent px-3 py-2 text-[13px] leading-relaxed text-background'
|
|
707
|
+
: 'min-w-0 flex-1 pt-0.5 text-[13px] leading-relaxed text-foreground'}>
|
|
708
|
+
{msg.text
|
|
709
|
+
? msg.role === 'assistant'
|
|
710
|
+
? <RichText text={msg.text} />
|
|
711
|
+
: msg.text
|
|
712
|
+
: msg.streaming ? <TypingDots /> : null}
|
|
713
|
+
</div>
|
|
714
|
+
</div>
|
|
715
|
+
))}
|
|
716
|
+
</>
|
|
717
|
+
);
|
|
718
|
+
|
|
719
|
+
const renderLiveMessages = () => {
|
|
720
|
+
const showQuickPrompts = liveMessages.length <= 1;
|
|
721
|
+
const lastAssistantMsgId = [...liveMessages].reverse().find(m => m.role === 'assistant')?.id;
|
|
722
|
+
const lastMessage = liveMessages[liveMessages.length - 1];
|
|
723
|
+
const isWaitingForAssistant = isStreaming && lastMessage?.role === 'user';
|
|
724
|
+
|
|
725
|
+
return (
|
|
726
|
+
<>
|
|
727
|
+
{liveMessages.map(msg => {
|
|
728
|
+
if (msg.id === 'welcome' && showQuickPrompts) {
|
|
729
|
+
return (
|
|
730
|
+
<div key={msg.id} class="space-y-3">
|
|
731
|
+
<div class="flex items-start gap-2.5">
|
|
732
|
+
<BotAvatar />
|
|
733
|
+
<p class="min-w-0 flex-1 pt-0.5 text-[13px] leading-relaxed text-foreground">
|
|
734
|
+
{getTextFromMessage(msg)}
|
|
735
|
+
</p>
|
|
736
|
+
</div>
|
|
737
|
+
<div class="flex flex-wrap gap-1.5 pl-8">
|
|
738
|
+
{quickPrompts.map(q => (
|
|
739
|
+
<button key={q} type="button" onClick={() => doSend(q)}
|
|
740
|
+
class="rounded-lg border border-border bg-muted/30 px-2.5 py-1 text-[12px] text-foreground-soft transition-colors hover:border-accent/40 hover:bg-accent/10 hover:text-foreground"
|
|
741
|
+
>{q}</button>
|
|
742
|
+
))}
|
|
743
|
+
</div>
|
|
744
|
+
</div>
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const text = getTextFromMessage(msg);
|
|
749
|
+
const isAssistant = msg.role === 'assistant';
|
|
750
|
+
const isLastAssistantStreaming = isStreaming && msg.id === lastAssistantMsgId;
|
|
751
|
+
|
|
752
|
+
return (
|
|
753
|
+
<div key={msg.id} class={msg.role === 'user' ? 'flex justify-end' : 'flex items-start gap-2.5'}>
|
|
754
|
+
{isAssistant && <BotAvatar />}
|
|
755
|
+
<div class={msg.role === 'user'
|
|
756
|
+
? 'max-w-[82%] rounded-2xl rounded-br-md bg-accent px-3 py-2 text-[13px] leading-relaxed text-background'
|
|
757
|
+
: 'min-w-0 flex-1 pt-0.5 text-[13px] leading-relaxed text-foreground'}>
|
|
758
|
+
{isAssistant
|
|
759
|
+
? <AssistantMessage message={msg} isStreaming={isLastAssistantStreaming} lang={lang} />
|
|
760
|
+
: text}
|
|
761
|
+
</div>
|
|
762
|
+
</div>
|
|
763
|
+
);
|
|
764
|
+
})}
|
|
765
|
+
{isWaitingForAssistant && (
|
|
766
|
+
<div class="flex items-start gap-2.5">
|
|
767
|
+
<BotAvatar />
|
|
768
|
+
<div class="min-w-0 flex-1 pt-0.5">
|
|
769
|
+
<ReasoningBlock text="" isStreaming={true} lang={lang} />
|
|
770
|
+
</div>
|
|
771
|
+
</div>
|
|
772
|
+
)}
|
|
773
|
+
</>
|
|
774
|
+
);
|
|
775
|
+
};
|
|
776
|
+
|
|
777
|
+
return (
|
|
778
|
+
<div ref={panelRef} data-ai-chat-panel
|
|
779
|
+
class="fixed right-4 bottom-20 z-[90] flex w-[370px] max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-2xl border border-border bg-background shadow-2xl sm:right-6 sm:bottom-20"
|
|
780
|
+
style={{ height: 'min(520px, calc(100vh - 7rem))' }}>
|
|
781
|
+
|
|
782
|
+
{/* Header */}
|
|
783
|
+
<div class="flex shrink-0 items-center justify-between border-b border-border px-3.5 py-2.5">
|
|
784
|
+
<div class="flex items-center gap-2">
|
|
785
|
+
<div class="flex size-6 shrink-0 items-center justify-center rounded-full bg-accent/15">
|
|
786
|
+
<BotIcon class="size-3 text-accent" />
|
|
787
|
+
</div>
|
|
788
|
+
<div class="flex flex-col">
|
|
789
|
+
<span class="text-[13px] font-semibold text-foreground">{t('ai.assistantName', lang)}</span>
|
|
790
|
+
{articleContext && (
|
|
791
|
+
<span class="max-w-[180px] truncate text-[10px] text-foreground-soft">
|
|
792
|
+
{t('ai.header.reading', lang)}{articleContext.title}
|
|
793
|
+
</span>
|
|
794
|
+
)}
|
|
795
|
+
</div>
|
|
796
|
+
<span class={`rounded-full px-1.5 py-px text-[10px] font-medium ${
|
|
797
|
+
isMockMode ? 'bg-amber-500/15 text-amber-600 dark:text-amber-400' : 'bg-green-500/15 text-green-600 dark:text-green-400'
|
|
798
|
+
}`}>
|
|
799
|
+
{isMockMode ? t('ai.header.mode', lang) : t('ai.status.live', lang)}
|
|
800
|
+
</span>
|
|
801
|
+
</div>
|
|
802
|
+
<div class="flex items-center gap-0.5">
|
|
803
|
+
<button type="button" onClick={handleClear}
|
|
804
|
+
class="rounded-md p-1 text-foreground-soft transition-colors hover:bg-muted/60 hover:text-foreground"
|
|
805
|
+
title={t('ai.clear', lang)}>
|
|
806
|
+
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
807
|
+
<path d="M3 6h18" /><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" /><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
|
808
|
+
</svg>
|
|
809
|
+
</button>
|
|
810
|
+
<button type="button" onClick={onClose}
|
|
811
|
+
class="rounded-md p-1 text-foreground-soft transition-colors hover:bg-muted/60 hover:text-foreground"
|
|
812
|
+
title={t('ai.close', lang)}>
|
|
813
|
+
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
814
|
+
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
|
|
815
|
+
</svg>
|
|
816
|
+
</button>
|
|
817
|
+
</div>
|
|
818
|
+
</div>
|
|
819
|
+
|
|
820
|
+
{/* Messages */}
|
|
821
|
+
<div class="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3.5 py-3 [scrollbar-width:thin]">
|
|
822
|
+
<div class="space-y-4">
|
|
823
|
+
{isMockMode ? renderMockMessages() : renderLiveMessages()}
|
|
824
|
+
|
|
825
|
+
{error && (
|
|
826
|
+
<div class="flex items-start gap-2.5">
|
|
827
|
+
<BotAvatar />
|
|
828
|
+
<div class="flex flex-col gap-1 pt-0.5">
|
|
829
|
+
<p class="text-[13px] text-amber-600 dark:text-amber-400">{parseErrorMessage(error, lang)}</p>
|
|
830
|
+
{isRetryable(error) && (
|
|
831
|
+
<button type="button" onClick={() => regenerate()}
|
|
832
|
+
class="self-start rounded-md border border-amber-500/30 px-2 py-0.5 text-[11px] text-amber-600 transition-colors hover:bg-amber-500/10 dark:text-amber-400">
|
|
833
|
+
{t('ai.retry', lang)}
|
|
834
|
+
</button>
|
|
835
|
+
)}
|
|
836
|
+
</div>
|
|
837
|
+
</div>
|
|
838
|
+
)}
|
|
839
|
+
|
|
840
|
+
<div ref={messagesEndRef} />
|
|
841
|
+
</div>
|
|
842
|
+
</div>
|
|
843
|
+
|
|
844
|
+
{/* Input Area */}
|
|
845
|
+
<div class="shrink-0 border-t border-border px-3 pb-2.5 pt-2">
|
|
846
|
+
<div class="flex items-end gap-1.5 rounded-xl border border-border bg-muted/30 px-2.5 py-1.5 transition-colors focus-within:border-accent/40 focus-within:bg-background">
|
|
847
|
+
<textarea ref={inputRef} rows={1} value={inputValue}
|
|
848
|
+
onInput={(e) => { setInputValue((e.target as HTMLTextAreaElement).value); autoResize(); }}
|
|
849
|
+
onKeyDown={handleKeyDown} placeholder={placeholder} maxLength={500}
|
|
850
|
+
class="min-w-0 flex-1 resize-none bg-transparent py-0.5 text-[13px] leading-snug text-foreground outline-none placeholder:text-foreground-soft"
|
|
851
|
+
style={{ maxHeight: '96px' }} />
|
|
852
|
+
<button type="button" onClick={handleSend}
|
|
853
|
+
disabled={!inputValue.trim() || isStreaming || cooldown}
|
|
854
|
+
class="mb-0.5 flex size-6 shrink-0 items-center justify-center rounded-md bg-accent text-background transition-all hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-30">
|
|
855
|
+
{isStreaming ? (
|
|
856
|
+
<svg class="size-3 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
|
|
857
|
+
) : (
|
|
858
|
+
<svg class="size-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m22 2-7 20-4-9-9-4Z" /><path d="M22 2 11 13" /></svg>
|
|
859
|
+
)}
|
|
860
|
+
</button>
|
|
861
|
+
</div>
|
|
862
|
+
</div>
|
|
863
|
+
</div>
|
|
864
|
+
);
|
|
865
|
+
}
|