已开启
feat(web): 会话内切换模型时明确作用域提示(Issue #97) #150
feat(web): 会话内切换模型时明确作用域提示(Issue #97) #150
已开启
hugoxk创建于 9月19日
共 6 个文件变更+93-6
Mapps/web/src/App.tsx+13-4文件内容审核中,请稍后刷新重试
@@ -266,6 +266,7 @@ export function ModelPicker({
266 <strong>{t("composer.modelPicker.title")}</strong>266 <strong>{t("composer.modelPicker.title")}</strong>
267 <span>{t("composer.modelPicker.count", { total: models.length })}</span>267 <span>{t("composer.modelPicker.count", { total: models.length })}</span>
268 </div>268 </div>
269+ <p className="model-picker-scope-note">{t("composer.modelPicker.scopeHint")}</p>
269 {groups.length ? <div className="model-picker-groups">270 {groups.length ? <div className="model-picker-groups">
270 {groups.map((group) => <div className="model-picker-group" key={group.provider?.id ?? "other"}>271 {groups.map((group) => <div className="model-picker-group" key={group.provider?.id ?? "other"}>
271 <h4>{group.provider ? group.provider.name : t("composer.modelPicker.otherGroup")}</h4>272 <h4>{group.provider ? group.provider.name : t("composer.modelPicker.otherGroup")}</h4>
@@ -18,7 +18,30 @@ import type {
18} from "@sciencediscovery/schema";18} from "@sciencediscovery/schema";
19 19 
20import { SendIcon, StopIcon } from "../icons.js";20import { SendIcon, StopIcon } from "../icons.js";
21-import { useLocale } from "../i18n/index.js";21+import { useLocale, type MessageKey } from "../i18n/index.js";
22+ 
23+export interface ModelSwitchNotice {
24+ detail: string;
25+ title: string;
26+ tone: "success";
27+}
28+ 
29+/**
30+ * Scope feedback after picking a model in the composer. The picker sits where a
31+ * global setting would, but the choice only writes this Session's override, so
32+ * the notice names the model and says plainly that a new Session falls back to
33+ * the Project or system default — the confusion issue #97 reported.
34+ */
35+export function modelSwitchNotice(
36+ modelName: string,
37+ t: (key: MessageKey, variables?: Record<string, string | number>) => string,
38+): ModelSwitchNotice {
39+ return {
40+ detail: t("composer.modelSwitch.detail"),
41+ title: t("composer.modelSwitch.title", { model: modelName }),
42+ tone: "success",
43+ };
44+}
22 45 
23export function buildCreateSessionRequest(46export function buildCreateSessionRequest(
24 title: string,47 title: string,
@@ -319,6 +319,9 @@ export const en = {
319 "composer.modelPicker.off": "Off",319 "composer.modelPicker.off": "Off",
320 "composer.modelPicker.thinkingOff": "Thinking off",320 "composer.modelPicker.thinkingOff": "Thinking off",
321 "composer.modelPicker.thinkingUnsupported": "This model does not expose a thinking control field.",321 "composer.modelPicker.thinkingUnsupported": "This model does not expose a thinking control field.",
322+ "composer.modelPicker.scopeHint": "Switching here applies to this Session only. New Sessions use the Project or system default model.",
323+ "composer.modelSwitch.title": "Current Session model switched to {model}",
324+ "composer.modelSwitch.detail": "Applies to this Session only; new Sessions keep the default model. Change the default in Project or system settings.",
322 "composer.keyboardHint": "Enter to send · Shift+Enter for a new line",325 "composer.keyboardHint": "Enter to send · Shift+Enter for a new line",
323 "composer.approvals": "Approvals",326 "composer.approvals": "Approvals",
324 "composer.askDangerous": "Ask for dangerous actions",327 "composer.askDangerous": "Ask for dangerous actions",
@@ -2906,6 +2909,9 @@ export const zhCN: Partial<Record<MessageKey, string>> = {
2906 "composer.modelPicker.off": "关闭",2909 "composer.modelPicker.off": "关闭",
2907 "composer.modelPicker.thinkingOff": "思考关闭",2910 "composer.modelPicker.thinkingOff": "思考关闭",
2908 "composer.modelPicker.thinkingUnsupported": "此模型不暴露思考控制字段。",2911 "composer.modelPicker.thinkingUnsupported": "此模型不暴露思考控制字段。",
2912+ "composer.modelPicker.scopeHint": "此处的切换仅对当前会话生效;新会话使用项目或系统默认模型。",
2913+ "composer.modelSwitch.title": "已将当前会话模型切换为 {model}",
2914+ "composer.modelSwitch.detail": "仅对当前会话生效;新会话将继续使用默认模型,可在项目设置或系统设置中修改。",
2909 "composer.keyboardHint": "Enter 发送 · Shift+Enter 换行",2915 "composer.keyboardHint": "Enter 发送 · Shift+Enter 换行",
2910 "composer.approvals": "审批",2916 "composer.approvals": "审批",
2911 "composer.askDangerous": "危险操作时询问",2917 "composer.askDangerous": "危险操作时询问",
@@ -168,6 +168,7 @@ button.session-bar-project:hover { color: var(--accent-strong); }
168.model-picker-popover { position: absolute; z-index: var(--z-panel); left: 0; bottom: calc(100% + 8px); width: min(340px, calc(100vw - 24px)); max-height: min(55vh, 460px); overflow-y: auto; border: 1px solid var(--border-strong); border-radius: var(--radius-l); background: var(--surface); box-shadow: var(--shadow-3); padding: 10px 12px; }168.model-picker-popover { position: absolute; z-index: var(--z-panel); left: 0; bottom: calc(100% + 8px); width: min(340px, calc(100vw - 24px)); max-height: min(55vh, 460px); overflow-y: auto; border: 1px solid var(--border-strong); border-radius: var(--radius-l); background: var(--surface); box-shadow: var(--shadow-3); padding: 10px 12px; }
169.model-picker-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 6px; color: var(--text-heading); font-size: 12px; }169.model-picker-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 6px; color: var(--text-heading); font-size: 12px; }
170.model-picker-heading span { color: var(--muted); font-weight: 650; }170.model-picker-heading span { color: var(--muted); font-weight: 650; }
171+.model-picker-scope-note { margin: 0 0 8px; color: var(--muted); font-size: 11px; line-height: 1.5; }
171.model-picker-group > h4 { margin: 8px 0 2px; color: var(--text-faint); font-size: 10px; font-weight: 750; text-transform: uppercase; }172.model-picker-group > h4 { margin: 8px 0 2px; color: var(--text-faint); font-size: 10px; font-weight: 750; text-transform: uppercase; }
172.model-picker-group:first-child > h4 { margin-top: 0; }173.model-picker-group:first-child > h4 { margin-top: 0; }
173.model-picker-popover ul { display: grid; margin: 0; padding: 0; list-style: none; }174.model-picker-popover ul { display: grid; margin: 0; padding: 0; list-style: none; }
@@ -18,6 +18,7 @@ import test from "node:test";
18import type { ModelProfile, ModelProvider } from "@sciencediscovery/schema";18import type { ModelProfile, ModelProvider } from "@sciencediscovery/schema";
19import { createElement } from "react";19import { createElement } from "react";
20import { renderToStaticMarkup } from "react-dom/server";20import { renderToStaticMarkup } from "react-dom/server";
21+import { act, create, type ReactTestRenderer } from "react-test-renderer";
21 22 
22import {23import {
23 groupModelsByProvider,24 groupModelsByProvider,
@@ -29,8 +30,9 @@ import {
29 thinkingChoiceLabel,30 thinkingChoiceLabel,
30 thinkingChoiceLabelKey,31 thinkingChoiceLabelKey,
31} from "../src/composer/ModelPicker.js";32} from "../src/composer/ModelPicker.js";
33+import { modelSwitchNotice } from "../src/composer/model.js";
32import { installWebModelCatalog } from "./model-catalog-fixture.js";34import { installWebModelCatalog } from "./model-catalog-fixture.js";
33-import { LocaleProvider } from "../src/i18n/index.js";35+import { LocaleProvider, translate } from "../src/i18n/index.js";
34import { en, zhCN } from "../src/i18n/messages.js";36import { en, zhCN } from "../src/i18n/messages.js";
35import type { ModelThinkingControls } from "../src/modelThinking.js";37import type { ModelThinkingControls } from "../src/modelThinking.js";
36import { thinkingChoiceOptions, thinkingChoiceValue } from "../src/modelThinking.js";38import { thinkingChoiceOptions, thinkingChoiceValue } from "../src/modelThinking.js";
@@ -258,3 +260,48 @@ test("model default replaces auto in user-facing labels", () => {
258 assert.equal(en["composer.modelPicker.thinkingOff"], "Thinking off");260 assert.equal(en["composer.modelPicker.thinkingOff"], "Thinking off");
259 assert.equal(zhCN["composer.modelPicker.thinkingOff"], "思考关闭");261 assert.equal(zhCN["composer.modelPicker.thinkingOff"], "思考关闭");
260});262});
263+ 
264+test("switching a model names it and states the Session-only scope", () => {
265+ const enNotice = modelSwitchNotice("DeepSeek V4 Flash", (key, variables) => translate("en", key, variables));
266+ assert.equal(enNotice.tone, "success");
267+ assert.match(enNotice.title, /DeepSeek V4 Flash/);
268+ assert.match(enNotice.detail, /new Sessions keep the default model/i);
269+ assert.match(enNotice.detail, /Project or system settings/);
270+ 
271+ const zhNotice = modelSwitchNotice("DeepSeek V4 Flash", (key, variables) => translate("zh-CN", key, variables));
272+ assert.equal(zhNotice.title, "已将当前会话模型切换为 DeepSeek V4 Flash");
273+ assert.match(zhNotice.detail, /仅对当前会话生效/);
274+ assert.match(zhNotice.detail, /新会话将继续使用默认模型/);
275+ assert.match(zhNotice.detail, /项目设置或系统设置/);
276+});
277+ 
278+test("the picker states the Session scope in the popover before a model is chosen", async (t) => {
279+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
280+ const previousDocument = globalThis.document;
281+ globalThis.document = new EventTarget() as Document;
282+ const models = [model("m1", "DeepSeek Chat", "p1")];
283+ let view: ReactTestRenderer | undefined;
284+ t.after(async () => {
285+ if (view) await act(async () => view!.unmount());
286+ globalThis.document = previousDocument;
287+ });
288+ await act(async () => {
289+ view = create(createElement(LocaleProvider, { initialLocale: "zh-CN" }, createElement(ModelPicker, {
290+ activeModelId: "m1",
291+ controls: FULL_CONTROLS,
292+ models,
293+ onOpenSettings: () => undefined,
294+ onSelect: () => undefined,
295+ onThinkingChange: () => undefined,
296+ providers: [provider("p1", "DeepSeek")],
297+ thinkingEffort: "high",
298+ thinkingMode: "auto",
299+ })));
300+ });
301+ // The hint belongs to the open picker, not the closed trigger.
302+ assert.equal(view!.root.findAllByProps({ className: "model-picker-scope-note" }).length, 0);
303+ await act(async () => { view!.root.findByType("button").props.onClick(); });
304+ const note = view!.root.findByProps({ className: "model-picker-scope-note" });
305+ assert.match(JSON.stringify(note.children), /仅对当前会话生效/);
306+ assert.match(JSON.stringify(note.children), /项目或系统默认模型/);
307+});