diff --git a/.cursor/rules/convex_rules.mdc b/.cursor/rules/convex_rules.mdc index 1d984804..58f1e3a5 100644 --- a/.cursor/rules/convex_rules.mdc +++ b/.cursor/rules/convex_rules.mdc @@ -180,7 +180,7 @@ Note: `paginationOpts` is an object with the following properties: ## Schema guidelines - Always define your schema in `convex/schema.ts`. -- Always import the schema definition functions from `convex/server`: +- Always import the schema definition functions from `convex/server`. - System fields are automatically added to all documents and are prefixed with an underscore. The two system fields that are automatically added to all documents are `_creationTime` which has the validator `v.number()` and `_id` which has the validator `v.id(tableName)`. - Always include all index fields in the index name. For example, if an index is defined as `["field1", "field2"]`, the index name should be "by_field1_and_field2". - Index fields must be queried in the same order they are defined. If you want to be able to query by "field1" then "field2" and by "field2" then "field1", you must create separate indexes. @@ -480,8 +480,8 @@ import OpenAI from "openai"; import { internal } from "./_generated/api"; /** - * Create a user with a given name. - */ + * Create a user with a given name. + */ export const createUser = mutation({ args: { name: v.string(), @@ -493,8 +493,8 @@ export const createUser = mutation({ }); /** - * Create a channel with a given name. - */ + * Create a channel with a given name. + */ export const createChannel = mutation({ args: { name: v.string(), @@ -506,8 +506,8 @@ export const createChannel = mutation({ }); /** - * List the 10 most recent messages from a channel in descending creation order. - */ + * List the 10 most recent messages from a channel in descending creation order. + */ export const listMessages = query({ args: { channelId: v.id("channels"), @@ -532,8 +532,8 @@ export const listMessages = query({ }); /** - * Send a message to a channel and schedule a response from the AI. - */ + * Send a message to a channel and schedule a response from the AI. + */ export const sendMessage = mutation({ args: { channelId: v.id("channels"), @@ -672,5 +672,4 @@ export default defineSchema({ export default function App() { return
Hello World
; } -``` - +``` \ No newline at end of file diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 00000000..bb2f4f47 --- /dev/null +++ b/.github/workflows/preview.yml @@ -0,0 +1,59 @@ +name: Preview Playground PR + +on: + pull_request: + paths: + - "playground/**" + +permissions: + contents: read + pull-requests: write + +jobs: + preview: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + cache: "npm" + + - name: Install root dependencies & build + run: npm ci && npm run build + + - name: Install playground dependencies + run: cd playground && npm i + + - name: Build Vite project + run: cd playground && npm run build + + - name: Enable SPA routing + run: cp playground/dist/index.html playground/dist/200.html + + - name: Deploy to Surge + run: | + npm install -g surge + surge playground/dist agent-pr-${{ github.event.pull_request.number }}.surge.sh --token ${{ secrets.SURGE_TOKEN }} + + - name: Comment PR with preview URL + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 + with: + script: | + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + if (comments.some(c => c.body.includes('🚀 Preview Deployment'))) { + return; // Already commented + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: `## 🚀 Preview Deployment\n\nYour preview is ready!\n\n**URL:** https://agent-pr-${{ github.event.pull_request.number }}.surge.sh` + }); diff --git a/example/convex/_generated/dataModel.d.ts b/example/convex/_generated/dataModel.d.ts index 8541f319..f97fd194 100644 --- a/example/convex/_generated/dataModel.d.ts +++ b/example/convex/_generated/dataModel.d.ts @@ -38,7 +38,7 @@ export type Doc = DocumentByName< * Convex documents are uniquely identified by their `Id`, which is accessible * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). * - * Documents can be loaded using `db.get(id)` in query and mutation functions. + * Documents can be loaded using `db.get(tableName, id)` in query and mutation functions. * * IDs are just strings at runtime, but this type can be used to distinguish them from other * strings when type checking. diff --git a/playground/src/components/MessageDocItem.tsx b/playground/src/components/MessageDocItem.tsx new file mode 100644 index 00000000..49e706d9 --- /dev/null +++ b/playground/src/components/MessageDocItem.tsx @@ -0,0 +1,321 @@ +import React, { useState } from "react"; +import { formatDistanceToNow } from "date-fns"; +import type { Message, User } from "../types"; +import { Button } from "@/components/ui/button"; +import { + ChevronDown, + ChevronUp, + Bot, + User as UserIcon, + Wrench, + AlertCircle, + CheckCircle, + Clock, + Zap, +} from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { Badge } from "@/components/ui/badge"; + +interface MessageDocItemProps { + user: User | undefined; + message: Message; + isSelected: boolean; + onClick: React.MouseEventHandler; +} + +const MessageDocItem: React.FC = ({ + user, + message, + isSelected, + onClick, +}) => { + const [expanded, setExpanded] = useState(false); + + const messageDate = new Date(message._creationTime); + const relativeTime = formatDistanceToNow(messageDate, { addSuffix: true }); + + const role = message.message?.role; + const isUser = role === "user"; + const isAssistant = role === "assistant"; + const isTool = role === "tool"; + const isSystem = role === "system"; + + const toggleExpanded = (e: React.MouseEvent) => { + e.stopPropagation(); + setExpanded(!expanded); + }; + + // Get status icon and color + const getStatusInfo = () => { + switch (message.status) { + case "success": + return { + icon: , + color: "text-green-600", + bgColor: "bg-green-100", + }; + case "failed": + return { + icon: , + color: "text-red-600", + bgColor: "bg-red-100", + }; + case "pending": + return { + icon: , + color: "text-yellow-600", + bgColor: "bg-yellow-100", + }; + default: + return { + icon: null, + color: "text-gray-600", + bgColor: "bg-gray-100", + }; + } + }; + + const statusInfo = getStatusInfo(); + + // Get role icon + const getRoleIcon = () => { + if (isUser) { + return ( +
+ +
+ ); + } + if (isAssistant) { + return ( +
+ +
+ ); + } + if (isTool) { + return ( +
+ +
+ ); + } + if (isSystem) { + return ( +
+ +
+ ); + } + return null; + }; + + // Get role label + const getRoleLabel = () => { + if (isUser) return user?.name ?? "User"; + if (isAssistant) return message.agentName ?? "Assistant"; + if (isTool) return "Tool Result"; + if (isSystem) return "System"; + return "Unknown"; + }; + + // Extract text content preview + const getTextPreview = () => { + if (message.text) { + return message.text.length > 200 + ? message.text.substring(0, 200) + "..." + : message.text; + } + if ( + message.message?.content && + typeof message.message.content === "string" + ) { + const content = message.message.content; + return content.length > 200 ? content.substring(0, 200) + "..." : content; + } + return null; + }; + + // Get tool info if this is a tool message + const getToolInfo = () => { + if (!message.message) return null; + const content = message.message.content; + if (typeof content === "string") return null; + + const toolCalls = content?.filter( + (p): p is { type: "tool-call"; toolName: string; toolCallId: string } => + p.type === "tool-call", + ); + const toolResults = content?.filter( + (p): p is { type: "tool-result"; toolName: string; toolCallId: string } => + p.type === "tool-result", + ); + + return { toolCalls, toolResults }; + }; + + const toolInfo = getToolInfo(); + const textPreview = getTextPreview(); + + return ( +
+ {/* Header row */} +
+
+ {getRoleIcon()} + {getRoleLabel()} + + {role ?? "unknown"} + + {message.tool && ( + + + tool + + )} +
+ +
+ + + + + {statusInfo.icon} + {message.status} + + + +

