durable-actors 0.5.2 → 0.5.3
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 +23 -26
- package/dist/cli/actor-source-watcher.d.ts +18 -4
- package/dist/cli/actor-source-watcher.d.ts.map +1 -1
- package/dist/cli/actor-source-watcher.js +90 -32
- package/dist/cli/actor-source-watcher.js.map +1 -1
- package/dist/templates/actor/package.json +1 -1
- package/dist/templates/ai-chat/README.md +3 -25
- package/dist/templates/ai-chat/package.json +1 -1
- package/dist/templates/ai-chat/src/Chat.tsx +9 -2
- package/dist/templates/ai-chat/src/actors.ts +2 -3
- package/dist/templates/ai-chat/src/backend.ts +7 -2
- package/dist/templates/chat/README.md +1 -1
- package/dist/templates/chat/package.json +1 -1
- package/dist/templates/documents/README.md +1 -1
- package/dist/templates/documents/package.json +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<div align="
|
|
1
|
+
<div align="left">
|
|
2
2
|
<a href="https://github.com/TerseAI/durable-actors/blob/main/.github/assets/team-agent.gif">
|
|
3
3
|
<picture>
|
|
4
4
|
<source media="(prefers-reduced-motion: reduce)" srcset="https://raw.githubusercontent.com/TerseAI/durable-actors/main/.github/assets/team-agent.png">
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
<h1>Durable Actors</h1>
|
|
10
10
|
|
|
11
11
|
<p><strong>Durable state for collaborative apps and AI agents.</strong></p>
|
|
12
|
-
<p>TypeScript actors. Rust runtime
|
|
12
|
+
<p>TypeScript actors. Rust runtime.</p>
|
|
13
13
|
|
|
14
14
|
<p>
|
|
15
15
|
<a href="https://github.com/TerseAI/durable-actors/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/TerseAI/durable-actors?style=flat&logo=github&color=f5a623"></a>
|
|
@@ -97,17 +97,9 @@ console.log(await counter.increment())
|
|
|
97
97
|
|
|
98
98
|
For complete sample applications, see [AI Chat](https://github.com/TerseAI/durable-actors/tree/main/examples/ai-chat), [Collaborative documents](https://github.com/TerseAI/durable-actors/tree/main/examples/documents), and [Chatroom](https://github.com/TerseAI/durable-actors/tree/main/examples/chat).
|
|
99
99
|
|
|
100
|
-
## Define an
|
|
100
|
+
## Define an actor
|
|
101
101
|
|
|
102
|
-
Install `ai` in your actor project:
|
|
103
|
-
|
|
104
|
-
```sh
|
|
105
|
-
pnpm install ai
|
|
106
|
-
# Or with npm:
|
|
107
|
-
npm install ai
|
|
108
|
-
```
|
|
109
|
-
|
|
110
|
-
Define and export actors in your actor project’s `src/actors.ts`, the default entrypoint loaded by `durable-actors dev`. For example, a chat history actor:
|
|
102
|
+
Install `ai` in your actor project, then define actors in `src/actors.ts`. This chat history stores completed exchanges in one persisted array:
|
|
111
103
|
|
|
112
104
|
```ts
|
|
113
105
|
import type { UIMessage } from "ai"
|
|
@@ -120,16 +112,15 @@ export class ChatHistory extends Actor {
|
|
|
120
112
|
return this.messages
|
|
121
113
|
}
|
|
122
114
|
|
|
123
|
-
async append(
|
|
124
|
-
this.messages.push(
|
|
125
|
-
return this.messages
|
|
115
|
+
async append(...messages: UIMessage[]) {
|
|
116
|
+
this.messages.push(...messages)
|
|
126
117
|
}
|
|
127
118
|
}
|
|
128
119
|
```
|
|
129
120
|
|
|
130
|
-
## Stream from
|
|
121
|
+
## Stream from your backend
|
|
131
122
|
|
|
132
|
-
|
|
123
|
+
Generate a type-safe client with `npx durable-actors generate`. In your Express backend, load the conversation, stream a reply, and save the prompt and reply together when the stream succeeds:
|
|
133
124
|
|
|
134
125
|
```ts
|
|
135
126
|
import { openai } from "@ai-sdk/openai"
|
|
@@ -149,7 +140,8 @@ app.post("/api/chat", async (request, response) => {
|
|
|
149
140
|
const [message] = await validateUIMessages({ messages: [request.body.messages.at(-1)] })
|
|
150
141
|
if (message.role !== "user") return response.sendStatus(400)
|
|
151
142
|
const chat = actors.ChatHistory.get(request.body.id)
|
|
152
|
-
const messages = await chat.
|
|
143
|
+
const messages = [...(await chat.load()), message]
|
|
144
|
+
let failed = false
|
|
153
145
|
const result = streamText({
|
|
154
146
|
model: openai("gpt-5-mini"),
|
|
155
147
|
messages: await convertToModelMessages(messages)
|
|
@@ -160,15 +152,23 @@ app.post("/api/chat", async (request, response) => {
|
|
|
160
152
|
stream: result.stream,
|
|
161
153
|
originalMessages: messages,
|
|
162
154
|
generateMessageId: generateId,
|
|
155
|
+
onError: () => {
|
|
156
|
+
failed = true
|
|
157
|
+
return "Reply failed. Check the API key and try again."
|
|
158
|
+
},
|
|
163
159
|
onEnd: async ({ responseMessage, outcome }) => {
|
|
164
|
-
if (outcome.status === "completed") await chat.append(responseMessage)
|
|
160
|
+
if (!failed && outcome.status === "completed") await chat.append(message, responseMessage)
|
|
165
161
|
}
|
|
166
162
|
})
|
|
167
163
|
})
|
|
168
164
|
})
|
|
169
165
|
```
|
|
170
166
|
|
|
171
|
-
|
|
167
|
+
Failed or interrupted attempts are not saved. Each completed exchange is persisted in one actor call.
|
|
168
|
+
|
|
169
|
+
## Connect the frontend
|
|
170
|
+
|
|
171
|
+
The AI SDK's `useChat` handles streaming and retries:
|
|
172
172
|
|
|
173
173
|
```tsx
|
|
174
174
|
import { useChat } from "@ai-sdk/react"
|
|
@@ -177,7 +177,7 @@ import type { UIMessage } from "ai"
|
|
|
177
177
|
const history: UIMessage[] = await fetch("/api/chat/lobby").then(response => response.json())
|
|
178
178
|
|
|
179
179
|
function Chat() {
|
|
180
|
-
const { messages, sendMessage, status } = useChat({ id: "lobby", messages: history })
|
|
180
|
+
const { messages, sendMessage, regenerate, status, error } = useChat({ id: "lobby", messages: history })
|
|
181
181
|
const busy = status === "submitted" || status === "streaming"
|
|
182
182
|
|
|
183
183
|
return (
|
|
@@ -187,14 +187,11 @@ function Chat() {
|
|
|
187
187
|
{message.role}: {message.parts.map(part => (part.type === "text" ? part.text : "")).join("")}
|
|
188
188
|
</p>
|
|
189
189
|
))}
|
|
190
|
-
<form
|
|
191
|
-
action={async form => {
|
|
192
|
-
await sendMessage({ text: String(form.get("message")) })
|
|
193
|
-
}}
|
|
194
|
-
>
|
|
190
|
+
<form action={async form => { await sendMessage({ text: String(form.get("message")) }) }}>
|
|
195
191
|
<input name="message" aria-label="Message" required disabled={busy} />
|
|
196
192
|
<button disabled={busy}>Send</button>
|
|
197
193
|
</form>
|
|
194
|
+
{error && <p role="alert">{error.message} <button onClick={() => regenerate()} disabled={busy}>Retry</button></p>}
|
|
198
195
|
</>
|
|
199
196
|
)
|
|
200
197
|
}
|
|
@@ -1,11 +1,25 @@
|
|
|
1
|
+
import { watch } from "chokidar";
|
|
1
2
|
interface ActorSourceWatcherOptions {
|
|
2
3
|
projectDirectory: string;
|
|
3
4
|
dataDirectory?: string;
|
|
4
5
|
}
|
|
5
|
-
|
|
6
|
+
declare function watchActorSources(options: ActorSourceWatcherOptions, refresh: () => Promise<void>): Promise<ActorSourceWatcher>;
|
|
7
|
+
declare class ActorSourceWatcher {
|
|
8
|
+
private readonly options;
|
|
9
|
+
private readonly refresh;
|
|
10
|
+
private readonly createWatcher;
|
|
11
|
+
private readonly warn;
|
|
12
|
+
private watcher?;
|
|
13
|
+
private timer?;
|
|
14
|
+
private updates;
|
|
15
|
+
private closing?;
|
|
16
|
+
private disabled;
|
|
17
|
+
constructor(options: ActorSourceWatcherOptions, refresh: () => Promise<void>, createWatcher: typeof watch, warn: (message: string) => void);
|
|
18
|
+
start(): Promise<void>;
|
|
6
19
|
close(): Promise<void>;
|
|
20
|
+
private scheduleRefresh;
|
|
21
|
+
private waitUntilReady;
|
|
22
|
+
private disable;
|
|
7
23
|
}
|
|
8
|
-
|
|
9
|
-
export { watchActorSources };
|
|
10
|
-
export type { ActorSourceWatcher };
|
|
24
|
+
export { ActorSourceWatcher, watchActorSources };
|
|
11
25
|
//# sourceMappingURL=actor-source-watcher.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"actor-source-watcher.d.ts","sourceRoot":"","sources":["../../src/cli/actor-source-watcher.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"actor-source-watcher.d.ts","sourceRoot":"","sources":["../../src/cli/actor-source-watcher.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkB,KAAK,EAAE,MAAM,UAAU,CAAA;AAGhD,UAAU,yBAAyB;IAC/B,gBAAgB,EAAE,MAAM,CAAA;IACxB,aAAa,CAAC,EAAE,MAAM,CAAA;CACzB;AAED,iBAAe,iBAAiB,CAC5B,OAAO,EAAE,yBAAyB,EAClC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAC7B,OAAO,CAAC,kBAAkB,CAAC,CAI7B;AAED,cAAM,kBAAkB;IAQhB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,aAAa;IAC9B,OAAO,CAAC,QAAQ,CAAC,IAAI;IAVzB,OAAO,CAAC,OAAO,CAAC,CAAW;IAC3B,OAAO,CAAC,KAAK,CAAC,CAA+B;IAC7C,OAAO,CAAC,OAAO,CAAoB;IACnC,OAAO,CAAC,OAAO,CAAC,CAAe;IAC/B,OAAO,CAAC,QAAQ,CAAQ;gBAGH,OAAO,EAAE,yBAAyB,EAClC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,EAC5B,aAAa,EAAE,OAAO,KAAK,EAC3B,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI;IAG9C,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAsBtB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAY5B,OAAO,CAAC,eAAe;IAQvB,OAAO,CAAC,cAAc;YAgBR,OAAO;CAUxB;AAyBD,OAAO,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,CAAA"}
|
|
@@ -1,39 +1,97 @@
|
|
|
1
1
|
import { watch } from "chokidar";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
async function watchActorSources(options, refresh) {
|
|
4
|
-
const watcher =
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
4
|
+
const watcher = new ActorSourceWatcher(options, refresh, watch, message => console.warn(message));
|
|
5
|
+
await watcher.start();
|
|
6
|
+
return watcher;
|
|
7
|
+
}
|
|
8
|
+
class ActorSourceWatcher {
|
|
9
|
+
options;
|
|
10
|
+
refresh;
|
|
11
|
+
createWatcher;
|
|
12
|
+
warn;
|
|
13
|
+
watcher;
|
|
14
|
+
timer;
|
|
15
|
+
updates = Promise.resolve();
|
|
16
|
+
closing;
|
|
17
|
+
disabled = false;
|
|
18
|
+
constructor(options, refresh, createWatcher, warn) {
|
|
19
|
+
this.options = options;
|
|
20
|
+
this.refresh = refresh;
|
|
21
|
+
this.createWatcher = createWatcher;
|
|
22
|
+
this.warn = warn;
|
|
23
|
+
}
|
|
24
|
+
async start() {
|
|
25
|
+
try {
|
|
26
|
+
this.watcher = this.createWatcher(this.options.projectDirectory, {
|
|
27
|
+
ignored: (watchedPath, stats) => ignoredActorPath(watchedPath, this.options) ||
|
|
28
|
+
(stats !== undefined && !stats.isDirectory() && (!stats.isFile() || !isActorSource(watchedPath))),
|
|
29
|
+
ignoreInitial: true,
|
|
30
|
+
followSymlinks: false,
|
|
31
|
+
atomic: true,
|
|
32
|
+
awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 20 }
|
|
33
|
+
});
|
|
34
|
+
this.watcher.on("all", (_event, changedPath) => this.scheduleRefresh(changedPath));
|
|
35
|
+
await this.waitUntilReady(this.watcher);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (isWatchLimit(error))
|
|
39
|
+
await this.disable(error);
|
|
40
|
+
else {
|
|
41
|
+
await this.close();
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async close() {
|
|
47
|
+
this.disabled = true;
|
|
48
|
+
clearTimeout(this.timer);
|
|
49
|
+
if (!this.closing) {
|
|
50
|
+
this.closing = this.watcher?.close() ?? Promise.resolve();
|
|
51
|
+
// Chokidar removes listeners on close, but pending filesystem operations can still emit errors.
|
|
52
|
+
this.watcher?.on("error", () => { });
|
|
53
|
+
}
|
|
54
|
+
await this.closing;
|
|
55
|
+
await this.updates;
|
|
56
|
+
}
|
|
57
|
+
scheduleRefresh(changedPath) {
|
|
58
|
+
if (this.disabled || !isActorSource(changedPath))
|
|
16
59
|
return;
|
|
17
|
-
clearTimeout(timer);
|
|
18
|
-
timer = setTimeout(() => {
|
|
19
|
-
updates = updates.then(refresh).catch(reportWatchError);
|
|
60
|
+
clearTimeout(this.timer);
|
|
61
|
+
this.timer = setTimeout(() => {
|
|
62
|
+
this.updates = this.updates.then(this.refresh).catch(reportWatchError);
|
|
20
63
|
}, 75);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
64
|
+
}
|
|
65
|
+
waitUntilReady(watcher) {
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
let ready = false;
|
|
68
|
+
watcher.once("ready", () => {
|
|
69
|
+
ready = true;
|
|
70
|
+
resolve();
|
|
71
|
+
});
|
|
72
|
+
watcher.on("error", error => {
|
|
73
|
+
if (this.disabled)
|
|
74
|
+
return;
|
|
75
|
+
if (isWatchLimit(error))
|
|
76
|
+
void this.disable(error).then(resolve, reject);
|
|
77
|
+
else if (!ready)
|
|
78
|
+
reject(error);
|
|
79
|
+
else
|
|
80
|
+
reportWatchError(error);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
async disable(error) {
|
|
85
|
+
if (!this.disabled)
|
|
86
|
+
this.warn(`Actor source watcher reached the OS file/watch limit (${error.code}). Automatic reload is disabled; ` +
|
|
87
|
+
"the development server will continue running. Restart it to apply source changes. " +
|
|
88
|
+
"Close other watchers or raise your OS file/watch limit, then restart to restore automatic reload. " +
|
|
89
|
+
"Use --no-watch to disable watching explicitly.");
|
|
90
|
+
await this.close();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function isWatchLimit(error) {
|
|
94
|
+
return error instanceof Error && "code" in error && ["EMFILE", "ENFILE", "ENOSPC"].includes(String(error.code));
|
|
37
95
|
}
|
|
38
96
|
function ignoredActorPath(candidate, options) {
|
|
39
97
|
const relative = path.relative(options.projectDirectory, candidate);
|
|
@@ -55,5 +113,5 @@ function isActorSource(candidate) {
|
|
|
55
113
|
function reportWatchError(error) {
|
|
56
114
|
console.error(`Actor source update failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
57
115
|
}
|
|
58
|
-
export { watchActorSources };
|
|
116
|
+
export { ActorSourceWatcher, watchActorSources };
|
|
59
117
|
//# sourceMappingURL=actor-source-watcher.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"actor-source-watcher.js","sourceRoot":"","sources":["../../src/cli/actor-source-watcher.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"actor-source-watcher.js","sourceRoot":"","sources":["../../src/cli/actor-source-watcher.ts"],"names":[],"mappings":"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"}
|
|
@@ -29,36 +29,14 @@ Open [localhost:3000](http://127.0.0.1:3000), send a message, and reload after t
|
|
|
29
29
|
|
|
30
30
|
## Save the conversation
|
|
31
31
|
|
|
32
|
-
[ChatHistory](src/actors.ts)
|
|
32
|
+
[ChatHistory](src/actors.ts) stores messages in one `@Persisted` array. The [backend](src/backend.ts) streams the model's reply, then saves the user message and completed reply together in one actor call.
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
import type { UIMessage } from "ai"
|
|
36
|
-
import { Actor, Persisted } from "durable-actors"
|
|
37
|
-
|
|
38
|
-
export class ChatHistory extends Actor {
|
|
39
|
-
@Persisted private messages: UIMessage[] = []
|
|
40
|
-
|
|
41
|
-
async load() {
|
|
42
|
-
return this.messages
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
async append(message: UIMessage) {
|
|
46
|
-
this.messages.push(message)
|
|
47
|
-
return this.messages
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
## Stream the reply
|
|
53
|
-
|
|
54
|
-
The [Express backend](src/backend.ts) appends the user message, sends the saved conversation to the model, and streams the reply. It saves the assistant message when the reply completes.
|
|
55
|
-
|
|
56
|
-
The [React client](src/Chat.tsx) loads saved messages and uses `useChat` to display the stream. In-progress streams are not resumed after a reload.
|
|
34
|
+
Failed or interrupted attempts are not saved. The [React client](src/Chat.tsx) shows the error and uses the AI SDK's built-in retry action. Reloading restores only completed exchanges; streams are not resumed.
|
|
57
35
|
|
|
58
36
|
The lobby is shared and has no authentication. Add authentication and chat ownership checks before using it for private conversations.
|
|
59
37
|
|
|
60
38
|
## Development
|
|
61
39
|
|
|
62
|
-
Both processes read `.env`; actor state lives in `.durable-actors/`. Actor code reloads automatically; restart `npm run dev` after editing the actor class imported by the backend. For multiple examples, set distinct `PORT`, `DURABLE_ACTORS_PORT`, and matching control-plane URLs; see [Run the examples together](
|
|
40
|
+
Both processes read `.env`; actor state lives in `.durable-actors/`. Actor code reloads automatically; restart `npm run dev` after editing the actor class imported by the backend. For multiple examples, set distinct `PORT`, `DURABLE_ACTORS_PORT`, and matching control-plane URLs; see [Run the examples together](https://github.com/TerseAI/durable-actors/tree/main/examples#run-the-examples-together).
|
|
63
41
|
|
|
64
42
|
`npm run build` checks TypeScript and builds the frontend.
|
|
@@ -6,7 +6,7 @@ import type { UIMessage } from "ai"
|
|
|
6
6
|
const initialMessages: UIMessage[] = await fetch("/api/chat/lobby").then(response => response.json())
|
|
7
7
|
|
|
8
8
|
function Chat() {
|
|
9
|
-
const { messages, sendMessage, status, error } = useChat({ id: "lobby", messages: initialMessages })
|
|
9
|
+
const { messages, sendMessage, regenerate, status, error } = useChat({ id: "lobby", messages: initialMessages })
|
|
10
10
|
const busy = status === "submitted" || status === "streaming"
|
|
11
11
|
|
|
12
12
|
return (
|
|
@@ -29,7 +29,14 @@ function Chat() {
|
|
|
29
29
|
</label>
|
|
30
30
|
<button disabled={busy}>{busy ? "Thinking…" : "Send"}</button>
|
|
31
31
|
</form>
|
|
32
|
-
{error &&
|
|
32
|
+
{error && (
|
|
33
|
+
<p role="alert">
|
|
34
|
+
{error.message}{" "}
|
|
35
|
+
<button onClick={() => regenerate()} disabled={busy}>
|
|
36
|
+
Retry
|
|
37
|
+
</button>
|
|
38
|
+
</p>
|
|
39
|
+
)}
|
|
33
40
|
</main>
|
|
34
41
|
)
|
|
35
42
|
}
|
|
@@ -19,7 +19,8 @@ app.post("/api/chat", async (request, response) => {
|
|
|
19
19
|
const [message] = await validateUIMessages({ messages: [request.body.messages.at(-1)] })
|
|
20
20
|
if (message.role !== "user") return response.sendStatus(400)
|
|
21
21
|
const chat = ChatHistory.get(request.body.id)
|
|
22
|
-
const messages = await chat.
|
|
22
|
+
const messages = [...(await chat.load()), message]
|
|
23
|
+
let failed = false
|
|
23
24
|
const result = streamText({
|
|
24
25
|
model: openai("gpt-5-mini"),
|
|
25
26
|
messages: await convertToModelMessages(messages)
|
|
@@ -30,8 +31,12 @@ app.post("/api/chat", async (request, response) => {
|
|
|
30
31
|
stream: result.stream,
|
|
31
32
|
originalMessages: messages,
|
|
32
33
|
generateMessageId: generateId,
|
|
34
|
+
onError: () => {
|
|
35
|
+
failed = true
|
|
36
|
+
return "Reply failed. Check the API key and try again."
|
|
37
|
+
},
|
|
33
38
|
onEnd: async ({ responseMessage, outcome }) => {
|
|
34
|
-
if (outcome.status === "completed") await chat.append(responseMessage)
|
|
39
|
+
if (!failed && outcome.status === "completed") await chat.append(message, responseMessage)
|
|
35
40
|
}
|
|
36
41
|
})
|
|
37
42
|
})
|
|
@@ -69,6 +69,6 @@ Everyone joins as a guest. Add authentication and room access checks before issu
|
|
|
69
69
|
|
|
70
70
|
## Development
|
|
71
71
|
|
|
72
|
-
Both processes read `.env`; saved state lives in `.durable-actors/`. Actor code reloads automatically. After changing public actor types, restart `npm run dev` to regenerate the client. For multiple examples, set distinct `PORT`, `DURABLE_ACTORS_PORT`, and matching control-plane URLs; see [Run the examples together](
|
|
72
|
+
Both processes read `.env`; saved state lives in `.durable-actors/`. Actor code reloads automatically. After changing public actor types, restart `npm run dev` to regenerate the client. For multiple examples, set distinct `PORT`, `DURABLE_ACTORS_PORT`, and matching control-plane URLs; see [Run the examples together](https://github.com/TerseAI/durable-actors/tree/main/examples#run-the-examples-together).
|
|
73
73
|
|
|
74
74
|
`npm run build` generates clients, checks TypeScript, and builds the frontend.
|
|
@@ -48,6 +48,6 @@ Editing pauses while disconnected. Reload to reconnect; unsaved edits are not st
|
|
|
48
48
|
|
|
49
49
|
## Development
|
|
50
50
|
|
|
51
|
-
Both processes read `.env`; saved state lives in `.durable-actors/`. Actor code reloads automatically. After changing public actor types, restart `npm run dev` to regenerate the client. For multiple examples, set distinct `PORT`, `DURABLE_ACTORS_PORT`, and matching control-plane URLs; see [Run the examples together](
|
|
51
|
+
Both processes read `.env`; saved state lives in `.durable-actors/`. Actor code reloads automatically. After changing public actor types, restart `npm run dev` to regenerate the client. For multiple examples, set distinct `PORT`, `DURABLE_ACTORS_PORT`, and matching control-plane URLs; see [Run the examples together](https://github.com/TerseAI/durable-actors/tree/main/examples#run-the-examples-together).
|
|
52
52
|
|
|
53
53
|
`npm run build` generates clients, checks TypeScript, and builds the frontend.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "durable-actors",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.3",
|
|
4
4
|
"description": "Provider-neutral actors for sandbox compute",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
"vite": "^8.3.0",
|
|
94
94
|
"ws": "^8.18.3",
|
|
95
95
|
"zod": "^4.3.6",
|
|
96
|
-
"durable-actors-observer": "0.5.
|
|
96
|
+
"durable-actors-observer": "0.5.3"
|
|
97
97
|
},
|
|
98
98
|
"devDependencies": {
|
|
99
99
|
"@types/node": "^22.0.0",
|