Files
timi-tdesign-mobile/src/components/ai/AiChatPanel.vue
T
2026-09-26 14:20:18 +08:00

261 lines
6.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<section class="ai-chat-panel" :aria-label="props.ariaLabel">
<ai-chat-message-list
ref="messageList"
:style="messageListStyle"
:messages="props.messages"
:is-generating="props.isGenerating"
:loading="props.isLoading"
:loading-text="props.loadingText"
:aria-label="props.messageListAriaLabel"
:role-names="props.roleNames"
:role-name-formatter="props.roleNameFormatter"
:thinking-title="props.thinkingTitle"
:created-at-label="props.createdAtLabel"
:duration-label="props.durationLabel"
:format-message-status="props.formatMessageStatus"
>
<template v-if="$slots.welcome" #welcome="slotProps">
<slot name="welcome" v-bind="slotProps" />
</template>
<template v-if="$slots['assistant-icon']" #assistant-icon="slotProps">
<slot name="assistant-icon" v-bind="slotProps" />
</template>
<template v-if="$slots['user-icon']" #user-icon="slotProps">
<slot name="user-icon" v-bind="slotProps" />
</template>
<template v-if="$slots.message" #message="slotProps">
<slot name="message" v-bind="slotProps" />
</template>
<template v-if="$slots['role-name']" #role-name="slotProps">
<slot name="role-name" v-bind="slotProps" />
</template>
<template v-if="$slots.thinking" #thinking="slotProps">
<slot name="thinking" v-bind="slotProps" />
</template>
<template v-if="$slots.citation" #citation="slotProps">
<slot name="citation" v-bind="slotProps" />
</template>
<template v-if="$slots['message-status']" #message-status="slotProps">
<slot name="message-status" v-bind="slotProps" />
</template>
</ai-chat-message-list>
<div ref="bottomArea" class="bottom-area">
<slot name="tool-status" :status="props.activeToolStatus">
<div v-if="props.activeToolStatus" class="tool-status">
<t-icon name="loading" size="1rem" />
<span v-text="props.activeToolStatus" />
</div>
</slot>
<slot
name="composer"
:draft="draft"
:is-generating="props.isGenerating"
:is-loading="props.isLoading"
:last-error="props.lastError"
:can-retry="props.canRetry"
:on-send="submitMessage"
:on-stop="() => emit('stop')"
:on-retry="() => emit('retry')"
:on-new-conversation="() => emit('new-conversation')"
:on-add-attachment="() => emit('add-attachment')"
>
<ai-chat-composer
v-model="draft"
:is-generating="props.isGenerating"
:disabled="props.disabled || props.isLoading"
:last-error="props.lastError"
:can-retry="props.canRetry"
:placeholder="props.placeholder"
:send-text="props.sendText"
:stop-text="props.stopText"
:retry-text="props.retryText"
:max-length="props.maxLength"
@send="submitMessage"
@stop="emit('stop')"
@retry="emit('retry')"
@new-conversation="emit('new-conversation')"
@add-attachment="emit('add-attachment')"
/>
</slot>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, shallowRef, useTemplateRef } from "vue";
import type {
AiChatMessage,
AiChatMessageStatusFormatter,
AiChatRoleNameFormatter,
AiChatRoleNames
} from "./types";
import AiChatMessageList from "./AiChatMessageList.vue";
import AiChatComposer from "./AiChatComposer.vue";
defineOptions({
name: "AiChatPanel"
});
export interface Props {
messages: readonly AiChatMessage[];
isGenerating: boolean;
isLoading?: boolean;
activeToolStatus?: string;
lastError?: string;
canRetry?: boolean;
disabled?: boolean;
loadingText?: string;
ariaLabel?: string;
messageListAriaLabel?: string;
roleNames?: AiChatRoleNames;
roleNameFormatter?: AiChatRoleNameFormatter;
thinkingTitle?: string;
createdAtLabel?: string;
durationLabel?: string;
formatMessageStatus?: AiChatMessageStatusFormatter;
placeholder?: string;
sendText?: string;
stopText?: string;
retryText?: string;
maxLength?: number;
}
const props = withDefaults(defineProps<Props>(), {
isLoading: false,
activeToolStatus: "",
lastError: "",
canRetry: false,
disabled: false,
loadingText: "正在加载…",
ariaLabel: "AI 对话",
messageListAriaLabel: "聊天消息",
roleNames: () => ({ assistant: "助手", user: "我" }),
thinkingTitle: "正在思考…",
createdAtLabel: "生成时间:",
durationLabel: "耗时:",
placeholder: "输入消息…",
sendText: "发送",
stopText: "停止",
retryText: "重试",
maxLength: 2000
});
const emit = defineEmits<{
send: [message: string];
stop: [];
retry: [];
"new-conversation": [];
"add-attachment": [];
}>();
const messageListRef = useTemplateRef<{ scrollToBottom: () => Promise<void> }>("messageList");
const bottomAreaRef = useTemplateRef<HTMLElement>("bottomArea");
const bottomAreaHeight = ref<number>();
const draft = shallowRef("");
let bottomAreaObserver: ResizeObserver | undefined;
const messageListStyle = computed(() => {
if (bottomAreaHeight.value === undefined) {
return {};
}
return {
"--ai-chat-bottom-area-height": `${bottomAreaHeight.value}px`
};
});
function submitMessage(message: string): void {
emit("send", message);
}
async function scrollToBottom(): Promise<void> {
await messageListRef.value?.scrollToBottom();
}
onMounted(() => {
const bottomArea = bottomAreaRef.value;
if (!bottomArea) {
return;
}
const updateBottomAreaHeight = () => {
bottomAreaHeight.value = Math.ceil(bottomArea.getBoundingClientRect().height);
};
updateBottomAreaHeight();
bottomAreaObserver = new ResizeObserver(updateBottomAreaHeight);
bottomAreaObserver.observe(bottomArea);
});
onBeforeUnmount(() => {
bottomAreaObserver?.disconnect();
});
defineExpose({
scrollToBottom
});
</script>
<style scoped lang="less">
.ai-chat-panel {
--ai-chat-background: var(--td-bg-color-page, #fff);
--ai-chat-bubble: var(--td-bg-color-container, #f5f6f8);
--ai-chat-accent: var(--td-brand-color, #0f766e);
--ai-chat-accent-soft: #ccfbf1;
--ai-chat-soft: #f0fdfa;
--ai-chat-user-accent: #2563eb;
--ai-chat-user-accent-soft: #dbeafe;
height: 100%;
display: flex;
position: relative;
overflow: hidden;
min-width: 0;
min-height: 0;
background: var(--ai-chat-background);
flex-direction: column;
.bottom-area {
inset: auto 1rem calc(1rem + env(safe-area-inset-bottom, 0px));
width: auto;
margin: 0 auto;
z-index: 2;
position: absolute;
max-width: 40rem;
display: flex;
flex-direction: column;
.tool-status {
gap: .375rem;
margin-bottom: .5rem;
color: var(--ai-chat-accent);
padding: .5rem .75rem;
border-radius: .75rem;
display: flex;
font-size: .75rem;
background: var(--ai-chat-soft);
align-items: center;
.t-icon {
animation: rotate 1s linear infinite;
}
}
}
}
@keyframes rotate {
to {
transform: rotate(360deg);
}
}
:global(html.theme-dark) {
.ai-chat-panel {
--ai-chat-background: var(--td-bg-color-page, #0f172a);
--ai-chat-bubble: var(--td-bg-color-container, #1e293b);
--ai-chat-accent-soft: rgba(20, 184, 166, .12);
--ai-chat-soft: rgba(20, 184, 166, .12);
--ai-chat-user-accent-soft: rgba(37, 99, 235, .18);
}
}
</style>