Compare commits

...

5 Commits

  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. 2
      app/pages/admin/agents/[slug].vue
  6. 30
      app/pages/chat/[agentSlug]/index.vue
  7. 129
      app/pages/settings/preferences.vue
  8. BIN
      packages/drizzle-pkg/db.sqlite
  9. 3
      server/api/agents/[agentSlug]/chat/index.post.ts
  10. 11
      server/api/agents/[agentSlug]/info.get.ts
  11. 11
      server/service/agent/chat-engine.ts
  12. 8
      server/service/config/registry.ts

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 && loggedIn"
: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 && loggedIn"
: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);

2
app/pages/admin/agents/[slug].vue

@ -283,7 +283,7 @@ onMounted(async () => {
</div>
</label>
<label class="toggle-item">
<input type="checkbox" :checked="form.isDefault === 1" @change="form.isDefault = form.isDefault ? 0 : 1" />
<input type="checkbox" disabled :checked="form.isDefault === 1" @change="form.isDefault = form.isDefault ? 0 : 1" />
<div class="toggle-info">
<div class="toggle-name">默认 Agent</div>
<div class="toggle-desc">设为新用户/未指定时的默认 Agent</div>

30
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");
}
@ -270,6 +282,18 @@ async function handleLogout() {
}
onMounted(async () => {
if (!auth.loggedIn.value) {
try {
const res = await $fetch<{ code: number; data: { slug: string } }>("/api/agents/default-agent");
const defaultSlug = res.data?.slug;
if (defaultSlug && defaultSlug !== agentSlug.value) {
navigateTo(`/chat/${defaultSlug}`, { replace: true });
return;
}
} catch {
// ignore
}
}
await Promise.all([sessions.init(), loadModels(), loadSystemPrompt(), loadUserConfig()]);
});
</script>
@ -282,6 +306,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 +316,7 @@ onMounted(async () => {
@toggle="isMobile ? (mobileSidebarOpen = !mobileSidebarOpen) : (sidebarCollapsed = !sidebarCollapsed)"
@close="mobileSidebarOpen = false"
@show-documents="showDocumentDrawer = true"
@switch-agent="handleSwitchAgent"
/>
<AgentChatArea
@ -308,6 +334,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"

129
app/pages/settings/preferences.vue

@ -12,8 +12,10 @@ interface ModelOption {
const models = ref<ModelOption[]>([]);
const preferredModelId = ref<number | null>(null);
const reviewApprovalMode = ref<string>("manual");
const loading = ref(false);
const saving = ref(false);
const savingApproval = ref(false);
async function loadModels() {
try {
@ -37,6 +39,8 @@ async function loadUserConfig() {
);
const val = res.data?.config?.preferredLlmModelId;
preferredModelId.value = typeof val === "number" && val > 0 ? val : null;
const approvalVal = res.data?.config?.reviewApprovalMode;
reviewApprovalMode.value = typeof approvalVal === "string" && ["manual", "all-pass"].includes(approvalVal) ? approvalVal : "manual";
} catch {
preferredModelId.value = null;
}
@ -64,6 +68,23 @@ async function handleSave() {
}
}
async function handleSaveApproval() {
savingApproval.value = true;
try {
await $fetch("/api/config/me", {
method: "PUT",
body: { key: "reviewApprovalMode", value: reviewApprovalMode.value },
});
const { $toast } = useNuxtApp();
$toast?.success?.("审批设置已保存");
} catch {
const { $toast } = useNuxtApp();
$toast?.error?.("保存失败,请稍后重试");
} finally {
savingApproval.value = false;
}
}
onMounted(async () => {
loading.value = true;
await Promise.all([loadModels(), loadUserConfig()]);
@ -116,6 +137,60 @@ onMounted(async () => {
</div>
</div>
</div>
<div class="settings-card">
<div class="card-header">
<h2 class="card-title">审批设置</h2>
<p class="card-desc">Agent 调用工具时的审批策略</p>
</div>
<div class="card-body">
<div class="approval-options">
<label
class="approval-option"
:class="{ active: reviewApprovalMode === 'manual' }"
>
<input
v-model="reviewApprovalMode"
type="radio"
value="manual"
class="approval-radio"
/>
<div class="approval-content">
<span class="approval-label">手动审批</span>
<span class="approval-desc">每次工具调用需要您手动确认后才会执行</span>
</div>
</label>
<label
class="approval-option"
:class="{ active: reviewApprovalMode === 'all-pass' }"
>
<input
v-model="reviewApprovalMode"
type="radio"
value="all-pass"
class="approval-radio"
/>
<div class="approval-content">
<span class="approval-label">全部通过</span>
<span class="approval-desc">所有工具调用自动批准,无需手动确认</span>
</div>
</label>
</div>
<div class="form-actions">
<button
type="button"
class="btn-primary"
:disabled="savingApproval"
@click="handleSaveApproval"
>
{{ savingApproval ? "保存中..." : "保存" }}
</button>
</div>
</div>
</div>
</div>
</template>
@ -247,4 +322,58 @@ onMounted(async () => {
padding: 24px;
}
}
.approval-options {
display: flex;
flex-direction: column;
gap: 12px;
}
.approval-option {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px;
border: 1px solid var(--color-hairline);
border-radius: 8px;
cursor: pointer;
transition: all 0.15s ease;
}
.approval-option:hover {
border-color: var(--color-primary);
}
.approval-option.active {
border-color: var(--color-primary);
background: rgba(204, 120, 92, 0.05);
}
.approval-radio {
margin-top: 2px;
accent-color: var(--color-primary);
cursor: pointer;
}
.approval-content {
display: flex;
flex-direction: column;
gap: 4px;
}
.approval-label {
font-size: 14px;
font-weight: 500;
color: var(--color-ink);
}
.approval-desc {
font-size: 13px;
color: var(--color-muted);
line-height: 1.5;
}
.settings-card + .settings-card {
margin-top: 24px;
}
</style>

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.

3
server/api/agents/[agentSlug]/chat/index.post.ts

@ -57,8 +57,10 @@ export default defineEventHandler(async (event) => {
const ip = getRequestIP(event, { xForwardedFor: true }) || "unknown";
let preferredModelId: number | null = null;
let reviewApprovalMode: string = "manual";
if (identity.userId) {
preferredModelId = (await getConfigUser(event, "preferredLlmModelId")) ?? null;
reviewApprovalMode = (await getConfigUser(event, "reviewApprovalMode")) ?? "manual";
}
const globalDefaultModelId = (await getConfigGlobal("agentDefaultModelId")) ?? null;
@ -84,6 +86,7 @@ export default defineEventHandler(async (event) => {
ip,
preferredModelId,
globalDefaultModelId,
reviewApprovalMode,
requestId: event.context.requestId,
registerAbort: registerAbortController,
unregisterAbort: unregisterAbortController,

11
server/api/agents/[agentSlug]/info.get.ts

@ -1,5 +1,6 @@
import { defineWrappedResponseHandler } from "#server/utils/handler";
import { R } from "#server/utils/response";
import { getCurrentUser, getConfigGlobal } from "#server/utils/context";
import { getAgentBySlug, getAgentTools } from "#server/service/agent/agent";
export default defineWrappedResponseHandler(async (event) => {
@ -13,7 +14,15 @@ export default defineWrappedResponseHandler(async (event) => {
return R.error("agent 不存在", null);
}
const tools = await getAgentTools(agent.id);
const user = await getCurrentUser(event);
const isGuest = !user;
let tools = await getAgentTools(agent.id);
if (isGuest) {
const publicToolSlugs: string[] = await getConfigGlobal("agentPublicToolSlugs");
tools = tools.filter((t) => publicToolSlugs.includes(t.slug));
}
return R.success({
id: agent.id,

11
server/service/agent/chat-engine.ts

@ -46,6 +46,7 @@ export interface ChatEngineParams {
ip: string;
preferredModelId?: number | null;
globalDefaultModelId?: number | null;
reviewApprovalMode?: string;
requestId?: string;
registerAbort: (sessionId: string, controller: AbortController) => void;
unregisterAbort: (sessionId: string) => void;
@ -434,13 +435,21 @@ export async function executeChat(params: ChatEngineParams): Promise<ChatEngineR
logger.info("[%s] [APPROVAL-PREFIX] written %d bytes to buffer before stream start", requestId, approvalPrefixChunk.length);
}
const toolApproval = params.reviewApprovalMode === "all-pass"
? () => "approved" as const
: undefined;
const result = streamText({
model: languageModel,
system: systemPrompt,
messages: modelMessages,
abortSignal: serverAbortController.signal,
...(Object.keys(tools).length > 0
? { tools, stopWhen: stepCountIs(maxSteps) }
? {
tools,
stopWhen: stepCountIs(maxSteps),
...(toolApproval ? { toolApproval } : {}),
}
: {}),
...(thinkingEnabled
? {

8
server/service/config/registry.ts

@ -134,6 +134,14 @@ const CONFIG_REGISTRY = {
userOverridable: true,
validate: (value: number) => Number.isInteger(value) && value >= 0,
}),
reviewApprovalMode: defineConfig<string>({
key: "reviewApprovalMode",
scope: "user",
valueType: "string",
defaultValue: "manual",
userOverridable: true,
validate: (value: string) => ["manual", "all-pass"].includes(value),
}),
} as const;
export type KnownConfigKey = keyof typeof CONFIG_REGISTRY;

Loading…
Cancel
Save