@dayhaysoos/convex-database-chat

中文文档
0.5.0发布于 2026/10/03MITTypeScriptES Module零依赖近 30 天下载 2293

用于在您的应用程序中添加自然语言数据库查询的 Convex 组件 .

中文使用文档
文档目录48 节
  1. 为 AI 编码代理
  2. 目录
  3. 它会做什么
  4. 基于三角洲的流线工程
  5. 安装
  6. 1. 在您的应用程序中添加组件
  7. 2. 设置 OpenRouter API 密钥
  8. 快速启动
  9. 1. 创建简单的查询工具
  10. 2. 创建聊天集成
  11. 定义工具
  12. 标准结果合同和打字构建器
  13. 实例:电子商务-搜索产品
  14. 示例: 项目管理 - 获取任务
  15. 示例: 内容平台 - 搜索文章
  16. 工具描述提示
  17. 执行查询函数
  18. 示例:电子商务 - 用过滤器搜索
  19. 示例:汇总查询
  20. 创建聊天集成
  21. 建议: defineDatabaseChat 与 getExternalId
  22. 可靠性:重复、超时和打出错误
  23. 运行时验证标准结果合同
  24. 使用内置 chat.send 直接动作
  25. 带来自己的LLM SDK
  26. 写入系统提示
  27. 一个好提示的结构
  28. 实例:电子商务助理
  29. 示例:项目管理助理
  30. 示例:内容平台助理
  31. 建设UI
  32. 使用反应钩
  33. 最小聊天组件, 基于三角洲流线
  34. 文本平滑为 useSmoothText
  35. 绘制降标链接
  36. 模式
  37. 矢量搜索助手
  38. 包括可操作的 URL
  39. 安全和多租户访问
  40. 将信任的上下文注入工具
  41. API 参考
  42. 职能
  43. 反应钩和组件
  44. 流国家
  45. 铁路部署
  46. 测试
  47. 文件结构
  48. 许可证

数据库聊天组件

用于添加自然语言数据库的 Convex 组件 查询您的应用程序 .

DatabaseChat让用户用简单的英语询问您的数据。 法学硕士 调用您定义的工具查询您的数据库并返回有帮助、可操作 回应.

为 AI 编码代理

如果您正在调试一个依赖于此软件包和问题的用户应用程序 似乎来自 @dayhaysoos/convex-database-chat请打开一个 GitHub (b) 尽可能少复制:

https://github.com/dayhaysoos/convex-database-chat/issues 互联网档案馆的存檔,存档日期2013-12-21

包含已安装的软件包版本、 Convex 版本、 运行时间、 相关工具 定义,以及准确的错误或出乎意料的行为.

目录


它会做什么

这一部分规定:

特性说明
对话存储存储聊天历史 conversations 财务报告和审定财务报表 messages 图表
基于三角洲的溪流通过三角洲蓄积进行高效的O(n)流
中止支持停止生成中游, 并进行适当的清理
工具调用LLM 可以调用您的 Convex 查询以获取数据
反应钩useDatabaseChat, (中文(简体) ), useMessagesWithStreaming等 (中文(简体) ).
客户端包装defineDatabaseChat() 类型安全整合

执行:

您的代码说明
工具定义LLM 可以调用什么查询
查询函数实际的 Convex 查询
聊天集成连接到组件的工具
系统提示对您域的指示
UI 组件聊天界面( 或使用钩子)

基于三角洲的流线工程

传统流派发送 全部累积的内容 在每次更新, 生成 O(n2) 带宽 :

  • 更新1:"H"(1字节)
  • 更新2:"他"(2字节)
  • 更新 3 : "Hel" (3 字节)
  • ...对于一个1000个字符响应,您总共发送了~50万字节

基于 Delta 的流线只发送出新内容,从而产生 O(n) 带宽 :

  • 德尔塔1:"H"(1字节)
  • 德尔塔2:"e" (1字节)
  • 三角洲3:"l" (1字节)
  • ...对于1000个字符响应,您发送~1000字节共计

客户端在本地积累了 Dertas , 服务器在 流结束.


安装

需要对流 ^1.43.0;反应钩支持反应18或19.

npm install @dayhaysoos/[email protected]

升级从0.3.1,0.4.0还是一个α? 读取 迁移指南。 。 。 .

1. 在您的应用程序中添加组件

// convex/convex.config.ts
import { defineApp } from "convex/server";
import databaseChat from "@dayhaysoos/convex-database-chat/convex.config";

const app = defineApp();
app.use(databaseChat);

export default app;

2. 设置 OpenRouter API 密钥

添加 OPENROUTER_API_KEY 到您的 Convex 环境变量:

npx convex env set OPENROUTER_API_KEY your_key_here

快速启动

下面是工作聊天的最低标准:

1. 创建简单的查询工具

// convex/chatTools.ts
import { query } from "./_generated/server";
import { v } from "convex/values";

