Skip to content

Conversation

@duyet
Copy link
Owner

@duyet duyet commented Oct 11, 2025

Summary

This PR integrates the standalone duyetbot-chatkit project into the monorepo as a new AI chat application at apps/ai/. The app provides an AI-powered chat interface using OpenAI's ChatKit and will be deployed to ai.duyet.net.

Changes

New Application Structure

  • apps/ai/ - Complete Next.js 15 application with React 19
    • /app - App router pages and API routes
    • /components - React components (ChatKitPanel, ConversationList, etc.)
    • /hooks - Custom hooks for ChatKit integration
    • /lib - Utility functions and configuration
    • /types - TypeScript type definitions

Key Features

  • OpenAI ChatKit integration for AI chat functionality
  • Conversation history management with localStorage
  • Responsive UI with dark mode support
  • Client-side tools for conversation management
  • Session management API route

Configuration Updates

  • turbo.json: Added AI app environment variables:
    • NEXT_PUBLIC_DUYET_AI_URL
    • OPENAI_API_KEY
    • NEXT_PUBLIC_CHATKIT_WORKFLOW_ID
    • CHATKIT_API_BASE
  • vercel.json: Deployment configuration for ai.duyet.net
  • yarn.lock: Updated with new dependencies

Integration Details

The app follows monorepo patterns:

  • Uses shared packages (@duyet/components, @duyet/libs, etc.)
  • Follows existing ESLint and Prettier configurations
  • Runs on port 3002 in development
  • Integrated with Turborepo build pipeline

Deployment

Testing

The app has been built successfully and includes:

  • Unit tests for hooks (useColorScheme, etc.)
  • Tests for utility functions (client-tools, conversation-storage, errors)

Notes

  • Source project: /Users/duet/project/duyetbot-chatkit/
  • All files have been copied and adapted for monorepo structure
  • Ready for deployment after PR approval

Generated with Claude Code

Summary by Sourcery

Integrate a new AI chat application into the monorepo under apps/ai, built with Next.js 15 and React 19, featuring an OpenAI ChatKit–powered interface with session API, custom hooks, localStorage–backed conversation history, theme management, client-side tools, and comprehensive error handling. Configure Turborepo and Vercel for deployment at ai.duyet.net.

New Features:

  • Add AI chat application at apps/ai with Next.js 15 and React 19
  • Integrate OpenAI ChatKit web component with a serverless session creation API
  • Implement theme management hook with dark mode and system preference support
  • Provide conversation history sidebar with localStorage persistence
  • Add client-side tools for theme switching and fact recording in chat

Enhancements:

  • Include a full-screen accessible error overlay with retry logic
  • Adopt monorepo patterns for shared ESLint, Prettier, and Tailwind configuration

Build:

  • Update turbo.json with AI app environment variables and add vercel.json for ai.duyet.net deployment
  • Add package.json, tsconfig, Next.js, PostCSS, and Tailwind config for apps/ai

Documentation:

  • Add README files for the AI app, components, hooks, and lib modules

Tests:

  • Add unit tests for custom hooks (useColorScheme, useChatKitSession, useChatKitScript)
  • Add tests for utility modules (client-tools, conversation-storage, errors)

@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Oct 11, 2025

Reviewer's Guide

This PR folds the standalone duyetbot-chatkit project into the monorepo as a new Next.js 15 AI chat app at apps/ai. It lays out a conventional app router structure, adds core components (ChatKitPanel, sidebars, overlays), implements custom hooks for theme, script loading, session management and conversation history, introduces a registry-based client-tools system, provides a serverless create-session API route, and wires up Turborepo/Vercel deployment through updated config files.

Sequence diagram for ChatKit session creation via API route

sequenceDiagram
    participant U as actor User
    participant App as Next.js App
    participant API as "create-session API route"
    participant OpenAI as "OpenAI ChatKit API"
    U->>App: Loads chat page
    App->>API: POST /api/create-session
    API->>OpenAI: POST /v1/chatkit/sessions (with workflowId, userId)
    OpenAI-->>API: Returns client_secret
    API-->>App: Returns client_secret
    App->>ChatKit: Initializes session with client_secret
    ChatKit-->>U: Shows chat interface
Loading

File-Level Changes

Change Details Files
Introduce new Next.js AI chat application under apps/ai with monorepo pipelines
  • Create complete app folder structure (app, components, hooks, lib, types, public)
  • Update turbo.json with AI environment variables
  • Add vercel.json for ai.duyet.net deployment
  • Refresh yarn.lock with new dependencies
