@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alia.onl/sdk",
3
- "version": "11.0.0",
3
+ "version": "11.1.0",
4
4
  "description": "Alia AI chat SDK — reusable chat UI, voice, and streaming components for Oxy apps",
5
5
  "main": "src/index.ts",
6
6
  "react-native": "src/index.ts",
@@ -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((inv) => !isWebInvocation(inv));
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';