agents-vue 0.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 +18 -0
- package/LICENSE +23 -0
- package/README.md +179 -0
- package/dist/index.d.mts +140 -0
- package/dist/index.d.ts +140 -0
- package/dist/index.mjs +692 -0
- package/package.json +82 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
## v0.1.0
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
### π Enhancements
|
|
8
|
+
|
|
9
|
+
- Initial scaffold ([c717193](https://github.com/ymansurozer/agents-vue/commit/c717193))
|
|
10
|
+
|
|
11
|
+
### π Documentation
|
|
12
|
+
|
|
13
|
+
- Write README with usage, API, coverage table ([16461f2](https://github.com/ymansurozer/agents-vue/commit/16461f2))
|
|
14
|
+
|
|
15
|
+
### β€οΈ Contributors
|
|
16
|
+
|
|
17
|
+
- Ymansurozer <ymansurozer@gmail.com>
|
|
18
|
+
|
package/LICENSE
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yusuf Mansur Γzer
|
|
4
|
+
Portions copyright (c) 2025 Cloudflare, Inc. β original React
|
|
5
|
+
implementation in @cloudflare/ai-chat (https://github.com/cloudflare/agents)
|
|
6
|
+
|
|
7
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
8
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
9
|
+
in the Software without restriction, including without limitation the rights
|
|
10
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
11
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
12
|
+
furnished to do so, subject to the following conditions:
|
|
13
|
+
|
|
14
|
+
The above copyright notice and this permission notice shall be included in all
|
|
15
|
+
copies or substantial portions of the Software.
|
|
16
|
+
|
|
17
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
18
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
19
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
20
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
21
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
22
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
23
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# agents-vue
|
|
2
|
+
|
|
3
|
+
Vue 3 composable for [Cloudflare Agents](https://github.com/cloudflare/agents) β a port of `@cloudflare/ai-chat/react`'s `useAgentChat` to the Vue Composition API and `@ai-sdk/vue`.
|
|
4
|
+
|
|
5
|
+
> `agents-vue` is a Vue 3 port of [`@cloudflare/ai-chat`](https://github.com/cloudflare/agents/tree/main/packages/ai-chat)'s `useAgentChat` React hook. The protocol, semantics, and most of the algorithmic structure come directly from the upstream React implementation. This package adapts it to the Vue Composition API and `@ai-sdk/vue`.
|
|
6
|
+
|
|
7
|
+
## Status
|
|
8
|
+
|
|
9
|
+
v0.1 β covers the surface needed for a working chat UI: streaming responses, client-side tools, tool approvals, stream resumption, broadcast handling. Deeper upstream features (the `useAgent` connection hook, initial-message HTTP cache, `isServerStreaming` flag) are on the roadmap.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pnpm add agents-vue
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Peer dependencies (must also be installed):
|
|
18
|
+
|
|
19
|
+
| Peer | Version |
|
|
20
|
+
|---|---|
|
|
21
|
+
| `vue` | `^3.3.4` |
|
|
22
|
+
| `@ai-sdk/vue` | `^3.0.0` |
|
|
23
|
+
| `ai` | `^6.0.0` |
|
|
24
|
+
| `agents` | `>=0.11.0 <1.0.0` |
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
```vue
|
|
29
|
+
<script setup lang="ts">
|
|
30
|
+
import { useAgentChat } from "agents-vue";
|
|
31
|
+
|
|
32
|
+
const { chat, addToolOutput, addToolApprovalResponse, clearHistory, stop } = useAgentChat({
|
|
33
|
+
client: {
|
|
34
|
+
agent: "MyAgent", // Agent class name
|
|
35
|
+
host: "agent.example.com", // your worker host
|
|
36
|
+
name: "session-id", // identifies the Durable Object instance
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<template>
|
|
42
|
+
<div v-for="message in chat.messages" :key="message.id">
|
|
43
|
+
<strong>{{ message.role }}:</strong>
|
|
44
|
+
<template v-for="(part, i) in message.parts" :key="i">
|
|
45
|
+
<span v-if="part.type === 'text'">{{ part.text }}</span>
|
|
46
|
+
</template>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<button @click="chat.sendMessage({ text: 'hi' })">Send</button>
|
|
50
|
+
<button @click="stop()">Stop</button>
|
|
51
|
+
<button @click="clearHistory()">Clear</button>
|
|
52
|
+
</template>
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`chat` is the underlying [`@ai-sdk/vue` `Chat` instance](https://ai-sdk.dev/) β `chat.messages`, `chat.status`, `chat.sendMessage()` are all reactive and standard. The composable layers WebSocket-aware overrides for `addToolOutput`, `addToolApprovalResponse`, `clearHistory`, and `stop`, plus full-history sync, broadcast merging, and stream resumption.
|
|
56
|
+
|
|
57
|
+
## SSR
|
|
58
|
+
|
|
59
|
+
`agents-vue` is **client-only**. The composable opens a WebSocket on construction and will throw in Node / SSR contexts. Wrap call sites in `<ClientOnly>` (Nuxt) or defer with `onMounted`.
|
|
60
|
+
|
|
61
|
+
```vue
|
|
62
|
+
<!-- Nuxt -->
|
|
63
|
+
<ClientOnly>
|
|
64
|
+
<ChatComponent />
|
|
65
|
+
</ClientOnly>
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## API
|
|
69
|
+
|
|
70
|
+
### `useAgentChat<AgentT, ChatMessage, State>(options)`
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
|
|
74
|
+
| Field | Type | Description |
|
|
75
|
+
|---|---|---|
|
|
76
|
+
| `chat` | `Chat<ChatMessage>` | `@ai-sdk/vue` chat instance β reactive `messages`, `status`, `error`, plus `sendMessage`, `regenerate`. |
|
|
77
|
+
| `socket` | `AgentClient<AgentT, State>` | Underlying PartySocket. Typed RPC stub when `AgentT` is provided. |
|
|
78
|
+
| `addToolOutput` | `function` | Submit a tool result. Sends `cf_agent_tool_result` and triggers continuation if enabled. |
|
|
79
|
+
| `addToolApprovalResponse` | `function` | Resolve a human-in-the-loop tool approval. Sends `cf_agent_tool_approval` and triggers continuation. |
|
|
80
|
+
| `clearHistory` | `function` | Clear local state and broadcast `cf_agent_chat_clear` to the server. |
|
|
81
|
+
| `stop` | `function` | Abort the active stream and any pending tool continuation. |
|
|
82
|
+
|
|
83
|
+
### Options
|
|
84
|
+
|
|
85
|
+
| Option | Type | Default | Description |
|
|
86
|
+
|---|---|---|---|
|
|
87
|
+
| `client` | `AgentClientOptions<State>` | β | **Required.** Connection config β `agent`, `host`, `name`, `basePath`. |
|
|
88
|
+
| `initialMessages` | `ChatMessage[]` | `[]` | Seed messages on mount. |
|
|
89
|
+
| `body` | `object \| () => object \| Promise<object>` | β | Extra request body β static or per-request function. |
|
|
90
|
+
| `tools` | `Record<string, AgentClientTool>` | β | Client-side tool definitions. Schemas auto-extracted and sent with each request. |
|
|
91
|
+
| `onToolCall` | `({ toolCall, addToolOutput }) => void \| Promise<void>` | β | Custom tool-call handler. Overrides per-tool `execute`. |
|
|
92
|
+
| `autoContinueAfterToolResult` | `boolean` | `true` | Server resumes the stream after a tool result. |
|
|
93
|
+
| `resume` | `boolean` | `true` | Resume in-flight streams on socket reconnect. |
|
|
94
|
+
| `onOpen` | `(event: Event) => void` | β | WebSocket open. |
|
|
95
|
+
| `onClose` | `(event: CloseEvent) => void` | β | WebSocket close. |
|
|
96
|
+
| `onSocketError` | `(event: Event) => void` | β | Connection-level error. |
|
|
97
|
+
| `onError` | `(error: Error) => void` | β | Chat-turn error from the AI SDK. |
|
|
98
|
+
|
|
99
|
+
### Client-side tools
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
useAgentChat({
|
|
103
|
+
client: { /* ... */ },
|
|
104
|
+
tools: {
|
|
105
|
+
weather: {
|
|
106
|
+
description: "Get the weather for a city",
|
|
107
|
+
parameters: {
|
|
108
|
+
type: "object",
|
|
109
|
+
properties: { city: { type: "string" } },
|
|
110
|
+
required: ["city"],
|
|
111
|
+
},
|
|
112
|
+
execute: async ({ city }) => ({ temp: 70, conditions: "clear" }),
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Tool schemas are auto-extracted from any tool with an `execute` function and sent to the server with each request. The server invokes the tool by name; `execute` runs client-side; the output flows back via `addToolOutput`.
|
|
119
|
+
|
|
120
|
+
### Tool approvals (human-in-the-loop)
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
const { addToolApprovalResponse } = useAgentChat({ /* ... */ });
|
|
124
|
+
|
|
125
|
+
// When the model requests approval for a destructive tool:
|
|
126
|
+
await addToolApprovalResponse({ id: approvalId, approved: true });
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The composable matches the approval id against a `tool-approve` part in `chat.messages` and forwards `cf_agent_tool_approval` to the server. If no match exists it warns to the console and bails β no SDK crash.
|
|
130
|
+
|
|
131
|
+
### `WebSocketChatTransport`
|
|
132
|
+
|
|
133
|
+
Exported for advanced use cases. The composable wires this internally; you only need it directly if you're building a non-`@ai-sdk/vue` flow over the same `cf_agent_*` wire protocol.
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
import { WebSocketChatTransport, type WebSocketLike } from "agents-vue";
|
|
137
|
+
|
|
138
|
+
const transport = new WebSocketChatTransport({
|
|
139
|
+
socket, // any WebSocketLike
|
|
140
|
+
prepareBody, // optional
|
|
141
|
+
});
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Coverage vs upstream
|
|
145
|
+
|
|
146
|
+
`agents-vue@0.1` covers the surface real apps use. Wire protocol is identical to upstream β same `cf_agent_*` message shapes, same resumable streaming, same broadcast transitions, same tool continuation flow.
|
|
147
|
+
|
|
148
|
+
| Feature | Status |
|
|
149
|
+
|---|---|
|
|
150
|
+
| `useAgentChat` composable | β
|
|
|
151
|
+
| `WebSocketChatTransport` | β
|
|
|
152
|
+
| Streaming responses | β
|
|
|
153
|
+
| Client-side tools with `execute` | β
|
|
|
154
|
+
| Tool approvals (HITL) | β
|
|
|
155
|
+
| `body` option (static / sync / async) | β
|
|
|
156
|
+
| Stream resumption (resumable streaming) | β
|
|
|
157
|
+
| Partial-hydration replay rebuild | β
|
|
|
158
|
+
| Broadcast handling (multi-tab) | β
|
|
|
159
|
+
| Tool approval continuation merge across broadcasts | β
|
|
|
160
|
+
| `useAgent` connection hook | π§ roadmap |
|
|
161
|
+
| Initial-message HTTP cache | π§ roadmap |
|
|
162
|
+
| `isServerStreaming` / `isStreaming` flags | π§ roadmap |
|
|
163
|
+
|
|
164
|
+
## Contributing
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
pnpm install
|
|
168
|
+
pnpm exec playwright install chromium
|
|
169
|
+
pnpm test # 59 tests in chromium (vitest-browser-vue)
|
|
170
|
+
pnpm typecheck
|
|
171
|
+
pnpm lint
|
|
172
|
+
pnpm build
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Releases are driven by [`changelogen`](https://github.com/unjs/changelogen) with conventional commits.
|
|
176
|
+
|
|
177
|
+
## License
|
|
178
|
+
|
|
179
|
+
MIT β see [LICENSE](./LICENSE). Includes attribution to the original Cloudflare React implementation in [`@cloudflare/ai-chat`](https://github.com/cloudflare/agents/tree/main/packages/ai-chat).
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import * as ai from 'ai';
|
|
2
|
+
import { UIMessage, Tool, JSONSchema7, ChatTransport, ChatRequestOptions, UIMessageChunk } from 'ai';
|
|
3
|
+
import { Chat } from '@ai-sdk/vue';
|
|
4
|
+
import { AgentClientOptions, AgentClient } from 'agents/client';
|
|
5
|
+
|
|
6
|
+
interface AgentClientTool<Input = unknown, Output = unknown> {
|
|
7
|
+
description?: Tool["description"];
|
|
8
|
+
parameters?: JSONSchema7;
|
|
9
|
+
inputSchema?: JSONSchema7;
|
|
10
|
+
execute?: (input: Input) => Output | Promise<Output>;
|
|
11
|
+
}
|
|
12
|
+
interface AgentToolOutput {
|
|
13
|
+
toolCallId: string;
|
|
14
|
+
output?: unknown;
|
|
15
|
+
state?: "output-available" | "output-error";
|
|
16
|
+
errorText?: string;
|
|
17
|
+
}
|
|
18
|
+
interface AgentToolCall {
|
|
19
|
+
toolCallId: string;
|
|
20
|
+
toolName: string;
|
|
21
|
+
input: unknown;
|
|
22
|
+
}
|
|
23
|
+
type StateOf<AgentT> = AgentT extends {
|
|
24
|
+
readonly state: infer S;
|
|
25
|
+
} ? S : AgentT;
|
|
26
|
+
interface UseAgentChatOptions<AgentT = unknown, ChatMessage extends UIMessage = UIMessage, State = StateOf<AgentT>> {
|
|
27
|
+
/** AgentClient connection config β agent name, host, basePath, etc. */
|
|
28
|
+
client: AgentClientOptions<State>;
|
|
29
|
+
initialMessages?: ChatMessage[];
|
|
30
|
+
/** Extra body to include with every request β static object or (sync/async) function. */
|
|
31
|
+
body?: Record<string, unknown> | (() => Record<string, unknown> | Promise<Record<string, unknown>>);
|
|
32
|
+
tools?: Record<string, AgentClientTool>;
|
|
33
|
+
onToolCall?: (options: {
|
|
34
|
+
toolCall: AgentToolCall;
|
|
35
|
+
addToolOutput: (toolOutput: AgentToolOutput) => Promise<void>;
|
|
36
|
+
}) => void | Promise<void>;
|
|
37
|
+
autoContinueAfterToolResult?: boolean;
|
|
38
|
+
/** When true (default), the chat resumes any in-flight stream on socket open. */
|
|
39
|
+
resume?: boolean;
|
|
40
|
+
/** Called when the underlying WebSocket opens (initial connect or reconnect). */
|
|
41
|
+
onOpen?: (event: Event) => void;
|
|
42
|
+
/** Called when the underlying WebSocket closes. */
|
|
43
|
+
onClose?: (event: CloseEvent) => void;
|
|
44
|
+
/** Called for connection-level errors. Distinct from `onError`, which fires for chat turn errors. */
|
|
45
|
+
onSocketError?: (event: Event) => void;
|
|
46
|
+
/** Called for chat-turn errors (AI SDK). */
|
|
47
|
+
onError?: (error: Error) => void;
|
|
48
|
+
}
|
|
49
|
+
declare function useAgentChat<AgentT = unknown, ChatMessage extends UIMessage = UIMessage, State = StateOf<AgentT>>(options: UseAgentChatOptions<AgentT, ChatMessage, State>): {
|
|
50
|
+
addToolApprovalResponse: (args_0: {
|
|
51
|
+
id: string;
|
|
52
|
+
approved: boolean;
|
|
53
|
+
reason?: string;
|
|
54
|
+
options?: ai.ChatRequestOptions;
|
|
55
|
+
}) => Promise<void>;
|
|
56
|
+
addToolOutput: (args_0: {
|
|
57
|
+
tool: keyof (ChatMessage extends UIMessage<unknown, ai.UIDataTypes, infer TOOLS extends ai.UITools> ? TOOLS : ai.UITools);
|
|
58
|
+
toolCallId: string;
|
|
59
|
+
options?: ai.ChatRequestOptions;
|
|
60
|
+
} & ({
|
|
61
|
+
state: "output-error";
|
|
62
|
+
output?: never;
|
|
63
|
+
errorText: string;
|
|
64
|
+
} | {
|
|
65
|
+
state?: "output-available";
|
|
66
|
+
output: (ChatMessage extends UIMessage<unknown, ai.UIDataTypes, infer TOOLS extends ai.UITools> ? TOOLS : ai.UITools)[keyof (ChatMessage extends UIMessage<unknown, ai.UIDataTypes, infer TOOLS extends ai.UITools> ? TOOLS : ai.UITools)]["output"];
|
|
67
|
+
errorText?: never;
|
|
68
|
+
})) => Promise<void>;
|
|
69
|
+
clearHistory: () => void;
|
|
70
|
+
chat: Chat<ChatMessage>;
|
|
71
|
+
socket: AgentClient<AgentT, State>;
|
|
72
|
+
stop: () => Promise<void>;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** Minimal interface a WebSocket must satisfy for the transport. */
|
|
76
|
+
interface WebSocketLike {
|
|
77
|
+
send(data: string): void;
|
|
78
|
+
addEventListener(type: string, listener: (event: MessageEvent) => void, options?: AddEventListenerOptions): void;
|
|
79
|
+
}
|
|
80
|
+
interface StreamResumingData {
|
|
81
|
+
type: string;
|
|
82
|
+
id: string;
|
|
83
|
+
}
|
|
84
|
+
interface WebSocketChatTransportOptions {
|
|
85
|
+
socket: WebSocketLike;
|
|
86
|
+
activeRequestIds?: Set<string>;
|
|
87
|
+
prepareBody?: (options: {
|
|
88
|
+
messages: UIMessage[];
|
|
89
|
+
trigger: "submit-message" | "regenerate-message";
|
|
90
|
+
messageId?: string;
|
|
91
|
+
}) => Record<string, unknown> | Promise<Record<string, unknown>>;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* ChatTransport that communicates with a Cloudflare Agent over WebSocket.
|
|
95
|
+
* Mirrors the protocol implemented by `@cloudflare/ai-chat`'s
|
|
96
|
+
* `WebSocketChatTransport`: same `cf_agent_*` message shapes, same resumable
|
|
97
|
+
* streaming handshake, same tool continuation flow. Framework-agnostic β the
|
|
98
|
+
* Vue composable consumes this; another framework binding could too.
|
|
99
|
+
*/
|
|
100
|
+
declare class WebSocketChatTransport implements ChatTransport<UIMessage> {
|
|
101
|
+
private socket;
|
|
102
|
+
private prepareBody?;
|
|
103
|
+
private activeRequestIds;
|
|
104
|
+
_resumeResolver: ((data: StreamResumingData) => void) | null;
|
|
105
|
+
_resumeNoneResolver: (() => void) | null;
|
|
106
|
+
private _expectToolContinuation;
|
|
107
|
+
private _abortToolContinuation;
|
|
108
|
+
constructor(options: WebSocketChatTransportOptions);
|
|
109
|
+
/** Mark that the next reconnectToStream() should attach to a server-initiated tool continuation. */
|
|
110
|
+
expectToolContinuation(): void;
|
|
111
|
+
/** Abort the active client-side tool continuation stream. */
|
|
112
|
+
abortActiveToolContinuation(): boolean;
|
|
113
|
+
/** True when the transport is waiting for a resume handshake. */
|
|
114
|
+
isAwaitingResume(): boolean;
|
|
115
|
+
/** Called by the composable when it receives cf_agent_stream_resuming. */
|
|
116
|
+
handleStreamResuming(data: StreamResumingData): boolean;
|
|
117
|
+
/** Called by the composable when it receives cf_agent_stream_resume_none. */
|
|
118
|
+
handleStreamResumeNone(): boolean;
|
|
119
|
+
sendMessages(options: {
|
|
120
|
+
trigger: "submit-message" | "regenerate-message";
|
|
121
|
+
chatId: string;
|
|
122
|
+
messageId: string | undefined;
|
|
123
|
+
messages: UIMessage[];
|
|
124
|
+
abortSignal: AbortSignal | undefined;
|
|
125
|
+
} & ChatRequestOptions): Promise<ReadableStream<UIMessageChunk>>;
|
|
126
|
+
reconnectToStream(_options: {
|
|
127
|
+
chatId: string;
|
|
128
|
+
} & ChatRequestOptions): Promise<ReadableStream<UIMessageChunk> | null>;
|
|
129
|
+
/**
|
|
130
|
+
* Creates a deferred ReadableStream for client-side tool continuations.
|
|
131
|
+
* Returned immediately so AI SDK status becomes "submitted" right after tool output,
|
|
132
|
+
* then waits for the server to announce continuation via STREAM_RESUMING.
|
|
133
|
+
*/
|
|
134
|
+
private _createToolContinuationStream;
|
|
135
|
+
/** Creates a ReadableStream that receives resumed stream chunks. */
|
|
136
|
+
private _createResumeStream;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export { WebSocketChatTransport, useAgentChat };
|
|
140
|
+
export type { UseAgentChatOptions, WebSocketChatTransportOptions, WebSocketLike };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import * as ai from 'ai';
|
|
2
|
+
import { UIMessage, Tool, JSONSchema7, ChatTransport, ChatRequestOptions, UIMessageChunk } from 'ai';
|
|
3
|
+
import { Chat } from '@ai-sdk/vue';
|
|
4
|
+
import { AgentClientOptions, AgentClient } from 'agents/client';
|
|
5
|
+
|
|
6
|
+
interface AgentClientTool<Input = unknown, Output = unknown> {
|
|
7
|
+
description?: Tool["description"];
|
|
8
|
+
parameters?: JSONSchema7;
|
|
9
|
+
inputSchema?: JSONSchema7;
|
|
10
|
+
execute?: (input: Input) => Output | Promise<Output>;
|
|
11
|
+
}
|
|
12
|
+
interface AgentToolOutput {
|
|
13
|
+
toolCallId: string;
|
|
14
|
+
output?: unknown;
|
|
15
|
+
state?: "output-available" | "output-error";
|
|
16
|
+
errorText?: string;
|
|
17
|
+
}
|
|
18
|
+
interface AgentToolCall {
|
|
19
|
+
toolCallId: string;
|
|
20
|
+
toolName: string;
|
|
21
|
+
input: unknown;
|
|
22
|
+
}
|
|
23
|
+
type StateOf<AgentT> = AgentT extends {
|
|
24
|
+
readonly state: infer S;
|
|
25
|
+
} ? S : AgentT;
|
|
26
|
+
interface UseAgentChatOptions<AgentT = unknown, ChatMessage extends UIMessage = UIMessage, State = StateOf<AgentT>> {
|
|
27
|
+
/** AgentClient connection config β agent name, host, basePath, etc. */
|
|
28
|
+
client: AgentClientOptions<State>;
|
|
29
|
+
initialMessages?: ChatMessage[];
|
|
30
|
+
/** Extra body to include with every request β static object or (sync/async) function. */
|
|
31
|
+
body?: Record<string, unknown> | (() => Record<string, unknown> | Promise<Record<string, unknown>>);
|
|
32
|
+
tools?: Record<string, AgentClientTool>;
|
|
33
|
+
onToolCall?: (options: {
|
|
34
|
+
toolCall: AgentToolCall;
|
|
35
|
+
addToolOutput: (toolOutput: AgentToolOutput) => Promise<void>;
|
|
36
|
+
}) => void | Promise<void>;
|
|
37
|
+
autoContinueAfterToolResult?: boolean;
|
|
38
|
+
/** When true (default), the chat resumes any in-flight stream on socket open. */
|
|
39
|
+
resume?: boolean;
|
|
40
|
+
/** Called when the underlying WebSocket opens (initial connect or reconnect). */
|
|
41
|
+
onOpen?: (event: Event) => void;
|
|
42
|
+
/** Called when the underlying WebSocket closes. */
|
|
43
|
+
onClose?: (event: CloseEvent) => void;
|
|
44
|
+
/** Called for connection-level errors. Distinct from `onError`, which fires for chat turn errors. */
|
|
45
|
+
onSocketError?: (event: Event) => void;
|
|
46
|
+
/** Called for chat-turn errors (AI SDK). */
|
|
47
|
+
onError?: (error: Error) => void;
|
|
48
|
+
}
|
|
49
|
+
declare function useAgentChat<AgentT = unknown, ChatMessage extends UIMessage = UIMessage, State = StateOf<AgentT>>(options: UseAgentChatOptions<AgentT, ChatMessage, State>): {
|
|
50
|
+
addToolApprovalResponse: (args_0: {
|
|
51
|
+
id: string;
|
|
52
|
+
approved: boolean;
|
|
53
|
+
reason?: string;
|
|
54
|
+
options?: ai.ChatRequestOptions;
|
|
55
|
+
}) => Promise<void>;
|
|
56
|
+
addToolOutput: (args_0: {
|
|
57
|
+
tool: keyof (ChatMessage extends UIMessage<unknown, ai.UIDataTypes, infer TOOLS extends ai.UITools> ? TOOLS : ai.UITools);
|
|
58
|
+
toolCallId: string;
|
|
59
|
+
options?: ai.ChatRequestOptions;
|
|
60
|
+
} & ({
|
|
61
|
+
state: "output-error";
|
|
62
|
+
output?: never;
|
|
63
|
+
errorText: string;
|
|
64
|
+
} | {
|
|
65
|
+
state?: "output-available";
|
|
66
|
+
output: (ChatMessage extends UIMessage<unknown, ai.UIDataTypes, infer TOOLS extends ai.UITools> ? TOOLS : ai.UITools)[keyof (ChatMessage extends UIMessage<unknown, ai.UIDataTypes, infer TOOLS extends ai.UITools> ? TOOLS : ai.UITools)]["output"];
|
|
67
|
+
errorText?: never;
|
|
68
|
+
})) => Promise<void>;
|
|
69
|
+
clearHistory: () => void;
|
|
70
|
+
chat: Chat<ChatMessage>;
|
|
71
|
+
socket: AgentClient<AgentT, State>;
|
|
72
|
+
stop: () => Promise<void>;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** Minimal interface a WebSocket must satisfy for the transport. */
|
|
76
|
+
interface WebSocketLike {
|
|
77
|
+
send(data: string): void;
|
|
78
|
+
addEventListener(type: string, listener: (event: MessageEvent) => void, options?: AddEventListenerOptions): void;
|
|
79
|
+
}
|
|
80
|
+
interface StreamResumingData {
|
|
81
|
+
type: string;
|
|
82
|
+
id: string;
|
|
83
|
+
}
|
|
84
|
+
interface WebSocketChatTransportOptions {
|
|
85
|
+
socket: WebSocketLike;
|
|
86
|
+
activeRequestIds?: Set<string>;
|
|
87
|
+
prepareBody?: (options: {
|
|
88
|
+
messages: UIMessage[];
|
|
89
|
+
trigger: "submit-message" | "regenerate-message";
|
|
90
|
+
messageId?: string;
|
|
91
|
+
}) => Record<string, unknown> | Promise<Record<string, unknown>>;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* ChatTransport that communicates with a Cloudflare Agent over WebSocket.
|
|
95
|
+
* Mirrors the protocol implemented by `@cloudflare/ai-chat`'s
|
|
96
|
+
* `WebSocketChatTransport`: same `cf_agent_*` message shapes, same resumable
|
|
97
|
+
* streaming handshake, same tool continuation flow. Framework-agnostic β the
|
|
98
|
+
* Vue composable consumes this; another framework binding could too.
|
|
99
|
+
*/
|
|
100
|
+
declare class WebSocketChatTransport implements ChatTransport<UIMessage> {
|
|
101
|
+
private socket;
|
|
102
|
+
private prepareBody?;
|
|
103
|
+
private activeRequestIds;
|
|
104
|
+
_resumeResolver: ((data: StreamResumingData) => void) | null;
|
|
105
|
+
_resumeNoneResolver: (() => void) | null;
|
|
106
|
+
private _expectToolContinuation;
|
|
107
|
+
private _abortToolContinuation;
|
|
108
|
+
constructor(options: WebSocketChatTransportOptions);
|
|
109
|
+
/** Mark that the next reconnectToStream() should attach to a server-initiated tool continuation. */
|
|
110
|
+
expectToolContinuation(): void;
|
|
111
|
+
/** Abort the active client-side tool continuation stream. */
|
|
112
|
+
abortActiveToolContinuation(): boolean;
|
|
113
|
+
/** True when the transport is waiting for a resume handshake. */
|
|
114
|
+
isAwaitingResume(): boolean;
|
|
115
|
+
/** Called by the composable when it receives cf_agent_stream_resuming. */
|
|
116
|
+
handleStreamResuming(data: StreamResumingData): boolean;
|
|
117
|
+
/** Called by the composable when it receives cf_agent_stream_resume_none. */
|
|
118
|
+
handleStreamResumeNone(): boolean;
|
|
119
|
+
sendMessages(options: {
|
|
120
|
+
trigger: "submit-message" | "regenerate-message";
|
|
121
|
+
chatId: string;
|
|
122
|
+
messageId: string | undefined;
|
|
123
|
+
messages: UIMessage[];
|
|
124
|
+
abortSignal: AbortSignal | undefined;
|
|
125
|
+
} & ChatRequestOptions): Promise<ReadableStream<UIMessageChunk>>;
|
|
126
|
+
reconnectToStream(_options: {
|
|
127
|
+
chatId: string;
|
|
128
|
+
} & ChatRequestOptions): Promise<ReadableStream<UIMessageChunk> | null>;
|
|
129
|
+
/**
|
|
130
|
+
* Creates a deferred ReadableStream for client-side tool continuations.
|
|
131
|
+
* Returned immediately so AI SDK status becomes "submitted" right after tool output,
|
|
132
|
+
* then waits for the server to announce continuation via STREAM_RESUMING.
|
|
133
|
+
*/
|
|
134
|
+
private _createToolContinuationStream;
|
|
135
|
+
/** Creates a ReadableStream that receives resumed stream chunks. */
|
|
136
|
+
private _createResumeStream;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export { WebSocketChatTransport, useAgentChat };
|
|
140
|
+
export type { UseAgentChatOptions, WebSocketChatTransportOptions, WebSocketLike };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
import { Chat } from '@ai-sdk/vue';
|
|
2
|
+
import { broadcastTransition } from 'agents/chat';
|
|
3
|
+
import { AgentClient } from 'agents/client';
|
|
4
|
+
import { onScopeDispose } from 'vue';
|
|
5
|
+
|
|
6
|
+
function randomRequestId() {
|
|
7
|
+
return Math.random().toString(36).slice(2, 10);
|
|
8
|
+
}
|
|
9
|
+
class WebSocketChatTransport {
|
|
10
|
+
socket;
|
|
11
|
+
prepareBody;
|
|
12
|
+
activeRequestIds;
|
|
13
|
+
// Resume handshake state (public so the composable message handler can resolve them)
|
|
14
|
+
_resumeResolver = null;
|
|
15
|
+
_resumeNoneResolver = null;
|
|
16
|
+
// Tool continuation state
|
|
17
|
+
_expectToolContinuation = false;
|
|
18
|
+
_abortToolContinuation = null;
|
|
19
|
+
constructor(options) {
|
|
20
|
+
this.socket = options.socket;
|
|
21
|
+
this.prepareBody = options.prepareBody;
|
|
22
|
+
this.activeRequestIds = options.activeRequestIds ?? /* @__PURE__ */ new Set();
|
|
23
|
+
}
|
|
24
|
+
/** Mark that the next reconnectToStream() should attach to a server-initiated tool continuation. */
|
|
25
|
+
expectToolContinuation() {
|
|
26
|
+
this._expectToolContinuation = true;
|
|
27
|
+
}
|
|
28
|
+
/** Abort the active client-side tool continuation stream. */
|
|
29
|
+
abortActiveToolContinuation() {
|
|
30
|
+
return this._abortToolContinuation?.() ?? false;
|
|
31
|
+
}
|
|
32
|
+
/** True when the transport is waiting for a resume handshake. */
|
|
33
|
+
isAwaitingResume() {
|
|
34
|
+
return this._resumeResolver !== null || this._resumeNoneResolver !== null;
|
|
35
|
+
}
|
|
36
|
+
/** Called by the composable when it receives cf_agent_stream_resuming. */
|
|
37
|
+
handleStreamResuming(data) {
|
|
38
|
+
if (!this._resumeResolver) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
this._resumeResolver(data);
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
/** Called by the composable when it receives cf_agent_stream_resume_none. */
|
|
45
|
+
handleStreamResumeNone() {
|
|
46
|
+
if (!this._resumeNoneResolver) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
this._resumeNoneResolver();
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
async sendMessages(options) {
|
|
53
|
+
const requestId = randomRequestId();
|
|
54
|
+
const abortController = new AbortController();
|
|
55
|
+
let completed = false;
|
|
56
|
+
let extraBody = {};
|
|
57
|
+
if (this.prepareBody) {
|
|
58
|
+
extraBody = await this.prepareBody({
|
|
59
|
+
messages: options.messages,
|
|
60
|
+
trigger: options.trigger,
|
|
61
|
+
messageId: options.messageId
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
if (options.body) {
|
|
65
|
+
extraBody = { ...extraBody, ...options.body };
|
|
66
|
+
}
|
|
67
|
+
const tail = options.messages.at(-1);
|
|
68
|
+
const bodyPayload = JSON.stringify({
|
|
69
|
+
messages: tail ? [tail] : [],
|
|
70
|
+
trigger: options.trigger,
|
|
71
|
+
...extraBody
|
|
72
|
+
});
|
|
73
|
+
this.activeRequestIds.add(requestId);
|
|
74
|
+
const socket = this.socket;
|
|
75
|
+
const activeIds = this.activeRequestIds;
|
|
76
|
+
const finish = (action, keepId = false) => {
|
|
77
|
+
if (completed) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
completed = true;
|
|
81
|
+
try {
|
|
82
|
+
action();
|
|
83
|
+
} catch {
|
|
84
|
+
}
|
|
85
|
+
if (!keepId) {
|
|
86
|
+
activeIds.delete(requestId);
|
|
87
|
+
}
|
|
88
|
+
abortController.abort();
|
|
89
|
+
};
|
|
90
|
+
const abortError = new Error("Aborted");
|
|
91
|
+
abortError.name = "AbortError";
|
|
92
|
+
let streamController;
|
|
93
|
+
const onAbort = () => {
|
|
94
|
+
if (completed) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
socket.send(JSON.stringify({ id: requestId, type: "cf_agent_chat_request_cancel" }));
|
|
99
|
+
} catch {
|
|
100
|
+
}
|
|
101
|
+
finish(() => streamController.error(abortError), true);
|
|
102
|
+
};
|
|
103
|
+
const stream = new ReadableStream({
|
|
104
|
+
start(controller) {
|
|
105
|
+
streamController = controller;
|
|
106
|
+
const onMessage = (event) => {
|
|
107
|
+
try {
|
|
108
|
+
const data = JSON.parse(String(event.data));
|
|
109
|
+
if (data.type !== "cf_agent_use_chat_response") {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (data.id !== requestId) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (data.error) {
|
|
116
|
+
finish(() => controller.error(new Error(data.body || "Stream error")));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (data.body?.trim()) {
|
|
120
|
+
try {
|
|
121
|
+
const chunk = JSON.parse(data.body);
|
|
122
|
+
controller.enqueue(chunk);
|
|
123
|
+
} catch {
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (data.done) {
|
|
127
|
+
finish(() => controller.close());
|
|
128
|
+
}
|
|
129
|
+
} catch {
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
socket.addEventListener("message", onMessage, { signal: abortController.signal });
|
|
133
|
+
},
|
|
134
|
+
cancel() {
|
|
135
|
+
onAbort();
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
if (options.abortSignal) {
|
|
139
|
+
options.abortSignal.addEventListener("abort", onAbort, { once: true });
|
|
140
|
+
if (options.abortSignal.aborted) {
|
|
141
|
+
onAbort();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
socket.send(
|
|
145
|
+
JSON.stringify({
|
|
146
|
+
id: requestId,
|
|
147
|
+
init: { method: "POST", body: bodyPayload },
|
|
148
|
+
type: "cf_agent_use_chat_request"
|
|
149
|
+
})
|
|
150
|
+
);
|
|
151
|
+
return stream;
|
|
152
|
+
}
|
|
153
|
+
async reconnectToStream(_options) {
|
|
154
|
+
if (this._expectToolContinuation) {
|
|
155
|
+
this._expectToolContinuation = false;
|
|
156
|
+
return this._createToolContinuationStream();
|
|
157
|
+
}
|
|
158
|
+
const activeIds = this.activeRequestIds;
|
|
159
|
+
return new Promise((resolve) => {
|
|
160
|
+
let resolved = false;
|
|
161
|
+
const done = (value) => {
|
|
162
|
+
if (resolved) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
resolved = true;
|
|
166
|
+
this._resumeResolver = null;
|
|
167
|
+
this._resumeNoneResolver = null;
|
|
168
|
+
clearTimeout(timeout);
|
|
169
|
+
resolve(value);
|
|
170
|
+
};
|
|
171
|
+
this._resumeNoneResolver = () => done(null);
|
|
172
|
+
this._resumeResolver = (data) => {
|
|
173
|
+
const id = data.id;
|
|
174
|
+
activeIds.add(id);
|
|
175
|
+
this.socket.send(JSON.stringify({ type: "cf_agent_stream_resume_ack", id }));
|
|
176
|
+
done(this._createResumeStream(id));
|
|
177
|
+
};
|
|
178
|
+
try {
|
|
179
|
+
this.socket.send(JSON.stringify({ type: "cf_agent_stream_resume_request" }));
|
|
180
|
+
} catch {
|
|
181
|
+
}
|
|
182
|
+
const timeout = setTimeout(() => done(null), 5e3);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Creates a deferred ReadableStream for client-side tool continuations.
|
|
187
|
+
* Returned immediately so AI SDK status becomes "submitted" right after tool output,
|
|
188
|
+
* then waits for the server to announce continuation via STREAM_RESUMING.
|
|
189
|
+
*/
|
|
190
|
+
_createToolContinuationStream() {
|
|
191
|
+
const socket = this.socket;
|
|
192
|
+
const activeIds = this.activeRequestIds;
|
|
193
|
+
const streamAbort = new AbortController();
|
|
194
|
+
const abortError = new Error("Aborted");
|
|
195
|
+
abortError.name = "AbortError";
|
|
196
|
+
let completed = false;
|
|
197
|
+
let requestId = null;
|
|
198
|
+
let readerController;
|
|
199
|
+
let onResumeRef = null;
|
|
200
|
+
let onResumeNoneRef = null;
|
|
201
|
+
const clearHandshakeResolvers = (resumeResolver, resumeNoneResolver) => {
|
|
202
|
+
if (resumeResolver === void 0 && resumeNoneResolver === void 0) {
|
|
203
|
+
this._resumeResolver = null;
|
|
204
|
+
this._resumeNoneResolver = null;
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (resumeResolver && this._resumeResolver === resumeResolver) {
|
|
208
|
+
this._resumeResolver = null;
|
|
209
|
+
}
|
|
210
|
+
if (resumeNoneResolver && this._resumeNoneResolver === resumeNoneResolver) {
|
|
211
|
+
this._resumeNoneResolver = null;
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
const finish = (action, resumeResolver, resumeNoneResolver, keepRequestId = false) => {
|
|
215
|
+
if (completed) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
completed = true;
|
|
219
|
+
this._abortToolContinuation = null;
|
|
220
|
+
clearHandshakeResolvers(resumeResolver, resumeNoneResolver);
|
|
221
|
+
try {
|
|
222
|
+
action();
|
|
223
|
+
} catch {
|
|
224
|
+
}
|
|
225
|
+
if (requestId && !keepRequestId) {
|
|
226
|
+
activeIds.delete(requestId);
|
|
227
|
+
}
|
|
228
|
+
streamAbort.abort();
|
|
229
|
+
};
|
|
230
|
+
this._abortToolContinuation = () => {
|
|
231
|
+
if (completed) {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
if (requestId === null) {
|
|
235
|
+
finish(() => readerController.error(abortError), onResumeRef, onResumeNoneRef);
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
socket.send(JSON.stringify({ type: "cf_agent_chat_request_cancel", id: requestId }));
|
|
240
|
+
} catch {
|
|
241
|
+
}
|
|
242
|
+
finish(() => readerController.error(abortError), onResumeRef, onResumeNoneRef, true);
|
|
243
|
+
return true;
|
|
244
|
+
};
|
|
245
|
+
return new ReadableStream({
|
|
246
|
+
start: (controller) => {
|
|
247
|
+
readerController = controller;
|
|
248
|
+
const onResumeNone = () => {
|
|
249
|
+
clearTimeout(timeout);
|
|
250
|
+
finish(() => controller.close(), onResume, onResumeNone);
|
|
251
|
+
};
|
|
252
|
+
const onResume = (data) => {
|
|
253
|
+
if (requestId) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
requestId = data.id;
|
|
257
|
+
activeIds.add(requestId);
|
|
258
|
+
clearHandshakeResolvers(onResume, onResumeNone);
|
|
259
|
+
clearTimeout(timeout);
|
|
260
|
+
socket.send(JSON.stringify({ type: "cf_agent_stream_resume_ack", id: requestId }));
|
|
261
|
+
};
|
|
262
|
+
onResumeRef = onResume;
|
|
263
|
+
onResumeNoneRef = onResumeNone;
|
|
264
|
+
const timeout = setTimeout(() => finish(() => controller.close(), onResume, onResumeNone), 5e3);
|
|
265
|
+
this._resumeResolver = onResume;
|
|
266
|
+
this._resumeNoneResolver = onResumeNone;
|
|
267
|
+
const onMessage = (event) => {
|
|
268
|
+
try {
|
|
269
|
+
const data = JSON.parse(String(event.data));
|
|
270
|
+
if (data.type !== "cf_agent_use_chat_response" || requestId == null || data.id !== requestId) {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (data.error) {
|
|
274
|
+
finish(() => controller.error(new Error(data.body || "Stream error")), onResume, onResumeNone);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (data.body?.trim()) {
|
|
278
|
+
try {
|
|
279
|
+
const chunk = JSON.parse(data.body);
|
|
280
|
+
controller.enqueue(chunk);
|
|
281
|
+
} catch {
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (data.done) {
|
|
285
|
+
finish(() => controller.close(), onResume, onResumeNone);
|
|
286
|
+
}
|
|
287
|
+
} catch {
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
socket.addEventListener("message", onMessage, { signal: streamAbort.signal });
|
|
291
|
+
try {
|
|
292
|
+
socket.send(JSON.stringify({ type: "cf_agent_stream_resume_request" }));
|
|
293
|
+
} catch {
|
|
294
|
+
finish(() => controller.close());
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
cancel() {
|
|
298
|
+
finish(() => {
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
/** Creates a ReadableStream that receives resumed stream chunks. */
|
|
304
|
+
_createResumeStream(requestId) {
|
|
305
|
+
const socket = this.socket;
|
|
306
|
+
const activeIds = this.activeRequestIds;
|
|
307
|
+
const chunkAbort = new AbortController();
|
|
308
|
+
let completed = false;
|
|
309
|
+
const finish = (action) => {
|
|
310
|
+
if (completed) {
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
completed = true;
|
|
314
|
+
try {
|
|
315
|
+
action();
|
|
316
|
+
} catch {
|
|
317
|
+
}
|
|
318
|
+
activeIds.delete(requestId);
|
|
319
|
+
chunkAbort.abort();
|
|
320
|
+
};
|
|
321
|
+
return new ReadableStream({
|
|
322
|
+
start(controller) {
|
|
323
|
+
const onMessage = (event) => {
|
|
324
|
+
try {
|
|
325
|
+
const data = JSON.parse(String(event.data));
|
|
326
|
+
if (data.type !== "cf_agent_use_chat_response") {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (data.id !== requestId) {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (data.error) {
|
|
333
|
+
finish(() => controller.error(new Error(data.body || "Stream error")));
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (data.body?.trim()) {
|
|
337
|
+
try {
|
|
338
|
+
const chunk = JSON.parse(data.body);
|
|
339
|
+
controller.enqueue(chunk);
|
|
340
|
+
} catch {
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (data.done) {
|
|
344
|
+
finish(() => controller.close());
|
|
345
|
+
}
|
|
346
|
+
} catch {
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
socket.addEventListener("message", onMessage, { signal: chunkAbort.signal });
|
|
350
|
+
},
|
|
351
|
+
cancel() {
|
|
352
|
+
finish(() => {
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function isRecord(value) {
|
|
360
|
+
return typeof value === "object" && value !== null;
|
|
361
|
+
}
|
|
362
|
+
function parseProtocolMessage(data) {
|
|
363
|
+
const parsed = JSON.parse(data);
|
|
364
|
+
if (!isRecord(parsed) || typeof parsed.type !== "string") {
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
const message = isUiMessage(parsed.message) ? parsed.message : void 0;
|
|
368
|
+
const messages = Array.isArray(parsed.messages) && parsed.messages.every(isUiMessage) ? parsed.messages : void 0;
|
|
369
|
+
return {
|
|
370
|
+
type: parsed.type,
|
|
371
|
+
id: typeof parsed.id === "string" ? parsed.id : void 0,
|
|
372
|
+
body: typeof parsed.body === "string" ? parsed.body : void 0,
|
|
373
|
+
done: typeof parsed.done === "boolean" ? parsed.done : void 0,
|
|
374
|
+
error: typeof parsed.error === "boolean" ? parsed.error : void 0,
|
|
375
|
+
replay: typeof parsed.replay === "boolean" ? parsed.replay : void 0,
|
|
376
|
+
replayComplete: typeof parsed.replayComplete === "boolean" ? parsed.replayComplete : void 0,
|
|
377
|
+
continuation: typeof parsed.continuation === "boolean" ? parsed.continuation : void 0,
|
|
378
|
+
message,
|
|
379
|
+
messages
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
function isUiMessage(value) {
|
|
383
|
+
return isRecord(value) && typeof value.id === "string" && typeof value.role === "string" && Array.isArray(value.parts);
|
|
384
|
+
}
|
|
385
|
+
function getToolCallIds(message) {
|
|
386
|
+
const ids = /* @__PURE__ */ new Set();
|
|
387
|
+
for (const part of message.parts) {
|
|
388
|
+
if ("toolCallId" in part && typeof part.toolCallId === "string") {
|
|
389
|
+
ids.add(part.toolCallId);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return ids;
|
|
393
|
+
}
|
|
394
|
+
function replaceUpdatedMessage(messages, updatedMessage) {
|
|
395
|
+
const idIndex = messages.findIndex((message) => message.id === updatedMessage.id);
|
|
396
|
+
if (idIndex >= 0) {
|
|
397
|
+
return messages.map((message) => message.id === updatedMessage.id ? updatedMessage : message);
|
|
398
|
+
}
|
|
399
|
+
const updatedToolCallIds = getToolCallIds(updatedMessage);
|
|
400
|
+
if (updatedToolCallIds.size === 0) {
|
|
401
|
+
return messages;
|
|
402
|
+
}
|
|
403
|
+
const toolIndex = messages.findIndex(
|
|
404
|
+
(message) => message.parts.some(
|
|
405
|
+
(part) => "toolCallId" in part && typeof part.toolCallId === "string" && updatedToolCallIds.has(part.toolCallId)
|
|
406
|
+
)
|
|
407
|
+
);
|
|
408
|
+
if (toolIndex < 0) {
|
|
409
|
+
return messages;
|
|
410
|
+
}
|
|
411
|
+
const nextMessages = [...messages];
|
|
412
|
+
const existingMessage = nextMessages[toolIndex];
|
|
413
|
+
if (!existingMessage) {
|
|
414
|
+
return messages;
|
|
415
|
+
}
|
|
416
|
+
nextMessages[toolIndex] = {
|
|
417
|
+
...updatedMessage,
|
|
418
|
+
id: existingMessage.id
|
|
419
|
+
};
|
|
420
|
+
return nextMessages;
|
|
421
|
+
}
|
|
422
|
+
function getApprovalToolCallId(messages, approvalId) {
|
|
423
|
+
for (const message of messages) {
|
|
424
|
+
for (const part of message.parts) {
|
|
425
|
+
if (!("toolCallId" in part) || typeof part.toolCallId !== "string" || !("approval" in part) || !isRecord(part.approval)) {
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
if (part.approval.id === approvalId) {
|
|
429
|
+
return part.toolCallId;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
function getClientToolSchemas(tools) {
|
|
436
|
+
if (!tools) {
|
|
437
|
+
return void 0;
|
|
438
|
+
}
|
|
439
|
+
const schemas = Object.entries(tools).filter(([, tool]) => tool.execute).map(([name, tool]) => ({
|
|
440
|
+
name,
|
|
441
|
+
description: tool.description,
|
|
442
|
+
parameters: tool.parameters ?? tool.inputSchema
|
|
443
|
+
}));
|
|
444
|
+
return schemas.length > 0 ? schemas : void 0;
|
|
445
|
+
}
|
|
446
|
+
function getErrorText(error) {
|
|
447
|
+
return error instanceof Error ? error.message : String(error);
|
|
448
|
+
}
|
|
449
|
+
function preserveActiveStreamMessages(currentMessages, incomingMessages) {
|
|
450
|
+
const tailMessage = currentMessages.at(-1);
|
|
451
|
+
if (!tailMessage || tailMessage.role !== "assistant") {
|
|
452
|
+
return incomingMessages;
|
|
453
|
+
}
|
|
454
|
+
const existingIndex = incomingMessages.findIndex((message) => message.id === tailMessage.id);
|
|
455
|
+
if (existingIndex >= 0) {
|
|
456
|
+
const nextMessages = [...incomingMessages];
|
|
457
|
+
nextMessages[existingIndex] = tailMessage;
|
|
458
|
+
return nextMessages;
|
|
459
|
+
}
|
|
460
|
+
const tailToolCallIds = getToolCallIds(tailMessage);
|
|
461
|
+
if (tailToolCallIds.size > 0) {
|
|
462
|
+
const remappedIndex = incomingMessages.findIndex(
|
|
463
|
+
(message) => message.parts.some(
|
|
464
|
+
(part) => "toolCallId" in part && typeof part.toolCallId === "string" && tailToolCallIds.has(part.toolCallId)
|
|
465
|
+
)
|
|
466
|
+
);
|
|
467
|
+
if (remappedIndex >= 0) {
|
|
468
|
+
const remapped = incomingMessages[remappedIndex];
|
|
469
|
+
const nextMessages = [...incomingMessages];
|
|
470
|
+
nextMessages[remappedIndex] = { ...tailMessage, id: remapped.id };
|
|
471
|
+
return nextMessages;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return [...incomingMessages, tailMessage];
|
|
475
|
+
}
|
|
476
|
+
function createMessageId() {
|
|
477
|
+
return globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2);
|
|
478
|
+
}
|
|
479
|
+
function useAgentChat(options) {
|
|
480
|
+
const autoContinueAfterToolResult = options.autoContinueAfterToolResult ?? true;
|
|
481
|
+
const activeRequestIds = /* @__PURE__ */ new Set();
|
|
482
|
+
let streamState = { status: "idle" };
|
|
483
|
+
let isResumingToolContinuation = false;
|
|
484
|
+
const socket = new AgentClient(options.client);
|
|
485
|
+
const transport = new WebSocketChatTransport({
|
|
486
|
+
socket,
|
|
487
|
+
activeRequestIds,
|
|
488
|
+
prepareBody: async () => {
|
|
489
|
+
const rawBody = options.body;
|
|
490
|
+
const extraBody = typeof rawBody === "function" ? await rawBody() : rawBody ?? {};
|
|
491
|
+
const clientTools = getClientToolSchemas(options.tools);
|
|
492
|
+
return clientTools ? { ...extraBody, clientTools } : extraBody;
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
const handleToolCall = async ({ toolCall }) => {
|
|
496
|
+
const clientTool = options.tools?.[toolCall.toolName];
|
|
497
|
+
const addClientToolOutput = (toolOutput) => addToolOutputForTool(toolCall.toolName, toolOutput);
|
|
498
|
+
if (options.onToolCall) {
|
|
499
|
+
await options.onToolCall({ toolCall, addToolOutput: addClientToolOutput });
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
if (!clientTool?.execute) {
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
try {
|
|
506
|
+
const output = await clientTool.execute(toolCall.input);
|
|
507
|
+
await addClientToolOutput({ toolCallId: toolCall.toolCallId, output });
|
|
508
|
+
} catch (error) {
|
|
509
|
+
await addClientToolOutput({
|
|
510
|
+
toolCallId: toolCall.toolCallId,
|
|
511
|
+
state: "output-error",
|
|
512
|
+
errorText: getErrorText(error)
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
const chat = new Chat({
|
|
517
|
+
messages: options.initialMessages,
|
|
518
|
+
transport,
|
|
519
|
+
onToolCall: handleToolCall,
|
|
520
|
+
onError: options.onError
|
|
521
|
+
});
|
|
522
|
+
function startToolContinuation() {
|
|
523
|
+
if (!autoContinueAfterToolResult || isResumingToolContinuation) {
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
isResumingToolContinuation = true;
|
|
527
|
+
transport.expectToolContinuation();
|
|
528
|
+
void chat.resumeStream().finally(() => {
|
|
529
|
+
isResumingToolContinuation = false;
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
async function stop() {
|
|
533
|
+
try {
|
|
534
|
+
await chat.stop();
|
|
535
|
+
} finally {
|
|
536
|
+
transport.abortActiveToolContinuation();
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
function clearHistory() {
|
|
540
|
+
void stop();
|
|
541
|
+
isResumingToolContinuation = false;
|
|
542
|
+
streamState = broadcastTransition(streamState, { type: "clear" }).state;
|
|
543
|
+
chat.messages = [];
|
|
544
|
+
socket.send(JSON.stringify({ type: "cf_agent_chat_clear" }));
|
|
545
|
+
}
|
|
546
|
+
async function addToolOutputForTool(toolName, toolOutput) {
|
|
547
|
+
const shouldAutoContinue = toolOutput.state === "output-error" ? false : autoContinueAfterToolResult;
|
|
548
|
+
const clientTools = getClientToolSchemas(options.tools);
|
|
549
|
+
socket.send(
|
|
550
|
+
JSON.stringify({
|
|
551
|
+
type: "cf_agent_tool_result",
|
|
552
|
+
toolCallId: toolOutput.toolCallId,
|
|
553
|
+
toolName,
|
|
554
|
+
output: "output" in toolOutput ? toolOutput.output : void 0,
|
|
555
|
+
...toolOutput.state ? { state: toolOutput.state } : {},
|
|
556
|
+
...toolOutput.errorText !== void 0 ? { errorText: toolOutput.errorText } : {},
|
|
557
|
+
autoContinue: shouldAutoContinue,
|
|
558
|
+
...clientTools ? { clientTools } : {}
|
|
559
|
+
})
|
|
560
|
+
);
|
|
561
|
+
if (toolOutput.state === "output-error") {
|
|
562
|
+
await chat.addToolOutput({
|
|
563
|
+
tool: toolName,
|
|
564
|
+
toolCallId: toolOutput.toolCallId,
|
|
565
|
+
state: "output-error",
|
|
566
|
+
errorText: toolOutput.errorText ?? "Tool execution failed."
|
|
567
|
+
});
|
|
568
|
+
} else {
|
|
569
|
+
await chat.addToolOutput({
|
|
570
|
+
tool: toolName,
|
|
571
|
+
toolCallId: toolOutput.toolCallId,
|
|
572
|
+
output: toolOutput.output
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
if (shouldAutoContinue) {
|
|
576
|
+
startToolContinuation();
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
async function addToolOutput(...args) {
|
|
580
|
+
const [toolOutput] = args;
|
|
581
|
+
await addToolOutputForTool(String(toolOutput.tool), toolOutput);
|
|
582
|
+
}
|
|
583
|
+
async function addToolApprovalResponse(...args) {
|
|
584
|
+
const [approvalResponse] = args;
|
|
585
|
+
const toolCallId = getApprovalToolCallId(chat.messages, approvalResponse.id);
|
|
586
|
+
if (!toolCallId) {
|
|
587
|
+
console.warn(`[agents-vue] Could not find tool call for approval "${approvalResponse.id}".`);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
socket.send(
|
|
591
|
+
JSON.stringify({
|
|
592
|
+
type: "cf_agent_tool_approval",
|
|
593
|
+
toolCallId,
|
|
594
|
+
approved: approvalResponse.approved,
|
|
595
|
+
autoContinue: autoContinueAfterToolResult
|
|
596
|
+
})
|
|
597
|
+
);
|
|
598
|
+
await chat.addToolApprovalResponse(...args);
|
|
599
|
+
startToolContinuation();
|
|
600
|
+
}
|
|
601
|
+
socket.addEventListener("message", (event) => {
|
|
602
|
+
try {
|
|
603
|
+
const data = parseProtocolMessage(String(event.data));
|
|
604
|
+
if (!data) {
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
switch (data.type) {
|
|
608
|
+
// Full message list sync (sent on connect, and after every appendMessage / turn).
|
|
609
|
+
case "cf_agent_chat_messages":
|
|
610
|
+
chat.messages = chat.status === "streaming" || chat.status === "submitted" || streamState.status !== "idle" ? preserveActiveStreamMessages(chat.messages, data.messages ?? []) : data.messages ?? [];
|
|
611
|
+
break;
|
|
612
|
+
// Single message update (e.g. tool result applied)
|
|
613
|
+
case "cf_agent_message_updated":
|
|
614
|
+
if (data.message) {
|
|
615
|
+
chat.messages = replaceUpdatedMessage(chat.messages, data.message);
|
|
616
|
+
}
|
|
617
|
+
break;
|
|
618
|
+
// Clear all messages
|
|
619
|
+
case "cf_agent_chat_clear":
|
|
620
|
+
streamState = broadcastTransition(streamState, { type: "clear" }).state;
|
|
621
|
+
chat.messages = [];
|
|
622
|
+
break;
|
|
623
|
+
// Forward resume handshake to transport
|
|
624
|
+
case "cf_agent_stream_resuming":
|
|
625
|
+
if (data.id && !transport.handleStreamResuming({ type: data.type, id: data.id }) && !activeRequestIds.has(data.id)) {
|
|
626
|
+
streamState = broadcastTransition(streamState, {
|
|
627
|
+
type: "resume-fallback",
|
|
628
|
+
streamId: data.id,
|
|
629
|
+
messageId: createMessageId()
|
|
630
|
+
}).state;
|
|
631
|
+
socket.send(JSON.stringify({ type: "cf_agent_stream_resume_ack", id: data.id }));
|
|
632
|
+
}
|
|
633
|
+
break;
|
|
634
|
+
case "cf_agent_stream_resume_none":
|
|
635
|
+
transport.handleStreamResumeNone();
|
|
636
|
+
break;
|
|
637
|
+
case "cf_agent_use_chat_response":
|
|
638
|
+
if (!data.id || activeRequestIds.has(data.id)) {
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
{
|
|
642
|
+
const chunkData = data.body?.trim() ? JSON.parse(data.body) : void 0;
|
|
643
|
+
const result = broadcastTransition(streamState, {
|
|
644
|
+
type: "response",
|
|
645
|
+
streamId: data.id,
|
|
646
|
+
messageId: createMessageId(),
|
|
647
|
+
chunkData,
|
|
648
|
+
done: data.done,
|
|
649
|
+
error: data.error,
|
|
650
|
+
replay: data.replay,
|
|
651
|
+
replayComplete: data.replayComplete,
|
|
652
|
+
continuation: data.continuation,
|
|
653
|
+
currentMessages: data.continuation ? chat.messages : void 0
|
|
654
|
+
});
|
|
655
|
+
streamState = result.state;
|
|
656
|
+
if (result.messagesUpdate) {
|
|
657
|
+
chat.messages = result.messagesUpdate(chat.messages);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
break;
|
|
661
|
+
}
|
|
662
|
+
} catch {
|
|
663
|
+
}
|
|
664
|
+
});
|
|
665
|
+
socket.addEventListener("open", (event) => {
|
|
666
|
+
options.onOpen?.(event);
|
|
667
|
+
if (options.resume !== false) {
|
|
668
|
+
void chat.resumeStream();
|
|
669
|
+
}
|
|
670
|
+
});
|
|
671
|
+
socket.addEventListener("close", (event) => {
|
|
672
|
+
if (options.onClose && event instanceof CloseEvent) {
|
|
673
|
+
options.onClose(event);
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
socket.addEventListener("error", (event) => {
|
|
677
|
+
options.onSocketError?.(event);
|
|
678
|
+
});
|
|
679
|
+
onScopeDispose(() => {
|
|
680
|
+
socket.close();
|
|
681
|
+
});
|
|
682
|
+
return {
|
|
683
|
+
addToolApprovalResponse,
|
|
684
|
+
addToolOutput,
|
|
685
|
+
clearHistory,
|
|
686
|
+
chat,
|
|
687
|
+
socket,
|
|
688
|
+
stop
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
export { WebSocketChatTransport, useAgentChat };
|
package/package.json
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "agents-vue",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Vue 3 composable for Cloudflare Agents β a port of @cloudflare/ai-chat/react's useAgentChat.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"vue",
|
|
7
|
+
"vue3",
|
|
8
|
+
"cloudflare",
|
|
9
|
+
"agents",
|
|
10
|
+
"ai",
|
|
11
|
+
"chat",
|
|
12
|
+
"websocket",
|
|
13
|
+
"ai-sdk",
|
|
14
|
+
"composable"
|
|
15
|
+
],
|
|
16
|
+
"homepage": "https://github.com/ymansurozer/agents-vue",
|
|
17
|
+
"bugs": "https://github.com/ymansurozer/agents-vue/issues",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/ymansurozer/agents-vue.git"
|
|
21
|
+
},
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"author": "Yusuf Mansur Γzer",
|
|
24
|
+
"type": "module",
|
|
25
|
+
"sideEffects": false,
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./dist/index.d.mts",
|
|
29
|
+
"import": "./dist/index.mjs"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"main": "./dist/index.mjs",
|
|
33
|
+
"module": "./dist/index.mjs",
|
|
34
|
+
"types": "./dist/index.d.mts",
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"LICENSE",
|
|
38
|
+
"README.md",
|
|
39
|
+
"CHANGELOG.md"
|
|
40
|
+
],
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@ai-sdk/vue": "^3.0.0",
|
|
43
|
+
"agents": ">=0.11.0 <1.0.0",
|
|
44
|
+
"ai": "^6.0.0",
|
|
45
|
+
"vue": "^3.3.4"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@ai-sdk/vue": "^3.0.0",
|
|
49
|
+
"@eslint/js": "^9.0.0",
|
|
50
|
+
"@types/node": "^22.0.0",
|
|
51
|
+
"@vitest/browser": "^4.1.5",
|
|
52
|
+
"@vitest/browser-playwright": "^4.1.5",
|
|
53
|
+
"agents": "^0.11.4",
|
|
54
|
+
"ai": "^6.0.0",
|
|
55
|
+
"changelogen": "^0.6.2",
|
|
56
|
+
"eslint": "^9.0.0",
|
|
57
|
+
"playwright": "^1.49.0",
|
|
58
|
+
"typescript": "^5.6.0",
|
|
59
|
+
"typescript-eslint": "^8.0.0",
|
|
60
|
+
"unbuild": "^3.0.0",
|
|
61
|
+
"vitest": "^4.1.5",
|
|
62
|
+
"vitest-browser-vue": "^2.1.0",
|
|
63
|
+
"vue": "^3.5.0",
|
|
64
|
+
"vue-tsc": "^3.0.0"
|
|
65
|
+
},
|
|
66
|
+
"publishConfig": {
|
|
67
|
+
"access": "public"
|
|
68
|
+
},
|
|
69
|
+
"engines": {
|
|
70
|
+
"node": ">=20"
|
|
71
|
+
},
|
|
72
|
+
"scripts": {
|
|
73
|
+
"dev": "unbuild --watch",
|
|
74
|
+
"build": "unbuild",
|
|
75
|
+
"test": "vitest run",
|
|
76
|
+
"test:watch": "vitest",
|
|
77
|
+
"lint": "eslint .",
|
|
78
|
+
"lint:fix": "eslint . --fix",
|
|
79
|
+
"typecheck": "vue-tsc --noEmit",
|
|
80
|
+
"release": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run build && changelogen --release && git push --follow-tags"
|
|
81
|
+
}
|
|
82
|
+
}
|