Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
26 changes: 12 additions & 14 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ This boundary is **enforced by Next.js bundling at build time** (importing `pg`/

1. `OPERATING INSTRUCTIONS` — `DEFAULT_OPERATING_INSTRUCTION` (`src/lib/shared/llm/system-instructions.ts`)
2. `RUNTIME DATE CONTEXT` — current UTC timestamp + user timezone (from `X-User-Timezone` header)
3. **Provider overlay** (`PROVIDER OVERLAY: ALIBABA|MOONSHOTAI`) — keyed by the model's **provider org**, not its nickname (alibaba=Qwen, moonshotai=Kimi). Always applied for a supported model.
3. **Provider overlay** (`PROVIDER OVERLAY: ZAI`) — keyed by the model's **provider org** (zai=GLM). Always applied for the supported model. `resolvePromptProvider()` is param-less and always returns `"zai"`.
4. `IDENTITY AND TONE CONTEXT` — `DEFAULT_SOUL_FALLBACK_INSTRUCTION` (`src/lib/shared/llm/system-instructions.ts`)
5. `AUTH USER CONTEXT` — authenticated user id, name, email

Expand Down Expand Up @@ -136,16 +136,15 @@ The only tools are the two Tavily web-search tools, and both are registered toge

### Model Registry

All models are defined in `src/lib/shared/llm/models.ts`:
The app runs on a **single model**, defined in `src/lib/shared/llm/models.ts`:

| Key | Model ID | Display Name |
| ---------------------- | ---------------------- | ------------ |
| `ALIBABA_QWEN3_7_MAX` | `alibaba/qwen3.7-max` | Qwen 3.7 Max |
| `MOONSHOTAI_KIMI_K2_6` | `moonshotai/kimi-k2.6` | Kimi K2.6 |
| Key | Model ID | Display Name |
| ------------- | ------------- | ------------ |
| `ZAI_GLM_5_2` | `zai/glm-5.2` | GLM 5.2 |

- `MODEL_SELECTOR_MODELS` — the chat selector subset: Qwen 3.7 Max and Kimi K2.6.
- `DEFAULT_MODEL` (`= AvailableModels.ZAI_GLM_5_2`) is the single model used everywhere. There is **no model-selector UI** and no per-user model persistence — the client always submits `DEFAULT_MODEL`, and the model still flows through requests/threads so the API can validate it.
- The agent is text-only: all chat input is plain text (no image, file, or PDF input).
- Adding a model means updating `AvailableModels`, `ModelInfos`, `SUPPORTED_MODELS`, and optionally `MODEL_SELECTOR_MODELS`. `/api/models` filters this registry by configured keys (`getModels()` in `src/lib/actions/api-keys.ts`).
- Adding a model means updating `AvailableModels`, `ModelInfos`, and `SUPPORTED_MODELS` (and re-introducing selector UI if more than one model is ever exposed). `/api/models` filters this registry by configured keys (`getModels()` in `src/lib/actions/api-keys.ts`).

### Thread Storage

Expand Down Expand Up @@ -240,7 +239,7 @@ src/
# follow-up-questions
agent/messages/ # Message rendering (user, assistant, queued, activity timeline)
agent/markdown/ # Memoized markdown renderer
agent/prompt-form/ # PromptForm, ModelSelector
agent/prompt-form/ # PromptForm (single model; no selector)
app-sidebar.tsx # Sidebar shell (lazy-loads SearchChats + NavThreads)
nav-threads.tsx # Thread list + client-side pinning (localStorage)
nav-user.tsx # Account menu + sign-out
Expand All @@ -250,15 +249,14 @@ src/
layout/ # route group layout
ui/ # shadcn/ui primitives (base-lyra/stone) + ShikiCode
hooks/
agent/ # use-models (server-seeded models context),
# use-persistent-selected-model (localStorage-backed)
agent/ # use-models (server-seeded models context)
lib/
actions/api-keys.ts # getModels() server action
brand/colors.ts # App brand colors (used by layout/manifest)
editor/highlighter.ts
server/
agent-context.ts # buildAgentSystemInstruction
agent-prompt-steering.ts # Provider overlays (Qwen/Kimi tuning)
agent-prompt-steering.ts # Provider overlay (GLM tuning)
agent-route.ts # parseAgentStreamRequest, createAgentStreamResponse
agent-runtime-config.ts # Runtime constants (no env knobs)
auth.ts / auth-session.ts # isAuthConfigured, getRequestSession
Expand All @@ -280,7 +278,7 @@ src/
agent/messages.ts # AgentStreamEvent, Message, ToolInvocation, run statuses
agent/reasoning-privacy.ts # sanitizeReasoningForDisplay
agent-request-limits.ts # Message/char limit defaults
llm/models.ts # AvailableModels, ModelInfos, SUPPORTED/SELECTOR/RESEARCH
llm/models.ts # AvailableModels, ModelInfos, SUPPORTED_MODELS, DEFAULT_MODEL
llm/system-instructions.ts # DEFAULT_OPERATING_INSTRUCTION, DEFAULT_SOUL_FALLBACK_INSTRUCTION
threads.ts # Thread type, sort/normalize/deriveThreadTitle
proxy.ts # Next.js middleware (named export `proxy` + `config`)
Expand Down Expand Up @@ -357,5 +355,5 @@ Request size limits, stream/gateway timeouts, tool-step budgets, and body-size l
- The **mock smoke test uses the production server** (`next start`), so build first.
- `pnpm.onlyBuiltDependencies` already approves the `sharp` build script — do not run `pnpm approve-builds`.
- Don't reintroduce Sentry/PostHog/OpenTelemetry; they were intentionally removed in favor of Vercel Analytics/Speed Insights + structured logs.
- Pinning and selected model are **client-side localStorage** — there are no server columns or APIs for them.
- Pinning is **client-side localStorage** — there is no server column or API for it. There is no model selection or persistence: the app runs on the single `DEFAULT_MODEL` (GLM 5.2).
- After signing up via `/api/auth/sign-up/email`, the session cookie is set automatically; no separate sign-in is needed.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Chloei

Chloei is a Next.js 16 chat app backed by Vercel AI Gateway. It currently exposes a curated model selector that defaults to Qwen 3.7 Max and also includes Kimi K2.6, and offers optional Tavily web search and Better Auth email/password authentication with PostgreSQL-backed users and sessions.
Chloei is a Next.js 16 chat app backed by Vercel AI Gateway. It runs on a single model — GLM 5.2 (`zai/glm-5.2`) — and offers optional Tavily web search and Better Auth email/password authentication with PostgreSQL-backed users and sessions.

## Documentation

Expand Down
15 changes: 1 addition & 14 deletions src/app/(home)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,6 @@ import { ModelsProvider } from "@/hooks/agent/use-models"
import { getModels } from "@/lib/actions/api-keys"
import { isAuthConfigured } from "@/lib/server/auth"
import { getCurrentViewer } from "@/lib/server/auth-session"
import {
getModelSelectorModels,
resolveDefaultModelSelectorModel,
} from "@/lib/shared"

export const dynamic = "force-dynamic"

Expand All @@ -24,19 +20,10 @@ export default async function Home() {
}

const availableModels = getModels()
const modelSelectorModels = getModelSelectorModels(availableModels)

const resolvedInitialSelectedModel =
modelSelectorModels.length > 0
? resolveDefaultModelSelectorModel(modelSelectorModels)
: null

return (
<ModelsProvider models={availableModels}>
<HomePageContent
initialSelectedModel={resolvedInitialSelectedModel}
viewer={viewer}
/>
<HomePageContent viewer={viewer} />
</ModelsProvider>
)
}
3 changes: 2 additions & 1 deletion src/app/api/agent/follow-ups/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ function createEmptyFollowUpResponse(requestId: string) {
}

