feat: 对话式首页接口对接,逻辑调整

This commit is contained in:
rd
2025-08-26 17:59:42 +08:00
parent 6211c78c07
commit a125f6f092
12 changed files with 320 additions and 178 deletions

1
env.d.ts vendored
View File

@ -1,4 +1,5 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const vueComponent: DefineComponent<{}, {}, any>;

View File

@ -13,14 +13,16 @@ export default {
type: Boolean,
default: false,
},
rightViewContent: {
type: String,
default: '',
rightViewInfo: {
type: Object,
default: {},
},
},
setup(props, { emit, expose }) {
const bubbleRef = ref(null);
console.log(props.rightViewInfo)
const md = markdownit({
html: true,
breaks: true,
@ -85,7 +87,6 @@ export default {
variant="borderless"
style={{ width: '100%' }}
typing={{ step: 2, interval: 100 }}
content={props.rightViewContent}
onTypingComplete={() => {
console.log('onTypingComplete');
}}

View File

@ -74,6 +74,7 @@ export default {
expose({
focus,
searchValue: computed(() => localSearchValue.value),
});
return () => (

View File

@ -2,6 +2,8 @@ import type { Ref } from 'vue';
import type { BubbleListProps } from '@/components/xt-chat/xt-bubble/types';
// 定义角色常量
export const LOADING_ROLE = 'loading'; // 加载中
export const INTELLECTUAL_THINKING_ROLE = 'intellectual_thinking'; // 智能思考标题
export const QUESTION_ROLE = 'question';
export const ANSWER_ROLE = 'text';
export const FILE_ROLE = 'file';
@ -23,9 +25,16 @@ export const ANSWER_STYLE = {
export interface UseChatHandlerReturn {
roles: BubbleListProps['roles'];
currentTaskId: Ref<string | null>;
handleMessage: (parsedData: { event: string; data: any }) => void;
handleMessage: (parsedData: { event: string; data: MESSAGE.Answer }) => void;
handleOpen: (data: Response) => void;
generateLoading: Ref<boolean>;
conversationList: Ref<any[]>;
showRightView: Ref<boolean>;
rightViewContent: Ref<string>;
rightViewInfo: Ref<any>;
senderRef: Ref<null>
}
export enum EnumTeamRunStatus {
TeamRunStarted = 'TeamRunStarted', // 开始
TeamRunResponseContent = 'TeamRunResponseContent', // 执行中
TeamRunCompleted = 'TeamRunCompleted', // 完成
}

View File

@ -6,12 +6,11 @@ import { Typography } from 'ant-design-vue';
import RightView from './components/right-view/index.vue';
import { useRoute } from 'vue-router';
import { genRandomId } from '@/utils/tools';
import { useChatStore } from '@/stores/modules/chat';
import querySSE from '@/utils/querySSE';
import useChatHandler from './useChatHandler';
import { QUESTION_ROLE, ANSWER_ROLE } from './constants';
import { QUESTION_ROLE, LOADING_ROLE } from './constants';
export default {
props: {
@ -25,12 +24,9 @@ export default {
const route = useRoute();
const senderRef = ref(null);
const rightViewRef = ref(null);
const bubbleListRef = ref<any>(null);
const { roles, showRightView, rightViewContent, currentTaskId, handleMessage, conversationList, generateLoading } =
useChatHandler();
const sseController = ref<any>(null);
const conversationId = computed(() => {
return route.params.conversationId;
@ -41,42 +37,49 @@ export default {
antdMessage.warning('停止生成后可发送');
return;
}
conversationList.value.push({
role: QUESTION_ROLE,
content: message,
});
initSse({ message });
};
const handleCancel = () => {
generateLoading.value = false;
// 中止当前正在输出的回答
if (currentTaskId.value && bubbleListRef.value?.abortTypingByKey) {
bubbleListRef.value.abortTypingByKey(currentTaskId.value);
if (generateLoading.value) {
bubbleListRef.value?.abortTypingByKey(currentTaskId.value);
sseController.value?.abort?.();
}
if (showRightView.value) {
rightViewRef.value?.abortTyping?.();
}
generateLoading.value = false;
antdMessage.info('取消生成');
};
const initSse = (inputInfo: CHAT.TInputInfo) => {
const initSse = (inputInfo: CHAT.TInputInfo): void => {
if (sseController.value) {
sseController.value.abort?.();
sseController.value = null;
}
try {
const { message } = inputInfo;
generateLoading.value = true;
conversationList.value.push({
role: QUESTION_ROLE,
content: message,
sseController.value = querySSE({
method: 'POST',
handleMessage,
handleOpen,
body: JSON.stringify({
content: message,
session_id: conversationId.value,
agent_id: chatStore.agentInfo.agent_id,
}),
});
const taskId = genRandomId();
currentTaskId.value = taskId;
const url = `http://localhost:3000/agent/input?content=${message}&session_id=${conversationId.value}&agent_id=${chatStore.agentInfo?.agent_id}`;
querySSE(
{
handleMessage,
},
url,
);
} catch (error) {
console.error('Failed to initialize SSE:', error);
antdMessage.error('初始化连接失败');
@ -84,10 +87,30 @@ export default {
}
};
const {
roles,
showRightView,
rightViewInfo,
currentTaskId,
handleMessage,
handleOpen,
conversationList,
generateLoading,
senderRef,
} = useChatHandler({
initSse,
});
watch(
() => props.inputInfo,
(newVal) => {
newVal && initSse(newVal);
if (newVal) {
conversationList.value.push({
role: QUESTION_ROLE,
content: newVal,
});
initSse(newVal);
}
},
{ deep: true },
);
@ -101,14 +124,14 @@ export default {
roles={roles}
items={[
...conversationList.value,
generateLoading.value ? { loading: true, role: ANSWER_ROLE } : null,
generateLoading.value ? { loading: true, role: LOADING_ROLE } : null,
].filter(Boolean)}
/>
</div>
<div class="w-full flex flex-col justify-center items-center">
<SenderInput
class="w-600px"
ref={senderRef}
class="w-600px"
placeholder="继续追问..."
loading={generateLoading.value}
onSubmit={handleSubmit}
@ -122,7 +145,7 @@ export default {
{showRightView.value && (
<RightView
ref={rightViewRef}
rightViewContent={rightViewContent.value}
rightViewInfo={rightViewInfo.value}
showRightView={showRightView.value}
onClose={() => (showRightView.value = false)}
/>

View File

@ -1,36 +1,46 @@
import { ref, } from 'vue';
import { ref } from 'vue';
import type { BubbleListProps } from '@/components/xt-chat/xt-bubble/types';
import markdownit from 'markdown-it';
import { message as antdMessage } from 'ant-design-vue';
import { IconFile, IconCaretUp, IconDownload, IconRefresh } from '@arco-design/web-vue/es/icon';
import { message as antdMessage, Timeline } from 'ant-design-vue';
import { IconFile, IconCaretUp, IconDownload, IconRefresh, IconCopy } from '@arco-design/web-vue/es/icon';
import { Tooltip } from 'ant-design-vue';
import TextOverTips from '@/components/text-over-tips/index.vue';
import { genRandomId } from '@/utils/tools';
import icon1 from "@/assets/img/agent/icon-end.png"
import icon2 from "@/assets/img/agent/icon-loading.png"
import icon1 from '@/assets/img/agent/icon-end.png';
import icon2 from '@/assets/img/agent/icon-loading.png';
import { useClipboard } from '@vueuse/core';
import { QUESTION_ROLE, ANSWER_ROLE, FILE_ROLE, THOUGHT_ROLE, ROLE_STYLE, ANSWER_STYLE } from './constants';
import type { UseChatHandlerReturn } from "./constants"
import {
QUESTION_ROLE,
ANSWER_ROLE,
INTELLECTUAL_THINKING_ROLE,
FILE_ROLE,
LOADING_ROLE,
THOUGHT_ROLE,
ROLE_STYLE,
EnumTeamRunStatus,
} from './constants';
import type { UseChatHandlerReturn } from './constants';
/**
* 聊天处理器Hook
* @returns 包含角色配置、消息处理函数和对话列表的对象
/**
* 聊天处理器Hook
* @returns 包含角色配置、消息处理函数和对话列表的对象
*/
export default function useChatHandler(): UseChatHandlerReturn {
export default function useChatHandler({ initSse }): UseChatHandlerReturn {
// 在内部定义对话列表
const { copy } = useClipboard();
const senderRef = ref(null);
const conversationList = ref<any[]>([]);
const generateLoading = ref<boolean>(false);
const currentTaskId = ref<string | null>(null);
const showRightView = ref(false);
const rightViewContent = ref('');
const rightViewInfo = ref<any>({});
// 初始化markdown
// 初始化markdown
const md = markdownit({
html: true,
breaks: true,
@ -38,8 +48,19 @@ export default function useChatHandler(): UseChatHandlerReturn {
typographer: true,
});
// 定义角色配置
// 定义角色配置
const roles: BubbleListProps['roles'] = {
[LOADING_ROLE]: {
placement: 'start',
variant: 'borderless',
style: { ...ROLE_STYLE, paddingLeft: '12px' },
},
[INTELLECTUAL_THINKING_ROLE]: {
placement: 'start',
variant: 'borderless',
typing: { step: 2, interval: 100 },
style: ROLE_STYLE,
},
[ANSWER_ROLE]: {
placement: 'start',
variant: 'borderless',
@ -47,7 +68,7 @@ export default function useChatHandler(): UseChatHandlerReturn {
onTypingComplete: () => {
currentTaskId.value = null;
},
style: ROLE_STYLE
style: ROLE_STYLE,
},
[FILE_ROLE]: {
placement: 'start',
@ -67,24 +88,23 @@ export default function useChatHandler(): UseChatHandlerReturn {
</div>
));
},
style: ROLE_STYLE
style: ROLE_STYLE,
},
[THOUGHT_ROLE]: {
placement: 'start',
variant: 'borderless',
style: ROLE_STYLE
style: ROLE_STYLE,
},
[QUESTION_ROLE]: {
placement: 'end',
shape: 'round',
style: ROLE_STYLE
style: ROLE_STYLE,
},
};
// 下载处理
const onDownload = (content: string) => {
console.log('onDownload', content);
// 这里可以添加实际的下载逻辑
// 下载处理
const onDownload = () => {
console.log('onDownload', rightViewInfo.value);
};
const onCopy = (content: string) => {
@ -92,123 +112,161 @@ export default function useChatHandler(): UseChatHandlerReturn {
antdMessage.success('复制成功!');
};
// 开始处理
const handleStart = (data: any) => {
const { run_id } = data;
conversationList.value.push({
run_id,
role: ANSWER_ROLE,
content: (
<div class="flex items-center">
<span class="font-family-medium color-#211F24 text-14px font-400 lh-22px mr-4px"></span>
<IconCaretUp size={16} class="color-#211F24" />
</div>
),
});
// 开始处理
const handleOpen = (data: any): void => {
// const { run_id } = data;
// currentTaskId.value = run_id;
// conversationList.value.push({
// key: run_id,
// run_id,
// role: INTELLECTUAL_THINKING_ROLE,
// content: (
// <div class="flex items-center">
// <span class="font-family-medium color-#211F24 text-14px font-400 lh-22px mr-4px">智能思考</span>
// <IconCaretUp size={16} class="color-#211F24" />
// </div>
// ),
// });
};
// 节点更新处理
const handleNodeUpdate = (data: any) => {
const { run_id, status, output } = data;
switch (status) {
case 'TeamRunResponseContent':
conversationList.value.push({
run_id,
content: data,
role: ANSWER_ROLE,
messageRender: (item) => (
<div class='flex items-center'>
<img src={icon2} width={13} height={13} class="mr-4px" />
<span>{item.message}</span>
</div>
)
});
break;
case 'TeamRunCompleted':
conversationList.value.push({
run_id,
content: output,
role: ANSWER_ROLE,
messageRender: (content: string) => <div v-html={md.render(content)}></div>,
style: ANSWER_STYLE
});
break;
}
};
// 最终结果处理
const handleFinalResult = (data: any) => {
// 最终结果处理
const handleFileReview = (data: MESSAGE.Answer) => {
const { run_id, output } = data;
showRightView.value = true;
const _files = output?.files;
rightViewContent.value = _files?.[0]?.content || '';
// const _files = output?.files;
// rightViewInfo.value = _files?.[0]?.content || '';
conversationList.value.push({
run_id,
id: currentTaskId.value,
role: FILE_ROLE,
content: _files,
style: ANSWER_STYLE,
footer: ({ item }: { item: any }) => {
const nonQuestionElements = conversationList.value.filter((item) => item.role !== QUESTION_ROLE);
const isLastAnswer = nonQuestionElements[nonQuestionElements.length - 1]?.id === item.id;
// return (
// <div class="flex items-center">
// <Tooltip title="下载" onClick={() => onDownload(rightViewContent?.value || '')}>
// <IconDownload size={16} class="color-#737478 cursor-pointer" />
// </Tooltip>
// {isLastAnswer && onRefresh && (
// <Tooltip title="重新生成" onClick={() => onRefresh(currentTaskId.value!, conversationList.value.length)}>
// <IconRefresh size={16} class="color-#737478 cursor-pointer" />
// </Tooltip>
// )}
// </div>
// );
},
});
// conversationList.value.push({
// run_id,
// role: FILE_ROLE,
// content: _files,
// style: ANSWER_STYLE,
// footer: ({ item }: { item: any }) => {
// const nonQuestionElements = conversationList.value.filter((item) => item.role !== QUESTION_ROLE);
// const isLastAnswer = nonQuestionElements[nonQuestionElements.length - 1]?.id === item.id;
// },
// });
};
// 重置生成状态
// 重置生成状态
const resetGenerateStatus = () => {
generateLoading.value = false;
};
// 错误处理
const handleError = () => {
resetGenerateStatus();
antdMessage.error('连接服务器失败');
};
// // 错误处理
// const handleError = () => {
// resetGenerateStatus();
// antdMessage.error('连接服务器失败');
// };
const onRefresh = (tempId: string, tempIndex: number) => {
generateLoading.value = true;
conversationList.value.splice(tempIndex, 1, {
id: tempId,
loading: true,
const handleTaskStart = (data: MESSAGE.Answer) => {
const { run_id } = data;
currentTaskId.value = run_id;
conversationList.value.push({
run_id,
content: data,
output: data.output,
role: ANSWER_ROLE,
messageRender: (data: MESSAGE.Answer) => {
let outputEleClass: string = `thought-chain-output border-l-#E6E6E8 border-l-1px pl-12px relative left-6px mb-4px`;
!isLastAnswer(data) && (outputEleClass += ' hasLine pb-12px pt-4px');
return (
<>
<div class="flex items-center">
<span class="font-family-medium color-#211F24 text-14px font-400 lh-22px mr-4px"></span>
<IconCaretUp size={16} class="color-#211F24" />
</div>
<div class="relative thought-chain-item">
<div class="flex items-center mb-4px">
<img src={icon2} width={13} height={13} class="mr-4px" />
<div>{data.node}</div>
</div>
<div v-html={md.render(data.output ?? '')} class={outputEleClass} />
</div>
</>
);
},
});
};
// 消息处理主函数
const handleMessage = (parsedData: { event: string; data: any }) => {
const { event, data } = parsedData;
switch (event) {
case 'start':
handleStart(data);
const onRefresh = (run_id: string) => {
generateLoading.value = true;
conversationList.value = conversationList.value.filter((item) => item.run_id !== run_id);
initSse({ message: senderRef.value?.searchValue });
};
const isLastAnswer = (data: MESSAGE.Answer) => {
const { run_id } = data;
const nonQuestionElements = conversationList.value.filter(
(item) => item.role === ANSWER_ROLE && item.run_id === run_id,
);
return nonQuestionElements[nonQuestionElements.length - 1]?.run_id === run_id;
};
const isLastTask = (data: MESSAGE.Answer) => {
const { run_id } = data;
const lastElement = conversationList.value[conversationList.value.length - 1];
return lastElement && lastElement.run_id === run_id;
};
// 节点更新处理
const handleTaskUpdate = (data: MESSAGE.Answer) => {
const { run_id, output } = data;
const existingItemIndex = conversationList.value.findIndex((item) => item.run_id === run_id);
if (existingItemIndex !== -1 && output) {
const existingItem = conversationList.value[existingItemIndex];
existingItem.content.output += output;
}
};
const handleTaskEnd = (data: MESSAGE.Answer) => {
const { run_id, output, team_session_state } = data;
const existingItem = conversationList.value.find((item) => item.run_id === run_id);
resetGenerateStatus();
if (existingItem) {
existingItem.content.output += output;
existingItem.footer = ({ item: any }) => {
return (
<div class="flex items-center">
<Tooltip title="复制" onClick={() => onCopy(existingItem.content.output)}>
<IconCopy size={16} class="color-#737478 cursor-pointer mr-12px" />
</Tooltip>
<Tooltip title="下载" onClick={onDownload}>
<IconDownload size={16} class="color-#737478 cursor-pointer mr-12px" />
</Tooltip>
{isLastTask(data) && (
<Tooltip title="重新生成" onClick={() => onRefresh(run_id)}>
<IconRefresh size={16} class="color-#737478 cursor-pointer" />
</Tooltip>
)}
</div>
);
};
}
if (team_session_state) {
handleFileReview(data);
}
};
// 消息处理主函数
const handleMessage = (parsedData: { event: string; data: MESSAGE.Answer }) => {
const { data } = parsedData;
const { status } = data;
switch (status) {
case EnumTeamRunStatus.TeamRunStarted:
handleTaskStart(data);
break;
case 'node_update':
handleNodeUpdate(data);
case EnumTeamRunStatus.TeamRunResponseContent:
handleTaskUpdate(data);
break;
case 'final_result':
handleFinalResult(data);
break;
case 'end':
resetGenerateStatus();
break;
case 'error':
handleError();
case EnumTeamRunStatus.TeamRunCompleted:
handleTaskEnd(data);
break;
default:
break;
@ -217,11 +275,13 @@ export default function useChatHandler(): UseChatHandlerReturn {
return {
roles,
senderRef,
currentTaskId,
handleMessage,
handleOpen,
generateLoading,
conversationList,
showRightView,
rightViewContent,
rightViewInfo,
};
}
}

View File

@ -28,6 +28,27 @@
flex-direction: row-reverse;
}
.thought-chain-item {
position: relative;
margin: 0;
font-size: 14px;
list-style: none;
.thought-chain-output {
position: relative;
&.hasLine {
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-left: 1px solid #E6E6E8;
}
}
}
}
.xt-bubble-avatar {
}

View File

@ -105,6 +105,7 @@ export default defineComponent({
// 暴露控制方法
const abortTypingByKey = (key: string | number) => {
bubbleRefs.value[key]?.abortTyping?.();
console.log('abortTypingByKey----', bubbleRefs.value[key])
};
// 对外暴露能力
expose({

View File

@ -1,7 +1,9 @@
declare global {
namespace CHAT {
export type TInputInfo = {
type TInputInfo = {
message: string;
};
}
}
export default {};

19
src/types/message.ts Normal file
View File

@ -0,0 +1,19 @@
declare global {
namespace MESSAGE {
type TASK_STATUS = 'TeamRunStarted' | 'TeamRunResponseContent' | 'TeamRunCompleted';
interface Answer {
message: string;
node: string;
output: string;
run_id: string;
status: TASK_STATUS;
extra_data: {
type: string,
data: Record<string, any>
},
team_session_state: any
}
}
}
export default {};

View File

@ -3,8 +3,10 @@ import type { EventSourceMessage } from '@microsoft/fetch-event-source';
import { useEnterpriseStore } from '@/stores/modules/enterprise';
import { glsWithCatch } from '@/utils/stroage';
const customHost = 'http://localhost:3000';
const DEFAULT_SSE_URL = `${customHost}/agent/input`;
const customHost = 'http://192.168.40.41:8001';
//
// const customHost = 'http://localhost:3000';
const DEFAULT_SSE_URL = `${customHost}/api/agent/runs`;
const SSE_HEADERS = {
'Content-Type': 'application/json',
@ -20,26 +22,30 @@ interface SSEConfig {
handleMessage?: (data: any) => void;
handleError?: (err: any) => number | null | undefined | void;
handleClose?: () => void;
handleOpen?: (response: Response) => Promise<void>;
handleOpen?: (response: Response) => void;
}
/**
* 创建服务器发送事件SSE连接
* @param config SSE 配置
* @param url 可选的自定义 URL
* @returns 包含abort方法的对象用于中断SSE连接
*/
export default async (config: SSEConfig, url: string = DEFAULT_SSE_URL): Promise<void> => {
export default async (config: SSEConfig, url: string = DEFAULT_SSE_URL): Promise<{ abort: () => void }> => {
const {
body = undefined,
headers = {},
method = 'get',
method = 'post',
handleMessage,
handleError,
handleOpen,
handleClose,
} = config;
const store = useEnterpriseStore();
// 创建AbortController实例用于中断请求
const abortController = new AbortController();
fetchEventSource(url, {
method,
// credentials: 'include',
@ -50,6 +56,7 @@ export default async (config: SSEConfig, url: string = DEFAULT_SSE_URL): Promise
...headers,
},
body,
signal: abortController.signal, // 传递signal给fetchEventSource
openWhenHidden: true, // 用户切换到另一个页面后仍能保持SSE连接
onmessage(event: EventSourceMessage) {
if (event.data) {
@ -75,4 +82,9 @@ export default async (config: SSEConfig, url: string = DEFAULT_SSE_URL): Promise
handleOpen?.(response);
},
});
// 返回abort方法供外部调用
return {
abort: () => abortController.abort()
};
};

View File

@ -21,21 +21,13 @@ export default {
};
const tagList = [
'人工智能',
'人工智能与应用',
'行业分析与市场数据',
'标签标签标签标签标签标签标签',
'标签标签标签标签标签标签标签',
'标签标签标签标签标签标签标签',
'标签标签标签标签标签标签标签',
'标签A',
'啊啊啊',
'宝宝贝贝',
'微信',
'吧啊啊',
'哦哦哦哦哦哦哦哦',
'人工智能',
'人工智能与应用',
'汇总账号昨天的运营情况',
'查账号本月点赞量 Top3 的笔记详情',
'统计投流账户上周的消耗金额 + 点击率',
'把昨天漏采数据的账号重新抓取一次数据',
'规划账号未来 2 周的内容发布排期和选题',
'根据账号的已有选题生成具体内容稿件',
'根据热点生成账号的选题及内容',
];
const handleTagClick = (tag: string) => {