Skip to content
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
7e8b73a
Add debug logger and tool call support for OpenAI API compatible tool…
Pablonara Jul 1, 2025
9f6835d
Remove debugging in production
Pablonara Jul 1, 2025
680fd24
Remove reasoning tag rendering for user logic as users will never out…
Pablonara Jul 1, 2025
eac8917
Fallback to assistiant rendering and warn in console instead if inval…
Pablonara Jul 1, 2025
993d2db
Change any[] to check with zod first
Pablonara Jul 1, 2025
2983918
Switch log to devLog (logging wrapper)
Pablonara Jul 1, 2025
69ffaed
Add tempfix for max recursion depth on toolcalls using prompt enginee…
Pablonara Jul 2, 2025
ea434dc
Fix issues with tool_calls blank array making model providers angry a…
Pablonara Jul 2, 2025
e244873
Fix hardcoded parameter assumption when sending search parameters
Pablonara Jul 2, 2025
6d9682a
More efficient DB querying
Pablonara Jul 2, 2025
58c9c21
Fix passing proper depth context to sysprompt
Pablonara Jul 2, 2025
dbf8eb5
Log if tool call fails (plausible if model is smaller)
Pablonara Jul 2, 2025
1637f32
Fix: Actually log an error in production using the correct function
Pablonara Jul 2, 2025
a6bdfb4
Fix tool message rendering \\n and add searchWeb tool!!!
Pablonara Jul 2, 2025
33789b1
fix weird zod shit and clean up
x4132 Jul 2, 2025
23fddc8
reorganize tool functions
x4132 Jul 2, 2025
8c66de9
further reorg, remove test tool, etc
x4132 Jul 2, 2025
1f4efc1
Fix CI
Pablonara Jul 2, 2025
472579a
client-side keys
x4132 Jul 5, 2025
50dbeda
new hooks
x4132 Jul 5, 2025
ab9b74a
Convert showcase videos from .mov to .mp4 format
x4132 Jul 8, 2025
fab11bb
Update docker-compose to healthcheck faster for faster boot times in …
Pablonara Jul 13, 2025
6a00cad
Add getPageContent for better tool calling
Pablonara Jul 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .cursorrules
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Project Rules: Zod Import Conventions

## Objective
Ensure consistent usage of Zod across the code-base:

1. **Server-side (backend) code** – _must_ import Zod from `"zod/v4"`.
2. **Client-side (frontend) code** – _must_ import Zod from `"zod/v4-mini"`.

## Scope
* **Server-side**: any TypeScript / TSX files under `src/` or other backend folders.
* **Client-side**: any files under `client/`.

## Guidelines
* When writing or modifying backend code, always use:
```ts
import { z } from "zod/v4";
```
* When writing or modifying frontend code, always use:
```ts
import { z } from "zod/v4-mini";
```
* Do **not** mix the two versions in the same file.
* If migrating an existing file, update its Zod import to follow these rules.

These rules apply to all future code generations and refactors.
147 changes: 126 additions & 21 deletions client/src/components/MessageRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { z } from "zod/v4-mini";
import { useNavigate } from "@tanstack/react-router";
import { toast } from "sonner";
import { useActiveId, useActiveMessage } from "./WSManager";
import { normaliseToolCalls, safeRole } from "@/lib/message-normalise";

