diff --git a/.env.example b/.env.example index 79c90efae..4e901db56 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,8 @@ # Desktop edition (cn or intl; defaults to intl) MEMMY_APP_EDITION=intl -# Cloud service -MEMMY_CLOUD_SERVICE=https://memmy-api.memtensor.cn +# Cloud service origin (clients append /api/... paths) +MEMMY_CLOUD_SERVICE=https://api.memmy.cn # Analytics (GA4) MEMMY_GA4_MEASUREMENT_ID= diff --git a/.github/release-notes/v1.1.8.md b/.github/release-notes/v1.1.8.md new file mode 100644 index 000000000..694c9355f --- /dev/null +++ b/.github/release-notes/v1.1.8.md @@ -0,0 +1,13 @@ +# Memmy v1.1.8 + +## Highlights + +- Fixed Windows startup lifecycle handling reported by MEMMY-495. +- Fixed Windows memory-service cleanup and update blocking reported by MEMMY-504 and MEMMY-494. +- Persisted monotonic WebUI transcript event timestamps reported by MEMMY-434. + +## Upgrade notes + +- Memmy Desktop and Agent are version 1.1.8. +- The bundled Memory service, viewer, and CLI remain independently versioned. +- The Memory protocol remains version 1. diff --git a/App/backend/local-api-contracts/src/index.ts b/App/backend/local-api-contracts/src/index.ts index 98d03d49f..1d9331291 100644 --- a/App/backend/local-api-contracts/src/index.ts +++ b/App/backend/local-api-contracts/src/index.ts @@ -592,6 +592,35 @@ export const PromotionFlagsSchema = z.object({ }); export type PromotionFlags = z.infer; +export const LotteryStatusSchema = z.object({ + shouldShow: z.boolean(), + startAt: z.number().int().nonnegative(), + endAt: z.number().int().nonnegative(), + serverNow: z.number().int().nonnegative(), + landingUrl: z.string().url() +}).refine((status) => status.endAt > status.startAt, { + message: "Lottery endAt must be later than startAt", + path: ["endAt"] +}); +export type LotteryStatus = z.infer; + +export const LotteryRewardSchema = z.discriminatedUnion("hasReward", [ + z.object({ + hasReward: z.literal(false) + }), + z.object({ + hasReward: z.literal(true), + drawId: z.string().min(1).optional(), + tokenAmount: z.number().int().positive() + }) +]); +export type LotteryReward = z.infer; + +export const LotteryRewardAckInputSchema = z.object({ + drawId: z.string().min(1).optional() +}); +export type LotteryRewardAckInput = z.infer; + export const AppBootstrapResponseSchema = z.object({ app: AppSettingsDtoSchema, onboarding: OnboardingStateDtoSchema, @@ -611,7 +640,8 @@ export const AppBootstrapResponseSchema = z.object({ legal: LegalAgreementUrlsSchema.optional(), // Src module. // Promotions. - promotions: PromotionFlagsSchema.optional() + promotions: PromotionFlagsSchema.optional(), + lotteryStatus: LotteryStatusSchema.optional() }); export type AppBootstrapResponse = z.infer; diff --git a/App/backend/src/adapters/inbound/local-api/routes/account.ts b/App/backend/src/adapters/inbound/local-api/routes/account.ts index 2a2816a49..c84671bc3 100644 --- a/App/backend/src/adapters/inbound/local-api/routes/account.ts +++ b/App/backend/src/adapters/inbound/local-api/routes/account.ts @@ -5,6 +5,8 @@ import { AccountProfileViewSchema, AccountSessionViewSchema, AvatarOptionSchema, + LotteryRewardAckInputSchema, + LotteryRewardSchema, OkResponseSchema, SendCodeInputSchema, SendCodeResponseSchema, @@ -122,6 +124,25 @@ export function registerAccountRoutes(app: FastifyInstance, options: RegisterAcc }) ); + app.get( + "/api/account/lottery/reward", + { preHandler: options.authenticateRuntimeToken }, + withErrorEnvelope(async (_request, reply) => { + const response = LotteryRewardSchema.parse(await options.account.getLotteryReward()); + return reply.send(response); + }) + ); + + app.post( + "/api/account/lottery/reward/ack", + { preHandler: options.authenticateRuntimeToken }, + withErrorEnvelope(async (request, reply) => { + const input = LotteryRewardAckInputSchema.parse(request.body); + const response = OkResponseSchema.parse(await options.account.ackLotteryReward(input)); + return reply.send(response); + }) + ); + app.get( "/api/account/avatars", { preHandler: options.authenticateRuntimeToken }, diff --git a/App/backend/src/adapters/inbound/local-api/tests/account-routes.test.ts b/App/backend/src/adapters/inbound/local-api/tests/account-routes.test.ts index 64b74c9da..f8557ffbf 100644 --- a/App/backend/src/adapters/inbound/local-api/tests/account-routes.test.ts +++ b/App/backend/src/adapters/inbound/local-api/tests/account-routes.test.ts @@ -106,6 +106,34 @@ describe("account local api routes", () => { ]); }); + it("forwards lottery reward lookup and acknowledgement through AccountService", async () => { + const calls: unknown[] = []; + app = createServer({ + account: { + async getLotteryReward() { + calls.push("get"); + return { hasReward: true as const, drawId: "1", tokenAmount: 500_000 }; + }, + async ackLotteryReward(input: unknown) { + calls.push({ ack: input }); + return { ok: true }; + } + } + }); + + const reward = await app.inject({ + method: "GET", + url: "/api/account/lottery/reward", + headers: { "x-memmy-local-token": "test-token" } + }); + const ack = await injectJson("POST", "/api/account/lottery/reward/ack", { drawId: "1" }); + + expect(reward.statusCode).toBe(200); + expect(reward.json()).toEqual({ hasReward: true, drawId: "1", tokenAmount: 500_000 }); + expect(ack.json()).toEqual({ ok: true }); + expect(calls).toEqual(["get", { ack: { drawId: "1" } }]); + }); + it("lists avatars and stores the selected avatar behind the runtime token", async () => { const calls: string[] = []; app = createServer({ diff --git a/App/backend/src/adapters/outbound/cloud-client/http-cloud-client.ts b/App/backend/src/adapters/outbound/cloud-client/http-cloud-client.ts index a6beda24e..6eb1c4e66 100644 --- a/App/backend/src/adapters/outbound/cloud-client/http-cloud-client.ts +++ b/App/backend/src/adapters/outbound/cloud-client/http-cloud-client.ts @@ -9,6 +9,8 @@ import { LegalAgreementUrlsSchema, IntegrationToolResultSchema, InvitationResultSchema, + LotteryRewardSchema, + LotteryStatusSchema, OkResponseSchema, PromotionFlagsSchema, QWEN_ASR_MODEL_ID, @@ -20,6 +22,8 @@ import { type IntegrationConnection, type IntegrationConnectionsResponse, type LegalAgreementUrls, + type LotteryReward, + type LotteryStatus, type IntegrationToolResult, type OkResponse, type PromotionFlags, @@ -45,10 +49,12 @@ import type { CloudStartSocialLoginResult, CloudLogoutInput, GetAccountInfoInput, + GetLotteryRewardInput, EnsureInvitationCodeInput, GetTokenQuotaEligibilityInput, GetTokenUsageInput, GrantTokensInput, + AckLotteryRewardInput, ReleaseCheckResult, RequestTokenQuotaInput, TokenQuotaApplyResult, @@ -414,6 +420,36 @@ export function createHttpCloudClient(options: CreateHttpCloudClientOptions = {} } catch { return undefined; } + }, + + async getLotteryStatus(): Promise { + try { + const data = await requestCloudData(fetchImpl, baseUrl, timeoutMs, "/api/memmy/lottery/status", { + method: "GET", + lang: "zh" + }); + const parsed = LotteryStatusSchema.safeParse(data); + return parsed.success ? parsed.data : undefined; + } catch { + return undefined; + } + }, + + async getLotteryReward(input: GetLotteryRewardInput): Promise { + const data = await requestCloudData(fetchImpl, baseUrl, timeoutMs, "/api/memmy/lottery/reward", { + method: "GET", + lang: "zh", + bearerCredential: input.uuid + }); + return LotteryRewardSchema.parse(data); + }, + + async ackLotteryReward(input: AckLotteryRewardInput): Promise { + await requestBoolean(fetchImpl, baseUrl, timeoutMs, "/api/memmy/lottery/reward/ack", { + body: input.drawId ? { drawId: input.drawId } : {}, + lang: "zh", + bearerCredential: input.uuid + }); } }; } diff --git a/App/backend/src/adapters/outbound/cloud-client/index.ts b/App/backend/src/adapters/outbound/cloud-client/index.ts index 72c917b5f..4da38d507 100644 --- a/App/backend/src/adapters/outbound/cloud-client/index.ts +++ b/App/backend/src/adapters/outbound/cloud-client/index.ts @@ -1,5 +1,6 @@ export { createHttpCloudClient, type CreateHttpCloudClientOptions } from "./http-cloud-client.js"; export type { + AckLotteryRewardInput, CheckReleaseInput, CloudAsrTranscriptionInput, CloudAsrTranscriptionResult, @@ -18,6 +19,7 @@ export type { CloudStartSocialLoginResult, CloudLogoutInput, GetAccountInfoInput, + GetLotteryRewardInput, GetTokenQuotaEligibilityInput, GetTokenUsageInput, GrantTokensInput, diff --git a/App/backend/src/adapters/outbound/cloud-client/tests/cloud-client.test.ts b/App/backend/src/adapters/outbound/cloud-client/tests/cloud-client.test.ts index 9228c7d09..498655008 100644 --- a/App/backend/src/adapters/outbound/cloud-client/tests/cloud-client.test.ts +++ b/App/backend/src/adapters/outbound/cloud-client/tests/cloud-client.test.ts @@ -1101,6 +1101,112 @@ describe("cloud client", () => { await expect(client.getPromotions()).resolves.toBeUndefined(); }); + it("http client fetches the remote lottery status", async () => { + const requests: Array<{ path: string; method: string | undefined }> = []; + const lotteryStatus = { + shouldShow: true, + startAt: 1790121600000, + endAt: 1790812800000, + serverNow: 1790456789000, + landingUrl: "https://memmy.cn/activity/mid-autumn" + }; + server = createServer((request, response) => { + requests.push({ + path: request.url ?? "", + method: request.method + }); + sendJson(response, { code: 0, message: "ok", data: lotteryStatus }); + }); + await listen(server); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Mock cloud server did not bind to a port"); + } + const client = createHttpCloudClient({ baseUrl: `http://127.0.0.1:${address.port}`, timeoutMs: 1000 }); + + await expect(client.getLotteryStatus()).resolves.toEqual(lotteryStatus); + expect(requests).toEqual([{ + path: "/api/memmy/lottery/status", + method: "GET" + }]); + }); + + it("http client fails closed when the lottery status is invalid", async () => { + server = createServer((_request, response) => { + sendJson(response, { + code: 0, + message: "ok", + data: { + shouldShow: true, + startAt: 1790121600000, + endAt: 1790812800000 + } + }); + }); + await listen(server); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Mock cloud server did not bind to a port"); + } + const client = createHttpCloudClient({ baseUrl: `http://127.0.0.1:${address.port}`, timeoutMs: 1000 }); + + await expect(client.getLotteryStatus()).resolves.toBeUndefined(); + }); + + it("http client reads and acknowledges a lottery reward with account authorization", async () => { + const requests: Array<{ + path: string; + method: string | undefined; + body: unknown; + authorization: string | undefined; + }> = []; + server = createServer(async (request, response) => { + requests.push({ + path: request.url ?? "", + method: request.method, + body: await readJson(request), + authorization: request.headers.authorization + }); + sendJson(response, request.url === "/api/memmy/lottery/reward" + ? { + code: 0, + message: "ok", + data: { hasReward: true, drawId: "1", tokenAmount: 500_000 } + } + : { code: 0, message: "ok", data: true }); + }); + await listen(server); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Mock cloud server did not bind to a port"); + } + const client = createHttpCloudClient({ baseUrl: `http://127.0.0.1:${address.port}`, timeoutMs: 1000 }); + + await expect(client.getLotteryReward({ uuid: "cloud.login.uuid" })).resolves.toEqual({ + hasReward: true, + drawId: "1", + tokenAmount: 500_000 + }); + await expect(client.ackLotteryReward({ + uuid: "cloud.login.uuid", + drawId: "1" + })).resolves.toBeUndefined(); + expect(requests).toEqual([ + { + path: "/api/memmy/lottery/reward", + method: "GET", + body: {}, + authorization: "Bearer cloud.login.uuid" + }, + { + path: "/api/memmy/lottery/reward/ack", + method: "POST", + body: { drawId: "1" }, + authorization: "Bearer cloud.login.uuid" + } + ]); + }); + }); function listen(serverToListen: ReturnType): Promise { diff --git a/App/backend/src/adapters/outbound/cloud-client/types.ts b/App/backend/src/adapters/outbound/cloud-client/types.ts index 37a00f448..95909dedc 100644 --- a/App/backend/src/adapters/outbound/cloud-client/types.ts +++ b/App/backend/src/adapters/outbound/cloud-client/types.ts @@ -9,6 +9,8 @@ import type { LegalAgreementUrls, IntegrationToolResult, InvitationResult, + LotteryReward, + LotteryStatus, OkResponse, PromotionFlags, SocialLoginProvider, @@ -129,6 +131,15 @@ export interface GrantTokensInput { uuid?: string; } +export interface GetLotteryRewardInput { + uuid: string; +} + +export interface AckLotteryRewardInput { + uuid: string; + drawId?: string; +} + /** Contract for request token quota input. */ export interface RequestTokenQuotaInput { /** Uuid. */ @@ -254,4 +265,10 @@ export interface CloudClient { getLegalUrls(): Promise; /** Reads get promotions. */ getPromotions(): Promise; + /** Reads the remotely controlled lottery campaign status. */ + getLotteryStatus(): Promise; + /** Reads the latest unacknowledged lottery reward for the account. */ + getLotteryReward(input: GetLotteryRewardInput): Promise; + /** Marks the latest matching lottery reward as shown. */ + ackLotteryReward(input: AckLotteryRewardInput): Promise; } diff --git a/App/backend/src/load-env.ts b/App/backend/src/load-env.ts index c6397e99e..f232128ce 100644 --- a/App/backend/src/load-env.ts +++ b/App/backend/src/load-env.ts @@ -34,15 +34,6 @@ export interface LoadCloudServiceEnvOptions { /** Loads the public cloud-service origin without allowing packaged raw env files. */ export function loadCloudServiceEnv(options: LoadCloudServiceEnvOptions = {}): string | null { const env = options.env ?? process.env; - if (Object.prototype.hasOwnProperty.call(env, "MEMMY_CLOUD_SERVICE")) { - const externalValue = env.MEMMY_CLOUD_SERVICE?.trim(); - if (externalValue) { - env.MEMMY_CLOUD_SERVICE = externalValue; - return "environment"; - } - delete env.MEMMY_CLOUD_SERVICE; - } - if (options.manifestPath !== undefined) { if (!existsSync(options.manifestPath)) { throw new Error("Packaged desktop runtime manifest is missing"); @@ -54,9 +45,21 @@ export function loadCloudServiceEnv(options: LoadCloudServiceEnvOptions = {}): s throw new Error("Packaged desktop runtime manifest cloud service must use HTTPS"); } env.MEMMY_CLOUD_SERVICE = manifestService; + // MEMMY_CLOUD_URL is a development-only override. Do not let a stale + // value inherited from an older installation redirect a packaged app. + delete env.MEMMY_CLOUD_URL; return options.manifestPath; } + if (Object.prototype.hasOwnProperty.call(env, "MEMMY_CLOUD_SERVICE")) { + const externalValue = env.MEMMY_CLOUD_SERVICE?.trim(); + if (externalValue) { + env.MEMMY_CLOUD_SERVICE = externalValue; + return "environment"; + } + delete env.MEMMY_CLOUD_SERVICE; + } + const moduleDir = options.moduleDir ?? dirname(fileURLToPath(import.meta.url)); const envPath = findRepoEnvFile(options.cwd ?? process.cwd()) ?? findRepoEnvFile(moduleDir); if (envPath) { diff --git a/App/backend/src/project-version.ts b/App/backend/src/project-version.ts index df3b4098a..913ec8dcd 100644 --- a/App/backend/src/project-version.ts +++ b/App/backend/src/project-version.ts @@ -1,2 +1,2 @@ /** Generated from the root package.json by scripts/sync-project-version.mjs. */ -export const MEMMY_VERSION = "1.1.7"; +export const MEMMY_VERSION = "1.1.8"; diff --git a/App/backend/src/services/account-service.ts b/App/backend/src/services/account-service.ts index 99beb8c5c..84178908b 100644 --- a/App/backend/src/services/account-service.ts +++ b/App/backend/src/services/account-service.ts @@ -4,6 +4,7 @@ import { AccountLoginResultViewSchema, AccountProfileViewSchema, AccountSessionViewSchema, + LotteryRewardSchema, SendCodeResponseSchema, SocialLoginStatusResponseSchema, StartSocialLoginResponseSchema, @@ -12,6 +13,8 @@ import { type AccountLoginResultView, type AccountProfileView, type AccountSessionView, + type LotteryReward, + type LotteryRewardAckInput, type SendCodeInput, type SendCodeResponse, type SocialLoginStatusInput, @@ -39,6 +42,8 @@ export interface AccountService { startSocialLogin(input: StartSocialLoginInput): Promise; getSocialLoginStatus(input: SocialLoginStatusInput): Promise; getInvitation(): Promise; + getLotteryReward(): Promise; + ackLotteryReward(input: LotteryRewardAckInput): Promise; updateProfile(input: UpdateAccountProfileInput): Promise; markGuideFinished(): Promise; logout(): Promise; @@ -136,6 +141,32 @@ export function createAccountService(options: CreateAccountServiceOptions): Acco ); }, + async getLotteryReward() { + const uuid = options.accountSessionRepository.getCloudUuid(); + if (!uuid) { + return { hasReward: false }; + } + try { + return LotteryRewardSchema.parse(await options.cloudClient.getLotteryReward({ uuid })); + } catch { + return { hasReward: false }; + } + }, + + async ackLotteryReward(input) { + const uuid = options.accountSessionRepository.getCloudUuid(); + if (!uuid) { + throw Object.assign(new Error("Account session is not authenticated"), { + code: "unauthorized" as const + }); + } + await options.cloudClient.ackLotteryReward({ + uuid, + ...(input.drawId ? { drawId: input.drawId } : {}) + }); + return { ok: true }; + }, + async updateProfile(input) { const session = options.accountSessionRepository.get(); if (!session.authenticated) { diff --git a/App/backend/src/services/bootstrap-service.ts b/App/backend/src/services/bootstrap-service.ts index 2d21fd9b3..ee04bee34 100644 --- a/App/backend/src/services/bootstrap-service.ts +++ b/App/backend/src/services/bootstrap-service.ts @@ -5,6 +5,7 @@ import { type AppSettingsDto, type HealthStatus, type LegalAgreementUrls, + type LotteryStatus, type OnboardingStateDto, type PromotionFlags, type TokenUsageDto @@ -34,12 +35,13 @@ export function createBootstrapService(options: CreateBootstrapServiceOptions): const bootstrap = options.appStateStore.repositories.bootstrap; const appSettings = bootstrap.getAppSettings(); const onboarding = await reconcileImprovementProgram(options, appSettings, bootstrap.getOnboardingState()); - const [memoryHealth, cloudHealth, tokenUsage, legal, promotions] = await Promise.all([ + const [memoryHealth, cloudHealth, tokenUsage, legal, promotions, lotteryStatus] = await Promise.all([ getMemoryHealth(options.memoryClient), getCloudHealth(options.cloudClient), refreshTokenUsage(options), getLegalUrls(options.cloudClient), - getPromotions(options.cloudClient) + getPromotions(options.cloudClient), + getLotteryStatus(options.cloudClient) ]); return AppBootstrapResponseSchema.parse({ @@ -62,7 +64,8 @@ export function createBootstrapService(options: CreateBootstrapServiceOptions): cloud: cloudHealth.status }, ...(legal ? { legal } : {}), - promotions + promotions, + ...(lotteryStatus ? { lotteryStatus } : {}) }); } }; @@ -166,6 +169,14 @@ async function getPromotions(cloudClient: CloudClient): Promise } } +async function getLotteryStatus(cloudClient: CloudClient): Promise { + try { + return await cloudClient.getLotteryStatus(); + } catch { + return undefined; + } +} + async function getMemoryHealth(memoryClient: MemoryClient): Promise { try { const health = await memoryClient.health(); diff --git a/App/backend/src/services/tests/account-service.test.ts b/App/backend/src/services/tests/account-service.test.ts index 180105a4c..95d1dc4c8 100644 --- a/App/backend/src/services/tests/account-service.test.ts +++ b/App/backend/src/services/tests/account-service.test.ts @@ -28,6 +28,75 @@ function createAccountService(options: TestAccountServiceOptions) { } describe("AccountService", () => { + it("treats a logged-out lottery reward lookup as no reward", async () => { + let cloudCalls = 0; + const service = createAccountService({ + cloudClient: { + ...createCloudClientStub(), + async getLotteryReward() { + cloudCalls += 1; + return { hasReward: true as const, drawId: "1", tokenAmount: 500_000 }; + } + }, + accountSessionRepository: createAccountSessionRepositoryStub() + }); + + await expect(service.getLotteryReward()).resolves.toEqual({ hasReward: false }); + expect(cloudCalls).toBe(0); + }); + + it("reads and acknowledges lottery rewards with the current cloud credential", async () => { + const calls: unknown[] = []; + const service = createAccountService({ + cloudClient: { + ...createCloudClientStub(), + async getLotteryReward(input) { + calls.push({ get: input }); + return { hasReward: true as const, drawId: "1", tokenAmount: 500_000 }; + }, + async ackLotteryReward(input) { + calls.push({ ack: input }); + } + }, + accountSessionRepository: { + ...createAccountSessionRepositoryStub(), + getCloudUuid() { + return "cloud.login.uuid"; + } + } + }); + + await expect(service.getLotteryReward()).resolves.toEqual({ + hasReward: true, + drawId: "1", + tokenAmount: 500_000 + }); + await expect(service.ackLotteryReward({ drawId: "1" })).resolves.toEqual({ ok: true }); + expect(calls).toEqual([ + { get: { uuid: "cloud.login.uuid" } }, + { ack: { uuid: "cloud.login.uuid", drawId: "1" } } + ]); + }); + + it("fails closed when the cloud lottery reward lookup is unavailable", async () => { + const service = createAccountService({ + cloudClient: { + ...createCloudClientStub(), + async getLotteryReward() { + throw Object.assign(new Error("unauthorized"), { code: "unauthorized" as const }); + } + }, + accountSessionRepository: { + ...createAccountSessionRepositoryStub(), + getCloudUuid() { + return "stale.cloud.uuid"; + } + } + }); + + await expect(service.getLotteryReward()).resolves.toEqual({ hasReward: false }); + }); + it("rejects verification channels that are not supported by the desktop package", async () => { let cloudCalls = 0; const service = createAccountService({ @@ -1071,6 +1140,12 @@ function createCloudClientStub() { async grantImprovementProgramTokens() { return this.getTokenUsage({}); }, + async getLotteryReward() { + return { hasReward: false as const }; + }, + async ackLotteryReward() { + return undefined; + }, async sendTelemetry() { return undefined; }, diff --git a/App/backend/src/services/tests/bootstrap-service.test.ts b/App/backend/src/services/tests/bootstrap-service.test.ts index 866d6dbc9..d0e1f5e48 100644 --- a/App/backend/src/services/tests/bootstrap-service.test.ts +++ b/App/backend/src/services/tests/bootstrap-service.test.ts @@ -4,6 +4,25 @@ import type { AppStateStore } from "../../infrastructure/app-state-store/index.j import { createBootstrapService } from "../bootstrap-service.js"; describe("BootstrapService", () => { + it("includes the remote lottery status in bootstrap for logged-out clients", async () => { + const lotteryStatus = { + shouldShow: true, + startAt: 1790121600000, + endAt: 1790812800000, + serverNow: 1790456789000, + landingUrl: "https://memmy.cn/activity/mid-autumn" + }; + const service = createBootstrapService(createUnauthenticatedOptions({ + async getLotteryStatus() { + return lotteryStatus; + } + })); + + await expect(service.getBootstrap()).resolves.toMatchObject({ + lotteryStatus + }); + }); + it("refreshes token usage from cloud for authenticated account sessions", async () => { const calls: unknown[] = []; const cloudTokenUsage = { diff --git a/App/backend/src/tests/load-env.test.ts b/App/backend/src/tests/load-env.test.ts index 7e2322a2f..042890e1f 100644 --- a/App/backend/src/tests/load-env.test.ts +++ b/App/backend/src/tests/load-env.test.ts @@ -2,6 +2,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { resolveCloudClientConfig } from "../config/service-urls.js"; import { loadCloudServiceEnv } from "../load-env.js"; const roots: string[] = []; @@ -11,13 +12,18 @@ afterEach(() => { }); describe("backend cloud-service env loading", () => { - it("keeps an explicit environment origin ahead of the packaged manifest", () => { + it("uses the packaged manifest instead of an inherited environment origin", () => { const root = fixtureRoot(); const manifestPath = writeManifest(root, "https://manifest.example.test"); - const env = { MEMMY_CLOUD_SERVICE: "https://external.example.test" }; + const env = { + MEMMY_CLOUD_SERVICE: "https://external.example.test", + MEMMY_CLOUD_URL: "https://stale.example.test" + }; - expect(loadCloudServiceEnv({ env, manifestPath })).toBe("environment"); - expect(env.MEMMY_CLOUD_SERVICE).toBe("https://external.example.test"); + expect(loadCloudServiceEnv({ env, manifestPath })).toBe(manifestPath); + expect(env.MEMMY_CLOUD_SERVICE).toBe("https://manifest.example.test"); + expect(env.MEMMY_CLOUD_URL).toBeUndefined(); + expect(resolveCloudClientConfig(env).baseUrl).toBe("https://manifest.example.test"); }); it("loads only the allowlisted cloud service from a packaged manifest", () => { diff --git a/App/backend/src/tests/local-app-contracts.test.ts b/App/backend/src/tests/local-app-contracts.test.ts index 050dc5584..6a0623f62 100644 --- a/App/backend/src/tests/local-app-contracts.test.ts +++ b/App/backend/src/tests/local-app-contracts.test.ts @@ -21,6 +21,9 @@ import { LocalDataClearResponseSchema, LocalDataExportResponseSchema, LocalDataRevealResponseSchema, + LotteryRewardAckInputSchema, + LotteryRewardSchema, + LotteryStatusSchema, ImageGenModelConfigInputSchema, ImageGenModelConfigViewSchema, ModelConfigInputSchema, @@ -543,6 +546,45 @@ describe("local app contracts", () => { }); }); + it("parses the remote lottery status contract", () => { + expect(LotteryStatusSchema.parse({ + shouldShow: true, + startAt: 1790121600000, + endAt: 1790812800000, + serverNow: 1790456789000, + landingUrl: "https://memmy.cn/activity/mid-autumn" + })).toEqual({ + shouldShow: true, + startAt: 1790121600000, + endAt: 1790812800000, + serverNow: 1790456789000, + landingUrl: "https://memmy.cn/activity/mid-autumn" + }); + expect(() => LotteryStatusSchema.parse({ + shouldShow: true, + startAt: 1790812800000, + endAt: 1790121600000, + serverNow: 1790456789000, + landingUrl: "https://memmy.cn/activity/mid-autumn" + })).toThrow(); + }); + + it("parses lottery reward and acknowledgement contracts", () => { + expect(LotteryRewardSchema.parse({ hasReward: false })).toEqual({ hasReward: false }); + expect(LotteryRewardSchema.parse({ + hasReward: true, + drawId: "1", + tokenAmount: 500_000 + })).toEqual({ + hasReward: true, + drawId: "1", + tokenAmount: 500_000 + }); + expect(LotteryRewardAckInputSchema.parse({ drawId: "1" })).toEqual({ drawId: "1" }); + expect(LotteryRewardAckInputSchema.parse({})).toEqual({}); + expect(() => LotteryRewardSchema.parse({ hasReward: true, tokenAmount: 0 })).toThrow(); + }); + it("parses tool integration contracts", () => { const listItem = IntegrationListItemSchema.parse({ id: "wechat", diff --git a/App/backend/src/tests/support/mock-cloud-client.ts b/App/backend/src/tests/support/mock-cloud-client.ts index 2c3e36b0e..62fbfb50d 100644 --- a/App/backend/src/tests/support/mock-cloud-client.ts +++ b/App/backend/src/tests/support/mock-cloud-client.ts @@ -25,7 +25,7 @@ import type { UpdateAccountGuideInput, UpdateCloudAccountProfileInput } from "../../adapters/outbound/cloud-client/types.js"; -import type { LegalAgreementUrls, PromotionFlags } from "@memmy/local-api-contracts"; +import type { LegalAgreementUrls, LotteryStatus, PromotionFlags } from "@memmy/local-api-contracts"; export interface CreateMockCloudClientOptions { health?: CloudHealth; @@ -35,6 +35,8 @@ export interface CreateMockCloudClientOptions { legal?: LegalAgreementUrls; /** Promotions. */ promotions?: PromotionFlags; + /** Lottery campaign status. */ + lotteryStatus?: LotteryStatus; } export function createMockCloudClient(options: CreateMockCloudClientOptions = {}): CloudClient { @@ -225,6 +227,18 @@ export function createMockCloudClient(options: CreateMockCloudClientOptions = {} async getPromotions() { return options.promotions; + }, + + async getLotteryStatus() { + return options.lotteryStatus; + }, + + async getLotteryReward() { + return { hasReward: false }; + }, + + async ackLotteryReward() { + return undefined; } }; } diff --git a/App/frontend/desktop/src/api/account-client.ts b/App/frontend/desktop/src/api/account-client.ts index e6a2e310e..18c141796 100644 --- a/App/frontend/desktop/src/api/account-client.ts +++ b/App/frontend/desktop/src/api/account-client.ts @@ -3,6 +3,8 @@ import { AccountLoginResultViewSchema, AccountProfileViewSchema, AccountSessionViewSchema, + LotteryRewardAckInputSchema, + LotteryRewardSchema, OkResponseSchema, SendCodeInputSchema, SendCodeResponseSchema, @@ -16,6 +18,8 @@ import { type AccountLoginResultView, type AccountProfileView, type AccountSessionView, + type LotteryReward, + type LotteryRewardAckInput, type OkResponse, type RuntimeConfig, type SendCodeInput, @@ -63,6 +67,8 @@ export interface AccountClient { startSocialLogin(input: StartSocialLoginInput): Promise; getSocialLoginStatus(input: SocialLoginStatusInput): Promise; getInvitation(): Promise; + getLotteryReward(): Promise; + ackLotteryReward(input: LotteryRewardAckInput): Promise; updateProfile(input: UpdateAccountProfileInput): Promise; markGuideFinished(): Promise; logout(): Promise; @@ -116,6 +122,24 @@ export function createHttpAccountClient(config: RuntimeConfig): AccountClient { }); }, + async getLotteryReward() { + return requestJson({ + config, + path: "/api/account/lottery/reward", + schema: LotteryRewardSchema + }); + }, + + async ackLotteryReward(input) { + return requestJson({ + config, + path: "/api/account/lottery/reward/ack", + schema: OkResponseSchema, + init: { method: "POST" }, + body: LotteryRewardAckInputSchema.parse(input) + }); + }, + async updateProfile(input) { return requestJson({ config, diff --git a/App/frontend/desktop/src/api/tests/account-client.test.ts b/App/frontend/desktop/src/api/tests/account-client.test.ts index 1764fc272..85cf6d9cf 100644 --- a/App/frontend/desktop/src/api/tests/account-client.test.ts +++ b/App/frontend/desktop/src/api/tests/account-client.test.ts @@ -85,6 +85,38 @@ describe("account-client", () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + it("读取并确认到账 Token 奖励", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + expect(init?.headers).toMatchObject({ + "x-memmy-local-token": "token" + }); + + if (url.endsWith("/api/account/lottery/reward")) { + expect(init?.method).toBe("GET"); + return jsonResponse({ hasReward: true, drawId: "1", tokenAmount: 500_000 }); + } + + if (url.endsWith("/api/account/lottery/reward/ack")) { + expect(init?.method).toBe("POST"); + expect(JSON.parse(String(init?.body))).toEqual({ drawId: "1" }); + return jsonResponse({ ok: true }); + } + + throw new Error(`unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + const client = createHttpAccountClient(config); + + await expect(client.getLotteryReward()).resolves.toEqual({ + hasReward: true, + drawId: "1", + tokenAmount: 500_000 + }); + await expect(client.ackLotteryReward({ drawId: "1" })).resolves.toEqual({ ok: true }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + it("通过真实本地 API 完成验证码发送、校验和昵称更新", async () => { const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); diff --git a/App/frontend/desktop/src/app.tsx b/App/frontend/desktop/src/app.tsx index f6ea86ae0..70796707a 100644 --- a/App/frontend/desktop/src/app.tsx +++ b/App/frontend/desktop/src/app.tsx @@ -16,8 +16,11 @@ import { import { AppProviders, useApiClients } from "./app/providers.js"; import { AppRouter } from "./app/router.js"; import { UpdateCoordinatorProvider } from "./app/update-coordinator.js"; +import { CampaignPromptHost } from "./components/campaign-prompt-host.js"; import { GithubStarPromptHost } from "./components/github-star-prompt-host.js"; import { InviteResultToast } from "./components/invite-result-toast.js"; +import { NotificationCenterProvider } from "./components/notification-center.js"; +import { TokenCreditToastHost } from "./components/token-credit-toast-host.js"; import { FOCUSED_AGENT_CHAT_STORAGE_KEY, readGuidanceCompleted, @@ -329,9 +332,12 @@ function RuntimeApp() { return ( - - - {state.invitationToast ? ( + + + + + + {state.invitationToast ? ( - ) : null} + ) : null} + ); diff --git a/App/frontend/desktop/src/app/campaign-prompt-state.ts b/App/frontend/desktop/src/app/campaign-prompt-state.ts new file mode 100644 index 000000000..6026ca7dc --- /dev/null +++ b/App/frontend/desktop/src/app/campaign-prompt-state.ts @@ -0,0 +1,173 @@ +/** Local persistence and eligibility for the Mid-Autumn campaign prompt. */ +import type { LotteryStatus, UserMode } from "@memmy/local-api-contracts"; + +/** Build-time safety switch layered on top of the remote campaign status. */ +export const CAMPAIGN_PROMPT_ENABLED = true; + +export const CAMPAIGN_PROMPT_STORAGE_KEY = "memmy.campaignPrompt.v1"; +export const CAMPAIGN_PROMPT_MAX_SHOWS = 1; + +export interface CampaignPromptPersistedState { + showCount: number; + dismissedAt: number | null; + actioned: boolean; +} + +const EMPTY_STATE: CampaignPromptPersistedState = { + showCount: 0, + dismissedAt: null, + actioned: false +}; + +let campaignPromptOpen = false; + +/** Whether the campaign prompt is currently on screen (hides the GitHub star card). */ +export function isCampaignPromptOpen(): boolean { + return campaignPromptOpen; +} + +/** Records whether the campaign prompt is currently rendered. */ +export function setCampaignPromptOpen(open: boolean): void { + campaignPromptOpen = open; +} + +/** Reads persisted campaign prompt state from storage. */ +export function readCampaignPromptState(storage: Storage | undefined): CampaignPromptPersistedState { + if (!storage) { + return { ...EMPTY_STATE }; + } + try { + const raw = storage.getItem(CAMPAIGN_PROMPT_STORAGE_KEY); + if (!raw) { + return { ...EMPTY_STATE }; + } + const parsed = JSON.parse(raw) as Partial; + return { + showCount: typeof parsed.showCount === "number" && parsed.showCount >= 0 ? Math.floor(parsed.showCount) : 0, + dismissedAt: typeof parsed.dismissedAt === "number" ? parsed.dismissedAt : null, + actioned: parsed.actioned === true + }; + } catch { + return { ...EMPTY_STATE }; + } +} + +/** Writes persisted campaign prompt state to storage. */ +export function writeCampaignPromptState( + storage: Storage | undefined, + state: CampaignPromptPersistedState +): void { + if (!storage) { + return; + } + storage.setItem(CAMPAIGN_PROMPT_STORAGE_KEY, JSON.stringify(state)); +} + +/** Whether the campaign prompt may be offered on this app open. */ +export function shouldOfferCampaignPrompt( + state: CampaignPromptPersistedState, + remoteStatus: LotteryStatus | null | undefined +): boolean { + if (!CAMPAIGN_PROMPT_ENABLED) { + return false; + } + if (!remoteStatus?.shouldShow) { + return false; + } + if (remoteStatus.serverNow < remoteStatus.startAt || remoteStatus.serverNow >= remoteStatus.endAt) { + return false; + } + if (state.actioned) { + return false; + } + if (state.showCount >= CAMPAIGN_PROMPT_MAX_SHOWS) { + return false; + } + return true; +} + +const CAMPAIGN_PROMPT_WORKSPACE_PATHS = new Set([ + "/main", + "/memory", + "/memory-sources", + "/knowledge", + "/tools", + "/settings" +]); + +/** Account needs a signed-in user. BYOK needs a saved Agent model. Both wait until guidance has settled. */ +export function isCampaignPromptSessionReady(input: { + userMode: UserMode | null | undefined; + accountUserId: string | null | undefined; + byokConfigured?: boolean; + guidanceDone?: boolean; +}): boolean { + if (input.guidanceDone !== true) { + return false; + } + if (input.userMode === "byok") { + return input.byokConfigured === true; + } + return input.userMode === "account" && Boolean(input.accountUserId); +} + +/** Guidance has settled when no deferred step is showing, and this machine finished the guide or onboarding is already complete. */ +export function isGuidanceDone(input: { + guidanceCompleted: boolean; + onboardingCompleted: boolean; + deferredGuidanceStep: string | null; +}): boolean { + if (input.deferredGuidanceStep != null) { + return false; + } + if (input.guidanceCompleted) { + return true; + } + return input.onboardingCompleted; +} + +/** Workspace routes that can show the campaign prompt after boot. Setup, login, onboarding, and the pet window wait. */ +export function isCampaignPromptSurfaceReady(input: { + startupStatus: string; + currentPath: string; +}): boolean { + return input.startupStatus === "ready" && CAMPAIGN_PROMPT_WORKSPACE_PATHS.has(input.currentPath); +} + +/** Records that the prompt was shown (counts toward the max of 1). */ +export function markCampaignPromptShown(storage: Storage | undefined): CampaignPromptPersistedState { + const current = readCampaignPromptState(storage); + const next: CampaignPromptPersistedState = { + ...current, + showCount: current.showCount + 1, + dismissedAt: null + }; + writeCampaignPromptState(storage, next); + return next; +} + +/** Records "maybe later". With a one-show cap, this does not reopen later. */ +export function markCampaignPromptDismissed( + storage: Storage | undefined, + nowMs: number = Date.now() +): CampaignPromptPersistedState { + const current = readCampaignPromptState(storage); + const next: CampaignPromptPersistedState = { + ...current, + dismissedAt: nowMs + }; + writeCampaignPromptState(storage, next); + return next; +} + +/** Records that the user opened the activity site — never show again. */ +export function markCampaignPromptActioned(storage: Storage | undefined): CampaignPromptPersistedState { + const current = readCampaignPromptState(storage); + const next: CampaignPromptPersistedState = { + ...current, + actioned: true, + dismissedAt: null + }; + writeCampaignPromptState(storage, next); + return next; +} diff --git a/App/frontend/desktop/src/app/campaign-prompt-url.ts b/App/frontend/desktop/src/app/campaign-prompt-url.ts new file mode 100644 index 000000000..a1d4c25d8 --- /dev/null +++ b/App/frontend/desktop/src/app/campaign-prompt-url.ts @@ -0,0 +1,20 @@ +import type { ResolvedLanguage } from "../i18n/messages.js"; + +/** Official activity page URL for the current app edition and language. */ +export function getCampaignActivityUrl(language: ResolvedLanguage): string { + const international = import.meta.env.MEMMY_APP_EDITION === "intl"; + const envKey = international ? "MEMMY_LEGAL_INTL_BASE_URL" : "MEMMY_LEGAL_CN_BASE_URL"; + const baseUrl = (international + ? import.meta.env.MEMMY_LEGAL_INTL_BASE_URL + : import.meta.env.MEMMY_LEGAL_CN_BASE_URL + )?.trim(); + + if (!baseUrl) { + throw new Error(`${envKey} is required.`); + } + + const localePrefix = international + ? (language === "zh-CN" ? "cn/" : "") + : (language === "en-US" ? "en/" : ""); + return new URL(`${localePrefix}activity/`, `${baseUrl.replace(/\/+$/, "")}/`).toString(); +} diff --git a/App/frontend/desktop/src/app/desktop-prompt-preview.ts b/App/frontend/desktop/src/app/desktop-prompt-preview.ts new file mode 100644 index 000000000..52da39982 --- /dev/null +++ b/App/frontend/desktop/src/app/desktop-prompt-preview.ts @@ -0,0 +1,14 @@ +export type DesktopPromptPreviewKind = "campaign" | "tokenCredit"; + +/** Dev-only query `?previewPrompt=campaign|tokenCredit|all` to force-show desktop prompts. */ +export function isDesktopPromptPreview(kind: DesktopPromptPreviewKind): boolean { + if (typeof window === "undefined" || !import.meta.env.DEV) { + return false; + } + try { + const preview = new URLSearchParams(window.location.search).get("previewPrompt"); + return preview === kind || preview === "all"; + } catch { + return false; + } +} diff --git a/App/frontend/desktop/src/app/tests/campaign-prompt-state.test.ts b/App/frontend/desktop/src/app/tests/campaign-prompt-state.test.ts new file mode 100644 index 000000000..0bb741963 --- /dev/null +++ b/App/frontend/desktop/src/app/tests/campaign-prompt-state.test.ts @@ -0,0 +1,200 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { LotteryStatus } from "@memmy/local-api-contracts"; +import { + CAMPAIGN_PROMPT_MAX_SHOWS, + CAMPAIGN_PROMPT_STORAGE_KEY, + isGuidanceDone, + isCampaignPromptSessionReady, + isCampaignPromptSurfaceReady, + markCampaignPromptActioned, + markCampaignPromptDismissed, + markCampaignPromptShown, + readCampaignPromptState, + shouldOfferCampaignPrompt, + type CampaignPromptPersistedState +} from "../campaign-prompt-state.js"; + +class MemoryStorage implements Storage { + private readonly data = new Map(); + + get length() { + return this.data.size; + } + + clear() { + this.data.clear(); + } + + getItem(key: string) { + return this.data.get(key) ?? null; + } + + key(index: number) { + return [...this.data.keys()][index] ?? null; + } + + removeItem(key: string) { + this.data.delete(key); + } + + setItem(key: string, value: string) { + this.data.set(key, value); + } +} + +const duringCampaign = 1790456789000; + +function remoteStatus(overrides: Partial = {}): LotteryStatus { + return { + shouldShow: true, + startAt: 1790121600000, + endAt: 1790812800000, + serverNow: duringCampaign, + landingUrl: "https://memmy.cn/activity/mid-autumn", + ...overrides + }; +} + +function emptyState(): CampaignPromptPersistedState { + return { showCount: 0, dismissedAt: null, actioned: false }; +} + +describe("campaign prompt eligibility", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("offers the prompt during the campaign window", () => { + expect(shouldOfferCampaignPrompt(emptyState(), remoteStatus())).toBe(true); + }); + + it("fails closed when the remote switch is off or unavailable", () => { + expect(shouldOfferCampaignPrompt(emptyState(), remoteStatus({ shouldShow: false }))).toBe(false); + expect(shouldOfferCampaignPrompt(emptyState(), undefined)).toBe(false); + }); + + it("uses server time to enforce the remote campaign window", () => { + expect(shouldOfferCampaignPrompt(emptyState(), remoteStatus({ serverNow: 1790121599999 }))).toBe(false); + expect(shouldOfferCampaignPrompt(emptyState(), remoteStatus({ serverNow: 1790812800000 }))).toBe(false); + }); + + it("is ready only on workspace routes after boot", () => { + for (const currentPath of ["/main", "/memory", "/memory-sources", "/knowledge", "/tools", "/settings"]) { + expect(isCampaignPromptSurfaceReady({ startupStatus: "ready", currentPath })).toBe(true); + } + }); + + it("treats an authenticated account and a configured BYOK session as logged in", () => { + expect(isCampaignPromptSessionReady({ + userMode: "account", + accountUserId: "user-1", + guidanceDone: true + })).toBe(true); + expect(isCampaignPromptSessionReady({ + userMode: "account", + accountUserId: "user-1", + guidanceDone: false + })).toBe(false); + expect(isCampaignPromptSessionReady({ userMode: "account", accountUserId: "user-1" })).toBe(false); + expect(isCampaignPromptSessionReady({ + userMode: "byok", + accountUserId: null, + byokConfigured: true, + guidanceDone: true + })).toBe(true); + expect(isCampaignPromptSessionReady({ + userMode: "byok", + accountUserId: null, + byokConfigured: false, + guidanceDone: true + })).toBe(false); + expect(isCampaignPromptSessionReady({ userMode: "byok", accountUserId: null, guidanceDone: true })).toBe(false); + expect(isCampaignPromptSessionReady({ userMode: "byok", accountUserId: null })).toBe(false); + expect(isCampaignPromptSessionReady({ + userMode: "byok", + accountUserId: null, + byokConfigured: true, + guidanceDone: false + })).toBe(false); + expect(isCampaignPromptSessionReady({ userMode: "account", accountUserId: null })).toBe(false); + expect(isCampaignPromptSessionReady({ userMode: "account", accountUserId: "" })).toBe(false); + expect(isCampaignPromptSessionReady({ userMode: "unset", accountUserId: null })).toBe(false); + expect(isCampaignPromptSessionReady({ userMode: undefined, accountUserId: "user-1" })).toBe(false); + }); + + it("treats finished guidance as done, and an in-progress tour as not done", () => { + expect(isGuidanceDone({ + guidanceCompleted: true, + onboardingCompleted: false, + deferredGuidanceStep: null + })).toBe(true); + expect(isGuidanceDone({ + guidanceCompleted: true, + onboardingCompleted: false, + deferredGuidanceStep: "product_tour" + })).toBe(false); + expect(isGuidanceDone({ + guidanceCompleted: true, + onboardingCompleted: true, + deferredGuidanceStep: "improvement" + })).toBe(false); + expect(isGuidanceDone({ + guidanceCompleted: false, + onboardingCompleted: true, + deferredGuidanceStep: null + })).toBe(true); + expect(isGuidanceDone({ + guidanceCompleted: false, + onboardingCompleted: true, + deferredGuidanceStep: "product_tour" + })).toBe(false); + expect(isGuidanceDone({ + guidanceCompleted: false, + onboardingCompleted: false, + deferredGuidanceStep: null + })).toBe(false); + }); + + it("waits for boot and skips setup, login, onboarding, and the pet window", () => { + expect(isCampaignPromptSurfaceReady({ startupStatus: "loading", currentPath: "/main" })).toBe(false); + for (const currentPath of [ + "/welcome", + "/login", + "/token-detail", + "/onboarding", + "/api-key", + "/api-key-models", + "/api-key-optional", + "/pet" + ]) { + expect(isCampaignPromptSurfaceReady({ startupStatus: "ready", currentPath })).toBe(false); + } + }); + + it("stops after one show or a site visit", () => { + expect(CAMPAIGN_PROMPT_MAX_SHOWS).toBe(1); + expect(shouldOfferCampaignPrompt({ + showCount: CAMPAIGN_PROMPT_MAX_SHOWS, + dismissedAt: null, + actioned: false + }, remoteStatus())).toBe(false); + expect(shouldOfferCampaignPrompt({ + showCount: 0, + dismissedAt: null, + actioned: true + }, remoteStatus())).toBe(false); + }); + + it("persists shown, dismissed, and actioned states", () => { + const storage = new MemoryStorage(); + expect(readCampaignPromptState(storage)).toEqual(emptyState()); + + expect(markCampaignPromptShown(storage)).toMatchObject({ showCount: 1, dismissedAt: null }); + expect(JSON.parse(storage.getItem(CAMPAIGN_PROMPT_STORAGE_KEY) ?? "{}")).toMatchObject({ showCount: 1 }); + expect(shouldOfferCampaignPrompt(readCampaignPromptState(storage), remoteStatus())).toBe(false); + + expect(markCampaignPromptDismissed(storage, duringCampaign).dismissedAt).toBe(duringCampaign); + expect(markCampaignPromptActioned(storage)).toMatchObject({ actioned: true, dismissedAt: null }); + expect(shouldOfferCampaignPrompt(readCampaignPromptState(storage), remoteStatus())).toBe(false); + }); +}); diff --git a/App/frontend/desktop/src/app/tests/campaign-prompt-url.test.ts b/App/frontend/desktop/src/app/tests/campaign-prompt-url.test.ts new file mode 100644 index 000000000..168ce0466 --- /dev/null +++ b/App/frontend/desktop/src/app/tests/campaign-prompt-url.test.ts @@ -0,0 +1,25 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getCampaignActivityUrl } from "../campaign-prompt-url.js"; + +beforeEach(() => { + vi.stubEnv("MEMMY_APP_EDITION", "cn"); + vi.stubEnv("MEMMY_LEGAL_CN_BASE_URL", "https://test.memmy.cn"); + vi.stubEnv("MEMMY_LEGAL_INTL_BASE_URL", "https://test.memmy.bot"); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("getCampaignActivityUrl", () => { + it("uses the CN activity path and language prefix", () => { + expect(getCampaignActivityUrl("zh-CN")).toBe("https://test.memmy.cn/activity/"); + expect(getCampaignActivityUrl("en-US")).toBe("https://test.memmy.cn/en/activity/"); + }); + + it("uses the intl activity path and language prefix", () => { + vi.stubEnv("MEMMY_APP_EDITION", "intl"); + expect(getCampaignActivityUrl("en-US")).toBe("https://test.memmy.bot/activity/"); + expect(getCampaignActivityUrl("zh-CN")).toBe("https://test.memmy.bot/cn/activity/"); + }); +}); diff --git a/App/frontend/desktop/src/assets/campaign/mid-autumn-riceball.jpg b/App/frontend/desktop/src/assets/campaign/mid-autumn-riceball.jpg new file mode 100644 index 000000000..2021a1e7f Binary files /dev/null and b/App/frontend/desktop/src/assets/campaign/mid-autumn-riceball.jpg differ diff --git a/App/frontend/desktop/src/components/agent-model-selector.tsx b/App/frontend/desktop/src/components/agent-model-selector.tsx index 08f1f9990..85528e583 100644 --- a/App/frontend/desktop/src/components/agent-model-selector.tsx +++ b/App/frontend/desktop/src/components/agent-model-selector.tsx @@ -20,6 +20,12 @@ export interface AgentModelSelectorProps { scopeKey: string; disabled: boolean; seedConfig?: ModelProviderConfig | null; + /** + * Called when the user explicitly picks a model, so the caller can persist it as the + * mode default (remembered for new chats after restart). The per-chat selection is + * still tracked separately in Agent state. + */ + onDefaultModelSelected?: (candidateId: string) => void; } /** Per-chat catalog preset picker. Selection lives in Agent state, never browser storage. */ @@ -80,6 +86,7 @@ export function AgentModelSelector(props: AgentModelSelectorProps) { function selectModel(candidateId: string) { dispatch(agentActions.pendingModelPresetUpdated(props.scopeKey, candidateId)); + props.onDefaultModelSelected?.(candidateId); } function openCustomModelSettings() { diff --git a/App/frontend/desktop/src/components/campaign-prompt-host.tsx b/App/frontend/desktop/src/components/campaign-prompt-host.tsx new file mode 100644 index 000000000..00c430ab3 --- /dev/null +++ b/App/frontend/desktop/src/components/campaign-prompt-host.tsx @@ -0,0 +1,109 @@ +/** Hosts the Mid-Autumn campaign prompt on eligible app opens. */ +import { useEffect, useRef, useState } from "react"; +import { getCampaignActivityUrl } from "../app/campaign-prompt-url.js"; +import { + isGuidanceDone, + isCampaignPromptOpen, + isCampaignPromptSessionReady, + isCampaignPromptSurfaceReady, + markCampaignPromptActioned, + markCampaignPromptDismissed, + markCampaignPromptShown, + readCampaignPromptState, + setCampaignPromptOpen, + shouldOfferCampaignPrompt +} from "../app/campaign-prompt-state.js"; +import { isDesktopPromptPreview } from "../app/desktop-prompt-preview.js"; +import { readDeferredGuidanceStep, readGuidanceCompleted } from "../app/routes.js"; +import { useTranslation } from "../i18n/use-translation.js"; +import { useAppState } from "../state/app-state.js"; +import { openExternalUrl } from "../utils/open-url.js"; +import { CampaignPrompt } from "./campaign-prompt.js"; + +function browserStorage(): Storage | undefined { + return typeof window === "undefined" ? undefined : window.localStorage; +} + +/** Shows the campaign reminder after the user enters a workspace with guidance settled. */ +export function CampaignPromptHost() { + const { state } = useAppState(); + const { language } = useTranslation(); + const [open, setOpen] = useState(false); + const offeredRef = useRef(false); + const preview = isDesktopPromptPreview("campaign"); + const surfaceReady = isCampaignPromptSurfaceReady({ + startupStatus: state.startup.status, + currentPath: state.navigation.currentPath + }); + const sessionReady = isCampaignPromptSessionReady({ + userMode: state.bootstrap?.app.userMode, + accountUserId: state.account.userId, + byokConfigured: Boolean(state.modelConfig?.catalog?.modelAssignments.byok.agent.candidates.length), + guidanceDone: isGuidanceDone({ + guidanceCompleted: readGuidanceCompleted(browserStorage()), + onboardingCompleted: state.bootstrap?.onboarding?.completed === true, + deferredGuidanceStep: readDeferredGuidanceStep(typeof window === "undefined" ? undefined : window.sessionStorage) + }) + }); + + useEffect(() => { + if (open === isCampaignPromptOpen()) { + return; + } + setCampaignPromptOpen(open); + return () => { + if (isCampaignPromptOpen()) { + setCampaignPromptOpen(false); + } + }; + }, [open]); + + useEffect(() => { + if (open || offeredRef.current) { + return; + } + + if (preview) { + offeredRef.current = true; + setOpen(true); + return; + } + + if (!surfaceReady || !sessionReady) { + return; + } + + const storage = browserStorage(); + const persisted = readCampaignPromptState(storage); + if (!shouldOfferCampaignPrompt(persisted, state.bootstrap?.lotteryStatus)) { + offeredRef.current = true; + return; + } + + offeredRef.current = true; + markCampaignPromptShown(storage); + setOpen(true); + }, [open, preview, sessionReady, state.bootstrap?.lotteryStatus, surfaceReady]); + + if (!open) { + return null; + } + + return ( + { + if (!preview) { + markCampaignPromptDismissed(browserStorage()); + } + setOpen(false); + }} + onOpenSite={() => { + if (!preview) { + markCampaignPromptActioned(browserStorage()); + } + setOpen(false); + void openExternalUrl(getCampaignActivityUrl(language)); + }} + /> + ); +} diff --git a/App/frontend/desktop/src/components/campaign-prompt.css b/App/frontend/desktop/src/components/campaign-prompt.css new file mode 100644 index 000000000..08756fddb --- /dev/null +++ b/App/frontend/desktop/src/components/campaign-prompt.css @@ -0,0 +1,193 @@ +.campaign-intro-backdrop { + position: fixed; + inset: 0; + z-index: 4000; + display: grid; + place-items: center; + padding: 16px; + box-sizing: border-box; + background: color-mix(in srgb, var(--color-text-ink) 23%, transparent); + backdrop-filter: blur(5px); + -webkit-backdrop-filter: blur(5px); +} + +.campaign-intro-dialog { + --campaign-intro-muted: color-mix(in srgb, var(--color-text-ink) 64%, var(--color-background-paper)); + --campaign-intro-border: color-mix(in srgb, var(--color-border-stone) 65%, var(--color-background-paper)); + display: grid; + grid-template-columns: 300px minmax(300px, 1fr); + align-items: stretch; + position: relative; + width: min(720px, calc(100vw - 32px)); + max-height: calc(100dvh - 32px); + border-radius: 26px; + overflow-x: hidden; + overflow-y: auto; + box-shadow: + 0 22px 90px color-mix(in srgb, var(--color-text-ink) 18%, transparent), + 0 2px 10px color-mix(in srgb, var(--color-text-ink) 6%, transparent); + background: + linear-gradient( + 165deg, + color-mix(in srgb, var(--color-accent-peach) 18%, var(--color-background-paper)) 0%, + var(--color-background-paper) 42%, + var(--color-background-paper) 100% + ); + color: var(--color-text-ink); + outline: none; + font-family: var(--font-sans); +} + +.campaign-intro-close { + position: absolute; + z-index: 2; + top: 12px; + right: 12px; + display: grid; + place-items: center; + width: 22px; + height: 22px; + padding: 0; + border: 0; + border-radius: 999px; + color: #fff8e6; + background: rgba(13, 24, 42, 0.42); + box-shadow: 0 0 0 1px rgba(255, 248, 230, 0.22); + cursor: pointer; +} + +.campaign-intro-close:hover { + color: #fff; + background: rgba(13, 24, 42, 0.62); +} + +.campaign-intro-copy { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: flex-start; + padding: 30px 22px 22px; + overflow-y: auto; + min-height: 0; + background: color-mix(in srgb, var(--color-accent-peach) 16%, var(--color-background-paper)); +} + +.campaign-intro-eyebrow { + margin: 0 0 10px; + width: fit-content; + padding: 4px 10px; + border-radius: 999px; + background: color-mix(in srgb, var(--color-accent-peach) 42%, var(--color-background-paper)); + color: color-mix(in srgb, var(--color-icon-ember) 72%, var(--color-text-ink)); + font-size: 12px; + font-weight: 650; + letter-spacing: 0.04em; +} + +.campaign-intro-copy h2 { + margin: 0; + font-size: 24px; + line-height: 1.3; + font-weight: 700; + letter-spacing: -0.5px; + text-wrap: balance; +} + +.campaign-intro-poem { + margin: 18px 0 22px; + padding: 4px 0 4px 14px; + display: flex; + flex-direction: column; + gap: 8px; + border-left: 2px solid color-mix(in srgb, var(--color-icon-ember) 38%, var(--color-accent-peach)); +} + +.campaign-intro-poem p { + margin: 0; + font-size: 16px; + line-height: 1.75; + font-weight: 550; + color: color-mix(in srgb, var(--color-text-ink) 88%, var(--color-background-paper)); +} + +.campaign-intro-poem p:first-child { + font-size: 18px; + font-weight: 650; +} + +.campaign-intro-actions { + margin-top: auto; + padding-top: 24px; + display: flex; + align-items: center; + gap: 10px; +} + +.campaign-intro-primary { + width: 100%; + min-height: 44px; + padding: 10px 18px; + border: 0; + border-radius: 12px; + background: color-mix(in srgb, var(--color-action-sky) 64%, var(--color-icon-ember)); + color: #fffaf0; + font-size: 14px; + font-weight: 700; + letter-spacing: 0.02em; + cursor: pointer; +} + +.campaign-intro-primary:hover { + background: color-mix(in srgb, var(--color-action-sky-hover) 60%, var(--color-icon-ember)); +} + +.campaign-intro-dialog button:focus-visible { + outline: 2px solid color-mix(in srgb, var(--color-action-sky) 55%, var(--color-icon-ember)); + outline-offset: 4px; +} + +.campaign-intro-visual { + position: relative; + isolation: isolate; + overflow: hidden; + min-height: 468px; + background: #0d182a; +} + +.campaign-intro-art { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; + object-fit: cover; + object-position: center 34%; +} + +@media (max-height: 620px) and (min-width: 821px) { + .campaign-intro-copy { padding: 24px 20px 18px; } + .campaign-intro-copy h2 { font-size: 24px; } + .campaign-intro-poem { margin: 14px 0 18px; } + .campaign-intro-poem p { font-size: 15px; } + .campaign-intro-poem p:first-child { font-size: 16px; } + .campaign-intro-visual { min-height: 400px; } +} + +@media (max-width: 820px) { + .campaign-intro-backdrop { padding: 16px; } + .campaign-intro-dialog { + width: min(430px, calc(100vw - 32px)); + max-height: calc(100dvh - 32px); + display: flex; + flex-direction: column; + overflow-y: auto; + border-radius: 21px; + } + .campaign-intro-copy { overflow: visible; padding: 28px 24px 24px; flex-shrink: 0; } + .campaign-intro-copy h2 { font-size: 24px; } + .campaign-intro-close { top: 10px; right: 10px; } + .campaign-intro-poem { margin: 18px 0 22px; } + .campaign-intro-visual { min-height: 280px; flex-shrink: 0; } +} diff --git a/App/frontend/desktop/src/components/campaign-prompt.tsx b/App/frontend/desktop/src/components/campaign-prompt.tsx new file mode 100644 index 000000000..fba7290f8 --- /dev/null +++ b/App/frontend/desktop/src/components/campaign-prompt.tsx @@ -0,0 +1,138 @@ +/** Large campaign reminder, matching the computer-history feature-update dialog. */ +import { X } from "lucide-react"; +import { useEffect, useRef, type KeyboardEvent } from "react"; +import { createPortal } from "react-dom"; +import campaignArtUrl from "../assets/campaign/mid-autumn-riceball.jpg"; +import { useTranslation } from "../i18n/use-translation.js"; +import "./campaign-prompt.css"; + +export interface CampaignPromptProps { + onOpenSite: () => void; + onDismiss: () => void; +} + +const CN_COPY = { + eyebrow: "campaignPrompt.eyebrow", + title: "campaignPrompt.title", + poem: ["campaignPrompt.poemLine1", "campaignPrompt.poemLine2", "campaignPrompt.poemLine3"], + cta: "campaignPrompt.cta" +} as const; + +const INTL_COPY = { + eyebrow: "campaignPromptIntl.eyebrow", + title: "campaignPromptIntl.title", + poem: ["campaignPromptIntl.poemLine1", "campaignPromptIntl.poemLine2", "campaignPromptIntl.poemLine3"], + cta: "campaignPromptIntl.cta" +} as const; + +/** Overseas builds show the intl copy set; the mainland build keeps the CN set. */ +function campaignCopy() { + return import.meta.env.MEMMY_APP_EDITION === "intl" ? INTL_COPY : CN_COPY; +} + +/** Centered feature-update dialog inviting the user to the activity site. */ +export function CampaignPrompt(props: CampaignPromptProps) { + const { t } = useTranslation(); + const dialogRef = useRef(null); + const onDismiss = props.onDismiss; + const copy = campaignCopy(); + + useEffect(() => { + const overflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + dialogRef.current?.focus(); + + function onKey(event: globalThis.KeyboardEvent) { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + onDismiss(); + } + } + + document.addEventListener("keydown", onKey, true); + return () => { + document.body.style.overflow = overflow; + document.removeEventListener("keydown", onKey, true); + }; + }, [onDismiss]); + + if (typeof document === "undefined") { + return null; + } + + return createPortal( +
{ + if (event.target === event.currentTarget) { + onDismiss(); + } + }} + > +
+ +
+

