Browse Source

feat: 增加代理切换功能,支持代理名称和描述的显示

feat/agent-independence
npmrun 2 months ago
parent
commit
006ef674f4
  1. 4
      app/components/agent/AgentChatArea.vue
  2. 52
      app/components/agent/AgentSidebar.vue
  3. 247
      app/components/agent/AgentSwitcher.vue
  4. 24
      app/components/agent/AgentWelcome.vue
  5. 18
      app/pages/chat/[agentSlug]/index.vue
  6. BIN
      packages/drizzle-pkg/db.sqlite

4
app/components/agent/AgentChatArea.vue

@ -33,6 +33,8 @@ const props = defineProps<{
systemPrompt?: string;
sessionId?: string | null;
agentTools?: ToolInfo[];
agentName?: string;
agentDescription?: string;
}>();
const emit = defineEmits<{
@ -112,6 +114,8 @@ watch(
<AgentWelcome
v-if="messages.length === 0"
:logged-in="loggedIn"
:agent-name="agentName"
:agent-description="agentDescription"
@example="handleExample"
/>

52
app/components/agent/AgentSidebar.vue

@ -8,6 +8,7 @@ const props = defineProps<{
user: { name?: string | null; email?: string | null; role?: string | null } | null;
collapsed: boolean;
mobile?: boolean;
currentAgentSlug?: string;
}>();
const emit = defineEmits<{
@ -20,6 +21,7 @@ const emit = defineEmits<{
toggle: [];
close: [];
showDocuments: [];
switchAgent: [slug: string];
}>();
const isAdmin = computed(() => props.user?.role === "admin");
@ -29,13 +31,20 @@ const isAdmin = computed(() => props.user?.role === "admin");
<!-- Desktop: inline flex sidebar (hidden on mobile via CSS) -->
<aside class="agent-sidebar desktop" :class="{ collapsed }">
<div class="sidebar-header">
<button class="new-chat-btn" @click="emit('newSession')">
<Icon name="lucide:plus" />
<span>新对话</span>
</button>
<button class="collapse-btn" @click="emit('toggle')">
<Icon :name="collapsed ? 'lucide:chevrons-right' : 'lucide:chevrons-left'" />
</button>
<AgentSwitcher
v-if="currentAgentSlug"
:current-slug="currentAgentSlug"
@switch="emit('switchAgent', $event)"
/>
<div class="sidebar-header-row">
<button class="new-chat-btn" @click="emit('newSession')">
<Icon name="lucide:plus" />
<span>新对话</span>
</button>
<button class="collapse-btn" @click="emit('toggle')">
<Icon :name="collapsed ? 'lucide:chevrons-right' : 'lucide:chevrons-left'" />
</button>
</div>
</div>
<div class="sidebar-sessions">
@ -100,13 +109,20 @@ const isAdmin = computed(() => props.user?.role === "admin");
<Transition name="sidebar-slide">
<aside v-if="mobile && !collapsed" class="agent-sidebar mobile">
<div class="sidebar-header">
<button class="new-chat-btn" @click="emit('newSession')">
<Icon name="lucide:plus" />
<span>新对话</span>
</button>
<button class="collapse-btn" @click="emit('close')">
<Icon name="lucide:x" />
</button>
<AgentSwitcher
v-if="currentAgentSlug"
:current-slug="currentAgentSlug"
@switch="emit('switchAgent', $event)"
/>
<div class="sidebar-header-row">
<button class="new-chat-btn" @click="emit('newSession')">
<Icon name="lucide:plus" />
<span>新对话</span>
</button>
<button class="collapse-btn" @click="emit('close')">
<Icon name="lucide:x" />
</button>
</div>
</div>
<div class="sidebar-sessions">
@ -188,12 +204,18 @@ const isAdmin = computed(() => props.user?.role === "admin");
.sidebar-header {
display: flex;
align-items: center;
flex-direction: column;
gap: 8px;
padding: 12px;
flex-shrink: 0;
}
.sidebar-header-row {
display: flex;
align-items: center;
gap: 8px;
}
.new-chat-btn {
display: flex;
align-items: center;

247
app/components/agent/AgentSwitcher.vue

@ -0,0 +1,247 @@
<script setup lang="ts">
interface AgentInfo {
slug: string;
name: string;
description?: string | null;
}
const props = defineProps<{
currentSlug: string;
}>();
const emit = defineEmits<{
switch: [slug: string];
}>();
const open = ref(false);
const agents = ref<AgentInfo[]>([]);
const loading = ref(false);
const dropdownRef = ref<HTMLElement | null>(null);
const currentAgent = computed(() =>
agents.value.find((a) => a.slug === props.currentSlug) ?? null,
);
const displayName = computed(() => currentAgent.value?.name ?? "智能助手");
async function loadAgents() {
loading.value = true;
try {
const res = await $fetch<{ code: number; data: { list: AgentInfo[] } }>("/api/agents");
agents.value = res.data?.list ?? [];
} catch {
agents.value = [];
} finally {
loading.value = false;
}
}
function toggle() {
if (!open.value && agents.value.length === 0) {
loadAgents();
}
open.value = !open.value;
}
function pick(slug: string) {
open.value = false;
if (slug !== props.currentSlug) {
emit("switch", slug);
}
}
function handleClickOutside(e: MouseEvent) {
if (dropdownRef.value && !dropdownRef.value.contains(e.target as Node)) {
open.value = false;
}
}
onMounted(() => {
document.addEventListener("click", handleClickOutside);
});
onBeforeUnmount(() => {
document.removeEventListener("click", handleClickOutside);
});
</script>
<template>
<div ref="dropdownRef" class="agent-switcher">
<button class="switcher-trigger" :class="{ active: open }" @click="toggle">
<Icon name="lucide:bot" class="trigger-icon" />
<span class="trigger-name">{{ displayName }}</span>
<Icon name="lucide:chevron-down" class="trigger-arrow" :class="{ rotated: open }" />
</button>
<Transition name="dropdown">
<div v-if="open" class="switcher-dropdown">
<div v-if="loading" class="dropdown-loading">加载中…</div>
<template v-else>
<button
v-for="a in agents"
:key="a.slug"
class="dropdown-item"
:class="{ selected: a.slug === currentSlug }"
@click="pick(a.slug)"
>
<Icon
:name="a.slug === currentSlug ? 'lucide:check' : 'lucide:bot'"
class="item-icon"
/>
<div class="item-meta">
<div class="item-name">{{ a.name }}</div>
<div v-if="a.description" class="item-desc">{{ a.description }}</div>
</div>
</button>
<div v-if="agents.length === 0" class="dropdown-empty">暂无可用 agent</div>
</template>
</div>
</Transition>
</div>
</template>
<style scoped>
.agent-switcher {
position: relative;
flex-shrink: 0;
}
.switcher-trigger {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 10px;
background: var(--color-surface-card);
border: 1px solid var(--color-hairline);
border-radius: 10px;
font-size: 13px;
font-weight: 500;
color: var(--color-ink);
cursor: pointer;
transition: all 0.15s;
}
.switcher-trigger:hover {
border-color: var(--color-primary);
background: var(--color-canvas);
}
.switcher-trigger.active {
border-color: var(--color-primary);
}
.trigger-icon {
font-size: 15px;
color: var(--color-primary);
flex-shrink: 0;
}
.trigger-name {
flex: 1;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.trigger-arrow {
font-size: 14px;
color: var(--color-muted);
flex-shrink: 0;
transition: transform 0.2s;
}
.trigger-arrow.rotated {
transform: rotate(180deg);
}
.switcher-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--color-canvas);
border: 1px solid var(--color-hairline);
border-radius: 10px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
z-index: 50;
max-height: 320px;
overflow-y: auto;
padding: 4px;
}
.dropdown-loading,
.dropdown-empty {
padding: 16px;
text-align: center;
font-size: 13px;
color: var(--color-muted);
}
.dropdown-item {
display: flex;
align-items: flex-start;
gap: 8px;
width: 100%;
padding: 8px 10px;
border: none;
background: none;
border-radius: 8px;
cursor: pointer;
text-align: left;
transition: background 0.12s;
}
.dropdown-item:hover {
background: var(--color-surface-soft);
}
.dropdown-item.selected {
background: var(--color-surface-soft);
}
.item-icon {
font-size: 15px;
color: var(--color-primary);
flex-shrink: 0;
margin-top: 1px;
}
.dropdown-item.selected .item-icon {
color: var(--color-primary);
}
.item-meta {
flex: 1;
min-width: 0;
}
.item-name {
font-size: 13px;
font-weight: 500;
color: var(--color-ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-desc {
font-size: 11px;
color: var(--color-muted);
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dropdown-enter-active,
.dropdown-leave-active {
transition: opacity 0.15s, transform 0.15s;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style>

24
app/components/agent/AgentWelcome.vue

@ -1,17 +1,22 @@
<script setup lang="ts">
const props = defineProps<{
loggedIn: boolean;
agentName?: string;
agentDescription?: string;
}>();
const emit = defineEmits<{
example: [content: string];
}>();
const displayName = computed(() => props.agentName?.trim() || "智能助手");
const hasDescription = computed(() => !!props.agentDescription?.trim());
const examples = [
"帮我写一个 Python 函数,计算斐波那契数列",
"解释一下什么是闭包,并给出一个 JavaScript 示例",
"帮我分析这段代码的性能瓶颈",
"如何用 SQL 查询去重并统计数量?",
"你好,能帮我做些什么?",
"给我介绍一下你自己",
"今天天气怎么样?",
"帮我写一封邮件",
];
</script>
@ -20,7 +25,8 @@ const examples = [
<div class="welcome-icon">
<Icon name="lucide:sparkles" />
</div>
<h1 class="welcome-title">有什么可以帮你的?</h1>
<h1 class="welcome-title">{{ displayName }}</h1>
<p v-if="hasDescription" class="welcome-agent-desc">{{ agentDescription }}</p>
<p class="welcome-subtitle">
{{ loggedIn ? "选择模型,开始对话" : "无需登录即可体验,登录后可保存对话记录" }}
</p>
@ -71,6 +77,14 @@ const examples = [
letter-spacing: -0.5px;
}
.welcome-agent-desc {
font-size: 14px;
color: var(--color-muted);
margin: 0 0 8px;
max-width: 560px;
line-height: 1.5;
}
.welcome-subtitle {
font-size: 15px;
color: var(--color-muted);

18
app/pages/chat/[agentSlug]/index.vue

@ -21,6 +21,8 @@ const mobileSidebarOpen = ref(false);
const showPromptModal = ref(false);
const showDocumentDrawer = ref(false);
const agentSystemPrompt = ref("");
const agentName = ref("");
const agentDescription = ref("");
const agentTools = ref<{ slug: string; name: string; type: string; description: string }[]>([]);
const systemPromptText = computed(() =>
sessions.currentSession.value?.systemPrompt?.trim() || agentSystemPrompt.value || "",
@ -149,10 +151,12 @@ async function loadUserConfig() {
async function loadSystemPrompt() {
try {
const res = await $fetch<{ code: number; data: { systemPrompt?: string; tools?: { id: string; name: string; slug: string; type: string; needsApproval: number }[] } }>(
const res = await $fetch<{ code: number; data: { name?: string; description?: string; systemPrompt?: string; tools?: { id: string; name: string; slug: string; type: string; needsApproval: number }[] } }>(
`/api/agents/${agentSlug.value}/info`,
);
agentSystemPrompt.value = res.data?.systemPrompt ?? "";
agentName.value = res.data?.name ?? "";
agentDescription.value = res.data?.description ?? "";
agentTools.value = (res.data?.tools ?? []).map((t) => ({
slug: t.slug,
name: t.name,
@ -161,6 +165,8 @@ async function loadSystemPrompt() {
}));
} catch {
agentSystemPrompt.value = "";
agentName.value = "";
agentDescription.value = "";
agentTools.value = [];
}
}
@ -259,6 +265,12 @@ function handleExample(content: string) {
handleSend(content);
}
function handleSwitchAgent(slug: string) {
chat.forceStop();
if (isMobile) mobileSidebarOpen.value = false;
navigateTo(`/chat/${slug}`);
}
function handleLogin() {
navigateTo("/auth/login");
}
@ -282,6 +294,7 @@ onMounted(async () => {
:user="auth.user.value"
:collapsed="isMobile ? !mobileSidebarOpen : sidebarCollapsed"
:mobile="isMobile"
:current-agent-slug="agentSlug"
@new-session="handleNewSession"
@select="sessions.selectSession($event); if (isMobile) mobileSidebarOpen = false"
@rename="sessions.renameSession"
@ -291,6 +304,7 @@ onMounted(async () => {
@toggle="isMobile ? (mobileSidebarOpen = !mobileSidebarOpen) : (sidebarCollapsed = !sidebarCollapsed)"
@close="mobileSidebarOpen = false"
@show-documents="showDocumentDrawer = true"
@switch-agent="handleSwitchAgent"
/>
<AgentChatArea
@ -308,6 +322,8 @@ onMounted(async () => {
:system-prompt="systemPromptText"
:session-id="sessions.currentSessionId.value"
:agent-tools="agentTools"
:agent-name="agentName"
:agent-description="agentDescription"
@send="handleSend"
@stop="chat.stopGeneration()"
@update:model-id="currentModelId = $event"

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.
Loading…
Cancel
Save