Browse Source

feat: enhance agent configuration and user preferences

- Updated agent configuration page to include a title generation model for guest users.
- Added a new preferences page for users to set their preferred LLM model.
- Implemented loading and saving functionality for user preferences.
- Introduced new API endpoints for guest users to access models and system prompts.
- Enhanced model loading logic to accommodate guest users and their default models.
- Improved error handling and user feedback for model saving operations.
- Added search tool executor with support for Bing, Baidu, and Google.
- Implemented parsing logic for search results from different engines.
- Updated session management to utilize user-specific model preferences.
feat/ai-sdk-v6-upgrade
npmrun 2 months ago
parent
commit
59f860da87
  1. 33
      app/components/agent/AgentChatArea.vue
  2. 109
      app/components/agent/AgentInput.vue
  3. 2
      app/components/agent/AgentMessageParts.vue
  4. 14
      app/components/agent/AgentSidebarItem.vue
  5. 2
      app/components/agent/AgentToolCallBlock.vue
  6. 27
      app/components/agent/AgentToolbar.vue
  7. 66
      app/composables/useAgentChat.ts
  8. 16
      app/composables/useAgentSessions.ts
  9. 20
      app/composables/useLlmChat.ts
  10. 1
      app/pages/admin.vue
  11. 83
      app/pages/admin/agent-config.vue
  12. 10
      app/pages/admin/agent-tools/index.vue
  13. 2
      app/pages/admin/global-config/index.vue
  14. 71
      app/pages/index.vue
  15. 5
      app/pages/settings.vue
  16. 250
      app/pages/settings/preferences.vue
  17. 18
      app/types/chat.ts
  18. 12
      packages/common/config/index.ts
  19. BIN
      packages/drizzle-pkg/db.sqlite
  20. 6
      server/api/agent/chat/index.post.ts
  21. 33
      server/api/agent/models/index.get.ts
  22. 18
      server/api/agent/sessions/index.post.ts
  23. 32
      server/service/agent-tool/executors/search/config.ts
  24. 119
      server/service/agent-tool/executors/search/parse.ts
  25. 173
      server/service/agent-tool/executors/search/search.ts
  26. 10
      server/service/agent-tool/index.ts
  27. 2
      server/service/agent/session.ts
  28. 13
      server/service/llm/index.ts

33
app/components/agent/AgentChatArea.vue

