Unverified 提交 1e56c111 authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

Increase Basic Agent free quota from 5 to 10 messages (#3147)

## Summary - Raise the Basic Agent (free tier) message quota from 5 to 10 per window. - Add `src/lib/free_agent_quota_limit.ts` as the single source of truth; IPC re-exports the constant. - UI (selector, banner, chat error) uses `messagesLimit` from quota status so copy stays in sync. - Update `e2e-tests/free_agent_quota.spec.ts` for the new limit and strings. ## Test plan - `npm run fmt && npm run lint:fix && npm run ts` - `npm test` (vitest) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Made with [Cursor](https://cursor.com) <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/3147" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> Co-authored-by: 's avatarClaude <noreply@anthropic.com>
上级 3a220449
...@@ -4,7 +4,7 @@ import { expect } from "@playwright/test"; ...@@ -4,7 +4,7 @@ import { expect } from "@playwright/test";
/** /**
* E2E test for Basic Agent mode quota (free users). * E2E test for Basic Agent mode quota (free users).
* *
* Basic Agent mode is available to non-Pro users with a 5-message-per-day limit. * Basic Agent mode is available to non-Pro users with a 10-message-per-window limit.
* This test verifies mode availability, quota tracking, exceeded banner, and mode switching. * This test verifies mode availability, quota tracking, exceeded banner, and mode switching.
*/ */
...@@ -24,9 +24,9 @@ testSkipIfWindows( ...@@ -24,9 +24,9 @@ testSkipIfWindows(
po.page.getByRole("option", { name: /Agent v2/ }), po.page.getByRole("option", { name: /Agent v2/ }),
).not.toBeVisible(); ).not.toBeVisible();
// 2. Verify quota display is present (may not be 5/5 if AI_RULES.md generation consumed quota) // 2. Verify quota display is present (may not be 10/10 if AI_RULES.md generation consumed quota)
await expect( await expect(
po.page.getByRole("option", { name: /Basic Agent.*\d\/5 remaining/ }), po.page.getByRole("option", { name: /Basic Agent.*\d+\/10 remaining/ }),
).toBeVisible(); ).toBeVisible();
await po.page.keyboard.press("Escape"); await po.page.keyboard.press("Escape");
...@@ -36,8 +36,8 @@ testSkipIfWindows( ...@@ -36,8 +36,8 @@ testSkipIfWindows(
"Basic Agent", "Basic Agent",
); );
// 4. Send 5 messages to exhaust quota (this will exhaust quota even if some was already used) // 4. Send 10 messages to exhaust quota (this will exhaust quota even if some was already used)
for (let i = 0; i < 5; i++) { for (let i = 0; i < 10; i++) {
await po.sendPrompt(`tc=local-agent/simple-response message ${i + 1}`); await po.sendPrompt(`tc=local-agent/simple-response message ${i + 1}`);
await po.chatActions.waitForChatCompletion(); await po.chatActions.waitForChatCompletion();
} }
...@@ -47,7 +47,7 @@ testSkipIfWindows( ...@@ -47,7 +47,7 @@ testSkipIfWindows(
timeout: Timeout.MEDIUM, timeout: Timeout.MEDIUM,
}); });
await expect(po.page.getByTestId("free-agent-quota-banner")).toContainText( await expect(po.page.getByTestId("free-agent-quota-banner")).toContainText(
"You have used all 5 messages for the free Agent mode today", "You have used all 10 messages for the free Agent mode today",
); );
await expect( await expect(
po.page.getByRole("button", { name: "Upgrade to Dyad Pro" }), po.page.getByRole("button", { name: "Upgrade to Dyad Pro" }),
...@@ -56,14 +56,14 @@ testSkipIfWindows( ...@@ -56,14 +56,14 @@ testSkipIfWindows(
po.page.getByRole("button", { name: "Switch back to Build mode" }), po.page.getByRole("button", { name: "Switch back to Build mode" }),
).toBeVisible(); ).toBeVisible();
// 6. Try to send a 6th message - should be blocked with error // 6. Try to send an 11th message - should be blocked with error
await po.sendPrompt("tc=local-agent/simple-response message 6"); await po.sendPrompt("tc=local-agent/simple-response message 11");
// Verify error message appears indicating quota exceeded // Verify error message appears indicating quota exceeded
await expect(po.page.getByTestId("chat-error-box")).toBeVisible({ await expect(po.page.getByTestId("chat-error-box")).toBeVisible({
timeout: Timeout.MEDIUM, timeout: Timeout.MEDIUM,
}); });
await expect(po.page.getByTestId("chat-error-box")).toContainText( await expect(po.page.getByTestId("chat-error-box")).toContainText(
"You have used all 5 free Agent messages for today", "You have used all 10 free Agent messages for today",
); );
// 8. Click "Switch back to Build mode" and verify mode changes // 8. Click "Switch back to Build mode" and verify mode changes
...@@ -104,10 +104,11 @@ testSkipIfWindows( ...@@ -104,10 +104,11 @@ testSkipIfWindows(
// 2. Verify quota decreased (exact count may vary due to setup messages) // 2. Verify quota decreased (exact count may vary due to setup messages)
await po.page.getByTestId("chat-mode-selector").click(); await po.page.getByTestId("chat-mode-selector").click();
// The quota should be less than 5/5 after sending messages const basicAgentOption = po.page.getByRole("option", {
await expect( name: /Basic Agent/,
po.page.getByRole("option", { name: /Basic Agent.*[0-4]\/5 remaining/ }), });
).toBeVisible(); await expect(basicAgentOption).toContainText(/\/10 remaining/);
await expect(basicAgentOption).not.toContainText("10/10");
await po.page.keyboard.press("Escape"); await po.page.keyboard.press("Escape");
// 3. Simulate 25 hours passing by calling the test-only IPC handler // 3. Simulate 25 hours passing by calling the test-only IPC handler
...@@ -130,10 +131,10 @@ testSkipIfWindows( ...@@ -130,10 +131,10 @@ testSkipIfWindows(
timeout: Timeout.MEDIUM, timeout: Timeout.MEDIUM,
}); });
// 5. Verify quota has reset to 5/5 remaining // 5. Verify quota has reset to 10/10 remaining
await po.page.getByTestId("chat-mode-selector").click(); await po.page.getByTestId("chat-mode-selector").click();
await expect( await expect(
po.page.getByRole("option", { name: /Basic Agent.*5\/5 remaining/ }), po.page.getByRole("option", { name: /Basic Agent.*10\/10 remaining/ }),
).toBeVisible(); ).toBeVisible();
await po.page.keyboard.press("Escape"); await po.page.keyboard.press("Escape");
......
...@@ -35,7 +35,8 @@ export function ChatModeSelector() { ...@@ -35,7 +35,8 @@ export function ChatModeSelector() {
// Migration happens on read, so selectedChatMode will never be "agent" // Migration happens on read, so selectedChatMode will never be "agent"
const selectedMode = settings?.selectedChatMode || "build"; const selectedMode = settings?.selectedChatMode || "build";
const isProEnabled = settings ? isDyadProEnabled(settings) : false; const isProEnabled = settings ? isDyadProEnabled(settings) : false;
const { messagesRemaining, isQuotaExceeded } = useFreeAgentQuota(); const { messagesRemaining, messagesLimit, isQuotaExceeded } =
useFreeAgentQuota();
const { servers } = useMcp(); const { servers } = useMcp();
const enabledMcpServersCount = servers.filter((s) => s.enabled).length; const enabledMcpServersCount = servers.filter((s) => s.enabled).length;
...@@ -171,8 +172,7 @@ export function ChatModeSelector() { ...@@ -171,8 +172,7 @@ export function ChatModeSelector() {
<Bot size={14} className="text-muted-foreground" /> <Bot size={14} className="text-muted-foreground" />
<span className="font-medium">Basic Agent</span> <span className="font-medium">Basic Agent</span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
({isQuotaExceeded ? "0" : messagesRemaining}/5 remaining for {`(${isQuotaExceeded ? "0" : messagesRemaining}/${messagesLimit} remaining for today)`}
today)
</span> </span>
</div> </div>
<span className="text-xs text-muted-foreground ml-[22px]"> <span className="text-xs text-muted-foreground ml-[22px]">
......
...@@ -76,7 +76,7 @@ export function DefaultChatModeSelector() { ...@@ -76,7 +76,7 @@ export function DefaultChatModeSelector() {
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{isProEnabled {isProEnabled
? "Better at bigger tasks" ? "Better at bigger tasks"
: "Free tier (5 messages/day)"} : "Free tier (10 messages/day)"}
</span> </span>
</div> </div>
</SelectItem> </SelectItem>
......
import { ipc } from "@/ipc/types"; import { ipc } from "@/ipc/types";
import { useFreeAgentQuota } from "@/hooks/useFreeAgentQuota";
import { AI_STREAMING_ERROR_MESSAGE_PREFIX } from "@/shared/texts"; import { AI_STREAMING_ERROR_MESSAGE_PREFIX } from "@/shared/texts";
import { import {
X, X,
...@@ -25,6 +26,8 @@ export function ChatErrorBox({ ...@@ -25,6 +26,8 @@ export function ChatErrorBox({
isDyadProEnabled: boolean; isDyadProEnabled: boolean;
onStartNewChat?: () => void; onStartNewChat?: () => void;
}) { }) {
const { messagesLimit } = useFreeAgentQuota();
if (error.includes("doesn't have a free quota tier")) { if (error.includes("doesn't have a free quota tier")) {
return ( return (
<ChatErrorContainer onDismiss={onDismiss}> <ChatErrorContainer onDismiss={onDismiss}>
...@@ -118,8 +121,8 @@ export function ChatErrorBox({ ...@@ -118,8 +121,8 @@ export function ChatErrorBox({
if (error.includes("FREE_AGENT_QUOTA_EXCEEDED")) { if (error.includes("FREE_AGENT_QUOTA_EXCEEDED")) {
return ( return (
<ChatErrorContainer onDismiss={onDismiss}> <ChatErrorContainer onDismiss={onDismiss}>
You have used all 5 free Agent messages for today. Please upgrade to You have used all {messagesLimit} free Agent messages for today. Please
Dyad Pro for unlimited access or switch to Build mode. upgrade to Dyad Pro for unlimited access or switch to Build mode.
<div className="mt-2 space-y-2 space-x-2"> <div className="mt-2 space-y-2 space-x-2">
<ExternalLink <ExternalLink
href="https://dyad.sh/pro?utm_source=dyad-app&utm_medium=app&utm_campaign=free-agent-quota-exceeded" href="https://dyad.sh/pro?utm_source=dyad-app&utm_medium=app&utm_campaign=free-agent-quota-exceeded"
......
...@@ -14,8 +14,13 @@ interface FreeAgentQuotaBannerProps { ...@@ -14,8 +14,13 @@ interface FreeAgentQuotaBannerProps {
export function FreeAgentQuotaBanner({ export function FreeAgentQuotaBanner({
onSwitchToBuildMode, onSwitchToBuildMode,
}: FreeAgentQuotaBannerProps) { }: FreeAgentQuotaBannerProps) {
const { quotaStatus, isQuotaExceeded, hoursUntilReset, resetTime } = const {
useFreeAgentQuota(); quotaStatus,
isQuotaExceeded,
hoursUntilReset,
resetTime,
messagesLimit,
} = useFreeAgentQuota();
if (!isQuotaExceeded || !quotaStatus) { if (!isQuotaExceeded || !quotaStatus) {
return null; return null;
...@@ -51,9 +56,10 @@ export function FreeAgentQuotaBanner({ ...@@ -51,9 +56,10 @@ export function FreeAgentQuotaBanner({
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400 shrink-0 mt-0.5" /> <AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400 shrink-0 mt-0.5" />
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2">
<p className="text-sm text-amber-700 dark:text-amber-300"> <p className="text-sm text-amber-700 dark:text-amber-300">
You have used all 5 messages for the free Agent mode today. Check You have used all {messagesLimit} messages for the free Agent mode
back in {resetTimeDisplay} ({resetDateTime}). If you don't want to today. Check back in {resetTimeDisplay} ({resetDateTime}). If you
wait, upgrade to Dyad Pro or switch back to Build mode. don't want to wait, upgrade to Dyad Pro or switch back to Build
mode.
</p> </p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button onClick={handleUpgrade} size="sm" className="gap-1.5"> <Button onClick={handleUpgrade} size="sm" className="gap-1.5">
......
...@@ -3,6 +3,7 @@ import { ipc, type FreeAgentQuotaStatus } from "@/ipc/types"; ...@@ -3,6 +3,7 @@ import { ipc, type FreeAgentQuotaStatus } from "@/ipc/types";
import { queryKeys } from "@/lib/queryKeys"; import { queryKeys } from "@/lib/queryKeys";
import { useSettings } from "./useSettings"; import { useSettings } from "./useSettings";
import { isDyadProEnabled } from "@/lib/schemas"; import { isDyadProEnabled } from "@/lib/schemas";
import { FREE_AGENT_QUOTA_LIMIT } from "@/lib/free_agent_quota_limit";
const THIRTY_MINUTES_IN_MS = 30 * 60 * 1000; const THIRTY_MINUTES_IN_MS = 30 * 60 * 1000;
// In test mode, use very short staleTime for faster E2E tests // In test mode, use very short staleTime for faster E2E tests
...@@ -53,10 +54,10 @@ export function useFreeAgentQuota() { ...@@ -53,10 +54,10 @@ export function useFreeAgentQuota() {
// Convenience properties for easier consumption // Convenience properties for easier consumption
isQuotaExceeded: quotaStatus?.isQuotaExceeded ?? false, isQuotaExceeded: quotaStatus?.isQuotaExceeded ?? false,
messagesUsed: quotaStatus?.messagesUsed ?? 0, messagesUsed: quotaStatus?.messagesUsed ?? 0,
messagesLimit: quotaStatus?.messagesLimit ?? 5, messagesLimit: quotaStatus?.messagesLimit ?? FREE_AGENT_QUOTA_LIMIT,
messagesRemaining: quotaStatus messagesRemaining: quotaStatus
? Math.max(0, quotaStatus.messagesLimit - quotaStatus.messagesUsed) ? Math.max(0, quotaStatus.messagesLimit - quotaStatus.messagesUsed)
: 5, : FREE_AGENT_QUOTA_LIMIT,
hoursUntilReset: quotaStatus?.hoursUntilReset ?? null, hoursUntilReset: quotaStatus?.hoursUntilReset ?? null,
resetTime: quotaStatus?.resetTime ?? null, resetTime: quotaStatus?.resetTime ?? null,
}; };
......
...@@ -6,6 +6,7 @@ import { freeAgentQuotaContracts } from "../types/free_agent_quota"; ...@@ -6,6 +6,7 @@ import { freeAgentQuotaContracts } from "../types/free_agent_quota";
import log from "electron-log"; import log from "electron-log";
import { ipcMain } from "electron"; import { ipcMain } from "electron";
import { IS_TEST_BUILD } from "../utils/test_utils"; import { IS_TEST_BUILD } from "../utils/test_utils";
import { FREE_AGENT_QUOTA_LIMIT } from "@/lib/free_agent_quota_limit";
import fetch from "node-fetch"; import fetch from "node-fetch";
const logger = log.scope("free_agent_quota_handlers"); const logger = log.scope("free_agent_quota_handlers");
...@@ -61,8 +62,7 @@ async function getServerTime(): Promise<number> { ...@@ -61,8 +62,7 @@ async function getServerTime(): Promise<number> {
} }
} }
/** Maximum number of free agent messages per 24-hour window */ export { FREE_AGENT_QUOTA_LIMIT };
export const FREE_AGENT_QUOTA_LIMIT = 5;
/** /**
* Duration of the quota window in milliseconds (23 hours). * Duration of the quota window in milliseconds (23 hours).
...@@ -138,7 +138,7 @@ export async function unmarkMessageAsUsingFreeAgentQuota( ...@@ -138,7 +138,7 @@ export async function unmarkMessageAsUsingFreeAgentQuota(
* Gets the current free agent quota status. * Gets the current free agent quota status.
* Exported for use in chat stream handlers. * Exported for use in chat stream handlers.
* *
* Quota behavior: All 5 messages are released at once when 24 hours have passed * Quota behavior: All quota messages are released at once when 24 hours have passed
* since the oldest message was sent (not a rolling window). * since the oldest message was sent (not a rolling window).
*/ */
export async function getFreeAgentQuotaStatus() { export async function getFreeAgentQuotaStatus() {
...@@ -164,7 +164,7 @@ export async function getFreeAgentQuotaStatus() { ...@@ -164,7 +164,7 @@ export async function getFreeAgentQuotaStatus() {
} }
// Check if the oldest message is >= 24 hours old // Check if the oldest message is >= 24 hours old
// If so, all 5 messages are released at once (quota resets) // If so, all quota messages are released at once (quota resets)
// Uses server time to prevent clock manipulation cheating // Uses server time to prevent clock manipulation cheating
const oldestMessage = quotaMessages[0]; const oldestMessage = quotaMessages[0];
const windowStartTime = oldestMessage.createdAt.getTime(); const windowStartTime = oldestMessage.createdAt.getTime();
......
...@@ -27,7 +27,7 @@ export type FreeAgentQuotaStatus = z.infer<typeof FreeAgentQuotaStatusSchema>; ...@@ -27,7 +27,7 @@ export type FreeAgentQuotaStatus = z.infer<typeof FreeAgentQuotaStatusSchema>;
/** /**
* Free agent quota contracts define the IPC interface for managing * Free agent quota contracts define the IPC interface for managing
* the 5-message-per-day quota for non-Pro users using Basic Agent mode. * the Basic Agent per-window message quota for non-Pro users.
*/ */
export const freeAgentQuotaContracts = { export const freeAgentQuotaContracts = {
/** /**
......
/** Maximum number of Basic Agent (free tier) messages per quota window */
export const FREE_AGENT_QUOTA_LIMIT = 10;
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论