feat: 完成管理端聊天工作台收口

- 新增管理端聊天工作台与会话级额外知识库持久化

- 补齐发布态聊天、历史会话只读判断与答案版本切换

- 新增 chat_round 热数据与主线消息读取支撑
This commit is contained in:
2026-05-14 20:22:46 +08:00
parent 2ad8935a61
commit 47c2bad839
63 changed files with 8609 additions and 136 deletions

View File

@@ -197,6 +197,28 @@ export class SseClient {
return;
}
const contentType = res.headers.get('content-type') || '';
if (!contentType.includes('text/event-stream')) {
let errorMessage = '请求失败,请稍后再试';
try {
const body = await res.json();
errorMessage =
body?.error ?? body?.message ?? body?.data?.message ?? errorMessage;
} catch {
try {
const text = await res.text();
if (text.trim()) {
errorMessage = text.trim();
}
} catch {
// ignore body parse failures and keep fallback message
}
}
showErrorOnce(errorMessage);
options?.onError?.(new Error(errorMessage));
return;
}
// 在开始事件流之前检查是否还是同一个请求
if (this.currentRequestId !== currentRequestId) {
return;

View File

@@ -0,0 +1,147 @@
<script setup lang="ts">
import {
ArrowLeft,
ArrowRight,
Loading,
} from '@element-plus/icons-vue';
const props = withDefaults(
defineProps<{
current?: number;
disabled?: boolean;
disabledNext?: boolean;
disabledPrev?: boolean;
loading?: boolean;
total?: number;
}>(),
{
current: 1,
disabled: false,
disabledNext: false,
disabledPrev: false,
loading: false,
total: 1,
},
);
const emit = defineEmits<{
next: [];
previous: [];
}>();
function handlePrevious() {
if (props.disabled || props.disabledPrev || props.loading) {
return;
}
emit('previous');
}
function handleNext() {
if (props.disabled || props.disabledNext || props.loading) {
return;
}
emit('next');
}
</script>
<template>
<div
v-if="total > 1"
class="variant-nav"
:class="{ 'is-loading': loading }"
>
<button
type="button"
class="variant-nav__button"
:disabled="disabled || disabledPrev || loading"
aria-label="查看上一版答案"
title="上一版"
@click="handlePrevious"
>
<Loading v-if="loading" />
<ArrowLeft v-else />
</button>
<span class="variant-nav__label">{{ current }}/{{ total }}</span>
<button
type="button"
class="variant-nav__button"
:disabled="disabled || disabledNext || loading"
aria-label="查看下一版答案"
title="下一版"
@click="handleNext"
>
<Loading v-if="loading" />
<ArrowRight v-else />
</button>
</div>
</template>
<style scoped>
.variant-nav {
display: inline-flex;
gap: 2px;
align-items: center;
}
.variant-nav__button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
color: hsl(var(--text-muted));
cursor: pointer;
border: none;
border-radius: 999px;
background: transparent;
transition:
color 0.18s ease,
background-color 0.18s ease;
}
.variant-nav__button:hover:not(:disabled) {
color: hsl(var(--text-strong));
background: hsl(var(--foreground) / 0.05);
}
.variant-nav__button:focus-visible {
outline: 2px solid hsl(var(--primary) / 0.32);
outline-offset: 2px;
}
.variant-nav__button:disabled {
opacity: 0.42;
cursor: not-allowed;
}
.variant-nav__button :deep(svg) {
width: 14px;
height: 14px;
}
.variant-nav.is-loading .variant-nav__button :deep(svg) {
animation: variant-nav-spin 0.8s linear infinite;
}
.variant-nav__label {
min-width: 44px;
font-size: 11px;
font-weight: 600;
line-height: 1;
text-align: center;
color: hsl(var(--text-muted));
}
@keyframes variant-nav-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,429 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import {
ArrowDown,
Check,
Close,
Plus,
} from '@element-plus/icons-vue';
import { ElPopover } from 'element-plus';
interface KnowledgeOption {
label: string;
value: string;
}
interface KnowledgeView {
id: string;
title: string;
}
const MAX_EXTRA_KNOWLEDGE_COUNT = 3;
const knowledgePanelOpen = ref(false);
const props = withDefaults(
defineProps<{
boundKnowledges?: KnowledgeView[];
selectedExtraKnowledges?: KnowledgeView[];
extraKnowledgeIds?: string[];
extraKnowledgeOptions?: KnowledgeOption[];
disabled?: boolean;
knowledgeDisabled?: boolean;
loading?: boolean;
mode?: 'editable' | 'readonly';
}>(),
{
boundKnowledges: () => [],
selectedExtraKnowledges: () => [],
extraKnowledgeIds: () => [],
extraKnowledgeOptions: () => [],
disabled: false,
knowledgeDisabled: false,
loading: false,
mode: 'editable',
},
);
const emit = defineEmits<{
'update:extraKnowledgeIds': [value: string[]];
}>();
const selectedExtraIdSet = computed(() => new Set(props.extraKnowledgeIds));
const hasAnyKnowledge = computed(
() =>
props.boundKnowledges.length > 0 || props.selectedExtraKnowledges.length > 0,
);
function updateExtraKnowledges(value: string[]) {
emit('update:extraKnowledgeIds', value || []);
}
function toggleKnowledge(id: string) {
const normalizedId = String(id);
const nextIds = [...props.extraKnowledgeIds];
const currentIndex = nextIds.findIndex((item) => String(item) === normalizedId);
if (currentIndex >= 0) {
nextIds.splice(currentIndex, 1);
updateExtraKnowledges(nextIds);
return;
}
if (nextIds.length >= MAX_EXTRA_KNOWLEDGE_COUNT) {
return;
}
nextIds.push(normalizedId);
updateExtraKnowledges(nextIds);
}
function removeExtraKnowledge(id: string) {
updateExtraKnowledges(
props.extraKnowledgeIds.filter((item) => String(item) !== String(id)),
);
}
function isKnowledgeSelected(id: string) {
return selectedExtraIdSet.value.has(String(id));
}
function isKnowledgeDisabled(id: string) {
if (props.disabled || props.knowledgeDisabled || props.loading) {
return true;
}
return (
props.extraKnowledgeIds.length >= MAX_EXTRA_KNOWLEDGE_COUNT &&
!isKnowledgeSelected(id)
);
}
</script>
<template>
<div
v-if="mode === 'editable' || hasAnyKnowledge"
class="context-rail"
:class="{ 'is-readonly': mode === 'readonly' }"
>
<div class="context-rail__row">
<ElPopover
v-if="mode === 'editable'"
v-model:visible="knowledgePanelOpen"
placement="top-start"
:teleported="false"
popper-class="chat-knowledge-popper"
:show-arrow="false"
:width="356"
:offset="10"
trigger="click"
>
<template #reference>
<button
type="button"
class="context-rail__trigger"
:disabled="disabled || knowledgeDisabled || loading"
:class="{ 'is-open': knowledgePanelOpen }"
>
<Plus class="context-rail__trigger-icon" />
<span>{{ loading ? '加载知识库中' : '知识库' }}</span>
<ArrowDown class="context-rail__trigger-caret" />
</button>
</template>
<div class="context-rail__panel">
<div class="context-rail__panel-head">
<span class="context-rail__panel-title">选择知识库</span>
<span class="context-rail__panel-caption">最多 3 </span>
</div>
<div
v-if="extraKnowledgeOptions.length > 0"
class="context-rail__option-list"
>
<button
v-for="item in extraKnowledgeOptions"
:key="item.value"
type="button"
class="context-rail__option"
:class="{
'is-active': isKnowledgeSelected(item.value),
'is-disabled': isKnowledgeDisabled(item.value),
}"
:disabled="isKnowledgeDisabled(item.value)"
@click="toggleKnowledge(item.value)"
>
<span class="context-rail__option-label">
{{ item.label }}
</span>
<Check
v-if="isKnowledgeSelected(item.value)"
class="context-rail__option-check"
/>
</button>
</div>
<div v-else class="context-rail__empty">
暂无可选知识库
</div>
</div>
</ElPopover>
<div
v-if="hasAnyKnowledge"
class="context-rail__chips"
>
<span
v-for="knowledge in boundKnowledges"
:key="knowledge.id"
class="context-rail__chip is-bound"
>
{{ knowledge.title }}
</span>
<span
v-for="knowledge in selectedExtraKnowledges"
:key="knowledge.id"
class="context-rail__chip is-extra"
>
<span class="context-rail__chip-label">{{ knowledge.title }}</span>
<button
v-if="mode === 'editable'"
type="button"
class="context-rail__chip-remove"
aria-label="移除知识库"
@click="removeExtraKnowledge(knowledge.id)"
>
<Close />
</button>
</span>
</div>
</div>
</div>
</template>
<style scoped>
.context-rail {
min-width: 0;
}
.context-rail__row {
display: flex;
gap: 10px;
align-items: center;
min-width: 0;
flex-wrap: wrap;
}
.context-rail__trigger {
display: inline-flex;
gap: 8px;
align-items: center;
min-height: 32px;
padding: 0 12px;
font-size: 12px;
font-weight: 600;
color: hsl(var(--text-strong));
border: 1px solid hsl(var(--divider-faint) / 0.86);
border-radius: 999px;
background: hsl(var(--surface-panel));
transition:
border-color 0.18s ease,
background-color 0.18s ease,
color 0.18s ease;
}
.context-rail__trigger:hover:not(:disabled) {
border-color: hsl(var(--primary) / 0.22);
background: hsl(var(--surface-subtle));
}
.context-rail__trigger.is-open {
border-color: hsl(var(--primary) / 0.24);
background: hsl(var(--surface-subtle));
}
.context-rail__trigger:focus-visible,
.context-rail__option:focus-visible,
.context-rail__chip-remove:focus-visible {
outline: 2px solid hsl(var(--primary) / 0.28);
outline-offset: 2px;
}
.context-rail__trigger:disabled {
cursor: not-allowed;
opacity: 0.68;
}
.context-rail__trigger-icon,
.context-rail__trigger-caret {
width: 14px;
height: 14px;
color: hsl(var(--text-muted));
}
.context-rail__chips {
display: flex;
gap: 8px;
align-items: center;
min-width: 0;
flex-wrap: wrap;
}
.context-rail__chip {
display: inline-flex;
gap: 6px;
align-items: center;
max-width: min(100%, 220px);
min-height: 32px;
padding: 0 12px;
overflow: hidden;
font-size: 12px;
font-weight: 500;
color: hsl(var(--text-muted));
border: 1px solid hsl(var(--divider-faint) / 0.84);
border-radius: 999px;
background: hsl(var(--surface-panel));
}
.context-rail__chip.is-extra {
color: hsl(var(--text-strong));
border-color: hsl(var(--primary) / 0.18);
background: hsl(var(--primary) / 0.06);
}
.context-rail__chip-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.context-rail__chip-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
color: hsl(var(--text-muted));
border: none;
border-radius: 999px;
background: transparent;
}
.context-rail__chip-remove:hover {
color: hsl(var(--text-strong));
background: hsl(var(--surface-panel) / 0.7);
}
.context-rail__chip-remove :deep(svg) {
width: 12px;
height: 12px;
}
.context-rail__panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.context-rail__panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.context-rail__panel-title {
font-size: 13px;
font-weight: 650;
color: hsl(var(--text-strong));
}
.context-rail__panel-caption {
font-size: 11px;
color: hsl(var(--text-muted));
}
.context-rail__option-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.context-rail__option {
display: flex;
gap: 10px;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 11px 12px;
text-align: left;
color: hsl(var(--text-strong));
border: 1px solid transparent;
border-radius: 12px;
background: hsl(var(--surface-subtle) / 0.82);
transition:
border-color 0.18s ease,
background-color 0.18s ease,
color 0.18s ease;
}
.context-rail__option:hover:not(:disabled) {
border-color: hsl(var(--primary) / 0.16);
background: hsl(var(--surface-panel));
}
.context-rail__option.is-active {
border-color: hsl(var(--primary) / 0.18);
background: hsl(var(--primary) / 0.07);
}
.context-rail__option.is-disabled {
cursor: not-allowed;
opacity: 0.58;
}
.context-rail__option-label {
overflow: hidden;
font-size: 12px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.context-rail__option-check {
flex: none;
width: 14px;
height: 14px;
color: hsl(var(--primary));
}
.context-rail__empty {
padding: 2px 0;
font-size: 12px;
text-align: left;
color: hsl(var(--text-muted));
}
:global(.chat-knowledge-popper.el-popper) {
padding: 12px;
border: 1px solid hsl(var(--divider-faint) / 0.88);
border-radius: 18px;
background:
linear-gradient(180deg, hsl(var(--surface-panel) / 0.98) 0%, hsl(var(--surface-subtle) / 0.96) 100%);
box-shadow: 0 18px 40px -32px hsl(var(--foreground) / 0.22);
backdrop-filter: blur(18px);
}
:global(.chat-knowledge-popper.el-popper .el-popper__arrow) {
display: none;
}
@media (max-width: 768px) {
.context-rail__row {
align-items: stretch;
}
.context-rail__trigger {
justify-content: center;
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,153 @@
<script setup lang="ts">
import {
CopyDocument,
RefreshRight,
} from '@element-plus/icons-vue';
import ChatAnswerVariantNavigator from './ChatAnswerVariantNavigator.vue';
const props = withDefaults(
defineProps<{
align?: 'end' | 'start';
allowCopy?: boolean;
allowRegenerate?: boolean;
disabledVariantNext?: boolean;
disabledVariantPrevious?: boolean;
regenerateDisabled?: boolean;
showVariantNavigator?: boolean;
variantLoading?: boolean;
variantCurrent?: number;
variantTotal?: number;
}>(),
{
align: 'start',
allowCopy: false,
allowRegenerate: false,
disabledVariantNext: false,
disabledVariantPrevious: false,
regenerateDisabled: false,
showVariantNavigator: false,
variantLoading: false,
variantCurrent: 1,
variantTotal: 1,
},
);
const emit = defineEmits<{
copy: [];
regenerate: [];
selectNextVariant: [];
selectPreviousVariant: [];
}>();
function handleCopy() {
emit('copy');
}
function handleRegenerate() {
if (props.regenerateDisabled) {
return;
}
emit('regenerate');
}
function handleSelectPreviousVariant() {
emit('selectPreviousVariant');
}
function handleSelectNextVariant() {
emit('selectNextVariant');
}
</script>
<template>
<div
v-if="allowCopy || allowRegenerate || showVariantNavigator"
class="message-actions"
:class="`is-${align}`"
>
<ChatAnswerVariantNavigator
v-if="showVariantNavigator"
:current="variantCurrent"
:total="variantTotal"
:disabled-prev="disabledVariantPrevious"
:disabled-next="disabledVariantNext"
:loading="variantLoading"
@previous="handleSelectPreviousVariant"
@next="handleSelectNextVariant"
/>
<button
v-if="allowCopy"
type="button"
class="message-actions__button"
aria-label="复制消息"
title="复制"
@click="handleCopy"
>
<CopyDocument />
</button>
<button
v-if="allowRegenerate"
type="button"
class="message-actions__button"
:disabled="regenerateDisabled"
aria-label="重新生成"
title="重新生成"
@click="handleRegenerate"
>
<RefreshRight />
</button>
</div>
</template>
<style scoped>
.message-actions {
display: inline-flex;
gap: 4px;
align-items: center;
margin-top: 6px;
}
.message-actions.is-end {
justify-content: flex-end;
}
.message-actions__button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
color: hsl(var(--text-muted));
cursor: pointer;
border: none;
border-radius: 999px;
background: transparent;
transition:
color 0.18s ease,
background-color 0.18s ease;
}
.message-actions__button:hover:not(:disabled) {
color: hsl(var(--text-strong));
background: hsl(var(--foreground) / 0.05);
}
.message-actions__button:focus-visible {
outline: 2px solid hsl(var(--primary) / 0.32);
outline-offset: 2px;
}
.message-actions__button:disabled {
opacity: 0.42;
cursor: not-allowed;
}
.message-actions__button :deep(svg) {
width: 15px;
height: 15px;
}
</style>

View File

@@ -0,0 +1,199 @@
<script setup lang="ts">
import { computed } from 'vue';
import { ArrowDown, Check } from '@element-plus/icons-vue';
import {
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
} from 'element-plus';
interface AssistantOption {
label: string;
value: string;
}
const props = withDefaults(
defineProps<{
assistantId?: string;
assistantName?: string;
assistantOptions?: AssistantOption[];
disabled?: boolean;
loading?: boolean;
placeholder?: string;
}>(),
{
assistantId: undefined,
assistantName: '',
assistantOptions: () => [],
disabled: false,
loading: false,
placeholder: '选择智能体',
},
);
const emit = defineEmits<{
'update:assistantId': [value?: string];
}>();
const currentLabel = computed(() => {
if (props.assistantName?.trim()) {
return props.assistantName.trim();
}
return (
props.assistantOptions.find((item) => item.value === props.assistantId)?.label ||
props.placeholder
);
});
function handleCommand(value: string) {
emit('update:assistantId', value || undefined);
}
</script>
<template>
<ElDropdown
class="assistant-inline-picker"
:disabled="disabled"
trigger="click"
@command="handleCommand"
>
<button
type="button"
class="assistant-inline-picker__trigger"
:class="{
'is-placeholder': !assistantId,
'is-disabled': disabled,
}"
>
<span class="assistant-inline-picker__label">
{{ loading ? '加载智能体中' : currentLabel }}
</span>
<ArrowDown class="assistant-inline-picker__arrow" />
</button>
<template #dropdown>
<ElDropdownMenu class="assistant-inline-picker__menu">
<ElDropdownItem
v-for="item in assistantOptions"
:key="item.value"
:command="item.value"
class="assistant-inline-picker__item"
>
<span class="assistant-inline-picker__item-label">
{{ item.label }}
</span>
<Check
v-if="item.value === assistantId"
class="assistant-inline-picker__item-check"
/>
</ElDropdownItem>
<ElDropdownItem
v-if="assistantOptions.length === 0"
disabled
class="assistant-inline-picker__item is-empty"
>
暂无可用智能体
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</template>
<style scoped>
.assistant-inline-picker {
display: inline-flex;
vertical-align: middle;
}
.assistant-inline-picker__trigger {
display: inline-flex;
gap: 8px;
align-items: center;
max-width: min(100%, 360px);
min-height: 42px;
padding: 0 14px;
font-size: 22px;
font-weight: 680;
line-height: 1;
color: hsl(var(--text-strong));
border: 1px solid hsl(var(--divider-faint) / 0.82);
border-radius: 999px;
background: hsl(var(--surface-panel));
box-shadow: 0 12px 28px -24px hsl(var(--foreground) / 0.22);
transition:
border-color 0.18s ease,
background-color 0.18s ease,
color 0.18s ease;
}
.assistant-inline-picker__trigger:hover:not(.is-disabled) {
border-color: hsl(var(--primary) / 0.22);
background: hsl(var(--surface-subtle));
}
.assistant-inline-picker__trigger:focus-visible {
outline: 2px solid hsl(var(--primary) / 0.28);
outline-offset: 2px;
}
.assistant-inline-picker__trigger.is-placeholder {
color: hsl(var(--text-muted));
}
.assistant-inline-picker__trigger.is-disabled {
cursor: default;
opacity: 0.72;
}
.assistant-inline-picker__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.assistant-inline-picker__arrow {
flex: none;
width: 16px;
height: 16px;
color: hsl(var(--text-muted));
}
.assistant-inline-picker__menu :deep(.el-dropdown-menu__item) {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
min-width: 220px;
padding: 10px 14px;
font-size: 13px;
color: hsl(var(--text-strong));
}
.assistant-inline-picker__item-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.assistant-inline-picker__item-check {
flex: none;
width: 14px;
height: 14px;
color: hsl(var(--primary));
}
.assistant-inline-picker__item.is-empty {
justify-content: center;
color: hsl(var(--text-muted));
}
@media (max-width: 768px) {
.assistant-inline-picker__trigger {
max-width: 100%;
min-height: 38px;
padding-inline: 12px;
font-size: 18px;
}
}
</style>

View File

@@ -22,6 +22,7 @@
"oauth": "OAuth"
},
"ai": {
"chat": "Chat",
"bots": "ChatAssistant",
"title": "AI",
"resources": "Resources",

View File

@@ -22,6 +22,7 @@
"oauth": "认证设置"
},
"ai": {
"chat": "聊天",
"bots": "聊天助手",
"title": "AI能力",
"resources": "素材库",

File diff suppressed because it is too large Load Diff