diff --git a/app/components/agent/AgentChatArea.vue b/app/components/agent/AgentChatArea.vue index f10590b..cd268fd 100644 --- a/app/components/agent/AgentChatArea.vue +++ b/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(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( @@ -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')" - /> - - diff --git a/app/components/agent/AgentInput.vue b/app/components/agent/AgentInput.vue index 25fdd9e..446eab0 100644 --- a/app/components/agent/AgentInput.vue +++ b/app/components/agent/AgentInput.vue @@ -1,17 +1,52 @@ @@ -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" diff --git a/app/pages/settings.vue b/app/pages/settings.vue index 68a6733..8fdfe1e 100644 --- a/app/pages/settings.vue +++ b/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', diff --git a/app/pages/settings/preferences.vue b/app/pages/settings/preferences.vue new file mode 100644 index 0000000..4356882 --- /dev/null +++ b/app/pages/settings/preferences.vue @@ -0,0 +1,250 @@ + + + + + diff --git a/app/types/chat.ts b/app/types/chat.ts new file mode 100644 index 0000000..695c5c4 --- /dev/null +++ b/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 +} diff --git a/packages/common/config/index.ts b/packages/common/config/index.ts index cab7cd2..79322cd 100644 --- a/packages/common/config/index.ts +++ b/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" diff --git a/packages/drizzle-pkg/db.sqlite b/packages/drizzle-pkg/db.sqlite index be1041f..5b1a240 100644 Binary files a/packages/drizzle-pkg/db.sqlite and b/packages/drizzle-pkg/db.sqlite differ diff --git a/server/api/agent/chat/index.post.ts b/server/api/agent/chat/index.post.ts index b083d07..90c267f 100644 --- a/server/api/agent/chat/index.post.ts +++ b/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, diff --git a/server/api/agent/models/index.get.ts b/server/api/agent/models/index.get.ts new file mode 100644 index 0000000..419b0b2 --- /dev/null +++ b/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 }); +}); diff --git a/server/api/agent/sessions/index.post.ts b/server/api/agent/sessions/index.post.ts index 03825b7..edf9108 100644 --- a/server/api/agent/sessions/index.post.ts +++ b/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, }); diff --git a/server/service/agent-tool/executors/search/config.ts b/server/service/agent-tool/executors/search/config.ts new file mode 100644 index 0000000..16451c9 --- /dev/null +++ b/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; + +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; +} diff --git a/server/service/agent-tool/executors/search/parse.ts b/server/service/agent-tool/executors/search/parse.ts new file mode 100644 index 0000000..7fd1aeb --- /dev/null +++ b/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 结果项在
  • 中 + const itemRegex = /]*class="[^"]*b_algo[^"]*"[^>]*>([\s\S]*?)<\/li>/gi; + let match: RegExpExecArray | null; + + while ((match = itemRegex.exec(html)) !== null && items.length < maxResults) { + const block = match[1]; + + // 标题和链接在

    标题

    + const linkMatch = block.match(/]*href="(https?:\/\/[^"]+)"[^>]*>([\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(/]*class="[^"]*b_lineclamp[^"]*"[^>]*>([\s\S]*?)<\/p>/i) || + block.match(/]*class="[^"]*b_caption[^"]*"[^>]*>[\s\S]*?]*>([\s\S]*?)<\/p>/i); + const snippet = snippetMatch ? truncate(stripTags(snippetMatch[1]).trim(), snippetLength) : ""; + + // 显示URL + const displayMatch = block.match(/]*>([\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[] = []; + + // 百度结果项在

    或
    + const itemRegex = /]*class="[^"]*(?:result|c-container)[^"]*"[^>]*>([\s\S]*?)(?=]*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(/]*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(/]*class="[^"]*content-right[^"]*"[^>]*>([\s\S]*?)<\/span>/i) || + block.match(/]*class="[^"]*c-abstract[^"]*"[^>]*>([\s\S]*?)<\/div>/i) || + block.match(/]*>([\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 结果项在
    中 + const itemRegex = /]*class="[^"]*\bg\b[^"]*"[^>]*>([\s\S]*?)<\/div>\s*(?=]*class="[^"]*\bg\b|]*id="foot"|$)/gi; + let match: RegExpExecArray | null; + + while ((match = itemRegex.exec(html)) !== null && items.length < maxResults) { + const block = match[1]; + + const linkMatch = block.match(/]*href="\/url\?q=(https?:\/\/[^&"]+)&[^"]*"[^>]*>([\s\S]*?)<\/a>/i) || + block.match(/]*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(/]*>([\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(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/ /g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function truncate(str: string, maxLen: number): string { + if (str.length <= maxLen) return str; + return str.slice(0, maxLen) + "..."; +} diff --git a/server/service/agent-tool/executors/search/search.ts b/server/service/agent-tool/executors/search/search.ts new file mode 100644 index 0000000..c32f3b6 --- /dev/null +++ b/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; + +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 { + 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 = { + 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 { + 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 }, + }; + } + }, +}; diff --git a/server/service/agent-tool/index.ts b/server/service/agent-tool/index.ts index 43a8108..d622fee 100644 --- a/server/service/agent-tool/index.ts +++ b/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 = { 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"; diff --git a/server/service/agent/session.ts b/server/service/agent/session.ts index bb43cbb..24ba682 100644 --- a/server/service/agent/session.ts +++ b/server/service/agent/session.ts @@ -172,7 +172,7 @@ export async function saveMessage(params: { export async function truncateMessagesAfter(sessionId: string, sortOrder: number): Promise { 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 { diff --git a/server/service/llm/index.ts b/server/service/llm/index.ts index 434d8c7..269c2a8 100644 --- a/server/service/llm/index.ts +++ b/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;