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 BottomPopup from "./bottom-popup";
|
||||
import Space from "./space";
|
||||
import {
|
||||
AiChatComposer,
|
||||
AiChatMessageList,
|
||||
AiChatPanel,
|
||||
AiChatThinking
|
||||
} from "./ai";
|
||||
|
||||
export default [
|
||||
RootLayout,
|
||||
@@ -22,7 +28,11 @@ export default [
|
||||
NotifyEditor,
|
||||
NotifyView,
|
||||
BottomPopup,
|
||||
Space
|
||||
Space,
|
||||
AiChatComposer,
|
||||
AiChatMessageList,
|
||||
AiChatPanel,
|
||||
AiChatThinking
|
||||
];
|
||||
|
||||
export {
|
||||
@@ -36,9 +46,39 @@ export {
|
||||
NotifyEditor,
|
||||
NotifyView,
|
||||
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 {
|
||||
SpaceAlign,
|
||||
SpaceDirection,
|
||||
|
||||
Reference in New Issue
Block a user