@alicloud/appflow-chat 0.0.3 → 0.0.4-alpha.10

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.
@@ -0,0 +1,227 @@
1
+ /**
2
+ * MessageAttachments - 消息附件展示组件
3
+ * 用于在消息气泡中展示上传的图片和文件
4
+ */
5
+
6
+ import React from 'react';
7
+ import styled from 'styled-components';
8
+ import { Image } from 'antd';
9
+ import {
10
+ FileWordOutlined,
11
+ FilePdfOutlined,
12
+ FileExcelOutlined,
13
+ FileTextOutlined,
14
+ FileOutlined,
15
+ } from '@ant-design/icons';
16
+
17
+ // ==================== 类型定义 ====================
18
+
19
+ export interface MessageAttachmentsProps {
20
+ /** 消息角色,影响卡片配色 */
21
+ role?: 'user' | 'bot';
22
+ /** 图片URL列表 */
23
+ images?: string[];
24
+ /** 文件列表 */
25
+ files?: { name: string; url: string }[];
26
+ }
27
+
28
+ // ==================== 样式组件 ====================
29
+
30
+ const AttachmentsArea = styled.div`
31
+ display: flex;
32
+ flex-direction: column;
33
+ gap: 8px;
34
+ margin-top: 4px;
35
+ `;
36
+
37
+ const ImageCard = styled.div<{ $role: 'user' | 'bot' }>`
38
+ background: ${props => props.$role === 'user' ? '#eef0ff' : '#e8f4fd'};
39
+ border-radius: 8px;
40
+ padding: 8px;
41
+ display: inline-flex;
42
+ flex-direction: column;
43
+ gap: 6px;
44
+ max-width: 180px;
45
+ color: #333;
46
+
47
+ .image-preview {
48
+ border-radius: 6px;
49
+ overflow: hidden;
50
+ cursor: pointer;
51
+
52
+ .ant-image {
53
+ display: block;
54
+ }
55
+
56
+ img {
57
+ max-width: 100%;
58
+ height: auto;
59
+ display: block;
60
+ }
61
+ }
62
+ `;
63
+
64
+ const ImagesRow = styled.div`
65
+ display: flex;
66
+ flex-wrap: wrap;
67
+ gap: 8px;
68
+ `;
69
+
70
+ const FileCard = styled.a<{ $role: 'user' | 'bot' }>`
71
+ display: flex;
72
+ align-items: center;
73
+ gap: 10px;
74
+ background: ${props => props.$role === 'user' ? '#eef0ff' : '#e8f4fd'};
75
+ border-radius: 8px;
76
+ padding: 10px 12px;
77
+ text-decoration: none;
78
+ color: #333;
79
+ transition: background 0.2s;
80
+ max-width: 100%;
81
+ box-sizing: border-box;
82
+
83
+ &:hover {
84
+ background: ${props => props.$role === 'user' ? '#e2e5ff' : '#d6ecf8'};
85
+ }
86
+
87
+ .file-icon {
88
+ font-size: 28px;
89
+ flex-shrink: 0;
90
+ color: #1677ff;
91
+ }
92
+
93
+ .file-info {
94
+ display: flex;
95
+ flex-direction: column;
96
+ gap: 2px;
97
+ min-width: 0;
98
+ }
99
+
100
+ .file-name {
101
+ font-size: 13px;
102
+ font-weight: 500;
103
+ overflow: hidden;
104
+ text-overflow: ellipsis;
105
+ white-space: nowrap;
106
+ color: #333;
107
+ }
108
+
109
+ .file-meta {
110
+ display: flex;
111
+ align-items: center;
112
+ gap: 6px;
113
+ }
114
+
115
+ .file-type {
116
+ font-size: 11px;
117
+ padding: 1px 4px;
118
+ border-radius: 3px;
119
+ background: rgba(0, 0, 0, 0.06);
120
+ color: #666;
121
+ text-transform: uppercase;
122
+ font-weight: 500;
123
+ }
124
+ `;
125
+
126
+ // ==================== 工具函数 ====================
127
+
128
+ /** 根据文件名获取文件扩展名 */
129
+ function getFileExtension(fileName: string): string {
130
+ const parts = fileName.split('.');
131
+ return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : '';
132
+ }
133
+
134
+ /** 根据文件扩展名获取对应的图标组件 */
135
+ function getFileIcon(ext: string): React.ReactNode {
136
+ switch (ext) {
137
+ case 'doc':
138
+ case 'docx':
139
+ return <FileWordOutlined />;
140
+ case 'pdf':
141
+ return <FilePdfOutlined />;
142
+ case 'xls':
143
+ case 'xlsx':
144
+ case 'csv':
145
+ return <FileExcelOutlined />;
146
+ case 'txt':
147
+ case 'md':
148
+ case 'json':
149
+ return <FileTextOutlined />;
150
+ default:
151
+ return <FileOutlined />;
152
+ }
153
+ }
154
+
155
+ // ==================== 组件实现 ====================
156
+
157
+ /**
158
+ * MessageAttachments - 消息附件展示组件
159
+ *
160
+ * 在消息气泡中展示上传的图片(缩略图 + 点击预览)和文件(图标 + 文件名 + 类型标签 + 点击下载)。
161
+ *
162
+ * @example
163
+ * ```tsx
164
+ * <MessageAttachments
165
+ * role="user"
166
+ * images={['https://example.com/image.png']}
167
+ * files={[{ name: '文档.docx', url: 'https://example.com/doc.docx' }]}
168
+ * />
169
+ * ```
170
+ */
171
+ export const MessageAttachments: React.FC<MessageAttachmentsProps> = ({
172
+ role = 'user',
173
+ images,
174
+ files,
175
+ }) => {
176
+ const hasImages = images && images.length > 0;
177
+ const hasFiles = files && files.length > 0;
178
+
179
+ if (!hasImages && !hasFiles) return null;
180
+
181
+ return (
182
+ <AttachmentsArea>
183
+ {/* 图片列表 */}
184
+ {hasImages && (
185
+ <ImagesRow>
186
+ {images.map((url, index) => (
187
+ <ImageCard key={index} $role={role}>
188
+ <div className="image-preview">
189
+ <Image
190
+ src={url}
191
+ width={160}
192
+ style={{ borderRadius: 6, objectFit: 'cover' }}
193
+ preview={{ mask: '预览' }}
194
+ />
195
+ </div>
196
+ </ImageCard>
197
+ ))}
198
+ </ImagesRow>
199
+ )}
200
+
201
+ {/* 文件列表 */}
202
+ {hasFiles && files.map((file, index) => {
203
+ const ext = getFileExtension(file.name);
204
+ return (
205
+ <FileCard
206
+ key={index}
207
+ className="appflow-file-card"
208
+ $role={role}
209
+ href={file.url}
210
+ target="_blank"
211
+ rel="noopener noreferrer"
212
+ >
213
+ <span className="file-icon">{getFileIcon(ext)}</span>
214
+ <div className="file-info">
215
+ <span className="file-name">{file.name}</span>
216
+ <div className="file-meta">
217
+ {ext && <span className="file-type">{ext}</span>}
218
+ </div>
219
+ </div>
220
+ </FileCard>
221
+ );
222
+ })}
223
+ </AttachmentsArea>
224
+ );
225
+ };
226
+
227
+ export default MessageAttachments;
@@ -7,6 +7,8 @@
7
7
  import React, { useEffect, useState, useCallback } from 'react';
