@alia.onl/sdk 11.0.0 → 11.1.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/CHANGELOG.md +14 -0
- package/package.json +1 -1
- package/src/components/AliaChatContent.tsx +1 -0
- package/src/components/AliaChatMessageList.tsx +27 -1
- package/src/components/QuestionCard.tsx +62 -0
- package/src/index.ts +8 -0
- package/src/lib/questions.ts +78 -0
- package/src/tools.ts +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# @alia.onl/sdk
|
|
2
2
|
|
|
3
|
+
## 11.1.0
|
|
4
|
+
|
|
5
|
+
Alia can ask clarifying questions, as ChatGPT and Claude do.
|
|
6
|
+
|
|
7
|
+
- **New:** `QuestionCard` (Bloom's `Questionnaire`: single or multiple choice, a free-text
|
|
8
|
+
"Other", one question per step), drawn under a reply that asked. `AliaChatMessageList`
|
|
9
|
+
renders it from the turn's `askUserQuestion` tool call; `AliaChatContent` sends the
|
|
10
|
+
answers as the person's next message (`onAnswerQuestions` on the list, for hosts that
|
|
11
|
+
compose their own).
|
|
12
|
+
- **New, pure** (root entry and `@alia.onl/sdk/tools`): `askedQuestions`, `formatAnswers`,
|
|
13
|
+
`isQuestionInvocation`, `ASK_USER_QUESTION_TOOL`. The question is the stored tool call, so
|
|
14
|
+
it survives a reload; it is answered once any later message exists, typed or picked.
|
|
15
|
+
- The turn **ends** at the card: the server stops after the call and writes no answer under it.
|
|
16
|
+
|
|
3
17
|
## 11.0.0
|
|
4
18
|
|
|
5
19
|
The chat UI is Bloom's, not a copy of it. Breaking:
|
package/package.json
CHANGED
|
@@ -404,6 +404,7 @@ function AliaChatContentBody({
|
|
|
404
404
|
onThumbsDown={onThumbsDown}
|
|
405
405
|
onApprovePlan={onApprovePlan}
|
|
406
406
|
onRejectPlan={onRejectPlan}
|
|
407
|
+
onAnswerQuestions={(message) => send(message)}
|
|
407
408
|
renderMarkdown={renderMarkdown}
|
|
408
409
|
welcomeComponent={welcomeComponent}
|
|
409
410
|
/>
|
|
@@ -33,6 +33,8 @@ import {
|
|
|
33
33
|
} from '@oxy.so/bloom/icons';
|
|
34
34
|
import { ThinkingBlock } from './ThinkingBlock';
|
|
35
35
|
import { PlanPreviewCard } from './PlanPreviewCard';
|
|
36
|
+
import { QuestionCard } from './QuestionCard';
|
|
37
|
+
import { askedQuestions, isQuestionInvocation } from '../lib/questions';
|
|
36
38
|
import { chatMessageEnter } from '../animations';
|
|
37
39
|
import { getToolLabel, getToolActiveLabel, getResearchActiveLabel } from '../lib/tool-registry';
|
|
38
40
|
import { getTextFromContent, getImagesFromContent } from '../lib/content-utils';
|
|
@@ -67,6 +69,8 @@ export interface AliaChatMessageListProps {
|
|
|
67
69
|
onThumbsDown?: (messageId: string) => void;
|
|
68
70
|
onApprovePlan?: (planId: string) => void;
|
|
69
71
|
onRejectPlan?: (planId: string) => void;
|
|
72
|
+
/** Sends the answers to Alia's questions as the person's next message. */
|
|
73
|
+
onAnswerQuestions?: (message: string) => void;
|
|
70
74
|
// Welcome
|
|
71
75
|
welcomeComponent?: React.ReactNode;
|
|
72
76
|
// Markdown renderer override (app passes CustomMarkdown, SDK uses AliaMarkdown)
|
|
@@ -188,6 +192,7 @@ const AssistantMessage = React.memo(function AssistantMessage({
|
|
|
188
192
|
onThumbsDown,
|
|
189
193
|
onApprovePlan,
|
|
190
194
|
onRejectPlan,
|
|
195
|
+
onAnswerQuestions,
|
|
191
196
|
renderMarkdown,
|
|
192
197
|
}: {
|
|
193
198
|
message: ChatMessage;
|
|
@@ -203,6 +208,7 @@ const AssistantMessage = React.memo(function AssistantMessage({
|
|
|
203
208
|
onThumbsDown?: (messageId: string) => void;
|
|
204
209
|
onApprovePlan?: (planId: string) => void;
|
|
205
210
|
onRejectPlan?: (planId: string) => void;
|
|
211
|
+
onAnswerQuestions?: (message: string) => void;
|
|
206
212
|
renderMarkdown?: (content: string) => React.ReactNode;
|
|
207
213
|
}) {
|
|
208
214
|
const labels = useLabels();
|
|
@@ -217,8 +223,16 @@ const AssistantMessage = React.memo(function AssistantMessage({
|
|
|
217
223
|
() => webSearchLog(toolInvocations, researchProgress, { t: labels.workLog }),
|
|
218
224
|
[toolInvocations, researchProgress, labels.workLog],
|
|
219
225
|
);
|
|
226
|
+
// The questions Alia asked, drawn as a card. Answerable only on the last message once the
|
|
227
|
+
// turn is over; any later message (typed or sent from the card) is the answer.
|
|
228
|
+
const questions = useMemo(
|
|
229
|
+
() => (toolInvocations ?? []).flatMap((inv) => askedQuestions(inv) ?? []),
|
|
230
|
+
[toolInvocations],
|
|
231
|
+
);
|
|
220
232
|
const taskLog = useMemo(() => {
|
|
221
|
-
const tasks = (toolInvocations ?? []).filter(
|
|
233
|
+
const tasks = (toolInvocations ?? []).filter(
|
|
234
|
+
(inv) => !isWebInvocation(inv) && !isQuestionInvocation(inv),
|
|
235
|
+
);
|
|
222
236
|
return tasks.length === 0 || isStreamingThis
|
|
223
237
|
? null
|
|
224
238
|
: taskListLog(tasks, false, { t: labels.workLog });
|
|
@@ -338,6 +352,16 @@ const AssistantMessage = React.memo(function AssistantMessage({
|
|
|
338
352
|
</View>
|
|
339
353
|
)}
|
|
340
354
|
|
|
355
|
+
{questions.length === 0 ? null : (
|
|
356
|
+
<QuestionCard
|
|
357
|
+
questions={questions}
|
|
358
|
+
onSubmit={
|
|
359
|
+
isLastMessage && !isLoading && onAnswerQuestions !== undefined
|
|
360
|
+
? onAnswerQuestions
|
|
361
|
+
: undefined
|
|
362
|
+
}
|
|
363
|
+
/>
|
|
364
|
+
)}
|
|
341
365
|
{thinkingIndicator}
|
|
342
366
|
</View>
|
|
343
367
|
);
|
|
@@ -356,6 +380,7 @@ export function AliaChatMessageList({
|
|
|
356
380
|
onThumbsDown,
|
|
357
381
|
onApprovePlan,
|
|
358
382
|
onRejectPlan,
|
|
383
|
+
onAnswerQuestions,
|
|
359
384
|
welcomeComponent,
|
|
360
385
|
renderMarkdown,
|
|
361
386
|
}: AliaChatMessageListProps) {
|
|
@@ -459,6 +484,7 @@ export function AliaChatMessageList({
|
|
|
459
484
|
onThumbsDown={onThumbsDown}
|
|
460
485
|
onApprovePlan={onApprovePlan}
|
|
461
486
|
onRejectPlan={onRejectPlan}
|
|
487
|
+
onAnswerQuestions={onAnswerQuestions}
|
|
462
488
|
renderMarkdown={renderMarkdown}
|
|
463
489
|
/>
|
|
464
490
|
)}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Questionnaire, type QuestionnaireAnswers } from '@oxy.so/bloom/questionnaire';
|
|
2
|
+
import { Muted } from '@oxy.so/bloom/typography';
|
|
3
|
+
import { View } from 'react-native';
|
|
4
|
+
import { type AskedQuestion, formatAnswers } from '../lib/questions';
|
|
5
|
+
|
|
6
|
+
interface QuestionCardProps {
|
|
7
|
+
questions: AskedQuestion[];
|
|
8
|
+
/** Sends the answers as the person's next message. Absent: the card is read-only. */
|
|
9
|
+
onSubmit?: (message: string) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Ids are positions: the questions are the model's, and carry none. */
|
|
13
|
+
const idOf = (index: number) => `q${index}`;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The questions Alia asked: Bloom's `Questionnaire` while the person can still answer, and
|
|
17
|
+
* the questions as plain lines once they have (the answer is their message, right below).
|
|
18
|
+
* Typing a reply in the composer answers just as well, which is why there is no dismiss.
|
|
19
|
+
*/
|
|
20
|
+
export function QuestionCard({ questions, onSubmit }: QuestionCardProps) {
|
|
21
|
+
if (onSubmit === undefined) {
|
|
22
|
+
return (
|
|
23
|
+
<View className="my-2 gap-1">
|
|
24
|
+
{questions.map((q, i) => (
|
|
25
|
+
// The model's questions never reorder: the position is the identity.
|
|
26
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: see above
|
|
27
|
+
<Muted key={i}>{q.question}</Muted>
|
|
28
|
+
))}
|
|
29
|
+
</View>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const complete = (answers: QuestionnaireAnswers) =>
|
|
34
|
+
onSubmit(
|
|
35
|
+
formatAnswers(
|
|
36
|
+
questions,
|
|
37
|
+
questions.map((_, i) => ({
|
|
38
|
+
picked: (answers[idOf(i)]?.values ?? []).map(Number),
|
|
39
|
+
other: answers[idOf(i)]?.other,
|
|
40
|
+
})),
|
|
41
|
+
),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<View className="my-2">
|
|
46
|
+
<Questionnaire
|
|
47
|
+
questions={questions.map((q, i) => ({
|
|
48
|
+
id: idOf(i),
|
|
49
|
+
question: q.question,
|
|
50
|
+
select: q.multiSelect ? 'multiple' : 'single',
|
|
51
|
+
other: true,
|
|
52
|
+
options: q.options.map((option, index) => ({
|
|
53
|
+
value: String(index),
|
|
54
|
+
label: option.label,
|
|
55
|
+
description: option.description,
|
|
56
|
+
})),
|
|
57
|
+
}))}
|
|
58
|
+
onComplete={complete}
|
|
59
|
+
/>
|
|
60
|
+
</View>
|
|
61
|
+
);
|
|
62
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -22,6 +22,14 @@ export { AliaMarkdown } from './components/Markdown';
|
|
|
22
22
|
export type { AliaMarkdownProps, RenderCodeBlock } from './components/Markdown';
|
|
23
23
|
export { AliaWelcomeMessage } from './components/AliaWelcomeMessage';
|
|
24
24
|
export { PlanPreviewCard } from './components/PlanPreviewCard';
|
|
25
|
+
export { QuestionCard } from './components/QuestionCard';
|
|
26
|
+
export {
|
|
27
|
+
ASK_USER_QUESTION_TOOL,
|
|
28
|
+
askedQuestions,
|
|
29
|
+
formatAnswers,
|
|
30
|
+
isQuestionInvocation,
|
|
31
|
+
} from './lib/questions';
|
|
32
|
+
export type { AskedOption, AskedQuestion, QuestionAnswer } from './lib/questions';
|
|
25
33
|
|
|
26
34
|
// ── Chat hook ──
|
|
27
35
|
export { useAliaChat } from './hooks/useAliaChat';
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// The clarifying questions an assistant turn asked, read back from the stored tool call.
|
|
2
|
+
//
|
|
3
|
+
// The server's `askUserQuestion` ends the turn at the card, so the question IS the tool
|
|
4
|
+
// call: it is stored with the message, survives a reload, and is answered by the person's
|
|
5
|
+
// next message. Pure functions, no React Native, so a host's models and tests can use them.
|
|
6
|
+
|
|
7
|
+
import type { ToolInvocation } from '../types';
|
|
8
|
+
|
|
9
|
+
/** The tool's name on the wire. */
|
|
10
|
+
export const ASK_USER_QUESTION_TOOL = 'askUserQuestion';
|
|
11
|
+
|
|
12
|
+
export interface AskedOption {
|
|
13
|
+
label: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface AskedQuestion {
|
|
18
|
+
question: string;
|
|
19
|
+
options: AskedOption[];
|
|
20
|
+
multiSelect: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** What the person picked for one question: option indexes, in option order, and typed text. */
|
|
24
|
+
export interface QuestionAnswer {
|
|
25
|
+
picked: number[];
|
|
26
|
+
other?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const isQuestionInvocation = (inv: Pick<ToolInvocation, 'toolName'>): boolean =>
|
|
30
|
+
inv.toolName === ASK_USER_QUESTION_TOOL;
|
|
31
|
+
|
|
32
|
+
const text = (value: unknown): string | null =>
|
|
33
|
+
typeof value === 'string' && value.trim() !== '' ? value.trim() : null;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The questions of a finished call, or `null` when it is not one or its arguments are not
|
|
37
|
+
* drawable. Model output is data the app did not write: every field is checked, and a
|
|
38
|
+
* malformed card is no card rather than a thrown render.
|
|
39
|
+
*/
|
|
40
|
+
export function askedQuestions(inv: ToolInvocation): AskedQuestion[] | null {
|
|
41
|
+
if (!isQuestionInvocation(inv) || inv.state !== 'result') return null;
|
|
42
|
+
const raw = (inv.args as { questions?: unknown } | undefined)?.questions;
|
|
43
|
+
if (!Array.isArray(raw)) return null;
|
|
44
|
+
|
|
45
|
+
const questions: AskedQuestion[] = [];
|
|
46
|
+
for (const item of raw) {
|
|
47
|
+
const q = item as { question?: unknown; options?: unknown; multiSelect?: unknown };
|
|
48
|
+
const question = text(q.question);
|
|
49
|
+
if (question === null || !Array.isArray(q.options)) return null;
|
|
50
|
+
const options: AskedOption[] = [];
|
|
51
|
+
for (const o of q.options) {
|
|
52
|
+
const label = text((o as { label?: unknown })?.label);
|
|
53
|
+
if (label === null) return null;
|
|
54
|
+
const description = text((o as { description?: unknown }).description);
|
|
55
|
+
options.push(description === null ? { label } : { label, description });
|
|
56
|
+
}
|
|
57
|
+
if (options.length < 2) return null;
|
|
58
|
+
questions.push({ question, options, multiSelect: q.multiSelect === true });
|
|
59
|
+
}
|
|
60
|
+
return questions.length === 0 ? null : questions;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The answers as the message the person "sends": one line per question, so the thread reads
|
|
65
|
+
* as what they said and the model reads the same text. Language-neutral on purpose.
|
|
66
|
+
*/
|
|
67
|
+
export function formatAnswers(questions: AskedQuestion[], answers: QuestionAnswer[]): string {
|
|
68
|
+
return questions
|
|
69
|
+
.map((q, i) => {
|
|
70
|
+
const answer = answers[i];
|
|
71
|
+
const parts = [
|
|
72
|
+
...(answer?.picked ?? []).map((index) => q.options[index]?.label),
|
|
73
|
+
answer?.other?.trim(),
|
|
74
|
+
].filter((part): part is string => !!part);
|
|
75
|
+
return `${q.question}\n→ ${parts.join(', ')}`;
|
|
76
|
+
})
|
|
77
|
+
.join('\n\n');
|
|
78
|
+
}
|
package/src/tools.ts
CHANGED
|
@@ -5,6 +5,13 @@
|
|
|
5
5
|
export { extractCitationSources } from './lib/citations';
|
|
6
6
|
export type { CitationSource } from './lib/citations';
|
|
7
7
|
export { getToolDoneLabel, getToolPillLabel } from './lib/task-labels';
|
|
8
|
+
export {
|
|
9
|
+
ASK_USER_QUESTION_TOOL,
|
|
10
|
+
askedQuestions,
|
|
11
|
+
formatAnswers,
|
|
12
|
+
isQuestionInvocation,
|
|
13
|
+
} from './lib/questions';
|
|
14
|
+
export type { AskedOption, AskedQuestion, QuestionAnswer } from './lib/questions';
|
|
8
15
|
export { toolCallStatus } from './lib/tool-call-status';
|
|
9
16
|
export type { ToolCallStatus } from './lib/tool-call-status';
|
|
10
17
|
export { englishWorkLog } from './lib/work-log-copy';
|