Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ TENANT_PACKAGE_DIR=../examples/fintech
# closes it. A deployment on a paid Intelligence plan never shows it. A fork running OpenBot for its
# own organization can turn it off for everybody.
# OPENBOT_SELF_HOST_BANNER=false
# The signed-in app asks each person once, after their fifth message, to star OpenBot on GitHub, and
# Settings → General links to it. A fork running OpenBot for its own organization can turn both off.
# OPENBOT_STAR_PROMPT=false
# What this deployment calls itself, when more than one shares an Intelligence project. A copy of a
# deployment made for development uses the same project key, and threads are listed per Bot with
# nothing to say which deployment a conversation came from. The name goes into every thread id this
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,15 @@ every deployment:

### Conversations

- **OpenBot asks once to be starred on GitHub.** After your fifth message, a card above the message
box asks you to star OpenBot on GitHub, with Not now beside the link. Either answer is saved to
your account, so it is never asked again on any device, and it waits until you have answered the
notifications offer, so the two never show together. Settings → General keeps the link in a new
OpenBot section. Set `OPENBOT_STAR_PROMPT=false` to turn both off for everybody, which suits a
deployment running OpenBot for its own organization. With product telemetry on, OpenBot counts
how many people were shown the card, opened the link or dismissed it, through three new events
(`star_prompt_shown`, `star_prompt_clicked`, `star_prompt_dismissed`); see
[TELEMETRY.md](docs/TELEMETRY.md).
- **A Bot no longer does an app change twice after you answer an approval.** When a built-in Bot
asked for several app changes at once and one of them waited for your Allow, the others that
needed no approval still went ahead, but the Bot was told they had not run. It then did them
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,7 @@ Settings worth knowing:
| `TENANT_PACKAGE_DIR` | Directory containing tenant YAML. Defaults to `../examples/fintech`. |
| `DEPLOYMENT_ID` | Names this deployment when two share one Intelligence project. |
| `OPENBOT_SELF_HOST_BANNER` | Set `false` to hide the bar offering CopilotKit's help self-hosting OpenBot. It never shows on a paid Intelligence plan. |
| `OPENBOT_STAR_PROMPT` | Set `false` to stop asking people to star OpenBot on GitHub, and drop the link from Settings. |

Full reference: [docs/configuration.md](docs/configuration.md).

Expand Down
33 changes: 32 additions & 1 deletion app/src/components/channels/conversation-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,14 @@ import {
type QueuedMessage,
reduceQueue,
} from "@/components/channels/composer";
import { NotifyPrompt } from "@/components/notifications/notify-prompt";
import {
NotifyPrompt,
notifyPromptAnswered,
} from "@/components/notifications/notify-prompt";
import { StarPrompt } from "@/components/star-prompt/star-prompt";
import { attachmentUrl } from "@/lib/channels/attachments";
import { useNotificationPermission } from "@/lib/notifications/permission";
import { notePromptSent } from "@/lib/star-prompt";
import { newId } from "../../lib/new-id";