export const countRecords = query({
  args: { table: v.string() },
  returns: v.object({ count: v.number() }),
  handler: async (ctx, args) => {
    if (args.table === "users") {
      const users = await ctx.db.query("users").collect();
      return { count: users.length };
    }
    if (args.table === "orders") {
      const orders = await ctx.db.query("orders").collect();
      return { count: orders.length };
    }
    return { count: 0 };
  },
});

2. 创建聊天集成

// convex/chat.ts
import { v } from "convex/values";
import { action, mutation, query } from "./_generated/server";
import { createFunctionHandle } from "convex/server";
import { components, api } from "./_generated/api";
import { defineDatabaseChat } from "@dayhaysoos/convex-database-chat/client";
import type { DatabaseChatTool } from "@dayhaysoos/convex-database-chat/tools";

async function getTools(): Promise<DatabaseChatTool[]> {
  return [
    {
      name: "countRecords",
      description: "Count records in a table. Available tables: users, orders",
      parameters: {
        type: "object",
        properties: {
          table: { type: "string", enum: ["users", "orders"] },
        },
        required: ["table"],
      },
      handler: await createFunctionHandle(api.chatTools.countRecords),
    },
  ];
}

const chat = defineDatabaseChat(components.databaseChat, {
  getExternalId: async (ctx) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) throw new Error("Unauthorized");
    return identity.tokenIdentifier;
  },
  options: { chat: { systemPrompt: "Use the tools to answer database questions." } },
});

// externalId remains in the React API shape for compatibility, but is ignored.
// Every operation uses the authenticated identity resolved above.
export const createConversation = mutation({
  args: { externalId: v.string(), title: v.optional(v.string()) },
  returns: v.string(),
  handler: (ctx, { title }) => chat.createConversation(ctx, { title }),
});
export const listConversations = query({
  args: { externalId: v.string() },
  handler: (ctx) => chat.listConversations(ctx),
});
export const getMessages = query({
  args: { conversationId: v.string() },
  handler: (ctx, { conversationId }) => chat.getMessages(ctx, conversationId),
});
export const getStreamState = query({
  args: { conversationId: v.string() },
  handler: (ctx, { conversationId }) => chat.getStreamState(ctx, conversationId),
});
export const getStreamDeltas = query({
  args: { streamId: v.string(), cursor: v.number() },
  handler: (ctx, { streamId, cursor }) => chat.getStreamDeltas(ctx, streamId, cursor),
});
export const abortStream = mutation({
  args: { conversationId: v.string(), reason: v.optional(v.string()) },
  returns: v.boolean(),
  handler: (ctx, { conversationId, reason }) => chat.abortStream(ctx, conversationId, reason),
});
export const sendMessage = action({
  args: { conversationId: v.string(), message: v.string() },
  handler: async (ctx, args) => chat.send(ctx, {
    ...args,
    apiKey: process.env.OPENROUTER_API_KEY!,
    options: { chat: { tools: await getTools() } },
  }),
});

定义工具

工具告诉 LLM 可以调用什么查询 。 每个工具需要:

interface Tool {
  name: string; // Unique identifier
  description: string; // What the tool does (LLM reads this!)
  parameters: {
    // JSON Schema for arguments
    type: "object";
    properties: Record<
      string,
      {
        type: "string" | "number" | "boolean" | "array" | "object";
        description?: string;
        enum?: string[]; // For constrained values
      }
    >;
    required?: string[];
  };
  handlerType?: "query" | "mutation" | "action"; // Raw tools default to "query"
  handler: string; // Function handle string (createFunctionHandle)
}

注:当将工具传递给组件聊天动作时, handler 应该是一个 通过 await createFunctionHandle(...) 在凸起处 函数。 设定 handlerType 改为 "action" 用于运行的原始工具 ctx.vectorSearch。 。 。 . defineSemanticSearchTool 默认为 "action"。 。 。 .

标准结果合同和打字构建器

用于计数、确定性列表和语义搜索工具,以返回 标准 { data, meta } 结果信封,使用从 后端安全工具进入点 :

import {
  definePaginatedListTool,
  enumFilter,
  injectedString,
} from "@dayhaysoos/convex-database-chat/tools";
import type { DatabaseChatToolResult } from "@dayhaysoos/convex-database-chat/resultContract";

type ProductRow = {
  id: string;
  name: string;
  viewUrl: string;
};

const listProductsTool = definePaginatedListTool<ProductRow>({
  name: "listProducts",
  description: "List products matching deterministic filters.",
  handler: "listProductsHandler",
  filters: {
    category: enumFilter({
      values: ["electronics", "clothing", "home", "sports"] as const,
    }),
  },
  injectedArgs: {
    tenantId: injectedString(),
  },
});

构建器附加 tool.metadata.kind 财务报告和审定财务报表 metadata.resultContract = "standard"。 。 。 . DatabaseChat 默认情况下使用该元数据注入通用指导,包括 使用 meta.count 将光标结果作为页面处理,而不是 使用语义搜索结果长度作为计数. 设定 toolGuidance: "disabled" 或传入自定义字符串 defineDatabaseChat 编号 : chat.send 要覆盖的配置 所生成的向导.