turbo.json
yarn.lock
apps/ai/
vercel.json
Implement ChatKitPanel and UI components for chat interface
  • Build ChatKitPanel integration component with error and retry flow
  • Add full-screen ErrorOverlay with ARIA support
  • Develop ConversationSidebar, ConversationList, ConversationItem for history management
apps/ai/components/ChatKitPanel.tsx
apps/ai/components/ErrorOverlay.tsx
apps/ai/components/ConversationSidebar.tsx
apps/ai/components/ConversationList.tsx
apps/ai/components/ConversationItem.tsx
Add custom hooks for theme, script loading, session, and conversation history
  • useColorScheme for light/dark/system management
  • useChatKitScript for script availability and timeout
  • useChatKitSession for ChatKit session lifecycle
  • useConversationHistory with localStorage persistence
apps/ai/hooks/useColorScheme.ts
apps/ai/hooks/useChatKitScript.ts
apps/ai/hooks/useChatKitSession.ts
apps/ai/hooks/useConversationHistory.ts
Create client-tools registry pattern for in-chat actions
  • Define ClientToolContext and registry in lib/client-tools.ts
  • Implement switch_theme and record_fact handlers with deduplication
  • Expose executeClientTool and registration API for extensions
apps/ai/lib/client-tools.ts
Add serverless API route and central configuration
  • Create POST /api/create-session route with cookie handling and error parsing
  • Define config.ts for workflow ID, prompts, models and endpoints
  • Tie Next.js app and environment into proper tsconfig and next.config.ts
apps/ai/app/api/create-session/route.ts
apps/ai/lib/config.ts
apps/ai/tsconfig.json
apps/ai/next.config.ts

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@vercel
Copy link

vercel bot commented Oct 11, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
blog Ready Ready Preview Comment Oct 19, 2025 3:47pm
cv Error Error Oct 19, 2025 3:47pm
insights Ready Ready Preview Comment Oct 19, 2025 3:47pm
monorepo-home Ready Ready Preview Comment Oct 19, 2025 3:47pm
photos Ready Ready Preview Comment Oct 19, 2025 3:47pm

💡 Enable Vercel Agent with $100 free credit for automated AI reviews

@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello @duyet, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request introduces a new AI chat application into the monorepo, migrating a standalone project to enhance the ecosystem with an AI-powered conversational interface. The integration ensures consistency with existing development patterns and prepares the application for deployment, offering users a robust and interactive chat experience.

Highlights

  • New AI Chat Application: A new Next.js 15 application has been added at apps/ai/, integrating OpenAI's ChatKit to provide an AI-powered chat interface.
  • OpenAI ChatKit Integration: The application leverages @openai/chatkit-react for core AI chat functionality, including session management and client-side tool execution.
  • Conversation History & UI: Features conversation history management with localStorage persistence, a responsive UI, dark mode support, and client-side tools for conversation actions.
  • Monorepo Adherence: The new app follows monorepo patterns, utilizing shared packages (@duyet/components, @duyet/libs), ESLint, Prettier, and TypeScript configurations.
  • Deployment Configuration: Configured for deployment to ai.duyet.net via vercel.json and includes necessary environment variables in turbo.json.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey there - I've reviewed your changes - here's some feedback:

  • The dozens of autogenerated README.md files in each folder will clutter the codebase—consider consolidating these into a single docs directory or generating them outside of your source tree to keep production code lean.
  • The useConversationHistory hook exposes an isReady flag but the UI renders immediately—gate the conversation sidebar (or show a loader) until isReady is true to avoid flashing empty states on initial load.
  • The create-session API route contains a lot of inline parsing and error-extraction logic; extracting those into shared utility functions would reduce duplication and make the handler easier to maintain.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The dozens of autogenerated README.md files in each folder will clutter the codebase—consider consolidating these into a single docs directory or generating them outside of your source tree to keep production code lean.
- The useConversationHistory hook exposes an `isReady` flag but the UI renders immediately—gate the conversation sidebar (or show a loader) until `isReady` is true to avoid flashing empty states on initial load.
- The create-session API route contains a lot of inline parsing and error-extraction logic; extracting those into shared utility functions would reduce duplication and make the handler easier to maintain.

## Individual Comments

