diff --git a/.gitignore b/.gitignore index a885b99d..c1a0c68d 100644 --- a/.gitignore +++ b/.gitignore @@ -43,5 +43,6 @@ frontend/pnpm-workspace.yaml /benchmarks/xalen_oracle/target/ /benchmarks/vedastro_nuget_probe/bin/ /benchmarks/vedastro_nuget_probe/obj/ -/benchmarks/vedastro_nuget_probe/bin/ -/benchmarks/vedastro_nuget_probe/obj/ + +# Local isolated feature worktrees +.worktrees/ diff --git a/deploy/README.md b/deploy/README.md index 78965b38..00feac8b 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -72,14 +72,20 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=... SUPABASE_SERVICE_ROLE_KEY=... ADMIN_EMAILS=... -# Either OpenAI: -OPENAI_API_KEY=... -MASTRA_MODEL=... +# Recommended multi-model catalog. The JSON references server-only keys. +LLM_DEFAULT_MODEL_ID=deepseek-pro +LLM_MODELS_JSON='[{"id":"deepseek-pro","label":"DeepSeek V4 Pro","description":"更适合复杂分析","provider":"openai-compatible","baseURL":"https://api.deepseek.com","apiKeyEnv":"DEEPSEEK_API_KEY","model":"deepseek-v4-pro","creditCost":1},{"id":"gpt-5-mini","label":"ChatGPT 5 Mini","description":"响应稳定、速度均衡","provider":"openai","apiKeyEnv":"OPENAI_API_KEY","model":"openai/gpt-5-mini","creditCost":1}]' +DEEPSEEK_API_KEY= +OPENAI_API_KEY= -# Or an OpenAI-compatible provider: -LLM_BASE_URL=... -LLM_API_KEY=... -LLM_MODEL=... +# Legacy single-model OpenAI configuration remains supported: +# OPENAI_API_KEY= +# MASTRA_MODEL=openai/gpt-5-mini + +# Legacy single OpenAI-compatible provider remains supported: +# LLM_BASE_URL=https://provider.example/v1 +# LLM_API_KEY= +# LLM_MODEL=provider-model-id # Optional VedAstro official upstream; local fallback remains available: VEDASTRO_API_ENDPOINT=... @@ -152,7 +158,7 @@ ssh -p 22000 root@103.117.123.53 \ Expected: HTTP `200`, `"status": "ok"`, and `"swisseph_available": true`. Public access to `103.117.123.53:5200` must fail. -Before deploying application code that depends on a new Supabase RPC, run `cd frontend && npx supabase db push --linked`; the GitHub deployment workflow does not apply database migrations. Then manually verify: OTP login, onboarding/profile persistence, chat-session persistence, code redemption, admin code generation, the 2.5-second free undo window, streaming response, one-credit charge, refund before the first output chunk, and charged stop with partial output preserved after streaming starts. +Before deploying application code that depends on any new Supabase migration (columns, tables, grants, policies, or RPCs), run `cd frontend && npx supabase db push --linked`; the GitHub deployment workflow does not apply database migrations. Multi-model chat specifically requires `20260717010000_chat_session_model.sql` before the new web image is deployed. Then manually verify: OTP login, onboarding/profile persistence, per-session `model_id` persistence, code redemption, admin code generation, authenticated `/api/models` returns only sanitized public metadata, invalid model IDs are rejected before charging, each configured model can answer, the 2.5-second free undo window, streaming response, one-credit charge, refund before the first output chunk, and charged stop with partial output preserved after streaming starts. ## Common operations diff --git a/docs/superpowers/plans/2026-07-17-multi-model-chat-selection.md b/docs/superpowers/plans/2026-07-17-multi-model-chat-selection.md new file mode 100644 index 00000000..605b2f83 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-multi-model-chat-selection.md @@ -0,0 +1,443 @@ +# Multi-Model Chat Selection Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let signed-in users choose a server-configured language model per chat session without exposing provider credentials or breaking the existing credit settlement rules. + +**Architecture:** A server-only catalog parses `LLM_MODELS_JSON`, resolves referenced secret environment variables, and exposes only sanitized metadata through an authenticated endpoint. The browser stores only a stable model ID in each Supabase chat session and submits that ID with a consultation; the server resolves it before reserving a credit and selects a cached Mastra Agent for that model. + +**Tech Stack:** Next.js 16 App Router, React 19, TypeScript 5, Zod 3, Mastra 1.50, Base UI Popover, Supabase Postgres/RLS, Node test runner, CSS design tokens. + +## Global Constraints + +- Local secrets live only in `frontend/.env.local`; production secrets live only in `/opt/jyotisha-app/.env.production`. +- Never expose provider URLs, API model IDs, secret environment-variable names, or credentials through browser payloads or logs. +- Model selection is remembered per session and may change only between messages. +- Onboarding always uses the configured default model. +- Every enabled model costs exactly one credit in this release. +- Resolve and reject an unknown model before calling `begin_consultation_credit`. +- Preserve the existing free undo, pre-output refund, and post-output charged-stop behavior. +- Follow `frontend/DESIGN.md`; no new raw color, typography, spacing, shadow, or motion token. +- Preserve unrelated dirty files owned by other agents. + +--- + +### Task 1: Server Model Catalog + +**Files:** +- Modify: `frontend/src/mastra/model.ts` +- Create: `frontend/tests/model-catalog.test.ts` +- Modify: `frontend/README.md` +- Modify: `deploy/README.md` + +**Interfaces:** +- Produces: `resolveLanguageModelCatalog(environment)`, `languageModelCatalog`, `resolveLanguageModel(modelId)`, `defaultLanguageModel()`, `publicLanguageModelCatalog()`. +- Produces public shape: `{ id, label, description, creditCost: 1, isDefault }`. +- Consumes: `MastraModelConfig`, Zod, `NodeJS.ProcessEnv`. + +- [x] **Step 1: Write failing catalog tests** + +Add tests for a two-model catalog, secret redaction, invalid entries, unknown defaults, and legacy single-model fallback: + +```ts +import assert from "node:assert/strict"; +import test from "node:test"; +import { resolveLanguageModelCatalog } from "../src/mastra/model.ts"; + +test("resolves two configured models while returning sanitized public metadata", () => { + // Given + const environment = { + LLM_DEFAULT_MODEL_ID: "deepseek-pro", + LLM_MODELS_JSON: JSON.stringify([ + { + id: "deepseek-pro", + label: "DeepSeek V4 Pro", + description: "复杂分析", + provider: "openai-compatible", + baseURL: "https://api.deepseek.com", + apiKeyEnv: "DEEPSEEK_API_KEY", + model: "deepseek-v4-pro", + creditCost: 1, + }, + { + id: "gpt-mini", + label: "ChatGPT Mini", + description: "均衡响应", + provider: "openai", + apiKeyEnv: "OPENAI_API_KEY", + model: "openai/gpt-5-mini", + creditCost: 1, + }, + ]), + DEEPSEEK_API_KEY: "deepseek-secret", + OPENAI_API_KEY: "openai-secret", + }; + + // When + const catalog = resolveLanguageModelCatalog(environment); + + // Then + assert.equal(catalog.defaultModelId, "deepseek-pro"); + assert.deepEqual(catalog.publicModels[0], { + id: "deepseek-pro", + label: "DeepSeek V4 Pro", + description: "复杂分析", + creditCost: 1, + isDefault: true, + }); + assert.equal(JSON.stringify(catalog.publicModels).includes("secret"), false); + assert.equal(JSON.stringify(catalog.publicModels).includes("baseURL"), false); +}); +``` + +- [x] **Step 2: Run the catalog test and verify RED** + +Run: `cd frontend && node --test tests/model-catalog.test.ts` + +Expected: FAIL because `resolveLanguageModelCatalog` is not exported. + +- [x] **Step 3: Implement the catalog parser and resolver** + +Use a Zod boundary for each raw catalog item and return immutable resolved entries. Catalog entries produce an explicit Mastra configuration object containing the resolved server-side key; OpenAI-compatible entries also include their fixed URL. Resolve `apiKeyEnv` only on the server. Invalid items are excluded with redacted issue codes. If `LLM_MODELS_JSON` is absent, derive one entry from the shipped `LLM_*` or `OPENAI_*` variables. + +The catalog result must have this contract: + +```ts +export type PublicLanguageModel = { + readonly id: string; + readonly label: string; + readonly description: string; + readonly creditCost: 1; + readonly isDefault: boolean; +}; + +export type ResolvedLanguageModel = PublicLanguageModel & { + readonly model: MastraModelConfig; +}; + +export type LanguageModelCatalog = { + readonly models: readonly ResolvedLanguageModel[]; + readonly publicModels: readonly PublicLanguageModel[]; + readonly defaultModelId: string | null; + readonly issues: readonly string[]; +}; +``` + +- [x] **Step 4: Run catalog tests and type checking** + +Run: `cd frontend && node --test tests/model-catalog.test.ts && npx tsc --noEmit` + +Expected: all catalog tests PASS and TypeScript exits `0`. + +- [x] **Step 5: Document configuration** + +Update `frontend/README.md` and `deploy/README.md` with `LLM_MODELS_JSON`, `LLM_DEFAULT_MODEL_ID`, one secret environment variable per provider, and the existing single-model fallback. Use redacted values only. Do not add `frontend/.env.example`: the repository intentionally ignores all `.env*` files. + +- [x] **Step 6: Commit the catalog task** + +```bash +git add frontend/src/mastra/model.ts frontend/tests/model-catalog.test.ts frontend/README.md deploy/README.md docs/superpowers/plans/2026-07-17-multi-model-chat-selection.md +git commit -m "feat: add server model catalog" +``` + +--- + +### Task 2: Authenticated Model Endpoint and Agent Selection + +**Files:** +- Create: `frontend/src/app/api/models/route.ts` +- Modify: `frontend/src/mastra/index.ts` +- Modify: `frontend/src/app/api/consult/route.ts` +- Modify: `frontend/src/app/api/onboarding/route.ts` +- Create: `frontend/tests/public-models.test.ts` +- Create: `frontend/src/lib/public-models.ts` + +**Interfaces:** +- Consumes: catalog functions from Task 1. +- Produces: `GET /api/models -> { models, defaultModelId }` after Supabase authentication. +- Produces: `getJyotishAgent(model)` and `getOnboardingAgent(model)` process-local caches. +- Consultation request consumes `modelId: string`. + +- [x] **Step 1: Write failing public payload tests** + +Create a Zod client boundary that accepts only the sanitized response and rejects routing fields: + +```ts +import assert from "node:assert/strict"; +import test from "node:test"; +import { parsePublicModelCatalog } from "../src/lib/public-models.ts"; + +test("parses a sanitized public model catalog", () => { + // Given + const payload = { + defaultModelId: "deepseek-pro", + models: [{ + id: "deepseek-pro", + label: "DeepSeek V4 Pro", + description: "复杂分析", + creditCost: 1, + isDefault: true, + }], + }; + + // When + const catalog = parsePublicModelCatalog(payload); + + // Then + assert.equal(catalog.defaultModelId, "deepseek-pro"); + assert.equal(catalog.models.length, 1); +}); +``` + +- [x] **Step 2: Run the payload test and verify RED** + +Run: `cd frontend && node --test tests/public-models.test.ts` + +Expected: FAIL because the parser module does not exist. + +- [x] **Step 3: Implement the public parser and authenticated route** + +`parsePublicModelCatalog(value: unknown)` must use strict Zod objects so additional secret or routing fields are rejected. `GET /api/models` must authenticate through `createServerSupabaseClient`, return `401` when logged out, `503` when the catalog has no default, and otherwise return the sanitized catalog. + +- [x] **Step 4: Refactor Mastra Agent construction** + +Move the existing shared instructions into constants and build Agents through keyed factories: + +```ts +const jyotishAgents = new Map(); + +export function getJyotishAgent(model: ResolvedLanguageModel) { + const cached = jyotishAgents.get(model.id); + if (cached) return cached; + const agent = new Agent({ + id: `jyotish-guide-${model.id}`, + name: "Jyotish Guide", + model: model.model, + instructions: jyotishInstructions, + skills: [jyotishSkillPath], + tools: { consultationTool }, + }); + jyotishAgents.set(model.id, agent); + return agent; +} +``` + +Create the onboarding Agent with the default resolved model and keep its existing instructions unchanged. + +- [x] **Step 5: Select the model before credit reservation** + +Extend `chatRequestSchema` with `modelId: z.string().trim().min(1).max(64)`. Resolve the ID after authentication, request parsing, and prompt-extraction blocking, but before `begin_consultation_credit`. Return `409` with a safe message for an unavailable model. Use `getJyotishAgent(resolvedModel)` for streaming and record `resolvedModel.id` in `credit_transactions`. + +- [x] **Step 6: Run tests, type checking, and lint** + +Run: `cd frontend && npm test && npx tsc --noEmit && npm run lint` + +Expected: all tests PASS; type checking and lint exit `0`. + +- [x] **Step 7: Commit the endpoint task** + +```bash +git add frontend/src/app/api/models/route.ts frontend/src/lib/public-models.ts frontend/tests/public-models.test.ts frontend/src/mastra/index.ts frontend/src/app/api/consult/route.ts frontend/src/app/api/onboarding/route.ts +git commit -m "feat: route consultations by model" +``` + +--- + +### Task 3: Session Model Persistence + +**Files:** +- Create: `frontend/supabase/migrations/20260717010000_chat_session_model.sql` +- Modify: `frontend/src/app/page.tsx` +- Modify: `frontend/src/lib/public-models.ts` +- Modify: `frontend/tests/public-models.test.ts` + +**Interfaces:** +- Consumes: `PublicLanguageModelCatalog` from Task 2. +- Produces: `resolveSessionModelId(saved, catalog) -> { modelId, fellBack }`. +- Persists: `chat_sessions.model_id text`. + +- [x] **Step 1: Write failing session fallback tests** + +```ts +test("falls back to the configured default when a saved model is removed", () => { + // Given + const catalog = parsePublicModelCatalog({ + defaultModelId: "deepseek-pro", + models: [{ + id: "deepseek-pro", + label: "DeepSeek V4 Pro", + description: "复杂分析", + creditCost: 1, + isDefault: true, + }], + }); + + // When + const result = resolveSessionModelId("removed-model", catalog); + + // Then + assert.deepEqual(result, { modelId: "deepseek-pro", fellBack: true }); +}); +``` + +- [x] **Step 2: Run the fallback test and verify RED** + +Run: `cd frontend && node --test tests/public-models.test.ts` + +Expected: FAIL because `resolveSessionModelId` does not exist. + +- [x] **Step 3: Implement fallback and migration** + +Add nullable `model_id text` to `public.chat_sessions` and grant authenticated users column-level insert/update access. Do not store labels, provider fields, or secrets. + +`resolveSessionModelId` returns the saved ID when it is in the catalog and otherwise returns the default with `fellBack: true`. + +- [x] **Step 4: Wire persistence into the page** + +Extend `ChatSession` with `modelId`. Fetch `/api/models` during bootstrap, parse it through `parsePublicModelCatalog`, normalize loaded sessions, and persist fallback replacements once. New sessions use `defaultModelId`; `persistSession` reads/writes `model_id`; consultation requests include the active session's `modelId`. + +Preview mode must install a deterministic two-model catalog so browser QA can run without provider keys. + +- [x] **Step 5: Run focused tests and build** + +Run: `cd frontend && npm test && npx tsc --noEmit && npm run build` + +Expected: tests PASS and production build exits `0`. + +- [x] **Step 6: Commit persistence** + +```bash +git add frontend/supabase/migrations/20260717010000_chat_session_model.sql frontend/src/app/page.tsx frontend/src/lib/public-models.ts frontend/tests/public-models.test.ts +git commit -m "feat: persist session model choice" +``` + +--- + +### Task 4: Composer Model Selection Bubble + +**Files:** +- Create: `frontend/src/components/model-selector.tsx` +- Modify: `frontend/src/app/page.tsx` +- Modify: `frontend/src/app/globals.css` +- Modify: `frontend/DESIGN.md` + +**Interfaces:** +- Consumes: `readonly PublicLanguageModel[]`, selected ID, disabled state, selection callback. +- Produces: accessible Base UI Popover with native radio inputs. + +- [x] **Step 1: Add the model-selector primitive to `DESIGN.md`** + +Document the compact trigger, upward warm-canvas popup, radio rows, 44px touch target, focus behavior, disabled request states, and existing motion/token usage before writing JSX or CSS. + +- [x] **Step 2: Add the component in preview mode and observe RED behavior** + +Render a temporary import of the not-yet-created `ModelSelector` in the composer footer and run `cd frontend && npx tsc --noEmit`. + +Expected: FAIL because `frontend/src/components/model-selector.tsx` does not exist. + +- [x] **Step 3: Implement the Base UI Popover** + +Use `Popover.Root`, `Trigger`, `Portal`, `Positioner side="top" align="start"`, and `Popup`. Render a `role="radiogroup"` whose rows contain controlled native radio inputs. Selecting an item closes the popup and invokes the supplied callback. Base UI owns Escape, outside press, focus restoration, and collision positioning. + +Component contract: + +```ts +type ModelSelectorProps = { + readonly models: readonly PublicLanguageModel[]; + readonly selectedModelId: string; + readonly disabled: boolean; + readonly onSelect: (modelId: string) => void; +}; +``` + +- [x] **Step 4: Persist selection from the page** + +Place the trigger below `.composer` and before the status line. Optimistically update the active session, persist it immediately, retain the visible choice on sync failure, and show a retryable composer notice. Disable selection while undo, streaming, cancellation, session creation, or model loading is active. + +- [x] **Step 5: Style entirely from existing design tokens** + +Add `.composer-tools`, `.model-selector-*` rules using current canvas, border, ink, radius, spacing, shadow, type, and 120/180ms motion tokens. Constrain the popup to the viewport and keep each row at least 44px. Add reduced-motion behavior through the existing media query. + +- [x] **Step 6: Run static verification** + +Run: `cd frontend && npm test && npx tsc --noEmit && npm run lint && npm run build` + +Expected: all commands exit `0`. + +- [x] **Step 7: Commit the UI task** + +```bash +git add frontend/src/components/model-selector.tsx frontend/src/app/page.tsx frontend/src/app/globals.css frontend/DESIGN.md +git commit -m "feat: add chat model selector" +``` + +--- + +### Task 5: Migration, Runtime QA, Slop Audit, and Delivery + +**Files:** +- Modify only if verification exposes a defect in files already owned by Tasks 1–4. + +**Interfaces:** +- Consumes the complete feature. +- Produces fresh test, browser, migration, security, and deployment evidence. + +- [x] **Step 1: Run the complete relevant verification set** + +```bash +cd frontend +npm test +npx tsc --noEmit +npm run lint +npm run build +``` + +Run focused repository contracts from the repository root: + +```bash +.venv/bin/python -m pytest -q \ + tests/test_supabase_user_data_contract.py \ + tests/test_agent_chat_contract.py \ + tests/test_railway_deployment.py \ + tests/test_frontend_theme_contract.py +``` + +Expected: all relevant checks PASS. Name any unrelated pre-existing failure without modifying it. + +- [ ] **Step 2: Apply and verify the Supabase migration** + +Deferred for the local trial: the migration file is verified, but the linked remote database is intentionally unchanged until the user approves deployment preparation. + +Run: `cd frontend && npx supabase db push --linked` + +Then run: `npx supabase migration list --linked` + +Expected: local and remote both list `20260717010000`. + +- [x] **Step 3: Run real browser QA** + +Start the production-like app with preview data, then drive it through the in-app browser or Playwright at 375px, 768px, and 1280px. Verify open/close, radio keyboard behavior, Escape, focus return, model switching, per-session persistence, disabled state during undo/streaming, no horizontal overflow, and no console errors. Inspect the `/api/models` payload to confirm no provider routing or secret fields are present. + +- [x] **Step 4: Run the requested AI-slop audit** + +Run: + +```bash +node ../.agents/skills/kill-ai-slop/scripts/scan.mjs frontend/src --json +``` + +Review every hit against `frontend/DESIGN.md`; fix confirmed slop and retain only deliberate, documented patterns. + +- [x] **Step 5: Run final review and debugging gates** + +Review goal coverage, QA evidence, code quality, security, and missed context. Record at least three runtime hypotheses and the evidence that ruled each in or out. Fix every blocking finding and rerun only the checks whose inputs changed. + +- [x] **Step 6: Commit verification fixes** + +If verification required changes, stage only feature-owned files and commit them with a focused `fix:` message. If no files changed, do not create an empty commit. + +- [ ] **Step 7: Publish through the user-selected Git workflow** + +Deferred by user request: keep the branch local and unpushed until the local service trial is accepted. + +After fresh verification, preserve unrelated work, inspect branch/upstream state, and use the finishing-a-development-branch workflow. Push only after the feature commits and migration evidence are complete; if merged to `main`, monitor CI and production deployment through the existing workflows and run the production smoke checks documented in `deploy/README.md`. diff --git a/docs/superpowers/specs/2026-07-17-multi-model-chat-selection-design.md b/docs/superpowers/specs/2026-07-17-multi-model-chat-selection-design.md new file mode 100644 index 00000000..f4c7806a --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-multi-model-chat-selection-design.md @@ -0,0 +1,251 @@ +# Multi-Model Chat Selection Design + +Date: 2026-07-17 +Status: Approved in conversation; awaiting written-spec review + +## 1. Goal + +Allow a signed-in user to choose which configured language model answers the next message in a chat session. The selected model is remembered per session, may be changed between messages, and never exposes provider credentials or arbitrary provider URLs to the browser. + +The first release supports DeepSeek and OpenAI while keeping the provider layer generic enough for additional OpenAI-compatible models. Every enabled model costs one consultation credit in this release. The catalog still carries `creditCost` so differentiated pricing can be introduced deliberately later. + +## 2. Product Decisions + +- Model selection is stored per chat session and synchronized through Supabase. +- A user may switch models before any new message; the switch affects only the next and later messages. +- Existing message history is sent normally after a switch. +- Model selection is disabled during the undo window, streaming, cancellation, and settlement. +- Onboarding generation uses the server-configured default model, not the active session model. +- Every enabled model costs one credit in the first release. +- Provider keys remain server-only. The client submits only a catalog model ID. + +## 3. Configuration + +### 3.1 Locations + +- Local development: `frontend/.env.local` +- Production: `/opt/jyotisha-app/.env.production` + +Neither file is committed. Model secrets must never use a `NEXT_PUBLIC_` prefix. + +### 3.2 Catalog shape + +`LLM_MODELS_JSON` contains non-secret routing metadata and references a separate environment variable for each key: + +```dotenv +LLM_DEFAULT_MODEL_ID=deepseek-pro + +LLM_MODELS_JSON='[ + { + "id": "deepseek-pro", + "label": "DeepSeek V4 Pro", + "description": "更适合复杂分析", + "provider": "openai-compatible", + "baseURL": "https://api.deepseek.com", + "apiKeyEnv": "DEEPSEEK_API_KEY", + "model": "deepseek-v4-pro", + "creditCost": 1 + }, + { + "id": "gpt-5-mini", + "label": "ChatGPT 5 Mini", + "description": "响应稳定、速度均衡", + "provider": "openai", + "apiKeyEnv": "OPENAI_API_KEY", + "model": "openai/gpt-5-mini", + "creditCost": 1 + } +]' + +DEEPSEEK_API_KEY= +OPENAI_API_KEY= +``` + +Catalog validation requires: + +- unique, stable, URL-safe IDs; +- non-empty labels and model identifiers; +- a supported provider value; +- an HTTPS `baseURL` for OpenAI-compatible providers; +- an existing non-empty environment variable named by `apiKeyEnv`; +- `creditCost` equal to `1` in this release; +- a default ID that resolves to an enabled model. + +An invalid catalog item is excluded from the public list and recorded through a redacted server warning. Secrets, complete configuration objects, and secret environment-variable values are never logged. + +### 3.3 Existing configuration compatibility + +If `LLM_MODELS_JSON` is absent, the server derives one default catalog item from the shipped single-model configuration: + +- `LLM_BASE_URL`, `LLM_API_KEY`, `LLM_MODEL`, and optional `LLM_PROVIDER_ID`; or +- `OPENAI_API_KEY` and `MASTRA_MODEL`. + +This preserves the current production deployment while allowing migration to the multi-model catalog. Once the catalog is present, it is authoritative and the legacy variables do not create additional choices. + +## 4. Server Architecture + +### 4.1 Model catalog module + +Replace the current import-time singleton with a server-only catalog module responsible for: + +- parsing and validating configuration; +- returning sanitized public metadata; +- resolving a submitted model ID to a Mastra model configuration; +- resolving the default model; +- reporting configuration problems without including secrets. + +The public model shape is limited to: + +```ts +type PublicModel = { + id: string; + label: string; + description: string; + creditCost: 1; + isDefault: boolean; +}; +``` + +### 4.2 Agent creation + +The Jyotish agent instructions, skills, and tools remain shared. Agent construction becomes a factory keyed by the resolved catalog model. A process-local cache avoids rebuilding an identical Agent for every request. + +The onboarding agent is created from the default model. A session choice never changes onboarding generation. + +### 4.3 Public models endpoint + +`GET /api/models`: + +- requires a valid Supabase user session; +- returns only enabled `PublicModel` items and the default ID; +- returns `503` with a safe message when no model is configured; +- never returns provider URLs, provider identifiers, model API identifiers, environment-variable names, or credentials. + +### 4.4 Consultation endpoint + +`POST /api/consult` accepts a bounded `modelId` string in addition to the current request fields. + +Processing order: + +1. authenticate the user; +2. validate the request shape and prompt safety; +3. resolve `modelId` against the server catalog; +4. reject unavailable models before any credit reservation; +5. reserve the consultation credit; +6. run the cached Agent for the resolved model; +7. settle using the existing undo, cancel, partial-output, and completion rules; +8. record the actual catalog model ID and token usage in `credit_transactions`. + +The client cannot submit a base URL, provider, API model identifier, or key. An unknown or disabled model returns a client-safe error and does not consume a credit. + +## 5. Persistence + +Add a nullable `model_id text` column to `chat_sessions` through a Supabase migration. + +- New sessions start with the current server default model ID. +- Selecting a model persists the changed session immediately. +- Existing rows with `NULL` resolve to the current default when read. +- If a stored ID is no longer available, the client selects the current default, persists it, and shows one non-blocking notice. +- No provider secret or provider configuration is stored in Supabase. + +The existing RLS policy continues to restrict session changes to the owning user. + +## 6. Composer Interaction + +Add a lightweight toolbar directly below the composer field and above the existing status/help text. + +### 6.1 Trigger + +- Left-aligned compact text control: selected model label plus a downward chevron. +- Right-aligned keyboard hint remains available on desktop. +- The control uses the current warm canvas, hairline, typography, focus ring, and motion tokens from `frontend/DESIGN.md`. +- It is not rendered as a large pill and does not introduce a new palette or shadow token. + +### 6.2 Selection bubble + +- Opens upward from the trigger so it remains visible above the viewport bottom. +- Uses radio semantics with one checked model. +- Each row shows the public label, concise description, and `1 点/次`. +- Selecting an item closes the bubble, restores focus to the trigger, updates the session, and persists it. +- Escape closes without changing the selection. +- Outside click closes without changing the selection. +- Touch targets are at least 44px high. +- Mobile width is constrained to the viewport; desktop width remains compact. + +### 6.3 Disabled states + +The trigger and options are disabled while: + +- a message is inside the 2.5-second free undo window; +- a response is streaming; +- cancellation or settlement is pending; +- session data or model catalog data is not yet ready. + +The displayed label is therefore guaranteed to match the model attached to an active request. + +## 7. Billing and Cancellation + +All catalog models use one credit in this release. Model resolution occurs before `begin_consultation_credit`, so an invalid or removed model cannot reserve a credit. + +After reservation, existing behavior remains authoritative: + +- cancel before the first output chunk: refund idempotently; +- stop or fail after output begins: keep partial content and charge; +- complete normally: charge and record token usage; +- free undo before the API call: no model invocation and no charge. + +The stored transaction model value is the stable catalog ID, not a user-supplied label. + +## 8. Error Handling + +- Catalog unavailable: disable sending and show a concise service-configuration message. +- Saved model removed: switch to default, persist, and show a one-time notice. +- Submitted model unknown: return a safe client error before billing; restore the question to the composer. +- Provider fails before output: use the existing cancellation/refund path. +- Provider fails after partial output: preserve the partial answer and complete billing. +- Session persistence fails after a local selection: keep the visible choice for the current page and show a retryable synchronization notice. + +No error response contains provider credentials, provider URLs, environment-variable names, internal catalog objects, or stack traces. + +## 9. Security Boundaries + +- The catalog parser and model resolver are server-only modules. +- The browser receives an allowlist, not executable provider configuration. +- Submitted IDs are length-limited and matched exactly against the allowlist. +- Provider URLs cannot be influenced by request data, preventing request-level SSRF. +- API keys stay in server environment variables and are passed directly to the model SDK. +- Logs use catalog IDs and redacted validation codes only. +- `/api/models` requires authentication to avoid exposing operational inventory unnecessarily. + +## 10. Verification + +Automated coverage must prove: + +- valid multi-model and legacy single-model configuration parsing; +- rejection of duplicates, missing keys, invalid URLs, and unknown defaults; +- sanitized `/api/models` output contains no secrets or server routing fields; +- unknown model rejection occurs before credit reservation; +- the selected catalog model reaches the Agent factory and usage ledger; +- session `model_id` round-trips through Supabase serialization; +- removed models fall back to the default; +- all cancellation and refund invariants remain unchanged. + +Manual browser QA must exercise: + +- opening and closing the model bubble; +- keyboard navigation, Escape, focus return, and radio state; +- switching models and sending the next message; +- session switching and page refresh persistence; +- disabled switching during undo and streaming; +- removed-model fallback messaging; +- 375px, 768px, and 1280px viewport layouts; +- console and network inspection confirming no credential or provider routing data reaches the browser. + +## 11. Deployment + +1. Apply the Supabase migration before deploying code that writes `model_id`. +2. Add `LLM_MODELS_JSON`, `LLM_DEFAULT_MODEL_ID`, and provider keys to `/opt/jyotisha-app/.env.production`. +3. Rebuild the web container; model configuration is read server-side at runtime. +4. Run CI, deploy through the existing `main` workflow, and verify `/api/models`, consultation billing, and internal health. + +The first production catalog will expose DeepSeek V4 Pro and the selected OpenAI model. Their exact public labels and descriptions come from the server catalog, while their credentials remain only in the production environment file. diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 607ebf15..d65d3897 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -93,6 +93,14 @@ The base unit is 4px. Tokens are `--space-1: 4px`, `--space-2: 8px`, `--space-3: - **States:** default, hover, focus with deep-brown ring, disabled, invalid, loading. - **Accessibility:** persistent label where practical; composer has an explicit accessible label. +### Model selector + +- **Structure:** a compact text trigger sits below the composer and opens an upward popover aligned to its left edge. The trigger shows only the active model name; each option shows only its model name and radio selection state. +- **Surface:** canvas trigger with no card treatment; the popover uses the elevated canvas recipe, warm hairlines, and one selected-surface row. The action color is reserved for the selected indicator and focus ring. +- **States:** closed, open, hover, focus-visible, selected, disabled, and unavailable catalog. Selecting a model closes the popover and only affects later messages in the current conversation. +- **Accessibility:** the trigger and every option meet the 44px touch target; options are a native radio group, with a small roving-focus fallback so Tab, arrow keys, Space, and screen readers consistently expose the selected model inside the popover. +- **Motion:** the popup enters over 120ms with opacity and a 4px vertical translation; reduced-motion removes the translation. + ### Navigation item - **Structure:** title, optional metadata, current-state marker. diff --git a/frontend/README.md b/frontend/README.md index 2b231d1e..ddae818c 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -23,14 +23,15 @@ Browser - Node.js 20+ - Python 3.11 或 3.12(主项目代码不兼容系统自带的 Python 3.9) -- OpenAI 或兼容 OpenAI Chat Completions 的第三方模型 Key。未配置时仍可返回 Python 引擎摘要,但不会生成完整 AI 解读。 +- OpenAI 或兼容 OpenAI Chat Completions 的第三方模型 Key。至少要配置一个可用模型;未配置时咨询入口会明确提示模型服务不可用,不会扣点或返回伪造摘要。 - Supabase 项目,用于邮箱 OTP 登录、咨询点数、一次性兑换码和账务流水。 ## 配置 ```bash cd /Users/jesse/Downloads/Copse/astrology/yinduzhanxing/frontend -cp .env.example .env.local +# 仓库不提供含占位密钥的 .env.example;请新建仅供本机使用的 .env.local +$EDITOR .env.local ``` `.env.local`: @@ -39,24 +40,27 @@ cp .env.example .env.local # Python 占星计算服务 JYOTISH_API_BASE=http://127.0.0.1:5200 -# 方案 A:默认 OpenAI -OPENAI_API_KEY=sk-... -MASTRA_MODEL=openai/gpt-5-mini +# 推荐:多模型目录。目录只保存路由元数据,Key 由 apiKeyEnv 引用。 +LLM_DEFAULT_MODEL_ID=deepseek-pro +LLM_MODELS_JSON='[{"id":"deepseek-pro","label":"DeepSeek V4 Pro","description":"更适合复杂分析","provider":"openai-compatible","baseURL":"https://api.deepseek.com","apiKeyEnv":"DEEPSEEK_API_KEY","model":"deepseek-v4-pro","creditCost":1},{"id":"gpt-5-mini","label":"ChatGPT 5 Mini","description":"响应稳定、速度均衡","provider":"openai","apiKeyEnv":"OPENAI_API_KEY","model":"openai/gpt-5-mini","creditCost":1}]' +DEEPSEEK_API_KEY= +OPENAI_API_KEY= -# 方案 B:任意 OpenAI-compatible 第三方模型 -# 只要填写任一 LLM_* 项,应用便会优先使用本方案。 -# Base URL 通常填写到 /v1,不要填写完整 /chat/completions 地址。 -LLM_BASE_URL=https://your-provider.example/v1 -LLM_API_KEY=your-secret-key -LLM_MODEL=your-model-id -# 可选:只作为 Mastra 内部标签,不影响请求地址 -LLM_PROVIDER_ID=third-party +# 兼容旧的单模型 OpenAI 配置 +# OPENAI_API_KEY= +# MASTRA_MODEL=openai/gpt-5-mini + +# 兼容旧的单个 OpenAI-compatible 配置 +# LLM_BASE_URL=https://your-provider.example/v1 +# LLM_API_KEY= +# LLM_MODEL=your-model-id +# LLM_PROVIDER_ID=third-party # 可选:部署目录与本仓结构不同时,显式指定 Mastra Skill 目录 # JYOTISH_SKILL_PATH=/absolute/path/to/yinduzhanxing/skills/jyotish-vedic-astrology ``` -第三方端点必须兼容 OpenAI 的 Chat Completions 调用方式,并支持工具调用(function calling),否则 Agent 无法稳定调用占星计算工具。密钥只放在 `.env.local`,**不要**加 `NEXT_PUBLIC_` 前缀,也不要提交到 Git。每次修改 `.env.local` 后重启 Next.js 开发服务器。 +第三方端点必须兼容 OpenAI 的 Chat Completions 调用方式,并支持工具调用(function calling),否则 Agent 无法稳定调用占星计算工具。`LLM_MODELS_JSON` 只能填写服务端认可的固定地址和模型;浏览器只会得到模型 ID、名称、说明和点数。密钥只放在 `.env.local`,**不要**加 `NEXT_PUBLIC_` 前缀,也不要提交到 Git。每次修改 `.env.local` 后重启 Next.js 开发服务器。 ## Skill 如何触发 @@ -79,7 +83,7 @@ LLM_PROVIDER_ID=third-party -> Agent 流式组织聊天回答 ``` -必须配置可用的模型 Key 才会进入这条链路。没有配置模型时,`/api/consult` 会直接返回 Python 引擎摘要,此时不会运行 Mastra Agent,也不会加载 Skill。可在浏览器 Network 中查看 `/api/consult` 响应头:`x-ayanam-mode: mastra` 表示请求进入了 Agent;`x-ayanam-mode: engine` 表示只运行了 Python 引擎。 +必须配置可用的模型 Key 才会进入这条链路。没有配置模型时,`/api/models` 返回 `503`,聊天框会停止发送;`/api/consult` 也会在预扣点数前拒绝未知或不可用模型。成功咨询始终由 Mastra Agent 生成流式回答。 仓库根目录的主 `SKILL.md` 通过 `skills/jyotish-vedic-astrology/` 这个 Mastra 兼容目录加载。该目录名必须与 Skill frontmatter 中的 `name` 一致。生产部署时需确保 `SKILL.md`、`references/`、`scripts/` 和 `assets/` 一起存在;如果目录结构不同,请设置 `JYOTISH_SKILL_PATH`。 @@ -175,12 +179,13 @@ supabase/migrations/20260715020000_service_role_table_grants.sql supabase/migrations/20260715030000_user_profiles_chat_sessions.sql supabase/migrations/20260715040000_agent_onboarding_cache.sql supabase/migrations/20260717000000_consultation_request_lifecycle.sql +supabase/migrations/20260717010000_chat_session_model.sql ``` 迁移会创建: - `profiles`:用户点数余额、称呼与出生档案。 -- `chat_sessions`:用户的聊天 Session、消息和最近更新时间。 +- `chat_sessions`:用户的聊天 Session、消息、每个 Session 选用的模型和最近更新时间。 - `redemption_codes`:只保存兑换码 SHA-256 与掩码,不保存完整码。 - `credit_transactions`:兑换、预扣、退款和模型 Token 用量流水。 - `redeem_code`:一次性兑换,使用行锁保证同一码全局只成功一次,并记录兑换账户。 @@ -235,12 +240,14 @@ NEXT_PUBLIC_SUPABASE_URL=... NEXT_PUBLIC_SUPABASE_ANON_KEY=... SUPABASE_SERVICE_ROLE_KEY=... ADMIN_EMAILS=... +LLM_DEFAULT_MODEL_ID=deepseek-pro +LLM_MODELS_JSON='[{"id":"deepseek-pro","label":"DeepSeek V4 Pro","description":"更适合复杂分析","provider":"openai-compatible","baseURL":"https://api.deepseek.com","apiKeyEnv":"DEEPSEEK_API_KEY","model":"deepseek-v4-pro","creditCost":1},{"id":"gpt-5-mini","label":"ChatGPT 5 Mini","description":"响应稳定、速度均衡","provider":"openai","apiKeyEnv":"OPENAI_API_KEY","model":"openai/gpt-5-mini","creditCost":1}]' +DEEPSEEK_API_KEY=... OPENAI_API_KEY=... -MASTRA_MODEL=openai/gpt-5-mini JYOTISH_API_BASE=https://your-python-api.example.com ``` -如果使用第三方模型,则用 `LLM_BASE_URL`、`LLM_API_KEY`、`LLM_MODEL` 替换 OpenAI 配置。所有服务端 Key 只配置在 Vercel,不要写进浏览器代码。 +多个模型优先使用 `LLM_MODELS_JSON`;旧的 `LLM_BASE_URL`、`LLM_API_KEY`、`LLM_MODEL` 单模型配置仍兼容。所有服务端 Key 只配置在 Vercel,不要写进浏览器代码。 ### Python 服务必须单独部署 @@ -259,6 +266,8 @@ Vercel 上的 Next.js 不能访问你电脑的 `127.0.0.1:5200`。需要把仓 8. 用户在 2.5 秒撤回窗口内停止时不调用模型、不扣点 9. 撤回窗口结束后、首个输出分片前取消时点数退回 10. 用户已经收到输出后停止时保留已有内容并正常计费 +11. 登录后 `/api/models` 只返回模型 ID、名称、说明、点数和默认状态,不包含 Key、端点或环境变量名 +12. 不同 Session 能保存各自的模型选择;已下线模型会回退到默认模型 ``` ## Demo 防滥用边界 diff --git a/frontend/src/app/api/chart-profiles/[id]/route.ts b/frontend/src/app/api/chart-profiles/[id]/route.ts new file mode 100644 index 00000000..bc49ae14 --- /dev/null +++ b/frontend/src/app/api/chart-profiles/[id]/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; +import { isSupabaseConfigurationError } from "@/lib/supabase/config"; + +type RouteContext = { + params: Promise<{ id: string }>; +}; + +function errorMessage(error: unknown, fallback: string) { + return error instanceof Error ? error.message : fallback; +} + +export async function DELETE(_request: Request, context: RouteContext) { + try { + const { id } = await context.params; + const supabase = await createServerSupabaseClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); + + const { error } = await supabase + .from("chart_profiles") + .delete() + .eq("id", id) + .eq("user_id", user.id) + .eq("role", "other"); + + if (error) throw error; + return NextResponse.json({ ok: true }); + } catch (error) { + if (isSupabaseConfigurationError(error)) { + return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 }); + } + return NextResponse.json({ error: errorMessage(error, "星盘删除失败") }, { status: 500 }); + } +} diff --git a/frontend/src/app/api/chart-profiles/route.ts b/frontend/src/app/api/chart-profiles/route.ts new file mode 100644 index 00000000..e537af1d --- /dev/null +++ b/frontend/src/app/api/chart-profiles/route.ts @@ -0,0 +1,95 @@ +import { NextResponse } from "next/server"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; +import { isSupabaseConfigurationError } from "@/lib/supabase/config"; + +type ChartProfilePayload = { + id?: string; + role?: "self" | "other"; + profile?: unknown; +}; + +function errorMessage(error: unknown, fallback: string) { + return error instanceof Error ? error.message : fallback; +} + +export async function GET() { + try { + const supabase = await createServerSupabaseClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); + + const { data, error } = await supabase + .from("chart_profiles") + .select("id, role, profile, updated_at") + .eq("user_id", user.id) + .order("updated_at", { ascending: false }); + + if (error) throw error; + return NextResponse.json({ profiles: data ?? [] }); + } catch (error) { + if (isSupabaseConfigurationError(error)) { + return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 }); + } + return NextResponse.json({ error: errorMessage(error, "星盘库暂时不可用") }, { status: 500 }); + } +} + +export async function POST(request: Request) { + try { + const supabase = await createServerSupabaseClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); + + const body = await request.json().catch(() => null) as ChartProfilePayload | null; + if (!body?.profile || typeof body.profile !== "object") { + return NextResponse.json({ error: "星盘资料格式不正确" }, { status: 400 }); + } + const role = body.role === "self" ? "self" : "other"; + const updatedAt = new Date().toISOString(); + let data; + let error; + if (role === "self") { + const existing = await supabase + .from("chart_profiles") + .select("id") + .eq("user_id", user.id) + .eq("role", "self") + .maybeSingle(); + if (existing.error) throw existing.error; + const query = existing.data?.id + ? supabase + .from("chart_profiles") + .update({ profile: body.profile, updated_at: updatedAt }) + .eq("id", existing.data.id) + .select("id, role, profile, updated_at") + .single() + : supabase + .from("chart_profiles") + .insert({ user_id: user.id, role, profile: body.profile, updated_at: updatedAt }) + .select("id, role, profile, updated_at") + .single(); + ({ data, error } = await query); + } else { + const record = { + ...(body.id ? { id: body.id } : {}), + user_id: user.id, + role, + profile: body.profile, + updated_at: updatedAt, + }; + ({ data, error } = await supabase + .from("chart_profiles") + .upsert(record, { onConflict: "id" }) + .select("id, role, profile, updated_at") + .single()); + } + + if (error) throw error; + return NextResponse.json({ profile: data }); + } catch (error) { + if (isSupabaseConfigurationError(error)) { + return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 }); + } + return NextResponse.json({ error: errorMessage(error, "星盘保存失败") }, { status: 500 }); + } +} diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index 390ddbd6..e0102815 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -1,15 +1,15 @@ import { NextResponse } from "next/server"; import { consultationInputSchema, - jyotishAgent, - runConsultationWorkflow, + getJyotishAgent, } from "@/mastra"; import { languageModelConfigurationMessage, - languageModelSettings, + resolveLanguageModel, } from "@/mastra/model"; import { blocksPromptExtraction } from "@/lib/consult-safety"; import { CreditRpcError, runCreditRpc } from "@/lib/consultation-billing"; +import { reserveConsultationModel } from "@/lib/consultation-model-selection"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { streamTextResponse } from "@/lib/stream-text-response"; @@ -20,6 +20,7 @@ export const maxDuration = 60; const chatRequestSchema = consultationInputSchema.extend({ requestId: z.string().uuid(), + modelId: z.string().trim().min(1).max(64), name: z.string().trim().max(80).optional().default(""), history: z.array(z.object({ role: z.enum(["user", "assistant"]), @@ -35,42 +36,11 @@ function currentTimeContext(now = new Date()) { return `服务端当前时间(权威):${now.toISOString()};中国标准时间(UTC+8):${chinaTime}。涉及“现在、今天、今年、未来几个月”等相对时间时,以此为准。`; } -async function* staticTextStream(text: string) { - yield text; -} - -function engineSummary(data: Record) { - const topics = Array.isArray(data.guided_topics) ? data.guided_topics : []; - const routing = data.routing && typeof data.routing === "object" ? data.routing : {}; - const route = "primary_route" in routing ? String(routing.primary_route) : "统一咨询工作流"; - const topicText = topics - .slice(0, 3) - .map((item) => { - if (!item || typeof item !== "object") return null; - const record = item as Record; - return String(record.title || record.label || record.theme || "值得继续探索的主题"); - }) - .filter(Boolean); - - return [ - "星盘计算已完成,但当前没有配置 AI 模型,因此先返回引擎摘要。", - `本次路由:${route}。`, - topicText.length ? `建议继续查看:${topicText.join("、")}。` : "可继续查看事业、关系与年度时间窗口。", - "启动 AI 解读需配置模型;原始计算结果已保留。", - ].join("\n"); -} - -function configuredModelId() { - if (languageModelSettings.mode === "compatible") { - return process.env.LLM_MODEL?.trim() || "third-party"; - } - return process.env.MASTRA_MODEL?.trim() || "openai/gpt-5-mini"; -} - async function recordModelUsage( accounting: ReturnType, userId: string, requestId: string, + modelId: string, usage: Promise<{ inputTokens?: number; outputTokens?: number }>, ) { try { @@ -78,7 +48,7 @@ async function recordModelUsage( const { error } = await accounting .from("credit_transactions") .update({ - model: configuredModelId(), + model: modelId, input_tokens: Math.max(0, Math.trunc(resolved.inputTokens ?? 0)), output_tokens: Math.max(0, Math.trunc(resolved.outputTokens ?? 0)), }) @@ -86,9 +56,10 @@ async function recordModelUsage( .eq("transaction_type", "reserve") .eq("request_id", requestId); - if (error) console.warn("[billing] unable to record model usage", error.message); + if (error) console.warn(`[billing] unable to record model usage request=${requestId} model=${modelId}`); } catch (error) { - console.warn("[billing] unable to read model usage", error); + const reason = error instanceof Error ? error.name : "UnknownError"; + console.warn(`[billing] unable to read model usage request=${requestId} model=${modelId} reason=${reason}`); } } @@ -136,17 +107,32 @@ export async function POST(request: Request) { const userId = user.id; const requestId = parsed.data.requestId; - let reserveResult; + let modelSelection; try { - reserveResult = await runCreditRpc(accounting, "begin_consultation_credit", userId, requestId); + modelSelection = await reserveConsultationModel( + parsed.data.modelId, + resolveLanguageModel, + () => runCreditRpc(accounting, "begin_consultation_credit", userId, requestId), + ); } catch (error) { - console.error(`[billing] reservation failed for ${requestId}`, error); + const reason = error instanceof Error ? error.name : "UnknownError"; + console.error(`[billing] reservation failed request=${requestId} reason=${reason}`); return NextResponse.json( { error: "暂时无法确认咨询点数", message: "请稍后重试。" }, { status: 503 }, ); } + if (modelSelection.status === "unavailable") { + return NextResponse.json( + { error: "模型暂不可用", message: "请选择其他模型后重新发送,本次不会扣除点数。" }, + { status: 409 }, + ); + } + + const selectedModel = modelSelection.model; + const reserveResult = modelSelection.reservation; + if (!reserveResult.success) { const insufficient = reserveResult.error_code === "insufficient_credits"; return NextResponse.json( @@ -162,7 +148,8 @@ export async function POST(request: Request) { try { await runCreditRpc(accounting, "cancel_consultation_credit", userId, requestId); } catch (error) { - console.error(`[billing] cancellation failed for ${requestId}`, error); + const reason = error instanceof Error ? error.name : "UnknownError"; + console.error(`[billing] cancellation failed request=${requestId} reason=${reason}`); } } @@ -181,18 +168,7 @@ export async function POST(request: Request) { const { history, name } = parsed.data; const toolInput = consultationInputSchema.parse(parsed.data); - if (!languageModelSettings.configured) { - const evidence = await runConsultationWorkflow(toolInput); - return streamTextResponse(staticTextStream(engineSummary(evidence)), { - mode: "engine", - requestId, - onComplete: () => settle(complete), - onError: (_error, emitted) => settle(emitted ? complete : cancel), - onCancel: (emitted) => settle(emitted ? complete : cancel), - }); - } - - const result = await jyotishAgent.stream([ + const result = await getJyotishAgent(selectedModel).stream([ ...history.map((message) => message.role === "user" ? { role: "user" as const, content: message.text } : { role: "assistant" as const, content: message.text }), @@ -209,7 +185,7 @@ export async function POST(request: Request) { ]); const completeAndRecordUsage = async () => { await complete(); - void recordModelUsage(accounting, userId, requestId, result.totalUsage); + void recordModelUsage(accounting, userId, requestId, modelSelection.usageModelId, result.totalUsage); }; const settleInterrupted = (emitted: boolean) => settle(emitted ? completeAndRecordUsage : cancel); return streamTextResponse(result.textStream, { @@ -221,12 +197,13 @@ export async function POST(request: Request) { }); } catch (error) { await cancel(); - const message = error instanceof Error ? error.message : "咨询服务暂时不可用"; + const reason = error instanceof Error ? error.name : "UnknownError"; + console.error(`[consult] generation failed request=${requestId} model=${modelSelection.usageModelId} reason=${reason}`); return NextResponse.json( { error: "暂时无法生成解读", - message, - recovery: `请确认 Python API 已运行,并检查 JYOTISH_API_BASE 与模型配置。${languageModelConfigurationMessage() ? ` ${languageModelConfigurationMessage()}` : ""}`, + message: "咨询服务暂时不可用,请稍后再试。", + recovery: languageModelConfigurationMessage() ? "当前没有可用的咨询模型,请联系管理员。" : "稍后重试,或换一个模型继续。", }, { status: 503 }, ); diff --git a/frontend/src/app/api/models/route.ts b/frontend/src/app/api/models/route.ts new file mode 100644 index 00000000..11c80f33 --- /dev/null +++ b/frontend/src/app/api/models/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; +import { publicLanguageModelCatalog } from "@/mastra/model"; + +export const runtime = "nodejs"; + +export async function GET() { + let supabase: Awaited>; + try { + supabase = await createServerSupabaseClient(); + } catch (error) { + if (error instanceof Error) { + return NextResponse.json( + { error: "服务尚未配置", message: "请先配置 Supabase 环境变量。" }, + { status: 503 }, + ); + } + throw error; + } + + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json( + { error: "请先登录", message: "登录后才能读取可用模型。" }, + { status: 401 }, + ); + } + + const catalog = publicLanguageModelCatalog(); + if (!catalog.defaultModelId || catalog.models.length === 0) { + return NextResponse.json( + { error: "模型服务尚未配置", message: "当前没有可用的咨询模型。" }, + { status: 503 }, + ); + } + + return NextResponse.json(catalog); +} diff --git a/frontend/src/app/api/onboarding/route.ts b/frontend/src/app/api/onboarding/route.ts index 04c28bc1..8a1dc487 100644 --- a/frontend/src/app/api/onboarding/route.ts +++ b/frontend/src/app/api/onboarding/route.ts @@ -2,8 +2,8 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; import { createServerSupabaseClient } from "@/lib/supabase/server"; -import { onboardingAgent } from "@/mastra"; -import { languageModelSettings } from "@/mastra/model"; +import { getOnboardingAgent } from "@/mastra"; +import { defaultLanguageModel } from "@/mastra/model"; export const runtime = "nodejs"; export const maxDuration = 30; @@ -134,9 +134,10 @@ export async function POST() { let payload = fallbackPayload; let source: "agent" | "fallback" = "fallback"; - if (languageModelSettings.configured) { + const onboardingModel = defaultLanguageModel(); + if (onboardingModel) { try { - const result = await onboardingAgent.generate([ + const result = await getOnboardingAgent(onboardingModel).generate([ { role: "user", content: [ diff --git a/frontend/src/app/api/synastry-reports/route.ts b/frontend/src/app/api/synastry-reports/route.ts new file mode 100644 index 00000000..e51a2b81 --- /dev/null +++ b/frontend/src/app/api/synastry-reports/route.ts @@ -0,0 +1,72 @@ +import { NextResponse } from "next/server"; +import { isSupabaseConfigurationError } from "@/lib/supabase/config"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; + +type SynastryReportPayload = { + id?: string; + partnerName?: string; + report?: unknown; +}; + +function errorMessage(error: unknown, fallback: string) { + return error instanceof Error ? error.message : fallback; +} + +export async function GET() { + try { + const supabase = await createServerSupabaseClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); + + const { data, error } = await supabase + .from("synastry_reports") + .select("id, partner_name, report, created_at") + .eq("user_id", user.id) + .order("created_at", { ascending: false }) + .limit(10); + + if (error) throw error; + return NextResponse.json({ reports: data ?? [] }); + } catch (error) { + if (isSupabaseConfigurationError(error)) { + return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 }); + } + return NextResponse.json({ error: errorMessage(error, "合盘历史暂时不可用") }, { status: 500 }); + } +} + +export async function POST(request: Request) { + try { + const body = await request.json().catch(() => null) as SynastryReportPayload | null; + if (!body?.report || typeof body.report !== "object" || Array.isArray(body.report)) { + return NextResponse.json({ error: "合盘报告格式不正确" }, { status: 400 }); + } + + const supabase = await createServerSupabaseClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); + + const partnerName = body.partnerName?.trim() || "对方"; + const record = { + ...(body.id ? { id: body.id } : {}), + user_id: user.id, + partner_name: partnerName, + report: body.report, + created_at: new Date().toISOString(), + }; + + const { data, error } = await supabase + .from("synastry_reports") + .insert(record) + .select("id, partner_name, report, created_at") + .single(); + + if (error) throw error; + return NextResponse.json({ report: data }); + } catch (error) { + if (isSupabaseConfigurationError(error)) { + return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 }); + } + return NextResponse.json({ error: errorMessage(error, "合盘历史保存失败") }, { status: 500 }); + } +} diff --git a/frontend/src/app/api/synastry/route.ts b/frontend/src/app/api/synastry/route.ts new file mode 100644 index 00000000..39c4a1ee --- /dev/null +++ b/frontend/src/app/api/synastry/route.ts @@ -0,0 +1,159 @@ +import { NextResponse } from "next/server"; +import { chinaLocations } from "@/data/china-locations"; + +type Profile = { + name?: string; + date?: string; + time?: string; + countryCode?: "CN"; + provinceCode?: string; + cityCode?: string; + districtCode?: string; +}; + +const apiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200"; +const china = chinaLocations.country; + +function birthPayload(profile: Profile) { + const [year, month, day] = String(profile.date || "").split("-").map(Number); + const [hour, minute] = String(profile.time || "").split(":").map(Number); + const province = china.provinces.find((item) => item.code === profile.provinceCode); + const city = province?.cities.find((item) => item.code === profile.cityCode); + const district = city?.districts.find((item) => item.code === profile.districtCode); + const location = district ?? city; + if (![year, month, day, hour, minute].every(Number.isFinite) || !location) { + throw new Error("birth_profile_incomplete"); + } + return { + year, month, day, hour, minute, + second: 0, + lat: location.center[1], + lon: location.center[0], + tz: china.timezone, + }; +} + +function moonLongitude(chart: Record) { + const planets = chart.planets && typeof chart.planets === "object" ? chart.planets as Record : {}; + const moon = planets.Moon && typeof planets.Moon === "object" ? planets.Moon as Record : {}; + const value = moon.lon ?? moon.longitude ?? moon.degree; + const numeric = Number(value); + if (!Number.isFinite(numeric)) throw new Error("moon_longitude_missing"); + return numeric; +} + +function moonSummary(chart: Record) { + const planets = chart.planets && typeof chart.planets === "object" ? chart.planets as Record : {}; + const moon = planets.Moon && typeof planets.Moon === "object" ? planets.Moon as Record : {}; + return { + sign: moon.sign, + nakshatra: moon.nakshatra, + pada: moon.nakshatra_pada, + lord: moon.nakshatra_lord, + longitude: moonLongitude(chart), + }; +} + +function d9Summary(varga: Record) { + const result = varga.result && typeof varga.result === "object" ? varga.result as Record : {}; + const d9 = result.D9_Navamsa && typeof result.D9_Navamsa === "object" ? result.D9_Navamsa as Record : {}; + const ascendant = d9.ascendant && typeof d9.ascendant === "object" ? d9.ascendant as Record : {}; + const planets = d9.planets && typeof d9.planets === "object" ? d9.planets as Record : {}; + return { + ascendant, + moon: planets.Moon, + venus: planets.Venus, + mars: planets.Mars, + source: varga.source, + }; +} + +function planetSign(point: unknown) { + return point && typeof point === "object" && "sign" in point + ? String((point as Record).sign || "unknown") + : "unknown"; +} + +function relationshipReport(synastry: Record, selfD9: Record, partnerD9: Record) { + const total = Number(synastry.total_score ?? 0); + const max = Number(synastry.max_score ?? 36); + const ratio = max > 0 ? total / max : 0; + const band = ratio >= 0.72 ? "supportive" : ratio >= 0.5 ? "mixed" : "challenging"; + const self = d9Summary(selfD9); + const partner = d9Summary(partnerD9); + return { + status: "evidence_summary", + scoreBand: band, + headline: band === "supportive" + ? "基础匹配度偏支持,但仍需结合现实互动与长期运势。" + : band === "mixed" + ? "基础匹配度中等,适合重点观察沟通节奏、价值观与关系承诺。" + : "基础匹配度偏谨慎,需要先处理冲突模式与现实条件。", + strengths: [ + `Ashtakoot ${total}/${max}`, + `本人 D9 Moon:${planetSign(self.moon)}`, + `对方 D9 Moon:${planetSign(partner.moon)}`, + ], + risks: [ + "这不是完整婚恋结论;尚未纳入双方 Dasha、UL/DK 与长期时机。", + `D9 Venus/Mars 需要继续解释:本人 ${planetSign(self.venus)}/${planetSign(self.mars)},对方 ${planetSign(partner.venus)}/${planetSign(partner.mars)}。`, + ], + nextEvidence: ["双方 Dasha", "UL/DK", "D9 7宫/7主", "现实关系时间线"], + }; +} + +async function postPython(path: string, body: unknown) { + const response = await fetch(`${apiBase}${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(45_000), + }); + const data = await response.json().catch(() => null); + if (!response.ok || !data || typeof data !== "object") { + throw new Error(`jyotish_api_${response.status}`); + } + return data as Record; +} + +export async function POST(request: Request) { + try { + const body = await request.json().catch(() => null) as { selfProfile?: Profile; partnerProfile?: Profile } | null; + if (!body?.selfProfile || !body.partnerProfile) { + return NextResponse.json({ error: "请提供双方星盘资料" }, { status: 400 }); + } + const selfChart = await postPython("/api/chart", birthPayload(body.selfProfile)); + const partnerChart = await postPython("/api/chart", birthPayload(body.partnerProfile)); + const selfD9 = await postPython("/api/varga_full", { + ...birthPayload(body.selfProfile), + planets: selfChart.planets, + ascendant: selfChart.ascendant, + divisions: ["D9"], + }); + const partnerD9 = await postPython("/api/varga_full", { + ...birthPayload(body.partnerProfile), + planets: partnerChart.planets, + ascendant: partnerChart.ascendant, + divisions: ["D9"], + }); + const synastry = await postPython("/api/synastry", { + male_moon: moonLongitude(selfChart), + female_moon: moonLongitude(partnerChart), + }); + return NextResponse.json({ + status: "ok", + method: "ashtakoot_plus_moon_nakshatra_d9", + evidenceLayers: ["ashtakoot", "moon_nakshatra", "d9_navamsa"], + selfChart: { moon: moonSummary(selfChart), d9: d9Summary(selfD9) }, + partnerChart: { moon: moonSummary(partnerChart), d9: d9Summary(partnerD9) }, + synastry, + relationshipReport: relationshipReport(synastry, selfD9, partnerD9), + }); + } catch (error) { + return NextResponse.json({ + status: "blocked", + error: error instanceof Error ? error.message : "synastry_unavailable", + message: "合盘计算暂时不可用;可先保留合盘问题草稿。", + }, { status: 503 }); + } +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index d1b421d9..9284a830 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -212,7 +212,7 @@ button:disabled { cursor: default; opacity: .45; } .message p, .message-markdown { font-size: 16px; } .message-user p { font-size: 14px; } .composer { min-height: 56px; } - .composer-wrap > p { display: none; } + .composer-footer > p { display: none; } .profile-overlay { align-items: flex-end; } .profile-overlay.is-open .profile-dialog { transform: translateY(0); } .account-actions { padding-bottom: max(0px, env(safe-area-inset-bottom)); } @@ -231,6 +231,7 @@ button:disabled { cursor: default; opacity: .45; } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; transition-duration: .01ms !important; transition-delay: 0s !important; } + .model-selector-popup[data-starting-style], .model-selector-popup[data-ending-style] { transform: none; } .auth-step { transform: none; transition: opacity 80ms linear !important; } @starting-style { .auth-step { opacity: 0; transform: none; } } } @@ -339,8 +340,32 @@ button:disabled { cursor: default; opacity: .45; } .composer button { width: 44px; height: 44px; display: grid; flex: 0 0 auto; place-items: center; border: 0; color: var(--color-on-dark); cursor: pointer; transition: background-color 120ms ease-out, transform 120ms ease-out; border-radius: var(--radius-md); background: var(--color-action); } .composer .composer-stop { background: var(--color-ink); } .composer .composer-stop:not(:disabled):hover { background: var(--color-ink-strong); } -.composer-wrap > p { width: min(760px, 100%); margin: 6px auto 0; color: var(--color-ink-tertiary); text-align: center; margin-top: var(--space-2); font-size: 12px; } -.composer-wrap > p.composer-notice { display: block; color: var(--color-action-hover); } +.composer-footer { width: min(760px, 100%); min-height: 44px; display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; margin: var(--space-1) auto 0; } +.composer-footer > p { grid-column: 2; margin: 0; color: var(--color-ink-tertiary); text-align: center; font-size: var(--type-overline); } +.composer-footer > p.composer-notice { display: block; color: var(--color-action-hover); } +.model-selector-trigger { min-width: 0; max-width: 100%; min-height: 44px; display: inline-flex; grid-column: 1; align-items: center; justify-self: start; gap: var(--space-2); border: 0; padding: 0 var(--space-2); border-radius: var(--radius-md); background: transparent; color: var(--color-ink-secondary); cursor: pointer; transition: background-color 120ms ease-out, color 120ms ease-out; } +.model-selector-trigger > b { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--type-caption); font-weight: 500; } +.model-selector-trigger svg { width: 16px; height: 16px; flex: 0 0 auto; color: var(--color-ink-tertiary); transition: transform 120ms ease-out; } +.model-selector-trigger[data-popup-open] svg { transform: rotate(180deg); } +.model-selector-positioner { z-index: 30; outline: 0; } +.model-selector-popup { width: min(360px, calc(100vw - var(--space-6))); border: 1px solid var(--color-border); padding: var(--space-3); border-radius: var(--radius-lg); background: var(--color-canvas); box-shadow: var(--shadow-elevated); transform-origin: var(--transform-origin); transition: opacity 120ms ease-out, transform 120ms var(--ease-out); } +.model-selector-popup[data-starting-style], .model-selector-popup[data-ending-style] { opacity: 0; transform: translateY(var(--space-1)); } +.model-selector-title { margin: 0; color: var(--color-ink); font-family: var(--font-display); font-size: var(--type-title-sm); font-weight: 400; } +.model-selector-options { display: grid; gap: var(--space-1); margin: var(--space-2) 0 0; padding: 0; border: 0; } +.model-selector-option { min-height: 48px; display: grid; grid-template-columns: minmax(0, 1fr) 20px; align-items: center; gap: var(--space-3); padding: var(--space-2) var(--space-3); border: 1px solid transparent; border-radius: var(--radius-md); color: var(--color-ink-secondary); cursor: pointer; transition: border-color 120ms ease-out, background-color 120ms ease-out, color 120ms ease-out; } +.model-selector-option[data-selected] { border-color: var(--color-border); background: var(--color-selected); color: var(--color-ink); } +.model-selector-option:has(input:focus-visible) { outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent); outline-offset: 1px; } +.model-selector-copy { min-width: 0; display: grid; gap: var(--space-1); } +.model-selector-copy b { overflow: hidden; color: inherit; text-overflow: ellipsis; white-space: nowrap; font-size: var(--type-body-sm); font-weight: 500; } +.model-selector-check { width: 18px; height: 18px; opacity: 0; color: var(--color-action); } +.model-selector-option[data-selected] .model-selector-check { opacity: 1; } + +@media (max-width: 900px) { + .composer-footer { grid-template-columns: minmax(0, 1fr); } + .composer-footer > p:not(.composer-notice) { display: none; } + .composer-footer > p.composer-notice { grid-column: 1; grid-row: 2; margin: 0 auto; } + .model-selector-trigger { max-width: 100%; grid-column: 1; grid-row: 1; } +} .profile-overlay { position: fixed; z-index: 20; inset: 0; display: flex; justify-content: flex-end; opacity: 0; visibility: hidden; transition: opacity 180ms ease-out, visibility 0s linear 180ms; background: var(--color-scrim); } .profile-dialog { height: 100dvh; overflow-y: auto; border-left: 1px solid var(--color-border); transform: translateX(24px); transition: transform 180ms var(--ease-out); width: min(560px, 100%); padding: var(--space-8); border-color: var(--color-border); background: var(--color-canvas); box-shadow: var(--shadow-elevated); } @@ -358,6 +383,24 @@ button:disabled { cursor: default; opacity: .45; } .default-chart-card { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); margin-top: var(--space-5); padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-surface-subtle); } .default-chart-card span, .default-chart-card small { display: block; color: var(--color-ink-secondary); font-size: var(--type-caption); } .default-chart-card strong { display: block; margin: 4px 0; color: var(--color-ink); font-size: var(--type-body); font-weight: 500; } +.chart-library-panel { display: grid; gap: var(--space-5); margin-top: var(--space-5); } +.chart-library-group { display: grid; gap: var(--space-3); } +.chart-library-group > b { color: var(--color-ink); font-size: var(--type-caption); font-weight: 600; } +.chart-library-item { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas); } +.chart-library-item strong, .chart-library-item small { display: block; } +.chart-library-item strong { color: var(--color-ink); font-size: var(--type-body); font-weight: 500; } +.chart-library-item small, .chart-library-item > span, .empty-library-copy { color: var(--color-ink-secondary); font-size: var(--type-caption); } +.chart-library-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); } +.chart-library-form { padding-top: var(--space-4); border-top: 1px solid var(--color-border); } +.synastry-report-card { display: grid; gap: var(--space-3); padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-surface-subtle); } +.synastry-report-card span, .synastry-report-card small, .synastry-report-card li { color: var(--color-ink-secondary); font-size: var(--type-caption); } +.synastry-report-card strong, .synastry-report-card p { color: var(--color-ink); } +.synastry-report-card strong { display: block; margin-top: 4px; font-size: var(--type-body); font-weight: 500; } +.synastry-report-card p, .synastry-report-card ul { margin: 0; } +.synastry-history-list { display: grid; gap: var(--space-2); } +.synastry-history-list > b { color: var(--color-ink); font-size: var(--type-caption); font-weight: 600; } +.synastry-history-item { display: grid; gap: 3px; padding: var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); color: var(--color-ink); text-align: left; } +.synastry-history-item small { color: var(--color-ink-secondary); font-size: var(--type-caption); } input, select { width: 100%; min-height: 44px; padding: 0 12px; border: 1px solid var(--color-border-strong); color: var(--color-ink); border-color: var(--color-border-strong); border-radius: var(--radius-md); background: var(--color-canvas); font-size: 14px; } input:disabled, select:disabled { color: var(--color-ink-tertiary); background: var(--color-canvas-muted); } @@ -407,6 +450,8 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background: @media (hover: hover) { .new-chat:not(:disabled):hover { background: var(--color-surface-dark-raised); } .composer button:not(:disabled):hover, .button-primary:not(:disabled):hover { background: var(--color-action-hover); } + .model-selector-trigger:not(:disabled):hover { background: var(--color-canvas-muted); color: var(--color-ink); } + .model-selector-option:hover { background: var(--color-canvas-soft); color: var(--color-ink); } .starter-list button:not(:disabled):hover { background: var(--color-canvas-strong); } .starter-list button:first-child:not(:disabled):hover { background: color-mix(in srgb, var(--color-action-soft) 72%, var(--color-canvas)); } .composer-suggestions button:not(:disabled):hover { border-color: var(--color-action); background: var(--color-canvas); color: var(--color-action-hover); } diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index dd5de2aa..323fb786 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -5,11 +5,21 @@ import { ArrowUp, ArrowUpRight, ChevronRight, Menu, Minus, Plus, Sparkles, Squar import { useEffect, useRef, useState } from "react"; import type { FormEvent, KeyboardEvent } from "react"; import { ChatMessageContent } from "@/components/chat-message-content"; +import { ModelSelector } from "@/components/model-selector"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { chinaLocations, type ProvinceNode } from "@/data/china-locations"; import { parseAgentReply, type ReplyTheme } from "@/lib/agent-reply"; import { keepFocusWithin } from "@/lib/focus-trap"; +import { + SessionModelPersistenceQueue, + persistSessionModelSelection, +} from "@/lib/session-model-persistence"; +import { + parsePublicModelCatalog, + resolveSessionModelId, + type PublicLanguageModelCatalog, +} from "@/lib/public-models"; import { createBrowserSupabaseClient } from "@/lib/supabase/client"; type Theme = ReplyTheme; @@ -23,7 +33,38 @@ type Profile = { cityCode: string; districtCode: string; }; -type ChatSession = { id: string; title: string; theme: Theme; messages: Message[]; updatedAt: number }; +type ChartLibraryRecord = { + id: string; + role: "self" | "other"; + profile: Profile; + updatedAt: number; +}; +type ChartLibraryApiRecord = { + id: string; + role: "self" | "other"; + profile: Profile; + updated_at?: string; +}; +type SynastryReportCard = { + id: string; + partnerName: string; + score?: number; + maxScore?: number; + assessment?: string; + headline?: string; + scoreBand?: string; + strengths?: string[]; + risks?: string[]; + nextEvidence?: string[]; + createdAt: number; +}; +type SynastryReportApiRecord = { + id: string; + partner_name?: string; + report?: SynastryReportCard; + created_at?: string; +}; +type ChatSession = { id: string; title: string; theme: Theme; modelId: string; messages: Message[]; updatedAt: number }; type RequestError = { sessionId: string; message: string }; type StreamingReply = { sessionId: string; text: string }; type BirthPlace = { label: string; lat: number; lon: number; tz: number }; @@ -32,6 +73,7 @@ type OnboardingSuggestion = { theme: Exclude; text: string }; type OnboardingContent = { greeting: string; suggestions: OnboardingSuggestion[] }; type OnboardingStep = "name" | "birth" | "place"; type GreetingPeriod = "morning" | "noon" | "afternoon" | "evening" | "late-night"; +type SessionReadResult = { readonly sessions: ChatSession[]; readonly fallbackSessionIds: string[] }; type PendingConsultation = { readonly requestId: string; readonly sessionId: string; @@ -54,6 +96,14 @@ const themes: Array<{ id: Exclude; label: string; prompt: stri { id: "timing", label: "时运", prompt: "未来哪些阶段值得把握?" }, ]; +const previewModelCatalog = parsePublicModelCatalog({ + defaultModelId: "deepseek-pro", + models: [ + { id: "deepseek-pro", label: "DeepSeek V4 Pro", description: "更适合复杂分析", creditCost: 1, isDefault: true }, + { id: "gpt-5-mini", label: "ChatGPT 5 Mini", description: "响应稳定、速度均衡", creditCost: 1, isDefault: false }, + ], +}); + const presetOnboardingMessage = "你好,我是 Jyotisha。\n开始前,我想先认识你。\n请问我该怎么称呼你?"; const greetingVariants: Record string>> = { @@ -112,11 +162,12 @@ function timestamp() { return Date.now(); } -function createSession(): ChatSession { +function createSession(modelId: string): ChatSession { return { id: globalThis.crypto.randomUUID(), title: "新对话", theme: "general", + modelId, messages: [], updatedAt: timestamp(), }; @@ -146,6 +197,125 @@ function selectedBirthPlace(profile: Profile): BirthPlace | null { return { label, lat: location.center[1], lon: location.center[0], tz: china.timezone }; } +function chartLibraryStorageKey(accountId: string) { + return `jyotisha_chart_library:${accountId}`; +} +function synastryHistoryStorageKey(accountId: string) { + return `jyotisha_synastry_history:${accountId}`; +} + +function profileReadyForLibrary(profile: Profile) { + return !missingProfileStep(profile); +} + +function buildSelfChartRecord(profile: Profile): ChartLibraryRecord { + return { id: "self", role: "self", profile, updatedAt: timestamp() }; +} + +function upsertSelfChart(library: ChartLibraryRecord[], profile: Profile) { + if (!profileReadyForLibrary(profile)) return library.filter((record) => record.role !== "self"); + const others = library.filter((record) => record.role !== "self"); + return [buildSelfChartRecord(profile), ...others]; +} + +function readChartLibrary(accountId: string): ChartLibraryRecord[] { + try { + const parsed = JSON.parse(localStorage.getItem(chartLibraryStorageKey(accountId)) || "[]") as ChartLibraryRecord[]; + return Array.isArray(parsed) ? parsed.filter((record) => record?.id && record?.profile) : []; + } catch { + return []; + } +} +function readSynastryHistory(accountId: string): SynastryReportCard[] { + try { + const parsed = JSON.parse(localStorage.getItem(synastryHistoryStorageKey(accountId)) || "[]") as SynastryReportCard[]; + return Array.isArray(parsed) ? parsed.filter((record) => record?.id && record?.partnerName).slice(0, 10) : []; + } catch { + return []; + } +} + +function writeSynastryHistory(accountId: string, history: SynastryReportCard[]) { + localStorage.setItem(synastryHistoryStorageKey(accountId), JSON.stringify(history.slice(0, 10))); +} + +function normalizeSynastryReportApiRecord(record: SynastryReportApiRecord): SynastryReportCard | null { + if (!record.report || typeof record.report !== "object") return null; + return { + ...record.report, + id: record.id, + partnerName: record.partner_name || record.report.partnerName || "对方", + createdAt: Date.parse(record.created_at || "") || record.report.createdAt || timestamp(), + }; +} + +async function fetchCloudSynastryHistory() { + const response = await fetch("/api/synastry-reports", { cache: "no-store" }); + if (!response.ok) throw new Error("cloud_synastry_history_unavailable"); + const payload = await response.json().catch(() => null) as { reports?: SynastryReportApiRecord[] } | null; + return (payload?.reports || []).map(normalizeSynastryReportApiRecord).filter(Boolean) as SynastryReportCard[]; +} + +async function saveCloudSynastryReport(report: SynastryReportCard) { + const response = await fetch("/api/synastry-reports", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ partnerName: report.partnerName, report }), + }); + if (!response.ok) throw new Error("cloud_synastry_report_save_failed"); + const payload = await response.json().catch(() => null) as { report?: SynastryReportApiRecord } | null; + return payload?.report ? normalizeSynastryReportApiRecord(payload.report) || report : report; +} + +function normalizeChartLibraryApiRecord(record: ChartLibraryApiRecord): ChartLibraryRecord { + return { + id: record.role === "self" ? "self" : record.id, + role: record.role, + profile: record.profile, + updatedAt: Date.parse(record.updated_at || "") || timestamp(), + }; +} + +async function fetchCloudChartLibrary() { + const response = await fetch("/api/chart-profiles", { cache: "no-store" }); + if (!response.ok) throw new Error("cloud_chart_library_unavailable"); + const payload = await response.json().catch(() => null) as { profiles?: ChartLibraryApiRecord[] } | null; + return (payload?.profiles || []).map(normalizeChartLibraryApiRecord); +} + +async function saveCloudChartProfile(record: ChartLibraryRecord) { + const response = await fetch("/api/chart-profiles", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: record.role === "self" ? undefined : record.id, + role: record.role, + profile: record.profile, + }), + }); + if (!response.ok) throw new Error("cloud_chart_profile_save_failed"); + const payload = await response.json().catch(() => null) as { profile?: ChartLibraryApiRecord } | null; + return payload?.profile ? normalizeChartLibraryApiRecord(payload.profile) : record; +} + +async function deleteCloudChartProfile(recordId: string) { + const response = await fetch(`/api/chart-profiles/${encodeURIComponent(recordId)}`, { method: "DELETE" }); + if (!response.ok) throw new Error("cloud_chart_profile_delete_failed"); +} + +function profilePlaceLabel(profile: Profile) { + return selectedBirthPlace(profile)?.label || "地点未完整"; +} + +function buildSynastryQuestion(selfProfile: Profile, partnerProfile: Profile) { + return [ + `请用印度占星合盘分析我和${partnerProfile.name || "对方"}的关系。`, + `我的资料:${selfProfile.name || "本人"},${selfProfile.date} ${selfProfile.time},${profilePlaceLabel(selfProfile)}。`, + `对方资料:${partnerProfile.name || "对方"},${partnerProfile.date} ${partnerProfile.time},${profilePlaceLabel(partnerProfile)}。`, + "请先说明会使用哪些证据层,再分析关系模式、冲突点、适合发展的方式和需要谨慎的时间窗口。", + ].join("\n"); +} + function missingProfileStep(profile: Profile): OnboardingStep | null { if (!profile.name.trim()) return "name"; if (!profile.date || !profile.time) return "birth"; @@ -240,12 +410,13 @@ function readProfile(value: unknown): Profile { }; } -function readSessions(value: unknown): ChatSession[] { - if (!Array.isArray(value)) return []; - return value.flatMap((item) => { +function readSessions(value: unknown, catalog: PublicLanguageModelCatalog | null): SessionReadResult { + if (!Array.isArray(value)) return { sessions: [], fallbackSessionIds: [] }; + const fallbackSessionIds: string[] = []; + const sessions = value.flatMap((item): ChatSession[] => { if (!item || typeof item !== "object") return []; - const session = item as Partial & { updated_at?: unknown }; - const messages = Array.isArray(session.messages) + const session = item as Partial & { model_id?: unknown; updated_at?: unknown }; + const messages: Message[] = Array.isArray(session.messages) ? session.messages.flatMap((message) => ( message && typeof message === "object" && ((message as Message).role === "user" || (message as Message).role === "assistant") @@ -259,20 +430,26 @@ function readSessions(value: unknown): ChatSession[] { )) : []; - return typeof session.id === "string" - ? [{ + if (typeof session.id !== "string") return []; + const savedModelId = session.model_id ?? session.modelId; + const selection = catalog + ? resolveSessionModelId(savedModelId, catalog) + : { modelId: typeof savedModelId === "string" ? savedModelId : "", fellBack: false }; + if (catalog && selection.fellBack) fallbackSessionIds.push(session.id); + return [{ id: session.id, title: typeof session.title === "string" ? session.title.slice(0, 36) : "新对话", theme: session.theme === "career" || session.theme === "marriage" || session.theme === "timing" ? session.theme : "general", + modelId: selection.modelId, messages, updatedAt: typeof session.updatedAt === "number" ? session.updatedAt : typeof session.updated_at === "string" ? Date.parse(session.updated_at) : timestamp(), - }] - : []; + }]; }); + return { sessions, fallbackSessionIds }; } function BirthMomentFields({ value, onChange }: { value: Profile; onChange: (profile: Profile) => void }) { @@ -303,12 +480,12 @@ function BirthLocationFields({ value, onChange }: { value: Profile; onChange: (p ); } -function ProfileFields({ value, onChange }: { value: Profile; onChange: (profile: Profile) => void }) { +function ProfileFields({ value, onChange, nameInputId }: { value: Profile; onChange: (profile: Profile) => void; nameInputId?: string }) { return ( <> @@ -391,9 +568,21 @@ async function fetchAccount(signal?: AbortSignal): Promise { return payload as Account; } +async function fetchModelCatalog(signal?: AbortSignal) { + const response = await fetch("/api/models", { signal, cache: "no-store" }); + const payload = await response.json().catch(() => null); + if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法读取可用模型")); + return parsePublicModelCatalog(payload); +} + export default function Home() { const [profile, setProfile] = useState(emptyProfile); const [profileDraft, setProfileDraft] = useState(emptyProfile); + const [chartLibrary, setChartLibrary] = useState([]); + const [chartLibraryOpen, setChartLibraryOpen] = useState(false); + const [otherProfileDraft, setOtherProfileDraft] = useState(emptyProfile); + const [synastryReportCard, setSynastryReportCard] = useState(null); + const [synastryHistory, setSynastryHistory] = useState([]); const [profileOpen, setProfileOpen] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [profileNotice, setProfileNotice] = useState(""); @@ -406,6 +595,7 @@ export default function Home() { const [redeeming, setRedeeming] = useState(false); const [signingOut, setSigningOut] = useState(false); const [sessions, setSessions] = useState([]); + const [modelCatalog, setModelCatalog] = useState(null); const [activeSessionId, setActiveSessionId] = useState(""); const [draft, setDraft] = useState(""); const [draftTheme, setDraftTheme] = useState(null); @@ -439,7 +629,11 @@ export default function Home() { const cancellationInFlight = useRef(false); const stoppedRequestAwaitingSettlement = useRef(null); const stoppedSessionPersistence = useRef(new Map>()); + const modelPersistence = useRef(new SessionModelPersistenceQueue()); + const modelSyncFailures = useRef(new Set()); + const modelSelectionVersions = useRef(new Map()); const activeSessionIdRef = useRef(""); + const chartLibraryLoadedAccount = useRef(""); const uiPreview = useRef(false); const uiPreviewMode = useRef(null); @@ -453,6 +647,56 @@ export default function Home() { }, [activeSessionId]); const activeSuggestions = activeSession?.messages.reduce((latest, message) => message.role === "assistant" && message.suggestions?.length ? message.suggestions : latest, [] as string[]) ?? []; const accountId = account?.user.id; + + useEffect(() => { + if (!accountId) { + setChartLibrary([]); + setSynastryHistory([]); + chartLibraryLoadedAccount.current = ""; + return; + } + if (chartLibraryLoadedAccount.current === accountId) return; + chartLibraryLoadedAccount.current = accountId; + setChartLibrary(upsertSelfChart(readChartLibrary(accountId), profile)); + setSynastryHistory(readSynastryHistory(accountId)); + void fetchCloudChartLibrary() + .then((cloudLibrary) => { + setChartLibrary((current) => { + const otherById = new Map([ + ...current.filter((record) => record.role === "other").map((record) => [record.id, record] as const), + ...cloudLibrary.filter((record) => record.role === "other").map((record) => [record.id, record] as const), + ]); + const next = upsertSelfChart([...otherById.values()], profile); + localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next)); + return next; + }); + }) + .catch(() => { + // Cloud chart library is best-effort; local library remains usable. + }); + void fetchCloudSynastryHistory() + .then((cloudHistory) => { + setSynastryHistory((current) => { + const byId = new Map([...current, ...cloudHistory].map((record) => [record.id, record] as const)); + const next = [...byId.values()].sort((a, b) => b.createdAt - a.createdAt).slice(0, 10); + writeSynastryHistory(accountId, next); + return next; + }); + }) + .catch(() => { + // Cloud synastry history is best-effort; local history remains usable. + }); + }, [accountId, profile]); + + useEffect(() => { + if (!accountId) return; + setChartLibrary((current) => { + const next = upsertSelfChart(current, profile); + localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next)); + return next; + }); + }, [accountId, profile]); + const profileComplete = isProfileComplete(profile); const onboardingPending = profileComplete && !onboarding && !onboardingError; const currentOnboardingMessage = onboardingJustCompleted @@ -508,10 +752,12 @@ export default function Home() { id: "preview-session", title: previewMessages.length > 0 ? "未来半年是否适合换工作" : "新对话", theme: "career", + modelId: previewModelCatalog.defaultModelId, messages: previewMessages, updatedAt: timestamp(), }; setAccount({ user: { id: "preview-user", email: "preview@local.test" }, credits: 8, isAdmin: false }); + setModelCatalog(previewModelCatalog); setProfile(previewProfile); setProfileDraft(previewProfile); setOnboardingStep(missingProfileStep(previewProfile) ?? "name"); @@ -535,7 +781,16 @@ export default function Home() { return; } - const nextAccount = await fetchAccount(controller.signal); + const [nextAccount, modelCatalogResult] = await Promise.all([ + fetchAccount(controller.signal), + fetchModelCatalog(controller.signal) + .then((catalog) => ({ catalog, unavailable: false })) + .catch((caught: unknown) => { + if (caught instanceof Error && caught.name === "AbortError") throw caught; + return { catalog: null, unavailable: true }; + }), + ]); + const nextModelCatalog = modelCatalogResult.catalog; const [profileResult, sessionsResult] = await Promise.all([ supabase .from("profiles") @@ -545,7 +800,7 @@ export default function Home() { .maybeSingle(), supabase .from("chat_sessions") - .select("id,title,theme,messages,updated_at") + .select("id,title,theme,model_id,messages,updated_at") .abortSignal(controller.signal) .order("updated_at", { ascending: false }), ]); @@ -553,10 +808,11 @@ export default function Home() { if (profileResult.error) throw profileResult.error; if (sessionsResult.error) throw sessionsResult.error; - let nextSessions = readSessions(sessionsResult.data); + const parsedSessions = readSessions(sessionsResult.data, nextModelCatalog); + let nextSessions = parsedSessions.sessions; if (nextSessions.length === 0) { if (controller.signal.aborted) return; - const initialSession = createSession(); + const initialSession = createSession(nextModelCatalog?.defaultModelId ?? ""); const { error } = await supabase .from("chat_sessions") .insert({ @@ -564,6 +820,7 @@ export default function Home() { user_id: nextAccount.user.id, title: initialSession.title, theme: initialSession.theme, + model_id: initialSession.modelId || null, messages: initialSession.messages, updated_at: new Date(initialSession.updatedAt).toISOString(), }) @@ -575,13 +832,31 @@ export default function Home() { if (controller.signal.aborted) return; const nextProfile = readProfile(profileResult.data); setAccount(nextAccount); + setModelCatalog(nextModelCatalog); setProfile(nextProfile); setProfileDraft(nextProfile); setStartGreeting(nextProfile.name.trim() ? createStartGreeting(nextProfile.name) : ""); setOnboardingStep(missingProfileStep(nextProfile) ?? "name"); setSessions(nextSessions); setActiveSessionId(nextSessions[0].id); + if (modelCatalogResult.unavailable) { + setComposerNotice("模型服务暂时不可用,当前无法发送问题。"); + } else if (parsedSessions.fallbackSessionIds.length > 0) { + setComposerNotice("此前选择的模型已下线,已切换为默认模型。"); + } setAccountError(""); + + if (nextModelCatalog && parsedSessions.fallbackSessionIds.length > 0) { + const { error } = await supabase + .from("chat_sessions") + .update({ model_id: nextModelCatalog.defaultModelId }) + .eq("user_id", nextAccount.user.id) + .in("id", parsedSessions.fallbackSessionIds) + .abortSignal(controller.signal); + if (error && !controller.signal.aborted) { + setComposerNotice("已在当前页面切换为默认模型,但云端同步失败;刷新后可能需要重新选择。"); + } + } } catch (caught) { if ((caught as Error).name !== "AbortError" && !controller.signal.aborted) { setAccountError(friendlyError(caught instanceof Error ? caught.message : "暂时无法读取云端数据")); @@ -719,6 +994,7 @@ export default function Home() { const values = { title: session.title, theme: session.theme, + model_id: session.modelId, messages: session.messages, updated_at: new Date(session.updatedAt).toISOString(), }; @@ -741,9 +1017,9 @@ export default function Home() { } async function startNewChat() { - if (!account || creatingSession) return; + if (!account || !modelCatalog || creatingSession) return; setMobileSidebarOpen(false); - const nextSession = createSession(); + const nextSession = createSession(modelCatalog.defaultModelId); const previousSessionId = activeSession?.id ?? ""; setCreatingSession(true); setSessions((current) => [nextSession, ...current]); @@ -766,6 +1042,58 @@ export default function Home() { } } + async function selectSessionModel(modelId: string) { + const userId = account?.user.id; + if (!activeSession || !modelCatalog || !userId || pendingSessionId || cancellationPending || creatingSession) return; + const selectedModel = modelCatalog.models.find((model) => model.id === modelId); + const retryingFailedSync = activeSession.modelId === modelId && modelSyncFailures.current.has(activeSession.id); + if (!selectedModel || (activeSession.modelId === modelId && !retryingFailedSync)) return; + + const nextSession: ChatSession = retryingFailedSync + ? activeSession + : { ...activeSession, modelId, updatedAt: timestamp() }; + const selectionVersion = (modelSelectionVersions.current.get(nextSession.id) ?? 0) + 1; + modelSelectionVersions.current.set(nextSession.id, selectionVersion); + if (!retryingFailedSync) updateSession(activeSession.id, () => nextSession); + setRequestError(null); + setComposerNotice(""); + + try { + await modelPersistence.current.enqueue(nextSession.id, () => persistSessionModelSelection( + async ({ values, sessionId, userId: ownerId }) => { + if (process.env.NODE_ENV === "development" && uiPreview.current) { + return { found: true, error: null }; + } + const { data, error } = await createBrowserSupabaseClient() + .from("chat_sessions") + .update(values) + .eq("id", sessionId) + .eq("user_id", ownerId) + .select("id") + .maybeSingle(); + return { found: Boolean(data), error: error?.message ?? null }; + }, + userId, + nextSession.id, + modelId, + )); + if (modelSelectionVersions.current.get(nextSession.id) !== selectionVersion) return; + modelSelectionVersions.current.delete(nextSession.id); + modelSyncFailures.current.delete(nextSession.id); + } catch (caught) { + if (modelSelectionVersions.current.get(nextSession.id) !== selectionVersion) return; + modelSelectionVersions.current.delete(nextSession.id); + modelSyncFailures.current.add(nextSession.id); + if (activeSessionIdRef.current === nextSession.id) { + setComposerNotice(`已在当前页面选择 ${selectedModel.label},但云端同步失败;再次选择当前模型即可重试。`); + } + setRequestError({ + sessionId: nextSession.id, + message: caught instanceof Error ? caught.message : "模型选择暂时无法同步到云端。", + }); + } + } + function openAccount(showRedeem = false) { setMobileSidebarOpen(false); if (profileComplete) setProfileDraft(profile); @@ -808,6 +1136,64 @@ export default function Home() { .maybeSingle(); if (error) throw error; if (!data) throw new Error("账户档案不存在,请重新登录后再试。"); + await saveCloudChartProfile({ ...buildSelfChartRecord(nextProfile), updatedAt: timestamp() }).catch(() => null); + } + + async function saveOtherChart(event: FormEvent) { + event.preventDefault(); + const nextProfile = { ...otherProfileDraft, name: otherProfileDraft.name.trim() }; + if (missingProfileStep(nextProfile)) { + setAccountError("请补全其他星盘的称呼、出生时间和出生地点。"); + return; + } + if (!accountId) return; + let record: ChartLibraryRecord = { + id: globalThis.crypto.randomUUID(), + role: "other", + profile: nextProfile, + updatedAt: timestamp(), + }; + try { + record = await saveCloudChartProfile(record); + } catch { + // Keep local chart library usable when cloud sync is unavailable. + } + setChartLibrary((current) => { + const next = [...upsertSelfChart(current, profile), record]; + localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next)); + return next; + }); + setOtherProfileDraft(emptyProfile); + setAccountError(""); + setProfileNotice("已添加到星盘库。"); + } + + function deleteOtherChart(recordId: string) { + if (!accountId) return; + void deleteCloudChartProfile(recordId).catch(() => { + // Local deletion should not be blocked by temporary cloud sync failures. + }); + setChartLibrary((current) => { + const next = current.filter((record) => record.id !== recordId || record.role === "self"); + localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next)); + return next; + }); + } + + async function makeDefaultChart(record: ChartLibraryRecord) { + if (record.role !== "other" || profileSaving) return; + setProfileSaving(true); + setAccountError(""); + try { + await persistProfile(record.profile); + setProfile(record.profile); + setProfileDraft(record.profile); + setProfileNotice("已设为当前默认星盘。"); + } catch (caught) { + setAccountError(friendlyError(caught instanceof Error ? caught.message : "默认星盘保存失败")); + } finally { + setProfileSaving(false); + } } async function saveProfile(event: FormEvent) { @@ -939,6 +1325,67 @@ export default function Home() { window.requestAnimationFrame(() => composerInput.current?.focus()); } + async function draftSynastryQuestionFromChart(record: ChartLibraryRecord) { + if (record.role !== "other") return; + const baseQuestion = buildSynastryQuestion(profile, record.profile); + try { + const response = await fetch("/api/synastry", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ selfProfile: profile, partnerProfile: record.profile }), + }); + const payload = await response.json().catch(() => null) as { status?: string; evidenceLayers?: string[]; synastry?: { total_score?: number; max_score?: number; assessment?: string }; relationshipReport?: { headline?: string; scoreBand?: string; strengths?: string[]; risks?: string[]; nextEvidence?: string[] } } | null; + if (response.ok && payload?.status === "ok") { + const score = payload.synastry?.total_score; + const max = payload.synastry?.max_score; + const assessment = payload.synastry?.assessment; + const layers = (payload.evidenceLayers || []).join(" / ") || "Ashtakoot / Moon / D9"; + const reportCard: SynastryReportCard = { + id: `${record.id}-${Date.now()}`, + partnerName: record.profile.name || "对方", + score, + maxScore: max, + assessment, + headline: payload.relationshipReport?.headline, + scoreBand: payload.relationshipReport?.scoreBand, + strengths: payload.relationshipReport?.strengths, + risks: payload.relationshipReport?.risks, + nextEvidence: payload.relationshipReport?.nextEvidence, + createdAt: Date.now(), + }; + let savedReportCard = reportCard; + if (accountId) { + try { + savedReportCard = await saveCloudSynastryReport(reportCard); + } catch { + // Local history remains the fallback when cloud persistence is unavailable. + } + } + setSynastryReportCard(savedReportCard); + if (accountId) { + setSynastryHistory((current) => { + const next = [savedReportCard, ...current.filter((item) => item.id !== savedReportCard.id)].slice(0, 10); + writeSynastryHistory(accountId, next); + return next; + }); + } + chooseSuggestedQuestion([ + baseQuestion, + "", + `已计算基础合盘证据:${layers};Ashtakoot ${score ?? "?"}/${max ?? "?"},初步评级:${assessment || "待解释"}。请基于这个证据包继续分析。`, + payload.relationshipReport?.headline ? `结构化摘要:${payload.relationshipReport.headline}` : "", + ].join("\n"), "marriage"); + } else { + chooseSuggestedQuestion(baseQuestion, "marriage"); + setComposerNotice(payload?.status === "blocked" ? "合盘计算暂时不可用,已先生成问题草稿。" : "已生成合盘问题草稿。"); + } + } catch { + chooseSuggestedQuestion(baseQuestion, "marriage"); + setComposerNotice("合盘计算暂时不可用,已先生成问题草稿。"); + } + setProfileOpen(false); + } + async function requestCancellation(requestId: string) { const existing = cancellationRequests.current.get(requestId); if (existing) return existing; @@ -1059,7 +1506,7 @@ export default function Home() { async function send(text: string, requestedTheme?: Theme) { const originalQuestion = text; const question = text.trim(); - if (!question || !activeSession || pendingSessionId || cancellationInFlight.current || pendingConsultation.current || !account) return; + if (!question || !activeSession || !modelCatalog || pendingSessionId || cancellationInFlight.current || pendingConsultation.current || !account) return; if (account.credits <= 0) { openAccount(true); @@ -1178,6 +1625,7 @@ export default function Home() { headers: { "content-type": "application/json" }, body: JSON.stringify({ requestId, + modelId: currentSession.modelId, name: profile.name, year, month, @@ -1369,7 +1817,7 @@ export default function Home() { - +