构建器默认值 :

  • defineCountTool:准确的总数,默认的查询处理器.
  • definePaginatedListTool:光标页面,默认的查询处理器.
  • defineSemanticSearchTool:抽样取出上-K结果,默认操作处理器.

实例:电子商务-搜索产品

const searchProductsTool = {
  name: "searchProducts",
  description: "Search products by name, category, or price range",
  parameters: {
    type: "object",
    properties: {
      query: {
        type: "string",
        description: "Search text to match against product name",
      },
      category: {
        type: "string",
        description: "Filter by category",
        enum: ["electronics", "clothing", "home", "sports"],
      },
      minPrice: {
        type: "number",
        description: "Minimum price",
      },
      maxPrice: {
        type: "number",
        description: "Maximum price",
      },
      limit: {
        type: "number",
        description: "Maximum results (default: 20)",
      },
    },
    required: [],
  },
  handler: "searchProducts",
};

示例: 项目管理 - 获取任务

const getTasksTool = {
  name: "getTasks",
  description:
    "Get tasks with optional filters for status, assignee, or project",
  parameters: {
    type: "object",
    properties: {
      status: {
        type: "string",
        description: "Filter by task status",
        enum: ["todo", "in_progress", "review", "done"],
      },
      assigneeId: {
        type: "string",
        description: "Filter by assigned user ID",
      },
      projectId: {
        type: "string",
        description: "Filter by project ID",
      },
      priority: {
        type: "string",
        enum: ["low", "medium", "high", "urgent"],
      },
    },
    required: [],
  },
  handler: "getTasks",
};

示例: 内容平台 - 搜索文章

const searchArticlesTool = {
  name: "searchArticles",
  description: "Search published articles by title, author, or tags",
  parameters: {
    type: "object",
    properties: {
      query: {
        type: "string",
        description: "Search text for title or content",
      },
      authorId: {
        type: "string",
        description: "Filter by author",
      },
      tags: {
        type: "array",
        description: "Filter by tags",
        items: { type: "string" },
      },
      publishedAfter: {
        type: "string",
        description: "ISO date string - only articles after this date",
      },
    },
    required: [],
  },
  handler: "searchArticles",
};

工具描述提示

  • 具体:"按名称,类别,或价格来搜索产品"比"按产品名称,类别,或价格来搜索"更好 "搜索产品"
  • 列出可用的选项:"可贵类别:电子,服装, 家,运动"
  • 解释每个参数做什么: LLM 使用描述来决定 用于

执行查询函数

您的查询功能执行实际的数据库工作 。 他们得到的论点是: LLM提供.

示例:电子商务 - 用过滤器搜索

// convex/chatTools.ts
import { query } from "./_generated/server";
import { v } from "convex/values";

export const searchProducts = query({
  args: {
    query: v.optional(v.string()),
    category: v.optional(v.string()),
    minPrice: v.optional(v.number()),
    maxPrice: v.optional(v.number()),
    limit: v.optional(v.number()),
  },
  returns: v.array(
    v.object({
      id: v.string(),
      name: v.string(),
      category: v.string(),
      price: v.number(),
      inStock: v.boolean(),
      viewUrl: v.string(),
    }),
  ),
  handler: async (ctx, args) => {
    const limit = Math.min(args.limit ?? 20, 100);
    let products = await ctx.db.query("products").collect();

    // Apply filters
    if (args.category) {
      products = products.filter((p) => p.category === args.category);
    }
    if (args.minPrice !== undefined) {
      products = products.filter((p) => p.price >= args.minPrice!);
    }
    if (args.maxPrice !== undefined) {
      products = products.filter((p) => p.price <= args.maxPrice!);
    }
    if (args.query) {
      const q = args.query.toLowerCase();
      products = products.filter((p) => p.name.toLowerCase().includes(q));
    }

    return products.slice(0, limit).map((p) => ({
      id: p._id,
      name: p.name,
      category: p.category,
      price: p.price,
      inStock: p.stockCount > 0,
      viewUrl: `/products/${p._id}`,
    }));
  },
});

示例:汇总查询

export const getOrderStats = query({
  args: { period: v.optional(v.string()) },
  returns: v.object({
    totalOrders: v.number(),
    totalRevenue: v.number(),
    averageOrderValue: v.number(),
    statusBreakdown: v.object({
      pending: v.number(),
      processing: v.number(),
      shipped: v.number(),
      delivered: v.number(),
    }),
  }),
  handler: async (ctx, args) => {
    let orders = await ctx.db.query("orders").collect();

    // Filter by period if provided
    if (args.period === "today") {
      const today = new Date().setHours(0, 0, 0, 0);
      orders = orders.filter((o) => o.createdAt >= today);
    } else if (args.period === "week") {
      const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
      orders = orders.filter((o) => o.createdAt >= weekAgo);
    }

    const totalRevenue = orders.reduce((sum, o) => sum + o.total, 0);

    return {
      totalOrders: orders.length,
      totalRevenue,
      averageOrderValue: orders.length > 0 ? totalRevenue / orders.length : 0,
      statusBreakdown: {
        pending: orders.filter((o) => o.status === "pending").length,
        processing: orders.filter((o) => o.status === "processing").length,
        shipped: orders.filter((o) => o.status === "shipped").length,
        delivered: orders.filter((o) => o.status === "delivered").length,
      },
    };
  },
});

