diff --git a/commands/db.ts b/commands/db.ts index 3635379..ce9596c 100644 --- a/commands/db.ts +++ b/commands/db.ts @@ -20,8 +20,8 @@ import { handleQuery } from "./query"; import { handleHistory } from "./history"; import { handleFavorite } from "./favorites"; import { handleRelations } from "./relations"; -import { getLastRelatedCache } from "./query"; -import { openRelatedBrowser } from "./related-browser"; +import { RelatedBrowserCacheStore, openRelatedBrowser } from "./related-browser"; +import { sendDbStatus } from "./llm-context"; import { writeToggle } from "../state/extension-toggle"; // ====== 自动补全项类型(结构上匹配 pi-tui AutocompleteItem)====== @@ -76,6 +76,10 @@ export function registerDbCommand( return; } + // 最近关联查询缓存——注册时创建一次,注入 query 路径写入、 + // related 路径读取(替代 module 级单例,见 AGENTS.md 注入接缝)。 + const relatedCache = new RelatedBrowserCacheStore(); + pi.registerCommand("db", { description: "Database workspace: /db (panel) | switch | add | tables | schema | query [table] | history [kw] | favorite | relations | related | on | off", @@ -94,10 +98,10 @@ export function registerDbCommand( switch (sub) { case undefined: { // 在展示交互式仪表盘之前发送静默 LLM 上下文 - sendLLMContext(ws, pi); + sendDbStatus(pi, ws); const action = await showDashboard(ctx, ws); if (!action) return; - await dispatchAction(action, ctx, ws, pi, rest); + await dispatchAction(action, ctx, ws, pi, rest, relatedCache); break; } case "switch": @@ -113,7 +117,7 @@ export function registerDbCommand( await handleSchema(ctx, ws, pi, rest[0]); break; case "query": - await handleQuery(ctx, ws, pi, rest.join(" ") || undefined); + await handleQuery(ctx, ws, pi, rest.join(" ") || undefined, relatedCache); break; case "history": await handleHistory(ctx, ws, pi, rest[0]); @@ -125,7 +129,7 @@ export function registerDbCommand( await handleRelations(ctx, ws, pi, rest); break; case "related": - await handleRelatedBrowser(ctx); + await handleRelatedBrowser(ctx, relatedCache); break; case "on": await handleToggle(ctx, true, toggleBaseDir); @@ -143,8 +147,11 @@ export function registerDbCommand( // ====== 关联表浏览器 ================================================ /** 打开最近一次关联查询的浏览器;无缓存时提示引导。 */ -async function handleRelatedBrowser(ctx: ExtensionCommandContext): Promise { - const cache = getLastRelatedCache(); +async function handleRelatedBrowser( + ctx: ExtensionCommandContext, + relatedCache: RelatedBrowserCacheStore, +): Promise { + const cache = relatedCache.get(); if (!cache) { ctx.ui.notify( "没有可浏览的关联表——先执行关联表查询(/db query 选表后选择「📎 是,一起查询关联表」)", @@ -239,29 +246,6 @@ const DASHBOARD_ACTIONS: DashboardAction[] = [ { value: "related", label: "📎 关联表浏览器", needsConnection: false }, ]; -/** 发送静默上下文消息,让 LLM 知道数据库状态。 */ -function sendLLMContext(ws: DatabaseWorkspaceService, pi: ExtensionAPI): void { - if (ws.isReady) { - pi.sendMessage( - { - customType: "db-active-db", - content: `Current database: ${ws.current!.database} (connection: ${ws.current!.connectionId}, environment: ${ws.current!.environment}). Config file: ${ws.configPath}.`, - display: false, - }, - { deliverAs: "followUp", triggerTurn: false }, - ); - } else if (ws.isConfigured) { - pi.sendMessage( - { - customType: "db-hint", - content: `Database connections are configured but no database is selected. Tell the user to run /db switch to connect. Config file: ${ws.configPath}.`, - display: false, - }, - { deliverAs: "followUp", triggerTurn: false }, - ); - } -} - /** 构建交互式仪表盘组件。 */ async function showDashboard( ctx: ExtensionCommandContext, @@ -357,6 +341,7 @@ async function dispatchAction( ws: DatabaseWorkspaceService, pi: ExtensionAPI, rest: string[], + relatedCache: RelatedBrowserCacheStore, ): Promise { switch (action) { case "switch": @@ -372,7 +357,7 @@ async function dispatchAction( await handleSchema(ctx, ws, pi, rest[0]); break; case "query": - await handleQuery(ctx, ws, pi, rest.join(" ") || undefined); + await handleQuery(ctx, ws, pi, rest.join(" ") || undefined, relatedCache); break; case "history": await handleHistory(ctx, ws, pi, rest[0]); @@ -384,7 +369,7 @@ async function dispatchAction( await handleRelations(ctx, ws, pi, rest); break; case "related": - await handleRelatedBrowser(ctx); + await handleRelatedBrowser(ctx, relatedCache); break; } } diff --git a/commands/llm-context.ts b/commands/llm-context.ts new file mode 100644 index 0000000..9259e41 --- /dev/null +++ b/commands/llm-context.ts @@ -0,0 +1,66 @@ +/** + * 发给 LLM 的静默上下文消息 —— 唯一构建点。 + * + * AGENTS.md 要求这些字符串保持英文且稳定;本模块是它们的单一归属。 + * index.ts(session_start)、db.ts(仪表盘)、switch.ts(切换后)共用。 + */ + +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import type { DatabaseWorkspaceService } from "../state/workspace"; + +/** 已选择数据库:告知当前目标、配置文件与可用工具。 */ +export function sendActiveDb(pi: ExtensionAPI, ws: DatabaseWorkspaceService): void { + const db = ws.current!; + pi.sendMessage( + { + customType: "db-active-db", + content: + `Current database: ${db.database} (connection: ${db.connectionId}, ` + + `environment: ${db.environment}). Config file: ${ws.configPath}. ` + + `Use db_query and db_tables to query this database.`, + display: false, + }, + { deliverAs: "followUp", triggerTurn: false }, + ); +} + +/** 已配置但未选择数据库:引导用户运行 /db switch。 */ +export function sendConfiguredHint(pi: ExtensionAPI, ws: DatabaseWorkspaceService): void { + pi.sendMessage( + { + customType: "db-hint", + content: + `Database connections are configured but no database is selected. ` + + `Tell the user to run /db switch to connect. Config file: ${ws.configPath}.`, + display: false, + }, + { deliverAs: "followUp", triggerTurn: false }, + ); +} + +/** + * 按工作空间状态发送对应消息(active 或 hint);两者皆无时不发。 + * 会话启动与仪表盘入口共用。 + */ +export function sendDbStatus(pi: ExtensionAPI, ws: DatabaseWorkspaceService): void { + if (ws.isReady) { + sendActiveDb(pi, ws); + } else if (ws.isConfigured) { + sendConfiguredHint(pi, ws); + } +} + +/** 未配置任何连接:triggerTurn 让 AI 主动提议建立第一个连接。 */ +export function sendUnconfiguredHint(pi: ExtensionAPI, configPath: string): void { + pi.sendMessage( + { + customType: "db-hint", + content: + `No database connections are configured yet. Help the user create their first ` + + `connection in ${configPath}. Ask for host, port, username, password, and default ` + + `database name. After the config file is written, tell the user to run /db switch to connect.`, + display: false, + }, + { deliverAs: "followUp", triggerTurn: true }, + ); +} diff --git a/commands/query.ts b/commands/query.ts index 5b619db..96c12bc 100644 --- a/commands/query.ts +++ b/commands/query.ts @@ -20,7 +20,7 @@ import { READONLY_SQL_RE } from "../connection/sql-policy"; import { formatTableCompact } from "../formatting/result-table"; import { pickTableFuzzy, withLoader } from "./utils"; import type { QueryResultEntryData, RelatedTuiData } from "./renderers"; -import type { RelatedBrowserCache } from "./related-browser"; +import type { RelatedBrowserCacheStore } from "./related-browser"; interface ExecutedResult { columns: string[]; @@ -77,15 +77,6 @@ export function toRelatedTuiData(related: RelatedResult[]): RelatedTuiData[] { })); } -// ── 最近关联查询缓存(/db related 浏览器入口用)── - -let lastRelatedCache: RelatedBrowserCache | null = null; - -/** 最近一次关联查询的结果缓存;无关联查询时返回 null。 */ -export function getLastRelatedCache(): RelatedBrowserCache | null { - return lastRelatedCache; -} - // ── 展示(双受众:TUI 条目 + LLM 上下文)────────── async function displayQueryResult( @@ -94,6 +85,7 @@ async function displayQueryResult( pi: ExtensionAPI, result: ExecutedResult, related: RelatedResult[] = [], + relatedCache?: RelatedBrowserCacheStore, ): Promise { const database = ws.current!.database; try { @@ -106,7 +98,7 @@ async function displayQueryResult( const relatedTui = toRelatedTuiData(related); if (relatedTui.length > 0) { - lastRelatedCache = { database, sql: result.sql, related: relatedTui }; + relatedCache?.set({ database, sql: result.sql, related: relatedTui }); } pi.appendEntry("db-query-result", { @@ -173,6 +165,7 @@ async function queryByTable( ws: DatabaseWorkspaceService, pi: ExtensionAPI, preSelectedTable?: string, + relatedCache?: RelatedBrowserCacheStore, ): Promise { const table = preSelectedTable ?? (await pickTableFuzzy(ctx, ws, "选择数据表")); if (!table) return; @@ -206,7 +199,7 @@ async function queryByTable( ctx.ui.notify(`查询出错:${err.message}`, "error"); return; } - await displayQueryResult(ctx, ws, pi, result, result.related); + await displayQueryResult(ctx, ws, pi, result, result.related, relatedCache); } else { await executeAndDisplay(ctx, ws, pi, sql); } @@ -233,6 +226,7 @@ export async function handleQuery( ws: DatabaseWorkspaceService, pi: ExtensionAPI, tableArg?: string, + relatedCache?: RelatedBrowserCacheStore, ): Promise { if (!ws.isReady) { ctx.ui.notify("未选择数据库,请先执行 /db switch", "warning"); @@ -247,7 +241,7 @@ export async function handleQuery( tables = []; } if (tables.includes(tableArg)) { - return await queryByTable(ctx, ws, pi, tableArg); + return await queryByTable(ctx, ws, pi, tableArg, relatedCache); } if (READONLY_SQL_RE.test(tableArg)) { return await executeAndDisplay(ctx, ws, pi, tableArg); @@ -264,5 +258,5 @@ export async function handleQuery( if (choice === "__sql__") { return await queryRaw(ctx, ws, pi); } - return await queryByTable(ctx, ws, pi, choice); + return await queryByTable(ctx, ws, pi, choice, relatedCache); } diff --git a/commands/related-browser.ts b/commands/related-browser.ts index 2418bf9..f21740a 100644 --- a/commands/related-browser.ts +++ b/commands/related-browser.ts @@ -33,6 +33,22 @@ export interface RelatedBrowserCache { related: RelatedTuiData[]; } +/** + * 最近关联查询缓存的持有者——经调用链注入(db.ts 注册时创建、 + * 传给 query 路径写入、related 路径读取),替代 module 级单例。 + */ +export class RelatedBrowserCacheStore { + private current: RelatedBrowserCache | null = null; + + set(cache: RelatedBrowserCache): void { + this.current = cache; + } + + get(): RelatedBrowserCache | null { + return this.current; + } +} + // ── 表切换 reducer(纯函数,可单测)──────────────── export interface BrowserNav { diff --git a/commands/switch.ts b/commands/switch.ts index 65d4d7e..3686b64 100644 --- a/commands/switch.ts +++ b/commands/switch.ts @@ -5,6 +5,7 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; import type { DatabaseWorkspaceService } from "../state/workspace"; import { withLoader } from "./utils"; +import { sendActiveDb, sendUnconfiguredHint } from "./llm-context"; export const STATUS_KEY = "db-workspace"; @@ -81,14 +82,7 @@ export async function showWorkspacePanel( // display: false 让它不进聊天;triggerTurn 让 AI // 主动提议帮助建立第一个连接。 if (!ws.isConfigured && pi) { - pi.sendMessage( - { - customType: "db-hint", - content: `No database connections are configured yet. Help the user create their first connection in ${ws.configPath}. Ask for host, port, username, password, and default database name. After the config file is written, tell the user to run /db switch to connect.`, - display: false, - }, - { deliverAs: "followUp", triggerTurn: true }, - ); + sendUnconfiguredHint(pi, ws.configPath); } } @@ -180,14 +174,7 @@ export async function handleSwitch( // 告知 LLM 当前激活的数据库,避免它猜测。 // display: false 避免冗余消息污染聊天。 - pi.sendMessage( - { - customType: "db-active-db", - content: `Current database: ${database} (connection: ${connectionId}, environment: ${env}). Use db_query and db_tables to query this database.`, - display: false, - }, - { deliverAs: "followUp", triggerTurn: false }, - ); + sendActiveDb(pi, ws); ctx.ui.notify(`已连接:${env}/${database} @ ${connectionId}`, "info"); } diff --git a/docs/extension-toggle.md b/docs/extension-toggle.md index 779292f..a209195 100644 --- a/docs/extension-toggle.md +++ b/docs/extension-toggle.md @@ -1,6 +1,6 @@ # 扩展开关设计:Extension Toggle -> 状态:设计稿(待评审) +> 状态:已实施(`/db on|off` 位于 `commands/db.ts`) > 目标版本:0.9.0 ## 1. 背景与目标 diff --git a/docs/table-tool-consolidation.md b/docs/table-tool-consolidation.md index 40d1c6a..6ca7ef5 100644 --- a/docs/table-tool-consolidation.md +++ b/docs/table-tool-consolidation.md @@ -1,6 +1,6 @@ # 表工具合并方案:db_list_tables + db_table_schema → db_tables -> 状态:待实施(目标版本 v0.8.0,与 tool-loading 合并发布) +> 状态:已实施(`db_tables` 位于 `tools/db-tools.ts`) > 前置:v0.8.0 Dynamic Tool Loading 已完成(`docs/tool-loading-redesign.md`) ## 1. 背景与目标 diff --git a/index.ts b/index.ts index 7fac5b5..ceac39c 100644 --- a/index.ts +++ b/index.ts @@ -7,6 +7,7 @@ import { readToggle } from "./state/extension-toggle"; import { registerDbCommand, restoreStatusBar } from "./commands/db"; import { registerRenderers } from "./commands/renderers"; import { registerDbTools, applyInitialToolSet } from "./tools/db-tools"; +import { sendDbStatus } from "./commands/llm-context"; const baseDir = dirname(fileURLToPath(import.meta.url)); @@ -57,27 +58,7 @@ export default function (pi: ExtensionAPI) { restoreStatusBar(ws, ctx); // 恢复会话时告知 LLM 当前激活的数据库, // 避免它通过一次失败的调用去发现。 - if (ws.isReady) { - const db = ws.current!; - pi.sendMessage( - { - customType: "db-active-db", - content: `Current database: ${db.database} (connection: ${db.connectionId}, environment: ${db.environment}). Config file: ${ws.configPath}.`, - display: false, - }, - { deliverAs: "followUp", triggerTurn: false }, - ); - } else if (ws.isConfigured) { - // 已配置但未切换——让 AI 知道,以便协助用户选择数据库。 - pi.sendMessage( - { - customType: "db-hint", - content: `Database connections are configured but no database is selected. Tell the user to run /db switch to connect. Config file: ${ws.configPath}.`, - display: false, - }, - { deliverAs: "followUp", triggerTurn: false }, - ); - } + sendDbStatus(pi, ws); }); // 关闭时清理 diff --git a/scripts/repro-autocomplete.mjs b/scripts/repro-autocomplete.mjs deleted file mode 100644 index eaae4f5..0000000 --- a/scripts/repro-autocomplete.mjs +++ /dev/null @@ -1,81 +0,0 @@ -/** - * 临时复现脚本:模拟 pi 编辑器对 /db 命令参数补全的完整链路。 - * 用 pi-tui 真实的 CombinedAutocompleteProvider + db.ts 的补全逻辑。 - * 用法:node scripts/repro-autocomplete.mjs - */ -import { CombinedAutocompleteProvider } from "@earendil-works/pi-tui"; - -// 从 commands/db.ts 复制 getCompletions 核心逻辑(保持逐行一致) -const SUBCOMMANDS = [ - "switch", - "add", - "tables", - "schema", - "query", - "history", - "favorite", - "relations", - "on", - "off", -]; - -function getCompletions(prefix) { - const parts = prefix.trim().split(/\s+/); - const hasTrailingSpace = prefix.endsWith(" "); - const sub = parts[0]; - const partial = hasTrailingSpace ? "" : (parts[1] ?? ""); - - const subSubs = { - favorite: ["add"], - relations: ["add", "remove", "discover", "er-diagram"], - }; - - if (parts.length === 1 && SUBCOMMANDS.includes(sub) && sub in subSubs) { - return subSubs[sub] - .filter((s) => s.startsWith(partial)) - .map((s) => ({ value: `${sub} ${s} `, label: s })); - } - - if (parts.length === 1 && !hasTrailingSpace) { - return SUBCOMMANDS.filter((s) => s.startsWith(sub)).map((s) => ({ - value: s + " ", - label: s, - })); - } - - if (subSubs[sub] && parts.length === 2 && !hasTrailingSpace) { - const filtered = subSubs[sub].filter((s) => s.startsWith(partial)); - if (filtered.length > 0) { - return filtered.map((s) => ({ value: `${sub} ${s} `, label: s })); - } - } - - return null; -} - -const provider = new CombinedAutocompleteProvider( - [{ name: "db", getArgumentCompletions: getCompletions }], - "/tmp", - null, -); - -const cases = [ - ["/db ", 4, false, "自然输入(空格后)"], - ["/db o", 5, false, "自然输入 o"], - ["/db on", 6, false, "自然输入 on"], - ["/db s", 5, false, "自然输入 s(对照)"], - ["/db switch", 9, false, "自然输入 switch(对照)"], - ["/db o", 5, true, "Tab 强制(force)"], - ["/db on", 6, true, "Tab 强制 on(force)"], -]; - -for (const [text, col, force, label] of cases) { - const res = await provider.getSuggestions([text], 0, col, { - signal: new AbortController().signal, - force, - }); - console.log( - `${label.padEnd(22)} "${text}" force=${force} →`, - res ? JSON.stringify(res.items.map((i) => i.value)) : "null(无候选)", - ); -} diff --git a/tools/db-tools.ts b/tools/db-tools.ts index 949a27d..c1a3e98 100644 --- a/tools/db-tools.ts +++ b/tools/db-tools.ts @@ -28,13 +28,13 @@ import { showMutationConfirm } from "../commands/mutate-confirm"; import { LOADER_TOOL_NAME, LAZY_TOOL_INFO, matchDbTools } from "./db-tool-catalog"; export { applyInitialToolSet } from "./db-tool-catalog"; -function truncate(text: string): string { +function truncate(text: string, hint = "Narrow the query or add a LIMIT."): string { const t = truncateHead(text, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES }); if (!t.truncated) return t.content; return ( t.content + `\n\n[Output truncated: ${t.outputLines} of ${t.totalLines} lines` + - ` (${formatSize(t.outputBytes)} of ${formatSize(t.totalBytes)}). Narrow the query or add a LIMIT.]` + ` (${formatSize(t.outputBytes)} of ${formatSize(t.totalBytes)}). ${hint}]` ); } @@ -58,21 +58,6 @@ export function registerDbTools( pi: ExtensionAPI, getWorkspace: () => DatabaseWorkspaceService, ): void { - /** - * 当未选择数据库且调用方未传显式目标时抛错(→ isError 工具结果), - * 除非显式目标本身不需要工作空间选择即可工作。 - */ - const ready = (explicitTargetOk = false): DatabaseWorkspaceService => { - const ws = getWorkspace(); - if (!ws.isReady && !explicitTargetOk) { - throw new Error( - "No database selected. Ask the user to run /db switch first, " + - "or pass connection + database explicitly.", - ); - } - return ws; - }; - // ── Loader:按需启用懒加载工具 ──────────────────────────── pi.registerTool({ name: LOADER_TOOL_NAME, @@ -153,7 +138,7 @@ export function registerDbTools( ...targetParams, }), async execute(_toolCallId, params) { - const ws = ready(!!(params.connection && params.database)); + const ws = getWorkspace(); const result = await ws.executeQuery(params.sql, { connectionId: params.connection, database: params.database, @@ -206,7 +191,7 @@ export function registerDbTools( ), }), async execute(_toolCallId, params) { - const ws = ready(!!params.connection); + const ws = getWorkspace(); const conns = ws.listConnections(); const lines = [ `Connections (${conns.length}):`, @@ -247,7 +232,7 @@ export function registerDbTools( ...targetParams, }), async execute(_toolCallId, params) { - const ws = ready(!!(params.connection && params.database)); + const ws = getWorkspace(); const target = ws.resolveTarget({ connectionId: params.connection, database: params.database, @@ -266,7 +251,10 @@ export function registerDbTools( content: [ { type: "text", - text: truncate(formatSchemaMarkdown(params.table, target.database, columns, indexes)), + text: truncate( + formatSchemaMarkdown(params.table, target.database, columns, indexes), + "Table has too many columns to display. Use db_query to select specific columns.", + ), }, ], details, @@ -274,11 +262,15 @@ export function registerDbTools( } const tables = await ws.getTables(target); details.tables = tables; + const tableList = `Tables in ${target.connectionId}/${target.database} (${tables.length}):\n${tables.join("\n")}`; return { content: [ { type: "text", - text: `Tables in ${target.connectionId}/${target.database} (${tables.length}):\n${tables.join("\n")}`, + text: truncate( + tableList, + "Too many tables to display. Inspect a specific table with table= instead.", + ), }, ], details, @@ -302,7 +294,7 @@ export function registerDbTools( ), }), async execute(_toolCallId, params) { - const ws = ready(!!params.database); + const ws = getWorkspace(); const rows = ws.listRelations(params.table, params.database); const lines = rows.map( (r) => @@ -359,7 +351,7 @@ export function registerDbTools( } // 2. 解析目标 - const ws = ready(!!(params.connection && params.database)); + const ws = getWorkspace(); const target = ws.resolveTarget({ connectionId: params.connection, database: params.database, @@ -458,8 +450,13 @@ export function registerDbTools( ), }), async execute(_toolCallId, params, _signal, _onUpdate) { - const ws = ready(!!params.database); - const schema = params.database ?? ws.current!.database; + const ws = getWorkspace(); + const schema = params.database ?? ws.current?.database; + if (!schema) { + throw new Error( + "No database selected. Run /db switch first, or pass connection + database explicitly.", + ); + } if (params.action === "register") { const row = ws.upsertRelation(