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

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