@morlay/dsh-session-mode 0.0.1-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,109 @@
1
+ /**
2
+ * 新会话屏幕上的模式 chip:**切换列表**就在这里(点开是 coding / chat 两项)。
3
+ *
4
+ * 为什么住在新会话屏幕而不是输入框上方:选择只在会话开始**之前**有效——一旦跑过 turn,那段历史是在某个
5
+ * 模式的工具与提示词下产生的,host 会拒绝换(与上游 `agentPresets.select` 同一条判据)。一个大半辈子都
6
+ * 该禁用的控件,放在它仍然可用的那块屏幕上更诚实。
7
+ */
8
+
9
+ import { useState } from "react";
10
+ import {
11
+ IconAgentPresetOutlineRegular,
12
+ IconChevronDownOutlineRegular,
13
+ Menu,
14
+ } from "@deepseek-ai/dsh-client-ui-primitives";
15
+ import type { PropsLocale, PropsRuntime } from "@deepseek-ai/dsh-client-ui-slots";
16
+ // Type-only:拉入本包 host 半的 Context / 投影声明(`sessionMode`),让 `projectionValues` 有类型。
17
+ import type {} from "../index.ts";
18
+ // Type-only:拉入 ui-conversation 的 SlotMap 合并(hero 的座位)。
19
+ import type {} from "@morlay/dsh-client-ui-conversation/client";
20
+ import { selectMode } from "./api.ts";
21
+ import { useRoster } from "./use-roster.ts";
22
+ import css from "./SessionModeSeat.module.css";
23
+
24
+ /** 完整 props:hero 槽位的运行时 props + 本包的字典。 */
25
+ export type SessionModeSeatProps = PropsRuntime<"conversation.hero.agentPreset"> &
26
+ PropsLocale<"session-mode">;
27
+
28
+ /**
29
+ * 渲染新会话的模式 chip。
30
+ * @param props - 槽位合成的 props。
31
+ * @returns chip;清单没读到、或不是主视图的会话时返回 null。
32
+ */
33
+ export function SessionModeSeat({
34
+ sessionId,
35
+ useSessions,
36
+ useSessionRetainInfo,
37
+ t,
38
+ }: SessionModeSeatProps) {
39
+ const roster = useRoster();
40
+ const selected = useSessions((state) => {
41
+ const value =
42
+ sessionId === undefined ? undefined : state.byId[sessionId]?.projectionValues?.sessionMode;
43
+ return typeof value === "string" ? value : undefined;
44
+ });
45
+ const main = useSessionRetainInfo(
46
+ (info) => sessionId === undefined || (info?.retainedBy.mainView ?? 0) > 0,
47
+ );
48
+ const [open, setOpen] = useState(false);
49
+ const [busy, setBusy] = useState(false);
50
+ const [error, setError] = useState<string | null>(null);
51
+
52
+ if (!main || roster === undefined) return null;
53
+
54
+ const current = selected ?? roster.default;
55
+ const chosen = roster.modes.find((mode) => mode.id === current);
56
+ const label = chosen?.name ?? current;
57
+
58
+ return (
59
+ <Menu
60
+ open={open}
61
+ onClose={() => {
62
+ setOpen(false);
63
+ }}
64
+ items={roster.modes.map((mode) => ({
65
+ id: mode.id,
66
+ label: (
67
+ <span className={css.item}>
68
+ <span className={css.itemName}>{mode.name}</span>
69
+ <span className={css.itemDesc}>{mode.description ?? t("noDescription")}</span>
70
+ </span>
71
+ ),
72
+ }))}
73
+ selectedId={current}
74
+ onSelect={(id) => {
75
+ setOpen(false);
76
+ if (sessionId === undefined || id === current) return;
77
+ setBusy(true);
78
+ setError(null);
79
+ void selectMode(sessionId, id)
80
+ .catch((cause: unknown) => {
81
+ setError(cause instanceof Error ? cause.message : String(cause));
82
+ })
83
+ .finally(() => {
84
+ setBusy(false);
85
+ });
86
+ }}
87
+ align="start"
88
+ portal
89
+ className={css.menuAnchor}
90
+ anchor={
91
+ <button
92
+ type="button"
93
+ className={css.seat}
94
+ aria-haspopup="menu"
95
+ aria-expanded={open}
96
+ title={error ?? t("seatHint")}
97
+ disabled={busy}
98
+ onClick={() => {
99
+ setOpen((value) => !value);
100
+ }}
101
+ >
102
+ <IconAgentPresetOutlineRegular className={css.seatIcon} />
103
+ <span className={css.seatLabel}>{label}</span>
104
+ <IconChevronDownOutlineRegular className={css.chevron} />
105
+ </button>
106
+ }
107
+ />
108
+ );
109
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * client 半与 host 的**唯一**通路:模式清单(`GET`)与切换(`POST`)。
3
+ *
4
+ * 当前模式不走这里——它是一条会话投影(`sessionMode`),随会话列表一起到页面,组件用 `useSessions`
5
+ * 读它。于是这里只剩两件事:拉清单、发一次切换。
6
+ */
7
+
8
+ import {
9
+ SESSION_MODE_PATH,
10
+ type SessionModeRoster,
11
+ type SessionModeSelectResult,
12
+ } from "../shared.ts";
13
+
14
+ /** 读一次模式清单;清单在页面生命周期里是静态的(装配事实),调用方自己缓存。 */
15
+ export async function fetchRoster(): Promise<SessionModeRoster> {
16
+ const response = await fetch(SESSION_MODE_PATH, {
17
+ method: "GET",
18
+ headers: { accept: "application/json" },
19
+ });
20
+ if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
21
+ return (await response.json()) as SessionModeRoster;
22
+ }
23
+
24
+ /**
25
+ * 把某个空白会话切到某个模式。
26
+ * @param sessionId - 目标会话。
27
+ * @param mode - 目标模式 id。
28
+ * @returns 提交后的模式 id。
29
+ * @throws 会话已开始、模式不存在、会话不存在——host 给的中文原因。
30
+ */
31
+ export async function selectMode(sessionId: string, mode: string): Promise<string> {
32
+ const response = await fetch(SESSION_MODE_PATH, {
33
+ method: "POST",
34
+ headers: { accept: "application/json", "content-type": "application/json" },
35
+ body: JSON.stringify({ sessionId, mode }),
36
+ });
37
+ const value = (await response.json().catch(() => undefined)) as
38
+ | (Partial<SessionModeSelectResult> & { error?: unknown })
39
+ | undefined;
40
+ if (!response.ok) {
41
+ const error = value?.["error"];
42
+ throw new Error(typeof error === "string" ? error : `HTTP ${String(response.status)}`);
43
+ }
44
+ return value?.mode ?? mode;
45
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * client 半:会话里的两个面(模式 chip 与头部标签)+ 设置页里那张「会话模式」卡片。
3
+ *
4
+ * | 槽位 | 呈现 |
5
+ * | ---------------------------------------- | --------------------------------------------- |
6
+ * | `conversation.hero.agentPreset` | 新会话屏幕的顶部占位——chip 点开就是切换列表 |
7
+ * | `conversation.session.header.actions` | 会话头部的只读模式标签(`order: -10`,贴标题) |
8
+ * | `plugins.row.config`(key 见下) | 设置页里**本行**的配置入口:各模式的默认模型(`config.models`) |
9
+ *
10
+ * 官方的 `@deepseek-ai/dsh-client-ui-agent-preset` 在装配里被禁用(它同时带来设置页那块 roster 面板),
11
+ * 所以这两个面归我们。设置页那张卡片编辑的是**各模式的默认模型**——配置事实(`config.models`),不是模式
12
+ * 清单;模式清单仍然是装配配置,改它不需要页面。
13
+ *
14
+ * 卡片的数据面是 host 行的 settings namespace `session-mode`(namespace 名就是行 id):host 行被描述出来
15
+ * 之后(`configForms.whileServed`)条目才注册,行不在时条目也一起消失。注册到 `plugins.row.config` 而不是
16
+ * 官方分组 `plugins.item`:后者按上游契约由官方那几张设置卡占用(一个 host 命名空间一个伴生包),行自己的
17
+ * 配置就该挂在自己的行上——key 由 `SESSION_MODE_ROW_CONFIG_KEY` 给出。
18
+ */
19
+
20
+ import type { Context } from "@deepseek-ai/cordis";
21
+ import type {} from "@deepseek-ai/dsh-api-remotes/client";
22
+ import type {} from "@deepseek-ai/dsh-client-locale/client";
23
+ import type {} from "@deepseek-ai/dsh-client-ui-plugin-manager/client";
24
+ import type {} from "@deepseek-ai/dsh-client-ui-settings/client";
25
+ import type {} from "@deepseek-ai/dsh-client-ui-slots";
26
+ // Type-only:槽位声明与 standard props(session / session-maybe / global)。
27
+ import type {} from "@morlay/dsh-client-ui-conversation/client";
28
+ import { ModelDefaultsCard } from "./ModelDefaultsCard.tsx";
29
+ import { SESSION_MODE_NS, ModelDefaultsCardController } from "./model-defaults-card-controller.ts";
30
+ import { SessionModeLabel } from "./SessionModeLabel.tsx";
31
+ import { SessionModeSeat } from "./SessionModeSeat.tsx";
32
+ import { fetchRoster } from "./api.ts";
33
+ import { en, zh, type SessionModeLocaleKey } from "./locales.ts";
34
+
35
+ declare module "@deepseek-ai/dsh-client-ui-slots" {
36
+ interface LocaleNamespaceMap {
37
+ /** 模式选择面与设置卡片的文案。 */
38
+ "session-mode": SessionModeLocaleKey;
39
+ }
40
+ }
41
+
42
+ /** 浏览器半插件的字典命名空间。 */
43
+ const NS = "session-mode";
44
+
45
+ /**
46
+ * 本行的配置入口 key:`<bundle 包名>#<行 id>`。
47
+ *
48
+ * 页面侧由 `ui-plugin-manager` 的 `rowConfigKey(bundle, rowId)` 生成同一个串——bundle 是本包
49
+ * (`dsh.profile.bundles` 里那一项),行 id 是本包 patch 里插的 `session-mode`。
50
+ */
51
+ export const SESSION_MODE_ROW_CONFIG_KEY = "@morlay/dsh-session-mode#session-mode";
52
+
53
+ export type { ModelDefaultsCardProps } from "./ModelDefaultsCard.tsx";
54
+ export type {
55
+ ModelDefaultsCardFace,
56
+ ModelDefaultsCardState,
57
+ ModelDefaultsRow,
58
+ ModelDirectory,
59
+ ModelDefaultsSources,
60
+ SessionModeSettings,
61
+ } from "./model-defaults-card-controller.ts";
62
+ export { ModelDefaultsCardController, SESSION_MODE_NS } from "./model-defaults-card-controller.ts";
63
+ export type { SessionModeLabelProps } from "./SessionModeLabel.tsx";
64
+ export type { SessionModeSeatProps } from "./SessionModeSeat.tsx";
65
+ export type { SessionModeLocaleKey } from "./locales.ts";
66
+
67
+ /** 需要的服务:槽位与字典(会话列表经槽位的标准 props 到达组件,不必自己 inject)。 */
68
+ export const inject = ["slots", "locale"];
69
+
70
+ /**
71
+ * 装上三个面。
72
+ * @param ctx - 浏览器插件上下文。
73
+ */
74
+ export function apply(ctx: Context): void {
75
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), "session-mode: dictionaries");
76
+
77
+ // 会话里的两个面都要求会话流的上下文(hero 的座位与头部动作行挂在 conversation 上)。
78
+ ctx.inject(["slots", "conversation"], (scope) => {
79
+ scope.effect(() => {
80
+ const seat = scope.slots.register(
81
+ { name: "conversation.hero.agentPreset", locale: NS },
82
+ SessionModeSeat,
83
+ );
84
+ const label = scope.slots.register(
85
+ {
86
+ name: "conversation.session.header.actions",
87
+ id: "session-mode",
88
+ // 静态会话上下文占头部前导的负序位(与官方那一行同位)。
89
+ order: -10,
90
+ locale: NS,
91
+ },
92
+ SessionModeLabel,
93
+ );
94
+ return () => {
95
+ seat();
96
+ label();
97
+ };
98
+ }, "session-mode: hero chip and header label");
99
+ });
100
+
101
+ // 设置页那张卡片要 `configForms`(共享配置表单)、`remote.session`(模型目录)与 `plugins.item`
102
+ // (Plugins 页的槽)。它们都可能比本行晚到、或在这个部署里根本没有,所以走 `ctx.inject` 与
103
+ // `slots.inject` 等它们就位——本行的基础 `inject` 不点它们,缺了也不影响上面两个面。
104
+ ctx.inject(["slots", "locale", "configForms", "remote", "remote.session"], (forms) => {
105
+ const t = forms.locale.bind(NS);
106
+ forms.effect(() => {
107
+ const controller = new ModelDefaultsCardController(
108
+ forms.configForms.get(SESSION_MODE_NS),
109
+ {
110
+ roster: fetchRoster,
111
+ // 目录只在打开卡片时读一次:它决定选择器的选项,页面生命周期里不会变。
112
+ directory: async () => {
113
+ const response = await forms.remote.session.modelCatalog();
114
+ if (!response.ok) throw new Error("model catalog is unavailable");
115
+ return {
116
+ groups: response.value.groups,
117
+ failures: response.value.failures.map((failure) => failure.name),
118
+ };
119
+ },
120
+ },
121
+ );
122
+ const face = controller.inject();
123
+ const page = forms.configForms.whileServed([SESSION_MODE_NS], () =>
124
+ forms.slots.inject("plugins.row.config", () =>
125
+ forms.slots.register(
126
+ {
127
+ name: "plugins.row.config",
128
+ key: SESSION_MODE_ROW_CONFIG_KEY,
129
+ // 文案经我们自己的注入面传:keyed 槽的注册项不带 `locale` 声明,不赌页面一定注入字典。
130
+ inject: () => ({ ...face, t }),
131
+ },
132
+ ModelDefaultsCard,
133
+ ),
134
+ ),
135
+ );
136
+ return () => {
137
+ page();
138
+ controller.dispose();
139
+ };
140
+ }, "session-mode: settings card");
141
+ });
142
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * client 半的文案:命名空间 `session-mode`(与 locale 注册时用的 key 一致)。
3
+ *
4
+ * 两个面共用这一份字典:会话里的两个槽位(模式 chip 与头部标签)与设置页那张卡片。模式的名字与说明由
5
+ * host 的清单给(那是数据,不是文案)。
6
+ */
7
+
8
+ import type { SettingsFormLabels } from "@morlay/dsh-client-ui-primitives/client";
9
+
10
+ export const zh = {
11
+ seatHint: "选择这个会话的模式",
12
+ headerHint: "这个会话运行的模式",
13
+ noDescription: "(无说明)",
14
+ switchRefused: "切换失败:{reason}",
15
+ loadFailed: "读不到模式清单",
16
+ cardTitle: "会话模式",
17
+ cardDescription: "给每个会话模式指定默认模型;不指定就跟全局默认模型。",
18
+ defaultsTitle: "各模式的默认模型",
19
+ defaultsHint:
20
+ "只在会话还没有模型事实(没选过模型、也还没跑过请求)时生效;留空就是跟全局默认模型。改完按下面的保存写进设置。",
21
+ providerLabel: "服务商",
22
+ modelLabel: "模型",
23
+ effortLabel: "思考档位",
24
+ inheritOption: "跟全局默认",
25
+ chooseModel: "选择模型…",
26
+ effortDefaultOption: "跟模型默认",
27
+ staleOption: "{value}(不在目录里)",
28
+ overridden: "已覆盖",
29
+ reset: "恢复默认",
30
+ modelRequired: "还没选模型",
31
+ readOnly: "本部署的设置为只读。",
32
+ unavailable: "本插件当前未加载,暂时无法配置。",
33
+ save: "保存",
34
+ saving: "保存中…",
35
+ saveFailed: "本部署没有接受这些值,已保留供你修改。",
36
+ rosterFailed: "读不到会话模式清单,暂时列不出模式。",
37
+ directoryFailed: "读不到模型目录,暂时只能看当前值或恢复默认。",
38
+ directoryPartial: "这些服务商的目录没读到:{names}",
39
+ };
40
+
41
+ export const en: Record<keyof typeof zh, string> = {
42
+ seatHint: "Pick the mode for this session",
43
+ headerHint: "The mode this session runs",
44
+ noDescription: "(no description)",
45
+ switchRefused: "Switch refused: {reason}",
46
+ loadFailed: "Could not load the mode roster",
47
+ cardTitle: "Session mode",
48
+ cardDescription: "Set the default model of each session mode; unset follows the global default.",
49
+ defaultsTitle: "Default model per mode",
50
+ defaultsHint:
51
+ "Applies only while a session has no model fact yet (no model picked, no request made); unset follows the global default model. Save writes it into the settings document.",
52
+ providerLabel: "Provider",
53
+ modelLabel: "Model",
54
+ effortLabel: "Reasoning effort",
55
+ inheritOption: "Same as global default",
56
+ chooseModel: "Choose a model…",
57
+ effortDefaultOption: "Model default",
58
+ staleOption: "{value} (not in the directory)",
59
+ overridden: "Overridden",
60
+ reset: "Reset to default",
61
+ modelRequired: "No model picked yet",
62
+ readOnly: "This deployment stores settings read-only.",
63
+ unavailable: "This plugin is not loaded, so it cannot be configured right now.",
64
+ save: "Save",
65
+ saving: "Saving…",
66
+ saveFailed: "The deployment did not accept these values; they were left for you to correct.",
67
+ rosterFailed: "Could not load the session mode roster, so no mode can be listed.",
68
+ directoryFailed:
69
+ "Could not read the model directory, so only the current value or a reset is available.",
70
+ directoryPartial: "These providers could not be read: {names}",
71
+ };
72
+
73
+ export type SessionModeLocaleKey = keyof typeof zh;
74
+
75
+ /**
76
+ * 共享设置表单外框的文案,从本页字典里读。
77
+ * @param t - 本页的字典读取器。
78
+ * @returns `SettingsForm` 渲染用的标签。
79
+ */
80
+ export function formLabels(t: (key: SessionModeLocaleKey) => string): SettingsFormLabels {
81
+ return {
82
+ unavailable: t("unavailable"),
83
+ readOnly: t("readOnly"),
84
+ saveFailed: t("saveFailed"),
85
+ save: t("save"),
86
+ saving: t("saving"),
87
+ };
88
+ }