已开启
feat(web): 会话内切换模型时明确作用域提示(Issue #97) #150
hugoxk创建于 9月19日
feat(web): 会话内切换模型时明确作用域提示(Issue #97) #150
已开启
共 6 个文件变更+93-6
| @@ -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 | ||
| 20 | import { SendIcon, StopIcon } from "../icons.js"; | 20 | import { 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 | ||
| 23 | export function buildCreateSessionRequest( | 46 | export 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"; | |||
| 18 | import type { ModelProfile, ModelProvider } from "@sciencediscovery/schema"; | 18 | import type { ModelProfile, ModelProvider } from "@sciencediscovery/schema"; |
| 19 | import { createElement } from "react"; | 19 | import { createElement } from "react"; |
| 20 | import { renderToStaticMarkup } from "react-dom/server"; | 20 | import { renderToStaticMarkup } from "react-dom/server"; |
| 21 | +import { act, create, type ReactTestRenderer } from "react-test-renderer"; | ||
| 21 | 22 | ||
| 22 | import { | 23 | import { |
| 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"; | ||
| 32 | import { installWebModelCatalog } from "./model-catalog-fixture.js"; | 34 | import { installWebModelCatalog } from "./model-catalog-fixture.js"; |
| 33 | -import { LocaleProvider } from "../src/i18n/index.js"; | 35 | +import { LocaleProvider, translate } from "../src/i18n/index.js"; |
| 34 | import { en, zhCN } from "../src/i18n/messages.js"; | 36 | import { en, zhCN } from "../src/i18n/messages.js"; |
| 35 | import type { ModelThinkingControls } from "../src/modelThinking.js"; | 37 | import type { ModelThinkingControls } from "../src/modelThinking.js"; |
| 36 | import { thinkingChoiceOptions, thinkingChoiceValue } from "../src/modelThinking.js"; | 38 | import { 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 | +}); | ||