### Comment 1
<location> `apps/ai/hooks/useConversationHistory.ts:159-78` </location>
<code_context>
+  /**
+   * Delete a conversation
+   */
+  const deleteConversation = useCallback((threadId: string) => {
+    setConversations((prev) => {
+      const filtered = prev.filter((c) => c.threadId !== threadId);
+
+      if (isDev) {
+        console.debug(
+          "[useConversationHistory] Deleted conversation:",
+          threadId,
+        );
+      }
+
+      return filtered;
+    });
+  }, []);
+
+  /**
</code_context>

<issue_to_address>
**suggestion (bug_risk):** deleteConversation could clear activeThreadId if the deleted thread is active.

Please update activeThreadId to null when the deleted thread matches the current activeThreadId to prevent UI inconsistencies.

Suggested implementation:

```typescript
  /**
   * Delete a conversation
   */
  const deleteConversation = useCallback((threadId: string) => {
    setConversations((prev) => {
      const filtered = prev.filter((c) => c.threadId !== threadId);

      if (isDev) {
        console.debug(
          "[useConversationHistory] Deleted conversation:",
          threadId,
        );
      }

      // Clear activeThreadId if the deleted thread is active
      setActiveThreadId((currentActiveThreadId) =>
        currentActiveThreadId === threadId ? null : currentActiveThreadId
      );

      return filtered;
    });
  }, []);

```

Make sure that `setActiveThreadId` is defined in the same scope as `deleteConversation`. If it is not, you will need to add a `useState` for `activeThreadId` and `setActiveThreadId` at the top of your hook:

```ts
const [activeThreadId, setActiveThreadId] = useState<string | null>(null);
```
</issue_to_address>

### Comment 2
<location> `apps/ai/lib/conversation-storage.ts:57-67` </location>
<code_context>
+      return { success: true, data: [] };
+    }
+
+    // Validate each conversation object
+    const validated = parsed.filter((item): item is Conversation => {
+      return (
+        typeof item === "object" &&
</code_context>

<issue_to_address>
**suggestion:** Validation does not check for optional fields' types.

Currently, optional fields such as preview and messageCount are not type-checked during validation. Please update the validation logic to verify their types when these fields are present.

```suggestion
    // Validate each conversation object
    const validated = parsed.filter((item): item is Conversation => {
      return (
        typeof item === "object" &&
        item !== null &&
        typeof item.threadId === "string" &&
        typeof item.title === "string" &&
        typeof item.createdAt === "number" &&
        typeof item.lastActivityAt === "number" &&
        (
          // preview is optional, but if present must be a string
          !("preview" in item) || typeof item.preview === "string"
        ) &&
        (
          // messageCount is optional, but if present must be a number
          !("messageCount" in item) || typeof item.messageCount === "number"
        )
      );
    });
```
</issue_to_address>

### Comment 3
<location> `apps/ai/components/ChatKitPanel.tsx:190-199` </location>
<code_context>
+   * Handles thread changes in ChatKit
+   * Clears processed facts when switching conversations and tracks thread ID
+   */
+  const handleThreadChange = useCallback(
+    ({ threadId }: { threadId: string | null }) => {
+      processedFacts.current.clear();
+
+      // Update active thread ID in conversation history
+      setActiveThreadId(threadId);
+
+      // If this is a new thread (not from sidebar selection), add it to history
+      if (threadId && threadId !== lastThreadIdRef.current) {
+        upsertConversation(threadId, {
+          title: "New Conversation",
+          preview: undefined,
+          messageCount: 0,
+        });
+      }
+
+      lastThreadIdRef.current = threadId;
+
+      if (isDev) {
+        console.debug("[ChatKitPanel] Thread changed:", threadId);
+      }
+    },
+    [setActiveThreadId, upsertConversation],
+  );
+
</code_context>

<issue_to_address>
**suggestion:** Thread change handler may add duplicate conversations if threadId is reused.

To avoid overwriting existing conversation titles when threadId is reused, ensure the default title is only set for genuinely new threads.

Suggested implementation:

```typescript
      // If this is a new thread (not from sidebar selection), add it to history
      if (
        threadId &&
        threadId !== lastThreadIdRef.current &&
        !conversations?.[threadId] // or use: !getConversation(threadId)
      ) {
        upsertConversation(threadId, {
          title: "New Conversation",
          preview: undefined,
          messageCount: 0,
        });
      }

