add aiChat

This commit is contained in:
Timi
2026-09-20 12:03:03 +08:00
parent 3d2968cb3b
commit b435bf992e
8 changed files with 1782 additions and 2 deletions
+163
View File
@@ -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>
+341
View File
@@ -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>
+221
View File
@@ -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>
+94
View File
@@ -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>
+31
View File
@@ -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";
+106
View File
@@ -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;
+784
View File
@@ -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
View File
@@ -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,