@aphrody/m3-ai 3.3.2 → 3.3.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/README.md +29 -7
- package/dist/A2APeerInspector.d.ts +41 -0
- package/dist/A2APeerInspector.d.ts.map +1 -0
- package/dist/A2ATaskTimeline.d.ts +28 -0
- package/dist/A2ATaskTimeline.d.ts.map +1 -0
- package/dist/AgUiEventStream.d.ts +87 -0
- package/dist/AgUiEventStream.d.ts.map +1 -0
- package/dist/GeminiAccountMenu.d.ts +55 -0
- package/dist/GeminiAccountMenu.d.ts.map +1 -0
- package/dist/GeminiPersonalContextEdit.d.ts +29 -0
- package/dist/GeminiPersonalContextEdit.d.ts.map +1 -0
- package/dist/GoogleSpotlightBar.d.ts +32 -0
- package/dist/GoogleSpotlightBar.d.ts.map +1 -0
- package/dist/Markdown.d.ts +2 -1
- package/dist/Markdown.d.ts.map +1 -1
- package/dist/McpAppCard.d.ts +24 -0
- package/dist/McpAppCard.d.ts.map +1 -0
- package/dist/ModelSelector.d.ts.map +1 -1
- package/dist/TaskRow.d.ts.map +1 -1
- package/dist/ToolCall.d.ts.map +1 -1
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3629 -1950
- package/dist/index.js.map +32 -1
- package/package.json +17 -20
- package/src/A2APeerInspector.tsx +370 -0
- package/src/A2ATaskTimeline.tsx +218 -0
- package/src/AgUiEventStream.ts +492 -0
- package/src/ChatComposer.tsx +261 -0
- package/src/ConversationList.tsx +207 -0
- package/src/GeminiAccountMenu.tsx +212 -0
- package/src/GeminiPersonalContextEdit.tsx +147 -0
- package/src/GoogleSpotlightBar.tsx +165 -0
- package/src/InlineBranchCard.tsx +97 -0
- package/src/InlineWorkerCard.tsx +182 -0
- package/src/Markdown.tsx +769 -0
- package/src/McpAppCard.tsx +161 -0
- package/src/MemoryPanel.tsx +176 -0
- package/src/MessageBubble.tsx +143 -0
- package/src/ModelSelector.tsx +319 -0
- package/src/ModelUnavailable.tsx +116 -0
- package/src/TaskCreateForm.tsx +123 -0
- package/src/TaskDetail.tsx +224 -0
- package/src/TaskList.tsx +136 -0
- package/src/TaskPriorityIcon.tsx +39 -0
- package/src/TaskRow.tsx +177 -0
- package/src/TaskStatusIcon.tsx +39 -0
- package/src/ToolCall.tsx +644 -0
- package/src/chatIcons.tsx +105 -0
- package/src/chatLabels.ts +243 -0
- package/src/citations.tsx +286 -0
- package/src/index.ts +173 -0
- package/src/internal.tsx +107 -0
- package/src/openaiChatStream.ts +400 -0
- package/src/types.ts +212 -0
- package/src/useOpenAIChat.ts +126 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0 AND MIT
|
|
2
|
+
// Derived from SpaceUI (packages/aphrody-spaceui/packages/ai/src/index.ts), MIT, rebuilt on Material Design 3.
|
|
3
|
+
// @aphrody/m3-ai - AI agent interaction components on Material Design 3
|
|
4
|
+
|
|
5
|
+
// Types
|
|
6
|
+
export type {
|
|
7
|
+
ToolCallStatus,
|
|
8
|
+
ToolCallPair,
|
|
9
|
+
ActionContent,
|
|
10
|
+
TranscriptStep,
|
|
11
|
+
TranscriptItem,
|
|
12
|
+
TaskInfo,
|
|
13
|
+
MemoryInfo,
|
|
14
|
+
CronJobInfo,
|
|
15
|
+
AgentInfo,
|
|
16
|
+
ModelOption,
|
|
17
|
+
TaskStatus,
|
|
18
|
+
TaskPriority,
|
|
19
|
+
Subtask,
|
|
20
|
+
Task,
|
|
21
|
+
} from "./types";
|
|
22
|
+
|
|
23
|
+
export {
|
|
24
|
+
pairTranscriptSteps,
|
|
25
|
+
tryParseJson,
|
|
26
|
+
isErrorResult,
|
|
27
|
+
TASK_STATUS_ORDER,
|
|
28
|
+
TASK_STATUS_LABEL,
|
|
29
|
+
TASK_PRIORITY_LABEL,
|
|
30
|
+
} from "./types";
|
|
31
|
+
|
|
32
|
+
// Components
|
|
33
|
+
export { ToolCall } from "./ToolCall";
|
|
34
|
+
export type { ToolCallProps } from "./ToolCall";
|
|
35
|
+
export {
|
|
36
|
+
Markdown,
|
|
37
|
+
CodeBlock,
|
|
38
|
+
InlineMath,
|
|
39
|
+
BlockMath,
|
|
40
|
+
externalLinkProps,
|
|
41
|
+
isExternalHref,
|
|
42
|
+
} from "./Markdown";
|
|
43
|
+
export type {
|
|
44
|
+
MarkdownProps,
|
|
45
|
+
CodeBlockProps,
|
|
46
|
+
InlineMathProps,
|
|
47
|
+
BlockMathProps,
|
|
48
|
+
MarkdownLinkHandler,
|
|
49
|
+
MarkdownLinkRenderer,
|
|
50
|
+
} from "./Markdown";
|
|
51
|
+
export { MessageBubble } from "./MessageBubble";
|
|
52
|
+
export type { MessageBubbleProps } from "./MessageBubble";
|
|
53
|
+
export { InlineWorkerCard } from "./InlineWorkerCard";
|
|
54
|
+
export type { InlineWorkerCardProps } from "./InlineWorkerCard";
|
|
55
|
+
export { InlineBranchCard } from "./InlineBranchCard";
|
|
56
|
+
export type { InlineBranchCardProps } from "./InlineBranchCard";
|
|
57
|
+
export { ModelSelector, ModelOptionList, groupModelsByProvider } from "./ModelSelector";
|
|
58
|
+
export type { ModelSelectorProps, ModelOptionListProps } from "./ModelSelector";
|
|
59
|
+
export { ChatComposer } from "./ChatComposer";
|
|
60
|
+
export type { ChatComposerProps } from "./ChatComposer";
|
|
61
|
+
|
|
62
|
+
// Google & Gemini Desktop / Web benchmark components
|
|
63
|
+
export { GoogleSpotlightBar } from "./GoogleSpotlightBar";
|
|
64
|
+
export type { GoogleSpotlightBarProps } from "./GoogleSpotlightBar";
|
|
65
|
+
export { GeminiAccountMenu, ComposerMoreUploadsMenu } from "./GeminiAccountMenu";
|
|
66
|
+
export type {
|
|
67
|
+
GeminiAccountMenuProps,
|
|
68
|
+
ComposerMoreUploadsMenuProps,
|
|
69
|
+
MenuItem,
|
|
70
|
+
} from "./GeminiAccountMenu";
|
|
71
|
+
export { GeminiPersonalContextEdit } from "./GeminiPersonalContextEdit";
|
|
72
|
+
export type { GeminiPersonalContextEditProps } from "./GeminiPersonalContextEdit";
|
|
73
|
+
|
|
74
|
+
// Task components
|
|
75
|
+
export { TaskStatusIcon } from "./TaskStatusIcon";
|
|
76
|
+
export type { TaskStatusIconProps } from "./TaskStatusIcon";
|
|
77
|
+
export { TaskPriorityIcon } from "./TaskPriorityIcon";
|
|
78
|
+
export type { TaskPriorityIconProps } from "./TaskPriorityIcon";
|
|
79
|
+
export { TaskRow } from "./TaskRow";
|
|
80
|
+
export type { TaskRowProps } from "./TaskRow";
|
|
81
|
+
export { TaskList } from "./TaskList";
|
|
82
|
+
export type { TaskListProps } from "./TaskList";
|
|
83
|
+
export { TaskDetail } from "./TaskDetail";
|
|
84
|
+
export type { TaskDetailProps } from "./TaskDetail";
|
|
85
|
+
export { TaskCreateForm } from "./TaskCreateForm";
|
|
86
|
+
export type { TaskCreateFormProps, TaskCreateFormData } from "./TaskCreateForm";
|
|
87
|
+
|
|
88
|
+
// AG-UI Protocol & Agent-to-User (A2U) Bridge
|
|
89
|
+
export { EventType } from "@aphrody/ag-ui";
|
|
90
|
+
export {
|
|
91
|
+
AgUiEventAccumulator,
|
|
92
|
+
agUiEventsToTranscriptSteps,
|
|
93
|
+
agUiEventsToTranscriptItems,
|
|
94
|
+
coordEnvelopeToAgUiEvent,
|
|
95
|
+
agUiEventToCoordEnvelope,
|
|
96
|
+
} from "./AgUiEventStream";
|
|
97
|
+
export type {
|
|
98
|
+
CoordEnvelope,
|
|
99
|
+
EnvelopeKind,
|
|
100
|
+
SubagentTrace,
|
|
101
|
+
AgUiStreamState,
|
|
102
|
+
} from "./AgUiEventStream";
|
|
103
|
+
|
|
104
|
+
// Aphrody A2A (Agent-to-Agent) Fleet UI
|
|
105
|
+
export { A2APeerInspector } from "./A2APeerInspector";
|
|
106
|
+
export type { A2APeerInspectorProps, AgentPeer, SendEnvelopePayload } from "./A2APeerInspector";
|
|
107
|
+
export { A2ATaskTimeline } from "./A2ATaskTimeline";
|
|
108
|
+
export type { A2ATaskTimelineProps, TimelineStep } from "./A2ATaskTimeline";
|
|
109
|
+
|
|
110
|
+
// MCP-UI Generative Card
|
|
111
|
+
export { McpAppCard } from "./McpAppCard";
|
|
112
|
+
export type { McpAppCardProps } from "./McpAppCard";
|
|
113
|
+
|
|
114
|
+
// Local chat: injectable labels, OpenAI-compatible streaming, conversations, grounding, memory
|
|
115
|
+
export {
|
|
116
|
+
chatLabelsEn,
|
|
117
|
+
chatLabelsFr,
|
|
118
|
+
chatLabelsJa,
|
|
119
|
+
chatLabelsByLang,
|
|
120
|
+
defaultChatLabels,
|
|
121
|
+
resolveChatLabels,
|
|
122
|
+
formatLabel,
|
|
123
|
+
} from "./chatLabels";
|
|
124
|
+
export type { ChatLabels } from "./chatLabels";
|
|
125
|
+
export {
|
|
126
|
+
ChatStreamError,
|
|
127
|
+
accumulateToolCall,
|
|
128
|
+
chatCompletionsUrl,
|
|
129
|
+
chatReducer,
|
|
130
|
+
createSseParser,
|
|
131
|
+
initialChatState,
|
|
132
|
+
isUnavailableStatus,
|
|
133
|
+
newMessageId,
|
|
134
|
+
parseChatCompletionChunk,
|
|
135
|
+
streamChatCompletion,
|
|
136
|
+
toOpenAIMessages,
|
|
137
|
+
} from "./openaiChatStream";
|
|
138
|
+
export type {
|
|
139
|
+
ChatAction,
|
|
140
|
+
ChatMessage,
|
|
141
|
+
ChatRole,
|
|
142
|
+
ChatState,
|
|
143
|
+
ChatStatus,
|
|
144
|
+
ChatStreamErrorKind,
|
|
145
|
+
ChatStreamEvent,
|
|
146
|
+
OpenAIToolCall,
|
|
147
|
+
StreamChatOptions,
|
|
148
|
+
} from "./openaiChatStream";
|
|
149
|
+
export { useOpenAIChat } from "./useOpenAIChat";
|
|
150
|
+
export type { UseOpenAIChatOptions, UseOpenAIChatResult } from "./useOpenAIChat";
|
|
151
|
+
export { ConversationList, filterConversations } from "./ConversationList";
|
|
152
|
+
export type { ConversationListProps, ConversationSummary } from "./ConversationList";
|
|
153
|
+
export { ModelUnavailable, clampProgress } from "./ModelUnavailable";
|
|
154
|
+
export type { ModelAvailabilityPhase, ModelUnavailableProps } from "./ModelUnavailable";
|
|
155
|
+
export {
|
|
156
|
+
CitationChip,
|
|
157
|
+
CitationList,
|
|
158
|
+
CitedMarkdown,
|
|
159
|
+
RefusalNotice,
|
|
160
|
+
citedNumbers,
|
|
161
|
+
linkCitationMarkers,
|
|
162
|
+
} from "./citations";
|
|
163
|
+
export type {
|
|
164
|
+
Citation,
|
|
165
|
+
CitationChipProps,
|
|
166
|
+
CitationListProps,
|
|
167
|
+
CitedMarkdownProps,
|
|
168
|
+
RefusalNoticeProps,
|
|
169
|
+
} from "./citations";
|
|
170
|
+
export { MemoryPanel } from "./MemoryPanel";
|
|
171
|
+
export type { MemoryItem, MemoryPanelProps } from "./MemoryPanel";
|
|
172
|
+
export { ChatIcon, chatIconNames } from "./chatIcons";
|
|
173
|
+
export type { ChatIconName, ChatIconProps } from "./chatIcons";
|
package/src/internal.tsx
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Shared Material Design 3 building blocks for @aphrody/m3-ai (internal, not exported).
|
|
3
|
+
|
|
4
|
+
import { m3Durations, m3Easings } from "@aphrody/m3-motion";
|
|
5
|
+
import { clsx } from "clsx";
|
|
6
|
+
import type { ReactNode } from "react";
|
|
7
|
+
|
|
8
|
+
/** The M3 focus indicator (contract: docs/design/SPACEUI-M3-FUSION.md, "States"). */
|
|
9
|
+
export const focusRing =
|
|
10
|
+
"outline-none focus-visible:outline-3 focus-visible:outline-secondary focus-visible:outline-offset-2";
|
|
11
|
+
|
|
12
|
+
/** Expand / collapse: enter on emphasized decelerate, exit on emphasized accelerate. */
|
|
13
|
+
export const expandMotion = {
|
|
14
|
+
initial: { height: 0, opacity: 0 },
|
|
15
|
+
animate: {
|
|
16
|
+
height: "auto",
|
|
17
|
+
opacity: 1,
|
|
18
|
+
transition: { duration: m3Durations.medium2, ease: m3Easings.emphasizedDecelerate },
|
|
19
|
+
},
|
|
20
|
+
exit: {
|
|
21
|
+
height: 0,
|
|
22
|
+
opacity: 0,
|
|
23
|
+
transition: { duration: m3Durations.short4, ease: m3Easings.emphasizedAccelerate },
|
|
24
|
+
},
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Indeterminate M3 circular progress indicator (active indicator on `primary` or the current
|
|
29
|
+
* color, 4 dp track gap omitted at small sizes). Replaces the upstream Grid loader.
|
|
30
|
+
*/
|
|
31
|
+
export function CircularIndicator({
|
|
32
|
+
size = 16,
|
|
33
|
+
className,
|
|
34
|
+
label = "Loading",
|
|
35
|
+
}: {
|
|
36
|
+
size?: number;
|
|
37
|
+
className?: string;
|
|
38
|
+
label?: string;
|
|
39
|
+
}) {
|
|
40
|
+
const stroke = size <= 16 ? 2 : size <= 24 ? 3 : 4;
|
|
41
|
+
const r = (size - stroke) / 2;
|
|
42
|
+
const c = 2 * Math.PI * r;
|
|
43
|
+
return (
|
|
44
|
+
<span
|
|
45
|
+
// oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- M3 circular indeterminate indicator is an animated SVG arc; a native <progress> cannot render it.
|
|
46
|
+
role="progressbar"
|
|
47
|
+
aria-label={label}
|
|
48
|
+
className={clsx("m3-circular-indicator inline-flex shrink-0 text-primary", className)}
|
|
49
|
+
style={{ width: size, height: size }}
|
|
50
|
+
>
|
|
51
|
+
<svg
|
|
52
|
+
width={size}
|
|
53
|
+
height={size}
|
|
54
|
+
viewBox={`0 0 ${size} ${size}`}
|
|
55
|
+
className="animate-spin"
|
|
56
|
+
aria-hidden="true"
|
|
57
|
+
>
|
|
58
|
+
<circle
|
|
59
|
+
cx={size / 2}
|
|
60
|
+
cy={size / 2}
|
|
61
|
+
r={r}
|
|
62
|
+
fill="none"
|
|
63
|
+
stroke="currentColor"
|
|
64
|
+
strokeWidth={stroke}
|
|
65
|
+
strokeLinecap="round"
|
|
66
|
+
strokeDasharray={`${c * 0.7} ${c}`}
|
|
67
|
+
/>
|
|
68
|
+
</svg>
|
|
69
|
+
</span>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type ChipTone = "primary" | "success" | "error" | "warning" | "neutral";
|
|
74
|
+
|
|
75
|
+
const chipTones: Record<ChipTone, string> = {
|
|
76
|
+
primary: "bg-primary-container text-on-primary-container",
|
|
77
|
+
success: "bg-success-container text-on-success-container",
|
|
78
|
+
error: "bg-error-container text-on-error-container",
|
|
79
|
+
warning: "bg-warning-container text-on-warning-container",
|
|
80
|
+
neutral: "border border-outline-variant text-on-surface-variant",
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** Non-interactive M3 assist chip used as a status label (dense, `rounded-small`). */
|
|
84
|
+
export function StatusChip({
|
|
85
|
+
tone,
|
|
86
|
+
icon,
|
|
87
|
+
children,
|
|
88
|
+
className,
|
|
89
|
+
}: {
|
|
90
|
+
tone: ChipTone;
|
|
91
|
+
icon?: ReactNode;
|
|
92
|
+
children: ReactNode;
|
|
93
|
+
className?: string;
|
|
94
|
+
}) {
|
|
95
|
+
return (
|
|
96
|
+
<span
|
|
97
|
+
className={clsx(
|
|
98
|
+
"inline-flex h-6 shrink-0 items-center gap-1 rounded-small px-2 text-label-medium",
|
|
99
|
+
chipTones[tone],
|
|
100
|
+
className,
|
|
101
|
+
)}
|
|
102
|
+
>
|
|
103
|
+
{icon}
|
|
104
|
+
{children}
|
|
105
|
+
</span>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Streaming client of an OpenAI-compatible `/v1/chat/completions` endpoint (llama-server,
|
|
3
|
+
// vLLM, the Aphrody gateway...): SSE framing, chunk decoding, tool-call accumulation, a pure
|
|
4
|
+
// reducer for the conversation state, and typed errors that tell "model unavailable" apart.
|
|
5
|
+
// Pure module: no React, no DOM; `fetch` is injectable.
|
|
6
|
+
|
|
7
|
+
import type { Citation } from "./citations";
|
|
8
|
+
|
|
9
|
+
export type ChatRole = "system" | "user" | "assistant" | "tool";
|
|
10
|
+
|
|
11
|
+
/** One complete tool call, as OpenAI returns it once every delta is merged. */
|
|
12
|
+
export interface OpenAIToolCall {
|
|
13
|
+
id: string;
|
|
14
|
+
type: "function";
|
|
15
|
+
function: { name: string; arguments: string };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** One message of the conversation, in the shape the chat components render. */
|
|
19
|
+
export interface ChatMessage {
|
|
20
|
+
id: string;
|
|
21
|
+
role: ChatRole;
|
|
22
|
+
content: string;
|
|
23
|
+
/** `reasoning_content` deltas (reasoning models), kept apart from the answer. */
|
|
24
|
+
reasoning?: string;
|
|
25
|
+
toolCalls?: OpenAIToolCall[];
|
|
26
|
+
/** For a `tool` message: the call it answers. */
|
|
27
|
+
toolCallId?: string;
|
|
28
|
+
finishReason?: string | null;
|
|
29
|
+
/** Grounded answer: the numbered passages `[n]` the text refers to. */
|
|
30
|
+
citations?: Citation[];
|
|
31
|
+
/** The backend refused to answer (out of the sources' scope). */
|
|
32
|
+
refusal?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** What one SSE chunk contributes. */
|
|
36
|
+
export type ChatStreamEvent =
|
|
37
|
+
| { type: "content"; text: string }
|
|
38
|
+
| { type: "reasoning"; text: string }
|
|
39
|
+
| {
|
|
40
|
+
type: "tool_call_delta";
|
|
41
|
+
index: number;
|
|
42
|
+
id?: string;
|
|
43
|
+
name?: string;
|
|
44
|
+
arguments?: string;
|
|
45
|
+
}
|
|
46
|
+
| { type: "citations"; citations: Citation[] }
|
|
47
|
+
| { type: "refusal"; text?: string }
|
|
48
|
+
| { type: "finish"; reason: string }
|
|
49
|
+
| { type: "done" };
|
|
50
|
+
|
|
51
|
+
export type ChatStreamErrorKind = "network" | "http" | "aborted" | "parse" | "stream";
|
|
52
|
+
|
|
53
|
+
/** Typed failure of a chat request. `unavailable` marks a model that cannot answer right now. */
|
|
54
|
+
export class ChatStreamError extends Error {
|
|
55
|
+
readonly kind: ChatStreamErrorKind;
|
|
56
|
+
readonly status?: number;
|
|
57
|
+
readonly unavailable: boolean;
|
|
58
|
+
|
|
59
|
+
constructor(
|
|
60
|
+
kind: ChatStreamErrorKind,
|
|
61
|
+
message: string,
|
|
62
|
+
options: { status?: number; cause?: unknown } = {},
|
|
63
|
+
) {
|
|
64
|
+
super(message, options.cause === undefined ? undefined : { cause: options.cause });
|
|
65
|
+
this.name = "ChatStreamError";
|
|
66
|
+
this.kind = kind;
|
|
67
|
+
this.status = options.status;
|
|
68
|
+
this.unavailable = kind === "network" || isUnavailableStatus(options.status);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** HTTP statuses that mean "no model to answer" rather than "bad request". */
|
|
73
|
+
export function isUnavailableStatus(status: number | undefined): boolean {
|
|
74
|
+
return status === 404 || status === 502 || status === 503 || status === 504;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Incremental SSE parser: feed it decoded text in arbitrary pieces, it calls `onData` with the
|
|
79
|
+
* payload of every complete event (multi-line `data:` fields joined by `\n`). Comments and
|
|
80
|
+
* other fields are ignored.
|
|
81
|
+
*/
|
|
82
|
+
export function createSseParser(onData: (data: string) => void): {
|
|
83
|
+
push(chunk: string): void;
|
|
84
|
+
flush(): void;
|
|
85
|
+
} {
|
|
86
|
+
let buffer = "";
|
|
87
|
+
let data: string[] = [];
|
|
88
|
+
const dispatch = () => {
|
|
89
|
+
if (data.length > 0) onData(data.join("\n"));
|
|
90
|
+
data = [];
|
|
91
|
+
};
|
|
92
|
+
const line = (raw: string) => {
|
|
93
|
+
const text = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
|
|
94
|
+
if (text === "") return dispatch();
|
|
95
|
+
if (text.startsWith(":")) return;
|
|
96
|
+
const colon = text.indexOf(":");
|
|
97
|
+
const field = colon === -1 ? text : text.slice(0, colon);
|
|
98
|
+
let value = colon === -1 ? "" : text.slice(colon + 1);
|
|
99
|
+
if (value.startsWith(" ")) value = value.slice(1);
|
|
100
|
+
if (field === "data") data.push(value);
|
|
101
|
+
};
|
|
102
|
+
return {
|
|
103
|
+
push(chunk) {
|
|
104
|
+
buffer += chunk;
|
|
105
|
+
let newline = buffer.indexOf("\n");
|
|
106
|
+
while (newline !== -1) {
|
|
107
|
+
line(buffer.slice(0, newline));
|
|
108
|
+
buffer = buffer.slice(newline + 1);
|
|
109
|
+
newline = buffer.indexOf("\n");
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
flush() {
|
|
113
|
+
if (buffer.length > 0) line(buffer);
|
|
114
|
+
buffer = "";
|
|
115
|
+
dispatch();
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface RawChunk {
|
|
121
|
+
choices?: {
|
|
122
|
+
delta?: {
|
|
123
|
+
content?: string | null;
|
|
124
|
+
reasoning_content?: string | null;
|
|
125
|
+
refusal?: string | null;
|
|
126
|
+
tool_calls?: {
|
|
127
|
+
index?: number;
|
|
128
|
+
id?: string;
|
|
129
|
+
function?: { name?: string; arguments?: string };
|
|
130
|
+
}[];
|
|
131
|
+
};
|
|
132
|
+
finish_reason?: string | null;
|
|
133
|
+
}[];
|
|
134
|
+
citations?: Citation[];
|
|
135
|
+
refusal?: boolean;
|
|
136
|
+
error?: { message?: string } | string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Decode one `data:` payload of a chat-completions stream into events. `[DONE]` yields `done`.
|
|
141
|
+
* A payload carrying `error` throws a `stream` error; malformed JSON throws a `parse` error.
|
|
142
|
+
* Top-level `citations` / `refusal: true` (grounded backends such as Shenron) are passed on.
|
|
143
|
+
*/
|
|
144
|
+
export function parseChatCompletionChunk(payload: string): ChatStreamEvent[] {
|
|
145
|
+
const trimmed = payload.trim();
|
|
146
|
+
if (trimmed === "") return [];
|
|
147
|
+
if (trimmed === "[DONE]") return [{ type: "done" }];
|
|
148
|
+
let chunk: RawChunk;
|
|
149
|
+
try {
|
|
150
|
+
chunk = JSON.parse(trimmed) as RawChunk;
|
|
151
|
+
} catch (cause) {
|
|
152
|
+
throw new ChatStreamError("parse", "invalid chat-completions chunk", { cause });
|
|
153
|
+
}
|
|
154
|
+
if (chunk.error) {
|
|
155
|
+
const message = typeof chunk.error === "string" ? chunk.error : chunk.error.message;
|
|
156
|
+
throw new ChatStreamError("stream", message || "stream error");
|
|
157
|
+
}
|
|
158
|
+
const events: ChatStreamEvent[] = [];
|
|
159
|
+
for (const choice of chunk.choices ?? []) {
|
|
160
|
+
const delta = choice.delta;
|
|
161
|
+
if (delta?.reasoning_content) events.push({ type: "reasoning", text: delta.reasoning_content });
|
|
162
|
+
if (delta?.content) events.push({ type: "content", text: delta.content });
|
|
163
|
+
if (delta?.refusal) events.push({ type: "refusal", text: delta.refusal });
|
|
164
|
+
for (const call of delta?.tool_calls ?? []) {
|
|
165
|
+
events.push({
|
|
166
|
+
type: "tool_call_delta",
|
|
167
|
+
index: call.index ?? 0,
|
|
168
|
+
id: call.id,
|
|
169
|
+
name: call.function?.name,
|
|
170
|
+
arguments: call.function?.arguments,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
if (choice.finish_reason) events.push({ type: "finish", reason: choice.finish_reason });
|
|
174
|
+
}
|
|
175
|
+
if (Array.isArray(chunk.citations))
|
|
176
|
+
events.push({ type: "citations", citations: chunk.citations });
|
|
177
|
+
if (chunk.refusal === true) events.push({ type: "refusal" });
|
|
178
|
+
return events;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Merge a tool-call delta into the calls accumulated so far (returns a new array). */
|
|
182
|
+
export function accumulateToolCall(
|
|
183
|
+
calls: readonly OpenAIToolCall[],
|
|
184
|
+
delta: Extract<ChatStreamEvent, { type: "tool_call_delta" }>,
|
|
185
|
+
): OpenAIToolCall[] {
|
|
186
|
+
const next = calls.slice();
|
|
187
|
+
const current = next[delta.index] ?? {
|
|
188
|
+
id: "",
|
|
189
|
+
type: "function" as const,
|
|
190
|
+
function: { name: "", arguments: "" },
|
|
191
|
+
};
|
|
192
|
+
next[delta.index] = {
|
|
193
|
+
id: delta.id ?? current.id,
|
|
194
|
+
type: "function",
|
|
195
|
+
function: {
|
|
196
|
+
name: current.function.name + (delta.name ?? ""),
|
|
197
|
+
arguments: current.function.arguments + (delta.arguments ?? ""),
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
return next;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The request body messages, in the OpenAI wire shape. */
|
|
204
|
+
export function toOpenAIMessages(messages: readonly ChatMessage[]): Record<string, unknown>[] {
|
|
205
|
+
return messages.map((message) => {
|
|
206
|
+
const wire: Record<string, unknown> = { role: message.role, content: message.content };
|
|
207
|
+
if (message.toolCalls?.length) wire.tool_calls = message.toolCalls;
|
|
208
|
+
if (message.toolCallId) wire.tool_call_id = message.toolCallId;
|
|
209
|
+
return wire;
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface StreamChatOptions {
|
|
214
|
+
/** API root including `/v1` (e.g. `http://127.0.0.1:8080/v1`). */
|
|
215
|
+
baseUrl: string;
|
|
216
|
+
model?: string;
|
|
217
|
+
messages: readonly ChatMessage[];
|
|
218
|
+
/** Extra body fields (`temperature`, `tools`, `max_tokens`...). */
|
|
219
|
+
body?: Record<string, unknown>;
|
|
220
|
+
headers?: Record<string, string>;
|
|
221
|
+
signal?: AbortSignal;
|
|
222
|
+
fetch?: typeof fetch;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** `<baseUrl>/chat/completions`, tolerant of a trailing slash. */
|
|
226
|
+
export function chatCompletionsUrl(baseUrl: string): string {
|
|
227
|
+
return `${baseUrl.replace(/\/+$/, "")}/chat/completions`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* POST a streaming chat completion and yield its events until `[DONE]` or the end of the body.
|
|
232
|
+
*
|
|
233
|
+
* Throws `ChatStreamError`: `network` (fetch rejected: server down), `http` (non-2xx status;
|
|
234
|
+
* `unavailable` for 404/502/503/504), `aborted` (the signal fired), `parse` / `stream`.
|
|
235
|
+
*/
|
|
236
|
+
export async function* streamChatCompletion(
|
|
237
|
+
options: StreamChatOptions,
|
|
238
|
+
): AsyncGenerator<ChatStreamEvent, void, undefined> {
|
|
239
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
240
|
+
let response: Response;
|
|
241
|
+
try {
|
|
242
|
+
response = await doFetch(chatCompletionsUrl(options.baseUrl), {
|
|
243
|
+
method: "POST",
|
|
244
|
+
headers: {
|
|
245
|
+
"content-type": "application/json",
|
|
246
|
+
accept: "text/event-stream",
|
|
247
|
+
...options.headers,
|
|
248
|
+
},
|
|
249
|
+
body: JSON.stringify({
|
|
250
|
+
...options.body,
|
|
251
|
+
...(options.model ? { model: options.model } : {}),
|
|
252
|
+
messages: toOpenAIMessages(options.messages),
|
|
253
|
+
stream: true,
|
|
254
|
+
}),
|
|
255
|
+
signal: options.signal,
|
|
256
|
+
});
|
|
257
|
+
} catch (cause) {
|
|
258
|
+
if (options.signal?.aborted) throw new ChatStreamError("aborted", "request aborted", { cause });
|
|
259
|
+
throw new ChatStreamError("network", "chat endpoint unreachable", { cause });
|
|
260
|
+
}
|
|
261
|
+
if (!response.ok) {
|
|
262
|
+
let detail = "";
|
|
263
|
+
try {
|
|
264
|
+
detail = (await response.text()).slice(0, 500);
|
|
265
|
+
} catch {
|
|
266
|
+
// body unreadable: the status alone is reported
|
|
267
|
+
}
|
|
268
|
+
throw new ChatStreamError("http", detail || `HTTP ${response.status}`, {
|
|
269
|
+
status: response.status,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
if (!response.body) throw new ChatStreamError("stream", "response has no body");
|
|
273
|
+
|
|
274
|
+
const queue: string[] = [];
|
|
275
|
+
const parser = createSseParser((data) => queue.push(data));
|
|
276
|
+
const reader = response.body.getReader();
|
|
277
|
+
const decoder = new TextDecoder();
|
|
278
|
+
try {
|
|
279
|
+
for (;;) {
|
|
280
|
+
let result: Awaited<ReturnType<typeof reader.read>>;
|
|
281
|
+
try {
|
|
282
|
+
// oxlint-disable-next-line no-await-in-loop -- SSE chunks are read in order, one at a time.
|
|
283
|
+
result = await reader.read();
|
|
284
|
+
} catch (cause) {
|
|
285
|
+
if (options.signal?.aborted)
|
|
286
|
+
throw new ChatStreamError("aborted", "request aborted", { cause });
|
|
287
|
+
throw new ChatStreamError("stream", "stream interrupted", { cause });
|
|
288
|
+
}
|
|
289
|
+
if (result.done) {
|
|
290
|
+
parser.push(decoder.decode());
|
|
291
|
+
parser.flush();
|
|
292
|
+
} else {
|
|
293
|
+
parser.push(decoder.decode(result.value, { stream: true }));
|
|
294
|
+
}
|
|
295
|
+
while (queue.length > 0) {
|
|
296
|
+
for (const event of parseChatCompletionChunk(queue.shift()!)) {
|
|
297
|
+
yield event;
|
|
298
|
+
if (event.type === "done") return;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (result.done) return;
|
|
302
|
+
}
|
|
303
|
+
} finally {
|
|
304
|
+
reader.releaseLock();
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ── Conversation state ─────────────────────────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
export type ChatStatus = "idle" | "streaming" | "error" | "unavailable";
|
|
311
|
+
|
|
312
|
+
export interface ChatState {
|
|
313
|
+
messages: ChatMessage[];
|
|
314
|
+
status: ChatStatus;
|
|
315
|
+
error: ChatStreamError | null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export type ChatAction =
|
|
319
|
+
| { type: "start"; user: ChatMessage; assistantId: string }
|
|
320
|
+
| { type: "event"; assistantId: string; event: ChatStreamEvent }
|
|
321
|
+
| { type: "finish" }
|
|
322
|
+
| { type: "fail"; error: ChatStreamError }
|
|
323
|
+
| { type: "stop" }
|
|
324
|
+
| { type: "reset"; messages?: ChatMessage[] }
|
|
325
|
+
| { type: "set"; messages: ChatMessage[] };
|
|
326
|
+
|
|
327
|
+
export function initialChatState(messages: ChatMessage[] = []): ChatState {
|
|
328
|
+
return { messages, status: "idle", error: null };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function applyEvent(message: ChatMessage, event: ChatStreamEvent): ChatMessage {
|
|
332
|
+
switch (event.type) {
|
|
333
|
+
case "content":
|
|
334
|
+
return { ...message, content: message.content + event.text };
|
|
335
|
+
case "reasoning":
|
|
336
|
+
return { ...message, reasoning: (message.reasoning ?? "") + event.text };
|
|
337
|
+
case "tool_call_delta":
|
|
338
|
+
return { ...message, toolCalls: accumulateToolCall(message.toolCalls ?? [], event) };
|
|
339
|
+
case "citations":
|
|
340
|
+
return { ...message, citations: event.citations };
|
|
341
|
+
case "refusal":
|
|
342
|
+
return { ...message, refusal: true };
|
|
343
|
+
case "finish":
|
|
344
|
+
return { ...message, finishReason: event.reason };
|
|
345
|
+
case "done":
|
|
346
|
+
return message;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Pure reducer of a streamed conversation (the state behind `useOpenAIChat`). */
|
|
351
|
+
export function chatReducer(state: ChatState, action: ChatAction): ChatState {
|
|
352
|
+
switch (action.type) {
|
|
353
|
+
case "start":
|
|
354
|
+
return {
|
|
355
|
+
status: "streaming",
|
|
356
|
+
error: null,
|
|
357
|
+
messages: [
|
|
358
|
+
...state.messages,
|
|
359
|
+
action.user,
|
|
360
|
+
{ id: action.assistantId, role: "assistant", content: "" },
|
|
361
|
+
],
|
|
362
|
+
};
|
|
363
|
+
case "event":
|
|
364
|
+
return {
|
|
365
|
+
...state,
|
|
366
|
+
messages: state.messages.map((message) =>
|
|
367
|
+
message.id === action.assistantId ? applyEvent(message, action.event) : message,
|
|
368
|
+
),
|
|
369
|
+
};
|
|
370
|
+
case "finish":
|
|
371
|
+
case "stop":
|
|
372
|
+
return { ...state, status: "idle" };
|
|
373
|
+
case "fail": {
|
|
374
|
+
// An empty assistant turn left by a failed request is dropped.
|
|
375
|
+
const last = state.messages.at(-1);
|
|
376
|
+
const messages =
|
|
377
|
+
last && last.role === "assistant" && !last.content && !last.toolCalls?.length
|
|
378
|
+
? state.messages.slice(0, -1)
|
|
379
|
+
: state.messages;
|
|
380
|
+
return {
|
|
381
|
+
messages,
|
|
382
|
+
status: action.error.unavailable ? "unavailable" : "error",
|
|
383
|
+
error: action.error,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
case "reset":
|
|
387
|
+
return initialChatState(action.messages ?? []);
|
|
388
|
+
case "set":
|
|
389
|
+
return { ...state, messages: action.messages };
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
let counter = 0;
|
|
394
|
+
/** Message id: `crypto.randomUUID` when present, else a process-local counter. */
|
|
395
|
+
export function newMessageId(): string {
|
|
396
|
+
const uuid = globalThis.crypto?.randomUUID?.();
|
|
397
|
+
if (uuid) return uuid;
|
|
398
|
+
counter += 1;
|
|
399
|
+
return `msg-${Date.now().toString(36)}-${counter}`;
|
|
400
|
+
}
|