add aiChat
This commit is contained in:
@@ -0,0 +1,163 @@
|
|||||||
|
<template>
|
||||||
|
<div class="composer">
|
||||||
|
<div v-if="props.lastError" class="error-bar">
|
||||||
|
<slot name="error" :error="props.lastError" :can-retry="props.canRetry">
|
||||||
|
<span class="error-text" v-text="props.lastError" />
|
||||||
|
<t-button
|
||||||
|
v-if="props.canRetry"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
:content="props.retryText"
|
||||||
|
@click="emit('retry')"
|
||||||
|
/>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
<t-form class="input-wrap" @submit="submitMessage">
|
||||||
|
<t-input
|
||||||
|
v-model="draft"
|
||||||
|
class="input"
|
||||||
|
borderless
|
||||||
|
:disabled="props.isGenerating || props.disabled"
|
||||||
|
:maxlength="props.maxLength"
|
||||||
|
:placeholder="props.placeholder"
|
||||||
|
/>
|
||||||
|
<slot
|
||||||
|
v-if="props.isGenerating"
|
||||||
|
name="stop"
|
||||||
|
:disabled="props.disabled"
|
||||||
|
:on-stop="() => emit('stop')"
|
||||||
|
>
|
||||||
|
<t-button
|
||||||
|
class="send"
|
||||||
|
type="button"
|
||||||
|
size="small"
|
||||||
|
theme="danger"
|
||||||
|
:content="props.stopText"
|
||||||
|
@click="emit('stop')"
|
||||||
|
/>
|
||||||
|
</slot>
|
||||||
|
<slot
|
||||||
|
v-else
|
||||||
|
name="send"
|
||||||
|
:disabled="props.disabled"
|
||||||
|
:on-send="submitMessage"
|
||||||
|
>
|
||||||
|
<t-button
|
||||||
|
class="send"
|
||||||
|
type="submit"
|
||||||
|
size="small"
|
||||||
|
theme="primary"
|
||||||
|
:disabled="props.disabled"
|
||||||
|
:content="props.sendText"
|
||||||
|
/>
|
||||||
|
</slot>
|
||||||
|
</t-form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineOptions({
|
||||||
|
name: "AiChatComposer"
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
isGenerating: boolean;
|
||||||
|
lastError?: string;
|
||||||
|
canRetry?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
sendText?: string;
|
||||||
|
stopText?: string;
|
||||||
|
retryText?: string;
|
||||||
|
maxLength?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
lastError: "",
|
||||||
|
canRetry: false,
|
||||||
|
disabled: false,
|
||||||
|
placeholder: "输入消息…",
|
||||||
|
sendText: "发送",
|
||||||
|
stopText: "停止",
|
||||||
|
retryText: "重试",
|
||||||
|
maxLength: 2000
|
||||||
|
});
|
||||||
|
|
||||||
|
const draft = defineModel<string>({ required: true });
|
||||||
|
const emit = defineEmits<{
|
||||||
|
send: [message: string];
|
||||||
|
stop: [];
|
||||||
|
retry: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function submitMessage(): void {
|
||||||
|
if (props.disabled || props.isGenerating) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = draft.value.trim();
|
||||||
|
if (!message) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
draft.value = "";
|
||||||
|
emit("send", message);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.composer {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.error-bar {
|
||||||
|
gap: .375rem;
|
||||||
|
display: flex;
|
||||||
|
padding: .5rem .75rem;
|
||||||
|
border-radius: .75rem;
|
||||||
|
color: var(--ai-chat-error-color, #b42318);
|
||||||
|
font-size: .75rem;
|
||||||
|
background: var(--ai-chat-error-background, #fef3f2);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
min-width: 0;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-button {
|
||||||
|
color: inherit;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrap {
|
||||||
|
--td-input-bg-color: transparent;
|
||||||
|
--td-button-border-radius: 1rem;
|
||||||
|
|
||||||
|
gap: .5rem;
|
||||||
|
border: 1px solid var(--td-component-border);
|
||||||
|
margin: .5rem 0 0;
|
||||||
|
display: flex;
|
||||||
|
padding: .25rem .25rem .25rem .75rem;
|
||||||
|
box-shadow: 0 .5rem 1.5rem rgba(15, 23, 42, .12);
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
|
||||||
|
.input {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send {
|
||||||
|
width: 4rem;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.theme-dark) {
|
||||||
|
.error-bar {
|
||||||
|
color: var(--ai-chat-error-color-dark, #fda29b);
|
||||||
|
background: var(--ai-chat-error-background-dark, rgba(180, 35, 24, .18));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="messageList" class="message-list" role="log" :aria-label="props.ariaLabel" aria-live="polite">
|
||||||
|
<div v-if="props.loading" class="list-state loading-state">
|
||||||
|
<t-loading size="1.5rem" />
|
||||||
|
<span v-text="props.loadingText" />
|
||||||
|
</div>
|
||||||
|
<slot
|
||||||
|
v-else-if="!props.messages.length"
|
||||||
|
name="welcome"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template v-for="(message, index) in props.messages" v-else :key="message.id">
|
||||||
|
<slot
|
||||||
|
name="message"
|
||||||
|
:message="message"
|
||||||
|
:index="index"
|
||||||
|
:is-generating="props.isGenerating"
|
||||||
|
>
|
||||||
|
<article class="message-row" :class="message.role">
|
||||||
|
<div class="message-header">
|
||||||
|
<div class="avatar">
|
||||||
|
<slot
|
||||||
|
:name="message.role === 'assistant' ? 'assistant-icon' : 'user-icon'"
|
||||||
|
:message="message"
|
||||||
|
:role="message.role"
|
||||||
|
>
|
||||||
|
<t-icon :name="message.role === 'assistant' ? 'robot-2' : 'user'" />
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
<slot name="role-name" :message="message" :role="message.role">
|
||||||
|
<div class="role-name" v-text="getRoleName(message)" />
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
<div class="message-main">
|
||||||
|
<div class="bubble" :class="message.role">
|
||||||
|
<template v-if="message.role === 'assistant'">
|
||||||
|
<slot
|
||||||
|
v-for="thinking in message.thinkingList"
|
||||||
|
:key="thinking.id"
|
||||||
|
name="thinking"
|
||||||
|
:message="message"
|
||||||
|
:thinking="thinking"
|
||||||
|
:is-generating="props.isGenerating && thinking.id === message.thinkingList[message.thinkingList.length - 1]?.id"
|
||||||
|
>
|
||||||
|
<ai-chat-thinking
|
||||||
|
:content="thinking.content"
|
||||||
|
:title="props.thinkingTitle"
|
||||||
|
:is-generating="props.isGenerating && thinking.id === message.thinkingList[message.thinkingList.length - 1]?.id"
|
||||||
|
/>
|
||||||
|
</slot>
|
||||||
|
</template>
|
||||||
|
<div v-if="message.content && message.role === 'assistant'" class="content markdown-content">
|
||||||
|
<markdown-view :content="message.content" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="message.content" class="content" v-text="message.content" />
|
||||||
|
<div v-else-if="message.role === 'assistant' && props.isGenerating" class="typing">
|
||||||
|
<span /><span /><span />
|
||||||
|
</div>
|
||||||
|
<div v-if="$slots.citation && message.role === 'assistant' && message.citationList.length" class="citation-list">
|
||||||
|
<template v-for="(citation, citationIndex) in message.citationList" :key="`${message.id}-${citationIndex}`">
|
||||||
|
<slot
|
||||||
|
name="citation"
|
||||||
|
:citation="citation"
|
||||||
|
:message="message"
|
||||||
|
:index="citationIndex"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="message.role === 'assistant' && (message.statusText || (!props.isGenerating && message.content))"
|
||||||
|
class="message-status"
|
||||||
|
>
|
||||||
|
<slot name="message-status" :message="message" :text="getMessageStatus(message)">
|
||||||
|
<span v-text="getMessageStatus(message)" />
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</slot>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, useTemplateRef } from "vue";
|
||||||
|
import { MarkdownView } from "timi-web";
|
||||||
|
import AiChatThinking from "./AiChatThinking.vue";
|
||||||
|
import type {
|
||||||
|
AiChatMessage,
|
||||||
|
AiChatMessageStatusFormatter,
|
||||||
|
AiChatRoleNameFormatter,
|
||||||
|
AiChatRoleNames,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "AiChatMessageList"
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
messages: readonly AiChatMessage[];
|
||||||
|
isGenerating: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
loadingText?: string;
|
||||||
|
ariaLabel?: string;
|
||||||
|
roleNames?: AiChatRoleNames;
|
||||||
|
roleNameFormatter?: AiChatRoleNameFormatter;
|
||||||
|
thinkingTitle?: string;
|
||||||
|
createdAtLabel?: string;
|
||||||
|
durationLabel?: string;
|
||||||
|
formatMessageStatus?: AiChatMessageStatusFormatter;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
loading: false,
|
||||||
|
loadingText: "正在加载…",
|
||||||
|
ariaLabel: "聊天消息",
|
||||||
|
roleNames: () => ({ assistant: "助手", user: "我" }),
|
||||||
|
thinkingTitle: "正在思考…",
|
||||||
|
createdAtLabel: "生成时间:",
|
||||||
|
durationLabel: "耗时:"
|
||||||
|
});
|
||||||
|
|
||||||
|
const messageListRef = useTemplateRef<HTMLElement>("messageList");
|
||||||
|
|
||||||
|
async function scrollToBottom(): Promise<void> {
|
||||||
|
await nextTick();
|
||||||
|
const element = messageListRef.value;
|
||||||
|
if (element) {
|
||||||
|
element.scrollTop = element.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoleName(message: AiChatMessage): string {
|
||||||
|
return props.roleNameFormatter?.(message.role, message)
|
||||||
|
|| props.roleNames[message.role]
|
||||||
|
|| message.role;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMessageStatus(message: AiChatMessage): string {
|
||||||
|
if (props.formatMessageStatus) {
|
||||||
|
return props.formatMessageStatus(message);
|
||||||
|
}
|
||||||
|
if (message.statusText) {
|
||||||
|
return message.statusText;
|
||||||
|
}
|
||||||
|
const timeText = `${props.createdAtLabel}${formatDateTime(message.createdAt)}`;
|
||||||
|
if (message.duration !== undefined) {
|
||||||
|
return `${timeText} · ${props.durationLabel}${formatDuration(message.duration)}`;
|
||||||
|
}
|
||||||
|
return timeText;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(timestamp: number): string {
|
||||||
|
return new Date(timestamp).toLocaleString("zh-CN", { hour12: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(duration: number): string {
|
||||||
|
if (duration < 1000) {
|
||||||
|
return `${duration} 毫秒`;
|
||||||
|
}
|
||||||
|
return `${(duration / 1000).toFixed(1)} 秒`;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
scrollToBottom
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.message-list {
|
||||||
|
gap: 1rem;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
padding: .5rem 1rem calc(6rem + env(safe-area-inset-bottom, 0px));
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.list-state {
|
||||||
|
gap: .5rem;
|
||||||
|
margin: auto 0;
|
||||||
|
padding: 1rem;
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
display: flex;
|
||||||
|
font-size: .8125rem;
|
||||||
|
text-align: center;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-row {
|
||||||
|
gap: .5rem;
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
&.user {
|
||||||
|
.message-header {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-name {
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-header {
|
||||||
|
gap: .5rem;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
flex: none;
|
||||||
|
color: var(--ai-chat-accent, #0f766e);
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
display: flex;
|
||||||
|
background: var(--ai-chat-accent-soft, #ccfbf1);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.user .avatar {
|
||||||
|
color: var(--ai-chat-user-accent, #2563eb);
|
||||||
|
background: var(--ai-chat-user-accent-soft, #dbeafe);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-main {
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.user .message-main {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-name {
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
margin: 0;
|
||||||
|
font-size: .6875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
color: var(--td-text-color-primary);
|
||||||
|
width: 100%;
|
||||||
|
padding: .75rem;
|
||||||
|
border-radius: .25rem 1rem 1rem 1rem;
|
||||||
|
background: var(--ai-chat-bubble, #f5f6f8);
|
||||||
|
text-align: left;
|
||||||
|
box-shadow: 0 .25rem 1rem rgba(15, 23, 42, .05);
|
||||||
|
|
||||||
|
.thinking + .thinking {
|
||||||
|
margin-top: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.user {
|
||||||
|
color: white;
|
||||||
|
border-radius: 1rem .25rem 1rem 1rem;
|
||||||
|
background: var(--ai-chat-accent, #0f766e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
font-size: .875rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content {
|
||||||
|
white-space: normal;
|
||||||
|
|
||||||
|
:deep(.tui-markdown-view) {
|
||||||
|
width: auto;
|
||||||
|
font-size: .875rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing {
|
||||||
|
gap: .25rem;
|
||||||
|
display: flex;
|
||||||
|
padding: .25rem .125rem;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
span {
|
||||||
|
width: .375rem;
|
||||||
|
height: .375rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--ai-chat-accent, #0f766e);
|
||||||
|
animation: typing 1.2s infinite ease-in-out;
|
||||||
|
|
||||||
|
&:nth-child(2) {
|
||||||
|
animation-delay: .16s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:nth-child(3) {
|
||||||
|
animation-delay: .32s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.citation-list {
|
||||||
|
gap: .375rem;
|
||||||
|
margin-top: .75rem;
|
||||||
|
padding-top: .625rem;
|
||||||
|
display: flex;
|
||||||
|
border-top: 1px solid var(--td-component-border);
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-status {
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
margin-top: .5rem;
|
||||||
|
font-size: .6875rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes typing {
|
||||||
|
0%, 60%, 100% {
|
||||||
|
opacity: .35;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
30% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(-.1875rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
<template>
|
||||||
|
<section class="ai-chat-panel" :aria-label="props.ariaLabel">
|
||||||
|
<ai-chat-message-list
|
||||||
|
ref="messageList"
|
||||||
|
: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 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')"
|
||||||
|
>
|
||||||
|
<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')"
|
||||||
|
/>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { 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: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const messageListRef = useTemplateRef<{ scrollToBottom: () => Promise<void> }>("messageList");
|
||||||
|
const draft = shallowRef("");
|
||||||
|
|
||||||
|
function submitMessage(message: string): void {
|
||||||
|
emit("send", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scrollToBottom(): Promise<void> {
|
||||||
|
await messageListRef.value?.scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
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-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;
|
||||||
|
|
||||||
|
.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>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<template>
|
||||||
|
<section class="thinking" :aria-label="props.ariaLabel">
|
||||||
|
<div class="thinking-header">
|
||||||
|
<slot name="header">
|
||||||
|
<span v-text="props.title" />
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref="thinkingContent"
|
||||||
|
class="thinking-content"
|
||||||
|
role="log"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<span v-text="props.content" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, useTemplateRef, watch } from "vue";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "AiChatThinking"
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
content: string;
|
||||||
|
isGenerating: boolean;
|
||||||
|
title?: string;
|
||||||
|
ariaLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
title: "正在思考…",
|
||||||
|
ariaLabel: "思考过程"
|
||||||
|
});
|
||||||
|
|
||||||
|
const thinkingContentRef = useTemplateRef<HTMLElement>("thinkingContent");
|
||||||
|
|
||||||
|
async function scrollToBottom(): Promise<void> {
|
||||||
|
await nextTick();
|
||||||
|
const element = thinkingContentRef.value;
|
||||||
|
if (element) {
|
||||||
|
element.scrollTop = element.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[() => props.content, () => props.isGenerating],
|
||||||
|
async ([content, isGenerating]) => {
|
||||||
|
if (content && isGenerating) {
|
||||||
|
await scrollToBottom();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ flush: "post" }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.thinking {
|
||||||
|
gap: .5rem;
|
||||||
|
height: clamp(2rem, 24vh, 10rem);
|
||||||
|
display: flex;
|
||||||
|
padding: .625rem .75rem;
|
||||||
|
border: 1px solid var(--td-component-border);
|
||||||
|
border-radius: .75rem;
|
||||||
|
background: var(--ai-chat-background, var(--td-bg-color-container, #fff));
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 2rem;
|
||||||
|
max-height: 10rem;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.thinking-header {
|
||||||
|
gap: .375rem;
|
||||||
|
display: flex;
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
font-size: .75rem;
|
||||||
|
line-height: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-content {
|
||||||
|
flex: 1;
|
||||||
|
padding-right: .25rem;
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
font-size: .75rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { WebToolkit } from "timi-web";
|
||||||
|
import AiChatComposerView from "./AiChatComposer.vue";
|
||||||
|
import AiChatMessageListView from "./AiChatMessageList.vue";
|
||||||
|
import AiChatPanelView from "./AiChatPanel.vue";
|
||||||
|
import AiChatThinkingView from "./AiChatThinking.vue";
|
||||||
|
|
||||||
|
export const AiChatComposer = WebToolkit.withInstall(AiChatComposerView);
|
||||||
|
export const AiChatMessageList = WebToolkit.withInstall(AiChatMessageListView);
|
||||||
|
export const AiChatPanel = WebToolkit.withInstall(AiChatPanelView);
|
||||||
|
export const AiChatThinking = WebToolkit.withInstall(AiChatThinkingView);
|
||||||
|
|
||||||
|
export default AiChatPanel;
|
||||||
|
|
||||||
|
export { useAiChat, AiChatHttpError, AiChatStreamError, ensureChatResponse, parseSseData, readSseStream } from "./useAiChat";
|
||||||
|
export { parseAssistantResponse } from "./useAiChat";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
AiChatCitationFormatter,
|
||||||
|
AiChatErrorMessages,
|
||||||
|
AiChatEventAction,
|
||||||
|
AiChatMessage,
|
||||||
|
AiChatMessageStatusFormatter,
|
||||||
|
AiChatRequestContext,
|
||||||
|
AiChatRoleNameFormatter,
|
||||||
|
AiChatRoleNames,
|
||||||
|
AiChatStreamEvent,
|
||||||
|
AiCitation,
|
||||||
|
AiMessageRole,
|
||||||
|
AiThinking,
|
||||||
|
UseAiChatOptions
|
||||||
|
} from "./types";
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import type { MaybeRefOrGetter } from "vue";
|
||||||
|
|
||||||
|
/** AI 消息角色。 */
|
||||||
|
export type AiMessageRole = "user" | "assistant";
|
||||||
|
|
||||||
|
/** 消息引用信息。业务方可以通过格式化函数显示自定义字段。 */
|
||||||
|
export interface AiCitation {
|
||||||
|
source?: string;
|
||||||
|
dataDomain?: string;
|
||||||
|
timeRange?: string;
|
||||||
|
caliber?: string;
|
||||||
|
generatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 思考过程分段。 */
|
||||||
|
export interface AiThinking {
|
||||||
|
id: number;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 聊天消息。 */
|
||||||
|
export interface AiChatMessage {
|
||||||
|
id: number;
|
||||||
|
role: AiMessageRole;
|
||||||
|
content: string;
|
||||||
|
thinkingList: AiThinking[];
|
||||||
|
citationList: AiCitation[];
|
||||||
|
createdAt: number;
|
||||||
|
statusText?: string;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SSE 事件。 */
|
||||||
|
export interface AiChatStreamEvent {
|
||||||
|
event: string;
|
||||||
|
data: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发起聊天请求时传给业务适配器的上下文。 */
|
||||||
|
export interface AiChatRequestContext<TContext = unknown> {
|
||||||
|
message: string;
|
||||||
|
conversationId?: string;
|
||||||
|
context?: TContext;
|
||||||
|
signal: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SSE 事件转换后的通用动作。 */
|
||||||
|
export type AiChatEventAction =
|
||||||
|
| { type: "thinking"; content: string; newSegment?: boolean }
|
||||||
|
| { type: "answer"; content: string }
|
||||||
|
| { type: "tool-status"; text: string }
|
||||||
|
| { type: "citation"; citation: AiCitation }
|
||||||
|
| { type: "done"; conversationId?: string; duration?: number }
|
||||||
|
| { type: "error"; error: unknown };
|
||||||
|
|
||||||
|
/** 可覆盖的聊天错误文案。 */
|
||||||
|
export interface AiChatErrorMessages {
|
||||||
|
request: string;
|
||||||
|
stream: string;
|
||||||
|
unauthorized: string;
|
||||||
|
forbidden: string;
|
||||||
|
tooManyRequests: string;
|
||||||
|
timeout: string;
|
||||||
|
aborted: string;
|
||||||
|
connection: string;
|
||||||
|
unknown: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 聊天请求和历史会话的业务适配器配置。 */
|
||||||
|
export interface UseAiChatOptions<TConversationResponse = unknown, TContext = unknown> {
|
||||||
|
/** 发起一个返回 SSE 响应的请求。 */
|
||||||
|
request: (context: AiChatRequestContext<TContext>) => Promise<Response>;
|
||||||
|
/** 读取历史会话,可选。 */
|
||||||
|
readConversation?: (conversationId: string, signal: AbortSignal) => Promise<TConversationResponse>;
|
||||||
|
/** 把业务历史响应转换成通用消息。 */
|
||||||
|
mapConversationMessages?: (response: TConversationResponse) => AiChatMessage[];
|
||||||
|
/** 用于切换业务上下文时自动清空当前会话。 */
|
||||||
|
sessionKey?: MaybeRefOrGetter<unknown>;
|
||||||
|
/** 传给 request 的业务上下文。 */
|
||||||
|
context?: MaybeRefOrGetter<TContext | undefined>;
|
||||||
|
/** 消息流式更新后的滚动回调。 */
|
||||||
|
scrollToBottom?: () => Promise<void>;
|
||||||
|
/** 自定义 SSE 事件转换器,未转换的事件继续使用内置协议。 */
|
||||||
|
parseEvent?: (event: AiChatStreamEvent, payload: unknown) => AiChatEventAction | undefined;
|
||||||
|
/** 判断当前错误是否表示会话已失效。命中后会自动清空会话并重试一次。 */
|
||||||
|
isConversationExpired?: (error: unknown) => boolean;
|
||||||
|
/** 自定义错误文案转换。 */
|
||||||
|
getErrorMessage?: (error: unknown) => string | undefined;
|
||||||
|
/** 响应文本中的思考标记。 */
|
||||||
|
thinkingOpenMarkers?: readonly string[];
|
||||||
|
thinkingCloseMarkers?: readonly string[];
|
||||||
|
/** 覆盖默认错误文案。 */
|
||||||
|
errorMessages?: Partial<AiChatErrorMessages>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 消息列表的角色文案。 */
|
||||||
|
export type AiChatRoleNames = Partial<Record<AiMessageRole, string>>;
|
||||||
|
|
||||||
|
/** 消息状态格式化函数。 */
|
||||||
|
export type AiChatMessageStatusFormatter = (message: AiChatMessage) => string;
|
||||||
|
|
||||||
|
/** 引用格式化函数。 */
|
||||||
|
export type AiChatCitationFormatter = (citation: AiCitation) => string;
|
||||||
|
|
||||||
|
/** 角色名称格式化函数。 */
|
||||||
|
export type AiChatRoleNameFormatter = (role: AiMessageRole, message: AiChatMessage) => string;
|
||||||
@@ -0,0 +1,784 @@
|
|||||||
|
import { computed, onUnmounted, shallowRef, toValue, watch } from "vue";
|
||||||
|
import type {
|
||||||
|
AiChatErrorMessages,
|
||||||
|
AiChatEventAction,
|
||||||
|
AiChatMessage,
|
||||||
|
AiChatRequestContext,
|
||||||
|
AiChatStreamEvent,
|
||||||
|
AiCitation,
|
||||||
|
AiThinking,
|
||||||
|
UseAiChatOptions
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
AiChatMessage,
|
||||||
|
AiChatErrorMessages,
|
||||||
|
AiChatEventAction,
|
||||||
|
AiChatRequestContext,
|
||||||
|
AiChatStreamEvent,
|
||||||
|
AiCitation,
|
||||||
|
AiMessageRole,
|
||||||
|
AiThinking,
|
||||||
|
UseAiChatOptions
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export { parseAssistantResponse };
|
||||||
|
|
||||||
|
/** 通用聊天 HTTP 错误。 */
|
||||||
|
export class AiChatHttpError extends Error {
|
||||||
|
readonly status: number;
|
||||||
|
readonly code?: string;
|
||||||
|
|
||||||
|
constructor(status: number, message: string, code?: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "AiChatHttpError";
|
||||||
|
this.status = status;
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通用聊天流错误。 */
|
||||||
|
export class AiChatStreamError extends Error {
|
||||||
|
readonly code?: string;
|
||||||
|
|
||||||
|
constructor(message: string, code?: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "AiChatStreamError";
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_ERROR_MESSAGES: AiChatErrorMessages = {
|
||||||
|
request: "聊天请求失败,请稍后重试",
|
||||||
|
stream: "聊天连接中断,请重试",
|
||||||
|
unauthorized: "登录状态已失效,请重新登录",
|
||||||
|
forbidden: "没有权限使用聊天服务",
|
||||||
|
tooManyRequests: "请求过于频繁,请稍后再试",
|
||||||
|
timeout: "聊天请求超时,请稍后重试",
|
||||||
|
aborted: "已停止生成,可重新提问",
|
||||||
|
connection: "连接中断,可重新提问",
|
||||||
|
unknown: "聊天服务暂时不可用,请稍后重试"
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_THINKING_OPEN_MARKERS = ["<thinking>", "<think>"];
|
||||||
|
const DEFAULT_THINKING_CLOSE_MARKERS = ["</thinking>", "</think>"];
|
||||||
|
|
||||||
|
interface AssistantResponseState {
|
||||||
|
rawText: string;
|
||||||
|
eventThinkingList: AiThinking[];
|
||||||
|
taggedThinkingList: AiThinking[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理通用 AI 聊天的会话、SSE 流式消息、取消和重试。
|
||||||
|
*
|
||||||
|
* 业务 API 通过 request 和可选的历史会话回调注入,composable 本身不依赖具体
|
||||||
|
* 登录 Store、HTTP 客户端或业务数据模型。
|
||||||
|
*/
|
||||||
|
export function useAiChat<TConversationResponse = unknown, TContext = unknown>(
|
||||||
|
options: UseAiChatOptions<TConversationResponse, TContext>
|
||||||
|
) {
|
||||||
|
const messages = shallowRef<AiChatMessage[]>([]);
|
||||||
|
const conversationId = shallowRef("");
|
||||||
|
const isGenerating = shallowRef(false);
|
||||||
|
const isLoadingConversation = shallowRef(false);
|
||||||
|
const activeToolStatus = shallowRef("");
|
||||||
|
const lastError = shallowRef("");
|
||||||
|
const lastQuestion = shallowRef("");
|
||||||
|
const canRetry = computed(() => Boolean(
|
||||||
|
lastError.value
|
||||||
|
&& lastQuestion.value
|
||||||
|
&& !isGenerating.value
|
||||||
|
&& !isLoadingConversation.value
|
||||||
|
));
|
||||||
|
let activeController: AbortController | undefined;
|
||||||
|
let messageSequence = 0;
|
||||||
|
let thinkingSequence = 0;
|
||||||
|
const responseStateMap = new Map<number, AssistantResponseState>();
|
||||||
|
const errorMessages = { ...DEFAULT_ERROR_MESSAGES, ...options.errorMessages };
|
||||||
|
|
||||||
|
async function sendMessage(message: string): Promise<void> {
|
||||||
|
const normalizedMessage = message.trim();
|
||||||
|
if (!normalizedMessage || isGenerating.value || isLoadingConversation.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userMessage = createMessage("user", normalizedMessage);
|
||||||
|
const assistantMessage = createMessage("assistant", "");
|
||||||
|
messages.value = [...messages.value, userMessage, assistantMessage];
|
||||||
|
lastQuestion.value = normalizedMessage;
|
||||||
|
lastError.value = "";
|
||||||
|
isGenerating.value = true;
|
||||||
|
activeToolStatus.value = "";
|
||||||
|
const controller = new AbortController();
|
||||||
|
activeController = controller;
|
||||||
|
let hasRetriedExpiredConversation = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
resetAssistantMessage(assistantMessage.id);
|
||||||
|
try {
|
||||||
|
await readChatStream(normalizedMessage, assistantMessage.id, controller);
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
!hasRetriedExpiredConversation
|
||||||
|
&& conversationId.value
|
||||||
|
&& options.isConversationExpired?.(error)
|
||||||
|
) {
|
||||||
|
hasRetriedExpiredConversation = true;
|
||||||
|
conversationId.value = "";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
handleChatError(error, assistantMessage.id, controller.signal.aborted);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (activeController === controller) {
|
||||||
|
activeController = undefined;
|
||||||
|
}
|
||||||
|
isGenerating.value = false;
|
||||||
|
activeToolStatus.value = "";
|
||||||
|
responseStateMap.delete(assistantMessage.id);
|
||||||
|
await scrollToBottom();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopGeneration(): void {
|
||||||
|
if (!isGenerating.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activeController?.abort();
|
||||||
|
activeToolStatus.value = "";
|
||||||
|
const assistantMessage = [...messages.value].reverse().find(item => item.role === "assistant");
|
||||||
|
if (assistantMessage) {
|
||||||
|
updateMessage(assistantMessage.id, { statusText: errorMessages.aborted });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetConversation(): void {
|
||||||
|
activeController?.abort();
|
||||||
|
activeController = undefined;
|
||||||
|
messages.value = [];
|
||||||
|
responseStateMap.clear();
|
||||||
|
conversationId.value = "";
|
||||||
|
isGenerating.value = false;
|
||||||
|
isLoadingConversation.value = false;
|
||||||
|
activeToolStatus.value = "";
|
||||||
|
lastError.value = "";
|
||||||
|
lastQuestion.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConversation(id: string): Promise<void> {
|
||||||
|
const normalizedId = id.trim();
|
||||||
|
if (
|
||||||
|
!normalizedId
|
||||||
|
|| isGenerating.value
|
||||||
|
|| isLoadingConversation.value
|
||||||
|
|| !options.readConversation
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoadingConversation.value = true;
|
||||||
|
messages.value = [];
|
||||||
|
responseStateMap.clear();
|
||||||
|
conversationId.value = normalizedId;
|
||||||
|
lastError.value = "";
|
||||||
|
lastQuestion.value = "";
|
||||||
|
const controller = new AbortController();
|
||||||
|
try {
|
||||||
|
const response = await options.readConversation(normalizedId, controller.signal);
|
||||||
|
if (conversationId.value !== normalizedId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
messages.value = options.mapConversationMessages
|
||||||
|
? options.mapConversationMessages(response)
|
||||||
|
: normalizeConversationMessages(response);
|
||||||
|
lastQuestion.value = [...messages.value].reverse().find(item => item.role === "user")?.content || "";
|
||||||
|
await scrollToBottom();
|
||||||
|
} catch (error) {
|
||||||
|
if (conversationId.value === normalizedId) {
|
||||||
|
resetConversation();
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
isLoadingConversation.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retryLastMessage(): Promise<void> {
|
||||||
|
if (canRetry.value) {
|
||||||
|
await sendMessage(lastQuestion.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readChatStream(
|
||||||
|
message: string,
|
||||||
|
assistantId: number,
|
||||||
|
controller: AbortController
|
||||||
|
): Promise<void> {
|
||||||
|
const requestContext: AiChatRequestContext<TContext> = {
|
||||||
|
message,
|
||||||
|
conversationId: conversationId.value || undefined,
|
||||||
|
context: options.context ? toValue(options.context) : undefined,
|
||||||
|
signal: controller.signal
|
||||||
|
};
|
||||||
|
const response = await options.request(requestContext);
|
||||||
|
await ensureChatResponse(response, errorMessages.request);
|
||||||
|
let hasDoneEvent = false;
|
||||||
|
await readSseStream(response, async event => {
|
||||||
|
hasDoneEvent = handleStreamEvent(event, assistantId, hasDoneEvent);
|
||||||
|
await options.scrollToBottom?.();
|
||||||
|
});
|
||||||
|
if (!hasDoneEvent && !controller.signal.aborted) {
|
||||||
|
throw new AiChatStreamError(errorMessages.stream, "STREAM_INCOMPLETE");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStreamEvent(event: AiChatStreamEvent, assistantId: number, hasDoneEvent: boolean): boolean {
|
||||||
|
const payload = parseSseData(event.data);
|
||||||
|
const action = options.parseEvent?.(event, payload) || parseDefaultEvent(event, payload);
|
||||||
|
if (!action) {
|
||||||
|
return hasDoneEvent;
|
||||||
|
}
|
||||||
|
switch (action.type) {
|
||||||
|
case "thinking":
|
||||||
|
appendAssistantThinking(assistantId, action.content, action.newSegment === true);
|
||||||
|
return hasDoneEvent;
|
||||||
|
case "answer":
|
||||||
|
appendAssistantResponse(assistantId, action.content);
|
||||||
|
return hasDoneEvent;
|
||||||
|
case "tool-status":
|
||||||
|
activeToolStatus.value = action.text;
|
||||||
|
return hasDoneEvent;
|
||||||
|
case "citation":
|
||||||
|
appendCitation(assistantId, action.citation);
|
||||||
|
return hasDoneEvent;
|
||||||
|
case "done":
|
||||||
|
applyDoneAction(assistantId, action);
|
||||||
|
return true;
|
||||||
|
case "error":
|
||||||
|
throw action.error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDoneAction(assistantId: number, action: Extract<AiChatEventAction, { type: "done" }>): void {
|
||||||
|
if (action.conversationId) {
|
||||||
|
conversationId.value = action.conversationId;
|
||||||
|
}
|
||||||
|
updateMessage(assistantId, {
|
||||||
|
createdAt: Date.now(),
|
||||||
|
duration: action.duration
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendAssistantResponse(assistantId: number, text: string): void {
|
||||||
|
if (!text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const state = getAssistantResponseState(assistantId);
|
||||||
|
state.rawText = mergeResponseText(state.rawText, text);
|
||||||
|
const parsedResponse = parseAssistantResponse(
|
||||||
|
state.rawText,
|
||||||
|
options.thinkingOpenMarkers || DEFAULT_THINKING_OPEN_MARKERS,
|
||||||
|
options.thinkingCloseMarkers || DEFAULT_THINKING_CLOSE_MARKERS
|
||||||
|
);
|
||||||
|
state.taggedThinkingList = parsedResponse.thinkingList.map((content, index) => ({
|
||||||
|
id: state.taggedThinkingList[index]?.id || nextThinkingId(),
|
||||||
|
content
|
||||||
|
}));
|
||||||
|
updateMessage(assistantId, {
|
||||||
|
content: parsedResponse.answer,
|
||||||
|
thinkingList: [...state.eventThinkingList, ...state.taggedThinkingList],
|
||||||
|
statusText: undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendAssistantThinking(assistantId: number, text: string, createSegment = false): void {
|
||||||
|
if (!text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const state = getAssistantResponseState(assistantId);
|
||||||
|
const thinkingList = [...state.eventThinkingList];
|
||||||
|
const lastThinking = thinkingList[thinkingList.length - 1];
|
||||||
|
if (createSegment || !lastThinking) {
|
||||||
|
thinkingList.push({ id: nextThinkingId(), content: text });
|
||||||
|
} else {
|
||||||
|
thinkingList[thinkingList.length - 1] = {
|
||||||
|
...lastThinking,
|
||||||
|
content: lastThinking.content + text
|
||||||
|
};
|
||||||
|
}
|
||||||
|
state.eventThinkingList = thinkingList;
|
||||||
|
updateMessage(assistantId, {
|
||||||
|
thinkingList: [...state.eventThinkingList, ...state.taggedThinkingList],
|
||||||
|
statusText: undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendCitation(assistantId: number, citation: AiCitation): void {
|
||||||
|
const message = messages.value.find(item => item.id === assistantId);
|
||||||
|
if (!message) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateMessage(assistantId, { citationList: [...message.citationList, citation] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChatError(error: unknown, assistantId: number, isAborted: boolean): void {
|
||||||
|
if (isAborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = options.getErrorMessage?.(error) || getDefaultErrorMessage(error, errorMessages);
|
||||||
|
lastError.value = message;
|
||||||
|
updateMessage(assistantId, { statusText: message });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAssistantResponseState(assistantId: number): AssistantResponseState {
|
||||||
|
const currentState = responseStateMap.get(assistantId);
|
||||||
|
if (currentState) {
|
||||||
|
return currentState;
|
||||||
|
}
|
||||||
|
const nextState: AssistantResponseState = {
|
||||||
|
rawText: "",
|
||||||
|
eventThinkingList: [],
|
||||||
|
taggedThinkingList: []
|
||||||
|
};
|
||||||
|
responseStateMap.set(assistantId, nextState);
|
||||||
|
return nextState;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMessage(id: number, patch: Partial<AiChatMessage>): void {
|
||||||
|
messages.value = messages.value.map(item => item.id === id ? { ...item, ...patch } : item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAssistantMessage(id: number): void {
|
||||||
|
responseStateMap.delete(id);
|
||||||
|
updateMessage(id, {
|
||||||
|
content: "",
|
||||||
|
thinkingList: [],
|
||||||
|
citationList: [],
|
||||||
|
statusText: undefined,
|
||||||
|
duration: undefined,
|
||||||
|
createdAt: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMessage(role: AiChatMessage["role"], content: string): AiChatMessage {
|
||||||
|
return {
|
||||||
|
id: nextMessageId(),
|
||||||
|
role,
|
||||||
|
content,
|
||||||
|
thinkingList: [],
|
||||||
|
citationList: [],
|
||||||
|
createdAt: Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextMessageId(): number {
|
||||||
|
messageSequence += 1;
|
||||||
|
return messageSequence;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextThinkingId(): number {
|
||||||
|
thinkingSequence += 1;
|
||||||
|
return thinkingSequence;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scrollToBottom(): Promise<void> {
|
||||||
|
await options.scrollToBottom?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.sessionKey !== undefined) {
|
||||||
|
watch(() => toValue(options.sessionKey), () => {
|
||||||
|
resetConversation();
|
||||||
|
}, { flush: "sync" });
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
activeController?.abort();
|
||||||
|
messages.value = [];
|
||||||
|
responseStateMap.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages,
|
||||||
|
conversationId: computed(() => conversationId.value),
|
||||||
|
isGenerating,
|
||||||
|
isLoadingConversation,
|
||||||
|
activeToolStatus,
|
||||||
|
lastError,
|
||||||
|
canRetry,
|
||||||
|
sendMessage,
|
||||||
|
loadConversation,
|
||||||
|
stopGeneration,
|
||||||
|
resetConversation,
|
||||||
|
retryLastMessage
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取 SSE 响应并按事件块回调。 */
|
||||||
|
export async function readSseStream(
|
||||||
|
response: Response,
|
||||||
|
onEvent: (event: AiChatStreamEvent) => Promise<void>
|
||||||
|
): Promise<void> {
|
||||||
|
if (!response.body) {
|
||||||
|
throw new AiChatStreamError("聊天响应没有可读取的数据流", "EMPTY_STREAM");
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
buffer = await consumeSseBuffer(buffer, onEvent, false);
|
||||||
|
}
|
||||||
|
buffer += decoder.decode();
|
||||||
|
await consumeSseBuffer(buffer, onEvent, true);
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将非 2xx 响应转换成通用错误,不暴露原始响应正文。 */
|
||||||
|
export async function ensureChatResponse(response: Response, fallbackMessage = DEFAULT_ERROR_MESSAGES.request): Promise<void> {
|
||||||
|
if (response.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let code: string | undefined;
|
||||||
|
let message = fallbackMessage;
|
||||||
|
try {
|
||||||
|
const payload = await response.json() as {
|
||||||
|
code?: unknown;
|
||||||
|
message?: unknown;
|
||||||
|
error?: unknown;
|
||||||
|
};
|
||||||
|
code = getString(payload.code);
|
||||||
|
message = getString(payload.message) || getString(payload.error) || message;
|
||||||
|
} catch {
|
||||||
|
// 非 JSON 响应使用安全默认文案。
|
||||||
|
}
|
||||||
|
throw new AiChatHttpError(response.status, message, code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析 SSE data,解析失败时返回原始文本。 */
|
||||||
|
export function parseSseData(data: string): unknown {
|
||||||
|
const normalizedData = data.trim();
|
||||||
|
if (normalizedData === "[DONE]") {
|
||||||
|
return normalizedData;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(normalizedData) as unknown;
|
||||||
|
} catch {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析包含思考标记的完整响应文本。 */
|
||||||
|
function parseAssistantResponse(
|
||||||
|
value: string,
|
||||||
|
openMarkers: readonly string[] = DEFAULT_THINKING_OPEN_MARKERS,
|
||||||
|
closeMarkers: readonly string[] = DEFAULT_THINKING_CLOSE_MARKERS
|
||||||
|
): { answer: string; thinkingList: string[] } {
|
||||||
|
let answer = "";
|
||||||
|
let thinking = "";
|
||||||
|
let mode: "answer" | "thinking" = "answer";
|
||||||
|
let cursor = 0;
|
||||||
|
const thinkingList: string[] = [];
|
||||||
|
|
||||||
|
while (cursor < value.length) {
|
||||||
|
const marker = findMarker(value.slice(cursor), [...openMarkers, ...closeMarkers]);
|
||||||
|
if (!marker) {
|
||||||
|
const rest = value.slice(cursor);
|
||||||
|
if (mode === "thinking") {
|
||||||
|
thinking += rest;
|
||||||
|
} else {
|
||||||
|
answer += rest;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = value.slice(cursor, cursor + marker.index);
|
||||||
|
if (mode === "thinking") {
|
||||||
|
thinking += content;
|
||||||
|
if (thinking) {
|
||||||
|
thinkingList.push(thinking);
|
||||||
|
}
|
||||||
|
thinking = "";
|
||||||
|
mode = "answer";
|
||||||
|
} else if (openMarkers.some(item => item.toLowerCase() === marker.value.toLowerCase())) {
|
||||||
|
answer += content;
|
||||||
|
mode = "thinking";
|
||||||
|
} else {
|
||||||
|
if (content) {
|
||||||
|
thinkingList.push(content);
|
||||||
|
}
|
||||||
|
mode = "answer";
|
||||||
|
}
|
||||||
|
cursor += marker.index + marker.value.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "thinking" && thinking) {
|
||||||
|
thinkingList.push(thinking);
|
||||||
|
}
|
||||||
|
return { answer, thinkingList };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function consumeSseBuffer(
|
||||||
|
buffer: string,
|
||||||
|
onEvent: (event: AiChatStreamEvent) => Promise<void>,
|
||||||
|
isFinal: boolean
|
||||||
|
): Promise<string> {
|
||||||
|
const normalizedBuffer = buffer.replaceAll("\r\n", "\n").replaceAll("\r", "\n");
|
||||||
|
const blockList = normalizedBuffer.split("\n\n");
|
||||||
|
const remain = isFinal ? "" : blockList.pop() || "";
|
||||||
|
for (const block of blockList) {
|
||||||
|
const event = parseSseBlock(block);
|
||||||
|
if (event) {
|
||||||
|
await onEvent(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isFinal && remain.trim()) {
|
||||||
|
const event = parseSseBlock(remain);
|
||||||
|
if (event) {
|
||||||
|
await onEvent(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return remain;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSseBlock(block: string): AiChatStreamEvent | undefined {
|
||||||
|
let event = "message";
|
||||||
|
const dataLineList: string[] = [];
|
||||||
|
for (const line of block.split("\n")) {
|
||||||
|
if (line.startsWith("event:")) {
|
||||||
|
event = line.slice(6).trim() || "message";
|
||||||
|
} else if (line.startsWith("data:")) {
|
||||||
|
const data = line.slice(5);
|
||||||
|
dataLineList.push(data.startsWith(" ") ? data.slice(1) : data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!dataLineList.length) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return { event, data: dataLineList.join("\n") };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDefaultEvent(event: AiChatStreamEvent, payload: unknown): AiChatEventAction | undefined {
|
||||||
|
switch (event.event.toLowerCase()) {
|
||||||
|
case "think":
|
||||||
|
case "thinking":
|
||||||
|
return { type: "thinking", content: extractThinkingText(payload) };
|
||||||
|
case "agent_thought":
|
||||||
|
return { type: "thinking", content: extractThinkingText(payload), newSegment: true };
|
||||||
|
case "message":
|
||||||
|
case "answer":
|
||||||
|
case "text":
|
||||||
|
return { type: "answer", content: extractText(payload) };
|
||||||
|
case "tool_status":
|
||||||
|
case "tool-status":
|
||||||
|
return { type: "tool-status", text: extractToolStatus(payload) };
|
||||||
|
case "citation":
|
||||||
|
case "source": {
|
||||||
|
const citation = normalizeCitation(payload);
|
||||||
|
return citation ? { type: "citation", citation } : undefined;
|
||||||
|
}
|
||||||
|
case "done":
|
||||||
|
case "finish":
|
||||||
|
case "completed":
|
||||||
|
return { type: "done", ...normalizeDonePayload(payload) };
|
||||||
|
case "error":
|
||||||
|
return { type: "error", error: createStreamError(payload) };
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDonePayload(payload: unknown): { conversationId?: string; duration?: number } {
|
||||||
|
if (!isRecord(payload)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
conversationId: getString(payload.conversationId) || getString(payload.conversation_id),
|
||||||
|
duration: getNumber(payload.duration)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractText(payload: unknown): string {
|
||||||
|
if (typeof payload === "string") {
|
||||||
|
return payload === "[DONE]" ? "" : payload;
|
||||||
|
}
|
||||||
|
if (!isRecord(payload)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return getRawString(payload.answer)
|
||||||
|
|| getRawString(payload.text)
|
||||||
|
|| getRawString(payload.content)
|
||||||
|
|| "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractThinkingText(payload: unknown): string {
|
||||||
|
if (typeof payload === "string") {
|
||||||
|
return payload === "[DONE]" ? "" : payload;
|
||||||
|
}
|
||||||
|
if (!isRecord(payload)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return getRawString(payload.thought)
|
||||||
|
|| getRawString(payload.thinking)
|
||||||
|
|| getRawString(payload.reasoning)
|
||||||
|
|| getRawString(payload.observation)
|
||||||
|
|| getRawString(payload.text)
|
||||||
|
|| getRawString(payload.content)
|
||||||
|
|| "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractToolStatus(payload: unknown): string {
|
||||||
|
if (typeof payload === "string") {
|
||||||
|
return payload === "[DONE]" ? "" : payload;
|
||||||
|
}
|
||||||
|
if (!isRecord(payload)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return getString(payload.text)
|
||||||
|
|| getString(payload.message)
|
||||||
|
|| getString(payload.statusText)
|
||||||
|
|| getString(payload.status)
|
||||||
|
|| "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCitation(payload: unknown): AiCitation | undefined {
|
||||||
|
if (typeof payload === "string") {
|
||||||
|
return payload ? { source: payload } : undefined;
|
||||||
|
}
|
||||||
|
if (!isRecord(payload)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const citation: AiCitation = {
|
||||||
|
source: getString(payload.source),
|
||||||
|
dataDomain: getString(payload.dataDomain) || getString(payload.domain),
|
||||||
|
timeRange: getString(payload.timeRange) || formatScope(payload.scope),
|
||||||
|
caliber: getString(payload.caliber) || getString(payload.metric),
|
||||||
|
generatedAt: getDisplayText(payload.generatedAt) || getDisplayText(payload.generated_at)
|
||||||
|
};
|
||||||
|
return Object.values(citation).some(Boolean) ? citation : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStreamError(payload: unknown): AiChatStreamError {
|
||||||
|
if (!isRecord(payload)) {
|
||||||
|
return new AiChatStreamError(DEFAULT_ERROR_MESSAGES.unknown, "STREAM_ERROR");
|
||||||
|
}
|
||||||
|
return new AiChatStreamError(
|
||||||
|
getString(payload.message) || DEFAULT_ERROR_MESSAGES.unknown,
|
||||||
|
getString(payload.code)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultErrorMessage(error: unknown, messages: AiChatErrorMessages): string {
|
||||||
|
if (error instanceof AiChatHttpError) {
|
||||||
|
if (error.status === 401) {
|
||||||
|
return messages.unauthorized;
|
||||||
|
}
|
||||||
|
if (error.status === 403) {
|
||||||
|
return messages.forbidden;
|
||||||
|
}
|
||||||
|
if (error.status === 429) {
|
||||||
|
return messages.tooManyRequests;
|
||||||
|
}
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
if (error instanceof AiChatStreamError) {
|
||||||
|
if (error.code?.toLowerCase().includes("timeout")) {
|
||||||
|
return messages.timeout;
|
||||||
|
}
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
if (error instanceof DOMException && error.name === "AbortError") {
|
||||||
|
return messages.aborted;
|
||||||
|
}
|
||||||
|
if (error instanceof TypeError) {
|
||||||
|
return messages.connection;
|
||||||
|
}
|
||||||
|
return messages.unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeConversationMessages(response: unknown): AiChatMessage[] {
|
||||||
|
if (Array.isArray(response)) {
|
||||||
|
return response as AiChatMessage[];
|
||||||
|
}
|
||||||
|
if (isRecord(response) && Array.isArray(response.data)) {
|
||||||
|
return response.data as AiChatMessage[];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeResponseText(currentText: string, nextText: string): string {
|
||||||
|
if (currentText && nextText.startsWith(currentText)) {
|
||||||
|
return nextText;
|
||||||
|
}
|
||||||
|
return currentText + nextText;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMarker(value: string, markerList: string[]): { index: number; value: string } | undefined {
|
||||||
|
const lowerValue = value.toLowerCase();
|
||||||
|
return markerList.reduce<{ index: number; value: string } | undefined>((current, marker) => {
|
||||||
|
const index = lowerValue.indexOf(marker.toLowerCase());
|
||||||
|
if (index < 0 || (current && current.index <= index)) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return { index, value: marker };
|
||||||
|
}, undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatScope(value: unknown): string | undefined {
|
||||||
|
const text = getString(value);
|
||||||
|
if (text) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const beginAt = getDisplayText(value.beginAt) || getDisplayText(value.begin);
|
||||||
|
const endAt = getDisplayText(value.endAt) || getDisplayText(value.end);
|
||||||
|
if (beginAt && endAt) {
|
||||||
|
return `${beginAt} 至 ${endAt}`;
|
||||||
|
}
|
||||||
|
return beginAt || endAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDisplayText(value: unknown): string | undefined {
|
||||||
|
const text = getString(value);
|
||||||
|
if (text) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
const timestamp = getNumber(value);
|
||||||
|
if (timestamp === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const milliseconds = timestamp < 100000000000 ? timestamp * 1000 : timestamp;
|
||||||
|
return new Date(milliseconds).toLocaleString("zh-CN", { hour12: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getString(value: unknown): string | undefined {
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRawString(value: unknown): string {
|
||||||
|
return typeof value === "string" ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNumber(value: unknown): number | undefined {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||||
|
}
|
||||||
+42
-2
@@ -10,6 +10,12 @@ import NotifyEditor from "./notify-editor";
|
|||||||
import NotifyView from "./notify-view";
|
import NotifyView from "./notify-view";
|
||||||
import BottomPopup from "./bottom-popup";
|
import BottomPopup from "./bottom-popup";
|
||||||
import Space from "./space";
|
import Space from "./space";
|
||||||
|
import {
|
||||||
|
AiChatComposer,
|
||||||
|
AiChatMessageList,
|
||||||
|
AiChatPanel,
|
||||||
|
AiChatThinking
|
||||||
|
} from "./ai";
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
RootLayout,
|
RootLayout,
|
||||||
@@ -22,7 +28,11 @@ export default [
|
|||||||
NotifyEditor,
|
NotifyEditor,
|
||||||
NotifyView,
|
NotifyView,
|
||||||
BottomPopup,
|
BottomPopup,
|
||||||
Space
|
Space,
|
||||||
|
AiChatComposer,
|
||||||
|
AiChatMessageList,
|
||||||
|
AiChatPanel,
|
||||||
|
AiChatThinking
|
||||||
];
|
];
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -36,9 +46,39 @@ export {
|
|||||||
NotifyEditor,
|
NotifyEditor,
|
||||||
NotifyView,
|
NotifyView,
|
||||||
BottomPopup,
|
BottomPopup,
|
||||||
Space
|
Space,
|
||||||
|
AiChatComposer,
|
||||||
|
AiChatMessageList,
|
||||||
|
AiChatPanel,
|
||||||
|
AiChatThinking
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
AiChatHttpError,
|
||||||
|
AiChatStreamError,
|
||||||
|
ensureChatResponse,
|
||||||
|
parseAssistantResponse,
|
||||||
|
parseSseData,
|
||||||
|
readSseStream,
|
||||||
|
useAiChat
|
||||||
|
} from "./ai";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
AiChatCitationFormatter,
|
||||||
|
AiChatErrorMessages,
|
||||||
|
AiChatEventAction,
|
||||||
|
AiChatMessage,
|
||||||
|
AiChatMessageStatusFormatter,
|
||||||
|
AiChatRequestContext,
|
||||||
|
AiChatRoleNameFormatter,
|
||||||
|
AiChatRoleNames,
|
||||||
|
AiChatStreamEvent,
|
||||||
|
AiCitation,
|
||||||
|
AiMessageRole,
|
||||||
|
AiThinking,
|
||||||
|
UseAiChatOptions
|
||||||
|
} from "./ai";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
SpaceAlign,
|
SpaceAlign,
|
||||||
SpaceDirection,
|
SpaceDirection,
|
||||||
|
|||||||
Reference in New Issue
Block a user