@geekbeer/minion 4.5.1 → 4.7.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/core/db/migrations/20260607000000_chat_runs.js +48 -0
- package/core/db/migrations/20260607120000_page_recipes_ready_selector.js +22 -0
- package/core/lib/chat-run-manager.js +406 -0
- package/core/lib/web-extract/extractor.js +27 -7
- package/core/lib/web-extract/playwright-runner.js +199 -1
- package/core/lib/web-extract/recipe-generator.js +19 -2
- package/core/routes/web.js +12 -3
- package/core/stores/chat-store.js +119 -2
- package/core/stores/page-recipe-store.js +9 -7
- package/docs/api-reference.md +66 -4
- package/docs/task-guides.md +20 -2
- package/linux/routes/chat.js +158 -193
- package/package.json +1 -1
- package/rules/core.md +9 -1
- package/win/routes/chat.js +154 -157
package/docs/task-guides.md
CHANGED
|
@@ -20,16 +20,34 @@ curl -X POST http://localhost:8080/api/web/extract \
|
|
|
20
20
|
|
|
21
21
|
このAPIは内部で Playwright + Readability を回して **メインセッションには結果 JSON だけ返す** ため、Playwright MCP を使うときに起きていたチャットコンテキストのトークン肥大化が回避できる。
|
|
22
22
|
|
|
23
|
+
### SPA / 無限スクロールのページ (v4.7.0〜)
|
|
24
|
+
|
|
25
|
+
- **SPA (React/Vue 等でクライアント描画するページ)** もそのまま `/api/web/extract` でよい。内部で DOM が静止するまで待ってから抽出するため、空シェルを掴む問題は解消済み。
|
|
26
|
+
- **無限スクロール / 「もっと見る」で件数が増えるページ**で十分な件数を確保したい場合は `scroll` オプションを付ける。**どれだけ集めるかは呼び出し側が決める**:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
curl -X POST http://localhost:8080/api/web/extract \
|
|
30
|
+
-H "Authorization: Bearer $API_TOKEN" -H "Content-Type: application/json" \
|
|
31
|
+
-d '{
|
|
32
|
+
"url": "対象URL",
|
|
33
|
+
"scroll": { "strategy": "count", "targetItems": 50, "maxScrolls": 20, "maxMs": 15000 }
|
|
34
|
+
}' | jq
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
- `count`: 目標件数 (`targetItems`) に達するまでスクロール。`untilStable`: 件数が増えなくなるまで。`fixed`: 回数固定。
|
|
38
|
+
- レスポンスの `scrollInfo.reachedTarget` が `false` なら上限で打ち切られている → `maxScrolls` / `maxMs` を上げて再試行する。
|
|
39
|
+
- スクロール上限はサーバー側でクランプされる (maxScrolls≤50 / maxMs≤45s)。それ以上の網羅が要るなら**ページネーションURLをループ呼び出し**する方が確実。
|
|
40
|
+
|
|
23
41
|
### Playwright MCP を使うべき場面
|
|
24
42
|
|
|
25
43
|
`/api/web/extract` で対応できないのは以下のケース。このときだけ `mcp__playwright__*` を使う:
|
|
26
44
|
|
|
27
45
|
- ログイン必須ページ (Cookie/2FA 等の認証必要)
|
|
28
46
|
- フォーム入力・複数ページ遷移を伴う操作
|
|
29
|
-
-
|
|
47
|
+
- 「もっと見る」**ボタンのクリック**で追加ロードするページ (スクロールでは増えないもの。`scroll` はスクロール式のみ対応)
|
|
30
48
|
- Lancers コンペ応募など、明らかに対話的操作が必要なフロー
|
|
31
49
|
|
|
32
|
-
|
|
50
|
+
**単純な閲覧・抽出 (SPA・無限スクロール含む) では MCP を使わない。**
|
|
33
51
|
|
|
34
52
|
### よくあるパターン
|
|
35
53
|
|
package/linux/routes/chat.js
CHANGED
|
@@ -27,15 +27,15 @@ const todoStore = require('../../core/stores/todo-store')
|
|
|
27
27
|
const { runEndOfDay } = require('../../core/lib/end-of-day')
|
|
28
28
|
const { DATA_DIR } = require('../../core/lib/platform')
|
|
29
29
|
const { getActivePrimary } = require('../../core/llm-plugins/lib/active')
|
|
30
|
-
|
|
31
|
-
/** @type {import('child_process').ChildProcess | null} */
|
|
32
|
-
let activeChatChild = null
|
|
30
|
+
const chatRunManager = require('../../core/lib/chat-run-manager')
|
|
33
31
|
|
|
34
32
|
/**
|
|
35
33
|
* Register chat routes as Fastify plugin
|
|
36
34
|
* @param {import('fastify').FastifyInstance} fastify
|
|
37
35
|
*/
|
|
38
36
|
async function chatRoutes(fastify) {
|
|
37
|
+
// Sweep stale runs from a previous boot and prune old run logs.
|
|
38
|
+
chatRunManager.init()
|
|
39
39
|
|
|
40
40
|
// POST /api/chat - Send a message and get streaming response
|
|
41
41
|
fastify.post('/api/chat', async (request, reply) => {
|
|
@@ -71,25 +71,66 @@ async function chatRoutes(fastify) {
|
|
|
71
71
|
console.error('[Chat] failed to persist user message:', err.message)
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
// Start a DETACHED run. The LLM is owned by the run manager, not this HTTP
|
|
75
|
+
// request — if the connection drops the run keeps going. This response is
|
|
76
|
+
// just a live tail of the run's event log.
|
|
77
|
+
const runId = chatRunManager.start({
|
|
78
|
+
sessionId: currentSessionId,
|
|
79
|
+
pendingSessionId,
|
|
80
|
+
workspaceId,
|
|
81
|
+
invoke: buildInvoke(prompt, currentSessionId),
|
|
82
|
+
})
|
|
83
|
+
|
|
74
84
|
// Take over response handling from Fastify for SSE streaming
|
|
75
85
|
reply.hijack()
|
|
76
|
-
|
|
77
|
-
|
|
86
|
+
const res = reply.raw
|
|
87
|
+
res.writeHead(200, {
|
|
78
88
|
'Content-Type': 'text/event-stream',
|
|
79
89
|
'Cache-Control': 'no-cache',
|
|
80
90
|
'Connection': 'keep-alive',
|
|
81
91
|
})
|
|
82
|
-
|
|
92
|
+
res.flushHeaders()
|
|
83
93
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
94
|
+
// Tell the client the run id first so it can reconnect via
|
|
95
|
+
// GET /api/chat/stream after a drop without losing the in-flight run.
|
|
96
|
+
res.write(`data: ${JSON.stringify({ type: 'run', run_id: runId, session_id: currentSessionId })}\n\n`)
|
|
97
|
+
|
|
98
|
+
await tailRunToResponse(res, runId, 0)
|
|
99
|
+
res.end()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
// GET /api/chat/stream - Reconnect to an in-flight (or recently finished) run
|
|
103
|
+
// and resume tailing from `cursor`. This is what makes detached execution
|
|
104
|
+
// observable: a refreshed tab / dropped network reattaches here instead of
|
|
105
|
+
// starting a new run. Either run_id or workspace_id (to auto-find the active
|
|
106
|
+
// run) must be provided.
|
|
107
|
+
fastify.get('/api/chat/stream', async (request, reply) => {
|
|
108
|
+
if (!verifyToken(request)) {
|
|
109
|
+
reply.code(401)
|
|
110
|
+
return { success: false, error: 'Unauthorized' }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const workspaceId = request.query?.workspace_id || null
|
|
114
|
+
const runId = request.query?.run_id || chatRunManager.getActiveRunId(workspaceId)
|
|
115
|
+
const cursor = parseInt(request.query?.cursor, 10) || 0
|
|
116
|
+
|
|
117
|
+
if (!runId) {
|
|
118
|
+
reply.code(404)
|
|
119
|
+
return { success: false, error: 'No active run' }
|
|
90
120
|
}
|
|
91
121
|
|
|
92
|
-
reply.
|
|
122
|
+
reply.hijack()
|
|
123
|
+
const res = reply.raw
|
|
124
|
+
res.writeHead(200, {
|
|
125
|
+
'Content-Type': 'text/event-stream',
|
|
126
|
+
'Cache-Control': 'no-cache',
|
|
127
|
+
'Connection': 'keep-alive',
|
|
128
|
+
})
|
|
129
|
+
res.flushHeaders()
|
|
130
|
+
res.write(`data: ${JSON.stringify({ type: 'run', run_id: runId })}\n\n`)
|
|
131
|
+
|
|
132
|
+
await tailRunToResponse(res, runId, cursor)
|
|
133
|
+
res.end()
|
|
93
134
|
})
|
|
94
135
|
|
|
95
136
|
// GET /api/chat/session - Get active chat session for a workspace
|
|
@@ -101,8 +142,14 @@ async function chatRoutes(fastify) {
|
|
|
101
142
|
|
|
102
143
|
const workspaceId = request.query?.workspace_id || null
|
|
103
144
|
const session = chatStore.load(workspaceId)
|
|
145
|
+
|
|
146
|
+
// Surface any in-flight detached run so the client can attach to its live
|
|
147
|
+
// stream (GET /api/chat/stream) instead of rendering static history.
|
|
148
|
+
const activeRunId = chatRunManager.getActiveRunId(workspaceId)
|
|
149
|
+
const activeRun = activeRunId ? chatRunManager.getRunInfo(activeRunId) : null
|
|
150
|
+
|
|
104
151
|
if (!session) {
|
|
105
|
-
return { success: true, session: null }
|
|
152
|
+
return { success: true, session: null, active_run: activeRun }
|
|
106
153
|
}
|
|
107
154
|
|
|
108
155
|
return {
|
|
@@ -115,6 +162,7 @@ async function chatRoutes(fastify) {
|
|
|
115
162
|
created_at: session.created_at,
|
|
116
163
|
updated_at: session.updated_at,
|
|
117
164
|
},
|
|
165
|
+
active_run: activeRun,
|
|
118
166
|
}
|
|
119
167
|
})
|
|
120
168
|
|
|
@@ -159,31 +207,26 @@ async function chatRoutes(fastify) {
|
|
|
159
207
|
return { success: true }
|
|
160
208
|
})
|
|
161
209
|
|
|
162
|
-
// POST /api/chat/abort -
|
|
210
|
+
// POST /api/chat/abort - Explicitly kill the active run for a workspace.
|
|
211
|
+
// This is now the ONLY path that terminates the LLM. A dropped connection no
|
|
212
|
+
// longer does. Optionally accepts an explicit run_id.
|
|
163
213
|
fastify.post('/api/chat/abort', async (request, reply) => {
|
|
164
214
|
if (!verifyToken(request)) {
|
|
165
215
|
reply.code(401)
|
|
166
216
|
return { success: false, error: 'Unauthorized' }
|
|
167
217
|
}
|
|
168
218
|
|
|
169
|
-
|
|
219
|
+
const workspaceId = request.body?.workspace_id || null
|
|
220
|
+
const runId = request.body?.run_id || chatRunManager.getActiveRunId(workspaceId)
|
|
221
|
+
if (!runId) {
|
|
170
222
|
return { success: false, error: 'No active chat process' }
|
|
171
223
|
}
|
|
172
224
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
setTimeout(() => {
|
|
179
|
-
try {
|
|
180
|
-
if (activeChatChild && activeChatChild.pid === pid) {
|
|
181
|
-
activeChatChild.kill('SIGKILL')
|
|
182
|
-
}
|
|
183
|
-
} catch { /* already dead */ }
|
|
184
|
-
}, 2000)
|
|
185
|
-
|
|
186
|
-
return { success: true }
|
|
225
|
+
const aborted = chatRunManager.abort(runId)
|
|
226
|
+
if (!aborted) {
|
|
227
|
+
return { success: false, error: 'No active chat process' }
|
|
228
|
+
}
|
|
229
|
+
return { success: true, run_id: runId }
|
|
187
230
|
})
|
|
188
231
|
|
|
189
232
|
// POST /api/chat/reset - Carry over relevant messages and start fresh session
|
|
@@ -409,9 +452,11 @@ async function buildContextPrefix(message, context, sessionId, workspaceId, refe
|
|
|
409
452
|
'このAPIは内部で Playwright + Readability を回し、抽出済みJSONだけを返すため、',
|
|
410
453
|
'DOM全体がチャットに流れ込んでトークン肥大化することを防げる。',
|
|
411
454
|
'初回アクセスで学習したセレクタはSQLiteにキャッシュされ、2回目以降はLLM呼び出しなしで抽出される。',
|
|
455
|
+
'SPA(クライアント描画)も内部でDOM静止を待ってから抽出するためそのまま利用可。',
|
|
456
|
+
'無限スクロールで件数を増やしたい場合は `"scroll": {"strategy":"count","targetItems":50}` を付ける(どれだけ集めるかは呼び出し側が指定)。',
|
|
412
457
|
'',
|
|
413
|
-
'Playwright MCP (`mcp__playwright__*`) は
|
|
414
|
-
'
|
|
458
|
+
'Playwright MCP (`mcp__playwright__*`) は **ログイン・フォーム入力・複数画面の対話操作・「もっと見る」ボタン**が必要な場合のみ使用する。',
|
|
459
|
+
'単純な閲覧・要約・一覧取得用途(SPA・無限スクロール含む)ではMCPを使わない。',
|
|
415
460
|
'',
|
|
416
461
|
'HQ (`*.minion-agent.com`) のページURLは **Playwrightで開かずAPIで取得する**。ノートは `GET $HQ_URL/api/minion/workspaces/:wsId/notes/:id`、タスクは `GET $HQ_URL/api/minion/projects/:pid/tasks/:id`。チャットで参照されたノート/タスクの本文は上記「参照ノート」「参照チケット」ブロックに既に注入済み。',
|
|
417
462
|
''
|
|
@@ -570,99 +615,109 @@ function getLlmBinary() {
|
|
|
570
615
|
}
|
|
571
616
|
|
|
572
617
|
/**
|
|
573
|
-
*
|
|
574
|
-
*
|
|
575
|
-
*
|
|
576
|
-
*
|
|
618
|
+
* Tail a detached run into an SSE response. Subscribes from `cursor`, forwards
|
|
619
|
+
* each event (seq-stamped, so the client can resume), and resolves on the
|
|
620
|
+
* terminal `done` event OR when the client disconnects. Disconnecting only
|
|
621
|
+
* stops the tail — it never touches the underlying LLM process.
|
|
622
|
+
*
|
|
623
|
+
* @param {import('http').ServerResponse} res
|
|
624
|
+
* @param {string} runId
|
|
625
|
+
* @param {number} cursor last sequence the client already has
|
|
626
|
+
*/
|
|
627
|
+
function tailRunToResponse(res, runId, cursor) {
|
|
628
|
+
return new Promise(resolve => {
|
|
629
|
+
let settled = false
|
|
630
|
+
let unsubscribe = () => {}
|
|
631
|
+
const finish = () => {
|
|
632
|
+
if (settled) return
|
|
633
|
+
settled = true
|
|
634
|
+
try { unsubscribe() } catch { /* ignore */ }
|
|
635
|
+
resolve()
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
unsubscribe = chatRunManager.subscribe(runId, cursor, event => {
|
|
639
|
+
try { res.write(`data: ${JSON.stringify(event)}\n\n`) } catch { /* socket gone */ }
|
|
640
|
+
// The manager always closes a run with a terminal `done` (an `error`
|
|
641
|
+
// precedes it when the run failed), so `done` is our single stop signal.
|
|
642
|
+
if (event.type === 'done') finish()
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
// Client went away (tab close, navigation, proxy timeout, network blip):
|
|
646
|
+
// stop forwarding, but leave the run running so a reconnect can resume it.
|
|
647
|
+
res.on('close', finish)
|
|
648
|
+
})
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Build the LLM executor for a run. The executor is LLM-specific (plugin vs
|
|
653
|
+
* legacy CLI) but knows nothing about HTTP — it emits normalized wire events
|
|
654
|
+
* and returns the collected result. The run manager owns persistence, the
|
|
655
|
+
* terminal `done` event, and abort.
|
|
656
|
+
*
|
|
657
|
+
* @param {string} prompt
|
|
658
|
+
* @param {string|null} sessionId
|
|
659
|
+
* @returns {(emit: (e: object) => void, activeRef: { current: any }) => Promise<{ fullResponse: string, resolvedSessionId: string|null, turnCount: number }>}
|
|
577
660
|
*/
|
|
578
|
-
|
|
579
|
-
// Plugin system path: Primary is set → delegate to plugin
|
|
661
|
+
function buildInvoke(prompt, sessionId) {
|
|
580
662
|
const primary = getActivePrimary()
|
|
581
663
|
if (primary) {
|
|
582
|
-
return
|
|
664
|
+
return (emit, activeRef) => invokeViaPlugin(primary, prompt, sessionId, emit, activeRef)
|
|
583
665
|
}
|
|
584
|
-
return
|
|
666
|
+
return (emit, activeRef) => invokeViaLegacy(prompt, sessionId, emit, activeRef)
|
|
585
667
|
}
|
|
586
668
|
|
|
587
|
-
async function
|
|
669
|
+
async function invokeViaPlugin(plugin, prompt, sessionId, emit, activeRef) {
|
|
588
670
|
const input = { prompt }
|
|
589
|
-
const activeRef = { current: null }
|
|
590
|
-
activeChatChild = { kill: () => activeRef.current?.kill?.('SIGTERM') }
|
|
591
|
-
|
|
592
671
|
let fullResponse = ''
|
|
593
672
|
let resolvedSessionId = sessionId || null
|
|
594
673
|
let turnCount = 0
|
|
595
674
|
|
|
596
|
-
|
|
675
|
+
// Translate plugin events into wire events. `session` is captured (not
|
|
676
|
+
// forwarded); delta/text are accumulated; everything else is forwarded as-is.
|
|
677
|
+
const onEvent = event => {
|
|
597
678
|
if (event.type === 'session') {
|
|
598
679
|
resolvedSessionId = event.sessionId
|
|
599
680
|
} else if (event.type === 'delta') {
|
|
600
681
|
fullResponse += event.content
|
|
601
|
-
|
|
682
|
+
emit({ type: 'delta', content: event.content })
|
|
602
683
|
} else if (event.type === 'text') {
|
|
603
684
|
fullResponse += event.content
|
|
604
|
-
|
|
685
|
+
emit({ type: 'text', content: event.content })
|
|
605
686
|
turnCount++
|
|
606
687
|
} else {
|
|
607
|
-
|
|
688
|
+
emit(event)
|
|
608
689
|
}
|
|
609
690
|
}
|
|
610
691
|
|
|
611
|
-
res.on('close', () => { activeRef.current?.kill?.('SIGTERM') })
|
|
612
|
-
|
|
613
|
-
let pluginError = null
|
|
614
692
|
try {
|
|
615
693
|
let output
|
|
616
694
|
if (plugin.capabilities.streaming && typeof plugin.stream === 'function') {
|
|
617
|
-
output = await plugin.stream(input,
|
|
695
|
+
output = await plugin.stream(input, onEvent, { resumeSessionId: sessionId, activeChildRef: activeRef })
|
|
618
696
|
} else {
|
|
619
697
|
output = await plugin.invoke(input)
|
|
620
698
|
if (output.text) {
|
|
621
699
|
fullResponse = output.text
|
|
622
|
-
|
|
700
|
+
emit({ type: 'text', content: output.text })
|
|
623
701
|
turnCount = 1
|
|
624
702
|
}
|
|
625
703
|
if (output.error) {
|
|
626
|
-
|
|
704
|
+
emit({ type: 'error', error: output.error.message })
|
|
627
705
|
}
|
|
628
706
|
}
|
|
629
707
|
resolvedSessionId = output?.metadata?.sessionId || resolvedSessionId
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
pluginError = err
|
|
633
|
-
} finally {
|
|
634
|
-
activeChatChild = null
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
// For new sessions, the user message was persisted under pendingSessionId
|
|
638
|
-
// before the plugin call. Rekey it to the real session ID now that we
|
|
639
|
-
// know it. If the plugin never reported a session ID, leave the message
|
|
640
|
-
// under the pending key so the history isn't lost.
|
|
641
|
-
const persistSessionId = resolvedSessionId || pendingSessionId
|
|
642
|
-
try {
|
|
643
|
-
if (!sessionId && resolvedSessionId && pendingSessionId && pendingSessionId !== resolvedSessionId) {
|
|
644
|
-
chatStore.rekeySession(pendingSessionId, resolvedSessionId)
|
|
645
|
-
}
|
|
646
|
-
if (fullResponse && persistSessionId) {
|
|
647
|
-
await chatStore.addMessage(persistSessionId, { role: 'assistant', content: fullResponse }, turnCount, workspaceId)
|
|
708
|
+
if (output?.error) {
|
|
709
|
+
emit({ type: 'error', error: output.error.message, partial: !!fullResponse })
|
|
648
710
|
}
|
|
649
711
|
} catch (err) {
|
|
650
|
-
|
|
712
|
+
// Surface the error inline but still return the partial so the manager can
|
|
713
|
+
// persist it. (A spawn failure typically yields no partial.)
|
|
714
|
+
emit({ type: 'error', error: err.message, partial: !!fullResponse })
|
|
651
715
|
}
|
|
652
716
|
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
let totalTurnCount = turnCount
|
|
656
|
-
try {
|
|
657
|
-
const session = await chatStore.load(workspaceId)
|
|
658
|
-
totalTurnCount = session?.turn_count || turnCount
|
|
659
|
-
} catch (err) {
|
|
660
|
-
console.error('[Chat] failed to load session for done event:', err.message)
|
|
661
|
-
}
|
|
662
|
-
res.write(`data: ${JSON.stringify({ type: 'done', session_id: resolvedSessionId, turn_count: totalTurnCount })}\n\n`)
|
|
717
|
+
return { fullResponse, resolvedSessionId, turnCount }
|
|
663
718
|
}
|
|
664
719
|
|
|
665
|
-
function
|
|
720
|
+
function invokeViaLegacy(prompt, sessionId, emit, activeRef) {
|
|
666
721
|
return new Promise((resolve, reject) => {
|
|
667
722
|
const binaryName = getLlmBinary()
|
|
668
723
|
if (!binaryName) {
|
|
@@ -673,20 +728,8 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
|
|
|
673
728
|
const binary = fs.existsSync(binaryPath) ? binaryPath : binaryName
|
|
674
729
|
|
|
675
730
|
// Build CLI args (no --max-turns: allow unlimited turns for task completion)
|
|
676
|
-
const args = [
|
|
677
|
-
|
|
678
|
-
'--verbose',
|
|
679
|
-
'--model', 'sonnet',
|
|
680
|
-
'--output-format', 'stream-json',
|
|
681
|
-
]
|
|
682
|
-
|
|
683
|
-
// Resume existing session
|
|
684
|
-
if (sessionId) {
|
|
685
|
-
args.push('--resume', sessionId)
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
// Prompt is passed via stdin (not as CLI argument) to avoid
|
|
689
|
-
// shell argument parsing issues with spaces/special characters.
|
|
731
|
+
const args = ['-p', '--verbose', '--model', 'sonnet', '--output-format', 'stream-json']
|
|
732
|
+
if (sessionId) args.push('--resume', sessionId)
|
|
690
733
|
|
|
691
734
|
console.log(`[Chat] spawning: ${binary} ${sessionId ? `--resume ${sessionId}` : '(new session)'} (cwd: ${config.HOME_DIR})`)
|
|
692
735
|
|
|
@@ -698,21 +741,17 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
|
|
|
698
741
|
timeout: 3600000, // 60 min — allow long-running tasks to complete
|
|
699
742
|
})
|
|
700
743
|
|
|
701
|
-
//
|
|
702
|
-
|
|
744
|
+
// Expose the child to the run manager for abort. NOT tied to any HTTP request.
|
|
745
|
+
activeRef.current = child
|
|
703
746
|
|
|
704
|
-
// Write prompt to stdin and close — claude -p reads from stdin when no positional arg
|
|
705
747
|
child.stdin.write(prompt)
|
|
706
748
|
child.stdin.end()
|
|
707
|
-
|
|
708
749
|
console.log(`[Chat] child PID: ${child.pid}`)
|
|
709
750
|
|
|
710
751
|
let fullResponse = ''
|
|
711
752
|
let stderrBuffer = ''
|
|
712
753
|
let lineBuffer = ''
|
|
713
754
|
let resolvedSessionId = sessionId || null
|
|
714
|
-
|
|
715
|
-
// Block-type state tracking for correct event forwarding
|
|
716
755
|
let currentBlockType = null // 'text' | 'tool_use' | null
|
|
717
756
|
let currentToolName = null
|
|
718
757
|
let toolInputBuffer = ''
|
|
@@ -721,7 +760,6 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
|
|
|
721
760
|
child.stdout.on('data', (data) => {
|
|
722
761
|
lineBuffer += data.toString()
|
|
723
762
|
const parts = lineBuffer.split('\n')
|
|
724
|
-
// Keep the last (potentially incomplete) line in the buffer
|
|
725
763
|
lineBuffer = parts.pop() || ''
|
|
726
764
|
|
|
727
765
|
for (const line of parts) {
|
|
@@ -729,96 +767,69 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
|
|
|
729
767
|
try {
|
|
730
768
|
const parsed = JSON.parse(line)
|
|
731
769
|
|
|
732
|
-
// system init event — capture session_id
|
|
733
770
|
if (parsed.type === 'system' && parsed.session_id) {
|
|
734
771
|
resolvedSessionId = parsed.session_id
|
|
735
772
|
console.log(`[Chat] session_id: ${resolvedSessionId}`)
|
|
736
773
|
}
|
|
737
774
|
|
|
738
|
-
// content_block_start — track block type
|
|
739
775
|
if (parsed.type === 'content_block_start') {
|
|
740
776
|
const blockType = parsed.content_block?.type
|
|
741
777
|
if (blockType === 'tool_use') {
|
|
742
778
|
currentBlockType = 'tool_use'
|
|
743
779
|
currentToolName = parsed.content_block.name || 'unknown'
|
|
744
780
|
toolInputBuffer = ''
|
|
745
|
-
|
|
746
|
-
type: 'tool_start',
|
|
747
|
-
tool: currentToolName,
|
|
748
|
-
})
|
|
749
|
-
res.write(`data: ${event}\n\n`)
|
|
781
|
+
emit({ type: 'tool_start', tool: currentToolName })
|
|
750
782
|
} else if (blockType === 'text') {
|
|
751
783
|
currentBlockType = 'text'
|
|
752
784
|
}
|
|
753
785
|
}
|
|
754
786
|
|
|
755
|
-
// content_block_delta — handle both text and tool input
|
|
756
787
|
if (parsed.type === 'content_block_delta') {
|
|
757
788
|
const deltaType = parsed.delta?.type
|
|
758
789
|
if (deltaType === 'input_json_delta' && currentBlockType === 'tool_use') {
|
|
759
|
-
// Accumulate tool input JSON
|
|
760
790
|
const partial = parsed.delta.partial_json || ''
|
|
761
791
|
if (partial) {
|
|
762
792
|
toolInputBuffer += partial
|
|
763
|
-
|
|
764
|
-
res.write(`data: ${event}\n\n`)
|
|
793
|
+
emit({ type: 'tool_input_delta', partial_json: partial })
|
|
765
794
|
}
|
|
766
795
|
} else {
|
|
767
|
-
// Text delta
|
|
768
796
|
const delta = parsed.delta?.text || ''
|
|
769
797
|
if (delta) {
|
|
770
798
|
fullResponse += delta
|
|
771
|
-
|
|
772
|
-
res.write(`data: ${event}\n\n`)
|
|
799
|
+
emit({ type: 'delta', content: delta })
|
|
773
800
|
}
|
|
774
801
|
}
|
|
775
802
|
}
|
|
776
803
|
|
|
777
|
-
// content_block_stop — only emit tool_end for tool_use blocks (bug fix)
|
|
778
804
|
if (parsed.type === 'content_block_stop') {
|
|
779
805
|
if (currentBlockType === 'tool_use') {
|
|
780
|
-
// Try to parse the accumulated tool input
|
|
781
806
|
let parsedInput = null
|
|
782
807
|
try {
|
|
783
808
|
if (toolInputBuffer) parsedInput = JSON.parse(toolInputBuffer)
|
|
784
809
|
} catch { /* partial or invalid JSON */ }
|
|
785
|
-
|
|
786
|
-
type: 'tool_end',
|
|
787
|
-
tool: currentToolName,
|
|
788
|
-
input: parsedInput,
|
|
789
|
-
})
|
|
790
|
-
res.write(`data: ${event}\n\n`)
|
|
810
|
+
emit({ type: 'tool_end', tool: currentToolName, input: parsedInput })
|
|
791
811
|
}
|
|
792
812
|
currentBlockType = null
|
|
793
813
|
currentToolName = null
|
|
794
814
|
toolInputBuffer = ''
|
|
795
815
|
}
|
|
796
816
|
|
|
797
|
-
// assistant message — count turns and forward text blocks
|
|
798
817
|
if (parsed.type === 'assistant' && parsed.message) {
|
|
799
818
|
turnCount++
|
|
800
819
|
for (const block of (parsed.message.content || [])) {
|
|
801
820
|
if (block.type === 'text') {
|
|
802
821
|
fullResponse += block.text
|
|
803
|
-
|
|
804
|
-
res.write(`data: ${event}\n\n`)
|
|
822
|
+
emit({ type: 'text', content: block.text })
|
|
805
823
|
}
|
|
806
824
|
}
|
|
807
825
|
} else if (parsed.type === 'result') {
|
|
808
|
-
// result event — forward but do NOT overwrite accumulated fullResponse
|
|
809
826
|
const resultText = parsed.result || ''
|
|
810
827
|
if (resultText) {
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
// Use result as fullResponse only if nothing was accumulated
|
|
814
|
-
// (single-turn responses without deltas)
|
|
815
|
-
if (!fullResponse) {
|
|
816
|
-
fullResponse = resultText
|
|
817
|
-
}
|
|
828
|
+
emit({ type: 'result', content: resultText })
|
|
829
|
+
if (!fullResponse) fullResponse = resultText
|
|
818
830
|
}
|
|
819
831
|
}
|
|
820
832
|
} catch {
|
|
821
|
-
// Non-JSON line — ignore
|
|
822
833
|
console.warn(`[Chat] ignoring non-JSON line: ${line.substring(0, 80)}`)
|
|
823
834
|
}
|
|
824
835
|
}
|
|
@@ -830,72 +841,26 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
|
|
|
830
841
|
console.error(`[Chat] stderr: ${text}`)
|
|
831
842
|
})
|
|
832
843
|
|
|
833
|
-
child.on('close',
|
|
834
|
-
|
|
835
|
-
|
|
844
|
+
child.on('close', (code) => {
|
|
845
|
+
activeRef.current = null
|
|
836
846
|
console.log(`[Chat] child closed: code=${code}, response=${fullResponse.length}chars, turns=${turnCount}, stderr=${stderrBuffer.length}bytes, session=${resolvedSessionId}`)
|
|
837
847
|
if (stderrBuffer.trim()) {
|
|
838
848
|
console.log(`[Chat] final stderr (tail 500): ${stderrBuffer.slice(-500)}`)
|
|
839
849
|
}
|
|
840
|
-
|
|
841
|
-
// For new sessions, the user message was already persisted under
|
|
842
|
-
// pendingSessionId before spawn. Rekey it to the real session ID when
|
|
843
|
-
// Claude CLI reported one; otherwise leave the message under the
|
|
844
|
-
// pending key so the history is never lost on crash.
|
|
845
|
-
const persistSessionId = resolvedSessionId || pendingSessionId
|
|
846
|
-
try {
|
|
847
|
-
if (!sessionId && resolvedSessionId && pendingSessionId && pendingSessionId !== resolvedSessionId) {
|
|
848
|
-
chatStore.rekeySession(pendingSessionId, resolvedSessionId)
|
|
849
|
-
}
|
|
850
|
-
// Persist any partial response we managed to collect, even on error
|
|
851
|
-
if (fullResponse && persistSessionId) {
|
|
852
|
-
await chatStore.addMessage(persistSessionId, { role: 'assistant', content: fullResponse }, turnCount, workspaceId)
|
|
853
|
-
}
|
|
854
|
-
} catch (err) {
|
|
855
|
-
console.error('[Chat] failed to persist assistant message:', err.message)
|
|
856
|
-
}
|
|
857
|
-
|
|
858
850
|
if (code !== 0) {
|
|
859
851
|
const errorMsg = stderrBuffer.trim() || `Claude CLI exited with code ${code}`
|
|
860
852
|
console.error(`[Chat] CLI failed (exit ${code}, partial=${!!fullResponse}): ${errorMsg}`)
|
|
861
|
-
|
|
862
|
-
type: 'error',
|
|
863
|
-
error: errorMsg,
|
|
864
|
-
partial: !!fullResponse,
|
|
865
|
-
exit_code: code,
|
|
866
|
-
})
|
|
867
|
-
res.write(`data: ${errorEvent}\n\n`)
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
// Load current turn count from session for the done event
|
|
871
|
-
let totalTurnCount = turnCount
|
|
872
|
-
try {
|
|
873
|
-
const session = await chatStore.load(workspaceId)
|
|
874
|
-
totalTurnCount = session?.turn_count || turnCount
|
|
875
|
-
} catch (err) {
|
|
876
|
-
console.error('[Chat] failed to load session for done event:', err.message)
|
|
853
|
+
emit({ type: 'error', error: errorMsg, partial: !!fullResponse, exit_code: code })
|
|
877
854
|
}
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
session_id: resolvedSessionId,
|
|
882
|
-
turn_count: totalTurnCount,
|
|
883
|
-
})
|
|
884
|
-
res.write(`data: ${doneEvent}\n\n`)
|
|
885
|
-
resolve()
|
|
855
|
+
// Resolve (don't reject) on non-zero exit so the manager persists the
|
|
856
|
+
// partial response and emits a clean terminal `done`.
|
|
857
|
+
resolve({ fullResponse, resolvedSessionId, turnCount })
|
|
886
858
|
})
|
|
887
859
|
|
|
888
860
|
child.on('error', (err) => {
|
|
889
|
-
|
|
861
|
+
activeRef.current = null
|
|
890
862
|
console.error(`[Chat] spawn error: ${err.message}`)
|
|
891
|
-
|
|
892
|
-
res.write(`data: ${errorEvent}\n\n`)
|
|
893
|
-
reject(err)
|
|
894
|
-
})
|
|
895
|
-
|
|
896
|
-
// Handle client disconnect
|
|
897
|
-
res.on('close', () => {
|
|
898
|
-
child.kill('SIGTERM')
|
|
863
|
+
reject(new Error(`Failed to start Claude CLI: ${err.message}`))
|
|
899
864
|
})
|
|
900
865
|
})
|
|
901
866
|
}
|
package/package.json
CHANGED
package/rules/core.md
CHANGED
|
@@ -132,7 +132,15 @@ curl -X POST http://localhost:8080/api/web/extract \
|
|
|
132
132
|
|
|
133
133
|
レシピは初回アクセス時に LLM (Haiku) で生成・SQLite (`page_recipes` テーブル) に保存され、2回目以降の構造的に同じページでは LLM 呼び出しなしで抽出される。
|
|
134
134
|
|
|
135
|
-
|
|
135
|
+
**SPA / 無限スクロール (v4.7.0〜):** SPA (クライアント描画) は内部で DOM が静止するまで待ってから抽出するためそのまま利用できる。無限スクロールで件数を確保したい場合は `scroll` オプションを付ける(どれだけ集めるかは呼び出し側が指定。`scrollInfo.reachedTarget=false` なら上限打ち切りなので `maxScrolls`/`maxMs` を上げて再試行):
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
curl -X POST http://localhost:8080/api/web/extract \
|
|
139
|
+
-H "Authorization: Bearer $API_TOKEN" -H "Content-Type: application/json" \
|
|
140
|
+
-d '{"url": "対象URL", "scroll": {"strategy": "count", "targetItems": 50, "maxScrolls": 20}}'
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Playwright MCP (`mcp__playwright__*`) は **フォーム入力・クリック・複数画面遷移・「もっと見る」ボタンなど対話的な操作**が必要な場合のみ使用すること。単に「ページを読む」目的 (SPA・無限スクロール含む) では MCP を使わない。
|
|
136
144
|
|
|
137
145
|
**実験的機能**: レスポンス形状は予告なく変わる可能性がある。要件: (1) primary LLM 設定済み (`PUT /api/llm/config` で `claude` 等を選択、`hq llm primary <name>` でも可) または `ANTHROPIC_API_KEY` シークレット設定済み、(2) ホスト上で `npx playwright install chromium` 実行済み。primary LLM が設定されていれば API キー不要 (Claude Code CLI の認証情報を再利用)。
|
|
138
146
|
|