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
package/lib/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-codinput",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DSH WebUI 插件:把聊天输入框变成代码编辑器一样的输入风格(CodeMirror 6 接管 conversation.composer.bar)。",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Witherwithwinter",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"dsh",
|
|
9
|
+
"deepseek",
|
|
10
|
+
"deepseek-harness",
|
|
11
|
+
"codemirror",
|
|
12
|
+
"composer",
|
|
13
|
+
"input",
|
|
14
|
+
"plugin",
|
|
15
|
+
"webui"
|
|
16
|
+
],
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/Witherwithwinter/DSH-Codinput.git"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/Witherwithwinter/DSH-Codinput#readme",
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/Witherwithwinter/DSH-Codinput/issues"
|
|
24
|
+
},
|
|
25
|
+
"main": "./lib/index.js",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": "./lib/index.js",
|
|
28
|
+
"./client": "./lib/client.js",
|
|
29
|
+
"./package.json": "./package.json"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"lib/",
|
|
33
|
+
"src/",
|
|
34
|
+
"assets/",
|
|
35
|
+
"NOTICE",
|
|
36
|
+
"cordis.patch.yml",
|
|
37
|
+
"CHANGELOG.md",
|
|
38
|
+
"TESTING.md"
|
|
39
|
+
],
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "node scripts/build.mjs",
|
|
42
|
+
"build:dev": "node scripts/build.mjs --dev",
|
|
43
|
+
"typecheck": "tsc --noEmit",
|
|
44
|
+
"prepublishOnly": "npm run build && npm run typecheck"
|
|
45
|
+
},
|
|
46
|
+
"dsh": {
|
|
47
|
+
"bundle": {
|
|
48
|
+
"patch": "./cordis.patch.yml"
|
|
49
|
+
},
|
|
50
|
+
"client": {
|
|
51
|
+
"platform": "web",
|
|
52
|
+
"inject": [
|
|
53
|
+
"slots",
|
|
54
|
+
"conversation"
|
|
55
|
+
],
|
|
56
|
+
"external": [
|
|
57
|
+
"react",
|
|
58
|
+
"react-dom"
|
|
59
|
+
]
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"dependencies": {
|
|
63
|
+
"@codemirror/commands": "^6.8.0",
|
|
64
|
+
"@codemirror/language": "^6.10.8",
|
|
65
|
+
"@codemirror/state": "^6.5.2",
|
|
66
|
+
"@codemirror/view": "^6.36.0",
|
|
67
|
+
"dompurify": "^3.2.4",
|
|
68
|
+
"marked": "^15.0.6"
|
|
69
|
+
},
|
|
70
|
+
"devDependencies": {
|
|
71
|
+
"@types/react": "^18.3.18",
|
|
72
|
+
"@types/react-dom": "^18.3.7",
|
|
73
|
+
"esbuild": "^0.25.0",
|
|
74
|
+
"typescript": "^5.7.3"
|
|
75
|
+
},
|
|
76
|
+
"allowScripts": {
|
|
77
|
+
"esbuild@0.25.12": true
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 附件入草稿的共用链路:工具行的文件选择、卡片内粘贴、拖文件进卡片三条路径
|
|
3
|
+
* 都走这里,语义与失败处理完全一致(官方公开面:createDrafts + addAttachments,
|
|
4
|
+
* 失败时释放草稿附件,不留悬挂)。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ConversationFace, InputActions } from './host-types';
|
|
8
|
+
import { stash } from './services';
|
|
9
|
+
import { t } from './i18n';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 把文件加入草稿附件。
|
|
13
|
+
* @returns 是否成功入草稿(false = 没有会话/通道、空文件、或宿主拒绝)。
|
|
14
|
+
*/
|
|
15
|
+
export function addFilesToDraft(
|
|
16
|
+
sessionId: string | undefined,
|
|
17
|
+
actions: InputActions | undefined,
|
|
18
|
+
files: FileList | readonly File[] | null | undefined,
|
|
19
|
+
onNotify?: (level: 'info' | 'error', text: string) => void,
|
|
20
|
+
): boolean {
|
|
21
|
+
const list = files ? Array.from(files) : [];
|
|
22
|
+
if (!sessionId || !actions || list.length === 0) return false;
|
|
23
|
+
const conversation = stash.conversation as ConversationFace | undefined;
|
|
24
|
+
if (!conversation) {
|
|
25
|
+
onNotify?.('error', t('attach.channelUnavailable'));
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const drafts = conversation.createDrafts(sessionId, list);
|
|
30
|
+
if (drafts.length === 0) return false;
|
|
31
|
+
const added = actions.addAttachments(drafts.map((d) => d.id));
|
|
32
|
+
if (!added) conversation.releaseDraftAttachments(drafts);
|
|
33
|
+
return added;
|
|
34
|
+
} catch (error) {
|
|
35
|
+
onNotify?.('error', error instanceof Error ? error.message : String(error));
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 拖拽中是否带着文件(dragover 阶段 dataTransfer.files 还是空的,只有 types 可读)。 */
|
|
41
|
+
export function dragHasFiles(dataTransfer: DataTransfer | null): boolean {
|
|
42
|
+
return dataTransfer !== null && Array.from(dataTransfer.types).includes('Files');
|
|
43
|
+
}
|
package/src/client/cm.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/** CodeMirror 6 集成:行号(可关)、当前行与行号高亮(常开)、软换行、撤销历史、Tab 缩进。 */
|
|
2
|
+
|
|
3
|
+
import { history, historyKeymap, defaultKeymap, indentWithTab } from '@codemirror/commands';
|
|
4
|
+
import {
|
|
5
|
+
highlightActiveLine,
|
|
6
|
+
highlightActiveLineGutter,
|
|
7
|
+
lineNumbers,
|
|
8
|
+
drawSelection,
|
|
9
|
+
dropCursor,
|
|
10
|
+
placeholder as cmPlaceholder,
|
|
11
|
+
EditorView,
|
|
12
|
+
keymap,
|
|
13
|
+
} from '@codemirror/view';
|
|
14
|
+
import { Annotation, Compartment, EditorState, type Extension } from '@codemirror/state';
|
|
15
|
+
import { loadPrefs, subscribePrefs } from './prefs';
|
|
16
|
+
|
|
17
|
+
const lineNumbersCompartment = new Compartment();
|
|
18
|
+
const themeCompartment = new Compartment();
|
|
19
|
+
const placeholderCompartment = new Compartment();
|
|
20
|
+
const editableCompartment = new Compartment();
|
|
21
|
+
|
|
22
|
+
export interface EditorOptions {
|
|
23
|
+
lineNumbers: boolean;
|
|
24
|
+
fontFamily: string;
|
|
25
|
+
placeholder: string;
|
|
26
|
+
editable: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 外部灌入(官方草稿回写)标注:本来源的变化不再回写 setDraft。 */
|
|
30
|
+
export const externalAnnotation = Annotation.define<boolean>();
|
|
31
|
+
|
|
32
|
+
export interface EditorHooks {
|
|
33
|
+
/** DOM 级 keydown(先于默认键位表):返回 true 表示已消费。 */
|
|
34
|
+
onKeydown(view: EditorView, event: KeyboardEvent): boolean;
|
|
35
|
+
/** 本地文档变化(用户输入所致):唯一写入口在此回调里交给官方 setDraft。 */
|
|
36
|
+
onLocalChange(text: string): void;
|
|
37
|
+
/** 光标/文档变化后的上报(触发管线 track + 行列指示)。 */
|
|
38
|
+
onCaret(text: string, caret: number): void;
|
|
39
|
+
onCompositionChange(composing: boolean): void;
|
|
40
|
+
onFocusChange(focused: boolean): void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 当前挂载编辑器的逻辑回调。扩展里**不闭包捕获** hooks,而是每次调用现取
|
|
45
|
+
* 这个槽——因为跨形态切换会复用(见 takeEditorSnapshot)上一次的 EditorState,
|
|
46
|
+
* 而它的扩展是那时创建的:若闭包捕获旧实例,恢复后就还在调用早已卸载的
|
|
47
|
+
* 组件的 props(读旧 actions/旧 keyboard snapshot)。同一时刻只有一个编辑面
|
|
48
|
+
* (三态单实例),模块级单槽足够且不会串台。
|
|
49
|
+
*/
|
|
50
|
+
let activeHooks: EditorHooks | null = null;
|
|
51
|
+
|
|
52
|
+
/** 挂载时绑定(EditorPane 的 effect 负责)。 */
|
|
53
|
+
export function setActiveEditorHooks(hooks: EditorHooks | null): void {
|
|
54
|
+
activeHooks = hooks;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 卸载时清空——但只清自己装的那一份。侧栏承载的切换不是原子的:侧栏 body
|
|
59
|
+
* 常常先挂载(新实例装上 hooks),主输入面在下一拍才卸载(旧实例清空),无
|
|
60
|
+
* 条件清空会把新实例的 hooks 摘掉,于是侧栏里打字不落草稿、行列指示不动、
|
|
61
|
+
* 发送手势失效。按实例身份判断即可。
|
|
62
|
+
*/
|
|
63
|
+
export function clearActiveEditorHooks(hooks: EditorHooks): void {
|
|
64
|
+
if (activeHooks === hooks) activeHooks = null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createEditorExtensions(options: EditorOptions): Extension[] {
|
|
68
|
+
return [
|
|
69
|
+
EditorView.domEventHandlers({
|
|
70
|
+
keydown: (event, view) => activeHooks?.onKeydown(view, event) ?? false,
|
|
71
|
+
compositionstart: () => {
|
|
72
|
+
activeHooks?.onCompositionChange(true);
|
|
73
|
+
return false;
|
|
74
|
+
},
|
|
75
|
+
compositionend: () => {
|
|
76
|
+
// compositionend 落在 DOM 变更之后;延一帧避免选词回车误入 send 分支。
|
|
77
|
+
setTimeout(() => activeHooks?.onCompositionChange(false), 0);
|
|
78
|
+
return false;
|
|
79
|
+
},
|
|
80
|
+
focus: () => {
|
|
81
|
+
activeHooks?.onFocusChange(true);
|
|
82
|
+
return false;
|
|
83
|
+
},
|
|
84
|
+
blur: () => {
|
|
85
|
+
activeHooks?.onFocusChange(false);
|
|
86
|
+
return false;
|
|
87
|
+
},
|
|
88
|
+
}),
|
|
89
|
+
history(),
|
|
90
|
+
drawSelection(),
|
|
91
|
+
dropCursor(),
|
|
92
|
+
EditorView.lineWrapping,
|
|
93
|
+
highlightActiveLine(),
|
|
94
|
+
highlightActiveLineGutter(),
|
|
95
|
+
lineNumbersCompartment.of(options.lineNumbers ? lineNumbers() : []),
|
|
96
|
+
themeCompartment.of(baseTheme(options.fontFamily)),
|
|
97
|
+
placeholderCompartment.of(cmPlaceholder(options.placeholder)),
|
|
98
|
+
editableCompartment.of(EditorView.editable.of(options.editable)),
|
|
99
|
+
keymap.of([...historyKeymap, ...defaultKeymap, indentWithTab]),
|
|
100
|
+
EditorView.updateListener.of((update) => {
|
|
101
|
+
if (update.docChanged && !update.transactions.some((tr) => tr.annotation(externalAnnotation) === true)) {
|
|
102
|
+
activeHooks?.onLocalChange(update.state.doc.toString());
|
|
103
|
+
}
|
|
104
|
+
if (update.docChanged || update.selectionSet) {
|
|
105
|
+
activeHooks?.onCaret(update.state.doc.toString(), update.state.selection.main.head);
|
|
106
|
+
}
|
|
107
|
+
}),
|
|
108
|
+
];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function applyEditorOptions(view: EditorView, options: EditorOptions): void {
|
|
112
|
+
view.dispatch({
|
|
113
|
+
effects: [
|
|
114
|
+
lineNumbersCompartment.reconfigure(options.lineNumbers ? lineNumbers() : []),
|
|
115
|
+
themeCompartment.reconfigure(baseTheme(options.fontFamily)),
|
|
116
|
+
placeholderCompartment.reconfigure(cmPlaceholder(options.placeholder)),
|
|
117
|
+
editableCompartment.reconfigure(EditorView.editable.of(options.editable)),
|
|
118
|
+
],
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** busy 阶段软锁 / owner disabled:切换编辑开关。 */
|
|
123
|
+
export function setEditorEditable(view: EditorView, editable: boolean): void {
|
|
124
|
+
view.dispatch({
|
|
125
|
+
effects: editableCompartment.reconfigure(EditorView.editable.of(editable)),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 偏好(行号/字体)热更新订阅:settings 与编辑器共用 localStorage 单源。 */
|
|
130
|
+
export function subscribeEditorPrefs(view: EditorView, placeholder: string, editable: boolean): () => void {
|
|
131
|
+
return subscribePrefs(() => {
|
|
132
|
+
const prefs = loadPrefs();
|
|
133
|
+
applyEditorOptions(view, {
|
|
134
|
+
lineNumbers: prefs.lineNumbers,
|
|
135
|
+
fontFamily: prefs.fontFamily,
|
|
136
|
+
placeholder,
|
|
137
|
+
editable,
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function baseTheme(fontFamily: string): Extension {
|
|
143
|
+
return EditorView.theme({
|
|
144
|
+
'&': { fontSize: '13px', height: '100%' },
|
|
145
|
+
'&.cm-focused': { outline: 'none' },
|
|
146
|
+
'.cm-scroller': {
|
|
147
|
+
fontFamily,
|
|
148
|
+
lineHeight: '1.6',
|
|
149
|
+
overflow: 'auto',
|
|
150
|
+
},
|
|
151
|
+
'.cm-content': { caretColor: 'var(--dsw-alias-state-business-primary, currentColor)', paddingBottom: '8px' },
|
|
152
|
+
'.cm-line': { padding: '0 8px' },
|
|
153
|
+
// CodeMirror 自绘光标(drawSelection)默认黑色,深色主题下不可见——
|
|
154
|
+
// 显式对齐官方输入框的 caret 颜色(品牌强调色)。
|
|
155
|
+
'.cm-cursor, .cm-dropCursor': {
|
|
156
|
+
borderLeftColor: 'var(--dsw-alias-state-business-primary, currentColor)',
|
|
157
|
+
},
|
|
158
|
+
'.cm-gutters': {
|
|
159
|
+
backgroundColor: 'transparent',
|
|
160
|
+
border: 'none',
|
|
161
|
+
color: 'color-mix(in srgb, currentColor 32%, transparent)',
|
|
162
|
+
},
|
|
163
|
+
'.cm-activeLineGutter': { backgroundColor: 'transparent', color: 'currentColor', fontWeight: 600 },
|
|
164
|
+
'.cm-activeLine': { backgroundColor: 'color-mix(in srgb, currentColor 6%, transparent)' },
|
|
165
|
+
'.cm-lineNumbers .cm-gutterElement': { minWidth: '2.2em', padding: '0 6px 0 10px' },
|
|
166
|
+
'.cm-selectionBackground': { backgroundColor: 'color-mix(in srgb, currentColor 15%, transparent) !important' },
|
|
167
|
+
'.cm-placeholder': { color: 'color-mix(in srgb, currentColor 38%, transparent)' },
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function createEditorState(extensions: Extension[], doc: string): EditorState {
|
|
172
|
+
return EditorState.create({ doc, extensions });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---------- 编辑器状态暂存(跨形态 / 跨会话保留撤销历史) ----------
|
|
176
|
+
//
|
|
177
|
+
// 三态切换时协调表面会重挂载(inline ↔ portal ↔ 侧栏),EditorView 随之销毁。
|
|
178
|
+
// 若每次都新建 EditorState,用户敲了半天的内容一拖成悬浮就撤销不回去了。
|
|
179
|
+
// 故按会话暂存 state(撤销栈、选区、文档都在 state 里),下次挂载原样装回;
|
|
180
|
+
// 滚动位置单独记住(state 不含滚动)。同一时刻只有一个持有者:挂载即 take
|
|
181
|
+
// (移出),卸载再 put——避免两个 EditorView 共享同一 state(CM 不支持)。
|
|
182
|
+
|
|
183
|
+
export interface EditorSnapshot {
|
|
184
|
+
readonly state: EditorState;
|
|
185
|
+
readonly scrollTop: number;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const CACHE_MAX = 4;
|
|
189
|
+
const snapshots = new Map<string, EditorSnapshot>();
|
|
190
|
+
|
|
191
|
+
/** 取回并移出该键的快照(含撤销历史);没有则 undefined(新建)。 */
|
|
192
|
+
export function takeEditorSnapshot(key: string): EditorSnapshot | undefined {
|
|
193
|
+
const snapshot = snapshots.get(key);
|
|
194
|
+
if (snapshot) snapshots.delete(key);
|
|
195
|
+
return snapshot;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** 卸载时暂存:不清空内容,只把「离开时的编辑器」留给下次挂载。 */
|
|
199
|
+
export function putEditorSnapshot(key: string, snapshot: EditorSnapshot): void {
|
|
200
|
+
snapshots.delete(key);
|
|
201
|
+
snapshots.set(key, snapshot);
|
|
202
|
+
while (snapshots.size > CACHE_MAX) {
|
|
203
|
+
const oldest = snapshots.keys().next().value;
|
|
204
|
+
if (oldest === undefined) break;
|
|
205
|
+
snapshots.delete(oldest);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /codinput 命令:经官方命令通道注册的 action 型客户端贡献。
|
|
3
|
+
* 输入 /codinput 回车即切换接管(进入/退出),菜单描述随当前状态变化。
|
|
4
|
+
* 接管关闭(官方输入框)时输入同样生效——贡献注册是全局生命周期。
|
|
5
|
+
* 菜单拾取路径会把 "/codinput" 插进草稿而不提交,切换后需清空当前草稿。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ClientContext } from './host-types';
|
|
9
|
+
import { stash } from './services';
|
|
10
|
+
import { loadPrefs, savePrefs } from './prefs';
|
|
11
|
+
import { t } from './i18n';
|
|
12
|
+
import { IconCodinputCommand16 } from './icons';
|
|
13
|
+
import { diagWindow, runGuarded } from './diag';
|
|
14
|
+
|
|
15
|
+
function clearDraftOf(sessionId: string | undefined): void {
|
|
16
|
+
if (!sessionId) return;
|
|
17
|
+
try {
|
|
18
|
+
const actx = stash.sessions?.scope(sessionId);
|
|
19
|
+
if (actx && stash.conversation) stash.conversation.input.for(actx).setDraft('');
|
|
20
|
+
} catch {
|
|
21
|
+
/* 会话作用域不可达时忽略(提交路径本就会清空草稿) */
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function registerCodinputCommand(ctx: ClientContext): void {
|
|
26
|
+
runGuarded('command.register', () => {
|
|
27
|
+
ctx.inject(['commandUi'], (scope: Record<string, unknown>) => {
|
|
28
|
+
const commandUi = scope['commandUi'] as {
|
|
29
|
+
register(contribution: Record<string, unknown>): () => void;
|
|
30
|
+
candidates?(session: unknown, req: { query: string }): Promise<unknown[]>;
|
|
31
|
+
} | undefined;
|
|
32
|
+
if (typeof commandUi?.register !== 'function') {
|
|
33
|
+
diagWindow().__dshCodinputShell['command'] = 'commandUi-unavailable';
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
// 指令栏字典序:官方 rankByName 在空查询时保持「宿主列表 + 贡献注册序」,
|
|
37
|
+
// 贡献的 /codinput 会被排在末尾。这里包装 candidates,仅在空查询(完整
|
|
38
|
+
// 指令栏)时按名称字典序排序;非空查询保留官方的相关性排序。
|
|
39
|
+
let restoreOrder: (() => void) | undefined;
|
|
40
|
+
if (typeof commandUi.candidates === 'function') {
|
|
41
|
+
const original = commandUi.candidates.bind(commandUi);
|
|
42
|
+
commandUi.candidates = (session: unknown, req: { query: string }) =>
|
|
43
|
+
Promise.resolve(original(session, req)).then((rows) => {
|
|
44
|
+
if (req.query !== '' || !Array.isArray(rows)) return rows;
|
|
45
|
+
return [...(rows as { name: string }[])].sort((a, b) =>
|
|
46
|
+
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
restoreOrder = () => {
|
|
50
|
+
commandUi.candidates = original;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
ctx.effect(() => restoreOrder, 'dsh-codinput: dictionary order patch');
|
|
54
|
+
ctx.effect(
|
|
55
|
+
() =>
|
|
56
|
+
commandUi.register({
|
|
57
|
+
name: 'codinput',
|
|
58
|
+
description: () =>
|
|
59
|
+
loadPrefs().enabled ? t('cmd.exit') : t('cmd.enter'),
|
|
60
|
+
// 官方命令菜单 glyph:icon 为 React 组件,菜单按 <icon size={16}/> 渲染。
|
|
61
|
+
icon: IconCodinputCommand16,
|
|
62
|
+
available: () => true,
|
|
63
|
+
ui: {
|
|
64
|
+
kind: 'action',
|
|
65
|
+
run: () => {
|
|
66
|
+
// 卸载会清掉诊断 sessionId,先捕获再延迟清草稿。
|
|
67
|
+
const sessionId = diagWindow().__dshCodinputSessionId ?? undefined;
|
|
68
|
+
savePrefs({ enabled: !loadPrefs().enabled });
|
|
69
|
+
setTimeout(() => clearDraftOf(sessionId), 80);
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
}),
|
|
73
|
+
'dsh-codinput: /codinput contribution',
|
|
74
|
+
);
|
|
75
|
+
diagWindow().__dshCodinputShell['command'] = 'registered';
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 草稿附件轨:复刻官方 `dsh-client-ui-attachment` 的展示(ComposerAttachments +
|
|
3
|
+
* AttachmentRail + FileCard)。
|
|
4
|
+
*
|
|
5
|
+
* - 图片附件 → 64×64 圆角缩略图(`object-fit:cover`,`previewUrl` 由宿主给出),
|
|
6
|
+
* 悬停(或触摸设备常显)出右上角圆形移除钮;
|
|
7
|
+
* - 其它文件 → 240×64 文件卡:类型 glyph(28px,官方 28×28 viewBox 的文件图形)
|
|
8
|
+
* + 文件名(14/500)+ 元信息(扩展名 + 体积;上传中显示「上传中…」、失败显示
|
|
9
|
+
* 「上传失败」并整卡可点重试、红色描边);
|
|
10
|
+
* - 上传中出 2px 进度条(有 loaded/total 时按比例,否则走官方同款往返动画);
|
|
11
|
+
* - 轨道横向溢出时两端出圆形翻页箭头(滚动条隐藏,滚轮横向平移)。
|
|
12
|
+
*
|
|
13
|
+
* 取值逐条对齐官方 CSS(FileCard.module.css / ComposerAttachments.module.css),
|
|
14
|
+
* 类名换成 dci- 前缀;官方 glyph 路径逐字取自 primitives(MIT,见 LICENSE 归属段)。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { useEffect, useRef, useState } from 'react';
|
|
18
|
+
import type { ConversationFace, DraftAttachmentId, InputState } from '../host-types';
|
|
19
|
+
import { stash } from '../services';
|
|
20
|
+
import { useSnapshot } from './useSnapshot';
|
|
21
|
+
import { t } from '../i18n';
|
|
22
|
+
|
|
23
|
+
const FILE_BODY =
|
|
24
|
+
'M8.48924 28H19.5108C21.6479 28 22.7165 28 23.5594 27.6509C24.6833 27.1853 25.5762 26.2924 26.0417 25.1685C26.3909 24.3256 26.3909 23.257 26.3909 21.1199V8.79443C26.3909 8.32877 26.3909 8.09593 26.3471 7.87507C26.2887 7.58058 26.173 7.30042 26.0067 7.05048C25.882 6.86303 25.7177 6.69799 25.3893 6.36792L20.0611 1.01354C19.7304 0.681235 19.5651 0.515081 19.3769 0.38885C19.126 0.220541 18.8443 0.103463 18.5481 0.0443412C18.3259 0 18.0915 0 17.6226 0H8.48924C6.35209 0 5.28351 0 4.4406 0.349145C3.31672 0.814671 2.4238 1.70759 1.95828 2.83147C1.60913 3.67438 1.60913 4.74296 1.60913 6.88011V21.1199C1.60913 23.257 1.60913 24.3256 1.95828 25.1685C2.4238 26.2924 3.31672 27.1853 4.4406 27.6509C5.28351 28 6.35209 28 8.48924 28Z';
|
|
25
|
+
const FILE_FOLD =
|
|
26
|
+
'M26.3909 7.37445L19.0525 0V3.77445C19.0525 4.89271 19.0525 5.45184 19.2352 5.89289C19.4788 6.48096 19.946 6.94818 20.5341 7.19176C20.9751 7.37445 21.5342 7.37445 22.6525 7.37445H26.3909Z';
|
|
27
|
+
|
|
28
|
+
/** 官方 FileTypeIcon 的通用文件 glyph(28×28 视图框,fill 自带轮廓)。 */
|
|
29
|
+
function FileGlyph({ size = 28 }: { size?: number }): JSX.Element {
|
|
30
|
+
return (
|
|
31
|
+
<svg width={size} height={size} viewBox="0 0 28 28" fill="none" aria-hidden="true">
|
|
32
|
+
<path d={FILE_BODY} fill="currentColor" />
|
|
33
|
+
<path d={FILE_FOLD} fill="var(--dsw-static-neutral-00, #fff)" fillOpacity="0.7" />
|
|
34
|
+
</svg>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 官方 fileExtension:取 basename 最后一个点之后的部分。 */
|
|
39
|
+
function fileExtension(name: string): string {
|
|
40
|
+
const base = name.split(/[\\/]/).pop() ?? name;
|
|
41
|
+
const dot = base.lastIndexOf('.');
|
|
42
|
+
return dot < 0 ? '' : base.slice(dot + 1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 官方 fileSizeText 逐值复刻。 */
|
|
46
|
+
function fileSizeText(bytes: number): string {
|
|
47
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
48
|
+
const kb = bytes / 1024;
|
|
49
|
+
if (kb < 1024) return `${kb < 10 ? kb.toFixed(1) : Math.round(kb)}KB`;
|
|
50
|
+
const mb = kb / 1024;
|
|
51
|
+
if (mb < 1024) return `${mb < 10 ? mb.toFixed(1) : Math.round(mb)}MB`;
|
|
52
|
+
const gb = mb / 1024;
|
|
53
|
+
return `${gb < 10 ? gb.toFixed(1) : Math.round(gb)}GB`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 上传状态(宿主 fileUploads 投影;缺席按 ready 处理)。 */
|
|
57
|
+
interface UploadLike {
|
|
58
|
+
status?: string;
|
|
59
|
+
loaded?: number;
|
|
60
|
+
total?: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface AttachmentRailProps {
|
|
64
|
+
state: InputState | undefined;
|
|
65
|
+
sessionId: string | undefined;
|
|
66
|
+
onRemove(id: DraftAttachmentId): void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function AttachmentRail(props: AttachmentRailProps): JSX.Element | null {
|
|
70
|
+
const { state, sessionId, onRemove } = props;
|
|
71
|
+
const conversation = stash.conversation as ConversationFace | undefined;
|
|
72
|
+
const uploads = useSnapshot(conversation?.fileUploads) as Record<string, UploadLike> | undefined;
|
|
73
|
+
const railRef = useRef<HTMLDivElement | null>(null);
|
|
74
|
+
const [overflow, setOverflow] = useState<{ left: boolean; right: boolean }>({ left: false, right: false });
|
|
75
|
+
|
|
76
|
+
const attachments =
|
|
77
|
+
state === undefined || typeof conversation?.resolveDraftAttachments !== 'function'
|
|
78
|
+
? []
|
|
79
|
+
: conversation.resolveDraftAttachments(state.attachmentIds) ?? [];
|
|
80
|
+
|
|
81
|
+
// 溢出翻页箭头:滚动几何 + 尺寸变化(ResizeObserver,面板变宽也算)重算。
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
const rail = railRef.current;
|
|
84
|
+
if (!rail) return;
|
|
85
|
+
const sync = (): void => {
|
|
86
|
+
const maxScroll = rail.scrollWidth - rail.clientWidth;
|
|
87
|
+
const next = { left: rail.scrollLeft > 1, right: maxScroll > 1 && rail.scrollLeft < maxScroll - 1 };
|
|
88
|
+
setOverflow((prev) => (prev.left === next.left && prev.right === next.right ? prev : next));
|
|
89
|
+
};
|
|
90
|
+
sync();
|
|
91
|
+
rail.addEventListener('scroll', sync, { passive: true });
|
|
92
|
+
const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(sync) : null;
|
|
93
|
+
observer?.observe(rail);
|
|
94
|
+
// 纵向滚轮横向平移(官方同名行为:独占消费,非 passive)。
|
|
95
|
+
const onWheel = (event: WheelEvent): void => {
|
|
96
|
+
if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;
|
|
97
|
+
event.preventDefault();
|
|
98
|
+
rail.scrollLeft += event.deltaY;
|
|
99
|
+
};
|
|
100
|
+
rail.addEventListener('wheel', onWheel, { passive: false });
|
|
101
|
+
return () => {
|
|
102
|
+
rail.removeEventListener('scroll', sync);
|
|
103
|
+
rail.removeEventListener('wheel', onWheel);
|
|
104
|
+
observer?.disconnect();
|
|
105
|
+
};
|
|
106
|
+
}, [attachments.length]);
|
|
107
|
+
|
|
108
|
+
if (attachments.length === 0) return null;
|
|
109
|
+
|
|
110
|
+
const page = (dir: -1 | 1): void => {
|
|
111
|
+
railRef.current?.scrollBy({ left: dir * Math.max(120, (railRef.current?.clientWidth ?? 240) * 0.8), behavior: 'smooth' });
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<div className="dci-attach-root">
|
|
116
|
+
<div className="dci-attach-rail" ref={railRef} role="group" aria-label={t('attach.group')}>
|
|
117
|
+
{attachments.map((att) => {
|
|
118
|
+
const upload = uploads?.[att.id];
|
|
119
|
+
const status = upload?.status ?? 'ready';
|
|
120
|
+
const isImage = att.kind === 'image' || att.file.type.startsWith('image/');
|
|
121
|
+
const percent =
|
|
122
|
+
status === 'uploading' && upload?.total ? Math.min(100, Math.round(((upload.loaded ?? 0) / upload.total) * 100)) : null;
|
|
123
|
+
if (isImage) {
|
|
124
|
+
return (
|
|
125
|
+
<div className="dci-attach-item" key={att.id}>
|
|
126
|
+
<button type="button" className="dci-attach-thumb" title={att.file.name} aria-label={att.file.name}>
|
|
127
|
+
{att.previewUrl ? <img src={att.previewUrl} alt={att.file.name} /> : <FileGlyph size={28} />}
|
|
128
|
+
</button>
|
|
129
|
+
<button
|
|
130
|
+
type="button"
|
|
131
|
+
className="dci-attach-remove"
|
|
132
|
+
aria-label={t('attach.remove', { name: att.file.name })}
|
|
133
|
+
onClick={() => onRemove(att.id)}
|
|
134
|
+
>
|
|
135
|
+
<RemoveGlyph />
|
|
136
|
+
</button>
|
|
137
|
+
{percent !== null ? (
|
|
138
|
+
<span className="dci-attach-progress">
|
|
139
|
+
<span className="dci-attach-progressBar" style={{ width: `${percent}%` }} />
|
|
140
|
+
</span>
|
|
141
|
+
) : null}
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
const failed = status === 'error';
|
|
146
|
+
const meta =
|
|
147
|
+
status === 'uploading'
|
|
148
|
+
? t('attach.uploading')
|
|
149
|
+
: failed
|
|
150
|
+
? t('attach.failed')
|
|
151
|
+
: [fileExtension(att.file.name).toUpperCase().slice(0, 8), fileSizeText(att.file.size)]
|
|
152
|
+
.filter((part) => part !== '')
|
|
153
|
+
.join(' ');
|
|
154
|
+
const body = (
|
|
155
|
+
<>
|
|
156
|
+
<span className="dci-attach-name">{att.file.name}</span>
|
|
157
|
+
<span className={failed ? 'dci-attach-meta dci-attach-metaFailed' : 'dci-attach-meta'}>{meta}</span>
|
|
158
|
+
</>
|
|
159
|
+
);
|
|
160
|
+
return (
|
|
161
|
+
<div className={failed ? 'dci-attach-card dci-attach-cardFailed' : 'dci-attach-card'} key={att.id} title={att.file.name}>
|
|
162
|
+
<span className="dci-attach-icon" aria-hidden="true">
|
|
163
|
+
{status === 'uploading' ? <span className="dci-attach-spinner" /> : <FileGlyph size={28} />}
|
|
164
|
+
</span>
|
|
165
|
+
{failed ? (
|
|
166
|
+
<button
|
|
167
|
+
type="button"
|
|
168
|
+
className="dci-attach-body dci-attach-retry"
|
|
169
|
+
aria-label={t('attach.retry')}
|
|
170
|
+
onClick={() => retryUpload(sessionId, att.id)}
|
|
171
|
+
>
|
|
172
|
+
{body}
|
|
173
|
+
</button>
|
|
174
|
+
) : (
|
|
175
|
+
<span className="dci-attach-body" aria-label={t('attach.pending', { name: att.file.name })}>
|
|
176
|
+
{body}
|
|
177
|
+
</span>
|
|
178
|
+
)}
|
|
179
|
+
<button
|
|
180
|
+
type="button"
|
|
181
|
+
className={failed ? 'dci-attach-removeInline dci-attach-cardFailedRemove' : 'dci-attach-removeInline'}
|
|
182
|
+
aria-label={t('attach.remove', { name: att.file.name })}
|
|
183
|
+
onClick={() => onRemove(att.id)}
|
|
184
|
+
>
|
|
185
|
+
<RemoveGlyph />
|
|
186
|
+
</button>
|
|
187
|
+
{percent !== null ? (
|
|
188
|
+
<span className="dci-attach-progress">
|
|
189
|
+
<span className="dci-attach-progressBar" style={{ width: `${percent}%` }} />
|
|
190
|
+
</span>
|
|
191
|
+
) : null}
|
|
192
|
+
</div>
|
|
193
|
+
);
|
|
194
|
+
})}
|
|
195
|
+
</div>
|
|
196
|
+
{overflow.left ? (
|
|
197
|
+
<button type="button" className="dci-attach-arrow dci-attach-arrowLeft" aria-label={t('attach.scrollLeft')} onClick={() => page(-1)}>
|
|
198
|
+
<ArrowGlyph dir="left" />
|
|
199
|
+
</button>
|
|
200
|
+
) : null}
|
|
201
|
+
{overflow.right ? (
|
|
202
|
+
<button type="button" className="dci-attach-arrow dci-attach-arrowRight" aria-label={t('attach.scrollRight')} onClick={() => page(1)}>
|
|
203
|
+
<ArrowGlyph dir="right" />
|
|
204
|
+
</button>
|
|
205
|
+
) : null}
|
|
206
|
+
</div>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function retryUpload(sessionId: string | undefined, id: DraftAttachmentId): void {
|
|
211
|
+
const conversation = stash.conversation as ConversationFace | undefined;
|
|
212
|
+
if (!sessionId || typeof conversation?.retryFileUpload !== 'function') return;
|
|
213
|
+
try {
|
|
214
|
+
conversation.retryFileUpload(sessionId, id);
|
|
215
|
+
} catch {
|
|
216
|
+
/* 通道缺席:静默(附件仍在草稿里) */
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** 移除钮的 ×(官方为 fill 路径,16 视框内 18px 圆钮)。 */
|
|
221
|
+
function RemoveGlyph(): JSX.Element {
|
|
222
|
+
return (
|
|
223
|
+
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
|
|
224
|
+
<path
|
|
225
|
+
d="M1.6 1.6L8.4 8.4M8.4 1.6L1.6 8.4"
|
|
226
|
+
stroke="currentColor"
|
|
227
|
+
strokeWidth="1.4"
|
|
228
|
+
strokeLinecap="round"
|
|
229
|
+
/>
|
|
230
|
+
</svg>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** 翻页箭头(细 chevron,随方向旋转)。 */
|
|
235
|
+
function ArrowGlyph({ dir }: { dir: 'left' | 'right' }): JSX.Element {
|
|
236
|
+
return (
|
|
237
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true" style={dir === 'left' ? { transform: 'scaleX(-1)' } : undefined}>
|
|
238
|
+
<path d="M5.5 3.5L9 7L5.5 10.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
239
|
+
</svg>
|
|
240
|
+
);
|
|
241
|
+
}
|