feat: 接入聊天历史界面与外链会话恢复

- 新增管理端与用户端聊天历史接口和页面

- 外链聊天支持访问令牌登录、身份保活与当前会话恢复

- 聊天执行链路切到统一 runtime 与 chatlog 查询接口
This commit is contained in:
2026-04-05 11:37:25 +08:00
parent 25e80433a5
commit a4f75a5e4c
48 changed files with 3724 additions and 972 deletions

View File

@@ -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>

View File

@@ -0,0 +1,5 @@
export { default as ChatThinkingBlock } from './ChatThinkingBlock.vue';
export type {
ChatThinkingBlockProps,
ChatThinkingBlockStatus,
} from './types';

View File

@@ -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;
}

View File

@@ -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';

View File

@@ -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

View File

@@ -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,

View File

@@ -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',