@leviyuan/lodestar 0.1.4 → 0.1.6
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/daemon.ts +24 -1
- package/package.json +1 -1
- package/src/cards.ts +71 -36
- package/src/session.ts +96 -30
package/daemon.ts
CHANGED
|
@@ -108,6 +108,18 @@ async function handleMessage(data: any): Promise<void> {
|
|
|
108
108
|
if (await session.runCommand(text)) return
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
// Pending AskUserQuestion: route the message as a custom answer
|
|
112
|
+
// instead of opening a new turn. This is how custom-text answers
|
|
113
|
+
// work in this version — Feishu schema 2.0 doesn't support form/
|
|
114
|
+
// input elements, so the chat box itself is the input. Only applies
|
|
115
|
+
// to text-only messages (an image attachment opens a new turn as
|
|
116
|
+
// usual). Bare-word commands have already been intercepted above.
|
|
117
|
+
if (msgType === 'text' && text && session.hasPendingAsk()) {
|
|
118
|
+
const userId = message.sender?.sender_id?.open_id ?? ''
|
|
119
|
+
await session.onAskMessageAnswer(text, userId)
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
|
|
111
123
|
let filePath: string | undefined
|
|
112
124
|
if (msgType === 'image' && contentObj.image_key) {
|
|
113
125
|
filePath = await feishu.downloadAttachment(message.message_id, contentObj.image_key, 'image')
|
|
@@ -140,9 +152,20 @@ async function handleCardAction(data: any): Promise<any> {
|
|
|
140
152
|
case 'menu':
|
|
141
153
|
await session.onUserMessage(`(menu choice ${value.choice + 1})`)
|
|
142
154
|
return { toast: { type: 'success', content: 'OK' } }
|
|
143
|
-
case 'ask':
|
|
155
|
+
case 'ask': {
|
|
156
|
+
// Custom-text branch: form submit packages the input under
|
|
157
|
+
// `form_value`. Try a couple of plausible keys since the exact
|
|
158
|
+
// shape can drift between Feishu schema versions; fall back to
|
|
159
|
+
// empty (onAskCustomAnswer ignores blank).
|
|
160
|
+
if (value.custom) {
|
|
161
|
+
const fv = action?.form_value ?? action?.input ?? {}
|
|
162
|
+
const customText: string = fv?.custom_answer ?? action?.input_value ?? ''
|
|
163
|
+
await session.onAskCustomAnswer(value.tool_use_id, value.question_idx ?? 0, customText, userId)
|
|
164
|
+
return { toast: { type: customText.trim() ? 'success' : 'error', content: customText.trim() ? '已回答' : '请输入答案' } }
|
|
165
|
+
}
|
|
144
166
|
await session.onAskAnswer(value.tool_use_id, value.question_idx ?? 0, value.option_idx, userId)
|
|
145
167
|
return { toast: { type: 'success', content: '已回答' } }
|
|
168
|
+
}
|
|
146
169
|
}
|
|
147
170
|
return { toast: { type: 'info', content: 'unknown action' } }
|
|
148
171
|
}
|
package/package.json
CHANGED
package/src/cards.ts
CHANGED
|
@@ -332,56 +332,57 @@ export interface AskQuestion {
|
|
|
332
332
|
* the first question only and a text-only listing for the rest (an
|
|
333
333
|
* acceptable limitation — these are rare in practice and we can lift
|
|
334
334
|
* it once the UX is validated). */
|
|
335
|
+
/** Final-state info for an AskUserQuestion panel. Mutually-exclusive
|
|
336
|
+
* branches inside: either the user picked one of the SDK-provided
|
|
337
|
+
* options (`optionIdx`), or they typed a free-form answer
|
|
338
|
+
* (`customText`). When both are absent the panel is still pending. */
|
|
339
|
+
export interface AskAnswered {
|
|
340
|
+
optionIdx?: number
|
|
341
|
+
customText?: string
|
|
342
|
+
user?: string
|
|
343
|
+
}
|
|
344
|
+
|
|
335
345
|
export function askUserQuestionElement(
|
|
336
346
|
i: number,
|
|
337
347
|
toolUseId: string,
|
|
338
348
|
questions: AskQuestion[],
|
|
339
349
|
status: '🤔' | '✅' | '❌' = '🤔',
|
|
340
|
-
|
|
341
|
-
* option turns into plain text (no click target). Used after the
|
|
342
|
-
* user has answered so the panel freezes in a sensible terminal
|
|
343
|
-
* state instead of inviting another click. */
|
|
344
|
-
pickedOptionIdx?: number,
|
|
345
|
-
resolvedNote?: string,
|
|
350
|
+
answered?: AskAnswered,
|
|
346
351
|
): object {
|
|
347
352
|
const primary = questions[0]
|
|
353
|
+
const isAnswered = !!answered && (answered.optionIdx !== undefined || !!answered.customText)
|
|
348
354
|
const headerTag = primary?.header ? ` · ${primary.header}` : ''
|
|
349
|
-
|
|
355
|
+
let headerText: string
|
|
356
|
+
if (isAnswered) {
|
|
357
|
+
// Surface the picked answer in the header so a collapsed panel
|
|
358
|
+
// still tells the reader what was chosen.
|
|
359
|
+
const picked = answered!.customText
|
|
360
|
+
?? (answered!.optionIdx !== undefined ? primary?.options[answered!.optionIdx]?.label : undefined)
|
|
361
|
+
?? '?'
|
|
362
|
+
headerText = `${status} 已回答${headerTag}:${picked}`
|
|
363
|
+
} else {
|
|
364
|
+
headerText = `${status} 🤔 AskUserQuestion${headerTag}`
|
|
365
|
+
}
|
|
350
366
|
const bodyElements: any[] = []
|
|
351
367
|
if (primary) {
|
|
352
368
|
bodyElements.push({ tag: 'markdown', content: `**${primary.question}**` })
|
|
353
|
-
// One row per option, each a full-width
|
|
354
|
-
// the entire row
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
// the callbacks — selected option marked ✅, others dimmed.
|
|
369
|
+
// One row per option. While pending, each row is a full-width
|
|
370
|
+
// interactive_container so the entire row is the click target.
|
|
371
|
+
// Once answered, rows degrade to plain markdown (Feishu has no
|
|
372
|
+
// generic non-interactive container — `div` doesn't exist, it
|
|
373
|
+
// rejects the PUT with code 300121).
|
|
359
374
|
for (let optIdx = 0; optIdx < primary.options.length; optIdx++) {
|
|
360
375
|
const opt = primary.options[optIdx]
|
|
361
|
-
const isPicked =
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
? `**✅ ${opt.label}**`
|
|
365
|
-
: isAnswered
|
|
366
|
-
? `~~${opt.label}~~`
|
|
367
|
-
: `**${opt.label}**`
|
|
368
|
-
const descLine = opt.description ? `\n${opt.description}` : ''
|
|
369
|
-
const rowContent = { tag: 'markdown', content: `${labelLine}${descLine}` }
|
|
370
|
-
if (isAnswered) {
|
|
371
|
-
// Frozen — no behaviors, plain container so it stops looking
|
|
372
|
-
// clickable.
|
|
373
|
-
bodyElements.push({
|
|
374
|
-
tag: 'div',
|
|
375
|
-
elements: [rowContent],
|
|
376
|
-
})
|
|
377
|
-
} else {
|
|
376
|
+
const isPicked = answered?.optionIdx === optIdx
|
|
377
|
+
const desc = opt.description ? ` · ${opt.description}` : ''
|
|
378
|
+
if (!isAnswered) {
|
|
378
379
|
bodyElements.push({
|
|
379
380
|
tag: 'interactive_container',
|
|
380
381
|
background_style: 'default',
|
|
381
382
|
has_border: true,
|
|
382
383
|
corner_radius: '6px',
|
|
383
384
|
padding: '8px 12px',
|
|
384
|
-
margin: '4px
|
|
385
|
+
margin: '4px 0px 4px 0px',
|
|
385
386
|
behaviors: [{
|
|
386
387
|
type: 'callback',
|
|
387
388
|
value: {
|
|
@@ -391,13 +392,40 @@ export function askUserQuestionElement(
|
|
|
391
392
|
option_idx: optIdx,
|
|
392
393
|
},
|
|
393
394
|
}],
|
|
394
|
-
elements: [
|
|
395
|
+
elements: [{ tag: 'markdown', content: `**${opt.label}**${desc}` }],
|
|
396
|
+
})
|
|
397
|
+
} else if (isPicked) {
|
|
398
|
+
bodyElements.push({
|
|
399
|
+
tag: 'markdown',
|
|
400
|
+
content: `✅ **${opt.label}**${desc}`,
|
|
401
|
+
})
|
|
402
|
+
} else {
|
|
403
|
+
bodyElements.push({
|
|
404
|
+
tag: 'markdown',
|
|
405
|
+
content: `~~◯ ${opt.label}${desc}~~`,
|
|
395
406
|
})
|
|
396
407
|
}
|
|
397
408
|
}
|
|
409
|
+
// Custom-answer hint (pending state only). Feishu schema 2.0
|
|
410
|
+
// doesn't actually have `form` / `input` elements — `tag: form`
|
|
411
|
+
// gets rejected with code 300315. Instead we route custom
|
|
412
|
+
// answers through a plain chat message: daemon.handleMessage
|
|
413
|
+
// detects a pending ask and forwards the next inbound text as
|
|
414
|
+
// the answer, no new turn opened. This is also more Feishu-
|
|
415
|
+
// native: the chat input is right there, no extra widget.
|
|
416
|
+
if (!isAnswered) {
|
|
417
|
+
bodyElements.push({
|
|
418
|
+
tag: 'markdown',
|
|
419
|
+
content: '_💬 也可以直接在群里回复你的答案(裸词命令 `hi`/`kill`/`restart`/`clear` 仍然优先)_',
|
|
420
|
+
})
|
|
421
|
+
} else if (answered?.customText) {
|
|
422
|
+
bodyElements.push({
|
|
423
|
+
tag: 'markdown',
|
|
424
|
+
content: `✏️ **自定义回答**:${answered.customText}`,
|
|
425
|
+
})
|
|
426
|
+
}
|
|
398
427
|
}
|
|
399
|
-
// Secondary questions
|
|
400
|
-
// panels when actually requested by a real prompt).
|
|
428
|
+
// Secondary questions (rare): text-only listing. TODO when needed.
|
|
401
429
|
for (let qi = 1; qi < questions.length; qi++) {
|
|
402
430
|
const q = questions[qi]
|
|
403
431
|
const opts = q.options.map(o => ` - ${o.label}${o.description ? ` — ${o.description}` : ''}`).join('\n')
|
|
@@ -406,12 +434,19 @@ export function askUserQuestionElement(
|
|
|
406
434
|
content: `\n---\n**(其他问题 #${qi + 1}, 暂未支持回答)** ${q.question}\n${opts}`,
|
|
407
435
|
})
|
|
408
436
|
}
|
|
409
|
-
if (
|
|
437
|
+
if (isAnswered && answered?.user) {
|
|
438
|
+
bodyElements.push({
|
|
439
|
+
tag: 'markdown',
|
|
440
|
+
content: `\n*— 由 ${answered.user} 回答*`,
|
|
441
|
+
})
|
|
442
|
+
}
|
|
410
443
|
return {
|
|
411
444
|
tag: 'collapsible_panel',
|
|
412
445
|
element_id: ELEMENTS.tool(i),
|
|
413
446
|
header: { title: { tag: 'plain_text', content: headerText } },
|
|
414
|
-
|
|
447
|
+
// Collapse once answered: header carries the picked answer, body
|
|
448
|
+
// is just history at that point.
|
|
449
|
+
expanded: !isAnswered,
|
|
415
450
|
elements: bodyElements,
|
|
416
451
|
}
|
|
417
452
|
}
|
package/src/session.ts
CHANGED
|
@@ -79,8 +79,9 @@ export class Session {
|
|
|
79
79
|
/** Open AskUserQuestion tool calls — keyed by tool_use_id. The SDK
|
|
80
80
|
* routes AskUserQuestion through the can_use_tool flow even under
|
|
81
81
|
* bypass; we have to thread the permission `requestId` through here
|
|
82
|
-
* so
|
|
83
|
-
*
|
|
82
|
+
* so the answer (option click OR custom text submit) can resolve
|
|
83
|
+
* the permission with `updatedInput.answers` populated.
|
|
84
|
+
* `deferredAnswer` covers the race where the user clicks/submits
|
|
84
85
|
* BEFORE can_use_tool arrives (addTool fires on the assistant
|
|
85
86
|
* message; can_use_tool is a separate control_request that lands
|
|
86
87
|
* slightly later). */
|
|
@@ -88,7 +89,12 @@ export class Session {
|
|
|
88
89
|
questions: cards.AskQuestion[]
|
|
89
90
|
i: number
|
|
90
91
|
requestId?: string
|
|
91
|
-
deferredAnswer?: {
|
|
92
|
+
deferredAnswer?: {
|
|
93
|
+
questionIdx: number
|
|
94
|
+
optionIdx?: number
|
|
95
|
+
customText?: string
|
|
96
|
+
user: string
|
|
97
|
+
}
|
|
92
98
|
}>()
|
|
93
99
|
private turnCounter = 0
|
|
94
100
|
// Last seen sessionId — preserved across `kill`/`stop` so a later
|
|
@@ -326,43 +332,96 @@ export class Session {
|
|
|
326
332
|
}
|
|
327
333
|
}
|
|
328
334
|
|
|
329
|
-
/**
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
|
|
334
|
-
|
|
335
|
+
/** True iff there's at least one open AskUserQuestion awaiting an
|
|
336
|
+
* answer in this session. `daemon.handleMessage` uses this to
|
|
337
|
+
* decide whether an inbound chat message should be a custom answer
|
|
338
|
+
* (routed to onAskMessageAnswer) instead of opening a new turn. */
|
|
339
|
+
hasPendingAsk(): boolean {
|
|
340
|
+
return this.pendingAsks.size > 0
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Funnel an arbitrary chat message into the oldest pending ask as
|
|
344
|
+
* a `customText` answer. Falls back to a normal turn if for some
|
|
345
|
+
* reason there's nothing pending (defensive — handleMessage's gate
|
|
346
|
+
* should prevent that). Picks the first entry by Map insertion
|
|
347
|
+
* order, which is the earliest unanswered ask. */
|
|
348
|
+
async onAskMessageAnswer(text: string, user: string): Promise<void> {
|
|
349
|
+
const firstEntry = this.pendingAsks.entries().next()
|
|
350
|
+
if (firstEntry.done) {
|
|
351
|
+
log(`session "${this.sessionName}": onAskMessageAnswer with no pending — falling back to onUserMessage`)
|
|
352
|
+
await this.onUserMessage(text)
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
const [toolUseId, _pending] = firstEntry.value
|
|
356
|
+
await this.onAskCustomAnswer(toolUseId, 0, text, user)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Click handler for an AskUserQuestion option button. Dispatches
|
|
360
|
+
* to `resolveAsk` if can_use_tool has already arrived, otherwise
|
|
361
|
+
* parks the click on the pendingAsk record for renderPermission
|
|
362
|
+
* to drain. */
|
|
335
363
|
async onAskAnswer(toolUseId: string, questionIdx: number, optionIdx: number, user: string): Promise<void> {
|
|
336
364
|
const pending = this.pendingAsks.get(toolUseId)
|
|
337
365
|
if (!pending) { log(`session "${this.sessionName}": stray ask answer for ${toolUseId}`); return }
|
|
338
366
|
if (pending.requestId) {
|
|
339
|
-
this.resolveAsk(toolUseId, pending.requestId, questionIdx, optionIdx, user)
|
|
367
|
+
this.resolveAsk(toolUseId, pending.requestId, { questionIdx, optionIdx, user })
|
|
340
368
|
} else {
|
|
341
|
-
// can_use_tool hasn't landed yet — park the click. renderPermission
|
|
342
|
-
// will drain it the moment the request arrives.
|
|
343
369
|
pending.deferredAnswer = { questionIdx, optionIdx, user }
|
|
344
370
|
log(`session "${this.sessionName}": ask answer deferred for ${toolUseId} (no requestId yet)`)
|
|
345
371
|
}
|
|
346
372
|
}
|
|
347
373
|
|
|
374
|
+
/** Form-submit handler for the custom-answer input on an
|
|
375
|
+
* AskUserQuestion panel. Same dispatch pattern as `onAskAnswer`,
|
|
376
|
+
* just routes a free-form string into `resolveAsk` instead of an
|
|
377
|
+
* option index. Empty/whitespace input is ignored (no answer
|
|
378
|
+
* sent, panel stays pending). */
|
|
379
|
+
async onAskCustomAnswer(toolUseId: string, questionIdx: number, customText: string, user: string): Promise<void> {
|
|
380
|
+
const pending = this.pendingAsks.get(toolUseId)
|
|
381
|
+
if (!pending) { log(`session "${this.sessionName}": stray ask custom for ${toolUseId}`); return }
|
|
382
|
+
const trimmed = (customText ?? '').trim()
|
|
383
|
+
if (!trimmed) { log(`session "${this.sessionName}": empty custom answer, ignoring`); return }
|
|
384
|
+
if (pending.requestId) {
|
|
385
|
+
this.resolveAsk(toolUseId, pending.requestId, { questionIdx, customText: trimmed, user })
|
|
386
|
+
} else {
|
|
387
|
+
pending.deferredAnswer = { questionIdx, customText: trimmed, user }
|
|
388
|
+
log(`session "${this.sessionName}": ask custom deferred for ${toolUseId} (no requestId yet)`)
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
348
392
|
/** Settle an AskUserQuestion: emit the permission allow with the
|
|
349
|
-
* picked option folded into `updatedInput.answers`
|
|
350
|
-
* shape the SDK reads to synthesise the tool_result
|
|
351
|
-
* the panel ✅, drop bookkeeping. Single source
|
|
352
|
-
*
|
|
393
|
+
* picked option OR custom text folded into `updatedInput.answers`
|
|
394
|
+
* (this is the shape the SDK reads to synthesise the tool_result
|
|
395
|
+
* string), repaint the panel ✅, drop bookkeeping. Single source
|
|
396
|
+
* of truth — option-click, custom-submit, and the deferred drain
|
|
397
|
+
* all go through here. */
|
|
353
398
|
private resolveAsk(
|
|
354
399
|
toolUseId: string,
|
|
355
400
|
requestId: string,
|
|
356
|
-
questionIdx: number,
|
|
357
|
-
optionIdx: number,
|
|
358
|
-
user: string,
|
|
401
|
+
answer: { questionIdx: number; optionIdx?: number; customText?: string; user: string },
|
|
359
402
|
): void {
|
|
360
403
|
const pending = this.pendingAsks.get(toolUseId)
|
|
361
404
|
if (!pending) return
|
|
362
|
-
const q = pending.questions[questionIdx]
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
405
|
+
const q = pending.questions[answer.questionIdx]
|
|
406
|
+
if (!q) {
|
|
407
|
+
log(`session "${this.sessionName}": ask answer out of range q=${answer.questionIdx}`)
|
|
408
|
+
return
|
|
409
|
+
}
|
|
410
|
+
// Determine the literal string that will become the SDK's
|
|
411
|
+
// `answers` value for this question — custom wins if both are
|
|
412
|
+
// somehow set (shouldn't happen, but defensive).
|
|
413
|
+
let answerValue: string
|
|
414
|
+
if (answer.customText) {
|
|
415
|
+
answerValue = answer.customText
|
|
416
|
+
} else if (answer.optionIdx !== undefined) {
|
|
417
|
+
const opt = q.options?.[answer.optionIdx]
|
|
418
|
+
if (!opt) {
|
|
419
|
+
log(`session "${this.sessionName}": ask option out of range o=${answer.optionIdx}`)
|
|
420
|
+
return
|
|
421
|
+
}
|
|
422
|
+
answerValue = opt.label
|
|
423
|
+
} else {
|
|
424
|
+
log(`session "${this.sessionName}": resolveAsk called with neither optionIdx nor customText`)
|
|
366
425
|
return
|
|
367
426
|
}
|
|
368
427
|
const turn = this.currentTurn
|
|
@@ -370,9 +429,8 @@ export class Session {
|
|
|
370
429
|
const originalInput = meta?.input ?? {}
|
|
371
430
|
// SDK keys the answer record by the question's text — confirmed
|
|
372
431
|
// by the v0.1.2 jsonl trace (empty record formatted to "User has
|
|
373
|
-
// answered your questions: .").
|
|
374
|
-
|
|
375
|
-
const answers: Record<string, string> = { [q.question]: opt.label }
|
|
432
|
+
// answered your questions: .").
|
|
433
|
+
const answers: Record<string, string> = { [q.question]: answerValue }
|
|
376
434
|
this.proc?.sendPermissionResponse(requestId, 'allow', {
|
|
377
435
|
updatedInput: { ...originalInput, answers },
|
|
378
436
|
})
|
|
@@ -382,8 +440,11 @@ export class Session {
|
|
|
382
440
|
if (turn && meta) {
|
|
383
441
|
meta.output = JSON.stringify({ answers })
|
|
384
442
|
meta.isError = false
|
|
385
|
-
const
|
|
386
|
-
|
|
443
|
+
const el = cards.askUserQuestionElement(meta.i, toolUseId, pending.questions, '✅', {
|
|
444
|
+
optionIdx: answer.optionIdx,
|
|
445
|
+
customText: answer.customText,
|
|
446
|
+
user: answer.user || '匿名',
|
|
447
|
+
})
|
|
387
448
|
void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
|
|
388
449
|
}
|
|
389
450
|
|
|
@@ -747,9 +808,14 @@ export class Session {
|
|
|
747
808
|
ask.requestId = req.request_id
|
|
748
809
|
this.pendingPermissions.set(req.request_id, { toolUseId })
|
|
749
810
|
if (ask.deferredAnswer) {
|
|
750
|
-
const
|
|
811
|
+
const d = ask.deferredAnswer
|
|
751
812
|
ask.deferredAnswer = undefined
|
|
752
|
-
this.resolveAsk(toolUseId, req.request_id,
|
|
813
|
+
this.resolveAsk(toolUseId, req.request_id, {
|
|
814
|
+
questionIdx: d.questionIdx,
|
|
815
|
+
optionIdx: d.optionIdx,
|
|
816
|
+
customText: d.customText,
|
|
817
|
+
user: d.user,
|
|
818
|
+
})
|
|
753
819
|
}
|
|
754
820
|
return
|
|
755
821
|
}
|