fix: harden multi-model chat selection
This commit is contained in:
+1
-1
@@ -158,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
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
- Produces public shape: `{ id, label, description, creditCost: 1, isDefault }`.
|
||||
- Consumes: `MastraModelConfig`, Zod, `NodeJS.ProcessEnv`.
|
||||
|
||||
- [ ] **Step 1: Write failing catalog tests**
|
||||
- [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:
|
||||
|
||||
@@ -90,15 +90,15 @@ test("resolves two configured models while returning sanitized public metadata",
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the catalog test and verify RED**
|
||||
- [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.
|
||||
|
||||
- [ ] **Step 3: Implement the catalog parser and resolver**
|
||||
- [x] **Step 3: Implement the catalog parser and resolver**
|
||||
|
||||
Use a Zod boundary for each raw catalog item and return immutable resolved entries. OpenAI entries produce a Mastra model string; OpenAI-compatible entries produce `{ providerId, modelId, url, apiKey }`. 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.
|
||||
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:
|
||||
|
||||
@@ -123,17 +123,17 @@ export type LanguageModelCatalog = {
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run catalog tests and type checking**
|
||||
- [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`.
|
||||
|
||||
- [ ] **Step 5: Document configuration**
|
||||
- [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.
|
||||
|
||||
- [ ] **Step 6: Commit the catalog task**
|
||||
- [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
|
||||
@@ -158,7 +158,7 @@ git commit -m "feat: add server model catalog"
|
||||
- Produces: `getJyotishAgent(model)` and `getOnboardingAgent(model)` process-local caches.
|
||||
- Consultation request consumes `modelId: string`.
|
||||
|
||||
- [ ] **Step 1: Write failing public payload tests**
|
||||
- [x] **Step 1: Write failing public payload tests**
|
||||
|
||||
Create a Zod client boundary that accepts only the sanitized response and rejects routing fields:
|
||||
|
||||
@@ -189,17 +189,17 @@ test("parses a sanitized public model catalog", () => {
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the payload test and verify RED**
|
||||
- [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.
|
||||
|
||||
- [ ] **Step 3: Implement the public parser and authenticated route**
|
||||
- [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.
|
||||
|
||||
- [ ] **Step 4: Refactor Mastra Agent construction**
|
||||
- [x] **Step 4: Refactor Mastra Agent construction**
|
||||
|
||||
Move the existing shared instructions into constants and build Agents through keyed factories:
|
||||
|
||||
@@ -224,17 +224,17 @@ export function getJyotishAgent(model: ResolvedLanguageModel) {
|
||||
|
||||
Create the onboarding Agent with the default resolved model and keep its existing instructions unchanged.
|
||||
|
||||
- [ ] **Step 5: Select the model before credit reservation**
|
||||
- [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`.
|
||||
|
||||
- [ ] **Step 6: Run tests, type checking, and lint**
|
||||
- [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`.
|
||||
|
||||
- [ ] **Step 7: Commit the endpoint task**
|
||||
- [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
|
||||
@@ -256,7 +256,7 @@ git commit -m "feat: route consultations by model"
|
||||
- Produces: `resolveSessionModelId(saved, catalog) -> { modelId, fellBack }`.
|
||||
- Persists: `chat_sessions.model_id text`.
|
||||
|
||||
- [ ] **Step 1: Write failing session fallback tests**
|
||||
- [x] **Step 1: Write failing session fallback tests**
|
||||
|
||||
```ts
|
||||
test("falls back to the configured default when a saved model is removed", () => {
|
||||
@@ -280,31 +280,31 @@ test("falls back to the configured default when a saved model is removed", () =>
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the fallback test and verify RED**
|
||||
- [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.
|
||||
|
||||
- [ ] **Step 3: Implement fallback and migration**
|
||||
- [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`.
|
||||
|
||||
- [ ] **Step 4: Wire persistence into the page**
|
||||
- [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.
|
||||
|
||||
- [ ] **Step 5: Run focused tests and build**
|
||||
- [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`.
|
||||
|
||||
- [ ] **Step 6: Commit persistence**
|
||||
- [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
|
||||
@@ -325,17 +325,17 @@ git commit -m "feat: persist session model choice"
|
||||
- Consumes: `readonly PublicLanguageModel[]`, selected ID, disabled state, selection callback.
|
||||
- Produces: accessible Base UI Popover with native radio inputs.
|
||||
|
||||
- [ ] **Step 1: Add the model-selector primitive to `DESIGN.md`**
|
||||
- [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.
|
||||
|
||||
- [ ] **Step 2: Add the component in preview mode and observe RED behavior**
|
||||
- [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.
|
||||
|
||||
- [ ] **Step 3: Implement the Base UI Popover**
|
||||
- [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.
|
||||
|
||||
@@ -350,21 +350,21 @@ type ModelSelectorProps = {
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Persist selection from the page**
|
||||
- [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.
|
||||
|
||||
- [ ] **Step 5: Style entirely from existing design tokens**
|
||||
- [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.
|
||||
|
||||
- [ ] **Step 6: Run static verification**
|
||||
- [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`.
|
||||
|
||||
- [ ] **Step 7: Commit the UI task**
|
||||
- [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
|
||||
@@ -382,7 +382,7 @@ git commit -m "feat: add chat model selector"
|
||||
- Consumes the complete feature.
|
||||
- Produces fresh test, browser, migration, security, and deployment evidence.
|
||||
|
||||
- [ ] **Step 1: Run the complete relevant verification set**
|
||||
- [x] **Step 1: Run the complete relevant verification set**
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
@@ -406,17 +406,19 @@ Expected: all relevant checks PASS. Name any unrelated pre-existing failure with
|
||||
|
||||
- [ ] **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`.
|
||||
|
||||
- [ ] **Step 3: Run real browser QA**
|
||||
- [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.
|
||||
|
||||
- [ ] **Step 4: Run the requested AI-slop audit**
|
||||
- [x] **Step 4: Run the requested AI-slop audit**
|
||||
|
||||
Run:
|
||||
|
||||
@@ -426,14 +428,16 @@ 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.
|
||||
|
||||
- [ ] **Step 5: Run final review and debugging gates**
|
||||
- [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.
|
||||
|
||||
- [ ] **Step 6: Commit verification fixes**
|
||||
- [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`.
|
||||
|
||||
+12
-6
@@ -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`:
|
||||
@@ -82,7 +83,7 @@ OPENAI_API_KEY=<server-secret>
|
||||
-> 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`。
|
||||
|
||||
@@ -178,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`:一次性兑换,使用行锁保证同一码全局只成功一次,并记录兑换账户。
|
||||
@@ -238,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 服务必须单独部署
|
||||
|
||||
@@ -262,6 +266,8 @@ Vercel 上的 Next.js 不能访问你电脑的 `127.0.0.1:5200`。需要把仓
|
||||
8. 用户在 2.5 秒撤回窗口内停止时不调用模型、不扣点
|
||||
9. 撤回窗口结束后、首个输出分片前取消时点数退回
|
||||
10. 用户已经收到输出后停止时保留已有内容并正常计费
|
||||
11. 登录后 `/api/models` 只返回模型 ID、名称、说明、点数和默认状态,不包含 Key、端点或环境变量名
|
||||
12. 不同 Session 能保存各自的模型选择;已下线模型会回退到默认模型
|
||||
```
|
||||
|
||||
## Demo 防滥用边界
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} 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";
|
||||
@@ -55,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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,26 +105,33 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const selectedModel = resolveLanguageModel(parsed.data.modelId);
|
||||
if (!selectedModel) {
|
||||
const userId = user.id;
|
||||
const requestId = parsed.data.requestId;
|
||||
let modelSelection;
|
||||
try {
|
||||
modelSelection = await reserveConsultationModel(
|
||||
parsed.data.modelId,
|
||||
resolveLanguageModel,
|
||||
() => runCreditRpc(accounting, "begin_consultation_credit", userId, requestId),
|
||||
);
|
||||
} catch (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 userId = user.id;
|
||||
const requestId = parsed.data.requestId;
|
||||
let reserveResult;
|
||||
try {
|
||||
reserveResult = await runCreditRpc(accounting, "begin_consultation_credit", userId, requestId);
|
||||
} catch (error) {
|
||||
console.error(`[billing] reservation failed for ${requestId}`, error);
|
||||
return NextResponse.json(
|
||||
{ error: "暂时无法确认咨询点数", message: "请稍后重试。" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
const selectedModel = modelSelection.model;
|
||||
const reserveResult = modelSelection.reservation;
|
||||
|
||||
if (!reserveResult.success) {
|
||||
const insufficient = reserveResult.error_code === "insufficient_credits";
|
||||
@@ -139,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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,7 +185,7 @@ export async function POST(request: Request) {
|
||||
]);
|
||||
const completeAndRecordUsage = async () => {
|
||||
await complete();
|
||||
void recordModelUsage(accounting, userId, requestId, selectedModel.id, result.totalUsage);
|
||||
void recordModelUsage(accounting, userId, requestId, modelSelection.usageModelId, result.totalUsage);
|
||||
};
|
||||
const settleInterrupted = (emitted: boolean) => settle(emitted ? completeAndRecordUsage : cancel);
|
||||
return streamTextResponse(result.textStream, {
|
||||
@@ -187,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 },
|
||||
);
|
||||
|
||||
+62
-21
@@ -11,6 +11,10 @@ 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,
|
||||
@@ -470,6 +474,9 @@ export default function Home() {
|
||||
const cancellationInFlight = useRef(false);
|
||||
const stoppedRequestAwaitingSettlement = useRef<string | null>(null);
|
||||
const stoppedSessionPersistence = useRef(new Map<string, Promise<void>>());
|
||||
const modelPersistence = useRef(new SessionModelPersistenceQueue());
|
||||
const modelSyncFailures = useRef(new Set<string>());
|
||||
const modelSelectionVersions = useRef(new Map<string, number>());
|
||||
const activeSessionIdRef = useRef("");
|
||||
const uiPreview = useRef(false);
|
||||
const uiPreviewMode = useRef<string | null>(null);
|
||||
@@ -616,16 +623,6 @@ export default function Home() {
|
||||
nextSessions = [initialSession];
|
||||
}
|
||||
|
||||
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) throw error;
|
||||
}
|
||||
|
||||
if (controller.signal.aborted) return;
|
||||
const nextProfile = readProfile(profileResult.data);
|
||||
setAccount(nextAccount);
|
||||
@@ -642,6 +639,18 @@ export default function Home() {
|
||||
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 : "暂时无法读取云端数据"));
|
||||
@@ -828,23 +837,55 @@ export default function Home() {
|
||||
}
|
||||
|
||||
async function selectSessionModel(modelId: string) {
|
||||
if (!activeSession || !modelCatalog || pendingSessionId || cancellationPending || creatingSession) return;
|
||||
const userId = account?.user.id;
|
||||
if (!activeSession || !modelCatalog || !userId || pendingSessionId || cancellationPending || creatingSession) return;
|
||||
const selectedModel = modelCatalog.models.find((model) => model.id === modelId);
|
||||
if (!selectedModel || activeSession.modelId === modelId) return;
|
||||
const retryingFailedSync = activeSession.modelId === modelId && modelSyncFailures.current.has(activeSession.id);
|
||||
if (!selectedModel || (activeSession.modelId === modelId && !retryingFailedSync)) return;
|
||||
|
||||
const nextSession: ChatSession = {
|
||||
...activeSession,
|
||||
modelId,
|
||||
updatedAt: timestamp(),
|
||||
};
|
||||
updateSession(activeSession.id, () => nextSession);
|
||||
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(`已切换至 ${selectedModel.label},只影响之后的问题。`);
|
||||
setComposerNotice(retryingFailedSync
|
||||
? `正在重新同步 ${selectedModel.label}。`
|
||||
: `已切换至 ${selectedModel.label},只影响之后的问题。`);
|
||||
|
||||
try {
|
||||
await persistSession(nextSession);
|
||||
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);
|
||||
if (retryingFailedSync && activeSessionIdRef.current === nextSession.id) {
|
||||
setComposerNotice(`已同步 ${selectedModel.label}。`);
|
||||
}
|
||||
} catch (caught) {
|
||||
setComposerNotice(`已在当前页面切换至 ${selectedModel.label},但云端同步失败。`);
|
||||
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 : "模型选择暂时无法同步到云端。",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Popover } from "@base-ui/react/popover";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import { useId, useRef, useState } from "react";
|
||||
import { useId, useState } from "react";
|
||||
import type { KeyboardEvent } from "react";
|
||||
import type { PublicLanguageModel } from "@/lib/public-models";
|
||||
|
||||
@@ -21,7 +21,6 @@ export function ModelSelector({
|
||||
}: ModelSelectorProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const groupName = useId();
|
||||
const radioRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
const selectedModel = models.find((model) => model.id === selectedModelId);
|
||||
const unavailable = models.length === 0;
|
||||
|
||||
@@ -38,7 +37,7 @@ export function ModelSelector({
|
||||
const nextModel = models[nextIndex];
|
||||
if (!nextModel) return;
|
||||
onSelect(nextModel.id);
|
||||
window.requestAnimationFrame(() => radioRefs.current[nextIndex]?.focus());
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -65,21 +64,23 @@ export function ModelSelector({
|
||||
{models.map((model, index) => (
|
||||
<label className="model-selector-option" data-selected={model.id === selectedModelId ? "" : undefined} key={model.id}>
|
||||
<input
|
||||
ref={(element) => { radioRefs.current[index] = element; }}
|
||||
className="sr-only"
|
||||
type="radio"
|
||||
name={groupName}
|
||||
value={model.id}
|
||||
checked={model.id === selectedModelId}
|
||||
onChange={() => onSelect(model.id)}
|
||||
onClick={() => setOpen(false)}
|
||||
onClick={() => {
|
||||
if (model.id === selectedModelId) onSelect(model.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
onKeyDown={(event) => moveRadioSelection(event, index)}
|
||||
/>
|
||||
<span className="model-selector-copy">
|
||||
<b>{model.label}</b>
|
||||
<small>{model.description || "通用分析模型"}</small>
|
||||
</span>
|
||||
<span className="model-selector-cost">{model.creditCost} 点</span>
|
||||
<span className="model-selector-cost">{model.creditCost} 点/次</span>
|
||||
<Check className="model-selector-check" aria-hidden="true" />
|
||||
</label>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
export async function reserveConsultationModel<
|
||||
Model extends { readonly id: string },
|
||||
Reservation,
|
||||
>(
|
||||
modelId: string,
|
||||
resolveModel: (modelId: string) => Model | null,
|
||||
reserveCredit: () => Promise<Reservation>,
|
||||
) {
|
||||
const model = resolveModel(modelId);
|
||||
if (!model) return { status: "unavailable" } as const;
|
||||
|
||||
return {
|
||||
status: "reserved",
|
||||
model,
|
||||
usageModelId: model.id,
|
||||
reservation: await reserveCredit(),
|
||||
} as const;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
type SessionModelWrite = {
|
||||
readonly values: { readonly model_id: string };
|
||||
readonly sessionId: string;
|
||||
readonly userId: string;
|
||||
};
|
||||
|
||||
type SessionModelWriter = (write: SessionModelWrite) => PromiseLike<{
|
||||
found: boolean;
|
||||
error: string | null;
|
||||
}>;
|
||||
|
||||
export async function persistSessionModelSelection(
|
||||
write: SessionModelWriter,
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
modelId: string,
|
||||
) {
|
||||
const result = await write({ values: { model_id: modelId }, sessionId, userId });
|
||||
if (result.error) throw new Error(`云端同步失败:${result.error}`);
|
||||
if (!result.found) throw new Error("云端同步失败:对话不存在或无权修改");
|
||||
}
|
||||
|
||||
export class SessionModelPersistenceQueue {
|
||||
private readonly pending = new Map<string, Promise<void>>();
|
||||
|
||||
enqueue(sessionId: string, write: () => Promise<void>) {
|
||||
const previous = this.pending.get(sessionId) ?? Promise.resolve();
|
||||
const current = previous.catch(() => undefined).then(write);
|
||||
this.pending.set(sessionId, current);
|
||||
return current.finally(() => {
|
||||
if (this.pending.get(sessionId) === current) this.pending.delete(sessionId);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -80,7 +80,11 @@ function resolveCatalogEntry(
|
||||
return {
|
||||
...shared,
|
||||
mode: "openai",
|
||||
model: entry.model,
|
||||
model: {
|
||||
providerId: "openai",
|
||||
modelId: entry.model.replace(/^openai\//, ""),
|
||||
apiKey,
|
||||
},
|
||||
};
|
||||
case "openai-compatible":
|
||||
return {
|
||||
@@ -214,6 +218,10 @@ export function resolveLanguageModelCatalog(environment: Environment): LanguageM
|
||||
|
||||
export const languageModelCatalog = resolveLanguageModelCatalog(process.env);
|
||||
|
||||
if (languageModelCatalog.issues.length > 0) {
|
||||
console.warn(`[models] catalog configuration issues: ${languageModelCatalog.issues.join(",")}`);
|
||||
}
|
||||
|
||||
export function resolveLanguageModelFromCatalog(catalog: LanguageModelCatalog, modelId: string) {
|
||||
return catalog.models.find((model) => model.id === modelId) ?? null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { reserveConsultationModel } from "../src/lib/consultation-model-selection.ts";
|
||||
|
||||
test("rejects an unknown model before credit reservation", async () => {
|
||||
let reservationCalls = 0;
|
||||
|
||||
const result = await reserveConsultationModel(
|
||||
"removed-model",
|
||||
() => null,
|
||||
async () => {
|
||||
reservationCalls += 1;
|
||||
return { success: true };
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(result, { status: "unavailable" });
|
||||
assert.equal(reservationCalls, 0);
|
||||
});
|
||||
|
||||
test("keeps the resolved agent model and ledger model id together", async () => {
|
||||
const model = { id: "deepseek-pro", model: "deepseek-v4-pro" };
|
||||
|
||||
const result = await reserveConsultationModel(
|
||||
"deepseek-pro",
|
||||
(modelId) => modelId === model.id ? model : null,
|
||||
async () => ({ success: true, credits: 4 }),
|
||||
);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
status: "reserved",
|
||||
model,
|
||||
usageModelId: "deepseek-pro",
|
||||
reservation: { success: true, credits: 4 },
|
||||
});
|
||||
});
|
||||
@@ -50,7 +50,11 @@ test("resolves configured models while returning sanitized public metadata", ()
|
||||
});
|
||||
assert.equal(JSON.stringify(catalog.publicModels).includes("secret"), false);
|
||||
assert.equal(JSON.stringify(catalog.publicModels).includes("baseURL"), false);
|
||||
assert.equal(catalog.models[1]?.model, "openai/gpt-5-mini");
|
||||
assert.deepEqual(catalog.models[1]?.model, {
|
||||
providerId: "openai",
|
||||
modelId: "gpt-5-mini",
|
||||
apiKey: "openai-secret",
|
||||
});
|
||||
});
|
||||
|
||||
test("excludes an invalid catalog entry without leaking its secret", () => {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
SessionModelPersistenceQueue,
|
||||
persistSessionModelSelection,
|
||||
} from "../src/lib/session-model-persistence.ts";
|
||||
|
||||
test("persists only model_id for the owned session", async () => {
|
||||
const writes: unknown[] = [];
|
||||
|
||||
await persistSessionModelSelection(async (write) => {
|
||||
writes.push(write);
|
||||
return { found: true, error: null };
|
||||
}, "user-1", "session-1", "gpt-mini");
|
||||
|
||||
assert.deepEqual(writes, [{
|
||||
values: { model_id: "gpt-mini" },
|
||||
sessionId: "session-1",
|
||||
userId: "user-1",
|
||||
}]);
|
||||
});
|
||||
|
||||
test("serializes model writes for the same session", async () => {
|
||||
const queue = new SessionModelPersistenceQueue();
|
||||
const calls: string[] = [];
|
||||
let releaseFirst = () => {};
|
||||
const firstGate = new Promise<void>((resolve) => { releaseFirst = resolve; });
|
||||
|
||||
const first = queue.enqueue("session-1", async () => {
|
||||
calls.push("first");
|
||||
await firstGate;
|
||||
});
|
||||
const second = queue.enqueue("session-1", async () => {
|
||||
calls.push("second");
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(calls, ["first"]);
|
||||
releaseFirst();
|
||||
await Promise.all([first, second]);
|
||||
assert.deepEqual(calls, ["first", "second"]);
|
||||
});
|
||||
|
||||
test("continues with the latest model write after an earlier sync fails", async () => {
|
||||
const queue = new SessionModelPersistenceQueue();
|
||||
const calls: string[] = [];
|
||||
|
||||
const failed = queue.enqueue("session-1", async () => {
|
||||
calls.push("failed");
|
||||
throw new Error("offline");
|
||||
});
|
||||
const latest = queue.enqueue("session-1", async () => {
|
||||
calls.push("latest");
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled([failed, latest]);
|
||||
assert.equal(results[0]?.status, "rejected");
|
||||
assert.equal(results[1]?.status, "fulfilled");
|
||||
assert.deepEqual(calls, ["failed", "latest"]);
|
||||
});
|
||||
@@ -6,6 +6,11 @@ PAGE = ROOT / "frontend" / "src" / "app" / "page.tsx"
|
||||
AGENT = ROOT / "frontend" / "src" / "mastra" / "index.ts"
|
||||
ONBOARDING_ROUTE = ROOT / "frontend" / "src" / "app" / "api" / "onboarding" / "route.ts"
|
||||
CONSULT_ROUTE = ROOT / "frontend" / "src" / "app" / "api" / "consult" / "route.ts"
|
||||
MODELS_ROUTE = ROOT / "frontend" / "src" / "app" / "api" / "models" / "route.ts"
|
||||
MODEL_SELECTION = ROOT / "frontend" / "src" / "lib" / "consultation-model-selection.ts"
|
||||
SESSION_MODEL_PERSISTENCE = (
|
||||
ROOT / "frontend" / "src" / "lib" / "session-model-persistence.ts"
|
||||
)
|
||||
ONBOARDING_MIGRATION = (
|
||||
ROOT
|
||||
/ "frontend"
|
||||
@@ -40,7 +45,7 @@ def test_onboarding_and_agent_suggestion_contract() -> None:
|
||||
assert "suggestions: reply.suggestions" in page
|
||||
assert "activeSuggestions.map" in page
|
||||
|
||||
assert "export const onboardingAgent" in agent
|
||||
assert "export function getOnboardingAgent" in agent
|
||||
assert "skills: [jyotishSkillPath]" in agent
|
||||
assert "This is onboarding, not a chart reading" in agent
|
||||
assert "<!--AYANAM_SUGGESTIONS:" in agent
|
||||
@@ -52,8 +57,27 @@ def test_onboarding_and_agent_suggestion_contract() -> None:
|
||||
assert "currentTimeContext()," in consult_route
|
||||
assert "中国标准时间(UTC+8)" in consult_route
|
||||
assert 'profile.onboarding_version === ONBOARDING_VERSION' in route
|
||||
assert "onboardingAgent.generate" in route
|
||||
assert "getOnboardingAgent(onboardingModel).generate" in route
|
||||
assert 'source: "cache"' in route
|
||||
assert "onboarding_payload" in migration
|
||||
assert "to service_role" in migration
|
||||
assert "to authenticated" not in migration
|
||||
|
||||
|
||||
def test_multi_model_route_and_persistence_contract() -> None:
|
||||
consult_route = CONSULT_ROUTE.read_text(encoding="utf-8")
|
||||
models_route = MODELS_ROUTE.read_text(encoding="utf-8")
|
||||
selection = MODEL_SELECTION.read_text(encoding="utf-8")
|
||||
persistence = SESSION_MODEL_PERSISTENCE.read_text(encoding="utf-8")
|
||||
|
||||
assert "supabase.auth.getUser()" in models_route
|
||||
assert "publicLanguageModelCatalog()" in models_route
|
||||
assert "reserveConsultationModel(" in consult_route
|
||||
assert "resolveLanguageModel," in consult_route
|
||||
assert 'runCreditRpc(accounting, "begin_consultation_credit"' in consult_route
|
||||
assert "getJyotishAgent(selectedModel).stream" in consult_route
|
||||
assert "modelSelection.usageModelId" in consult_route
|
||||
assert 'if (!model) return { status: "unavailable" }' in selection
|
||||
assert 'reservation: await reserveCredit()' in selection
|
||||
assert 'values: { model_id: modelId }' in persistence
|
||||
assert "SessionModelPersistenceQueue" in persistence
|
||||
|
||||
Reference in New Issue
Block a user