@alia.onl/sdk 15.8.0 → 19.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 +262 -13
- package/README.md +223 -151
- package/package.json +16 -6
- package/src/animations.ts +0 -1
- package/src/components/AliaAskProvider.tsx +127 -0
- package/src/components/AliaChatContent.tsx +604 -392
- package/src/components/AliaChatHeader.tsx +14 -38
- package/src/components/AliaChatScreen.tsx +56 -31
- package/src/components/AliaChatSheet.tsx +110 -36
- package/src/components/AliaComposer.tsx +1 -1
- package/src/components/AliaParticipants.tsx +202 -0
- package/src/components/AliaParticipantsHeader.tsx +60 -0
- package/src/components/AliaSharedConversation.tsx +86 -0
- package/src/components/AliaWelcomeMessage.tsx +18 -5
- package/src/components/Image.tsx +12 -0
- package/src/components/Markdown.tsx +498 -14
- package/src/components/PlanPreviewCard.tsx +7 -49
- package/src/components/ReplyMarkdown.tsx +359 -0
- package/src/components/RichBlocks.tsx +195 -0
- package/src/components/canvas/CanvasComponent.tsx +59 -0
- package/src/components/canvas/ChartRenderer.tsx +73 -0
- package/src/components/canvas/CodeRenderer.tsx +24 -0
- package/src/components/canvas/FormRenderer.tsx +101 -0
- package/src/components/canvas/MarkdownRenderer.tsx +14 -0
- package/src/components/canvas/TableRenderer.tsx +47 -0
- package/src/components/thought/FilesAndSources.tsx +120 -0
- package/src/components/thought/ThoughtPanel.tsx +417 -0
- package/src/components/thought/ToolStep.tsx +141 -0
- package/src/components/thread/AgentResultCard.tsx +125 -0
- package/src/components/thread/AgentTaskCard.tsx +127 -0
- package/src/components/thread/AliaThread.tsx +401 -0
- package/src/components/thread/MessageBlockBoundary.tsx +78 -0
- package/src/components/thread/NewConversationOffer.tsx +54 -0
- package/src/components/thread/ThreadRow.tsx +458 -0
- package/src/components/thread/ThreadSearch.tsx +129 -0
- package/src/components/thread/ToolResultCard.tsx +254 -0
- package/src/components/thread/TurnMenu.tsx +54 -0
- package/src/components/thread/TurnStatusLine.tsx +34 -0
- package/src/components/voice/AliaVoiceCall.tsx +21 -0
- package/src/components/voice/VoiceControls.tsx +2 -28
- package/src/components/voice/VoiceSession.tsx +39 -11
- package/src/hooks/useAliaActivity.ts +2 -2
- package/src/hooks/useAtBottom.ts +51 -0
- package/src/hooks/useAttachMenu.ts +94 -0
- package/src/hooks/useChatComposer.ts +299 -0
- package/src/hooks/useChatVoice.ts +129 -0
- package/src/hooks/useDocumentPicker.ts +3 -29
- package/src/hooks/useFailedTurnToast.ts +43 -0
- package/src/hooks/useMessageVote.ts +50 -0
- package/src/hooks/useOpenThought.ts +79 -0
- package/src/hooks/usePendingQuestions.ts +2 -2
- package/src/hooks/useReadAloud.ts +92 -0
- package/src/hooks/useShareHandler.ts +24 -0
- package/src/hooks/useSoundEffects.ts +1 -1
- package/src/hooks/useThoughtScope.ts +59 -0
- package/src/hooks/useThreadRows.ts +145 -0
- package/src/hooks/useThreadView.ts +68 -0
- package/src/hooks/useTimelineWindow.ts +256 -0
- package/src/hooks/useVoiceLauncher.ts +180 -0
- package/src/hooks/useVoiceRoom.ts +4 -4
- package/src/index.ts +43 -29
- package/src/lib/activity-store.ts +1 -1
- package/src/lib/add-menu.ts +7 -0
- package/src/lib/call-thread.ts +33 -0
- package/src/lib/citations.ts +79 -2
- package/src/lib/clipboard.ts +23 -0
- package/src/lib/content-utils.ts +10 -0
- package/src/lib/copy-en.ts +284 -0
- package/src/lib/copy.ts +32 -0
- package/src/lib/document-picker.ts +31 -0
- package/src/lib/flights.ts +22 -13
- package/src/lib/focus-return.ts +66 -0
- package/src/lib/keyboard.native.tsx +19 -0
- package/src/lib/keyboard.tsx +9 -0
- package/src/lib/labels.tsx +69 -17
- package/src/lib/participants.ts +86 -0
- package/src/lib/power-level-options.ts +66 -0
- package/src/lib/questions.ts +2 -2
- package/src/lib/task-labels.ts +4 -4
- package/src/lib/tool-call-status.ts +1 -1
- package/src/lib/tool-icons.ts +36 -0
- package/src/lib/tool-renderers.tsx +48 -0
- package/src/lib/turn-state.ts +2 -2
- package/src/lib/voice-call-request.ts +49 -0
- package/src/lib/voice-call.ts +98 -35
- package/src/lib/voice-controls-labels.ts +31 -0
- package/src/lib/voice-protocol.ts +34 -15
- package/src/lib/work-log.ts +6 -6
- package/src/runtime/agents.ts +151 -0
- package/src/runtime/api.ts +141 -0
- package/src/runtime/app-assertion.ts +53 -0
- package/src/runtime/attachment-utils.ts +96 -0
- package/src/runtime/composer-draft-store.ts +270 -0
- package/src/runtime/config.tsx +186 -0
- package/src/runtime/conversation-store.ts +49 -0
- package/src/runtime/conversations.ts +557 -0
- package/src/runtime/error-utils.ts +104 -0
- package/src/runtime/host-tools.ts +140 -0
- package/src/runtime/index.ts +113 -0
- package/src/runtime/message-history.ts +124 -0
- package/src/runtime/model.ts +39 -0
- package/src/runtime/oxy-files.ts +37 -0
- package/src/runtime/power-levels.ts +66 -0
- package/src/runtime/query-keys.ts +34 -0
- package/src/runtime/random-uuid.ts +25 -0
- package/src/runtime/routes.ts +51 -0
- package/src/runtime/sharing.ts +165 -0
- package/src/runtime/sse-frame-reader.ts +88 -0
- package/src/runtime/stream-events.ts +146 -0
- package/src/runtime/stream-outcome.ts +21 -0
- package/src/runtime/thought-store.ts +103 -0
- package/src/runtime/thread/agent-activity.ts +261 -0
- package/src/runtime/thread/automation-labels.ts +79 -0
- package/src/runtime/thread/conversation-export.ts +135 -0
- package/src/runtime/thread/message-days.ts +120 -0
- package/src/runtime/thread/message-processor.ts +93 -0
- package/src/runtime/thread/task-utils.ts +11 -0
- package/src/runtime/thread/thought-utils.ts +752 -0
- package/src/runtime/thread/thread-history.ts +139 -0
- package/src/runtime/thread/timeline.ts +202 -0
- package/src/runtime/thread/title-tags.ts +8 -0
- package/src/runtime/thread/tool-cards.ts +383 -0
- package/src/runtime/turn-failure.ts +108 -0
- package/src/runtime/types.ts +148 -0
- package/src/runtime/usage-limit-error.ts +44 -0
- package/src/runtime/use-agent-activity.ts +80 -0
- package/src/runtime/use-conversation-stream.ts +1433 -0
- package/src/runtime/use-conversation-thread.ts +556 -0
- package/src/runtime/use-start-conversation.ts +107 -0
- package/src/runtime/use-thread-history.ts +64 -0
- package/src/runtime/use-thread-search.ts +95 -0
- package/src/runtime/use-turn-edit.ts +99 -0
- package/src/tools.ts +2 -2
- package/src/types.ts +18 -47
- package/src/components/AliaChatMessageList.tsx +0 -532
- package/src/components/ThinkingBlock.tsx +0 -43
- package/src/hooks/useAliaChat.ts +0 -573
- package/src/lib/catalogue.ts +0 -236
- package/src/lib/chat-stream.ts +0 -655
- package/src/lib/chat-transport.ts +0 -98
- package/src/lib/delegation.ts +0 -34
- package/src/lib/work-log-copy.ts +0 -61
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { useMutation, useQuery } from '@tanstack/react-query';
|
|
2
|
+
import { useMemo } from 'react';
|
|
3
|
+
import { useAliaApi } from './api';
|
|
4
|
+
import { useAliaRuntime } from './config';
|
|
5
|
+
import { aliaQueryKeys } from './query-keys';
|
|
6
|
+
import { API_ROUTES } from './routes';
|
|
7
|
+
import type { Message } from './types';
|
|
8
|
+
|
|
9
|
+
/** A conversation's read-only link (`POST /v1/conversations/:id/share`, `docs/agent-embedding.md` §6). */
|
|
10
|
+
export interface AliaConversationShare {
|
|
11
|
+
conversationId: string;
|
|
12
|
+
/** What `GET /v1/shared/:token` reads it by; the host builds its own URL around it. */
|
|
13
|
+
token: string;
|
|
14
|
+
expiresAt: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** How long a share link lives unless the caller says otherwise, in hours (the API takes 1–168). */
|
|
18
|
+
export const DEFAULT_SHARE_TTL_HOURS = 24;
|
|
19
|
+
|
|
20
|
+
/** Mint and revoke a conversation's share link, as the signed-in person. */
|
|
21
|
+
export function useShareConversation() {
|
|
22
|
+
const api = useAliaApi();
|
|
23
|
+
const share = useMutation({
|
|
24
|
+
mutationFn: async ({
|
|
25
|
+
conversationId,
|
|
26
|
+
ttlHours = DEFAULT_SHARE_TTL_HOURS,
|
|
27
|
+
}: {
|
|
28
|
+
conversationId: string;
|
|
29
|
+
ttlHours?: number;
|
|
30
|
+
}): Promise<AliaConversationShare> => {
|
|
31
|
+
const hours = Math.min(168, Math.max(1, Math.round(ttlHours)));
|
|
32
|
+
const { data } = await api.post<{ token?: unknown; expiresAt?: unknown }>(
|
|
33
|
+
API_ROUTES.conversations.share(conversationId),
|
|
34
|
+
{ ttlHours: hours },
|
|
35
|
+
);
|
|
36
|
+
if (typeof data?.token !== 'string' || typeof data.expiresAt !== 'string') {
|
|
37
|
+
throw new Error('The share link came back without a token.');
|
|
38
|
+
}
|
|
39
|
+
return { conversationId, token: data.token, expiresAt: data.expiresAt };
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
const revoke = useMutation({
|
|
43
|
+
mutationFn: async (conversationId: string): Promise<void> => {
|
|
44
|
+
await api.delete(API_ROUTES.conversations.share(conversationId));
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
return useMemo(
|
|
48
|
+
() => ({
|
|
49
|
+
/** Mint the link of `conversationId` (1–168 hours, default 24). */
|
|
50
|
+
share: (conversationId: string, ttlHours?: number) =>
|
|
51
|
+
share.mutateAsync({ conversationId, ttlHours }),
|
|
52
|
+
/** End the link: the token stops answering. */
|
|
53
|
+
revoke: (conversationId: string) => revoke.mutateAsync(conversationId),
|
|
54
|
+
isSharing: share.isPending,
|
|
55
|
+
}),
|
|
56
|
+
[share, revoke],
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A message of a shared conversation: words and pictures, and who wrote them. Never a tool's output. */
|
|
61
|
+
export interface AliaSharedMessage {
|
|
62
|
+
role: 'user' | 'assistant';
|
|
63
|
+
content: string | Array<{ type: string; [key: string]: unknown }>;
|
|
64
|
+
agent?: AliaSharedAgent;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface AliaSharedAgent {
|
|
68
|
+
id: string;
|
|
69
|
+
name: string;
|
|
70
|
+
handle?: string;
|
|
71
|
+
character?: unknown;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface AliaSharedConversationData {
|
|
75
|
+
title: string;
|
|
76
|
+
/** The conversation's lead, when it was an agent's. */
|
|
77
|
+
agent?: AliaSharedAgent;
|
|
78
|
+
messages: AliaSharedMessage[];
|
|
79
|
+
expiresAt: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The agent the server sent, kept only with an id and a name. */
|
|
83
|
+
function readSharedAgent(value: unknown): AliaSharedAgent | undefined {
|
|
84
|
+
if (value === null || typeof value !== 'object') return undefined;
|
|
85
|
+
const { id, name, handle, character } = value as Record<string, unknown>;
|
|
86
|
+
if (typeof id !== 'string' || typeof name !== 'string') return undefined;
|
|
87
|
+
return {
|
|
88
|
+
id,
|
|
89
|
+
name,
|
|
90
|
+
...(typeof handle === 'string' ? { handle } : {}),
|
|
91
|
+
...(character === undefined || character === null ? {} : { character }),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The shared conversation as the contract names it; any message of another shape is left out. */
|
|
96
|
+
export function readSharedConversation(value: unknown): AliaSharedConversationData | null {
|
|
97
|
+
if (value === null || typeof value !== 'object') return null;
|
|
98
|
+
const raw = value as Record<string, unknown>;
|
|
99
|
+
if (!Array.isArray(raw.messages) || typeof raw.expiresAt !== 'string') return null;
|
|
100
|
+
const messages = raw.messages.flatMap((message): AliaSharedMessage[] => {
|
|
101
|
+
if (message === null || typeof message !== 'object') return [];
|
|
102
|
+
const { role, content, agent } = message as Record<string, unknown>;
|
|
103
|
+
if (role !== 'user' && role !== 'assistant') return [];
|
|
104
|
+
if (typeof content !== 'string' && !Array.isArray(content)) return [];
|
|
105
|
+
const author = readSharedAgent(agent);
|
|
106
|
+
return [
|
|
107
|
+
{
|
|
108
|
+
role,
|
|
109
|
+
content: content as AliaSharedMessage['content'],
|
|
110
|
+
...(author ? { agent: author } : {}),
|
|
111
|
+
},
|
|
112
|
+
];
|
|
113
|
+
});
|
|
114
|
+
const lead = readSharedAgent(raw.agent);
|
|
115
|
+
return {
|
|
116
|
+
title: typeof raw.title === 'string' ? raw.title : '',
|
|
117
|
+
...(lead ? { agent: lead } : {}),
|
|
118
|
+
messages,
|
|
119
|
+
expiresAt: raw.expiresAt,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The shared messages as the thread draws them: an agent's under its own identity. */
|
|
124
|
+
export function sharedMessages(shared: AliaSharedConversationData): Message[] {
|
|
125
|
+
return shared.messages.map((message, index) => ({
|
|
126
|
+
id: `shared-${index}`,
|
|
127
|
+
role: message.role,
|
|
128
|
+
content: message.content,
|
|
129
|
+
...(message.agent ? { agentInfo: { ...message.agent } } : {}),
|
|
130
|
+
}));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Thrown when a token answers nothing: revoked, expired or never minted. */
|
|
134
|
+
export class SharedConversationGoneError extends Error {
|
|
135
|
+
constructor() {
|
|
136
|
+
super('This shared conversation is no longer available.');
|
|
137
|
+
this.name = 'SharedConversationGoneError';
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A conversation somebody shared, read by its token. The route needs no session, so it is fetched
|
|
143
|
+
* plainly rather than through the signed-in Oxy client: a person who is not signed in can read it.
|
|
144
|
+
*/
|
|
145
|
+
export function useSharedConversation(token: string | undefined) {
|
|
146
|
+
const { apiUrl } = useAliaRuntime();
|
|
147
|
+
return useQuery({
|
|
148
|
+
queryKey: aliaQueryKeys.shared(token ?? ''),
|
|
149
|
+
queryFn: async (): Promise<AliaSharedConversationData> => {
|
|
150
|
+
const response = await fetch(new URL(API_ROUTES.shared(token ?? ''), apiUrl).toString(), {
|
|
151
|
+
headers: { Accept: 'application/json' },
|
|
152
|
+
});
|
|
153
|
+
if (response.status === 404 || response.status === 410) {
|
|
154
|
+
throw new SharedConversationGoneError();
|
|
155
|
+
}
|
|
156
|
+
if (!response.ok)
|
|
157
|
+
throw new Error(`Shared conversation failed with status ${response.status}`);
|
|
158
|
+
const shared = readSharedConversation(await response.json());
|
|
159
|
+
if (shared === null) throw new Error('The shared conversation had an unexpected shape.');
|
|
160
|
+
return shared;
|
|
161
|
+
},
|
|
162
|
+
enabled: token !== undefined && token !== '',
|
|
163
|
+
retry: (count, error) => !(error instanceof SharedConversationGoneError) && count < 2,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reassembles an SSE byte stream into whole frames.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this is a module rather than a few lines inside the read loop
|
|
5
|
+
*
|
|
6
|
+
* It used to be a few lines inside the read loop, and that is exactly how the
|
|
7
|
+
* bug happened. `buffer` was correctly declared outside the loop, so a line
|
|
8
|
+
* split across two `reader.read()` calls survived — but `currentEventType` was
|
|
9
|
+
* declared INSIDE it and reset to `''` on every chunk. A frame is
|
|
10
|
+
* `event: X\ndata: {…}\n\n` (`packages/api/src/lib/sse-emitter.ts:24`), and a
|
|
11
|
+
* chunk boundary falls wherever the network puts it, so any frame split between
|
|
12
|
+
* its `event:` and `data:` lines arrived with its type forgotten. It then fell
|
|
13
|
+
* through to the OpenAI-shaped branch, found no `choices[0]`, and was dropped
|
|
14
|
+
* without a trace.
|
|
15
|
+
*
|
|
16
|
+
* The events that carry the most bytes are the ones most likely to be split, so
|
|
17
|
+
* this was not rare and not uniform: `alia.title`, `alia.tool_result`,
|
|
18
|
+
* `alia.plan_preview`, `alia.host_action`, `alia.agent_turn` and the
|
|
19
|
+
* artifact events are precisely the large ones. A title that sometimes does not
|
|
20
|
+
* arrive, a canvas that sometimes does not open.
|
|
21
|
+
*
|
|
22
|
+
* Two pieces of state that must both outlive a chunk are easy to get half
|
|
23
|
+
* right when they are loose variables in a 1 000-line hook, and hard to get
|
|
24
|
+
* wrong when they are fields of one object whose whole job is to hold them.
|
|
25
|
+
* That is the reason for this file, and the reason the test can feed the same
|
|
26
|
+
* stream split at every byte offset — which no test of the hook could do.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export interface SseFrame {
|
|
30
|
+
/** The `event:` name, or `''` for an unnamed (OpenAI-shaped) frame. */
|
|
31
|
+
readonly event: string;
|
|
32
|
+
/** The `data:` payload, trimmed. `[DONE]` is passed through as-is. */
|
|
33
|
+
readonly data: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface SseFrameReader {
|
|
37
|
+
/** Feed one decoded chunk; get back every frame it completed. */
|
|
38
|
+
push(chunk: string): SseFrame[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createSseFrameReader(): SseFrameReader {
|
|
42
|
+
let buffer = '';
|
|
43
|
+
let currentEventType = '';
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
push(chunk: string): SseFrame[] {
|
|
47
|
+
buffer += chunk;
|
|
48
|
+
|
|
49
|
+
const frames: SseFrame[] = [];
|
|
50
|
+
const lines = buffer.split('\n');
|
|
51
|
+
// The trailing element is whatever came after the last newline, which is
|
|
52
|
+
// an incomplete line unless the chunk happened to end on a boundary (in
|
|
53
|
+
// which case it is `''` and costs nothing to carry).
|
|
54
|
+
buffer = lines.pop() ?? '';
|
|
55
|
+
|
|
56
|
+
for (const rawLine of lines) {
|
|
57
|
+
// A stream that uses CRLF leaves the CR on the end of every line;
|
|
58
|
+
// `data: {…}\r` is not valid JSON.
|
|
59
|
+
const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
|
|
60
|
+
|
|
61
|
+
if (line.startsWith('event: ')) {
|
|
62
|
+
currentEventType = line.slice(7).trim();
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// A blank line closes a frame. Resetting here is what stops a `data:`
|
|
67
|
+
// line in the NEXT frame inheriting this frame's event name.
|
|
68
|
+
if (line === '') {
|
|
69
|
+
currentEventType = '';
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (line.startsWith('data: ')) {
|
|
74
|
+
frames.push({ event: currentEventType, data: line.slice(6).trim() });
|
|
75
|
+
// One `data:` line is one frame for this protocol. The event name is
|
|
76
|
+
// consumed with it, so a second `data:` without its own `event:` is
|
|
77
|
+
// correctly unnamed rather than inheriting.
|
|
78
|
+
currentEventType = '';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Comments (`: keep-alive`) and any field this client does not read
|
|
82
|
+
// are ignored, as the SSE specification requires.
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return frames;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stream frames of ADR 0020, read strictly (`docs/agent-embedding.md` §2, §4, §5).
|
|
3
|
+
*
|
|
4
|
+
* A frame is kept only when it has the version and every field the contract names, with the
|
|
5
|
+
* types it names; anything else is not that frame and is dropped whole, never half-applied. Pure:
|
|
6
|
+
* `use-conversation-stream.ts` decides what each one does to the thread.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The consulted agent, as `alia.agent_start` names it. */
|
|
10
|
+
export interface ConsultAgent {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
handle?: string;
|
|
14
|
+
color?: string;
|
|
15
|
+
character?: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type ConsultEvent =
|
|
19
|
+
| { type: 'start'; consultId: string; agent: ConsultAgent; task?: string }
|
|
20
|
+
| { type: 'delta'; consultId: string; content: string }
|
|
21
|
+
| {
|
|
22
|
+
type: 'tool';
|
|
23
|
+
consultId: string;
|
|
24
|
+
phase: 'call' | 'result';
|
|
25
|
+
toolCallId: string;
|
|
26
|
+
name: string;
|
|
27
|
+
input?: Record<string, unknown>;
|
|
28
|
+
output?: unknown;
|
|
29
|
+
}
|
|
30
|
+
| {
|
|
31
|
+
type: 'end';
|
|
32
|
+
consultId: string;
|
|
33
|
+
status: 'completed' | 'failed';
|
|
34
|
+
content?: string;
|
|
35
|
+
errorCode?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** The consult frames' names, the retired `alia.agent` not among them. */
|
|
39
|
+
export const CONSULT_EVENTS = [
|
|
40
|
+
'alia.agent_start',
|
|
41
|
+
'alia.agent_delta',
|
|
42
|
+
'alia.agent_tool',
|
|
43
|
+
'alia.agent_end',
|
|
44
|
+
] as const;
|
|
45
|
+
|
|
46
|
+
type Raw = Record<string, unknown>;
|
|
47
|
+
|
|
48
|
+
const isObject = (value: unknown): value is Raw =>
|
|
49
|
+
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
50
|
+
const isText = (value: unknown): value is string => typeof value === 'string' && value !== '';
|
|
51
|
+
const optionalText = (value: unknown): string | undefined =>
|
|
52
|
+
typeof value === 'string' && value !== '' ? value : undefined;
|
|
53
|
+
|
|
54
|
+
/** A frame of this contract: version 1 and a consult it belongs to. */
|
|
55
|
+
function versioned(data: unknown): (Raw & { consultId: string }) | null {
|
|
56
|
+
if (!isObject(data) || data.eventVersion !== 1 || !isText(data.consultId)) return null;
|
|
57
|
+
return data as Raw & { consultId: string };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The consult frame `event` carries, or `null` when it is not one or not well-formed. */
|
|
61
|
+
export function parseConsultEvent(event: string, data: unknown): ConsultEvent | null {
|
|
62
|
+
const frame = versioned(data);
|
|
63
|
+
if (frame === null) return null;
|
|
64
|
+
const { consultId } = frame;
|
|
65
|
+
switch (event) {
|
|
66
|
+
case 'alia.agent_start': {
|
|
67
|
+
const agent = frame.agent;
|
|
68
|
+
if (!isObject(agent) || !isText(agent.id) || !isText(agent.name)) return null;
|
|
69
|
+
return {
|
|
70
|
+
type: 'start',
|
|
71
|
+
consultId,
|
|
72
|
+
agent: {
|
|
73
|
+
id: agent.id,
|
|
74
|
+
name: agent.name,
|
|
75
|
+
...(optionalText(agent.handle) ? { handle: agent.handle as string } : {}),
|
|
76
|
+
...(optionalText(agent.color) ? { color: agent.color as string } : {}),
|
|
77
|
+
...(agent.character === undefined || agent.character === null
|
|
78
|
+
? {}
|
|
79
|
+
: { character: agent.character }),
|
|
80
|
+
},
|
|
81
|
+
...(optionalText(frame.task) ? { task: frame.task as string } : {}),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
case 'alia.agent_delta':
|
|
85
|
+
return typeof frame.content === 'string'
|
|
86
|
+
? { type: 'delta', consultId, content: frame.content }
|
|
87
|
+
: null;
|
|
88
|
+
case 'alia.agent_tool': {
|
|
89
|
+
const { phase, toolCallId, name, input } = frame;
|
|
90
|
+
if ((phase !== 'call' && phase !== 'result') || !isText(toolCallId) || !isText(name)) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
if (input !== undefined && !isObject(input)) return null;
|
|
94
|
+
return {
|
|
95
|
+
type: 'tool',
|
|
96
|
+
consultId,
|
|
97
|
+
phase,
|
|
98
|
+
toolCallId,
|
|
99
|
+
name,
|
|
100
|
+
...(input === undefined ? {} : { input }),
|
|
101
|
+
...(phase === 'result' ? { output: frame.output } : {}),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
case 'alia.agent_end': {
|
|
105
|
+
const { status, content, error } = frame;
|
|
106
|
+
if (status !== 'completed' && status !== 'failed') return null;
|
|
107
|
+
if (content !== undefined && typeof content !== 'string') return null;
|
|
108
|
+
const errorCode = isObject(error) && isText(error.code) ? error.code : undefined;
|
|
109
|
+
return {
|
|
110
|
+
type: 'end',
|
|
111
|
+
consultId,
|
|
112
|
+
status,
|
|
113
|
+
...(content === undefined ? {} : { content }),
|
|
114
|
+
...(errorCode === undefined ? {} : { errorCode }),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
default:
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A UI effect the model asked the embedding client for (`alia.host_action`). */
|
|
123
|
+
export interface HostAction {
|
|
124
|
+
toolCallId: string;
|
|
125
|
+
app: string;
|
|
126
|
+
name: string;
|
|
127
|
+
input: Record<string, unknown>;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The host action an `alia.host_action` frame carries, or `null` when it is not well-formed. */
|
|
131
|
+
export function parseHostAction(data: unknown): HostAction | null {
|
|
132
|
+
if (!isObject(data) || data.eventVersion !== 1) return null;
|
|
133
|
+
const { toolCallId, app, name, input } = data;
|
|
134
|
+
if (!isText(toolCallId) || !isText(app) || !isText(name)) return null;
|
|
135
|
+
if (input !== undefined && !isObject(input)) return null;
|
|
136
|
+
return { toolCallId, app, name, input: (input as Record<string, unknown> | undefined) ?? {} };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The error code that ends a turn an agent's feature gate refused (§5). */
|
|
140
|
+
export const FEATURE_ENTITLEMENT_REQUIRED = 'feature_entitlement_required';
|
|
141
|
+
|
|
142
|
+
/** The feature an error refused, when the error is a feature gate's refusal. */
|
|
143
|
+
export function lockedFeatureOf(error: unknown): string | null {
|
|
144
|
+
if (!isObject(error) || error.code !== FEATURE_ENTITLEMENT_REQUIRED) return null;
|
|
145
|
+
return isText(error.feature) ? error.feature : null;
|
|
146
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Evidence the client may use to decide whether a streamed turn answered. */
|
|
2
|
+
export interface StreamOutputEvidence {
|
|
3
|
+
realOutputChars: number;
|
|
4
|
+
agentOutputChars: number;
|
|
5
|
+
durableArtifactCount: number;
|
|
6
|
+
/** Progress only. Deliberately not sufficient for success. */
|
|
7
|
+
toolInvocationCount: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A successful turn needs a user-facing answer or durable product output.
|
|
12
|
+
* Reasoning and ordinary tool cards are progress; accepting either on stream
|
|
13
|
+
* close leaves an empty assistant bubble that looks successfully completed.
|
|
14
|
+
*/
|
|
15
|
+
export function hasUsableStreamOutput(evidence: StreamOutputEvidence): boolean {
|
|
16
|
+
return (
|
|
17
|
+
evidence.realOutputChars > 0 ||
|
|
18
|
+
evidence.agentOutputChars > 0 ||
|
|
19
|
+
evidence.durableArtifactCount > 0
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { create } from 'zustand';
|
|
2
|
+
import type { FailedTurn } from './turn-failure';
|
|
3
|
+
import type { Message } from './types';
|
|
4
|
+
|
|
5
|
+
/** The thought panel's tabs. The store owns them because it owns which one opens. */
|
|
6
|
+
export type ThoughtTab = 'steps' | 'sources' | 'activity';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Whether the conversation a selection belongs to has its messages yet. `loading` is the
|
|
10
|
+
* seeded-then-upgraded window in which a message can be on screen without its tool history;
|
|
11
|
+
* `failed` is a load that will not arrive. The panel shows each as itself instead of as "no steps".
|
|
12
|
+
*/
|
|
13
|
+
export type ThoughtScopeStatus = 'loading' | 'ready' | 'failed';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The conversation a thought selection was made in, as the panel reads it.
|
|
17
|
+
*
|
|
18
|
+
* ## Why the messages travel WITH the selection
|
|
19
|
+
*
|
|
20
|
+
* The panel used to read one global array that the chat screen copied into only while the panel
|
|
21
|
+
* was open. So the first press on a tool row rendered against whatever the array held — the
|
|
22
|
+
* previous conversation, or nothing — and found no message by id: three empty tabs under five
|
|
23
|
+
* visible tool rows. And with the new-chat screen mounted under a conversation, TWO chat screens
|
|
24
|
+
* raced to write the array, and the empty one could win.
|
|
25
|
+
*
|
|
26
|
+
* Now a selection names its conversation and carries that conversation's messages, written in the
|
|
27
|
+
* same update as the message id, and a later sync is accepted only from the SAME conversation. A
|
|
28
|
+
* screen showing another conversation cannot blank what was opened from this one.
|
|
29
|
+
*/
|
|
30
|
+
export interface ThoughtScope {
|
|
31
|
+
/** `null` for a conversation that has not been created yet — the first turn of a new chat. */
|
|
32
|
+
conversationId: string | null;
|
|
33
|
+
messages: Message[];
|
|
34
|
+
status: ThoughtScopeStatus;
|
|
35
|
+
/** A turn is in flight in this conversation. */
|
|
36
|
+
isLoading: boolean;
|
|
37
|
+
/** The turn that got no answer in this conversation, or `null`. */
|
|
38
|
+
failedTurn: FailedTurn | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The turn the thought panel is about. */
|
|
42
|
+
export interface ThoughtSelection {
|
|
43
|
+
messageId: string;
|
|
44
|
+
/**
|
|
45
|
+
* Which tab the panel opens on. A caller that already knows what the reader asked for — the
|
|
46
|
+
* Sources row under an answer — says so, instead of landing them on Steps to go hunting.
|
|
47
|
+
*/
|
|
48
|
+
tab: ThoughtTab;
|
|
49
|
+
/** The conversation the message lives in. */
|
|
50
|
+
scope: ThoughtScope;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface ThoughtState {
|
|
54
|
+
/** `null` while no turn is selected. */
|
|
55
|
+
selection: ThoughtSelection | null;
|
|
56
|
+
/**
|
|
57
|
+
* Select a turn, with the conversation it was pressed in. One update: there is no render in
|
|
58
|
+
* which the id is set and the messages are not.
|
|
59
|
+
*/
|
|
60
|
+
open: (messageId: string, scope: ThoughtScope, tab?: ThoughtTab) => void;
|
|
61
|
+
setTab: (tab: ThoughtTab) => void;
|
|
62
|
+
/**
|
|
63
|
+
* The chat screen's newest view of a conversation. Taken only when it is the conversation the
|
|
64
|
+
* open selection belongs to; every other screen's write is dropped, which is what keeps a second
|
|
65
|
+
* mounted chat from blanking the panel.
|
|
66
|
+
*/
|
|
67
|
+
sync: (scope: ThoughtScope) => void;
|
|
68
|
+
/** Clear the selection. */
|
|
69
|
+
close: () => void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* What the thought panel shows. Session state: the thread writes it, the panel (which the host
|
|
74
|
+
* places — a right panel, a sheet) reads it.
|
|
75
|
+
*/
|
|
76
|
+
export const useThoughtStore = create<ThoughtState>()((set, get) => ({
|
|
77
|
+
selection: null,
|
|
78
|
+
|
|
79
|
+
open: (messageId, scope, tab = 'steps') => set({ selection: { messageId, tab, scope } }),
|
|
80
|
+
|
|
81
|
+
setTab: (tab) => {
|
|
82
|
+
const { selection } = get();
|
|
83
|
+
if (selection !== null) set({ selection: { ...selection, tab } });
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
sync: (scope) => {
|
|
87
|
+
const { selection } = get();
|
|
88
|
+
if (selection === null || selection.scope.conversationId !== scope.conversationId) return;
|
|
89
|
+
const held = selection.scope;
|
|
90
|
+
if (
|
|
91
|
+
held.messages === scope.messages &&
|
|
92
|
+
held.status === scope.status &&
|
|
93
|
+
held.isLoading === scope.isLoading &&
|
|
94
|
+
held.failedTurn === scope.failedTurn
|
|
95
|
+
)
|
|
96
|
+
return;
|
|
97
|
+
set({ selection: { ...selection, scope } });
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
close: () => {
|
|
101
|
+
if (get().selection !== null) set({ selection: null });
|
|
102
|
+
},
|
|
103
|
+
}));
|