@ -14,7 +14,6 @@ const props = defineProps<{
loggedIn: boolean;
modelId: number | null;
models: ModelOption[];
enableThinking: boolean;
enableTools: boolean;
rateLimitInfo?: {
sessionRemaining: number | null;
@ -22,13 +21,13 @@ const props = defineProps<{
};
sidebarCollapsed: boolean;
errorMessage: string;
systemPrompt?: string;
}>();
const emit = defineEmits<{
send: [content: string];
stop: [];
"update:modelId": [modelId: number | null];
"update:enableThinking": [val: boolean];
"update:enableTools": [val: boolean];
approve: [toolCallId: string, approved: boolean];
feedback: [messageId: string, feedback: "like" | "dislike"];
@ -40,15 +39,24 @@ const emit = defineEmits<{
}>();
const inputRef = ref<{ setText: (text: string) => void; focus: () => void } | null>(null);
const showPromptModal = ref(false);
const editingMessageId = ref<string | null>(null);
function handleExample(content: string) {
emit("example", content);
}
function handleEdit(messageId: string, content: string) {
editingMessageId.value = messageId;
inputRef.value?.setText(content);
emit("edit", messageId, content);
}
function handleSend(content: string) {
if (editingMessageId.value) {
emit("edit", editingMessageId.value, content);
editingMessageId.value = null;
} else {
emit("send", content);
}
}
watch(
@ -67,14 +75,10 @@ watch(
<AgentToolbar
:model-id="modelId"
:models="models"
:enable-thinking="enableThinking"
:enable-tools="enableTools"
:logged-in="loggedIn"
:rate-limit-info="rateLimitInfo"
@update:model-id="emit('update:modelId', $event)"
@update:enable-thinking="emit('update:enableThinking', $event)"
@update:enable-tools="emit('update:enableTools', $event)"
@show-prompt="showPromptModal = true"
@show-prompt="emit('showPrompt')"
@toggle-sidebar="emit('toggleSidebar')"
/>
@ -101,14 +105,11 @@ watch(
ref="inputRef"
:is-loading="isLoading"
:disabled="rateLimitInfo?.sessionRemaining === 0"
@send="emit('send', $event)"
:enable-tools="enableTools"
:logged-in="loggedIn"
@send="handleSend"
@stop="emit('stop')"
/>
<AgentSystemPromptModal
:show="showPromptModal"
system-prompt=""
@close="showPromptModal = false"
@update:enable-tools="emit('update:enableTools', $event)"
/>
</div>
</template>

109
app/components/agent/AgentInput.vue

@ -1,17 +1,52 @@
<script setup lang="ts">
interface ToolInfo {
slug: string;
name: string;
type: string;
description: string;
}
const props = defineProps<{
isLoading: boolean;
disabled?: boolean;
placeholder?: string;
enableTools: boolean;
loggedIn: boolean;
}>();
const emit = defineEmits<{
send: [content: string];
stop: [];
"update:enableTools": [val: boolean];
}>();
const inputText = ref("");
const textareaRef = ref<HTMLTextAreaElement | null>(null);
const availableTools = ref<ToolInfo[]>([]);
async function loadTools() {
try {
const res = await $fetch<{ code: number; data: ToolInfo[] }>("/api/llm/chat/tools");
availableTools.value = res.data ?? [];
} catch {
availableTools.value = [];
}
}
watch(
() => props.enableTools,
(val) => {
if (val && availableTools.value.length === 0) {
loadTools();
}
},
);
onMounted(() => {
if (props.enableTools) {
loadTools();
}
});
function autoResize() {
if (!textareaRef.value) return;
@ -55,6 +90,29 @@ defineExpose({
<template>
<div class="agent-input">
<!-- Tools bar above input -->
<div v-if="loggedIn" class="tools-bar">
<button
class="toggle-btn"
:class="{ active: enableTools }"
title="工具调用"
@click="emit('update:enableTools', !enableTools)"
>
<Icon name="lucide:wrench" />
<span>工具</span>
</button>
<div v-if="enableTools && availableTools.length > 0" class="tools-list">
<span
v-for="t in availableTools"
:key="t.slug"
class="tool-chip"
:title="t.description"
>
{{ t.name }}
</span>
</div>
</div>
<div class="input-wrapper">
<textarea
ref="textareaRef"
@ -95,6 +153,57 @@ defineExpose({
width: 100%;
}
.tools-bar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
flex-wrap: wrap;
}
.toggle-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: var(--color-surface-soft);
border: 1px solid var(--color-hairline);
border-radius: 8px;
font-size: 12px;
color: var(--color-muted);
cursor: pointer;
transition: all 0.15s;
}
.toggle-btn:hover {
border-color: var(--color-primary);
}
.toggle-btn.active {
background: var(--color-primary);
border-color: var(--color-primary);
color: var(--color-on-primary);
}
.tools-list {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.tool-chip {
display: inline-flex;
align-items: center;
padding: 2px 8px;
background: var(--color-surface-soft);
border: 1px solid var(--color-hairline);
border-radius: 6px;
font-size: 11px;
color: var(--color-muted);
cursor: default;
}
.input-wrapper {
display: flex;
align-items: flex-end;

2
app/components/agent/AgentMessageParts.vue

@ -1,5 +1,5 @@
<script setup lang="ts">
import type { MessagePart } from "~/composables/useLlmChat";
import type { MessagePart } from "~/types/chat";
const props = defineProps<{
parts: MessagePart[];

14
app/components/agent/AgentSidebarItem.vue

@ -128,11 +128,21 @@ function formatTime(dateStr: string): string {
}
.sidebar-item:hover {
background: var(--color-surface-soft);
background: var(--color-surface-card);
}
.sidebar-item.active {
background: var(--color-surface-soft);
background: var(--color-surface-card);
box-shadow: inset 3px 0 0 var(--color-primary);
}
.sidebar-item.active .item-icon {
color: var(--color-primary);
}
.sidebar-item.active .item-title {
color: var(--color-primary);
font-weight: 500;
}
.item-icon {

2
app/components/agent/AgentToolCallBlock.vue

@ -1,5 +1,5 @@
<script setup lang="ts">
import type { MessagePart } from "~/composables/useLlmChat";
import type { MessagePart } from "~/types/chat";
const props = defineProps<{
part: MessagePart;

27
app/components/agent/AgentToolbar.vue

@ -9,8 +9,6 @@ interface ModelOption {
const props = defineProps<{
modelId: number | null;
models: ModelOption[];
enableThinking: boolean;
enableTools: boolean;
loggedIn: boolean;
rateLimitInfo?: {
sessionRemaining: number | null;
@ -20,8 +18,6 @@ const props = defineProps<{
const emit = defineEmits<{
"update:modelId": [modelId: number | null];
"update:enableThinking": [val: boolean];
"update:enableTools": [val: boolean];
showPrompt: [];
toggleSidebar: [];
}>();
@ -53,29 +49,6 @@ const emit = defineEmits<{
</span>
</div>
<!-- Thinking toggle -->
<button
class="toggle-btn"
:class="{ active: enableThinking }"
title="思考模式"
@click="emit('update:enableThinking', !enableThinking)"
>
<Icon name="lucide:brain" />
<span>思考</span>
</button>
<!-- Tools toggle (logged in only) -->
<button
v-if="loggedIn"
class="toggle-btn"
:class="{ active: enableTools }"
title="工具调用"
@click="emit('update:enableTools', !enableTools)"
>
<Icon name="lucide:wrench" />
<span>工具</span>
</button>
<!-- System prompt button -->
<button class="icon-btn" title="系统提示词" @click="emit('showPrompt')">
<Icon name="lucide:info" />

66
app/composables/useAgentChat.ts

@ -2,25 +2,9 @@ import { ref } from "vue";
import { parseJsonEventStream, uiMessageChunkSchema } from "ai";
import { useAuthSession } from "./useAuthSession";
import { useAgentRateLimit } from "./useAgentRateLimit";
import type { MessagePart, MessagePartType } from "../types/chat";
export type MessagePartType = "text" | "reasoning" | "tool-call" | "tool-result" | "tool-approval";
export interface MessagePart {
id: string;
type: MessagePartType;
text?: string;
toolName?: string;
toolCallId?: string;
args?: unknown;
result?: unknown;
state?: "call" | "result" | "approval-requested" | "approval-responded";
approvalId?: string;
approved?: boolean;
approvalReason?: string;
isAutomaticApproval?: boolean;
reasoningLoading?: boolean;
reasoningDuration?: number;
}
export type { MessagePart, MessagePartType } from "../types/chat";
export interface AgentMessage {
id: string;
@ -280,11 +264,46 @@ export function useAgentChat(options: UseAgentChatOptions) {
const sid = sessionId();
if (!trimmed || sid === null || isLoading.value) return;
if (!modelId()) {
errorMessage.value = "请先选择模型后再发送消息";
const { $toast } = useNuxtApp();
$toast?.error?.("请先选择模型后再发送消息");
return;
}
errorMessage.value = "";
isStopped.value = false;
let userMsgIdx = -1;
if (!opts?.editMessageId && !opts?.regenerate) {
if (opts?.editMessageId) {
const editIdx = messages.value.findIndex((m) => m.id === opts.editMessageId);
if (editIdx === -1) {
errorMessage.value = "编辑的消息不存在";
const { $toast } = useNuxtApp();
$toast?.error?.("编辑的消息不存在");
return;
}
const editMsg = messages.value[editIdx];
if (editMsg) {
editMsg.content = trimmed;
}
messages.value = messages.value.slice(0, editIdx + 1);
userMsgIdx = editIdx;
} else if (opts?.regenerate) {
const lastAssistantIdx = messages.value.map((m) => m.role).lastIndexOf("assistant");
if (lastAssistantIdx !== -1) {
messages.value.splice(lastAssistantIdx, 1);
}
const lastUserIdx = messages.value.map((m) => m.role).lastIndexOf("user");
if (lastUserIdx !== -1) {
const lastUserMsg = messages.value[lastUserIdx];
if (lastUserMsg) {
lastUserMsg.content = trimmed;
}
userMsgIdx = lastUserIdx;
}
} else {
messages.value.push({
id: generateId(),
role: "user",
@ -345,6 +364,15 @@ export function useAgentChat(options: UseAgentChatOptions) {
if (msg && !msg.content && (!msg.parts || msg.parts.length === 0)) {
messages.value.splice(assistantIdx, 1);
}
if (!opts?.editMessageId && !opts?.regenerate && userMsgIdx >= 0) {
const currentUserMsg = messages.value[userMsgIdx];
if (currentUserMsg && currentUserMsg.role === "user" && currentUserMsg.id.startsWith("am_") === false) {
const hasDbId = currentUserMsg.id.startsWith("am_");
if (!hasDbId) {
messages.value.splice(userMsgIdx, 1);
}
}
}
}
} finally {
isLoading.value = false;

16
app/composables/useAgentSessions.ts

@ -64,6 +64,11 @@ export function useAgentSessions() {
}
async function newSession(): Promise<AgentSessionItem | null> {
const latest = sessions.value[0];
if (latest && latest.lastActiveAt === latest.createdAt) {
currentSessionId.value = latest.id;
return latest;
}
const session = await createSession();
if (session) {
currentSessionId.value = session.id;
@ -88,7 +93,16 @@ export function useAgentSessions() {
await $fetch(`/api/agent/sessions/${id}`, { method: "DELETE" });
sessions.value = sessions.value.filter((s) => s.id !== id);
if (currentSessionId.value === id) {
currentSessionId.value = sessions.value[0]?.id ?? null;
if (sessions.value.length > 0) {
currentSessionId.value = sessions.value[0]?.id ?? null;
} else {
const session = await createSession();
if (session) {
currentSessionId.value = session.id;
} else {
currentSessionId.value = null;
}
}
}
} catch {
}

20
app/composables/useLlmChat.ts

@ -1,23 +1,7 @@
import { parseJsonEventStream, uiMessageChunkSchema } from 'ai'
import type { MessagePart, MessagePartType } from '../types/chat'
export type MessagePartType = 'text' | 'reasoning' | 'tool-call' | 'tool-result' | 'tool-approval'
export interface MessagePart {
id: string
type: MessagePartType
text?: string
toolName?: string
toolCallId?: string
args?: unknown
result?: unknown
state?: 'call' | 'result' | 'approval-requested' | 'approval-responded'
approvalId?: string
approved?: boolean
approvalReason?: string
isAutomaticApproval?: boolean
reasoningLoading?: boolean
reasoningDuration?: number
}
export type { MessagePart, MessagePartType } from '../types/chat'
export interface LlmChatMessage {
id: string

1
app/pages/admin.vue

@ -24,6 +24,7 @@ const adminNav: NavItem[] = [
requireAdmin: true,
children: [
{ label: '全局配置', to: '/admin/global-config' },
{ label: 'Agent 配置', to: '/admin/agent-config' },
{ label: '用户管理', to: '/admin/users' },
]
},

83
app/pages/admin/agent-config.vue

@ -1,15 +1,13 @@
<script setup lang="ts">
import { useAuthSession } from "~/composables/useAuthSession";
definePageMeta({
layout: "default",
layout: false,
});
const auth = useAuthSession();
const { $toast } = useNuxtApp() as any;
const systemPrompt = ref("");
const defaultModelId = ref<number | null>(null);
const titleModelId = ref<number | null>(null);
const publicToolSlugs = ref<string[]>([]);
const loading = ref(false);
const saving = ref(false);
@ -18,6 +16,7 @@ interface ModelOption {
id: number;
name: string;
modelId: string;
type: string;
}
const models = ref<ModelOption[]>([]);
@ -31,6 +30,7 @@ async function loadConfig() {
const d = res.data;
systemPrompt.value = d.systemPrompt ?? "";
defaultModelId.value = d.defaultModelId ?? null;
titleModelId.value = d.titleModelId ?? null;
publicToolSlugs.value = d.publicToolSlugs ?? [];
} catch {
$toast?.error?.("加载配置失败");
@ -41,11 +41,14 @@ async function loadConfig() {
async function loadModels() {
try {
const res = await $fetch<{ code: number; data: { list: any[] } }>(
"/api/admin/llm/models",
);
const res = await $fetch<{ code: number; data: { list: any[] } }>("/api/llm/models");
const list = res.data?.list ?? [];
models.value = list.map((m) => ({ id: m.id, name: m.name, modelId: m.modelId }));
models.value = list.map((m) => ({
id: m.id,
name: m.name,
modelId: m.modelId,
type: m.type,
}));
} catch {
models.value = [];
}
@ -53,10 +56,8 @@ async function loadModels() {
async function loadTools() {
try {
const res = await $fetch<{ code: number; data: { list: any[] } }>(
"/api/agent-tools",
);
availableTools.value = (res.data?.list ?? []).map((t) => ({
const res = await $fetch<{ code: number; data: any[] }>("/api/agent-tools");
availableTools.value = (res.data ?? []).map((t: any) => ({
slug: t.slug,
name: t.name,
description: t.description ?? "",
@ -86,9 +87,9 @@ async function saveDefaultModel() {
try {
await $fetch("/api/admin/agent-config/default-model", {
method: "PUT",
body: { modelId: defaultModelId.value },
body: { defaultModelId: defaultModelId.value, titleModelId: titleModelId.value },
});
$toast?.success?.("默认模型已保存");
$toast?.success?.("模型配置已保存");
} catch {
$toast?.error?.("保存失败");
} finally {
@ -147,16 +148,30 @@ onMounted(async () => {
</button>
</section>
<!-- Default Model -->
<!-- Guest Default Model -->
<section class="config-section">
<h2 class="section-title">默认模型</h2>
<p class="section-desc">新对话的默认模型选择</p>
<select v-model="defaultModelId" class="config-select">
<option :value="null">— 未设置 —</option>
<option v-for="m in models" :key="m.id" :value="m.id">
{{ m.name }} ({{ m.modelId }})
</option>
</select>
<h2 class="section-title">未登录用户模型</h2>
<p class="section-desc">未登录用户(临时会话)使用的默认模型与标题生成模型</p>
<div class="model-row">
<div class="model-field">
<label class="field-label">默认模型</label>
<select v-model="defaultModelId" class="config-select">
<option :value="null">— 未设置 —</option>
<option v-for="m in models" :key="m.id" :value="m.id">
{{ m.name }} ({{ m.modelId }})
</option>
</select>
</div>
<div class="model-field">
<label class="field-label">标题生成模型</label>
<select v-model="titleModelId" class="config-select">
<option :value="null">— 未设置 —</option>
<option v-for="m in models" :key="m.id" :value="m.id">
{{ m.name }} ({{ m.modelId }})
</option>
</select>
</div>
</div>
<button class="save-btn" :disabled="saving" @click="saveDefaultModel">
保存
</button>
@ -165,7 +180,9 @@ onMounted(async () => {
<!-- Public Tools -->
<section class="config-section">
<h2 class="section-title">公开工具白名单</h2>
<p class="section-desc">未登录用户可自动执行的工具(无需审批)</p>
<p class="section-desc">
未登录用户(临时会话)可自动执行的工具列表。仅白名单中的工具会以无需审批模式提供给匿名用户,其他已启用工具不会对匿名用户暴露。
</p>
<div class="tools-list">
<label
v-for="t in availableTools"
@ -266,6 +283,24 @@ onMounted(async () => {
cursor: pointer;
}
.model-row {
display: flex;
gap: 16px;
}
.model-field {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
}
.field-label {
font-size: 13px;
font-weight: 500;
color: var(--color-muted);
}
.save-btn {
margin-top: 16px;
padding: 8px 20px;

10
app/pages/admin/agent-tools/index.vue

@ -385,6 +385,16 @@ function onExecuteClose() {
color: #be185d;
}
.type-search {
background: #e6f4ea;
color: #137333;
}
.type-user-info {
background: #e0f2fe;
color: #0369a1;
}
.status-badge {
display: inline-block;
padding: 2px 10px;

2
app/pages/admin/global-config/index.vue

@ -18,7 +18,7 @@ const configMeta: ConfigMeta[] = [
{ key: 'siteIcon', label: '站点图标', description: 'favicon 图标,支持上传或填写 URL。推荐 32×32 PNG / 16×16 ICO', type: 'string', group: '基本设置' },
{ key: 'allowRegister', label: '允许注册', description: '是否开放新用户注册功能', type: 'boolean', group: '基本设置' },
{ key: 'agentSystemPrompt', label: 'Agent 系统提示词', description: 'Agent 对话的默认系统提示词,引导模型行为', type: 'string', group: 'Agent 设置' },
{ key: 'agentDefaultModelId', label: 'Agent 默认模型 ID', description: '新对话默认使用的 LLM 模型 ID(数字,如 2)。留 0 则不设置默认', type: 'number', group: 'Agent 设置' },
{ key: 'agentDefaultModelId', label: 'Agent 未登录默认模型 ID', description: '未登录用户(临时会话)使用的默认 LLM 模型 ID(数字,如 2)。留 0 则不设置', type: 'number', group: 'Agent 设置' },
{ key: 'agentTitleModelId', label: 'Agent 标题生成模型 ID', description: '用于自动生成对话标题的 LLM 模型 ID(数字,如 2)。留 0 则不自动生成标题', type: 'number', group: 'Agent 设置' },
]

71
app/pages/index.vue

@ -12,6 +12,8 @@ const sessions = useAgentSessions();
const sidebarCollapsed = ref(false);
const showPromptModal = ref(false);
const systemPromptText = ref("");
const preferredModelId = ref<number | null>(null);
const guestDefaultModelId = ref<number | null>(null);
interface ModelOption {
id: number;
@ -23,7 +25,13 @@ interface ModelOption {
const models = ref<ModelOption[]>([]);
const currentModelId = computed({
get: () => sessions.currentSession.value?.modelId ?? null,
get: () => {
const sessionModelId = sessions.currentSession.value?.modelId ?? null;
if (sessionModelId !== null && models.value.length > 0 && !models.value.some((m) => m.id === sessionModelId)) {
return null;
}
return sessionModelId;
},
set: (val: number | null) => {
if (sessions.currentSessionId.value) {
sessions.updateSessionConfig(sessions.currentSessionId.value, { modelId: val });
@ -31,14 +39,23 @@ const currentModelId = computed({
},
});
const enableThinking = computed({
get: () => !!(sessions.currentSession.value?.enableThinking),
set: (val: boolean) => {
if (sessions.currentSessionId.value) {
sessions.updateSessionConfig(sessions.currentSessionId.value, { enableThinking: val });
watch(
() => [sessions.currentSessionId.value, models.value] as const,
([sid, modelList]) => {
if (!sid || modelList.length === 0) return;
const session = sessions.currentSession.value;
const mid = session?.modelId ?? null;
if (mid !== null && !modelList.some((m) => m.id === mid)) {
currentModelId.value = null;
} else if (mid === null) {
const fallbackSource = auth.loggedIn.value
? (preferredModelId.value && modelList.some((m) => m.id === preferredModelId.value) ? preferredModelId.value : null)
: (guestDefaultModelId.value && modelList.some((m) => m.id === guestDefaultModelId.value) ? guestDefaultModelId.value : null);
const fallback = fallbackSource ?? modelList[0].id;
currentModelId.value = fallback;
}
},
});
);
const enableTools = computed({
get: () => !!(sessions.currentSession.value?.enableTools),
@ -52,7 +69,7 @@ const enableTools = computed({
const chat = useAgentChat({
sessionId: () => sessions.currentSessionId.value,
modelId: () => currentModelId.value,
enableThinking: () => enableThinking.value,
enableThinking: () => false,
enableTools: () => enableTools.value,
});
@ -66,19 +83,37 @@ const rateLimitInfo = computed(() => {
async function loadModels() {
try {
const res = await $fetch<{ code: number; data: { list: any[] } }>("/api/llm/models");
const isGuest = !auth.loggedIn.value;
const endpoint = isGuest ? "/api/agent/models" : "/api/llm/models";
const res = await $fetch<{ code: number; data: { list: any[]; defaultModelId?: number | null } }>(endpoint);
const list = res.data?.list ?? [];
models.value = list.map((m) => ({
id: m.id,
name: m.name,
modelId: m.modelId,
providerName: m.providerName ?? m.provider?.name ?? "—",
providerName: m.providerName ?? "—",
}));
if (isGuest) {
guestDefaultModelId.value = res.data?.defaultModelId ?? null;
}
} catch {
models.value = [];
}
}
async function loadUserConfig() {
if (!auth.loggedIn.value) return;
try {
const res = await $fetch<{ code: number; data: { config: Record<string, unknown> } }>(
"/api/config/me",
);
const val = res.data?.config?.preferredLlmModelId;
preferredModelId.value = typeof val === "number" && val > 0 ? val : null;
} catch {
preferredModelId.value = null;
}
}
async function loadSystemPrompt() {
try {
const res = await $fetch<{ code: number; data: { systemPrompt?: string } }>(
@ -111,13 +146,18 @@ async function handleSend(content: string) {
await chat.send(content);
sessions.touchSessionOrder(sid);
if (wasNewSession) {
setTimeout(() => sessions.refreshSessionTitle(sid), 8000);
setTimeout(() => sessions.refreshSessionTitle(sid), 15000);
setTimeout(() => sessions.refreshSessionTitle(sid), 25000);
refreshTitleWithRetry(sid);
}
}
}
function refreshTitleWithRetry(sid: string) {
const delays = [3000, 6000, 10000, 15000, 22000];
delays.forEach((delay) => {
setTimeout(() => sessions.refreshSessionTitle(sid), delay);
});
}
async function handleApprove(toolCallId: string, approved: boolean) {
await chat.respondToApproval(toolCallId, approved);
}
@ -146,7 +186,7 @@ function handleLogin() {
}
onMounted(async () => {
await Promise.all([sessions.init(), loadModels(), loadSystemPrompt()]);
await Promise.all([sessions.init(), loadModels(), loadSystemPrompt(), loadUserConfig()]);
});
</script>
@ -171,15 +211,14 @@ onMounted(async () => {
:logged-in="auth.loggedIn.value"
:model-id="currentModelId"
:models="models"
:enable-thinking="enableThinking"
:enable-tools="enableTools"
:rate-limit-info="rateLimitInfo"
:sidebar-collapsed="sidebarCollapsed"
:error-message="chat.errorMessage.value"
:system-prompt="systemPromptText"
@send="handleSend"
@stop="chat.stopGeneration()"
@update:model-id="currentModelId = $event"
@update:enable-thinking="enableThinking = $event"
@update:enable-tools="enableTools = $event"
@approve="handleApprove"
@feedback="handleFeedback"

5
app/pages/settings.vue

@ -8,6 +8,11 @@ const { user, clear } = useAuthSession()
const settingsNav: NavItem[] = [
{
label: '偏好设置',
to: '/settings/preferences',
icon: 'lucide:sliders-horizontal',
},
{
label: '模型配置',
to: '/settings/llm-config',
icon: 'lucide:brain-circuit',

250
app/pages/settings/preferences.vue

@ -0,0 +1,250 @@
<script setup lang="ts">
definePageMeta({
layout: false,
});
interface ModelOption {
id: number;
name: string;
modelId: string;
providerName: string;
}
const models = ref<ModelOption[]>([]);
const preferredModelId = ref<number | null>(null);
const loading = ref(false);
const saving = ref(false);
async function loadModels() {
try {
const res = await $fetch<{ code: number; data: { list: any[] } }>("/api/llm/models");
const list = res.data?.list ?? [];
models.value = list.map((m) => ({
id: m.id,
name: m.name,
modelId: m.modelId,
providerName: m.providerName ?? "—",
}));
} catch {
models.value = [];
}
}
async function loadUserConfig() {
try {
const res = await $fetch<{ code: number; data: { config: Record<string, unknown> } }>(
"/api/config/me",
);
const val = res.data?.config?.preferredLlmModelId;
preferredModelId.value = typeof val === "number" && val > 0 ? val : null;
} catch {
preferredModelId.value = null;
}
}
async function handleSave() {
if (preferredModelId.value === null) {
const { $toast } = useNuxtApp();
$toast?.error?.("请选择一个模型");
return;
}
saving.value = true;
try {
await $fetch("/api/config/me", {
method: "PUT",
body: { key: "preferredLlmModelId", value: preferredModelId.value },
});
const { $toast } = useNuxtApp();
$toast?.success?.("默认模型已保存");
} catch {
const { $toast } = useNuxtApp();
$toast?.error?.("保存失败,请稍后重试");
} finally {
saving.value = false;
}
}
onMounted(async () => {
loading.value = true;
await Promise.all([loadModels(), loadUserConfig()]);
loading.value = false;
});
</script>
<template>
<div class="preferences-page">
<header class="page-header">
<h1 class="page-title">偏好设置</h1>
<p class="page-subtitle">管理您的个人偏好配置</p>
</header>
<div v-if="loading" class="loading-state">加载中...</div>
<div v-else class="settings-card">
<div class="card-header">
<h2 class="card-title">默认模型</h2>
<p class="card-desc">新建对话时将自动选中此模型</p>
</div>
<div class="card-body">
<div class="form-group">
<label class="form-label">偏好模型</label>
<select
v-model="preferredModelId"
class="form-select"
>
<option :value="null" disabled>请选择模型</option>
<option
v-for="m in models"
:key="m.id"
:value="m.id"
>
{{ m.name }} ({{ m.providerName }})
</option>
</select>
</div>
<div class="form-actions">
<button
type="button"
class="btn-primary"
:disabled="saving || preferredModelId === null"
@click="handleSave"
>
{{ saving ? "保存中..." : "保存" }}
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.preferences-page {
padding: 40px;
max-width: 800px;
}
.page-header {
margin-bottom: 32px;
}
.page-title {
font-family: var(--font-display);
font-size: 32px;
font-weight: 400;
color: var(--color-ink);
letter-spacing: -0.3px;
margin: 0 0 8px 0;
}
.page-subtitle {
font-size: 15px;
color: var(--color-muted);
margin: 0;
}
.loading-state {
padding: 40px;
text-align: center;
color: var(--color-muted);
font-size: 14px;
}
.settings-card {
background: var(--color-surface-card);
border-radius: 12px;
overflow: hidden;
}
.card-header {
padding: 20px 24px;
border-bottom: 1px solid var(--color-hairline);
background: var(--color-surface-soft);
}
.card-title {
font-size: 16px;
font-weight: 500;
color: var(--color-ink);
margin: 0 0 4px 0;
}
.card-desc {
font-size: 13px;
color: var(--color-muted);
margin: 0;
}
.card-body {
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-label {
font-size: 14px;
font-weight: 500;
color: var(--color-body-strong);
}
.form-select {
width: 100%;
padding: 12px 16px;
font-size: 14px;
color: var(--color-ink);
background: var(--color-canvas);
border: 1px solid var(--color-hairline);
border-radius: 8px;
transition: all 0.15s ease;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23999' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 40px;
}
.form-select:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(204, 120, 92, 0.15);
}
.form-actions {
padding-top: 8px;
}
.btn-primary {
padding: 12px 24px;
font-size: 14px;
font-weight: 500;
color: var(--color-on-primary);
background: var(--color-primary);
border: none;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s ease;
}
.btn-primary:hover {
background: var(--color-primary-active);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
@media (max-width: 768px) {
.preferences-page {
padding: 24px;
}
}
</style>

18
app/types/chat.ts

@ -0,0 +1,18 @@
export type MessagePartType = 'text' | 'reasoning' | 'tool-call' | 'tool-result' | 'tool-approval'
export interface MessagePart {
id: string
type: MessagePartType
text?: string
toolName?: string
toolCallId?: string
args?: unknown
result?: unknown
state?: 'call' | 'result' | 'approval-requested' | 'approval-responded'
approvalId?: string
approved?: boolean
approvalReason?: string
isAutomaticApproval?: boolean
reasoningLoading?: boolean
reasoningDuration?: number
}

12
packages/common/config/index.ts

@ -37,6 +37,18 @@ export const API_ALLOWLIST: RouteRule[] = [
/** 项目 — 公开只读,登录后增删改 */
{ path: "/api/projects", methods: ["GET"] },
{ path: "/api/projects/:id", methods: ["GET", "PUT", "DELETE"] },
/** Agent — 未登录用户通过 tempToken 使用 */
{ path: "/api/agent/models", methods: ["GET"] },
{ path: "/api/agent/system-prompt", methods: ["GET"] },
{ path: "/api/agent/sessions", methods: ["GET", "POST"] },
{ path: "/api/agent/sessions/:id", methods: ["GET", "PUT", "DELETE"] },
{ path: "/api/agent/sessions/:id/config", methods: ["PUT"] },
{ path: "/api/agent/sessions/:id/messages", methods: ["GET"] },
{ path: "/api/agent/chat", methods: ["POST"] },
{ path: "/api/agent/chat/tool-approve", methods: ["POST"] },
{ path: "/api/agent/feedback", methods: ["POST"] },
{ path: "/api/agent-tools", methods: ["GET"] },
{ path: "/api/llm/chat/tools", methods: ["GET"] },
];
export const FRONTEND_LOGIN_PATH = "/auth/login"

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.

6
server/api/agent/chat/index.post.ts

@ -7,7 +7,7 @@ import { getSessionByIdAndUser, getMessagesBySession, getMaxSortOrder, saveMessa
import { getAgentToolsForChat } from "#server/service/agent-tool";
import { getTempTokenFromCookie } from "#server/service/agent/temp-token";
import { checkRateLimit, incrementRateLimit } from "#server/service/agent/rate-limit";
import { getModelWithProviderById, getSystemModelWithProviderById } from "#server/service/llm";
import { getModelWithProviderById, getModelWithProviderByIdAny } from "#server/service/llm";
import { generateSessionTitle } from "#server/service/agent/title";
import log4js from "logger";
@ -94,7 +94,7 @@ export default defineEventHandler(async (event) => {
if (!session.modelId) {
throw createError({ statusCode: 400, statusMessage: "系统未配置默认模型" });
}
modelRow = await getSystemModelWithProviderById(session.modelId);
modelRow = await getModelWithProviderByIdAny(session.modelId);
}
if (!modelRow) {
@ -174,7 +174,7 @@ export default defineEventHandler(async (event) => {
const modelMessages = [
...historyMessages
.filter((m) => m.id !== userMessage.id || editMessageId || regenerate)
.filter((m) => m.id !== userMessage.id)
.map((m) => ({
role: m.role as "user" | "assistant",
content: m.content,

33
server/api/agent/models/index.get.ts

@ -0,0 +1,33 @@
import { defineWrappedResponseHandler } from "#server/utils/handler";
import { R } from "#server/utils/response";
import { getConfigGlobal } from "#server/utils/context";
import { listSystemModels, getModelWithProviderByIdAny } from "#server/service/llm";
export default defineWrappedResponseHandler(async () => {
const systemModels = await listSystemModels();
const defaultModelId = await getConfigGlobal("agentDefaultModelId");
const list = systemModels.map((m) => ({
id: m.id,
name: m.name,
modelId: m.modelId,
providerName: "—",
}));
if (defaultModelId) {
const exists = list.some((m) => m.id === defaultModelId);
if (!exists) {
const row = await getModelWithProviderByIdAny(defaultModelId);
if (row) {
list.unshift({
id: row.model.id,
name: row.model.name,
modelId: row.model.modelId,
providerName: row.provider.name,
});
}
}
}
return R.success({ list, defaultModelId: defaultModelId ?? null });
});

18
server/api/agent/sessions/index.post.ts

@ -3,7 +3,7 @@ import { R } from "#server/utils/response";
import { resolveAgentIdentity } from "#server/service/agent/identity";
import { createSession } from "#server/service/agent/session";
import { generateTempToken, setTempTokenCookie, getTempTokenTtlMs } from "#server/service/agent/temp-token";
import { getConfigGlobal } from "#server/utils/context";
import { getConfigUser, getConfigGlobal } from "#server/utils/context";
export default defineWrappedResponseHandler(async (event) => {
const identity = await resolveAgentIdentity(event);
@ -20,12 +20,24 @@ export default defineWrappedResponseHandler(async (event) => {
expiresAt = new Date(Date.now() + getTempTokenTtlMs());
}
const defaultModelId = await getConfigGlobal("agentDefaultModelId");
let modelId: number | null = null;
if (identity.userId) {
const preferred = await getConfigUser(event, "preferredLlmModelId");
if (preferred && preferred > 0) {
modelId = preferred;
}
} else {
const guestDefault = await getConfigGlobal("agentDefaultModelId");
if (guestDefault && guestDefault > 0) {
modelId = guestDefault;
}
}
const session = await createSession({
userId: identity.userId,
tempToken,
modelId: defaultModelId || null,
modelId,
expiresAt,
});

32
server/service/agent-tool/executors/search/config.ts

@ -0,0 +1,32 @@
import { z } from "zod";
export const searchConfigSchema = z.object({
engine: z
.enum(["bing", "baidu", "google"])
.default("bing")
.describe("搜索引擎: bing(必应), baidu(百度), google(谷歌)"),
maxResults: z.number().int().positive().max(20).default(10),
timeout: z.number().int().positive().max(30000).default(10000),
language: z.string().min(1).default("zh-CN"),
region: z.string().min(1).default("CN"),
snippetLength: z.number().int().positive().max(500).default(200),
});
export type SearchToolConfig = z.infer<typeof searchConfigSchema>;
export const DEFAULT_SEARCH_CONFIG: SearchToolConfig = {
engine: "bing",
maxResults: 10,
timeout: 10000,
language: "zh-CN",
region: "CN",
snippetLength: 200,
};
export function parseSearchConfig(raw: unknown): SearchToolConfig {
const parsed = searchConfigSchema.safeParse(raw);
if (!parsed.success) {
throw new Error(`Invalid search config: ${parsed.error.message}`);
}
return parsed.data;
}

119
server/service/agent-tool/executors/search/parse.ts

@ -0,0 +1,119 @@
export interface SearchResultItem {
title: string;
url: string;
snippet: string;
displayUrl?: string;
}
export interface ParsedSearchResults {
items: SearchResultItem[];
totalResults?: string;
engine: string;
}
export function parseBingResults(html: string, maxResults: number, snippetLength: number): ParsedSearchResults {
const items: SearchResultItem[] = [];
// Bing 结果项在 <li class="b_algo"> 中
const itemRegex = /<li[^>]*class="[^"]*b_algo[^"]*"[^>]*>([\s\S]*?)<\/li>/gi;
let match: RegExpExecArray | null;
while ((match = itemRegex.exec(html)) !== null && items.length < maxResults) {
const block = match[1];
// 标题和链接在 <h2><a href="..." ...>标题</a></h2>
const linkMatch = block.match(/<a[^>]*href="(https?:\/\/[^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
if (!linkMatch) continue;
const url = linkMatch[1];
const title = stripTags(linkMatch[2]).trim();
if (!title || !url) continue;
// 摘要在 <p class="b_lineclamp..."> 或 <div class="b_caption"><p>
const snippetMatch =
block.match(/<p[^>]*class="[^"]*b_lineclamp[^"]*"[^>]*>([\s\S]*?)<\/p>/i) ||
block.match(/<div[^>]*class="[^"]*b_caption[^"]*"[^>]*>[\s\S]*?<p[^>]*>([\s\S]*?)<\/p>/i);
const snippet = snippetMatch ? truncate(stripTags(snippetMatch[1]).trim(), snippetLength) : "";
// 显示URL
const displayMatch = block.match(/<cite[^>]*>([\s\S]*?)<\/cite>/i);
const displayUrl = displayMatch ? stripTags(displayMatch[1]).trim() : undefined;
items.push({ title, url, snippet, displayUrl });
}
return { items, engine: "bing" };
}
export function parseBaiduResults(html: string, maxResults: number, snippetLength: number): ParsedSearchResults {
const items: SearchResultItem[] = [];
// 百度结果项在 <div class="result ..."> 或 <div class="c-container ...">
const itemRegex = /<div[^>]*class="[^"]*(?:result|c-container)[^"]*"[^>]*>([\s\S]*?)(?=<div[^>]*class="[^"]*(?:result|c-container)|$)/gi;
let match: RegExpExecArray | null;
while ((match = itemRegex.exec(html)) !== null && items.length < maxResults) {
const block = match[1];
const linkMatch = block.match(/<a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
if (!linkMatch) continue;
const url = linkMatch[1];
const title = stripTags(linkMatch[2]).trim();
if (!title || !url) continue;
const snippetMatch = block.match(/<span[^>]*class="[^"]*content-right[^"]*"[^>]*>([\s\S]*?)<\/span>/i) ||
block.match(/<div[^>]*class="[^"]*c-abstract[^"]*"[^>]*>([\s\S]*?)<\/div>/i) ||
block.match(/<span[^>]*>([\s\S]*?)<\/span>/i);
const snippet = snippetMatch ? truncate(stripTags(snippetMatch[1]).trim(), snippetLength) : "";
items.push({ title, url, snippet });
}
return { items, engine: "baidu" };
}
export function parseGoogleResults(html: string, maxResults: number, snippetLength: number): ParsedSearchResults {
const items: SearchResultItem[] = [];
// Google 结果项在 <div class="g"> 中
const itemRegex = /<div[^>]*class="[^"]*\bg\b[^"]*"[^>]*>([\s\S]*?)<\/div>\s*(?=<div[^>]*class="[^"]*\bg\b|<div[^>]*id="foot"|$)/gi;
let match: RegExpExecArray | null;
while ((match = itemRegex.exec(html)) !== null && items.length < maxResults) {
const block = match[1];
const linkMatch = block.match(/<a[^>]*href="\/url\?q=(https?:\/\/[^&"]+)&[^"]*"[^>]*>([\s\S]*?)<\/a>/i) ||
block.match(/<a[^>]*href="(https?:\/\/[^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
if (!linkMatch) continue;
const url = decodeURIComponent(linkMatch[1]);
const title = stripTags(linkMatch[2]).trim();
if (!title || !url) continue;
const snippetMatch = block.match(/<span[^>]*>([\s\S]*?)<\/span>/i);
const snippet = snippetMatch ? truncate(stripTags(snippetMatch[1]).trim(), snippetLength) : "";
items.push({ title, url, snippet });
}
return { items, engine: "google" };
}
function stripTags(html: string): string {
return html
.replace(/<[^>]+>/g, "")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function truncate(str: string, maxLen: number): string {
if (str.length <= maxLen) return str;
return str.slice(0, maxLen) + "...";
}

173
server/service/agent-tool/executors/search/search.ts

@ -0,0 +1,173 @@
import { z } from "zod";
import type { JSONSchema7 } from "json-schema";
import type { ToolExecutor, ToolContext, ToolResult } from "../../registry";
import type { SearchToolConfig } from "./config";
import { parseBingResults, parseBaiduResults, parseGoogleResults } from "./parse";
export const searchInputSchema = z.object({
query: z.string().min(1).max(500).describe("搜索关键词"),
maxResults: z.number().int().positive().max(20).optional().describe("返回结果数量上限,不传则使用默认值"),
engine: z
.enum(["bing", "baidu", "google"])
.optional()
.describe("搜索引擎,不传则使用配置默认值"),
});
type SearchInput = z.infer<typeof searchInputSchema>;
function buildSearchUrl(
query: string,
engine: "bing" | "baidu" | "google",
language: string,
region: string,
): string {
const encoded = encodeURIComponent(query);
switch (engine) {
case "bing":
return `https://www.bing.com/search?q=${encoded}&setlang=${language}&cc=${region}&count=30`;
case "baidu":
return `https://www.baidu.com/s?wd=${encoded}&rn=30`;
case "google":
return `https://www.google.com/search?q=${encoded}&hl=${language}&gl=${region}&num=30`;
}
}
function getBrowserHeaders(engine: "bing" | "baidu" | "google"): Record<string, string> {
return {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Accept-Encoding": "gzip, deflate, br",
...(engine === "google" ? { "Cookie": "CONSENT=YES+cb.20240101-00-0;" } : {}),
};
}
export const searchExecutor: ToolExecutor<SearchToolConfig> = {
buildInputSchema(_config: SearchToolConfig): JSONSchema7 {
return {
type: "object",
properties: {
query: { type: "string", description: "搜索关键词" },
maxResults: { type: "number", description: "返回结果数量上限" },
engine: {
type: "string",
enum: ["bing", "baidu", "google"],
description: "搜索引擎",
},
},
required: ["query"],
};
},
buildDescription(config: SearchToolConfig): string {
return `网络搜索工具。使用 ${config.engine} 搜索引擎获取实时网页结果。默认返回最多 ${config.maxResults} 条结果(标题+URL+摘要)。语言: ${config.language},地区: ${config.region}。`;
},
async execute(
input: unknown,
config: SearchToolConfig,
_ctx: ToolContext,
): Promise<ToolResult> {
const start = Date.now();
const parsed = searchInputSchema.safeParse(input);
if (!parsed.success) {
return {
success: false,
data: null,
error: `输入参数校验失败: ${parsed.error.message}`,
metadata: { durationMs: Date.now() - start },
};
}
const inp = parsed.data as SearchInput;
const engine = inp.engine ?? config.engine;
const maxResults = inp.maxResults ?? config.maxResults;
const searchUrl = buildSearchUrl(inp.query, engine, config.language, config.region);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), config.timeout);
try {
const response = await fetch(searchUrl, {
method: "GET",
headers: getBrowserHeaders(engine),
signal: controller.signal,
redirect: "follow",
});
clearTimeout(timeoutId);
if (!response.ok) {
return {
success: false,
data: null,
error: `搜索引擎返回 HTTP ${response.status} ${response.statusText}`,
metadata: { durationMs: Date.now() - start },
};
}
const html = await response.text();
let parsedResults;
switch (engine) {
case "bing":
parsedResults = parseBingResults(html, maxResults, config.snippetLength);
break;
case "baidu":
parsedResults = parseBaiduResults(html, maxResults, config.snippetLength);
break;
case "google":
parsedResults = parseGoogleResults(html, maxResults, config.snippetLength);
break;
}
if (parsedResults.items.length === 0) {
return {
success: true,
data: {
query: inp.query,
engine,
items: [],
message: "未找到搜索结果,可能搜索引擎返回了反爬页面或验证码。建议更换关键词或引擎重试。",
},
metadata: {
responseSize: html.length,
durationMs: Date.now() - start,
},
};
}
return {
success: true,
data: {
query: inp.query,
engine,
totalResults: parsedResults.items.length,
items: parsedResults.items,
},
metadata: {
responseSize: html.length,
durationMs: Date.now() - start,
},
};
} catch (e) {
clearTimeout(timeoutId);
if (e instanceof Error && e.name === "AbortError") {
return {
success: false,
data: null,
error: `搜索请求超时 (${config.timeout}ms)`,
metadata: { durationMs: Date.now() - start },
};
}
return {
success: false,
data: null,
error: e instanceof Error ? e.message : String(e),
metadata: { durationMs: Date.now() - start },
};
}
},
};

10
server/service/agent-tool/index.ts

@ -24,6 +24,8 @@ import { parseRegexTesterConfig, DEFAULT_REGEX_TESTER_CONFIG } from "./executors
import { regexTesterExecutor, regexTesterInputSchema } from "./executors/regex-tester/regex-tester";
import { parseUserInfoConfig, DEFAULT_USER_INFO_CONFIG } from "./executors/user-info/config";
import { userInfoExecutor, userInfoInputSchema } from "./executors/user-info/user-info";
import { parseSearchConfig, DEFAULT_SEARCH_CONFIG } from "./executors/search/config";
import { searchExecutor, searchInputSchema } from "./executors/search/search";
// ============ 工具类型注册表 ============
interface ToolTypeRegistration {
@ -82,6 +84,12 @@ const TOOL_TYPE_REGISTRY: Record<string, ToolTypeRegistration> = {
defaultConfig: DEFAULT_USER_INFO_CONFIG,
zodSchema: userInfoInputSchema,
},
search: {
executor: searchExecutor,
parseConfig: parseSearchConfig,
defaultConfig: DEFAULT_SEARCH_CONFIG,
zodSchema: searchInputSchema,
},
};
// 立即注册所有工具类型
@ -478,4 +486,6 @@ export { DEFAULT_REGEX_TESTER_CONFIG } from "./executors/regex-tester/config";
export type { RegexTesterToolConfig } from "./executors/regex-tester/config";
export { DEFAULT_USER_INFO_CONFIG } from "./executors/user-info/config";
export type { UserInfoToolConfig } from "./executors/user-info/config";
export { DEFAULT_SEARCH_CONFIG } from "./executors/search/config";
export type { SearchToolConfig } from "./executors/search/config";
export type { ToolExecutor, ToolContext, ToolResult } from "./registry";

2
server/service/agent/session.ts

@ -172,7 +172,7 @@ export async function saveMessage(params: {
export async function truncateMessagesAfter(sessionId: string, sortOrder: number): Promise<void> {
await dbGlobal
.delete(agentMessages)
.where(and(eq(agentMessages.sessionId, sessionId), sql`${agentMessages.sortOrder} >= ${sortOrder}`));
.where(and(eq(agentMessages.sessionId, sessionId), sql`${agentMessages.sortOrder} > ${sortOrder}`));
}
export async function deleteMessage(messageId: string): Promise<void> {

13
server/service/llm/index.ts

@ -333,6 +333,19 @@ export async function getSystemModelWithProviderById(modelId: number) {
return row ?? null;
}
export async function getModelWithProviderByIdAny(modelId: number) {
const [row] = await dbGlobal
.select({
model: llmModels,
provider: llmProviders,
})
.from(llmModels)
.innerJoin(llmProviders, eq(llmModels.providerId, llmProviders.id))
.where(eq(llmModels.id, modelId))
.limit(1);
return row ?? null;
}
export async function listAllEnabledModelsForUser(userId: number | null): Promise<
{
id: number;

Loading…
Cancel
Save