mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-13 09:16:04 +00:00
refactor: switch webchat from sse to websocket (#1808)
* refactor: switch webchat from sse to websocket * perf: image preview dialog * chore: remove console.log
This commit is contained in:
committed by
GitHub
parent
348620ac0a
commit
d09b823c49
@@ -129,7 +129,6 @@ export default function BotForm({
|
||||
form.setValue('adapter_config', val.adapter_config);
|
||||
form.setValue('enable', val.enable);
|
||||
form.setValue('use_pipeline_uuid', val.use_pipeline_uuid || '');
|
||||
console.log('form', form.getValues());
|
||||
handleAdapterSelect(val.adapter);
|
||||
// dynamicForm.setFieldsValue(val.adapter_config);
|
||||
})
|
||||
@@ -145,7 +144,6 @@ export default function BotForm({
|
||||
async function initBotFormComponent() {
|
||||
// 初始化流水线列表
|
||||
const pipelinesRes = await httpClient.getPipelines();
|
||||
console.log('rawPipelineList', pipelinesRes);
|
||||
setPipelineNameList(
|
||||
pipelinesRes.pipelines.map((item) => {
|
||||
return {
|
||||
@@ -157,7 +155,6 @@ export default function BotForm({
|
||||
|
||||
// 拉取adapter
|
||||
const adaptersRes = await httpClient.getAdapters();
|
||||
console.log('rawAdapterList', adaptersRes);
|
||||
setAdapterNameList(
|
||||
adaptersRes.adapters.map((item) => {
|
||||
return {
|
||||
@@ -253,12 +250,10 @@ export default function BotForm({
|
||||
}
|
||||
|
||||
// 只有通过外层固定表单验证才会走到这里,真正的提交逻辑在这里
|
||||
function onDynamicFormSubmit(value: object) {
|
||||
function onDynamicFormSubmit() {
|
||||
setIsLoading(true);
|
||||
console.log('set loading', true);
|
||||
if (initBotId) {
|
||||
// 编辑提交
|
||||
// console.log('submit edit', form.getFieldsValue(), value);
|
||||
const updateBot: Bot = {
|
||||
uuid: initBotId,
|
||||
name: form.getValues().name,
|
||||
@@ -270,8 +265,7 @@ export default function BotForm({
|
||||
};
|
||||
httpClient
|
||||
.updateBot(initBotId, updateBot)
|
||||
.then((res) => {
|
||||
console.log('update bot success', res);
|
||||
.then(() => {
|
||||
onFormSubmit(form.getValues());
|
||||
toast.success(t('bots.saveSuccess'));
|
||||
})
|
||||
@@ -285,7 +279,6 @@ export default function BotForm({
|
||||
});
|
||||
} else {
|
||||
// 创建提交
|
||||
console.log('submit create', form.getValues(), value);
|
||||
const newBot: Bot = {
|
||||
name: form.getValues().name,
|
||||
description: form.getValues().description,
|
||||
@@ -295,7 +288,6 @@ export default function BotForm({
|
||||
httpClient
|
||||
.createBot(newBot)
|
||||
.then((res) => {
|
||||
console.log('create bot success', res);
|
||||
toast.success(t('bots.createSuccess'));
|
||||
initBotId = res.uuid;
|
||||
|
||||
|
||||
@@ -86,7 +86,6 @@ export default function BotConfigPage() {
|
||||
}
|
||||
|
||||
function handleNewBotCreated(botId: string) {
|
||||
console.log('new bot created', botId);
|
||||
getBotList();
|
||||
setSelectedBotId(botId);
|
||||
}
|
||||
|
||||
@@ -110,8 +110,6 @@ export default function DynamicFormComponent({
|
||||
// 当 initialValues 变化时更新表单值
|
||||
// 但要避免因为内部表单更新触发的 onSubmit 导致的 initialValues 变化而重新设置表单
|
||||
useEffect(() => {
|
||||
console.log('initialValues', initialValues);
|
||||
|
||||
// 首次挂载时,使用 initialValues 初始化表单
|
||||
if (isInitialMount.current) {
|
||||
isInitialMount.current = false;
|
||||
@@ -148,7 +146,6 @@ export default function DynamicFormComponent({
|
||||
const subscription = form.watch(() => {
|
||||
// 获取完整的表单值,确保包含所有默认值
|
||||
const formValues = form.getValues();
|
||||
console.log('formValues', formValues);
|
||||
const finalValues = itemConfigList.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.name] = formValues[item.name] ?? item.default;
|
||||
@@ -156,7 +153,6 @@ export default function DynamicFormComponent({
|
||||
},
|
||||
{} as Record<string, object>,
|
||||
);
|
||||
console.log('finalValues', finalValues);
|
||||
onSubmit?.(finalValues);
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
|
||||
@@ -66,7 +66,6 @@ export default function HomeSidebar({
|
||||
.catch((error) => {
|
||||
console.error('Failed to fetch GitHub star count:', error);
|
||||
});
|
||||
return () => console.log('sidebar.unmounted');
|
||||
}, []);
|
||||
|
||||
function handleChildClick(child: SidebarChildVO) {
|
||||
@@ -90,7 +89,6 @@ export default function HomeSidebar({
|
||||
}
|
||||
|
||||
function handleRoute(child: SidebarChildVO) {
|
||||
console.log(child);
|
||||
router.push(`${child.route}`);
|
||||
}
|
||||
|
||||
@@ -102,7 +100,6 @@ export default function HomeSidebar({
|
||||
routeList[1] === 'home' &&
|
||||
sidebarConfigList.find((childConfig) => childConfig.route === pathname)
|
||||
) {
|
||||
console.log('find success');
|
||||
const routeSelectChild = sidebarConfigList.find(
|
||||
(childConfig) => childConfig.route === pathname,
|
||||
);
|
||||
@@ -144,7 +141,6 @@ export default function HomeSidebar({
|
||||
<div
|
||||
key={config.id}
|
||||
onClick={() => {
|
||||
console.log('click:', config.id);
|
||||
handleChildClick(config);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -103,8 +103,6 @@ export default function KBForm({
|
||||
};
|
||||
|
||||
const onSubmit = (data: z.infer<typeof formSchema>) => {
|
||||
console.log('data', data);
|
||||
|
||||
if (initKbId) {
|
||||
// update knowledge base
|
||||
const updateKb: KnowledgeBase = {
|
||||
@@ -116,7 +114,6 @@ export default function KBForm({
|
||||
httpClient
|
||||
.updateKnowledgeBase(initKbId, updateKb)
|
||||
.then((res) => {
|
||||
console.log('update knowledge base success', res);
|
||||
onKbUpdated(res.uuid);
|
||||
toast.success(t('knowledge.updateKnowledgeBaseSuccess'));
|
||||
})
|
||||
@@ -135,7 +132,6 @@ export default function KBForm({
|
||||
httpClient
|
||||
.createKnowledgeBase(newKb)
|
||||
.then((res) => {
|
||||
console.log('create knowledge base success', res);
|
||||
onNewKbCreated(res.uuid);
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -200,7 +196,6 @@ export default function KBForm({
|
||||
disabled={!!initKbId}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
console.log('value', value);
|
||||
}}
|
||||
value={field.value}
|
||||
>
|
||||
|
||||
@@ -326,8 +326,7 @@ export default function EmbeddingForm({
|
||||
api_keys: apiKey ? [apiKey] : [],
|
||||
extra_args: extraArgsObj,
|
||||
})
|
||||
.then((res) => {
|
||||
console.log(res);
|
||||
.then(() => {
|
||||
toast.success(t('models.testSuccess'));
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -341,8 +341,7 @@ export default function LLMForm({
|
||||
abilities: form.getValues('abilities'),
|
||||
extra_args: extraArgsObj,
|
||||
})
|
||||
.then((res) => {
|
||||
console.log(res);
|
||||
.then(() => {
|
||||
toast.success(t('models.testSuccess'));
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -54,7 +54,6 @@ export default function LLMConfigPage() {
|
||||
.getProviderLLMModels()
|
||||
.then((resp) => {
|
||||
const llmModelList: LLMCardVO[] = resp.models.map((model: LLMModel) => {
|
||||
console.log('model', model);
|
||||
return new LLMCardVO({
|
||||
id: model.uuid,
|
||||
iconURL: httpClient.getProviderRequesterIconURL(model.requester),
|
||||
@@ -66,7 +65,6 @@ export default function LLMConfigPage() {
|
||||
abilities: model.abilities || [],
|
||||
});
|
||||
});
|
||||
console.log('get llmModelList', llmModelList);
|
||||
setCardList(llmModelList);
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -78,7 +76,6 @@ export default function LLMConfigPage() {
|
||||
function selectLLM(cardVO: LLMCardVO) {
|
||||
setIsEditForm(true);
|
||||
setNowSelectedLLM(cardVO);
|
||||
console.log('set now vo', cardVO);
|
||||
setModalOpen(true);
|
||||
}
|
||||
function handleCreateModelClick() {
|
||||
|
||||
@@ -49,6 +49,7 @@ export default function PipelineDialog({
|
||||
propPipelineId,
|
||||
);
|
||||
const [currentMode, setCurrentMode] = useState<DialogMode>('config');
|
||||
const [isWebSocketConnected, setIsWebSocketConnected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setPipelineId(propPipelineId);
|
||||
@@ -184,10 +185,29 @@ export default function PipelineDialog({
|
||||
</Sidebar>
|
||||
<main className="flex flex-1 flex-col h-full min-h-0">
|
||||
<DialogHeader
|
||||
className="px-6 pt-6 pb-4 shrink-0"
|
||||
className="px-6 pt-6 pb-4 shrink-0 flex flex-row items-center justify-start"
|
||||
style={{ height: '4rem' }}
|
||||
>
|
||||
<DialogTitle>{getDialogTitle()}</DialogTitle>
|
||||
{currentMode === 'debug' && (
|
||||
<div className="flex items-center gap-2 ml-2">
|
||||
<div
|
||||
className={`w-2.5 h-2.5 rounded-full ${
|
||||
isWebSocketConnected ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}
|
||||
title={
|
||||
isWebSocketConnected
|
||||
? t('pipelines.debugDialog.connected')
|
||||
: t('pipelines.debugDialog.disconnected')
|
||||
}
|
||||
/>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{isWebSocketConnected
|
||||
? t('pipelines.debugDialog.connected')
|
||||
: t('pipelines.debugDialog.disconnected')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</DialogHeader>
|
||||
<div
|
||||
className="flex-1 overflow-y-auto px-6 pb-4 w-full"
|
||||
@@ -217,6 +237,7 @@ export default function PipelineDialog({
|
||||
open={true}
|
||||
pipelineId={pipelineId}
|
||||
isEmbedded={true}
|
||||
onConnectionStatusChange={setIsWebSocketConnected}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,30 +4,32 @@ import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { DialogContent } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Message } from '@/app/infra/entities/message';
|
||||
import {
|
||||
Message,
|
||||
MessageChainComponent,
|
||||
Image,
|
||||
Plain,
|
||||
At,
|
||||
} from '@/app/infra/entities/message';
|
||||
import { toast } from 'sonner';
|
||||
import AtBadge from './AtBadge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
|
||||
interface MessageComponent {
|
||||
type: 'At' | 'Plain';
|
||||
target?: string;
|
||||
text?: string;
|
||||
}
|
||||
import { WebSocketClient } from '@/app/infra/websocket/WebSocketClient';
|
||||
import ImagePreviewDialog from './ImagePreviewDialog';
|
||||
|
||||
interface DebugDialogProps {
|
||||
open: boolean;
|
||||
pipelineId: string;
|
||||
isEmbedded?: boolean;
|
||||
onConnectionStatusChange?: (isConnected: boolean) => void;
|
||||
}
|
||||
|
||||
export default function DebugDialog({
|
||||
open,
|
||||
pipelineId,
|
||||
isEmbedded = false,
|
||||
onConnectionStatusChange,
|
||||
}: DebugDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedPipelineId, setSelectedPipelineId] = useState(pipelineId);
|
||||
@@ -37,10 +39,19 @@ export default function DebugDialog({
|
||||
const [showAtPopover, setShowAtPopover] = useState(false);
|
||||
const [hasAt, setHasAt] = useState(false);
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const [isStreaming, setIsStreaming] = useState(true);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [selectedImages, setSelectedImages] = useState<
|
||||
Array<{ file: File; preview: string; fileKey?: string }>
|
||||
>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [previewImageUrl, setPreviewImageUrl] = useState<string>('');
|
||||
const [showImagePreview, setShowImagePreview] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const wsClientRef = useRef<WebSocketClient | null>(null);
|
||||
const isInitializingRef = useRef<boolean>(false);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
// 使用setTimeout确保在DOM更新后执行滚动
|
||||
@@ -60,7 +71,7 @@ export default function DebugDialog({
|
||||
const loadMessages = useCallback(
|
||||
async (pipelineId: string) => {
|
||||
try {
|
||||
const response = await httpClient.getWebChatHistoryMessages(
|
||||
const response = await httpClient.getWebSocketHistoryMessages(
|
||||
pipelineId,
|
||||
sessionType,
|
||||
);
|
||||
@@ -71,23 +82,123 @@ export default function DebugDialog({
|
||||
},
|
||||
[sessionType],
|
||||
);
|
||||
|
||||
// 初始化WebSocket连接
|
||||
const initWebSocket = useCallback(
|
||||
async (pipelineId: string) => {
|
||||
// 防止重复初始化
|
||||
if (isInitializingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isInitializingRef.current = true;
|
||||
|
||||
// 断开旧连接
|
||||
if (wsClientRef.current) {
|
||||
wsClientRef.current.disconnect();
|
||||
wsClientRef.current = null;
|
||||
}
|
||||
|
||||
// 创建新连接
|
||||
const wsClient = new WebSocketClient(pipelineId, sessionType);
|
||||
|
||||
wsClient
|
||||
.onConnected(() => {
|
||||
setIsConnected(true);
|
||||
isInitializingRef.current = false;
|
||||
})
|
||||
.onMessage((wsMessage) => {
|
||||
// 将 WebSocketMessage 转换为 Message 类型
|
||||
const message: Message = {
|
||||
...wsMessage,
|
||||
message_chain: wsMessage.message_chain as MessageChainComponent[],
|
||||
};
|
||||
|
||||
setMessages((prevMessages) => {
|
||||
// 查找是否已存在相同ID的消息
|
||||
const existingIndex = prevMessages.findIndex(
|
||||
(m) => m.id === message.id,
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// 更新已存在的消息(流式输出)
|
||||
const newMessages = [...prevMessages];
|
||||
newMessages[existingIndex] = message;
|
||||
return newMessages;
|
||||
} else {
|
||||
// 添加新消息
|
||||
return [...prevMessages, message];
|
||||
}
|
||||
});
|
||||
})
|
||||
.onError((error) => {
|
||||
console.error('WebSocket错误:', error);
|
||||
setIsConnected(false);
|
||||
isInitializingRef.current = false;
|
||||
toast.error(t('pipelines.debugDialog.connectionError'));
|
||||
})
|
||||
.onClose(() => {
|
||||
setIsConnected(false);
|
||||
isInitializingRef.current = false;
|
||||
})
|
||||
.onBroadcast((message) => {
|
||||
toast.info(message);
|
||||
});
|
||||
|
||||
await wsClient.connect();
|
||||
wsClientRef.current = wsClient;
|
||||
} catch (error) {
|
||||
console.error('WebSocket连接失败:', error);
|
||||
setIsConnected(false);
|
||||
isInitializingRef.current = false;
|
||||
toast.error(t('pipelines.debugDialog.connectionFailed'));
|
||||
}
|
||||
},
|
||||
[sessionType, t],
|
||||
);
|
||||
|
||||
// 在useEffect中监听messages变化时滚动
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, scrollToBottom]);
|
||||
|
||||
// 监听 open 和 pipelineId 变化,进入时连接,离开时断开
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedPipelineId(pipelineId);
|
||||
loadMessages(pipelineId);
|
||||
} else {
|
||||
// 关闭对话框时立即断开WebSocket
|
||||
if (wsClientRef.current) {
|
||||
wsClientRef.current.disconnect();
|
||||
wsClientRef.current = null;
|
||||
setIsConnected(false);
|
||||
isInitializingRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
// 组件卸载时断开WebSocket
|
||||
if (wsClientRef.current) {
|
||||
wsClientRef.current.disconnect();
|
||||
wsClientRef.current = null;
|
||||
isInitializingRef.current = false;
|
||||
}
|
||||
};
|
||||
}, [open, pipelineId]);
|
||||
|
||||
// 监听 sessionType 和 selectedPipelineId 变化,重新加载消息和连接
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadMessages(selectedPipelineId);
|
||||
initWebSocket(selectedPipelineId);
|
||||
}
|
||||
}, [sessionType, selectedPipelineId, open, loadMessages]);
|
||||
}, [sessionType, selectedPipelineId, open, loadMessages, initWebSocket]);
|
||||
|
||||
// 通知父组件连接状态变化
|
||||
useEffect(() => {
|
||||
onConnectionStatusChange?.(isConnected);
|
||||
}, [isConnected, onConnectionStatusChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
@@ -147,10 +258,42 @@ export default function DebugDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const newImages: Array<{ file: File; preview: string }> = [];
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (file.type.startsWith('image/')) {
|
||||
const preview = URL.createObjectURL(file);
|
||||
newImages.push({ file, preview });
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedImages((prev) => [...prev, ...newImages]);
|
||||
};
|
||||
|
||||
const handleRemoveImage = (index: number) => {
|
||||
setSelectedImages((prev) => {
|
||||
const newImages = [...prev];
|
||||
URL.revokeObjectURL(newImages[index].preview);
|
||||
newImages.splice(index, 1);
|
||||
return newImages;
|
||||
});
|
||||
};
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!inputValue.trim() && !hasAt) return;
|
||||
if (!inputValue.trim() && !hasAt && selectedImages.length === 0) return;
|
||||
if (!isConnected || !wsClientRef.current) {
|
||||
toast.error(t('pipelines.debugDialog.notConnected'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsUploading(true);
|
||||
|
||||
const messageChain = [];
|
||||
|
||||
let text_content = inputValue.trim();
|
||||
@@ -161,142 +304,133 @@ export default function DebugDialog({
|
||||
if (hasAt) {
|
||||
messageChain.push({
|
||||
type: 'At',
|
||||
target: 'webchatbot',
|
||||
target: 'websocketbot',
|
||||
display: 'websocketbot',
|
||||
});
|
||||
}
|
||||
messageChain.push({
|
||||
type: 'Plain',
|
||||
text: text_content,
|
||||
});
|
||||
|
||||
if (hasAt) {
|
||||
// for showing
|
||||
text_content = '@webchatbot' + text_content;
|
||||
// 添加文本
|
||||
if (text_content) {
|
||||
messageChain.push({
|
||||
type: 'Plain',
|
||||
text: text_content,
|
||||
});
|
||||
}
|
||||
|
||||
const userMessage: Message = {
|
||||
id: -1,
|
||||
role: 'user',
|
||||
content: text_content,
|
||||
timestamp: new Date().toISOString(),
|
||||
message_chain: messageChain,
|
||||
};
|
||||
// 根据isStreaming状态决定使用哪种传输方式
|
||||
if (isStreaming) {
|
||||
// streaming
|
||||
// 创建初始bot消息
|
||||
const placeholderRandomId = Math.floor(Math.random() * 1000000);
|
||||
const botMessagePlaceholder: Message = {
|
||||
id: placeholderRandomId,
|
||||
role: 'assistant',
|
||||
content: 'Generating...',
|
||||
timestamp: new Date().toISOString(),
|
||||
message_chain: [{ type: 'Plain', text: 'Generating...' }],
|
||||
};
|
||||
|
||||
// 添加用户消息和初始bot消息到状态
|
||||
|
||||
setMessages((prevMessages) => [
|
||||
...prevMessages,
|
||||
userMessage,
|
||||
botMessagePlaceholder,
|
||||
]);
|
||||
setInputValue('');
|
||||
setHasAt(false);
|
||||
// 上传图片并添加到消息链
|
||||
for (const image of selectedImages) {
|
||||
try {
|
||||
await httpClient.sendStreamingWebChatMessage(
|
||||
sessionType,
|
||||
messageChain,
|
||||
const result = await httpClient.uploadWebSocketImage(
|
||||
selectedPipelineId,
|
||||
(data) => {
|
||||
// 处理流式响应数据
|
||||
console.log('data', data);
|
||||
if (data.message) {
|
||||
// 更新完整内容
|
||||
|
||||
setMessages((prevMessages) => {
|
||||
const updatedMessages = [...prevMessages];
|
||||
const botMessageIndex = updatedMessages.findIndex(
|
||||
(message) => message.id === placeholderRandomId,
|
||||
);
|
||||
if (botMessageIndex !== -1) {
|
||||
updatedMessages[botMessageIndex] = {
|
||||
...updatedMessages[botMessageIndex],
|
||||
content: data.message.content,
|
||||
message_chain: [
|
||||
{ type: 'Plain', text: data.message.content },
|
||||
],
|
||||
};
|
||||
}
|
||||
return updatedMessages;
|
||||
});
|
||||
}
|
||||
},
|
||||
() => {},
|
||||
(error) => {
|
||||
// 处理错误
|
||||
console.error('Streaming error:', error);
|
||||
if (sessionType === 'person') {
|
||||
toast.error(t('pipelines.debugDialog.sendFailed'));
|
||||
}
|
||||
},
|
||||
image.file,
|
||||
);
|
||||
messageChain.push({
|
||||
type: 'Image',
|
||||
path: result.file_key,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to send streaming message:', error);
|
||||
if (sessionType === 'person') {
|
||||
toast.error(t('pipelines.debugDialog.sendFailed'));
|
||||
}
|
||||
console.error('图片上传失败:', error);
|
||||
toast.error(t('pipelines.debugDialog.imageUploadFailed'));
|
||||
}
|
||||
} else {
|
||||
// non-streaming
|
||||
setMessages((prevMessages) => [...prevMessages, userMessage]);
|
||||
setInputValue('');
|
||||
setHasAt(false);
|
||||
}
|
||||
|
||||
const response = await httpClient.sendWebChatMessage(
|
||||
sessionType,
|
||||
messageChain,
|
||||
selectedPipelineId,
|
||||
180000,
|
||||
// 清空输入框和图片
|
||||
setInputValue('');
|
||||
setHasAt(false);
|
||||
selectedImages.forEach((img) => URL.revokeObjectURL(img.preview));
|
||||
setSelectedImages([]);
|
||||
|
||||
// 通过WebSocket发送消息
|
||||
// 不在本地添加消息,等待后端广播回来(带有正确的ID)
|
||||
wsClientRef.current.sendMessage(messageChain);
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error);
|
||||
toast.error(t('pipelines.debugDialog.sendFailed'));
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const renderMessageComponent = (
|
||||
component: MessageChainComponent,
|
||||
index: number,
|
||||
) => {
|
||||
switch (component.type) {
|
||||
case 'Plain':
|
||||
return <span key={index}>{(component as Plain).text}</span>;
|
||||
|
||||
case 'At': {
|
||||
const atComponent = component as At;
|
||||
// 优先使用 display,如果没有则使用 target
|
||||
const displayName =
|
||||
atComponent.display || atComponent.target?.toString() || '';
|
||||
return (
|
||||
<span key={index} className="inline-flex align-middle mx-1">
|
||||
<AtBadge targetName={displayName} readonly={true} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
case 'AtAll':
|
||||
return (
|
||||
<span key={index} className="inline-flex align-middle mx-1">
|
||||
<AtBadge targetName="全体成员" readonly={true} />
|
||||
</span>
|
||||
);
|
||||
|
||||
setMessages((prevMessages) => [...prevMessages, response.message]);
|
||||
}
|
||||
} catch (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
error: any
|
||||
) {
|
||||
console.log(error, 'type of error', typeof error);
|
||||
console.error('Failed to send message:', error);
|
||||
case 'Image': {
|
||||
const img = component as Image;
|
||||
const imageUrl = img.url || (img.base64 ? img.base64 : '');
|
||||
|
||||
if (!error.message.includes('timeout') && sessionType === 'person') {
|
||||
toast.error(t('pipelines.debugDialog.sendFailed'));
|
||||
if (!imageUrl) return null;
|
||||
|
||||
return (
|
||||
<div key={index} className="my-2">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="Image"
|
||||
className="max-w-full max-h-96 rounded-lg cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => {
|
||||
setPreviewImageUrl(imageUrl);
|
||||
setShowImagePreview(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
inputRef.current?.focus();
|
||||
|
||||
case 'File': {
|
||||
const file = component as MessageChainComponent & { name?: string };
|
||||
return (
|
||||
<div key={index} className="my-2 flex items-center gap-2 text-sm">
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" />
|
||||
</svg>
|
||||
<span>[文件] {file.name || 'Unknown'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'Voice':
|
||||
return <span key={index}>[语音]</span>;
|
||||
|
||||
case 'Source':
|
||||
// Source 不显示
|
||||
return null;
|
||||
|
||||
default:
|
||||
return <span key={index}>[{component.type}]</span>;
|
||||
}
|
||||
};
|
||||
|
||||
const renderMessageContent = (message: Message) => {
|
||||
return (
|
||||
<span className="text-base leading-relaxed align-middle whitespace-pre-wrap">
|
||||
{(message.message_chain as MessageComponent[]).map(
|
||||
(component, index) => {
|
||||
if (component.type === 'At') {
|
||||
return (
|
||||
<AtBadge
|
||||
key={index}
|
||||
targetName={component.target || ''}
|
||||
readonly={true}
|
||||
/>
|
||||
);
|
||||
} else if (component.type === 'Plain') {
|
||||
return <span key={index}>{component.text}</span>;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
<div className="text-base leading-relaxed align-middle whitespace-pre-wrap">
|
||||
{message.message_chain.map((component, index) =>
|
||||
renderMessageComponent(component, index),
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -341,11 +475,10 @@ export default function DebugDialog({
|
||||
<path d="M2 22C2 17.5817 5.58172 14 10 14C14.4183 14 18 17.5817 18 22H16C16 18.6863 13.3137 16 10 16C6.68629 16 4 18.6863 4 22H2ZM10 13C6.685 13 4 10.315 4 7C4 3.685 6.685 1 10 1C13.315 1 16 3.685 16 7C16 10.315 13.315 13 10 13ZM10 11C12.21 11 14 9.21 14 7C14 4.79 12.21 3 10 3C7.79 3 6 4.79 6 7C6 9.21 7.79 11 10 11ZM18.2837 14.7028C21.0644 15.9561 23 18.752 23 22H21C21 19.564 19.5483 17.4671 17.4628 16.5271L18.2837 14.7028ZM17.5962 3.41321C19.5944 4.23703 21 6.20361 21 8.5C21 11.3702 18.8042 13.7252 16 13.9776V11.9646C17.6967 11.7222 19 10.264 19 8.5C19 7.11935 18.2016 5.92603 17.041 5.35635L17.5962 3.41321Z"></path>
|
||||
</svg>
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col w-[10rem] h-full min-h-0">
|
||||
<ScrollArea className="flex-1 p-6 overflow-y-auto min-h-0 bg-white dark:bg-black">
|
||||
<ScrollArea className="flex-1 p-6 overflow-y-auto min-h-0 bg-white dark:bg-black scroll-area">
|
||||
<div className="space-y-6">
|
||||
{messages.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-12 text-lg">
|
||||
@@ -389,16 +522,65 @@ export default function DebugDialog({
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* 图片预览区域 */}
|
||||
{selectedImages.length > 0 && (
|
||||
<div className="px-4 pb-2 bg-white dark:bg-black">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{selectedImages.map((image, index) => (
|
||||
<div key={index} className="relative group">
|
||||
<img
|
||||
src={image.preview}
|
||||
alt={`preview-${index}`}
|
||||
className="w-20 h-20 object-cover rounded-lg border border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleRemoveImage(index)}
|
||||
className="absolute -top-2 -right-2 w-5 h-5 bg-red-500 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4 pb-0 bg-white dark:bg-black flex gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">
|
||||
{t('pipelines.debugDialog.streaming')}
|
||||
</span>
|
||||
<Switch checked={isStreaming} onCheckedChange={setIsStreaming} />
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handleImageSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={!isConnected || isUploading}
|
||||
className="w-10 h-10 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="上传图片"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
{hasAt && (
|
||||
<AtBadge targetName="webchatbot" onRemove={handleAtRemove} />
|
||||
<AtBadge targetName="websocketbot" onRemove={handleAtRemove} />
|
||||
)}
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
@@ -412,7 +594,8 @@ export default function DebugDialog({
|
||||
? t('pipelines.debugDialog.privateChat')
|
||||
: t('pipelines.debugDialog.groupChat'),
|
||||
})}
|
||||
className="flex-1 rounded-md px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 focus:border-[#2288ee] transition-none text-base"
|
||||
disabled={!isConnected || isUploading}
|
||||
className="flex-1 rounded-md px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 focus:border-[#2288ee] transition-none text-base disabled:opacity-50"
|
||||
/>
|
||||
{showAtPopover && (
|
||||
<div
|
||||
@@ -431,7 +614,7 @@ export default function DebugDialog({
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
>
|
||||
<span className="text-gray-800 dark:text-gray-200">
|
||||
@webchatbot - {t('pipelines.debugDialog.atTips')}
|
||||
@websocketbot - {t('pipelines.debugDialog.atTips')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -440,10 +623,14 @@ export default function DebugDialog({
|
||||
</div>
|
||||
<Button
|
||||
onClick={sendMessage}
|
||||
disabled={!inputValue.trim() && !hasAt}
|
||||
className="rounded-md bg-[#2288ee] hover:bg-[#2288ee] w-20 text-white px-6 py-2 text-base font-medium transition-none flex items-center gap-2 shadow-none"
|
||||
disabled={
|
||||
(!inputValue.trim() && !hasAt && selectedImages.length === 0) ||
|
||||
!isConnected ||
|
||||
isUploading
|
||||
}
|
||||
className="rounded-md bg-[#2288ee] hover:bg-[#2288ee] w-20 text-white px-6 py-2 text-base font-medium transition-none flex items-center gap-2 shadow-none disabled:opacity-50"
|
||||
>
|
||||
<>{t('pipelines.debugDialog.send')}</>
|
||||
{isUploading ? '上传中...' : t('pipelines.debugDialog.send')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -453,16 +640,30 @@ export default function DebugDialog({
|
||||
// 如果是嵌入模式,直接返回内容
|
||||
if (isEmbedded) {
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex-1 min-h-0 flex flex-col">{renderContent()}</div>
|
||||
</div>
|
||||
<>
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex-1 min-h-0 flex flex-col">{renderContent()}</div>
|
||||
</div>
|
||||
<ImagePreviewDialog
|
||||
open={showImagePreview}
|
||||
imageUrl={previewImageUrl}
|
||||
onClose={() => setShowImagePreview(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 原有的Dialog包装
|
||||
return (
|
||||
<DialogContent className="!max-w-[70vw] max-w-6xl h-[70vh] p-6 flex flex-col rounded-2xl shadow-2xl bg-white dark:bg-black">
|
||||
{renderContent()}
|
||||
</DialogContent>
|
||||
<>
|
||||
<DialogContent className="!max-w-[70vw] max-w-6xl h-[70vh] p-6 flex flex-col rounded-2xl shadow-2xl bg-white dark:bg-black">
|
||||
{renderContent()}
|
||||
</DialogContent>
|
||||
<ImagePreviewDialog
|
||||
open={showImagePreview}
|
||||
imageUrl={previewImageUrl}
|
||||
onClose={() => setShowImagePreview(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ImagePreviewDialogProps {
|
||||
open: boolean;
|
||||
imageUrl: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ImagePreviewDialog({
|
||||
open,
|
||||
imageUrl,
|
||||
onClose,
|
||||
}: ImagePreviewDialogProps) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center p-8 animate-in fade-in duration-200"
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* 背景遮罩 */}
|
||||
<div className="absolute inset-0 bg-black/20 " />
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="relative z-10 flex flex-col items-center gap-2">
|
||||
{/* 关闭按钮 - 在图片上方 */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="self-end w-9 h-9 rounded-full bg-white hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 text-gray-800 dark:text-gray-100 shadow-lg transition-all hover:scale-105 flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* 图片 */}
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="Preview"
|
||||
className="max-w-[50vw] max-h-[50vh] object-contain rounded-lg shadow-2xl bg-white"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -159,7 +159,6 @@ export default function PipelineFormComponent({
|
||||
}, [form, isEditMode]);
|
||||
|
||||
function handleFormSubmit(values: FormValues) {
|
||||
console.log('handleFormSubmit', values);
|
||||
if (isEditMode) {
|
||||
handleModify(values);
|
||||
} else {
|
||||
@@ -168,7 +167,6 @@ export default function PipelineFormComponent({
|
||||
}
|
||||
|
||||
function handleCreate(values: FormValues) {
|
||||
console.log('handleCreate', values);
|
||||
const pipeline: Pipeline = {
|
||||
config: {},
|
||||
description: values.basic.description,
|
||||
|
||||
@@ -75,7 +75,6 @@ export default function PluginConfigPage() {
|
||||
setPipelineList(pipelineList);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
toast.error(t('pipelines.getPipelineListError') + error.message);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -329,7 +329,6 @@ function MarketPageContent({
|
||||
|
||||
// 安装插件
|
||||
// const handleInstallPlugin = (plugin: PluginV4) => {
|
||||
// console.log('install plugin', plugin);
|
||||
// };
|
||||
|
||||
return (
|
||||
|
||||
@@ -123,7 +123,6 @@
|
||||
|
||||
// function handleDragEnd(event: DragEndEvent) {
|
||||
// const { active, over } = event;
|
||||
// console.log('Drag end event:', { active, over });
|
||||
|
||||
// if (over && active.id !== over.id) {
|
||||
// setSortedPlugins((items) => {
|
||||
|
||||
@@ -266,7 +266,6 @@ export default function PluginConfigPage() {
|
||||
watchTask(taskId);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log('error when install plugin:', err);
|
||||
setInstallError(err.message);
|
||||
setPluginInstallStatus(PluginInstallStatus.ERROR);
|
||||
});
|
||||
@@ -278,7 +277,6 @@ export default function PluginConfigPage() {
|
||||
watchTask(taskId);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log('error when install plugin:', err);
|
||||
setInstallError(err.message);
|
||||
setPluginInstallStatus(PluginInstallStatus.ERROR);
|
||||
});
|
||||
@@ -431,7 +429,9 @@ export default function PluginConfigPage() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.pageContainer} h-full flex flex-col ${isDragOver ? 'bg-blue-50' : ''}`}
|
||||
className={`${styles.pageContainer} h-full flex flex-col ${
|
||||
isDragOver ? 'bg-blue-50' : ''
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
|
||||
@@ -1,7 +1,148 @@
|
||||
// Message component base interface
|
||||
export interface MessageComponent {
|
||||
type: string;
|
||||
}
|
||||
|
||||
// Source component
|
||||
export interface Source extends MessageComponent {
|
||||
type: 'Source';
|
||||
id: number | string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// Plain text component
|
||||
export interface Plain extends MessageComponent {
|
||||
type: 'Plain';
|
||||
text: string;
|
||||
}
|
||||
|
||||
// Quote component
|
||||
export interface Quote extends MessageComponent {
|
||||
type: 'Quote';
|
||||
id?: number;
|
||||
group_id?: number | string;
|
||||
sender_id?: number | string;
|
||||
target_id?: number | string;
|
||||
origin: MessageComponent[];
|
||||
}
|
||||
|
||||
// At component
|
||||
export interface At extends MessageComponent {
|
||||
type: 'At';
|
||||
target: number | string;
|
||||
display?: string;
|
||||
}
|
||||
|
||||
// AtAll component
|
||||
export interface AtAll extends MessageComponent {
|
||||
type: 'AtAll';
|
||||
}
|
||||
|
||||
// Image component
|
||||
export interface Image extends MessageComponent {
|
||||
type: 'Image';
|
||||
image_id?: string;
|
||||
url?: string;
|
||||
path?: string;
|
||||
base64?: string;
|
||||
}
|
||||
|
||||
// Voice component
|
||||
export interface Voice extends MessageComponent {
|
||||
type: 'Voice';
|
||||
voice_id?: string;
|
||||
url?: string;
|
||||
path?: string;
|
||||
base64?: string;
|
||||
length?: number;
|
||||
}
|
||||
|
||||
// File component
|
||||
export interface File extends MessageComponent {
|
||||
type: 'File';
|
||||
id?: string;
|
||||
name?: string;
|
||||
size?: number;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
// Unknown component
|
||||
export interface Unknown extends MessageComponent {
|
||||
type: 'Unknown';
|
||||
text?: string;
|
||||
}
|
||||
|
||||
// Forward message node
|
||||
export interface ForwardMessageNode {
|
||||
sender_id?: number | string;
|
||||
sender_name?: string;
|
||||
message_chain?: MessageComponent[];
|
||||
message_id?: number;
|
||||
}
|
||||
|
||||
// Forward message display
|
||||
export interface ForwardMessageDisplay {
|
||||
title?: string;
|
||||
brief?: string;
|
||||
source?: string;
|
||||
preview?: string[];
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
// Forward component
|
||||
export interface Forward extends MessageComponent {
|
||||
type: 'Forward';
|
||||
display?: ForwardMessageDisplay;
|
||||
node_list?: ForwardMessageNode[];
|
||||
}
|
||||
|
||||
// WeChat specific components
|
||||
export interface WeChatMiniPrograms extends MessageComponent {
|
||||
type: 'WeChatMiniPrograms';
|
||||
mini_app_id: string;
|
||||
user_name: string;
|
||||
display_name?: string;
|
||||
page_path?: string;
|
||||
title?: string;
|
||||
image_url?: string;
|
||||
}
|
||||
|
||||
export interface WeChatEmoji extends MessageComponent {
|
||||
type: 'WeChatEmoji';
|
||||
emoji_md5: string;
|
||||
emoji_size: number;
|
||||
}
|
||||
|
||||
export interface WeChatLink extends MessageComponent {
|
||||
type: 'WeChatLink';
|
||||
link_title?: string;
|
||||
link_desc?: string;
|
||||
link_url?: string;
|
||||
link_thumb_url?: string;
|
||||
}
|
||||
|
||||
// Union type for all message components
|
||||
export type MessageChainComponent =
|
||||
| Source
|
||||
| Plain
|
||||
| Quote
|
||||
| At
|
||||
| AtAll
|
||||
| Image
|
||||
| Voice
|
||||
| File
|
||||
| Unknown
|
||||
| Forward
|
||||
| WeChatMiniPrograms
|
||||
| WeChatEmoji
|
||||
| WeChatLink;
|
||||
|
||||
// Message interface
|
||||
export interface Message {
|
||||
id: number;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
message_chain: object[];
|
||||
message_chain: MessageChainComponent[];
|
||||
timestamp: string;
|
||||
is_final?: boolean;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
GetPipelineResponseData,
|
||||
GetPipelineMetadataResponseData,
|
||||
AsyncTask,
|
||||
ApiRespWebChatMessage,
|
||||
ApiRespWebChatMessages,
|
||||
ApiRespKnowledgeBases,
|
||||
ApiRespKnowledgeBase,
|
||||
@@ -199,136 +198,58 @@ export class BackendClient extends BaseHttpClient {
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Debug WebChat API ============
|
||||
|
||||
// ============ Debug WebChat API ============
|
||||
public sendWebChatMessage(
|
||||
sessionType: string,
|
||||
messageChain: object[],
|
||||
pipelineId: string,
|
||||
timeout: number = 15000,
|
||||
): Promise<ApiRespWebChatMessage> {
|
||||
return this.post(
|
||||
`/api/v1/pipelines/${pipelineId}/chat/send`,
|
||||
{
|
||||
session_type: sessionType,
|
||||
message: messageChain,
|
||||
},
|
||||
{
|
||||
timeout,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public async sendStreamingWebChatMessage(
|
||||
sessionType: string,
|
||||
messageChain: object[],
|
||||
pipelineId: string,
|
||||
onMessage: (data: ApiRespWebChatMessage) => void,
|
||||
onComplete: () => void,
|
||||
onError: (error: Error) => void,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// 构造完整的URL,处理相对路径的情况
|
||||
let url = `${this.baseURL}/api/v1/pipelines/${pipelineId}/chat/send`;
|
||||
if (this.baseURL === '/') {
|
||||
// 获取用户访问的完整URL
|
||||
const baseURL = window.location.origin;
|
||||
url = `${baseURL}/api/v1/pipelines/${pipelineId}/chat/send`;
|
||||
}
|
||||
|
||||
// 使用fetch发送流式请求,因为axios在浏览器环境中不直接支持流式响应
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.getSessionSync()}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
session_type: sessionType,
|
||||
message: messageChain,
|
||||
is_stream: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('ReadableStream not supported');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
// 读取流式响应
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
onComplete();
|
||||
break;
|
||||
}
|
||||
|
||||
// 解码数据
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// 处理完整的JSON对象
|
||||
const lines = buffer.split('\n\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data:')) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(5));
|
||||
|
||||
if (data.type === 'end') {
|
||||
// 流传输结束
|
||||
reader.cancel();
|
||||
onComplete();
|
||||
return;
|
||||
}
|
||||
if (data.type === 'start') {
|
||||
console.log(data.type);
|
||||
}
|
||||
|
||||
if (data.message) {
|
||||
// 处理消息数据
|
||||
onMessage(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing streaming data:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
} catch (error) {
|
||||
onError(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
public getWebChatHistoryMessages(
|
||||
// ============ WebSocket Chat API ============
|
||||
public getWebSocketHistoryMessages(
|
||||
pipelineId: string,
|
||||
sessionType: string,
|
||||
): Promise<ApiRespWebChatMessages> {
|
||||
return this.get(
|
||||
`/api/v1/pipelines/${pipelineId}/chat/messages/${sessionType}`,
|
||||
`/api/v1/pipelines/${pipelineId}/ws/messages/${sessionType}`,
|
||||
);
|
||||
}
|
||||
|
||||
public resetWebChatSession(
|
||||
public async uploadWebSocketImage(
|
||||
pipelineId: string,
|
||||
imageFile: File,
|
||||
): Promise<{ file_key: string }> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', imageFile);
|
||||
|
||||
return this.postFile(`/api/v1/files/images`, formData);
|
||||
}
|
||||
|
||||
public resetWebSocketSession(
|
||||
pipelineId: string,
|
||||
sessionType: string,
|
||||
): Promise<{ message: string }> {
|
||||
return this.post(
|
||||
`/api/v1/pipelines/${pipelineId}/chat/reset/${sessionType}`,
|
||||
);
|
||||
return this.post(`/api/v1/pipelines/${pipelineId}/ws/reset/${sessionType}`);
|
||||
}
|
||||
|
||||
public getWebSocketConnections(pipelineId: string): Promise<{
|
||||
stats: {
|
||||
total_connections: number;
|
||||
pipelines: number;
|
||||
connections_by_pipeline: Record<string, number>;
|
||||
connections_by_session_type: Record<string, number>;
|
||||
};
|
||||
connections: Array<{
|
||||
connection_id: string;
|
||||
session_type: string;
|
||||
created_at: string;
|
||||
last_active: string;
|
||||
is_active: boolean;
|
||||
}>;
|
||||
}> {
|
||||
return this.get(`/api/v1/pipelines/${pipelineId}/ws/connections`);
|
||||
}
|
||||
|
||||
public broadcastWebSocketMessage(
|
||||
pipelineId: string,
|
||||
message: string,
|
||||
): Promise<{ message: string }> {
|
||||
return this.post(`/api/v1/pipelines/${pipelineId}/ws/broadcast`, {
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Platform API ============
|
||||
|
||||
@@ -97,8 +97,6 @@ export abstract class BaseHttpClient {
|
||||
|
||||
switch (status) {
|
||||
case 401:
|
||||
console.log('401 error: ', errMessage, error.request);
|
||||
console.log('responseURL', error.request.responseURL);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('token');
|
||||
if (!error.request.responseURL.includes('/check-token')) {
|
||||
|
||||
295
web/src/app/infra/websocket/WebSocketClient.ts
Normal file
295
web/src/app/infra/websocket/WebSocketClient.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* WebSocket客户端类
|
||||
* 用于管理WebSocket连接和消息处理
|
||||
*/
|
||||
export interface WebSocketMessage {
|
||||
id: number;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
message_chain: Array<{ type: string; text?: string; target?: string }>;
|
||||
timestamp: string;
|
||||
is_final?: boolean;
|
||||
connection_id?: string;
|
||||
}
|
||||
|
||||
export interface WebSocketResponse {
|
||||
type:
|
||||
| 'connected'
|
||||
| 'response'
|
||||
| 'user_message'
|
||||
| 'pong'
|
||||
| 'broadcast'
|
||||
| 'error';
|
||||
connection_id?: string;
|
||||
pipeline_uuid?: string;
|
||||
session_type?: string;
|
||||
timestamp?: string;
|
||||
data?: WebSocketMessage;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export class WebSocketClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private connectionId: string | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private maxReconnectAttempts = 5;
|
||||
private reconnectDelay = 3000; // 3秒重连间隔
|
||||
private heartbeatInterval: NodeJS.Timeout | null = null;
|
||||
private heartbeatIntervalMs = 30000; // 30秒
|
||||
private isConnecting = false; // 防止重复连接
|
||||
|
||||
// 事件回调
|
||||
private onConnectedCallback?: (data: WebSocketResponse) => void;
|
||||
private onMessageCallback?: (data: WebSocketMessage) => void;
|
||||
private onErrorCallback?: (error: Error) => void;
|
||||
private onCloseCallback?: () => void;
|
||||
private onBroadcastCallback?: (message: string) => void;
|
||||
|
||||
constructor(
|
||||
private pipelineId: string,
|
||||
private sessionType: 'person' | 'group' = 'person',
|
||||
private token?: string,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 连接到WebSocket服务器
|
||||
*/
|
||||
public connect(): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
// 防止重复连接
|
||||
if (
|
||||
this.isConnecting ||
|
||||
(this.ws && this.ws.readyState === WebSocket.CONNECTING)
|
||||
) {
|
||||
console.warn('WebSocket正在连接中,忽略重复连接请求');
|
||||
reject(new Error('Connection already in progress'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果已经连接,直接返回
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
console.warn('WebSocket已连接,忽略重复连接请求');
|
||||
resolve(this.connectionId || '');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isConnecting = true;
|
||||
|
||||
// 构建WebSocket URL
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
// extract host from process.env.NEXT_PUBLIC_API_BASE_URL
|
||||
const host =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL?.split('://')[1] || '';
|
||||
const url = `${protocol}//${host}/api/v1/pipelines/${this.pipelineId}/ws/connect?session_type=${this.sessionType}`;
|
||||
|
||||
this.ws = new WebSocket(url);
|
||||
|
||||
// 连接打开
|
||||
this.ws.onopen = () => {
|
||||
this.reconnectAttempts = 0;
|
||||
this.isConnecting = false;
|
||||
this.startHeartbeat();
|
||||
};
|
||||
|
||||
// 接收消息
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const data: WebSocketResponse = JSON.parse(event.data);
|
||||
this.handleMessage(data);
|
||||
|
||||
// 第一次连接成功
|
||||
if (data.type === 'connected' && data.connection_id) {
|
||||
this.connectionId = data.connection_id;
|
||||
resolve(data.connection_id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('解析WebSocket消息失败:', error);
|
||||
this.onErrorCallback?.(error as Error);
|
||||
}
|
||||
};
|
||||
|
||||
// 连接关闭
|
||||
this.ws.onclose = () => {
|
||||
this.isConnecting = false;
|
||||
this.stopHeartbeat();
|
||||
this.onCloseCallback?.();
|
||||
|
||||
// 自动重连
|
||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.reconnectAttempts++;
|
||||
setTimeout(() => {
|
||||
this.connect().catch(console.error);
|
||||
}, this.reconnectDelay * this.reconnectAttempts);
|
||||
}
|
||||
};
|
||||
|
||||
// 连接错误
|
||||
this.ws.onerror = (event) => {
|
||||
console.error('WebSocket错误:', event);
|
||||
this.isConnecting = false;
|
||||
const error = new Error('WebSocket连接失败');
|
||||
this.onErrorCallback?.(error);
|
||||
reject(error);
|
||||
};
|
||||
} catch (error) {
|
||||
this.isConnecting = false;
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理接收到的消息
|
||||
*/
|
||||
private handleMessage(data: WebSocketResponse) {
|
||||
switch (data.type) {
|
||||
case 'connected':
|
||||
this.onConnectedCallback?.(data);
|
||||
break;
|
||||
|
||||
case 'response':
|
||||
if (data.data) {
|
||||
this.onMessageCallback?.(data.data);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'user_message':
|
||||
// 用户消息广播(包括自己发送的消息)
|
||||
if (data.data) {
|
||||
this.onMessageCallback?.(data.data);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'pong':
|
||||
// 心跳响应
|
||||
break;
|
||||
|
||||
case 'broadcast':
|
||||
if (data.message) {
|
||||
this.onBroadcastCallback?.(data.message);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
const error = new Error(data.message || '未知错误');
|
||||
this.onErrorCallback?.(error);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn('未知消息类型:', data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息
|
||||
*/
|
||||
public sendMessage(
|
||||
messageChain: Array<{ type: string; text?: string; target?: string }>,
|
||||
) {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
throw new Error('WebSocket未连接');
|
||||
}
|
||||
|
||||
const message = {
|
||||
type: 'message',
|
||||
message: messageChain,
|
||||
};
|
||||
|
||||
this.ws.send(JSON.stringify(message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送心跳
|
||||
*/
|
||||
private sendHeartbeat() {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.ws.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动心跳
|
||||
*/
|
||||
private startHeartbeat() {
|
||||
this.stopHeartbeat();
|
||||
this.heartbeatInterval = setInterval(() => {
|
||||
this.sendHeartbeat();
|
||||
}, this.heartbeatIntervalMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止心跳
|
||||
*/
|
||||
private stopHeartbeat() {
|
||||
if (this.heartbeatInterval) {
|
||||
clearInterval(this.heartbeatInterval);
|
||||
this.heartbeatInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开连接
|
||||
*/
|
||||
public disconnect() {
|
||||
if (this.ws) {
|
||||
this.stopHeartbeat();
|
||||
|
||||
// 停止自动重连
|
||||
this.reconnectAttempts = this.maxReconnectAttempts;
|
||||
|
||||
// 发送断开消息
|
||||
if (this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({ type: 'disconnect' }));
|
||||
}
|
||||
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
this.connectionId = null;
|
||||
this.isConnecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取连接ID
|
||||
*/
|
||||
public getConnectionId(): string | null {
|
||||
return this.connectionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取连接状态
|
||||
*/
|
||||
public isConnected(): boolean {
|
||||
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
// ===== 事件回调设置 =====
|
||||
|
||||
public onConnected(callback: (data: WebSocketResponse) => void) {
|
||||
this.onConnectedCallback = callback;
|
||||
return this;
|
||||
}
|
||||
|
||||
public onMessage(callback: (data: WebSocketMessage) => void) {
|
||||
this.onMessageCallback = callback;
|
||||
return this;
|
||||
}
|
||||
|
||||
public onError(callback: (error: Error) => void) {
|
||||
this.onErrorCallback = callback;
|
||||
return this;
|
||||
}
|
||||
|
||||
public onClose(callback: () => void) {
|
||||
this.onCloseCallback = callback;
|
||||
return this;
|
||||
}
|
||||
|
||||
public onBroadcast(callback: (message: string) => void) {
|
||||
this.onBroadcastCallback = callback;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -61,9 +61,7 @@ export default function Login() {
|
||||
router.push('/register');
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log('error at getIsInitialized: ', err);
|
||||
});
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function checkIfAlreadyLoggedIn() {
|
||||
@@ -75,9 +73,7 @@ export default function Login() {
|
||||
router.push('/home');
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log('error at checkIfAlreadyLoggedIn: ', err);
|
||||
});
|
||||
.catch(() => {});
|
||||
}
|
||||
function onSubmit(values: z.infer<ReturnType<typeof formSchema>>) {
|
||||
handleLogin(values.email, values.password);
|
||||
@@ -89,13 +85,10 @@ export default function Login() {
|
||||
.then((res) => {
|
||||
localStorage.setItem('token', res.token);
|
||||
localStorage.setItem('userEmail', username);
|
||||
console.log('login success: ', res);
|
||||
router.push('/home');
|
||||
toast.success(t('common.loginSuccess'));
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log('login error: ', err);
|
||||
|
||||
.catch(() => {
|
||||
toast.error(t('common.loginFailed'));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -59,9 +59,7 @@ export default function Register() {
|
||||
router.push('/login');
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log('error at getIsInitialized: ', err);
|
||||
});
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function onSubmit(values: z.infer<ReturnType<typeof formSchema>>) {
|
||||
@@ -71,13 +69,11 @@ export default function Register() {
|
||||
function handleRegister(username: string, password: string) {
|
||||
httpClient
|
||||
.initUser(username, password)
|
||||
.then((res) => {
|
||||
console.log('init user success: ', res);
|
||||
.then(() => {
|
||||
toast.success(t('register.initSuccess'));
|
||||
router.push('/login');
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log('init user error: ', err);
|
||||
.catch((err: Error) => {
|
||||
toast.error(t('register.initFailed') + err.message);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -70,13 +70,11 @@ export default function ResetPassword() {
|
||||
setIsResetting(true);
|
||||
httpClient
|
||||
.resetPassword(email, recoveryKey, newPassword)
|
||||
.then((res) => {
|
||||
console.log('reset password success: ', res);
|
||||
.then(() => {
|
||||
toast.success(t('resetPassword.resetSuccess'));
|
||||
router.push('/login');
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log('reset password error: ', err);
|
||||
.catch(() => {
|
||||
toast.error(t('resetPassword.resetFailed'));
|
||||
})
|
||||
.finally(() => {
|
||||
|
||||
@@ -23,8 +23,6 @@ export default function I18nProvider({ children }: I18nProviderProps) {
|
||||
export const extractI18nObject = (i18nObject: I18nObject): string => {
|
||||
// 根据当前语言返回对应的值, fallback优先级:en_US、zh_Hans、zh_Hant、ja_JP
|
||||
const language = i18n.language.replace('-', '_');
|
||||
console.log('language:', language);
|
||||
console.log('i18nObject:', i18nObject);
|
||||
if (language === 'en_US' && i18nObject.en_US) return i18nObject.en_US;
|
||||
if (language === 'zh_Hans' && i18nObject.zh_Hans) return i18nObject.zh_Hans;
|
||||
if (language === 'zh_Hant' && i18nObject.zh_Hant) return i18nObject.zh_Hant;
|
||||
|
||||
@@ -518,6 +518,12 @@ const enUS = {
|
||||
loadPipelinesFailed: 'Failed to load pipelines',
|
||||
atTips: 'Mention the bot',
|
||||
streaming: 'Streaming',
|
||||
connected: 'WebSocket connected',
|
||||
disconnected: 'WebSocket disconnected',
|
||||
connectionError: 'WebSocket connection error',
|
||||
connectionFailed: 'WebSocket connection failed',
|
||||
notConnected: 'WebSocket not connected, please try again later',
|
||||
imageUploadFailed: 'Image upload failed',
|
||||
},
|
||||
},
|
||||
knowledge: {
|
||||
|
||||
@@ -521,6 +521,13 @@ const jaJP = {
|
||||
loadPipelinesFailed: 'パイプラインの読み込みに失敗しました',
|
||||
atTips: 'ボットをメンション',
|
||||
streaming: 'ストリーミング',
|
||||
connected: 'WebSocket接続済み',
|
||||
disconnected: 'WebSocket未接続',
|
||||
connectionError: 'WebSocket接続エラー',
|
||||
connectionFailed: 'WebSocket接続に失敗しました',
|
||||
notConnected:
|
||||
'WebSocketに接続されていません。しばらくしてからやり直してください',
|
||||
imageUploadFailed: '画像のアップロードに失敗しました',
|
||||
},
|
||||
},
|
||||
knowledge: {
|
||||
|
||||
@@ -500,6 +500,12 @@ const zhHans = {
|
||||
loadPipelinesFailed: '加载流水线失败',
|
||||
atTips: '提及机器人',
|
||||
streaming: '流式传输',
|
||||
connected: 'WebSocket已连接',
|
||||
disconnected: 'WebSocket未连接',
|
||||
connectionError: 'WebSocket连接错误',
|
||||
connectionFailed: 'WebSocket连接失败',
|
||||
notConnected: 'WebSocket未连接,请稍后重试',
|
||||
imageUploadFailed: '图片上传失败',
|
||||
},
|
||||
},
|
||||
knowledge: {
|
||||
|
||||
@@ -497,6 +497,13 @@ const zhHant = {
|
||||
loadMessagesFailed: '載入訊息失敗',
|
||||
loadPipelinesFailed: '載入流程線失敗',
|
||||
atTips: '提及機器人',
|
||||
streaming: '串流傳輸',
|
||||
connected: 'WebSocket已連接',
|
||||
disconnected: 'WebSocket未連接',
|
||||
connectionError: 'WebSocket連接錯誤',
|
||||
connectionFailed: 'WebSocket連接失敗',
|
||||
notConnected: 'WebSocket未連接,請稍後重試',
|
||||
imageUploadFailed: '圖片上傳失敗',
|
||||
},
|
||||
},
|
||||
knowledge: {
|
||||
|
||||
Reference in New Issue
Block a user