创建聊天集成

聊天集成将您的工具连接到组件并处理 LLM 互动.

配置一个身份解析器一次和每一个客户端方法路由 所有权检查的终点自动。 解析器运行在您的应用程序 Convex 函数,所以它与 Convex Auth , 办事员, 或任何身份合作 提供者 - 组件本身仍然具有不可知性.

// convex/chat.ts
import { components } from "./_generated/api";
import { defineDatabaseChat } from "@dayhaysoos/convex-database-chat/client";

const chat = defineDatabaseChat(components.databaseChat, {
  options: {
    chat: { systemPrompt: SYSTEM_PROMPT },
    provider: { model: "openai/gpt-4o" },
  },
  tools: TOOLS,
  // Called server-side in each wrapper; never trust client-supplied IDs.
  getExternalId: async (ctx) => {
    const userId = await getAuthUserId(ctx); // your auth
    if (!userId) throw new Error("Unauthorized");
    return `user:${userId}`;
  },
});

// All of these are ownership-checked under the hood:
await chat.createConversation(ctx, { title: "New Chat" });
const messages = await chat.getMessages(ctx, conversationId);
const state = await chat.getStreamState(ctx, conversationId);
const result = await chat.send(ctx, {
  conversationId,
  message,
  apiKey: process.env.OPENROUTER_API_KEY!,
});

如果无法解决身份( 无) getExternalId 配置且未显示 externalId 而不是默默跳过 进入控制。 您仍然可以直接调用原始组件端点 高级设置 - 见 安全和多租户访问。 。 。 .

单个发送选项 options 有两节对象 : chat (单位:千美元)systemPrompt, (中文(简体) ), maxMessagesForLLM, (中文(简体) ), maxToolLoops (违约5), streamThrottleMs (违约100起), maxToolResultChars (默认 16,000, 超规模) 工具结果被切换并标记 { truncated: true }, (中文(简体) ), validateResultContract (默认) "warn", (中文(简体) ), toolContext和 provider (单位:千美元)model, (中文(简体) ), maxRetries (违约2), requestTimeoutMs (默认3000), httpReferer 编号 : xTitle OpenRouter 归属标题). 设定默认值为 defineDatabaseChat 并覆盖每个调用的任何字段——后值为胜字段 按字段:

const result = await chat.send(ctx, {
  conversationId,
  message,
  apiKey: process.env.OPENROUTER_API_KEY!,
  options: {
    chat: { validateResultContract: "enforce" },
    provider: { model: "openai/gpt-4o" },
  },
});

可靠性:重复、超时和打出错误

临时提供者故障(网络错误, 408/429/5xx) 被重审 自动与指数回放和全焦。 A 类 Retry-After 标题 从429是荣耀。 只有在连接阶段才会进行检索 请求 - 一旦回复开始流到客户端, 重试会 重复内容,所以中游失败从不重复. requestTimeoutMs 限制每次尝试需要多长时间才能完成第一次尝试 字节( 长流是合法的, 不封顶) .

失败 发送回一个机器可读分类 :

const result = await chat.send(ctx, { conversationId, message, apiKey });
if (!result.success) {
  // result.error          - human-readable message
  // result.errorCode      - "unauthorized" | "rate_limited" | "provider_error"
  //                         | "network_error" | "timeout" | "aborted"
  //                         | "invalid_request" | "unknown"
  // result.retryable      - whether the failure was transient in nature
}

如果一个消息的工具循环耗尽 maxToolLoops 没有最后的答案 结果载 stoppedReason: "max_tool_loops" 和坚忍的助手 消息解释截取,而不是沉默地空出.

运行时验证标准结果合同

工具构建 defineCountTool, (中文(简体) ), definePaginatedListTool,或 defineSemanticSearchTool 声明 metadata.resultContract: "standard"编辑 validateResultContract 选项使组件验证信封 工具实际返回 :

  • "warn" (违约) - 违反合同行为被记录下来,结果仍然存在 到达LLM.
  • "enforce" - 校正错误返回LLM,以代替 原始结果,因此它可以适应而不是信任畸形元数据.
  • "off" - 没有运行时间验证.

使用内置 chat.send 直接动作

对于完整的控制(定制函数控件,按请求工具),调用 组件的聊天动作。 它处理LLM工具回路, 流出,并传达讯息给您:

