@leviyuan/lodestar 0.1.3 → 0.1.5
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 +12 -1
- package/package.json +1 -1
- package/src/cards.ts +111 -26
- package/src/session.ts +78 -30
package/daemon.ts
CHANGED
|
@@ -140,9 +140,20 @@ async function handleCardAction(data: any): Promise<any> {
|
|
|
140
140
|
case 'menu':
|
|
141
141
|
await session.onUserMessage(`(menu choice ${value.choice + 1})`)
|
|
142
142
|
return { toast: { type: 'success', content: 'OK' } }
|
|
143
|
-
case 'ask':
|
|
143
|
+
case 'ask': {
|
|
144
|
+
// Custom-text branch: form submit packages the input under
|
|
145
|
+
// `form_value`. Try a couple of plausible keys since the exact
|
|
146
|
+
// shape can drift between Feishu schema versions; fall back to
|
|
147
|
+
// empty (onAskCustomAnswer ignores blank).
|
|
148
|
+
if (value.custom) {
|
|
149
|
+
const fv = action?.form_value ?? action?.input ?? {}
|
|
150
|
+
const customText: string = fv?.custom_answer ?? action?.input_value ?? ''
|
|
151
|
+
await session.onAskCustomAnswer(value.tool_use_id, value.question_idx ?? 0, customText, userId)
|
|
152
|
+
return { toast: { type: customText.trim() ? 'success' : 'error', content: customText.trim() ? '已回答' : '请输入答案' } }
|
|
153
|
+
}
|
|
144
154
|
await session.onAskAnswer(value.tool_use_id, value.question_idx ?? 0, value.option_idx, userId)
|
|
145
155
|
return { toast: { type: 'success', content: '已回答' } }
|
|
156
|
+
}
|
|
146
157
|
}
|
|
147
158
|
return { toast: { type: 'info', content: 'unknown action' } }
|
|
148
159
|
}
|
package/package.json
CHANGED
package/src/cards.ts
CHANGED
|
@@ -332,31 +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
|
-
|
|
350
|
+
answered?: AskAnswered,
|
|
341
351
|
): object {
|
|
342
352
|
const primary = questions[0]
|
|
353
|
+
const isAnswered = !!answered && (answered.optionIdx !== undefined || !!answered.customText)
|
|
343
354
|
const headerTag = primary?.header ? ` · ${primary.header}` : ''
|
|
344
|
-
|
|
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
|
+
}
|
|
345
366
|
const bodyElements: any[] = []
|
|
346
367
|
if (primary) {
|
|
347
368
|
bodyElements.push({ tag: 'markdown', content: `**${primary.question}**` })
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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).
|
|
374
|
+
for (let optIdx = 0; optIdx < primary.options.length; optIdx++) {
|
|
375
|
+
const opt = primary.options[optIdx]
|
|
376
|
+
const isPicked = answered?.optionIdx === optIdx
|
|
377
|
+
const desc = opt.description ? ` · ${opt.description}` : ''
|
|
378
|
+
if (!isAnswered) {
|
|
379
|
+
bodyElements.push({
|
|
380
|
+
tag: 'interactive_container',
|
|
381
|
+
background_style: 'default',
|
|
382
|
+
has_border: true,
|
|
383
|
+
corner_radius: '6px',
|
|
384
|
+
padding: '8px 12px',
|
|
385
|
+
margin: '4px 0px 4px 0px',
|
|
360
386
|
behaviors: [{
|
|
361
387
|
type: 'callback',
|
|
362
388
|
value: {
|
|
@@ -366,18 +392,70 @@ export function askUserQuestionElement(
|
|
|
366
392
|
option_idx: optIdx,
|
|
367
393
|
},
|
|
368
394
|
}],
|
|
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}~~`,
|
|
406
|
+
})
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
// Custom-answer input (pending state only). Feishu form submit
|
|
410
|
+
// packages the input value under `form_value` in the callback
|
|
411
|
+
// payload; daemon.handleCardAction reads it as
|
|
412
|
+
// `value.form_value.custom_answer`.
|
|
413
|
+
if (!isAnswered) {
|
|
414
|
+
bodyElements.push({ tag: 'markdown', content: '_或自己输入:_' })
|
|
415
|
+
bodyElements.push({
|
|
416
|
+
tag: 'form',
|
|
417
|
+
name: `ask_form_${toolUseId.slice(-12)}`,
|
|
418
|
+
elements: [{
|
|
419
|
+
tag: 'column_set',
|
|
420
|
+
columns: [
|
|
421
|
+
{
|
|
422
|
+
tag: 'column', width: 'weighted', weight: 3, vertical_align: 'center',
|
|
423
|
+
elements: [{
|
|
424
|
+
tag: 'input',
|
|
425
|
+
name: 'custom_answer',
|
|
426
|
+
placeholder: { tag: 'plain_text', content: '自定义回答…' },
|
|
427
|
+
required: false,
|
|
428
|
+
}],
|
|
429
|
+
},
|
|
430
|
+
{
|
|
431
|
+
tag: 'column', width: 'weighted', weight: 1, vertical_align: 'center',
|
|
432
|
+
elements: [{
|
|
433
|
+
tag: 'button',
|
|
434
|
+
text: { tag: 'plain_text', content: '发送' },
|
|
435
|
+
type: 'primary',
|
|
436
|
+
form_action_type: 'submit',
|
|
437
|
+
behaviors: [{
|
|
438
|
+
type: 'callback',
|
|
439
|
+
value: {
|
|
440
|
+
kind: 'ask',
|
|
441
|
+
tool_use_id: toolUseId,
|
|
442
|
+
question_idx: 0,
|
|
443
|
+
custom: true,
|
|
444
|
+
},
|
|
445
|
+
}],
|
|
446
|
+
}],
|
|
447
|
+
},
|
|
448
|
+
],
|
|
369
449
|
}],
|
|
370
|
-
})
|
|
371
|
-
})
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
if (descLines) bodyElements.push({ tag: 'markdown', content: descLines })
|
|
450
|
+
})
|
|
451
|
+
} else if (answered?.customText) {
|
|
452
|
+
bodyElements.push({
|
|
453
|
+
tag: 'markdown',
|
|
454
|
+
content: `✏️ **自定义回答**:${answered.customText}`,
|
|
455
|
+
})
|
|
456
|
+
}
|
|
378
457
|
}
|
|
379
|
-
// Secondary questions
|
|
380
|
-
// panels when actually requested by a real prompt).
|
|
458
|
+
// Secondary questions (rare): text-only listing. TODO when needed.
|
|
381
459
|
for (let qi = 1; qi < questions.length; qi++) {
|
|
382
460
|
const q = questions[qi]
|
|
383
461
|
const opts = q.options.map(o => ` - ${o.label}${o.description ? ` — ${o.description}` : ''}`).join('\n')
|
|
@@ -386,12 +464,19 @@ export function askUserQuestionElement(
|
|
|
386
464
|
content: `\n---\n**(其他问题 #${qi + 1}, 暂未支持回答)** ${q.question}\n${opts}`,
|
|
387
465
|
})
|
|
388
466
|
}
|
|
389
|
-
if (
|
|
467
|
+
if (isAnswered && answered?.user) {
|
|
468
|
+
bodyElements.push({
|
|
469
|
+
tag: 'markdown',
|
|
470
|
+
content: `\n*— 由 ${answered.user} 回答*`,
|
|
471
|
+
})
|
|
472
|
+
}
|
|
390
473
|
return {
|
|
391
474
|
tag: 'collapsible_panel',
|
|
392
475
|
element_id: ELEMENTS.tool(i),
|
|
393
476
|
header: { title: { tag: 'plain_text', content: headerText } },
|
|
394
|
-
|
|
477
|
+
// Collapse once answered: header carries the picked answer, body
|
|
478
|
+
// is just history at that point.
|
|
479
|
+
expanded: !isAnswered,
|
|
395
480
|
elements: bodyElements,
|
|
396
481
|
}
|
|
397
482
|
}
|
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,72 @@ export class Session {
|
|
|
326
332
|
}
|
|
327
333
|
}
|
|
328
334
|
|
|
329
|
-
/** Click handler for an AskUserQuestion option button.
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
* the pendingAsk record) versus deferring (the rare race where the
|
|
334
|
-
* user clicks before can_use_tool lands). */
|
|
335
|
+
/** Click handler for an AskUserQuestion option button. Dispatches
|
|
336
|
+
* to `resolveAsk` if can_use_tool has already arrived, otherwise
|
|
337
|
+
* parks the click on the pendingAsk record for renderPermission
|
|
338
|
+
* to drain. */
|
|
335
339
|
async onAskAnswer(toolUseId: string, questionIdx: number, optionIdx: number, user: string): Promise<void> {
|
|
336
340
|
const pending = this.pendingAsks.get(toolUseId)
|
|
337
341
|
if (!pending) { log(`session "${this.sessionName}": stray ask answer for ${toolUseId}`); return }
|
|
338
342
|
if (pending.requestId) {
|
|
339
|
-
this.resolveAsk(toolUseId, pending.requestId, questionIdx, optionIdx, user)
|
|
343
|
+
this.resolveAsk(toolUseId, pending.requestId, { questionIdx, optionIdx, user })
|
|
340
344
|
} else {
|
|
341
|
-
// can_use_tool hasn't landed yet — park the click. renderPermission
|
|
342
|
-
// will drain it the moment the request arrives.
|
|
343
345
|
pending.deferredAnswer = { questionIdx, optionIdx, user }
|
|
344
346
|
log(`session "${this.sessionName}": ask answer deferred for ${toolUseId} (no requestId yet)`)
|
|
345
347
|
}
|
|
346
348
|
}
|
|
347
349
|
|
|
350
|
+
/** Form-submit handler for the custom-answer input on an
|
|
351
|
+
* AskUserQuestion panel. Same dispatch pattern as `onAskAnswer`,
|
|
352
|
+
* just routes a free-form string into `resolveAsk` instead of an
|
|
353
|
+
* option index. Empty/whitespace input is ignored (no answer
|
|
354
|
+
* sent, panel stays pending). */
|
|
355
|
+
async onAskCustomAnswer(toolUseId: string, questionIdx: number, customText: string, user: string): Promise<void> {
|
|
356
|
+
const pending = this.pendingAsks.get(toolUseId)
|
|
357
|
+
if (!pending) { log(`session "${this.sessionName}": stray ask custom for ${toolUseId}`); return }
|
|
358
|
+
const trimmed = (customText ?? '').trim()
|
|
359
|
+
if (!trimmed) { log(`session "${this.sessionName}": empty custom answer, ignoring`); return }
|
|
360
|
+
if (pending.requestId) {
|
|
361
|
+
this.resolveAsk(toolUseId, pending.requestId, { questionIdx, customText: trimmed, user })
|
|
362
|
+
} else {
|
|
363
|
+
pending.deferredAnswer = { questionIdx, customText: trimmed, user }
|
|
364
|
+
log(`session "${this.sessionName}": ask custom deferred for ${toolUseId} (no requestId yet)`)
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
348
368
|
/** 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
|
-
*
|
|
369
|
+
* picked option OR custom text folded into `updatedInput.answers`
|
|
370
|
+
* (this is the shape the SDK reads to synthesise the tool_result
|
|
371
|
+
* string), repaint the panel ✅, drop bookkeeping. Single source
|
|
372
|
+
* of truth — option-click, custom-submit, and the deferred drain
|
|
373
|
+
* all go through here. */
|
|
353
374
|
private resolveAsk(
|
|
354
375
|
toolUseId: string,
|
|
355
376
|
requestId: string,
|
|
356
|
-
questionIdx: number,
|
|
357
|
-
optionIdx: number,
|
|
358
|
-
user: string,
|
|
377
|
+
answer: { questionIdx: number; optionIdx?: number; customText?: string; user: string },
|
|
359
378
|
): void {
|
|
360
379
|
const pending = this.pendingAsks.get(toolUseId)
|
|
361
380
|
if (!pending) return
|
|
362
|
-
const q = pending.questions[questionIdx]
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
381
|
+
const q = pending.questions[answer.questionIdx]
|
|
382
|
+
if (!q) {
|
|
383
|
+
log(`session "${this.sessionName}": ask answer out of range q=${answer.questionIdx}`)
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
// Determine the literal string that will become the SDK's
|
|
387
|
+
// `answers` value for this question — custom wins if both are
|
|
388
|
+
// somehow set (shouldn't happen, but defensive).
|
|
389
|
+
let answerValue: string
|
|
390
|
+
if (answer.customText) {
|
|
391
|
+
answerValue = answer.customText
|
|
392
|
+
} else if (answer.optionIdx !== undefined) {
|
|
393
|
+
const opt = q.options?.[answer.optionIdx]
|
|
394
|
+
if (!opt) {
|
|
395
|
+
log(`session "${this.sessionName}": ask option out of range o=${answer.optionIdx}`)
|
|
396
|
+
return
|
|
397
|
+
}
|
|
398
|
+
answerValue = opt.label
|
|
399
|
+
} else {
|
|
400
|
+
log(`session "${this.sessionName}": resolveAsk called with neither optionIdx nor customText`)
|
|
366
401
|
return
|
|
367
402
|
}
|
|
368
403
|
const turn = this.currentTurn
|
|
@@ -370,9 +405,8 @@ export class Session {
|
|
|
370
405
|
const originalInput = meta?.input ?? {}
|
|
371
406
|
// SDK keys the answer record by the question's text — confirmed
|
|
372
407
|
// 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 }
|
|
408
|
+
// answered your questions: .").
|
|
409
|
+
const answers: Record<string, string> = { [q.question]: answerValue }
|
|
376
410
|
this.proc?.sendPermissionResponse(requestId, 'allow', {
|
|
377
411
|
updatedInput: { ...originalInput, answers },
|
|
378
412
|
})
|
|
@@ -382,8 +416,11 @@ export class Session {
|
|
|
382
416
|
if (turn && meta) {
|
|
383
417
|
meta.output = JSON.stringify({ answers })
|
|
384
418
|
meta.isError = false
|
|
385
|
-
const
|
|
386
|
-
|
|
419
|
+
const el = cards.askUserQuestionElement(meta.i, toolUseId, pending.questions, '✅', {
|
|
420
|
+
optionIdx: answer.optionIdx,
|
|
421
|
+
customText: answer.customText,
|
|
422
|
+
user: answer.user || '匿名',
|
|
423
|
+
})
|
|
387
424
|
void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
|
|
388
425
|
}
|
|
389
426
|
|
|
@@ -606,6 +643,12 @@ export class Session {
|
|
|
606
643
|
// can't discard the output after the first paint.
|
|
607
644
|
meta.output = output
|
|
608
645
|
meta.isError = isError
|
|
646
|
+
// AskUserQuestion already had its final panel painted by resolveAsk
|
|
647
|
+
// (✅ + the chosen option marked, others dimmed). The tool_result
|
|
648
|
+
// arriving here is just the SDK's synthesised echo — re-rendering
|
|
649
|
+
// via toolCallElement would clobber the nice option-row layout
|
|
650
|
+
// with a generic JSON dump. Bail out; the panel is done.
|
|
651
|
+
if (meta.name === 'AskUserQuestion') return
|
|
609
652
|
// Update the local todo mirror BEFORE rendering so the just-
|
|
610
653
|
// completed panel shows the new state too (e.g. a TaskCreate panel
|
|
611
654
|
// already lists the task it just created).
|
|
@@ -741,9 +784,14 @@ export class Session {
|
|
|
741
784
|
ask.requestId = req.request_id
|
|
742
785
|
this.pendingPermissions.set(req.request_id, { toolUseId })
|
|
743
786
|
if (ask.deferredAnswer) {
|
|
744
|
-
const
|
|
787
|
+
const d = ask.deferredAnswer
|
|
745
788
|
ask.deferredAnswer = undefined
|
|
746
|
-
this.resolveAsk(toolUseId, req.request_id,
|
|
789
|
+
this.resolveAsk(toolUseId, req.request_id, {
|
|
790
|
+
questionIdx: d.questionIdx,
|
|
791
|
+
optionIdx: d.optionIdx,
|
|
792
|
+
customText: d.customText,
|
|
793
|
+
user: d.user,
|
|
794
|
+
})
|
|
747
795
|
}
|
|
748
796
|
return
|
|
749
797
|
}
|