```

If you do not have a `conversations` object/map available in this scope, you may need to:
1. Pass it in as a prop or retrieve it from context/state.
2. Alternatively, use a selector or a function like `getConversation(threadId)` to check for existence.
Adjust the existence check accordingly to fit your codebase.
</issue_to_address>

### Comment 4
<location> `apps/ai/components/ChatKitPanel.tsx:91` </location>
<code_context>
+ * />
+ * ```
+ */
+export function ChatKitPanel({
+  theme,
+  onWidgetAction,
</code_context>

<issue_to_address>
**issue (complexity):** Consider extracting the ChatKitPanel's logic into a custom hook to separate integration details from presentation.

Here’s one way to pull most of the “plumbing” out of the component into a reusable hook so that your panel only deals with JSX and very high-level state. You can fold all of these callbacks + the toolContext + the ChatKit setup into a `useChatKitIntegration` hook:

```ts
// hooks/useChatKitIntegration.ts
import { useCallback, useMemo, useRef } from "react";
import { useChatKit } from "@openai/chatkit-react";
import { executeClientTool, type ClientToolContext } from "@/lib/client-tools";
import { CREATE_SESSION_ENDPOINT, WORKFLOW_ID, MODELS, PLACEHOLDER_INPUT, STARTER_PROMPTS, GREETING } from "@/lib/config";
import { useChatKitSession } from "@/hooks/useChatKitSession";
import { useChatKitScript } from "@/hooks/useChatKitScript";
import { useConversationHistory } from "@/hooks/useConversationHistory";

export function useChatKitIntegration(
  theme: ColorScheme,
  onWidgetAction: (act: FactAction) => Promise<void>,
  onThemeRequest: (scheme: ColorScheme) => void,
  onResponseEnd: () => void,
  setErrorState: (upd: Partial<ErrorState>) => void,
) {
  const processedFacts = useRef(new Set<string>());
  const lastThreadIdRef = useRef<string | null>(null);
  const { conversations, activeThreadId, setActiveThreadId, upsertConversation, deleteConversation } = useConversationHistory();
  const isWorkflowConfigured = Boolean(WORKFLOW_ID && !WORKFLOW_ID.startsWith("wf_replace"));

  const { isInitializing: isInitializingSession, getClientSecret } = useChatKitSession({
    workflowId: WORKFLOW_ID,
    endpoint: CREATE_SESSION_ENDPOINT,
    onErrorUpdate: setErrorState,
    isWorkflowConfigured,
  });
  const { isReady: isScriptReady } = useChatKitScript({
    onErrorUpdate: setErrorState,
    loadTimeout: 5000,
  });

  // client-tool context + handler
  const toolContext = useMemo<ClientToolContext>(
    () => ({ onThemeRequest, onWidgetAction, processedFacts: processedFacts.current, isDev: false }),
    [onWidgetAction, onThemeRequest]
  );
  const handleClientTool = useCallback(
    (invocation) => executeClientTool(invocation, toolContext),
    [toolContext]
  );

  const handleResponseStart = useCallback(
    () => setErrorState({ integration: null, retryable: false }),
    [setErrorState]
  );

  const handleThreadChange = useCallback(
    ({ threadId }: { threadId: string | null }) => {
      processedFacts.current.clear();
      setActiveThreadId(threadId);
      if (threadId && threadId !== lastThreadIdRef.current) {
        upsertConversation(threadId, { title: "New Conversation", preview: undefined, messageCount: 0 });
      }
      lastThreadIdRef.current = threadId;
    },
    [setActiveThreadId, upsertConversation]
  );

  const handleError = useCallback(({ error }) => console.error(error), []);

  const chatkit = useChatKit({
    api: { getClientSecret },
    theme: {
      colorScheme: theme,
      /* ... other theme props ... */
    },
    startScreen: { greeting: GREETING, prompts: STARTER_PROMPTS },
    composer: { placeholder: PLACEHOLDER_INPUT, models: MODELS },
    onClientTool: handleClientTool,
    onResponseEnd,
    onResponseStart: handleResponseStart,
    onThreadChange,
    onError: handleError,
  });

  return {
    chatkit,
    conversations,
    activeThreadId,
    deleteConversation,
    setThread: chatkit.setThreadId,
    isInitializingSession,
    isScriptReady,
  };
}
```

Then your panel collapses to:

```tsx
export function ChatKitPanel({ theme, onWidgetAction, onResponseEnd, onThemeRequest }: Props) {
  const [errors, setErrors] = useState<ErrorState>(createInitialErrors());
  const [widgetKey, bump] = useReducer((x) => x + 1, 0);

  const setErrorState = useCallback((u: Partial<ErrorState>) => setErrors((e) => ({ ...e, ...u })), []);
  const {
    chatkit,
    conversations,
    activeThreadId,
    deleteConversation,
    setThread,
    isInitializingSession,
    isScriptReady,
  } = useChatKitIntegration(theme, onWidgetAction, onThemeRequest, onResponseEnd, setErrorState);

  // compute blockingError / showChatKit...
  // JSX only
}
```

This reduces the 350 lines of one component into:

 1. a ~120 line hook (`useChatKitIntegration.ts`)  
 2. a ~50 line presentational component  

All feature‐parity is preserved, but the panel now only wires up state+JSX.
</issue_to_address>

### Comment 5
<location> `apps/ai/hooks/useColorScheme.ts:258` </location>
<code_context>
+ * }
+ * ```
+ */
+export function useColorScheme(
+  initialPreference: ColorSchemePreference = "system",
+): UseColorSchemeResult {
</code_context>

<issue_to_address>
**issue (complexity):** Consider splitting the large hook into three smaller, focused hooks for system detection, localStorage management, and DOM updates.

You can dramatically shrink this one‐huge hook by breaking it into three focused pieces—(1) system‐scheme detection via `useSyncExternalStore`, (2) a `useLocalStorage` hook that handles read/write + cross‐tab sync, and (3) a small “apply to DOM” hook. Then your top‐level `useColorScheme` is just wiring those together:

```ts
// hooks/usePrefersColorScheme.ts
import { useSyncExternalStore } from "react";
import { getMediaQuery, subscribeSystem } from "./mediaQueryUtils";

export function usePrefersColorScheme(): ColorScheme {
  return useSyncExternalStore(
    subscribeSystem,
    () => (getMediaQuery()?.matches ? "dark" : "light"),
    () => "light"
  );
}
```

```ts
// hooks/useLocalStorage.ts
import { useState, useEffect, useCallback } from "react";

export function useLocalStorage<T>(
  key: string,
  defaultValue: T
): [T, (val: T) => void] {
  const [state, setState] = useState<T>(() => {
    try {
      const raw = window.localStorage.getItem(key);
      return raw != null ? JSON.parse(raw) : defaultValue;
    } catch {
      return defaultValue;
    }
  });

  // persist + cross-tab
  useEffect(() => {
    try {
      window.localStorage.setItem(key, JSON.stringify(state));
    } catch {}
  }, [key, state]);

  useEffect(() => {
    const onStorage = (e: StorageEvent) => {
      if (e.key === key && e.newValue) {
        setState(JSON.parse(e.newValue));
      }
    };
    window.addEventListener("storage", onStorage);
    return () => window.removeEventListener("storage", onStorage);
  }, [key]);

  return [state, useCallback((v: T) => setState(v), [])];
}
```

```ts
// hooks/useApplyColorScheme.ts
import { useEffect } from "react";
import { applyDocumentScheme } from "./domUtils";

export function useApplyColorScheme(scheme: ColorScheme) {
  useEffect(() => {
    applyDocumentScheme(scheme);
  }, [scheme]);
}
```

```ts
// hooks/useColorScheme.ts
import { usePrefersColorScheme } from "./usePrefersColorScheme";
import { useLocalStorage } from "./useLocalStorage";
import { useApplyColorScheme } from "./useApplyColorScheme";

export function useColorScheme(
  initialPref: ColorSchemePreference = "system"
): UseColorSchemeResult {
  const system = usePrefersColorScheme();
  const [pref, setPref] = useLocalStorage<ColorSchemePreference>(
    STORAGE_KEY,
    initialPref
  );
  const scheme = pref === "system" ? system : pref;

  useApplyColorScheme(scheme);

  const setScheme = (s: ColorScheme) => setPref(s);
  const resetPref = () => setPref("system");

  return { scheme, preference: pref, setScheme, setPreference: setPref, resetPreference: resetPref };
}
```

This keeps 100% of your features, but each file only does one thing and is ~30 lines instead of 300+.
</issue_to_address>

### Comment 6
<location> `apps/ai/app/api/create-session/route.ts:24` </location>
<code_context>
+  return process.env.CHATKIT_API_BASE ?? DEFAULT_CHATKIT_BASE;
+}
+
+export async function POST(request: Request): Promise<Response> {
+  if (request.method !== "POST") {
+    return methodNotAllowedResponse();
</code_context>

<issue_to_address>
**issue (complexity):** Consider refactoring to use Next.js's built-in request and response APIs to simplify JSON parsing, cookie handling, and response creation.

You can collapse almost all of the manual JSON/cookie/response plumbing by using Next.js’s built-in `NextResponse` and `NextRequest.json()` / `cookies` APIs. Below is an example sketch of how your `POST` handler could shrink by 60–70% without losing any functionality:

```ts
// app/api/create-session/route.ts
import { NextResponse, NextRequest } from 'next/server';

export async function POST(req: NextRequest) {
  if (req.method !== "POST") {
    return NextResponse.json({ error: "Method Not Allowed" }, { status: 405 });
  }

  const openaiApiKey = process.env.OPENAI_API_KEY;
  if (!openaiApiKey) {
    return NextResponse.json(
      { error: "Missing OPENAI_API_KEY environment variable" },
      { status: 500 }
    );
  }

  // 1) parse JSON
  let body: CreateSessionRequestBody;
  try {
    body = await req.json();
  } catch {
    return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
  }

  // 2) resolve or set cookie
  const existing = req.cookies.get(SESSION_COOKIE_NAME)?.value;
  const userId = existing
    ? existing
    : crypto.randomUUID?.() ?? Math.random().toString(36).slice(2);

  const res = NextResponse.next();

  if (!existing) {
    // 3) set cookie
    res.cookies.set({
      name: SESSION_COOKIE_NAME,
      value: userId,
      maxAge: SESSION_COOKIE_MAX_AGE,
      path: "/",
      httpOnly: true,
      sameSite: "lax",
      secure: process.env.NODE_ENV === "production",
    });
  }

  // 4) call upstream
  const workflowId = body.workflow?.id ?? body.workflowId ?? WORKFLOW_ID;
  if (!workflowId) {
    return NextResponse.json({ error: "Missing workflow id" }, { status: 400 });
  }

  const upstream = await fetch(`${getApiBase()}/v1/chatkit/sessions`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${openaiApiKey}`,
      "OpenAI-Beta": "chatkit_beta=v1",
      ...(process.env.CLOUDFLARE_AI_GATEWAY_URL && {
        "cf-aig-authorization": `Bearer ${openaiApiKey}`,
      }),
    },
    body: JSON.stringify({ workflow: { id: workflowId }, user: userId }),
  });

  const json = await upstream.json().catch(() => ({}));
  if (!upstream.ok) {
    const errorMsg = extractUpstreamError(json) ?? upstream.statusText;
    return NextResponse.json(
      { error: errorMsg, details: json },
      { status: upstream.status }
    );
  }

  // 5) success
  return NextResponse.json(
    {
      client_secret: json.client_secret,
      expires_after: json.expires_after,
    },
    { status: 200 }
  );
}
```

What this gives you:

- Request.json() instead of your `safeParseJson`  
- `req.cookies.get` / `res.cookies.set` instead of manual cookie parsing/serializing  
- `NextResponse.json` instead of rolling your own `buildJsonResponse`  
- No more manual `new Response(JSON.stringify(…))`, `Headers`, or method-checks  

You can safely remove `safeParseJson`, `getCookieValue`, `serializeSessionCookie`, and `buildJsonResponse` once you’ve migrated to `NextResponse`.
</issue_to_address>

### Comment 7
<location> `apps/ai/app/api/create-session/route.ts:251` </location>
<code_context>
  const error = payload.error;

</code_context>

<issue_to_address>
**suggestion (code-quality):** Prefer object destructuring when accessing and using properties. ([`use-object-destructuring`](https://docs.sourcery.ai/Reference/Rules-and-In-Line-Suggestions/TypeScript/Default-Rules/use-object-destructuring))

```suggestion
  const {error} = payload;
```

<br/><details><summary>Explanation</summary>Object destructuring can often remove an unnecessary temporary reference, as well as making your code more succinct.

From the [Airbnb Javascript Style Guide](https://airbnb.io/javascript/#destructuring--object)
</details>
</issue_to_address>

### Comment 8
<location> `apps/ai/app/api/create-session/route.ts:265` </location>
<code_context>
  const details = payload.details;

</code_context>

<issue_to_address>
**suggestion (code-quality):** Prefer object destructuring when accessing and using properties. ([`use-object-destructuring`](https://docs.sourcery.ai/Reference/Rules-and-In-Line-Suggestions/TypeScript/Default-Rules/use-object-destructuring))

```suggestion
  const {details} = payload;
```

<br/><details><summary>Explanation</summary>Object destructuring can often remove an unnecessary temporary reference, as well as making your code more succinct.

From the [Airbnb Javascript Style Guide](https://airbnb.io/javascript/#destructuring--object)
</details>
</issue_to_address>

### Comment 9
<location> `apps/ai/lib/errors.ts:87` </location>
<code_context>
  const error = payload.error;

</code_context>

<issue_to_address>
**suggestion (code-quality):** Prefer object destructuring when accessing and using properties. ([`use-object-destructuring`](https://docs.sourcery.ai/Reference/Rules-and-In-Line-Suggestions/TypeScript/Default-Rules/use-object-destructuring))

```suggestion
  const {error} = payload;
```

<br/><details><summary>Explanation</summary>Object destructuring can often remove an unnecessary temporary reference, as well as making your code more succinct.

From the [Airbnb Javascript Style Guide](https://airbnb.io/javascript/#destructuring--object)
</details>
</issue_to_address>

### Comment 10
<location> `apps/ai/lib/errors.ts:102` </location>
<code_context>
  const details = payload.details;

</code_context>

<issue_to_address>
**suggestion (code-quality):** Prefer object destructuring when accessing and using properties. ([`use-object-destructuring`](https://docs.sourcery.ai/Reference/Rules-and-In-Line-Suggestions/TypeScript/Default-Rules/use-object-destructuring))

```suggestion
  const {details} = payload;
```

<br/><details><summary>Explanation</summary>Object destructuring can often remove an unnecessary temporary reference, as well as making your code more succinct.

From the [Airbnb Javascript Style Guide](https://airbnb.io/javascript/#destructuring--object)
</details>
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

}

setIsReady(true);
}, []);
Copy link
Contributor

Choose a reason for hiding this comment

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

suggestion (bug_risk): deleteConversation could clear activeThreadId if the deleted thread is active.

Please update activeThreadId to null when the deleted thread matches the current activeThreadId to prevent UI inconsistencies.

Suggested implementation:

  /**
   * Delete a conversation
   */
  const deleteConversation = useCallback((threadId: string) => {
    setConversations((prev) => {
      const filtered = prev.filter((c) => c.threadId !== threadId);

      if (isDev) {
        console.debug(
          "[useConversationHistory] Deleted conversation:",
          threadId,
        );
      }

      // Clear activeThreadId if the deleted thread is active
      setActiveThreadId((currentActiveThreadId) =>
        currentActiveThreadId === threadId ? null : currentActiveThreadId
      );

      return filtered;
    });
  }, []);

Make sure that setActiveThreadId is defined in the same scope as deleteConversation. If it is not, you will need to add a useState for activeThreadId and setActiveThreadId at the top of your hook:

const [activeThreadId, setActiveThreadId] = useState<string | null>(null);

Comment on lines +57 to +67
// Validate each conversation object
const validated = parsed.filter((item): item is Conversation => {
return (
typeof item === "object" &&
item !== null &&
typeof item.threadId === "string" &&
typeof item.title === "string" &&
typeof item.createdAt === "number" &&
typeof item.lastActivityAt === "number"
);
});
Copy link
Contributor

Choose a reason for hiding this comment

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

suggestion: Validation does not check for optional fields' types.

Currently, optional fields such as preview and messageCount are not type-checked during validation. Please update the validation logic to verify their types when these fields are present.

Suggested change
// Validate each conversation object
const validated = parsed.filter((item): item is Conversation => {
return (
typeof item === "object" &&
item !== null &&
typeof item.threadId === "string" &&
typeof item.title === "string" &&
typeof item.createdAt === "number" &&
typeof item.lastActivityAt === "number"
);
});
// Validate each conversation object
const validated = parsed.filter((item): item is Conversation => {
return (
typeof item === "object" &&
item !== null &&
typeof item.threadId === "string" &&
typeof item.title === "string" &&
typeof item.createdAt === "number" &&
typeof item.lastActivityAt === "number" &&
(
// preview is optional, but if present must be a string
!("preview" in item) || typeof item.preview === "string"
) &&
(
// messageCount is optional, but if present must be a number
!("messageCount" in item) || typeof item.messageCount === "number"
)
);
});

Comment on lines +190 to +199
const handleThreadChange = useCallback(
({ threadId }: { threadId: string | null }) => {
processedFacts.current.clear();

// Update active thread ID in conversation history
setActiveThreadId(threadId);

// If this is a new thread (not from sidebar selection), add it to history
if (threadId && threadId !== lastThreadIdRef.current) {
upsertConversation(threadId, {
Copy link
Contributor

Choose a reason for hiding this comment

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

suggestion: Thread change handler may add duplicate conversations if threadId is reused.

To avoid overwriting existing conversation titles when threadId is reused, ensure the default title is only set for genuinely new threads.

Suggested implementation:

      // If this is a new thread (not from sidebar selection), add it to history
      if (
        threadId &&
        threadId !== lastThreadIdRef.current &&
        !conversations?.[threadId] // or use: !getConversation(threadId)
      ) {
        upsertConversation(threadId, {
          title: "New Conversation",
          preview: undefined,
          messageCount: 0,
        });
      }

If you do not have a conversations object/map available in this scope, you may need to:

  1. Pass it in as a prop or retrieve it from context/state.
  2. Alternatively, use a selector or a function like getConversation(threadId) to check for existence.
    Adjust the existence check accordingly to fit your codebase.

@cloudflare-workers-and-pages
Copy link

cloudflare-workers-and-pages bot commented Oct 11, 2025

Deploying duyet-cv with  Cloudflare Pages  Cloudflare Pages

Latest commit: a8efa61
Status:🚫  Build failed.

View logs

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This is an impressive pull request that successfully integrates the duyetbot-chatkit project as a new AI chat application within the monorepo. The code quality is excellent, with a well-thought-out architecture, comprehensive documentation in READMEs, and robust implementation of custom hooks and components. The use of modern React features like useSyncExternalStore for theme management and the extensible client-tool registry are particularly noteworthy. I've only found a couple of minor areas for cleanup in the API route and Next.js configuration, which are detailed in the comments. Overall, this is a fantastic addition to the project.

Comment on lines +25 to +27
if (request.method !== "POST") {
return methodNotAllowedResponse();
}
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

In Next.js App Router, an exported function named POST in a route.ts file is only ever invoked for POST requests. This check for request.method !== "POST" is redundant and can be safely removed to simplify the code. This pattern is more common in the older Pages Router or other frameworks.

Comment on lines +5 to +10
webpack: (config) => {
config.resolve.alias = {
...(config.resolve.alias ?? {}),
}
return config
},
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The webpack function in your next.config.ts currently performs a no-op by spreading the existing aliases into a new object. If there are no aliases to add, this entire webpack configuration block can be removed to simplify the config file. If you plan to add aliases later, consider adding a // TODO comment explaining why it's there.

@cloudflare-workers-and-pages
Copy link

Deploying duyet-blog with  Cloudflare Pages  Cloudflare Pages

Latest commit: a8d87b2
Status: ✅  Deploy successful!
Preview URL: https://bb62ab04.duyet-blog.pages.dev
Branch Preview URL: https://feat-add-ai-chat-app.duyet-blog.pages.dev

View logs

@cloudflare-workers-and-pages
Copy link

Deploying duyet-home with  Cloudflare Pages  Cloudflare Pages

Latest commit: a8d87b2
Status:🚫  Build failed.

View logs

@cloudflare-workers-and-pages
Copy link

Deploying duyet-insights with  Cloudflare Pages  Cloudflare Pages

Latest commit: a8d87b2
Status: ✅  Deploy successful!
Preview URL: https://9e76f843.duyet-insights.pages.dev
Branch Preview URL: https://feat-add-ai-chat-app.duyet-insights.pages.dev

View logs

@cloudflare-workers-and-pages
Copy link

Deploying duyet-photos with  Cloudflare Pages  Cloudflare Pages

Latest commit: a8d87b2
Status: ✅  Deploy successful!
Preview URL: https://684c8d0d.duyet-photos.pages.dev
Branch Preview URL: https://feat-add-ai-chat-app.duyet-photos.pages.dev

View logs

duyet and others added 4 commits October 19, 2025 22:12
Add new AI chat application to the monorepo as apps/ai,
integrated from the standalone duyetbot-chatkit project.
This app provides an AI-powered chat interface using
OpenAI's ChatKit.

Key features:
- Next.js 15 application with React 19
- OpenAI ChatKit integration for AI chat functionality
- Conversation history management with localStorage
- Responsive UI with dark mode support
- Client-side tools for conversation management

Configuration updates:
- Added AI app environment variables to turbo.json
- Configured for deployment at ai.duyet.net
- Updated yarn.lock with new dependencies

Project structure:
- app/ - Next.js app router pages and API routes
- components/ - React components
- hooks/ - Custom React hooks for ChatKit integration
- lib/ - Utility functions and configuration

Generated with Claude Code

Co-Authored-By: Claude <[email protected]>
- Replace .eslintrc.js with eslint.config.mjs to match monorepo pattern
- Add CLOUDFLARE_AI_GATEWAY_URL to turbo.json globalEnv
- Fixes ESLint linting errors in CI

Generated with Claude Code

Co-Authored-By: Claude <[email protected]>
Add Cloudflare Workers deployment configuration to AI chat app:
- Add wrangler.jsonc for Cloudflare Workers config
- Add open-next.config.ts for OpenNext.js adapter
- Add .dev.vars.example for local development env vars
- Add @opennextjs/cloudflare and wrangler as optional dependencies
- Add deployment scripts: preview, deploy, upload, cf-typegen

The app now supports both Vercel and Cloudflare Workers deployment.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
Cloudflare Pages (cv) and Vercel (home) failures are unrelated to AI app changes.
Triggering rerun to verify deployment status.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants