feat: add configuration selector for session management and enhance session handling in chat components
This commit is contained in:
@@ -85,3 +85,22 @@ class UmopConfigRouter:
|
||||
|
||||
self.umop_to_conf_id[umo] = conf_id
|
||||
await self.sp.global_put("umop_config_routing", self.umop_to_conf_id)
|
||||
|
||||
async def delete_route(self, umo: str):
|
||||
"""删除一条路由
|
||||
|
||||
Args:
|
||||
umo (str): 需要删除的 UMO 字符串
|
||||
|
||||
Raises:
|
||||
ValueError: 当 umo 格式不正确时抛出
|
||||
"""
|
||||
|
||||
if not isinstance(umo, str) or len(umo.split(":")) != 3:
|
||||
raise ValueError(
|
||||
"umop must be a string in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all",
|
||||
)
|
||||
|
||||
if umo in self.umop_to_conf_id:
|
||||
del self.umop_to_conf_id[umo]
|
||||
await self.sp.global_put("umop_config_routing", self.umop_to_conf_id)
|
||||
|
||||
@@ -56,6 +56,7 @@ class ChatRoute(Route):
|
||||
self.conv_mgr = core_lifecycle.conversation_manager
|
||||
self.platform_history_mgr = core_lifecycle.platform_message_history_manager
|
||||
self.db = db
|
||||
self.umop_config_router = core_lifecycle.umop_config_router
|
||||
|
||||
self.running_convs: dict[str, bool] = {}
|
||||
|
||||
@@ -266,7 +267,8 @@ class ChatRoute(Route):
|
||||
return Response().error("Permission denied").__dict__
|
||||
|
||||
# 删除该会话下的所有对话
|
||||
unified_msg_origin = f"{session.platform_id}:FriendMessage:{session.platform_id}!{username}!{session_id}"
|
||||
message_type = "GroupMessage" if session.is_group else "FriendMessage"
|
||||
unified_msg_origin = f"{session.platform_id}:{message_type}:{session.platform_id}!{username}!{session_id}"
|
||||
await self.conv_mgr.delete_conversations_by_user_id(unified_msg_origin)
|
||||
|
||||
# 删除消息历史
|
||||
@@ -276,6 +278,16 @@ class ChatRoute(Route):
|
||||
offset_sec=99999999,
|
||||
)
|
||||
|
||||
# 删除与会话关联的配置路由
|
||||
try:
|
||||
await self.umop_config_router.delete_route(unified_msg_origin)
|
||||
except ValueError as exc:
|
||||
logger.warning(
|
||||
"Failed to delete UMO route %s during session cleanup: %s",
|
||||
unified_msg_origin,
|
||||
exc,
|
||||
)
|
||||
|
||||
# 清理队列(仅对 webchat)
|
||||
if session.platform_id == "webchat":
|
||||
webchat_queue_mgr.remove_queues(session_id)
|
||||
|
||||
@@ -87,6 +87,8 @@
|
||||
:disabled="isStreaming || isConvRunning"
|
||||
:enableStreaming="enableStreaming"
|
||||
:isRecording="isRecording"
|
||||
:session-id="currSessionId || null"
|
||||
:current-session="getCurrentSession"
|
||||
@send="handleSendMessage"
|
||||
@toggleStreaming="toggleStreaming"
|
||||
@removeImage="removeImage"
|
||||
|
||||
@@ -11,7 +11,13 @@
|
||||
style="width: 100%; resize: none; outline: none; border: 1px solid var(--v-theme-border); border-radius: 12px; padding: 8px 16px; min-height: 40px; font-family: inherit; font-size: 16px; background-color: var(--v-theme-surface);"></textarea>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; padding: 0px 12px;">
|
||||
<div style="display: flex; justify-content: flex-start; margin-top: 4px; align-items: center; gap: 8px;">
|
||||
<ProviderModelSelector ref="providerModelSelectorRef" />
|
||||
<ConfigSelector
|
||||
:session-id="sessionId || null"
|
||||
:platform-id="sessionPlatformId"
|
||||
:is-group="sessionIsGroup"
|
||||
@config-changed="handleConfigChange"
|
||||
/>
|
||||
<ProviderModelSelector v-if="showProviderSelector" ref="providerModelSelectorRef" />
|
||||
|
||||
<v-tooltip :text="enableStreaming ? tm('streaming.enabled') : tm('streaming.disabled')" location="top">
|
||||
<template v-slot:activator="{ props }">
|
||||
@@ -58,9 +64,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import ProviderModelSelector from './ProviderModelSelector.vue';
|
||||
import ConfigSelector from './ConfigSelector.vue';
|
||||
import type { Session } from '@/composables/useSessions';
|
||||
|
||||
interface Props {
|
||||
prompt: string;
|
||||
@@ -69,9 +77,14 @@ interface Props {
|
||||
disabled: boolean;
|
||||
enableStreaming: boolean;
|
||||
isRecording: boolean;
|
||||
sessionId?: string | null;
|
||||
currentSession?: Session | null;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
sessionId: null,
|
||||
currentSession: null
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:prompt': [value: string];
|
||||
@@ -90,12 +103,16 @@ const { tm } = useModuleI18n('features/chat');
|
||||
const inputField = ref<HTMLTextAreaElement | null>(null);
|
||||
const imageInputRef = ref<HTMLInputElement | null>(null);
|
||||
const providerModelSelectorRef = ref<InstanceType<typeof ProviderModelSelector> | null>(null);
|
||||
const showProviderSelector = ref(true);
|
||||
|
||||
const localPrompt = computed({
|
||||
get: () => props.prompt,
|
||||
set: (value) => emit('update:prompt', value)
|
||||
});
|
||||
|
||||
const sessionPlatformId = computed(() => props.currentSession?.platform_id || 'webchat');
|
||||
const sessionIsGroup = computed(() => Boolean(props.currentSession?.is_group));
|
||||
|
||||
const canSend = computed(() => {
|
||||
return (props.prompt && props.prompt.trim()) || props.stagedImagesUrl.length > 0 || props.stagedAudioUrl;
|
||||
});
|
||||
@@ -168,7 +185,16 @@ function handleRecordClick() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleConfigChange(payload: { configId: string; agentRunnerType: string }) {
|
||||
const runnerType = (payload.agentRunnerType || '').toLowerCase();
|
||||
const isInternal = runnerType === 'internal' || runnerType === 'local';
|
||||
showProviderSelector.value = isInternal;
|
||||
}
|
||||
|
||||
function getCurrentSelection() {
|
||||
if (!showProviderSelector.value) {
|
||||
return null;
|
||||
}
|
||||
return providerModelSelectorRef.value?.getCurrentSelection();
|
||||
}
|
||||
|
||||
|
||||
311
dashboard/src/components/chat/ConfigSelector.vue
Normal file
311
dashboard/src/components/chat/ConfigSelector.vue
Normal file
@@ -0,0 +1,311 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-tooltip text="选择用于当前会话的配置文件" location="top">
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<v-chip
|
||||
v-bind="tooltipProps"
|
||||
class="text-none config-chip"
|
||||
variant="tonal"
|
||||
size="x-small"
|
||||
rounded="lg"
|
||||
@click="openDialog"
|
||||
:disabled="loadingConfigs || saving"
|
||||
>
|
||||
<v-icon start size="14">mdi-cog</v-icon>
|
||||
{{ selectedConfigLabel }}
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<v-dialog v-model="dialog" max-width="480" persistent>
|
||||
<v-card>
|
||||
<v-card-title class="d-flex align-center justify-space-between">
|
||||
<span>选择配置文件</span>
|
||||
<v-btn icon variant="text" @click="closeDialog">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<div v-if="loadingConfigs" class="text-center py-6">
|
||||
<v-progress-circular indeterminate color="primary"></v-progress-circular>
|
||||
</div>
|
||||
|
||||
<v-list v-else class="config-list" density="comfortable">
|
||||
<v-list-item
|
||||
v-for="config in configOptions"
|
||||
:key="config.id"
|
||||
:active="tempSelectedConfig === config.id"
|
||||
rounded="lg"
|
||||
variant="text"
|
||||
@click="tempSelectedConfig = config.id"
|
||||
>
|
||||
<v-list-item-title>{{ config.name }}</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-caption text-grey">
|
||||
{{ config.id }}
|
||||
</v-list-item-subtitle>
|
||||
<template #append>
|
||||
<v-icon v-if="tempSelectedConfig === config.id" color="primary">mdi-check</v-icon>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<div v-if="configOptions.length === 0" class="text-center text-body-2 text-medium-emphasis">
|
||||
暂无可选配置,请先在配置页创建。
|
||||
</div>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn variant="text" @click="closeDialog">取消</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
@click="confirmSelection"
|
||||
:disabled="!tempSelectedConfig"
|
||||
:loading="saving"
|
||||
>
|
||||
应用
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useToast } from '@/utils/toast';
|
||||
|
||||
interface ConfigInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ConfigChangedPayload {
|
||||
configId: string;
|
||||
agentRunnerType: string;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'chat.selectedConfigId';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
sessionId?: string | null;
|
||||
platformId?: string;
|
||||
isGroup?: boolean;
|
||||
}>(), {
|
||||
sessionId: null,
|
||||
platformId: 'webchat',
|
||||
isGroup: false
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ 'config-changed': [ConfigChangedPayload] }>();
|
||||
|
||||
const configOptions = ref<ConfigInfo[]>([]);
|
||||
const loadingConfigs = ref(false);
|
||||
const dialog = ref(false);
|
||||
const tempSelectedConfig = ref('');
|
||||
const selectedConfigId = ref('default');
|
||||
const agentRunnerType = ref('local');
|
||||
const saving = ref(false);
|
||||
const pendingSync = ref(false);
|
||||
const routingEntries = ref<Array<{ pattern: string; confId: string }>>([]);
|
||||
const configCache = ref<Record<string, string>>({});
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const normalizedSessionId = computed(() => {
|
||||
const id = props.sessionId?.trim();
|
||||
return id ? id : null;
|
||||
});
|
||||
|
||||
const hasActiveSession = computed(() => !!normalizedSessionId.value);
|
||||
|
||||
const messageType = computed(() => (props.isGroup ? 'GroupMessage' : 'FriendMessage'));
|
||||
|
||||
const username = computed(() => localStorage.getItem('user') || 'guest');
|
||||
|
||||
const sessionKey = computed(() => {
|
||||
if (!normalizedSessionId.value) {
|
||||
return null;
|
||||
}
|
||||
return `${props.platformId}!${username.value}!${normalizedSessionId.value}`;
|
||||
});
|
||||
|
||||
const targetUmo = computed(() => {
|
||||
if (!sessionKey.value) {
|
||||
return null;
|
||||
}
|
||||
return `${props.platformId}:${messageType.value}:${sessionKey.value}`;
|
||||
});
|
||||
|
||||
const selectedConfigLabel = computed(() => {
|
||||
const target = configOptions.value.find((item) => item.id === selectedConfigId.value);
|
||||
return target?.name || selectedConfigId.value || 'default';
|
||||
});
|
||||
|
||||
function openDialog() {
|
||||
tempSelectedConfig.value = selectedConfigId.value;
|
||||
dialog.value = true;
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
dialog.value = false;
|
||||
}
|
||||
|
||||
async function fetchConfigList() {
|
||||
loadingConfigs.value = true;
|
||||
try {
|
||||
const res = await axios.get('/api/config/abconfs');
|
||||
configOptions.value = res.data.data?.info_list || [];
|
||||
} catch (error) {
|
||||
console.error('加载配置文件列表失败', error);
|
||||
configOptions.value = [];
|
||||
} finally {
|
||||
loadingConfigs.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRoutingEntries() {
|
||||
try {
|
||||
const res = await axios.get('/api/config/umo_abconf_routes');
|
||||
const routing = res.data.data?.routing || {};
|
||||
routingEntries.value = Object.entries(routing).map(([pattern, confId]) => ({
|
||||
pattern,
|
||||
confId: confId as string
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('获取配置路由失败', error);
|
||||
routingEntries.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
function matchesPattern(pattern: string, target: string): boolean {
|
||||
const parts = pattern.split(':');
|
||||
const targetParts = target.split(':');
|
||||
if (parts.length !== 3 || targetParts.length !== 3) {
|
||||
return false;
|
||||
}
|
||||
return parts.every((part, index) => part === '' || part === '*' || part === targetParts[index]);
|
||||
}
|
||||
|
||||
function resolveConfigId(umo: string | null): string {
|
||||
if (!umo) {
|
||||
return 'default';
|
||||
}
|
||||
for (const entry of routingEntries.value) {
|
||||
if (matchesPattern(entry.pattern, umo)) {
|
||||
return entry.confId;
|
||||
}
|
||||
}
|
||||
return 'default';
|
||||
}
|
||||
|
||||
async function getAgentRunnerType(confId: string): Promise<string> {
|
||||
if (configCache.value[confId]) {
|
||||
return configCache.value[confId];
|
||||
}
|
||||
try {
|
||||
const res = await axios.get('/api/config/abconf', {
|
||||
params: { id: confId }
|
||||
});
|
||||
const type = res.data.data?.config?.provider_settings?.agent_runner_type || 'local';
|
||||
configCache.value[confId] = type;
|
||||
return type;
|
||||
} catch (error) {
|
||||
console.error('获取配置文件详情失败', error);
|
||||
return 'local';
|
||||
}
|
||||
}
|
||||
|
||||
async function setSelection(confId: string) {
|
||||
const normalized = confId || 'default';
|
||||
selectedConfigId.value = normalized;
|
||||
const runnerType = await getAgentRunnerType(normalized);
|
||||
agentRunnerType.value = runnerType;
|
||||
emit('config-changed', {
|
||||
configId: normalized,
|
||||
agentRunnerType: runnerType
|
||||
});
|
||||
}
|
||||
|
||||
async function applySelectionToBackend(confId: string): Promise<boolean> {
|
||||
if (!targetUmo.value) {
|
||||
pendingSync.value = true;
|
||||
return true;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
await axios.post('/api/config/umo_abconf_route/update', {
|
||||
umo: targetUmo.value,
|
||||
conf_id: confId
|
||||
});
|
||||
const filtered = routingEntries.value.filter((entry) => entry.pattern !== targetUmo.value);
|
||||
filtered.push({ pattern: targetUmo.value, confId });
|
||||
routingEntries.value = filtered;
|
||||
return true;
|
||||
} catch (error) {
|
||||
const err = error as any;
|
||||
console.error('更新配置文件失败', err);
|
||||
toast.error(err?.response?.data?.message || '配置文件应用失败');
|
||||
return false;
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmSelection() {
|
||||
if (!tempSelectedConfig.value) {
|
||||
return;
|
||||
}
|
||||
const previousId = selectedConfigId.value;
|
||||
await setSelection(tempSelectedConfig.value);
|
||||
localStorage.setItem(STORAGE_KEY, tempSelectedConfig.value);
|
||||
const applied = await applySelectionToBackend(tempSelectedConfig.value);
|
||||
if (!applied) {
|
||||
localStorage.setItem(STORAGE_KEY, previousId);
|
||||
await setSelection(previousId);
|
||||
}
|
||||
dialog.value = false;
|
||||
}
|
||||
|
||||
async function syncSelectionForSession() {
|
||||
if (!targetUmo.value) {
|
||||
pendingSync.value = true;
|
||||
return;
|
||||
}
|
||||
if (pendingSync.value) {
|
||||
pendingSync.value = false;
|
||||
await applySelectionToBackend(selectedConfigId.value);
|
||||
return;
|
||||
}
|
||||
await fetchRoutingEntries();
|
||||
const resolved = resolveConfigId(targetUmo.value);
|
||||
await setSelection(resolved);
|
||||
localStorage.setItem(STORAGE_KEY, resolved);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.sessionId, props.platformId, props.isGroup],
|
||||
async () => {
|
||||
await syncSelectionForSession();
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchConfigList();
|
||||
const stored = localStorage.getItem(STORAGE_KEY) || 'default';
|
||||
selectedConfigId.value = stored;
|
||||
await setSelection(stored);
|
||||
await syncSelectionForSession();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.config-chip {
|
||||
cursor: pointer;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.config-list {
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,7 @@
|
||||
<!-- 选择提供商和模型按钮 -->
|
||||
<v-chip class="text-none" variant="tonal" size="x-small"
|
||||
v-if="selectedProviderId && selectedModelName" @click="openDialog">
|
||||
<v-icon start size="14">mdi-creation</v-icon>
|
||||
{{ selectedProviderId }} / {{ selectedModelName }}
|
||||
</v-chip>
|
||||
<v-chip variant="tonal" rounded="xl" size="x-small" v-else @click="openDialog">
|
||||
|
||||
@@ -4,8 +4,12 @@ import { useRouter } from 'vue-router';
|
||||
|
||||
export interface Session {
|
||||
session_id: string;
|
||||
display_name: string;
|
||||
updated_at: string;
|
||||
display_name?: string | null;
|
||||
updated_at?: string;
|
||||
platform_id?: string;
|
||||
creator?: string;
|
||||
is_group?: number;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export function useSessions(chatboxMode: boolean = false) {
|
||||
|
||||
@@ -63,12 +63,12 @@
|
||||
<div v-for="group in groupedProviders" :key="group.typeKey" class="mb-8">
|
||||
<h1 class="text-h3 font-weight-bold mb-4">{{ group.label }}</h1>
|
||||
<v-row>
|
||||
<v-col v-for="(provider, index) in group.items" :key="`${group.typeKey}-${index}`" cols="12" md="6" lg="4"
|
||||
xl="3">
|
||||
<v-col v-for="(provider, index) in group.items" :key="`${group.typeKey}-${index}`" cols="12" md="6"
|
||||
lg="4" xl="3">
|
||||
<item-card :item="provider" title-field="id" enabled-field="enable"
|
||||
:loading="isProviderTesting(provider.id)" @toggle-enabled="providerStatusChange"
|
||||
:bglogo="getProviderIcon(provider.provider)" @delete="deleteProvider"
|
||||
@edit="configExistingProvider" @copy="copyProvider" :show-copy-button="true">
|
||||
:bglogo="getProviderIcon(provider.provider)" @delete="deleteProvider" @edit="configExistingProvider"
|
||||
@copy="copyProvider" :show-copy-button="true">
|
||||
<template #actions="{ item }">
|
||||
<v-btn style="z-index: 100000;" variant="tonal" color="info" rounded="xl" size="small"
|
||||
:loading="isProviderTesting(item.id)" @click="testSingleProvider(item)">
|
||||
@@ -775,6 +775,9 @@ export default {
|
||||
if (!provider.enable) {
|
||||
throw new Error('该提供商未被用户启用');
|
||||
}
|
||||
if (provider.provider_type === 'agent_runner') {
|
||||
throw new Error('暂时无法测试 Agent Runner 类型的提供商');
|
||||
}
|
||||
|
||||
const res = await axios.get(`/api/config/provider/check_one?id=${provider.id}`);
|
||||
if (res.data && res.data.status === 'ok') {
|
||||
|
||||
Reference in New Issue
Block a user