export function ConversationView({
Expand Down Expand Up @@ -194,6 +200,7 @@ export function ConversationView({
* for the same fact.
*/
const [running, setRunning] = useState(false);
const notificationPermission = useNotificationPermission();
const inFlight = pending || running;

/**
Expand Down Expand Up @@ -321,6 +328,14 @@ export function ConversationView({
setRunning(true);
try {
await onSubmit(draft);
/*
* Once `onSubmit` has resolved, which in a conversation is when the turn it started is over
* (a stopped turn included), so the card can appear as the answer lands. A send that throws,
* whose words go back in the box, is not counted. A first message that opens a new
* conversation resolves once the conversation is made, so it counts even if its run then
* fails, which is fine for a nudge counted to five.
*/
notePromptSent();
} finally {
setRunning(false);
}
Expand Down Expand Up @@ -506,6 +521,22 @@ export function ConversationView({
{messages.some((message) => message.role === "user") ? (
<NotifyPrompt />
) : null}
{/*
* The ask to star OpenBot on GitHub, once this browser has earned it, and never beside the
* notifications offer: one card above the composer at a time, and the offer that is useful
* to this person goes first. Read on each render, which a finished turn always causes.
*
* It also waits while OpenBot Desktop has not yet said whether notifications are on
* (`checking`), or the card could draw, be counted as shown, and vanish when the answer is
* "not asked yet". And never in a conversation that cannot take a message: the brief
* "Picking a Bot" screen, or one whose Bot was deleted.
*/}
{disabled ||
notificationPermission === "checking" ||
(notificationPermission === "default" &&
!notifyPromptAnswered()) ? null : (
<StarPrompt />
)}
{/*
* THE DRAWER IS THE LAST THING ABOVE THE COMPOSER, AND THE STARTER TASKS STEP ASIDE FOR IT.
*
Expand Down
5 changes: 3 additions & 2 deletions app/src/components/notifications/notify-prompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ import {
/** Set once somebody answers the prompt either way, so it is offered once per browser. */
export const NOTIFY_PROMPT_KEY = "openbot-notify-prompt-answered";

function answered(): boolean {
/** Whether this browser has answered the offer, either way. The star prompt waits until it has. */
export function notifyPromptAnswered(): boolean {
try {
return localStorage.getItem(NOTIFY_PROMPT_KEY) !== null;
} catch {
Expand All @@ -42,7 +43,7 @@ function remember() {
*/
export function NotifyPrompt() {
const permission = useNotificationPermission();
const [dismissed, setDismissed] = useState(answered);
const [dismissed, setDismissed] = useState(notifyPromptAnswered);
if (permission !== "default" || dismissed) return null;

return (
Expand Down
69 changes: 69 additions & 0 deletions app/src/components/settings/star-on-github.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { IconStar } from "@tabler/icons-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { PageRows, PageSection } from "@/components/layout/page-shell";
import { buttonVariants } from "@/components/ui/button";
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from "@/components/ui/item";
import { deploymentCapabilitiesQueryOptions } from "@/lib/deployment/queries";
import {
answerStarPromptMutationOptions,
useUserPreferences,
} from "@/lib/settings/message-list";
import { OPENBOT_REPO_URL, recordStarPrompt } from "@/lib/star-prompt";

/**
* Settings → General's permanent way to star OpenBot on GitHub: the quiet half of the ask whose
* active half is the card above the composer (`components/star-prompt/star-prompt.tsx`).
*
* Here whenever the deployment allows the ask at all, and gone with it when the operator set
* OPENBOT_STAR_PROMPT=false. Unlike the card it stays after an answer, because somebody who said
* Not now may want it later and this is where they would look. Pressing it is counted under
* `settings`, and answers the card, so somebody who starred from here is not asked again.
*/
export function StarOnGitHubSection() {
const queryClient = useQueryClient();
const { data: capabilities } = useQuery(deploymentCapabilitiesQueryOptions());
const { data: preferences, userId } = useUserPreferences();
const answer = useMutation(
answerStarPromptMutationOptions(queryClient, userId),
);
if (capabilities?.starPrompt !== true) return null;

return (
<PageSection title="OpenBot">
<PageRows>
<Item size="sm">
<ItemMedia variant="icon">
<IconStar />
</ItemMedia>
<ItemContent>
<ItemTitle>Star OpenBot on GitHub</ItemTitle>
<ItemDescription>
OpenBot is open source. A star helps other people find it.
</ItemDescription>
</ItemContent>
<ItemActions>
<a
className={buttonVariants({ size: "sm", variant: "outline" })}
href={OPENBOT_REPO_URL}
onClick={() => {
void recordStarPrompt("star_prompt_clicked", "settings");
if (preferences?.starPromptAnswered !== true) answer.mutate();
}}
rel="noopener noreferrer"
target="_blank"
>
Star on GitHub
</a>
</ItemActions>
</Item>
</PageRows>
</PageSection>
);
}
120 changes: 120 additions & 0 deletions app/src/components/star-prompt/star-prompt.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { IconStar } from "@tabler/icons-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useEffect } from "react";
import { Button, buttonVariants } from "@/components/ui/button";
import {
Item,
ItemActions,
ItemContent,
ItemMedia,
ItemTitle,
} from "@/components/ui/item";
import { currentUserQueryOptions } from "@/lib/auth/queries";
import { deploymentCapabilitiesQueryOptions } from "@/lib/deployment/queries";
import {
answerStarPromptMutationOptions,
userPreferencesQueryOptions,
} from "@/lib/settings/message-list";
import {
firstStarPromptShow,
OPENBOT_REPO_URL,
recordStarPrompt,
STAR_PROMPT_TRIGGER,
type StarPromptEvent,
type StarPromptTrigger,
starPromptEarned,
} from "@/lib/star-prompt";
import { queryClient } from "@/query-client";

/**
* The ask to star OpenBot on GitHub, once this browser has reached the moment it asks at (see
* `lib/star-prompt.ts`). The same card as the notifications offer above it, so it reads as part of
* the composer rather than as an interruption, and never a dialog.
*
* Hidden until every answer is in, so it never flashes on and then off: the deployment has to allow
* it, and this person's preferences have to have loaded and say they have not answered it. Either
* button answers it, on every device they sign in from, and is counted with the trigger that asked.
*
* THE APP'S QUERY CLIENT, NAMED. This is drawn inside the conversation, whose components read no
* query context of their own (`voice-call-widget.tsx` names it the same way), so it does not depend
* on a provider being above it.
*/
export function StarPrompt({
trigger = STAR_PROMPT_TRIGGER,
}: {
trigger?: StarPromptTrigger;
}) {
const { data: capabilities } = useQuery(
deploymentCapabilitiesQueryOptions(),
queryClient,
);
const userId = useQuery(currentUserQueryOptions(), queryClient).data?.id;
const { data: preferences } = useQuery(
userPreferencesQueryOptions(userId),
queryClient,
);
const answer = useMutation(
answerStarPromptMutationOptions(queryClient, userId),
queryClient,
);
const shown =
capabilities?.starPrompt === true &&
preferences !== undefined &&
preferences.starPromptAnswered !== true &&
starPromptEarned(trigger);

// Once per browser and trigger; the server keys the count on the person, so this is people.
useEffect(() => {
if (shown && firstStarPromptShow(trigger))
void recordStarPrompt("star_prompt_shown", trigger);
}, [shown, trigger]);

if (!shown) return null;

const respond = (kind: StarPromptEvent) => {
void recordStarPrompt(kind, trigger);
answer.mutate();
};

return (
<Item
// The composer's own surface, as the notifications offer does.
className="mb-2 bg-card"
data-star-prompt=""
size="sm"
variant="outline"
>
<ItemMedia variant="icon">
<IconStar />
</ItemMedia>
<ItemContent>
<ItemTitle className="text-pretty">
Enjoying OpenBot? Star it on GitHub so more people find it.
</ItemTitle>
</ItemContent>
<ItemActions>
<Button
onClick={() => respond("star_prompt_dismissed")}
size="sm"
variant="ghost"
>
Not now
</Button>
{/*
* A link, not a button that opens one: it goes somewhere, and a person can see where before
* they go. A new tab in a browser; OpenBot Desktop opens it in the system browser, where
* they are signed in to GitHub (`desktop/src-tauri/src/outside-links.js`).
*/}
<a
className={buttonVariants({ size: "sm", variant: "outline" })}
href={OPENBOT_REPO_URL}
onClick={() => respond("star_prompt_clicked")}
rel="noopener noreferrer"
target="_blank"
>
Star on GitHub
</a>
</ItemActions>
</Item>
);
}
7 changes: 6 additions & 1 deletion app/src/lib/agents/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { CreateBotRequest } from "@/lib/api/generated/model";
import { idempotencyKey, ORGANIZATION_ID } from "@/lib/api/request";
import { channelKeys } from "@/lib/channels/queries";
import { client } from "@/lib/client";
import { noteBotCreated } from "@/lib/star-prompt";
import { type AgentProfile, agentApiPath, agentKeys } from "./queries";

/**
Expand Down Expand Up @@ -34,7 +35,11 @@ export function createAgentMutationOptions(queryClient: QueryClient) {
fallback: FALLBACK,
})
).bot,
onSuccess: () => invalidateAgents(queryClient),
onSuccess: () => {
// Here rather than in the New Bot sheet, so a Bot made from a conversation counts too.
noteBotCreated();
return invalidateAgents(queryClient);
},
});
}

Expand Down
7 changes: 7 additions & 0 deletions app/src/lib/deployment/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ export type DeploymentCapabilities = {
* for a deployment on a paid Intelligence plan.
*/
selfHostBanner: boolean;
/**
* Whether to ask people to star OpenBot on GitHub, and list the link in Settings. On unless the
* deployment set OPENBOT_STAR_PROMPT=false. Absent, from a server too old to say, reads as off.
*/
starPrompt?: boolean;
/**
* Whether this is somebody trying OpenBot on their own machine rather than an organization's
* deployment: single-user mode on the server, which only a local, unreachable install can be in.
Expand Down Expand Up @@ -76,6 +81,7 @@ export function deploymentCapabilitiesQueryOptions() {
).json()) as {
generativeUi?: boolean;
selfHostBanner?: boolean;
starPrompt?: boolean;
localEvaluation?: boolean;
edition?: string;
transcription?: boolean;
Expand All @@ -85,6 +91,7 @@ export function deploymentCapabilitiesQueryOptions() {
return {
generativeUi: body.generativeUi === true,
selfHostBanner: body.selfHostBanner === true,
starPrompt: body.starPrompt === true,
localEvaluation: body.localEvaluation === true,
edition: body.edition === "personal" ? "personal" : "team",
transcription: body.transcription === true,
Expand Down
Loading
Loading