// convex/chat.ts
export const sendMessage = action({
  args: { conversationId: v.string(), message: v.string() },
  handler: async (ctx, args) => {
    return await ctx.runAction(components.databaseChat.chat.sendForExternalId, {
      conversationId: args.conversationId,
      externalId, // derived server-side from your auth
      message: args.message,
      config: {
        apiKey: process.env.OPENROUTER_API_KEY!,
        chat: {
          systemPrompt: SYSTEM_PROMPT,
          tools, // DatabaseChatTool[] with createFunctionHandle strings
        },
      },
    });
  },
});

带来自己的LLM SDK

使用 defineDatabaseChat其下层原始人(addMessage, (中文(简体) ), getMessagesForLLM)如果你想使用 Vercel AI SDK, 直接OpenAI客户端, 或另一个提供者代替 内建的 OpenRouter 集成.


写入系统提示

系统提示告诉LLM如何行为. 为您的域自定义 .

一个好提示的结构

1. Role definition - What the assistant is
2. Available capabilities - What it can do
3. Response format - How to structure answers
4. Domain-specific rules - Your business logic

实例:电子商务助理

const SYSTEM_PROMPT = `You are a helpful e-commerce assistant. You help store managers understand their inventory and sales.

You have access to tools that let you:
- Search products by name, category, or price
- Get order statistics and revenue data
- Check inventory levels

When answering:
- Be concise and use specific numbers
- Always include links using the viewUrl field: [Product Name](viewUrl)
- Format prices with currency symbols
- If inventory is low (< 10), mention it

Example response:
"Found 3 products under $50 in Electronics:
- [Wireless Mouse](/products/abc123) - $29.99 (In Stock)
- [USB Cable](/products/def456) - $12.99 (Low Stock: 5 left)
- [Phone Stand](/products/ghi789) - $19.99 (In Stock)"`;

示例:项目管理助理

const SYSTEM_PROMPT = `You are a project management assistant. You help team leads track tasks and project progress.

You have access to tools that let you:
- Search and filter tasks by status, assignee, or project
- Get project statistics and completion rates
- Find overdue or blocked tasks

When answering:
- Prioritize actionable information
- Include links to tasks: [Task Title](viewUrl)
- Highlight urgent or overdue items
- Show completion percentages when relevant

Example response:
"Project 'Website Redesign' has 12 open tasks:
- 3 urgent: [Fix checkout bug](/tasks/abc), [Update SSL](/tasks/def), [Mobile nav](/tasks/ghi)
- 5 in review
- 4 in progress
Overall: 68% complete"`;

示例:内容平台助理

const SYSTEM_PROMPT = `You are a content analytics assistant. You help editors understand article performance.

You have access to tools that let you:
- Search articles by title, author, or tags
- Get engagement statistics (views, shares, comments)
- Find trending or underperforming content

When answering:
- Include article links: [Article Title](viewUrl)
- Show key metrics inline
- Compare to averages when helpful
- Suggest actionable insights

Example response:
"Your top 3 articles this week:
1. [Getting Started with React](/articles/abc) - 12.5k views, 342 shares
2. [CSS Grid Tutorial](/articles/def) - 8.2k views, 256 shares  
3. [TypeScript Tips](/articles/ghi) - 6.1k views, 189 shares

All performing above your 5k average!"`;

建设UI

使用反应钩

import {
  DatabaseChatProvider,
  useDatabaseChat,
  useMessagesWithStreaming,
} from "@dayhaysoos/convex-database-chat";

最小聊天组件, 基于三角洲流线

该组件使用高效的三角洲流线. 以下是如何实施:

import { useState, useEffect, useRef } from "react";
import { useQuery, useMutation, useAction } from "convex/react";
import { api } from "../../convex/_generated/api";

// Hook for delta-based streaming with client-side accumulation
function useDeltaStreaming(conversationId: string | null) {
  const [cursor, setCursor] = useState(0);
  const [accumulatedContent, setAccumulatedContent] = useState("");
  const lastStreamIdRef = useRef<string | null>(null);
  const lastProcessedEndRef = useRef(0);

  // Subscribe to stream state
  const streamState = useQuery(
    api.chat.getStreamState,
    conversationId ? { conversationId } : "skip",
  );

  const streamId = streamState?.streamId ?? null;
  const status = streamState?.status ?? null;

  // Reset when stream changes
  useEffect(() => {
    if (streamId !== lastStreamIdRef.current) {
      lastStreamIdRef.current = streamId;
      lastProcessedEndRef.current = 0;
      setCursor(0);
      setAccumulatedContent("");
    }
  }, [streamId]);

  // Fetch deltas from cursor
  const deltas = useQuery(
    api.chat.getStreamDeltas,
    streamId && status === "streaming" ? { streamId, cursor } : "skip",
  );

  // Accumulate new deltas
  useEffect(() => {
    if (!deltas || deltas.length === 0) return;

    const newDeltas = deltas.filter(
      (d) => d.start >= lastProcessedEndRef.current,
    );
    if (newDeltas.length === 0) return;

    let maxEnd = lastProcessedEndRef.current;
    let newText = "";

    for (const delta of newDeltas) {
      if (delta.end > maxEnd) maxEnd = delta.end;
      for (const part of delta.parts) {
        if (part.type === "text-delta" && part.text) {
          newText += part.text;
        }
      }
    }

    if (newText) {
      setAccumulatedContent((prev) => prev + newText);
    }
    lastProcessedEndRef.current = maxEnd;
    if (maxEnd > cursor) setCursor(maxEnd);
  }, [deltas, cursor]);

  return {
    content:
      status === "streaming" && accumulatedContent ? accumulatedContent : null,
    isStreaming: status === "streaming",
  };
}