Status: {message.status}

+
+
+
+ {relativeTime} +
+
+ + {/* Metadata badges row */} +
+ + order: {message.order} + + + step: {message.stepOrder} + + {message.model && ( + + {message.model} + + )} + {message.provider && ( + + {message.provider} + + )} + {message.usage?.totalTokens && ( + + {message.usage.totalTokens} tokens + + )} + {message.finishReason && ( + + {message.finishReason} + + )} +
+ + {/* Content preview */} +
+ {/* Tool calls/results info */} + {toolInfo?.toolCalls && toolInfo.toolCalls.length > 0 && ( +
+ {toolInfo.toolCalls.map((tc) => ( + + + {tc.toolName} + + ))} +
+ )} + + {toolInfo?.toolResults && toolInfo.toolResults.length > 0 && ( +
+ {toolInfo.toolResults.map((tr) => ( + + + {tr.toolName} result + + ))} +
+ )} + + {/* Text preview */} + {textPreview && ( +
+ {textPreview} +
+ )} + + {/* Error message */} + {message.error && ( +
+ Error: {message.error} +
+ )} + + {/* Expand/collapse button */} + + + {/* Expanded details - full message document JSON */} + {expanded && ( +
+
+              {JSON.stringify(message, null, 2)}
+            
+
+ )} +
+
+ ); +}; + +export default MessageDocItem; diff --git a/playground/src/components/MessageList.tsx b/playground/src/components/MessageList.tsx index 49f2a8e1..278f7bf6 100644 --- a/playground/src/components/MessageList.tsx +++ b/playground/src/components/MessageList.tsx @@ -1,7 +1,16 @@ -import React, { useMemo, useRef, useEffect } from "react"; -import MessageItem from "./MessageItem"; +import React, { useMemo, useRef, useEffect, useState } from "react"; +import UIMessageItem from "./UIMessageItem"; +import MessageDocItem from "./MessageDocItem"; import { Message, User } from "../types"; import { toUIMessages } from "@convex-dev/agent/react"; +import { Button } from "@/components/ui/button"; +import { List, Layers } from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; interface MessageListProps { users: User[]; @@ -17,6 +26,8 @@ const MessageList: React.FC = ({ onSelectMessage, }) => { const messagesEndRef = useRef(null); + const [viewMode, setViewMode] = useState<"raw" | "ui">("raw"); + const uiMessages = useMemo(() => { // TODO: segment the messages by "order" so the message item can show all of // the messages that have been grouped together. Right now you can only see @@ -41,20 +52,80 @@ const MessageList: React.FC = ({ }, [messages]); // Add messages as a dependency return ( -
- {uiMessages.map((message) => ( - user._id === message.userId)} - message={message} - isSelected={message._id === selectedMessageId} - onClick={() => { - onSelectMessage(message._id); - }} - /> - ))} - {/* Add an invisible div at the bottom to scroll to */} -
+
+ {/* View mode toggle */} +
+ + {viewMode === "raw" + ? `${messages.length} messages (raw)` + : `${uiMessages.length} messages (grouped)`} + + +
+ + + + + +

Show all thread messages separately with full data

+
+
+ + + + + +

Show messages grouped as UIMessages (may hide some data)

+
+
+
+
+
+ + {/* Messages list */} +
+ {viewMode === "raw" + ? messages.map((message) => ( + user._id === message.userId)} + message={message} + isSelected={message._id === selectedMessageId} + onClick={() => { + onSelectMessage(message._id); + }} + /> + )) + : uiMessages.map((message) => ( + user._id === message.userId)} + message={message} + isSelected={message._id === selectedMessageId} + onClick={() => { + onSelectMessage(message._id); + }} + /> + ))} + {/* Add an invisible div at the bottom to scroll to */} +
+
); }; diff --git a/playground/src/components/MessageItem.tsx b/playground/src/components/UIMessageItem.tsx similarity index 98% rename from playground/src/components/MessageItem.tsx rename to playground/src/components/UIMessageItem.tsx index e3909a17..e3634360 100644 --- a/playground/src/components/MessageItem.tsx +++ b/playground/src/components/UIMessageItem.tsx @@ -13,14 +13,14 @@ import { import { DynamicToolUIPart, ToolUIPart, UIMessage } from "ai"; import { SmoothText } from "@convex-dev/agent/react"; -interface MessageItemProps { +interface UIMessageItemProps { user: User | undefined; message: Omit & { message: UIMessage }; isSelected: boolean; onClick: React.MouseEventHandler; } -const MessageItem: React.FC = ({ +const UIMessageItem: React.FC = ({ user, message, isSelected, @@ -159,7 +159,7 @@ const MessageItem: React.FC = ({ ); }; -export default MessageItem; +export default UIMessageItem; const ToolCall: React.FC<{ part: ToolUIPart | DynamicToolUIPart; diff --git a/src/component/_generated/dataModel.ts b/src/component/_generated/dataModel.ts index 8541f319..f97fd194 100644 --- a/src/component/_generated/dataModel.ts +++ b/src/component/_generated/dataModel.ts @@ -38,7 +38,7 @@ export type Doc = DocumentByName< * Convex documents are uniquely identified by their `Id`, which is accessible * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). * - * Documents can be loaded using `db.get(id)` in query and mutation functions. + * Documents can be loaded using `db.get(tableName, id)` in query and mutation functions. * * IDs are just strings at runtime, but this type can be used to distinguish them from other * strings when type checking. diff --git a/src/component/_generated/server.ts b/src/component/_generated/server.ts index 24994e4e..739b02f7 100644 --- a/src/component/_generated/server.ts +++ b/src/component/_generated/server.ts @@ -107,11 +107,6 @@ export const internalAction: ActionBuilder = */ export const httpAction: HttpActionBuilder = httpActionGeneric; -type GenericCtx = - | GenericActionCtx - | GenericMutationCtx - | GenericQueryCtx; - /** * A set of services for use within Convex query functions. *