function isAvailableModel(model: ModelType): boolean {
return getModels().some((availableModel) => availableModel.id === model)
const availableIds = new Set<string>(getModels().map((m) => m.id))
return availableIds.has(model)
}

export async function POST(request: NextRequest) {
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/agent/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ export async function POST(request: NextRequest) {
const requestNow = new Date()
const userTimeZone = resolveUserTimeZone(request)
const featureFlags = await resolveAgentFeatureFlags()
const promptProvider = resolvePromptProvider(selectedModel)
const promptProvider = resolvePromptProvider()
const systemInstruction = buildAgentSystemInstruction(
{
id: session.user.id,
Expand Down
10 changes: 1 addition & 9 deletions src/components/agent/home/home-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,7 @@ const Messages = dynamic(
}
)

export function HomePageContent({
initialSelectedModel,
viewer,
}: {
initialSelectedModel?: ModelType | null
viewer: AuthViewer
}) {
export function HomePageContent({ viewer }: { viewer: AuthViewer }) {
const [isPending, startTransition] = useTransition()
const [isFallbackEnteringConversation, setIsFallbackEnteringConversation] =
useState(false)
Expand Down Expand Up @@ -375,7 +369,6 @@ export function HomePageContent({
onStopStream={handleStopStream}
isStreaming={streamingState}
dismissKeyboardOnSubmit={isMobile}
initialSelectedModel={initialSelectedModel}
transition={{ isPending, startTransition }}
viewTransitionName={promptViewTransitionName}
/>
Expand Down Expand Up @@ -426,7 +419,6 @@ export function HomePageContent({
onClearQueuedMessage={clearQueuedSubmission}
isStreaming={streamingState}
dismissKeyboardOnSubmit={isMobile}
initialSelectedModel={initialSelectedModel}
transition={{ isPending, startTransition }}
viewTransitionName={promptViewTransitionName}
/>
Expand Down
64 changes: 8 additions & 56 deletions src/components/agent/messages/user-message.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,14 @@
import { Check, Copy, CornerRightUp, Loader2, X } from "lucide-react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { toast } from "sonner"

import { useModels } from "@/hooks/agent/use-models"
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
import {
getModelSelectorModels,
isModelSelectorModel,
isModelType,
type Message,
type ModelType,
resolveDefaultModelSelectorModel,
} from "@/lib/shared"
import { DEFAULT_MODEL, type Message, type ModelType } from "@/lib/shared"
import { cn } from "@/lib/utils"

import { Button } from "../../ui/button"
import { Textarea } from "../../ui/textarea"
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip"
import { ModelSelector } from "../prompt-form/model-selector"
import {
agentShellFrameClass,
agentShellHighlightClass,
Expand Down Expand Up @@ -45,30 +36,8 @@ export function UserMessage({
newModel: ModelType
}) => Promise<void> | void
}) {
const { data: availableModels } = useModels()
const modelSelectorModels = useMemo(
() => getModelSelectorModels(availableModels),
[availableModels]
)
const initialModel = useMemo(() => {
const selectedModel = message.metadata?.selectedModel
if (isModelType(selectedModel) && isModelSelectorModel(selectedModel)) {
return selectedModel
}

if (
isModelType(message.llmModel) &&
isModelSelectorModel(message.llmModel)
) {
return message.llmModel
}

return resolveDefaultModelSelectorModel(modelSelectorModels)
}, [message.llmModel, message.metadata?.selectedModel, modelSelectorModels])

const [isEditing, setIsEditing] = useState(false)
const [editValue, setEditValue] = useState(message.content)
const [selectedModel, setSelectedModel] = useState<ModelType>(initialModel)
const [isEditPending, setIsEditPending] = useState(false)
const messageContentRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
Expand All @@ -93,23 +62,15 @@ export function UserMessage({
}
}, [isEditing])

const handleSelectModel = useCallback((model: ModelType | null) => {
if (model) {
setSelectedModel(model)
}
}, [])

const handleStartEditing = useCallback(() => {
setEditValue(message.content)
setSelectedModel(initialModel)
setIsEditing(true)
}, [initialModel, message.content])
}, [message.content])

const handleStopEditing = useCallback(() => {
setIsEditing(false)
setEditValue(message.content)
setSelectedModel(initialModel)
}, [message.content, initialModel])
}, [message.content])

const handleSubmit = useCallback(async () => {
const trimmedValue = editValue.trim()
Expand All @@ -129,7 +90,7 @@ export function UserMessage({
await onEditMessage({
messageId: message.id,
newContent: trimmedValue,
newModel: selectedModel,
newModel: DEFAULT_MODEL,
})
setIsEditing(false)
} catch (error) {
Expand All @@ -139,7 +100,7 @@ export function UserMessage({
} finally {
setIsEditPending(false)
}
}, [editValue, handleStopEditing, message.id, onEditMessage, selectedModel])
}, [editValue, handleStopEditing, message.id, onEditMessage])

const onKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
Expand Down Expand Up @@ -221,24 +182,15 @@ export function UserMessage({
onKeyDown={onKeyDown}
/>

<div className="grid grid-cols-2 items-center px-2 py-2">
<div className="flex min-w-0 items-center justify-start gap-1">
<ModelSelector
selectedModel={selectedModel}
handleSelectModel={handleSelectModel}
/>
</div>

<div className="flex items-center justify-end px-2 py-2">
<div className="flex min-w-0 items-center justify-end gap-[8px]">
<Button
onClick={() => {
void handleSubmit()
}}
size="iconSm"
variant="default"
disabled={
!editValue.trim() || isEditPending || !selectedModel
}
disabled={!editValue.trim() || isEditPending}
className="shrink-0 ring-offset-background"
>
{isEditPending ? (
Expand Down
Loading