@shawnstack/quickforge 1.6.11 → 1.6.13
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/README.md +292 -452
- package/dist/assets/AgentProfilesPage-Be0oZDW5.js +1 -0
- package/dist/assets/ChatPanelHost-CxEotTsy.js +291 -0
- package/dist/assets/{PluginsPage-B_4nlPca.js → PluginsPage-DZlqGFLn.js} +1 -1
- package/dist/assets/ScheduledTasksPage-DTkj-0O4.js +2 -0
- package/dist/assets/{SettingsWorkspacePage-BtgkwIKN.js → SettingsWorkspacePage-BJq11OPD.js} +397 -403
- package/dist/assets/{SharedConversationPage-B5FS3eSl.js → SharedConversationPage-BCR8xzii.js} +1 -1
- package/dist/assets/TerminalDock-Bs5b-YNp.js +2 -0
- package/dist/assets/WorkspaceInspector-BP0XyAlE.js +13 -0
- package/dist/assets/icons-DAxUA0e-.js +1 -0
- package/dist/assets/index-BZC61KZg.css +3 -0
- package/dist/assets/index-DGQbvw7v.js +63 -0
- package/dist/assets/{mcp-servers-dialog-BPk4a4nW.js → mcp-servers-dialog-BHljnwYv.js} +2 -2
- package/dist/assets/{monaco-CdOyGlWD.js → monaco-DEQoYzYI.js} +1 -1
- package/dist/assets/{react-vendor-VqdnQHHS.js → react-vendor-CZsiwuxm.js} +1 -1
- package/dist/assets/{skills-dialog-DcxeFWSt.js → skills-dialog-8rn1XU9U.js} +1 -1
- package/dist/index.html +6 -6
- package/dist/licenses/material-icon-theme.txt +8 -0
- package/package.json +1 -1
- package/server/agent-manager.mjs +163 -25
- package/server/custom-commands.mjs +17 -0
- package/server/image-generation.mjs +130 -0
- package/server/index.mjs +6 -0
- package/server/provider-config.mjs +78 -0
- package/server/routes/agent.mjs +9 -0
- package/server/routes/project.mjs +3 -18
- package/server/routes/session-assets.mjs +49 -0
- package/server/routes/shared-conversation.mjs +10 -0
- package/server/routes/tools.mjs +1 -1
- package/server/routes/workspace.mjs +5 -2
- package/server/session-assets.mjs +134 -0
- package/server/storage.mjs +13 -1
- package/server/tools/definitions.mjs +10 -0
- package/server/tools/index.mjs +2 -0
- package/server/utils/platform.mjs +20 -3
- package/dist/assets/AgentProfilesPage-CvoB0OId.js +0 -1
- package/dist/assets/ChatPanelHost-Cyi1yi_P.js +0 -244
- package/dist/assets/ScheduledTasksPage-Zbsuo8o6.js +0 -2
- package/dist/assets/TerminalDock-CiJCmyqg.js +0 -2
- package/dist/assets/WorkspaceInspector-C9HyQIvF.js +0 -13
- package/dist/assets/icons-C7j5jdKo.js +0 -1
- package/dist/assets/index-BFKildY3.js +0 -63
- package/dist/assets/index-CsKSKUn-.css +0 -3
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { builtinImagesModels } from '@earendil-works/pi-ai/providers/all'
|
|
2
|
+
import { resolveOpenRouterConfig } from './provider-config.mjs'
|
|
3
|
+
import { deleteSessionAsset, writeSessionAsset } from './session-assets.mjs'
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_IMAGE_MODEL = 'google/gemini-2.5-flash-image'
|
|
6
|
+
export const MAX_GENERATED_IMAGES = 4
|
|
7
|
+
export const MAX_GENERATED_IMAGE_BYTES = 50 * 1024 * 1024
|
|
8
|
+
|
|
9
|
+
function requestError(message, statusCode = 400) {
|
|
10
|
+
const error = new Error(message)
|
|
11
|
+
error.statusCode = statusCode
|
|
12
|
+
return error
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function normalizeBucket(value) {
|
|
16
|
+
if (value?.scope === 'project') return { scope: 'project', projectId: value.projectId }
|
|
17
|
+
return { scope: 'global' }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function resolveImagesModels(runtime) {
|
|
21
|
+
const candidate = runtime.imagesModels
|
|
22
|
+
if (candidate && typeof candidate.getModel === 'function') return candidate
|
|
23
|
+
if (typeof candidate === 'function') return candidate()
|
|
24
|
+
return builtinImagesModels()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function imageByteLength(data) {
|
|
28
|
+
const normalized = typeof data === 'string' ? data.replace(/\s+/g, '') : ''
|
|
29
|
+
if (!normalized || normalized.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)) {
|
|
30
|
+
throw requestError('Image provider returned invalid base64 data')
|
|
31
|
+
}
|
|
32
|
+
const buffer = Buffer.from(normalized, 'base64')
|
|
33
|
+
if (buffer.toString('base64') !== normalized) {
|
|
34
|
+
throw requestError('Image provider returned invalid base64 data')
|
|
35
|
+
}
|
|
36
|
+
return buffer.byteLength
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function resultText(output) {
|
|
40
|
+
return output
|
|
41
|
+
.filter((item) => item?.type === 'text' && typeof item.text === 'string' && item.text.trim())
|
|
42
|
+
.map((item) => item.text.trim())
|
|
43
|
+
.join('\n\n')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function contentSummary(count, text) {
|
|
47
|
+
const generated = `Generated ${count} image${count === 1 ? '' : 's'}.`
|
|
48
|
+
return text ? `${generated}\n\n${text}` : generated
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function generateSessionImages(params, context = {}, runtime = {}) {
|
|
52
|
+
const prompt = typeof params?.prompt === 'string' ? params.prompt.trim() : ''
|
|
53
|
+
if (!prompt) throw requestError('prompt is required')
|
|
54
|
+
|
|
55
|
+
const sessionId = typeof context.sessionId === 'string' ? context.sessionId : params?.sessionId
|
|
56
|
+
if (!sessionId) throw requestError('sessionId is required')
|
|
57
|
+
const bucket = normalizeBucket(context.bucket || context.sessionBucket || params?.bucket || {
|
|
58
|
+
scope: context.scope,
|
|
59
|
+
projectId: context.projectId,
|
|
60
|
+
})
|
|
61
|
+
const requestedModel = typeof params?.model === 'string' && params.model.trim()
|
|
62
|
+
? params.model.trim()
|
|
63
|
+
: DEFAULT_IMAGE_MODEL
|
|
64
|
+
|
|
65
|
+
const imagesModels = resolveImagesModels(runtime)
|
|
66
|
+
const model = imagesModels.getModel('openrouter', requestedModel)
|
|
67
|
+
if (!model || model.api !== 'openrouter-images') {
|
|
68
|
+
throw requestError(`Unsupported image model: ${requestedModel}`)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const resolveConfig = runtime.resolveOpenRouterConfig || resolveOpenRouterConfig
|
|
72
|
+
const config = await resolveConfig(runtime)
|
|
73
|
+
const requestModel = config.baseUrl ? { ...model, baseUrl: config.baseUrl } : model
|
|
74
|
+
const response = await imagesModels.generateImages(
|
|
75
|
+
requestModel,
|
|
76
|
+
{ input: [{ type: 'text', text: prompt }] },
|
|
77
|
+
{
|
|
78
|
+
apiKey: config.apiKey,
|
|
79
|
+
...(config.headers ? { headers: config.headers } : {}),
|
|
80
|
+
...(runtime.signal || context.signal ? { signal: runtime.signal || context.signal } : {}),
|
|
81
|
+
},
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
if (response?.stopReason !== 'stop') {
|
|
85
|
+
throw new Error(response?.errorMessage || `Image generation stopped: ${response?.stopReason || 'unknown'}`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const output = Array.isArray(response.output) ? response.output : []
|
|
89
|
+
const images = output.filter((item) => item?.type === 'image')
|
|
90
|
+
if (!images.length) throw new Error('Image generation returned no images')
|
|
91
|
+
if (images.length > MAX_GENERATED_IMAGES) {
|
|
92
|
+
throw requestError(`Image generation returned more than ${MAX_GENERATED_IMAGES} images`, 413)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let totalBytes = 0
|
|
96
|
+
for (const image of images) {
|
|
97
|
+
totalBytes += imageByteLength(image.data)
|
|
98
|
+
if (totalBytes > MAX_GENERATED_IMAGE_BYTES) {
|
|
99
|
+
throw requestError(`Generated images exceed the ${MAX_GENERATED_IMAGE_BYTES} byte limit`, 413)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const writeAsset = runtime.writeSessionAsset || writeSessionAsset
|
|
104
|
+
const deleteAsset = runtime.deleteSessionAsset || deleteSessionAsset
|
|
105
|
+
const assets = []
|
|
106
|
+
try {
|
|
107
|
+
for (const image of images) {
|
|
108
|
+
assets.push(await writeAsset(bucket, sessionId, image))
|
|
109
|
+
}
|
|
110
|
+
} catch (error) {
|
|
111
|
+
await Promise.allSettled(assets.map((asset) => deleteAsset(bucket, sessionId, asset.assetId)))
|
|
112
|
+
throw error
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const text = resultText(output)
|
|
116
|
+
return {
|
|
117
|
+
content: contentSummary(assets.length, text),
|
|
118
|
+
details: {
|
|
119
|
+
type: 'generated_image_result',
|
|
120
|
+
sessionId,
|
|
121
|
+
prompt,
|
|
122
|
+
model: requestedModel,
|
|
123
|
+
assets,
|
|
124
|
+
text,
|
|
125
|
+
usage: response.usage,
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const generateImages = generateSessionImages
|
package/server/index.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import { handleBackupApi } from './routes/backup.mjs'
|
|
|
23
23
|
import { handleSystemApi } from './routes/system.mjs'
|
|
24
24
|
import { handleSharesApi } from './routes/shares.mjs'
|
|
25
25
|
import { handleSharedConversationApi } from './routes/shared-conversation.mjs'
|
|
26
|
+
import { handleSessionAssetsApi } from './routes/session-assets.mjs'
|
|
26
27
|
import { handleLanAccessApi, renderLanUnlockPage } from './routes/lan-access.mjs'
|
|
27
28
|
import { handleMcpApi } from './routes/mcp.mjs'
|
|
28
29
|
import { handlePluginsApi } from './routes/plugins.mjs'
|
|
@@ -288,6 +289,11 @@ async function handleApi(req, res, url) {
|
|
|
288
289
|
return
|
|
289
290
|
}
|
|
290
291
|
|
|
292
|
+
if (pathname.startsWith('/api/session-assets/')) {
|
|
293
|
+
await handleSessionAssetsApi(req, res, url)
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
|
|
291
297
|
if (pathname === '/api/lan-access/status' || pathname === '/api/lan-access/settings' || pathname === '/api/lan-access/unlock' || pathname === '/api/lan-access/logout' || pathname === '/api/lan-access/revoke-all') {
|
|
292
298
|
await handleLanAccessApi(req, res, url, {
|
|
293
299
|
port,
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { readStore } from './storage.mjs'
|
|
2
|
+
|
|
3
|
+
const DEFAULT_OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'
|
|
4
|
+
|
|
5
|
+
function requestError(message, statusCode = 400) {
|
|
6
|
+
const error = new Error(message)
|
|
7
|
+
error.statusCode = statusCode
|
|
8
|
+
return error
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function providersFromStore(store) {
|
|
12
|
+
return Array.isArray(store)
|
|
13
|
+
? store
|
|
14
|
+
: Object.entries(store || {}).map(([id, provider]) => (
|
|
15
|
+
provider && typeof provider === 'object' ? { id, ...provider } : provider
|
|
16
|
+
))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isOpenRouterProvider(provider) {
|
|
20
|
+
if (!provider || typeof provider !== 'object') return false
|
|
21
|
+
const id = String(provider.id || '').trim().toLowerCase()
|
|
22
|
+
const name = String(provider.name || '').trim().toLowerCase()
|
|
23
|
+
if (id === 'openrouter' || name === 'openrouter') return true
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
return new URL(String(provider.baseUrl || '')).hostname.toLowerCase() === 'openrouter.ai'
|
|
27
|
+
} catch {
|
|
28
|
+
return false
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeHeaders(provider) {
|
|
33
|
+
const source = provider?.headers || provider?.models?.find((model) => model?.headers)?.headers
|
|
34
|
+
if (!source || typeof source !== 'object' || Array.isArray(source)) return undefined
|
|
35
|
+
const headers = {}
|
|
36
|
+
for (const [key, value] of Object.entries(source)) {
|
|
37
|
+
if (typeof value === 'string' && key.trim()) headers[key.trim()] = value
|
|
38
|
+
}
|
|
39
|
+
return Object.keys(headers).length ? headers : undefined
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function findProviderKey(keys, providerName) {
|
|
43
|
+
if (!keys || typeof keys !== 'object' || Array.isArray(keys)) return undefined
|
|
44
|
+
if (typeof keys[providerName] === 'string' && keys[providerName].trim()) return keys[providerName].trim()
|
|
45
|
+
|
|
46
|
+
const wanted = String(providerName || '').trim().toLowerCase()
|
|
47
|
+
for (const [key, value] of Object.entries(keys)) {
|
|
48
|
+
const normalized = key.trim().toLowerCase()
|
|
49
|
+
if ((normalized === wanted || normalized === 'openrouter') && typeof value === 'string' && value.trim()) {
|
|
50
|
+
return value.trim()
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return undefined
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function resolveOpenRouterConfig(runtime = {}) {
|
|
57
|
+
const read = runtime.readStore || readStore
|
|
58
|
+
const [providerStore, providerKeys] = await Promise.all([
|
|
59
|
+
read('custom-providers'),
|
|
60
|
+
read('provider-keys'),
|
|
61
|
+
])
|
|
62
|
+
const provider = providersFromStore(providerStore).find(isOpenRouterProvider)
|
|
63
|
+
if (!provider) throw requestError('OpenRouter provider is not configured')
|
|
64
|
+
|
|
65
|
+
const providerName = String(provider.name || provider.id || 'openrouter').trim()
|
|
66
|
+
const apiKey = findProviderKey(providerKeys, providerName)
|
|
67
|
+
if (!apiKey) throw requestError('OpenRouter API key is not configured')
|
|
68
|
+
|
|
69
|
+
const baseUrl = typeof provider.baseUrl === 'string' && provider.baseUrl.trim()
|
|
70
|
+
? provider.baseUrl.trim().replace(/\/$/, '')
|
|
71
|
+
: DEFAULT_OPENROUTER_BASE_URL
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
apiKey,
|
|
75
|
+
baseUrl,
|
|
76
|
+
headers: normalizeHeaders(provider),
|
|
77
|
+
}
|
|
78
|
+
}
|
package/server/routes/agent.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
touchSession,
|
|
17
17
|
listSessions,
|
|
18
18
|
updateSessionAccessMode,
|
|
19
|
+
updateSessionTitle,
|
|
19
20
|
updateSessionYoloMode,
|
|
20
21
|
updateSessionModel,
|
|
21
22
|
updateSessionThinkingLevel,
|
|
@@ -153,6 +154,14 @@ export async function handleAgentApi(req, res, url) {
|
|
|
153
154
|
return
|
|
154
155
|
}
|
|
155
156
|
|
|
157
|
+
// POST /api/agents/:sessionId/title — update the authoritative session title
|
|
158
|
+
if (req.method === 'POST' && subPath === 'title') {
|
|
159
|
+
const body = await readJsonBody(req)
|
|
160
|
+
const result = await updateSessionTitle(sessionId, body?.title)
|
|
161
|
+
sendJson(res, 200, result)
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
156
165
|
// POST /api/agents/:sessionId/access-mode — update session Agent access mode
|
|
157
166
|
if (req.method === 'POST' && subPath === 'access-mode') {
|
|
158
167
|
const body = await readJsonBody(req)
|
|
@@ -99,12 +99,7 @@ export async function handleProjectApi(req, res, url) {
|
|
|
99
99
|
|
|
100
100
|
if (req.method === 'POST' && url.pathname.startsWith('/api/project/') && url.pathname.endsWith('/open-in-explorer')) {
|
|
101
101
|
const id = decodeSegment(url.pathname.split('/').filter(Boolean)[2])
|
|
102
|
-
const selected = config
|
|
103
|
-
if (!selected) {
|
|
104
|
-
const error = new Error('Unknown project')
|
|
105
|
-
error.statusCode = 404
|
|
106
|
-
throw error
|
|
107
|
-
}
|
|
102
|
+
const selected = resolveRequestedProject(config, id, getDefaultWorkspaceRoot())
|
|
108
103
|
await openPathInFileManager(selected.path)
|
|
109
104
|
sendJson(res, 200, { ok: true })
|
|
110
105
|
return
|
|
@@ -112,12 +107,7 @@ export async function handleProjectApi(req, res, url) {
|
|
|
112
107
|
|
|
113
108
|
if (req.method === 'POST' && url.pathname.startsWith('/api/project/') && url.pathname.endsWith('/open-in-vscode')) {
|
|
114
109
|
const id = decodeSegment(url.pathname.split('/').filter(Boolean)[2])
|
|
115
|
-
const selected = config
|
|
116
|
-
if (!selected) {
|
|
117
|
-
const error = new Error('Unknown project')
|
|
118
|
-
error.statusCode = 404
|
|
119
|
-
throw error
|
|
120
|
-
}
|
|
110
|
+
const selected = resolveRequestedProject(config, id, getDefaultWorkspaceRoot())
|
|
121
111
|
await openPathInVSCode(selected.path)
|
|
122
112
|
sendJson(res, 200, { ok: true })
|
|
123
113
|
return
|
|
@@ -125,12 +115,7 @@ export async function handleProjectApi(req, res, url) {
|
|
|
125
115
|
|
|
126
116
|
if (req.method === 'POST' && url.pathname.startsWith('/api/project/') && url.pathname.endsWith('/open-in-idea')) {
|
|
127
117
|
const id = decodeSegment(url.pathname.split('/').filter(Boolean)[2])
|
|
128
|
-
const selected = config
|
|
129
|
-
if (!selected) {
|
|
130
|
-
const error = new Error('Unknown project')
|
|
131
|
-
error.statusCode = 404
|
|
132
|
-
throw error
|
|
133
|
-
}
|
|
118
|
+
const selected = resolveRequestedProject(config, id, getDefaultWorkspaceRoot())
|
|
134
119
|
await openPathInIDEA(selected.path)
|
|
135
120
|
sendJson(res, 200, { ok: true })
|
|
136
121
|
return
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { findSessionBucket } from '../storage.mjs'
|
|
2
|
+
import { readSessionAsset } from '../session-assets.mjs'
|
|
3
|
+
import { decodeSegment } from '../utils/response.mjs'
|
|
4
|
+
|
|
5
|
+
function notFound(message = 'Image asset not found') {
|
|
6
|
+
const error = new Error(message)
|
|
7
|
+
error.statusCode = 404
|
|
8
|
+
return error
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function sendSessionAsset(res, bucket, sessionId, assetId) {
|
|
12
|
+
let asset
|
|
13
|
+
try {
|
|
14
|
+
asset = await readSessionAsset(bucket, sessionId, assetId)
|
|
15
|
+
} catch (error) {
|
|
16
|
+
if (error?.code === 'ENOENT') throw notFound()
|
|
17
|
+
throw error
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
res.writeHead(200, {
|
|
21
|
+
'content-type': asset.mimeType,
|
|
22
|
+
'content-length': String(asset.size),
|
|
23
|
+
'cache-control': 'private, max-age=31536000, immutable',
|
|
24
|
+
'content-disposition': 'inline',
|
|
25
|
+
'x-content-type-options': 'nosniff',
|
|
26
|
+
})
|
|
27
|
+
res.end(asset.data)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function handleSessionAssetsApi(req, res, url) {
|
|
31
|
+
if (req.method !== 'GET') {
|
|
32
|
+
const error = new Error('Session asset endpoints require GET')
|
|
33
|
+
error.statusCode = 405
|
|
34
|
+
throw error
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const parts = url.pathname.split('/').filter(Boolean)
|
|
38
|
+
const sessionId = decodeSegment(parts[2])
|
|
39
|
+
const assetId = decodeSegment(parts[3])
|
|
40
|
+
if (!sessionId || !assetId || parts.length !== 4) {
|
|
41
|
+
const error = new Error('Missing session image asset path')
|
|
42
|
+
error.statusCode = 400
|
|
43
|
+
throw error
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const bucket = await findSessionBucket(sessionId)
|
|
47
|
+
if (!bucket) throw notFound('Session not found')
|
|
48
|
+
await sendSessionAsset(res, bucket, sessionId, assetId)
|
|
49
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { sendJson, readJsonBody, decodeSegment } from '../utils/response.mjs'
|
|
2
2
|
import { readSessionValue, readStore } from '../storage.mjs'
|
|
3
|
+
import { sendSessionAsset } from './session-assets.mjs'
|
|
3
4
|
import { abortRun, restoreAgent, runPrompt, getSessionState, getSessionEventBus, updateSessionModel, updateSessionThinkingLevel } from '../agent-manager.mjs'
|
|
4
5
|
import {
|
|
5
6
|
assertShareActive,
|
|
@@ -307,6 +308,7 @@ export async function handleSharedConversationApi(req, res, url) {
|
|
|
307
308
|
const parts = url.pathname.split('/').filter(Boolean)
|
|
308
309
|
const shareId = decodeSegment(parts[2])
|
|
309
310
|
const action = parts[3]
|
|
311
|
+
const actionId = parts[4]
|
|
310
312
|
|
|
311
313
|
if (!shareId) {
|
|
312
314
|
const error = new Error('Missing share id')
|
|
@@ -343,6 +345,14 @@ export async function handleSharedConversationApi(req, res, url) {
|
|
|
343
345
|
const record = await requireShareAuth(req, shareId)
|
|
344
346
|
if (record.permission === 'operate' && !record.passwordHash) throw passwordRequiredError()
|
|
345
347
|
|
|
348
|
+
if (req.method === 'GET' && action === 'assets' && actionId) {
|
|
349
|
+
const bucket = record.scope === 'project'
|
|
350
|
+
? { scope: 'project', projectId: record.projectId }
|
|
351
|
+
: { scope: 'global' }
|
|
352
|
+
await sendSessionAsset(res, bucket, record.sessionId, decodeSegment(actionId))
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
|
|
346
356
|
if (req.method === 'GET' && action === 'session') {
|
|
347
357
|
sendJson(res, 200, await sharedSessionPayload(record))
|
|
348
358
|
return
|
package/server/routes/tools.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { callPluginTool, createPluginToolDefinitions, isPluginToolName } from '.
|
|
|
8
8
|
import { safeReadTools } from '../approval-store.mjs'
|
|
9
9
|
import { projectContextFromId, readProjectConfig } from '../project-config.mjs'
|
|
10
10
|
|
|
11
|
-
const directRouteDisabledTools = new Set(['run_subagent', 'manage_global_memory'])
|
|
11
|
+
const directRouteDisabledTools = new Set(['run_subagent', 'manage_global_memory', 'generate_image'])
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* GET /api/tools — returns canonical tool definitions (no project context needed).
|
|
@@ -304,9 +304,12 @@ async function countWorkspaceLines(context, relativePath) {
|
|
|
304
304
|
}
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
-
async function listGitStatus(context) {
|
|
307
|
+
export async function listGitStatus(context) {
|
|
308
308
|
if (!(await isGitRepository(context.workspaceRoot))) return { isGitRepository: false, files: [] }
|
|
309
|
-
const result = await git(
|
|
309
|
+
const result = await git(
|
|
310
|
+
['status', '--porcelain=v1', '-z', '--untracked-files=all'],
|
|
311
|
+
context.workspaceRoot,
|
|
312
|
+
)
|
|
310
313
|
const files = parseGitStatus(result.stdout)
|
|
311
314
|
const numstat = await collectNumstat(context)
|
|
312
315
|
for (const file of files) {
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { randomUUID } from 'node:crypto'
|
|
4
|
+
import { storageDir } from './storage.mjs'
|
|
5
|
+
|
|
6
|
+
export const MAX_SESSION_IMAGE_BYTES = 25 * 1024 * 1024
|
|
7
|
+
|
|
8
|
+
const MIME_TYPES = new Map([
|
|
9
|
+
['image/png', 'png'],
|
|
10
|
+
['image/jpeg', 'jpg'],
|
|
11
|
+
['image/webp', 'webp'],
|
|
12
|
+
['image/gif', 'gif'],
|
|
13
|
+
])
|
|
14
|
+
|
|
15
|
+
const SAFE_SEGMENT_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/
|
|
16
|
+
const ASSET_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.(?:png|jpg|webp|gif)$/i
|
|
17
|
+
|
|
18
|
+
function requestError(message, statusCode = 400) {
|
|
19
|
+
const error = new Error(message)
|
|
20
|
+
error.statusCode = statusCode
|
|
21
|
+
return error
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function assertSafeSegment(value, label) {
|
|
25
|
+
if (typeof value !== 'string' || !SAFE_SEGMENT_RE.test(value)) {
|
|
26
|
+
throw requestError(`Invalid ${label}`)
|
|
27
|
+
}
|
|
28
|
+
return value
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function normalizeBucket(bucket) {
|
|
32
|
+
if (!bucket || typeof bucket !== 'object' || Array.isArray(bucket)) {
|
|
33
|
+
throw requestError('Invalid session asset scope')
|
|
34
|
+
}
|
|
35
|
+
if (bucket.scope === 'global') return { scope: 'global' }
|
|
36
|
+
if (bucket.scope === 'project') {
|
|
37
|
+
return {
|
|
38
|
+
scope: 'project',
|
|
39
|
+
projectId: assertSafeSegment(bucket.projectId, 'projectId'),
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
throw requestError('Invalid session asset scope')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function assetsRoot(bucket) {
|
|
46
|
+
if (bucket.scope === 'project') {
|
|
47
|
+
return path.join(storageDir, 'conversations', 'projects', bucket.projectId, 'assets')
|
|
48
|
+
}
|
|
49
|
+
return path.join(storageDir, 'conversations', 'global', 'assets')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function sessionAssetsDir(bucket, sessionId) {
|
|
53
|
+
return path.join(assetsRoot(normalizeBucket(bucket)), assertSafeSegment(sessionId, 'sessionId'))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizeMimeType(mimeType) {
|
|
57
|
+
const value = typeof mimeType === 'string' ? mimeType.trim().toLowerCase() : ''
|
|
58
|
+
const extension = MIME_TYPES.get(value)
|
|
59
|
+
if (!extension) throw requestError(`Unsupported image MIME type: ${mimeType || ''}`)
|
|
60
|
+
return { mimeType: value, extension }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function decodeBase64(value) {
|
|
64
|
+
const normalized = typeof value === 'string' ? value.replace(/\s+/g, '') : ''
|
|
65
|
+
if (!normalized || normalized.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)) {
|
|
66
|
+
throw requestError('Invalid base64 image data')
|
|
67
|
+
}
|
|
68
|
+
const buffer = Buffer.from(normalized, 'base64')
|
|
69
|
+
if (buffer.toString('base64') !== normalized) throw requestError('Invalid base64 image data')
|
|
70
|
+
return buffer
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function normalizeImageData(data) {
|
|
74
|
+
if (Buffer.isBuffer(data)) return data
|
|
75
|
+
if (data instanceof Uint8Array) return Buffer.from(data)
|
|
76
|
+
return decodeBase64(data)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function mimeTypeFromAssetId(assetId) {
|
|
80
|
+
const extension = path.extname(assetId).slice(1).toLowerCase()
|
|
81
|
+
for (const [mimeType, candidate] of MIME_TYPES) {
|
|
82
|
+
if (candidate === extension) return mimeType
|
|
83
|
+
}
|
|
84
|
+
throw requestError('Invalid assetId')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function assetMetadata(assetId, mimeType, size) {
|
|
88
|
+
return { assetId, mimeType, size }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function validateSessionAssetId(assetId) {
|
|
92
|
+
if (typeof assetId !== 'string' || !ASSET_ID_RE.test(assetId)) {
|
|
93
|
+
throw requestError('Invalid assetId')
|
|
94
|
+
}
|
|
95
|
+
return assetId
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function writeSessionAsset(bucket, sessionId, image) {
|
|
99
|
+
const dir = sessionAssetsDir(bucket, sessionId)
|
|
100
|
+
const { mimeType, extension } = normalizeMimeType(image?.mimeType)
|
|
101
|
+
const data = normalizeImageData(image?.data)
|
|
102
|
+
if (data.byteLength > MAX_SESSION_IMAGE_BYTES) {
|
|
103
|
+
throw requestError(`Image exceeds the ${MAX_SESSION_IMAGE_BYTES} byte limit`, 413)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
await fs.mkdir(dir, { recursive: true })
|
|
107
|
+
const assetId = `${randomUUID()}.${extension}`
|
|
108
|
+
await fs.writeFile(path.join(dir, assetId), data, { flag: 'wx' })
|
|
109
|
+
return assetMetadata(assetId, mimeType, data.byteLength)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function readSessionAsset(bucket, sessionId, assetId) {
|
|
113
|
+
const dir = sessionAssetsDir(bucket, sessionId)
|
|
114
|
+
const safeAssetId = validateSessionAssetId(assetId)
|
|
115
|
+
const data = await fs.readFile(path.join(dir, safeAssetId))
|
|
116
|
+
if (data.byteLength > MAX_SESSION_IMAGE_BYTES) {
|
|
117
|
+
throw requestError('Stored image exceeds the allowed size', 413)
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
...assetMetadata(safeAssetId, mimeTypeFromAssetId(safeAssetId), data.byteLength),
|
|
121
|
+
data,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function deleteSessionAsset(bucket, sessionId, assetId) {
|
|
126
|
+
const dir = sessionAssetsDir(bucket, sessionId)
|
|
127
|
+
const safeAssetId = validateSessionAssetId(assetId)
|
|
128
|
+
await fs.rm(path.join(dir, safeAssetId), { force: true })
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function deleteSessionAssets(bucket, sessionId) {
|
|
132
|
+
const dir = sessionAssetsDir(bucket, sessionId)
|
|
133
|
+
await fs.rm(dir, { recursive: true, force: true })
|
|
134
|
+
}
|
package/server/storage.mjs
CHANGED
|
@@ -517,7 +517,12 @@ async function writeSessionValues(data) {
|
|
|
517
517
|
existingFiles.map(async (file) => {
|
|
518
518
|
const sessionId = path.basename(file, '.json')
|
|
519
519
|
if (!nextIds.has(sessionId)) {
|
|
520
|
+
const bucket = sessionBucketIndex.get(sessionId) || await findSessionBucketByDataFile(sessionId)
|
|
520
521
|
await fs.rm(file, { force: true })
|
|
522
|
+
if (bucket) {
|
|
523
|
+
const { deleteSessionAssets } = await import('./session-assets.mjs')
|
|
524
|
+
await deleteSessionAssets(bucket, sessionId)
|
|
525
|
+
}
|
|
521
526
|
sessionBucketIndex.delete(sessionId)
|
|
522
527
|
}
|
|
523
528
|
}),
|
|
@@ -574,7 +579,12 @@ export async function findSessionBucket(sessionId) {
|
|
|
574
579
|
await ensureStorage()
|
|
575
580
|
await rebuildBucketIndex()
|
|
576
581
|
}
|
|
577
|
-
|
|
582
|
+
const indexed = sessionBucketIndex.get(sessionId)
|
|
583
|
+
if (indexed) return indexed
|
|
584
|
+
|
|
585
|
+
const recovered = await findSessionBucketByDataFile(sessionId)
|
|
586
|
+
if (recovered) sessionBucketIndex.set(sessionId, recovered)
|
|
587
|
+
return recovered
|
|
578
588
|
}
|
|
579
589
|
|
|
580
590
|
export async function readSessionValue(sessionId) {
|
|
@@ -599,6 +609,8 @@ export async function deleteSessionValue(sessionId) {
|
|
|
599
609
|
const bucket = await findSessionBucket(sessionId)
|
|
600
610
|
if (!bucket) return
|
|
601
611
|
await fs.rm(sessionDataFile(sessionId, bucket), { force: true })
|
|
612
|
+
const { deleteSessionAssets } = await import('./session-assets.mjs')
|
|
613
|
+
await deleteSessionAssets(bucket, sessionId)
|
|
602
614
|
sessionBucketIndex.delete(sessionId)
|
|
603
615
|
})
|
|
604
616
|
}
|
|
@@ -116,6 +116,16 @@ export const workspaceTools = [
|
|
|
116
116
|
}),
|
|
117
117
|
executionMode: 'sequential',
|
|
118
118
|
},
|
|
119
|
+
{
|
|
120
|
+
name: 'generate_image',
|
|
121
|
+
label: 'Generate image',
|
|
122
|
+
description: 'Generate images with the configured OpenRouter provider and save them as assets owned by the current conversation. Use this when the user explicitly asks for a generated bitmap image. The operation may incur provider charges.',
|
|
123
|
+
parameters: Type.Object({
|
|
124
|
+
prompt: Type.String({ description: 'Detailed image-generation prompt.' }),
|
|
125
|
+
model: Type.Optional(Type.String({ description: 'Optional OpenRouter image model ID. Defaults to google/gemini-2.5-flash-image.' })),
|
|
126
|
+
}),
|
|
127
|
+
executionMode: 'sequential',
|
|
128
|
+
},
|
|
119
129
|
{
|
|
120
130
|
name: 'present_files',
|
|
121
131
|
label: 'Present files',
|
package/server/tools/index.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from '../skills.mjs'
|
|
16
16
|
import { getToolWorkspaceRoot } from '../utils/workspace.mjs'
|
|
17
17
|
import { manageGlobalMemory } from '../global-memory.mjs'
|
|
18
|
+
import { generateSessionImages } from '../image-generation.mjs'
|
|
18
19
|
|
|
19
20
|
const require = createRequire(import.meta.url)
|
|
20
21
|
|
|
@@ -1100,6 +1101,7 @@ export const toolHandlers = {
|
|
|
1100
1101
|
write_file: toolWriteFile,
|
|
1101
1102
|
edit_file: toolEditFile,
|
|
1102
1103
|
run_command: toolRunCommand,
|
|
1104
|
+
generate_image: generateSessionImages,
|
|
1103
1105
|
present_files: toolPresentFiles,
|
|
1104
1106
|
activate_skill: toolActivateSkill,
|
|
1105
1107
|
read_skill_resource: toolReadSkillResource,
|
|
@@ -131,6 +131,22 @@ async function findExistingFile(candidates) {
|
|
|
131
131
|
return undefined
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
export function createExternalAppEnv(source = process.env) {
|
|
135
|
+
const env = { ...source }
|
|
136
|
+
delete env.ELECTRON_RUN_AS_NODE
|
|
137
|
+
delete env.ELECTRON_NO_ATTACH_CONSOLE
|
|
138
|
+
delete env.ATOM_SHELL_INTERNAL_RUN_AS_NODE
|
|
139
|
+
return env
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function createVSCodeOpenArgs(targetPath, platform = process.platform) {
|
|
143
|
+
return platform === 'win32' ? ['--reuse-window', targetPath] : [targetPath]
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function shouldHideVSCodeLauncherWindow(command, platform = process.platform) {
|
|
147
|
+
return platform === 'win32' && path.win32.basename(command).toLowerCase() === 'cmd.exe'
|
|
148
|
+
}
|
|
149
|
+
|
|
134
150
|
async function findIntelliJIdeaExecutable() {
|
|
135
151
|
const roots = [
|
|
136
152
|
process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, 'Programs') : undefined,
|
|
@@ -206,10 +222,10 @@ export async function openPathInVSCode(targetPath) {
|
|
|
206
222
|
const codeExecutable = await findExistingFile(candidates)
|
|
207
223
|
if (codeExecutable) {
|
|
208
224
|
command = codeExecutable
|
|
209
|
-
args =
|
|
225
|
+
args = createVSCodeOpenArgs(resolved)
|
|
210
226
|
} else {
|
|
211
227
|
command = 'cmd.exe'
|
|
212
|
-
args = ['/d', '/s', '/c', 'start', '""', '/b', 'code', resolved]
|
|
228
|
+
args = ['/d', '/s', '/c', 'start', '""', '/b', 'code', '--reuse-window', resolved]
|
|
213
229
|
}
|
|
214
230
|
}
|
|
215
231
|
|
|
@@ -217,8 +233,9 @@ export async function openPathInVSCode(targetPath) {
|
|
|
217
233
|
const child = spawn(command, args, {
|
|
218
234
|
detached: true,
|
|
219
235
|
stdio: 'ignore',
|
|
220
|
-
windowsHide:
|
|
236
|
+
windowsHide: shouldHideVSCodeLauncherWindow(command),
|
|
221
237
|
shell: false,
|
|
238
|
+
env: createExternalAppEnv(),
|
|
222
239
|
})
|
|
223
240
|
child.once('error', (error) => {
|
|
224
241
|
error.statusCode = 500
|