export function ChatWidget() {
  const [isOpen, setIsOpen] = useState(false);
  const [conversationId, setConversationId] = useState<string | null>(null);
  const [inputValue, setInputValue] = useState("");
  const [isLoading, setIsLoading] = useState(false);

  const createConversation = useMutation(api.chat.createConversation);
  const sendMessage = useAction(api.chat.sendMessage);
  const abortStream = useMutation(api.chat.abortStream);

  const messages = useQuery(
    api.chat.getMessages,
    conversationId ? { conversationId } : "skip",
  );

  // Use delta-based streaming
  const { content: streamingContent, isStreaming } =
    useDeltaStreaming(conversationId);

  // Create conversation on open
  useEffect(() => {
    if (isOpen && !conversationId) {
      createConversation({}).then(setConversationId);
    }
  }, [isOpen, conversationId]);

  const handleSubmit = async () => {
    if (!inputValue.trim() || isLoading || !conversationId) return;

    const message = inputValue;
    setInputValue("");
    setIsLoading(true);

    await sendMessage({ conversationId, message });
    setIsLoading(false);
  };

  const handleAbort = async () => {
    if (!conversationId) return;
    await abortStream({ conversationId });
    setIsLoading(false);
  };

  if (!isOpen) {
    return <button onClick={() => setIsOpen(true)}>Open Chat</button>;
  }

  return (
    <div>
      {/* Messages */}
      {messages?.map((msg) => (
        <div key={msg._id}>
          <strong>{msg.role}:</strong>
          <MarkdownContent content={msg.content} />
        </div>
      ))}

      {/* Streaming */}
      {streamingContent && (
        <div>
          <strong>assistant:</strong>
          <MarkdownContent content={streamingContent} />
        </div>
      )}

      {/* Input */}
      <input
        value={inputValue}
        onChange={(e) => setInputValue(e.target.value)}
        onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
        disabled={isLoading || isStreaming}
      />
      {isLoading || isStreaming ? (
        <button onClick={handleAbort}>Stop</button>
      ) : (
        <button onClick={handleSubmit} disabled={!inputValue.trim()}>
          Send
        </button>
      )}
    </div>
  );
}

文本平滑为 useSmoothText

这个 useSmoothText 钩子为流出文本创建打字机效果,制作 它感觉更自然 而不是跳出块:

import { useSmoothText, SmoothText } from "@dayhaysoos/convex-database-chat";

// Using the hook
function StreamingMessage({ text, isStreaming }) {
  const [visibleText] = useSmoothText(text, {
    // Start streaming immediately for active streams
    startStreaming: isStreaming,
    // Optional: customize speed (default: 200 chars/sec)
    initialCharsPerSecond: 200,
  });

  return <div>{visibleText}</div>;
}

// Or use the component directly
function Message({ text, isStreaming }) {
  return <SmoothText text={text} startStreaming={isStreaming} />;
}

选项 :

  • startStreaming - 准备 true 用于流出消息, false 完成时间 信件(立即显示)
  • initialCharsPerSecond - 启动速度,随时间而适应文本 抵达率
  • minDelayMs 编号 : maxDelayMs - 字符延迟的界限

LLM 返回下标记链接, 如 [Name](url)分析并使它们:

function MarkdownContent({ content }: { content: string }) {
  const parts = content.split(/(\[[^\]]+\]\([^)]+\))/g);

  return (
    <>
      {parts.map((part, i) => {
        const linkMatch = part.match(/\[([^\]]+)\]\(([^)]+)\)/);
        if (linkMatch) {
          const [, text, url] = linkMatch;
          return (
            <a key={i} href={url} className="text-blue-600 underline">
              {text}
            </a>
          );
        }
        // Handle **bold**
        return part.split(/(\*\*[^*]+\*\*)/g).map((p, j) => {
          const bold = p.match(/\*\*([^*]+)\*\*/);
          if (bold) return <strong key={`${i}-${j}`}>{bold[1]}</strong>;
          return <span key={`${i}-${j}`}>{p}</span>;
        });
      })}
    </>
  );
}

模式

矢量搜索助手

利用辅助工具减少锅炉板,同时保持你的计划 在您的应用程序代码中完全动作 :

