dsh-codinput 0.1.0
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.
- package/CHANGELOG.md +30 -0
- package/LICENSE +21 -0
- package/NOTICE +18 -0
- package/README.en.md +167 -0
- package/README.md +167 -0
- package/TESTING.md +95 -0
- package/assets/en/preview-ball-detail.png +0 -0
- package/assets/en/preview-float.png +0 -0
- package/assets/en/preview-normal.png +0 -0
- package/assets/en/preview-side.png +0 -0
- package/assets/en/preview-split.png +0 -0
- package/assets/icon.svg +1 -0
- package/assets/preview-ball-detail.png +0 -0
- package/assets/preview-float.png +0 -0
- package/assets/preview-normal.png +0 -0
- package/assets/preview-side.png +0 -0
- package/assets/preview-split.png +0 -0
- package/cordis.patch.yml +5 -0
- package/lib/client.js +544 -0
- package/lib/index.js +5 -0
- package/package.json +79 -0
- package/src/client/attachments.ts +43 -0
- package/src/client/cm.ts +207 -0
- package/src/client/command.ts +78 -0
- package/src/client/components/AttachmentRail.tsx +241 -0
- package/src/client/components/CodinputBar.tsx +357 -0
- package/src/client/components/CodinputSurface.tsx +375 -0
- package/src/client/components/EditorPane.tsx +214 -0
- package/src/client/components/ExpandBall.tsx +122 -0
- package/src/client/components/MenuSurface.tsx +72 -0
- package/src/client/components/ModelMenu.tsx +234 -0
- package/src/client/components/PermissionMenu.tsx +257 -0
- package/src/client/components/PopupSelectView.tsx +140 -0
- package/src/client/components/SlashMenu.tsx +255 -0
- package/src/client/components/StatsRow.tsx +188 -0
- package/src/client/components/Toolbar.tsx +239 -0
- package/src/client/components/popup-filter.ts +13 -0
- package/src/client/components/useSnapshot.ts +18 -0
- package/src/client/diag.ts +58 -0
- package/src/client/host-types.ts +328 -0
- package/src/client/hotkey.ts +82 -0
- package/src/client/i18n.ts +312 -0
- package/src/client/icons.tsx +296 -0
- package/src/client/index.tsx +178 -0
- package/src/client/modeldir.ts +42 -0
- package/src/client/permission.ts +123 -0
- package/src/client/prefs.ts +123 -0
- package/src/client/services.ts +78 -0
- package/src/client/settings.tsx +236 -0
- package/src/client/sidebar.tsx +114 -0
- package/src/client/sideinput.ts +171 -0
- package/src/client/stats.ts +166 -0
- package/src/client/styles.ts +489 -0
- package/src/client/triggers.ts +84 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 小球:侧栏收起后的 Codinput 唤出入口。
|
|
3
|
+
*
|
|
4
|
+
* 场景:输入入口在侧栏(形态 = side),用户把右侧栏收起——此时界面上
|
|
5
|
+
* 没有任何输入入口。小球出现在**标题栏(会话头部)下方**的右上角空余处:
|
|
6
|
+
* 与官方角落按钮同内缩(右侧 12px)右对齐,垂直贴着头部底边下沿 8px;
|
|
7
|
+
* 头部尺寸/窗口变化时重测(左栏展开、分屏、窗口缩放都会带动)。点击 →
|
|
8
|
+
* 展开右侧栏并聚焦 Codinput 标签,小球随承载态消失。
|
|
9
|
+
*
|
|
10
|
+
* 不占用官方任何槽位(早前版本接管 `conversation.session.header.corner`
|
|
11
|
+
* 会顶掉官方侧栏展开按钮——已废弃):小球由接管条目 portal 到 body,
|
|
12
|
+
* 只在「侧栏承载且已收起」时存在,官方标题栏按钮原样保留。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useEffect, useState, type JSX } from 'react';
|
|
16
|
+
import { createPortal } from 'react-dom';
|
|
17
|
+
import { stash } from '../services';
|
|
18
|
+
import { diagError } from '../diag';
|
|
19
|
+
import { useSidebarCarry } from '../sideinput';
|
|
20
|
+
import { SIDEBAR_TAB_KIND } from '../sidebar';
|
|
21
|
+
import { IconBrand } from '../icons';
|
|
22
|
+
import { t, useT } from '../i18n';
|
|
23
|
+
|
|
24
|
+
/** 球径(比官方角落按钮的 28px 大一档,置于内容之上需更显眼)。 */
|
|
25
|
+
const BALL_SIZE = 36;
|
|
26
|
+
/** 球内图标边长。 */
|
|
27
|
+
const BALL_ICON = 18;
|
|
28
|
+
/** 距标题栏右边缘的内缩(官方角落按钮为 12px,取同一值以对齐视觉基线)。 */
|
|
29
|
+
const HEADER_INSET = 12;
|
|
30
|
+
/** 与标题栏底边的间距。 */
|
|
31
|
+
const HEADER_GAP = 8;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 标题栏元素:会话头部(`<header>` 带 role=banner)。优先语义角色,
|
|
35
|
+
* 类名是构建哈希(wSkVaW_*)不可依赖。
|
|
36
|
+
*/
|
|
37
|
+
function headerElement(): HTMLElement | null {
|
|
38
|
+
return document.querySelector<HTMLElement>('[role="banner"]') ?? document.querySelector<HTMLElement>('header');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function CodinputBall(props: { sessionId?: string }): JSX.Element | null {
|
|
42
|
+
const { sessionId } = props;
|
|
43
|
+
useT();
|
|
44
|
+
const { carried, collapsed } = useSidebarCarry(sessionId);
|
|
45
|
+
const active = carried && collapsed;
|
|
46
|
+
const [pos, setPos] = useState<{ left: number; top: number } | null>(null);
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (!active) {
|
|
50
|
+
setPos(null);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const measure = (): void => {
|
|
54
|
+
const rect = headerElement()?.getBoundingClientRect();
|
|
55
|
+
if (!rect || rect.height === 0) return;
|
|
56
|
+
const next = {
|
|
57
|
+
left: Math.round(rect.right - HEADER_INSET - BALL_SIZE),
|
|
58
|
+
top: Math.round(rect.bottom + HEADER_GAP),
|
|
59
|
+
};
|
|
60
|
+
setPos((prev) => (prev && prev.left === next.left && prev.top === next.top ? prev : next));
|
|
61
|
+
};
|
|
62
|
+
measure();
|
|
63
|
+
const header = headerElement();
|
|
64
|
+
const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(measure) : null;
|
|
65
|
+
if (header && observer) observer.observe(header);
|
|
66
|
+
window.addEventListener('resize', measure);
|
|
67
|
+
// 头部尚未挂载(会话切换/重挂载)时短轮询兜底,量到即停。
|
|
68
|
+
const timer = window.setInterval(() => {
|
|
69
|
+
if (!headerElement()) return;
|
|
70
|
+
measure();
|
|
71
|
+
window.clearInterval(timer);
|
|
72
|
+
}, 250);
|
|
73
|
+
return () => {
|
|
74
|
+
observer?.disconnect();
|
|
75
|
+
window.removeEventListener('resize', measure);
|
|
76
|
+
window.clearInterval(timer);
|
|
77
|
+
};
|
|
78
|
+
}, [active]);
|
|
79
|
+
|
|
80
|
+
if (!active || !pos) return null;
|
|
81
|
+
return createPortal(
|
|
82
|
+
<button
|
|
83
|
+
type="button"
|
|
84
|
+
className="dci-ball"
|
|
85
|
+
data-codinput-expand="true"
|
|
86
|
+
style={{ left: pos.left, top: pos.top }}
|
|
87
|
+
aria-label={t("ball.label")}
|
|
88
|
+
title={t("ball.title")}
|
|
89
|
+
onClick={() => summonSidebarCodinput(sessionId)}
|
|
90
|
+
>
|
|
91
|
+
<IconBrand size={BALL_ICON} />
|
|
92
|
+
</button>,
|
|
93
|
+
document.body,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 唤回侧栏里的 Codinput:展开右侧栏 + 聚焦该标签。openTabIn 是语义路径
|
|
99
|
+
* (放置/去重/聚焦/展开一次完成);缺席时退回「仅展开」——侧栏上次激活
|
|
100
|
+
* 的就是 Codinput,展开即露出。
|
|
101
|
+
*/
|
|
102
|
+
function summonSidebarCodinput(sessionId: string | undefined): void {
|
|
103
|
+
const sidebar = stash.sidebarRight;
|
|
104
|
+
if (!sidebar) return;
|
|
105
|
+
try {
|
|
106
|
+
if (sessionId && typeof sidebar.openTabIn === 'function') {
|
|
107
|
+
sidebar.openTabIn(sessionId, SIDEBAR_TAB_KIND);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (typeof sidebar.openTab === 'function') {
|
|
111
|
+
sidebar.openTab(SIDEBAR_TAB_KIND);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
} catch (error) {
|
|
115
|
+
diagError('ball.openTab', error);
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
if (typeof sidebar.isExpanded === 'function' && !sidebar.isExpanded()) sidebar.toggleExpanded?.();
|
|
119
|
+
} catch (error) {
|
|
120
|
+
diagError('ball.expand', error);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** 官方 Menu 容器复刻:fixed 定位、锚点上方 4px、r20 卡片、视口约束。 */
|
|
2
|
+
|
|
3
|
+
import { useEffect, useLayoutEffect, useRef, type ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
export interface MenuSurfaceProps {
|
|
6
|
+
/** 触发器矩形(getBoundingClientRect 快照)。 */
|
|
7
|
+
anchor: { left: number; top: number; right: number; bottom: number };
|
|
8
|
+
onClose(): void;
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
/** 右对齐锚点(trailing 控件用)。 */
|
|
11
|
+
alignEnd?: boolean;
|
|
12
|
+
minWidth?: number;
|
|
13
|
+
/** 弹出方向:top = 锚点上方(默认),bottom = 锚点下方(设置区下拉)。 */
|
|
14
|
+
side?: 'top' | 'bottom';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function MenuSurface(props: MenuSurfaceProps): JSX.Element {
|
|
18
|
+
const { anchor, onClose, children, alignEnd, minWidth = 218, side = 'top' } = props;
|
|
19
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
20
|
+
|
|
21
|
+
useLayoutEffect(() => {
|
|
22
|
+
const el = ref.current;
|
|
23
|
+
if (!el) return;
|
|
24
|
+
const rect = el.getBoundingClientRect();
|
|
25
|
+
let top: number;
|
|
26
|
+
if (side === 'top') {
|
|
27
|
+
top = Math.max(12, anchor.top - rect.height - 4);
|
|
28
|
+
el.style.bottom = 'auto';
|
|
29
|
+
} else {
|
|
30
|
+
top = Math.min(window.innerHeight - rect.height - 12, anchor.bottom + 4);
|
|
31
|
+
el.style.bottom = 'auto';
|
|
32
|
+
}
|
|
33
|
+
el.style.top = `${top}px`;
|
|
34
|
+
let left = anchor.left;
|
|
35
|
+
if (alignEnd) left = anchor.right - rect.width;
|
|
36
|
+
left = Math.min(Math.max(12, left), window.innerWidth - rect.width - 12);
|
|
37
|
+
el.style.left = `${left}px`;
|
|
38
|
+
}, [anchor, alignEnd, side]);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const onDown = (event: PointerEvent): void => {
|
|
42
|
+
if (ref.current && event.target instanceof Node && ref.current.contains(event.target)) return;
|
|
43
|
+
onClose();
|
|
44
|
+
};
|
|
45
|
+
window.addEventListener('pointerdown', onDown, true);
|
|
46
|
+
return () => window.removeEventListener('pointerdown', onDown, true);
|
|
47
|
+
}, [onClose]);
|
|
48
|
+
|
|
49
|
+
const below = side === 'bottom';
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
ref={ref}
|
|
53
|
+
className="dci-menu"
|
|
54
|
+
style={
|
|
55
|
+
below
|
|
56
|
+
? { top: anchor.bottom + 4, bottom: 'auto', ...(alignEnd ? { left: 'auto', right: window.innerWidth - anchor.right } : { left: anchor.left }), minWidth }
|
|
57
|
+
: { left: anchor.left, bottom: window.innerHeight - anchor.top + 4, top: 'auto', ...(alignEnd ? { left: 'auto', right: window.innerWidth - anchor.right } : {}), minWidth }
|
|
58
|
+
}
|
|
59
|
+
>
|
|
60
|
+
<div className="dci-viewport">{children}</div>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 官方菜单项选中勾(描边勾,14-16)。 */
|
|
66
|
+
export function MenuCheck({ size = 16 }: { size?: number }): JSX.Element {
|
|
67
|
+
return (
|
|
68
|
+
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
69
|
+
<path d="M3 8.5L6.5 12L13 4.5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
|
|
70
|
+
</svg>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 模型与思考强度菜单:官方 ModelSelection 的触发器与两级 cell 菜单。
|
|
3
|
+
* 数据来自官方 per-session 目录(ctx.modelDirectories.directoryFor(id).store:
|
|
4
|
+
* { current, groups, status, error }),写入走官方 select(selectModel RPC)。
|
|
5
|
+
* 服务缺席时触发器禁用。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useEffect, useRef, useState } from 'react';
|
|
9
|
+
import type { InputActions, ModelCatalogModel, ModelProviderGroup, ModelSelection } from '../host-types';
|
|
10
|
+
import { getSessionDirectory, type DirectorySnapshot } from '../modeldir';
|
|
11
|
+
import { stash } from '../services';
|
|
12
|
+
import { IconChevronOfficial } from '../icons';
|
|
13
|
+
import { MenuCheck, MenuSurface } from './MenuSurface';
|
|
14
|
+
import { useSnapshot } from './useSnapshot';
|
|
15
|
+
import { t } from '../i18n';
|
|
16
|
+
|
|
17
|
+
export interface ModelMenuProps {
|
|
18
|
+
sessionId: string | undefined;
|
|
19
|
+
onNotify?(level: 'info' | 'error', text: string): void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type MenuView = 'root' | 'models' | 'efforts';
|
|
23
|
+
|
|
24
|
+
/** 目录中按 (provider, model id) 找目录项。 */
|
|
25
|
+
function findCurrent(
|
|
26
|
+
current: ModelSelection | null,
|
|
27
|
+
groups: readonly ModelProviderGroup[],
|
|
28
|
+
): { group: ModelProviderGroup; model: ModelCatalogModel } | null {
|
|
29
|
+
if (!current) return null;
|
|
30
|
+
for (const group of groups) {
|
|
31
|
+
if (group.id !== current.provider) continue;
|
|
32
|
+
const model = group.models.find((m) => m.id === current.model);
|
|
33
|
+
if (model) return { group, model };
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 触发器显示模型**显示名**(目录项 name;目录未热时退回 id)。 */
|
|
39
|
+
function modelLabel(selection: ModelSelection | null, groups: readonly ModelProviderGroup[]): string {
|
|
40
|
+
if (!selection) return t('tool.modelPlain');
|
|
41
|
+
return findCurrent(selection, groups)?.model.name ?? selection.model;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 推理等级显示名(目录 efforts[].name,如 High;未热时退回首字母大写 id)。 */
|
|
45
|
+
function effortLabel(selection: ModelSelection | null, groups: readonly ModelProviderGroup[]): string {
|
|
46
|
+
const effortId = selection?.reasoningEffort;
|
|
47
|
+
if (!effortId) return 'Default';
|
|
48
|
+
const named = findCurrent(selection, groups)?.model.reasoning?.efforts.find((e) => e.id === effortId);
|
|
49
|
+
if (named) return named.name;
|
|
50
|
+
return effortId.charAt(0).toUpperCase() + effortId.slice(1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function ModelMenu(props: ModelMenuProps): JSX.Element {
|
|
54
|
+
const { sessionId, onNotify } = props;
|
|
55
|
+
const [busy, setBusy] = useState(false);
|
|
56
|
+
const [open, setOpen] = useState(false);
|
|
57
|
+
const [view, setView] = useState<MenuView>('root');
|
|
58
|
+
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
|
59
|
+
const [anchor, setAnchor] = useState<{ left: number; top: number; right: number; bottom: number } | null>(null);
|
|
60
|
+
|
|
61
|
+
// 服务缺席(installServices 未注入到)时禁用;svcEpoch 轮询兜底见 CodinputSurface。
|
|
62
|
+
const directory = getSessionDirectory(sessionId);
|
|
63
|
+
const snap = useSnapshot(directory?.store) as DirectorySnapshot | undefined;
|
|
64
|
+
|
|
65
|
+
// 首次打开时确保目录已加载(官方 load 语义)。
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (!open || !directory) return;
|
|
68
|
+
directory.load().catch(() => {});
|
|
69
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
70
|
+
}, [open]);
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (!open) return;
|
|
74
|
+
const onDown = (event: PointerEvent): void => {
|
|
75
|
+
if (refContains(rootMenuRef.current, event.target)) return;
|
|
76
|
+
close();
|
|
77
|
+
};
|
|
78
|
+
window.addEventListener('pointerdown', onDown, true);
|
|
79
|
+
return () => window.removeEventListener('pointerdown', onDown, true);
|
|
80
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
81
|
+
}, [open]);
|
|
82
|
+
|
|
83
|
+
const rootMenuRef = useRef<HTMLDivElement | null>(null);
|
|
84
|
+
|
|
85
|
+
const openMenu = (): void => {
|
|
86
|
+
const el = triggerRef.current;
|
|
87
|
+
if (!el) return;
|
|
88
|
+
setAnchor(el.getBoundingClientRect());
|
|
89
|
+
setView('root');
|
|
90
|
+
setOpen(true);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const close = (): void => {
|
|
94
|
+
setOpen(false);
|
|
95
|
+
setView('root');
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const current = snap?.current ?? null;
|
|
99
|
+
const groups = snap?.groups ?? [];
|
|
100
|
+
const currentModelName = modelLabel(current, groups);
|
|
101
|
+
const currentEffort = effortLabel(current, groups);
|
|
102
|
+
|
|
103
|
+
const pick = async (group: ModelProviderGroup, model: ModelCatalogModel, effortId: string | undefined): Promise<void> => {
|
|
104
|
+
if (!directory || busy) return;
|
|
105
|
+
const next: ModelSelection = {
|
|
106
|
+
provider: group.id,
|
|
107
|
+
model: model.id,
|
|
108
|
+
...(effortId !== undefined ? { reasoningEffort: effortId } : {}),
|
|
109
|
+
};
|
|
110
|
+
setBusy(true);
|
|
111
|
+
try {
|
|
112
|
+
await directory.select(next);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
onNotify?.('error', error instanceof Error ? error.message : String(error));
|
|
115
|
+
} finally {
|
|
116
|
+
setBusy(false);
|
|
117
|
+
close();
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const refContains = (el: Element | null, target: EventTarget | null): boolean =>
|
|
122
|
+
!!el && target instanceof Node && el.contains(target);
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<div style={{ position: 'relative', minWidth: 0 }}>
|
|
126
|
+
<button
|
|
127
|
+
ref={triggerRef}
|
|
128
|
+
type="button"
|
|
129
|
+
className="dci-trigger"
|
|
130
|
+
style={{ maxWidth: 'min(360px, 45cqw)' }}
|
|
131
|
+
data-open={open}
|
|
132
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
133
|
+
disabled={!directory}
|
|
134
|
+
onClick={() => (open ? close() : openMenu())}
|
|
135
|
+
aria-label={t("tool.model", { name: `${currentModelName} · ${currentEffort}` })}
|
|
136
|
+
title={t("tool.model", { name: `${currentModelName} · ${currentEffort}` })}
|
|
137
|
+
>
|
|
138
|
+
<span className="dci-triggerLabel">{currentModelName}</span>
|
|
139
|
+
{currentEffort ? <span className="dci-triggerEffort">{currentEffort}</span> : null}
|
|
140
|
+
<span className="dci-chev" data-open={open}>
|
|
141
|
+
<IconChevronOfficial size={14} />
|
|
142
|
+
</span>
|
|
143
|
+
</button>
|
|
144
|
+
|
|
145
|
+
{open && anchor ? (
|
|
146
|
+
<div ref={rootMenuRef}>
|
|
147
|
+
<MenuSurface anchor={anchor} onClose={close} alignEnd={true} minWidth={240}>
|
|
148
|
+
{view === 'root' ? (
|
|
149
|
+
<>
|
|
150
|
+
<button type="button" className="dci-cell" onClick={() => setView('models')}>
|
|
151
|
+
<span className="dci-cellLabel">{t("menu.model")}</span>
|
|
152
|
+
<span className="dci-cellValue">{currentModelName}</span>
|
|
153
|
+
<span className="dci-cellChevron" style={{ transform: 'rotate(-90deg)' }} aria-hidden="true">
|
|
154
|
+
<IconChevronOfficial size={14} />
|
|
155
|
+
</span>
|
|
156
|
+
</button>
|
|
157
|
+
<button type="button" className="dci-cell" onClick={() => setView('efforts')}>
|
|
158
|
+
<span className="dci-cellLabel">{t("menu.reasoning")}</span>
|
|
159
|
+
<span className="dci-cellValue">{currentEffort}</span>
|
|
160
|
+
<span className="dci-cellChevron" style={{ transform: 'rotate(-90deg)' }} aria-hidden="true">
|
|
161
|
+
<IconChevronOfficial size={14} />
|
|
162
|
+
</span>
|
|
163
|
+
</button>
|
|
164
|
+
</>
|
|
165
|
+
) : null}
|
|
166
|
+
|
|
167
|
+
{view === 'models' ? (
|
|
168
|
+
<>
|
|
169
|
+
<div className="dci-menuLabel">{t("menu.model")}</div>
|
|
170
|
+
{groups.map((group) => (
|
|
171
|
+
<div key={group.id}>
|
|
172
|
+
<div className="dci-menuLabel">{group.name}</div>
|
|
173
|
+
{group.models.map((model) => {
|
|
174
|
+
const active = current !== null && current.provider === group.id && current.model === model.id;
|
|
175
|
+
return (
|
|
176
|
+
<button
|
|
177
|
+
key={model.id}
|
|
178
|
+
type="button"
|
|
179
|
+
className="dci-item"
|
|
180
|
+
disabled={busy}
|
|
181
|
+
title={model.description}
|
|
182
|
+
onClick={() => void pick(group, model, model.reasoning?.defaultEffort)}
|
|
183
|
+
>
|
|
184
|
+
<span className="dci-itemLabel">{model.name}</span>
|
|
185
|
+
{active ? (
|
|
186
|
+
<span className="dci-check" aria-hidden="true">
|
|
187
|
+
<MenuCheck size={16} />
|
|
188
|
+
</span>
|
|
189
|
+
) : null}
|
|
190
|
+
</button>
|
|
191
|
+
);
|
|
192
|
+
})}
|
|
193
|
+
</div>
|
|
194
|
+
))}
|
|
195
|
+
{groups.length === 0 ? <div className="dci-empty">{snap?.status === 'loading' ? t('menu.refreshingModels') : t('menu.noModels')}</div> : null}
|
|
196
|
+
</>
|
|
197
|
+
) : null}
|
|
198
|
+
|
|
199
|
+
{view === 'efforts' ? (
|
|
200
|
+
<>
|
|
201
|
+
<div className="dci-menuLabel">{t("menu.reasoning")}</div>
|
|
202
|
+
{(() => {
|
|
203
|
+
const found = findCurrent(current, groups);
|
|
204
|
+
const efforts = found?.model.reasoning?.efforts ?? [];
|
|
205
|
+
if (efforts.length === 0) return <div className="dci-empty">{t("menu.reasoningNone")}</div>;
|
|
206
|
+
return efforts.map((eff) => {
|
|
207
|
+
const active = current?.reasoningEffort === eff.id;
|
|
208
|
+
return (
|
|
209
|
+
<button
|
|
210
|
+
key={eff.id}
|
|
211
|
+
type="button"
|
|
212
|
+
className="dci-item"
|
|
213
|
+
disabled={busy}
|
|
214
|
+
title={eff.description}
|
|
215
|
+
onClick={() => found && void pick(found.group, found.model, eff.id)}
|
|
216
|
+
>
|
|
217
|
+
<span className="dci-itemLabel">{eff.name}</span>
|
|
218
|
+
{active ? (
|
|
219
|
+
<span className="dci-check" aria-hidden="true">
|
|
220
|
+
<MenuCheck size={16} />
|
|
221
|
+
</span>
|
|
222
|
+
) : null}
|
|
223
|
+
</button>
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
})()}
|
|
227
|
+
</>
|
|
228
|
+
) : null}
|
|
229
|
+
</MenuSurface>
|
|
230
|
+
</div>
|
|
231
|
+
) : null}
|
|
232
|
+
</div>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 权限菜单:官方 PermissionSelect 复刻。事实源是宿主投影
|
|
3
|
+
* useProjection("permissions")({ currentValue, options }),触发器
|
|
4
|
+
* (模式 glyph + 标签 + chevron,open 时旋转)、上方 Menu(三项 +
|
|
5
|
+
* glyph + 选中勾)与「完全权限」的 RiskConfirmation 模态确认都按官方
|
|
6
|
+
* 流程:选完全权限先关菜单、弹确认框,勾选后才提交 /permission 命令。
|
|
7
|
+
* 投影缺席时整个按钮不渲染(同官方 value === undefined 返回 null)。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useEffect, useRef, useState } from 'react';
|
|
11
|
+
import type { InputActions, UseProjection } from '../host-types';
|
|
12
|
+
import { t } from '../i18n';
|
|
13
|
+
import {
|
|
14
|
+
fullAccessConfirm,
|
|
15
|
+
PERMISSION_PRESET_IDS,
|
|
16
|
+
applyPermission,
|
|
17
|
+
permissionLabel,
|
|
18
|
+
type PermissionProjection,
|
|
19
|
+
} from '../permission';
|
|
20
|
+
import {
|
|
21
|
+
IconChevronOfficial,
|
|
22
|
+
IconShieldBarsOfficial,
|
|
23
|
+
IconShieldCheckOfficial,
|
|
24
|
+
IconShieldWriteOfficial,
|
|
25
|
+
IconWarningOfficial,
|
|
26
|
+
IconX,
|
|
27
|
+
} from '../icons';
|
|
28
|
+
import { MenuCheck, MenuSurface } from './MenuSurface';
|
|
29
|
+
|
|
30
|
+
export interface PermissionMenuProps {
|
|
31
|
+
sessionId: string | undefined;
|
|
32
|
+
actions: InputActions | undefined;
|
|
33
|
+
currentDraft: string;
|
|
34
|
+
useProjection: UseProjection | undefined;
|
|
35
|
+
/** owner disabled(blocked/removed 场景锁住触发器)。 */
|
|
36
|
+
disabled?: boolean;
|
|
37
|
+
onNotify?(level: 'info' | 'error', text: string): void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const FULL_ACCESS = 'danger-full-access';
|
|
41
|
+
|
|
42
|
+
/** 官方 permissionGlyphs:每个预设一枚专属盾 glyph;未知值无 glyph。 */
|
|
43
|
+
function glyph(value: string): JSX.Element | undefined {
|
|
44
|
+
if (value === 'read-only') return <IconShieldCheckOfficial size={16} />;
|
|
45
|
+
if (value === 'workspace-write') return <IconShieldWriteOfficial size={16} />;
|
|
46
|
+
if (value === FULL_ACCESS) return <IconShieldBarsOfficial size={16} />;
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function PermissionMenu(props: PermissionMenuProps): JSX.Element | null {
|
|
51
|
+
const { sessionId, actions, currentDraft, useProjection, disabled, onNotify } = props;
|
|
52
|
+
// 官方同款事实源:宿主投影(hook 顺序随 useProjection prop 存在性固定)。
|
|
53
|
+
const permissions = useProjection
|
|
54
|
+
? (useProjection('permissions') as PermissionProjection | undefined)
|
|
55
|
+
: undefined;
|
|
56
|
+
const [pick, setPick] = useState<string | null>(null);
|
|
57
|
+
const [open, setOpen] = useState(false);
|
|
58
|
+
const [confirmation, setConfirmation] = useState<string | null>(null);
|
|
59
|
+
const [acknowledged, setAcknowledged] = useState(false);
|
|
60
|
+
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
|
61
|
+
const [anchor, setAnchor] = useState<{ left: number; top: number; right: number; bottom: number } | null>(null);
|
|
62
|
+
|
|
63
|
+
// locked/无值时收起(官方 useEffect 同语义)。
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (disabled) return;
|
|
66
|
+
if (permissions !== undefined) return;
|
|
67
|
+
setOpen(false);
|
|
68
|
+
setAcknowledged(false);
|
|
69
|
+
setConfirmation(null);
|
|
70
|
+
}, [disabled, permissions]);
|
|
71
|
+
|
|
72
|
+
// alpha 权限投影只含 currentValue(选项目录移至宿主 permission catalog):
|
|
73
|
+
// 触发器以 currentValue 显示;选项用内建三预设,当前值不在其中时补入兜底。
|
|
74
|
+
if (!permissions || typeof permissions.currentValue !== 'string') return null;
|
|
75
|
+
|
|
76
|
+
const busy = pick !== null || confirmation !== null;
|
|
77
|
+
const currentValue = pick ?? permissions.currentValue;
|
|
78
|
+
const currentLabel = permissionLabel(currentValue, currentValue);
|
|
79
|
+
|
|
80
|
+
// 官方顺序:内建三预设按目录序,当前值不在其中时附加兜底。
|
|
81
|
+
const items = [...PERMISSION_PRESET_IDS, ...(PERMISSION_PRESET_IDS.includes(currentValue) ? [] : [currentValue])].map((id) => ({
|
|
82
|
+
id,
|
|
83
|
+
label: permissionLabel(id, id),
|
|
84
|
+
icon: glyph(id),
|
|
85
|
+
}));
|
|
86
|
+
|
|
87
|
+
const close = (): void => setOpen(false);
|
|
88
|
+
|
|
89
|
+
const submit = (id: string): void => {
|
|
90
|
+
if (!sessionId) return;
|
|
91
|
+
setPick(id);
|
|
92
|
+
applyPermission(sessionId, id, actions, currentDraft)
|
|
93
|
+
.then((outcome) => {
|
|
94
|
+
if (!outcome.ok) onNotify?.('error', outcome.message ?? t('perm.switchFailed'));
|
|
95
|
+
})
|
|
96
|
+
.finally(() => setPick(null));
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const choose = (id: string): void => {
|
|
100
|
+
close();
|
|
101
|
+
if (id === permissions.currentValue) return;
|
|
102
|
+
if (id === FULL_ACCESS) {
|
|
103
|
+
setAcknowledged(false);
|
|
104
|
+
setConfirmation(id);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
submit(id);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const closeConfirmation = (): void => {
|
|
111
|
+
setAcknowledged(false);
|
|
112
|
+
setConfirmation(null);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const confirmFullAccess = (): void => {
|
|
116
|
+
if (disabled || !acknowledged || confirmation === null) return;
|
|
117
|
+
const id = confirmation;
|
|
118
|
+
closeConfirmation();
|
|
119
|
+
submit(id);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<>
|
|
124
|
+
<button
|
|
125
|
+
ref={triggerRef}
|
|
126
|
+
type="button"
|
|
127
|
+
className="dci-trigger"
|
|
128
|
+
aria-label={t("perm.trigger", { name: currentLabel })}
|
|
129
|
+
title={undefined}
|
|
130
|
+
disabled={disabled || busy}
|
|
131
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
132
|
+
onClick={() => {
|
|
133
|
+
if (!open) setAnchor(triggerRef.current?.getBoundingClientRect() ?? null);
|
|
134
|
+
setOpen(!open);
|
|
135
|
+
}}
|
|
136
|
+
>
|
|
137
|
+
{glyph(currentValue) !== undefined ? (
|
|
138
|
+
<span className="dci-triggerIcon" aria-hidden="true">
|
|
139
|
+
{glyph(currentValue)}
|
|
140
|
+
</span>
|
|
141
|
+
) : null}
|
|
142
|
+
<span className="dci-triggerLabel">{currentLabel}</span>
|
|
143
|
+
<span className="dci-chev" data-open={open}>
|
|
144
|
+
<IconChevronOfficial size={14} />
|
|
145
|
+
</span>
|
|
146
|
+
</button>
|
|
147
|
+
|
|
148
|
+
{open && anchor ? (
|
|
149
|
+
<MenuSurface anchor={anchor} onClose={close}>
|
|
150
|
+
{items.map((item) => {
|
|
151
|
+
const active = item.id === currentValue;
|
|
152
|
+
return (
|
|
153
|
+
<button
|
|
154
|
+
key={item.id}
|
|
155
|
+
type="button"
|
|
156
|
+
className="dci-item"
|
|
157
|
+
disabled={busy}
|
|
158
|
+
onClick={() => choose(item.id)}
|
|
159
|
+
>
|
|
160
|
+
{item.icon !== undefined ? (
|
|
161
|
+
<span className="dci-itemIcon" aria-hidden="true">
|
|
162
|
+
{item.icon}
|
|
163
|
+
</span>
|
|
164
|
+
) : null}
|
|
165
|
+
<span className="dci-itemLabel">{item.label}</span>
|
|
166
|
+
{active ? (
|
|
167
|
+
<span className="dci-check" aria-hidden="true">
|
|
168
|
+
<MenuCheck size={16} />
|
|
169
|
+
</span>
|
|
170
|
+
) : null}
|
|
171
|
+
</button>
|
|
172
|
+
);
|
|
173
|
+
})}
|
|
174
|
+
</MenuSurface>
|
|
175
|
+
) : null}
|
|
176
|
+
|
|
177
|
+
<RiskConfirmation
|
|
178
|
+
open={confirmation !== null}
|
|
179
|
+
acknowledged={acknowledged}
|
|
180
|
+
disabled={disabled === true}
|
|
181
|
+
onAcknowledgedChange={setAcknowledged}
|
|
182
|
+
onCancel={closeConfirmation}
|
|
183
|
+
onConfirm={confirmFullAccess}
|
|
184
|
+
/>
|
|
185
|
+
</>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** 官方 RiskConfirmation(Modal + 警示行 + 勾选 + 取消/启用)。 */
|
|
190
|
+
function RiskConfirmation(props: {
|
|
191
|
+
open: boolean;
|
|
192
|
+
acknowledged: boolean;
|
|
193
|
+
disabled: boolean;
|
|
194
|
+
onAcknowledgedChange(checked: boolean): void;
|
|
195
|
+
onCancel(): void;
|
|
196
|
+
onConfirm(): void;
|
|
197
|
+
}): JSX.Element | null {
|
|
198
|
+
const { open, acknowledged, disabled, onAcknowledgedChange, onCancel, onConfirm } = props;
|
|
199
|
+
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
if (!open) return;
|
|
202
|
+
const onKeyDown = (event: KeyboardEvent): void => {
|
|
203
|
+
if (event.key === 'Escape') onCancel();
|
|
204
|
+
};
|
|
205
|
+
document.addEventListener('keydown', onKeyDown);
|
|
206
|
+
return () => document.removeEventListener('keydown', onKeyDown);
|
|
207
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
208
|
+
}, [open]);
|
|
209
|
+
|
|
210
|
+
if (!open) return null;
|
|
211
|
+
return (
|
|
212
|
+
<div className="dci-modal-root" role="presentation">
|
|
213
|
+
<div className="dci-modal-mask" aria-hidden="true" onClick={onCancel} />
|
|
214
|
+
<div className="dci-modal-dialog dci-confirm" role="dialog" aria-modal="true" aria-label={fullAccessConfirm().title}>
|
|
215
|
+
<div className="dci-modal-content">
|
|
216
|
+
<div className="dci-modal-header">
|
|
217
|
+
<h2 className="dci-modal-title">{fullAccessConfirm().title}</h2>
|
|
218
|
+
<button type="button" className="dci-modal-close" aria-label={t("perm.close")} onClick={onCancel}>
|
|
219
|
+
<IconX size={14} />
|
|
220
|
+
</button>
|
|
221
|
+
</div>
|
|
222
|
+
<div className="dci-modal-body">
|
|
223
|
+
<div className="dci-confirm-warning">
|
|
224
|
+
<span className="dci-confirm-warningIcon" aria-hidden="true">
|
|
225
|
+
<IconWarningOfficial size={18} />
|
|
226
|
+
</span>
|
|
227
|
+
<p>{fullAccessConfirm().description}</p>
|
|
228
|
+
</div>
|
|
229
|
+
<label className="dci-confirm-ack">
|
|
230
|
+
<input
|
|
231
|
+
type="checkbox"
|
|
232
|
+
checked={acknowledged}
|
|
233
|
+
disabled={disabled}
|
|
234
|
+
autoFocus
|
|
235
|
+
onChange={(e) => onAcknowledgedChange(e.currentTarget.checked)}
|
|
236
|
+
/>
|
|
237
|
+
<span>{fullAccessConfirm().acknowledgeLabel}</span>
|
|
238
|
+
</label>
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
<div className="dci-modal-footer">
|
|
242
|
+
<button type="button" className="dci-btn dci-btn-outline dci-btn-cancel" onClick={onCancel}>
|
|
243
|
+
{fullAccessConfirm().cancelLabel}
|
|
244
|
+
</button>
|
|
245
|
+
<button
|
|
246
|
+
type="button"
|
|
247
|
+
className="dci-btn dci-btn-primary dci-btn-confirm"
|
|
248
|
+
disabled={disabled || !acknowledged}
|
|
249
|
+
onClick={onConfirm}
|
|
250
|
+
>
|
|
251
|
+
{fullAccessConfirm().enableLabel}
|
|
252
|
+
</button>
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
);
|
|
257
|
+
}
|