feat: 接入聊天历史界面与外链会话恢复
- 新增管理端与用户端聊天历史接口和页面 - 外链聊天支持访问令牌登录、身份保活与当前会话恢复 - 聊天执行链路切到统一 runtime 与 chatlog 查询接口
This commit is contained in:
@@ -148,6 +148,7 @@ export const api = createRequestClient(apiURL, {
|
||||
export const baseRequestClient = new RequestClient({ baseURL: apiURL });
|
||||
|
||||
export interface SseOptions {
|
||||
headers?: HeadersInit;
|
||||
onMessage?: (message: ServerSentEventMessage) => void;
|
||||
onError?: (err: any) => void;
|
||||
onFinished?: () => void;
|
||||
@@ -186,7 +187,7 @@ export class SseClient {
|
||||
const res = await fetch(apiURL + url, {
|
||||
method: 'POST',
|
||||
signal, // 使用局部变量 signal
|
||||
headers: this.getHeaders(),
|
||||
headers: this.getHeaders(options?.headers),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
@@ -233,13 +234,20 @@ export class SseClient {
|
||||
}
|
||||
}
|
||||
|
||||
private getHeaders() {
|
||||
private getHeaders(extraHeaders?: HeadersInit) {
|
||||
const accessStore = useAccessStore();
|
||||
return {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'text/event-stream',
|
||||
'Content-Type': 'application/json',
|
||||
'easyflow-token': accessStore.accessToken || '',
|
||||
};
|
||||
if (!extraHeaders) {
|
||||
return headers;
|
||||
}
|
||||
new Headers(extraHeaders).forEach((value, key) => {
|
||||
headers[key] = value;
|
||||
});
|
||||
return headers;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
registerLoadingDirective,
|
||||
setDefaultModalProps,
|
||||
} from '@easyflow/common-ui';
|
||||
import '@easyflow/icons';
|
||||
import { preferences } from '@easyflow/preferences';
|
||||
import { initStores } from '@easyflow/stores';
|
||||
import '@easyflow/styles';
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
<script setup lang="ts">
|
||||
import { ChatThinkingBlock } from '@easyflow/common-ui';
|
||||
import ElXMarkdown from 'vue-element-plus-x/es/XMarkdown/index.js';
|
||||
|
||||
import { IconifyIcon } from '@easyflow/icons';
|
||||
|
||||
import { CircleCheck, Close } from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElButton,
|
||||
ElCollapse,
|
||||
ElCollapseItem,
|
||||
ElEmpty,
|
||||
ElIcon,
|
||||
ElScrollbar,
|
||||
} from 'element-plus';
|
||||
|
||||
import ShowJson from '#/components/json/ShowJson.vue';
|
||||
|
||||
interface ChatHistoryDetailDrawerProps {
|
||||
visible?: boolean;
|
||||
loading?: boolean;
|
||||
session?: any;
|
||||
messages?: any[];
|
||||
hasMore?: boolean;
|
||||
onLoadMore?: (() => void | Promise<void>) | undefined;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<ChatHistoryDetailDrawerProps>(), {
|
||||
visible: false,
|
||||
loading: false,
|
||||
session: undefined,
|
||||
messages: () => [],
|
||||
hasMore: false,
|
||||
onLoadMore: undefined,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
function formatTime(value?: string) {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
const time = new Date(value);
|
||||
if (Number.isNaN(time.getTime())) {
|
||||
return value;
|
||||
}
|
||||
const year = time.getFullYear();
|
||||
const month = String(time.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(time.getDate()).padStart(2, '0');
|
||||
const hour = String(time.getHours()).padStart(2, '0');
|
||||
const minute = String(time.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hour}:${minute}`;
|
||||
}
|
||||
|
||||
function resolveSenderName(item: any) {
|
||||
if (item?.senderName) {
|
||||
return item.senderName;
|
||||
}
|
||||
return item?.role === 'assistant' ? '聊天助手' : '聊天用户';
|
||||
}
|
||||
|
||||
function isThinkingChain(chain: any) {
|
||||
return !chain?.id;
|
||||
}
|
||||
|
||||
function toolStatusText(status?: string) {
|
||||
return status === 'TOOL_RESULT' ? '调用成功' : '工具调用中';
|
||||
}
|
||||
|
||||
async function handleLoadMore() {
|
||||
await props.onLoadMore?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-loading="loading"
|
||||
class="chat-history-detail"
|
||||
:class="{ 'is-visible': visible }"
|
||||
>
|
||||
<div class="chat-history-detail__summary">
|
||||
<div class="chat-history-detail__summary-main">
|
||||
<div class="chat-history-detail__title-row">
|
||||
<h2 class="chat-history-detail__title">
|
||||
{{ session?.title || '未命名会话' }}
|
||||
</h2>
|
||||
<span class="chat-history-detail__assistant-tag">
|
||||
{{ session?.assistantName || '聊天助手' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="chat-history-detail__meta-row">
|
||||
<span class="chat-history-detail__meta-inline">
|
||||
<span class="chat-history-detail__meta-label">聊天用户</span>
|
||||
<span class="chat-history-detail__meta-value">
|
||||
{{ session?.userAccount || '-' }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="chat-history-detail__meta-divider"></span>
|
||||
<span class="chat-history-detail__meta-inline">
|
||||
<span class="chat-history-detail__meta-label">最近活跃</span>
|
||||
<span class="chat-history-detail__meta-value">
|
||||
{{ formatTime(session?.lastMessageAt || session?.accessAt) }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="chat-history-detail__meta-divider"></span>
|
||||
<span class="chat-history-detail__meta-inline">
|
||||
<span class="chat-history-detail__meta-label">消息数</span>
|
||||
<span class="chat-history-detail__meta-value">
|
||||
{{ session?.messageCount ?? messages.length ?? 0 }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="chat-history-detail__close"
|
||||
aria-label="关闭聊天详情"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<ElIcon size="18">
|
||||
<Close />
|
||||
</ElIcon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="chat-history-detail__stream">
|
||||
<div class="chat-history-detail__stream-toolbar">
|
||||
<ElButton
|
||||
v-if="hasMore"
|
||||
text
|
||||
type="primary"
|
||||
@click="handleLoadMore"
|
||||
>
|
||||
加载更早消息
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<ElScrollbar class="chat-history-detail__scrollbar">
|
||||
<div
|
||||
v-if="messages.length > 0"
|
||||
class="chat-history-detail__message-list"
|
||||
>
|
||||
<article
|
||||
v-for="item in messages"
|
||||
:key="item.key"
|
||||
class="chat-history-detail__message"
|
||||
:class="`is-${item.role}`"
|
||||
>
|
||||
<div
|
||||
class="chat-history-detail__message-meta"
|
||||
:class="`is-${item.role}`"
|
||||
>
|
||||
<span class="chat-history-detail__message-author">
|
||||
{{ resolveSenderName(item) }}
|
||||
</span>
|
||||
<span class="chat-history-detail__message-time">
|
||||
{{ formatTime(item.created) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="chat-history-detail__message-bubble"
|
||||
:class="`is-${item.role}`"
|
||||
>
|
||||
<div
|
||||
v-if="item.chains?.length"
|
||||
class="chat-history-detail__message-chains"
|
||||
>
|
||||
<template
|
||||
v-for="(chain, index) in item.chains"
|
||||
:key="chain.id || index"
|
||||
>
|
||||
<ChatThinkingBlock
|
||||
v-if="isThinkingChain(chain)"
|
||||
v-model:expanded="chain.thinkingExpanded"
|
||||
:content="chain.reasoning_content"
|
||||
readonly
|
||||
:status="chain.thinkingStatus"
|
||||
class="chat-history-detail__thinking"
|
||||
/>
|
||||
|
||||
<ElCollapse v-else class="chat-history-detail__tool-panel">
|
||||
<ElCollapseItem :title="chain.name" :name="chain.id">
|
||||
<template #title>
|
||||
<div class="chat-history-detail__tool-title">
|
||||
<ElIcon size="16">
|
||||
<IconifyIcon icon="svg:wrench" />
|
||||
</ElIcon>
|
||||
<span class="chat-history-detail__tool-name">
|
||||
{{ chain.name }}
|
||||
</span>
|
||||
<div class="chat-history-detail__tool-status">
|
||||
<ElIcon
|
||||
v-if="chain.status === 'TOOL_RESULT'"
|
||||
size="14"
|
||||
color="var(--el-color-success)"
|
||||
>
|
||||
<CircleCheck />
|
||||
</ElIcon>
|
||||
<IconifyIcon
|
||||
v-else
|
||||
icon="mdi:clock-time-five-outline"
|
||||
/>
|
||||
<span>{{ toolStatusText(chain.status) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<ShowJson :value="chain.result" />
|
||||
</ElCollapseItem>
|
||||
</ElCollapse>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="item.content && String(item.content).trim()"
|
||||
class="chat-history-detail__markdown"
|
||||
>
|
||||
<ElXMarkdown :markdown="item.content" />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<ElEmpty v-else description="暂无聊天消息" />
|
||||
</ElScrollbar>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chat-history-detail {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at top right,
|
||||
hsl(var(--nav-ambient) / 0.1),
|
||||
transparent 26%
|
||||
),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
hsl(var(--glass-border) / 0.18) 0%,
|
||||
hsl(var(--glass-tint) / 0.34) 10%,
|
||||
hsl(var(--surface-panel) / 0.88) 28%,
|
||||
hsl(var(--surface-panel) / 0.96) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.chat-history-detail__summary {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 20px 24px 12px;
|
||||
border-bottom: 1px solid hsl(var(--divider-faint) / 0.16);
|
||||
}
|
||||
|
||||
.chat-history-detail__summary-main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chat-history-detail__title-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.chat-history-detail__title {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--text-strong));
|
||||
}
|
||||
|
||||
.chat-history-detail__assistant-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 26px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid hsl(var(--glass-border) / 0.38);
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--glass-tint) / 0.54);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--nav-item-active-foreground));
|
||||
}
|
||||
|
||||
.chat-history-detail__meta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.chat-history-detail__meta-label {
|
||||
font-size: 11px;
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.chat-history-detail__meta-value {
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--text-strong));
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.chat-history-detail__meta-inline {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-history-detail__meta-divider {
|
||||
display: inline-flex;
|
||||
height: 12px;
|
||||
width: 1px;
|
||||
background: hsl(var(--divider-faint) / 0.42);
|
||||
}
|
||||
|
||||
.chat-history-detail__close {
|
||||
display: inline-flex;
|
||||
height: 36px;
|
||||
width: 36px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid hsl(var(--divider-faint) / 0.24);
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--glass-tint) / 0.46);
|
||||
color: hsl(var(--text-muted));
|
||||
transition:
|
||||
transform 0.18s ease,
|
||||
background-color 0.18s ease,
|
||||
color 0.18s ease;
|
||||
}
|
||||
|
||||
.chat-history-detail__close:hover {
|
||||
color: hsl(var(--text-strong));
|
||||
background: hsl(var(--surface-contrast-soft) / 0.92);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.chat-history-detail__stream {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
padding: 14px 24px 20px;
|
||||
}
|
||||
|
||||
.chat-history-detail__stream-toolbar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.chat-history-detail__scrollbar {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chat-history-detail__message-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.chat-history-detail__message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-history-detail__message.is-user {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.chat-history-detail__message.is-assistant {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.chat-history-detail__message-meta {
|
||||
display: inline-flex;
|
||||
max-width: 88%;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 11px;
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.chat-history-detail__message-meta.is-user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.chat-history-detail__message-author {
|
||||
font-weight: 600;
|
||||
color: hsl(var(--text-strong));
|
||||
}
|
||||
|
||||
.chat-history-detail__message-bubble {
|
||||
width: min(88%, 760px);
|
||||
border: 1px solid hsl(var(--divider-faint) / 0.34);
|
||||
border-radius: 22px;
|
||||
padding: 14px 16px;
|
||||
font-size: 14px;
|
||||
line-height: 1.72;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.chat-history-detail__message-bubble.is-assistant {
|
||||
background: hsl(var(--glass-tint) / 0.88);
|
||||
box-shadow:
|
||||
inset 0 1px 0 hsl(var(--glass-border) / 0.48),
|
||||
0 12px 26px -24px hsl(var(--foreground) / 0.18);
|
||||
}
|
||||
|
||||
.chat-history-detail__message-bubble.is-user {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
hsl(var(--primary) / 0.14) 0%,
|
||||
hsl(var(--surface-panel) / 0.96) 100%
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 1px 0 hsl(var(--glass-border) / 0.42),
|
||||
0 16px 30px -26px hsl(var(--primary) / 0.24);
|
||||
}
|
||||
|
||||
.chat-history-detail__message-chains {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.chat-history-detail__thinking {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-history-detail__tool-panel {
|
||||
overflow: hidden;
|
||||
border: 1px solid hsl(var(--divider-faint) / 0.24);
|
||||
border-radius: 14px;
|
||||
background: hsl(var(--surface-panel) / 0.68);
|
||||
box-shadow: inset 0 1px 0 hsl(var(--glass-border) / 0.18);
|
||||
}
|
||||
|
||||
.chat-history-detail__tool-title {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-left: 6px;
|
||||
}
|
||||
|
||||
.chat-history-detail__tool-name {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-history-detail__tool-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--surface-contrast-soft) / 0.92);
|
||||
font-size: 12px;
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.chat-history-detail__markdown {
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.72;
|
||||
}
|
||||
|
||||
.chat-history-detail__markdown :deep(.markdown-body) {
|
||||
background: transparent;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.chat-history-detail__markdown :deep(.markdown-body > :first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.chat-history-detail__markdown :deep(.markdown-body > :last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chat-history-detail__markdown :deep(.markdown-body p) {
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.chat-history-detail__markdown :deep(pre) {
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.chat-history-detail__tool-panel :deep(.el-collapse-item__wrap) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chat-history-detail__tool-panel :deep(.el-collapse-item__header) {
|
||||
min-height: 44px;
|
||||
padding-right: 14px;
|
||||
background: transparent;
|
||||
border-bottom-color: hsl(var(--divider-faint) / 0.16);
|
||||
}
|
||||
|
||||
.chat-history-detail__message-bubble :deep(.el-collapse-item__content) {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.chat-history-detail__summary {
|
||||
padding: 18px 18px 12px;
|
||||
}
|
||||
|
||||
.chat-history-detail__stream {
|
||||
padding: 12px 18px 18px;
|
||||
}
|
||||
|
||||
.chat-history-detail__meta-row {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-history-detail__meta-divider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-history-detail__meta-inline {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-history-detail__message-bubble,
|
||||
.chat-history-detail__message-meta {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -3,16 +3,18 @@ import type {
|
||||
BubbleListInstance,
|
||||
BubbleListProps,
|
||||
} from 'vue-element-plus-x/types/BubbleList';
|
||||
import type { ThinkingStatus } from 'vue-element-plus-x/types/Thinking';
|
||||
import type { TypewriterInstance } from 'vue-element-plus-x/types/Typewriter';
|
||||
|
||||
import {
|
||||
ChatThinkingBlock,
|
||||
type ChatThinkingBlockStatus,
|
||||
} from '@easyflow/common-ui';
|
||||
import type { BotInfo, ChatMessage } from '@easyflow/types';
|
||||
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch, watchEffect } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import ElBubbleList from 'vue-element-plus-x/es/BubbleList/index.js';
|
||||
import ElSender from 'vue-element-plus-x/es/Sender/index.js';
|
||||
import ElThinking from 'vue-element-plus-x/es/Thinking/index.js';
|
||||
import ElXMarkdown from 'vue-element-plus-x/es/XMarkdown/index.js';
|
||||
|
||||
import { IconifyIcon } from '@easyflow/icons';
|
||||
@@ -49,8 +51,8 @@ import SendingIcon from '../icons/SendingIcon.vue';
|
||||
|
||||
type Think = {
|
||||
reasoning_content?: string;
|
||||
thinkingStatus?: ThinkingStatus;
|
||||
thinlCollapse?: boolean;
|
||||
thinkingStatus?: ChatThinkingBlockStatus;
|
||||
thinkingExpanded?: boolean;
|
||||
};
|
||||
|
||||
type Tool = {
|
||||
@@ -329,7 +331,7 @@ const handleSubmit = async (refreshContent: string) => {
|
||||
if (index === -1) {
|
||||
chains.push({
|
||||
thinkingStatus: 'thinking',
|
||||
thinlCollapse: true,
|
||||
thinkingExpanded: false,
|
||||
reasoning_content: delta,
|
||||
});
|
||||
} else {
|
||||
@@ -525,13 +527,14 @@ onBeforeUnmount(() => {
|
||||
v-for="(chain, index) in item.chains"
|
||||
:key="chain.id || index"
|
||||
>
|
||||
<ElThinking
|
||||
<ChatThinkingBlock
|
||||
v-if="isThink(chain)"
|
||||
v-model="chain.thinlCollapse"
|
||||
v-model:expanded="chain.thinkingExpanded"
|
||||
:content="chain.reasoning_content"
|
||||
:status="chain.thinkingStatus"
|
||||
class="chat-thinking-block-item"
|
||||
/>
|
||||
<ElCollapse v-else class="mb-2">
|
||||
<ElCollapse v-else class="chat-tool-panel">
|
||||
<ElCollapseItem :title="chain.name" :name="chain.id">
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2 pl-5">
|
||||
@@ -569,42 +572,6 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- <ElThinking
|
||||
v-if="item.reasoning_content"
|
||||
v-model="item.thinlCollapse"
|
||||
:content="item.reasoning_content"
|
||||
:status="item.thinkingStatus"
|
||||
class="mb-3"
|
||||
/> -->
|
||||
<!-- <ElCollapse v-if="item.tools" class="mb-2">
|
||||
<ElCollapseItem
|
||||
class="mb-2"
|
||||
v-for="tool in item.tools"
|
||||
:key="tool.id"
|
||||
:title="tool.name"
|
||||
:name="tool.id"
|
||||
>
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2 pl-5">
|
||||
<ElIcon size="16">
|
||||
<IconifyIcon icon="svg:wrench" />
|
||||
</ElIcon>
|
||||
<span>{{ tool.name }}</span>
|
||||
<template v-if="tool.status === 'TOOL_CALL'">
|
||||
<ElIcon size="16">
|
||||
<IconifyIcon icon="svg:spinner" />
|
||||
</ElIcon>
|
||||
</template>
|
||||
<template v-else>
|
||||
<ElIcon size="16" color="var(--el-color-success)">
|
||||
<CircleCheck />
|
||||
</ElIcon>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<ShowJson :value="tool.result" />
|
||||
</ElCollapseItem>
|
||||
</ElCollapse> -->
|
||||
</div>
|
||||
</template>
|
||||
<!-- 自定义头像 -->
|
||||
@@ -799,22 +766,38 @@ onBeforeUnmount(() => {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
:deep(.el-thinking) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:deep(.el-thinking .content-wrapper) {
|
||||
--el-thinking-content-wrapper-width: var(--bubble-content-max-width);
|
||||
|
||||
.chat-thinking-block-item {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
:deep(.el-collapse-item) {
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
.chat-tool-panel {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
:deep(.el-collapse-item__content) {
|
||||
:deep(.chat-tool-panel.el-collapse) {
|
||||
border: 1px solid hsl(var(--divider-faint) / 0.26);
|
||||
border-radius: 14px;
|
||||
background: hsl(var(--surface-panel) / 0.7);
|
||||
box-shadow: inset 0 1px 0 hsl(var(--glass-border) / 0.2);
|
||||
}
|
||||
|
||||
:deep(.chat-tool-panel .el-collapse-item) {
|
||||
overflow: hidden;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
:deep(.chat-tool-panel .el-collapse-item__wrap) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:deep(.chat-tool-panel .el-collapse-item__header) {
|
||||
min-height: 44px;
|
||||
padding-right: 14px;
|
||||
background: transparent;
|
||||
border-bottom-color: hsl(var(--divider-faint) / 0.16);
|
||||
}
|
||||
|
||||
:deep(.chat-tool-panel .el-collapse-item__content) {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
"notConfigured": "NotConfigured",
|
||||
"chatPublishBaseUrlMissing": "Publish base URL is not configured. Please set it in system settings first.",
|
||||
"chatExternalLink": "Chat External Link",
|
||||
"chatAccessToken": "Access Token",
|
||||
"chatAccessTokenPlaceholder": "Optional. Selected links will include the token",
|
||||
"chatAccessTokenHint": "Only enabled access tokens with public-api chat permission are listed. After selection, copied links and iframe code will automatically include the token.",
|
||||
"iframeEmbedCode": "Iframe Embed Code",
|
||||
"copyLink": "Copy Link",
|
||||
"copyIframeCode": "Copy Code",
|
||||
@@ -36,6 +39,7 @@
|
||||
"publicChatLoading": "Initializing chat environment...",
|
||||
"publicChatThinking": "Thinking...",
|
||||
"publicChatInitError": "Initialization failed, please try again later",
|
||||
"publicChatTokenInvalid": "The access token is invalid or expired",
|
||||
"publicChatAssistantReply": "Assistant Reply",
|
||||
"publicChatToolCalling": "Calling tool",
|
||||
"publicChatToolDone": "Tool completed",
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
"notConfigured": "未配置",
|
||||
"chatPublishBaseUrlMissing": "未配置发布域名,请先到系统设置中配置",
|
||||
"chatExternalLink": "聊天外链",
|
||||
"chatAccessToken": "访问令牌",
|
||||
"chatAccessTokenPlaceholder": "可选,选择后外链将携带访问令牌",
|
||||
"chatAccessTokenHint": "仅展示已启用且具备 public-api 聊天权限的访问令牌。选中后,复制链接和 iframe 代码会自动附带该令牌。",
|
||||
"iframeEmbedCode": "iframe 嵌入代码",
|
||||
"copyLink": "复制链接",
|
||||
"copyIframeCode": "复制代码",
|
||||
@@ -36,6 +39,7 @@
|
||||
"publicChatLoading": "正在初始化聊天环境...",
|
||||
"publicChatThinking": "思考中...",
|
||||
"publicChatInitError": "初始化失败,请稍后重试",
|
||||
"publicChatTokenInvalid": "访问令牌无效或已过期",
|
||||
"publicChatAssistantReply": "助手回复",
|
||||
"publicChatToolCalling": "工具调用中",
|
||||
"publicChatToolDone": "工具已返回",
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type {AiLlm, BotInfo} from '@easyflow/types';
|
||||
import type { AiLlm, BotInfo } from '@easyflow/types';
|
||||
|
||||
import {computed, onMounted, ref, watch} from 'vue';
|
||||
import {useRoute} from 'vue-router';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import {$t} from '@easyflow/locales';
|
||||
import {useBotStore} from '@easyflow/stores';
|
||||
import { $t } from '@easyflow/locales';
|
||||
import { useBotStore } from '@easyflow/stores';
|
||||
|
||||
import {CopyDocument, Delete, InfoFilled, Link, Plus, Setting} from '@element-plus/icons-vue';
|
||||
import {useDebounceFn} from '@vueuse/core';
|
||||
import {
|
||||
CopyDocument,
|
||||
Delete,
|
||||
InfoFilled,
|
||||
Link,
|
||||
Plus,
|
||||
Setting,
|
||||
} from '@element-plus/icons-vue';
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import {
|
||||
ElAlert,
|
||||
ElButton,
|
||||
@@ -19,6 +26,7 @@ import {
|
||||
ElInput,
|
||||
ElInputNumber,
|
||||
ElMessage,
|
||||
ElOption,
|
||||
ElRow,
|
||||
ElSelect,
|
||||
ElSkeleton,
|
||||
@@ -26,7 +34,7 @@ import {
|
||||
ElSwitch,
|
||||
ElTooltip,
|
||||
} from 'element-plus';
|
||||
import {tryit} from 'radash';
|
||||
import { tryit } from 'radash';
|
||||
|
||||
import {
|
||||
getPerQuestions,
|
||||
@@ -35,7 +43,7 @@ import {
|
||||
updateLlmId,
|
||||
updateLlmOptions,
|
||||
} from '#/api';
|
||||
import {api} from '#/api/request';
|
||||
import { api } from '#/api/request';
|
||||
import ProblemPresupposition from '#/components/chat/ProblemPresupposition.vue';
|
||||
import PublishWxOfficalAccount from '#/components/chat/PublishWxOfficalAccount.vue';
|
||||
import CollapseViewItem from '#/components/collapseViewItem/CollapseViewItem.vue';
|
||||
@@ -47,6 +55,14 @@ interface SelectedMcpTool {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface ApiKeyOption {
|
||||
id: string;
|
||||
apiKey: string;
|
||||
expiredAt?: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
bot?: BotInfo;
|
||||
hasSavePermission?: boolean;
|
||||
@@ -68,6 +84,8 @@ const dialogueSettings = ref({
|
||||
enableDeepThinking: false,
|
||||
anonymousEnabled: false,
|
||||
});
|
||||
const selectedPublishApiKey = ref('');
|
||||
const publishApiKeyOptions = ref<ApiKeyOption[]>([]);
|
||||
const publishBaseUrl = ref('');
|
||||
const routerHistoryMode = import.meta.env.VITE_ROUTER_HISTORY;
|
||||
const normalizePublishBaseUrl = (value: string) => {
|
||||
@@ -102,18 +120,29 @@ const publicChatPath = computed(() =>
|
||||
? `/#/embed/chat/${botId.value}`
|
||||
: `/embed/chat/${botId.value}`,
|
||||
);
|
||||
const publicChatUrl = computed(() => {
|
||||
const buildPublicChatUrl = (embed = false) => {
|
||||
if (!hasPublishBaseUrl.value) {
|
||||
return '';
|
||||
}
|
||||
const base = normalizePublishBaseUrl(publishBaseUrl.value);
|
||||
return `${base}${publicChatPath.value}`;
|
||||
const query = new URLSearchParams();
|
||||
if (selectedPublishApiKey.value) {
|
||||
query.set('token', selectedPublishApiKey.value);
|
||||
}
|
||||
if (embed) {
|
||||
query.set('embed', '1');
|
||||
}
|
||||
const queryString = query.toString();
|
||||
if (!queryString) {
|
||||
return `${base}${publicChatPath.value}`;
|
||||
}
|
||||
return `${base}${publicChatPath.value}?${queryString}`;
|
||||
};
|
||||
const publicChatUrl = computed(() => {
|
||||
return buildPublicChatUrl(false);
|
||||
});
|
||||
const publicChatEmbedUrl = computed(() => {
|
||||
if (!publicChatUrl.value) {
|
||||
return '';
|
||||
}
|
||||
return `${publicChatUrl.value}?embed=1`;
|
||||
return buildPublicChatUrl(true);
|
||||
});
|
||||
const iframeCode = computed(() => {
|
||||
if (!publicChatEmbedUrl.value) {
|
||||
@@ -233,7 +262,9 @@ const updatingBotIcon = ref(false);
|
||||
const updatingBasicInfo = ref(false);
|
||||
const syncingBasicInfoForm = ref(false);
|
||||
const getPublishBaseUrl = async () => {
|
||||
const [, res] = await tryit(api.get)('/api/v1/sysOption/list?keys=chat_publish_base_url');
|
||||
const [, res] = await tryit(api.get)(
|
||||
'/api/v1/sysOption/list?keys=chat_publish_base_url',
|
||||
);
|
||||
if (res?.errorCode === 0) {
|
||||
publishBaseUrl.value = (res.data?.chat_publish_base_url || '').trim();
|
||||
}
|
||||
@@ -267,6 +298,84 @@ const getBotDetail = async () => {
|
||||
}
|
||||
});
|
||||
};
|
||||
const formatApiKeyOptionLabel = (apiKey: string, expiredAt?: string) => {
|
||||
const normalized = String(apiKey || '').trim();
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
const prefix = normalized.slice(0, 8);
|
||||
const suffix = normalized.slice(-6);
|
||||
const baseLabel =
|
||||
normalized.length > 18 ? `${prefix}...${suffix}` : normalized;
|
||||
return expiredAt ? `${baseLabel} · ${expiredAt}` : baseLabel;
|
||||
};
|
||||
const getPublishApiKeyOptions = async () => {
|
||||
const [resourceErr, resourceRes] = await tryit(api.get)(
|
||||
'/api/v1/sysApiKeyResourcePermission/list',
|
||||
);
|
||||
if (
|
||||
resourceErr ||
|
||||
resourceRes?.errorCode !== 0 ||
|
||||
!Array.isArray(resourceRes?.data)
|
||||
) {
|
||||
publishApiKeyOptions.value = [];
|
||||
return;
|
||||
}
|
||||
const publicChatResource = resourceRes.data.find(
|
||||
(item: any) => item?.requestInterface === '/public-api/bot/chat',
|
||||
);
|
||||
if (!publicChatResource?.id) {
|
||||
publishApiKeyOptions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const [apiKeyErr, apiKeyRes] = await tryit(api.get)(
|
||||
'/api/v1/sysApiKey/page',
|
||||
{
|
||||
params: {
|
||||
pageNumber: 1,
|
||||
pageSize: 200,
|
||||
sortKey: 'created',
|
||||
sortType: 'desc',
|
||||
status: 1,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (apiKeyErr || apiKeyRes?.errorCode !== 0) {
|
||||
publishApiKeyOptions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const records = apiKeyRes?.data?.records || [];
|
||||
const now = Date.now();
|
||||
publishApiKeyOptions.value = records
|
||||
.filter((item: any) => {
|
||||
if (item?.status !== 1) {
|
||||
return false;
|
||||
}
|
||||
if (item?.expiredAt && new Date(item.expiredAt).getTime() <= now) {
|
||||
return false;
|
||||
}
|
||||
const permissionIds = Array.isArray(item?.permissionIds)
|
||||
? item.permissionIds.map(String)
|
||||
: [];
|
||||
return permissionIds.includes(String(publicChatResource.id));
|
||||
})
|
||||
.map((item: any) => ({
|
||||
id: String(item.id || ''),
|
||||
apiKey: String(item.apiKey || ''),
|
||||
expiredAt: item.expiredAt,
|
||||
label: formatApiKeyOptionLabel(item.apiKey, item.expiredAt),
|
||||
}));
|
||||
if (
|
||||
selectedPublishApiKey.value &&
|
||||
!publishApiKeyOptions.value.some(
|
||||
(item) => item.apiKey === selectedPublishApiKey.value,
|
||||
)
|
||||
) {
|
||||
selectedPublishApiKey.value = '';
|
||||
}
|
||||
};
|
||||
const getLlmListData = async () => {
|
||||
const url = `/api/v1/model/list?modelType=chatModel&added=true`;
|
||||
api.get(url, {}).then((res) => {
|
||||
@@ -277,6 +386,7 @@ const getLlmListData = async () => {
|
||||
};
|
||||
onMounted(async () => {
|
||||
getPublishBaseUrl();
|
||||
getPublishApiKeyOptions();
|
||||
getAiBotPluginToolList();
|
||||
getAiBotKnowledgeList();
|
||||
getAiBotWorkflowList();
|
||||
@@ -285,9 +395,7 @@ onMounted(async () => {
|
||||
getLlmListData();
|
||||
});
|
||||
|
||||
const handleAnonymousAccessChange = (
|
||||
value: boolean | number | string,
|
||||
) => {
|
||||
const handleAnonymousAccessChange = (value: boolean | number | string) => {
|
||||
handleDialogOptionsStrChange('anonymousEnabled', value);
|
||||
};
|
||||
|
||||
@@ -681,14 +789,19 @@ const handleBasicInfoChange = async (
|
||||
key: 'alias' | 'categoryId' | 'title',
|
||||
value: any,
|
||||
) => {
|
||||
if (!botInfo.value || !props.hasSavePermission || syncingBasicInfoForm.value) {
|
||||
if (
|
||||
!botInfo.value ||
|
||||
!props.hasSavePermission ||
|
||||
syncingBasicInfoForm.value
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (updatingBasicInfo.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedValue = key === 'categoryId' ? value : String(value || '').trim();
|
||||
const normalizedValue =
|
||||
key === 'categoryId' ? value : String(value || '').trim();
|
||||
if ((key === 'title' || key === 'alias') && !normalizedValue) {
|
||||
ElMessage.warning($t('message.required'));
|
||||
basicInfoForm.value[key] = botInfo.value[key] as string;
|
||||
@@ -729,7 +842,7 @@ const handleBasicInfoChange = async (
|
||||
<div
|
||||
:class="[
|
||||
'bot-avatar-upload-wrap',
|
||||
(!hasSavePermission || updatingBotIcon) ? 'is-disabled' : '',
|
||||
!hasSavePermission || updatingBotIcon ? 'is-disabled' : '',
|
||||
]"
|
||||
>
|
||||
<UploadAvatar
|
||||
@@ -742,7 +855,9 @@ const handleBasicInfoChange = async (
|
||||
</div>
|
||||
<div class="bot-basic-form-panel">
|
||||
<div class="bot-basic-form-item">
|
||||
<span class="bot-basic-form-label">{{ $t('aiWorkflow.title') }}</span>
|
||||
<span class="bot-basic-form-label">{{
|
||||
$t('aiWorkflow.title')
|
||||
}}</span>
|
||||
<ElInput
|
||||
v-model="basicInfoForm.title"
|
||||
:disabled="!hasSavePermission || updatingBasicInfo"
|
||||
@@ -758,12 +873,16 @@ const handleBasicInfoChange = async (
|
||||
/>
|
||||
</div>
|
||||
<div class="bot-basic-form-item">
|
||||
<span class="bot-basic-form-label">{{ $t('aiWorkflow.categoryId') }}</span>
|
||||
<span class="bot-basic-form-label">{{
|
||||
$t('aiWorkflow.categoryId')
|
||||
}}</span>
|
||||
<DictSelect
|
||||
v-model="basicInfoForm.categoryId"
|
||||
dict-code="aiBotCategory"
|
||||
:disabled="!hasSavePermission || updatingBasicInfo"
|
||||
@change="(value: any) => handleBasicInfoChange('categoryId', value)"
|
||||
@change="
|
||||
(value: any) => handleBasicInfoChange('categoryId', value)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1165,6 +1284,25 @@ const handleBasicInfoChange = async (
|
||||
:closable="false"
|
||||
/>
|
||||
</div>
|
||||
<div class="publish-external-item">
|
||||
<label class="publish-external-label">
|
||||
{{ $t('bot.chatAccessToken') }}
|
||||
</label>
|
||||
<ElSelect
|
||||
v-model="selectedPublishApiKey"
|
||||
clearable
|
||||
filterable
|
||||
class="w-full"
|
||||
:placeholder="$t('bot.chatAccessTokenPlaceholder')"
|
||||
>
|
||||
<ElOption
|
||||
v-for="item in publishApiKeyOptions"
|
||||
:key="item.id"
|
||||
:label="item.label"
|
||||
:value="item.apiKey"
|
||||
/>
|
||||
</ElSelect>
|
||||
</div>
|
||||
<div class="publish-external-item">
|
||||
<label class="publish-external-label">
|
||||
{{ $t('bot.chatExternalLink') }}
|
||||
@@ -1182,11 +1320,7 @@ const handleBasicInfoChange = async (
|
||||
</ElIcon>
|
||||
{{ $t('bot.copyLink') }}
|
||||
</ElButton>
|
||||
<ElButton
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="openPublicPage"
|
||||
>
|
||||
<ElButton size="small" type="primary" @click="openPublicPage">
|
||||
<ElIcon class="mr-1">
|
||||
<Link />
|
||||
</ElIcon>
|
||||
@@ -1197,15 +1331,16 @@ const handleBasicInfoChange = async (
|
||||
<div class="publish-external-item">
|
||||
<label class="publish-external-label flex items-center gap-1">
|
||||
<span>{{ $t('bot.iframeEmbedCode') }}</span>
|
||||
<ElTooltip
|
||||
effect="dark"
|
||||
placement="top"
|
||||
>
|
||||
<ElTooltip effect="dark" placement="top">
|
||||
<template #content>
|
||||
<div>{{ $t('bot.embedUsageTip1') }}</div>
|
||||
<div class="mt-1">{{ $t('bot.embedUsageTip2') }}</div>
|
||||
</template>
|
||||
<ElIcon class="text-gray-400 hover:text-gray-600 cursor-pointer transition-colors"><InfoFilled /></ElIcon>
|
||||
<ElIcon
|
||||
class="cursor-pointer text-gray-400 transition-colors hover:text-gray-600"
|
||||
>
|
||||
<InfoFilled />
|
||||
</ElIcon>
|
||||
</ElTooltip>
|
||||
</label>
|
||||
<div class="publish-external-code-preview">
|
||||
@@ -1447,6 +1582,12 @@ const handleBasicInfoChange = async (
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.publish-external-hint {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.publish-external-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -1490,7 +1631,8 @@ const handleBasicInfoChange = async (
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
|
||||
'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
|
||||
646
easyflow-ui-admin/app/src/views/ai/chatHistory/index.vue
Normal file
646
easyflow-ui-admin/app/src/views/ai/chatHistory/index.vue
Normal file
@@ -0,0 +1,646 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import { useEasyFlowDrawer } from '@easyflow/common-ui';
|
||||
|
||||
import { Search } from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElButton,
|
||||
ElDatePicker,
|
||||
ElEmpty,
|
||||
ElInput,
|
||||
ElPagination,
|
||||
ElSelect,
|
||||
ElTable,
|
||||
ElTableColumn,
|
||||
} from 'element-plus';
|
||||
import { tryit } from 'radash';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import ChatHistoryDetailDrawer from '#/components/chat-history/ChatHistoryDetailDrawer.vue';
|
||||
import ListPageShell from '#/components/page/ListPageShell.vue';
|
||||
|
||||
const assistantList = ref<any[]>([]);
|
||||
const sessions = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
const query = ref({
|
||||
assistantId: undefined as number | undefined,
|
||||
userAccount: '',
|
||||
timeRange: [] as string[],
|
||||
pageNumber: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
const quickRange = ref<'' | 'last7' | 'last30' | 'today'>('');
|
||||
const pageState = ref({
|
||||
total: 0,
|
||||
});
|
||||
const pageSizeOptions = [20, 50, 100];
|
||||
const quickRangeOptions = [
|
||||
{ label: '今天', value: 'today' as const },
|
||||
{ label: '最近 7 天', value: 'last7' as const },
|
||||
{ label: '最近 30 天', value: 'last30' as const },
|
||||
];
|
||||
|
||||
const drawerLoading = ref(false);
|
||||
const currentSession = ref<any>();
|
||||
const messageList = ref<any[]>([]);
|
||||
const messagePage = ref({
|
||||
total: 0,
|
||||
pageNumber: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
|
||||
const [Drawer, drawerApi] = useEasyFlowDrawer({
|
||||
appendToMain: false,
|
||||
class:
|
||||
'!w-[820px] max-w-[calc(100vw-24px)] !border-0 !bg-[hsl(var(--glass-tint))/0.28] shadow-[0_24px_48px_-36px_hsl(var(--foreground)/0.12)] supports-[backdrop-filter]:!bg-[hsl(var(--glass-tint))/0.2]',
|
||||
closable: false,
|
||||
contentClass: 'p-0',
|
||||
footer: false,
|
||||
header: false,
|
||||
modal: false,
|
||||
placement: 'right',
|
||||
});
|
||||
|
||||
const hasMoreMessages = computed(
|
||||
() => messageList.value.length < messagePage.value.total,
|
||||
);
|
||||
|
||||
const selectedSessionId = computed(() =>
|
||||
String(currentSession.value?.id || ''),
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchAssistants(), fetchSessions()]);
|
||||
});
|
||||
|
||||
async function fetchAssistants() {
|
||||
const [, res] = await tryit(api.get)('/api/v1/bot/list', {
|
||||
params: { status: 1 },
|
||||
});
|
||||
if (res?.errorCode === 0) {
|
||||
assistantList.value = (res.data || []).map((item: any) => ({
|
||||
label: item.title,
|
||||
value: item.id,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSessions() {
|
||||
loading.value = true;
|
||||
const [, res] = await tryit(api.get)('/api/v1/chatHistory/sessions', {
|
||||
params: {
|
||||
assistantId: query.value.assistantId,
|
||||
userAccount: query.value.userAccount || undefined,
|
||||
startTime: query.value.timeRange?.[0],
|
||||
endTime: query.value.timeRange?.[1],
|
||||
pageNumber: query.value.pageNumber,
|
||||
pageSize: query.value.pageSize,
|
||||
},
|
||||
});
|
||||
loading.value = false;
|
||||
if (res?.errorCode === 0) {
|
||||
sessions.value = res.data?.records || [];
|
||||
pageState.value.total = res.data?.total || 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function openSession(sessionId: number | string) {
|
||||
drawerLoading.value = true;
|
||||
currentSession.value =
|
||||
sessions.value.find((item: any) => String(item.id) === String(sessionId)) ||
|
||||
undefined;
|
||||
messageList.value = [];
|
||||
messagePage.value = {
|
||||
total: 0,
|
||||
pageNumber: 1,
|
||||
pageSize: 20,
|
||||
};
|
||||
drawerApi.open();
|
||||
|
||||
const [, summaryRes] = await tryit(api.get)(
|
||||
`/api/v1/chatHistory/sessions/${sessionId}`,
|
||||
);
|
||||
if (summaryRes?.errorCode !== 0) {
|
||||
drawerLoading.value = false;
|
||||
drawerApi.close();
|
||||
return;
|
||||
}
|
||||
|
||||
currentSession.value = summaryRes.data;
|
||||
|
||||
await loadMessages(true);
|
||||
drawerLoading.value = false;
|
||||
}
|
||||
|
||||
async function loadMessages(reset = false) {
|
||||
if (!currentSession.value?.id) {
|
||||
return;
|
||||
}
|
||||
const nextPageNumber = reset ? 1 : messagePage.value.pageNumber + 1;
|
||||
const [, res] = await tryit(api.get)(
|
||||
`/api/v1/chatHistory/sessions/${currentSession.value.id}/messages`,
|
||||
{
|
||||
params: {
|
||||
pageNumber: nextPageNumber,
|
||||
pageSize: messagePage.value.pageSize,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (res?.errorCode !== 0) {
|
||||
return;
|
||||
}
|
||||
const normalized = normalizeMessages(res.data?.records || []);
|
||||
messageList.value = reset
|
||||
? normalized
|
||||
: [...normalized, ...messageList.value];
|
||||
messagePage.value.total = res.data?.total || 0;
|
||||
messagePage.value.pageNumber = nextPageNumber;
|
||||
}
|
||||
|
||||
function normalizeMessages(records: any[]) {
|
||||
return [...records].reverse().map((item: any) => ({
|
||||
key: String(item.id),
|
||||
role: item.senderRole === 'assistant' ? 'assistant' : 'user',
|
||||
content:
|
||||
item.senderRole === 'assistant'
|
||||
? String(item.contentText || '').replace(/^Final Answer:\s*/i, '')
|
||||
: item.contentText,
|
||||
created: item.created,
|
||||
senderName: item.senderName,
|
||||
chains: Array.isArray(item.contentPayload?.chains)
|
||||
? item.contentPayload.chains.map((chain: any) =>
|
||||
chain?.id
|
||||
? chain
|
||||
: {
|
||||
...chain,
|
||||
thinkingExpanded: false,
|
||||
},
|
||||
)
|
||||
: [],
|
||||
}));
|
||||
}
|
||||
|
||||
function changePage(pageNumber: number) {
|
||||
query.value.pageNumber = pageNumber;
|
||||
fetchSessions();
|
||||
}
|
||||
|
||||
function changePageSize(pageSize: number) {
|
||||
query.value.pageNumber = 1;
|
||||
query.value.pageSize = pageSize;
|
||||
fetchSessions();
|
||||
}
|
||||
|
||||
function formatTime(value?: string) {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
const time = new Date(value);
|
||||
if (Number.isNaN(time.getTime())) {
|
||||
return value;
|
||||
}
|
||||
const year = time.getFullYear();
|
||||
const month = String(time.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(time.getDate()).padStart(2, '0');
|
||||
const hour = String(time.getHours()).padStart(2, '0');
|
||||
const minute = String(time.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hour}:${minute}`;
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
query.value.pageNumber = 1;
|
||||
fetchSessions();
|
||||
}
|
||||
|
||||
function applyQuickRange(range: 'last7' | 'last30' | 'today') {
|
||||
quickRange.value = range;
|
||||
query.value.timeRange = resolveQuickRange(range);
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
function handleTimeRangeChange(value?: string[]) {
|
||||
quickRange.value = '';
|
||||
query.value.timeRange = normalizeTimeRange(value);
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
function resolveQuickRange(range: 'last7' | 'last30' | 'today') {
|
||||
const now = new Date();
|
||||
const end = endOfDay(now);
|
||||
let start = startOfDay(now);
|
||||
|
||||
if (range === 'last7') {
|
||||
start = startOfDay(addDays(now, -6));
|
||||
} else if (range === 'last30') {
|
||||
start = startOfDay(addDays(now, -29));
|
||||
}
|
||||
|
||||
return [formatDateTime(start), formatDateTime(end)];
|
||||
}
|
||||
|
||||
function normalizeTimeRange(value?: string[]) {
|
||||
if (!value?.length) {
|
||||
return [];
|
||||
}
|
||||
const [startValue, endValue] = value;
|
||||
if (!startValue || !endValue) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
formatDateTime(startOfDay(new Date(startValue))),
|
||||
formatDateTime(endOfDay(new Date(endValue))),
|
||||
];
|
||||
}
|
||||
|
||||
function startOfDay(date: Date) {
|
||||
const value = new Date(date);
|
||||
value.setHours(0, 0, 0, 0);
|
||||
return value;
|
||||
}
|
||||
|
||||
function endOfDay(date: Date) {
|
||||
const value = new Date(date);
|
||||
value.setHours(23, 59, 59, 0);
|
||||
return value;
|
||||
}
|
||||
|
||||
function addDays(date: Date, days: number) {
|
||||
const value = new Date(date);
|
||||
value.setDate(value.getDate() + days);
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatDateTime(value: Date) {
|
||||
const year = value.getFullYear();
|
||||
const month = String(value.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(value.getDate()).padStart(2, '0');
|
||||
const hour = String(value.getHours()).padStart(2, '0');
|
||||
const minute = String(value.getMinutes()).padStart(2, '0');
|
||||
const second = String(value.getSeconds()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
drawerApi.close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat-history-page flex h-full flex-col gap-6 p-6">
|
||||
<ListPageShell class="flex-1" :content-padding="20">
|
||||
<template #filters>
|
||||
<div class="chat-history-page__filters">
|
||||
<ElSelect
|
||||
v-model="query.assistantId"
|
||||
clearable
|
||||
placeholder="筛选聊天助手"
|
||||
:options="assistantList"
|
||||
class="chat-history-page__filter-control is-select"
|
||||
@change="handleSearch"
|
||||
/>
|
||||
<ElInput
|
||||
v-model="query.userAccount"
|
||||
class="chat-history-page__filter-control is-input"
|
||||
placeholder="搜索聊天用户"
|
||||
:prefix-icon="Search"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<div class="chat-history-page__quick-ranges">
|
||||
<ElButton
|
||||
v-for="item in quickRangeOptions"
|
||||
:key="item.value"
|
||||
:type="quickRange === item.value ? 'primary' : 'default'"
|
||||
class="chat-history-page__quick-range-button"
|
||||
@click="applyQuickRange(item.value)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</ElButton>
|
||||
</div>
|
||||
<ElDatePicker
|
||||
v-model="query.timeRange"
|
||||
class="chat-history-page__filter-control is-range"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
@change="handleTimeRangeChange"
|
||||
/>
|
||||
<ElButton type="primary" @click="handleSearch">查询</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-loading="loading" class="chat-history-page__content">
|
||||
<div v-if="sessions.length > 0" class="chat-history-page__table-shell">
|
||||
<ElTable
|
||||
:data="sessions"
|
||||
:current-row-key="selectedSessionId || undefined"
|
||||
row-key="id"
|
||||
class="chat-history-page__table"
|
||||
height="100%"
|
||||
highlight-current-row
|
||||
empty-text=""
|
||||
@row-click="(row) => openSession(row.id)"
|
||||
>
|
||||
<ElTableColumn label="会话信息" min-width="280">
|
||||
<template #default="{ row }">
|
||||
<div class="chat-history-page__session-cell">
|
||||
<div class="chat-history-page__session-title">
|
||||
{{ row.title || '未命名会话' }}
|
||||
</div>
|
||||
<div
|
||||
v-if="row.lastMessagePreview"
|
||||
class="chat-history-page__session-preview"
|
||||
>
|
||||
{{ row.lastMessagePreview }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn label="聊天助手" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<span class="chat-history-page__assistant-chip">
|
||||
{{ row.assistantName || '聊天助手' }}
|
||||
</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn prop="userAccount" label="聊天用户" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<span class="chat-history-page__user-cell">
|
||||
{{ row.userAccount || '-' }}
|
||||
</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn label="消息数" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<span class="chat-history-page__count-pill">
|
||||
{{ row.messageCount || 0 }}
|
||||
</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn label="最近活跃" width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="chat-history-page__time-cell">
|
||||
{{ formatTime(row.lastMessageAt || row.accessAt) }}
|
||||
</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn width="108" align="right">
|
||||
<template #default="{ row }">
|
||||
<ElButton
|
||||
link
|
||||
type="primary"
|
||||
class="chat-history-page__detail-action"
|
||||
@click.stop="openSession(row.id)"
|
||||
>
|
||||
查看详情
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</div>
|
||||
|
||||
<div v-else class="chat-history-page__empty">
|
||||
<ElEmpty description="暂无聊天历史" />
|
||||
</div>
|
||||
|
||||
<div v-if="pageState.total > 0" class="chat-history-page__pagination">
|
||||
<ElPagination
|
||||
background
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:current-page="query.pageNumber"
|
||||
:page-size="query.pageSize"
|
||||
:page-sizes="pageSizeOptions"
|
||||
:total="pageState.total"
|
||||
@current-change="changePage"
|
||||
@size-change="changePageSize"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ListPageShell>
|
||||
|
||||
<Drawer>
|
||||
<ChatHistoryDetailDrawer
|
||||
:visible="true"
|
||||
:loading="drawerLoading"
|
||||
:session="currentSession"
|
||||
:messages="messageList"
|
||||
:has-more="hasMoreMessages"
|
||||
:on-load-more="() => loadMessages(false)"
|
||||
@close="closeDetail"
|
||||
/>
|
||||
</Drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chat-history-page__filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.chat-history-page__filter-control {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-history-page__filter-control.is-select,
|
||||
.chat-history-page__filter-control.is-input {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.chat-history-page__filter-control.is-range {
|
||||
width: 360px;
|
||||
}
|
||||
|
||||
.chat-history-page__quick-ranges {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-history-page__quick-range-button {
|
||||
min-width: 88px;
|
||||
}
|
||||
|
||||
.chat-history-page__content {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-history-page__table-shell {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
border: 1px solid hsl(var(--glass-border) / 0.42);
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
hsl(var(--glass-border) / 0.28) 0%,
|
||||
hsl(var(--glass-tint) / 0.4) 14%,
|
||||
hsl(var(--surface-panel) / 0.94) 100%
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 1px 0 hsl(var(--glass-border) / 0.54),
|
||||
0 24px 42px -36px hsl(var(--foreground) / 0.16);
|
||||
}
|
||||
|
||||
.chat-history-page__table {
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chat-history-page__session-cell {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.chat-history-page__session-title {
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--text-strong));
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-history-page__session-preview {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: hsl(var(--text-muted));
|
||||
word-break: break-word;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
}
|
||||
|
||||
.chat-history-page__assistant-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
min-height: 28px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid hsl(var(--glass-border) / 0.48);
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--glass-tint) / 0.76);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--nav-item-active-foreground));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-history-page__user-cell,
|
||||
.chat-history-page__time-cell {
|
||||
font-size: 13px;
|
||||
color: hsl(var(--text-secondary));
|
||||
}
|
||||
|
||||
.chat-history-page__count-pill {
|
||||
display: inline-flex;
|
||||
min-width: 42px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--surface-contrast-soft) / 0.88);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--text-strong));
|
||||
}
|
||||
|
||||
.chat-history-page__detail-action {
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.chat-history-page__empty {
|
||||
display: flex;
|
||||
min-height: 360px;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.chat-history-page__pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.chat-history-page__content :deep(.el-table),
|
||||
.chat-history-page__content :deep(.el-table__inner-wrapper),
|
||||
.chat-history-page__content :deep(.el-table__body-wrapper),
|
||||
.chat-history-page__content :deep(.el-scrollbar),
|
||||
.chat-history-page__content :deep(.el-scrollbar__wrap),
|
||||
.chat-history-page__content :deep(.el-scrollbar__view) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chat-history-page__content :deep(.el-table::before) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-history-page__content :deep(.el-table tr) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-history-page__content :deep(.el-table th.el-table__cell) {
|
||||
height: 48px;
|
||||
background: hsl(var(--surface-contrast-soft) / 0.54);
|
||||
border-bottom-color: hsl(var(--divider-faint) / 0.28);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.chat-history-page__content :deep(.el-table td.el-table__cell) {
|
||||
padding: 14px 0;
|
||||
background: transparent;
|
||||
border-bottom-color: hsl(var(--divider-faint) / 0.22);
|
||||
}
|
||||
|
||||
.chat-history-page__content
|
||||
:deep(.el-table__body tr:hover > td.el-table__cell) {
|
||||
background: hsl(var(--primary) / 0.04);
|
||||
}
|
||||
|
||||
.chat-history-page__content
|
||||
:deep(.el-table__body tr.current-row > td.el-table__cell) {
|
||||
background: hsl(var(--primary) / 0.08);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.chat-history-page__filter-control.is-range {
|
||||
width: min(100%, 360px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.chat-history-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.chat-history-page__filters {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chat-history-page__filter-control.is-select,
|
||||
.chat-history-page__filter-control.is-input,
|
||||
.chat-history-page__filter-control.is-range {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,279 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import type { ChatThinkingBlockProps } from './types';
|
||||
|
||||
defineOptions({
|
||||
name: 'ChatThinkingBlock',
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<ChatThinkingBlockProps>(), {
|
||||
content: '',
|
||||
disabled: false,
|
||||
emptyBehavior: 'hide',
|
||||
expanded: false,
|
||||
label: '',
|
||||
readonly: false,
|
||||
status: 'end',
|
||||
summary: '',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:expanded': [boolean];
|
||||
}>();
|
||||
|
||||
const normalizedContent = computed(() =>
|
||||
String(props.content || '')
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/^\s*\n+/, '')
|
||||
.trimEnd(),
|
||||
);
|
||||
|
||||
const shouldRender = computed(
|
||||
() =>
|
||||
normalizedContent.value.length > 0 || props.emptyBehavior === 'placeholder',
|
||||
);
|
||||
|
||||
const expandedModel = computed({
|
||||
get: () => props.expanded,
|
||||
set: (value: boolean) => emit('update:expanded', value),
|
||||
});
|
||||
|
||||
const computedLabel = computed(() => {
|
||||
if (props.label) {
|
||||
return props.label;
|
||||
}
|
||||
if (props.status === 'thinking') {
|
||||
return '思考中';
|
||||
}
|
||||
if (props.status === 'error') {
|
||||
return '思考异常';
|
||||
}
|
||||
return '已思考';
|
||||
});
|
||||
|
||||
const computedSummary = computed(() => {
|
||||
if (props.summary) {
|
||||
return props.summary;
|
||||
}
|
||||
const source = normalizedContent.value
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean);
|
||||
if (!source) {
|
||||
return props.emptyBehavior === 'placeholder' ? '暂无思考内容' : '';
|
||||
}
|
||||
return source.length > 56 ? `${source.slice(0, 56)}...` : source;
|
||||
});
|
||||
|
||||
const canToggle = computed(
|
||||
() => !props.disabled && normalizedContent.value.length > 0,
|
||||
);
|
||||
|
||||
function toggleExpanded() {
|
||||
if (!canToggle.value) {
|
||||
return;
|
||||
}
|
||||
expandedModel.value = !expandedModel.value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="shouldRender"
|
||||
class="chat-thinking-block"
|
||||
:class="[
|
||||
`is-${status}`,
|
||||
{
|
||||
'is-disabled': disabled,
|
||||
'is-expanded': expandedModel,
|
||||
'is-readonly': readonly,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="chat-thinking-block__trigger"
|
||||
:disabled="!canToggle"
|
||||
@click="toggleExpanded"
|
||||
>
|
||||
<span class="chat-thinking-block__leading">
|
||||
<span class="chat-thinking-block__indicator" aria-hidden="true"></span>
|
||||
<span class="chat-thinking-block__label">{{ computedLabel }}</span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="!expandedModel && computedSummary"
|
||||
class="chat-thinking-block__summary"
|
||||
>
|
||||
{{ computedSummary }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="chat-thinking-block__chevron"
|
||||
:class="{ 'is-open': expandedModel }"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
</button>
|
||||
|
||||
<transition name="chat-thinking-block__body-transition">
|
||||
<div
|
||||
v-if="expandedModel && normalizedContent"
|
||||
class="chat-thinking-block__body"
|
||||
>
|
||||
<div class="chat-thinking-block__content">
|
||||
{{ normalizedContent }}
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chat-thinking-block {
|
||||
border: 1px solid hsl(var(--divider-faint) / 0.18);
|
||||
border-radius: 16px;
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
hsl(var(--glass-tint) / 0.48) 0%,
|
||||
hsl(var(--surface-panel) / 0.74) 100%
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 1px 0 hsl(var(--glass-border) / 0.24),
|
||||
0 10px 24px -24px hsl(var(--foreground) / 0.18);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.chat-thinking-block__trigger {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 12px;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: inherit;
|
||||
transition: background-color 0.18s ease;
|
||||
}
|
||||
|
||||
.chat-thinking-block__trigger:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-thinking-block__trigger:not(:disabled):hover {
|
||||
background: hsl(var(--surface-contrast-soft) / 0.34);
|
||||
}
|
||||
|
||||
.chat-thinking-block__trigger:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.chat-thinking-block__leading {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-thinking-block__indicator {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--text-muted) / 0.74);
|
||||
}
|
||||
|
||||
.chat-thinking-block.is-thinking .chat-thinking-block__indicator {
|
||||
background: hsl(var(--primary) / 0.82);
|
||||
box-shadow: 0 0 0 4px hsl(var(--primary) / 0.12);
|
||||
animation: chat-thinking-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.chat-thinking-block.is-error .chat-thinking-block__indicator {
|
||||
background: hsl(var(--destructive) / 0.86);
|
||||
box-shadow: 0 0 0 4px hsl(var(--destructive) / 0.1);
|
||||
}
|
||||
|
||||
.chat-thinking-block__label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
color: hsl(var(--text-strong));
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-thinking-block__summary {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
color: hsl(var(--text-muted));
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-thinking-block__chevron {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-right: 1.5px solid hsl(var(--text-muted));
|
||||
border-bottom: 1.5px solid hsl(var(--text-muted));
|
||||
transform: rotate(45deg) translateY(-1px);
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
|
||||
.chat-thinking-block__chevron.is-open {
|
||||
transform: rotate(225deg) translateY(-1px);
|
||||
}
|
||||
|
||||
.chat-thinking-block__body {
|
||||
padding: 0 12px 12px;
|
||||
}
|
||||
|
||||
.chat-thinking-block__content {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: hsl(var(--surface-panel) / 0.72);
|
||||
font-size: 12px;
|
||||
line-height: 1.68;
|
||||
color: hsl(var(--text-secondary));
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chat-thinking-block.is-disabled {
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.chat-thinking-block__body-transition-enter-active,
|
||||
.chat-thinking-block__body-transition-leave-active {
|
||||
transition:
|
||||
opacity 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.chat-thinking-block__body-transition-enter-from,
|
||||
.chat-thinking-block__body-transition-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
@keyframes chat-thinking-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 4px hsl(var(--primary) / 0.12);
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
50% {
|
||||
box-shadow: 0 0 0 7px hsl(var(--primary) / 0.04);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
export { default as ChatThinkingBlock } from './ChatThinkingBlock.vue';
|
||||
export type {
|
||||
ChatThinkingBlockProps,
|
||||
ChatThinkingBlockStatus,
|
||||
} from './types';
|
||||
@@ -0,0 +1,12 @@
|
||||
export type ChatThinkingBlockStatus = 'end' | 'error' | 'thinking';
|
||||
|
||||
export interface ChatThinkingBlockProps {
|
||||
content?: string;
|
||||
disabled?: boolean;
|
||||
emptyBehavior?: 'hide' | 'placeholder';
|
||||
expanded?: boolean;
|
||||
label?: string;
|
||||
readonly?: boolean;
|
||||
status?: ChatThinkingBlockStatus;
|
||||
summary?: string;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './api-component';
|
||||
export * from './captcha';
|
||||
export * from './chat-thinking';
|
||||
export * from './col-page';
|
||||
export * from './count-to';
|
||||
export * from './ellipsis-text';
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<g fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 3.5h10v6.1a1.4 1.4 0 0 1-1.4 1.4H8.1l-2.2 2V11H4.4A1.4 1.4 0 0 1 3 9.6V3.5Z"/>
|
||||
<path d="M5.4 6h3.1M5.4 8.1h1.9"/>
|
||||
<circle cx="10.4" cy="7.4" r="1.8"/>
|
||||
<path d="M10.4 6.6v.95l.72.48"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 394 B |
@@ -10,6 +10,7 @@ const SvgDownloadIcon = createIconifyIcon('svg:download');
|
||||
const SvgCardIcon = createIconifyIcon('svg:card');
|
||||
const SvgBellIcon = createIconifyIcon('svg:bell');
|
||||
const SvgCakeIcon = createIconifyIcon('svg:cake');
|
||||
const SvgChatHistoryIcon = createIconifyIcon('svg:chat-history');
|
||||
const SvgAntdvLogoIcon = createIconifyIcon('svg:antdv-logo');
|
||||
const SvgGithubIcon = createIconifyIcon('svg:github');
|
||||
const SvgGoogleIcon = createIconifyIcon('svg:google');
|
||||
@@ -44,6 +45,7 @@ export {
|
||||
SvgBellIcon,
|
||||
SvgCakeIcon,
|
||||
SvgCardIcon,
|
||||
SvgChatHistoryIcon,
|
||||
SvgDataCenterIcon,
|
||||
SvgDepartmentIcon,
|
||||
SvgDingDingIcon,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { IconifyIconStructure } from '@easyflow-core/icons';
|
||||
|
||||
import { addIcon } from '@easyflow-core/icons';
|
||||
import chatHistorySvg from './icons/chat-history.svg?raw';
|
||||
|
||||
let loaded = false;
|
||||
if (!loaded) {
|
||||
@@ -39,6 +40,14 @@ function parseSvg(svgData: string): IconifyIconStructure {
|
||||
* <Icon icon="svg:avatar"></Icon>
|
||||
*/
|
||||
async function loadSvgIcons() {
|
||||
addIcon('svg:chat-history', {
|
||||
...parseSvg(
|
||||
typeof chatHistorySvg === 'object'
|
||||
? chatHistorySvg.default
|
||||
: chatHistorySvg,
|
||||
),
|
||||
});
|
||||
|
||||
const svgEagers = import.meta.glob('./icons/**', {
|
||||
eager: true,
|
||||
query: '?raw',
|
||||
|
||||
Reference in New Issue
Block a user