dsh-db-studio 0.0.0-stage → 1.0.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/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.en.md +94 -0
- package/README.md +91 -2
- package/cordis.patch.yml +11 -0
- package/docs/conversation.png +0 -0
- package/docs/demo.gif +0 -0
- package/docs/panel.png +0 -0
- package/lib/client.js +2313 -0
- package/lib/drivers.js +456 -0
- package/lib/home.js +38 -0
- package/lib/index.js +481 -0
- package/lib/service.js +448 -0
- package/lib/session-store.js +223 -0
- package/lib/store.js +203 -0
- package/package.json +69 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-db-studio — Cordis entry (host half).
|
|
3
|
+
*
|
|
4
|
+
* Exposes two independent surfaces, both optional so a headless or non-HTTP
|
|
5
|
+
* assembly still gets the other:
|
|
6
|
+
*
|
|
7
|
+
* - `POST /dsh-db-studio/api` for the browser panel (overview, CRUD, rows,
|
|
8
|
+
* SQL).
|
|
9
|
+
* - model-facing tools `db_list_connections`, `db_describe_schema`,
|
|
10
|
+
* `db_query`, `db_execute`.
|
|
11
|
+
*
|
|
12
|
+
* The database work lives in `service.js`; this file only wires transports,
|
|
13
|
+
* config and disposal. Host packages (`@deepseek-ai/dsh-tools`, `webServer`)
|
|
14
|
+
* are probed through `optionalHostImport`, the same pattern dsh-bill uses, so
|
|
15
|
+
* a `link:`ed checkout resolves them from the harness entry point.
|
|
16
|
+
*
|
|
17
|
+
* @module dsh-db-studio
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { createStudio, DEFAULT_CONFIG } from './service.js'
|
|
21
|
+
|
|
22
|
+
const ROUTE = '/dsh-db-studio/api'
|
|
23
|
+
const MAX_BODY = 4 * 1024 * 1024
|
|
24
|
+
|
|
25
|
+
/** One issue in the Standard Schema config vocabulary. */
|
|
26
|
+
const issue = (message, ...path) => ({ message, path: path.length ? path : undefined })
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Plugin config as a Standard Schema.
|
|
30
|
+
*
|
|
31
|
+
* Defaults are materialized here, before the fiber starts, so `apply` always
|
|
32
|
+
* receives a complete object and a typo is refused with its path named.
|
|
33
|
+
*/
|
|
34
|
+
export const Config = {
|
|
35
|
+
'~standard': {
|
|
36
|
+
version: 1,
|
|
37
|
+
vendor: 'dsh-db-studio',
|
|
38
|
+
validate(input) {
|
|
39
|
+
if (input === undefined || input === null) return { value: { ...DEFAULT_CONFIG } }
|
|
40
|
+
if (typeof input !== 'object' || Array.isArray(input)) return { issues: [issue('expected an object')] }
|
|
41
|
+
const issues = []
|
|
42
|
+
const value = {}
|
|
43
|
+
const booleans = ['demo', 'allowWrite', 'agentTools']
|
|
44
|
+
for (const key of booleans) {
|
|
45
|
+
const raw = input[key]
|
|
46
|
+
if (raw === undefined) value[key] = DEFAULT_CONFIG[key]
|
|
47
|
+
else if (typeof raw !== 'boolean') issues.push(issue('expected a boolean', key))
|
|
48
|
+
else value[key] = raw
|
|
49
|
+
}
|
|
50
|
+
const maxRows = input.maxRows
|
|
51
|
+
if (maxRows === undefined) value.maxRows = DEFAULT_CONFIG.maxRows
|
|
52
|
+
else if (typeof maxRows !== 'number' || !Number.isInteger(maxRows) || maxRows <= 0) issues.push(issue('expected a positive integer', 'maxRows'))
|
|
53
|
+
else value.maxRows = maxRows
|
|
54
|
+
const { dshHome } = input
|
|
55
|
+
if (dshHome !== undefined) {
|
|
56
|
+
if (typeof dshHome !== 'string' || !dshHome.trim()) issues.push(issue('expected a non-empty path', 'dshHome'))
|
|
57
|
+
else value.dshHome = dshHome
|
|
58
|
+
}
|
|
59
|
+
return issues.length ? { issues } : { value }
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Import a package that exists only inside the harness graph. */
|
|
65
|
+
async function optionalHostImport(specifier) {
|
|
66
|
+
try {
|
|
67
|
+
return await import(specifier)
|
|
68
|
+
} catch {
|
|
69
|
+
const entry = process.argv[1]
|
|
70
|
+
if (typeof entry !== 'string' || entry === '') return null
|
|
71
|
+
try {
|
|
72
|
+
const { createRequire } = await import('node:module')
|
|
73
|
+
const { pathToFileURL } = await import('node:url')
|
|
74
|
+
const resolved = createRequire(entry).resolve(specifier)
|
|
75
|
+
return await import(pathToFileURL(resolved).href)
|
|
76
|
+
} catch {
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Read a small JSON body. */
|
|
83
|
+
function readBody(req) {
|
|
84
|
+
return new Promise((resolve, reject) => {
|
|
85
|
+
let data = ''
|
|
86
|
+
req.on('data', (chunk) => {
|
|
87
|
+
data += chunk
|
|
88
|
+
if (data.length > MAX_BODY) {
|
|
89
|
+
req.destroy()
|
|
90
|
+
reject(new Error('请求体过大 / request body too large'))
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
req.on('end', () => {
|
|
94
|
+
if (!data.trim()) return resolve({})
|
|
95
|
+
try {
|
|
96
|
+
resolve(JSON.parse(data))
|
|
97
|
+
} catch {
|
|
98
|
+
reject(new Error('请求体不是合法 JSON / invalid JSON body'))
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
req.on('error', reject)
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Format a cell for a markdown table. */
|
|
106
|
+
function cell(value) {
|
|
107
|
+
if (value === null || value === undefined) return 'NULL'
|
|
108
|
+
let text = typeof value === 'object' ? JSON.stringify(value) : String(value)
|
|
109
|
+
if (text === '') return ' '
|
|
110
|
+
text = text.replaceAll('|', '\\|').replaceAll('\n', ' ')
|
|
111
|
+
return text.length > 120 ? text.slice(0, 117) + '...' : text
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Render columns/rows as a markdown table, capped for the transcript. */
|
|
115
|
+
function markdownTable(columns, rows, maxRows = 20) {
|
|
116
|
+
const cols = Array.isArray(columns) && columns.length ? columns : Object.keys(rows?.[0] ?? {})
|
|
117
|
+
if (cols.length === 0 || !rows?.length) return ''
|
|
118
|
+
const head = `| ${cols.map((name) => String(name).replaceAll('|', '\\|')).join(' | ')} |`
|
|
119
|
+
const sep = `| ${cols.map(() => '---').join(' | ')} |`
|
|
120
|
+
const body = rows.slice(0, maxRows).map((row) => `| ${cols.map((name) => cell(row?.[name])).join(' | ')} |`)
|
|
121
|
+
const more = rows.length > maxRows ? `\n_… 还有 ${rows.length - maxRows} 行未显示_` : ''
|
|
122
|
+
return [head, sep, ...body].join('\n') + more
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The calling Session, when a tool was invoked from an agent turn. */
|
|
126
|
+
function sessionOf(exec) {
|
|
127
|
+
const session = exec?.agent?.session
|
|
128
|
+
return session === undefined ? null : { id: session.id, cwd: session.header?.cwd }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Resolve a connection id for a tool call.
|
|
133
|
+
*
|
|
134
|
+
* The conversation's selection is the authorization boundary: a Session with
|
|
135
|
+
* no databases selected is refused outright, and an explicit connectionId
|
|
136
|
+
* outside the selection is refused too. Only the IDE-side panel (no Session)
|
|
137
|
+
* sees every connection.
|
|
138
|
+
*/
|
|
139
|
+
async function resolveConnectionId(studio, requested, session) {
|
|
140
|
+
const overview = await studio.listConnections()
|
|
141
|
+
const rows = overview.connections
|
|
142
|
+
const find = (id) => rows.find((row) => row.id === id)
|
|
143
|
+
if (!session) {
|
|
144
|
+
if (requested) {
|
|
145
|
+
const found = find(requested)
|
|
146
|
+
if (!found) throw new Error(`找不到连接 "${requested}"。可用连接:${rows.map((row) => `${row.id} (${row.name})`).join(', ') || '无'}`)
|
|
147
|
+
return found.id
|
|
148
|
+
}
|
|
149
|
+
if (rows.length === 1) return rows[0].id
|
|
150
|
+
if (rows.length === 0) throw new Error('还没有配置数据库连接。请让用户在「数据库」侧边栏面板新建连接后再试。')
|
|
151
|
+
throw new Error(`有多个数据库连接,请用 connectionId 指定:${rows.map((row) => `${row.id} (${row.name})`).join(', ')}`)
|
|
152
|
+
}
|
|
153
|
+
const state = await studio.sessionState(session.id, session.cwd)
|
|
154
|
+
const selected = state.selectedIds
|
|
155
|
+
if (selected.length === 0) {
|
|
156
|
+
throw new Error('当前会话还没有选择数据库。请让用户在输入框旁的多选下拉里选择数据库后再试。')
|
|
157
|
+
}
|
|
158
|
+
if (requested) {
|
|
159
|
+
const found = find(requested)
|
|
160
|
+
if (!found) throw new Error(`找不到连接 "${requested}"。当前会话可选:${selected.map((id) => `${id} (${find(id)?.name ?? id})`).join(', ')}`)
|
|
161
|
+
if (!selected.includes(requested)) {
|
|
162
|
+
throw new Error(`数据库 "${requested}" 不在当前会话的选择范围内。当前已选:${selected.map((id) => `${id} (${find(id)?.name ?? id})`).join(', ')}`)
|
|
163
|
+
}
|
|
164
|
+
return found.id
|
|
165
|
+
}
|
|
166
|
+
if (selected.length === 1) return selected[0]
|
|
167
|
+
throw new Error(`当前会话选中了多个数据库,请用 connectionId 指定:${selected.map((id) => `${id} (${find(id)?.name ?? id})`).join(', ')}`)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Build the four model-facing tools. */
|
|
171
|
+
function buildToolDefinitions(studio) {
|
|
172
|
+
return [
|
|
173
|
+
{
|
|
174
|
+
name: 'db_list_connections',
|
|
175
|
+
description: 'List the database connections configured in DSH DB Studio, with their ids, kinds and targets, and mark which ones are selected for this conversation. '
|
|
176
|
+
+ 'Call this first when the user asks about data but you do not know which connection to use. Only selected connections may be queried. Passwords are never returned.',
|
|
177
|
+
parameters: {},
|
|
178
|
+
output: {
|
|
179
|
+
schema: {
|
|
180
|
+
type: 'object',
|
|
181
|
+
properties: {
|
|
182
|
+
summary: { type: 'string' },
|
|
183
|
+
connections: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
184
|
+
drivers: { type: 'object', additionalProperties: true },
|
|
185
|
+
},
|
|
186
|
+
additionalProperties: true,
|
|
187
|
+
},
|
|
188
|
+
render: (_args, value) => [{ type: 'text', text: value?.summary ?? 'No connections.' }],
|
|
189
|
+
},
|
|
190
|
+
async execute(_args, exec) {
|
|
191
|
+
const session = sessionOf(exec)
|
|
192
|
+
const overview = await studio.listConnections()
|
|
193
|
+
const rows = overview.connections
|
|
194
|
+
let selectedIds = rows.map((row) => row.id)
|
|
195
|
+
if (session) {
|
|
196
|
+
const state = await studio.sessionState(session.id, session.cwd)
|
|
197
|
+
selectedIds = state.selectedIds
|
|
198
|
+
}
|
|
199
|
+
const marked = rows.map((row) => ({ ...row, selected: selectedIds.includes(row.id) }))
|
|
200
|
+
const selectedRows = marked.filter((row) => row.selected)
|
|
201
|
+
const summary = rows.length === 0
|
|
202
|
+
? '还没有配置数据库连接。'
|
|
203
|
+
: (selectedRows.length === 0
|
|
204
|
+
? '当前会话还没有选择数据库。请让用户先在输入框旁的数据库多选下拉里选择。\n'
|
|
205
|
+
: `当前会话已选择 ${selectedRows.length} 个数据库:\n` + selectedRows.map((row) => `- \`${row.id}\` · ${row.name} · ${row.kind}${row.demo ? '(示例)' : ''}`).join('\n') + '\n\n')
|
|
206
|
+
+ `全部 ${rows.length} 个连接:\n` + marked.map((row) => `- \`${row.id}\` · ${row.name} · ${row.kind}${row.selected ? ' ✓ 已选' : ''}`).join('\n')
|
|
207
|
+
return { summary, connections: marked, selectedIds, drivers: overview.drivers }
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
name: 'db_describe_schema',
|
|
212
|
+
description: 'Inspect a database schema. Without `table`, lists every table and view. '
|
|
213
|
+
+ 'With `table`, returns that table\'s columns (type, nullability, default, primary key) and row count. '
|
|
214
|
+
+ 'Use it before writing SQL so column names and types are correct.',
|
|
215
|
+
parameters: {
|
|
216
|
+
connectionId: { type: 'string', description: 'Connection id from db_list_connections. Optional when the conversation has exactly one selected database; only selected databases are allowed.' },
|
|
217
|
+
table: { type: 'string', description: "Table name, optionally schema-qualified (e.g. 'users' or 'public.users'). Omit to list tables." },
|
|
218
|
+
},
|
|
219
|
+
output: {
|
|
220
|
+
schema: {
|
|
221
|
+
type: 'object',
|
|
222
|
+
properties: {
|
|
223
|
+
summary: { type: 'string' },
|
|
224
|
+
tables: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
225
|
+
table: { type: 'string' },
|
|
226
|
+
columns: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
227
|
+
primaryKey: { type: 'array', items: { type: 'string' } },
|
|
228
|
+
rowCount: { type: 'number' },
|
|
229
|
+
},
|
|
230
|
+
additionalProperties: true,
|
|
231
|
+
},
|
|
232
|
+
render: (_args, value) => [{ type: 'text', text: value?.summary ?? '' }],
|
|
233
|
+
},
|
|
234
|
+
async execute(args, exec) {
|
|
235
|
+
const session = sessionOf(exec)
|
|
236
|
+
const id = await resolveConnectionId(studio, args?.connectionId, session)
|
|
237
|
+
const schema = await studio.schema(id, args?.table, session ? { sessionId: session.id, cwd: session.cwd } : {})
|
|
238
|
+
if (schema.tables) {
|
|
239
|
+
const list = schema.tables.map((row) => `- ${row.schema ? `${row.schema}.` : ''}${row.name} (${row.type})`)
|
|
240
|
+
return {
|
|
241
|
+
summary: `连接 \`${id}\` 中共有 ${schema.tables.length} 张表/视图:\n${list.join('\n') || '(空)'}`,
|
|
242
|
+
tables: schema.tables,
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const lines = schema.columns.map((column) => {
|
|
246
|
+
const flags = [column.primaryKey ? 'PK' : '', column.notNull ? 'NOT NULL' : ''].filter(Boolean).join(' ')
|
|
247
|
+
return `- ${column.name} ${column.type}${flags ? ` — ${flags}` : ''}${column.defaultValue != null ? ` default ${column.defaultValue}` : ''}`
|
|
248
|
+
})
|
|
249
|
+
return {
|
|
250
|
+
summary: `表 \`${schema.table}\`(${schema.rowCount} 行)的字段:\n${lines.join('\n')}`,
|
|
251
|
+
table: schema.table,
|
|
252
|
+
columns: schema.columns,
|
|
253
|
+
primaryKey: schema.primaryKey,
|
|
254
|
+
rowCount: schema.rowCount,
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
name: 'db_query',
|
|
260
|
+
description: 'Run a READ-ONLY SQL query (SELECT / WITH / EXPLAIN / SHOW / PRAGMA) against a database selected for this conversation and return columns and rows. '
|
|
261
|
+
+ 'Only the conversation\'s selected databases are allowed. Writes are rejected; use db_execute for INSERT/UPDATE/DELETE/DDL. '
|
|
262
|
+
+ 'Results are capped by the plugin\'s maxRows; aggregate in SQL rather than fetching everything. '
|
|
263
|
+
+ 'Use the exact table and column names returned by db_describe_schema.',
|
|
264
|
+
parameters: {
|
|
265
|
+
connectionId: { type: 'string', description: 'Connection id from db_list_connections. Optional when the conversation has exactly one selected database; only selected databases are allowed.' },
|
|
266
|
+
sql: { type: 'string', required: true, description: 'One read-only SQL statement.' },
|
|
267
|
+
limit: { type: 'number', description: 'Maximum rows to return (default 200, capped by plugin config).' },
|
|
268
|
+
},
|
|
269
|
+
output: {
|
|
270
|
+
schema: {
|
|
271
|
+
type: 'object',
|
|
272
|
+
properties: {
|
|
273
|
+
summary: { type: 'string' },
|
|
274
|
+
columns: { type: 'array', items: { type: 'string' } },
|
|
275
|
+
rows: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
276
|
+
rowCount: { type: 'number' },
|
|
277
|
+
truncated: { type: 'boolean' },
|
|
278
|
+
elapsedMs: { type: 'number' },
|
|
279
|
+
},
|
|
280
|
+
additionalProperties: true,
|
|
281
|
+
},
|
|
282
|
+
render: (_args, value) => [{ type: 'text', text: value?.summary ?? '' }],
|
|
283
|
+
},
|
|
284
|
+
async execute(args, exec) {
|
|
285
|
+
const session = sessionOf(exec)
|
|
286
|
+
const id = await resolveConnectionId(studio, args?.connectionId, session)
|
|
287
|
+
const result = await studio.query(id, args?.sql, { limit: args?.limit, ...(session ? { sessionId: session.id, cwd: session.cwd } : {}) })
|
|
288
|
+
const rows = result.rows ?? []
|
|
289
|
+
const summaryParts = [`查询成功,返回 ${rows.length} 行${result.truncated ? '(已截断)' : ''},耗时 ${result.elapsedMs} ms。`]
|
|
290
|
+
const table = markdownTable(result.columns, rows, 25)
|
|
291
|
+
if (table) summaryParts.push(table)
|
|
292
|
+
else summaryParts.push('(没有数据行)')
|
|
293
|
+
return {
|
|
294
|
+
summary: summaryParts.join('\n'),
|
|
295
|
+
columns: result.columns,
|
|
296
|
+
rows,
|
|
297
|
+
rowCount: rows.length,
|
|
298
|
+
truncated: result.truncated === true,
|
|
299
|
+
elapsedMs: result.elapsedMs,
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
name: 'db_execute',
|
|
305
|
+
description: 'Run a single write statement (INSERT / UPDATE / DELETE / CREATE / ALTER / DROP) against a database selected for this conversation. '
|
|
306
|
+
+ 'Only the conversation\'s selected databases are allowed. Requires `confirm: true`; only set it after the user has clearly asked for the change. '
|
|
307
|
+
+ 'Prefer a targeted WHERE clause, and report the affected row count afterwards. '
|
|
308
|
+
+ 'Rejected when the connection is read-only or the plugin config disables writes.',
|
|
309
|
+
parameters: {
|
|
310
|
+
connectionId: { type: 'string', description: 'Connection id from db_list_connections. Optional when the conversation has exactly one selected database; only selected databases are allowed.' },
|
|
311
|
+
sql: { type: 'string', required: true, description: 'One write statement.' },
|
|
312
|
+
confirm: { type: 'boolean', required: true, description: 'Explicitly confirm the write. Set true only when the user asked for this change.' },
|
|
313
|
+
},
|
|
314
|
+
output: {
|
|
315
|
+
schema: {
|
|
316
|
+
type: 'object',
|
|
317
|
+
properties: {
|
|
318
|
+
summary: { type: 'string' },
|
|
319
|
+
changes: { type: 'number' },
|
|
320
|
+
lastInsertRowid: { type: 'string' },
|
|
321
|
+
elapsedMs: { type: 'number' },
|
|
322
|
+
},
|
|
323
|
+
additionalProperties: true,
|
|
324
|
+
},
|
|
325
|
+
render: (_args, value) => [{ type: 'text', text: value?.summary ?? '' }],
|
|
326
|
+
},
|
|
327
|
+
async execute(args, exec) {
|
|
328
|
+
if (args?.confirm !== true) throw new Error('写操作需要显式确认:请先在对话中得到用户同意,再以 confirm: true 重试。')
|
|
329
|
+
const session = sessionOf(exec)
|
|
330
|
+
const id = await resolveConnectionId(studio, args?.connectionId, session)
|
|
331
|
+
const result = await studio.execute(id, args?.sql, session ? { sessionId: session.id, cwd: session.cwd } : {})
|
|
332
|
+
const affected = result.changes ?? 0
|
|
333
|
+
return {
|
|
334
|
+
summary: `执行成功:影响 ${affected} 行${result.lastInsertRowid != null ? `,最后插入 id = ${result.lastInsertRowid}` : ''},耗时 ${result.elapsedMs ?? 0} ms。`,
|
|
335
|
+
changes: affected,
|
|
336
|
+
lastInsertRowid: result.lastInsertRowid == null ? '' : String(result.lastInsertRowid),
|
|
337
|
+
elapsedMs: result.elapsedMs ?? 0,
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
]
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Dispatch one HTTP request body. */
|
|
345
|
+
async function dispatch(studio, body, options = {}) {
|
|
346
|
+
const action = body?.action
|
|
347
|
+
if (!action) throw new Error('缺少 action / missing action')
|
|
348
|
+
const sessionId = typeof body.sessionId === 'string' && body.sessionId ? body.sessionId : undefined
|
|
349
|
+
const cwd = sessionId ? (body.cwd ?? options.cwdOf?.(sessionId)) : undefined
|
|
350
|
+
const scoped = sessionId ? { sessionId, cwd } : {}
|
|
351
|
+
switch (action) {
|
|
352
|
+
case 'overview':
|
|
353
|
+
case 'list':
|
|
354
|
+
return { ok: true, ...(await studio.listConnections()) }
|
|
355
|
+
case 'session-state':
|
|
356
|
+
return { ok: true, ...(await studio.sessionState(sessionId, cwd)) }
|
|
357
|
+
case 'session-set':
|
|
358
|
+
return { ok: true, ...(await studio.setSessionSelection(sessionId, body.ids, cwd)) }
|
|
359
|
+
case 'session-status':
|
|
360
|
+
return { ok: true, ...(await studio.sessionStatus(sessionId, cwd)) }
|
|
361
|
+
case 'tables':
|
|
362
|
+
return { ok: true, ...(await studio.schema(body.id, undefined, scoped)) }
|
|
363
|
+
case 'describe':
|
|
364
|
+
return { ok: true, ...(await studio.schema(body.id, body.table, scoped)) }
|
|
365
|
+
case 'rows':
|
|
366
|
+
return { ok: true, ...(await studio.rows(body.id, body.table, { limit: body.limit, offset: body.offset, ...scoped })) }
|
|
367
|
+
case 'query':
|
|
368
|
+
return { ok: true, ...(await studio.query(body.id, body.sql, { limit: body.limit, ...scoped })) }
|
|
369
|
+
case 'execute':
|
|
370
|
+
return { ok: true, ...(await studio.execute(body.id, body.sql, scoped)) }
|
|
371
|
+
case 'test':
|
|
372
|
+
return { ok: true, ...(await studio.testConnection(body.connection ?? body)) }
|
|
373
|
+
case 'add':
|
|
374
|
+
return { ok: true, connection: await studio.addConnection(body.connection ?? body) }
|
|
375
|
+
case 'update':
|
|
376
|
+
return { ok: true, connection: await studio.updateConnection(body.id, body.patch ?? {}) }
|
|
377
|
+
case 'remove':
|
|
378
|
+
return { ok: true, removed: await studio.removeConnection(body.id) }
|
|
379
|
+
case 'seed-demo':
|
|
380
|
+
return { ok: true, ...(await studio.seedDemo()) }
|
|
381
|
+
case 'ping':
|
|
382
|
+
return { ok: true, time: Date.now() }
|
|
383
|
+
default:
|
|
384
|
+
throw new Error(`未知操作 / unknown action: ${action}`)
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Cordis plugin entry. */
|
|
389
|
+
export default {
|
|
390
|
+
name: 'dsh-db-studio',
|
|
391
|
+
Config,
|
|
392
|
+
inject: [],
|
|
393
|
+
apply(ctx, config = null) {
|
|
394
|
+
const cfg = config ?? { ...DEFAULT_CONFIG }
|
|
395
|
+
const studio = createStudio({ dshHome: cfg.dshHome, config: cfg })
|
|
396
|
+
|
|
397
|
+
// Kick the load/demo seed off now; individual requests await the same
|
|
398
|
+
// promise, so the first request after boot cannot race it.
|
|
399
|
+
studio.ready().catch((error) => {
|
|
400
|
+
console.warn('dsh-db-studio: initialization failed —', error?.message ?? error)
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
ctx.effect(() => () => studio.dispose().catch(() => {}), 'dsh-db-studio: adapters')
|
|
404
|
+
|
|
405
|
+
// Live Session lookup for workspace-aware defaults. `sessions` may not be
|
|
406
|
+
// mounted in every assembly, so this is a probe rather than an inject.
|
|
407
|
+
const cwdOf = (sessionId) => {
|
|
408
|
+
try {
|
|
409
|
+
const sessions = ctx.get('sessions')
|
|
410
|
+
return sessions?.get?.(sessionId)?.header?.cwd
|
|
411
|
+
} catch {
|
|
412
|
+
return undefined
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ── HTTP carrier for the browser panel ────────────────────────────────
|
|
417
|
+
ctx.inject(['webServer'], (child) => {
|
|
418
|
+
child.effect(() => child.webServer.register({
|
|
419
|
+
kind: 'exact',
|
|
420
|
+
path: ROUTE,
|
|
421
|
+
handler: async (req, res) => {
|
|
422
|
+
const send = (status, payload) => {
|
|
423
|
+
res.writeHead(status, {
|
|
424
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
425
|
+
'Cache-Control': 'no-store',
|
|
426
|
+
})
|
|
427
|
+
res.end(JSON.stringify(payload))
|
|
428
|
+
}
|
|
429
|
+
// Only same-origin pages may talk to this route. A cross-origin
|
|
430
|
+
// fetch that uses a "simple" content type (text/plain) would skip
|
|
431
|
+
// preflight and could otherwise write data without reading a reply.
|
|
432
|
+
const origin = req.headers?.origin
|
|
433
|
+
if (typeof origin === 'string' && origin !== '') {
|
|
434
|
+
let sameOrigin = false
|
|
435
|
+
try { sameOrigin = new URL(origin).host === req.headers.host } catch { sameOrigin = false }
|
|
436
|
+
if (!sameOrigin) {
|
|
437
|
+
send(403, { ok: false, error: 'cross-origin request refused' })
|
|
438
|
+
return
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
if (req.method !== 'POST') {
|
|
442
|
+
send(200, { ok: true, service: 'dsh-db-studio', route: ROUTE })
|
|
443
|
+
return
|
|
444
|
+
}
|
|
445
|
+
try {
|
|
446
|
+
const body = await readBody(req)
|
|
447
|
+
send(200, await dispatch(studio, body, { cwdOf }))
|
|
448
|
+
} catch (error) {
|
|
449
|
+
send(200, { ok: false, error: error?.message ?? String(error) })
|
|
450
|
+
}
|
|
451
|
+
},
|
|
452
|
+
}), 'dsh-db-studio: http api')
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
// ── model-facing tools ───────────────────────────────────────────────
|
|
456
|
+
if (cfg.agentTools !== false) {
|
|
457
|
+
ctx.inject(['tools'], (child) => {
|
|
458
|
+
optionalHostImport('@deepseek-ai/dsh-tools').then((mod) => {
|
|
459
|
+
if (!mod?.defineTool) {
|
|
460
|
+
console.warn('dsh-db-studio: @deepseek-ai/dsh-tools not reachable; agent tools are disabled')
|
|
461
|
+
return
|
|
462
|
+
}
|
|
463
|
+
try {
|
|
464
|
+
child.effect(() => {
|
|
465
|
+
const definitions = buildToolDefinitions(studio)
|
|
466
|
+
const disposers = definitions.map((definition) => child.tools.register(mod.defineTool(definition)))
|
|
467
|
+
console.info('dsh-db-studio: registered ' + definitions.length + ' agent tools (' + definitions.map((definition) => definition.name).join(', ') + ')')
|
|
468
|
+
return () => { for (const dispose of disposers) dispose?.() }
|
|
469
|
+
}, 'dsh-db-studio: agent tools')
|
|
470
|
+
} catch (error) {
|
|
471
|
+
console.warn('dsh-db-studio: agent tools not registered —', error?.message ?? error)
|
|
472
|
+
}
|
|
473
|
+
}).catch((error) => {
|
|
474
|
+
console.warn('dsh-db-studio: agent tools not registered —', error?.message ?? error)
|
|
475
|
+
})
|
|
476
|
+
})
|
|
477
|
+
}
|
|
478
|
+
},
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
export { buildToolDefinitions, dispatch, markdownTable, ROUTE }
|