dsh-asuta-projectless-chat 0.0.0-stage → 0.1.2

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Asuta and contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,16 @@
1
- # Temporary Holding Version
1
+ # 独立对话
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 在“最近”标题栏悬停或键盘聚焦时显示“新建独立对话”。每次创建全新的原生主会话,不继承历史,也不加入已有工作区。没有最近列表时,在侧栏底部提供备用入口。
4
+
5
+ 点击新建后,独立对话的输入框上方显示原生模式选择。可在发送首条消息前切换模式,不影响现有会话。
6
+
7
+ - 默认工作目录:用户主目录下的 Documents/DSH/<日期>/new-chat-<唯一后缀>。
8
+ - 设置 → 通用 → 独立对话默认目录:输入绝对路径或浏览主机文件夹,保存或恢复默认。仅影响以后创建的对话,不迁移既有文件。
9
+ - 聊天记录仍由 DSH 原生会话存储管理;归档不会删除工作文件。
10
+ - 插件设置保存到 DSH_HOME/plugin-data/projectless-chat/settings.json;不修改 profile 文件。
11
+ - 使用 sessions.create({ cwd }) 和 uiWorkspace.openSession,完整复用原生聊天视图和模式选择。
12
+ - 未绑定工作区的空白 hero 原本会禁用输入框;插件仅在独立对话的渲染范围内提供“未分组”显示标签,使原生 hero 输入框和模式选择可用,不修改真实工作区数据。
13
+ - 文件夹选择器通过本地受 DSH 身份验证的接口逐级读取目录,不依赖桌面原生 picker,因此网页版也可用。
14
+ - 最近标题栏挂载使用现有侧栏增强插件的 data-slot=sidebar.recent.section 标记,不依赖构建哈希类名。
15
+
16
+ 本地开发安装:在仓库根目录执行 dsh plugin --profile desktop add ./plugins/projectless-chat。运行 node --test test/*.test.mjs 验证目录与配置逻辑。
package/client.js ADDED
@@ -0,0 +1,180 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-asuta-projectless-chat',
3
+ factory(require) {
4
+ const React = require('react'), h = React.createElement
5
+ const { createPortal } = require('react-dom')
6
+ const NS = 'projectlessChat'
7
+ const zh = { create: '新建独立对话', creating: '正在创建独立对话…', ungrouped: '未分组', title: '独立对话默认目录', help: '不属于任何工作区的新对话,会在此目录下按日期创建独立文件夹。仅影响以后新建的对话;聊天记录仍由 DSH 管理。', save: '保存', reset: '恢复默认', browse: '选择文件夹', reload: '重新读取', saved: '已保存;新建对话将使用此目录。', cancel: '取消', choose: '使用此文件夹', loading: '正在加载…', close: '关闭', error: '操作失败:', empty: '此目录下没有子文件夹。', truncated: '目录较多,仅显示部分结果;可直接输入完整路径。', retry: '重试打开' }
8
+ const en = { create: 'New independent conversation', creating: 'Creating conversation…', ungrouped: 'Ungrouped', title: 'Default independent conversation folder', help: 'New conversations outside workspaces receive separate dated folders here. Changes apply only to new conversations. DSH continues to manage chat history.', save: 'Save', reset: 'Restore default', browse: 'Choose folder', reload: 'Reload', saved: 'Saved. New conversations will use this folder.', cancel: 'Cancel', choose: 'Use this folder', loading: 'Loading…', close: 'Close', error: 'Operation failed: ', empty: 'No subfolders.', truncated: 'Some folders are omitted. Enter the full path directly if needed.', retry: 'Open again' }
9
+ async function request(body, signal) {
10
+ const response = await fetch('/projectless-chat/api', { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json', 'x-projectless-chat': '1' }, body: JSON.stringify(body), signal })
11
+ const result = await response.json()
12
+ if (!response.ok || !result.ok) throw Object.assign(new Error(result.error || 'Request failed'), { code: result.code })
13
+ return result
14
+ }
15
+ const CSS = [
16
+ '.pc-new{display:inline-flex;align-items:center;justify-content:center;flex:none;width:28px;height:28px;padding:0;border:0;border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;opacity:0;transition:opacity 120ms ease;-webkit-app-region:no-drag}',
17
+ '[data-pc-header]:hover .pc-new,[data-pc-header]:focus-within .pc-new,.pc-new:focus-visible,.pc-new:disabled{opacity:1}',
18
+ '.pc-new:hover,.pc-button:hover{background:var(--dsw-alias-interactive-bg-hover)}',
19
+ '.pc-new:focus-visible,.pc-button:focus-visible,.pc-input:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:2px}',
20
+ '.pc-new:disabled,.pc-button:disabled{opacity:.5;cursor:default}',
21
+ '.pc-setting{padding:18px 0;border-bottom:.5px solid var(--dsw-alias-border-l2)}',
22
+ '.pc-title{font-size:14px;line-height:20px}.pc-help{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;margin:5px 0 12px}',
23
+ '.pc-input{box-sizing:border-box;width:100%;min-width:0;padding:9px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px}',
24
+ '.pc-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}.pc-button{padding:7px 11px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;cursor:pointer}',
25
+ '.pc-error{color:var(--dsw-alias-state-error-primary);font-size:13px;overflow-wrap:anywhere}.pc-success{color:var(--dsw-alias-label-secondary);font-size:12px}',
26
+ '.pc-overlay{position:fixed;inset:0;z-index:10020;background:#0007;display:flex;align-items:center;justify-content:center;-webkit-app-region:no-drag}',
27
+ '.pc-dialog{box-sizing:border-box;width:min(620px,calc(100vw - 40px));max-height:80vh;overflow:auto;border:1px solid var(--dsw-alias-border-l2);border-radius:14px;background:var(--dsw-specific-sidebar-fill,#202020);color:var(--dsw-alias-label-primary);padding:22px;box-shadow:0 16px 60px #0006}',
28
+ '.pc-dialog h3{font-size:16px;margin:0 0 16px}.pc-folders{max-height:300px;overflow:auto;display:flex;flex-direction:column;gap:4px;margin-top:12px}.pc-folders button{text-align:left}.pc-crumbs{display:flex;flex-wrap:wrap;gap:4px;margin-top:8px}',
29
+ '.pc-failure{position:fixed;bottom:28px;left:28px;z-index:10030;max-width:480px;padding:14px 18px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-specific-sidebar-fill,#202020);box-shadow:0 8px 30px #0005}',
30
+ '@media(hover:none){.pc-new{opacity:1}}@media(prefers-reduced-motion:reduce){.pc-new{transition:none}}'
31
+ ].join(String.fromCharCode(10))
32
+
33
+ function FolderPicker({ t, onChoose, onClose }) {
34
+ const [listing, setListing] = React.useState(null), [error, setError] = React.useState(''), [busy, setBusy] = React.useState(false)
35
+ const sequence = React.useRef(0), dialog = React.useRef(null)
36
+ async function load(path) {
37
+ const current = ++sequence.current
38
+ setBusy(true); setError('')
39
+ try { const result = await request({ action: 'browse', path }); if (sequence.current === current) setListing(result) }
40
+ catch (err) { if (sequence.current === current) setError(err.message) }
41
+ finally { if (sequence.current === current) setBusy(false) }
42
+ }
43
+ React.useEffect(() => { void load(undefined); const focus = document.activeElement; dialog.current?.focus(); return () => { sequence.current++; focus?.focus?.() } }, [])
44
+ function keys(event) {
45
+ if (event.key === 'Escape') { event.stopPropagation(); onClose() }
46
+ if (event.key !== 'Tab') return
47
+ const buttons = [...dialog.current.querySelectorAll('button:not(:disabled)')]
48
+ if (!buttons.length) return
49
+ const first = buttons[0], last = buttons.at(-1)
50
+ if (event.shiftKey && (document.activeElement === first || document.activeElement === dialog.current)) { event.preventDefault(); last.focus() }
51
+ else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
52
+ }
53
+ return createPortal(h('div', { className: 'pc-overlay', onMouseDown: event => { if (event.target === event.currentTarget) onClose() } },
54
+ h('div', { role: 'dialog', 'aria-modal': true, 'aria-label': t('browse'), className: 'pc-dialog', ref: dialog, tabIndex: -1, onKeyDown: keys },
55
+ h('h3', null, t('browse')), error ? h('p', { role: 'alert', className: 'pc-error' }, error) : null,
56
+ listing ? h(React.Fragment, null, h('code', null, listing.path), h('div', { className: 'pc-crumbs' }, ...listing.crumbs.map(item => h('button', { key: item.path, type: 'button', className: 'pc-button', disabled: busy, onClick: () => load(item.path) }, item.name))),
57
+ h('div', { className: 'pc-folders', 'aria-busy': busy }, ...listing.entries.map(item => h('button', { type: 'button', className: 'pc-button', key: item.path, disabled: busy, onClick: () => load(item.path) }, '📁 ' + item.name)), listing.entries.length ? null : h('p', { className: 'pc-help' }, t('empty'))),
58
+ listing.truncated ? h('p', { className: 'pc-help' }, t('truncated')) : null) : error ? null : h('p', null, t('loading')),
59
+ h('div', { className: 'pc-actions' }, h('button', { type: 'button', className: 'pc-button', disabled: busy || !listing, onClick: () => onChoose(listing.path) }, t('choose')), h('button', { type: 'button', className: 'pc-button', onClick: onClose }, t('cancel'))))), document.body)
60
+ }
61
+ function DirectorySetting({ ctx, t }) {
62
+ const [state, setState] = React.useState(null), [value, setValue] = React.useState(''), [busy, setBusy] = React.useState(false), [error, setError] = React.useState(''), [notice, setNotice] = React.useState(''), [picker, setPicker] = React.useState(false)
63
+ const alive = React.useRef(true)
64
+ async function load(signal) {
65
+ setBusy(true); setError(''); setNotice('')
66
+ try { const result = await request({ action: 'read' }, signal); if (alive.current) { setState(result); setValue(result.root) } }
67
+ catch (err) { if (alive.current && err.name !== 'AbortError') setError(err.message) }
68
+ finally { if (alive.current) setBusy(false) }
69
+ }
70
+ React.useEffect(() => { alive.current = true; const abort = new AbortController(); void load(abort.signal); return () => { alive.current = false; abort.abort() } }, [])
71
+ async function save(root) {
72
+ setBusy(true); setError(''); setNotice('')
73
+ try { const result = await request({ action: 'save', root, revision: state.revision }); if (alive.current) { setState(result); setValue(result.root); setNotice(t('saved')) } }
74
+ catch (err) { if (alive.current) setError(err.message) }
75
+ finally { if (alive.current) setBusy(false) }
76
+ }
77
+ return h('section', { className: 'pc-setting', 'data-plugin': 'dsh-asuta-projectless-chat' },
78
+ h('div', { className: 'pc-title' }, t('title')), h('p', { className: 'pc-help' }, t('help')),
79
+ h('input', { className: 'pc-input', 'aria-label': t('title'), value, disabled: busy || !state, spellCheck: false, onChange: event => setValue(event.target.value) }),
80
+ h('div', { className: 'pc-actions' },
81
+ h('button', { type: 'button', className: 'pc-button', disabled: busy || !state, onClick: () => setPicker(true) }, t('browse')),
82
+ h('button', { type: 'button', className: 'pc-button', disabled: busy || !state || value === state.root, onClick: () => save(value) }, t('save')),
83
+ h('button', { type: 'button', className: 'pc-button', disabled: busy || !state || (state.isDefault && value === state.root), onClick: () => save(null) }, t('reset')),
84
+ h('button', { type: 'button', className: 'pc-button', disabled: busy, onClick: () => load() }, t('reload'))),
85
+ error ? h('p', { role: 'alert', className: 'pc-error' }, t('error') + error) : null, notice ? h('p', { role: 'status', className: 'pc-success' }, notice) : null,
86
+ picker ? h(FolderPicker, { t, onClose: () => setPicker(false), onChoose: path => { setValue(path); setPicker(false) } }) : null)
87
+ }
88
+
89
+ // The native hero needs a workspace label before it enables its composer.
90
+ // Give only an independent blank session a display-only workspace, so the
91
+ // native hero and its agent-preset seat stay mounted and editable.
92
+ function installIndependentComposer(ctx) {
93
+ const host = ctx.slots.hostFace(), original = Symbol.for('cordis.original')
94
+ {
95
+ let patch = null, changing = false, stopped = false
96
+ function restore() {
97
+ if (patch && patch.entry.component === patch.wrapper) Object.defineProperty(patch.entry, 'component', patch.descriptor)
98
+ patch = null
99
+ }
100
+ function reconcile() {
101
+ if (stopped || changing) return
102
+ changing = true
103
+ try {
104
+ const candidate = host.factoryOf('conversation.content')
105
+ const entry = candidate?.[original] ?? candidate
106
+ if (patch?.entry === entry && entry.component === patch.wrapper) return
107
+ restore()
108
+ if (!entry || typeof entry.component !== 'function') return
109
+ const descriptor = Object.getOwnPropertyDescriptor(entry, 'component')
110
+ if (!descriptor || (!descriptor.writable && !descriptor.configurable)) return
111
+ const NativeContent = entry.component
112
+ function IndependentContent(props) {
113
+ const summary = props.useSessions(state => props.sessionId === undefined ? undefined : state.byId[props.sessionId])
114
+ const workspaces = props.useWorkspaces(state => state)
115
+ const independent = summary?.cwd && workspaces.phase === 'ready' && !workspaces.items.some(workspace => workspace.sessionIds.includes(props.sessionId))
116
+ const displayWorkspaces = React.useMemo(() => independent && props.hero ? {
117
+ ...workspaces,
118
+ items: [...workspaces.items, { workspaceId: 'projectless:' + props.sessionId, title: ctx.locale.bind(NS)('ungrouped'), sessionIds: [props.sessionId] }],
119
+ } : null, [independent, props.hero, props.sessionId, workspaces])
120
+ const useWorkspaces = selector => props.useWorkspaces(() => selector(displayWorkspaces ?? workspaces))
121
+ return h(NativeContent, { ...props, useWorkspaces })
122
+ }
123
+ Object.defineProperty(entry, 'component', { ...descriptor, value: IndependentContent })
124
+ patch = { entry, descriptor, wrapper: IndependentContent }
125
+ } finally { changing = false }
126
+ }
127
+ const unsubscribe = host.subscribeFactory('conversation.content', reconcile)
128
+ reconcile()
129
+ return () => { stopped = true; unsubscribe(); restore() }
130
+ }
131
+ }
132
+
133
+ function Launcher({ ctx, t, wide }) {
134
+ const [anchor, setAnchor] = React.useState(null), [busy, setBusy] = React.useState(false), [error, setError] = React.useState('')
135
+ const pending = React.useRef(false), alive = React.useRef(true), created = React.useRef(null)
136
+ React.useEffect(() => {
137
+ alive.current = true
138
+ let previous = null, scheduled = false, stopped = false
139
+ const sync = () => {
140
+ scheduled = false
141
+ if (stopped) return
142
+ const section = document.querySelector('[data-slot="sidebar.recent.section"]')
143
+ const header = section?.firstElementChild
144
+ if (previous !== header) { previous?.removeAttribute('data-pc-header'); header?.setAttribute('data-pc-header', ''); previous = header; setAnchor(header || null) }
145
+ }
146
+ const observer = new MutationObserver(() => { if (!scheduled) { scheduled = true; queueMicrotask(sync) } })
147
+ observer.observe(document.body, { childList: true, subtree: true })
148
+ sync()
149
+ return () => { stopped = true; alive.current = false; observer.disconnect(); previous?.removeAttribute('data-pc-header') }
150
+ }, [])
151
+ async function create() {
152
+ if (pending.current) return
153
+ pending.current = true; setBusy(true); setError('')
154
+ try {
155
+ if (!created.current) {
156
+ const { cwd } = await request({ action: 'allocate' })
157
+ created.current = await ctx.sessions.create({ cwd })
158
+ }
159
+ ctx.uiWorkspace.openSession(created.current)
160
+ created.current = null
161
+ } catch (err) { if (alive.current) setError(err.message) }
162
+ finally { pending.current = false; if (alive.current) setBusy(false) }
163
+ }
164
+ const button = h('button', { type: 'button', className: 'pc-new', 'data-projectless-new': '', 'aria-label': busy ? t('creating') : t('create'), title: t('create'), disabled: busy, onClick: create },
165
+ h('svg', { width: 18, height: 18, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.5, 'aria-hidden': true }, h('path', { d: 'M12 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-6M16 3l5 5M10 14l1-4 8-8 4 4-8 8-5 1Z' })))
166
+ return h(React.Fragment, null, anchor ? createPortal(button, anchor) : wide ? h('button', { type: 'button', className: 'pc-button', disabled: busy, onClick: create }, busy ? t('creating') : t('create')) : null,
167
+ error ? createPortal(h('div', { className: 'pc-failure', role: 'alert' }, h('p', { className: 'pc-error' }, t('error') + error), h('button', { type: 'button', className: 'pc-button', onClick: create }, t('retry')), h('button', { type: 'button', className: 'pc-button', onClick: () => setError('') }, t('close'))), document.body) : null)
168
+ }
169
+ return {
170
+ inject: ['slots', 'locale', 'sessions', 'uiWorkspace'],
171
+ apply(ctx) {
172
+ ctx.effect(() => installIndependentComposer(ctx), 'projectless-chat: native independent composer')
173
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'projectless-chat: translations')
174
+ ctx.effect(() => { const tag = document.createElement('style'); tag.dataset.plugin = 'dsh-asuta-projectless-chat'; tag.dataset.pluginCss = NS; tag.textContent = CSS; document.head.appendChild(tag); return () => tag.remove() }, 'projectless-chat: styles')
175
+ ctx.effect(() => ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({ name: 'sidebar.footer.action', id: 'projectless-chat.create', order: 31, locale: NS }, props => h(Launcher, { ...props, ctx }))), 'projectless-chat: recent launcher')
176
+ ctx.effect(() => ctx.slots.inject('settings.general.item', () => ctx.slots.register({ name: 'settings.general.item', id: 'projectless-chat.directory', order: 80, locale: NS }, props => h(DirectorySetting, { ...props, ctx }))), 'projectless-chat: directory preference')
177
+ },
178
+ }
179
+ },
180
+ })
@@ -0,0 +1,4 @@
1
+ - insert:
2
+ - id: projectless-chat
3
+ name: 'dsh-asuta-projectless-chat'
4
+ config: {}
package/core.js ADDED
@@ -0,0 +1,76 @@
1
+ import fs from 'node:fs/promises'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { createHash, randomUUID } from 'node:crypto'
5
+
6
+ const fault = (code, message) => Object.assign(new Error(message), { code })
7
+ const digest = text => createHash('sha256').update(text).digest('hex')
8
+ export function defaultHome(env = process.env) {
9
+ const home = env.DSH_HOME?.trim() || path.join(os.homedir(), '.dsh')
10
+ return path.resolve(home === '~' ? os.homedir() : home.startsWith('~' + path.sep) ? path.join(os.homedir(), home.slice(2)) : home)
11
+ }
12
+ export function createDirectorySettings({ home = defaultHome(), defaultRoot = path.join(os.homedir(), 'Documents', 'DSH'), clock = () => new Date() } = {}) {
13
+ const file = path.join(home, 'plugin-data', 'projectless-chat', 'settings.json')
14
+ let tail = Promise.resolve()
15
+ const serial = operation => { const next = tail.then(operation); tail = next.catch(() => {}); return next }
16
+ function normalize(value) {
17
+ if (typeof value !== 'string' || !value.trim() || value.length > 4096 || [...value].some(c => c.charCodeAt(0) < 32)) throw fault('INVALID_PATH', '请输入有效的绝对目录路径。')
18
+ const root = value.trim()
19
+ const parsed = path.parse(root)
20
+ if (!path.isAbsolute(root) || (process.platform === 'win32' && parsed.root.length < 3)) throw fault('INVALID_PATH', '请使用完整的绝对目录路径。')
21
+ return path.normalize(root)
22
+ }
23
+ async function read() {
24
+ let text
25
+ try { text = await fs.readFile(file, 'utf8') } catch (error) { if (error.code !== 'ENOENT') throw error }
26
+ const saved = text === undefined ? null : JSON.parse(text)
27
+ if (saved !== null && (saved.version !== 1 || !(saved.root === null || typeof saved.root === 'string'))) throw fault('INVALID_SETTINGS', '独立对话目录配置损坏,请检查设置文件。')
28
+ const root = saved?.root === null || saved === null ? defaultRoot : normalize(saved.root)
29
+ return { root, defaultRoot, isDefault: saved?.root == null, revision: digest(text ?? 'missing') }
30
+ }
31
+ async function ensureWritable(root) {
32
+ await fs.mkdir(root, { recursive: true })
33
+ if (!(await fs.stat(root)).isDirectory()) throw fault('INVALID_PATH', '所选路径不是文件夹。')
34
+ const probe = path.join(root, '.dsh-write-check-' + randomUUID())
35
+ await fs.writeFile(probe, '', { flag: 'wx', mode: 0o600 })
36
+ await fs.unlink(probe)
37
+ }
38
+ function save({ root, revision }) {
39
+ return serial(async () => {
40
+ const previous = await read()
41
+ if (revision !== previous.revision) throw fault('CONFLICT', '设置已在其他页面更改,请重新读取后保存。')
42
+ const chosen = root === null ? defaultRoot : normalize(root)
43
+ await ensureWritable(chosen)
44
+ await fs.mkdir(path.dirname(file), { recursive: true })
45
+ const temporary = file + '.' + randomUUID() + '.tmp'
46
+ try {
47
+ await fs.writeFile(temporary, JSON.stringify({ version: 1, root: root === null ? null : chosen }, null, 2), { flag: 'wx', mode: 0o600 })
48
+ await fs.rename(temporary, file)
49
+ } finally { await fs.rm(temporary, { force: true }) }
50
+ return read()
51
+ })
52
+ }
53
+ function allocate() {
54
+ return serial(async () => {
55
+ const settings = await read()
56
+ await ensureWritable(settings.root)
57
+ const now = clock()
58
+ const date = [now.getFullYear(), String(now.getMonth() + 1).padStart(2, '0'), String(now.getDate()).padStart(2, '0')].join('-')
59
+ const parent = path.join(settings.root, date)
60
+ await fs.mkdir(parent, { recursive: true })
61
+ const cwd = await fs.mkdtemp(path.join(parent, 'new-chat-'))
62
+ return { cwd }
63
+ })
64
+ }
65
+ async function browse(value) {
66
+ const root = value === undefined ? os.homedir() : normalize(value)
67
+ const items = await fs.readdir(root, { withFileTypes: true })
68
+ const directories = items.filter(item => item.isDirectory()).sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }))
69
+ const base = path.parse(root).root
70
+ const crumbs = [{ name: base, path: base }]
71
+ let current = base
72
+ for (const part of path.relative(base, root).split(path.sep).filter(Boolean)) { current = path.join(current, part); crumbs.push({ name: part, path: current }) }
73
+ return { path: root, crumbs, entries: directories.slice(0, 200).map(item => ({ name: item.name, path: path.join(root, item.name) })), truncated: directories.length > 200 }
74
+ }
75
+ return { read, save, allocate, browse }
76
+ }
package/icon.svg ADDED
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M12 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-6M16 4l4 4M10 14l1-4 8-8 4 4-8 8-5 1Z"/></svg>
package/index.js ADDED
@@ -0,0 +1,30 @@
1
+ import { createDirectorySettings } from './core.js'
2
+ export const inject = ['webServer', 'connection']
3
+ function respond(res, status, value) {
4
+ res.statusCode = status
5
+ res.setHeader('content-type', 'application/json; charset=utf-8')
6
+ res.setHeader('cache-control', 'no-store')
7
+ res.end(JSON.stringify(value))
8
+ }
9
+ export function apply(ctx) {
10
+ const settings = createDirectorySettings()
11
+ ctx.effect(() => ctx.webServer.register({
12
+ kind: 'exact', path: '/projectless-chat/api',
13
+ async handler(req, res) {
14
+ const rejection = ctx.connection.requestRejection(req)
15
+ if (rejection !== undefined) return respond(res, rejection, { ok: false, error: '需要 DSH 身份验证。' })
16
+ if (req.method !== 'POST') return respond(res, 405, { ok: false, error: 'POST only' })
17
+ if (req.headers['x-projectless-chat'] !== '1' || (req.headers.origin && req.headers.origin !== ctx.webServer.getOrigin())) return respond(res, 403, { ok: false, error: '不允许跨站请求。' })
18
+ try {
19
+ const chunks = []; let size = 0
20
+ for await (const chunk of req) { size += chunk.length; if (size > 32768) throw new Error('请求过大。'); chunks.push(chunk) }
21
+ const args = JSON.parse(Buffer.concat(chunks).toString('utf8'))
22
+ if (args.action === 'read') return respond(res, 200, { ok: true, ...await settings.read() })
23
+ if (args.action === 'save') return respond(res, 200, { ok: true, ...await settings.save(args) })
24
+ if (args.action === 'allocate') return respond(res, 200, { ok: true, ...await settings.allocate() })
25
+ if (args.action === 'browse') return respond(res, 200, { ok: true, ...await settings.browse(args.path) })
26
+ throw new Error('未知操作。')
27
+ } catch (error) { respond(res, error.code === 'CONFLICT' ? 409 : 400, { ok: false, code: error.code, error: error.message }) }
28
+ },
29
+ }), 'projectless-chat: authenticated directory settings')
30
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Independent conversations",
4
+ "description": "Create conversations outside existing workspaces and configure their default working directory."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "独立对话",
4
+ "description": "从最近列表新建不绑定工作区的对话,可在通用设置更改默认工作目录。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,63 @@
1
1
  {
2
2
  "name": "dsh-asuta-projectless-chat",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.2",
4
+ "type": "module",
5
+ "description": "Create independent conversations outside existing workspaces.",
6
+ "main": "index.js",
7
+ "icon": "./icon.svg",
8
+ "exports": {
9
+ ".": "./index.js",
10
+ "./client": "./client.js",
11
+ "./package.json": "./package.json",
12
+ "./locale/*.json": "./locale/*.json",
13
+ "./cordis.patch.yml": "./cordis.patch.yml"
14
+ },
15
+ "files": [
16
+ "index.js",
17
+ "core.js",
18
+ "client.js",
19
+ "cordis.patch.yml",
20
+ "icon.svg",
21
+ "locale/*.json",
22
+ "README.md",
23
+ "LICENSE*",
24
+ "NOTICE*",
25
+ "THIRD_PARTY_NOTICES.md"
26
+ ],
27
+ "dsh": {
28
+ "bundle": {
29
+ "patch": "./cordis.patch.yml"
30
+ },
31
+ "client": {
32
+ "platform": "web",
33
+ "immediately": true,
34
+ "inject": [
35
+ "@deepseek-ai/dsh-client-ui-workspace",
36
+ "@deepseek-ai/dsh-client-ui-settings-general",
37
+ "@deepseek-ai/dsh-client-ui-slots",
38
+ "@deepseek-ai/dsh-client-locale"
39
+ ]
40
+ }
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/Asuta/dsh-cdx-plus.git",
48
+ "directory": "packages/projectless-chat"
49
+ },
50
+ "homepage": "https://github.com/Asuta/dsh-cdx-plus/tree/main/packages/projectless-chat",
51
+ "bugs": {
52
+ "url": "https://github.com/Asuta/dsh-cdx-plus/issues"
53
+ },
54
+ "keywords": [
55
+ "dsh-plugin",
56
+ "deepseek-harness"
57
+ ],
58
+ "engines": {
59
+ "node": "^22.19.0 || >=24",
60
+ "dsh": "0.2.2-rc.2"
61
+ },
62
+ "license": "MIT"
63
+ }