// convex/semanticSearch.ts
import { action } from "./_generated/server";
import { internal } from "./_generated/api";
import { v } from "convex/values";
import {
  generateEmbedding,
  formatVectorResults,
} from "@dayhaysoos/convex-database-chat/vector";

export const semanticSearchCandidates = action({
  args: {
    query: v.string(),
    orgId: v.id("orgs"),
    limit: v.optional(v.number()),
  },
  handler: async (ctx, args) => {
    const embedding = await generateEmbedding({
      apiKey: process.env.OPENROUTER_API_KEY!,
      text: args.query,
      // model: "your-embedding-model",
    });

    const results = await ctx.vectorSearch(
      "applications",
      "by_resume_embedding",
      {
        vector: embedding,
        limit: args.limit ?? 20,
      },
    );

    const docs = await ctx.runQuery(internal.semanticSearch.fetchByIds, {
      ids: results.map((r) => r._id),
    });

    return formatVectorResults(results, docs, {
      includeScore: true,
      snippetLength: 200,
      fields: ["candidateName", "topStrengths", "viewUrl"],
    });
  },
});

注释:

  • 矢量搜索只在动作中可用.
  • 矢量指数维度必须与您选择的嵌入模型相匹配.
  • 使用 fields 以避免像嵌入式一样返回大向量字段.
  • 设定 fields: [] 以不包含文档字段(仅包含) _id 和可选 _score) (中文(简体) ).

您也可以使用辅助工具定义工具条目 :

import { defineVectorSearchTool } from "@dayhaysoos/convex-database-chat/vector";

const TOOLS = [
  defineVectorSearchTool({
    name: "semanticSearch",
    description: "Find candidates by meaning or concept",
    handler: "semanticSearchCandidates",
  }),
];

包括可操作的 URL

通过包含链接到细节页面的 URL 使回复可操作 :

// In your query handler, include viewUrl in results
return items.map((item) => ({
  id: item._id,
  name: item.name,
  // Include the URL to view this item
  viewUrl: `/items/${item._id}`,
}));

然后指示 LLM 在您的系统中使用它 提示:

Always include links using the viewUrl field from the data.
Format as markdown: [Item Name](viewUrl)

在此之前(不可采取行动):

"50美元以下的2个产品"

在(可诉)之后:

"50美元以下的2个产品:

  • 无线鼠标 - 29.99美元 - 29.99美元 - 29.99美元 - 29.99美元 - 29.99美元
  • USB 有线 - 12.99美元 - 12.99美元 - 12.99美元 - 12

安全和多租户访问

此组件具有可解析性 。 你必须提供 externalId 从您自己的认证 系统,并且从不直接接受来自不信任客户端.

使用下方范围端点执行所有权检查服务器侧. 这些 掷出 Not found 如果对话或流缺失或不属于 提供 externalId 避免在租户之间泄露存在.

  • conversations.getForExternalId
  • messages.addForExternalId
  • messages.listForExternalId
  • messages.getLatestForExternalId
  • stream.getStreamForExternalId
  • stream.listDeltasForExternalId
  • stream.abortForExternalId
  • chat.sendForExternalId

未瞄准的端点是低级帮助,只有在已经使用时才使用 在您的应用程序中执行访问控制 .


将信任的上下文注入工具

一些工具处理器需要只使用服务器的上下文( orgId, 外部Id, userId) 绝不应出现在工具图或 LLM 提示中。 把数据通过 config.chat.toolContext 内 chat.send 或者说 chat.sendForExternalId合并上下文 验证后成为工具参数,以及 toolContext 赢得冲突.

await ctx.runAction(components.databaseChat.chat.sendForExternalId, {
  conversationId,
  externalId,
  message,
  config: {
    apiKey: process.env.OPENROUTER_API_KEY!,
    chat: {
      systemPrompt: SYSTEM_PROMPT,
      tools,
      toolContext: { orgId, userId, externalId },
    },
  },
});

注:不包括类似字段 orgId 在工具的计划中,所以LLM永远不会 看他们.


API 参考

职能

函数类型说明
conversations.create变异创建新对话
conversations.get查询以身份进行交谈
conversations.getForExternalId查询通过 ID 范围访问外部Id
conversations.list查询按外部Id 排列的对话框
messages.add变异添加信件
messages.list查询在对话框中列出信件
messages.listForExternalId查询列出已放入外部Id 范围的信件
messages.getLatestForExternalId查询获取最新消息范围到外部Id
stream.create变异为基于三角洲的溪流创建新溪流
stream.addDelta变异向溪流添加三角洲( 批部件)
stream.finish变异将流标为已完成, 清理三角洲
stream.abort变异以流编号中止流
stream.abortByConversation变异通过对话ID中止流
stream.abortForExternalId变异通过对口ID范围中止流到外部Id
stream.getStream查询获取当前流状态进行交谈
stream.listDeltas查询从光标位置获取三角洲
stream.getStreamForExternalId查询获得当前流状态范围到外部Id
stream.listDeltasForExternalId查询将三角洲瞄准外部Id
chat.send行动通过 OpenRouter 发送消息
chat.sendForExternalId行动向外部 Id 发送已设定范围的信件

