streamtrust 0.2.0 → 0.3.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/README.md +17 -7
- package/examples/librechat/README.md +2 -2
- package/examples/vercel-ai/README.md +108 -0
- package/examples/vercel-ai/adapter.mjs +333 -0
- package/examples/vercel-ai/config.mjs +17 -0
- package/examples/vercel-ai/provider.mjs +79 -0
- package/examples/vercel-ai/reference-app/app/api/chat/route.js +86 -0
- package/examples/vercel-ai/reference-app/app/chat/[id]/chat.jsx +69 -0
- package/examples/vercel-ai/reference-app/app/chat/[id]/page.jsx +20 -0
- package/examples/vercel-ai/reference-app/app/layout.jsx +18 -0
- package/examples/vercel-ai/reference-app/app/page.jsx +8 -0
- package/examples/vercel-ai/reference-app/lib/store.js +37 -0
- package/examples/vercel-ai/reference-app/next.config.mjs +9 -0
- package/examples/vercel-ai/reference-app/npm-shrinkwrap.json +1247 -0
- package/examples/vercel-ai/reference-app/package.json +21 -0
- package/examples/vercel-ai/setup.mjs +27 -0
- package/package.json +13 -2
- package/src/cli.d.ts +2 -0
- package/src/cli.js +28 -16
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { consumeStream, streamText } from 'ai';
|
|
3
|
+
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
|
4
|
+
import { CHAT_ID, activePath, loadChat, saveChat } from '../../../lib/store.js';
|
|
5
|
+
|
|
6
|
+
export const dynamic = 'force-dynamic';
|
|
7
|
+
|
|
8
|
+
const provider = createOpenAICompatible({
|
|
9
|
+
name: 'model',
|
|
10
|
+
baseURL: process.env.MODEL_BASE_URL,
|
|
11
|
+
apiKey: process.env.MODEL_API_KEY,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const textOf = (message) =>
|
|
15
|
+
(message?.parts ?? [])
|
|
16
|
+
.filter((p) => p.type === 'text')
|
|
17
|
+
.map((p) => p.text)
|
|
18
|
+
.join('');
|
|
19
|
+
|
|
20
|
+
export async function POST(req) {
|
|
21
|
+
const { id, trigger, message } = await req.json();
|
|
22
|
+
if (!CHAT_ID.test(id ?? ''))
|
|
23
|
+
return new Response('Invalid chat id', { status: 400 });
|
|
24
|
+
const chat = (await loadChat(id)) ?? { id, messages: [], activeLeafId: '' };
|
|
25
|
+
let parentId;
|
|
26
|
+
if (trigger === 'regenerate-message') {
|
|
27
|
+
const leaf = chat.messages.find((m) => m.id === chat.activeLeafId);
|
|
28
|
+
if (!leaf) return new Response('Nothing to regenerate', { status: 409 });
|
|
29
|
+
// Keep the previous answer as a sibling instead of overwriting history.
|
|
30
|
+
parentId = leaf.role === 'assistant' ? leaf.parentId : leaf.id;
|
|
31
|
+
} else {
|
|
32
|
+
const text = textOf(message);
|
|
33
|
+
if (!text) return new Response('Empty message', { status: 400 });
|
|
34
|
+
const userId =
|
|
35
|
+
message.id && !chat.messages.some((m) => m.id === message.id)
|
|
36
|
+
? message.id
|
|
37
|
+
: randomUUID();
|
|
38
|
+
chat.messages.push({
|
|
39
|
+
id: userId,
|
|
40
|
+
parentId: chat.activeLeafId,
|
|
41
|
+
role: 'user',
|
|
42
|
+
text,
|
|
43
|
+
status: 'complete',
|
|
44
|
+
});
|
|
45
|
+
parentId = userId;
|
|
46
|
+
}
|
|
47
|
+
const history = activePath(chat, parentId)
|
|
48
|
+
.filter((m) => m.role === 'user' || m.status === 'complete')
|
|
49
|
+
.map((m) => ({ role: m.role, content: m.text }));
|
|
50
|
+
const assistantId = randomUUID();
|
|
51
|
+
chat.messages.push({
|
|
52
|
+
id: assistantId,
|
|
53
|
+
parentId,
|
|
54
|
+
role: 'assistant',
|
|
55
|
+
text: '',
|
|
56
|
+
status: 'streaming',
|
|
57
|
+
});
|
|
58
|
+
chat.activeLeafId = assistantId;
|
|
59
|
+
await saveChat(chat);
|
|
60
|
+
|
|
61
|
+
const result = streamText({
|
|
62
|
+
model: provider.chatModel(process.env.MODEL_NAME || 'fixture-model'),
|
|
63
|
+
messages: history,
|
|
64
|
+
maxRetries: 0,
|
|
65
|
+
// Stop in the browser aborts this request; propagate it to the model call.
|
|
66
|
+
abortSignal: req.signal,
|
|
67
|
+
});
|
|
68
|
+
return result.toUIMessageStreamResponse({
|
|
69
|
+
generateMessageId: () => assistantId,
|
|
70
|
+
onError: () => 'The model provider failed.',
|
|
71
|
+
// Keep consuming after a client disconnect so onEnd still records the outcome.
|
|
72
|
+
consumeSseStream: consumeStream,
|
|
73
|
+
async onEnd({ responseMessage, outcome }) {
|
|
74
|
+
const saved = await loadChat(id);
|
|
75
|
+
const target = saved.messages.find((m) => m.id === assistantId);
|
|
76
|
+
target.text = textOf(responseMessage);
|
|
77
|
+
target.status =
|
|
78
|
+
outcome.status === 'completed'
|
|
79
|
+
? 'complete'
|
|
80
|
+
: outcome.status === 'aborted'
|
|
81
|
+
? 'aborted'
|
|
82
|
+
: 'error';
|
|
83
|
+
await saveChat(saved);
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { useChat } from '@ai-sdk/react';
|
|
4
|
+
import { DefaultChatTransport } from 'ai';
|
|
5
|
+
|
|
6
|
+
const FAILED = 'The model provider failed.';
|
|
7
|
+
|
|
8
|
+
export default function Chat({ id, initialMessages }) {
|
|
9
|
+
const [input, setInput] = useState('');
|
|
10
|
+
const { messages, sendMessage, regenerate, stop, status, error } = useChat({
|
|
11
|
+
id,
|
|
12
|
+
messages: initialMessages,
|
|
13
|
+
transport: new DefaultChatTransport({
|
|
14
|
+
api: '/api/chat',
|
|
15
|
+
// The server owns history; send only the trigger and the newest message.
|
|
16
|
+
prepareSendMessagesRequest: ({ id, messages, trigger }) => ({
|
|
17
|
+
body: { id, trigger, message: messages.at(-1) },
|
|
18
|
+
}),
|
|
19
|
+
}),
|
|
20
|
+
});
|
|
21
|
+
const busy = status === 'submitted' || status === 'streaming';
|
|
22
|
+
const last = messages.at(-1);
|
|
23
|
+
return (
|
|
24
|
+
<main>
|
|
25
|
+
<div data-testid="messages">
|
|
26
|
+
{messages.map((m) => (
|
|
27
|
+
<div key={m.id} data-role={m.role} style={{ margin: '12px 0' }}>
|
|
28
|
+
<strong>{m.role === 'user' ? 'You' : 'Assistant'}: </strong>
|
|
29
|
+
{m.parts
|
|
30
|
+
.filter((p) => p.type === 'text')
|
|
31
|
+
.map((p) => p.text)
|
|
32
|
+
.join('')}
|
|
33
|
+
{m.metadata?.status === 'aborted' && <em> (stopped)</em>}
|
|
34
|
+
{m.metadata?.status === 'error' && <p role="alert">{FAILED}</p>}
|
|
35
|
+
</div>
|
|
36
|
+
))}
|
|
37
|
+
{error && <p role="alert">{FAILED}</p>}
|
|
38
|
+
</div>
|
|
39
|
+
<form
|
|
40
|
+
onSubmit={(e) => {
|
|
41
|
+
e.preventDefault();
|
|
42
|
+
if (!input.trim() || busy) return;
|
|
43
|
+
sendMessage({ text: input });
|
|
44
|
+
setInput('');
|
|
45
|
+
}}
|
|
46
|
+
>
|
|
47
|
+
<input
|
|
48
|
+
aria-label="Message"
|
|
49
|
+
value={input}
|
|
50
|
+
onChange={(e) => setInput(e.target.value)}
|
|
51
|
+
style={{ width: '70%' }}
|
|
52
|
+
/>
|
|
53
|
+
<button type="submit" disabled={busy}>
|
|
54
|
+
Send
|
|
55
|
+
</button>
|
|
56
|
+
{busy && (
|
|
57
|
+
<button type="button" onClick={() => stop()}>
|
|
58
|
+
Stop
|
|
59
|
+
</button>
|
|
60
|
+
)}
|
|
61
|
+
{!busy && (error || last?.role === 'assistant') && (
|
|
62
|
+
<button type="button" onClick={() => regenerate()}>
|
|
63
|
+
Regenerate
|
|
64
|
+
</button>
|
|
65
|
+
)}
|
|
66
|
+
</form>
|
|
67
|
+
</main>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { notFound } from 'next/navigation';
|
|
2
|
+
import { CHAT_ID, activePath, loadChat } from '../../../lib/store.js';
|
|
3
|
+
import Chat from './chat.jsx';
|
|
4
|
+
|
|
5
|
+
export const dynamic = 'force-dynamic';
|
|
6
|
+
|
|
7
|
+
export default async function ChatPage({ params }) {
|
|
8
|
+
const { id } = await params;
|
|
9
|
+
if (!CHAT_ID.test(id)) notFound();
|
|
10
|
+
const chat = await loadChat(id);
|
|
11
|
+
const initialMessages = chat
|
|
12
|
+
? activePath(chat).map((m) => ({
|
|
13
|
+
id: m.id,
|
|
14
|
+
role: m.role,
|
|
15
|
+
parts: [{ type: 'text', text: m.text }],
|
|
16
|
+
metadata: { status: m.status },
|
|
17
|
+
}))
|
|
18
|
+
: [];
|
|
19
|
+
return <Chat id={id} initialMessages={initialMessages} />;
|
|
20
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export const metadata = { title: 'AI SDK reference chat' };
|
|
2
|
+
|
|
3
|
+
export default function RootLayout({ children }) {
|
|
4
|
+
return (
|
|
5
|
+
<html lang="en">
|
|
6
|
+
<body
|
|
7
|
+
style={{
|
|
8
|
+
fontFamily: 'system-ui',
|
|
9
|
+
maxWidth: 720,
|
|
10
|
+
margin: '40px auto',
|
|
11
|
+
padding: 16,
|
|
12
|
+
}}
|
|
13
|
+
>
|
|
14
|
+
{children}
|
|
15
|
+
</body>
|
|
16
|
+
</html>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// File-backed chat store: the reference app's authoritative state.
|
|
2
|
+
// Messages are append-only with parent links, so regeneration adds a sibling branch.
|
|
3
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
export const CHAT_ID = /^[a-zA-Z0-9_-]{1,64}$/;
|
|
8
|
+
|
|
9
|
+
function dir() {
|
|
10
|
+
if (!process.env.CHAT_STORE_DIR)
|
|
11
|
+
throw new Error('CHAT_STORE_DIR is required');
|
|
12
|
+
return process.env.CHAT_STORE_DIR;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function loadChat(id) {
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(await readFile(join(dir(), `${id}.json`), 'utf8'));
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if (error.code === 'ENOENT') return null;
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function saveChat(chat) {
|
|
25
|
+
await mkdir(dir(), { recursive: true });
|
|
26
|
+
const tmp = join(dir(), `.${chat.id}.${randomUUID()}.tmp`);
|
|
27
|
+
await writeFile(tmp, JSON.stringify(chat));
|
|
28
|
+
await rename(tmp, join(dir(), `${chat.id}.json`));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The branch currently shown to the user: root → activeLeafId.
|
|
32
|
+
export function activePath(chat, leafId = chat.activeLeafId) {
|
|
33
|
+
const byId = new Map(chat.messages.map((m) => [m.id, m]));
|
|
34
|
+
const path = [];
|
|
35
|
+
for (let m = byId.get(leafId); m; m = byId.get(m.parentId)) path.unshift(m);
|
|
36
|
+
return path;
|
|
37
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
|
|
3
|
+
const root = fileURLToPath(new URL('.', import.meta.url));
|
|
4
|
+
|
|
5
|
+
// Pin the workspace root: this app usually sits inside a verification project with its own lockfile.
|
|
6
|
+
export default {
|
|
7
|
+
outputFileTracingRoot: root,
|
|
8
|
+
turbopack: { root },
|
|
9
|
+
};
|