<script setup lang="ts">
import '@opentiny/tiny-robot/dist/style.css';
import {
TrBubbleList,
TrSender,
TrBubbleProvider,
BubbleMarkdownContentRenderer,
} from '@opentiny/tiny-robot';
import { AIClient, GeneratingStatus, STATUS, type ChatMessage } from '@opentiny/tiny-robot-kit';
import { IconAi, IconUser, IconArrowDown } from '@opentiny/tiny-robot-svgs';
import type {
BubbleRoleConfig,
BubbleCommonProps,
BubbleContentItem,
UserItem,
UserTextItem,
} from '@opentiny/tiny-robot';
import { ref, watch, computed, h, inject, provide } from 'vue';
import type { Ref, Component } from 'vue';
import { CustomModelProvider } from './CustomModelProvider';
import { scrollEnd, throttle, toSlotFunction } from './chat-utils';
import { useFileUpload } from './useFileUpload';
import AttachmentsRenderer from './renderer/AttachmentsRenderer.vue';
import TemplateDataRenderer from './renderer/TemplateDataRenderer.vue';
import ReasoningRenderer from './renderer/ReasoningRenderer.vue';
import ToolRenderer from './renderer/ToolRenderer.vue';
import { type FileMeta, MIME_TYPE_MAP } from './file-upload/file-utils';
import { cardIdSymbol } from './useChat';
import { emitter } from './event-emitter';
import type { IChatProps, ICustomActionItem, IRolesConfig } from './chat.types';
import GeneratingComponent from './GeneratingComponent.vue';
import { useChatAction } from './continue-chat-action';
import type { IMessageItem, IStreamData } from '@opentiny/genui-sdk-core';
import type { IRendererProps } from '../renderer';
import { GenuiRenderer } from '../renderer';
import ErrorText from './ErrorText.vue';
import { useResize } from './composable/use-resize';
import { useConversation } from './tiny-robot-patch/useConversation';
import { useI18n } from './i18n';
import { GENUI_CONFIG, CUSTOM_CONTEXT } from './injection-tokens';
import { IResponseHandler, defaultResponseHandlers } from './response-handler';
const props = defineProps<IChatProps>();
const genuiConfig: any = inject(GENUI_CONFIG, null);
const { t } = useI18n();
const isAllowFiles = computed(() => {
const supportImage = props.features?.supportImage;
if (supportImage && supportImage?.enabled !== false) {
return true;
}
return false;
});
const buttonGroup = computed(() => {
const fileTypes = props.features?.supportImage?.supportedFileTypes;
const accept = fileTypes?.map((type: string) => MIME_TYPE_MAP[type.toLowerCase()]).join(',');
return {
file: {
disabled: false,
accept,
},
};
});
// 定义角色图标以及样式
const defaultRoles: { user: BubbleRoleConfig; assistant: BubbleRoleConfig } = {
assistant: {
placement: 'start',
avatar: h(IconAi, { style: { fontSize: '32px' } }),
maxWidth: '100%',
customContentField: 'messages',
},
user: {
placement: 'end',
maxWidth: '90%',
avatar: h(IconUser, { style: { fontSize: '32px' } }),
customContentField: 'messages',
},
};
const wrapSlots = (slots: any) => {
if (!slots) {
return;
}
const newSlots: Record<string, any> = {};
Object.keys(slots).forEach((key) => {
newSlots[key] = (props: any) => {
const isFinished = computed(() => {
if (props.bubbleProps.role !== 'assistant') {
return true;
}
return props.index !== messageManager.value.messages.value.length - 1 || !generating.value;
});
const slotFn = toSlotFunction(slots[key]);
if (slotFn) {
return slotFn({ ...props, isFinished: isFinished.value, messageManager: messageManager.value, chatMessage: messageManager.value.messages.value[props.index] });
}
return null;
};
});
return newSlots;
};
const roles = computed(() => {
const mergedRoles = { ...defaultRoles };
for (const keyAny in props.roles) {
const key = keyAny as keyof IRolesConfig;
mergedRoles[key] = {
...defaultRoles[key],
...props.roles[key],
slots: wrapSlots(props.roles[key]?.slots),
};
}
return mergedRoles;
});
const flatAllMessages = (messages: ChatMessage[]) =>
messages
.filter((item) => item.role === 'assistant')
.reduce((acc: IMessageItem[], chatItem) => {
const itemMessages = (chatItem as { messages?: IMessageItem[] }).messages;
if (Array.isArray(itemMessages)) {
return acc.concat(...itemMessages);
}
return acc;
}, []);
const getCardMessage = (cardId: string) => {
const flatMessages = flatAllMessages(messages.value);
return flatMessages.find((message: IMessageItem) => 'id' in message && message.id === cardId);
};
const saveState = (context: Record<string | symbol, any>) => {
const cardId = context[cardIdSymbol];
const cardMessage = getCardMessage(cardId);
if (cardMessage) {
(cardMessage as any).state = JSON.parse(JSON.stringify(context.state || {}));
}
saveConversations();
};
const chat = ({ llmFriendlyMessage, humanFriendlyMessage, context }: any) => {
saveState(context);
messageManager.value.addMessage({
role: 'user',
content: llmFriendlyMessage,
messages: [{ type: 'text', content: humanFriendlyMessage }],
});
messageManager.value.send();
};
const customContext = computed(() => {
return {
chat,
generating: generating.value,
};
});
provide(CUSTOM_CONTEXT, customContext);
const { continueChatAction, saveStateAction } = useChatAction({chat, saveState}); //TODO: Refactor
const generating = computed(() => GeneratingStatus.includes(messageManager.value.messageState.status));
const markdownRenderer = new BubbleMarkdownContentRenderer({
defaultAttrs: { class: 'markdown-content' },
mdConfig: { html: true },
});
const lastSchemaCardId = computed(() => {
const lastChatMessage = messages.value[messages.value.length - 1];
if (lastChatMessage?.role !== 'assistant') {
return null;
}
const items = lastChatMessage?.messages;
if (!Array.isArray(items) || !items?.length) {
return null;
}
return items[items.length - 1].id;
});
const messageRenderers = {
'custom-text': (props: BubbleCommonProps & { content: string }) =>
h('span', { class: 'tr-bubble__body-text' }, props.content),
'schema-card': (schemaCardProps: IRendererProps) => {
const customComponentsMap: Record<string, Component> = {};
if (props.customComponents) {
props.customComponents.forEach((item) => {
if (item.ref && item.component) {
customComponentsMap[item.component] = item.ref;
}
});
}
// 将 customActions 数组转换为对象格式
const customActionsMap: Record<string, ICustomActionItem> = {};
if (props.customActions) {
props.customActions.forEach((action) => {
if (action.name) {
customActionsMap[action.name] = action;
}
});
}
return h(
'div',
{},
h(
GenuiRenderer,
{
...schemaCardProps,
requiredCompleteFieldSelectors: props.requiredCompleteFieldSelectors || [],
generating: lastSchemaCardId.value === schemaCardProps.id ? generating.value : false,
customComponents: customComponentsMap,
customActions: {
...customActionsMap,
continueChat: continueChatAction,
saveState: saveStateAction,
},
key: schemaCardProps.id,
},
{
header: toSlotFunction(props.rendererSlots?.header),
footer: toSlotFunction(props.rendererSlots?.footer),
},
),
);
},
tool: ToolRenderer,
reasoning: ReasoningRenderer,
markdown: markdownRenderer,
templateData: TemplateDataRenderer,
'loading-text': props.thinkComponent || GeneratingComponent,
'error-text': ErrorText,
};
const responseHandlers: Ref<IResponseHandler<IStreamData>[]> = ref(defaultResponseHandlers);
const customModelProvider = new CustomModelProvider({
getChatOptions: () => ({
url: props.url,
model: props.model || '',
temperature: props.temperature ?? 0.3,
chatConfig: props.chatConfig || { addToolCallContext: false, showThinkingResult: false },
customComponents: props.customComponents || [],
customSnippets: props.customSnippets || [],
customExamples: props.customExamples || [],
customActions: [...(props.customActions || []), continueChatAction, saveStateAction],
customFetch: props.customFetch,
}),
});
customModelProvider.setResponseHandlers(responseHandlers.value);
const client = new AIClient({
provider: 'custom',
providerImplementation: customModelProvider,
});
let conversation = useConversation({
client,
autoSave: false,
events: {
onReceiveData(data, messages, preventDefault) {
messages.value.push(data as any);
preventDefault();
},
onLoaded(conversations) {
if (!conversations.length) {
createConversation();
saveConversations();
}
// 如果通过 props.messages 传入了初始消息,则在会话加载完成后覆盖当前会话的消息
if (props.messages?.length) {
const currentMessages = messageManager.value.messages.value;
currentMessages.splice(0, currentMessages.length, ...(props.messages as any));
}
},
onFinish(data: any, context) {
if (data?.type === 'error') {
context.messages.value.push({
role: 'assistant',
content: '',
messages: [{ type: 'error-text', content: data.error.message }],
});
}
saveConversations();
},
},
});
const { messageManager, createConversation, updateTitle, state: conversationState, saveConversations } = conversation;
const messages = computed(() => messageManager.value.messages.value);
const inputMessage = computed({
get: () => messageManager.value.inputMessage.value,
set: (v: string) => {
messageManager.value.inputMessage.value = v;
},
});
const setInputMessage = (message: string) => {
inputMessage.value = message;
}
if (props.messages?.length) {
messages.value.splice(0, messages.value.length, ...(props.messages as any));
}
const { attachments, templateData, clearAttachments, processAttachments, handleFilesSelected, handleTemplateEdit } =
useFileUpload();
const handleTemplateDataUpdate = (value: UserItem[]) => {
// 使用 handleTemplateEdit 处理 template 编辑,保持 templateData 和 attachments 同步
const updatedTemplateData = handleTemplateEdit(templateData, inputMessage.value);
inputMessage.value = '';
templateData.value = updatedTemplateData;
};
const showMessages = computed(() => {
let showMessages = messages.value;
if (messageManager.value.messageState.status === STATUS.PROCESSING) {
return [
...showMessages,
{
role: 'assistant',
content: t('loading.thinking'),
loading: true,
},
];
}
const lastMessage = messages.value[messages.value.length - 1];
// 在流式返回过程中,为最后一条助手消息添加 loading-text 组件
if (generating.value && lastMessage?.role === 'assistant') {
const existingMessages = Array.isArray((lastMessage as any)?.messages) ? (lastMessage as any).messages : [];
// 检查是否已经存在 loading-text,避免重复添加
const hasLoadingText = existingMessages.some((msg: any) => msg.type === 'loading-text');
if (!hasLoadingText) {
return [
...showMessages.slice(0, -1),
{
...lastMessage,
messages: [
...existingMessages,
{
type: 'loading-text',
emitter: emitter,
message: lastMessage,
showThinkingResult: props.chatConfig?.showThinkingResult,
},
],
},
];
}
}
return showMessages;
});
const setConversationTitle = (messageContent: string) => {
const currentTitle = conversationState.conversations.find(
(conversation) => conversation.id === conversationState.currentId,
)?.title;
const DEFAULT_TITLE = t('conversation.newConversation');
if (currentTitle === DEFAULT_TITLE && conversationState.currentId) {
const contentStr = typeof messageContent === 'string' ? messageContent : JSON.stringify(messageContent);
updateTitle(conversationState.currentId, contentStr.substring(0, 20));
}
};
const clearInputMessage = () => {
inputMessage.value = '';
templateData.value = [];
clearAttachments();
};
const handleRemoveAttachment = (item: FileMeta | undefined) => {
if (!item) return;
attachments.value = attachments.value.filter((attachment) => item.name !== attachment.name);
templateData.value = templateData.value.filter((data) => data.type !== 'template' || data.content !== item.name);
};
const handleSendMessage = async (ipt: string) => {
const messageContent = ipt;
const userMessageContent: BubbleContentItem[] = [];
let apiContent: any[] = [];
const attachmentsValue = attachments.value.slice();
const templateDataValue = templateData.value.slice();
const userMessage: ChatMessage = {
role: 'user',
content: messageContent,
};
messages.value.push(userMessage);
if (attachmentsValue.length > 0) {
const result = await processAttachments(attachmentsValue, props.features || {});
if (!result) {
messageManager.value.send();
scrollToBottom();
return;
}
apiContent = templateDataValue.map((templateItem: UserItem) => {
if (templateItem.type === 'template') {
return result.apiContent.find((att: any) => att.filename === templateItem.content);
} else {
return {
type: 'text',
text: (templateItem as UserTextItem).content,
};
}
});
// 添加 templateData 类型的消息项,用于渲染
if (templateDataValue.length > 0) {
userMessageContent.push({
type: 'templateData',
templateData: templateDataValue,
attachments: attachmentsValue,
});
}
userMessage.content = apiContent;
userMessage.messages = userMessageContent;
}
messageManager.value.send();
clearInputMessage();
setConversationTitle(messageContent);
saveConversations();
scrollToBottom();
};
const handleNewConversation = () => {
createConversation();
saveConversations();
};
const abortRequest = () => {
messageManager.value.abortRequest();
saveConversations();
};
const messagesContainer: Ref<HTMLElement | undefined> = ref();
const { width: messagesContainerWidth } = useResize(messagesContainer);
const { scrollToBottom, scrollToBottomWithRetry, autoScrollToBottom, isLastMessageInBottom } =
scrollEnd(messagesContainer);
// 使用节流包装 scrollToBottom,延迟 400ms
const throttledScrollToBottom = throttle(autoScrollToBottom, 400);
// 最新消息滚动到底部
watch(() => messages.value, throttledScrollToBottom, { deep: true });
watch(
() => conversationState.currentId,
() => {
// 切换会话, 使用带重试机制的滚动函数,确保在 DOM 完全渲染后滚动到底部
scrollToBottomWithRetry(10, 150);
},
);
defineExpose({
setInputMessage,
handleNewConversation,
// @experimental
getProps: () => props,
getConversation: () => conversation,
// experimental, not stable
getResponseHandlers: () => responseHandlers.value,
// experimental, not stable
setResponseHandlers: (handlers: IResponseHandler<IStreamData>[]) => {
responseHandlers.value = handlers;
customModelProvider.setResponseHandlers(handlers);
},
getMessageRenderers: () => messageRenderers,
setMessageRenderer: (key: string, renderer: Component<IRendererProps>) => {
messageRenderers[key] = renderer;
},
generating,
// @experimental
lastSchemaCardId,
continueChatAction,
saveStateAction,
});
</script>
<template>
<div
class="tg-chat-container"
:class="{ 'dark': genuiConfig?.theme === 'dark' }"
:style="!props.chatConfig?.showThinkingResult ? { '--thinking-display': 'none' } : {}"
>
<div
class="messages-container"
ref="messagesContainer"
:style="{ '--messages-container-width': messagesContainerWidth + 'px' }"
>
<tr-bubble-provider :content-renderers="messageRenderers" v-if="showMessages.length">
<tr-bubble-list :items="showMessages" :roles="roles" auto-scroll> </tr-bubble-list>
</tr-bubble-provider>
<slot v-else name="empty"></slot>
</div>
<div class="sender-container">
<!-- TODO: 抽离到组件 -->
<div
:class="['scroll-to-bottom-button', { 'is-generating': generating }]"
v-show="!isLastMessageInBottom"
@click="scrollToBottom"
>
<IconArrowDown class="icon-arrow-down" />
</div>
<tr-sender
v-model="inputMessage"
:placeholder="
GeneratingStatus.includes(messageManager.messageState.status)
? t('placeholder.thinking')
: t('placeholder.input')
"
:clearable="true"
:allow-files="isAllowFiles"
:buttonGroup="buttonGroup"
:loading="GeneratingStatus.includes(messageManager.messageState.status)"
@files-selected="(files) => handleFilesSelected(files, inputMessage)"
v-model:template-data="templateData"
@update:template-data="handleTemplateDataUpdate"
:showWordLimit="true"
:maxLength="1000"
@clear="clearInputMessage"
@submit="handleSendMessage"
@cancel="abortRequest"
>
<template #header v-if="attachments.length > 0">
<div class="attachments-container">
<AttachmentsRenderer :attachments="attachments" @remove="handleRemoveAttachment" />
</div>
</template>
</tr-sender>
<div class="footer-text">{{ t('footer.aiGenerated') }}</div>
</div>
</div>
</template>
<style scoped lang="less">
.tg-chat-container {
--ti-gen-chat-container-bg-color: #f0f0f0;
--thinking-display: initial;
--sender-bg: url('./assets/sender-light.svg') no-repeat center;
--sender-border-color: #e5e5e5;
--generating-bg-before: linear-gradient(90deg, #fff, #a2c7f4);
--generating-bg-after: #fff;
box-sizing: border-box;
height: 100%;
color: var(--tr-text-primary);
background-color: var(--ti-gen-chat-container-bg-color);
position: relative;
display: flex;
flex-direction: column;
overflow: auto;
&.dark {
--ti-gen-chat-container-bg-color: #191919;
--sender-bg: url('./assets/sender-dark.svg') no-repeat center;
--sender-border-color: #333;
--generating-bg-before: linear-gradient(90deg, #262626, #808080);
--generating-bg-after: #191919;
}
}
.is-loading-in-top {
margin-top: -48px;
}
.messages-container {
flex: 1;
overflow: auto;
word-break: break-word;
}
:deep(.tr-bubble__loading) {
margin-top: 8px;
}
:deep(.tr-bubble.placement-start) {
.tr-bubble__content {
padding: 0;
background: transparent;
border-radius: 0;
box-shadow: none;
}
}
:deep(.tr-bubble[data-role='assistant'] .tr-bubble__content-items) {
// 匹配:type非空 + 排除 schema-card/loading-text 这两个值
> [type]:not([type='']):not([type='schema-card']):not([type='loading-text']) {
display: var(--thinking-display, initial);
}
}
:deep(.tr-bubble__step-tool) {
& + .tr-bubble__step-tool {
margin-top: 16px;
}
}
:deep(.tr-bubble.placement-end) {
width: 100%;
}
:deep(.tr-bubble__content-wrapper) {
@avatar-and-gap-width: 56px;
// TODO: 后续规范变量名,在对外暴露
max-width: calc(100% - var(--ti-gen-chat-avatar-and-gap-width ,@avatar-and-gap-width) * 2);
.tr-bubble__content {
max-width: 100%;
}
.tr-bubble__content-items {
overflow-x: auto;
}
}
.sender-container {
position: relative;
flex-shrink: 0;
padding: 16px 0;
background: var(--sender-bg);
.attachments-container {
padding: 0 20px;
}
}
.scroll-to-bottom-button {
position: absolute;
left: 50%;
transform: translateX(-50%);
top: -35px;
width: 40px;
height: 40px;
background-color: var(--generating-bg-after);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border: 1px solid var(--sender-border-color);
z-index: 1000;
& > svg {
width: 20px;
height: 20px;
}
&:hover {
box-shadow: 0px 10px 20px 0px #0000001a, 0px 0px 1px 0px #00000026;
}
&.is-generating {
border: none;
background-color: transparent;
&::before {
content: '';
position: absolute;
top: -2px;
left: -2px;
right: -2px;
bottom: -2px;
width: calc(100% + 4px);
height: calc(100% + 4px);
border-radius: 50%;
background: var(--generating-bg-before);
z-index: 0;
animation: rotate-border 2s linear infinite;
}
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 50%;
background-color: var(--generating-bg-after);
z-index: 1;
}
& > svg {
z-index: 2;
}
}
}
.footer-text {
font-size: 12px;
color: #999;
text-align: center;
margin-top: 16px;
}
:deep(.schema-render-container) {
@large-screen-min-width: 400px;
@min-width-safe-padding: 250px;
@small-screen-min-width: calc(var(--messages-container-width) - @min-width-safe-padding);
min-width: min(@small-screen-min-width, @large-screen-min-width);
}
@keyframes rotate-border {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.tiny-sender {
width: 80%;
margin: 0 auto;
}
</style>