Add deterministic announcement demo workspace

This commit is contained in:
kenny lopez
2026-07-17 17:11:56 +02:00
parent 57316a378e
commit 05f4ec5fb4
21 changed files with 2866 additions and 58 deletions
+44
View File
@@ -0,0 +1,44 @@
# Buzz announcement demo
This branch includes a deterministic, relay-free workspace for recording the
Buzz announcement film.
## Launch
From this worktree, run:
```bash
just announcement-staging
```
The command launches the native Tauri staging app on this worktree's isolated
port with announcement mode enabled. The app uses the deterministic mock
workspace rather than reading or writing real staging-relay data. Reloading the
app restores the same clean demo workspace.
The local demo relay also includes a running agent named **Scout**. Configure
OpenAI or Anthropic in the Agents screen, then `@mention` Scout in
`#flight-path`, `#design`, `#marketing`, or
`#queen-bee-launch`. Scout shows a typing indicator and replies using the
selected provider with the recent conversation as context. Direct messages to
Scout work too. Agent settings are retained for reloads during the current app
session; provider keys are never written into the repository or logged by the
local provider bridge.
For a browser-only preview, use `just announcement-demo`.
## Demo workspace
- Workspace: **Honeycomb Studios**
- People: Alex Rivera (Product Lead) and nine fictional teammates across
engineering, design, marketing, research, QA, data, support, video, and
community
- Sections: **The Hive**, **Product**, and **Launch Swarm**
- Channels include `#announcements`, `#general`, `#flight-path`, `#design`,
`#mobile`, `#product-ideas`, `#marketing`, and
`#queen-bee-launch`
- Projects: `flight-path`, `nectar`, `comb-kit`, and `swarm-launch`
- Direct messages: Maya Chen, Jordan Brooks, and Priya Shah
All portraits are generated fictional people. Scout uses the default generated
identity until the announcement's final agent avatar is ready.
+25
View File
@@ -488,6 +488,31 @@ desktop-dev:
echo "Starting frontend dev server on Vite port ${BUZZ_VITE_PORT}, relay ${BUZZ_RELAY_URL}"
pnpm exec vite --port "${BUZZ_VITE_PORT}" --strictPort
# Open the deterministic, relay-free announcement workspace in a browser
announcement-demo:
#!/usr/bin/env bash
set -euo pipefail
cd {{desktop_dir}}
[[ -d node_modules ]] || pnpm install
source ../scripts/instance-env.sh
demo_url="http://127.0.0.1:${BUZZ_VITE_PORT}/?demo=announcement"
pnpm exec vite --port "${BUZZ_VITE_PORT}" --strictPort &
vite_pid=$!
trap 'kill "$vite_pid" 2>/dev/null || true' EXIT
for _ in $(seq 1 40); do
if curl -sf "http://127.0.0.1:${BUZZ_VITE_PORT}/" >/dev/null; then
break
fi
sleep 0.25
done
echo "Opening the Buzz announcement demo at ${demo_url}"
open "${demo_url}"
wait "$vite_pid"
# Open the deterministic announcement workspace in the native staging shell
announcement-staging *ARGS:
VITE_ANNOUNCEMENT_DEMO=1 just staging {{ARGS}}
# ─── Web ─────────────────────────────────────────────────────────────────────
# Run the web frontend dev server (port derived from worktree to avoid collisions)
+258
View File
@@ -0,0 +1,258 @@
import type { Plugin } from "vite";
const AGENT_RESPONSE_PATH = "/__announcement-demo/agent-response";
const MAX_REQUEST_BYTES = 64 * 1024;
const REQUEST_TIMEOUT_MS = 45_000;
type AnnouncementDemoAgentMessage = {
role: "assistant" | "user";
content: string;
};
type AnnouncementDemoAgentRequest = {
provider: "anthropic" | "openai";
apiKey: string;
model: string;
systemPrompt: string;
messages: AnnouncementDemoAgentMessage[];
};
type ProviderErrorBody = {
error?: string | { message?: string };
message?: string;
};
function isAgentMessage(value: unknown): value is AnnouncementDemoAgentMessage {
if (typeof value !== "object" || value === null) {
return false;
}
const candidate = value as Record<string, unknown>;
return (
(candidate.role === "assistant" || candidate.role === "user") &&
typeof candidate.content === "string" &&
candidate.content.trim().length > 0
);
}
function parseAgentRequest(value: unknown): AnnouncementDemoAgentRequest {
if (typeof value !== "object" || value === null) {
throw new Error("The agent request was not valid JSON.");
}
const candidate = value as Record<string, unknown>;
if (candidate.provider !== "anthropic" && candidate.provider !== "openai") {
throw new Error("Choose Anthropic or OpenAI as the agent provider.");
}
if (typeof candidate.apiKey !== "string" || !candidate.apiKey.trim()) {
throw new Error("Add an API key in the agent settings first.");
}
if (typeof candidate.model !== "string" || !candidate.model.trim()) {
throw new Error("Choose a model in the agent settings first.");
}
if (
typeof candidate.systemPrompt !== "string" ||
!Array.isArray(candidate.messages) ||
!candidate.messages.every(isAgentMessage)
) {
throw new Error("The agent conversation context was incomplete.");
}
return {
provider: candidate.provider,
apiKey: candidate.apiKey.trim(),
model: candidate.model.trim(),
systemPrompt: candidate.systemPrompt,
messages: candidate.messages,
};
}
function readRequestBody(request: NodeJS.ReadableStream): Promise<unknown> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let bytesRead = 0;
request.on("data", (chunk: Buffer) => {
bytesRead += chunk.length;
if (bytesRead > MAX_REQUEST_BYTES) {
reject(new Error("The agent conversation was too large to send."));
return;
}
chunks.push(chunk);
});
request.on("end", () => {
try {
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
} catch {
reject(new Error("The agent request was not valid JSON."));
}
});
request.on("error", reject);
});
}
function normalizeAnthropicModel(model: string) {
if (model === "goose-claude-4-6-sonnet") {
return "claude-sonnet-4-6";
}
if (model === "goose-claude-4-6-opus") {
return "claude-opus-4-6";
}
return model.replace(/^anthropic\//, "");
}
function extractProviderError(body: ProviderErrorBody, status: number) {
const nestedMessage =
typeof body.error === "object" ? body.error.message : undefined;
const message = nestedMessage ?? body.message ?? body.error;
if (typeof message === "string" && message.trim()) {
return `Provider request failed (${status}): ${message.trim()}`;
}
return `Provider request failed with status ${status}.`;
}
function extractOpenAiText(body: unknown) {
if (typeof body !== "object" || body === null) {
return null;
}
const response = body as {
output_text?: unknown;
output?: Array<{
content?: Array<{ type?: unknown; text?: unknown }>;
}>;
};
if (typeof response.output_text === "string" && response.output_text.trim()) {
return response.output_text.trim();
}
const text = (response.output ?? [])
.flatMap((item) => item.content ?? [])
.filter((part) => part.type === "output_text")
.map((part) => (typeof part.text === "string" ? part.text : ""))
.join("")
.trim();
return text || null;
}
function extractAnthropicText(body: unknown) {
if (typeof body !== "object" || body === null) {
return null;
}
const response = body as {
content?: Array<{ type?: unknown; text?: unknown }>;
};
const text = (response.content ?? [])
.filter((part) => part.type === "text")
.map((part) => (typeof part.text === "string" ? part.text : ""))
.join("")
.trim();
return text || null;
}
async function requestOpenAiResponse(input: AnnouncementDemoAgentRequest) {
const response = await fetch("https://api.openai.com/v1/responses", {
method: "POST",
headers: {
Authorization: `Bearer ${input.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: input.model,
instructions: input.systemPrompt,
input: input.messages,
max_output_tokens: 350,
store: false,
}),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
const body = (await response.json()) as unknown;
if (!response.ok) {
throw new Error(
extractProviderError(body as ProviderErrorBody, response.status),
);
}
const text = extractOpenAiText(body);
if (!text) {
throw new Error("OpenAI returned a response without any text.");
}
return text;
}
async function requestAnthropicResponse(input: AnnouncementDemoAgentRequest) {
const response = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"anthropic-version": "2023-06-01",
"Content-Type": "application/json",
"x-api-key": input.apiKey,
},
body: JSON.stringify({
model: normalizeAnthropicModel(input.model),
max_tokens: 350,
system: input.systemPrompt,
messages: input.messages,
}),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
const body = (await response.json()) as unknown;
if (!response.ok) {
throw new Error(
extractProviderError(body as ProviderErrorBody, response.status),
);
}
const text = extractAnthropicText(body);
if (!text) {
throw new Error("Anthropic returned a response without any text.");
}
return text;
}
function friendlyError(error: unknown) {
if (error instanceof Error && error.name === "TimeoutError") {
return "The model took too long to respond. Please try again.";
}
if (error instanceof Error) {
return error.message;
}
return "The model could not respond just now.";
}
/**
* Local-only provider proxy for the announcement demo. Keeping the API request
* in Vite means provider credentials are never built into the frontend bundle
* or written to source, and no Docker-backed relay is required.
*/
export function announcementDemoAgentPlugin(): Plugin {
return {
name: "buzz-announcement-demo-agent",
configureServer(server) {
server.middlewares.use(AGENT_RESPONSE_PATH, async (request, response) => {
response.setHeader("Content-Type", "application/json");
response.setHeader("Cache-Control", "no-store");
if (request.method !== "POST") {
response.statusCode = 405;
response.end(JSON.stringify({ error: "Method not allowed." }));
return;
}
try {
const input = parseAgentRequest(await readRequestBody(request));
const text =
input.provider === "openai"
? await requestOpenAiResponse(input)
: await requestAnthropicResponse(input);
response.statusCode = 200;
response.end(JSON.stringify({ text }));
} catch (error) {
response.statusCode = 502;
response.end(JSON.stringify({ error: friendlyError(error) }));
}
});
},
};
}
+7 -3
View File
@@ -1,5 +1,8 @@
import { defineConfig, devices } from "@playwright/test";
const previewPort = process.env.BUZZ_E2E_PREVIEW_PORT ?? "4173";
const previewUrl = `http://127.0.0.1:${previewPort}`;
export default defineConfig({
testDir: "./tests/e2e",
timeout: 30_000,
@@ -10,7 +13,7 @@ export default defineConfig({
["html", { open: "never", outputFolder: "playwright-report" }],
],
use: {
baseURL: "http://127.0.0.1:4173",
baseURL: previewUrl,
screenshot: "only-on-failure",
trace: "on-first-retry",
video: "retain-on-failure",
@@ -21,6 +24,7 @@ export default defineConfig({
testMatch: [
"**/smoke.spec.ts",
"**/onboarding-docked-cta-screenshots.spec.ts",
"**/announcement-demo.spec.ts",
"**/navigation.spec.ts",
"**/channels.spec.ts",
"**/channel-shared-header-backdrop.spec.ts",
@@ -140,9 +144,9 @@ export default defineConfig({
},
],
webServer: {
command: "python3 -m http.server 4173 -d dist",
command: `python3 -m http.server ${previewPort} -d dist`,
cwd: ".",
reuseExistingServer: !process.env.CI,
url: "http://127.0.0.1:4173",
url: previewUrl,
},
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 399 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 507 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

+58 -9
View File
@@ -8,6 +8,12 @@ import { UpdaterProvider } from "@/features/settings/hooks/UpdaterProvider";
import { migrateLegacyCommunityStorageBeforeRender } from "@/features/communities/legacyCommunityStorage";
import { CommunitiesProvider } from "@/features/communities/useCommunities";
import { CommunityOnboardingProvider } from "@/features/onboarding/communityOnboarding";
import {
ANNOUNCEMENT_DEMO_AGENT,
ANNOUNCEMENT_DEMO_COMMUNITY_NAME,
ANNOUNCEMENT_DEMO_PEOPLE,
ANNOUNCEMENT_DEMO_SECTION_STORE,
} from "@/testing/announcementDemoFixtures";
import { ThemeProvider } from "@/shared/theme/ThemeProvider";
import { EmojiBurstProvider } from "@/shared/ui/EmojiBurstProvider";
import { PoofBurstProvider } from "@/shared/ui/PoofBurstProvider";
@@ -22,6 +28,9 @@ const E2E_DEFAULT_PUBKEY = "deadbeef".repeat(8);
const E2E_COMMUNITY_ID = "e2e-default-community";
const ONBOARDING_COMPLETION_STORAGE_KEY_PREFIX = "buzz-onboarding-complete.v1:";
const DEV_STATE_RESET_PARAM = "resetDevState";
const ANNOUNCEMENT_DEMO_QUERY_VALUE = "announcement";
const CHANNEL_SECTIONS_STORAGE_KEY_PREFIX = "buzz-channel-sections.v1";
const SELF_PROFILE_STORAGE_KEY_PREFIX = "buzz-self-profile.v1";
function resetDevWebviewStateFromUrl() {
if (!import.meta.env.DEV) {
@@ -42,23 +51,44 @@ function resetDevWebviewStateFromUrl() {
window.history.replaceState(window.history.state, "", url);
}
function configureDevE2eBridgeFromUrl() {
if (!import.meta.env.DEV) {
return;
}
function configureMockBridgeFromUrl() {
const url = new URL(window.location.href);
if (url.searchParams.get("e2e") !== "mock") {
const isDevE2eMock =
import.meta.env.DEV && url.searchParams.get("e2e") === "mock";
const isAnnouncementDemo =
url.searchParams.get("demo") === ANNOUNCEMENT_DEMO_QUERY_VALUE ||
import.meta.env.VITE_ANNOUNCEMENT_DEMO === "1";
if (!isDevE2eMock && !isAnnouncementDemo) {
return;
}
const e2eWindow = window as E2eWindow;
e2eWindow.__BUZZ_E2E__ ??= { mode: "mock" };
if (isAnnouncementDemo) {
e2eWindow.__BUZZ_E2E__ = {
mode: "mock",
mock: {
announcementDemo: true,
managedAgents: [
{
pubkey: ANNOUNCEMENT_DEMO_AGENT.pubkey,
name: ANNOUNCEMENT_DEMO_AGENT.name,
systemPrompt: ANNOUNCEMENT_DEMO_AGENT.systemPrompt,
status: "running",
channelNames: [...ANNOUNCEMENT_DEMO_AGENT.channelNames],
respondTo: "owner-only",
},
],
},
};
} else {
e2eWindow.__BUZZ_E2E__ ??= { mode: "mock" };
}
const community = {
addedAt: new Date().toISOString(),
id: E2E_COMMUNITY_ID,
name: "E2E Test",
name: isAnnouncementDemo ? ANNOUNCEMENT_DEMO_COMMUNITY_NAME : "E2E Test",
relayUrl: "ws://localhost:3000",
};
window.localStorage.setItem("buzz-communities", JSON.stringify([community]));
@@ -67,6 +97,25 @@ function configureDevE2eBridgeFromUrl() {
`${ONBOARDING_COMPLETION_STORAGE_KEY_PREFIX}${E2E_DEFAULT_PUBKEY}`,
"true",
);
if (isAnnouncementDemo) {
const relayStorageScope = encodeURIComponent("ws://localhost:3000");
window.localStorage.setItem(
`${CHANNEL_SECTIONS_STORAGE_KEY_PREFIX}:${E2E_DEFAULT_PUBKEY}:${relayStorageScope}`,
JSON.stringify(ANNOUNCEMENT_DEMO_SECTION_STORE),
);
window.localStorage.setItem(
`${SELF_PROFILE_STORAGE_KEY_PREFIX}:ws://localhost:3000:${E2E_DEFAULT_PUBKEY}`,
JSON.stringify({
version: 1,
displayName: ANNOUNCEMENT_DEMO_PEOPLE.viewer.displayName,
avatarUrl: ANNOUNCEMENT_DEMO_PEOPLE.viewer.avatarUrl,
avatarDataUrl: null,
updatedAt: Date.now(),
hasProfileEvent: true,
}),
);
}
}
function renderApp() {
@@ -106,7 +155,7 @@ async function installE2eBridgeIfConfigured() {
async function bootstrap() {
resetDevWebviewStateFromUrl();
configureDevE2eBridgeFromUrl();
configureMockBridgeFromUrl();
await installE2eBridgeIfConfigured();
await migrateLegacyCommunityStorageBeforeRender();
renderApp();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,43 @@
import {
invoke as nativeInvoke,
type InvokeArgs,
type InvokeOptions,
} from "../../node_modules/@tauri-apps/api/core.js";
export type {
InvokeArgs,
InvokeOptions,
} from "../../node_modules/@tauri-apps/api/core.js";
export {
addPluginListener,
Channel,
checkPermissions,
convertFileSrc,
isTauri,
PluginListener,
requestPermissions,
Resource,
SERIALIZE_TO_IPC_FN,
transformCallback,
} from "../../node_modules/@tauri-apps/api/core.js";
type AnnouncementDemoWindow = Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
command: string,
payload?: unknown,
) => unknown;
};
/** Route native-shell commands through the deterministic announcement bridge. */
export function invoke<T>(
command: string,
payload?: InvokeArgs,
options?: InvokeOptions,
): Promise<T> {
const mockInvoke = (window as AnnouncementDemoWindow)
.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
if (mockInvoke) {
return Promise.resolve(mockInvoke(command, payload)) as Promise<T>;
}
return nativeInvoke<T>(command, payload, options);
}
File diff suppressed because it is too large Load Diff
+143
View File
@@ -0,0 +1,143 @@
import { expect, test } from "@playwright/test";
test("announcement demo loads its workspace, people, and projects", async ({
page,
}) => {
const agentReply =
"I’d lead with the handoff moment, then land on the shared launch room. That gives the story a clear before-and-after.";
await page.route("**/__announcement-demo/agent-response", async (route) => {
await route.fulfill({ json: { text: agentReply } });
});
await page.goto("/?demo=announcement");
await page.waitForFunction(
() =>
typeof (
window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown;
}
).__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function",
);
await page.evaluate(async () => {
const invoke = (
window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
command: string,
payload?: unknown,
) => Promise<unknown>;
}
).__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
if (!invoke) {
throw new Error("Announcement demo command bridge is unavailable.");
}
await invoke("set_global_agent_config", {
config: {
env_vars: { OPENAI_COMPAT_API_KEY: "e2e-demo-key" },
provider: "openai",
model: "gpt-5.4-mini",
},
});
});
await expect(page.getByText("The Hive", { exact: true })).toBeVisible();
await expect(page.getByText("Product", { exact: true })).toBeVisible();
await expect(page.getByText("Launch Swarm", { exact: true })).toBeVisible();
await expect(
page.getByText("Honeycomb Studios", { exact: true }),
).toBeVisible();
await expect(
page.getByText("Alex Rivera", { exact: true }).last(),
).toBeVisible();
await expect(
page.getByTestId("channel-DM").filter({ hasText: "Maya Chen" }),
).toBeVisible();
await expect(
page.getByTestId("channel-DM").filter({ hasText: "Jordan Brooks" }),
).toBeVisible();
await expect(
page.getByTestId("channel-DM").filter({ hasText: "Priya Shah" }),
).toBeVisible();
await expect(page.getByTestId("channel-DM")).toHaveCount(3);
await page.getByTestId("channel-flight-path").click();
const channelTimeline = page.getByTestId("message-timeline");
await expect(channelTimeline).toContainText("Marcus Reed");
await expect(channelTimeline).toContainText("Elena Torres");
await expect(channelTimeline).toContainText("Perfect. That’s the move.");
const demoBuildRow = page
.getByTestId("message-row")
.filter({ hasText: "Demo build is running" })
.last();
await expect(
demoBuildRow.locator('[data-link-preview="github-pull-request"]'),
).toBeVisible();
await expect(demoBuildRow.getByTestId("message-reactions")).toContainText(
"✅",
);
await expect(
channelTimeline.locator('[data-link-preview="linear-issue"]').last(),
).toBeVisible();
const channelMessage = `The recording pass is ready ${Date.now()}`;
await page.getByTestId("message-input").fill(channelMessage);
await page.getByTestId("send-message").click();
await expect(channelTimeline).toContainText(channelMessage);
const messageInput = page.getByTestId("message-input");
await messageInput.fill("Could ");
await messageInput.pressSequentially("@Sco");
const agentMention = page
.getByTestId("message-composer")
.getByTestId("mention-autocomplete")
.locator("button", { hasText: "Scout" });
await expect(agentMention).toBeVisible();
await agentMention.click();
await messageInput.pressSequentially(" suggest the strongest story beat?");
await page.getByTestId("send-message").click();
await expect(channelTimeline).toContainText(agentReply, { timeout: 10_000 });
const populatedChannels = [
["announcements", "Final smoke pass is clean"],
["general", "Please nobody breathe on main"],
["design", "Looks great on camera"],
["mobile", "The draft follows you now"],
["marketing", "No copy-paste script"],
["queen-bee-launch", "Sound mix is approved"],
] as const;
for (const [channel, excerpt] of populatedChannels) {
await page.getByTestId(`channel-${channel}`).click();
await expect(channelTimeline).toContainText(excerpt);
}
await page.getByTestId("channel-design").click();
await expect(channelTimeline.getByAltText("image").last()).toBeVisible();
await expect(
channelTimeline.locator('[data-link-preview="google-docs-document"]'),
).toBeVisible();
await page.getByTestId("channel-marketing").click();
await expect(
channelTimeline
.getByTestId("file-card")
.filter({ hasText: "launch-social-crops.zip" }),
).toBeVisible();
await expect(
channelTimeline.locator('[data-link-preview="google-sheets-spreadsheet"]'),
).toBeVisible();
await page.getByTestId("channel-DM").filter({ hasText: "Maya Chen" }).click();
const dmMessage = `Can you join the capture review? ${Date.now()}`;
await page.getByTestId("message-input").fill(dmMessage);
await page.getByTestId("send-message").click();
await expect(page.getByTestId("message-timeline")).toContainText(dmMessage);
await page.goto("/?demo=announcement#/projects");
await page.locator('button[aria-label="Repositories"]').click();
for (const project of ["flight-path", "nectar", "comb-kit", "swarm-launch"]) {
await expect(
page.locator(
`[data-testid="project-card-${project}"], [data-testid="project-row-${project}"]`,
),
).toBeVisible();
}
});
+10
View File
@@ -2,6 +2,7 @@ import path from "node:path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { tanstackRouter } from "@tanstack/router-plugin/vite";
import { announcementDemoAgentPlugin } from "./announcementDemoAgentPlugin";
// @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST;
@@ -21,11 +22,20 @@ export default defineConfig(async () => ({
],
}),
react(),
announcementDemoAgentPlugin(),
],
resolve: {
alias: {
"@": "/src",
"@features-manifest": path.resolve(__dirname, "../preview-features.json"),
...(process.env.VITE_ANNOUNCEMENT_DEMO === "1"
? {
"@tauri-apps/api/core": path.resolve(
__dirname,
"src/testing/announcementDemoTauriCore.ts",
),
}
: {}),
},
},