@shawnstack/quickforge 1.4.1 → 1.5.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/README.md +12 -12
- package/bin/quickforge.mjs +9 -0
- package/dist/assets/AgentProfilesPage-DUmXUxjA.js +1 -0
- package/dist/assets/ChatPanelHost-Syx0SSLe.js +242 -0
- package/dist/assets/PluginsPage-kiBq0gOT.js +1 -0
- package/dist/assets/ScheduledTasksPage-Dw4-tgp9.js +2 -0
- package/dist/assets/SharedConversationPage-CaE9bNb9.js +1 -0
- package/dist/assets/TerminalDock-BYJcp8Ts.js +2 -0
- package/dist/assets/WorkspaceInspector-Bzmv8Cvi.js +3 -0
- package/dist/assets/WorkspaceReaderDialog-BJo_KEWi.js +1 -0
- package/dist/assets/diff-line-counts-BZoYp5ai.js +10 -0
- package/dist/assets/icons-47L5YLKz.js +1 -0
- package/dist/assets/index-CqfScETb.js +1200 -0
- package/dist/assets/index-DzkBgHZf.css +3 -0
- package/dist/assets/{monaco-evITXh-m.js → monaco-CGq6uVF1.js} +1 -1
- package/dist/assets/{react-vendor-Mthyt1p4.js → react-vendor-DunfCFfp.js} +1 -1
- package/dist/favicon.svg +16 -1
- package/dist/index.html +5 -5
- package/dist/manifest.webmanifest +30 -30
- package/package.json +3 -2
- package/server/acp/server.mjs +921 -0
- package/server/agent-manager.mjs +198 -32
- package/server/agent-profile-files.mjs +179 -0
- package/server/agent-profiles.mjs +59 -5
- package/server/auto-compaction.mjs +82 -39
- package/server/channels/process-channel.mjs +278 -0
- package/server/channels/providers/wechat.mjs +271 -0
- package/server/channels/registry.mjs +58 -0
- package/server/custom-commands.mjs +13 -1
- package/server/frontmatter.mjs +167 -0
- package/server/index.mjs +52 -3
- package/server/project-config.mjs +43 -6
- package/server/routes/agent-profiles.mjs +6 -2
- package/server/routes/agent.mjs +12 -1
- package/server/routes/channels.mjs +145 -0
- package/server/routes/models.mjs +68 -0
- package/server/routes/project.mjs +2 -2
- package/server/routes/scheduled-tasks.mjs +6 -5
- package/server/routes/storage.mjs +4 -2
- package/server/routes/system.mjs +27 -0
- package/server/routes/tools.mjs +17 -6
- package/server/routes/workspace.mjs +138 -0
- package/server/session-utils.mjs +10 -2
- package/server/storage.mjs +29 -2
- package/server/system-prompt.mjs +1 -0
- package/server/tools/definitions.mjs +18 -0
- package/server/tools/index.mjs +83 -0
- package/server/utils/package-update.mjs +156 -0
- package/dist/assets/AgentProfilesPage-CNK5PxA3.js +0 -1
- package/dist/assets/ChatPanelHost-FqPQwwMO.js +0 -217
- package/dist/assets/PluginsPage-BCu1Ept0.js +0 -1
- package/dist/assets/ScheduledTasksPage-Bx04rjui.js +0 -2
- package/dist/assets/SharedConversationPage-55vX9sqe.js +0 -1
- package/dist/assets/TerminalDock-DLN_pLkJ.js +0 -2
- package/dist/assets/WorkspaceInspector-DoemHHnY.js +0 -3
- package/dist/assets/WorkspaceReaderDialog-C6xUHBCw.js +0 -6
- package/dist/assets/icons-BWtivFsx.js +0 -1
- package/dist/assets/index-CxOHP41X.css +0 -3
- package/dist/assets/index-Dcf73EL8.js +0 -895
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { promises as fs } from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { ProcessChannelProvider } from '../process-channel.mjs'
|
|
5
|
+
import { getDefaultWorkspaceRoot, readProjectConfig } from '../../project-config.mjs'
|
|
6
|
+
|
|
7
|
+
const WEIXIN_ACP_PACKAGE = 'weixin-acp'
|
|
8
|
+
const MIN_NODE_MAJOR = 22
|
|
9
|
+
const ACTION_TIMEOUT_MS = 120_000
|
|
10
|
+
const DEFAULT_WORKSPACE_ID = 'default'
|
|
11
|
+
|
|
12
|
+
function npxCommand() {
|
|
13
|
+
return process.platform === 'win32' ? 'npx.cmd' : 'npx'
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function shouldUseShellForNpx() {
|
|
17
|
+
return process.platform === 'win32'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function nodeMajor() {
|
|
21
|
+
return Number(process.versions.node.split('.')[0] || 0)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function qfAcpCommand(projectRoot) {
|
|
25
|
+
const nodeCommand = process.platform === 'win32' ? 'node' : process.execPath
|
|
26
|
+
return [nodeCommand, path.join(projectRoot, 'bin', 'quickforge.mjs'), 'acp']
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function workspaceSummary(id, name, workspacePath, kind) {
|
|
30
|
+
return {
|
|
31
|
+
id,
|
|
32
|
+
name,
|
|
33
|
+
path: workspacePath,
|
|
34
|
+
kind,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function assertDirectoryExists(workspacePath) {
|
|
39
|
+
try {
|
|
40
|
+
const stat = await fs.stat(workspacePath)
|
|
41
|
+
if (stat.isDirectory()) return
|
|
42
|
+
} catch {
|
|
43
|
+
// Report a clear channel action error below.
|
|
44
|
+
}
|
|
45
|
+
const error = new Error(`Workspace is not a directory: ${workspacePath}`)
|
|
46
|
+
error.statusCode = 400
|
|
47
|
+
throw error
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function resolveLaunchWorkspace(options = {}) {
|
|
51
|
+
const requestedId = typeof options.projectId === 'string' ? options.projectId : DEFAULT_WORKSPACE_ID
|
|
52
|
+
if (!requestedId || requestedId === DEFAULT_WORKSPACE_ID) {
|
|
53
|
+
const workspacePath = getDefaultWorkspaceRoot()
|
|
54
|
+
await assertDirectoryExists(workspacePath)
|
|
55
|
+
return workspaceSummary(DEFAULT_WORKSPACE_ID, 'workspace', workspacePath, 'default')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const config = await readProjectConfig()
|
|
59
|
+
const project = config.projects.find((item) => item.id === requestedId)
|
|
60
|
+
if (!project) {
|
|
61
|
+
const error = new Error(`Unknown project: ${requestedId}`)
|
|
62
|
+
error.statusCode = 404
|
|
63
|
+
throw error
|
|
64
|
+
}
|
|
65
|
+
await assertDirectoryExists(project.path)
|
|
66
|
+
return workspaceSummary(project.id, project.name, project.path, 'project')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function extractUrl(text) {
|
|
70
|
+
const matches = String(text).match(/https?:\/\/[^\s\u001b]+/g)
|
|
71
|
+
if (!matches?.length) return null
|
|
72
|
+
return matches[matches.length - 1].replace(/[),,。]+$/u, '')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function looksLikeTerminalQr(text) {
|
|
76
|
+
return /[█▀▄]{6,}/u.test(text) || /[▄▀]{6,}/u.test(text)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function runCommand(command, args, options = {}) {
|
|
80
|
+
return new Promise((resolve, reject) => {
|
|
81
|
+
const child = spawn(command, args, {
|
|
82
|
+
cwd: options.cwd,
|
|
83
|
+
env: options.env || process.env,
|
|
84
|
+
windowsHide: true,
|
|
85
|
+
shell: options.shell === true,
|
|
86
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
87
|
+
})
|
|
88
|
+
let stdout = ''
|
|
89
|
+
let stderr = ''
|
|
90
|
+
let settled = false
|
|
91
|
+
const timer = setTimeout(() => {
|
|
92
|
+
if (settled) return
|
|
93
|
+
child.kill('SIGTERM')
|
|
94
|
+
settled = true
|
|
95
|
+
const error = new Error(`Command timed out after ${options.timeoutMs || ACTION_TIMEOUT_MS}ms`)
|
|
96
|
+
error.stdout = stdout
|
|
97
|
+
error.stderr = stderr
|
|
98
|
+
reject(error)
|
|
99
|
+
}, options.timeoutMs || ACTION_TIMEOUT_MS)
|
|
100
|
+
|
|
101
|
+
child.stdout?.on('data', (chunk) => {
|
|
102
|
+
const text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk)
|
|
103
|
+
stdout += text
|
|
104
|
+
options.onStdout?.(text)
|
|
105
|
+
})
|
|
106
|
+
child.stderr?.on('data', (chunk) => {
|
|
107
|
+
const text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk)
|
|
108
|
+
stderr += text
|
|
109
|
+
options.onStderr?.(text)
|
|
110
|
+
})
|
|
111
|
+
child.once('error', (error) => {
|
|
112
|
+
if (settled) return
|
|
113
|
+
settled = true
|
|
114
|
+
clearTimeout(timer)
|
|
115
|
+
reject(error)
|
|
116
|
+
})
|
|
117
|
+
child.once('exit', (code, signal) => {
|
|
118
|
+
if (settled) return
|
|
119
|
+
settled = true
|
|
120
|
+
clearTimeout(timer)
|
|
121
|
+
if (code === 0) {
|
|
122
|
+
resolve({ stdout, stderr, code, signal })
|
|
123
|
+
} else {
|
|
124
|
+
const error = new Error(`Command exited with code ${code ?? 'null'}${signal ? ` signal ${signal}` : ''}`)
|
|
125
|
+
error.stdout = stdout
|
|
126
|
+
error.stderr = stderr
|
|
127
|
+
error.code = code
|
|
128
|
+
error.signal = signal
|
|
129
|
+
reject(error)
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export class WechatChannelProvider extends ProcessChannelProvider {
|
|
136
|
+
constructor({ projectRoot }) {
|
|
137
|
+
super({
|
|
138
|
+
id: 'wechat',
|
|
139
|
+
name: '微信',
|
|
140
|
+
description: '通过 weixin-acp 将微信消息接入 QuickForge ACP Agent。',
|
|
141
|
+
kind: 'process',
|
|
142
|
+
provider: 'weixin-acp',
|
|
143
|
+
icon: 'wechat',
|
|
144
|
+
commandLabel: 'npx weixin-acp start -- qf acp',
|
|
145
|
+
supportsWorkspaceSelection: true,
|
|
146
|
+
actions: [
|
|
147
|
+
{ id: 'logout', label: '退出登录', destructive: true },
|
|
148
|
+
{ id: 'relogin', label: '重新登录' },
|
|
149
|
+
],
|
|
150
|
+
requirements: [`Node.js >= ${MIN_NODE_MAJOR}`, 'npm/npx 可用', '首次启动需要微信扫码登录'],
|
|
151
|
+
})
|
|
152
|
+
this.projectRoot = projectRoot
|
|
153
|
+
this.launchWorkspace = null
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
snapshot() {
|
|
157
|
+
return {
|
|
158
|
+
...super.snapshot(),
|
|
159
|
+
launchWorkspace: this.launchWorkspace,
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async beforeStart(options = {}) {
|
|
164
|
+
if (nodeMajor() < MIN_NODE_MAJOR) {
|
|
165
|
+
const message = `微信渠道需要 Node.js >= ${MIN_NODE_MAJOR},当前版本是 ${process.versions.node}。请升级 Node 后重试。`
|
|
166
|
+
this.error = message
|
|
167
|
+
this.setStatus('error', { error: message })
|
|
168
|
+
const error = new Error(message)
|
|
169
|
+
error.statusCode = 409
|
|
170
|
+
throw error
|
|
171
|
+
}
|
|
172
|
+
this.launchWorkspace = await resolveLaunchWorkspace(options)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
buildStartCommand() {
|
|
176
|
+
const [acpCommand, ...acpArgs] = qfAcpCommand(this.projectRoot)
|
|
177
|
+
return {
|
|
178
|
+
command: npxCommand(),
|
|
179
|
+
args: ['-y', WEIXIN_ACP_PACKAGE, 'start', '--', acpCommand, ...acpArgs],
|
|
180
|
+
cwd: this.launchWorkspace?.path || getDefaultWorkspaceRoot() || this.projectRoot,
|
|
181
|
+
env: process.env,
|
|
182
|
+
shell: shouldUseShellForNpx(),
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
inspectOutput(text) {
|
|
187
|
+
const url = extractUrl(text)
|
|
188
|
+
if (url) {
|
|
189
|
+
this.qrCodeUrl = url
|
|
190
|
+
if (this.status === 'starting') this.setStatus('waiting_scan')
|
|
191
|
+
this.emitEvent('qrcode', { qrCodeUrl: this.qrCodeUrl, qrCodeText: this.qrCodeText, snapshot: this.snapshot() })
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (looksLikeTerminalQr(text)) {
|
|
195
|
+
const nextText = `${this.qrCodeText}\n${text}`.trim()
|
|
196
|
+
this.qrCodeText = nextText.length > 8000 ? nextText.slice(-8000) : nextText
|
|
197
|
+
if (this.status === 'starting') this.setStatus('waiting_scan')
|
|
198
|
+
this.emitEvent('qrcode', { qrCodeUrl: this.qrCodeUrl, qrCodeText: this.qrCodeText, snapshot: this.snapshot() })
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (/扫码|二维码|等待扫码|scan/i.test(text) && this.status === 'starting') {
|
|
202
|
+
this.setStatus('waiting_scan')
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (/与微信连接成功|启动 bot|\[weixin\] 启动 bot|connected|login success/i.test(text)) {
|
|
206
|
+
this.qrCodeText = ''
|
|
207
|
+
this.setStatus('running')
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
inspectOutputLine(line) {
|
|
212
|
+
if (/与微信连接成功|启动 bot|\[weixin\] 启动 bot|connected|login success/i.test(line)) {
|
|
213
|
+
this.qrCodeText = ''
|
|
214
|
+
this.setStatus('running')
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async runAction(action, options = {}) {
|
|
219
|
+
if (action === 'logout') return this.logout()
|
|
220
|
+
if (action === 'relogin') return this.relogin(options)
|
|
221
|
+
return super.runAction(action, options)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async logout({ preserveActiveAction = false } = {}) {
|
|
225
|
+
if (this.activeAction && !preserveActiveAction) return this.snapshot()
|
|
226
|
+
if (!preserveActiveAction) this.activeAction = 'logout'
|
|
227
|
+
this.emitEvent('status', { status: this.status, snapshot: this.snapshot() })
|
|
228
|
+
try {
|
|
229
|
+
if (this.process) await this.stop()
|
|
230
|
+
this.addLog('system', 'Running: npx weixin-acp logout')
|
|
231
|
+
await runCommand(npxCommand(), ['-y', WEIXIN_ACP_PACKAGE, 'logout'], {
|
|
232
|
+
cwd: this.projectRoot,
|
|
233
|
+
env: process.env,
|
|
234
|
+
shell: shouldUseShellForNpx(),
|
|
235
|
+
onStdout: (text) => this.addProcessText('stdout', text),
|
|
236
|
+
onStderr: (text) => this.addProcessText('stderr', text),
|
|
237
|
+
})
|
|
238
|
+
this.qrCodeUrl = null
|
|
239
|
+
this.qrCodeText = ''
|
|
240
|
+
this.error = null
|
|
241
|
+
this.setStatus('stopped')
|
|
242
|
+
return this.snapshot()
|
|
243
|
+
} catch (error) {
|
|
244
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
245
|
+
this.error = message
|
|
246
|
+
this.addLog('stderr', message)
|
|
247
|
+
this.setStatus('error', { error: message })
|
|
248
|
+
throw error
|
|
249
|
+
} finally {
|
|
250
|
+
if (!preserveActiveAction) this.activeAction = null
|
|
251
|
+
this.emitEvent('status', { status: this.status, snapshot: this.snapshot() })
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async relogin(options = {}) {
|
|
256
|
+
if (this.activeAction) return this.snapshot()
|
|
257
|
+
this.activeAction = 'relogin'
|
|
258
|
+
this.emitEvent('status', { status: this.status, snapshot: this.snapshot() })
|
|
259
|
+
try {
|
|
260
|
+
await this.logout({ preserveActiveAction: true })
|
|
261
|
+
return await this.start({ projectId: options.projectId || this.launchWorkspace?.id || DEFAULT_WORKSPACE_ID })
|
|
262
|
+
} finally {
|
|
263
|
+
this.activeAction = null
|
|
264
|
+
this.emitEvent('status', { status: this.status, snapshot: this.snapshot() })
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function createWechatChannelProvider(options) {
|
|
270
|
+
return new WechatChannelProvider(options)
|
|
271
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events'
|
|
2
|
+
import { createWechatChannelProvider } from './providers/wechat.mjs'
|
|
3
|
+
|
|
4
|
+
export const channelEvents = new EventEmitter()
|
|
5
|
+
|
|
6
|
+
const providers = new Map()
|
|
7
|
+
let initialized = false
|
|
8
|
+
|
|
9
|
+
function attachProvider(provider) {
|
|
10
|
+
providers.set(provider.definition.id, provider)
|
|
11
|
+
provider.on('event', (event) => {
|
|
12
|
+
channelEvents.emit('channel_event', event)
|
|
13
|
+
})
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function initializeChannels(options = {}) {
|
|
17
|
+
if (initialized) return
|
|
18
|
+
initialized = true
|
|
19
|
+
attachProvider(createWechatChannelProvider({ projectRoot: options.projectRoot }))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function requireProvider(id) {
|
|
23
|
+
const provider = providers.get(id)
|
|
24
|
+
if (!provider) {
|
|
25
|
+
const error = new Error(`Unknown channel: ${id}`)
|
|
26
|
+
error.statusCode = 404
|
|
27
|
+
throw error
|
|
28
|
+
}
|
|
29
|
+
return provider
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function listChannels() {
|
|
33
|
+
return Array.from(providers.values()).map((provider) => provider.snapshot())
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getChannelStatus(id) {
|
|
37
|
+
return requireProvider(id).snapshot()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function startChannel(id, options = {}) {
|
|
41
|
+
return requireProvider(id).start(options)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function stopChannel(id) {
|
|
45
|
+
return requireProvider(id).stop()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function restartChannel(id, options = {}) {
|
|
49
|
+
return requireProvider(id).restart(options)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function runChannelAction(id, action, options = {}) {
|
|
53
|
+
return requireProvider(id).runAction(action, options)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function shutdownChannels() {
|
|
57
|
+
await Promise.allSettled(Array.from(providers.values()).map((provider) => provider.stop()))
|
|
58
|
+
}
|
|
@@ -26,9 +26,14 @@ const builtinCommandCatalog = [
|
|
|
26
26
|
argumentHint: '[scope]',
|
|
27
27
|
permissionNote: 'no edits',
|
|
28
28
|
},
|
|
29
|
+
{
|
|
30
|
+
name: 'summary',
|
|
31
|
+
description: 'Create a new chat with this conversation summarized to reduce context usage.',
|
|
32
|
+
argumentHint: '',
|
|
33
|
+
},
|
|
29
34
|
{
|
|
30
35
|
name: 'compact',
|
|
31
|
-
description: '
|
|
36
|
+
description: 'Compact this conversation context in place using the same rolling summary as auto-compaction.',
|
|
32
37
|
argumentHint: '',
|
|
33
38
|
},
|
|
34
39
|
{
|
|
@@ -373,6 +378,9 @@ export function parseInternalCommandInvocation(message) {
|
|
|
373
378
|
const compactMatch = text.match(/^\/compact(?:\s+([\s\S]*))?$/i)
|
|
374
379
|
if (compactMatch) return { type: 'compact', args: (compactMatch[1] || '').trim() }
|
|
375
380
|
|
|
381
|
+
const summaryMatch = text.match(/^\/summary(?:\s+([\s\S]*))?$/i)
|
|
382
|
+
if (summaryMatch) return { type: 'summary', args: (summaryMatch[1] || '').trim() }
|
|
383
|
+
|
|
376
384
|
const createMatch = text.match(/^\/command\s+new\s+([A-Za-z0-9][A-Za-z0-9-]*)\s*$/i)
|
|
377
385
|
if (createMatch) {
|
|
378
386
|
const name = normalizeCommandName(createMatch[1])
|
|
@@ -389,6 +397,10 @@ export async function handleInternalCommand(invocation, workspaceRoot, commandDi
|
|
|
389
397
|
return { compact: true, args: invocation.args || '' }
|
|
390
398
|
}
|
|
391
399
|
|
|
400
|
+
if (invocation.type === 'summary') {
|
|
401
|
+
return { summary: true, args: invocation.args || '' }
|
|
402
|
+
}
|
|
403
|
+
|
|
392
404
|
if (invocation.type === 'plan') {
|
|
393
405
|
if (!invocation.args) return 'Usage: /plan <task>'
|
|
394
406
|
return { plan: true, args: invocation.args }
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
function leadingIndent(line) {
|
|
2
|
+
const match = String(line || '').match(/^\s*/)
|
|
3
|
+
return match ? match[0].length : 0
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
function stripInlineComment(value) {
|
|
7
|
+
const trimmed = String(value ?? '').trim()
|
|
8
|
+
if (trimmed.startsWith('"') || trimmed.startsWith("'")) return trimmed
|
|
9
|
+
const index = trimmed.indexOf(' #')
|
|
10
|
+
return index >= 0 ? trimmed.slice(0, index).trimEnd() : trimmed
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function parseYamlScalar(value, options = {}) {
|
|
14
|
+
const trimmed = stripInlineComment(value)
|
|
15
|
+
if (!trimmed) return ''
|
|
16
|
+
if (options.booleans !== false) {
|
|
17
|
+
const normalized = trimmed.toLowerCase()
|
|
18
|
+
if (normalized === 'true') return true
|
|
19
|
+
if (normalized === 'false') return false
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (
|
|
23
|
+
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
24
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
|
25
|
+
) {
|
|
26
|
+
return trimmed
|
|
27
|
+
.slice(1, -1)
|
|
28
|
+
.replace(/\\"/g, '"')
|
|
29
|
+
.replace(/\\'/g, "'")
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return trimmed
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function collectIndentedBlock(lines, startIndex, parentIndent) {
|
|
36
|
+
const block = []
|
|
37
|
+
let index = startIndex
|
|
38
|
+
while (index < lines.length) {
|
|
39
|
+
const line = lines[index]
|
|
40
|
+
if (!line.trim()) {
|
|
41
|
+
block.push(line)
|
|
42
|
+
index++
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
if (leadingIndent(line) <= parentIndent) break
|
|
46
|
+
block.push(line)
|
|
47
|
+
index++
|
|
48
|
+
}
|
|
49
|
+
return { block, nextIndex: index }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseBlockScalar(lines, style) {
|
|
53
|
+
const nonEmpty = lines.filter((line) => line.trim())
|
|
54
|
+
const minIndent = nonEmpty.length
|
|
55
|
+
? Math.min(...nonEmpty.map((line) => leadingIndent(line)))
|
|
56
|
+
: 0
|
|
57
|
+
const unindented = lines.map((line) => line.slice(Math.min(minIndent, line.length)))
|
|
58
|
+
return style === '>'
|
|
59
|
+
? unindented.join(' ').replace(/\s+/g, ' ').trim()
|
|
60
|
+
: unindented.join('\n').trim()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function parseSimpleYamlMap(text, options = {}) {
|
|
64
|
+
const result = {}
|
|
65
|
+
const lines = String(text || '').split(/\r?\n/)
|
|
66
|
+
let index = 0
|
|
67
|
+
|
|
68
|
+
while (index < lines.length) {
|
|
69
|
+
const line = lines[index]
|
|
70
|
+
const trimmed = line.trim()
|
|
71
|
+
if (!trimmed || trimmed.startsWith('#') || leadingIndent(line) > 0) {
|
|
72
|
+
index++
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const match = line.match(/^([A-Za-z0-9_.-]+):(?:\s*(.*))?$/)
|
|
77
|
+
if (!match) {
|
|
78
|
+
index++
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const [, key, rawValue = ''] = match
|
|
83
|
+
const value = rawValue.trim()
|
|
84
|
+
|
|
85
|
+
if (value === '|' || value === '>') {
|
|
86
|
+
const { block, nextIndex } = collectIndentedBlock(lines, index + 1, 0)
|
|
87
|
+
result[key] = parseBlockScalar(block, value)
|
|
88
|
+
index = nextIndex
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (value) {
|
|
93
|
+
result[key] = parseYamlScalar(value, options)
|
|
94
|
+
index++
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const nested = {}
|
|
99
|
+
let nestedIndex = index + 1
|
|
100
|
+
while (nestedIndex < lines.length) {
|
|
101
|
+
const nestedLine = lines[nestedIndex]
|
|
102
|
+
const nestedTrimmed = nestedLine.trim()
|
|
103
|
+
if (!nestedTrimmed || nestedTrimmed.startsWith('#')) {
|
|
104
|
+
nestedIndex++
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const indent = leadingIndent(nestedLine)
|
|
109
|
+
if (indent <= 0) break
|
|
110
|
+
|
|
111
|
+
const nestedMatch = nestedLine.slice(indent).match(/^([A-Za-z0-9_.-]+):(?:\s*(.*))?$/)
|
|
112
|
+
if (!nestedMatch) {
|
|
113
|
+
nestedIndex++
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const [, nestedKey, nestedRawValue = ''] = nestedMatch
|
|
118
|
+
nested[nestedKey] = parseYamlScalar(nestedRawValue.trim(), options)
|
|
119
|
+
nestedIndex++
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
result[key] = Object.keys(nested).length ? nested : ''
|
|
123
|
+
index = Object.keys(nested).length ? nestedIndex : index + 1
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return result
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function parseFrontmatter(text, options = {}) {
|
|
130
|
+
const normalized = String(text || '').replace(/^\uFEFF/, '')
|
|
131
|
+
const match = normalized.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)([\s\S]*)$/)
|
|
132
|
+
if (!match) {
|
|
133
|
+
return options.requireFrontmatter ? null : { metadata: {}, frontmatter: '', body: normalized.trim() }
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
metadata: parseSimpleYamlMap(match[1], options),
|
|
137
|
+
frontmatter: match[1],
|
|
138
|
+
body: match[2].trim(),
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function firstString(...values) {
|
|
143
|
+
for (const value of values) {
|
|
144
|
+
if (typeof value === 'string' && value.trim()) return value.trim()
|
|
145
|
+
}
|
|
146
|
+
return undefined
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function firstOptionalBoolean(...values) {
|
|
150
|
+
for (const value of values) {
|
|
151
|
+
if (value === true || value === false) return value
|
|
152
|
+
if (typeof value === 'string') {
|
|
153
|
+
const normalized = value.trim().toLowerCase()
|
|
154
|
+
if (normalized === 'true') return true
|
|
155
|
+
if (normalized === 'false') return false
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return undefined
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function splitDelimitedList(value) {
|
|
162
|
+
if (Array.isArray(value)) return value.map((item) => String(item || '').trim()).filter(Boolean)
|
|
163
|
+
return String(value || '')
|
|
164
|
+
.split(',')
|
|
165
|
+
.map((item) => item.trim())
|
|
166
|
+
.filter(Boolean)
|
|
167
|
+
}
|
package/server/index.mjs
CHANGED
|
@@ -27,13 +27,17 @@ import { handleMcpApi } from './routes/mcp.mjs'
|
|
|
27
27
|
import { handlePluginsApi } from './routes/plugins.mjs'
|
|
28
28
|
import { handleWorkspaceApi, handleGitApi } from './routes/workspace.mjs'
|
|
29
29
|
import { handleTerminalApi, handleTerminalUpgrade } from './routes/terminal.mjs'
|
|
30
|
+
import { handleChannelsApi } from './routes/channels.mjs'
|
|
31
|
+
import { handleModelsApi } from './routes/models.mjs'
|
|
30
32
|
import { serveStatic } from './routes/static.mjs'
|
|
31
33
|
import { logger, flushLogger } from './utils/logger.mjs'
|
|
34
|
+
import { getPackageInfo, checkForUpdates, installLatestVersion } from './utils/package-update.mjs'
|
|
32
35
|
import { installAiHttpLogger } from './ai-http-logger.mjs'
|
|
33
36
|
import { isLoopbackAddress, getLanUrls } from './utils/network.mjs'
|
|
34
37
|
import { parseCookies } from './share-store.mjs'
|
|
35
38
|
import { lanAccessCookieName, verifyLanAccessToken } from './lan-access-store.mjs'
|
|
36
39
|
import { shutdown as shutdownAgentManager, resetStaleTaskStatuses } from './agent-manager.mjs'
|
|
40
|
+
import { initializeChannels, shutdownChannels } from './channels/registry.mjs'
|
|
37
41
|
import { shutdownMcpConnections } from './mcp/registry.mjs'
|
|
38
42
|
import { shutdownTerminalSessions } from './terminal/terminal-manager.mjs'
|
|
39
43
|
|
|
@@ -54,8 +58,9 @@ if (!['127.0.0.1', 'localhost'].includes(host) && process.env.QUICKFORGE_ALLOW_R
|
|
|
54
58
|
const port = Number(process.env.QUICKFORGE_PORT || (isDev ? 32176 : 5176))
|
|
55
59
|
const vitePort = Number(process.env.QUICKFORGE_VITE_PORT || 5176)
|
|
56
60
|
let restartInProgress = false
|
|
61
|
+
let updateInProgress = false
|
|
57
62
|
|
|
58
|
-
setDefaultWorkspaceRoot(process.env.QUICKFORGE_WORKSPACE_DIR ||
|
|
63
|
+
setDefaultWorkspaceRoot(process.env.QUICKFORGE_WORKSPACE_DIR || path.join(dataDir, 'workspace'))
|
|
59
64
|
installAiHttpLogger()
|
|
60
65
|
|
|
61
66
|
function getRestartSupport() {
|
|
@@ -139,6 +144,7 @@ async function performRestart() {
|
|
|
139
144
|
stopVite()
|
|
140
145
|
await shutdownAgentManager()
|
|
141
146
|
await shutdownMcpConnections()
|
|
147
|
+
await shutdownChannels()
|
|
142
148
|
shutdownTerminalSessions()
|
|
143
149
|
await closeHttpServer()
|
|
144
150
|
process.exit(0)
|
|
@@ -169,6 +175,29 @@ async function requestRestart() {
|
|
|
169
175
|
return { ok: true, restarting: true, bootId }
|
|
170
176
|
}
|
|
171
177
|
|
|
178
|
+
async function updateQuickForge() {
|
|
179
|
+
if (updateInProgress) {
|
|
180
|
+
const error = new Error('Update already in progress')
|
|
181
|
+
error.statusCode = 423
|
|
182
|
+
throw error
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
updateInProgress = true
|
|
186
|
+
try {
|
|
187
|
+
const update = await checkForUpdates(projectRoot)
|
|
188
|
+
if (!update.updateAvailable) {
|
|
189
|
+
return { ...update, ok: true, updated: false }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
logger.info(`Updating QuickForge from ${update.currentVersion} to ${update.latestVersion}.`)
|
|
193
|
+
await installLatestVersion(update.name, { cwd: projectRoot })
|
|
194
|
+
logger.info('QuickForge update completed.')
|
|
195
|
+
return { ...update, ok: true, updated: true }
|
|
196
|
+
} finally {
|
|
197
|
+
updateInProgress = false
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
172
201
|
// --- Route dispatching ---
|
|
173
202
|
async function handleApi(req, res, url) {
|
|
174
203
|
const pathname = url.pathname
|
|
@@ -216,6 +245,12 @@ async function handleApi(req, res, url) {
|
|
|
216
245
|
return
|
|
217
246
|
}
|
|
218
247
|
|
|
248
|
+
// Custom model management (connection test)
|
|
249
|
+
if (pathname === '/api/models/test-connection') {
|
|
250
|
+
await handleModelsApi(req, res, url)
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
|
|
219
254
|
// Skills
|
|
220
255
|
if (pathname === '/api/skills' || pathname.startsWith('/api/skills/')) {
|
|
221
256
|
await handleSkillsApi(req, res, url)
|
|
@@ -228,6 +263,14 @@ async function handleApi(req, res, url) {
|
|
|
228
263
|
return
|
|
229
264
|
}
|
|
230
265
|
|
|
266
|
+
// Channels
|
|
267
|
+
if (pathname === '/api/channels' || pathname.startsWith('/api/channels/')) {
|
|
268
|
+
await handleChannelsApi(req, res, url, {
|
|
269
|
+
isLocalRequest: isLoopbackAddress(req.socket.remoteAddress),
|
|
270
|
+
})
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
|
|
231
274
|
// Plugins
|
|
232
275
|
if (pathname === '/api/plugins' || pathname.startsWith('/api/plugins/')) {
|
|
233
276
|
await handlePluginsApi(req, res, url)
|
|
@@ -241,7 +284,7 @@ async function handleApi(req, res, url) {
|
|
|
241
284
|
}
|
|
242
285
|
|
|
243
286
|
// Project workspace inspector routes
|
|
244
|
-
if (pathname === '/api/workspace/tree' || pathname === '/api/workspace/file' || pathname === '/api/workspace/resolve-path') {
|
|
287
|
+
if (pathname === '/api/workspace/tree' || pathname === '/api/workspace/file' || pathname === '/api/workspace/resolve-path' || pathname.startsWith('/api/workspace/preview/')) {
|
|
245
288
|
await handleWorkspaceApi(req, res, url)
|
|
246
289
|
return
|
|
247
290
|
}
|
|
@@ -288,10 +331,14 @@ async function handleApi(req, res, url) {
|
|
|
288
331
|
}
|
|
289
332
|
|
|
290
333
|
// System routes
|
|
291
|
-
if (pathname === '/api/system/status' || pathname === '/api/system/restart' || pathname === '/api/system/network' || pathname === '/api/system/terminal-shell') {
|
|
334
|
+
if (pathname === '/api/system/status' || pathname === '/api/system/restart' || pathname === '/api/system/network' || pathname === '/api/system/terminal-shell' || pathname === '/api/system/about' || pathname === '/api/system/update/check' || pathname === '/api/system/update') {
|
|
292
335
|
await handleSystemApi(req, res, url, {
|
|
293
336
|
getSystemStatus,
|
|
294
337
|
requestRestart,
|
|
338
|
+
getPackageInfo: () => getPackageInfo(projectRoot),
|
|
339
|
+
checkForUpdates: () => checkForUpdates(projectRoot),
|
|
340
|
+
updateQuickForge,
|
|
341
|
+
isLocalRequest: isLoopbackAddress(req.socket.remoteAddress),
|
|
295
342
|
getTerminalShellSetting: readTerminalShellSetting,
|
|
296
343
|
updateTerminalShellSetting,
|
|
297
344
|
getTerminalShellConfig: readTerminalShellConfig,
|
|
@@ -532,6 +579,7 @@ server.on('upgrade', (req, socket, head) => {
|
|
|
532
579
|
})
|
|
533
580
|
|
|
534
581
|
await ensureStorage()
|
|
582
|
+
initializeChannels({ projectRoot })
|
|
535
583
|
await resetStaleTaskStatuses()
|
|
536
584
|
await initializeActiveProject()
|
|
537
585
|
setActiveWorkspaceRootForFilesystem(getWorkspaceRoot())
|
|
@@ -577,6 +625,7 @@ async function gracefulShutdown(signal) {
|
|
|
577
625
|
stopVite()
|
|
578
626
|
await shutdownAgentManager()
|
|
579
627
|
await shutdownMcpConnections()
|
|
628
|
+
await shutdownChannels()
|
|
580
629
|
shutdownTerminalSessions()
|
|
581
630
|
flushLogger()
|
|
582
631
|
process.exit(0)
|