{t(copy.eyebrow)}

+

{t(copy.title)}

+
+ {copy.poem.map((key) => ( +

{t(key)}

+ ))} +
+
+ +
+
+ +
+
, + document.body + ); +} + +function trapFocus(event: KeyboardEvent) { + if (event.key !== "Tab") { + return; + } + const items = Array.from( + event.currentTarget.querySelectorAll('button:not(:disabled), a[href], [tabindex="0"]') + ); + const first = items[0]; + const last = items.at(-1); + if (!first || !last) { + event.preventDefault(); + return; + } + if (event.shiftKey && (document.activeElement === first || document.activeElement === event.currentTarget)) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && (document.activeElement === last || document.activeElement === event.currentTarget)) { + event.preventDefault(); + first.focus(); + } +} diff --git a/App/frontend/desktop/src/components/github-star-prompt-host.tsx b/App/frontend/desktop/src/components/github-star-prompt-host.tsx index 1f33f5657..1bc1911b0 100644 --- a/App/frontend/desktop/src/components/github-star-prompt-host.tsx +++ b/App/frontend/desktop/src/components/github-star-prompt-host.tsx @@ -1,5 +1,6 @@ /** Hosts the GitHub star prompt across all routes after agent turn completion. */ import { useEffect, useRef, useState } from "react"; +import { isCampaignPromptOpen } from "../app/campaign-prompt-state.js"; import { markGithubStarPromptActioned, markGithubStarPromptDismissed, @@ -29,7 +30,7 @@ export function GithubStarPromptHost() { } lastHandledCompletionAt.current = completion.at; - if (open) { + if (open || isCampaignPromptOpen()) { return; } diff --git a/App/frontend/desktop/src/components/notification-center.tsx b/App/frontend/desktop/src/components/notification-center.tsx new file mode 100644 index 000000000..eada9ac60 --- /dev/null +++ b/App/frontend/desktop/src/components/notification-center.tsx @@ -0,0 +1,112 @@ +/** App-wide notification center: a top-right stack with a queue for overflow. */ +import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode } from "react"; +import { createPortal } from "react-dom"; +import type { LucideIcon } from "lucide-react"; +import { useTranslation } from "../i18n/use-translation.js"; +import { NotificationToast, type NotificationVariant } from "./notification-toast.js"; + +/** Max notifications visible at once; extras wait in the queue until a slot frees. */ +export const NOTIFICATION_STACK_MAX_VISIBLE = 3; + +export interface NotificationInput { + variant?: NotificationVariant; + title: string; + body?: string; + /** Optional emphasized value shown as the hero line, e.g. a credited token amount. */ + emphasis?: string; + /** Optional icon override; defaults to the variant icon. */ + icon?: LucideIcon; + /** Optional inline action link; clicking it runs onAction, then dismisses the notification. */ + actionLabel?: string; + onAction?: () => void; + /** Side effect to run when this notification is dismissed (e.g. refresh balance). */ + onClose?: () => void; +} + +interface NotificationItem extends NotificationInput { + id: string; +} + +export interface NotificationCenterValue { + /** Enqueues a notification and returns its id. */ + notify(input: NotificationInput): string; + /** Dismisses a notification by id (runs its onClose). */ + dismiss(id: string): void; +} + +const NotificationCenterContext = createContext(null); + +let notificationSeq = 0; + +function nextNotificationId(): string { + notificationSeq += 1; + return `notification-${notificationSeq}-${Date.now()}`; +} + +/** Provides notify/dismiss and renders the top-right stack. */ +export function NotificationCenterProvider(props: { children: ReactNode }) { + const { t } = useTranslation(); + const [items, setItems] = useState([]); + const closeCallbacksRef = useRef(new Map void) | undefined>()); + + const dismiss = useCallback((id: string) => { + if (!closeCallbacksRef.current.has(id)) { + return; + } + const onClose = closeCallbacksRef.current.get(id); + closeCallbacksRef.current.delete(id); + setItems((current) => current.filter((item) => item.id !== id)); + onClose?.(); + }, []); + + const notify = useCallback((input: NotificationInput) => { + const id = nextNotificationId(); + closeCallbacksRef.current.set(id, input.onClose); + setItems((current) => [...current, { ...input, id }]); + return id; + }, []); + + const value = useMemo(() => ({ notify, dismiss }), [notify, dismiss]); + + // Keep FIFO order; show the first N, render newest-of-those on top. + const visible = items.slice(0, NOTIFICATION_STACK_MAX_VISIBLE); + const stack = typeof document === "undefined" || visible.length === 0 + ? null + : createPortal( +
+ {[...visible].reverse().map((item) => ( + { + item.onAction?.(); + dismiss(item.id); + } : undefined} + onDismiss={() => dismiss(item.id)} + /> + ))} +
, + document.body + ); + + return ( + + {props.children} + {stack} + + ); +} + +/** Access the notification center; throws outside the provider. */ +export function useNotificationCenter(): NotificationCenterValue { + const value = useContext(NotificationCenterContext); + if (!value) { + throw new Error("useNotificationCenter must be used within NotificationCenterProvider"); + } + return value; +} diff --git a/App/frontend/desktop/src/components/notification-toast.tsx b/App/frontend/desktop/src/components/notification-toast.tsx new file mode 100644 index 000000000..e6a0950e5 --- /dev/null +++ b/App/frontend/desktop/src/components/notification-toast.tsx @@ -0,0 +1,61 @@ +/** Generic top-right notification card. Compact base for all in-app notices. */ +import { ChevronRight, X, type LucideIcon } from "lucide-react"; +import { useTranslation } from "../i18n/use-translation.js"; + +export type NotificationVariant = "success" | "info" | "warning"; + +export interface NotificationToastProps { + variant?: NotificationVariant; + title: string; + body?: string; + /** Optional emphasized value shown under the title, e.g. a credited token amount. */ + emphasis?: string; + /** Optional leading icon. Omit for a text-only card. */ + icon?: LucideIcon; + /** Optional action link. */ + actionLabel?: string; + onAction?: () => void; + onDismiss: () => void; +} + +/** Notification card aligned with the GitHub-star floating card. */ +export function NotificationToast(props: NotificationToastProps) { + const { t } = useTranslation(); + const variant = props.variant ?? "info"; + const Icon = props.icon; + + return ( +
+ {Icon ? ( + + ) : null} +
+
+

{props.title}

+ +
+ {props.emphasis ?

{props.emphasis}

: null} + {props.body ?

{props.body}

: null} + {props.actionLabel && props.onAction ? ( + + ) : null} +
+
+ ); +} diff --git a/App/frontend/desktop/src/components/search-palette.tsx b/App/frontend/desktop/src/components/search-palette.tsx index a7161d884..ae5dfeda2 100644 --- a/App/frontend/desktop/src/components/search-palette.tsx +++ b/App/frontend/desktop/src/components/search-palette.tsx @@ -114,23 +114,22 @@ export function SearchPalette(props: SearchPaletteProps) { missingProjectLabel: props.missingProjectLabel, registryUnavailableLabel: props.registryUnavailableLabel }); + const taskLabel = [task.title || untitledLabel || "", ownership].filter(Boolean).join(" · "); return ( ); })} @@ -175,7 +174,7 @@ export function resolveTaskOwnership( ? projects.find((candidate) => candidate.id === task.groupProjectId) ?? null : null; if (project) return `${project.name} · ${task.cwd}`; - if (task.projectId == null) return labels.standaloneLabel ?? ""; + if (task.projectId == null) return ""; const unavailableLabel = labels.projectRegistryState === "corrupt" ? labels.registryUnavailableLabel : labels.missingProjectLabel; diff --git a/App/frontend/desktop/src/components/tests/campaign-token-prompts.test.tsx b/App/frontend/desktop/src/components/tests/campaign-token-prompts.test.tsx new file mode 100644 index 000000000..5ee93ea10 --- /dev/null +++ b/App/frontend/desktop/src/components/tests/campaign-token-prompts.test.tsx @@ -0,0 +1,352 @@ +// @vitest-environment happy-dom + +import { act, StrictMode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { clearDeferredGuidanceStep, writeDeferredGuidanceStep, writeGuidanceCompleted } from "../../app/routes.js"; +import { I18nProvider } from "../../i18n/i18n-provider.js"; +import { CampaignPromptHost } from "../campaign-prompt-host.js"; +import { CampaignPrompt } from "../campaign-prompt.js"; +import { NotificationToast } from "../notification-toast.js"; + +const mocks = vi.hoisted(() => ({ + openExternalUrl: vi.fn(), + appState: { + state: { + startup: { status: "ready" }, + navigation: { currentPath: "/main" }, + bootstrap: { + app: { userMode: "account" as const }, + onboarding: { completed: false }, + lotteryStatus: { + shouldShow: true, + startAt: 1790121600000, + endAt: 1790812800000, + serverNow: 1790456789000, + landingUrl: "https://remote.example/ignored" + } + }, + account: { userId: "user-1" as string | null }, + modelConfig: { + catalog: { + modelAssignments: { + byok: { agent: { candidates: [] as string[] } } + } + } + } + } + } +})); + +vi.mock("../../state/app-state.js", () => ({ + useAppState: () => mocks.appState +})); + +vi.mock("../../utils/open-url.js", () => ({ + openExternalUrl: mocks.openExternalUrl +})); + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +describe("campaign and token credit prompts", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + window.localStorage.clear(); + window.sessionStorage.clear(); + mocks.openExternalUrl.mockClear(); + mocks.appState.state.startup.status = "ready"; + mocks.appState.state.navigation.currentPath = "/main"; + mocks.appState.state.bootstrap.app.userMode = "account"; + mocks.appState.state.bootstrap.onboarding.completed = false; + mocks.appState.state.account.userId = "user-1"; + mocks.appState.state.modelConfig.catalog.modelAssignments.byok.agent.candidates = []; + writeGuidanceCompleted(window.localStorage); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + document.body.replaceChildren(); + vi.unstubAllEnvs(); + }); + + it("uses remote status only for eligibility and keeps the project activity URL", async () => { + vi.stubEnv("MEMMY_APP_EDITION", "cn"); + vi.stubEnv("MEMMY_LEGAL_CN_BASE_URL", "https://project-url.example"); + + await act(async () => { + root.render( + + + + ); + }); + + const action = Array.from(document.body.querySelectorAll("button")) + .find((button) => button.textContent === "去官网参与活动"); + expect(action).not.toBeUndefined(); + + await act(async () => { + action?.click(); + }); + + expect(mocks.openExternalUrl).toHaveBeenCalledWith("https://project-url.example/activity/"); + expect(mocks.openExternalUrl).not.toHaveBeenCalledWith("https://remote.example/ignored"); + }); + + it("waits for an account or BYOK login in the same session", async () => { + mocks.appState.state.navigation.currentPath = "/welcome"; + mocks.appState.state.bootstrap.app.userMode = "unset"; + mocks.appState.state.account.userId = null; + + await act(async () => { + root.render( + + + + ); + }); + expect(document.body.textContent).not.toContain("去官网参与活动"); + + mocks.appState.state.navigation.currentPath = "/main"; + mocks.appState.state.bootstrap.app.userMode = "account"; + mocks.appState.state.account.userId = "user-1"; + window.localStorage.removeItem("memmy.guidanceCompleted"); + await act(async () => { + root.render( + + + + ); + }); + expect(document.body.textContent).not.toContain("去官网参与活动"); + + writeGuidanceCompleted(window.localStorage); + await act(async () => { + root.render( + + + + ); + }); + expect(document.body.textContent).toContain("去官网参与活动"); + }); + + it("shows the prompt for BYOK after guidance, without a cloud account id", async () => { + mocks.appState.state.bootstrap.app.userMode = "byok"; + mocks.appState.state.account.userId = null; + mocks.appState.state.modelConfig.catalog.modelAssignments.byok.agent.candidates = ["local-agent"]; + window.localStorage.removeItem("memmy.guidanceCompleted"); + + await act(async () => { + root.render( + + + + ); + }); + expect(document.body.textContent).not.toContain("去官网参与活动"); + + writeGuidanceCompleted(window.localStorage); + await act(async () => { + root.render( + + + + ); + }); + + expect(document.body.textContent).toContain("去官网参与活动"); + }); + + it("waits out setup pages and an in-progress guide, then shows once", async () => { + mocks.appState.state.bootstrap.app.userMode = "byok"; + mocks.appState.state.account.userId = null; + mocks.appState.state.navigation.currentPath = "/api-key"; + window.localStorage.removeItem("memmy.guidanceCompleted"); + + await act(async () => { + root.render( + + + + ); + }); + expect(document.body.textContent).not.toContain("去官网参与活动"); + + mocks.appState.state.navigation.currentPath = "/welcome"; + await act(async () => { + root.render( + + + + ); + }); + expect(document.body.textContent).not.toContain("去官网参与活动"); + + mocks.appState.state.navigation.currentPath = "/main"; + mocks.appState.state.modelConfig.catalog.modelAssignments.byok.agent.candidates = ["local-agent"]; + mocks.appState.state.bootstrap.onboarding.completed = true; + writeGuidanceCompleted(window.localStorage); + writeDeferredGuidanceStep(window.sessionStorage, "product_tour"); + await act(async () => { + root.render( + + + + ); + }); + expect(document.body.textContent).not.toContain("去官网参与活动"); + + clearDeferredGuidanceStep(window.sessionStorage); + await act(async () => { + root.render( + + + + ); + }); + expect(document.body.textContent).toContain("去官网参与活动"); + + await act(async () => root.unmount()); + root = createRoot(container); + await act(async () => { + root.render( + + + + ); + }); + expect(document.body.textContent).not.toContain("去官网参与活动"); + }); + + it("shows once for an account that already finished onboarding", async () => { + window.localStorage.removeItem("memmy.guidanceCompleted"); + mocks.appState.state.bootstrap.onboarding.completed = true; + + await act(async () => { + root.render( + + + + ); + }); + expect(document.body.textContent).toContain("去官网参与活动"); + + await act(async () => root.unmount()); + root = createRoot(container); + await act(async () => { + root.render( + + + + ); + }); + expect(document.body.textContent).not.toContain("去官网参与活动"); + }); + + it("renders the large feature-update campaign dialog", () => { + act(() => { + root.render( + + + undefined} onDismiss={() => undefined} /> + + + ); + }); + + const dialog = document.body.querySelector(".campaign-intro-dialog"); + expect(dialog).not.toBeNull(); + expect(document.body.textContent).toContain("🥮 中秋限定 · 至 9.30"); + expect(document.body.textContent).toContain("中秋弹幕活动开始了"); + expect(document.body.textContent).toContain("🥮 中秋年年吃月饼"); + expect(document.body.textContent).toContain("🍙 饭团也可以是圆的"); + expect(document.body.textContent).toContain("吃完打卡评价还能领 Token"); + expect(document.body.textContent).toContain("去官网参与活动"); + expect(document.body.textContent).not.toContain("等下再来"); + expect(document.body.querySelector('[aria-label="关闭"]')).not.toBeNull(); + expect(document.body.querySelector(".campaign-intro-secondary")).toBeNull(); + expect(document.body.querySelector("img.campaign-intro-art")).not.toBeNull(); + expect(document.body.querySelector(".campaign-intro-moon")).toBeNull(); + expect(document.body.querySelector(".campaign-intro-mascot")).toBeNull(); + expect(document.body.querySelector(".campaign-intro-highlights")).toBeNull(); + expect(document.body.querySelector(".campaign-intro-demo")).toBeNull(); + expect(document.body.querySelector(".github-star-prompt")).toBeNull(); + }); + + it("renders the overseas copy for the intl edition", () => { + vi.stubEnv("MEMMY_APP_EDITION", "intl"); + + act(() => { + root.render( + + + undefined} onDismiss={() => undefined} /> + + + ); + }); + + expect(document.body.textContent).toContain("Word is, the year's roundest moon is almost here."); + expect(document.body.textContent).toContain("Our onigiri? Even rounder."); + expect(document.body.textContent).toContain("Doubt it? Take a bite, leave a review, and grab some tokens."); + expect(document.body.textContent).not.toContain("Mooncakes every Mid-Autumn"); + + vi.unstubAllEnvs(); + }); + + it("renders the overseas Chinese copy for the intl edition", () => { + vi.stubEnv("MEMMY_APP_EDITION", "intl"); + + act(() => { + root.render( + + + undefined} onDismiss={() => undefined} /> + + + ); + }); + + expect(document.body.textContent).toContain("听说,一年里最圆的月亮就要来了"); + expect(document.body.textContent).toContain("而我们的饭团?比它还圆"); + expect(document.body.textContent).not.toContain("中秋年年吃月饼"); + + vi.unstubAllEnvs(); + }); + + it("renders the token credit notice as title, amount, and close", () => { + const htmlContainer = document.createElement("div"); + const toastRoot = createRoot(htmlContainer); + const onDismiss = vi.fn(); + act(() => { + toastRoot.render( + + + + ); + }); + + expect(htmlContainer.querySelector(".memmy-notification--success")).not.toBeNull(); + expect(htmlContainer.querySelector(".memmy-notification__title")?.textContent).toBe("🎁 弹幕抽奖 Token 已到账"); + expect(htmlContainer.querySelector(".memmy-notification__emphasis")?.textContent).toBe("+500,000"); + expect(htmlContainer.querySelector(".memmy-notification__body")).toBeNull(); + expect(htmlContainer.querySelector(".memmy-notification__action")).toBeNull(); + + const close = htmlContainer.querySelector('[aria-label="关闭"]'); + act(() => (close as HTMLButtonElement).click()); + expect(onDismiss).toHaveBeenCalledTimes(1); + expect(htmlContainer.innerHTML).not.toContain("campaign-intro-primary"); + act(() => toastRoot.unmount()); + }); +}); diff --git a/App/frontend/desktop/src/components/tests/notification-center.test.tsx b/App/frontend/desktop/src/components/tests/notification-center.test.tsx new file mode 100644 index 000000000..0ec4debe0 --- /dev/null +++ b/App/frontend/desktop/src/components/tests/notification-center.test.tsx @@ -0,0 +1,99 @@ +// @vitest-environment happy-dom + +import { act, StrictMode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { I18nProvider } from "../../i18n/i18n-provider.js"; +import { + NOTIFICATION_STACK_MAX_VISIBLE, + NotificationCenterProvider, + useNotificationCenter, + type NotificationCenterValue +} from "../notification-center.js"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let center: NotificationCenterValue | null = null; + +function CenterHandle() { + center = useNotificationCenter(); + return null; +} + +function stackItems(): Element[] { + const stack = document.body.querySelector(".memmy-notification-stack"); + return stack ? Array.from(stack.querySelectorAll(".memmy-notification")) : []; +} + +describe("NotificationCenter", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + center = null; + act(() => { + root.render( + + + + + + + + ); + }); + }); + + afterEach(() => { + act(() => root.unmount()); + document.body.replaceChildren(); + }); + + it("caps visible notifications and queues the overflow", () => { + act(() => { + for (let i = 1; i <= NOTIFICATION_STACK_MAX_VISIBLE + 1; i += 1) { + center?.notify({ title: `通知 ${i}` }); + } + }); + + expect(stackItems()).toHaveLength(NOTIFICATION_STACK_MAX_VISIBLE); + // The 4th is queued, not shown yet. + expect(document.body.textContent).not.toContain("通知 4"); + }); + + it("runs onClose on dismiss and reveals the next queued notification", () => { + const onClose = vi.fn(); + act(() => { + center?.notify({ title: "通知 1", onClose }); + for (let i = 2; i <= NOTIFICATION_STACK_MAX_VISIBLE + 1; i += 1) { + center?.notify({ title: `通知 ${i}` }); + } + }); + expect(document.body.textContent).not.toContain("通知 4"); + + // Oldest visible ("通知 1") is rendered at the bottom; dismiss it via its close button. + const closeButtons = document.body.querySelectorAll('.memmy-notification [aria-label="关闭"]'); + act(() => (closeButtons[closeButtons.length - 1] as HTMLButtonElement).click()); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(stackItems()).toHaveLength(NOTIFICATION_STACK_MAX_VISIBLE); + expect(document.body.textContent).toContain("通知 4"); + }); + + it("runs the action then dismisses the notification", () => { + const onAction = vi.fn(); + act(() => { + center?.notify({ title: "带链接的通知", actionLabel: "打开详情", onAction }); + }); + + const action = document.body.querySelector(".memmy-notification__action"); + expect(action?.textContent).toContain("打开详情"); + act(() => (action as HTMLButtonElement).click()); + + expect(onAction).toHaveBeenCalledTimes(1); + expect(stackItems()).toHaveLength(0); + }); +}); diff --git a/App/frontend/desktop/src/components/tests/search-palette.test.tsx b/App/frontend/desktop/src/components/tests/search-palette.test.tsx index 943fc2abc..8ea9d7f32 100644 --- a/App/frontend/desktop/src/components/tests/search-palette.test.tsx +++ b/App/frontend/desktop/src/components/tests/search-palette.test.tsx @@ -160,4 +160,24 @@ describe("SearchPalette", () => { expect(html).toContain('title="项目注册表不可用 · /projects/alpha"'); expect(html).toContain('aria-label="Project task · 项目注册表不可用 · /projects/alpha"'); }); + + it("does not render the standalone ownership label for independent tasks", () => { + const html = renderToString( + undefined} + onSelectTask={() => undefined} + /> + ); + + expect(html).toContain("独立任务"); + expect(html).not.toContain("最近"); + expect(html).not.toContain("search-palette-item-preview"); + }); }); diff --git a/App/frontend/desktop/src/components/tests/token-credit-toast-host.test.tsx b/App/frontend/desktop/src/components/tests/token-credit-toast-host.test.tsx new file mode 100644 index 000000000..7a63ea769 --- /dev/null +++ b/App/frontend/desktop/src/components/tests/token-credit-toast-host.test.tsx @@ -0,0 +1,174 @@ +// @vitest-environment happy-dom + +import { act, StrictMode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { I18nProvider } from "../../i18n/i18n-provider.js"; +import { NotificationCenterProvider } from "../notification-center.js"; +import { TokenCreditToastHost } from "../token-credit-toast-host.js"; + +const mocks = vi.hoisted(() => ({ + dispatch: vi.fn(), + getLotteryReward: vi.fn(), + ackLotteryReward: vi.fn(), + getBootstrap: vi.fn(), + appState: { + state: { + account: { + userId: "user-1" as string | null + } + } + } +})); + +vi.mock("../../state/app-state.js", () => ({ + useAppState: () => ({ + state: mocks.appState.state, + dispatch: mocks.dispatch + }) +})); + +vi.mock("../../app/providers.js", () => ({ + useOptionalApiClients: () => ({ + clients: { + account: { + getLotteryReward: mocks.getLotteryReward, + ackLotteryReward: mocks.ackLotteryReward + }, + bootstrap: { + getBootstrap: mocks.getBootstrap + } + }, + setClients: () => undefined + }) +})); + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +describe("TokenCreditToastHost", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + mocks.dispatch.mockReset(); + mocks.getLotteryReward.mockReset(); + mocks.ackLotteryReward.mockReset(); + mocks.getBootstrap.mockReset(); + mocks.appState.state.account.userId = "user-1"; + mocks.ackLotteryReward.mockResolvedValue({ ok: true }); + mocks.getBootstrap.mockResolvedValue({ + tokenUsage: { + planName: "体验 Token", + totalTokens: 30_500_000, + usedTokens: 0, + remainingTokens: 30_500_000, + expiresAt: null, + lastSyncedAt: null, + sceneUsages: [] + } + }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + document.body.replaceChildren(); + }); + + it("coalesces startup and focus checks while a reward request is pending", async () => { + let resolveReward: ((reward: { hasReward: false }) => void) | undefined; + mocks.getLotteryReward.mockImplementation(() => new Promise((resolve) => { + resolveReward = resolve; + })); + + await renderHost(root); + act(() => { + window.dispatchEvent(new Event("focus")); + window.dispatchEvent(new Event("focus")); + }); + + expect(mocks.getLotteryReward).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveReward?.({ hasReward: false }); + }); + }); + + it("checks after login when the app started logged out", async () => { + mocks.appState.state.account.userId = null; + mocks.getLotteryReward.mockResolvedValue({ hasReward: false }); + + await renderHost(root); + expect(mocks.getLotteryReward).not.toHaveBeenCalled(); + + mocks.appState.state.account.userId = "user-1"; + await renderHost(root); + + expect(mocks.getLotteryReward).toHaveBeenCalledTimes(1); + }); + + it("shows a reward once and acknowledges it only after manual close", async () => { + mocks.getLotteryReward.mockResolvedValue({ + hasReward: true, + drawId: "1", + tokenAmount: 500_000 + }); + + await renderHost(root); + + expect(document.body.textContent).toContain("🎁 弹幕抽奖 Token 已到账"); + expect(document.body.textContent).toContain("+500,000"); + expect(mocks.ackLotteryReward).not.toHaveBeenCalled(); + + await act(async () => { + window.dispatchEvent(new Event("focus")); + }); + expect(document.body.querySelectorAll(".memmy-notification")).toHaveLength(1); + + const close = document.body.querySelector('[aria-label="关闭"]'); + await act(async () => { + close?.click(); + }); + + expect(mocks.ackLotteryReward).toHaveBeenCalledWith({ drawId: "1" }); + expect(mocks.getBootstrap).toHaveBeenCalledTimes(1); + expect(mocks.dispatch).toHaveBeenCalledWith(expect.objectContaining({ type: "tokenUsage/updated" })); + }); + + it("still shows the startup reward after StrictMode replays effects", async () => { + mocks.getLotteryReward.mockResolvedValue({ + hasReward: true, + drawId: "strict-1", + tokenAmount: 500_000 + }); + + await act(async () => { + root.render( + + + + + + + + ); + }); + + expect(document.body.textContent).toContain("+500,000"); + expect(mocks.getLotteryReward).toHaveBeenCalledTimes(1); + }); +}); + +async function renderHost(root: Root): Promise { + await act(async () => { + root.render( + + + + + + ); + }); +} diff --git a/App/frontend/desktop/src/components/token-credit-toast-host.tsx b/App/frontend/desktop/src/components/token-credit-toast-host.tsx new file mode 100644 index 000000000..48edc17b0 --- /dev/null +++ b/App/frontend/desktop/src/components/token-credit-toast-host.tsx @@ -0,0 +1,112 @@ +/** Enqueues the token-credit notification after a campaign reward is granted. */ +import { useCallback, useEffect, useRef } from "react"; +import { isDesktopPromptPreview } from "../app/desktop-prompt-preview.js"; +import { useOptionalApiClients } from "../app/providers.js"; +import { useTranslation } from "../i18n/use-translation.js"; +import { formatTokenGiftAmount } from "../pages/token-gift.js"; +import { appActions } from "../state/app-actions.js"; +import { useAppState } from "../state/app-state.js"; +import { useNotificationCenter } from "./notification-center.js"; + +const PREVIEW_TOKEN_AMOUNT = 500_000; + +/** Pushes a manual-close notice for the latest unacknowledged campaign reward. */ +export function TokenCreditToastHost() { + const { state, dispatch } = useAppState(); + const { clients } = useOptionalApiClients(); + const { t } = useTranslation(); + const { notify } = useNotificationCenter(); + const notifiedRef = useRef(false); + const activeAccountUserIdRef = useRef(null); + const rewardRequestRef = useRef<{ + accountUserId: string; + request: Promise; + } | null>(null); + const shownRewardKeysRef = useRef(new Set()); + + /** Refreshes the token balance after the user closes the notice. */ + const refreshBalance = useCallback(() => { + if (!clients) { + return; + } + void clients.bootstrap + .getBootstrap() + .then((bootstrap) => dispatch(appActions.tokenUsageUpdated(bootstrap.tokenUsage))) + .catch((error: unknown) => console.warn("[token-credit] failed to refresh token usage:", error)); + }, [clients, dispatch]); + + /** Enqueues the title + amount credit notification. */ + const showTokenCredit = useCallback((tokens: number, onClose: () => void = refreshBalance) => { + notify({ + variant: "success", + title: t("tokenCredit.title"), + emphasis: `+${formatTokenGiftAmount(tokens)}`, + onClose + }); + }, [notify, refreshBalance, t]); + + useEffect(() => { + if (notifiedRef.current || !isDesktopPromptPreview("tokenCredit")) { + return; + } + notifiedRef.current = true; + showTokenCredit(PREVIEW_TOKEN_AMOUNT); + }, [showTokenCredit]); + + useEffect(() => { + const accountUserId = state.account.userId; + if (!clients || !accountUserId) { + return; + } + const activeClients = clients; + const activeAccountUserId = accountUserId; + + activeAccountUserIdRef.current = activeAccountUserId; + + function checkReward() { + if (rewardRequestRef.current?.accountUserId === activeAccountUserId) { + return; + } + + const request = activeClients.account + .getLotteryReward() + .then((reward) => { + if (activeAccountUserIdRef.current !== activeAccountUserId || !reward.hasReward) { + return; + } + + const rewardKey = reward.drawId ? `draw:${reward.drawId}` : `amount:${reward.tokenAmount}`; + if (shownRewardKeysRef.current.has(rewardKey)) { + return; + } + shownRewardKeysRef.current.add(rewardKey); + + showTokenCredit(reward.tokenAmount, () => { + void activeClients.account + .ackLotteryReward(reward.drawId ? { drawId: reward.drawId } : {}) + .catch((error: unknown) => console.warn("[token-credit] failed to acknowledge reward:", error)) + .finally(refreshBalance); + }); + }) + .catch((error: unknown) => console.warn("[token-credit] failed to fetch reward:", error)) + .finally(() => { + if (rewardRequestRef.current?.request === request) { + rewardRequestRef.current = null; + } + }); + + rewardRequestRef.current = { accountUserId: activeAccountUserId, request }; + } + + checkReward(); + window.addEventListener("focus", checkReward); + return () => { + if (activeAccountUserIdRef.current === activeAccountUserId) { + activeAccountUserIdRef.current = null; + } + window.removeEventListener("focus", checkReward); + }; + }, [clients, refreshBalance, showTokenCredit, state.account.userId]); + + return null; +} diff --git a/App/frontend/desktop/src/i18n/messages.ts b/App/frontend/desktop/src/i18n/messages.ts index 968910a96..dfd42f44e 100644 --- a/App/frontend/desktop/src/i18n/messages.ts +++ b/App/frontend/desktop/src/i18n/messages.ts @@ -179,6 +179,21 @@ export const zhCNMessages = { "githubStar.bodyLine2": "你的 Star 会帮助更多人发现它", "githubStar.cta": "去 GitHub 支持 ⭐", "githubStar.later": "以后再说", + "campaignPrompt.eyebrow": "🥮 中秋限定 · 至 9.30", + "campaignPrompt.title": "中秋弹幕活动开始了", + "campaignPrompt.poemLine1": "🥮 中秋年年吃月饼", + "campaignPrompt.poemLine2": "🍙 饭团也可以是圆的", + "campaignPrompt.poemLine3": "吃完打卡评价还能领 Token", + "campaignPrompt.cta": "去官网参与活动", + "campaignPromptIntl.eyebrow": "🌕 中秋限定", + "campaignPromptIntl.title": "最圆的月亮就要来了", + "campaignPromptIntl.poemLine1": "🌕 听说,一年里最圆的月亮就要来了", + "campaignPromptIntl.poemLine2": "而我们的饭团?比它还圆", + "campaignPromptIntl.poemLine3": "不信?咬一口、留个评价,还能顺手领点 Token", + "campaignPromptIntl.cta": "去官网参与活动", + "tokenCredit.title": "🎁 弹幕抽奖 Token 已到账", + "tokenCredit.body": "发弹幕抽奖的奖励已发放到你的账户", + "notifications.regionLabel": "通知", "login.title": "创建你的 Memmy 账号", "login.identifier": "邮箱或手机号", "login.phonePlaceholder": "请输入手机号", @@ -1884,6 +1899,21 @@ export const enUSMessages: Record = { "githubStar.bodyLine2": "Your Star helps more people discover it", "githubStar.cta": "Star on GitHub ⭐️", "githubStar.later": "Maybe later", + "campaignPrompt.eyebrow": "🥮 Limited · until Sep 30", + "campaignPrompt.title": "Mid-Autumn comment draw is on", + "campaignPrompt.poemLine1": "🥮 Mooncakes every Mid-Autumn", + "campaignPrompt.poemLine2": "🍙 Rice balls can be round too", + "campaignPrompt.poemLine3": "Check in, share a review, get Token", + "campaignPrompt.cta": "Join on our site", + "campaignPromptIntl.eyebrow": "🌕 Mid-Autumn · limited time", + "campaignPromptIntl.title": "The roundest moon is almost here", + "campaignPromptIntl.poemLine1": "🌕 Word is, the year's roundest moon is almost here.", + "campaignPromptIntl.poemLine2": "Our onigiri? Even rounder.", + "campaignPromptIntl.poemLine3": "Doubt it? Take a bite, leave a review, and grab some tokens.", + "campaignPromptIntl.cta": "Join on our site", + "tokenCredit.title": "🎁 Comment-draw Token credited", + "tokenCredit.body": "Your comment-draw prize is now in your account", + "notifications.regionLabel": "Notifications", "login.title": "Create your Memmy account", "login.identifier": "Email or phone", "login.phonePlaceholder": "Enter your phone number", diff --git a/App/frontend/desktop/src/pages/agent-thread-messages.tsx b/App/frontend/desktop/src/pages/agent-thread-messages.tsx index 88a5d1f97..29588a66c 100644 --- a/App/frontend/desktop/src/pages/agent-thread-messages.tsx +++ b/App/frontend/desktop/src/pages/agent-thread-messages.tsx @@ -2334,7 +2334,7 @@ const TRACE_CATEGORY_ICONS: Record segment.type === "toolGroup" + ? segment.items.filter((item): item is ActivityToolStepItem => item.type === "toolStep") + : []); + steps.forEach((step, index) => { + if (step.event.phase !== "error") return; + const name = toolEventName(step.event); + if (!name) return; + step.recovered = steps.slice(index + 1).some((candidate) => ( + toolEventName(candidate.event) === name && toolEventSucceededForDisplay(candidate.event) + )); + }); +} + +function toolEventSucceededForDisplay(event: AgentToolProgressEvent): boolean { + if (event.phase !== "end" || event.error != null) return false; + if (typeof event.result === "string") { + const result = event.result.trim(); + if (/^(?:error|plugin_invalid|invalid|failed|failure)\s*:/iu.test(result)) return false; + if (result.startsWith("{")) { + try { + const parsed: unknown = JSON.parse(result); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + const record = parsed as Record; + if (record.ok === false || record.success === false || record.error) return false; + } + } catch { + // A non-JSON textual result may still represent a successful tool response. + } + } + } else if (event.result && typeof event.result === "object" && !Array.isArray(event.result)) { + const record = event.result as Record; + if (record.ok === false || record.success === false || record.error) return false; + } + return true; +} + function appendToolGroupSegment( segments: ActivitySegment[], key: string, diff --git a/App/frontend/desktop/src/pages/home-page.tsx b/App/frontend/desktop/src/pages/home-page.tsx index 484e141a6..f6ea3c1d3 100644 --- a/App/frontend/desktop/src/pages/home-page.tsx +++ b/App/frontend/desktop/src/pages/home-page.tsx @@ -51,7 +51,7 @@ import { type PendingFileAttachment, type PendingImage } from "../state/agent-composer-state.js"; -import { createModelWorkspace, resolveModelSelection } from "../state/model-workspace.js"; +import { createModelWorkspace, defaultModelSelectionInput, resolveModelSelection } from "../state/model-workspace.js"; import { AgentCommandPalette, buildVisibleSlashCommands, @@ -898,6 +898,27 @@ export function HomePage() { allowUnassignedSelected: pendingModelPreset == null && Boolean(committedModelSelection) } ); + // Persist the model the user picks in the chat selector as the mode default, so a new chat + // after restart defaults to the last-used model instead of the platform default. Only the + // account/byok `agent.default` is written (the backend mirrors it to agents.defaults.modelPreset); + // the per-chat selection stays in Agent state. Relies on the BYOK-candidate enrollment fix so + // the default survives the account-mode startup re-projection. + const persistDefaultModelRef = useRef(false); + const persistDefaultModel = useCallback(async (candidateId: string) => { + const configClient = clients?.config; + if (!configClient || persistDefaultModelRef.current) return; + const input = defaultModelSelectionInput(modelWorkspace, modelWorkspaceMode, candidateId); + if (!input) return; + persistDefaultModelRef.current = true; + try { + const saved = await configClient.saveModelCatalog(input); + dispatch(appActions.modelConfigUpdated(saved)); + } catch { + // The per-chat selection is already applied; a failed default persist must not break it. + } finally { + persistDefaultModelRef.current = false; + } + }, [clients, modelWorkspace, modelWorkspaceMode, dispatch]); useEffect(() => { setAnalyticsModelSource(resolvedConversationModel.candidate?.source ?? null); return () => setAnalyticsModelSource(null); @@ -2726,6 +2747,7 @@ export function HomePage() { scopeKey={modelSelectionScopeKey} disabled={isCurrentAgentRunning || isCreatingChat || messageSendInFlight} seedConfig={state.modelConfig} + onDefaultModelSelected={persistDefaultModel} />