261 lines
6.9 KiB
Vue
261 lines
6.9 KiB
Vue
<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>
|