feat: 完成管理端聊天工作台收口
- 新增管理端聊天工作台与会话级额外知识库持久化 - 补齐发布态聊天、历史会话只读判断与答案版本切换 - 新增 chat_round 热数据与主线消息读取支撑
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -22,6 +22,7 @@
|
||||
"oauth": "OAuth"
|
||||
},
|
||||
"ai": {
|
||||
"chat": "Chat",
|
||||
"bots": "ChatAssistant",
|
||||
"title": "AI",
|
||||
"resources": "Resources",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"oauth": "认证设置"
|
||||
},
|
||||
"ai": {
|
||||
"chat": "聊天",
|
||||
"bots": "聊天助手",
|
||||
"title": "AI能力",
|
||||
"resources": "素材库",
|
||||
|
||||
2571
easyflow-ui-admin/app/src/views/ai/chat/index.vue
Normal file
2571
easyflow-ui-admin/app/src/views/ai/chat/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user