8
8
  import styled from 'styled-components';
9
9
  import { Modal, Image, Space } from 'antd';
10
+ import { MessageAttachments } from './MessageAttachments';
11
+ import { AudioPlayer } from './AudioPlayer';
10
12
  import { loadEchartsScript } from '@/utils/loadEcharts';
11
13
  import { DocReferences, DocReferenceItem } from './DocReferences';
12
14
  import { WebSearchPanel } from './WebSearchPanel';
@@ -47,6 +49,12 @@ export interface MessageBubbleProps {
47
49
  status?: 'Running' | 'Success' | 'Error';
48
50
  /** 参考资料列表 */
49
51
  references?: DocReferenceItem[];
52
+ /** 图片URL列表(用户消息中上传的图片) */
53
+ images?: string[];
54
+ /** 文件列表(用户消息中上传的文件) */
55
+ files?: { name: string; url: string }[];
56
+ /** 语音消息URL */
57
+ audio?: string;
50
58
  /** 自定义类名 */
51
59
  className?: string;
52
60
  /** 自定义样式 */
@@ -80,11 +88,10 @@ const StyledContainer = styled.div<{ $role: 'user' | 'bot' }>`
80
88
  const StyledBubble = styled.div<{ $role: 'user' | 'bot' }>`
81
89
  padding: 12px 16px;
82
90
  border-radius: 12px;
83
- background: ${props => props.$role === 'user'
84
- ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
85
- : 'rgba(205, 208, 220, 0.15)'};
86
- color: ${props => props.$role === 'user' ? '#fff' : '#333'};
91
+ background: ${props => props.$role === 'user' ? '#e5effe' : 'rgba(205, 208, 220, 0.15)'};
92
+ color: '#333';
87
93
  word-break: break-word;
94
+ overflow: hidden;
88
95
 
89
96
  /* 样式隔离 */
90
97
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
@@ -178,7 +185,7 @@ const StyledBubble = styled.div<{ $role: 'user' | 'bot' }>`
178
185
  border-radius: 4px;
179
186
  }
180
187
 
181
- a {
188
+ a:not(.appflow-file-card) {
182
189
  color: ${props => props.$role === 'user' ? '#fff' : '#1890ff'};
183
190
  text-decoration: underline;
184
191
  }
@@ -261,6 +268,9 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
261
268
  role = 'bot',
262
269
  status = 'Success',
263
270
  references = [],
271
+ images,
272
+ files,
273
+ audio,
264
274
  className,
265
275
  style,
266
276
  onReferenceClick,
@@ -334,6 +344,9 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
334
344
  const handleReferenceClick = onReferenceClick || defaultReferenceClick;
335
345
  const handleWebSearchClick = onWebSearchClick || defaultWebSearchClick;
336
346
 
347
+ // 纯语音消息:跳过 StyledBubble 包裹,直接渲染 AudioPlayer
348
+ const isAudioOnly = !!(audio && !content);
349
+
337
350
  return (
338
351
  <>
339
352
  <StyledContainer
@@ -341,42 +354,55 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
341
354
  className={`appflow-sdk-message-bubble ${className || ''}`}
342
355
  style={style}
343
356
  >
344
- <StyledBubble $role={role}>
345
- {/* 使用核心组件渲染内容 */}
346
- <BubbleContent
347
- content={content}
348
- status={status}
349
- role={role}
350
- >
351
- {/* HumanVerify事件 - 人工审核表单 */}
352
- {eventType === 'humanVerify' && humanVerifyData && (
353
- <HumanVerify
354
- data={humanVerifyData}
355
- onSubmit={onHumanVerifySubmit}
356
- />
357
- )}
358
-
359
- {/* HistoryCard 事件 - 历史对话中的审核卡片 */}
360
- {eventType === 'historyCard' && historyCardData && (
361
- <HistoryCard
362
- data={historyCardData}
363
- />
364
- )}
365
-
366
- {/* 参考资料 */}
367
- {references && references.length > 0 && status === 'Success' && (
368
- <ReferencesContainer>
369
- <DocReferences
370
- items={references}
371
- status={status}
372
- onItemClick={handleReferenceClick}
373
- onWebSearchClick={handleWebSearchClick}
357
+ {isAudioOnly ? (
358
+ /* 纯语音消息:直接渲染播放器,无气泡包裹 */
359
+ <AudioPlayer src={audio} role={role} />
360
+ ) : (
361
+ <StyledBubble $role={role}>
362
+ {/* 使用核心组件渲染内容 */}
363
+ <BubbleContent
364
+ content={content}
365
+ status={status}
366
+ role={role}
367
+ >
368
+ {/* HumanVerify事件 - 人工审核表单 */}
369
+ {eventType === 'humanVerify' && humanVerifyData && (
370
+ <HumanVerify
371
+ data={humanVerifyData}
372
+ onSubmit={onHumanVerifySubmit}
373
+ />
374
+ )}
375
+
376
+ {/* HistoryCard 事件 - 历史对话中的审核卡片 */}
377
+ {eventType === 'historyCard' && historyCardData && (
378
+ <HistoryCard
379
+ data={historyCardData}
374
380
  />
375
- </ReferencesContainer>
376
- )}
377
- </BubbleContent>
378
- {contextHolder}
379
- </StyledBubble>
381
+ )}
382
+
383
+ {/* 参考资料 */}
384
+ {references && references.length > 0 && status === 'Success' && (
385
+ <ReferencesContainer>
386
+ <DocReferences
387
+ items={references}
388
+ status={status}
389
+ onItemClick={handleReferenceClick}
390
+ onWebSearchClick={handleWebSearchClick}
391
+ />
392
+ </ReferencesContainer>
393
+ )}
394
+ </BubbleContent>
395
+
396
+ {/* 附件展示区域:图片和文件 */}
397
+ <MessageAttachments
398
+ role={role}
399
+ images={images}
400
+ files={files}
401
+ />
402
+
403
+ {contextHolder}
404
+ </StyledBubble>
405
+ )}
380
406
  </StyledContainer>
381
407
 
382
408
  {/* 默认的网页搜索抽屉(仅在用户未传入onWebSearchClick时使用) */}
package/src/index.ts CHANGED
@@ -20,6 +20,7 @@ export type {
20
20
  ChatStream,
21
21
  HistoryMessage,
22
22
  ChatSession,
23
+ UploadResult,
23
24
  } from './services/ChatService';
24
25
 
25
26
  // ==================== UI 组件导出(简化接口,包含默认交互) ====================
@@ -28,6 +29,7 @@ export { MessageBubble } from './components/MessageBubble';
28
29
  export { RichMessageBubble } from './components/RichMessageBubble';
29
30
  export { DocReferences } from './components/DocReferences';
30
31
  export { WebSearchPanel } from './components/WebSearchPanel';
32
+ export { ChatSender } from './components/ChatSender';
31
33
 
32
34
  // ==================== UI 组件类型导出 ====================
33
35
  export type { MarkdownRendererProps } from './components/MarkdownRenderer';
@@ -40,6 +42,7 @@ export type {
40
42
  export type { RichMessageBubbleProps } from './components/RichMessageBubble';
41
43
  export type { DocReferencesProps, DocReferenceItem } from './components/DocReferences';
42
44
  export type { WebSearchPanelProps, WebSearchItem } from './components/WebSearchPanel';
45
+ export type { ChatSenderProps, ChatSenderSubmitData, ChatAttachment } from './components/ChatSender';
43
46
 
44
47
  // ==================== Core 组件导出(纯展示组件,供高级定制) ====================
45
48
  export { BubbleContent } from './core/BubbleContent';
package/src/main.tsx ADDED
@@ -0,0 +1,9 @@
1
+ import React from 'react';
2
+ import ReactDOM from 'react-dom/client';
3
+ import App from './App';
4
+
5
+ ReactDOM.createRoot(document.getElementById('root')!).render(
6
+ <React.StrictMode>
7
+ <App />
8
+ </React.StrictMode>
9
+ );
@@ -53,9 +53,28 @@ export const MarkdownView: React.FC<MarkdownViewProps> = ({
53
53
  />
54
54
  );
55
55
  },
56
- code: ({ node, className, ...props }: any) => {
56
+ code: ({ node, inline, className, ...props }: any) => {
57
+ // 判断是否为行内代码
58
+ // 行内代码没有 className,且 inline 为 true
59
+ const isInline = inline || (!className && !node?.properties?.className);
60
+
61
+ if (isInline) {
62
+ // 行内代码 - 使用简单的 <code> 标签样式
63
+ return (
64
+ <code style={{
65
+ backgroundColor: 'rgba(175, 184, 193, 0.2)',
66
+ padding: '0.2em 0.4em',
67
+ borderRadius: '3px',
68
+ fontSize: '85%',
69
+ fontFamily: 'Consolas, Monaco, "Andale Mono", monospace',
70
+ }}>
71
+ {props.children}
72
+ </code>
73
+ );
74
+ }
75
+
57
76
  // 元信息中解析语言类型
58
- const languageType = /language-(\w+)/.exec(node?.properties?.className || '')?.[0];
77
+ const languageType = /language-(\w+)/.exec(node?.properties?.className || className || '')?.[0];
59
78
  const langFromMeta = node?.position?.start?.line === 2 ? 'language-file' : null;
60
79
  const finalLang = languageType || langFromMeta;
61
80