dsh-workbuddy-files 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/LICENSE +21 -0
- package/README.md +184 -0
- package/cordis.patch.yml +24 -0
- package/lib/client.js +984 -0
- package/lib/index.js +348 -0
- package/package.json +88 -0
- package/src/client/app.ts +130 -0
- package/src/client/at-source.ts +57 -0
- package/src/client/components/file-cards.tsx +47 -0
- package/src/client/components/overlay.tsx +36 -0
- package/src/client/components/pick-button.tsx +55 -0
- package/src/client/css.ts +32 -0
- package/src/client/definitions.ts +83 -0
- package/src/client/index.ts +15 -0
- package/src/client/lib/bus.ts +34 -0
- package/src/client/lib/drop.ts +235 -0
- package/src/client/lib/icons.ts +26 -0
- package/src/client/lib/insert.ts +107 -0
- package/src/client/lib/transfer.ts +113 -0
- package/src/client/types.ts +60 -0
- package/src/host/index.ts +253 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { DropBus } from '../lib/bus'
|
|
2
|
+
|
|
3
|
+
/** 组件只需 React 的这三个能力;由 app.ts 的 factory 通过 require('react') 注入 */
|
|
4
|
+
export interface ReactLike {
|
|
5
|
+
createElement(type: unknown, props?: Record<string, unknown> | null, ...children: unknown[]): unknown
|
|
6
|
+
useState<T>(initial: T | (() => T)): [T, (next: T | ((prev: T) => T)) => void]
|
|
7
|
+
useEffect(effect: () => void | (() => void), deps?: readonly unknown[]): void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 全屏拖拽遮罩 + toast(挂在 shell.overlay 列表槽)。
|
|
12
|
+
* 遮罩只在拖拽含非图片文件/文件夹时出现(纯图片拖放交给原生图片轨道);
|
|
13
|
+
* 层本身点击穿透,遮罩激活时开启 pointer-events 承接 drop。
|
|
14
|
+
*/
|
|
15
|
+
export function createOverlayComponent(React: ReactLike, bus: DropBus) {
|
|
16
|
+
return function WorkbuddyOverlay() {
|
|
17
|
+
const [state, setState] = React.useState(bus.get())
|
|
18
|
+
React.useEffect(() => bus.subscribe(setState), [])
|
|
19
|
+
|
|
20
|
+
return React.createElement('div', { className: 'wbd-overlay' },
|
|
21
|
+
state.active
|
|
22
|
+
? React.createElement('div', { className: 'wbd-shield' },
|
|
23
|
+
React.createElement('div', { className: 'wbd-shield-inner' },
|
|
24
|
+
React.createElement('div', { className: 'wbd-shield-icon' }, '📥'),
|
|
25
|
+
React.createElement('div', { className: 'wbd-shield-title' }, '松开以接收文件'),
|
|
26
|
+
React.createElement('div', { className: 'wbd-shield-sub' }, state.count + ' 项 · 将作为引用气泡插入输入框光标处'),
|
|
27
|
+
React.createElement('div', { className: 'wbd-shield-hint' }, '文件(含图片)将缓存至 ~/.dsh-drops 并引用绝对路径'),
|
|
28
|
+
),
|
|
29
|
+
)
|
|
30
|
+
: null,
|
|
31
|
+
state.toast
|
|
32
|
+
? React.createElement('div', { className: 'wbd-toast' + (state.toast.level === 'error' ? ' wbd-error' : '') }, state.toast.text)
|
|
33
|
+
: null,
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { DropBus } from '../lib/bus'
|
|
2
|
+
import type { DropHandlers } from '../lib/drop'
|
|
3
|
+
import type { TreeFile } from '../types'
|
|
4
|
+
import type { ReactLike } from './overlay'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 📎 引用按钮(挂在 conversation.input.left 列表槽):
|
|
8
|
+
* 统一走 <input type=file>(多选 / webkitdirectory)→ 立即插入气泡 →
|
|
9
|
+
* 后台缓存(所有浏览器行为一致)。
|
|
10
|
+
*/
|
|
11
|
+
export function createPickButtonComponent(React: ReactLike, bus: DropBus, handlers: Pick<DropHandlers, 'acceptTree'>) {
|
|
12
|
+
return function PickButton() {
|
|
13
|
+
const [open, setOpen] = React.useState(false)
|
|
14
|
+
const newBatch = () => 'drop-' + Date.now().toString(36)
|
|
15
|
+
|
|
16
|
+
const pickFolder = async () => {
|
|
17
|
+
setOpen(false)
|
|
18
|
+
const input = document.createElement('input')
|
|
19
|
+
input.type = 'file'
|
|
20
|
+
input.setAttribute('webkitdirectory', '')
|
|
21
|
+
input.onchange = () => {
|
|
22
|
+
const files: TreeFile[] = []
|
|
23
|
+
const tops: string[] = []
|
|
24
|
+
for (const f of Array.from(input.files ?? [])) {
|
|
25
|
+
files.push({ rel: f.webkitRelativePath || f.name, name: f.name, size: f.size, file: f })
|
|
26
|
+
const top = (f.webkitRelativePath || '').split('/')[0]
|
|
27
|
+
if (top !== '' && !tops.includes(top)) tops.push(top)
|
|
28
|
+
}
|
|
29
|
+
if (files.length > 0) void handlers.acceptTree(files, tops, newBatch())
|
|
30
|
+
}
|
|
31
|
+
input.click()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const pickFiles = async () => {
|
|
35
|
+
setOpen(false)
|
|
36
|
+
const input = document.createElement('input')
|
|
37
|
+
input.type = 'file'
|
|
38
|
+
input.multiple = true
|
|
39
|
+
input.onchange = () => {
|
|
40
|
+
const files: TreeFile[] = []
|
|
41
|
+
for (const f of Array.from(input.files ?? [])) files.push({ rel: f.name, name: f.name, size: f.size, file: f })
|
|
42
|
+
if (files.length > 0) void handlers.acceptTree(files, [], newBatch())
|
|
43
|
+
}
|
|
44
|
+
input.click()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return React.createElement('div', { className: 'wbd-pick' },
|
|
48
|
+
React.createElement('button', { type: 'button', className: 'wbd-pick-btn', title: '引用文件/文件夹(也可直接拖拽或粘贴)', onClick: () => setOpen(!open) }, '📎'),
|
|
49
|
+
open ? React.createElement('div', { className: 'wbd-pick-menu' },
|
|
50
|
+
React.createElement('button', { type: 'button', onClick: pickFiles }, '选择文件…'),
|
|
51
|
+
React.createElement('button', { type: 'button', onClick: pickFolder }, '选择文件夹…(保留目录树)'),
|
|
52
|
+
) : null,
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** 包内样式(styles.insert 注入,随插件 Run 生命周期清理) */
|
|
2
|
+
export const CSS = [
|
|
3
|
+
'.wbd-overlay{position:fixed;inset:0;pointer-events:none;z-index:2147483000}',
|
|
4
|
+
'.wbd-shield{position:fixed;inset:0;pointer-events:auto;display:flex;align-items:center;justify-content:center;background:color-mix(in srgb,var(--dsw-color-bg,#0e1116) 72%,transparent);backdrop-filter:blur(2px)}',
|
|
5
|
+
'.wbd-shield-inner{min-width:340px;max-width:520px;padding:28px 36px;text-align:center;border:2px dashed var(--dsw-alias-border-l3,#4c9aff);border-radius:14px;background:var(--dsw-color-bg-elevated,#161b24);box-shadow:0 12px 48px rgba(0,0,0,.45)}',
|
|
6
|
+
'.wbd-shield-icon{font-size:34px;line-height:1}',
|
|
7
|
+
'.wbd-shield-title{margin-top:10px;font-size:17px;font-weight:600;color:var(--dsw-alias-label-primary,#f2f4f8)}',
|
|
8
|
+
'.wbd-shield-sub{margin-top:6px;font-size:13px;color:var(--dsw-alias-label-secondary,#aab2c0)}',
|
|
9
|
+
'.wbd-shield-hint{margin-top:10px;font-size:12px;color:var(--dsw-alias-label-tertiary,#6b7280)}',
|
|
10
|
+
'.wbd-toast{position:fixed;right:24px;bottom:132px;max-width:360px;padding:9px 14px;border-radius:10px;background:var(--dsw-color-bg-elevated,#1c222e);border:1px solid var(--dsw-alias-border-l3,#3a4252);color:var(--dsw-alias-label-primary,#f2f4f8);font-size:13px;box-shadow:0 8px 28px rgba(0,0,0,.35);pointer-events:auto}',
|
|
11
|
+
'.wbd-toast.wbd-error{border-color:#b3453f}',
|
|
12
|
+
'.wbd-cards{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:14px 0 4px;font-size:13px}',
|
|
13
|
+
'.wbd-cards-label{color:var(--dsw-alias-label-tertiary,#6b7280);margin-right:4px}',
|
|
14
|
+
'.wbd-card{display:inline-flex;align-items:center;gap:7px;max-width:340px;padding:4px 10px 4px 8px;border:1px solid var(--dsw-alias-border-l2,#2c3342);border-radius:8px;background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.04));color:var(--dsw-alias-label-primary,#f2f4f8);font:inherit;font-size:13px;cursor:pointer;text-align:left}',
|
|
15
|
+
'.wbd-card:hover{border-color:var(--dsw-alias-border-l3,#4c9aff)}',
|
|
16
|
+
'.wbd-card-icon{flex:none;font-size:15px}',
|
|
17
|
+
'.wbd-card-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
18
|
+
'.wbd-card-sub{flex:none;color:var(--dsw-alias-label-tertiary,#6b7280);font-size:12px}',
|
|
19
|
+
'.wbd-pick{position:relative;display:inline-flex}',
|
|
20
|
+
'.wbd-pick-btn{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:none;border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary,#aab2c0);font-size:15px;cursor:pointer}',
|
|
21
|
+
'.wbd-pick-btn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.06));color:var(--dsw-alias-label-primary,#f2f4f8)}',
|
|
22
|
+
'.wbd-pick-menu{position:absolute;left:0;bottom:calc(100% + 8px);display:flex;flex-direction:column;min-width:220px;padding:6px;border-radius:10px;border:1px solid var(--dsw-alias-border-l2,#2c3342);background:var(--dsw-color-bg-elevated,#1c222e);box-shadow:0 10px 32px rgba(0,0,0,.4);z-index:10}',
|
|
23
|
+
'.wbd-pick-menu button{display:block;width:100%;padding:8px 10px;border:none;border-radius:6px;background:transparent;color:var(--dsw-alias-label-primary,#f2f4f8);font:inherit;font-size:13px;text-align:left;cursor:pointer}',
|
|
24
|
+
'.wbd-pick-menu button:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.06))}',
|
|
25
|
+
'/* ---- 引用气泡换肤:主题色圆角矩形(原子删除由输入机原生保证) ---- */',
|
|
26
|
+
'/* 关键:padding 用等量负 margin 抵消、描边用 box-shadow(不占布局)—— */',
|
|
27
|
+
'/* 气泡外部宽度与 textarea 字符宽度完全一致,backdrop 与光标严格对齐 */',
|
|
28
|
+
'[data-decoration="chip"]{padding:0 8px !important;margin:0 -8px !important;border-radius:8px;background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4c9aff) 14%,transparent) !important;box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dsw-alias-brand-primary,#4c9aff) 45%,transparent);color:var(--dsw-alias-brand-primary,#4c9aff) !important;font-weight:500}',
|
|
29
|
+
'[data-decoration="chip"]:hover{background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4c9aff) 22%,transparent) !important}',
|
|
30
|
+
'[data-decoration="chip"][data-invalid="true"]{color:var(--dsw-alias-state-error-primary,#e56a64) !important;background:color-mix(in srgb,var(--dsw-alias-state-error-primary,#e56a64) 14%,transparent) !important;box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dsw-alias-state-error-primary,#e56a64) 45%,transparent) !important}',
|
|
31
|
+
'[data-decoration="chip"] [class*="chipTriggerGlyph"]{font-size:12px;opacity:.8}',
|
|
32
|
+
].join('\n')
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { extractRefs } from './lib/transfer'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 会话事件定义(conversationEvents 注册表)—— 按轮次聚合用户消息中的
|
|
5
|
+
* 文件引用,发布为 Turn 级位置数据,供 turnTail 文件卡片的 selector
|
|
6
|
+
* 无快照、纯数据地判断「这一轮是否渲染卡片」。
|
|
7
|
+
*
|
|
8
|
+
* 关联规则:turn/start 时通过 reader.previous 取最近的前置用户消息上下文;
|
|
9
|
+
* 结合 turn 边界定义(workbuddy-turn-boundary)排除「无用户消息的轮次
|
|
10
|
+
* (如 goal 轮)」错误继承上一轮引用的情况。
|
|
11
|
+
*/
|
|
12
|
+
export interface UserMessageEvent {
|
|
13
|
+
type: 'user/message'
|
|
14
|
+
seq: number
|
|
15
|
+
data: {
|
|
16
|
+
id: string
|
|
17
|
+
source: { kind: string }
|
|
18
|
+
content: ReadonlyArray<{ type: string; text?: string }>
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface TurnBoundaryEvent {
|
|
23
|
+
type: 'turn/start' | 'turn/end'
|
|
24
|
+
seq: number
|
|
25
|
+
data: { turn: number }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const boundaryDef = {
|
|
29
|
+
kind: 'workbuddy-turn-boundary',
|
|
30
|
+
match: (event: { type: string; data: { turn: number } }) =>
|
|
31
|
+
event.type === 'turn/start' ? { id: String(event.data.turn), role: 'start' as const }
|
|
32
|
+
: event.type === 'turn/end' ? { id: String(event.data.turn), role: 'update' as const }
|
|
33
|
+
: null,
|
|
34
|
+
start: (_context: unknown, match: { event: TurnBoundaryEvent }) => {
|
|
35
|
+
if (match.event.type !== 'turn/start') throw new Error('workbuddy-turn-boundary start requires turn/start')
|
|
36
|
+
return { turn: match.event.data.turn, startSeq: match.event.seq }
|
|
37
|
+
},
|
|
38
|
+
update: (context: { state: unknown }) => context.state,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const fileRefsDef = {
|
|
42
|
+
kind: 'workbuddy-file-refs',
|
|
43
|
+
match: (event: { type: string; seq: number; data: { id?: string; turn?: number; source?: { kind: string }; content?: ReadonlyArray<{ type: string; text?: string }> } }) => {
|
|
44
|
+
if (event.type === 'user/message' && event.data?.source?.kind === 'user' && event.data.id !== undefined) {
|
|
45
|
+
return { id: String(event.data.id), role: 'start' as const }
|
|
46
|
+
}
|
|
47
|
+
if (event.type === 'turn/start' && event.data.turn !== undefined) {
|
|
48
|
+
return { id: 'turn-' + String(event.data.turn), role: 'start' as const }
|
|
49
|
+
}
|
|
50
|
+
return null
|
|
51
|
+
},
|
|
52
|
+
start: (context: { state: unknown }, match: { event: UserMessageEvent | TurnBoundaryEvent }, reader: { previous(kind: string): { startSeq: number; state: unknown } | undefined }) => {
|
|
53
|
+
if (match.event.type === 'turn/start') {
|
|
54
|
+
let refs: string[] = []
|
|
55
|
+
const prev = reader.previous('workbuddy-file-refs')
|
|
56
|
+
const prevBoundary = reader.previous('workbuddy-turn-boundary')
|
|
57
|
+
if (prev !== undefined && prev.state !== undefined && Array.isArray((prev.state as { refs?: unknown }).refs)) {
|
|
58
|
+
const afterBoundary = prevBoundary === undefined || prevBoundary.state === undefined || prev.startSeq > prevBoundary.startSeq
|
|
59
|
+
if (afterBoundary) refs = (prev.state as { refs: string[] }).refs
|
|
60
|
+
}
|
|
61
|
+
return { turn: match.event.data.turn, refs }
|
|
62
|
+
}
|
|
63
|
+
void context
|
|
64
|
+
const ev = match.event as UserMessageEvent
|
|
65
|
+
return { seq: ev.seq, refs: extractRefs(ev.data?.content ?? []) }
|
|
66
|
+
},
|
|
67
|
+
update: (context: { state: unknown }) => context.state,
|
|
68
|
+
buildLocationData: (context: { state: unknown }, scope: string) => {
|
|
69
|
+
if (scope !== 'turn') return null
|
|
70
|
+
const s = context.state as { turn?: number; refs?: string[] } | undefined
|
|
71
|
+
if (s === undefined || s.turn === undefined || !Array.isArray(s.refs) || s.refs.length === 0) return null
|
|
72
|
+
return { kind: 'turn', turn: s.turn, key: 'workbuddy-file-refs', value: { refs: s.refs } }
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** turnTail chain selector:仅当该轮次存在文件引用时返回 matched,避免抢占其他链条目 */
|
|
77
|
+
export function selectTurnFileRefs(owner: { turn?: { data: { get(key: string): { refs?: string[] } | undefined } } } | null | undefined): { refs: string[] } | null {
|
|
78
|
+
const t = owner !== null && owner !== undefined ? owner.turn : undefined
|
|
79
|
+
if (t === undefined || t.data === undefined || typeof t.data.get !== 'function') return null
|
|
80
|
+
const data = t.data.get('workbuddy-file-refs')
|
|
81
|
+
if (data === undefined || data === null || !Array.isArray(data.refs) || data.refs.length === 0) return null
|
|
82
|
+
return { refs: data.refs }
|
|
83
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Client 半侧 bundle 外壳:由 tsdown 构建为 lib/client.js。
|
|
2
|
+
// 必须是一个「普通副作用脚本」——加载时调用 window.__ModuleLoader__.load,
|
|
3
|
+
// 不能包含顶层 ESM export / import(react 由 factory 的 require 取得)。
|
|
4
|
+
import { makeFactory } from './app'
|
|
5
|
+
|
|
6
|
+
declare const window: {
|
|
7
|
+
__ModuleLoader__: {
|
|
8
|
+
load(info: { id: string; factory: (require: (m: string) => unknown) => unknown }): void
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
window.__ModuleLoader__.load({
|
|
13
|
+
id: 'dsh-workbuddy-files',
|
|
14
|
+
factory: makeFactory() as never,
|
|
15
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { BusState } from '../types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 拖拽遮罩 / toast 的轻量发布订阅总线。
|
|
5
|
+
* 动态(dynamic)插件里同一逻辑内联在 apply 中;这里抽成模块便于测试。
|
|
6
|
+
*/
|
|
7
|
+
export interface DropBus {
|
|
8
|
+
get(): BusState
|
|
9
|
+
set(next: BusState): void
|
|
10
|
+
subscribe(fn: (s: BusState) => void): () => void
|
|
11
|
+
toast(text: string, level?: 'info' | 'error'): void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createDropBus(): DropBus {
|
|
15
|
+
let state: BusState = { active: false, count: 0, toast: null }
|
|
16
|
+
const subs = new Set<(s: BusState) => void>()
|
|
17
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
18
|
+
|
|
19
|
+
const get = () => state
|
|
20
|
+
const set = (next: BusState) => {
|
|
21
|
+
state = next
|
|
22
|
+
for (const fn of subs) fn(state)
|
|
23
|
+
}
|
|
24
|
+
const subscribe = (fn: (s: BusState) => void) => {
|
|
25
|
+
subs.add(fn)
|
|
26
|
+
return () => { subs.delete(fn) }
|
|
27
|
+
}
|
|
28
|
+
const toast = (text: string, level: 'info' | 'error' = 'info') => {
|
|
29
|
+
if (timer !== null) clearTimeout(timer)
|
|
30
|
+
set({ ...state, toast: { text: String(text), level } })
|
|
31
|
+
timer = setTimeout(() => { timer = null; set({ ...state, toast: null }) }, 4600)
|
|
32
|
+
}
|
|
33
|
+
return { get, set, subscribe, toast }
|
|
34
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import type { InsertItem, TreeFile, UploadJob } from '../types'
|
|
2
|
+
import type { DropBus } from './bus'
|
|
3
|
+
import type { InsertPipeline } from './insert'
|
|
4
|
+
import { mentionFor, walkEntry } from './transfer'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 拖拽 / 粘贴处理(统一接管 + 拖入即插气泡):
|
|
8
|
+
*
|
|
9
|
+
* 1. 任何文件拖入/粘贴(含图片)都走本插件:全屏遮罩 → 解析引用项;
|
|
10
|
+
* 2. 最终缓存路径在拖入时即可确定(<dropsRoot>/<批次>/<相对路径>),
|
|
11
|
+
* 因此**立即**在输入框光标处插入气泡 —— 不等上传;
|
|
12
|
+
* 3. 文件落盘由 enqueueUpload 在后台异步进行(本地写盘,用户无感),
|
|
13
|
+
* 完成/失败以 toast 告知;
|
|
14
|
+
* 4. 接管时从 dragenter 起 stopPropagation(window capture 首站),
|
|
15
|
+
* DSH 原生图片拖放遮罩不会激活,也不会因收不到收尾事件而卡住页面。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface DropDeps {
|
|
19
|
+
bus: DropBus
|
|
20
|
+
insert: InsertPipeline
|
|
21
|
+
/** 缓存根目录(应用启动时预取,拖入时兜底拉取) */
|
|
22
|
+
ensureRoot(): Promise<string | null>
|
|
23
|
+
/** 后台上传入口(不阻塞输入;完成后自行 toast) */
|
|
24
|
+
enqueueUpload(jobs: UploadJob[], batch: string): void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 任何文件拖拽都接管;items 不可用(Firefox dragenter/dragover)时按 types 兜底 */
|
|
28
|
+
export function interceptable(dt: { items: DataTransferItemList | DataTransferItem[]; types?: readonly string[] } | null | undefined): boolean {
|
|
29
|
+
if (dt === null || dt === undefined) return false
|
|
30
|
+
const types = Array.from(dt.types ?? [])
|
|
31
|
+
if (types.includes('Files')) return true
|
|
32
|
+
if (dt.items) {
|
|
33
|
+
for (const it of Array.from(dt.items)) if (it.kind === 'file') return true
|
|
34
|
+
}
|
|
35
|
+
return false
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function countFiles(dt: { items: DataTransferItemList | DataTransferItem[] }): number {
|
|
39
|
+
let n = 0
|
|
40
|
+
for (const it of Array.from(dt.items)) if (it.kind === 'file') n += 1
|
|
41
|
+
return n
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface DropHandlers {
|
|
45
|
+
/** 已同步收集的拖拽/粘贴条目 → 立即插气泡 + 后台缓存 */
|
|
46
|
+
acceptAndInsert(synced: SyncedItem[], batch: string): Promise<void>
|
|
47
|
+
/** 文件选择框(input.files)→ 立即插气泡 + 后台缓存 */
|
|
48
|
+
acceptTree(files: TreeFile[], dirTops: string[], batch: string): Promise<void>
|
|
49
|
+
installListeners(): () => void
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** drop/paste 事件内同步收集的结果(事件结束后 DataTransfer 即失效,不可再读) */
|
|
53
|
+
export interface SyncedItem {
|
|
54
|
+
entry: FileSystemEntry | null
|
|
55
|
+
file: File | null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 关键:DataTransfer 只在事件同步阶段有效 —— getAsFile / webkitGetAsEntry
|
|
60
|
+
* 必须在事件处理器内、任何 await 之前完成调用,否则浏览器清空 DataTransfer
|
|
61
|
+
* 后全部返回 null。本函数专门在同步阶段收集 File / Entry。
|
|
62
|
+
*/
|
|
63
|
+
export function syncCollect(dt: { items: DataTransferItemList | DataTransferItem[] }): SyncedItem[] {
|
|
64
|
+
const synced: SyncedItem[] = []
|
|
65
|
+
for (const it of Array.from(dt.items)) {
|
|
66
|
+
if (it.kind !== 'file') continue
|
|
67
|
+
let entry: FileSystemEntry | null = null
|
|
68
|
+
try {
|
|
69
|
+
const getter = (it as unknown as { webkitGetAsEntry?: () => FileSystemEntry | null }).webkitGetAsEntry
|
|
70
|
+
?? (it as unknown as { getAsEntry?: () => FileSystemEntry | null }).getAsEntry
|
|
71
|
+
entry = typeof getter === 'function' ? getter() : null
|
|
72
|
+
} catch { entry = null }
|
|
73
|
+
let file: File | null = null
|
|
74
|
+
try { file = typeof (it as DataTransferItem).getAsFile === 'function' ? (it as DataTransferItem).getAsFile() : null } catch { file = null }
|
|
75
|
+
if (entry === null && file === null) continue
|
|
76
|
+
synced.push({ entry, file })
|
|
77
|
+
}
|
|
78
|
+
return synced
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function createDropHandlers(deps: DropDeps): DropHandlers {
|
|
82
|
+
const { bus, insert } = deps
|
|
83
|
+
|
|
84
|
+
const submitRefs = async (refs: InsertItem[], jobs: UploadJob[], batch: string): Promise<void> => {
|
|
85
|
+
if (refs.length === 0) { bus.toast('无法读取拖入的内容', 'error'); return }
|
|
86
|
+
// 1) 立即插入气泡(不等上传)
|
|
87
|
+
const inserted = await insert(refs)
|
|
88
|
+
if (inserted > 0) bus.toast('已引用 ' + inserted + ' 项,文件正在后台缓存')
|
|
89
|
+
else bus.toast('未能插入引用(见上方提示)', 'error')
|
|
90
|
+
// 2) 后台缓存
|
|
91
|
+
deps.enqueueUpload(jobs, batch)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 文件选择框路径(已有 TreeFile 列表) */
|
|
95
|
+
const acceptTree = async (files: TreeFile[], dirTops: string[], batch: string): Promise<void> => {
|
|
96
|
+
const root = await deps.ensureRoot()
|
|
97
|
+
if (root === null) { bus.toast('无法获取缓存目录,请重试', 'error'); return }
|
|
98
|
+
const refs: InsertItem[] = []
|
|
99
|
+
for (const top of dirTops) {
|
|
100
|
+
const dirPath = root + '/' + batch + '/' + top
|
|
101
|
+
refs.push({ label: top, reference: { source: 'workbuddy', ref: mentionFor(dirPath, true), label: top, appearance: 'folder', clipboardText: mentionFor(dirPath, true) } })
|
|
102
|
+
}
|
|
103
|
+
for (const f of files) {
|
|
104
|
+
const path = root + '/' + batch + '/' + f.rel
|
|
105
|
+
refs.push({ label: f.name, reference: { source: 'workbuddy', ref: mentionFor(path, false), label: f.name, appearance: 'file', clipboardText: mentionFor(path, false) } })
|
|
106
|
+
}
|
|
107
|
+
await submitRefs(refs, files.map((f) => ({ kind: 'file', file: f.file, rel: f.rel, name: f.name })), batch)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 核心:先按预分配路径立即插入气泡,再交给后台缓存。
|
|
112
|
+
* synced 必须在事件内同步收集完毕(见 syncCollect)。
|
|
113
|
+
*/
|
|
114
|
+
const acceptAndInsert = async (synced: SyncedItem[], batch: string): Promise<void> => {
|
|
115
|
+
const root = await deps.ensureRoot()
|
|
116
|
+
if (root === null) { bus.toast('无法获取缓存目录,请重试', 'error'); return }
|
|
117
|
+
|
|
118
|
+
const refs: InsertItem[] = []
|
|
119
|
+
const jobs: UploadJob[] = []
|
|
120
|
+
|
|
121
|
+
for (const s of synced) {
|
|
122
|
+
const { entry, file } = s
|
|
123
|
+
|
|
124
|
+
if (entry !== null && entry !== undefined && entry.isDirectory) {
|
|
125
|
+
const top = entry.name
|
|
126
|
+
const dirPath = root + '/' + batch + '/' + top
|
|
127
|
+
refs.push({ label: top, reference: { source: 'workbuddy', ref: mentionFor(dirPath, true), label: top, appearance: 'folder', clipboardText: mentionFor(dirPath, true) } })
|
|
128
|
+
jobs.push({ kind: 'dir', entry })
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (file !== null && file !== undefined) {
|
|
133
|
+
const rel = file.name
|
|
134
|
+
const path = root + '/' + batch + '/' + rel
|
|
135
|
+
refs.push({ label: file.name, reference: { source: 'workbuddy', ref: mentionFor(path, false), label: file.name, appearance: 'file', clipboardText: mentionFor(path, false) } })
|
|
136
|
+
jobs.push({ kind: 'file', file, rel, name: file.name })
|
|
137
|
+
continue
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// entry 是文件但 getAsFile 失败:尽力从 entry.file 取(事件外可能失效,尽力而为)
|
|
141
|
+
if (entry !== null && entry !== undefined && entry.isFile) {
|
|
142
|
+
const fe = entry as FileSystemFileEntry
|
|
143
|
+
const f = await new Promise<File | null>((resolve2) => fe.file((ff) => resolve2(ff), () => resolve2(null)))
|
|
144
|
+
if (f !== null && f !== undefined) {
|
|
145
|
+
const rel = f.name
|
|
146
|
+
const path = root + '/' + batch + '/' + rel
|
|
147
|
+
refs.push({ label: f.name, reference: { source: 'workbuddy', ref: mentionFor(path, false), label: f.name, appearance: 'file', clipboardText: mentionFor(path, false) } })
|
|
148
|
+
jobs.push({ kind: 'file', file: f, rel, name: f.name })
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
await submitRefs(refs, jobs, batch)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const installListeners = (): (() => void) => {
|
|
157
|
+
let dragDepth = 0
|
|
158
|
+
const onDragEnter = (e: DragEvent) => {
|
|
159
|
+
if (!interceptable(e.dataTransfer)) return
|
|
160
|
+
e.preventDefault()
|
|
161
|
+
// 关键:capture 首站即阻断传播。DSH 原生图片拖放轨道在 document(bubble)
|
|
162
|
+
// 上监听 dragenter 并无条件显示全屏遮罩;若它先激活、而我们的 drop 又
|
|
163
|
+
// stopPropagation,它会收不到收尾事件而永久卡住页面。
|
|
164
|
+
e.stopPropagation()
|
|
165
|
+
dragDepth += 1
|
|
166
|
+
bus.set({ ...bus.get(), active: true, count: countFiles(e.dataTransfer) })
|
|
167
|
+
}
|
|
168
|
+
const onDragOver = (e: DragEvent) => {
|
|
169
|
+
if (!interceptable(e.dataTransfer)) return
|
|
170
|
+
e.preventDefault()
|
|
171
|
+
e.stopPropagation()
|
|
172
|
+
if (!bus.get().active) { dragDepth = 1; bus.set({ ...bus.get(), active: true, count: countFiles(e.dataTransfer) }) }
|
|
173
|
+
}
|
|
174
|
+
const onDragLeave = (e: DragEvent) => {
|
|
175
|
+
if (!interceptable(e.dataTransfer)) return
|
|
176
|
+
e.preventDefault()
|
|
177
|
+
e.stopPropagation()
|
|
178
|
+
if (dragDepth > 0) dragDepth -= 1
|
|
179
|
+
if (dragDepth === 0) bus.set({ ...bus.get(), active: false, count: 0 })
|
|
180
|
+
}
|
|
181
|
+
const onDrop = (e: DragEvent) => {
|
|
182
|
+
const dt = e.dataTransfer
|
|
183
|
+
if (dt === null || dt === undefined) return
|
|
184
|
+
if (!interceptable(dt)) return
|
|
185
|
+
e.preventDefault()
|
|
186
|
+
e.stopPropagation()
|
|
187
|
+
dragDepth = 0
|
|
188
|
+
bus.set({ ...bus.get(), active: false, count: 0 })
|
|
189
|
+
// 事件内同步收集(await 之前),否则 DataTransfer 失效
|
|
190
|
+
const synced = syncCollect(dt)
|
|
191
|
+
if (synced.length === 0) return
|
|
192
|
+
acceptAndInsert(synced, 'drop-' + Date.now().toString(36)).catch((err) => {
|
|
193
|
+
console.error('[workbuddy] drop 处理失败:', err)
|
|
194
|
+
bus.toast('拖入处理失败:' + String((err as Error)?.message ?? err), 'error')
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
const onDragEnd = () => {
|
|
198
|
+
dragDepth = 0
|
|
199
|
+
bus.set({ ...bus.get(), active: false, count: 0 })
|
|
200
|
+
}
|
|
201
|
+
const onPaste = (e: ClipboardEvent) => {
|
|
202
|
+
const cd = e.clipboardData
|
|
203
|
+
if (cd === null || cd === undefined || !cd.items) return
|
|
204
|
+
let hasFile = false
|
|
205
|
+
for (const it of Array.from(cd.items)) if (it.kind === 'file') { hasFile = true; break }
|
|
206
|
+
if (!hasFile) return
|
|
207
|
+
e.preventDefault()
|
|
208
|
+
e.stopPropagation()
|
|
209
|
+
// 事件内同步收集(await 之前),否则 clipboardData 失效
|
|
210
|
+
const synced = syncCollect(cd)
|
|
211
|
+
if (synced.length === 0) return
|
|
212
|
+
acceptAndInsert(synced, 'drop-' + Date.now().toString(36)).catch((err) => {
|
|
213
|
+
console.error('[workbuddy] 粘贴处理失败:', err)
|
|
214
|
+
bus.toast('粘贴处理失败:' + String((err as Error)?.message ?? err), 'error')
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
window.addEventListener('dragenter', onDragEnter, true)
|
|
219
|
+
window.addEventListener('dragover', onDragOver, true)
|
|
220
|
+
window.addEventListener('dragleave', onDragLeave, true)
|
|
221
|
+
window.addEventListener('drop', onDrop, true)
|
|
222
|
+
window.addEventListener('dragend', onDragEnd, true)
|
|
223
|
+
window.addEventListener('paste', onPaste, true)
|
|
224
|
+
return () => {
|
|
225
|
+
window.removeEventListener('dragenter', onDragEnter, true)
|
|
226
|
+
window.removeEventListener('dragover', onDragOver, true)
|
|
227
|
+
window.removeEventListener('dragleave', onDragLeave, true)
|
|
228
|
+
window.removeEventListener('drop', onDrop, true)
|
|
229
|
+
window.removeEventListener('dragend', onDragEnd, true)
|
|
230
|
+
window.removeEventListener('paste', onPaste, true)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return { acceptAndInsert, acceptTree, installListeners }
|
|
235
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** 文件类型 → 图标/颜色映射(WorkBuddy 风格的类型标识) */
|
|
2
|
+
|
|
3
|
+
const ICONS: Record<string, string> = {
|
|
4
|
+
pdf: '📕', doc: '📘', docx: '📘', xls: '📊', xlsx: '📊', csv: '📊', ppt: '📙', pptx: '📙',
|
|
5
|
+
png: '🖼️', jpg: '🖼️', jpeg: '🖼️', gif: '🖼️', webp: '🖼️', svg: '🖼️', bmp: '🖼️',
|
|
6
|
+
zip: '🗜️', '7z': '🗜️', rar: '🗜️', tar: '🗜️', gz: '🗜️',
|
|
7
|
+
mp3: '🎵', wav: '🎵', flac: '🎵', mp4: '🎬', mov: '🎬', mkv: '🎬',
|
|
8
|
+
md: '📝', txt: '📄', log: '📄',
|
|
9
|
+
js: '💻', ts: '💻', jsx: '💻', tsx: '💻', py: '💻', go: '💻', rs: '💻', java: '💻',
|
|
10
|
+
c: '💻', h: '💻', cpp: '💻', cs: '💻', json: '💻', yaml: '💻', yml: '💻', toml: '💻',
|
|
11
|
+
sh: '💻', ps1: '💻', bat: '💻', css: '💻', html: '💻', vue: '💻', sql: '💻',
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function iconFor(name: string): string {
|
|
15
|
+
const dot = String(name).lastIndexOf('.')
|
|
16
|
+
const ext = dot >= 0 ? String(name).slice(dot + 1).toLowerCase() : ''
|
|
17
|
+
return ICONS[ext] ?? '📄'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function formatSize(n: number | null | undefined): string {
|
|
21
|
+
if (n === null || n === undefined || !Number.isFinite(n)) return ''
|
|
22
|
+
if (n < 1024) return n + ' B'
|
|
23
|
+
if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'
|
|
24
|
+
if (n < 1073741824) return (n / 1048576).toFixed(1) + ' MB'
|
|
25
|
+
return (n / 1073741824).toFixed(2) + ' GB'
|
|
26
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { InsertItem } from '../types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 气泡插入管线 —— 整个插件的核心:
|
|
5
|
+
*
|
|
6
|
+
* 通过对话包暴露的 `conversation.input`(InputHub,SessionInputResolver 面)
|
|
7
|
+
* 解析当前会话的输入 shell,读取实时 InputState(draft/draftRev),在
|
|
8
|
+
* 光标位置调用 `shell.insertReference(reference, span)` 铸造原生引用气泡
|
|
9
|
+
* (occurrence)。span 采用 draftRev CAS:若插入期间用户输入导致修订号
|
|
10
|
+
* 变化,重试读取新状态(最多 8 次);非 plain 阶段(提交中)插入被拒。
|
|
11
|
+
*
|
|
12
|
+
* 兜底 1:insertReference 反复失败 → setDraft 直接写草稿文本(保证输入框有内容);
|
|
13
|
+
* 兜底 2:无 facade → 聚焦 textarea 时 document.execCommand 纯文本插入。
|
|
14
|
+
*/
|
|
15
|
+
export interface InsertDeps {
|
|
16
|
+
sessions: {
|
|
17
|
+
list: { getSnapshot(): { current: string | undefined } }
|
|
18
|
+
}
|
|
19
|
+
conversation: {
|
|
20
|
+
input: {
|
|
21
|
+
/**
|
|
22
|
+
* id 寻址服务面(InputHub.shell)—— 直接返回会话输入 shell,
|
|
23
|
+
* 无需 sessions.scope()(后者返回 cordis Context,动态门面拒绝暴露)。
|
|
24
|
+
*/
|
|
25
|
+
shell(id: string): {
|
|
26
|
+
state: { getSnapshot(): { draft: string; draftRev: number } }
|
|
27
|
+
insertReference(reference: InsertItem['reference'], span: { start: number; end: number; draftRev: number }): boolean
|
|
28
|
+
setDraft(text: string): void
|
|
29
|
+
notify(level: 'info' | 'error', text: string): void
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
toast(text: string, level?: 'info' | 'error'): void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type InsertPipeline = (items: InsertItem[]) => Promise<number>
|
|
37
|
+
|
|
38
|
+
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms))
|
|
39
|
+
|
|
40
|
+
function readCaret(fallbackLen: number): number {
|
|
41
|
+
const el = document.activeElement
|
|
42
|
+
if (el !== null && el !== undefined && String(el.tagName).toUpperCase() === 'TEXTAREA' && typeof (el as HTMLTextAreaElement).selectionStart === 'number') {
|
|
43
|
+
return (el as HTMLTextAreaElement).selectionStart
|
|
44
|
+
}
|
|
45
|
+
return fallbackLen
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createInsertPipeline(deps: InsertDeps): InsertPipeline {
|
|
49
|
+
return async function insertItems(items: InsertItem[]): Promise<number> {
|
|
50
|
+
const sessionId = deps.sessions.list.getSnapshot().current
|
|
51
|
+
if (sessionId === undefined) {
|
|
52
|
+
deps.toast('请先打开或新建一个会话,再拖入文件', 'error')
|
|
53
|
+
return 0
|
|
54
|
+
}
|
|
55
|
+
let shell: null | {
|
|
56
|
+
state: { getSnapshot(): { draft: string; draftRev: number } }
|
|
57
|
+
insertReference(reference: InsertItem['reference'], span: { start: number; end: number; draftRev: number }): boolean
|
|
58
|
+
setDraft(text: string): void
|
|
59
|
+
notify(level: 'info' | 'error', text: string): void
|
|
60
|
+
} = null
|
|
61
|
+
try { shell = deps.conversation.input.shell(sessionId) } catch { shell = null }
|
|
62
|
+
|
|
63
|
+
let inserted = 0
|
|
64
|
+
let firstCaret: number | null = null
|
|
65
|
+
for (const item of items) {
|
|
66
|
+
if (shell !== null && shell !== undefined) {
|
|
67
|
+
let ok = false
|
|
68
|
+
for (let attempt = 0; attempt < 8 && !ok; attempt += 1) {
|
|
69
|
+
try {
|
|
70
|
+
const st = shell.state.getSnapshot()
|
|
71
|
+
let caret = st.draft.length
|
|
72
|
+
if (firstCaret === null) {
|
|
73
|
+
firstCaret = readCaret(st.draft.length)
|
|
74
|
+
caret = firstCaret
|
|
75
|
+
}
|
|
76
|
+
const pos = Math.min(caret, st.draft.length)
|
|
77
|
+
ok = shell.insertReference(item.reference, { start: pos, end: pos, draftRev: st.draftRev })
|
|
78
|
+
} catch { ok = false }
|
|
79
|
+
if (!ok) await sleep(90)
|
|
80
|
+
}
|
|
81
|
+
if (ok) { inserted += 1; continue }
|
|
82
|
+
// 兜底 1:直接写草稿文本(无气泡,但输入框立刻有内容)
|
|
83
|
+
try {
|
|
84
|
+
const st = shell.state.getSnapshot()
|
|
85
|
+
const pos = Math.min(firstCaret !== null ? firstCaret : st.draft.length, st.draft.length)
|
|
86
|
+
const mention = typeof item.reference.ref === 'string' ? item.reference.ref : String(item.label)
|
|
87
|
+
const next = st.draft.slice(0, pos) + mention + ' ' + st.draft.slice(pos)
|
|
88
|
+
shell.setDraft(next)
|
|
89
|
+
inserted += 1
|
|
90
|
+
continue
|
|
91
|
+
} catch (err2) {
|
|
92
|
+
try { shell.notify('error', '未能插入引用「' + item.label + '」:' + String((err2 as Error)?.message ?? err2)) } catch { /* ignore */ }
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
// 兜底 2:无 facade —— 聚焦 textarea 时 execCommand 纯文本插入
|
|
96
|
+
const text = item.reference.ref
|
|
97
|
+
const el = document.activeElement
|
|
98
|
+
if (el !== null && el !== undefined && String(el.tagName).toUpperCase() === 'TEXTAREA') {
|
|
99
|
+
try { document.execCommand('insertText', false, text + ' '); inserted += 1 } catch { /* ignore */ }
|
|
100
|
+
} else {
|
|
101
|
+
deps.toast('输入区不可用,未能插入「' + item.label + '」', 'error')
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return inserted
|
|
106
|
+
}
|
|
107
|
+
}
|