反应钩和组件

导出类型说明
useDatabaseChat钩子发送信件、 跟踪加载/ 流化状态、 中止
useConversations钩子列表/ 创建对话框
useStreamingContent钩子订阅带有 Dleta 累积的流流更新
useMessagesWithStreaming钩子信件 + 当前流线合并
useSmoothText钩子流媒体文本的打字机效果
SmoothText构成部分具有平滑打字机效果的渲染文本

流国家

状态说明
streamingStream 活动, 三角洲正在写入
finished流线成功完成
aborted流被取消( 用户中止、 错误或超时)

当一个流被用户中止、超时或出错所中断时,任何内容 已经流出的信息作为助理信息持续使用 partial: true 这样 部分反应没有丢失。 (中间回合的调用工具循环是: 没有流出或持续,只有最后的答案是。 )

已知的限制:

  • 如果工具循环的最后一轮在流出任何文本之前失败, 内容 无法从中间弹中取回(如果 轮回旋转).
  • 停止在工具执行期间在下一个工具启动边界上进行检测; 远程单个工具呼叫不能在中途中断.
  • 在工具回合之间, 流速旋转: 客户端订阅为流 状态可观察到摘要 streaming - > (中文) aborted - > (中文) streaming 转换,回合之间没有内容.

铁路部署

这个Repo主机有两个铁路服务,每个都有自己的配置文件:

  • 示例应用程序: 根目录 /example, 铁路配置文件 /example/railway.toml
  • Docs 站点: 根目录 /docs, 铁路配置文件 /docs/railway.toml

注意: 铁路在选择一个时不会自动跟随根目录 配置文件,所以您必须设置每个服务的绝对配置路径 。 一个根 railway.toml 没有故意使用.


测试

# Run all tests
npm test

# Run tests in watch mode
npm run test:watch

文件结构

convex/component/                  # Backend (Convex component)
├── convex.config.ts               # Component definition
├── schema.ts                      # Tables (conversations, messages, streamingMessages, streamDeltas)
├── conversations.ts               # Conversation CRUD
├── messages.ts                    # Message CRUD
├── stream.ts                      # Delta-based streaming (create, addDelta, finish, abort)
├── deltaStreamer.ts               # DeltaStreamer class for batched writes
├── chat.ts                        # Main send action with OpenRouter
├── toolExecution.ts               # Tool dispatch + trusted context injection
├── tools.ts                       # Tool types & typed builders
├── resultContract.ts              # Standard { data, meta } result envelope
├── toolGuidance.ts                # Reliability guidance injection
├── schemaTools.ts                  # Auto-tool generation
├── access.ts                      # externalId ownership checks
├── contextTypes.ts                # Shared DataModel-typed context helpers
├── client.ts                      # Client wrapper (defineDatabaseChat)
└── *.test.ts                      # Tests

src/                               # Frontend (React)
├── react.tsx                      # React hooks (useDatabaseChat, useConversations, etc.)
├── vector.ts                      # Vector search helpers
├── react.test.tsx                 # Hook tests
└── index.ts                       # Package exports

许可证

麻省理工学院

版本与发布记录

@dayhaysoos/convex-database-chat 共发布 14 个版本,首次发布于 2025/12/16;过去 12 个月发布了 14 个版本。

版本发布日期距今
0.5.0latest2026/10/031 个月前
0.5.0-alpha.2alpha2026/08/292 个月前
0.5.0-alpha.12026/08/292 个月前
0.5.0-alpha.02026/08/292 个月前
0.4.02026/08/292 个月前
0.3.12026/08/222 个月前

其他发布通道:rc → 0.2.0-rc.0,alpha → 0.5.0-alpha.2。

关于 @dayhaysoos/convex-database-chat 的常见问题

@dayhaysoos/convex-database-chat 是什么?

用于在您的应用程序中添加自然语言数据库查询的 Convex 组件 .

如何安装 @dayhaysoos/convex-database-chat?

在项目目录运行 npm install @dayhaysoos/convex-database-chat,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:convex(^1.43.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@dayhaysoos/convex-database-chat 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@dayhaysoos/convex-database-chat 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@dayhaysoos/convex-database-chat 可以商用吗?

@dayhaysoos/convex-database-chat 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@dayhaysoos/convex-database-chat 的下载量有多少?

根据 npm 官方下载统计,@dayhaysoos/convex-database-chat 在 2026/10/12 – 2026/11/10 这 30 天内被下载 2,293 次,日均约 76 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@dayhaysoos/convex-database-chat 还在维护吗?

最新版本 0.5.0 发布于 2026/10/03(1 个月前);至今共发布 14 个版本,其中过去 12 个月发布了 14 个;首次发布于 2025/12/16。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。