interface MessageRendererProps {
chatId?: string;
Expand All @@ -37,7 +38,6 @@ export function MessageRenderer({ chatId }: MessageRendererProps) {
const { chunks: activeMessage, setChunks: setActiveMessage } = useActiveMessage();
const activeId = useActiveId();

// Use useMutationState to access the sendMessage mutation state
const sendMessageVariables = useMutationState<string | null>({
filters: { mutationKey: ["sendMessage", chatId], status: "pending" },
select: (mutation) => z.string().parse(mutation.state.variables ?? ""),
Expand All @@ -46,10 +46,10 @@ export function MessageRenderer({ chatId }: MessageRendererProps) {
// HACK: do we really need inf. query? it has been disabled for now
const messagePages = useInfiniteQuery({
queryKey: ["messages", chatId],
queryFn: async ({ pageParam: cursor }) => {
queryFn: async ({ pageParam: cursor }): Promise<{ messages: Message[]; cursor?: number; }> => {
if (user_sess.data) {
if (chatId) {
// TODO: get messages
// TODO: proper pagination
let messageResponse;
try {
messageResponse = await ky.get(`/api/chats/${chatId}?cursor=${cursor}`);
Expand All @@ -64,12 +64,30 @@ export function MessageRenderer({ chatId }: MessageRendererProps) {
if (!messageResponse) {
throw new Error("Failed to fetch messages");
}
let messages = await messageResponse.json();

const safeParsedData = z.object({
messages: z.array(Message)
}).safeParse(await messageResponse.json());

if (!safeParsedData.success) {
console.error("zod error while parsing messages", safeParsedData.error, messageResponse);
return { messages: [], cursor: 0 };
}

// Normalise messages for proper rendering
const parsedMessages =
safeParsedData.data.messages?.map((msg: Message) => ({
...msg,
tool_calls: normaliseToolCalls(msg.tool_calls),
toolCallId: msg.toolCallId ?? null,
role: safeRole(msg.role),
})) ?? [];

if (!activeId && activeMessage.length > 0 && setActiveMessage) {
setActiveMessage([]);
}
return z.object({ messages: z.array(Message) }).parse(messages);

return { messages: parsedMessages, cursor: 0 };
} else {
return { messages: [], cursor: 0 };
}
Expand All @@ -93,6 +111,8 @@ export function MessageRenderer({ chatId }: MessageRendererProps) {
message: sendMessageVariables,
reasoning: null,
files: null,
tool_calls: null,
toolCallId: null,
finish_reason: null,
createdAt: new Date(),
});
Expand All @@ -108,6 +128,8 @@ export function MessageRenderer({ chatId }: MessageRendererProps) {
reasoning: activeMessage.reduce((prev, cur) => prev + cur.reasoning, ""),
finish_reason: activeMessage.reduce((prev: string | null, cur) => (prev ? prev : cur.finish_reason), null),
files: null,
tool_calls: null,
toolCallId: null,
createdAt: new Date(),
});
}
Expand Down Expand Up @@ -148,6 +170,8 @@ function RenderedMsg({
setActiveMessage?: (chunks: any[]) => void;
}) {
const [showThink, setShowThink] = React.useState(false);
const [showToolResult, setShowToolResult] = React.useState(false);
const [showFunctionCalls, setShowFunctionCalls] = React.useState(false);
const or_key = useORKey((state) => state.key);
const model = useModel((state) => state.model);
const [editMessage, setEditMessage] = useState("");
Expand Down Expand Up @@ -269,25 +293,106 @@ function RenderedMsg({
</div>
) : (
<div
className={`${message.role === "user" ? "border p-2 rounded-lg ml-auto" : "px-2 py-1"} bg-background mb-1 prose`}
className={`${message.role === "user" ? "border p-2 rounded-lg ml-auto" : "px-2 py-1"} bg-background mb-1`}
>
{message.reasoning ? (
<Collapsible>
<CollapsibleTrigger
className="flex items-center gap-1 transition-all text-foreground/50 hover:text-foreground"
onClick={() => setShowThink(!showThink)}
>
{showThink ? <ChevronDown /> : <ChevronRight />} {showThink ? "Hide Thinking" : "Show Thinking"}
</CollapsibleTrigger>
<CollapsibleContent>
<MarkdownRenderer>{message.reasoning ?? ""}</MarkdownRenderer>
</CollapsibleContent>
</Collapsible>
) : null}
{/* Only wrap in space-y-2 for non-user messages EDIT: changed wrapper to use space-y-3*/}
{message.role === "user" ? (
/* User messages: apply prose only to final content */
<div className="prose">
<MarkdownRenderer>{retryMessage.variables ?? message.message}</MarkdownRenderer>
</div>
) : (
/* Assistant/system/tool messages: apply prose only to final content */
/* Nevermind we use space-y-3 for more consistiency because my code is cooked */
<div className="space-y-3">
{message.reasoning ? (
<Collapsible>
<CollapsibleTrigger
className="flex items-center gap-1 transition-all text-foreground/50 hover:text-foreground"
onClick={() => setShowThink(!showThink)}
>
{showThink ? <ChevronDown /> : <ChevronRight />} {showThink ? "Hide Thinking" : "Show Thinking"}
</CollapsibleTrigger>
<CollapsibleContent>
<div className="prose">
<MarkdownRenderer>{message.reasoning ?? ""}</MarkdownRenderer>
</div>
</CollapsibleContent>
</Collapsible>
) : null}

<MarkdownRenderer>{retryMessage.variables ?? message.message}</MarkdownRenderer>
{/* Tool message rendering */}
{message.role === "tool" ? (
<Collapsible>
<CollapsibleTrigger
className="flex items-center gap-1 transition-all text-foreground/50 hover:text-foreground"
onClick={() => setShowToolResult(!showToolResult)}
>
{showToolResult ? <ChevronDown /> : <ChevronRight />} Tool Result
{message.toolCallId && (
<span className="text-xs text-foreground/40">({message.toolCallId})</span>
)}
</CollapsibleTrigger>
<CollapsibleContent className="mt-2">
<div className="border-l-2 border-foreground/20 pl-3 prose">
<MarkdownRenderer>
{(() => {
try {
// Try to parse as JSON first, then format with proper line breaks
const parsed = JSON.parse(retryMessage.variables ?? message.message);
return typeof parsed === 'string'
? parsed.replace(/\\n/g, '\n') // Convert escaped newlines to actual newlines
: JSON.stringify(parsed, null, 2); // Pretty print JSON with proper formatting
} catch {
// If not JSON, just handle escaped newlines
return (retryMessage.variables ?? message.message).replace(/\\n/g, '\n');
}
})()}
</MarkdownRenderer>
</div>
</CollapsibleContent>
</Collapsible>
) : message.tool_calls && message.tool_calls.length > 0 ? (
<>
<Collapsible>
<CollapsibleTrigger
className="flex items-center gap-1 transition-all text-foreground/50 hover:text-foreground"
onClick={() => setShowFunctionCalls(!showFunctionCalls)}
>
{showFunctionCalls ? <ChevronDown /> : <ChevronRight />} Function Calls ({message.tool_calls.length})
</CollapsibleTrigger>
<CollapsibleContent className="mt-2">
<div className="border-l-2 border-foreground/20 pl-3 space-y-2">
{message.tool_calls.map((toolCall, index) => (
<div key={toolCall.id || index} className="text-sm">
<div className="font-mono text-foreground/80 mb-1">
{toolCall.function.name}
</div>
<div className="text-xs text-foreground/60 bg-muted p-2 rounded font-mono overflow-x-auto">
{typeof toolCall.function.arguments === "object"
? JSON.stringify(toolCall.function.arguments, null, 2)
: String(toolCall.function.arguments)}
</div>
</div>
))}
</div>
</CollapsibleContent>
</Collapsible>
{message.message && (
<div className="prose">
<MarkdownRenderer>{retryMessage.variables ?? message.message}</MarkdownRenderer>
</div>
)}
</>
) : (
<div className="prose">
<MarkdownRenderer>{retryMessage.variables ?? message.message}</MarkdownRenderer>
</div>
)}
</div>
)}

{message.finish_reason && message.finish_reason !== "stop" ? (
{message.finish_reason && ["stop", "tool_calls", "tool_calls_response"].includes(message.finish_reason) !== true ? (
<Alert variant="destructive">
<AlertTitle>{message.finish_reason}</AlertTitle>
</Alert>
Expand Down
13 changes: 12 additions & 1 deletion client/src/lib/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,26 @@ export const Chat = z.object({
});
export const Chats = z.array(Chat);
export type Chat = z.infer<typeof Chat>;

export const allowedRoles = ["system", "user", "assistant", "tool"] as const;
export const Message = z.object({
id: z.uuidv4(),
role: z.enum(["system", "user", "assistant"]),
role: z.enum(allowedRoles),
senderId: z.string(),
chatId: z.string(),
files: z.nullable(z.array(z.string())),
reasoning: z.nullable(z.string()),
message: z.string(),
finish_reason: z.nullable(z.string()),
createdAt: z.coerce.date(),
tool_calls: z.nullable(z.array(z.object({
id: z.string(),
type: z.literal("function"),
function: z.object({
name: z.string(),
arguments: z.record(z.string(), z.unknown()),
}),
}))),
toolCallId: z.nullable(z.string())
});
export type Message = z.infer<typeof Message>;
24 changes: 24 additions & 0 deletions client/src/lib/message-normalise.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { allowedRoles } from "./db";

export function normaliseToolCalls(tool_calls?: any[] | null) {
if (!tool_calls) return null;
return tool_calls.map((tc) => ({
...tc,
function: {
...tc.function,
arguments:
typeof tc.function.arguments === "string"
? tc.function.arguments
: JSON.stringify(tc.function.arguments),
},
}));
}
Comment on lines +3 to +15
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Improve type safety and add defensive programming.

The function logic is correct, but consider these improvements:

  1. Type safety: Replace any[] with a more specific interface for better type checking
  2. Null safety: Add defensive checks for tc.function to prevent runtime errors
+interface ToolCall {
+  function?: {
+    arguments?: string | object;
+    [key: string]: any;
+  };
+  [key: string]: any;
+}
+
-export function normaliseToolCalls(tool_calls?: any[] | null) {
+export function normaliseToolCalls(tool_calls?: ToolCall[] | null) {
     if (!tool_calls) return null;
     return tool_calls.map((tc) => ({
         ...tc,
         function: {
-            ...tc.function,
+            ...(tc.function || {}),
             arguments:
-                typeof tc.function.arguments === "string"
-                    ? tc.function.arguments
-                    : JSON.stringify(tc.function.arguments),
+                typeof tc.function?.arguments === "string"
+                    ? tc.function.arguments
+                    : JSON.stringify(tc.function?.arguments || {}),
         },
     }));
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function normaliseToolCalls(tool_calls?: any[] | null) {
if (!tool_calls) return null;
return tool_calls.map((tc) => ({
...tc,
function: {
...tc.function,
arguments:
typeof tc.function.arguments === "string"
? tc.function.arguments
: JSON.stringify(tc.function.arguments),
},
}));
}
interface ToolCall {
function?: {
arguments?: string | object;
[key: string]: any;
};
[key: string]: any;
}
export function normaliseToolCalls(tool_calls?: ToolCall[] | null) {
if (!tool_calls) return null;
return tool_calls.map((tc) => ({
...tc,
function: {
...(tc.function || {}),
arguments:
typeof tc.function?.arguments === "string"
? tc.function.arguments
: JSON.stringify(tc.function?.arguments || {}),
},
}));
}
🤖 Prompt for AI Agents
In client/src/lib/message-normalise.ts lines 1 to 13, improve type safety by
defining and using a specific interface for the tool_calls array elements
instead of any[]. Add defensive checks to ensure tc.function exists before
accessing its properties to prevent runtime errors. Update the function to
handle cases where tc.function might be null or undefined, returning appropriate
defaults or skipping those entries safely.


export function safeRole(role: string): (typeof allowedRoles)[number] {
const idx = (allowedRoles as readonly string[]).indexOf(role);
if (idx !== -1) {
return allowedRoles[idx];
}
console.warn(`Invalid role: ${role}, defaulting to 'assistant'`);
return "assistant";
}
28 changes: 28 additions & 0 deletions drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,34 @@
"when": 1750267871981,
"tag": "0000_normal_proteus",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1751319970878,
"tag": "0001_odd_annihilus",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1751323086353,
"tag": "0002_sudden_nick_fury",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1751333885790,
"tag": "0003_sloppy_living_lightning",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1751335818675,
"tag": "0004_mixed_ser_duncan",
"breakpoints": true
}
]
}
9 changes: 8 additions & 1 deletion src/chats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,16 @@ async function getChatMessages(chatId: string): Promise<sync.Messages> {
completions.push({
...msg,
files: files.filter((file) => !!file),
toolCallId: msg.toolCallId ?? undefined,
tool_calls: Array.isArray(msg.tool_calls) ? msg.tool_calls : undefined
});
} else {
completions.push({ ...msg, files: [] });
completions.push({
...msg,
files: [],
toolCallId: msg.toolCallId ?? undefined,
tool_calls: Array.isArray(msg.tool_calls) ? msg.tool_calls : undefined
});
}
}

Expand Down
6 changes: 4 additions & 2 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { pgTable, text, timestamp, boolean, index, pgEnum, integer } from "drizzle-orm/pg-core";
import { pgTable, text, timestamp, boolean, index, pgEnum, integer, jsonb } from "drizzle-orm/pg-core";
import { desc } from "drizzle-orm";
import { int } from "drizzle-orm/mysql-core";

Expand Down Expand Up @@ -69,7 +69,7 @@ export const chats = pgTable("chats", {
.notNull(),
});

export const roleEnum = pgEnum("role", ["system", "assistant", "user"]);
export const roleEnum = pgEnum("role", ["system", "assistant", "user", "tool"]);
export const chatMessages = pgTable(
"chat_messages",
{
Expand All @@ -84,6 +84,8 @@ export const chatMessages = pgTable(
message: text("content").notNull(),
reasoning: text("reasoning"),
finish_reason: text("finish_reason"),
tool_calls: jsonb("tool_calls").$defaultFn(() => []), // An array/object of JSON is apparently 'jsonb' format and not type 'array'
toolCallId: text("tool_call_id"),
files: text("files").array(),
createdAt: timestamp("created_at")
.$defaultFn(() => /* @__PURE__ */ new Date())
Expand Down
6 changes: 5 additions & 1 deletion src/lib/sys_prompts.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export const default_prompt = (name: string, company: string) => `
export const default_prompt = (name: string, company: string, toolCallDepth: number) => `
# Citation Instructions
If the assistant's response is based on content returned by the web_search tool, the assistant must always appropriately cite its response. Here are the rules for good citations:

Expand Down Expand Up @@ -221,5 +221,9 @@ the Assistant does not mention this information unless it is relevant to the use

the Assistant never starts its response by saying a question or idea or observation was good, great, fascinating, profound, excellent, or any other positive adjective. It skips the flattery and responds directly.

The Assistant may be in a tool interaction. If so, the assistant has been interacting with this tool ${toolCallDepth} times (updated in real-time). If this number reaches more than ${process.env.MAX_TOOL_RECURSION_DEPTH || 10}, the assistiant will stop and notify the user that it has been interacting with the tool for a while and if the user wishes to refine their prompt or continue with the current prompt. If the user chooses to continue, the Assistant will continue interacting with the tool. If the user refines their prompt instead, the Assistiant will refine it's method to satisfy the conditions of the new prompt.

For example, if the assistant has reached the maximum tool recursion depth, it might say: ${name} has been working on this problem for a while now. Would you like to refine your prompt or continue with the current prompt?"

${name} is now being connected with a person.
`
Loading